リストの最後から2番目の要素を見つけるとき、なぜこれらの中で「最後」を使用するのが最も速いのですか?


10

以下の3つの関数は、リストの最後から2番目の要素を検索します。使用してlast . initいるものは他のものよりもはるかに速いようです。理由がわからないようです。

テストでは、[1..100000000](1億)の入力リストを使用しました。最後のものはほとんど瞬時に実行されますが、他のものは数秒かかります。

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

5
initリストの複数回の「アンパック」を回避するように最適化されています。
Willem Van Onsem

1
@WillemVanOnsemしかし、なぜmyButLastはるかに遅いのですか?それはリストをアンパックしていないようですが、init関数のようにそれをトラバースするだけです...
lsmor

1
@Ismor:それは[x, y]のために短い(x:(y:[]))第二の尾部があれば、それは外側の短所、第短所、及びチェックをアンパックように、consです[]。さらに、2番目の句は、リストを再びで展開し(x:xs)ます。はい、アンパックはかなり効率的ですが、もちろんそれが非常に頻繁に行われると、プロセスが遅くなります。
Willem Van Onsem

1
hackage.haskell.org/package/base-4.12.0.0/docs/src/…を見ると、最適化initは、引数がシングルトンリストか空のリストかどうかを繰り返しチェックしないようです。再帰が始まると、最初の要素が再帰呼び出しの結果に追加されることを前提としています。
chepner

2
@WillemVanOnsem私はおそらく解凍はここでは問題ではないと思います:GHCは呼び出しパターンの特殊化を行い、myButLast自動的に最適化されたバージョンを提供します。スピードアップのせいになっているのは、リストの融合の可能性が高いと思います。
oisdk

回答:


9

速度と最適化を研究するとき、非常に間違った結果得るのは非常に簡単です。特に、コンパイラのバージョンとベンチマーク設定の最適化モードについて言及しなければ、1つのバリアントが別のバリアントよりも速いとは言えません。それでも、最新のプロセッサは非常に洗練されており、あらゆる種類のキャッシュは言うまでもなく、ニューラルネットワークベースの分岐予測子を備えています。そのため、注意深く設定しても、ベンチマーク結果はぼやけます。

言われていること...

ベンチマークは私たちの友人です。

criterion高度なベンチマークツールを提供するパッケージです。私はすぐに次のようなベンチマークを作成しました:

module Main where

import Criterion
import Criterion.Main

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

butLast2 :: [a] -> a
butLast2 (x :     _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"

setupEnv = do
  let xs = [1 .. 10^7] :: [Int]
  return xs

benches xs =
  [ bench "slow?"   $ nf myButLast   xs
  , bench "decent?" $ nf myButLast'  xs
  , bench "fast?"   $ nf myButLast'' xs
  , bench "match2"  $ nf butLast2    xs
  ]

main = defaultMain
    [ env setupEnv $ \ xs -> bgroup "main" $ let bs = benches xs in bs ++ reverse bs ]

ご覧のとおり、2つの要素を明示的に一度に一致させるバリアントを追加しましたが、それ以外はまったく同じコードです。また、キャッシングによるバイアスを意識するために、ベンチマークを逆に実行します。だから、実行して見てみましょう!

% ghc --version
The Glorious Glasgow Haskell Compilation System, version 8.6.5


% ghc -O2 -package criterion A.hs && ./A
benchmarking main/slow?
time                 54.83 ms   (54.75 ms .. 54.90 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.86 ms   (54.82 ms .. 54.93 ms)
std dev              94.77 μs   (54.95 μs .. 146.6 μs)

benchmarking main/decent?
time                 794.3 ms   (32.56 ms .. 1.293 s)
                     0.907 R²   (0.689 R² .. 1.000 R²)
mean                 617.2 ms   (422.7 ms .. 744.8 ms)
std dev              201.3 ms   (105.5 ms .. 283.3 ms)
variance introduced by outliers: 73% (severely inflated)

benchmarking main/fast?
time                 84.60 ms   (84.37 ms .. 84.95 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 84.46 ms   (84.25 ms .. 84.77 ms)
std dev              435.1 μs   (239.0 μs .. 681.4 μs)

benchmarking main/match2
time                 54.87 ms   (54.81 ms .. 54.95 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.85 ms   (54.81 ms .. 54.92 ms)
std dev              104.9 μs   (57.03 μs .. 178.7 μs)

benchmarking main/match2
time                 50.60 ms   (47.17 ms .. 53.01 ms)
                     0.993 R²   (0.981 R² .. 0.999 R²)
mean                 60.74 ms   (56.57 ms .. 67.03 ms)
std dev              9.362 ms   (6.074 ms .. 10.95 ms)
variance introduced by outliers: 56% (severely inflated)

benchmarking main/fast?
time                 69.38 ms   (56.64 ms .. 78.73 ms)
                     0.948 R²   (0.835 R² .. 0.994 R²)
mean                 108.2 ms   (92.40 ms .. 129.5 ms)
std dev              30.75 ms   (19.08 ms .. 37.64 ms)
variance introduced by outliers: 76% (severely inflated)

benchmarking main/decent?
time                 770.8 ms   (345.9 ms .. 1.004 s)
                     0.967 R²   (0.894 R² .. 1.000 R²)
mean                 593.4 ms   (422.8 ms .. 691.4 ms)
std dev              167.0 ms   (50.32 ms .. 226.1 ms)
variance introduced by outliers: 72% (severely inflated)

benchmarking main/slow?
time                 54.87 ms   (54.77 ms .. 55.00 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.95 ms   (54.88 ms .. 55.10 ms)
std dev              185.3 μs   (54.54 μs .. 251.8 μs)

私たちの「遅い」バージョンはまったく遅くないようです!そして、パターンマッチングの複雑さは何も追加しません。match2私がキャッシングの影響によると、2つの連続した実行の間に見られるわずかなスピードアップ。)

より「科学的な」データを取得する方法があり-ddump-simplます。コンパイラーがコードを認識する方法を確認できます。

中間構造の検査は私たちの友人です。

「コア」はGHCの内部言語です。すべてのHaskellソースファイルは、ランタイムシステムが実行する最終的な機能グラフに変換される前に、コアに単純化されます。この中間段階を見るmyButLastbutLast2、同等であることがわかります。名前を変更する段階で、すべての素敵な識別子がランダムに変形されているので、それを見るのには時間がかかります。

% for i in `seq 1 4`; do echo; cat A$i.hs; ghc -O2 -ddump-simpl A$i.hs > A$i.simpl; done

module A1 where

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

module A2 where

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

module A3 where

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

module A4 where

butLast2 :: [a] -> a
butLast2 (x :     _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"

% ./EditDistance.hs *.simpl
(("A1.simpl","A2.simpl"),3866)
(("A1.simpl","A3.simpl"),3794)
(("A2.simpl","A3.simpl"),663)
(("A1.simpl","A4.simpl"),607)
(("A2.simpl","A4.simpl"),4188)
(("A3.simpl","A4.simpl"),4113)

と思われるA1A4最も類似しています。徹底的に調べると、実際のコード構造A1A4が同じであることがわかります。ことA2A3の両方が、2つの関数の組成物として定義されているので、似ていることも合理的です。

core出力を詳細に調べる場合は、やなどのフラグを指定することも意味が-dsuppress-module-prefixesあり-dsuppress-uniquesます。彼らはそれをとても読みやすくします。

私たちの敵の短いリストも。

では、ベンチマークと最適化で何がうまくいかないのでしょうか?

  • ghciは、インタラクティブなプレイと迅速な反復のために設計されており、Haskellソースを最終的な実行可能ファイルではなく特定のフレーバーバイトコードにコンパイルし、より高速なリロードを優先して高価な最適化を避けます。
  • プロファイリングは、複雑なプログラムの個々のビットやピースのパフォーマンスを調べるのに最適なツールのようですが、コンパイラの最適化を大幅に損なう可能性があり、結果は桁違いになります。
    • 安全策は、独自のベンチマークランナーを使用して、すべての小さなコードを個別の実行可能ファイルとしてプロファイルすることです。
  • ガベージコレクションは調整可能です。ちょうど今日、新しい主要な機能がリリースされました。ガベージコレクションの遅延は、簡単に予測できない方法でパフォーマンスに影響します。
  • 先に述べたように、コンパイラーのバージョンが異なると、パフォーマンスが異なるさまざまなコードがビルドされるため、約束をする前に、コードのユーザーがビルドに使用する可能性のあるバージョンを把握し、それをベンチマークする必要があります。

これは悲しそうに見えるかもしれません。しかし、ほとんどの場合、それは実際にはHaskellプログラマに関係するべきことではありません。実話:最近Haskellを学び始めたばかりの友人がいます。彼らは数値積分のためのプログラムを書いていて、カメは遅かった。そこで私たちは一緒に座って、図表などを使ってアルゴリズムのカテゴリ別の説明を書きました。彼らが抽象的な記述に合わせてコードを書き直したとき、それは魔法のように、チーターが速く、メモリもスリムになりました。すぐにπを計算しました。この話の教訓?完璧な抽象的な構造、そしてあなたのコードはそれ自身を最適化します。


非常に有益であり、この段階で私にとっても少し圧倒的です。この場合、私が行ったすべての「ベンチマーク」は、1億個のアイテムリストに対してすべての関数を実行することであり、一方が他方よりも時間がかかることに気付きました。基準付きのベンチマークはかなり便利に思われます。さらに、ghciあなたが言ったように、最初にexeを作成する場合と比較して、(速度に関して)異なる結果を与えるようです。
storm125
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.