核心思想:从 “一次一行” 到 “一次一批”

要理解向量化,首先要看它的对立面——传统的执行方式。

  1. 传统执行模式(火山模型 / 逐行处理)

    • 工作原理:查询计划由多个运算符组成(如 Scan, Filter, Aggregate)。每个运算符实现一个 next() 接口,每次调用都返回一行数据。
    • 示例:对于查询 SELECT sum(price) FROM orders WHERE category = 'Electronics'
      • Scan 运算符调用一次 next(),返回一行数据(如 (1, 'Electronics', 999))。
      • Filter 运算符拿到这行数据,检查 category 字段,如果匹配,则将这行数据传给下一个运算符 Aggregate
      • Aggregate 运算符拿到这行数据,取出 price,累加到 sum 中。
    • 问题:这是一个巨大的 CPU 效率黑洞
      • 虚函数调用开销:每个 next() 调用通常是一个虚函数调用,CPU 需要查找虚函数表,分支预测容易失败。
      • 指令流水线中断:CPU 的流水线无法被有效利用,因为每一步处理的数据量太小,而且控制逻辑(判断、跳转)远多于实际的计算(加法)。
      • 无法利用现代 CPU 的 SIMD 指令
  2. 向量化执行模式(列式处理)

    • 工作原理:每个运算符的 next() 接口不再返回一行,而是返回一个 数据块(Chunk),这个数据块包含多行数据(例如 8192 行),并且是以的形式组织的。
    • 示例:同样的查询 SELECT sum(price) FROM orders WHERE category = 'Electronics'
      • Scan 运算符调用一次 next(),返回一个块。这个块包含两列:category 列(是一个字符串数组)和 price 列(是一个整数数组)。
      • Filter 运算符拿到整个 category 列,它会对这个完整的数组进行操作,生成一个位图(Bitmap) 或选择向量。例如,它比较 category_array[i] == 'Electronics',如果为真,则在位图的第 i 位标记为 1。
      • Aggregate 运算符拿到整个 price 列和这个位图。它根据位图为 1 的位置,从 price 列中取出对应的值,然后对这些值进行批量求和

ClickHouse 向量化执行如何高效利用 CPU 算力

向量化执行的高性能秘诀在于它完美地契合了现代 CPU 的架构特点。

1. 减少解释开销 & 更好的分支预测

  • 原理:在传统模式下,处理 WHERE 条件时,每行数据都需要进行一次 if 判断。CPU 的分支预测器需要对成千上万次微小的、依赖于数据的判断进行预测,预测失败率很高。预测失败会导致流水线被清空,浪费十几个 CPU 周期。
  • ClickHouse 的做法:它一次性对整个列(数组)进行循环。这个循环是紧凑的、可预测的。循环内部几乎没有函数调用,只是简单的算术和比较。CPU 的分支预测器可以轻松预测循环的结束条件,从而保持流水线充满指令。

2. 触发 CPU 的 SIMD 指令

这是向量化执行的 “杀手级” 特性

  • 什么是 SIMD:单指令多数据。它允许一条 CPU 指令同时操作多个数据。例如,AVX2 指令集可以一次处理 8 个 32 位整数。
  • 传统模式的困境:由于每次只处理一行数据,编译器无法生成 SIMD 指令。
  • ClickHouse 的做法:因为数据是以数组形式存在的,编译器可以自动向量化循环,或者由 ClickHouse 开发者手动编写使用 SIMD 内在函数的代码。
    • 示例:对一个 price 数组求和。
      • 标量代码for (int i = 0; i < n; i++) sum += data[i];
      • SIMD 代码(概念性)
        __m256i sum_vec = _mm256_setzero_si256(); // 初始化一个256位宽的累加器为0
        for (int i = 0; i < n; i += 8) { // 每次步进8个整数
            __m256i chunk = _mm256_load_si256(&data[i]); // 一次性加载8个整数
            sum_vec = _mm256_add_epi32(sum_vec, chunk); // 一次性执行8个加法
        }
        // 最后将 sum_vec 中的8个部分和规约成一个总和
        
    • 效果:理论上,仅通过使用 SIMD,求和操作的速度就可以提升 8 倍。ClickHouse 在 memcpy, filter, hash table 查找、排序 等大量操作中都使用了 SIMD。

3. 提高 CPU 缓存利用率

  • 原理:CPU 的 L1/L2 缓存速度极快,但容量很小。当代码以线性的、可预测的方式访问连续的内存块时,缓存命中率最高。
  • ClickHouse 的做法:由于数据按列存储在数组中,当对一个列(如 price)进行计算时,CPU 加载到缓存中的都是相关的数值。没有无关的 categoryid 字段来"污染"缓存。
    • 对比行存储:处理 price 时,需要从内存中加载整行数据,而其中大部分字段(如 description)在当前操作中是无用的,这浪费了宝贵的缓存带宽和空间。

4. 编译器优化友好

  • 原理:对紧凑循环内的简单数组操作,是现代 C++ 编译器最擅长优化的场景。
  • ClickHouse 的做法:编译器可以轻松地进行诸如循环展开、常量传播、死代码消除等优化。这些优化在充斥着虚函数调用和条件分支的逐行处理代码中很难实施。

一个具体的代码案例对比

假设我们有一个 uint32_t 类型的数组 data,我们要计算其中大于 100 的元素之和。

逐行处理(低效):

uint64_t sum = 0;
for (size_t i = 0; i < row_count; ++i) {
    // 这里可能隐藏着一个虚函数调用,比如 Row::getValue()
    uint32_t value = getValueFromRow(i); 
    if (value > 100) { // 难以预测的分支
        sum += value;
    }
}

向量化处理(高效):

// 假设数据已经在列式数组 `column_data` 中
const uint32_t* data = column_data.data();
uint64_t sum = 0;
size_t i = 0;

// 使用 SIMD 进行批量比较和筛选(概念性代码)
for (; i + 8 <= row_count; i += 8) {
    __m256i chunk = _mm256_load_si256(reinterpret_cast<const __m256i*>(data + i));
    __m256i mask = _mm256_cmpgt_epi32(chunk, _mm256_set1_epi32(100)); // 一次性比较8个值
    // ... 使用 mask 将有贡献的值选出来并加到 sum 上 ...
}

// 处理尾部剩余的数据(不足8个)
for (; i < row_count; ++i) {
    if (data[i] > 100) {
        sum += data[i];
    }
}

总结

ClickHouse 的向量化执行技术本质上是一种 “数据导向” 的编程模型和执行引擎。它通过:

  1. 批量处理:将处理单元从"行"提升到"列块",极大减少了控制逻辑开销。
  2. 列式存储:为批量处理提供了自然的数据布局,同时提高了缓存利用率。
  3. SIMD 优化:充分利用了现代 CPU 的并行计算能力,将单核性能压榨到极致。

这三者结合,使得 ClickHouse 在进行数据分析类查询(全表扫描、聚合、过滤)时,能够将硬件(特别是 CPU 和内存带宽)的性能发挥到极限,从而实现惊人的查询速度。这正是它在 OLAP 领域脱颖而出的核心原因。

更多推荐