SIMD 优化实战:为什么很多代码用了 AVX 还是没有变快
SIMD 优化实战:为什么很多代码用了 AVX 还是没有变快
很多工程师第一次接触 SIMD 时,都会产生一种非常自然的期待。
假设有这样一段代码:
for(inti=0;i<count;++i){dst[i]=a[i]+b[i];}学习 AVX 之后,很容易把它改成:
__m256 va=_mm256_load_ps(a);__m256 vb=_mm256_load_ps(b);__m256 vc=_mm256_add_ps(va,vb);_mm256_store_ps(dst,vc);理论上:
一次处理 8 个 float似乎应该获得接近:
8 倍性能提升然而现实往往并非如此。
许多人在完成 SIMD 化之后发现:
提升 10% 提升 20% 提升 50% 甚至没有提升于是开始怀疑:
是不是 AVX 没有生效? 是不是编译器优化有问题? 是不是 CPU 不支持?但很多时候,问题根本不在 SIMD 指令。
而在于:
SIMD 解决的是计算问题,而你的瓶颈可能根本不是计算。
SIMD 到底优化了什么
很多人理解中的 SIMD 是:
减少指令数量例如:
普通代码:
add add add add add add add addAVX:
vector_add一次完成。
这种理解并不算错。
但它只看到了表面。
实际上现代 CPU 中,加法本身已经非常便宜。
以浮点加法为例。
现代 CPU 往往能够:
每周期执行多个浮点运算因此很多时候:
计算不是瓶颈真正昂贵的是:
加载数据 存储数据 等待数据也就是说:
CPU 真正花费时间的地方往往不是:
Add而是:
Load Store因此 SIMD 能否发挥作用,很大程度上取决于:
数据能否高效进入寄存器而不是:
指令是否足够高级一个容易被忽略的事实
考虑下面这段代码:
result=a+b;对于 CPU 来说。
真正发生的事情是:
读取 a 读取 b 执行加法 写回结果其中:
执行加法通常只需要几个周期。
而如果:
a b不在 Cache 中。
CPU 可能等待几十甚至上百个周期。
因此很多性能问题最终都会变成:
CPU 等数据而不是:
CPU 算不动为什么很多 SIMD 优化失败
假设我们有一个粒子系统:
structParticle{floatx;floaty;floatz;floatvx;floatvy;floatvz;};然后:
Particle particles[N];更新位置:
particles[i].x+=particles[i].vx;看起来完全正常。
但如果仔细分析会发现。
CPU 实际读取的是:
x y z vx vy vz整个结构体。
而真正使用的只有:
x vx大量数据被搬运进 Cache。
却没有参与计算。
AoS 与 SIMD 的天然冲突
这种布局被称为:
AoS Array of Structures即:
Particle Particle Particle Particle连续排列。
对于人类而言非常自然。
因为它符合对象思维。
但对于 SIMD 来说并不友好。
假设要同时处理 8 个粒子的:
x坐标。
理想情况应该是:
x0 x1 x2 x3 x4 x5 x6 x7连续存储。
但实际内存布局是:
x y z vx vy vz x y z vx vy vz x y z vx vy vz ...此时 CPU 无法直接连续读取。
只能进行:
Gather操作。
而 Gather 往往远比普通 Load 更昂贵。
于是出现一种情况:
AVX 很快 Gather 很慢最终整体性能并没有提升多少。
SoA 才是 SIMD 的真正朋友
将数据改成:
structParticleStorage{floatx[N];floaty[N];floatz[N];floatvx[N];floatvy[N];floatvz[N];};此时:
x0 x1 x2 x3 x4 x5 x6 x7天然连续。
AVX 可以直接:
__m256 px=_mm256_load_ps(&x[i]);__m256 pv=_mm256_load_ps(&vx[i]);px=_mm256_add_ps(px,pv);_mm256_store_ps(&x[i],px);整个过程不需要 Gather。
数据连续。
Cache 友好。
预取器容易工作。
此时 SIMD 才真正发挥作用。
Cache Miss 才是真正的敌人
考虑下面这段代码:
for(inti=0;i<count;++i){result+=array[randomIndex[i]];}即使使用:
AVX2 AVX512效果也可能极差。
原因很简单。
访问模式是随机的。
CPU 无法预测下一次访问位置。
于是不断发生:
L1 Miss L2 Miss L3 Miss DRAM Access每一次 Miss 的代价都远高于一次浮点加法。
因此:
SIMD 无法拯救糟糕的数据访问模式。
为什么很多游戏引擎先做 SoA 再做 SIMD
很多新人优化顺序是:
AVX AVX2 AVX512 手写 Intrinsics但大型引擎通常完全相反。
正确顺序往往是:
数据布局 Cache Locality 批处理 SIMD原因很简单。
如果数据布局错误。
那么:
再宽的向量寄存器也无法消除 Cache Miss。
而如果数据布局正确。
很多时候编译器甚至能够自动生成 SIMD 指令。
性能已经足够优秀。
ECS 为什么喜欢 SoA
很多人学习 ECS 时会发现:
组件通常被存储为:
Position[]Velocity[]Rotation[]而不是:
Entity{Position Velocity Rotation}原因之一就是 SIMD。
例如:
for(...){position+=velocity*dt;}此时:
Position[] Velocity[]都是连续内存。
CPU 可以:
预取 流水线 SIMD全部同时发挥作用。
因此 ECS 获得的收益往往不是来自某个神奇算法。
而是来自:
更适合现代硬件的数据布局GPU 其实也是 SIMD 机器
如果把视角扩大一点。
会发现 GPU 其实也遵循同样规律。
例如:
NVIDIA 的 Warp:
32 线程AMD 的 Wavefront:
64 线程本质上都是:
Single Instruction Multiple Data思想。
它们希望:
大量数据 执行同样指令从而获得最高吞吐率。
因此:
CPU SIMD GPU Warp ECS Archetype看起来属于不同领域。
实际上都在追求同一个目标:
提高数据并行度SIMD 优化真正的顺序
实际项目中。
SIMD 往往不是第一步。
而是最后一步。
更合理的优化顺序通常是:
1. 找到热点 2. 改善算法 3. 改善数据布局 4. 提高 Cache 命中率 5. 减少内存访问 6. 批处理数据 7. SIMD很多时候。
前六步带来的收益远远超过第七步。
甚至在完成前六步之后。
编译器已经能够自动生成高质量 SIMD 代码。
一个经验法则
如果你的代码满足:
连续访问 大量重复计算 相同操作 少分支那么 SIMD 通常非常有效。
例如:
粒子更新 矩阵运算 图像处理 音频处理 物理计算而如果代码充满:
随机访问 复杂分支 指针跳转 对象图遍历那么首先应该思考:
数据布局是否合理而不是立即开始写 AVX Intrinsics。
结语
很多人学习 SIMD 时。
首先看到的是:
_mm256_add_ps _mm256_mul_ps _mm512_fmadd_ps这些指令。
于是认为:
SIMD 优化就是学习更多 Intrinsics。
但在真实项目中。
真正决定性能的往往不是这些指令。
而是:
数据布局 Cache 命中率 访问连续性 内存带宽因此:
SIMD 不是几条神奇指令。
SIMD 是数据组织方式的自然结果。
当数据布局正确时,SIMD 会顺理成章地发挥作用。
当数据布局错误时,再宽的向量寄存器也救不了系统。
很多时候,性能优化的关键并不是:
如何写 AVX而是:
如何让数据更容易被 AVX 使用而这,才是 SIMD 优化真正的开始。
