译文,原文链接:Fastest Table Sort in the West – Redesigning DuckDB’s Sort – DuckDB
数据库系统出于多种目的使用排序,最明显的是当用户在查询中添加 ORDER BY
子句时。排序还用于操作符内部,例如窗口函数。DuckDB 最近改进了其排序实现,现在能够并行排序,并且可以排序比内存中更多的数据。在这篇文章中,我们将看看 DuckDB 是如何排序的,以及这与其他数据管理系统相比如何。
关系数据排序
排序是计算机科学中研究得最透彻的问题之一,也是数据管理的一个重要方面。排序算法的研究往往集中在对大型数组或键/值对进行排序。虽然这很重要,但这并没有涵盖如何在数据库系统中实现排序。对表进行排序不仅仅是对大型整数数组进行排序!
考虑以下对 TPC-DS 表片段的示例查询:
SELECT c_customer_sk, c_birth_country, c_birth_year
FROM customer
ORDER BY c_birth_country DESC,
c_birth_year ASC NULLS LAST;
结果如下:
c_customer_sk | c_birth_country | c_birth_year |
---|---|---|
64760 | NETHERLANDS | 1991 |
75011 | NETHERLANDS | 1992 |
89949 | NETHERLANDS | 1992 |
90766 | NETHERLANDS | NULL |
42927 | GERMANY | 1924 |
换句话说:c_birth_country
按降序排列,当 c_birth_country
相等时,我们按 c_birth_year
升序排列。通过指定 NULLS LAST
,c_birth_year
列中的空值被视为最低值。因此,整个行被重新排序,不仅仅是 ORDER BY
子句中的列。不在 ORDER BY
子句中的列我们称之为“负载列”。因此,负载列 c_customer_sk
也必须重新排序。
使用任何排序实现来评估示例查询是很容易的,例如 C++ 的 std::sort
。尽管 std::sort
在算法上是优秀的,但它仍然是单线程的方法,无法高效地按多列排序,因为函数调用开销会很快主导排序时间。下面我们将讨论为什么。
为了在排序表时获得良好的性能,需要自定义排序实现。当然,我们并不是第一个实现关系排序的人,所以我们查阅了文献以寻求指导。
2006 年,著名的 Goetz Graefe 写了一篇关于在数据库系统中实现排序的综述。在这篇综述中,他收集了许多已知的排序技术。如果你正要开始为表实现排序,这是一个很好的指导方针。
排序的成本主要由比较值和移动数据主导。任何使这两个操作更便宜的东西都将对总运行时间产生重大影响。
当有多个 ORDER BY
子句时,实现比较器有两种明显的方法:
-
循环遍历子句:比较列,直到我们找到一个不相等的列,或者直到我们比较了所有列。这已经相当复杂了,因为这需要一个带有 if/else 的循环,用于每一行数据。如果我们的存储是列式的,这个比较器必须在列之间跳跃,导致内存中的随机访问。
-
完全按第一个子句对数据进行排序,然后按第二个子句排序,但仅在第一个子句相等的地方,依此类推。当有许多重复值时,这种方法特别低效,因为它需要多次遍历数据。
二进制字符串比较
二进制字符串比较技术通过简化比较器来提高排序性能。它将 ORDER BY
子句中的所有列编码成一个单一的二进制序列,当使用 memcmp
进行比较时,将产生正确的总体排序顺序。编码数据并非免费,但由于我们在排序过程中频繁使用比较器,因此这是值得的。让我们再看看示例中的 3 行:
c_birth_country | c_birth_year |
---|---|
NETHERLANDS | 1991 |
NETHERLANDS | 1992 |
GERMANY | 1924 |
在小端硬件上,这些值在内存中的字节表示如下,假设年份是 32 位整数表示:
c_birth_country
-- NETHERLANDS
01001110 01000101 01010100 01001000 01000101 01010010 01001100 01000001 01001110 01000100 01010011 00000000
-- GERMANY
01000111 01000101 01010010 01001101 01000001 01001110 01011001 00000000
c_birth_year
-- 1991
11000111 00000111 00000000 00000000
-- 1992
11001000 00000111 00000000 00000000
-- 1924
10000100 00000111 00000000 00000000
诀窍是将这些转换为编码排序顺序的二进制字符串:
-- NETHERLANDS | 1991
10110001 10111010 10101011 10110111 10111010 10101101 10110011 10111110 10110001 10111011 10101100 11111111
10000000 00000000 00000111 11000111
-- NETHERLANDS | 1992
10110001 10111010 10101011 10110111 10111010 10101101 10110011 10111110 10110001 10111011 10101100 11111111
10000000 00000000 00000111 11001000
-- GERMANY | 1924
10111000 10111010 10101101 10110010 10111110 10110001 10100110 11111111 11111111 11111111 11111111 11111111
10000000 00000000 00000111 10000100
二进制字符串是固定大小的,因为这使得在排序过程中移动它变得更加容易。
“GERMANY” 比 “NETHERLANDS” 短,因此它用 00000000
填充。c_birth_country
列中的所有位随后被反转,因为这一列是按降序排列的。如果字符串太长,我们编码它的前缀,并且只有在前缀相等时才查看整个字符串。
c_birth_year
中的字节被交换,因为我们需要大端表示来编码排序顺序。第一个位也被翻转,以保留正负整数之间的顺序。如果有 NULL
值,这些必须使用额外的字节进行编码(在示例中未显示)。
有了这个二进制字符串,我们现在可以通过比较二进制字符串表示来同时比较两列。这可以通过 C++ 中的单个 memcmp
来完成!编译器将为单个函数调用生成高效的汇编代码,甚至自动生成 SIMD 指令。
这种技术解决了上述提到的一个问题,即使用复杂比较器时的函数调用开销。
基数排序
现在我们有了一个廉价的比较器,我们需要选择我们的排序算法。每个计算机科学学生都学习过基于比较的排序算法,如快速排序和归并排序,它们的时间复杂度为 O (n log n),其中 n 是正在排序的记录数。
然而,还有基于分布的排序算法,它们通常具有 O (n k) 的时间复杂度,其中 k 是排序键的宽度。这一类排序算法随着 n 的增大而更好地扩展,因为 k 是常数,而 log n 不是。
这样一个算法是基数排序。这个算法通过使用计数排序计算数据分布,多次进行,直到所有数字都被计数。
听起来可能违反直觉,我们编码排序键列,以便我们有一个廉价的比较器,然后选择一个不比较记录的排序算法。然而,编码对于基数排序是必要的:通过 memcmp
产生正确顺序的二进制字符串,如果我们在字节-by-字节基数排序,也会产生正确的顺序。
两阶段并行排序
DuckDB 使用 Morsel-Driven Parallelism,这是一个并行查询执行框架。对于排序操作符,这意味着多个线程并行地从表中收集大致相等数量的数据。
我们通过首先让每个线程使用我们的基数排序对其收集的数据进行排序来并行化排序。在第一阶段排序之后,每个线程有一个或多个排序后的数据块,这些数据块必须合并成最终的排序结果。归并排序是完成这项任务的首选算法。归并排序有两种主要的实现方式:K-way merge 和 Cascade merge。
K-way merge 在一次传递中将 K 个列表合并为一个排序列表,传统上用于外部排序(排序比内存中更多的数据),因为它最小化了 I/O。Cascade merge 每次合并两个排序数据列表,直到只剩下一条排序列表,由于它比 K-way merge 更高效,因此用于内存排序。我们希望有一个实现,它在内存中有高性能,并且在超过可用内存限制时优雅地降级。因此,我们选择 cascade merge。
在 cascade merge sort 中,我们一次合并两个排序数据块,直到只剩下一条排序块。自然地,我们希望使用所有可用的线程来计算合并。如果我们有更多的排序块而不是线程,我们可以将每个线程分配给合并两个块。然而,随着块被合并,我们将没有足够的块来保持所有线程忙碌。当最后两个块被合并时,这尤其慢:一个线程必须处理所有数据。
为了完全并行化这个阶段,我们实现了 Oded Green 等人的 Merge Path。Merge Path 预先计算了在合并时排序列表将相交的位置,如下图所示(取自论文)。
https://duckdb.org/images/blog/sorting/merge_path.png
可以使用二分查找高效地计算合并路径上的交点。如果我们知道交点在哪里,我们可以独立并行地合并排序数据的分区。这允许我们有效地使用所有可用线程进行整个合并阶段。有关改进归并排序的另一个技巧,请参阅附录。
列还是行?
除了比较之外,排序的另一个大成本是移动数据。DuckDB 有一个矢量化执行引擎。数据以列式布局存储,一次处理一批(称为块)数据。这种布局非常适合分析查询处理,因为块适合 CPU 缓存,并且为编译器提供了生成 SIMD 指令的很多机会。然而,当表被排序时,整个行被重新排序,而不是列。
我们可以坚持在排序时使用列式布局:对键列进行排序,然后逐个重新排序负载列。然而,重新排序将导致每个列的随机访问模式。如果有许多负载列,这将很慢。将列转换为行将使重新排序行变得更加容易。当然,这种转换并非免费:需要将列复制到行,然后在排序后再次从行复制到列。
因为我们想支持外部排序,所以我们必须将数据存储在可以被缓冲区管理器卸载到磁盘的缓冲区管理块中。因为无论如何我们都要将输入数据复制到这些块中,所以将行转换为列实际上是免费的。
还有一些操作符本质上是基于行的,例如连接和聚合。DuckDB 为这些操作符有一个统一的内部行布局,我们决定也为排序操作符使用它。这种布局迄今为止只在内存中使用过。在下一节中,我们将解释如何使其在磁盘上工作。我们应该注意,我们只会将排序数据写入磁盘,如果主内存无法容纳它。
外部排序
缓冲区管理器可以将块从内存卸载到磁盘。这并不是我们在排序实现中主动做的事情,而是缓冲区管理器如果内存会填满,就会决定这样做。它使用最近最少使用队列来决定写入哪些块。关于如何正确使用这个队列,请参阅附录。
当我们需要一个块时,我们“固定”它,如果它还没有加载,就从磁盘读取它。访问磁盘比访问内存慢得多,因此至关重要的是我们要最小化读写次数。
将数据卸载到磁盘对于固定大小的列(如整数)很容易,但对于可变大小的列(如字符串)则更困难。我们的行布局使用固定大小的行,无法容纳任意大小的字符串。因此,字符串由一个指针表示,该指针指向一个单独的内存块,实际的字符串数据存储在那里,所谓的“字符串堆”。
我们已经改变了我们的堆,也在缓冲区管理块中以行-by-行的方式存储字符串:
https://duckdb.org/images/blog/sorting/heap.svg
每一行都有一个额外的 8 字节字段 pointer
,它指向该行在堆中的起始位置。在内存表示中这是无用的,但我们将看到为什么它对磁盘表示很有用。
如果数据适合内存,堆块保持固定,只有固定大小的行在排序时被重新排序。如果数据不适合内存,块需要被卸载到磁盘,堆也会在排序时被重新排序。当一个堆块被卸载到磁盘时,指向它的指针被无效化。当我们把块重新加载到内存时,指针会改变。
这里我们的行式布局就派上用场了。8 字节的 pointer
字段被覆盖为一个 8 字节的 offset
字段,表示这一行的字符串在堆块中的位置。这种技术称为“指针旋转”。当我们将指针旋转时,行布局和堆块看起来像这样:
https://duckdb.org/images/blog/sorting/heap_swizzled.svg
指向后续字符串值的指针也被覆盖为一个 8 字节的相对偏移量,表示这个字符串相对于该行在堆中的偏移量(因此每个 stringA
的偏移量为 0
:它是该行中的第一个字符串)。在行内使用相对偏移量而不是绝对偏移量在排序期间非常有用,因为这些相对偏移量保持不变,不需要在复制行时更新。
当需要扫描块以读取排序结果时,我们“取消旋转”指针,使它们再次指向字符串。
通过这种双重用途的行式表示,我们可以轻松地复制固定大小的行和堆中的可变大小行。除了让缓冲区管理器加载/卸载块之外,内存排序和外部排序之间的唯一区别是我们旋转/取消旋转指向堆块的指针,并在归并排序期间从堆块复制数据。
所有这些减少了当块需要在内存中移动时的开销,这将导致在接近可用内存限制时性能优雅地降级。
与其他系统的比较
现在我们已经涵盖了我们在排序实现中使用的大多数技术,我们想知道我们与其他系统相比如何。DuckDB 通常用于交互式数据分析,因此通常与 dplyr 等工具进行比较。
在这种设置中,人们通常在笔记本电脑或个人电脑上运行,因此我们将在 2020 年款 MacBook Pro 上运行这些实验。这款笔记本电脑配备了 Apple M1 CPU,这是基于 ARM 的。M1 处理器有 8 个核心:4 个高性能(Firestorm)核心和 4 个节能(Icestorm)核心。Firestorm 核心具有非常、非常快的单线程性能,这应该会在某种程度上平衡单线程和多线程排序实现之间的差距。MacBook 配备了 16GB 的内存和一款在笔记本电脑中找到的最快的 SSD 之一。
我们将与以下系统进行比较:
-
ClickHouse,版本 21.7.5
-
HyPer,版本 2021.2.1.12564
-
Pandas,版本 1.3.2
-
SQLite,版本 3.36.0
ClickHouse 和 HyPer 被包括在我们的比较中,因为它们是强调性能的分析型 SQL 引擎。Pandas 和 SQLite 被包括在内,因为它们可以在 Python 中执行关系操作,就像 DuckDB 一样。Pandas 完全在内存中运行,而 SQLite 是一个更传统的基于磁盘的系统。这个系统列表应该给我们一个很好的单线程/多线程和内存/外部排序的混合。
ClickHouse 使用这个指南为 M1 构建。我们将内存限制设置为 12GB,并将 max_bytes_before_external_sort
设置为 10GB,遵循这个建议。
HyPer 是 Tableau 的数据引擎,由慕尼黑大学的数据库小组创建。它尚未在 ARM 架构的处理器(如 M1)上本地运行。我们将使用 Rosetta 2,macOS 的 x86 模拟器来运行它。模拟会带来一些开销,因此我们在附录中包含了一个在 x86 机器上的实验。
在数据库系统中对排序进行基准测试并不简单。理想情况下,我们希望只测量排序数据所需的时间,而不是读取输入数据和显示输出所需的时间。并非每个系统都有一个分析器可以精确测量排序操作符的时间,所以这不是一个选项。
为了接近公平比较,我们将测量将排序数据并写入临时表的查询的端到端时间,即:
CREATE TEMPORARY TABLE output AS
SELECT ...
FROM ...
ORDER BY ...;
没有完美的解决方案,但这个应该给我们一个很好的比较,因为这个查询的端到端时间应该主要由排序主导。对于 Pandas,我们将使用 sort_values
并设置 inplace=False
来模拟这个查询。
在 ClickHouse 中,临时表只能存在于内存中,这对我们进行外部实验是不利的。因此,我们将使用一个普通的 TABLE
,但然后我们也需要选择一个表引擎。大多数表引擎应用压缩或创建索引,而我们不想测量这些。因此,我们选择了最简单的磁盘引擎,即 File,格式为 Native。
我们为 ClickHouse 的输入表选择的表引擎是 MergeTree,ORDER BY tuple()
。我们选择这个是因为我们在使用 File(Native)
输入表时遇到了奇怪的行为,其中 SELECT * FROM ... ORDER BY
和 SELECT col1 FROM ... ORDER BY
之间的查询运行时间没有差异。可能是因为表中的所有列都被排序,无论选择了多少列。
为了测量稳定的端到端查询时间,我们运行每个查询 5 次并报告中位运行时间。这些系统之间在读取/写入表方面存在一些差异。例如,Pandas 无法从/写入磁盘,因此输入和输出数据帧都将在内存中。DuckDB 只有在没有足够的空间将其保留在内存中时,才会将输出表写入磁盘,因此也可能具有优势。然而,排序占总运行时间的主导地位,因此这些差异并不那么重要。
随机整数
我们将从一个简单的例子开始。我们生成了前 1 亿个整数并将它们打乱,我们想知道这些系统能多快地对它们进行排序。这个实验更多的是一个微观基准测试,而不是其他任何东西,它在现实世界中的意义不大。
在我们的第一个实验中,我们将看看这些系统如何随着行数的增加而扩展。从包含整数的初始表中,我们又制作了 9 个更多表,每个表分别包含 10M、20M……90M 个整数。
Matplotlib v3.4.3, https://matplotlib.org/
作为一个传统的基于磁盘的数据库系统,SQLite 总是选择外部排序策略。即使它们适合主内存,它也会将中间排序块写入磁盘,因此它要慢得多。其他系统的性能大致相当,DuckDB 和 ClickHouse 紧随其后,对 1 亿个整数分别约为 3 秒和 4 秒。因为 SQLite 太慢了,我们不会在接下来的实验(TPC-DS)中包括它。
DuckDB 和 ClickHouse 都很好地利用了所有可用的线程,使用单线程排序,然后是并行归并排序。我们不确定 HyPer 使用了什么策略。在我们的下一个实验中,我们将专注于多线程,并看看 ClickHouse 和 DuckDB 如何随着线程数量的增加而扩展(我们无法为 HyPer 设置线程数量)。
Matplotlib v3.4.3, https://matplotlib.org/
这个图表表明基数排序非常快。DuckDB 使用单线程对 1 亿个整数进行排序,只需不到 5 秒,这比 ClickHouse 快得多。增加线程对 DuckDB 的性能提升不大,因为基数排序比归并排序快得多。两个系统最终在 4 个线程时达到大约相同的性能。
超过 4 个线程后,我们没有看到性能有太大提升,这是由于 CPU 架构的原因。对于所有其他实验,我们已经将 DuckDB 和 ClickHouse 设置为使用 4 个线程。
在我们的最后一个随机整数实验中,我们将看看输入数据的排序性如何影响性能。对于使用快速排序的系统来说,这一点尤其重要,因为快速排序在逆序数据上的性能比随机数据差得多。
不出所料,所有系统在排序数据上的性能都更好,有时差距很大。ClickHouse、Pandas 和 SQLite 可能有一些优化:例如,在目录中跟踪排序性,或在扫描输入时检查排序性。DuckDB 和 HyPer 在输入数据排序时性能只有很小的差异,并且没有这样的优化。对于 DuckDB,略微提高的性能可以解释为在排序期间更好的内存访问模式:当数据已经排序时,访问模式主要是顺序的。
另一个有趣的结果是,DuckDB 排序数据的速度比其他一些系统读取已经排序的数据还要快。
TPC-DS
在下一个比较中,我们在标准 TPC 决策支持基准(TPC-DS)的两个表上即兴进行了一个关系排序基准测试。TPC-DS 对排序实现来说是一个挑战,因为它有宽表(有许多列,不像 TPC-H 中的表),并且混合了固定大小和可变大小的类型。随着规模因子的增加,行数也会增加。这里使用的表是 catalog_sales
和 customer
。
catalog_sales
有 34 列,都是固定大小的类型(整数和双精度),随着规模因子的增加,行数也会增加。customer
有 18 列(10 个整数和 8 个字符串),随着规模因子的增加,行数也会增加。每个规模因子下的行数如下表所示。
SF | customer | catalog_sales |
---|---|---|
1 | 100,000 | 1,441,548 |
10 | 500,000 | 14,401,261 |
100 | 2,000,000 | 143,997,065 |
300 | 5,000,000 | 260,014,080 |
我们将使用 SF100 和 SF300 的 customer
,这些数据在每个规模因子下都能适应内存。我们将使用 SF10 和 SF100 的 catalog_sales
表,在 SF100 时数据不再适应内存。
数据是使用 DuckDB 的 TPC-DS 扩展生成的,然后以随机顺序导出为 CSV,以消除生成数据中可能存在的任何排序模式。
Catalog Sales(数值类型)
我们对 catalog_sales
表的第一个实验是选择 1 列,然后 2 列……直到所有 34 列,始终按 cs_quantity
和 cs_item_sk
排序。这个实验将告诉我们不同的系统如何重新排序负载列。
我们在 SF10 和 SF100 时看到了类似的趋势,但在 SF100 时,大约在 12 个负载列左右,数据不再适应内存,ClickHouse 和 HyPer 的性能大幅下降。ClickHouse 切换到外部排序策略,这比它的内存策略慢得多。因此,增加几个负载列会导致运行时间高出几个数量级。在 20 个负载列时,ClickHouse 出现了以下错误:
DB::Exception: Memory limit (for query) exceeded: would use 11.18 GiB (attempt to allocate chunk of 4204712 bytes), maximum: 11.18 GiB: (while reading column cs_list_price): (while reading from part ./store/523/5230c288-7ed5-45fa-9230-c2887ed595fa/all_73_108_2/ from mark 4778 with max_rows_to_read = 8192): While executing MergeTreeThread.
HyPer 也在性能下降之前出现了错误,错误信息如下:
ERROR: Cannot allocate 333982248 bytes of memory: The `global memory limit` limit of 12884901888 bytes was exceeded.
据我们所知,HyPer 使用 mmap
,它在内存和文件之间创建一个映射。这允许操作系统在内存和磁盘之间移动数据。虽然很有用,但它不能替代一个适当的外部排序,因为它会导致对磁盘的随机访问,这非常慢。
Pandas 在 SF100 上表现令人惊讶地好,尽管数据不再适应内存。Pandas 只能这样做是因为 macOS 动态增加了交换空间大小。大多数操作系统不会这样做,而是会完全无法加载数据。使用交换通常会显著减慢处理速度,但由于 SSD 非常快,因此没有明显的性能下降!
当 Pandas 加载数据时,交换空间大小增长到令人印象深刻的约 40GB:文件和数据帧同时完全在内存/交换中,而不是流式传输到内存中。当文件读取完成后,交换空间大小下降到约 20GB 的内存/交换。Pandas 能够在实验中走得相当远,直到出现以下错误:
UserWarning: resource_tracker: There appear to be 1 leaked semaphore objects to clean up at shutdown
DuckDB 在内存和外部排序方面都表现良好,没有明显的数据不再适应内存的点:运行时间快且可靠。
Customer(字符串和整数)
现在我们已经看到了系统如何处理大量固定大小类型的,是时候看看一些可变大小类型了!在我们的第一个 customer
表实验中,我们将选择所有列,并按 3 个整数列(c_birth_year
、c_birth_month
、c_birth_day
)或 2 个字符串列(c_first_name
、c_last_name
)进行排序。比较字符串比比较整数困难得多,因为字符串可以有不同的大小,并且需要逐字节比较,而整数总是有相同的比较。
正如预期的那样,按字符串排序比按整数排序更昂贵,除了 HyPer,它令人印象深刻。Pandas 在按整数和按字符串排序之间的差异略大于 ClickHouse 和 DuckDB。这种差异可以通过字符串之间昂贵的比较器来解释。Pandas 使用 NumPy 的排序,这在 C 中高效实现。然而,当它对字符串进行排序时,它必须使用虚拟函数调用来比较 Python 字符串对象,这比 C 中的简单“<”比较整数要慢。尽管如此,Pandas 在 customer
表上的表现仍然很好。
在我们的下一个实验中,我们将看看负载类型如何影响性能。customer
有 10 个整数列和 8 个字符串列。我们将选择所有整数列或所有字符串列,并始终按(c_birth_year
、c_birth_month
、c_birth_day
)进行排序。
正如预期的那样,重新排序字符串比重新排序整数要花费更多的时间。Pandas 在这里有一个优势,因为它已经将字符串存储在内存中,很可能只需要重新排序指向这些字符串的指针。数据库系统需要两次复制字符串:一次是读取输入表时,另一次是创建输出表时。在 DuckDB 中进行分析表明,在 SF300 时,实际排序时间不到一秒,大部分时间都花在了(反)序列化字符串上。
结论
DuckDB 的新并行排序实现可以高效地排序比内存中更多的数据,利用现代 SSD 的速度。当其他系统因为内存不足而崩溃,或者切换到慢得多的外部排序策略时,DuckDB 的性能会随着超过内存限制而优雅地降级。
用于运行实验的代码可以在这里找到。如果我们犯了任何错误,请让我们知道!
DuckDB 是一个免费且开源的数据库管理系统(MIT 许可)。它旨在成为分析领域的 SQLite,并提供一个快速高效的数据库系统,没有外部依赖。它不仅适用于 Python,还适用于 C/C++、R、Java 等。
在 Hacker News 上讨论这篇文章
阅读我们在 ICDE '23 上关于排序的论文
收听 Laurens 在 Disseminate 播客中的出现:
-
Spotify
-
Google
-
Apple
附录 A:预测
我们用来加快归并排序速度的另一种技术是预测。通过这种技术,我们将带有 if/else 分支的代码转换为没有分支的代码。现代 CPU 会尝试预测 if 或 else 分支将被执行。如果这很难预测,它可能会减慢代码的速度。看看下面带有分支的伪代码示例。
// 继续直到合并完成
while (l_ptr && r_ptr) {
// 检查哪一边更小
if (memcmp(l_ptr, r_ptr, entry) < 0) {
// 从左边复制并前进
memcpy(result_ptr, l_ptr, entry);
l_ptr += entry;
} else {
// 从右边复制并前进
memcpy(result_ptr, r_ptr, entry);
r_ptr += entry;
}
// 前进结果
result_ptr += entry;
}
我们正在将来自左边和右边的数据块合并到一个结果块中,一次一个条目,通过推进指针。通过使用比较布尔值作为 0 或 1,可以将这段代码转换为无分支代码,如下伪代码所示。
// 继续直到合并完成
while (l_ptr && r_ptr) {
// 将比较结果存储在布尔值中
bool left_less = memcmp(l_ptr, r_ptr, entry) < 0;
bool right_less = 1 - left_less;
// 从任一侧复制
memcpy(result_ptr, l_ptr, left_less * entry);
memcpy(result_ptr, r_ptr, right_less * entry);
// 前进任一侧
l_ptr += left_less * entry;
l_ptr += right_less * entry;
// 前进结果
result_ptr += entry;
}
当 left_less
为真时,它等于 1。这意味着 right_less
为假,因此等于 0。我们使用这个来从左边复制 entry
字节,从右边复制 0 字节,并相应地推进左边和右边的指针。
使用预测代码,CPU 不需要预测将执行哪些指令,这意味着将有更少的指令缓存未命中!
附录 B:之字形
减少 I/O 的一个简单技巧是在级联归并排序中之字形地穿过要合并的块对。这在下面的图像中说明(虚线箭头表示块合并的顺序)。
通过之字形穿过块,我们从上一次迭代中最后合并的块开始迭代。这些块很可能仍然在内存中,为我们节省了一些宝贵的读/写操作。
附录 C:x86 实验
我们还在一台具有 x86 CPU 架构的机器上运行了 SF100 的 catalog_sales
实验,以获得与 HyPer(没有 Rosetta 2 模拟)的更公平比较。这台机器有一个 Intel(R) Xeon(R) W-2145 CPU @ 3.70GHz,它有 8 个核心(最多 16 个虚拟线程),以及 128 GB 的 RAM,所以这次数据完全适应内存。我们已经将 DuckDB 和 ClickHouse 使用的线程数量设置为 8,因为我们看到超过 8 个线程后性能没有明显提升。
Pandas 在这台机器上的表现比在 MacBook 上差,因为它有一个单线程的实现,而这个 CPU 的单线程性能较低。再次,Pandas 因为错误而崩溃(这台机器不会动态增加交换空间):
numpy.core._exceptions.MemoryError: Unable to allocate 6.32 GiB for an array with shape (6, 141430723) and data type float64
DuckDB、HyPer 和 ClickHouse 都很好地利用了更多的可用线程,比在 MacBook 上快得多。
这个图表中一个有趣的是,DuckDB 和 HyPer 随着额外负载列的增加而非常相似地扩展。尽管 DuckDB 在排序方面更快,但重新排序负载似乎对两个系统来说成本都差不多。因此,HyPer 很可能也使用了一个行布局。
ClickHouse 随着额外负载列的增加而扩展得更差。ClickHouse 没有使用行布局,因此在排序后重新排序每个列时必须付出随机访问的成本。