web-dev-qa-db-ja.com

アッカーマンはHaskell / GHCで非常に非効率的

Ackermann(4,1)を計算してみましたが、言語やコンパイラによってパフォーマンスに大きな違いがあります。以下は私のCore i7 3820QM、16G、Ubuntu 12.10 64ビット

C:1.6秒gcc -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.1smlton -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.5秒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:unfinished、22秒後にシステムによって強制終了されましたghc -O2(ghc 7.4.2を使用)

Haskell:1.8sajhc(with 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バージョンは、メモリを大量に消費するために適切に終了できない唯一のバージョンです。それは私のマシンをフリーズさせ、殺される前にスワップスペースを埋めます。コードを大幅に曖昧にすることなく、それを改善するために何ができますか?

[〜#〜] edit [〜#〜]:漸近的に賢いソリューションのいくつかに感謝しますが、それらは私が求めているものとは異なります。これは、アッカーマン関数を計算するよりも、コンパイラが特定のパターンを合理的に効率的な方法(スタック、末尾呼び出し、ボックス化解除など)で処理するかどうかを確認することです。

EDIT 2:いくつかの回答で指摘されているように、これは GHCの最近のバージョンのバグ のようです。 [〜#〜] ajhc [〜#〜] で同じコードを試してみると、パフォーマンスが大幅に向上します。

どうもありがとうございました :)

49
Phil

NB:メモリ使用量が多い問題 GHC RTSのバグです 、スタックオーバーフローと新しいスタックの割り当て時にガベージコレクションの期限かどうかはチェックされませんでした。これはGHCHEADですでに修正されています。


ackをCPS変換することで、はるかに優れたパフォーマンスを得ることができました。

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 バグを報告しました この動作について):

{-# 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:どうやら、スペースリーク GHC HEADで修正されています なので、+RTS -kc1Mは必要ありません将来は。

36

なんらかのバグが関係しているようです。どのGHCバージョンを使用していますか?

GHC 7では、あなたと同じ動作が得られます。プログラムは、出力を生成せずに使用可能なすべてのメモリを消費します。

ただし、ghc --make -O2 Ack.hsだけを使用してGHC6.12.1でコンパイルすると、完全に機能します。私のコンピューターでは10.8sで結果を計算しますが、プレーンCバージョンでは7.8s

GHC Webサイトでこのバグを報告する をお勧めします。

13
Petr Pudlák

このバージョンは、アッカーマン関数のいくつかのプロパティを使用します。他のバージョンと同等ではありませんが、高速です:

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))

編集:そしてこれはメモ化付きのバージョンです。haskellで関数をメモ化するのは簡単であることがわかります。唯一の変更は呼び出しサイトにあります:

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
7
Rémi Berson

以下は、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

ここでは、allアッカーマン関数の値の行列を怠惰に構築しています。その結果、その後のacksの呼び出しでは、何も再計算されません(つまり、acks !! 4 !! 1再びnot実行時間の2倍になります)。

これは最速のソリューションではありませんが、ナイーブな実装によく似ており、メモリ使用の点で非常に効率的であり、Haskellの奇妙な機能(怠惰)の1つを強みとして再キャストします。

5
scvalex

再帰のセマンティクスがまったく異なるため、Cで書いたのと同じようにHaskellでアルゴリズムを書くことは同じアルゴリズムではありません。

これは同じmathematicalアルゴリズムを使用するバージョンですが、データ型を使用してアッカーマン関数の呼び出しをシンボリックに表します。このようにして、再帰のセマンティクスをより正確に制御できます。

最適化を使用してコンパイルすると、このバージョンは一定のメモリで実行されますが、低速です。同じような環境では約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
4
Yitz

これがバグだとはまったくわかりません。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) )

これにより、rightの答えが得られ、すべて〜20,000桁で、10分の1秒未満ですが、アルゴリズムを使用したgccは、間違った答えをしてください。

4
applicative

このパフォーマンスの問題(明らかにGHC RTSのバグを除く)は、OS X10.8でApple XCode4.6.2に更新した後に修正されたようです。まだ再現できます。 Linuxでは(GHC LLVMバックエンドでテストしていますが)、OS Xではもうテストしていません。XCodeを4.6.2に更新した後、新しいバージョンはAckermannのGHCバックエンドコード生成に大きな影響を与えたようです(更新前のオブジェクトダンプを見て覚えています)XCode更新前にMacでパフォーマンスの問題を再現できました-数字はありませんが、確かにかなり悪かったので、XCode更新はGHCを改善したようですAckermannのコード生成。

現在、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フラグ(およびRTSバグ回避策のGHCの-rtsoptsフラグ)でコンパイルされました。しかし、それはかなり頭をかきむしります。 XCodeの更新は、GHCでのAckermannの最適化に大きな違いをもたらしたようです。

3
Sal