Скорочення часу паузи на збирання сміття в програмі Haskell


130

Ми розробляємо програму, яка отримує та передає "повідомлення", зберігаючи тимчасову історію цих повідомлень, щоб вона могла повідомити вам історію повідомлень за запитом. Повідомлення ідентифікуються чисельно, зазвичай мають розмір близько 1 кілобайт, і нам потрібно зберігати сотні тисяч цих повідомлень.

Ми хочемо оптимізувати цю програму за затримкою: час між відправленням та отриманням повідомлення повинен бути менше 10 мілісекунд.

Програма написана в Haskell і складена з GHC. Однак ми виявили, що паузи з вивезенням сміття є занадто довгими для наших вимог щодо затримки: понад 100 мілісекунд в нашій реальній програмі.

Наступна програма - це спрощена версія нашої програми. Він використовує aData.Map.Strict для зберігання повідомлень. Повідомлення ByteStringідентифікуються за допомогою 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,0515s або 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 ( -G, -H, -A,-c ), кількість ядер ( -N), різні структури даних ( Data.Sequence), розмір повідомлень, а кількість виділяється недовговічного сміття. Переважним визначальним фактором є кількість повідомлень в історії.

Наша теорія роботи полягає в тому, що паузи є лінійними за кількістю повідомлень, оскільки кожен цикл GC повинен переходити всю робочу доступну пам'ять і копіювати її, що є чітко лінійними операціями.

Запитання:

  • Чи правильна ця теорія лінійного часу? Чи може довжина пауз ГК виражатися таким простим способом, чи реальність є більш складною?
  • Якщо пауза ГК лінійна в робочій пам’яті, чи є можливість зменшити постійні чинники?
  • Чи є варіанти додаткового ГК чи щось подібне? Ми можемо бачити лише наукові роботи. Ми дуже готові торгувати пропускною здатністю для нижчої затримки.
  • Чи існують способи "розділити" пам'ять на менші цикли GC, окрім розділення на кілька процесів?

1
@Bakuriu: правильно, але 10 мс повинно бути досяжним майже з будь-якою сучасною ОС без будь-яких налаштувань. Коли я запускаю спрощені програми на C, навіть на моєму старому Raspberry pi, вони легко досягають затримок у діапазоні 5 мс або, принаймні, надійно щось на зразок 15 мс.
близько

3
Ви впевнені, що ваш тестовий випадок корисний (як, наприклад, ви не використовуєте COntrol.Concurrent.Chan? Змінні об'єкти змінюють рівняння)? Я б запропонував розпочати, переконуючись, що ви знаєте, яке сміття ви генеруєте, і робите якомога менше його (наприклад, переконайтеся, що відбувається синтез, спробуйте -funbox-strict). Можливо, спробуйте скористатися потоковою лінзою (іострими, труби, трубопровід, потокова передача) та дзвонити performGCбезпосередньо через більш часті проміжки часу.
jberryman

6
Якщо те, що ви намагаєтеся зробити, можна зробити в постійному просторі, тоді почніть з спроби зробити це (наприклад, можливо, буфер дзвінка з MutableByteArray; GC в цьому випадку взагалі не буде задіяний)
jberryman

1
Тим, хто пропонує мінливі структури та піклуються про створення мінімального сміття, зауважте, що саме нерозмірний розмір сміття, а не кількість зібраного сміття диктує час паузи. Прискорення частіших колекцій призводить до збільшення пауз приблизно однакової довжини. Редагувати: Змінні безграмотні структури можуть бути цікавими, але працювати з ними не так вже й багато!
Майк

6
Цей опис, безумовно, говорить про те, що час GC буде лінійним за розміром купи для всіх поколінь, важливими факторами є розмір збережених об'єктів (для копіювання) та кількість існуючих до них вказівників (для очищення): ghc.haskell. org / trac / ghc / wiki / Коментар / Rts / Зберігання / GC / Копіювання
Майк

Відповіді:


96

Ви насправді робите досить добре, щоб мати час паузи 51 мс із понад 200 Мб живих даних. Система, над якою я працюю, має більший максимальний час паузи з половиною тієї кількості живих даних.

Ваше припущення правильне, час основної паузи в GC прямо пропорційний кількості живих даних, і, на жаль, немає можливості обійти це з GHC, як він є. Ми експериментували з інкрементальним GC в минулому, але це був дослідницький проект і не досяг рівня зрілості, необхідної для складання його у звільнений GHC.

Одне, що ми сподіваємось, допоможе у цьому в майбутньому - це компактні регіони: https://phabricator.haskell.org/D1264 . Це своєрідне управління ручною пам'яттю, де ви ущільнюєте структуру в купі, і GC не повинен її обходити. Він найкраще працює для даних, що тривалий час існують, але, можливо, його буде досить добре використовувати для окремих повідомлень у ваших налаштуваннях. Ми прагнемо мати його в GHC 8.2.0.

Якщо ви перебуваєте в розподіленому налаштуванні і маєте якийсь балансир навантаження, ви можете грати, щоб уникнути удару паузи, але ви впевнені, що навантажувач не надсилає запити на машини, які збираються зробіть основний GC, і звичайно переконайтеся, що машина все-таки заповнює GC, навіть не отримуючи запитів.


13
Привіт Саймоне, дуже дякую за детальну відповідь! Це погані новини, але добре закрити. Зараз ми рухаємося до змінної реалізації, яка є єдиною підходящою альтернативою. Кілька речей, які ми не розуміємо: (1) Які хитрощі стосуються схеми збалансування навантаження - вони включають в себе посібник performGC? (2) Чому ущільнення з -cфункцією працює гірше - ми гадаємо, тому що він не знаходить багато речей, які він може залишити на місці? (3) Чи є деталі щодо компактів? Це звучить дуже цікаво, але, на жаль, нам це в майбутньому трохи далеко.
jameshfisher


@AlfredoDiNapoli Дякую!
mljrg

9

Я спробував ваш фрагмент коду з використанням підходу ringbuffer IOVector в якості основної структури даних. У моїй системі (GHC 7.10.3, ті ж варіанти компіляції) це призвело до скорочення максимального часу (показник, який ви згадали у вашій ОП) на ~ 22%.

NB. Тут я зробив два припущення:

  1. Структура даних, що змінюються, добре підходить для проблеми (я думаю, що передача повідомлення передбачає IO так чи інакше)
  2. Ідентифікатори вашого повідомлення безперервні

З деяким додатковим Intпараметром та арифметикою (наприклад, коли messageId повертаються до 0 або 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])

2
Привіт! Гарна відповідь. Я підозрюю, що причина цього прискорення становить лише 22% - це те, що GC все ще має пройти IOVectorта (незмінні, GC'd) значення в кожному індексі. Зараз ми досліджуємо варіанти повторної реалізації за допомогою змінних структур. Ймовірно, це буде схоже на вашу буферну систему. Але ми переміщуємо його повністю за межами пам’яті Haskell, щоб зробити власне ручне управління пам’яттю.
jameshfisher

11
@jamesfisher: Насправді я стикався з подібною проблемою, але вирішив зберегти управління пам’яттю на стороні Haskell. Рішенням було дійсно кільцевий буфер, який зберігає побічну копію оригінальних даних в єдиному безперервному блоці пам'яті, в результаті чого отримується єдине значення Haskell. Погляньте на це у цій суті RingBuffer.hs . Я перевірив його на зразок коду і мав прискореність близько 90% критичної метрики. Сміливо користуйтеся кодом у власні зручності.
mgmeier

8

Я маю згоду з іншими - якщо у вас є жорсткі обмеження в режимі реального часу, використання мови GC не є ідеальним.

Однак ви можете розглянути можливість експериментувати з іншими доступними структурами даних, а не лише Data.Map.

Я переписав його за допомогою Data.Sequence і отримав кілька перспективних удосконалень:

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,5s до 0,2s, а загальне використання пам'яті падає з 600MB до 27MB.

Слід зазначити, що я обдурив, підправляючи дизайн:

  • Я видалив IntзMsg , тому це не в двох місцях.
  • Замість того щоб використовувати карту від Intс до ByteStringс, я використовував Sequenceз ByteStringх, і замість одного 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])

4
Привіт! Дякую за вашу відповідь. Ваші результати безумовно все ще показують лінійне уповільнення, але досить цікаво, що ви отримали таку швидкість Data.Sequence- ми це перевірили, і виявили, що насправді вона гірша, ніж Data.Map! Я не впевнений, в чому різниця, тому мені доведеться розслідувати ...
jameshfisher

8

Як згадується в інших відповідях, збирач сміття в GHC передає живі дані, а це означає, що чим більше довгоживучих даних ви зберігаєте в пам'яті, тим довші паузи GC будуть.

GHC 8.2

Щоб частково подолати цю проблему, в GHC-8.2 була введена функція, названа компактними регіонами . Це і особливість системи виконання GHC, і бібліотека, яка пропонує зручний інтерфейс для роботи. Компактна функція регіонів дозволяє розмістити ваші дані на окремому місці в пам'яті, і GC не перемістить їх під час фази збору сміття. Отже, якщо у вас є велика структура, яку ви хочете зберегти в пам'яті, подумайте про використання компактних регіонів. Однак у компактному регіоні немає міні-збирача сміття всередині, він працює краще для додавання лише структур даних, а не на зразок того, HashMapде ви також хочете видалити речі. Хоча ви можете подолати цю проблему. Детальніше дивіться у наступному дописі в блозі:

GHC 8.10

Більше того, оскільки GHC-8.10 реалізований новий алгоритм поглибленого збору сміття з низькою затримкою . Це альтернативний алгоритм GC, який не включений за замовчуванням, але ви можете ввімкнути його, якщо хочете. Таким чином, ви можете переключити GC за замовчуванням на новіший, щоб автоматично отримувати функції, надані компактними регіонами, не потрібно робити ручне обгортання та розгортання. Однак новий GC не є срібною кулею і не вирішує всі проблеми автоматично, і він має свої вигоди. Для орієнтирів нового GC зверніться до такого сховища GitHub:


3

Що ж, ви знайшли обмеження мов із GC: вони не підходять для жорстких систем у режимі реального часу.

У вас є 2 варіанти:

1-й. Збільшити розмір купи та використовувати кешування на 2 рівні, найдавніші повідомлення надсилаються на диск, а новіші повідомлення зберігаються в пам'яті. Це можна зробити за допомогою підкачки в ОС. Проблема, однак, з цим рішенням полягає в тому, що пейджингові повідомлення можуть бути дорогими залежно від можливостей читання використовуваного другого блоку пам'яті.

2-го програмуйте це рішення, використовуючи "C" і інтерфейсуйте його з FFI до haskell. Таким чином ви можете зробити власне управління пам'яттю. Це було б найкращим варіантом, оскільки ви можете самостійно контролювати потрібну пам'ять.


1
Привіт, Фернандо. Дякую за це Наша система є лише "м'якою" в режимі реального часу, але в нашому випадку ми виявили, що GC занадто карає навіть для м'якого реального часу. Ми напевно схиляємось до вашого рішення №2.
jameshfisher
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.