C标准明确将带符号整数溢出指定为具有未定义行为。然而,大多数CPU都为溢出(定义了除法溢出:x / 0
和INT_MIN / -1
除外)实现了具有定义语义的带符号算术。
编译器作者一直在利用此类溢出的不确定性来添加更具攻击性的优化,这些优化往往会以非常微妙的方式破坏遗留代码。例如,此代码可能在较旧的编译器上有效,但在gcc
和clang
的当前版本上不再有效:
/* Tncrement a by a value in 0..255, clamp a to positive integers.
The code relies on 32-bit wrap-around, but the C Standard makes
signed integer overflow undefined behavior, so sum_max can now
return values less than a. There are Standard compliant ways to
implement this, but legacy code is what it is... */
int sum_max(int a, unsigned char b) {
int res = a + b;
return (res >= a) ? res : INT_MAX;
}
是否有确凿的证据表明这些优化是值得的?是否有比较研究记录了实际示例甚至经典基准的实际改进?
我在观看此问题时想到了这个问题:C++Now 2018: John Regehr “Closing Keynote: Undefined Behavior and Compiler Optimizations”
我正在标记 c 和 c ++ ,因为两种语言的问题相似,但答案可能不同。
答案 0 :(得分:20)
我不了解研究和统计信息,但是是的,肯定有一些优化考虑了编译器实际所做的事情。是的,它们非常重要(例如tldr循环矢量化)。
除了编译器优化外,还有其他方面需要考虑。使用UB,您可以获得C / C ++有符号整数,其数学行为与数学期望一样。例如x + 10 > x
现在适用(当然适用于有效的代码),但不适用于环绕行为。
我从Krister Walfridsson的博客中找到了一篇很棒的文章How undefined signed overflow enables optimizations in GCC,其中列出了一些优化方案,这些优化方案考虑了签名溢出UB。以下示例来自于此。我正在向它们添加c ++和汇编示例。
如果优化看起来过于简单,无趣或没有影响,请记住,这些优化只是更大范围的优化链中的步骤。蝴蝶效应确实发生了,因为在较早的步骤看似不重要的优化可能在较后的步骤触发更具影响力的优化。
如果示例看起来很荒谬(谁会写x * 10 > 0
),请记住,您可以很容易地在C和C ++中使用常量,宏,模板来获得此类示例。此外,当编译器在其IR中进行转换和优化时,编译器可以获取此类示例。
与0相比,消除乘法
(x * c) cmp 0 -> x cmp 0
bool foo(int x) { return x * 10 > 0 }
foo(int):
test edi, edi
setg al
ret
消除乘法后的除法
(x * c1)/ c2-> x *(c1 / c2)如果c1可被c2整除
int foo(int x) { return (x * 20) / 10; }
foo(int):
lea eax, [rdi+rdi]
ret
消除否定
(-x)/(-y)-> x / y
int foo(int x, int y) { return (-x) / (-y); }
foo(int, int):
mov eax, edi
cdq
idiv esi
ret
简化始终为真或为假的比较
x + c < x -> false x + c <= x -> false x + c > x -> true x + c >= x -> true
bool foo(int x) { return x + 10 >= x; }
foo(int):
mov eax, 1
ret
在比较中消除否定
(-x) cmp (-y) -> y cmp x
bool foo(int x, int y) { return -x < -y; }
foo(int, int):
cmp edi, esi
setg al
ret
减少常数的大小
x + c > y -> x + (c - 1) >= y x + c <= y -> x + (c - 1) < y
bool foo(int x, int y) { return x + 10 <= y; }
foo(int, int):
add edi, 9
cmp edi, esi
setl al
ret
消除比较中的常量
(x + c1) cmp c2 -> x cmp (c2 - c1) (x + c1) cmp (y + c2) -> x cmp (y + (c2 - c1)) if c1 <= c2
第二个转换仅在c1 <= c2时有效,因为它会 否则,当y的值为INT_MIN时会导致溢出。
bool foo(int x) { return x + 42 <= 11; }
foo(int):
cmp edi, -30
setl al
ret
如果一个操作没有溢出,那么如果 我们以更广泛的类型进行操作。这通常在执行时很有用 诸如64位架构上的数组索引之类的东西-索引 计算通常使用32位int完成,但指针是 64位,并且编译器在签名时可能会生成更有效的代码 通过将32位整数提升为64位来定义溢出 操作,而不是生成类型扩展。
另一方面,未定义的溢出确保a [i] 和a [i + 1]是相邻的。这样可以改善对以下内容的内存访问分析 向量化等。
这是非常重要的优化,因为循环矢量化是最有效的优化算法之一。
进行演示比较棘手。但是我记得当将索引从unsigned
更改为signed
时,实际上大大地改善了生成的程序集的情况。不幸的是,我现在不记得或不能复制它。如果我知道了,稍后再回来。
编译器在以下位置跟踪变量的可能值范围 程序中的每个点,例如
int x = foo(); if (x > 0) { int y = x + 5; int z = y / 4;
确定在x之后,x的范围为
[1, INT_MAX]
if语句,因此可以确定y的范围为[6, INT_MAX]
,因为不允许溢出。下一行可以是 优化为int z = y >> 2;
,因为编译器知道y为 非负的。
auto foo(int x)
{
if (x <= 0)
__builtin_unreachable();
return (x + 5) / 4;
}
foo(int):
lea eax, [rdi+5]
sar eax, 2
ret
未定义的溢出有助于需要比较两个的优化 值(因为包装盒将给出表格的可能值
[INT_MIN, (INT_MIN+4)]
或[6, INT_MAX]
会阻止所有有用的信息 与<
或>
)进行比较,例如
- 如果
x<y
和x
的范围不重叠,则将比较y
更改为true或false- 如果范围不重叠,请将
min(x,y)
或max(x,y)
更改为x
或y
- 如果范围未超过
abs(x)
,请将x
更改为-x
或0
- 如果
x/c
和常数x>>log2(c)
是x>0
的幂,则将c
更改为2
- 如果
x%c
和常数x&(c-1)
是x>0
的幂,则将c
更改为2
为什么未定义的有符号溢出有助于循环的典型示例 优化是像
这样的循环for (int i = 0; i <= m; i++)
保证会因未定义的溢出而终止。这有帮助 具有特定循环指令的体系结构 一般不会处理无限循环。
但是未定义的有符号溢出有助于更多的循环优化。所有 分析,例如确定迭代次数,变换 归纳变量,并跟踪使用的内存访问 上一节中的所有内容才能完成其工作。在 特别是,可以向量化的循环集非常严格 当允许签名溢出时减少。
答案 1 :(得分:7)
并不是优化的一个示例,但是未定义行为的一个有用结果是-ftrapv
的GCC / clang命令行切换。它插入的代码会在整数溢出时使您的程序崩溃。
根据无符号溢出是有意的想法,它不适用于无符号整数。
该标准对有符号整数溢出的措辞可确保人们不会故意编写溢出代码,因此ftrapv
是发现意外溢出的有用工具。
答案 2 :(得分:4)
这是一个实际的小基准,冒泡排序。我已经比较了不带-fwrapv
或不带 -O3 -O3 -fwrapv -O1 -O1 -fwrapv
Machine1, clang 5.2 6.3 6.8 7.7
Machine2, clang-8 4.2 7.8 6.4 6.7
Machine2, gcc-8 6.6 7.4 6.5 6.5
的时间(这意味着溢出是UB /不是UB)。结果(秒):
-fwrapv
如您所见,非UB(#include <stdio.h>
#include <stdlib.h>
void bubbleSort(int *a, long n) {
bool swapped;
for (int i = 0; i < n-1; i++) {
swapped = false;
for (int j = 0; j < n-i-1; j++) {
if (a[j] > a[j+1]) {
int t = a[j];
a[j] = a[j+1];
a[j+1] = t;
swapped = true;
}
}
if (!swapped) break;
}
}
int main() {
int a[8192];
for (int j=0; j<100; j++) {
for (int i=0; i<8192; i++) {
a[i] = rand();
}
bubbleSort(a, 8192);
}
}
)版本几乎总是较慢,最大的区别是非常大的1.85倍。
这是代码。请注意,我有意选择了一个实现,该实现应对此测试产生更大的差异。
__has_cpp_attribute
答案 3 :(得分:2)
答案实际上是您的问题:
但是大多数CPU都使用定义好的语义来实现带符号算术
我想不出今天可以买到的CPU,它没有对有符号整数使用二进制补码算术,但这并不总是这样。
C语言是在1972年发明的。那时,IBM 7090大型机仍然存在。并非所有的计算机都是二进制兼容。
要定义2s左右的语言(和溢出行为)会不利于在非2s机器上生成代码。
此外,正如已经说过的那样,将签名溢出指定为UB可以使编译器生成更好的代码,因为它可以减少签名溢出导致的代码路径,并假设这种情况永远不会发生。
如果我正确理解将a和b之和钳制为0 .... INT_MAX而又不进行环绕的话,我可以想到两种以兼容方式编写此函数的方法。
首先,低效率的一般情况适用于所有cpus:
int sum_max(int a, unsigned char b) {
if (a > std::numeric_limits<int>::max() - b)
return std::numeric_limits<int>::max();
else
return a + b;
}
第二种,令人惊讶的高效2s约定特定方式:
int sum_max2(int a, unsigned char b) {
unsigned int buffer;
std::memcpy(&buffer, &a, sizeof(a));
buffer += b;
if (buffer > std::numeric_limits<int>::max())
buffer = std::numeric_limits<int>::max();
std::memcpy(&a, &buffer, sizeof(a));
return a;
}
结果汇编器可以在这里看到:https://godbolt.org/z/F42IXV