【问题标题】:Why doesn't GCC use partial registers?为什么 GCC 不使用部分寄存器?
【发布时间】:2017-05-25 05:14:28
【问题描述】:

在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 不使用 dldilal。 有些人可能会争辩说,这个特性不会对性能产生任何影响,但是当我们谈论程序中的数千个寄存器访问时,mov $1, %rax => b801000000mov $1, %al => b001 之间的可执行文件大小存在很大差异。体积小不仅是软件优雅的一部分,而且对性能也有影响。

有人可以解释为什么“GCC 决定”它无关紧要吗?

【问题讨论】:

  • 如果你只加载部分寄存器,其余的将包含随机垃圾,被调用者将使用整个寄存器(根据数据类型而定)。它还会导致部分寄存器停顿。请注意,写入低 32 位将自动将高 32 位归零。 PS:你反汇编错了,所有这些指令实际上都是32位的(没有rex前缀)。
  • 它与GCC没有任何关系,每个C编译器都需要这样做。谷歌“C 整数提升”以了解更多信息。
  • @HansPassant 整数提升是否适用于原型函数的函数参数?据我所知,只有default argument promotions 适用于函数调用。引用:“整数提升仅适用于:作为通常算术转换的一部分,适用于某些参数表达式 [ndr:上面的默认 arg 提升],适用于一元 +、- 和 ~ 运算符的操作数,以及移位运算符的两个操作数,由它们各自的子条款指定"
  • @MargaretBloom 传递给参数的值通过赋值转换为参数类型。请参见第 7 段。无论哪种方式,这意味着常量31,已经是signed int,仍然是signed int
  • @MargaretBloom 对于它的价值,xor eax, eax 表明调用是在没有原型的范围内进行的。它不知道函数是否为可变参数,因此将 AL 设置为 0 表示在 SSE 寄存器中传递的参数为 0。您的奇怪案例实际上是一个 ABI 问题,只要双方同意,“好像”规则就允许任一实现。

标签: assembly gcc x86 x86-64 cpu-architecture


【解决方案1】:

是的,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 microarchitecture manual 解释的很好:

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 中的行为

部分寄存器永远不会被重命名。写入部分寄存器会合并到完整寄存器中,使写入依赖于完整寄存器的旧值作为输入。

如果没有部分寄存器重命名,如果您从未读取完整寄存器,则写入的输入依赖项是 false 依赖项。这限制了指令级并行性,因为从 CPU 的角度来看,将 8 位或 16 位寄存器重用于其他内容实际上并不独立(16 位代码可以访问 32 位寄存器,因此它必须在高位保持正确的值)一半)。而且,它使 AL 和 AH 不独立。当 Intel 设计 P6 系列(PPro 于 1993 年发布)时,16 位代码仍然很常见,因此部分寄存器重命名是使现有机器代码运行得更快的重要功能。 (实际上,许多二进制文件不会为新 CPU 重新编译。)

这就是编译器大多避免编写部分寄存器的原因。他们尽可能使用movzx / movsx 将窄值零或符号扩展到完整寄存器,以避免部分寄存器错误依赖(AMD)或停顿(Intel P6 系列)。因此,大多数现代机器代码并不能从部分寄存器重命名中受益,这就是为什么最近的 Intel CPU 正在简化其部分寄存器重命名逻辑。

As @BeeOnRope's answer points out,编译器仍然读取部分寄存器,因为这不是问题。 (不过,读取 AH/BH/CH/DH 会在 Haswell/Skylake 上增加一个额外的延迟周期,请参阅前面关于 Sandybridge 家族最近成员的部分寄存器的链接。)


另请注意 write 接受的参数对于 x86-64 通常配置的 GCC 需要完整的 32 位和 64 位寄存器,因此不能简单地将其组装成 @987654348 @。大小由数据的类型决定,而不是数据的

只有 32 位寄存器会隐式零扩展写入完整的 64 位;写入 8 位和 16 位部分寄存器保持高字节不变。 (这使得硬件难以有效处理,which is why AMD64 didn't follow that pattern。)

最后,在某些情况下,C 需要注意 default argument promotions虽然不是这样
实际上,正如RossRidge 指出的那样,调用可能是在没有可见原型的情况下进行的。


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

【讨论】:

  • “已为 AL 分配了一个临时寄存器,使其独立于 AH”。为什么要为其分配一个“新”寄存器,AL 在物理上不是 EAX 的“子集”?为什么一定要和EAX分开?
  • 让更多指令并行执行。它叫做register renaming,我链接的Agner Fog 手册比维基百科的文章有更深入的材料。英特尔优化手册也涵盖了这个主题。
  • Agner Fog 的上述引用是针对 Netburst (Pentium 4)。引用的 5 - 6 个时钟的延迟在以后的微架构上要好得多。以 Sandy Bridge 和 Ivy Bridge 为例,Ivy Bridge 仅在高 8 位寄存器(AH、BH、CH、DH)已被修改的情况下插入额外的 μop
  • 是的,简单的答案与寄存器停顿无关(即使在 -O2-O3 也很容易找到编译器读取和写入部分寄存器的示例),但 x86 x86-64 要求将小于 32 位的参数传递给函数时为零或符号扩展。无论原型是什么,即使范围内没有原型(ABI 仍然适用,具有基于调用形状的一些“默认”函数签名)。有趣的是,高 32 位 can 包含垃圾,但第 8 到 32 位不包含。
  • 顺便说一句,我上面的说法有点太强了,我在做剩下的研究之前写了它,发现它比我想象的要弱一点,@987654355 @不遵守和一些关于什么是正确的事情的辩论。不过,OP 问题的关键是 gcc 确实遵循扩展至 32 位的规则。
【解决方案2】:

在类似于原始 IBM PC 的设备上,如果已知 AH 包含 0,并且必须使用 0x34 之类的值加载 AX,则使用“MOV AL,34h”通常需要 8 个周期,而不是“”所需的 12 个周期MOV AX,0034h"——一个相当大的速度改进(如果预取指令可以在 2 个周期内执行,但实际上 8088 大部分时间都在等待以每个字节 4 个周期的成本获取指令) .然而,在当今通用计算机中使用的处理器上,获取代码所需的时间通常不是影响整体执行速度的重要因素,代码大小通常不是一个特别关注的问题。

此外,处理器供应商试图最大限度地提高人们可能运行的各种代码的性能,而如今 8 位加载指令的使用频率几乎不像 32 位加载指令那样频繁。处理器内核通常包括同时执行多个 32 位或 64 位指令的逻辑,但可能不包括同时执行 8 位操作和其他任何操作的逻辑。因此,虽然在可能的情况下在 8088 上使用 8 位运算是对 8088 的有用优化,但它实际上可能会显着消耗较新处理器的性能。

【讨论】:

  • 但可能不包括与其他任何东西同时执行 8 位操作的逻辑我不知道有任何 x86 CPU 是这种情况。现代编译器的真正问题是编写部分寄存器,各种微架构处理不同的方式。 (这几天主要是通过现场合并,所以mov al, whatever对RAX的旧值有错误的依赖。)
  • @PeterCordes:也许我的措辞不好,因为错误的依赖不一定会排除与不相关的寄存器的同时操作,但它仍然限制了处理器重叠操作的能力应该能够重叠。我的主要观点是,如果已知 RAX 的上半部分为零,则 MOV AL,12h 小于 MOV EAX,12h 的事实不太可能像错误依赖问题那样提高速度它更慢。
【解决方案3】:

前面的三个答案都有不同的错误。

Margaret Bloom 接受的回答暗示部分寄存器停顿是罪魁祸首。部分寄存器停顿是真实的,但不太可能与 GCC 的决定相关。

如果 GCC 将 mov edx,3 替换为 mov dl,3,那么代码就是错误的,因为写入字节寄存器(与写入 dword 寄存器不同)不会将寄存器的其余部分归零。 rdx 中的参数是size_t 类型,即64 位,因此被调用者将读取完整的寄存器,其中将包含8 到63 位的垃圾。部分寄存器停顿纯粹是性能问题;如果错误,代码运行速度有多快并不重要。

可以通过在mov dl,3 之前插入xor edx,edx 来修复该错误。通过该修复,没有部分寄存器停顿,因为在所有有停顿问题的 CPU 中,使用 xorsub 将完整寄存器归零然后写入低字节是特殊情况。所以部分寄存器停顿仍然与修复无关。

唯一与部分寄存器停顿相关的情况是,如果 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 位操作一样快执行阶段,有些更快。

我之前在这里写道:“据我所知,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 中没有整数提升要求,则仅使用 mov dl,3rdx 的其余部分可能为非零是正确的,并且实际参数类型为 @987654350 @ 或其他一些 8 位类型。

【讨论】:

  • 这个问题选择了一个不好的例子(两个 args 至少需要 32 位寄存器)。 Margaret 正在回答关于为什么 GCC 使用 movzx eax, byte ptr [rdi] 来实现 char foo(char *p) { return *p; } 和类似案例的同样有趣的一般案例。或者char bar(){ return 1; }godbolt.org/z/4ca9cTG9j 表明在 -Os(优化大小)处,GCC 确实 只写 AL 而不是扩展到 EAX。 (而且那个 clang 只是写 AL,冒着部分注册错误依赖的风险;可以假设调用者不读取 EAX。)
  • Re:3 字节推送/弹出:在现代 CPU 上,在发出/重命名期间,通过前端堆栈指令处理 RSP 更新的“stack engine”处理读取-以零延迟写入对 rsp 的依赖。 (但后来对 RSP 的显式使用将需要 Intel CPU 上的堆栈同步 uop)。 clang -Oz 优化了大小而不关心速度,实际上确实使用了 push/pop。
  • 如果您需要多个附近的常量,3 字节 lea edx, [rax+1] 或 w/e 会很好。 Tips for golfing in x86/x64 machine code。不过,关于 xor-zero 和 mov dl, 3 的有趣想法是 4 个字节。你是对的,前端的 2 条指令和 2 条微指令可能是一个瓶颈,无论是解码还是问题。并在 uop 缓存中占用更多空间。还会在 ROB 中占用更多空间,从而限制 CPU 可以看到多远才能找到 ILP 以进行无序执行。
  • 在所有 32/64 位 x86/x64 处理器上,8 位操作与 32/64 位操作一样快,甚至更快 - 对于ALU 部分是的,我认为这是真的。但是使用 8 位操作数大小意味着 写入 部分寄存器,这意味着对某些 CPU 的错误依赖。这可以使mov al, cl 在吞吐量甚至延迟方面都比mov eax, ecx 慢(mov 消除仅适用于 32 位和 64 位 mov,或英特尔,在 movzx 8->32 位上)。例如IvyBridge 可以做 4/clock 32-bit mov(不需要后端 ALU 端口),但即使你使用不同的 regs 也只能做 3/clock 8-bit mov。
  • 对于像 add 这样的 2 输入操作,这不是一个问题,您已经需要读取修改目标,因此输出依赖关系不为假。但是,在 8 位寄存器上,诸如异或归零之类的东西会比较慢,因为它们无法消除。见How exactly do partial registers on Haswell/Skylake perform? Writing AL seems to have a false dependency on RAX, and AH is inconsistent。还有 GCC 错误 gcc.gnu.org/bugzilla/show_bug.cgi?id=15533(尤其是我最后的评论 - 自 4.4 以来的 GCC 导致xor al,al 的部分注册停止@/阅读 EAX)
猜你喜欢
  • 2015-10-12
  • 1970-01-01
  • 2020-09-14
  • 2016-08-06
  • 1970-01-01
  • 2018-01-16
  • 1970-01-01
  • 1970-01-01
  • 2021-10-13
相关资源
最近更新 更多