131

我正在做一些矩阵乘法基准测试,正如前面提到的 为什么 MATLAB 在矩阵乘法中如此之快?

现在我遇到了另一个问题,当将两个 2048x2048 矩阵相乘时,C# 与其他矩阵之间存在很大差异。当我尝试仅乘以 2047x2047 矩阵时,这似乎很正常。也添加了一些其他的进行比较。

1024x1024 - 10 秒。

1027x1027 - 10 秒。

2047x2047 - 90 秒。

2048x2048 - 300 秒。

2049x2049 - 91 秒。(更新)

2500x2500 - 166 秒

对于 2k x 2k 的情况,这是三分半钟的差异。

使用 2dim 数组

//Array init like this
int rozmer = 2048;
float[,] matice = new float[rozmer, rozmer];

//Main multiply code
for(int j = 0; j < rozmer; j++)
{
   for (int k = 0; k < rozmer; k++)
   {
     float temp = 0;
     for (int m = 0; m < rozmer; m++)
     {
       temp = temp + matice1[j,m] * matice2[m,k];
     }
     matice3[j, k] = temp;
   }
 }
4

10 回答 10

63

这可能与 L2 缓存中的冲突有关。

matice1 上的缓存未命中不是问题,因为它们是按顺序访问的。但是对于 matice2,如果一个完整的列适合 L2(即当您访问 matice2[0, 0]、matice2[1, 0]、matice2[2, 0] ... 等时,没有任何东西被驱逐),那么没有问题使用 matice2 缓存也未命中。

现在更深入地了解缓存的工作原理,如果变量的字节地址是 X,那么它的缓存行将是 (X >> 6) & (L - 1)。其中 L 是缓存中的缓存行总数。L 始终是 2 的幂。六来自事实,即 2^6 == 64 字节是高速缓存行的标准大小。

现在这是什么意思?这意味着如果我有地址 X 和地址 Y 并且 (X >> 6) - (Y >> 6) 可以被 L 整除(即 2 的某个大幂),它们将存储在同一个缓存行中。

现在回到你的问题 2048 和 2049 有什么区别,

当 2048 是您的尺寸时:

如果您采用 &matice2[x, k] 和 &matice2[y, k] 差 (&matice2[x, k] >> 6) - (&matice2[y,k] >> 6) 将可被 2048 * 4 (大小浮动)。所以 2 的大幂。

因此,根据 L2 的大小,您将有很多缓存行冲突,并且仅利用 L2 的一小部分来存储列,因此您实际上无法在缓存中存储完整列,因此您将获得糟糕的性能.

当大小为 2049 时,差异为 2049 * 4,这不是 2 的幂,因此您将有更少的冲突,并且您的列将安全地适合您的缓存。

现在要测试这个理论,你可以做几件事:

像这样 matice2 [razmor, 4096] 分配您的数组 matice2 数组,并以 razmor = 1024、1025 或任何大小运行,与以前相比,您应该会看到非常糟糕的性能。这是因为您强制对齐所有列以使其相互冲突。

然后尝试 matice2 [razmor, 4097] 并以任何大小运行它,您应该会看到更好的性能。

于 2011-05-19T17:58:09.167 回答
21

可能是缓存效果。由于矩阵维度是 2 的大幂,并且缓存大小也是 2 的幂,您最终只能使用 L1 缓存的一小部分,从而大大降低速度。朴素矩阵乘法通常受限于将数据提取到缓存中的需要。使用平铺(或缓存忽略算法)的优化算法专注于更好地利用 L1 缓存。

如果您对其他对 (2^n-1,2^n) 计时,我希望您会看到类似的效果。

为了更全面地解释,在访问 matice2[m,k] 的内部循环中,matice2[m,k] 和 matice2[m+1,k] 可能彼此偏移 2048*sizeof(float)从而映射到 L1 缓存中的相同索引。使用 N 路关联缓存,您通常会有 1-8 个缓存位置用于所有这些缓存。因此,几乎所有这些访问都会触发 L1 缓存逐出,并从较慢的缓存或主内存中获取数据。

于 2011-05-19T15:31:57.193 回答
16

这可能与您的 cpu 缓存大小有关。如果矩阵矩阵的 2 行不适合,那么您将浪费时间从 RAM 中交换元素。额外的 4095 个元素可能足以防止行拟合。

在您的情况下,2047 个二维矩阵的 2 行位于 16KB 的内存中(假设为 32 位类型)。例如,如果您有一个 64KB 的 L1 缓存(最接近总线上的 CPU),那么您可以一次将至少 4 行(2047 * 32)放入缓存中。对于较长的行,如果需要任何填充将行对推到 16KB 以上,那么事情就会开始变得混乱。此外,每次您“错过”缓存时,从另一个缓存或主内存中交换数据都会延迟事情。

我的猜测是,您在不同大小的矩阵中看到的运行时间差异受到操作系统如何有效地利用可用缓存的影响(并且某些组合只是有问题)。当然,这只是我的一个粗略的简化。

于 2011-05-19T15:26:00.460 回答
11

Louis Brandy 写了两篇博客文章来分析这个问题:

更多缓存疯狂计算性能 - 一个初学者案例研究,其中包含一些有趣的统计数据并尝试更详细地解释行为,它确实归结为缓存大小限制。

于 2011-05-19T21:29:53.277 回答
5

鉴于时间在较大尺寸下下降,是否更有可能发生缓存冲突,特别是对于有问题的矩阵尺寸的 2 次幂?我不是缓存问题专家,但这里有关于缓存相关性能问题的优秀信息。

于 2011-05-19T16:34:01.610 回答
4

当您matice2垂直访问数组时,它将更多地换入和换出缓存。如果您对角镜像数组,以便您可以使用[k,m]而不是访问它[m,k],代码将运行得更快。

我对 1024x1024 矩阵进行了测试,它的速度大约是原来的两倍。对于 2048x2048 矩阵,它大约快十倍。

于 2011-05-19T17:09:31.700 回答
4

缓存别名

或者缓存抖动,如果我可以创造一个术语。

缓存通过低位索引和高位标记来工作。

想象一下你的缓存有 4 个字,你的矩阵是 4 x 4。当访问一列并且行的长度是 2 的任意幂时,内存中的每个列元素都将映射到同一个缓存元素。

对于这个问题,二加一的幂实际上是最佳的。每个新的列元素都将映射到下一个缓存槽,就像按行访问一样。

在现实生活中,一个标签覆盖多个顺序增加的地址,这些地址将缓存一行中的几个相邻元素。通过偏移每个新行映射到的存储桶,遍历该列不会替换前一个条目。当遍历下一列时,整个缓存将被不同的行填充,并且适合缓存的每个行部分将命中几列。

由于缓存比 DRAM 快得多(主要是由于是片上的)命中率就是一切。

于 2011-05-21T06:17:32.363 回答
2

您似乎已达到缓存大小限制,或者您的计时可能存在一些可重复性问题。

无论问题是什么,您都不应该自己用 C# 编写矩阵乘法,而应使用 BLAS 的优化版本。在任何现代机器上,矩阵的大小都应该在一秒钟内成倍增加。

于 2011-05-19T15:33:12.767 回答
1

有效利用缓存层次结构非常重要。您需要确保多维数组的数据排列良好,这可以通过tiling来完成。为此,您需要将 2D 数组存储为 1D 数组以及索引机制。传统方法的问题是,虽然内存中同一行的两个相邻数组元素是相邻的,但是同一列中的两个相邻元素在内存中会被W个元素隔开,其中W是列数. 平铺可以产生多达十倍的性能差异。

于 2011-05-19T16:16:57.717 回答
0

我怀疑这是所谓的“顺序洪水”的结果。这是因为您试图遍历略大于缓存大小的对象列表,因此对列表(数组)的每个请求都必须从 ram 完成,并且您不会获得单个缓存打。

在您的情况下,您正在遍历数组 2048 索引 2048 次,但您只有 2047 的空间(可能是由于数组结构的一些开销),因此每次访问数组 pos 时,它都需要获取此数组 pos从公羊。然后将其存储在缓存中,但在再次使用之前,它会被转储。所以缓存本质上是无用的,导致执行时间更长。

于 2011-05-19T17:25:10.820 回答