컴파일러에 빌드 시스템을 적용하기
요약
본 글은 OCaml 컴파일러에 '효과(effects)'를 적용하여 빌드 시스템을 개선한 내용을 다룹니다. 이를 통해 컴파일러가 온디맨드 라이브러리처럼 작동하는 '컴파일러 서비스'를 만들 수 있게 되었습니다. 이 기술적 접근 방식은 의존성 그래프의 제약을 우회하고, 단일 프로세스 내에서 여러 파일을 순차적으로 컴파일할 수 있는 유연성을 제공합니다.
핵심 포인트
- 효과(effects)를 활용하여 OCaml 컴파일러에 빌드 시스템을 적용했습니다.
- 컴파일러가 온디맨드 라이브러처럼 작동하는 '컴파일러 서비스' 구현이 핵심입니다.
- 전통적인 의존성 그래프 제약을 우회하고, 단일 프로세스에서 파일들을 순차적으로 처리할 수 있습니다.
지난 여름 동안 Lucas Ma는 OCaml 컴파일러 자체에서 효과(effects)를 사용하는 아이디어를 조사해 왔습니다. 그는 자신의 발견과 모험에 대해 블로그로 기록했습니다. 이 작업의 기술적 핵심은 OCaml 컴파일러를 온디맨드 라이브러리처럼 사용하여 더 오래 지속되는 “컴파일러 서비스”를 만들 수 있게 하는 방향으로 나아갑니다. 그 자체로는 전혀 혁신적이지 않지만, 30년 된 코드베이스로 이런 작업을 수행하는 것은 매우 어렵습니다.
Lucas는 OCaml의 빌드 시스템에 상당히 빠르게 익숙해졌고, 처음에는 Load_path라는 컴파일러의 핵심 내부 부분을 일반화하는 것을 살펴보았습니다. 이것은 컴파일러가 파일을 스캔하기 위해 다양한 “include” 디렉토리를 사용하는 데 사용됩니다. 주로 타입 정보를 찾기 위함입니다. 예를 들어, 코드가 Unix.stat을 호출하는 경우, 타입 검사기는 Unix라는 모듈에 대한 타입 정보가 필요하며, 이는 Load_path에서 unix.cmi를 요청하게 하고, 이 파일이 궁극적으로는 예컨대 ~/.opam/switch/lib/ocaml/unix/unix.cmi로 해결되기를 바랍니다.
효과(Effects)는 이러한 조회에 대한 제어권을 역전시키는 우아한 방법을 제공합니다. 이렇게 하면 컴파일러를 호출하는 프로그램이 이 파일들이 어떻게 조회되는지 변경할 수 있습니다. 또한, 실제로 존재하는 파일에 대해 컴파일러에게 “거짓말”을 할 기회도 주는데, 이것이 Lucas가 이 변화로 가장 먼저 시도한 것이었습니다. 특히, 이는 의존성 그래프를 무시할 수 있게 해줍니다. 모듈을 컴파일할 때, OCaml은 해당 모듈이 참조하는 모든 타입 정보가 사전에 컴파일되었어야 합니다. 만약 bar.ml에 인터페이스가 있는 bar.mli라는 모듈이 있고 코드가 Foo.value를 참조한다면, OCaml은 foo.mli와 bar.mli 둘 다 bar.ml이 컴파일되기 전에 컴파일되었어야 합니다. 하지만 이 효과(effectful) 트릭 덕분에 Lucas는 대신 컴파일러가 오직 bar.ml만 가지고 시작하도록 허용할 수 있었습니다. Foo.value를 만나면, foo.cmi에 대한 요청이 발생합니다.
, 그 지점에서 첫 번째 프로토타입에서는 컴파일러가 foo.mli를 컴파일하기 위해 자신을 또 다른 인스턴스로 빠르게 생성했습니다.
그리고 그 후에 bar.ml의 컴파일을 재개했습니다.
마찬가지로, 컴파일 마지막 부분에서 bar.cmi와 함께 같은 트릭이 발생합니다.
즉, 세 개의 파일(foo.mli, bar.mli, 그리고 bar.ml) 모두 오직 ocamlc -c bar.ml 명령만으로 컴파일되는 것입니다.
언젠가 OCaml의 소스 트리에서 이런 괴물 같은 것들을 제거할 수 있게 된다면 꽤 멋질 수도 있지만, 지금까지는 그렇게 흥미롭지는 않습니다. 하지만 효과(effects)는 단순히 컴파일러 작업에 대한 후크 이상의 것을 제공합니다. 우리는 컨티뉴에이션(continuation) 안에 전체 중단된 컴파일을 담았습니다... 이는 같은 컴파일러 “프로세스”가 이제 다른 작업을 수행할 수 있다는 것을 의미합니다. 다음 트릭은 새로운 컴파일러를 생성하는 대신, 현재 프로세스 자체가 컴파일러 내부로 돌아와 필요한 인터페이스 파일을 자체적으로 컴파일하고 그 후에 이전 파일의 컨티뉴에이션을 단순히 재개하도록 하는 것이었습니다. 이 지점에서 30년 된 코드베이스가 다시 모습을 드러냅니다. 속도와 공간상의 이유로, 컴파일러의 많은 부분, 특히 타입 체커(type checker)는 많은 전역 가변 상태(global mutable state)를 특징으로 합니다. 특히, 컴파일 파이프라인은 재진입 가능하지 않습니다(not re-entrant). 다행히 Merlin 프로젝트 덕분에, 타입 체커에는 이 모든 전역 상태의 스냅샷을 찍는 메커니즘이 있습니다. Lucas는 이를 활용하여, 컴파일러가 아직 존재하지 않는 .cmi 파일을 요청하는 효과(effect)를 수행하기 직전에, 모든 전역 상태를 스냅샷으로 찍고, 해당 효과를 수행한 다음, 재개될 때 그 상태를 다시 복원할 수 있게 했습니다.
이것을 사용하여 타입 검사를 중단하고 다른 작업을 시작하는 것은 이 Local_store가 하는 일과는 조금 다릅니다.
메커니즘은 원래 의도되었던 것이었으며, 등록되지 않고 있던 몇 가지 추가적인 전역 상태(global state) 조각들을 찾기 위해 약간의 디버깅이 필요했지만, Lucas는 컴파일러가 사전에 컴파일된 것이 전혀 없는 상태에서 OCaml 바이트코드 컴파일러를 빌드할 수 있는 방법을 얻었습니다. 컴파일러에게 필요한 것은 단지 필요한 .ml 파일 목록뿐이었습니다. 툴체인(toolchain) 관점에서 볼 때, 우리는 본질적으로 ocamldep을 폐기하는 것입니다.
지금까지는 여전히 대부분 깔끔했습니다: 하나의 컴파일러 프로세스(거의)가 스스로를 성공적으로 재컴파일하는 것만으로 충분했습니다. 하지만 이것은 make -j1과 동일합니다.
- 순차적이며, 따라서 느린 빌드입니다. 멋진 부분은 다음이었습니다. 도메인(Domains). Lucas가 작업하던 최종 버전에서는 여러 개의 도메인이 시작되었고, 각각의 도메인은 컴파일러에 필요한 .ml 파일 중 하나를 병렬로 컴파일하기 시작했습니다. 스케줄러는 이들 각각으로부터 발생하는 효과(effects)를 순차적으로 처리하고, .mli 파일을 컴파일해야 할 때 이를 전송(despatching)했습니다.
Local_store메커니즘이 여기서 유용하게 사용되었습니다. Lucas는 이를 도메인 로컬 스토리지(Domain Local Storage)와 스냅샷팅(snapshotting)을 결합하여 확장했습니다. 프로토타입은 단순성을 위해 이들 도메인 간의 공유 기능은 포함하지 않았습니다.
여름이 끝날 무렵, 이것은 매우 거의 작동하는 상태였는데, 이는 제가 예상했던 시간보다 훨씬 더 나아간 결과였습니다! 이러한 조사에서 흔히 그렇듯이, Lucas의 작업은 이 분야에 대해 이전에 저에게 명확하지 않았던 몇 가지 새로운 측면들을 밝혀냈습니다. 저는 이전에 우리가 이러한 종류의 멀티스레드 컴파일러를 드라이버 프로그램(driver programs)을 통해 사용자에게 어떻게 노출할지 궁금해했었지만, 이것이 필요하지 않은 것이라는 점이 점점 명확해졌습니다. 즉, 저희가 컴파일러 자체를 빌드하기 위해 작업하던 프로그램은 물론 컴파일러 드라이버가 아니라, 빌드 시스템이었던 것입니다. 저에게는 이 점에서 두 가지 특히 흥미로운 점이 있습니다:
이것은 정말 간단한 빌드 시스템입니다. 병렬 타입 검사기(parallel type checker)의 마지막 몇 가지 문제점들만 해결되면 (계속 읽어보세요...), 이것이 정말 간단하고 성능이 좋다는 점을 추가할 수 있을 것입니다. 또한, 이는 근본적으로 이식성이 뛰어납니다. 이를 통해 OCaml 자체로 쉽게 부트스트래핑(bootstrapping)하는 것이 가능해집니다. 이는 이전에 ocamlbuild를 사용해서도 시도된 적이 있지만, 그 결과는 유지보수 측면에서 재앙적이었습니다. 하지만 다중 도메인 효과 스케줄링 접근 방식의 순수한 단순함이 숙련된 빌드 시스템 해커들을 흥분시키고 있습니다…
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기