69、代码优化技术详解

代码优化技术详解

1. 引言

在代码生成过程中,简单的编译器可能会生成质量较差的代码。例如,每次使用变量作为右值时都会进行加载操作,每次赋值都会进行存储操作。为了提高代码质量,我们可以采用多种优化技术,如窥孔优化和基本块内的冗余消除。

2. 窥孔优化

窥孔优化是一种相对简单但有效的代码优化方法。它通过在目标代码上滑动一个固定大小的指令窗口(窥孔),寻找次优的指令模式,并进行相应的转换。以下是几种常见的窥孔优化方式:
- 消除冗余加载和存储 :当加载指令产生的值已经存在于寄存器中时,可消除该加载指令。例如:

r2 := r1 + 5
i := r2
r3 := i
r3 := r3 × 3

优化后变为:

r2 := r1 + 5
i := r2
r3 := r2 × 3

如果在窥孔内有两次对同一位置的存储操作,且中间没有对该位置的加载操作,通常可以消除第一次存储操作。
- 常量折叠 :对于在编译时可以计算的表达式,窥孔优化器可以在编译时进行计算,避免运行时的计算开销。例如:

r2 := 3 × 2

优化后变为:

r2 := 6
  • 常量传播 :当我们知道某个变量在程序的特定点具有常量值时,可以用该常量替换变量的使用。例如:
r2 := 4
r3 := r1 + r2
r2 := ...

优化后变为:

r2 := 4
r3 := r1 + 4
r2 := ...

如果后续对 r2 的赋值表明之前的常量值不再需要(即该值为死值),则可以进一步优化。
- 公共子表达式消除 :当同一计算在窥孔内出现两次时,通常可以消除第二次计算。例如:

r2 := r1 × 5
r2 := r2 + r3
r3 := r1 × 5

优化后变为:

r4 := r1 × 5
r2 := r4 + r3
r3 := r4

这里需要一个额外的寄存器来保存公共值。
- 复制传播 :当我们知道寄存器 b 包含与寄存器 a 相同的值时,可以用 a 替换 b 的使用,前提是 a b 都没有被修改。例如:

r2 := r1
r3 := r1 + r2
r2 := 5

优化后变为:

r2 := r1
r3 := r1 + r1
r2 := 5

复制传播可以减少寄存器压力,并可能消除一些指令。
- 强度削弱 :利用数值恒等式,用较便宜的指令替换较昂贵的指令。例如,乘以或除以 2 的幂可以用加法或移位操作代替:

r1 := r2 × 2

可以变为:

r1 := r2 + r2

r1 := r2 << 1
r1 := r2 / 2

可以变为:

r1 := r2 >> 1

但当 r2 为负数时,最后一个替换可能不正确。
- 消除无用指令 :像 r1 := r1 + 0 r1 := r1 × 1 这样的指令可以完全删除。
- 填充加载和分支延迟 :在某些情况下,可以通过重新安排指令来填充加载和分支延迟,提高指令执行效率。
- 利用指令集 :特别是在复杂指令集计算机(CISC)上,一系列简单指令可以用更少的复杂指令替换。例如:

r1 := r1 & 0x0000FF00
r1 := r1 >> 8

可以用“提取字节”指令替换。

窥孔优化器由于使用固定大小的小窗口,速度通常很快,并且相对容易编写。它可以显著提高简单代码的性能,但大多数优化形式并非窥孔优化所特有,更通用的优化方法可能会做得更好。

3. 基本块内的冗余消除

为了实现局部优化,编译器首先需要识别语法树中对应于基本块的片段。基本块大致由按中序遍历相邻的树节点组成,并且不包含选择或迭代构造。

3.1 运行示例

我们以一个计算二项式系数的子例程为例,展示代码优化的过程。该子例程的 C 代码如下:

void combinations(int n, int *A) {
    int i, t;
    A[0] = 1;
    A[n] = 1;
    t = 1;
    for (i = 1; i <= n/2; i++) {
        t = (t * (n+1-i)) / i;
        A[i] = t;
        A[n-i] = t;
    }
}

该代码利用了二项式系数的对称性,即对于所有 0 ≤ m ≤ n ,有 C(n, m) = C(n, n - m)

对应的语法树和控制流图可以帮助我们更好地理解代码结构。为了避免指令间的人为干扰,我们使用一种中级中间形式(IF),其中每个计算值都放在单独的虚拟寄存器中,命名为 v1, v2, ...

初始的控制流图如下:

Block 1:
    sp := sp – 8
    v1 := r0  ––  n
    n := v1
    v2 := r1  ––  A
    A := v2
    v3 := A
    v4 := 1
    *v3 := v4
    v5 := A
    v6 := n
    v7 := 4
    v8 := v6 × v7
    v9 := v5 + v8
    v10 := 1
    *v9 := v10
    v11 := 1
    t := v11
    v12 := 1
    i := v12
    goto Block 3
Block 4:
    sp := sp + 8
    goto *lr
Block 3:
    v39 := i
    v40 := n
    v41 := 2
    v42 := v40 div v41
    v43 := v39 <_ v42
    if v43 goto Block 2
    else goto Block 4
Block 2:
    v13 := t
    v14 := n
    v15 := 1
    v16 := v14 + v15
    v17 := i
    v18 := v16 − v17
    v19 := v13 × v18
    v20 := i
    v21 := v19 div v20
    t := v21
    v22 := A
    v23 := i
    v24 := 4
    v25 := v23 × v24
    v26 := v22 + v25
    v27 := t
    *v26 := v27
    v28 := A
    v29 := n
    v30 := i
    v31 := v29 − v30
    v32 := 4
    v33 := v31 × v32
    v34 := v28 + v33
    v35 := t
    *v34 := v35
    v36 := i
    v37 := 1
    v38 := v36 + v37
    i := v38
    goto Block 3
3.2 值编号

值编号是一种用于优化基本块内代码的技术。它通过为符号等价的计算分配相同的名称(虚拟寄存器),来识别和消除冗余计算。具体步骤如下:
1. 扫描指令 :按顺序扫描基本块的指令,维护一个字典来跟踪已经加载或计算的值。
2. 处理加载指令
- 对于 vi := x ,检查字典中是否已经有 x 在某个寄存器 vj 中。如果是,则将 vi 的使用替换为 vj 的使用。
- 对于 vi := c ,如果 c 足够小可以作为计算指令的立即操作数,则将 vi 的使用替换为常量 c ;如果 c 较大,检查字典中是否已经有该常量在某个寄存器中,若有则替换,否则加载该常量到 vi 并更新字典。
3. 处理计算指令 :对于 vi := vj op vk ,先根据字典替换操作数,若两个操作数都是常量,则进行常量折叠。然后检查字典中是否已经有该计算的结果,若有则替换,否则生成相应指令并更新字典。
4. 处理存储指令 :对于 x := vi ,更新字典中 x 的信息,并标记内存中 x 的值为陈旧。如果 x 可能是其他变量的别名,需要相应处理。
5. 生成存储和分支指令 :在块的末尾,遍历字典,为所有内存值陈旧的变量生成存储指令,然后生成结束块的分支指令。

在值编号过程中,我们自动执行了一些重要的操作,如识别公共子表达式、常量折叠、强度削弱、常量和复制传播以及消除冗余加载和存储。

为了增加可识别的公共子表达式数量,我们可以在将语法树线性化之前对表达式进行规范化。但简单的规范化技术可能无法识别所有冗余,并且在某些情况下可能会导致算术溢出或数值不稳定。

对于别名问题,简单的做法是假设数组元素赋值可能影响其他元素,指针赋值可能影响同类型的所有变量,子例程调用可能影响其作用域内的所有变量。但这种假设过于保守,更激进的编译器会进行更深入的符号分析来缩小潜在别名的范围。

经过局部冗余消除和强度削弱后,组合子例程的控制流图如下:

Block 1:
    sp := sp − 8
    v1 := r0  –– n
    n := v1
    v2 := r1  –– A
    A := v2
    *v2 := 1
    v8 := v1 << 2
    v9 := v2 + v8
    *v9 := 1
    t := 1
    i := 1
    goto Block 3
Block 4:
    sp := sp + 8
    goto *lr
Block 3:
    v39 := i
    v40 := n
    v42 := v40 >> 1
    v43 := v39 <_ v42
    if v43 goto Block 2
    else goto Block 4
Block 2:
    v13 := t
    v14 := n
    v16 := v14 + 1
    v17 :=  i
    v18 := v16 − v17
    v19 := v13 × v18
    v21 := v19 div v17
    v22 := A
    v25 := v17 << 2
    v26 := v22 + v25
    *v26 := v21
    v31 := v14 − v17
    v33 := v31 << 2
    v34 := v22 + v33
    *v34 := v21
    v38 := v17 + 1
    t := v21
    i := v38
    goto Block 3

我们消除了 21 条指令,其中 13 条在循环体中,并且对两条乘以常量 4 和一条除以 2 的指令进行了强度削弱,用移位操作替换。

4. 总结

通过窥孔优化和基本块内的冗余消除等技术,我们可以显著提高代码的质量和性能。窥孔优化适用于快速处理简单的代码优化,而基本块内的优化则更侧重于局部代码的冗余消除和性能提升。在实际应用中,我们可以结合多种优化技术,根据具体情况选择合适的方法。

以下是窥孔优化的几种方式总结表格:
| 优化方式 | 示例(优化前) | 示例(优化后) |
| — | — | — |
| 消除冗余加载和存储 | r2 := r1 + 5; i := r2; r3 := i; r3 := r3 × 3 | r2 := r1 + 5; i := r2; r3 := r2 × 3 |
| 常量折叠 | r2 := 3 × 2 | r2 := 6 |
| 常量传播 | r2 := 4; r3 := r1 + r2; r2 := ... | r2 := 4; r3 := r1 + 4; r2 := ... |
| 公共子表达式消除 | r2 := r1 × 5; r2 := r2 + r3; r3 := r1 × 5 | r4 := r1 × 5; r2 := r4 + r3; r3 := r4 |
| 复制传播 | r2 := r1; r3 := r1 + r2; r2 := 5 | r2 := r1; r3 := r1 + r1; r2 := 5 |
| 强度削弱 | r1 := r2 × 2 | r1 := r2 + r2 r1 := r2 << 1 |
| 消除无用指令 | r1 := r1 + 0 | 无 |

下面是值编号处理指令的流程图:

graph TD;
    A[开始扫描指令] --> B{是否为加载指令};
    B -- 是 --> C[处理加载指令];
    B -- 否 --> D{是否为计算指令};
    D -- 是 --> E[处理计算指令];
    D -- 否 --> F{是否为存储指令};
    F -- 是 --> G[处理存储指令];
    F -- 否 --> H[其他指令处理];
    C --> I[更新字典];
    E --> I;
    G --> I;
    I --> J{是否到达块末尾};
    J -- 否 --> A;
    J -- 是 --> K[生成存储和分支指令];
    K --> L[结束];
5. 优化技术的综合应用与影响

在实际的代码优化过程中,窥孔优化和基本块内的冗余消除并不是孤立进行的,而是相互配合,共同提升代码的性能。窥孔优化由于其快速和简单的特点,可以作为初步的优化步骤,快速处理一些明显的次优指令模式。而基本块内的冗余消除则更深入地分析代码结构,通过值编号等技术消除更复杂的冗余计算。

5.1 优化技术的配合流程

以下是一个综合使用窥孔优化和基本块内冗余消除的优化流程:
1. 生成初始代码 :使用简单的代码生成器生成初始的目标代码,可能包含大量的冗余和低效指令。
2. 应用窥孔优化 :对初始代码进行窥孔优化,快速消除一些明显的冗余,如冗余加载和存储、无用指令等。
3. 识别基本块 :将优化后的代码划分为基本块,为后续的基本块内优化做准备。
4. 值编号与优化 :对每个基本块进行值编号,识别和消除公共子表达式、进行常量折叠等操作,进一步优化代码。
5. 重复优化 :根据需要,可以多次重复窥孔优化和基本块内优化的过程,以达到更好的优化效果。

5.2 优化技术对代码性能的影响

通过上述优化技术的综合应用,代码的性能可以得到显著提升。以我们的组合子例程为例,经过局部冗余消除和强度削弱后,消除了 21 条指令,其中 13 条在循环体中。循环体中的优化尤为重要,因为循环通常会被多次执行,减少循环体内的指令数量可以大大提高程序的执行效率。

此外,优化技术还可以减少寄存器的使用,降低寄存器压力。例如,复制传播可以用一个寄存器替换另一个寄存器的使用,避免不必要的寄存器分配和复制操作。

6. 优化技术的局限性与挑战

尽管窥孔优化和基本块内的冗余消除等技术可以带来显著的性能提升,但它们也存在一些局限性和挑战。

6.1 窥孔优化的局限性

窥孔优化由于使用固定大小的小窗口,只能看到局部的指令模式,无法进行全局的优化。例如,它可能无法识别跨越多个基本块的公共子表达式,也无法对循环结构进行有效的优化。此外,窥孔优化的效果依赖于所定义的优化模式,如果模式定义不全面,可能会遗漏一些优化机会。

6.2 基本块内优化的挑战

基本块内优化主要关注基本块内部的代码,对于跨基本块的优化问题处理能力有限。例如,在处理循环时,基本块内优化可能无法将循环不变代码移出循环,从而无法充分发挥循环优化的潜力。另外,别名分析是基本块内优化的一个难点,简单的别名假设过于保守,而更精确的别名分析需要复杂的符号分析和程序分析技术。

6.3 优化技术的权衡

在实际应用中,我们需要在优化效果和优化成本之间进行权衡。更复杂的优化技术通常可以带来更好的优化效果,但也需要更多的计算资源和时间。例如,深入的别名分析和全局优化可能需要大量的程序分析和符号计算,会显著增加编译时间。因此,我们需要根据具体的应用场景和需求,选择合适的优化技术和优化级别。

7. 未来优化技术的发展趋势

随着计算机硬件和软件技术的不断发展,代码优化技术也在不断演进。未来的优化技术可能会朝着以下几个方向发展:

7.1 全局优化的加强

为了克服窥孔优化和基本块内优化的局限性,未来的优化技术将更加注重全局优化。通过对整个程序的控制流和数据流进行分析,识别跨越多个基本块和函数的公共子表达式、循环不变代码等,进行更全面的优化。例如,利用程序切片、静态单赋值(SSA)等技术,可以更准确地分析程序的依赖关系,实现更高效的全局优化。

7.2 机器学习在优化中的应用

机器学习技术在代码优化领域具有巨大的潜力。通过对大量代码样本的学习,机器学习算法可以自动发现代码中的优化模式和规律,为不同的代码片段选择最合适的优化策略。例如,使用深度学习模型预测代码的性能瓶颈,然后针对性地进行优化。

7.3 与硬件的紧密结合

未来的优化技术将更加紧密地与硬件结合,充分利用硬件的特性和优势。例如,针对多核处理器和并行计算架构,优化代码的并行性和数据局部性,提高程序的并行执行效率。同时,对于特定的硬件指令集,开发更高效的优化算法,充分发挥硬件的性能。

8. 总结与建议

代码优化是提高程序性能的重要手段,窥孔优化和基本块内的冗余消除是常用的优化技术。通过综合应用这些技术,可以显著减少代码中的冗余,提高代码的执行效率。

在实际应用中,我们应该根据具体的代码特点和性能需求,选择合适的优化技术和优化级别。对于简单的代码,可以先使用窥孔优化进行快速优化;对于复杂的代码,需要结合基本块内的冗余消除和更高级的全局优化技术。同时,要注意优化技术的局限性和挑战,在优化效果和优化成本之间进行合理的权衡。

以下是优化技术的特点和适用场景总结表格:
| 优化技术 | 特点 | 适用场景 |
| — | — | — |
| 窥孔优化 | 快速、简单,处理局部指令模式 | 初步优化,消除明显的冗余 |
| 基本块内冗余消除 | 深入分析基本块内部代码,消除复杂冗余 | 对基本块内的代码进行优化 |
| 全局优化 | 全面分析程序的控制流和数据流,进行跨基本块和函数的优化 | 处理复杂的程序结构,提高整体性能 |

下面是综合优化流程的流程图:

graph TD;
    A[生成初始代码] --> B[应用窥孔优化];
    B --> C[识别基本块];
    C --> D[值编号与优化];
    D --> E{是否需要重复优化};
    E -- 是 --> B;
    E -- 否 --> F[优化完成];

通过不断学习和应用新的优化技术,我们可以编写出更高效、更优质的代码,满足日益增长的性能需求。希望本文介绍的优化技术和方法能够对你的代码优化工作有所帮助。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值