# Inside Zig's incremental compilation

mlugg, a Zig core team member, on how the compiler detects which declarations changed since the last build, recompiles only those, and patches the resulting bytes directly into the output binary. The demo is Fizzy, a pixel editor: a 5-second cold build, then 50–70ms per rebuild. Nothing in the pipeline is allowed to be whole-program.

## Per-file: cache the IR, not the output

The first stage loops over source files — read, parse to an AST, lower to ZIR (an untyped SSA-form IR) with a pass called AstGen. AstGen also discovers `@import` calls, which is how the file set is found in the first place.

Three properties make this stage easy. Processing a file is a pure function of its contents, with no shared or external state. Parse plus AstGen over the entire `src/` directory of the Zig compiler takes about 920ms on the author's laptop with no parallelism at all. And because ZIR is laid out with data-oriented design patterns, it goes to and from disk in a single `writev`/`readv` — there is no serialization step, just bytes.

Purity buys embarrassing parallelism (one task per file on a thread pool; the only shared state is a mutex-protected hash set of seen paths) and it buys trivial incrementality: cache each file's ZIR and rebuild it only when the file changed. Both have been on by default for years. This is the part other compilers already do.

## Semantic analysis: analysis units and a dependency graph

Semantic analysis interprets ZIR — type checking plus [[comptime]] evaluation — emitting compile errors and, for runtime functions, producing AIR for later stages. It is the hard part to make incremental, and it is where language design starts to constrain what's possible. Zig's design has been adjusted over the years, sometimes controversially, specifically to keep this tractable.

The unit of work is an *analysis unit*, of which there are four kinds: the layout of a `struct` or `union`, the type of a container-level declaration, the value of a container-level `const`, and the body of a runtime function. Analyzing a unit records which other units it depended on. For

```zig
var global_0: u32 = 123;
const global_1: u32 = 456;

pub fn foo(cond: bool) u32 {
    if (cond) return global_0 else return global_1;
}
```

the body of `foo` ends up depending on the *types* of both globals and on the *value* of `global_1` — the latter only because it is `const` and therefore comptime-known and loaded at compile time. Two asymmetries fall out of this. Nothing can depend on a function *body*, so function-body units only have outgoing edges. And dependencies on a declaration's *value* exist solely because Zig lets you use those values at comptime; without that feature they would be as impossible as depending on a body.

The graph is useless on its own, because a rebuild needs a starting point. So units also depend on *source code*, via hashes that ZIR stores for interesting regions — typically one per container-level declaration. Change any byte in a region, the hash changes, the dependent units are marked outdated. Semantic inlining complicates this: an `inline` call analyzes the callee's code inside the *caller's* unit, so the caller picks up a source dependency on the callee.

Propagation stops early where it can. Change `const lucky_number = 42;` to `43` and the compiler re-lowers ZIR, maps old declarations to new ones by name, finds exactly one changed source hash, and re-analyzes the value of `lucky_number`. Had the edit been whitespace, the value would come out identical and the cascade would stop right there. Since it did change, the two function bodies depending on that value get re-analyzed — and since nothing can depend on a function body, the loop terminates.

## Codegen: the easy stage

Codegen turns AIR into MIR, a representation almost 1:1 with machine instructions, with a separate implementation per target architecture. It is embarrassingly parallel for the same reason as file processing — no shared state between functions — with one caveat: the queue of pending functions needs a size cap, because if codegen falls behind semantic analysis the queued AIR piles up fast.

Incrementally it is the simplest stage of all. AIR and MIR are already per-function, which is exactly the granularity incremental compilation works at, so neither is ever cached. AIR is discarded once codegen finishes and MIR once the linker consumes it.

## Linking: a mapped file with a tree of nodes

Incremental linking is the reason mlugg suspects no other major toolchain does this. General-purpose incremental linkers barely exist — [wild](https://github.com/wild-linker/wild) was conceived as one and has since shifted its focus to cold-link performance with no timeframe for incremental. One of the problems David Lattimore identifies for wild is diffing input object files to work out what changed; controlling the whole pipeline sidesteps that entirely, at the cost of coupling the linker tightly to the compiler.

The linker is single-threaded because linking is shared state. It receives MIR, converts it to machine code (which must happen on the linker thread, since emitting code generates relocations the linker has to record), reserves space in `.text`, and does the bookkeeping. Non-incrementally, addresses get assigned and relocations applied once at the end. Incrementally, all of that has to happen before the binary's contents are known, and then be revisable.

Most of the difficulty is moving things. Growing `.text` may require displacing neighbouring sections, which changes their file offsets *and* virtual addresses, which invalidates symbol table entries and every relocation targeting them. Jacob Young's `link.MappedFile` abstraction handles this: the output file is memory-mapped and tracked as a tree of nodes, the root covering the whole file and children covering regions of their parent. Callers add a node or grow one to a size; if the parent can't accommodate it, `MappedFile` moves other nodes to make room and sets a dirty flag on everything it touched.

Emitting a function is then: create a node large enough for the code (or resize the existing one), copy the code in, and always mark that node dirty so its relocations get applied. The binary is usually invalid at that moment, which is fine — when the linker thread's queue next drains, or at the end of compilation, it walks the dirty flags and does the fixups: new virtual addresses, section and program headers, symbol table addresses, re-applied relocations.

That fixup can be expensive when the PLT moves in a dynamic executable, since a lot of relocations target it. Nodes grow by exponential factors, the same trick `ArrayList` uses, which amortizes the cost down to rare — at the price of slightly larger development binaries. mlugg reports never having hit a slow update personally despite near-daily use.

## Flush, and where the time actually goes

Two loose ends remain at the end of an update. Zig's lazy analysis means something compiled on a previous update may now be unreferenced, so its compile errors must be ignored and its symbols not exported — which requires a traversal of the reference graph. Then the linker's own `flush` runs, and it is deliberately close to O(1): for ELF it writes the `.dynamic` section and the `entry` field of the header, and that's it.

A Tracy trace of a 37ms Fizzy update shows how lopsided the result is. The first ~6ms covers everything you'd expect to dominate: a burst of per-file work across the thread pool (every source file is checked, not just the changed one), about 1ms in `computeAliveFiles` walking the import graph to assign files to modules, about 1ms in `updateZirRefs` remapping references from old ZIR instruction indices to new ones, then 1.2ms of semantic analysis, 240µs of codegen, 170µs of linking, and 50µs regenerating the `@errorName` lookup table. The pipeline that dominates cold builds costs about 1.6ms here.

The remaining ~31ms is `resolveReferencesInner` — the reference-graph traversal — recomputed in full even though the reference graph did not change. mlugg keeps it in the post rather than fixing it first, because it shows how much is still on the table: skip the recomputation when the graph is unchanged, and when it does change, update incrementally via dynamic single-source shortest path.

## Using it

```
$ zig build --watch -fincremental
```

`--watch` makes the build system watch the filesystem; `-fincremental` sets the incremental flag on every `std.Build.Step.Compile`. Existing caches are not compatible, so the first run rebuilds everything even with a warm cache; exposing a `-Dincremental` option in `build.zig` and setting `exe.incremental = true` limits that to one compilation.

The constraints are real. It only works for `x86_64-linux` right now, because the other codegen and linker backends aren't mature — the core team optimized for its own machines first, and broadening targets is now the priority. Compiler state isn't yet persisted to disk, so it doesn't happen automatically on a plain `zig build`. Combining `--watch` with a run step works for short-lived programs, but a long-running graphical application blocks the next rebuild until it exits. And the feature is not stable: false-positive compile errors and miscompilations are both considered plausible.

Zig 0.16.0 has incremental compilation but lacks linker features added since, so this needs `master` or 0.17.0 — which matches the picture in [[returning-to-zig]], where 0.17 is expected to break every project's build system. Fast rebuilds are the payoff being offered for tolerating that churn.
