从标量到 AVX2、FMA、VNNI:一次看懂现代 CPU SIMD 指令集为什么这么快

在做高性能计算、数据库、搜索引擎、推理框架(如 ONNX Runtime、TensorRT、oneDNN)时,经常会看到:

  • SSE
  • AVX
  • AVX2
  • FMA
  • AVX-VNNI
  • AVX512

这些名词。

很多人知道:

SIMD 很快。

但不知道:

为什么快?到底快在哪里?

本文通过一个完整的 Benchmark 和教学示例,从最朴素的标量实现开始,一步步解释:

  1. 标量计算为什么慢
  2. AVX2 如何一次计算多个数据
  3. FMA 为什么能减少指令数
  4. VNNI 为什么能成为 AI 推理时代的重要指令
  5. 如何正确做 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 能获得巨大性能提升的根本原因。