Сокращение времени паузы при сборке мусора в программе Haskell
мы разрабатываем программу, которая получает и пересылает "сообщения", сохраняя при этом временную историю этих сообщений, чтобы она могла рассказать вам историю сообщений, если потребуется. Сообщения идентифицируются численно, обычно имеют размер около 1 килобайта, и нам нужно сохранить сотни тысяч этих сообщений.
мы хотим оптимизировать эту программу для задержки: время между отправкой и получением сообщения должно быть менее 10 миллисекунд.
в программа написана на языке Haskell и скомпилирована с GHC. Однако мы обнаружили, что паузы сборки мусора слишком длинны для наших требований к задержке: более 100 миллисекунд в нашей реальной программе.
следующая программа является упрощенной версией нашего приложения. Он использует Data.Map.Strict
для хранения сообщений. Сообщения - это ByteString
s идентифицируется с помощью Int
. 1 000 000 сообщений вставляются в возрастающем числовом порядке, а самые старые сообщения постоянно удаляются, чтобы сохранить история максимум 200 000 сообщений.
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])
мы скомпилировали и запустили эту программу с помощью:
$ 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
важным показателем здесь является" максимальная пауза " 0,0515 С, или 51 миллисекунда. Мы хотим уменьшить это, по крайней мере, на порядок.
эксперименты показывают, что длина паузы GC определяется количеством сообщений в истории. Эта связь является примерно линейной или, возможно, суперлинейной. Это показано в следующей таблице отношение. (вы можете увидеть наши тесты бенчмаркинга здесь и некоторые здесь графика.)
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
мы экспериментировали с несколькими другими переменными, чтобы найти, Могут ли они уменьшить эту задержку, ни одна из которых не имеет большого значения. Среди этих неважных переменных: оптимизация (-O
,-O2
); RTS GC options (-G
,-H
,-A
,-c
), количество ядер (-N
), разные структуры данных (Data.Sequence
), размер сообщения и количество генерируемого недолговечного мусора. Подавляющее определяющим фактором является количество сообщений в истории.
наша рабочая теория заключается в том, что паузы линейны по количеству сообщений, потому что каждый цикл GC должен пройти через всю рабочую доступную память и скопировать ее, что явно линейные операции.
вопросы:
- верна ли эта теория линейного времени? Может ли длина пауз GC быть выражена в этом простой способ, или реальность сложнее?
- если пауза GC линейна в рабочей памяти, есть ли способ уменьшить постоянные факторы?
- есть ли какие-либо варианты для инкрементного GC, или что-нибудь подобное? Мы можем видеть только исследовательские работы. Мы очень охотно готовы торговать объем для более низкой латентности.
- существуют ли какие-либо способы "разбиения" памяти на меньшие циклы GC, кроме разделения на несколько процессов?
4 ответа:
вы на самом деле делаете довольно хорошо, чтобы иметь время паузы 51 МС с более чем 200 Мб живых данных. Система, над которой я работаю, имеет большее максимальное время паузы с половиной этого количества живых данных.
ваше предположение верно, основное время паузы GC прямо пропорционально количеству живых данных, и, к сожалению, нет никакого способа обойти это с GHC в его нынешнем виде. Мы экспериментировали с инкрементным GC в прошлом, но это был исследовательский проект и не достиг уровня зрелости нужно было сложить его в выпущенный GHC.
одна вещь, которая, как мы надеемся, поможет с этим в будущем, - это компактные регионы:https://phabricator.haskell.org/D1264. это своего рода ручное управление памятью, где вы сжимаете структуру в куче, и GC не должен ее пересекать. Он лучше всего работает для долгоживущих данных, но, возможно, его будет достаточно хорошо использовать для отдельных сообщений в вашей настройке. Мы стремимся иметь его в GHC 8.2.0.
Если вы находитесь в распределенной настройке и имеете какой-то балансировщик нагрузки есть трюки, которые вы можете играть, чтобы избежать попадания паузы, вы в основном убедитесь, что балансировщик нагрузки не отправляет запросы на машины, которые собираются сделать крупный GC, и, конечно, убедитесь, что машина все еще завершает GC, даже если она не получает запросы.
Я пробовал ваш фрагмент кода с помощью метода ringbuffer с помощью
IOVector
в качестве базовой структуры данных. В моей системе (GHC 7.10.3, те же параметры компиляции) это привело к сокращению максимального времени (метрика, которую вы упомянули в своем OP) на ~22%.NB. Я сделал здесь два предположения:
- изменяемая структура данных хорошо подходит для этой проблемы (я думаю, передача сообщений подразумевает IO так или иначе)
- ваш код-это непрерывный
дополнительные
Int
параметр и арифметика (например, когда messageId сбрасываются обратно в 0 илиminBound
) это должно быть просто, чтобы определить, является ли определенное сообщение все еще в истории и получить его образуют соответствующий индекс в ringbuffer.для вашего удовольствия испытания:
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])
я должен согласиться с другими - если у вас есть жесткие ограничения в реальном времени, то использование языка GC не является идеальным.
тем не менее, вы можете рассмотреть возможность экспериментировать с другими доступными структурами данных, а не только с данными.Карта.
я переписал его с помощью данных.Последовательность и получил некоторые многообещающие улучшения:
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
несмотря на то, что вы оптимизируете задержку, я заметил, что другие показатели также улучшаются. В случае 200000 время выполнения падает с 1,5 С до 0,2 С, и общее использование памяти падает с 600 МБ до 27 МБ.
я должен отметить, что я обманул путем настройки дизайна:
- я убрал
Int
СMsg
, Так что это не в двух местах.- вместо того, чтобы использовать карты от
Int
ьByteString
s, я использовалSequence
наByteString
s, а вместо одногоInt
за сообщение, Я думаю, что это можно сделать с однимInt
на всюSequence
. Предполагая, что сообщения не могут быть переупорядочены, вы можете использовать одно смещение, чтобы перевести сообщение, которое вы хотите, где он находится в очереди.(я включил дополнительную функцию
getMsg
чтобы продемонстрировать, что.){-# 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])
Ну вы нашли ограничение языков с GC: они не подходят для хардкорных систем реального времени.
У вас есть 2 варианта:
1-й увеличить размер кучи и использовать систему кэширования 2 Уровня, самые старые сообщения отправляются на диск, и вы держите самые новые сообщения в памяти, вы можете сделать это с помощью подкачки ОС. Проблема, однако, с этим решением заключается в том, что подкачка может быть дорогостоящей в зависимости от возможностей чтения используемого вторичного блока памяти.
2-й Запрограммируйте это решение с помощью "C" и подключите его с помощью FFI к haskell. Таким образом, вы можете сделать свое собственное управление памятью. Это был бы лучший вариант, как вы можете контролировать память вам нужно самостоятельно.