要求された場合にメッセージ履歴を通知できるように、「メッセージ」を受信して転送するプログラムを開発していますが、これらのメッセージの一時的な履歴を保持しています。メッセージは数値で識別され、通常サイズは約1キロバイトであり、これらのメッセージを数十万個保持する必要があります。
このプログラムを最適化して待ち時間を短縮します。メッセージの送信と受信の間の時間は10ミリ秒未満でなければなりません。
このプログラムはHaskellで記述され、GHCでコンパイルされています。ただし、ガベージコレクションの一時停止は待機時間の要件に対して非常に長いことがわかりました。実際のプログラムでは100ミリ秒を超えています。
次のプログラムは、アプリケーションの簡易バージョンです。 Data.Map.Strict
を使用してメッセージを保存します。メッセージは、ByteString
で識別されるInt
sです。 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サイクルがすべての動作可能なアクセス可能なメモリを調べてコピーする必要があるため、一時停止はメッセージ数で線形であるというものです。
質問:
実際には、200Mbを超えるライブデータで51ミリ秒の一時停止時間を設定できます。私が取り組んでいるシステムでは、最大一時停止時間が長く、その半分の量のライブデータがあります。
あなたの仮定は正しいです、主要なGCの休止時間はライブデータの量に正比例します、そして残念なことに現状のGHCではそれを回避する方法はありません。過去にインクリメンタルGCを試しましたが、これは研究プロジェクトであり、リリースされたGHCに組み込むのに必要な成熟度に達しませんでした。
将来的にこれを支援することを期待していることの1つは、コンパクトな領域です。 https://phabricator.haskell.org/D1264 。これは、ヒープ内の構造を圧縮する手動のメモリ管理の一種であり、GCはそれを走査する必要がありません。存続期間の長いデータに最適ですが、設定内の個々のメッセージに使用するのに十分でしょう。 GHC 8.2.0でそれを目指しています。
分散設定で、ある種のロードバランサーを使用している場合、一時停止のヒットを回避するためにプレイできるトリックがあります。メジャーGCを実行し、もちろん、マシンが要求を受け取っていなくても、GCがまだ完了していることを確認してください。
基になるデータ構造としてIOVector
を使用するリングバッファーアプローチでコードスニペットを試しました。私のシステム(GHC 7.10.3、同じコンパイルオプション)では、これにより、最大時間(OPで言及したメトリック)が約22%削減されました。
NB。ここで2つの仮定を行いました。
いくつかの追加の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])
私は他の人に同意する必要があります-リアルタイムの厳しい制約がある場合、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秒に減少し、合計メモリ使用量は600MBから27MBに減少します。
デザインを微調整してごまかしたことに注意してください。
Int
からMsg
を削除したので、2つの場所にありません。Int
sからByteString
sへのマップを使用する代わりに、Sequence
sのByteString
を使用し、メッセージごとに1つのInt
の代わりに、 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])
さて、GCの言語の制限を見つけました:それらは、筋金入りのリアルタイムシステムには適していません。
次の2つのオプションがあります。
1ヒープサイズを増やし、2レベルのキャッシュシステムを使用します。最も古いメッセージはディスクに送信され、最新のメッセージをメモリに保持します。OSページングを使用してこれを行うことができます。問題は、このソリューションでは、使用されるセカンダリメモリユニットの読み取り機能によっては、ページングが高価になる可能性があることです。
「C」を使用してソリューションをプログラムし、FFIとhaskellに接続します。そうすれば、独自のメモリ管理を行うことができます。これは、必要なメモリを自分で制御できるため、最適なオプションです。