Thinking Functionally with Haskell勉強メモ: 第7章1 メモリ、計算量、`seq`
Thinking Functionally with Haskell全12章の折り返し地点に到達。第7章は最適化に関する話題。
メモリと計算量のトレードオフ
Haskellの評価戦略は完全にナイーブではない:
sqr (sqr (3+4)) = (sqr (3+4)) * (sqr (3+4)) = ((3+4) * (3+4)) * ((3+4) * (3+4)) = (7 * 7) * (7 * 7) = 49 * 49 = 2401
のようなダブった評価はしない。
sqr (sqr (3+4)) = let x = (sqr (3+4)) in x * x = let y = (3 + 4) in let x = y * y in x * x = let y = 7 in let x = y * y in x * x = let x = 7 * 7 in x * x = let x = 49 in x * x = 49 * 49 = 2401
と3+4
もsqr(7)
も一回ずつしか評価せず、同じ値を代入している。
しかし以下のような式の場合、ダブった部分を自動的にlet
やwhere
にくくり出すことはしない:
subseqs (x:xs) = subseqs xs ++ map (x:) subseqs xs
くくりだしてしまうとsubseqs xs
は一回評価した後ですべての結果をメモリ上に残しておく必要が生じる。そのトレードオフはプログラマが決定し明示的にコードに表す必要がある。
primesum
関数の定義
最小n
個の素数の和:
primesum :: Int -> Int primesum n = sum (take n primes) where primes = [x | x <- [2..], divisors x == [x]] divisors n = [d | d <- [2..n], n `mod` d == 0]
これだとprimesum
を呼び出すごとにprimes
が新しく作成され評価される。
primes
をwhere
に入れなければprimesum
を複数回呼ぶと同じprimes
から要素をとっていくので、評価された部分はメモリに残る。
primesum n = sum (take n primes) primes = [x | x <- [2..], divisors x == [x]] divisors n = [d | d <- [2..n], n `mod` d == 0]
問題点としては、primes
が関数の内部実装だとするとそれが外部に漏れているところと、メモリを圧迫しかねないところ。
前者はClosureを使うことで解決する:
primesum :: Int -> Int primesum = \n -> sum (take n primes) where primes = [x | x <- [2..], divisors x == [x]] divisors n = [d | d <- [2..n], n `mod` d == 0]
primesum
を無名関数の名前にし、primes
をその無名関数にバインドした。
これでprimesum
という関数が存在し続ける限りその内部では同一のprimes
リストが使われるが、関数の外部から直接primes
にアクセスすることはできないし名前衝突も起こらない。
メモリに関してはキャッシュ・メモ化して計算量を減らすのか、都度都度計算してメモリ消費を抑えるのか、やはりトレードオフである。
cp
関数の定義
リストのリストの直積を求めるcp
関数は過去の章で何回かでてきた。
素直な定義:
cp :: [[a]] -> [[a]] cp [] = [[]] cp (xs:xss) = [x:ys | x <- xs, ys <- cp xss]
これをpythonっぽく書くと:
for x in xs: for ys in cp xss: x:ys
なので外側のループが回る度にcp xss
が繰り返し評価されていることがわかる。
解決策としてはwhere
を使ってcp xss
をループの外に出す:
cp [] = [[]] cp (xs:xss) = [x:ys | x <- xs, ys <- yss] where yss = cp xss
あるいはそれをfoldr
化する:
cp = foldr f [[]] where f xs yss = [x:ys | x <- xs, ys <- yss]
上記二つの解決策はfoldr
の性質から同義。
個人的には、このケースだと:
cp [] = [[]] cp (xs:xss) = [x:ys | ys <- cp xss, x <- xs]
が一番簡単な気がするが・・・
seq
による先行評価
リストの数を足し合わせる関数sum
を評価してみる:
sum [1..5] = foldl (+) 0 [1..5] = foldl (+) (0+1) [2..5] = foldl (+) ((0+1)+2) [3..5] ... = foldl (+) (((((0+1)+2)+3)+4)+5) [] = (((((0+1)+2)+3)+4)+5) = ((((1+2)+3)+4)+5) ... = 10+5 = 15
一旦[1..5]
の要素がすべて(0+...)
式に書き出されてから評価されていくのがわかる。その時点ですべての要素はメモリに載っていなければいけない。これが「遅延評価のスペースリーク」。
このケースだと、和を求めるところだけ部分的に先行評価していきたい。そういった場合、関数seq
を使う。
seq
はHaskellの言語仕様レベルで提供されている関数で、「特定の評価順を指定する」ためだけに存在する。
seq :: a -> b -> b seq n m = m
で、式の値は第二引数の値となる。ただし、第二引数が評価される前に必ず第一引数がhead normal formまで評価される(head normal formについては補足メモ1を参照)。
foldl'
をseq
を使って定義
さて、foldl
を使うと評価過程で「最終引数であるリスト」の内部をすべて書き出した式が展開されてしまいスペースリークが起きる。
seq
を使ってその問題を避けるfoldl'
を定義する:
foldl' :: (b -> a -> b) -> b -> [a] -> b foldl' f e [] = e foldl' f e (x:xs) = y `seq` (foldl' f y xs) where y = f e x
これで(foldl' f y xs)
部分が展開される前にf e x
がhead normal formまで評価されるようになる。
sum = foldl' (+) 0
とするとO(1)スペースで総和が評価できる。
foldl'
は基本的にfoldl
の上位互換となるが、foldl f e
のf
がstrictでないと同値にならない場合があり、そういったケースでは普通のfoldl
を使わざるをえないこともある。
mean'
をseq
を使って定義
平均を求めるmean
関数:
mean :: [Float] -> Float mean [] = 0 mean xs = sum xs / fromIntegral (length xs)
上記の定義だとsum
のあとにxs
の値はすべてメモリに載っていることになる(length
でもxs
を使うので)。
それを避けるために以下のように定義を変えるとする:
mean :: [Float] -> Float mean [] = 0 mean xs = s / fromIntegral n where (s, n) = sumlen xs sumlen :: [Float] -> (Float, Int) sumlen = foldl' g (0, 0) where g (s, n) x = (s+x, n+1)
残念ながらこれもスペースリークを引き起こす。
評価してみると:
sumlen [1..5] = foldl' g (0, 0) [1..5] = x `seq` (foldl' g x [2..5]) where x = (g (0, 0) 1) = foldl' g (0+1, 0+1) [2..5] = x `seq` (foldl' g x [3..5]) where x = (g (0+1, 0+1) 2) = foldl' g ((0+1)+2, (0+1)+1) [3..5] ... = foldl' g ((((((0+1)+2)+3)+4)+5), (((((0+1)+1)+1)+1)+1) [] = ((((((0+1)+2)+3)+4)+5), (((((0+1)+1)+1)+1)+1) ... = (15, 5)
展開された式がO(n)のスペースをとっていることが確認できる。
ポイントはここ:
x `seq` (foldl' g x [2..5]) where x = (g (0, 0) 1) = foldl' g (0+1, 0+1) [2..5]
0+1
はhead normal formではないが、(0+1, 0+1)
はhead normal formなのである。
sumlen
の中でもseq
を使って先行評価させる必要がある。Birdの最終的な解:
mean :: [Float] -> Float mean [] = 0 mean xs = s / fromIntegral n where (s, n) = sumlen xs sumlen :: [Float] -> (Float, Int) sumlen = foldl' g (0, 0) where g (s, n) x = s `seq` n `seq` (s+x, n+1)
個人的には上記だとtuple内にかならず一回分のreduction
を残しているので
sumlen :: [Float] -> (Float, Int) sumlen = foldl' g (0, 0) where g (s, n) x = y `seq` z `seq` (y, z) where {y = s+x; z = n+1}
のほうがサプライズがなくていい気がするのだがどうだろう。
計算量の算出
GHCの最適化に関してのドキュメントをまとめると:
- ちゃんとプロファイルしよう
- アルゴリズムが一番大事
- ライブラリを使おう
とのこと。あとはType Classではなく具体的なTypeに落とし込めるところは落とし込むのと、できるところはstrict
でやっていく、の二点。
ある関数f
の入力量n
に対する漸近的な計算量をと表すとする。ただし、はf
の特定の定義・実装における先行評価下の計算量とする。
遅延評価だと算出が困難なことが理由。先行評価における漸近的上限は必ず遅延評価における漸近的上限であり、また下限も多くの場合一致する。
一例としてconcat
をfoldr
とfoldl
で定義した場合の計算量の変化を見てみる:
concat xss = foldr (++) [] xss T(concat)(m, n) = T(foldr (++) []) (m, n) -- n長のリストm個をconcatする場合の漸近的コスト T(foldr (++) []) (0, n) = O(1) T(foldr (++) []) (m+1, n) = T((++))(n, mn) + T(foldr (++) []) (m, n) T((++))(n, mn) = O(n) -- n長のリストとmn長のリストを++する場合はコストはnに比例する
T(foldr (++) [])(m, n) =
そして
concat' xss = foldr (++) [] xss T(concat')(m, n) = T(foldl (++)) (0, m, n) -- (k, m, n)のk値はfoldlのeの長さ T(foldr (++)) (k, 0, n) = O(1) T(foldr (++)) (k, m+1, n) = T((++))(k, n) + T(foldl (++)) (k+n, m, n) T((++))(k, n) = O(k)
T(foldl (++))(k, m, n) =
T(foldl (++))(0, m, n) =
というわけでconcat
をfoldl
で定義するとfoldr
で書いたものよりも漸近的に遅い。concatするリストの数m
に対して、foldr
は、foldl
は。
補足メモ1:head normal form
an expression is said to be in head normal form if it is a function ... or if it takes the form of a data constructor ... applied to its arguments
Every expression in normal form (i.e. in fully reduced form) is in head normal form but not vice versa
この部分はよくわからない。いきなり矛盾しているように思える。例えば1
という式はnormal formだが、functionでもdata constructor applied to its argumentsでもない。
正しくは
an expression is said to be in head normal form if it is either 1) in fully reduced form, 2) a function ... or 3) if it takes the form of a data constructor ... applied to its arguments
なのでは?
補足メモ:faster by a logarithmic factor
のアルゴリズムがより
asymptotically faster by a logarithmic factor
と書いてあって少し戸惑った。のが倍になったときの計算量の増加は、のが倍になったときの増加と等しい、ってことか。