做性能优化的人应该都经历过这样一幕:核心循环逻辑已经拆到了极限,位运算、查表、手动 cache 对齐都做完了,可一跑性能分析,运行时间纹丝不动,CPU 占用率还低得吓人。我印象最深的是一次矩阵转置加直方图的后处理管线,表面上像是在“计算”,实际每一秒都压在内存总线上。这种场景下,_mm_stream_si128 这类 non-temporal store 指令才是真正值得拿出来的工具。
写这篇文章的起因很简单:最近又在几个 Memory-Bound(内存受限)的算法里看到同行把代码优化方向搞偏了,对着 CPU 指令死磕,却忘了数据搬运早就把时间预算吃满了。所以我想把这几年用 _mm_stream_si128 做内存写入加速的经验整理出来,包括它的底层原理、什么时候有效、什么时候别碰,以及一份可以直接跑的基准代码。
1. 先别急着改循环:怎么确认当前算法真的被内存卡住
1.1 用 perf 一眼看出 CPU 在等内存
我判断一段热点代码是不是 Memory-Bound,第一步不是翻代码,而是跑一次 perf 或者 VTune,看两个指标:CPI(cycles per instruction)和 cache miss 率。假设你在 Linux 下面有一个叫 bench 的程序,直接执行:
bash复制perf stat -e task-clock,cycles,instructions,cache-misses,LLC-load-misses ./bench
如果出来的结果里,instructions 数量并没有你想象中那么多,但 cycles 非常夸张,CPI 甚至超过 2,而 LLC-load-misses 又居高不下,那基本可以断定:CPU 执行单元大部分时间都处于 stall 状态,在等数据从内存里挪过来。这种等待跟你的乘法指令是不是少了一条、分支预测是不是多猜错了一次,已经没有太大关系了。
现代 CPU 的乱序执行能力很强,只要指令之间有足够的 ILP(Instruction-Level Parallelism),流水线通常不会空转。真正让它空转的是“访存没回来”。很多 Memory-Bound 算法的 CPU 占用率看上去只有 40%-60%,不是核心在偷懒,而是 load/store 队列已经塞满,后面的指令全被堵住了。
1.2 算一下算术强度,别靠感觉猜测
“Memory-Bound”这个词在工作里被滥用得很厉害。有人一看到 cache miss 高就说内存受限,然后开始乱加 prefetch,结果反而变慢。更靠谱的判断方式,是算一下目标循环的 arithmetic intensity(算术强度):
text复制AI = 循环内有效计算量 / 该循环搬运的数据字节数
如果一段代码从内存读一个 int,做一次加法,再写回内存,那它每搬运 8 字节只做了 1 次计算,AI 大约等于 0.125 ops/byte。这个值远低于现代 CPU 的 machine balance(峰值算力除以内存带宽得出的一个阈值),基本就是 Memory-Bound。反过来,如果一个像素要跑几十次乘加,AI 很高,那瓶颈更可能在计算单元。用 Roofline model 可以画得很清楚,不想上工具的话,我在实践里还有个更粗暴但很有效的办法:
text复制把循环体里的计算改成一个空操作,只保留访存。
如果耗时几乎不变,瓶颈就是内存。
数组清零、大块 memcpy、图像帧搬运、矩阵整块赋值,这些操作本质上就是搬运数据,AI 趋近于 0。此时你去优化循环里的指令顺序,收益上限非常低;优化内存写入路径,才是把时间真正压下去的地方。
1.3 一个很容易被忽略的边界条件
Memory-B
