Есть ли в Haskell неотъемлемая «стоимость переноса» мусорных преобразователей?

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

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

Принципиальна ли такая разница между не- и строгими языками?

Я думаю, что этот вопрос имеет меньше общего с Haskell и GHC, чем с дизайном языка в целом. Я думаю, что теги должны отражать это, если я могу предложить?

AJF 07.04.2019 22:52

Не могли бы вы уточнить свой вопрос с некоторыми тестами, которые вы выполнили? Что значит «значительно больше», что вы получили и чего ожидали?

radrow 08.04.2019 01:07

Существует так много различий между ghc и javac — да, ленивый и нетерпеливый, но также неизменяемый и изменяемый, функциональный и императивный, копирующий сборщик мусора и сборщик мусора с маркировкой, и я уверен, что смогу найти ответ. и еще полдюжины, если вы дадите мне время хорошенько подумать. Что заставляет вас думать, что очевидно, что «ленивый против нетерпеливого» — это то, на что нужно указать пальцем?

Daniel Wagner 08.04.2019 01:14

В устных беседах Марк Джонс (iirc) сказал, что показал разработчикам компиляторов Java GHC в действии. Выделенные и скопированные байты были выше крыши с точки зрения разработчиков Java. Разница в том, что изменчивый и неизменяемый, но GHC RTS и GC с его распределителем рельефа созданы с учетом этого аспекта. В результате GC не так важен с точки зрения вычислений для большинства программ. Когда производительность (время, затраченное на GC по сравнению с мутатором) низкая, это стоит изучить, поскольку часто это «легкая» победа при оптимизации программы для производства (производство меньшего количества мусора, увеличение размера питомника и т. д.).

Thomas M. DuBuisson 08.04.2019 02:09

@ThomasM.DuBuisson, выделенные байты часто важны (особенно для параллельных программ, но я подозреваю, что также для использования кеша L1). Скопированные байты почти всегда важны.

dfeuer 08.04.2019 19:09
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
3
5
273
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

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

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

Ответ принят как подходящий

тл;др: Большая часть того, что JVM делает в кадрах стека, GHC делает в куче. Если вы хотите сравнить статистику кучи/GC GHC с эквивалентом 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 [] = ...

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

Теперь давайте сравним эти два подхода. На первый взгляд кажется, что виновата ленивая оценка. Мы повсюду создаем эти преобразователи, в которых не было бы необходимости, если бы оценка была строгой, верно? Но давайте посмотрим на один из этих преобразователей более внимательно. Рассмотрим преобразователь для 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 в стек операндов, мы загрузили их в блок памяти, размещенный в куче. Вместо того, чтобы выталкивать результат из стека операндов в локальное хранилище нашего фрейма стека и управлять фреймами стека и адресами возврата, мы оставили место для результата в санке и перед передачей управления bar поместили 16-байтовый фрейм обновления в стек.

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

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

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

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

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

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

Замечательное объяснение низкоуровневых операционных деталей, спасибо! Я бы хотел, чтобы была какая-нибудь награда за "ретробаунти"! Вы убедили меня в том, что количество оперативных данных "стека", вероятно, эквивалентно - почти наверняка после оптимизации и тем более после того, как новый сборщик мусора (надеюсь) не будет копировать эти преобразователи! Я все еще беспокоюсь о выражениях, которые (я думаю) оцениваются как WHNF из-за лени, их нужно носить с собой как «возможно» живые и намного позже отбрасывать, когда алгоритм решает их отбросить.

sevo 08.04.2019 22:34

@sevo, вы сможете предложить вознаграждение «в награду за существующий ответ» через два дня после вашего исходного сообщения.

dfeuer 08.04.2019 23:45

Другие вопросы по теме