我现在被“程序员视角下的计算机系统”一书中的性能优化实验室所困,如下所示:
在N * N矩阵M中,其中N是32的倍数,旋转操作可以表示为: 转置:交换元素M(i,j)和M(j,i) 交换行:行i与行N-1-i交换
矩阵旋转的示例(为简单起见,N为3而不是32):
------- -------
|1|2|3| |3|6|9|
------- -------
|4|5|6| after rotate is |2|5|8|
------- -------
|7|8|9| |1|4|7|
------- -------
一个天真的实现是:
#define RIDX(i,j,n) ((i)*(n)+(j))
void naive_rotate(int dim, pixel *src, pixel *dst)
{
int i, j;
for (i = 0; i < dim; i++)
for (j = 0; j < dim; j++)
dst[RIDX(dim-1-j, i, dim)] = src[RIDX(i, j, dim)];
}
我通过内循环展开提出了一个想法。结果是:
Code Version Speed Up
original 1x
unrolled by 2 1.33x
unrolled by 4 1.33x
unrolled by 8 1.55x
unrolled by 16 1.67x
unrolled by 32 1.61x
我还从pastebin.com获得了一个似乎可以解决这个问题的代码片段:
void rotate(int dim, pixel *src, pixel *dst)
{
int stride = 32;
int count = dim >> 5;
src += dim - 1;
int a1 = count;
do {
int a2 = dim;
do {
int a3 = stride;
do {
*dst++ = *src;
src += dim;
} while(--a3);
src -= dim * stride + 1;
dst += dim - stride;
} while(--a2);
src += dim * (stride + 1);
dst -= dim * dim - stride;
} while(--a1);
}
仔细阅读代码后,我认为该解决方案的主要思想是将32行视为数据区,并分别执行旋转操作。这个版本的速度提升了1.85倍,压倒了所有循环展开版本。
以下是问题:
在内循环展开版本中,如果展开因子增加,为什么增量减慢,特别是将展开因子从8更改为16,从4切换到8时效果不一样?结果是否与CPU管道的深度有某种关系?如果答案是肯定的,增量的降级是否会反映管道长度?
优化数据区版本的可能原因是什么?似乎与最初的天真版本没有太大的本质区别。
编辑:
我的测试环境是Intel Centrino Duo架构,gcc的版本是4.4
任何建议都将受到高度赞赏!
亲切的问候!
答案 0 :(得分:2)
您正在测试哪种处理器?我朦胧地记得,当处理器可以同时处理多个操作时,展开循环会有所帮助,但只能达到最大并行执行次数。因此,如果您的处理器只能处理8个同时指令,那么展开到16将无济于事。但是,了解更新近处理器设计的人将不得不管道/纠正我。
编辑:根据this PDF,centrino core2 duo有两个处理器,每个处理器能够同时执行4个指令。但这通常不是那么简单。除非您的编译器在两个内核之间进行优化(例如,当您运行任务管理器时(如果您使用的是Windows,如果您使用的是Linux,那么,您将看到CPU使用率最大化),您的进程将是一次在一个核心上运行。处理器还具有14个执行阶段,因此如果您可以保持管道满,您将获得更快的执行速度。
继续沿着理论路线,然后,通过单次展开,您可以获得33%的速度提升,因为您开始利用同步指令执行。进行4次展开并没有多大帮助,因为您现在仍处于该4个同步指令限制范围内。 8个展开会有所帮助,因为处理器现在可以更完整地填充管道,因此每个时钟周期将执行更多指令。
最后,考虑一下麦当劳如何通过工作(我认为这相对普遍?)。汽车进入穿过,在一个窗口下订单,在第二个窗口付款,并在第三个窗口接收食物。如果第二个驱动器在第一个驱动器仍在排序时进入,那么在两个驱动器完成时(假设驱动器中的每个操作都需要一个'周期'或时间单位),那么在4个周期结束时将完成2个完整操作。如果每辆车在一个窗口完成所有操作,那么第一辆车将需要3个周期来订购,支付和获取食物,然后第二辆车也需要3个周期来订购,支付和获取食物,总共6个周期。因此,由于流水线操作的操作时间减少。
当然,您必须保持管道满,以获得最大的速度提升。 14个阶段是很多阶段,所以进行16次展开会给你一些改进,因为更多的操作可以在进行中。
转到32导致性能下降可能与处理器从高速缓存带宽有关(再次猜测,无法确切地知道没有完全看到您的代码,以及机器代码)。如果所有指令都不能适应缓存或寄存器,那么有一些时间需要准备它们全部运行(即,人们必须进入他们的汽车并首先到达驱动器)。如果他们一次全部到达那里,速度将有所降低,并且必须进行一些改组以使操作继续进行。
请注意,从src到dst的每次移动都不是空闲或单个操作。您可以查找数组,这会花费时间。
至于为什么第二个版本如此快速地工作,我将冒险猜测它与[]运算符有关。每次调用时,您都会对src和dst数组进行一些查找,解析指向位置的指针,然后检索内存。另一个代码直接指向数组的指针并直接访问它们;基本上,对于从src到dst的每个移动,移动中涉及的操作较少,因为已经通过指针放置显式处理了查找。如果您使用[],则遵循以下步骤:
如果你带着一个指针,你只需要做数学运算(通常只是一个加法,没有乘法),然后返回结果,因为你已经完成了第二步。
如果我是对的,那么通过展开其内部循环,你可能会通过第二个代码获得更好的结果,这样就可以同时对多个操作进行流水线操作。
答案 1 :(得分:2)
问题的第一部分我不确定。我最初的想法是某种缓存问题,但你只能访问每个项目一次。
由于轿跑的原因,其他代码可能会更快。
1)循环倒计时而不是向上计数。将循环计数器与零进行比较在大多数体系结构上都没有任何成本(标志由自动递减设置),您必须在每次迭代时明确地与最大值进行比较。
2)内循环中没有数学运算。你在内循环中做了很多数学运算。我在主代码中看到2次减法,在宏中看到一次乘法(使用两次)。还有一些隐式的结果索引添加到数组的基地址,这可以通过使用指针来避免(x86上的良好寻址模式也应该消除这种惩罚)。
编写优化代码时,始终从内部自下而上构建它。这意味着采用最内层循环并将其内容减少到接近零。在这种情况下,移动数据是不可避免的。增加指针是到达下一个项目的最小值,另一个指针需要添加一个偏移量才能到达下一个项目。所以至少我们有4个操作:加载,存储,增量,添加。如果支持的架构“以后增量移动”,那么这将是2条指令。在英特尔,我怀疑这是3或4条指令。除了像减法和乘法之外的任何东西都会增加重要的代码。
查看每个版本的汇编代码应该提供很多见解。
如果在一个完全适合缓存的小矩阵(32x32)上反复运行,你应该看到实现中更加显着的差异。即使数据副本的数量相同,在1024x1024矩阵上运行也会比单个32x32的1024次旋转慢得多。
答案 2 :(得分:2)
循环展开的主要目的是减少循环控制所花费的时间(测试完成,递增计数器等等)。这是一个收益递减的情况,因为随着循环越来越多地展开,循环控制所花费的时间变得越来越少。就像mmr所说,循环展开也可以帮助编译器并行执行,但只能达到一定程度。
“数据区”算法似乎是缓存有效矩阵转置算法的一种版本。计算转置的天真方式的问题在于它导致大量缓存未命中。对于源数组,您正在沿着每一行访问内存,因此它将以线性方式逐元素访问。但是,这要求您沿列访问目标数组,这意味着每次访问元素时都会跳转dim
个元素。基本上,对于输入的每一行,您将遍历整个目标矩阵的内存。由于整个矩阵可能不适合缓存,因此必须经常从缓存中加载和卸载内存。
“数据区”算法采用您按列访问的矩阵,并且一次只执行32行的转置,因此您遍历的内存量为32xstride
,这应该是合适的完全进入缓存。基本上,目标是处理适合缓存的子部分,并减少内存中跳跃的数量。