有这个讲话,CppCon 2016: Chandler Carruth “Garbage In, Garbage Out: Arguing about Undefined Behavior...",Carruth先生在其中展示了bzip代码的一个例子。他们使用uint32_t i1
作为索引。在64位系统上,阵列访问block[i1]
将执行*(block + i1)
。问题是block
是64位指针而i1
是32位数。添加可能会溢出,并且由于无符号整数已定义溢出行为,因此编译器需要添加额外的指令以确保即使在64位系统上也确实满足这一要求。
我想用一个简单的例子来说明这一点。所以我尝试了++i
代码,其中包含各种有符号和无符号整数。以下是我的测试代码:
#include <cstdint>
void test_int8() { int8_t i = 0; ++i; }
void test_uint8() { uint8_t i = 0; ++i; }
void test_int16() { int16_t i = 0; ++i; }
void test_uint16() { uint16_t i = 0; ++i; }
void test_int32() { int32_t i = 0; ++i; }
void test_uint32() { uint32_t i = 0; ++i; }
void test_int64() { int64_t i = 0; ++i; }
void test_uint64() { uint64_t i = 0; ++i; }
使用g++ -c test.cpp
和objdump -d test.o
我会收到汇编列表
这样:
000000000000004e <_Z10test_int32v>:
4e: 55 push %rbp
4f: 48 89 e5 mov %rsp,%rbp
52: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
59: 83 45 fc 01 addl $0x1,-0x4(%rbp)
5d: 90 nop
5e: 5d pop %rbp
5f: c3 retq
说实话:我对x86汇编的了解相当有限,所以我的以下内容 结论和问题可能很幼稚。
前两个指令似乎只来自一个函数的调用,即 最后三个似乎是回报值。只删除这些行 以下内核留给各种数据类型:
int8_t
:
4: c6 45 ff 00 movb $0x0,-0x1(%rbp)
8: 0f b6 45 ff movzbl -0x1(%rbp),%eax
c: 83 c0 01 add $0x1,%eax
f: 88 45 ff mov %al,-0x1(%rbp)
uint8_t
:
19: c6 45 ff 00 movb $0x0,-0x1(%rbp)
1d: 80 45 ff 01 addb $0x1,-0x1(%rbp)
int16_t
:
28: 66 c7 45 fe 00 00 movw $0x0,-0x2(%rbp)
2e: 0f b7 45 fe movzwl -0x2(%rbp),%eax
32: 83 c0 01 add $0x1,%eax
35: 66 89 45 fe mov %ax,-0x2(%rbp)
uint16_t
:
40: 66 c7 45 fe 00 00 movw $0x0,-0x2(%rbp)
46: 66 83 45 fe 01 addw $0x1,-0x2(%rbp)
int32_t
:
52: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
59: 83 45 fc 01 addl $0x1,-0x4(%rbp)
uint32_t
:
64: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
6b: 83 45 fc 01 addl $0x1,-0x4(%rbp)
int64_t
:
76: 48 c7 45 f8 00 00 00 movq $0x0,-0x8(%rbp)
7d: 00
7e: 48 83 45 f8 01 addq $0x1,-0x8(%rbp)
uint64_t
:
8a: 48 c7 45 f8 00 00 00 movq $0x0,-0x8(%rbp)
91: 00
92: 48 83 45 f8 01 addq $0x1,-0x8(%rbp)
将签名与未签名的版本进行比较 Carruth谈到了生成额外的掩蔽指令。
但是对于int8_t
我们将一个字节(movb
)加载到%rbp
,然后加载并填充它
到累加器movzbl
的长(%eax
)。添加(add
)是
没有任何大小规格执行,因为没有定义溢出
无论如何。无符号版本直接使用字节指令。
add
和addb
/ addw
/ addl
/ addq
两者的数量相同
周期(延迟),因为Intel Sandy Bridge CPU为所有人提供硬件加法器
大小或32位单元在内部进行屏蔽,因此具有更长的时间
等待时间。
我找了一张有延迟的表,找到了one by
agner.org。那里
每个CPU(在这里使用Sandy Bridge)ADD
只有一个条目,但我这样做
没有看到其他宽度变体的条目。 Intel 64 and IA-32 Architectures Optimization Reference Manual似乎也只列出了一条add
指令。
这是否意味着在x86上实际上是++i
非本机长度整数
对于无符号类型更快,因为指令较少?
答案 0 :(得分:3)
这个问题有两个部分:Chandler关于基于溢出的优化未定义的观点,以及您在汇编输出中发现的差异。
Chandler的观点是,如果溢出是未定义的行为,那么编译器可以假设它不会发生。请考虑以下代码:
typedef int T;
void CopyInts(int *dest, const int *src) {
T x = 0;
for (; src[x]; ++x) {
dest[x] = src[x];
}
}
在这里,编译器可以安全地将for
循环更改为以下内容:
while (*src) {
*dest++ = *src++;
}
那是因为编译器不必担心x
溢出的情况。如果编译器不得不担心x
溢出,那么源指针和目标指针会从它们中减去16 GB,因此上面的简单转换将不起作用。
在汇编级别,上面是(使用GCC 7.3.0 for x86-64,-O2
):
_Z8CopyIntsPiPKii:
movl (%rsi), %edx
testl %edx, %edx
je .L1
xorl %eax, %eax
.L3:
movl %edx, (%rdi,%rax)
addq $4, %rax
movl (%rsi,%rax), %edx
testl %edx, %edx
jne .L3
.L1:
rep ret
如果我们将T
更改为unsigned int
,我们会得到这个代码较慢的代码:
_Z8CopyIntsPiPKij:
movl (%rsi), %eax
testl %eax, %eax
je .L1
xorl %edx, %edx
xorl %ecx, %ecx
.L3:
movl %eax, (%rdi,%rcx)
leal 1(%rdx), %eax
movq %rax, %rdx
leaq 0(,%rax,4), %rcx
movl (%rsi,%rax,4), %eax
testl %eax, %eax
jne .L3
.L1:
rep ret
这里,编译器将x
作为单独的变量保存,以便正确处理溢出。
您可以使用与指针大小相同的大小类型,而不是依赖于未定义的带符号溢出来提高性能。这意味着这样的变量只能在指针的同时溢出,这也是未定义的。因此,至少对于x86-64,size_t
也可以作为T
使用以获得更好的性能。
现在问题的第二部分:add
指令。 add
指令的后缀来自所谓的“AT&amp; T”样式的x86汇编语言。在AT&amp; T汇编语言中,参数是英特尔写入指令的方式落后的,并且通过在英特尔情况下为助记符添加后缀而不是类似dword ptr
的内容来消除指令大小的歧义。
示例:
英特尔:add dword ptr [eax], 1
AT&amp; T:addl $1, (%eax)
这些是相同的指令,只是用不同的方式写的。 l
取代dword ptr
。
在AT&amp; T指令缺少后缀的情况下,这是因为它不是必需的:大小是从操作数隐含的。
add $1, %eax
l
后缀是不必要的,因为该指令显然是32位,因为eax
是。
简而言之,它与溢出无关。始终在处理器级别定义溢出。在某些体系结构上,例如在MIPS上使用非u
指令时,overflow会引发异常,但它仍然是定义的。 C / C ++是唯一使溢出不可预测的行为的主要语言。
答案 1 :(得分:1)
add和addb / addw / addl / addq都需要相同的周期数(延迟),因为Intel Sandy Bridge CPU具有适用于所有大小的硬件加法器,或者32位单元在内部进行屏蔽,因此具有更长的周期等待时间。
首先,它是一个64位加法器,因为它支持具有相同性能的qword add
。
在硬件中,屏蔽位不需要额外的时钟周期;一个时钟周期很长gate-delays。启用/禁用控制信号可以将高半部分的结果归零(对于32位操作数大小),或者停止16或8位的进位传播(对于较小的操作数大小,使高位未经修改而不是零扩展) )。
因此,具有整数ALU执行单元的每个执行端口可能对所有操作数大小使用相同的加法器晶体管,使用控制信号来修改其行为。也许甚至将它用于XOR(通过阻止所有进位信号)。
我打算写更多关于你对优化问题的误解,但Myria已经介绍了它。
另请参阅What Every C Programmer Should Know About Undefined Behavior,一篇LLVM博客文章,解释了UB允许优化的一些方法,包括专门将计数器提升为64位或将其优化为指针增量,而不是实现签名环绕如果有符号整数溢出被严格定义为包装,那么你会得到。 (例如,如果使用gcc -fwrapv
编译,则与-fstrict-overflow
)
您的未优化编译器输出毫无意义,并没有告诉我们任何事情。 x86 add
指令实现了无符号和有符号2的补码,因为它们都是相同的二进制运算。 -O0
中的不同代码只是编译器内部的人工制品,而不是真实代码中发生的任何基本事件(使用-O2
或-O3
)。