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


218

在对不同大小的正方形矩阵进行了一些实验之后,出现了一个模式。不变地,转置大小矩阵2^n比转置size 慢2^n+1。对于的较小值n,差异不大。

但是,相差超过512。(至少对我而言)

免责声明:我知道由于元素的两次交换,该函数实际上并未转置矩阵,但没有区别。

遵循代码:

#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使我们可以更改大小(du!)。我在ideone上发布了两个版本:

在我的环境(MSVS 2010,全面优化)中,区别是相似的:

  • 大小512-平均2.19毫秒
  • 大小513-平均0.57毫秒

为什么会这样呢?


9
您的代码看起来对我不友好。
CodesInChaos

7
这个问题几乎是相同的问题:stackoverflow.com/questions/7905760/…–
Mysticial

关心细节,@ CodesInChaos吗?(或其他任何人。)
corazza 2012年

@Bane如何阅读已接受的答案?
CodesInChaos 2012年

4
@nzomkxia无需优化就可以测量任何东西。在禁用优化的情况下,生成的代码将被多余的垃圾乱堆,这些垃圾将隐藏其他瓶颈。(例如记忆)
Mysticial

Answers:


197

解释来自Agner Fog 在C ++优化软件中它解释了如何访问数据并将其存储在缓存中。

有关术语和详细信息,请参见有关缓存Wiki条目,我将在此处缩小范围。

缓存按组织。一次只能使用一组,而其中包含的任何行都可以使用。一行可以镜像的内存乘以行数即可得出缓存大小。

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

set = ( address / lineSize ) % numberOfsets

理想情况下,这种公式可以使集合之间的分布均匀,因为每个内存地址都可能被读取(我理想地说是)。

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

我将尝试遵循Agner的示例:

假设每个集合有4行,每行包含64个字节。我们首先尝试读取地址0x2710,该地址已固定28。然后,我们也尝试读取地址0x2F000x37000x3F000x4700。所有这些都属于同一集合。在阅读之前0x4700,该集中的所有行都已被占用。读取该内存会将集合中的现有行逐出,该行最初为0x2710。问题在于我们读取的地址(对于本示例而言)是0x800分开的。这是关键的一步(再次针对此示例)。

临界跨度也可以计算为:

criticalStride = numberOfSets * lineSize

criticalStride相同的高速缓存行之间的变量间隔或倍数竞争。

这是理论部分。接下来,进行解释(也是Agner,我在紧跟其后以避免出错):

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

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

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

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

大小不是关键跨度的倍数,这使灾难的完美方案变得混乱,因为我们不再处理垂直上跨度关键跨度的元素,因此缓存重载的数量大大减少了。

另一个免责声明 -我只是想解释一下,希望我能牢牢抓住它,但我可能会误会。无论如何,我正在等待Mysticial的回复(或确认)。:)


哦,下次。直接通过休息室 ping我。我在SO上找不到名称的每个实例。:)我只在定期的电子邮件通知中看到了这一点。
Mysticial

我的朋友@Mysticial @Luchian格里戈里一个告诉我,他Intel core i3的电脑上运行Ubuntu 11.04 i386演示了几乎相同的性能GCC 4.6。而这样是为我的电脑一样Intel Core 2 DuoMinGW的GCC4.4,谁上运行windows 7(32)。它也表现出很大的差异时,我编译该段与旧的小PC intel centrinoGCC 4.6,谁上运行ubuntu 12.04 i386
陈宏旭2012年

还要注意,地址差为4096的倍数的内存访问对Intel SnB系列CPU有错误的依赖性。(即页面内的相同偏移量)。特别是在存储某些操作时,这可能会降低吞吐量。负载和存储的混合。
彼得·科德斯

which goes in set 24您是说“ 28集”吗?您是否假设32套?
Ruslan

您是正确的,它是28。:)我也仔细检查了链接的文件,有关原始解释,您可以导航到9.2缓存组织
Luchian Grigore

78

Luchian解释了为什么会发生这种行为,但是我认为这是一个不错的主意,它可以显示对此问题的一种可能的解决方案,同时还可以说明一些关于缓存不了解的算法。

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

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

这对于现代CPU来说太可怕了。一种解决方案是了解有关缓存系统的详细信息,并调整算法以避免这些问题。只要知道这些细节,效果就很好。

我们可以做得更好吗?是的,我们可以:一种解决此问题的通用方法是忽略缓存的算法,顾名思义,该算法避免依赖于特定的缓存大小[1]

解决方案如下所示:

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

稍微复杂一点,但是简短测试显示了我在带有VS2010 x64版本的古老e8400上的一些有趣之处,测试代码为 MATSIZE 8192

int main() {
    LARGE_INTEGER start, end, freq;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&start);
    recursiveTranspose(0, MATSIZE, 0, MATSIZE);
    QueryPerformanceCounter(&end);
    printf("recursive: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));

    QueryPerformanceCounter(&start);
    transpose();
    QueryPerformanceCounter(&end);
    printf("iterative: %.2fms\n", (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-在误差范围之内,波动在100ms范围内;如果我们想要完全准确的值,那么这个“基准”并不是我不愿意接受的]]

[1]这些资料的来源:好吧,如果您不能从与Leiserson合作并就此进行过合作的人那里获得讲座,那么我认为他们的论文是一个很好的起点。那些算法仍然很少被描述-CLR仅有一个脚注。仍然是使人感到惊讶的好方法。


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

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

2
如果您比较不同大小的矩阵之间的时间,而不是递归和迭代,这将是有意义的。在指定大小的矩阵上尝试递归解决方案。
Luchian Grigore

@Luchian既然您已经解释了为什么他看到这种行为,所以我认为引入一种解决此问题的方法很有趣。
Voo

因为,我在质疑为什么更大的矩阵需要较短的时间来处理,而不是寻找更快的算法……
Luchian Grigore 2012年

@Luchian 16383和16384之间的差异是.. 28 vs 27ms在这里对我来说,大约是3.5%-并不是很明显。如果是的话,我会感到惊讶。
Voo

3
解释它的作用可能很有趣recursiveTranspose,即通过操作(尺寸)小的图块,它不会足够填满缓存LEAFSIZE x LEAFSIZE
Matthieu M.

60

作为对Luchian Grigore答案的解释的说明,这是64x64和65x65两种矩阵情况下矩阵高速缓存的外观(有关数字的详细信息,请参见上面的链接)。

以下动画中的颜色表示以下含义:

  • 白色 –不在缓存中,
  • 浅绿色 –在缓存中,
  • 鲜绿色 –缓存命中,
  • 橙子 –只是从RAM读取
  • 红 –缓存未命中。

64x64大小写:

64x64矩阵的缓存存在动画

注意几乎每次对新行的访问都会导致高速缓存未命中。现在,它如何查找正常情况下的65x65矩阵:

65x65矩阵的缓存存在动画

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


可以在此处看到为上述动画生成帧的代码。


为什么在第一种情况下不保存垂直扫描缓存命中,而在第二种情况下保存呢?在两个示例中,大多数块似乎都只一次访问了给定的块。
乔西亚·约德

我可以从@LuchianGrigore的答案中看到,这是因为该列中的所有行都属于同一集合。
Josiah Yoder '18

是的,很好的例证。我看到它们的速度相同。但实际上,不是,不是吗?
kelalaka

@kelalaka是的,动画FPS是相同的。我没有模拟减速,这里只有颜色很重要。
Ruslan '18

具有两个静态图像说明不同的缓存集将是很有趣的。
乔西亚·约德
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.