Zig의 증분 컴파일 내부 구조

Zig의 증분 컴파일 내부 구조

Zig 증분 컴파일 개요

Zig의 증분 컴파일은 ZIR 캐싱, 의존성 그래프 사용, 그리고 증분 링킹을 통해 변경된 코드를 밀리초 단위로 재빌드합니다. 컴파일러는 먼저 각 소스 파일을 ZIR로 변환하고 결과를 캐싱하며, 파일의 해시가 변경될 때만 재빌드를 수행합니다. 의미 분석(semantic analysis) 중에 분석 단위(layout, type, value, body)와 소스 코드 영역 간의 의존성을 추적하므로, 변경된 해시에 의존하는 단위만 무효화됩니다. 코드 생성은 함수 단위로 작동하며, 즉시 링커로 전달되는 MIR를 생성합니다. 링커는 출력 바이너리를 메모리 맵(memory-map)하고 노드 트리(MappedFile)를 사용하여 코드 섹션을 삽입하거나 크기를 조정하며, 노드가 dirty로 표시된 경우에만 fix-up을 적용합니다. 코드 생성 후 컴파일러는 참조 그래프를 탐색하여 도달 가능한 심볼만 유지하고, 링커의 flush를 호출하여 바이너리를 완성합니다. Tracy 프로파일링 결과 대부분의 시간이 참조 그래프 탐색에 소요되는 것으로 나타나, 추가적인 최적화 기회가 있음을 보여줍니다. 오늘 바로 증분 컴파일을 시도하려면, x86_64-linux를 타겟으로 하는 최신 master 빌드에서 zig build --watch -fincremental을 실행하십시오(또는 단계별 옵션 사용). 이 기능은 아직 안정적이지 않습니다. 디버그 빌드에서 가장 잘 작동하며 수동으로 캐시를 삭제해야 할 수도 있습니다. 향후 작업은 안정화, 다른 타겟 지원, 그리고 전체 참조 그래프의 재계산 방지에 집중될 것입니다.

파일 수준 캐싱 및 병렬성

컴파일러는 각 소스 파일의 ZIR을 디스크에 캐싱하며, 파싱과 AstGen이 순수 함수(pure functions)이기 때문에 파일을 병렬로 처리합니다. 파일을 읽고, AST로 파싱하고, 해당 AST를 ZIR로 변환하는 과정에는 공유되거나 외부 상태가 포함되지 않습니다. 이 단계들은 매우 빠릅니다. 전체 Zig 컴파일러 src/ 디렉토리에 대해 단일 스레드로 파싱과 AstGen을 수행하면 약 920ms가 소요됩니다. ZIR은 단일 writev/readv 시스템 호출로 디스크에 쓰고 읽을 수 있으므로 캐시 구현이 매우 간단합니다. 순수 함수 특성 덕분에 작업은 매우 병렬적입니다. 스레드 풀이 각 새로 발견된 소스 파일을 독립적으로 처리할 수 있으며, 이미 확인된 경로의 해시 세트만 뮤텍스(mutex)로 보호하면 됩니다. ZIR을 캐싱하고 파일의 소스 해시가 변경될 때만 재빌드하는 기능은 수년 동안 기본적으로 활성화되어 있어, 대부분의 경우 파이프라인의 이 부분이 거의 즉각적으로 수행됩니다.

의존성 그래프를 통한 의미 분석

증분 재컴파일은 변경 사항을 감지하기 위해 소스 코드 해시를 추적하는 분석 단위(layout, type, value, body)의 세밀한 그래프에 의존합니다. 의미 분석 중에 컴파일러는 각 단위에 대해 의존하는 다른 단위들의 집합을 채웁니다. 예를 들어, 전역 변수를 로드하는 함수의 body를 분석하면 해당 변수의 type에 대한 의존성이 추가됩니다. 만약 변수가 comptime으로 알려져 있다면, 해당 값(value)에 대한 의존성도 추가됩니다. 단위는 또한 자신을 정의하는 소스 코드 영역에 의존합니다. 컴파일러는 ZIR에 각 영역의 해시를 저장하므로, 소스 변경은 해시를 변경하고 의존하는 단위를 구식(outdated)으로 표시합니다. 소스 파일이 편집되면 컴파일러는 이름으로 이전 ZIR과 새 ZIR을 매핑하고, 해시를 비교한 다음, 해시가 변경된 단위만 재분석하고, 해당 값에 의존하는 모든 단위로 전파합니다. 런타임 함수의 body에 대한 의존성은 존재할 수 없으므로, 함수-body 단위는 그래프에서 나가는 엣지(outgoing edges)만 가집니다. 이 설계는 그래프를 다룰 수 있고 증분적으로 만들기 위해 의존성의 종류를 의도적으로 제한합니다.

코드 생성 및 MIR

코드 생성은 함수 단위로 작동하므로 AIR나 MIR의 캐싱이 필요하지 않습니다. 출력은 링커로 직접 전달됩니다. 이 단계는 의미 분석의 AIR를 머신 인스트럭션과 밀접하게 매핑되는 MIR로 변환합니다. AIR와 MIR는 증분 컴파일에서 사용하는 것과 동일한 개별 함수 단위의 세밀함으로 존재하므로, 이를 캐싱할 이점이 없습니다. 사용 후 폐기됩니다. 코드 생성은 매우 병렬적입니다. 각 함수의 AIR는 큐에 대기열로 추가되어 임의의 수의 스레드에 의해 처리될 수 있으며, 무한한 메모리 증가를 방지하기 위한 큐 크기 가드만 있으면 됩니다.

MappedFile을 이용한 증분 링킹

링커는 출력 바이너리를 메모리 맵하고 노드 트리(MappedFile)를 사용하여 코드 섹션을 삽입하거나 크기를 조정하며, 노드가 dirty로 표시된 경우에만 fix-up을 적용합니다. 증분 링킹이 없다면 링커는 모든 코드가 알려진 후에야 주소를 할당하고 재배치(relocation)를 적용할 것입니다. MappedFile를 사용하면, codegen이 함수에 대한 머신 코드를 생성한 후, 링커는 해당 코드를 담기에 충분한 크기로 매핑된 파일의 노드를 생성하거나 크기를 조정하고, 코드를 복사한 다음 노드에 dirty 플래그를 설정합니다. 부모 노드에 공간이 부족하면 MappedFile은 다른 노드들을 이동시켜 공간을 만들고, 필요에 따라 dirty 플래그를 전파합니다. 링커 스레드가 유휴 상태이거나 컴파일이 끝나면 모든 dirty 노드를 처리합니다: 가상 주소를 할당하고, 섹션/프로그램 헤더를 업데이트하고, 심볼 테이블 엔트리를 업데이트하고, 재배치를 다시 적용합니다. 노드는 (ArrayList처럼) 기하급수적으로 증가하므로 실제로는 이동이 드물어 평균 비용이 낮게 유지됩니다. 이 접근 방식은 전체 오브젝트 파일을 diff할 필요를 없애줍니다. 컴파일러-링커 통합을 통해 링커는 무엇이 변경되었는지 정확히 알 수 있습니다.

Flush 및 참조 해결

codegen 후, 컴파일러는 참조 그래프를 탐색하여 도달 가능한 심볼만 유지하고 링커의 flush를 호출하여 바이너리를 완성합니다. flush 단계는 어떤 Zig 선언이 실제로 참조되는지 결정하며, 마지막 빌드 이후 도달 불가능해진 선언은 무시합니다. 그 다음 모든 내보내기(exported)된 전역 심볼을 링커에 알려 링커가 적절한 심볼 테이블 엔트리를 추가할 수 있게 합니다. 마지막으로 링커의 flush 함수는 업데이트당 O(1)의 작업을 목표로 하면서.dynamic 섹션과 ELF 헤더 엔트리 필드를 쓰는 등의 남은 작업을 수행합니다. 여전히 dirty로 표시된 모든 MappedFile 노드는 이 단계에서 처리됩니다.

업데이트에 대한 Tracy 프로파일링

Tracy는 대부분의 시간이 참조 그래프 탐색에 소요됨을 보여주며, 이는 추가적인 최적화 기회가 있음을 나타냅니다. 37ms가 소요된 샘플 증분 업데이트에서, 처음 6ms는 파일별 ZIR 업데이트, 의미 분석, 코드 생성 및 링킹에 사용되었습니다. 나머지 약 31ms는 resolveReferencesInner에 소비되었는데, 이는 도달 가능한 선언을 결정하기 위해 전체 참조 그래프를 탐색합니다. 그래프가 변경되지 않았음에도 컴파일러는 매 업데이트마다 여전히 이를 재계산했습니다. 저자는 이것이 명확한 최적화 대상임을 언급합니다: 그래프가 변경되지 않았을 때는 재계산을 피하거나, 영향을 받는 부분만 업데이트하는 것입니다(동적 단일 소스 최단 경로 문제).

오늘 증분 컴파일 사용하기

증분 컴파일을 시도하려면 x86_64-linux를 타겟으로 하는 최신 master 빌드에서 zig build --watch -fincremental (또는 단계별 옵션)을 실행하십시오. --watch 플래그는 파일 시스템 변경 시 빌드 시스템이 재빌드하도록 합니다. -fincremental은 재빌드 시 증분 컴파일을 사용하도록 지시합니다. 기존의 디스크 캐시는 증분 컴파일과 호환되지 않으므로, 플래그를 활성화한 후 첫 실행 시에는 모든 것을 재빌드합니다. 그 후 소스 파일을 편집하고 저장하면 수십 밀리초 내에 완료되는 재빌드 트리거됩니다. 단계별 접근 방식을 사용하려면 build.zig-Dincremental 옵션을 노출시키고 옵션이 true일 때 exe.incremental = true로 설정한 다음, -fincremental 대신 -Dincremental을 사용하십시오. 저자는 이 워크플로우가 짧게 실행되는 프로그램에 가장 적합하다고 언급합니다. 오래 실행되는 그래픽 애플리케이션의 경우, 현재 빌드 시스템은 재빌드를 트리거하기 전에 이전 프로세스가 종료될 때까지 기다립니다.

현재 한계 및 향후 작업

증분 컴파일은 아직 안정적이지 않으며, 디버그 빌드에서 가장 잘 작동하고, 수동으로 캐시를 삭제해야 합니다. 향후 작업에는 기능 안정화, 다른 타겟 지원, 전체 참조 그래프의 재계산 방지 등이 포함됩니다. 저자는 증분 컴파일이 아직 안정적이지 않으며 잘못된 컴파일 오류나 오컴파일(miscompilation)이 포함될 수 있다고 명시적으로 밝힙니다. 이 포스트는 디버그 빌드에서 픽셀 에디터(Fizzy)의 빠른 재빌드를 보여주지만, 이 기능이 릴리스 빌드에서도 작동하는지는 다루지 않습니다. 한 댓글 작성자는 Zig 컴파일러가 C를 컴파일할 수 있으므로 C 코드에서도 작동하는지 물었으나, 포스트는 이에 답하지 않았습니다. 또 다른 댓글 작성자는 왜 링커가 여러 개의 작은 공유 라이브러리를 사용하는 대신 디버그 빌드에 하나의 거대한 바이너리를 생성하는지 의문을 제기했으나, 포스트는 대안적인 설계에 대해 논의하지 않습니다. 저자는 빌드 시스템의 향후 개선 사항으로, 실행 중인 프로그램이 종료될 때까지 기다리지 않고 재빌드를 트리거하는 것과 같은 다른 워크플로우를 지원할 계획이라고 언급합니다. 진행 중인 작업은 또한 결과물을 캐싱하거나 점진적으로 업데이트함으로써 비용이 많이 드는 참조 그래프 탐색을 제거하는 것을 목표로 합니다.

"이게 지금 릴리스 빌드에서도 작동하나요, 아니면 디버그 빌드에서만 작동하나요?" – @remywang

"Zig 컴파일러가 C를 컴파일할 수 있으니 C에서도 작동할까요?" – @hoppp

"이 설계에 대해 완전히 이해되지 않는 점이 있습니다. 왜 디버그 빌드에서 모든 코드가 포함된 거대한 바이너리를 만드는 것을 고집하는 건가요?" – @thefaux

Sources