Lazy ByteString 과 Strict ByteString

Posted on October 21, 2021

2022.7.8 추가 Thunk, Chunk를 혼용해서 쓰던 걸 바로 잡습니다.
Thunk : 아직 평가 되기 전 코드를 의미합니다. 이 문서에서는 혼동을 피하기 위해 <Thunk>로 표기하겠습니다.
Chunk : 전체를 이루는 각 부분들을 덩어리Chunk라고 합니다. Lazy와 관계 없이 부분을 뜻하는, 단어 뜻 그대로 덩어리란 의미로 읽으면 됩니다.

ex. Strict ByteStringLazy ByteString도 평가 하기 전에는 <Thunk>로 남아 있습니다.
ex. fromChunks를 이용해서 Strict ByteString Chunk를 여러개 모아 하나의 Lazy ByteString으로 만들 수 있습니다.

※ 바로 잡는데 도움을 주신 준규님, Ailrun님 감사합니다.

라이브러리 때문에 LazyStrict를 혼용해서 써야 되는 상황이 생겨 헤매다가, 문득 Lazy가 생각처럼 동작하는지 궁금해서 테스트 해봤습니다.

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE BangPatterns #-}
import Data.ByteString.Lazy as BL
import Data.ByteString as BS

func1 :: BS.ByteString -> BS.ByteString -> (BS.ByteString, BS.ByteString)
func1 a b = (a , b)

func2 :: BL.ByteString -> BL.ByteString -> (BL.ByteString, BL.ByteString)
func2 a b = (a , b)

-- BL에 bang을 붙이면 BS가 될까요?
func3 :: BL.ByteString -> BL.ByteString -> (BL.ByteString, BL.ByteString)
func3 !a !b = (a , b)

Bang! 패턴에 대한 설명은 Strict와 Lazy 포스트를 참고하세요.

*Main> :set -XOverloadedStrings
*Main> let t1 = func1 undefined "a"
*Main> let t2 = func2 undefined "b"
*Main> let t3 = func3 undefined "c"
*Main> snd t1
"a"
*Main> snd t2
"b"
*Main> snd t3
*** Exception: Prelude.undefined ...

Data.ByteStringStrict라 하면 t1Lazybang을 붙인 t3가 같은 동작을 해야 될 것 같은데 그렇지 않습니다. 예상했던 Strict의 동작인가요? ByteString StrictByteString Lazy 차이가 뭘까요?

Strict

Data.ByteString.Internal에 있는 정의

-- 효율적으로 공간을 활용하는 Word8 벡터입니다. 
-- 실제 데이터의 시작을 가리키는 포인터, 오프셋,길이로 표현됩니다.
-- 모든 필드에는 bang이 붙어 있습니다.
data ByteString = PS {-# UNPACK #-} !(ForeignPtr Word8) -- payload
                     {-# UNPACK #-} !Int                -- offset
                     {-# UNPACK #-} !Int                -- length
     deriving (Typeable)

1 ForeignPtr은 외부 객체 참조 포인터입니다.

Lazy

Data.ByteString.Lazy.Internal에 있는 정의

-- Chunk에는 Strict ByteString과 재귀적으로 Lazy ByteString이 같이 있습니다.
import qualified Data.ByteString.Internal as S
data ByteString = Empty | Chunk {-# UNPACK #-} !S.ByteString ByteString
    deriving (Typeable)

LazyStrict ByteString을 여러 Chunk로 쪼개어 갖고 있는 모양입니다. 달리 말하면, Chunk란 값 생성자로 여러 번 쌓여 있는 상태입니다. Strict에는 필드들이 모두 bang이 붙어 있으므로 PS 값 생성자가 평가될 때, 모든 필드들은 <Thunk>상태로 둘 수 없다는 말입니다. 물론 PS 생성자가 평가되기 전에는 <Thunk>로 남아 있을 겁니다.

Empty
Chunk !(포인터, 오프셋, 길이) Empty
Chunk !(포인터, 오프셋, 길이) ( Chunk !(포인터, 오프셋, 길이) Empty )
Chunk !(포인터, 오프셋, 길이) ( Chunk !(포인터, 오프셋, 길이) ( Chunk !(포인터, 오프셋, 길이) Empty ))
-- 이런 모양들이 될 수 있습니다.

연산들이 가장 바깥에 있는 Chunk를 평가할 때 하나의 바이트열만 strict하게 두고, 안에 있는 Chunk<Thunk>로 둡니다. 만일 오프셋, 길이를 봐서 다음 안 쪽의 Chunk로 넘어가면 그 때, 안 쪽 Chunk를 평가 할 겁니다.

단순히 bang을 붙였냐 안 붙였냐의 차이가 아닙니다. Strict ByteString이라 해서 무조건 ByteString만 만나면 Strict하게 동작하는 게 아니라, Chunk없이 전체 문자열을 가리키는 포인터를 가져오고, Lazy ByteStringChunk로 나누어 필요할 때 조금씩, 조금씩 각 조각을 가리키는 포인터를 가져오게 되어 있습니다.

이렇게 보면, Lazy ByteString은 단위를 1바이트보다 크게 잡은 배열에 값 생성자와 묶어 하나의 항목만 strict하게 평가되도록 bang이 붙어 있는 것으로 볼 수 있습니다.

둘의 차이가 궁금했던 이유

StrictLazy냐가 궁금해진 이유는, 하나의 함수에서 다른 함수로 ByteString을 건네 줄 때, 계속해서 Copy가 일어나면 어마 어마하게 비효율적일텐데 어떻게 처리되고 있는지가 궁금해서였습니다. Strict도, Lazy도 똑 같이 <Thunk>로 넘어가니 실제 복사는 일어나지 않다가, 평가될 때 복사가 일어날텐데, 그 전까지는 Lazy는 첫 번째 Chunk안에 들어 있는 PS 생성자가 가진 Strict 포인터를 복사할테고, StrictPS가 가진 Strict 포인터를 복사할 겁니다. 함수간 전달에서는 크게 차이가 날 것 같지 않습니다. immutable한 데이터를 가리키는 포인터만을 복사하는 것으로 보입니다.

mutable 해야 하는 상황이 생기면 어떻게 될까요? ByteString 두 개를 붙인다 하면 새로운 ByteString으로 두 개의 실제 데이터 내용을 모두 복사해와야 합니다. 예를들어 간단하게 앞에 한 바이트, 한 바이트 추가한다고 리스트처럼 생각하고 작업하다간 퍼포먼스가 한없이 떨어지게 됩니다. 빈번하게 mutable해야 한다면, Strict ByteString으로 해결할 수 있는 문제가 아닙니다. (이럴 때 Builder를 씁니다.)

둘이 대부분 같은 인터페이스가 구현되어 있어 사용할 때는 신경쓰지 않아도 되겠지 했는데, 그렇지 않습니다. 함수가 BL.ByteString을 받도록 설계하면, 이 함수는 BS.ByteString을 받을 수 없습니다. 이유가 대부분 안에서 다른 라이브러리에 있는 ByteString 관련 함수들을 가져다 쓰는데, 해당 라이브리리들의 버전을 모두 바꿔줘야 합니다. 때로는 Strict, Lazy 둘 중 하나만 지원하는 라이브러리들도 있습니다.

둘 중 어떤 걸 어떤 때 써야하는지 정리가 필요해 보이는데, 지금 당장은 딱 필요한 순간에 전체 바이트열이 필요한 경우가 많다면 Strict로, 그렇지 않으면 Lazy를 쓴다정도만 떠오릅니다.

둘 모두 꼭 필요한 때가 아니면 포인터로 머물러 있는데, 꼭 필요한 때를 상상해 보면 출력을 한다든지, 파싱을 한다든지 내용이 필요할 때입니다. 파싱을 가정해 보면 Strict ByteString은 안에 있는 포인터가 가리키는 메모리에서 내용을 한 번에 모두 읽어 옵니다. Lazy는 일단 첫 번째 Chunk가 가리키는 것만 읽어오고, 더 필요하면 또 읽어올 겁니다.


  1. ForeignPtr는 외부에 유지되는 객체에 대한 참조를 말합니다. 외부란, 하스켈 코드를 돌리는 런타임 시스템(더 구체적으론 스토리지 관리자)의 관리를 받지 않는 오브젝트란 뜻입니다. 하스켈의 힙이나 스택에서 더 이상 이 포인터를 보는 참조가 없으면 외부에 있는 릴리즈 담당 루틴을 부르게 됩니다. 더 깊이 보기 전에 추측해보면, C 등으로 alloc하고 해당 메모리를 가리키는 포인터를 하스켈이 받아서 작업할 때 쓸 것 같습니다.↩︎

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