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

19

总结:我正在查看汇编代码以指导我的优化,并且发现在将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中出现过。

这个线程证实了我的发现,但不清楚是否需要符号或零扩展。只有当高32位尚未设置时,才需要进行符号/零扩展。但是x86-64 ABI会不会足够聪明,要求这样做呢?

我有点不愿意将所有指针偏移量都改成ssize_t,因为寄存器泄漏会增加代码的缓存占用。


int 是一种有符号类型。如果你不想要符号扩展,请使用 unsigned 或更好地使用 size_t - EOF
4
我尝试过了,但编译器只是用零扩展替换了符号扩展,而我也不想要这个结果。 - Yale Zhang
我已经查看了SYS V x86-64 ABI,没有看到关于符号/零扩展的很多参考资料。但是当混合32位指针和16位偏移量时,我发现了相同的问题。 - Yale Zhang
5
SysV ABI规范并未要求在传递32位类型时,64位寄存器的高32位必须清零。编译以下代码并查看生成的汇编:void foo(uint32_t); void bar(uint64_t x){foo(x);} - EOF
@EOF 很好的观点。这证明了高32位经常是未定义的。我认为在64位模式下,每个指令都计算一个64位结果,使得调用者不需要进行转换,唯一的例外可能是传统的32位汇编代码(例如mov ah, 3),这是不鼓励的,因为部分寄存器写入速度较慢。我认为这是最好的答案,因为它解释了高32位未定义的普遍性,因为C通过截断将int64转换为int32。如果你写出来,我会接受它。 - Yale Zhang
显示剩余2条评论
2个回答

27
是的,你必须假设一个参数或返回值寄存器的高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 的内容。

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

ABI对于保存参数或返回值的整数或向量寄存器高位的内容没有进一步要求或保证,因此不存在。我通过电子邮件从ABI维护者之一Michael Matz确认了这一事实:“通常情况下,如果ABI没有说明某些内容是指定的,您就不能依赖它。”
他还确认了例如clang >= 3.6使用一个addps的bug,可能会在高元素中产生垃圾,从而减慢或引发额外的FP异常(这让我想起我应该报告一下)。他补充说,这曾经是一个AMD实现glibc数学函数的问题。当传递标量doublefloat参数时,普通C代码可以在向量寄存器的高元素中留下垃圾。

实际行为,尚未在标准中记录:

即使是_Bool/bool,狭窄的函数参数也会被符号或零扩展为32位。clang甚至制造了依赖于这种行为的代码(自2007年以来,显然)。ICC17没有这样做,因此ICC和clang在C方面甚至不兼容ABI。如果前6个整数参数中有任何一个小于32位,请勿从ICC编译的代码调用x86-64 SysV ABI的clang编译函数。

这并不适用于返回值,仅适用于参数:gcc和clang都假定它们接收到的返回值只有类型宽度范围内的有效数据。例如,gcc将使返回char的函数在%eax的高24位中留下垃圾。 最近在ABI讨论组上的一个帖子提出了一个建议,即澄清将8位和16位参数扩展为32位的规则,并可能实际修改ABI以要求此操作。主要编译器(除ICC外)已经这样做了,但这将是调用者和被调用者之间合同的变更。
这是一个例子(可以通过其他编译器检查或调整在Godbolt编译器资源管理器上的代码),其中包括许多简单的示例,仅演示谜题的一部分以及演示很多部分的示例。
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) 是等价的,但不会更小。如果我们可以保证fuint()返回值的高位被清零,编译器就可以使用array_us(%rbx, %rax, 2)代替使用add指令。

性能影响

故意忽略高32位是一个好的设计决策,对于32位操作,忽略高32位是免费的。32位操作自动将结果零扩展到64位,所以只有在64位寻址模式或64位操作中可以直接使用寄存器时,才需要额外的mov edx, edi或其他指令。

一些函数不会因为参数已经扩展到64位而节省任何指令,因此对于调用者来说总是需要这样做可能是一种潜在的浪费。一些函数以需要与参数符号相反的方式使用它们的参数,因此让被调用者决定该怎么做效果很好。

无论有无符号,将其零扩展为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进行参数传递的未记录行为是有道理的。有关这些微体系结构的更多详细信息,请参见为什么GCC不使用部分寄存器?


这对于实际代码中的代码大小可能不是什么大问题,因为微小的函数应该是静态内联的,并且参数处理指令只是更大函数的一小部分。即使没有内联,跨过调用之间的开销也可以通过函数间优化来消除,只要编译器能看到两个定义。(我不知道编译器在实践中做得如何。)
我不确定将函数签名更改为使用uintptr_t是否会在64位指针上帮助或损害整体性能。对于标量,我不会担心堆栈空间。在大多数函数中,编译器推送/弹出足够数量的保留寄存器(例如%rbx和%rbp),以使其自己的变量保持在寄存器中活动。相比于4B,额外的8B溢出空间微不足道。
就代码大小而言,使用64位值需要在一些指令上加上REX前缀,而本来不需要的指令则不需要。如果在将32位值用作数组索引之前需要进行任何操作,则零扩展到64位是免费的。如果需要符号扩展,则始终需要额外的指令。但编译器可以从一开始就对其进行符号扩展并将其视为64位有符号值来节省指令,代价是需要更多的REX前缀。(有符号溢出是未定义行为,不被定义为环绕,因此编译器通常可以避免在使用arr[i]int i循环内重新进行符号扩展。)
现代CPU通常更关注指令计数而不是指令大小,但要合理。热点代码通常会从具有uop缓存的CPU中运行。尽管如此,较小的代码可以提高uop缓存的密度。如果您可以节省代码大小而不使用更多或更慢的指令,则这是一个胜利,但通常不值得为此牺牲任何其他方面,除非节省的代码量非常大。

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


这是很深奥的内容,但你挖掘出了一宝藏般的信息。谢谢。现在的主要问题是选择数组索引类型应该使用什么最佳实践。目前,我几乎在所有参与地址计算的数字中都使用ssize_t。总体上看,这似乎效果不错,但根据你的发现,这可能并不必要甚至不是最优的。因此,我想改变我的策略,在所有顶层函数中使用ssize_t(以便永远不会有符号或零扩展)。然后对于叶子代码或热循环,尽可能利用int32。 - Yale Zhang
1
int32与您提到的同样快或更快的情况包括:1. 32位操作的免费零扩展。我对此持怀疑态度,因为这样你将不得不使用无符号类型,由于溢出而更容易出错。2. 在Nehalem之前和其他架构上,32位乘法比64位乘法更快。3. 不使用REX前缀可以减小代码大小。--------------- x86-64有所有这些怪癖真是太遗憾了。ARM64没有这个问题 - 它能够直接在地址计算中使用64位寄存器的低半部分。 - Yale Zhang
@YaleZhang:如果您发现任何可测量的速度差异,请告诉我。我也想知道这个问题,并且偶尔会查看使用signed intunsigned int的代码,具体取决于上下文可能会很笨重。 unsigned最大的缺点是编译器必须发出在它溢出时行为正确的代码,而不像int(有符号溢出是未定义行为)。这可以允许更多的优化 - Peter Cordes
3
就我所知,icc 似乎违反了您上面提到的实际(未记录的)标准:它不会将小于 32 位的参数进行符号扩展或零扩展以达到 32 位。这是一个例子。请注意,它只是使用 edi 的第 8-31 位中的任意垃圾数据调用 consumer(char a) - BeeOnRope
@BeeOnRope:发现得好。这很糟糕,所以显然如果有任何窄整数参数,您不能安全地从ICC编译的代码调用clang编译的函数。 - Peter Cordes
显示剩余2条评论

2
正如EOF的评论所示,编译器不能假设用于传递32位参数的64位寄存器的上32位具有任何特定值。这使得符号或零扩展是必要的。
唯一防止这种情况的方法是使用64位类型作为参数,但这会将扩展值的要求转移到调用者,这可能并不是改进。我不会太担心寄存器溢出的大小,因为你现在的做法更可能是在扩展后原始值已经无用了,而是64位扩展值被溢出。即使它没有无用,编译器仍然可能更喜欢溢出64位值。
如果你真的关心内存占用,并且不需要更大的64位地址空间,你可以看看x32 ABI,它使用ILP32类型但支持完整的64位指令集。

1
x32对于指针密集的数据结构来说是一个数据大小上的胜利,我认为对于代码大小也是如此。然而,当编译器无法证明64位寻址模式不会超出低4G时,它经常必须使用地址大小前缀。(例如,[eax + disp]将会回绕,但[rax + disp]不会,因此除非编译器可以在某种程度上证明地址和/或索引(如果使用另一个寄存器进行索引)的某些内容,否则需要使用地址大小前缀)。 - Peter Cordes
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Ross Ridge
我也感到惊讶,原因相同:这有什么用处?据我所知,即使通过执行32位操作或其他方式将其上32位清零,它仍然会使用它。此时,它可能更像是一种安全但不完美的实现。 - Peter Cordes
在调用者而不是被调用者中支付零/符号扩展成本的好处是很明显的,这可以使代码大小增加而不会减少执行的指令数量。我对寄存器溢出并不那么担心,因为如果32位读/写命中缓存,它们的延迟和吞吐量与64位读/写相同,并且导致额外缓存未命中的机会很低。 - Yale Zhang
我添加了自己的答案,并提供了更详细的信息。最有趣的是:clang取决于调用者对狭窄参数进行符号或零扩展以达到32位。 - Peter Cordes
更有趣的是:根据我上面的评论,icc甚至不会对32位进行符号/零扩展。因此,clangicc是相互不兼容的。gccicc兼容,因为即使它确实对32位进行了扩展,但在实现函数时似乎并没有依赖于它(尚未)。 - BeeOnRope

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接