DuckDB 中的并行分组聚合

DuckDB 中的并行分组聚合

分组聚合是核心数据分析命令。它对于大规模数据分析(“OLAP”)尤为重要,因为它可用于计算大型表的统计摘要。DuckDB 包含高度优化的并行聚合功能,可实现快速且可扩展的摘要。



前言

GROUP BY更改结果集基数 - 而不是返回相同数量的输入(如正常),返回与数据中组一样多的行数。考虑这个(非常熟悉的)示例查询:SELECTGROUP BY

SELECT
    l_returnflag,
    l_linestatus,
    sum(l_extendedprice),
    avg(l_quantity)
FROM
    lineitem
GROUP BY
    l_returnflag,
    l_linestatus;

GROUP BY后面跟有两个列名,l_returnflag和l_linestatus。这些是用于计算组的列,生成的表将包含数据中出现的同一列的所有组合。我们将GROUP BY子句中的列称为“分组列”,将其中所有值的组合称为“组”。SELECT子句包含四个(而不是五个)表达式:对分组列的引用,以及两个聚合:l_extendedprice上的sum和l_quantity上的avg。我们将这些称为“集合体”。如果执行,此查询的结果如下所示:
在这里插入图片描述
通常,SQL只允许GROUP BY子句中提到的列直接作为SELECT表达式的一部分,所有其他列都需要服从其中一个聚合函数,如sum、avg等。根据您使用的SQL系统,还有更多的聚合函数。

查询处理引擎应该如何计算这样的聚合?涉及到许多设计决策,我们将在下面讨论这些决策,特别是DuckDB做出的决策。计算分组结果时的主要问题是,分组可以以任何顺序出现在输入表中。如果输入已经在分组列上排序,那么计算聚合将是微不足道的,因为我们可以将分组列的当前值与以前的值进行比较。如果发生更改,则下一个组开始,并且需要计算新的聚合结果。由于排序的情况很容易,计算分组聚合的一种简单方法是首先对分组列上的输入表进行排序,然后使用琐碎的方法。但不幸的是,尽管我们尽了最大努力,对输入进行排序仍然是一项计算成本高昂的操作。通常,排序的计算复杂度为O(nlogn),其中n是排序的行数。

用于聚合的哈希表

更好的方法是使用哈希表。哈希表是计算中的一种基本数据结构,使我们能够找到计算复杂度为O(1)的条目。关于哈希表如何工作的全面讨论远远超出了本文的范围。下面,我们将重点介绍与聚合计算相关的一个非常基本的描述和注意事项。
在这里插入图片描述
为了将n行添加到哈希表中,我们看到了O(n)的复杂性,在排序方面比O(nlogn)好得多,尤其是当n达到数十亿时。上图说明了复杂性是如何随着表大小的增加而发展的。另一个很大的优势是,我们不必首先制作输入的排序副本,它将和输入一样大。相反,哈希表的条目最多与组的数量一样多,组的数量可能(通常)大大少于输入行。因此,整个过程是这样的:扫描输入表,并对每一行相应地更新哈希表。一旦输入耗尽,我们扫描哈希表,直接向上游运算符或查询结果提供行。

碰撞处理

那么,它就是哈希表!我们在输入上构建一个哈希表,其中组作为键,聚合作为条目。然后,对于每个输入行,我们计算组值的哈希,在哈希表中找到条目,并使用该行的值创建或更新聚合状态?不幸的是,它并没有那么简单:分组列的值不同的两行可能会导致指向同一哈希表条目的哈希,这将导致不正确的结果。

有两种主要的方法来解决这个问题:“链式”或“线性探测”。使用链接,我们不会直接将聚合值保存在哈希表中,而是保留组值和聚合的列表。如果分组值指向一个具有空列表的哈希表条目,则只需添加新组和聚合即可。如果分组值指向现有列表,我们将检查每个列表条目的分组值是否匹配。如果是,我们将更新该组的聚合。如果没有,我们将创建一个新的列表条目。在线性探测中,没有这样的列表,但在找到现有条目时,我们将比较分组值,如果它们匹配,我们将更新条目。如果它们不匹配,我们在哈希表中向下移动一个条目,然后重试。当找到匹配的组条目或找到空哈希表条目时,此过程结束。虽然理论上是等效的,但由于缓存的局部性,计算机硬件体系结构将支持线性探测。因为线性探测线性地遍历哈希表条目,所以下一个条目很可能在CPU缓存中,因此访问速度更快。在现代硬件架构上,链接通常会导致随机访问和更差的性能。因此,我们对聚合哈希表采用了线性探测。

如果存在太多冲突,即对同一哈希表条目进行哈希处理的组太多,则链接和线性探测的理论查找性能都将从O(1)wrt哈希表大小降低到O(n)wrt。这个问题的一个常见解决方案是,一旦“填充率”超过某个阈值,就调整哈希表的大小,例如,Java的HashMap默认为75%。这一点尤其重要,因为在开始聚合之前,我们不知道结果中的组数量。我们也不假设知道输入表中的行数。因此,我们从一个相当小的哈希表开始,并在填充率超过阈值时调整其大小。哈希表的基本结构如下图所示,该表有四个槽0-4。表中已经有三个组,组键分别为12、5和2。每组具有43等的合计值(例如,来自SUM)。
**加粗样式**
在调整大小后,调整部分填充的哈希表的大小是一个巨大的挑战,所有的组都在错误的位置,我们将不得不移动所有内容,这将非常昂贵。

在这里插入图片描述
为了有效地支持调整大小,我们实现了一个由两部分组成的聚合哈希表,该表由一个单独分配的指针数组组成,指针数组指向包含每个组的分组值和聚合状态的有效负载块。指针不是实际指针而是符号指针,它们指的是块ID和所述块内的行偏移量。如上图所示,哈希表条目分为两个有效负载块。在调整大小时,我们丢弃指针数组,并分配一个更大的数组。然后,我们再次读取所有有效负载块,对组值进行散列,并将指向它们的指针重新插入到新的指针数组中。因此,组数据保持不变,这大大降低了调整哈希表大小的成本。这可以在下图中看到,我们将指针数组的大小增加了一倍,但有效负载块保持不变。

在这里插入图片描述
在这里插入图片描述
简单的由两部分组成的哈希表设计需要在调整大小时对所有组值进行重新哈希,这可能非常昂贵,尤其是对于字符串值。为了加快速度,我们还将组值的原始哈希写入每个组的有效负载块。然后,在调整大小的过程中,我们不必重新散列组,只需从有效负载块中读取它们,计算指针数组中的新偏移量,然后插入其中。
在这里插入图片描述
由两部分组成的哈希表在查找条目时有一个很大的缺点:指针数组和有效负载块中的组条目之间没有排序。因此,跟随指针在内存层次结构中创建随机访问。这将导致计算中不必要的停顿。为了缓解这个问题,我们扩展了指针数组的内存布局,除了指向有效负载值的指针之外,还包括组哈希中的一些(1或2)字节。这样,线性探测可以首先将指针数组中的哈希位与当前组哈希进行比较,并决定是否值得遵循有效负载指针。对于指针链中的每个组,这种情况都可能继续。只有当散列位匹配时,我们才必须实际跟随指针并比较实际的组。这种优化大大减少了指向有效载荷块的指针必须遵循的次数,从而减少了对存储器的随机访问量,这与总体性能直接相关。它还有一个很好的副作用,那就是大大减少了全组比较,这也可能很昂贵,例如在包含字符串的组上聚合时。
在这里插入图片描述
这里的另一个(较小的)优化涉及指针数组项的宽度。对于条目较少的小哈希表,我们不需要很多比特来编码有效负载块偏移指针。DuckDB同时支持4字节和8字节的指针数组项。

对于大多数聚合查询,绝大多数查询处理时间都花在查找哈希表条目上,这就是为什么值得花时间优化它们的原因。如果你很好奇,这一切的代码都在DuckDB repo中,aggregate_hashtable.cpp。当我们知道列统计信息中只有几个不同的组时,还有另一个优化,即完美的哈希聚合,但这是针对另一篇文章的。但我们还没有结束。

并行聚合

虽然我们现在有一个聚合哈希表设计,它应该可以很好地用于分组聚合,但我们仍然没有考虑到DuckDB自动并行所有查询以使用多个硬件线程(“CPU”)的事实。并行性如何与哈希表协同工作?总的来说,不幸的是,答案是:“糟糕”。哈希表是一种复杂的结构,不能很好地处理并行修改。例如,假设一个线程希望调整哈希表的大小,而另一个线程则希望向其中添加一些新的组数据。或者,我们应该如何处理多个线程同时为同一条目插入新组?可以使用锁来确保一次只有一个线程在使用表,但这在很大程度上会使查询的并行化失败。已经有很多关于并发友好哈希表的研究,但简短的总结是,这仍然是一个悬而未决的问题。

可以让每个线程从下游操作符读取数据,并构建单独的本地哈希表,然后从单个线程将这些表合并在一起。如果像这篇文章顶部的例子中那样的小组很少,这会很好地工作。如果组很少,单个线程可以合并多个线程本地哈希表,而不会产生瓶颈。然而,完全有可能存在与输入行一样多的组,因为当有人在一列上进行分组时,这种情况往往会发生,该列将是主键的候选列,例如observation_number、timestamp等。因此,需要的是并行哈希表的并行合并。我们采用了Leis等人的方法:每个线程基于组哈希的基数划分来构建不是一个而是多个分区的哈希表。
在这里插入图片描述
这里的关键观察结果是,如果两个组具有不同的哈希值,那么它们不可能是相同的。由于此属性,只要所有线程使用相同的分区方案,就可以使用哈希值创建组的完全独立的分区,而不需要线程之间的任何通信(请参见上图中的阶段1)。

在构建完所有本地哈希表后,我们将各个分区分配给每个工作线程,并将该分区内的哈希表合并在一起(第2阶段)。因为分区是使用哈希的基数分区方案创建的,所以所有工作线程都可以在各自的分区内独立合并哈希表。结果是正确的,因为每个组都进入一个分区,并且只进入那个分区。

一个有趣的细节是,我们永远不需要构建一个包含所有组的最终(可能是巨大的)哈希表,因为基数组分区确保每个组都本地化到一个分区。

对于并行分区哈希表策略,还有两个额外的优化:1)只有当单个线程的聚合哈希表超过固定的条目限制(当前设置为10000行)时,我们才会开始分区。这是因为使用分区哈希表不是免费的。对于添加的每一行,我们都必须弄清楚它应该进入哪个分区,并且我们必须在最后将所有内容重新合并在一起。出于这个原因,在并行化的好处超过成本之前,我们不会开始分区。由于分区决策对每个线程都是独立的,因此很可能只有一些线程开始分区。如果是这种情况,我们将需要在开始合并之前对尚未进行分区的线程的哈希表进行分区。然而,这是一个完全线程的本地操作,不会干扰并行性。2) 一旦哈希表的指针数组超过某个阈值,我们将停止向其添加值。然后,每个线程都会构建多组潜在的分区哈希表。这是因为我们不希望指针数组变得任意大。虽然这可能会在多个哈希表中为同一组创建重复条目,但这并没有问题,因为我们稍后会将它们全部合并。这种优化在具有许多不同组,但组值以某种方式聚集在输入中的数据集上效果特别好。例如,在按日期排序的数据集中按天分组时。

有些聚合不能使用并行和分区哈希表方法。虽然并行化总和是微不足道的,因为总体结果的总和只是单个结果的总和,但这对于DuckDB也支持的中值等计算来说是相当不可能的。同样由于这个原因,DuckDB也支持approx_quantile,它是可并行的。

实验

把所有这些放在一起,现在是时候进行一些性能实验了。我们将把如上所述的DuckDB的聚合运算符与各种Python数据争论库中的相同运算符进行比较。其他的竞争者是熊猫,波拉斯和阿罗。之所以选择这些,是因为它们都可以在Pandas DataFrames上执行聚合运算符,而无需像DuckDB一样先转换成其他存储格式。

对于我们的基准测试,我们生成了一个合成数据集,其中包含两个整数列上预定义数量的组和一些要聚合的随机整数数据。在实验之前对整个数据集进行混洗,以防止利用合成生成的数据的聚类性质。对于每组,我们计算两个聚合,即数据列的总和和一个简单计数。此聚合的SQL版本将是SELECT g1,g2,sum(d),count(*)FROM dft GROUP BY g1,g2LIMIT 1;。在下面的实验中,我们改变了数据集的大小和其中的组的数量。这应该很好地显示了聚合的缩放行为。

因为我们对测量结果集物化时间不感兴趣,这对数百万组来说意义重大,所以我们使用只检索第一行的运算符进行聚合。这根本不会改变聚合的复杂性,因为它甚至需要在生成第一个结果行之前收集所有数据,因为最后一个输入数据行中可能有数据会更改第一个结果的结果。当然,这在实践中是相当不现实的,但它应该只很好地隔离聚合运算符的行为,因为对三列的head(1)操作应该相当便宜,并且执行时间恒定。

在这里插入图片描述

我们测量完成每次聚合所需的挂钟时间。为了说明微小的变化,我们将每次测量重复三次,并报告所需的中间时间。所有实验都在2021款MacBook Pro上运行,该机型配备了十核M1 Max处理器和64GB RAM。我们的数据生成基准脚本可在线获取,我们邀请感兴趣的读者在他们的机器上重新运行实验。
在这里插入图片描述
现在让我们讨论一些结果。我们首先在一百万到一亿之间改变表中的行数。我们对1000的固定(小)组计数和当组的数量等于行的数量时重复实验。结果绘制为日志-日志图,我们可以看到DuckDB如何始终优于其他系统,其中单线程Panda最慢,Polars和Arrow通常相似。
在这里插入图片描述
在下一个实验中,我们将行数固定在100M(我们实验过的最大大小),并显示增加组大小时的完整行为。我们可以再次看到,当增加组大小时,DuckDB是如何始终如一地表现出良好的扩展行为的,因为它可以有效地并行化聚合的所有阶段,如上所述。如果您对我们如何生成这些绘图感兴趣,也可以使用绘图脚本。

总结

主要使用聚合的数据分析管道将其绝大多数执行时间花在聚合哈希表中,这就是为什么值得花费大量人力来优化它们的原因。我们对未来的工作有一些想法,例如,我们希望将比较排序键的工作扩展到比较聚合哈希表中的组。我们还希望添加基于对创建的哈希表的动态观察来动态选择线程使用的分区数量的功能,例如,如果分区不平衡,我们可以使用更多的位来这样做。未来的另一大工作领域是使我们的聚合哈希表能够使用核心外操作,即单个哈希表不再适合内存,这在合并时尤其有问题。当然,总有机会对聚合运算符进行微调,我们正在不断改进DuckDB的聚合运算符。

如果你想从事这种将被成千上万人使用的尖端数据工程,可以考虑为DuckDB捐款,或者加入我们阿姆斯特丹的DuckDB实验室!

  • 31
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值