openlang

foldl은 잘 합성되고, 그래도 샌다

긴 리스트에 foldl (+) 0을 돌리는 것은 내가 아는 가장 작은 공간 누수이고, 산술보다는 합성의 문제에 가깝다. 단계마다 (acc + x) 썽크가 만들어지는데 끝까지 아무도 강제하지 않으니 누산기가 리스트 길이만 한 사슬로 자란다. Data.List의 foldl'은 매 단계 누산기를 약한 머리 정규형까지 강제하므로 사슬이 생기지 않는다.

함정은 두 버전의 타입이 같다는 점이다. foldl :: Foldable t => (b -> a -> b) -> b -> t a -> b 어디에도 결과가 요구되기 전까지 메모리를 얼마나 붙잡는지는 적혀 있지 않다. 이 fold를 보기 좋은 조합자로 감싸면 엄격성 결정은 한 단계 더 깊이 묻히고, 읽는 사람 눈에서 사라진다.

최적화를 켜면 GHC의 엄격성 분석이 그냥 foldl도 자주 구해 준다. 그래서 GHCi에서 처음 터질 때 더 당황스럽다.

더 나쁜 것은 foldl'도 WHNF까지만 간다는 점이다. (sum, count) 쌍을 누적하면 쌍 생성자만 평가되고 두 필드는 여전히 게으르다. 엄격한 필드나 구성 요소에 대한 뱅 패턴이 필요하다.

그래서 엄격성을 최적화 세부 사항으로 보지 않게 됐다. fold를 하는 조합자라면 엄격성은 이름이나 문서에, 타입 바로 옆에 있어야 한다.

이어지는 대화 4

서로의 근거를 따라
  1. 고유 링크

    고친 코드를 올리기 전에 무엇을 쟀는지부터 말하자. 총 할당량과 최대 상주 메모리는 다른 숫자이고, 누수는 두 번째에 나타난다. 엄격한 fold도 게으른 쪽만큼 할당하면서 살아 있는 것은 거의 남기지 않을 수 있다. GHC라면 -s 런타임 통계에서 위쪽 할당 줄이 아니라 maximum residency를 읽어야 한다. 파이썬의 tracemalloc.get_traced_memory()가 현재값과 최댓값을 따로 돌려주는 것도 같은 이유다.

  2. 고유 링크

    최대 상주 메모리만으로는 해결책에 닿지 못합니다. 누수가 있다는 것만 알려 줄 뿐, 무엇으로 이루어진 누수인지는 알려 주지 않습니다. 그러니 상주 메모리를 본 다음에는 클로저 타입별 힙 프로파일인 -hT를 봅니다. 프로파일링 빌드 없이도 동작하고, 썽크가 바로 눈에 띌 겁니다. JVM에서도 규칙은 같습니다. 수집기 플래그를 하나라도 건드리는 건 그 뒤입니다. 썽크 사슬에 맞춰 수집기를 조정하면 누수만 편해집니다.

  3. 고유 링크

    이름이나 문서로는 이걸 붙잡아 둘 수 없습니다. SQL에서는 아무도 게으름이라고 부르지도 않지만, 합성된 뷰에서 실제로 무엇을 실행할지는 플래너가 정합니다. PostgreSQL은 재귀가 아니고 부수 효과가 없으며 한 번만 참조되는 CTE를 MATERIALIZED라고 쓰지 않는 한 인라인합니다. 그래서 버전 12부터는 서브쿼리를 WITH로 감싸도 최적화 울타리가 되지 않습니다. 쿼리 텍스트는 같은데 평가가 달라졌고, 그걸 알아챈 문서 주석은 없었습니다. ilse가 든 GHCi 사례도 GHC에서 같은 일을 보여 줍니다. EXPLAIN을 돌리십시오. 평가 순서는 거기에 있습니다.

  4. 고유 링크

    누산기에 엄격한 필드를 가진 자기 타입을 줘 봐도 좋겠어요. data Acc = Acc !Int !Int 같은 식으로요. 그러면 foldl'이 바깥 생성자를 강제할 때 두 값도 함께 강제되고, 엄격성은 fold마다가 아니라 데이터를 정의하는 곳에 한 번만 적혀요. 이런 조합자들 대부분은 그 정도면 충분하겠네요.