3

我有一个 O(N^4) 图像处理循环,在对其进行分析后(使用 Intel Vtune 2013),我发现退役指令的数量大大减少了。我需要帮助来理解多核架构上的这种行为。(我使用的是 Intel Xeon x5365- 有 8 个内核,每 2 个内核共享 L2 缓存)。并且分支错误预测的数量也急剧增加!/////////////EDITS/////////// 我的非展开代码示例如下所示:

for(imageNo =0; imageNo<496;imageNo++){
for (unsigned int k=0; k<256; k++)
{
    double z = O_L + (double)k * R_L;
    for (unsigned int j=0; j<256; j++)
    {
        double y = O_L + (double)j * R_L;

        for (unsigned int i=0; i<256; i++)
        {
            double x[1] = {O_L + (double)i * R_L} ;             
            double w_n =  (A_n[2] * x[0] + A_n[5] * y + A_n[8] * z + A_n[11])  ;
            double u_n =  ((A_n[0] * x[0] + A_n[3] * y + A_n[6] * z + A_n[9] ) / w_n);
            double v_n =  ((A_n[1] * x[0] + A_n[4] * y + A_n[7] * z + A_n[10]) / w_n);                      

            for(int loop=0; loop<1;loop++)
            {
                px_x[loop] = (int) floor(u_n);
                px_y[loop] = (int) floor(v_n);
                alpha[loop] = u_n - px_x[loop] ;
                beta[loop]  = v_n - px_y[loop] ;
            }
///////////////////(i,j) pixels ///////////////////////////////
                if (px_x[0]>=0 && px_x[0]<(int)threadCopy[0].S_x && px_y[0]>=0 && px_y[0]<(int)threadCopy[0].S_y)                   

                    pixel_1[0] = threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + px_x[0]];
                else
                    pixel_1[0] = 0.0;               

                if (px_x[0]+1>=0 && px_x[0]+1<(int)threadCopy[0].S_x && px_y[0]>=0 && px_y[0]<(int)threadCopy[0].S_y)                   

                    pixel_1[2] = threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + (px_x[0]+1)];
                else
                    pixel_1[2] = 0.0;                   

/////////////////// (i+1, j) pixels/////////////////////////    

                if (px_x[0]>=0 && px_x[0]<(int)threadCopy[0].S_x && px_y[0]+1>=0 && px_y[0]+1<(int)threadCopy[0].S_y)
                    pixel_1[1] = threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + px_x[0]];
                else
                    pixel_1[1] = 0.0;                   

                if (px_x[0]+1>=0 && px_x[0]+1<(int)threadCopy[0].S_x && px_y[0]+1>=0 && px_y[0]+1<(int)threadCopy[0].S_y)

                    pixel_1[3] = threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + (px_x[0]+1)];

                else 

                    pixel_1[3] = 0.0;

                pix_1 = (1.0 - alpha[0]) * (1.0 - beta[0]) * pixel_1[0] + (1.0 - alpha[0]) * beta[0]  * pixel_1[1]

                +  alpha[0]  * (1.0 - beta[0]) * pixel_1[2]   +  alpha[0]  *  beta[0]  * pixel_1[3];                                

            f_L[k * L * L + j * L + i] += (float)(1.0 / (w_n * w_n) * pix_1);
                        }
    }

}
   }

我通过 4 次迭代展开最里面的循环。(你将有一个一般的理想我如何剥离循环。基本上我创建了一个 Array[4] 数组并在其中填充了相应的值。)做数学,我将总迭代次数减少 75%。假设每个循环有 4 个循环处理指令(加载 i、inc i、cmp i、jle 循环),展开后的指令总数应减少 (256-64)*4*256*256*496=24.96G . 分析结果如下:

Before UnRolling: Instr retired: 3.1603T      no of branch mis-predictions: 96 million
After UnRolling:  Instr retired: 2.642240T    no of branch mis-predictions: 144 million

no instr 退休减少了 518.06G 。我不知道这是怎么发生的。我将不胜感激有关此的任何帮助(即使它发生的可能性很小)。另外,我想知道为什么分支错误预测会增加。提前致谢!

4

1 回答 1

4

目前尚不清楚 gcc 将在何处减少指令数量。增加的寄存器压力可能会鼓励 gcc 使用加载+操作指令(因此原始操作数量相同但指令更少)。每个最内层循环的索引f_L只会增加一次,但这只会节省 6.2G (3*64*256*256*496) 指令。(顺便说一句,循环开销应该只有三个指令,因为i应该保留在寄存器中。)

以下使用双向展开的伪汇编(对于类似 RISC 的 ISA)显示了如何保存增量:

// the address of f_L[k * L * L + j * L + i] is in r1
// (float)(1.0 / (w_n * w_n) * pix_1) results are in f1 and f2
load-single f9 [r1];    // load float at address in r1 to register f9
add-single f9 f9 f1;    // f9 = f9 + f1
store-single [r1] f9;   // store float in f9 to address in r1
load-single f10 4[r1];  // load float at address of r1+4 to f10
add-single f10 f10 f2;  // f10 = f10 + f2
store-single 4[r1] f10; // store float in f10 to address of r1+4
add r1 r1 #8;           // increase the address by 8 bytes

非展开版本的两次迭代的轨迹看起来更像:

load-single f9 [r1];  // load float at address of r1 to f9
add-single f9 f9 f2;  // f9 = f9 + f2
store-single [r1] f9; // store float in f9 to address of r1
add r1 r1 #4;         // increase the address by 4 bytes
...
load-single f9 [r1];  // load float at address of r1 to f9
add-single f9 f9 f2;  // f9 = f9 + f2
store-single [r1] f9; // store float in f9 to address of r1
add r1 r1 #4;         // increase the address by 4 bytes

因为内存寻址指令通常包括添加立即数偏移(安腾是一个不寻常的例外),并且通常不会实现流水线来优化立即数为零的情况,因此使用非零立即数偏移通常是“免费的”。(它确实减少了指令的数量——在这种情况下是 7 条对比 8 条——但通常它也会提高性能。)

关于分支预测,根据 Agner Fog 的Intel、AMD 和 VIA CPU 的微架构:汇编程序员和编译器制造商的优化指南( PDF ),Core2 微架构的分支预测器使用 8 位全局历史。这意味着它会跟踪最后 8 个分支的结果并使用这 8 个位(以及来自指令地址的位)来索引一个表。这允许识别附近分支之间的相关性。

对于您的代码,对应于例如前 8 个分支的分支在每次迭代中不是同一个分支(因为使用了短路),因此很难概念化识别相关性的好坏程度。

分支中的一些相关性是显而易见的。如果px_x[0]>=0是真的,px_x[0]+1>=0也将是真的。如果px_x[0] <(int)threadCopy[0].S_x是真的,那么px_x[0]+1 <(int)threadCopy[0].S_x可能是真的。

如果展开是px_x[n]针对所有四个值进行测试的,n那么这些相关性将被推得更远,以便分支预测器不使用结果。

一些优化的可能性

尽管您没有询问任何优化的可能性,但我将提供一些探索途径。

首先,对于分支,如果不是严格的可移植性是可以的,测试x>=0 && x<y可以简化为(unsigned)x<(unsigned)y. 这不是严格可移植的,因为例如,机器理论上可以以符号幅度格式表示负数,其中最高有效位作为符号,负数由零位指示。对于有符号整数的常见表示,只要y是正符号整数,这种重新解释转换就可以工作,因为负值x将设置最高有效位,因此大于y解释为无符号整数。

px_x其次,通过使用或的 100% 相关性可以显着减少分支的数量px_y

if ((unsigned) px_y[0]<(unsigned int)threadCopy[0].S_y)
{
    if ((unsigned)px_x[0]<(unsigned int)threadCopy[0].S_x)
        pixel_1[0] = threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + px_x[0]];
    else
        pixel_1[0] = 0.0;
    if ((unsigned)px_x[0]+1<(unsigned int)threadCopy[0].S_x)
        pixel_1[2] = threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + (px_x[0]+1)];
    else
        pixel_1[2] = 0.0;
}
if ((unsigned)px_y[0]+1<(unsigned int)threadCopy[0].S_y)
{
    if ((unsigned)px_x[0]<(unsigned int)threadCopy[0].S_x)
        pixel_1[1] = threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + px_x[0]];
    else
        pixel_1[1] = 0.0;
    if ((unsigned)px_x[0]+1<(unsigned int)threadCopy[0].S_x)
        pixel_1[3] = threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + (px_x[0]+1)];
    else
        pixel_1[3] = 0.0;
}

(如果上面的代码部分被复制以展开,它可能应该被复制为一个块,而不是对不同的px_xpx_y值进行交叉测试,以允许px_y分支靠近px_y+1分支并且第一个px_x分支靠近另一个px_x分支和px_x+1分支.)

另一种可能的优化是将 的计算更改w_n为其倒数的计算。这会将一乘三除法更改为四乘一除法。除法比乘法昂贵得多此外,计算近似倒数对 SIMD 更友好,因为通常有倒数估计指令提供可以通过 Newton-Raphson 方法细化的起点。

如果可以接受更糟糕的代码混淆,您可以考虑将代码更改double y = O_L + (double)j * R_L;double y = O_L; ... y += R_L;. (我进行了测试,gcc 似乎无法识别这种优化,可能是因为使用了浮点数和强制转换为双精度。)因此:

for(int imageNo =0; imageNo<496;imageNo++){

double z = O_L;
for (unsigned int k=0; k<256; k++)
{

    double y = O_L;
    for (unsigned int j=0; j<256; j++)
    {
        double x[1]; x[0] = O_L;
        for (unsigned int i=0; i<256; i++)
        {
            ...
            x[0] +=  R_L ;
        } // end of i loop
        y += R_L;
    }  // end of j loop
    z += R_L;
} // end of k loop
    } // end of imageNo loop

我猜这只会适度提高性能,因此相对于收益而言,混淆成本会更高。

另一个可能值得尝试的更改是将一些pix_1计算合并到有条件设置pixel_1[]值的部分中。这会显着混淆代码并且可能没有太多好处。此外,它可能会使编译器的自动向量化更加困难。(通过有条件地将值设置为适当I_n或为零,SIMD 比较可以将每个元素设置为 -1 或 0,并且简单andI_n值将提供正确的值。在这种情况下,形成I_n向量的开销可能不会考虑到 Core2 只支持 2 宽度的双精度 SIMD 是值得的,但如果有收集支持,甚至只是更长的向量,权衡可能会改变。)

但是,当任何和超出范围时,这种更改增加基本块的大小并减少计算量(我猜这并不常见,因此好处最多只能是很小的)。px_xpx_y

double pix_1 = 0.0;
double alpha_diff = 1.0 - alpha;
if ((unsigned) px_y[0]<(unsigned int)threadCopy[0].S_y)
{
    double beta_diff = 1.0 - beta;
    if ((unsigned)px_x[0]<(unsigned int)threadCopy[0].S_x)
        pix1 += alpha_diff * beta_diff 
             * threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + px_x[0]];
    // no need for else statement since pix1 is already zeroed and not 
    // adding the pixel_1[0] factor is the same as zeroing pixel_1[0]
    if ((unsigned)px_x[0]+1<(unsigned int)threadCopy[0].S_x)
        pix1 += alpha * beta_diff 
             * threadCopy[0].I_n[px_y[0] * threadCopy[0].S_x + (px_x[0]+1)];
}
if ((unsigned)px_y[0]+1<(unsigned int)threadCopy[0].S_y)
{
    if ((unsigned)px_x[0]<(unsigned int)threadCopy[0].S_x)
        pix1 += alpha_diff * beta 
             * threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + px_x[0]];
    if ((unsigned)px_x[0]+1<(unsigned int)threadCopy[0].S_x)
        pix1 += alpha * beta 
             * threadCopy[0].I_n[(px_y[0]+1) * threadCopy[0].S_x + (px_x[0]+1)];
}

理想情况下,像您这样的代码将被矢量化,但我不知道如何让 gcc 识别机会,如何使用内在函数表达机会,也不知道在 SIMD 宽度仅为两个的情况下手动矢量化此代码是否值得付出巨大努力.

我不是程序员(只是一个喜欢学习和思考计算机体系结构的人)并且我对微优化有很大的倾向(从上面很清楚),所以应该从这个角度考虑上述建议。

于 2014-03-20T02:57:20.563 回答