Есть ли в Haskell «стоимость переноса» мусорных громов? - PullRequest
3 голосов
/ 07 апреля 2019

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

Эти числа, как правило, на порядок выше, чем мой опыт JVM предполагает, что они должны быть.В частности, число байтов, «скопированных» GC, кажется, намного больше, чем объемы данных, которые я вычисляю.

Является ли такое различие между не- и строгими языками фундаментальными?

Ответы [ 2 ]

8 голосов
/ 08 апреля 2019

tl; dr: Большинство вещей, которые JVM выполняет в стековых фреймах, GHC выполняет в куче. Если вы хотите сравнить статистику кучи GHC / GC с эквивалентом JVM, вам действительно нужно учитывать некоторую часть байтов / циклов, которые JVM тратит на передачу аргументов в стек или копирование возвращаемых значений между стеками кадры.

Длинная версия:

Языки, нацеленные на JVM, обычно используют его стек вызовов. Каждый вызванный метод имеет активный фрейм стека, который включает в себя хранилище для передаваемых ему параметров, дополнительные локальные переменные и временные результаты, а также место для «стека операндов», используемого для передачи аргументов и получения результатов от других методов, которые он вызывает.

В качестве простого примера, если код на Haskell:

bar :: Int -> Int -> Int
bar a b = a * b
foo :: Int -> Int -> Int -> Int
foo x y z = let u = bar y z in x + u

были скомпилированы в JVM, байт-код мог бы выглядеть примерно так:

public static int bar(int, int);
  Code:
    stack=2, locals=2, args_size=2
       0: iload_0   // push a
       1: iload_1   // push b
       2: imul      // multiply and push result
       3: ireturn   // pop result and return it

public static int foo(int, int, int);
  Code:
    stack=2, locals=4, args_size=3
       0: iload_1   // push y
       1: iload_2   // push z
       2: invokestatic bar   // call bar, pushing result
       5: istore_3  // pop and save to "u"
       6: iload_0   // push x
       7: iload_3   // push u
       8: iadd      // add and push result
       9: ireturn   // pop result and return it

Обратите внимание, что вызовы встроенных примитивов, таких как imul, и пользовательских методов, таких как bar, включают в себя копирование / передачу значений параметров из локального хранилища в стек операндов (с использованием инструкций iload), а затем вызов примитива. или метод. Возвращаемые значения затем должны быть сохранены / переданы в локальное хранилище (с помощью istore) или возвращены вызывающей стороне с помощью ireturn; иногда возвращаемое значение может быть оставлено в стеке, чтобы служить операндом для вызова другого метода. Кроме того, хотя это не является явным в байт-коде, инструкция ireturn включает в себя копию из стека операндов вызываемого в стек операнда вызывающего. Конечно, в реальных реализациях JVM возможны различные оптимизации, чтобы уменьшить копирование.

Когда что-то еще в конце концов вызывает foo, чтобы произвести вычисление, например:

some_caller t = foo (1+3) (2+4) t + 1

(неоптимизированный) код может выглядеть следующим образом:

       iconst_1
       iconst_3
       iadd      // put 1+3 on the stack
       iconst_2
       iconst_4
       iadd      // put 2+4 on the stack
       iload_0   // put t on the stack
       invokestatic foo
       iconst 1
       iadd
       ireturn

Опять же, подвыражения оцениваются с большим количеством нажатий и выталкиваний в стеке операндов. В конце концов, foo вызывается с его аргументами, помещенными в стек, и его результат извлекается для дальнейшей обработки.

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

Теперь, что произойдет, если этот же код скомпилирован с GHC 8.6.4 (без оптимизации и для архитектуры x86_64 для конкретности)? Ну, псевдокод для сгенерированной сборки выглядит примерно так:

foo [x, y, z] =
    u = new THUNK(sat_u)                   // thunk, 32 bytes on heap
    jump: (+) x u

sat_u [] =                                 // saturated closure for "bar y z"
    push UPDATE(sat_u)                     // update frame, 16 bytes on stack
    jump: bar y z

bar [a, b] =
    jump: (*) a b

Вызовы / переходы к (+) и (*) "примитивам" на самом деле более сложны, чем я их себе представлял, из-за задействованного класса типов. Например, переход к (+) выглядит больше как:

    push CONTINUATION(\f -> f x u)         // continuation, 24 bytes on stack
    jump: (+) dNumInt                      // get the right (+) from typeclass instance

Если вы включите -O2, GHC оптимизирует этот более сложный вызов, но также оптимизирует и все остальное, что интересно в этом примере, поэтому ради аргумента давайте представим, что приведенный выше псевдокод точен.

Опять же, foo не очень полезен, пока кто-то не позвонит. Для приведенного выше примера some_caller часть кода, вызывающая foo, будет выглядеть примерно так:

some_caller [t] =
    ...
    foocall = new THUNK(sat_foocall)       // thunk, 24 bytes on heap
    ...

sat_foocall [] =                           // saturated closure for "foo (1+3) (2+4) t"
    ...
    v = new THUNK(sat_v)                   // thunk "1+3", 16 bytes on heap
    w = new THUNK(sat_w)                   // thunk "2+4", 16 bytes on heap
    push UPDATE(sat_foocall)               // update frame, 16 bytes on stack
    jump: foo sat_v sat_w t

sat_v [] = ...
sat_w [] = ...

Обратите внимание, что почти все это распределение и копирование выполняется в куче, а не в стеке.

Теперь давайте сравним эти два подхода. На первый взгляд кажется, что виновник действительно ленивая оценка. Мы создаем эти громадины повсюду, которые не были бы необходимы, если оценка была строгой, верно? Но давайте посмотрим на один из этих приемов более внимательно. Рассмотрим thunk для sat_u в определении foo. Это 32 байта / 4 слова со следующим содержимым:

// THUNK(sat_u)
word 0:  ptr to sat_u info table/code
     1:  space for return value
     // variables we closed over:
     2:  ptr to "y"
     3:  ptr to "z"

Создание этого thunk принципиально не отличается от кода JVM:

       0: iload_1   // push y
       1: iload_2   // push z
       2: invokestatic bar   // call bar, pushing result
       5: istore_3  // pop and save to "u"

Вместо того, чтобы помещать y и z в стек операндов, мы загрузили их в выделенный массив кучи. Вместо того, чтобы выталкивать результат из стека операндов в локальное хранилище фрейма стека и управлять фреймами стека и адресами возврата, мы оставляли место для результата в thunk и помещали 16-байтовый фрейм обновления в стек перед передачей управления на bar .

Аналогично, при вызове foo в some_caller вместо оценки подвыражений аргументов путем помещения констант в стек и вызова примитивов для отправки результатов в стек, мы создали thunks в куче, каждый из которых включал указатель на информационную таблицу / код для вызова примитивов с этими аргументами и место для возвращаемого значения; кадр обновления заменил ведение стека и неявное копирование результатов в версии JVM.

В конечном итоге, thunks и кадры обновления являются заменой GHC для передачи параметров и результатов в стеке, локальных переменных и временной рабочей области. Большая часть активности в кадрах стека JVM происходит в куче GHC.

Теперь большая часть содержимого стека фреймов JVM и кучи GHC быстро становится мусором. Основное отличие состоит в том, что в JVM стековые кадры автоматически отбрасываются при возврате функции после того, как среда выполнения скопировала важные вещи (например, возвращаемые значения). В GHC куча должна собираться мусором. Как уже отмечали другие, среда выполнения GHC построена на идее, что подавляющее большинство объектов кучи сразу станет мусором: быстрый исходный распределитель используется для первоначального размещения объектов кучи, а не для копирования важных вещей каждый раз, когда функция возвращается (что касается JVM), сборщик мусора копирует его, когда куча выпуклостей наполняется.

Очевидно, что приведенный выше пример с игрушкой смешной. В частности, все станет намного сложнее, когда мы начнем говорить о коде, который работает с объектами Java и Haskell ADT, а не с Ints. Однако это служит иллюстрацией того, что прямое сравнение использования кучи и циклов GC между GHC и JVM не имеет большого смысла. Конечно, точный учет на самом деле не представляется возможным, так как подходы JVM и GHC слишком сильно отличаются друг от друга, и доказательство может быть в реальной работе. По крайней мере, сравнение использования яблок между яблоками использования кучи GHC и статистики GC должно учитывать некоторую часть циклов, которые JVM тратит на передачу, выталкивание и копирование значений между стеками операндов. В частности, по крайней мере некоторая часть инструкций JVM return должна учитываться для "скопированных байтов" GHC.

Что касается вклада "лени" в использование кучи (и в частности "кучи" мусора), то его трудно выделить. Thunks действительно играют двойную роль в качестве замены для передачи операнда на основе стека и в качестве механизма для отложенной оценки. Конечно, переключение с лени на строгость может уменьшить мусор - вместо того, чтобы сначала создать thunk, а затем в конечном итоге оценить его для другого замыкания (например, конструктора), вы можете просто создать вычисляемое замыкание напрямую - но это просто означает, что вместо Ваша простая программа выделяет поразительные 172 гигабайта в куче, может быть, строгая версия «только» выделяет скромные 84 гигабайта.

Насколько я вижу, конкретный вклад отложенной оценки в "скопированные байты" должен быть минимальным - если замыкание важно во время GC, его нужно будет скопировать. Если это все еще неоцененный thunk, thunk будет скопирован. Если это было оценено, нужно будет скопировать только окончательное закрытие. Во всяком случае, поскольку thunk для сложных структур намного меньше, чем их оцененные версии, лень обычно должна уменьшить скопированных байтов. Вместо этого обычная большая победа со строгостью заключается в том, что она позволяет определенным объектам кучи (или объектам стека) быстрее становиться мусором, поэтому мы не останавливаемся в утечке места.

3 голосов
/ 08 апреля 2019

Нет, лень по своей природе не приводит к большому количеству копирования в GC. Однако неспособность программиста правильно обработать лень может это сделать. Например, если из-за ленивых изменений постоянная структура данных будет заполнена цепочками блоков данных, то в итоге она будет сильно раздутой.

Другая важная проблема, с которой вы можете столкнуться, как отметил Дэниел Вагнер, - это цена неизменности. Хотя в Haskell можно программировать с изменяемыми структурами, гораздо проще работать с неизменяемыми, когда это возможно. Неизменные конструкции конструкции имеют различные компромиссы. Например, те, которые предназначены для высокой производительности при постоянном использовании, имеют тенденцию иметь низкие коэффициенты ветвления для увеличения общего доступа, что приводит к некоторому раздутию, когда они используются эфемерно.

...