Rust로 OCaml 런타임을 파일별·줄별로 포팅한 경험담, 방법론, 안전성, 성능 벤치마크, 그리고 인간이 조종한 AI 기반 리라이트에 대한 관찰을 정리한 글입니다.
Rust 커뮤니티는 OCaml 커뮤니티에 감사의 빚을 지고 있습니다. 첫 번째 Rust 컴파일러는 OCaml로 작성되었습니다. 이 글에서는 OCaml 런타임을 Rust로 다시 작성합니다. 빚을 갚기 위한 계약금 정도로 생각해 주세요. 
Marks Shinwell은 아무도 다치지 않았습니다[1]
OCaml 런타임은 C로 작성되어 있습니다. 저는 이 C 코드를 Rust로 포팅한 결과를 소개합니다. 동작합니다. OCaml 컴파일러의 테스트 스위트를 통과합니다. 테스트 스위트는 업스트림의 것을 수정 없이 그대로 사용했습니다. Rust 런타임을 사용하는 OCaml 컴파일러는 자기 자신을 빌드할 수 있고, dune을 빌드할 수 있으며, opam 스위치를 설치할 수 있고, 임의의 OCaml 프로그램을 바이트코드와 네이티브 양쪽 모두로 빌드할 수 있습니다. 런타임에는 더 이상 C가 남아 있지 않습니다. 성능은… 놀랍습니다! 가장 재미있었던 부분은 이 작업을 통해 OCaml에 대해 배운 점들이었습니다.
포크는 여기 https://github.com/mbacarella/rustcaml에 있습니다. 직접 살펴보고 싶다면 저장소를 클론하고 rust-runtime 브랜치로 이동한 뒤 일반적인 빌드 지침을 실행하면 됩니다. Rust cargo가 설치되어 있어야 합니다. 인터프리터를 더 빠르게 실행하는 rust-runtime-nightly 브랜치도 있습니다. 이것도 아래에서 더 설명하겠습니다.
이 글의 나머지는 성숙하고 정교한 코드베이스, 즉 OCaml 런타임을 인간이 조종하고 AI가 구동하는 방식으로 다시 쓴 경험 보고서이자 초기 사례 연구로 봐주시면 좋겠습니다.
이 작업은 “야 Claude, 에이전트 스웜이든 뭐든 돌려서 Rust로 OCaml 런타임을 처음부터 설계하고 구현해. 실수는 하지 마”라고 말하는 식으로 진행된 것이 아니었습니다. 오히려 지시는 “파일별로, 줄별로 C 코드를 동등한 Rust 코드로 번역하고, 매 단계마다 나와 확인하자”에 가까웠습니다.
비교적 저기술적인 오케스트레이션, 평범한 사람 수준의 토큰 예산, 그리고 Claude의 항의에도 불구하고, 해냈습니다!
OCaml이 왜 OCaml로 작성되지 않았는지 궁금해하는 독자를 위한 짧은 우회 설명입니다. 대부분은 실제로 그렇습니다! 컴파일러, 표준 라이브러리, 도구 체인은 OCaml로 작성되어 있습니다. 하지만 런타임은 C로 작성되어 있습니다. 소스 트리에는 이전 버전의 컴파일러를 바이트코드로 빌드한 boot/ocamlc가 포함되어 있습니다. 소스에서 OCaml을 빌드하려면, 먼저 OCaml 바이트코드를 실행할 줄 아는 ocamlrun이라는 C 프로그램을 빌드합니다. 그다음 ocamlrun + boot/ocamlc를 사용해 현재 소스를 네이티브 ocamlc로 컴파일합니다. 그렇다면 런타임도 OCaml로 작성하지 않는 이유는 무엇일까요? 할 수는 있습니다. 하지만 그러지 않는 이유 중 하나는, 그렇게 되면 지원하려는 모든 아키텍처와 운영체제에 대해 미리 빌드된 바이너리를 영원히 배포해야 하기 때문입니다. 그 외에도 좋은 이유들이 있는데, 아래에서 더 살펴보겠습니다.
저는 이 작업에 Claude Code와 x86-64 Linux 머신에서 실행한 Opus 4.7[2] 모델을 사용했습니다. Claude는 전술적인 작업 대부분을 맡았습니다. 즉, 코드를 작성하고 다음에 어디로 갈지 선택지를 제시하고 테스트를 실행했습니다. 저는 방향을 잡고 가끔 격려도 했습니다(이 부분도 아래에서 더 이야기합니다).
Bun 프로젝트는 최근 Zig 코드 96만(!) 줄을 Rust로 포팅했다고 보고했고, 이는 적지 않은, 뭐랄까요… 흥분을 불러일으켰습니다. 저는 “JavaScript”라는 말을 보는 순간 눈이 흐려지는 편이라 이 이야기를 좀 늦게 접했습니다. 하지만 차이점 때문에 비교가 유익합니다. Bun은 JavaScriptCore 위에서 실행되므로, 가비지 컬렉터와 인터프리터는 C++로 되어 있었고 Rust 포팅 범위에 애초에 들어가지 않았습니다. 그 리라이트가 아무리 험난했어도, 일종의 기존 엔진을 둘러싼 기계 장치를 바꾼 셈입니다. 제 생각에는 정말 흥미로운 부분의 포팅은 피해 간 것입니다.
그래서 이 프로젝트는 다릅니다. OCaml 런타임은 숙련되게 작성된 C 기반 엔진입니다. 정교한 멀티코어 메커니즘, 진짜 가비지 컬렉터, 바이트 단위까지 지켜야 하는 FFI 계약이 있습니다. 이런 것들은 일반적인 프로그래머 경험의 일부가 아닙니다. 이런 포팅을 LLM이 얼마나 잘할까? 저는 그게 궁금했습니다!
계획은 단순했지만, 명시해 둘 가치가 있다고 생각합니다. 컴파일러가 실행 불가능한 상태가 되지 않게 한다. 빌드에 파일 단위 토글을 넣는다. 스위치를 뒤집으면 링커가 C 버전 대신 Rust 버전 파일을 선택한다. 이렇게 하면 한 번에 파일 하나씩 포팅하고, 스위치를 바꾼 뒤마다 전체 테스트 스위트를 돌리고, 다음으로 넘어가기 전에 정상 동작이 확인된 상태를 커밋할 수 있었습니다.
각 .c 파일은 가능한 한 줄 단위로 대응되도록 .rs 파일로 번역되었습니다. 그래야 OCaml 업스트림의 변경 사항을 추적하고 반영하기가 가장 쉬웠기 때문입니다. 이 포트는 형님 격인 OCaml과 함께 성장할 수 있습니다.
각 포팅 뒤에 git 커밋을 남긴 목적은, 가까운 미래든 먼 미래든 정말 막히는 상황이 생겼을 때를 대비하기 위해서였습니다. 예를 들어 어떤 애플리케이션이 극도로 희귀한 버그를 유발한다면, 어느 시점으로든 bisect해서 포팅 작업의 어디에서 차이가 도입되었는지 확인할 수 있습니다. OCaml 런타임은 Rust로 2%, 50%, 99% 포팅된 어느 단계에서도 실행 불가능한 상태가 아니었습니다.
결국 브랜치가 두 개 생겼습니다. 하나는 Rust stable만 유지한 rust-runtime 브랜치이고, 다른 하나는 성능 실험을 위해 Rust nightly 기능을 추가한 rust-runtime-nightly였습니다.
좋습니다, 충분히 기다리게 했네요. 다들 궁금하실 겁니다. 이 작업에 들어가기 전 제 예상은 Rust 기반 네이티브 실행 파일은 C보다 약 10-20% 느리고, 바이트코드 인터프리터는 20-30% 느릴 것이라는 것이었습니다.
이렇게 예상한 이유는 포팅에 걸어둔 제약 때문이었습니다. 관용적인 Rust를 쓰지 않고, 가능한 한 줄별로 C와 맞추고, Rust stable 기능만 사용하고, computed goto도 쓰지 않는다는 조건이었으니까요.
핵심 결과를 한마디로 하면 “음, 거의 동급이긴 한데, 약간 그런 느낌”입니다.
| Runtime | Bytecode (cycles vs C) | Native (cycles vs C) |
|---|---|---|
| C (trunk) | 1.00x: (기준선) | 1.00x (기준선) |
| Rust (stable) | 1.44x, C보다 느림 | ~1.05x (범위: 0.87-1.13) |
| Rust (nightly) (ETCs) | 0.91x, C보다 빠름 | Rust stable과 동일 |
일부 sandmark 테스트에 따르면 네이티브 실행 파일은 대체로 동급 수준입니다. 반면 바이트코드 인터프리터는 computed goto가 없어서 Rust stable에서는 약 2배 느리게 실행됩니다. Rust nightly로 전환하고 명시적 tail call(ETC)을 추가하면 C 런타임과 같거나 약간 더 빨라지기도 합니다.
저는 비관용적인 번역이 전반적으로 더 느릴 거라고 예상했습니다. 언어의 장점을 살리는 대신 언어와 싸우고 있으니까요. 하지만 알고 보니, 약간의 흠은 있어도 C 같은 Rust는 C와 거의 비슷한 성능을 낼 수 있었습니다.
전체 벤치마크 표는 맨 끝에 있습니다.
unsafeocaml/runtime % rg unsafe *.rs | wc -l
2015
이 점도 미리 분명히 하고 넘어가죠. 이것이 Rust로 옮겨졌다고 해서 더 안전해진 것은 아닙니다. 오히려 이 버전은 C보다 약간 더 안전하지 않을 수도 있습니다. 하지만 약 2015개의 unsafe는 번역 실패의 결과가 아닙니다. Rust는 원래 이미 존재하던 unsafe를 더 읽기 쉽게 드러낼 뿐입니다.
이 숫자가 훨씬 더 낮아질 수 없는 이유는 세 가지가 있습니다. 가장 줄이기 어려운 것부터 말하면:
네 번째 이유는 저에게 있습니다. 업스트림 추적을 위해 Claude를 관용적인 방식이 아니라 줄별 C 번역에 가두었기 때문에, 그중 일부는 C와의 대응 관계를 유지하기 위한 것이며 프로젝트에 제약이 없어지면 나중에 캡슐화할 수 있습니다.
많은 unsafe는 오늘날 우리가 아는 OCaml을 깨뜨리지 않고는 제거할 수 없습니다. 이런 의미에서 unsafe의 도입은 런타임 내부에서 벌어지는 일이 얼마나 세심하게 관리된 비안전성에 의존하는지를 드러낼 뿐입니다.
그렇다고 해도, 만약 이 프로젝트가 OCaml과의 빌드 및 바이너리 호환성에서 벗어난다면, 전반적인 안전성을 더 높이는 좋은 기반이 될 수는 있습니다.
처음 Claude에게 이 프로젝트를 검토하고 포팅 계획을 제안해 달라고 했을 때, Claude는 이것이 꽤 미친 일이고 성공 가능성이 낮다고 봤습니다. 어느 시점에는 미묘한 오류가 스며들어 거의 줄일 수 없는 수준이 될 것이고, 우리가 중간에 지루해지지 않는다는 가정하에 끝내는 데 2-3 _년_이 걸릴 것이라고 예측하기도 했습니다.
저는 약간 격려를 해주고, 어떻게 하면 조심스럽게 발을 디디면서 자신감을 유지할 수 있을지 이야기해 줘야 했습니다. 어떤 면에서는 인간 개발자와 일하는 느낌이 들더군요.
물론 완전히 잘못 판단한 건 아니었습니다! OCaml 런타임은 C를 한계까지 밀어붙입니다. 개발자 경험을 관리 가능하게 만들기 위해 아주 영리한 매크로를 많이 사용합니다. 멀티코어 시스템은 강한 보장을 제공합니다. 외부 함수 인터페이스(FFI)는 C ABI 호환성에 의존하고, Rust에는 자체 표준 ABI가 없기 때문에 그것은 영원히 유지되어야 합니다. 가끔은 파일 하나를 포팅해 놓고 테스트에서 진전을 내지 못하면 낙담해서 그 파일을 되돌리고, 일단 다른 걸 해보자고 제안하기도 했습니다.
현실 세계에서 이 프로젝트 전체는 약 7일이 걸렸습니다. 대부분의 시간은 제가 일상생활을 하는 동안 명령 승인 대기 상태로 흘러갔습니다(모바일 앱으로도 Claude Code를 조작할 수 있지만 여전히 꽤 불안정하고 자주 멈춥니다). Claude C Compiler 실험과 달리 저는 “Claude Teams”나 다른 종류의 멀티 에이전트 오케스트레이션을 쓰지 않았습니다. 에이전트는 하나만 돌렸습니다. 또한 runtime/*.c 파일 71개를 .rs로 하나씩 포팅할 때마다 테스트 스위트를 다시 돌리느라 많은 시간이 들었습니다. 실제로 발생한 실패를 반복해서 좁히고 고치는 데도 몇 시간씩 걸리곤 했습니다.
워크플로 관점에서 보면, 저는 코드 자체를 거의 통제하지 않았습니다. 제 경험상 줄별 번역은 LLM이 특히 잘하는 일입니다. 그리고 저도 약 4만 줄의 코드를 검토할 수는 없었을 겁니다. 대신 파일 단위에서 더 많은 시간을 썼고, 어떤 파일을 어떤 순서로 포팅할지 결정하고, 그것들이 테스트에서 실제로 실행되고 있는지 확인하는 데 집중했습니다. 기본적으로는, 우리가 생각할 수 있는 모든 테스트를 통과한 뒤 어느 모호한 애플리케이션을 실행했더니 이상한 힙 손상 버그가 났고 어디서부터 시작해야 할지조차 모르는 상황이 얼마나 끔찍할지를 계속 상상했습니다. 그런 가능성을 염두에 두는 것이 LLM을 어느 정도 얌전하게 유지해 주었습니다.
컴파일러 테스트 스위트와 sandmark는 우리의 길잡이였고, Claude는 진전을 만들기 위해 그것들을 조작하려는 시도를 단 한 번도 하지 않았습니다. 하지만 가끔은 혼란스러워하기도 했습니다. 초반에 어떤 테스트가 세그폴트를 일으켰는데도 Claude는 계속 진행하면서 더 많은 파일을 포팅해 버렸습니다. 제가 왜 세그폴트를 조사하지 않았느냐고 되묻자, trunk 버전에서도 항상 그 세그폴트가 있었던 것으로 가정했고 확인해 볼 생각을 못 했다고 하더군요.
실제로 이런 일은 몇 번 있었습니다. 세션이 길어지고, 특히 컨텍스트가 압축된 뒤에는 테스트의 황금 기준 상태가 무엇인지 놓쳐버리고, 현재 테스트 실패가 자기 변경으로 도입된 것이 아니라고 스스로 납득해 버릴 때가 있습니다. 저는 trunk에서 테스트 스위트를 돌려 모두 초록불로 돌아오는 걸 직접 보라고 다시 상기시켜줘야만 했습니다.
제가 LLM은 줄별 번역에 뛰어나다고 말했지만, 실제로 그렇습니다. 인간보다 이 일을 더 잘합니다. 다만 완벽하지는 않습니다. 그 한 번의 세그폴트 무시 외에도, 큰 enum을 정확히 재현하지 못하는 실수를 하기도 했습니다. 예를 들어 시작 인덱스가 0이 아닐 때 같은 경우입니다. 가끔은 초기화 코드의 매직 넘버를 뒤바꾸기도 합니다(예: { a=2, b=1 }을 { a=1, b=2 } 대신 쓰는 식). 수작업으로 옮겨 적을 내용이 많으면 스스로 sed/awk 스크립트를 써서 번역할 만큼 영리했지만, 항상 그런 것은 아니었습니다. 일반적으로 이런 실수는 테스트에서 빨리 드러났지만, 그것을 찾아 고치기까지 반복 작업에 많은 시간이 들었습니다.
Claude는 자주 스스로를 의심했지만, 조금만 떠밀어 주면 작업을 해내고 놀라울 정도로 성공했습니다. 반면 어떤 때는 여전히 독립적으로 처리할 수 있는 것들을 묶어서 생각하기도 했습니다. 예를 들어, 우리는 내내 파일별 포팅 전략을 따르고 있었는데도 GC 전체를 한 번에 포팅해야 한다고 판단했습니다. GC는 여러 개의 큰 파일에 걸쳐 있었고, Claude는 네 개 파일을 한꺼번에 옮긴 뒤 5000줄짜리 아주 빡센 Rust 코드를 디버깅할 준비를 하고 있었습니다. 하지만 우리가 계속 파일 단위 전략을 따라왔고 왜 여기서는 안 되냐고 상기시켜 주자, 가능하다고 인정했고 실제로 그렇게 해냈습니다.
전반적으로 Claude의 직감은 꽤 좋았습니다. OCaml 런타임, C, Rust를 한꺼번에 내면화한 사람과 페어 프로그래밍하는 느낌이었습니다. 동시에 ADHD가 있는 사람과 페어 프로그래밍하는 느낌이기도 했고요.
실행한 테스트
~/code/ocaml에 있다고 알려줌)opam install 실행
제가 시작할 때 과소평가했던 것 중 하나는, 특히 이런 코드를, LLM이 번역하는 과정을 지켜보는 것이 지닌 교육적 가치였습니다. C 코드를 직접 읽으면 부분적인 그림을 얻게 됩니다. 각 함수가 무엇을 하고 왜 그런 구조인지 보이죠. 모델에게 다른 언어로 번역하라고 시키면 충분히 기계적인 작업처럼 보이지만, 모델이 걸려 넘어지거나 선택을 요구하는 지점들은 내가 놓치고 있던 것들을 드러냅니다.
중간쯤 lf_skiplist.c를 번역하고 있었는데, 저는 살면서 skip-list를 한 번도 써본 적이 없다는 걸 깨달았습니다[3]. 살펴보면서 왜 이것이 트리가 아닐까 궁금했습니다. Rust로 표현하기는 분명 트리가 훨씬 쉬웠을 테니까요. 그런데 곧 차이는 동시성 과 락 프리 라는 점이라는 걸 깨달았습니다. skip-list라면 이 특성이 실제로 가능하지만, 트리였다면 극도로 어려웠을 것입니다. 정말 우아한 결정입니다.
여담이지만, 혹시 Algorithm Design Manual을 뒤적여 본 적이 있고, 가슴에 올려둔 채 잠들어 본 적이 있으며, 거기에 75개가 넘는 알고리즘이 정리되어 있어도 실제로는 상위 5개 정도만 쓴다는 사실을 한탄해 본 적이 있다면, 컴파일러 분야 경력을 고려해 보셔도 좋겠습니다.
락 프리 skip-list 이야기로 돌아가죠. Rust에는 연결 리스트를 구현하기가 어렵다는 약간의 밈이 있습니다. 수명 모델과 잘 맞지 않기 때문입니다. 이 Rust 포트는 raw pointer 조작을 그대로 유지함으로써 여기서 borrow checker를 완전히 우회합니다. unsafe를 쓰면 사실상 C 버전만큼만 어렵습니다
두 번째이자 더 흥미로운 문제는 바이트코드 인터프리터였습니다. 이 구현은 큰 성능 향상을 위해 computed goto를 활용합니다. computed goto는 C 컴파일러의 비표준 확장입니다(GCC와 LLVM이 지원). 저도 이런 것이 있다는 건 알고 있었지만, 확장이 너무 틈새 기능이라 제 경력 동안 한 번도 직접 써본 적은 없었습니다.
인터프리터 루프에서 computed goto가 빠른 이유는 핸들러로 점프하는 것 자체를 최적화해서가 아니라, CPU 내부의 branch predictor가 그 점프를 예측 가능하게 만들기 때문입니다. Python 인터프리터는 최근 상당히 큰 성능 회귀를 겪었는데, 이 휴리스틱의 동작을 바꾸는 툴체인 함정을 건드렸기 때문입니다. 하지만 computed goto는 Rust stable에서는 표현할 수 없습니다. 아래 “rust-runtime-nightly” 섹션에서 우회 방법을 더 설명하겠습니다.
제게 마지막으로, 비교적 작지만, OCaml 세계에 오래 있었던 사람으로서는 약간 부끄러운 놀라움도 있었습니다. 런타임 값이 인코딩되는 방식에 대해 제가 잘못 이해하고 있었던 것입니다. 저는 OCaml 런타임이 각 값에서 한 비트를 훔쳐 즉시값(int63)인지 힙 포인터인지를 표시한다는 건 알고 있었습니다. 하지만 방향을 반대로 이해하고 있었습니다. 저는 최상위 비트를 훔쳐서, 어차피 32비트 플랫폼에서는 힙으로 실질적으로 2GB만 사용할 수 있고(커널이 절반을 예약하니까), 64비트 플랫폼에서는 주소 공간이 9경 바이트로 제한돼도 큰 문제가 없으니, 그저 주소 가능 공간을 절반으로 줄이는 방식이라고 생각했습니다. 하지만 실제로는 반대였습니다. 훔치는 것은 최하위 비트입니다. 이 비트가 0이면, 모든 힙 포인터가 4바이트 또는 8바이트 정렬되어 있어서 하위 비트가 원래 자연스럽게 0이므로 포인터임을 바로 알 수 있습니다. 하위 비트가 1이면, 상위 63비트가 정수라는 것을 알고 그 값을 아래로 시프트하면 됩니다. 프로그래밍 언어 GC의 세계에서 이것은 미묘한 절충이며, 각 구현은 조금씩 다르게 합니다. OCaml에서는 포인터를 사용할 때 디코딩이 필요 없고, 즉시값은 디코딩에 시프트 한 번만 필요하다는 뜻입니다. 경우에 따라서는 시프트 없이 인코딩된 값에 직접 수학 연산을 할 수도 있습니다.
이 중 두 가지, computed goto와 값 인코딩은, OCaml 런타임을 OCaml 자체로 최적으로 작성할 수 없는 주요 이유이기도 하다는 점은 언급할 가치가 있습니다. 런타임은 언어가 제공하는 추상화보다 더 아래 계층에 살아야 하며, 그것이 C 같은 더 저수준 언어를 선택하는 이유 중 하나입니다. 그리고 이를 더 관용적인 Rust로 번역하는 일이 사소하지 않은 이유이기도 합니다.
C 기반 런타임이 사용하던 기능 세 가지는 Rust nightly에서만 사용할 수 있었습니다. Rust nightly는 언제든 바뀔 수 있고 의존해서는 안 되기 때문에, 시대를 타지 않는 소프트웨어를 원한다면 Rust stable을 고수하라는 권고를 받습니다. 그런데 이런 제약이 주어지자 LLM은 Rust stable에 남기 위해 그것들을 인라인 어셈블리로 구현하자고 아주 태연하게 제안했습니다 
no_std를 사용하고 있기 때문에 Rust nightly를 택하거나 직접 구현해야 했습니다..weak로 표시하면 끝입니다.C에만 있고 Rust에는 대응물이 없는 기능도 하나 있습니다. setjmp/longjmp, 즉 비지역 goto입니다. 인터프리터는 예외 처리를 위해 이것이 필요합니다. Rust는 sigsetjmp를 안전하게 호출하고 sigsetlongjmp를 통해 다시 호출되는 흐름을 안전하게 지원할 수 없습니다. 하지만 인라인 어셈블리 버전은 만들 수 있습니다! Rust에서는 일반적으로 returns_twice 때문에 이에 대한 우려가 많지만, Claude는 이를 사용하는 두 호출 지점을 검사해 보고 returns_twice가 초래하는 clobbering으로 인해 지역 변수가 유지된다고 가정하는 코드가 없다고 판단했습니다.
이 프로젝트에 인라인 어셈블리를 도입하는 것이 아주 이질적인 일은 아닙니다. OCaml 네이티브 컴파일러 자체도 손으로 조정한 어셈블리를 직접 생성하니까요.
rust-runtime-nightly 브랜치가 있습니다computed goto를 쓰는 C 바이트코드 인터프리터의 Rust stable 버전은 직선적인 loop { match ... }로 포팅되었습니다. 성능은 상당히 나빠졌습니다! 그래서 우리는 별도의 브랜치에 Rust nightly를 도입해, 인터프리터에서 computed goto 손실을 메우기 위해 명시적 tail call을 가져올 수 있는지 확인해 보기로 했습니다.
벤치마크 결과, 실제로 C의 computed goto 버전보다 최대 5% 더 빠를 수 있었습니다. 그러니 그런 점도 있죠.
최소한 재미있는 실험이었고, 저에게는 그것만으로도 충분한 이유입니다. 하지만 저를 정말 계속 괴롭히는 것은 이것입니다. OCaml 런타임이 C로 되어 있는 이유는 그것이 쓰이던 당시의 상황 때문이지, 누군가가 최근에 C를 대안들과 저울질해 본 뒤 선택했기 때문이 아닙니다. Rust는 OCaml이 길을 연 지 20년이 지나서야 등장했습니다. 이것은 유산이지, 능동적인 결정이 아닙니다. 그리고 값싸고 기계적인 리라이트의 흥미로운 점은, “이건 C로 되어 있다”는 이미 굳어진 사실을 다시 살아 있는 질문으로 되돌린다는 것입니다.
제가 주장하려는 것은 “그러므로 Rust로 다시 써야 한다”가 아닙니다.
기계적 번역은 쉽고 값싼 부분이며, 이 글 전체가 그 증거입니다. 비용이 많이 드는 부분은 리라이트가 해결해 주지 않는 모든 것들입니다. 그리고 이제 기계적 비용이 거의 0에 가까워졌으니, 그런 고려 사항들은 더 이상 각주가 아니라 대화의 핵심이 됩니다.
실제 절충점은 무엇인가? 기계적 리라이트를 손으로 쓴 리라이트만큼 신뢰하려면 무엇이 필요할까? 만약 인간이 이와 똑같은 포트를 2년에 걸쳐 손으로 했다면, 더 신뢰했을까? 그렇다면 그 차이는 정확히 무엇으로 이루어져 있으며, 그 간극은 메울 수 있을까?
열린 질문들입니다! 그리고 1년 전보다 더 열려 있습니다.
이게 애초에 작동했다는 사실만으로도 정말 신납니다. 이 일을 하면서 엄청나게 즐거웠고, 만약 이것이 누군가에게 흥미로운 커피 타임 대화거리 정도라도 된다면 그 자체로 충분히 가치가 있습니다.
OCaml 팀이 이 포트로 진지하게 전환할 거라고는 기대하지 않습니다(우선 이것은 그들의 새로운 AI 정책을 준수하지 않습니다). 하지만 이 경험 보고서가 세상 누구에게도 아무런 인식 변화를 일으키지 않는다면 오히려 조금 놀랄 것 같습니다. 인간이 조종하는 AI 리라이트는 분명 프로그래머의 도구 상자에 들어가야 할 무언가입니다. 특히 성숙한 테스트 스위트가 있고, 진실의 기준으로 삼을 기존 시스템이 있을 때는 더욱 그렇습니다. AI 연구소 내부자들이 가진 무한한 토큰 예산이 없어도, 꽤 멀리 갈 수 있습니다!
앞으로의 단계로는 unsafe 부분을 더 캡슐화할 기회를 찾고, 생태계를 더 많이 검증하고(어쩌면 더 널리 채택된 OCaml 릴리스로 백포팅함으로써), 실제로 새 런타임 버그를 얼마나 도입하지 않았는지 제대로 확인하는 일이 있을 수 있습니다. 좀 더 투기적으로는, 모든 unsafe를 제거했을 때 성능 비용이 얼마나 드는지 보기 위해 호환성을 깨는 장난감 OCaml 포크를 시도해 볼 수도 있습니다.
이 벤치마크는 적당히 걸러서 봐 주세요.
순수 Rust stable 런타임은 네이티브 실행 파일 성능에서는 동급이지만, computed goto가 없기 때문에 인터프리터에서 최악의 경우 성능 저하가 더 큽니다.
Rust nightly에서는 computed goto 대신 명시적 tail call(ETC)을 위한 become 키워드를 추가하면, 인터프리터 성능 이 동급이거나 더 빠릅니다. 다만 Rust 문서에 따르면 explicit_tail_calls는 “현재 미완성이며 제대로 동작하지 않을 수 있다”고 합니다.
한 가지 주의점! Rust는 GC의 느린 경로를 세게 두드리는 벤치마크에서 더 나은 성능을 보였고, 우리는 그것이 “Rust라는 언어” 때문이 아니라 빌드 플래그 때문일 수도 있다고 생각했습니다. Rust 런타임은 -O3와 링크 타임 최적화(LTO)로 빌드된 하나의 crate인 반면, 업스트림 C는 -O2와 별도 번역 단위(TU), 그리고 LTO 없음입니다. 업스트림은 -O3를 “다소 위험하다”고 부릅니다. 좀 더 사과 대 사과 비교를 해보기 위해, trunk의 C 런타임을 -O3 -flto로 다시 빌드하고 전부 다시 돌렸습니다. 결과는 이랬습니다. 플래그는 lists의 향상 중 약 3분의 1을 설명하지만, finalise의 향상은 전혀 설명하지 못했고, 바이트코드에서는 오히려 9% 회귀가 있었습니다. 번역 단위 간 인라이닝이 인터프리터 루프를 비대하게 만들기 때문입니다. 업스트림의 기본 빌드는 알고 보니 C 런타임에 가장 좋은 구성이고, 따라서 더 좋은 기준선이 됩니다.
아래 결과는 업스트림 기본 플래그와, LTO 및 -O3를 사용한 Rust를 비교한 것입니다.
다시 말하지만, 이 벤치마크는 적당히 걸러서 보세요. 제 생각에 여기서 얻어야 할 요점은 “음, 거의 동급이긴 한데, 약간 그런 느낌”이지, “Rust가 엄청난 향상을 주니까 지금 당장 반드시 바꿔야 한다!”가 아닙니다.
벤치마크가 게시된 뒤 보통 벌어지는 일을 생각하면, 다섯 번째 댓글쯤에서 왜 제 벤치마크가 완전히 틀렸는지 설명하는 글이 나올 거라고 예상하셔도 됩니다. 빠져 있다면 마음속으로 채워 넣어 주세요.
=== [stock] WALL CLOCK (min ms, lower is better) ===
trunk rust-runtime rust-runtime-nightly
native
almabench 782.61 ms 780.61 ms (0.997x) 788.07 ms (1.007x)
bdd 1553.51 ms 1531.07 ms (0.986x) 1530.86 ms (0.985x)
crout_decomposition 460.74 ms 463.42 ms (1.006x) 473.57 ms (1.028x)
durand_kerner_aberth 569.35 ms 560.39 ms (0.984x) 567.75 ms (0.997x)
kb 1272.94 ms 1269.73 ms (0.997x) 1245.44 ms (0.978x)
soli 2798.39 ms 2850.08 ms (1.018x) 2825.36 ms (1.010x)
binarytrees5 1969.36 ms 1936.12 ms (0.983x) 2044.22 ms (1.038x)
alloc 5916.50 ms 6654.26 ms (1.125x) 6612.67 ms (1.118x)
lists 204.67 ms 177.14 ms (0.865x) 178.34 ms (0.871x)
finalise 351.60 ms 326.26 ms (0.928x) 326.49 ms (0.929x)
TOTAL 15879.67 ms 16549.09 ms (1.042x) 16592.80 ms (1.045x)
bytecode
almabench 5201.01 ms 4754.61 ms (0.914x) 4143.59 ms (0.797x)
bdd 26813.52 ms 36608.96 ms (1.365x) 25355.17 ms (0.946x)
crout_decomposition 6862.29 ms 7705.78 ms (1.123x) 6121.15 ms (0.892x)
durand_kerner_aberth 12174.51 ms 14244.28 ms (1.170x) 12766.48 ms (1.049x)
kb 8106.58 ms 11773.33 ms (1.452x) 8507.30 ms (1.049x)
soli 89943.46 ms 112959.91 ms (1.256x) 74206.08 ms (0.825x)
binarytrees5 8493.53 ms 13385.23 ms (1.576x) 8839.91 ms (1.041x)
alloc 70911.11 ms 128335.46 ms (1.810x) 69083.19 ms (0.974x)
lists 311.60 ms 342.14 ms (1.098x) 310.50 ms (0.996x)
finalise 1448.42 ms 1917.89 ms (1.324x) 1327.77 ms (0.917x)
TOTAL 230266.01 ms 332027.59 ms (1.442x) 210661.13 ms (0.915x)
=== [stock] INSTRUCTIONS (min retired, lower is better) ===
trunk rust-runtime rust-runtime-nightly
native
almabench 11,982,525,444 12,045,767,816 (1.005x) 12,119,109,811 (1.011x)
bdd 23,195,880,693 23,672,243,713 (1.021x) 23,496,447,226 (1.013x)
crout_decomposition 10,540,697,462 10,528,518,080 (0.999x) 10,529,006,913 (0.999x)
durand_kerner_aberth 9,925,393,139 9,931,601,603 (1.001x) 9,933,069,944 (1.001x)
kb 16,180,752,516 16,273,026,843 (1.006x) 16,446,171,810 (1.016x)
soli 80,190,717,599 80,192,319,756 (1.000x) 80,192,251,301 (1.000x)
binarytrees5 36,203,768,664 37,582,691,016 (1.038x) 39,510,055,070 (1.091x)
alloc 130,567,325,652 130,484,776,522 (0.999x) 130,485,472,014 (0.999x)
lists 4,080,393,283 3,694,861,446 (0.906x) 3,694,934,206 (0.906x)
finalise 6,795,543,488 6,445,368,456 (0.948x) 6,339,747,723 (0.933x)
TOTAL 329,662,997,940 330,851,175,251 (1.004x) 332,746,266,018 (1.009x)
bytecode
almabench 65,289,519,768 81,655,602,353 (1.251x) 75,567,878,905 (1.157x)
bdd 322,229,228,258 511,376,407,765 (1.587x) 351,643,764,781 (1.091x)
crout_decomposition 89,704,774,045 126,778,953,728 (1.413x) 109,322,946,879 (1.219x)
durand_kerner_aberth 174,081,597,005 260,836,571,057 (1.498x) 251,548,510,858 (1.445x)
kb 113,945,898,760 179,633,203,899 (1.576x) 136,450,793,255 (1.198x)
soli 1,069,822,905,043 1,640,541,482,411 (1.533x) 1,073,678,761,274 (1.004x)
binarytrees5 130,470,446,317 195,276,263,749 (1.497x) 158,298,506,061 (1.213x)
alloc 1,282,744,491,682 2,064,392,411,405 (1.609x) 1,612,854,371,669 (1.257x)
lists 6,153,104,169 6,883,900,743 (1.119x) 6,582,283,553 (1.070x)
finalise 23,288,098,487 31,858,883,756 (1.368x) 25,418,880,165 (1.091x)
TOTAL 3,277,730,063,534 5,099,233,680,866 (1.556x) 3,801,366,697,400 (1.160x)
=== [stock] CYCLES (min CPU cycles, lower is better) ===
trunk rust-runtime rust-runtime-nightly
native
almabench 4,275,133,794 4,266,969,984 (0.998x) 4,306,909,109 (1.007x)
bdd 8,423,078,413 8,336,325,514 (0.990x) 8,299,324,772 (0.985x)
crout_decomposition 2,436,557,980 2,452,598,643 (1.007x) 2,499,609,328 (1.026x)
durand_kerner_aberth 3,069,143,209 3,026,946,007 (0.986x) 3,061,589,426 (0.998x)
kb 6,910,707,438 6,896,853,517 (0.998x) 6,769,334,136 (0.980x)
soli 14,973,590,917 15,301,024,579 (1.022x) 15,129,903,373 (1.010x)
binarytrees5 10,631,483,033 10,480,155,164 (0.986x) 11,050,267,510 (1.039x)
alloc 31,649,853,386 35,786,537,383 (1.131x) 35,671,107,350 (1.127x)
lists 1,102,518,006 953,712,673 (0.865x) 955,703,816 (0.867x)
finalise 1,878,606,838 1,754,635,089 (0.934x) 1,750,940,930 (0.932x)
TOTAL 85,350,673,014 89,255,758,553 (1.046x) 89,494,689,750 (1.049x)
bytecode
almabench 28,150,819,846 25,755,551,403 (0.915x) 22,276,857,902 (0.791x)
bdd 146,021,329,177 200,336,983,620 (1.372x) 138,615,168,879 (0.949x)
crout_decomposition 37,529,521,884 41,904,393,924 (1.117x) 33,079,620,789 (0.881x)
durand_kerner_aberth 66,297,703,676 77,048,128,983 (1.162x) 68,526,496,042 (1.034x)
kb 44,399,403,914 64,233,052,062 (1.447x) 46,199,410,939 (1.041x)
soli 495,570,241,232 617,323,022,780 (1.246x) 406,057,473,311 (0.819x)
binarytrees5 46,566,265,069 72,906,173,460 (1.566x) 47,896,412,861 (1.029x)
alloc 386,859,994,733 698,738,688,990 (1.806x) 371,099,084,024 (0.959x)
lists 1,687,035,843 1,840,186,059 (1.091x) 1,673,067,897 (0.992x)
finalise 7,894,225,414 10,423,868,878 (1.320x) 7,145,578,643 (0.905x)
TOTAL 1,260,976,540,788 1,810,510,050,159 (1.436x) 1,142,569,171,287 (0.906x)
=== [flambda] WALL CLOCK (min ms, lower is better) ===
trunk rust-runtime rust-runtime-nightly
native
almabench 788.42 ms 778.92 ms (0.988x) 776.47 ms (0.985x)
bdd 1701.32 ms 1713.61 ms (1.007x) 1706.10 ms (1.003x)
crout_decomposition 356.73 ms 361.88 ms (1.014x) 354.48 ms (0.994x)
durand_kerner_aberth 560.64 ms 566.03 ms (1.010x) 562.88 ms (1.004x)
kb 1286.42 ms 1277.64 ms (0.993x) 1254.64 ms (0.975x)
soli 2846.62 ms 2835.48 ms (0.996x) 2826.60 ms (0.993x)
binarytrees5 1974.28 ms 2083.74 ms (1.055x) 1985.55 ms (1.006x)
alloc 6614.50 ms 6621.29 ms (1.001x) 5895.50 ms (0.891x)
lists 201.42 ms 179.15 ms (0.889x) 180.03 ms (0.894x)
finalise 333.90 ms 316.36 ms (0.947x) 312.57 ms (0.936x)
TOTAL 16664.24 ms 16734.09 ms (1.004x) 15854.81 ms (0.951x)
=== [flambda] INSTRUCTIONS (min retired, lower is better) ===
trunk rust-runtime rust-runtime-nightly
native
almabench 12,034,850,839 12,098,421,833 (1.005x) 12,172,354,394 (1.011x)
bdd 27,423,697,883 27,857,846,511 (1.016x) 27,756,041,045 (1.012x)
crout_decomposition 7,427,485,867 7,414,807,272 (0.998x) 7,414,600,792 (0.998x)
durand_kerner_aberth 10,049,245,201 10,055,781,084 (1.001x) 10,056,665,095 (1.001x)
kb 16,031,096,938 16,117,382,051 (1.005x) 16,319,595,413 (1.018x)
soli 80,312,687,759 80,310,809,241 (1.000x) 80,313,394,595 (1.000x)
binarytrees5 35,947,315,834 37,275,057,576 (1.037x) 39,466,919,214 (1.098x)
alloc 130,580,050,276 130,480,782,692 (0.999x) 130,470,459,850 (0.999x)
lists 4,060,601,508 3,674,872,614 (0.905x) 3,675,307,359 (0.905x)
finalise 6,527,081,899 6,188,708,550 (0.948x) 6,066,414,273 (0.929x)
TOTAL 330,394,114,004 331,474,469,424 (1.003x) 333,711,752,030 (1.010x)
=== [flambda] CYCLES (min CPU cycles, lower is better) ===
trunk rust-runtime rust-runtime-nightly
native
almabench 4,310,869,190 4,271,067,276 (0.991x) 4,273,574,788 (0.991x)
bdd 9,216,952,701 9,282,752,592 (1.007x) 9,249,625,080 (1.004x)
crout_decomposition 1,892,517,849 1,921,876,466 (1.016x) 1,889,816,433 (0.999x)
durand_kerner_aberth 3,033,087,434 3,058,715,626 (1.008x) 3,050,420,028 (1.006x)
kb 7,011,708,143 6,956,097,326 (0.992x) 6,830,973,136 (0.974x)
soli 15,289,634,946 15,222,697,901 (0.996x) 15,185,371,100 (0.993x)
binarytrees5 10,696,099,880 11,287,126,205 (1.055x) 10,758,391,992 (1.006x)
alloc 35,697,684,634 35,701,748,838 (1.000x) 31,670,157,017 (0.887x)
lists 1,085,485,730 959,634,997 (0.884x) 970,192,157 (0.894x)
finalise 1,797,386,524 1,694,521,695 (0.943x) 1,683,679,531 (0.937x)
TOTAL 90,031,427,031 90,356,238,922 (1.004x) 85,562,201,262 (0.950x)
좋습니다, 여기까지입니다. 읽어주셔서 감사합니다.