하스켈을 JVM 위에서 실행하려는 시도는 새삼스러운 일이 아니다. 그러나 에드워드 켓(Edward Kmett)이 공개한 THC(Turbo Haskell Compiler)는 접근 방식에서 기존 프로젝트들과 뚜렷하게 갈린다. 개발자 본인의 설명에 따르면 휴가 중 농담처럼 일주일 만에 시작한 프로젝트인데, 그 짧은 기간에 GHC 9.14.1의 모든 프림옵(prim-op)을 구현하고 GHC 코어(Core)를 JVM에서 실행하는 JIT를 갖췄다고 한다. 과거 타입드 함수형 언어를 JVM에서 돌리기 위해 만든 Cadenza의 방법론, 즉 Truffle과 GraalVM을 토대로 삼았다.
핵심 설계는 역할 분담이다. 파싱, 타입 검사, 디슈가링, 코어 최적화까지는 여전히 GHC가 담당한다. THC는 그 이후, 즉 최적화된 코어를 받아 자체 런타임으로 컴파일하고 실행하는 단계만 넘겨받는다. 덕분에 템플릿 하스켈이나 선형 하스켈(Linear Haskell) 같은 고급 기능을 그대로 지원할 수 있다고 한다. JIT로도 쓸 수 있지만 Native Image를 이용한 AOT 컴파일도 지원해 실행 파일을 만들 수 있고, 패키지는 Cabal로 해석하며 여러 라이브러리를 가진 패키지와 Backpack도 다룬다. 실제로 pandoc, happy, alex는 물론 GHC 자신까지 JIT 혹은 AOT로 돌렸다는 점은 커버리지 측면에서 주목할 만하다.
꼬리 호출을 루프로 바꾸는 트릭
함수형 코드를 JVM에서 돌릴 때 가장 큰 장애물은 꼬리 호출(tail call) 처리다. JVM은 꼬리 호출 최적화를 기본 제공하지 않기 때문이다. Eta 언어나 저자가 Runar Bjarnason와 함께 Scalaz 모나드에 적용했던 방식은 트램펄린(trampoline) 기법을 썼다. THC는 다른 길을 택했다. 코드 변환을 통해 뜨거운(hot) 꼬리 호출을 기본 블록 형태의 타이트한 루프 안에 묶어두고, 측면 출구(side exit)를 둔다.
구체적으로는 트레이싱 모드에서 재귀가 일어날 때 64비트 블룸 필터를 채워 재귀적 꼬리 호출일 가능성을 감지한다. 적중 가능성이 보이면 슬로우패스 예외를 던져 연속(continuation)과 시작 지점을 연결하고, 커스텀 Truffle 노드가 Graal을 통해 현재의 꼬리 호출 루프를 함수 경계를 넘어 하나의 타이트한 루프로 변환한다. 블룸 필터 특성상 거짓 양성이 생길 수 있지만, 이 경우 추가 슬로우패스 작업이 발생할 뿐 프로그램 결과는 바뀌지 않는다. 경로가 분기하면 트레이싱 JIT처럼 측면 루프를 키우다가 JVM의 함수 본문 크기 한계에 도달하면 스택 프레임을 '누출'시키는 방식으로 꼬리 호출을 흘려보낸다. 다만 이 누출은 일시적이며, 비동기 예외에 대응하기 위해 만든 재개 가능 코드 메커니즘을 재활용해 CHICKEN Scheme의 가비지 컬렉션 전략과 유사한 방법으로 누적된 스택 프레임을 압축한다. Data.Map 벤치마크에서는 수백만 건의 패스트패스 호출 대비 약 66건의 폴백 트램펄린 호출만 필요했다는 수치가 제시됐다.
다른 언어 생태계를 끌어오는 FFI
실무적으로 눈에 띄는 대목은 폴리글랏 FFI다. THC는 Python, Ruby, R, JavaScript와의 상호 운용을 제공해, 하스켈에서 다른 언어의 라이브러리를 호출하고 그 결과를 JIT 컴파일된 하스켈 프로그램 안으로 바로 가져올 수 있다. UTF-8로 인코딩된 문자열의 경우 Data.Text와 Truffle 문자열 간 변환이 제로카피로 이뤄진다. 데이터프레임이 필요하거나 LLM을 돌리거나 D3.js 시각화를 쓰고 싶으면 Text를 foreign import로 내보내면 된다는 것이 저자의 구상이며, 쿼지 인용(quasi-quotation) 기반의 인라인 바인딩도 어렵지 않게 구현할 수 있다고 본다. 하스켈 라이브러리 안의 C/C++ 코드는 네이티브 모드 Sulong(JVM 위의 LLVM)을 통해 FFI로 실행된다.
런타임 측면에서도 선택지가 넓다. Truffle 평가용으로 바이트코드 기반과 AST 기반 두 가지 백엔드를 두고, 단일 스레드와 멀티 스레드 양쪽을 지원한다. GHCi가 만들어내는 BCO 코드, 즉 GHC 바이트코드 자체도 실행할 수 있다. throwTo와 비동기 예외, 마스킹을 완전히 지원하며, 일반 자바 스레딩과 더불어 Project Loom 위에서 GHC 스타일의 경량 그린 스레딩을 제공해 저렴한 MVar를 구현한다. SIMD는 GHC 프림옵의 제한된 범위를 따르되, 런타임에 SIMD 폭을 선택하고 incubating 상태인 Vector API로 루프를 JIT 컴파일하는 식으로 더 나아갈 수 있다. 32비트 오프셋으로 힙 참조를 표현하는 압축 OOP도 지원하는데, 이 모드에서는 8바이트 정렬 기준상 힙이 약 32GB로 제한된다.
성능과 남은 과제
성능 수치는 조심스럽게 읽어야 한다. 개발 초기 이틀간 Data.Map 등에서 워밍업 이후 GHC 대비 3배 빠름부터 3배 느림 사이, 대체로 10~20% 느린 수준이었다고 한다. 그러나 이후 며칠간은 벤치마크 대신 커버리지 확대에 집중하면서 일부 쉬운 벤치마크에서 10배가량의 성능 저하가 생겼고, 이를 잡는 작업이 계속되고 있다. 비(非)꼬리 호출 경로에서의 스택 증가가 GHC 대비 유계로 유지되는지는 아직 검증되지 않았고, 저자 스스로 향후 과제로 남겨두고 있다.
결국 THC는 완성된 제품이라기보다 하나의 가능성 증명에 가깝다. GHC의 프런트엔드를 그대로 두고 코어 이후만 넘겨받는 분업 구조는 언어 호환성을 유지하면서 JVM 생태계와 폴리글랏 상호 운용이라는 이득을 노린다. 한국의 실무자 입장에서 당장 프로덕션에 올릴 대상은 아니지만, 함수형 언어의 꼬리 호출 문제를 트램펄린 없이 트레이싱 JIT 기법으로 접근한 방식, 그리고 Truffle/GraalVM이 다중 언어 런타임의 공통 기반으로 어디까지 쓰일 수 있는지를 보여주는 사례로서 참고할 가치가 있다. 코드는 github.com/ekmett/thc에 공개돼 있고, 개발 논의는 Libera.Chat IRC의 ##thc 채널에서 이뤄지고 있다.