【问题标题】:Performance problem with Euler problem and recursion on Int64 types欧拉问题的性能问题和 Int64 类型的递归
【发布时间】:2011-08-16 01:00:51
【问题描述】:

我目前正在使用 Euler 问题作为我的游乐场来学习 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);
}

}

编辑:在 cmets 中进行讨论后,我对 Haskell 代码进行了一些修改并进行了一些性能测试。首先,我将 n 更改为 2^29 以避免溢出。然后我尝试了 6 个不同的版本: Int64 或 Int 以及在 norm2 或两者之前带有刘海,以及声明 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(Haskell 平台二进制发行版)下使用 GHC 7.0.2。根据cmets的说法,在其他配置下编译时不会出现该问题。这让我觉得 Int64 类型在 Windows 版本中被破坏了。

【问题讨论】:

  • 你试过刘海模式吗? arcLength' x y !norm2 !acc? norm2acc 并不总是严格传递,因为在采用第一个分支时可能不需要它们。顺便说一句,在我的机器上只需要 6 秒。
  • 一般来说,Haskell 可以是速度更快的语言之一。你的 Haskell 代码中可能有一些东西会导致更复杂的情况,或者不能很好地配合 GHC 的优化。
  • -O2 是 GHC 优化的典型标志。 -O 不会做太多,iirc。
  • cmets 似乎暗示这是一个与 32 位 Windows 上的 GHC 7.0.2 和 64 位 GMP(Int64 类型来自哪里)相关的错误。你可以升级libgmp,升级 GHC 到 7.0.3 或者在 64 位 Windows 上测试吗?
  • 我在 GHC 7.0.3 上得到了相同的行为。我也在 64 位 Windows 上运行。但我怀疑 Haskell 平台的二进制发行版是 32 位的。没有任何 64 次下载。

标签: windows performance haskell 64-bit 32bit-64bit


【解决方案1】:

嗯,这很有趣。所以我只是编译了你的两个程序,并尝试了它们:

% java -版本 java版本“1.6.0_18” OpenJDK 运行时环境 (IcedTea6 1.8.7) (6b18-1.8.7-2~squeeze1) OpenJDK 64 位服务器 VM(内部版本 14.0-b16,混合模式) % javac ArcLength.java % java 弧长 843298604 6630

所以Java 解决方案大约需要 6.6 秒。接下来是经过一些优化的 ghc:

% ghc --版本 Glorious Glasgow Haskell 编译系统,版本 6.12.1 % ghc --make -O arc.hs % 时间./弧 843298604 ./arc 12.68s user 0.04s system 99% cpu 12.718 total

ghc -O 不到 13 秒

尝试进一步优化:

% ghc --make -O3 % 时间 ./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
  • 我在windows下使用ghc。 ghc --version The Glorious Glasgow Haskell 编译系统,版本 7.0.2
  • 当用 32 位版本的 GHC 7.0.2 编译时,我看到与 Int64 相同的缓慢,而当使用 x64 版本(尽管是 7.0.3)编译时,时间相同的
  • 嗯,在没有 -O3 的情况下编译时,我在大约一分钟后杀死了它,所以这里 -O3 所做的优化是巨大的。
  • 嗯,我觉得用感叹号随机混乱代码会产生如此大的影响,这有点令人失望。但对我来说,真正的问题似乎是 64 位算术的实现很糟糕......根据我的时间安排,使用 64 位整数几乎将执行时间增加了 10 倍。
【解决方案2】:

性能相关代码的正常优化标志是-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 大约快 1 秒 (20%):

tommd@Mavlo:Test$ time java ArcLength
843298604
3880

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

但 GHC 的一个有趣之处在于它有许多不同的后端。默认情况下,它使用我们上面计时的本机代码生成器 (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

但是,正如 cmets 中提到的 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。

【讨论】:

  • 当您在arcLength' 中的参数norm2 处添加爆炸模式时,llvm 后端会做得更好,因为默认情况下norm2 并不严格。
  • 啊!您还必须在arcLength !n 上添加一个爆炸声。
【解决方案3】:

我已经使用了一些代码,这个版本在我的笔记本电脑上似乎比 Java 版本运行得更快(3.55s vs 4.63s):

{-# 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 使代码变慢。
  • @FUZxxl 我不能尝试 gcj,但是 c++ (gcc -O3) 版本在 2.1 秒内运行(与我的 ghc 的 3.5 秒相比——还不错)。不知道为什么我的版本是 6s,我也无法解释为什么将 x+1 更改为 succ x 会产生如此大的差异(5.3s vs 3.6)。
  • 我也是。 Mybe 我的机器没那么强大。在我的机器上,普通 C 语言大约需要 2.8 秒,Java 大约需要 3.5 秒。
【解决方案4】:

您的问题中有一些有趣的事情。

您应该主要使用-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)

窥探核心

我们将快速浏览一下核心,using 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 上为 64 位整数生成不良代码的 libgmp 版本有关。首先尝试升级到 GHC 7.0.3,然后尝试其他一些代码生成器后端,如果您仍然对 Int64 有问题,请向 GHC trac 提交错误报告。

广泛确认确实是在 64 位整数的 32 位仿真中进行这些 C 调用的成本,我们可以将 Int64 替换为 Integer,这是通过在每台机器上对 GMP 的 C 调用来实现的,并且事实上,运行时间从 3 秒到一分钟多一点。

课程:尽可能使用 64 位硬件。

【讨论】:

  • 并非总是可能的。 Haskell 无法在 windows 下编译成 64 位代码,所以这并不总是一种选择。
  • 你如何获得如此干净的核心输出?
  • 使用ghc-core,你可以在这里获得:hackage.haskell.org/package/ghc-core
【解决方案5】:

嗯,我用 7.0.3 安装了一个全新的 Haskell 平台,并为您的程序大致获取了以下核心 (-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.IntWord64source code 似乎证实了这一点:在 32 位构建中(如平台当前附带的版本),您将只能通过 FFI 接口进行仿真。

【讨论】:

  • 没错。在 32 位机器上,Int64 是通过 C GMP 库实现的。它将有一些性能开销。在 64 位机器上,没有这样的问题。
  • 我想这就解决了。谢谢!我也猜想,如果你有 Windows,那么拥有 64 位机器是不够的,因为 Windows 没有 64 位 GHC (hackage.haskell.org/trac/ghc/wiki/WindowsGhc)。
  • 而您将使用Integer 获得相同的降级(这也作为对libgmp 的C 调用实现)。
【解决方案6】:

dberg,我觉得这一切都因不幸的-O 标志而开端。只是为了强调其他人提出的观点,对于普通的编译和测试,请像我一样将其粘贴到您的 .bashrc 或其他内容中:

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2011-12-04
    • 1970-01-01
    • 1970-01-01
    • 2012-07-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2022-01-18
    相关资源
    最近更新 更多