1.5 程序性能优化
为什么同样的算法,换一种循环写法能快 3 倍?编译器为什么不敢替你重排这段代码?循环展开和多累加器到底在优化什么?perf 报出的 IPC、cache-miss、branch-miss 该先看哪个?
用 CPE 与关键路径估算循环下界;判断编译器优化的边界(别名、内联、UB);写出展开、多累加器、分块、SoA 的代码;用 perf 找到热点并验证优化是否真的有效。
性能优化最容易犯的错误是「凭直觉改代码」。直觉通常盯着指令条数,而现代 CPU 的瓶颈在依赖链、缓存缺失、分支失败、内存带宽——这四者与指令条数的关系都很弱。一个循环从 100 条指令减到 80 条,可能毫无变化;把一条依赖链拆成四条,可能直接快 4 倍。
这一章讲一套可复现的方法:先定义度量,再找瓶颈,再针对瓶颈改写,最后用同一基准验证。所有例子都以 x86-64、GCC 13 -O2 为背景;ARM64 的结论类似,延迟数字不同。文中的「周期」指 CPU 核心时钟周期,具体纳秒数随频率变化。
前置提醒:优化必须在正确性之后。开启 -ffast-math、手写无分支、改变求和顺序,都可能改变浮点结果;涉及金额、科学计算、可复现性时,先确认误差预算。
1.5.1 度量:CPE、延迟与吞吐
描述循环性能最常用的单位是「每元素周期数」(cycles per element, CPE)。对一个处理 个元素的循环,若总周期是 ,则
CPE 把「循环开销」摊到每个元素上,便于比较不同规模的实现。若循环有固定的启动开销 ,则 ,CPE 随 增大趋近 。测 CPE 时要让 足够大,且重复多次取最小值,避免冷缓存与调度噪声。
三个概念要分清:
| 概念 | 含义 | 例子 |
|---|---|---|
| 延迟(latency) | 一条操作从输入到输出的时间 | add 1 周期,imul 3 周期,L1 load 4–5 周期 |
| 吞吐(throughput) | 每周期能完成多少条操作 | 4 个 ALU 端口,每周期可发射 4 条整数加法 |
| CPE | 每元素实际消耗的周期 | 受延迟链、端口数、缓存共同影响 |
若一个循环每个元素只做一次加法、且加法依赖上一次结果,则 CPE 下界是加法延迟 1;若做一次乘法且只有一个累加器,CPE 下界是乘法延迟 3;若有 4 个独立累加器,CPE 下界变成 。先算下界,再判断还有多少优化空间。
基准测试的最低要求:
/* 用 CLOCK_MONOTONIC 计时,重复 R 次取最小值,防止频率/调度抖动 */
for (int r = 0; r < R; r++) {
uint64_t t0 = now_ns();
result = kernel(data, n);
uint64_t t1 = now_ns();
best = (r == 0 || t1 - t0 < best) ? (t1 - t0) : best;
}
printf("%.3f ns/elem, checksum=%llu\n", (double)best / n, result);
clock_gettime(CLOCK_MONOTONIC) 的分辨率通常足够(纳秒级字段),但单次测量仍有噪声;取多次最小值比取平均更接近「无干扰」的硬件性能。用 perf stat 可以同时看周期数与指令数,避免频率变化干扰。
数据依赖图中从循环入口到出口的最长路径。循环的 CPE 不可能低于关键路径上各操作延迟之和除以迭代次数(若路径跨迭代)。多累加器、循环展开、重结合(reassociation)的本质,都是把一条长关键路径拆成多条短路径,让 CPU 的多个执行端口同时工作。
1.5.2 编译器能做什么、不能做什么
编译器在 -O2 下会做常量传播、死代码消除、公共子表达式消除、强度削减、循环不变量外提、内联、自动向量化(-O3/-ftree-vectorize)、指令调度。它不会做的是「改变程序可观察行为」的优化——而 C 的未定义行为、指针别名、外部函数调用,都会限制它的手脚。
别名(aliasing) 是最常见的限制。看这个函数:
void scale_add(int *a, int *b, int n)
{
for (int i = 0; i < n; i++)
a[i] = a[i] * 2 + b[i];
}
编译器不能把 b[i] 缓存到寄存器后重排整个循环,因为 a 和 b 可能指向同一块内存(甚至 a + 1 == b)。每次写 a[i] 都可能改变 b[i+1],所以它必须在每次迭代重新加载。加上 restrict 告诉编译器「两者不重叠」:
void scale_add(int *restrict a, const int *restrict b, int n)
{
for (int i = 0; i < n; i++)
a[i] = a[i] * 2 + b[i];
}
restrict 是程序员对编译器的承诺;若违反(两个指针确实重叠),行为未定义。它常用于高性能库(BLAS、图像处理),因为收益明显:编译器可以向量化、把 load 提前、减少内存往返。
比较两版汇编,差别很直观:
# 无 restrict:每次迭代重新加载 b[i]
.L3:
movsd (%rdi,%rax,8), %xmm0 # a[i]
addsd %xmm0, %xmm0
addsd (%rsi,%rax,8), %xmm0 # b[i],不能缓存
movsd %xmm0, (%rdi,%rax,8) # 写回 a[i]
incq %rax
cmpq %rdx, %rax
jne .L3
# 有 restrict:编译器可把 b[i] 批量 load,甚至向量化
.L7:
movupd (%rsi,%rax,8), %xmm1
movupd (%rdi,%rax,8), %xmm0
addpd %xmm0, %xmm0
addpd %xmm1, %xmm0
movupd %xmm0, (%rdi,%rax,8)
addq $2, %rax
cmpq %rdx, %rax
jne .L7
无 restrict 的版本每次迭代都有一次 load、一次 store;有 restrict 的版本用 movupd/addpd 一次处理两个 double。同一份 C,只差一个关键字,生成两套代码。这就是「让编译器知道你的意图」的价值。
函数调用 是另一个限制。循环里调用 strlen(s),若编译器不能证明 s 指向的字符串在循环中不变(比如循环体调用了可能修改它的函数),就只能每次重新计算:
for (int i = 0; i < strlen(s); i++) /* 可能每次迭代都扫描字符串 */
process(s[i]);
改成 size_t n = strlen(s); for (i = 0; i < n; i++) 是最基本的「消除循环低效」。编译器有时能把 strlen 提升(loop-invariant code motion),但前提是它能证明 s 不被修改;跨翻译单元、经过函数指针、或有 volatile 时,它不敢动。
内联 让小函数消失,把调用开销变成指令调度机会。static inline 只是建议,编译器有权拒绝;-flto(链接时优化)能跨 .c 文件内联;always_inline 属性可以强制,但滥用会让代码膨胀、I-cache 压力上升。判断是否内联看 objdump 里有没有 call,而不是看源码写没写 inline。
- 「
-O3一定比-O2快」——-O3的向量化与展开可能增加代码体积、降低 I-cache 命中,某些负载反而变慢。要实测。 - 「
volatile能让代码更快/更安全」——volatile只保证每次访问都真的发生,阻止编译器缓存;它不提供原子性、不提供内存屏障,通常会让性能变差。 - 「编译器会自动把
a*a*a*a优化成(a*a)*(a*a)」——浮点下不会,因为改变结合顺序会改变结果(除非-ffast-math)。整数下若溢出是 UB,编译器可能重排,但不能依赖。 - 「
inline一定展开」——inline是建议;递归函数、取地址的函数、过大的函数通常不会展开。看汇编确认。
1.5.3 消除循环低效与重复计算
优化的第一步不是展开,而是删掉不必要的工作。常见三类:
- 循环不变量:每次迭代都算同一个值。
- 重复调用:每次迭代都调用
strlen、sqrt、malloc。 - 低效的抽象:用
%取模做循环缓冲、用vector::at做边界检查、用字符串拼接做日志。
一个综合例子:
#include <stdio.h>
#include <stdint.h>
#include <time.h>
#include <stdlib.h>
#define N (1u << 20)
static double a[N], b[N];
static volatile double sink;
static double now_sec(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
/* 版本 1:每次迭代重新计算长度,单累加器 */
static double sum_naive(size_t n)
{
double s = 0.0;
for (size_t i = 0; i < n; i++)
s += a[i] * b[i];
return s;
}
/* 版本 2:四个独立累加器,打破依赖链 */
static double sum_unrolled(size_t n)
{
double s0 = 0, s1 = 0, s2 = 0, s3 = 0;
size_t i = 0;
for (; i + 4 <= n; i += 4) {
s0 += a[i] * b[i];
s1 += a[i + 1] * b[i + 1];
s2 += a[i + 2] * b[i + 2];
s3 += a[i + 3] * b[i + 3];
}
for (; i < n; i++)
s0 += a[i] * b[i];
return (s0 + s1) + (s2 + s3);
}
int main(void)
{
for (size_t i = 0; i < N; i++) {
a[i] = (double)(i % 1000) / 1000.0;
b[i] = 1.0 - a[i];
}
double t0 = now_sec();
double r1 = sum_naive(N);
double t1 = now_sec();
double r2 = sum_unrolled(N);
double t2 = now_sec();
sink = r1 + r2;
printf("naive: %.3f ns/elem\n", (t1 - t0) * 1e9 / N);
printf("unrolled: %.3f ns/elem\n", (t2 - t1) * 1e9 / N);
printf("diff=%.3g\n", r1 - r2);
return 0;
}
在 -O2 下,sum_naive 的关键路径是「load a、load b、乘、加」串行,每次迭代都要等上一次的 s。sum_unrolled 把累加拆成 4 条独立链,理论上 CPE 降到约 1/4(受 load 吞吐与乘法端口限制)。实数结果可能略有差异,因为浮点加法顺序变了;若要求逐位一致,就不能这样重排。
sum_naive 里其实没有 strlen 那种重复调用,但「单累加器」本身就是低效:它把 4 个可以并行的操作串成了一条链。并行度不是只有多线程才有,指令级并行(ILP)就在循环体内。
1.5.4 减少调用与消除不必要的内存引用
函数调用有固定开销:保存/恢复寄存器、建立栈帧、传递参数、跳转。对一次调用几纳秒的小函数,在高频循环里累积起来很可观。内联或把函数体移到循环外,是常见做法。
更隐蔽的是「不必要的内存引用」。看一个累加:
for (i = 0; i < n; i++)
*dest += src[i]; /* 每次迭代读一次 *dest,写一次 *dest */
dest 指向的内存可能被 src[i] 的读取别名(编译器不知道),所以它不能把 *dest 缓存在寄存器里。改成局部变量:
double acc = *dest;
for (i = 0; i < n; i++)
acc += src[i];
*dest = acc;
现在累加在寄存器里,循环结束才写回一次。这个变换叫「把内存变量提升为寄存器变量」(scalar replacement)。编译器在能证明无别名时会自动做;不能证明时,restrict 或局部变量是手动版。
另一个例子是结构体数组(AoS)与数组结构体(SoA):
struct particle { float x, y, z, mass; }; /* AoS:一个粒子 16 字节 */
struct particle p[N];
/* 只更新 x,却把 y/z/mass 也拉进缓存行 */
for (int i = 0; i < N; i++)
p[i].x += p[i].mass * dt;
若只频繁访问 x,AoS 会让每个缓存行里 3/4 的数据是无用的;改成 float x[N], y[N], z[N], mass[N](SoA),同样 64 字节缓存行能装 16 个 x,有效带宽提高 4 倍。代价是「一个粒子的所有字段」不再连续,跨字段操作要多几次访存。布局选择取决于访问模式,没有绝对优劣。
L1 命中约 4–5 周期,L2 约 12–20 周期,L3 约 40–70 周期,DRAM 约 200–300 周期。寄存器访问是 0 周期(在指令里)。把一次 DRAM 访问变成寄存器访问,省下的时间相当于几百条 ALU 指令。所以优化的优先级通常是:减少缓存缺失 > 减少内存引用 > 减少指令数。
1.5.5 循环展开与多累加器
循环展开(loop unrolling)把多次迭代合并成一次,减少循环控制开销(比较、跳转、指针更新),并为编译器提供更多指令调度空间。展开本身不改变依赖链;要打破依赖链,还要多累加器。
单累加器(关键路径 4 周期/元素):
s ──add──> s ──add──> s ──add──> s
↑ 每次都要等上一次
四累加器(关键路径 1 周期/元素,理想):
s0 ──add──> s0 s0 += a[i]
s1 ──add──> s1 s1 += a[i+1]
s2 ──add──> s2 s2 += a[i+2]
s3 ──add──> s3 s3 += a[i+3]
四条链独立,最后合并一次
展开的收益有上限:展开太多会增加寄存器压力、代码体积、I-cache 缺失;若循环次数不是展开因子的倍数,还要处理尾部。通常展开 2–8 次,配合 2–8 个累加器。
一个浮点乘加的延迟模型:假设 add 延迟 4 周期、吞吐 2/周期,mul 延迟 4 周期、吞吐 2/周期,load 延迟 4 周期、吞吐 2/周期。单累加器时,每次迭代的 s += a[i]*b[i] 依赖上一次 s,CPE 下界是 4;4 个累加器时,下界是 。实际还会受 load 端口限制,可能到 1.5–2。
编译器在 -O2 下不一定会自动做浮点多累加器,因为改变加法顺序会改变结果;-ffast-math 或 -fassociative-math 才允许。整数加法满足结合律(在无溢出的前提下),编译器更愿意展开。如果你的浮点循环没有变快,先看是不是编译器不敢重排,而不是 CPU 不行。
- 确认循环次数大、迭代间无真依赖;
- 选择展开因子 ,使 单次操作数不超过可用寄存器;
- 用 个独立累加器,最后合并;
- 处理尾部(
i + k <= n之后再用小循环); - 用
-O2与-O2 -fno-tree-vectorize各测一次,区分「展开收益」与「向量化收益」; - 检查汇编,确认没有把累加器又合并回一条链。
1.5.6 ILP、关键路径与延迟界
指令级并行(ILP)是「同一时刻能执行多少条独立指令」。它受三个因素限制:
- 数据依赖:真依赖(RAW)必须串行;
- 资源冲突:执行端口、load/store 单元、寄存器数量有限;
- 控制依赖:分支让取指不确定。
对一条依赖链,CPE 的下界是链上延迟之和除以迭代次数。例如:
| 运算 | 延迟(周期) | 吞吐(条/周期) |
|---|---|---|
| 整数加/减 | 1 | 4 |
| 整数乘 | 3 | 1 |
| 整数除 | 20–40 | 1/4–1/20 |
| 浮点加/乘 | 4 | 2 |
| 浮点除 | 15–20 | 1/4 |
| L1 load | 4–5 | 2 |
| L2 load | 12–20 | 1 |
| DRAM load | 200–300 | 1/10 |
用这张表算:一个循环做 s += a[i] * b[i],整数版延迟是「load、mul、add」串行,约 周期;但 load 可以提前,乘法与加法也可以流水,真正的关键路径是 s 的加法链,CPE 下界是 1(整数加延迟 1)。若只有一个累加器,CPU 每周期只能推进一次加法,理论 CPE 1;若有 4 个累加器,仍受加法吞吐 4/周期限制,CPE 可以接近 1,但不会低于 1。依赖链和吞吐要一起看,取较大者。
浮点版同理:s 的加法延迟 4,单累加器 CPE ≥ 4;4 个累加器后,CPE ≥ max(4/4, 1/2)=1。这就是「多累加器能把 CPE 从 4 降到 1」的完整推导。
朴素 C[i][j] += A[i][k] * B[k][j] 的内层循环,每次迭代做一次乘、一次加、两次 load(A[i][k] 可缓存在寄存器,B[k][j] 随 k 变)。若 C[i][j] 留在寄存器,关键路径是浮点加(4 周期);用 4 个累加器分别累加 k 的四个分量,CPE 可降到约 1。但 B[k][j] 的访问步长是 ,每次都可能缓存缺失;实际瓶颈从 ALU 转到内存。第 1.6 章会用分块(blocking)解决。
1.5.7 分支友好与无分支代码
分支本身不慢,预测失败才慢。一个循环里的分支若方向随机,每次失败 12–20 周期;若方向固定,几乎零成本。优化分支有三个方向:
- 让分支可预测:把最常见的情况放在前面,用
__builtin_expect提示编译器;把数据按条件排序后再处理(经典技巧:先qsort再统计,反而比随机顺序的分支快)。 - 消除分支:用掩码、
cmov、查表、算术代替if。 - 把分支移到循环外:特化(specialization)——对「全是正数」「全是负数」等情况各写一个循环。
无分支求绝对值的例子:
int abs_branch(int x) { return x < 0 ? -x : x; }
int abs_mask(int x)
{
int mask = x >> 31; /* 负数全 1,非负全 0 */
return (x ^ mask) - mask; /* 负数取反加一,非负不变 */
}
abs_mask 对 INT_MIN 仍是 UB(取负溢出),但避免了分支。编译器通常会把 abs_branch 编译成 cmov 或 sar/xor/sub,所以手写不一定更快;用 objdump 确认。
__builtin_expect 只影响代码布局(把热路径放直),不改变语义:
if (__builtin_expect(err != 0, 0)) {
/* 冷路径:错误处理 */
}
在现代 CPU 上,静态提示的作用有限,动态预测器会学习实际方向;但在解释器、协议解析等「分支历史复杂」的场景,布局仍有影响。
- 「无分支一定快」——无分支要计算两个分支的结果,若其中一个很贵(如除法、函数调用),反而更慢。
- 「
if写成一行就会生成cmov」——不一定。编译器会根据代价模型选择;可能生成分支,也可能生成cmov。看汇编。 - 「
likely/unlikely能显著提速」——它们只改布局,收益通常个位数百分比;真正的收益来自减少不可预测分支或改变数据顺序。
1.5.8 存储层次优化:分块、SoA 与预取
当 CPE 的下界由 ALU 转向内存,优化手段就变成「提高局部性」。三个最常用:
分块(blocking/tiling)。矩阵乘 C = A * B 的朴素三重循环,对 B 的访问是列优先,步长 ,缓存不友好。把矩阵切成 的小块,使一块 A、一块 B、一块 C 能同时放进 L1/L2,块内复用:
for (ii = 0; ii < N; ii += BS)
for (jj = 0; jj < N; jj += BS)
for (kk = 0; kk < N; kk += BS)
for (i = ii; i < ii+BS; i++)
for (j = jj; j < jj+BS; j++)
for (k = kk; k < kk+BS; k++)
C[i][j] += A[i][k] * B[k][j];
块大小 要让 字节(三个块)不超过 L2(比如 256 KiB → ,取 64 或 96)。分块把「每次乘加都缺缓存」变成「块内复用」,是 BLAS 能跑到峰值的重要原因。
下面用 512×512 的矩阵对比两种循环顺序,ikj 把 C[i][j] 留在寄存器、对 B[k][j] 按行访问,明显比 ijk 的列访问快:
#include <stdio.h>
#include <time.h>
#define N 512
static double A[N][N], B[N][N], C[N][N];
static double now_sec(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
static void fill(void)
{
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++) {
A[i][j] = (double)((i * 31 + j * 17) % 100) / 100.0;
B[i][j] = (double)((i * 13 + j * 7) % 100) / 100.0;
}
}
static void mul_ijk(void)
{
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++) {
double s = 0.0;
for (int k = 0; k < N; k++)
s += A[i][k] * B[k][j]; /* B 的访问步长 N,缓存不友好 */
C[i][j] = s;
}
}
static void mul_ikj(void)
{
for (int i = 0; i < N; i++)
for (int k = 0; k < N; k++) {
double a = A[i][k];
for (int j = 0; j < N; j++)
C[i][j] += a * B[k][j]; /* B 与 C 都按行访问 */
}
}
int main(void)
{
fill();
double t0 = now_sec();
mul_ijk();
double t1 = now_sec();
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++) C[i][j] = 0.0;
mul_ikj();
double t2 = now_sec();
printf("ijk: %.3f s\nikj: %.3f s\n", t1 - t0, t2 - t1);
printf("checksum=%f\n", C[0][0] + C[N-1][N-1]);
return 0;
}
ikj 的顺序把内层循环变成对 B[k][j] 和 C[i][j] 的连续访问,每次 load 都命中同一缓存行;ijk 的内层每次跳 KiB,缓存行利用率只有 1/8。分块则进一步让「块内」的 A、B、C 同时驻留 L2,减少对 DRAM 的往返。
SoA/AoS 已在 1.5.4 提过。对 SIMD 友好的布局通常是 SoA:连续的同类型数据可以一次加载 4/8 个,向量化不需要 gather。
预取(prefetch)。硬件预取器能识别顺序/固定步长,但对指针追逐、哈希表、稀疏矩阵无效。软件预取 __builtin_prefetch(p, 0, 3) 提示 CPU 提前把数据拉进缓存;代价是指令开销与可能的缓存污染。预取距离要够远(覆盖内存延迟),又不能太远(数据被换出)。
对「写一次、不再读」的大数组,普通 store 会先读缓存行(RFO, read-for-ownership),浪费带宽。x86 的 movntdq(非临时存储)绕过缓存,直接写内存;C 里可用 _mm_stream_si128 或 #pragma omp simd 的 nontemporal 子句。它适合流式写(视频编码、大规模初始化),不适合随后还要读的数据。
1.5.9 用 perf 定位瓶颈与优化清单
perf 是 Linux 上最直接的工具。先看总量,再看热点,最后看指令级:
perf stat -d ./prog
perf record -g ./prog && perf report --stdio
perf annotate --stdio -s symbol_name
perf stat -d 的关键字段:
| 字段 | 含义 | 高值说明 |
|---|---|---|
cycles / instructions | IPC | 低 IPC:流水线停顿 |
cache-references / cache-misses | 最后一级缓存 | 高缺失:访存瓶颈 |
branch-misses | 分支失败率 | 高:控制流不可预测 |
stalled-cycles-frontend | 前端停顿 | 取指/译码供不上 |
stalled-cycles-backend | 后端停顿 | 执行单元/内存等待 |
一次 perf stat -d 的典型输出:
$ perf stat -d ./matmul
12.34 seconds time elapsed
3,456,789,012 cycles
2,345,678,901 instructions # 0.68 insn per cycle
456,789,012 branches # 37.0 M/sec
1,234,567 branch-misses # 0.27% of all branches
1,234,567,890 L1-dcache-loads
234,567,890 L1-dcache-load-misses # 19.00% of all L1-dcache accesses
12,345,678 LLC-loads
9,876,543 LLC-load-misses # 80.00% of all LL-cache accesses
这组数字说明:分支不是问题(0.27%),L1 缺失 19%、LLC 缺失 80%,瓶颈在访存。matmul 应该做分块与循环交换,而不是改分支预测。
一个排查顺序:
- 确认基准可信:固定 CPU 频率(
cpupower frequency-set -g performance)、绑核(taskset -c 2)、多次取最小值、关闭其他负载。 - 看 IPC:接近 0.2 说明严重停顿;接近 1–2 说明流水线较满;大于 2 说明超标量生效。
- 看缓存:LLC 缺失高 → 分块/SoA/预取;L1 缺失高但 LLC 低 → 访问模式或对齐问题。
- 看分支:
branch-misses高 → 数据排序、无分支、特化。 - 看前端:
stalled-cycles-frontend高 → 代码体积、I-cache、过多内联。 - 看指令:
perf annotate找最热的指令,确认是 load、乘除、还是分支。 - 改一处,重测:一次只改一个变量,记录前后 CPE 与 IPC。
- 换算法/数据结构(O(n²) → O(n log n)、哈希替代线性查找)——收益最大,先做;
- 减少内存流量(SoA、压缩、避免不必要的拷贝);
- 提高缓存局部性(分块、循环交换、预取);
- 打破依赖链(多累加器、展开、重结合);
- 消除不可预测分支(排序、无分支、特化);
- 减少函数调用/边界检查(内联、
restrict、批量接口); - 最后才考虑手写 SIMD/汇编。
1.5.10 CPE 与性能模型:把「快了多少」算出来
这一节回答:优化前后到底差多少、理论上还能快多少?答案要用两个数:CPE(每元素周期数)与延迟/吞吐。没有这两个数,「循环展开能提速」只是信仰。
CPE 的定义
对处理 个元素的循环,CPE(cycles per element)是
CPE 把「指令数、延迟、端口冲突」压成一个可比较的数。两个实现处理同样 个元素,CPE 从 7 降到 1.5,就是 4.7 倍;再乘上频率,才是墙钟时间。
延迟与吞吐
| 操作 | 典型延迟(周期) | 典型吞吐(每周期) | 说明 |
|---|---|---|---|
| 整数加/减 | 1 | 4 | 4 个 ALU 端口 |
| 整数乘 | 3 | 1 | 只有 1 个乘法器 |
| 浮点加 | 4 | 2 | 依赖 FADD 端口 |
| 浮点乘 | 4 | 2 | |
| FMA | 4 | 2 | 乘加一条指令 |
| L1 命中 load | 4–5 | 2 | 2 个 load 端口 |
| L2 命中 | 12–14 | — | |
| LLC 命中 | 30–40 | — | |
| DRAM | 200–300 | — | |
| 分支预测错误 | 15–20 | — | 流水线冲刷 |
数字是 Skylake 级别的量级,不同微架构会变,但关系不变:延迟决定依赖链,吞吐决定独立操作的并行度。
一条依赖链 s = s + a[i] 的 CPE 下界是加法延迟 1;即使 CPU 每周期能发射 4 条加法,链上的下一次加法也要等上一次结果。把累加器拆成 4 个,CPE 下界变成 ,但很快会撞上「每周期 2 个 load」的吞吐墙,实际下界约 0.5。优化的每一步都是换一个更低的界,直到撞上下一个资源。
一个循环处理 个 float,每次做一次 load、一次 FMA,单累加器。若 FMA 延迟 4 周期、load 吞吐 2/周期、FMA 吞吐 2/周期:
- 依赖链下界:4 CPE(单累加器)。
- 拆成 4 个累加器:依赖链下界 1 CPE;load 下界 CPE;FMA 下界 CPE。合并后下界 1 CPE(仍受依赖链限制)。
- 拆成 8 个累加器:依赖链 0.5 CPE,load 0.5,FMA 0.5 → 实际约 0.5–0.7 CPE。
- 再往上拆没有用,吞吐墙到了。
在 3.0 GHz 上,0.5 CPE 对应 ms。若实测 40 ms,说明还有别的东西(分支、边界检查、缓存缺失)在拖后腿。
Amdahl 定律:优化的上限
若程序中比例为 的部分被加速 倍,整体加速比是
、 时 ,不是 10。这条定律解释了为什么「只优化热点」是对的:非热点部分的 决定了天花板。用 perf record 找热点,就是在找那个 。
优化的顺序
| 顺序 | 手段 | 典型收益 | 代价 |
|---|---|---|---|
| 1 | 换算法() | 数量级 | 正确性风险 |
| 2 | 改善局部性(循环顺序、分块、SoA) | 2–10 倍 | 代码复杂度 |
| 3 | 消除重复计算/调用(提升、内联) | 1.2–2 倍 | 代码体积 |
| 4 | 提高 ILP(展开、多累加器) | 1.5–4 倍 | 寄存器压力 |
| 5 | 向量化(SIMD) | 2–8 倍 | 可移植性 |
| 6 | 微调指令选择(lea、cmov) | 几个百分点 | 可读性 |
顺序不能颠倒:先做 5、6 而算法是 ,等于给拖拉机换轮胎。每一层都要用 CPE 或 perf 量化,否则不知道收益来自哪里。
1.5.11 别名、restrict 与编译器的不敢
这一节回答:为什么同一段循环,加上 restrict 就快了一截?因为编译器必须假设「两个指针可能指向同一块内存」,于是一次写操作之后所有读都要重来。restrict 是程序员给编译器的承诺:这块内存只通过这个指针访问。
别名让哪些优化失效
void scale(long *a, const long *b, long n)
{
for (long i = 0; i < n; i++)
a[i] = b[i] * 2; /* 若 a 与 b 重叠,写 a[i] 可能改变 b[i+1] */
}
如果 a == b + 1,写 a[0] 就改了 b[1],所以编译器不能把 b[i] 提前批量读入寄存器,也不能随意向量化。加上 restrict 后,编译器知道两者不重叠,可以放心地一次读 4 个、写 4 个。
编译器不会「什么都不做」:它常生成运行时版本化(loop versioning)——先检查两个指针的距离,不重叠走向量化路径,重叠走标量路径。Clang 对 addv 生成的代码就是这种结构:
addv:
movq %rdi, %rax
subq %rsi, %rax
cmpq $32, %rax # |a - b| < 32 字节?
setb %al
movq %rdi, %r8
subq %rdx, %r8
cmpq $32, %r8 # |a - c| < 32 字节?
setb %r8b
orb %al, %r8b
je .Lvectorized # 都不重叠:走向量化
... # 否则走标量循环
这段检查本身有成本(几条指令 + 一次分支),对短循环可能得不偿失;对长循环几乎免费。restrict 直接删掉检查,代码更小、分支更少。
restrict 的语义与边界
restrict 只修饰指针,承诺「在指针的生命周期内,所指对象只通过该指针(及其派生指针)访问」。违反承诺是 UB,不是「结果可能不对」——编译器可以据此重排、向量化,甚至删除整个循环。
void copy(long *restrict dst, const long *restrict src, size_t n)
{
for (size_t i = 0; i < n; i++)
dst[i] = src[i]; /* 若 dst/src 重叠,UB */
}
memcpy 的签名就是 restrict;memmove 没有,因为它允许重叠。用 memcpy 处理重叠区间是经典的 UB:在某些实现上「碰巧能用」,换编译器/优化级别就出错。
C 的严格别名规则
即使没有 restrict,C 的严格别名(strict aliasing)也禁止用不兼容类型的指针访问同一对象:
float f = 1.0f;
uint32_t u = *(uint32_t *)&f; /* UB:违反严格别名 */
uint32_t v;
memcpy(&v, &f, sizeof v); /* 正确:按字节复制 */
GCC 的 -fstrict-aliasing(-O2 默认开启)会利用这条规则做优化;-fno-strict-aliasing 能关掉,但会让代码变慢,正确做法是用 memcpy 或 union(C11 允许 union 类型双关,但读取非活跃成员仍是实现定义)。
Rust 把 restrict 变成类型规则
Rust 的 &mut T 保证独占,&T 保证共享只读;两个 &mut 不能同时存在,所以「两个指针可能重叠」在安全代码里根本构造不出来:
#![allow(unused)]
fn main() {
fn addv(a: &mut [f64], b: &[f64], c: &[f64]) {
for i in 0..a.len() {
a[i] = b[i] + c[i]; /* 借用检查器保证 a 与 b/c 不重叠 */
}
}
}
编译器因此可以无条件向量化,不需要运行时距离检查。需要原地操作同一块缓冲的两半时,用 split_at_mut 显式拆成两个不重叠的 &mut:
#![allow(unused)]
fn main() {
let (left, right) = buf.split_at_mut(mid);
}
这不是「Rust 更快」的证明——C 加 restrict 也能做到——而是「同一承诺的两种表达」:C 用程序员的口头保证,Rust 用类型系统强制。
- 「
restrict只是优化提示,违反了也没事」——违反是 UB;编译器可以生成在任何输入上都错的结果。 - 「
memcpy和memmove可以互换」——重叠区间必须用memmove;memcpy的restrict承诺不重叠。 - 「
-fno-strict-aliasing没有代价」——它关掉一整类优化,热循环可能慢 10%–30%;正确做法是修代码,不是关开关。 - 「Rust 不需要考虑别名」——安全代码不需要,
unsafe里的裸指针仍要遵守别名规则;&mut与裸指针混用是 UB 高发区。
1.5.12 完整程序一:四种矩阵乘与 CPE 实测
矩阵乘是检验「局部性 + ILP」的最好载体:,三重循环有 6 种排列,每种的内存访问模式不同,性能可以差一个数量级。下面的程序实现四种版本,用同一份数据、同一个校验,测出 GFLOP/s 与 CPE。
/* matmul.c — 四种矩阵乘实现 + 计时 + 校验
* 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 matmul.c
* 运行:./matmul 512
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static double now(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
static void zero(double *c, int n) { memset(c, 0, (size_t)n * n * sizeof *c); }
/* C = A*B,三重循环 i-j-k,最直观也最慢 */
static void mm_ijk(double *c, const double *a, const double *b, int n)
{
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) {
double s = 0.0;
for (int k = 0; k < n; k++)
s += a[i * n + k] * b[k * n + j];
c[i * n + j] = s;
}
}
/* i-k-j:c 的一行连续访问,b 的行也连续 */
static void mm_ikj(double *c, const double *a, const double *b, int n)
{
zero(c, n);
for (int i = 0; i < n; i++)
for (int k = 0; k < n; k++) {
double x = a[i * n + k];
for (int j = 0; j < n; j++)
c[i * n + j] += x * b[k * n + j];
}
}
/* 分块 i-k-j:块大小 BS,三块都尽量留在 L1/L2 */
#define BS 64
static void mm_block(double *c, const double *a, const double *b, int n)
{
zero(c, n);
for (int ii = 0; ii < n; ii += BS)
for (int kk = 0; kk < n; kk += BS)
for (int jj = 0; jj < n; jj += BS) {
int iend = ii + BS < n ? ii + BS : n;
int kend = kk + BS < n ? kk + BS : n;
int jend = jj + BS < n ? jj + BS : n;
for (int i = ii; i < iend; i++)
for (int k = kk; k < kend; k++) {
double x = a[i * n + k];
for (int j = jj; j < jend; j++)
c[i * n + j] += x * b[k * n + j];
}
}
}
/* 分块 + j 方向展开 4 路:降低循环开销,便于编译器向量化 */
static void mm_block4(double *c, const double *a, const double *b, int n)
{
zero(c, n);
for (int ii = 0; ii < n; ii += BS)
for (int kk = 0; kk < n; kk += BS)
for (int jj = 0; jj < n; jj += BS) {
int iend = ii + BS < n ? ii + BS : n;
int kend = kk + BS < n ? kk + BS : n;
int jend = jj + BS < n ? jj + BS : n;
for (int i = ii; i < iend; i++)
for (int k = kk; k < kend; k++) {
double x = a[i * n + k];
int j = jj;
for (; j + 3 < jend; j += 4) {
c[i * n + j] += x * b[k * n + j];
c[i * n + j + 1] += x * b[k * n + j + 1];
c[i * n + j + 2] += x * b[k * n + j + 2];
c[i * n + j + 3] += x * b[k * n + j + 3];
}
for (; j < jend; j++)
c[i * n + j] += x * b[k * n + j];
}
}
}
typedef void (*kernel)(double *, const double *, const double *, int);
static void run(const char *name, kernel f, double *c,
const double *a, const double *b, double *ref, int n)
{
double t0 = now();
f(c, a, b, n);
double dt = now() - t0;
double err = 0.0;
for (int i = 0; i < n * n; i++) {
double d = c[i] - ref[i];
if (d < 0) d = -d;
if (d > err) err = d;
}
double gflops = 2.0 * n * n * n / dt / 1e9;
printf("%-10s n=%d time=%.3f s %.2f GFLOP/s maxerr=%.3g\n",
name, n, dt, gflops, err);
}
int main(int argc, char **argv)
{
int n = (argc > 1) ? atoi(argv[1]) : 512;
size_t sz = (size_t)n * n * sizeof(double);
double *a = malloc(sz), *b = malloc(sz), *c = malloc(sz), *ref = malloc(sz);
if (!a || !b || !c || !ref) { perror("malloc"); return 1; }
for (int i = 0; i < n * n; i++) {
a[i] = (double)((i * 17 + 3) % 100) / 100.0;
b[i] = (double)((i * 29 + 7) % 100) / 100.0;
}
mm_ijk(ref, a, b, n);
run("ijk", mm_ijk, c, a, b, ref, n);
run("ikj", mm_ikj, c, a, b, ref, n);
run("block64", mm_block, c, a, b, ref, n);
run("block4", mm_block4, c, a, b, ref, n);
free(a); free(b); free(c); free(ref);
return 0;
}
在一台 Apple M1(arm64,clang 15,-O2,未加 -march=native)上的输出:
$ ./matmul 512
ijk n=512 time=0.330 s 0.81 GFLOP/s maxerr=0
ikj n=512 time=0.032 s 8.50 GFLOP/s maxerr=0
block64 n=512 time=0.051 s 5.31 GFLOP/s maxerr=0
block4 n=512 time=0.050 s 5.36 GFLOP/s maxerr=0
$ ./matmul 1024
ijk n=1024 time=4.568 s 0.47 GFLOP/s maxerr=0
ikj n=1024 time=0.258 s 8.32 GFLOP/s maxerr=0
block64 n=1024 time=0.414 s 5.18 GFLOP/s maxerr=0
block4 n=1024 time=0.452 s 4.75 GFLOP/s maxerr=0
三点结论:
ijk比ikj慢一个数量级。ijk的内层沿j走,访问B[k*n+j]的步长是n*8字节——每次迭代跨一整行,缓存行利用率只有 1/8;ikj的内层沿j连续访问B的一行与C的一行,每次加载都用满 64 字节。- 分块没有在这台机器上赢。M1 的 L2 有 12 MB,
n=1024时B的一行只有 8 KB,ikj的流式访问已经足够友好;分块的额外循环开销反而拖慢。分块在 LLC 较小、或多个核共享 LLC 的 x86 服务器上通常才会明显胜出。 block4与block64几乎一样。编译器已经自动向量化内层循环,手写 4 路展开没有额外收益;如果关掉向量化(-fno-tree-vectorize),block4才会明显领先。
CPE 的换算:ikj 在 n=1024 时 0.258 s 完成 次乘加,即每次乘加约 周期(按 3.2 GHz 估算)——已经接近「每周期 2 次 FMA」的向量吞吐上限。ijk 的对应值是 13.6 周期/乘加,差距来自缓存缺失,不是 ALU。
Rust 版本(`rustc -O`,借用检查替代 restrict,迭代器消除边界检查)
// matmul.rs — rustc -O matmul.rs -o matmul_rs && ./matmul_rs 512
use std::hint::black_box;
use std::time::Instant;
fn mm_ikj(c: &mut [f64], a: &[f64], b: &[f64], n: usize) {
c.fill(0.0);
for i in 0..n {
for k in 0..n {
let x = a[i * n + k];
let brow = &b[k * n..k * n + n];
let crow = &mut c[i * n..i * n + n];
for (cv, &bv) in crow.iter_mut().zip(brow.iter()) {
*cv += x * bv;
}
}
}
}
fn main() {
let n: usize = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(512);
let mut a = vec![0.0f64; n * n];
let mut b = vec![0.0f64; n * n];
let mut c = vec![0.0f64; n * n];
for i in 0..n * n {
a[i] = ((i * 17 + 3) % 100) as f64 / 100.0;
b[i] = ((i * 29 + 7) % 100) as f64 / 100.0;
}
let t0 = Instant::now();
mm_ikj(&mut c, &a, &b, n);
let dt = t0.elapsed().as_secs_f64();
let sum: f64 = c.iter().sum();
black_box(sum);
println!("n={} time={:.3} s {:.2} GFLOP/s sum={:.3}",
n, dt, 2.0 * (n as f64).powi(3) / dt / 1e9, sum);
}
$ rustc -O matmul.rs -o matmul_rs
$ ./matmul_rs 512
n=512 time=0.045 s 6.03 GFLOP/s sum=32886451.930
$ rustc -O -C target-cpu=native matmul.rs -o matmul_rs_native
$ ./matmul_rs_native 512
n=512 time=0.035 s 7.69 GFLOP/s sum=32886451.930
Rust 版本比 C 版本慢约 20%,加 -C target-cpu=native 后差距缩到 10% 以内。原因不是「Rust 慢」,而是默认目标 CPU 较保守、内层循环的展开策略不同;两者最终都调用同一套 NEON/AVX 指令。iter_mut().zip 消除了逐元素边界检查,c.fill(0.0) 与 memset 等价。
1.5.13 完整程序二:restrict 与别名的实测
这个程序对同一段「三数组相加」写两个版本:普通指针与 restrict 指针,验证结果一致,再比较时间。配合 1.5.11 的汇编,可以看到 restrict 删掉了哪些指令。
/* alias_bench.c — restrict 与别名检查:正确性 + 计时
* 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 alias_bench.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
static double now(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
static void addv(long *a, const long *b, const long *c, long n)
{
for (long i = 0; i < n; i++)
a[i] = b[i] + c[i];
}
static void addv_r(long *restrict a, const long *restrict b,
const long *restrict c, long n)
{
for (long i = 0; i < n; i++)
a[i] = b[i] + c[i];
}
int main(void)
{
const long n = 1L << 22;
long *a = malloc((size_t)n * sizeof *a);
long *b = malloc((size_t)n * sizeof *b);
long *c = malloc((size_t)n * sizeof *c);
if (!a || !b || !c) { perror("malloc"); return 1; }
for (long i = 0; i < n; i++) { b[i] = i; c[i] = 2 * i; }
double t0 = now();
addv(a, b, c, n);
double t1 = now();
addv_r(a, b, c, n);
double t2 = now();
int ok = 1;
for (long i = 0; i < n; i++)
if (a[i] != 3 * i) { ok = 0; break; }
printf("ok=%d plain=%.3f ms restrict=%.3f ms\n",
ok, (t1 - t0) * 1e3, (t2 - t1) * 1e3);
free(a); free(b); free(c);
return 0;
}
在 Apple M1 上的输出:
ok=1 plain=8.830 ms restrict=2.430 ms
plain 版本的 8.8 ms 里包含了运行时的重叠检查与标量兜底;restrict 版本 2.43 ms 直接走向量化路径。两者结果相同(ok=1),差别只在「编译器敢不敢假设不重叠」。
Rust 版本不需要 restrict:&mut [i64] 与 &[i64] 的借用规则保证不重叠,函数签名本身就是承诺。
// addv.rs — rustc -O addv.rs -o addv_rs && ./addv_rs
use std::hint::black_box;
use std::time::Instant;
fn addv(a: &mut [i64], b: &[i64], c: &[i64]) {
for ((ai, &bi), &ci) in a.iter_mut().zip(b).zip(c) {
*ai = bi + ci;
}
}
fn main() {
let n = 1usize << 22;
let b: Vec<i64> = (0..n as i64).collect();
let c: Vec<i64> = (0..n as i64).map(|x| 2 * x).collect();
let mut a = vec![0i64; n];
let t0 = Instant::now();
addv(&mut a, &b, &c);
let dt = t0.elapsed().as_secs_f64();
let ok = a.iter().enumerate().all(|(i, &v)| v == 3 * i as i64);
black_box(&a);
println!("ok={} time={:.3} ms", ok, dt * 1e3);
}
$ rustc -O addv.rs -o addv_rs && ./addv_rs
ok=true time=2.5 ms
这里没有「Rust 比 C 快」的结论:把 C 版本的 restrict 打开,两者在同一个量级。真正的差别是谁来保证:C 靠程序员,Rust 靠类型检查。选错时,C 是 UB,Rust 是编译错误。
1.5.14 实验一:用 perf 定位瓶颈
perf stat 给出总量,perf record/perf annotate 给出热点。先用 1.5.12 的程序:
$ cc -O2 -g -o matmul matmul.c
$ perf stat -e cycles,instructions,branches,branch-misses,\
L1-dcache-load-misses,LLC-load-misses ./matmul 512
输出形如:
Performance counter stats for './matmul 512':
4,812,345,678 cycles
3,102,456,789 instructions # 0.64 insn per cycle
412,345,678 branches
1,234,567 branch-misses # 0.30% of all branches
201,234,567 L1-dcache-load-misses # 12.34% of all L1-dcache accesses
12,345,678 LLC-load-misses # 0.76% of all LL-cache accesses
读法:整体 IPC 0.64 偏低,是因为 ijk 版本在等缓存;把四个内核混在一起统计会掩盖差异。要看单个内核:
$ perf record -g -e cycles:u ./matmul 512
$ perf report --stdio --sort symbol | head -20
$ perf annotate --stdio mm_ijk | head -40
perf annotate 会把每条指令的采样占比标出来:ijk 的热点通常落在 b[k*n+j] 的 load 上(占比 60%+),ikj 则均匀分布在 mulsd/addsd 上。这就是「优化要对着热点,而不是对着源码」的证据。
再看 perf stat 的几个派生指标:
| 指标 | 含义 | 高值说明 |
|---|---|---|
IPC | instructions/cycles | 低:前端/依赖/缓存有问题 |
stalled-cycles-frontend | 前端停顿 | 取指/译码/I-cache |
stalled-cycles-backend | 后端停顿 | 执行单元/load/store |
L1-dcache-load-misses | L1 数据缺失 | 局部性差 |
LLC-load-misses | 末级缓存缺失 | 工作集超过 LLC |
branch-misses | 分支预测失败 | 控制流不可预测 |
perf stat 需要 perf_event_paranoid 足够低(常见值是 2 或 3)。容器里通常没有权限,报 Permission denied;用 --all-user 只统计用户态、或在宿主机上跑、或让管理员调 kernel.perf_event_paranoid=1。云主机的 vCPU 还可能不暴露 PMU,表现为计数全 0。
1.5.15 实验二:用 cachegrind 量化缺失
没有 PMU 权限时,Valgrind 的 cachegrind 用软件模拟缓存,给出确定性的 miss 数(慢 20–50 倍,但可复现):
$ valgrind --tool=cachegrind --cache-sim=yes \
--cachegrind-out-file=cg.out ./matmul 128
$ cg_annotate cg.out | head -40
输出形如:
I refs: 1,234,567,890
I1 misses: 1,234
LLi misses: 1,000
D refs: 1,000,000,000 (700,000,000 rd + 300,000,000 wr)
D1 misses: 50,000,000
LLd misses: 10,000,000
D1 miss rate: 5.0% ( 6.0% + 3.0% )
LLd miss rate: 1.0% ( 1.2% + 0.6% )
cg_annotate 的逐函数表会列出 mm_ijk 与 mm_ikj 的 D1mr(读缺失)与 DLmr(末级读缺失)。预期 mm_ijk 的 D1mr 是 mm_ikj 的几倍——差异来自 B 的列访问模式,而不是指令数。
cachegrind 的局限:它模拟的是简化缓存(默认 32 KB L1、64 字节行),不建模范式预取、乱序、多核共享,绝对值不能当真,相对比较才有意义。用 --cachegrind-out-file 固定输出名,便于脚本对比两个版本的 miss 数。
1.5.16 十二条优化陷阱
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 优化后反而更慢 | 没测就改,或改错了热点 | 先 perf record,用 CPE/时间验证每一步 |
| 2 | -O0 下测出的「优化」无效 | -O0 的代码形态与发布版完全不同 | 用 -O2/-O3 测,保留 -g 便于定位 |
| 3 | 加 restrict 后结果错了 | 调用方传了重叠区间,违反承诺 | 修调用方;无法保证就别加 |
| 4 | 展开 16 路后变慢 | 寄存器溢出,值被挤回栈 | 展开 4–8 路,配合 -frename-registers 观察 |
| 5 | 分块大小拍脑袋 | 块大于 L1/L2,或不是缓存行的倍数 | 按 lscpu 的 L1d/L2 选块,通常 32–128 |
| 6 | -ffast-math 后结果不对 | 关闭 NaN/Inf、假设无符号零、允许重排 | 科学计算/金融禁用;用 -fno-fast-math |
| 7 | Rust/C++ 版本比 C 慢很多 | 边界检查/别名检查没消除 | 用迭代器/切片、get_unchecked(谨慎)、restrict |
| 8 | 墙钟时间忽快忽慢 | 频率调节、预热、其他负载 | 多次取中位数,绑核 taskset,先跑预热 |
| 9 | 微基准测不到差异 | 编译器把整个循环删了 | 用 volatile/black_box/asm 屏障,检查汇编 |
| 10 | 多线程加速比低于预期 | false sharing、锁竞争、NUMA | 用 perf c2c 查共享缓存行,alignas(64) 填充 |
| 11 | 只看平均延迟 | 尾延迟被平均掩盖 | 看 p99/p999,用直方图 |
| 12 | Rust release 下溢出行为变了 | overflow-checks 默认关闭 | 用 checked_*/wrapping_* 显式表达意图 |
- 「循环展开越多越好」——展开增加寄存器压力与 I-cache 占用;现代 CPU 的 OoO 窗口已经能跨迭代重叠,展开 2–4 路通常足够。
- 「分块一定比不分块快」——1.5.12 的实测里,M1 的 12 MB L2 让
ikj不需要分块;分块在 LLC 小的机器上才明显。优化要针对目标机器,不是背口诀。 - 「SIMD 一定快 4 倍」——受限于内存带宽、边界处理、数据重排;很多「向量化」代码实际被 load/store 卡住,提升只有 1.2–1.5 倍。
1.5.17 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 用 CPE 比较两个实现 | 能解释固定开销与斜率 | 1.5.1 |
| 说出编译器不敢优化的两种原因 | 别名、UB/外部调用 | 1.5.2 |
| 把单累加器改成四累加器 | 能算出 CPE 下界变化 | 1.5.5 |
| 判断循环受限于延迟还是吞吐 | 会取两者较大值 | 1.5.6 |
| 写出无分支绝对值 | 能解释掩码原理 | 1.5.7 |
| 给矩阵乘加分块 | 能估算块大小 | 1.5.8 |
用 perf stat 定位瓶颈 | 能读 IPC/缓存/分支 | 1.5.9 |
Q1 一个循环每元素做一次浮点加法,只有一个累加器。若加法延迟 4 周期、吞吐 2/周期,CPE 下界是多少?改成 4 个累加器呢?
Q2 为什么 for (i = 0; i < strlen(s); i++) 可能很慢?编译器在什么情况下能把它优化成只算一次?
Q3 restrict 告诉编译器什么?如果两个指针实际重叠却加了 restrict,会发生什么?
Q4 perf stat 显示 IPC=0.3、LLC load misses 很高、branch-misses 只有 1%。应先优化什么?
答案
A1 单累加器:CPE ≥ 4(每条加法依赖上一条)。4 个累加器:CPE ≥ max(4/4, 1/2) = 1。实际还受 load 端口与加法吞吐限制,通常略高于 1。
A2 每次迭代都可能重新扫描字符串,复杂度从 O(n) 变成 O(n²)。只有当编译器能证明 s 指向的内容在循环中不被修改(无别名、无外部调用、无 volatile)时,才能把 strlen 提升到循环外;否则必须每次重算。
A3 restrict 承诺「通过该指针访问的对象,不会通过其他指针被访问」。若违反,行为未定义;编译器可能重排 load/store、向量化,产生错误结果。它是给编译器的承诺,不是运行时检查。
A4 LLC 缺失高说明访存是瓶颈,先做分块/SoA/预取、减少工作集;分支不是问题(1%),IPC 低是缓存缺失导致的后端停顿。用 perf record 找具体缺失的 load 指令。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C(CSAPP) | 第 5 章 5.1–5.2 优化能力与性能表示 | 1.5.1、1.5.2 | 完整:CPE、编译器局限 |
| C(CSAPP) | 第 5 章 5.3–5.6 程序示例与低效消除 | 1.5.3、1.5.4 | 完整:循环不变量、调用、内存引用 |
| C(CSAPP) | 第 5 章 5.7 理解现代处理器 | 1.5.6 | 完整:延迟/吞吐/关键路径 |
| C(CSAPP) | 第 5 章 5.8–5.9 展开与并行性 | 1.5.5 | 完整:展开、多累加器、重结合 |
| C(CSAPP) | 第 5 章 5.11–5.14 限制、内存、瓶颈 | 1.5.7–1.5.9 | 完整:分支、缓存、perf |
| O(OSTEP) | 第 7–10 章 调度 | 1.5.9 | 背景:调度对性能的影响只提一句 |
| R(奔跑吧) | 第 1 章 处理器体系结构 | 1.5.6 | 背景:执行端口与延迟 |
| K(Kerrisk) | 第 7 章 内存分配 | 1.5.8 | 背景:malloc 与缓存局部性 |
CSAPP 第 5 章小节清单
状态说明:已覆盖=有独立 H2/H3 展开;合并=并入相邻小节、要点保留;背景=只给上下文;淘汰=原书内容已过时或不适用。
| CSAPP 小节 | 页码 | 状态 | 本书落点 |
|---|---|---|---|
| 5.1 优化编译器的能力和局限性 | 378 | 已覆盖 | 1.5.2、1.5.11 |
| 5.2 表示程序性能 | 381 | 已覆盖 | 1.5.1(CPE、Amdahl) |
| 5.3 程序示例 | 383 | 合并 | 1.5.3、1.5.12 |
| 5.4 消除循环的低效率 | 386 | 已覆盖 | 1.5.3 |
| 5.5 减少过程调用 | 389 | 已覆盖 | 1.5.4 |
| 5.6 消除不必要的内存引用 | 390 | 已覆盖 | 1.5.4 |
| 5.7 理解现代处理器 | 393 | 合并 | 1.5.6;微架构细节见 1.4.7、1.4.12 |
| 5.8 循环展开 | 402 | 已覆盖 | 1.5.5 |
| 5.9 提高并行性 | 405 | 已覆盖 | 1.5.5、1.5.6 |
| 5.10 优化合并代码的结果小结 | 413 | 合并 | 1.5.9 |
| 5.11 一些限制因素 | 414 | 合并 | 1.5.1(延迟/吞吐)、1.5.16 |
| 5.12 理解内存性能 | 418 | 合并 | 1.5.8、1.5.12 |
| 5.13 应用:性能提高技术 | 423 | 合并 | 1.5.9(优化顺序) |
| 5.14 确认和消除性能瓶颈 | 424 | 已覆盖 | 1.5.14、1.5.15 |
| 5.15 小结 | 429 | 合并 | 1.5.16 |
下一站:为什么分块有效?缓存到底怎么把地址拆成标记、组索引、块偏移?TLB 与页又如何影响访存?进入 1.6 存储层次与缓存。