从标量到 AVX2、FMA、VNNI:一次看懂现代 CPU SIMD 指令集为什么这么快
在做高性能计算、数据库、搜索引擎、推理框架(如 ONNX Runtime、TensorRT、oneDNN)时,经常会看到:
- SSE
- AVX
- AVX2
- FMA
- AVX-VNNI
- AVX512
这些名词。
很多人知道:
SIMD 很快。
但不知道:
为什么快?到底快在哪里?
本文通过一个完整的 Benchmark 和教学示例,从最朴素的标量实现开始,一步步解释:
- 标量计算为什么慢
- AVX2 如何一次计算多个数据
- FMA 为什么能减少指令数
- VNNI 为什么能成为 AI 推理时代的重要指令
- 如何正确做 SIMD Benchmark
文中代码全部基于:
u8 × s8 → dot product
以及:
float FMA
进行演示。
SIMD 到底是什么
SIMD:
Single Instruction Multiple Data
即:
单条指令处理多个数据
传统 CPU 标量代码:
for (int i = 0; i < 8; i++) {
sum += a[i] * b[i];
}
实际上 CPU 会执行:
mul
add
mul
add
mul
add
...
一次只处理一个元素。
而 SIMD 的思路是:
把多个元素打包进一个寄存器
一次处理
例如:
AVX2 = 256 bit
可以同时装:
8 个 float
或者:
32 个 int8
因此理论吞吐量会显著提升。
本文测试内容
测试包含四类实现:
1. 串行标量实现
scalar_dot_u8s8_serial()
特点:
- 单累加器
- 无 SIMD
- 无 ILP 优化
最容易理解。
2. 优化后的标量实现
scalar_dot_u8s8()
特点:
s0
s1
s2
s3
四路累加器。
目的:
减少依赖链长度
提升 ILP(Instruction Level Parallelism)
虽然仍然不是 SIMD。
但已经比最朴素实现快不少。
3. AVX2 实现
avx2_dot_u8s8()
核心指令:
_mm256_maddubs_epi16
以及:
_mm256_madd_epi16
用于实现:
u8 × s8
点积。
4. AVX-VNNI 实现
_mm256_dpbusd_avx_epi32
这是近几年 AI 推理最重要的指令之一。
直接完成:
Dot Product
运算。
本质上:
4 个 int8 乘法
+
4 个 int8 加法归约
融合为一条指令。
CPU 如何检测是否支持这些指令
程序启动时会检测:
cpu_supports_avx2()
以及:
cpu_supports_avx_vnni()
主要依赖:
CPUID
与:
XGETBV
检查:
- CPU 是否支持
- 操作系统是否启用了对应寄存器状态保存
因为:
CPU 支持 ≠ OS 支持
缺少 OS 支持时直接执行 AVX 指令可能触发异常。
第一部分:为什么标量实现慢
最基础实现:
for (int i = 0; i < n; i++) {
sum += a[i] * b[i];
}
CPU 实际执行:
load a[i]
load b[i]
mul
add
然后:
load a[i+1]
load b[i+1]
mul
add
周而复始。
问题在于:
一次只处理一个元素
吞吐能力有限。
四路累加器为什么更快
代码:
int64_t s0;
int64_t s1;
int64_t s2;
int64_t s3;
分别负责:
i
i+1
i+2
i+3
的计算。
原因:
CPU 的流水线最怕:
长依赖链
例如:
sum += x;
sum += y;
sum += z;
后面的加法必须等待前面完成。
而:
s0 += ...
s1 += ...
s2 += ...
s3 += ...
互不依赖。
CPU 可以同时调度。
这就是:
ILP
Instruction Level Parallelism
AVX2 到底在做什么
数学目标
我们要计算:
sum = Σ(a[i] × b[i])
其中:
a : uint8_t
b : int8_t
AVX2 的第一步
加载数据:
__m256i va
__m256i vb
每个寄存器:
256 bit
即:
32 bytes
一次装:
32 个 u8
32 个 s8
第二步:批量乘法
核心指令:
_mm256_maddubs_epi16
作用:
u8 × s8
并产生:
int16
部分和。
相当于:
32 次乘法
一次完成。
第三步:归约
使用:
_mm256_madd_epi16
继续把:
int16
归并为:
int32
结果。
四个独立累加器
代码中:
acc0
acc1
acc2
acc3
分别处理:
0~31
32~63
64~95
96~127
目的:
进一步提升 ILP
避免:
acc += result
形成长依赖链。
FMA 为什么重要
FMA:
Fused Multiply Add
即:
a * b + c
融合。
传统计算:
mul
add
需要两条指令。
FMA:
_mm256_fmadd_ps()
只需:
一条指令
完成:
(a × b) + c
FMA 最小示例
代码:
y[i] = a[i] * b[i] + c[i];
AVX2:
__m256 vy =
_mm256_fmadd_ps(
va,
vb,
vc);
一次完成:
8 个 float
计算。
数学等价:
lane0
lane1
...
lane7
同时执行:
a * b + c
为什么 AI 推理喜欢 VNNI
近年来推理框架大量采用:
INT8
量化。
例如:
FP32 → INT8
原因:
- 模型更小
- 内存带宽更低
- 吞吐更高
而神经网络最核心的操作:
Dot Product
例如:
权重 × 激活值
VNNI 干了什么
传统方式:
mul
mul
mul
mul
add
add
add
VNNI:
_mm256_dpbusd_avx_epi32()
直接完成:
a0*b0
+
a1*b1
+
a2*b2
+
a3*b3
并累加到:
int32 lane
中。
VNNI 的本质
每个 Lane:
4 个乘法
+
归约
融合。
例如:
lane0 =
a0*b0 +
a1*b1 +
a2*b2 +
a3*b3
直接生成。
减少:
- 指令数
- 中间结果
- 寄存器压力
Benchmark 设计
很多 SIMD Benchmark 是错误的。
例如:
跑一次
输出时间
这样结果基本没参考价值。
本文采用:
Warmup
warmup = 100
先预热:
- Cache
- 分支预测
- CPU 频率提升
多次迭代
iterations = 2000
甚至:
80000
次。
避免计时误差。
Streaming 测试
完整遍历:
n
个元素。
模拟:
真实内存流式访问
场景。
Cache Resident 测试
限制:
effective_n <= 4096
让数据驻留:
L1/L2 Cache
中。
突出:
纯计算吞吐
能力。
混合吞吐测试
现代 CPU 不只运行一种算子。
例如推理框架中:
浮点算子
+
INT8 算子
同时存在。
因此代码中还设计:
mixed_avx2_fma_vnni()
同时执行:
AVX2 + FMA
和:
AVX-VNNI
观察混合负载下的表现。
实验结果说明
实际运行时通常会看到:
Serial
<
Scalar(4 accumulators)
<
AVX2
<
VNNI
这样的性能排序。
原因如下:
| 实现 | 优势 |
|---|---|
| Serial | 最简单 |
| Scalar(4路) | 利用 ILP |
| AVX2 | 一次处理 32 个字节 |
| FMA | 减少乘加指令 |
| VNNI | 硬件级 Dot Product |
为什么速度会快这么多
本质原因只有一句话:
CPU 并没有变快,而是单条指令完成的工作量变大了。
例如:
标量:
1 次乘法
1 次加法
处理:
1 个元素
AVX2:
1 条向量指令
处理:
32 个元素
VNNI:
1 条指令
处理:
多个乘法 + 归约
因此吞吐量会出现数量级提升。
一句话总结
现代 CPU 的性能提升越来越依赖:
SIMD
+
FMA
+
VNNI
而不是单纯提升主频。
对于:
- 搜索引擎
- 数据库
- 图像处理
- 音视频编解码
- AI 推理
这些领域而言:
理解 SIMD,本质上就是理解现代 CPU 如何把“一次算一个数”变成“一次算几十个数”。
这也是 AVX2、FMA、VNNI 能获得巨大性能提升的根本原因。