为什么GCC不使用部分寄存器?

34

在使用 gcc -s -nostdlib -nostartfiles -O3 编译的 Linux 系统上反汇编 write(1,"hi",3) 的结果为:

ba03000000     mov edx, 3 ; thanks for the correction jester!
bf01000000     mov edi, 1
31c0           xor eax, eax
e9d8ffffff     jmp loc.imp.write

虽然我不是编译器开发专家,但由于这些寄存器中移动的每个值都是在编译时已知且恒定的,因此我很好奇为什么GCC不使用dldilal

有人可能会认为这个特性对性能没有任何影响,但是当我们谈论程序中数千个寄存器访问时,mov $1, %rax => b801000000mov $1, %al => b001之间的可执行文件大小差异很大。小巧不仅是软件优雅的一部分,它确实会对性能产生影响。

有人能解释一下为什么“GCC决定”这并不重要吗?


8
如果你只加载部分寄存器,其余部分将包含随机垃圾值,并且被调用方将使用整个寄存器(适合数据类型的方式)。此外,这会导致部分寄存器停顿。请注意,写入低32位将自动清零高32位。附注:你反汇编错误了,所有这些指令实际上都是32位的(没有rex前缀)。 - Jester
3
@HansPassant,整数提升是否适用于原型函数的函数参数?根据标准,只有默认参数提升适用于函数调用。引用:“整数提升仅应用于:作为通常算术转换的一部分,某些参数表达式 [ndr:上述默认参数提升],一元+、-和~运算符的操作数,以及移位运算符的两个操作数,如它们各自的子条款所指定的”。 - Margaret Bloom
1
@MargaretBloom 传递的参数值通过赋值转换为参数类型。请参见第7段。无论如何,这意味着常量31,它们已经是signed int,仍然保持为signed int - Ross Ridge
@RossRidge 是的,但是赋值操作会执行整数提升吗?据我理解,答案似乎是否定的。 - Margaret Bloom
9
“针对于 @MargaretBloom 的问题,xor eax, eax 表示该调用没有原型的范围。它不知道函数是否为可变参数,因此将 AL 设置为 0,表示未传递任何 SSE 寄存器中的参数。你的情况涉及到 ABI 的问题,“好像”规则允许任一实现,只要两端都同意即可。” - Ross Ridge
显示剩余4条评论
3个回答

53
是的,GCC通常避免写入部分寄存器,除非进行大小优化(-Os)而不是纯速度优化(-O3)。某些情况下为了正确性,需要至少写入32位寄存器,因此更好的示例是:

char foo(char *p) { return *p; }编译为movzx eax, byte ptr [rdi]
而不是mov al, [rdi]https://godbolt.org/z/4ca9cTG9j

但是,GCC并不总是避免使用部分寄存器,有时甚至会导致部分寄存器停顿https://gcc.gnu.org/bugzilla/show_bug.cgi?id=15533


在许多x86处理器上,写入部分寄存器会导致性能下降,因为当写入时,它们会被重命名为不同的物理寄存器,而不是与整个寄存器相同。 (有关寄存器重命名实现乱序执行的详细信息,请参见this Q&A)。

但是,当指令读取整个寄存器时,CPU必须检测到它没有单个物理寄存器中可用的正确体系结构寄存器值的事实。(这发生在发出/重命名阶段,因为CPU准备将uop发送到乱序调度器中。)

这被称为部分寄存器停顿Agner Fog's微架构手册解释得非常好:

6.8 部分寄存器停顿 (PPro/PII/PIII和早期Pentium-M)

部分寄存器停顿是一个问题,当我们写入32位寄存器的一部分并稍后从整个寄存器或更大的一部分读取时会发生。
例如:

; Example 6.10a. Partial register stall
mov al, byte ptr [mem8]
mov ebx, eax ; Partial register stall

这会导致一个5-6个时钟的延迟。原因是为了使 AL 寄存器与 AH 寄存器独立,分配了一个临时寄存器。执行单元必须等待 AL 的写入完成后才能将其与 EAX 的其余部分的值结合起来。
在不同的CPU上表现不同。
  • Intel早期的P6系列:参见上文:暂停5-6个时钟,直到部分写入完成。
  • Intel Pentium-M(型号D)/ Core2 / Nehalem:插入合并的uop时暂停2-3个周期。(请参阅此微基准测试,写入AX并读取EAX,是否先进行xor-zeroing
  • Intel Sandybridge:在不停顿的情况下为low8 / low16(AL / AX)插入合并的uop,或者在停顿1个周期的同时为AH / BH / CH / DH插入合并的uop。
  • Intel IvyBridge(可能),但肯定是Haswell / Skylake:AL / AX没有重命名,但AH仍然是:Haswell / Skylake上的部分寄存器如何执行?写入AL似乎对RAX有误依赖,并且AH不一致
  • 所有其他x86 CPU:Intel Pentium4,Atom / Silvermont / Knight's Landing。 所有AMD(和Via等):部分寄存器永远不会被重命名。 写入部分寄存器会合并到完整寄存器中,使写入依赖于完整寄存器的旧值作为输入。
没有部分寄存器重命名,如果您从未读取完整寄存器,则写入的输入依赖关系是错误的依赖关系。这限制了指令级并行性,因为将8位或16位寄存器重新用于其他用途实际上从CPU的角度来看并不是独立的(16位代码可以访问32位寄存器,因此必须在上半部分维护正确的值)。而且,它使AL和AH不独立。当英特尔设计P6系列(PPro于1993年发布)时,16位代码仍然很常见,因此部分寄存器重命名是一个重要的功能,可以使现有的机器码运行得更快。(实际上,许多二进制文件不会重新编译为新的CPU。)
这就是为什么编译器大多数情况下避免写入部分寄存器。他们尽可能使用movzx / movsx将窄值扩展到完整寄存器以避免部分寄存器错误依赖(AMD)或停顿(Intel P6系列)。因此,大多数现代机器码并不会从部分寄存器重命名中获益,这就是为什么最近的英特尔CPU正在简化其部分寄存器重命名逻辑的原因。

正如@BeeOnRope的回答所指出的那样,编译器仍然可以读取部分寄存器,因为这不是问题。(在Haswell/Skylake上读取AH/BH/CH/DH可能会增加一个额外的延迟周期,但请参见有关Sandybridge系列最新成员部分寄存器的早期链接。)


请注意,write接受参数,在x86-64通常配置的GCC中需要整个32位和64位寄存器,因此无法简单地组装成mov dl, 3。大小由数据的类型而不是决定。
只有32位寄存器写入隐式零扩展到完整的64位;写入8位和16位部分寄存器会保留高位字节不变。(这使得硬件难以有效地处理,这就是为什么AMD64没有遵循该模式的原因。)

最后,在某些情况下,C语言需要注意默认参数提升尽管这不是通常情况
实际上,正如RossRidge所指出的那样,该调用可能是在没有可见原型的情况下进行的。


你的反汇编有误,正如@Jester所指出的那样。
例如mov rdx, 3实际上是mov edx, 3,尽管两者具有相同的效果——即将3放入整个rdx中。
这是因为立即值3不需要符号扩展,MOV r32, imm32隐式清除寄存器的高32位。

2
已经为AL分配了一个临时寄存器,使其独立于AH。为什么要为它分配一个“新”的寄存器,AL并不是EAX的物理“子集”?为什么它必须与EAX分开? - Ábrahám Endre
2
为了使更多的指令并行执行,这被称为寄存器重命名,我提供的Agner Fog手册比维基百科文章更深入。Intel优化手册也涵盖了这个主题。 - Margaret Bloom
1
Agner Fog所引用的上述语录是针对Netburst(Pentium 4)的。在后来的微架构中,引用的5-6个时钟延迟要好得多。例如从Sandy Bridge和Ivy Bridge开始,Ivy Bridge仅在修改了高8位寄存器(AH、BH、CH、DH)的情况下插入一个额外的μop - Olsonist
1
是的,简单的答案与寄存器停顿无关(即使在 -O2-O3 下也很容易找到编译器读写部分寄存器的示例),而是因为 x86 和 x86-64 要求 在将小于 32 位的参数传递给函数时进行零扩展或符号扩展。无论原型是什么,甚至如果没有原型在作用域内(ABI 仍然适用,基于调用形状的一些“默认”函数签名)。有趣的是,高 32 位 可以 包含垃圾数据,但不包括第 8 到 32 位。 - BeeOnRope
1
顺便说一句,我之前的说法有点过于绝对了,那是在我做完剩下的研究之前写的。后来我发现这个规范并不像我想象中的那么强制,icc 并没有遵循它,而且关于正确的做法还存在一些争议。然而,对于 OP 的问题,关键在于 gcc 遵循了扩展到 32 位的规则。 - BeeOnRope
显示剩余8条评论

4
之前的三个答案都有不同的错误。
Margaret Bloom 的接受的答案暗示了部分寄存器停顿是问题所在。部分寄存器停顿确实存在,但不太可能与 GCC 在此处做出的决定有关。
如果 GCC 将 mov edx,3 替换为 mov dl,3,那么代码就是错误的,因为对字节寄存器的写入(与对双字寄存器的写入不同)不会清零寄存器的其余部分。参数在 rdx 中的类型为 size_t,大小为 64 位,因此被调用者将读取整个寄存器,其中位 8 到 63 包含垃圾值。 部分寄存器停顿纯粹是性能问题;如果代码运行得很快但是结果错误,那也没有意义。
可以通过在 mov dl,3 之前插入 xor edx,edx 来修复该错误。使用这种修复方法,就没有部分寄存器停顿,因为使用 xorsub 清零整个寄存器,然后写入低字节,在所有具有停顿问题的 CPU 上都有特殊情况。 因此,修复后仍然与部分寄存器停顿无关。
唯一情况下部分寄存器停顿才会变得相关,就是如果GCC恰好知道该寄存器为零,但它没有被特殊指令之一清零。例如,如果此系统调用之前有:
loop:
  ...
  dec edx
  jnz loop

然后GCC可以推断出,在它想要将3放入rdx的位置,rdx为零,mov dl,3是正确的。但通常情况下这是不好的,因为它可能会导致部分寄存器停顿。在这里,这并不重要,因为系统调用本来就很慢,但我认为GCC在其内部类型系统中没有“无需加速优化调用”的“慢函数”属性。
为什么GCC不会发出xor后跟一个字节移动,难道不是因为部分寄存器停顿吗?我不知道,但我可以猜测。
这只在初始化r0r3时节省空间,即使如此,它也只能节省一个字节。它增加了指令的数量,这有其自身的成本(指令解码器经常是瓶颈)。它还会破坏标志,不像标准的mov,这意味着它不能作为一种替代品。GCC必须跟踪一个单独的标志破坏寄存器初始化序列,在大多数情况下(15个可能的目标寄存器中的11个)都会明显不太高效。
如果你正在积极优化大小,可以使用push 3,然后是pop rdx,无论目标寄存器是什么,都可以节省2个字节,并且不会破坏标志。但它可能要慢得多,因为它写入内存并且与rsp存在虚假的读写依赖关系,而且空间节省似乎不值得。 (它还修改了red zone,因此也不能作为一种替代品。)
超级猫的回答说:
处理器核心通常包括执行多个32位或64位指令的逻辑,但可能不包括同时执行8位操作的逻辑。因此,在8088上尽可能使用8位操作是一种有用的优化,但在新型处理器上实际上可能会显著降低性能。
现代优化编译器实际上经常使用8位通用寄存器(GPRs)。 (它们相对较少使用16位GPR,但我认为这是因为16位量在现代代码中不常见。)8位和16位操作至少在大多数执行阶段与32位和64位操作一样快,有些更快。
我之前曾在这里写道:“据我所知,在所有32/64位的x86/x64处理器上,8位操作与32/64位操作一样快或更快。”但是我错了。相当多的超标量x86/x64处理器会将8位和16位的目标合并到完整寄存器中的每个写入操作中,这意味着仅写入指令(如mov)在目标为8/16位时存在虚假读取依赖关系,而32/64位则不存在。如果在每次移动之前不清除寄存器(或者在移动期间使用movzx之类的东西),虚假依赖链会使执行变慢。尽管最早的超标量处理器(Pentium Pro/II/III)没有这个问题,但现代化的优化编译器在我的经验中确实使用较小的寄存器。
BeeOnRope的答案说:
短期内,对于您的特定情况来说,原因是gcc在调用C ABI函数时总是对参数进行32位符号或零扩展。
但是,该函数本身没有比32位短的参数。文件描述符恰好为32位长,而size_t恰好为64位长。即使其中很多位通常为零,这也无关紧要。它们不是可变长度的整数,如果它们很小,就会在1字节中编码。只有当ABI中没有整数提升要求,并且实际参数类型为char或其他8位类型时,使用mov dl,3才是正确的,而rdx的其余部分可能不为零。

这个问题选择了一个糟糕的例子(其中至少需要32位寄存器来处理两个参数)。Margaret正在回答一个同样有趣的一般情况,即为什么GCC使用movzx eax,byte ptr [rdi]来实现char foo(char * p){return * p;}和类似的情况。或者char bar(){ return 1; } https://godbolt.org/z/4ca9cTG9j显示,在-Os(优化大小)下,GCC只写入AL而不是扩展为EAX。(而clang只写入AL,轻率地冒着部分寄存器错误依赖的风险;可以假定调用者不会读取EAX。) - Peter Cordes
1
关于3字节的push/pop:在现代CPU上,"堆栈引擎"通过前端处理RSP更新的堆栈指令,在发出/重命名期间处理对rsp的读写依赖关系,具有零延迟。 (但是后来在Intel CPU上显式使用RSP需要一个堆栈同步uop)。 clang -Oz优化大小而不关心速度,并且实际上使用push/pop。 - Peter Cordes
如果您需要多个附近的常量,3字节的lea edx,[rax + 1]或其他东西可能很好。x86 / x64机器码高尔夫技巧。关于xor-zero和mov dl,3的想法很有趣,因为它只占用4个字节。您说得对,这两个指令和2个uop在前端可能是瓶颈,无论是解码还是发出。并且在uop缓存中占用更多的空间。在ROB中也占用更多的空间,限制了CPU寻找ILP以进行乱序执行的能力。 - Peter Cordes
1
8位操作在每个32/64位x86/x64处理器上都与32/64位操作一样快或更快。对于ALU部分,我认为这是正确的。但是使用8位操作数大小意味着写入部分寄存器,这意味着某些CPU上存在错误依赖性。这可能会使mov al,clmov eax,ecx在吞吐量和延迟方面都变慢(mov消除仅适用于32位和64位mov,或者在Intel上,对于movzx 8->32位)。例如,IvyBridge可以在每个时钟周期内执行4个32位mov(不需要后端ALU端口),但即使您使用不同的寄存器,每个时钟周期内也只能执行3个8位mov。 - Peter Cordes
1
对于像add这样的2输入操作,这不是一个问题,因为您已经需要读取修改目标,因此输出依赖关系不是错误的。但是,像xor-zeroing这样的东西在8位寄存器上速度较慢,因为它们无法被消除。请参见Haswell / Skylake上的部分寄存器如何执行?写入AL似乎对RAX有误依赖性,AH不一致。还有GCC bug https://gcc.gnu.org/bugzilla/show_bug.cgi?id=15533(特别是我在末尾的评论 - 自从4.4以来,GCC会导致部分寄存器停顿,使用xor al,al / read EAX)。 - Peter Cordes
显示剩余4条评论

-1
在像原始IBM PC这样的设备上,如果已知AH包含0并且需要将AX加载为0x34之类的值,则使用“MOV AL,34h”通常需要8个周期,而不是“MOV AX,0034h”所需的12个周期 - 这是一个相当大的速度提升(如果预取,任何指令都可以在2个周期内执行,但实际上8088花费大部分时间等待指令以每字节四个周期的代价被获取)。然而,在今天的通用计算机中使用的处理器中,获取代码所需的时间通常不是整体执行速度的重要因素,代码大小通常也不是特别关注的问题。
此外,处理器供应商试图最大化人们可能运行的代码类型的性能,并且现在不太可能经常使用8位载入指令,而是更多地使用32位载入指令。处理器核心通常包括逻辑来同时执行多个32位或64位指令,但可能不包括同时执行8位操作和其他任何操作的逻辑。因此,虽然在8088上尽可能使用8位操作是一种有用的优化,但实际上它可能会对新型处理器产生显著的性能损失。

但可能不包括同时执行8位操作的逻辑。我不知道任何x86 CPU有这种情况。对于现代编译器来说,真正的问题是写入部分寄存器,各种微架构处理方式不同。(现在主要通过即时合并来处理,因此mov al,whatever对RAX的旧值具有虚假依赖性。) - Peter Cordes
1
@PeterCordes: 可能我表述不当,因为错误依赖并不一定排除与无关寄存器的同时操作,但它仍会限制处理器重叠本应能够重叠的操作的能力。我的主要观点是,如果已知RAX的上半部分为零,则MOV AL,12h比MOV EAX,12h更小的事实,并不像错误依赖问题使其变慢那么多可能会提高速度。 - supercat

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