为什么我的程序在完全循环8192个元素时会变慢?

745 c++ performance gcc memory-management

以下是相关程序的摘录.矩阵img[][]的大小为SIZE×SIZE,并在以下位置初始化:

img[j][i] = 2 * j + i

然后,你创建一个矩阵res[][],这里的每个字段都是img矩阵中它周围9个字段的平均值.为简单起见,边框保留为0.

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}
Run Code Online (Sandbox Code Playgroud)

这就是该计划的全部内容.为了完整起见,以下是之前的内容.没有代码.如您所见,它只是初始化.

#define SIZE 8192
float img[SIZE][SIZE]; // input image
float res[SIZE][SIZE]; //result of mean filter
int i,j,k,l;
for(i=0;i<SIZE;i++) 
    for(j=0;j<SIZE;j++) 
        img[j][i] = (2*j+i)%8196;
Run Code Online (Sandbox Code Playgroud)

基本上,当SIZE是2048的倍数时,此程序很慢,例如执行时间:

SIZE = 8191: 3.44 secs
SIZE = 8192: 7.20 secs
SIZE = 8193: 3.18 secs
Run Code Online (Sandbox Code Playgroud)

编译器是GCC.据我所知,这是因为内存管理,但我对这个主题并不太了解,这就是我在这里问的原因.

另外如何解决这个问题会很好,但如果有人能够解释这些执行时间,我已经足够开心了.

我已经知道malloc/free了,但问题不在于使用的内存量,它只是执行时间,所以我不知道这会有多大帮助.

Mys*_*ial 942

差异是由以下相关问题引起的相同超对齐问题引起的:

但那只是因为代码还有另外一个问题.

从原始循环开始:

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}
Run Code Online (Sandbox Code Playgroud)

首先注意两个内环是微不足道的.它们可以按如下方式展开:

for(i=1;i<SIZE-1;i++) {
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}
Run Code Online (Sandbox Code Playgroud)

这样就留下了我们感兴趣的两个外环.

现在我们可以看到问题在这个问题中是一样的:为什么在迭代2D数组时,循环的顺序会影响性能?

您是按列而不是按行迭代矩阵.


要解决此问题,您应该交换两个循环.

for(j=1;j<SIZE-1;j++) {
    for(i=1;i<SIZE-1;i++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}
Run Code Online (Sandbox Code Playgroud)

这完全消除了所有非顺序访问,因此您不再在大功率二次上获得随机减速.


酷睿i7 920 @ 3.5 GHz

原始代码:

8191: 1.499 seconds
8192: 2.122 seconds
8193: 1.582 seconds
Run Code Online (Sandbox Code Playgroud)

互换的外循环:

8191: 0.376 seconds
8192: 0.357 seconds
8193: 0.351 seconds
Run Code Online (Sandbox Code Playgroud)

  • 我还要注意,展开内部循环对性能没有影响.编译器可能会自动执行此操作.我展开它们的唯一目的是摆脱它们,以便更容易发现外部循环的问题. (212认同)
  • 这是一个关于SO的好答案的完美示例:引用相似的问题,逐步解释如何处理它,解释问题,解释如何修复问题,具有良好的格式,甚至是运行代码的示例在你的机器上.感谢您的贡献. (149认同)
  • @ClickUpvote这实际上是一个硬件(缓存)问题.它与语言无关.如果您尝试使用任何其他编译或JIT到本机代码的语言,您可能会看到相同的效果. (33认同)
  • 并且您可以通过缓存每行的总和来将此代码加速另一个因子.但是,这种和其他优化不在原始问题的范围之内. (26认同)
  • @ClickUpvote:你似乎很误导.那个"第二个循环"只是Mystical手动展开内部循环.这是你的编译器几乎肯定会做的事情,并且Mystical只是为了使外部循环的问题更加明显.这绝不是你应该为自己做的事情. (18认同)
  • 我实际上没有注意到外循环的问题,直到*我*展开内循环之后.通过4个嵌套级别完全不明显.这很有趣,因为循环展开通常会使代码更加难以理解*.但在这种情况下,它让我看到了问题. (11认同)
  • 现在我再次查看代码,它看起来像是图像模糊操作.它将相邻细胞的强度平均化. (6认同)
  • @ted:三分之一不是推测性的; 我计时了.您不缓存数组的整个副本,只缓存最近两行的总和. (6认同)
  • 我在Java中实现了类似的测试,时间的增加也很明显:1.3秒.当按列迭代时,0.3 s.按行迭代时 问题大小是4096个元素,计算机是2年的笔记本电脑. (4认同)
  • 即使这是正确的答案,有些东西并不适合我...外部/第一个索引不应该是'i'而内部的索引不应该是'j'吗?由于OP首先在'i'上循环,因此只需切换原始问题中的索引就可以解决整个问题. (2认同)
  • 此外,与其展开循环,不如将其注释掉。尽管有您的注意,但从视觉上看,展开仍然是修复的重要组成部分。 (2认同)
  • 如果第二个循环比第一个循环更有效,那么我就不想再使用c ++了 (2认同)
  • 公平地说,@ClickUpvote 可能会受益于比 C++ 更高级的语言,如果它可以为您优化这样的问题,例如,如果它是函数式的,您只需指定要对矩阵执行的操作,然后让编译器整理出循环如何发生(可能还有并行化)的详细信息。所以原因是硬件,但他很可能受益于软件。 (2认同)
  • @EricPostpischil,这种技术在一个非常狭窄的领域中应用于二维,足以获得我的两项专利中的第一项:[4,811,414](http://patft.uspto.gov/netacgi/nph-Parser?Sect2=PTO1&Sect2=HITOFF&p = 1&U =/netahtml/PTO /搜索bool.html&R = 1&F = G&升= 50&d = PALL&RefSrch = YES&查询= PN/4811414).这是在25 Mhz是快速处理器的时代. (2认同)

bok*_*kan 57

以下测试是使用Visual C++编译器完成的,因为默认的Qt Creator安装使用它(我猜没有优化标志).使用GCC时,Mystical的版本与我的"优化"代码之间没有太大区别.所以结论是编译器优化比人类更好地处理微优化(我最后).我留下余下的答案供参考.


以这种方式处理图像效率不高.最好使用单维数组.处理所有像素是在一个循环中完成的.可以使用以下方法随机访问点:

pointer + (x + y*width)*(sizeOfOnePixel)
Run Code Online (Sandbox Code Playgroud)

在这种特殊情况下,最好水平计算和缓存三个像素组的总和,因为它们每次使用三次.

我做了一些测试,我认为值得分享.每个结果平均有五个测试.

用户1615209的原始代码:

8193: 4392 ms
8192: 9570 ms
Run Code Online (Sandbox Code Playgroud)

神秘的版本:

8193: 2393 ms
8192: 2190 ms
Run Code Online (Sandbox Code Playgroud)

使用1D阵列的两次传递:第一次传递用于水平和,第二次用于垂直和和平均值.两个传递寻址有三个指针,只有这样的增量:

imgPointer1 = &avg1[0][0];
imgPointer2 = &avg1[0][SIZE];
imgPointer3 = &avg1[0][SIZE+SIZE];

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(*(imgPointer1++)+*(imgPointer2++)+*(imgPointer3++))/9;
}

8193: 938 ms
8192: 974 ms
Run Code Online (Sandbox Code Playgroud)

使用一维数组进行两次传递并进行如下寻址:

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(hsumPointer[i-SIZE]+hsumPointer[i]+hsumPointer[i+SIZE])/9;
}

8193: 932 ms
8192: 925 ms
Run Code Online (Sandbox Code Playgroud)

一次缓存水平求和只是前面一行,所以它们保留在缓存中:

// Horizontal sums for the first two lines
for(i=1;i<SIZE*2;i++){
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
}
// Rest of the computation
for(;i<totalSize;i++){
    // Compute horizontal sum for next line
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
    // Final result
    resPointer[i-SIZE]=(hsumPointer[i-SIZE-SIZE]+hsumPointer[i-SIZE]+hsumPointer[i])/9;
}

8193: 599 ms
8192: 652 ms
Run Code Online (Sandbox Code Playgroud)

结论:

  • 没有使用几个指针和只是增量的好处(我认为它会更快)
  • 缓存水平总和比计算几次更好.
  • 两次通过不快三倍,仅两次.
  • 使用单次传递和缓存中间结果可以快3.6倍

我相信它可以做得更好.

注意 请注意,我写了这个答案来解决一般性能问题,而不是Mystical的优秀答案中解释的缓存问题.一开始它只是伪代码.我被要求在评论中做测试......这是一个完全重构的测试版本.

  • "我认为这至少要快3倍" - 用一些指标或引用来支持这种说法吗? (9认同)
  • @AdamRosenfield"我认为"=假设!="这是"=声明.我没有这方面的指标,我想看一个测试.但我的每个像素需要7个增量,2个子,2个加法和1个div.每个循环使用较少的本地var而不是CPU中的寄存器.另一个需要7个增量,6个减量,1个div和10到20个mul,用于寻址,具体取决于编译器优化.此外,循环中的每条指令都需要前一条指令的结果,这会丢弃Pentiums的超标量体系结构的优点.所以它必须更快. (7认同)
  • 原始问题的答案都是关于内存和缓存效果的.OP的代码如此之慢的原因在于它的内存访问模式是按行而不是按行进行的,这些行具有非常差的引用缓存局部性.它在8192处特别糟糕,因为连续行最终在直接映射缓存或具有低关联性的缓存中使用相同的缓存行,因此缓存未命中率甚至更高.交换环路通过大大增加缓存局部性来提供巨大的性能提升. (3认同)
  • @AdamRosenfield今天早上我很担心,因为我无法重现测试.似乎只有Visual C++编译器才能提高性能.使用gcc,只有一点不同. (2认同)