使用Euler问题和Int64类型的递归存在性能问题

13

我目前正在使用项目欧拉问题学习Haskell编程。与其他语言编写的类似程序相比,我的Haskell程序速度非常慢,这让我感到惊讶。我想知道是否预见到了什么问题,或者这是使用Haskell时需要预期的性能损失。

以下程序受问题331启发,但我在发布之前进行了更改,以免为其他人提供任何线索。它计算在2^30 x 2^30网格上绘制的离散圆的弧长。这是一个简单的尾递归实现,并且我确保跟踪弧长的累加变量的更新是严格的。然而,即使使用ghc的-O标志编译,它也需要将近一分半钟才能完成。

import Data.Int

arcLength :: Int64->Int64
arcLength n = arcLength' 0 (n-1) 0 0 where
    arcLength' x y norm2 acc
        | x > y = acc
        | norm2 < 0 = arcLength' (x + 1) y (norm2 + 2*x +1) acc
        | norm2 > 2*(n-1) = arcLength' (x - 1) (y-1) (norm2 - 2*(x + y) + 2) acc
        | otherwise = arcLength' (x + 1) y (norm2 + 2*x + 1) $! (acc + 1)

main = print $ arcLength (2^30)

这是Java的一个对应实现。它需要大约4.5秒完成。

public class ArcLength {
public static void main(String args[]) {
    long n = 1 << 30;
    long x = 0;
    long y = n-1;
    long acc = 0;
    long norm2 = 0;
    long time = System.currentTimeMillis();

    while(x <= y) {
        if (norm2 < 0) {
            norm2 += 2*x + 1;
            x++;
        } else if (norm2 > 2*(n-1)) {
            norm2 += 2 - 2*(x+y);
            x--;
            y--;
        } else {
            norm2 += 2*x + 1;
            x++;
            acc++;
        }
    }

    time = System.currentTimeMillis() - time;
    System.err.println(acc);
    System.err.println(time);
}

编辑:在评论讨论后,我对Haskell代码进行了一些修改并进行了一些性能测试。首先,我将n更改为2 ^ 29以避免溢出。然后我尝试了6个不同版本:使用Int64或Int,在norm2或both之前加上bangs以及在声明arcLength' x y!norm2!acc中使用norm2和acc。所有版本都编译了。

ghc -O3 -prof -rtsopts -fforce-recomp -XBangPatterns arctest.hs

以下是结果:

(Int !norm2 !acc)
total time  =        3.00 secs   (150 ticks @ 20 ms)
total alloc =       2,892 bytes  (excludes profiling overheads)

(Int norm2 !acc)
total time  =        3.56 secs   (178 ticks @ 20 ms)
total alloc =       2,892 bytes  (excludes profiling overheads)

(Int norm2 acc)
total time  =        3.56 secs   (178 ticks @ 20 ms)
total alloc =       2,892 bytes  (excludes profiling overheads)

(Int64 norm2 acc)
arctest.exe: out of memory

(Int64 norm2 !acc)
total time  =       48.46 secs   (2423 ticks @ 20 ms)
total alloc = 26,246,173,228 bytes  (excludes profiling overheads)

(Int64 !norm2 !acc)
total time  =       31.46 secs   (1573 ticks @ 20 ms)
total alloc =       3,032 bytes  (excludes profiling overheads)

我正在使用64位Windows 7操作系统下的GHC 7.0.2(Haskell平台二进制发行版)。根据评论,当在其他配置下编译时,问题不会出现。这让我认为,在Windows版本中,Int64类型存在问题。


你尝试过使用感叹号模式吗?arcLength' x y !norm2 !acc?因为当第一个分支被执行时,norm2acc并不总是需要严格传递。顺便说一下,在我的机器上只需要6秒钟。 - fuz
通常情况下,Haskell 可能是速度较快的语言之一。可能存在某些代码实现方式导致了更糟糕的复杂性,或者与 GHC 优化不兼容。 - user395760
4
-O2 是 GHC 优化的典型标志。如果我没记错,-O 的优化效果不太明显。 - Thomas M. DuBuisson
评论似乎表明这是与GHC 7.0.2和64位GMP(其中Int64类型来自)在32位Windows上相关的错误。您可以升级libgmp,将GHC升级到7.0.3或在64位Windows上进行测试吗? - Don Stewart
1
我在 GHC 7.0.3 上得到了相同的行为。我也在 64 位 Windows 上运行。但我怀疑 Haskell 平台的二进制分发是 32 位的。没有任何 64 位下载。 - dbergh
6个回答

9

嗯,我安装了一个新的Haskell平台,版本为7.0.3,并为您的程序得到了大致以下核心代码(-ddump-simpl):

Main.$warcLength' =
  \ (ww_s1my :: GHC.Prim.Int64#) (ww1_s1mC :: GHC.Prim.Int64#)
    (ww2_s1mG :: GHC.Prim.Int64#) (ww3_s1mK :: GHC.Prim.Int64#) ->
    case {__pkg_ccall ghc-prim hs_gtInt64 [...]
           ww_s1my ww1_s1mC GHC.Prim.realWorld#
[...]

GHC已经意识到它可以展开您的整数,这是好事。但是,hs_getInt64调用看起来很像C调用。查看汇编器输出(-ddump-asm),我们会看到类似以下的内容:

pushl %eax
movl 76(%esp),%eax
pushl %eax
call _hs_gtInt64
addl $16,%esp

这看起来非常像在后端将Int64的每个操作都转换为完整的C调用。显然这是很慢的。
GHC.IntWord64源代码似乎证实了这一点:在32位构建中(例如当前与平台一起提供的构建),您只能通过FFI接口进行仿真。

没错。在32位机器上,Int64是通过C GMP库实现的。这会带来一些性能开销。但在64位机器上就没有这样的问题了。 - Don Stewart
我猜这就解决了。谢谢!我还猜想,如果你使用Windows系统,拥有一台64位机器也不足够,因为Windows没有64位的GHC(http://hackage.haskell.org/trac/ghc/wiki/WindowsGhc)。 - dbergh
使用Integer代替也会得到相同的降级效果(它也是通过C调用libgmp实现的)。 - Don Stewart

8

嗯,这很有趣。我刚刚编译了你们两个的程序并试用了一下:

% java -version                                                                                          
java版本 "1.6.0_18"
OpenJDK运行环境(IcedTea6 1.8.7) (6b18-1.8.7-2~squeeze1)
OpenJDK 64位服务器虚拟机 (build 14.0-b16, mixed mode)
% javac ArcLength.java                                                                                   
% java ArcLength                                                                                         
843298604
6630

Java解决方案大约需要6.6秒。接下来是ghc进行优化:

% ghc --version                                                                                          
The Glorious Glasgow Haskell Compilation System, version 6.12.1
% ghc --make -O arc.hs
% time ./arc                                                                                             
843298604
./arc  12.68s user 0.04s system 99% cpu 12.718 total

ghc -O大约需要不到13秒

尝试使用更多的优化:

% ghc --make -O3
% time ./arc                                                                                             [13:16]
843298604
./arc  5.75s user 0.00s system 99% cpu 5.754 total

使用更多的优化标志后,haskell解决方案只需不到6秒

很有趣,想知道您使用的编译器版本。


对我也一样。当我将 Int64 更改为 Int 并在内部工作器中添加了显式类型标记时,速度变得更快了。我使用的是 x64,ghc 7.0.3版本。 - fuz
此外,非x64处理器也具有64位数据寄存器。在这种情况下,Int64比Integer快得多(使用Integer后4分钟后我终止了进程)。无论如何,这并不能解释计算时间的巨大差异。@Monk,你真的是直接复制的代码吗? - dbergh
2
当使用32位版本的GHC 7.0.2编译时,我发现Int64也会出现相同的缓慢情况,并且当使用x64版本(尽管是7.0.3)编译时,时间少于5秒。顺便说一下,-fllvm选项似乎可以加速这个32位版本(不确定在MacOS上的x64 GHC中如何处理-fllvm)。 - Ed'ka
1
哦,如果没有使用 -O3 编译,它大约在一分钟左右就会崩溃,所以这里由 -O3 所做的优化是巨大的。 - Palmik
1
嗯,我觉得随意在代码中添加感叹号会产生如此大的影响有点令人失望。但对我来说,真正的问题似乎是64位算术的实现不好...根据我的计时,使用64位整数几乎会将执行时间增加10倍。 - dbergh
显示剩余13条评论

7

你的问题有几个有趣的地方。

首先,你应该主要使用-O2。它将会更好地完成任务(在这种情况下,识别和消除-O版本中仍然存在的惰性)。

其次,你的Haskell代码与Java代码不完全相同(它执行不同的测试和分支)。像其他人一样,在我的Linux系统上运行你的代码需要大约6秒的运行时间。看起来没问题。

确保它与Java代码相同

一个想法:让我们进行字面上的转录,使用相同的控制流、操作和类型,使它与你的Java代码相同。

import Data.Bits
import Data.Int

loop :: Int -> Int
loop n = go 0 (n-1) 0 0
    where
        go :: Int -> Int -> Int -> Int -> Int
        go x y acc norm2
            | x <= y        = case () of { _
                | norm2 < 0         -> go (x+1) y     acc     (norm2 + 2*x + 1)
                | norm2 > 2 * (n-1) -> go (x-1) (y-1) acc     (norm2 + 2 - 2 * (x+y))
                | otherwise         -> go (x+1) y     (acc+1) (norm2 + 2*x + 1)
            }
            | otherwise     = acc

main = print $ loop (1 `shiftL` 30)

了解核心

我们将通过使用ghc-core快速了解核心,并展示一个非常好的未装箱类型循环。

main_$s$wgo
  :: Int#
     -> Int#
     -> Int#
     -> Int#
     -> Int#

main_$s$wgo =
  \ (sc_sQa :: Int#)
    (sc1_sQb :: Int#)
    (sc2_sQc :: Int#)
    (sc3_sQd :: Int#) ->
    case <=# sc3_sQd sc2_sQc of _ {
      False -> sc1_sQb;
      True ->
        case <# sc_sQa 0 of _ {
          False ->
            case ># sc_sQa 2147483646 of _ {
              False ->
                main_$s$wgo
                  (+# (+# sc_sQa (*# 2 sc3_sQd)) 1)
                  (+# sc1_sQb 1)
                  sc2_sQc
                      (+# sc3_sQd 1);
              True ->
                main_$s$wgo
                  (-#
                     (+# sc_sQa 2)
                     (*# 2 (+# sc3_sQd sc2_sQc)))
                  sc1_sQb
                  (-# sc2_sQc 1)
                  (-# sc3_sQd 1)
            };
          True ->
            main_$s$wgo
              (+# (+# sc_sQa (*# 2 sc3_sQd)) 1)
              sc1_sQb
              sc2_sQc
              (+# sc3_sQd 1)

也就是说,所有的内容都被存储在寄存器中。那个循环看起来很棒!并且表现得非常好(Linux/x86-64/GHC 7.03):
./A  5.95s user 0.01s system 99% cpu 5.980 total

检查汇编代码

我们得到了合理的汇编代码,一个很好的循环:

Main_mainzuzdszdwgo_info:
        cmpq    %rdi, %r8
        jg      .L8
.L3:
        testq   %r14, %r14
        movq    %r14, %rdx
        js      .L4
        cmpq    $2147483646, %r14
        jle     .L9
.L5:
        leaq    (%rdi,%r8), %r10
        addq    $2, %rdx
        leaq    -1(%rdi), %rdi
        addq    %r10, %r10
        movq    %rdx, %r14
        leaq    -1(%r8), %r8
        subq    %r10, %r14
        jmp     Main_mainzuzdszdwgo_info
.L9:
        leaq    1(%r14,%r8,2), %r14
        addq    $1, %rsi
        leaq    1(%r8), %r8
        jmp     Main_mainzuzdszdwgo_info
.L8:
        movq    %rsi, %rbx
        jmp     *0(%rbp)
.L4:
        leaq    1(%r14,%r8,2), %r14
        leaq    1(%r8), %r8
        jmp     Main_mainzuzdszdwgo_info

使用-fvia-C后端。
所以这看起来没问题!
我的怀疑,如上面的评论所述,与您在32位Windows上拥有的libgmp版本生成64位整数的贫弱代码有关。首先尝试升级到GHC 7.0.3,然后尝试其他代码生成器后端,如果您仍然有Int64问题,请向GHC trac提交错误报告。
广泛证实,确实是在32位模拟64位整数的情况下进行这些C调用的成本,我们可以将Int64替换为Integer,它在每台机器上都使用C调用到GMP中实现,事实上,运行时从3秒变成了一分钟以上。
教训:尽可能使用硬件64位。

并非总是可行。Haskell 无法在 Windows 下编译为 64 位代码,因此这并不总是一个选项。 - fuz
你如何获得如此干净的核心输出? - fuz
使用 ghc-core,你可以在这里获取:http://hackage.haskell.org/package/ghc-core - Don Stewart

4

对于性能相关的代码,正常的优化标志是-O2。而你使用的-O几乎没有起到作用。-O3并没有比-O2更多(或者说没有)的优化效果,它甚至包含了实验性的“优化”,这些“优化”往往会使程序变得明显更慢。

使用-O2可以获得与Java竞争的性能:

tommd@Mavlo:Test$ uname -r -m
2.6.37 x86_64
tommd@Mavlo:Test$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.0.3

tommd@Mavlo:Test$ ghc -O2 so.hs
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
tommd@Mavlo:Test$ time ./so
843298604

real    0m4.948s
user    0m4.896s
sys     0m0.000s

而且 Java 执行速度更快,比 Python 快了大约1秒(20%):

tommd@Mavlo:Test$ time java ArcLength
843298604
3880

real    0m3.961s
user    0m3.936s
sys     0m0.024s

不过 GHC(Glasgow Haskell Compiler)的一个有趣之处在于,它具有许多不同的后端。默认情况下,它使用本地代码生成器(NCG),我们在上面进行了时间测试。还有一个LLVM后端,通常表现更好...但这里不适用:

tommd@Mavlo:Test$ ghc -O2 so.hs -fllvm -fforce-recomp
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
tommd@Mavlo:Test$ time ./so
843298604

real    0m5.973s
user    0m5.968s
sys     0m0.000s

但是,正如FUZxxl在评论中提到的那样,当您添加一些严格性注释时,LLVM的表现要好得多:

$ ghc -O2 -fllvm -fforce-recomp so.hs
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
tommd@Mavlo:Test$ time ./so
843298604

real    0m4.099s
user    0m4.088s
sys     0m0.000s

还有一个旧的“via-c”生成器,它使用C作为中间语言。在这种情况下表现良好:

tommd@Mavlo:Test$ ghc -O2 so.hs -fvia-c -fforce-recomp
[1 of 1] Compiling Main             ( so.hs, so.o )

on the commandline:
    Warning: The -fvia-c flag will be removed in a future GHC release
Linking so ...
ttommd@Mavlo:Test$ ti
tommd@Mavlo:Test$ time ./so
843298604

real    0m3.982s
user    0m3.972s
sys     0m0.000s

希望在他们移除这个后端之前,NCG能够得到改进,以匹配此情况下的via-c。

1
еҪ“жӮЁеңЁarcLength'зҡ„еҸӮж•°norm2дёҠж·»еҠ "bang patterns"ж—¶пјҢLLVMеҗҺз«ҜдјҡеҒҡеҫ—жӣҙеҘҪпјҢеӣ дёәnorm2й»ҳи®Өжғ…еҶөдёӢдёҚжҳҜдёҘж јзҡ„гҖӮ - fuz
1
啊!而且你也要在 arcLength !n 后面加一个感叹号。 - fuz

2
,我感觉所有这些都从不幸的-O标志开始走错了方向。只是为了强调其他人已经提出的一点,在进行日常编译和测试时,请像我一样将此粘贴到您的.bashrc或其他文件中:

alias ggg="ghc --make -O2"
alias gggg="echo 'Glorious Glasgow for Great Good!' && ghc --make -O2 --fforce-recomp"

1

我已经尝试了一下代码,这个版本在我的笔记本电脑上似乎比Java版本运行得更快(3.55秒对比4.63秒):

{-# LANGUAGE BangPatterns #-}

arcLength :: Int->Int
arcLength n = arcLength' 0 (n-1) 0 0 where
    arcLength' :: Int -> Int -> Int -> Int -> Int
    arcLength' !x !y !norm2 !acc
        | x > y = acc
        | norm2 > 2*(n-1) = arcLength' (x - 1) (y - 1) (norm2 - 2*(x + y) + 2) acc
        | norm2 < 0 = arcLength' (succ x) y (norm2 + x*2 + 1) acc
        | otherwise = arcLength' (succ x) y (norm2 + 2*x + 1) (acc + 1)      

main = print $ arcLength (2^30)

:

$ ghc -O2 tmp1.hs -fforce-recomp
[1 of 1] Compiling Main             ( tmp1.hs, tmp1.o )
Linking tmp1 ...

$ time ./tmp1
843298604

real    0m3.553s
user    0m3.539s
sys 0m0.006s

我在我的机器上使用gcj编译了Java版本,运行时间为3.5秒,而Haskell则需要6秒。JVM使代码变慢了。 - fuz
@FUZxxl,我无法尝试gcj,但c++ (gcc -O3)版本在这里运行需要2.1秒(与我的ghc的3.5秒相比不算太差)。不确定为什么你使用我的版本会得到6秒的结果,也无法解释为什么将x+1更改为succ x会在这里产生如此大的差异(5.3秒 vs 3.6秒)。 - Ed'ka
我也是。也许我的机器不够强大。在我的机器上,纯C大约需要2.8秒,而Java则需要3.5秒。 - fuz

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