DuckDB 向量化执行引擎
优化器交出物理计划,执行引擎接手。这是全部层次里离硬件最近的一层:向量化的批量求值、按连接形态自动选择的算子家族、把数据切给八个线程的流水线调度。本篇用一条真实的哈希连接贯穿,看 DuckDB 怎么把"批"和"并行"两件事做进同一个进程。
第 04 篇结尾说过,DuckDB 只把代价模型留给连接排序等三处;那么剩下的成千上万次决策靠什么?靠执行层的工程直觉。这一层没有"智能",只有对硬件的忠诚:缓存行、SIMD 寄存器、内存带宽、调度延迟。所有花哨的数据库特性,最终都要落成"一次搬多少值、在哪个核上搬、按什么顺序搬"这三个问题的答案。
批:2048 的由来
执行引擎里所有数据都以 DataChunk 为单位流动,一个 DataChunk 是若干列的定长切片,默认 2048 行。这个数字不是拍脑袋:2048 个 int64 正好 16 KB,可以舒适地驻留在 L1/L2 缓存里;算子在一个 chunk 上完成全部工作,循环体是紧凑的同类型数组操作,编译器能自动向量化;2048 还是 2 的幂,压缩元数据按它对齐。它可以在编译期配置(vector_size.hpp 里定义为 DEFAULT_STANDARD_VECTOR_SIZE),但改了就要接受所有对齐假设的连锁反应。
比"2048"更精细的设计是 Vector 的六种形态(src/include/duckdb/common/enums/vector_type.hpp):普通平面向量(FLAT)、FSST 压缩字符串向量、常量向量(整个 chunk 一个值)、字典向量(一个选择向量叠在另一个向量上,连接和投影的常见产物)、序列向量(只存起点和步长,range(50000000) 生成的连续整数在扫描时就是这种形态)、以及 VARIANT 类型的 shredded 向量。算子拿到一个 Vector 时先看形态再决定怎么算——常量向量上的过滤可以整批跳过,字典向量上的表达式只需对字典求值一遍。
这套形态系统有一个第 06 篇会呼应的漂亮后果:压缩数据可以不减速查询。作者在 TPC-H Q1 的 EXPLAIN 输出里看到,字符串列进入聚合时带着 __internal_compress_string_utinyint 这样的内部函数——分组直接在压缩表示上进行,字典向量让"字符串相等比较"退化成"小整数相等比较"。存储层的压缩选择(第 06 篇)和执行层的向量形态在这里闭环。
表达式求值本身按类型分发:src/execution/expression_executor/ 下,比较、合取、CASE、函数调用、引用、常量各有独立的向量化实现文件,每种实现都可以针对自己的语义做优化——合取(AND)短路是按向量逐行判定,比较走特化的 SIMD 友好循环。这继承了 VectorWise 的"每个算子多份实现、按运行时环境选择"的传统。
算子:一个按数据形态自适应的家族
物理算子(src/execution/operator/)是执行引擎的劳动力,作者从代码地图里数出七十余个物理算子类。它们不是静态装配的流水线工人,而是按计划期和运行期的信息动态选择:
连接算子四种形态。 嵌套循环连接(任一侧基数小于 nested_loop_join_threshold,默认 5)、归并连接(两侧已按连接键有序且基数小于 merge_join_threshold,默认 1000)、哈希连接(默认主力)、以及不等值连接专用的 IEJoin(Khayyat 等人在 ICDE 2015 提出的算法,DuckDB 是少数内置它的系统)。ASOF 连接还有自己的阈值(64)。这些阈值全部暴露在 duckdb_settings() 里,可调。
聚合算子三种形态。 普通哈希聚合、完美哈希聚合(分组键空间已知且稀疏可控时,用最小值加位宽直接寻址,连哈希函数都省掉——第 02 篇例子里 l_returnflag 只有 3 个值,走的就是这条路)、分区聚合(基数太大时按哈希分区,每分区独立聚合再归并)。
哈希连接的并行构造。 以 lineitem JOIN orders 为例:构建侧流水线先跑,每个线程扫描 orders 的一个分片,先建线程本地的哈希表(HashJoinLocalSinkState),Combine 阶段再把本地表合并进全局表。探测侧流水线等全局表就绪后启动,八个线程动态领取 lineitem 的 morsel 逐批探测。本地建表避免了并发写一张大表的锁竞争,合并成本则被 radix 分区摊薄。
自适应与溢出。 哈希表超过内存预算时,radix 分区哈希表(radix_partitioned_hashtable.cpp)会把一部分分区溢出到磁盘临时目录,执行继续;连接执行期间统计到的过滤条件会变成自适应过滤器回填给扫描侧(adaptive_filter.cpp),减少无效探测。聚合状态超限同理(aggregate_state_spilling.cpp)——第 06 篇有 100MB 内存限制下跑 280MB 数据的实测。
并行:流水线与 morsel
并行模型可以概括成三句话。第一,计划被切成流水线。 物理计划以源算子为起点、汇算子为终点切成若干条 Pipeline,组合成 MetaPipeline 和依赖图——哈希连接的"建表"与"探测"天然是两条有依赖的流水线,聚合的"部分聚合"与"最终聚合"也是。第二,线程领取 morsel 而非静态分片。 一条流水线的源(通常是扫描)把数据切成比行组更细的 morsel,任务调度器(TaskScheduler)让空闲线程动态领取;快线程多领、慢线程少领,负载自均衡,不受数据偏斜影响。这就是 Leis 等人"Morsel-Driven Parallelism"论文的工程化。第三,批索引保序。 当查询要求保持输入顺序(preserve_insertion_order 默认为 true),每个 morsel 带批次索引,乱序完成的批次要等前面批就位才能交付——作者实测 SF1 的 22 条 TPC-H 查询,CPU 时间 1,604 ms 对墙钟 381 ms,约 4.2 倍加速。至于"关掉保序会更快"的流行说法,作者的实测是在这个基准上并不成立(第 09 篇:425 ms 对 392 ms)——它放开的是调度自由度,收益全看负载有没有顺序依赖。
窗口函数是并行模型的一个特例,值得单独说。窗口算子的语义依赖分区内的顺序,DuckDB 的实现(merge_sort_tree.cpp)用段树聚合(Segment Tree Aggregation,同样是 Leis 等人的论文)在排序后的数据上增量计算窗口值,既避免了对每个窗口帧的重复扫描,又保持了可并行性。row_number() OVER () 这类无 PARTITION 的窗口更会被优化器直接改写成行号虚拟列(第 04 篇的 ROW_NUMBER_REWRITER 一步)。
实测:加速比与它的边界
作者在本机(8 核)实测的三个数字,勾勒出并行模型的能力边界:
纯聚合近线性。 SF1 上 SELECT count(*) FROM (SELECT l_orderkey, sum(l_quantity) FROM lineitem GROUP BY l_orderkey):墙钟 33.9 ms,CPU 178 ms,约 5 倍加速。部分聚合天然可分,这类查询是并行的天堂。
带归并的略打折。 TPC-H 全套 22 条:墙钟 381 ms,CPU 1,604 ms,约 4.2 倍。涉及排序、哈希连接归并的查询,最终合并阶段是串行的,加速比随之回落。
短查询看规划。 第 02 篇的阶段账本显示 100MB 数据上的聚合查询规划占四成——并行只压缩执行的那六成。查询越小,规划开销越显眼,预备语句的价值越大。
冷静的部分
执行层有三个常被误读的地方。
"向量化"不等于"SIMD"。 向量化的核心收益是消掉每行的解释开销、把缓存局部性做到循环里;编译器能否把某个循环变成 SIMD 指令,取决于数据形态和代码写法,DuckDB 里有相当多手写的向量化循环并不产出 SIMD 代码,但依然比一次一行快一个数量级。宣传口径上的"SIMD 加速"要打折看。
并行不是免费的。 每个 morsel 的调度、每个批索引的同步、每次 Combine 的合并都有成本。小查询(几十毫秒以下)上,8 线程常常跑不过精心调度的单线程——这也是 threads 设置允许调到 1 的原因。作者的实测里 SF0.1 的 Q6(1 ms 级)加速收益就非常有限。
算子选择会错。 阈值是启发式:基数估错了,该用哈希连接的地方可能选了嵌套循环。执行层的补偿手段(自适应过滤器、延迟物化)能减轻后果,但不能根治——根治在优化器,那是第 04 篇的战场。
到这里,引擎的"算"讲完了。下一篇讲"存":这些向量中的数据,脱胎自磁盘上怎样的字节组织——行组、段、压缩家族,以及内存不够时发生什么。