← DuckDB 技术文档

DUCKDB INTERNALS · CHAPTER 4

04 · DataChunk 与向量化执行

从 DataChunk、Vector、SelectionVector 和 UnifiedVectorFormat 出发,理解 DuckDB 如何用批处理摊薄解释开销并改善数据局部性。

本章目录

为什么既不是一次一行,也不是一次一整列

传统 Volcano 模型让父算子反复调用子算子的 next(),每次返回一行。接口清晰,但函数调用、分支判断和表达式分派会落到每一行上。另一端的算子级代码生成能把整条查询融合成机器码,但编译成本和系统复杂度更高。

DuckDB 采用向量化执行:算子一次处理一个固定上限的数据批次。批次足够大,可以摊薄虚函数和调度开销,并让紧凑列数据更适合 CPU Cache 与 SIMD;又足够小,不必物化完整中间结果。核心容器是 DataChunk,其中每一列是一个 Vector,所有列共享相同的逻辑行数。

在当前阅读的 DuckDB 1.4 系列源码中,默认标准向量大小 STANDARD_VECTOR_SIZE 为 2048 行。这是默认批大小,不代表每个 Chunk 永远有 2048 行:最后一批、经过过滤的结果或某些算子输出都可能更小。

DataChunk:执行引擎的数据交换单位

可以把一个 Chunk 想成一个小型列式表:

DataChunk (size = 4)
  column 0, BIGINT : [101, 102, 103, 104]
  column 1, DOUBLE : [8.2, 7.5, NULL, 9.1]
  column 2, VARCHAR: ["A", "B", "A", "C"]

Pipeline 中的 Source 产生 Chunk,中间 Operator 接收并转换 Chunk,Sink 消费 Chunk 并更新全局或局部状态。因为接口统一,扫描、Filter、Projection、Join Probe、Aggregate Sink 等算子可以在同一个执行框架中组合。

Chunk 的生命周期通常很短。算子不应默认输入向量在下一次调用后仍然有效;若状态要跨 Chunk 保存数据,需要明确持有或复制相应缓冲区。字符串、List、Struct 等嵌套类型还涉及子向量和额外 Buffer,生命周期错误比基本数值类型更隐蔽。

Vector 不只有“平铺数组”一种形式

DuckDB 会根据数据特征使用不同 Vector 表示,避免无意义的复制和展开:

  • Flat Vector:普通连续值数组;
  • Constant Vector:整个批次共享一个值,例如常量表达式;
  • Dictionary Vector:通过 SelectionVector 间接引用另一个向量,常用于过滤后的零拷贝切片;
  • Sequence Vector:用起点与步长表示序列;
  • 另外还有针对编码数据或复合类型的表示。

假设 Filter 保留第 [0, 3, 4] 行,它不一定立即复制三行数据,而可以让结果向量携带选择索引,继续引用原数据。好处是减少内存搬运;代价是下游算子不能假设输入一定连续。

UnifiedVectorFormat 的作用

如果每个函数都为 Flat、Constant、Dictionary 等表示编写一套循环,代码会迅速膨胀。ToUnifiedFormat 将不同表示统一暴露为三部分:

data      → 底层值缓冲区
sel[i]    → 逻辑第 i 行映射到 data 的哪个位置
validity  → 该位置是否为 NULL

一个向量化函数的典型循环因此类似:

for (idx_t i = 0; i < count; i++) {
    auto row = format.sel->get_index(i);
    if (!format.validity.RowIsValid(row)) {
        // propagate or handle NULL
        continue;
    }
    result[i] = operation(input[row]);
}

这不是实际完整代码,而是理解接口的最小模型。性能敏感路径仍会针对常量、无 NULL 或特定物理类型走更快的分支。

NULL、逻辑类型与物理类型

NULL 通常不作为特殊值塞进数据数组,而由 ValidityMask 单独表示。这样数值数组保持紧凑,向量循环也能先走“全有效”的 fast path,只有存在 NULL 时才检查位图。

LogicalType 描述 SQL 语义,如 DECIMAL、DATE、VARCHAR;PhysicalType 决定内存中使用何种底层表示。多个逻辑类型可能共享同一种物理表示,但比较、Cast 和输出格式仍必须遵循各自语义。编写执行函数时只看 C++ 底层类型而忽略 LogicalType,容易在 Decimal 精度、时间单位或枚举类型上出错。

过滤和投影如何流过一个 Chunk

SELECT price * quantity AS amount
FROM lineitem
WHERE quantity >= 10;

可以把一次批处理理解为:Scan 生成 pricequantity 两个 Vector;Filter 对 quantity 批量比较并生成 SelectionVector;Projection 只对保留位置批量执行乘法;结果 Chunk 继续推给 Sink 或 Result Collector。整个过程中不需要为每一行构造通用 Row 对象。

真正的优化点通常是:减少输入列宽、让 Filter 尽早缩小 selection、避免不必要的 Flatten/Copy、为常量与无 NULL 数据提供 fast path,以及让输出 Buffer 可以复用。

源码阅读入口

  • src/include/duckdb/common/types/data_chunk.hpp:Chunk 的列集合、容量与生命周期操作。
  • src/include/duckdb/common/types/vector.hpp:Vector 与 UnifiedVectorFormat。
  • src/include/duckdb/common/types/selection_vector.hpp:逻辑行到物理位置的映射。
  • src/include/duckdb/common/types/validity_mask.hpp:NULL 位图。
  • src/execution/expression_executor/:表达式的批量执行。
  • src/include/duckdb/common/vector_operations/:常用向量算子入口。

面试时容易被追问

向量化为什么比逐行执行快? 它把函数调用和类型分派摊到一批数据上,提高数据局部性,为紧凑循环、SIMD 和特化 fast path 创造条件;并不是“用了 Vector 类”本身就会自动变快。

Dictionary Vector 为什么既能提速又可能变慢? 它避免复制,但引入间接寻址。若下游多次随机访问,或 selection 很不连续,Flatten 成连续数据可能反而更合适。

为什么默认批大小不是越大越好? 更大的批次能继续摊薄调用开销,却增加工作集、临时内存和缓存压力,也会降低调度与中断响应粒度。批大小是在多种开销之间折中。

延伸阅读