为什么这个简单的 Haskell 程序如此缓慢?

3
在这个简单的程序中,它会打印从1到10000000的所有数字。有一个Haskell版本和一个C版本,为什么Haskell版本如此缓慢,有哪些命令可以帮助学习如何提高Haskell程序的性能
下面是一个包含所有必要细节以重现我的激动人心事件的报告,当制作报告时,打印出所有源代码,包括Makefile的源代码。
$ make -B report
cat Foo.hs
import Data.Foldable
main = traverse_ print [1..10000000]
cat Fooc.c
#include <stdio.h>

int main()
{
    for (int n = 0; n < 10000000; ++n)
    {
        printf("%d\n", n+1);
    }
}
ghc -O3 Foo.hs -o Foo
time ./Foo | tail -n1
3.45user 0.03system 0:03.49elapsed 99%CPU (0avgtext+0avgdata 4092maxresident)k
0inputs+0outputs (0major+290minor)pagefaults 0swaps
10000000
cc -O3    Fooc.c   -o Fooc
time ./Fooc | tail -n1
0.63user 0.02system 0:00.66elapsed 99%CPU (0avgtext+0avgdata 1468maxresident)k
0inputs+0outputs (0major+63minor)pagefaults 0swaps
10000000
cat Makefile
.PHONY: printFoo printFooc printMakefile
printFoo: Foo.hs
    cat $^

printFooc: Fooc.c
    cat $^

printMakefile: Makefile
    cat $^

Fooc: CFLAGS=-O3
Fooc: Fooc.c
Foo: Foo.hs
    ghc -O3 $^ -o $@

.PHONY: timeFoo timeFooc
timeFoo: Foo
    time ./$^ | tail -n1
timeFooc: Fooc
    time ./$^ | tail -n1

.PHONY: report
report: printFoo printFooc timeFoo timeFooc printMakefile

5
请使用 Int64Int,而不是 Integer - Willem Van Onsem
1
请注意,您的两个时间包括两个不同的部分:生成值的计算和打印它们的I/O。如果将它们分开,您的结果将更具信息性。例如,print (last [1::Int .. 10000000])将仅打印列表的最后一个元素(同时使用Int而不是其他人建议的Integer)。 - duplode
6
如果你要评论,请确保你有实际的观点!Integer 和常量大小类型与这里的性能问题关系不大,未装箱的列表结构也一样。事实是,在 C 语言中 print 确实比 printf 慢得多。但是 a) 如果你关心打印的性能,那么这相当愚蠢,因为如果你的数字太多了,它会很重要,那就不要打印它们!(对于大数据量总是使用二进制格式。)... - leftaroundabout
1
因此,因为这个原因说“Haskell很慢”是非常不公平的,只是在一个本来不应该影响速度的任务上变慢了。b) 它并不是非常慢。5倍的因素,许多语言只能梦想接近C - 在真正需要性能的任务中!合理编写的Haskell通常比C慢2倍;有时甚至一个简单版本就接近C,并且通过一些努力几乎总是可以实现的。 - leftaroundabout
1
我应该指出,这两个程序编写的经济成本相同,理解的成本也相似,因此“你编写的代码很慢”这种评论并没有提供太多信息。 - codeshot
显示剩余8条评论
1个回答

7

在我的系统上,您的Haskell代码需要约3.2秒的时间。

注意:您的C代码需要...

time ./fooc | tail -n1
ld: warning: directory not found for option '-L/opt/local/lib'
10000000
./fooc  0.92s user 0.03s system 33% cpu 2.863 total
tail -n1  2.85s user 0.01s system 99% cpu 2.865 total

请注意 time a | btime (a | b) 的区别。Haskell 很慢,部分原因可能是以下问题:
  1. 默认情况下,print 和底层的 putStrLn 使用的是字符串 String,这是一个字符链表。
  2. UTF 编码。
  3. 运行时系统(RTS)的差异。
对于第一点,使用 Text 这个压缩变体并没有表现出更高效的性能,可能是由于第二点的影响。
对于第二点,代替字符的字节组成的 ByteString 变体更接近 C 程序的处理方式。
-- Using functions from the Relude package
main = traverse_ putBSLn (show <$> [(1::Int)..10000000])

结果在

10000000
./foo  1.55s user 0.08s system 56% cpu 2.904 total

因此,CPU时间更接近于您的C程序,我猜测这种差异在很大程度上是由于Haskell预定义函数中默认使用了不必要的UTF8处理。

死路:

  • 我尝试过无缓冲和大块缓冲,但没有成功。
  • 构建一个大的bytestring并在单个调用中打印并没有更好的效果(lazy或strict bytestrings)。
  • 使用Text而不是String进行打印只有极微小的改善。
  • 直接将渲染写入ByteString,而不是将值pack成Strings后再进行。我认为,如果操作得当,这可能会带来一些收益。

编辑:我简直不敢相信我忘记了Builder,它是构建bytestrings的一种优化方式,在某些情况下,它能够很好地融合以减少分配。Builder是我已经展示的上面例子的基础,但是直接使用它允许进行一些手动优化。

{-# LANGUAGE OverloadedStrings #-}
import Data.ByteString.Builder
import System.IO (stdout)
import Data.Foldable

main :: IO ()
main = do
    traverse_ (hPutBuilder stdout . (<>"\n") . intDec) [(1::Int)..10000000]

表演位置:

./foo  1.05s user 0.13s system 38% cpu 3.048 total
tail -n1  3.02s user 0.01s system 99% cpu 3.047 total

实际上,这比之前进行多次单独调用hPut更加高效,因为如hPutBuilder所述:

与 hPut . toLazyByteString 相比,该函数更加高效,因为在许多情况下无需进行缓冲区分配。此外,短建造者的多个执行结果被连接到处理句柄的缓冲区中,从而避免了不必要的缓冲区刷新。

因此,我应该补充:4. 在这种情况下Haskell较慢,因为有时计算不会合并,你最终会得到过度的分配,这并不是免费的。


1
在我的测试中,另一件有很大差异的事情是:构建包含换行符的完整字符串,然后仅调用一次 putStr(Ln) 来处理整个 String,而不是每行都调用一次。似乎在实际将字符转储到输出缓冲区之前,putStrLn 中有一堆前置操作。可能 putBSLn 也存在类似的问题,你可以获得第二个便宜的胜利——尽管除非你使用惰性字节串,否则内存成本可能需要考虑! - Daniel Wagner
另一个需要考虑的是 RTS 设置时间。也许即使 main = pure() 只需要一些时间,因为 RTS 需要设置 GC 等等(尽管没有测试过)。 - chi
主函数 = 纯() 在0.00秒报告。所以基本RTS设置不是问题。 - codeshot
谢谢您提供的信息,但问题的关键部分缺失了:如何找出类似这样的性能问题(以及剩余的2/5秒)的原因。必须了解下一步应该改进什么,或者必须确定没有实际可行的改进方法。 - codeshot

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