在使用write(1,"hi",3)
构建的linux上反汇编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
和{ {1}}而是。
有些人可能会争辩说,这个功能在性能上没有任何差别,但当我们讨论程序中数千个寄存器访问时,al
和mov $1, %rax => b801000000
之间的可执行文件大小有很大差异。如果软件的优雅部分不仅体积小,它确实会对性能产生影响。
有人可以解释为什么“海湾合作委员会决定”这无关紧要吗?
答案 0 :(得分:21)
部分寄存器会对许多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中的行为:
所有其他x86 CPU :Intel Pentium4,Atom / Silvermont / Knight's Landing。所有AMD(以及Via等):
永远不会重命名部分寄存器。写入部分寄存器会合并到完整寄存器中,使写入依赖于完整寄存器的旧值作为输入。
如果您从未读取完整寄存器,则在没有部分寄存器重命名的情况下,写入的输入依赖性是 false 依赖关系。这限制了指令级并行性,因为将8位或16位寄存器重新用于其他内容实际上并不独立于CPU的观点(16位代码可以访问32位寄存器,因此它必须在上层保持正确的值半)。而且,它使AL和AH不独立。当英特尔设计P6系列(1993年发布的PPro)时,16位代码仍然很常见,因此部分寄存器重命名是使现有机器代码运行更快的重要特性。 (实际上,许多二进制文件不会为新CPU重新编译。)
这就是为什么编译器主要避免写部分寄存器。他们尽可能使用movzx
/ movsx
将窄值零或符号扩展为完整寄存器,以避免部分寄存器错误依赖(AMD)或停顿(Intel P6系列)。因此,大多数现代机器代码并没有从部分寄存器重命名中获益,这就是为什么最近的英特尔CPU正在简化其部分寄存器重命名逻辑。
As @BeeOnRope's answer points out,编译器仍读取部分寄存器,因为这不是问题。 (阅读AH / BH / CH / DH可以在Haswell / Skylake上增加额外的延迟周期,但是,请参阅早期关于Sandybridge家族最近成员的部分注册的链接。)
另请注意 write
接受的参数是,对于x86-64通常配置的GCC,需要整个32位和64位寄存器,因此不能简单地组装成mov dl, 3
。大小由数据的类型决定,而不是数据的值。
最后,在某些情况下,C要default argument promotions注意,虽然情况并非如此。
实际上,正如RossRidge指出的那样,调用可能是在没有可见原型的情况下进行的。
正如@Jester指出的,你的反汇编是误导性的
例如,mov rdx, 3
实际上是mov edx, 3
,虽然两者具有相同的效果 - 即将3放在整个rdx
中。
这是正确的,因为立即值3不需要符号扩展,MOV r32, imm32
隐式清除寄存器的高32位。
答案 1 :(得分:2)
事实上, gcc经常使用部分寄存器。如果您查看生成的代码,您会发现许多使用部分寄存器的情况。
您的特定情况的简短答案是,因为在调用C ABI函数时,gcc始终将参数符号或零扩展为32位。
gcc
和clang
采用的事实 SysV x86和x86-64 ABI要求小于32位的参数为零或符号扩展为32 -Bits。有趣的是,它们不需要一直扩展到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
位,可能是任何位。
同样地,对于foo_unsigned(unsigned short u)
,您在第16-31位中有0
,但它会相同。
请注意,我说 defacto - 因为实际上并没有真正记录如何处理较小的返回类型,但您可以在此处查看Peter's answer以获取详细信息。我还问了一个相关问题here。
经过一些进一步的测试,我得出结论icc
实际上打破了这个事实上的标准。 gcc
和clang
似乎遵守它,但gcc
只是以保守的方式:当调用一个函数时,它会将零/符号扩展参数32位,但在其函数实现中,调用者不会依赖。 clang
实现依赖于调用者将参数扩展到32位的函数。因此,实际上clang
和icc
即使对于纯C函数也是互不兼容的,如果它们的参数小于int
。
答案 2 :(得分:0)
在原始IBM PC之类的东西上,如果已知AH包含0并且需要加载具有类似0x34的值的AX,则使用“MOV AL,34h”通常需要8个周期而不是“12”所需的12个周期。 MOV AX,0034h“ - 相当大的速度改进(如果预取,则任一指令可以在2个周期内执行,但实际上8088花费大部分时间等待以每字节4个周期的成本获取指令) 。然而,在当今通用计算机中使用的处理器上,获取代码所需的时间通常不是整体执行速度的重要因素,并且代码大小通常不是特别关注的问题。
此外,处理器供应商试图最大限度地提高人们可能运行的代码的性能,并且8位加载指令不太可能像32位加载指令那样经常使用。处理器内核通常包括同时执行多个32位或64位指令的逻辑,但可能不包括与其他任何内容同时执行8位操作的逻辑。因此,尽管在8088上使用8位操作虽然可以在8088上进行有用的优化,但它实际上可以显着降低新处理器的性能。