Read OSS

编译编排、缓存与增量编译

高级

前置知识

  • 本系列第 1–4 篇文章
  • 了解缓存策略与依赖图
  • 熟悉并发编程(线程池、互斥锁、原子操作)

编译编排、缓存与增量编译

前四篇文章逐一剖析了 Zig 编译器的各个阶段:解析、AstGen、Sema、代码生成和链接。但这一切由谁来驱动?当源文件发生变化时,编译器如何判断哪些函数需要重新分析?缓存机制是如何工作的?自举流程又是如何将这一切整合在一起,从一个 WebAssembly blob 构建出完整编译器的?

本文作为系列终篇,聚焦于编排层——Compilation.update() 循环、任务队列系统、三种缓存模式、InternPool 的依赖跟踪,以及 PerThread 线程模型。

Compilation.update() 循环

一切从 Compilation.update() 开始。buildOutputType() 构造完 Compilation 后会调用它,长期运行的编译服务器也会在每次编辑循环中调用它。

函数的执行流程取决于缓存模式,但整体结构如下:

flowchart TD
    START["update()"] --> CLEAR["Clear misc failures"]
    CLEAR --> CACHE{"Cache mode?"}
    CACHE -->|"whole"| CHECK["Check cache manifest"]
    CHECK -->|"hit"| DONE["Return (cache hit)"]
    CHECK -->|"miss"| WORK
    CACHE -->|"none"| WORK["Create temp directory"]
    CACHE -->|"incremental"| WORK2["Detect changed files"]
    WORK --> QUEUE["Queue AstGen, C compilation,\nSema, codegen, linking jobs"]
    WORK2 --> QUEUE
    QUEUE --> PROCESS["Process work queues\n(thread pool)"]
    PROCESS --> LINK["Wait for link tasks"]
    LINK --> FINALIZE["Finalize output"]
    FINALIZE --> END["Write cache manifest"]

第 2884 行,函数根据 comp.cache_use 进行分支:

  • whole:在编译开始前检查完整的缓存清单。如果所有输入都匹配,则跳过整个编译过程。这是一次性构建的默认模式。
  • none:完全不使用缓存,用于特殊场景。
  • incremental:按声明粒度跟踪变更,文件修改后只对受影响的部分进行定向重分析。

whole 模式下,第 2898–2960 行的缓存检查会根据所有输入(源文件、编译标志、目标三元组等)计算清单,并检查是否存在缓存输出。这样只需一次文件系统检查,就能跳过整个编译流程。

分阶段任务队列与优先级

Compilation 维护着一个任务队列数组,其大小在编译时根据任务阶段数确定:

work_queues: [len: { ... }]std.Deque(Job),

第 119–127 行的长度计算逻辑会遍历所有 Job.Tag 变体,找出最大阶段编号。Job union 定义了所有任务类型:

const Job = union(enum) {
    codegen_func: struct { func: InternPool.Index, air: Air },
    link_nav: InternPool.Nav.Index,
    link_type: InternPool.Index,
    analyze_comptime_unit: InternPool.AnalUnit,
    analyze_func: InternPool.Index,
    analyze_mod: *Package.Module,
    resolve_type_fully: InternPool.Index,
    windows_import_lib: usize,
};

stage() 函数负责分配优先级:

fn stage(tag: Tag) usize {
    return switch (tag) {
        .resolve_type_fully, .analyze_func, .codegen_func => 0,
        else => 1,
    };
}

阶段 0 的任务(类型解析、函数分析、代码生成)优先于阶段 1 的任务(模块分析、link_nav 等)处理。这样的设计确保了 Sema 分析完一个函数后,代码生成线程能立即获得工作,从而最大化并行度。

flowchart LR
    subgraph "Stage 0 (High Priority)"
        RF["resolve_type_fully"]
        AF["analyze_func"]
        CF["codegen_func"]
    end
    subgraph "Stage 1 (Normal Priority)"
        AM["analyze_mod"]
        LN["link_nav"]
        LT["link_type"]
        ACU["analyze_comptime_unit"]
    end
    AF -->|"produces"| CF
    CF -->|"produces"| LN

processOneJob 函数负责分发每种任务类型。正如第 4 篇所介绍的,codegen_func 任务通过创建 SharedMir 来处理,既可以派生线程执行,也可以内联运行代码生成。

提示:第 1012 行的编译期断言(assert(stage(.resolve_type_fully) <= stage(.codegen_func)))在结构层面保证了类型解析必须在代码生成使用这些类型之前完成——这不只是运行时的期望,而是硬性约束。

三种缓存模式:none、whole 与 incremental

三种缓存模式代表着本质不同的策略:

模式 使用场景 粒度 失效机制
none 特殊场景 全量重建
whole 默认一次性构建 整个编译 任意输入变化即失效
incremental Watch 模式 / 服务器 单个声明 依赖跟踪式失效

whole 缓存是最简单也最常见的方式。它将所有输入(源文件、编译标志、目标平台)哈希为一份缓存清单,若清单匹配则直接使用缓存二进制文件。这提供了"无变化、无需重建"的快速路径。

incremental 缓存则复杂得多。它以 AnalUnit 为粒度跟踪依赖——涵盖单个函数、comptime 块和类型解析。当源文件发生变化时,只有实际发生变更的声明(通过源码哈希对比检测)会被重新分析,且只有依赖这些声明的单元才会被标记为失效。

增量模式依赖 InternPool 的依赖跟踪基础设施,接下来我们深入了解。

InternPool 中的依赖跟踪

InternPool 在第 34–65 行维护着八个依赖哈希表:

src_hash_deps: AutoArrayHashMap(TrackedInst.Index, DepEntry.Index),
nav_val_deps: AutoArrayHashMap(Nav.Index, DepEntry.Index),
nav_ty_deps: AutoArrayHashMap(Nav.Index, DepEntry.Index),
interned_deps: AutoArrayHashMap(Index, DepEntry.Index),
zon_file_deps: AutoArrayHashMap(FileIndex, DepEntry.Index),
embed_file_deps: AutoArrayHashMap(Zcu.EmbedFile.Index, DepEntry.Index),
namespace_deps: AutoArrayHashMap(TrackedInst.Index, DepEntry.Index),
namespace_name_deps: AutoArrayHashMap(NamespaceNameKey, DepEntry.Index),
graph TD
    subgraph "Dependency Sources (Dependees)"
        SH["src_hash_deps\n(ZIR instruction hashes)"]
        NV["nav_val_deps\n(Nav values)"]
        NT["nav_ty_deps\n(Nav types)"]
        ID["interned_deps\n(runtime funcs, containers)"]
        NS["namespace_deps\n(all names in scope)"]
        NN["namespace_name_deps\n(specific name existence)"]
    end
    subgraph "Dependency Consumers (Dependers)"
        AU["AnalUnit\n(func, comptime, nav_val, etc.)"]
    end
    SH -->|"invalidates"| AU
    NV -->|"invalidates"| AU
    NT -->|"invalidates"| AU
    ID -->|"invalidates"| AU
    NS -->|"invalidates"| AU
    NN -->|"invalidates"| AU

每个哈希表将一个被依赖项(可能发生变化的事物)映射到一个 DepEntry 链表。每个 DepEntry 记录了依赖该项的 AnalUnit,以及指向两个链表中下一个条目的指针(一个用于"该被依赖项的所有依赖者",另一个用于"某依赖者的所有依赖项")。

TrackedInst 为 ZIR 指令提供跨更新的稳定引用。在增量更新时,ZIR 指令索引可能发生偏移,TrackedInst 负责将旧索引映射到新索引。若映射失败(指令已被删除),MaybeLost 包装器会使用哨兵值 .lost 表示。

失效流程如下:

  1. 源文件变化 → AstGen 生成新的 ZIR
  2. 比较 TrackedInst 的源码哈希:新旧对比
  3. 哈希变化触发 src_hash_deps 查找
  4. 每个受影响的 AnalUnit 被标记为需要重新分析
  5. 重新分析可能改变 Nav 的值或类型,进而通过 nav_val_depsnav_ty_deps 触发级联失效

第 76 行first_dependency 映射提供了反向查找:给定一个 AnalUnit,找出它的所有依赖项,以便在该单元重新分析时将这些依赖关系清除。

PerThread 线程模型

ZcuInternPool 的线程安全访问通过 PerThread 来管理:

zcu: *Zcu,
tid: Id,  // dense, per-thread unique index

pub fn activate(zcu: *Zcu, tid: Id) Zcu.PerThread {
    zcu.intern_pool.activate();
    return .{ .zcu = zcu, .tid = tid };
}

pub fn deactivate(pt: Zcu.PerThread) void {
    pt.zcu.intern_pool.deactivate();
}

activate/deactivate 模式是一种轻量级的作用域守卫。activate() 递增 InternPool 的活跃线程计数,deactivate() 则递减。这不会产生任何锁操作——InternPool 的分片设计允许多个线程同时 intern 值而不产生竞争,前提是它们使用不同的分片(由 tid 选择)。

sequenceDiagram
    participant T1 as Thread 1
    participant T2 as Thread 2
    participant IP as InternPool
    participant S1 as Shard[tid=0]
    participant S2 as Shard[tid=1]

    T1->>IP: activate(tid=0)
    T2->>IP: activate(tid=1)
    T1->>S1: intern type (no contention)
    T2->>S2: intern type (no contention)
    T1->>IP: deactivate()
    T2->>IP: deactivate()

Compilation 本身有一个 mutex 用于保护共享可变状态,例如错误列表、失败对象表和任务队列。在单线程构建中,这个互斥锁会退化为无操作结构体。

线程模型为最常见的场景而设计:Sema 在一个线程上运行,代码生成在另一个线程上并行进行。InternPool 的每线程 Local 存储使得 Sema 可以 intern 新类型,而不会阻塞代码生成的读取操作。依赖跟踪数据只在两次更新之间的单线程失效阶段才会被修改。

提示:IdBacking 类型为 u7,最多支持 128 个线程。tid 通过 tid_shift_* 字段嵌入 InternPool 索引的高位,因此每个索引都隐式记录了创建它的线程。

多阶段自举

让我们把整个流程串联起来。第 1 篇简要介绍了三阶段自举,现在我们理解了整个编译流水线,可以更深刻地体会这一设计的精妙之处。

bootstrap.c 负责编排整个链条:

阶段 1 — zig1(bootstrap 环境):

// 1. Build wasm2c from C
cc -o zig-wasm2c stage1/wasm2c.c
// 2. Convert zig1.wasm to C
./zig-wasm2c stage1/zig1.wasm zig1.c
// 3. Compile zig1 from C
cc -o zig1 zig1.c stage1/wasi.c

随后,bootstrap.c 在第 145 行写入 pub const dev = .core;config.zig,为 zig2 做好准备。

阶段 2 — zig2(core 环境):

// 4. zig1 compiles the compiler to C
./zig1 lib build-exe -ofmt=c -target host ...
// 5. System C compiler builds zig2
cc -o zig2 zig2.c compiler_rt.c

阶段 3 — zig3(完整环境): zig2 作为普通 Zig 编译器运行(dev = .core 支持所有后端和链接器),并使用 dev = .full 构建最终的阶段 3 编译器。

src/dev.zig 中的功能门控系统使每个阶段都能正常运作:

sequenceDiagram
    participant B as bootstrap (zig1)
    participant C as core (zig2)
    participant F as full (zig3)

    Note over B: 6 features enabled
    Note over B: C backend only
    Note over B: No networking, no fmt
    B->>C: Compile with -ofmt=c

    Note over C: ~35 features enabled
    Note over C: All backends + linkers
    Note over C: No fmt, fetch, init
    C->>F: Compile with all features

    Note over F: All features enabled
    Note over F: Complete compiler

第 58–67 行bootstrap 环境仅支持:build_exe_commandbuild_obj_commandast_gensemac_backendc_linker。其余一切——x86_64 代码生成、ELF 链接、LLVM、增量编译——都在编译期被死代码消除。最终生成的 zig1.c 足够精简,任何 C 编译器都能快速编译它。

第 68–130 行core 环境启用了绝大多数编译功能,但有意排除了网络支持(network_listen)、fmt 命令等非必要功能。第 126 行的注释解释了原因:"Avoid dragging networking into zig2.c because it adds dependencies on some linker symbols that are annoying to satisfy while bootstrapping."

三阶段设计从根本上解决了自举的先有鸡还是先有蛋问题:编译 Zig 编译器需要一个 Zig 编译器。通过以 WebAssembly 作为可移植的 stage0,以 C 作为通用自举语言进行转换,并在每个阶段逐步启用功能,Zig 实现了一个完全自托管的编译器——只需一个 C 编译器,就能在任意平台上从源码构建。

结语

历经五篇文章,我们完整地走过了 Zig 编译器的全貌——从代码仓库结构到最终的二进制输出。这套架构体现了几个鲜明的设计理念:

  1. 扁平数据结构无处不在。 AST、ZIR、AIR 和 InternPool 全部使用整数索引而非指针。这带来了出色的缓存局部性和近乎零代价的序列化。

  2. comptime 驱动的配置。 dev.zig 的功能门控、importBackend() 的 comptime 分发,以及 AnyMir union,都充分运用了 Zig 的 comptime 能力,在编译期完成死代码消除。

  3. 从底层支持增量编译。 AnalUnit / Nav / TrackedInst / 依赖跟踪基础设施并非事后补丁——它们深度织入了核心数据结构。

  4. 前端作为库共享。 将词法分析器、解析器和 AstGen 放在 lib/std/zig/ 中,确保了语言语法有唯一的权威来源,由编译器、格式化工具和语言服务器共同使用。

  5. 通过渐进式能力实现自托管。 结合 dev.zig 功能门控的三阶段自举,是解决自托管自举问题的优雅方案。

Zig 编译器是一个以 0.16.0-dev 为目标的活跃项目,这些结构还将持续演进。但核心架构——IR 链条、InternPool、分阶段任务系统和自举策略——已构成一个既有原则又务实的坚实基础。深入理解它,无论是参与贡献、围绕 Zig 构建工具链,还是纯粹欣赏现代系统编程领域最具雄心的编译器项目之一,你都会站在一个更高的起点上。