Сжатие большей производительности из монадических потоков в Haskell

Самый простой монадический "поток" - это всего лишь список монадических действий Monad m => [ma]. Функция sequence :: [ma] → m [a] оценивает каждое монадическое действие и собирает результаты. Как оказалось, sequence не очень эффективна, хотя, потому что она работает над списками, а монада является препятствием для достижения слияния во всем, кроме простейших случаев.

Возникает вопрос: каков наиболее эффективный подход к монадическим потокам?

Чтобы исследовать это, я предлагаю игрушку, а также несколько попыток улучшить производительность. Исходный код можно найти в github. Единый контрольный показатель, представленный ниже, может вводить в заблуждение для более реалистичных проблем, хотя я считаю, что это худший сценарий, т.е. Самые возможные накладные расходы на полезное вычисление.

Игрушечная проблема

представляет собой максимальную длину 16-бит L inear F eedback S hift R egister (LFSR), реализованную на C несколько более тщательно, с оберткой Haskell в монаде IO. "Over-developate" относится к ненужному использованию struct и ее malloc - цель этого осложнения состоит в том, чтобы сделать его более похожим на реалистичные ситуации, когда все, что у вас есть, - это оболочка Haskell вокруг FFI для C- struct с OO-ish new, set, get, operate семантику (то есть очень важный стиль). Типичная программа Haskell выглядит так:

import LFSR

main = do
    lfsr <- newLFSR            -- make a LFSR object
    setLFSR lfsr 42            -- initialise it with 42 
    stepLFSR lfsr              -- do one update
    getLFSR lfsr >>= print     -- extract the new value and print

Задача по умолчанию - рассчитать среднее значение (удваивает) 10 000 000 повторений LFSR. Эта задача может быть частью набора тестов для анализа "случайности" этого потока из 16-битных целых чисел.

0. Исходный уровень

Базой является реализация C в среднем по n итерациям:

double avg(state_t* s, int n) {
    double sum = 0;
    for(int i=0; i<n; i++, sum += step_get_lfsr(s));
    return sum / (double)n;
}

Реализация C не должна быть особенно хорошей или быстрой. Он просто дает осмысленное вычисление.

1. Списки Haskell

По сравнению с базой C, по этой задаче списки Haskell в 73 раза медленнее.

=== RunAvg =========
Baseline: 1.874e-2
IO:       1.382488
factor:   73.77203842049093

Это реализация ( RunAvg.hs):

step1 :: LFSR -> IO Word32
step1 lfsr = stepLFSR lfsr >> getLFSR lfsr

avg :: LFSR -> Int -> IO Double
avg lfsr n = mean <$> replicateM n (step1 lfsr) where
    mean :: [Word32] -> Double
    mean vs = (sum $ fromIntegral <$> vs) / (fromIntegral n)

2. Использование библиотеки streaming

Это приводит нас к 9-кратному исходному уровню,

=== RunAvgStreaming ===
Baseline: 1.9391e-2
IO:       0.168126
factor:   8.670310969006241

(Обратите внимание, что в эти короткие сроки выполнения эталонные тесты являются довольно неточными).

Это реализация ( RunAvgStreaming.hs):

import qualified Streaming.Prelude as S
avg :: LFSR -> Int -> IO Double
avg lfsr n = do
    let stream = S.replicateM n (fromIntegral <$> step1 lfsr :: IO Double)
    (mySum :> _) <- S.sum stream
    return (mySum / fromIntegral n)

3. Использование Data.Vector.Fusion.Stream.Monadic

Это дает лучшую производительность на данный момент, в пределах 3x от базовой линии,

=== RunVector =========
Baseline: 1.9986e-2
IO:       4.9146e-2
factor:   2.4590213149204443

Как обычно, вот реализация ( RunAvgVector.hs):

import qualified Data.Vector.Fusion.Stream.Monadic as V
avg :: LFSR -> Int -> IO Double
avg lfsr n = do
   let stream = V.replicateM n (step1' lfsr)
   V.foldl (+) 0.0 stream

Я не ожидал найти хорошую реализацию монадического потока в Data.Vector. Помимо предоставления из fromVector и concatVectors, Data.Vector.Fusion.Stream.Monadic имеет очень мало общего с Vector from Data.Vector.

Взгляд на отчет профилирования показывает, что Data.Vector.Fusion.Stream.Monadic имеет значительную утечку пространства, но это звучит не так.

4. Списки не обязательно медленны

Для очень простых операций списки вообще не страшны:

=== RunRepeat =======
Baseline: 1.8078e-2
IO:       3.6253e-2
factor:   2.0053656377917912

Здесь цикл for выполняется в Haskell вместо того, чтобы нажимать его на C ( RunRepeat.hs):

do
   setLFSR lfsr 42
   replicateM_ nIter (stepLFSR lfsr)
   getLFSR lfsr

Это просто повторение вызовов stepLFSR не передавая результат на уровень Haskell. Это дает указание на то, какое влияние на накладные расходы вызывает вызов обертки и FFI.

Анализ

Приведенный выше пример repeat показывает, что большинство, но не всех (?), Штрафа за производительность происходит из-за накладных расходов на вызов оболочки и/или FFI. Но теперь я не уверен, где искать твики. Может быть, это так же хорошо, как и в отношении монадических потоков, и на самом деле это все об обрезке FFI, теперь...

Sidenotes

  1. Тот факт, что LFSR выбраны в качестве игрушечной проблемы, не означает, что Haskell не в состоянии сделать это эффективно - см. Вопрос SO "Эффективное битово в реализации LFSR".
  2. Итерирование 16-бит LFSR 10M раз - довольно глупая вещь. Для достижения начального состояния потребуется не более 2 ^ 16-1 итераций. В максимальной длине LFSR потребуется ровно 2 ^ 16-1 итераций.

Обновление 1

Попытку удалить вызовы withForeignPtr можно сделать, введя Storable и затем используя alloca :: Storable a => (Ptr a → IO b) → IO b

repeatSteps :: Word32 -> Int -> IO Word32
repeatSteps start n = alloca rep where
    rep :: Ptr LFSRStruct' -> IO Word32
    rep p = do
        setLFSR2 p start
        (sequence_ . (replicate n)) (stepLFSR2 p)
        getLFSR2 p

где LFSRStruct'

data LFSRStruct' = LFSRStruct' CUInt

и обертка

foreign import ccall unsafe "lfsr.h set_lfsr"
    setLFSR2 :: Ptr LFSRStruct' -> Word32 -> IO ()

-- likewise for setLFSR2, stepLFSR2, ...

См. RunRepeatAlloca.hs и src/LFSR.hs. По производительности это не имеет значения (в рамках временной дисперсии).

=== RunRepeatAlloca =======
Baseline: 0.19811199999999998
IO:       0.33433
factor:   1.6875807623970283

Ответ 1

После расшифровки продукта сборки GHC для RunRepeat.hs я прихожу к такому выводу: GHC не будет step_lfsr(state_t*) вызов функции C step_lfsr(state_t*), тогда как компилятор C будет, и это имеет значение для этой игрушечной проблемы,

Я могу продемонстрировать это, запретив встраивание с помощью __attribute__ ((noinline)). В целом, исполняемый файл C замедляется, поэтому разрыв между Haskell и C закрывается.

Вот результаты:

=== RunRepeat =======
#iter:    100000000
Baseline: 0.334414
IO:       0.325433
factor:   0.9731440669349967
=== RunRepeatAlloca =======
#iter:    100000000
Baseline: 0.330629
IO:       0.333735
factor:   1.0093942152684732
=== RunRepeatLoop =====
#iter:    100000000
Baseline: 0.33195399999999997
IO:       0.33791
factor:   1.0179422450098508

Т.е. больше нет штрафа за вызовы FFI на lfsr_step.

=== RunAvg =========
#iter:    10000000
Baseline: 3.4072e-2
IO:       1.3602589999999999
factor:   39.92307466541442
=== RunAvgStreaming ===
#iter:    50000000
Baseline: 0.191264
IO:       0.666438
factor:   3.484388070938598

Хорошие старые списки не сливаются, поэтому огромный удар по производительности, и streaming библиотека также не оптимальна. Но Data.Vector.Fusion.Stream.Monadic получает в пределах 20% от производительности C:

=== RunVector =========
#iter:    200000000
Baseline: 0.705265
IO:       0.843916
factor:   1.196594188000255

Уже было замечено, что GHC не имеет встроенных вызовов FFI: "Как заставить GHC подключаться к FFI?" ,

В ситуациях, когда преимущество инкрустации настолько велико, т.е. рабочая нагрузка на вызов FFI настолько низок, возможно, стоит обратить внимание на inline-c.