非本机长度

时间:2018-04-11 18:58:47

标签: c++ assembly optimization x86-64 compiler-optimization

有这个讲话,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.cppobjdump -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)是 没有任何大小规格执行,因为没有定义溢出 无论如何。无符号版本直接使用字节指令。

addaddb / 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非本机长度整数 对于无符号类型更快,因为指令较少?

2 个答案:

答案 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)。