关于C++:为什么把512×512的矩阵调换得比转置513×513的矩阵慢得多?

Why is transposing a matrix of 512x512 much slower than transposing a matrix of 513x513?

在对不同尺寸的正方形矩阵进行了一些实验后,得出了一个模式。总的来说,转移大小为2^n的矩阵比转移大小为2^n+1的矩阵慢。对于小值的n,差异不大。

但是,在512的值上会出现很大的差异。(至少对我来说)

免责声明:我知道函数实际上并没有因为元素的双重交换而改变矩阵,但是它没有任何区别。

遵循代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#define SAMPLES 1000
#define MATSIZE 512

#include <time.h>
#include <iostream>
int mat[MATSIZE][MATSIZE];

void transpose()
{
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
   {
       int aux = mat[i][j];
       mat[i][j] = mat[j][i];
       mat[j][i] = aux;
   }
}

int main()
{
   //initialize matrix
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
       mat[i][j] = i+j;

   int t = clock();
   for ( int i = 0 ; i < SAMPLES ; i++ )
       transpose();
   int elapsed = clock() - t;

   std::cout <<"Average for a matrix of" << MATSIZE <<":" << elapsed / SAMPLES;
}

改变MATSIZE让我们改变大小(duh!).I在IDeone上发布了两个版本:

  • 尺寸512-平均2.46 ms-http://ideone.com/1pv7m
  • 尺寸513-平均0.75 ms-http://ideone.com/nshpo

在我的环境中(MSV2010,完全优化),区别类似:

  • 尺寸512-平均2.19 ms
  • 尺寸513-平均0.57 ms

为什么会这样?


解释来自于优化C++软件中的Agne雾,它减少了数据如何访问和存储在缓存中。

有关条款和详细信息,请参见有关缓存的wiki条目,我将在这里缩小范围。

缓存按集合和行组织。一次只能使用一个集合,其中包含的任何行都可以使用。一条线可以镜像的内存乘以线的数量就得到了缓存的大小。

对于一个特定的内存地址,我们可以用以下公式计算应该镜像哪个集合:

1
set = ( address / lineSize ) % numberOfsets

理想情况下,这种公式在集合中给出了一个统一的分布,因为每个内存地址都有可能被读取(我理想地说)。

很明显,重叠会发生。如果缓存未命中,将在缓存中读取内存并替换旧值。记住,每一组都有许多行,其中最近使用的一行被新读取的内存覆盖。

我将尝试遵循Agner的例子:

假设每个集合有4行,每个行包含64个字节。我们首先尝试读取地址0x2710,该地址位于集合28中。然后我们还尝试读取地址0x2F000x37000x3F000x4700。所有这些都属于同一组。在读取0x4700之前,集合中的所有行都将被占用。读取该内存会将集合中的一条现有行逐出,即最初保存0x2710的行。问题在于,我们读取的地址(例如)与0x800分开。这是关键的一步(同样,在本例中)。

临界跨距也可以计算:

1
criticalStride = numberOfSets * lineSize

变量间隔criticalStride或多个分开的缓存线争夺相同的缓存线。

这是理论部分。接下来,解释(也是Agner,我会密切关注它以避免犯错误):

假设一个64x64的矩阵(记住,效果因缓存而异),8kb缓存,每组4行*64字节的行大小。每行可容纳矩阵中的8个元素(64位int)。

临界跨距为2048字节,对应于矩阵的4行(在内存中是连续的)。

假设我们正在处理第28行。我们正在尝试获取此行的元素,并将它们与第28列中的元素交换。行的前8个元素组成一个缓存行,但它们将进入第28列中的8个不同的缓存行。记住,关键的步幅间隔4行(一列中有4个连续的元素)。

当列中达到元素16(每组4条缓存线,间隔4行=故障)时,将从缓存中移出ex-0元素。当我们到达列的末尾时,所有以前的缓存行都将丢失,需要在访问下一个元素时重新加载(整个行被覆盖)。

拥有一个不是关键步幅的倍数的大小会破坏灾难的完美场景,因为我们不再处理垂直方向上关键步幅分开的元素,所以缓存重新加载的数量会严重减少。

另一个免责声明-我只是想解释一下,希望我能理解,但我可能错了。不管怎样,我在等待神秘主义的回应(或确认)。:)


Luchian给出了这种行为发生的原因的解释,但我认为展示这个问题的一个可能的解决方案是个不错的主意,同时展示了一些缓存遗忘算法。

你的算法基本上可以做到:

1
2
3
for (int i = 0; i < N; i++)
   for (int j = 0; j < N; j++)
        A[j][i] = A[i][j];

这对现代的CPU来说是可怕的。一种解决方案是了解缓存系统的详细信息,并调整算法以避免这些问题。只要你知道这些细节就行。不是特别轻便。

我们能做得更好吗?是的,我们可以:解决这个问题的一般方法是缓存遗忘算法,顾名思义,这种算法可以避免依赖于特定的缓存大小[1]

解决方案如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void recursiveTranspose(int i0, int i1, int j0, int j1) {
    int di = i1 - i0, dj = j1 - j0;
    const int LEAFSIZE = 32; // well ok caching still affects this one here
    if (di >= dj && di > LEAFSIZE) {
        int im = (i0 + i1) / 2;
        recursiveTranspose(i0, im, j0, j1);
        recursiveTranspose(im, i1, j0, j1);
    } else if (dj > LEAFSIZE) {
        int jm = (j0 + j1) / 2;
        recursiveTranspose(i0, i1, j0, jm);
        recursiveTranspose(i0, i1, jm, j1);
    } else {
    for (int i = i0; i < i1; i++ )
        for (int j = j0; j < j1; j++ )
            mat[j][i] = mat[i][j];
    }
}

稍微复杂一点,但是一个简短的测试显示了一些非常有趣的东西,关于我的古老的e8400和vs2010 x64版本,用于EDOCX1的测试代码(0)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main() {
    LARGE_INTEGER start, end, freq;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&start);
    recursiveTranspose(0, MATSIZE, 0, MATSIZE);
    QueryPerformanceCounter(&end);
    printf("recursive: %.2fms
"
, (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));

    QueryPerformanceCounter(&start);
    transpose();
    QueryPerformanceCounter(&end);
    printf("iterative: %.2fms
"
, (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));
    return 0;
}

results:
recursive: 480.58ms
iterative: 3678.46ms

编辑:关于大小的影响:虽然在某种程度上仍然很明显,但它不太明显,因为我们将迭代解用作叶节点,而不是递归到1(递归算法的常见优化)。如果我们将leafsize设置为1,缓存对我没有影响[8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms--在误差范围内,波动在100毫秒范围内;如果我们想要完全精确的值,这个"基准"不是我能接受的东西])

[1]这方面的资料来源:如果你不能从和雷瑟森合作的人那里得到一个演讲……我认为他们的论文是一个很好的起点。这些算法仍然很少被描述——clr只有一个脚注。不过,这还是一个让人吃惊的好方法。

编辑(注意:我不是发布此答案的人;我只是想添加此内容):这里有一个完整的C++版本的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
template<class InIt, class OutIt>
void transpose(InIt const input, OutIt const output,
    size_t const rows, size_t const columns,
    size_t const r1 = 0, size_t const c1 = 0,
    size_t r2 = ~(size_t) 0, size_t c2 = ~(size_t) 0,
    size_t const leaf = 0x20)
{
    if (!~c2) { c2 = columns - c1; }
    if (!~r2) { r2 = rows - r1; }
    size_t const di = r2 - r1, dj = c2 - c1;
    if (di >= dj && di > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, (r1 + r2) / 2, c2);
        transpose(input, output, rows, columns, (r1 + r2) / 2, c1, r2, c2);
    }
    else if (dj > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, r2, (c1 + c2) / 2);
        transpose(input, output, rows, columns, r1, (c1 + c2) / 2, r2, c2);
    }
    else
    {
        for (ptrdiff_t i1 = (ptrdiff_t) r1, i2 = (ptrdiff_t) (i1 * columns);
            i1 < (ptrdiff_t) r2; ++i1, i2 += (ptrdiff_t) columns)
        {
            for (ptrdiff_t j1 = (ptrdiff_t) c1, j2 = (ptrdiff_t) (j1 * rows);
                j1 < (ptrdiff_t) c2; ++j1, j2 += (ptrdiff_t) rows)
            {
                output[j2 + i1] = input[i2 + j1];
            }
        }
    }
}


作为Luchian Grigore答案中解释的一个例子,下面是64x64和65x65矩阵的两种情况下的矩阵缓存状态(有关数字的详细信息,请参见上面的链接)。

以下动画中的颜色表示以下内容:

  • white—不在缓存中,
  • light-green在缓存中,
  • bright green–缓存命中,
  • orange–从RAM读取,
  • red–缓存未命中。

64×64例:

cache presence animation for 64x64 matrix

注意,几乎每一次对新行的访问都会导致缓存丢失。现在,它是如何寻找正常情况的,一个65x65矩阵:

cache presence animation for 65x65 matrix

在这里,您可以看到初始预热后的大多数访问都是缓存命中。这就是CPU缓存一般的工作方式。