Roc 컴파일러의 Rust→Zig 재작성 진행기

5 days ago 13
  • 기존 구현의 구조적 결함을 해결하기 위해 Rust 30만 줄을 Zig로 다시 작성했으며, 487일 만에 기능 동등성에 도달해 올해 후반 첫 정식 번호 릴리스인 0.1.0을 목표로 함
  • 새 컴파일러는 핫 코드 로딩과 재현 가능한 크로스 컴파일, 패턴 매칭 안의 문자열 보간, HTTP 라우팅의 힙 할당 제거를 지원하며 Rocci Bird의 wasm 크기도 절반 이하인 31KB로 줄임
  • Zig를 선택한 핵심 이유는 빌드 시간, 세분화된 할당자와 데이터 배치 제어, 컴파일러 개발에 적합한 생태계, 메모리 비안전 코드 검사였으며 Zig 0.17.0의 증분 빌드는 약 46만 줄을 35ms에 다시 빌드함
  • 실제 버그 분류에서 Rust 컴파일러는 메모리 손상 21건, Zig 컴파일러는 10건이었지만 대부분 잘못된 코드 생성 때문이었고, Zig 컴파일러 자체의 메모리 안전성 오류는 파일명을 깨뜨린 use-after-free 2건이었음
  • Zig는 포인터 없는 데이터 구조, 무파싱 역직렬화, LLVM bitcode 직렬화기 재사용에 잘 맞았지만 테스트의 자동 메모리 해제, 다형성, 비공개 구조체 필드, 죽은 코드 탐지, 릴리스 간 호환성에서는 Rust의 개발 경험이 더 나았음

기능 동등성에 도달한 재작성

  • Roc 컴파일러 팀은 약 1년 반 동안 Rust 30만 줄Zig로 재작성해 기존 컴파일러와 기능 동등성에 도달함
  • 기능 동등성은 중요한 이정표지만 정식 릴리스는 아니며, 새 컴파일러의 0.1.0은 올해 후반을 목표로 함
  • 2024년 WASM-4 게임 Rocci Bird를 새 컴파일러로 빌드할 수 있게 됨
    • 전체 게임은 Roc 코드 1,000줄 미만임
    • 업데이트된 소스원본보다 간결함
    • roc build --opt=size가 생성하는 wasm 바이너리는 31KB로, 기존 컴파일러 결과물의 절반보다 작음
  • 홈페이지에서는 2.5MB WebAssembly 컴파일러로 기본 Roc 프로그램을 브라우저에서 작성하고 실행할 수 있음
  • 재작성에는 487일이 걸려 Bun이 Zig 약 50만 줄을 Rust로 옮긴 11일보다 476일 길었음
    • Bun은 직접 포팅했지만 Roc은 여러 컴파일러 구조와 기능을 크게 바꾸는 재작성에 가까웠음
    • 따라서 두 코드베이스의 규모나 기간을 Rust와 Zig만으로 직접 비교하기는 어려움

새 컴파일러가 제공하는 개발 경험

  • 핫 코드 로딩과 크로스 컴파일

    • roc server.roc로 서버를 실행한 뒤 코드를 수정하면 다음 요청부터 새 코드가 자동 적용
    • Python 같은 인터프리터 언어에서는 일반적이지만 Roc 같은 고성능 컴파일 언어에서는 흔하지 않은 동작임
    • 웹 서버뿐 아니라 간단한 2D 게임에서도 같은 방식으로 작동함
    • 배포할 때는 roc build server.roc가 LLVM 최적화를 적용한 독립 실행형 바이너리를 생성함
    • roc build --target=x64musl로 Alpine Linux용 정적 바이너리를 크로스 컴파일할 수 있음
    • 동일한 소스 바이트를 입력하면 Mac을 포함한 어느 빌드 시스템에서도 동일한 출력 바이트를 생성함
  • 패턴 매칭 안의 문자열 보간

    • "/users/${id}" 같은 문자열 보간을 패턴 매칭 안에서 지원함
    • Express식 라우팅처럼 실행 중 템플릿 문자열을 파싱하지 않고 컴파일러가 직접 처리함
    • HTTP 메서드와 경로를 함께 매칭하고, 중첩 경로나 기본값도 패턴으로 분기할 수 있음
    • 예시의 전체 HTTP 요청 처리 코드는 컴파일 시 타입 안전성을 검사하면서 힙 할당을 전혀 수행하지 않음
    • 순수 함수의 컴파일 시 실행을 이용해 HTTP 요청 라우팅을 무할당으로 구현했으며, 홈페이지의 WebAssembly 컴파일러에서 문법을 시험할 수 있음

처음부터 다시 작성한 이유

  • Roc은 Rust·C·Zig와 달리 시스템 언어가 아니며, 참조 카운팅 기반 자동 메모리 관리를 사용함
    • 추적식 가비지 컬렉터의 일시 중지를 피함
    • Perceus 최적화와 Koka식 기회적 변경을 활용함
  • 일반적인 비시스템 언어처럼 클로저 캡처마다 힙을 할당하지 않고, 람다 집합 특수화를 통한 다형적 비함수화를 사용함
  • 비함수화는 함수형 언어에서 인라이닝처럼 여러 후속 최적화를 가능하게 하지만, 기존 구현을 정확히 만드는 일은 매우 어려웠음
  • Ayaz Hafiz의 OCaml 프로토타입을 통해 문제가 여러 컴파일러 단계에 걸친 구조적 결함이며, 해결하려면 컴파일러 대부분을 다시 작성해야 한다는 점을 확인함
  • 다른 기여자들도 각기 다른 이유로 여러 부분을 다시 작성할 계획이어서, 거의 전부를 점진적으로 교체하는 테세우스의 배 방식 대신 전체 재작성을 검토하게 됨
  • 성공한 컴파일러 프로젝트에서는 자체 호스팅 등을 위해 처음부터 재작성하는 사례가 흔하며, 자체 언어가 아닌 Go로 옮긴 TypeScript 재작성 사례도 있음
  • Roc 컴파일러는 자체 호스팅하지 않는다는 방침이지만, 이번에는 재작성으로 얻는 이점이 알려진 비용보다 크다고 판단함

다시 Rust가 아닌 Zig를 선택한 기준

  • 팀은 이미 표준 라이브러리의 여러 기본 기능에 Zig를 사용하고 있었고, 충분히 잘 아는 시스템 언어가 Rust와 Zig뿐이어서 두 언어만 진지하게 검토함
  • 프로젝트마다 적합한 언어가 다르며, Rust를 다른 업무에서 계속 사용하는 것과 Roc에 Zig를 선택하는 일은 모순되지 않음
  • 빌드 시간

    • Rust의 cargo 빌드는 증분 빌드까지 오래 걸렸으며 코드베이스가 커질수록 주요 불편으로 남았음
    • Zig로 옮기면 빌드가 훨씬 빨라질 것으로 예상함
  • 메모리와 데이터 배치 제어

    • 컴파일 단계마다 다양한 할당자, 특히 아레나 할당자를 사용하고 구조체 배열(SoA) 배치를 광범위하게 활용함
    • Rust 생태계는 대체로 하나의 전역 할당자를 가정하지만, Zig 생태계는 세분화된 할당자 전달을 기본으로 삼고 SoA 지원도 표준으로 제공함
  • 필요한 생태계의 범위

    • Rust 전체 생태계는 Zig보다 크지만, Roc의 특수한 요구와 관련된 패키지는 두 생태계 모두 많지 않았음
    • LLVM C++ 라이브러리를 감싸는 대신 빠르게 bitcode를 생성하는 코드처럼 필요한 틈새 기능은 Zig 쪽에 더 많았음
  • 메모리 비안전 코드 지원

    • 기존 Rust 컴파일러 30만 줄에는 약 1,200개의 unsafe 사용이 있었음
    • 비교 대상으로 Rust 컴파일러 자체는 350만 줄에서 약 4만 개의 unsafe를 사용함
    • 기계어를 생성하는 컴파일러에서는 메모리 비안전 작업이 중요한 업무 일부임
    • Rust는 드문 unsafe 블록을 격리하고 Miri나 Valgrind로 검사하는 모델이지만, Roc에서는 unsafe가 드물지 않았음
    • Zig는 메모리 비안전 코드를 올바르게 작동시키기 위한 기능을 더 제공했고, 팀은 바로 이 영역에서 가장 많은 지원을 원했음

borrow checker 없이 얻은 메모리 안전성

  • Rust와 Zig의 검사 범위

    • Microsoft의 2019년 자료에 따르면 매년 보안 업데이트로 처리되는 취약점의 약 70%가 메모리 안전성 문제였음
    • 해당 자료의 2018년 분류를 Rust와 Zig 관점에서 나누면 다음과 같음
    • 83.6%는 범위 밖 읽기·쓰기, 안전하지 않은 캐스트, 초기화되지 않은 읽기, 스택 오버플로, 비메모리 안전성 문제로, Rust와 Zig 선택에 영향을 받지 않는 범주임
    • 16.4%는 use-after-free이며 Rust의 borrow checker, Zig ReleaseSafe, Fil-C 방식 검사로 잡을 수 있는 범주임
    • Zig의 ReleaseSafe는 해제된 메모리를 사용하면 실행 중 패닉을 발생시킴
      • Rust의 안전한 부분집합보다 검사가 덜 포괄적임
      • 실행 비용이 있고 프로그램이 패닉할 수 있음
    • ReleaseFast는 프로덕션에서 검사를 생략하지만 디버그 빌드와 테스트에서는 유지함
    • 모든 실제 실행 경로를 테스트한다면 ReleaseSafe와 같은 안전성을 얻을 수 있지만, 그런 테스트 범위는 일반적으로 현실적이지 않음
  • 다른 Zig 프로젝트와 Rust의 unsafe

    • TigerBeetle는 ReleaseSafe를 사용하며, 정밀한 Jepsen 검증에서 안전성 버그 2건이 발견됐지만 둘 다 메모리 안전성과 무관했음
    • ReleaseFast를 사용하는 Ghostty와 Zig 컴파일러도 Zig 코드의 메모리 비안전성으로 발생한 CVE가 없음
    • Rust 프로그램도 의존성 내부의 unsafe를 통해 메모리 안전성 공백이 생길 수 있음
    • Unsafe Rust에는 ReleaseFast Zig와 같은 위험이 있지만, 개발 중 문제를 잡아내는 Zig의 실행 검사에 해당하는 기능은 없음
    • Miri와 Valgrind가 도움을 줄 수 있지만 이를 사용하는 Rust 프로젝트는 많지 않음
    • 대신 unsafe를 드물게 사용하고 더 엄격히 검토하는 문화가 실무에서 Rust의 강한 메모리 안전성 평판을 만듦
    • Rust 기반 프로젝트에서도 unsafe 관련 취약점이 발생한 사례가 있음
      • Deno에는 범위 밖 읽기와 use-after-free CVE가 있었음
      • Rocket에는 use-after-free CVE가 있었음
      • Actix는 unsafe 사용량이 비정상적으로 높던 시기에 여러 메모리 비안전성 CVE가 발생함
    • Roc은 할당 대부분이 수명이 단순한 아레나에서 일어나므로 use-after-free를 큰 위험으로 보지 않았고, Zig의 추가 검사가 본질적으로 비안전한 코드에 더 유용하다고 판단함

재작성 후 확인한 메모리 손상 버그

  • Claude Opus 4.8로 Roc 이슈 트래커를 분류한 결과는 다음과 같음
컴파일러 메모리 손상 발생 메모리 손상 없음 전체
Rust 21 2,575 2,596
Zig 10 421 431
  • Rust 컴파일러의 메모리 손상 21건은 컴파일러 내부 로직의 손상이 아니었음
    • borrow checker는 의도한 역할을 수행함
    • 잘못 생성된 기계어가 컴파일된 프로그램에서 메모리를 손상시킨 오컴파일 버그였음
  • Zig 컴파일러의 메모리 손상 10건 중 8건도 오컴파일이었음
  • 나머지 2건은 오류 보고 코드의 use-after-free였음
    • roc check와 roc bundle의 오류 메시지에서 파일명이 U+FFFD 대체 문자로 깨졌음
    • Rust borrow checker였다면 두 버그를 모두 잡을 수 있었음
  • 다른 도구 선택이 실제 사용자에게 미쳤을 결과는 다음과 같음
도구 선택 실제 메모리 안전성 영향
Zig ReleaseFast 일부 오류 메시지에서 파일명이 렌더링되지 않는 버그 2건
Zig ReleaseSafe 일부 오류가 패닉하고 파일명을 렌더링하지 못하는 버그 2건
Rust borrow checker 두 버그 모두 방지
  • 18개월, 수십만 줄, 수백 건의 버그 보고를 고려하면 세 선택지의 차이는 프로젝트에 실질적으로 크지 않았음
  • Bun은 JavaScript의 추적식 가비지 컬렉션 값과 수동 관리 값을 함께 다루면서 use-after-free, double-free, 해제 누락이 큰 비중을 차지했음
  • Roc 컴파일러는 JavaScript나 다른 추적식 가비지 컬렉터와 연동하지 않으므로 같은 수명 관리 문제를 겪지 않음
  • Roc에는 컴파일러 내부 메모리 오류보다 생성된 출력 코드의 메모리 오류를 찾는 도구가 더 필요하며, 후자는 borrow checker의 범위 밖임

빌드 시간의 실제 결과

  • zig build --watch -fincremental은 현재 약 45만~46만 줄의 Zig 코드에서 변경 사항을 약 35ms에 다시 빌드함
  • 안정 버전 Zig 0.16.0에는 Roc 코드베이스에서 -fincremental을 깨뜨리는 버그가 있음
    • 수정 사항은 반영됐지만 이를 사용하려면 호환성이 깨지는 Zig 0.17.0 사전 빌드로 옮겨야 함
    • 관련 의존성도 함께 벤더링하고 0.17.0으로 업그레이드해야 하므로 다음 안정 릴리스를 기다리기로 함
  • Intel 데스크톱과 Ubuntu 26에서 측정한 결과는 다음과 같음
Roc 컴파일러 코드 규모 콜드 빌드 증분 빌드
Rust 1.85.0 기반 원본 354K 32.4초 10.0초
Rust 1.97.0 기반 원본 354K 25.4초 3.4초
Zig 0.16.0 기능 동등성 시점 320K 39.6초 8.6초
Zig 0.17.0 현재 재작성본 464K 32.1초 0.035초
  • 기능 동등성 시점에는 거의 변하지 않는 산출물도 매번 다시 빌드했지만, 현재는 필요할 때만 생성함
    • 이 변경으로 코드가 약 50% 늘었는데도 콜드 빌드는 더 빨라짐
  • Rust 1.85.0에서 1.97.0으로 넘어가며 증분 빌드는 10초에서 3.4초로 줄어들어 18개월 동안 약 3분의 2가 개선됨
  • Zig의 35ms는 3.4초의 약 100분의 1이며, Rust 측정 코드보다 약 50% 큰 코드베이스에서 나온 결과임
  • 현재 -fincremental은 x86-64에서만 작동하고 많은 기여자가 ARM 기반 Mac을 사용하므로 아직 빌드 시간 이점을 온전히 활용하지 못함

포인터 없는 구조와 무파싱 역직렬화

  • 새 디스크 캐시는 Zig 컴파일러와 게임 개발에서 쓰이는 방식처럼 실행에 효율적인 메모리 배치를 그대로 디스크 형식으로 사용함
  • 모든 컴파일러 데이터 구조는 포인터 대신 32비트 인덱스 배열로 표현되며, 여러 곳에서 구조체 배열 형식을 사용함
    • 메모리 사용량을 줄이고 실행 속도를 높임
    • 별도 직렬화 형식으로 바꾸지 않고 데이터 구조를 디스크에 직접 기록할 수 있음
  • 역직렬화할 때는 디스크 바이트를 파싱하지 않음
    • 바이트를 메모리에 읽음
    • 기존 데이터 구조가 새 배열을 가리키도록 일부 재배치함
    • 이후 즉시 사용할 수 있음
  • 속도는 사실상 디스크에서 메모리로 바이트를 읽는 I/O 속도로 제한됨
    • 운영체제 디스크 캐시에 데이터가 있으면 대략 memcpy 속도로 이전 빌드 결과를 불러옴
  • roc check는 첫 실행에서 파싱·타입 검사 등의 결과를 디스크에 저장함
    • 입력 소스가 바뀌지 않았다면 두 번째 실행에서는 데이터 구조를 디스크에서 메모리로 바로 옮김
  • roc test는 결정적인 순수 함수 테스트 결과도 캐시함
  • 캐시는 파일 단위로 동작하므로 한 파일을 바꾸면 해당 파일과 의존 파일만 다시 처리함
  • 이 방식은 컴파일러 전체가 포인터 대신 인덱스를 사용하기 때문에 가능하며, 일반적인 포인터 중심 구조에서는 무파싱 역직렬화가 불가능함
  • 인덱스 기반 구조의 안전성 한계

    • 포인터가 잘못된 주소를 가리킬 수 있듯 인덱스도 잘못된 배열을 조회해 임의의 바이트를 읽을 수 있음
    • Rust borrow checker는 포인터 수명을 다루지만 어떤 인덱스가 어떤 배열에 속하는지는 검사하지 않음
    • 필요한 배열 수를 사전에 안다면 Rust의 compact_arena가 매크로로 타입 태그를 만들어 잘못된 배열 조회를 막을 수 있음
    • Roc처럼 모듈 수에 따라 배열 수가 달라지면 이 기법을 적용할 수 없어 compact_arena도 SmallArena::new를 unsafe로 표시함
    • 빈 아레나 생성 자체는 위험을 만들지 않지만 실제 위험은 매우 자주 수행하는 배열 인덱싱에 있음
    • Safe Rust는 unsafe가 작고 격리돼 있다는 가정에서 효과적이지만, Roc처럼 unsafe가 광범위하면 이 가정이 성립하지 않음

Roc에 맞았던 Zig 생태계

  • Bun은 JavaScript와 수동 메모리의 상호 운용 때문에 정리 코드를 한 번만 실행하는 Rust의 Drop이 유용했음
  • Roc은 반대로 모듈과 컴파일 단계마다 별도 아레나를 사용하려 하므로, 전역 할당자와 암묵적 Drop을 전제로 한 Rust 패키지가 불편했음
  • Zig 생태계는 할당자를 명시적으로 전달하는 API가 일반적이어서 Roc의 메모리 관리 방식과 잘 맞음
  • Rust 생태계는 Bun이 원하는 구조에, Zig 생태계는 Roc이 원하는 구조에 각각 더 적합했음
  • LLVM bitcode 직렬화기 재사용

    • LLVM은 Roc 최적화기의 핵심 의존성이며, Roc 자체 최적화 뒤에 추가 최적화를 수행함
    • LLVM은 주요 API 호환성을 자주 깨뜨려 버전 업그레이드에 상당한 시간과 비용이 필요했음
    • LLVM의 직렬화된 bitcode 형식은 안정적이고 하위 호환되므로, 자체 직렬화기를 사용하면 C++ API 변경에서 벗어날 수 있음
    • 이를 위해서는 LLVM C++ 라이브러리와 분리된 수작업 bitcode 직렬화기가 필요함
    • 기존에 알려진 구현은 Zig 컴파일러에 있었고, Roc의 새 컴파일러는 해당 Zig 코드를 재사용함
    • Roc이 Zig 생태계에서 얻는 가장 큰 의존성 원천은 일반 패키지보다 Zig 컴파일러 자체

Zig에서 그리운 Rust 기능

  • 컴파일러 구현에서는 명시적 할당 제어가 필요하지만 테스트에서는 Rust의 자동 할당·해제가 더 편리했음
    • Zig 테스트 할당자는 메모리 누수를 찾아내고 컴파일된 Roc 코드의 누수도 감지할 수 있음
    • 대신 각 테스트에서 init과 defer deinit을 정확히 작성해야 하며, 하나라도 잘못되면 누수로 테스트가 실패함
  • Zig의 comptime이 매개변수적·임시 다형성과 겹치지만 두 형태의 다형성이 그리움
    • Rust의 Allocator trait은 self를 받을 수 있음
    • Zig의 ArenaAllocator 같은 구현은 anyopaque 포인터를 받은 뒤 자기 타입으로 캐스팅해야 함
  • 비공개 구조체 필드가 없어 직접 접근하면 안 되는 필드를 컴파일 오류로 막을 수 없음
    • 코드 차이 검토에서는 필드 접근만 보이고 원래 구조체의 문서는 보이지 않으므로 매번 별도로 확인해야 함
  • 함수·변수·상수가 모두 snake_case를 사용하는 Rust의 일관성이 때때로 그리움
  • unsafe와 borrow checker는 비용도 있었지만 특정 문제를 unsafe 블록 안에서만 걱정하게 해주는 안정감이 있었음
    • Zig에 같은 기능을 추가해야 한다는 입장은 아님
  • Zig에서는 Rust보다 죽은 코드를 뒤늦게 발견하는 일이 많았음
    • Zig 내장 도구와 TigerBeetle의 tidy.zig도 일부 죽은 코드를 잡지 못함
    • 죽은 코드는 바이너리에 생성되지 않아 사용자에게 영향을 주지 않지만 코드베이스 관리에는 불리함
  • Rust는 소수 버전 업그레이드와 에디션 변경이 대부분 수월했음
  • 현재 Zig는 하위 호환성을 목표로 하지 않으며 예상했던 조건이라 큰 문제는 아니었지만, Rust의 간단한 업그레이드 경험은 더 나았음

Zig에서 만족한 부분

  • Zig는 함수형 프로그래밍처럼 익숙한 도구를 줄이는 대신 다른 속성을 얻는 감산적 설계의 매력이 있음
  • 매크로가 없으며, 매개변수적 다형성을 포함한 많은 문제를 comptime이나 일반 함수로 해결할 수 있음
  • 데이터 배치를 세밀하게 제어할 수 있음
    • u7, u5처럼 2의 거듭제곱이 아닌 정수 타입을 별도 비트 처리 없이 사용함
    • packed struct를 기본 지원함
    • 선언 위치가 아니라 호출 위치에서 함수를 인라인할 수 있음
    • Rust에서는 매크로 기반 크레이트가 필요한 기능을 추가 의존성 없이 이용함
  • Zig 빌드 도구 체인은 Alpine Linux와 WebAssembly용 독립 실행형 바이너리 생성에 잘 맞았음
    • Roc 표준 라이브러리에 해당하는 builtins를 불투명한 바이너리 블롭으로 컴파일해 최종 실행 파일에 포함하는 특수한 빌드도 처리함
    • Uber도 Zig 언어를 사용하지 않으면서 빌드 인프라에 Zig 도구 체인을 사용함
  • Zig의 오류 처리에서는 실패한 힙 할당도 일반 사용자 공간 오류로 다룸
  • Roc도 익명 합 타입과 페이로드를 이용해 오류가 자연스럽게 누적되는 유사한 전략을 사용함
  • Rust의 anyhow, thiserror, 기본 Result 기반 처리보다 Zig와 Roc의 방식을 선호함
  • 오류 전파 문법은 Zig의 try보다 Rust의 후위 ?를 선호해 Roc에도 후위 ? 연산자를 채택함
  • 세분화된 할당자 API와 고성능 컴파일러용 재사용 코드를 포함해, 프로젝트 전체로는 Zig 선택에 매우 만족함

Roc의 다음 단계

  • 새 컴파일러의 0.1.0을 올해 후반에 출시할 계획이며, Roc 최초로 번호가 붙은 릴리스가 됨
  • 출시 전에도 Nightly 빌드를 시험할 수 있지만 현재는 여러 버그, 미완성 기능, 불완전한 문서가 남아 있음
  • Roc Programming Language Foundation은 미국의 501(c)(3) 비영리단체이며, 기부금은 주로 기여자 보상에 사용함
  • 향후 개발 진행과 질문은 Roc Zulip에서 확인할 수 있음
Read Entire Article