Skip to content

SIMD 向量化:SSE/AVX、intrinsic、自动向量化

一、SIMD 是什么

Single Instruction, Multiple Data:一条指令同时算多个数据。现代 x86:

  • SSE(128-bit):一次 4 个 float / 2 个 double / 16 个 char。
  • AVX(256-bit):8 float / 4 double。
  • AVX-512(亦 hybrid 下变频):16 float / 8 double(Broadwell+、Skylake-X…)。
  • Arm:NEON(128-bit)→ SVE(变长)。

好处:给点积/矩阵/滤波/范式内核近乎乘数级提速(理想 4/8/16 倍但受带宽/依赖/下降频限制)。

二、用 SIMD 的三种层次(考试常问“你怎么写”)

1. 自动向量化(auto-vectorization)

编译器帮你识别可向量循环:

cpp
// -O3 -march=native 下
for (i...) c[i] = a[i] * b[i] + s;
  • 前提缺一不可:循环可数、无别名(用 restrict/不 alias)、可 if-conv、尽量数据对齐连续。
  • 查看是否真生成:-fopt-info-vec / -fopt-info-vec-missed,或反汇编找 vaddps/vfmadd

2. 矢量内建/intrinsic(显式,可控)

_mm256_*/_mm_*:用类型如 __m256(8 float)操作。

cpp
#include <immintrin.h>
__m256 va = _mm256_loadu_ps(&a[i]);   // load 8 floats (可能未对齐用 loadu)
__m256 vb = _mm256_loadu_ps(&b[i]);
__m256 vr = _mm256_fmadd_ps(va, vb, _mm256_set1_ps(s)); // s + a[i]*b[i]  (FMA)
_mm256_storeu_ps(&c[i], vr);
  • 优势:绕过编译器是否识别的顾虑、确定得到 SIMD。
  • 麻烦:手写尾部余量处理(n%8)、对齐选择(load vs loadu)、跨平台指令集不同。

3. 手写 asm(罕见,极端 case)

仅当要特定指令/要让某条指令不被打扰(如内存屏障特殊语义),一般用 intrinsic 足够。能答“intrinsic 让我锁定指令又不至于裸汇编难移植”即到位。

三、关键“会写向量点积/SGEMM”示范骨架

cpp
float dot_simd(const float* a, const float* b, size_t n) {
    __m256 acc = _mm256_setzero_ps();
    size_t i = 0;
    for (; i + 8 <= n; i += 8)
        acc = _mm256_fmadd_ps(_mm256_loadu_ps(a+i), _mm256_loadu_ps(b+i), acc);
    // 水平求和(把 8 lane 相加)
    __m128 lo = _mm256_castps256_ps128(acc);
    __m128 hi = _mm256_extractf128_ps(acc, 1);
    __m128 s = _mm_add_ps(lo, hi);
    s = _mm_hadd_ps(s, s); s = _mm_hadd_ps(s, s);   // or use hadd/permute
    float sum = _mm_cvtss_f32(s);
    // 尾部标量
    for (; i < n; ++i) sum += a[i]*b[i];
    return sum;
}

要点:多用 FMA(乘加合一条)省带宽;用多路累加器拆依赖链(见 cpu-hardware);对齐用 aligned load 更快;余量分开。

四、性能上限认知(答“为什么没有 8 倍”)

  1. 内存带宽墙:数据量大时受 DRAM 带宽上界限制,SIMD 只是让 ALU 不再堵,真正瓶颈转去 cache miss。
  2. FMA 单元数/吞吐:每核每周期能发几条 SIMD?现代核通常 2 个 256 位 FMA 吞吐,需指令数匹配。
  3. AVX-512 频率下降(尤其服务器)与功耗——非始终最爽。
  4. 依赖链延迟:累加器串行链 → 拆多个独立累加器并行。
  5. 数据不连续/不规则(如 gather)反而慢。

五、跨平台

  • x86: <immintrin.h> 全 family;<x86intrin.h>
  • ARM: <arm_neon.h>
  • 想可移植可调库:Eigen 向量化、std::experimental::simd(C++26 方向/部分 vendor)、手写 dispatch 函数指针按 cpuid 选。
  • 判断可用指令:__AVX2__/__AVX512F__ 宏加编译期 #if;运行期 cpuid/__builtin_cpu_supports("avx2")

六、给 interviewer 的系统回答

「优先让编译器自动向量化(连续对齐无别名 + -O3 -march=native),不够再 intrinsics:仔细对齐、用 FMA、拆多路累加打破依赖链、处理尾部;最后才考虑 AVX-512 与频率下降的权衡,或 fallback 通用 march 路径用 cpuid 分派。」

七、自测

  1. 为什么 tail (余量) 不能偷懒读越界?—— 越界访问内存 UB/可能 segv;正确用 padding buffer 或标量尾。
  2. load vs loadu 差别?—— 对齐要求;对齐时 aligned 更快(但现代同 cache line 常 loadu 也行)。
  3. 什么时候 SIMD 上不来带宽?—— 足够大数据量遍历受 DRAM 带宽限。

C++ 面试八股 · VitePress 版