优秀的编程知识分享平台

网站首页 > 技术文章 正文

最快的表排序 - 重新设计过 DuckDB 的排序

nanyue 2025-01-17 12:29:39 技术文章 3 ℃

更多互联网精彩资讯、工作效率提升关注【飞鱼在浪屿】(日更新)


TLDR: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升序排列。NULL被视为c_birth_year列中的最小值。因此,整行被重新排序,而不仅仅是ORDER BY子句中的列。不在ORDER BY子句中的列我们称为“有效负载列”。因此,payload 列c_customer_sk也必须重新排序。

使用任何排序实现(例如C++的std::sort. 虽然std::sort在算法上非常出色,但它仍然是一种单线程方法,无法有效地按多列进行排序,因为函数调用开销会增加排序时间。下面我们将讨论为什么会这样。

为了在排序表时获得良好的性能,需要自定义排序实现。当然,这不是第一次研究关系排序,因此深入研究文献以寻求指导。

2006 年,著名的 Goetz Graefe 写了一篇关于在数据库系统中实现排序的调查。在这次调查中,他收集了许多社区熟知的排序技巧。如果你即将开始对表格进行排序,这是一个很好的指南。

排序的成本主要由比较值和移动数据决定。任何使这两个操作更便宜的东西都会对总运行时间产生重大影响。

当有多个ORDER BY子句时,有两种明显的方法来实现比较器:

  1. 循环遍历子句:比较列,直到找到不相等的列,或者直到比较了所有列。这已经相当复杂了,因为这需要一个循环,其中每一行数据都有一个 if/else。如果用列式存储,这个比较器必须在列之间跳转,导致内存中的随机访问。
  2. 按第一个子句对数据进行完全排序,然后按第二个子句排序,但仅在第一个子句相等的地方排序,依此类推。当存在许多重复值时,这种方法尤其低效,因为它需要多次传递数据。

二进制字符串比较

二进制字符串比较技术通过简化比较器来提高排序性能。它将子句中的所有列编码ORDER BY为一个二进制序列,当比较时,memcmp将产生正确的整体排序顺序。对数据进行编码不是免费的,但由于在排序过程中大量使用比较器,因此它有所值。让我们再看一下示例的 3 行:

c_birth_country

c_birth_year

NETHERLANDS

1991

NETHERLANDS

1992

GERMANY

1924

在little-endian硬件上,表示这些值的字节在内存中看起来像这样,假设年份为 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值,则必须使用附加字节对这些值进行编码(示例中未显示)。

有了这个二进制字符串,现在可以通过只比较二进制字符串表示来同时比较两列。这可以用一个做memcmp在C ++!编译器将为单个函数调用发出高效的汇编,甚至自动生成SIMD 指令。

该技术解决了上述问题之一,即使用复杂比较器时的函数调用开销。


基数排序

现在我们有了一个便宜的比较器,我们必须选择我们的排序算法。每个计算机科学专业的学生都会学习基于比较的排序算法,例如Quicksort和Merge sort,它们的时间复杂度为O (n log n),其中n是要排序的记录数。

但是,也有基于分布的排序算法,它们的时间复杂度通常为O (nk),其中k是排序键的宽度。由于k是常数,而 log n不是,这类排序算法在较大的n 下可扩展得更好。

一种这样的算法是基数排序。该算法通过使用Counting sort多次计算数据分布来对数据进行排序,直到所有数字都被计数完毕。

对排序键列进行编码以便我们有一个便宜的比较器,然后选择不比较记录的排序算法,这听起来可能有悖常理。但是,编码对于基数排序是必要的:memcmp如果逐字节基数排序,产生正确顺序的二进制字符串将产生正确的顺序。


两相并行排序

DuckDB 使用Morsel-Driven Parallelism,这是一个用于并行查询执行的框架。对于排序运算符,这意味着多个线程从表中并行收集大致相等数量的数据。

使用这种并行性进行排序,首先让每个线程使用我们的基数排序对它收集的数据进行排序。在第一个排序阶段之后,每个线程都有一个或多个排序的数据块,这些数据块必须组合成最终的排序结果。 合并排序是此任务的首选算法。实现归并排序主要有两种方式:K-way归并和级联归并。

K-way merge 将 K 个列表一次合并为一个排序列表,传统上用于外部排序(排序比内存中容纳的数据更多),因为它最大限度地减少了 I/O。级联合并一次合并两个已排序的数据列表,直到只剩下一个已排序的列表,并且用于内存中排序,因为它比 K-way 合并更有效。我们的目标是拥有一个具有高内存性能的实现,当我们超过可用内存的限制时,它会优雅地降级。因此,选择级联合并。

在级联归并排序中,我们一次合并两块已排序的数据,直到只剩下一个已排序的块。自然,我们希望使用所有可用线程来计算合并。如果我们有比线程更多的排序块,我们可以分配每个线程来合并两个块。但是,随着块合并,我们将没有足够的块来保持所有线程忙碌。当最后两个块合并时,这尤其慢:一个线程必须处理所有数据。

为了完全并行化这个阶段,实现了 Oded Green 等人的Merge Path。合并路径预先计算,其中所述有序列表将合并而相交,下面的图像(从所述纸张取)所示。

使用Binary Search可以有效地计算合并路径上的交叉点。如果知道交点在哪里,就可以并行地独立合并已排序数据的分区。这使在整个合并阶段有效地使用所有可用线程。有关改进归并排序的另一个技巧,请参阅附录


https://duckdb.org/2021/08/27/external-sorting.html#predication


列还是行?

除了比较之外,排序的另一个主要成本是移动数据。DuckDB 有一个向量化的执行引擎。数据以柱状布局存储,一次分批(称为块)处理。这种布局非常适合分析查询处理,因为块适合 CPU 缓存,并且它为编译器生成 SIMD 指令提供了很多机会。然而,当表格被排序时,整行被乱序排列,而不是列。

在排序时坚持柱状布局:对关键列进行排序,然后将有效负载列一一重新排序。但是,重新排序将导致内存中每列的随机访问模式。如果有很多有效载荷列,这会很慢。将列转换为行将使重新排序行变得更加容易。这种转换当然不是免费的:需要将列复制到行,然后在排序后再次从行返回到列。

因为想要支持外部排序,所以必须将数据存储在可以卸载到磁盘的缓冲区管理块中。因为无论如何都必须将输入数据复制到这些块中,所以将行转换为列实际上是免费的。

有一些运算符本质上是基于行的,例如连接和聚合。DuckDB 为这些操作符提供了统一的内部行布局,将它也用于排序操作符。到目前为止,这种布局仅在内存中使用。在下一节中,解释如何让它也能在磁盘上工作。应该注意到,如果主内存无法容纳排序数据,我们只会将排序数据写入磁盘。


外部排序

缓冲区管理器可以将块从内存卸载到磁盘。这不是我们在排序实现中主动做的事情,而是缓冲区管理器决定做的事情,否则内存会填满。它使用最近最少使用的队列来决定要写入哪些块。有关如何正确使用此队列的更多信息,请参见附录。

https://duckdb.org/2021/08/27/external-sorting.html#zigzag

当我们需要一个块时,我们“固定”它,如果它尚未加载,它会从磁盘读取它。访问磁盘比访问内存慢得多,因此尽量减少读写次数至关重要。

将数据卸载到磁盘对于固定大小的列(如整数)很容易,但对于可变大小的列(如字符串)则更困难。行布局使用固定大小的行,不能适应任意大小的字符串。因此,字符串由一个指针表示,该指针指向实际字符串数据所在的单独内存块,即所谓的“字符串堆”。

我们已经改变了堆,以在缓冲区管理的块中逐行存储字符串:

每行都有一个额外的 8 字节字段pointer,它指向堆中该行的开头。这在内存表示中是无用的,但下面解释为什么它对磁盘表示有用。

如果数据适合内存,则堆块保持固定状态,并且在排序时仅对固定大小的行进行重新排序。如果数据在内存中放不下,则需要将块卸载到磁盘,并且在排序时堆也会重新排序。当堆块被卸载到磁盘时,指向它的指针将失效。当我们将块加载回内存时,指针将发生变化。

这就是行式布局发挥作用的地方。8 字节pointer字段被8 字节字段覆盖offset,表示可以在该行的堆块字符串中的何处找到。这种技术被称为“指针混合”。当我们混合指针时,行布局和堆块看起来像这样:

指向后续字符串值的指针也被 8 字节的相对偏移量覆盖,表示该字符串与堆中行开头的偏移量(因此每个stringA都有一个偏移量0:它是行中的第一个字符串)。在排序期间使用行内的相对偏移量而不是绝对偏移量非常有用,因为这些相对偏移量保持不变,并且在复制行时不需要更新。

当需要扫描块以读取排序结果时,我们“解开”指针,使它们再次指向字符串。

通过这种双重用途的行式表示,可以轻松地复制堆中固定大小的行和可变大小的行。除了缓冲区管理器加载/卸载块之外,内存中排序和外部排序之间的唯一区别是我们混合/取消混合指向堆块的指针,并在合并排序期间从堆块中复制数据。

所有这些都减少了需要将块移入和移出内存时的开销,当接近可用内存的限制时,这将导致性能下降。


与其他系统的比较

现在已经涵盖了排序实现中使用的大部分技术,我们想知道我们如何与其他系统进行比较。DuckDB 通常用于交互式数据分析,因此经常与dplyr 等工具进行比较。

在这种情况下,人们通常在笔记本电脑或 PC 上运行,因此我们将在 2020 款 MacBook Pro 上运行这些实验。这种笔记本电脑有一个苹果M1的CPU,这是ARM为基础的。M1 处理器有 8 个内核:4 个高性能(Firestorm)内核和 4 个节能(Icestorm)内核。Firestorm 内核具有非常非常快的单线程性能,因此这应该在一定程度上平衡单线程和多线程排序实现之间的竞争环境。MacBook 拥有 16GB 内存,是笔记本电脑中速度最快的 SSD 之一。

我们将与以下系统进行比较:

    1. ClickHouse, version 21.7.5
    2. HyPer, version 2021.2.1.12564
    3. Pandas, version 1.3.2
    4. SQLite, version 3.36.0

ClickHouse 和 HyPer 在比较中,因为它们是强调性能的分析 SQL 引擎。Pandas 和 SQLite 包含在我们的比较中,因为它们可用于在 Python 中执行关系操作,如 DuckDB。Pandas 完全在内存中运行,而 SQLite 是一个更传统的基于磁盘的系统。这个系统列表应该为我们提供了单/多线程和内存/外部排序的良好组合。

ClickHouse 是使用本指南为 M1 构建的。我们已
max_bytes_before_external_sort按照此建议将内存限制设置为 12GB 和10GB 。

HyPer 是Tableau 的数据引擎,由慕尼黑大学的数据库组创建。它不能(还)在基于 ARM 的处理器(如 M1)上本地运行。我们将使用Rosetta 2,MacOS 的 x86 模拟器来运行它。仿真会导致一些开销,因此我们在附录中包含了在 x86 机器上的实验。

数据库系统中的基准排序并不简单。理想情况下,我们只想测量对数据进行排序所需的时间,而不是读取输入数据和显示输出所需的时间。并非每个系统都有一个分析器来精确测量排序操作符的时间,所以这不是一个选项。

为了进行公平比较,将测量对数据进行排序并将结果写入临时表的查询的端到端时间,即:

CREATE TEMPORARY TABLE output AS
SELECT ...
FROM ...
ORDER BY ...;

这个问题没有完美的解决方案,但这应该给我们一个很好的比较,因为这个查询的端到端时间应该以排序为主。对于 Pandas,使用sort_valueswithinplace=False来模拟这个查询。

在 ClickHouse 中,临时表只能存在于内存中,这对于我们的核外实验来说是有问题的。因此,我们将使用常规TABLE,但是我们还需要选择一个表引擎。大多数表引擎应用压缩或创建索引,我们不想测量。因此,我们选择了最简单的磁盘引擎,即File,格式为Native。

我们为 ClickHouse 的输入表选择的表引擎是MergeTree with ORDER BY tuple(). 我们选择这个是因为我们遇到了File(Native)输入表的奇怪行为,查询SELECT * FROM ... ORDER BY和SELECT col1 FROM ... ORDER BY. 大概是因为无论选择了多少列,都对表中的所有列进行了排序。

为了衡量稳定的端到端查询时间,我们将每个查询运行 5 次并报告运行时间的中位数。系统之间在读/写表方面存在一些差异。例如,Pandas 不能从/向磁盘读/写,因此输入和输出数据帧都将在内存中。DuckDB 不会将输出表写入磁盘,除非没有足够的空间将其保存在内存中,因此也可能具有优势。但是,排序在总运行时间中占主导地位,因此这些差异并没有那么大的影响。


随机整数

我们将从一个简单的例子开始。我们已经生成了前 1 亿个整数并将它们打乱,我们想知道系统对它们的排序能力。这个实验更像是一个微基准测试,在现实世界中几乎没有意义。

对于我们的第一个实验,我们将研究系统如何随着行数而扩展。从带有整数的初始表中,我们又制作了 9 个表,每个表有 10M、20M、……、90M 个整数。

作为传统的基于磁盘的数据库系统,SQLite 总是选择外部排序策略。即使它们适合主内存,它也会将中间排序的块写入磁盘,因此速度要慢得多。其他系统的性能大致相同,对于 100M 整数,DuckDB 和 ClickHouse 以大约 4 秒的速度进行对比。因为 SQLite 慢得多,所以我们不会将它包含在我们的下一组实验 (TPC-DS) 中。

DuckDB 和 ClickHouse 都很好地利用了所有可用线程,并行单线程排序,然后是并行合并排序。我们不确定 HyPer 使用什么策略。在我们的下一个实验中,我们将放大多线程,看看 ClickHouse 和 DuckDB 随着线程数量的扩展(我们无法为 HyPer 设置线程数量)。

该图表明基数排序非常快。DuckDB 使用单个线程在不到 7 秒的时间内对 100M 整数进行排序,这比 ClickHouse 快得多。添加线程并不会像 DuckDB 那样提高性能,因为基数排序比合并排序快得多。两个系统在 4 个线程时的性能大致相同。

由于 CPU 架构的原因,超过 4 个线程,我们看不到性能有更多的提升。对于所有其他实验,我们都将 DuckDB 和 ClickHouse 设置为使用 4 个线程。

对于我们最后的随机整数实验,我们将看到输入的排序如何影响性能。这在使用 Quicksort 的系统中尤为重要,因为 Quicksort 在反向排序的数据上比在随机数据上的表现要差得多。

毫不奇怪,所有系统在排序数据上的表现都更好,有时会大幅提升。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

使用customerSF100 和 SF300,它们在每个比例因子下都适合内存。使用catalog_salesSF10 和 SF100 中的表,它们在 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 加载数据时,交换大小增长到令人印象深刻的约 40 GB:文件和数据帧都完全在内存中/同时交换,而不是流式传输到内存中。当文件被读取完时,这将下降到大约 20 GB 的内存/交换。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) 对它们进行排序。比较字符串比比较整数要困难得多,因为字符串可以具有可变大小,并且需要逐字节比较,而整数总是具有相同的比较。

正如预期的那样,按字符串排序比按整数排序更昂贵。除了 Pandas 之外的所有系统在按整数排序和按字符串排序之间只有很小的区别。这种差异可以通过字符串之间的昂贵比较器来解释。Pandas 使用NumPy的排序,它在C 中有效实现。但是,在对字符串进行排序时,它必须使用虚函数调用来比较 Python 字符串对象,这比C 中<整数之间的简单“ ”要慢。尽管如此,Pandas 在桌面上的表现还是不错的。customer

在下一个实验中,我们将看到有效载荷类型如何影响性能。 customer有 10 个整数列和 8 个字符串列。我们将每次选择所有整数列或所有字符串列并按 ( c_birth_year, c_birth_month, c_birth_day)排序。


正如预期的那样,重新排序字符串比重新排序整数需要更多的时间,但只是一点点。对于每个系统 HyPer 都是如此,考虑到字符串难以有效处理,这一点令人印象深刻。


结论

DuckDB 新的并行排序实现可以利用现代 SSD 的速度,有效地对超出内存容量的数据进行排序。当其他系统因为内存不足而崩溃,或者切换到速度更慢的外部排序策略时,DuckDB 的性能会随着超过内存限制而优雅地降低。

可以在此处找到用于运行实验的代码。

https://github.com/lnkuiper/experiments/tree/master/sorting


附录 A:预测

我们用来加速归并排序的另一种技术是预测。使用这种技术,我们将带有if/else分支的代码转换为没有分支的代码。现代 CPU 试图预测ifelse分支是否会被预测。如果这很难预测,它会减慢代码速度。看看下面带有分支的伪代码示例。

// continue until merged
while (l_ptr && r_ptr) {
  // check which side is smaller
  if (memcmp(l_ptr, r_ptr, entry) < 0) {
    // copy from left side and advance
    memcpy(result_ptr, l_ptr, entry);
    l_ptr += entry;
  } else {
    // copy from right side and advance
    memcpy(result_ptr, r_ptr, entry);
    r_ptr += entry;
  }
  // advance result
  result_ptr += entry;
}

我们通过推进指针将左右块的数据合并到一个结果块中,一次一个条目。通过使用比较布尔值作为 0 或 1,可以使此代码无分支,如下面的伪代码所示。

// continue until merged
while (l_ptr && r_ptr) {
  // store comparison result in a bool
  bool left_less = memcmp(l_ptr, r_ptr, entry) < 0;
  bool right_less = 1 - left_less;
  // copy from either side
  memcpy(result_ptr, l_ptr, left_less * entry);
  memcpy(result_ptr, r_ptr, right_less * entry);
  // advance either one
  l_ptr += left_less * entry;
  l_ptr += right_less * entry;
  // advance result
  result_ptr += entry;
}

当left_less为真时,它等于 1。这意味着right_less为假,因此等于 0。我们使用它entry从左侧复制字节,从右侧复制0 个字节,并相应地递增左右指针。

使用谓词代码,CPU 不必预测要执行哪些指令,这意味着指令缓存未命中会更少!

附录 B:锯齿形调整

减少 I/O 的一个简单技巧是通过成对的块在级联归并排序中以之字形进行合并。这如下图所示(虚线箭头表示块合并的顺序)。

通过锯齿形穿过块,我们通过合并在前一次迭代中合并的最后一个块来开始迭代。这些块可能仍在内存中,节省了一些宝贵的读/写操作。


附录 C:x86 实验

我们还在catalog_salesx86 CPU 架构的机器上运行了SF100 实验,以便与 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 不使用行布局,因此必须支付随机访问的成本,因为每列在排序后重新排序。

最近发表
标签列表