Appearance
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 倍”)
- 内存带宽墙:数据量大时受 DRAM 带宽上界限制,SIMD 只是让 ALU 不再堵,真正瓶颈转去 cache miss。
- FMA 单元数/吞吐:每核每周期能发几条 SIMD?现代核通常 2 个 256 位 FMA 吞吐,需指令数匹配。
- AVX-512 频率下降(尤其服务器)与功耗——非始终最爽。
- 依赖链延迟:累加器串行链 → 拆多个独立累加器并行。
- 数据不连续/不规则(如 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 分派。」
七、自测
- 为什么 tail (余量) 不能偷懒读越界?—— 越界访问内存 UB/可能 segv;正确用 padding buffer 或标量尾。
- load vs loadu 差别?—— 对齐要求;对齐时 aligned 更快(但现代同 cache line 常 loadu 也行)。
- 什么时候 SIMD 上不来带宽?—— 足够大数据量遍历受 DRAM 带宽限。