为什么在32位寄存器上执行x86-64指令会将完整的64位寄存器的高位清零?

176

x86-64 Intel手册之旅中,我读到:

也许最令人惊讶的事实是,例如MOV EAX, EBX这样的指令会自动清零RAX寄存器的高32位。

在同一来源引用的Intel文档(基本架构手册中的64位模式下通用寄存器3.4.1.1)告诉我们:

  • 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汇编的怪癖)。


23
如果你在谷歌上搜索“Partial register stall”,你会找到关于他们(几乎可以肯定地)试图避免的问题的相当多的信息。 - Jerry Coffin
4
x86-64寄存器包括RAX,EAX,AX和AL。如果我将一个值写入其中一个寄存器的低位(如AL),那么这将覆盖整个寄存器的值吗? - Hans Passant
6
不仅是“大多数”。据我所知,所有带有r32目标操作数的指令都会将高32位清零,而不是合并。例如,一些汇编器将使用pmovmskb r32,xmm替换pmovmskb r64,xmm,以节省REX,因为64位目标版本的行为完全相同。即使手册的操作部分单独列出了所有6种32/64位目标和64/128/256位源的组合,r32形式的隐式零扩展也会复制r64形式的显式零扩展。我对硬件实现很感兴趣... - Peter Cordes
3
@HansPassant,循环引用开始了。 - kchoi
1
相关:[xor eax,eaxxor r8d,r8d是将RAX或R8清零的最佳方法(为RAX节省REX前缀,64位XOR在Silvermont上甚至没有特殊处理)。 相关:[Haswell / Skylake上的部分寄存器如何执行? 写入AL似乎对RAX有错误依赖性,并且AH不一致。] - Peter Cordes
4个回答

130
我不是AMD,也不代表他们说话。但我会以同样的方式来做。因为将高半部分清零不会在CPU上创造依赖于先前值的情况,从而不必等待。如果不是这样做,寄存器重命名机制实际上就会被破坏。
这样,您可以在64位模式下使用32位值编写快速代码,而无需一直明确地打破依赖关系。如果没有这种行为,在64位模式下的每个32位指令都必须等待发生在之前的某些事情,即使几乎永远不会使用那个高部分。(将int变为64位会浪费缓存占用和内存带宽;x86-64最有效地支持32位和64位的操作数大小)

8位和16位操作数的行为是奇怪的。依赖关系混乱是16位指令现在被避免的原因之一。x86-64从8086(8位)和386(16位)继承了这个问题,并决定在64位模式下使8位和16位寄存器的工作方式与32位模式下相同。


请参阅为什么GCC不使用部分寄存器?,了解实际处理8位和16位部分寄存器写入(以及后续对完整寄存器的读取)的CPU细节。


9
我认为这并不奇怪,我认为他们不想做太多改动,所以保留了旧的行为方式。 - Alexey Frunze
8
当引入32位模式时,高位部分没有旧的行为,因为在此之前根本没有高位部分。当然,在那之后就不能再改变了。@Alex - harold
4
我理解你的话是说,“在64位模式下,对于16位操作数没有进行零扩展是很奇怪的”,因此我提出了保持相同的方式以获得更好的兼容性的建议。请注意,我的翻译尽量保持内容原意不变,同时使其易于理解。 - Alexey Frunze
9
哦,我明白了。好的,从那个角度来看,我不认为这很奇怪。只是从“回顾过去,也许这不是一个好主意”的角度来看有些奇怪。我想我应该表达得更清楚 :) - harold
3
16位指令的逻辑可以是:“如果我们需要保持兼容性和对前一个寄存器值的16-31位依赖,那么清除32-63位不会为我们节省空间。因此,完全忽略这种清除。”无论如何,这并不是x86-64最奇特的地方。 - Netch
显示剩余6条评论

13

这只是在指令和指令集中节省空间。您可以使用现有的(32位)指令将小的立即值移动到64位寄存器中。

当您需要将8字节值编码为MOV RAX, 42时,它也可以避免这种情况,而可以重复使用MOV EAX, 42

对于8位和16位操作,这种优化并不那么重要(因为它们更小),在那里改变规则也会破坏旧代码。


7
如果没错的话,它进行符号扩展似乎更有意义,而不是进行0扩展? - Damien_The_Unbeliever
19
符号扩展较慢,即使在硬件中也是如此。零扩展可以与产生低半部分的任何计算并行进行,但符号扩展直到(至少是)低半部分的符号被计算出来之前都无法进行。 - Jerry Coffin
14
另一个相关的技巧是使用 XOR EAX,EAX ,因为 XOR RAX,RAX 需要一个 REX 前缀。 - Neil
4
@Nubok:当然,他们本可以添加一个带立即参数的movzx / movsx编码。大多数情况下,将高位清零更方便,这样您可以将值用作数组索引(因为有效地址中所有寄存器必须具有相同的大小:[rsi + edx]是不允许的)。当然,避免错误依赖/部分寄存器停顿(另一个答案)也是另一个主要原因。 - Peter Cordes
5
更改规则也会破坏旧代码。无论如何,旧代码无法在64位模式下运行(例如1字节的inc/dec是REX前缀),这与此无关。不清理x86的问题之所以是做的原因是为了减少长模式和兼容/传统模式之间的差异,从而使更少的指令根据模式进行不同的解码。AMD不知道AMD64会变得流行,并且非常保守,以便支持需要更少的晶体管。长期来看,如果编译器和人们记住在64位模式下哪些内容工作方式不同的话,这样做没问题。 - Peter Cordes
显示剩余10条评论

5

如果不将0扩展到64位,那么从rax读取的指令将有2个操作数依赖项(写入eax和它之前写入rax的指令),这会导致部分寄存器停顿,当存在3种可能的宽度时,这将变得棘手。因此,有助于使raxeax写入整个寄存器,这意味着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的高位bit包含在内,比如,如果它最初包含0xffffffffffffffff,则结果将是0xffffffff00000007,但ISA为此作出代价更大的保证的原因很少,而且更有可能需要零扩展的好处,因此可以省略掉额外的代码行mov rax, 0。通过保证它总是被零扩展到64位,编译器可以在mov rdx, rax中以此为前提进行工作,rax只需要等待其单个依赖项,这意味着它可以更快地开始执行并退役,释放执行单元。此外,它还允许更有效的零习语,例如xor eax,eax来将rax归零,而不需要REX字节。

1
Skylake上的部分标志至少可以通过为CF和任何SPAZO分别提供输入来工作(因此cmovbe是2个uop,但cmovb是1个)。 但是,没有任何进行任何部分寄存器重命名的CPU会按照您建议的方式执行。 相反,如果将部分寄存器与完整寄存器分别重命名(即“脏”),则会插入合并uop。 请参见为什么GCC不使用部分寄存器?Haswell / Skylake上的部分寄存器如何执行? 写入AL似乎对RAX有错误依赖性,AH不一致 - Peter Cordes
P6系列的CPU(Core2 / Nehalem)要么在大约3个周期内停顿以插入合并uop,要么早期的P6系列(P-M、PIII、PII、PPro)只需停顿(至少?)约6个周期。也许这就像您在第2点中建议的那样,等待完整的寄存器值通过写回到永久/架构寄存器文件中变得可用。 - Peter Cordes
@PeterCordes 哦,我知道至少对于部分标志暂停合并 uops 的情况。很有道理,但我忘记了它是如何工作的; 它曾经单击过,但我忘记做笔记了。 - Lewis Kelsey
1
@PeterCordes microarchitecture.pdf: “这会导致5-6个时钟的延迟。原因是已经为AL分配了一个临时寄存器,使其独立于AH。执行单元必须等待写入AL的退役后才能将AL的值与EAX的其余部分的值组合起来。”我找不到解决这个问题所使用的“合并uop”的示例,同样也找不到部分标志暂停的示例。 - Lewis Kelsey
不仅清零习语可以避免REX前缀:mov eax,7是一个5字节的指令,而mov rax,7是一个7字节的指令。(额外的REX和ModRM,尽管如果AMD没有进行隐式零扩展,他们可能会对mov reg,imm32 no-modrm操作码的REX版本做出其他事情,并使它们符号扩展32位立即数,并将64位立即数放在某些其他操作码上。可能有一个modrm。) - Peter Cordes
显示剩余3条评论

4

从硬件的角度来看,更新寄存器的一半始终是比较昂贵的。但在原始的8088上,它可以让手写汇编代码将8088视为具有两个非堆栈相关的16位寄存器和八个8位寄存器,六个非堆栈相关的16位寄存器和零个8位寄存器,或其他16位和8位寄存器的中间组合。这种实用性值得额外的成本。

当80386添加32位寄存器时,并未提供访问寄存器顶部一半的功能,但是像 ROR ESI,16 这样的指令速度足够快,使得能够在ESI中存储两个16位值并在它们之间切换仍然具有价值。

随着迁移到x64架构,增加了寄存器集和其他架构增强功能,减少了程序员需要将尽可能多的信息挤入每个寄存器的需求。此外,寄存器重命名增加了进行局部寄存器更新的成本。如果代码执行如下操作:

    mov rax,[whatever]
    mov [something],rax
    mov rax,[somethingElse]
    mov [yetAnother],rax

寄存器重命名以及相关逻辑可以让CPU记录从[whatever]加载的值需要写入something,只要最后两个地址不同,就允许加载somethingElse并将其存储到yetAnother而无需等待数据实际从whatever读取。如果第三条指令是mov eax,[somethingElse],并且它被指定为不影响上半部分位,则第四条指令无法在第一次加载完成之前存储RAX,即使允许加载EAX也会很困难,因为处理器必须跟踪下半部分可用但上半部分不可用的事实。


1
隐式地将高位清零还可以使得5字节的mov eax, 1(操作码+imm32)作为设置完整64位寄存器的一种方式,而不需要7字节的mov rax, sign_extended_imm32(REX+操作码+modrm+imm32)或10字节的mov rax, imm64(rex+操作码+imm64)。在许多其他情况下,免费进行零扩展也是有用的,例如在使用无符号32位整数作为数组索引(地址模式的一部分)或已知为非负的有符号整数时。 - Peter Cordes
即使不考虑错误依赖性的性能问题,清除高垃圾比与某些东西合并更频繁。x86-64 可以有一个movzx r64,r/m32,每次需要使用它,但那会更糟。特别是如果他们希望仍然有效地处理32位整数,如正常的C类型模型(32位“int”,64位指针)。相关:MOVZX缺少32位寄存器到64位寄存器 - 一些ISA(例如MIPS64)做出了不同的选择,例如保持窄值符号扩展。 - Peter Cordes
1
@PeterCordes: 很多其他答案提到了寄存器重命名,但我认为那些对该概念不熟悉的人会从更完整的例子中受益。从硬件复杂性或指令集可用性的角度来看,我认为很容易有一个前缀来方便例如“add rax,signed byte [whatever]”或“add rsi,unsigned word [whatever]”,而指令大小对性能的影响已经几乎消失。真正的问题是跟踪额外的依赖关系是昂贵的。顺便说一句... - supercat
2
有时我会想,是否有一个“通用”的ABI是有意义的,它使用基于预期调用约定的修改后的符号名称作为入口点。如果有一个仅在所有用于传递小于64位参数的寄存器已知适当扩展其类型时才使用的入口点,那么编译器可以在它知道所有参数寄存器已经适当设置的情况下使用该入口点,并且在调用者无法保证时使用需要零扩展或符号扩展值的入口点。 - supercat
是的,这是一个很好的明确而具体的例子,说明了隐式扩展避免了虚假依赖问题。已经点赞了。如果他们想将所有东西扩展到64位,他们可能本可以重新利用其他已删除的操作码(如AAA / AAM /等)作为64位模式源大小/有符号性覆盖。 (但这会使像imul这样的指令在K8上变慢(64位乘法不如32位快),除非它还设置了操作数大小并将结果从32位截断/扩展以填充寄存器。)但是这里的评论不是讨论的地方 :/ - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接