25. 循环优化(所有处理器)
分析程序你经常会看到大部分时间都花费在最内层的循环上面。 提高速度的方法就是认真地用汇编优化最花时间的循环。
其它的部分仍然用高级语言完成。
下面所有的例子都假定数据全在1级cache内。 如果数据cache失效是瓶颈,那么没有理由去对指令进行优化。
而应该把注意力集中在组织你的数据,尽量减少cache失效次数(第七章)。
25.1 PPlain和PMMX上的循环
循环通常包括一个控制叠代次数的计数器,而且经常是一次叠代读或写一个数组元素。我选了这样一个例子:一个过程从数组中读整数,改变每个整数的符号,把结果存入另一个数组中。
这个过程用C语言可以写成:
void ChangeSign (int * A, int * B,
int N) {
int i;
for (i=0; i<N; i++) B[i] = -A[i];}
翻译成汇编,我们可以写成:
示例1.1:
_ChangeSign PROC NEAR
PUSH
ESI
PUSH EDI
A EQU DWORD
PTR [ESP+12]
B EQU DWORD PTR [ESP+16]
N EQU DWORD PTR [ESP+20]
MOV
ECX, [N]
JECXZ L2
MOV
ESI, [A]
MOV EDI, [B]
CLD
L1: LODSD
NEG EAX
STOSD
LOOP L1
L2: POP EDI
POP ESI
RET ;
(如果是_cdecl调用规则,那么没有额外的POP指令)
_ChangeSign ENDP
看上去写得很漂亮,但这不是优化的,因为它用了慢的未配对指令。 在所有数据在1级cache的前提下,每次叠代化11个时钟周期。
*只用可配对的指令(PPlain和PMMX)
示例1.2:
MOV ECX, [N]
MOV ESI,
[A]
TEST ECX, ECX
JZ
SHORT L2
MOV EDI, [B]
L1:
MOV EAX, [ESI] ; u
XOR EBX, EBX ; v (成对)
ADD ESI, 4 ; u
SUB EBX, EAX
; v (成对)
MOV [EDI], EBX ; u
ADD EDI, 4 ; v (成对)
DEC ECX
; u
JNZ L1 ; v (成对)
L2:
在此,我们只用可配对指令,并组织指令使它们都能配对。
现在每次叠代只花4个时钟了。不“切开”NEG指令我们也能获得相同的速度,但另一条无法配对的指令应该“切开”。
*用一个寄存器既当变址寄存器又当计数器
示例1.3:
MOV ESI, [A]
MOV EDI,
[B]
MOV ECX, [N]
XOR
EDX, EDX
TEST ECX, ECX
JZ SHORT L2
L1: MOV EAX,
[ESI+4*EDX] ; u
NEG EAX ; u
MOV [EDI+4*EDX], EAX ; u
INC EDX ; v (成对)
CMP EDX,
ECX ; u
JB L1 ; v (成对)
L2:
用同一个寄存器既当变址寄存器又当计数器使循环体的指令数目减少了,但仍然要4个时钟,因为有两条未配对的指令。
*让计数器以0结束(PPlain和PMMX)
我们想摆脱1.3中的CMP指令,就像在1。2中那样,使计数器以0结束,测试ZF标志来判断循环是否结束。
一个办法是先取数组的最后一个元素,再向后执行循环。 然而,数据cache是为向前访问数据优化的,而不是向后。
因此如果可能发生cache失效,好的办法还是使计数器从-N开始,沿着负值加到0。 这样基址指针应该指向数组末尾而不是开头:
示例1.4:
MOV ESI, [A]
MOV EAX,
[N]
MOV EDI, [B]
XOR
ECX, ECX
LEA ESI, [ESI+4*EAX] ; 指向A数组尾
SUB ECX, EAX ; -N
LEA EDI,
[EDI+4*EAX] ; 指向B数组尾
JZ SHORT L2
L1: MOV EAX, [ESI+4*ECX] ; u
NEG EAX ; u
MOV
[EDI+4*ECX], EAX ; u
INC ECX ; v (成对)
JNZ L1 ; u
L2:
现在循环体的指令减为五条了,但因为配对不佳,每次叠代仍是4个时钟(如果数组的地址和尺寸是常量,我们可以通过用A+SIZE
A取代ESI,B+SIZE B取取代EDI来节省两个寄存器)。 让我们看看如何改进配对:
*循环中指令的配对(PPlain和PMMX)
我们希望通过循环控制指令的混合计算改进配对情况。 如果想在INC
ECX和JNZ L1中插入一些指令,那么这些指令不能影响ZF。 在INC ECX后的MOV
[EDI+4*ECX],EBX指令会产生AGI延迟,因此我们必须设计得更精妙:
示例1.5:
MOV EAX, [N]
XOR ECX,
ECX
SHL EAX, 2 ; 4 * N
JZ SHORT L3
MOV ESI, [A]
MOV EDI, [B]
SUB ECX, EAX ;
- 4 * N
ADD ESI, EAX ; 指向A数组尾
ADD EDI, EAX ; 指向A数组尾
JMP
SHORT L2
L1: MOV [EDI+ECX-4], EAX ; u
L2: MOV EAX, [ESI+ECX] ; v (成对)
XOR EAX, -1 ; u
ADD ECX, 4
; v (成对)
INC EAX ; u
JNC
L1 ; v (成对)
MOV [EDI+ECX-4], EAX
L3:
在此我们用了一个不同的方法计算EAX的相反数:把所有位取反,再加一。
用这个方法巧妙利用了INC指令:INC指令不改变CF(ADD指令会影响CF)。用ADD而不是INC增加循环计数,测试CF而不是ZF。如此就能把INC
EAX指令插入而不影响CF。 你可能想, 我们为何不用LEA EAX,[EAX+1]取代INC EAX,至少它不影响任何标志。
但要知道LEA指令会产生AGI延迟,不是最好的解决之道。
注意,如此利用INC指令不改变CF的性质只有在PPlain和PMMX上有用,在PPro,PII和PIII上会引起部分标志延迟。
我们已经获得了完美的配对,每次叠代只需3个时钟了。
至于循环计数每次加1(示例1.4)还是加4(示例1.5)只是个人喜好,循环花的时间都一样。
*将每次操作的末尾与下一次操作的开头环绕(PPlain和PMMX)
示例1.5用的方法不是通用的,因此我们找寻其它方法改进配对机会。
一个方法就是重组循环,使每次操作的末尾与下一次操作的开头环绕。称它为“盘旋式循环”。一个“盘旋式循环”的每次叠代总有一些未完成的操作留待下一次叠代时完成。
实际上,示例1.5每次叠代的最后一个MOV与下一个叠代的第一个MOV也是相关的,但我们想更深入地研究这个方法:
示例1.6:
MOV ESI, [A]
MOV EAX,
[N]
MOV EDI, [B]
XOR
ECX, ECX
LEA ESI, [ESI+4*EAX] ; 指向A数组末尾
SUB ECX, EAX ; -N
LEA EDI,
[EDI+4*EAX] ; 指向B数组末尾
JZ SHORT L3
XOR EBX, EBX
MOV EAX,
[ESI+4*ECX]
INC ECX
JZ
SHORT L2
L1: SUB EBX, EAX ; u
MOV EAX, [ESI+4*ECX] ; v (成对)
MOV [EDI+4*ECX-4], EBX ; u
INC ECX ; v (成对)
MOV EBX, 0
; u
JNZ L1 ; v (成对)
L2:
SUB EBX, EAX
MOV [EDI+4*ECX-4], EBX
L3:
在此,我们在存储前一个值之前就读出下一个值,这当然改进了配对机会。 插在INC ECX和JNZ L1之间的MOV
EBX,0指令不是为了增加配对机会,而是为了避免AGI延迟。
*展开循环(PPlain和PMMX)
增加配对机会的常用的方法是每次叠代做2次操作,叠代次数减半。 这被称为循环展开:
示例 1.7:
MOV ESI, [A]
MOV EAX, [N]
MOV EDI,
[B]
XOR ECX, ECX
LEA
ESI, [ESI+4*EAX] ; 指向A数组的末尾
SUB ECX, EAX ; -N
LEA EDI, [EDI+4*EAX] ; 指向B数组的末尾
JZ SHORT L2
TEST AL,1 ; 看N是否是奇数
JZ SHORT L1
MOV EAX, [ESI+4*ECX] ; N是奇数,则把多余的一个先做掉
NEG EAX
MOV [EDI+4*ECX], EAX
INC ECX ; 使计数器变为偶数
JZ SHORT L2 ; N = 1
L1: MOV EAX, [ESI+4*ECX] ; u
MOV EBX, [ESI+4*ECX+4] ; v (配对)
NEG EAX ; u
NEG EBX ; u
MOV [EDI+4*ECX], EAX ; u
MOV [EDI+4*ECX+4], EBX ; v (配对)
ADD ECX, 2 ; u
JNZ L1 ; v
(配对)
L2:
现在我们并行执行2次操作,得到了最好的配对机会。 我们必须先看N是否是奇数,如果是的话在循环外做掉一次操作。
因为循环只能做偶数次操作。
循环的第一条MOV指令有AGI延迟,因为ECX在前一个时钟周期被增加过。
因此循环的每次叠代(包括2次操作)花6个时钟。
*重组循环避免AGI延迟(PPlain和PMMX)
示例 1.8:
MOV ESI, [A]
MOV EAX, [N]
MOV EDI,
[B]
XOR ECX, ECX
LEA
ESI, [ESI+4*EAX] ; 指向A数组的末尾
SUB ECX, EAX ; -N
LEA EDI, [EDI+4*EAX] ; 指向B数组的末尾
JZ SHORT L3
TEST AL,1 ;
看N是否是奇数
JZ SHORT L2
MOV
EAX, [ESI+4*ECX] ; N是奇数,则把多余的一个先做掉
NEG EAX ;
没有配对机会
MOV [EDI+4*ECX-4], EAX
INC ECX ; 使计数器变为偶数
JNZ SHORT L2
NOP ; JNZ L2不容易预测,因此加入NOP
NOP
JMP SHORT L3 ; N = 1
L1: NEG EAX ; u
NEG EBX ;
u
MOV [EDI+4*ECX-8], EAX ; u
MOV [EDI+4*ECX-4], EBX ; v (配对)
L2: MOV EAX, [ESI+4*ECX] ; u
MOV EBX, [ESI+4*ECX+4] ; v (配对)
ADD ECX, 2 ; u
JNZ L1 ; v
(配对)
NEG EAX
NEG EBX
MOV [EDI+4*ECX-8], EAX
MOV
[EDI+4*ECX-4], EBX
L3:
该技巧就是找出那些不用计数器做变址索引的指令,重组循环,使计数器在前3个周期就已经增加过。
现在我们的每2次操作减为5个时钟了,这已接近最好的可能。
如果数据cache是瓶颈,那么你可以通过把A、B数组交错成一个数组来进一步提高速度,因为这样每个B[i]就紧跟在对应的A[i]之后了。
如果该结构数组至少按8对齐的话,B[i]将一直和A[i]在一条cache行内,这样在写B[i]的时候就不可能cache失效。
当然,用了这方法或许会使程序的其它部分很不方便,因此要权衡利弊得失。
*展开2次以上(PPlain和PMMX)
你可能会想每次叠代做2次以上的操作,这样可以减少平均每次操作的循环开销。
但对于大多数情形,循环开销都可以减少到每次叠代只有1个时钟周期,因此相比展开成2次操作而言,展开成4次操作只能在节省1/4时钟/操作,这几乎不值得尝试。
只有在循环开销无法降低到1个时钟且N非常大,你可以考虑展开成4次操作/叠代。
过分的循环展开的缺点有:
1. 你需要计算N%R的值,这里R是展开的次数。然后在主循环的前面或后面做掉N%R次操作,使剩余的操作次数可以被R整除。这会多出很多代码而且其分支不容易预测。而且循环体也变大了。
2. 一片代码的第一次运行会花去很多时间。代码越多首次运行的惩罚越大,尤其是当N很小的时候。
3. 过多的代码使得代码cache更难有效利用。
*在32位寄存器中并行处理多个8或16位的操作数(PPlain和PMMX)
如果需要妥善处理8或16位操作数的数组,那么展开循环还会遇到问题。
因为你将无法使2条访问内存的指令配对。 比如,MOV AL,[ESI]/MOV BL,[ESI+1]不能配对——如果两个操作数在内存的同一个dword中的话。
有一个更妙的方法,也就是在32位寄存器中一次处理4个字节。
以下例子是把2加到字节数组的每个元素上去:
示例 1.9:
MOV ESI, [A] ; 字节数组的地址
MOV
ECX, [N] ; 字节数组的元素个数
TEST ECX, ECX ; 看N是否=0
JZ SHORT L2
MOV EAX, [ESI]
; 读开始的4个字节
L1: MOV EBX, EAX ; 拷贝到EBX
AND EAX, 7F7F7F7FH ; 得到EAX中每个字节的低7位
XOR EBX, EAX ; 得到每个字节的最高位
ADD EAX, 02020202H ; 把值同时加到4个字节上
XOR EBX, EAX ; 再与最高位组合
MOV
EAX, [ESI+4] ; 读下4个字节
MOV [ESI], EBX ; 存结果
ADD ESI, 4 ; 增加指针
SUB ECX,
4 ; 减少循环计数
JA L1 ; 循环
L2:
该循环中每4个字节的操作花5个时钟。
数组当然应该按4对齐。如果数组元素个数不能被4整除,那么你可以在数组的末尾增加一些字节使其长度能被4整除。
该循环总是会越界访问数组尾,因此你得保证数组没有放置在段的末尾,以避免常规保护性错误。
注意,这里用掩码保护每个字节的最高位,以避免加每个字节时把进位带给下个字节。
再次组合最高位时,我用了XOR而不用ADD是为了避免进位。
ADD ESI,4指令可以通过用一个像示例1.4那样的循环计数器来避免。
然而,这将使循环体的指令数变为奇数,从而产生一个未配对的指令,叠代仍然花5个周期。
如果转移指令是不配对的,那么可以在最后一个分支预测失败的操作(即退出循环时)后节省1个时钟,但我们得花额外的时钟进行预处理——设定指向数组尾部的指针并计算-N。
因此两种方法一样的速度,这里提供的方法是最简单、最短的。
下个例子是通过找第一个0字节得到以0结尾的字符串的长度。它比用REP SCASB快:
示例1.10:
STRLEN PROC NEAR
MOV
EAX,[ESP+4] ; 得到串起始指针
MOV EDX,7
ADD EDX,EAX ; 起始指针加7的值,最后要用
PUSH EBX
MOV EBX,[EAX] ;
读开始的4字节
ADD EAX,4 ; 移动指针
L1:
LEA ECX,[EBX-01010101H] ; 各个字节减1
XOR EBX,-1 ;
各个字节取补
AND ECX,EBX ; 把两个结果与
MOV EBX,[EAX] ; 读下4个字节
ADD
EAX,4 ; 移动指针
AND ECX,80808080H ;
测ECX各个字节的符号位(最高位)
JZ L1 ; 如果没找到0字节,继续找
TEST ECX,00008080H ; 检查低2个字节
JNZ SHORT L2
SHR ECX,16 ;
0字节不在低2个字节中 ADD EAX,2
L2: SHL CL,1 ; 利用CF避免了一个分支
POP EBX
SBB EAX,EDX ;
得到长度,存入EAX
RET
STRLEN
ENDP
在此,我们又把一次叠代的尾部放到下一次操作中做,这样可以增加配对机会。 我没有把循环展开,因为叠代的次数可能不会很多。
串当然最好按4对齐(即起始地址能被4整除)。 代码的访问总会超过串尾,因此串不能放在段尾。
循环体的指令数目是奇数,有1条指令未配对。
令分支指令而不是其它指令不配对是有好处的——当分支预测失败的时候可以节省1个时钟。
TEST ECX,00008080H指令是不能配对的。 你可以用可配对的指令OR
CH,CL取代它,但你必须插入一条NOP或其它指令以避免连续分支带来的惩罚。 OR CH,CL的另一个问题是在PPro,PII和PIII上会引起部分寄存器延迟。
因此我仍然用不可配对的指令TEST ECX,00008080H。
一次处理4个字节可能是相当困难的。 上面代码利用了这样一个规律:当且仅当碰到0字节的时候,才会计算产生一个非0的值。
这使得一次叠代测试4个字节变为了可能。 算法包括了从各个字节的减1操作(LEA指令)。
示例中,在减1操作之前我并没有用掩码保护每个字节的最高位,因为只有碰上0字节,减法才可能会向高字节借位。而这种情况下我们恰恰不用关心高字节是什么,因为我们是在向前寻找第一个0字节。
如果我们是在向后寻找,那么我们必须在侦测到0字节以后重新载入整个dword,并测全这4个字节,找到最后一个0。
用BSWAP指令将字节的顺序逆转也可以。
如果你想找第一个非0值,那么要先把4个字节与你想找的值XOR,然后仍旧用上面的方法找0值。
*带MMX指令的循环(PMMX)
在一个寄存器中处理多个操作数早在MMX处理器上出现过。
因为它有专门的指令和专门的64位寄存器用于这个目的。
回到把2加到数组中所有字节的例子,我们使用MMX指令:
示例1.11:
.data
ALIGN 8
ADDENTS DQ 0202020202020202h ; 为加8次定制字节
A DD ? ; 字节数组的地址
N DD ? ; 叠代的次数
.code
MOV ESI, [A]
MOV ECX,
[N]
MOVQ MM2, [ADDENTS]
JMP SHORT L2
; 循环头
L1: MOVQ [ESI-8], MM0 ; 存储结果
L2:
MOVQ MM0, MM2 ; 载入ADDENTS
PADDB MM0, [ESI] ;
一条指令完成8次加法
ADD ESI, 8
DEC ECX
JNZ L1
MOVQ [ESI-8], MM0 ; 存储最后一次结果
EMMS
存储指令被移到循环控制指令之后,这是为了避免存储延迟。
每次叠代需要4个周期,因为PADDB指令无法与ADD
ESI,8配对(访问内存的MMX指令不能与非MMX指令配对,也不能与另一条访问内存的MMX指令配对)。 但如果用ECX作为变址而不用ADD
ESI,8的话,又会产生AGI延迟。
因为叠代的开销比较大,我们可以考虑展开循环:
示例1.12:
.data
ALIGN 8
ADDENTS DQ 0202020202020202h ; 为加8次定制字节
A DD ? ; 字节数组的地址
N DD ? ; 叠代的次数
.code
MOVQ MM2, [ADDENTS]
MOV
ESI, [A]
MOV ECX, [N]
MOVQ MM0, MM2
MOVQ MM1,
MM2
L3: PADDB MM0, [ESI]
PADDB MM1, [ESI+8]
MOVQ
[ESI], MM0
MOVQ MM0, MM2
MOVQ [ESI+8], MM1
MOVQ MM1,
MM2
ADD ESI, 16
DEC
ECX
JNZ L3
EMMS
展开后的循环每次叠代做16次加法,花6个时钟周期。PADDB指令不是配对的。两个线程交错开,避免存储延迟。
如果在用MMX指令后不久就要用浮点指令,那么付出的代价很大。在这种情形下,可能应该像示例1。9那样仍然使用32位寄存器。
*带浮点指令的循环(PPlain和PMMX)
优化浮点循环的方法基本类似整型循环——尽管通常浮点指令之间是重叠而不是配对。
看一段C代码:
int i, n; double * X; double
* Y; double DA;
for (i=0; i<n; i++) Y[i] = Y[i] -
DA * X[i];
这片代码(被称作DAXPY)被广泛研究,因为这是解线性方程的关键。
示例1.13:
DSIZE = 8 ; 数据尺寸
MOV EAX, [N] ; 元素个数
MOV
ESI, [X] ; X的指针
MOV EDI, [Y] ; Y的指针
XOR ECX, ECX
LEA ESI,
[ESI+DSIZE*EAX] ; 指向X数组尾的指针
SUB ECX, EAX ; -N
LEA EDI, [EDI+DSIZE*EAX] ; 指向Y数组尾的指针
JZ SHORT L3 ; 看是否N = 0
FLD
DSIZE PTR [DA]
FMUL DSIZE PTR [ESI+DSIZE*ECX] ; DA *
X[0]
JMP SHORT L2 ; 跳入循环
L1:
FLD DSIZE PTR [DA]
FMUL DSIZE PTR [ESI+DSIZE*ECX]
; DA * X[i]
FXCH ; 得到旧的结果
FSTP DSIZE PTR [EDI+DSIZE*ECX-DSIZE] ; 存Y[i]
L2: FSUBR DSIZE PTR [EDI+DSIZE*ECX] ; 从Y[i]中减
INC ECX ; 增加索引值
JNZ L1 ;
循环
FSTP DSIZE PTR [EDI+DSIZE*ECX-DSIZE] ; 存最后一个结果
L3:
这里我们用了像示例1.6中的方法:用循环计数器充当比例变址寄存器,从负值加到0。
每次操作的末尾放到下一次操作的头部去做。
交错型的浮点操作在这里工作得很完美:在FMUL和FSUBR之间2个时钟的延迟被前面结果的FSTP指令填掉了。
FSUBR和FSTP之间3个时钟的延迟被循环控制的开销和下一次操作的开头两条指令填掉了。
在计数器被增加后的第一个时钟周期里,我们读的是与计数器无关的DA参数,从而又避免了AGI延迟。
结果是每次操作花6个时钟周期,这比Intel的展开循环的解决方案好!
*展开浮点循环(PPlain和PMMX)
DAXPY循环的3-展开是非常复杂的:
示例1.14:
DSIZE = 8 ; 数据尺寸
IF DSIZE EQ 4
SHIFTCOUNT = 2
ELSE
SHIFTCOUNT = 3
ENDIF
MOV EAX, [N] ; 数据元素个数
MOV ECX, 3*DSIZE ; 计数器步进值是3*DSIZE
SHL EAX, SHIFTCOUNT ; DSIZE*N
JZ L4 ; N = 0
MOV ESI, [X]
; 指向X的指针
SUB ECX, EAX ; 令ECX初值=(3-N)*DSIZE
MOV EDI, [Y] ; 指向Y的指针
SUB
ESI, ECX ; 指针指向(数组末尾-步进值)的地方
SUB EDI, ECX
TEST ECX, ECX
FLD DSIZE PTR
[ESI+ECX] ; X数组的第一个元素
JNS SHORT L2 ; 总共的操作次数少于4
L1: ; 主循环
FMUL DSIZE PTR
[DA]
FLD DSIZE PTR [ESI+ECX+DSIZE]
FMUL DSIZE PTR [DA]
FXCH
FSUBR DSIZE PTR [EDI+ECX]
FXCH
FLD DSIZE PTR
[ESI+ECX+2*DSIZE]
FMUL DSIZE PTR [DA]
FXCH
FSUBR DSIZE PTR
[EDI+ECX+DSIZE]
FXCH ST(2)
FSTP DSIZE PTR [EDI+ECX]
FSUBR DSIZE PTR [EDI+ECX+2*DSIZE]
FXCH
FSTP DSIZE PTR
[EDI+ECX+DSIZE]
FLD DSIZE PTR [ESI+ECX+3*DSIZE]
FXCH
FSTP DSIZE PTR
[EDI+ECX+2*DSIZE]
ADD ECX, 3*DSIZE
JS L1 ; 循环
L2: FMUL DSIZE PTR
[DA] ; 完成剩余的操作
FSUBR DSIZE PTR [EDI+ECX]
SUB ECX, 2*DSIZE ; 移动指针
JZ
SHORT L3 ; 完成
FLD DSIZE PTR [DA] ; 开始下一个操作
FMUL DSIZE PTR [ESI+ECX+3*DSIZE]
FXCH
FSTP DSIZE PTR
[EDI+ECX+2*DSIZE]
FSUBR DSIZE PTR
[EDI+ECX+3*DSIZE]
ADD ECX, 1*DSIZE
JZ SHORT L3 ; 完成
FLD DSIZE
PTR [DA]
FMUL DSIZE PTR [ESI+ECX+3*DSIZE]
FXCH
FSTP DSIZE PTR
[EDI+ECX+2*DSIZE]
FSUBR DSIZE PTR
[EDI+ECX+3*DSIZE]
ADD ECX, 1*DSIZE
L3: FSTP DSIZE PTR [EDI+ECX+2*DSIZE]
L4:
这里展示了把循环进行3-展开的过程,其目的不是推荐这么做,而是让你看看它有多么复杂!
做这种事之前,先要做好花大量时间调试、检测代码正确性的心理准备。还要考虑到这些问题:多数情况下,你不可能把一个展开度小于4的浮点循环的所有延迟全部克服——除非你将循环“环绕”(也就是每次叠代都有一些未完成的操作留待下次完成)。
上面的代码中,主循环的最后一个FLD是下次叠代的第一个操作的开始。
如果像示例1.9和1.10那样,读取数据直到越过数组边界,然后废弃尾部多余的数据——这样的方法似乎很好,但不推荐对浮点循环采用这种做法。
因为当数组后面的内存位置含有不合法的“浮点数”时,读取这些额外的数据会抛出常规的操作数异常。为了避免这种情况,我们必须在主循环后做至少1次额外的操作。
在一个展开循环的外部做的操作次数一般是N%R,其中N是总操作次数,R是展开因子。
可如果是“盘旋式”循环,基于上述的理由,我们要多做一次,也就是 (N-1)%R + 1 次。
一般来讲,我们更倾向在主循环前做额外的操作,但上述例子中我们必须在后面做,有两条理由:一是考虑到“盘旋式”循环带来的剩余操作;二是计算多余操作次数需要做除法(如果R不是2的幂次的话),除法是耗时的,而在循环后做那些多余的操作则避免了除法。
再一个问题就是确定循环计数器的步进值,使计数器以符号改变作为退出循环的标志,且在初始化中,根据步进值相应地设定基址指针的位置。
最后,你得保证对于任何N值,“盘旋式”循环带来的剩余操作都能被正确处理。
做1-3个剩余操作的收尾代码也可做一个单独的循环来实现,但这会多导致一次分支预测失败,因此上面的方法更快。
看了3-展开的困难示例,你可能会感到头大。
接下来你会看到4-展开要容易得多了:
示例1.15:
DSIZE = 8 ; 数据尺寸
MOV EAX, [N] ; 数据元素个数
MOV
ESI, [X] ; 指向X的指针
MOV EDI, [Y] ; 指向Y的指针
XOR ECX, ECX
LEA ESI,
[ESI+DSIZE*EAX] ; 指向X数组尾部的指针
SUB ECX, EAX ; -N
LEA EDI, [EDI+DSIZE*EAX] ; 指向Y数组尾部的指针
TEST AL,1 ; 看N是否是奇数
JZ
SHORT L1
FLD DSIZE PTR [DA] ; 做掉奇数的操作
FMUL DSIZE PTR [ESI+DSIZE*ECX]
FSUBR DSIZE PTR [EDI+DSIZE*ECX]
INC ECX ; 调整计数器
FSTP DSIZE
PTR [EDI+DSIZE*ECX-DSIZE]
L1: TEST AL,2 ;
看是否要多做2次操作
JZ L2
FLD
DSIZE PTR [DA] ; N % 4 = 2 或 3。 再做2次操作
FMUL DSIZE
PTR [ESI+DSIZE*ECX]
FLD DSIZE PTR [DA]
FMUL DSIZE PTR [ESI+DSIZE*ECX+DSIZE]
FXCH
FSUBR DSIZE PTR
[EDI+DSIZE*ECX]
FXCH
FSUBR DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
FXCH
FSTP DSIZE PTR
[EDI+DSIZE*ECX]
FSTP DSIZE PTR
[EDI+DSIZE*ECX+DSIZE]
ADD ECX, 2 ; 现在计数器值能被4整除了
L2: TEST ECX, ECX
JZ L4 ;
不需要再做更多操作了
L3: ; 主循环:
FLD
DSIZE PTR [DA]
FLD DSIZE PTR [ESI+DSIZE*ECX]
FMUL ST,ST(1)
FLD DSIZE PTR
[ESI+DSIZE*ECX+DSIZE]
FMUL ST,ST(2)
FLD DSIZE PTR [ESI+DSIZE*ECX+2*DSIZE]
FMUL ST,ST(3)
FXCH ST(2)
FSUBR DSIZE PTR [EDI+DSIZE*ECX]
FXCH ST(3)
FMUL DSIZE PTR
[ESI+DSIZE*ECX+3*DSIZE]
FXCH
FSUBR DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
FXCH ST(2)
FSUBR DSIZE PTR
[EDI+DSIZE*ECX+2*DSIZE]
FXCH
FSUBR DSIZE PTR [EDI+DSIZE*ECX+3*DSIZE]
FXCH ST(3)
FSTP DSIZE PTR
[EDI+DSIZE*ECX]
FSTP DSIZE PTR
[EDI+DSIZE*ECX+2*DSIZE]
FSTP DSIZE PTR
[EDI+DSIZE*ECX+DSIZE]
FSTP DSIZE PTR
[EDI+DSIZE*ECX+3*DSIZE]
ADD ECX, 4 ; 索引值加4
JNZ L3 ; 循环
L4:
通常,找出4-展开的没有延迟的解决方案是很容易的,也不需要“盘旋式”循环。
在主循环外的多余操作次数是N%4,它的计算十分简单不需要做除法,只要测试N的低2位就可以了。 多余的操作将在主循环前完成,使循环计数器的处理更简单。
循环展开的副作用是循环外的多余操作比较慢,这是由于不完全的重叠和可能的分支预测失败的缘故;而且因为代码变长了,首次运行的代价更高了。
一般来说,对于重要的循环如果N很大,或者只用“盘旋式”循环而不进行展开的话已经无法很好地避免延迟,那么对于整型循环推荐展开度为2,是浮点型推荐展开度为4。
25.2 PPro,PII和PIII上的循环
在前面的章节(25.1)中我已经解释了在PPlain和PMMX上,怎样用“盘旋”技术以及展开循环来改进指令配对的机会。
在PPro,PII和PIII上由于乱序执行机制,我们不需要这么做。 但也有其它的难题需要关注,最重要的当数ifetch块的边界问题和寄存器读延迟。
就像前面一样,我选择了和25.1节相同的示例:
一个过程从数组中读出整数,改变每个整数的符号,再把结果存入另一个数组。
这个过程的C语言版本如下:
void ChangeSign (int
* A, int * B, int N) {
int i;
for (i=0; i<N; i++) B[i] = -A[i];}
翻译成汇编,我们可以这么写:
示例2.1:
_ChangeSign PROC NEAR
PUSH
ESI
PUSH EDI
A EQU DWORD
PTR [ESP+12]
B EQU DWORD PTR [ESP+16]
N EQU DWORD PTR [ESP+20]
MOV ECX, [N]
JECXZ L2
MOV ESI, [A]
MOV EDI, [B]
CLD
L1: LODSD
NEG EAX
STOSD
LOOP L1
L2: POP EDI
POP ESI
RET
_ChangeSign ENDP
看上去解决得不错,实际上它并没优化过:用了没有优化的指令LOOP,LODSD和STOSD,这些指令会产生很多微码。
在所有数据全在1级cache中的前提下,每次叠代要花6-7个时钟周期。 避免这些指令,我们写成:
示例2.2:
MOV ECX, [N]
JECXZ L2
MOV ESI, [A]
MOV EDI,
[B]
ALIGN 16
L1: MOV EAX,
[ESI] ; len=2, p2rESIwEAX
ADD ESI, 4 ; len=3,
p01rwESIwF
NEG EAX ; len=2, p01rwEAXwF
MOV [EDI], EAX ; len=2, p4rEAX, p3rEDI
ADD EDI, 4 ; len=3, p01rwEDIwF
DEC ECX ; len=1, p01rwECXwF
JNZ L1 ; len=2, p1rF
L2:
先对批注做个解释:比如MOV
EAX,[ESI]指令,长度是2个字节,产生一条微码使用端口2读ESI,并写入EAX(被重命名)。 批注的信息是用来分析可能潜在的瓶颈的。
先分析指令解码(第14章):这些指令中,有一条MOV
[EDI],EAX指令要产生2条微码,这条指令必须进入D0。循环体包括的3个解码组,因此解码花3个周期。
然后分析取指令的情况(第15章):如果ifetch边界线使得前3条指令无法一起解码,那么最后一个ifetch块会有3个解码组,这样下一次叠代时ifetch块就会从我们希望的那条指令开始了,我们仅仅在第一次叠代的时候有延迟。
较坏的一种情形是有16-字节边界线和ifetch边界线在最后3条指令中,那么根据前面有关ifetch的表格,会产生1个时钟的延迟并引起下一次叠代的第一个ifetch块从16-字节边界开始,因此每次叠代都会出现问题。
结果就是每次叠代的取指令时间是4个时钟而不是3个。有两种方法避免这个问题:一是在第一次叠代时控制ifetch块的位置;二是控制16-字节边界线的位置。
后者的实现是最容易的。 就像上面代码所示,既然整个循环的代码长度只有15个字节,你可以通过将循环入口按16-对齐来避免循环中出现16-字节边界。
这样就使整个循环简单地放进了一个ifetch块中,不需要再对取指令作更进一步的分析了。
第三个问题是察看是否有寄存器读延迟(第16章)。
在此循环中,不存在对那些至少已有几个周期没被写过的寄存器的读取操作,故没有寄存器读延迟。
第四个要分析的是指令的执行(第17章)。
统计各个端口的微码数:
端口0 或 1: 4条
只在端口1的微码: 1条
端口2: 1条
端口3: 1条
端口4: 1条
假定那些既能进0端口又能进1端口的微码的分配是最优的,那么每次叠代的执行时间是2。5个时钟。
最后分析引退(第18章)。
因为循环体的微码数目不能被3整除,而跳转操作是必须在第一个引退槽中引退的,故引退槽没有被最佳利用。 引退需要的时钟数是|微码数目/3|(向上取整)。
所以这里引退需要3个时钟。
结论是,如果循环入口按16对齐的话,上述循环的执行需要3个时钟。
在此,我作了“除退出循环的那次,条件跳转每次都能被正确地预测(第22.2章)”这样一个假设。
*用同一个寄存器既做计数器又做变址寄存器,并让计数器在0结束(PPro,PII和PIII)
示例2.3:
MOV ECX, [N]
MOV ESI,
[A]
MOV EDI, [B]
LEA
ESI, [ESI+4*ECX] ; 指向A数组的尾部
LEA EDI, [EDI+4*ECX] ;
指向B数组的尾部
NEG ECX ; -N
JZ
SHORT L2
ALIGN 16
L1: MOV EAX,
[ESI+4*ECX] ; len=3, p2rESIrECXwEAX
NEG EAX ; len=2,
p01rwEAXwF
MOV [EDI+4*ECX], EAX ; len=3, p4rEAX,
p3rEDIrECX
INC ECX ; len=1, p01rwECXwF
JNZ L1 ; len=2, p1rF
L2:
用同一个寄存器既做计数器又做变址寄存器,我们将微码数减为6条。
基址寄存器指向数组的尾部,这样变址寄存器就可以从负值增加到0了。
解码:循环体中包括2个解码组,因此解码时间是2个时钟。
取指令:循环花在取指令上的周期数总是至少比它含有的16-字节块的数目多1?。因为循环代码只有11字节,可能会放进一个ifetch块。
通过把循环入口按16对齐后,我们可以保证得到的16-字节块的数目不会超过1个,因此取指令花2个时钟周期。
寄存器读延迟:在循环内,ESI、EDI寄存器被读,但没有被写,因此这被看作是读永久寄存器,但不在同一个三元组中。
EAX、ECX和标志寄存器在循环内部被修改,且读操作发生在它们被写回之前,因此不会引起永久寄存器读。 结论是没有寄存器读延迟。
执行:
端口0或1:
2条
端口1: 1条
端口2: 1条
端口3: 1条
端口4: 1条
执行时间: 1。5个时钟。
引退:
6条微码 = 2个时钟。
结论: 这段循环的每次叠代只花2个时钟。
如果你用了绝对地址,而不用ESI,EDI,那么循环将花3个周期,因为它不能被放进一个16-字节块内。
*展开循环(PPro,PII和PIII)
每次叠代做一次以上操作,做相对少的叠代被称为循环展开。
早期的处理器展开循环的目的是为了提高配对机会达到并行执行(25.1节)。
在PPro,PII和PIII中配对是不需要的,因为乱序执行机制已经在这方面做得很好了。 也不需要用两个不同的寄存器,因为寄存器重命名机制会解决这个问题。
这里展开循环的目的是减少每次叠代的循环开销。
下面的例子与示例2.2相同,只是展开因子为2,这意味着每次叠代做2次操作,叠代次数减半。
示例2.4:
MOV ECX, [N]
MOV ESI, [A]
MOV EDI,
[B]
SHR ECX, 1 ; N/2
JNC
SHORT L1 ; 看N是否是奇数
MOV EAX, [ESI] ; 先做掉奇数的一次
ADD ESI, 4
NEG EAX
MOV [EDI], EAX
ADD EDI, 4
L1: JECXZ L3
ALIGN 16
L2: MOV EAX, [ESI] ; len=2, p2rESIwEAX
NEG EAX ; len=2, p01rwEAXwF
MOV [EDI], EAX ; len=2, p4rEAX, p3rEDI
MOV EAX, [ESI+4] ; len=3, p2rESIwEAX
NEG EAX ; len=2, p01rwEAXwF
MOV [EDI+4], EAX ; len=3, p4rEAX, p3rEDI
ADD ESI, 8 ; len=3, p01rwESIwF
ADD EDI, 8 ; len=3, p01rwEDIwF
DEC ECX ; len=1, p01rwECXwF
JNZ L2 ; len=2, p1rF
L3:
示例2.2中循环所需要的开销(也就是调整指针和计数器,跳回)是4条微码且“真正的工作”是4条微码。
2-展开循环后相当于一次叠代做两次“真正的工作”,总共是12条微码。 循环开销的微码数占总数的比例从50%下降到33%。
由于展开的循环只能做偶数次操作,因此必须先检查N是否是奇数,是的话在循环外做掉一次操作。
分析循环的取指令的情况,我们发现一个新的ifetch块从ADD ESI,8开始,迫使它进入D0解码器。
这使得循环的解码花5个周期,而不是我们希望的4个。 通过硬性地把前一条指令改得更长,解决这个问题。 把MOV [EDI+4],EAX改为:
MOV [EDI+9999],EAX ; 使指令变长
displacement
ORG $-4
DD 4 ;
将偏移量改回4
这样强迫一个新的ifetch块从变长的MOV
[EDI+4],EAX指令开始,解码时间减为4个周期。
流水线的其它阶段可以在1个时钟周期处理3条微码,因此估计执行时间是每次叠代4个时钟(即每次操作2个时钟)。
真正测试这段代码发现花的时间比预期多一点。
我的测量结果显示大约是每个叠代4。5个时钟。 可能是因为微码的乱序执行并没有完全优化,ROB并没有找到最优的微码执行顺序,只提交了一个次优的顺序。
这个问题是无法预知的,只有实际的测试才能暴露它。 我们可以通过手工做一些乱序工作来“帮助”ROB:
示例2.5:
ALIGN 16
L2: MOV EAX, [ESI] ; len=2, p2rESIwEAX
MOV EBX, [ESI+4] ; len=3, p2rESIwEBX
NEG EAX ; len=2, p01rwEAXwF
MOV [EDI], EAX ; len=2, p4rEAX, p3rEDI
ADD ESI, 8 ; len=3, p01rwESIwF
NEG EBX ; len=2, p01rwEBXwF
MOV [EDI+4], EBX ; len=3, p4rEBX, p3rEDI
ADD EDI, 8 ; len=3, p01rwEDIwF
DEC ECX ; len=1, p01rwECXwF
JNZ L2 ; len=2, p1rF
L3:
现在循环的每次叠代是4个周期了。
这样的做法同样解决了ifetch块的取指令问题。 额外的付出就是因为不利用寄存器重命名,我们要多用一个寄存器。
*展开2次以上
当循环本身的开销在总开销中占的比例很大时,推荐循环展开。
示例2.3循环本身的开销只有2条微码,因此展开循环获利很小,但这里还是把它展开了,权且作为一种练习吧:
“真正的工作”是4条微码,循环开销是2条微码。 进行2-展开后循环体有2*4+2=10条微码。
引退时间将是10/3,向上取整后是4个时钟周期。 计算表明进行2-展开并没有获利。 进行4-展开:
示例2.6:
MOV ECX, [N]
SHL ECX, 2 ;
将要处理的字节数
MOV ESI, [A]
MOV EDI, [B]
ADD ESI, ECX ;
指向A数组的尾部
ADD EDI, ECX ; 指向B数组的尾部
NEG ECX ; -4*N
TEST ECX, 4
; 看N是否是奇数
JZ SHORT L1
MOV EAX, [ESI+ECX] ; N是奇数则先做掉一次
NEG EAX
MOV [EDI+ECX],
EAX
ADD ECX, 4
L1: TEST
ECX, 8 ; 看N/2是否是奇数
JZ SHORT L2
MOV EAX, [ESI+ECX] ; N/2是奇数,做掉多出的2次
NEG EAX
MOV [EDI+ECX],
EAX
MOV EAX, [ESI+ECX+4]
NEG EAX
MOV [EDI+ECX+4],
EAX
ADD ECX, 8
L2: JECXZ
SHORT L4
ALIGN 16
L3: MOV EAX, [ESI+ECX] ; len=3, p2rESIrECXwEAX
NEG EAX ; len=2, p01rwEAXwF
MOV [EDI+ECX], EAX ; len=3, p4rEAX, p3rEDIrECX
MOV EAX, [ESI+ECX+4] ; len=4, p2rESIrECXwEAX
NEG EAX ; len=2, p01rwEAXwF
MOV [EDI+ECX+4], EAX ; len=4, p4rEAX, p3rEDIrECX
MOV EAX, [ESI+ECX+8] ; len=4, p2rESIrECXwEAX
MOV EBX, [ESI+ECX+12] ; len=4, p2rESIrECXwEAX
NEG EAX ; len=2, p01rwEAXwF
MOV [EDI+ECX+8], EAX ; len=4, p4rEAX, p3rEDIrECX
NEG EBX ; len=2, p01rwEAXwF
MOV [EDI+ECX+12], EBX ; len=4, p4rEAX, p3rEDIrECX
ADD ECX, 16 ; len=3, p01rwECXwF
JS L3 ; len=2, p1rF
L4:
ifetch块正好从我们希望的位置开始。 解码时间是6个时钟。
在这里,寄存器读延迟是一个问题。
因为在循环的尾部附近,ECX已经引退了,而我们需要读ESI,EDI和ECX。
为了避免在循环的尾部附近读ESI,已经调整过代码的次序了,从而避免了一个寄存器读延迟。 换句话说,重组代码并多用一个寄存器(EBX)的原因与前面的例子是不同的。
有12条微码且循环的每次叠代花6个时钟(即每次操作 1.5
个时钟)。
展开因子更大以获得最快的速度似乎很诱人。
但因为大多数情况下循环本身的开销每次叠代才1个周期,因此相对于2-展开,把循环进行4-展开只在每次叠代节省了1/4时钟周期,这几乎不值得尝试。
只有相比循环的其它部分而言,循环开销占了很大比例且N很大的时候,你可以考虑4-展开。 展开因子大于4是没什么意义的。
过分的循环展开的缺点在于:
1. 你需要计算N%R的值,这里R是展开的次数。
然后在主循环的前面或后面做掉N%R次操作,使剩余的操作次数可以被R整除。 这会多出很多代码而且其分支不容易预测。而且循环体也变大了。
2. 一片代码的第一次运行会花去很多时间。 代码越多首次运行的惩罚越大,尤其是当N很小的时候。
3. 过多的代码使得代码cache更难有效利用。
循环因子不是2的幂次使得N%R的计算更困难,一般不推荐,除非已知N能被R整除。 示例1.14展示了如何进行3-展开。
*在32位寄存器中并行处理多个8或16位的操作数(PPro,PII和PIII)
有时候,可以在同一个32位寄存器中一次处理4个字节。下面的例子是把2加到字节数组的所有元素之上:
示例2.7:
MOV ESI, [A] ; 字节数组的地址
MOV
ECX, [N] ; 字节数组的元素个数
JECXZ L2
ALIGN 16
DB 7 DUP (90H) ; 7
NOP's for controlling alignment
L1: MOV EAX, [ESI] ;
一次读4个字节
MOV EBX, EAX ; 拷贝到EBX
AND EAX, 7F7F7F7FH ; 在EAX中得到每个字节的低7位
XOR EBX, EAX ; 在EBX中得到每个字节的最高位
ADD EAX, 02020202H ; 把值加到4个字节上
XOR EBX, EAX ; 结合最高位
MOV
[ESI], EBX ; 存结果
ADD ESI, 4 ; 移动指针
SUB ECX, 4 ; 减少计数器值
JA L1 ;
循环
L2:
注意,这里用掩码保护每个字节的最高位,以避免加每个字节时把进位带给下个字节。
再次组合最高位时,我用了XOR而不用ADD是为了避免进位。 当然,字节数组最好是按4对齐。
理论上,该循环的每次叠代花4个周期,但实际上因为有依赖链使得乱序执行比较困难,花的时间要多一点。
对于此类事情,在PPro,PII和PIII上,你可以用MMX寄存器获得更高的效率。
下个例子是通过找第一个0字节得到以0结尾的字符串的长度。
它比用REP SCASB快:
示例2.8:
_strlen PROC NEAR
PUSH EBX
MOV EAX,[ESP+8] ; 得到串指针
LEA
EDX,[EAX+3] ; 串首址加3的值,最后要用
L1: MOV EBX,[EAX] ;
读开头4个字节
ADD EAX,4 ; 移动指针
LEA ECX,[EBX-01010101H] ; 各个字节减1
NOT EBX ; 所有位取补
AND ECX,EBX
; 把两个结果与
AND ECX,80808080H ; 测符号位(最高位)
JZ L1 ; 未找到0字节,继续循环
MOV
EBX,ECX
SHR EBX,16
TEST
ECX,00008080H ; 看0字节是否在低2个字节中
CMOVZ ECX,EBX ;
如果不在低2个字节,则右移
LEA EBX,[EAX+2]
CMOVZ EAX,EBX
SHL CL,1 ;
用CF,从而避免了一个分支
SBB EAX,EDX ; 得到长度
POP EBX
RET
_strlen ENDP
该循环每次叠代测试4个字节,花3个时钟。 串当然最好是按4对齐。 代码的访问总会超过串尾,因此串不能放在段尾。
并行处理4个字节可能是相当困难的。
上面代码利用了这样一个规律:当且仅当碰到0字节的时候,才会计算产生一个非0的值。 这使得一次叠代测试4个字节变为了可能。
算法包括了从各个字节的减1操作(LEA指令)。
示例中,在减1操作之前我并没有用掩码保护每个字节的最高位,因为只有碰上0字节,减法才可能会向高字节借位。而这种情况下我们恰恰不用关心高字节是什么,因为我们是在向前寻找第一个0字节。
如果我们是在向后寻找,那么我们必须在侦测到0字节以后重新载入整个dword,并测全这4个字节,找到最后一个0。 用BSWAP指令将字节的顺序逆转也可以。
如果你想找第一个非0值,那么要先把4个字节与你想找的值XOR,然后仍旧用上面的方法找0值。
*带MMX指令的循环(PII和PIII)
用了MMX指令,我们可以一次操作进行8个字节的比较:
示例2.9:
_strlen PROC NEAR
PUSH EBX
MOV EAX,[ESP+8]
LEA EDX,[EAX+7]
PXOR
MM0,MM0
L1: MOVQ MM1,[EAX] ; len=3 p2rEAXwMM1
ADD EAX,8 ; len=3 p01rEAX
PCMPEQB MM1,MM0 ; len=3 p01rMM0rMM1
MOVD EBX,MM1 ; len=3 p01rMM1wEBX
PSRLQ MM1,32 ; len=4 p1rMM1
MOVD ECX,MM1 ; len=3 p01rMM1wECX
OR ECX,EBX ; len=2 p01rECXrEBXwF
JZ L1 ; len=2 p1rF
MOVD
ECX,MM1
TEST EBX,EBX
CMOVZ EBX,ECX
LEA
ECX,[EAX+4]
CMOVZ EAX,ECX
MOV ECX,EBX
SHR ECX,16
TEST BX,BX
CMOVZ EBX,ECX
LEA ECX,[EAX+2]
CMOVZ
EAX,ECX
SHR BL,1
SBB
EAX,EDX
EMMS
POP EBX
RET
_strlen ENDP
循环中有7条微码在0端口和1端口,平均执行时间是每次叠代3.5个时钟。实际测试的时间是3.8个时钟,这表明ROB对于这种情况的处理还是很合理的——尽管有长达6条微码的依赖链存在。
能在少于4个时钟下测试8个字节,已经比REPNE SCASB快得令人难以相信了。
*带浮点指令的循环(PPro,PII和PIII)
优化浮点循环的方法基本上和优化整型循环差不多,但要更注意依赖链,因为浮点指令的执行时间较长。
看一段C代码:
int i, n; double * X; double * Y; double DA;
for (i=0; i<n; i++) Y[i] = Y[i] - DA * X[i];
这片代码(被称作DAXPY)被广泛研究,因为这是解线性方程的关键。
示例2.10:
DSIZE = 8 ; 数据尺寸 (4 or 8)
MOV ECX, [N] ; 数据元素个数
MOV
ESI, [X] ; 指向X的指针
MOV EDI, [Y] ; 指向Y的指针
JECXZ L2 ; 看是否N = 0
FLD
DSIZE PTR [DA] ; 在循环外载入DA数值
ALIGN 16
DB 2 DUP (90H) ; 2个NOP指令用于对齐代码
L1: FLD DSIZE PTR [ESI] ; len=3 p2rESIwST0
ADD ESI,DSIZE ; len=3 p01rESI
FMUL ST,ST(1) ; len=2 p0rST0rST1
FSUBR DSIZE PTR [EDI] ; len=3 p2rEDI, p0rST0
FSTP DSIZE PTR [EDI] ; len=3 p4rST0, p3rEDI
ADD EDI,DSIZE ; len=3 p01rEDI
DEC ECX ; len=1 p01rECXwF
JNZ L1 ; len=2 p1rF
FSTP ST
; 废弃DA
L2:
依赖链长达10个时钟,但每次叠代只花4个时钟。 因为在前一次操作尚未完成时后一次操作已经开始了。
将代码对齐的目的是避免16-字节边界线进入最后一个ifetch块。
示例2.11:
DSIZE = 8 ; 数据尺寸(4 or 8)
MOV ECX, [N] ; 数据元素个数
MOV
ESI, [X] ; 指向X的指针
MOV EDI, [Y] ; 指向Y的指针
LEA ESI, [ESI+DSIZE*ECX] ; 指向X数组尾部的指针
LEA EDI, [EDI+DSIZE*ECX] ; 指向Y数组尾部的指针
NEG ECX ; -N
JZ SHORT L2 ;
看是否N = 0
FLD DSIZE PTR [DA] ; 在循环外载入DA数值
ALIGN 16
L1: FLD DSIZE PTR
[ESI+DSIZE*ECX] ; len=3 p2rESIrECXwST0
FMUL ST,ST(1)
; len=2 p0rST0rST1
FSUBR DSIZE PTR [EDI+DSIZE*ECX] ;
len=3 p2rEDIrECX, p0rST0
FSTP DSIZE PTR
[EDI+DSIZE*ECX] ; len=3 p4rST0, p3rEDIrECX
INC ECX ;
len=1 p01rECXwF
JNZ L1 ; len=2 p1rF
FSTP ST ; 废弃DA
L2:
这里我们用了如同 示例2.3 一样的技巧。
理论上,每次叠代花3个周期,实际上大约要3.5个周期——由于过长的依赖链的缘故。 即使展开循环也不会节省很多。
*带XMM指令的循环(PIII)
PIII上XMM指令使你可以并行操作4个单精度浮点数。
操作数应当按16对齐。
用XMM指令完成DAXPY算法不合适,因为精度太低了,还可能操作数不是按16对齐的。
如果操作次数不是4的倍数的话,你需要写一些额外的代码。 尽管如此,还是展示了代码,其目的只是为了给出一个带XMM指令的循环实例:
示例2.12:
MOV ECX, [N] ; 数据元素的个数
MOV ESI, [X] ; 指向X的指针
MOV
EDI, [Y] ; 指向Y的指针
SHL ECX, 2
ADD ESI, ECX ; 指向X数组尾部
ADD
EDI, ECX ; 指向Y数组尾部
NEG ECX ; -4*N
MOV EAX, [DA] ; 在循环外载入DA参数
XOR EAX, 80000000H ; 改变DA的符号
PUSH EAX
MOVSS XMM1, [ESP]
; -DA
ADD ESP, 4
SHUFPS
XMM1, XMM1, 0 ; 把-DA拷贝到XMM1的4个位置
CMP ECX, -16
JG L2
L1: MOVAPS XMM0,
[ESI+ECX] ; len=4 2*p2rESIrECXwXMM0
ADD ECX, 16 ;
len=3 p01rwECXwF
MULPS XMM0, XMM1 ; len=3
2*p0rXMM0rXMM1
CMP ECX, -16 ; len=3 p01rECXwF
ADDPS XMM0, [EDI+ECX-16] ; len=5 2*p2rEDIrECX,
2*p1rXMM0
MOVAPS [EDI+ECX-16], XMM0 ; len=5
2*p4rXMM0, 2*p3rEDIrECX
JNG L1 ; len=2 p1rF
L2: JECXZ L4 ; 检查是否应该结束了
MOVAPS XMM0, [ESI+ECX] ; 少做1-3个操作,再多做4个
MULPS XMM0, XMM1
ADDPS
XMM0, [EDI+ECX]
CMP ECX, -8
JG L3
MOVLPS [EDI+ECX],
XMM0 ; 多存2个结果
ADD ECX, 8
MOVHLPS XMM0, XMM0
L3: JECXZ
L4
MOVSS [EDI+ECX], XMM0 ; 多存1个结果
L4:
L1循环的每次叠代做4个操作,花5-6个时钟。
用在RAT中的ESI或EDI同时读XMM1寄存器的两个部分将导致寄存器读端口的延迟,这通过MULPS
XMM0,XMM1指令的前后各放一条有关ECX的指令来避免。 在L2之后的额外代码是考虑到N不能被4整除的情况。
注意,这段代码可能会对X和Y数组进行越界访问,如果越界的内存位置含有不正常的浮点数,那么最后的操作会被延迟。
如果可能的话,在数组后面插入一些额外的浮点数(哑元)使操作次数能够被4整除,这样就不需要L2之后的额外代码了。