支持高效的xchg 并非易事,而且可能不值得在 CPU 的各个部分增加额外的复杂性。一个真正的 CPU 的微架构比你在优化软件时可以使用的心智模型复杂得多。例如,推测执行使一切变得更加复杂,因为它必须能够回滚到发生异常的点。
使fxch 高效对于x87 性能非常重要,因为x87 的堆栈特性使其(或fld st(2) 之类的替代方案)难以避免。编译器生成的 FP 代码(对于不支持 SSE 的目标)确实确实使用了大量的fxch。似乎快速fxch 完成是因为它很重要,而不是因为它很容易。 Intel Haswell 甚至放弃了对单微指令 fxch 的支持。它仍然是零延迟,但在 HSW 和更高版本上解码为 2 微指令(从 P5 中的 1 和通过 IvyBridge 的 PPro 上升)。
xchg 通常很容易避免。在大多数情况下,您可以展开一个循环,这样相同的值现在在不同的寄存器中就可以了。例如斐波那契与add rax, rdx / add rdx, rax 而不是add rax, rdx / xchg rax, rdx。编译器一般不使用xchg reg,reg,通常手写的asm 也不使用。 (这个鸡/蛋问题非常类似于 loop 很慢(Why is the loop instruction slow? Couldn't Intel have implemented it efficiently?)。loop 对于 Core2/Nehalem 上的 adc 循环非常有用,其中 adc + dec/jnz 循环导致部分标志停顿。)
由于 xchg 在以前的 CPU 上仍然很慢,编译器在几年内都不会开始使用 -mtune=generic。 与fxch 或mov-elimination 不同,支持快速xchg 的设计更改不会帮助CPU 更快地运行大多数现有代码,并且只会提高当前设计的性能在极少数情况下,它实际上是一个有用的窥视孔优化。
与 x87 不同,整数寄存器被部分寄存器的东西复杂化了
xchg 有 4 种操作数大小,其中 3 种使用相同的操作码和 REX 或操作数大小前缀。 (xchg r8,r8 is a separate opcode,因此让解码器以不同于其他解码器的方式对其进行解码可能更容易)。由于隐含的 lock 前缀,解码器已经必须将带有内存操作数的 xchg 识别为特殊的,但如果 reg-reg 形成所有解码到相同的数字,则解码器的复杂性(晶体管计数 + 功率)可能会更低不同操作数大小的微指令。
将一些r,r 表单解码为单个uop 会更加复杂,因为单uop 指令必须由“简单”解码器和复杂解码器处理。所以他们都需要能够解析xchg 并决定它是一个单uop还是多uop表单。
从程序员的角度来看,AMD 和 Intel CPU 的行为有些相似,但有许多迹象表明内部实现有很大不同。例如,Intel mov-elimination 仅在某些时候工作,受某种微架构资源的限制,但执行 mov-elimination 的 AMD CPU 100% 的时间都可以做到(例如用于低车道的推土机向量regs)。
请参阅英特尔的优化手册Example 3-25. Re-ordering Sequence to Improve Effectiveness of Zero-Latency MOV Instructions,其中讨论了立即覆盖零延迟-movzx 结果以更快地释放内部资源。 (我尝试了 Haswell 和 Skylake 上的示例,发现实际上 mov-elimination 在执行此操作时确实工作得更多,但它实际上在总周期中稍慢,而不是更快。该示例旨在显示IvyBridge 的好处,它可能是其 3 个 ALU 端口的瓶颈,但 HSW/SKL 只是 dep 链中资源冲突的瓶颈,并且似乎不需要 ALU 端口来获取更多 movzx 指令。)
我不确切知道需要在有限大小的表中跟踪什么(?)以消除 mov。可能与不再需要时需要尽快释放注册文件条目有关,因为Physical Register File size limits rather than ROB size can be the bottleneck for the out-of-order window size。交换索引可能会使这更难。
xor-zeroing is eliminated 100% of the time on Intel Sandybridge-family;假设这是通过重命名物理零寄存器来实现的,并且这个寄存器永远不需要被释放。
如果xchg 使用与 mov-elimination 相同的机制,它也可能只在某些时候起作用。它需要解码到足够的微指令才能在重命名时未处理的情况下工作。 (否则,当xchg 需要超过1 uop 时,问题/重命名阶段将不得不插入额外的微指令,就像un-laminating micro-fused uops with indexed addressing modes that can't stay micro-fused in the ROB 时那样,或者在为标志或高8 部分寄存器插入合并微指令时。但那是只有当xchg 是一个常见且重要的指令时,才值得这样做。)
请注意,xchg r32,r32 必须将两个结果都零扩展为 64 位,因此它不能是 RAT(寄存器别名表)条目的简单交换。这更像是就地截断两个寄存器。请注意,英特尔 CPU 永远不会消除 mov same,same。它确实已经需要支持mov r32,r32 和movzx r32, r8 而没有执行端口,所以大概它有一些位表明rax = al 或其他东西。 (是的,Intel HSW/SKL do that,不仅仅是 Ivybridge,尽管 Agner 的微架构指南是这么说的。)
我们知道 P6 和 SnB 具有像这样的高零位,因为在 setz al 之前的 xor eax,eax 在读取 eax 时避免了部分寄存器停顿。 HSW/SKL never rename al separately in the first place, only ah。在引入 mov-elimination (Ivybridge) 的同一个 uarch 中似乎已经放弃了部分寄存器重命名(AH 除外),这可能不是巧合。不过,一次为 2 个寄存器设置该位将是一种特殊情况,需要特殊支持。
xchg r64,r64 可能只是交换 RAT 条目,但与 r32 情况不同的解码是另一个复杂因素。它可能仍需要为两个输入触发部分寄存器合并,但add r64,r64 也需要这样做。
另请注意,Intel uop(fxch 除外)只会产生一个寄存器结果(加上标志)。不接触标志不会“释放”输出槽;例如mulx r64,r64,r64 仍然需要 2 微指令才能在 HSW/SKL 上产生 2 个整数输出,即使所有“工作”都在端口 1 的乘法单元中完成,与 mul r64 相同,它确实会产生一个标志结果。)
即使它像“交换 RAT 条目”一样简单,构建一个支持每个 uop 写入多个条目的 RAT 也很复杂。在单个问题组中重命名 4 个xchg uops 时该怎么办?在我看来,这会使逻辑变得更加复杂。请记住,这必须由逻辑门/晶体管构成。即使您说“使用微码陷阱处理特殊情况”,您也必须构建整个管道以支持该管道阶段可能发生这种异常的可能性。
单微指令fxch 需要支持在 FP RAT (fRAT) 中交换 RAT 条目(或其他机制),但它是与整数 RAT (iRAT) 不同的硬件块。即使您在 fRAT(Haswell 之前)中有这种并发症,在 iRAT 中忽略该并发症似乎也是合理的。
不过,问题/重命名复杂性绝对是功耗问题。请注意,Skylake 扩展了很多前端(旧版解码和 uop 缓存获取)和退役,但保留了 4 范围的问题/重命名限制。 SKL 还在后端的更多端口上添加了复制执行单元,因此问题带宽在更多时候成为瓶颈,尤其是在混合了负载、存储和 ALU 的代码中。
RAT(或整数寄存器文件,IDK)甚至可能具有有限的读取端口,因为在发布/重命名许多 3 输入微指令(如 add rax, [rcx+rdx])时似乎存在一些前端瓶颈。我发布了一些微基准测试(this 和后续帖子)显示 Skylake 在读取大量寄存器时比 Haswell 更快,例如与索引寻址模式的微融合。或者瓶颈可能确实存在其他一些微架构限制。
但是 1-uop fxch 是如何工作的? IDK 它在 Sandybridge / Ivybridge 是如何完成的。在 P6 系列 CPU 中,基本上存在一个额外的重映射表来支持FXCH。这可能只是因为 P6 使用一个退休寄存器文件,每个“逻辑”寄存器有 1 个条目,而不是物理寄存器文件 (PRF)。正如您所说,即使“冷”寄存器值只是指向 PRF 条目的指针,您也希望它更简单。 (来源:US patent 5,499,352:浮点寄存器别名表 FXCH 和退休浮点寄存器数组(描述 Intel 的 P6 uarch)。
rfRAT阵列802包含在本发明fRAT逻辑中的一个主要原因是本发明实现FXCH指令的方式的直接结果。
(感谢 Andy Glew (@krazyglew),我没有想到 looking up patents 来了解 CPU 内部结构。)这很繁重,但可以提供一些关于推测执行所需的簿记的见解。
有趣的花絮:该专利也描述了整数,并提到有一些“隐藏”的逻辑寄存器保留供微码使用。 (英特尔的 3-uop xchg 几乎可以肯定使用其中之一作为临时。)
我们或许可以从 AMD 的工作中获得一些见解。
有趣的是,AMD 在 K10、Bulldozer-family、Bobcat/Jaguar 和 Ryzen 中有 2-uop xchg r,r。 (但 Jaguar xchg r8,r8 是 3 uop。也许是为了支持 xchg ah,al 角落案例,而不需要特殊的 uop 来交换单个 reg 的低 16)。
大概两个 uop 在第一个更新 RAT 之前读取输入架构寄存器的旧值。 IDK 究竟是如何工作的,因为它们不一定在同一个周期中发布/重命名(但它们至少在 uop 流中是连续的,所以在最坏的情况下,第二个 uop 是下一个周期中的第一个 uop)。我不知道 Haswell 的 2-uop fxch 是否同样工作,或者他们是否正在做其他事情。
Ryzen 是在“发明” mov-elimination 之后设计的一种新架构,因此他们大概会尽可能地利用它。 (Bulldozer 系列重命名向量移动(但仅适用于 YMM 向量的低 128b 通道);Ryzen 也是第一个为 GP regs 执行此操作的 AMD 架构。)xchg r32,r32 和 r64,r64 是零延迟(重命名),但仍然每个 2 微秒。 (r8 和 r16 需要一个执行单元,因为它们与旧值合并而不是零扩展或复制整个 reg,但仍然只有 2 微秒)。
锐龙的fxch 是 1 uop。 AMD(如英特尔)可能不会花费大量晶体管来使 x87 更快(例如,fmul 每个时钟只有 1 个,并且与fadd 在同一个端口上),所以大概他们能够做到这一点没有很多的额外支持。他们的微编码 x87 指令(like fyl2x) are faster than on recent Intel CPUs,所以英特尔可能更不在乎(至少关于微编码 x87 指令)。
也许 AMD 也可以让 xchg r64,r64 成为一个微指令,比英特尔更容易。甚至xchg r32,r32 也可能是单个 uop,因为像 Intel 一样,它需要支持 mov r32,r32 零扩展而没有执行端口,所以它可以设置任何存在的“upper 32 zeroed”位来支持它。 Ryzen 不会在重命名时消除 movzx r32, r8,所以大概只有一个 upper32-0 位,而不是其他宽度的位。
如果英特尔愿意,他们可能会以低廉的成本做些什么:
英特尔可能会像 Ryzen 那样支持 2-uop xchg r,r(r32,r32 和 r64,r64 表单的零延迟,或 r8,r8 和 r16,r16 表单的 1c)而无需太多额外核心关键部分的复杂性,例如管理寄存器别名表 (RAT) 的问题/重命名和停用阶段。但也许不是,如果他们不能让 2 个微指令在第一个微指令写入时读取寄存器的“旧”值。
xchg ah,al 之类的东西绝对是一个额外的复杂因素,因为Intel CPUs don't rename partial registers separately anymore, except AH/BH/CH/DH。
xchg 当前硬件上的实际延迟
您对它如何在内部工作的猜测很好。它几乎可以肯定使用内部临时寄存器之一(仅可访问微码)。不过,您对他们如何重新排序的猜测太有限了。
事实上,一个方向有 2c 的延迟,而另一个方向有 ~1c 的延迟。
00000000004000e0 <_start.loop>:
4000e0: 48 87 d1 xchg rcx,rdx # slow version
4000e3: 48 83 c1 01 add rcx,0x1
4000e7: 48 83 c1 01 add rcx,0x1
4000eb: 48 87 ca xchg rdx,rcx
4000ee: 48 83 c2 01 add rdx,0x1
4000f2: 48 83 c2 01 add rdx,0x1
4000f6: ff cd dec ebp
4000f8: 7f e6 jg 4000e0 <_start.loop>
此循环在 Skylake 上每次迭代运行约 8.06 个周期。反转xchg 操作数使其每次迭代运行约6.23c 个周期(在Linux 上使用perf stat 测量)。 uops 发出/执行的计数器是相等的,所以没有发生消除。看起来dst <- src 方向比较慢,因为将add 微指令放在该依赖链上会比它们放在dst -> src 依赖链上时慢。
如果您想在关键路径上使用 xchg reg,reg(代码大小原因?),请在关键路径上使用 dst -> src 方向,因为这只有大约 1c 的延迟。
来自 cmets 的其他副话题和问题
这 3 个微操作让我的 4-1-1-1 节奏打乱了
Sandybridge 系列解码器与 Core2/Nehalem 不同。它们总共最多可以产生 4 条微指令,而不是 7 条,因此模式为 1-1-1-1、2-1-1、3-1 或 4。
还要注意,如果最后一个 uop 是可以进行宏融合的,它们会一直挂在它上,直到下一个解码周期,以防下一个块中的第一条指令是 jcc。 (当每次解码时代码从 uop 缓存中运行多次时,这是一个胜利。这通常仍然是每个时钟解码吞吐量 3 uop。)
Skylake 有一个额外的“简单”解码器,因此它可以执行 1-1-1-1-1 到 4-1 我猜,但 > 4 uop 的一条指令仍然需要微码 ROM。 Skylake 也加强了 uop 缓存,如果后端(或分支未命中)不是瓶颈,通常会成为每个时钟问题 4 个融合域 uop/重命名吞吐量限制的瓶颈。
我实际上是在寻找约 1% 的减速带,因此手动优化一直在对主循环代码进行。不幸的是,这大约是 18kB 的代码,所以我什至不再考虑 uop 缓存了。
这似乎有点疯狂,除非您主要将自己限制在主循环内较短循环中的 asm 级优化。主循环中的任何内部循环仍将从 uop 缓存中运行,这可能是您花费大部分时间优化的地方。编译器通常做得足够好,以至于人类在大规模范围内做很多事情是不切实际的。当然,尝试以编译器可以很好地处理 C 或 C++ 的方式编写您的 C 或 C++,但是在 18kB 的代码上寻找像这样的微小窥视孔优化似乎是走入了兔子洞。
使用诸如idq.dsb_uops 与uops_issued.any 之类的性能计数器来查看您的总微指令中有多少来自微指令缓存(DSB = 解码流缓冲区或其他东西)。 Intel's optimization manual 有一些建议让其他性能计数器查看不适合 uop 缓存的代码,例如 DSB2MITE_SWITCHES.PENALTY_CYCLES。 (MITE 是传统解码路径)。在 pdf 中搜索 DSB 以找到它提到的几个地方。
性能计数器将帮助您找到存在潜在问题的地方,例如uops_issued.stall_cycles 高于平均水平的区域可以从寻找暴露更多 ILP(如果有)的方法中受益,或者从解决前端问题中受益,或者从减少分支错误预测中受益。
如 cmets 中所述,单个 uop 最多产生 1 个寄存器结果
As an aside, 和mul %rbx,你真的会同时得到%rdx 和%rax 还是ROB 在技术上是否可以比较高部分提前一个周期访问结果的较低部分?还是就像“mul”微指令进入乘法单元,然后乘法单元直接向ROB发出两个微指令,最后写入结果?
术语:乘法结果不会进入 ROB。它通过转发网络到任何其他微控制器读取它,然后进入 PRF。
mul %rbx 指令在解码器中解码为 2 uop。它们甚至不必在同一个周期中发布,更不用说在同一个周期中执行了。
但是,Agner Fog's instruction tables 仅列出一个延迟数字。事实证明,3 个周期是从两个输入到 RAX 的延迟。根据 InstlatX64 对 Haswell 和 Skylake-X 的测试,RDX 的最小延迟为 4c。
据此,我得出结论,第二个微指令依赖于第一个微指令,并且存在将结果的高半部分写入架构寄存器。 port1 uop 产生完整的 128b 乘法结果。
在 p6 uop 读取它之前,我不知道高半结果在哪里。也许在乘法执行单元和连接到端口 6 的硬件之间存在某种内部队列。通过依赖低半结果来调度 p6 uop,这可能会从多个运行中的mul 指令中安排 p6 uop以正确的顺序运行。但是随后,uop 并没有实际使用那个虚拟的低半输入,而是从连接到端口 6 的执行单元中的队列输出中获取高半结果,并将其作为结果返回。 (这是纯猜测工作,但我认为这是一种可能的内部实现。请参阅 comments 了解早期的一些想法)。
有趣的是,根据Agner Fog's instruction tables,在 Haswell 上,mul r64 的两个微指令去端口 1 和 6。mul r32 是 3 微指令,并在 p1 + p0156 上运行。 Agner 并没有像他对其他一些 insns 那样说这是否真的是 2p1 + p0156 或 p1 + 2p0156。 (不过,他说mulx r32,r32,r32 在p1 + 2p056 上运行(注意p056 不包括p1)。)
更奇怪的是,他说 Skylake 在 p1 p5 上运行 mulx r64,r64,r64 而在 p1 p6 上运行 mul r64。如果这是准确的并且不是拼写错误(这是一种可能性),那么它几乎可以排除额外的 uop 是上半部分乘数的可能性。