Zig Incremental Compilation Internals – How the Compiler Achieves Millisecond Rebuilds

Zig Incremental Compilation Internals – How the Compiler Achieves Millisecond Rebuilds

Incremental compilation in Zig makes rebuilds fast enough to edit a complex app in tens of milliseconds

Takeaway: Zig’s compiler now tracks changes at the level of individual functions and declarations, recompiles only what is necessary, and patches the binary in‑place, reducing rebuild times for real‑world projects to 50–70 ms after an initial 5 s build.


File‑level processing is a pure, parallelizable pipeline

Zig reads each source file, parses it into an AST, and lowers the AST to ZIR (Zig Intermediate Representation). This three‑step loop runs independently for every file because:

  1. The transformation is a pure function of the file contents – no shared state is consulted.
  2. Parsing + AstGen on the entire compiler source tree takes ~920 ms on a laptop, even without parallelism.
  3. ZIR is written to and read from disk with a single writev/readv system call; there is no separate serialization step.

Because the work per file is independent, the compiler can schedule one task per file on a thread‑pool, protecting only a global hash‑set of already‑seen paths. The result is embarrassingly parallel file processing and a trivial caching strategy: each file’s ZIR is cached on disk and recomputed only when its source hash changes.


Semantic analysis is broken into four incremental units

The hardest part of incremental compilation is semantic analysis, which performs type checking, comptime evaluation, and produces AIR (the next IR). Zig models the analysis as a dependency graph of analysis units:

Unit type What it represents
Layout Size and alignment of a struct/union
Type The type of a top‑level (container‑level) declaration
Value The compile‑time value of a const declaration
Body The body of a runtime function

During analysis, each unit records edges to other units it depends on. For example, the body of fn foo depends on the type of any var it reads and on the value of any const that is known at compile time. Function bodies never have incoming edges—no other unit depends on a function’s body—so they can be re‑analyzed independently.

To detect source changes, ZIR stores a hash for every container‑level declaration. When a file is re‑lowered, the compiler maps old declarations to new ones by name, compares the stored hashes, and marks any unit whose hash changed as out‑of‑date. The dependency graph then propagates invalidation to all downstream units.


Code generation operates at function granularity and needs no caching

After semantic analysis, each function’s AIR is turned into MIR (Machine IR). MIR maps almost one‑to‑one to machine instructions, and the transformation is also embarrassingly parallel because no function shares state with another. Since both AIR and MIR exist per‑function, the incremental pipeline does not cache either representation: the compiler discards AIR after generating MIR, and MIR is consumed immediately by the linker.


Incremental linking is achieved by a memory‑mapped file abstraction

Traditional linkers are single‑threaded and cannot easily apply incremental updates because they must recompute section offsets and relocations for the whole binary. Zig sidesteps this by tightly integrating the linker with the compiler via link.MappedFile:

  • The output file is memory‑mapped and represented as a tree of nodes (e.g., .text region, symbol table entries).
  • Adding or resizing a node automatically moves other nodes if necessary and marks the moved nodes as dirty.
  • When a function’s MIR changes, the compiler either creates a new node or resizes the existing one, writes the new machine code, and sets the dirty flag for that node.
  • The linker thread periodically scans dirty nodes, reassigns virtual addresses, updates section headers, symbol tables, and reapplies relocations.

Because node sizes grow exponentially, most updates require no movement at all; the occasional resize that does move nodes is rare and usually costs only a few hundred milliseconds in the worst case (e.g., when the PLT moves).


Flush phase finalizes the update in O(1) work

After code generation and linking, the compiler performs a graph traversal to determine which declarations are still reachable. Unreferenced units are ignored, and the linker is told which symbols to export. The ELF linker’s flush routine then writes only the .dynamic section and the ELF entry point, leaving the rest of the binary untouched. This step is deliberately O(1) because it runs on every incremental update.


Real‑world performance measured with Tracy

The author instrumented the compiler with the Tracy profiler. A typical incremental update on the Fizzy pixel editor shows:

  • ~6 ms spent on per‑file ZIR caching, dependency‑graph updates, semantic analysis, codegen, and linking of the changed functions.
  • ~31 ms spent in resolveReferencesInner, which recomputes the full reference graph on every update. This is the biggest remaining hotspot and could be optimized by caching the graph when it does not change.

Overall, the end‑to‑end rebuild time was 37 ms, with the core compilation work (semantic analysis, codegen, linking) completing in ~1.6 ms.


How to try incremental compilation today

Incremental compilation is available on Zig master and will land in the stable 0.17.0 release. To use it on a Linux x86_64 project:

zig build --watch -fincremental
  • --watch watches the filesystem for changes.
  • -fincremental enables incremental compilation for every std.Build.Step.Compile.

If you prefer a per‑step flag, add the following to build.zig:

const incremental = b.option(bool, "incremental", "Enable incremental compilation") orelse false;
if (incremental) exe.incremental = true;

Then invoke zig build -Dincremental.

Note that the first rebuild after a clean checkout will recompile all objects because the on‑disk caches are not yet compatible with incremental mode.


Community reaction

  • Users praised the speed gains, calling the work “incredibly valuable, high‑impact” and noting that other toolchains have long ignored compilation‑time improvements.
  • Several comments compared Zig’s approach to Rust’s query‑based incremental compilation, highlighting how Zig’s language design (four well‑defined unit types) simplifies dependency tracking.
  • Questions were raised about support for release builds, C compilation, and the design choice of patching a single giant binary versus generating many small shared libraries. The author clarified that the current implementation targets x86_64‑linux and that incremental linking is deliberately built into the compiler to avoid the complexity of a separate incremental linker.

Outlook

The implementation is still marked unstable and may produce false‑positive errors or miscompilations. The Zig team encourages users to file issues on the repository. Future work includes:

  • Reducing the reference‑graph recomputation cost during flush.
  • Extending incremental support to other architectures and to release builds.
  • Improving the MappedFile node allocation strategy for better space efficiency.

When Zig 0.17.0 ships, incremental compilation will become part of the stable toolchain, making rapid edit‑compile‑run cycles a default part of Zig development.


Bottom line: Zig’s incremental compilation pipeline—pure per‑file ZIR caching, a fine‑grained dependency graph for semantic analysis, function‑level codegen, and a memory‑mapped incremental linker—delivers rebuilds in the tens of milliseconds, turning the compiler into a near‑instant feedback loop for real‑world applications.

Sources