ChatGPT解决这个技术问题 Extra ChatGPT

为什么我的程序在恰好循环 8192 个元素时很慢?

这是相关程序的摘录。矩阵 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;
}

这就是程序的全部内容。为了完整起见,这里是之前的内容。后面没有代码。如您所见,这只是初始化。

#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;

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

SIZE = 8191: 3.44 secs
SIZE = 8192: 7.20 secs
SIZE = 8193: 3.18 secs

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

另外如何解决这个问题会很好,但如果有人能解释这些执行时间,我已经很高兴了。

我已经知道 malloc/free,但问题不在于使用的内存量,而只是执行时间,所以我不知道这会有什么帮助。

@bokan 当大小是缓存临界步长的倍数时会发生这种情况。
@Mysticial,没关系,它暴露了同样的问题;代码可以不同,但基本上这两个问题都在同一时间提出(并且它们的标题绝对相似)。
如果您想要高性能,则不应使用二维数组处理图像。考虑所有像素都处于原始状态并像一维数组一样处理它们。分两遍做这个模糊。首先使用 3 个像素的滑动和来添加周围像素的值:slideSum+=src[i+1]-src[i-1]; dest[i]=slideSum;.然后垂直做同样的事情,同时除法:dest[i]=(src[i-width]+src[i]+src[i+width])/9。 www-personal.engin.umd.umich.edu/~jwvm/ece581/18_RankedF.pdf
这里实际上发生了两件事。这不仅仅是超级对齐。
(只是对你的答案的一个小问题。对于第一个代码段,如果你所有的 for 循环都有大括号会很好。)

C
Community

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

为什么转置 512x512 的矩阵比转置 513x513 的矩阵慢得多?

矩阵乘法:矩阵大小差异小,时序差异大

但这只是因为代码还有另一个问题。

从原始循环开始:

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;
}

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

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;
    }
}

这样就剩下我们感兴趣的两个外循环了。

现在我们可以看到这个问题的问题是一样的:Why does the order of the loops affect performance when iterating over a 2D array?

您正在逐列而不是逐行迭代矩阵。

要解决这个问题,您应该交换两个循环。

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;
    }
}

这完全消除了所有非顺序访问,因此您不再会因大的二次幂而随机减速。

酷睿 i7 920 @ 3.5 GHz

原始代码:

8191: 1.499 seconds
8192: 2.122 seconds
8193: 1.582 seconds

互换外环:

8191: 0.376 seconds
8192: 0.357 seconds
8193: 0.351 seconds

我还会注意到展开内部循环对性能没有影响。编译器可能会自动执行此操作。我展开它们的唯一目的是摆脱它们,以便更容易发现外部循环的问题。
通过缓存每一行的总和,您可以将这段代码的速度再提高三倍。但这和其他优化超出了原始问题的范围。
@ClickUpvote 这实际上是一个硬件(缓存)问题。它与语言无关。如果您尝试使用任何其他编译或 JIT 为本机代码的语言,您可能会看到相同的效果。
@ClickUpvote:您似乎被误导了。那个“第二个循环”只是神秘的用手展开内部循环。无论如何,您的编译器几乎肯定会这样做,而 Mystical 只是为了使外部循环的问题更加明显。这绝不是您应该为自己做的事情。
这是一个关于 SO 的好答案的完美示例:引用类似的问题,逐步解释您如何处理它,解释问题,解释如何解决问题,具有很好的格式,甚至是运行代码的示例在你的机器上。感谢您的贡献。
1
18 revs, 5 users 83%

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

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

pointer + (x + y*width)*(sizeOfOnePixel)

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

我做了一些测试,我认为值得分享。每个结果是五次测试的平均值。

user1615209的原始代码:

8193: 4392 ms
8192: 9570 ms

神秘的版本:

8193: 2393 ms
8192: 2190 ms

使用一维数组的两遍:第一遍用于水平总和,第二遍用于垂直总和和平均值。使用三个指针进行两遍寻址,并且仅像这样递增:

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

两遍使用一维数组并像这样寻址:

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

8193: 932 ms
8192: 925 ms

一次缓存水平总和仅向前一行,因此它们保留在缓存中:

// 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

结论:

使用多个指针和增量没有任何好处(我认为它会更快)

缓存水平总和比多次计算要好。

两次通过不是快三倍,只有两次。

使用单次传递和缓存中间结果可以实现 3.6 倍的速度

我确信可以做得更好。

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


“我认为它至少快了 3 倍”——愿意用一些指标或引用来支持这一说法吗?
@AdamRosenfield“我认为”=假设!=“它是”=声称。我没有这方面的指标,我想看看测试。但我的需要 7 个增量,2 个 sub,2 个 add 和一个 div 每个像素。每个循环使用的本地变量少于 CPU 中的寄存器。另一个需要 7 个增量、6 个减量、1 个 div 和 10 到 20 mul 之间的寻址,具体取决于编译器优化。此外,循环中的每条指令都需要前一条指令的结果,这放弃了奔腾超标量架构的好处。所以它必须更快。
原始问题的答案都是关于内存和缓存的影响。 OP 的代码如此缓慢的原因是它的内存访问模式是按列而不是按行进行的,这具有非常差的缓存引用局部性。它在 8192 处特别糟糕,因为连续行最终使用直接映射缓存或低关联性缓存中的相同缓存行,因此缓存未命中率甚至更高。通过大大增加缓存局部性,交换循环提供了巨大的性能提升。
因此,虽然您可以像您一样通过计算指令和微优化来获得更多性能,但巨大的性能提升来自按行顺序遍历数据以最大化缓存局部性(您已经也完成了)。由于循环交换,我相信原始代码的 3 倍增益(或更多),但绝对不是 Mystical 答案的 3 倍增益。
@AdamRosenfield 今天早上我很担心,因为我无法重现测试。似乎只有 Visual C++ 编译器才能提高性能。使用 gcc,只有很小的区别。