Estamos desarrollando un programa que recibe y reenvía "mensajes", mientras mantiene un historial temporal de esos mensajes, para que pueda informarle el historial de mensajes si así lo solicita. Los mensajes se identifican numéricamente, por lo general tienen un tamaño de alrededor de 1 kilobyte y necesitamos mantener cientos de miles de estos mensajes.
Deseamos optimizar este programa para la latencia: el tiempo entre enviar y recibir un mensaje debe ser inferior a 10 milisegundos.
El programa está escrito en Haskell y compilado con GHC. Sin embargo, hemos descubierto que las pausas de recolección de basura son demasiado largas para nuestros requisitos de latencia: más de 100 milisegundos en nuestro programa del mundo real.
El siguiente programa es una versión simplificada de nuestra aplicación. Utiliza a Data.Map.Strict
para almacenar mensajes. Los mensajes se ByteString
identifican mediante un Int
. Se insertan 1,000,000 de mensajes en orden numérico creciente, y los mensajes más antiguos se eliminan continuamente para mantener el historial en un máximo de 200,000 mensajes.
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])
Compilamos y ejecutamos este programa 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 métrica importante aquí es la "pausa máxima" de 0.0515s, o 51 milisegundos. Deseamos reducir esto en al menos un orden de magnitud.
La experimentación muestra que la duración de una pausa de GC está determinada por el número de mensajes en el historial. La relación es más o menos lineal, o quizás súper lineal. La siguiente tabla muestra esta relación. ( Puede ver nuestras pruebas de evaluación comparativa aquí , y algunas tablas aquí ).
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
Hemos experimentado con varias otras variables para encontrar si pueden reducir esta latencia, ninguna de las cuales hace una gran diferencia. Entre estas variables sin importancia están: optimización ( -O
, -O2
); Opciones RTS GC ( -G
, -H
, -A
, -c
), número de núcleos ( -N
), diferentes estructuras de datos ( Data.Sequence
), el tamaño de los mensajes, y la cantidad de basura generada de corta duración. El factor determinante abrumador es la cantidad de mensajes en el historial.
Nuestra teoría de trabajo es que las pausas son lineales en la cantidad de mensajes porque cada ciclo de GC tiene que recorrer toda la memoria accesible de trabajo y copiarla, que son operaciones claramente lineales.
Preguntas:
- ¿Es correcta esta teoría del tiempo lineal? ¿Se puede expresar la duración de las pausas GC de esta manera simple, o la realidad es más compleja?
- Si la pausa de GC es lineal en la memoria de trabajo, ¿hay alguna forma de reducir los factores constantes involucrados?
- ¿Hay alguna opción para GC incremental, o algo así? Solo podemos ver trabajos de investigación. Estamos muy dispuestos a cambiar el rendimiento por una menor latencia.
- ¿Hay alguna forma de "particionar" la memoria para ciclos GC más pequeños, aparte de dividirla en múltiples procesos?
COntrol.Concurrent.Chan
por ejemplo, los objetos mutables cambian la ecuación)? Sugeriría comenzar asegurándose de saber qué basura está generando y hacer la menor cantidad posible (por ejemplo, asegúrese de que ocurra la fusión, intente -funbox-strict
). Tal vez intente usar una biblioteca de transmisión (iostreams, tuberías, conductos, transmisión) y llame performGC
directamente a intervalos más frecuentes.
MutableByteArray
; GC no estará involucrado en absoluto en ese caso)