# 18. 程序代码优化 > **课程**: 操作系统 - 程序代码优化 > **核心内容**: 机器无关优化、代码移动、消除不必要的内存引用、优化障碍(指针别名、函数副作用)、性能度量 --- ## 前置知识 - [[03_C语言编程基础]] -- C语言编译链接过程、指针与内存模型 - [[13_存储管理基础]] -- 存储器层次结构、局部性原理 --- ## 一、代码优化的概念与意义 ### 1.1 为什么需要代码优化 > [!important] 核心观点 > 常数因子也很重要!根据代码编写方式的不同,程序性能可能相差 **10倍** 以上。必须在多个层次上进行优化:算法、数据表示、过程调用和循环。 代码优化的目标: - 理解程序如何被编译和执行 - 学习如何度量程序性能并识别瓶颈 - 在不破坏代码模块化和通用性的前提下提升性能 ### 1.2 优化的层次 ```mermaid graph TB A["算法优化
选择更高效的算法"] --> B["数据结构优化
选择合适的内存布局"] B --> C["编译器优化
利用编译器选项"] C --> D["源代码级优化
代码移动、消除冗余"] D --> E["指令级优化
利用底层硬件特性"] 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{"循环体中有
不变量?"} B -->|是| C["循环不变量外提"] B -->|否| D{"循环迭代
次数少?"} C --> D D -->|是| E["循环展开"] D -->|否| F{"多个循环
数据相关?"} 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`?有什么潜在的代价?