矩阵旋转的性能优化

时间:2010-06-03 14:09:20

标签: c performance optimization

我现在被“程序员视角下的计算机系统”一书中的性能优化实验室所困,如下所示:

在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倍,压倒了所有循环展开版本。

以下是问题:

  1. 在内循环展开版本中,如果展开因子增加,为什么增量减慢,特别是将展开因子从8更改为16,从4切换到8时效果不一样?结果是否与CPU管道的深度有某种关系?如果答案是肯定的,增量的降级是否会反映管道长度?

  2. 优化数据区版本的可能原因是什么?似乎与最初的天真版本没有太大的本质区别。

  3. 编辑:

    我的测试环境是Intel Centrino Duo架构,gcc的版本是4.4

    任何建议都将受到高度赞赏!

    亲切的问候!

3 个答案:

答案 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的每个移动,移动中涉及的操作较少,因为已经通过指针放置显式处理了查找。如果您使用[],则遵循以下步骤:

  • 在[]
  • 中做任何数学运算
  • 获取指向该位置的指针(内存中的startOfArray + [])
  • 在内存中返回该位置的结果

如果你带着一个指针,你只需要做数学运算(通常只是一个加法,没有乘法),然后返回结果,因为你已经完成了第二步。

如果我是对的,那么通过展开其内部循环,你可能会通过第二个代码获得更好的结果,这样就可以同时对多个操作进行流水线操作。

答案 1 :(得分:2)

问题的第一部分我不确定。我最初的想法是某种缓存问题,但你只能访问每个项目一次。

由于轿跑的原因,其他代码可能会更快。

1)循环倒计时而不是向上计数。将循环计数器与零进行比较在大多数体系结构上都没有任何成本(标志由自动递减设置),您必须在每次迭代时明确地与最大值进行比较。

2)内循环中没有数学运算。你在内循环中做了很多数学运算。我在主代码中看到2次减法,在宏中看到一次乘法(使用两次)。还有一些隐式的结果索引添加到数组的基地址,这可以通过使用指针来避免(x86上的良好寻址模式也应该消除这种惩罚)。

编写优化代码时,始终从内部自下而上构建它。这意味着采用最内层循环并将其内容减少到接近零。在这种情况下,移动数据是不可避免的。增加指针是到达下一个项目的最小值,另一个指针需要添加一个偏移量才能到达下一个项目。所以至少我们有4个操作:加载,存储,增量,添加。如果支持的架构“以后增量移动”,那么这将是2条指令。在英特尔,我怀疑这是3或4条指令。除了像减法和乘法之外的任何东西都会增加重要的代码。

查看每个版本的汇编代码应该提供很多见解。

如果在一个完全适合缓存的小矩阵(32x32)上反复运行,你应该看到实现中更加显着的差异。即使数据副本的数量相同,在1024x1024矩阵上运行也会比单个32x32的1024次旋转慢得多。

答案 2 :(得分:2)

  1. 循环展开的主要目的是减少循环控制所花费的时间(测试完成,递增计数器等等)。这是一个收益递减的情况,因为随着循环越来越多地展开,循环控制所花费的时间变得越来越少。就像mmr所说,循环展开也可以帮助编译器并行执行,但只能达到一定程度。

  2. “数据区”算法似乎是缓存有效矩阵转置算法的一种版本。计算转置的天真方式的问题在于它导致大量缓存未命中。对于源数组,您正在沿着每一行访问内存,因此它将以线性方式逐元素访问。但是,这要求您沿列访问目标数组,这意味着每次访问元素时都会跳转dim个元素。基本上,对于输入的每一行,您将遍历整个目标矩阵的内存。由于整个矩阵可能不适合缓存,因此必须经常从缓存中加载和卸载内存。

    “数据区”算法采用您按列访问的矩阵,并且一次只执行32行的转置,因此您遍历的内存量为32xstride,这应该是合适的完全进入缓存。基本上,目标是处理适合缓存的子部分,并减少内存中跳跃的数量。