【问题标题】:Ackermann very inefficient with Haskell/GHCAckermann 使用 Haskell/GHC 效率非常低
【发布时间】:2013-04-20 02:13:35
【问题描述】:

我尝试计算 Ackermann(4,1),但不同语言/编译器之间的性能差异很大。下面是我的Core i7 3820QM, 16G, Ubuntu 12.10 64bit,

的结果

C:1.6sgcc -O3(使用 gcc 4.7.2)

int ack(int m, int n) {
  if (m == 0) return n+1;
  if (n == 0) return ack(m-1, 1);
  return ack(m-1, ack(m, n-1));
}

int main() {
  printf("%d\n", ack(4,1));
  return 0;
}

OCaml:3.6socamlopt(使用 ocaml 3.12.1)

let rec ack = function
  | 0,n -> n+1
  | m,0 -> ack (m-1, 1)
  | m,n -> ack (m-1, ack (m, n-1))
in print_int (ack (4, 1))

标准 ML:5.1 秒 mlton -codegen c -cc-opt -O3 (使用 mlton 20100608)

fun ack 0 n = n+1
  | ack m 0 = ack (m-1) 1
  | ack m n = ack (m-1) (ack m (n-1));
print (Int.toString (ack 4 1));

球拍:11.5s racket (使用球拍 v5.3.3)

(require racket/unsafe/ops)

(define + unsafe-fx+)
(define - unsafe-fx-)
(define (ack m n)
  (cond
    [(zero? m) (+ n 1)]
    [(zero? n) (ack (- m 1) 1)]
    [else (ack (- m 1) (ack m (- n 1)))]))

(time (ack 4 1))

Haskell:未完成,在 22 秒后被系统杀死 ghc -O2(使用 ghc 7.4.2)

Haskell:1.8 秒 ajhc (使用 ajhc 0.8.0.4)

main = print $ ack 4 1
  where ack :: Int -> Int -> Int
        ack 0 n = n+1
        ack m 0 = ack (m-1) 1
        ack m n = ack (m-1) (ack m (n-1))

Haskell 版本是唯一无法正确终止的版本,因为它占用了太多内存。它会冻结我的机器并在被杀死之前填充交换空间。 在不严重修改代码的情况下,我可以做些什么来改进它?

编辑:我很欣赏一些渐近智能的解决方案,但它们并不是我所要求的。这更多是关于查看编译器是否以相当有效的方式(堆栈、尾调用、拆箱等)处理某些模式,而不是计算 ackermann 函数。

编辑 2:正如一些回复所指出的,这似乎是 a bug in recent versions of GHC。我用AJHC 尝试相同的代码并获得更好的性能。

非常感谢:)

【问题讨论】:

  • 对于任何尝试这样做的人来说,这里的问题是计算问题,而不是算法问题。对 ack(4,1) 的非记忆调用导致深度为 65535 的递归,以及对 ack 的总共 2862984010 次调用。
  • ghc-core 显示Ints 正确地转换为未装箱的Int#,所以这不是问题。
  • @scvalex 谢谢。我盲目地尝试了这个。
  • Frege 中的相同代码在 8.3 秒内运行,尽管需要给 JVM 一些额外的堆栈空间。硬件是 i3 CPU M 350 @ 2.27GHz
  • 您好,替代方法,该函数使用 Int,并且 GHC -O2 已经将其拆箱为 Int#。所以不需要显式注释。谢谢。

标签: performance haskell optimization recursion benchmarking


【解决方案1】:

注意: 高内存使用问题is a bug in the GHC RTS,在堆栈溢出和分配新堆栈时未检查垃圾回收是否到期。它已在 GHC HEAD 中修复。


我能够通过 CPS 转换 ack 获得更好的性能:

module Main where

data P = P !Int !Int

main :: IO ()
main = print $ ack (P 4 1) id
  where
    ack :: P -> (Int -> Int) -> Int
    ack (P 0 n) k = k (n + 1)
    ack (P m 0) k = ack (P (m-1) 1) k
    ack (P m n) k = ack (P m (n-1)) (\a -> ack (P (m-1) a) k)

你原来的函数消耗了我机器上的所有可用内存,而这个函数在恒定空间中运行。

$ time ./Test
65533
./Test  52,47s user 0,50s system 96% cpu 54,797 total

不过,Ocaml 仍然更快:

$ time ./test
65533./test  7,97s user 0,05s system 94% cpu 8,475 total

编辑:当使用JHC 编译时,您的原始程序与 Ocaml 版本的速度差不多:

$ time ./hs.out 
65533
./hs.out  5,31s user 0,03s system 96% cpu 5,515 total

编辑 2: 我发现的其他一些东西:以更大的堆栈块大小 (+RTS -kc1M) 运行您的原始程序使其在恒定空间中运行。不过,CPS 版本还是要快一些。

编辑 3: 通过手动展开主循环,我设法生成了一个运行速度几乎与 Ocaml 版本一样快的版本。但是,它仅在与 +RTS -kc1M 一起运行时才有效(Dan Doel has filed a bug 关于此行为):

{-# LANGUAGE CPP #-}
module Main where

data P = P {-# UNPACK #-} !Int {-# UNPACK #-} !Int

ack0 :: Int -> Int
ack0 n =(n+1)

#define C(a) a
#define CONCAT(a,b) C(a)C(b)

#define AckType(M) CONCAT(ack,M) :: Int -> Int

AckType(1)
AckType(2)
AckType(3)
AckType(4)

#define AckDecl(M,M1) \
CONCAT(ack,M) n = case n of { 0 -> CONCAT(ack,M1) 1 \
; 1 ->  CONCAT(ack,M1) (CONCAT(ack,M1) 1) \
; _ ->  CONCAT(ack,M1) (CONCAT(ack,M) (n-1)) }

AckDecl(1,0)
AckDecl(2,1)
AckDecl(3,2)
AckDecl(4,3)

ack :: P -> (Int -> Int) -> Int
ack (P m n) k = case m of
  0 -> k (ack0 n)
  1 -> k (ack1 n)
  2 -> k (ack2 n)
  3 -> k (ack3 n)
  4 -> k (ack4 n)
  _ -> case n of
    0 -> ack (P (m-1) 1) k
    1 -> ack (P (m-1) 1) (\a -> ack (P (m-1) a) k)
    _ -> ack (P m (n-1)) (\a -> ack (P (m-1) a) k)

main :: IO ()
main = print $ ack (P 4 1) id

测试:

$ time ./Test +RTS -kc1M
65533
./Test +RTS -kc1M  6,30s user 0,04s system 97% cpu 6,516 total

编辑 4:显然,is fixed in GHC HEAD 的空间泄漏,所以将来不需要+RTS -kc1M

【讨论】:

  • 值得注意的是,无论您是否进行 CPS-convert(这确实显着提高了运行时间),通过 uncurrying 和使用未装箱的元组(类似于 -XUnboxedTuples)来实现恒定的内存使用。
  • 从CMM代码来看,运行代码所需的栈大小刚好小于1M(根据@scvalex,递归深度65535,每次调用16字节)。因此-kc1M 不占用额外空间,而-kc768K 完成,但占用大量空间。
【解决方案2】:

似乎涉及某种错误。您使用的是哪个 GHC 版本?

使用 GHC 7,我会得到与您相同的行为。程序消耗所有可用内存而不产生任何输出。

但是,如果我只用ghc --make -O2 Ack.hs 用 GHC 6.12.1 编译它,它就可以完美运行。它在我的计算机上以 10.8s 计算结果,而普通 C 版本需要 7.8s

我建议你report this bug on GHC web site

【讨论】:

    【解决方案3】:

    这个版本使用了 ackermann 函数的一些属性。它不等同于 其他版本,但速度很快:

    ackermann :: Int -> Int -> Int
    ackermann 0 n = n + 1
    ackermann m 0 = ackermann (m - 1) 1
    ackermann 1 n = n + 2
    ackermann 2 n = 2 * n + 3
    ackermann 3 n = 2 ^ (n + 3) - 3
    ackermann m n = ackermann (m - 1) (ackermann m (n - 1))
    

    编辑:这是一个带有memoization的版本,我们看到在haskell中memoize一个函数很容易,唯一的变化是在调用站点:

    import Data.Function.Memoize
    
    ackermann :: Integer -> Integer -> Integer
    ackermann 0 n = n + 1
    ackermann m 0 = ackermann (m - 1) 1
    ackermann 1 n = n + 2
    ackermann 2 n = 2 * n + 3
    ackermann 3 n = 2 ^ (n + 3) - 3
    ackermann m n = ackermann (m - 1) (ackermann m (n - 1))
    
    main :: IO ()
    main = print $ memoize2 ackermann 4 2
    

    【讨论】:

      【解决方案4】:

      以下是一个惯用的版本,它利用了 Haskell 的惰性和 GHC 对常量顶级表达式的优化。

      acks :: [[Int]]
      acks = [ [ case (m, n) of
                      (0, _) -> n + 1
                      (_, 0) -> acks !! (m - 1) !! 1
                      (_, _) -> acks !! (m - 1) !! (acks !! m !! (n - 1))
               | n <- [0..] ]
             | m <- [0..] ]
      
      main :: IO ()
      main = print $ acks !! 4 !! 1
      

      在这里,我们正在为所有 Ackermann 函数的值构建一个矩阵。因此,后续对 acks 的调用将不会重新计算任何内容(即再次评估 acks !! 4 !! 1 不会将运行时间加倍)。

      虽然这不是最快的解决方案,但它看起来很像幼稚的实现,它在内存使用方面非常有效,并且将 Haskell 的一个奇怪的特性(懒惰)重新塑造为一种优势。

      【讨论】:

      • 在恒定空间中运行,但仍然比 Ocaml 慢得多:./Test 82,38s user 0,44s system 92% cpu 1:29,98 total
      • 是的。我个人很高兴它可以运行。问题中的版本会在我的机器上耗尽内存。我现在会尝试使用数组来加快速度。
      • 我认为对这样一个简单的基准测试问题使用不同的算法是一种作弊......
      • 利用 Haskell 可以做大多数其他语言不能做的事情的事实有点作弊,我想,但如果你不这样做,基准测试最终会比较苹果和橘子。基准不是“用 Haskell 编写的 C 代码要慢多少”,对吧?
      • @scvalex:其他语言也可以做记忆。只是您不能为此使用列表推导。
      【解决方案5】:

      我根本不认为这是一个错误,ghc 只是没有利用它知道 4 和 1 是函数将被调用的唯一参数的事实——也就是说,说白了就是不作弊。它也不会为你做固定的数学运算,所以如果你写了main = print $ ack (2+2) 1,它不会计算出 2+2 = 4 直到运行时。 ghc 有更重要的事情要考虑。如果您关心它http://hackage.haskell.org/package/const-math-ghc-plugin,则可以获得后一种困难的帮助。

      所以ghc 如果您做一些数学运算会有所帮助,例如这至少比使用 4 和 1 作为参数的 C 程序快一百倍。但是尝试使用 4 和 2:

      main = print $ ack 4 2 where
      
          ack :: Int -> Integer -> Integer
          ack 0 n = n + 1
          ack 1 n = n + 2 
          ack 2 n = 2 * n + 3
          ack m 0 = ack (m-1) 1
          ack m n = ack (m-1) (ack m (n-1) )
      

      这将在不到十分之一秒的时间内给出所有约 20,000 位数字的正确答案,而 gcc 和你的算法将永远给出错误的答案。

      【讨论】:

      • 这是一个错误。即使您对允许使用的堆和堆栈空间的数量设置了硬限制,运行时也会很快耗尽所有内存并挂起机器。幸运的是,这个错误只发生在像 Akermann 函数这样非常神秘的函数上,无论如何它在 HEAD 中已经修复。
      【解决方案6】:

      在 Haskell 中以与您在 C 中编写算法的方式相似的方式编写算法并不是同一种算法,因为递归的语义完全不同。

      这是一个使用相同数学算法的版本,但我们使用数据类型象征性地表示对阿克曼函数的调用。这样,我们可以更精确地控制递归的语义。

      使用优化编译时,此版本在恒定内存中运行,但速度很慢 - 在与您类似的环境中大约需要 4.5 分钟。但我确信它可以修改得更快。这只是给出一个想法。

      data Ack = Ack !Int
      
      ack :: Int -> Int -> Int
      ack m n = length . ackR $ Ack m : replicate n (Ack 0)
        where
          ackR n@(Ack 0 : _) = n
          ackR n             = ackR $ ack' n
      
          ack' [] = []
          ack' (Ack 0 : n) = Ack 0 : ack' n
          ack' [Ack m]     = [Ack (m-1), Ack 0]
          ack' (Ack m : n) = Ack (m-1) : ack' (Ack m : decr n)
      
          decr (Ack 0 : n) = n
          decr n           = decr $ ack' n
      

      【讨论】:

      • 嗯。我将data 更改为newtype,认为这样可以节省内存,从而减少垃圾收集和时间。但结果实际上更慢了。改回data
      • 一个简单的优化是将相同m 的长时间运行Ack m 合并为一个,在Ack 中添加一个额外参数,用于重复它的次数。这减少了垃圾收集的数量。这将我的运行时间缩短到大约 1 分钟。
      【解决方案7】:

      在 Apple XCode 更新到 4.6.2 之后,这个性能问题(显然 GHC RTS 错误除外)现在似乎已在 OS X 10.8 上得到修复。我仍然可以在 Linux 上重现它(虽然我一直在使用 GHC LLVM 后端进行测试),但在 OS X 上就不行了。在我将 XCode 更新到 4.6.2 后,新版本似乎影响了 GHC 后端代码生成Ackermann 实质上(根据我在更新前查看对象转储的记忆)。在 XCode 更新之前,我能够在 Mac 上重现性能问题 - 我没有数字,但它们肯定很糟糕。因此,XCode 更新似乎改进了 Ackermann 的 GHC 代码生成。

      现在,C 和 GHC 版本非常接近。 C代码:

      int ack(int m,int n){
      
        if(m==0) return n+1;
        if(n==0) return ack(m-1,1);
        return ack(m-1, ack(m,n-1));
      
      }
      

      执行 ack(4,1) 的时间:

      GCC 4.8.0: 2.94s
      Clang 4.1: 4s
      

      Haskell 代码:

      ack :: Int -> Int -> Int
      ack 0 n = n+1
      ack m 0 = ack (m-1) 1
      ack m n = ack (m-1) (ack m (n-1))
      

      执行 ack 4 1 的时间(使用 +RTS -kc1M):

      GHC 7.6.1 Native: 3.191s
      GHC 7.6.1 LLVM: 3.8s 
      

      所有文件都使用-O2 标志编译(以及用于 GHC 的-rtsopts 标志,用于 RTS 错误解决方法)。不过,这真是令人头疼。更新 XCode 似乎对 GHC 中的 Ackermann 优化产生了很大的影响。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2011-08-24
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多