[英]Why doesn't GCC use partial registers?
在 linux 上反汇编write(1,"hi",3)
,使用gcc -s -nostdlib -nostartfiles -O3
:
ba03000000 mov edx, 3 ; thanks for the correction jester!
bf01000000 mov edi, 1
31c0 xor eax, eax
e9d8ffffff jmp loc.imp.write
我不参与编译器开发,但由于移动到这些寄存器中的每个值都是常量并且在编译时已知,我很好奇为什么 gcc 不使用dl
、 dil
和al
来代替。 也许有人会说,此功能不会让任何性能上的差异,但有一个在之间的可执行文件的大小有很大的区别mov $1, %rax => b801000000
和mov $1, %al => b001
当我们谈论成千上万的寄存器访问一个程序。 不仅体积小是软件优雅的一部分,它确实对性能有影响。
有人可以解释为什么“海湾合作委员会决定”这无关紧要?
是的,GCC 通常避免写入部分寄存器,除非优化大小 ( -Os
) 而不是纯粹的速度 ( -O3
)。 有些情况需要至少写入 32 位寄存器以确保正确性,因此更好的示例如下:
char foo(char *p) { return *p; }
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 处理器的性能下降,因为它们在写入时被重命名为与整个对应物不同的物理寄存器。 (有关启用乱序执行的寄存器重命名的更多信息,请参阅此问答)。
但是当一条指令读取整个寄存器时,CPU 必须检测这样一个事实,即它在单个物理寄存器中没有可用的正确架构寄存器值。 (这发生在问题/重命名阶段,因为 CPU 准备将 uop 发送到无序调度程序中。)
它被称为部分寄存器停顿。 Agner Fog 的微架构手册很好地解释了它:
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
的写入退出,然后才能将来自AL
的值与EAX
其余部分的值组合。
不同 CPU 中的行为:
部分寄存器永远不会被重命名。 写入部分寄存器合并到完整寄存器,使写入依赖于完整寄存器的旧值作为输入。
如果没有部分寄存器重命名,如果您从未读取完整寄存器,则写入的输入依赖项是错误依赖项。 这限制了指令级并行性,因为将 8 位或 16 位寄存器重用于其他用途实际上并不独立于 CPU 的观点(16 位代码可以访问 32 位寄存器,因此它必须在高一半)。 而且,它使 AL 和 AH 不独立。 当英特尔设计 P6 系列(1993 年发布的 PPro)时,16 位代码仍然很常见,因此部分寄存器重命名是使现有机器代码运行得更快的重要功能。 (实际上,许多二进制文件不会为新 CPU 重新编译。)
这就是编译器大多避免编写部分寄存器的原因。 他们尽可能使用movzx
/ movsx
将窄值零或符号扩展到完整寄存器,以避免部分寄存器错误依赖 (AMD) 或停顿(英特尔 P6 系列)。 因此,大多数现代机器代码并没有从部分寄存器重命名中获益多少,这就是为什么最近的 Intel CPU 正在简化其部分寄存器重命名逻辑。
正如@BeeOnRope 的回答指出的那样,编译器仍然读取部分寄存器,因为这不是问题。 (阅读 AH/BH/CH/DH 可以在 Haswell/Skylake 上增加额外的延迟周期,但请参阅有关 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 位。
事实上, gcc 经常使用部分寄存器。 如果您查看生成的代码,您会发现很多使用部分寄存器的情况。
对于您的特定情况,简短的回答是因为 gcc 在调用 C ABI 函数时总是将参数符号或零扩展到 32 位。
gcc
和clang
采用的事实上的SysV x86 和 x86-64 ABI 要求小于 32 位的参数为零或符号扩展到 32 位。 有趣的是,它们不需要一直扩展到 64 位。
因此,对于 64 位平台 SysV ABI 平台上的类似以下功能:
void foo(short s) {
...
}
...参数s
在rdi
传递, s 的位如下(但请参阅下面关于icc
警告):
bits 0-31: SSSSSSSS SSSSSSSS SPPPPPPP PPPPPPPP
bits 32-63: XXXXXXXX XXXXXXXX XXXXXXXX XXXXXXXX
where:
P: the bottom 15 bits of the value of `s`
S: the sign bit of `s` (extended into bits 16-31)
X: arbitrary garbage
foo
的代码可以依赖于S
和P
位,但不依赖于X
位, X
位可以是任何东西。
类似地,对于foo_unsigned(unsigned short u)
,第 16-31 位的0
,但否则将相同。
请注意,我说的是事实上- 因为它实际上并没有真正记录如何处理较小的返回类型,但您可以在此处查看Peter 的答案以了解详细信息。 我也在这里问了一个相关的问题。
经过一些进一步的测试,我得出的结论是icc
实际上违反了这个事实上的标准。 gcc
和clang
似乎坚持它,但gcc
只是以一种保守的方式:当调用一个函数时,它会将参数零/符号扩展到 32 位,但在它的函数实现中不依赖于调用者这样做. clang
实现了依赖于调用者将参数扩展到 32 位的函数。 因此,实际上,如果clang
和icc
任何参数小于int
即使对于普通的 C 函数,它们也是互不兼容的。
所有三个较早的答案在不同方面都是错误的。
Margaret Bloom 接受的答案暗示部分寄存器停顿是罪魁祸首。 部分寄存器停顿是真实存在的,但不太可能与 GCC 的决定相关。
如果 GCC 将mov edx,3
替换为mov dl,3
,那么代码就是错误的,因为写入字节寄存器(与写入 dword 寄存器不同)不会将寄存器的其余部分归零。 rdx
的参数是size_t
类型,它是 64 位,所以被调用者将读取完整的寄存器,其中将包含第 8 到 63 位的垃圾。部分寄存器停顿纯粹是一个性能问题; 如果代码出错,代码运行的速度有多快都没有关系。
该错误可以通过在mov dl,3
之前插入xor edx,edx
来修复。 有了这个修复,就没有部分寄存器停顿,因为在所有有停顿问题的 CPU 中,用xor
或sub
一个完整的寄存器清零然后写入低字节是特殊情况。 所以部分寄存器停顿仍然与修复无关。
部分寄存器停顿会变得相关的唯一情况是,如果 GCC 碰巧知道寄存器为零,但它没有被特殊指令之一清零。 例如,如果此系统调用之前是
loop:
...
dec edx
jnz loop
然后 GCC 可以推断出rdx
在它想要放入 3 的点处为零,并且mov dl,3
将是正确的 - 但通常这将是一个坏主意,因为它可能导致部分寄存器停顿。 (在这里,这无关紧要,因为系统调用无论如何都很慢,但我不认为 GCC 在其内部类型系统中具有“无需优化调用速度的慢速函数”属性。)
如果不是因为部分寄存器停顿,为什么 GCC 不发出xor
后跟字节移动? 我不知道,但我可以推测。
它只在初始化r0
到r3
时节省空间,即使这样它也只节省一个字节。 它增加了指令的数量,这有其自身的成本(指令解码器通常是瓶颈)。 与标准mov
不同,它还破坏了标志,这意味着它不是直接替代品。 GCC 必须跟踪一个单独的标志破坏寄存器初始化序列,在大多数情况下(可能目标寄存器的 11/15)显然效率较低。
如果您正在积极优化大小,您可以执行push 3
后跟pop rdx
,无论目标寄存器如何,都可以节省 2 个字节,并且不会破坏标志。 但它可能要慢得多,因为它写入内存并且对rsp
具有错误的读写依赖性,并且节省的空间似乎不太值得。 (它还修改了red zone ,因此它也不是替代品。)
超级猫的回答说
处理器内核通常包括同时执行多个 32 位或 64 位指令的逻辑,但可能不包括同时执行 8 位操作和其他指令的逻辑。 因此,虽然尽可能在 8088 上使用 8 位操作是对 8088 的有用优化,但它实际上可能会显着降低新处理器的性能。
现代优化编译器实际上大量使用 8 位 GPR。 (他们相对很少使用 16 位 GPR,但我认为这是因为 16 位数量在现代代码中并不常见。)8 位和 16 位操作至少与 32 位和 64 位操作一样快执行阶段,有些更快。
我之前在这里写道:“据我所知,8 位操作与绝对所有 32/64 位 x86/x64 处理器上的 32/64 位操作一样快,甚至更快。” 但是我错了。 相当多的超标量 x86/x64 处理器在每次写入时将 8 位和 16 位目标合并到完整寄存器中,这意味着当目标是 8/16 位时,像mov
这样的只写指令具有错误的读取依赖性当它是 32/64 位时存在。 如果您没有在每次移动之前(或在使用movzx
东西期间)清除寄存器,则错误的依赖链会减慢执行速度。 即使最早的超标量处理器 (Pentium Pro/II/III) 没有,较新的处理器也有这个问题。 尽管如此,根据我的经验,现代优化编译器确实使用较小的寄存器。
BeeOnRope 的回答说
对于您的特定情况,简短的回答是因为 gcc 在调用 C ABI 函数时总是将参数符号或零扩展到 32 位。
但是这个函数首先没有短于 32 位的参数。 文件描述符正好是 32 位长,而size_t
正好是 64 位长。 这些位中的许多位通常为零并不重要。 如果它们很小,它们不是以 1 个字节编码的可变长度整数。 如果 ABI 中没有整数提升要求并且实际参数类型是char
或其他一些 8 位类型mov dl,3
则只对参数使用mov dl,3
,而rdx
的其余部分可能不为零,作为参数。
在类似于原始 IBM PC 的设备上,如果已知 AH 包含 0 并且需要使用 0x34 之类的值加载 AX,则使用“MOV AL,34h”通常需要 8 个周期,而不是“MOV AX”所需的 12 个周期, 0034h”——一个相当大的速度改进(如果预取,任何一条指令都可以在 2 个周期内执行,但实际上 8088 花费大部分时间等待指令被提取,代价是每字节四个周期)。 然而,在当今通用计算机中使用的处理器上,获取代码所需的时间通常不是整体执行速度的重要因素,并且代码大小通常不是特别关注的问题。
此外,处理器供应商试图最大限度地提高人们可能运行的代码类型的性能,而如今 8 位加载指令的使用频率可能不如 32 位加载指令。 处理器内核通常包括同时执行多个 32 位或 64 位指令的逻辑,但可能不包括同时执行 8 位操作和其他指令的逻辑。 因此,虽然尽可能在 8088 上使用 8 位操作是对 8088 的有用优化,但它实际上可能会显着降低新处理器的性能。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.