cerealのgetFloat*多用はメモリ長者にのみ許されし贅沢

約1年も書いてなかったのか.


ここ2,3年はそこそこのサイズの配列をどうこうする機会が多く,そういうとき特に何も考えなければ Data.Vector に突っ込んでおきたいキモチになる.で,実際に大きな配列をHaskellで扱うとメモリ不足でOOM Killerに殺されたりするわけだけど,そんな原因のひとつについて.

サンプルコードはココにあるのでヒマな人はどうぞ.

stack init
stack build

まずputterというexecutableがそこそこ大きな要素数を持ったFloatの配列をlittle endianで出力する.

stack exec putter

これでカレントディレクトリにoutput.dat という50MBくらいのファイルができたかと.次にnaive-getterというexecutableでoutput.datを読み出す.やっていることは,Floatの配列に戻して総和を取り,元の配列の総和と一致するかを見ているだけ.

stack exec naive-getter

naive-getterの実装でByteStringからFloatのVector変換する部分は,次の通りcerealでgetFloat32leを使った脳直実装になっている.自然

runGetLazy (V.replicateM len getFloat32le) dat

しかし,コイツが想像以上にメモリをバカ食いするので,適当にプロファイルを見てみる.

stack build --profile
stack exec -- naive-getter +RTS -hc -p -s
stack exec -- hp2ps -c naive-getter.hp
open naive-getter.ps

するとこんな感じになる.すごい.

f:id:notogawa:20180902010856p:plain

8秒ちょいまではputterが出した配列をgetterでも再度作って総和とか評価してる部分なので無視するとして,Unboxed Vectorではないことを考慮しても以降のメモリ消費は立ち上がり過ぎている.どういうことかとgetFloat32leの実装を見に行くと,次のようになっている.

-- | Read a Float in little endian IEEE-754 format
getFloat32le :: Get Float
getFloat32le = wordToFloat <$> getWord32le

(snip.)

{-# INLINE wordToFloat #-}
wordToFloat :: Word32 -> Float
wordToFloat w = unsafeDupablePerformIO $ alloca $ \(ptr :: Ptr Word32) -> do
    poke ptr w
    peek (castPtr ptr)

1要素毎にコレやるわけだが,配列のようなコレクション中の要素を一気にたくさん変換するなら雑に次のような関数でいいかなと別の手段を用意する.

getFloat32leVector :: Int -> Get (V.Vector Float)
getFloat32leVector n = do
  v <- V.replicateM n getWord32le
  return $ unsafeDupablePerformIO $ alloca $ \ptr -> do
    V.mapM (\x -> poke ptr x >> peek (castPtr ptr)) v

これでgetFloat32leを置き換えて次のように使ったrevised-getterを作り,

runGetLazy (getFloat32leVector len)

こちらでもnaive-getter同様にprofileを取る.

stack build --profile
stack exec -- revised-getter +RTS -hc -p -s
stack exec -- hp2ps -c revised-getter.hp
open revised-getter.ps

結果はだいぶ減ったことがわかる.それでもまだ大富豪様ではあるわけだが

f:id:notogawa:20180902011918p:plain

別に総和くらいだったらストリーム処理すばいいんだろうけどそれは簡単のために総和を置いてるだけで,現実的にはもっと配列全体をガチャガチャする処理が控えているためそういうわけにも.