SIMD常见操作,结合样例一文理解

一、先建立最重要的画面感

假设有三个数组:

复制代码
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);

这句是核心。

它表示:

vavb 中对应位置的 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 数组加法,整个流程可以翻译成:

  1. a 里取 8 个数
  2. b 里取 8 个数
  3. 这 8 对数同时相加
  4. 把 8 个结果写回 c
  5. 指针往后跳 8 个位置
  6. 重复
  7. 剩下零头元素再逐个处理

你只要能把这 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 神秘了。它只是把你熟悉的标量循环,改写成"成批处理"的形式。

相关推荐
仰泳的熊猫2 小时前
题目2305:蓝桥杯2019年第十届省赛真题-等差数列
数据结构·c++·算法·蓝桥杯
码界奇点2 小时前
基于.NET Core的CMS内容管理系统设计与实现
c++·毕业设计·.netcore·源代码管理
Geoking.2 小时前
【新手向】go语言最新下载及安装配置教程
开发语言·后端·golang
ん贤2 小时前
Go map 底层原理
算法·golang·map
游戏开发爱好者82 小时前
如何使用Instruments和Keymob进行Swift应用性能优化分析
开发语言·ios·性能优化·小程序·uni-app·iphone·swift
小温冲冲2 小时前
ReSharper 在 Visual Studio 中的详细配置指南
c++·ide·qt·visual studio
奔跑吧邓邓子2 小时前
Hash算法性能优化:从理论到实战的飞跃
算法·性能优化·哈希算法·hash·理论到实战
6+h2 小时前
【java IO】字节流详解
java·开发语言·python
Mem0rin2 小时前
[Java面向对象]接口的声明和实现继承
java·开发语言