测试准备:循环展开

    一般来说,测试程序总要对一定量的数据进行操作以后才能得到比较准确的测试结果。只有测试代码运行了足够长时间得到的测试结果才能真实地反映硬件的实际情况。所以,在测试中循环是不可避免的。而循环必然是有附加开销的,这又影响了测试结果的准确性。所以,在计算测试结果时,必须考虑消除循环的附加开销的影响。

    处理循环的附加开销的方法主要有两种:(1)差量法;(2)循环展开。

    差量法步骤如下:(1)运行正常测试程序,得到运行时间:

int TimePoint1 = GetHighAccurateTime();

for(int i=0;i<LOOP_COUNT;i++)
    BenchCore();

int TotalTimeUsed=GetHighAccurateTime()-TimePoint1;

(2)运行空循环,测试运行时间:

int TimePoint1 = GetHighAccurateTime();

for(int i=0;i<LOOP_COUNT;i++)
    ;/*空循环*/

int NullTimeUsed=GetHighAccurateTime()-TimePoint1;

(3)测试函数运行时间为:CoreTime = TotalTimeUsed-NullTimeUsed。

这种方法在理论上是很完美的,但在采用了很多高级优化技术的现代CPU上并不适用。由于现代CPU都采用了超标量、超流水等技术,CPU的速度和指令的数量不再是简单的线性关系,上面简单的相减运算不能真实反映循环体的开销。在实际测试中,如果用上面的方法,很可能得到一个远大于正常值的测试值。

    所以,我们的测试将不采用差量法,而采用循环展开的方法。

    循环展开是一种编译优化技术。但在这里我们将手工实现这个功能,以此来消除循环附加开销。

    循环展开的原理很简单。看下面的代码:

for(int i=0;i<128;i++)

{

    BenchCore();

}

for(int i=0;i<64;i++)

{

    BenchCore();

    BenchCore();

}

左右2段代码执行的功能完全相同,但右面代码的循环次数只有左面代码的一变,也就是说,右面代码的循环附加开销将比较小。当然其循环体的大小增加了一倍。这对小的循环(在我们的测试中,循环体将只有1~4条指令),不会有任何不利影响。

    上面只是最简单的情况。我们还可以更进一步,展开更多次数(4,8,16,......)。极限情况是展开到128次,从而不再需要循环。但这里会有另一个问题:I-Cache。当循环体的大小大于L1 I-Cache的时候,循环的某些指令必须放到L2中,这本身就使执行速度下降,而且还会影响数据通道上的数据传递。所以,在循环展开过程中,必须保证函数大小不要超过L1 I-Cache。我们主要处理的CPU中,PIII系列的L1 I-Cache是16KB,Athlon系列是64KB。P4系列是12K uOP,不能直接对应成自己数。不过我们的测试都使用简单指令(读/写),每条指令翻译成1~2条uOP,折算下来,12K uOP应该可以等价于15~30KB的L1 I-Cache。所以,我们把测试函数的代码量限制在16KB以下。当前的主流CPU都可以适应,而且将来推出更小L1 I-Cache的可能性不大。16KB的代码大小对我们的测试来说,基本上是够了。当然再大一些会有更好的灵活性(如果可以用64KB,则比较理想)。

    当然,上面所说的“循环展开减小循环附加开销”仅是理论上的结论,我们还是希望能够看到循环展开的实际影响。所以,我们希望测试函数可以是未展开的、展开1次、展开4次......要手工准备如此多的函数是很复杂的。而且,最后展开到函数接近16KB大小时,指令数将达到2K~4K条。即使是剪刀糨糊也不比恶梦好多少。何况百密一疏,难免出错(至少指令条数不能数错了!)?所以,在测试中,我们将让测试程序自己生成指定展开次数的测试函数。这听起来很好,实现起来可是有难度的。要实现这个功能必须:(1)可以把循环分解成3部分:循环首部、核心语句和循环尾部;(2)可以把这些部分按要求组装起来;(3)可以修改循环次数计数常量,这通常是在代码中以立即数方式给出;(4)核心语句可能还需要调整;(5)循环尾部的跳转指令偏移需要调整。

    在汇编语言中,可以在函数的各部分加标号,并可以把标号作为常量运算。所以,通过使用汇编语言,以及适当地进行标号运算,可以做到(1)。(2)的实现和以前讨论的测试函数起始偏移的要求差不多,这里就不再讨论。(3)、(4)、(5)的情况是一样的:要对某些指令中使用的常数进行调整。这里的关键是知道要调整的常数的地址。通过汇编语言的标号运算,这也是可以实现的。具体实现细节这里就不讨论了,见源码。

    由于我们准备支出16KB的内存存储测试函数,指令条数将达到2K~4K。我们不可能对所有可能的展开次数(1~4K)都遍历。所以循环展开按指数方式展开。设控制展开次数的参数是整数UnrollArg(以后称作展开因子),则展开后有核心语句(1<<UnrollArg)条。这样,展开因子每增加1,循环体内核心语句数加倍,循环体大小基本加倍。

    关于循环展开和性能之间的关系,见下面的测试结果:

LoopUnroll_Tb01.gif (8134 bytes)

表中每列表示不同的“功能”,每行表示不同展开因子下该“功能”的最大测试值(在不同函数起始偏移下的最大测试值),最左一列是展开因子。测试值单位是Byte/Cycle,越大越好。

    从表中可以看出,总体趋势是随着展开次数增加,函数性能增加。到一定展开次数以后,性能增加就不明显了,可以认为循环附加开销已经可以忽略不计。

    最后2个功能比较特殊,其性能不受展开次数影响。这主要是因为(1)循环附加语句很少,仅2条;(2)虽然循环核心语句也很少(1~4),但延迟很大,而且只能串行执行。这样,循环的附加语句完全被超标量CPU中的其它执行部件同时执行了,使可见的循环附加开销降到0。

    还有一个异常情况是3个Write相关功能在展开因子为2时达到性能最好。为更仔细观察,把这3列数据画成曲线图:

LoopUnroll_01.gif (3566 bytes)

三曲线形状非常相似,就象比例变换得出的一样。这个现象很难解释。据猜测,因为只影响Write相关功能,而且对不同的Write影响相同,很可能和指令调度部件的某个特性有关。这就很难测试出具体原因了,只有Intel能够完成更进一步的测试。

    不过这至少可以说明如下问题:(1)还有性能更好的实现Write功能的函数;(2)Copy相关功能(CP/04、CP/08、CP/16)也会受到影响,因为它们也使用了相同的Write指令。在以后的测试中将更仔细地讨论更好性能的实现。在测试中,主要使用的是Read相关功能,所以这里的异常不会有太大的问题。在涉及到Write的时候,再特别考虑。

    关于循环附加开销和循环展开的问题就讨论这么多。有更好的想法请与我联系!


Leading Cloud Surveillance, Recording and Storage service; IP camera live viewing

Leading Enterprise Cloud IT Service; cloud file server, FTP Hosting, Online Storage, Backup and Sharing

Powered by FirstCloudIT.com, a division of DriveHQ, the leading Cloud IT and Cloud Surveillance Service provider since 2003.