C 标准明确指定有符号整数溢出具有未定义的行为。然而,大多数 CPU 都实现了带有定义溢出语义的有符号算术(除法溢出除外:x / 0
和INT_MIN / -1
)。
编译器编写者一直在利用这种溢出的未定义性来添加更积极的优化,这些优化往往会以非常微妙的方式破坏遗留代码。例如,此代码可能适用于较旧的编译器,但不再适用于当前版本的gcc
和clang
:
/* Increment 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++现在2018:John Regehr"闭幕主题演讲:未定义的行为和编译器优化">
我正在标记c和c++,因为两种语言的问题相似,但答案可能不同。
我不知道研究和统计,但是是的,考虑到编译器实际所做的,肯定有优化。是的,它们非常重要(例如 tldr 循环矢量化)。
除了编译器优化之外,还有另一个方面需要考虑。使用 UB,您可以获得 C/C++ 有符号整数,使其在数学上表现得像您期望的那样算术。例如,x + 10 > x
现在成立(当然对于有效的代码),但在环绕行为上不会。
我从Krister Walfridsson的博客中找到了一篇很棒的文章 未定义的有符号溢出如何在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] 相邻。这改进了对内存访问的分析 矢量化等
这是一个非常重要的优化,因为循环矢量化是最有效和最有效的优化算法之一。
这是将索引从无符号索引更改为有符号索引会改进生成的程序集的示例:
无符号版本
#include <cstddef>
auto foo(int* v, std::size_t start)
{
int sum = 0;
for (std::size_t i = start; i < start + 4; ++i)
sum += v[i];
return sum;
}
对于无符号,必须考虑start + 4
环绕的情况,并生成一个分支来处理这种情况(分支对性能不利):
; gcc on x64 with -march=skylake
foo1(int*, unsigned long):
cmp rsi, -5
ja .L3
vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
vpsrldq xmm1, xmm0, 8
vpaddd xmm0, xmm0, xmm1
vpsrldq xmm1, xmm0, 4
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
.L3:
xor eax, eax
ret
; clang on x64 with -march=skylake
foo1(int*, unsigned long): # @foo1(int*, unsigned long)
xor eax, eax
cmp rsi, -4
jae .LBB0_2
vpbroadcastq xmm0, qword ptr [rdi + 4*rsi + 8]
vpaddd xmm0, xmm0, xmmword ptr [rdi + 4*rsi]
vpshufd xmm1, xmm0, 85 # xmm1 = xmm0[1,1,1,1]
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
.LBB0_2:
ret
作为旁注,使用较窄的类型会导致最糟糕的组装,从而抑制使用 SSE 矢量化指令:
#include <cstddef>
auto foo(int* v, unsigned start)
{
int sum = 0;
for (unsigned i = start; i < start + 4; ++i)
sum += v[i];
return sum;
}
; gcc on x64 with -march=skylake
foo(int*, unsigned int):
cmp esi, -5
ja .L3
mov eax, esi
mov eax, DWORD PTR [rdi+rax*4]
lea edx, [rsi+1]
add eax, DWORD PTR [rdi+rdx*4]
lea edx, [rsi+2]
add eax, DWORD PTR [rdi+rdx*4]
lea edx, [rsi+3]
add eax, DWORD PTR [rdi+rdx*4]
ret
.L3:
xor eax, eax
ret
; clang on x64 with -march=skylake
foo(int*, unsigned int): # @foo(int*, unsigned int)
xor eax, eax
cmp esi, -5
ja .LBB0_3
mov ecx, esi
add esi, 4
mov eax, dword ptr [rdi + 4*rcx]
lea rdx, [rcx + 1]
cmp rdx, rsi
jae .LBB0_3
add eax, dword ptr [rdi + 4*rcx + 4]
add eax, dword ptr [rdi + 4*rcx + 8]
add eax, dword ptr [rdi + 4*rcx + 12]
.LBB0_3:
ret
签名版本
但是,使用有符号索引会产生很好的矢量化无分支代码:
#include <cstddef>
auto foo(int* v, std::ptrdiff_t start)
{
int sum = 0;
for (std::ptrdiff_t i = start; i < start + 4; ++i)
sum += v[i];
return sum;
}
; gcc on x64 with -march=skylake
foo(int*, long):
vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
vpsrldq xmm1, xmm0, 8
vpaddd xmm0, xmm0, xmm1
vpsrldq xmm1, xmm0, 4
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
; clang on x64 with -march=skylake
foo(int*, long): # @foo(int*, long)
vpbroadcastq xmm0, qword ptr [rdi + 4*rsi + 8]
vpaddd xmm0, xmm0, xmmword ptr [rdi + 4*rsi]
vpshufd xmm1, xmm0, 85 # xmm1 = xmm0[1,1,1,1]
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
使用较窄的有符号类型时,仍使用矢量化指令:
#include <cstddef>
auto foo(int* v, int start)
{
int sum = 0;
for (int i = start; i < start + 4; ++i)
sum += v[i];
return sum;
}
; gcc on x64 with -march=skylake
foo(int*, int):
movsx rsi, esi
vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
vpsrldq xmm1, xmm0, 8
vpaddd xmm0, xmm0, xmm1
vpsrldq xmm1, xmm0, 4
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
; clang on x64 with -march=skylake
foo(int*, int): # @foo(int*, int)
movsxd rax, esi
vpbroadcastq xmm0, qword ptr [rdi + 4*rax + 8]
vpaddd xmm0, xmm0, xmmword ptr [rdi + 4*rax]
vpshufd xmm1, xmm0, 85 # xmm1 = xmm0[1,1,1,1]
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
ret
值范围计算
编译器跟踪变量的可能值范围 程序中的每个点,即对于代码,例如
int x = foo(); if (x > 0) { int y = x + 5; int z = y / 4;
它确定 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
- 如果范围不越过
0
,请将abs(x)
更改为x
或-x
- 如果
x>0
和恒定c
,将x/c
更改为x>>log2(c)
是一种2
的力量- 如果
x>0
和恒定c
,将x%c
更改为x&(c-1)
是一种2
的力量
循环分析和优化
为什么未定义的有符号溢出帮助循环的规范示例 优化是循环,例如
for (int i = 0; i <= m; i++)
保证在未定义的溢出时终止。这有助于 具有特定循环指令的体系结构,如 一般不处理无限循环。
但是未定义的有符号溢出有助于更多的循环优化。都 分析,例如确定迭代次数、转换 归纳变量和跟踪内存访问正在使用 前面各节中的所有内容,以便完成其工作。在 特别是,可以矢量化的循环集严重 允许签名溢出时减少。
不完全是优化的例子,但未定义行为的一个有用结果是 GCC/clang 的命令行切换-ftrapv
。它插入的代码在整数溢出时使程序崩溃。
它不适用于无符号整数,因为无符号溢出是故意的。
该标准关于有符号整数溢出的措辞确保人们不会故意编写溢出代码,因此ftrapv
是发现意外溢出的有用工具。
这是一个实际的小基准,气泡排序。我已经比较了没有/与-fwrapv
的时间(这意味着溢出是 UB/不是 UB)。以下是结果(秒):
-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(-fwrapv
)版本几乎总是较慢,最大的差异非常大,为1.85倍。
这是代码。请注意,我有意选择了一种实现,它应该会为此测试产生更大的差异。
#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);
}
}
答案实际上在你的问题中:
然而,大多数 CPU 都实现了具有定义语义的有符号算术
我想不出您今天可以购买的CPU不使用二进制赞美算法来表示有符号整数,但情况并非总是如此。
C语言发明于1972年。当时,IBM 7090 大型机仍然存在。并非所有计算机都是双人恭维。
围绕 2s 赞美定义语言(和溢出行为)会损害在非 2s 赞美的机器上生成代码。
此外,如前所述,指定有符号溢出为 UB 允许编译器生成更好的代码,因为它可以忽略由签名溢出产生的代码路径,假设这永远不会发生。
如果我正确理解它的目的是将 a 和 b 的总和固定到 0....INT_MAX 而不进行环绕,我可以想到两种以兼容的方式编写此函数的方法。
首先,适用于所有 CPU 的低效一般情况:
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