i007.cc

i007.cc

优先队列-降维打击

05.价值资料

SIMD 向量化优化

🔷 一、什么是 SIMD?

SIMD = Single Instruction, Multiple Data(单指令多数据)

普通标量计算:一条指令处理一个数据

add r1, r2  →  1次加法,处理1对数字

 

SIMD:一条指令同时处理多个数据

vaddps ymm1, ymm2  →  1次指令,同时处理8对float(AVX2,256位寄存器)

 

标量计算:
[a1] + [b1] = [c1]   ← 第1次指令
[a2] + [b2] = [c2]   ← 第2次指令
[a3] + [b3] = [c3]   ← 第3次指令
[a4] + [b4] = [c4]   ← 第4次指令
(4条指令)

SIMD计算:
[a1 a2 a3 a4] + [b1 b2 b3 b4] = [c1 c2 c3 c4]
(1条指令,理论上快4倍!)

 


🔷 二、SIMD 指令集家族(按寄存器宽度)

指令集 寄存器宽度 可并行处理的 float 数量
SSE 128位 4个
AVX/AVX2 256位 8个
AVX-512 512位 16个
bash
# 查看你的CPU支持哪些指令集
cat /proc/cpuinfo | grep flags | head -1
# 看到 avx2、sse4_2 等字样

 


🔷 三、最简单的方式:让编译器自动向量化

cpp
// 编译时加上优化选项
// g++ -O3 -march=native myprogram.cpp

void addArrays(float* a, float* b, float* result, int n) {
    for (int i = 0; i < n; i++) {
        result[i] = a[i] + b[i];   // 编译器可能自动转成 SIMD 指令
    }
}

 

-O3 会开启自动向量化,-march=native 让编译器使用你CPU支持的最高指令集。

验证编译器是否真的向量化了

bash
g++ -O3 -march=native -S myprogram.cpp -o output.asm
grep -i "vaddps\|vmulps" output.asm   # 看到这些说明用上了AVX指令

 

或者用 Compiler Explorer 在线查看汇编。


🔷 四、为什么编译器有时”罢工”不向量化?

陷阱1:数据依赖(前一次结果影响下一次)

cpp
// ❌ 无法向量化:sum 依赖上一次迭代的结果
float sum = 0;
for (int i = 0; i < n; i++) {
    sum += a[i];   // 每次都依赖上一次的 sum
}

 

解决:用多个独立的累加器,最后合并(打破依赖链):

cpp
// ✅ 4路展开,4个独立累加器,可以并行
float sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
int i = 0;
for (; i + 4 <= n; i += 4) {
    sum0 += a[i];
    sum1 += a[i+1];
    sum2 += a[i+2];
    sum3 += a[i+3];
}
float sum = sum0 + sum1 + sum2 + sum3;
for (; i < n; i++) sum += a[i];  // 处理剩余的

 

陷阱2:指针别名(编译器不确定内存是否重叠)

cpp
// ❌ 编译器不知道 a、b、result 是否指向重叠内存,不敢随意重排计算顺序
void addArrays(float* a, float* b, float* result, int n) {
    for (int i = 0; i < n; i++) {
        result[i] = a[i] + b[i];
    }
}

// ✅ restrict 关键字承诺"这些指针不重叠",编译器才敢大胆向量化
void addArrays(float* __restrict a, float* __restrict b, 
                float* __restrict result, int n) {
    for (int i = 0; i < n; i++) {
        result[i] = a[i] + b[i];
    }
}

 

陷阱3:循环里有分支跳转

cpp
// ❌ 难以向量化:每个元素走的分支可能不同
for (int i = 0; i < n; i++) {
    if (a[i] > 0) result[i] = a[i] * 2;
    else result[i] = 0;
}

// ✅ 改写成无分支形式(用乘法代替if),更容易向量化
for (int i = 0; i < n; i++) {
    result[i] = (a[i] > 0) * a[i] * 2;   // bool 自动转 0/1
}

 


🔷 五、手动 SIMD:Intrinsics(直接控制底层指令)

当编译器自动向量化不够理想时,可以手写 SIMD 指令

cpp
#include <immintrin.h>   // AVX intrinsics

void addArraysSIMD(float* a, float* b, float* result, int n) {
    int i = 0;
    
    // 每次处理8个float(AVX2,256位 = 8×32位)
    for (; i + 8 <= n; i += 8) {
        __m256 va = _mm256_loadu_ps(&a[i]);       // 加载8个float进寄存器
        __m256 vb = _mm256_loadu_ps(&b[i]);
        __m256 vresult = _mm256_add_ps(va, vb);   // 一条指令完成8次加法
        _mm256_storeu_ps(&result[i], vresult);    // 存回内存
    }
    
    // 处理剩余不足8个的元素(标量方式补尾)
    for (; i < n; i++) {
        result[i] = a[i] + b[i];
    }
}

 

性能对比实测(典型量级)

cpp
// 处理1000万个float相加

// 标量版本
for (int i = 0; i < n; i++) result[i] = a[i] + b[i];
// 耗时:~8ms

// AVX2手动向量化
addArraysSIMD(a, b, result, n);
// 耗时:~2ms(约4倍提升,受内存带宽限制,未达到理论8倍)

 

注意:实际加速比往往达不到理论值(8倍),因为内存带宽、数据加载/存储开销也是瓶颈,不是单纯的计算变快了就万事大吉。


🔷 六、常用 SIMD Intrinsics 速查

cpp
// 加载/存储
__m256 v = _mm256_loadu_ps(ptr);        // 加载8个float(无对齐要求)
__m256 v = _mm256_load_ps(ptr);         // 加载8个float(要求32字节对齐,更快)
_mm256_storeu_ps(ptr, v);               // 存储

// 算术运算
_mm256_add_ps(a, b);    // 加法
_mm256_sub_ps(a, b);    // 减法
_mm256_mul_ps(a, b);    // 乘法
_mm256_div_ps(a, b);    // 除法

// 比较与选择
_mm256_cmp_ps(a, b, _CMP_GT_OS);        // 比较,生成掩码
_mm256_blendv_ps(a, b, mask);           // 根据掩码选择

// 水平求和(把8个数加成1个,常用于reduce)
__m256 v = ...;
__m128 hi = _mm256_extractf128_ps(v, 1);
__m128 lo = _mm256_castps256_ps128(v);
__m128 sum = _mm_add_ps(hi, lo);
// ...继续水平相加直到剩1个数

 

手写 Intrinsics 代码冗长、可读性差、还要针对不同CPU指令集写多份代码——这正是为什么实践中更推荐用专门的库


🔷 七、更友好的方式:SIMD 封装库

1. std::experimental::simd(C++标准提案,部分编译器支持)

cpp
#include <experimental/simd>
namespace stdx = std::experimental;

void addArrays(float* a, float* b, float* result, int n) {
    using simd_t = stdx::native_simd<float>;
    int width = simd_t::size();   // 自动获取当前CPU的SIMD宽度
    
    int i = 0;
    for (; i + width <= n; i += width) {
        simd_t va(a + i, stdx::element_aligned);
        simd_t vb(b + i, stdx::element_aligned);
        simd_t vresult = va + vb;   // 像普通运算符一样写代码!
        vresult.copy_to(result + i, stdx::element_aligned);
    }
    for (; i < n; i++) result[i] = a[i] + b[i];
}

 

2. 第三方库:xsimdhighway(Google)

cpp
#include <xsimd/xsimd.hpp>

void addArrays(float* a, float* b, float* result, int n) {
    using batch = xsimd::batch<float>;
    std::size_t inc = batch::size;
    
    std::size_t i = 0;
    for (; i < n - n % inc; i += inc) {
        batch va = batch::load_unaligned(&a[i]);
        batch vb = batch::load_unaligned(&b[i]);
        batch vres = va + vb;
        vres.store_unaligned(&result[i]);
    }
    for (; i < n; i++) result[i] = a[i] + b[i];
}

 

优势:写一份代码,库自动适配 SSE/AVX2/AVX-512/ARM NEON 等不同硬件,不用手写多份 Intrinsics。


🔷 八、SIMD 友好的数据布局(呼应上一节 SoA)

cpp
// ❌ AoS:SIMD难以加载,因为x/y/z交错排列,不连续
struct Point { float x, y, z; };
std::vector<Point> points;

// ✅ SoA:x坐标连续存储,可以直接SIMD批量加载
struct PointsSoA {
    std::vector<float> x, y, z;
};

void scalePoints(PointsSoA& p, float factor) {
    addArraysSIMD(p.x.data(), ..., factor, p.x.size());  // 直接SIMD处理整个x数组
}

 

这就是为什么上节讲的 SoA 和这节的 SIMD 是”天作之合”——SIMD 要求数据连续且同质,SoA 恰好提供了这种布局。


🔷 九、何时不值得手动 SIMD?

✅ 值得手动优化的场景:
- 大规模数值计算(科学计算、图形学、音视频处理)
- 已确认是性能瓶颈(用 perf 验证过)
- 数据规模大(几千个元素以上才能摊薄开销)

❌ 不值得的场景:
- 数据量很小(SIMD的"启动开销"可能超过收益)
- 逻辑复杂、分支多(向量化收益有限,代码可读性大幅下降)
- 还没确认是瓶颈(过早优化是万恶之源)

 


🔷 完整优化层次回顾

第1层:选对算法和数据结构(O(n log n) vs O(n²))→ 收益最大
第2层:Cache-friendly 布局(SoA、连续内存)       → 通常2-4倍提升
第3层:SIMD 向量化                                → 通常2-8倍提升(叠加在第2层之上)
第4层:手写汇编/极致指令调优                       → 边际收益递减,维护成本剧增

 

黄金法则:先用 perf 找到真正的瓶颈,确认是”计算密集型循环”,再考虑 SIMD;否则很可能优化了一段根本不影响整体性能的代码。


一句话总结

SIMD 的本质是”用一条指令做多份相同的工作”,前提是数据连续、操作一致、没有分支依赖。它和 cache-friendly 设计(尤其是 SoA)是天然搭档——好的内存布局是 SIMD 能发挥威力的基础,二者结合才能真正榨干现代 CPU 的算力。

要不要看看一个完整的实战案例——用 Google Benchmark 实测对比“标量 vs 自动向量化 vs 手动SIMD”三种实现的性能差异?我可以帮你写出可直接编译运行的代码。

2 thoughts on “SIMD 向量化优化

  • WillPost author

    还记得Leetcode里面那个合并n个有序链表么?里面推荐的做法是两两合并,这样的好处是:
    1. 链表的总长度容易得到控制,降低时间复杂度。
    2. 减少依赖,可以把合并过程分散到多个处理器中。

发表回复