A modular compiler: decision basis

The owner’s question (2026-10-24): can the compiler be cut into parts, so that a step rebuilds only the part it changed, prebuilt parts stay (dynamic linking was the idea), and two builders can work at once?

The problem

Every self-hosting step runs one cycle: edit a tools/*.nika file (and its Rust adapter in crates/nikaia), build nikaia, nikaia lower-std to regenerate crates/nikaia-std/src/tools/lowered/*.rs (was package.rs; split in 8dd21e75), build again, run the touched tests. The cycle’s length decides how fast the work goes, and its shape decides whether two builders can run side by side.

Today, checked in the code (develop 7439a1e7)

Part Size Rebuilt by a one-file .nika step
crates/nikaia/src (Rust, one crate, 37 top modules) 48 k lines yes: it depends on nikaia-std
crates/nikaia-std/src, hand-written runtime 25 k lines yes, same crate as the package (incremental reuses most)
tools/*.nika (213 files, one namespace) 80 k lines the one file, through lower-std
tools/package.rs, their lowering 73 k lines, 3.9 MB, one module all of it, see below
crates/nikaia/tests 267 .rs, now 1 test binary it (files in tests/it/), 97 k lines every one relinks

Facts behind the table:

How others do it

Options

Option What it gives Cost and risk
A. One Rust module per .nika in package.rs (#667 step 4): mod x_body { use super::*; ... } and pub use x_body::*; a one-file step re-optimises one unit, not half the crate; 4 cores used lowering in sysroot.rs; items another file reads must lower pub(crate); names are already unique
A2. One generated file per .nika (tools/lowered/x.rs, include!d by A’s modules) git diffs and merges per file: two builders on two files no longer conflict in generated code same place as A; CI’s freshness check and self_hosting.py read the new layout
B. One integration-test binary 267 links -> 1; ~4.5 GB -> ~20 MB per profile, so a second target/ fits on disk moves 267 files; --test a becomes a filter (--test it a::); tests now share a process
C. nikaia-tools crate beside nikaia-std a program’s std and the runtime never rebuild for a tools change path nikaia_std::tools moves (ADR-290 D16, use std::tools); small gain once A is in
D. Split crates/nikaia into crates nothing until the cycles are broken breaking them is large work on code that is leaving: not recommended
E. dylib / prefer-dynamic links shrink B gives the same without ABI questions; no compile saved: not recommended
F. cdylib plugin boundary a part truly prebuilt needs a serialised interface over the shared types: not recommended now
G. nextest, sccache parallel test pool, CI shards; cached cold builds a tool to install in CI; no change to the edit loop

What to measure (counts, not time)

Recommendation

  1. A now, as #667 step 4, right after its per-file cache keys (step 2): the evidence is the single 7.4 MB unit. Keep it if the count of re-optimised bytes falls; it will, unless the lowering’s use super::* makes rustc inline across modules into one unit again (measure).
  2. A2 with it or right after: it is what makes two builders on two .nika files independent in generated code.
  3. B next: the disk is the limit on a second target/, and every step pays 267 links.
  4. Stage 2 (two builders) after A2 and B, each builder with its own worktree and CARGO_TARGET_DIR, on disjoint .nika files and disjoint Rust adapters. Still shared: crates/nikaia/src/emit/mod.rs, check/mod.rs, project.rs (the hubs; one builder at a time on each), and a change to a type or function another file uses (the #667 key on the package interface re-lowers the readers; the second builder rebases).
  5. C, G later, D, E, F not: the Rust crate’s cycles are not worth breaking while it shrinks, and dynamic linking saves only what B saves. Splitting the .nika package into crates waits until the files have layers (explicit use between tools modules); not decided here.
This site is open source. Improve this page.