【问题标题】:On improving Haskell's performance compared to C in fibonacci micro-benchmark与斐波那契微基准测试中的 C 相比,提高 Haskell 的性能
【发布时间】:2011-10-06 16:13:34
【问题描述】:

我遇到了this question,它比较了各种编译器在以幼稚的方式计算斐波那契数的性能。

我尝试用 Haskell 来做这件事,看看它与 C 相比如何。

C 代码:

#include <stdio.h>
#include <stdlib.h>

int fib (int n) {
  if (n < 2) return 1;
  return fib (n-1) + fib (n-2);
}

int main (int argc, char* argv[]) {
  printf ("%i\n", fib (atoi(argv[1])));
  return 0;
}

结果:

> gcc -O3 main.c -o fib
> time ./fib 40
165580141
real    0m0.421s
user    0m0.420s
sys 0m0.000s

哈斯克尔:

module Main where
import System.Environment (getArgs)

fib :: Int -> Int
fib n | n < 2 = 1
      | otherwise = fib (n-1) + fib (n-2)

main = getArgs >>= print . fib . read . head

结果:

> ghc -O3 -fllvm -optlo-O3 Main.hs -o fib
> time ./fib 40
165580141
real    0m1.476s
user    0m1.476s
sys 0m0.000s

分析

> ghc -O3 -fllvm -optlo-O3 -prof -auto-all -caf-all -rtsopts Main.hs -fforce-recomp -o fib
> ./fib 40 +RTS -prof

显示fib 需要 100% 的时间和分配,这不足为奇。我对堆进行了一些概要分析,但不知道它们的含义:

> ./fib 40 +RTS -hc

> ./fib 40 +RTS -hd

所以我的问题是:我能做些什么来让这个 Haskell 程序的性能更接近 C,或者这只是 GHC 的做法,碰巧让它在这个微基准测试中变慢? (我不是在要求一种渐近更快的算法来计算 fibs。)

非常感谢。

[编辑]

在这种情况下,ghc -O3ghc -O3 -fllvm -optlo-O3 快。但是optlo-block-placement 对 LLVM 后端产生了明显的影响:

> ghc -O3 Main.hs -o fib -fforce-recomp
> time ./fib 40
165580141
real    0m1.283s
user    0m1.284s
sys 0m0.000s

> ghc -O3 -fllvm -optlo-O3 -o fib -fforce-recomp
> time ./fib 40
165580141
real    0m1.449s
user    0m1.448s
sys 0m0.000s

> ghc -O3 -fllvm -optlo-O3 -optlo-block-placement -o fib -fforce-recomp
> time ./fib 40
165580141
real    0m1.112s
user    0m1.096s
sys 0m0.016s

我想对此进行调查的原因是因为 C 和 OCaml 在这个程序中都比 Haskell 快得多。我有点无法接受这一点,并想了解更多信息以确保我已经做了我能做的一切:D

> ocamlopt main.ml -o fib
> time ./fib 40
165580141
real    0m0.668s
user    0m0.660s
sys 0m0.008s

【问题讨论】:

  • gcc -O3 可能会将其变成非递归循环。我不知道 GHC 是否可以做到这一点。
  • 哦,真的吗? Fib 不是尾递归的。它甚至不是线性递归的。怎么会变成循环呢?
  • @Po:参见stackoverflow.com/questions/405770/why-are-compilers-so-stupid/… 的示例,使用(也不是尾递归)阶乘,原理相同。你不会是第一个低估 C 编译器的人。您可以使用-S 制作 gcc 输出程序集,以检查它是否对其进行了优化(我会检查自己,但我无法轻松访问 Unix 机器 ATM)。
  • @delnan:我明白了。对于命令式语言编译器,-O3 的这种行为非常令人印象深刻。但是回到这个程序,这个fib函数怎么可能变成非递归的呢?您有 2 个递归调用,最终未决添加是不可避免的。并且原作者注意在运行时提供参数,因此编译器无法从您提供的链接执行像 gcc -O3 中那样的常量传播。
  • @Po:该常量与移除递归无关(继续,自己尝试一下 - 或者考虑一下 gcc 甚至没有费心在 -O2 上传播它的事实,但是仍然生成循环)。虽然这两个递归调用似乎使它更难,但我只是看看而不是假设。同样,您不会是第一个低估编译器的人……

标签: performance haskell ghc micro-optimization microbenchmark


【解决方案1】:

这里的堆配置文件不是很有趣,因为 GHC 将fib 编译成一个单独操作堆栈的函数。只需查看配置文件...仅分配了 800 个字节,这是您的 main 实现的小开销。

就 GHC 的核心级别而言,这实际上已经尽可能地进行了优化。不过,低级代码生成是另一回事。让我们快速深入了解 GHC 生成的代码:

_Main_zdwfib_info:
.Lc1RK:
    leal -8(%ebp),%eax
    cmpl 84(%ebx),%eax
    jb .Lc1RM

这是对堆栈空间的检查。可能是 C 不需要的东西,因为它可以让操作系统处理堆栈空间分配。 Haskell 有用户级线程,所以栈空间是手动管理的。

    cmpl $2,0(%ebp)
    jl .Lc1RO

与您的代码中的 2 进行比较。

    movl 0(%ebp),%eax
    decl %eax

参数从堆栈中重新加载并递减以获取递归调用的参数。重新加载可能是不必要的 - 但不确定它是否有所作为。

    movl %eax,-8(%ebp)
    movl $_s1Qp_info,-4(%ebp)
    addl $-8,%ebp
    jmp _Main_zdwfib_info

参数和返回地址被压入栈顶,我们直接跳转到标签处进行递归。

.Lc1RO:
    movl $1,%esi
    addl $4,%ebp
    jmp *0(%ebp)

参数小于2情况的代码。返回值在寄存器中传递。

底线:一切似乎都在按预期运行,您不太可能通过更改程序从中获得更多收益。自定义堆栈检查是导致速度下降的明显原因,但不确定是否可以将全部时间差异归咎于它。

【讨论】:

  • 如何消除这种堆栈检查?
  • @is7s:它不能被消除——它是使用 GHC 编译的程序的一个组成部分。请注意,大多数程序不必经常进行此堆栈检查,并且自定义堆栈允许 GHC 扩展到数百万个用户线程。
【解决方案2】:

正如barsoap 所说,这些似乎是一个非常微弱的“基准”。假设我比较以下几乎同样幼稚的程序:

module Main where
import System.Environment (getArgs)

fib ::  Int ->  Int
fib 0 = 1
fib 1 = 1
fib 2 = 2 
fib n = (\x y -> x + y + y )  (fib (n-3))  (fib (n-2) )

main = getArgs >>= print . fib . read . head  

...在另一个角落...

#include <stdio.h>
#include <stdlib.h>

int fib (int n) {
  if (n < 2) return 1;
  if (n < 3) return n;
  return (fib (n-3) + fib (n-2)) + fib (n-2);
}

int main (int argc, char* argv[]) {
  printf ("%i\n", fib (atoi(argv[1])));
  return 0;
}

然后光荣的ghc 碾压gcc,这并不奇怪,真的:

$ ghc --make -fforce-recomp fib.hs -o fibh
[1 of 1] Compiling Main             ( fib.hs, fib.o )
Linking fibh ...
$ time ./fibh 40
165580141

real    0m0.013s
user    0m0.007s
sys 0m0.003s

$ gcc fib.c -o fibc
$ time ./fibc 40
165580141

real    0m1.495s
user    0m1.483s
sys 0m0.003s

现在开启优化,ghc 加快了一点速度:

$ ghc --make -fforce-recomp fib.hs -O3 -o fibhO
$ time ./fibhO 40
165580141

real    0m0.007s
user    0m0.002s
sys 0m0.004s

gcc 终于找到了线索。

$ gcc fib.c -O3 -o fibcO
$ time ./fibcO 40
165580141

real    0m0.007s
user    0m0.004s
sys 0m0.002s

我认为解释是ghc 对常见子表达式消除的谨慎态度:“(几乎)一切都是表达式”是危险的,它表明程序员知道如何使用 lambda。

【讨论】:

  • 这种性能差异是因为使用了 lambda 表达式吗?
  • 我同时进行了两项修改,使事情变得不那么清晰:首先,我公开了fib n = fib n-3 + fib n-2 + fib n-2(并因此提供了案例fib 3)——就像在C文件中一样。使用ghc -O2,这与gcc -O3 一样快。如果没有优化标志,ghcgcc 都很慢。如果(第二次修正)我添加了 lambda,那么 ghc without 优化标志已经很快了。我的想法是 ghc -O2gcc -O3 看到的基本上就是这个 lambda。 gcc 甚至可以在 pos 文件中看到这一点,这有点额外的聪明。
  • 另一种说法是,使用 lambda,即使使用 runhugs 也比 gcc 更快,编译时没有优化标志。
  • 但我不明白为什么或如何添加 lambda 会导致这种改进。
  • @is7s:因为每个子 (fib n) 只写入一次并绑定到 xy 参数。然后,这些表达式中的每一个都(懒惰地)只评估一次。 y 的第二次调用自然会使用易于评估的值。
【解决方案3】:

GHC 编译得很好。下一步是对 GHC 后端的输出进行微优化。在这里使用各种 LLVM 标志会有所帮助。

为此,请使用 ghc-core 检查程序集,并尝试使用 LLVM 的其他标志来查看您得到的结果。

另一种方法是添加少量并行性。

【讨论】:

  • 谢谢唐:D。我玩了一下 LLVM 标志。看起来只有 optlo-block-placement 产生了明显的差异(从 ~1.5s 到 ~1.1s)。顺便说一句,我没有注意到 GHC 的默认后端(简单地说 ghc -O3)比我机器上的这个程序的 ghc -O3 -fllvm -optlo-O3(分别为~1.2s,~1.5s)快。
  • 还可以做更高级的llvm游戏:见donsbot.wordpress.com/2010/03/01/…
【解决方案4】:

试试这个:

fibs :: [Integer]
fibs = 0:1:zipWith (+) fibs (tail fibs)

fib n = fibs !! n

$ time ./fib 10000
  33644[...]6875
  ./fib 10000  0.03s user 0.01s system 89% cpu 0.039 total

(这是一个很好的 ole Athlon64 3200+)

您使用的版本是,对于每个n,计算fib (n-1)fib (n-2),也就是说,具有大致三角形性质的复杂性。上面的版本是线性的:每个 fib 只计算一次。尽管非 Haskell 编程头脑似乎是这么想的,但 Haskell 并没有 automatically memoise(无论如何,这通常比好的 ole 动态编程要慢)。

the Haskell Wiki 上还有更快(使用数学技巧)的斐波那契版本。

将 C 版本更改为非递归版本,我敢打赌,您会看到 Haskell 和 C 具有非常相似的性能。紧密循环更容易优化。

【讨论】:

  • 来自问题:“比较了各种编译器在计算斐波那契数方面的性能朴素的方式,还有“我是不要求渐近更快的算法来计算 fibs"
  • ...我理解这个问题,但我不明白为什么要比较微基准的幼稚实现。比较 naive C IO 和 naive stream-fusion 启用 Haskell IO,我会理解的。另外,那个 zipWith 幼稚的,这取决于你手中的数学书。
  • 基准是比较某个递归算法的实现;关键不是要找到斐波那契数。
  • 感谢 barsoap :)。我想进一步调查的原因是因为在这个测试中,C 和 OCaml 都比 Haskell 快得多(在我的机器上分别为 0.4s、0.6s 和 1.4s),我发现它有点难以接受,所以我想了解更多,以确保我已经尽我所能。
  • @barsoap 朴素的递归版本测试了实现处理大量小函数调用的能力,这是 FP 语言实现应该相当擅长的。
猜你喜欢
  • 2012-10-24
  • 2017-12-06
  • 2017-08-03
  • 1970-01-01
  • 1970-01-01
  • 2011-02-17
  • 1970-01-01
  • 2015-01-06
相关资源
最近更新 更多