【问题标题】:Performance difference of signed and unsigned integers of non-native length非本机长度的有符号和无符号整数的性能差异
【发布时间】:2023-03-16 04:56:01
【问题描述】:

有一个演讲,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 实际上是 无符号类型更快,因为指令更少?

【问题讨论】:

  • 需要注意的是,这些函数没有副作用并且不返回任何内容,因此编译器可以轻松将其优化为空(gcc does at -Og)。您可能应该将整数作为参数并返回结果。
  • 您是在没有优化的情况下进行构建吗?如果启用它,结果会有所不同吗?性能测量和基准测试应始终在优化的代码上进行。
  • 对于此类测试,重要的是至少使用 -O2 优化级别以及清楚 -march、-mtune 开关
  • 在 x86 或 ARM 等架构上计算指令是没有意义的。更多的指令可能会导致更快的执行。而是分析您的代码如果您有性能问题并优化热点。
  • 第一段不是对钱德勒解释的准确总结,所以这个问题似乎是基于一个误解。

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


【解决方案1】:

这个问题有两个部分: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,所以上面的简单转换将不起作用。

在汇编级别,上面是(对于 x86-64 的 GCC 7.3.0,-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 指令上的后缀来自x86 汇编语言的所谓“AT&T”风格。在 AT&T 汇编语言中,参数与 Intel 编写指令的方式相反,并且通过在助记符中添加后缀而不是 Intel 案例中的 dword ptr 之类的后缀来消除指令大小的歧义。

例子:

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

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

这些是相同的指令,只是写法不同。 l 取代了 dword ptr

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

add $1, %eax

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

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

【讨论】:

  • 他们将其设为未定义,因为 they allow other signed formats,不仅仅是 2 的补码
  • 请注意,在这种情况下,编译器只是做得不好。它可以使用缩放索引寻址模式进行加载和存储,并使用inc %eax 而不是inc %rax,以正确实现 C++ 源代码的确切行为。 leaq 0(,%rax,4), %rcx 为下一次迭代进行设置特别脑残。但是,是的,包装索引的额外要求以某种方式限制了编译器,使其在此处自作自受。请注意,int 版本仍然使用索引寻址模式,但索引是 64 位的,并且按比例放大了 4 个字节偏移量。
  • 建议:您可以显示gcc -O2 -fwrapv 的asm 输出,这使得有符号整数溢出可以很好地定义为换行。
【解决方案2】:

add 和 addb/addw/addl/addq 都采用相同数量的周期(延迟),因为 Intel Sandy Bridge CPU 具有适用于所有尺寸的硬件加法器,或者 32 位单元在内部进行屏蔽,因此具有更长的时间延迟。

首先,它是一个64位的加法器,因为它支持qwordadd具有相同的性能。

在硬件中,屏蔽位不需要额外的时钟周期;一个时钟周期是许多gate-delays 长。启用/禁用控制信号可以将高半部分的结果归零(对于 32 位操作数大小),或在 16 位或 8 位停止进位传播(对于较小的操作数大小,使高位保持不变而不是零扩展)。

因此,具有整数 ALU 执行单元的每个执行端口可能对所有操作数大小使用相同的加法器晶体管,并使用控制信号来修改其行为。甚至可以将它用于 XOR(通过阻止所有进位信号)。


我本来打算写更多关于你对优化问题的误解,但 Myria 已经涵盖了它。

另请参阅What Every C Programmer Should Know About Undefined Behavior,这是一篇 LLVM 博客文章,它解释了 UB 允许优化的一些方法,包括专门将计数器提升为 64 位或将其优化为指针增量,而不是像您那样实现带符号的环绕d 得到有符号整数溢出是否被严格定义为换行。 (例如,如果您使用gcc -fwrapv 编译,则与-fstrict-overflow 相反)


您未优化的编译器输出毫无意义,也没有告诉我们任何信息。 x86 add 指令实现了无符号和有符号 2 的补码加法,因为它们都是相同的二进制运算-O0 的不同代码生成只是编译器内部的人工制品,而不是实际代码中会发生的任何基本内容(-O2-O3)。

【讨论】:

    猜你喜欢
    • 2012-04-19
    • 2011-06-10
    • 1970-01-01
    • 2012-02-11
    • 1970-01-01
    • 2013-10-02
    • 2015-02-17
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多