在提高Haskell在斐波那契微基准测试中的性能方面与C进行比较

14

我看到了这个问题,它比较了各种编译器在计算斐波那契数列的朴素方法上的性能。

我尝试用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

Haskell:

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

enter image description here

我的问题是:有没有什么我可以做的,使得这个 Haskell 程序的性能更接近 C 语言?还是 GHC 就是以这种方式运行导致在这个微基准测试中它变慢了呢?(我不是要求使用渐进快速算法来计算斐波那契数列。)

非常感谢。

[编辑]

事实证明,在这种情况下 ghc -O3 要比 ghc -O3 -fllvm -optlo-O3 快。但对于 LLVM 后端,optlo-block-placement 可以产生明显的差异:

> 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 是否也可以做到这一点。 - user395760
哦,真的吗?Fib不是尾递归。它甚至不是线性递归。它怎么能被转换成循环呢? - Phil
@Po:请参考https://dev59.com/EXRC5IYBdhLWcg3wG9Xo#414774,其中有一个例子展示了如何使用(非尾递归的)阶乘来实现相同的原理。你不会是第一个低估C编译器的人。你可以使用“-S”选项让gcc输出汇编代码,以检查它是否进行了优化(我本人无法检查,因为目前没有易于访问的Unix机器)。 - user395760
@delnan:我明白了。对于一种命令式语言编译器来说,使用“-O3”选项的行为非常令人印象深刻。但回到这个程序,如何将这个“fib”函数转换为非递归形式呢?你有两个递归调用,最终挂起的加法是不可避免的。而原始作者注意到在运行时给出参数,所以编译器不能像你提供的链接中的“gcc -O3”那样执行常量传播。 - Phil
@Po: 这个常量与递归的消除无关(可以自己试试看-或者考虑GCC在-O2上甚至不需要传播它,但仍然生成了循环)。而且虽然这两个递归调用似乎使得问题更难,但我只是建议大家去看一下实际情况,而非猜测。再说了,你也不会是第一个低估编译器的人... - user395760
4个回答

9
堆剖析在这里并不是很有趣,因为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
2
@is7s: 它无法被消除 - 它是使用GHC编译的程序工作的一个重要组成部分。请注意,大多数程序不必经常进行堆栈检查,而自定义堆栈使得GHC能够扩展到数百万个用户线程。 - Peter Wortmann

6

这似乎是一个非常脆弱的'基准测试',正如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  

... and in the other corner ...

#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表达式吗? - is7s
我一次做了两个修改,使事情变得不够清晰:首先,我暴露了 fib n = fib n-3 + fib n-2 + fib n-2(因此提供了情况 fib 3)-- 如同 C 文件中所示。使用 ghc -O2,这与 gcc -O3 一样快。没有优化标志,ghcgcc 都很慢。如果(第二个修改)我添加 lambda,则 ghc 没有 优化标志已经很快了。我的想法是,基本上是这个 lambda 让 ghc -O2gcc -O3 看到了它。即使在 po 文件中,gcc 也能看到这一点,这是额外的一点聪明才智。 - applicative
另一种表述是,使用lambda,即使使用runhugs比没有优化标志进行编译更快。 - applicative
但是我不明白为什么或如何lambda加法会导致这种改进。 - is7s
po 的原始子句 otherwise = fib (n-1) + fib (n-2) 中,没有(明显)重复的内容,因此不需要使用 lambda。如果我们将 fib (n - 1) 重写为 fib (n - 2) + fib (n - 3),这是正确的(对于 n>3),那么我们得到 otherwise = fib (n-2) + fib (n-3) + fib (n-2)。然后我们有一个公共子表达式 fib (n-2),可以写成 otherwise = fib2 + fib3 + fib2,其中 fib2 = fib (n-2); fib3 = fib (n - 3) 或者我使用的显式 lambda。 (当然,我们也可以写成 2 * fib2 + fib3)。要执行任何操作,您需要添加第三个“基本情况”,即 fib 2 = 2 - applicative
显示剩余2条评论

3

GHC可以很好地编译这个程序。下一步是微调GHC后端的输出。尝试使用不同的LLVM标志可以帮助优化。

要做到这一点,请使用ghc-core来检查汇编代码,并尝试使用其他LLVM标志来查看结果。

另一种方法是增加少量并行处理。


1
谢谢 Don :D。我尝试了一下LLVM标志。看起来只有optlo-block-placement产生了明显的差异(从约1.5秒到约1.1秒)。顺便说一句,我没有注意到GHC的默认后端(仅为ghc -O3)在我的机器上对于这个程序比ghc -O3 -fllvm -optlo-O3更快(分别为约1.2秒和约1.5秒)。 - Phil
您还可以进行更高级的 LLVM 游戏:请参见 http://donsbot.wordpress.com/2010/03/01/evolving-faster-haskell-programs-now-with-llvm/。 - Don Stewart

1

试试这个:

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

(这是在一台好老的 Athlon64 3200+ 上)

你正在使用的版本对于每个 n 都计算 fib(n-1)fib(n-2),因此其复杂度大致呈三角形状。上面的版本是线性的:每个 fib 只计算一次。尽管非 Haskell 编程群体似乎认为 Haskell 会自动进行记忆化(这通常比好老的动态规划更慢),但实际上并不是这样。

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

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


7
从问题中:“比较各种编译器在计算斐波那契数列的朴素方法上的性能”,还有“我不是要求一个渐进更快的算法来计算斐波那契数列”。 - ShreevatsaR
1
我理解这个问题,只是不明白为什么有人想要比较微基准测试的天真实现。比较天真的C IO和启用流融合的Haskell IO,我可以理解。此外,根据你手头拿着哪本数学书,zipWith *也可能是天真的。 - barsoap
7
基准是为了比较某个递归算法的实现;重点不在于找到斐波那契数列。 - ShreevatsaR
谢谢barsoap :)。我想进一步调查的原因是,无论是C还是OCaml,在这个测试中都比Haskell快得多(分别在我的机器上为0.4秒、0.6秒和1.4秒),而我发现这有点难以接受,所以我想学习更多的知识,确保我已经尽了我所能。 - Phil
1
@barsoap 这个天真递归版本测试了实现处理大量小函数调用的能力,这正是 FP 语言实现应该擅长的。 - stonemetal
@stonemetal 问题在于,由于非常积极的内联,GHC代码通常不会以大量小函数调用结束。这里的有产出工作/无法内联的调用比率对于实际程序来说非常不典型。 - barsoap

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接