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 位。
手写 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_alloc 或 posix_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 指令。
我建议你这样做:
- 写简单的循环——不要有复杂的指针操作或函数调用
- 使用 restrict 关键字——告诉编译器指针不重叠
- 确保数据对齐——用
__attribute__((aligned(32))) - 开启编译优化——
-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