将32位偏移量添加到x86-64 ABI的指针时是否需要符号或零扩展?

问题描述 投票:14回答:2

简介:我正在查看汇编代码来指导我的优化,并在将int32添加到指针时看到许多符号或零扩展。

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

起初,我认为我的编译器在添加32位到64位整数时遇到了挑战,但我已经用Intel ICC 11,ICC 14和GCC 5.3证实了这种行为。

这个thread证实了我的发现,但不清楚是否需要符号或零延伸。仅当尚未设置高32位时,才需要此符号/零扩展。但x86-64 ABI难道不够聪明吗?

我有点不愿意将所有指针偏移更改为ssize_t,因为寄存器溢出会增加代码的缓存占用空间。

assembly x86-64 compiler-optimization abi sign-extension
2个回答
17
投票

是的,您必须假设arg或返回值寄存器的高32位包含垃圾。另一方面,您可以在打电话或自行返回时将垃圾留在高32。即负载在接收端忽略高位,而不是在传递侧清除高位。

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


The standard:

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

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

另外,关于%al持有FP寄存器args数量的函数,而不是整个%rax

open github issue上有一个关于这个确切问题的the github page for the x32 and x86-64 ABI documents

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

他还证实,例如clang >= 3.6's use of an addps that could slow down or raise extra FP exceptions with garbage in high elements is a bug(这提醒我,我应该报告)。他补充说,这是一个AMD执行glibc数学函数的问题。通过标量doublefloat args时,正常的C代码可以在向量regs的高元素中留下垃圾。


Actual behaviour which is not (yet) documented in the standard:

窄函数参数,即使是_Bool / bool,也是符号或零扩展到32位。 clang甚至制作依赖于这种行为的代码(since 2007, apparently)。 ICC17 doesn't do it,因此ICC和clang不兼容ABI,即使对于C.如果前6个整数args中的任何一个小于32,请不要从ICC编译的代码中调用x86-64 SysV ABI的clang编译函数位。

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

recent thread on the ABI discussion group是一个澄清将8位和16位args扩展到32位的规则的提议,并且可能实际上修改了ABI以要求这样做。主要的编译器(ICC除外)已经做到了,但它将改变呼叫者和被叫者之间的合同。

这是一个例子(与其他编译器一起检查或调整代码on the 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)而不是使用add insn。


Performance implications

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

在执行32位操作时,忽略高32是免费的。 A 32-bit operation zero-extends its result to 64-bit for free,所以如果你可以直接在64位寻址模式或64位操作中使用reg,你只需要额外的mov edx, edi

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

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

16位和8位操作数大小通常会导致错误依赖(AMD,P4或Silvermont,以及后来的SnB系列),或部分寄存器停顿(pre-SnB)或轻微减速(Sandybridge),因此无证件行为需要8和16b类型扩展到32b的arg传递是有道理的。有关这些微体系结构的更多详细信息,请参阅Why doesn't GCC use partial registers?


这对于实际代码中的代码大小来说可能不是什么大问题,因为微小的函数是/应该是static inline,而arg处理insn只是更大函数的一小部分。当编译器可以看到两个定义时,即使没有内联,过程间优化也可以消除调用之间的开销。 (IDK编译器在实践中对此做了多少。)

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

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

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

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


2
投票

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

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

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

© www.soinside.com 2019 - 2024. All rights reserved.