Самый простой монадический "поток" - это всего лишь список монадических действий 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