是否有一些有意义的统计数据来证明保持有符号整数算术溢出未定义是合理的

Is there some meaningful statistical data to justify keeping signed integer arithmetic overflow undefined?

本文关键字:整数 溢出 未定义 符号 有意义 统计数据 是否 证明      更新时间:2023-10-16

C 标准明确指定有符号整数溢出具有未定义的行为。然而,大多数 CPU 都实现了带有定义溢出语义的有符号算术(除法溢出除外:x / 0INT_MIN / -1)。

编译器编写者一直在利用这种溢出的未定义性来添加更积极的优化,这些优化往往会以非常微妙的方式破坏遗留代码。例如,此代码可能适用于较旧的编译器,但不再适用于当前版本的gccclang

/* 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"闭幕主题演讲:未定义的行为和编译器优化">

我正在标记cc++,因为两种语言的问题相似,但答案可能不同。

我不知道研究和统计,但是是的,考虑到编译器实际所做的,肯定有优化。是的,它们非常重要(例如 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]<>的比较),例如

  • 如果xy的范围不重叠,则将比较x<y更改为 true 或 false
  • 如果范围不重叠,则将min(x,y)max(x,y)更改为xy
  • 如果范围不越过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