虽然我不了解研究和统计方面的知识,但编译器确实考虑了这一点进行了优化。这些优化非常重要(例如,循环向量化)。
除了编译器优化之外,还有另一个需要考虑的方面。在存在未定义行为(UB)时,C/C++带符号整数的算术行为与你所期望的数学行为相同。例如,现在x + 10>x
成立(当然是对于有效的代码),但在回绕行为下则不成立。
我找到了Krister Walfridsson博客上的一篇优秀文章《如何利用有符号溢出的未定义行为使GCC进行优化》,其中列出了一些考虑带符号溢出UB的优化。以下示例来自该文章。我将给它们添加C++和汇编示例。
如果这些优化看起来过于简单、无趣或没有影响力,请记住,这些优化只是更大的优化链中的步骤。蝴蝶效应确实会发生,因为较早步骤的看似不重要的优化可能会触发更具影响力的优化。
如果这些示例看起来毫无意义(谁会写x * 10>0
),请记住,你可以通过常量、宏、模板很容易地得到这种示例。此外,编译器在应用IR的转换和优化时也可以得到这种示例。
带符号整数表达式简化
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;
if语句后程序确定了变量x的范围为[1, INT_MAX]
,因此可以确定y的范围为[6, INT_MAX]
,因为不允许出现溢出。同时,由于编译器知道y是非负数,下一行代码可优化为int z = y >> 2;
。
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是2的幂,则将x / c更改为x>> log2(c)
- 如果x> 0并且常数c是2的幂,则将x%c更改为x&(c-1)
循环分析和优化的典型例子是像以下示例的循环:for (int i = 0
保证在未定义溢出时终止。这有助于具有特定循环指令的体系结构,因为它们通常不能处理无限循环。
但是,未定义的带符号溢出可以帮助许多循环优化。所有分析,例如确定迭代次数、转换归纳变量和跟踪内存访问都使用前面部分中的所有内容来完成其工作。特别地,允许带符号溢出会严重减少可以进行向量化的循环集合。