【问题标题】:Optimization of the algorithm when working with images in C (image convolution)在 C 中处理图像时优化算法(图像卷积)
【发布时间】:2020-08-13 20:05:24
【问题描述】:

我需要编写一个程序,将图像读入数组,执行卷积操作(锐化)并将其保存回文件(ppm)。我写了一个标准算法:

    unsigned char* imgBefore = malloc(height*(3*width)*sizeof(unsigned char));
    assert(fread(imgBefore, 3*width, height, inputFile) == height);
    unsigned char* imgAfter = malloc(height*(3*width)*sizeof(unsigned char));

    short ker[3][3] = {{0, -1, 0}, {-1, 5, -1}, {0, -1, 0}};

    unsigned char r, g, b;

    for(int y = 0; y < height; y++) {
        for(int x = 0; x < width; x++) {
            if(y == 0 || y == height-1 || x == 0 || x == width-1) {
                r = imgBefore[3*(width*y + x) + 0];
                g = imgBefore[3*(width*y + x) + 1];
                b = imgBefore[3*(width*y + x) + 2];
                imgAfter[3*(width*y + x) + 0]  = r;
                imgAfter[3*(width*y + x) + 1]  = g;
                imgAfter[3*(width*y + x) + 2]  = b;
                continue;
            }

            int rSum = 0, gSum = 0, bSum = 0, val;

            for(int dy = 0; dy < 3; dy++) { // kernels height
                int yy = 3*width*(y+dy-1);
                for(int dx = 0; dx < 3; dx++) { // kerenels width
                    int xx = 3*(x+dx-1);
                    val = ker[dy][dx];

                    rSum += val * imgBefore[yy + xx + 0];
                    gSum += val * imgBefore[yy + xx + 1];
                    bSum += val * imgBefore[yy + xx + 2];
                }
            }
            rSum = rSum < 0 ? 0 : (rSum > 255 ? 255 : rSum);
            gSum = gSum < 0 ? 0 : (gSum > 255 ? 255 : gSum);
            bSum = bSum < 0 ? 0 : (bSum > 255 ? 255 : bSum);

            imgAfter[3*(width*y + x) + 0] = rSum;
            imgAfter[3*(width*y + x) + 1] = gSum;
            imgAfter[3*(width*y + x) + 2] = bSum;
        }
    }

    fwrite(imgAfter, 3*width, height, outputFile);

接下来,我需要优化它与缓存交互的效率。在我看来,问题部分出在这段代码中:

for(int dy = 0; dy < 3; dy++) { // kernels height
    int yy = 3*width*(y+dy-1);
    for(int dx = 0; dx < 3; dx++) { // kerenels width
        int xx = 3*(x+dx-1);
        val = ker[dy][dx];

        rSum += val * imgBefore[yy + xx + 0];
        gSum += val * imgBefore[yy + xx + 1];
        bSum += val * imgBefore[yy + xx + 2];
    }
}

因为它首先加载矩阵的一行,仅使用 3 (9) 个元素,然后转到下一行。与缓存相比,这似乎完全无效。

我能做些什么来解决这个问题?

我还尝试重用单个像素或整行。所有这一切只会使结果恶化(很有可能我只是简单地实现了 FIFO 之类的结构,或者在错误的地方添加和读取它们)。如果一个程序需要它,它应该是什么样子? 对于评估,我使用: valgrind --tool=cachegrind --I1=32768,8,64 --D1=32768,8,64 --LL=1048576,16,64 ./a.out

如果有任何建议,我将不胜感激

【问题讨论】:

  • 您假设代码受内存层次结构的限制,但我认为这不是最关键的一点:您的代码既没有被 GCC 也没有 Clang 向量化(它似乎也没有被并行化)。问题来自对 SIMD 不友好的数据布局。因此,我希望代码受到计算的限制。您是否尝试过分析您的程序(valgrind 除外)?您用于进行测试的图像的典型尺寸是多少?
  • 这项工作的本质是优化缓存访问。我首先想到的事情之一是并行化,但我意识到可以使用编译器选项添加它,这在我的情况下是固定的。还是我弄错了?在编译选项中,唯一我不知道也找不到信息的是 -mssse3 选项。这能以某种方式帮助吗?图像分辨率 4608x3456
  • 好的。 -mssse3 是启用 SSSE3 指令集启用矢量化的选项。它在这里应该没有帮助,因为编译器可能无法自动 向量化此代码。对于并行化,您可以使用 OpenMP 轻松完成。在 y 循环之前的一个简单的 #pragma omp parallel for 指令应该会给你一个很好的加速(不要忘记使用编译器选项启用 OpenMP,比如 GCC/Clang 上的 -fopenmp)。

标签: c caching optimization convolution ppm


【解决方案1】:

您访问数据的方式对于大图像不适合缓存。这可以改进(例如使用平铺)。

请注意,此程序的计算时间不受内存层次结构的限制,而是由低效的数据布局导致的基于标量的缓慢顺序计算计算。

这是使用4608 x 3456 x 3 图像对 10 个模板函数调用的 Valgrind 分析结果:

--171871-- warning: L3 cache found, using its data for the LL simulation.
--171871-- warning: specified LL cache: line_size 64  assoc 12  total_size 9,437,184
--171871-- warning: simulated LL cache: line_size 64  assoc 18  total_size 9,437,184
==171871== 
==171871== I   refs:      30,437,416,378
==171871== I1  misses:               944
==171871== LLi misses:               938
==171871== I1  miss rate:           0.00%
==171871== LLi miss rate:           0.00%
==171871== 
==171871== D   refs:       7,526,412,742  (7,000,797,573 rd   + 525,615,169 wr)
==171871== D1  misses:        30,599,898  (   22,387,768 rd   +   8,212,130 wr)
==171871== LLd misses:        15,679,220  (    7,467,138 rd   +   8,212,082 wr)
==171871== D1  miss rate:            0.4% (          0.3%     +         1.6%  )
==171871== LLd miss rate:            0.2% (          0.1%     +         1.6%  )
==171871== 
==171871== LL refs:           30,600,842  (   22,388,712 rd   +   8,212,130 wr)
==171871== LL misses:         15,680,158  (    7,468,076 rd   +   8,212,082 wr)
==171871== LL miss rate:             0.0% (          0.0%     +         1.6%  )

首先,我们可以看到有很多D1 cache reference(每个写入像素47,由于标量访问)。

D1 missesD1 miss rate 的数量似乎很少,所以我们可以认为缓存访问是好的。但事实并非如此。 Valgrind 结果非常具有误导性,因为D1 cache reference 工作在 1 字节标量粒度,而D1 misses 工作在 64 字节缓存行粒度。

如果我们再仔细分析一下,我们可以看到几乎所有写入的数据都会导致 D1 缓存未命中。此外,从 L1 读取的 20% 的数据需要从 LLC(重新)加载(对于从 D1 缓存读取的 7 GB,从 LLC 加载 1.43 GB)。

问题来自图像的大线条。实际上,1 行图像需要4608 * 3 = 13824 字节,而写一行需要 3 行。因此,从 D1 缓存中读取 41472 字节以写入 13824 字节。理论上,只要有足够大的 D1 缓存,就应该从 D1 缓存中重用 27648。但是,D1 缓存太小,无法容纳所有读/写数据(大概是因为41472+13824 &gt; 32768)。

我们可以使用两种方法来改善这种情况:

  • 平铺:想法是将x循环绑定在一个小的固定大小(例如1024),以便计算图像的垂直块,然后添加一个新的包含xBlock循环(包括yx 循环)遍历垂直块。这种方法有提高 D1 缓存重用的好处,但仍可能在现代处理器上导致一些不必要的缓存未命中(因为加载的行不再连续)。您可以通过在y 维度上执行平铺或调整数据布局来缓解这种情况。这是一个例子:
const int blockSize = 1024;
for(int xBlock = 0; xBlock < width; xBlock+=blockSize) {
    for(int y = 0; y < height; y++) {
        int xMax = xBlock + blockSize;
        if(xMax > width)
            xMax = width;
        for(int x = xBlock; x < xMax; x++) {
            // Unchanged part of the code
        }
    }
}
  • 非临时存储:将数据写入缓存层次结构会导致缓存污染,从而导致额外的缓存未命中(因为写入的数据需要清除之前读取的缓存行)。它还可能在某些架构上造成更多负载(例如,在英特尔处理器上)。我们可以告诉处理器将数据直接写入内存。一些编译器提供#pragma 指令来执行此操作(手动执行此操作在您的代码中有点棘手)。仅当图像太大而无法放入 LLC 缓存或以后不重用时,这才有用。请参阅here 了解更多信息。

这是应用平铺方法后的 Valgrind 结果:

==183432== D   refs:       7,527,450,132  (7,001,731,093 rd   + 525,719,039 wr)
==183432== D1  misses:        16,025,288  (    7,640,368 rd   +   8,384,920 wr)
==183432== LLd misses:        16,024,790  (    7,639,918 rd   +   8,384,872 wr)

我们可以看到,现在 D1 缓存未命中次数减少了 2 倍(读取次数减少了 3 倍)。

【讨论】: