ADR 0022 — M14 K2a: generated C in several translation units
- Status: Accepted (project owner, 2026-10-29, taking every recommendation in §5). The wording is the agent's.
- Scope: M14 K2a (
milestone-14.md), the first step of OD-29: novac stays whole-program, but its C is split so that a C compiler can build it in parallel, and rebuild only what changed.
1. Context
- Today (c-backend §2.1):
novac build --emit cwrites onemain.cholding every function of the program, lib/core's included, each with internal linkage (static), plus the runtime files (nova_rt_core.c, …) and, with@export,nova_exports.h. Internal linkage is what lets CosmoOS link several separately generated programs (its four ports) into one kernel: each has its own copy of the lib/core functions it uses, and the copies never collide. - The cost at kernel scale (M14, measured 2026-10-29, Mac M-series): a synthetic 202,004-line package became one 1,076,433-line
main.c. novac took 13.1 s, andclang -O2 -ctook 33.8 s and 1.25 GB on one core. A kernel written in Nova would pay both on every change. CosmoOS's C kernel compiles each file separately, in parallel, and only what changed. - What does not change here. Checking stays whole-program (ADR 0013 D4), and so does novac's own time. Separate compilation of Nova packages is OD-29's second step, decided later with K2a's measurements.
2. Options
| A. Split by source file (proposed) | B. Split into N equal parts | C. One file, as today | |
|---|---|---|---|
| Unit of rebuild | the Nova file that changed, if no other unit's declarations changed | any change can move functions between parts | everything |
| Determinism | each function's unit is a function of its source file | needs a stable partition | — |
| Readable | kernel/net/tcp.nova → tcp.c | no | — |
3. Proposed decision: A, behind a switch
3.1 The layout
novac build --emit c --split -o DIR writes:
nova_program.h, the program's declarations: every type, every function prototype, the runtime's header, and the@exportprototypes (the content ofnova_exports.h, which is still written as before);- one
<name>.cper Nova source file, holding the functions declared in it, the closures written in them, and their string literals and source locations. Each includesnova_program.h; nova_instances.c, holding the instantiations of generic code, lib/core's functions and the drop glue: everything that belongs to no single user file. One file, so that two units never define the same instance;- the runtime files, as today.
A unit's name is its source file's name, with the shortest distinguishing directory prefix when two files share a name (net_tcp.c, vfs_tcp.c), so the layout is a pure function of the package's file list.
3.2 Linkage and names
- A function called from another unit has external linkage. Every other function stays
static, as today. - External names carry the program's prefix, so two programs linked into one image cannot collide:
nvF_becomesnvF_<program>_. The program's name is the package's (nova.toml), or--program NAMEfor a file build. The prefix also applies to the instances innova_instances.c, which every program has a copy of. - Small helpers that the emitter generates per use (
nvVP_,nvVA_, the check helpers) becomestatic inlineinnova_program.h, so every unit can inline them. A call across units to a Nova function cannot be inlined without link-time optimisation. §4 says what that may cost. @exportnames are unchanged: they are the C names the user chose.
3.3 Determinism
The output stays a pure function of the inputs: the units, their order, each function's placement and the prefix are derived from the sources and the switch, never from hash order or time. A test builds each emit-c corpus program with --split twice and compares, and builds the result in every C configuration.
3.4 Without --split
Nothing changes: one main.c, internal linkage, today's names. CosmoOS's four ports, and every snapshot, stay byte-identical. CosmoOS opts in per program when its build moves to Nova (its roadmap 2.0's SA1).
4. Consequences
- Parallel and incremental C builds. The acceptance measures them on the 202,004-line program:
make -jover the units, and a rebuild after a one-file change. A change to a function's signature changesnova_program.h, which every unit includes, so it rebuilds them all. A body-only change rebuilds one unit, plusnova_instances.cif it changes an instance. - Performance. Calls across units are no longer inlinable at
-O2without LTO. The ports' benchmarks (benchmarks/lz4,fbvalid,gpt) are run with and without--splitto measure it. Kernel code built with LTO regains it. - Tools. The debugger helpers (
editors/debug/nova_names.py) learn the prefix. Source maps (#line) are unchanged, because each unit's#linedirectives name its own Nova file. - Not decided here: separate checking of packages, the rest of OD-29.
5. Questions for the owner
- Split by source file, behind
--split, with one-file output unchanged by default? (Recommended.) - External names prefixed with the program's name (
nvF_<program>_)? (Recommended: the alternative, hidden visibility, does not prevent duplicate symbols in a static kernel link.) - Generic instances and lib/core in one
nova_instances.c? (Recommended; the alternative is one unit per instance.)
6. Implementation and results (2026-10-29)
novac build --split [--program NAME](CEmitOptions.Split,CEmitter.EmitSplit), specified in c-backend §2.4. The run corpus builds every program split as a C configuration of its own (clang -O2 split), with the strict warnings. The debugger helpers read the renamed functions whenNOVA_PROGRAMis set (tooling §3.3).As §3.2 says, the per-type helpers belong in the header. A first version put them in
nova_instances.cwith external linkage. Every vector push and drop then became a call across units, andvector-growthandstring-buildingran 1.7–1.9× slower. In the header,static inline, both run as fast as one file.Measurements (Mac M-series, Apple clang 21,
-O2; the synthetic program ofmilestone-14.mdspread over 21 Nova files): novac 14.2 s. The C:Build Wall CPU one file, as before 33.8 s 33.8 s --split,make -j1014.0 s 80.3 s --split, one unit changed2.4 s 2.3 s The program printed the same value either way. CPU time grows because every unit parses the 99,484-line header. A smaller header per unit, holding only what that unit uses, would cut it, and is left for when kernel builds show the need.
Two
--splitbuilds of the same sources are byte-identical.