SIMD 与向量化:自动向量化条件、intrinsics 与 CPU 分发
一条指令处理多个数据
SIMD(Single Instruction, Multiple Data)是当代 CPU 提升算力的主力手段:一条指令同时处理多个数据。寄存器宽度越宽,同时处理的数据越多:
- SSE:128 位寄存器,一次处理 4 个 float(或 2 个 double)。
- AVX/AVX2:256 位,一次 8 个 float。
- AVX-512:512 位,一次 16 个 float。
理论上,把标量循环改成 AVX2,吞吐能直接 ×8(每条指令做 8 个 float 的运算)。我们用点积实测看真实数字:
===== SIMD 点积(4M float = 16MB,30 次平均)=====
标量 dot_scalar 958.2 us
手写 AVX2 dot_avx2 48.1 us
AVX2/标量 = 19.9x近 20 倍。 这个数字比理论 8× 高,因为手写版除了 SIMD 宽度,还用了 FMA(一条指令做乘加两条的活)+ 多累加器(打破依赖链,04-02 讲过),三者叠加。先纠正一个直觉:标量版 dot_scalar 在 -O3 -mavx2 下其实已经被自动向量化了(g++ -O3 -mavx2 -fopt-info-vec-optimized 会报 loop vectorized using 32 byte vectors),并不是「没向量化」。那它为什么还是慢 20 倍?因为它的累加形成依赖链(下面看汇编知道是标量水平累加链),手写 dot_avx2 有 4 个累加器并行。这 20× 主要是「多累加器 vs 单累加器」的 ILP 差距,不是「向量化与否」。 这正是本篇要讲的重点。
自动向量化:条件 + 一个关于 FP 归约的常见误解
编译器在 -O2/-O3 -ftree-vectorize 下会尝试把循环自动改成 SIMD。前提条件:
- 循环次数可估(编译器要知道大概多少次,才能安排 SIMD)。
- 无指针别名(编译器要敢假设两次循环迭代访问的内存不重叠;
__restrict帮它,见 ch04-04)。 - 无函数调用(或调用可内联消除)。
- 分支简单(无复杂控制流)。
第 5 条「FP 归约」值得更新一下旧认知。 点积的核心是 acc += a[i] * b[i],这是一个 FP 归约(reduction)。SIMD 化它要把累加拆成多个并行通道,这改变了浮点加法的结合顺序,而浮点加法不满足结合律((a+b)+c ≠ a+(b+c),尾数舍入误差不同)。旧教材常说「默认 -O3 严格遵守 FP 语义、不敢改结合顺序,所以不向量化 FP 归约」,这个说法在现代 GCC 上过时了:GCC 10 起会用 ordered(顺序保持)归约模式向量化 FP 归约,它保持可观察的累加顺序(结果位精确一致),只在内部用 SIMD 做保持顺序的部分和。亲手验证:g++ -O3 -mavx2 -fopt-info-vec-optimized 编译本篇的 dot_scalar,会看到 loop vectorized using 32 byte vectors。
那为什么标量点积还是比手写 dot_avx2 慢近 20 倍? 看 ordered 模式实际生成的汇编:dot_scalar 在 -O3 -mavx2 下乘法用 vmulps(8 路 SIMD)向量化了,但保序累加是一串标量 vaddss(逐 lane 水平加进同一个 xmm0),形成一条标量加法依赖链。这是 ordered 模式的代价:它向量化了乘法,却没法让累加并行(并行累加会改变 FP 结合顺序)。手写 dot_avx2 的 4 个向量累加器才打破这个限制、让 4 条 FMA 真正并行。所以这 20× 主要是「4 累加器并行 vs 标量顺序累加」的差距,不是「向量化 vs 没向量化」。 想让编译器自己拆多通道,要给 -ffast-math(或更窄的 -fassociative-math)放宽 FP 语义。开了 -ffast-math 后 GCC 确实会把 dot_scalar 重排成 4 个向量累加器(汇编可见 vfmadd231ps × 4 + unroll 32,和手写 dot_avx2 同款结构);但在本机 WSL2 上实测耗时几乎不变,说明这个简单循环的 20× 差距主因不只是累加器数,还有 dot_scalar 被内联进 REP 外层循环、和作为独立函数调用的 dot_avx2 处于不同的测量结构。整数归约没有结合律问题,默认就能多通道,自动向量化更积极。
想知道你的循环向量化没有、为什么没拿满,用 GCC 诊断:-fopt-info-vec-optimized(哪些被向量化)、-fopt-info-vec-missed(为什么没),这是读编译器心思的最快办法。
手写 intrinsics:AVX2 实战
当自动向量化做不到(非标准访问模式、特定指令、或就是要榨干)时,手写 intrinsics。intrinsics 是编译器内置的、对应单条 SIMD 指令的 C 函数,头文件 <immintrin.h>。AVX2 点积的核心:
#include <immintrin.h>
float dot_avx2(const float* a, const float* b) {
__m256 v0 = _mm256_setzero_ps(), v1 = _mm256_setzero_ps(),
v2 = _mm256_setzero_ps(), v3 = _mm256_setzero_ps();
for (int i = 0; i < N; i += 32) { // 4 条向量 × 8 float = 32
v0 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i), _mm256_loadu_ps(b+i), v0);
v1 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+8), _mm256_loadu_ps(b+i+8), v1);
v2 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+16), _mm256_loadu_ps(b+i+16), v2);
v3 = _mm256_fmadd_ps(_mm256_loadu_ps(a+i+24), _mm256_loadu_ps(b+i+24), v3);
}
// horizontal 合并 4 个累加器
__m256 s = _mm256_add_ps(_mm256_add_ps(v0, v1), _mm256_add_ps(v2, v3));
alignas(32) float tmp[8]; _mm256_store_ps(tmp, s);
float acc = 0; for (int i = 0; i < 8; ++i) acc += tmp[i];
return acc;
}几个要点:
__m256是 256 位向量类型,装 8 个 float。_mm256_loadu_ps:从内存 load 8 个 float(u= unaligned,允许未对齐;对齐版本_mm256_load_ps要求数据 32 字节对齐)。_mm256_fmadd_ps(a, b, c):a*b + c,FMA 一条指令做乘加(AVX2 + FMA 才有,编译要加-mfma)。- 4 个累加器
v0..v3:打破依赖链(04-02),让 4 条 FMA 并行填满执行端口。 - horizontal 合并:SIMD 累加器最后要归约成一个标量,这一步标量运算较多,所以累加器也别开太多(开 8 个收益就不如 4 个了)。
这套写法吃透了 SIMD 宽度 + FMA + 多累加器,实测拿到 20×。但代价是可移植性差(绑死 AVX2+FMA,老 CPU 跑不了)和可读性差。所以手写 intrinsics 的边界:自动向量化能做到就别手写,手写只在「自动做不到 + 确认是热点」时上。
AVX-512:小心降频
AVX-512 听起来更猛(512 位,一次 16 个 float),但有个坑:在某些 Intel CPU 上,运行 AVX-512 指令会触发许可证级降频(license-based frequency),CPU 认为 AVX-512 的功耗/散热要求高,主动把全核频率降一档,而且降频会延续到 AVX-512 指令结束后一段时间(等「冷却」)。结果:一小段 AVX-512 代码可能拖慢整个程序的其它部分,得不偿失。
这条坑随 Intel 代际在改善(Ice Lake 起轻量 AVX-512 不降频;Skylake-X 那代最严重),AMD Zen 4 才引入 AVX-512 且策略不同。实践上,SSE/AVX2 + FMA 是安全甜点,收益已经很大、没有降频坑;AVX-512 要么在确认不降频的新 CPU 上、要么用「轻量」指令子集,并且必须 benchmark。本机 5800H 是 Zen 3,只支持到 AVX2(无 AVX-512),所以本卷的 SIMD 例子都是 AVX2 级。
CPU 分发:function multiversioning
手写 intrinsics 绑死指令集,可移植性差。解法是 CPU 分发 / function multiversioning:同一函数写多个版本(SSE 版、AVX2 版、标量版),运行时按 CPU 能力选。GCC 内建 __builtin_cpu_supports("avx2") 查能力;更优雅的是 __attribute__((target_clones("avx2","default"))) 让编译器自动生成多版本 + 分发:
__attribute__((target_clones("avx2","default")))
float dot(const float* a, const float* b) { /* 一份通用代码,编译器按 target 各编一遍 */ }编译器会生成 AVX2 版和默认版,运行时第一次调用时检测 CPU 选版本(IFUNC 机制)。这样一份源码,在不同 CPU 上跑各自的优化版。代价:二进制变大(多版本代码)、首次调用有微小分发开销。
展望:std::simd(C++26)
C++26 起标准化 std::simd(在 <experimental/simd>),提供一个可移植的 SIMD 抽象,你写「用宽度为 N 的 SIMD 处理」,编译器映射到各平台的具体指令。这能把手写 intrinsics 的可移植性问题交给标准库。vol6 这里只提一笔展望,深入等 C++26 普及。
把这一篇压成几句:SIMD 理论 ×4/×8/×16,实测手写 AVX2 点积拿 ~20×(SIMD 宽度 + FMA + 多累加器叠加);自动向量化的条件是循环次数可估、无别名、无复杂分支,现代 GCC(GCC 10+)默认会用 ordered 模式向量化 FP 归约(保持顺序、结果位精确),但单累加器限死依赖链,拿满 SIMD 要靠多累加器(手写,或 -ffast-math 让编译器敢拆通道),不是「开 -O3/-ffast-math」就行,用 -fopt-info-vec 读诊断;手写 intrinsics 榨干 SIMD,但可移植性差、只在热点 + 自动做不到时上;AVX-512 有降频坑,SSE/AVX2+FMA 是安全甜点,必须 benchmark;CPU 分发(target_clones / __builtin_cpu_supports)解决可移植性,std::simd(C++26)是未来。
参考资源
- Agner Fog《Optimizing assembly》§5「Vector programming」+ §13「AVX-512」。本地
- GCC 文档
-ftree-vectorize/-fopt-info-vec/target_clones/__builtin_cpu_supports - Intel Intrinsics Guide(intel.com/intrinsics-guide)——intrinsics 速查
- 本篇实测代码:
code/volumn_codes/vol6-performance/ch04/simd.cpp