【问题标题】:Why is XCHG reg, reg a 3 micro-op instruction on modern Intel architectures?为什么 XCHG reg, reg 是现代英特尔架构上的 3 条微操作指令?
【发布时间】:2018-01-27 17:14:32
【问题描述】:

我正在对代码的性能关键部分进行微优化,并遇到了指令序列(在 AT&T 语法中):

add %rax, %rbx
mov %rdx, %rax
mov %rbx, %rdx

我以为我终于有了xchg 的用例,它可以让我剃掉一条指令并写:

add  %rbx, %rax
xchg %rax, %rdx

然而,我从 Agner Fog 的instruction tables 中发现,xchg 是一个 3 微操作指令,在 Sandy Bridge、Ivy Bridge、Broadwell、Haswell 甚至 Skylake 上具有 2 个周期的延迟。 3 个完整的微操作和 2 个延迟周期!这 3 个微操作打破了我的 4-1-1-1 节奏,并且 2 个周期的延迟使它在最好的情况下比原来的更糟,因为原始中的最后 2 条指令可能并行执行。

现在...我知道 CPU 可能会将指令分解为相当于以下内容的微操作:

mov %rax, %tmp
mov %rdx, %rax
mov %tmp, %rdx 

tmp 是一个匿名内部寄存器,我想最后两个微操作可以并行运行,因此延迟为 2 个周期。

鉴于寄存器重命名发生在这些微架构上,但是,以这种方式完成对我来说没有意义。为什么寄存器重命名器不只是交换标签?从理论上讲,这将只有 1 个周期(可能为 0?)的延迟,并且可以表示为单个微操作,因此会便宜得多。

【问题讨论】:

  • 在 Zen 上,它是一个零延迟的双操作指令。还要注意在 Intel 上 fxch 如何比 xchg 快,所以看起来交换操作并非不可能优化。也许英特尔只是没有看到有必要加快速度?
  • 是的,我记得从 Agner Fog 的微架构文档中读到 fxch 在 P4 之前一直是一个纯粹的寄存器重命名指令,这让我相信他们已经为通用寄存器做了这个也是如此,特别是因为寄存器移动也是较新处理器上的零延迟操作。还有implication 表示,浮点堆栈的用户对fxch 有一定的压力要便宜。
  • xchg reg, reg 是一种罕见的指令类型,它有两个通用输出。在我看来,只有imul/mul, div, pop, xadd, cmpxchg8/16b 和一些字符串操作可以做到这一点。除了xchgxadd 之外的所有这些,它们要么自然慢(div),要么至少自然地在不同的数据路径(pop)和/或具有不同的延迟(mul)中产生结果.如果几乎所有指令都只需要一个结果数据路径,那么设计一个提供两条低延迟数据路径以供很少使用的xchg 的 CPU 将是一种浪费。
  • @jeteon: fxch 很难避免,因为 x87 的堆栈特性。与xchg 不同,拥有快速fxch 对于大多数pre-SSE 浮点代码的性能很重要。 xchg 通常很容易避免。在大多数情况下,您可以展开一个循环,这样相同的值现在在不同的寄存器中就可以了。例如使用add rax, rdx / add rdx, rax 而不是add rax, rdx / xchg rax, rdx 的斐波那契。
  • 顺便说一句,“为什么” - 因为 C 编译器不使用 xchg 来处理多线程中的原子锁同步,或者其他一些特殊情况。所以没有理由在现代 x86 中对其进行优化。你不需要它,如果你有mov 和足够的备用寄存器,并且你需要编译器中的 reg 分配逻辑,交换只是特殊情况(关于“一切看起来像钉子,一旦你有锤子手”)。

标签: performance assembly x86 intel


【解决方案1】:

支持高效的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=genericfxchmov-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,r32movzx 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,r32r64,r64 是零延迟(重命名),但仍然每个 2 微秒。 (r8r16 需要一个执行单元,因为它们与旧值合并而不是零扩展或复制整个 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,rr32,r32r64,r64 表单的零延迟,或 r8,r8r16,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 &lt;- src 方向比较慢,因为将add 微指令放在该依赖链上会比它们放在dst -&gt; src 依赖链上时慢。

如果您想在关键路径上使用 xchg reg,reg(代码大小原因?),请在关键路径上使用 dst -&gt; src 方向,因为这只有大约 1c 的延迟。


来自 cmets 的其他副话题和问题

这 3 个微操作让我的 4-1-1-1 节奏打乱了

Sandybridge 系列解码器与 Core2/Nehalem 不同。它们总共最多可以产生 4 条微指令,而不是 7 条,因此模式为 1-1-1-12-1-13-14

还要注意,如果最后一个 uop 是可以进行宏融合的,它们会一直挂在它上,直到下一个解码周期,以防下一个块中的第一条指令是 jcc。 (当每次解码时代码从 uop 缓存中运行多次时,这是一个胜利。这通常仍然是每个时钟解码吞吐量 3 uop。)

Skylake 有一个额外的“简单”解码器,因此它可以执行 1-1-1-1-14-1 我猜,但 > 4 uop 的一条指令仍然需要微码 ROM。 Skylake 也加强了 uop 缓存,如果后端(或分支未命中)不是瓶颈,通常会成为每个时钟问题 4 个融合域 uop/重命名吞吐量限制的瓶颈。

我实际上是在寻找约 1% 的减速带,因此手动优化一直在对主循环代码进行。不幸的是,这大约是 18kB 的代码,所以我什至不再考虑 uop 缓存了。

这似乎有点疯狂,除非您主要将自己限制在主循环内较短循环中的 asm 级优化。主循环中的任何内部循环仍将从 uop 缓存中运行,这可能是您花费大部分时间优化的地方。编译器通常做得足够好,以至于人类在大规模范围内做很多事情是不切实际的。当然,尝试以编译器可以很好地处理 C 或 C++ 的方式编写您的 C 或 C++,但是在 18kB 的代码上寻找像这样的微小窥视孔优化似乎是走入了兔子洞。

使用诸如idq.dsb_uopsuops_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 对 HaswellSkylake-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 + p0156p1 + 2p0156。 (不过,他说mulx r32,r32,r32p1 + 2p056 上运行(注意p056 不包括p1)。)

更奇怪的是,他说 Skylake 在 p1 p5 上运行 mulx r64,r64,r64 而在 p1 p6 上运行 mul r64。如果这是准确的并且不是拼写错误(这是一种可能性),那么它几乎可以排除额外的 uop 是上半部分乘数的可能性。

【讨论】:

  • ... 不过,这似乎会使每个时钟吞吐量难以实现 1 mul。我认为mul/mulx r32 是 3 uop 而不是 2 很重要,可能是因为它必须将乘法器输出的底部 64 位分成低半和高半。但我不确定mul r64 告诉我们什么。我更倾向于内部缓冲理论; mul r64 似乎不太可能只通过转发网络发送高半部分,否则调度程序将不得不对多个微指令之间的耦合了解太多。
  • @jeteon:更新了我之前遗漏的测试结果。 xchg dst,srcdst-&gt;src 方向上只有 1c 延迟,所以这是一个内部 mov 的方向。
  • @jeteon:请记住,“记录的”延迟是通过运行xchg %eax, %edx 或其他东西的长序列计算得出的。 (Agner Fog 说他通过重复指令进行测试)。例如,Agner 的shr %cl, %r32 号码也是半假的。 2c 延迟是从标志输入到标志输出。如果您在一个循环中重复shl %cl, %eax 100 次,您将测量 2c 延迟。但是如果你把它放在add 指令或破坏标志dep的东西之间,你测量的更像是1.2c的平均值。在这里查看我的实验:agner.org/optimize/blog/read.php?i=415#860
  • @jeteon:对于xchg,你在正确的轨道上寻找可以并行运行的东西,但在一个方向上与另一个方向交互时被挂断了。一个方向的关键路径是mov %rax, %tmp/mov %tmp, %rdx。另一个方向的关键路径是mov %rdx, %rax。 (但不幸的是,这些是一种特殊的mov uop,无法消除。IDK 为什么。)无论如何,使用内部 tmp 意味着两个方向之间不必有任何交互。他们将安排到不同的端口,并像往常一样首先在最旧的就绪状态下运行。
  • @jeteon:忘了提:注意当xchg 有一个输入准备好但另一个没有准备好时会发生什么。相应的输出将在 1 或 2 个周期内准备好,即使另一个输入仍未准备好。因此,一个长链 imul,然后是一个 xchg,然后是另一侧的一个长链 imul,然后是另一个 xchg,仍然可以有效地执行,重叠两个 imul dep 链,而不是通过在 @ 内部相互依赖来序列化987654473@。 (我用那些短的add 链尝试了这个的迷你版,所以我认为我的预测是正确的。)
猜你喜欢
  • 2019-01-10
  • 2019-04-18
  • 2012-09-15
  • 1970-01-01
  • 2016-08-30
  • 1970-01-01
  • 1970-01-01
  • 2012-01-15
  • 2018-04-19
相关资源
最近更新 更多