Nous développons un programme qui reçoit et transmet des "messages", tout en conservant un historique temporaire de ces messages, de sorte qu'il puisse vous indiquer l'historique des messages si vous le demandez. Les messages sont identifiés numériquement, leur taille est typiquement d'environ 1 kilooctet, et nous devons conserver des centaines de milliers de ces messages.
Nous souhaitons optimiser ce programme pour la latence : le temps entre l'envoi et la réception d'un message doit être inférieur à 10 millisecondes.
Le programme est écrit en Haskell et compilé avec GHC. Cependant, nous avons constaté que les pauses du ramassage des ordures sont beaucoup trop longues pour nos exigences en matière de latence : plus de 100 millisecondes dans notre programme réel.
Le programme suivant est une version simplifiée de notre application. Il utilise un Data.Map.Strict
pour stocker des messages. Les messages sont ByteString
identifiés par un Int
. 1 000 000 de messages sont insérés par ordre numérique croissant, et les messages les plus anciens sont continuellement supprimés pour maintenir l'historique à un maximum de 200 000 messages.
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])
Nous avons compilé et exécuté ce programme en utilisant :
$ 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 mesure importante ici est la "pause maximale" de 0,0515s, soit 51 millisecondes. Nous souhaitons réduire cette valeur d'au moins un ordre de grandeur.
L'expérimentation montre que la durée d'une pause GC est déterminée par le nombre de messages dans l'historique. La relation est à peu près linéaire, ou peut-être super-linéaire. Le tableau suivant montre cette relation. ( Vous pouvez consulter nos tests d'évaluation comparative ici et quelques graphiques ici .)
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
Nous avons expérimenté plusieurs autres variables pour savoir si elles pouvaient réduire cette latence, mais aucune ne fait une grande différence. Parmi ces variables sans importance, on trouve : l'optimisation ( -O
, -O2
) ; options RTS GC ( -G
, -H
, -A
, -c
), le nombre de cœurs ( -N
), des structures de données différentes ( Data.Sequence
), la taille des messages et la quantité de déchets éphémères générés. Le facteur déterminant le plus important est le nombre de messages dans l'historique.
Notre théorie de travail est que les pauses sont linéaires dans le nombre de messages parce que chaque cycle GC doit parcourir toute la mémoire accessible de travail et la copier, qui sont clairement des opérations linéaires.
Questions :
- Cette théorie du temps linéaire est-elle correcte ? La longueur des pauses GC peut-elle être exprimée de cette manière simple, ou la réalité est-elle plus complexe ?
- Si la pause GC est linéaire dans la mémoire de travail, y a-t-il un moyen de réduire les facteurs constants impliqués ?
- Existe-t-il des options pour la GC incrémentielle, ou quelque chose de similaire ? Nous ne voyons que des documents de recherche. Nous sommes tout à fait disposés à échanger le débit contre une latence plus faible.
- Existe-t-il des moyens de "partitionner" la mémoire pour des cycles GC plus petits, autres que la division en plusieurs processus ?
0 votes
Je ne peux malheureusement pas vous aider. Je suppose que vous avez déjà lire cette question connexe . Quoi qu'il en soit, ayant un debe exiger que la latence soit au maximum de 10 millisecondes ressemble à une contrainte de temps réel... pour y parvenir, vous devez probablement régler le planificateur du système d'exploitation pour les tâches en temps réel également, car il n'y a aucun intérêt à optimiser une grande partie du code Haskell si le système d'exploitation décide que vous pouvez attendre 100 millisecondes...
1 votes
@Bakuriu : c'est vrai, mais 10 ms devrait être réalisable avec presque tous les OS modernes sans aucune modification. Lorsque j'exécute des programmes C simplistes, même sur mon vieux Raspberry pi, ils atteignent facilement des latences de l'ordre de 5 ms, ou au moins de 10 ms. de manière fiable quelque chose comme 15 ms.
4 votes
Etes-vous sûr que votre test-case est utile (comme si vous n'utilisiez pas de
COntrol.Concurrent.Chan
par exemple ? Les objets mutables changent l'équation) ? Je suggérerais de commencer par vous assurer que vous savez quels déchets vous générez et d'en faire le moins possible (par exemple, assurez-vous que la fusion se produit, essayez-funbox-strict
). Essayez peut-être d'utiliser une librairie de streaming (iostreams, pipes, conduit, streaming), et d'appelerperformGC
directement à des intervalles plus fréquents.9 votes
Si ce que vous essayez d'accomplir peut être fait en espace constant, alors commencez par essayer de faire en sorte que cela se produise (par exemple, peut-être un tampon en anneau d'une
MutableByteArray
; le GC ne sera pas du tout impliqué dans ce cas).1 votes
Le GC a passé une quantité comparable de temps total sur le gen0 et le gen1 (0,280s vs. 0,250s) mais a fait 6558 collections dans le gen0 et seulement 14 dans le gen1. Vos données à longue durée de vie (c'est-à-dire l'historique) correspondent au gen1 - le GC décide de n'y jeter un œil qu'occasionnellement, donc quand il le fait, il doit faire beaucoup de travail en même temps. Le fait de ne pas vérifier souvent le gen1 est voulu. Par opposition à la GC du gen0, qui a en fait fait plus travail total sur la durée de vie de votre programme, mais le temps par cycle était beaucoup plus faible. Utilisation de
performGC
peut vraiment aider ici - ainsi que le stockage des données dans une mémoire nonGC'ed (bonnes suggestions de @jberryman).2 votes
Pour ceux qui suggèrent des structures mutables et qui prennent soin de créer un minimum de déchets, notez que c'est le retenu C'est la taille des déchets, et non la quantité de déchets collectés, qui semble dicter le temps de pause. Le fait de forcer des collectes plus fréquentes entraîne un plus grand nombre de pauses de même durée. Edit : Les structures mutables hors du tas peuvent être intéressantes, mais ne sont pas aussi amusantes à utiliser dans la plupart des cas !
6 votes
Cette description suggère certainement que le temps de GC sera linéaire dans la taille du tas pour toutes les générations, les facteurs importants étant la taille des objets retenus (pour la copie) et le nombre de pointeurs existant vers eux (pour le scavenging) : ghc.haskell.org/trac/ghc/wiki/Commentary/Rts/Storage/GC/Copying
0 votes
La FFI ghc ne pourrait-elle pas être utilisée pour gérer manuellement l'historique des messages à l'aide de la fonction
malloc*
dans Foreign.Marshal.Alloc sur le tas "de bas niveau" (le même que celui utilisé par le système d'exécution) ? D'après wiki.haskell.org/Foreign_Function_Interface Les allocations sur le tas de bas niveau ne sont pas gérées par l'implémentation Haskell et doivent être libérées explicitement avec la commandefree
. Ainsi, la plupart de votre implémentation de ghc utiliserait le gc standard mais la partie de l'historique des messages problématiques du gc utiliserait malloc et free.0 votes
@GeorgeColpitts oui, c'est exactement ce que nous avons expérimenté après avoir abandonné l'approche décrite ci-dessus. L'approche FFI est viable, mais nous ne l'avons pas adoptée, pour toute une série d'autres raisons.
0 votes
Haskell 8.2.1 a été publié ( telechargements.haskell.org/~ghc/master/guide-utilisateurs/8.2.1-notes.html ), jetez-y un coup d'œil, vous pourrez sans doute revenir à Haskell après votre voyage d'un an en Go.
0 votes
@securecurve nous allons certainement le vérifier ! Mais la version 8.2.1 est-elle vraiment sortie ? Page de la version de GHC ne le mentionne pas et la page d'état 8.2.1 en parle au futur...
0 votes
@jameshfisher, maintenant c'est le cas :)
1 votes
@securecurve merci ! Nous (Pusher) allons expérimenter avec les régions compactes et ajouter une référence pour cela à la page de l'annuaire. gitlab.com/gasche/gc-latency-experiment
0 votes
@jameshfisher, Salut ... Avez-vous envisagé Rust pour un tel cas d'utilisation, je pense qu'il fera assez bien ... Je ne l'ai pas vu dans les benchmarks publiés que vous avez fait ... Merci !