haskell-notes

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

проще программы вычисления левой – известно только нам.

Отметим то, что прагма RULES применяется до тех пор пока есть возможность её применять, при этом мы

можем войти в бесконечный цикл:

{-# RULES

”infinite”

forall a b. f a b = f b a

#-}

С помощью прагмы RULES можно реализовать очень сложные схемы оптимизации. Так в Prelude реализу-

ется слияние (fusion) списков. За счёт этой оптимизации многие выражения вида свёртка/развёртка не будут

производить промежуточных списков. Этой схеме будет посвящена отдельная глава. Например если список

преобразуется серией функций map, filter и foldr промежуточные списки не строятся.

Посмотрим как работает прагма RULES, попробуем скомпилировать такой код:

module Main where

data List a = Nil | Cons a (List a)

deriving (Show)

foldrL :: (a -> b -> b) -> b -> List a -> b

foldrL cons nil x = case x of

Nil

-> nil

Cons a as

-> cons a (foldrL cons nil as)

Оптимизация программ | 175

mapL :: (a -> b) -> List a -> List b

mapL = undefined

{-# RULES

”mapL”

forall f xs.

mapL f xs = foldrL (Cons . f) Nil xs

#-}

main = print $ mapL (+100) $ Cons 1 $ Cons 2 $ Cons 3 Nil

Функция mapL не определена, вместо этого мы сделали косвенное определение в прагме RULES. Проверим,

для того чтобы RULES заработали, необходимо компилировать с одним из флагов оптимизаций O или O2:

$ ghc —make -O Rules.hs

$ ./Rules

Rules: Prelude.undefined

Что-то не так. Дело в том, что GHC слишком поторопился и заменил простую функцию mapL на её опре-

деление. Функция $ также очень короткая, если бы нам удалось задержать встраивание mapL, тогда $ превра-

тилось бы в обычное применение и наши правила сработали бы.

Фазы компиляции

Для решения этой проблемы в прагмы RULES и INLINE были введены ссылки на фазы компиляции. С по-

мощью них мы можем указать GHC в каком порядке реагировать на эти прагмы. Фазы пишутся в квадратных

скобках:

{-# INLINE [2] someFun #-}

{-# RULES

”fun” [0] forall …

”fun” [1] forall …

”fun” [~1] forall …

#-}

Компиляция выполняется в несколько фаз. Фазы следуют некотрого заданного целого числа, например

трёх, до нуля. Мы можем сослаться на фазу двумя способами: просто номером и номером с тильдой. Ссылка

без тильды говорит: попытайся применить это правило как можно раньше до тех пор пока не наступит данная

фаза, далее не применяй. Ссылка с тильдой говорит: подожди до наступления этой фазы. В нашем примере

мы задержим встраивание для mapL и foldrL так:

{-# INLINE [1] foldrL #-}

foldrL :: (a -> b -> b) -> b -> List a -> b

{-# INLINE [1] mapL #-}

mapL :: (a -> b) -> List a -> List b

Посмотреть какие правила сработали можно с помощью флага ddumprulefirings. Теперь скомпилиру-

ем:

$ ghc —make -O Rules.hs -ddump-rule-firings

Rule fired: SPEC Main.$fShowList [GHC.Integer.Type.Integer]

Rule fired: mapL

Rule fired: Class op show

$ ./Rules

Cons 101 (Cons 102 (Cons 103 Nil))

Среди прочих правил, определённых в стандартных библиотеках, сработало и наше. Составим правила,

которые будут проводить оптимизацию слияние для наших списков, они будут заменять последовательное

применение mapL на один mapL c композицией функций, также промежуточный список будет устранён в

связке foldrL/mapL.

176 | Глава 10: Реализация Haskell в GHC

Прагма UNPACK

Наш основной враг на этапе оптимизации программы это лишние объекты кучи. Чем меньше объектов

мы создаём на пути к результату, тем эффективнее наша программа. С помощью прагмы INLINE мы можем

избавиться от многих объектов, связанных с вызовом функции, это объекты типа FUN. Прагма UNPACK позволя-

ет нам бороться с лишними объектами типа CON. В прошлой главе мы говорили о том, что значения в Haskell

содержат дополнительную служебную информацию, которая необходима на этапе вычисления, например

значение сначала было отложенным, потом мы до него добрались и вычислили, возможно оно оказалось не

определённым значением (undefined). Такие значения называются запакованными (boxed). Незапакованное

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

целых чисел:

data Int = I# Int#

По традиции все незапакованные значения пишутся с решёткой на конце. Запакованные значения позво-

ляют отклдывать вычисления, пользоваться undefined при определении функции. Но за эту гибкость прихо-

дится платить. Вспомним расход памяти в выражении [Pair 1 2]

nil = []

— глобальный объект (не в счёт)

let x1

= I# 1

— 2 слова

x2

= I# 2

— 2 слова

p

= Pair x1 x2

— 3 слова

val = Cons p nil

— 3 слова

in

val

————

— 10 слов

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

списка, который хранит такие пары будет зависеть от числа элементов N как 10 N. Тогда как полезная

нагрузка составляет 2 N. С помощью прагмы UNPACK мы можем отказаться от ленивой гибкости в пользу

меньшего расхода памяти. Эта прагма позволяет встраивать

один конструктор в поле другого. Это поле должно быть строгим (с пометкой ! ) и мономорфным (тип поля

должен быть конкретным типом, а не параметром), причём подчинённый тип должен содержать лишь один

конструктор (у него нет альтернатив):

data PairInt = PairInt

{-# UNPACK #-} !Int

{-# UNPACK #-} !Int

Мы конкретизировали поля Pair и сделали их строгими с помощью восклицательных знаков. После этого

значения из конструктора Int будут храниться прямо в конструкторе PairInt:

nil = []

— глобальный объект (не в счёт)

let p

= PairInt 1 2

— 3 слова

val = Cons p nil

— 3 слова

in

val

————

— 6 слов

Так мы сократим размер до 6 N. Но мы можем пойти ещё дальше. Если этот тип является ключевым

типом нашей программы и мы расчитываем на то, что в нём будет хранится много значений мы можем

создать специальный список для таких пар и распаковать значение списка:

data ListInt = ConsInt {-# UNPACK #-} !PairInt

| NilInt

nil = NilInt

let val = ConsInt 1 2 nil

— 4 слова

in

val

————

— 4 слова

Значение будет встроено дважды и получится, что у нашего нового конструктора Cons уже три поля.

Отметим, что эта прагма имеет смысл лишь при включённом флаге оптимизации -O или выше. Если мы

не включим этот флаг, то компилятор не будет проводить встраивание функций, поэтому при вычислении

функций вроде

Оптимизация программ | 177

sumPair :: PairInt -> Int

sumPair (Pair a b) = a + b

Плюс не будет встроен и вместо того, чтобы сразу сложить два числа с помощью примитивной функции,

компилятор сначала запакует их в конструктор I# и затем применит функцию +, в которой опять распакует

их, сложит и затем, снова запаковав, вернёт результат.

Компилятор автоматически запаковывает все такие значения при передаче в ленивую функцию, это мо-

жет привести к снижению быстродействия даже при включённом флаге оптимизации, при недостаточном

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

оптимизация привела к повышению эффективности.

В стандартных библиотеках предусмотрено много незапакованных типов. Например это специальные

кортежи. Они пишутся с решётками:

newtype ST s a = ST (STRep s a)

type STRep s a = State# s -> (# State# s, a #)

Читай продолжение на следующей странице
Добавить комментарии