Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

컴파일러 개요

이 장에서는 프로그램을 컴파일하는 전체 과정, 즉 모든 요소가 어떻게 맞물려 동작하는지를 다룹니다.

Rust 컴파일러는 두 가지 면에서 특별합니다. 다른 컴파일러가 하지 않는 작업(예: 빌림 검사)을 코드에 수행하며, 관례에서 벗어난 구현 방식(예: 쿼리)을 많이 택하고 있습니다. 이 장에서는 이 두 가지를 차례로 다루고, 가이드의 나머지 부분에서는 각 구성 요소를 더 자세히 살펴보겠습니다.

컴파일러가 코드에 수행하는 작업

먼저 컴파일러가 코드에 어떤 작업을 수행하는지 살펴봅시다. 당분간은 필요한 경우를 제외하고 컴파일러가 이 단계들을 어떻게 구현하는지는 언급하지 않겠습니다.

호출

컴파일은 사용자가 Rust 소스 프로그램을 텍스트로 작성하고 그 프로그램에 대해 rustc 컴파일러를 호출할 때 시작됩니다. 컴파일러가 수행해야 하는 작업은 명령줄 옵션으로 정의됩니다. 예를 들어 nightly 기능(-Z 플래그)을 활성화하거나, check 전용 빌드를 수행하거나, 실행 가능한 기계어 코드 대신 LLVM 중간 표현(LLVM-IR)을 출력할 수 있습니다. rustc 실행 파일은 cargo를 통해 간접적으로 호출될 수도 있습니다.

명령줄 인자 파싱은 rustc_driver에서 이루어집니다. 이 크레이트는 사용자가 요청한 컴파일 구성을 정의하고, 이를 rustc_interface::Config의 형태로 나머지 컴파일 과정에 전달합니다.

렉싱과 파싱

가공되지 않은 Rust 소스 텍스트는 rustc_lexer에 있는 저수준 렉서가 분석합니다. 이 단계에서 소스 텍스트는 토큰이라고 부르는 원자적 소스 코드 단위의 스트림으로 변환됩니다. lexer는 유니코드 문자 인코딩을 지원합니다.

토큰 스트림은 컴파일 과정의 다음 단계를 준비하기 위해 rustc_parse에 있는 더 높은 수준의 렉서를 거칩니다. 이 단계에서는 Lexer struct를 사용하여 일련의 검증을 수행하고 문자열을 인턴된 심볼로 변환합니다(인터닝은 뒤에서 다룹니다). 문자열 인터닝은 서로 다른 문자열 값마다 불변 사본을 하나만 저장하는 방식입니다.

렉서는 인터페이스가 작으며 rustc의 진단 인프라에 직접 의존하지 않습니다. 그 대신 진단을 일반 데이터로 제공하며, 이 데이터는 rustc_parse::lexer에서 실제 진단으로 출력됩니다. lexer는 IDE와 절차적 매크로(때로는 “proc-macros“라고도 합니다) 모두를 위해 원본 정보를 손실 없이 보존합니다.

파서는 lexer에서 받은 토큰 스트림을 추상 구문 트리(AST)로 변환합니다. 파서는 구문 분석에 재귀 하향(top-down) 방식을 사용합니다. parser의 크레이트 진입점은 rustc_parse::parser::Parser에 있는 Parser::parse_crate_mod 메서드와 Parser::parse_mod 메서드입니다. 외부 모듈 파싱의 진입점은 rustc_expand::module::parse_external_mod입니다. 그리고 매크로 parser의 진입점은 Parser::parse_nonterminal입니다.

파싱은 bump, check, eat, expect, look_ahead를 비롯한 여러 parser 유틸리티 메서드를 사용하여 수행됩니다.

파싱은 의미론적 구성 요소별로 구성되어 있습니다. 개별 parse_* 메서드는 rustc_parse 디렉터리에서 찾을 수 있습니다. 소스 파일 이름은 구성 요소의 이름을 따릅니다. 예를 들어, parser에는 다음 파일들이 있습니다.

이 명명 방식은 여러 컴파일러 단계에 걸쳐 사용됩니다. 파싱, 로워링, 타입 검사, 타입이 지정된 고수준 중간 표현(THIR) 로워링, 중간 수준 중간 표현(MIR) 빌드 소스 전반에 걸쳐 같은 이름의 파일이나 디렉터리를 찾을 수 있습니다.

매크로 확장, AST 검증, 이름 해석, 초기 린트도 렉싱 및 파싱 단계에서 이루어집니다.

파서는 rustc_ast::ast::{Crate, Expr, Pat, …} AST 노드를 반환하며, 오류 처리에는 표준 Diag API가 사용됩니다. 일반적으로 Rust 컴파일러는 Rust 문법의 상위 집합을 파싱하여 오류로부터 복구를 시도하는 동시에 오류 타입도 내보냅니다.

AST 로워링

다음으로 AST는 AST를 컴파일러가 다루기 더 쉽게 표현한 고수준 중간 표현(HIR)으로 변환됩니다. 이 과정을 “로워링“이라고 하며, 루프나 async fn 같은 것에 대한 많은 디슈가링(축약되거나 생략된 구문 구조를 확장하고 정형화하는 것)이 포함됩니다.

그런 다음 HIR를 사용하여 타입 추론(표현식의 타입을 자동으로 알아내는 과정), 트레이트 해결(trait에 대한 각 참조를 impl과 짝짓는 과정), 타입 검사를 수행합니다. 타입 검사는 사용자가 작성한 내용을 나타내는 HIR의 타입(hir::Ty)을 컴파일러가 사용하는 내부 표현(Ty<'tcx>)으로 변환하는 과정입니다. 이 정보가 프로그램에서 사용되는 타입의 타입 안전성, 정확성, 일관성을 검증하는 데 사용되기 때문에 타입 검사라고 부릅니다.

MIR 로워링

HIR는 THIR(패턴 및 완전성 검사에 사용되는, 디슈가링이 한층 더 진행된 HIR)를 구성한 뒤 이를 MIR로 변환하는 방식으로 MIR(빌림 검사에 사용됨)로 한 단계 더 로어링됩니다.

MIR은 제네릭하며 이후의 코드 생성과 컴파일 속도를 향상시키기 때문에 MIR에서 많은 최적화를 수행합니다. 일부 최적화는 LLVM-IR 수준보다 MIR 수준에서 수행하는 것이 더 쉽습니다. 예를 들어, LLVM은 simplify_try MIR 최적화가 찾는 패턴을 최적화하지 못하는 것으로 보입니다.

Rust 코드는 코드 생성 중에 단형화되기도 하는데, 이는 타입 매개변수를 구체적인 타입으로 대체하여 모든 제네릭 코드의 사본을 만드는 것을 의미합니다. 이를 위해서는 어떤 구체적인 타입에 대해 코드를 생성해야 하는지 목록을 수집해야 합니다. 이를 단형화 수집이라고 하며, MIR 수준에서 이루어집니다.

코드 생성

이어서 간단히 코드 생성 또는 codegen이라고 부르는 단계를 시작합니다. 코드 생성 단계는 소스의 고수준 표현을 실행 가능한 바이너리로 바꾸는 단계입니다. rustc는 코드 생성에 LLVM을 사용하므로, 첫 번째 단계는 MIR을 LLVM-IR로 변환하는 것입니다. 바로 이 단계에서 MIR이 실제로 단형화(monomorphize)됩니다. LLVM-IR은 LLVM에 전달되고, LLVM은 이를 대상으로 훨씬 더 많은 최적화를 수행한 뒤 기계어 코드를 내보냅니다. 기계어 코드는 기본적으로 저수준 타입과 어노테이션이 추가된 어셈블리 코드입니다(예: ELF 오브젝트나 WASM). 그런 다음 여러 라이브러리와 바이너리를 함께 링크하여 최종 바이너리를 만듭니다.

컴파일러가 이를 수행하는 방법

지금까지 컴파일러가 여러분의 코드에 무엇을 하는지 개략적으로 살펴보았으니, 이제 컴파일러가 이 모든 일을 어떻게 하는지 개략적으로 살펴봅시다. 컴파일러가 충족하거나 최적화해야 하는 제약 조건과 상충하는 목표는 매우 많습니다. 예를 들면 다음과 같습니다.

  • 컴파일 속도: 프로그램을 얼마나 빠르게 컴파일합니까? 컴파일 타임 분석이 많아지거나 정교해질수록 컴파일이 느려지는 경우가 많습니다.
    • 또한 증분 컴파일을 지원하려 하므로 이 점도 고려해야 합니다. 사용자가 프로그램을 수정했을 때 어떤 작업을 다시 해야 하고 어떤 작업을 재사용할 수 있는지 어떻게 추적할 수 있겠습니까?
      • 또한 증분 캐시에 너무 많은 것을 저장할 수는 없습니다. 디스크에서 불러오는 데 시간이 오래 걸리고 사용자 시스템의 공간을 많이 차지할 수 있기 때문입니다…
  • 컴파일러 메모리 사용량: 프로그램을 컴파일하는 동안 필요 이상으로 메모리를 사용하고 싶지 않습니다.
  • 프로그램 속도: 컴파일된 프로그램은 얼마나 빠릅니까? 컴파일 타임 분석이 많아지거나 정교해질수록 컴파일러가 더 나은 최적화를 수행할 수 있는 경우가 많습니다.
  • 프로그램 크기: 컴파일된 바이너리는 얼마나 큽니까? 앞의 항목과 비슷합니다.
  • 컴파일러 컴파일 속도: 컴파일러 자체를 컴파일하는 데 얼마나 걸립니까? 이는 기여자와 컴파일러 유지 보수에 영향을 줍니다.
  • 구현 복잡도: 컴파일러를 만드는 일은 개인이나 집단이 할 수 있는 가장 어려운 일 중 하나이며, Rust는 그다지 단순한 언어가 아닙니다. 그렇다면 컴파일러의 코드베이스를 어떻게 관리 가능한 상태로 유지할 수 있겠습니까?
  • 컴파일러 정확성: 컴파일러가 만든 바이너리는 입력 프로그램이 명시한 대로 동작해야 하며, 끊임없이 일어나는 엄청난 양의 변경에도 불구하고 계속 그렇게 동작해야 합니다.
  • 통합: 여러 다른 도구(예: cargo, clippy, Miri)가 다양한 방식으로 컴파일러를 사용해야 하며, 이를 지원해야 합니다.
  • 컴파일러 안정성: 컴파일러는 stable 채널에서 크래시하거나 비정상적으로 실패해서는 안 됩니다.
  • Rust 안정성: 컴파일러는 구현에 항상 많은 변경이 일어나더라도 이전에 컴파일되던 프로그램을 깨뜨리지 않음으로써 Rust의 안정성 보장을 지켜야 합니다.
  • 다른 도구의 한계: rustc는 백엔드에서 LLVM을 사용하는데, LLVM에는 우리가 활용하는 강점도 있고 우회해야 하는 측면도 있습니다.

그러니 이 가이드의 나머지 부분을 계속 읽어 나가면서 이러한 사항을 염두에 두십시오. 이러한 사항은 우리가 내리는 결정에 영향을 주는 경우가 많습니다.

중간 표현

대부분의 컴파일러와 마찬가지로 rustc도 계산을 쉽게 하기 위해 몇 가지 중간 표현(IR)을 사용합니다. 일반적으로 소스 코드를 직접 다루는 것은 매우 불편하고 오류가 생기기 쉽습니다. 소스 코드는 모호하지 않으면서도 사람이 다루기 쉽도록 설계되어 있지만, 이를테면 타입 검사 같은 작업을 하기에는 덜 편리합니다.

대신 rustc를 포함한 대부분의 컴파일러는 소스 코드에서 분석하기 쉬운 일종의 IR을 빌드합니다. rustc에는 여러 IR이 있으며, 각각 서로 다른 목적에 맞게 최적화되어 있습니다:

  • 토큰 스트림: 렉서는 소스 코드로부터 직접 토큰 스트림을 생성합니다. 이 토큰 스트림은 원시 텍스트보다 파서가 다루기 더 쉽습니다.
  • 추상 구문 트리(AST): 추상 구문 트리는 렉서가 생성한 토큰 스트림으로부터 만들어집니다. 추상 구문 트리는 사용자가 작성한 내용을 거의 그대로 나타냅니다. 추상 구문 트리는 몇 가지 구문상의 온전성 검사(예: 사용자가 타입을 작성한 위치에 실제로 타입이 와야 하는지 확인하는 것)를 하는 데 도움이 됩니다.
  • 고수준 IR(HIR): 일종의 디슈가링된 AST입니다. 구문적으로는 여전히 사용자가 작성한 내용에 가깝지만, 생략된 일부 라이프타임 등 몇 가지 암묵적인 요소를 포함합니다. 이 IR은 타입 검사에 적합합니다.
  • 타입이 지정된 HIR(THIR) 이전 명칭은 고수준 추상 IR(HAIR): HIR과 MIR 사이의 중간 단계입니다. HIR과 비슷하지만, 타입이 완전히 지정되어 있고 디슈가링이 조금 더 진행되어 있습니다(예: 메서드 호출과 암묵적 역참조가 완전히 명시적으로 드러납니다). 그 결과 HIR보다 THIR에서 MIR로 로워링하는 것이 더 쉽습니다.
  • 중간 수준 IR(MIR): 이 IR은 기본적으로 제어 흐름 그래프(CFG)입니다. CFG는 프로그램의 기본 블록과 그 사이에서 제어 흐름이 어떻게 이동할 수 있는지를 보여 주는 일종의 다이어그램입니다. 마찬가지로 MIR에도 간단한 타입이 지정된 문장(예: 대입, 간단한 계산 등)을 담은 여러 기본 블록과, 다른 기본 블록으로 이어지는 제어 흐름 간선(예: 호출, 값 드롭)이 있습니다. MIR은 빌림 검사와, 초기화되지 않은 값 검사 같은 그 밖의 중요한 데이터 흐름 기반 검사에 사용됩니다. 또한 일련의 최적화와 (Miri를 통한) 상수 평가에도 사용됩니다. MIR은 아직 제네릭 상태이므로, 이 단계에서는 단형화 이후보다 많은 분석을 더 효율적으로 수행할 수 있습니다.
  • LLVM-IR: LLVM 컴파일러에 입력되는 모든 것의 표준 형식입니다. LLVM-IR은 많은 주석이 달린 일종의 타입 있는 어셈블리 언어입니다. LLVM을 사용하는 모든 컴파일러가 사용하는 표준 형식입니다(예: clang C 컴파일러도 LLVM-IR을 출력합니다). LLVM-IR은 다른 컴파일러가 쉽게 생성할 수 있으면서도, LLVM이 여러 최적화를 수행할 수 있을 만큼 풍부하도록 설계되었습니다.

또 하나 주목할 점은 컴파일러의 많은 값이 인터닝된다는 것입니다. 이는 값을 아레나라는 특별한 할당자에 할당하는 성능 및 메모리 최적화 기법입니다. 그런 다음 아레나에 할당된 값에 대한 참조를 주고받습니다. 이를 통해 동일한 값(예: 프로그램의 타입)이 한 번만 할당되도록 보장할 수 있으며, 포인터를 비교하는 것만으로 값을 저렴하게 비교할 수 있습니다. 많은 중간 표현이 인터닝됩니다.

쿼리

첫 번째 주요 구현상의 선택은 Rust가 컴파일러에서 쿼리 시스템을 사용한다는 점입니다. Rust 컴파일러는 코드를 대상으로 순차적으로 실행되는 일련의 패스로 구성되어 있지 않습니다. Rust 컴파일러가 이렇게 하는 것은 증분 컴파일을 가능하게 하기 위해서입니다. 즉, 사용자가 프로그램을 변경하고 다시 컴파일할 때, 새 바이너리를 출력하는 데 드는 중복 작업을 최대한 줄이고자 하는 것입니다.

rustc에서는 위의 모든 주요 단계가 서로를 호출하는 여러 쿼리로 구성되어 있습니다. 예를 들어, 어떤 대상의 타입을 요청하는 쿼리가 있고, 함수의 최적화된 MIR을 요청하는 쿼리도 있습니다. 이러한 쿼리는 서로를 호출할 수 있으며, 모두 쿼리 시스템을 통해 추적됩니다. 쿼리 결과는 디스크에 캐시되므로, 컴파일러는 지난 컴파일 이후 어떤 쿼리의 결과가 바뀌었는지 파악하여 해당 쿼리만 다시 수행할 수 있습니다. 증분 컴파일은 이러한 방식으로 동작합니다.

원칙적으로 쿼리화된 단계에서는 위의 각 작업을 항목마다 개별적으로 수행합니다. 예를 들어, 어떤 함수의 HIR을 가져와 쿼리를 사용해 그 HIR에 대한 LLVM-IR을 요청합니다. 이는 최적화된 MIR의 생성을 유발하고, 최적화된 MIR의 생성은 빌림 검사기를 구동하며, 빌림 검사기는 다시 MIR의 생성을 유발하는 식으로 이어집니다.

… 다만 이는 지나치게 단순화한 설명입니다. 실제로 일부 쿼리는 디스크에 캐시되지 않으며, 컴파일러의 일부(예: 빌림 검사기)는 정확성을 위해 코드가 죽은 코드이더라도 어차피 모든 코드에 대해 실행되어야 합니다. 예를 들어, 현재 mir_borrowck 쿼리는 먼저 크레이트의 모든 함수에 대해 실행됩니다. 그런 다음 코드 생성 백엔드가 collect_and_partition_mono_items 쿼리를 호출하는데, 이 쿼리는 먼저 도달 가능한 모든 함수에 대해 optimized_mir을 재귀적으로 요청하고, 이는 다시 해당 함수에 대해 mir_borrowck를 실행한 뒤 코드 생성 단위를 만듭니다. 도달할 수 없는 함수에서도 오류가 여전히 보고되도록 하려면 이러한 분리는 계속 유지되어야 합니다.

게다가 컴파일러는 원래 쿼리 시스템을 사용하도록 만들어지지 않았습니다. 쿼리 시스템은 나중에 컴파일러에 덧붙여진 것이므로, 컴파일러의 일부는 아직 쿼리화되지 않았습니다. 또한 LLVM은 우리 코드가 아니므로 역시 쿼리화되어 있지 않습니다. 최종적으로는 이전 절에 나열된 모든 단계를 쿼리화할 계획이지만, 2022년 11월 현재 HIR와 LLVM-IR 사이의 단계만 쿼리화되어 있습니다. 즉, 렉싱, 파싱, 이름 해석, 매크로 확장은 전체 프로그램에 대해 한꺼번에 수행됩니다.

여기서 한 가지 더 언급할 것은 매우 중요한 “타이핑 컨텍스트“인 TyCtxt입니다. 이는 모든 것의 중심에 있는 거대한 구조체입니다. (이 이름은 대체로 역사적인 이유로 붙은 것입니다. 이것은 타입 이론에서 말하는 Γ나 Δ와 같은 의미의 “타이핑 컨텍스트“가 아닙니다. 소스 코드에서 구조체 이름이 그렇게 되어 있기 때문에 이 이름을 유지하고 있습니다.) 모든 쿼리는 TyCtxt 타입의 메서드로 정의되며, 메모리 내 쿼리 캐시도 이곳에 저장됩니다. 코드에는 보통 타이핑 컨텍스트에 대한 핸들인 tcx라는 변수가 있습니다. 'tcx라는 이름의 라이프타임도 보게 될 텐데, 이는 어떤 대상이 TyCtxt의 라이프타임에 묶여 있다는 뜻입니다(대개 그곳에 저장되거나 인터닝되어 있습니다).

컴파일러의 쿼리에 대한 자세한 내용은 쿼리 장을 참고하십시오.

ty::Ty

Rust에서 타입은 매우 중요하며, 여러 컴파일러 분석의 핵심을 이룹니다. (사용자 프로그램의) 타입을 표현하는 (컴파일러 내의) 주요 타입은 rustc_middle::ty::Ty입니다. 이는 매우 중요하기 때문에 ty::Ty를 다루는 장이 따로 있지만, 지금은 이것이 존재하며 rustc가 타입을 표현하는 방식이라는 점만 언급해 두겠습니다!

또한 rustc_middle::ty 모듈이 앞서 언급한 TyCtxt 구조체를 정의한다는 점도 참고하십시오.

병렬성

컴파일러 성능은 우리가 개선하고자 하는(그리고 늘 개선하기 위해 노력하고 있는) 문제입니다. 그 한 측면은 rustc 자체를 병렬화하는 것입니다.

현재 rustc에서 기본적으로 병렬로 동작하는 부분은 코드 생성 하나뿐입니다.

그러나 컴파일러의 나머지 부분은 아직 병렬화되지 않았습니다. 이를 위해 많은 노력을 기울여 왔지만, 이는 대체로 어려운 문제입니다. 현재의 접근 방식은 RefCell을 Mutex로 바꾸는 것, 즉 스레드 안전한 내부 가변성으로 전환하는 것입니다. 그러나 락 경합, 동시성 환경에서의 쿼리 시스템 불변 조건 유지, 코드베이스의 복잡성 등 여전히 해결해야 할 과제가 남아 있습니다. bootstrap.toml에서 병렬 컴파일을 활성화하면 현재 진행 중인 작업을 직접 사용해 볼 수 있습니다. 아직 초기 단계이지만, 이미 몇 가지 유망한 성능 향상이 나타나고 있습니다.

부트스트래핑

rustc 자체는 Rust로 작성되어 있습니다. 그렇다면 컴파일러는 어떻게 컴파일합니까? 이전 버전의 컴파일러를 사용하여 새 버전의 컴파일러를 컴파일합니다. 이를 부트스트래핑이라고 합니다.

부트스트래핑에는 흥미로운 함의가 많습니다. 예를 들어, 이는 Rust의 주요 사용자 중 하나가 Rust 컴파일러라는 뜻이므로, 우리는 우리 자신의 소프트웨어를 끊임없이 테스트하고 있는 셈입니다(“자기 개밥 먹기”).

부트스트래핑에 대한 자세한 내용은 가이드의 부트스트래핑 절을 참고하십시오.

참고 자료