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

bxgwgixi  于 2022-11-13  发布在  其他
关注(0)|答案(3)|浏览(223)

在Linux上反汇编使用gcc -s -nostdlib -nostartfiles -O3构建的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

我对编译器开发不感兴趣,但既然移入这些寄存器的每个值都是常量,和al。有些人可能会认为这个特性不会对性能产生任何影响,但是'当我们谈论一个程序中成千上万的寄存器访问时,mov $1, %rax => b801000000mov $1, %al => b001在可执行文件大小上有很大的区别。小的大小不仅是软件优雅的一部分,而且对性能也有影响。
有人能解释一下为什么“GCC决定”这无关紧要吗?

xzv2uavs

xzv2uavs1#

是的,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准备将微操作发送到无序调度器)。
这被称为 * 部分寄存器暂停 *。Agner Fog's microarchitecture manual对此进行了很好的解释:

6.8部分寄存器暂停(PPro/PII/PIII与早期得奔腾-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中的行为

如果没有部分寄存器重命名,则如果您从未读取完整寄存器,则写入的输入依赖性为 false 依赖性。这限制了指令级并行性,因为从CPU的Angular 来看,将8位或16位寄存器重新用于其他操作实际上并不独立(16位代码可以访问32位寄存器,因此必须在上半部分保持正确的值)。此外,这使得AL和AH不是独立的。(PPro于1993年发布),16位代码仍然很常见,因此部分寄存器重命名是一个重要的功能,使现有的机器代码运行得更快。(实际上,许多二进制文件不会针对新的CPU进行重新编译。)
这就是为什么编译器大多避免 * 写 * 部分寄存器的原因。只要有可能,它们就使用movzx/movsx将窄值零扩展或符号扩展到一个完整的寄存器,以避免部分寄存器的假依赖(AMD)或失速因此,大多数现代机器代码并没有从部分寄存器重命名中获得太多好处,这就是为什么最近的Intel CPU正在简化其部分寄存器重命名逻辑。
正如@BeeOnRope的回答所指出的,编译器仍然 * 读取 * 部分寄存器,因为这不是问题。(阅读AH/BH/CH/DH可能会在Haswell/Skylake上增加一个额外的延迟周期,不过,请参见前面关于Sandybridge系列最新成员的部分寄存器的链接。)

另请注意write采用的参数对于x86-64典型配置的GCC而言,需要完整的32位和64位寄存器,因此无法简单地将其汇编到mov dl, 3中。其大小由数据的 type 决定,而不是由数据的 value 决定。

只有32位寄存器写操作隐式零扩展到完整的64位;写入8位和16位部分寄存器时,高位字节保持不变。(这使得硬件很难有效处理,which is why AMD64 didn't follow that pattern。)
最后,在某些上下文中,C需要注意default argument promotions,尽管情况并非如此。
事实上,正如RossRidge指出的那样,这个电话很可能是在没有可见原型的情况下发出的。
正如“杰斯特”所指出的,你的拆解是误导性的。
例如,mov rdx, 3实际上是mov edx, 3,尽管两者具有相同的效果--即在整个rdx中放入3。
这是因为立即数3不需要符号扩展,并且MOV r32, imm32隐式清除寄存器的高32位。

cnjp1d6j

cnjp1d6j2#

前面的三个答案在不同方面都是错误的。
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就可以推断出rdx在它要放入3的位置为零,mov dl,3是正确的--但这通常不是一个好主意,因为它可能会导致部分寄存器暂停。(在这里,这并不重要,因为系统调用非常慢,但我认为GCC在其内部类型系统中没有“不需要对调用进行速度优化慢函数”属性。)
如果不是因为部分寄存器暂停,为什么GCC不发出xor,然后进行字节移动?我不知道,但我可以推测。
它只在初始化r0r3时节省空间,即使这样也只节省一个字节。(指令解码器通常是一个瓶颈)。与标准的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位操作一样快,有些更快。
我以前在这里写过“据我所知,在所有32/64位x86/x64处理器上,8位操作都和32/64位操作一样快,甚至更快。”但我错了。相当多的超标量x86/x64处理器在每次写操作时都会将8位和16位目标合并到整个寄存器中,这意味着,当目标为8/16位时,像mov这样的只写指令具有假读取相关性,而当目标为32/64位时,这种相关性就不存在了。如果在每次移动之前不清除寄存器,假相关性链会降低执行速度(或者在使用movzx时)。较新的处理器有这个问题,即使最早的超标量处理器(奔腾Pro/II/III)没有这个问题。尽管如此,根据我的经验,现代优化编译器确实使用较小的寄存器。
蜜蜂系绳的回答是
简短的回答 * 对于您的特定情况 *,是因为gcc在调用CABI函数时总是将参数符号或零扩展到32位。
但是这个函数没有比32位更短的参数。文件描述符的长度正好是32位,而size_t的长度正好是64位。这些位中的许多位通常是零并不重要。如果它们很小,它们就不是用1字节编码的变长整数。只有使用mov dl,3才是正确的。rdx的其余部分可能为非零,对于参数,如果在ABI * 和 * 中没有整数提升要求,则实际参数类型为char或某个其它8位类型。

8aqjt8rx

8aqjt8rx3#

在像最初的IBMPC这样的计算机上,如果已知AH包含0,并且必须用像0x 34这样的值加载AX,则使用“MOVA 1,34 h”通常需要8个周期,而不是“MOVAX 1,0034 h”所需的12个周期--这是一个相当大的速度改进(如果被预取,则任一指令可在2个循环中执行,但实际上8088花费其大部分时间以每字节四个周期的代价等待要提取的指令)。然而,在今天的通用计算机中使用的处理器上,获取代码所需的时间通常不是总体执行速度中的重要因素,并且代码大小通常不是特别关注的问题。
此外,处理器供应商试图最大限度地提高人们可能运行的代码类型的性能,8位加载指令现在不太可能像32位加载指令那样经常使用。处理器内核通常包含同时执行多个32位或64位指令的逻辑,但可能不包含同时执行8位操作和其他操作的逻辑。因此,虽然在8088上尽可能使用8位操作是对8088的一种有用的优化,但它实际上可能是较新处理器的一个显著性能消耗。

相关问题