在某些CPU的紧密循环中出现ADC / SBB和INC / DEC问题

时间:2015-08-18 23:25:30

标签: delphi assembly x86

我在Delphi中编写一个简单的BigInteger类型。它主要由TLimb的动态数组组成,其中TLimb是32位无符号整数,32位大小字段,它还保存BigInteger的符号位。

要添加两个BigIntegers,我创建一个适当大小的新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或{{1}的组合花了我一段时间可能会非常慢。但是对于我的大多数其他人(我有五台其他PC来测试它,虽然其中有四台完全相同),但速度非常快。

所以我写了一个新版本,使用SBBINC来模仿DECLEA,如下所示:

模拟代码的一部分

JECXZ

这使我在“慢”机器上的代码几乎快了三倍,但在“更快”的机器上慢了20%。所以现在,作为初始化代码,我做一个简单的时序循环,并使用它来决定我是否将设置单元来调用普通或模拟例程。这几乎始终是正确的,但有时它选择(较慢的)普通例程应该选择模拟例程。

但我不知道这是否是最好的方法。

问题

我给出了我的解决方案,但是这里的asm大师可能知道更好的方法来避免某些CPU的缓慢吗?

更新

彼得和尼尔斯的回答帮助我走上了正轨。这是我@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 版本的最终解决方案的主要部分:

普通代码:

DEC

我删除了很多空白区域,我想读者可以完成剩下的日常工作。它类似于主循环。速度提高约。对于较大的BigIntegers,20%,对于小型BigIntegers,只有10%(仅有几个肢体)。

64位版本现在尽可能使用64位加法(在主循环中以及Main3和Main2中,它们不像上面那样“直通”)以前,64位比32位慢很多,但现在它比32位快30%,是原始简单64位循环的两倍。

更新2

英特尔在其 Intel 64和IA-32架构优化参考手册中提出 3.5.2.6部分标志寄存器停顿 - 示例3-29

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,$00000003
        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...    

该标记保存在 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不会受到部分标志寄存器停顿的影响。

2 个答案:

答案 0 :(得分:18)

你所看到的是部分旗帜失速。

Intel CPU(P4除外)分别重命名每个标志位,因此JNE仅取决于设置其使用的所有标志的最后一条指令(在本例中,只是Z标志)。事实上,最近的英特尔CPU甚至可以internally combine an inc/jne into a single inc-and-branch uop(宏融合)。但是,当读取更新任何标志的最后一条指令未修改的标志位时,会出现问题。

Agner Fog表示英特尔CPU(甚至PPro / PII)不会在inc / jnz停滞不前。它实际上不是inc/jnz拖延,它是下一次迭代中adc必须在{{1}之后读取CF标志的inc写了其他标志但未修改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使进位标志保持不变的事实。" (适用于Pentium M / Core2 / Nehalem)。完全避免inc / dec的建议已过时,仅适用于P4。其他CPU分别重命名EFLAGS的不同部分,并且只在需要合并时才会遇到麻烦(读取上一个insn未修改的标志以写入任何标志)。

在速度很快的机器上(Sandybridge及其后),当你读取最后修改的指令没有写入的位时,他们会插入一个额外的uop来合并标志寄存器它。这比停顿7个周期更快 ,但仍不理想。

P4总是跟踪整个寄存器,而不是重命名部分寄存器,甚至不是EFLAGS。所以inc/jz有一个" false"依赖于在它之前写下标志的东西。这意味着循环条件不能检测循环的结束,直到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 CPU上都很糟糕(SnB系列上有7个uop)。

开卷

展开时,使用指针而不是索引寻址模式because 2-reg addressing modes can't micro-fuse on SnB and later可以获得另一个小的收益。一组加载/ adc /存储指令是没有微融合的6个uop,但只有4个微融合。 CPU可以发出4个融合域uops / clock。 (有关此级别的详细信息,请参阅Agner Fog的CPU微架文档和指令表。)

当你可以确保CPU可以比执行更快地发出指令时保存uops,以确保它能够在指令流中看得足够远以吸收insn fetch中的任何气泡(例如,分支错误预测)。安装在28uop环路缓冲器中也意味着省电(并且在Nehalem上,避免了指令解码瓶颈。)有些指令对齐和交叉uop缓存线边界使得很难在没有环路的情况下维持完整的4 uops / clock也是缓冲区。

另一个技巧是保持指向缓冲区末尾的指针,并向上计数到零。 (因此,在循环开始时,您将第一个项目设为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的展开应该是好的。不需要过度,因为你很难。能够使Haswell之前的加载/存储端口饱和,只需3或4,甚至2。

2的展开将使上述循环恰好为Intel CPU提供14个融合域uop。 adc是2 ALU(+1融合内存),jecxz是2,其余(包括LEA)都是1.在未融合域中,10 ALU /分支和6内存(井,8内存)如果你真的分别计算商店地址和商店数据。)

  • 每次迭代14个融合域uops:每4个时钟发出一次迭代。 (最后的奇数2个uop必须作为一组2发出,即使是从循环缓冲区发出。)
  • 10 ALU& branch uops:需要3.33c才能在pre-haswell上执行它们。我认为任何一个端口都不会成为瓶颈:adc uops可以在任何端口上运行,而lea可以在p0 / p1上运行。跳转使用port5(并且jecx也使用p0 / p1之一)
  • 6个内存操作:在Haswell前CPU上执行3c,每个时钟可处理2个。 Haswell为商店添加了专用的AGU,因此它可以维持2load + 1store / clock。

因此,对于预先存在的CPU,使用LEA / JECXZ,2韩元的展开会使ALU或加载/存储端口完全饱和。展开4将使其达到22个融合的uop(发布6个周期)。 14 ALU&分支:4.66c执行。 12个内存:执行6个周期。所以4的展开将使Haswell CPU前期饱和,但只是勉强。 CPU不会有任何指令缓冲区在分支误预测上流失。

Haswell及以后将始终在前端瓶颈(每个时钟限制4个uop),因为加载/ adc / store组合需要4个uop,并且每个时钟可以维持一个。所以从来没有任何"房间"用于循环开销而不削减adc吞吐量。这是您必须知道不要过度使用并展开太多的地方。

在Broadwell / Skylake上,adc is only a single uop with 1c latency, and load / adc r, m / store appears to be the best sequence. adc m, r/i是4 uops。这应该支持每个时钟一个adc,比如AMD。

在AMD CPU上,adc只是一个宏操作,所以如果CPU可以维持4的发布率(即没有解码瓶颈),那么他们也可以使用他们的2个load / 1存储端口来哈斯威尔。此外,AMD上的jecxz与任何其他分支一样高效:只有一个宏操作。多精度数学是AMD CPU擅长的少数几项工作之一。某些整数指令的较低延迟使它们在某些GMP例程中具有优势。

超过5的展开可能会损害Nehalem的性能,因为这会使循环大于28uop循环缓冲区。然后,指令解码会限制每个时钟少于4个uop。甚至更早(Core2),有一个64B x86指令循环缓冲区(x86代码的64B,而不是uops),这有助于解密。

除非此adc例程是您应用中唯一的瓶颈,否则我会将展开因素降至2或者甚至不展开,如果这样可以节省大量的序言/结尾代码,而你的BigInts并不算大。当调用者调用许多不同的BigInteger函数(如add,sub,mul)并在其间执行其他操作时,您不希望过多地膨胀代码并创建缓存未命中。如果您的程序在每次通话中都没有花费很长时间在内循环中,那么展开太多以赢得微基准测试可能会让自己陷入困境。

如果您的BigInt值通常不是很大,那么它不仅仅是您需要调整的循环。较小的展开可能有助于简化序言/结语逻辑。当然,确保检查长度,以便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将始终与上一个CF的{​​{1}}输出同时准备就绪。 (在AMD和P4 / Silvermont上,部分注册对整个注册表有误判。他们不会单独重命名部分注册表)。保存/恢复是adc dep链的一部分,而不是循环条件dep链。

循环条件仅检查adccmpsub写入的标志。保存/恢复它周围的标志并不能使它成为dec dep链的一部分,因此可以在执行adc之前检测到循环结束时的分支错误预测。 (这个答案的先前版本错了。)

几乎可以肯定有一些空间来削减设置代码中的指令,可能通过使用值开始的寄存器。你并没有 使用edi和esi作为指针,虽然我知道当你按照与传统"传统&#34一致的方式使用寄存器时,它会使初始开发变得更容易;使用。 (例如EDI中的目的地指针)。

Delphi是否允许您使用adc?拥有第7个注册表真好。

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

答案 1 :(得分:8)

有许多x86芯片在使用时间上有很大不同,你可以实际拥有所有这些芯片的最佳代码。您在使用前获得两个已知良好功能和基准的方法已经相当先进。

但是,根据BigIntegers的大小,您可以通过简单的循环展开来改进代码。这将彻底消除循环开销。

E.g。你可以执行一个专门的块,它会添加8个这样的整数:

@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,您将大部分时间花在展开的部分上,现在应该执行得更快。

如果你想要它更快,那么写下七个专门用于剩余元素计数的块,并根据元素计数分支到它们。这可以通过将七个地址存储在查找表中,从中加载地址并直接跳转到专用代码来完成。

对于小元素计数,这完全消除了整个循环,对于大元素,您将获得展开循环的全部好处。