【问题标题】:Why is there huge performance hit in 2048x2048 versus 2047x2047 array multiplication?为什么 2048x2048 与 2047x2047 数组乘法相比,性能会受到巨大影响?
【发布时间】:2011-08-28 23:50:31
【问题描述】:

我正在做一些矩阵乘法基准测试,如前所述 Why is MATLAB so fast in matrix multiplication?

现在我遇到了另一个问题,当将两个 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;
   }
 }

【问题讨论】:

  • 对于高级 C 编程或 OS 设计课程来说,这将是一道很棒的考试题 ;-)
  • 您是否尝试过测试多维 [,] 和锯齿状 [][] 数组以及 32 位和 64 位数组?我只测试了几次,但锯齿状看起来更符合你的结果,但锯齿状的 64 位很高,我不知道 jit 中是否有任何适用于这种情况的启发式方法,或者它的缓存是否与之前建议的那样相关。如果你想要一个 GPGPU 解决方案,research.microsoft.com/en-us/projects/accelerator 应该可以与你其他帖子中的时代竞争。
  • 有点幼稚的问题,但是将两个方阵相乘涉及多少操作(加法/乘法)?

标签: c# arrays matrix-multiplication


【解决方案1】:

缓存别名

或者缓存抖动,如果我能创造一个术语的话。

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

假设您的缓存有 4 个字,矩阵为 4 x 4。当访问一列并且该行的长度为 2 的任意幂时,内存中的每个列元素都将映射到同一个缓存元素。

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

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

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

【讨论】:

    【解决方案2】:

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

    More Cache Craziness 和 Computational Performance - A beginners case study 有一些有趣的统计数据并试图更详细地解释行为,这确实归结为缓存大小限制。

    【讨论】:

      【解决方案3】:

      这可能与 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] 并以任意大小运行它,您应该会看到更好的性能。

      【讨论】:

      • 您在最后 2 段中是否有错误?两次尝试完全相同。 :)
      • 缓存关联性也起作用。
      【解决方案4】:

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

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

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

      【讨论】:

      • 但他不太可能拥有 16.7 MB 的 CPU 缓存
      • 我将结果更新为 2049x2049 - 91 秒。如果是“缓存问题”,这不应该还是300+s吗?
      • @Marino 已更新答案以考虑到这一点。
      • 我觉得这些解释都不能充分解决引发问题的各种稀疏尺寸的新细节,而其他解释则不受影响。
      • 我认为这个解释是不正确的。问题在于,当大小为 2 的幂时,由于缓存线冲突,没有充分利用缓存容量。此外,操作系统实际上与缓存无关,因为不是操作系统决定缓存什么以及驱逐什么,这就是全部在硬件中。 OS与数据对齐有关,但在这种情况下,这完全是关于C#如何决定分配数据以及如何在内存中表示二维数组,OS与它无关。
      【解决方案5】:

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

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

      【讨论】:

      • 不正确。 2049 比 2048 快,这反驳了你的说法。
      • @Macke:这很有可能。但是他的处理器中使用的缓存策略仍有轻微的机会可能会做出这个决定。这不太可能,但并非不可想象。
      【解决方案6】:

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

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

      【讨论】:

      • 这并不能解释为什么 2049 比 2048 快。
      • @Macke:那是因为它在内存缓存中通过了一些限制,所以有更多的缓存未命中。
      • 为什么投反对票?如果你不说出你认为错的地方,它就无法改善答案。
      • 另一个没有任何解释的反对票...是不是我的答案中的“可能”、“猜测”和“应该”太少了,就像获得最多赞成票的答案一样...?
      【解决方案7】:

      考虑到时间在更大的尺寸上下降,是否更有可能是缓存冲突,尤其是有问题的矩阵尺寸的 2 次方?我不是缓存问题方面的专家,但在缓存相关的性能问题上提供了极好的信息here。

      【讨论】:

      • 关于缓存关联性的链接的第 5 节似乎特别适用。
      【解决方案8】:

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

      【讨论】:

      • Hmm - 然而声明为 2D 的数组 (float[,] matice = new float[rozmer, rozmer];) 仅在 RAM 中分配为一维数组,并且行/步长计算在引擎盖。那么为什么将其声明为 1D 并进行手动行/步幅计算会更快呢?你的意思是 sol'n 将一个大数组分配为较小的切片数组,每个切片都可以放入缓存中,而大数组不能?
      • 如果您的库或您正在使用的任何工具进行平铺,那么您不需要。但是,如果您要在 C/C++ 中使用传统的 2D 数组,那么平铺将提高性能。
      【解决方案9】:

      可能是缓存效果。由于矩阵维度是 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 缓存逐出,并从较慢的缓存或主内存中获取数据。

      【讨论】:

      • +1。听起来很可能。必须小心缓存关联性。
      【解决方案10】:

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

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

      【讨论】:

      • 我知道 BLAS,但任务不是让它尽可能快,而是用各种语言编写和测试它。这对我来说是一个非常奇怪的问题,我真的很好奇为什么结果会像现在这样。
      • @Wolf 我很难为应该花费一秒钟的事情是花费 90 秒还是 300 秒而感到兴奋。
      • 了解某事物如何工作的最佳方法是自己编写它,看看如何改进您的实现;这是(希望)Wolf 正在做的事情。
      • @Callum Rogers,同意了。这就是我了解缓冲区大小在文件复制操作中的重要性的方式。
      猜你喜欢
      • 1970-01-01
      • 2017-01-24
      • 1970-01-01
      • 2019-08-13
      • 1970-01-01
      • 2012-05-11
      • 1970-01-01
      • 2011-11-15
      • 2020-07-23
      相关资源
      最近更新 更多