在 Coffee Lake (Skylake) 上进行 bigint 乘法的第一步中,ADD 比 ADC 慢

ste*_*pan 10 performance x86 assembly cpu-architecture micro-optimization

在下面突出显示的行中更改add为可adc显着提高性能。我觉得这很违反直觉,因为add有更多的端口要执行,而且它不依赖于标志。

CPU:英特尔 i7-9750H(Coffee Lake)。
UOPS_ISSUED.ANY add= ~2.87 uops /cycle。
UOPS_ISSUED.ANY adc= ~3.47 uops /cycle。
在这两种情况下,退休插槽是 98.5% 的 uops。

它反映在基准时间上,add版本要慢得多。

如果有人能帮助我理解为什么add变慢,我将不胜感激?我可以提供更多指标,只是不知道要寻找什么。

# Code to multiply large integer by a qword.
# RSI = input large integer (qword array).
# RDI = output large integer (qword array).
# RDX = qword to multiply the large integer by.
# ECX = number of 32-byte blocks to process (i.e. qwords count / 4).
# RAX = carry

xor eax, eax

.p2align 4
L_mul_word_loop:
  mulx r9, r8, [rsi]
  add r8, rax         ###  <-- "adc r8, rax" (+20-30% performance)
  mov [rdi], r8

  mulx rax, r8, [rsi+8]      # rax:r8 = RDX * [rsi+8]
  adc r9, r8                 # add high-half of last mulx to low half of this
  mov [rdi+8], r9            # and store.

  mulx r9, r8, [rsi+16]
  adc r8, rax
  mov [rdi+16], r8

  mulx rax, r8, [rsi+24]
  adc r9, r8
  mov [rdi+24], r9

  adc rax, 0                # include CF into high half of last mulx: can't wrap
  add rdi, 32
  add rsi, 32

  dec ecx
  jnz L_mul_word_loop
Run Code Online (Sandbox Code Playgroud)

循环携带的依赖链是通过CF在add->adc指令之间,RAX从循环的底部回到顶部。(最大的可能产品的高一半小于0xFF...,因此最终adc rax, 0无法包装并产生自己的结转。例如 0xFF * 0xFF = 0xFE01)。这个依赖链有 5 个周期长。

(实验性地,请参阅注释,使用lea代替add指针增量并删除将adc rax, 0其缩短为 4 个周期的速度并不比adc在循环顶部使用的此版本快。)


最少的可重现代码:add-adc.asm

GitHub 上的完整代码(仅在 Mac测试;旧版本在 Ubuntu 上运行,虽然我有一段时间没有检查过)。我在这个测试中观察到这种影响:

build/benchmark_asm_x86_64 --benchmark_filter=mul_word/100 --benchmark_repetitions=3 --benchmark_report_aggregates_only=true
Run Code Online (Sandbox Code Playgroud)

更新

我添加了一个最小的可重现示例链接(单个 ASM 文件)。尝试收集每次迭代的指标。请注意,外循环迭代计数远高于内循环(希望为 L1 缓存保持足够小的数组),希望这不会使数字倾斜太多。这是我得到的:

add每个内循环迭代:
~ 6.88 个周期
~ 20.00 uops_issued.any
~ 4.83 uops_dispatched_port.port1

adc每个内循环迭代:
~ 6.12 个周期
~ 20.11 uops_issued.any
~ 4.34 uops_dispatched_port.port1

amo*_*kov 2

我认为这是由于调度不理想造成的,写回冲突惩罚带来了额外的打击。

比较 uops.info 的 64 位mulx与各种变体的测量结果,imul我们可以合理地得出结论,其中mulx包括一个端口 1 uop,延迟 3 产生结果的低 64 位,加上一个端口 5 uop 在一个周期后产生高 64 位。

考虑当在周期i在端口 1 上发出 3 周期 uop ,并且 1 周期 uop 处于待处理状态时会发生什么情况。它不能在周期i发出(端口不能接受该周期上的第二个 uop)。它也不能在周期i +2上发出:这会导致该端口上周期i +3 上的写回冲突(它无法在同一周期上产生两个微指令的结果)。

通过构建一个具有强制写回冲突的循环,Fabian Giesen证明,当这种情况发生时,显然会受到额外的惩罚。

因此,如果单周期和多周期微指令混合竞争同一个端口,那就有点像地雷了。我最初尝试解决这个问题是在循环中添加第五 条指令(该指令的输出将被丢弃),因此 p1 和 p5 上的多周期微指令会产生持续的压力,并且s 将被安排在其他地方。这在某种程度上起到了作用,但还可以做得更好!mulxadd

以下测量数据来自 Skylake (i7-6700)。

基线版本每次迭代运行约 6.9 个周期:

    10,337,227,178      cycles:u
     4,283,434,673       uops_dispatched_port.port_0:u
     7,278,477,707       uops_dispatched_port.port_1:u
     6,849,168,521       uops_dispatched_port.port_5:u
     5,609,252,055       uops_dispatched_port.port_6:u

    10,384,026,044      cycles:u
     3,922,820,702       uops_dispatched_port.port_2:u
     4,024,756,546       uops_dispatched_port.port_3:u
     6,388,517,494       uops_dispatched_port.port_4:u
     4,087,151,242       uops_dispatched_port.port_7:u
Run Code Online (Sandbox Code Playgroud)

请注意,端口 4 上的存储数据微指令数量比应有的数量高出 5% 以上。一些商店正在重播,这将对后续测量产生越来越混乱的影响。

adc代替初始版本的变体add每次迭代运行 6 个周期:

     8,877,983,794      cycles:u
     5,181,755,017       uops_dispatched_port.port_0:u
     6,525,640,349       uops_dispatched_port.port_1:u
     6,019,129,311       uops_dispatched_port.port_5:u
     6,295,528,774       uops_dispatched_port.port_6:u

     9,040,426,883      cycles:u
     3,762,317,354       uops_dispatched_port.port_2:u
     3,814,931,097       uops_dispatched_port.port_3:u
     7,292,924,631       uops_dispatched_port.port_4:u
     4,462,674,038       uops_dispatched_port.port_7:u
Run Code Online (Sandbox Code Playgroud)

(注意端口 4 上的计数更高)

现在让我们交换 和 的增量rsirdi之前rsi需要)。这带来了非常显着的改进,每次迭代 5.6 个周期:

     8,376,301,855      cycles:u
     5,129,834,339       uops_dispatched_port.port_0:u
     6,632,790,174       uops_dispatched_port.port_1:u
     6,088,383,045       uops_dispatched_port.port_5:u
     6,173,097,806       uops_dispatched_port.port_6:u

     8,404,972,940      cycles:u
     4,287,284,508       uops_dispatched_port.port_2:u
     4,317,891,165       uops_dispatched_port.port_3:u
     7,408,432,079       uops_dispatched_port.port_4:u
     3,429,913,047       uops_dispatched_port.port_7:u
Run Code Online (Sandbox Code Playgroud)

(端口 4 计数再次增加)

现在,如果我们认为在端口 1 和 5 上发出的单周期指令给调度程序带来了麻烦,我们能否找到一种方法以某种方式强制它们在其他端口上?是的!宏融合预测的未采取的 add-jcc 对只能在端口 0 和 6 上发出。因此,让我们在jz .+2之后添加add rsi, 32(注意,Skylake JCC 勘误表可能会在这里困扰我,但幸运的是,我们的循环从 16 字节 mod 32 开始,因此我们避免了有问题的交叉):

     8,339,935,074      cycles:u
     4,749,784,934       uops_dispatched_port.port_0:u
     6,429,206,233       uops_dispatched_port.port_1:u
     6,045,479,355       uops_dispatched_port.port_5:u
     6,798,134,405       uops_dispatched_port.port_6:u

     8,330,518,755      cycles:u
     4,250,791,971       uops_dispatched_port.port_2:u
     4,284,637,776       uops_dispatched_port.port_3:u
     7,379,587,531       uops_dispatched_port.port_4:u
     3,503,715,123       uops_dispatched_port.port_7:u
Run Code Online (Sandbox Code Playgroud)

哦不,我们的周期几乎没有变化!如果我们最终硬着头皮取消第一家商店(nop dword ptr [rdi]代替mov [rdi], r8)会怎样:

     7,912,840,444      cycles:u
     4,648,297,975       uops_dispatched_port.port_0:u
     6,334,248,230       uops_dispatched_port.port_1:u
     6,059,133,113       uops_dispatched_port.port_5:u
     6,982,987,722       uops_dispatched_port.port_6:u

     7,843,194,695      cycles:u
     3,810,009,654       uops_dispatched_port.port_2:u
     3,790,523,332       uops_dispatched_port.port_3:u
     6,094,690,751       uops_dispatched_port.port_4:u
     2,938,959,057       uops_dispatched_port.port_7:u
Run Code Online (Sandbox Code Playgroud)

即每次迭代 5.25 个周期。对 运用同样的jz .+2技巧add rdi, 32,我们得到:

     7,630,926,523      cycles:u
     5,831,880,767       uops_dispatched_port.port_0:u
     6,004,354,504       uops_dispatched_port.port_1:u
     6,002,011,214       uops_dispatched_port.port_5:u
     6,183,831,282       uops_dispatched_port.port_6:u

     7,638,112,528      cycles:u
     3,751,406,178       uops_dispatched_port.port_2:u
     3,695,775,382       uops_dispatched_port.port_3:u
     5,638,534,896       uops_dispatched_port.port_4:u
     3,091,934,468       uops_dispatched_port.port_7:u
Run Code Online (Sandbox Code Playgroud)

每次迭代的周期数低于 5.1 个,而预测吞吐量为每次迭代 5 个周期。我们看到端口 1 和 5 只被指令占用mulx。恢复 nopped 的存储,每次迭代我得到 5.36 个周期:

     8,041,908,278      cycles:u
     5,599,216,700       uops_dispatched_port.port_0:u
     6,010,109,267       uops_dispatched_port.port_1:u
     6,002,448,325       uops_dispatched_port.port_5:u
     6,417,804,937       uops_dispatched_port.port_6:u

     8,050,871,976      cycles:u
     4,183,403,858       uops_dispatched_port.port_2:u
     4,166,022,265       uops_dispatched_port.port_3:u
     7,179,871,612       uops_dispatched_port.port_4:u
     3,695,465,024       uops_dispatched_port.port_7:u
Run Code Online (Sandbox Code Playgroud)

我不清楚是什么导致了重播。