返回笔记

计算机系统 - 程序性能优化

目录
目录

〇、性能度量基础

0.1 程序性能的表达

指标含义说明
CPECycles Per Element处理每个数据元素所需的时钟周期数,越小越快
CPICycles Per Instruction每条指令平均时钟周期数
IPCInstructions Per Cycle每周期完成的指令数(= 1/CPI)
吞吐量Throughput单位时间内完成的操作数
延迟Latency一次操作从开始到完成的总时间

优化的核心目标:降低 CPE。同一个算法,通过代码写法、编译器选项和硬件利用方式的不同,CPE 可以相差数倍甚至数十倍。

0.2 Amdahl 定律

对系统某部分加速时,整体加速比受该部分占比限制。

S=1(1f)+fkS = \frac{1}{(1 - f) + \frac{f}{k}}
符号含义
ff可优化部分占总执行时间的比例
kk该部分的加速倍数
SS整体加速比

例子:某程序 60% 时间花在循环 A 上,将 A 加速 3 倍:

S=10.4+0.63=10.4+0.2=10.61.67S = \frac{1}{0.4 + \frac{0.6}{3}} = \frac{1}{0.4 + 0.2} = \frac{1}{0.6} ≈ 1.67

启示:即使把 A 加速到无限快(kk → ∞),整体也最多加速 10.4=2.5\frac{1}{0.4} = 2.5 倍。优化要先找瓶颈,对占比小的部分做极致优化收益很低。


一、代码级优化

这一层不依赖编译器,靠程序员改写代码来消除不必要的开销。

1.1 消除循环低效(Code Motion)

把每次迭代都会计算但结果不变的表达式移到循环外面。

// ❌ 优化前:strlen 每次循环都被调用,O(n²)
for (int i = 0; i < strlen(s); i++) {
    // ...
}

// ✅ 优化后:提到循环外
int len = strlen(s);
for (int i = 0; i < len; i++) {
    // ...
}

为什么编译器不一定能帮你做? 编译器无法确定 strlen(s) 是否有副作用——如果循环体里修改了 s 的内容,每次调用 strlen 的结果可能不同。编译器必须保守,不敢擅自移动。

1.2 减少函数调用开销

// ❌ 每次循环调用 get_length()
for (int i = 0; i < get_length(v); i++) {
    int val = get_element(v, i);
    // ...
}

// ✅ 直接访问数据,消除调用
int length = v->length;
int *data = v->data;
for (int i = 0; i < length; i++) {
    int val = data[i];
    // ...
}

函数调用本身有开销(压栈、跳转、返回),更重要的是它会阻止编译器做进一步优化——编译器把函数调用当作"黑盒",不敢假设函数没有副作用。

1.3 消除不必要的内存访问

// ❌ 每次循环都写回内存
void sum(int *data, int n, int *result) {
    *result = 0;
    for (int i = 0; i < n; i++)
        *result += data[i];  // 每次迭代:读 *result → 加 → 写 *result
}

// ✅ 用局部变量累加,最后一次写回
void sum(int *data, int n, int *result) {
    int acc = 0;
    for (int i = 0; i < n; i++)
        acc += data[i];       // acc 在寄存器中,无需访存
    *result = acc;
}

为什么编译器不能自动做? 因为内存别名(Memory Aliasing):编译器不知道 result 是否指向 data 数组内部的某个元素。如果 result == &data[3],两种写法的结果就不同——编译器必须保守地每次都写回内存。

1.4 强度削减(Strength Reduction)

代价低的运算替换代价高的运算。

替换说明
乘法 → 移位 + 加法x * 8x << 3
除法 → 移位x / 4x >> 2(无符号数)
乘法 → 累加循环中 i * k → 每次迭代加 k
// ❌ 每次循环做乘法
for (int i = 0; i < n; i++)
    a[i * 4] = 0;

// ✅ 强度削减:乘法 → 加法
for (int j = 0; j < n * 4; j += 4)
    a[j] = 0;

1.5 公共子表达式消除

// ❌ 重复计算
int u = a * b + c;
int v = a * b + d;

// ✅ 提取公共部分
int t = a * b;
int u = t + c;
int v = t + d;

1.6 小结:代码级优化速查表

技术目标效果
Code Motion循环不变量外提减少重复计算
减少函数调用消除调用开销 + 暴露优化机会允许编译器进一步优化
消除内存访问用寄存器变量替代指针反复读写避免 aliasing 导致的冗余访存
强度削减高代价运算 → 低代价运算降低每次迭代的指令延迟
公共子表达式消除提取重复计算减少运算次数

二、编译器优化

2.1 优化级别

以 GCC 为例:

级别含义典型优化
-O0不优化(默认)便于调试,代码与源码一一对应
-O1基础优化常量折叠、死代码消除、简单的寄存器分配
-O2标准优化循环优化、指令调度、更积极的内联
-O3激进优化自动向量化(SIMD)、函数内联、循环展开
-Os优化代码体积类似 -O2 但避免增大代码的优化

2.2 编译器能做的

优化说明
常量折叠x = 3 * 4x = 12,编译期直接算好
常量传播a = 5; b = a + 1;b = 6;
死代码消除删除不可达代码或结果未被使用的计算
内联展开将小函数体直接插入调用处,消除调用开销
循环展开减少循环控制开销(详见处理器级优化部分)
寄存器分配将频繁使用的变量分配到寄存器
指令调度重排指令顺序以减少流水线停顿
自动向量化用 SIMD 指令(如 SSE/AVX)一次处理多个数据

2.3 编译器做不了的(优化的障碍)

编译器必须保证优化后的程序行为与原程序完全一致,因此遇到以下情况时会放弃优化:

内存别名(Memory Aliasing)

void foo(int *p, int *q) {
    *p += 1;
    *q += 1;
    *p += 1;
}

编译器不敢把两次 *p += 1 合并为 *p += 2——因为 pq 可能指向同一地址。如果 p == q,合并后结果会从 +3 变成 +2 + 1 = +3……等等,这个例子中恰好结果一样。换一个:

void bar(int *p, int *q) {
    *p = 10;
    *q = 20;
    int x = *p;  // 如果 p == q,x 应为 20,不是 10
}

编译器不能假设 x = 10(常量传播),因为 *q = 20 可能已经覆盖了 *p

C99 的 restrict:程序员用 restrict 关键字承诺"这个指针指向的内存不会被其他指针修改",从而允许编译器大胆优化。

函数副作用

int counter = 0;
int f() { return counter++; }

int x = f() + f();  // 编译器不能优化为 2 * f()

关键理解:编译器只做安全的优化。如果你知道某个函数无副作用、某两个指针不会别名,就需要程序员主动把优化做了,或者用 restrictconstinline 等关键字给编译器更多信息。


三、处理器级优化

这一层的优化是利用现代 CPU 的微架构特性,让代码的执行模式与硬件更契合。

3.1 理解现代 CPU 的执行模型

现代处理器(如 Intel Core 系列)远不只是"五级流水线",它们使用超标量 + 乱序执行

特性说明
超标量(Superscalar)每个周期可以发射多条指令到不同功能单元
乱序执行(OoO)不按程序顺序,而是数据就绪即执行
多功能单元有多个加法器、乘法器、load/store 单元可并行工作
寄存器重命名消除假依赖(WAR、WAW),暴露更多并行度
分支预测在分支结果确定之前就投机执行预测路径

关键概念:延迟(Latency)vs 吞吐量(Throughput)

操作延迟(周期)吞吐量(周期/操作)
整数加法10.5(每周期可做 2 次)
整数乘法31
浮点加法31
浮点乘法51~2
除法20~4020~40

延迟 = 一次操作从输入到输出的完整时间;吞吐量 = 流水化后每隔多久可以启动下一次操作。乘法延迟 3 周期但吞吐量 1 周期——因为乘法单元是流水化的,上一个乘法还在算的时候下一个已经可以进去了。

3.2 循环展开(Loop Unrolling)

将循环体复制多份,减少循环控制开销(分支、自增、比较),同时给 CPU 更多指令级并行的机会。

// ❌ 原始循环
for (int i = 0; i < n; i++)
    sum += a[i];

// ✅ 2×1 展开
for (int i = 0; i < n - 1; i += 2) {
    sum += a[i];
    sum += a[i + 1];
}
if (n % 2) sum += a[n - 1];  // 处理剩余

展开本身效果有限——因为 sum += a[i]sum += a[i+1] 之间仍有数据依赖(都要等上一次加法的结果写入 sum)。真正的威力要配合下面的技术。

3.3 多路并行累加(Multiple Accumulators)

打破数据依赖链,让 CPU 的多个功能单元同时工作

// ✅ 2×2 展开 + 双累加器
int sum0 = 0, sum1 = 0;
for (int i = 0; i < n - 1; i += 2) {
    sum0 += a[i];      // 独立的依赖链 ①
    sum1 += a[i + 1];  // 独立的依赖链 ②
}
int sum = sum0 + sum1;
flowchart LR
    subgraph 依赖链①
        A0["a[0]"] --> S0_1["sum0"] --> A2["a[2]"] --> S0_2["sum0"] --> A4["a[4]"]
    end
    subgraph 依赖链②
        A1["a[1]"] --> S1_1["sum1"] --> A3["a[3]"] --> S1_2["sum1"] --> A5["a[5]"]
    end

效果:两条独立的依赖链可以在不同功能单元上并行执行,CPE 接近减半。如果 CPU 有 k 个加法单元,理论上用 k 路累加器就能打满吞吐量。

3.4 重结合变换(Reassociation)

改变运算的结合顺序,缩短关键路径。

// ❌ 原始:每步都依赖上一步的 sum
sum = ((sum + a[0]) + a[1]) + a[2];
//    ^^^^^^^^^^ 必须先算完才能 + a[1]

// ✅ 重结合:先算括号内,再合并
sum = sum + (a[0] + a[1]);
//           ^^^^^^^^^^^^^ 这部分可以独立计算,不依赖 sum
// 在循环中的体现:
// ❌ 顺序依赖
for (int i = 0; i < n - 1; i += 2)
    sum = (sum + a[i]) + a[i+1];  // 关键路径长度 = 2×加法延迟

// ✅ 重结合
for (int i = 0; i < n - 1; i += 2)
    sum = sum + (a[i] + a[i+1]);  // a[i]+a[i+1] 与 sum 并行 → 关键路径 = 1×加法延迟

注意:对整数来说结合律成立,编译器理论上可以做这个变换。但对浮点数,加法不满足结合律(舍入误差),编译器不会自动做——需要程序员手动改。

3.5 分支预测友好的代码

CPU 流水线深度可达 1520 级,分支预测失败的代价是冲刷整条流水线(1520 个周期的惩罚)。

// ❌ 分支难以预测(数据依赖的随机分支)
for (int i = 0; i < n; i++) {
    if (a[i] > threshold)
        sum += a[i];
}

// ✅ 用条件移动消除分支(编译器可能自动做)
for (int i = 0; i < n; i++) {
    int mask = -(a[i] > threshold);  // 全 1 或全 0
    sum += a[i] & mask;
}
技巧说明
减少不可预测的分支数据相关的条件分支预测准确率低
用条件移动(cmov将 if-else 转为无分支的条件赋值
有规律的分支更好总是执行 / 总是不执行 / 交替 → 预测器容易学习

3.6 缓存友好编程(Cache-Friendly Code)

内存访问是最大的性能杀手之一(L1 命中 ~4 周期,主存 ~200 周期)。写出缓存友好的代码就是要提高时间局部性和空间局部性

空间局部性:按行优先访问

// ❌ 逐列访问(步长 = N,每次跳过一整行)
for (int j = 0; j < N; j++)
    for (int i = 0; i < N; i++)
        sum += a[i][j];  // 每次跳 N 个元素 → 缓存行不断被换出

// ✅ 逐行访问(步长 = 1,连续访问)
for (int i = 0; i < N; i++)
    for (int j = 0; j < N; j++)
        sum += a[i][j];  // 顺序访问 → 每个缓存行充分利用

C 语言二维数组按**行优先(Row-major)**存储,a[i][0]a[i][N-1] 在内存中连续。逐行访问步长为 1,正好匹配缓存行预取。

时间局部性:分块(Blocking / Tiling)

处理大矩阵时,将矩阵分成小块,让每一块能装进缓存,在块内完成尽可能多的操作再换下一块。

// 矩阵乘法分块示意
for (int ii = 0; ii < N; ii += B)
  for (int jj = 0; jj < N; jj += B)
    for (int kk = 0; kk < N; kk += B)
      // B×B 的小块乘法
      for (int i = ii; i < ii + B; i++)
        for (int j = jj; j < jj + B; j++)
          for (int k = kk; k < kk + B; k++)
            C[i][j] += A[i][k] * B[k][j];

选择合适的块大小 B,使三个 B×B 的块(A 的一块、B 的一块、C 的一块)总共能装入 L1 Cache。

3.7 关键路径与性能瓶颈分析

优化循环的核心思路是找到关键路径(Critical Path)——循环中最长的数据依赖链决定了最小 CPE。

以向量求和为例:

写法关键路径CPE(整数加法,延迟=1)
原始n 次串行加法1.0
2×1 展开n 次串行加法(没变!)1.0
2×2 多路累加n/2 次串行加法 × 2 路并行0.5(如果有 2 个加法单元)
2×1a 重结合n/2 次串行加法0.5

CPE 的下界由两个因素决定,取较大者:

  • 延迟界(Latency Bound):关键路径上的总延迟 / 元素数
  • 吞吐量界(Throughput Bound):功能单元的最大吞吐量

只展开不拆依赖链 → 受延迟界限制。多路累加 / 重结合 → 突破延迟界,逼近吞吐量界。


四、总结:优化层次与优先级

flowchart TD
    A["① 选对算法和数据结构"] --> B["② 代码级优化"]
    B --> C["③ 利用编译器优化"]
    C --> D["④ 处理器级优化"]
    style A fill:#e8f5e9
    style B fill:#e3f2fd
    style C fill:#fff3e0
    style D fill:#fce4ec
优先级层次效果量级说明
最高算法 + 数据结构O(n²) → O(n log n)这是最大的杠杆,任何底层优化都弥补不了算法的差距
代码级优化2~5×消除冗余计算、减少内存访问
编译器优化1.5~3×-O2 / -O3,给编译器足够的信息(restrictinline
可观处理器级优化2~10×循环展开 + 多路累加 + 缓存友好

先 profile,再优化。用 gprofperfvalgrind --tool=cachegrind 等工具找到热点,然后集中火力优化瓶颈部分——Amdahl 定律告诉我们,优化非热点代码几乎没有收益。



0 / 2000
正在加载评论...