Stiamo sviluppando un programma che riceve e inoltra "messaggi", pur mantenendo una cronologia temporanea di tali messaggi, in modo che possa comunicarvi la cronologia dei messaggi, se richiesto. I messaggi sono identificati numericamente, in genere hanno una dimensione di circa 1 kilobyte e dobbiamo conservare centinaia di migliaia di questi messaggi.
Desideriamo ottimizzare questo programma per la latenza: il tempo tra l'invio e la ricezione di un messaggio deve essere inferiore a 10 millisecondi.
Il programma è scritto in Haskell e compilato con GHC. Tuttavia, abbiamo scoperto che le pause per la raccolta dei rifiuti sono troppo lunghe per i nostri requisiti di latenza: oltre 100 millisecondi nel nostro programma del mondo reale.
Il seguente programma è una versione semplificata della nostra applicazione. Usa a Data.Map.Strict
per archiviare i messaggi. I messaggi sono ByteString
identificati da un Int
. 1.000.000 di messaggi vengono inseriti in ordine numerico crescente e i messaggi più vecchi vengono continuamente rimossi per mantenere la cronologia ad un massimo di 200.000 messaggi.
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])
Abbiamo compilato ed eseguito questo programma usando:
$ 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
La metrica importante qui è la "pausa massima" di 0,0515 secondi o 51 millisecondi. Desideriamo ridurlo di almeno un ordine di grandezza.
La sperimentazione mostra che la durata di una pausa GC è determinata dal numero di messaggi nella cronologia. La relazione è approssimativamente lineare o forse superlineare. La tabella seguente mostra questa relazione. ( Puoi vedere i nostri test di benchmarking qui e alcuni grafici qui .)
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
Abbiamo sperimentato diverse altre variabili per scoprire se possono ridurre questa latenza, nessuna delle quali fa una grande differenza. Tra queste variabili non importanti ci sono: ottimizzazione ( -O
, -O2
); Opzioni RTS GC ( -G
, -H
, -A
, -c
), numero di core ( -N
), strutture di dati diversi ( Data.Sequence
), la dimensione dei messaggi, e la quantità di spazzatura prodotta breve durata. Il fattore determinante travolgente è il numero di messaggi nella cronologia.
La nostra teoria di funzionamento è che le pause sono lineari nel numero di messaggi perché ogni ciclo GC deve percorrere tutta la memoria accessibile funzionante e copiarla, che sono operazioni chiaramente lineari.
Domande:
- Questa teoria del tempo lineare è corretta? La durata delle pause GC può essere espressa in questo modo semplice o la realtà è più complessa?
- Se la pausa GC è lineare nella memoria di lavoro, esiste un modo per ridurre i fattori costanti coinvolti?
- Ci sono opzioni per GC incrementale o qualcosa del genere? Possiamo solo vedere documenti di ricerca. Siamo molto disposti a scambiare throughput per una latenza inferiore.
- Esistono modi per "partizionare" la memoria per cicli GC più piccoli, oltre alla suddivisione in più processi?
COntrol.Concurrent.Chan
ad esempio non lo stai usando ? Gli oggetti mutabili cambiano l'equazione)? Suggerirei di iniziare assicurandoti di sapere quale immondizia stai generando e di farne il meno possibile (ad es. Assicurati che avvenga la fusione, prova -funbox-strict
). Forse prova a utilizzare una libreria di streaming (iostreams, pipe, conduit, streaming) e a chiamare performGC
direttamente a intervalli più frequenti.
MutableByteArray
; GC non sarà coinvolto affatto in quel caso)