Haskellプログラムでのガベージコレクションの一時停止時間の削減


130

「メッセージ」を受信して​​転送するプログラムを開発していますが、メッセージの一時的な履歴を保持しているので、要求に応じてメッセージの履歴を伝えることができます。メッセージは数値で識別され、通常は約1キロバイトのサイズであり、数十万のメッセージを保持する必要があります。

このプログラムを遅延に対して最適化したいと思います。メッセージの送信と受信の間の時間は10ミリ秒未満でなければなりません。

プログラムはHaskellで書かれており、GHCでコンパイルされています。ただし、ガベージコレクションの一時停止は、待機時間の要件に対して長すぎることがわかりました。実際のプログラムでは100ミリ秒を超えています。

次のプログラムは、アプリケーションの簡易バージョンです。Data.Map.Strictメッセージの保存にを使用します。メッセージはでByteString識別されますInt。1,000,000メッセージは昇順で挿入され、最も古いメッセージは継続的に削除されて履歴が最大200,000メッセージに保たれます。

module Main (main) where

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import qualified Data.Map.Strict as Map

data Msg = Msg !Int !ByteString.ByteString

type Chan = Map.Map Int ByteString.ByteString

message :: Int -> Msg
message n = Msg n (ByteString.replicate 1024 (fromIntegral n))

pushMsg :: Chan -> Msg -> IO Chan
pushMsg chan (Msg msgId msgContent) =
  Exception.evaluate $
    let
      inserted = Map.insert msgId msgContent chan
    in
      if 200000 < Map.size inserted
      then Map.deleteMin inserted
      else inserted

main :: IO ()
main = Monad.foldM_ pushMsg Map.empty (map message [1..1000000])

このプログラムをコンパイルして実行しました。

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.10.3
$ ghc -O2 -optc-O3 Main.hs
$ ./Main +RTS -s
   3,116,460,096 bytes allocated in the heap
     385,101,600 bytes copied during GC
     235,234,800 bytes maximum residency (14 sample(s))
     124,137,808 bytes maximum slop
             600 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0      6558 colls,     0 par    0.238s   0.280s     0.0000s    0.0012s
  Gen  1        14 colls,     0 par    0.179s   0.250s     0.0179s    0.0515s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.652s  (  0.745s elapsed)
  GC      time    0.417s  (  0.530s elapsed)
  EXIT    time    0.010s  (  0.052s elapsed)
  Total   time    1.079s  (  1.326s elapsed)

  %GC     time      38.6%  (40.0% elapsed)

  Alloc rate    4,780,213,353 bytes per MUT second

  Productivity  61.4% of total user, 49.9% of total elapsed

ここで重要なメトリックは、0.0515秒、つまり51ミリ秒の「最大休止」です。これを少なくとも1桁削減したいと考えています。

実験により、GCの一時停止の長さは履歴内のメッセージ数によって決まることが示されています。関係はおおよそ線形、またはおそらく超線形です。次の表は、この関係を示しています。(ここ私たちのベンチマークテストを見ることができここいくつかのチャートを見ることができます。)

msgs history length  max GC pause (ms)
===================  =================
12500                                3
25000                                6
50000                               13
100000                              30
200000                              56
400000                             104
800000                             199
1600000                            487
3200000                           1957
6400000                           5378

他のいくつかの変数を試して、このレイテンシを短縮できるかどうかを確認しましたが、どれも大きな違いはありません。これらの重要でない変数の中には:最適化(-O-O2); RTS GCオプション(-G-H-A-c)、コア(の数-N)、異なるデータ構造(Data.Sequence)、メッセージのサイズ、および生成された短命のごみの量。圧倒的な決定要因は、履歴内のメッセージの数です。

私たちの作業理論は、各GCサイクルがすべての作業中のアクセス可能なメモリをウォークスルーしてコピーする必要があるため、一時停止はメッセージ数において線形であり、それは明らかに線形操作です。

質問:

  • この線形時間理論は正しいですか?GCポーズの長さをこの単純な方法で表現できますか、それとも現実はより複雑ですか?
  • 作業メモリ内でGCの一時停止が線形である場合、関連する一定の要因を減らす方法はありますか?
  • インクリメンタルGCなどのオプションはありますか?研究論文しか見ることができません。スループットを低くしてレイテンシを低くすることをいとわないです。
  • 複数のプロセスに分割する以外に、GCサイクルを小さくするためにメモリを「パーティション化」する方法はありますか?

1
@Bakuriu:そうですが、ほとんどの微調整なしの最新のOSで10ミリ秒を達成できます。単純なCプログラムを実行すると、古いRaspberry piでも、5ミリ秒の範囲のレイテンシ、または少なくとも確実に 15ミリ秒程度のレイテンシを簡単に達成できます。
leftaround約

3
テストケースが便利であると確信しCOntrol.Concurrent.Chanていますか(たとえば、使用していないように?可変オブジェクトは方程式を変更します)?まず、どのガベージを生成しているかを確認し、それをできるだけ少なくすることから始めることをお勧めします(たとえば、フュージョンが発生することを確認して、を試してください-funbox-strict)。おそらく、ストリーミングライブラリ(iostream、パイプ、コンジット、ストリーミング)を使用して、performGCより頻繁な間隔で直接呼び出してみてください。
jberryman

6
何を達成しようとしている一定の間隔で行うことができるならば、それが実現するために試みることによって開始する(多分などから、リングバッファをMutableByteArray、GCは、その場合のまったく関与することはありません)
jberryman

1
変更可能な構造を提案し、最小限のガベージを作成するように注意している人にとって、それは保持サイズであり、一時停止時間を決定するように見える収集されたガベージの量ではないことに注意してください。より頻繁なコレクションを強制すると、ほぼ同じ長さの一時停止が多くなります。編集:可変のヒープ外構造は興味深いかもしれませんが、多くの場合、操作するのはそれほど楽しくありません!
マイク

6
この説明は、GC時間がすべての世代のヒープのサイズに線形であることを確かに示唆しています。重要な要素は、保持されたオブジェクトのサイズ(コピー用)とそれらに存在するポインターの数(清掃用)です:ghc.haskell。 org / trac / ghc / wiki / Commentary / Rts / Storage / GC / Copying
mike

回答:


96

200Mbを超えるライブデータで51msの休止時間を得るために、実際にはかなりうまくやっています。私が取り組んでいるシステムでは、最大一時停止時間が長く、その半分の量のライブデータがあります。

あなたの仮定は正しいです。主要なGCの一時停止時間はライブデータの量に正比例します。残念ながら、現状ではGHCでそれを回避する方法はありません。過去にインクリメンタルGCを試しましたが、これは研究プロジェクトであり、リリースされたGHCに組み込むために必要な成熟度のレベルに達していませんでした。

私たちが将来これを助けることを期待していることの1つは、コンパクトなリージョンです:https : //phabricator.haskell.org/D1264。これは、ヒープ内の構造を圧縮する一種の手動メモリ管理であり、GCはそれを通過する必要はありません。長期間有効なデータに最適ですが、設定内の個々のメッセージに使用するのに十分な場合があります。私たちはGHC 8.2.0でそれを持っていることを目指しています。

分散設定で、何らかのロードバランサーを使用していて、一時停止ヒットを回避するためにプレイできるトリックがある場合は、基本的に、ロードバランサーがリクエストを送信しようとしているマシンにリクエストを送信しないようにします。メジャーなGCを実行します。もちろん、リクエストを受け取っていなくても、マシンがまだGCを完了していることを確認してください。


13
こんにちはサイモン、細かい返事をありがとうございました!悪い知らせですが、閉鎖するのは良いことです。私たちは現在、唯一の適切な代替手段である可変実装に向かって動いています。私たちが理解していないいくつかの事柄:(1)ロードバランシングスキームに含まれるトリックは何performGCですか?それらは手動を含みますか?(2)で圧縮すると-cパフォーマンスが低下するのはなぜですか?そのままにしておくことができる多くのものが見つからないためと考えますか?(3)コンパクトについての詳細はありますか?非常に興味深いように思えますが、残念ながら、将来的にはあまりにも遠すぎて検討できません。
jameshfisher 2016


@AlfredoDiNapoliありがとうございます!
mljrg

9

IOVector基盤となるデータ構造としてリングバッファアプローチを使用してコードスニペットを試しました。私のシステム(GHC 7.10.3、同じコンパイルオプション)では、最大時間(OPで言及したメトリック)が約22%減少しました。

NB。ここでは2つの仮定をしました。

  1. 可変データ構造は問題に大丈夫です(とにかくメッセージパッシングはIOを意味していると思います)
  2. メッセージIDは連続しています

追加のIntパラメーターと演算(messageIdが0またはにリセットされる場合などminBound)を使用すると、特定のメッセージがまだ履歴にあるかどうかを判断し、リングバッファー内の対応するインデックスからそれを取得することは簡単です。

あなたのテストの喜びのために:

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import qualified Data.Map.Strict as Map

import qualified Data.Vector.Mutable as Vector

data Msg = Msg !Int !ByteString.ByteString

type Chan = Map.Map Int ByteString.ByteString

data Chan2 = Chan2
    { next          :: !Int
    , maxId         :: !Int
    , ringBuffer    :: !(Vector.IOVector ByteString.ByteString)
    }

chanSize :: Int
chanSize = 200000

message :: Int -> Msg
message n = Msg n (ByteString.replicate 1024 (fromIntegral n))


newChan2 :: IO Chan2
newChan2 = Chan2 0 0 <$> Vector.unsafeNew chanSize

pushMsg2 :: Chan2 -> Msg -> IO Chan2
pushMsg2 (Chan2 ix _ store) (Msg msgId msgContent) =
    let ix' = if ix == chanSize then 0 else ix + 1
    in Vector.unsafeWrite store ix' msgContent >> return (Chan2 ix' msgId store)

pushMsg :: Chan -> Msg -> IO Chan
pushMsg chan (Msg msgId msgContent) =
  Exception.evaluate $
    let
      inserted = Map.insert msgId msgContent chan
    in
      if chanSize < Map.size inserted
      then Map.deleteMin inserted
      else inserted

main, main1, main2 :: IO ()

main = main2

main1 = Monad.foldM_ pushMsg Map.empty (map message [1..1000000])

main2 = newChan2 >>= \c -> Monad.foldM_ pushMsg2 c (map message [1..1000000])

2
こんにちは!素敵な答え。これが22%のスピードアップしか得られない理由は、GCがまだIOVector各インデックスでと(不変、GCされた)値をウォークする必要があるためだと思います。現在、可変構造を使用して再実装するためのオプションを調査しています。リングバッファーシステムに似ている可能性があります。しかし、私たちはそれを完全にHaskellメモリー空間の外に移動して、独自の手動メモリー管理を行っています。
jameshfisher 2016

11
@jamesfisher:私は実際に同様の問題に直面していましたが、Hamsell側のメモリ管理を維持することにしました。実際の解決策は、元のデータのバイト単位のコピーを単一の連続したメモリブロックに保持するリングバッファーでした。これにより、単一のHaskell値が得られます。このRingBuffer.hsの要旨でそれを見てください。私はあなたのサンプルコードに対してそれをテストしました、そして、重要なメトリックのおよそ90%のスピードアップがありました。都合のよいときにコードを自由に使用してください。
mgmeier

8

他の人にも同意する必要があります。リアルタイムの制約がある場合、GC言語の使用は理想的ではありません。

ただし、Data.Mapだけでなく、他の使用可能なデータ構造を試すことを検討することもできます。

Data.Sequenceを使用して書き直し、いくつかの有望な改善を得ました。

msgs history length  max GC pause (ms)
===================  =================
12500                              0.7
25000                              1.4
50000                              2.8
100000                             5.4
200000                            10.9
400000                            21.8
800000                            46
1600000                           87
3200000                          175
6400000                          350

レイテンシを最適化しているにもかかわらず、他のメトリックも改善していることに気付きました。200000の場合、実行時間は1.5秒から0.2秒に低下し、合計メモリ使用量は600 MBから27 MBに低下します。

私はデザインを微調整することでだまされたことに注意する必要があります:

  • Intから取り外したMsgので、2か所にはありません。
  • Ints からsへByteStringのMapを使用する代わりにSequenceByteStringsのaを使用しました。Intメッセージごとに1つではなくInt、全体で1つを使用して実行できると思いますSequence。メッセージが並べ替えられないと仮定すると、単一のオフセットを使用して、キューのどこにメッセージを配置するかを変換できます。

(私はそれgetMsgを示すために追加の関数を含めました。)

{-# LANGUAGE BangPatterns #-}

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import Data.Sequence as S

newtype Msg = Msg ByteString.ByteString

data Chan = Chan Int (Seq ByteString.ByteString)

message :: Int -> Msg
message n = Msg (ByteString.replicate 1024 (fromIntegral n))

maxSize :: Int
maxSize = 200000

pushMsg :: Chan -> Msg -> IO Chan
pushMsg (Chan !offset sq) (Msg msgContent) =
    Exception.evaluate $
        let newSize = 1 + S.length sq
            newSq = sq |> msgContent
        in
        if newSize <= maxSize
            then Chan offset newSq
            else
                case S.viewl newSq of
                    (_ :< newSq') -> Chan (offset+1) newSq'
                    S.EmptyL -> error "Can't happen"

getMsg :: Chan -> Int -> Maybe Msg
getMsg (Chan offset sq) i_ = getMsg' (i_ - offset)
    where
    getMsg' i
        | i < 0            = Nothing
        | i >= S.length sq = Nothing
        | otherwise        = Just (Msg (S.index sq i))

main :: IO ()
main = Monad.foldM_ pushMsg (Chan 0 S.empty) (map message [1..5 * maxSize])

4
こんにちは!ご回答有難うございます。結果は間違いなく線形の減速を示していますが、このような高速化が得られたことは非常に興味深いことです。Data.Sequenceテストしたところ、実際にはData.Mapよりも悪いことがわかりました。違いがわからないので、調査する必要があります...
jameshfisher

8

他の回答で述べたように、GHCのガベージコレクターはライブデータをトラバースします。つまり、メモリに長期間存続するデータを格納するほど、GCの休止時間が長くなります。

GHC 8.2

この問題を部分的に克服するために、コンパクト領域と呼ばれる機能がGHC-8.2で導入されました。これはGHCランタイムシステムの機能であり、操作に便利なインターフェイスを公開するライブラリでもあります。コンパクトリージョン機能を使用すると、データをメモリ内の別の場所に配置でき、GCはガベージコレクションフェーズ中にデータをトラバースしません。したがって、メモリに保持したい大きな構造がある場合は、コンパクト領域の使用を検討してください。ただし、コンパクト領域自体に 内部にミニガベージコレクターがありません。これは、データを削除したい場所などではなく、追加専用のデータ構造に適していますHashMap。この問題は克服できますが。詳細については、次のブログ投稿を参照してください。

GHC 8.10

さらに、GHC-8.10以降、新しい低遅延の増分ガベージコレクターアルゴリズムが実装されています。これは、デフォルトでは有効になっていない代替のGCアルゴリズムですが、必要に応じてオプトインできます。したがって、デフォルトのGCを新しいGCに切り替えて、手動で折り返したり展開したりすることなく、コンパクト領域によって提供される機能を自動的に取得できます。ただし、新しいGCは特効薬ではなく、すべての問題を自動的に解決するわけではなく、トレードオフがあります。新しいGCのベンチマークについては、次のGitHubリポジトリを参照してください。


3

さて、GCでの言語の制限を見つけました。これらはハードコアリアルタイムシステムには適していません。

次の2つのオプションがあります。

1番目ヒープサイズを増やし、2レベルのキャッシュシステムを使用します。最も古いメッセージがディスクに送信され、最新のメッセージをメモリに保持します。これは、OSページングを使用して行うことができます。問題は、このソリューションでは、使用されるセカンダリメモリユニットの読み取り能力によってはページングが高価になる可能性があることです。

2番目のプログラムは「C」を使用してそのソリューションをプログラムし、それをFFIとインターフェースしてhaskellに送ります。このようにして、独自のメモリ管理を行うことができます。自分で必要なメモリを制御できるので、これは最良のオプションです。


1
こんにちは、フェルナンド。これをありがとう。私たちのシステムは「ソフト」リアルタイムのみですが、私たちの場合、GCはソフトリアルタイムに対してもあまりにも罰金を課すことがわかりました。私たちは間違いなくあなたの#2ソリューションに傾いています。
jameshfisher 2016
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.