ADC/SBB 和 INC/DEC 在某些 CPU 上的紧密循环中出现问题
Problems with ADC/SBB and INC/DEC in tight loops on some CPUs
我正在 Delphi 中编写一个简单的 BigInteger 类型。它主要由一个TLimb的动态数组组成,其中一个TLimb是一个32位的无符号整数,和一个32位的size field,其中也保存着BigInteger的符号位。
为了添加两个 BigInteger,我创建了一个适当大小的新 BigInteger,然后在一些簿记之后调用以下过程,将三个指针传递给左右操作数和结果,以及左右肢体的数量。
明码:
class procedure BigInteger.PlainAdd(Left, Right, Result: PLimb; LSize, RSize: Integer);
asm
// EAX = Left, EDX = Right, ECX = Result
PUSH ESI
PUSH EDI
PUSH EBX
MOV ESI,EAX // Left
MOV EDI,EDX // Right
MOV EBX,ECX // Result
MOV ECX,RSize // Number of limbs at Left
MOV EDX,LSize // Number of limbs at Right
CMP EDX,ECX
JAE @SkipSwap
XCHG ECX,EDX // Left and LSize should be largest
XCHG ESI,EDI // so swap
@SkipSwap:
SUB EDX,ECX // EDX contains rest
PUSH EDX // ECX contains smaller size
XOR EDX,EDX
@MainLoop:
MOV EAX,[ESI + CLimbSize*EDX] // CLimbSize = SizeOf(TLimb) = 4.
ADC EAX,[EDI + CLimbSize*EDX]
MOV [EBX + CLimbSize*EDX],EAX
INC EDX
DEC ECX
JNE @MainLoop
POP EDI
INC EDI // Do not change Carry Flag
DEC EDI
JE @LastLimb
@RestLoop:
MOV EAX,[ESI + CLimbSize*EDX]
ADC EAX,ECX
MOV [EBX + CLimbSize*EDX],EAX
INC EDX
DEC EDI
JNE @RestLoop
@LastLimb:
ADC ECX,ECX // Add in final carry
MOV [EBX + CLimbSize*EDX],ECX
@Exit:
POP EBX
POP EDI
POP ESI
end;
// RET is inserted by Delphi compiler.
这段代码运行良好,我对它非常满意,直到我注意到,在我的开发设置(iMac 上的 Parallels VM 中的 Win7)上,一个简单的 PURE PASCAL 加法例程,在模拟携带一个变量和一些 if
子句,比我简单、直接的手工汇编程序例程快。
我花了一段时间才发现在某些 CPU 上(包括我的 iMac 和旧笔记本电脑),DEC
或 INC
和 ADC
或 [= 的组合20=] 可能会非常慢。但是在我的其他大多数电脑上(我还有五台其他 PC 来测试它,尽管其中四台完全相同),速度相当快。
所以我写了一个新版本,使用 LEA
和 JECXZ
来模拟 INC
和 DEC
,就像这样:
部分仿真代码:
@MainLoop:
MOV EAX,[ESI + EDX*CLimbSize]
LEA ECX,[ECX - 1] // Avoid INC and DEC, see above.
ADC EAX,[EDI + EDX*CLimbSize]
MOV [EBX + EDX*CLimbSize],EAX
LEA EDX,[EDX + 1]
JECXZ @DoRestLoop // LEA does not modify Zero flag, so JECXZ is used.
JMP @MainLoop
@DoRestLoop:
// similar code for the rest loop
这使得我的代码在“慢”机器上的速度几乎快了三倍,但在“快”机器上慢了大约 20%。所以现在,作为初始化代码,我做了一个简单的定时循环,并用它来决定我是否要设置单元来调用普通例程或模拟例程。这 几乎 总是正确的,但有时当它应该选择模拟例程时它会选择(较慢的)普通例程。
但我不知道这是否是最好的方法。
问题
我给出了我的解决方案,但是这里的 asm 专家是否知道避免某些 CPU 速度缓慢的更好方法?
更新
Peter 和 Nils 的回答对我走上正轨有很大帮助。这是我针对 DEC
版本的最终解决方案的主要部分:
明码:
class procedure BigInteger.PlainAdd(Left, Right, Result: PLimb; LSize, RSize: Integer);
asm
PUSH ESI
PUSH EDI
PUSH EBX
MOV ESI,EAX // Left
MOV EDI,EDX // Right
MOV EBX,ECX // Result
MOV ECX,RSize
MOV EDX,LSize
CMP EDX,ECX
JAE @SkipSwap
XCHG ECX,EDX
XCHG ESI,EDI
@SkipSwap:
SUB EDX,ECX
PUSH EDX
XOR EDX,EDX
XOR EAX,EAX
MOV EDX,ECX
AND EDX,[=14=]000003
SHR ECX,2
CLC
JE @MainTail
@MainLoop:
// Unrolled 4 times. More times will not improve speed anymore.
MOV EAX,[ESI]
ADC EAX,[EDI]
MOV [EBX],EAX
MOV EAX,[ESI + CLimbSize]
ADC EAX,[EDI + CLimbSize]
MOV [EBX + CLimbSize],EAX
MOV EAX,[ESI + 2*CLimbSize]
ADC EAX,[EDI + 2*CLimbSize]
MOV [EBX + 2*CLimbSize],EAX
MOV EAX,[ESI + 3*CLimbSize]
ADC EAX,[EDI + 3*CLimbSize]
MOV [EBX + 3*CLimbSize],EAX
// Update pointers.
LEA ESI,[ESI + 4*CLimbSize]
LEA EDI,[EDI + 4*CLimbSize]
LEA EBX,[EBX + 4*CLimbSize]
// Update counter and loop if required.
DEC ECX
JNE @MainLoop
@MainTail:
// Add index*CLimbSize so @MainX branches can fall through.
LEA ESI,[ESI + EDX*CLimbSize]
LEA EDI,[EDI + EDX*CLimbSize]
LEA EBX,[EBX + EDX*CLimbSize]
// Indexed jump.
LEA ECX,[@JumpsMain]
JMP [ECX + EDX*TYPE Pointer]
// Align jump table manually, with NOPs. Update if necessary.
NOP
// Jump table.
@JumpsMain:
DD @DoRestLoop
DD @Main1
DD @Main2
DD @Main3
@Main3:
MOV EAX,[ESI - 3*CLimbSize]
ADC EAX,[EDI - 3*CLimbSize]
MOV [EBX - 3*CLimbSize],EAX
@Main2:
MOV EAX,[ESI - 2*CLimbSize]
ADC EAX,[EDI - 2*CLimbSize]
MOV [EBX - 2*CLimbSize],EAX
@Main1:
MOV EAX,[ESI - CLimbSize]
ADC EAX,[EDI - CLimbSize]
MOV [EBX - CLimbSize],EAX
@DoRestLoop:
// etc...
我去掉了很多白space,估计reader可以搞定剩下的套路。它类似于主循环。速度提高约。较大的 BigIntegers 为 20%,较小的 BigIntegers 为 10%(只有几个肢体)。
64 位版本现在尽可能使用 64 位加法(在主循环和 Main3 和 Main2 中,它们不像上面那样“失败”)和以前,64 位比 32 慢很多位,但现在它比 32 位快 30%,比原来的简单 64 位循环快两倍。
更新 2
Intel 在其 Intel 64 和 IA-32 架构优化参考手册 中提出,3.5.2.6 部分标志寄存器停顿 -- 示例 3-29:
XOR EAX,EAX
.ALIGN 16
@MainLoop:
ADD EAX,[ESI] // Sets all flags, so no partial flag register stall
ADC EAX,[EDI] // ADD added in previous carry, so its result might have carry
MOV [EBX],EAX
MOV EAX,[ESI + CLimbSize]
ADC EAX,[EDI + CLimbSize]
MOV [EBX + CLimbSize],EAX
MOV EAX,[ESI + 2*CLimbSize]
ADC EAX,[EDI + 2*CLimbSize]
MOV [EBX + 2*CLimbSize],EAX
MOV EAX,[ESI + 3*CLimbSize]
ADC EAX,[EDI + 3*CLimbSize]
MOV [EBX + 3*CLimbSize],EAX
SETC AL // Save carry for next iteration
MOVZX EAX,AL
ADD ESI,CUnrollIncrement*CLimbSize // LEA has slightly worse latency
ADD EDI,CUnrollIncrement*CLimbSize
ADD EBX,CUnrollIncrement*CLimbSize
DEC ECX
JNZ @MainLoop
标志保存在AL
中,并通过MOVZX
保存在EAX
中。它是通过循环中的第一个 ADD
添加的。然后需要一个 ADC
,因为 ADD
可能会产生一个进位。另见评论。
因为进位保存在EAX
,我也可以用ADD
来更新指针。循环中的第一个 ADD
也会更新所有标志,因此 ADC
不会受到部分标志寄存器停顿的影响。
有如此多的 x86 芯片,它们的使用时序差异很大,实际上您不可能为所有这些芯片都提供最佳代码。您在使用前拥有两个已知的良好功能和基准的方法已经非常先进。
但是,根据 BigInteger 的大小,您可能可以通过简单的循环展开来改进代码。这将大大消除循环开销。
例如你可以执行一个专门的块来添加八个整数,如下所示:
@AddEight:
MOV EAX,[ESI + EDX*CLimbSize + 0*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 0*CLimbSize]
MOV [EBX + EDX*CLimbSize + 0*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 1*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 1*CLimbSize]
MOV [EBX + EDX*CLimbSize + 1*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 2*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 2*CLimbSize]
MOV [EBX + EDX*CLimbSize + 2*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 3*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 3*CLimbSize]
MOV [EBX + EDX*CLimbSize + 3*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 4*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 4*CLimbSize]
MOV [EBX + EDX*CLimbSize + 4*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 5*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 5*CLimbSize]
MOV [EBX + EDX*CLimbSize + 5*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 6*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 6*CLimbSize]
MOV [EBX + EDX*CLimbSize + 6*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 7*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 7*CLimbSize]
MOV [EBX + EDX*CLimbSize + 7*CLimbSize],EAX
LEA ECX,[ECX - 8]
现在你重建你的循环,只要你有超过 8 个元素要处理就执行上面的块,并使用你已有的单个元素添加循环来处理剩下的几个元素。
对于大的 BitIntegers,您将花费大部分时间在展开的部分,现在应该执行得更快。
如果你想要它更快,那么再写七个专门用于剩余元素计数的块,并根据元素计数分支到它们。这最好通过将七个地址存储在查找 table 中,从中加载地址并直接跳转到专用代码来完成。
对于小元素计数,这将完全删除整个循环,而对于大元素,您将获得展开循环的全部好处。
您在旧 P6 系列 CPUs 上看到的是部分标志失速。
早期的 Sandybridge-family 处理合并更有效,后来的 SnB-family(例如 Skylake)根本没有合并成本:uops that need both CF and some flags from the SPAZO group read them as 2 separate inputs.
Intel CPUs(P4 除外)分别重命名每个标志位,因此 JNE
仅取决于设置其使用的所有标志的最后一条指令(在这种情况下,仅 Z
标志)。事实上,最近的 Intel CPUs 甚至可以 (宏融合)。但是,当读取最后一条更新任何标志的指令未修改的标志位时,问题就来了。
Agner Fog 表示英特尔 CPUs(甚至 PPro / PII)不会在 inc / jnz
上停止。实际上不是 inc/jnz
在拖延,而是下一次迭代中的 adc
在 inc
写入其他标志但离开 CF
之后必须读取 CF
标志未修改。
; Example 5.21. Partial flags stall when reading unmodified flag bits
cmp eax, ebx
inc ecx
jc xx
; Partial flags stall (P6 / PIII / PM / Core2 / Nehalem)
Agner Fog 也更笼统地说:“避免依赖于 INC 或 DEC 保持进位标志不变的代码。” (对于奔腾 M/Core2/Nehalem)。完全避免 inc
/dec
的建议已过时,仅适用于 P4。其他 CPUs 分别重命名 EFLAGS 的不同部分,并且只有在需要合并时才会出问题(读取一个被最后一个 insn 未修改的标志来写入任何标志)。
在速度很快的机器上(Sandybridge 及更高版本),当您读取未由修改它的最后一条指令写入的位时,他们会插入一个额外的 uop 来合并标志寄存器。这比停止 7 个周期 快得多,但仍然不理想。
P4 始终跟踪整个寄存器,而不是重命名部分寄存器,甚至 EFLAGS 也不行。所以 inc/jz
对它之前写的标志有一个“错误”的依赖。这意味着在 adc
dep 链的执行到达之前,循环条件无法检测到循环结束,因此无法及早检测到在停止采用循环分支时可能发生的分支预测错误.不过,它确实可以防止任何部分标志停顿。
您的 lea / jecxz
很好地避免了这个问题。它在 SnB 上较慢,后来因为您根本没有展开循环。您的 LEA 版本是 11 uops(每 3 个周期可以发出一次迭代),而 inc
版本是 7 uops(每 2 个周期可以发出一个 iter),不包括它插入的标志合并 uop 而不是停止。
如果the loop
instruction wasn't slow,就完美了。它实际上在 AMD Bulldozer 系列(1 m-op,与融合比较和分支的成本相同)和 Via Nano3000 上速度很快。不过,它对所有 Intel CPUs 都不好(在 SnB 系列上为 7 微指令)。
展开
展开时,使用指针而不是索引寻址模式可以获得另一个小好处,because 2-reg addressing modes can't micro-fuse on SnB and later。一组加载/adc
/存储指令在没有微融合的情况下是 6 微指令,但只有 4 微融合。 CPUs 可以发出 4 个融合域 uops/clock。 (有关此级别的详细信息,请参阅 Agner Fog 的 CPU 微架构文档和指令表。)
尽可能保存 uops 以确保 CPU 可以比执行更快地发出指令,以确保它可以在指令流中看得足够远以吸收 insn fetch 中的任何气泡(例如分支错误预测).安装在 28uop 循环缓冲区中也意味着节能(在 Nehalem 上,避免指令解码瓶颈。)指令对齐和跨越 uop 缓存行边界等事情使得在没有循环的情况下很难维持完整的 4 uops /时钟缓冲区也是。
另一个技巧是将指针指向缓冲区的末尾,并向零计数。 (所以在你的循环开始时,你得到的第一项是 end[-idx]
。)
; pure loads are always one uop, so we can still index it
; with no perf hit on SnB
add esi, ecx ; point to end of src1
neg ecx
UNROLL equ 4
@MainLoop:
MOV EAX, [ESI + 0*CLimbSize + ECX*CLimbSize]
ADC EAX, [EDI + 0*CLimbSize]
MOV [EBX + 0*CLimbSize], EAX
MOV EAX, [ESI + 1*CLimbSize + ECX*CLimbSize]
ADC EAX, [EDI + 1*CLimbSize]
MOV [EBX + 1*CLimbSize], EAX
; ... repeated UNROLL times. Use an assembler macro to repeat these 3 instructions with increasing offsets
LEA ECX, [ECX+UNROLL] ; loop counter
LEA EDI, [EDI+ClimbSize*UNROLL] ; Unrolling makes it worth doing
LEA EBX, [EBX+ClimbSize*UNROLL] ; a separate increment to save a uop for every ADC and store on SnB & later.
JECXZ @DoRestLoop // LEA does not modify Zero flag, so JECXZ is used.
JMP @MainLoop
@DoRestLoop:
展开 4 应该不错。没必要做得太过分,因为你很可能。只需展开 3 或 4 个,甚至可能是 2 个,就可以使 Haswell 之前的 load/store 端口饱和。
对于 Intel CPUs,2 的展开将使上述循环正好是 14 个融合域微指令。 adc
是2个ALU(+1个融合内存),jecxz
是2个,其余(包括LEA)都是1。在unfused域中,10个ALU/branch和6个内存(嗯, 8内存,如果你真的把存储地址和存储数据分开算)。
- 每次迭代 14 个融合域微指令:每 4 个时钟发出一次迭代。 (末尾的奇数 2 微指令必须作为一组 2 发出,即使来自循环缓冲区。)
- 10 ALU 和分支 uops:需要 3.33c 在 pre-haswell 上执行它们。我认为任何一个端口都不会成为瓶颈:
adc
的 uops 可以在任何端口上 运行,而 lea
可以在 [=140= 上 运行 ].跳转使用端口 5(jecx 也使用 p0/p1 之一)
- 6 次内存操作:需要 3c 才能在 Pre-Haswell CPUs 上执行,每个时钟可以处理 2 次。 Haswell 为存储添加了一个专用 AGU,因此它可以维持 2load+1store/clock。
因此对于 pre-haswell CPUs,使用 LEA/JECXZ,2 的展开不会使 ALU 或 load/store 端口完全饱和。展开 4 将使其达到 22 个融合微指令(6 个周期发出)。 14 ALU&branch: 4.66c 执行。 12 内存:执行 6 个周期。所以 4 的展开将饱和 pre-Haswell CPUs,但只是勉强。 CPU 将没有任何指令缓冲区可以在分支预测错误的情况下搅动。
Haswell 和更高版本将始终在前端成为瓶颈(每个时钟限制 4 微指令),因为加载/adc
/存储组合需要 4 微指令,并且可以维持在每个时钟一个。因此,在不削减 adc
吞吐量的情况下,循环开销永远不会有任何“空间”。这是你必须知道不要过度和展开太多的地方。
在 Broadwell / Skylake 上, adc m, r/i
是 4 微指令。这应该每个时钟维持一个 adc,就像 AMD。
在 AMD CPUs 上,adc
只是一个宏操作,所以如果 CPU 可以维持 4 的发布率(即没有解码瓶颈),那么他们也可以使用他们的 2 个加载/1 个存储端口来击败 Haswell。此外,AMD 上的 jecxz
与任何其他分支一样高效:只有一个宏操作。多精度数学是 AMD CPU 为数不多的擅长的事情之一。某些整数指令的较低延迟使其在某些 GMP 例程中具有优势。
展开超过 5 可能会影响 Nehalem 的性能,因为这会使循环大于 28uop 循环缓冲区。然后,指令解码会将您限制在每个时钟少于 4 微指令。在更早的版本 (Core2) 上,有一个 64B x86 指令循环缓冲区(64B 的 x86 代码,不是 uops),这有助于一些解码。
除非这个 adc
例程是您应用程序中的唯一瓶颈,否则我会将展开因子保持在 2 左右。或者甚至可能不展开,如果这样可以节省大量序言/结尾代码,而且您的 BigInts 不是太大。当调用者调用许多不同的 BigInteger 函数(如 add、sub、mul 以及在两者之间执行其他操作)时,您不想让代码膨胀太多并造成缓存未命中。如果您的程序在每次调用时没有在内部循环中花费很长时间,那么为了在微基准测试中取胜而展开太多可能会搬起石头砸自己的脚。
如果您的 BigInt 值通常不是很大,那么您需要调整的不仅仅是循环。较小的展开可能有助于简化 prologue/epilogue 逻辑。当然,确保检查长度,这样 ECX 就不会越过零而永远不会为零。这是展开和向量的问题。 :/
为旧 CPU 保存/恢复 CF
,而不是无标志循环:
这可能是最有效的方法:
lahf
# clobber flags
sahf ; cheap on AMD and Intel. This doesn't restore OF, but we only care about CF
# or
setc al
# clobber flags
add al, 255 ; generate a carry if al is non-zero
使用与 adc dep 链相同的寄存器实际上不是问题:eax
将始终与最后一个 adc
的 CF
输出同时准备就绪. (在 AMD 和 P4/Silvermont partial-reg writes 上有一个 false dep on the full reg。他们不单独重命名部分 reg。 save/restore 是 adc dep 链的一部分,而不是循环条件 dep 链。
循环条件只检查由cmp
、sub
或dec
写入的标志。它周围的 Saving/restoring 标志不会使其成为 adc
dep 链的一部分,因此可以在 adc
执行到达之前检测到循环末尾的分支预测错误。 (此答案的先前版本弄错了。)
几乎可以肯定有一些空间可以削减设置代码中的指令,也许通过使用值开始的寄存器。您 不必 将 edi 和 esi 用作指针,尽管我知道当您以与其“传统”用途一致的方式使用寄存器时,这会使初始开发更容易。 (例如 EDI 中的目标指针)。
Delphi可以使用ebp
吗?很高兴有第 7 个寄存器。
显然 64 位代码会使您的 BigInt 代码 运行 快两倍,即使您不得不担心在 64 位循环结束时执行单个 32b adc
adc
。它还会给你 2 倍的寄存器数量。
我正在 Delphi 中编写一个简单的 BigInteger 类型。它主要由一个TLimb的动态数组组成,其中一个TLimb是一个32位的无符号整数,和一个32位的size field,其中也保存着BigInteger的符号位。
为了添加两个 BigInteger,我创建了一个适当大小的新 BigInteger,然后在一些簿记之后调用以下过程,将三个指针传递给左右操作数和结果,以及左右肢体的数量。
明码:
class procedure BigInteger.PlainAdd(Left, Right, Result: PLimb; LSize, RSize: Integer);
asm
// EAX = Left, EDX = Right, ECX = Result
PUSH ESI
PUSH EDI
PUSH EBX
MOV ESI,EAX // Left
MOV EDI,EDX // Right
MOV EBX,ECX // Result
MOV ECX,RSize // Number of limbs at Left
MOV EDX,LSize // Number of limbs at Right
CMP EDX,ECX
JAE @SkipSwap
XCHG ECX,EDX // Left and LSize should be largest
XCHG ESI,EDI // so swap
@SkipSwap:
SUB EDX,ECX // EDX contains rest
PUSH EDX // ECX contains smaller size
XOR EDX,EDX
@MainLoop:
MOV EAX,[ESI + CLimbSize*EDX] // CLimbSize = SizeOf(TLimb) = 4.
ADC EAX,[EDI + CLimbSize*EDX]
MOV [EBX + CLimbSize*EDX],EAX
INC EDX
DEC ECX
JNE @MainLoop
POP EDI
INC EDI // Do not change Carry Flag
DEC EDI
JE @LastLimb
@RestLoop:
MOV EAX,[ESI + CLimbSize*EDX]
ADC EAX,ECX
MOV [EBX + CLimbSize*EDX],EAX
INC EDX
DEC EDI
JNE @RestLoop
@LastLimb:
ADC ECX,ECX // Add in final carry
MOV [EBX + CLimbSize*EDX],ECX
@Exit:
POP EBX
POP EDI
POP ESI
end;
// RET is inserted by Delphi compiler.
这段代码运行良好,我对它非常满意,直到我注意到,在我的开发设置(iMac 上的 Parallels VM 中的 Win7)上,一个简单的 PURE PASCAL 加法例程,在模拟携带一个变量和一些 if
子句,比我简单、直接的手工汇编程序例程快。
我花了一段时间才发现在某些 CPU 上(包括我的 iMac 和旧笔记本电脑),DEC
或 INC
和 ADC
或 [= 的组合20=] 可能会非常慢。但是在我的其他大多数电脑上(我还有五台其他 PC 来测试它,尽管其中四台完全相同),速度相当快。
所以我写了一个新版本,使用 LEA
和 JECXZ
来模拟 INC
和 DEC
,就像这样:
部分仿真代码:
@MainLoop:
MOV EAX,[ESI + EDX*CLimbSize]
LEA ECX,[ECX - 1] // Avoid INC and DEC, see above.
ADC EAX,[EDI + EDX*CLimbSize]
MOV [EBX + EDX*CLimbSize],EAX
LEA EDX,[EDX + 1]
JECXZ @DoRestLoop // LEA does not modify Zero flag, so JECXZ is used.
JMP @MainLoop
@DoRestLoop:
// similar code for the rest loop
这使得我的代码在“慢”机器上的速度几乎快了三倍,但在“快”机器上慢了大约 20%。所以现在,作为初始化代码,我做了一个简单的定时循环,并用它来决定我是否要设置单元来调用普通例程或模拟例程。这 几乎 总是正确的,但有时当它应该选择模拟例程时它会选择(较慢的)普通例程。
但我不知道这是否是最好的方法。
问题
我给出了我的解决方案,但是这里的 asm 专家是否知道避免某些 CPU 速度缓慢的更好方法?
更新
Peter 和 Nils 的回答对我走上正轨有很大帮助。这是我针对 DEC
版本的最终解决方案的主要部分:
明码:
class procedure BigInteger.PlainAdd(Left, Right, Result: PLimb; LSize, RSize: Integer);
asm
PUSH ESI
PUSH EDI
PUSH EBX
MOV ESI,EAX // Left
MOV EDI,EDX // Right
MOV EBX,ECX // Result
MOV ECX,RSize
MOV EDX,LSize
CMP EDX,ECX
JAE @SkipSwap
XCHG ECX,EDX
XCHG ESI,EDI
@SkipSwap:
SUB EDX,ECX
PUSH EDX
XOR EDX,EDX
XOR EAX,EAX
MOV EDX,ECX
AND EDX,[=14=]000003
SHR ECX,2
CLC
JE @MainTail
@MainLoop:
// Unrolled 4 times. More times will not improve speed anymore.
MOV EAX,[ESI]
ADC EAX,[EDI]
MOV [EBX],EAX
MOV EAX,[ESI + CLimbSize]
ADC EAX,[EDI + CLimbSize]
MOV [EBX + CLimbSize],EAX
MOV EAX,[ESI + 2*CLimbSize]
ADC EAX,[EDI + 2*CLimbSize]
MOV [EBX + 2*CLimbSize],EAX
MOV EAX,[ESI + 3*CLimbSize]
ADC EAX,[EDI + 3*CLimbSize]
MOV [EBX + 3*CLimbSize],EAX
// Update pointers.
LEA ESI,[ESI + 4*CLimbSize]
LEA EDI,[EDI + 4*CLimbSize]
LEA EBX,[EBX + 4*CLimbSize]
// Update counter and loop if required.
DEC ECX
JNE @MainLoop
@MainTail:
// Add index*CLimbSize so @MainX branches can fall through.
LEA ESI,[ESI + EDX*CLimbSize]
LEA EDI,[EDI + EDX*CLimbSize]
LEA EBX,[EBX + EDX*CLimbSize]
// Indexed jump.
LEA ECX,[@JumpsMain]
JMP [ECX + EDX*TYPE Pointer]
// Align jump table manually, with NOPs. Update if necessary.
NOP
// Jump table.
@JumpsMain:
DD @DoRestLoop
DD @Main1
DD @Main2
DD @Main3
@Main3:
MOV EAX,[ESI - 3*CLimbSize]
ADC EAX,[EDI - 3*CLimbSize]
MOV [EBX - 3*CLimbSize],EAX
@Main2:
MOV EAX,[ESI - 2*CLimbSize]
ADC EAX,[EDI - 2*CLimbSize]
MOV [EBX - 2*CLimbSize],EAX
@Main1:
MOV EAX,[ESI - CLimbSize]
ADC EAX,[EDI - CLimbSize]
MOV [EBX - CLimbSize],EAX
@DoRestLoop:
// etc...
我去掉了很多白space,估计reader可以搞定剩下的套路。它类似于主循环。速度提高约。较大的 BigIntegers 为 20%,较小的 BigIntegers 为 10%(只有几个肢体)。
64 位版本现在尽可能使用 64 位加法(在主循环和 Main3 和 Main2 中,它们不像上面那样“失败”)和以前,64 位比 32 慢很多位,但现在它比 32 位快 30%,比原来的简单 64 位循环快两倍。
更新 2
Intel 在其 Intel 64 和 IA-32 架构优化参考手册 中提出,3.5.2.6 部分标志寄存器停顿 -- 示例 3-29:
XOR EAX,EAX
.ALIGN 16
@MainLoop:
ADD EAX,[ESI] // Sets all flags, so no partial flag register stall
ADC EAX,[EDI] // ADD added in previous carry, so its result might have carry
MOV [EBX],EAX
MOV EAX,[ESI + CLimbSize]
ADC EAX,[EDI + CLimbSize]
MOV [EBX + CLimbSize],EAX
MOV EAX,[ESI + 2*CLimbSize]
ADC EAX,[EDI + 2*CLimbSize]
MOV [EBX + 2*CLimbSize],EAX
MOV EAX,[ESI + 3*CLimbSize]
ADC EAX,[EDI + 3*CLimbSize]
MOV [EBX + 3*CLimbSize],EAX
SETC AL // Save carry for next iteration
MOVZX EAX,AL
ADD ESI,CUnrollIncrement*CLimbSize // LEA has slightly worse latency
ADD EDI,CUnrollIncrement*CLimbSize
ADD EBX,CUnrollIncrement*CLimbSize
DEC ECX
JNZ @MainLoop
标志保存在AL
中,并通过MOVZX
保存在EAX
中。它是通过循环中的第一个 ADD
添加的。然后需要一个 ADC
,因为 ADD
可能会产生一个进位。另见评论。
因为进位保存在EAX
,我也可以用ADD
来更新指针。循环中的第一个 ADD
也会更新所有标志,因此 ADC
不会受到部分标志寄存器停顿的影响。
有如此多的 x86 芯片,它们的使用时序差异很大,实际上您不可能为所有这些芯片都提供最佳代码。您在使用前拥有两个已知的良好功能和基准的方法已经非常先进。
但是,根据 BigInteger 的大小,您可能可以通过简单的循环展开来改进代码。这将大大消除循环开销。
例如你可以执行一个专门的块来添加八个整数,如下所示:
@AddEight:
MOV EAX,[ESI + EDX*CLimbSize + 0*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 0*CLimbSize]
MOV [EBX + EDX*CLimbSize + 0*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 1*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 1*CLimbSize]
MOV [EBX + EDX*CLimbSize + 1*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 2*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 2*CLimbSize]
MOV [EBX + EDX*CLimbSize + 2*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 3*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 3*CLimbSize]
MOV [EBX + EDX*CLimbSize + 3*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 4*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 4*CLimbSize]
MOV [EBX + EDX*CLimbSize + 4*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 5*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 5*CLimbSize]
MOV [EBX + EDX*CLimbSize + 5*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 6*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 6*CLimbSize]
MOV [EBX + EDX*CLimbSize + 6*CLimbSize],EAX
MOV EAX,[ESI + EDX*CLimbSize + 7*CLimbSize]
ADC EAX,[EDI + EDX*CLimbSize + 7*CLimbSize]
MOV [EBX + EDX*CLimbSize + 7*CLimbSize],EAX
LEA ECX,[ECX - 8]
现在你重建你的循环,只要你有超过 8 个元素要处理就执行上面的块,并使用你已有的单个元素添加循环来处理剩下的几个元素。
对于大的 BitIntegers,您将花费大部分时间在展开的部分,现在应该执行得更快。
如果你想要它更快,那么再写七个专门用于剩余元素计数的块,并根据元素计数分支到它们。这最好通过将七个地址存储在查找 table 中,从中加载地址并直接跳转到专用代码来完成。
对于小元素计数,这将完全删除整个循环,而对于大元素,您将获得展开循环的全部好处。
您在旧 P6 系列 CPUs 上看到的是部分标志失速。
早期的 Sandybridge-family 处理合并更有效,后来的 SnB-family(例如 Skylake)根本没有合并成本:uops that need both CF and some flags from the SPAZO group read them as 2 separate inputs.
Intel CPUs(P4 除外)分别重命名每个标志位,因此 JNE
仅取决于设置其使用的所有标志的最后一条指令(在这种情况下,仅 Z
标志)。事实上,最近的 Intel CPUs 甚至可以
Agner Fog 表示英特尔 CPUs(甚至 PPro / PII)不会在 inc / jnz
上停止。实际上不是 inc/jnz
在拖延,而是下一次迭代中的 adc
在 inc
写入其他标志但离开 CF
之后必须读取 CF
标志未修改。
; Example 5.21. Partial flags stall when reading unmodified flag bits
cmp eax, ebx
inc ecx
jc xx
; Partial flags stall (P6 / PIII / PM / Core2 / Nehalem)
Agner Fog 也更笼统地说:“避免依赖于 INC 或 DEC 保持进位标志不变的代码。” (对于奔腾 M/Core2/Nehalem)。完全避免 inc
/dec
的建议已过时,仅适用于 P4。其他 CPUs 分别重命名 EFLAGS 的不同部分,并且只有在需要合并时才会出问题(读取一个被最后一个 insn 未修改的标志来写入任何标志)。
在速度很快的机器上(Sandybridge 及更高版本),当您读取未由修改它的最后一条指令写入的位时,他们会插入一个额外的 uop 来合并标志寄存器。这比停止 7 个周期 快得多,但仍然不理想。
P4 始终跟踪整个寄存器,而不是重命名部分寄存器,甚至 EFLAGS 也不行。所以 inc/jz
对它之前写的标志有一个“错误”的依赖。这意味着在 adc
dep 链的执行到达之前,循环条件无法检测到循环结束,因此无法及早检测到在停止采用循环分支时可能发生的分支预测错误.不过,它确实可以防止任何部分标志停顿。
您的 lea / jecxz
很好地避免了这个问题。它在 SnB 上较慢,后来因为您根本没有展开循环。您的 LEA 版本是 11 uops(每 3 个周期可以发出一次迭代),而 inc
版本是 7 uops(每 2 个周期可以发出一个 iter),不包括它插入的标志合并 uop 而不是停止。
如果the loop
instruction wasn't slow,就完美了。它实际上在 AMD Bulldozer 系列(1 m-op,与融合比较和分支的成本相同)和 Via Nano3000 上速度很快。不过,它对所有 Intel CPUs 都不好(在 SnB 系列上为 7 微指令)。
展开
展开时,使用指针而不是索引寻址模式可以获得另一个小好处,because 2-reg addressing modes can't micro-fuse on SnB and later。一组加载/adc
/存储指令在没有微融合的情况下是 6 微指令,但只有 4 微融合。 CPUs 可以发出 4 个融合域 uops/clock。 (有关此级别的详细信息,请参阅 Agner Fog 的 CPU 微架构文档和指令表。)
尽可能保存 uops 以确保 CPU 可以比执行更快地发出指令,以确保它可以在指令流中看得足够远以吸收 insn fetch 中的任何气泡(例如分支错误预测).安装在 28uop 循环缓冲区中也意味着节能(在 Nehalem 上,避免指令解码瓶颈。)指令对齐和跨越 uop 缓存行边界等事情使得在没有循环的情况下很难维持完整的 4 uops /时钟缓冲区也是。
另一个技巧是将指针指向缓冲区的末尾,并向零计数。 (所以在你的循环开始时,你得到的第一项是 end[-idx]
。)
; pure loads are always one uop, so we can still index it
; with no perf hit on SnB
add esi, ecx ; point to end of src1
neg ecx
UNROLL equ 4
@MainLoop:
MOV EAX, [ESI + 0*CLimbSize + ECX*CLimbSize]
ADC EAX, [EDI + 0*CLimbSize]
MOV [EBX + 0*CLimbSize], EAX
MOV EAX, [ESI + 1*CLimbSize + ECX*CLimbSize]
ADC EAX, [EDI + 1*CLimbSize]
MOV [EBX + 1*CLimbSize], EAX
; ... repeated UNROLL times. Use an assembler macro to repeat these 3 instructions with increasing offsets
LEA ECX, [ECX+UNROLL] ; loop counter
LEA EDI, [EDI+ClimbSize*UNROLL] ; Unrolling makes it worth doing
LEA EBX, [EBX+ClimbSize*UNROLL] ; a separate increment to save a uop for every ADC and store on SnB & later.
JECXZ @DoRestLoop // LEA does not modify Zero flag, so JECXZ is used.
JMP @MainLoop
@DoRestLoop:
展开 4 应该不错。没必要做得太过分,因为你很可能。只需展开 3 或 4 个,甚至可能是 2 个,就可以使 Haswell 之前的 load/store 端口饱和。
对于 Intel CPUs,2 的展开将使上述循环正好是 14 个融合域微指令。 adc
是2个ALU(+1个融合内存),jecxz
是2个,其余(包括LEA)都是1。在unfused域中,10个ALU/branch和6个内存(嗯, 8内存,如果你真的把存储地址和存储数据分开算)。
- 每次迭代 14 个融合域微指令:每 4 个时钟发出一次迭代。 (末尾的奇数 2 微指令必须作为一组 2 发出,即使来自循环缓冲区。)
- 10 ALU 和分支 uops:需要 3.33c 在 pre-haswell 上执行它们。我认为任何一个端口都不会成为瓶颈:
adc
的 uops 可以在任何端口上 运行,而lea
可以在 [=140= 上 运行 ].跳转使用端口 5(jecx 也使用 p0/p1 之一) - 6 次内存操作:需要 3c 才能在 Pre-Haswell CPUs 上执行,每个时钟可以处理 2 次。 Haswell 为存储添加了一个专用 AGU,因此它可以维持 2load+1store/clock。
因此对于 pre-haswell CPUs,使用 LEA/JECXZ,2 的展开不会使 ALU 或 load/store 端口完全饱和。展开 4 将使其达到 22 个融合微指令(6 个周期发出)。 14 ALU&branch: 4.66c 执行。 12 内存:执行 6 个周期。所以 4 的展开将饱和 pre-Haswell CPUs,但只是勉强。 CPU 将没有任何指令缓冲区可以在分支预测错误的情况下搅动。
Haswell 和更高版本将始终在前端成为瓶颈(每个时钟限制 4 微指令),因为加载/adc
/存储组合需要 4 微指令,并且可以维持在每个时钟一个。因此,在不削减 adc
吞吐量的情况下,循环开销永远不会有任何“空间”。这是你必须知道不要过度和展开太多的地方。
在 Broadwell / Skylake 上,adc m, r/i
是 4 微指令。这应该每个时钟维持一个 adc,就像 AMD。
在 AMD CPUs 上,adc
只是一个宏操作,所以如果 CPU 可以维持 4 的发布率(即没有解码瓶颈),那么他们也可以使用他们的 2 个加载/1 个存储端口来击败 Haswell。此外,AMD 上的 jecxz
与任何其他分支一样高效:只有一个宏操作。多精度数学是 AMD CPU 为数不多的擅长的事情之一。某些整数指令的较低延迟使其在某些 GMP 例程中具有优势。
展开超过 5 可能会影响 Nehalem 的性能,因为这会使循环大于 28uop 循环缓冲区。然后,指令解码会将您限制在每个时钟少于 4 微指令。在更早的版本 (Core2) 上,有一个 64B x86 指令循环缓冲区(64B 的 x86 代码,不是 uops),这有助于一些解码。
除非这个 adc
例程是您应用程序中的唯一瓶颈,否则我会将展开因子保持在 2 左右。或者甚至可能不展开,如果这样可以节省大量序言/结尾代码,而且您的 BigInts 不是太大。当调用者调用许多不同的 BigInteger 函数(如 add、sub、mul 以及在两者之间执行其他操作)时,您不想让代码膨胀太多并造成缓存未命中。如果您的程序在每次调用时没有在内部循环中花费很长时间,那么为了在微基准测试中取胜而展开太多可能会搬起石头砸自己的脚。
如果您的 BigInt 值通常不是很大,那么您需要调整的不仅仅是循环。较小的展开可能有助于简化 prologue/epilogue 逻辑。当然,确保检查长度,这样 ECX 就不会越过零而永远不会为零。这是展开和向量的问题。 :/
为旧 CPU 保存/恢复 CF
,而不是无标志循环:
这可能是最有效的方法:
lahf
# clobber flags
sahf ; cheap on AMD and Intel. This doesn't restore OF, but we only care about CF
# or
setc al
# clobber flags
add al, 255 ; generate a carry if al is non-zero
使用与 adc dep 链相同的寄存器实际上不是问题:eax
将始终与最后一个 adc
的 CF
输出同时准备就绪. (在 AMD 和 P4/Silvermont partial-reg writes 上有一个 false dep on the full reg。他们不单独重命名部分 reg。 save/restore 是 adc dep 链的一部分,而不是循环条件 dep 链。
循环条件只检查由cmp
、sub
或dec
写入的标志。它周围的 Saving/restoring 标志不会使其成为 adc
dep 链的一部分,因此可以在 adc
执行到达之前检测到循环末尾的分支预测错误。 (此答案的先前版本弄错了。)
几乎可以肯定有一些空间可以削减设置代码中的指令,也许通过使用值开始的寄存器。您 不必 将 edi 和 esi 用作指针,尽管我知道当您以与其“传统”用途一致的方式使用寄存器时,这会使初始开发更容易。 (例如 EDI 中的目标指针)。
Delphi可以使用ebp
吗?很高兴有第 7 个寄存器。
显然 64 位代码会使您的 BigInt 代码 运行 快两倍,即使您不得不担心在 64 位循环结束时执行单个 32b adc
adc
。它还会给你 2 倍的寄存器数量。