Производительность Haskell по примеру

У меня есть эти основные типы в моем коде:

newtype Foo (m :: Factored) = Foo Int64 deriving (NFData)

foo :: forall m . (Fact m) => Foo m -> Foo m

class T t where t :: (Fact m ) => t m -> t m

instance T Foo where t = foo

newtype Bar t (m :: Factored) = Bar (t m) deriving (NFData)

bar :: (Fact m, T t) => Bar t m -> Bar t m
bar (Bar v) = Bar $ t v

(игнорировать Fact и Factored на данный момент). Я сравниваю код на разных уровнях, сравнивая производительность foo, t и bar. В тестах t = foo и bar применяется только t через newtype. Таким образом, их время выполнения должно быть практически идентичным, но criterion сообщает, что foo принимает 9.2ns, t занимает примерно вдвое больше, чем при 17.45ns, и bar берет колоссальные 268.1ns.

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

Полный код можно найти здесь. Я обещаю, что это не пугает. Модули:

  • Foo: определяет foo, foo и t
  • Бар: определяет bar и bar
  • FooBench: определяет ориентир для foo
  • TBench: определяет ориентир для t
  • BarBench: определяет ориентир для bar
  • Main: выполняется три теста
  • Factored: определяет Fact и Factored с помощью singletons

Большинство модулей являются крошечными; Я определил три теста в отдельных файлах, чтобы я мог исследовать их ядро. Я создал ядро ​​для трех модулей *Bench и выровнял их, насколько мог. Они всего ~ 250 строк каждый, а первые 200 строк идентичны, вплоть до переименования. Проблема в том, что я не знаю, что делать из последних 50 или около того строк. Здесь (core) diff для FooBench vs TBench находится здесь, diff для TBench vs BarBench - здесь, а diff для FooBench vs BarBench здесь.

Несколько вопросов, которые у меня есть:

  • На высоком уровне, какова существенная разница между основными файлами? Я ищу что-то вроде "Здесь вы можете видеть, что GHC не вписывает вызов x". Что я должен искать?

  • Что можно сделать, чтобы все три теста все работали примерно в 9.2ns? Оптимизация GHC? INLINE/INLINABLE прагмы? SPECIALIZE прагмы, которые я пропустил? (Вам не разрешено специализироваться на F128::Factored, в моей реальной библиотеке это значение может быть подтверждено во время выполнения.) Ограничение/задержка встраивания в конкретную фазу?

Хотя я ищу фактическое решение для быстрого тестирования тестов, возможно, что трюки, которые работают для этого примера, не будут масштабироваться в моей реальной библиотеке. В результате я также ищу "высокоуровневое" объяснение того, почему конкретная техника должна работать.

Ответ 1

Сначала, посмотрев bar:

bar :: (Fact m, T t) => Bar t m -> Bar t m
bar (Bar v) = Bar $ t v

мы можем написать это без использования аргумента, используя coerce:

bar :: (Fact m, T t) => Bar t m -> Bar t m
bar = (coerce :: (t m -> t m) -> Bar t m -> Bar t m) t

это (как мы надеемся) получает bar, выполняя то же самое, что и t. (На самом деле ядро ​​для TBench и BarBench точно такое же, исключая сигнатуры типов).

Я не совсем уверен, почему, но используя INLINE вместо INLINEABLE делает t и bar так же, как foo. Я не эксперт, но обычно лучше использовать INLINE для небольших функций, которые вы уверены, что хотите встроить.

Тем не менее, я думаю, что некоторые из этих проблем связаны с тем, как критерий делает тесты для остановки обмана ghc. Например, запись bench "Bar" $ nf (GHC.Magic.inline bar) x в исходном коде bar выполняется так же, как foo. Я подозреваю, что "настоящая" программа не будет такой деликатной.