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 和旧笔记本电脑),DECINCADC 或 [= 的组合20=] 可能会非常慢。但是在我的其他大多数电脑上(我还有五台其他 PC 来测试它,尽管其中四台完全相同),速度相当快。

所以我写了一个新版本,使用 LEAJECXZ 来模拟 INCDEC,就像这样:

部分仿真代码:

@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 在拖延,而是下一次迭代中的 adcinc 写入其他标志但离开 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 将始终与最后一个 adcCF 输出同时准备就绪. (在 AMD 和 P4/Silvermont partial-reg writes 上有一个 false dep on the full reg。他们不单独重命名部分 reg。 save/restore 是 adc dep 链的一部分,而不是循环条件 dep 链。

循环条件只检查由cmpsubdec写入的标志。它周围的 Saving/restoring 标志不会使其成为 adc dep 链的一部分,因此可以在 adc 执行到达之前检测到循环末尾的分支预测错误。 (此答案的先前版本弄错了。)


几乎可以肯定有一些空间可以削减设置代码中的指令,也许通过使用值开始的寄存器。您 不必 将 edi 和 esi 用作指针,尽管我知道当您以与其“传统”用途一致的方式使用寄存器时,这会使初始开发更容易。 (例如 EDI 中的目标指针)。

Delphi可以使用ebp吗?很高兴有第 7 个寄存器。

显然 64 位代码会使您的 BigInt 代码 运行 快两倍,即使您不得不担心在 64 位循环结束时执行单个 32b adc adc。它还会给你 2 倍的寄存器数量。