yuqi-zheng

编译器优化:SIMD 与自动向量化


现代 CPU 包含宽 SIMD 执行单元,一条指令能处理 8、16 甚至 32 个整数。自动向量化(auto-vectorization)是编译器识别”在独立数据元素上操作”的循环、把它们改写为使用这些单元 —— 而无需改动源代码 —— 的能力。当它奏效时,性能差距是巨大的。

一个具体的例子

考虑一个函数:对两个数组,把 x 的每个元素替换为 x[i]y[i] 中较大的那个:

void max_in_place(std::span<int> x, std::span<const int> y) {
    for (size_t i = 0; i < x.size(); ++i) {
        if (y[i] > x[i]) x[i] = y[i];
    }
}

-O2 下,GCC 和 Clang 生成直白的标量循环:

.L3:
    mov edx, DWORD PTR [rsi + rax*4]   ; 加载 y[i]
    cmp edx, DWORD PTR [rdi + rax*4]   ; 与 x[i] 比较
    jle .L2                            ; y[i] <= x[i] 则跳过
    mov DWORD PTR [rdi + rax*4], edx   ; 把 y[i] 存入 x[i]
.L2:
    add rax, 1
    cmp rax, 65536
    jne .L3

每次迭代一个整数。循环内有分支、有加载、有条件存储。完全正确,但它让 SIMD 单元完全闲置。

启用自动向量化

需要两样东西:

  1. 更高的优化级别:-O3,或至少 -O2 -ftree-loop-vectorize
  2. 支持 SIMD 指令的目标:-march=skylake-march=native

两个标志齐备时,编译器针对 AVX2,使用 256 位 ymm 寄存器,每次迭代处理 8 个 int

.L4:
    vmovdqu ymm1, YMMWORD PTR [rsi + rax]
    vpcmpgtd ymm0, ymm1, YMMWORD PTR [rdi + rax]
    vptest ymm0, ymm0
    je .L3
    vpmaskmovd YMMWORD PTR [rdi + rax], ymm0, ymm1
.L3:
    add rax, 32
    cmp rax, 262144
    jne .L4

每次迭代 8 个整数,而不是 1 个。编译器从 y 加载 8 个元素,用一条 vpcmpgtd 把它们与 x 对应的 8 个元素比较,再用带掩码的存储条件地写回。最好情况下吞吐量大约提升 8 倍。

让代码更干净:去掉分支

上面那种带掩码存储的版本仍有点别扭。比较结果是一个谓词掩码,vptest 检查是否需要更新,vpmaskmovd 做带掩码写入。分支存在是因为源码里有个 if

如果你把源代码改成无条件表达操作 —— 用三目或 std::max —— 编译器会直接把它识别为向量 max 操作:

x[i] = std::max(x[i], y[i]);

生成的汇编更干净:

.L3:
    vmovdqu ymm0, YMMWORD PTR [rdi + rax]
    vpmaxsd ymm0, ymm0, YMMWORD PTR [rsi + rax]
    vmovdqu YMMWORD PTR [rdi + rax], ymm0
    add rax, 32
    cmp rax, 262144
    jne .L3

加载 8 个元素、用一条 vpmaxsd 求逐元素最大、写回。没有比较、没有分支、没有掩码。这是这个问题可能的最干净向量化输出。

别名问题

你可能注意到,编译器常常为一个向量化循环生成两份:快的 SIMD 版本和一个标量回退版本。之所以存在标量回退,是因为编译器不能总是证明两个指针不重叠。

如果 xy 恰好指向重叠区域,以 x[i] = max(x[i], y[i]) 方式一次处理 8 个会产生错误结果 —— 写 x[i] 后再读 y[i+3] 可能读到已被修改的值。编译器必须保守处理,因此插入一次运行时检查:

lea rax, [rdi - 4]
sub rax, rsi
cmp rax, 24
jbe .L_slow_path

如果检查判定数组可能重叠,就走标量路径;如果明显不重叠,就走快的 SIMD 路径。

对你确定指针不会别名的场景,GCC/Clang 扩展 __restrict 能传达这个保证:

void max_in_place(int* __restrict x, const int* __restrict y, size_t n);

加上 __restrict 后,编译器会省略别名检查、只生成 SIMD 路径。代价是:你必须保证每个调用点都满足这个约定;违反它就是未定义行为。

哪些事情会阻碍自动向量化

有几样东西可能阻止编译器向量化循环:

  • 迭代间的数据依赖:如果第 i+1 次迭代读取第 i 次迭代写入的值,循环就无法并行化。
  • 循环内的函数调用:除非函数可内联,否则副作用未知。
  • 指针别名:如上所述 —— 除非被告知,否则编译器必须假设指针可能重叠。
  • 非单位步长访问:每隔 4 个元素读一次比读连续元素更难向量化。

如果你怀疑某个循环没有被向量化,大多数编译器都有诊断标志(-fopt-info-vec-Rpass=loop-vectorize),会告诉你向量化为什么被阻止。

写出”能向量化”的代码

从实际观察自动向量化中学到的要点:

优先使用无条件表达式。 std::max(a, b)if (b > a) a = b 更容易向量化。编译器可以选用向量 max 指令;但它并不总能从条件写入中消除带掩码存储。

保持数据连续。 结构体数组(Array of Structures)布局常常妨碍向量化,因为循环关心的字段在内存中分散。数组结构体(Structure of Arrays)布局让同一字段的所有值连续存放,天然适合向量化。

不要手工分解算术。 想要 x * 3 就写 x * 3,不要写 (x << 1) + x。编译器能识别乘法模式,自己在 SIMD 乘法、移位加法或其他最快的方式中做选择。

尽早考虑数据结构布局。 从一开始就为向量化设计远比事后重构数据结构容易。

小结

自动向量化是可用的最高杠杆的优化之一,因为它能按 SIMD 宽度成倍提升吞吐,而无需修改源代码。但它也是最脆弱的之一:别名担忧、循环携带依赖、看起来像条件写入的源码模式,都可能让编译器无法应用它。理解编译器需要什么 —— 无条件操作、独立迭代、不别名的指针 —— 能让你自然而然写出可向量化的代码。