Как оптимизировать сборку мусора для мягкого приложения реального времени в Haskell?

Я написал мягкое приложение реального времени в Haskell, которое занимается имитируемой физикой, обнаружением столкновения, всем этим хорошим материалом. Выполняя все это, я выделяю много памяти, и я мог бы, вероятно, оптимизировать использование памяти, если захочу, но так как я хорошо сижу на 40% -ном процессоре и только в 1% используемой ОЗУ, это не кажется необходимым. То, что я вижу, - это то, что много времени, когда сборщик мусора заходит, рамки пропускаются. Я проверял, что это является причиной проблемы путем профилирования с помощью threadscope: никаких полезных вычислений не происходит иногда до 0,05 секунд, пока сборщик мусора ведет свою деятельность, в результате чего получается до 3 пропущенных кадров, что очень заметно и очень раздражает.

Теперь я попытался решить это, вручную вызвав performMinorGC каждый фрейм, и это, похоже, облегчает проблему, делая ее более гладкой, за исключением того, что общее использование ЦП резко увеличивается примерно до 70%. Ясно, что я предпочел бы избежать этого.

Еще одна вещь, которую я пробовал, заключалась в уменьшении пространства распределения GC до 64k от 512k с -H64k, и я также попытался установить -I0.03, чтобы попытаться собрать его чаще. Обе эти опции изменили структуру сборки мусора, которую я увидел в threadscope, но они все же привели к пропущенным кадрам.

Может ли кто-нибудь с опытом работы с оптимизацией GC помочь мне здесь? Я обречен на ручное вызов performMinorGC и потерю массивной потери производительности?

ИЗМЕНИТЬ

Я попытался запустить его в течение такого же количества времени в этих тестах, но так как в режиме реального времени нет точки, в которой он "сделан".

Статистика времени выполнения с performMinorGC каждые 4 кадра:

     9,776,109,768 bytes allocated in the heap
     349,349,800 bytes copied during GC
      53,547,152 bytes maximum residency (14 sample(s))
      12,123,104 bytes maximum slop
             105 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0     15536 colls, 15536 par    3.033s   0.997s     0.0001s    0.0192s
  Gen  1        14 colls,    13 par    0.207s   0.128s     0.0092s    0.0305s

  Parallel GC work balance: 6.15% (serial 0%, perfect 100%)

  TASKS: 20 (2 bound, 13 peak workers (18 total), using -N4)

  SPARKS: 74772 (20785 converted, 0 overflowed, 0 dud, 38422 GC'd, 15565 fizzled)

  INIT    time    0.000s  (  0.001s elapsed)
  MUT     time    9.773s  (  7.368s elapsed)
  GC      time    3.240s  (  1.126s elapsed)
  EXIT    time    0.003s  (  0.004s elapsed)
  Total   time   13.040s  (  8.499s elapsed)

  Alloc rate    1,000,283,400 bytes per MUT second

  Productivity  75.2% of total user, 115.3% of total elapsed

gc_alloc_block_sync: 29843
whitehole_spin: 0
gen[0].sync: 11
gen[1].sync: 71

Без performMinorGC

  12,316,488,144 bytes allocated in the heap
     447,495,936 bytes copied during GC
      63,556,272 bytes maximum residency (15 sample(s))
      15,418,296 bytes maximum slop
             146 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0     19292 colls, 19292 par    2.613s   0.950s     0.0000s    0.0161s
  Gen  1        15 colls,    14 par    0.237s   0.165s     0.0110s    0.0499s

  Parallel GC work balance: 2.67% (serial 0%, perfect 100%)

  TASKS: 17 (2 bound, 13 peak workers (15 total), using -N4)

  SPARKS: 100714 (29688 converted, 0 overflowed, 0 dud, 47577 GC'd, 23449 fizzled)

  INIT    time    0.000s  (  0.001s elapsed)
  MUT     time   13.377s  (  9.917s elapsed)
  GC      time    2.850s  (  1.115s elapsed)
  EXIT    time    0.000s  (  0.006s elapsed)
  Total   time   16.247s  ( 11.039s elapsed)

  Alloc rate    920,744,995 bytes per MUT second

  Productivity  82.5% of total user, 121.4% of total elapsed

gc_alloc_block_sync: 68533
whitehole_spin: 0
gen[0].sync: 9
gen[1].sync: 147

Общая производительность, по-видимому, ниже, чем performMinorGC, чем когда я вчера ее тестировал по-настоящему - до того, как она была всегдa > 90%.

Ответы

Ответ 1

У вас есть большое старое поколение. Это как 100Mb большой.

По умолчанию GHC выполняет основной GC, когда размер кучи достигает 2x его размера после последнего основного GC. Это означает, что в какой-то момент GC пришлось сканировать и копировать 50 Мб данных. Если ваш процессор имеет пропускную способность памяти 10 ГБ, тогда загрузка и копирование 50 Мб займет не менее 0,01 сек (сравните с общей и максимальной паузой).

(Я предполагаю, что вы проверили eventlog, чтобы гарантировать, что GC действительно работает во время паузы 0.05 сек. Поэтому это не проблема с синхронизацией потоков, когда GC ждет других потоков вместо реальной работы.)

Итак, чтобы минимизировать паузы GC, вы должны обеспечить, чтобы старое поколение было небольшим. Если большая часть этого 50Mb представляет собой статические данные, выделенные в самом начале и до конца дожидаясь (например, текстуры или сетки), вы застряли. Единственным обходным решением, которое я знаю, является упаковка данных в, например, сохраняемый вектор и распаковывать его снова, когда вам это нужно.

Если данные распределяются во время выполнения и проживают ограниченное количество времени (но достаточно, чтобы выжить несколько основных поколений), попробуйте переосмыслить свой конвейер. Обычно никакие данные не должны выдерживать один кадр, поэтому вы делаете что-то неправильно. Например. вы сохраняете данные, если не хотите.

Другой плохой знак - gen0 max pause 0.02sec. Это довольно странно. По умолчанию область выделения gen0 равна 0.5Mb, поэтому gen0 GC должен быть быстрым. Вероятно, у вас есть большой запомненный набор. Возможная причина: изменяемые структуры (IORef, изменяемый вектор и т.д.) Или много ленивых обновлений thunk.

И незначительная (возможно, не связанная) проблема: похоже, что вы используете неявный parallelism, но только 1/3 искры преобразуются. Вы выделяете слишком много sparts, 1/2 из них GC'd.