Сокращение времени паузы при сборке мусора в программе 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 117

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. Я сделал здесь два предположения:

  1. изменяемая структура данных хорошо подходит для этой проблемы (я думаю, передача сообщений подразумевает IO так или иначе)
  2. ваш код-это непрерывный

дополнительные 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ь ByteStrings, я использовал 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. Таким образом, вы можете сделать свое собственное управление памятью. Это был бы лучший вариант, как вы можете контролировать память вам нужно самостоятельно.