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。在对这批数据进行多步连续操作时(如先
Filter再Multiply),数据直接在 L1/L2 中循环使用,无需写回主存。
- Doris 采用 Batch 模式(默认 4096 行)。这个大小经过精心设计:既能保证 SIMD 寄存器装满,又保证了一个 Batch 的列数据可以完整放入 L1/L2 Cache。在对这批数据进行多步连续操作时(如先
2. 消除指令缓存缺失(I-Cache Miss)
- 在传统的火山模型中,每个算子(如 Project, Filter)都通过虚函数(
next())进行链式调用。虚函数的动态绑定会导致大量的间接分支跳转,导致 CPU 无法提前预取指令,从而频繁发生 I-Cache Miss。 - Doris 向量化引擎消除了运行时的虚函数调用。其核心循环变成了非常简单的 C++ 紧凑循环(Tight Loop),例如:这种简单的循环体代码量极小,可以完全放入 L1 I-Cache 中,CPU 可以以极高的速率不断解码和执行这些指令。cpp
for (size_t i = 0; i < size; ++i) { dest[i] = src1[i] + src2[i]; }
三、 CPU 流水线与分支预测优化
现代 CPU 是高度流水线化的,分支预测失败(Branch Misprediction)会导致流水线排空,带来 15-20 个 CPU 周期的延迟惩罚。
1. 分支消除(Branchless Programming)
Doris 在向量化代码中大量使用了无分支编程技术。
例如,在处理 NULL 值时,传统做法是:
if (!null_map[i]) {
sum += data[i];
}
Doris 向量化则利用算术运算或位运算消除 if:
sum += data[i] * (!null_map[i]);
// 或者利用 SIMD mask 遮罩直接进行矢量计算
没有了 if 分支,CPU 的分支预测器(BPU)就不会犯错,流水线得以全速运转。
2. 循环展开(Loop Unrolling)
Doris 在底层的向量化算子中,大量使用了模板和手动循环展开(Loop Unrolling,如每次循环处理 4 个或 8 个元素)。这减少了循环计数器的自增和条件跳转次数,进一步提高了指令级并行度(ILP)。
四、 内存对齐与自定义分配器(Jemalloc/Chunk)
为了配合寄存器和缓存的高效工作,Doris 在内存分配上也做了底层重构:
- Arena Allocator(区域分配器): Doris 在向量化执行期间使用自定义的
Arena内存池。在构建 Hash 表或进行临时计算时,内存分配是连续的,几乎没有碎片,这保证了物理内存的连续性,极大地有利于 Cache 预取。 - 严格的内存对齐: 所有向量化列(
Column)的底层数组均使用对齐的分配器(如 64 字节对齐),确保每一个数据块的起始地址都是 Cache Line 的整数倍,避免跨 Cache Line 访问导致的额外开销。
总结:Doris 向量化的吞吐提升公式
Doris 通过向量化在底层实现的吞吐提升,可以总结为以下公式:
通过连续内存布局、SIMD 指令重构、消除虚函数和无分支计算,Doris 将单机 CPU 的计算潜力压榨到了极致,从而在不增加硬件成本的前提下,实现了单机查询吞吐的跃升。