1. 一次优化实测:从图像处理里挖出的性能黑洞
1.1 一段看起来正常的 SIMD 代码
先说个我自己的经历。前几年我在调一段图像滤镜代码,逻辑不复杂:读入一张大图的像素,做一个查表变换,再写回输出缓冲。最初版本是用标量循环写的,后来为了提速改成 SSE 向量化,核心代码差不多长这样:
c复制#include <immintrin.h>
void transform_rgba(unsigned char *dst,
const unsigned char *src,
int width, int height) {
size_t total = (size_t)width * height * 4;
size_t i = 0;
for (; i + 16 <= total; i += 16) {
__m128i pixel = _mm_loadu_si128((const __m128i *)(src + i));
__m128i r = _mm_and_si128(pixel, _mm_set1_epi32(0xFF));
// 这里做一连串查表和运算,省略细节
__m128i result = ...; // 输出仍是 RGBA
_mm_storeu_si128((__m128i *)(dst + i), result);
}
for (; i < total; i++) dst[i] = lut[src[i]];
}
编译开满优化后,我以为性能已经不错了。可实际一测 4K 分辨率的 RGBA 图,单帧耗时反而比手写标量版本好不了多少,跟理论上 SIMD 的 4 路并行完全不成正比。CPU 单核占用是满的,但内存控制器的带宽并没有跑满,这就很让人困惑。
后来我用性能分析工具跑了一圈,发现程序大部分时间停顿在访存阶段,算术指令的吞吐量远没有到极限。换句话说,这段代码不是“算不过来”,而是“等内存”。这正是 Memory-Bound 类算法的典型特征:计算单元在大部分时间里处于等待状态,瓶颈不在 CPU 主频,而在内存系统的读写流量。
1.2 为什么 CPU 占用很高但耗时很长
很多人一看到 CPU 占用率高,就以为瓶颈在计算。其实在内存受限场景下,CPU 占用率反而可能是 100%,因为它要反复发起访存指令并等待数据返回,只是这些“等待”消耗的不是执行单元,而是流水线停顿。你观察到的结果就是:占用率高、功耗高、性能上不去。
这类问题的几个信号很明确:
- 数据规模远大于 L2/L3 缓存,工作集动辄几十 MB 甚至上 GB;
- 算法对每个元素的“计算量”很低,比如拷贝、变换、清零、阈值化;
- 一个循环体内累加消耗的时间,和内存带宽强相关;
- 用
perf stat -d查看时,stalled-cycles-backend占比很高。
我当时拿到的指标里,后端停顿时钟周期占了 60% 以上,内存带宽理论值约 20GB/s,可程序实际带宽只有一半。说明这块代码的确还有优化空间。今天要讲的 _mm_stream_si128,就是解决这类问题的一个非常锋利,但也非常容易用错的工具。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Memory-Bound 的底层逻辑:计算在等内存,还是内存在等计算
2.1 一个简单的带宽模型
要理解 _mm_stream_si128 为什么有效,先要建立一个带宽思维模型。
假设你要处理 1GB 的数据,CPU 每秒能执行 10 亿次简单运算,内存带宽是 20GB/s。如果每个元素只做一次加法,那么单核 CPU 处理 1GB 大约需要 1 秒(10 亿次),而光把 1GB 数据从内存搬到 CPU 就需要 0.05 秒。看起来 CPU 更慢?实际上现代 CPU 的单个核心并不一定能每秒执行 10 亿次访存循环,因为每次内存访问都有延迟,而普通 SIMD 循环里如果只算一次加法,访存等待会吃掉指令流水的绝大部分时间。
更准确的计算方式应该是这样:
- 整个过程的总数据流量 = 读流量 + 写流量;
- 理论最短时间 = 总数据流量 / 内存带宽;
- 真实时间还会受缓存命中率、DRAM 行激活、NUMA 距离影响。
如果按这个公式算出来,程序实际耗时接近理论值,那它就是 Memory-Bound 的典型形态。如果算出来的理论耗时远
