【问题标题】:Why is the performance of these matrix multiplications so different?为什么这些矩阵乘法的性能如此不同?
【发布时间】:2023-03-10 09:32:02
【问题描述】:

我在 Java 中编写了两个矩阵类,只是为了比较它们的矩阵乘法的性能。一个类 (Mat1) 存储一个 double[][] A 成员,其中矩阵的行 iA[i]。另一个类(Mat2)存储AT,其中TA 的转置。

假设我们有一个方阵 M,我们想要 M.mult(M) 的乘积。致电产品P

当 M 是 Mat1 实例时,使用的算法很简单:

P[i][j] += M.A[i][k] * M.A[k][j]
    for k in range(0, M.A.length)

在 M 是我使用的 Mat2 的情况下:

P[i][j] += M.A[i][k] * M.T[j][k]

这是与T[j][k]==A[k][j] 相同的算法。在 1000x1000 矩阵上,第二个算法在我的机器上大约需要 1.2 秒,而第一个算法至少需要 25 秒。我期待第二个更快,但不是这么快。问题是,为什么会这么快?

我唯一的猜测是第二种算法更好地利用了 CPU 缓存,因为数据以大于 1 个字的块的形式被拉入缓存,第二种算法通过仅遍历行而受益,而第一种算法忽略了数据通过立即转到下面的行(在内存中大约 1000 个字,因为数组按行主要顺序存储)被拉入缓存,没有任何数据被缓存。

我问了一个人,他认为这是因为更友好的内存访问模式(即第二个版本会导致更少的 TLB 软故障)。我完全没有想到这一点,但我可以看出它是如何减少 TLB 错误的。

那么,它是什么?还是有其他原因导致性能差异?

【问题讨论】:

标签: java performance matrix-multiplication


【解决方案1】:

这是因为您的数据的局部性。

在 RAM 中,虽然从您的角度来看是二维的,但它当然存储为连续的字节数组。与一维数组的唯一区别是偏移量是通过插入您使用的两个索引来计算的。

这意味着如果您访问位置x,y 的元素,它将计算x*row_length + y,这将是用于引用指定位置的元素的偏移量。

发生的情况是,一个大矩阵不仅仅存储在内存页面中(这是操作系统管理 RAM 的方式,通过将其拆分为块),因此如果您尝试,它必须在 CPU 缓存中加载正确的页面访问不存在的元素。

只要您连续进行乘法运算,就不会产生任何问题,因为您主要使用页面的所有系数,然后切换到下一个系数,但如果您反转索引,则会发生每个单个元素可能包含在不同的内存页面中,因此每次它需要向 RAM 请求不同的页面时,这几乎适用于您执行的每一次乘法运算,这就是差异如此之大的原因。

(我相当简化了整个解释,只是为了给你关于这个问题的基本概念)

无论如何,我不认为这是由 JVM 本身引起的。这可能与您的操作系统如何管理 Java 进程的内存有关..

【讨论】:

  • “在 RAM 中是一个矩阵,虽然从您的角度来看是二维的,但它当然存储为一个连续的字节数组。”。这对 Java 来说是不正确的。在 Java 中,二维数组表示为数组数组。每个级别的数组的位置取决于 1) 它们的分配方式和 2) 垃圾收集器是否将它们保持在一起。
  • Stephen C:没错,但我的数组被分配为:int n;新双[n][n];所以很明显 jvm 会尝试将它分配到一个连续的块中
  • @Jack - 你有这方面的参考吗?还是只是假设?
  • @CromTheDestroyer -“显然”!=“事实”:-)
  • @Jack - 在某种程度上,JVM必须将 double[][] 实现为数组对象的数组。 JLS 要求 ((double[][])obj)[1] 评估为与普通 double[] 引用无法区分的引用。理论上,JIT 可以进行全局分析以确定不需要对数组的“内部”引用,并且可以使用连续的内存块。然而,随后的动态加载可能会使这种分析无效,使 JVM 处于必须查找和转换现有多维数组 objects 的表示的不可能的境地。
【解决方案2】:

缓存和 TLB 假设都是合理的,但我想看看你的基准测试的完整代码......而不仅仅是伪代码 sn-ps。

另一种可能性是,性能差异是由于您的应用程序在转置版本中为数据数组使用了 50% 以上的内存。如果您的 JVM 的堆大小很小,这可能会导致 GC 运行过于频繁。这很可能是使用默认堆大小的结果。 (三批1000 x 1000 x 8 字节约为24Mb)

尝试将初始和最大堆大小设置为(例如)当前最大大小的两倍。如果这没有区别,那么这不是一个简单的堆大小问题。

【讨论】:

  • 可能是有误会,但是存储数据越多的情况越快。并且直到乘法完成之后才会真正进行大量 GC,因此不会干扰时间。
【解决方案3】:

很容易猜到问题可能出在局部性上,也许确实如此,但这仍然是一个猜测。

不必猜测。两种技术可能会给您答案 - 单步执行和随机暂停。

如果您单步执行缓慢的代码,您可能会发现它正在做很多您从未梦想过的事情。比如,你问?试一试就知道了。您应该看到它在机器语言级别上做的事情是有效地通过内部循环而没有浪费动作。

如果它实际上是在没有浪费动作的情况下通过内部循环,那么随机暂停将为您提供信息。由于慢的比快的要花 20 倍的时间,这意味着 95% 的时间它在做一些它不需要做的事情。所以看看它是什么。每次暂停时,您都有 95% 的机会看到那是什么,以及为什么。

如果在慢速情况下,它正在执行的指令看起来与快速情况一样有效,那么缓存局部性是对其缓慢原因的合理猜测。我敢肯定,一旦你消除了可能发生的任何其他愚蠢行为,缓存位置占据主导地位。

【讨论】:

    【解决方案4】:

    鉴于set of results...,您可以尝试比较 JDK6 和 OpenJDK7 之间的性能...

    【讨论】:

      猜你喜欢
      • 2011-03-14
      • 1970-01-01
      • 1970-01-01
      • 2020-10-01
      • 1970-01-01
      相关资源
      最近更新 更多