【问题标题】:Why is factorial calculation much faster in Haskell than in Java为什么 Haskell 中的阶乘计算比 Java 中的快得多
【发布时间】:2013-07-09 05:00:21
【问题描述】:

我遇到的一个编程问题涉及计算大数(最多 10^5 的数)的阶乘。我见过一个简单的 Haskell 代码,它是这样的

factorial :: (Eq x, Num x) => x -> x
factorial 0 = 1
factorial a = a * factorial (a - 1)

它隐式地处理大量数字,并且即使在代码中不涉及任何缓存的情况下也能以某种方式运行得更快。

当我尝试使用 Java 解决问题时,我不得不使用 BigInteger 来保存巨大的数字并使用迭代版本的阶乘

public static BigInteger factorialIterative(int n)
{
        if(n == 0 || n == 1) return BigInteger.valueOf(1);
        BigInteger f = BigInteger.valueOf(1);
        for(int i = 1 ; i <= n ;i++)
            f = f.multiply(BigInteger.valueOf(i));
        return f;
}

以上代码超出了程序设定的执行时间限制。我还尝试了阶乘的缓存递归版本

public static BigInteger factorial(int n)
{
     if(cache[n] != null) 
         return cache[n];
     else if(n == 0) 
         return new BigInteger("1");
     else {
         cache[n] = n* factorial(n - 1);
         return cache[n]; 
     }
}          

这给了我一个内存不足的错误(可能是由于递归)。

我的问题是,为什么像 Haskell 这样的函数式编程语言在处理这类涉及大量数字的问题时更好? (尽管没有明显的缓存)。有没有办法让 java 代码运行得和 Haskell 代码一样快?

【问题讨论】:

  • 如果您提供完整的(可运行的)程序并准确说明您是如何编译和运行它们的,那就太好了。问题可能不是您所期望的。
  • 我正在将代码上传到一个编程挑战网站link,该网站为包括 Java 和 Haskell 在内的各种语言提供编译器。虽然我不知道他们使用哪种编译器
  • 好的,我自己对它进行了基准测试,我还发现 Java 代码比 Haskell 代码慢得多。我的猜测是这是一个问题,因为 Java 的 BigInteger 比 GMP 慢得多(GHC 用于 Integer 的库),并且几乎与语言本身无关。

标签: java haskell factorial


【解决方案1】:

不同之处在于,正如shachaf 所说,GHC(默认情况下)对超出Int 范围的Integer 计算使用GMP,并且GMP 得到了很好的优化。它与纯度、缓存、尾调用优化等无关。

Java 的BigInteger 或多或少地使用了幼稚的教科书算法。如果您查看multiply (openjdk7) 的代码,工作马是

/**
 * Multiplies int arrays x and y to the specified lengths and places
 * the result into z. There will be no leading zeros in the resultant array.
 */
private int[] multiplyToLen(int[] x, int xlen, int[] y, int ylen, int[] z) {
    int xstart = xlen - 1;
    int ystart = ylen - 1;

    if (z == null || z.length < (xlen+ ylen))
        z = new int[xlen+ylen];

    long carry = 0;
    for (int j=ystart, k=ystart+1+xstart; j>=0; j--, k--) {
        long product = (y[j] & LONG_MASK) *
                       (x[xstart] & LONG_MASK) + carry;
        z[k] = (int)product;
        carry = product >>> 32;
    }
    z[xstart] = (int)carry;

    for (int i = xstart-1; i >= 0; i--) {
        carry = 0;
        for (int j=ystart, k=ystart+1+i; j>=0; j--, k--) {
            long product = (y[j] & LONG_MASK) *
                           (x[i] & LONG_MASK) +
                           (z[k] & LONG_MASK) + carry;
            z[k] = (int)product;
            carry = product >>> 32;
        }
        z[i] = (int)carry;
    }
    return z;
}

二次数字乘法(数字当然不是以 10 为底)。这在这里并没有太大的伤害,因为其中一个因素始终是个位数,但表明在 Java 中优化 BigInteger 计算方面尚未投入太多工作。

从源代码中可以看出,smallNumber * largeNumber 形式的 Java 产品比 largeNumber * smallNumber 更快(特别是如果小数字是个位数,则将其作为第一个数字意味着第二个带有嵌套循环的循环根本不运行,因此您的循环控制开销完全减少,并且运行的循环具有更简单的主体)。

变化很大

f = f.multiply(BigInteger.valueOf(i));

在你的 Java 版本中

f = BigInteger.valueOf(i).multiply(f);

提供了相当大的加速(随着参数的增加,25000 ~2×,50000 ~2.5×,100000 ~2.8×)。

在我的盒子的测试范围内,计算仍然比 GHC/GMP 组合慢了大约 4 倍,但是,GMP 的实现显然得到了更好的优化。

如果您进行经常将两个大数相乘的计算,则当因子足够大时(对于非常大的数字,FFT),二次BigInteger 乘法与使用 Karatsuba 或 Toom-Cook 的 GMP 之间的算法差异将显示出来。

但是,如果乘法不是您所做的全部,如果您打印出阶乘,因此将它们转换为 String,那么您会受到 BigIntegertoString 方法非常慢的事实的打击(它大致是二次的,因此由于阶乘的计算在结果的长度上完全是二次的,因此您不会得到 [太多] 更高的算法复杂度,但是您会在计算之上得到一个 big 常数因子)。 ShowInteger 实例要好得多,O(n * (log n)^x) [不确定 x 是什么,介于 1 和 2 之间],因此将结果转换为 String 只会增加一点计算时间。

【讨论】:

  • 确实如此。可悲的是,这已经众所周知多年了,但是像 github.com/tbuktu/bigint 这样的改进并没有进入 Java7。
  • 同意。我可以理解 Sun/Oracle 有其他优先事项,但是当有人已经完成了这项工作时,没有充分的理由不审查和合并它(除非它会花费真钱)。
  • 老实说,我不明白优先事项,因为他们投入了大量资金来对抗“Java 速度慢”的模因。我相信即使是 Sun/Oracle 的人也应该知道,更好的算法在这里是唾手可得的成果。 (除了显而易见的是,专门从事数值计算的数学家应该从一开始就监督 java.lang.BigInteger 的实现。但即便如此,合同一个大约 8 周的时间来一劳永逸地修复它要花多少钱。 )
  • 好吧,我认为典型的 Java 应用程序很少使用BigInteger,因此它不是首要任务。我正是这个意思。他们明显完全不感兴趣确实令人惊讶。
【解决方案2】:

我首先要指出两个因素,这两个因素显然不是导致速度差异的原因,但在问题和一些答案中仍然提到了。

没有缓存/记忆

问题提到了缓存,一些答案提到了记忆。但是阶乘函数并没有从记忆化中受益,因为它递归地使用 不同的 参数调用自己。所以我们永远不会碰到缓存中已经填满的条目,整个缓存是不必要的。也许人们在这里想到了斐波那契函数?

为了记录,Haskell 无论如何都不会提供自动记忆。

没有其他巧妙的优化

Java 和 Haskell 程序在我看来已经非常理想。两个程序都使用各自语言选择的迭代机制:Java 使用循环,Haskell 使用递归。两个程序都使用标准类型进行大整数运算。

如果有的话,Haskell 版本应该更慢,因为它不是尾递归,而 Java 版本使用循环,这是 Java 中最快的循环结构。

我认为编译器可以对这些程序进行巧妙的高级优化的余地不大。我怀疑观察到的速度差异是由于有关实现多大整数的低级细节造成的。

那么为什么 Haskell 版本更快?

Haskell 编译器对 Integer 具有内置且合理的支持。对于 Java 实现和大整数类来说,这似乎不那么重要。我搜索了“BigInteger 慢”,结果表明问题应该是:为什么 Java 的 BigInteger 这么慢?似乎还有其他更快的大整数类。我不是 Java 专家,所以我无法详细回答这个问题的变体。

【讨论】:

  • 你是对的,除了“对象创建很慢”。我确信 Haskell 运行时必须执行相同数量的“对象创建”,即使底层大整数库使用可变整数。这是因为,从 Haskell 代码的角度来看,整数需要看起来是纯的和不可变的。
  • 也许 Haskell 运行时创建相同数量的对象,但我认为在 Haskell 堆中创建一个对象比在 Java 堆中创建一个对象更快,这是出于低级原因。我对现代 Java 实现的了解还不够透彻,无法真正说些什么,所以我尝试更仔细地表述这部分答案。
  • 在 Haskell 与 Java 中对象创建“已知快得多”(强调我的)的声明的任何引用?我怀疑对于那些只为它们构成的组件设置初始值的对象(并且在这个意义上,与 Haskell“构造函数”相当)。关于方法表,它只是一个指向常量池的指针。然而,在 BigIntegers 的情况下,构造确实意味着构造一个 int 数组,我敢打赌 GMP 库会保持该数组内联。
  • 同时削弱了我删除 much 的主张。我没有任何证据。您认为如果不提及“对象创建”,答案会更好吗?
  • 确实,我认为 Java BigIntegers 使用不太受欢迎(较慢)的算法,这是较慢的主要原因(同时寻找支持该主张的参考)。由于将它们作为 Java 对象,因此会增加一些开销。因此,最公平的比较是使用 Java GMP 实现。
【解决方案3】:

这是一个相关的问题:https://softwareengineering.stackexchange.com/q/149167/26988

在这种特殊情况下,您似乎看到了纯函数与非纯函数的优化差异。在 Haskell 中,所有函数都是纯函数,除非它们正在执行 IO(参见链接)。

我认为发生的事情是,由于纯度的保证,GHC 能够更好地优化代码。即使没有尾调用,它也知道没有任何副作用(因为纯度保证),所以它可以做一些 Java 代码不能 的优化(比如自动缓存和@andrew 之类的)在他的回答中提到)

Haskell 中更好的解决方案是使用内置的 product 函数:

factorial n = product [1..n]

这能够进行尾调用优化,因为它只是迭代。可以在 Java 中使用与您的示例一样的 for 循环来完成相同的操作,但它没有功能纯正的好处。

编辑:

我认为尾部呼叫消除正在发生,但显然不是。这是供参考的原始答案(它仍然有有用的信息说明为什么 Haskell 在某些递归上下文中可能比 Java 更快)。

Haskell 等函数式编程语言利用尾调用消除的优势。

在大多数编程语言中,递归调用维护一个调用堆栈。每个递归函数分配一个新的堆栈,直到它返回才被清理。例如:

call fact()
    call fact()
        call fact()
        cleanup
    cleanup
cleanup

但是,函数式语言不需要维护堆栈。在过程语言中,通常很难判断返回值是否会被校准函数使用,因此很难优化。然而,在 FP 中,返回值仅在递归完成时才有意义,因此您可以消除调用堆栈并最终得到如下内容:

call fact()
call fact()
call fact()
cleanup

call fact() 行都可以出现在同一个堆栈帧中,因为中间计算不需要返回值。

现在,回答您的问题,您可以通过多种方式解决此问题,所有这些都旨在消除调用堆栈:

  • 使用 for 循环而不是递归(通常是最佳选择)
  • 返回 void 并希望编译器消除尾调用
  • 使用trampoline function(类似于for循环的想法,但看起来更像递归)

以下是一些相关问题以及上述示例:

注意:

不能保证递归调用会重用相同的堆栈帧,因此某些实现可能会在每次递归调用时重新分配。这通常更容易,并且仍然提供与重用堆栈帧相同的内存安全性。

有关此的更多信息,请参阅以下文章:

【讨论】:

  • 他的 haskell factorial 函数甚至不是尾递归的,它必须在返回之前将函数的返回乘以 a。除非懒惰解决了这个问题,否则肯定会有另一个答案。
  • 嗯,它似乎不是尾递归的。我仍然认为我的答案中的信息很有用,但它不正确。
  • 我不知道一个规范的地方可以指出 GHC 进行优化。 :-) 只需查看有关该主题的search results 之一。 GHC可以做到这一点,但弄清楚在哪里做是“困难的”,这种空间换时间的权衡留给程序员去做。
  • (另外:我想说“尾调用消除”在这里并不是一个准确的短语——GHC 的评估模型非常不同,因此谈论同一种堆栈没有意义首先是框架。)
  • 至于实际问题——我很确定这就是我上面所说的,即 GHC 的 GMP Integer 比 Java 的 BigInteger 快得多。几乎所有的时间都花在整数乘法上,所以这将是最大的不同。我测量了 GHC-Integer 与 Java-BigInteger 中的其他简单操作,GHC 更快。
【解决方案4】:

我认为差异与尾调用优化或根本优化无关。我认为这是因为优化最多只能实现类似于您的迭代 Java 版本的东西。

真正的原因是,恕我直言,与 Haskell 相比,Java BigInteger 速度较慢。

为了证明这一点,我提出了 2 个实验:

  1. 使用相同的算法,但使用 long。 (对于更大的数字,结果将是一些垃圾,但仍然会完成计算。)这里,Java 版本应该与 Haskell 相当。

  2. 在 java 版本中使用更快的大整数库。性能应该会相应提高。那里有 GMP 的包装器,以及对 here 等 java 大整数的改进。大数相乘可能会带来多倍的性能提升。

【讨论】:

  • +1 用于提议实验。但请注意,尽管如此,“计算”仍不会完成。循环会运行,但我们在每次循环迭代中所做的工作量会下降很多,因此性能特征可能会大不相同。
  • 另一个很好的实验是比较 Java 中的“Long”和“long”。
  • @Toxaris 对于“Long”,可以执行计算列表中数字的阶乘的任务,即map fac [1..1000000000] 或类似的 - 时间应该由装箱/拆箱操作来控制两边。
【解决方案5】:

下面的解释显然是不够的。这里有一些幻灯片解释了一个函数在参数严格(如上面的例子)并且没有生成 thunk 时所经历的转换: http://www.slideshare.net/ilyasergey/static-analyses-and-code-optimizations-in-glasgow-haskell-compiler

Haskell 版本将只进行计算,仅存储上一个计算并应用下一个计算,例如 6 x 4。而 Java 版本正在执行缓存(所有历史值)、内存管理、GC 等.

它正在做严格性分析,它会自动缓存以前的计算。看: http://neilmitchell.blogspot.com.au/2008/03/lazy-evaluation-strict-vs-speculative.html?m=1

更多细节在 Haskell Wiki 上: “像 GHC 这样的优化编译器尝试使用严格分析来降低惰性成本,该分析尝试确定哪些函数参数始终由函数评估,因此可以由调用者评估。”

“严格性分析可以发现参数 n 是严格的事实,并且可以表示为未装箱。生成的函数在运行时不会使用任何堆,正如您所期望的那样。”

“严格性分析是 GHC 在编译时尝试确定哪些数据肯定会‘总是需要’的过程。然后,GHC 可以构建代码来仅计算此类数据,而不是正常的(更高的开销)存储计算并稍后执行的过程。”

http://www.haskell.org/haskellwiki/Performance/Strictness http://www.haskell.org/haskellwiki/GHC_optimisations

【讨论】:

  • 严格分析是对 Haskell 代码的一个很好的优化,但与 Java 中的类似代码相比几乎没有相关性,Java 中的一切都是严格的!如果使用Integer 调用代码,GHC 将为Int 执行的 CPR/worker-wrapper 优化(即拆箱)在此也不相关。正如我上面写的,GHC 不做“自动缓存”,所以这也不会影响这里的事情。
  • 是的,我知道严格性分析的作用。我也知道 Haskell 函数被编译成什么样的代码——即使是内联/严格分析/CPRed/其他的——我真的怀疑将这些代码归因于它是否公平(当然这很难没有看到完整程序的任何完全确定的陈述)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2010-12-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多