Вычисление производных меток типа

Haskell позволяет выводить экземпляры типов, например:

{-# LANGUAGE DeriveFunctor #-}

data Foo a = MakeFoo a a deriving (Functor)

... но иногда тесты показывают, что производительность улучшается, если вы вручную реализуете экземпляр typeclass и комментируете метод класса класса с INLINE, например:

data Foo a = MakeFoo a a

instance Functor Foo where
    fmap f (MakeFoo x y) = MakeFoo (f x) (f y)
    {-# INLINE fmap #-}

Есть ли способ получить лучшее из обоих миров? Другими словами, существует ли способ получить экземпляр typeclass, а также аннотировать полученные производные методы меток с помощью INLINE?

Ответ 1

Хотя вы не можете "повторно открывать" экземпляры в Haskell, как вы могли, с классами на динамических языках, есть способы обеспечить, чтобы функции были агрессивно встроены, когда это возможно, путем передачи определенных флагов в GHC.

-fspecialise-aggressively удаляет ограничения, по которым функции являются специализированными. Любая перегруженная функция будет специализироваться на этом флаге. Это может создать много дополнительного кода.

-fexpose-all-unfoldings будет включать (оптимизированное) разворачивание всех функций в файлах интерфейса, чтобы они могли быть встроены и специализированы по всем модулям.

Использование этих двух флагов в совокупности будет иметь почти такой же эффект, как и любое определение как INLINABLE кроме того, что разворачивание определений INLINABLE не оптимизировано.

(Источник: https://wiki.haskell.org/Inlining_and_Specialisation#Which_flags_can_I_use_to_control_the_simplifier_and_inliner.3F)

Эти параметры позволят компилятору GHC встроить fmap. Опция -fexpose-all-unfoldings, в частности, позволяет компилятору подвергать внутренности Data.Functor остальной части программы для встраивания целей (и, как представляется, она обеспечивает наибольшую производительность). Вот быстрый и неглубокий бенчмарк, который я бросил вместе:

functor.hs содержит этот код:

{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE Strict #-}

data Foo a = MakeFoo a a deriving (Functor)

one_fmap foo = fmap (+1) foo

main = sequence (fmap (\n -> return $ one_fmap $ MakeFoo n n) [1..10000000])

Составлено без аргументов:

$ time ./functor 

real    0m4.036s
user    0m3.550s
sys 0m0.485s

Скомпилирован с -fexpose-all-unfoldings:

$ time ./functor

real    0m3.662s
user    0m3.258s
sys 0m0.404s

Здесь файл .prof из этой компиляции, чтобы показать, что вызов fmap действительно вставляется:

    Sun Oct  7 00:06 2018 Time and Allocation Profiling Report  (Final)

       functor +RTS -p -RTS

    total time  =        1.95 secs   (1952 ticks @ 1000 us, 1 processor)
    total alloc = 4,240,039,224 bytes  (excludes profiling overheads)

COST CENTRE MODULE SRC              %time %alloc

CAF         Main   <entire-module>  100.0  100.0


                                                                     individual      inherited
COST CENTRE MODULE                SRC             no.     entries  %time %alloc   %time %alloc

MAIN        MAIN                  <built-in>       44          0    0.0    0.0   100.0  100.0
 CAF        Main                  <entire-module>  87          0  100.0  100.0   100.0  100.0
 CAF        GHC.IO.Handle.FD      <entire-module>  84          0    0.0    0.0     0.0    0.0
 CAF        GHC.IO.Encoding       <entire-module>  77          0    0.0    0.0     0.0    0.0
 CAF        GHC.Conc.Signal       <entire-module>  71          0    0.0    0.0     0.0    0.0
 CAF        GHC.IO.Encoding.Iconv <entire-module>  58          0    0.0    0.0     0.0    0.0

Скомпилирован с -fspecialise-aggressively:

$ time ./functor

real    0m3.761s
user    0m3.300s
sys 0m0.460s

Скомпилирован с обоими флагами:

$ time ./functor

real    0m3.665s
user    0m3.213s
sys 0m0.452s

Эти небольшие тесты ни в коем случае не представляют, что производительность (или файл) понравится в реальном коде, но это определенно показывает, что вы можете заставить компилятор GHC встроить fmap (и что он действительно может иметь незначительные эффекты на производительность),