Haskell Quiz No.6

難易度: λλ

以下の Conduit を使ったコードの実行結果を予想してみてください!

#!/usr/bin/env stack
-- stack script --resolver lts-11.2
import Conduit

trans :: Monad m => ConduitM Int Int m ()
trans = do
  takeC 5 .| mapC (+ 1)
  mapC (* 2)

main :: IO ()
main = runConduit $ yieldMany [1..10] .| trans .| mapM_C print

答えは次回

最近は Conduit にはまっているので、クイズも Conduit が続きます。

はじめに

前回の問題と答えは以下の通りです。

問題

難易度: λλ

以下の Conduit を使ったコードの実行結果を予想してみてください!

#!/usr/bin/env stack
-- stack script --resolver lts-11.0
import Conduit

sink :: Monad m => ConduitM Int o m (String, Int)
sink = do
  x <- takeC 5 .| mapC show .| foldC
  y <- sumC
  return (x, y)

main :: IO ()
main = do
  let res = runConduitPure $ yieldMany [1..10] .| sink
  print res

こたえ

実際に実行してみましょう!

$ ./Quiz5.hs
("12345",40)

どうですか?予想通りでしたか??

Haskell Quiz No.5 の解説

この問題を解くためには conduit というストリーム処理ライブラリの知識が必要になります。

Conduit を使うモチベーション

具体例として指定したディレクトリ以下のファイル数容量の合計を出力するようなプログラムを作ってみましょう。

ディレクトリ操作については directory パッケージに便利な関数が色々と定義されているので、このパッケージを利用します。

必要な操作と、対応する関数は以下の通りです。

これらの関数を使って、こんな感じでプログラムを作ることができます。

#!/usr/bin/env stack
{-
stack script --resolver lts-11.3
  --package extra
  --package filepath
  --package directory
-}

import System.Environment (getArgs)
import System.Directory (listDirectory, doesFileExist, getFileSize)
import System.FilePath ((</>))
import Control.Monad.Extra (partitionM, ifM)
import Control.Monad (when)

main :: IO ()
main = do
  arg <- getArgs
  when (length arg == 1) $ do
    (cnt, size) <- recListDir $ head arg
    putStrLn $ "総ファイル数: " ++ show cnt
    putStrLn $ "総ファイルサイズ: " ++ show size

recListDir :: FilePath -> IO (Int, Integer)
recListDir fp = loop (0, 0) [fp]
  where
    loop summary [] = return summary
    loop (accCnt, accSize) (fp:fps) = do
      dirs <- listDirectory fp
      (files, childDirs) <- partitionM doesFileExist $ map (fp </>) dirs
      size <- sum <$> mapM getFileSize files
      let summary = (accCnt + length files, accSize + size)
      loop summary $ fps ++ childDirs

実際に、プロファイリングを取得しつつ動かしてみます。

$ stack ghc Ex && sudo ./Ex /home/bm12/Desktop/ +RTS -s
総ファイル数: 338866
総ファイルサイズ: 37870090712

とりあえず、上手く動いているような気がします。

しかし、メモリ使用量は・・・

  13,440,124,048 bytes allocated in the heap
   8,760,418,592 bytes copied during GC
   1,225,650,008 bytes maximum residency (23 sample(s))
      19,423,400 bytes maximum slop
            2599 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0      9869 colls,     0 par    7.821s   9.831s     0.0010s    1.1223s
  Gen  1        23 colls,     0 par    0.011s   0.013s     0.0006s    0.0009s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    5.255s  (  6.347s elapsed)
  GC      time    7.832s  (  9.845s elapsed)
  EXIT    time    0.032s  (  0.123s elapsed)
  Total   time   13.118s  ( 16.315s elapsed)

  %GC     time      59.7%  (60.3% elapsed)

  Alloc rate    2,557,607,298 bytes per MUT second

  Productivity  40.3% of total user, 39.7% of total elapsed
  • 2599 MB total memory in use
  • %GC time 59.7% (60.3% elapsed)

ということで、非常にやばいですね。

Conduit で書き直そう!

先程作ったプログラムは、どうやらスペースリークしているようです。指定したディレクトリ以下のファイルの数とファイルサイズの合計を取得するだけなのに、メモリを使いすぎですね。

解決方法は色々ありますが、今回はストリームライブラリの Conduit を使って解決していきましょう。

Conduit には sourceDirectoryDeep という、関数が用意されています。

だいたいこんな感じで書き直すことができます。先程の定義と比べると sourceDirectoryDeep 関数のおかげでスッキリした印象です。

#!/usr/bin/env stack
{-
stack script --resolver lts-11.3
  --package conduit
  --package extra
  --package directory
-}

import Conduit

import System.Environment (getArgs)
import System.Directory (doesFileExist, getFileSize)
import Control.Monad.Extra (whenM)
import Control.Monad (when)

main :: IO ()
main = do
  arg <- getArgs
  when (length arg == 1) $ do
    (cnt, size) <-
      runConduitRes $ sourceDirectoryDeep True (head arg)
                   .| awaitForever getInfo
                   .| getZipSink ((,) <$> ZipSink lengthC <*> ZipSink sumC)
    putStrLn $ "総ファイル数: " ++ show cnt
    putStrLn $ "総ファイルサイズ: " ++ show size

getInfo :: MonadResource m => FilePath -> ConduitM FilePath Integer m ()
getInfo path =
  whenM (liftIO $ doesFileExist path) $ do
    size <- liftIO $ getFileSize path
    yield size

では、同様にプロファイルを取得しつつ、実行してみましょう。

$ stack ghc Ex2 && sudo ./Ex2 /home/bm12/Desktop/ +RTS -s
総ファイル数: 338866
総ファイルサイズ: 37870092264

肝心のメモリ使用量はと言うと・・・

  10,742,224,392 bytes allocated in the heap
      86,720,088 bytes copied during GC
          87,576 bytes maximum residency (19 sample(s))
          33,320 bytes maximum slop
               3 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0     10347 colls,     0 par    0.146s   0.198s     0.0000s    0.0008s
  Gen  1        19 colls,     0 par    0.000s   0.001s     0.0000s    0.0001s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    5.252s  (  7.444s elapsed)
  GC      time    0.146s  (  0.198s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    5.398s  (  7.642s elapsed)

  %GC     time       2.7%  (2.6% elapsed)

  Alloc rate    2,045,428,118 bytes per MUT second

  Productivity  97.3% of total user, 97.4% of total elapsed
  • 3 MB total memory in use
  • %GC time 2.7% (2.6% elapsed)

どうですか?ストリーム処理って凄いですよね。

解説

この問題の重要なポイントは、実行すると ("12345", 6+7+8+9+10) という結果のように、[1..10] のリストの前半と後半で異なる処理になっているという点です。

$ ./Quiz5.hs
("12345",40)

ここで理解しておきたい知識は以下の3点です。

  • データはパイプ (ストリーム) を流れて処理されます
  • yieldMany 関数は受け取ったデータをパイプに流す準備をします (yieldMany は自分から積極的にデータを流すことはしません。準備だけしておき await 関数などで、実際に必要になった際にだけデータを流します)
  • .| はパイプを合成します
main :: IO ()
main = do
  let res = runConduitPure $ yieldMany [1..10] .| sink
  print res

sink 関数は takeC 5 .| mapC show .| foldC というパイプと sumC というパイプからなる、大きなパイプです。

takeC 5 .| mapC show .| foldC 関数は takeC 5 の部分でデータを 5つだけ 上流のパイプに要求します。そのため、残りの5つのデータは次の sumC に流れることになります。

sink :: Monad m => ConduitM Int o m (String, Int)
sink = do
  x <- takeC 5 .| mapC show .| foldC -- 1,2,3,4,5  のデータが処理される
  y <- sumC                          -- 6,7,8,9,10 のデータが処理される
  return (x, y)

そのため、最終的には ("12345",40) となりました。

"12345" はそれぞれの Int 型が mapC show によって String 型に変換され、foldCmappend による畳込みによって文字列連結されます。

まとめ

実用的なアプリケーションを作ろうと考えている方は Conduit などのストリームライブラリを理解していると、色々と面倒なことを考えなくて済むのでとても良いですよ。

以上です。