为啥 32 位寄存器上的 x86-64 指令会将完整 64 位寄存器的上部归零?
Posted
技术标签:
【中文标题】为啥 32 位寄存器上的 x86-64 指令会将完整 64 位寄存器的上部归零?【英文标题】:Why do x86-64 instructions on 32-bit registers zero the upper part of the full 64-bit register?为什么 32 位寄存器上的 x86-64 指令会将完整 64 位寄存器的上部归零? 【发布时间】:2012-06-25 23:43:27 【问题描述】:在x86-64 Tour of Intel Manuals,我读到了
也许最令人惊讶的事实是,像
MOV EAX, EBX
这样的指令会自动将RAX
寄存器的高32 位归零。
同一来源引用的英特尔文档(3.4.1.1 General-Purpose Registers in 64-Bit Mode in manual Basic Architecture)告诉我们:
64 位操作数在目标通用寄存器中生成 64 位结果。 32 位操作数生成 32 位结果,在目标通用寄存器中零扩展为 64 位结果。 8 位和 16 位操作数生成 8 位或 16 位结果。目标通用寄存器的高 56 位或 48 位(分别)不会被操作修改。如果 8 位或 16 位运算的结果用于 64 位地址计算,请显式将寄存器符号扩展为完整的 64 位。
在x86-32和x86-64汇编中,16位指令如
mov ax, bx
不要表现出这种eax的高位字归零的“奇怪”行为。
因此:引入这种行为的原因是什么?乍一看似乎不合逻辑(但原因可能是我习惯了 x86-32 汇编的怪癖)。
【问题讨论】:
如果您在 Google 上搜索“部分寄存器停顿”,您会发现很多关于他们(几乎可以肯定)试图避免的问题的信息。 ***.com/questions/25455447/… 不仅仅是“大多数”。 AFAIK,带有r32
目标操作数的 all 指令将高位 32 归零,而不是合并。例如,一些汇编程序会将pmovmskb r64, xmm
替换为pmovmskb r32, xmm
,从而节省了REX,因为64 位目标版本的行为相同。尽管Operation section of the manual 分别列出了 32/64 位 dest 和 64/128/256b 源的所有 6 种组合,但 r32 形式的隐式零扩展复制了 r64 形式的显式零扩展。我对硬件实现很好奇...
@HansPassant,循环引用开始。
相关:xor eax,eax
or xor r8d,r8d
is the best way to zero RAX or R8(为 RAX 保存 REX 前缀,并且在 Silvermont 上甚至没有专门处理 64 位 XOR)。相关:How exactly do partial registers on Haswell/Skylake perform? Writing AL seems to have a false dependency on RAX, and AH is inconsistent
【参考方案1】:
我不是 AMD 或代表他们,但我会以同样的方式做。因为将高半部分归零不会产生对先前值的依赖,所以 CPU 将不得不等待。如果不这样做,register renaming 机制基本上会被击败。
通过这种方式,您可以在 64 位模式下使用 32 位值编写快速代码,而不必一直显式地打破依赖关系。如果没有这种行为,64 位模式下的每条 32 位指令都必须等待之前发生的事情,即使几乎永远不会使用高位部分。 (使int
64 位会浪费缓存占用和内存带宽;x86-64 most efficiently supports 32 and 64-bit operand sizes)
8 位和 16 位操作数大小的行为很奇怪。依赖疯狂是现在避免使用 16 位指令的原因之一。 x86-64 从 8086 的 8 位和 386 的 16 位继承了这一点,并决定让 8 位和 16 位寄存器在 64 位模式下的工作方式与在 32 位模式下的工作方式相同。
另请参阅Why doesn't GCC use partial registers?,了解真实 CPU 如何处理对 8 位和 16 位部分寄存器的写入(以及对完整寄存器的后续读取)的实际详细信息。
【讨论】:
我认为这并不奇怪,我认为他们不想破坏太多并保留旧的行为。 @Alex 当他们引入 32 位模式时,高部分没有旧行为。之前没有高分。当然之后就不能再改了。 我说的是 16 位操作数,为什么在这种情况下最高位不会归零。它们不在非 64 位模式下。这也保持在 64 位模式。 我将您的“16 位指令的行为很奇怪”解释为“奇怪的是,在 64 位模式下 16 位操作数不会发生零扩展”。因此,我的 cmets 关于在 64 位模式下保持相同的方式以获得更好的兼容性。 @Alex 哦,我明白了。好的。从这个角度来看,我认为这并不奇怪。只是从“回首过去,也许这不是一个好主意”的角度来看。我想我应该更清楚:)【参考方案2】:它只是节省了指令和指令集的空间。您可以使用现有(32 位)指令将小的立即数移动到 64 位寄存器。
当MOV EAX, 42
可以重复使用时,它还使您不必为MOV RAX, 42
编码8 字节值。
这种优化对于 8 位和 16 位操作并不那么重要(因为它们更小),并且更改那里的规则也会破坏旧代码。
【讨论】:
如果那是正确的,那么签名扩展而不是 0 扩展不是更有意义吗? 签名扩展速度较慢,即使在硬件中也是如此。零扩展可以与产生下半部分的任何计算并行完成,但在计算下半部分(至少是符号)之前不能进行符号扩展。 另一个相关的技巧是使用XOR EAX, EAX
,因为XOR RAX, RAX
需要一个REX前缀。
@Nubok:当然,他们可以添加一个 movzx / movsx 的编码,它需要一个立即参数。大多数情况下,将高位清零更方便,因此您可以将值用作数组索引(因为有效地址中的所有 reg 必须具有相同的大小:[rsi + edx]
是'不允许)。当然,避免错误的依赖/部分注册停顿(另一个答案)是另一个主要原因。
更改那里的规则也会破坏旧代码。 旧代码无论如何都不能在 64 位模式下运行(例如,1 字节 inc/dec 是 REX 前缀) ;这无关紧要。 不清理 x86 缺陷的原因是长模式和兼容/旧模式之间的差异较小,因此需要根据模式进行不同解码的指令更少。 AMD 不知道 AMD64 会流行起来,不幸的是它非常保守,因此需要更少的晶体管来支持。从长远来看,如果编译器和人类必须记住哪些东西在 64 位模式下的工作方式不同,那就太好了。【参考方案3】:
如果不将零扩展到 64 位,则意味着从 rax
读取的指令将对其 rax
操作数有 2 个依赖项(写入 eax
的指令和在其之前写入 rax
的指令),这将导致 partial register stall,当有 3 种可能的宽度时,它开始变得棘手,所以它有助于 rax
和 eax
写入完整的寄存器,这意味着 64 位指令集不会引入任何新的部分重命名层。
mov rdx, 1
mov rax, 6
imul rax, rdx
mov rbx, rax
mov eax, 7 //retires before add rax, 6
mov rdx, rax // has to wait for both imul rax, rdx and mov eax, 7 to finish before dispatch to the execution units, even though the higher order bits are identical anyway
不进行零扩展的唯一好处是确保包含rax
的高位,例如,如果它最初包含 0xffffffffffffffff,则结果将是 0xffffffff00000007,但 ISA 几乎没有理由做出这种保证以这样的代价,并且更可能实际上需要更多的零扩展的好处,因此它节省了额外的代码行mov rax, 0
。通过保证它总是零扩展到 64 位,编译器可以牢记这个公理,而在 mov rdx, rax
、rax
中只需要等待它的单个依赖项,这意味着它可以更快地开始执行并退出,从而释放执行单元。此外,它还允许更有效的零习语,如 xor eax, eax
到零 rax
,而不需要 REX 字节。
【讨论】:
Skylake 上的部分标志至少可以通过为 CF 和任何 SPAZO 提供单独的输入来工作。 (所以cmovbe
是 2 微秒,但 cmovb
是 1)。但是没有任何 CPU 会按照您的建议进行任何部分寄存器重命名。相反,如果部分 reg 与完整 reg 分开重命名(即“脏”),它们会插入一个合并 uop。见Why doesn't GCC use partial registers? 和How exactly do partial registers on Haswell/Skylake perform? Writing AL seems to have a false dependency on RAX, and AH is inconsistent
P6 系列 CPU 要么停滞约 3 个周期以插入合并微指令(Core2 / Nehalem),要么更早的 P6 系列(PM、PIII、PII、PPro)停滞(至少? ) ~6 个周期。也许这就像你在 2 中建议的那样,等待完整的 reg 值通过写回永久/架构寄存器文件可用。
@PeterCordes 哦,我知道合并 uops 至少对于部分标志位。有道理,但我忘记了它是如何工作的;它点击了一次,但我忘了做笔记
@PeterCordes microarchitecture.pdf:This gives a delay of 5 - 6 clocks. The reason is that a temporary register has been assigned to AL to make it independent of AH. The execution unit has to wait until the write to AL has retired before it is possible to combine the value from AL with the value of the rest of EAX
我找不到可以用来解决此问题的“合并 uop”示例,对于部分标志停止也是如此
对,早期的 P6 只是停滞不前,直到写回。 Core2 和 Nehalem 在之后/之前插入合并 uop?只会让前端停滞更短的时间。 Sandybridge 插入合并微指令而不会停止。 (但是 AH-merging 必须自己在一个循环中发出,而 AL 合并可以是一个完整组的一部分。)Haswell/SKL 根本不会将 AL 与 RAX 分开重命名,因此 mov al, [mem]
是一个微融合负载+ALU 合并,仅重命名 AH,AH 合并 uop 仍然单独发布。这些 CPU 中的部分标志合并机制各不相同,例如与部分注册不同,Core2/Nehalem 仍然只是为部分标志停止。【参考方案4】:
从硬件的角度来看,更新半个寄存器的能力总是有些昂贵,但在最初的 8088 上,允许手写汇编代码将 8088 视为具有两个与堆栈无关的16 位寄存器和 8 个 8 位寄存器,6 个非堆栈相关的 16 位寄存器和零个 8 位寄存器,或 16 位和 8 位寄存器的其他中间组合。这样的用处值得付出额外的代价。
当 80386 添加 32 位寄存器时,没有提供仅访问寄存器上半部分的工具,但是像 ROR ESI,16
这样的指令将足够快,以保持两个 16 -ESI 中的位值并在它们之间切换。
随着迁移到 x64 架构,增加的寄存器集和其他架构增强功能减少了程序员将最大数量的信息压缩到每个寄存器中的需要。此外,寄存器重命名增加了进行部分寄存器更新的成本。如果代码要执行以下操作:
mov rax,[whatever]
mov [something],rax
mov rax,[somethingElse]
mov [yetAnother],rax
寄存器重命名和相关逻辑可以让 CPU 记录从[whatever]
加载的值需要写入something
的事实,然后——只要最后两个地址不同--允许somethingElse
的加载和存储到yetAnother
的处理,而不必等待从whatever
实际读取数据。但是,如果第三条指令是 mov eax,[somethingElse
,并且它被指定为不影响高位,则第四条指令在第一次加载完成之前无法存储 RAX,甚至允许发生 EAX
的加载也会发生这很困难,因为处理器必须跟踪这样一个事实:虽然下半部分可用,但上半部分不可用。
【讨论】:
隐式归零高位也使 5 字节mov eax, 1
(opcode + imm32) 用作设置完整 64 位寄存器的一种方式,而不是需要 7 字节 mov rax, sign_extended_imm32
(REX +操作码 + modrm + imm32) 或 10 字节 mov rax, imm64
(rex + opcode + imm64)。以及许多其他免费零扩展很有用的情况,例如当使用无符号 32 位整数作为数组索引(寻址模式的一部分)或已知为非负的有符号整数时。
所以即使除了错误依赖的性能问题之外,你想要清除高垃圾比合并某些东西更频繁。 x86-64 可能有一个 movzx r64, r/m32 ,你每次需要时都必须使用它,但这会更糟。特别是如果他们希望像普通 C 类型模型(32 位 int
,64 位指针)那样使用 32 位整数仍然有效。相关:MOVZX missing 32 bit register to 64 bit register - 一些像 MIPS64 这样的 ISA 做出了不同的选择,比如保持窄值符号扩展。
@PeterCordes:很多其他答案都提到了寄存器重命名,但我认为不熟悉这个概念的人可以从更完整的示例中受益。从硬件复杂性或指令集可用性的角度来看,我不认为有一个前缀可以促进例如“add rax,signed byte[whatever]”或“add rsi,unsigned word[whatever]”,指令大小对性能的影响,在大多数情况下,几乎没有。真正的问题是跟踪额外的依赖关系是昂贵的。顺便说一句...
...我有时想知道拥有一个“通用”ABI 是否有意义,它根据预期的调用约定使用修改后的符号名称作为入口点。如果一个入口点仅在用于传递小于 64 位参数的所有寄存器都已知对其类型进行适当扩展时使用,那么编译器可以在它知道所有参数寄存器的情况下使用该入口点已经适当设置,并且当调用者无法保证时,将根据需要对值进行零或符号扩展的入口点。
是的,这是隐式扩展避免的错误依赖问题的一个清晰而具体的例子。已经投票了。是的,如果他们想将所有内容扩展到 64 位,他们可能会将其他一些已删除的操作码(如 AAA / AAM / 等)重新用作 64 位模式源大小/签名覆盖。 (但这会使像 imul
这样的指令在 K8 上变慢(64 位乘法不如 32 快),除非这也设置了操作数大小并将结果从 32 位截断/扩展以填充 reg .) 但是这里的 cmets 不是进一步讨论的地方:/以上是关于为啥 32 位寄存器上的 x86-64 指令会将完整 64 位寄存器的上部归零?的主要内容,如果未能解决你的问题,请参考以下文章