У GHC есть много оптимизаций, которые он может выполнить, но я не знаю, чем они все являются, и какова вероятность их выполнения и при каких обстоятельствах.
Мой вопрос: какие преобразования я могу ожидать, чтобы они применялись каждый раз или почти так? Если я смотрю на фрагмент кода, который будет выполняться (оцениваться) часто, и моя первая мысль - «хм, может быть, мне следует оптимизировать это», в каких случаях мне следует подумать «даже не думать об этом, GHC получил это "?
Я читал статью « Поток слияния: от списков к потокам и вообще ни к чему», и метод, который они использовали для переписывания обработки списков в другую форму, которую обычная оптимизация GHC затем надежно оптимизировала бы в простые циклы, был для меня новым. Как я могу определить, соответствуют ли мои собственные программы такой оптимизации?
В руководстве GHC есть некоторая информация , но это только часть пути к ответу на вопрос.
РЕДАКТИРОВАТЬ: я начинаю щедрость. Я хотел бы получить список низкоуровневых преобразований, таких как лямбда / let / case-floating, специализация аргументов типа / конструктора / функции, анализ строгости и распаковка, рабочий / упаковщик и все, что еще важно для GHC, которые я пропустил наряду с объяснениями и примерами ввода и вывода кода, а также идеальными иллюстрациями ситуаций, когда суммарный эффект больше, чем сумма его частей. И в идеале некоторые упоминания о том, когда преобразования не будутбывает. Я не ожидаю подробного объяснения каждой трансформации, пары предложений и примеров встроенного однострочного кода может быть достаточно (или ссылка, если не до двадцати страниц научной статьи), пока общая картина ясно к концу этого. Я хочу иметь возможность взглянуть на кусок кода и сделать правильное предположение о том, скомпилируется ли он в сжатый цикл, или почему нет, или что мне придется изменить, чтобы сделать его. (Меня не очень интересуют большие фреймворки для оптимизации, такие как потоковое слияние (я только что прочитал статью об этом); больше - знания, которыми обладают люди, пишущие эти фреймворки.)
источник
Ответы:
Эта страница GHC Trac также объясняет проходы довольно хорошо. Эта страница объясняет порядок оптимизации, хотя, как и большинство Trac Wiki, он устарел.
Что касается специфики, лучшее, что можно сделать, это, вероятно, посмотреть, как конкретная программа компилируется. Лучший способ увидеть, какие оптимизации выполняются - это многословно скомпилировать программу, используя
-v
флаг. Взяв в качестве примера первый фрагмент Haskell, который я смог найти на своем компьютере:Если посмотреть с первого
*** Simplifier:
на последнее, где происходят все этапы оптимизации, мы видим довольно много.Прежде всего, Simplifier работает практически на всех этапах. Это делает написание многих проходов намного проще. Например, при реализации многих оптимизаций они просто создают правила перезаписи для распространения изменений, вместо того чтобы делать это вручную. Упрощение включает в себя ряд простых оптимизаций, включая встраивание и слияние. Основное ограничение, которое я знаю, состоит в том, что GHC отказывается включать рекурсивные функции, и что вещи должны быть названы правильно, чтобы слияние работало.
Далее мы видим полный список всех выполненных оптимизаций:
специализируются
Основная идея специализации состоит в том, чтобы устранить полиморфизм и перегрузки путем определения мест, где вызывается функция, и создания версий функции, которые не являются полиморфными - они специфичны для типов, с которыми они вызываются. Вы также можете указать компилятору сделать это с помощью
SPECIALISE
прагмы. В качестве примера возьмем факториальную функцию:Поскольку компилятор не знает никаких свойств умножения, которое должно использоваться, он не может оптимизировать это вообще. Однако, если он видит, что он используется в
Int
, он теперь может создать новую версию, отличающуюся только типом:Далее, правила, упомянутые ниже, могут сработать, и вы получите что-то, работающее с unboxed
Int
s, что намного быстрее, чем оригинал. Другой способ взглянуть на специализацию - это частичное применение словарей классов типов и переменных типов.Источник здесь имеет нагрузку нот в нем.
Всплыть
РЕДАКТИРОВАТЬ: Я, очевидно, неправильно понял это раньше. Мое объяснение полностью изменилось.
Основная идея этого состоит в том, чтобы переместить вычисления, которые не должны повторяться, из функций. Например, предположим, что у нас было это:
В приведенной выше лямбде, каждый раз, когда вызывается функция,
y
пересчитывается. Лучшая функция, которую производит плавающая функция, этоЧтобы облегчить процесс, могут быть применены другие преобразования. Например, это происходит:
Опять же, повторное вычисление сохраняется.
Источник очень читаемый в этом случае.
На данный момент привязки между двумя соседними лямбдами не плавают. Например, этого не происходит:
собирается
Плавать внутрь
Цитирование исходного кода,
Основная цель
floatInwards
заключается в переходе к ветвям кейса, так что мы не распределяем вещи, не сохраняем их в стеке, а затем обнаруживаем, что они не нужны в выбранной ветке.В качестве примера предположим, что у нас было это выражение:
Если
v
оцениватьFalse
, то, выделяяx
, что, по-видимому, является большим толчком, мы потратили впустую время и пространство. Плавающий внутрь исправляет это, производя это:, который впоследствии заменяется на упрощитель
Эта статья , хотя и охватывает другие темы, дает довольно четкое введение. Обратите внимание, что несмотря на их имена, всплывающие и всплывающие объекты не попадают в бесконечный цикл по двум причинам:
case
операторы, а float out - функции.Анализ спроса
Анализ спроса, или анализ строгости - это не трансформация, а, как следует из названия, в большей степени проход сбора информации. Компилятор находит функции, которые всегда оценивают свои аргументы (или, по крайней мере, некоторые из них), и передает эти аргументы, используя вызов по значению вместо вызова по необходимости. Так как вам удается избежать накладных расходов, это часто происходит намного быстрее. Многие проблемы с производительностью в Haskell возникают либо из-за сбоя этого прохода, либо из-за недостаточно строгого кода. Простым примером является разница между использованием
foldr
,foldl
иfoldl'
для суммирования списка целых чисел - первое вызывает переполнение стека, второе вызывает переполнение кучи, а последнее выполняется нормально из-за строгости. Это, пожалуй, самый простой для понимания и документально подтвержденный из всех этих. Я считаю, что полиморфизм и код CPS часто побеждают это.Работник Обертка связывает
Основная идея преобразования рабочий / упаковщик состоит в том, чтобы сделать простой цикл на простой структуре, преобразуя ее в конечную структуру и из нее. Например, возьмите эту функцию, которая вычисляет факториал числа.
Используя определение
Int
в GHC, мы имеемЗаметьте, как код описан в
I#
s? Мы можем удалить их, сделав это:Хотя этот конкретный пример мог бы также сделать SpecConstr, преобразование рабочий / упаковщик является очень общим в том, что он может делать.
Общее подвыражение
Это еще одна очень простая оптимизация, которая очень эффективна, например, анализ строгости. Основная идея заключается в том, что если у вас есть два одинаковых выражения, они будут иметь одинаковое значение. Например, если
fib
калькулятор чисел Фибоначчи, CSE преобразуетв
который сокращает вычисления в два раза. К сожалению, это может иногда мешать другим оптимизациям. Другая проблема состоит в том, что два выражения должны быть в одном и том же месте и что они должны быть синтаксически одинаковыми, а не одинаковыми по значению. Например, CSE не будет запускаться в следующем коде без множества вставок:
Однако, если вы скомпилируете через llvm, вы можете получить часть этого вместе из-за прохода нумерации глобальных значений.
Освободить дело
Это, кажется, ужасно документированное преобразование, помимо того факта, что оно может вызвать взрыв кода. Вот переформатированная (и немного переписанная) версия небольшой документации, которую я нашел:
Этот модуль переходит
Core
и ищетcase
свободные переменные. Критерий: еслиcase
на пути к рекурсивному вызову имеется свободная переменная on, то рекурсивный вызов заменяется на развертывание. Например, ввнутреннее
f
заменено. делатьОбратите внимание на необходимость слежки. Упрощая, мы получаем
Это лучший код, потому что
a
он свободен внутриletrec
, а не требует проекцииv
. Обратите внимание, что это касается свободных переменных , в отличие от SpecConstr, который имеет дело с аргументами известной формы.Смотрите ниже для получения дополнительной информации о SpecConstr.
SpecConstr - это трансформирует такие программы, как
в
В качестве расширенного примера возьмем это определение
last
:Сначала мы преобразуем его в
Далее запускается упрощатель, и мы имеем
Обратите внимание, что теперь программа работает быстрее, поскольку мы не занимаемся многократной коробкой и распаковыванием передней части списка. Также обратите внимание, что встраивание имеет решающее значение, поскольку оно позволяет фактически использовать новые, более эффективные определения, а также улучшать рекурсивные определения.
SpecConstr контролируется рядом эвристик. Те, упомянутые в статье, таковы:
a
.Однако эвристика почти наверняка изменилась. Фактически, газета упоминает альтернативную шестую эвристику:
Специализируется на аргументе ,
x
только еслиx
будет только внимательно изученоcase
, и не передаются обычной функции, или возвращаются как часть результата.Это был очень маленький файл (12 строк), и поэтому, возможно, он не вызвал столько оптимизаций (хотя я думаю, что он сделал их все). Это также не говорит вам, почему он выбрал эти проходы и почему он разместил их в таком порядке.
источник
Лень
Это не «оптимизация компилятора», но это гарантировано языковой спецификацией, так что вы всегда можете рассчитывать на это. По сути, это означает, что работа не выполняется, пока вы не «сделаете что-то» с результатом. (Если вы не сделаете одно из нескольких действий, чтобы сознательно отключить лень.)
Это, очевидно, целая тема сама по себе, и у SO уже есть много вопросов и ответов на нее.
Исходя из моего ограниченного опыта, слишком ленивый или слишком строгий код имеет значительно большие потери производительности (во времени и пространстве), чем любой другой материал, о котором я собираюсь поговорить ...
Анализ строгости
Лень - это избегать работы, если в этом нет необходимости. Если компилятор может определить, что данный результат будет «всегда» необходим, он не будет беспокоиться о сохранении вычисления и его выполнении позже; он просто выполнит это напрямую, потому что это более эффективно. Это так называемый «анализ строгости».
Очевидно, что проблема заключается в том, что компилятор не всегда может определить, когда что-то можно сделать строгим. Иногда вам нужно дать компилятору маленькие подсказки. (Я не знаю ни одного простого способа определить, выполнил ли анализ строгости то, что, по вашему мнению, он сделал, кроме как просмотреть основные результаты.)
Встраивание
Если вы вызываете функцию, и компилятор может определить, какую функцию вы вызываете, он может попытаться «встроить» эту функцию, то есть заменить вызов функции на копию самой функции. Накладные расходы при вызове функции обычно довольно малы, но встраивание часто позволяет выполнять другие оптимизации, которых не было бы иначе, поэтому встраивание может быть большой победой.
Функции встраиваются только в том случае, если они «достаточно малы» (или если вы добавляете прагму, специально запрашивающую встраивание). Кроме того, функции могут быть встроены, только если компилятор может сказать, какую функцию вы вызываете. Есть два основных способа, которыми компилятор не может сказать:
Если функция, которую вы вызываете, передается откуда-то еще. Например, когда
filter
функция скомпилирована, вы не можете встроить предикат фильтра, потому что это предоставленный пользователем аргумент.Если вызываемая вами функция является методом класса и компилятор не знает, какой тип задействован. Например, когда
sum
функция компилируется, компилятор не может встроить+
функцию, потому чтоsum
работает с несколькими различными типами чисел, каждый из которых имеет свою+
функцию.В последнем случае вы можете использовать
{-# SPECIALIZE #-}
прагму для генерации версий функции, жестко закодированных для определенного типа. Например,{-# SPECIALIZE sum :: [Int] -> Int #-}
будет скомпилирована версия сsum
жестким кодом дляInt
типа, что означает, что она+
может быть встроена в эту версию.Обратите внимание, что наша новая специальная
sum
функция будет вызываться только тогда, когда компилятор может сказать, что мы работаемInt
. В противном случае вызывается оригинал, полиморфныйsum
. Опять же, фактические накладные расходы на вызов функции довольно малы. Это дополнительная оптимизация, которую может обеспечить включение, которые являются полезными.Устранение общего подвыражения
Если определенный блок кода вычисляет одно и то же значение дважды, компилятор может заменить его одним экземпляром одного и того же вычисления. Например, если вы делаете
тогда компилятор может оптимизировать это
Вы можете ожидать, что компилятор всегда будет делать это. Однако, по-видимому, в некоторых ситуациях это может привести к снижению производительности, а не к улучшению, поэтому GHC не всегда делает это. Честно говоря, я не очень понимаю детали этого. Но суть в том, что если это преобразование важно для вас, это не сложно сделать вручную. (И если это не важно, почему вы беспокоитесь об этом?)
Регистр выражений
Учтите следующее:
Все первые три уравнения проверяют, является ли список непустым (среди прочего). Но проверять то же самое трижды бесполезно. К счастью, компилятору очень легко оптимизировать это в несколько вложенных выражений. В этом случае что-то вроде
Это скорее менее интуитивно, но более эффективно. Поскольку компилятор может легко выполнить это преобразование, вам не нужно беспокоиться об этом. Просто напишите ваше сопоставление с образцом наиболее интуитивным способом; компилятор очень хорош в переупорядочении и переупорядочении, чтобы сделать его максимально быстрым.
сплавление
Стандартная идиома Haskell для обработки списков состоит в том, чтобы связать воедино функции, которые берут один список и создают новый список. Канонический пример
К сожалению, в то время как лень гарантирует пропуск ненужной работы, все выделения и освобождения для промежуточного списка снижают производительность. «Fusion» или «вырубка леса» - это то, где компилятор пытается устранить эти промежуточные шаги.
Проблема в том, что большинство этих функций являются рекурсивными. Без рекурсии было бы элементарным упражнением во вложении, чтобы объединить все функции в один большой блок кода, запустить над ним упрощатель и создать действительно оптимальный код без промежуточных списков. Но из-за рекурсии это не сработает.
Вы можете использовать
{-# RULE #-}
прагмы, чтобы исправить это. Например,Теперь каждый раз, когда GHC видит
map
применениеmap
, он разбивает его на один проход по списку, исключая промежуточный список.Беда в том, что это работает только
map
послеmap
. Есть много других возможностей - сmap
последующимиfilter
,filter
сопровождаемыми иmap
т. Д. Вместо того, чтобы вручную кодировать решение для каждой из них, было изобретено так называемое «объединение потоков». Это более сложный трюк, который я не буду здесь описывать.Короче говоря, это все специальные приемы оптимизации, написанные программистом . Сам GHC ничего не знает о слиянии; это все в списке библиотек и других контейнерных библиотек. То, какие оптимизации произойдут, зависит от того, как написаны ваши библиотеки контейнеров (или, более реалистично, какие библиотеки вы выберете).
Например, если вы работаете с массивами Haskell '98, не ожидайте какого-либо слияния. Но я понимаю, что
vector
библиотека обладает обширными возможностями слияния. Это все о библиотеках; компилятор просто предоставляетRULES
прагму. (Кстати, это очень мощно. Как автор библиотеки, вы можете использовать его для перезаписи клиентского кода!)Мета:
Я согласен с тем, что люди говорят «сначала код, второй профиль, третий оптимизируйте».
Я также согласен с людьми, которые говорят, что «полезно иметь мысленную модель того, сколько стоит данное проектное решение».
Баланс во всех вещах, и все такое ...
источник
it's something guaranteed by the language specification ... work is not performed until you "do something" with the result.
- не совсем. Спецификация языка обещает не строгую семантику ; это ничего не обещает о том, будет ли выполнена лишняя работа.Если привязка let v = rhs используется только в одном месте, вы можете рассчитывать на компилятор, чтобы встроить его, даже если rhs большое.
Исключение (это почти не одно в контексте текущего вопроса) - лямбда, рискующая дублированием работы. Рассматривать:
там вставка v была бы опасна, потому что одно (синтаксическое) использование привело бы к 99 дополнительным оценкам rhs. Однако в этом случае вы вряд ли захотите добавить его вручную. По сути, вы можете использовать правило:
Если вы захотите добавить имя, которое появляется только один раз, компилятор все равно сделает это.
Как счастливое следствие, использование привязки let просто для декомпозиции длинного утверждения (с надеждой получить ясность) по существу бесплатно.
Это взято из community.haskell.org/~simonmar/papers/inline.pdf, в котором содержится гораздо больше информации о встраивании.
источник