算术和逻辑表达式的处理与优化
复杂表达式的处理
当表达式比之前提到的五种形式更复杂时,编译器需要生成一系列两条或更多的指令来计算该表达式。在编译代码时,大多数编译器会将复杂表达式内部转换为一系列“三地址语句”,这些语句在语义上与更复杂的表达式等价。
例如,对于复杂表达式
complex = ( a + b ) * ( c - d ) - e / f;
,典型编译器可能会生成以下三地址指令序列:
temp1 = a + b;
temp2 = c - d;
temp1 = temp1 * temp2;
temp2 = e / f;
complex = temp1 - temp2;
研究这五条语句,可以发现它们在语义上与注释中的复杂表达式等价。计算中的主要区别是引入了两个临时值(
temp1
和
temp2
),大多数编译器会尝试使用机器寄存器来维护这些临时值。
是否手动将复杂表达式转换为三地址形式来帮助编译器,这取决于编译器。对于许多优秀的编译器,将复杂计算拆分成小部分可能会妨碍编译器对某些序列的优化能力。所以,大多数时候应该清晰地编写代码,让编译器来优化结果。不过,如果能以自然转换为二地址或三地址形式的方式指定计算,那就这样做,这至少不会影响编译器生成的代码,在某些特殊情况下还可能帮助编译器生成更好的代码,并且代码可能更易读和维护。
算术语句的优化
高级语言(HLL)编译器最初设计为让程序员在源代码中使用类似代数的表达式,因此算术表达式的优化在计算机科学领域得到了深入研究。大多数提供合理优化器的现代编译器在将算术表达式转换为机器代码方面表现不错,通常可以认为所使用的编译器在优化算术表达式时不需要太多帮助,如果需要,可能应该考虑更换更好的编译器。
下面介绍一些现代优化编译器常见的优化方式:
常量折叠
常量折叠是一种在编译时计算常量表达式或子表达式的值,而不是在运行时发出代码来计算结果的优化方法。例如,支持此优化的 Pascal 编译器会在生成机器代码之前,将
i := 5 + 6;
转换为
i := 11;
,这显然节省了运行时需要执行的加法指令。
再比如,要分配一个包含 16MB 存储的数组,有以下两种方式:
char bigArray[ 16777216 ]; // 16MB of storage
char bigArray[ 16*1024*1024 ]; // 16MB of storage
虽然两种方式编译器分配的存储量相同,但第二种方式更易读,是更好的代码。
所有包含常量操作数的算术表达式(或子表达式)都可以进行常量折叠优化。如果使用常量表达式能更清晰地编写算术表达式,就应该选择更易读的版本,让编译器在编译时处理常量计算。如果编译器不支持常量折叠,可以手动进行所有常量计算,但这应该作为最后手段,更换更好的编译器通常是更好的选择。
一些优秀的优化编译器在折叠常量时可能会采取极端措施,例如,启用足够高的优化级别后,某些编译器会将带有常量参数的函数调用替换为相应的常量值,如将
sineR = sin(0);
转换为
sineR = 0;
,但这种常量折叠不太常见,通常需要启用特殊的编译器模式。
常量传播
常量传播是一种编译器优化方法,如果编译器确定可以用常量值替换变量访问,就会进行替换。例如,支持常量传播的编译器会对以下代码进行优化:
// original code:
variable = 1234;
result = f( variable );
// code after constant propagation optimization
variable = 1234;
result = f( 1234 );
在目标代码中,操作立即常量比操作变量更高效,因此常量传播通常会产生更好的代码。在某些情况下,常量传播还允许编译器完全消除某些变量和语句。
例如,以下 C 代码:
#include <stdio.h>
static int rtn3( void )
{
return 3;
}
int main( void )
{
printf( "%d", rtn3() + 2 );
return( 0 );
}
使用 GCC 以
-O3
(最大)优化选项编译后,生成的 80x86 代码中
rtn3
函数不见了。GCC 发现
rtn3
只是返回一个常量,并将该常量返回结果传播到所有调用
rtn3
的地方,在
printf
函数调用中,常量传播和常量折叠结合产生了一个常量(5)传递给
printf
函数。
如果编译器不支持常量传播,可以手动模拟,但这应该作为最后手段,更换更好的编译器通常是更好的选择。
死代码消除
死代码消除是指如果程序不再使用某个源代码语句的结果,就移除与之关联的目标代码。这通常是编程错误导致的,如果编译器在源文件中遇到死代码,可能会警告检查代码逻辑。在某些情况下,早期的优化可能会产生死代码,例如前面例子中对
variable
进行常量传播后,
variable = 1234;
可能成为死代码,支持死代码消除的编译器会悄悄地从目标文件中移除该语句的目标代码。
例如,以下 C 程序:
static int rtn3( void )
{
return 3;
}
int main( void )
{
int i = rtn3() + 2;
// Note that this program
// never again uses the value of i.
return( 0 );
}
使用 GCC 以
-O3
命令行选项编译时,生成的 80x86 代码中没有对
i
的赋值操作。而不启用优化时,代码中会有对
rtn3
的调用和计算。
实际上,很多程序示例中调用
printf
函数显示各种值,是为了明确使用这些变量的值,防止死代码消除从汇编输出文件中删除正在研究的代码。如果移除这些示例中 C 程序的最终
printf
语句,由于死代码消除,大部分汇编代码会消失。
以下是一个简单的流程图展示死代码消除的过程:
graph TD;
A[源代码] --> B[编译器分析];
B --> C{结果是否被使用};
C -- 是 --> D[保留代码];
C -- 否 --> E[移除代码];
D --> F[生成目标代码];
E --> F;
公共子表达式消除
某些表达式的小部分(甚至大部分)可能在当前函数的其他地方出现。如果子表达式中变量的值没有变化,程序不需要两次计算该表达式的值,而是可以在第一次计算时保存子表达式的值,然后在子表达式再次出现的地方使用该值。
例如,以下 Pascal 代码:
complex := ( a + b ) * ( c - d ) - ( e div f );
lessSo := ( a + b ) - ( e div f );
quotient := e div f;
一个不错的编译器可能会将其转换为以下三地址语句序列:
temp1 := a + b;
temp2 := c - d;
temp3 := e div f;
complex := temp1 * temp2;
complex := complex - temp3;
lessSo := temp1 - temp3;
quotient := temp3;
虽然原语句中
(a + b)
出现了两次,
(e div f)
出现了三次,但三地址代码序列只计算这些子表达式一次,并在公共子表达式再次出现时使用其值。
再看以下 C/C++ 代码:
#include <stdio.h>
static int i, j, k, m, n;
static int expr1, expr2, expr3;
extern int someFunc( void );
int main( void )
{
i = someFunc();
j = someFunc();
k = someFunc();
m = someFunc();
n = someFunc();
expr1 = (i + j) * (k * m + n);
expr2 = (i + j);
expr3 = (k * m + n);
printf( "%d %d %d", expr1, expr2, expr3 );
return( 0 );
}
使用 GCC 以
-O3
选项编译时,编译器会在各种寄存器中维护公共子表达式的结果。如果编译器不支持公共子表达式优化,可以通过检查汇编输出来确定,这种情况下编译器的优化器可能较差,应该考虑更换编译器。如果必须使用不支持此优化的编译器,可以手动进行优化,例如:
#include <stdio.h>
static int i, j, k, m, n;
static int expr1, expr2, expr3;
static int ijExpr, kmnExpr;
extern int someFunc( void );
int main( void )
{
i = someFunc();
j = someFunc();
k = someFunc();
m = someFunc();
n = someFunc();
ijExpr = i + j;
kmnExpr = (k * m + n);
expr1 = ij * kmn;
expr2 = ij;
expr3 = kmn;
printf( "%d %d %d", expr1, expr2, expr3 );
return( 0 );
}
强度削弱
CPU 通常可以使用与源代码指定不同的运算符直接计算某些值,从而用更简单的指令替换更复杂(或更强)的指令。例如,移位操作可以实现对 2 的幂次方常量的乘法或除法,某些模(余数)操作可以使用按位与指令实现,移位和按位与指令通常比乘法和除法指令执行速度快得多。大多数编译器优化器善于识别此类操作,并用更便宜的机器指令序列替换更昂贵的计算。
以下是一个展示强度削弱的 C 代码示例:
#include <stdio.h>
unsigned i, j, k, m, n;
extern unsigned someFunc( void );
extern void preventOptimization( unsigned arg1, ... );
int main( void )
{
i = someFunc();
j = i * 2;
k = i % 32;
m = i / 4;
n = i * 8;
preventOptimization( i,j,k,m,n );
return( 0 );
}
GCC 生成的 80x86 代码中,虽然 C 代码广泛使用了乘法和除法运算符,但 GCC 从未发出乘法或除法指令,而是用更便宜的地址计算、移位和逻辑与操作替换了这些操作。
值得注意的是,此 C 示例将变量声明为无符号类型,因为强度削弱对某些无符号操作数产生的代码比有符号操作数更高效。如果可以在有符号和无符号整数操作数之间选择,应尽量使用无符号值,因为编译器在处理无符号操作数时通常可以生成更好的代码。
以下是使用有符号整数重写上述 C 代码后 GCC 的 80x86 汇编输出:
.file "t.c"
.text
.p2align 2,,3
.globl main
.type main,@function
main:
;Build main's activation record:
pushl %ebp
movl %esp, %ebp
pushl %esi
pushl %ebx
andl $-16, %esp
; Call someFunc to get i's value:
call someFunc
leal (%eax,%eax), %esi ;j = i * 2
testl %eax, %eax ;Test i's sign
movl %eax, %ecx
movl %eax, i
movl %esi, j
js .L4
; Here's the code we execute if i is non-negative:
.L2:
andl $-32, %eax ;MOD operation
movl %ecx, %ebx
subl %eax, %ebx
testl %ecx, %ecx ;Test i's sign
movl %ebx, k
movl %ecx, %eax
js .L5
.L3:
subl $12, %esp
movl %eax, %edx
leal 0(,%ecx,8), %eax ;i * 8
pushl %eax
sarl $2, %edx ;Signed div by 4
pushl %edx
pushl %ebx
pushl %esi
pushl %ecx
movl %eax, n
movl %edx, m
call preventOptimization
leal -8(%ebp), %esp
popl %ebx
xorl %eax, %eax
popl %esi
可以看到有符号整数的代码相对更复杂。
综上所述,了解这些编译器优化方法可以帮助我们编写更高效的代码,同时避免对编译器已经能很好处理的部分进行手动优化。在实际编程中,应根据具体情况选择合适的优化策略,充分发挥编译器的优化能力。
算术和逻辑表达式的处理与优化(续)
优化方法总结与对比
为了更清晰地了解各种优化方法,下面通过表格对前面介绍的几种优化方法进行总结和对比:
| 优化方法 | 定义 | 示例 | 优点 | 适用场景 |
| — | — | — | — | — |
| 常量折叠 | 在编译时计算常量表达式或子表达式的值,而不是在运行时计算 |
i := 5 + 6;
编译为
i := 11;
| 节省运行时计算指令,提高代码执行效率 | 表达式中包含常量操作数的情况 |
| 常量传播 | 如果编译器确定可以用常量值替换变量访问,就进行替换 |
variable = 1234; result = f( variable );
优化为
variable = 1234; result = f( 1234 );
| 操作立即常量更高效,可能消除某些变量和语句 | 变量值在后续使用中固定为常量的情况 |
| 死代码消除 | 如果程序不再使用某个源代码语句的结果,就移除与之关联的目标代码 | 移除未使用结果的赋值语句 | 减少目标代码量,提高代码简洁性 | 代码中存在未使用结果的语句 |
| 公共子表达式消除 | 当子表达式在函数中多次出现且变量值不变时,保存第一次计算的值并重复使用 | 对
(a + b)
和
(e div f)
等公共子表达式只计算一次 | 减少重复计算,提高效率 | 表达式中存在重复子表达式的情况 |
| 强度削弱 | 用更简单的指令替换更复杂的指令 | 用移位操作替换乘法或除法 | 提高指令执行速度,降低计算成本 | 涉及特定运算(如 2 的幂次方运算)的情况 |
优化策略的选择
在实际编程中,选择合适的优化策略至关重要。以下是一些选择优化策略的建议:
1.
优先使用编译器默认优化
:大多数现代编译器都提供了合理的优化器,能够自动处理许多常见的优化情况。因此,在编写代码时,应先让编译器发挥其优化能力,避免过早进行手动优化。
2.
根据代码特点选择优化方法
:
- 如果代码中包含大量常量表达式,应考虑使用常量折叠优化,让代码更易读。
- 当变量的值在后续使用中固定为常量时,常量传播可以提高代码效率。
- 检查代码中是否存在未使用结果的语句,使用死代码消除来精简代码。
- 对于存在重复子表达式的代码,使用公共子表达式消除可以减少重复计算。
- 涉及 2 的幂次方运算时,强度削弱可以提高指令执行速度。
3.
考虑编译器支持情况
:如果编译器不支持某些优化方法,可以手动模拟,但这应该作为最后手段。优先考虑更换支持更多优化功能的编译器。
优化实践案例分析
下面通过一个综合案例来展示如何应用这些优化方法。假设有以下 C 代码:
#include <stdio.h>
static int func1() {
return 5;
}
static int func2() {
return 3;
}
int main() {
int a = func1();
int b = func2();
int c = a * 4 + b * 4;
int d = a * 4;
int e = b * 4;
printf("%d %d %d\n", c, d, e);
return 0;
}
我们可以对这段代码进行以下优化:
1.
常量折叠
:如果编译器支持,
func1()
和
func2()
的返回值在编译时可以确定,可进行常量折叠。
2.
常量传播
:将
a
和
b
的常量值传播到后续使用的地方。
3.
公共子表达式消除
:
a * 4
和
b * 4
是公共子表达式,可以只计算一次。
4.
强度削弱
:
a * 4
和
b * 4
可以用移位操作
a << 2
和
b << 2
替换。
优化后的代码如下:
#include <stdio.h>
int main() {
int c = (5 << 2) + (3 << 2);
int d = 5 << 2;
int e = 3 << 2;
printf("%d %d %d\n", c, d, e);
return 0;
}
以下是优化过程的流程图:
graph TD;
A[原始代码] --> B[常量折叠];
B --> C[常量传播];
C --> D[公共子表达式消除];
D --> E[强度削弱];
E --> F[优化后代码];
总结
通过对复杂表达式的处理和各种优化方法的介绍,我们了解到合理利用编译器的优化功能可以显著提高代码的执行效率和简洁性。在实际编程中,应根据代码的具体情况选择合适的优化策略,同时要注意编译器的支持情况。希望这些优化方法和策略能够帮助你编写更高效、更优质的代码。
总之,对算术和逻辑表达式的优化是一个需要综合考虑多种因素的过程,通过不断实践和学习,我们可以更好地掌握这些优化技巧,提升自己的编程能力。
超级会员免费看

被折叠的 条评论
为什么被折叠?



