Сжатие большей производительности из монадических потоков в 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
- Тот факт, что 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
Ответы
Ответ 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
.