14. SIMD 指令集入门:SSE/AVX 简介、使用编译器自动向量化、手写 SIMD 代码

说到性能优化,SIMD 是个绕不开的话题。我最早接触它是在做图像处理的时候,那时候处理一张 4K 图片要几十毫秒,用了 SIMD 之后直接降到个位数。说实话,那种感觉挺震撼的。

SIMD 的全称是 Single Instruction Multiple Data,也就是单指令多数据流。你想想看,普通指令一次只能处理一个数据,SIMD 一次能处理一堆。这效率能不高吗?

SSE 和 AVX 是什么?

Intel 的 SIMD 指令集主要有两大家族:SSE 和 AVX。

SSE(Streaming SIMD Extensions) 是 1999 年推出的,寄存器宽度 128 位。什么意思呢?就是一次能塞进 4 个 float 或者 2 个 double 同时计算。

AVX(Advanced Vector Extensions) 是 2011 年推出的,寄存器宽度 256 位。一次能处理 8 个 float 或者 4 个 double。后来还有 AVX-512,一次 512 位,不过那个功耗也大,我一般不太推荐在移动端用。

核心区别一句话: SSE 是 128 位车道,AVX 是 256 位车道。车道越宽,一次拉的数据越多。

编译器自动向量化

很多人一听到 SIMD 就觉得要手写汇编,其实不然。现代编译器已经能自动把一些循环转换成 SIMD 指令了。我个人的习惯是:先让编译器试试,不行再自己动手

要让编译器帮你做向量化,有几个条件必须满足:

  • 循环次数要确定 —— 编译器不喜欢不确定的东西
  • 数据要对齐 —— 16 字节或 32 字节对齐最好
  • 没有数据依赖 —— 比如循环里不能有前后依赖的累加
  • 循环体要简单 —— 别搞太复杂的条件分支

举个例子,下面这段代码编译器很容易自动向量化:

void add_arrays(float* a, float* b, float* c, int n) {
    // 假设 a, b, c 都是 16 字节对齐
    for (int i = 0; i < n; ++i) {
        c[i] = a[i] + b[i];
    }
}

用 GCC 编译时加上 -O2 -mavx2 -ftree-vectorize,编译器就会自动生成 AVX 指令。你可以用 -fopt-info-vec 看看它到底向量化了没有。

小技巧: 我经常用 __builtin_assume_aligned 告诉编译器指针是对齐的,这样能提高自动向量化的成功率。

手写 SIMD 代码

自动向量化虽然方便,但有时候编译器就是不开窍。比如处理非连续内存访问、或者有复杂的数据重组时,编译器就怂了。这时候就得我们自己动手。

手写 SIMD 一般用 intrinsic 函数,也就是 C/C++ 里直接调用 SIMD 指令的接口。不用写汇编,但写起来还是有点啰嗦。

来看一个实际例子:计算两个 float 数组的点积。

#include <immintrin.h>

float dot_product_sse(const float* a, const float* b, int n) {
    __m128 sum = _mm_setzero_ps();  // 初始化累加器为 0
    
    for (int i = 0; i < n; i += 4) {
        __m128 va = _mm_loadu_ps(a + i);  // 加载 4 个 float
        __m128 vb = _mm_loadu_ps(b + i);
        __m128 mul = _mm_mul_ps(va, vb);  // 逐元素相乘
        sum = _mm_add_ps(sum, mul);       // 累加
    }
    
    // 把 4 个累加结果加起来
    float result[4];
    _mm_storeu_ps(result, sum);
    return result[0] + result[1] + result[2] + result[3];
}

这段代码一次处理 4 个 float,比普通循环快 3-4 倍。如果换成 AVX,把 __m128 改成 __m256,一次就能处理 8 个。

注意:_mm_loadu_ps 加载未对齐数据会有性能损失。如果数据能保证 16 字节对齐,用 _mm_load_ps 会更快。我曾经在一个项目里因为没注意对齐,性能只提升了 1.5 倍,改成对齐后直接飙到 3 倍。

SIMD 的常见陷阱

手写 SIMD 有几个坑,我踩过不少,给你提个醒:

  • 数据对齐问题 —— 用 aligned_allocposix_memalign 分配内存
  • 尾部处理 —— 数组长度不一定是 4 或 8 的倍数,剩下的元素要单独处理
  • 指令集兼容性 —— 老 CPU 不支持 AVX,运行时要用 CPUID 检测
  • 寄存器压力 —— 别在循环里塞太多变量,寄存器不够用反而更慢

嗯,说到尾部处理,我习惯这样写:

int i = 0;
// 主循环:处理 8 个一组
for (; i + 7 < n; i += 8) {
    // AVX 处理
}
// 尾部:逐个处理剩下的
for (; i < n; ++i) {
    c[i] = a[i] + b[i];
}

知识体系总览

下面这张图把 SIMD 的核心脉络梳理了一下,你可以对照着看:

SIMD 指令集入门知识体系 SSE / AVX 简介 编译器自动向量化 手写 SIMD 代码 SSE:128 位,4 个 float AVX:256 位,8 个 float AVX-512:512 位,16 个 float 寄存器宽度决定吞吐量 循环次数确定 数据对齐 无数据依赖 编译选项:-O2 -mavx2 Intrinsic 函数 数据对齐与加载 尾部处理 运行时 CPUID 检测 核心原则:先让编译器自动向量化,不行再手写。手写时注意对齐和尾部处理。

什么时候该用 SIMD?

不是所有场景都适合 SIMD。我总结了几类适合的场景:

场景 适合程度 说明
图像/视频处理 ⭐⭐⭐⭐⭐ 像素数据天然连续,非常适合
音频处理 ⭐⭐⭐⭐⭐ 采样点数据,批量操作多
矩阵运算 ⭐⭐⭐⭐ 注意内存布局,行优先 vs 列优先
字符串处理 ⭐⭐⭐ 查找、比较可以用,但要注意长度
分支密集的逻辑 SIMD 怕分支,性能反而下降

我的经验: 如果你发现热点函数里循环占了 80% 以上的时间,而且循环体里没有复杂分支,那 SIMD 基本能给你带来 2-4 倍的提升。我曾经把一个音频混音函数从 12ms 优化到 3ms,靠的就是 AVX。

最后说一句,SIMD 不是银弹。它适合计算密集型的场景,但如果你瓶颈在内存带宽或者 IO 上,那 SIMD 也帮不了你。先做 profiling,找到真正的热点,再决定要不要上 SIMD。这是我一直坚持的做法。