非本机长度的有符号和无符号整数的性能差异

Mar*_*ing 4 c++ optimization assembly x86-64 compiler-optimization

有这样一个演讲,CppCon 2016:Chandler Carruth \xe2\x80\x9cGarbage In,Garbage Out:争论未定义的行为...”,其中 Carruth 先生展示了 bzip 代码中的一个示例。他们已将其用作uint32_t i1索引。在 64 位系统中,数组访问block[i1]将执行*(block + i1)。问题是block是 64 位指针,而i1是 32 位数字。加法可能会溢出,并且由于无符号整数已定义溢出行为,因此编译器需要添加额外的指令确保即使在 64 位系统上也确实实现了这一点。

\n\n

我还想用一个简单的例子来展示这一点。所以我尝试了++i使用各种有符号和无符号整数的代码。以下是我的测试代码:

\n\n
#include <cstdint>\n\nvoid test_int8() { int8_t i = 0; ++i; }\nvoid test_uint8() { uint8_t i = 0; ++i; }\n\nvoid test_int16() { int16_t i = 0; ++i; }\nvoid test_uint16() { uint16_t i = 0; ++i; }\n\nvoid test_int32() { int32_t i = 0; ++i; }\nvoid test_uint32() { uint32_t i = 0; ++i; }\n\nvoid test_int64() { int64_t i = 0; ++i; }\nvoid test_uint64() { uint64_t i = 0; ++i; } \n
Run Code Online (Sandbox Code Playgroud)\n\n

使用g++ -c test.cppobjdump -d test.o我得到像这样的程序集列表:

\n\n
000000000000004e <_Z10test_int32v>:\n  4e:   55                      push   %rbp\n  4f:   48 89 e5                mov    %rsp,%rbp\n  52:   c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)\n  59:   83 45 fc 01             addl   $0x1,-0x4(%rbp)\n  5d:   90                      nop\n  5e:   5d                      pop    %rbp\n  5f:   c3                      retq   \n
Run Code Online (Sandbox Code Playgroud)\n\n

说实话:我对x86汇编的了解相当有限,所以我下面的结论和问题可能很幼稚。

\n\n

前两条指令似乎只是来自函数的调用,最后三个指令似乎是返回值。仅删除这些行,\n为各种数据类型留下以下内核:

\n\n\n\n

比较签名版本和未签名版本,我从 Carruth 先生的演讲中预计会生成额外的屏蔽指令。

\n\n

但是int8_t我们将一个字节 ( movb) 加载到 中%rbp,然后将其加载并用零填充为一个长整型 ( movzbl) 到累加器中%eax。执行加法 ( add) 时无需指定任何大小,因为无论如何都未定义溢出。无符号版本直接使用字节指令。

\n\n

addaddb//两者都采用相同的周期数(延迟),因为 Intel Sandy Bridge addwCPU具有适用于所有大小的硬件加法addladdq,或者 32 位单元在内部进行掩码,因此延迟较长。

\n\n

我寻找了一张有延迟的表格,并找到了由 \nagner.org 提供的表格。对于每个 CPU(此处使用 Sandy Bridge)只有一个条目,ADD但我没有看到其他宽度变体的条目。Intel 64 和 IA-32 架构优化参考手册似乎也只列出了一条add指令。

\n\n

这是否意味着在 x86 上,++i对于无符号类型,非本机长度整数实际上更快,因为指令较少?

\n

Myr*_*ria 6

这个问题有两个部分:钱德勒关于基于未定义的溢出的优化的观点,以及您在汇编输出中发现的差异。

钱德勒的观点是,如果溢出是未定义的行为,那么编译器可以假设它不会发生。考虑以下代码:

typedef int T;
void CopyInts(int *dest, const int *src) {
    T x = 0;
    for (; src[x]; ++x) {
        dest[x] = src[x];
    }
}
Run Code Online (Sandbox Code Playgroud)

在这里,编译器可以安全地将for循环更改为以下内容:

    while (*src) {
        *dest++ = *src++;
    }
Run Code Online (Sandbox Code Playgroud)

那是因为编译器不必担心x溢出的情况。如果编译器必须担心x溢出,源指针和目标指针会突然减去 16 GB,因此上面的简单转换将不起作用。

在汇编级别,上面是(对于 x86-64 的 GCC 7.3.0,-O2):

    while (*src) {
        *dest++ = *src++;
    }
Run Code Online (Sandbox Code Playgroud)

如果我们更改T为 be 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
Run Code Online (Sandbox Code Playgroud)

在这里,编译器将其保留x为单独的变量,以便正确处理溢出。

您可以使用与指针大小相同的大小类型,而不是依赖未定义的有符号溢出来提高性能。这意味着这样的变量只能与指针同时溢出,而指针也是未定义的。因此,至少对于 x86-64,size_t也可以获得T更好的性能。

现在回答您问题的第二部分:add说明。指令上的后缀add来自所谓的“AT&T”风格的 x86 汇编语言。在 AT&T 汇编语言中,参数与 Intel 编写指令的方式相反,并且通过在助记符中添加后缀来消除指令大小的歧义,而不是像dword ptrIntel 那样。

例子:

英特尔:add dword ptr [eax], 1

美国电话电报公司:addl $1, (%eax)

这些是相同的指令,只是写法不同。代替。ldword ptr

如果 AT&T 指令中缺少后缀,这是因为它不是必需的:大小是从操作数中隐含的。

add $1, %eax

后缀l是不必要的,因为该指令显然是 32 位的,因为eax是。

简而言之,与溢出无关。溢出始终在处理器级别定义。在某些体系结构上,例如u在 MIPS 上使用非指令时,溢出会引发异常,但它仍然被定义。C/C++ 是唯一使溢出行为变得不可预测的主要语言。