测试准备:测试函数
在进行测试之前,必须介绍一下核心测试函数,因为在源代码中你将不能看到完整的测试函数,只能看到连接函数将要使用的一个个代码片断。
首先要说明一下函数需要的参数。我们希望读/写/复制操作都可以一致地完成,所以首先要有2个指针参数,我们叫做pSrc和pDst。读和写操作针对pSrc所指区域操作,而复制操作将同时使用2个区域。接下来是内存的字节大小(MemSize)和块(跳跃)的字节大小(BlockSize),其含义都是很直接的。另外我们还需要2个参数:ItemSize和LoopCount。LoopCount是循环次数,没有什么疑问。ItemSize是指每次操作的单元大小。由于我们的测试涉及ALU、MMX和SSE等3个部件,对应的操作其每次处理的数据量是不同的分别为4、8和16字节。在这里需要这个参数来进行一些地址运算。综合起来,函数的C语言原型如下:
unsigned __int64 TBenchFunc(void *pSrc,void *pDst,int MemSize,int BlockSize,int ItemSize,int LoopCount);
返回值是循环体执行的周期数。
下面是测试函数的流程图:
在测试中,核心语句序列总是向前递增地址。在达到内存块的末尾的时候,如以前讨论操作顺序时指出的,要绕回到内存块的开始部分。所以测试共有2重循环:内循环处理一遍内存(指地址增加到内存块末尾处,并不一定每个内存单元都访问到了),外循环保证整个核心语句序列执行足够多次数。
在测试运行过程中,我们不希望把任何临时变量放到内存中,因为这会影响CACHE的运行。上面的函数共有6个参数,再加上一些临时中间变量,通用寄存器组将没有足够空间。为此我们将使用MMX寄存器来存储一些临时变量。这样在循环中除了核心操作以外,没有内存访问指令。当然坏处是不能在不支持MMX指令的机器上运行。这应该没有太大的问题。
下面是测试函数的具体讨论。在循环入口以前的辅助代码清单如下:
由于这部分代码不进入时间统计,没有对其进行优化。
开始4行是保存要使用的寄存器。EAX、ECX和EDX可以任意使用而不恢复其值,ESP是栈指针不能使用。其余4个寄存器都保存了。
接着是把pSrc加载到ESI中:
mov esi, pSrc
各个变量在栈中的地址在函数之前由宏定义,所以这里只要引用变量名字就可以了。
然后是加载每次的地址修正量:
mov ebp, AddrInc
AddrInc是一个常量,是以前讨论访问顺序时所说的BlockSize*n。但在编译时不可能知道其值,所以这里填入的只是一个虚值,在运行时将由连接函数改成实际值。该指令后面的标号AddrIncHelp就是为了定位这个需要修改的常量的位置而设置的。
接着是加载pDst到EDI:
mov edi, pDst
后面的4条减法指令是以前讨论访问顺序时所说的“进入循环前的修正”,含义应该很直接。执行到这里,ESI和EDI中存储了循环开始时应该有的地址。但在循环中,我们要修改ESI和EDI的值,从而在某些时候要求把这2个寄存器的值恢复到现在的内容。所以需要把这2个寄存器的当前值保存到MMX寄存器中以备将来使用。由于MMX寄存器是64位的,只需要为这2个变量分配一个MMX寄存器就可以了。实际分配的寄存器是由前面的宏AddrR定义的,这里只要引用这个宏就可以了。但并没有指令可以把通用寄存器对的值拼成64位传递到MMX寄存器中,所以这里用下面的3条MMX指令把ESI和EDI存储到AddrR中:
movd AddrR, esi ;传递ESI
movd mm0, edi ;传递EDI
punpckldq AddrR, mm0 ;拼成一个64位单元
在AddrR中,低32位是ESI,高32位是EDI。在这中间有下面的一条指令:
mov ebx, AdjustFreq
这里AdjustFreq是一个常量,是指要进行多少次核心循环才需要恢复一次ESI和EDI。这就是前面流程图中的内循环的次数。这个常量也是在连接的时候由程序计算出来填到这里的,所以后面有一个AdjustFreqHelp标号帮助定位地址。在每次外循环中,这个值也是要被恢复的,所以后面有把这个变量保存到MMX寄存器的指令:
movd AdjustFreqR, ebx
另外,我们把ESI和EDI存储到MMX寄存器中,除了节约通用寄存器外,还希望利用MMX指令进行地址绕回处理。利用MMX的并行计算特点,2个地址(ESI和EDI)的绕回处理只需要一次运算就足够了,这也是我们把ESI和EDI合并到一个MMX寄存器的目的之一。不过,在进行地址绕回处理的时候,还需要用BlockSize和ItemSize两个变量。所以下面的指令序列把BlockSize和ItemSize存储到MMX寄存器中:
存储BlockSize | 存储ItemSize |
mov eax, BlockSize movd BlockSizeR, eax punpckldq BlockSizeR, BlockSizeR |
mov eax, ItemSize movd ItemSizeR, eax punpckldq ItemSizeR, ItemSizeR |
最后一条指令的目的是使MMX寄存高32位和低32位内容相同,为后面的并行地址绕回处理作准备。
在这些代码中间有一条指令:
pxor InBlockR, InBlockR
其中InBlockR是一个MMX寄存器。该寄存器存储的是当前处理循环中块内的偏移。请参考关于操作顺序的文章:当第一遍处理(外循环的第一次)的时候,访问所有块的第一个单元,此时InBlockR为0......第k遍时访问所有块的第k个单元,InBlockR的值为k×ItemSize;当k×ItemSize>=BlockSize时,要将InBlockR重新清零。利用InBlockR这个临时变量,可以很方便的进行地址绕回处理。
下面的代码是设置外循环的计数:
mov eax, LoopCount
xor edx, edx
div ebx
mov ecx, eax
在测试中我们传进来的参数LoopCount是指核心语句序列要执行的次数,所以这里要先除以内循环的次数才是外循环的循环计数。
下面的代码是为顺序操作特殊准备的:
mov eax, BlockSize
cmp eax, ItemSize
jne short NoAddr2
movd AddrR2, edi ;BlockSize==ItemSize:Sequencial
NoAddr2:
顺序操作不需要复杂的地址绕回处理,所以将对其进行特殊处理,以减少循环附加开销。判断是否顺序顺序操作很简单:如果BlockSize==ItemSize就是顺序操作,否则不是。特殊处理也很简单,就是另外分配一个MMX寄存器(AddrR2)来存储EDI。
下面就是记录进入循环的时间了:
rdtsc
movd TscR, eax
mov eax, pSrc
movd mm0, edx
punpckldq TscR, mm0
rdtsc指令读出当前的时钟计数到EAX:EDX中。不过我们需要把EAX和EDX释放出来作它用,所以还要把它们的值传递到MMX寄存器(TscR)中保存。在这中间有一条指令:
mov eax, pSrc
是为两个特殊测试(有数据相关的读和复制)准备的。在测试程序中将会有几个特殊测试,我们将在以后用到的时候再讨论。
至此固定的循环初始化就算完成了。不过根据不同的测试,在进入循环前还有少量的不同初始化指令。
对有数据相关的复制操作,需要在后面紧跟一条指令:
mov edx, MemSize
对SIB地址方式(有数据相关的测试不会采用这种地址方式),需要在后面紧跟另一条指令:
xor edx, edx
对其它测试则不需要额外的初始化指令。这些都由程序判断然后根据需要连接到一起。
核心语句序列已经在前面讨论过,这里就不再讨论了。下面讨论进行地址绕回判断和地址绕回处理。
由于我们已经把内循环的计数放到EBX中,所以地址绕回判断也是很直接的:
sub ebx, 1 ;Intel建议用SUB而不用DEC
jnz loop_start
前面已经讨论过,那条sub指令将插入核心语句序列最后一块的开头,所以在循环末尾只需要连接条件转移就可以了。要注意的是当所操作的内存块比较小而展开次数又比较大的时候,内循环可能只有1次。此时不需要进行内地址绕回判断。连接函数将判断这种情况,根据需要决定是否要连接这个条件转移语句和前面的减指令。在要连接绕回判断的情况,由于循环展开的次数不同,条件转移的目的偏移是不同的。连接程序将根据实际情况修改这里的偏移。
地址绕回处理分多种情况。首先看特殊测试的情况。某些特殊测试(如:有数据相关的操作、寄存器内的操作等)不需要地址绕回判断和处理。这种情况相对简单,连接函数将不连接相关的部分。此时连接程序还要把AdjustFreq置为1,保证计算出的外循环次数正确。
接下来是顺序操作的情况。顺序操作的地址绕回也很简单,只要把ESI和EDI的值恢复了就可以。当然还有恢复内循环计数EBX的指令:
movd esi, AddrR
movd edi, AddrR2
movd ebx, AdjustFreqR
其中恢复EDI的指令仅有复制操作需要,连接函数也会根据需要选择。
最后是普通的块跳跃操作的地址绕回处理。下面是代码清单:
paddd InBlockR, ItemSizeR
movq mm0, BlockSizeR
pcmpgtd mm0, InBlockR
pand InBlockR, mm0movq mm0, AddrR
paddd mm0, InBlockRmovd esi, mm0
punpckhdq mm0, mm0
movd edi, mm0movd ebx, AdjustFreqR
第一阶段的4条指令计算出下一个块内偏移,注意后3条指令的目的是让块内偏移大于或等于BlockSize后回到0。接下来的2条指令是把块内偏移和原来的地址相加,就得到了需要恢复的地址(mm0中)。后面的2个阶段分别是恢复ESI和EDI。当然恢复EDI的2条指令只在复制操作中才连接进去。最后的指令是恢复内层循环计数的。
下面是循环尾部的处理了。其清单如下:
sub ecx, 1
jnz near ptr _BenchFunc
LoopAdjustHelp:
rdtsc
movd esi, TscR
punpckhdq TscR, TscR
movd edi, TscR
emms
sub eax, esi
sbb edx, edi
pop ebp
pop edi
pop esi
pop ebx
ret
前2条指令是进行外循环用的,意义很明确。其中的跳转指令的目的地址同样不能确定,必须在连接的时候由程序调整,所以后面有一个帮助获得其偏移地址的标号LoopAdjustHelp。紧接着的rdtsc是读退出循环时的计时值。后面的3条指令是把以前存储到MMX寄存器TscR中的进入循环的计时值读回到ESI和EDI中。再后面的代码都很简单,就不说了。
测试函数的代码就这么多。应该说还是比较简单的。还可以看出,经过充分展开的测试函数其循环附加开销是很小的,基本上不会影响测试结果。在后面的测试中我们也将讨论这个问题。所以后面的一些理论分析可能不会考虑循环附加开销的问题。