732 lines
21 KiB
Markdown
732 lines
21 KiB
Markdown
# 18. 程序代码优化
|
||
|
||
> **课程**: 操作系统 - 程序代码优化
|
||
> **核心内容**: 机器无关优化、代码移动、消除不必要的内存引用、优化障碍(指针别名、函数副作用)、性能度量
|
||
|
||
---
|
||
|
||
## 前置知识
|
||
- [[03_C语言编程基础]] -- C语言编译链接过程、指针与内存模型
|
||
- [[13_存储管理基础]] -- 存储器层次结构、局部性原理
|
||
|
||
---
|
||
|
||
## 一、代码优化的概念与意义
|
||
|
||
### 1.1 为什么需要代码优化
|
||
|
||
> [!important] 核心观点
|
||
> 常数因子也很重要!根据代码编写方式的不同,程序性能可能相差 **10倍** 以上。必须在多个层次上进行优化:算法、数据表示、过程调用和循环。
|
||
|
||
代码优化的目标:
|
||
- 理解程序如何被编译和执行
|
||
- 学习如何度量程序性能并识别瓶颈
|
||
- 在不破坏代码模块化和通用性的前提下提升性能
|
||
|
||
### 1.2 优化的层次
|
||
|
||
```mermaid
|
||
graph TB
|
||
A["算法优化<br/>选择更高效的算法"] --> B["数据结构优化<br/>选择合适的内存布局"]
|
||
B --> C["编译器优化<br/>利用编译器选项"]
|
||
C --> D["源代码级优化<br/>代码移动、消除冗余"]
|
||
D --> E["指令级优化<br/>利用底层硬件特性"]
|
||
|
||
style A fill:#e8f5e9
|
||
style B fill:#e1f5fe
|
||
style C fill:#fff3e0
|
||
style D fill:#fce4ec
|
||
style E fill:#f3e5f5
|
||
```
|
||
|
||
### 1.3 编译器优化级别
|
||
|
||
| 优化级别 | 说明 | 特点 |
|
||
|---------|------|------|
|
||
| `-O0` | 不优化 | 编译最快,调试最方便,性能最差 |
|
||
| `-O1` | 基本优化 | 消除冗余代码、简单内联,平衡编译速度和性能 |
|
||
| `-O2` | 推荐优化 | 启用大多数优化,包括循环优化、指令调度等 |
|
||
| `-O3` | 激进优化 | 包含 `-O2` 所有优化,加上循环展开、SIMD向量化等 |
|
||
|
||
```bash
|
||
# 不同优化级别的编译
|
||
gcc -O0 prog.c -o prog_O0 # 无优化(调试用)
|
||
gcc -O1 prog.c -o prog_O1 # 基本优化
|
||
gcc -O2 prog.c -o prog_O2 # 推荐优化
|
||
gcc -O3 prog.c -o prog_O3 # 激进优化
|
||
|
||
# 比较不同优化级别的汇编输出
|
||
gcc -O0 -S prog.c -o prog_O0.s
|
||
gcc -O2 -S prog.c -o prog_O2.s
|
||
diff prog_O0.s prog_O2.s
|
||
```
|
||
|
||
> [!tip] 编译器的局限性
|
||
> 编译器通常**不会**改善渐近效率(大O复杂度),这取决于程序员选择最优算法。大O节省通常比常数因子更重要,但常数因子也确实重要。编译器在面对"优化障碍"时往往难以进行优化。
|
||
|
||
---
|
||
|
||
## 二、性能度量:CPE(每元素周期数)
|
||
|
||
### 2.1 CPE 概念
|
||
|
||
CPE(Cycles Per Element)是度量向量或列表操作程序性能的便捷方式:
|
||
|
||
$$T = CPE \times n + Overhead$$
|
||
|
||
其中 $n$ 是向量长度,$T$ 是总执行时间(时钟周期数)。
|
||
|
||
### 2.2 时间尺度
|
||
|
||
| 指标 | 说明 |
|
||
|------|------|
|
||
| 绝对时间 | 通常使用纳秒($10^{-9}$ 秒) |
|
||
| 时钟周期 | 100 MHz → 10ns/周期;2 GHz → 0.5ns/周期 |
|
||
|
||
### 2.3 CPE 度量示例
|
||
|
||
```c
|
||
// psum1: 朴素前缀和
|
||
void psum1(float a[], float p[], long int n) {
|
||
long int i;
|
||
p[0] = a[0];
|
||
for (i = 0; i < n; i++)
|
||
p[i] = p[i-1] + a[i];
|
||
}
|
||
|
||
// psum2: 循环展开的前缀和
|
||
void psum2(float a[], float p[], long int n) {
|
||
long int i;
|
||
p[0] = a[0];
|
||
for (i = 1; i < n-1; i += 2) {
|
||
float mid_val = p[i-1] + a[i];
|
||
p[i] = mid_val;
|
||
p[i+1] = mid_val + a[i+1];
|
||
}
|
||
if (i < n)
|
||
p[i] = p[i-1] + a[i];
|
||
}
|
||
```
|
||
|
||
> [!note] 性能对比
|
||
> `psum2` 通过循环展开减少了关键路径上的操作次数,从而降低了 CPE。
|
||
|
||
---
|
||
|
||
## 三、优化实例:向量求和的逐步优化
|
||
|
||
### 3.1 向量 ADT 定义
|
||
|
||
```c
|
||
typedef int data_t;
|
||
|
||
typedef struct {
|
||
int len;
|
||
data_t *data;
|
||
} vec_rec, *vec_ptr;
|
||
```
|
||
|
||
相关操作:
|
||
- `new_vec(len)` -- 创建指定长度的向量
|
||
- `vec_length(v)` -- 返回向量长度
|
||
- `get_vec_start(v)` -- 返回向量数据起始指针
|
||
- `get_vec_element(v, index, &dest)` -- 获取指定下标的元素(带边界检查)
|
||
|
||
### 3.2 combine1:抽象版本(基线)
|
||
|
||
```c
|
||
void combine1(vec_ptr v, data_t *dest) {
|
||
long int i;
|
||
*dest = IDENT;
|
||
for (i = 0; i < vec_length(v); i++) {
|
||
data_t val;
|
||
get_vec_element(v, i, &val);
|
||
*dest = *dest OP val;
|
||
}
|
||
}
|
||
```
|
||
|
||
> [!warning] 问题分析
|
||
> 每次循环迭代都调用 `vec_length(v)`,即使其返回值始终不变。这属于**循环不变量**问题。
|
||
|
||
### 3.3 combine2:代码移动(Code Motion)
|
||
|
||
```c
|
||
void combine2(vec_ptr v, data_t *dest) {
|
||
long int i;
|
||
long int length = vec_length(v); // 移出循环
|
||
*dest = IDENT;
|
||
for (i = 0; i < length; i++) {
|
||
data_t val;
|
||
get_vec_element(v, i, &val);
|
||
*dest = *dest OP val;
|
||
}
|
||
}
|
||
```
|
||
|
||
> [!tip] 代码移动优化
|
||
> 将 `vec_length()` 调用从循环体内移到循环之前。循环不变量外提(Loop-Invariant Code Motion)是最基本的优化之一。
|
||
|
||
### 3.4 combine3:消除过程调用(Reduction in Strength)
|
||
|
||
```c
|
||
void combine3(vec_ptr v, data_t *dest) {
|
||
long int i;
|
||
long int length = vec_length(v);
|
||
data_t *data = get_vec_start(v); // 获取数据指针
|
||
*dest = IDENT;
|
||
for (i = 0; i < length; i++) {
|
||
*dest = *dest OP data[i]; // 直接数组访问
|
||
}
|
||
}
|
||
```
|
||
|
||
**优化要点**:避免每次迭代都调用 `get_vec_element()` 函数来获取元素,而是在循环前获取数据指针,循环内直接进行指针引用。
|
||
|
||
### 3.5 combine4:消除不必要的内存引用
|
||
|
||
```c
|
||
void combine4(vec_ptr v, data_t *dest) {
|
||
long int i;
|
||
long int length = vec_length(v);
|
||
data_t *data = get_vec_start(v);
|
||
data_t acc = IDENT; // 使用局部变量累积
|
||
for (i = 0; i < length; i++)
|
||
acc = acc OP data[i];
|
||
*dest = acc; // 最后一次性写回
|
||
}
|
||
```
|
||
|
||
**汇编对比**:
|
||
|
||
```
|
||
combine3 的循环体(每次迭代需要3条内存指令):
|
||
movss (%rbp), %xmm0 # 从 dest 读取累加值
|
||
mulss (%rax,%rdx,4), %xmm0 # 乘以 data[i]
|
||
movss %xmm0, (%rbp) # 写回 dest
|
||
addq $1, %rdx # i++
|
||
cmpq %rdx, %r12 # 比较 i:limit
|
||
jg .L498 # 循环跳转
|
||
|
||
combine4 的循环体(只有1条内存指令):
|
||
mulss (%rax,%rdx,4), %xmm0 # acc *= data[i]
|
||
addq $1, %rdx # i++
|
||
cmpq %rdx, %rbp # 比较 limit:i
|
||
jg .L488 # 循环跳转
|
||
```
|
||
|
||
> [!important] 优化效果
|
||
> - `combine3`:每次迭代需要 **1次读 + 1次写** 内存
|
||
> - `combine4`:每次迭代只需要 **0次** 额外内存访问(`acc` 在寄存器中)
|
||
> - 局部变量 `acc` 告诉编译器:不需要在每轮循环都检查内存别名
|
||
|
||
### 3.6 优化效果汇总
|
||
|
||
| 函数 | 优化方法 | 整数+ CPE | 整数* CPE | 浮点+ CPE |
|
||
|------|---------|----------|----------|----------|
|
||
| combine1 | 抽象接口 | 12.00 | 12.00 | 12.00 |
|
||
| combine2 | 代码移动 | -- | -- | -- |
|
||
| combine3 | 消除过程调用 | -- | -- | -- |
|
||
| combine4 | 使用临时变量累积 | **2.00** | **3.00** | **3.00** |
|
||
|
||
---
|
||
|
||
## 四、代码移动(Code Motion)详解
|
||
|
||
### 4.1 循环不变量外提
|
||
|
||
将循环中每次迭代结果相同的计算移到循环之前:
|
||
|
||
```c
|
||
// 优化前:strlen 在每次循环都被调用
|
||
void lower1(char *s) {
|
||
int i;
|
||
for (i = 0; i < strlen(s); i++) // O(n^2) 复杂度!
|
||
if (s[i] >= 'A' && s[i] <= 'Z')
|
||
s[i] -= ('A' - 'a');
|
||
}
|
||
|
||
// 优化后:strlen 只调用一次
|
||
void lower2(char *s) {
|
||
int i;
|
||
int len = strlen(s); // O(n) 复杂度
|
||
for (i = 0; i < len; i++)
|
||
if (s[i] >= 'A' && s[i] <= 'Z')
|
||
s[i] -= ('A' - 'a');
|
||
}
|
||
```
|
||
|
||
> [!warning] 为什么编译器不能自动做这个优化?
|
||
> `strlen` 是一个函数调用,编译器**不知道**它是否有副作用,也不知道字符串长度在循环中是否会改变。编译器必须保守处理。
|
||
|
||
### 4.2 strlen 的内部实现
|
||
|
||
```c
|
||
size_t strlen(const char *s) {
|
||
int length = 0;
|
||
while (*s != '\0') {
|
||
s++;
|
||
length++;
|
||
}
|
||
return length;
|
||
}
|
||
```
|
||
|
||
`strlen` 本身是 $O(n)$ 的。如果在循环中每次迭代都调用它,整体复杂度就变成了 $O(n^2)$。
|
||
|
||
---
|
||
|
||
## 五、优化障碍(Optimization Blockers)
|
||
|
||
编译器在进行优化时受到两大根本性约束:
|
||
1. 编译器在**任何可能的条件下**都不能改变程序的行为
|
||
2. 当程序员知道的信息比编译器多时,需要程序员主动干预
|
||
|
||
### 5.1 指针别名(Memory Aliasing)
|
||
|
||
> [!danger] 别名问题
|
||
> 当两个不同的内存引用指向同一个存储位置时,就产生了别名。C语言允许地址算术运算和直接访问存储结构,因此别名问题非常容易出现。
|
||
|
||
```c
|
||
// twiddle1 和 twiddle2 看似等价,但实际上不一定!
|
||
void twiddle1(int *xp, int *yp) {
|
||
*xp += *yp;
|
||
*xp += *yp;
|
||
}
|
||
|
||
void twiddle2(int *xp, int *yp) {
|
||
*xp += 2 * *yp;
|
||
}
|
||
```
|
||
|
||
**当 `xp == yp` 时(别名情况)**:
|
||
- `twiddle1`:`*xp = *xp + *xp = 2*xp`,然后 `*xp = 2*xp + 2*xp = 4*xp`
|
||
- `twiddle2`:`*xp = *xp + 2*(*xp) = 3*xp`
|
||
- 结果不同!编译器不能将 `twiddle1` 优化为 `twiddle2`
|
||
|
||
**别名实例演示**:
|
||
|
||
```c
|
||
// v = [2, 3, 5]
|
||
combine3(v, get_vec_start(v) + 2); // dest 指向 v->data[2]
|
||
combine4(v, get_vec_start(v) + 2); // dest 指向 v->data[2]
|
||
```
|
||
|
||
| 函数 | 初始 | i=0 | i=1 | i=2 | 最终 |
|
||
|------|------|-----|-----|-----|------|
|
||
| combine3 | [2,3,5] | [2,3,1] | [2,3,2] | [2,3,6] | [2,3,36] |
|
||
| combine4 | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,30] |
|
||
|
||
> [!tip] 避免别名问题的方法
|
||
> 养成使用**局部变量**的习惯。在循环中用局部变量累积结果,循环结束后再写回目标地址。这是告诉编译器"不需要检查别名"的方式。
|
||
|
||
### 5.2 函数调用的副作用
|
||
|
||
```c
|
||
int f(int);
|
||
|
||
// func1:调用 f 4次
|
||
int func1(int x) {
|
||
return f(x) + f(x) + f(x) + f(x);
|
||
}
|
||
|
||
// func2:调用 f 1次
|
||
int func2(int x) {
|
||
return 4 * f(x);
|
||
}
|
||
```
|
||
|
||
看起来 `func1` 可以优化为 `func2`,但当 `f` 有副作用时就不行了:
|
||
|
||
```c
|
||
int counter = 0;
|
||
int f(int x) {
|
||
return counter++; // 每次调用返回不同的值!
|
||
}
|
||
```
|
||
|
||
> [!warning] 编译器的保守策略
|
||
> 编译器通常假设函数调用可能有副作用,因此不会轻易将多次函数调用合并。程序员可以:
|
||
> - 使用 `inline` 关键字建议编译器内联
|
||
> - 使用 `const`、`pure` 等属性标记无副作用的函数
|
||
> - 将函数调用结果缓存到局部变量中
|
||
|
||
---
|
||
|
||
## 六、循环优化技术
|
||
|
||
### 6.1 循环展开(Loop Unrolling)
|
||
|
||
减少循环控制开销,增加指令级并行性:
|
||
|
||
```c
|
||
// 优化前
|
||
for (int i = 0; i < n; i++) {
|
||
sum += a[i];
|
||
}
|
||
|
||
// 2x 循环展开
|
||
int i;
|
||
for (i = 0; i < n - 1; i += 2) {
|
||
sum += a[i] + a[i+1];
|
||
}
|
||
for (; i < n; i++) { // 处理剩余元素
|
||
sum += a[i];
|
||
}
|
||
```
|
||
|
||
### 6.2 循环合并(Loop Fusion)
|
||
|
||
将多个独立循环合并为一个,改善数据局部性:
|
||
|
||
```c
|
||
// 优化前:两次遍历
|
||
for (int i = 0; i < n; i++)
|
||
a[i] = b[i] + c[i];
|
||
for (int i = 0; i < n; i++)
|
||
d[i] = a[i] * 2;
|
||
|
||
// 优化后:一次遍历
|
||
for (int i = 0; i < n; i++) {
|
||
a[i] = b[i] + c[i];
|
||
d[i] = a[i] * 2;
|
||
}
|
||
```
|
||
|
||
### 6.3 循环不变量外提(Loop-Invariant Code Motion)
|
||
|
||
将循环中不变的计算移到循环外:
|
||
|
||
```c
|
||
// 优化前
|
||
for (int i = 0; i < n; i++) {
|
||
a[i] = b[i] * (x * y + z); // x*y+z 在循环中不变
|
||
}
|
||
|
||
// 优化后
|
||
int tmp = x * y + z;
|
||
for (int i = 0; i < n; i++) {
|
||
a[i] = b[i] * tmp;
|
||
}
|
||
```
|
||
|
||
### 6.4 循环优化流程图
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["循环代码"] --> B{"循环体中有<br>不变量?"}
|
||
B -->|是| C["循环不变量外提"]
|
||
B -->|否| D{"循环迭代<br>次数少?"}
|
||
C --> D
|
||
D -->|是| E["循环展开"]
|
||
D -->|否| F{"多个循环<br>数据相关?"}
|
||
E --> F
|
||
F -->|是| G["循环合并"]
|
||
F -->|否| H["保持原样"]
|
||
G --> I["优化后的循环"]
|
||
H --> I
|
||
```
|
||
|
||
---
|
||
|
||
## 七、内存访问优化与缓存友好性
|
||
|
||
### 7.1 空间局部性与时间局部性
|
||
|
||
程序的局部性原理(参见 [[13_存储管理基础]])是内存优化的基础:
|
||
|
||
- **空间局部性**:访问了某个地址,附近地址很可能也会被访问
|
||
- **时间局部性**:刚访问过的数据很可能再次被访问
|
||
|
||
### 7.2 按行优先 vs 按列遍历
|
||
|
||
```c
|
||
#define M 2048
|
||
#define N 2048
|
||
|
||
// 方式P1:按行遍历(空间局部性好)
|
||
int sumarrayrows(int a[M][N]) {
|
||
int i, j, sum = 0;
|
||
for (i = 0; i < M; i++)
|
||
for (j = 0; j < N; j++)
|
||
sum += a[i][j]; // 顺序访问内存
|
||
return sum;
|
||
}
|
||
|
||
// 方式P2:按列遍历(空间局部性差)
|
||
int sumarraycols(int a[M][N]) {
|
||
int i, j, sum = 0;
|
||
for (j = 0; j < N; j++)
|
||
for (i = 0; i < M; i++)
|
||
sum += a[i][j]; // 每次跳过 N 个元素
|
||
return sum;
|
||
}
|
||
```
|
||
|
||
> [!important] 实测性能差距
|
||
> 在 2GHz Intel Pentium 4 上:
|
||
> - **P1(按行遍历)**:59,393,288 时钟周期
|
||
> - **P2(按列遍历)**:1,277,877,876 时钟周期
|
||
> - P1 比 P2 **快 21.5 倍**!
|
||
|
||
### 7.3 局部性分析
|
||
|
||
| 数据 | 按行遍历(P1) | 按列遍历(P2) |
|
||
|------|-------------|-------------|
|
||
| 数组 `a` | 空间局部性好(顺序访问) | 空间局部性差(每次跳2048个单元) |
|
||
| 变量 `sum,i,j` | 时间局部性好(循环中反复访问) | 时间局部性好 |
|
||
| 循环指令 | 空间局部性好 + 时间局部性好 | 空间局部性好 + 时间局部性好 |
|
||
|
||
### 7.4 缓存友好的代码编写原则
|
||
|
||
```mermaid
|
||
graph LR
|
||
A["缓存友好原则"] --> B["按行优先遍历多维数组"]
|
||
A --> C["使用连续内存布局"]
|
||
A --> D["减少跨步访问"]
|
||
A --> E["数据结构对齐缓存行"]
|
||
|
||
style A fill:#e8f5e9
|
||
```
|
||
|
||
---
|
||
|
||
## 八、函数调用优化
|
||
|
||
### 8.1 内联函数(Inline)
|
||
|
||
将函数体直接展开到调用处,消除函数调用开销:
|
||
|
||
```c
|
||
// 优化前:函数调用有开销
|
||
int square(int x) {
|
||
return x * x;
|
||
}
|
||
int result = square(5);
|
||
|
||
// 使用 inline 关键字
|
||
static inline int square_inline(int x) {
|
||
return x * x;
|
||
}
|
||
int result = square_inline(5); // 编译器可能直接展开为 5*5
|
||
```
|
||
|
||
> [!note] 编译器的内联决策
|
||
> 即使不使用 `inline` 关键字,`-O2` 及以上优化级别下,编译器也会自动内联小型函数。但大型函数的内联会增加代码体积,可能导致指令缓存不友好。
|
||
|
||
### 8.2 消除过程调用
|
||
|
||
如 combine3 的优化所示,将函数调用替换为直接内存访问:
|
||
|
||
```c
|
||
// 优化前:每次迭代调用函数
|
||
for (i = 0; i < length; i++) {
|
||
data_t val;
|
||
get_vec_element(v, i, &val); // 函数调用开销
|
||
*dest = *dest OP val;
|
||
}
|
||
|
||
// 优化后:直接指针访问
|
||
data_t *data = get_vec_start(v);
|
||
for (i = 0; i < length; i++) {
|
||
*dest = *dest OP data[i]; // 直接数组访问
|
||
}
|
||
```
|
||
|
||
### 8.3 尾调用优化
|
||
|
||
当函数最后一步是调用另一个函数时,编译器可以复用当前栈帧:
|
||
|
||
```c
|
||
// 优化前:递归可能导致栈溢出
|
||
int factorial(int n) {
|
||
if (n <= 1) return 1;
|
||
return n * factorial(n - 1); // 不是尾调用(乘法在递归之后)
|
||
}
|
||
|
||
// 尾递归版本
|
||
int factorial_tail(int n, int acc) {
|
||
if (n <= 1) return acc;
|
||
return factorial_tail(n - 1, n * acc); // 尾调用
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 九、SIMD 与底层优化
|
||
|
||
### 9.1 SIMD 概念
|
||
|
||
SIMD(Single Instruction, Multiple Data)允许一条指令同时处理多个数据元素:
|
||
|
||
```c
|
||
// 标量版本:一次处理一个元素
|
||
for (int i = 0; i < n; i++) {
|
||
c[i] = a[i] + b[i];
|
||
}
|
||
|
||
// SSE 向量化(伪代码):一次处理4个float
|
||
// 编译器在 -O3 下可能自动生成 SIMD 指令
|
||
```
|
||
|
||
### 9.2 编译器自动向量化
|
||
|
||
```bash
|
||
# 查看编译器是否进行了向量化
|
||
gcc -O3 -ftree-vectorize -fopt-info-vec prog.c
|
||
|
||
# 明确启用 SIMD 优化
|
||
gcc -O3 -mavx2 prog.c # 使用 AVX2 指令集
|
||
gcc -O3 -msse4.2 prog.c # 使用 SSE4.2 指令集
|
||
```
|
||
|
||
### 9.3 数据对齐
|
||
|
||
```c
|
||
// 确保数据对齐以获得最佳 SIMD 性能
|
||
float *a = aligned_alloc(32, n * sizeof(float)); // 32字节对齐(AVX)
|
||
```
|
||
|
||
---
|
||
|
||
## 十、优化编译器的局限性
|
||
|
||
### 10.1 编译器的基本约束
|
||
|
||
> [!important] 根本约束
|
||
> 编译器在**任何可能的条件下**都不能改变程序的行为。这意味着即使某些行为只在极端情况下发生,编译器也必须保守地保留这些行为。
|
||
|
||
### 10.2 编译器难以优化的情况
|
||
|
||
| 情况 | 原因 | 程序员对策 |
|
||
|------|------|-----------|
|
||
| 指针别名 | 编译器不知道两个指针是否指向同一位置 | 使用局部变量累积结果 |
|
||
| 函数副作用 | 编译器不知道函数是否有副作用 | 使用 `inline`、`const`、`pure` 属性 |
|
||
| 数据范围 | 编译器不知道变量的实际取值范围 | 使用更精确的数据类型 |
|
||
| 循环边界 | 编译器不确定循环次数 | 使用常量或 `restrict` 关键字 |
|
||
|
||
### 10.3 `restrict` 关键字
|
||
|
||
C99 引入的 `restrict` 告诉编译器指针没有别名:
|
||
|
||
```c
|
||
// 使用 restrict 告诉编译器 src 和 dest 不重叠
|
||
void copy(int *restrict dest, const int *restrict src, int n) {
|
||
for (int i = 0; i < n; i++) {
|
||
dest[i] = src[i];
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 十一、性能测量与 Profiling 工具
|
||
|
||
### 11.1 常用性能分析工具
|
||
|
||
| 工具 | 用途 | 命令示例 |
|
||
|------|------|---------|
|
||
| `time` | 测量程序总执行时间 | `time ./prog` |
|
||
| `perf` | Linux 性能计数器分析 | `perf stat ./prog` |
|
||
| `gprof` | GNU 函数级 profiling | `gcc -pg prog.c && ./a.out && gprof` |
|
||
| `valgrind/callgrind` | 缓存和分支预测分析 | `valgrind --tool=callgrind ./prog` |
|
||
| `cachegrind` | 缓存命中率分析 | `valgrind --tool=cachegrind ./prog` |
|
||
|
||
### 11.2 使用 perf 进行分析
|
||
|
||
```bash
|
||
# 基本性能统计
|
||
perf stat ./prog
|
||
|
||
# 详细缓存分析
|
||
perf stat -e cache-references,cache-misses ./prog
|
||
|
||
# 函数级热点分析
|
||
perf record ./prog
|
||
perf report
|
||
```
|
||
|
||
### 11.3 使用 gprof 进行分析
|
||
|
||
```bash
|
||
# 编译时加入 profiling 支持
|
||
gcc -pg -O2 prog.c -o prog
|
||
|
||
# 运行程序(生成 gmon.out)
|
||
./prog
|
||
|
||
# 查看分析结果
|
||
gprof prog gmon.out > analysis.txt
|
||
```
|
||
|
||
### 11.4 优化工作流程
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["编写正确的代码"] --> B["选择合适的算法"]
|
||
B --> C["使用 profiling 找到瓶颈"]
|
||
C --> D{"瓶颈在哪里?"}
|
||
D -->|循环| E["循环优化:展开、外提、合并"]
|
||
D -->|内存| F["内存优化:局部性、缓存友好"]
|
||
D -->|函数调用| G["内联、消除过程调用"]
|
||
D -->|I/O| H["缓冲、批量处理"]
|
||
E --> I["测量优化效果"]
|
||
F --> I
|
||
G --> I
|
||
H --> I
|
||
I --> J{"性能满足要求?"}
|
||
J -->|否| C
|
||
J -->|是| K["完成"]
|
||
```
|
||
|
||
---
|
||
|
||
## 十二、优化总结
|
||
|
||
### 12.1 机器无关优化的核心策略
|
||
|
||
| 优化策略 | 说明 | 对应函数 |
|
||
|---------|------|---------|
|
||
| **代码移动** | 将循环不变量移出循环 | combine1 → combine2 |
|
||
| **消除过程调用** | 用直接内存访问替代函数调用 | combine2 → combine3 |
|
||
| **消除不必要内存引用** | 使用局部变量/寄存器累积 | combine3 → combine4 |
|
||
| **循环展开** | 减少循环控制开销 | psum1 → psum2 |
|
||
|
||
### 12.2 关键原则
|
||
|
||
> [!abstract] 代码优化的核心原则
|
||
> 1. **先正确,后优化**:保证代码正确性是前提
|
||
> 2. **度量驱动**:使用 profiling 工具找到真正的瓶颈
|
||
> 3. **算法优先**:$O(n \log n)$ 的算法比 $O(n^2)$ 的优化更重要
|
||
> 4. **利用局部性**:按行访问数组,使用连续内存布局
|
||
> 5. **减少函数调用**:在热循环中避免不必要的函数调用
|
||
> 6. **使用局部变量**:告诉编译器数据没有别名问题
|
||
> 7. **信任编译器**:`-O2` 通常足够,除非有明确的性能需求
|
||
|
||
---
|
||
|
||
## 本章术语
|
||
|
||
| 术语 | 英文 | 说明 |
|
||
|------|------|------|
|
||
| 代码移动 | Code Motion | 将循环不变量计算移到循环外 |
|
||
| CPE | Cycles Per Element | 每元素处理所需的时钟周期数 |
|
||
| 别名 | Aliasing | 两个不同指针指向同一内存位置 |
|
||
| 循环展开 | Loop Unrolling | 复制循环体以减少循环控制开销 |
|
||
| 内联 | Inline | 将函数体展开到调用处 |
|
||
| SIMD | Single Instruction Multiple Data | 单指令多数据并行 |
|
||
| 向量化 | Vectorization | 利用 SIMD 指令并行处理数据 |
|
||
| 代码选择 | Code Selection | 编译器选择合适的机器指令 |
|
||
| 寄存器分配 | Register Allocation | 将变量分配到 CPU 寄存器 |
|
||
| `restrict` | Restrict Qualifier | 告诉编译器指针没有别名 |
|
||
|
||
---
|
||
|
||
## 复习与思考
|
||
|
||
1. 为什么 `combine1` 到 `combine4` 的优化能显著降低 CPE?每一步消除了什么开销?
|
||
2. 当 `xp` 和 `yp` 指向同一地址时,`twiddle1` 和 `twiddle2` 的结果为什么不同?
|
||
3. 为什么按列遍历二维数组比按行遍历慢很多?这与 [[13_存储管理基础]] 中的局部性原理有什么关系?
|
||
4. 编译器为什么不能自动将 `func1` 优化为 `func2`?程序员应该如何帮助编译器?
|
||
5. 在什么情况下应该使用 `-O3` 而不是 `-O2`?有什么潜在的代价?
|