程序性能优化探讨(6)——矩阵乘法优化之分块矩阵

来源:互联网 发布:mac放电影有杂音 编辑:程序博客网 时间:2024/05/16 17:11

        有一种性格叫做偏执,有一种矩阵优化运算叫做分块。实话说,也许我这辈子也用不上这种随牛B但很复杂的算法,有些版本的教材直接删除这个内容。但越是这样我越想不过,因此借写这篇博客,把分块矩阵乘法彻底分析清楚。


         把矩阵乘法进行分块优化,很奇妙的算法,假设我们要做11X11矩阵乘法,AXB = C原本是这样算的:


        比如我们要计算C1.2,就要把上图的两条射线,交叉乘加。把A1.0~A1.10和B0.2~B10.2遍历完,也就是说要调用121个元素,还要进行11次乘法和10次加法运算,才能得出答案!如果按照上图中分出四个粗线块来进行分批交叉乘加,就能提高效率。

        关键是,怎么理解这种算法的转变呢?如何证明两种算法是等价的呢?这里我肯定没办法做完整的论证。不过我可以简单做个定性的判断。既然矩阵乘法的本质就是交叉乘加,既然最外层都是加法,那么根据加法结合律,把射线分段进行乘加,如果射线拼起来刚好是原来整个射线,那么两种算法等价了。

                  (1)                    (2)                (3)

        


        像上图这样,把计算C1.2这个元素的大的交叉乘加,分成了三个段组。而什么时候计算哪组射线,这个顺序是没有影响的,只要小的交叉组配对正确(比如第一个横条对应第一个竖条),再把交叉乘加的结果都加到C1.2的临时值中,就能正确得出C1.2的最终结论。

        好了,下面把分块矩阵乘法的完整代码一段段贴出来:


vord brck(array A, array B, array C, int n, int bsize) 
{
    int r, c, k, kk, cc;
    double sum;
    int en = bsize * (n/bsize); /* Amount that frts evenly into blocks */
  
    for (r = 0; r < n; r++)
        for (c = 0; c < n; c++)
            C[r][c] = 0.0;




    for (kk = 0; kk < en; kk += bsize) { 
        for (cc = 0; cc < en; cc += bsize) {
            for (r = 0; r < n; r++) {
                for (c = cc; c < cc + bsize; c++) {
                    sum = C[r][c];
                    for (k = kk; k < kk + bsize; k++) {
                         sum += A[r][k]*B[k][c];
                    }
                    C[r][c] = sum;
                }
            }
        }


        好,第一段代码截止在这里。咋一看很复杂,我们来一条条分析。以我的习惯是从多层循环的最里层进行分析。

    sum = C[r][c];
    for (k = kk; k < kk + bsrze; k++) {
        sum += A[r][k]*B[k][c];
    }
    C[r][c] = sum;

        我们先分析kk=0的情况,而bsize是块的大小4,通过这个,把循环限制在某个分块里。代码中的C[r][c]存储的是Cr.c临时值,通过sum不断增加C[r][c]的累积加法结果。

        假设kk = 0,r = 1、c = 2。那么代码执行的就是下一段:

    for (k = 0; k < bsize; k++) {
        sum += A[1][k]*B[k][2];
    }

        这就相当于在做第一条红横线和第一条蓝竖线的交叉乘加!能理解到这里,那么我们就能理解更多的代码了。


        假设kk = 0,cc = 0,r = 0,那么以下代码:


for (c = 0; c <  bsize; c++) {
    sum = C[r][c];
    for (k = 0; k < bsize; k++) {
        sum += A[r][k]*B[k][c];
    }
    C[r][c] = sum;
}

        

        这相当于在做以下的交叉乘加遍历:

        

        

        如果把外层循环for (r = 0; r < n; r++) {}加上,那么他们的循环就是:

        


        我们看到r把所有行遍历完,但由于k被限制在bsize范围内,因此每行只处理四个列。

        好了,接下来剩下两个外层循环:

    for (kk = 0; kk < en; kk += bsize) { 
        for (cc = 0; cc < en; cc += bsize) {


        首先是cc,cc被限制在en里,而en的计算是限制在四个小分块中,好,那么我们先分析cc。我们看到,cc影响的是B[k][c],因此能把蓝横竖线遍历的范围扩大:

        


        我们看到,由于循环参数的限制,for (cc = 0; cc < en; cc += bsize) 的循环只增加了四条小射线,真正扩大的因素在 for (kk = 0; kk < en; kk += bsize) 。关于这个kk的变化,会同时影响到A[r][k]和B[k][c],而由于en本身是bsize的整数倍结果,那么kk的递增的扩展结果:

        


        好了,我们发现,第一段代码能遍历的范围似乎并不全,这时候我发现,在外层循环for (kk = 0; kk < en; kk += bsize)里,还有一段代码忘分析了:

/* $end bmm-rck */
/* Now finish off rest of c values  (not shown in textbook) */
    for (r = 0; r < n; r++) {
       for (c = en; c < n; c++) {
           sum = C[r][c];
           for (k = kk; k < kk + bsize; k++) {
                 sum += A[r][k]*B[k][c];
           }
           C[r][c] = sum;
       }
    }
/* $begin bmm-rck */
}


        我们到,这段代码的for (r = 0; r < n; r++) 和A[r][k]为遍历A的行提供了条件,而for (c = en; c < n; c++)和B[k][c]就是遍历A的列。

        这段代码相当于遍历矩阵B中分块en以外的那部分列,要是单独拿来分析,当kk = 0时的遍历范围就应该是:

        


        再加上外层循环for (kk = 0; kk < en; kk += bsize),遍历范围应该是:


        


        经过对循环for (kk = 0; kk < en; kk += bsize)内的两段代码分析,我们找出了他们实现的交叉乘加遍历范围:

        


        剩余未纳入计算的区域,矩阵A和矩阵B中一目了然,接下来分析第三段代码:


    /* $end bmm-rck */
    /* Now finish remaining k values (not shown in textbook) */ 
    for (cc = 0; cc < en; cc += bsize) {
        for (r = 0; r < n; r++) {
            for (c = cc; c < cc + bsize; c++) {
                sum = C[r][c];
                for (k = en; k < n; k++) {
                    sum += A[r][k]*B[k][c];
                }
                C[r][c] = sum;
            }
        }
    }


        很明显,这段代码和第一段代码的唯一区别就是for (k = en; k < n; k++) ,因此交叉乘加的遍历范围很容易得出:

        


        好了,就只剩下B的最后9个元素没有被交叉乘加过,剩下的这点代码,和第三段代码唯一区别就是for (c = en; c < n; c++):


    /* Now finish off rest of c values (not shown in textbook) */
    for (r = 0; r < n; r++) {
        for (c = en; c < n; c++) {
           sum = C[r][c];
           for (k = en; k < n; k++) {
               sum += A[r][k]*B[k][c];
           }
           C[r][c] = sum;
        }
   }
    /* $begin bmm-rck */
}
/* $end bmm-rck */


        那么这段代码实现的交叉乘加遍历范围是:

        


        至此,我们终于将所有交叉乘加遍历干净!

        

        费了那么多功夫,有个问题是,干嘛我们要用这种分块来实现算法优化呢?主要考虑在于,当数组大小增加时,时间局部性会明显降低,高速缓存中不命中数目增加。当使用分块技术是,时间局部性由分块大小来决定,而不是数组总大小来决定。另外,分块虽然能明显提高效率,但使得代码灰常难懂,因此一般用于编译器或者频繁执行的库例程……所以说嘛,反正我这辈子是用不上的……

         那么我们就具体来实践一下,把之前的六个版本,和分块的两个版本b_rck、b_rkc(我只分析了前一个),结果如下:

 rckcrkckrkcrkrcrkcb_rckb_rkc250.120.020.01000.3112.3814.48500.090.010.010.100.020.010.07750.020.010.020.050.01000.151000.070.073.711.784.480.111.371.61250.671.886.134.230.670.660.662.251501.061.449.88.161.771.791.782.471751.661.6613.0512.321.952.051.942.372001.591.9717.3915.432.212.161.873.092251.972.1720.1617.382.572.382.222.822502.862.7122.07212.62.412.173.162754.453.6522.4920.32.492.392.53.483004.745.4422.7821.422.342.423.043.513256.957.1323.7621.082.742.32.953.223507.757.5723.9721.552.32.952.693.653757.388.5323.7521.132.912.642.963.994008.998.9523.9823.093.073.092.933.924259.839.8724.7921.973.262.722.853.9445010.0611.1625.723.333.132.842.843.9647511.110.5625.4724.353.562.972.784.2450011.0910.6825.1124.743.753.152.963.8952511.5511.1626.1225.774.263.123.153.7755011.7611.0826.0924.44.42.973.293.9557511.5311.0926.9325.855.153.163.163.9860011.7110.8526.6626.66.853.693.174.0762513.212.5329.1329.616.853.493.194.2165011.9611.229.8129.117.263.193.274.2867511.8311.129.1430.147.363.183.284.2870011.771127.9529.348.053.243.34.4372515.8613.8632.6836.4710.184.683.214.0875015.2114.0233.3336.349.754.393.244.1777515.0613.7233.0936.6110.244.643.284.4780015.0413.6731.8336.69.564.493.314.1582514.6713.5832.1336.5510.264.143.44.3785014.1612.8231.5936.899.983.843.354.3387513.8912.6931.4637.0310.233.593.384.3390013.8312.553137.1810.363.683.434.4492513.7713.1331.5637.910.753.823.54.595013.4513.0231.5738.3310.643.743.524.3997521.2418.3840.0843.8210.895.123.434.44100020.218.2439.6143.7210.894.93.474.28102519.6217.9138.8243.6210.544.563.414.4105019.1517.8738.543.2910.444.473.544.38107518.8717.8138.1743.5710.514.293.434.46110018.5817.5937.5443.2210.444.063.434.58112518.2317.5737.3943.2410.413.963.384.48115017.8617.5137.2743.1310.453.853.634.44117517.7917.4536.7743.4210.353.783.584.3120017.5517.4236.543.4110.433.723.464.36122517.2517.3836.4343.4510.413.633.324.5125017.2317.3136.243.2210.423.583.494.53127516.9817.7235.8543.159.243.193.444.47


        接下来是统计图:

        


        从结果看,b_rck,b_rkc的性能,确实随着数组元素个数的增长而区域性能的稳定,but,这里居然发现,rkc的版本也基本围绕b_rck,b_rkc来走,而且周期数还有超越这两个分块版本的可能!这个和教科书上的完全不一样啊!呵呵,不过,我也不知道这是为什么o(╯□╰)o


0 0
原创粉丝点击