Strict와 Lazy

Posted on June 20, 2021

※ 웹서핑으로 하스켈 자료를 찾다 보면 fpcomplete 회사를 피할 수 없을 정도로, 하스켈 생태계에선 중요한 역할을 하고 있는 회사로 보입니다. 이 회사의 설립자 마이클 스노이먼Michael snoyman이 주 개발자로 있는 프로젝트가 stack, stackage, conduit, yesod입니다. 뒤에 둘은 몰라도 앞에 둘 은 생태계에서 매우 중요한 도구로 보입니다. fpcomplete에 올라와 있는 스노이먼의 글들은 아카데미보다는 실무자를 위한 글들이 많아 저 같은 사람한테는 보물 창고 같은 사이트입니다.

이 글은 https://www.fpcomplete.com/blog/2017/09/all-about-strictness/ 글을 의역하며 주석을 추가한 정도의 글입니다. 요약하며 빠뜨린 문장들도 있고 대부분 의역이라, 완전한 번역 페이지는 아닙니다. 원 글을 읽으며 보조적으로 읽으면 좋습니다.

단순한 번역 페이지는 올리지 않으려고 노력하는데, 내공이 부족해서 여기 내용들을 아직 편하게 가지고 놀지 못하겠습니다.

메모리와 퍼포먼스에 민감한 코드를 짜려면, 하스켈의 장점으로 꼽히는 lazy를 오히려 적절하게 빼내면서 작업해야 합니다. 이러기 위해 아래 항목들을 중요 토픽으로 뽑고 있습니다.

WHNF vs NF
seq, deepseq
타입에 Strictness1 표시 달기
Bang patterns - 느낌표exclamation mark !를 Bang이라 부릅니다.
Bang은 seq 함수의 편의 문법syntactic sugar입니다.
데이터 구조의 Strictness: lazy, spine(가시,등골)-strict, 그리고 value-strict
적절한 보조helper 함수 고르기. 특히 folds를 쓸 때.

사전에서 프로그래밍과 관련되지 않은 예문들을 보면 reduce나 execution의 의미는 보이지 않긴 합니다.

  1. 지연 평가 기본 개념
  2. Bang!
  3. seq의 평가 순서
  4. bottom
  5. 소모하는 메모리 체크 방법
  6. Bang을 이용해서 메모리 적게 쓰기
  7. seq는 어디까지 평가하지? WHNF 까지만!
  8. deepseq
  9. Strict 데이터
  10. newtype
  11. $!, $!! 연산자와 force 함수
  12. spine strict
  13. Vector
  14. Sets, Maps
  15. 함수 인자
  16. Folds
  17. Streaming data
  18. Chain reaction

지연 평가 기본 개념

strict한 C언어를 먼저 살펴 보면

#include <stdio.h>

int add(int x, int y) {
  return x + y;
}

int main() {
  int five = add(1 + 1, 1 + 2); // (가) 
  int seven = add(1 + 2, 1 + 3); 

  printf("Five: %d\n", five);
  return 0;
}

(가)의 reduce 절차를 보면,

add (1 + 1, 1 + 2)
add (2, 3)
2 + 3
five변수가 가리키는 메모리에 5를 넣어 둡니다.
seven변수가 가리키는 메모리에 7을 넣어 둡니다.
printf를 실행해서 five를 출력합니다.

lazy한 하스켈을 보면

main :: IO ()
main = do
  let five = add (1 + 1) (1 + 2) -- (나)
      seven = add (1 + 2) (1 + 3)
  putStrLn $ "Five: " ++ show five

※ 필요할 때, 특정 시점에 평가2가 작동하게 하는 걸 forcing evaluation이라 합니다.
(나)의 reduce 절차를 보면, putStrLn이 five가 가리키는 thunk가 먼저 평가되게(forcing evaluation)합니다.
평가해야만 하는 상황이 돼서, 평가를 시작하는 걸 force evaluation이라 말하는데, 평가를 강제로, 또는 강요한다는 번역보다는, thunk 상태로 멈춰 있던 걸 평가 시작한다가 더 어울립니다.

five = thunk, seven = thunk

-- putStrLn에 도달해서 five 값이 필요해지면
five = add (thunk, thunk)
return thunk + thunk -- add 함수 body 실행 
return (1 + 1) + thunk
return (1 + 1) + (1 + 2)
return 2 + 3
return 5

-- seven은 그대로 thunk
seven = thunk

lazy가 seven을 계산하지 않아 장점만 있어 보이지만, 꼭 그렇진 않습니다. thunk를 만드는데도 비용이 들어갑니다. 메모리를 할당하고, 나중에 해제도 해야 됩니다. five thunk에 드는 비용을 보면, (메모리 비용을 바이트로 표시하지 않고, 몇 비트 장비냐에 따라 달라지니, 정보를 저장하는 최소 단위를 machine word라 표현했습니다.)

thunk임을 나타내는 1 machine word,
thunk 안에 add 함수를 가리키는 포인터, (1 + 1) thunk를 가리키는 포인터, (1 + 2) thunk를 가리키는 포인터 합해서 3 machine word,
(1 + 1) thunk 안에도 마찬가지로 (+) 함수를 가리키는 포인터, 1을 가리키는 포인터, 1을 가리키는 포인터 합해서 3 machine word,
(1 + 2) thunk 안에도 마찬가지로 (+) 함수를 가리키는 포인터, 1을 가리키는 포인터, 2을 가리키는 포인터 합해서 3 machine word,
모두 합해서 10 machine word

※ 정수 관련해선 최적화가 이루어지므로 딱 이대로 동작하진 않지만, 이론상 이렇게 보면 됩니다.

C언어와 비교하면 10배의 메모리를 씁니다.

Bang!

느낌표를 bang이라 부르는데, BangPatterns 확장을 켜면 !를 추가해서 strict하게 동작을 바꿀 수 있습니다.

{-# LANGUAGE BangPatterns #-}
add :: Int -> Int -> Int
add !x !y = x + y

main :: IO ()
main = do
  let !five = add (1 + 1) (1 + 2)
      !seven = add (1 + 2) (1 + 3)

  putStrLn $ "Five: " ++ show five

!를 붙이는 위치가 헛갈리는데, !를 붙인 건 thunk로 놔둘 수 없다 정도로 생각하고 진도 나가겠습니다. five = thunk 상태가 안된다는 말입니다. !seq 함수의 편의 문법입니다.

seq :: a -> b -> b

b가 평가될 때 항상 a도 평가되는 걸 보장하기 위해 GHC의 primitive operation3을 씁니다. bang 편의 문법이 아니라 seq로 쓰면

add :: Int -> Int -> Int
add x y =
  let part1 = seq x part2
      part2 = seq y answer
      answer = x + y
   in part1
part1 = part2가 평가될 때 x도 평가되는 표현식
part2 = answer가 평가될 때 y도 평가되는 표현식
answer = x + y

thunk 용어를 써서 설명하면, add 함수는 seq가 없다면, thunk + thunk 까지만 평가하고 넘어가는데, 이렇게 seq를 쓰면 “thunk 상태로 머물 수 없다”로 해석하면 됩니다.

설명을 위해 길게 풀어 쓴 거고, 보통은 중위 연산자로 씁니다.

add x y = x `seq` y `seq` x + y

add를 만났다고 무조건 평가한다는 말이 아닙니다. let result = add 1 2를 만나면 add를 평가하기 전에 result = thunk 상태로 머무는데, result가 forcing evaluation 되면 그 때 seq가 작동합니다. (위에 bang이 들어간 let !five = add ...five = thunk가 안된다는 말이니, 바로 평가가 시작됩니다. bang이 쓰여진 위치에 따라 다릅니다.)

main :: IO ()
main = do
  let five = add (1 + 1) (1 + 2)
      seven = add (1 + 2) (1 + 3)
  five `seq` seven `seq` putStrLn ("Five: " ++ show five)

seq의 평가 순서

{-# LANGUAGE BangPatterns #-}
import Debug.Trace

main :: IO ()
main = do
  let !five = trace "five" (add (1 + 1) (1 + 2))
      !seven = trace "seven" (add (1 + 2) (1 + 3))
  putStrLn $ "Five: " ++ show five

이렇게 하면 항상 "five"가 출력되고, 그 다음 "seven"이 출력되야 할 것 같은데, 거꾸로 출력되기도 하고, 어쩔 땐 섞여서 출력되기도 합니다. 반면에, five \seq` seven `seq` putStrLn (“Five:” ++ show five)라고 쓰면 순서대로“five”,“seven”,“Five: 5”로 출력됩니다. bang 패턴은seq`의 편의 문법이라 했는데, 출력 결과가 다릅니다. 원문에는 다음과 같이 쓰여 있는데, 완전히 편의 문법으로 봐도 되는 건지 아닌지 얘기가 따로 없습니다.

This gives a bit of a lie to my claim that bang patterns are always a simple translation to seqs. However, the fact is that with an expression x seq y, GHC is free to choose whether it evaluates x or y first, as long as it ensure that when that expression finishes evaluating, both x and y are evaluated.

seq x y에서 x,y가 pure하면 사실 먼저 x를 평가하고 y를 평가하든, y를 평가하고 x를 평가하든 결과가 같게 나옵니다. (좀 헛갈릴 때는 이렇게 생각해 보세요. x를 평가하려면 y의 평가 결과가 필요한 상황이었다면 굳이 seq를 쓰지 않아도 됩니다. 런타임이 알아서 순서대로 평가합니다. seq는 서로 직접적인 선후 관계가 없는 상태에서, 강제로 평가를 해서 메모리를 적게 쓰기위한 장치입니다.) 위의 경우는 trace를 써서 impure한 함수가 끼어들었기 때문에 순서에 민감해 진 것이지, pure한 함수끼리 묶는다면 평가 순서는 상관이 없습니다.

bottom

인자를 strict하게 처리하는지, lazy하게 처리하는지 궁금하면 bottom을 의미하는 undefined 함수를 넣어보면 압니다.
참고로, 하스켈은 기본이 lazy 전략을 쓰며, strict가 필요할 때 bang!을 씁니다. 다른 strict가 기본인 언어들은 closure를 이용하여 lazy를 구현할 수 있습니다. 원문에서 rust로 구현한 걸 보여줍니다. 지금은 rust에 손 댈 여력이 없어, rust 소스를 굳이 옮기지 않겠습니다.

소모하는 메모리 체크 방법

data RunningTotal = RunningTotal
  { sum :: Int
  , count :: Int
  }

printAverage :: RunningTotal -> IO ()
printAverage (RunningTotal sum count)
  | count == 0 = error "Need at least one value!"
  | otherwise = print (fromIntegral sum / fromIntegral count :: Double)

-- | A fold would be nicer... we'll see that later
printListAverage :: [Int] -> IO ()
printListAverage =
  go (RunningTotal 0 0)
  where
    go rt [] = printAverage rt
    go (RunningTotal sum count) (x:xs) =
      let rt = RunningTotal (sum + x) (count + 1)
       in go rt xs

main :: IO ()
main = printListAverage [1..1000000]

lazy하게 동작하는 코드입니다. 메모리 사용량을 보려면

$ stack ghc average.hs && ./average +RTS -s

[1 of 1] Compiling Main             ( average.hs, average.o )
Linking average ...
500000.5
     258,654,528 bytes allocated in the heap
     339,889,944 bytes copied during GC
      95,096,512 bytes maximum residency (9 sample(s))
       1,148,312 bytes maximum slop
             164 MB total memory in use (0 MB lost due to fragmentation)

258MB를 할당하고, 한 번에 최대 95MB가 메모리에 머뭅니다.

Bang을 이용해서 메모리 적게 쓰기

위 코드 중 한군데에 bang을 붙이면 간단히 해결됩니다. 루프를 돌기 전에 thunk로 남아 있을 식을 strict하게 평가해서 값으로 유지하게 하면 됩니다.

go !rt [] = printAverage rt
go (RunningTotal sum count) (x:xs) =
  let rt = RunningTotal (sum + x) (count + 1)
   in go rt xs

마치 이렇게 하면 될 것처럼 보이지만, 이렇게 쓰면 위와 같은 양의 메모리를 씁니다. 왜 그런지 보려면 WHNF에 대해서 알아야 합니다.

seq는 어디까지 평가하지? WHNF 까지만!

참고 - Weak Head Normal Form, Stack Overflow 답변

main = putStrLn $ undefined `seq` "Hello World"

위 코드는 예상대로 에러가 나지만

main = putStrLn $ Just undefined `seq` "Hello World"

값 생성자로 한 번 감싸져 있는 undefined는 에러가 나지 않습니다. seq 동작을 설명하는 forcing evaluation이 thunk가 하나도 남지 않을 때(이 걸 Normal Form, NF라 합니다.)까지 evaluate하는 걸 의미하진 않습니다. seq에서 얘기하는 forcing evaluation은 WHNF 한 단계까지만 평가합니다. 많은 경우, 한 단계란 생성자 하나를 벗겨내는 작업을 의미합니다. seqJust까지만 벗겨내고 안에 들어있는 건 건드리지 않습니다.

보통의 값 생성자로 둘러 쌓인 값을 seq에 넣는 건, 가장 바깥 쪽 생성자와 패턴 매칭하는 것과 같은 동작을 합니다.

다음 중 첫 번째, 두 번째는 에러가 나지 않지만, 세 번째는 에러가 납니다. 이유를 알려면 WHNF를 알아야 합니다.

main = do
  putStrLn $ error `seq` "Hello"
  putStrLn $ (\x -> undefined) `seq` "World"
  putStrLn $ error "foo" `seq` "Goodbye!"

seq가 forcing evaluation 하는 건 WHNF까지입니다. 인자를 모두 받지 않은 함수나 람다식은 이미 WHNF입니다. 하지만 세 번째 error "foo"는 필요한 인자를 모두 받았기 때문에 값으로 reduce됩니다. (값에 가까워지게 식을 푸는 작업은 평가eval보다는 reduce란 말이 더 직관적이긴 합니다.)

이제 WHNF를 알았으니 위 !rt 코드를 다시 보면,

go !rt [] = printAverage rt
...

rtRunningTotal 생성자로 감싸져 있으므로, rt에 bang을 붙여봤자, RunningTotal 생성자만 벗겨내고 안에 있는 sum, count는 건드리지 않습니다. 여전히 thunk로 남아 있다는 말입니다. 이걸 해결하려면

go rt [] = printAverage rt
go (RunningTotal !sum !count) (x:xs) =
  let rt = RunningTotal (sum + x) (count + 1)
   in go rt xs

이렇게 하면 loop를 돌 때마다 sumcount를 thunk로 놔두지 못하고, 계속 평가하게 됩니다. (sum + x 는 WHNF가 아니니, WHNF가 되게 평가한다는 뜻입니다.)

deepseq

seq처럼 WHNF까지만 평가하는게 아니라, 아예 NF에 도달할 때까지 평가하는게 필요할 때가 있습니다. 그래야 thunk를 아예 남기지 않고, 정말 strict하게 값을 유지해서 메모리를 최소한만 사용하게 할 수 있습니다. 하스켈에 빌트되어 있는 이런 기능은 없지만, semi-standard4 라이브러리가 있습니다. rnf(Reduce a value to Normal Form) 메소드를 가진 NFData 타입 클래스입니다.

class NFData a where
  rnf :: a -> ()

rnf는 NF가 될 때까지 reduce하고 ()를 리턴합니다.

{-# LANGUAGE BangPatterns #-}
import Control.DeepSeq

data RunningTotal = RunningTotal
  { sum :: Int
  , count :: Int
  }
instance NFData RunningTotal where
  rnf (RunningTotal sum count) = sum `deepseq` count `deepseq` ()

printAverage :: RunningTotal -> IO ()
printAverage (RunningTotal sum count)
  | count == 0 = error "Need at least one value!"
  | otherwise = print (fromIntegral sum / fromIntegral count :: Double)

-- | A fold would be nicer... we'll see that later
printListAverage :: [Int] -> IO ()
printListAverage =
  go (RunningTotal 0 0)
  where
    go rt [] = printAverage rt
    go (RunningTotal sum count) (x:xs) =
      let rt = RunningTotal (sum + x) (count + 1)
       in rt `deepseq` go rt xs

main :: IO ()
main = printListAverage [1..1000000]

RunningTotal안에 들어있는 값 sumcount를 모두 deepseq하게 처리하는 rnf 메소드를 정의해서 NFData의 인스턴스로 만듭니다. 하스켈을 계속 공부하다 보면, 이렇게 값 생성자 안에 들어 있는 모든 값들을 thunk로 두지 않게 만드는 처리가 자주 필요하게 됩니다. GHC의 DeriveGeneric 확장을 이용하면 프로그래머가 직접 NFData의 인스턴스로 만들지 않고, GHC에게 작업을 떠넘길 수 있습니다.

{-# LANGUAGE DeriveGeneric #-}
import GHC.Generics (Generic)
import Control.DeepSeq

data RunningTotal = RunningTotal
  { sum :: Int
  , count :: Int
  }
  deriving Generic
instance NFData RunningTotal

GHC 7.10부터 도입된 DeriveAnyClass 확장을 쓰면 더 간단하게 쓸 수 있습니다.

{-# LANGUANGE DeriveGeneric, DeriveAnyClass #-}
data RunningTotal = ... deriving (Generic, NFData)

보통 메모리를 효율적으로 쓰기 위해 lazy를 걷어낼 때, NFData 클래스를 쓰는데 다른 부수적인 효과로 값을 나타내는 thunk안에 예외가 들어가는 걸 피하는 효과가 생깁니다. safe-exceptions 라이브러리의 tryAnyDeep 함수 참고

Q. 어디서 rnf를 부르고 있을까요?
A. deepseq 내부에서 부릅니다.

deepseq :: NFData a => a -> b -> b
deepseq a b = rnf a `seq` b

Q. NFData 인스턴스만 deepseq를 부를 수 있습니다. 그럼 deepseqNFData의 메소드로 만들지 왜 rnf를 메소드로 만들었을까요?
A. rnf가 단독으로 쓰일 수도 있고, 다른 함수에서도 쓰고 있을거라 추측할 수 있습니다.

Strict 데이터

lazy하게 RunningTotal값을 가지고 있다는 말은,
두 개의 Int를 가지고 있을 수도 있고,
Int를 만들어 낼 thunk를 가지고 있을 수도 있고,
예외를 던질 thunk를 가지고 있을 수도 있다는 말입니다.

RunningTotal이 아예 lazy한 동작을 못하게 막으려면, 데이터 타입을 선언할 때 strictness annotations를 쓰면 됩니다.

data RunningTotal = RunningTotal
  { sum :: !Int
  , count :: !Int
  }
  deriving Generic

printAverage :: RunningTotal -> IO ()
printAverage (RunningTotal sum count)
  | count == 0 = error "Need at least one value!"
  | otherwise = print (fromIntegral sum / fromIntegral count :: Double)

-- | A fold would be nicer... we'll see that later
printListAverage :: [Int] -> IO ()
printListAverage =
  go (RunningTotal 0 0)
  where
    go rt [] = printAverage rt
    go (RunningTotal sum count) (x:xs) =
      let rt = RunningTotal (sum + x) (count + 1)
       in go rt xs

main :: IO ()
main = printListAverage [1..1000000]

이렇게 데이터 선언에 bang을 붙여 놓으면 RunningTotal을 다음과 같이 쓰겠다는 말입니다.

“RunningTotal 타입의 값을 평가할 때는, 안에 들어 있는 두 개의 Int도 항상 평가해야 한다.”

Q. 안에 들어 있는 Int에 bang을 붙이면, 굳이 deriving Generic을 안해도 되지 않을까요?

Int 같이 작은 값들은 굳이 RunningTotal thunk안에서도 포인터로 가리키거나 하지 않고, Int 자체를 가지고 있도록 해서 메모리 효율을 높인다고 합니다.

언제 bang을 써서 strict로 만들지 애매할 수 있는데, 원 글에선 다음과 같이 권장하고 있습니다.

명확하게 필드가 lazy한 동작을 하는 걸 원하지 않을 땐 모두 strict로 바꾸라고 합니다. 이렇게 하면

여기서 본 것처럼 메모리 사용률을 낮출 수 있고,
bottom값이 들어가는 걸 막을 수 있고,
record 문법을 쓸 때, strict 필드를 빠뜨리면 GHC가 알아서 알려 줍니다. (기본값은 non-strict 필드가 채워지지 않으면 경고를 보냅니다.)

newtype

data Foo = Foo Int
data Bar = Bar !Int
newtype Baz = Baz Int

1. case undefined of 
    Foo _ -> putStrLn "Still alive!"
    -- undefined 와 Foo _ 매칭 -- 예외

2. case Foo undefined of 
    Foo _ -> putStrLn "Still alive!"
    -- Foo undefined 와 Foo _ 매칭 - undefined는 홀과 매칭되어 버려집니다. -- OK

3. case undefined of 
    Bar _ -> putStrLn "Still alive!"
    -- undefined 와 Bar _ 매칭 -- 예외

4. case Bar undefined of 
    Bar _ -> putStrLn "Still alive!"
    -- Bar undefined 와 Bar _ 매칭 - 홀과 매칭되긴 하지만 
    -- bang이 붙어 있어 평가가 이루어져 예외가 발생합니다.

5. case undefined of 
    Baz _ -> putStrLn "Still alive!"
    -- newtype Baz는 생성자 Baz가 런타임에 사라집니다.
    -- undefined 와 _ 를 매칭하는 것과 같습니다.  -- OK

6. case Baz undefined of { Baz _ -> putStrLn "Still alive!" }
    -- 위와 마찬가지로 newtype Baz를 지우면
    -- undefined 와 _ 매칭

5번은 newtype은 왜 필드 하나만 갖는 생성자 하나만 있는 타입일까? 참고

$!, $!! 연산자와 force 함수

$! 연산자
함수와 인자를 받아, 인자를 WHNF가 되게 평가하고, 함수를 적용합니다.

f $! x = let !vx = x in f vx 
mysum :: [Int] -> Int
mysum list0 =
  go list0 0
  where
    go [] total = total
    go (x:xs) total = go xs $! total + x

main = print $ mysum [1..1000000]

total + x$! 연산자를 적용해서 thunk로 남아 있을 수 없습니다.

$!! 연산자는 $! 연산자와 같은 동작을 하는데, seq 대신 deepseq를 씁니다.

infixr 0 $!!
($!!) :: (NFData a) => (a -> b) -> a -> b
f $!! x = x `deepseq` f x

force 함수는 WHNF로 평가되는 걸 NF가 될 때까지 평가하도록 바꿉니다.

force :: NFData a => a -> a
force x = x `deepseq` x
go [] (total, count) = fromIntegral total / count
go (x:xs) (total, count) = go xs $! force (total + x, count + 1)

Q. $! force$!! 와 같은 것 아닌가요?

spine strict

data List a = Cons a (List a) | Nil
main = Cons undefined undefined `seq` putStrLn "Hello World"
-- main = (undefined:undefined) `seq` putStrLn "Hello World"

Cons undefined undefined는 이미 WHNF여서 평가하지 않으므로 OK

data List a = Cons a !(List a) | Nil
main = Cons undefined undefined `seq` putStrLn "Hello World"

두 번째 인자 tail 자리는 strict인데 undefined를 만났으니 예외 발생

data List a = Cons a !(List a) | Nil
main = Cons undefined (Cons undefined Nil) `seq` putStrLn "Hello World"

몇 개의 원소를 가진 리스트인지는 알 수 있지만(구조=뼈대=spine은 알 수 있지만), 안에 들어 있는 건 알 필요 없을 수 있습니다. 이럴 때 spine strict라 부릅니다.

data List a = Cons !a !(List a) | Nil
main = Cons undefined (Cons undefined Nil) `seq` putStrLn "Hello World"

첫 번째 인자가 strict인데 undefined를 만났으니 예외 발생

data List a = Cons !a (List a) | Nil

이런 구조는 원 글의 저자도 본 적이 아직 없다고 합니다. 그래서 이런 걸 위한 특별한 이름이 있는지 조차 모른다고 합니다. 구조는 정해지지 않았는데 안에 들어있는 값이 정해진 경우도 있지 않을까요?

Vector

Data.Vector 모듈에 있는 boxed vector는 spine strict 구조입니다.

main = Data.Vector.fromList [undefined] `seq` putStrLn "Hello World"

spine은 생선뼈, 등뼈처럼 구조가 다 있다는 뜻의 재밌는 이름 같습니다. 구조는 있으므로we have full spine, OK

main = Data.Vector.fromList (undefined:undefined) `seq` putStrLn "Hello World"

몇 개의 원소가 있는지 알 수 없으므로, 즉 뼈대를 알 수 없으므로 예외 발생

main = Data.Vector.fromList undefined `seq` putStrLn "Hello World"

뼈대 뿐만 아니라 전체 구조를 알 수 없으므로 당연히 예외 발생

Data.Vector.Unboxed 모듈에 있는 vector는 value strict 구조입니다.

import qualified Data.Vector.Unboxed as V

fromList :: [Int] -> V.Vector Int
fromList = V.fromList

1. main = fromList [undefined] `seq` putStrLn "Hello World"
2. main = fromList (undefined:undefined) `seq` putStrLn "Hello World"
3. main = fromList undefined `seq` putStrLn "Hello World"

2번과 3번은 boxed vector와 같은 이유로 결과가 같은데, 첫 번째만 다릅니다. spine strict일 때는 구조만 있어도 OK였는데, value strict일 때는 구조 안에 들어 있는 것도 명확해야 OK입니다.

Sets, Maps

Map류의 모듈들은 Strict 버전과 Lazy 버전(eg. Data.HashMap.Strict, Data.HashMap.Lazy)들이 준비되어 있는 반면, Set류의 모듈들은 그렇지 않습니다. 컨테이너들은 모두 spine strict이므로, key 자리는 strict해야 합니다. set은 key만 가지고 있으니 value strict 구조입니다.
Map류의 Lazy 버전은 spine-strict, value-lazy이고 Strict 버전은 둘 다 strict입니다.

함수 인자

bottom값을 주면 bottom값을 리턴할 때, strict하다고 말합니다. Int를 받는 +는 strict입니다. undefined + x도 bottom값을 리턴하고 x + undefined도 bottom값을 리턴합니다.
const 함수는 const a b = a 로 정의되어 있는데, 첫 번째 인자에 strict하고, 두 번째 인자에 lazy합니다. 리스트의 (:) 값 생성자는 첫 인자와 두 번째 인자에 lazy합니다. 하지만 data List = Cons !a !(List a) | Nil 이렇게 정의하면 Cons는 첫 번째, 두 번째 인자에 strict 합니다.

Folds

foldl의 strict 버전인 fold'도 NF까지 strict하진 않습니다. NF까지 도달하는 strict한 foldl'이 필요할 때 다음처럼 force를 통해 불러주면 됩니다.

import Data.List (foldl')
import Control.DeepSeq (force)

average :: [Int] -> Double
average =
  divide . foldl' add (0, 0)
  where
    divide (total, count) = fromIntegral total / count
    add (total, count) x = force (total + x, count + 1)

main :: IO ()
main = print $ average [1..1000000]

Streaming data

conduit같은 스트리밍 라이브러리들이 내세우는 것 중 하나가 고정된 크기의 메모리constant memory 사용입니다. 메모리 누수 걱정없이 쓸 수 있는 것처럼 보이지만 WHNF까지인지, NF까지인지 살펴봐야 합니다.

뭘 조심해야 하는지 보이도록 average 함수를 conduit을 이용해서 안 좋은 방식으로 정의해 보겠습니다.

import Conduit

average :: Monad m => ConduitM Int o m Double
average =
  divide <$> foldlC add (0, 0)
  where
    divide (total, count) = fromIntegral total / count
    add (total, count) x = (total + x, count + 1)

main :: IO ()
main = print $ runConduitPure $ enumFromToC 1 1000000 .| average

메모리 사용량을 보려면

$ stack --resolver lts-9.3 ghc --package conduit-combinators -- Main.hs -O2
$ ./Main +RTS -s

아직 conduit이 익숙하지 않아, 원 글에 아직은 추가할 게 없습니다.

Chain reaction

아래는 아주 빡빡하게 strict로 만들어 놓은 작업입니다. 메모리를 얼마나 쓰는지 확인해 보세요.

#!/usr/bin/env stack
-- stack --resolver lts-9.3 script
{-# LANGUAGE BangPatterns #-}

data StrictList a = Cons !a !(StrictList a) | Nil

strictMap :: (a -> b) -> StrictList a -> StrictList b
strictMap _ Nil = Nil
strictMap f (Cons a list) =
  let !b = f a ------------------------------------ bang!
      !list' = strictMap f list ------------------- bang!
   in b `seq` list' `seq` Cons b list' ------------ seq

strictEnum :: Int -> Int -> StrictList Int
strictEnum low high =
  go low
  where
    go !x
      | x == high = Cons x Nil
      | otherwise = Cons x (go $! x + 1) ---------- $!

double :: Int -> Int
double !x = x * 2 --------------------------------- bang!

evens :: StrictList Int
evens = strictMap double $! strictEnum 1 1000000 -- $!

main :: IO ()
main = do
  let string = "Hello World"
      string' = evens `seq` string ---------------- seq
  putStrLn string

seqdeepseq를 이용해서 엮어 놓은 체인은 IO액션을 만나기 전에는 여전히 thunk 상태로 있습니다. 너무 오래 IO를 만나지 않고 루프를 돌게 만들면 의도와 달리 많은 메모리를 소모할 수 있습니다.


  1. strict 번역을 뭐라 하는게 좋을까요? lazy를 지연으로, strict를 즉시로 하면 말이 안되게 붙을 때가 있습니다. 뒤에 평가란 낱말을 추가하면 조금 더 부드러워지긴 합니다. strict 함수는 (인자를) 즉시 평가 함수, lazy 함수는 (인자를) 지연 평가 함수. 그럼 strictness는 뭘로 번역할까요? 즉시 평가 정도? 사실, 시간과 관련된 말이 아니라, bottom을 받았을 때 바로 bottom을 돌려 주냐 마냐에 따른 구분입니다. 단어 뜻 그대로 엄격이라고 번역된 자료가 자주 보이긴 하는데 딱 맞아 보이는 번역은 아닙니다. spine-strict는 즉시 구조(골격, 뼈대) 평가 함수쯤 될까요?↩︎

  2. evaluation을 평가말고 다른 어떤 말이 어울릴까요? 보통 가치를 매길 때 평가란 말을 씁니다. 지금 문장이 가치가 있는지를 살펴보는 게 아니라, reduce하거나 실행하거나 하는 동작을 evaluation이라고 합니다. 평가로 번역하면 딱 맞는 느낌은 아닌데, 어쨌든 자리 잡은 용어긴 합니다. 하스켈 문서들에선 reduce와 evaluate가 거의 동의어로 쓰입니다. 람다 대수에서 reduce하는 걸, 프로그래밍에선 evaluate라고 합니다.↩︎

  3. primitive operation은 라이브러리등에 있는게 아니라, GHC가 만들어내는 런타임 베이스에 들어있는 작업입니다. 라이브러리 소스들을 보면 #(MagicHash라고 부릅니다)이 붙은 코드들이 보이는데, 이들이 primitive operation입니다. 하스켈 코드로 구현되어 있지 않다고 하는데, 아마도 C로 구현되어 있지 않을까요? 참고 - Toan Ngyen - Typeable
    힙에 값을 넣어 놓고 가리키도록 만든 포인터를 boxed value라고 하고(eg. Int는 힙에 2 word 크기를 차지하는 객체를 가리키는 포인터), 다이렉트로 값에 접근하는 걸 unboxed value라고 합니다. #이 붙은 primitive value를 unboxed value라고 부릅니다. 참고 - Unboxed types and primitive operations↩︎

  4. 아직 표준으로 정해지진 않았지만 GHC와 함께 배포되는 라이브러리↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com