Estamos desenvolvendo um programa que recebe e encaminha "mensagens", mantendo um histórico temporário dessas mensagens, para que ele possa lhe informar o histórico, se solicitado. As mensagens são identificadas numericamente, geralmente têm cerca de 1 kilobyte e precisamos manter centenas de milhares dessas mensagens.
Desejamos otimizar este programa para latência: o tempo entre o envio e o recebimento de uma mensagem deve ser inferior a 10 milissegundos.
O programa foi escrito em Haskell e compilado com o GHC. No entanto, descobrimos que as pausas na coleta de lixo são muito longas para nossos requisitos de latência: mais de 100 milissegundos em nosso programa no mundo real.
O programa a seguir é uma versão simplificada do nosso aplicativo. Ele usa a Data.Map.Strict
para armazenar mensagens. As mensagens são ByteString
identificadas por um Int
. 1.000.000 de mensagens são inseridas em ordem numérica crescente e as mensagens mais antigas são continuamente removidas para manter o histórico em um máximo de 200.000 mensagens.
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 e executamos 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
A métrica importante aqui é a "pausa máxima" de 0,0515s ou 51 milissegundos. Desejamos reduzir isso em pelo menos uma ordem de magnitude.
A experiência mostra que a duração de uma pausa no GC é determinada pelo número de mensagens no histórico. O relacionamento é aproximadamente linear, ou talvez super-linear. A tabela a seguir mostra esse relacionamento. ( Você pode ver nossos testes de benchmarking aqui e alguns gráficos aqui .)
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
Experimentamos várias outras variáveis para descobrir se elas podem reduzir essa latência, nenhuma das quais faz uma grande diferença. Entre essas variáveis sem importância estão: otimização ( -O
, -O2
); Opções RTS GC ( -G
, -H
, -A
, -c
), número de núcleos ( -N
), estruturas de dados diferentes ( Data.Sequence
), o tamanho das mensagens, ea quantidade de lixo gerado de curta duração. O fator determinante esmagador é o número de mensagens na história.
Nossa teoria de trabalho é que as pausas são lineares no número de mensagens, porque cada ciclo do GC precisa percorrer toda a memória acessível de trabalho e copiá-la, que são operações claramente lineares.
Questões:
- Essa teoria do tempo linear está correta? A duração das pausas no GC pode ser expressa dessa maneira simples ou a realidade é mais complexa?
- Se a pausa do GC for linear na memória de trabalho, existe alguma maneira de reduzir os fatores constantes envolvidos?
- Existem opções para GC incremental ou algo parecido? Só podemos ver trabalhos de pesquisa. Estamos muito dispostos a trocar a taxa de transferência por menor latência.
- Existem maneiras de "particionar" a memória para ciclos menores de GC, além da divisão em vários processos?
COntrol.Concurrent.Chan
por exemplo? Objetos mutáveis alteram a equação)? Eu sugiro começar certificando-se de saber que lixo você está gerando e fazendo o mínimo possível (por exemplo, verifique se a fusão acontece, tente -funbox-strict
). Talvez tente usar uma biblioteca de streaming (iostreams, pipes, conduit, streaming) e ligue performGC
diretamente em intervalos mais frequentes.
MutableByteArray
; GC não será envolvido em tudo, nesse caso)