为什么 x86 通常不允许不是第一个源寄存器的目标寄存器?

Why does x86 commonly not allow a destination register that is not the first source register?

在RISC-V中,可以用指令

进行整数运算Regs[x1] <- Regs[x2]+Regs[x3]
add x1,x2,x3

在 x86 中,这个相同的操作显然需要两条指令,

mov x1,x2
add x1,x3

src1 <- src1 op src2 模式对于 x86 中的其他基本指令似乎很常见,例如andorsub。但是,x86 确实有 dest <- src1 op src2 例如对于浮点 adds.

是双指令模式mov x1,x2op x1,x3;通常将宏融合到单个微操作中?还是这些操作的独立目的地如此罕见,以至于 x86 架构不会在单个 uop 中允许它?如果是这样,不允许独立目的地提供什么效率?

几乎是 的重复,它解释了 machine-code 原因(以及一般情况下的一些例外情况)。

If so, what efficiencies does disallowing independent destination provide?

只是代码大小。它使其他一切变得更糟,这就是为什么所有现代 high-performance 设计都提供 3 操作数指令,以及如果它们是 re-architecting x86-64 从头开始​​提高性能,任何人都会做的事情。

x86 使用紧凑的 variable-length 指令编码,evolved as a 2-operand ISA out of 8-bit 8080 which was more or less a 1-operand ISA where most opcodes implied one of the operands(通常是累加器)。

您可以说,作为 CISC ISA,x86 在 memory-source 操作数的可能性上使用其额外编码 space,而不是在单独的目标上。尽管这只是有点真实,因为只有 2 位编码寄存器与 [register] 间接与 [reg+disp8] 与 [reg+disp32]。 space 的其余部分不存在,因为典型指令只有 2 个字节长,操作码 + modrm。 (加上前缀,立即数,and/or寻址模式的额外字节)。

有趣的是,16 位与 ARM Thumb 的长度相同,ARM Thumb 做出相同的选择,主要是 2 操作数编码,因为这就是您以有时需要更多指令为代价来保持指令小的方式。在原始的 8086(尤其是带有 half-width 总线的 8088)上,code-fetch 是 的主要瓶颈,节省代码字节通常可以提高性能,无论说明。

x86 机器码当时是一成不变的,我们仍然坚持使用它。这对于今天的 CPU 来说非常不方便,32 位模式下的 VEX 和 EVEX 编码被其他指令的无效编码强行塞进;这是一团糟,而且解码速度非常慢 + power-intensive。例如英特尔 CPU 有一个单独的流水线阶段,用于在将指令提供给解码器之前找到指令长度/边界。这就是为什么现代 CPU 有一个 decoded-uop 缓存,以避免在“热”代码区域中出现 re-decode,以及为什么那些长管道需要良好的分支预测。

任何放弃 2 操作数编码以腾出更多空间的小改动都会引发这样的问题:为什么要保留任何遗留包袱,为什么不从头开始?然后,为什么完全是 x86-64,为什么不是像 AArch64 这样漂亮干净的设计?


另请注意,ADDPDADDSD 是 2 操作数 SSE 指令。同一指令的 3 操作数 non-destructive 目标编码是 AVX 新增的,称为 VADDPD / VADDSD.


MOV + ADD 的效率

mov / add(和移位)可以用 lea 完成,例如lea eax, [rdi + rsi*4] 实现 return x + y*4; 以便解决最常见指令的问题。 查看 x86-64 优化编译器输出。

x86 微体系结构在实践中不会 macro-fuse mov + op,尽管这在理论上是可能的。在实践中,编译器确实必须使用大量 mov reg,reg 指令,但每个 ALU 指令明显少于 1 条。还不足以让硬件供应商在解码时开始寻找融合机会。目前,他们只将 cmp/test + 分支融合成一个 uop。 (或者 , also other ALU+branch instructions like AND+branch or DEC+branch.) 还涵盖 memory-source CISC 指令中的 micro-fusion load+ALU 微指令。

在 issue/rename 时间确实使 MOV+ALU 对仍然只有关键路径的 1 个周期延迟。(尽管有时您可以实现通过让关键路径使用原始路径和一些 shorter-latency 或独立的 dep 链使用副本来获得相同的延迟优势。但这通常需要展开循环。)

然而,mov-消除对 front-end 吞吐量或保持 out-of-order window 更小没有帮助。对于流水线的其余部分,MOV 的成本与 NOP 相同。

Haswell through Skylake 的 front-end 宽度与 back-end 中 ALU 执行单元的数量相同。即使使用 Ice Lake 和 Zen(更宽 front-end,仍然“只有”4 个整数 ALU 执行单元),non-eliminated mov 也很少成为瓶颈。大多数代码包括偶尔的存储或 non-micro-fused 加载 uop。

Intel 8086 的两个操作数设计的最初动机,其中目标和第一个操作数必须是相同的寄存器,只是为了保持指令解码器简单。 8086 只有 27,000 个晶体管。英特尔没有晶体管预算来实现三操作数指令集。

虽然 x86 指令集经常被批评需要需要大量晶体管的复杂解码器,但这仅适用于您尝试尽可能快地解码现代 x86 指令集的情况。正如最初的 8086 设计所示,它根本不需要大量晶体管来解码基本指令集。

在设计 8086 时,双操作数指令集并没有什么不寻常之处。它的主要竞争对手 68000 也有两个操作数指令集,IBM 大型机也是如此。这实际上是对 8 位微处理器设计的改进,例如 Intel 8080,其更小的晶体管预算通常实现一个单操作数指令集,其中目标和第一个操作数始终是累加器。

虽然两个操作数指令集允许更紧凑的编码,但这不是目标。英特尔做出的一些简化解码的设计决策实际上增加了代码大小。指令前缀占用整个字节以有效地向指令编码添加一些位。然而,通过将它们视为在处理器中设置隐藏的内部标志的单字节指令,它们非常容易实现。很少使用的单字节 XCHG 指令可能被设计为实现 NOP 指令(XCHG AX,AX)的廉价方法,尽管设计者也可能只是认为它会经常使用以证明单字节编码是合理的。无论哪种方式,如果将此操作码 space 用于它们,还有许多其他更常用的操作可能会产生更紧凑的代码。

如果您使用当今的晶体管预算从头开始设计一个指令集,您可能会设计一个三操作数指令集。然而,在仍然关注晶体管数量的地方,您确实看到了相对现代的设计,例如仅支持两个操作数的 8 位 AVR 指令集。