要添加两个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.
这个代码运行良好,我非常满意,直到我注意到,在我的开发设置(Win7在iMac上的Parallels VM中)是一个简单的PURE PASCAL加载例程,在使用变量模拟进位的同时,几个if子句比我简单直接的手工汇编程序要快.
在某些cpu(包括我的iMac和一台较旧的笔记本电脑)上花了一阵时间,DEC或INC和ADC或SBB的组合可能非常慢.但是在我的其他大部分人(我有五台电脑来测试它们,尽管其中四个完全一样),这是相当快的.
所以我写了一个新版本,仿效INC和DEC,使用LEA和JECXZ,就像这样:
模拟代码的一部分:
@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的缓慢?
更新
彼得和尼尔斯的回答帮助我很多东西走上正轨.这是DEC版本的最终解决方案的主要部分:
普通代码:
class procedure BigInteger.PlainAdd(Left,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,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],[ESI + 2*CLimbSize] ADC EAX,[EDI + 2*CLimbSize] MOV [EBX + 2*CLimbSize],[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...
我删除了很多空格,我猜读者可以得到其余的例程.它与主循环类似.速度提高约较大的BigInteger为20%,小型BigInteger为10%(仅限少数肢体).
64位版本现在在可能的情况下使用64位加法(在主循环中,在Main3和Main2中,它们不像上面那样是“fall-through”),而之前的64位比32位慢了很多,但现在它比32位快30%,是原始简单64位循环的两倍.
解决方法
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在写入其他标志但是未修改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使进位标志不变的事实.” (适用于Pentium M / Core2 / Nehalem).避免inc / dec的建议完全是过时的,只适用于P4.其他cpu分别重命名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推土机系列(1 m-op,与融合比较分支机构相同的成本)以及Via Nano3000中,实际上是快速的.不过,在所有英特尔®cpu上都是坏的(7位在SnB系列上).
展开
当您展开时,您可以使用指针而不是索引寻址模式获得另一个小增益.because 2-reg addressing modes can’t micro-fuse on SnB and later.一组加载/ adc / store指令是6个无微型融合的指令,只有4个带有微熔点. cpu可以发出4个融合域uops / clock. (有关此级别的详细信息,请参阅Agner Fog的cpu microarch文档和说明表).
当您可以确保cpu可以比执行时更快地发出指令,以确保它能够在指令流中足够长的时间内捕获任何未获取的气泡(例如分支错误预测).在28uop循环缓冲区中的安装也意味着节电(并且在Nehalem上,避免了指令解码瓶颈.)有诸如指令对齐和跨越Uop高速缓存行界限的事情,使得难以在没有循环的情况下维持一个完整的4个uop /缓冲区也是.
另一个诀窍是保持指针到缓冲区的末尾,并计数到零. (所以在你的循环开始,你得到第一个项目作为结束[-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的展开应该是好的.没有必要过度,因为你是prob.将能够使Haswell之前的加载/存储端口饱和,只能打开3或4,甚至可能是2.
2的展开将使上述循环正好是英特尔cpu的14个融合域. adc是2个ALU(1个融合存储器),jecxz是2,其余的(包括LEA)都是1个.在未分配的域中,有10个ALU /分支和6个内存(如果你真的算存储地址和存储数据分开).
>每次迭代14个融合域uops:每4个时钟发出一次迭代. (最后的奇怪的2个uops必须作为一组2发出,甚至从循环缓冲区发出.)
> 10 ALU&分支uops:要3.33c在pre-haswell之前执行它们.我不认为任何一个端口将成为瓶颈,无论是:adc的uop可以在任何端口上运行,lea可以在p0 / p1上运行.跳转使用port5(和jecx也使用p0 / p1之一)
> 6个内存操作:使3c执行前的Haswell cpu,每个时钟可以处理2个. Haswell为商店添加了专门的AGU,因此可以承受2负载1 /时钟.
因此,对于pre-haswell cpu,使用LEA / JECXZ,展开2将不会使ALU或加载/存储端口饱和. 4号的展开将带来22个融合(发布6个周期). 14 ALU& branch:4.66c执行. 12个内存:执行6个周期.所以4的展开将使Haswell之前的cpus饱和,但只是勉强. cpu不会有任何缓冲区的指示,以便在分支错误预测中进行调整.
Haswell和以后将永远在前端瓶颈(每个时钟限制为4 uops),因为加载/ adc / store组合需要4个uops,并且可以每秒钟一个维持.所以没有任何“空间”的循环开销,而不会削减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.这应该像AMD那样支持一个adc.
在AMD cpu上,adc只是一个宏操作,所以如果cpu可以维持4的发布率(即没有解码瓶颈),那么他们也可以使用他们的2个加载/ 1存储端口来击败Haswell.另外,AMD的jecxz与其他分支一样高效:只有一个宏操作.多精度数学是AMD cpu擅长的几个方面之一.某些整数指令的较低延迟给了他们一些GMP例程的优势.
超过5次的展开可能会伤害Nehalem的性能,因为这会使循环大于28uop循环缓冲区.然后指令解码将限制您每时钟少于4个uops.甚至更早(Core2),还有一个64B x86指令循环缓冲区(64位x86代码,不是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将始终与最后一个adc的CF输出同时准备就绪. (在AMD和P4 / Silvermont部分注册表中有一个完全注册的虚假的dep,它们不会单独重命名部分注册表).保存/恢复是adc dep链的一部分,而不是循环条件解码链.
循环条件只检查由cmp,sub或dec写的标志.保存/恢复其周围的标志不会使其成为adc dep链的一部分,因此在adc执行到达之前可以检测到循环结束时的分支错误预测. (此答案的以前版本出错)
几乎肯定有一些空间可以擦除设置代码中的指令,也许通过使用值开始的寄存器.您不必将edi和esi用于指针,尽管我知道当您以与“传统”使用方式一致的方式使用寄存器时,初始化开发变得更简单. (例如EDI中的目标指针).
Delphi是否让您使用ebp?有一个第七个注册表很高兴
显然,64位代码将使您的BigInt代码运行速度提高约两倍,即使您必须担心在64位adc循环结束时执行一个32b adc.它也会给你2x的数量的寄存器.