布尔表达式求值与算术运算性能分析
1. 短路求值与完全布尔求值
1.1 短路求值的危险性与示例
短路求值存在一定危险性,因为某些算法期望所有副作用均会发生,即便表达式在某点求值为假。例如下面这个奇特但合法的 C 语句,它将“游标”指针推进到字符串中的下一个 8 字节边界,或者字符串末尾(以先到者为准):
*++ptr && *++ptr && *++ptr && *++ptr && *++ptr && *++ptr && *++ptr && *++ptr;
该语句的执行过程如下:
1. 先递增指针,然后从内存(由 ptr 指向)中获取一个字节。
2. 如果获取的字节为零,则整个表达式求值为假,表达式/语句的执行立即停止。
3. 如果获取的字符不为零,则该过程最多再重复七次。
最终,要么 ptr 指向一个零字节,要么它指向原始位置之后的 8 个字节处。这里的技巧(涉及短路布尔求值)在于,表达式在到达字符串末尾时会立即终止,而不是盲目地跳过该点。
1.2 短路求值与完全布尔求值的选择
不同算法在不同情况下可能需要使用短路布尔求值或完全布尔求值来产生正确结果。只有少数编程语言(如 Ada)提供了在程序控制下选择任一方案的标准化方法。一些语言(如 C、C++、C# 和 Java)指定了其中一种形式。而大多数语言则由编译器实现来决定使用哪种方案。如果所使用的语言定义未明确指定求值形式,或者想使用另一种形式(如在 C 中使用完全布尔求值),则需要编写代码来强制使用期望的求值方案。
1.3 强制短路或完全布尔求值
1.3.1 强制完全布尔求值
在使用(或可能使用)短路求值的语言中,强制进行完全布尔求值相对容易。具体步骤如下:
1. 将表达式拆分为单个语句。
2. 将每个子表达式的结果存储到一个变量中。
3. 对这些临时变量应用合取和析取运算符。
例如,将以下复杂表达式:
// Complex expression:
if( (a < f(x)) && (b != g(y)) || predicate( a + b ))
{
<<stmts to execute if this expression is True>>
}
转换为使用完全布尔求值的形式:
// Translation to a form that uses complete Boolean evaluation:
temp1 = a < f(x);
temp2 = b != g(y);
temp3 = predicate( a + b );
if( temp1 && temp2 || temp3 )
{
<<stmts to execute if this expression is True>>
}
虽然 if 语句中的布尔表达式仍使用短路求值,但由于此代码在 if 语句之前对所有子表达式进行了求值,因此能确保 f、g 和 predicate 函数产生的所有副作用都会发生。
1.3.2 强制短路求值
如果语言仅支持完全布尔求值(或未指定求值类型),而想强制进行短路求值,操作会稍微复杂一些,但仍然不难。
例如,对于以下 Pascal 代码:
if( ((a < f(x)) and (b <> g(y))) or predicate( a + b )) then begin
<<stmts to execute if the expression is True>>
end; (*if*)
要强制进行短路布尔求值,可以使用以下代码:
boolResult := a < f(x);
if( boolResult ) then
boolResult := b <> g(y);
if( not boolResult ) then
boolResult := predicate( a + b );
if( boolResult ) then begin
<<stmts to execute if the IF's expression is True>>
end; (*if*)
此代码通过使用 if 语句根据布尔表达式的当前状态(存储在 boolResult 变量中)来阻止(或强制)g 和 predicate 函数的执行,从而模拟短路求值。
1.4 效率问题
1.4.1 短路求值与完全布尔求值的效率对比
一般来说,在处理复杂布尔表达式或某些子表达式的计算成本较高时,短路求值通常比完全布尔求值更快。而在生成的目标代码量方面,两者大致相当,具体差异完全取决于所求值的表达式。
以下是使用 HLA 代码实现同一布尔表达式的两种形式:
// 使用完全布尔求值
f(x); // Assume f returns its result in EAX
cmp( a, eax ); // Compare a with f(x)'s return result.
setb( bl ); // bl = a < f(x)
g(y); // Assume g returns its result in EAX
cmp( b, eax ); // Compare b with g(y)'s return result
setne( bh ); // bh = b != g(y)
mov( a, eax ); // Compute a + b to pass along to the
add( b, eax ); // predicate function.
predicate( eax );// al holds predicate's result (0/1)
and( bh, bl ); // bl = temp1 && temp2
or( bl, al ); // al = (temp1 && temp2) || temp3
jz skipStmts; // Zero if false, not zero if true.
<<stmts to execute if the condition is True>>
skipStmts:
// 使用短路布尔求值
f(x);
cmp( a, eax );
jnb TryOR; // If a is not less than f(x), try the OR clause
g(y);
cmp( b, eax );
jne DoStmts // If b is not equal g(y) [and a < f(x)], then do
// the body.
TryOR:
mov( a, eax );
add( b, eax );
predicate( eax );
test( eax, eax ); // EAX = 0?
jz SkipStmts;
DoStmts:
<<stmts to execute if the condition is True>>
SkipStmts:
通过简单计算语句数量可以发现,使用短路求值的版本稍短(11 条指令对 12 条)。而且,短路求值版本可能运行得更快,因为有一半的时间代码只需计算三个表达式中的两个。只有当第一个子表达式 (a < f(x)) 求值为真且第二个表达式 (b != g(y)) 求值为假时,才会计算所有三个子表达式。如果这些布尔表达式的结果出现的概率相等,那么此代码将有 25% 的时间测试所有三个子表达式,其余时间只需测试两个子表达式。
1.4.2 优化布尔表达式的性能
如果执行 f、g 和 predicate 函数所需的时间大致相同,可以通过简单的修改来显著提高代码性能。例如,将以下表达式:
// 原始表达式
// if( (a < f(x)) && (b != g(y)) || predicate( a + b ))
// {
// <<stmts to execute if the IF's expression is True>>
// }
// 修改后的表达式
// if( predicate( a + b ) || (a < f(x)) && (b != g(y)))
// {
// <<stmts to execute if the expression evaluates to True>>
// }
mov( a, eax );
add( b, eax );
predicate( eax );
test( eax, eax ); // EAX = True (nonzero)?
jnz DoStmts;
f(x);
cmp( a, eax );
jnb SkipStmts; // If a is not less than f(x), try the OR clause
g(y);
cmp( b, eax );
je SkipStmts; // If b is not equal g(y) (and a < f(x)), then
// do the body.
DoStmts:
<<stmts to execute if the condition is true>>
SkipStmts:
假设每个子表达式的结果是随机且均匀分布的(即每个子表达式产生真的概率为 50%),那么此代码平均运行速度将比之前的版本快约 50%。这是因为将对 predicate 的测试移到代码片段的开头后,代码现在可以通过一次测试来确定是否需要执行主体。由于 predicate 有 50% 的时间返回真,因此大约有一半的时间可以通过一次测试来确定是否要执行循环主体,而在之前的示例中,总是至少需要两次测试才能确定。
1.5 操作数顺序对性能的影响
1.5.1 函数调用成本的影响
在编译器使用短路求值时,对于逻辑与操作,如
a < f(x) && b != g(y)
和
b != g(y) && a < f(x)
这两个语义等价的表达式(在没有副作用的情况下),如果调用函数 f 的成本低于调用函数 g 的成本,则第一个表达式执行速度更快;反之,如果调用 f 的成本更高,则第二个表达式通常执行速度更快。
1.5.2 表达式返回值概率的影响
对于合取表达式
expr1 && expr2
,应将更有可能返回真的表达式放在合取运算符 (&&) 的右侧,将最有可能返回假的操作数放在表达式的左侧,这样可以更频繁地避免计算第二个操作数。
对于析取表达式
expr3 || expr4
,应安排操作数使 expr3 比 expr4 更有可能返回真,这样可以更频繁地跳过右侧表达式的执行。
需要注意的是,如果布尔表达式会产生副作用,则不能随意重新排列操作数,因为这些副作用的正确计算可能取决于子表达式的精确顺序。
1.6 算术运算的相对成本
大多数算法分析方法假设所有操作花费的时间相同,但实际上,某些算术运算可能比其他计算慢两个数量级。例如,简单的整数加法通常比整数乘法快得多,整数运算通常也比相应的浮点运算快得多。
不同 CPU 上,同一算术运算符的性能会有所不同。例如,在奔腾 III 上,移位和旋转操作相对加法操作较快,但在奔腾 4 上则慢得多。因此,无法创建一个列出所有运算符相对速度的表格。
不过,可以根据操作相对于加法操作的性能将各种操作分为不同类别,以下是一个估算相对性能的表格:
| 相对性能 | 操作 |
| ---- | ---- |
| 最快 | 整数加法、减法、取反、逻辑与、逻辑或、逻辑异或、逻辑非和比较;逻辑移位;逻辑旋转 |
| | 乘法 |
| | 除法 |
| | 浮点比较和取反 |
| | 浮点加法和减法 |
| | 浮点乘法 |
| 最慢 | 浮点除法 |
这个表格中的估计值并非适用于所有 CPU,但可以作为初步参考。在许多处理器上,最快和最慢操作的性能差异可能在两到三个数量级之间。特别是,除法在大多数处理器上往往相当慢(浮点除法更慢),乘法通常比加法慢,但具体差异因处理器而异。
如果必须进行浮点除法,很难通过使用其他操作来提高应用程序的性能。但许多整数算术计算可以使用不同的算法。例如,左移操作通常比乘以 2 的成本更低。虽然大多数编译器会自动处理此类“运算符转换”,但编译器并非无所不知,不一定能找到计算结果的最佳方法。因此,手动进行“运算符转换”可以确保得到更优的结果。
1.7 更多信息
有许多关于编译器设计和实现的教科书会详细讨论算术表达式的代码生成以及这些表达式代码的优化。以下是一些值得研究的编译器构造教科书:
- Compilers, Principles, Techniques, and Tools, Alfred Aho, Ravi Sethi, and Jeffrey Ullman (Addison-Wesley, 1986)
- Compiler Construction: Theory and Practice, William Barret and John Couch (SRA, 1986)
- A Retargetable C Compiler: Design and Implementation, Christopher Fraser and David Hansen (Addison-Wesley Professional, 1995)
- Introduction to Compiler Design, Thomas Parsons (W. H. Freeman, 1992)
- Compiler Construction: Principles and Practice, Kenneth Louden (Course Technology, 1997)
学习汇编语言编程是学习如何编写能生成优质机器代码的高级语言代码的最佳方法之一。The Art of Assembly Language (No Starch Press, 2003) 是学习如何在汇编语言中求值算术表达式的优秀资源。
如需了解更多关于编译器基准测试和编译器优化器功能的信息,可以访问 Willus.com 编译器基准测试页面:www.willus.com/ccomp_benchmark.shtml。
2. 布尔表达式求值与算术运算性能分析总结
2.1 布尔表达式求值方法总结
在布尔表达式求值方面,我们主要探讨了短路求值和完全布尔求值两种方法。短路求值在某些情况下可能存在危险性,但在处理复杂布尔表达式或子表达式计算成本较高时,通常具有更好的性能。而完全布尔求值能确保所有子表达式的副作用都会发生。
| 求值方法 | 特点 | 适用场景 | 转换方法 |
|---|---|---|---|
| 短路求值 | 可能提前终止表达式计算,部分子表达式可能不执行 | 子表达式计算成本高,部分子表达式结果可决定整体结果 | 语言未支持时,通过条件判断语句模拟 |
| 完全布尔求值 | 所有子表达式都会计算,确保副作用发生 | 算法要求所有副作用都发生 | 将表达式拆分为单个语句,存储子表达式结果到变量,再进行逻辑运算 |
2.2 性能优化策略总结
2.2.1 布尔表达式优化
- 调整操作数顺序 :根据函数调用成本和表达式返回值概率调整操作数顺序。对于逻辑与操作,将更可能返回真的表达式放右侧;对于逻辑或操作,将更可能返回真的表达式放左侧。
- 优先测试低成本或高概率表达式 :将低成本或高概率返回真的子表达式提前测试,减少不必要的计算。
2.2.2 算术运算优化
- 选择高效运算符 :了解不同算术运算符的相对性能,优先使用性能高的运算符,如大多数 CPU 上加法操作效率较高。
- 使用替代算法 :对于整数算术计算,可使用替代算法,如左移代替乘以 2。
2.3 性能优化示例流程
以下是一个 mermaid 格式的流程图,展示了布尔表达式性能优化的流程:
graph TD
A[开始] --> B[分析布尔表达式]
B --> C{是否有副作用}
C -- 是 --> D[不随意调整操作数顺序]
C -- 否 --> E[评估函数调用成本和返回值概率]
E --> F[调整操作数顺序]
D --> G[评估子表达式成本和概率]
F --> G
G --> H[将低成本或高概率表达式提前测试]
H --> I[结束]
2.4 算术运算性能对比回顾
再次回顾算术运算的相对性能表格,不同操作的性能差异较大:
| 相对性能 | 操作 |
| ---- | ---- |
| 最快 | 整数加法、减法、取反、逻辑与、逻辑或、逻辑异或、逻辑非和比较;逻辑移位;逻辑旋转 |
| | 乘法 |
| | 除法 |
| | 浮点比较和取反 |
| | 浮点加法和减法 |
| | 浮点乘法 |
| 最慢 | 浮点除法 |
在实际编程中,应根据具体需求和硬件环境,合理选择算术运算符和算法,以提高程序的性能。例如,在需要频繁进行整数乘法的场景中,可考虑使用左移操作代替;而在必须进行浮点除法时,要意识到其性能成本较高。
2.5 学习资源推荐
为了进一步学习编译器设计、代码优化以及汇编语言编程,推荐以下资源:
-
编译器设计教科书
:如 Compilers, Principles, Techniques, and Tools 等,这些书籍详细讨论了算术表达式的代码生成和优化。
-
汇编语言学习书籍
:The Art of Assembly Language 是学习汇编语言中算术表达式求值的优秀资源。
-
在线资源
:Willus.com 编译器基准测试页面提供了关于编译器基准测试和优化器功能的信息。
通过学习这些资源,能够更深入地理解布尔表达式求值和算术运算性能的相关知识,从而编写出更高效的代码。
总之,在编程过程中,要充分考虑布尔表达式求值方法和算术运算的性能差异,运用合适的优化策略,以提高程序的执行效率和性能。同时,不断学习相关知识和技术,积累经验,才能在实际项目中更好地应对各种性能挑战。
超级会员免费看
5万+

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



