Riduzione del tempo di pausa della garbage collection in un programma Haskell


130

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.Strictper archiviare i messaggi. I messaggi sono ByteStringidentificati 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?

1
@Bakuriu: giusto, ma 10 ms dovrebbero essere raggiungibili praticamente con qualsiasi sistema operativo moderno senza alcuna modifica. Quando eseguo programmi C semplicistici, anche sul mio vecchio Raspberry Pi, riescono facilmente a raggiungere latenze nell'intervallo di 5 ms, o almeno in modo affidabile come 15 ms.
circa il

3
Sei sicuro che il tuo test-case sia utile (come COntrol.Concurrent.Chanad 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 performGCdirettamente a intervalli più frequenti.
jberryman,

6
Se ciò che stai cercando di realizzare può essere fatto in uno spazio costante, inizia cercando di farlo (ad es. Forse un ring buffer di un MutableByteArray; GC non sarà coinvolto affatto in quel caso)
jberryman

1
A coloro che suggeriscono strutture mutabili e si prendono cura di creare immondizia minima, si noti che è la dimensione mantenuta , non la quantità di immondizia raccolta che sembra dettare il tempo di pausa. Forzare raccolte più frequenti provoca più pause della stessa lunghezza. Modifica: strutture off-heap mutevoli possono essere interessanti, ma non così divertenti con cui lavorare in molti casi!
mike,

6
Questa descrizione suggerisce certamente che il tempo GC sarà lineare nella dimensione dell'heap per tutte le generazioni, fattori importanti sono la dimensione degli oggetti conservati (per la copia) e il numero di puntatori esistenti (per lo scavenging): ghc.haskell. org / trac / ghc / wiki / Commentary / Rts / Storage / GC / Copia
mike

Risposte:


96

Stai effettivamente andando abbastanza bene per avere un tempo di pausa di 51 ms con oltre 200 Mb di dati in tempo reale. Il sistema su cui lavoro ha un tempo di pausa massimo maggiore con metà della quantità di dati in tempo reale.

La tua ipotesi è corretta, il tempo di pausa del GC principale è direttamente proporzionale alla quantità di dati in tempo reale e, sfortunatamente, GHC non ha alcuna soluzione. In passato abbiamo sperimentato GC incrementali, ma era un progetto di ricerca e non ha raggiunto il livello di maturità necessario per inserirlo nel GHC rilasciato.

Una cosa che speriamo possa essere d'aiuto in futuro sono le regioni compatte: https://phabricator.haskell.org/D1264 . È una sorta di gestione manuale della memoria in cui compatti una struttura nell'heap e il GC non deve attraversarla. Funziona meglio per i dati di lunga durata, ma forse sarà abbastanza buono da usare per i singoli messaggi nelle impostazioni. Puntiamo ad averlo in GHC 8.2.0.

Se ti trovi in ​​un'impostazione distribuita e hai un bilanciamento del carico di qualche tipo, ci sono dei trucchi che puoi giocare per evitare di fare il colpo di pausa, in pratica assicurati che il bilanciamento del carico non invii richieste alle macchine che stanno per eseguire un GC principale e, naturalmente, assicurarsi che la macchina completi ancora il GC anche se non riceve richieste.


13
Ciao Simone, grazie mille per la tua risposta dettagliata! È una brutta notizia, ma è bene avere una chiusura. Attualmente ci stiamo muovendo verso un'implementazione mutevole, essendo l'unica alternativa adatta. Alcune cose che non capiamo: (1) Quali sono i trucchi coinvolti nello schema di bilanciamento del carico - comportano un manuale performGC? (2) Perché la compattazione con -cprestazioni peggiori - supponiamo che non trovi molte cose che può lasciare sul posto? (3) Ci sono altri dettagli sui patti? Sembra molto interessante, ma sfortunatamente è un po 'troppo lontano in futuro per noi da considerare.
jameshfisher,


@AlfredoDiNapoli Grazie!
mljrg,

9

Ho provato il tuo frammento di codice con un approccio ringbuffer usando IOVectorcome struttura di dati sottostante. Sul mio sistema (GHC 7.10.3, stesse opzioni di compilazione) ciò ha comportato una riduzione del tempo massimo (la metrica menzionata nel tuo PO) di circa il 22%.

NB. Ho fatto due ipotesi qui:

  1. Una struttura di dati mutabili è adatta per il problema (suppongo che il passaggio di messaggi implichi comunque IO)
  2. I tuoi messaggi sono continui

Con alcuni Intparametri e aritmetica aggiuntivi (come quando i MessageId vengono ripristinati a 0 o minBound) dovrebbe essere semplice determinare se un determinato messaggio è ancora nella cronologia e recuperarlo dall'indice corrispondente nel ringbuffer.

Per il piacere del test:

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
Ciao! Bella risposta. Ho il sospetto che la ragione per cui questo accelera solo del 22% è perché GC deve ancora percorrere i IOVectorvalori (immutabili, GC'd) su ciascun indice. Stiamo attualmente esaminando le opzioni per la reimplementazione utilizzando strutture mutabili. È probabile che sia simile al tuo sistema di buffer ad anello. Ma lo stiamo spostando completamente fuori dallo spazio di memoria di Haskell per eseguire la nostra gestione manuale della memoria.
jameshfisher,

11
@jamesfisher: in realtà stavo affrontando un problema simile, ma ho deciso di mantenere la gestione dei mem sul lato Haskell. La soluzione era in effetti un buffer ad anello, che conserva una copia a byte dei dati originali in un singolo blocco continuo di memoria, risultando in un singolo valore di Haskell. Dai un'occhiata a questa sintesi di RingBuffer.hs . L'ho provato con il tuo codice di esempio e ho avuto una velocità di circa il 90% della metrica critica. Sentiti libero di usare il codice a tuo piacimento.
mgmeier,

8

Devo essere d'accordo con gli altri: se hai dei vincoli in tempo reale difficili, usare un linguaggio GC non è l'ideale.

Tuttavia, potresti considerare di sperimentare altre strutture di dati disponibili anziché solo Data.Map.

L'ho riscritto usando Data.Sequence e ho ottenuto alcuni promettenti miglioramenti:

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

Anche se stai ottimizzando la latenza, ho notato che anche altre metriche stanno migliorando. Nel caso 200000, il tempo di esecuzione scende da 1,5 a 0,2 secondi e l'utilizzo della memoria totale scende da 600 MB a 27 MB.

Dovrei notare che ho tradito modificando il design:

  • Ho rimosso il Intda Msg, quindi non è in due posti.
  • Invece di usare una mappa da Ints a ByteStrings, ho usato a Sequenceof ByteStrings, e invece di uno Intper messaggio, penso che si possa fare con uno Intper il tutto Sequence. Supponendo che i messaggi non possano essere riordinati, è possibile utilizzare un unico offset per tradurre il messaggio in cui si trova nella coda.

(Ho incluso una funzione aggiuntiva getMsgper dimostrarlo.)

{-# 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
Ciao! Grazie per la tua risposta. I tuoi risultati mostrano sicuramente ancora il rallentamento lineare, ma è piuttosto interessante che tu abbia ottenuto un tale aumento di velocità Data.Sequence: l'abbiamo testato e lo abbiamo trovato effettivamente peggiore di Data.Map! Non sono sicuro di quale sia stata la differenza, quindi dovrò indagare ...
jameshfisher il

8

Come menzionato in altre risposte, il garbage collector in GHC attraversa i dati in tempo reale, il che significa che più dati di lunga durata vengono archiviati in memoria, più lunghe saranno le pause GC.

GHC 8.2

Per risolvere parzialmente questo problema, in GHC-8.2 è stata introdotta una funzionalità chiamata regioni compatte . È sia una funzionalità del sistema di runtime GHC che una libreria che espone una comoda interfaccia con cui lavorare. La funzione delle regioni compatte consente di inserire i dati in un posto separato nella memoria e GC non li attraverserà durante la fase di raccolta dei rifiuti. Quindi, se si dispone di una struttura di grandi dimensioni che si desidera conservare in memoria, considerare l'utilizzo di aree compatte. Tuttavia, la stessa regione compatta non ha al suo interno un mini garbage collector , funziona meglio per le strutture di dati di sola aggiunta, non qualcosa di simile a HashMapdove si desidera anche eliminare elementi. Sebbene tu possa superare questo problema. Per i dettagli, consultare il seguente post di blog:

GHC 8.10

Inoltre, da GHC-8.10 è stato implementato un nuovo algoritmo incrementale a bassa latenza per garbage collector. È un algoritmo GC alternativo che non è abilitato per impostazione predefinita, ma puoi attivarlo se lo desideri. Quindi puoi cambiare il GC predefinito con uno più recente per ottenere automaticamente le funzionalità fornite da regioni compatte senza dover eseguire il wrapping e il wrapping manuale. Tuttavia, il nuovo GC non è un proiettile d'argento e non risolve tutti i problemi automagicamente e ha i suoi compromessi. Per i benchmark del nuovo GC fare riferimento al seguente repository GitHub:


3

Bene, hai trovato la limitazione delle lingue con GC: non sono adatte ai sistemi hardcore in tempo reale.

Hai 2 opzioni:

1 ° Aumenta le dimensioni dell'heap e utilizza un sistema di memorizzazione nella cache a 2 livelli, i messaggi più vecchi vengono inviati al disco e mantieni i messaggi più recenti in memoria, puoi farlo utilizzando il paging del sistema operativo. Il problema, sebbene con questa soluzione è che il paging può essere costoso a seconda delle capacità di lettura dell'unità di memoria secondaria utilizzata.

2 ° Programma quella soluzione usando 'C' e interfacciala con FFI su haskell. In questo modo puoi gestire la tua memoria. Questa sarebbe l'opzione migliore in quanto puoi controllare la memoria di cui hai bisogno da solo.


1
Ciao Fernando Grazie per questo. Il nostro sistema è solo "soft" in tempo reale, ma nel nostro caso abbiamo riscontrato che GC è troppo severo anche per il soft in tempo reale. Ci stiamo decisamente avvicinando alla tua soluzione n. 2.
jameshfisher,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.