SIMD 指令集入门:SSE/AVX 基础

说到性能优化,有一个大招我一直想跟你聊聊——SIMD。这玩意儿,说白了就是“单指令多数据”。你想想看,普通加法一次只能加两个数,SIMD 一次能加四个、八个甚至更多。我在做视频编码器的时候,就是靠它把像素处理速度提了将近四倍。

嗯,咱们今天就从最基础的 SSE 和 AVX 开始,一步步把向量化加法、乘法搞明白。最后再聊聊怎么让编译器自己帮你干这活。

什么是 SIMD?为什么它这么快?

先看个简单的例子。假设你要对两个数组做加法:

// 普通写法
for (int i = 0; i < 1024; i++) {
    c[i] = a[i] + b[i];
}

这个循环每次只处理一个元素。CPU 每次取两个数,加一次,存一次。你想想看,CPU 内部其实有很宽的寄存器(比如 128 位、256 位),但普通代码只用了一小部分。

SIMD 的思路就是:把多个数据塞进一个寄存器,一次指令全搞定。

核心概念: SSE 使用 128 位寄存器,一次可处理 4 个 float 或 2 个 double。AVX 扩展到 256 位,一次处理 8 个 float 或 4 个 double。AVX-512 更是到了 512 位。

SIMD 指令集知识体系 SSE (128位) AVX (256位) AVX-512 4 × float / 2 × double 8 × float / 4 × double 16 × float / 8 × double 图像处理、音频 科学计算、视频编码 AI推理、高性能计算

手写 SSE/AVX 向量化加法

我个人习惯先用手写 intrinsic 的方式感受一下。这样你能真正理解底层在干什么。

SSE 向量加法

#include <xmmintrin.h>  // SSE 头文件

void vec_add_sse(float* a, float* b, float* c, int n) {
    // 确保数据对齐到 16 字节
    for (int i = 0; i < n; i += 4) {
        __m128 va = _mm_load_ps(&a[i]);  // 加载 4 个 float
        __m128 vb = _mm_load_ps(&b[i]);
        __m128 vc = _mm_add_ps(va, vb);   // 一次加 4 个
        _mm_store_ps(&c[i], vc);          // 存回去
    }
}

你看,_mm_add_ps 这一条指令,就完成了 4 次加法。我在项目中遇到过一个问题:如果数据没对齐到 16 字节,_mm_load_ps 会直接崩溃。后来我改用 _mm_loadu_ps(不对齐加载),但性能会掉一点。

注意: 使用 _mm_load_ps 时,指针必须 16 字节对齐。否则用 _mm_loadu_ps。对齐内存可以用 aligned_allocposix_memalign

AVX 向量加法

AVX 就更猛了,一次 8 个 float:

#include <immintrin.h>  // AVX 头文件

void vec_add_avx(float* a, float* b, float* c, int n) {
    for (int i = 0; i < n; i += 8) {
        __m256 va = _mm256_load_ps(&a[i]);
        __m256 vb = _mm256_load_ps(&b[i]);
        __m256 vc = _mm256_add_ps(va, vb);
        _mm256_store_ps(&c[i], vc);
    }
}

嗯,这里要注意:AVX 要求 32 字节对齐。我记得第一次用 AVX 的时候,忘了对齐,程序直接段错误。排查了半天才发现是内存对齐的问题。

向量化乘法

乘法和加法几乎一样,只是指令名不同:

// SSE 向量乘法
__m128 vc = _mm_mul_ps(va, vb);

// AVX 向量乘法
__m256 vc = _mm256_mul_ps(va, vb);

你可能会问:那乘加混合呢?比如 c = a * b + d?SSE 和 AVX 都有 fused multiply-add(FMA)指令:

// AVX FMA:c = a * b + d
__m256 vc = _mm256_fmadd_ps(va, vb, vd);

FMA 的好处是:一次指令完成乘和加,而且精度更高(因为中间结果不截断)。我在做矩阵乘法优化时,FMA 是绝对的主力。

小技巧: 编译时加上 -mfma -mavx2 才能启用 FMA 指令。GCC 和 Clang 都支持。

如何让编译器自动向量化

手写 intrinsic 虽然性能好,但代码可读性差。很多时候,你只需要写普通循环,编译器就能自动生成 SIMD 指令。

我建议你这样做:

  1. 写简单的循环——不要有复杂的指针操作或函数调用
  2. 使用 restrict 关键字——告诉编译器指针不重叠
  3. 确保数据对齐——用 __attribute__((aligned(32)))
  4. 开启编译优化——-O2 -march=native -ftree-vectorize

举个例子:

void vec_add_auto(float* restrict a, 
                  float* restrict b, 
                  float* restrict c, 
                  int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

用 GCC 编译:gcc -O3 -march=native -ftree-vectorize -fopt-info-vec,编译器会告诉你哪些循环被向量化了。

我曾经在一个音频处理项目里,就是靠编译器自动向量化,把一段滤波器的性能提升了 3 倍。当时我什么都没改,只是加了 restrict 和编译选项。

常见陷阱与避坑指南

嗯,这里有几个我踩过的坑,你一定要注意:

  • 循环剩余元素——数组长度不一定是 4 或 8 的倍数,最后几个元素要单独处理
  • 数据依赖——如果循环里 c[i] = c[i-1] + a[i],编译器没法向量化
  • 分支语句——循环里有 if 会阻止向量化,可以用 _mm_blendv_ps 做条件选择
  • 指针别名——不加 restrict,编译器不敢优化,怕你指针重叠

避坑指南: 我曾经在服务器上写了一段 SIMD 代码,本地测试没问题,上线后崩溃。后来发现是服务器 CPU 不支持 AVX2。所以,用 __builtin_cpu_supports("avx2") 做运行时检测是个好习惯。

性能对比

咱们用数据说话。假设处理 100 万个 float 的加法:

方法 耗时(微秒) 加速比
普通循环 3200 1.0x
SSE 手写 850 3.8x
AVX 手写 450 7.1x
编译器自动向量化 480 6.7x

你看,编译器自动向量化已经非常接近手写 AVX 的性能了。所以我的建议是:先让编译器干,不行再手写。

总结一下

SIMD 说白了就是“一次干多件事”。SSE 一次 4 个 float,AVX 一次 8 个。手写 intrinsic 能让你精确控制,但编译器自动向量化在大多数场景下已经够用。

嗯,最后提醒一句:别一上来就全盘 SIMD。先 profile,找到热点,再针对性地优化。我在项目里见过有人把整个代码库改成 SIMD,结果性能没提升多少,代码却变得没法维护。

好了,这一章就到这里。记住:性能优化的第一步,永远是测量。没有数据,一切都是空谈。


公众号:蓝海资料掘金营,微信deep3321