一、先建立最重要的画面感
假设有三个数组:
float a[8] = {1,2,3,4,5,6,7,8};
float b[8] = {10,20,30,40,50,60,70,80};
float c[8];
如果用 AVX,一次 load 进去以后,寄存器里可以想象成这样:
va = [1, 2, 3, 4, 5, 6, 7, 8]
vb = [10,20,30,40,50,60,70,80]
然后做一次 add:
vc = [11,22,33,44,55,66,77,88]
再 store 回去:
c = [11,22,33,44,55,66,77,88]
这就是 SIMD 最核心的执行模型。
二、最简单的数组加法示例
先看代码:
#include <immintrin.h>
void add_array_avx(const float* a, const float* b, float* c, int n)
{
int i = 0;
for (; i + 7 < n; i += 8)
{
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
__m256 vc = _mm256_add_ps(va, vb);
_mm256_storeu_ps(c + i, vc);
}
for (; i < n; ++i)
{
c[i] = a[i] + b[i];
}
}
下面逐行拆。
1)__m256 va = _mm256_loadu_ps(a + i);
这句的作用是:
从内存地址 a + i 开始,连续取 8 个 float,装进一个 AVX 寄存器。
你可以把它脑补成:
va = [a[i], a[i+1], a[i+2], a[i+3], a[i+4], a[i+5], a[i+6], a[i+7]]
比如当 i = 0 时:
va = [a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]]
这里的 _loadu_ 里那个 u 表示 unaligned ,也就是"允许非对齐加载"。
你现在先把它理解成:从数组里把一批数读进来 就行。
2)__m256 vb = _mm256_loadu_ps(b + i);
和上一句完全一样,只不过这次是从 b 数组加载。
所以:
vb = [b[i], b[i+1], b[i+2], b[i+3], b[i+4], b[i+5], b[i+6], b[i+7]]
3)__m256 vc = _mm256_add_ps(va, vb);
这句是核心。
它表示:
把 va 和 vb 中对应位置的 8 个 float 两两相加。
也就是:
vc[0] = va[0] + vb[0]
vc[1] = va[1] + vb[1]
...
vc[7] = va[7] + vb[7]
整体写成一行就是:
vc = [
a[i] + b[i],
a[i+1] + b[i+1],
a[i+2] + b[i+2],
a[i+3] + b[i+3],
a[i+4] + b[i+4],
a[i+5] + b[i+5],
a[i+6] + b[i+6],
a[i+7] + b[i+7]
]
所以 _mm256_add_ps 不是"把整个寄存器当一个大数相加",而是按 lane、按元素逐个对应相加。
这个理解特别重要。
4)_mm256_storeu_ps(c + i, vc);
这句表示:
把寄存器 vc 里的 8 个 float,按顺序写回到内存 c + i 开始的位置。
也就是:
c[i] = vc[0]
c[i+1] = vc[1]
...
c[i+7] = vc[7]
你可以把 store 理解成"把批量计算结果落地"。
5)for (; i + 7 < n; i += 8)
这句循环条件也必须看懂。
因为 AVX 一次处理 8 个 float,所以只有当从 i 开始至少还有 8 个元素时,才能走 SIMD 这一轮。
i += 8 的意思就是:
每次前进一个向量块,而不是前进一个元素。
这就是"批量处理"的直接体现。
6)尾部处理
for (; i < n; ++i)
{
c[i] = a[i] + b[i];
}
因为数组长度 n 不一定是 8 的倍数,所以剩下不够 8 个的那几个元素,最后用普通标量代码补上。
这就是尾部处理。
三、把数组加法翻译成"人话流程"
上面的 SIMD 数组加法,整个流程可以翻译成:
- 从
a里取 8 个数 - 从
b里取 8 个数 - 这 8 对数同时相加
- 把 8 个结果写回
c - 指针往后跳 8 个位置
- 重复
- 剩下零头元素再逐个处理
你只要能把这 7 步在脑子里过一遍,load/add/store 就算真的懂了。
四、整体加常数示例
这个例子也非常经典,比如把数组里每个元素都加上 10.0f。
代码:
#include <immintrin.h>
void add_constant_avx(const float* src, float* dst, float value, int n)
{
int i = 0;
__m256 vvalue = _mm256_set1_ps(value);
for (; i + 7 < n; i += 8)
{
__m256 vx = _mm256_loadu_ps(src + i);
__m256 vy = _mm256_add_ps(vx, vvalue);
_mm256_storeu_ps(dst + i, vy);
}
for (; i < n; ++i)
{
dst[i] = src[i] + value;
}
}
还是逐行理解。
1)__m256 vvalue = _mm256_set1_ps(value);
这句很关键。
它的意思不是从内存加载,而是:
把同一个 float 值复制 8 份,填满整个寄存器。
如果 value = 10.0f,那么:
vvalue = [10,10,10,10,10,10,10,10]
这类操作通常叫 broadcast,也就是"广播"。
因为你要给每个元素都加同一个常数,所以最自然的做法就是先构造一个"全是 10"的向量。
2)__m256 vx = _mm256_loadu_ps(src + i);
加载源数组当前这 8 个元素:
vx = [src[i], src[i+1], ..., src[i+7]]
3)__m256 vy = _mm256_add_ps(vx, vvalue);
逐元素相加:
vy = [
src[i] + 10,
src[i+1] + 10,
...
src[i+7] + 10
]
4)_mm256_storeu_ps(dst + i, vy);
把这 8 个结果写回去。
这个例子的本质
这个例子让你看到一个新点:
SIMD 不一定非得是"两个数组相加",也可以是:
- 一个数组 + 一个常量
- 一个数组 * 一个常量
- 一个数组和阈值比较
只要是"同样的操作作用在很多元素上",SIMD 都适合。
五、阈值判断示例
这个例子特别适合图像处理理解。
假设我们想做这样的操作:
if (src[i] > thresh)
dst[i] = 255;
else
dst[i] = 0;
先给你一个 float 版本,便于理解 compare 的本质。
#include <immintrin.h>
void threshold_avx(const float* src, float* dst, float thresh, int n)
{
int i = 0;
__m256 vthresh = _mm256_set1_ps(thresh);
__m256 v255 = _mm256_set1_ps(255.0f);
__m256 vzero = _mm256_set1_ps(0.0f);
for (; i + 7 < n; i += 8)
{
__m256 vx = _mm256_loadu_ps(src + i);
__m256 mask = _mm256_cmp_ps(vx, vthresh, _CMP_GT_OQ);
__m256 vy = _mm256_blendv_ps(vzero, v255, mask);
_mm256_storeu_ps(dst + i, vy);
}
for (; i < n; ++i)
{
dst[i] = (src[i] > thresh) ? 255.0f : 0.0f;
}
}
这个例子比前两个稍微多一步,但它能帮你理解 compare 在 SIMD 里到底干了什么。
1)准备阈值和输出值
__m256 vthresh = _mm256_set1_ps(thresh);
__m256 v255 = _mm256_set1_ps(255.0f);
__m256 vzero = _mm256_set1_ps(0.0f);
分别得到:
vthresh = [thresh, thresh, ..., thresh]
v255 = [255,255,255,255,255,255,255,255]
vzero = [0,0,0,0,0,0,0,0]
2)__m256 vx = _mm256_loadu_ps(src + i);
还是先加载 8 个输入元素。
3)__m256 mask = _mm256_cmp_ps(vx, vthresh, _CMP_GT_OQ);
这是理解 compare 的关键一句。
它的意思是:
把 vx 里的 8 个元素分别和阈值比较,判断是否大于阈值。
如果某个位置满足 vx[k] > thresh,那么这个位置在结果里会得到"全 1";不满足就得到"全 0"。
你可以先用逻辑层面理解成:
mask = [
vx[0] > thresh ? TRUE : FALSE,
vx[1] > thresh ? TRUE : FALSE,
...
vx[7] > thresh ? TRUE : FALSE
]
虽然底层并不是 C++ 布尔值 true/false,而是位模式上的全 1 / 全 0,但你现在先这样理解完全可以。
比如:
vx = [120, 140, 80, 200, 130, 50, 170, 90]
vthresh = [128, 128,128,128,128,128,128,128]
比较后逻辑上相当于:
mask = [F, T, F, T, T, F, T, F]
4)__m256 vy = _mm256_blendv_ps(vzero, v255, mask);
这句的作用是:
根据 mask 选值。
规则可以理解成:
- mask 为假,选
vzero - mask 为真,选
v255
所以:
vy[k] = (mask[k] 为真) ? 255 : 0
延续刚才那个例子:
mask = [F, T, F, T, T, F, T, F]
那么:
vy = [0, 255, 0, 255, 255, 0, 255, 0]
这就等价于标量里的三目运算。
5)_mm256_storeu_ps(dst + i, vy);
把这一批阈值判断结果写回去。
六、compare 到底在做什么
你现在要牢牢记住:
SIMD 里的 compare 通常不是直接给你最终结果,而是先给你一个 mask。
这个 mask 本质上是在说:
- 哪些位置满足条件
- 哪些位置不满足条件
然后你再用这个 mask 去做下一步:
- 选 0 或 255
- 保留旧值或写入新值
- 统计哪些元素命中条件
所以 compare 更像是"批量生成判定结果"。
七、用更接近图像处理的方式理解阈值判断
如果你做灰度图二值化,标量代码通常这样写:
for (int i = 0; i < n; ++i)
{
dst[i] = (src[i] > 128) ? 255 : 0;
}
SIMD 版本并没有改变逻辑,只是把这个判断一口气对 8 个元素同时做了。
你可以把它脑补成:
一次读 8 个像素
一次比较这 8 个像素是否 > 128
一次生成 8 个结果 0/255
一次写回 8 个像素
所以 SIMD 真正改变的是吞吐方式,不是逻辑本身。
八、把这几个核心操作放在一起对照理解
现在你已经见过 4 个最常见动作,我们把它们做一个统一解释。
load
__m256 vx = _mm256_loadu_ps(src + i);
含义:
从内存连续读取 8 个 float 到寄存器。
本质:
把标量数组的一段,打包成向量。
store
_mm256_storeu_ps(dst + i, vy);
含义:
把寄存器里的 8 个 float 写回内存。
本质:
把向量结果重新落回数组。
add
__m256 vy = _mm256_add_ps(vx, vv);
含义:
对两个寄存器里对应位置的元素逐个相加。
本质:
批量做同一种算术操作。
compare
__m256 mask = _mm256_cmp_ps(vx, vthresh, _CMP_GT_OQ);
含义:
逐元素比较,得到一组真假判定。
本质:
批量做条件判断,并生成掩码。
九、把 SIMD 代码和普通代码逐项对应起来
例如数组加常数:
普通写法:
for (int i = 0; i < n; ++i)
{
dst[i] = src[i] + value;
}
SIMD 写法:
__m256 vvalue = _mm256_set1_ps(value);
for (int i = 0; i + 7 < n; i += 8)
{
__m256 vx = _mm256_loadu_ps(src + i);
__m256 vy = _mm256_add_ps(vx, vvalue);
_mm256_storeu_ps(dst + i, vy);
}
它们一一对应是这样的:
src[i]变成src[i...i+7]value变成[value, value, ..., value]- 一次
+变成 8 次并行+ dst[i] = ...变成dst[i...i+7] = ...
这样你就不会觉得 intrinsic 神秘了。它只是把你熟悉的标量循环,改写成"成批处理"的形式。