Самый простой монадический "поток" - это всего лишь список монадических действий 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
- Тот факт, что LFSR выбраны в качестве игрушечной проблемы, не означает, что Haskell не в состоянии сделать это эффективно - см. Вопрос SO "Эффективное битово в реализации LFSR".
- Итерирование 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