基于本文回答
0
评论

Doris 是如何通过向量化从底层 CPU 寄存器和缓存维度提升单机查询吞吐的?

Apache Doris 自 1.1 版本全面实现向量化执行引擎(Vectorized Execution Engine)以来,其单机查询吞吐实现了数倍甚至数十倍的提升。

这一提升的核心在于:从传统的“行存+火山模型(Volcano Model)”转变为“列存+批处理+向量化模型”,从而在底层极致地利用了现代 CPU 的寄存器、高速缓存(Cache)以及流水线架构。

以下是 Doris 如何在底层 CPU 寄存器和缓存维度进行极致优化的详细解析:


一、 寄存器维度:SIMD 指令集与并行计算

在传统的火山模型中,CPU 寄存器频繁地进行上下文切换和通用寄存器(如 RAX, RBX)的读写,每次只处理一行数据。Doris 向量化引擎通过 SIMD(Single Instruction, Multiple Data,单指令多数据) 充分释放了 CPU 寄存器的计算威力。

1. 宽寄存器的极致利用

现代 CPU 提供了宽寄存器(如 Intel 的 YMM 256位寄存器、ZMM 512位寄存器)。

  • 非向量化: 计算 a + b,CPU 寄存器一次只能加载一个 32 位整型进行加法。
  • Doris 向量化: 利用 AVX2(256位)或 AVX-512(512位)指令集,一个 ZMM 寄存器可以同时容纳 16 个 32 位整型。Doris 通过一条 _mm512_add_epi32 指令,在一个 CPU 周期内同时完成 16 个数据的加法运算。

2. SIMD 友好的数据布局

为了让 CPU 能够直接将数据加载到 SIMD 寄存器中,Doris 在内存中采用连续列式存储(基于 IColumn 结构)。

  • 数据在内存中是一段连续的 Buffer。
  • Doris 利用内存对齐(Alignment,如 32 字节或 64 字节对齐),使得 CPU 可以使用高效的对齐向量加载指令(如 _mm256_load_si256),直接将数据从内存刷入 SIMD 寄存器,避免了非对齐加载导致的性能损耗。

3. 算子与函数的 SIMD 化

Doris 重写了大量的聚合、过滤、投影和 Hash 表构建算子:

  • 过滤操作(Filter):WHERE age > 18 过滤中,Doris 使用 SIMD 比较指令生成一个 Mask(掩码),然后通过 _mm256_blendv_epi8 等指令直接筛选数据,避免了逐行 if-else 的判断。
  • 哈希表构建: 在 Join 和 Agg 算子中,Doris 采用向量化的 Hash 计算和冲突处理,成批计算 Hash 值并载入寄存器。

二、 缓存(Cache)维度:最大化缓存命中率

CPU 寄存器虽然极快,但容量极小。数据必须从内存逐级加载到 L1/L2/L3 Cache。Doris 通过精细控制内存布局和访问模式,极大减少了 Cache Miss(缓存缺失)。

1. 消除数据缓存缺失(D-Cache Miss)

  • 空间局部性(Spatial Locality):
    • 在行存中,读取一个字段会把整行(包含很多无关字段)加载到 Cache Line(通常为 64 字节),造成极大的缓存污染。
    • Doris 向量化引擎以 Block(块) 为单位处理数据,每个 Block 包含若干列,每列在内存中是连续的数组。当 CPU 读取 column_a[0] 时,硬件预取器(Hardware Prefetcher)会自动将相邻的 column_a[1...15](假设是 4 字节整型)一次性加载到同一个 Cache Line 中,实现几乎 100% 的 D-Cache 命中率。
  • 时间局部性(Temporal Locality):
    • Doris 采用 Batch 模式(默认 4096 行)。这个大小经过精心设计:既能保证 SIMD 寄存器装满,又保证了一个 Batch 的列数据可以完整放入 L1/L2 Cache。在对这批数据进行多步连续操作时(如先 FilterMultiply),数据直接在 L1/L2 中循环使用,无需写回主存。

2. 消除指令缓存缺失(I-Cache Miss)

  • 在传统的火山模型中,每个算子(如 Project, Filter)都通过虚函数(next())进行链式调用。虚函数的动态绑定会导致大量的间接分支跳转,导致 CPU 无法提前预取指令,从而频繁发生 I-Cache Miss。
  • Doris 向量化引擎消除了运行时的虚函数调用。其核心循环变成了非常简单的 C++ 紧凑循环(Tight Loop),例如:
    cpp
    for (size_t i = 0; i < size; ++i) {
        dest[i] = src1[i] + src2[i];
    }
    这种简单的循环体代码量极小,可以完全放入 L1 I-Cache 中,CPU 可以以极高的速率不断解码和执行这些指令。

三、 CPU 流水线与分支预测优化

现代 CPU 是高度流水线化的,分支预测失败(Branch Misprediction)会导致流水线排空,带来 15-20 个 CPU 周期的延迟惩罚。

1. 分支消除(Branchless Programming)

Doris 在向量化代码中大量使用了无分支编程技术。
例如,在处理 NULL 值时,传统做法是:

cpp
if (!null_map[i]) {
    sum += data[i];
}

Doris 向量化则利用算术运算或位运算消除 if

cpp
sum += data[i] * (!null_map[i]); 
// 或者利用 SIMD mask 遮罩直接进行矢量计算

没有了 if 分支,CPU 的分支预测器(BPU)就不会犯错,流水线得以全速运转。

2. 循环展开(Loop Unrolling)

Doris 在底层的向量化算子中,大量使用了模板和手动循环展开(Loop Unrolling,如每次循环处理 4 个或 8 个元素)。这减少了循环计数器的自增和条件跳转次数,进一步提高了指令级并行度(ILP)。


四、 内存对齐与自定义分配器(Jemalloc/Chunk)

为了配合寄存器和缓存的高效工作,Doris 在内存分配上也做了底层重构:

  1. Arena Allocator(区域分配器): Doris 在向量化执行期间使用自定义的 Arena 内存池。在构建 Hash 表或进行临时计算时,内存分配是连续的,几乎没有碎片,这保证了物理内存的连续性,极大地有利于 Cache 预取。
  2. 严格的内存对齐: 所有向量化列(Column)的底层数组均使用对齐的分配器(如 64 字节对齐),确保每一个数据块的起始地址都是 Cache Line 的整数倍,避免跨 Cache Line 访问导致的额外开销。

总结:Doris 向量化的吞吐提升公式

Doris 通过向量化在底层实现的吞吐提升,可以总结为以下公式:

查询吞吐=每次指令处理的数据量(SIMD)指令执行周期(I-Cache/D-Cache+分支预测失败)\text{查询吞吐} \uparrow = \frac{\text{每次指令处理的数据量} \uparrow (\text{SIMD})}{\text{指令执行周期} \downarrow (\text{I-Cache/D-Cache} \uparrow + \text{分支预测失败} \downarrow)}

通过连续内存布局SIMD 指令重构消除虚函数无分支计算,Doris 将单机 CPU 的计算潜力压榨到了极致,从而在不增加硬件成本的前提下,实现了单机查询吞吐的跃升。

右滑查看面试常问