现代硬件算法[4.4]: 契约编程
契约编程
在Java和Rust这样的“安全”语言中,通常对每一个可能的操作和每一个潜在的输入都有定义良好的行为。有些东西是非严格定义的(under-defined),比如哈希表中键的顺序或std::vector
的扩容因子,但这些通常是一些待实现的小细节,为了以后获得潜在的性能提升。
相反,C和C++将未定义行为的概念提升到了另一个层次。某些操作在编译或运行时不会导致错误,但却是不被允许的——从程序员和编译器之间存在契约的角度看,如果出现未定义的行为,编译器做任何事情都是合法的,包括炸毁监视器或格式化硬盘。但编译器工程师对此(炸毁你的监视器)并不感兴趣。相反,未定义的行为常被用来保证没有corner cases和帮助优化。
为什么存在未定义的行为
导致未定义行为的行为主要有两类:
-
一些无意的错误操作,如除以零、对空指针解引用或读取未初始化的内存。你会希望在测试过程中尽快定位到这些错误,因此让程序崩溃或出现不确定的行为会比让它们总是执行固定的回退操作(如返回零)要好。
可以编译并运行带有sanitizers的程序,以便尽早捕获未定义的行为。在GCC和Clang中,可以使用
-fsanitize=undefined
标志,一些因会导致UB(undefined behavior,未定义行为)而臭名昭著的操作将在运行时被检测到。 -
在不同平台上,一些操作的行为可能会略微不同。例如,将整数左移超过31位的结果是未定义的,因为执行该操作的指令在Arm和x86 CPU上的实现方式不同。如果你标准化了一个特定的行为,会导致另一个平台编译的所有程序都必须多花几个周期来检查该边界情况,那么最好令其保持未定义状态。
有时,当某些平台特定行为有一个合法的用例时,与其声明它未定义,不如说它是由实现定义(implementation-defined)的。例如,将负整数右移的结果取决于平台:它要么移入0,要么移入1(例如,将
11010110=-42
右移1位可能得到01101011=107
或11101011=-21
,这两种用例都是真实存在的)。
将某些东西指定为未定义行为,而不是实现定义行为也有助于编译器进行优化。考虑有符号整数溢出的情况。在几乎所有的架构上,有符号整数的溢出方式与无符号整数相同,INT_MAX + 1 == INT_MIN
,但根据C++标准,这是未定义的行为。这是故意为之的:如果你不允许有符号整数溢出,那么(x + 1) > x
保证对int
类型始终为true,但对unsigned int
则不为true,因为(x + 1)
可能会溢出。对于有符号类型,这使得编译器可以优化此类检查。
举一个更生动的例子,考虑具有整数控制变量的循环。现代C++和Rust等语言鼓励程序员使用无符号整数(如size_t
或usize
),而C程序员仍然坚持使用int
。要理解原因,请考虑以下for
循环:
for (unsigned int i = 0; i < n; i++) { |
该循环执行多少次?从技术上讲,有两个有效的答案:n
和无穷大。第二种情况是当n
大于时,i
在每次迭代后被重置为0。虽然前者可能是程序员假定的,但为了遵守语言规范,编译器仍必须插入额外的运行时检查并考虑两种情况,这是应当被优化的。对比之下,int
版本将完全执行n
次迭代,因为有符号溢出是不存在的。
删除边界情况
“安全”的编程风格通常包括进行大量的运行时检查,但它们不必以性能为代价。
例如,Rust在索引数组和其他随机访问结构时使用了著名的边界检查。在C++ STL 中,vector
和array
有一个“不安全”的[]
运算符和一个“安全”的.at()
方法,方法如下:
T at(size_t k) { |
有趣的是,这些检查很少在运行时实际执行,因为编译器通常可以在编译期就证明每个访问都在范围内。例如,在for
循环中从1到数组大小进行元素索引,在每个步骤上都没有任何非法操作发生,因此可以安全地优化边界检查。
假设
当编译器不能证明不存在边界情况,但你知道不存在时,可以使用未定义行为的机制来提供这些附加信息。
Clang有一个有用的__builtin_assume
函数,你可以在其中放置一个保证为true的语句,编译器将在优化中使用此假设。在GCC中,你可以使用__builtin_unreachable
执行相同操作:
void assume(bool pred) { |
例如,在上面的例子中,可以在at
之前放上assume(k < vector.size())
,这样边界检查将会被优化掉。
将assume
与assert
或static_assert
结合起来查找bug也是非常有用的:你可以使用assert
来检查调试构建(debug build)中的前提条件,然后使用assume
来提高生产构建(production build)中的性能。
运算
边界情况是你应该时刻谨记的,尤其是在优化算数运算时。
对于浮点运算,这就不那么令人担忧了,因为只需使用-ffast-math
标志(它也包含在-Ofast
中)来禁用严格遵守标准。一般情况下,你几乎必须这样做,因为不然,编译器只能按与源代码相同的顺序执行算术运算,做不了任何优化。
对于整数运算,情况则不同,因为整数运算的结果必须是精确的。考虑除以2的例子:
unsigned div_unsigned(unsigned x) { |
一个广为人知的优化是用一个右移(x >> 1
)来代替它:
shr eax |
对于所有的正数来说,这当然是正确的,但对于更一般的情况呢?
int div_signed(int x) { |
如果x
是负的,那么简单的移位就不起作用——无论移入0还是移入符号位:
- 如果我们移入0,我们得到一个非负的结果(符号位变成0)。
- 如果我们移入符号位,那么舍入方向将是负无穷大而不是0(
-5/2
将等于-3
而不是-2
)。
因此,对于一般情况,我们必须插入一些额外操作才能使其工作:
mov ebx, eax |
当只有正数的情况是可预期的时,我们也可以使用assume
机制来消除负数x
的可能性,以避免处理这种corner case:
int div_assume(int x) { |
但是在这种特殊情况下,表示我们只期望非负数的最佳语法可能是使用无符号整数类型。
由于这样的细微差别,在中间函数中展开代数运算并进行手动简化,而不仅仅是依赖编译器优化,通常是有益的。
内存别名
编译器对涉及内存读写的操作的优化非常糟糕。这是因为他们通常没有足够的上下文来进行正确的优化。
考虑以下示例:
void add(int *a, int *b, int n) { |
由于这个循环的每次迭代都是独立的,所以它可以并行执行并进行向量化。但从技术上讲是这样吗?
如果数组a
和b
相交,则可能会出现问题。考虑b == a + 1
的情况,也就是说,b
只是从a
的第二个元素开始的内存视图。在这种情况下,下一次迭代依赖于上一次迭代(译者注:是不是应该为a == b + 1
?),唯一正确的解决方案是按顺序执行循环。即使程序员知道这不可能发生,编译器也必须检查这种可能性。
这就是为什么会有const
和restrict
关键字。第一个关键字强制我们不会用指针变量修改内存,第二个关键字是告诉编译器内存保证不会被别名化(aliased)。
void add(int * __restrict__ a, const int * __restrict__ b, int n) { |
这些关键字对用于自我记录代码意图也很有帮助。
C++ 契约
契约编程是一种未被充分利用但功能非常强大的技术。
有一个新的提案,建议将契约设计(design-by-contract)以契约属性(contract attributes)的形式添加到C++标准中,这在功能上等同于我们手工设计的编译器特定的assume
:
T at(size_t k) [[ expects: k < n ]] { |
有三种类型的属性——expects
、ensures
和assert
——分别用于指定函数中的前置条件和后置条件,以及可以放在程序中任何位置的通用断言。
不幸的是,这个令人兴奋的新特性还没有最终标准化,更不用说在主要的C++编译器中实现了。但是,也许几年后,我们将能够编写这样的代码:
bool is_power_of_two(int m) { |
最后提一个与语言无关的一般建议,就是始终检查编译器生成的汇编代码,如果它不是你所希望的,请尝试考虑可能限制编译器优化代码的corner cases。
备注
[1] 有趣的事实:在Python中,由于某种原因,负数除以整数会使结果向下取整,因此-5 // 2 = -3
,相当于-5 >> 1 = -3
。我怀疑Guido van Rossum在最初设计该语言时是否考虑到了利用移位进行优化,但从理论上讲,JIT编译的Python程序在处理大量除2运算时可能比类似的C++程序更快。