Лень
Это не «оптимизация компилятора», но это гарантировано языковой спецификацией, так что вы всегда можете рассчитывать на это. По сути, это означает, что работа не выполняется, пока вы не «сделаете что-то» с результатом. (Если вы не сделаете одно из нескольких действий, чтобы сознательно отключить лень.)
Это, очевидно, целая тема сама по себе, и у SO уже есть много вопросов и ответов на нее.
Исходя из моего ограниченного опыта, слишком ленивый или слишком строгий код имеет значительно большие потери производительности (во времени и пространстве), чем любой другой материал, о котором я собираюсь поговорить ...
Анализ строгости
Лень - это избегать работы, если в этом нет необходимости. Если компилятор может определить, что данный результат будет «всегда» необходим, он не будет беспокоиться о сохранении вычисления и его выполнении позже; он просто выполнит это напрямую, потому что это более эффективно. Это так называемый «анализ строгости».
Очевидно, что проблема заключается в том, что компилятор не всегда может определить, когда что-то можно сделать строгим. Иногда вам нужно дать компилятору маленькие подсказки. (Я не знаю ни одного простого способа определить, выполнил ли анализ строгости то, что, по вашему мнению, он сделал, кроме как просмотреть основные результаты.)
Встраивание
Если вы вызываете функцию, и компилятор может определить, какую функцию вы вызываете, он может попытаться «встроить» эту функцию, то есть заменить вызов функции на копию самой функции. Накладные расходы при вызове функции обычно довольно малы, но встраивание часто позволяет выполнять другие оптимизации, которых не было бы иначе, поэтому встраивание может быть большой победой.
Функции встраиваются только в том случае, если они «достаточно малы» (или если вы добавляете прагму, специально запрашивающую встраивание). Кроме того, функции могут быть встроены, только если компилятор может сказать, какую функцию вы вызываете. Есть два основных способа, которыми компилятор не может сказать:
Если функция, которую вы вызываете, передается откуда-то еще. Например, когда filter
функция скомпилирована, вы не можете встроить предикат фильтра, потому что это предоставленный пользователем аргумент.
Если вызываемая вами функция является методом класса и компилятор не знает, какой тип задействован. Например, когда sum
функция компилируется, компилятор не может встроить +
функцию, потому что sum
работает с несколькими различными типами чисел, каждый из которых имеет свою +
функцию.
В последнем случае вы можете использовать {-# SPECIALIZE #-}
прагму для генерации версий функции, жестко закодированных для определенного типа. Например, {-# SPECIALIZE sum :: [Int] -> Int #-}
будет скомпилирована версия с sum
жестким кодом для Int
типа, что означает, что она +
может быть встроена в эту версию.
Обратите внимание, что наша новая специальная sum
функция будет вызываться только тогда, когда компилятор может сказать, что мы работаем Int
. В противном случае вызывается оригинал, полиморфный sum
. Опять же, фактические накладные расходы на вызов функции довольно малы. Это дополнительная оптимизация, которую может обеспечить включение, которые являются полезными.
Устранение общего подвыражения
Если определенный блок кода вычисляет одно и то же значение дважды, компилятор может заменить его одним экземпляром одного и того же вычисления. Например, если вы делаете
(sum xs + 1) / (sum xs + 2)
тогда компилятор может оптимизировать это
let s = sum xs in (s+1)/(s+2)
Вы можете ожидать, что компилятор всегда будет делать это. Однако, по-видимому, в некоторых ситуациях это может привести к снижению производительности, а не к улучшению, поэтому GHC не всегда делает это. Честно говоря, я не очень понимаю детали этого. Но суть в том, что если это преобразование важно для вас, это не сложно сделать вручную. (И если это не важно, почему вы беспокоитесь об этом?)
Регистр выражений
Учтите следующее:
foo (0:_ ) = "zero"
foo (1:_ ) = "one"
foo (_:xs) = foo xs
foo ( []) = "end"
Все первые три уравнения проверяют, является ли список непустым (среди прочего). Но проверять то же самое трижды бесполезно. К счастью, компилятору очень легко оптимизировать это в несколько вложенных выражений. В этом случае что-то вроде
foo xs =
case xs of
y:ys ->
case y of
0 -> "zero"
1 -> "one"
_ -> foo ys
[] -> "end"
Это скорее менее интуитивно, но более эффективно. Поскольку компилятор может легко выполнить это преобразование, вам не нужно беспокоиться об этом. Просто напишите ваше сопоставление с образцом наиболее интуитивным способом; компилятор очень хорош в переупорядочении и переупорядочении, чтобы сделать его максимально быстрым.
сплавление
Стандартная идиома Haskell для обработки списков состоит в том, чтобы связать воедино функции, которые берут один список и создают новый список. Канонический пример
map g . map f
К сожалению, в то время как лень гарантирует пропуск ненужной работы, все выделения и освобождения для промежуточного списка снижают производительность. «Fusion» или «вырубка леса» - это то, где компилятор пытается устранить эти промежуточные шаги.
Проблема в том, что большинство этих функций являются рекурсивными. Без рекурсии было бы элементарным упражнением во вложении, чтобы объединить все функции в один большой блок кода, запустить над ним упрощатель и создать действительно оптимальный код без промежуточных списков. Но из-за рекурсии это не сработает.
Вы можете использовать {-# RULE #-}
прагмы, чтобы исправить это. Например,
{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-}
Теперь каждый раз, когда GHC видит map
применение map
, он разбивает его на один проход по списку, исключая промежуточный список.
Беда в том, что это работает только map
после map
. Есть много других возможностей - с map
последующими filter
, filter
сопровождаемыми и map
т. Д. Вместо того, чтобы вручную кодировать решение для каждой из них, было изобретено так называемое «объединение потоков». Это более сложный трюк, который я не буду здесь описывать.
Короче говоря, это все специальные приемы оптимизации, написанные программистом . Сам GHC ничего не знает о слиянии; это все в списке библиотек и других контейнерных библиотек. То, какие оптимизации произойдут, зависит от того, как написаны ваши библиотеки контейнеров (или, более реалистично, какие библиотеки вы выберете).
Например, если вы работаете с массивами Haskell '98, не ожидайте какого-либо слияния. Но я понимаю, что vector
библиотека обладает обширными возможностями слияния. Это все о библиотеках; компилятор просто предоставляет RULES
прагму. (Кстати, это очень мощно. Как автор библиотеки, вы можете использовать его для перезаписи клиентского кода!)
Мета:
Я согласен с тем, что люди говорят «сначала код, второй профиль, третий оптимизируйте».
Я также согласен с людьми, которые говорят, что «полезно иметь мысленную модель того, сколько стоит данное проектное решение».
Баланс во всех вещах, и все такое ...