Skip to content

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 的运算)。我们用点积实测看真实数字:

text
===== 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。前提条件:

  1. 循环次数可估(编译器要知道大概多少次,才能安排 SIMD)。
  2. 无指针别名(编译器要敢假设两次循环迭代访问的内存不重叠;__restrict 帮它,见 ch04-04)。
  3. 无函数调用(或调用可内联消除)。
  4. 分支简单(无复杂控制流)。

第 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 点积的核心:

cpp
#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"))) 让编译器自动生成多版本 + 分发:

cpp
__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

v0.7.1-2-g3718060 · 3718060 · 2026-07-06