Fast, light builds: what the AOT build compiles and how to pre-chew it (study, 2026-10-19)
The question (owner): a Zinc app should build fast and light on modest computers, with as much compiled ahead of time as possible, the way Arduino does it (the board core goes into core.a once, compiled libraries are cached, and only the sketch is compiled again). This study measures what zinc build / zinc run (native) compiles today for examples/music, what stops library code from being compiled once, and proposes the smallest designs that would fix it. Read-only on the engine; nothing was committed.
Machine: Apple Silicon arm64, 10 cores, Apple clang 17.0.0 (/usr/bin/c++, not a ccache wrapper). The binary was the existing next/build/zinc (Oct 19 00:36), with no rebuild. The load average was about 12 throughout because of other sessions, so the timings are noisy (±30%), but the ratios are stable. Temporary files are in ~/.claude/jobs/c6db9e88/tmp/aot-study/.
1. Where the 1891 functions of examples/music come from
The commands. ZN_KEEP_CPP=1 ZINC_LOG=debug next/build/zinc build ../examples/music -o $T/music gave 1891 functions, 41 files, 41 C++ units (the main unit plus 40 units of 48 functions). The ZBC carries only names, with no module. The attribution comes from hot mode’s stable keys (<path>::<name>, <path>#<offset> for lambdas, ZN-742, src/ir/ir.h:146-153, assigned in src/ir/lower.cpp:819-866): ZINC_HOT=1 ZN_DUMP_HOT_KEYS=keys zinc --emit=zbc-bin (dump at src/main.cpp:487-493). The release functions were then aligned to the hot build’s functions by name and order (difflib). Hot mode adds 19 @init: functions, and 48 release functions (2.5%) were placed by their neighbour. Debug builds can’t be used: they don’t inline, so they have 2428 functions, and their #line only names the app’s files.
| Origin | Functions | C++ body | Est. compile CPU |
|---|---|---|---|
App (examples/music/src: main.ts 370, notify 98, ui 32, weather 25, faces 12, radios 10, screen 8, keyboard 6, plus main) | 562 (30%) | 3.5 MB (47%) | 17 s |
| lib/std (web.ts 278, fetch.ts 85, ui.ts 72, icons/solid 3) | 439 (23%) | 1.8 MB (24%) | 10 s |
| Plugin Zinc code (sonos 141, spotify 98, gsap 98, async-storage 38, socket 39, link 39, svg 10, webos 6) | 469 (25%) | 1.5 MB (20%) | 9 s |
<prelude>, generated by the engine per program (Error, Date, async, JSON/inspect/Dyn, per-type __fmtN/__dynToN/__gN helpers) | 380 (20%) | 0.5 MB (7%) | 3 s |
zinc:* modules (sources embedded in the engine: net, fs, gfx, sys…) | 41 (2%) | 0.1 MB | 0.6 s |
| lib/std/kit, lib/std/nuxt, lib/compat | 0 |
So 70% of the functions and 57% of the C++ compile time are not the app’s own code. The other examples show the same pattern (hot-key counts): hero has 1109 app, 552 std, 276 kit, 90 prelude and 72 plugin functions; nuxt-ui has 272 app, 534 std, 504 nuxt, 86 prelude and 10 plugin; rn-showcase has 1042 app, 862 std, 262 prelude and 202 plugin.
The compile CPU is per-unit wall time measured alone, with the PCH (-include-pch), at 39.7 s for the 40 units, split by body bytes. With 5 at once (ZINC_JOBS default = cores / 2), the 40 units take 8.4 s of wall time.
2. What makes a std function’s C++ differ between programs
The measurement compared music with examples/music-tree, a different app using the same plugins and std (1938 functions, 42 units). The two were matched by hot key, and the C++ bodies were compared after successive normalizations:
| Origin | Present in both | Byte-identical | Same after naming callees | Same after also masking table indices |
|---|---|---|---|---|
| lib/std | 425 | 0 | 69 | 412 (97%) |
| plugins | 455 | 0 | 73 | 407 (89%) |
| prelude | 325 | 63 | 137 | 316 (97%) |
| zinc:* | 35 | 0 | 13 | 33 |
The cause of each difference, with whether it blocks compiling once:
- Function indices.
fname()(src/aot/aot.cpp:42) emitsf<i>andt<i>, and every unit declares all 1891 functions plus 1030Z_C<c>class structs (aot.cpp:416-427, 251 KB per unit). Indices come from lowering order and are renumbered by DCE (src/ir/opt.cpp:517-567). This blocks reuse: one function added or removed anywhere changes every unit. To fix it, use stable names (the hot key hashed:z_<hash(key)>) and declare only the callees a unit calls. The repeated header itself costs only 0.02-0.1 s per unit (header-only unit measured at 0.10 s, an empty one at 0.08 s); the problem is that it puts the whole program into each unit’s cache key. - String table.
ZSTR(k)=m.strConsts[k](aot.cpp:402).internStrnumbers strings program-wide in order of first use (src/ir/lower.cpp:207-211). This blocks reuse: one new literal shifts every later id. To fix it, give each unit a local table of its strings, with anextern const uint32_trelocation array per unit filled in the main unit (or bound by content at load). - Class ids.
Z_C<c>,ZOBJ(c, …),newObject(m, c),downcast,instanceOf,catches(aot.cpp:236-260) all block reuse. Field offsets are stable per class (inherited fields first), and only the class id moves. The fix is the same relocation array, with structs named by class key. - Selector ids.
ZVIRT(x, sel)/virtualTarget(..., sel)use selectors interned program-wide, keyedname#<checker type id>(lower.cpp:232-241). This blocks reuse; fix it with the relocation array and a key of the name plus the signature’s text.virtArity()(aot.cpp:154) also takes the argument count from the first class of the program implementing the selector, which is whole-program text. Derive it from the selector’s signature instead. - Global slots.
m.globals[g],ZSETG(g, …)(aot.cpp:263-266;lower.cpp:342, 782) block reuse; use the relocation array. - Native ids.
nativeCall(m, D)(aot.cpp:282) indexes the program’snativeslist (lower.cpp:2570). This blocks reuse; use the relocation array (the loader already binds natives by module and name). - Lambda and helper names.
lambda<N>comes from a program-wide counter (lower.cpp:534), and__g<N>,__fmt<typeid>,__dynTo<typeid>(src/frontend/check.cpp:900,src/frontend/dyn.cpp:66) come from per-program counters and type ids. These block reuse: they are only names, but they enter the callers’ text. Hot keys already avoid them (<path>#<offset>, signatures instead of ids). - Cross-module inlining. Any callee of 40 instructions or fewer is inlined, for 3 rounds (
opt.cpp:20, 111-136, 570-580). This partly blocks reuse: a std body inlines other std bodies, which is stable per engine version, but it can also inline app code it calls directly. It accounts for most of the “frame size” differences (10 std, 37 plugin functions). The rule for library code: inline only library callees. - Closed-world devirtualization. A virtual call becomes direct when one instantiated class implements it (
opt.cpp:40-72; alsolower.cpp:252-264over the classes the programnews). This blocks reuse: the result depends on every class of the program (5 std and 17 plugin functions differ for this reason alone). Library code must be compiled open-world, devirtualized only on exact types. - Dead-code elimination.
removeUnreachable(opt.cpp:517) keeps only reachable functions and marks classes abstract when their vtable lost entries. This blocks grouping by module: music keeps 439 of the 1370 functions of the std modules it imports (measured withZN_KEEP_UNREACHABLE=1), and hero shares only 208 of music’s 436 std functions. A precompiled core has to be compiled without DCE, with the linker’s-dead_stripdoing that job. - Constant globals folded.
foldConstGlobals(opt.cpp:177) folds a constant written once by@maininto every read. This doesn’t block reuse for library globals (stable per std), but app globals must not be folded into library code (they aren’t read there). - Generic instances. Generics are monomorphized (
check.cpp:3771,lower.cpp:800). This doesn’t block reuse: an instance with app types belongs to the app’s units, and instances internal to std belong to the core. Hot keys already carryin <instance>. - The prelude. It is assembled per program from flags such as
needsJsonandneedsAsync(src/frontend/modules.cpp:3352-3364). This blocks reuse: make it a fixed std module that is always in the core, and leave the per-type helpers of app types to the app. - Typed classification.
typedFunctions()(aot.cpp:332) runs a fixpoint over callees. This doesn’t block reuse, because it is stable within the library. - Rt ids and
hostFast[]. These are engine-wide constants (include/zn/runtime.h) and don’t block reuse: they are stable per engine version, which already keys the cache.
In short: once table indices are made symbolic, 93% of the library functions the two apps share are already identical C++. The remaining 7% come from whole-program optimizations (inlining, devirtualization, DCE). All of them can be removed for library code alone, at a small cost: a virtual call from std into app code stays virtual.
3. Designs and expected reuse
Measured today, on a copy of music in another directory ($T/proj/music, zinc build after each edit):
| Change | Units reused | C++ compile | Total |
|---|---|---|---|
| Same program, another directory | 41/41 | 0 | 2.8 s |
One body edited (return -1 → -2 in radios.ts) | 38/41 | 2.5 s | 5.6 s |
| One string literal added (radios.ts) | 4/41 | 10.7 s | 13.7 s |
| One function added (radios.ts) | 0/41 | 10.3 s | 13.4 s |
| Second app (music-tree) after music | 0/42 | 9.3 s | 12.5 s |
Even a full cache hit costs 5.6 s with -o (frontend 1.3 s, AOT emit 1.8 s, link 1.5 s). zinc run’s run cache (ZN-605) skips all of that when no input changed.
(a) Stable units. This changes only src/aot/aot.cpp and the loader. It compiles the same functions as today, but changes how they are named and grouped:
- Compute the hot keys in every build (the code exists in
lower.cpp:819-866), with library paths relative to the lib root, and name each functionz_<hash(key)>. - Group units by source module, in chunks of 48 functions in source order, and declare only the callees and class structs a unit uses.
- Replace every program-table index (strings, classes, selectors, globals, natives) with
zr_<unit>[n], a per-unitextern const uint32_tarray that the main unit defines. That is one extra load per reference. - Derive
virtArityfrom the selector.
Expected for music: 63 units (18 app, 8 prelude, 6 zinc:*, 13 std, 18 plugins).
- Edit one app file (any of the three edits above): that file’s units (1 for radios.ts; 1 to 8 for main.ts, depending on where the chunks shift) plus the main unit are compiled, against 3, 37 or 41 of 41 today. That is about 1-3 s of CPU instead of 40 s.
- Second app: simulated on music → music-tree with the normalization above, 26 of 45 library units would be identical (zinc:* 5/6, std 10/13, plugins 9/18, prelude 2/8). For an app less alike (hero shares half of music’s std functions), DCE subsets make close to 0 std units match. Turning off the whole-program optimizations for library code would raise the near-twin case to most units, but not the different-app case.
(b) A real precompiled core. This builds on (a). lib/std, the zinc:* modules and a fixed prelude are compiled once per engine version, target and flags, with no DCE, open-world, inlining only library callees, into libzn_core-<target>.a plus its table blob (classes, selectors, strings, globals of the core, merged by the loader at start). It is built by the release CI and shipped like libzn_rt.a. Each plugin’s Zinc code is compiled the same way when the plugin is installed (zinc add, like its native archive today: “cached native library”). The main unit’s binding table names only the core functions the program reaches, so -Wl,-dead_strip drops the rest, the way core.a works.
- Edit one app file: only that file’s units plus the main unit, as in (a).
- Any second app: only its own app units (13 for music-tree), and 0 library units ever, whatever subset it uses. The 57% of the CPU that is library code disappears from every user build.
- Cost: a core of the std modules music imports is about 1370 functions without DCE (about 30 s of CPU at the measured 21 ms per function). The whole lib/std (15.6k lines) is about 3-4k functions, a few minutes of CI per target, and nothing on the user’s machine.
(b) is Arduino’s model. (a) is the half of it that is needed anyway, because (b) needs the stable names and relocation arrays.
Order: (a) first: it fixes the edit loop of every app and is local to the AOT emitter and the loader. (b) next: library code is lowered open-world, and the core is built in CI.
4. The main unit: 31 MB of C++
Of music.cpp’s 30,984,566 bytes, 251 KB are the declarations, 410 KB are kModule (232,478 bytes of ZBC tables), 30.3 MB are kResources (10,272,268 bytes of baked fonts and images, about 3 characters per byte as an octal-escaped literal, from appendBytes, aot.cpp:317), and 14 KB are the tail. The main unit changes on almost every edit (the ZBC tables are in it), so its cost recurs.
The measurement compiled the kept unit with the logged flags (-std=c++20 -O2 -mno-outline -mllvm -aarch64-enable-ldst-opt=false -fno-stack-protector -w -ffp-contract=off -I…, from the compile and link: line at src/main.cpp:882, plus the current -include-pch), using /usr/bin/time -l:
| Variant | Wall time | Peak RSS |
|---|---|---|
| Main unit as generated, with PCH | 0.55-0.9 s (1.2 s cold) | 903 MB |
| Main unit, no PCH | 1.0-1.2 s | — |
Same unit with #embed "resources.bin" | 0.76-1.3 s | 892-973 MB (no gain: the backend holds the constant) |
Resources removed (extern symbol), with PCH | 0.18 s | 131 MB |
resources.s with .incbin (10 MB) | 0.04 s | — |
| One function unit, for comparison (part16) | 2.4-2.9 s | 203 MB |
The main unit is not the time bottleneck on this machine (the 40 function units are), but its 900 MB peak would exhaust a 1 GB Pi 3 or a 4 GB laptop running two builds. The fix is small:
- Write the blob to a file and emit an assembler file with
.globl _kResourcesBlob; _kResourcesBlob: .incbin "res.bin"; .byte 0. It works the same with clang, gcc andzig ccfor the cross targets, and the symbol prefix_is needed on Mach-O only. - Cache its object by the blob’s hash: the blob only changes with the assets.
- Replace
sizeof kResources - 1with an exported size symbol. - Do the same for
kModuleif wanted (232 KB, negligible).
Two alternatives: on macOS only, -Wl,-sectcreate,__DATA,__zres,res.bin plus getsectiondata() needs no object at all; on Linux, ld -r -b binary / objcopy works but is less portable than .incbin. A side gain: the AOT emit phase (1.8 s) would no longer escape 10 MB into 30 MB of text, and the cache would no longer hash it.
5. The interpreter as the development default
How the engine is chosen. zinc run of a program that draws compiles natively and caches the executable (D43, docs/reports/zinc-next-decisions.md:113; ZN-398, with ZN-605 for the run cache). runcache::wanted() (src/main.cpp:1062-1070) interprets on --interp (also set by zinc dev and zinc debug), on ZINC_HEADLESS, ZINC_DETERMINISTIC or ZINC_DEVAPP unless --native, on a project with webgl, with --profile, when there is no compiler, and when the build fails. The interpreter’s compile is itself cached (ZN-415, zbccache, main.cpp:994).
The comparison: ZINC_HEADLESS=1 ZINC_FRAMES=1 next/build/zinc run --interp|--native ../examples/music, timed with /usr/bin/time -p:
| Nothing changed (warm) | After an edit | |
|---|---|---|
| Interpreter | 0.10-0.17 s (cached ZBC; the program itself 45 ms) | 1.1 s (ZINC_NO_CACHE=1: full frontend + ZBC) |
| Native | 0.03-0.27 s (cached executable) | 5.6 s (body edit) to 13.7 s (new string or function), with 10 cores |
Nothing in the interpreter path needs a C++ compiler: the plugins load their cached native libraries at their first call (seen in the log), and resources are baked into resources.bin in the ZBC cache. D43’s own revisit condition reads: “if the first-run build time grows past 10 s (then build in the background and run interpreted meanwhile)”. Music already crosses it on a 10-core Apple Silicon machine (13.4 s after adding one function), and a modest machine would take several times longer.
D43 measured interpreted speed at 75% of compiled speed (23.9 against 31.7 fps on 200k balls). That is acceptable for a development loop, and UI apps spend most of their time in the host (raster, layout, text), which is native in both engines.
Recommendation. Make the interpreter the default of zinc run for development:
- Start interpreted at once.
- Build the native executable in the background (low priority, with
ZINC_JOBSlimited) and use it from the next run when it is ready. - Keep
--native,zinc buildandzinc exportfor speed and shipping.
This is D43’s option (c) applied only once the build is slow. It needs a new decision record, because it reverses D43.
With (a) and (b) in place, the native rebuild after an edit falls to the edited file’s units plus a 0.2 s main unit and the link (1.5 s), about 2-4 s, and the interpreter-first question matters less.