我正在用Delphi编写一个简单的BigInteger类型。它主要由TLimb的动态数组组成,其中TLimb是32位无符号整数,还有一个32位大小字段,它也保存BigInteger的符号位。
要添加两个BigIntegers,我会创建一个适当大小的新BigInteger,然后在一些簿记之后,调用以下过程,并传递左操作数和右操作数以及结果的数组各自开始的三个指针,以及左和右的limbs数量。
源代码:
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),一个简单的纯PASCAL加法例程,使用变量和一些if
子句来模拟进位时,比我的简单、直接手工编写的汇编程序还要快。
我花了一些时间才发现,在某些CPU上(包括我的iMac和一台旧笔记本电脑),DEC
或INC
与ADC
或SBB
的组合可能非常缓慢。但在我的其他大多数PC上(我有另外五台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的答案帮助我尽快进入正确的轨道。这是我最终解决方案的主要部分:
普通代码:
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...
我删除了很多空格,我猜读者可以理解剩下的程序。它类似于主循环。对于较大的BigIntegers,速度提高了约20%,对于小型BigIntegers(仅有几个limbs),速度提高了约10%。
64位版本现在在可能的情况下使用64位加法(在主循环中以及Main3和Main2中,这些都不像上面那样“fall-through”),之前,64位比32位慢得多,但现在它比32位快30%,比原始简单的64位循环快两倍。
更新2
英特尔在其《英特尔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
不会受到部分标志寄存器停顿的影响。
jecxz
在Intel上只有2个uops,而宏合并的测试和分支只有1个。在AMD上,它仅有一个总的macro-op。这不像LOOP
指令那样慢。看起来这是一种正当的情况,因为你需要循环而不影响标志位。Nils的展开版本非常好地分摊了成本。 - Peter Cordes