19

Summary: I was looking at assembly code to guide my optimizations and see lots of sign or zero extensions when adding int32 to a pointer.

void Test(int *out, int offset)
{
    out[offset] = 1;
}
-------------------------------------
movslq  %esi, %rsi
movl    $1, (%rdi,%rsi,4)
ret

At first, I thought my compiler was challenged at adding 32bit to 64bit integers, but I've confirmed this behavior with Intel ICC 11, ICC 14, and GCC 5.3.

This thread confirms my findings, but it's not clear if the sign or zero extension is necessary. This sign/zero extension would only be necessary if the upper 32bits aren't already set. But wouldn't the x86-64 ABI be smart enough to require that?

I'm kind of reluctant to change all my pointer offsets to ssize_t because register spills will increase the cache footprint of the code.

4

2 回答 2

25

是的,您必须假设 arg 或返回值寄存器的高 32 位包含垃圾。另一方面,您可以在调用或返回自己时将垃圾留在高位 32 中。即忽略高位的负担在接收方,而不是在通过方清除高位。

您需要将符号或零扩展到 64 位才能使用 64 位有效地址中的值。在x32 ABI中,gcc 经常使用 32 位有效地址而不是使用 64 位操作数大小来修改用作数组索引的潜在负整数的每条指令。


标准:

x86-64 SysV ABI只说明寄存器的哪些部分被归零(_Bool又名bool)。第 20 页:

当一个类型的值_Bool被返回或传入寄存器或堆栈时,位 0 包含真值,位 1 到 7 应为零(脚注 14:其他位未指定,因此这些值的消费者端可以依赖截断为 8 位时为 0 或 1)

此外,关于%al保存可变参数函数的 FP 寄存器参数数量的东西,而不是整个%rax.

在 x32 和 x86-64 ABI 文档的 github 页面上有一个关于这个确切问题的开放 github 问题

ABI 没有对保存 args 或返回值的整数或向量寄存器的高位部分的内容提出任何进一步的要求或保证,因此没有任何要求或保证。我通过 Michael Matz(ABI 维护者之一)的电子邮件确认了这一事实:“一般来说,如果 ABI 没有说明什么是指定的,你就不能依赖它。”

他还证实,例如clang >= 3.6 使用addps可能会减慢或引发额外的 FP 异常以及高元素中的垃圾是一个错误(这提醒我我应该报告这一点)。他补充说,这曾经是 glibc 数学函数的 AMD 实现的一个问题。传递标量或参数时,普通 C 代码可能会在向量 reg 的高元素中留下垃圾。doublefloat


(尚未)在标准中记录的实际行为:

窄函数参数,甚至_Bool/ bool,被符号或零扩展为 32 位。clang 甚至编写了依赖于这种行为的代码(显然是从 2007 年开始)。ICC17不这样做,因此ICC 和 clang 不兼容 ABI,即使对于 C 也是如此。如果前 6 个整数参数中的任何一个,请勿从 x86-64 SysV ABI 的 ICC 编译代码调用 clang 编译函数比 32 位更窄。

这不适用于返回值,只有 args:gcc 和 clang 都假定它们接收到的返回值仅具有不超过类型宽度的有效数据。例如, gcc 将使返回的函数char在 的高 24 位中留下垃圾%eax

ABI 讨论组最近的一个主题是一项提案,旨在阐明将 8 位和 16 位 args 扩展到 32 位的规则,并可能实际修改 ABI 以要求这样做。主要编译器(ICC 除外)已经这样做了,但这将改变调用者和被调用者之间的合同。

这是一个示例(与其他编译器一起检查或调整Godbolt Compiler Explorer 上的代码,其中我包含了许多仅演示一个难题的简单示例,以及演示了很多内容的示例):

extern short fshort(short a);
extern unsigned fuint(unsigned int a);

extern unsigned short array_us[];
unsigned short lookupu(unsigned short a) {
  unsigned int a_int = a + 1234;
  a_int += fshort(a);                 // NOTE: not the same calls as the signed lookup
  return array_us[a + fuint(a_int)];
}

# clang-3.8 -O3  for x86-64.    arg in %rdi.  (Actually in %di, zero-extended to %edi by our caller)
lookupu(unsigned short):
    pushq   %rbx                      # save a call-preserved reg for out own use.  (Also aligns the stack for another call)
    movl    %edi, %ebx                # If we didn't assume our arg was already zero-extended, this would be a movzwl (aka movzx)
    movswl  %bx, %edi                 # sign-extend to call a function that takes signed short instead of unsigned short.
    callq   fshort(short)
    cwtl                              # Don't trust the upper bits of the return value.  (This is cdqe, Intel syntax.  eax = sign_extend(ax))
    leal    1234(%rbx,%rax), %edi     # this is the point where we'd get a wrong answer if our arg wasn't zero-extended.  gcc doesn't assume this, but clang does.
    callq   fuint(unsigned int)
    addl    %ebx, %eax                # zero-extends eax to 64bits
    movzwl  array_us(%rax,%rax), %eax # This zero-extension (instead of just writing ax) is *not* for correctness, just for performance: avoid partial-register slowdowns if the caller reads eax
    popq    %rbx
    retq

注意:movzwl array_us(,%rax,2)将是等效的,但不会更小。%rax如果我们可以依赖在 的返回值中归零的高位fuint(),编译器可以使用array_us(%rbx, %rax, 2)而不是使用addinsn.


性能影响

保留未定义的 high32 是故意的,我认为这是一个很好的设计决定。

在进行 32 位操作时,忽略高 32 是免费的。 32 位操作将其结果零扩展为 64 位免费mov edx, edi,因此如果您可以直接在 64 位寻址模式或 64 位操作中使用 reg ,则只需要额外的或其他东西。

有些函数不会保存任何 insn,因为它们的 args 已经扩展到 64 位,所以调用者总是不得不这样做是一种潜在的浪费。一些函数使用它们的 args 的方式需要与 arg 的签名相反的扩展,因此将它留给被调用者来决定要做什么很有效。

但是,对于大多数调用者来说,无论签名如何,零扩展到 64 位都是免费的,并且可能是 ABI 设计的一个不错的选择。由于 arg regs 无论如何都会被破坏,如果调用者想要在只通过低 32 的调用中保持完整的 64 位值,则调用者已经需要做一些额外的事情。因此,当您需要 64 位时,通常只会花费额外的费用调用之前的结果,然后将截断的版本传递给函数。在 x86-64 SysV 中,您可以在 RDI 中生成结果并使用它,然后call foo它只会查看 EDI。

16 位和 8 位操作数大小通常会导致错误的依赖关系(AMD、P4 或 Silvermont,以及后来的 SnB 系列),或部分寄存器停顿(前 SnB)或轻微减速(Sandybridge),因此未记录的行为需要将 8 和 16b 类型扩展到 32b 以进行 arg 传递是有道理的。请参阅为什么 GCC 不使用部分寄存器?有关这些微架构的更多详细信息。


这对于实际代码中的代码大小可能没什么大不了的,因为小函数是 / 应该是static inline,而 arg-handling insns 是更大函数的一小部分。当编译器可以看到这两个定义时,即使没有内联,过程间优化也可以消除调用之间的开销。(IDK 编译器在实践中的表现如何。)

我不确定更改要使用的函数签名是否uintptr_t有助于或损害 64 位指针的整体性能。我不会担心标量的堆栈空间。在大多数函数中,编译器会推送/弹出足够多的调用保留寄存器(如%rbxand %rbp)以保持其自己的变量存在于寄存器中。8B 溢出而不是 4B 溢出的一点点额外空间可以忽略不计。

就代码大小而言,使用 64 位值需要在一些原本不需要的 insn 上使用 REX 前缀。如果在将 32 位值用作数组索引之前需要对 32 位值进行任何操作,则零扩展到 64 位是免费的。如果需要,符号扩展总是需要额外的指令。但是编译器可以从一开始就将其符号扩展并作为 64 位有符号值使用以节省指令,但代价是需要更多的 REX 前缀。(有符号溢出是 UB,未定义为环绕,因此编译器通常可以避免在int i使用. 的循环内重做符号扩展arr[i]。)

在合理范围内,现代 CPU 通常更关心 insn 数量而不是 insn 大小。热代码通常会从拥有它们的 CPU 中的 uop 缓存中运行。尽管如此,更小的代码可以提高 uop 缓存中的密度。如果您可以在不使用更多或更慢的 insn 的情况下节省代码大小,那么这是一个胜利,但通常不值得牺牲任何其他东西,除非它有很多代码大小。

就像可能是一个额外的 LEA 指令以允许[reg + disp8]为十几个以后的指令寻址,而不是disp32. 或者在使用寄存器源替换 imm32=0 的xor eax,eax多条指令之前。mov [rdi+n], 0(特别是如果这允许微融合,而 RIP 相对 + 立即数是不可能的,因为真正重要的是前端 uop 计数,而不是指令计数。)

于 2016-04-21T05:38:37.833 回答
2

由于 EOF 的注释表明编译器不能假定用于传递 32 位参数的 64 位寄存器的高 32 位具有任何特定值。这使得符号或零扩展是必要的。

防止这种情况的唯一方法是对参数使用 64 位类型,但这将要求将值扩展到调用者,这可能不会有所改进。不过,我不会太担心寄存器溢出的大小,因为您现在这样做的方式很可能在扩展之后原始值将失效,而溢出的是 64 位扩展值. 即使它没有死,编译器可能仍然更喜欢溢出 64 位值。

如果您真的担心内存占用并且不需要更大的 64 位地址空间,您可以查看使用 ILP32 类型但支持完整 64 位指令集的x32 ABI 。

于 2016-04-19T03:53:03.333 回答