循环展开的性能权衡测试及相关技术疑问
循环展开优化的性能分析与疑问
测试函数
int sumElements(int *arr, int count) { int sum = 0; for (int i = 0; i < count; ++i) { sum += arr[i]; } return sum; } int sumElementsUnrolled(int *arr, int count) { int sumA = 0; int sumB = 0; for (int i = 0; i < count; i += 2) { sumA += arr[i]; sumB += arr[i + 1]; } return sumA + sumB; }
基准测试结果(-O0/-O1优化级别)
-------------------------------------------------------------- Benchmark Time CPU Iterations -------------------------------------------------------------- sumElements 965 ns 963 ns 723250 sumElementsUnrolled 641 ns 641 ns 1091406
在-O0和-O1优化级别下,循环展开版本sumElementsUnrolled获得了显著的性能提升。推测这源于依赖链的打破,可减少CPU执行指令时的流水线停顿,且-O1优化级别下生成的汇编代码也支持该推测。但执行perf stat后发现,循环展开版本的stalled-cycles-backend占比更高,无法确定其相关性,因此提出以下技术疑问:
- 上述性能提升原因的推测是否正确?
- 若推测正确,如何在此场景下测量流水线停顿?
- 若推测不正确,性能提升的真实原因是什么?CPU层面打破依赖链有何作用?
基准测试实现代码
#define SIZE 4096 int *initArray(int size) { int *arr = (int*)malloc(size * sizeof(int)); for (int i = 0; i < size; ++i) { arr[i] = rand(); } return arr; } void doBenchmark(benchmark::State &s) { int *arr = initArray(SIZE); for (auto _ : s) { int sum = sumElements(arr, SIZE); benchmark::DoNotOptimize(sum); } free(arr); } BENCHMARK(doBenchmark);
解答
1. 推测正确性判断
你的推测是正确的。未展开的循环中,sum += arr[i]形成了一条强依赖链:每次加法必须等待前一次加法的结果写入寄存器后才能执行,这种单链会导致CPU流水线频繁停顿——因为流水线需要连续的指令流维持高效运转,等待依赖结果的过程会让流水线空转。
循环展开后,sumA和sumB各自形成独立的依赖链,CPU的乱序执行单元可以同时调度两条链上的加法操作,充分利用超标量CPU的多执行单元,减少了因依赖等待导致的停顿,最终提升了性能。
2. 流水线停顿的测量方法
针对该场景,可通过以下方式精准测量流水线停顿:
- 使用
perf stat统计细分事件:
执行命令:
对比两个版本的指标:perf stat -e stalled-cycles-frontend,stalled-cycles-backend,cycles,instructions ./your_benchmark_binarystalled-cycles-frontend:指令取指/译码阶段的停顿周期stalled-cycles-backend:执行阶段因数据依赖、资源冲突导致的停顿周期
计算总停顿占比:(stalled-cycles-frontend + stalled-cycles-backend) / cycles,展开版本的该比例应低于未展开版本(即使stalled-cycles-backend占比高,也要结合总周期数——展开版本总周期更少,实际停顿的绝对周期可能更低)。
- CPU架构专用工具:
- Intel CPU:使用
ocperf.py(perf的Intel扩展),统计arithmetic_stalls、ld_blocks.store_forward等依赖相关的细分事件; - AMD CPU:使用
perf stat -e amd64/arith_divider_stalls/,amd64/load_hit_pre/等架构专属事件。
- Intel CPU:使用
- 流水线模拟与反汇编分析:
对比两个版本的汇编代码,使用Intel SDE等工具模拟流水线执行,查看每条指令的等待周期数,直观分析依赖导致的停顿。
3. 替代原因及依赖链的作用
如果推测不成立(概率极低,因为-O1下编译器自动展开的程度有限,手动展开仍有明确收益),可能的替代原因包括:
- 循环控制开销减少:展开后的循环迭代次数减半,
i += 2和循环条件判断的总指令数减少,节省了部分执行时间; - 预取效率提升:连续访问的数组在展开后,CPU预取器可能更高效地提前加载后续数据,但该影响远小于依赖链打破的作用。
CPU层面打破依赖链的核心作用:
- 充分利用超标量执行能力:现代CPU一个周期可执行多条指令,独立依赖链让CPU能并行调度多条加法操作;
- 减少数据相关停顿:避免因等待前一条指令结果导致的流水线空转;
- 提升乱序执行效率:独立依赖链给指令调度单元提供了更多可并行执行的指令选择,最大化CPU资源利用率。
内容的提问来源于stack exchange,提问作者LostCoder
相关产品推荐
相关产品推荐

