计算机系统基础-01:程序性能优化
性能分析
程序性能不仅由算法复杂度决定,常数因子也会造成明显差异。优化通常需要同时考虑算法、数据表示、过程调用和循环结构。
性能分析的基本目标:
- 理解程序如何被编译和执行。
- 测量程序性能并定位瓶颈。
- 在不破坏代码模块化和通用性的前提下改进性能。
常用时间尺度包括绝对时间和时钟周期。机器指令通常以纳秒为尺度;处理器频率越高,单个时钟周期越短。例如 100 MHz 的时钟周期约为 10 ns,2 GHz 的时钟周期约为 0.5 ns。
对向量或链表类程序,常用 CPE Cycles Per Element 表示单位元素的处理代价:
其中 是数据长度, 是总运行时间。CPE 越低,单位元素处理效率越高。
性能优化应优先关注内层循环,因为内层循环通常贡献最多执行次数。
prefix sum 示例可用于观察 CPE:
1
2
3
4
5
6
7
8
9 void psum1(float a[], float p[], long n)
{
long i;
/* 前缀和第 0 项直接等于输入第 0 项。 */
p[0] = a[0];
for (i = 1; i < n; i++)
/* 每个 p[i] 依赖上一项 p[i-1],形成串行依赖链。 */
p[i] = p[i-1] + a[i];
}一次循环处理两个元素可减少部分循环控制开销:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 void psum2(float a[], float p[], long n)
{
long i;
p[0] = a[0];
for (i = 1; i < n-1; i += 2) {
/* 先计算 p[i],复用该中间值继续计算 p[i+1]。 */
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];
}
psum2降低了循环开销,但前缀和本身存在数据依赖:p[i]依赖p[i-1],因此无法完全并行。
机器无关优化
向量抽象数据类型
示例程序使用一个向量 ADT。它用结构体保存长度和数据指针,并通过过程接口访问长度、元素和起始地址。
1
2
3
4
5
6
7
8
9
10
11
12
13 typedef long data_t;
typedef struct {
/* 当前向量长度。 */
long len;
/* 指向底层数组的指针。 */
data_t *data;
} vec_rec, *vec_ptr;
vec_ptr new_vec(long len);
long vec_length(vec_ptr v);
data_t *get_vec_start(vec_ptr v);
int get_vec_element(vec_ptr v, long index, data_t *dest);
get_vec_element会进行边界检查,new_vec负责分配头部结构和数组空间;若数组分配失败,需要释放已分配的头部结构并返回NULL。这种接口封装简化了调用方逻辑,但也可能隐藏循环中的访问成本。
combine1将向量中所有元素合并,IDENT和OP由编译期常量决定,可表示整数或浮点数的加法、乘法。
1
2
3
4
5
6
7
8
9
10
11
12 void combine1(vec_ptr v, data_t *dest)
{
long i;
*dest = IDENT;
for (i = 0; i < vec_length(v); i++) {
data_t val;
/* 每轮通过函数接口读取一个元素。 */
get_vec_element(v, i, &val);
/* 将当前元素并入 dest 指向的累积结果。 */
*dest = *dest OP val;
}
}
循环不变量
combine1 在每次循环迭代中调用 vec_length(v)。该值在循环期间不变,可以移动到循环外:
1 | void combine2(vec_ptr v, data_t *dest) |
代码移动适用于循环不变量:表达式结果在每次迭代中相同,就应避免重复计算。
减少过程调用
combine2 仍在每次迭代中调用 get_vec_element。可以提前取得数组起始地址,在循环中直接访问数组:
1 | void combine3(vec_ptr v, data_t *dest) |
该优化减少了内层循环中的函数调用和边界检查。PPT 中指出,这一修改本身不一定带来明显性能提升,因为仍存在不必要的内存读写。
消除不必要内存引用
combine3 在每次循环中读取并写回 *dest。若将累积值保存在局部变量中,编译器通常可以把它放入寄存器:
1 | void combine4(vec_ptr v, data_t *dest) |
combine3 的循环每轮需要从 dest 读一次、向 dest 写一次。combine4 只在循环结束后写回结果,减少了每轮内存访问。
1 | # combine3 的核心行为 |
优化效果
以 CPE 为单位,combine4 相比 combine1 有明显下降:
| 函数 | 方法 | 整数加法 | 整数乘法 | 浮点加法 | 浮点乘法 |
|---|---|---|---|---|---|
combine1 |
抽象接口,-O1 |
10.12 | 10.12 | 10.17 | 11.14 |
combine4 |
局部变量累积 | 1.27 | 3.01 | 3.01 | 5.01 |
主要收益来自:
- 减少内层循环中的函数调用。
- 减少内层循环中的内存引用。
- 让编译器更容易使用寄存器保存临时值。
优化编译器的能力与限制
优化编译器通常能完成寄存器分配、指令选择、指令排序和小规模低效代码消除,但通常不会改变算法复杂度。选择合适算法和数据结构仍由程序员负责。
编译器的基本约束是:在任何可能条件下都不能改变程序行为。因此它会保守处理两类常见阻碍。
有些性质对程序员很明显,但编译器不一定能证明。例如某个 int 变量实际只会取少量枚举值,或者数组下标实际不会越界;只要无法在语言语义层面证明,编译器就必须保守。
函数调用与副作用
以下两个函数在数学表达上相似,但不一定等价:
1 | int f(int); |
若 f 有副作用,则两者行为不同:
1 | int counter = 0; |
此时 func1 会调用 f 四次,而 func2 只调用一次。编译器不能把 func1 自动改写为 func2。
内存别名
内存别名指两个不同表达式引用同一内存位置。例如:
1 | void twiddle1(int *xp, int *yp) |
若 xp == yp,两段代码结果不同。类似问题也会影响 combine3 和 combine4:
1 | v = [2, 3, 5]; |
| 函数 | 初始状态 | 循环前 | i=0 |
i=1 |
i=2 |
最终状态 |
|---|---|---|---|---|---|---|
combine3 |
[2,3,5] |
[2,3,1] |
[2,3,2] |
[2,3,6] |
[2,3,36] |
[2,3,36] |
combine4 |
[2,3,5] |
[2,3,5] |
[2,3,5] |
[2,3,5] |
[2,3,5] |
[2,3,30] |
combine3 先把目标位置改为 IDENT,随后又从同一数组位置读取值,因此结果受到别名影响。combine4 在局部变量中累积,最后才写回。
在 C 语言中,地址运算和直接内存访问容易产生别名。内层循环中使用局部变量累积,既能减少内存访问,也能向编译器表达更明确的数据依赖。
现代处理器
基本特征
现代处理器通常具有两个关键特征:
- Superscalar:每个时钟周期可执行多个操作。
- Out-of-Order Execution:实际执行顺序可以不同于汇编程序中的指令顺序,只要保持程序语义不变。
这些机制利用 指令级并行 Instruction-Level Parallelism, ILP 提高吞吐。
处理器结构
现代处理器可分为两个主要部分。
| 部件 | 职责 |
|---|---|
| 指令控制单元 ICU | 从内存读取指令,将指令解码为较简单的操作,并处理分支预测、寄存器更新和提交。 |
| 执行单元 EU | 接收操作并在功能单元上执行,包括算术、加载、存储和分支等。 |

指令解码与寄存器重命名
一条复杂机器指令可能被解码为多个简单操作。例如:
1 | addq %rax, 8(%rdx) # 将 %rax 加到内存地址 8(%rdx) 对应的值上 |
可理解为:
1 | load 8(%rdx) -> t1 # 从内存读出原值 |
寄存器重命名将同一程序寄存器的不同版本分开表示。例如:
1 | addq $8, %rdx # 程序视角:更新 %rdx |
重命名表维护程序寄存器与内部标签之间的对应关系,这样可以消除部分名称相关。以内层循环为例:
1 | .L25: |
可拆分为:
1 | load (%rdx.0) -> t.1 # 读取当前元素,生成临时值 |
其中:
load从内存生成临时值。mulq只处理寄存器操作数。%xmm0和%rdx在不同迭代中具有不同版本。- 条件码也可像寄存器一样被标记,用于连接比较指令和分支指令。
若分支预测失败,执行单元通知指令控制单元,后者丢弃错误路径上的操作,并从正确位置重新取指。
功能单元与流水线
功能单元从 ICU 接收操作,并在每个周期执行多个操作。Haswell Core i7 中的典型端口功能如下:
| 端口 | 可执行操作 |
|---|---|
| 0 | 整数运算、浮点乘法、整数/浮点除法、分支 |
| 1 | 整数运算、浮点加法、整数乘法、浮点乘法 |
| 2 | 加载、地址计算 |
| 3 | 加载、地址计算 |
| 4 | 存储 |
| 5 | 整数运算 |
| 6 | 整数运算、分支 |
| 7 | 存储地址计算 |
两个重要性能指标:
- 延迟 Latency:一个操作从发射到完成需要的周期数。
- 发射间隔 Issue Time:同一功能单元上连续发射同类操作所需间隔。
如果整数乘法延迟为 3 周期、发射间隔为 1 周期,说明该功能单元是流水线化的:单个操作完成较慢,但可以每周期接收新操作。
吞吐下界由可执行该操作的功能单元数量、发射间隔和其他资源限制共同决定。例如浮点乘法可由多个端口执行时,理论吞吐可低于 1 cycle/op;但在完整循环中,加载、存储和循环控制也可能成为限制因素。
乱序调度
操作会在满足以下条件时被派发到功能单元:
- 所有操作数已经就绪。
- 对应类型的功能单元可用。
执行结果可以通过数据转发直接传给后续功能单元,不必先写回寄存器文件。
乱序执行提高的是可用并行度;若代码存在严格数据依赖,关键路径仍会限制性能。
关键路径与循环展开
combine4 使用单个累积变量 acc,每次迭代都依赖上一轮结果,因此形成关键路径。对乘法而言,浮点乘法延迟会直接限制吞吐。

循环展开可以减少循环控制开销:
1 | void combine5(vec_ptr v, data_t *dest) |
这是 2 * 1 展开:每轮处理 2 个元素,但只有 1 个累积变量,因此仍有单条依赖链。

多累积变量
多累积变量可把一条依赖链拆成多条独立依赖链:
1 | void combine6(vec_ptr v, data_t *dest) |
该方法让两个累积链并行执行,更容易利用多个功能单元。

重结合变换
重结合改变表达式结合顺序:
1 | void combine7(vec_ptr v, data_t *dest) |
这种写法让部分元素先彼此组合,再与累积值组合,可缩短部分依赖链。需要注意,对于浮点运算,重结合可能改变舍入结果,编译器通常不会在默认条件下自动执行这类变换。

机器相关优化包括:循环展开,多累积变量,重结合变换。
机器无关优化包括:消除循环低效,减少过程调用,消除不必要的内存引用。
分支预测
ICU 需要提前生成足够操作以保持 EU 忙碌,因此会进行分支预测和推测执行。
分支预测的流程:
- 预测分支是否跳转。
- 按预测路径取指、解码和执行。
- 在分支结果确认前,不真正修改体系结构可见的寄存器或内存状态。
- 若预测失败,丢弃错误路径结果并从正确位置继续。
退休单元将指令放入 FIFO 队列,并保证顺序语义:
- Retired:指令操作完成,分支预测正确,寄存器或内存更新正式生效。
- Flushed:分支预测失败,已计算结果被丢弃,不改变程序状态。
Core i7 上一次分支预测失败大约浪费 19 个时钟周期。可预测分支通常影响较小,例如循环结束分支通常只在最后一次产生预测失败。
避免不可预测分支
以下代码根据条件进行交换:
1 | void minmax1(int a[], int b[], int n) |
如果 a[i] > b[i] 难以预测,分支失败会造成损失。可改写为条件表达式:
1 | void minmax2(int a[], int b[], int n) |
该版本避免显式分支,更适合分支结果随机的情况。若分支高度可预测,则改写收益可能不明显。
加载和存储性能
加载和存储通过数据缓存访问内存。加载或存储单元每周期可发起的操作数有限,因此内存访问可能成为吞吐瓶颈。
链表长度计算示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 typedef struct ELE {
/* 指向下一个链表节点。 */
struct ELE *next;
/* 当前节点保存的数据。 */
int data;
} list_ele, *list_ptr;
long list_len(list_ptr ls)
{
long len = 0;
while (ls) {
len++;
/* 下一次迭代的地址依赖当前节点的 next。 */
ls = ls->next;
}
return len;
}循环中下一次加载地址依赖当前节点的
next指针,形成指针追逐,难以通过乱序执行并行化。
存储也可能受限。示例:
1
2
3
4
5
6
7
8
9 void array_clear(int *dest, int n)
{
int i;
/* limit 控制循环次数;示例关注连续存储的吞吐行为。 */
int limit = n - 3;
for (i = 0; i < limit; i++)
/* 连续写入 dest[i],地址按 int 大小递增。 */
dest[i] = 0;
}连续写数组时,吞吐受存储单元发射能力和存储缓冲区影响。
存储后立即读取也可能产生相关性:
1
2
3
4
5
6
7
8
9
10
11 void write_read(long *src, long *dest, long n)
{
long cnt = n;
long val = 0;
while (cnt) {
/* 先写 dest。若 src 与 dest 可能别名,后续读 src 需等待写入关系确认。 */
*dest = val;
val = (*src) + 1;
cnt--;
}
}若调用
write_read(&a[0], &a[1], 3),读写地址不同;若调用write_read(&a[0], &a[0], 3),读写地址相同,会出现存储到加载的相关性。处理器需要通过存储缓冲区匹配地址并转发数据,以保证语义正确。
性能调优
调优流程
性能调优应先测量,再修改:
- 做计时研究。
- 识别热点。
- 优先使用更好的算法或数据结构。
- 在热点代码上进行局部优化。
不应先从局部代码技巧开始。算法和数据结构的改进通常比单条语句优化更重要。
Amdahl 定律
若程序中比例为 的部分被加速 倍,总体加速比为:
当该部分被无限加速时:
PPT 示例中,原始时间 ,热点部分时间 ,因此 ,理论极限加速比 。若新时间为 ,实际加速比为 。
应优先优化耗时最多的热点部分。热点可以使用
gprof工具来定位。


