kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

commit f203f822edbed3bc9c9ee38095985e542bdd43db
parent 830bc7b70276572fcda41678e495c9606ef69fe4
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 16 Jun 2026 19:31:21 -0700

doc/plan: clean up roadmaps — retire shipped/landed docs, promote durable design

Triaged all 29 doc/plan/ roadmaps. Plan docs whose work has fully landed are
deleted after their durable "as-built" design moves up to the design set;
genuinely-open roadmaps stay; speculative not-yet-started sketches move to
doc/ideas/.

Deleted (design moved up first):
- O1.md, O1-PATTERNS.md      -> doc/OPT.md + doc/plan/OPTIMIZER.md (§3/§4)
- PERF.md                    -> doc/BENCHMARKING.md (new; methodology only)
- CG-TYPES.md, CG-TYPE-DEBUG-SPLIT.md -> doc/CODEGEN.md ("Type system")
- CG-STACK-API.md, LEX-PP-API.md      -> doc/FRONTENDS.md
- FRONTEND-SHAPE.md          (abandoned outright)
- BUILD_COMMANDS.md          (already in doc/DRIVER.md)
- KERNEL.md                  -> doc/KERNEL.md (new design doc)
- BOOTSTRAP.md               -> doc/BUILD.md (fixed point + triage playbook)
- windows.md                 -> doc/WINDOWS.md (aarch64 self-host)
- ARCH.md, WASM.md           -> open items folded into doc/plan/TODO.md
- LINKER-COMPAT.md           -> folded into doc/plan/LINKER.md

Promoted: PORT.md -> doc/PORT.md (added to DESIGN.md index).
Archived: RQL.md, RSN.md -> doc/ideas/ (+ ideas/README.md).
Carved out: kit prof (host-native sampling profiler) -> doc/plan/PROF.md.

Updated in place: LINKER.md (consolidated + corrected an overstated
"file-incremental done for ELF" claim), RELEASE.md (removed 18 verified-landed
items; corrected stale ecosystem-bug prose), DEBUG.md, OPTIMIZER.md, README.md,
TODO.md, DESIGN.md, and doc/LINK.md (linker-script subset has grown — MEMORY/
PHDRS/PROVIDE/etc. are now parsed, no longer "rejected").

doc/plan/ 29 -> 13 docs, all open roadmaps. All cross-links verified resolving.

Diffstat:
Mdoc/ARCH.md | 6++++--
Adoc/BENCHMARKING.md | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/BUILD.md | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/CODEGEN.md | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/DESIGN.md | 6+++++-
Mdoc/FRONTENDS.md | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Adoc/KERNEL.md | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/LINK.md | 13+++++++++----
Mdoc/OPT.md | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Adoc/PORT.md | 279+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/WINDOWS.md | 44+++++++++++++++++++++++++++++++++++++++++---
Adoc/ideas/README.md | 11+++++++++++
Rdoc/plan/RQL.md -> doc/ideas/RQL.md | 0
Rdoc/plan/RSN.md -> doc/ideas/RSN.md | 0
Ddoc/plan/ARCH.md | 182-------------------------------------------------------------------------------
Mdoc/plan/ARM32.md | 6+++---
Ddoc/plan/BOOTSTRAP.md | 281-------------------------------------------------------------------------------
Ddoc/plan/BUILD_COMMANDS.md | 292-------------------------------------------------------------------------------
Ddoc/plan/CG-STACK-API.md | 721-------------------------------------------------------------------------------
Ddoc/plan/CG-TYPE-DEBUG-SPLIT.md | 373-------------------------------------------------------------------------------
Ddoc/plan/CG-TYPES.md | 591-------------------------------------------------------------------------------
Mdoc/plan/DEBUG.md | 147+++++++++++++++++++------------------------------------------------------------
Ddoc/plan/FRONTEND-SHAPE.md | 433-------------------------------------------------------------------------------
Ddoc/plan/KERNEL.md | 580-------------------------------------------------------------------------------
Ddoc/plan/LEX-PP-API.md | 571-------------------------------------------------------------------------------
Ddoc/plan/LINKER-COMPAT.md | 240-------------------------------------------------------------------------------
Mdoc/plan/LINKER.md | 538++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Ddoc/plan/O1-PATTERNS.md | 554-------------------------------------------------------------------------------
Ddoc/plan/O1.md | 876-------------------------------------------------------------------------------
Mdoc/plan/OPTIMIZER.md | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ddoc/plan/PERF.md | 657-------------------------------------------------------------------------------
Ddoc/plan/PORT.md | 278-------------------------------------------------------------------------------
Adoc/plan/PROF.md | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/plan/README.md | 47++++++++++++++++++++++++-----------------------
Mdoc/plan/RELEASE.md | 82++++++++++++++++++++-----------------------------------------------------------
Mdoc/plan/TODO.md | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ddoc/plan/WASM.md | 205-------------------------------------------------------------------------------
Ddoc/plan/windows.md | 178-------------------------------------------------------------------------------
38 files changed, 1769 insertions(+), 7497 deletions(-)

diff --git a/doc/ARCH.md b/doc/ARCH.md @@ -390,5 +390,7 @@ is only true under the optimizer's `func_begin_known_frame`. --- -Remaining and planned per-arch work (deferred niche encodings, audit -follow-ups) is tracked in [plan/ARCH.md](plan/ARCH.md). +Remaining and planned per-arch work — x64/rv64 tail-call realization, +prologue/epilogue cost-model alignment, x64 debugger step-out unwind, and the +deferred niche `as`/inline-`asm()` encodings — is tracked in the "Arch-backend +parity" entry of [plan/TODO.md](plan/TODO.md). diff --git a/doc/BENCHMARKING.md b/doc/BENCHMARKING.md @@ -0,0 +1,253 @@ +# Benchmarking compile speed and code size + +This is the reference for *how to measure* kit's two performance axes — +**compile+link throughput** and **emitted code size** — reproducibly. It is the +methodology a contributor reuses to attribute a regression, validate a change, or +re-rank where the cost is. It is deliberately free of standings and rankings +(those move every landing); it describes the tools and the traps. + +The two axes here are distinct from the two other doc surfaces nearby. Generated- +code *quality* (runtime speed / density of optimized output) is the optimizer's +concern — see [OPT.md](OPT.md) and [plan/OPTIMIZER.md](plan/OPTIMIZER.md). The +*correctness* gates a perf change must also pass (the byte-identity gate, the toy/ +opt/smoke suites, the ecosystem run-and-diff) are in [TESTING.md](TESTING.md). A +static line-count and binary-byte breakdown of kit *itself* by component is in +[CODE_SIZE.md](CODE_SIZE.md); this doc is about measuring the C *workload* kit +compiles. + +## Two rules + +- **A superlinear axis is a bug.** A compile-speed regression that scales worse + than linearly in some input dimension (function count, declaration count, …) + means an inner step is O(n²)-ish — a scan run per item, a table that rebuilds, + a list re-walked. The synthetic scaling guard below exists to catch these. +- **Measure on a RELEASE build.** Use `make bin RELEASE=1` (binary at + `build/release/kit`), or `PROFILE=1` for sampling — never the default + `make bin`, which is `RELEASE=0` and links ASan/UBSan. ASan amplifies + *memory*-op cost and dilutes *compute* cost, which inverts the ranking of where + the time goes. Correctness gates are build-mode-independent; timers are not. + +A kit binary copied outside the build tree fails with `support dir not found` — +it resolves its `rt`/support dir relative to its own path. To A/B two builds keep +both binaries inside `build/release/` (e.g. `kit_baseline`, `kit_fixed`), each +beside its own `support/rt` sibling. + +## The reference workload and the bar + +The standing real workload is the **sqlite amalgamation** — one ~9 MB / 263 K-line +C file (`tmp/projects/sqlite-amalg/sqlite3.c`): real, huge, declaration/macro/ +type-heavy, and doubling as an end-to-end correctness test (compile + link the +shell, run a query). Commands below assume `SDK=$(xcrun --sdk macosx --show-sdk-path)` +and a release kit at `build/release/kit`. + +The code-size and compile-speed bar is **tcc** (the fastest mainstream C +compiler); clang is recorded for context. Build tcc from the mob mirror as an +optimized binary: + +```sh +git clone --depth 1 https://github.com/tinycc/tinycc tmp/tinycc && cd tmp/tinycc +export SDKROOT=$(xcrun --sdk macosx --show-sdk-path) # else "stdio.h not found" +sed -i 's|^CFLAGS=.*|CFLAGS=-Wall -O3 -DNDEBUG -Wdeclaration-after-statement|' config.mak +./configure ; make clean ; make CC=cc # CC=cc = Apple clang (xcrun-aware) +# binary at tmp/tinycc/tcc ; compile with: tcc -c sqlite3.c -o x.o +``` + +## Compile speed: instruction count (macOS) + +**`instructions retired` is the metric to trust** — it is load-independent, so it +reproduces under noise. `/usr/bin/time -l` is the macOS `perf stat`; cycles/wall +are low-load readings worth recording only for context. Take best-of-N to filter +scheduling noise: + +```sh +m(){ local bi=9e18 bc=9e18 br=9e18; for i in $(seq 1 7); do + /usr/bin/time -l "$@" >/dev/null 2>/tmp/m.txt + local I=$(awk '/instructions retired/{print $1}' /tmp/m.txt) + local C=$(awk '/cycles elapsed/{print $1}' /tmp/m.txt) + local R=$(awk '/real/{print $1}' /tmp/m.txt) + awk "BEGIN{exit !($I<$bi)}"&&bi=$I; awk "BEGIN{exit !($C<$bc)}"&&bc=$C + awk "BEGIN{exit !($R<$br)}"&&br=$R; done; echo "instr=$bi cycles=$bc real=${br}s"; } +cd tmp/projects/sqlite-amalg +m build/release/kit cc -c sqlite3.c -o /tmp/k.o --sysroot "$SDK" +m tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o +``` + +**Caveat:** Apple clang spawns a `cc1` that `/usr/bin/time` does **not** count — +measure clang with `cc -fintegrated-cc1 -c …`. kit and tcc are single-process, so +their figures are whole-compile. + +### Phase decomposition + +To split lex+pp from the rest, **do not use `-E`**. `-E` adds a token→text +serialization that neither compiler does at `-c`, and kit and tcc serialize at +wildly different cost, so `-E` is a misleading lex+pp proxy. Instead **drain the +token stream to EOF** (lex+pp only, no parse, no output) in each compiler: + +```sh +# kit: KIT_PP_DRAIN runs pp_next_parse to EOF then returns (lang/c/c.c) +m env KIT_PP_DRAIN=1 build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /dev/null # lex+pp +m build/release/kit cc -fsyntax-only sqlite3.c --sysroot "$SDK" # +parse/sema/types/CG +m build/release/kit cc -c -o /tmp/k.o sqlite3.c --sysroot "$SDK" # +emit+objwrite +# tcc: the -bench hook drains next() to EOF; patched (tccpp.c tcc_preprocess) +# to use -c parse_flags. TCC_DRAIN_FLAGS=c also decodes literals in the lexer. +m env TCC_DRAIN_FLAGS=c tmp/tinycc/tcc -E -bench sqlite3.c -o /dev/null # tcc lex+pp +m tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o # tcc full -c +``` + +The three `kit` invocations are nested (each adds a phase), so successive +differences attribute instructions to lex+pp, parse/sema/types/CG, and +emit+objwrite respectively. + +### macOS hotspot sample (self-time leaves) + +A single `-c` is too fast for `sample`; build a profiling kit (`-g` + frame +pointers, no strip) and merge ~80 short runs. This gives self-time *leaves* only — +for inclusive attribution use Linux callgrind (below). + +```sh +make RELEASE=1 PROFILE=1 CC=clang BUILD_DIR=build/bench bin # -g + frame ptrs, no strip +cd tmp/projects/sqlite-amalg ; KIT=build/bench/kit ; rm -f /tmp/p_*.txt +for i in $(seq 1 80); do + $KIT cc -c sqlite3.c -o /tmp/kk.o --sysroot "$SDK" 2>/dev/null & pid=$! + sample $pid 5 1 -file /tmp/p_$i.txt -mayDie >/dev/null 2>&1 ; wait $pid; done +for f in /tmp/p_*.txt; do awk '/Sort by top of stack/{f=1;next}/Binary Images/{f=0}f' "$f"; done \ + | sed -E 's/\(in [^)]*\)//' | grep -oE '[A-Za-z_][A-Za-z0-9_]*[[:space:]]+[0-9]+' \ + | awk '{c[$1]+=$2;t+=$2} END{for(k in c) printf "%.1f%% %s\n",100*c[k]/t,k}' | sort -rn | head -25 +``` + +## Compile speed: Linux callgrind (inclusive attribution) + +There is no valgrind on macOS, and `sample` sees only self-time leaves. The +**inclusive, instruction-grounded** profiler — the one that attributes a parent's +cost across the functions it calls — is Linux `valgrind --tool=callgrind`. +`scripts/perf_callgrind.sh` codifies the recipe; one-time +`scripts/perf_callgrind.sh image` bakes the container, then: + +```sh +scripts/perf_callgrind.sh run <tag> # builds kit in-container, runs callgrind, prints total Ir + # -> build/linux-prof/cg.<tag>.annot.txt (self Ir + callers) +``` + +For an `-O1` *optimizer-finalize* profile of sqlite, the full collection +overflows Valgrind's brk segment on the default VM, so give the VM more memory, +toggle collection at `opt_on_finalize`, and force glibc malloc onto `mmap`: + +```sh +podman machine stop || true +podman machine set --memory 3072 +podman machine start +podman run --rm --platform linux/arm64 -v "$PWD":/work:Z kit-prof sh -c ' + set -eu + cd /work/tmp/projects/sqlite-amalg + env MALLOC_MMAP_THRESHOLD_=1 MALLOC_ARENA_MAX=1 \ + valgrind --tool=callgrind --cache-sim=no --branch-sim=no --dump-instr=no \ + --collect-jumps=no --collect-atstart=no --toggle-collect=opt_on_finalize \ + --callgrind-out-file=/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.out \ + /work/build/linux-prof/kit cc -O1 -c sqlite3.c \ + -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -lc -o /tmp/sqlite_o1.o \ + 2>/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.vg.log + callgrind_annotate --threshold=99.5 \ + /work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.out \ + >/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.annot.txt +' +podman machine stop ; podman machine set --memory 2048 ; podman machine start # restore +``` + +Gotchas baked into the script (do not relearn the hard way): + +- **Pass `-lc`** so kit finds the glibc sysroot even for `-c`. Use a **bookworm** + base (glibc 2.36), *not* ubuntu 24.04 (glibc 2.39's `bits/math-vector.h` uses a + vector-typedef attribute the C frontend rejects). +- **`strip --strip-debug`** the kit binary *in place* before profiling — valgrind + 3.19 chokes on clang DWARF5, and callgrind needs only `.symtab`. Strip in place + so kit still resolves `support/rt` relative to its own path. +- **Trust self/exclusive `Ir`, not inclusive %.** Recursion in the recursive- + descent parser makes `--inclusive=yes` double-count to absurd numbers. (Self-`Ir` + may be summed across callgrind's `'2` symbol splits for a recursive function.) +- The **Linux/ELF total is not comparable to the macOS/Mach-O instruction count** + — different libc, sysroot, and format, and the Linux figure includes glibc + the + loader + the `-lc` probe. Use callgrind for *where* (distribution); use macOS + `instructions` for *how much*. An `-O1` optimizer-finalize profile excludes + frontend recording, so it is a distribution, not a total comparable to a full + `-O0` compile. + +## Heap allocation counts (KIT_METRICS) + +`KIT_METRICS=1` prints the hosted-heap counters (allocs / large allocs ≥32 KiB / +reallocs / frees) at exit. The counts are host-independent — kit issues the same +`h->alloc` calls everywhere — so they isolate allocation *behavior* from the +host's malloc cost: + +```sh +KIT_METRICS=1 build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /tmp/k.o +# -> kit heap metrics: heap.allocs=... heap.large_allocs=... heap.reallocs=... heap.frees=... +``` + +`KIT_METRICS=1` is also the way to see where an `-O1` compile spends its time: +each `metrics_scope_*` bracket in the optimizer becomes a `<scope> <ticks> ticks` +line, with per-pass counters (`opt.funcs`, `opt.blocks`, `opt.pregs`, the inliner +refusal histogram, …). Ticks are the raw host cycle counter; within one run the +scopes are directly comparable. See [OPT.md](OPT.md) §8 for the scope tree and +interpretation; the heap counters reuse the same `KitProfiler` machinery +(embedder counter range). + +## Code size + +The honest size metric is **`.text` machine code**, not the object-file size: the +object is format-skewed (kit emits Mach-O, tcc ELF) and not comparable. `kit size` +reports `.text`; the per-mnemonic histogram diff localizes where the byte excess +(or surplus) is: + +```sh +build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /tmp/k.o ; build/release/kit size /tmp/k.o +tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o ; build/release/kit size /tmp/t.o +# per-mnemonic excess (heavier in kit = positive count delta): +hist(){ build/release/kit objdump -d "$1" | grep -E '^[[:space:]]+[0-9a-f]+:' \ + | sed -E 's/.*\t([a-z][a-z0-9._]*).*/\1/' | grep -E '^[a-z]' | sort | uniq -c | sort -rn; } +diff <(hist /tmp/k.o) <(hist /tmp/t.o) +``` + +For `-O1` generated-code-quality size comparison against **clang** per ecosystem +file, `scripts/o1_quality.sh` builds the per-file kit (`.k.o`) and clang (`.c.o`) +objects into `build/o1_quality/`; the per-file ratio is kit `__TEXT` ÷ clang +`__TEXT` via `size -m`. When counting instruction lines from `kit objdump -d`, +note the format is `addr:\t bytes \t mnemonic operands` — the mnemonic is after +the *second* tab, so a naive `grep ':\t\w+'` matches the byte column and is wrong. +clang on macOS needs `-isysroot "$SDK"`; kit needs `--sysroot "$SDK"`. + +## The synthetic scaling guard (`make bench-cc`) + +`make bench-cc` is the regression guard for the *superlinear-axis-is-a-bug* rule. +It sweeps each input dimension — `fn-count`, `body-size`, `global-decl`, +`type-decl`, `locals-per-fn`, `pp-macro`, `pp-include`, `ref-density`, +`obj-count`, `symbol-count` — over a geometric series, fits a scaling exponent +(`p ≈ 1.0` is the goal; `p ≥ 1.4` is flagged `SUPERLINEAR` and treated as a bug), +samples hotspots, and records a clang reference. Output lands in `build/bench/cc/` +(`scaling.md`, `hotspots.md`). Sources of truth: `scripts/cc_bench_gen.py` (the +axis catalog), `scripts/cc_bench.sh` (harness), `scripts/cc_bench_report.py` +(fit + verdicts). + +Use the synthetic sweep to catch a *re-introduced* O(n²); use the sqlite profile +to decide *where* to optimize — the synthetic axes over-weight codegen relative +to real declaration/macro-heavy code. + +For an `-O1` quality change specifically (which deliberately changes emitted +bytes), the relevant linearity check is that sqlite `-O1` compile time does not +move materially, validated with this sweep plus a before/after timing of the +sqlite `-O1` compile. + +## End-to-end correctness as a perf gate + +The sqlite workload is also a correctness oracle, run at both opt levels: + +```sh +build/release/kit cc sqlite3.c shell.c -o /tmp/sq --sysroot "$SDK" -lc +/tmp/sq :memory: "create table t(a,b); insert into t values(2,40),(10,32); + select sum(a)+sum(b), count(*) from t;" # -> 84|2 +``` + +The full perf-change gate (byte-identity for compile-speed changes, +run-correctness + determinism for code-size changes, and the suite list) is in +[TESTING.md](TESTING.md); `scripts/perf_identity_gate.sh` and +`scripts/perf_axis_time.py` are the gate/timer tooling, driven by +`make perf-golden` / `make perf-gate`. diff --git a/doc/BUILD.md b/doc/BUILD.md @@ -277,3 +277,71 @@ through the bootstrapped compiler. The host-clang seed is the current root of trust. A full diverse-double-compilation / hex0-style seed chain (a tiny seed binary that needs no pre-existing C compiler) is a separate concern and is deliberately outside the boundary of this build. + +### Reach and host coverage + +The fixed point is reached on aarch64 across macOS (Mach-O), Linux (ELF, both +musl and glibc), and FreeBSD (ELF), at both `-O0` and `-O1`. In every +configuration `cmp stage2/kit stage3/kit` is byte-identical, the per-object diff +across all stage2/stage3 `*.o`/`*.a` reports zero differences, and the +bootstrapped stage3 runs the Toy corpus. Because the byte-identity check leans on +the deterministic output above, any nondeterminism in codegen or object layout +surfaces here as a stage2/stage3 mismatch — so the bootstrap doubles as the +project's strongest end-to-end correctness oracle, exercising the C frontend, +every optimizer pass, the native backends, the object writers, the linker, and +the archive tools on a real, substantial program. + +`make bootstrap` keys off the build host's own `uname`, selecting the native +toolchain and object format with no cross-compilation. A Linux or FreeBSD target +is bootstrapped from the macOS dev host by running the ordinary three-stage build +*inside* a matching arm64 container or VM (`scripts/linux_bootstrap.sh +[musl|glibc]`, `scripts/freebsd_bootstrap.sh aarch64`), where it is a plain +native build. Three host-environment differences from the macOS reference are +handled by those scripts / `mk/bootstrap.mk`: LeakSanitizer must be disabled +(`detect_leaks=0`) because kit's arena allocator deliberately never frees; +`HOST_SYSROOT_{C,LD}FLAGS=-lc` is passed to the stage2/3 sub-makes so kit's +hosted profile wires up the libc include/library dirs (on macOS the `-isysroot` +already supplies them); and glibc + Linux-UAPI headers need the C-frontend/pp +GCC-ism compatibility handled by the frontend. + +### Triage playbook for fixed-point regressions + +A stage2/stage3 mismatch (or a stage3 link failure) is almost always a +miscompile or a nondeterministic codegen change, not a build-system bug. The +following approach is the default starting point. + +- **Use object reproduction, not "does it link", as the oracle.** A stage3 link + failure is usually a *symptom* of a malformed object emitted earlier. Compile + one suspect TU with both the host `kit` and the stage2 `kit` under identical + flags and `cmp` the two objects; this separates malformed-object bugs from + link-driver symptoms and points straight at the diverging codegen. +- **Narrow with hybrid relinks.** Relink stage2 after replacing one suspect TU + with a clang-built object, then use that stage2 to compile the known-differing + target object — isolating a linker bug from codegen for a specific source file. +- **Inspect MIR around the suspect symbol.** A filtered MIR dump taken after + lowering and the combine pass is usually enough to see the divergence (e.g. a + call argument referencing a backend scratch register where it should reference + an allocable one). +- **Avoid `-g` while triaging `-O1` codegen.** Debug info changes object layout + and can create or mask layout-sensitive bugs; triage on the non-`-g` object + first. + +The bug classes that have historically broken the `-O1` fixed point all live in +the interaction between the optimizer's register-level reasoning and the +backend's scratch-register discipline, with the object/link layer as where the +symptom surfaces. Keep them in mind when bringing up a new arch or platform: + +- **Operand clobber in native emit** — materializing the left operand of a + binop/compare into a scratch register that already holds the right operand + (compute the RHS location first and exclude its register when materializing the + LHS). +- **Copy propagation across backend scratch registers** — scratch registers may + appear in lowered MIR but must not be extended across later instructions, since + native lowering reuses them as transient temporaries. +- **Coalesce overlap checks** must use raw range points, not compressed points. +- **Lower-pass hint fallback** must not place values live across a call into + caller-saved hint registers. +- **Native scratch budget** — a backend needs enough integer scratch registers + for all-spilled three-operand operations (aa64 needs three). +- **Aggregate copy/set with pointer operands** must not force-home the pointer + local; genuinely frame-backed pointer locals need prematerialized indirect bases. diff --git a/doc/CODEGEN.md b/doc/CODEGEN.md @@ -111,6 +111,76 @@ table. Source-like targets override `switch_` to emit a native construct (the C target a real `switch`, a wasm target `br_table`). Same semantic input, different realization. +## Type system + +Types flow through CG as `KitCgTypeId`, a `uint32_t` handle into one +compiler-owned type table (`src/cg/type.c`). The handle is the *only* thing +stored in IR, ABI records, frontends, and backends, so it stays cheap to copy. +Ids are stable only within one `KitCompiler` and are never serialized as semantic +identities. Decode is a direct table index — builtins occupy the first nonzero +ids, user entries append after them — with no segment/bias scheme. `0` +(`KIT_CG_TYPE_NONE`) means **invalid/absent only**. + +`KitCgTypeId` is a pure **storage/ABI/operational** identity. Its kind lattice +(`KitCgTypeKind`) is exactly `VOID, BOOL, INT, FLOAT, PTR, ARRAY, FUNC, RECORD, +ENUM, VARARG_STATE`. Two load-bearing consequences: + +- **Void is a real builtin type**, never `KIT_CG_TYPE_NONE`. A function that + returns no value uses `kit_cg_type_builtin(c, KIT_CG_BUILTIN_VOID)` as its + result type; `KitCgFuncResult.type` is always a valid id. No caller tests + `result.type == KIT_CG_TYPE_NONE` to mean "void"; `kit_cg_type_is_void` and + `kit_cg_func_result_has_value` answer that. This separates "no result type + yet" (invalid) from "the void type" (a type), which were conflated before. +- **Integers are width-only.** `int` and `unsigned` both lower to the `i32` + builtin; signedness is not on the type. It is carried by operations + (`sdiv`/`udiv`, `sext`/`zext`), by ABI extension attrs + (`KIT_CG_ABI_SIGNEXT`/`ZEROEXT`), and by the per-load `KIT_CG_MEM_*` signedness + flag. There are **no transparent wrapper kinds** in the lattice — no `ALIAS`, + no source-spelling base — so the operational type is its own storage type. The + old pervasive unalias step is gone: `kit_cg_type_resolve_alias` is identity and + `KitCgTypeInfo.storage_id` always equals `id`. + +Construction is interned for structural shapes and nominal for the rest. Pointer, +array, and function constructors return a stable id for the same exact shape +(pointer key is `{pointee, address_space}`, array key `{elem, count}`, function +key the full result/params/attrs/conv/variadic shape). Records and enums get +fresh nominal identities. + +**Records are nominal and two-phase.** `kit_cg_type_record_decl(tag, is_union)` +mints an incomplete record id; `kit_cg_type_record_complete` fills its fields +exactly once. A pointer to an incomplete record is legal, so the recursive +`struct S; struct T { struct S *p; }; struct S { … };` pattern declares `S`, uses +`S*` while building `T`, then completes the same `S` id. Sizing, by-value fields, +arrays, by-value params/results, locals, and memory accesses require a +complete/sized type. The C frontend therefore no longer erases record-context +pointees to `void*` to make layout proceed — it caches the declared id and +completes it in place. + +**Layout authority is the ABI, not the type table.** Size, alignment, scalar +width, and record field offsets come from `TargetABI`; the type entry only +*caches* the computed `KitCgTypeLayout`, filled on demand after the facts it +depends on are final (record layout at completion). `kit_cg_type_record_layout` +returns a CG-owned, lifetime-stable `KitCgRecordLayout` that frontends cache +rather than copying offsets into a parallel table. This keeps 32-bit, Windows, +and bit-field ABI rules in one place. + +### Debug spelling is a separate channel + +Source-facing spelling — primitive sign+name (`signed char`, `unsigned int`), +typedef names, the enum underlying spelling — is not on `KitCgTypeId`. It rides a +separate, optional `KitCgDebugType` handle that exists only when debug info is +requested on the session; the builders return `KIT_CG_DEBUG_TYPE_NONE` when it is +not. `kit_cg_debug_base`/`_typedef`/`_ptr`/`_array`/`_func`/`_enum` build the +debug graph, and `kit_cg_debug_of_type` derives a default from an operational +type (reusing the existing record/enum/func emission). The frontend overrides +only the leaves it cares about (named scalars, typedefs) and attaches the result +to a decl via the optional `debug_type` field on the function/object/local attrs; +`0` there means "derive the default from the operational type." This keeps the +hot operational path free of any debug concern — debug types never enter interned +operational descriptors — while preserving faithful signed/unsigned/char DWARF +and enum enumerators. Non-debug frontends (toy, wasm) pass `0` and rely on the +default derivation. + ## CgTarget realizations `session.c`'s `kit_cg_begin` picks the realization. It asks the arch diff --git a/doc/DESIGN.md b/doc/DESIGN.md @@ -211,6 +211,7 @@ unless an API states otherwise. | [INTERPRETER.md](INTERPRETER.md) | The bytecode interpreter over the optimizer IR used by `run --no-jit`. | | [OBJ.md](OBJ.md) | The format-neutral object model and ELF/Mach-O/COFF/Wasm read/write behind `ObjFormatImpl`. | | [LINK.md](LINK.md) | Linking: symbol resolution, layout, relocation, linker scripts, and incremental linking. | +| [KERNEL.md](KERNEL.md) | The freestanding kernel build + image pipeline: `build-obj`/`build-exe`, the linker-script subset, `kit image` / `objcopy -O binary`, flat-kernel `Image` headers, and `kit cpio` initramfs archives. | | [JIT.md](JIT.md) | The JIT image model, executable-memory and TLS host hooks, and publish/append/replace. | | [EMU.md](EMU.md) | The user-mode guest-ELF emulator and its per-block JIT translation. | | [DWARF.md](DWARF.md) | DWARF debug-info production and the consumer used by the debugger and dumpers. | @@ -222,6 +223,9 @@ unless an API states otherwise. | [RUNTIME.md](RUNTIME.md) | The freestanding headers and compiler-rt/libc-style support in `rt/`. | | [BUILD.md](BUILD.md) | The build system and `KIT_*_ENABLED` component gating. | | [TESTING.md](TESTING.md) | The test suites and harnesses under `test/`. | +| [PORT.md](PORT.md) | The portability test surface: the harmonized `cross` + `selfhost` make targets and the substrates beneath them, parametrized over one support-set matrix. | +| [BENCHMARKING.md](BENCHMARKING.md) | How to measure compile speed and code size reproducibly: instruction counts, Linux callgrind, the `make bench-cc` scaling guard, and the vs-tcc/clang bars. | | [CODE_SIZE.md](CODE_SIZE.md) | Line counts per component (per-format/per-target split from core). | -Planned work and roadmaps live under `doc/plan/`. +Planned work and roadmaps live under `doc/plan/`; speculative not-yet-committed +designs live under `doc/ideas/`. diff --git a/doc/FRONTENDS.md b/doc/FRONTENDS.md @@ -142,6 +142,28 @@ the preprocessor traffics in identifiers, not keywords. `parse_c` interns the C11 keyword spellings into symbols once at startup (`kw_names[]` in `parse.c`) and recognizes keywords by symbol identity as it consumes tokens. +The token that flows across the whole lexer -> preprocessor -> parser lane is a +**lean 32-byte `Tok`** (`lang/cpp/lex/lex.h`), and the boundary deliberately +keeps the layers separate without paying per-token costs to do so: + +- **Identity is eager, spelling is lazy.** `Tok.aux` carries the interned `Sym` + for an identifier or the integer `Punct` code for a punctuator, so macro + lookup and parser keyword/binding checks are one indexed lookup and + punctuators — roughly half a large C stream — never hit the symbol table. + Exact spelling rides a `TextRef`, materialized only when `-E`, stringize, + paste, literal decode, or a redefinition check actually needs the bytes; a + `TEXT_SRC` ref names a span in the source's retained post-splice buffer, a + `TEXT_SYM` ref carries an interned `Sym` for synthetic tokens, and a canonical + punctuator carries no text at all (it reconstructs from `aux`). +- **Location is a compact `LocRef`** — `(file_id, byte_off)` — not an eager + line/column. The scanner tracks no running line number; `pp_materialize_loc` + resolves a `LocRef` to a `SrcLoc` on demand from the owning source's line map, + applying any `#line` overlay. Diagnostics and dynamic `__LINE__` go through + that helper, so the hot token never pays column arithmetic. +- **Slot-based handoff.** `pp_next_parse` / `pp_next_raw` and `lex_next` write + into a caller-owned `Tok` slot rather than returning a 32-byte struct by + value, avoiding the hidden sret copy at every step of the stream. + ### Preprocessor — lang/cpp/pp `pp.c` (+ `pp_directive.c`, `pp_expand.c`) implements translation phase 4 and @@ -155,16 +177,21 @@ Internally the preprocessor runs a **token-source stack**. Each source is either a `Lexer` (the main file or an `#include`d file) or a pre-built token buffer (a macro expansion in progress). Includes push a new lexer; macro invocations push an expansion buffer; EOF pops. This is what makes the -include stack and macro rescanning fall out of one mechanism. - -Macro expansion uses the **Prosser hideset** algorithm (the standard's -"nested replacement" / blue-paint rule). Every token in an expansion buffer -carries a hideset: the set of macro names it must not be re-expanded by during -rescan. Function-like expansions compute the result hideset as the invocation -hideset unioned with the just-expanded macro name, which is exactly what stops -infinite recursion through self-referential and mutually-referential macros. -Hidesets are interned into a small table (`pp_expand.c`) and kept sorted for -canonical identity, so identical hidesets share one id. +include stack and macro rescanning fall out of one mechanism. Because lean +tokens hold only a `LocRef`/`TextRef`, the PP keeps a **`SrcInfo` registry** +(`pp_priv.h`): each pushed source registers its retained byte buffer, line map, +and `#line` overlay, kept alive until `pp_free` so a token's location and +spelling still materialize after its lexer has been popped. + +Macro expansion uses the **disabled-frame** availability model (cpplib-style), +not a per-token hideset. A macro's replacement-list frame on the source stack +carries a `disabled_owner`: pushing it increments that macro's `disabled_depth` +and popping it decrements it, so the macro is unavailable for re-expansion across +the entire rescan of its replacement (and any nested replacement above it). A +macro expands only when its `disabled_depth` is zero. This is what stops infinite +recursion through self- and mutually-referential macros — the same guarantee the +hideset gave — but as a stack property rather than a set carried on every token, +which keeps the lean token free of any per-token expansion state. `pp_directive.c` owns the rest of phase 4: the `#if` nesting stack and the preprocessor constant-expression evaluator, `#include` search and file open @@ -185,11 +212,16 @@ types/declarators in `parse_type.c`, initializers in `parse_init.c`, and statements in `parse_stmt.c`. Because lowering happens inline, the parser uses the **CG operand stack as its -typed expression stack**. Each CG stack entry carries frontend-owned -`lang_type`/`lang_flags` facts: the C type plus value flags for lvalue, -modifiable-lvalue, bit-field, register, and null-pointer-constant state. The -parser reads those facts with `kit_cg_slot_info()` and stamps fresh results with -`kit_cg_retag_*()`; structural CG stack ops move the facts with the value. +typed expression stack** — there is no parser-side shadow stack. The CG stack is +the single liveness authority: a value or place is live exactly while a stack +slot references it, and popping that slot is the cheap single-pass death signal +the native direct target and temporary reclaim already rely on. Each CG stack +entry carries frontend-owned `lang_type`/`lang_flags` facts (stored inline on the +slot, opaque to CG): the C type plus value flags for lvalue, modifiable-lvalue, +bit-field, register, and null-pointer-constant state. The parser reads those +facts with `kit_cg_slot_info()` and stamps fresh results with `kit_cg_retag_*()` +/ `kit_cg_set_top_flags()`; a producing op clears the new slot's lang facts and +structural CG stack ops (`dup`/`swap`/`rot3`/…) move them with the value. ### Parser codegen helpers @@ -207,10 +239,29 @@ does not keep a separate lvalue auxiliary record. Loads, stores, address-taking, and `store_keep` consume the same CG places, with the C facts kept only in the inline lang fields. -Codegen suppression is CG unevaluated mode. The parser still has semantic forks -for C control-shape rules and constant-expression legality, but type-only and -constant-evaluation paths preserve CG stack shape and C slot facts without -target emission. +Codegen suppression is CG unevaluated mode. Bracketing a parse with +`kit_cg_unevaluated_push`/`_pop` keeps the value-stack shape, the inline C slot +facts, and CG's always-on constant payload maintained while skipping all target +emission, temp/local allocation, and `-O0` analyses. The parser still has +semantic forks for C control-shape rules and constant-expression legality, but +type-only paths (`sizeof` operands, `_Generic` arms, dead expression arms) run on +the normal grammar with nothing emitted. + +Integer-constant-expression evaluation rides this same machinery. The parser +evaluates an ICE by parsing the real expression grammar once under unevaluated +mode and reading the folded value from CG's constant-value model +(`kit_cg_top_const_int_ex` over a width-complete `KitCgConstInt`, which carries +known integer bits plus width/signedness independent of whether the value would +have been an immediate, a local, or a runtime call). There is no second constant +grammar: the old `cexpr_*` / `cint_*` parser subsystems are gone. What stays +parser-owned is a syntactic/semantic legality guard, `CConstGuard` +(`parse_expr.c`), because a *foldable* result is not the same as a *legal* C +integer constant expression — `(0, 1)` folds but the comma operator is illegal in +an ICE, enum constants are ICE operands while ordinary objects are not, and +`sizeof(x++)` is legal because the increment is unevaluated. The guard records +those events as the normal parse routines pass over them, and `eval_const_int` +accepts a value only when both the guard says the syntax is permitted and CG +reports a known constant of the required type. ### Semantic layers — type / decl / sem / abi diff --git a/doc/KERNEL.md b/doc/KERNEL.md @@ -0,0 +1,288 @@ +# Kernel build and image pipeline + +kit can build freestanding kernels from C and assembly and turn the linked +result into the flat load images and initramfs archives a bare-metal or +QEMU-driven boot needs. This is a thin policy layer on top of the existing +toolchain: the same compile, link, and object machinery used for hosted +programs, plus three things a kernel author specifically needs — a richer +linker-script subset, an image emitter (`kit image` / `objcopy -O binary`), and +a cpio packager (`kit cpio`). + +kit deliberately stops at the artifact boundary. It does not generate startup +code, set up page tables, stacks, TLS, or privilege-mode entry, and it does not +implement any boot protocol. Kernel startup and boot-protocol compliance are the +author's responsibility; kit produces a deterministic ELF, a byte-exact flat +image, and a well-formed archive, and nothing about how they are loaded or run. + +Related: [DRIVER.md](DRIVER.md) (the multi-call binary and tool registry), +[LINK.md](LINK.md) (symbol resolution, layout, relocation, linker scripts), +[OBJ.md](OBJ.md) (the object/image model the emitter reads), and +[RUNTIME.md](RUNTIME.md) (the freestanding headers, compiler-rt helpers, and the +TLS contract — note there is no `crt0`). + +## Freestanding targets and the build shape + +The supported freestanding ELF targets are: + +- `x86_64-none-elf` +- `aarch64-none-elf` +- `riscv64-none-elf` +- `riscv32-none-elf` + +A `*-none-elf` triple (EI_OSABI STANDALONE) resolves to a non-PIE ELF target by +default and puts the driver and linker into *freestanding* mode, which turns on +the strict validation described below. + +There is no separate `kit kernel` command. The compile/link front doors are the +ordinary build verbs (see [DRIVER.md](DRIVER.md)): + +- **`build-obj`** compiles a polyglot source set (C / asm / toy / wasm) to one + object; multiple sources combine into a single relocatable object via `ld -r`. +- **`build-exe`** compiles a source set in memory and links it — together with + any `.o` / `.a` / `.so` inputs — into an executable, with no intermediate + files. + +Both accept the freestanding and link flags a kernel needs, and `build-exe` +accepts the common direct linker flags (not only the `-Wl,` escape hatch). A +representative kernel link: + +``` +kit build-exe -target x86_64-none-elf \ + -ffreestanding -nostdlib -nostartfiles -static -no-pie \ + -mcmodel=kernel -mno-red-zone \ + -ffunction-sections -fdata-sections \ + -T kernel.ld -e _start \ + -Wl,--gc-sections \ + --map kernel.map --symbols kernel.sym \ + -o kernel.elf \ + boot.S kernel.c mm.c +``` + +The flags that matter for kernel code, and that kit honors rather than merely +accepts: + +- `-ffreestanding` / `-fhosted` select freestanding vs hosted assumptions + (whether sysroot-hosted profiles may engage). +- `-nostdinc`, `-nostdlib`, `-nodefaultlibs`, `-nostartfiles` precisely control + include paths and runtime/CRT/libc insertion. kit never invents a startup + object for a freestanding kernel. +- `-static`, `-no-pie`, `-fno-pic`, `-fno-pie` produce static non-PIE, + ET_EXEC-style code unless PIC/PIE is requested explicitly. +- `-mcmodel=...` selects the code model. +- `-mno-red-zone` disables the x86-64 SysV red zone — it changes backend frame + selection, it is not just parsed. +- `-mgeneral-regs-only` prevents accidental SIMD/FP codegen where the + privilege-mode context does not save those registers. +- `-fno-builtin` and the stack-protector flags constrain what the frontend may + synthesize. +- `-ffunction-sections` / `-fdata-sections` compose with `--gc-sections` and + script `KEEP(...)`. + +`-Ttext` / `-Tdata` / `-Tbss` and `--section-start=.name=addr` place a +freestanding image at a fixed load address from the command line, and work +through both `build-exe` and `ld`. + +### Strict freestanding validation + +A freestanding executable link (triggered by a `*-none-elf` input, or forced by +the same `freestanding_strict` trigger from either `build-exe` or `ld`) rejects, +by default, anything that implies a dynamic loader: DSO inputs, dynamic +interpreter paths, dynamic sections and PLT/GOT imports, unresolved symbols, and +cross-input target / object-format mismatches. `--no-undefined` makes unresolved +references an error explicitly; `--allow-undefined` is the escape hatch. + +## Linker-script subset + +Kernel layouts need a structured GNU-ld-compatible script subset larger than the +default `SECTIONS`-only form. The script is parsed by a hand-written +recursive-descent parser (`src/link/link_script.c`) into a structured +`KitLinkScript`; the linker accepts only that structured form, and `ld` parses +`-T` text into it. Unsupported directives are rejected with a diagnostic rather +than silently ignored. See [LINK.md](LINK.md) for how scripted layout replaces +the default permission-bucket placement. + +The supported constructs: + +- `MEMORY` with `ORIGIN` / `LENGTH` and attributes, with region-overflow + diagnostics, and output-section placement into regions. +- Load-memory placement via `AT(expr)` / `AT> REGION`, so VMA/LMA-split kernels + are representable. +- `PHDRS`, the `:phdr` section attribute, and segment flags, so a kernel + controls its program headers and segment permissions. Multiple sections naming + one `:phdr` coalesce into a single PT_LOAD (perms unioned); a section listing + several phdrs appears under each. +- `PROVIDE`, `PROVIDE_HIDDEN`, and `HIDDEN` symbol definitions; top-level and + in-section assignments; `. = expr` dot moves, which apply at their textual + position interleaved with the section walk. +- `ASSERT(expr, "message")`. +- `EXTERN(symbol)` as a GC root / undefined-symbol declaration. +- Input-section patterns including `EXCLUDE_FILE` and the alignment helpers + `ALIGN` / `BLOCK`. +- Section fills (`=0x...` and `FILL(...)`), which lay a repeating big-endian + pattern across holes. +- `NOLOAD` output sections: PROGBITS content is forced to occupy no file bytes, + and relocations into a NOLOAD section are skipped rather than written through a + null buffer. +- `OUTPUT_ARCH` / `OUTPUT_FORMAT` as validation directives (mismatches are + reported; they do not drive target selection). + +`KEEP(...)` roots continue to interact correctly with `--gc-sections`. + +## Link-side side outputs + +The linker produces deterministic side files, available from both `ld` and +`build-exe`, so a kernel build can audit its own layout: + +- `--map FILE` writes a link map: target triple and output kind, entry symbol + and address, memory regions and usage, program headers, output sections with + VMA / LMA / file offset / size / alignment, input contributions, linker-defined + symbols, discarded sections, and unresolved symbols. Input paths are + normalized to basenames so no absolute host path leaks in. +- `--symbols FILE` writes post-link absolute symbols; `--symbols-format=nm|json` + selects the format. +- `--cref FILE` writes a cross-reference table (including imported/undefined + symbols). +- `--print-memory-usage` summarizes per-`MEMORY`-region usage (overflow-safe for + high-half regions). + +These are linker-owned, not image-owned: the image metadata sidecar (below) is a +report about the image transform, not a substitute for `--map`. Supporting +policy flags include `--defsym name=expr` (can satisfy an otherwise-undefined +reference), `--orphan-handling=place|warn|error|discard`, and `--fatal-warnings`. + +## Image emission + +Two front doors turn a linked ELF into a flat load image, both backed by one +lower-level emitter (`include/kit/image.h`, implemented in `src/obj/image.c`): + +- **`objcopy -O binary`** stays a minimal object-transformer path: + + ``` + kit objcopy -O binary kernel.elf kernel.bin + ``` + +- **`kit image`** owns image-building policy: + + ``` + kit image --format bin kernel.elf -o kernel.bin + ``` + +The emitter consumes already-opened object/image state plus the original input +bytes (see [OBJ.md](OBJ.md) for the linked-image view it reads). It never reads +the filesystem itself, chooses a boot protocol, or invokes an emulator. + +### Formats + +- `bin` — flat binary derived from loadable segments (the default) or selected + sections. +- `rom` — fixed-size flat binary: requires `--pad-to`, fills unused bytes + deterministically, and fails when the payload exceeds the requested size. +- `sections` — concatenate `--section NAME` (repeatable) in declared order; + missing sections are rejected and the address metadata is made explicit so a + concatenation is not mistaken for a loadable memory image. +- `elf` — normalize / strip / split-debug. This is **deliberately delegated** to + `objcopy` + `strip`; the emitter returns a diagnostic pointing at those tools + rather than duplicating the object-rewrite machinery. The `--strip-debug` / + `--split-debug` / `--keep-symbols` knobs are only meaningful with `--format + elf`. + +Segment-based emission reads loadable ranges from the program headers, sorts by +the selected address kind, detects overlaps, fills or rejects holes per policy, +and preserves bytes exactly as they would be loaded. The main flag groups: + +- Selection: `--from segments|sections`, `--segment PT_LOAD` (repeatable), + `--only-section` / `--remove-section` / `--section` (all repeatable). +- Addressing: `--addr vaddr|paddr|lma`, `--base ADDR`, `--bias N`. +- Layout: `--fill BYTE`, `--fail-on-holes`, `--max-hole SIZE`, `--align`, + `--pad-to`, `--max-size` (sizes accept `K`/`M`/`G` suffixes). +- Validation: `--require-entry`, `--require-symbol NAME`, `--require-section + NAME`, `--no-dynamic` — checked against the opened object before any bytes are + written. +- Reporting: `--metadata FILE` writes a deterministic JSON sidecar (stable key + order, no host paths, no timestamps) describing target, object format, entry, + build id, the selection that actually contributed bytes, base/bias/fill policy, + payload/output sizes, and warnings. + +### Flat-kernel `Image` header (arm64 / riscv64) + +QEMU's `-kernel` path on arm64 and riscv consumes the flat Linux `Image` format: +a raw loadable binary prefixed with a fixed 64-byte header the loader reads to +place and size the image. The first 32 bytes are common to both arches: `code0` +/ `code1` (the entry branch; `"MZ"` low half when EFI), a u64 `text_offset` +(load offset from a 2 MiB-aligned base), a u64 `image_size` (the in-memory +footprint **including BSS**), and a u64 `flags` (bit 0 endianness; on arm64 bits +1-2 page size, bit 3 placement). The tails differ: arm64 carries `magic = +"ARM\x64"` at offset 56; riscv64 carries a `version` u32 (currently `0x2`) at 32 +and `magic2 = "RSC\x05"` at 56. + +kit supports this two ways: + +- **Author-owned, pass-through.** A kernel's own `head.S` can emit the 64-byte + header (placing `code0`/`code1` and declaring `image_size` from linker + symbols). `kit image --format bin` / `objcopy -O binary` copy the PT_LOAD + bytes verbatim, so the leading header survives byte-exact and the result is a + loadable `Image`. kit needs nothing extra for this path. + +- **kit-synthesized.** The awkward field for authors is `image_size`, the + in-memory span including BSS, which kit already knows from each loadable + segment's `vsize`. So `--image-header[=arm64|riscv|auto]` (on `--format bin` or + `rom`; `auto` infers the arch from the object's machine) synthesizes the + header. It *overlays* the first segment rather than prepending: the author's + first 8 bytes (the `code0`/`code1` entry branch) are preserved and kit fills + the 56-byte metadata tail, so the output stays the same size as a plain `bin`. + The author's first segment must reserve those 56 bytes. `image_size` is the + in-memory span including BSS, computed from `vsize` over all selected loadable + segments (BSS-only `file_size == 0` segments count toward the span but + contribute no bytes). + +Because `flags` encodes boot semantics, header synthesis is an explicit opt-in, +never a default, and the boot-semantic fields are surfaced as explicit options +(`--image-text-offset`, `--image-endian`, `--image-page-size` [arm64 only, +4k/16k/64k]) rather than invented — they error if given without +`--image-header`. kit fills the magic/version and `image_size` deterministically +and chooses no boot policy. + +## `kit cpio` — initramfs archives + +The Linux kernel unpacks its initramfs from a `cpio -H newc` archive (magic +`070701`, or `070702` for the CRC variant), optionally compressed; the +early-microcode convention is just an uncompressed cpio concatenated ahead of +the compressed main archive. This is an archive format, not a boot protocol — a +sibling of `ar` — so `kit cpio` lives in the byte-utility tool family rather than +in the image emitter. The newc codec is driver-local (`driver/cmd/cpio.c`, +mirroring `tar.c`'s stateless shape) since only this one tool consumes it; the +driver has no `-Isrc`. kit packages and inspects the archive; it does not build, +mount, or boot it. + +``` +kit cpio -o -F initramfs.cpio -z init etc/ # create, gzip-compressed +kit cpio -t -F initramfs.cpio.gz # list (auto-detects gzip) +kit cpio -i -F initramfs.cpio # extract +``` + +What it supports: + +- **SVR4 `newc` only** (`-H newc` default, `-H crc` for the `070702` checksum + variant). The legacy `bin`/`odc` formats are out of scope. +- Create (`-o`), list (`-t`), and extract (`-i`) over regular files, + directories, and symlinks. Special/device nodes are out of scope. +- **Deterministic output**: members sorted by archived path (a stable sort that + is a valid DFS, each directory before its children), normalized metadata + (uid/gid 0, mtime 0, sequential inode, mode = type | perms keyed on the source + executable bit), a closing `TRAILER!!!` record, and a 512-byte tail pad. + Identical inputs yield byte-identical output. +- **Concatenation**: the reader continues past a `TRAILER!!!`, skips + inter-segment zero padding, and resumes on the next magic, so early-init + segments (built by `cat` of 512-padded archives) list and extract cleanly; + trailing non-cpio data is noted, not fatal. +- **Compression** as a create-time flag (`--compress=gzip|lz4`, with short `-z` + for gzip and `--lz4`), via the public `kit/compress.h` codecs. An initramfs is + just a compressed newc archive, so there is no separate `initramfs` tool. gzip + and lz4 only; a zstd/xz magic or `--compress=zstd|xz` gets a specific + diagnostic rather than pretend support. On read, `-d` and always-on + auto-detection round-trip a compressed archive with no separate step. +- **Safety**: absolute names and `..` components are refused on both create and + extract, so a crafted archive cannot escape the destination directory. + +The tool is gated in `driver/main.c` behind `KIT_TOOL_CPIO_ENABLED`, alongside +the other archive utilities. diff --git a/doc/LINK.md b/doc/LINK.md @@ -237,10 +237,15 @@ The script itself is parsed by `kit_link_script_parse` (link_script.c), a hand-written recursive-descent parser for a deliberately small GNU-ld subset: `ENTRY(sym)`, top-level and in-section symbol assignments with a small arithmetic-expression grammar, `. = expr` dot moves and alignment, -`SECTIONS { output : { input-matchers } }`, and `/DISCARD/`. Unsupported -directives (`MEMORY`, `PHDRS`, `PROVIDE`, `OVERLAY`, `OUTPUT_FORMAT`, -`GROUP`, ...) are rejected with a diagnostic rather than silently -ignored. The linker accepts only the structured `KitLinkScript` form — +`SECTIONS { output : { input-matchers } }`, and `/DISCARD/`. The subset has +since grown to cover the directives a freestanding kernel link needs: +`MEMORY` regions (with `ORIGIN`/`LENGTH` and `> REGION` / `AT> REGION` +placement), `PHDRS` segment assignment, `PROVIDE` / `PROVIDE_HIDDEN` / +`HIDDEN`, `ASSERT`, `EXTERN`, `FILL`, `NOLOAD`, `EXCLUDE_FILE`, +`OUTPUT_ARCH`, and `OUTPUT_FORMAT` — see [KERNEL.md](KERNEL.md) for the +kernel-facing usage. Genuinely unrecognized directives are rejected with a +diagnostic rather than silently ignored. The linker accepts only the +structured `KitLinkScript` form — there is no text setter on the `Linker`; hosts that have GNU-ld text run the parser first. Input matchers use a `*`-only glob. diff --git a/doc/OPT.md b/doc/OPT.md @@ -259,7 +259,7 @@ improve the code are exactly these, none of which needs SSA: loop-invariant immediates once in the entry block instead of per iteration. - **`dead_def_elim_with_live`** — liveness-driven pre-RA dead-definition removal. - **`regalloc_locations`** — point-bitmap linear-scan allocation, **without** - live-range splitting. A *linear* move-coalescer (O1.md W3) now populates the + live-range splitting. A *linear* move-coalescer now populates the union-find (`opt_coalesce_parent`) before allocation so copy-related values share a location; the O2-only quality knobs that stay off are live-range splitting and the O(n²) conflict-**matrix** coalescer (`opt_coalesce_ranges`). @@ -277,9 +277,9 @@ improve the code are exactly these, none of which needs SSA: Everything else under §4 (`build_ssa`, `gvn`, `dse`, `licm`, `copy_prop`, `simplify`, live-range splitting, the O(n²) matrix coalescer) is O2-only and never runs. (Linear move coalescing *does* run at O1 — see `regalloc_locations` -above and O1.md W3; only the splitting/matrix variants are O2-only.) The O1.md -worklist (frame layout, remat, switch/cmp immediates, branch cleanup, inline cap) -is landed; its passes are noted inline above and recorded in O1.md §5. +above; only the splitting/matrix variants are O2-only.) The -O1 code-quality +work (frame layout, rematerialization, switch/cmp immediates, branch cleanup, +inline cap) is landed; its passes are noted inline above. The reachability decision lives *outside* this pipeline, in the finalize sweep (Section 1), identical on every architecture. At module finalization @@ -461,7 +461,7 @@ transform or analysis; the file paths orient the reader. `opt_regalloc_locations` is a point-bitmap linear-scan allocator producing the canonical `Func.preg_locs` location table (hard reg or spill slot per PReg) without mutating HIR operands. Two coalescers feed its union-find: the **O1 - linear coalescer** `opt_coalesce_linear` (O1.md W3) merges copy-related values + linear coalescer** `opt_coalesce_linear` merges copy-related values using bounded per-root member lists + on-demand range-overlap tests (no matrix), and `opt_verify_alloc` treats same-root PRegs as one value; the **O2 matrix coalescer** `opt_coalesce_ranges`, gated on live-range splitting, builds @@ -668,17 +668,80 @@ object links and runs correctly: the ecosystem gate compiles and runs sqlite at `-O0` and `-O1` against clang. The three copy/extension folds above (`addr_of [base+0]`, same-width convert, -ZEXT-of-load) were added after a disassembly audit of the ecosystem `-O1` -output found the same redundancies recurring: a register move per `&*p`, a move -per pointer cast, and a `uxtb`/`uxth` after every narrow unsigned load (C's -integer promotions). Each is a local, target-agnostic canonicalization, so they -keep `-O1` linear (sqlite `-O1` compile time is unchanged); correctness is held -by the ecosystem golden + vs-clang run at `-O0`/`-O1` and the toy/opt suites. -Measured `__TEXT` reduction across the ecosystem corpus (aarch64/Darwin, `-O1`): sqlite −1.9%, lua VM −3.2%, -`lua/lapi.c` −9.1%, yyjson −4.3%, miniz/lz4 ≈ −3.4%, ~−2.4% summed. The folds -are not the whole story — `-O1` text is still several times clang's on -inline-heavy files, because the wins clang gets from GVN / DSE / redundant-load -elimination and post-inline cleanup are SSA-only and stay parked in the O2 -mid-end (Section 3). Within the no-SSA budget the remaining linear headroom is -cross-block copy elimination (the per-block `mir_combine` cannot retire a copy -whose result is live-out) and local rematerialized-constant CSE. +ZEXT-of-load) are examples of the local, target-agnostic canonicalizations that +make up the `-O1` density work. Each was added after a disassembly audit of the +ecosystem `-O1` output found the same redundancy recurring: a register move per +`&*p`, a move per pointer cast, and a `uxtb`/`uxth` after every narrow unsigned +load (C's integer promotions). The folds are not the whole story — `-O1` text is +still several times clang's on inline-heavy files, because the wins clang gets +from GVN / DSE / redundant-load elimination and post-inline cleanup are SSA-only +and stay parked in the O2 mid-end (Section 3). + +### The shape of `-O1` quality work + +Two design constraints bound everything done to improve `-O1` generated code, +and together they define the method: + +- **No SSA — the compile must stay linear.** `-O1` is the no-SSA pipeline of + Section 3: local + linear-scan machinery, no dominance-frontier phi insertion, + no value numbering, no interference graph. A `-O1` density transform must keep + that property — a per-function `O(n·log n)` slot sort or a bounded per-block + side table is fine, but anything that needs SSA, a full interference graph, or + an O(n²) analysis belongs in the parked O2 mid-end, not here. The synthetic + scaling sweep (Section 8) and sqlite `-O1` timing are the guard that a quality + change did not add a superlinear axis; the correctness gate is the ecosystem + run-and-diff vs clang at `-O0`/`-O1` plus the toy/opt suites (these changes + deliberately alter emitted bytes, so byte-identity is *not* the gate). + +- **The transforms are linear same-block peepholes.** Within that budget the + realized wins are a family of forward, single-block peepholes over the post-RA + MIR (most live in `pass_combine.c`, branch-shaped ones in `pass_jump.c`): + store-to-load forwarding across a register mismatch, boolean-into-branch fusion + (`cset;cbnz` → `b.cc`), redundant-extension drops, single-use copy/shift + folding into the consuming op, same-block redundant-load/CSE, and same-block + stack dead-store elimination. Each reuses the combiner's existing per-block + last-def map and hard-register liveness, so it stays O(1) per instruction. The + structural ceiling these *cannot* reach — the cross-block over-spilling that + dominates the residual gap on large, high-pressure functions — needs the SSA + register allocator and is out of scope for `-O1` (Section 3 / the optimizer + roadmap). + +### Frame shape drives density + +The largest `-O1` density lever is not a peephole but the **frame layout**, and +it exploits a standing `-O1` asset the `-O0` single-pass path lacks: at `-O1` +the frame is *fully known before the body is emitted* (`*_func_begin_known_frame` +fixes the final frame size and slot list up front). Two layout choices follow: + +- **Hot-slot-low ordering (all arches).** Body frame slots are ordered so the + most-frequently-accessed spills get the smallest final displacement from the + layout's addressing base — small offsets are cheapest to encode everywhere + (`disp8` vs `disp32` on x64, inside the scaled reach on aa64, inside the ±2 KB + `imm12` window on rv64). The per-slot priority is the allocator's spill-cost + metric, aggregated over every PReg that shares a reused slot; ordering is a + layout choice with no added analysis (one slot sort per function). + +- **Positive-offset spill addressing (aa64).** aa64's only wide + single-instruction memory form is the unsigned-scaled `ldr/str [base,#pos]` + (reach 0..32760); its signed unscaled `ldur` reaches only ±256. So + one-instruction slot access requires the addressing base to sit *below* the + slots. The aa64 known-frame layout therefore anchors the frame pointer x29 at + the *bottom* of the static slots (just above the outgoing-arg area), uniformly, + so every slot is a one-instruction positive `ldr/str [x29,#k]` (the + `add x16,x29,#hi; ldr [x16,#lo]` build only past the 32 KB scaled reach). This + replaces the old top-anchored layout's `sub x17,x29,#k; ldur` fallback — two-to- + four instructions per access, recomputed for each access — which on large + high-pressure functions was the single biggest `-O1` density cost. x29 is the + (always reserved, alloca-stable) frame pointer, so the change is contained in + the aa64 backend with no regalloc change, and alloca falls out for free: the + saved fp/lr pair is co-located *at* x29 (`[x29]`/`[x29+8]`) to preserve the + frame-pointer chain kit's unwinder walks, and outgoing args stay sp-relative so + calls after an alloca still address their arg area from the current sp. x64 + needs none of this — `mov [rbp-disp32]` already reaches any slot in one + instruction; rv64's ±2 KB window covers any realistic hot working set, so it + takes the shared ordering only. + +The wins compound: hot-slot ordering and positive addressing shrink the +*cost* of each spill, the linear move coalescer (Section 4) lowers the spill +*count*, and rematerialization (recompute an input-less spilled value at its use +instead of reloading) cuts spill *traffic*. None of the three needs SSA. diff --git a/doc/PORT.md b/doc/PORT.md @@ -0,0 +1,279 @@ +# Portability test surface: `cross` + `selfhost` over one support set + +This doc is the spec for kit's portability test surface — the harmonized +top-level make targets and the scripts beneath them that answer two questions: + +1. **cross** — can kit, running on the dev host, cross-compile a *correct* + executable for every target in its support set? +2. **selfhost** — can kit be built to *run on* each target, and then compile + + run a program there? + +It supersedes the old scattered targets (`test-hosted*`, `test-libc*`, +`test-freebsd*`, `test-coff-windows-*`, `test-toy-*-vm`, `test-link-x64`, +`test-parse-rv64-wide`, `bootstrap-linux*`, `bootstrap-freebsd`, the standalone +`windows_cross.sh`, and the freestanding smokes). See "Migration" at the end. + +Related: [BUILD.md](BUILD.md) (the 3-stage self-build mechanism), +[plan/SYSROOTS.md](plan/SYSROOTS.md) (sysroot provisioning), +[WINDOWS.md](WINDOWS.md). + +## Model: two modes over one matrix + +``` + support set (one canonical list in scripts/hosted.sh) + ┌──────────────────────────────────────────────────────┐ + mode cross │ host kit builds for T → artifact runs correctly on T │ + mode selfhost│ build a kit that runs on T → it builds + runs a program │ + │ on T │ + └──────────────────────────────────────────────────────┘ + substrate (native / qemu-user / podman / VM / qemu-system) = IMPLICIT, + resolved per target by the exec seam; never named in a target. +``` + +The support set, one token grammar everywhere: **`<os>[-<libc>]-<arch>`**. + +``` +cross matrix (16): + linux-{glibc,musl}-{aa64,x64,rv64} (6) + freebsd-{aarch64,amd64,riscv64} (3) + windows-{aarch64,x64} (2) + macos-aarch64 (1) + freestanding-{aa64,x64,rv64,rv32} (4) ← rv32 lives here only + +selfhost matrix (12): the same, minus freestanding +``` + +Freestanding has **no OS**, so there is nothing to run a compiler *on*: it +participates in `cross` only. `selfhost` of a `freestanding-*` target is a hard +error. + +Arch tokens are the short forms **`aa64` / `x64` / `rv64`** (plus `rv32`, +freestanding-only). The long forms (`aarch64`/`amd64`/`x86_64`/`riscv64`) are +accepted as input aliases by `scripts/hosted.sh`. + +## Top-level make targets + +``` +make test-port # test-cross + test-selfhost +make test-cross [TARGET=…] [DEPTH=…] [KIT_VM=…] [RUN=0|1] +make test-selfhost [TARGET=…] [DEPTH=…] [KIT_VM=…] +make kit-cross [TARGET=…] [CROSS_CC=kit|clang] [VERIFY=0|1] +make provision [TARGET=…] [KIT_VM=…] +``` + +Defaults: `TARGET ?= all`, `DEPTH ?= smoke`, `KIT_VM ?= 1`, `RUN ?= 1`, +`CROSS_CC ?= kit`, `VERIFY ?= 0`. + +`test-port` is **not** part of the default `make test` (it is heavy and needs +provisioning). The native 3-stage `bootstrap` + `test-bootstrap-toy` stay in the +default suite as the fast self-reproduction check. + +### `TARGET=` selector grammar + +Resolved centrally by `scripts/hosted.sh expand <selector> [--mode=cross|selfhost]`: + +| Selector | Expands to | +|---|---| +| `all` | every support-set token (mode- and `KIT_VM`-filtered) | +| `linux` / `freebsd` / `windows` / `macos` / `freestanding` | all configs for that OS | +| `linux-musl` / `linux-glibc` | that libc, all 3 arches | +| `linux-glibc-x64`, `freebsd-aarch64`, `freestanding-rv32`, … | one exact config | +| `a,b,c` | comma list of any of the above | + +`--mode=selfhost` drops `freestanding-*` from the expansion. `KIT_VM=0` drops +`freebsd-*` and `windows-*`. + +### `DEPTH` + +`test-cross` accepts: + +- `coarse` — cross-compile and link the smoke artifacts across the expanded + support set, but do not execute them. This is the named form of + `make test-cross RUN=0`. +- `smoke` (default) — two cases, building up platform coverage: + 1. `exit` — an `#include`-free exit-code program (`test/cross/cases/exit.c`, + returns 42): toolchain + link + crt + the exit-code path; on freestanding + the bare-metal exit oracle. Runs on **every** config (the only case + freestanding can run — no libc there). + 2. `hello` — the libc hello-world (`hello.c`, prints + returns 0): the + **sysroot headers + libc + stdout**. Hosted configs only. + + Each case runs once per **link mode** (`link_modes`): **musl exercises both + `static` (libc.a) and `dynamic` (libc.so)** — the two ways the sysroot is + consumed; lanes are suffixed `:static`/`:dynamic`. FreeBSD is static; glibc is + dynamic-only (static glibc is discouraged / NSS-fragile); macOS/Windows use + their single default shape. So a musl target yields four smoke lanes + (exit/hello × static/dynamic), a glibc target two, freestanding one. +- `full` — the two smoke cases + the toy and parse corpora (cross-compiled and + run on the target) + the libc cases (linux only). Orchestrates the existing + mature `test/{toy,parse,libc}` runners with the right arch/tag; it does not + reimplement them. + +`smoke` is the default to honor "prefer targeted runs over mass runs" +(CLAUDE.md). `DEPTH=full` is the deliberate heavy run, usually with a scoped +`TARGET`. + +`test-selfhost` accepts `smoke` and `full`; it has no build-only `coarse` lane +because its purpose is to run the newly built compiler on the target. + +### `kit-cross` — cross-build the compiler itself + +`test-cross` asks "can kit cross-compile a *program* for a target?"; +`kit-cross` asks "can kit (or clang) cross-compile *kit itself* to run on a +target?". It is the general form of the old `windows_cross.sh`: pick any hosted +token and a backend, and it produces a runnable `kit` binary. + +``` +make kit-cross TARGET=windows-x64 # kit.exe for windows-x64, via kit +make kit-cross TARGET=linux CROSS_CC=clang VERIFY=1 +``` + +- `CROSS_CC=kit` (default) dogfoods kit as the cross-compiler; `CROSS_CC=clang` + uses an independent clang + lld (and llvm-mingw's compiler-rt for Windows) + toolchain — a useful differential check, since clang's strictness surfaces + source issues kit's frontend tolerates. +- `VERIFY=1` runs the freshly built `kit` *on the target* through the same exec + seam the tests use (`kit_cross_verify.sh`: bare `kit` prints its banner and + exits 0 — exercises load + dynamic-linker + libc init + main). +- `freestanding-*` is excluded (kit needs an OS to run on): the selector uses + `expand --mode=selfhost`. +- Output: `build/kit-cross/<backend>/<target>/kit[.exe]`. The per-target engine + is `scripts/kit_cross.sh`; the makefile loop is in `mk/port.mk`. + +The two backends differ only in toolchain wiring, not source: the Windows +sources are plain-clang-clean (no `__try`, no `_environ` extern, `__thread` +rather than `__declspec(thread)`), and `kit cc` auto-appends `.exe` to an +extension-less `-o` for a Windows target exactly as gcc/clang-mingw do, so both +backends write `kit.exe` directly. The GC flag is the one linker-specific knob: +GNU/lld and kit ld take `--gc-sections`, Apple ld (clang on darwin) takes +`-dead_strip`. + +## Architecture + +### Source of truth — `scripts/hosted.sh` + +Already owns the support set and the `triple`/`path`/`tag` resolvers. Extended +with: + +- `hosted.sh list [selector]` — emit tokens (one per line). +- `hosted.sh expand <selector> [--mode=…]` — the selector grammar above. + +The Makefile reads it via `$(shell …)`; nothing else re-encodes the matrix. + +### Two orchestrators + +- `scripts/cross_test.sh <selector> [DEPTH]` — expand (mode=cross) → **fail-fast + provisioning pre-flight** → compile/link all smoke artifacts → optional + execution phase (`DEPTH=coarse`, `RUN=0`, or `KIT_CROSS_RUN=0` disables it) + → aggregate report. +- `scripts/selfhost.sh <selector> [DEPTH]` — expand (mode=selfhost) → pre-flight + → per-token self-host (dispatch by OS) → on-target corpus → report. + +Both share `test/lib/kit_sh_report.sh` for the verdict/summary layer. + +### Provision-or-error + +Provisioning (network + VM prepare) is **separate** from running, and a requested +target whose sysroot / image / VM is missing is a **hard error**, never a silent +skip. The orchestrators do an aggregated pre-flight: they collect *all* missing +provisioning for the expanded TARGET set, print each with the exact +`make provision TARGET=…` (or host-tool install) to fix, and exit non-zero +before running anything. + +`make provision [TARGET=…]` wraps `hosted.sh prepare`, `make test-images`, the +glibc run images, the Windows UCRT sysroots, and VM prepare — scoped by TARGET +and KIT_VM. rt archives are local build artifacts (not network): the runners +build them on demand via idempotent `make rt-…`. + +### One exec front door, three backends + +`test/lib/exec_target.sh` is the single tag-dispatched front door. A tag is +`<arch>-<os>[-<libc>]`. It routes to one of three backends: + +``` +exec_target.sh +├── stateless os ∈ {linux, macos} → native / qemu-user / podman +├── exec_vm.sh os ∈ {freebsd, windows} → boot VM, run +└── exec_bare.sh os == freestanding → qemu-system bare-metal (NEW) +``` + +`exec_bare.sh` is the consolidated owner of all per-arch bare-metal scaffolding +(reset stub + linker script + exit-code oracle + `qemu-system` invocation), +generalizing the old `exec_rv32_bare.sh` to four arches and absorbing the stubs +that were inlined in `freestanding_system.sh`. It exposes two contracts: + +- `exec_bare_run_image <arch> <kernel.elf> <out> <err> <rc>` — run a ready + bootable image (what `test/link`'s `kernel_image` cases need; `exec_kernel.sh` + is now a thin shim over this). +- `exec_bare_setup <arch> <work>` + `exec_bare_run <arch> <obj> <work> <rc>` — + link a corpus `.o` (entry `main`, returns the exit code) with the per-arch stub + + rt into a bootable image, then run it (what toy/parse need). + +Exit-code oracle per arch (so callers compare `rc == expected` uniformly): + +| arch | mechanism | decode | +|---|---|---| +| aa64 | ARM semihosting `hlt #0xf000` + `ADP_Stopped_ApplicationExit` | qemu rc = guest code | +| rv64/rv32 | SiFive test finisher MMIO at `0x100000` | stub writes `0x3333\|(code<<16)` (or `0x5555` for 0); qemu rc = code | +| x64 | `isa-debug-exit` (iobase `0x501`) | qemu rc = `(code<<1)\|1` → code = `(rc-1)>>1` | + +## Self-host shapes (the substrate asymmetry) + +`selfhost.sh` dispatches by OS, because "build a kit that runs on T" differs: + +- **macos** → native 3-stage `bootstrap` here, then the corpus through stage3. +- **linux** → native 3-stage in a podman container (emulated for non-host arch), + then the corpus. Generalizes `scripts/linux_bootstrap.sh` to (arch, libc). + **rv64 uses a hybrid seed**: an in-container clang stage1 would itself run + emulated (slow), so the stage1 seed is cross-built on the host with clang + (`kit_cross.sh linux-musl-rv64 --cc=clang`, native speed) and fed to + `mk/bootstrap.mk` via `BOOTSTRAP_SEED`; only stages 2/3 run emulated. They are + still byte-identical because the seed and stage2 are the same kit source, hence + functionally identical compilers. Emulated builds write objects to + container-local storage (the `:Z` virtiofs mount flakes new-file creates under + sustained emulated write load) and copy the stage kits back. +- **freebsd** → native 3-stage in the VM, then the corpus + (`scripts/freebsd_bootstrap.sh`). +- **windows** → **cross**-build `kit.exe` on the host (the VM has no seed + compiler), then run it on the VM to cc + run a program + (`scripts/windows_cross.sh`). + +Every selfhost leaf ends with the same contract: *a kit running on the target +compiled and ran a program there.* + +## Status & phased backlog + +`cross`: + +| Config | coarse | smoke | full | +|---|---|---|---| +| linux-{glibc,musl}-{aa64,x64,rv64} | ready | ready | ready (toy X + parse E + libc) | +| macos-aarch64 | ready | ready (native) | ready (native toy/parse) | +| freebsd-{aarch64,amd64,riscv64} | ready | ready (VM) | ready (toy via VM) | +| windows-{aarch64,x64} | ready | ready (VM) | ready (toy via VM) | +| freestanding-rv32 | ready | ready (bare) | ready (toy X + parse E, bare) | +| freestanding-{aa64,x64,rv64} | ready | ready (bare smoke) | **deferred** | + +`selfhost`: macos (native), linux musl/glibc aarch64 (container), linux x64/rv64 +(container under emulation), freebsd aarch64/amd64 (VM), windows aa64/x64 (cross ++ VM). + +### Backlog: freestanding `DEPTH=full` for aa64 / x64 / rv64 + +Running the toy/parse corpora bare-metal on these three arches is new capability +beyond the smoke payload. The smoke stubs in `exec_bare.sh` are minimal; the +corpus exercises TLS, soft-float, large frames, i128, etc., which need hardened +per-arch stubs (the rv32 path already has this — its reset stub seeds a static +TLS image and enables the FPU). Work items: + +1. Harden the aa64/rv64 `exec_bare` stubs (TLS image seed + thread-pointer setup, + matching the rv32 stub and the toy X-lane `start.c`). +2. Build the x64 long-mode corpus stub (the rv32/aa64 reset path + a full + exit-code oracle via `isa-debug-exit`). +3. Wire a bare lane into `test/{toy,parse}/run.sh` for aa64/x64/rv64 (the rv32 + "X / V" path generalized), gated on `qemu-system-<arch>`. +4. Flip the table rows above from **deferred** to **ready** and drop this section. + +Until then, `test-cross TARGET=freestanding-{aa64,x64,rv64} DEPTH=full` runs the +smoke payload and `log`s that corpus-depth is not yet wired for the arch (it does +not silently claim full coverage). diff --git a/doc/WINDOWS.md b/doc/WINDOWS.md @@ -5,9 +5,47 @@ kit's Windows targets are PE/COFF, 64-bit only: - `x86_64-windows` - `aarch64-windows` -The hosted profile is MinGW-w64 UCRT via llvm-mingw. kit uses the target -headers, CRT objects, and import libraries from that sysroot; it does not use -llvm-mingw's compiler, assembler, or linker tools for the cross-compile path. +The hosted profile is MinGW-w64 UCRT via llvm-mingw (not MSVC): kit advertises +`__MINGW32__`/`__MINGW64__`, never `_MSC_VER`. kit uses the target headers, CRT +objects, and import libraries from that sysroot; it does not use llvm-mingw's +compiler, assembler, or linker tools for the cross-compile path. + +## Native self-host + +kit cross-builds into a native Windows `kit.exe` that runs as a Windows +toolchain. `scripts/windows_cross.sh aarch64` uses the host `build/kit` as the +cross-compiler to produce a PE32+ console `kit.exe`, overriding `HOST_OS=windows +HOST_ARCH=aarch64` so `mk/env.mk` selects `driver/env/windows.c`. (There is no +seed C compiler in the VM, so the Windows binary is always cross-produced on the +dev host — unlike the native Linux/FreeBSD bootstraps.) On the ARM64 Win11 VM +that `kit.exe` runs `kit cc` (compile + link real C programs, building +`libkit_rt` on demand) and `kit run` (the in-process JIT, including external +calls and libc I/O via `dlsym`) natively; the Toy AOT corpus passes when +compiled, linked, and executed by the native `kit.exe`. + +Windows-specific points the codegen and link paths have to honor: + +- **Large-frame stack probe.** Windows commits stack pages lazily via a guard + page, so a function whose frame exceeds one page must touch each page on entry + or it faults past the guard. The backends emit a probe (an inline page-walk on + aarch64, `__chkstk` on x64) at the ABI-defined interval + (`abi_stack_probe_interval`); without it, large-frame functions — including + many inside kit itself — crash on the VM. +- **`setjmp`.** mingw's `setjmp.h` expands `setjmp` to `_setjmp` declared as a + COFF `WEAK_EXTERNAL` aliasing `__intrinsic_setjmp` from the CRT-private + `api-ms-win-crt-private-l1-1-0.dll` api-set, which fails to load on some + runtimes. `libkit_rt` exports strong `_setjmp`/`_setjmpex` (over `__mingw_setjmp`) + so the alias is never pulled; on the Windows link line `libkit_rt.a` is placed + before the hosted libc group so its strong `_setjmp` is defined before + libucrt.a's lazy scan runs (and a second time before `crtend.o`, to catch the + late `__chkstk` undefs that libucrt members introduce — lazy archive scanning + keeps either placement from defining a symbol twice). +- **COFF imports.** The COFF reader/linker handles short-import library members + and `EXPORTAS` so DLL imports whose export name differs from the import name + resolve correctly. +- **JIT executable memory.** `kit run`'s JIT maps code pages with + `FILE_MAP_WRITE` then flips them executable, the Windows analog of the POSIX + `mmap`/`mprotect` W^X dance. ## UCRT Sysroots diff --git a/doc/ideas/README.md b/doc/ideas/README.md @@ -0,0 +1,11 @@ +# Ideas + +Speculative, not-yet-committed designs: sketched in full, but with no code in the +tree and no scheduled work. They are parked here — out of the active roadmaps in +`doc/plan/` — until something is actually built against them. Treat each as a +captured idea, not a plan of record. + +| Sketch | Scope | +|--------|-------| +| [RQL.md](RQL.md) | A typed relational query language (a clean-break SQL replacement): surface syntax, logical algebra/IR, physical plan, algebraic laws, option types instead of NULL. | +| [RSN.md](RSN.md) | A statically-typed scripting language to replace make/shell/Python for kit's build and test orchestration: typed CG-API frontend, staged arena/GC runtime, cooperative fibers. | diff --git a/doc/plan/RQL.md b/doc/ideas/RQL.md diff --git a/doc/plan/RSN.md b/doc/ideas/RSN.md diff --git a/doc/plan/ARCH.md b/doc/plan/ARCH.md @@ -1,182 +0,0 @@ -# Arch-Backend Completeness (planned work) - -This roadmap consolidates the *remaining* native-backend work across the three -machine-code targets (aa64, x64, rv64). The bulk of the NativeTarget port -- the -single-pass (-O0) path and the known-frame (-O1) path for all three arches, plus -the asm/disasm/link-reloc/dwarf matrix -- is already in tree and is treated here -as the **baseline**, not as planned work. What follows is the genuinely-open -follow-up: per-arch hooks where x64/rv64 still trail the aa64 reference, -prologue/epilogue and tail-call cost-model parity, and a small set of niche -asm/disasm and debugger gaps. The backend abstraction and ABI layer this work -sits behind are documented in the design set: -[../CODEGEN.md](../CODEGEN.md), [../OPT.md](../OPT.md), [../ASM.md](../ASM.md), -[../DWARF.md](../DWARF.md). - -## Baseline (done -- context, not planned work) - -- All three backends implement the full NativeTarget vtable on both the - single-pass (`NativeDirectTarget`) and known-frame (`func_begin_known_frame`) - paths: prologue/epilogue, `bind_param`, frame slots, calls/returns, atomics, - variadics, inline/file-scope asm, TLS (Local-Exec), and intrinsics. -- x64 carries both ABIs (SysV + Win64), including shadow space, callee-saved - XMMs, the `__chkstk` large-frame probe, and the SysV 176-byte variadic - register-save area. rv64 covers LP64D with the s0-anchored frame and Zba/Zbb - use where available. -- asm/disasm/link-reloc/dwarf parity across the OS matrix (ELF/COFF/Mach-O as - applicable) is in place: rv64 relocation emission, the x64 `.eh_frame` RBP - DWARF-reg fix, the aa64 FP/SIMD and x64 SSE disasm rows, named params/locals in - x64 and rv64 DWARF, and the shared LEB128 / `.comm` assembler directives. - -The items below are what is *not* yet at aa64 parity. - -## 1. Tail-call realization on x64 and rv64 (blocker-check removal) - -aa64 realizes sibling (tail) calls whenever the outgoing stack-argument area fits -the caller's incoming parameter window -- `aa_no_tail` returns a blocker *only* on -that size check. The same is true of the size check on x64 and rv64 (x64 even -accounts for the shadow-space prefix), and the restore-before-jump *machinery* -already exists on both: `x64_emit_tail_site` / `rv_emit_tail_site` emit the -callee-save restore and frame teardown ahead of the tail jump exactly the way -aa64 does. What remains is conservatism in the realizability gate -- `x64_no_tail` -and `rv_no_tail` still bail out with `"callee-saved registers in use"` whenever -the function has any callee-save live (`frame.ncallee_saves != 0`), so those -functions fall back to a normal call + return even though the tail site could -handle them. - -This is the single largest aa64-vs-rest divergence and matters most for the -recursion-heavy / interpreter-dispatch workloads that the O(1)-tail-call work -targets (see the interpreter and toy `musttail` tracks). - -- Remove the `ncallee_saves` guard from `x64_no_tail` / `rv_no_tail` so the - size check alone gates realizability, letting the existing tail-site restore - sequences run for callee-saves-live functions. -- x64: confirm the existing restore ordering interacts correctly with the SysV - vs Win64 callee-save sets (Win64 also saves XMM6-15) and with a forwarded sret - pointer before lifting the guard. -- rv64: confirm the s2-s11 / fs2-fs11 and s0/ra restore-then-`jr` sequence holds - for the previously-blocked frames once the guard is gone. -- Extend the tail-call test corpus to cover the callee-saves-live case on x64 and - rv64, since those paths were unexercised while the guard masked them. -- Win64 FP-arg tail-call interaction is noted as a deferred sub-case in the port - notes; validate or document the restriction explicitly. - -## 2. Prologue / epilogue cost-model parity (per-call overhead) - -The fixed per-call overhead -- prologue + epilogue + arg setup, independent of the -body -- is the dominant cost on call-heavy code. aa64 picks one of four frame -shapes per function to minimize it. x64 and rv64 now select a cheaper known-frame -shape too (see **Done** below); the design rationale lives in -[../ARCH.md](../ARCH.md); the aa64 measurements and the remaining body-level warts -are tracked alongside [../OPT.md](../OPT.md) and [OPTIMIZER.md](OPTIMIZER.md). - -aa64 tiers (baseline, for reference): - -| tier | when | fixed insns | -| --- | --- | ---: | -| `slim_prologue` (Tier A) | no callee-saves, no alloca, no body slots, no outgoing stack | 3 (optimal) | -| `fp_at_bottom` | >=1 callee-save/body slot, **no outgoing stack args**, frame <= 504 | 5 (optimal) | -| `slim_small_frame` | as above but with outgoing stack args | 7 | -| fat | large frame / alloca / big saved-pair offset | 7+ | - -The known-frame asymmetry (bottom-record only on the -O1 path) is intentional: -the frame-size-dependent offsets require the frame to be final before the body, -which only the optimizer's frame planner guarantees. - -Leaf-ness is surfaced to the backends through `NativeKnownFrameDesc.is_leaf` -(set in `plan_frame`, `pass_native_emit.c`, as "no `IR_CALL` of any kind -- -regular or sibling/tail"). A leaf never clobbers the return-address register or -the stack below sp, which is what unlocks the no-frame / red-zone shapes below. - -Done: - -- **x64 slim + red-zone tiers** (`x64_func_begin_known_frame`). Two known-frame - shapes, both keeping the `push rbp; mov rbp,rsp` record (so the `leave` - epilogue, the `CFA = rbp+16` CFI, and every rbp-relative offset are unchanged) - and only dropping the `sub rsp` reservation: - - `slim_frame` -- empty frame (no callee-saves, no body slots, no outgoing - args, no alloca). Safe for non-leaves too: `push rbp` keeps rsp 16-aligned - for any register-only call, and nothing lives below rsp. SysV + Win64. - - `redzone_leaf` -- SysV leaf with a small frame (`is_leaf`, no alloca, no - outgoing args, `frame_size <= 128`). Locals/callee-saves stay at their - rbp-relative offsets, which now land in the 128-byte red zone. Leaf-only, - since any call would clobber the red zone; Win64 (no red zone) is excluded - by the `shadow_space == 0` gate. - - No x64 *fold* tier: `push rbp` already folds the sp-move into the store, so - there is no aa64-`fp_at_bottom`-style win to capture. -- **rv64 leaf tier** (`rv_func_begin_known_frame`, `slim_prologue`). A leaf with - no callee-saves, no body slots, no outgoing args, no sret/variadic and - register-only params (`signature_stack_bytes == 0`) never reads s0 nor clobbers - ra (both are reserved, never allocable), so it emits **no prologue** and a bare - `ret` -- the whole frame setup/teardown is elided (~8 insns/leaf). CFI is - `def_cfa(sp, 0)`, matching the CIE default (ra stays live in its register). -- **rv64 frame fold: intentionally not ported.** Porting aa64's `fp_at_bottom` - to rv64 was measured at a **zero** instruction win: RISC-V has no - pre/post-indexed store, so moving the saved s0/ra pair to the bottom still - needs a separate `addi sp,sp,-N` plus the `sd`/`addi s0,sp` -- the same four - instructions as the top-record shape. The fold only relocates data, it removes - no instruction, so it was skipped rather than add a fold-aware offset-helper - layer for no benefit. (Per the "quantify the win before committing" guidance - that previously stood here.) The rv64 leaf tier above is the real rv64 win. - -Still open: - -- **Cost-model alignment.** `signature_stack_bytes` / `call_stack_bytes` are the - shared hooks the optimizer uses to size the outgoing area and gate tail-call - realizability; they exist on all three. As the tail-call paths (section 1) - land, verify the optimizer's per-call cost estimates reflect the cheaper - shapes so frame/spill decisions stay consistent across arches. - -Body-level per-call warts from the aa64 study that are arch-shared and still -open: - -- **Redundant branch chain.** An if/else merge can emit `b A; A: b B` -- a - conditional branch to a label that just unconditionally branches onward. - `cleanup_layout_fallthrough_branches` in the jump pass does not yet thread this - shape; this is an optimizer pass fix, surfaced per-arch at the call site. - -## 3. x64 debugger step-out / unwind - -`kit_dwarf_unwind_step` has no memory provider, and x64 (unlike aa64/rv64, which -have a link register) has no link-register fallback, so step-out can't recover the -return address from the stack. Compounding it, the JIT debugger doesn't populate -`.eh_frame` for in-process images. - -- Add a memory-reading unwind variant so the unwinder can read the saved RA / - RBP from the stack on x64. -- Populate `.eh_frame` (or an equivalent CFI source) for JIT in-process images so - the debugger has unwind data to consume. - -This is a debugging-UX robustness item with test-infra dependencies; see -[../DBG.md](../DBG.md) and [../DWARF.md](../DWARF.md). Sibling debugger roadmap: -[DEBUG.md](DEBUG.md). - -## 4. Niche assembler / disassembler gaps - -These are in the standalone `as` / inline-`asm()` encode-decode paths only. The -compiler's codegen emits machine code directly and never routes through the text -assembler, and the shipped runtime `.s`/`.S` files don't use these forms, so -none of this blocks any build. They are GNU-as / llvm-mc parity gaps for -hand-written assembly. Design context: [../ASM.md](../ASM.md). - -- **aa64 atomics, remaining encode forms.** `CASP`, the LSE min/max family - (`ldsmax`/`ldsmin`/`ldumax`/`ldumin`), and `LDAPR`/`STLLR` are not yet encoded. -- **aa64 disasm rows for the new encode-only forms.** The recently-added - exclusive/LSE atomics, register-offset, and writeback load/store forms encode - correctly but have no decode rows, so a round-trip currently renders them as - `.inst`. Add the matching disasm rows. -- **TLS relocation modifiers in operands.** `:tprel_*:` (aa64) and `%tls_*` - (rv64) operand syntax is not yet accepted; the non-TLS modifiers - (`:lo12:`/`:got:`, `%hi`/`%lo`/`%pcrel_*`, x64 `@PLT`/`@GOTPCREL`) are done. -- **`.L`-prefixed local-label spellings in operand references.** Plain labels - work (including as the `%pcrel_lo` anchor); the `.L`-prefixed spelling in an - operand position is a shared-lexer change. - -## 5. Cross-cutting hygiene - -- Keep the three backends converging on the shared `NativeFrame` / `native_argmove` - (parallel-copy shuffle) scaffolding rather than re-implementing per-arch; new - fold tiers and tail-call paths should reuse it. -- As each gap above closes, prefer locking it in with a targeted corpus case - (per-arch, per-form) over broad sweeps, per the testing guidance in - [../TESTING.md](../TESTING.md). diff --git a/doc/plan/ARM32.md b/doc/plan/ARM32.md @@ -32,7 +32,7 @@ producing and consuming correct `arm-none-eabi` ELFCLASS32 objects and static executables for **ARMv7-M (Cortex-M3)** and **ARMv7E-M (Cortex-M4/M7)**, validated under `qemu-system-arm` exactly the way `riscv32-none-elf` is validated under `qemu-system-riscv32` (the `cross` matrix, `freestanding-*` only — see -[PORT.md](PORT.md)). The recently-closed rv32 work is the direct precedent: it +[PORT.md](../PORT.md)). The recently-closed rv32 work is the direct precedent: it solved 32-bit-on-a-32-bit-arch legalization and the freestanding bare-metal test lane; arm32 reuses both. @@ -277,7 +277,7 @@ branch targets; since everything is Thumb on Cortex-M, BL never relaxes to BLX. arm32 is **freestanding, cross-only**: it joins the `cross` matrix as `freestanding-arm32`, never `selfhost` (no OS to run a compiler on). The exec seam already has the right backend — `test/lib/exec_bare.sh`, the `qemu-system` -bare-metal front door ([PORT.md](PORT.md) "One exec front door, three +bare-metal front door ([PORT.md](../PORT.md) "One exec front door, three backends"). We add an `arm32` arm to it, plus a smoke oracle and the corpus lanes. @@ -487,4 +487,4 @@ kit-compiled reset stub (needs kit `as` for MSR/MRS/BKPT or a C+inline-asm stub) template `rv32.sh`), `test/toy/run.sh` (`cross_one_arm32`), `test/parse/run.sh` (E lane) + `test/lib/kit_test_target.h`, `test/arch/arm32_decode_test.c`, `test/elf/unit/arm32_class32.c`, `test/link/arm32_jit_test.c`, - `scripts/hosted.sh`, `mk/{test,test_unit}.mk`. Design of record: [PORT.md](PORT.md). + `scripts/hosted.sh`, `mk/{test,test_unit}.mk`. Design of record: [PORT.md](../PORT.md). diff --git a/doc/plan/BOOTSTRAP.md b/doc/plan/BOOTSTRAP.md @@ -1,281 +0,0 @@ -# Self-Build Bootstrap (current state and roadmap) - -This roadmap covers the staged self-build of kit: building the compiler with -itself until it reproduces its own output byte-for-byte. The mechanics and -products of the build are described in [../BUILD.md](../BUILD.md); this document -tracks the reproducibility goal, the current baseline, the open problems that -remain, and the next steps for widening coverage. The bootstrap is the strongest -end-to-end correctness oracle in the project, because it exercises the C -frontend, every optimizer pass, the native backends, the object writers, the -linker, and the archive tools, all on the compiler's own source. - -## Goal: a self-reproducing fixed point - -The bootstrap builds kit three times and requires the last two stages to be -identical: - -- **stage1** = the host-built kit, copied aside, exposing `cc`/`ld`/`ar`/`ranlib`/`as`. -- **stage2** = the whole tree rebuilt with stage1 as the toolchain (`CC`/`AR`/`LD`). -- **stage3** = the whole tree rebuilt again with stage2 as the toolchain. -- The invariant is `cmp stage2/kit stage3/kit` — they must be byte-identical. - -Stage2 vs stage3 is the fixed point: once the compiler reproduces itself, a third -pass cannot change anything. The bootstrap drives the *normal* Makefile with -`CC`/`AR`/`LD` repointed at each stage's symlinks, so there is no separate build -system to maintain — it is the same rules run with kit as the toolchain. This -depends on the reproducible-build guarantees in [../BUILD.md](../BUILD.md) -(deterministic ordering, no embedded timestamps/paths); any nondeterminism in -codegen or object layout surfaces here as a stage2/stage3 mismatch. - -Driving targets (see [../BUILD.md](../BUILD.md)): - -- `make bootstrap` runs both the debug (`-O0`) and release (`-O1`) chains. -- `make bootstrap-debug` / `make bootstrap-release` run one chain. -- `make test-bootstrap-toy` additionally runs the Toy corpus through the - bootstrapped compiler as a behavioral check on top of the byte-identity check. - -## Current baseline - -Done (baseline) on aarch64-macos: - -- Both the `-O0` (debug) and `-O1` (release) chains reach the fixed point: - `cmp stage2/kit stage3/kit` is clean, and the per-object check across all - `*.o` in stage2 vs stage3 reports zero differences in both modes. -- Both bootstrapped compilers run the full Toy corpus clean (1034 pass, 0 fail, - 8 skip) across the run, link/native, C-backend, and Wasm paths at Toy opt - levels 0 and 1. - -Done on aarch64-linux (ELF), run natively inside an arm64 Linux container from -the macOS host (see "Bootstrapping a Linux target from a non-Linux host" below): - -- **musl** (alpine): both the `-O0` and `-O1` chains reach the fixed point; - `cmp stage2/kit stage3/kit` is clean and the per-object check across all 321 - `*.o`/`*.a` in stage2 vs stage3 reports zero differences in both modes. The - bootstrapped stage3 runs the Toy corpus at 1365 pass / 15 fail / 39 skip; the - 15 failures are not bootstrap/codegen issues (per-object is byte-identical) — - they are Mach-O-tuned `.objdump` golden substrings that differ on ELF, emitted - C the container's host clang rejects under `-Werror`, and one Linux JIT-TLS - `.tdata`-init discrepancy (see below). -- **glibc** (debian): reaching the fixed point required a series of kit - C-frontend / preprocessor compatibility fixes for the glibc + Linux-UAPI - header set (musl's ISO-C headers never exercised them): erase `__extension__` - on all targets, map `__signed__`/`__volatile__`/`__const__` to the canonical - keywords, and support GNU named variadic macro parameters (`args...`) in the - preprocessor. - -Done on aarch64-freebsd (ELF), run natively inside the FreeBSD aarch64 VM from -the macOS host (`scripts/freebsd_bootstrap.sh aarch64`; see "Bootstrapping a -Linux target from a non-Linux host" — the FreeBSD VM path is the same shape): - -- **Both the `-O0` (debug) and `-O1` (release) chains reach the fixed point**: - `cmp stage2/kit stage3/kit` is byte-identical in both modes. The bootstrapped - stage3 runs the Toy corpus at 1378 pass / 2 fail / 39 skip in both chains; the - 2 failures are the JIT-TLS `.tdata`-init `R`-lane discrepancy - (`141_threadlocal_mutate`) — a non-bootstrap gap shared with aarch64-linux - (the in-process JIT does not set up a per-thread TLS block / copy the `.tdata` - initializer; the native-link path is already `.link.skip`-gated), not a - codegen issue. - - Four fixes were needed, in the order they surfaced: - - kit `cc` accepting `-rdynamic` (FreeBSD's `HOST_ENV_LDFLAGS` passes it; the - other ELF hosts do not). - - **ELF symbol-version (Verneed/Versym) emission.** FreeBSD's INO64 transition - left `stat`/`fstat`/... as two incompatible `struct stat` ABIs behind a - hidden `FBSD_1.0` (compat) and the default `FBSD_1.5`; kit emitted - *unversioned* undefined references, so the runtime bound the compat version - and read `st_size` at the wrong offset — stage2 then failed to read its own - source files. The linker now reads each DSO's `.gnu.version_d`/`.gnu.version` - and emits a matching `.gnu.version_r` + `.gnu.version`, gated on the DSO - carrying versions (musl/static links unchanged; glibc links gain correct - `GLIBC_*` requirements). - - **(`-O1`) Deferred-symbol globalization in the assembler.** `-O1` *deferred* - anonymous const-data / jump-table symbols (`.Lkit_ro.N` / `.Lkit_jt.N`) are - LOCAL tombstones (`obj_symbol_defer`, `removed=1`) until - `opt_whole_module_finalize` materializes them. `promote_undef_externs` - (`src/asm/asm.c`) — which globalizes undefined LOCAL externs — walked every - slot *including tombstones* (the only `obj_symiter` consumer not honoring the - `removed` contract) and flipped them to defined GLOBALs. It bit FreeBSD - because `<stdlib.h>` injects a file-scope `__asm__(".symver …")` whose replay - runs that pass *before* the deferred data is materialized, so the four hosted - `driver/env/*.o` each defined a global `.Lkit_ro.0` → `duplicate definition - of global symbol '.Lkit_ro.0'`. Fix: skip `removed` tombstones. Not - FreeBSD-specific — any TU with a file-scope `asm` + a deferred const-data - symbol at `-O1` reproduces it on any target. - - **(`-O1`) `--gc-sections` rooting of DSO back-references.** With the above - fixed the stage2 link succeeded, but the `-O1` `kit` failed to *load* - (`ld-elf.so.1: /lib/libc.so.7: Undefined symbol "__progname"`): the release - chain links `-Wl,--gc-sections`, and kit's section-GC liveness - (`src/link/link_resolve.c`) did not root executable definitions that a - linked DSO references, so `__progname`/`environ` (crt-defined, needed by - `libc.so.7`) were collected out of the dynsym. `read_elf_dso` now records - each DSO's undefined-symbol names and the GC pass roots the executable's - definitions of them — matching GNU ld's default "keep what shared libraries - need" behaviour (`-O0` has no `--gc-sections` and is unaffected). - -This gives four fully self-hosting configurations — aarch64-macos, -aarch64-linux (musl + glibc), and aarch64-freebsd — each at both `-O0` and -`-O1`. The remaining work is breadth: the other native targets (x86-64, rv64), -and guarding the property over time. - -## Open problems and next steps - -### Widen target and platform coverage - -The fixed point holds for aarch64-macos, aarch64-linux (musl + glibc), and -aarch64-freebsd. The bootstrap should hold for every supported native target -and object format. Until -each is green it is an open question whether its backend + object writer are -fully deterministic and self-consistent. - -- [ ] Reach the fixed point on x86-64 (ELF and Mach-O) for both `-O0` and `-O1`. -- [ ] Reach the fixed point on rv64 (ELF) for both `-O0` and `-O1`. -- [x] Reach the fixed point on aarch64-linux (ELF), distinct from the macOS - Mach-O path already covered. Done for musl and glibc; the aarch64-linux - backend + ELF writer are confirmed deterministic and self-consistent. -- [x] For each new configuration, run the per-object diff and the Toy corpus - through the bootstrapped compiler, not just the final `cmp`. Done for - aarch64-linux (321/321 objects identical; Toy 1365/15/39). - -### Bootstrapping a Linux target from a non-Linux host - -`make bootstrap` keys off the build host's own `uname` (`HOST_OS` + machine), so -it selects the native toolchain and object format with no cross-compilation. To -bootstrap aarch64-linux from the macOS dev host, run the normal three-stage -build *inside* an arm64 Linux container, where it is an ordinary native build: - -- `scripts/linux_bootstrap.sh [musl|glibc] [both|debug|release]` drives a podman - container (alpine for musl, debian for glibc — the same image families the - hosted test suite uses), provisions a seed clang + make + libc headers, and - runs `make bootstrap` with the stage tree under `build/linux-boot/<libc>/`. - `KIT_LINUX_BOOT_TOY=1` additionally runs the Toy corpus through stage3. -- `make bootstrap-linux` (→ `-musl`) / `make bootstrap-linux-glibc` wrap it. - -Three host-environment differences from the macOS reference, all handled by the -script / `mk/bootstrap.mk`: - -- **LeakSanitizer.** The `-O0` chain builds stage1 with ASan+UBSan as on macOS, - but LSan (unsupported on Darwin, so never run there) flags kit's arena - allocator — which deliberately never frees — and aborts every stage1 `cc`. The - container sets `ASAN_OPTIONS=…:detect_leaks=0`, the honest equivalent of - Darwin's behavior. -- **`-lc` for the kit-compiled stages.** On macOS kit gets its system headers via - the `-isysroot` in `HOST_SYSROOT_CFLAGS`; on Linux that is empty and kit's - hosted profile only wires up the libc include + library dirs once libc is - requested, so `mk/bootstrap.mk` passes `HOST_SYSROOT_{C,LD}FLAGS=-lc` to the - stage2/3 sub-makes (Linux/FreeBSD only). -- **glibc header compatibility.** glibc + Linux-UAPI headers use GCC-isms musl's - cleaner headers don't; reaching the glibc fixed point needed the C-frontend / - pp fixes listed in the baseline above. - -Non-bootstrap gaps surfaced by the aarch64-linux Toy run (tracked, not -fixed-point blockers — the per-object diff is byte-identical): - -- **JIT-TLS `.tdata` initializer.** `141_threadlocal_mutate` returns 3 instead - of 43 on the R (in-process JIT) lane: the JIT zero-initializes the - thread-local block instead of copying its `.tdata` initializer (40). Native - link of this case is already `.link.skip`-gated; the JIT path is the gap. -- **`.objdump` golden substrings** for a few cases (`122_data_entsize`, - `127_switch_forced_jump_table`, `62_decl_data_attrs`) encode Mach-O section / - entsize spellings and need ELF-flavored sidecars. -- **C-backend emitted source** for ~7 cases is rejected by the container's host - clang under `-Wall -Wextra -Werror` (host-toolchain strictness, varies by - clang version). - -The native-ELF Toy `L` lane links hosted (`kit cc -lc`, so the crt provides -`_start`) rather than freestanding `kit ld`, because an ELF executable needs a -crt entry where Mach-O drives `LC_MAIN` straight to `main`; `test/toy/run.sh` -selects this automatically on non-Darwin hosts (`KIT_TOY_L_HOSTED`). - -These connect to the per-arch backend state tracked in [../CODEGEN.md](../CODEGEN.md) -and [../ARCH.md](../ARCH.md), and to the object/format paths in -[../OBJ.md](../OBJ.md) and [../LINK.md](../LINK.md) / [LINKER.md](LINKER.md). A -new arch's first bootstrap is also the most thorough regression test those -components get. - -### Guard the property over time - -The fixed point is easy to break with a single nondeterministic or -miscompiling change, and a regression is expensive to bisect after the fact. - -- [ ] Run `make bootstrap` (or at least one chain) in CI on the reference host so - breakage is caught at the offending change, not after multiple commits - have been made. -- [ ] Keep the per-object diff available as a first-line triage signal: it points - directly at the diverging translation unit, which is far cheaper than - diffing whole linked binaries. - -### Cross-bootstrap (stretch) - -The current chains are native (host arch building host arch). A cross-bootstrap — -host kit building a stage2 for a *different* target, then validating that -stage2 reproduces a stage3 when run under [../EMU.md](../EMU.md) or on hardware — -would prove the backends independent of the host. This is a stretch goal that -depends on the emulator being able to host the full compiler. - -## Triage playbook for fixed-point regressions - -When a stage2/stage3 mismatch (or a stage3 link failure) appears, the following -approach has proven effective and should be the default starting point. - -**Use object reproduction, not just "does it link", as the oracle.** A stage3 -link failure is usually a *symptom* of a malformed object emitted earlier, not a -linker bug. The decisive question is whether stage2, used as a compiler, -reproduces the same `.o` that the host-built compiler produces. Compile one -suspect TU with both the host `kit` and the stage2 `kit` using identical -flags, then `cmp` the two objects. This separates malformed-object bugs from -link-driver symptoms and points straight at the diverging codegen. - -**Narrow with hybrid relinks.** Relink stage2 after replacing one suspect TU (or -one piece of a split TU) with a clang-built object, then use that stage2 to -compile the known-differing target object. This isolates whether a failure is in -the linker itself or in codegen for a specific source file. - -**Inspect MIR around the suspect symbol.** A temporary filtered MIR dump around -the target function, taken after lowering and the combine pass, is usually enough -to see the divergence (e.g. a call argument that should reference an allocable -register but instead references a backend scratch register). - -**Avoid `-g` while triaging `-O1` codegen.** Debug info changes object layout and -can create or hide layout-sensitive bugs; one historical "regalloc" diagnosis was -actually a `-g` artifact. Triage on the non-`-g` object first. - -## Root-cause classes seen at the fixed point - -These bug classes were responsible for past `-O1` fixed-point and stage3-link -failures. They are fixed in the baseline, but they map the parts of the pipeline -most likely to break the property again, so they are worth keeping in mind when a -new arch or platform is brought up. See [../OPT.md](../OPT.md) for the passes. - -- **Operand clobber in native emit.** Materializing the left operand of a binop, - compare, or compare-branch into a scratch register that already holds the right - operand. The general rule: compute the RHS location first and exclude its - register when materializing the LHS. A real instance produced `1 << 1` for - `1u << (n & 31)`, which corrupted Mach-O section alignments and only manifested - as a downstream `ld -r` / stage3 link failure. - -- **Copy propagation across backend scratch registers.** Treating backend scratch - registers as ordinary hard registers during the combine pass: scratch registers - may appear in lowered MIR, but they must not be extended across later - instructions, because native lowering reuses them as transient temporaries. - A real instance rewrote a stack-argument call operand back to scratch `x9`, - which was then clobbered before the store, sending an unrelated value into a - stack slot and flipping an inline-always flag. - -- **Coalesce overlap checks** must use raw range points, not compressed points. - -- **Lower-pass hint fallback** must not place values that are live across a call - into caller-saved hint registers. - -- **Native scratch budget.** A backend needs enough integer scratch registers for - all-spilled three-operand operations (aa64 needs three). - -- **Aggregate copy/set with pointer operands.** Pointer-valued operands of - aggregate copy/set must not force-home the pointer local; genuinely - frame-backed pointer locals need prematerialized indirect bases. - -The throughline: the fragile interactions are between the optimizer's -register-level reasoning ([../OPT.md](../OPT.md)) and the backend's scratch-register -discipline ([../ARCH.md](../ARCH.md)), with the object/link layer -([../OBJ.md](../OBJ.md), [../LINK.md](../LINK.md)) as where the symptom finally -surfaces. New backends should expect to re-litigate these before reaching their -own fixed point. diff --git a/doc/plan/BUILD_COMMANDS.md b/doc/plan/BUILD_COMMANDS.md @@ -1,292 +0,0 @@ -# kit build commands - -Forward-looking roadmap for the kit-native build verbs — `build-exe`, -`build-lib`, `build-obj` — that **replace** the `compile` tool. They compile a -mixed-language set of sources entirely in memory and produce a final artifact -(executable / static or shared library / object) in one invocation, with full -control over both the per-source compile and the whole-build link. Design doc -when shipped: [../DRIVER.md](../DRIVER.md). - -Distinct from [BUILD.md](BUILD.md) (the CAS-backed incremental build -*coordinator*) and from [../BUILD.md](../BUILD.md) (kit's own Makefile build). -This is about the driver's single-shot build commands. - -## Motivation - -Today the kit-native compile path splits awkwardly: - -- **`compile`** (driver/cmd/compile.c) resolves exactly one frontend (by `-x` - or suffix), forwards frontend-specific flags (e.g. wasm `-mfeature=`), and - emits objects / `.s` / portable C / IR — but **never links** and rejects - `.o`/`.a` inputs. Linking means writing intermediate objects to disk and - invoking `ld`/`cc`/`run` separately. -- **`cc`** (driver/cmd/cc.c) already compiles a *polyglot* source set - (`.c .s .S .toy .wat .wasm`, language resolved per-file) to in-memory - `KitObjBuilder*`, links them with byte-loaded `.o`/`.a`/`.so` via a single - `KitLinkSession`, and emits an executable or shared library — **with no - intermediate files**. But `cc` is deliberately a GCC-compatible C driver: its - flag surface is a GCC subset and it does **not** expose frontend-specific - flags. - -So the in-memory, no-temp-files, polyglot compile+link pipeline already exists -and is proven inside `cc`'s link path (driver/cmd/cc.c `cc_run_link_exe`). What -is missing is a **kit-native front door** to that pipeline: one that is polyglot, -forwards per-language frontend flags, exposes the full link-flag surface, and -lets the caller scope compile flags to individual sources or groups of sources — -without pretending to be `gcc`. - -The public API already supports every output we need: - -| Artifact | API | -|----------|-----| -| executable | `KitLinkSession` + `KIT_LINK_OUTPUT_EXE` → `kit_link_session_emit` | -| shared library | `KitLinkSession` + `KIT_LINK_OUTPUT_SHARED` | -| combined relocatable object | `KitLinkSession` + `KIT_LINK_OUTPUT_RELOCATABLE` | -| single object | `kit_obj_builder_emit` (include/kit/object.h) | -| static archive | `kit_obj_builder_emit` each member, then `kit_ar_write` (include/kit/archive.h) | - -This work is therefore **almost entirely a driver-layer reorganization**, not new -core machinery: lift `cc`'s link path into a shared engine, add a kit-native -argument grammar on top, and retire `compile`. - -## The command set - -A Zig-inspired trio. Every command is polyglot, compiles in memory, and writes -no intermediate files. - -| Command | Produces | Backend | -|---------|----------|---------| -| `kit build-exe` | executable | link session, `OUTPUT_EXE` | -| `kit build-lib` | static `.a` (default) or shared library (`-dynamic`) | `kit_ar_write` / `OUTPUT_SHARED` | -| `kit build-obj` | a single object; or `--emit=asm\|c\|ir`; or `-fsyntax-only` check | one `KitObjBuilder`, or `OUTPUT_RELOCATABLE` for multi-source | - -`build-obj` is the full replacement for `compile`: it keeps `--emit=obj|asm|c|ir`, -`-fsyntax-only`, the single-frontend-or-polyglot source handling, and frontend -flag forwarding — and it gains the ability to **combine several sources into one -relocatable object** (`ld -r` style) via `KIT_LINK_OUTPUT_RELOCATABLE`. The -standalone `kit check` (cc.c `driver_check`) and `cc`'s own `--emit=`/`-S` are -unaffected and remain available. - -The three share ~90% of their code; like `cc`/`check` they live in one file -(`driver/cmd/build.c`) with three thin entry points (`driver_build_exe`, -`driver_build_lib`, `driver_build_obj`) over a shared parse+run parameterized by -output kind. - -## Command-line grammar - -### Two flag tiers - -1. **Global / per-output flags** apply to the whole build and may appear - anywhere outside a group. These are everything that must agree across the - link, plus the optimization and debug knobs (per the decision below): - - - `-target TRIPLE` / `--target=`, and target-feature flags - - `-O0|-O1|-O2`, `-g` - - `-fPIC|-fPIE`, `-fvisibility=hidden|default` - - `-ffunction-sections`, `-fdata-sections` - - all **link** flags: `-l`, `-L`, `-e`, `-T`, `-static`/`-dynamic`, - `-pie`/`-no-pie`, `--build-id=`, `-Wl,…`, soname/rpath, subsystem, … - - all **output** flags: `-o`, `--emit=`, `-S`, `-fsyntax-only` - - `-Werror`, `-fmax-errors=N` - -2. **Scopable flags** may appear globally (baseline for every source) *and* - inside a `--group` (override for that group's sources only). The scopable set - is intentionally small — only what is genuinely per-translation-unit: - - - preprocessor: `-I`, `-isystem`, `-D`, `-U` - - language selection: `-x LANG` - - frontend-specific: `-X<lang> FLAG` (see below) - -Placing a global flag inside a `--group` is a **usage error** with a pointed -diagnostic (e.g. `-O is a per-output flag; place it before any --group`). This -keeps the rule a one-liner: *outside a group = whole build; inside a group = -those sources.* - -### Groups - -``` ---group [scopable flags…] -- source [source…] -``` - -Each `--group` bundles scopable overrides with the sources listed up to the next -`--group` or the end of arguments. The `--` separates the group's flags from its -sources. Sources listed **outside** any group ("bare" sources) receive only the -global flags. - -Inheritance and precedence within a group, relative to the global baseline: - -- **Include dirs** (`-I`/`-isystem`): group dirs are prepended to the global - search path (searched first), global dirs still apply. -- **Defines** (`-D`/`-U`): additive; a group `-D` of an already-defined name - overrides it for that group. -- **Language** (`-x`): a group `-x` overrides suffix resolution for that group. -- **Frontend flags** (`-X<lang>`): a group's apply only to that group's sources - of `<lang>`; global `-X<lang>` applies to all sources of `<lang>`. - -Link order is the left-to-right order of source/object/archive appearance; a -group contributes its sources at the group's position. Bare inputs (`.o`/`.a`/ -`.so`) keep their command-line position for the linker. - -### Per-language frontend flags: `-X<lang>` - -`compile` could forward leftover flags unambiguously because it resolved exactly -one frontend. A polyglot build cannot, so frontend flags are explicitly -language-scoped: - -``` --X<lang> FLAG # e.g. -Xwasm -mfeature=simd128 -``` - -`-X<lang>` consumes exactly one following token and routes it to that frontend's -`kit_frontend_parse_options` (the same entry `compile` uses). Repeatable. -`<lang>` is `c|asm|toy|wasm`. Works both globally and inside a group. (Current -kit frontend flags are single-token; a multi-token form is a future extension if -ever needed.) - -### Naming conventions (hybrid) - -Keep kit/`cc`'s established output vocabulary; adopt Zig's clearer link-kind -selectors: - -- **Output form**: `--emit=obj|asm|c|ir` and `-o PATH` (unchanged from `compile`). - `-S` is sugar for `--emit=asm`. -- **Link kind**: `-static` / `-dynamic` instead of `-shared`. `build-lib` - defaults to a static `.a`; `-dynamic` makes a shared library. `build-exe` - defaults to the target's normal dynamic linking; `-static` produces a fully - static executable. `-shared` is accepted on `build-lib` as a **hidden alias** - for `-dynamic` (eases `cc`/`gcc` muscle memory) but is omitted from help, which - steers to `-dynamic`. - -### Output defaults - -- `build-exe`: `-o` optional; default `a.out` (`a.exe` on Windows). -- `build-lib`: `-o` **required** (no single obvious base name across N sources); - shared output respects soname/`--version`. -- `build-obj`: single source → default `<base>.o` (as `compile` does today, via - the equivalent of `compile_default_out`); multiple sources → `-o` required and - output is one relocatable object; `--emit=c` still requires `-o`; `--emit=ir` - still requires `-O1+`. - -`-o -` writes the emit to stdout for **all** emit forms (obj/asm/c/ir), -reusing the existing `driver_stdout_writer` that `cc` uses — natural for -pipelines (e.g. `build-obj --emit=ir -o - kernel.wat | less`). Binary objects to -a tty are unusual but harmless and not specially rejected. - -A single `-target` governs the whole build — mixing targets in one invocation is -an error (one link, one machine). - -## Worked examples - -```sh -# Polyglot executable: C + a hand-written asm TU + a Wasm module, in memory. -kit build-exe -target aarch64-linux-gnu -O2 -o app \ - main.c util.c \ - --group -DFAST -Iinc/fast -- hot1.c hot2.c \ - --group -Xwasm -mfeature=simd128 -- kernel.wat \ - prebuilt.o -Llib -lfoo - -# Static library from mixed sources (default kind). -kit build-lib -O2 -o libmix.a a.c b.toy c.s - -# Shared library with a soname. -kit build-lib -dynamic -fPIC -Wl,-soname=libmix.so.1 -o libmix.so.1 a.c b.c - -# Combine three TUs into one relocatable object (ld -r). -kit build-obj -O1 -o combined.o a.c b.c c.c - -# Inspect: emit IR for a Wasm module compiled with a frontend feature flag. -kit build-obj -O1 --emit=ir -Xwasm -mfeature=simd128 -o k.ir kernel.wat - -# Check only, no output. -kit build-obj -fsyntax-only main.c util.c -``` - -## Implementation plan - -The work is a factor-out + new-grammar exercise. Proposed file moves: - -1. **`driver/lib/link_engine.{h,c}`** — lift the body of cc.c `cc_run_link_exe` - into a reusable step. Input: a populated link plan (in-memory `KitObjBuilder*` - list, byte-loaded objects/archives/DSOs, an ordered `KitLinkInputOrder` - list, and a filled `KitLinkSessionOptions`). It opens the writer, builds the - `KitLinkSession`, adds inputs in order, and emits. `cc_run_link_exe` becomes a - thin caller, so `cc` and `build-*` share one link path (no behavior change to - `cc`). The runtime-archive insertion (`libkit_rt.a`), hosted-libc wiring - (driver/lib/hosted), and `-l`/`-L` resolution (driver/lib/lib_resolve) are - already factored and are reused as-is. - -2. **`driver/lib/archive_engine.{h,c}`** (small) — `driver_archive_emit(objs[], - names[], n, writer)`: `kit_obj_builder_emit` each member to bytes, then - `kit_ar_write`. Used by `build-lib` (static) and reusable by a future `ar` - pipeline. - -3. **`driver/cmd/build.c`** — the new grammar and the three entry points. Reuses - `driver_compile_run` (driver/lib/compile_engine.h) for the per-source compile, - `DriverCflags` (driver/lib/cflags) for `-I/-D/-U`, and - `driver_target_features_*`. New here: the `--group … --` parser, the - global-vs-scoped validation, the `-X<lang>` router, and per-group cflag/ - frontend-option contexts (one `DriverCflags` baseline plus per-group deltas). - -4. **`driver/main.c`** — register `build-exe`/`build-lib`/`build-obj` in - `driver_tools[]`, gated by new `KIT_TOOL_BUILD_*_ENABLED` flags - (include/kit/config.h); add them to the default install group. Remove the - `compile` entry and its `KIT_TOOL_COMPILE_ENABLED` gate. - -5. **Remove `driver/cmd/compile.c`** and its help. Its capabilities are fully - covered by `build-obj`. - -### Per-group compile state - -The compile loop already builds one `KitObjBuilder*` per source through a shared -`KitCompiler`. The only new state is per-group compile options: each source -carries (a) a `KitPreprocessOptions` derived from global cflags + the group's -cflag delta, (b) a resolved `KitLanguage` (group `-x` or suffix), and (c) the -`lang_extra` from that group's `-X<lang>` flags. This mirrors how `compile` -already calls `kit_frontend_parse_options` per frontend — now keyed per group. - -## Migration - -- **Tests**: `test/toy/run.sh` and any harness invoking `kit compile` move to - `kit build-obj` (same flags: `--emit=`, `-x`, `-fsyntax-only`, frontend flags). - The toy corpus exercises CG via the toy frontend → `build-obj`. -- **Config/install**: drop `KIT_TOOL_COMPILE_ENABLED`; add - `KIT_TOOL_BUILD_EXE_ENABLED` / `_LIB_` / `_OBJ_`. Update the `install` default - tool set and the centralized tool table in main.c. -- **Docs**: update [../DRIVER.md](../DRIVER.md) and the project `CLAUDE.md` code - map (the `compile` bullet → the three `build-*` bullets) when this ships. -- **`cc` unaffected**: it keeps its GCC-compatible surface; it just calls the - shared `link_engine` instead of its inlined copy. - -## Future work (post-v1) - -- **`@file` response files** and **attach-by-name overrides** - (`-Con GLOB : FLAGS`) are deferred. Both layer onto the `--group` grammar - later without breaking it (`@file` is pure argv preprocessing; attach is - additive). Add `@file` first if build-system drivers hit command-line length - limits — it is net-new (no existing expander in the driver) but small and - standard (gcc/ld/ar). -- **JIT/`run` reuse** — `KIT_LINK_OUTPUT_JIT` already backs `kit run`; a future - `build-exe --run` could share the same `link_engine` plan. - -## Verification notes - -- **Relocatable-object combine** — `build-obj` multi-source - (`KIT_LINK_OUTPUT_RELOCATABLE`) must match `ld -r` for symbol visibility and - common symbols. Cover with tests against the existing relocatable-link path - before release; this is the one v1 feature whose semantics need confirming - rather than just wiring. - -## Decisions (2026-06-04) - -| Decision | Choice | -|----------|--------| -| Replace `compile`? | Yes — trio `build-exe`/`build-lib`/`build-obj`; `build-obj` subsumes `compile`. | -| Flag scoping syntax | Explicit `--group [flags] -- sources` blocks. Outside = global/per-output, inside = scoped; a group of one = per-source. | -| Global (per-output) flags | `-O`, `-g`, `-fPIC/-fPIE`, `-fvisibility` are all global (plus `-target`, all link, all output flags). | -| Scopable-in-group set | `-I/-isystem/-D/-U`, `-x`, `-X<lang>` frontend flags. | -| Naming conventions | Hybrid: keep kit `--emit=`/`-o`; adopt Zig `-static`/`-dynamic` for link kind. | -| Inspection / check home | `build-obj` keeps `--emit=asm\|c\|ir`, `-fsyntax-only`, and gains multi-source → relocatable `.o`. | -| `-shared` on `build-lib` | Accepted as a hidden alias for `-dynamic` (not shown in help). | -| `build-obj` multi-source | Relocatable combine ships in v1, gated by `ld -r` parity tests. | -| `-o -` to stdout | Supported for all emit forms (obj/asm/c/ir) via `driver_stdout_writer`. | -| v1 input ergonomics | `--group` grammar only; `@file` and attach-by-name deferred to post-v1. | diff --git a/doc/plan/CG-STACK-API.md b/doc/plan/CG-STACK-API.md @@ -1,721 +0,0 @@ -# CG stack API: parser-driven codegen without a shadow stack - -**Goal.** Make the C parser drive `KitCg` directly, in the same single-pass -shape it has today, without maintaining a duplicate `pcg` value stack. The -codegen stack stays the liveness authority: a value/place is live exactly while -it is represented by a stack slot, and popping that slot is the cheap -single-pass death signal the native direct target and temporary reclaim already -use. - -This is not a plan to replace the stack API with free-floating value handles. It -is a plan to (a) make stack slots rich enough for a language frontend to use them -as its expression stack, (b) move all effective-address folding behind the CG -API, (c) make codegen suppression a CG mode that preserves stack shape, -frontend slot facts, and constant facts, and (d) **delete the `pcg` adapter -layer** so the parser drives the CG API directly. - -This is a **clean-break rewrite, no backcompat**. The implementation is phased -only where subsystems are genuinely separable; each phase is a one-time cutover -with the old path removed for that subsystem. There is no dual-stack -compatibility mode and no byte-identity gate. - ---- - -## 1. Decisions (resolved) - -| # | Decision | Choice | -|---|---|---| -| D1 | Keep the stack API, add stack-owned inline frontend slot facts | yes | -| D2 | Free `KitCgValue`/`KitCgPlace` handles independent of stack lifetime | **rejected** — loses the "on stack == live" invariant | -| D3 | Where the type/flags stack lives during suppression | **CG unevaluated mode** — KitCg maintains stack shape, inline lang facts, and constant facts while target emission is off | -| D4 | Effective-address folding | **Automatic behind the CG API.** The parser builds places naively (`deref`, `field_at`, `elem`); CG decides whether to fold into the fused `[base + index*scale + offset]` operand or materialize. **No deferral or folding logic in the parser.** | -| D5 | Success gate | **Correctness + determinism** (full suites pass, output deterministic). Emitted bytes MAY change vs today; byte-identity is *not* a gate. | -| D6 | Fused `store_void` / `store_keep` as CG ops | **in scope** | -| D7 | The `cg_adapter.c` / `pcg_*` layer | **deleted entirely.** The parser drives `kit_cg_*` directly and applies C facts via `kit_cg_retag_top`/`set_top_flags`. C-semantic operations survive as plain helper *functions* (op-enum maps, the conversion lattice, atomic/inline-asm/inc-dec shaping) — not a stack-mirroring layer, and not under a `pcg_` prefix. | -| D8 | Call-arg stack-mark API (old §4.4) | **dropped** — C already passes args on the CG stack (`kit_cg_call`), so a mark API adds nothing | -| D9 | `KitCgSlotInfo` + new ops are public API | **forced** — `lang/` builds only against `include/kit/cg.h` (no `src/cg` includes) | -| D10 | Lang facts storage | **inline in `ApiSValue`; final node size is 64 bytes.** Pack `kind`/`res`/`pinned`/`lvalue` into a `uint16_t flags` word and make `lang_flags` `uint16_t`, giving room for `const void* lang_type` while preserving the `ApiSValue <= 64` invariant (`src/cg/value.c:14`). | -| D11 | C integer-constant-expression evaluation | **moves onto a CG constant-value model, with parser-owned C legality tracking.** The parser evaluates an ICE by parsing it once through the normal path under unevaluated mode, checking the syntactic/semantic ICE guard, and reading the folded value from CG. The parser's duplicate `cexpr_*` grammar and `cint_*` arithmetic engine are **deleted in the final ICE phase**, not during the stack/place cutover. | -| D12 | Constant payload shape | **stack-parallel constant payload, not `OPK_IMM` alone.** `kit_cg_top_const_int` today only answers for <=64-bit immediate operands; the new model carries known integer bits + width + type independently of whether the emitted operand would be an immediate/local/runtime call. | -| D13 | `__int128` constant policy | **support useful 128-bit integer bit-pattern folding in CG, but do not require 128-bit div/rem.** CG already has i128/u128 types and emitted i128 div/rem lower through runtime helpers; those are dynamic code, not compile-time constants. Move the existing parser `CConstInt` add/sub/mul/bitwise/shift/compare/cast behavior into CG so current static i128 initializer coverage stays supported; i128 div/rem returns "unknown constant" until a true compile-time word-int division implementation is added. | - ---- - -## 2. Current state (what we are removing) - -The C frontend currently runs two stacks in lockstep: - -- **The real CG stack:** `KitCg.stack` of `ApiSValue` (`src/cg/internal.h:99`). - `api_push`/`api_pop` (`src/cg/value.c:161/187`) maintain `-O0` transient - reference counts that feed cheap single-pass liveness and temp reclaim. The - node already carries `type` (CG type id), an `lvalue` byte (PLACE vs VALUE), - and an `ApiBitField` (bit-field geometry of a PLACE). -- **The parser shadow stack:** `Parser.cg_slot_stack` of `PcgSlot` - (`lang/c/parse/parse_priv.h:250`, `cg_adapter.h:73`). Each slot carries - `const Type*`, a cached `cg_id`, C value flags, and a `PcgLvAux` - effective-address record (`cg_adapter.h:50`). - -`PcgSlot` duplicates four things; only one is genuinely C-only: - -| `PcgSlot` field | Status | -|---|---| -| `const Type*` (C type) | **C-only**; opaque to CG; must live somewhere | -| `cg_id` (cached CG id) | **redundant** — the CG node already has `type` | -| flags: LVALUE / MODIFIABLE / BITFIELD / NULL_PTR_CONST / REGISTER | LVALUE≈node `lvalue`, BITFIELD≈node bit-field; MODIFIABLE/NULL_PTR_CONST/REGISTER are **C-only** | -| `PcgLvAux` offset/scale/base_kind/bit_* | **redundant** with the CG place operand (`OPK_INDIRECT` base+index*scale+ofs) + node bit-field — kept only to *defer* the deref and avoid re-crossing C-layout | - -CG already has a complete PLACE/VALUE addressing model (`include/kit/cg.h:668`): -`push_local` / `deref(offset)` / `field(index)` / `elem` / `elem_scaled` / -`addr` / `load` / `store`, with offset/index/scale folded into the `OPK_INDIRECT` -operand (`kit_cg_field`, `src/cg/control.c:1157`, folds the field offset into the -displacement and preserves any index/scale). **toy and wasm use this model -directly.** The C frontend bypasses `kit_cg_field`/`kit_cg_elem` and reimplements -the same folding in `PcgLvAux` (`pcg_materialize_lv_to_ptr`, -`pcg_lv_to_memop_place`, `cg_adapter.c:585/647`) purely to (1) defer the deref so -`*p`/`p->f` stay a pointer rvalue, and (2) avoid re-deriving an offset it already -knows. D4 deletes this: the folding moves behind the CG API. - -The sqlite layer profile makes the cost visible: ~1.43 M `kit_cg_*` calls per -`sqlite3.c`, CG API ~19% of compile ticks, of which the stack/memory family is -~49%. The hot expression seam is paid twice — once per CG stack edit, once per -shadow-stack edit. - ---- - -## 3. Invariants (load-bearing) - -1. **Stack lifetime stays authoritative.** A transient operand is live iff a live - `ApiSValue` stack entry references it. `api_push`/`api_pop` remain the lifetime - hooks. Inline lang facts and the constant payload never own lifetime. -2. **No free expression handles.** Every API returns either a - local/symbol/label handle with existing lifetime rules, or a short-lived - stack depth — never a separately-live expression value. -3. **The frontend can inspect and retag slots.** Read top/top2/depth-N lang type - + flags; retag the result slot after an op. -4. **CG does not depend on C frontend types.** `src/cg` must not include `lang/c` - headers or dereference the lang payload. The lang payload is an opaque - `const void*` copied/dropped with the slot. -5. **Backend boundaries stay intact.** `CgTarget` / NDT / `NativeTarget` / MC - receive no C frontend state. The new place ops lower through the *existing* - CgTarget contract — **no backend changes**. -6. **The hot `ApiSValue` node stays `<= 64` bytes.** Inline lang facts are allowed - only because the packed layout keeps the node at exactly 64 bytes (D10). -7. **No global state.** Inline lang facts live on each `ApiSValue`; the constant - payload and unevaluated counter hang off `KitCg`. -8. **No separate lang side allocation.** Non-C frontends leave `lang_type=NULL` - and `lang_flags=0`; they pay the 64-byte node size but no extra side array or - enable/disable machinery. -9. **Constant facts are stack-owned CG facts, not backend facts.** The constant - payload shadows the CG stack in lockstep, carries known/unknown integer bits - plus width/type, and has no `CgTarget`, local, temp, or native-backend - lifetime. Frontends read it only through public CG APIs; they never inspect - `ApiSValue` to decide whether a value is constant. -10. **Constant tracking is always-on.** The constant payload is a CG semantic - fact, not a C frontend feature and not an optimization-level feature. It is - maintained for every `KitCg` stack slot in normal emitting mode and in - unevaluated mode; there is no `kit_cg_const_enable` and no "constants off" - mode. - ---- - -## 4. The new architecture - -### 4.1 One stack + inline opaque lang facts - -`ApiSValue` carries the frontend facts directly. There is no `KitCg.lang_side`, -no enable call, and no per-frontend side allocation. `lang_type` is an opaque -pointer copied/dropped with the stack node; `lang_flags` is a frontend-defined -16-bit field. A separate always-on constant payload still shadows the same stack -depths; it is owned by `KitCg`, follows the stack in lockstep, and is independent -of the frontend type/flags fields. - -Planned internal shape, preserving a 64-byte hot stack node: - -```c -typedef struct ApiSValue { - Operand op; /* 24 bytes: immediate/local/global/indirect operand */ - ApiDelayed* delayed; /* off-node SV_CMP/SV_ARITH payload, else NULL */ - const void* lang_type; /* opaque to CG; lang/c stores a const Type* */ - KitCgTypeId type; /* CG type id */ - KitCgLocal source_local; /* owned/fixed local tracking */ - ApiBitField bitfield; /* 12 bytes; bit_width != 0 => bit-field PLACE */ - uint16_t lang_flags; /* frontend-defined (C: LVALUE/MODIFIABLE/...) */ - uint16_t flags; /* kind:2, res:2, pinned:1, lvalue:1, spare:10 */ -} ApiSValue; /* 64 bytes, 8-byte aligned */ -``` - -`kind` is one of `SV_OPERAND`, `SV_CMP`, or `SV_ARITH`; `res` is one of -`RES_INHERENT`, `RES_LOCAL`, or `RES_FIXED_LOCAL`; `pinned` and `lvalue` are -booleans. These fit in six bits, leaving ten spare bits in the packed word. -`ApiDelayed` remains an off-node pooled extension for delayed compare/arithmetic -fusion only; it is not a language-fact or constant-value store. - -```c -typedef struct KitCgSlotInfo { - KitCgTypeId cg_type; /* read-only echo of the node's CG type, for queries */ - const void* lang_type; /* opaque to CG; lang/c stores a const Type* */ - uint16_t lang_flags; /* frontend-defined (C: LVALUE/MODIFIABLE/...) */ -} KitCgSlotInfo; -``` - -`kit_cg_slot_info` reads `{lang_type, lang_flags}` from `ApiSValue` and fills -`cg_type` from `stack[i].type` at query time. The C flags are frontend-private -bits and must fit in `uint16_t`; suggested C layout: - -``` -C_LVALUE /* a C lvalue (distinct from CG place-ness; e.g. a const lvalue) */ -C_MODIFIABLE /* modifiable lvalue (not const/array/func/void) */ -C_BITFIELD /* C-semantic bit-field marker (geometry lives on the CG place) */ -C_NULL_PTR_CONST/* integer 0 / null-pointer-constant */ -C_REGISTER /* `register` storage class — forbids `&` */ -``` - -`PcgLvAux.is_subobject` disappears: with eager places (4.3) the place operand -*is* the exact sub-object, so the to_rvalue struct-materialize heuristic -(`parse_expr.c:1040`) collapses to "leave the place as-is." - -### 4.2 Slot queries and retag - -```c -KitCgSlotInfo kit_cg_slot_info(KitCg*, uint32_t depth_from_top); /* depth 0 == top */ -void kit_cg_retag_top(KitCg*, const void* lang_type, uint16_t lang_flags); -void kit_cg_retag_at(KitCg*, uint32_t depth_from_top, - const void* lang_type, uint16_t lang_flags); -void kit_cg_set_top_flags(KitCg*, uint16_t set, uint16_t clear); -``` - -`kit_cg_stack_depth` already exists (`include/kit/cg.h:706`). Retag touches only -the inline lang fields — never the node's CG type (the producing op sets that). -C-type changes that carry no CG op (qualifier strip on a struct lvalue; the -deref retype) become a retag. Today's C queries — `pcg_top_type`, `pcg_top2_type`, -`pcg_top_is_lvalue`/`_modifiable_lvalue`/`_null_ptr_const`/`_bitfield`/ -`_register`, and the `pcg_retag_*` family — are replaced by direct -`kit_cg_slot_info` reads and `kit_cg_retag_*` calls at the parser sites (small -static inline accessors over the flag bits are fine; there is no `pcg` layer). - -**Producer rule:** an op that produces a *fresh* result slot (load, call result, -binop/cmp result, convert, addr, push_*) clears that slot's inline lang facts -(`lang_type=NULL, lang_flags=0`). The parser stamps the C facts immediately after -via `kit_cg_retag_top`. Structural ops (`dup`/`dup2`/`swap`/`rot3`/`drop`) -move/copy whole `ApiSValue` nodes, so lang facts move with the value. This is -exactly today's "emit op, then `pcg_retag_top`" idiom, with structural movement -done automatically inside CG. - -### 4.3 Eager places; folding behind the CG API (D4) - -The parser builds places naively and immediately; CG folds. One new op is -needed; the rest already exist. - -```c -/* PLACE -> PLACE: project to a sub-object at a known byte offset and field type. - * Folds the offset into the place operand (no record-layout lookup — the - * frontend already knows the offset from its own C layout). Errors if TOS is not - * a PLACE. Bit-field geometry, when present, is attached separately via - * kit_cg_field_bits. */ -void kit_cg_field_at(KitCg*, int64_t byte_offset, KitCgTypeId field_type); -``` - -Composition (all folding decided inside CG, as `kit_cg_field` already does): - -| C expression | CG op sequence | result | -|---|---|---| -| `x` (local) | `push_local x` | PLACE | -| `*p` | `deref 0` | PLACE | -| `s.f` | … `field_at(off_f, ty_f)` | PLACE | -| `p->f` | `deref 0; field_at(off_f, ty_f)` | PLACE | -| `a[i]` | decay `a`→`elem*` (`addr; bitcast`); push `i`; `elem 0` | PLACE | -| `a[i].f` | …`elem 0; field_at(off_f, ty_f)` | PLACE | -| `&e` | build place; `addr` | VALUE(ptr) | -| read of any lvalue `e` | build place; `load` | VALUE | - -`deref(0); field_at(off)` collapses inside CG to one -`OPK_INDIRECT(base=p, ofs=off)` memop; `elem; field_at` keeps the fused -`[base + i*scale + off]` form (the `kit_cg_field` INDIRECT branch already does -this). The "stride differs from access type" case (`arr[i].f`) is handled by the -natural `elem` (scales by `sizeof(elem)`) then `field_at` (adds `off_f`) -composition — so the C frontend no longer needs `kit_cg_elem_scaled` for it. - -Folding intelligence that currently lives in the parser moves into the CG place -ops, where it belongs: - -- constant offset → `OPK_INDIRECT` displacement (no extra add); -- dynamic index → fused `[base + i*scale + off]`; -- out-of-int32 displacement → materialize an explicit `base+ofs` pointer; -- the widening-signed-load hint (`KIT_CG_MEM_SEXT_LOAD`, today set in - `pcg_load`, `cg_adapter.c:540`) is decided inside `kit_cg_load` from the - access type; -- bit-field geometry rides the place via `kit_cg_field_bits` - (`src/cg/control.c:1253`), set by the frontend right after `field_at`. - -`pcg_materialize_lv_to_ptr`, `pcg_lv_to_memop_place`, `pcg_lv_member`, -`pcg_lv_subscript`, `pcg_decay_array`, and the whole `PcgLvAux` struct are -**deleted**. `kit_cg_field` (by index) and `kit_cg_elem`/`kit_cg_elem_scaled` -stay for toy/wasm. - -### 4.4 Fused load/store (D6) - -```c -void kit_cg_load(KitCg*, KitCgMemAccess); /* [place] -> [value] (exists) */ -void kit_cg_store(KitCg*, KitCgMemAccess); /* [place, value] -> [] (exists) */ -void kit_cg_store_keep(KitCg*, KitCgMemAccess); /* [place, value] -> [value] */ -``` - -`kit_cg_store` already is the `[place,value] -> []` primitive; the parser names -the discard intent through it (`store_void` == `store`, kept as the documented -spelling). `kit_cg_store_keep` adds the C assignment-expression value -preservation (`a = b`, `if ((x=f()))`) as one op, so the parser drops the -`dup`/`rot3`/`swap` choreography it does today (`cg_adapter.c:769-773`). CG keeps -the delayed-RHS-into-local fast path (`kit_cg_store`, `src/cg/memory.c:506-551`) -in both forms. - -### 4.5 Unevaluated mode: fold + types, zero emission (D3, D11) - -Unevaluated mode is a CG execution mode, not the full definition of C -"unevaluated operand" legality. It serves two implementation needs that share -one stack machine: - -- **Type/diagnostic-only parsing:** `sizeof` non-VLA operands, `_Generic` - controlling expressions and inactive associations, statically-dead expression - arms, `extern inline` bodies when the TU only needs diagnostics, and other - parse paths that must compute C result types/flags without emitting code. -- **Constant-value parsing:** ICE and static arithmetic-constant contexts where - the parser must parse the real expression grammar, suppress target emission, - then read the folded value from CG (§4.7). - -These uses share the same CG mechanics, but not the same C legality rules. CG -answers "what value/type would this expression stack produce if it can be folded"; -the parser's constant guard (§4.7) answers "is this syntax permitted in this C -constant-expression category." - -```c -void kit_cg_unevaluated_push(KitCg*); /* enter fold+type-only mode (nestable) */ -void kit_cg_unevaluated_pop(KitCg*); -``` - -Contract while `unevaluated > 0`: - -- **Maintained:** stack depth, inline lang facts, and the constant payload. - Every producing operation leaves a stack slot of the requested CG type when a - type is available; structural ops move/copy `ApiSValue` nodes and update the - constant payload in lockstep with the value stack. -- **Folded:** integer `push`/`cast`/`unop`/`binop`/`cmp` update the constant - payload using the CG fold core. A producing op that cannot fold pushes an - unknown constant payload, not an emitted value. Unknown is the value-level - "not a constant" signal; it is distinct from "illegal C ICE syntax." -- **Skipped:** every `CgTarget`/emission call, temp allocation, local allocation, - const-local store/load tracking, `-O0` ref accounting, and aggregate-place - validation that exists only to protect emitted operands. Handle-returning APIs - that the parser reaches while suppressed return benign dummy handles; those - handles exist only to keep one parse path shaped correctly and cannot be used - to recover emitted values later. -- **Early op rule:** every stack-mutating op must test unevaluated mode before - it forces operands into locals, allocates temps/locals, materializes delayed - values, calls target hooks, or updates emitted-code-only analyses. In this - path the op performs only its declared stack effect, inline-lang preservation - or clearing, constant-payload movement, and constant-payload folding/unknown - marking. - This matters for ops that are "structural" in API shape but not in the current - implementation: `dup` can emit a copy today, `deref`/`addr` can allocate a - pointer temp, and place/field/elem ops can materialize address arithmetic. -- **File-scope safe:** the mode touches no function state, so it works before a - function is open. This is required for file-scope array bounds, enum values, - bit-field widths, and static initializers. -- **Type-only tolerant:** pure type/diagnostic paths may use - `KIT_CG_TYPE_NONE` when no CG type is needed. Constant folding still requires - real CG integer types so CG knows width and signedness. - -`&&`, `||`, and `?:` need explicit handling; they are not solved by making -labels and branches no-op. In emitting mode they keep their normal lowering -through labels, branches, and temporaries. In unevaluated mode the parser's -control-shape helpers parse each syntactic operand once, compute the C result -type/flags from `KitCgSlotInfo`, and push a known or unknown result constant: - -- `&&` and `||` can fold from the known left operand without requiring the - short-circuited operand to be value-known, but the skipped operand is still - parsed for types and diagnostics under the correct C "not evaluated" guard. -- `?:` folds when the condition is known and the selected arm is known; the - unselected arm is still parsed for type merging and diagnostics under the - correct guard. When the condition is unknown, the result constant is unknown - even if both arms happen to have the same value, unless CG later grows a - deliberate meet rule. - -The important boundary is that the parser may keep semantic branches for C -control-shape constructs. What disappears is parser-owned emission bookkeeping: -`Parser.suppress_codegen`, `pcg_emit_enabled`, duplicate shadow-stack updates, -and forks whose only purpose was "emit this CG op or do not emit it." Parser-side -semantic side-effects in suppressed regions (VLA-size bookkeeping, `_Generic` -association collection, diagnostics) still run on the normal parse path. - -### 4.6 Calls (D8) - -C call arguments already flow through the CG stack: the C call path drives -`kit_cg_call(nargs, fn_type, attrs)`, which consumes the callee+args segment. -There is no second frontend arg list to remove. **No mark API is added.** The -parser stamps the result slot's C type via retag. - -### 4.7 Constant-expression evaluation on CG (D11) - -C integer-constant-expression evaluation stops being a parser subsystem. Today -`eval_const_int` → `cexpr_cond` (`parse_expr.c`) is a *complete second expression -grammar* (`cexpr_mul`/`add`/`shift`/`rel`/`eq`/`band`/`bxor`/`bor`/`land`/…) plus -a *second constant-arithmetic engine* (`cint_*`, with 128-bit lo/hi, casts, -conversions) — and it re-implements `offsetof` / `__builtin_constant_p` / -enum-constant handling separately from the real parser, a standing drift hazard. -All of it is **deleted in the ICE cutover phase**. - -"A real constant-value model" means CG no longer treats "constant" as a property -of `ApiSValue.operand == OPK_IMM`. The current `kit_cg_top_const_int` can only -answer for <=64-bit immediate operands. The new model is a stack-parallel -constant payload owned by `KitCg`: every CG stack slot has known/unknown state -plus integer bits, width, signedness, and result CG type. It can represent a -folded value even when normal emission would have produced a local, a wide value -lowered through a runtime helper, or no emitted value at all because unevaluated -mode is active. - -Proposed public shape: - -```c -typedef struct KitCgConstInt { - uint64_t lo; /* low bits, always truncated to width */ - uint64_t hi; /* high bits for width > 64 */ - uint16_t width; /* 1..128 for integer constants */ - uint8_t is_signed; - uint8_t known; -} KitCgConstInt; - -int kit_cg_top_const_int_ex(KitCg*, KitCgConstInt* out); -int kit_cg_top_const_i64(KitCg*, int64_t* out); /* convenience */ -void kit_cg_push_const_int(KitCg*, KitCgTypeId type, const KitCgConstInt* v); -``` - -The exact names can change, but the surface must be width-complete and must not -require frontends to inspect `ApiSValue` or know which operands are immediates. - -Constant payload policy: - -- **Always-on:** every `KitCg` maintains the payload for every value-stack slot, - regardless of frontend, optimization level, or unevaluated depth. This matches - CG's existing design pressure: the semantic layer already folds immediate - arithmetic, delayed arithmetic/compare, and local-constant loads for `-O0` - code quality, and C already queries constant-ness during normal expression - parsing. -- **Stack fact, not operand fact:** `kit_cg_top_const_int_ex` reads the payload, - not `ApiSValue.op.kind`. `OPK_IMM` becomes only one way to seed a known - payload. Values materialized as locals, delayed arith/cmp nodes, or wide - constants may still carry known payload bits. -- **Unknown is explicit:** fresh runtime values (`load` from unknown memory, - call results, atomics, volatile access, inline asm, address values, and - unsupported arithmetic such as i128 div/rem) produce an unknown payload. -- **Local-constant forwarding is separate:** `ApiSourceLocal.const_*` tracking - may seed the payload for an actual emitted load from a tracked local, but it is - still an emitting-mode optimization. The parser's `CConstGuard` decides whether - a local identifier is a legal C constant-expression operand; ordinary locals - do not become ICE operands just because CG can forward their current value. - -Fold coverage required before the ICE cutover: - -- **<=64-bit integers:** add/sub/mul, div/rem, bitwise ops, shifts, comparisons, - integer casts, and booleanization. Div/rem by zero must fail cleanly with a - diagnostic path the parser can report; it must not silently produce an - arbitrary constant. -- **128-bit integers:** move the existing parser `CConstInt` two-limb behavior - for add/sub/mul, bitwise ops, shifts, comparisons, and casts into CG so - existing static i128 initializer coverage remains supported. Compile-time - i128 div/rem is not required in this plan: emitted i128 div/rem lowers through - runtime helpers, and helper calls are dynamic code. CG marks i128 div/rem - payloads unknown until a true compile-time word-int division implementation is - added. - -The replacement parser helper is one pass over the normal grammar plus a guard: - -```c -CConstEval eval_const_int(Parser* p, SrcLoc loc, CConstKind kind) { - CConstGuardMark mark = c_const_guard_push(p, kind); - - kit_cg_unevaluated_push(p->cg); - parse_conditional(p); /* the real grammar */ - int ok_const = kit_cg_top_const_int_ex(p->cg, &value); - KitCgSlotInfo info = kit_cg_slot_info(p->cg, 0); - kit_cg_drop(p->cg); - kit_cg_unevaluated_pop(p->cg); - - int ok_guard = c_const_guard_pop(p, mark); - if (!ok_guard || !ok_const || !c_type_is_integer(info.lang_type)) - perr(loc, "integer constant expression required"); - return (CConstEval){ value, info.lang_type }; -} -``` - -The syntactic/semantic guard is mandatory. A foldable result is not the same -thing as a legal C integer constant expression: - -- `(0, 1)` folds to `1`, but the comma operator is not permitted in an ICE when - it is evaluated. -- `(x = 1, 1)` and `f(), 1` can have foldable tails, but assignment and function - call syntax still make the evaluated expression illegal. -- `sizeof(x++)` can be valid when the operand is non-VLA because the increment is - not evaluated; the guard must understand these not-evaluated exceptions rather - than reject tokens blindly. -- `enum` constants are ICE operands; ordinary objects are not, even if some - optimization or local-const tracking could know their value. -- Floating constants are only allowed in the narrow C cases such as the immediate - operand of a cast to integer type; the fold payload alone cannot encode that - syntactic permission. - -Implement the guard as parser mode/counter state, not a second expression -grammar. Normal parse routines call small note functions when active, for -example "comma operator in integer constant expression" or "assignment in static -initializer constant." The guard also has explicit "not evaluated" submodes for -`sizeof` non-VLA operands and short-circuited `&&`/`||`/`?:` arms. At the end, -both checks must pass: the guard says the syntax is legal for the requested C -constant category, and CG says the stack top has a known value of the required -type. - -Minimum guard event list for the ICE cutover: - -- evaluated comma operator; -- evaluated assignment and compound assignment; -- evaluated pre/post increment and decrement; -- evaluated function call, including builtin calls that are not specifically - permitted by the active constant category; -- identifier/reference classification: enum constants are legal ICE operands, - ordinary objects/functions are not, regardless of any CG local-const fact; -- floating constant use, including the narrow case where a floating constant is - immediately cast to an integer type and the broader cases where it is illegal - for ICE; -- pointer/address expressions, string-literal addresses, label addresses, and - relocation-like constants, which belong to the static-address category rather - than C ICE; -- `sizeof` / `_Alignof`: type-name forms are immediate constants; expression - forms enter a not-evaluated submode unless the operand is a VLA, where the VLA - size expression is evaluated and must be guarded normally; -- `_Generic`: the controlling expression is not evaluated; selected and - unselected associations follow the standard's type/diagnostic rules, and only - the selected expression contributes the resulting value category; -- `offsetof`: accepted through the parser's existing builtin path, but member - designator syntax must stay constrained to the builtin's rules; -- short-circuit `&&`/`||` and `?:`: skipped operands are parsed under the - correct not-evaluated submode; selected/evaluated operands are guarded - normally; -- unsupported arithmetic that CG marks unknown, including i128 div/rem in this - plan, reports "constant expression required" only after the guard has also - accepted the syntax. - -Keep the categories separate: - -- **C ICE:** integer result plus the C ICE guard; used by case labels, enum - values, bit-field widths, array bounds that require ICE, `alignas`, and - `_Static_assert`. -- **Static arithmetic constants:** can reuse the CG payload and normal parser - path, but may have different C legality from ICE. -- **Static address/relocation constants:** `&x + 4`, string-literal addresses, - and designated-init relocations remain in `parse_init.c` for this plan. A - later project can move them onto a CG constant-data model, but this plan does - not. - ---- - -## 5. What moves where - -| Today in `pcg` / `Parser` | Target owner | -|---|---| -| `PcgSlot.type` (`const Type*`) | inline `ApiSValue.lang_type` (opaque) | -| `PcgSlot.cg_id` | gone — read the node `type` via `kit_cg_slot_info` | -| `PcgSlot.flags` (C value flags) | inline `ApiSValue.lang_flags` | -| `Parser.cg_slot_stack` / `cg_type_sp` / `cg_type_cap` | gone — the CG stack is the typed stack | -| `PcgLvAux` (offset/scale/base_kind/bit_*) + `pcg_materialize_lv_to_ptr` / `pcg_lv_to_memop_place` / `pcg_lv_member` / `pcg_lv_subscript` / `pcg_decay_array` | gone — CG place ops (`deref`/`field_at`/`elem`/`addr`/`field_bits`) fold automatically | -| `Parser.suppress_codegen` + `pcg_emit_enabled` forks | CG unevaluated mode; parser keeps only semantic forks required by C control-shape constructs | -| `pcg_dup`/`swap`/`drop` (mirror onto shadow stack) | direct `kit_cg_dup`/`swap`/`drop` (inline lang facts move with `ApiSValue`; CG updates the constant payload) | -| assignment `dup`/`rot3`/`swap` sequences | `kit_cg_store` / `kit_cg_store_keep` | -| `cg_adapter.c` / `cg_adapter.h` / the `pcg_*` layer | **deleted** — parser drives `kit_cg_*` directly | -| `cexpr_*` grammar + `cint_*` constant-arith engine (`parse_expr.c`) | **deleted in the ICE cutover** — parser owns the legality guard; CG owns constant-value folding (§4.7) | -| C conversions, usual-arithmetic-conversions, binop/cmp/atomic op maps, lvalue-legality, null-ptr/register/bitfield C rules | plain helper *functions* the parser calls (no stack mechanics, no `pcg_` prefix) | - -After the cutover there is **no adapter layer**. The parser calls `kit_cg_*` -directly and stamps C facts with `kit_cg_retag_top`/`set_top_flags`. The -genuinely C-semantic operations survive as ordinary functions in the parse module -— the op-enum maps, the conversion lattice (today's `pcg_convert`), inc/dec, -call/atomic/intrinsic/inline-asm shaping — each driving `kit_cg_*` + retag -directly, with no parallel stack and no EA state. - ---- - -## 6. Op-by-op: inline lang + constant lockstep - -Every stack-mutating CG op maintains inline lang facts and the constant payload -so depths stay in sync. Grouped by effect: - -- **Producers (push a fresh slot, clear inline lang facts, set known/unknown - const):** - `push_int`, `push_float`, - `push_null`, `push_local`, `push_local_addr`, `push_symbol_addr`, - `push_label_addr`, `load`, `addr`, `deref`, `field`/`field_at`/`elem`/ - `elem_scaled` (consume then push a place — clear), `alloca`, `vararg_next`, - call result, `atomic_load`/`atomic_rmw`/`atomic_cmpxchg` result, - `intrinsic` result, overflow-builtin result, `inline_asm` outputs. -- **Retypers (1→1, keep depth):** `trunc`/`sext`/`zext`/`bitcast`/`fpext`/ - `fptrunc`/`int<->float`/`ptr<->int`, `int_unop`/`fp_unop`. Result inline lang - facts are cleared; parser retags. The constant payload is converted/folded - when CG can do so, otherwise marked unknown. -- **Combiners (N→1):** `int_binop`/`fp_binop`/`int_cmp`/`fp_cmp` (2→1), - `field`/`elem` (consume base/index), `store_keep` (2→1), `call`/`call_symbol` - (N→0/1), `va_copy` (2→0), `atomic_store` (2→0), `atomic_cmpxchg` (3→…). - Integer combiners fold the constant payload when operands are known and the op - is supported; otherwise the result payload is unknown. -- **Pure structural:** `dup` (copy top `ApiSValue` + constant payload), `dup2`, - `swap`, `rot3`, `drop`, `store`/`store_void` (2→0). These move inline lang - facts and constant payloads with no semantic change. -- **Consumers (→0):** `branch_true`/`branch_false`/`switch`/`computed_goto`, - `ret`. -- **Scope edges:** `api_scope_store_results`/`api_scope_push_results` move - carried `ApiSValue` results and their constant payloads when active. (C uses - only void scopes, so this is inert for C, but must be correct for any future - result-carrying frontend.) - -Implementation choke points: `api_push`/`api_pop` (`src/cg/value.c:161/187`) and -the structural ops `kit_cg_dup`/`dup2`/`swap`/`drop`/`rot3` -(`src/cg/memory.c:598-735`). `ApiSValue` copies carry inline lang facts -automatically, while producers must clear them explicitly. Routing -constant-payload movement through `api_push`/`api_pop` (which every -producer/consumer already calls) covers most ops automatically; the structural -ops and scope-edge movers need explicit handling. - ---- - -## 7. Cutover plan (clean break, gate = correctness + determinism) - -No dual-stack interim, no byte-identity gate. Each phase is either CG -infrastructure with no C frontend cutover, or a one-time frontend cutover that -removes the old path for that subsystem. Do not land a phase where the parser's -shadow stack and inline CG lang facts both authoritatively model live -expressions. - -1. **CG inline lang fields + slot queries.** Add the packed 64-byte `ApiSValue` - layout, `KitCgSlotInfo`, `kit_cg_slot_info`, `kit_cg_retag_top`/`_at`, and - `kit_cg_set_top_flags`; wire producer clearing, structural copies, and - scope-edge movers. Add focused CG API tests for producer clearing, - structural/scope movement, and query/retag behavior. No C frontend change - yet. -2. **CG place + store ops.** Add `kit_cg_field_at` and `kit_cg_store_keep`; move - the SEXT-load hint decision into `kit_cg_load`; confirm `deref`/`elem`/ - `field_at`/`field_bits` compose to fused operands without parser help. Add - targeted place tests for local/global load+store, `s.f`, `p->f`, `a[i]`, - `a[i].f`, bit-fields, displacement overflow, volatile/atomic accesses, - `&` of each lvalue form, assignment value preservation, `++`/`--`, and - compound assignment. -3. **CG unevaluated + constant payload substrate.** Add - `kit_cg_unevaluated_push`/`pop`, the stack-side constant payload, the - width-complete constant query/push API, <=64 div/rem folding, and the required - 128-bit bit-pattern folds excluding div/rem. No C frontend cutover yet. Add CG - API tests for file-scope/no-function use, unknown constants including i128 - div/rem, dummy handles, no target/local calls, nested unevaluated mode, and - inline-lang/constant lockstep under suppression. -4. **C frontend one-stack cutover.** Use inline lang fields for C; replace - parser stack reads/writes with `kit_cg_slot_info` + retag; replace EA folding - with eager places; replace assignment choreography with `store`/`store_keep`; - move `Parser.suppress_codegen` uses to CG unevaluated mode for type-only - paths; delete `cg_slot_stack`, `cg_type_sp`, `cg_type_cap`, `PcgSlot`, - `PcgLvAux`, and parser-owned effective-address folding. The existing - `cexpr_*`/`cint_*` subsystem may remain only because ICE is a later subsystem; - it must not keep or resurrect a live expression shadow stack. -5. **Adapter deletion and helper relocation.** Remove `cg_adapter.c`/`.h` and - the `pcg_` prefix. Move the C-semantic helpers (op maps, conversion lattice, - inc/dec, atomic/intrinsic/inline-asm shaping) into ordinary parse-module - functions that call `kit_cg_*` directly. Update `doc/FRONTENDS.md` to describe - the durable parser → CG contract. -6. **ICE cutover.** Add the parser `CConstGuard`; reimplement `eval_const_int` - over the normal parse path under unevaluated mode (§4.7); delete the - `cexpr_*` grammar and `cint_*` engine. Re-test every ICE context: case labels, - array sizes (block + file scope), enum values, bit-field widths, `alignas`, - `_Static_assert`, designated-init indices, invalid ICE syntax that still - folds, `sizeof` not-evaluated exceptions, short-circuit exceptions, i128 - constant expressions, i128 div/rem rejection/unknown behavior, and - divide-by-zero diagnostics for supported div/rem widths. - -Use targeted gates and redirect output to files per `AGENTS.md`: - -- Inline-lang/constant substrate: `make test-cg-api test-toy`. -- C frontend cutover: `make test-cg-api test-parse test-pp test-toy`. -- Codegen-sensitive place/store changes: `make test-cg-api test-isa - test-aa64-inline` plus targeted native smoke cases for x64/aa64/rv64 as - appropriate. -- Cross/portability: run `make test-cross TARGET=<selector> DEPTH=smoke` after - the one-stack C cutover is green on native targets, and a broader portability - pass before final handoff. `make test-port` is not a per-phase gate. -- Determinism: same input → identical output across repeated runs; byte identity - against the old implementation is not required. - ---- - -## 8. Requirements checklist (the agent verifies against this) - -- R1 Single liveness-authoritative value stack; `api_push`/`api_pop` unchanged as - lifetime hooks. (§3.1) -- R2 No free expression handles; only handles + stack depth escape. (§3.2) -- R3 Read/retag lang type+flags at top/top2/depth-N. (§4.2) -- R4 CG never dereferences the lang payload; it is `const void*`. (§3.4) -- R5 No backend (`CgTarget`/NDT/NT/MC) change; place ops lower through existing - contracts. (§3.5) -- R6 `ApiSValue` is 64 bytes with inline `const void* lang_type`, - `uint16_t lang_flags`, and a packed `uint16_t flags` word. (§3.6, D10) -- R7 No global state; inline lang facts live in `ApiSValue`, while the constant - payload and unevaluated counter hang off `KitCg`. (§3.7) -- R8 No language side allocation or enable call; constant payload is always-on - for every `KitCg`. (§3.8, §3.10) -- R9 Suppression: type+flags+constant facts maintained, zero emission, zero - temp/local alloc, no forced CG-type lowering for type-only paths; parser loses - emission-bookkeeping forks, not semantic forks required by C control-shape - constructs. Every op checks unevaluated mode before local/temp allocation, - delayed materialization, target calls, or emitted-code-only analysis updates. - (§4.5) -- R10 Eager places; all EA folding (const offset, dynamic index, displacement - overflow, SEXT-load hint) decided inside CG; `PcgLvAux` deleted. (§4.3, D4) -- R11 Bit-field geometry rides the CG place (`field_bits`); C keeps a semantic - `C_BITFIELD` flag for `sizeof`/`&` rejection. (§4.1, §4.3) -- R12 C value flags (MODIFIABLE / NULL_PTR_CONST / REGISTER / LVALUE) fit in - `uint16_t lang_flags`. (§4.1) -- R13 `store` / `store_keep` replace assignment dup/rot3/swap. (§4.4) -- R14 No call-mark API; C args stay on the CG stack. (§4.6, D8) -- R15 ICE evaluation moves to CG's constant-value model plus a parser-owned - syntactic/semantic C constant guard. (§4.7, D11) -- R16 Gate = correctness + determinism, not byte-identity. (D5) -- R17 `cg_adapter.c`/`.h` and the `pcg_` prefix deleted; parser drives `kit_cg_*` - directly; C-semantic helpers survive as plain functions. (§5, D7) -- R18 inc/dec, compound assignment, va_arg, atomics, intrinsics, inline-asm, - calls, returns all re-expressed on the new place ops + slot queries. (§6) -- R19 `&&`/`||`/`?:` have explicit unevaluated fold paths; they do not rely on - no-op labels/branches to leave the right stack result. (§4.5) -- R20 `eval_const_int` is reimplemented over the normal parse path with - `CConstGuard`; the `cexpr_*` grammar and `cint_*` engine are deleted; all ICE - contexts and invalid foldable-but-not-ICE cases are re-tested. (§4.7, §7.6) -- R21 CG exposes an always-on, width-complete integer constant payload API; - frontends do not inspect `ApiSValue` or `OPK_IMM` to determine constant-ness, - and local-const forwarding is not C ICE legality. (§4.7, D12) -- R22 <=64-bit div/rem folding moves into CG with clean divide-by-zero failure; - 128-bit add/sub/mul/bitwise/shift/compare/cast folding moves into CG, while - i128 div/rem returns unknown until a true compile-time word-int division - implementation exists. (§4.7, D13) -- R23 Cutovers are one-time subsystem moves with old paths removed; no dual-stack - or backcompat phase is introduced. (§7) - ---- - -## 9. Expected payoff - -CG API self time is ~19% of compile, half of it the stack/memory family, so the -directly visible ceiling of this cleanup is ~9-10% of compile. The real win is -larger only insofar as deleting the parser's duplicate stack + EA bookkeeping -also removes frontend work (one push/pop instead of two; no `PcgLvAux` -maintenance; no `if (emit)` branching). A realistic target is **5-10% total -compile improvement**, plus the qualitative payoff: one expression stack, no -fragile choreography, "parse expression, drive CG," and a clean home for future -frontend instrumentation — with single-pass liveness and temp reclaim preserved. - ---- - -## 10. Non-goals - -- No AST pass; no handle-based expression IR. -- No collapse of parser / CG / NDT / NT / MC into one module. -- No C frontend type dependency from `src/cg`. -- No attempt to close the whole ~2.8× tcc compile-speed gap with this refactor. diff --git a/doc/plan/CG-TYPE-DEBUG-SPLIT.md b/doc/plan/CG-TYPE-DEBUG-SPLIT.md @@ -1,373 +0,0 @@ -# CG type/debug split: move source spelling off the operational type id - -**Goal.** Make `KitCgTypeId` a storage/ABI/operational identity *only*, and carry -source-facing spelling (primitive sign+name, typedef names, enum underlying -spelling) on a separate, optional debug channel consumed at the points where -debug info is actually produced. The result removes pervasive alias-resolution -from the CG hot path: with no transparent wrapper kinds left in the operational -lattice, `api_unalias_type`, the `storage_id`-vs-`id` split, and the -per-backend `if (ALIAS) recurse; if (SOURCE_BASE) recurse` boilerplate all -disappear. - -This resolves [CG-TYPES.md](CG-TYPES.md) §10 Open Question 1 -("Should `KIT_CG_TYPE_ALIAS` remain a true public kind, or should source aliases -move entirely into debug/C-backend metadata?") — extended to cover -`KIT_CG_TYPE_SOURCE_BASE`, which was added after that question was written. - -This is a **clean-break redesign, no backcompat**, in the spirit of CG-TYPES.md. -The capability that `KIT_CG_TYPE_SOURCE_BASE` delivers (faithful signed/unsigned/ -char debug, plus enum enumerators) is **preserved** — only its placement moves. - ---- - -## 1. Current State - -The public type lattice (`include/kit/cg.h`, `src/cg/type.c`) contains two -*transparent wrapper* kinds whose only purpose is debug-info spelling: - -- `KIT_CG_TYPE_ALIAS` — a named typedef over a base type (`kit_cg_type_alias`). - **Dead for every real frontend**: the only callers are `test/api/cg_type_test.c`. - The C frontend never creates aliases (so C typedef names are already absent - from DWARF today). -- `KIT_CG_TYPE_SOURCE_BASE` — a source-facing scalar spelling (name + - `KitCgDebugEncoding`) over a width-only storage builtin (`kit_cg_type_source_base`, - added by commit `244f1039`). The C frontend lowers **every** integer/char - scalar to one of these (`lang/c/type/type.c`, `type_cg_scalar`), so in practice - almost every integer id flowing through CG is a wrapper. - -Both kinds are *transparent to storage*: every ABI/codegen/predicate site strips -them to the terminal builtin via `api_unalias_type` before use. - -### What the wrappers buy us (measured) - -- The **only** consumer of the exact wrapper kind is debug info: `api_debug_type` - (`src/cg/debug.c`) maps `ALIAS → DW_TAG_typedef` and - `SOURCE_BASE → DW_TAG_base_type + DW_ATE_*`. Reached at three live sites — the - subprogram type at `kit_cg_func_begin_attrs` and locals/params in - `api_debug_emit_source_locals` (`src/cg/session.c`). -- `src/opt/` has **zero** references to either kind. The optimizer only ever - sees unaliased storage types. -- Nothing in ABI/calling-convention/operation lowering reads the wrapper: - signedness of operations comes from op selection (`SDIV`/`UDIV`, `sext`/`zext`) - and from `KIT_CG_ABI_SIGNEXT`/`ZEROEXT` attrs; load signedness comes from the - `KIT_CG_MEM_SOURCE_SIGNED` flag. The encoding on a source-base is a debug - vehicle, never consulted for codegen. -- **Struct/union member types do not reach DWARF at all today**: `api_debug_type`'s - record case calls `debug_type_record_begin` then immediately - `debug_type_record_end`, never `debug_type_record_field`. So source-base - spelling on field types — and on interned func-sig param types — is consumed by - nothing. -- Enums *do* now thread enumerators (commit `244f1039`): `api_debug_type` emits - `DW_TAG_enumeration_type` with `DW_TAG_enumerator` children over the enum's - underlying type. The underlying type may itself be a source-base. - -### What it costs - -- `api_unalias_type` is called pervasively: ~33 per-op hot-path sites - (`src/cg/value.c`, `arith.c`, `call.c`, `memory.c`), ~9 more inside `type.c` - itself (the float/i128 predicates, predicate-bits fill, complete/sized/valid, - enum-base validation, `same_storage`), plus the recursive alias-stripping - prologue duplicated across **every ABI backend** (`src/abi/abi.c`, - `abi_rv64.c`, `abi_sysv_x64.c`, `abi_aapcs64.c`, `abi_win64_x64.c`, - `src/arch/wasm/abi.c`) and ~10 functions in `type.c`/`fold.c` - (`cg_type_pointee`, `cg_type_func_ret_id`, `kit_cg_type_int_width/float_width`, - `api_int_like_width`, `api_type_is_bool`, `cg_type_complete_id`, - `cg_type_sized_id`, `cg_type_same_storage_rec`). -- A per-type-entry caching subsystem that exists largely to amortize the above: - `storage_id` (the unalias terminal, filled once by `api_type_info_fill`), - `pred_bits`/`pred_valid` (predicate bitset computed on the *unaliased* type). -- A two-headed type-info surface (`kit_cg_type_view` identity prefix vs - `kit_cg_type_info` full snapshot) plus `storage_id`, - `kit_cg_type_resolve_alias`, and `kit_cg_type_same_storage`, all there to - manage the alias↔storage split. `kit_cg_type_is_void` is a two-hop through - `storage_id`. - -### Recent movement (reconciled) - -Three same-day changes touched this area and the plan is written against the -*current* working tree: - -- `64afd07c` (clean cutover) — direct-index ids; void is always the void - builtin; `KIT_CG_TYPE_NONE` is invalid/absent only. -- `244f1039` — *added* `SOURCE_BASE` and threaded enum enumerators into debug - info. This plan reverses the **placement** of that spelling (onto a debug - channel) without dropping the **capability**. -- Uncommitted working tree — consolidated the unalias caching: deleted the - redundant `CgApiTypeKind`/`CgApiType.kind` (entry kind is now `e->cg.kind`), - folded the old `unaliased`/`unalias_filled` fields into a single - `info.storage_id` filled by `api_type_info_fill`, and made `kit_cg_type_info` - delegate to `kit_cg_type_view`. This *cleaned up* the cost but did not remove - the unaliasing — the pervasiveness above is fully intact, and `storage_id` is - now load-bearing. - -This consolidation is evidence for the split, and makes it cleaner to land: the -unalias walk is now concentrated in one place (`api_type_info_fill`) instead of -two. - ---- - -## 2. Problems - -### 2.1 One id, two roles - -`KitCgTypeId` is overloaded to be both the storage/ABI/operational identity (what -every op needs; width-only for integers) and the source spelling for debug (name -+ signedness). To make one id serve both, transparent wrappers carry role-2 data -but must be seen through for every role-1 use. That see-through is the pervasive -`api_unalias_type`. - -### 2.2 Distinct spellings force distinct operational ids - -Two C types (`int`, `unsigned int`) share storage `i32`, so a spelling cannot -hang off the shared storage id — each spelling needs its own id. As long as those -ids are *operational* and handed to ops, ops must map them back to storage. The -only way to remove unaliasing is for ops to receive storage ids and for spelling -to travel separately. - -### 2.3 The payoff is tiny and shrinking - -The exact wrapper kind is consumed at three debug sites; `ALIAS` is dead; struct -members (the bulk of would-be spelled types) are not emitted at all. The cost — -pervasive unalias + a caching subsystem + per-backend boilerplate + a split type -API — is paid everywhere to feed a narrow, cold consumer. - ---- - -## 3. Design Decisions - -| # | Decision | Choice | -|---|---|---| -| E1 | Operational lattice | `KitCgTypeId` kinds shrink to `VOID, BOOL, INT(width), FLOAT(width), PTR, ARRAY, FUNC, RECORD, ENUM, VARARG_STATE`. No `ALIAS`, no `SOURCE_BASE`. | -| E2 | Storage identity | `storage_id == id` for every operational type. `api_unalias_type` is deleted; `kit_cg_type_resolve_alias` becomes identity; `kit_cg_type_is_void` is a single kind check. | -| E3 | Debug spelling home | A separate, optional `KitCgDebugType` handle, built only when debug info is requested, consumed only at debug emission. | -| E4 | Debug graph reuse | `KitCgDebugType` references operational ids for structural/nominal pieces (records, enums, funcs) via `kit_cg_debug_of_type`; the frontend overrides only leaves (primitive spellings) and inserts typedefs. No duplication of record/enum/func debug emission. | -| E5 | Attachment | Decl-bearing APIs gain an optional `KitCgDebugType` field; `0` (`NONE`) means "derive the default from the operational type" — preserving today's behavior for frontends that do not set it. | -| E6 | Interning purity | Debug types never enter interned operational descriptors (`KitCgFuncSig`/`KitCgFuncParam`), so spelling never splits structural interning. | -| E7 | Capability preservation | Faithful signed/unsigned/char debug and enum enumerators (from `244f1039`) are preserved via the debug channel; DWARF output is byte-identical. | -| E8 | Non-C frontends | Toy and Wasm pass `0` and rely on the existing default derivation (which they already use today — `void`/`bool`/`float` are never wrapped). No change required. | - ---- - -## 4. New Public Shape - -### 4.1 Debug-type handle and builders - -Thin public wrappers over the existing `src/debug/` DebugType producer (which -already has base/typedef/ptr/array/record/enum/func builders). Available only on -a `KitCg` with debug enabled; no-ops returning `NONE` otherwise. - -```c -typedef uint32_t KitCgDebugType; /* 0 = derive from the operational type */ -#define KIT_CG_DEBUG_TYPE_NONE 0u - -/* A named primitive leaf (DW_TAG_base_type): "int"/signed/4, etc. */ -KIT_API KitCgDebugType kit_cg_debug_base(KitCg*, KitSym name, - KitCgDebugEncoding, uint32_t bytes); -/* A named typedef (DW_TAG_typedef) over another debug type. */ -KIT_API KitCgDebugType kit_cg_debug_typedef(KitCg*, KitSym name, KitCgDebugType); -/* Structural debug types mirroring operational composition. */ -KIT_API KitCgDebugType kit_cg_debug_ptr(KitCg*, KitCgDebugType pointee); -KIT_API KitCgDebugType kit_cg_debug_array(KitCg*, KitCgDebugType elem, - uint64_t count); -/* Derive a debug type from an operational type: records/enums/funcs (with their - * existing emission) and default-named scalars. The leaf-override + typedef - * builders above are layered on top of this. */ -KIT_API KitCgDebugType kit_cg_debug_of_type(KitCg*, KitCgTypeId); -``` - -Composition examples (frontend side): - -- `int*` → `debug_ptr(debug_base("int", SIGNED, 4))` -- `unsigned` → `debug_base("unsigned int", UNSIGNED, 4)` -- `struct S*` → `debug_ptr(debug_of_type(S))` -- `double` → `debug_of_type(f64)` (default name is correct) -- `typedef T int; T x;` → `debug_typedef("T", debug_base("int", SIGNED, 4))` - (this *gains* typedef DIEs, which the current design silently drops) - -`KitCgDebugEncoding` is unchanged — it is already the clean debug-only enum; it -moves from `kit_cg_type_source_base` to `kit_cg_debug_base`. - -### 4.2 Attachment points - -Debug-only fields; `0` ⇒ derive default from the operational type. - -```c -typedef struct KitCgLocalAttrs { - KitSym name; - uint32_t align; - uint32_t flags; - KitCgDebugType debug_type; /* NEW: 0 = derive from the local's type */ -} KitCgLocalAttrs; /* covers both kit_cg_local and kit_cg_param */ - -typedef struct KitCgFuncAttrs { - /* ... existing ... */ - KitCgDebugType debug_type; /* NEW: the subprogram's type DIE; 0 = derive */ -} KitCgFuncAttrs; /* NOT in the interned KitCgFuncSig (E6) */ - -typedef struct KitCgObjectAttrs { - /* ... existing ... */ - KitCgDebugType debug_type; /* NEW: global object's type DIE; 0 = derive */ -} KitCgObjectAttrs; -``` - -`api_debug_type(KitCgTypeId)` becomes a resolver over `KitCgDebugType`: when a -decl site supplies a debug type, emit that; when it supplies `NONE`, fall back to -the operational-type walk that exists today. Record member types (when DWARF -members are eventually emitted) will carry `KitCgDebugType` on `KitCgField` at -that time; nothing depends on it now. - -### 4.3 Operational API simplifications - -- Delete `kit_cg_type_alias`, `kit_cg_type_source_base`, and their accessors - (`kit_cg_type_alias_name/base`, `kit_cg_type_source_base_name/storage/encoding`). -- Delete `KIT_CG_TYPE_ALIAS`, `KIT_CG_TYPE_SOURCE_BASE` from `KitCgTypeKind`. -- `kit_cg_type_resolve_alias` returns its argument (identity). Keep as a trivial - documented accessor or remove; `kit_cg_type_same_storage` reduces to structural - storage comparison with no alias step. -- `KitCgTypeInfo.storage_id` always equals `id`. Decision point (Open Q1): - keep the field as a documented `== id` identity (zero churn to the struct/ABI) - or remove it. - ---- - -## 5. Internal Architecture - -### 5.1 Type table - -`CgApiType` loses the wrapper-specific state once the kinds are gone: - -- `api_type_info_fill` (`src/cg/type.c`) drops its `while (ALIAS||SOURCE_BASE)` - walk; `info.storage_id = id`. -- `api_unalias_type` is deleted. Its ~33 hot-path callers and ~9 in-`type.c` - callers use the id directly. -- `pred_bits`/`pred_valid` no longer need an unalias step — the kind is now - exact, so `cg_type_is_int`/`_float`/`_ptr`/etc. read the kind directly. The - bitset cache can be kept (one indexed load) or dropped (kind lookup is already - one load); prefer dropping it to shed state unless a measurement says keep. -- The `ALIAS`/`SOURCE_BASE` recursion deletes from `cg_type_pointee`, - `cg_type_func_ret_id`/`_result_id`/`_param_id`, `kit_cg_type_int_width`, - `kit_cg_type_float_width`, `api_int_like_width`, `api_type_is_bool`, - `cg_type_complete_id`, `cg_type_sized_id`, `cg_type_same_storage_rec`. - -### 5.2 ABI layer - -The `if (t->kind == KIT_CG_TYPE_ALIAS) recurse; if (... SOURCE_BASE) recurse` -prologue deletes from `abi_cg_type_info` (`src/abi/abi.c`) and from every -per-arch classifier (`abi_rv64.c`, `abi_sysv_x64.c`, `abi_aapcs64.c`, -`abi_win64_x64.c`, `src/arch/wasm/abi.c`). ABI queries see only real storage -kinds. - -### 5.3 Debug producer - -`src/debug/` is untouched. `src/cg/debug.c` changes from "walk the operational -`CgType` and special-case `ALIAS`/`SOURCE_BASE`" to "walk a `KitCgDebugType` -graph; for an operational-type reference, dispatch into the existing -record/enum/func/ptr/array emission." Enum enumerators stay on the operational -enum entry (nothing unaliases an enum, so they cost no pervasiveness); only the -enum's *underlying spelling* comes from the debug channel. - ---- - -## 6. Frontend Impact - -### 6.1 C frontend (the only affected frontend) - -- `type_cg_scalar` (`lang/c/type/type.c`) stops wrapping in source-base; returns - the bare builtin. `t->cg_id` becomes a pure storage id, used unchanged at all - operational sites (memaccess, convert, params, locals, fields, calls). -- Add a parallel lazy `type_cg_debug(Type*) → KitCgDebugType`, cached as - `t->dbg_id`, structurally identical to `type_cg_lower`. The existing 13-entry - `kScalarDbg` table now drives `kit_cg_debug_base` instead of - `kit_cg_type_source_base`. The two lowerings can share one traversal (they - recurse in lockstep) if that is cleaner. -- At decl sites — `kit_cg_local`, `kit_cg_param`, `kit_cg_func_begin_attrs`, - global object definition — set `attrs.debug_type = type_cg_debug(t)`. -- Enum lowering keeps passing enumerators to `kit_cg_type_enum` (operational); - the enum's debug underlying spelling rides on `type_cg_debug`. - -### 6.2 Toy / Wasm - -No change. They pass `0` for `debug_type` and get today's default derivation. - ---- - -## 7. Implementation Plan - -### Phase 1: Add the debug channel, migrate C behind it - -- Add `KitCgDebugType`, the builders, and the decl-attr fields. -- Make `api_debug_type` a resolver: supplied `KitCgDebugType` wins; `NONE` - derives the default from the operational type (today's behavior). -- Add `type_cg_debug` to the C frontend; set `debug_type` at decl sites. -- `SOURCE_BASE`/`ALIAS` still exist at this point; the goal is for debug to stop - *reading* them. Verify DWARF byte-identity (`make test-debug test-dwarf`, - `kit cc -g` spot checks for `DW_ATE_unsigned`/`signed_char`/`unsigned_char` and - enumerator DIEs). - -### Phase 2: Delete the wrappers and all unaliasing - -Once debug no longer reads the wrappers, cut over in one clean break: - -- Stop the C frontend creating source-base ids (`type_cg_scalar` returns the - builtin). -- Delete `kit_cg_type_alias`/`source_base` + accessors, the two `KitCgTypeKind` - values, `api_unalias_type`, and the recursion/prologues listed in §5. -- Collapse `storage_id` to `id` (or remove the field — Open Q1). -- Simplify `kit_cg_type_resolve_alias`/`is_void`/`same_storage`. - -### Phase 3: Tests - -- Rewrite `test/api/cg_type_test.c` alias/source-base cases as `KitCgDebugType` - builder + emission tests. -- Add a debug-channel test: a local of `unsigned`, a `signed char`, an enum, and - a typedef each produce the expected DWARF DIE. - ---- - -## 8. Validation - -- `make test-debug test-dwarf` — primary gate; DWARF must be byte-identical for - the signed/unsigned/char/enum cases `244f1039` introduced. -- `make test-cg-api` — type API contract. -- `make test-parse test-toy test-opt` — frontend + mid-end unaffected. -- `make test-smoke-x64 test-smoke-rv64` — end-to-end with `-g`. -- Ecosystem / byte-identity gate (see [PERF.md](PERF.md) and the ecosystem - harness): object output must be unchanged; DWARF must match. - -Scenario coverage: `int`/`unsigned`/`char`/`signed char`/`unsigned char` locals -and params; enums with enumerators; typedef DIEs (new, previously dropped); -function pointers; records (members still opaque — confirm no regression); -`va_list`; non-C frontends emit unchanged debug via the default path. - ---- - -## 9. Acceptance Criteria - -- `KitCgTypeKind` has no `ALIAS`/`SOURCE_BASE`; `KitCgTypeId` is storage/ABI only. -- `api_unalias_type` and the per-backend alias/source-base prologues are gone. -- `storage_id == id` everywhere (field kept as identity or removed). -- No CG hot-path op resolves aliases; predicates read the exact kind. -- DWARF output for the `244f1039` capabilities is byte-identical; typedef DIEs - are additionally available. -- Toy and Wasm are unchanged. -- No global state, no VLAs; debug-type state hangs off `KitCg`. - ---- - -## 10. Open Questions - -1. **Keep or remove `KitCgTypeInfo.storage_id`?** It becomes vestigial (`== id`). - Keeping it as a documented identity is zero-churn and leaves - `kit_cg_type_resolve_alias` meaningful as a trivial accessor; removing it is - cleaner but touches the public struct and every reader. Prefer **remove** for - a true clean break, consistent with CG-TYPES.md's no-backcompat stance. -2. **Should enum enumerators also move to the debug channel?** They are - debug-only in principle, but they already live on the operational enum entry - and cost no pervasiveness (nothing unaliases an enum). Prefer **leave on the - operational enum** to minimize churn; revisit only if member-type debug work - wants a uniform debug-graph representation. -3. **`kit_cg_debug_of_type` vs a fully parallel debug graph.** The plan leans on - `of_type` to reuse existing record/enum/func emission. If a future need arises - to spell those differently from their operational shape (rare), the builder - set would grow `kit_cg_debug_struct/union/enum/func`. Prefer the minimal - `of_type`-based set until such a need is concrete. diff --git a/doc/plan/CG-TYPES.md b/doc/plan/CG-TYPES.md @@ -1,591 +0,0 @@ -# CG type and type-id system clean cutover - -**Goal.** Replace the current `KitCgTypeId` implementation and public type API -with a simpler, explicit, and ABI-correct type table. Keep the good part: a -small compiler-owned integer handle that can be stored cheaply throughout IR, -ABI records, frontends, and backends. Change what that handle means, how void -and aliases are represented, where layout facts are computed, and how nominal -records are completed. - -This is a **clean-break rewrite, no backcompat**. The old constructors, query -semantics, `KIT_CG_TYPE_NONE`-as-void convention, segmented id encoding, and -frontend workarounds are removed as the cutover lands. There is no compatibility -adapter and no byte-identity gate. - ---- - -## 1. Current State - -The public API uses an opaque `uint32_t KitCgTypeId`: - -- `0` is `KIT_CG_TYPE_NONE`. -- Builtins are encoded in a reserved id segment. -- User types are stored in a segmented vector with a biased segment id. -- Pointers, arrays, and functions are structurally interned. -- Aliases, records, and enums allocate fresh ids. - -Internally, `src/cg/type.c` owns `CgApiState`: - -- `CgType builtins[KIT_CG_BUILTIN_COUNT]` -- `CgApiTypes types` -- structural indexes for pointer, array, and function types -- per-entry caches for alias resolution, predicates, type class, and ABI layout - -The C frontend lowers `Type*` to `KitCgTypeId` lazily and caches ids on C type -nodes and parser stack slots. Incomplete records are handled outside CG: a -temporarily incomplete record lowers to `void`, and record-field/function-param -pointer lowering collapses pointees to `void*` so layout can proceed without a -true incomplete nominal record id. - -The ABI layer reads `CgType` entries and caches target layout/classification -facts by type id. Some storage facts live eagerly on `CgType` (`size`, `align`); -some target facts live in `TargetABI`; record layout is currently computed by -the CG type constructor and re-exposed by the ABI cache. - ---- - -## 2. Problems - -### 2.1 `NONE` Has Too Many Meanings - -`KIT_CG_TYPE_NONE` currently means: - -- invalid type id -- absent function result, i.e. a void return -- statement scope or no value in some control APIs - -There is also a real void builtin. Every caller must remember which API treats -void as a real type and which treats `NONE` as absence. That is a correctness -hazard and a permanent source of branchy special cases. - -### 2.2 Alias Semantics Are Implicit - -Some queries inspect the exact id. Others recurse through aliases. Examples: - -- integer and float width follow aliases -- predicates follow aliases through a cached descriptor -- pointer/array/function field queries are mostly exact-kind queries -- many internal call sites remember to call `api_unalias_type` - -The result is not one type model; it is a collection of local conventions. - -### 2.3 ID Decode Complexity Does Not Buy Enough - -The segmented builtin/user encoding makes every id lookup decode segment bits. -That helped keep builtin ids stable and user pointers stable, but the hot path -now contains extra decode logic and special builtin handling. A direct table -index keeps the same public handle shape with less machinery. - -### 2.4 Layout Authority Is Split - -`CgType` eagerly stores `size` and `align`, while `TargetABI` is supposed to be -the single authority for target-dependent layout and calling convention facts. -This is especially fragile for: - -- 32-bit ABIs and non-default pointer alignment -- Windows and other ABI-specific scalar/aggregate rules -- bit-field layout differences -- `va_list` and other target-shaped builtins - -### 2.5 Incomplete Nominal Records Are Not Modeled - -The C frontend needs true identities for incomplete records so these are legal: - -```c -struct S; -struct T { struct S *p; }; -struct S { int x; }; -``` - -Today CG has no incomplete record declaration/completion API, so the frontend -works around it by erasing some pointee identity to `void*` while lowering -record fields and function parameters. That preserves layout but loses type -identity and pushes a CG responsibility into the frontend. - -### 2.6 Queries Are Too Fragmented - -The public API exposes many one-off queries (`kind`, `size`, `align`, -`ptr_pointee`, `array_elem`, `func_param`, `record_field`, etc.). That is easy -to call but expensive and ambiguous at scale: each query chooses exact vs -resolved behavior separately, and hot call sites decode the same id repeatedly. - ---- - -## 3. Design Decisions - -| # | Decision | Choice | -|---|---|---| -| D1 | Public handle shape | Keep `typedef uint32_t KitCgTypeId`; `0` is invalid/none only | -| D2 | Void representation | Void is always the real void builtin type id; `NONE` never means void | -| D3 | ID layout | Builtin ids are `1..KIT_CG_BUILTIN_COUNT`; user ids are `KIT_CG_BUILTIN_COUNT + 1 + index` | -| D4 | Cross-compiler stability | Type ids are stable only within one `KitCompiler`; never serialized as semantic identities | -| D5 | Structural interning | Pointer, array, and function constructors intern by exact constructor shape | -| D6 | Nominal identity | Records, enums, and aliases keep fresh source-facing ids | -| D7 | Alias behavior | Exact queries inspect aliases; storage/semantic queries resolve aliases explicitly | -| D8 | Function void result | Function result type is always a valid type id; use the void builtin for no value | -| D9 | Layout authority | `TargetABI` computes all size/align/scalar layout facts; type entries only cache ABI results | -| D10 | Records | Records are nominal and two-phase: declare first, complete once | -| D11 | Incomplete use | Pointers to incomplete records are legal; sizing, arrays, fields, by-value params/results, locals, and memory accesses require complete/sized types | -| D12 | Caches | Caches live on the owning type entry or ABI context and are filled only after the facts they depend on are final | -| D13 | Implementation style | C11, no VLAs, no global type state; everything hangs off `KitCompiler`/`TargetABI` | - ---- - -## 4. New Public Shape - -### 4.1 IDs and Builtins - -The id remains opaque, but implementation decode is direct: - -```c -#define KIT_CG_TYPE_NONE 0u - -/* implementation rule, not a public promise beyond nonzero/stable per compiler: - * builtin id = 1 + KitCgBuiltinType - * user id = 1 + KIT_CG_BUILTIN_COUNT + user_index - */ -``` - -The public builtin query becomes singular. The whole-table query is removed -unless a real caller needs it after the cutover. - -```c -KIT_API KitCgTypeId kit_cg_type_builtin(KitCompiler*, KitCgBuiltinType); -``` - -### 4.2 Function Signatures - -`KitCgFuncResult.type` is always valid. A void function uses the void builtin. - -```c -typedef struct KitCgFuncResult { - KitCgTypeId type; /* valid; void builtin means no value */ - KitCgAbiAttrs attrs; -} KitCgFuncResult; - -typedef struct KitCgFuncSig { - KitCgFuncResult result; - const KitCgFuncParam* params; - uint32_t nparams; - KitCgCallConv call_conv; - bool abi_variadic; -} KitCgFuncSig; -``` - -The helper predicate is explicit: - -```c -KIT_API int kit_cg_type_is_void(KitCompiler*, KitCgTypeId); -KIT_API int kit_cg_func_result_has_value(KitCompiler*, KitCgFuncResult); -``` - -No caller tests `result.type == KIT_CG_TYPE_NONE` after this cutover. - -### 4.3 Exact and Storage Queries - -Exact identity and storage identity are separate operations. - -```c -KIT_API KitCgTypeId kit_cg_type_resolve_alias(KitCompiler*, KitCgTypeId); -KIT_API int kit_cg_type_same_storage(KitCompiler*, KitCgTypeId, - KitCgTypeId); -``` - -Rules: - -- `kit_cg_type_kind` is exact. An alias id reports `KIT_CG_TYPE_ALIAS`. -- `kit_cg_type_storage_info` resolves aliases before reporting layout/scalar - facts. -- `kit_cg_type_same_storage` recursively compares alias-resolved storage - structure where needed, so `ptr(alias(i32))` and `ptr(i32)` can be storage - equivalent even if their exact ids differ. - -### 4.4 Descriptor Query - -Add one structured query for hot callers and keep narrow helpers only where they -remain clearly useful. - -```c -typedef enum KitCgTypeFlag { - KIT_CG_TYPEF_COMPLETE = 1u << 0, - KIT_CG_TYPEF_SIZED = 1u << 1, - KIT_CG_TYPEF_BUILTIN = 1u << 2, - KIT_CG_TYPEF_NOMINAL = 1u << 3, -} KitCgTypeFlag; - -typedef enum KitCgStorageKind { - KIT_CG_STORAGE_VOID, - KIT_CG_STORAGE_BOOL, - KIT_CG_STORAGE_INT, - KIT_CG_STORAGE_FLOAT, - KIT_CG_STORAGE_PTR, - KIT_CG_STORAGE_AGGREGATE, -} KitCgStorageKind; - -typedef struct KitCgTypeLayout { - uint64_t size; - uint32_t align; - uint16_t scalar_width; - uint8_t storage_kind; /* KitCgStorageKind */ - uint8_t valid; -} KitCgTypeLayout; - -typedef struct KitCgTypeInfo { - KitCgTypeId id; - KitCgTypeId storage_id; /* alias-resolved id, or id */ - KitCgTypeKind kind; /* exact kind */ - uint32_t flags; /* KitCgTypeFlag */ - KitCgTypeLayout layout; /* valid only for sized/storage queries */ -} KitCgTypeInfo; - -KIT_API KitStatus kit_cg_type_info(KitCompiler*, KitCgTypeId, - KitCgTypeInfo*); -``` - -This descriptor is intentionally shallow. Shape-specific data still comes from -shape-specific APIs or internal entry access: - -- pointer: pointee + address space -- array: element + count -- function: result/params/call convention -- record: field count and field descriptors -- enum: base and values -- alias: name and base - -### 4.5 Record Declaration and Completion - -Records are true nominal objects. - -```c -KIT_API KitCgTypeId kit_cg_type_record_decl(KitCompiler*, KitSym tag, - int is_union); - -KIT_API KitStatus kit_cg_type_record_complete(KitCompiler*, KitCgTypeId record, - const KitCgRecordDesc*); - -KIT_API int kit_cg_type_is_complete(KitCompiler*, KitCgTypeId); -``` - -Convenience construction stays possible, but it is just declare + complete: - -```c -KIT_API KitCgTypeId kit_cg_type_record(KitCompiler*, const KitCgRecordDesc*); -``` - -Completion rules: - -- A record can be completed exactly once. -- Field types must be valid. -- A by-value field must be sized and complete. -- A pointer field may point to an incomplete record. -- A record cannot contain itself by value. -- Layout caches for the record are invalid until completion succeeds. - -The C frontend no longer lowers record-context pointers to `void*`. - -### 4.6 Array Counts - -The public array count remains `uint64_t`. The structural key must use the full -64-bit count or reject out-of-range counts with a documented status. Do not -truncate the key. Prefer full-width support unless an object/backend limit -requires a specific diagnostic. - -```c -KIT_API KitCgTypeId kit_cg_type_array(KitCompiler*, KitCgTypeId elem, - uint64_t count); -``` - ---- - -## 5. Internal Architecture - -### 5.1 Type Table - -Use one compiler-owned table. Builtins occupy the first nonzero ids; user entries -append after them. - -```c -typedef struct CgTypeEntry { - KitCgTypeKind kind; - uint32_t flags; - - KitCgTypeId id; - KitCgTypeId storage_id; /* alias terminal once known */ - - uint8_t pred_bits; - uint8_t pred_valid; - uint8_t class_bits; - uint8_t class_valid; - uint8_t layout_valid; - - KitCgTypeLayout layout; - - union { - CgBuiltinType builtin; - CgPtrType ptr; - CgArrayType array; - CgFuncType func; - CgRecordType record; - CgEnumType enum_; - CgAliasType alias; - }; -} CgTypeEntry; - -typedef struct CgTypeTable { - Heap* heap; - CgTypeEntries entries; - CgPtrMap ptr_index; - CgArrayMap array_index; - CgFuncMap func_index; -} CgTypeTable; -``` - -The table can still use a segmented vector if stable entry pointers are useful, -but indexes are direct: `id - 1` maps to an entry. Hash indexes store ids, not -entry pointers, so the implementation is free to move entries later if that -becomes desirable. - -### 5.2 Structural Indexes - -Pointer key: - -```c -{ pointee_id, address_space } -``` - -Array key: - -```c -{ elem_id, uint64_t count } -``` - -Function key: - -```c -{ result type+attrs, params type+attrs[], call_conv, abi_variadic } -``` - -The key is exact-id based. Alias preservation is therefore possible for source -facing APIs and generated C/debug output. Storage equivalence is a separate -query and must not depend on exact id equality. - -### 5.3 Layout Cache - -`CgTypeEntry.layout` is a cache of the ABI-computed layout, not an independent -source of truth. - -Flow: - -```text -kit_cg_type_size/align/info - -> cg_type_require_layout - -> TargetABI layout hook / shared ABI layout - -> cache on CgTypeEntry after success -``` - -Builtins can be initialized with target facts, but they still follow the same -layout contract. Target-shaped builtins such as `vararg_state` are computed -through the ABI layer. - -Record completion asks the ABI/layout authority to compute source-facing record -layout before marking the record complete. If a future ABI has different -bit-field layout rules, it plugs in at this boundary rather than after the -record is already committed. - -### 5.4 Predicates and Classes - -Predicate bits and codegen class bits remain valuable, but they should be -computed from one storage descriptor: - -```text -exact id -> resolve alias -> storage/layout descriptor -> pred/class bits -``` - -That avoids one-off predicate functions each re-decoding the id and each -choosing its own alias behavior. - ---- - -## 6. Frontend Impact - -### 6.1 C Frontend - -The C frontend should map source types to CG types as follows: - -- C scalar type -> builtin storage id -- C pointer type -> pointer to the exact lowered pointee id -- C function type -> function id with a real void result type when appropriate -- C record declaration -> `kit_cg_type_record_decl` -- C record definition -> `kit_cg_type_record_complete` -- C enum -> enum nominal id with integer base -- C typedef -> source alias id only if CG/debug/C-backend output needs the - source-facing name; storage semantics must use alias resolution - -The current incomplete-record workaround is deleted. The C type pool can cache -the declared record id as soon as the tag exists, then complete the same id when -the definition is parsed. - -### 6.2 Toy Frontend - -Toy mostly uses CG storage types directly. It should switch to: - -- real void result type in function signatures -- explicit storage/equivalence queries where it currently relies on exact - `kit_cg_type_kind` -- record construction through the new declare/complete convenience API - -### 6.3 C Backend and Debug Info - -The C backend and debug producer are the main consumers that care about exact -source-facing ids. They should use exact queries for names and aliases, and -storage queries for codegen legality. - ---- - -## 7. Implementation Plan - -### Phase 1: Lock the New Contract in Tests - -Add focused `test/api/cg_type_test.c` cases that fail under the old model: - -- void function result uses the void builtin, never `NONE` -- invalid ids stay invalid and never masquerade as void -- alias exact kind is `KIT_CG_TYPE_ALIAS` -- alias storage info reports the base storage kind/layout -- `kit_cg_type_same_storage` succeeds for alias-equivalent storage shapes -- record declaration creates an incomplete nominal id -- pointer to incomplete record is legal -- array of incomplete record is rejected -- record completion fills fields/layout and cannot run twice -- recursive by-pointer records are legal -- recursive by-value records are rejected -- full-width array count keys do not collide - -### Phase 2: Replace the Type Table - -Introduce the new direct-index table behind `src/cg/type.c`: - -- builtins are entries `1..KIT_CG_BUILTIN_COUNT` -- user entries append after builtins -- `cg_type_get` becomes direct index validation -- structural maps store ids -- remove segment/bias decode helpers - -Do this before converting all callers, accepting compile failures in the -working tree during the phase. There is no compatibility shim. - -### Phase 3: Cut Over Public API and Call Sites - -Update `include/kit/cg.h` and every caller: - -- replace `kit_cg_builtin_type_id` with `kit_cg_type_builtin` -- replace `result.type == KIT_CG_TYPE_NONE` void checks with void-builtin checks -- update scope/block APIs that currently use `NONE` for statement/no-result so - absence remains explicit and cannot be confused with the void type -- replace ambiguous one-off queries with exact or storage queries -- update function, intrinsic, call, local, memory, and data APIs to require real - type ids where a type is required - -### Phase 4: Move Layout Authority to ABI - -Make `TargetABI` the only layout authority: - -- scalar/builtin layout through ABI helpers -- pointer layout through target spec read by ABI -- array layout from element ABI layout -- record layout during completion through an ABI record-layout hook -- `kit_cg_type_size/align/info` read cached ABI layout, computing on demand - -Delete independent eager `CgType.size` / `CgType.align` ownership. A type entry -may cache layout but does not define it. - -### Phase 5: Add Two-Phase Records and Convert C Lowering - -Convert the C frontend record path: - -- create/get CG record id at tag declaration time -- complete that same id at record definition time -- preserve pointer-to-incomplete-record identity -- delete `TYPE_CG_RECORD_FIELD` pointee erasure to `void*` -- delete incomplete-record temporary lowering to `void` -- keep source diagnostics in the C frontend for illegal incomplete uses - -### Phase 6: Delete Old Caches and Helpers - -Remove now-redundant pieces: - -- segment id constants and decode helpers -- recursive alias helper conventions in random query functions -- `api_unalias_type` call-site scatter where replaced by descriptor queries -- duplicated eager layout caches in ABI/CG if the entry layout cache covers the - use case -- C frontend record memo workarounds that exist only because CG lacked - incomplete nominal records - ---- - -## 8. Validation - -Use targeted runs during the cutover: - -- `make test-cg-api` -- `make test-parse` -- `make test-toy` -- `make test-opt` -- `make test-aa64-inline` -- targeted smoke tests for one native 64-bit target and one non-aa64 backend - touched by layout or call classification - -Broaden only after the API and frontend are compiling cleanly. - -Important scenario coverage: - -- C recursive structs by pointer -- incomplete record misuse diagnostics -- function pointers involving records/enums/aliases -- varargs and `va_list` -- bit-fields, packed records, explicit/max alignment -- enum integer bases -- generated C backend output for aliases/records -- debug info typedef/record naming - ---- - -## 9. Acceptance Criteria - -- `KIT_CG_TYPE_NONE` means invalid/absent only; no function result or value type - uses it to mean void. -- Builtin and user ids decode by direct table index, with no segment/bias scheme. -- Pointer, array, and function interning remains O(1)-average and collision-safe. -- Aliases have explicit exact-vs-storage semantics. -- Records can be declared incomplete and completed once. -- The C frontend no longer erases record-context pointer pointees to `void*`. -- Size/align/scalar facts come from the ABI layout path. -- Existing type hot paths still have cached predicate/class/layout bytes, but - those caches derive from the new descriptor model. -- No global state, no VLAs, and all state hangs off `KitCompiler` or - `TargetABI`. - ---- - -## 10. Open Questions - -1. Should `KIT_CG_TYPE_ALIAS` remain a true public kind, or should source aliases - move entirely into debug/C-backend metadata after the cutover? The plan above - keeps alias ids because they already serve source-facing consumers, but the - storage semantics do not require them. - **Resolved → move them out.** See [CG-TYPE-DEBUG-SPLIT.md](CG-TYPE-DEBUG-SPLIT.md): - `KitCgTypeId` becomes storage/ABI-only and source spelling (now including the - `KIT_CG_TYPE_SOURCE_BASE` added since this question was written) moves onto a - separate optional debug-type channel, deleting `api_unalias_type` and the - per-backend unalias boilerplate. -2. Should function types reject incomplete by-value records at construction, or - permit declaration-like signatures and reject only when defining/calling? - Prefer rejection unless a frontend has a concrete declaration use case. -3. Should `kit_cg_type_info` include shallow shape facts, or stay layout-only - with shape-specific accessors? Prefer shallow layout-only initially to keep - the public struct stable. -4. Should array counts larger than target addressable object size be rejected by - the type constructor or by object/data/local users? Prefer constructor - rejection if every backend agrees on the same maximum; otherwise diagnose at - the first sized-object use. diff --git a/doc/plan/DEBUG.md b/doc/plan/DEBUG.md @@ -1,48 +1,40 @@ -# Debugger, Debug Info, and Profiling (planned work) +# Debugger and Debug Info (planned work) This roadmap consolidates the remaining work across the interactive JIT -debugger (`kit dbg`), the DWARF producer/consumer, and the not-yet-built -sampling profiler (`kit prof`). Designs live one level up: +debugger (`kit dbg`) and the DWARF producer/consumer. Designs live one level up: [../DBG.md](../DBG.md) covers the `KitDebugSession` architecture, the `KitDbgOs` host vtable, software breakpoints, and displaced single-step; [../DWARF.md](../DWARF.md) covers the producer pipeline and the -`kit_dwarf_*` consumer surface. This document is forward-looking: it states -the baseline only as a starting point, then enumerates the open gaps, their -rationale, and the next steps. Shipped items are noted as "done (baseline)". +`kit_dwarf_*` consumer surface. The host-native sampling profiler (`kit prof`), +which reuses this debugger's signal infrastructure, has its own roadmap in +[PROF.md](PROF.md). This document is forward-looking: it states the baseline +only as a starting point, then enumerates the open gaps, their rationale, and +the next steps. Shipped items are noted as "done (baseline)". ## Baseline -What already works and is not re-planned here: - -- The JIT debugger session (`src/dbg/session.c`, `bp.c`, `mem.c`, `step.c`, - `displaced.c`) is real: worker thread, park/unpark, fault classification, - refcounted software breakpoints with a read overlay, guarded memory access, - displaced single-step, and the `STEP_LINE` / `NEXT_LINE` / `STEP_OUT` state - machines. Done (baseline). -- Displaced-step lifter implementations exist for all three backends. The - lifter is arch-neutral (`dbg_displaced_prepare` drives an `ArchDbgOps` - vtable); each backend ships `build_displaced_shim` + `decode_insn`: - `src/arch/aa64/dbg.c`, `src/arch/x64/dbg.c` (INT3 + RIP-relative/rel8/rel32 - fixups), `src/arch/rv64/dbg.c` (EBREAK + AUIPC/JAL/branch fixups). Done - (baseline). -- Session-level integration of displaced-step is complete only on aarch64 - hosts. The x64 and rv64 backends decode and fix up instructions correctly in - isolation, but their end-to-end REPL session loop (fault classification, trap - PC normalization, register marshalling) has not been validated; closing that - gap is §1 below. Done (baseline) for aarch64 only. -- The POSIX host adapter (`driver/env/posix_dbg.c`, macOS/Linux/FreeBSD - ucontext marshalling) and the Windows host adapter (`driver/env/windows.c`: - `g_dbg_os_win` with `AddVectoredExceptionHandler`, `Set`/`GetThreadContext` - interrupt path, `__try`/`__except` guarded copy) are both wired. Done - (baseline). -- The DWARF producer (`src/debug/debug*.c`: abbrev/form/emit, line program, - type DIEs, `.eh_frame` CFI) and consumer (`src/debug/dwarf_*.c`: - open/line/die/type/loc/query/cfi) are implemented and tested via `test-dwarf` - / `test-debug`, including multi-input `kit_jit_view`, - `kit_dwarf_line_to_addr` suffix matching, and graceful "no debug info for - this frame" degradation. Done (baseline). - -The sections below are the work that remains. +What already works and is not re-planned here (the sections below are the work +that remains): + +- **JIT debugger session** (`src/dbg/session.c`, `bp.c`, `mem.c`, `step.c`, + `displaced.c`): worker thread, park/unpark, fault classification, refcounted + software breakpoints with a read overlay, guarded memory access, displaced + single-step, and the `STEP_LINE` / `NEXT_LINE` / `STEP_OUT` state machines. +- **Displaced-step lifters** for all three backends, behind the arch-neutral + `ArchDbgOps` vtable (`dbg_displaced_prepare`): `src/arch/aa64/dbg.c`, + `src/arch/x64/dbg.c` (INT3 + RIP-relative/rel8/rel32), `src/arch/rv64/dbg.c` + (EBREAK + AUIPC/JAL/branch). But end-to-end session integration is validated + on **aarch64 only**; x64/rv64 decode correctly in isolation yet their REPL + session loop is unproven — that gap is §1 below. +- **Host adapters**: the POSIX adapter (`driver/env/posix_dbg.c`, + macOS/Linux/FreeBSD ucontext marshalling) and the Windows adapter + (`driver/env/windows.c`: `g_dbg_os_win`, VEH interrupt path, `__try`/`__except` + guarded copy) are both wired. +- **DWARF producer + consumer** (`src/debug/debug*.c`, `src/debug/dwarf_*.c`): + abbrev/form/emit, line program, type DIEs, `.eh_frame` CFI on the producer; + open/line/die/type/loc/query/cfi on the consumer. Tested via `test-dwarf` / + `test-debug`, including multi-input `kit_jit_view`, `kit_dwarf_line_to_addr` + suffix matching, and graceful "no debug info for this frame" degradation. ## 1. Bring x64 / rv64 debug sessions to full parity @@ -176,82 +168,10 @@ Explicitly deferred until a client needs them (carried forward, not planned): - Split DWARF / `.dwo`, `.debug_pubnames`, and any `LSDA` / exception tables (C has none). -## 7. Sampling profiler — `kit prof` (not yet built) - -A statistical CPU profiler that reuses the debugger's host signal -infrastructure. Nothing exists yet: no `prof` subcommand in `driver/main.c`, -no `src/dbg/prof.c`, no `on_sample` field on `KitDbgSignalOps`. Design -intent: SIGPROF fires on the worker, the handler walks the frame-pointer chain -into a pre-allocated ring buffer and returns **without parking** — the one -property that keeps sampling cheap and guest timing undisturbed — and PCs are -symbolicated after the guest exits. - -> **Scope — this is the *sampling* profiler, deliberately on the host-native -> substrate.** It is one of two complementary profilers: this one measures real -> wall/CPU time by statistically sampling natively-run kit code; the -> *deterministic* instruction-counting profiler (callgrind-style — exact, -> reproducible, any guest arch) lives on the emulator substrate -> ([INSTRUMENT.md](INSTRUMENT.md) §9, §12.1). `kit prof` is **not** being -> rebased onto the emulator: the two share a back-end (symbolication + folded / -> flat output), not a collector. See INSTRUMENT.md §9 for the perf-vs-callgrind -> split and the one-core-two-collectors factoring. - -Public API (`include/kit.h`): - -- Add `on_sample(void* session, void* ucontext)` to `KitDbgSignalOps` (NULL = - ignore SIGPROF); it receives the raw `ucontext_t*`, not a marshalled frame, - because it extracts only PC and FP on the hot path. -- Declare `KitProfBuf` (fixed-capacity sample ring: `pcs[PROF_MAX_DEPTH]` per - sample, `count`/`cap`/`dropped`) and `KitProfWriter` (post-run symbolication - callback vtable), plus `kit_dbg_session_prof_attach(session, buf)` (before - `session_call`) and `kit_dbg_session_prof_collect(session, buf, writer)`. - -Library (`src/dbg/prof.c`, freestanding C11): - -- `dbg_fp_walk(ucontext, sample)`: frame-pointer walk via - `dbg_os->guarded_copy` for every dereference; terminate on NULL / misaligned - / non-advancing FP or `PROF_MAX_DEPTH`. The three frame layouts are identical - (`[FP]` = saved FP, `[FP+8]` = saved LR/return addr); FP is x29 / rbp / - s0(x8). WHY: no DWARF or symbol lookup on the signal path — raw PCs only. -- `on_sample` body: capacity check, walk, append or bump `dropped` (non-atomic; - the single worker makes that safe). `prof_attach` / `prof_collect` bodies; - `prof_collect` symbolicates each PC via `kit_jit_addr_to_sym` + - `kit_dwarf_addr_to_line`, dispatching to the writer (may allocate freely). - -Host adapter (`driver/env/posix_dbg.c` and `windows.c`): - -- Add SIGPROF to the POSIX handler's signal set with an early-return path: - `if (signo == SIGPROF && on_sample) { on_sample(...); return; }` — no - park/unpark. SIGPROF joins the blocked cohort so it does not recurse. Timer - arming (`setitimer(ITIMER_PROF)`) and thread targeting stay driver-side and - do **not** belong behind `KitDbgOs`. -- Decide the Windows sampling mechanism (no SIGPROF): a periodic - `SuspendThread` + `GetThreadContext` sampler thread is the natural analog of - the VEH interrupt path. WHY: the SIGPROF design has no direct Windows - equivalent, so this is a genuine open design question, not a port. - -Driver (`driver/cmd/prof.c`, wired into the multi-call dispatch in -`driver/main.c`): - -- Flags `--rate` (default 1ms), `--depth` (64), `--cap` (1M), `--output` - (`prof.folded`), `--no-folded`, `--no-flat`. Input handling mirrors - `kit run`, with `-g` forced on so symbolication always has DWARF. -- Arm the timer before `session_call`, disarm after, then `prof_collect`. - Emit folded stacks (sorted + RLE for `flamegraph.pl`) plus a flat - self%/cumul% report to stdout and a dropped-sample warning. - -Tests: `test/smoke/prof_hello` (assert `prof.folded` is non-empty and `main` -appears), `test/dbg/fp_walk_*` (canned frame chains per arch, assert the PC -sequence and termination), and `test/dbg/prof_buf_overflow` (fill to capacity, -assert `dropped` increments and `count` caps). - -Profiler follow-ons (deferred): per-thread timers via -`timer_create(CLOCK_THREAD_CPUTIME_ID)` + `SIGEV_THREAD_ID` for multi-thread -guests; an `ITIMER_REAL` wall-clock mode for I/O-bound programs; allocation -profiling via a conditional breakpoint on the allocator; SpeedScope / pprof -output. +The host-native sampling profiler (`kit prof`) that reuses this debugger's +signal infrastructure has moved to its own roadmap: see [PROF.md](PROF.md). -## 8. Bigger follow-ons (cross-cutting) +## 7. Bigger follow-ons (cross-cutting) - **Watchpoints**, once `CGTarget` can express them without an ISA-specific debug-register API. All breakpoints are software today; watchpoints need @@ -259,4 +179,5 @@ output. - **Multi-threaded guests.** The session assumes one worker. Concurrent guest threads require widening `KitDbgOs` with thread enumeration and per-tid stop/event slots; this is also the prerequisite for reliable per-thread - profiler timer delivery in §7. Out of scope until a concrete need lands. + sampling-profiler timer delivery ([PROF.md](PROF.md) §6). Out of scope until a + concrete need lands. diff --git a/doc/plan/FRONTEND-SHAPE.md b/doc/plan/FRONTEND-SHAPE.md @@ -1,433 +0,0 @@ -# Frontend shared tagged cell - -**Goal.** Define the end-state shape for the C front half around one shared, -compact cell used by the lexer, preprocessor, and parser input ring. This is a -design document, not a phasing plan. - -The value-stack redesign lives in `doc/plan/CG-STACK-API.md`: the C parser drives -`KitCg` directly, `KitCg` owns the single liveness-authoritative value stack, C -type/value facts live in the CG language sidecar, and constant values live in the -CG constant payload. This document deliberately stops at the front-half cell and -the handoff from parser primary to CG stack. - -In one line: **one compact lexeme cell is shared by lexer, preprocessor, and the -parser input ring; parser-only semantic tagging happens only on parser-owned -cells; the preprocessor remains independently drainable; the CG stack remains the -only live expression stack.** - -## Non-Goals - -- No parser-owned shadow value stack. `Parser.cg_slot_stack`, `PcgSlot`, and the - `pcg_*` adapter are removed by `CG-STACK-API.md`, not replaced here. -- No `ApiSValue` stored inside the frontend cell. A tagged cell is a short-lived - lowering seed, not a durable value. -- No scalar int-bitmask type lane in lex/pp cells. If scalar classification needs - caching after measurement, the cache belongs in the CG language sidecar or a - C type cache keyed by `const Type*`, not in every token. -- No C parser dependency from the lexer or preprocessor headers. Shared cell - helpers use `void*` and integer ids; C-specific wrappers live under `lang/c`. -- No global state. Source registries, macro tables, macro-disabled state, parser - rings, and CG side payloads hang off `Pp`, `Parser`, or `KitCg`. - -## Ownership Boundaries - -1. **Lexer and pp own the lexeme contract.** A lexeme cell is complete enough for - macro expansion, directives, `-E` serialization, diagnostics, and replay. It - carries lazy `LocRef`/`TextRef`, eagerly interned identifiers, punctuator codes - in `aux`, and no C parser state. -2. **The pp is independently drainable.** `cpp`, `cc -E`, and `KIT_PP_DRAIN` - pull cells into caller-owned storage with no parser attached. The pp reads and - writes only the lexeme view. -3. **The parser owns semantic tagging.** The parser may tag only cells in its own - input ring or short-lived scratch. It must never tag macro-definition storage, - directive buffers, or pp-owned source buffers. -4. **Macro replay copies before tagging.** Macro bodies are immutable lexeme-cell - arrays. When replay emits a token, pp copies one lexeme into the caller's - output cell, applies loc/first-token flag overrides, and returns. The parser - may then tag that caller-owned copy. -5. **`KitCg` owns values.** Once a primary is accepted, the parser lowers through - `kit_cg_*`, stamps the CG top with `kit_cg_retag_*`, and advances. The cell can - carry debug/lowering facts until the next `advance`, but expression lifetime is - the CG stack slot. - -## Shared Cell - -The durable stream/storage unit stays the current lean-token shape: 32 bytes on -LP64, trivially copyable, and without heap ownership. If the implementation -renames the type, today's `Tok` should become an alias of this cell rather than a -second representation. - -The shared header must not include C parser or CG internals. - -```c -typedef enum CFeKind { - /* 0..0x11ff remain TokKind / pp-internal token kinds. */ - CFE_SEM_FIRST = 0x8000, - CFE_SEM_VALUE = CFE_SEM_FIRST, /* parser-only: current token lowered as value */ - CFE_SEM_PLACE, /* parser-only: current token lowered as place */ -} CFeKind; - -typedef struct CFeSem { - const void* lang_type; /* C parser: const Type*; opaque to lex/pp */ - u32 cg_type; /* KitCgTypeId value, represented without cg includes */ - u32 lang_flags; /* same C value flags passed to kit_cg_retag_* */ -} CFeSem; - -typedef struct CFeCell { - u16 kind; /* TokKind in lexeme view; CFeKind >= CFE_SEM_FIRST after tagging */ - u16 flags; /* lexeme: TF_*; semantic: parser-private flags */ - u32 aux; /* lexeme: Sym/Punct/PP_PARAM; semantic: small discriminator */ - LocRef loc; /* survives both views; line/col materialize lazily through Pp */ - union { - TextRef text; /* lexeme spelling ref; lexer/pp/drain view */ - CFeSem sem; /* parser-only semantic seed; pp never reads this */ - } u; -} CFeCell; - -_Static_assert(sizeof(CFeSem) <= sizeof(TextRef), - "semantic overlay must not widen the lexeme cell"); -_Static_assert(sizeof(CFeCell) == 32, - "cell must stay the compact token-stream storage unit"); -``` - -Cell rules: - -- `kind < CFE_SEM_FIRST` means lexeme view. All pp helpers assert this when - reading a cell. -- `kind >= CFE_SEM_FIRST` means parser-private semantic view. Such a cell is - illegal to feed back into pp, macro storage, directive evaluation, or replay. -- The lexeme and semantic views do not co-live. The parser must finish all - spelling, suffix, and encoding reads before overwriting `u.text`. -- `loc` survives tagging. Diagnostics and debug locations can materialize full - `SrcLoc` lazily through `Pp`. -- Wide integer constants, float payloads, aggregate initializers, static - relocation constants, and expression lifetime never live in the cell. They go - to the CG constant payload or the existing initializer/static-data machinery. - -## Lexer Contract - -The lexer writes one caller-owned cell and returns nothing by value: - -```c -void lex_next(Lexer*, CFeCell* out); -``` - -Lexeme shape: - -- `TOK_IDENT`: `aux = Sym`, `u.text = TEXT_SRC` for exact spelling. Identifiers - remain eagerly interned because `Sym` is the macro, keyword, and binding key. -- `TOK_PUNCT`, `TOK_PP_HASH`, `TOK_PP_PASTE`: `aux = Punct` or character code. - Canonical punctuators use `TEXT_NONE`; digraphs may use `TEXT_SRC`. -- `TOK_NUM`, `TOK_FLT`, `TOK_STR`, `TOK_CHR`, `TOK_HEADER`: `u.text` is a source - span or synthetic symbol. Literal suffix and encoding facts stay in `flags` - until the parser decodes the literal. -- `TOK_NEWLINE`: produced only on raw/preprocessor-drain paths that need it for - text reconstruction. Parser-feed mode uses `TF_AT_BOL` plus directive state. -- `loc`: `(file_id, byte_off)` into the pp source registry. The lexer does not - stamp line/column per token. -- `TF_NO_EXPAND` is clear on lexer-produced identifiers. Only the pp sets it - when an identifier token becomes unavailable for macro expansion. - -Materialization remains pp-owned because only `Pp` retains source buffers, splice -tables, and `#line` overlays: - -```c -SrcLoc pp_materialize_loc(Pp*, LocRef loc); -KitSlice pp_text_slice(Pp*, const CFeCell* cell); -Sym pp_text_intern(Pp*, const CFeCell* cell); -int pp_text_eq_cstr(Pp*, const CFeCell* cell, const char* s); -``` - -## Preprocessor Contract - -The pp exposes two drainable pulls, both out-pointer only: - -```c -void pp_next_parse(Pp*, CFeCell* out); /* expanded, directives consumed, - * non-directive newlines suppressed */ -void pp_next_raw(Pp*, CFeCell* out); /* expanded, directives consumed, - * TOK_NEWLINE preserved for -E/cpp */ -void pp_emit_text(Pp*, Writer* out); /* drains pp_next_raw */ -``` - -Internal pp storage is lexeme-only: - -```c -typedef struct CFeReplay { - const CFeCell* cells; - u32 n; - Macro* disabled_owner; /* non-NULL while rescanning this macro replacement */ - LocRef loc_override; - u16 first_flags_or; -} CFeReplay; -``` - -Requirements: - -- Macro bodies, directive-line buffers, replay buffers, and argument slices store - `CFeCell[]` in lexeme view only. -- A live lexer source should write directly into the caller's output cell where - possible. A replay source copies one immutable lexeme into the caller's output - cell. -- Directive processing, `#if` expression evaluation, paste, stringize, - `__LINE__`, `__FILE__`, `_Pragma`, and `#include` consume and produce lexeme - cells only. -- Synthesized tokens use `TEXT_SYM` or `TEXT_NONE`; they never point at storage - whose lifetime is shorter than `pp_free`. -- The parser-feed path must not require newline tokens. `TF_AT_BOL` plus - directive state is the line-boundary contract; raw `-E` keeps `TOK_NEWLINE` for - text reconstruction. - -## Macro Expansion Availability - -Use the simpler cpplib-style availability model instead of a per-token hideset -table: a macro is disabled while its own replacement-list frame is being -rescanned, and an identifier token has one permanent unavailable bit. - -The unavailable bit is `TF_NO_EXPAND` in lexeme view. It means "this identifier -token must not be macro-expanded if it is seen again." One bit is sufficient -because the token's spelling names at most one macro at the time it is tested; -the bit does not need to encode a set of macro names. - -Suggested pp-owned state: - -```c -typedef struct Macro { - Sym name; - u16 disabled_depth; - /* existing macro definition fields... */ -} Macro; - -typedef struct TokSrc { - u8 kind; - CFeCell* cells; - u32 i; - u32 n; - Macro* disabled_owner; /* non-NULL for macro replacement-list frames */ - /* existing source/replay fields... */ -} TokSrc; -``` - -Frame rules: - -- Pushing a macro replacement-list frame increments - `disabled_owner->disabled_depth`. Popping that frame decrements it. -- The invoking macro is **not** disabled while its arguments are collected or - pre-expanded. It becomes disabled only while the substituted replacement list - is rescanned. -- Nested expansions stack naturally: parent macro frames remain on the source - stack while child macro frames are scanned, so parent macros remain disabled - through nested replacement-list rescans. -- Object-like no-copy replay, function-like substituted bodies, and paste - results all use the same frame rule. Empty replacement lists need no frame. - -Identifier test in `pp_next_*`: - -```c -if (cell.kind == TOK_IDENT && (cell.flags & TF_NO_EXPAND) == 0) { - Macro* m = mt_get(pp, tok_ident(&cell)); - if (m && m->disabled_depth != 0) { - cell.flags |= TF_NO_EXPAND; - *out = cell; - return; - } - if (m && macro_invocation_is_present(pp, m, &cell)) { - push_replacement_frame(pp, m, &cell); - continue; - } -} -``` - -Details that matter: - -- A token returned because its macro is disabled keeps `TF_NO_EXPAND` if it is - later captured as an argument, substituted into another macro, or replayed. - This is what makes `foo(foo)(1)` and `id(foo(foo)(1))` stay `foo(1)`. -- A function-like macro name that is not followed by `(` is returned as a plain - identifier **without** setting `TF_NO_EXPAND`. If it is later rescanned in a - context where another macro has supplied the `(`, it may expand. This keeps - cases like `id(f L 1))`, with `#define L (`, able to become `1`. -- `defined` operands in `#if` expansion can reuse `TF_NO_EXPAND`: the source of - the mark is different, but the effect is the same. -- Tokens created by `##` are fresh lexeme cells with `TF_NO_EXPAND` clear. They - are then rescanned under the active disabled-frame stack; if a pasted token - spells a currently disabled macro, the normal identifier test marks it - unavailable before returning it. -- This removes the canonical hideset table and the per-token `HidesetId` side - arrays from replay sources. The only per-token availability state that flows - through arguments and replay is the `TF_NO_EXPAND` bit already in the cell. - -## Parser Input Ring - -The parser consumes pp output through a small fixed ring of parser-owned cells. -APIs return pointers to parser-owned cells, never cells by value: - -```c -typedef struct ParserInput { - CFeCell cells[3]; /* fixed cur + LL(2) lookahead ring; no VLA */ - u8 cur; /* index of the current token in cells[] */ - u8 nvalid; /* number of valid slots from cur, 0..3 */ - CFeCell pending; /* string-literal fusion / one-token pushback */ - u8 has_pending; -} ParserInput; - -void parser_advance(Parser*); -void parser_fill_to(Parser*, u32 depth); /* cold path: fills through depth 0..2 */ - -static inline u8 parser_ring_idx(const ParserInput* in, u32 depth) { - return (u8)((in->cur + depth) % 3u); /* depth 0 == current */ -} - -static inline CFeCell* parser_cur(ParserInput* in) { - return &in->cells[in->cur]; -} - -static inline const CFeCell* parser_cur_const(const ParserInput* in) { - return &in->cells[in->cur]; -} - -static inline const CFeCell* parser_peek(Parser* p, u32 depth) { - ParserInput* in = &p->input; - if (in->nvalid <= depth) parser_fill_to(p, depth); - return &in->cells[parser_ring_idx(in, depth)]; /* depth 1..2 */ -} -``` - -Rules: - -- `parser_advance` rotates `cur = (cur + 1) % 3` and fills the newly-free tail - slot when needed. It does not copy or move live `CFeCell` values between - current/lookahead slots. -- `parser_peek(p, depth)` does the cheap inline validity check - (`nvalid <= depth`) and calls `parser_fill_to` only on miss. `parser_fill_to` - fills the ring tail with `pp_next_parse(p->pp, slot)`. -- `parser_peek` returns a `const CFeCell*` valid until the next `advance` or replay - source switch. Callers that need persistence copy the cell explicitly. -- Adjacent string literal fusion writes the fused result into the destination - parser cell in place: `TEXT_SYM`, combined encoding flags, and the preserved - location of the first literal. -- Parser replay sources store lexeme cells only. Before a cell is recorded for - replay, it must be in lexeme view; tagged semantic cells are not replayable. -- There are no VLAs. Fixed lookahead stays fixed; any variable replay buffer uses - the parser arena or an existing vector helper. - -## Parser Tagging - -Tagging is local to a consumed primary. It is not a pp feature and it is not an -expression stack. - -The shared-cell helper has no C or CG header dependency: - -```c -static inline void cfe_tag_primary_raw(CFeCell* c, u16 sem_kind, - const void* lang_type, - u32 cg_type, - u32 lang_flags) { - c->kind = sem_kind; /* CFE_SEM_VALUE or CFE_SEM_PLACE */ - c->flags = 0; /* parser-private flags if needed */ - c->u.sem.lang_type = lang_type; - c->u.sem.cg_type = cg_type; - c->u.sem.lang_flags = lang_flags; -} -``` - -The C parser may wrap it with C/CG-specific types: - -```c -static inline void c_tag_primary(CFeCell* c, u16 sem_kind, - const Type* type, - KitCgTypeId cg_type, - u32 c_flags) { - cfe_tag_primary_raw(c, sem_kind, type, (u32)cg_type, c_flags); -} -``` - -Identifier primary: - -1. `TOK_IDENT` carries `Sym` in `aux`. -2. The parser checks `kw_map`; otherwise it reads `BindingTab` once. -3. The parser emits the corresponding CG seed: local place, global/function - address, enum constant, integer constant, or other binding-specific form. -4. The parser calls `kit_cg_retag_top(p->cg, type, flags)` with the same semantic - facts it wrote into the cell. -5. The parser advances; the tagged cell is no longer live. - -Literal primary: - -1. The parser reads spelling, suffix, and encoding from lexeme view. -2. The parser decodes only when the literal becomes a value. -3. The parser pushes through the CG constant/value API. Width-complete integer - constants use `KitCgConstInt` from `CG-STACK-API.md`. -4. The parser retags the CG top with C type/value flags. -5. The parser may tag the current cell as a debug/lowering seed until `advance`. - -Operators never tag tokens. They consume CG stack entries via `kit_cg_*`, query -`KitCgSlotInfo`, and retag results according to `CG-STACK-API.md`. The parser -input cell can be tagged; the live value is still the CG stack slot. - -## CG-Facing Dependencies - -This design depends on the CG stack contract from `CG-STACK-API.md`: - -```c -void kit_cg_lang_enable(KitCg*); -KitCgSlotInfo kit_cg_slot_info(KitCg*, u32 depth_from_top); -void kit_cg_retag_top(KitCg*, const void* lang_type, u32 lang_flags); -void kit_cg_retag_at(KitCg*, u32 depth_from_top, - const void* lang_type, u32 lang_flags); -void kit_cg_set_top_flags(KitCg*, u32 set, u32 clear); - -int kit_cg_top_const_int_ex(KitCg*, KitCgConstInt* out); -void kit_cg_push_const_int(KitCg*, KitCgTypeId type, - const KitCgConstInt* value); -``` - -`CFeSem` deliberately mirrors the non-constant part of `KitCgSlotInfo`: -`{lang_type, cg_type, lang_flags}`. That keeps parser tagging and CG retagging -the same shape without making the pp pay for parser-only facts. - -## End-State Data Flow - -```text -source bytes - -> lex_next(Lexer*, CFeCell*) writes lexeme view - -> pp source/replay stack reads/writes lexeme view only - -> pp_next_parse/raw(Pp*, CFeCell*) fills caller-owned cell - -> cpp / cc -E / KIT_PP_DRAIN drains lexeme cells; no parser - -> ParserInput ring parser-owned cells - -> primary resolution optional in-place semantic tag - -> kit_cg_* push/op + retag CG stack owns values - -> CgTarget -> NativeTarget -> MC backend seam unchanged -``` - -The fusion point is the front-half representation and handoff: eliminate -by-value token returns and duplicate token shapes, preserve pp drainability, and -do not create a second value representation beside `KitCg`. - -## Correctness Checklist - -- A pp drain can run with no parser and never observes `CFE_SEM_*`. -- Macro bodies and replay buffers are immutable lexeme-cell streams. -- Parser tagging is limited to caller-owned parser cells. -- Tagged cells are never recorded for replay or passed back to pp helpers. -- Text and location materialization work after source pop and macro replay. -- String-literal fusion produces a single lexeme cell without a second token type. -- Parser current/lookahead is a three-cell ring with index rotation; `advance` - does not copy or move live `CFeCell` values. -- `TF_AT_BOL` is sufficient for parser-feed directive recognition; raw mode keeps - `TOK_NEWLINE` for `-E`. -- Macro replacement frames disable their owner only while the replacement list is - being rescanned, and re-enable it exactly when that frame is popped. -- A token skipped because its macro is disabled is returned with `TF_NO_EXPAND`, - and that bit survives argument pre-expansion, substitution, replay, and later - rescans. -- A function-like macro name not followed by `(` is not marked `TF_NO_EXPAND`; - it can expand if a later rescan sees a `(` supplied by another macro. -- Pasted identifier tokens start with `TF_NO_EXPAND` clear and are judged by the - active disabled-frame stack during normal rescan. -- Identifier resolution does one `Sym`-keyed lookup after keyword classification. -- Literal decoding is lazy and occurs before `u.text` is overwritten. -- The CG stack, not `CFeCell`, owns expression lifetime, constants, liveness, and - C type/value flags after primary lowering. -- No lexer/pp header includes C parser or CG internals. -- No VLAs and no global state. diff --git a/doc/plan/KERNEL.md b/doc/plan/KERNEL.md @@ -1,580 +0,0 @@ -# Kernel build and image pipeline - -This roadmap tracks the work needed for kit to build freestanding kernels from -C and assembly sources and emit kernel images that can be passed to QEMU's -direct loaders. It deliberately does not cover VM execution, bootloader -generation, UEFI application layout, or kit-provided startup code. Kernel -startup, privilege-mode entry, page-table setup, stack setup, TLS setup, and -boot-protocol compliance are the kernel author's responsibility. - -Related: [../DRIVER.md](../DRIVER.md), [../LINK.md](../LINK.md), -[../OBJ.md](../OBJ.md), [../RUNTIME.md](../RUNTIME.md), -[LINKER-COMPAT.md](LINKER-COMPAT.md), [PORT.md](PORT.md). - -## Scope - -The supported path is: - -``` -C / asm sources + objects + archives - -> kit build-obj / build-exe - -> freestanding static kernel ELF - -> kit image / objcopy - -> ELF / flat binary / ROM-style payload / section-concatenated payload -``` - -The first targets are freestanding ELF: - -- `x86_64-none-elf` -- `aarch64-none-elf` -- `riscv64-none-elf` -- `riscv32-none-elf` - -The output artifacts should be usable with QEMU features such as `-kernel`, -`-bios`, or `-device loader,file=...`, depending on the target machine and the -kernel's own entry contract. Kit should not decide or implement the boot -protocol. - -## Current baseline - -Useful pieces already exist: - -- `build-obj` compiles C / asm / toy / wasm sources into objects, and can combine - multiple source objects with `ld -r`. -- `build-exe` compiles a source set and links it with object/archive inputs. -- Freestanding triples resolve to non-PIE ELF/WASM targets by default. -- `build-*` already accepts common freestanding and link flags such as - `-ffreestanding`, `-nostdinc`, `-nostdlib`, `-nodefaultlibs`, - `-nostartfiles`, `-static`, `-pie`, `-no-pie`, `-mcmodel=...`, `-T`, `-e`, - `-Wl,...`, `--build-id=...`, `-ffunction-sections`, and `-fdata-sections`. -- The linker has a structured linker-script subset with section placement, - symbol assignment, `/DISCARD/`, and `KEEP` roots for `--gc-sections`. -- The runtime provides freestanding headers and compiler-runtime-style support, - but no `crt0`. -- `objcopy` can transform object files and rewrite sections/symbols, but does - not yet expose raw binary image output. - -The desired user-facing build shape stays on the existing tools: - -``` -kit build-exe -target x86_64-none-elf \ - -ffreestanding -nostdlib -static -no-pie \ - -mcmodel=kernel -mno-red-zone \ - -ffunction-sections -fdata-sections \ - -T kernel.ld -e _start \ - -Wl,--gc-sections \ - --map kernel.map \ - --symbols kernel.sym \ - -o kernel.elf \ - boot.S kernel.c mm.c -``` - -## Compile and build-driver support - -Do not add a separate `kit kernel compile` command. Keep `build-obj` and -`build-exe` as the compile/link front doors, and make their flag surface complete -enough for kernel authors. - -Required flag support and behavior: - -- `-ffreestanding` / `-fhosted`: select freestanding vs hosted assumptions, - including whether sysroot-hosted profiles may be engaged. -- `-nostdinc`: suppress all implicit non-resource include paths while still - allowing explicit `-I` / `-isystem`. -- `-nostdlib`, `-nodefaultlibs`, `-nostartfiles`: precisely control runtime - archive and hosted CRT/libc insertion. No startup object is ever invented by - kit for a freestanding kernel. -- `-static`, `-no-pie`, `-fno-pic`, `-fno-pie`: produce static non-PIE kernel - code and an ET_EXEC-style image unless the user deliberately opts into PIC/PIE. -- `-mcmodel=...`: keep existing model selection and add kernel-relevant aliases - where the target backend has meaningful behavior. -- `-mno-red-zone`: disable the x86_64 SysV red zone for kernel code. This must - affect backend frame selection, not merely be accepted as syntax. -- `-mgeneral-regs-only` or equivalent target-feature spelling: give kernels a - straightforward way to prevent accidental SIMD/FP codegen where an ABI or - privilege-mode context does not save those registers. -- `-fno-builtin`: accept in `build-*` and ensure the C frontend does not turn - freestanding source into calls or assumptions the kernel did not request. -- `-fno-stack-protector` / `-fstack-protector*`: either implement the supported - subset or reject unsupported modes explicitly. Silently ignoring stack - protector policy is not acceptable for kernels. -- `-ffunction-sections` and `-fdata-sections`: compose with linker - `--gc-sections` and script `KEEP`. -- `--group`: continue to scope include/define/language/frontend flags to source - subsets; do not let link-wide kernel policy drift into per-source groups. - -Driver parity to improve: - -- Accept common direct linker flags in `build-exe` where `ld` already accepts - them, instead of requiring every flag to pass through `-Wl,`. -- Keep `cc`, `ld`, and `build-exe` behavior aligned by routing shared policy - through `driver/lib/link_flags.*` and `driver/lib/target.*`. -- For freestanding executable links, default diagnostics should be strict: - unresolved symbols and dynamic-link artifacts should be errors unless the user - explicitly requests an escape hatch. - -## Linker work - -### Linker-script subset - -Kernel links need a larger structured GNU-ld-compatible subset. The goal is not -to interpret arbitrary linker scripts blindly, but to support the constructs -needed for deterministic kernel memory layouts with precise diagnostics. - -Add support for: - -- `MEMORY` with `ORIGIN`, `LENGTH`, attributes, and region-overflow diagnostics. -- Output section placement into memory regions with `> REGION`. -- Load-memory placement with `AT(expr)` and `AT> REGION`, so VMA/LMA split - kernels can be represented. -- `PHDRS`, `:phdr`, `FLAGS(...)`, and `FILEHDR` / `PHDRS` segment attributes, - so kernels can control program headers and segment permissions. -- `PROVIDE`, `PROVIDE_HIDDEN`, and `HIDDEN`. -- `ASSERT(expr, "message")`. -- `EXTERN(symbol)` as a GC root and undefined-symbol declaration. -- Richer input section patterns: `*(.text .text.*)`, file-qualified patterns, - and `EXCLUDE_FILE`. -- Section fills: `=0x...` and `FILL(...)`. -- Alignment and address helpers: `ALIGN`, `SUBALIGN`, `BLOCK`, `ADDR`, - `LOADADDR`, `SIZEOF`, `SIZEOF_HEADERS`, `DEFINED`, and `ABSOLUTE`. -- `OUTPUT_ARCH` and `OUTPUT_FORMAT` as validation directives initially. They do - not need to drive target selection in the first pass, but mismatches should be - reported clearly. - -Existing `KEEP(...)` support must continue to interact correctly with -`--gc-sections`. - -### Link output side files - -Add linker-produced side outputs, available from both `ld` and `build-exe`: - -- `--map FILE`: write a deterministic link map. -- `--symbols FILE`: write post-link absolute symbols. -- `--symbols-format=nm|json`: start with an nm-like text format; JSON can land - once the data model is stable. -- `--cref FILE`: optional cross-reference table. -- `--print-memory-usage`: summarize `MEMORY` regions once linker-script memory - regions exist. - -The link map should include: - -- target triple and output kind, -- entry symbol and address, -- memory regions and usage, -- program headers / segments, -- output sections with VMA, LMA, file offset, size, and alignment, -- input object/archive-member contributions, -- linker-defined symbols, -- discarded sections, -- unresolved symbols, -- section-GC roots and discarded-reason details where practical. - -### Linker flags and policy - -Add or normalize: - -- `--no-undefined`: reject unresolved symbols for executable/freestanding links, - not only shared-library output. -- `--allow-undefined`: explicit escape hatch. -- `--defsym name=expr`. -- `--section-start=.name=addr`. -- `-Ttext`, `-Tdata`, and `-Tbss` through `build-exe` as well as `ld`. -- `--orphan-handling=place|warn|error|discard`. -- `--fatal-warnings`. - -Freestanding kernel validation should reject, by default: - -- DSO inputs, -- dynamic interpreter paths, -- dynamic sections and PLT/GOT imports, -- unresolved relocations, -- missing entry symbols when `-e` or `ENTRY(...)` names one, -- target/object-format mismatches across inputs. - -## Image command - -Add a new `kit image` command for kernel-image emission. Also add a minimal -`objcopy -O binary` path for compatibility, backed by the same lower-level image -emitter. - -`objcopy` should remain an object transformer: - -``` -kit objcopy -O binary kernel.elf kernel.bin -``` - -`kit image` should own image-building policy: - -``` -kit image --format bin kernel.elf -o kernel.bin - -kit image --format bin --from segments --segment PT_LOAD \ - --addr paddr --base 0x80000000 --fill 0x00 --align 4096 \ - --pad-to 2M --max-size 8M \ - --metadata kernel.image.json \ - kernel.elf -o kernel.bin -``` - -### Image formats - -- `elf`: copy, normalize, strip, or split-debug an existing linked ELF. -- `bin`: flat binary derived from loadable segments or selected sections. -- `rom`: flat fixed-size binary with fill, padding, max-size checks, and later - optional checksum hooks. -- `sections`: concatenate explicitly named sections in user-specified order. - -Optional later embedded formats, such as Intel HEX, S-record, or UF2, are out of -the first kernel-focused pass. - -### Flat kernel `Image` (arm64 / riscv64) - -QEMU's `-kernel` path on arm64 and riscv consumes the flat `Image` format: a raw -loadable binary prefixed with a fixed **64-byte header** the kernel's loader -reads to place and size the image. The first 32 bytes are common to both arches: - -| offset | field | notes | -|--------|-------------------|---------------------------------------------------------| -| 0 | `code0` (u32) | first instruction (branch to entry; `"MZ"` low half if EFI) | -| 4 | `code1` (u32) | second instruction | -| 8 | `text_offset` | u64 LE — load offset from a 2 MiB-aligned base | -| 16 | `image_size` | u64 LE — effective image size **including BSS** | -| 24 | `flags` | u64 LE — bit 0 endianness; arm64 bits 1-2 page size, bit 3 placement | - -The tails differ. arm64: three reserved u64s, `magic = "ARM\x64"` (`0x644d5241`) -at offset 56, then a u32 PE-offset slot. riscv64: a `version` u32 (currently -`0x2`), reserved words, a deprecated `"RISCV\0\0\0"` magic at 48, and -`magic2 = "RSC\x05"` (`0x05435352`) at 56. - -There are two ways to support this, and they have different costs: - -- **Author-owned header (pass-through) — already supported.** In the - Linux-native workflow the kernel's own startup code emits the 64-byte header - (`head.S` places `code0`/`code1` and declares `image_size` from linker - symbols). This matches the project stance that kernel startup is the author's - responsibility. kit needs **nothing new**: `kit image --format bin` / - `objcopy -O binary` copy PT_LOAD bytes verbatim, so the leading header survives - byte-exact and the result is a loadable `Image`. Only a doc note and a fixture - that asserts the header bytes and size are required. - -- **Kit-synthesized header — small add, the part worth doing.** The one field - that is awkward for authors is `image_size`, the in-memory footprint including - BSS, which kit already knows: `KitObjSegInfo.vsize` gives each loadable - segment's memory size (the current emitter lays out `file_size` only and - ignores it). So the add is: a ~64-byte header emitter, a new - `--format arm64-image` / `--format riscv-image` (or an - `--image-header=arm64|riscv` modifier on `--format bin`), and `vsize`-based - computation of `image_size` and the memory span. - -Because `flags` encodes boot semantics (endianness, page size, placement), -header synthesis is an explicit opt-in, never a default. Those fields are -surfaced as explicit options (e.g. `--image-endian`, `--image-page-size`, -`--image-text-offset`) rather than invented; kit fills magic/version and -`image_size` deterministically and does not choose a boot policy. - -### Image flags - -Selection: - -- `--from=segments|sections` -- `--segment=PT_LOAD` (repeatable) -- `--only-section NAME` (repeatable) -- `--remove-section NAME` (repeatable) -- `--section NAME` (repeatable; explicit order for `--format sections`) - -Addressing: - -- `--addr=vaddr|paddr|lma` -- `--base ADDR` -- `--bias N` - -Holes and layout: - -- `--fill BYTE` -- `--fail-on-holes` -- `--max-hole SIZE` -- `--align N` -- `--pad-to SIZE` -- `--max-size SIZE` - -ELF/debug: - -- `--strip-debug` -- `--split-debug FILE` -- `--keep-symbols` - -Validation: - -- `--require-entry` -- `--require-symbol NAME` (repeatable) -- `--require-section NAME` (repeatable) -- `--no-dynamic` - -Reporting: - -- `--metadata FILE`: write a deterministic JSON sidecar containing target, - object format, entry, build id, selected segments/sections, source ranges, - output ranges, base/bias/fill policy, and warnings. - -### Image semantics - -Segment-based flat image emission should: - -- read loadable ranges from program headers, -- sort by selected address kind, -- detect overlaps, -- fill or reject holes according to policy, -- derive output offsets from `base` / lowest selected address, -- preserve bytes exactly as they would be loaded, -- include NOBITS memory ranges in metadata but not necessarily in output bytes - unless a selected format requires padding. - -Section-based emission should: - -- operate on named sections in declared order, -- reject missing sections unless a permissive option is added later, -- concatenate section bytes exactly, -- make address metadata explicit so users do not confuse section concatenation - with a loadable memory image. - -ROM-style emission should: - -- require an explicit size or `--pad-to`, -- fill unused bytes deterministically, -- fail when selected payload bytes exceed the requested size, -- leave target-specific checksums or reset-vector conventions as future, - explicit options. - -## Initramfs and archive packaging - -The kernel-boot pipeline needs one packaging format kit does not yet emit: the -SVR4 `newc` **cpio** archive the Linux kernel unpacks as its initramfs. An -initramfs is a `cpio -H newc` archive (magic `070701`, or `070702` for the CRC -variant), optionally compressed, that the kernel's built-in extractor reads at -boot; the early-microcode convention is just an uncompressed cpio concatenated -ahead of the compressed main archive. This is an archive format, not a boot -protocol — the direct analogue of the existing `ar` and tar paths — so it -belongs in kit's byte-utility tool family, not the image emitter. kit packages -and inspects the archive; it does not build, mount, or boot it, and does not -invent its contents. - -Add a `kit cpio` tool, reusing the `src/dist/tar.c` patterns and the public -`kit/compress.h` codecs: - -- `newc` (SVR4 "portable") format only — the format the kernel requires. The - legacy `bin`/`odc` cpio formats are out of scope. -- Create from a directory or explicit file list, list (`-t`), and extract - (`-i`), with deterministic ordering, normalized mode/uid/gid/mtime, and the - closing `TRAILER!!!` record. Regular files, directories, and symlinks first; - special/device nodes (which need explicit major/minor) can come later via a - manifest if a use case appears. -- Concatenation: build and accept already-concatenated archives so early-init - cpio segments can be assembled and inspected. -- Compression as a flag on `kit cpio` (e.g. `--compress=gzip|lz4`, with short - `-z` / `--lz4`) that build-then-compresses in one step. An initramfs is just a - compressed `newc` archive, so the flag is the whole story — no separate - `initramfs` tool is warranted. gzip and lz4 **only**, the two initramfs - compressors kit already ships; zstd and xz are out of scope, with a clear - diagnostic rather than pretend support. On read (`-t` / `-i`), `-d` and - auto-detection let a compressed archive round-trip with no separate - decompress step. - -Gate the tool in `driver/main.c` (`KIT_TOOL_CPIO_ENABLED`) alongside the other -archive utilities. - -## Implementation shape - -Add a shared image-emission layer rather than burying policy in `objcopy`: - -``` -driver/cmd/image.c CLI policy for kit image -driver/cmd/objcopy.c simple -O binary compatibility path -include/kit/image.h public image-emission API, if we want embedders to use it -src/api/image.c public wrapper -src/obj/image.c object/ELF-to-image implementation -``` - -The image API should consume already-read object bytes or an opened `KitObjFile` -view, plus explicit options. It should not read the filesystem directly and -should not run QEMU or inspect host bootloader installs. - -Map/symbol side outputs should be linker-owned rather than image-owned. The -image metadata file can reference link-map facts, but it should be a report about -the image transform, not a replacement for `--map`. - -## Phasing - -1. **Raw binary baseline** - - Add `objcopy -O binary`. - - Add `kit image --format bin --from segments`. - - Support `--base`, `--fill`, `--fail-on-holes`, `--pad-to`, `--max-size`. - - Add focused ELF fixtures for x64, aa64, rv64, and rv32. - -2. **Build/link parity for kernels** - - Add missing `build-exe` direct flag parity with `ld`. - - Add `-mno-red-zone`, `-mgeneral-regs-only`, builtin policy, and stack - protector policy. - - Add strict freestanding undefined/dynamic-artifact diagnostics. - -3. **Link map and symbols** - - Add `--map FILE`. - - Add `--symbols FILE`. - - Add deterministic tests for section layout, symbols, and discarded - sections. - -4. **Script growth** - - Add `MEMORY`, region placement, VMA/LMA split, and region overflow checks. - - Add `PHDRS` once memory regions are stable. - - Add `ASSERT`, `PROVIDE`, `EXTERN`, richer input patterns, and orphan - handling. - -5. **Image formats beyond bin** - - Add `--format sections`. - - Add `--format rom`. - - Add `--metadata FILE`. - - Add `--format elf` normalization/strip/split-debug behavior if it proves - cleaner than routing those cases through `objcopy` and `strip`. - -## Acceptance criteria - -For each first-pass freestanding target, kit should be able to: - -- compile a kernel source set containing C and assembly with `build-exe`, -- link it with a kernel-owned startup object and linker script, -- emit a static freestanding ELF with a deterministic layout, -- produce a link map and absolute-symbol side file, -- convert the ELF to a flat binary image, -- validate that the ELF/image has no accidental dynamic-loader dependencies, -- reproduce byte-identical outputs from identical inputs and options. - -The validation suite should stay targeted: - -- one small kernel-link fixture per architecture, -- one script-layout fixture per linker-script feature, -- one image-conversion fixture per image format and hole policy, -- negative tests for unresolved symbols, region overflow, dynamic artifacts, and - overlapping image ranges. - -## Remaining work - -Phases 1-5 are landed. Phases 1-4 (flag parity, undefined-symbol policy, -`--map`/`--symbols`, the linker-script parse surface, `kit image --format bin` / -`objcopy -O binary`) plus all of the items below shipped; an adversarial review -of the integrated diff found and fixed a further set of correctness bugs -(coalesced-PT_LOAD bss/perms, `--defsym` ordering, exact `--section-start` -addressing, NOLOAD relocations, memory-usage accounting, `--cref` imports, image -metadata accuracy, and cc/ld report parity). The checklist below is closed out. - -### Image formats beyond `bin` (Phase 5) - -- [x] `--format rom`: fixed-size flat binary; requires `--pad-to`, deterministic - fill, fails when payload exceeds the size. -- [x] `--format sections`: concatenate `--section NAME` (repeatable) in declared - order, reject missing sections, address metadata made explicit (new - section-iteration path in `src/obj/image.c`). -- [x] `--format elf`: evaluated and **deliberately delegated** to `objcopy` + - `strip` — a standalone implementation would duplicate the object-rewrite / - strip / split-debug machinery 1:1. The emitter returns a clear diagnostic - pointing to those tools rather than half-building a parallel path. -- [x] `--metadata FILE`: deterministic JSON sidecar (target, object format, - entry, build id, selection, source/output ranges, policy, warnings); the - `selection` field mirrors the emitter's actual segment policy. -- [x] Image selection/validation flags: `--only-section` / `--remove-section` / - `--section`; `--strip-debug` / `--split-debug` / `--keep-symbols` (gated to - `--format elf`); `--require-entry` / `--require-symbol` / `--require-section` / - `--no-dynamic`. - -### Linker-script layout (now honored at layout) - -- [x] `NOLOAD` with PROGBITS content (forces NOBITS / no file bytes; relocations - into a NOLOAD section are skipped, not written through a null buffer). -- [x] Inter-section `.` assignments apply at their textual position (sequence- - stamped assignments interleaved with the section walk). -- [x] Multi-byte fills (`=0x12345678` / `FILL(...)`) lay a repeating big-endian - pattern. -- [x] Multiple sections sharing one `:phdr` coalesce into one PT_LOAD; a section - listing several phdrs appears under each (with R/W/X perms union). -- [x] Recursion-depth guard in script expression parse and eval. - -### Linker flags and policy - -- [x] `--defsym name=expr` (can now satisfy an otherwise-undefined reference) and - `--section-start=.name=addr` (lands at the exact requested vaddr). -- [x] `-Tdata` / `-Tbss` through `build-exe` and `ld`. -- [x] `--orphan-handling=place|warn|error|discard` and `--fatal-warnings`. -- [x] `--cref FILE` (includes imported/undefined symbols) and - `--print-memory-usage` (per-`MEMORY`-region usage, overflow-safe for high-half - regions). -- [x] Map completeness: LMA, discarded sections, and unresolved symbols in - `--map`; input paths normalized to basenames (no absolute-path leak). - -### Freestanding strict validation (Phase 2 gap) - -- [x] Reject dynamic-interpreter paths, dynamic sections, and PLT/GOT imports. -- [x] Reject cross-input target / object-format mismatches. -- [x] Policy surfaced through `build-exe`, not just `ld` (both honor the same - `freestanding_strict` trigger). - -### Tests and fixtures - -- [x] Per-arch kernel-link fixtures for `x86_64-none-elf`, `aarch64-none-elf`, - `riscv64-none-elf`, and `riscv32-none-elf` (build → map/symbols → image → - validate, reproducible). -- [x] Byte-golden `--map` / `--symbols` fixtures (deterministic, committed - goldens for a pinned link). -- [x] Negative tests: region overflow, discarded sections, dynamic artifacts, - NOLOAD-with-PROGBITS, and cross-arch freestanding rejection. - -### Newly scoped: initramfs and `Image` packaging - -Added on top of the closed-out Phase 1-5 work; not yet started. - -Initramfs / cpio (archive packaging, sibling to `ar`) — landed as `kit cpio`. -The newc codec lives driver-local in `driver/cmd/cpio.c` (only the tool consumes -it; the driver has no `-Isrc`, so it mirrors `tar.c`'s stateless append/finish/ -iter shape rather than living in the dist subsystem). Create needed two -additive host shims: `driver_readlink` (read symlink targets) and -`driver_path_lstat` (no-follow operand classification + the source executable -bit). Bidirectionally interop-verified against host `bsdcpio`. - -- [x] `kit cpio` (newc/SVR4 only, magic `070701`/`070702`): create (`-o`) / list - (`-t`) / extract (`-i`) with deterministic ordering (members sorted by path, - a sorted DFS so each directory precedes its children) and normalized metadata - (uid/gid 0, mtime 0, sequential inode, mode = type | perms keyed on the source - exec bit), closing `TRAILER!!!` + a 512-byte tail pad. Regular files, - directories, and symlinks; `..`/absolute names refused on both create and - extract. -- [x] Archive concatenation for early-init segments: the reader continues past a - `TRAILER!!!`, skips inter-segment zero padding, and resumes on the next - `070701`/`070702` magic (trailing non-cpio data is noted, not fatal). Building - a concatenation is shell `cat` of 512-padded archives. -- [x] Compression as a `kit cpio` flag (`--compress=gzip|lz4`, `-z`/`--lz4`; - `-d` + always-on auto-detect on read) via the public `kit/compress.h` codecs — - gzip + lz4 only, with a specific diagnostic for a zstd/xz magic or - `--compress=zstd|xz`. An initramfs is just a compressed newc archive, so no - separate `initramfs` tool. -- [x] Tool gating in `driver/main.c` (`KIT_TOOL_CPIO_ENABLED`) + the - `test/cpio/run.sh` round-trip fixture (pack → list → extract, byte- - deterministic, newc-shape and 512-pad asserts, gzip/lz4/crc/concat coverage, - security + usage negatives; optional `KIT_CPIO_TEST_HOST=1` cross-check against - the host `cpio`). - -Flat kernel `Image` header (arm64 / riscv64) — landed via the `--image-header` -modifier on `--format bin`/`rom` (not separate formats); it overlays the first -64 bytes of the first loadable segment rather than prepending, so the entry -branch (code0/code1) is preserved and the output stays the same size as a plain -`bin`. - -- [x] Pass-through: an author-emitted 64-byte header survives - `kit image --format bin` byte-exact (magic + image_size preserved); covered by - the `image-hdr-pt-*` fixtures. -- [x] `--image-header=arm64|riscv|auto` (AUTO infers the arch from the object's - machine): synthesizes the 64-byte header; `image_size` is the in-memory span - **including BSS**, computed from `KitObjSegInfo.vsize` over all selected - loadable segments (BSS-only `file_size==0` segments included — the byte ranges - skip them, the span must not). -- [x] Explicit boot-semantic options (`--image-endian`, `--image-page-size` - [arm64-only], `--image-text-offset`); magic/version filled deterministically; - no invented boot policy. The sub-options error without `--image-header`. -- [x] Per-arch fixtures (aarch64 / riscv64) in `test/tools/run.sh`: synthesized - header is self-consistent (magic at 56, riscv version 2 at 32, `image_size` - counts the BSS), deterministic, and the entry branch is preserved. diff --git a/doc/plan/LEX-PP-API.md b/doc/plan/LEX-PP-API.md @@ -1,571 +0,0 @@ -# Lexer and preprocessor API: a lean token boundary for the C frontend - -**Goal.** Redesign the lexer -> preprocessor -> parser boundary so a wholesale -lexer/preprocessor rewrite can be high-performance without fusing the frontend -layers. The lexer, preprocessor, and parser remain separately reusable modules: -`cpp` / `cc -E` still use the preprocessor as a standalone component, and the C -parser still consumes a preprocessed token stream. What changes is the data and -the handoff contract. - -This is a design note for the new API and boundary types. It is not yet an -implementation plan for the scanner algorithm itself. - ---- - -## 1. Current boundary - -Today the frontend has a simple but heavy token contract. - -### 1.1 C compile path - -`lang/c/c.c` wires the C frontend as: - -```text -source bytes - -> lex_open_mem / lex_skip_shebang - -> pp_new / pp_push_input - -> parse_c - -> pcg adapter - -> KitCg - -> CgTarget / NativeDirectTarget - -> NativeTarget - -> MC emit -``` - -The current parser-feed path sets `pp_set_suppress_lexer_newlines(pp, 1)` before -the primary lexer is pushed. Include lexers inherit the same policy. This avoids -materializing non-directive newline tokens for the parser, while still surfacing -directive-terminating newlines so directive reading works. - -### 1.2 Lexer input - -`lex_open_mem` takes: - -- `Compiler*` -- source name as `const char*` or pre-interned `Sym` -- borrowed source bytes and byte length - -Opening a lexer also registers a fresh compiler source file id. The lexer owns -phase-2 line-splice folding. If a file contains no backslash-newline splices, it -borrows the original bytes. If splices exist, it builds a folded copy and records -fold points for physical line accounting. - -That means the true lexer input boundary is not just `(char*, len)`. It is: - -- source identity -- source bytes -- ownership/lifetime rule for those bytes -- source registry side effect -- newline policy -- phase-2 splice policy -- optional primary-file shebang handling - -### 1.3 Lexer output - -The lexer returns `Tok` by value: - -```c -typedef struct Tok { - u16 kind; - u16 flags; - SrcLoc loc; - Sym spelling; - union { - Sym ident; - Sym str; - u32 punct; - } v; -} Tok; -``` - -Important properties: - -- Every content token carries a full `SrcLoc` (`file_id`, `line`, `col`). -- Every token that needs text carries an interned exact `spelling`. -- Identifiers are represented by interned `Sym`. -- Punctuators carry both an integer punctuator code and an interned spelling. -- Number/string/char literals carry interned spelling even though parsing later - reads the spelling bytes again. -- `TOK_NEWLINE` is a real token for PP mode and directive termination. -- `TOK_PP_HASH`, `TOK_PP_PASTE`, and `TOK_HEADER` are lexer-visible PP concepts. - -### 1.4 PP internal source stack - -PP reads from a stack of token sources: - -- `SRC_LEX`: a `Lexer*` -- `SRC_BUF`: a `Tok[]` replay buffer - -`SRC_BUF` is used for macro bodies, argument prescan, pushback, `#pragma` -forwarding, and synthetic token sequences. Hidesets are internal to PP and are -carried either as a per-token side array or as one uniform hideset id for a -whole replay buffer. - -Current macro bodies store full `Tok` arrays. A no-`##` body can be pointer -replayed with per-invocation loc/first-flag overrides; bodies with paste still -copy/substitute/paste through scratch buffers. - -### 1.5 PP output to parser - -`pp_next()` returns a macro-expanded `Tok` by value. The parser wraps it with: - -- a `pending` token slot for string-literal fusion -- a one-token lookahead slot -- a replay buffer for recorded braced blocks - -The parser should not see non-directive newlines or directives. It does still -see forwarded non-pragma `#` tokens in invalid cases, and it explicitly swallows -forwarded pragmas. - -There are already out-pointer paths inside PP (`pp_next_raw_into`, -`pp_next_into`) because returning a 24-byte `Tok` by value costs a hidden sret -copy. The public boundary has not caught up to that shape. - ---- - -## 2. Design decision - -Use a lean token as the canonical internal token for the C preprocessor lane: - -- identifiers are interned eagerly -- punctuators are represented by integer code only -- exact spelling is a text reference, not always an interned `Sym` -- source location is a compact location reference, with line/column recovered - lazily when diagnostics or `__LINE__` need it -- PP writes into caller-owned token slots -- PP owns hidesets; hidesets do not cross to the parser - -This does not require control-flow fusion. The scanner can be rewritten as a -tcc-class high-throughput scanner, the PP can keep a source stack and macro -engine, and the parser can remain recursive descent. The boundary becomes cheap -enough that keeping the layers separate is not itself the hot cost. - ---- - -## 3. Proposed core types - -The names here are provisional. The point is the contract. - -### 3.1 Source input - -```c -typedef uint32_t CppSourceId; - -typedef enum CppSourceFlag { - CPP_SRC_PRIMARY = 1u << 0, /* allow shebang skip */ - CPP_SRC_SYSTEM = 1u << 1, /* diagnostics/deps source property */ - CPP_SRC_PARSER_FEED = 1u << 2, /* suppress non-directive newlines */ - CPP_SRC_NO_SPLICES = 1u << 3, /* caller proves no backslash-newline */ -} CppSourceFlag; - -typedef struct CppSourceSpec { - KitSlice name; - const char* bytes; - uint32_t len; - uint32_t flags; -} CppSourceSpec; -``` - -The lexer open API should make source identity explicit. It should return, or -store in the lexer, the compiler file id registered for this source. Include -resolution remains PP-owned; the lexer should not do filesystem work. - -`CPP_SRC_NO_SPLICES` is the safe version of today's paste fast path. It is only -valid for synthetic buffers whose construction proves no line splice can exist. -Normal source files and command-line definitions stay on the safe splice scan. - -### 3.2 Location reference - -```c -typedef struct CppLocRef { - uint32_t file_id; - uint32_t byte_off; -} CppLocRef; -``` - -The hot token carries a byte offset in the logical source stream, not an eager -`line`/`col` pair. The source registry or lexer source object must have enough -line-map data to turn `(file_id, byte_off)` into `KitSrcLoc` on demand. - -`#line` complicates this. The PP should keep the current file/line overlay on -the source stack, as it does today, and provide a helper: - -```c -KitSrcLoc cpp_pp_materialize_loc(CppPP* pp, CppLocRef loc); -``` - -Dynamic `__LINE__` expansion should use the overlay-aware helper. Diagnostics -from parser/PP should also route through it. The parser should not know the -details of `#line` state. - -### 3.3 Text reference - -```c -typedef enum CppTextKind { - CPP_TEXT_NONE = 0, - CPP_TEXT_SOURCE = 1, - CPP_TEXT_SYM = 2, -} CppTextKind; - -typedef struct CppTextRef { - uint32_t kind; - uint32_t source_id; - uint32_t off; - uint32_t len_or_sym; -} CppTextRef; -``` - -For source tokens, `CPP_TEXT_SOURCE` names a byte span in the source's logical -post-splice buffer. For synthetic tokens, `CPP_TEXT_SYM` carries an interned -symbol id in `len_or_sym`. - -Helpers: - -```c -KitSlice cpp_text_slice(CppPP* pp, CppTextRef text); -Sym cpp_text_intern(CppPP* pp, CppTextRef text); -int cpp_text_eq_cstr(CppPP* pp, CppTextRef text, const char* s); -``` - -This keeps exact spelling available for `-E`, diagnostics, stringize, paste, -literal decode, and macro identity checks without forcing every punctuator and -literal spelling through the global symbol table on first lex. - -### 3.4 Token - -```c -typedef struct CppTok { - uint16_t kind; - uint16_t flags; - uint32_t aux; - CppLocRef loc; - CppTextRef text; -} CppTok; -``` - -Field meaning: - -- `kind`: token kind (`EOF`, `IDENT`, `NUM`, `FLT`, `STR`, `CHR`, `PUNCT`, - `PP_HASH`, `PP_PASTE`, `HEADER`, `NEWLINE`, plus PP-internal kinds). -- `flags`: BOL, leading-space, no-expand, literal suffix/encoding flags, - literal-bad. -- `aux`: identifier `Sym`, punctuator code, macro parameter index, or small - synthetic value depending on kind. -- `loc`: compact source location. -- `text`: exact spelling reference if the token has one. - -For identifiers, `aux` is the interned `Sym`. `text` may also be present as the -exact spelling span, but normal identifier equality and macro lookup use `aux`. - -For punctuators, `aux` is the `Punct` code. `text` is optional for common -canonical punctuators on the parser-feed path. The `-E`, stringize, and paste -paths can still recover spelling by either source span or canonical punctuator -table. Digraphs need an exact source span. - -For literals, `text` is the source or synthetic spelling. Numeric and string -decoding should read via `cpp_text_slice` and only intern if a later operation -requires a `Sym`. - ---- - -## 4. New API shape - -### 4.1 Lexer - -```c -typedef struct CppLexer CppLexer; - -int cpp_lex_open(CppLexer* lx, KitCompiler* c, const CppSourceSpec* src); -void cpp_lex_reset(CppLexer* lx, const CppSourceSpec* src); -void cpp_lex_close(CppLexer* lx); - -void cpp_lex_set_mode(CppLexer* lx, uint32_t flags); -void cpp_lex_next(CppLexer* lx, CppTok* out); - -uint32_t cpp_lex_file_id(const CppLexer* lx); -KitSrcLoc cpp_lex_materialize_loc(const CppLexer* lx, CppLocRef loc); -KitSlice cpp_lex_text_slice(const CppLexer* lx, CppTextRef text); -``` - -The lexer should fill a caller-provided token slot. It should not return token -structs by value. - -The lexer should not track include keyword state as a hidden state machine. -Instead PP should request header-name lexing while reading an include/embed -directive. That can be a mode bit scoped to the next token, or an explicit -entry point: - -```c -void cpp_lex_next_header_name(CppLexer* lx, CppTok* out); -``` - -This removes `#include` knowledge from the normal scanner hot path. - -### 4.2 Preprocessor - -```c -typedef struct CppPP CppPP; - -int cpp_pp_open(CppPP* pp, KitCompiler* c, const KitPreprocessOptions* opts); -void cpp_pp_close(CppPP* pp); - -int cpp_pp_push_source(CppPP* pp, const CppSourceSpec* src); -void cpp_pp_add_include_dir(CppPP* pp, const char* dir, int system); -void cpp_pp_define(CppPP* pp, const char* name, const char* body); -void cpp_pp_undef(CppPP* pp, const char* name); - -void cpp_pp_next_raw(CppPP* pp, CppTok* out); -void cpp_pp_next_parse(CppPP* pp, CppTok* out); -void cpp_pp_emit_text(CppPP* pp, KitWriter* out); - -KitSrcLoc cpp_pp_materialize_loc(CppPP* pp, CppLocRef loc); -KitSlice cpp_pp_text_slice(CppPP* pp, CppTextRef text); -Sym cpp_pp_text_intern(CppPP* pp, CppTextRef text); -``` - -`cpp_pp_next_raw` is the `-E` stream: macro-expanded, directives consumed, -newlines preserved as needed for text reconstruction. - -`cpp_pp_next_parse` is the parser stream: macro-expanded, directives consumed, -non-directive newlines absent, forwarded pragmas swallowed or represented by a -parser-ignored event. - -Both write into caller-owned slots. - -### 4.3 Parser - -The parser should own a small token cursor: - -```c -typedef struct CTokenCursor { - CppPP* pp; - CppTok cur; - CppTok next; - CppTok pending; - uint8_t has_next; - uint8_t has_pending; -} CTokenCursor; -``` - -The parser's `advance`, `peek1`, pending string-literal fusion, and braced-block -replay stay local to the parser. The difference is that they copy `CppTok`, not -the old eager-interned `Tok`, and they ask PP/text helpers only when a spelling -or materialized location is actually needed. - ---- - -## 5. Layer responsibilities - -### 5.1 Lexer owns - -- raw byte scanning -- phase-2 splice folding and source line maps -- physical BOL / leading-space flags -- token kind recognition -- identifier interning -- numeric/string/char literal extent and suffix/encoding flags -- punctuator code classification -- header-name tokenization only when PP explicitly requests it - -### 5.2 PP owns - -- include search, include cache, include graph edges -- source stack and source lifetime -- directive recognition and directive-line reading -- conditional inclusion stack and skipped-section scanning -- macro table -- hidesets -- macro argument collection and prescan -- macro replay buffers -- token paste and stringize -- dynamic predefined macros -- `#line` overlays -- raw stream vs parser stream policy -- text emission for `-E` - -### 5.3 Parser owns - -- C keyword classification -- token lookahead -- string-literal fusion -- braced-block replay used by current parser logic -- literal value decoding -- diagnostics through PP materialization helpers -- semantic/type resolution and CG driving - -Hidesets must not cross into the parser. Include state must not cross into the -lexer. C semantic keyword/type state must not cross into PP. - ---- - -## 6. Important invariants - -1. **Exact spelling remains available.** - `#`, `##`, `-E`, diagnostics, literal decode, macro redefinition checks, and - header parsing all require exact spelling. The change is lazy access, not - lossy tokens. - -2. **Parser-feed tokens do not require newline materialization.** - The parser stream should never allocate or return non-directive newline - tokens. Directive termination remains an internal PP/lexer concern. - -3. **Identifier lookup stays `Sym`-based.** - Macro lookup and parser binding/keyword lookup should be one indexed or - table lookup on an interned identifier. - -4. **Punctuator spelling is not a hot-path symbol-table operation.** - Punctuators are roughly half the stream on large C files. The parser needs - the punctuator code, not a symbol table entry. Exact text is only needed for - `-E`, stringize, paste, and diagnostics. - -5. **Line/column is lazy.** - Hot tokens should not pay column arithmetic and `SrcLoc` construction unless - a consumer needs a materialized location. - -6. **Synthetic tokens have stable text.** - Macro expansions, dynamic predefined macros, paste output, stringize output, - and command-line definitions must have text refs with lifetime at least until - the returned token is consumed or until the owning PP arena is reset. - -7. **Macro replay can remain pointer-based.** - Macro bodies should store `CppTok[]` plus compact metadata. Object-like - no-paste bodies can still be pointer-replayed with loc/first-flag override. - -8. **The public compatibility API can exist during migration.** - Old `Tok pp_next(Pp*)` can be implemented as an adapter while parser code is - converted. This allows byte-identical gates at each step. - ---- - -## 7. Migration plan - -### Step 1: introduce the types and adapters - -Add `CppTok`, `CppTextRef`, `CppLocRef`, and text/location helper APIs next to -the current `Tok`. Keep old `Tok` as the public compatibility type. - -Build adapter helpers: - -```c -void cpp_tok_to_old_tok(CppPP* pp, const CppTok* in, Tok* out); -void old_tok_to_cpp_tok(CppPP* pp, const Tok* in, CppTok* out); -``` - -The old adapter will eagerly intern text and materialize locations. That is -acceptable as a temporary compatibility layer. - -### Step 2: make PP public pull slot-based - -Add slot-based public APIs over the existing implementation: - -```c -void pp_next_into_public(Pp* pp, Tok* out); -void pp_next_raw_into_public(Pp* pp, Tok* out); -``` - -Convert parser fetch to use the slot API before changing representation. This -is a low-risk cleanup because PP already has internal out-pointer machinery. - -### Step 3: rewrite lexer behind a compatibility adapter - -Implement the new scanner to fill `CppTok`. Convert to old `Tok` at the lexer -or PP boundary. Gate on: - -- PP golden tests -- parser tests -- sqlite object byte identity -- sqlite `-E` token/text identity -- diagnostic location parity on focused cases - -At this stage performance will understate the final win because the adapter -still eagerly interns/materializes. - -### Step 4: convert PP macro storage and source stack to `CppTok` - -Move macro bodies, replay buffers, argument vectors, and directive lines to -`CppTok`. Keep `pp_next()` compatibility at the outside edge. - -This is where lazy spelling starts to matter: macro expansion should carry -source text refs until a PP operation demands interned text. - -### Step 5: convert parser cursor to `CppTok` - -Replace parser `Tok` slots with `CppTok` slots. Parser helpers should use: - -- `tok_ident(t)` for identifier `Sym` -- `tok_punct(t)` for punctuator code -- `cpp_pp_text_slice` for literal spelling -- `cpp_pp_materialize_loc` for diagnostics and CG source loc - -After this step, the old `Tok` adapter is no longer on the compile hot path. - -### Step 6: delete or narrow the old token contract - -Keep an old-style token only where external API compatibility requires it, or -delete it if no public API promises it. The C frontend hot path should be -`CppTok` end to end through lexer, PP, and parser. - ---- - -## 8. Open questions - -1. **Line-map owner.** - Should line maps live in the compiler source registry, the lexer source - object, or PP's source cache? The answer affects how parser diagnostics - materialize `CppLocRef` after the lexer for an include has been popped. - -2. **Text lifetime for folded source.** - If a source needed splice folding, `CppTextRef` must point at the folded - logical buffer, not the original bytes. That folded buffer must live long - enough for any token text refs in macro bodies that outlive the source file. - -3. **Macro body text retention.** - A macro body defined in an include may survive after that include source is - popped. Either macro-body tokens must intern/copy their text at definition - time, or source buffers referenced by macro bodies must be retained until - `pp_free`. - -4. **Canonical punctuator text.** - For canonical punctuators, can `CppTextRef` be omitted and reconstructed from - the punctuator code? That is attractive for parser-feed and most PP paths, - but `-E` must preserve digraph spelling where relevant. - -5. **Header-name mode.** - The current lexer has directive state to emit `TOK_HEADER`. The new design - should move this to PP, but the exact API should be chosen when directive - reading is rewritten. - -6. **Token-paste validation.** - Paste currently concatenates spellings and re-lexes a tiny buffer. The new - scanner should preserve that validation path but route it through - `CPP_SRC_NO_SPLICES` and a reusable lexer object. - -7. **Diagnostics parity.** - Lazy location must preserve user-visible line/column behavior, including - `#line`, includes, splices, comments, and shebang handling. - -8. **Public names.** - The final names should probably not expose `Cpp` if this becomes the common - C-family preprocessor substrate. The design uses `Cpp*` only to avoid - colliding with current `Tok`/`Pp` names. - ---- - -## 9. Initial right-sized target - -The first useful deliverable is not the full PP rewrite. It is a measured -compatibility lane: - -1. New source spec, loc ref, text ref, and token definitions. -2. Slot-based public PP pull API over the current code. -3. New scanner filling the lean token. -4. Adapter to old `Tok`. -5. Head-to-head sqlite measurement: - - raw lex instructions per byte - - compile `-c` instructions - - `-E` byte identity - - object byte identity - -If the adapter lane cannot produce a scanner win in isolation, the scanner -algorithm is not yet good enough. If it does win in isolation but not through -compile, the next bottleneck is PP token storage/replay and parser conversion. diff --git a/doc/plan/LINKER-COMPAT.md b/doc/plan/LINKER-COMPAT.md @@ -1,240 +0,0 @@ -# System-linker compatibility - -This roadmap tracks the remaining linker work needed for `kit ld` to act as the -system linker over kit's supported target set. The near-term driver is Rust -using `kit ld` through `rustc`, but the target is broader: C, Rust, assembler -objects, archives, DSOs/dylibs/import libraries, PIE executables, shared -libraries, and relocatable links should all behave like the platform linker for -the supported arch/OS pairs. - -Related: [LINKER.md](LINKER.md), [../LINK.md](../LINK.md), -[../OBJ.md](../OBJ.md), [../DRIVER.md](../DRIVER.md), -[SYSROOTS.md](SYSROOTS.md). - -## Scope - -The support set is format-driven: - -- Mach-O: macOS `aarch64` and `x86_64`. -- ELF: Linux glibc/musl `aarch64`, `x86_64`, `riscv64`; FreeBSD where Rust and - sysroot artifacts exist; freestanding ELF for `aarch64`, `x86_64`, `riscv64`, - and `riscv32`. -- COFF/PE: Windows UCRT MinGW (`*-pc-windows-gnullvm`) for `x86_64` and - `aarch64`. - -The linker should preserve format boundaries. ELF `--as-needed` and TLSDESC -rules must not leak into Mach-O dylib or COFF import-library semantics. COFF's -archive search behavior, Mach-O atom/GOT/TLV behavior, and ELF dynamic-section -rules stay target-specific policy behind shared object and relocation APIs. - -## Current state - -The Rust-driven compatibility pass established these paths: - -- `kit ld` accepts the common system-linker flags Rust passes, including GNU - `-Wl,`/`-z` forms, target/sysroot flags, linker scripts, rlibs, exact - `-l:name` libraries, `-static-pie`, `-nodefaultlibs`, hosted/no-startfile - combinations, PE/MinGW flags, and Mach-O version/platform flags. -- Rust std links through `kit ld` for macOS `aarch64`/`x86_64`, Linux glibc - `aarch64`/`x86_64`/`riscv64`, Linux musl `aarch64`/`x86_64`/`riscv64`, - FreeBSD `x86_64`, and Windows UCRT MinGW `aarch64`/`x86_64`. -- Rust no-std links through `kit ld` for `aarch64-unknown-none`, - `x86_64-unknown-none`, `riscv64gc-unknown-none-elf`, and - `riscv32imac-unknown-none-elf`. -- ELF symbol-version imports can resolve explicit `name@VERSION` references, - which FreeBSD Rust uses for libc compatibility symbols. -- COFF archive libraries use global fixed-point search, matching the behavior - Rust/MinGW expects from the library set. -- RISC-V TLS-GD and x86_64 TLS-LD can relax local executable TLS cases; AArch64 - ELF TLSDESC relocation spellings are accepted and currently relax only for - local defined TLS. - -This is a useful system-linker subset, not completion. The open work below is -the gap between "Rust examples link" and "the platform linker can be replaced -over the support set." - -## Workstreams - -### 1. Ordered input and dependency selection - -ELF `--as-needed` must become real linker semantics, not a driver/script -special case. - -The linker needs to model, per ordered DSO input: - -- current link mode (`default`, `--as-needed`, `--no-as-needed`), -- selected/not-selected state, -- why it was selected, -- SONAME or fallback identity for `DT_NEEDED`, -- the exports made available to later inputs. - -ELF behavior: - -- default/no-as-needed DSOs are selected as explicit dependencies, -- as-needed DSOs are selected only when they satisfy an eligible strong - undefined reference at their position, -- unselected as-needed DSOs do not satisfy later references, -- selected DSOs are the source of `DT_NEEDED`, -- duplicate SONAMEs are suppressed while preserving first selected order. - -GNU linker scripts should lower into the same ordered input model: - -- `INPUT(...)` inserts normal inputs with the current mode, -- `GROUP(...)` preserves archive group behavior, -- `AS_NEEDED(...)` temporarily pushes as-needed mode for nested DSO or `-l` - tokens, -- nested scripts inherit and restore mode correctly. - -Mach-O and COFF need separate parity checks rather than ELF semantics: - -- Mach-O dylib load commands and weak/imported symbol behavior should follow - Darwin linker expectations. -- COFF import libraries, auto-imports, weak externals, and library-set archive - search should remain COFF-specific. - -### 2. ELF TLS model planning - -ELF TLS must be planned before final relocation application and dynamic-section -synthesis. The planner should classify each TLS relocation or relocation -sequence by: - -- symbol locality and visibility, -- imported/preemptible vs. non-preemptible, -- weak undefined behavior, -- executable/PIE/shared/relocatable output, -- static vs. dynamic link, -- target ABI TLS variant and thread-pointer bias. - -The action should be explicit: - -- relax to local-exec, -- synthesize initial-exec, -- preserve or synthesize dynamic TLS/TLSDESC, -- preserve relocations for `-r`, -- reject with a precise diagnostic. - -This pass should own the decision; architecture code should own only the -instruction byte rewrite once a sequence has been validated. - -### 3. TLSDESC completion - -The current AArch64 TLSDESC path accepts Rust/LLVM executable-local cases by -relaxing the four instruction relocations to materialize the local-exec offset. -Full TLSDESC support needs more: - -- complete per-architecture relocation surfaces in `RelocKind`, ELF mapping, - relocation names, descriptor metadata, object read/write, and `objdump`; -- descriptor GOT/data allocation, normally two machine words per unique - `(symbol, addend)` descriptor; -- TLSDESC dynamic relocation emission in `.rela.dyn`; -- integration with `.dynsym`, symbol versions, `DT_NEEDED`, PIE address shifts, - section GC, and DSO selection; -- checked sequence relaxation for AArch64, then x86_64, then other ELF - architectures as ABI support lands; -- shared-library output that preserves or synthesizes dynamic TLS where final - executable layout is not known. - -Imported TLSDESC references are real DSO uses and must select their provider -under `--as-needed`. - -### 4. Architecture relocation and relaxation parity - -Each supported architecture needs both relocation coverage and checked -relaxations for the code shapes produced by modern toolchains. - -Open areas include: - -- AArch64 ELF: complete TLSDESC dynamic path, validate local sequence - relaxation as a unit, cover host-toolchain GOT/TLS/unwind relocation shapes. -- x86_64 ELF: complete TLS-LD/GD/IE/LE relaxation behavior and add TLSDESC - once modeled. -- RISC-V ELF: keep expanding TLS-GD/LD pattern coverage conservatively, handle - compressed-instruction variants, and preserve dynamic TLS paths where local - relaxation is illegal. -- Mach-O AArch64/x86_64: finish GOT/TLV/unwind relocation parity against - Apple-produced objects and static archives. -- COFF AArch64/x86_64: continue matching MinGW/LLVM relocation spellings, - especially SECREL, unwind, pdata/xdata, and CRT object patterns. - -Relocation tests should pin both the descriptor table and representative byte -patches so enum additions are covered immediately. - -### 5. Shared libraries and relocatable links - -Replacing a system linker requires more than executable links: - -- `-shared` should emit correct dynamic symbols, relocations, SONAME/install - names/import tables, and TLS behavior for each format. -- `-r` should preserve relocations and avoid making final TLS/layout decisions. -- PIE and non-PIE executable behavior must stay distinct. -- Copy relocations, protected visibility, weak imports, COMDAT/section groups, - init/fini arrays, unwind tables, and build IDs must compose with GC and - dynamic linking. - -The near-term Rust path mostly exercises final executables; shared-library and -relocatable coverage should be broadened deliberately. - -### 6. Runtime/sysroot interoperability - -System-linker replacement depends on finding the same runtime inputs as the -platform toolchain: - -- crt objects and default startup ordering, -- libc/libm/libpthread/librt/libdl/libutil and libc-specific linker scripts, -- compiler builtins and unwind libraries (`libgcc_s`, compiler-rt, Rust - `compiler_builtins`), -- platform dynamic loaders/interpreters, -- MinGW UCRT CRT and import libraries, -- FreeBSD versioned libc and auxiliary runtime libraries. - -The driver should prefer explicit command-line inputs, then sysroot/target -layout, and finally host defaults only where that is safe. Runtime validation -should distinguish linker failure from a too-minimal test container, such as an -Alpine image missing `libgcc_s.so.1`. - -### 7. Diagnostics and tracing - -The linker needs precise diagnostics for unsupported system-linker cases: - -- target, input file/member, symbol, relocation kind, and output mode; -- why a DSO was selected or skipped; -- which symbol caused an as-needed DSO to be kept; -- why a TLS access cannot be relaxed or represented dynamically; -- which runtime library or sysroot path was searched. - -Use `KIT_TRACE` for opt-in structured tracing. Do not add global state. - -## Validation matrix - -For each supported arch/OS/libc family, track these lanes: - -- Rust std executable link, -- Rust std runtime execution where a runner exists, -- Rust no-std/freestanding link, -- C/assembler object corpus link, -- archive order and group behavior, -- DSO/dylib/import-library dependency selection, -- TLS local and imported access, -- shared-library output, -- relocatable `-r` output, -- debug/unwind preservation. - -The first priority remains the verified Rust support set, because it is a good -system-linker driver and produces real toolchain objects. The second priority is -small focused fixtures for every feature above, so failures are explainable and -do not depend on large external runtimes. - -## Acceptance criteria - -`kit ld` is complete enough to be the system linker for a target when: - -- Rust and C toolchains can use it directly with only target/sysroot/runtime - configuration, not target-specific wrapper rewrites; -- executables and shared libraries run under the target runtime loader; -- `readelf`/`otool`/`llvm-readobj` dynamic metadata matches the platform - linker's dependency and relocation shape for covered cases; -- relocatable links preserve the relocation surface correctly; -- local TLS relaxations are legal and checked, and dynamic TLS is emitted when - required; -- archive/DSO/import-library selection follows target format semantics; -- negative cases fail early with actionable diagnostics. diff --git a/doc/plan/LINKER.md b/doc/plan/LINKER.md @@ -1,253 +1,317 @@ # Linker (planned work) -This roadmap covers where the kit linker is headed beyond the static -and JIT linking it does today. It is dominated by **incremental linking**: -two related but distinct workstreams — append-only growth of a live JIT -image (the `kit dbg` / `kit emu` consumer) and file-based incremental -object linking (the build-system consumer, the "m2" redesign). Both rest -on the same linker invariants — address stability, durable non-destructive -relocation records, content-keyed reuse — and both fall back to a correct -full link whenever a change cannot be proven local. For the linker's -current architecture, passes, and invariants see [../LINK.md](../LINK.md); -for how a resolved image runs in process see [../JIT.md](../JIT.md); for -the object substrate see [../OBJ.md](../OBJ.md); for the build-system layer -that consumes the file-incremental interface see [../BUILD.md](../BUILD.md) -and the distribution CAS in [../DISTRIBUTE.md](../DISTRIBUTE.md). - -## Why incremental, and the shared invariants - -The full link is always available and always correct. Incremental linking -is an *accelerator* gated on a soundness check: a correct-but-slow result -always beats a fast-but-wrong one. Three invariants hold across both -workstreams and must never be violated by any incremental path: +This roadmap covers where the kit linker is headed beyond the static and +JIT linking it does today. Two strands: **incremental linking** (making a +relink cost `O(change)` instead of `O(program)`) and **system-linker +compatibility** (making `kit ld` a drop-in replacement for the platform +linker over kit's target set). For the linker's current architecture, +passes, and invariants see [../LINK.md](../LINK.md); for the object +substrate and per-format machinery see [../OBJ.md](../OBJ.md); for how a +resolved image runs in process see [../JIT.md](../JIT.md). Design rationale +lives in those docs — this file tracks open work, not background. + +## Shared invariants + +Everything below rests on three linker invariants that the full-link path +already upholds and no incremental or compat path may violate: - **Address stability.** Once a runtime/file vaddr is observable it never moves. Unchanged atoms keep their bytes *and* their addresses, so their - relocations are never reapplied — this is what makes a patch cost - `O(change)`. Enforced by overwrite-in-slack / append-to-free-slot, - **never compact**. + relocations are never reapplied. Enforced by overwrite-in-slack / + append-to-free-slot, **never compact**. - **Relocations are durable, relative, and symbolic.** `LinkRelocApply` - records survive as data and are not burned into bytes before emit. - Persist each as `(atom, offset-within-atom, kind, target-name, addend)`; - derive the absolute write address and target address from *current* - placements at apply time. An atom that moves then needs zero reloc - rewriting. -- **Content-hash keying, not transient IDs.** `LinkInputId`/`LinkSymId` - are stable only in-process. Persisted state is keyed by content hashes - and symbol names, never by re-derived IDs, so determinism is a dedup - nicety, not a correctness requirement. - -## Workstream 1 — append-only incremental JIT link - -Grow one live `KitJit` image with additional compiled objects while -keeping every previously published runtime address stable. New code may -reference old symbols; old debugger surfaces (`kit_jit_lookup`, -`kit_jit_addr_to_sym`, symbol iteration, breakpoints, PC translation, -the JIT debug view) must see new symbols. This is explicitly *not* hot -reload: existing code is never replaced or repatched (see -[../DBG.md](../DBG.md) for the debugger and the separate hot-reload -design). - -### Done (baseline) + records survive as data, not burned into bytes before emit: + `(atom, offset-within-atom, kind, target-name, addend)`, resolved against + *current* placements at apply time. A moved atom needs zero reloc rewriting. +- **Content-hash keying, not transient IDs.** `LinkInputId`/`LinkSymId` are + in-process only; any persisted state is keyed by content hashes and symbol + names. Determinism is a dedup nicety, not a correctness requirement. + +Incrementality is an *accelerator* gated on a soundness check: a +correct-but-slow full link always beats a fast-but-wrong patch, and any path +that cannot prove a change local falls back to a clean full link. + +--- + +# Incremental linking + +## Baseline (done): append-only JIT link The in-process append path is implemented and is the foundation the rest -builds on: the `kit_jit_publish` surface (an append/replace batch driven -by a `KitLinkSession`, reporting a bumped generation); append cursors with -reserved RX/R/RW/TLS slack over one contiguous master -VA reservation committed page-by-page; transactional rollback of cursors -and symbol/section/reloc counts on failure; generation-bumped invalidation -of the cached `kit_jit_view`; symbol resolution against the existing -image, the append batch, and the external resolver with duplicate-strong -detection; and a `dbg` REPL that drives compile → append → DWARF refresh -with the worker stopped so line-table replacement never races a running -thread. - -### Remaining - -- **Pending source-level breakpoints across appends.** Today a `b - file:line` for a file not yet covered stays unresolved until retried. - Maintain pending source breakpoint specs and arm them automatically - after each append. -- **Archive reselection on append.** v1 resolves a snippet against the - already-linked image plus the external resolver only. A later cut can - let appended inputs pull fresh archive members, sharing the gate logic - from the file-incremental gate (below). -- **`kit emu`'s append consumer.** Per-basic-block JIT translation wants - to grow a single `LinkImage` as cold blocks land (see - [../EMU.md](../EMU.md) §6). This is a separate consumer of the same - append machinery and lands alongside the emu lifter cut. It is the - motivation for the `link_resolve_at(Linker*, base_va)` / - `link_resolve_extend(Linker*, LinkImage*)` entries in `src/link/link.c`, - which are **panic stubs today** — see the shared surface below. -- **Promote the API.** `kit_jit_publish` stays experimental until a - second consumer (emu) exercises its append/replace batch, then settles - as the stable extend surface. - -## Workstream 2 — file-based incremental object link (the "m2" redesign) - -The goal is "instant" relinks for dev builds: after editing one -translation unit in a project of *N* TUs, the *link* cost should be -`O(changed atoms + their relocations)`, not `O(whole program)`. Compile -cost (caching, dependency scanning, the build graph and watch/daemon -modes) is the build system's problem and is out of scope here — this -workstream is the obj/link substrate that layer stands on. Incremental -link is a `-O0`/`-O1` *dev* feature; release builds (`--incremental` off) -always full-link, clean, and remain the canonical reproducible artifact. - -### Done (baseline) — "Done for ELF" - -The first cut landed on ELF as the reference format, with the acceptance -suite (`test/link-incremental/`) green on **ELF/aa64 + ELF/x64**: atom -content identity, per-atom reloc/symbol indices, the `LinkSession` with -per-segment cursors/slack/free-list, append-only extend, patch-in-slack, -the soundness gate with transactional rollback, per-segment build-id, -per-changed-TU debug regen, and move-on-grow via thunk. This is the -starting point; the rest of this section is what is *not* yet built. - -### The m2 redesign — design intent - -The redesign's central decision: **incrementality is not a parallel API — -it is the existing link session made fully mutable.** A full link is the -degenerate cold case (no prior state, nothing replaced); an incremental -relink seeds prior state and replaces the changed inputs. The build system -always drives *the same* session, and `resolve` internally decides -patch-vs-full and reports which via an outcome enum -(`FULL` / `PATCHED` / `FELL_BACK_FULL`). There is no separate "incremental" -entry point that could drift from the full-link path. This directly -matches the internal direction where `link_resolve` is "inputs → image" -and the `link_resolve_at` / `link_resolve_extend` surface makes that -resolve extend-capable. - -The atom is the patch unit — one function or one data object. Under -`--incremental`, frontends emit one section per function/global (a -`-ffunction-sections`/`-fdata-sections` equivalent) so each atom is -independently placeable; kit already lays out kept atoms as individual -`LinkSection`s. Each atom gets a BLAKE2b content id over its canonical -form (`bytes || align || flags || canonical(relocs)`), the diff key. - -### The soundness gate - -Reuse is correct only when the change cannot alter symbol resolution. The -edit is local only when the changed object's *interface* (defined global -names + bindings, COMMON sizes/aligns, set of undefs) is unchanged and no -archive pull-in changes; anything that can shift layout or resolution — -symbol-set/binding flips, new archive members, COMDAT/COMMON merge changes, -TLS-size shifts, import-set changes, slack/free-list exhaustion (data is -never thunked), or layout-affecting flags — forces a fall-back. On -fall-back the half-mutated session is discarded via the `LinkPatchTxn` -watermark and a correct full link runs; the JIT append path's -duplicate-global preflight is the precedent, but it **panics**, so -converting "detect non-local" into "roll back + full link" is the new -control flow at the heart of the redesign. See [../LINK.md](../LINK.md) for -the full trigger set and rollback mechanics. - -### The move-on-grow primitive (swappable) - -When an atom outgrows its slot it must move, and callers must still reach -it without their bytes changing. This is abstracted behind a single -`LinkMoveOps.atom_moved` hook with two implementations; the rest of the -design (atoms, slack, free-list, persisted session, the gate) is identical -either way. - -- **Thunk-on-grow — ship first.** Calls stay direct (what codegen emits - today). On a move, leave a jump island at the atom's *old* slot pointing - to the new location; callers branch to the old address and hit the - island. No codegen change, reachability is free by construction, and the - tax is one extra jump only for functions that actually moved. Reuses the - existing JIT call-stub island shape per arch. Data cannot be thunked, so - a grown data atom that outgrows its slack falls back to a full link. -- **GOT-cell — convergence target.** Under `--incremental`, codegen emits - cross-unit calls and movable-data loads through a GOT cell; a move - updates one cell. Costs a per-arch codegen change and a uniform extra - indirect load, and needs reserved GOT slack + a GOT free-list (the GOT - is one exactly-sized end segment today). Its strategic value is that it - is the *same* primitive hot reload assumes, so one mechanism would serve - both JIT hot reload and file incremental link. Build it when hot reload - is scheduled, designed then to serve both — unifying earlier is - speculative. - -### Persisted incremental state - -Side-band and content-addressed — **not** ELF-embedded incremental -sections, because kit is multi-format. Store one blob in the existing -`driver/dist` BLAKE2b CAS, recording per input and per atom: object + atom -content ids, the `LinkAtomPlace` table (vaddr / file_offset / size / -capacity / bucket), symbol→vaddr bindings keyed by *name*, relocations in -relative+symbolic form, and free-list + per-segment cursor state. The -session reads/writes it as opaque bytes through `KitWriter`; the build -system owns the key, CAS storage, and lifetime — libkit stays IO/CAS-free. - -### Remaining work +builds on. `kit_jit_publish` (an append/replace batch over a +`KitLinkSession`, reporting a bumped `kit_jit_generation`) grows one live +`KitJit` image with additional compiled objects while keeping every +previously published runtime address stable: append cursors with reserved +RX/R/RW/TLS slack over one master VA reservation, transactional rollback on +failure, generation-bumped invalidation of the cached `kit_jit_view`, +resolution against the existing image + append batch + external resolver +with duplicate-strong detection, and a `dbg` REPL that drives +compile → append → DWARF refresh with the worker stopped. This is *not* hot +reload: existing code is never replaced or repatched. Mechanics live in +[../LINK.md](../LINK.md) / [../JIT.md](../JIT.md) / [../DBG.md](../DBG.md). + +### Open + +- **Pending source-level breakpoints across appends.** A `b file:line` for a + file not yet covered stays unresolved until retried. Keep pending source + breakpoint specs and arm them automatically after each append. +- **Archive reselection on append.** v1 resolves an appended snippet against + the already-linked image + external resolver only. Let appended inputs + pull fresh archive members, sharing the file-incremental soundness gate. +- **`kit emu`'s append consumer.** Per-basic-block JIT translation wants to + grow a single `LinkImage` as cold blocks land (see [../EMU.md](../EMU.md) + §6). It is a second consumer of the same append machinery and the + motivation for the `link_resolve_at` / `link_resolve_extend` stubs below. +- **Promote the API.** `kit_jit_publish` stays experimental until emu + exercises its append/replace batch, then settles as the stable extend + surface. + +## File-based incremental object link (the "m2" redesign) + +Not yet built. Goal: after editing one TU in an *N*-TU project, the *link* +cost is `O(changed atoms + their relocations)`. Compile-side caching, dep +scanning, and the build graph are the build system's problem +([../BUILD.md](../BUILD.md)) — this is the obj/link substrate that layer +stands on. Incremental link is a `-O0`/`-O1` *dev* feature; release builds +always full-link and stay the canonical reproducible artifact. + +The current full-link path already preserves the invariants above and +carries the hooks the redesign plugs into (see the comments threaded through +`src/link/link.c` / `link_layout.c`), but none of the patch machinery +exists yet. Design intent and the full trigger/rollback set live in +[../LINK.md](../LINK.md); the actionable items: - **Resolve the panic stubs.** `link_resolve_at` and `link_resolve_extend` - in `src/link/link.c` are still `compiler_panic` stubs on the main path. - They are the public extend-capable surface for both the file-incremental - consumer and the emu append consumer; wiring them to the `LinkSession` - patch/extend logic (and to graceful fallback rather than panic on - exhaustion) is the remaining integration step to land the redesign on - the main resolve path. -- **Non-ELF formats.** The atom + slack + move-primitive core is - format-agnostic; the difference is per-format machinery, so the order is - ELF (done) → COFF/PE → Mach-O. COFF/PE is the incremental-friendly case - (IAT-indirected imports, per-page base relocs, side-band PDB debug) and - is gated mainly on kit's COFF maturity — see [../OBJ.md](../OBJ.md). - Mach-O is heaviest but feasible last: each of `__LINKEDIT` fixups, the - export trie, the indirect symtab, and the per-page code-signing - CodeDirectory needs a bounded (not `O(image)`) incremental updater. Until - a format's updater lands, that format falls back to the fast in-process - full link. -- **GOT-cell move primitive.** Deferred until hot reload is scheduled - (above); the free-list, slack, session, and gate are reused verbatim - when it lands — only `LinkMoveOps` changes. -- **rv64 patch path.** The per-arch surface is small — the island/cell - shape and the branch-into-island reloc kind. CI exercises ELF/aa64 + - ELF/x64 first; rv64 follows by adapting its trampoline shape. + in `src/link/link.c` are `compiler_panic` today. They are the public + extend-capable surface for both the file-incremental and emu-append + consumers. Wiring them into a mutable `LinkSession` (patch-vs-full decided + internally, reported via a `FULL` / `PATCHED` / `FELL_BACK_FULL` outcome, + with graceful fallback rather than panic on exhaustion) is the central + integration step. Incrementality is *the same session made mutable*, not a + parallel API: a full link is the degenerate cold case. +- **Atom model + soundness gate.** Under `--incremental`, frontends emit one + section per function/global so each atom is independently placeable; each + atom gets a BLAKE2b content id over `bytes || align || flags || + canonical(relocs)`. Reuse is sound only when the changed object's + *interface* (defined global names/bindings, COMMON sizes/aligns, undef set) + is unchanged and no archive pull-in changes; any symbol-set/binding flip, + new archive member, COMDAT/COMMON-merge change, TLS-size shift, import-set + change, slack exhaustion, or layout-affecting flag forces a fall-back via a + `LinkPatchTxn` watermark + clean full link. The JIT preflight is the + precedent but it *panics*; converting "detect non-local" into "roll back + + full link" is the new control flow. +- **Move-on-grow primitive (swappable behind `LinkMoveOps.atom_moved`).** + Ship **thunk-on-grow** first: calls stay direct, a moved atom leaves a jump + island at its old slot (reusing the per-arch JIT call-stub shape); no + codegen change, data that outgrows its slack falls back to full link. The + **GOT-cell** variant (one cell updated per move, the same primitive hot + reload needs) is the convergence target — build it when hot reload is + scheduled, so one mechanism serves both; unifying earlier is speculative. +- **Persisted incremental state.** Side-band and content-addressed (kit is + multi-format — **not** ELF-embedded sections). One blob in the `driver/dist` + BLAKE2b CAS per input/atom: content ids, the `LinkAtomPlace` table, + name-keyed symbol→vaddr bindings, relative+symbolic relocations, free-list + + cursor state. libkit reads/writes it as opaque bytes through `KitWriter`; + the build system owns key, storage, and lifetime. +- **Non-ELF formats.** The atom/slack/move core is format-agnostic; order is + ELF → COFF/PE (incremental-friendly: IAT-indirected imports, per-page base + relocs, side-band PDB) → Mach-O (heaviest — `__LINKEDIT` fixups, export + trie, indirect symtab, per-page code-sign CodeDirectory each need a bounded + incremental updater). Until a format's updater lands it falls back to the + fast in-process full link. +- **rv64 patch path.** Small per-arch surface (island/cell shape + + branch-into-island reloc kind); follows ELF/aa64 + ELF/x64 by adapting the + trampoline shape. - **Incremental build-id.** Per-segment FNV-1a subhashes combined Merkle-style so a patch re-hashes only changed segments, replacing the - current whole-image `O(image)` build-id. Keep this FNV-1a distinct from - the BLAKE2b used for content/CAS keying. -- **Determinism regression lock.** Object emission is already - byte-deterministic; lock it with a two-compiles-equal regression test to - enable cross-machine / shared-cache dedup. Content/name keying stays the - correctness backbone so any future drift degrades dedup, never - correctness. - -### Frontend contract and debug-info consistency - -All frontends converge to `ObjBuilder` and join the shared path at -`obj_finalize`, so the machinery attaches once, frontend-agnostically — -Toy, asm, and WASM get incremental link with no frontend-specific code. To -be incrementally safe a frontend must produce deterministic output for -identical `(source, flags, target, deps)`, declare its external dependency -set (C reuses `KitDepIter`; single-source frontends report none), use -stable source-derived symbol names, and expose a `frontend_id` + -`schema_version` that salts the build-system key. Toy's durable-module REPL -path is not a pure function of source, so it folds the module snapshot into -the input key or opts out of caching; Toy's batch/file compile conforms -like any other frontend. + current whole-image `O(image)` build-id (kept distinct from the BLAKE2b + content/CAS keying). +- **Determinism regression lock.** Object emission is byte-deterministic; + lock it with a two-compiles-equal test so cross-machine / shared-cache + dedup is safe. Content/name keying stays the correctness backbone, so drift + degrades dedup, never correctness. + +### Frontend contract and debug info + +All frontends converge to `ObjBuilder` at `obj_finalize`, so the machinery +attaches frontend-agnostically (Toy, asm, WASM included, no per-frontend +code). To be incrementally safe a frontend must be a pure function of +`(source, flags, target, deps)`, declare its external dependency set (C +reuses `KitDepIter`; single-source frontends report none), use stable +source-derived symbol names, and expose `frontend_id` + `schema_version` that +salt the build-system key. Toy's durable-module REPL path is not pure → folds +the module snapshot into the key or opts out of caching. On debug info: on any changed atom, **re-emit that TU's full `.debug_*`**. -kit emits one monolithic `.debug_line` program and one `.debug_info` CU -with intra-CU `DW_FORM_ref4` offsets, so a function's rows cannot be spliced -in isolation; and a body change rewrites the instruction→line mapping even -when the atom did not move, so "keep stale `.debug_line`" is incoherent. -Per-TU regen is `O(changed TU)`, cheap relative to the rest of the patch, -and unchanged TUs' debug stays byte-stable because their atoms keep their -addresses. Per-function CUs for `O(atom)` debug are a future option, not -pursued now. See [../DWARF.md](../DWARF.md). - -## Acceptance: definition of done per format - -The executable spec lives in `test/link-incremental/`, authored test-first -(red → green). Its synthetic multi-TU fixture (core TUs archived into a -static library linked into two executables that share it; no third-party -deps) covers an in-slack body edit (`PATCHED`, every vaddr stable, -whole-program `link_resolve` counter does not increment), a grow-past-slack -edit (`PATCHED`, atom moves, jump island at the old address, caller bytes -byte-identical), the soundness gate (each non-local edit ⇒ +kit emits one monolithic `.debug_line` and one `.debug_info` CU with intra-CU +`DW_FORM_ref4` offsets, so a function's rows can't be spliced in isolation, +and a body change rewrites the instruction→line map even without a move. +Per-TU regen is `O(changed TU)` and unchanged TUs stay byte-stable (their +atoms keep their addresses). Per-function CUs for `O(atom)` debug are a +future option, not pursued now. See [../DWARF.md](../DWARF.md). + +### Acceptance per format + +Author the suite test-first (`test/link-incremental/`, red → green) over a +synthetic multi-TU fixture (core TUs archived into a static lib linked into +two exes that share it; no third-party deps): in-slack body edit (`PATCHED`, +every vaddr stable, the whole-program resolve counter does not increment), +grow-past-slack edit (`PATCHED`, atom moves, jump island at old address, +caller bytes byte-identical), the soundness gate (each non-local edit ⇒ `FELL_BACK_FULL` matching a from-scratch link), multi-output consistency, -determinism, and a no-op relink. The two gates that define correctness are +determinism, and a no-op relink. The two correctness gates are vaddr-stability on a patch and fall-back on a non-local edit; both must be -green before a format is "done." ELF/aa64 + ELF/x64 are done; COFF, Mach-O, -and the rv64 patch path each repeat this bar. See -[../TESTING.md](../TESTING.md). +green before a format is "done." ELF/aa64 + ELF/x64 first; COFF, Mach-O, and +rv64 each repeat the bar. See [../TESTING.md](../TESTING.md). + +--- + +# System-linker compatibility + +Make `kit ld` act as the system linker over kit's support set. Near-term +driver: Rust through `rustc`; target: C, Rust, asm objects, archives, +DSOs/dylibs/import libs, PIE exes, shared libs, and relocatable links all +behaving like the platform linker for the supported arch/OS pairs (Mach-O on +macOS aa64/x64; ELF on Linux glibc/musl aa64/x64/rv64, FreeBSD, and +freestanding aa64/x64/rv64/rv32; COFF/PE on Windows UCRT MinGW aa64/x64). The +linker preserves format boundaries — ELF `--as-needed`/TLSDESC rules never +leak into Mach-O dylib or COFF import-library semantics. + +## Covered (tested via `test-link`) + +These paths are landed and exercised by the linker harness; only widen as +gaps surface: + +- **System-linker flag acceptance** — `kit ld` takes the common flags Rust + passes (GNU `-Wl,`/`-z`, target/sysroot, linker scripts, rlibs, `-l:name`, + `-static-pie`, `-nodefaultlibs`, hosted/no-startfile, PE/MinGW, Mach-O + version/platform), and Rust std + no-std link through it across the support + set. +- **Relocatable `-r` links** — `link_relocatable.c` builds a fresh + `ObjBuilder` (preserving object structure + unresolved externals) for + `KIT_LINK_OUTPUT_RELOCATABLE` ET_REL / MH_OBJECT output. +- **Shared-library output** — `-shared` / `KIT_LINK_OUTPUT_SHARED` emits a + loadable ET_DYN with `.dynsym`/`.dynstr`/`.dynamic`/`.rela.dyn` + (`src/obj/elf/link_dyn.c`). +- **ELF symbol-version imports** — explicit `name@VERSION` resolution + + Verneed/Vernaux synthesis (FreeBSD libc compat); `elf_version_import_test.c`. +- **COFF library-set search** — global fixed-point archive search + + weak-alias handling matching MinGW/LLVM; `coff_archive_fixpoint_test.c`, + `coff_weak_alias_test.c`. +- **Relocation descriptor + byte-patch coverage** — the per-arch `RelocDesc` + table and representative byte patches are pinned (`reloc_desc_test.c`, + `reloc_apply_test.c`), including the AArch64 TLSDESC and RISC-V TLS-GD + relocation spellings. +- **Local-exec TLS relaxation** — aa64 (the four TLSDESC relocs), x86_64 + (TLS-LD/GD), and rv64 (TLS-GD, incl. the compressed gap) relax local + defined TLS; `jit_tls_relax_test.c`, relax paths in `src/obj/elf/link.c`. + +This is a useful subset, not completion. The work below is the gap between +"Rust examples link" and "the platform linker can be replaced." + +## Open workstreams + +### 1. Ordered input and dependency selection (real `--as-needed`) + +The driver parses `--as-needed`/`--no-as-needed` into per-input `KitLinkMode`, +but the resolver does **not** act on it: `collect_needed` +(`src/obj/elf/link_dyn.c`) pulls *every* explicitly-supplied DSO into +`DT_NEEDED` regardless of mode. Make as-needed real linker semantics by +modeling, per ordered DSO input: current link mode, selected/not-selected +state and why, SONAME/fallback identity for `DT_NEEDED`, and exports made +available to later inputs. Then: + +- default/no-as-needed DSOs select as explicit deps; as-needed DSOs select + only when they satisfy an eligible strong undef at their position; +- unselected as-needed DSOs do not satisfy later references; +- selected DSOs are the `DT_NEEDED` source; duplicate SONAMEs suppressed + preserving first-selected order. +- GNU linker scripts lower into the *same* ordered model: `INPUT`/`GROUP` as + today plus `AS_NEEDED(...)` pushing/restoring as-needed mode for nested DSO + or `-l` tokens, with nested scripts inheriting/restoring mode. +- Mach-O (dylib load commands, weak/imported behavior) and COFF (import libs, + auto-imports, weak externals) get separate parity checks, not ELF semantics. + +### 2. ELF TLS model planning + +Plan ELF TLS *before* final reloc application and dynamic-section synthesis. A +planner classifies each TLS reloc/sequence by symbol locality/visibility, +imported-preemptible vs. non-preemptible, weak-undef, output kind +(exe/PIE/shared/`-r`), static vs. dynamic, and target TLS variant + +thread-pointer bias, then picks an explicit action: relax to local-exec, +synthesize initial-exec, preserve/synthesize dynamic TLS/TLSDESC, preserve +relocs for `-r`, or reject with a precise diagnostic. This pass owns the +decision; arch code owns only the validated instruction-byte rewrite. + +### 3. TLSDESC completion + +Today only executable-local cases relax (materializing the local-exec offset). +Full support needs: complete per-arch TLSDESC surfaces (`RelocKind`, ELF +mapping, names, descriptor metadata, read/write, `objdump`); descriptor +GOT/data allocation (two words per unique `(symbol, addend)`); TLSDESC dynamic +relocations in `.rela.dyn`; integration with `.dynsym`, symbol versions, +`DT_NEEDED`, PIE shifts, section GC, and DSO selection; checked sequence +relaxation aa64 → x86_64 → other ELF arches; and shared-library output that +preserves/synthesizes dynamic TLS where final layout is unknown. Imported +TLSDESC references are real DSO uses and must select their provider under +`--as-needed` (workstream 1). + +### 4. Architecture relocation and relaxation parity + +Per-arch reloc coverage + checked relaxations for modern-toolchain code +shapes: aa64 ELF (dynamic TLSDESC path, local-sequence relaxation validated as +a unit, host GOT/TLS/unwind shapes); x86_64 ELF (full TLS-LD/GD/IE/LE +relaxation + TLSDESC once modeled); RISC-V ELF (expand TLS-GD/LD conservatively, +compressed variants, preserve dynamic TLS where local relaxation is illegal); +Mach-O aa64/x64 (GOT/TLV/unwind parity vs. Apple objects/archives); COFF +aa64/x64 (SECREL, unwind, pdata/xdata, CRT object patterns). Every enum +addition pins both descriptor table and a byte patch immediately. + +### 5. Shared libraries and relocatable links — broaden coverage + +Baseline `-shared`/`-r` emit (above) is exercised mostly on final +executables. Deliberately broaden: `-shared` dynamic symbols/relocs/SONAME + +install-names/import-tables + per-format TLS; `-r` preserving relocs and +deferring final TLS/layout decisions; PIE vs. non-PIE staying distinct; and +copy relocations, protected visibility, weak imports, COMDAT/section groups, +init/fini arrays, unwind tables, and build IDs composing with GC and dynamic +linking. + +### 6. Runtime/sysroot interoperability + +Find the same runtime inputs as the platform toolchain: crt objects + startup +ordering; libc/libm/libpthread/librt/libdl/libutil + libc linker scripts; +builtins/unwind libs (`libgcc_s`, compiler-rt, Rust `compiler_builtins`); +dynamic loaders/interpreters; MinGW UCRT CRT + import libs; FreeBSD versioned +libc. The driver prefers explicit command-line inputs, then sysroot/target +layout, then host defaults only where safe. Runtime validation distinguishes a +linker failure from a too-minimal container (e.g. Alpine missing +`libgcc_s.so.1`). See [SYSROOTS.md](SYSROOTS.md). + +### 7. Diagnostics and tracing + +Precise diagnostics for unsupported cases — target, input file/member, +symbol, reloc kind, output mode; why a DSO was selected/skipped and which +symbol kept an as-needed DSO; why a TLS access can't relax or be represented +dynamically; which runtime/sysroot path was searched. Opt-in structured +tracing via `KIT_TRACE`; no global state. + +## Validation matrix + +Per supported arch/OS/libc family, track: Rust std link + runtime exec (where +a runner exists), Rust no-std/freestanding link, C/asm object-corpus link, +archive order/group behavior, DSO/dylib/import-lib selection, TLS local + +imported access, shared-library output, relocatable `-r` output, and +debug/unwind preservation. First priority is the verified Rust support set (a +good driver producing real toolchain objects); second is small focused +fixtures per feature so failures are explainable without large external +runtimes. + +`kit ld` is the system linker for a target when: Rust and C toolchains use it +directly with only target/sysroot/runtime config (no per-target wrapper +rewrites); exes and shared libs run under the target loader; +`readelf`/`otool`/`llvm-readobj` dynamic metadata matches the platform linker +for covered cases; `-r` preserves the relocation surface; local TLS +relaxations are legal and checked and dynamic TLS is emitted when required; +archive/DSO/import-lib selection follows format semantics; and negative cases +fail early with actionable diagnostics. diff --git a/doc/plan/O1-PATTERNS.md b/doc/plan/O1-PATTERNS.md @@ -1,554 +0,0 @@ -# O1 code-quality patterns: the post-W1 disassembly catalog - -A curated catalog of the *residual* `-O1` vs clang `-O1` code-quality gaps, from a -per-file disassembly audit of the ecosystem corpus **after the entire O1.md §3 -worklist (W1–W10) landed** (2026-06-16, aggregate `__TEXT` ≈ 1.09× clang). This -is the successor to that worklist: it identifies what remains, ranks the -linear/no-SSA wins still on the table, and cleanly separates them from the gaps -that genuinely require the parked O2 SSA mid-end. - -> **Update (2026-06-16): all of §2 (L1–L10) is landed.** Implemented in parallel -> (worktree-isolated agents) and merged gated. Aggregate `__TEXT` **1.09× → -> 1.059× clang** (lvm 2.86× → **2.57×** via L1; yyjson 0.86× → **0.80×** via -> L6/L4/L2; sqlite 1.11× → **1.09×**; lparser 1.07× → **1.04×** via L9; tinyexpr -> via L10). Per-item: **L1** store→load forwarding (`pass_combine.c`, lvm −9.95%), -> **L2** cset→cmp_branch fusion (same-block subset; cross-block stays O2), **L3** -> widen `aa_set_bytes` zero-init (`aa64/native.c`, cjson −5.4%), **L4** double-cset -> drop, **L5** confirmed already-subsumed by `try_substitute`/`try_ret_retarget` -> (no unsound new fold; the scratch-source residual is a miscompile, left out), -> **L6** sxt-after-load drop (yyjson −5.45%), **L7** shift-into-ALU + **L8** -> sxtw-index-into-addressing (Operand riders, aa64-only; x64/rv64 capability-gated -> off), **L9** const div/mul fold (`pass_simplify.c`, lparser udiv 22→0), **L10** -> frameless-leaf elision. L9 also surfaced + got fixed a **latent volatile-load -> miscompile** (a volatile load of a register-resident local was served from the -> value-residency fast path with no observable memory access emitted; fixed in -> `src/cg/memory.c`). Gates: test-opt (18 guards) + test-toy 1392/0 + ecosystem -> 28/0 + smoke-x64/rv64 3/0 + test-cg-api 289/289. The §3 (SSA/O2) and §4 -> (not-a-deficiency) findings below stand. - -Read [O1.md](O1.md) first — especially §3 (the landed worklist), §4 (the SSA/O2 -out-of-scope list), and §5 (already-landed). Read [OPT.md](OPT.md) §3 for the O1 -pipeline and pass inventory. Every "linear" claim here is checked against those. - ---- - -## 1. Methodology - -**Harness.** `scripts/o1_quality.sh` builds the per-file kit (`.k.o`) and clang -(`.c.o`) objects into `build/o1_quality/`; disassemble with -`./build/release/kit objdump -d NAME.k.o`. Per-file ratio = kit `__TEXT` ÷ clang -`__TEXT` (`size -m`). Instruction counts are real instruction lines (objdump -format is `addr:\t bytes \t mnemonic operands` — the mnemonic is after the -*second* tab; a naive `grep ':\t\w+'` matches the byte column and is wrong). - -**Corpus and current standings** (kit / clang `__TEXT`, all aarch64/Darwin `-O1`, -release kit): - -| file | kit/clang | note | -|---------|----------:|------| -| lz4 | **0.38×** | clang unrolls/inlines → clang bigger (NOT a kit gap) | -| yyjson | 0.86× | kit smaller (clang inlines far more) | -| lapi | 0.83× | kit smaller | -| tinyexpr| 0.96× | ~par | -| lparser | 1.07× | ~par; the SIZE_MAX/udiv guard pushes it over | -| cjson | 1.31× | aggregate/array zero-init dominates | -| miniz | 1.67× | csel + frame traffic in the deflate loops | -| sqlite | 1.11× | frame traffic + bit-tests + addr folds | -| lvm | **2.86×** | the lua interpreter loop — still the worst | - -W1.1 is confirmed landed in these objects: `sub xN,x29,#k` spill-address -recomputes are **0** in lvm (was 8,774) and sqlite (was 35,360); every spill slot -is a one-instruction positive `ldr/str [x29,#k]`. So the residual gap is no longer -*spill addressing* — it is the **spilling itself** (frame round-trips that a real -RA would not emit) plus a family of local peepholes the no-SSA O1 still misses. - -**Rubric.** Each pattern is graded LINEAR (a same-block / per-instruction forward -peephole, no SSA, no superlinear axis) · NEEDS-SSA-O2 (wants GVN/DSE/LICM/IV or a -cross-block register allocator) · NOT-A-DEFICIENCY (clang bigger via inlining/ -unrolling, or kit already smaller). Linearity verdicts and host-pass claims were -spot-checked against the live passes (`pass_combine.c:1914` compact rule, -`aa_set_bytes` at `aa64/native.c:2577`, `aa_add_lsl`/`aa_ldst_regoff` emitters). - -**Spot-checks performed** (all reproduced against the current `build/o1_quality/` -objects): lvm store→reload-different-register (1495 adjacent + 1962 within-4), -`mov wN,wM` 330 vs clang 5; cjson `strb` 338 with a 64-long consecutive run; -yyjson double-cset 573 triples and cset→cbnz 438. No high-impact pattern was -dropped on spot-check (one was *down*graded — see §4). - ---- - -## 2. Linear / n-log-n O1 opportunities - -The actionable shortlist, ranked by (impact × confidence × cross-file breadth). -This is the section that matters. - -### L1 — Store-to-load forwarding across a register mismatch ★ highest leverage - -**The gap.** After a spilled def is homed (`str rX,[slot]`) the O1 reload at the -next use targets a *fresh rotating scratch* (`ldr rY,[slot]`, `rY != rX`), so the -value round-trips through memory even though `rX` still holds it. The existing -adjacent compaction (`opt_combine_compact_block`, `pass_combine.c:1934`) collapses -the `str;ldr` pair **only when `same_reg_operand(store.src, load.dst)`** (rX==rY); -the dominant different-register case falls straight through to `bl->insts[w++]` -and survives. - -``` -kit (lvm _luaV_execute): clang keeps the bytecode word live: - str w10, [x29, #512] lsr x8, x28, #7 - ldr w9, [x29, #512] ; w9 != w10 add x8, x21, x8, lsl #4 - and w10, w9, #0xff ubfx w9, w28, #7, #8 - str w10, [x29, #512] ldr x10, [x9] - ldr w9, [x29, #512] ; again (no stack round-trip at all) -``` - -**Where / frequency.** lvm: **1495** adjacent `str;ldr` same-slot different-reg -(vs 79 same-reg already handled), 1962 within a 4-insn window; concentrated in -`luaV_execute`. lz4: 191 intra-block store-then-adjacent-reload pairs (the two -giant funcs). sqlite: ~8,500 store-then-reload-within-4. miniz: 1103 near same-slot -store→reload. **The single biggest cross-file contributor to the ldr/str excess** -(kit lvm ldr 4709 vs clang 731, str 3813 vs 214). - -**Impact.** Each forwarded reload becomes a `mov rY,rX`, which the existing copy -substitution then folds into rY's uses (often deleting the `mov`); when rX is dead -and the slot has no other reader, the store is dead too (→ W8 stack-DSE + mir_dce). -Conservatively ~1400 insns in lvm alone (~9% of the object), with real wins in lz4 -(~380) / sqlite (intra-block subset) / miniz. - -**Host pass + sketch.** `pass_combine.c`, `opt_combine_compact_block`. In the -`prev==IR_STORE && in==IR_LOAD && same_spill_slot_and_size` branch, drop the -`same_reg_operand` requirement: when the registers differ, rewrite the load -in-place to an `IR_COPY rY <- rX` instead of keeping it as a memory load. The -existing copy substitution / `mir_dce` retire the copy and (if rX dead + slot -unread) the store. The store-DSE half is W8, already present. - -**Stays linear:** single forward pass per block with the bounded last-store -tracking the compact pass already does; no new analysis, no cross-block reasoning. -Correctness boundary: only when no intervening clobber of rX, no aliasing memory -write, and no redefinition of the slot between str and ldr — exactly the adjacency -the compact pass already guarantees for the same-reg case. - -> Note vs O1.md: this is the natural extension of the **W8** machinery, not a new -> pass. W8 today finds 0 sites because W2/DCE clear the store-store shape upstream; -> the store→load-forward-on-mismatch shape is a *different* and very live pattern -> that no landed item touches. - -### L2 — Fuse `cmp; cset rD,cc; cbnz/cbz rD` → `cmp; b.cc` (boolean-into-branch) ★ broadest - -**The gap.** For `if (relational) goto L`, kit materializes the condition into a -GPR (`cset rD,cc`) and then re-tests it (`cbnz/cbz rD`), where clang branches -directly off the flags. The original `cmp`'s NZCV already encodes the branch; the -`cset` is the sole user and dies at the branch. - -``` -kit (yyjson _yyjson_arr_iter_init): clang: - cmp w8, #0 cmp w8, #0 - cset w12, eq b.eq 0x604 (or cbz w8,0x604) - cbnz w12, 0x604 -``` - -**Where / frequency — pervasive.** yyjson **438**, lvm 19 (→54 once L1 removes the -intervening homing), lapi 27, lparser 5, lz4 10, sqlite (part of the cbnz +2,440 -delta). clang emits ~0 of these. This is the most cross-file pattern in the -catalog. - -**Impact.** ~2 insns per site where a redundant cmp-against-0 also precedes the -cset (yyjson's "double-cset", see L4), else 1 insn (the cset). yyjson ~880 insns -(~2.1% of `__TEXT`); lapi ~27; broad small wins elsewhere. - -**Host pass + sketch.** `pass_combine.c` (or `pass_jump.c`, which already owns -`IR_CMP_BRANCH`/`IR_CONDBR` terminators). When a block terminator is `IR_CONDBR` -whose condition register's most-recent same-block def is a single-use `IR_CMP`, -fuse into `IR_CMP_BRANCH` carrying the cmp's `CmpOp`+operands (invert for the -cbz/`==0` case) and NOP the `IR_CMP`. This is the inverse of the fusion -`cg_ir_lower.c:941` already performs in the other direction; reuse the `CombineCtx` -`last_def` map + the existing hard-live use counts. - -**Stays linear:** same-block last-def lookup + single-use check, both already -computed; no SSA. - -### L3 — Widen aggregate/`memset` zero-init (one `strb` per byte → wide stores) ★ cjson/miniz - -**The gap.** `aa_set_bytes` (the AGG_SET / memset expander, `aa64/native.c:2577`) -emits exactly one `strb` per byte in a flat `0..size` loop, even for an aligned -whole-struct/array zero. A 64-byte zero is 64 `strb`; clang does it in 3–4 -(`movi v0.16b,#0; stp q0,q0,...`) or, scalarized, `str xzr` runs. - -``` -kit (cjson _cJSON_PrintPreallocated): clang: - add x8, x29, #16 movi v0.16b, #0 - movz x9, 0x0 stp q0, q0, [sp, #32] - strb w9, [x8] stp q0, q0, [sp] - strb w9, [x8, #1] ; 64 bytes in 3 insns - ... (64 consecutive strb) -``` - -**Where / frequency.** cjson: **338 `strb`, a 64-long consecutive run** confirmed -(296 of 338 are zero-byte stores from this path; clang has 30 strb total). -Hottest in `_cJSON_PrintPreallocated`/`_PrintBuffered` (64 each) and `_print_number` -(26). miniz: large `strb` surplus from PNG/struct buffers. - -**Impact.** Largest single win in cjson — ~250–300 insns / ~25% of cjson's 1236- -insn gap. Even a scalar widening (`str xzr` for 8-aligned runs, `str wzr`/`strh -wzr`/`strb wzr` tail) without NEON captures most of it. - -**Host pass + sketch.** `aa64/native.c` `aa_set_bytes`: rewrite the per-byte loop -to emit the widest aligned store covering the remaining run — `stp q`/`str q` if -NEON is permitted, else `str xzr`, `str wzr`, `strh wzr`, `strb wzr` tail; or `bl -_memset` above a size threshold. Pure per-call expansion, no analysis. The hardware -zero register is already used for stored zeros (`pass_native_emit.c`). - -**Stays linear:** it *removes* emitted instructions from a single expander; no new -pass. (rv64/x64 set_bytes should get the same widening for parity, but aa64 is the -measured win.) - -### L4 — Drop the redundant boolean re-normalize (`cset; cmp rD,#0; cset`) — yyjson - -**The gap.** kit lowers `(a==b)` to `cmp; cset rD,eq` (a 0/1 bool), then a -surrounding `bool != 0` / `if(bool)` context lowers to a **second** `cmp rD,#0; -cset rD,ne` that re-normalizes an already-0/1 value. The outer cmp+cset are a -no-op. - -``` -kit (yyjson _unsafe_yyjson_is_raw): clang (at each inline site): - cmp w8, #1 cmp w8, #1 - cset w8, eq cset w0, eq - cmp w8, #0 ; redundant - cset w8, ne ; w8 == w8 -``` - -**Where / frequency.** yyjson: **573** exact `cset; cmp #0; cset` triples (0 in -clang); dense in the `is_*`/`get_*` predicate family. (lapi/sqlite have the related -single-cset-of-a-bool but the triple is yyjson's signature.) - -**Impact.** ~2 insns each → ~1100 insns / ~2.6% of yyjson `__TEXT`. Combines with -L2: many of these feed a branch, so the L2 fusion subsumes the outer half. - -**Host pass + sketch.** `pass_combine.c` same-block forward peephole: when an -`IR_CMP`'s source operand's most-recent same-block def is itself an `IR_CMP` -(0/1 bool) and the comparison is against imm 0/1 with eq/ne, drop the outer -`IR_CMP` and rewrite its uses to the inner result (inverting for the `#0`/ne case). -Uses the existing `last_def` map + hard-live use counts. - -**Stays linear:** single-block, single-use, checkable from existing state. - -### L5 — Single-use register copy not coalesced into its consumer - -**The gap.** kit produces a value into one register then `mov`s it into the -register its sole consumer (a `sxtw`/store/call-arg/return) reads, instead of -producing directly into the final register. The cross-block coalescer W3 cannot -retire these because the destinations are multiply-defined physical pregs (W3's -`opt_copy_cleanup` only fires when `ndef[dst]==1`); the per-block `mir_combine` -substitution doesn't reach all the consumer paths. - -``` -kit (lvm): kit (lapi return): - ldrb w13, [x8, #11] and w12, w12, #0x7 - mov w12, w13 ; redundant mov w0, w12 ; redundant - sxtw x19, w12 ; sxtw x19,w13 ok ret ; and w0,w8,#0x7 ok -``` - -**Where / frequency.** Reg-to-reg `mov wN,wM`: lvm 330 vs clang 5; lapi 512 vs 194; -lparser 948 vs 541; miniz 1018 of 3203 are mov-of-prev-insn-dest; yyjson 209 feed a -uxtb + 59 are return copies (`mov w0,wN` before ret). The single largest `mov` -contributor across the value-heavy files. - -**Impact.** Several hundred insns across lapi+lparser+miniz+yyjson+lvm (conservative -after excluding genuinely class-crossing or still-live moves). - -**Host pass + sketch.** `pass_combine.c` post-RA peephole using the existing -`CombineCtx` liveness (`ctx_def_changed_since`, `count_uses_in_live_range`): for -`mov rD,rS` where rD has a single use in its live range before its next redef and -rS is not redefined between the mov and that use, forward rS to the use and drop -the mov. Extend the consumer set beyond what `try_substitute`/`try_sink` reach -today: convert/extend, store-value, call-arg, and `IR_RET` value operands. The -`IR_RET`→return-reg back-propagation mirrors the existing redundant-second-copy -handling at `pass_combine.c:~890`. - -**Stays linear:** the `CombineCtx` machinery already answers both guards; same-block. - -### L6 — Drop the redundant `sxtb`/`sxtw` after a same-width extending load - -**The gap.** Two shapes. (a) yyjson loads a signed byte as `ldrsb xD,[addr]` (full -64-bit sign-extend) then emits `sxtb wE,xD` for a W-domain consumer — but xD's low -32 bits are *already* the sign-extended byte, so the `sxtb` is a no-op (and clang -just does `ldrsb wD`). (b) lvm/sqlite `sxtw` of a value sourced from a zero-extending -`ldrb`/`ldrh`: kit loads always zero-extend, so a 0..255 byte widens identically -signed or unsigned → the `sxtw` is a same-width copy. - -``` -kit (yyjson _yyjson_mut_ptr_getx): clang: - ldrsb x12, [x21] ldrsb w9, [x13] - sxtb w8, x12 ; x12<31:0> == byte cmp w9, #34 - cmp w8, #47 -``` - -**Where / frequency.** yyjson: 112 `ldrsb x..; sxtb w..` + 74 `ldrb; sxtb`. lvm: -the `ldrb;mov;sxtw` triple subset (~6 tight + more across the body). sqlite: part of -the sxtw +2,582 (the ldrb/ldrh-rooted subset). This is the signed mirror of the -already-landed ZEXT-of-load fold (OPT.md §3 / commit 8826982d). - -**Impact.** yyjson ~186 insns; lvm ~30–80 from the byte-rooted cases; sqlite a slice -of the sxtw delta. Medium confidence on the broad count, high on the exact-shape -subset. - -**Host pass + sketch.** `pass_combine.c` `combine_exts` (already folds ext-of-ext): -a `SXTB`/`SXTH`/`SXTW` whose source's most-recent same-block def is a sign-extending -load of equal-or-smaller width, *or* a zero-extending narrow load whose value can't -have the sign bit set in the widened position, is redundant → drop it (rewrite uses -to the load dst). Folding `ldrb;sxtb`→`ldrsb` needs a load-with-extend MemAccess -rider (noted as structurally needed in prior O0 work); the pure -`sxtb`-after-`ldrsb`/`sxtw`-after-`ldrb` *drop* needs no rider and is the easy win. - -**Stays linear:** per-instruction, same-block last-def; no SSA. - -### L7 — Fold a single-use shift into the consuming ALU op (`lsl;add` → `add ...,lsl #k`) - -**The gap.** kit folds shifts into *memory* operands (`try_addr_synth` handles ISHL -into an indirect index scale) but not into general ALU consumers. So pointer -scaling whose sum is stored/compared (not a load address) emits `lsl xT,xS,#k; add -xD,xB,xT` where clang uses the one-instruction shifted-register form. - -``` -kit (lapi _index2value): clang: - sxtw x12, w1 sxtw x21, w1 ; once - lsl x12, x12, #4 add x8, x8, x21, lsl #4 - add x8, x13, x12 -``` - -**Where / frequency.** lvm 14 `lsl;add` (+`lsl;sub`); miniz 105 `lsl`-feeds- -add/orr/eor/sub (clang folds 74); lapi ~15–25; sqlite part of the lsl +1,335 delta. -kit emits ~0 shifted-register ALU ops; clang uses them heavily. Strongest in miniz's -LZ hash (`eor ...,lsl #5`) and byte-pack code. - -**Impact.** miniz ~105 insns; lvm/lapi ~20–30 each; plus a freed scratch that -relieves the spill pressure feeding L1. - -**Host pass + sketch.** `pass_combine.c` recognition + `aa64/native.c` emission. -Mark a single-use `IR_BINOP SHL reg,imm` (sh 1..4) feeding an `IADD`/`ISUB`/AND/ORR/ -EOR and emit the fused shifted-register form. The emitter exists (`aa_add_lsl`, -`native.c:750`; the ISA exposes `AA64_FMT_LOG_SR`/`ADDSUB_SR`) — it just isn't -reachable for non-memory consumers. Needs a shift rider on the register operand (the -current `Operand` only carries `log2_scale` inside the `ind` memory variant). x64 can -use this only for IADD (LEA scale); rv64 has no shifted-add → skip there. - -**Stays linear:** local single-use peephole, same live-range guards as the existing -ISHL-into-EA path. Medium confidence (needs the operand-model shift-rider). - -### L8 — Fold a `sxtw`/`uxtw` index into the load/store addressing mode - -**The gap.** For signed-int array indexing kit emits `sxtw` to widen the index, a -separate `add ...,lsl #scale` to form the address, then a plain `[reg]` access — -three instructions. aa64 has `[Xbase, Wm, sxtw #scale]` as a single addressing mode -(`aa_ldst_regoff`, `native.c:600`); clang uses it everywhere, kit zero times. - -``` -kit (miniz): clang: - sxtw x14, w12 ldr w8, [x9, w8, sxtw #2] - add x9, x0, x14, lsl #2 strh w9, [x26, w13, sxtw #1] - ldrh w20, [x9] -``` - -**Where / frequency.** miniz 15 `sxtw;add...,lsl` feeding a load/store (clang 98 -extended-reg memory operands, kit 0). sqlite **546** `sxtw` immediately feeding a -load/store offset. lapi/lparser index math. Big total across sqlite+miniz. - -**Impact.** sqlite ~546 insns (the standalone sxtw folds into the access); miniz -~100–200 (removes sxtw + the address-forming add). LINEAR slice of the larger -sxtw story (the cross-block redundant *re*-extend is NEEDS-SSA-O2, see §3). - -**Host pass + sketch.** `pass_addr_fold.c` already builds `OPK_INDIRECT` with -index + `log2_scale` but does not absorb a `sxtw`/`uxtw` of the index. Add an -extend-into-index rule: fold a single-use `sxtw`/`uxtw` producer of an index operand -into the indirect operand, recording the extend kind, and emit `aa_ldst_regoff_v` -with the extend in `aa64/native.c`. The scaled-index infra + REGOFF encoding exist. - -**Stays linear:** per-instruction forward fold, existing addr-fold machinery. - -### L9 — Constant-operand integer divide/multiply not folded - -**The gap.** kit materializes two constants and issues a runtime `udiv`/`sdiv`/`mul` -for fully compile-time-constant expressions (classically `ArraySize = sizeof/elem`, -and the Lua `MAX_SIZET/sizeof(T) >= LIMIT` growvector guard). The guard's SIZE_MAX -takes a 4-insn `movz/movk` chain into a register, then `udiv` + `cmp` + `b.hi`; -clang decides the always-true compare and materializes only the `LIMIT` immediate. - -``` -kit (sqlite _sqlite3_status64): clang: - movz x9, 0x50 cmp w0, #0xa - movz x10, 0x8 - udiv x8, x9, x10 ; = 10 at compile time - cmp w20, w8 -``` - -**Where / frequency.** sqlite 108 div/mul sites with both operands freshly `movz`'d. -lparser **22 udiv** (12 udiv-by-1, 10 the SIZE_MAX guard); lapi 9 sdiv; tinyexpr 5. -The SIZE_MAX guard is the dominant reason lparser is *larger* than clang. - -**Impact.** sqlite ~300–400 insns (each site collapses movz+movz+udiv+mov). lparser -~80–90 (the guard is ~7–8 insns → 1 movz at ~10 sites + the udiv-by-1). Each udiv is -also the slowest integer op, so a latency win too. - -**Host pass + sketch.** Two pieces. (a) `pass_simplify.c`: add a same-block forward -`LOAD_IMM`→known-value tracker (per-block reg→value map, invalidated on redef/block -boundary — no SSA, consecutive within-block) and feed it into `operand_const` so the -existing `BO_UDIV`/`BO_SDIV` folds and a new const-op-const fold fire when the -constant lives in a register rather than an inline `OPK_IMM`. The resulting `LOAD_IMM` -then lets `simplify_cmp` decide the always-true guard. (b) The divide-by-1 → -copy/sub falls out of (a). - -**Stays linear:** a bounded per-block reg map + the existing folds. Strength-reduction -of *non-constant* divide-by-constant (magic multiply, miniz adler32) is a separate, -also-linear `pass_lower.c` item but lower priority (~25 sites, medium confidence). - -### L10 — Frame elision on no-spill leaf functions - -**The gap.** kit emits a full `stp x29,x30,[sp,#-16]!; add x29,sp,#0 ... ldp` frame -in functions that are leaves (no call), spill nothing, and never reference `[x29]` — -i.e. need no frame. clang omits it entirely; for a one-op accessor the frame is the -majority of the function. - -``` -kit (tinyexpr _add): clang: - stp x29, x30, [sp, #-16]! fadd d0, d0, d1 - add x29, sp, #0 ret - fadd d0, d0, d1 - ldp x29, x30, [sp], #16 - ret -``` - -**Where / frequency.** yyjson 90 frameless-able leaves; lapi ~21; lparser ~10; -tinyexpr 8. The `is_*`/`get_*`/cast accessor family. (Caveat: many of these are -exactly the predicates clang *inlines away*, so the realizable win depends on -whether kit also inlines them — the frame-elision is independently correct for the -leaves that remain.) - -**Impact.** ~3 insns/function (stp+add+ldp). yyjson ~360, lapi ~63, lparser ~30, -tinyexpr ~24. Medium confidence on realizable total (the inlining caveat). - -**Host pass + sketch.** `aa64/native.c` prologue/epilogue (NativeFrame): a -per-function `needs_frame` flag = false when no `IR_CALL`, no spill slots, no -escaping `IR_ADDR_OF(local)`, no varargs. All known at frame-finalize time in one -pass; emit the stp/add/ldp only when set. x29 already sits at frame bottom (W1.1), so -omission is straightforward. - -**Stays linear:** single per-function check, no analysis. - ---- - -## 3. Needs SSA / the O2 mid-end - -Real gaps, but none is a linear no-SSA peephole — each maps to a parked O2 item -(OPT.md §3 schedule, `pass_o2.c`/`pass_ssa.c`; O1.md §4 out-of-scope list). - -- **Register-resident locals across blocks/calls (the over-spilling itself).** The - *largest* remaining gap on the big functions: kit's frame traffic is ~6× clang's - on sqlite (62,708 vs 10,412), ~4.3× on lz4, dominant in lvm/miniz. clang keeps - loop-carried accumulators/pointers in callee-saved regs across the whole live - range; kit's no-SSA linear-scan allocator (no splitting, falls to memory residency - in big bodies) cannot. **L1 (store→load forwarding) is the linear slice that - reaches the intra-block subset; the cross-block residency needs the SSA RA with - live-range splitting + global copy propagation.** → O2 register allocator + GVN. -- **csel / csinc / ccmp selection (if-conversion).** kit emits **0** csel/csinc/ccmp; - clang uses them heavily (lvm 72+19, miniz 218+34, sqlite 1,264+436, cjson 32, lz4 - 499). Every `x = cond ? a : b`, MZ_MIN/MAX clamp, and double→int saturate becomes a - compare+branch diamond with the selected value threaded through the frame (feeding - the spill gap). Doing it well needs a select op in the IR + a profitability/legality - gate that proves both arms side-effect-free and reasons about the merge of two - definitions — the SSA/O2 domain. (A narrow peephole for the exact - `cmp;b.cc;mov-imm;b;mov-imm` diamond could fit `pass_jump.c` but is fragile and - lower-value than the §2 items — *flag, do not prioritize*.) → O2 if-conversion. -- **`tbz`/`tbnz` from `and #(1<<k); cbz/cbnz`.** sqlite has 715 `and wN,#bit; cbz/cbnz` - that fold to a single `tbz/tbnz`; clang uses tbz/tbnz 3,102 times, kit 0. The - *narrow* single-bit-AND-feeding-an-adjacent-branch case is arguably LINEAR (a - `pass_native_emit`/`pass_jump` peephole dropping the `and` and emitting tbz/tbnz); - it is listed here rather than §2 because the broader csel/ccmp family it sits inside - is SSA-shaped, and the narrow slice should be validated before committing. → mostly - O2, with a possible linear `tbz` slice worth a spike. -- **Cross-block redundant re-extension / re-materialization.** Repeated `sxtw rX,wY` - of an unchanged source across blocks/calls (sqlite ~173 conservatively measured; - more in reality). L6/L8 handle the same-block and addressing-mode-foldable cases; - the cross-block redundant *re*-extend needs GVN / cross-block copy propagation. → O2 GVN. -- **FP constant folded at the wrong (narrower) width.** tinyexpr's `NAN = 0.0/0.0` / - `INFINITY = 1.0/0.0` fold to a *float* NaN/Inf, materialize into a GPR, then `fcvt - d,s` to widen (12 sites, +24 insns). Folding at the *result* width avoids the - widen, but correctness needs care that float→double of the folded constant is the - same value (canonical NaN payload), so it is a width-selection fix in the - constant-fold path, not a trivial peephole. Marked NLOGN/needs-care. → folder fix - (cg_ir_lower.c), schedule with care, low priority. - ---- - -## 4. Not a deficiency - -Where clang is bigger via inlining/unrolling, or kit is already smaller — so future -readers don't chase these. - -- **clang inlines/unrolls far more at `-O1`.** lz4 (kit **0.38×** clang) and yyjson - (0.86×), lapi (0.83×) are *smaller* in kit because clang inlines aggressively - (yyjson kit bl=942 vs clang 294; lz4 `LZ4_compress_generic`/`LZ4_count`/`LZ4_hash5` - fully inlined into every caller). Every clang-heavy mnemonic on those files - (movz/cmp/add/sub/movk/csel/madd/strh/ldrh) is an inline-site materialization/ - selection artifact, **not a kit gap**. Tuning kit's inline-pressure cap (W4, landed) - *upward* would increase total `__TEXT`, not decrease it — net size impact - neutral-to-negative. -- **`ret` / function-count deltas are inlining shape.** lparser kit 96 funcs / 96 ret - vs clang 24 / 20; kit emits exactly one ret per function (optimal per-function, no - duplicated epilogues). tinyexpr `blr` 16 vs clang `br` (tail-call dispatch after - inlining). These belong to the parked O2 inliner/tail-call work. -- **NEON / `.inst` columns.** clang's hundreds of `.inst` are `umulh`/`smulh` - magic-multiply divides, `movi` vector zeroing, and `stp q` — vectorization/strength- - reduction kit doesn't do. The zero-init half is recoverable as **L3** (scalar - widening); the magic-multiply half is the optional L9 strength-reduction tail. -- **`cbz` deltas favoring clang.** cjson `cbz` is −107 (kit 208 vs clang 315) — kit is - *not* over-emitting branches here; do not read the raw cbz delta as a kit gap. - -**Downgraded on spot-check:** the lz4/miniz "address bases spilled and reloaded" and -"large struct-field address rebuilt per use" items the per-file agents rated high -were largely the *cross-block* residency problem (NEEDS-SSA-O2, §3), not a clean -linear peephole — only their strictly-intra-block, same-base-unchanged subset is -linear, and that subset is already covered by **L1** (store→load forwarding) and the -landed **W1a** (frame-address sub/add-CSE) + **W5** (local CSE). They are not given -separate §2 entries to avoid double-counting. - ---- - -## 5. Suggested next O1 work - -In the style of O1.md's worklist — ranked by impact × confidence × breadth, each a -linear no-SSA change with a clear host pass and a `test/opt/` structural guard. - -1. **P1 — store→load forwarding on register mismatch** (`pass_combine.c`, - `opt_combine_compact_block`). Highest single lever (lvm ~1400 insns, plus lz4/ - sqlite/miniz intra-block). Drop the `same_reg_operand` requirement in the - store;load branch; emit a copy on mismatch, let substitution + W8 + mir_dce clean - up. Guard: assert the str;ldr-different-reg pattern is gone on an lvm fixture. -2. **P2 — `cset;cbnz/cbz` → `b.cc` fusion** (`pass_combine.c`/`pass_jump.c`). Broadest - cross-file win (yyjson 438, lapi 27, lvm/lz4/sqlite). Fuse `IR_CONDBR` of a - single-use `IR_CMP` into `IR_CMP_BRANCH`; inverse of the existing cg_ir_lower fusion. -3. **P3 — widen `aa_set_bytes` zero-init** (`aa64/native.c`). ~25% of cjson's gap; - helps miniz. Emit `str xzr`/`stp q0` aligned runs (or `bl _memset`) instead of - per-byte `strb`. Self-contained expander change. -4. **P4 — drop yyjson double-cset + redundant `sxtb`-after-`ldrsb`** (`pass_combine.c` - / `combine_exts`). ~1100 + ~186 insns on yyjson. Same-block last-def peepholes; - the sxtb-drop is the signed mirror of the landed ZEXT-of-load fold. -5. **P5 — constant-operand div/mul fold + same-block `LOAD_IMM` const tracker** - (`pass_simplify.c`). Fixes lparser being larger than clang (the SIZE_MAX guard) + - sqlite's 108 const-divide sites. A bounded per-block reg→value map feeding the - existing BO_UDIV/SDIV folds. -6. **P6 — single-use copy coalescing into convert/store/call-arg/return consumers** - (`pass_combine.c`) and **`sxtw`-index-into-addressing-mode** (`pass_addr_fold.c`). - The `mov` and standalone-`sxtw` surplus across the value-heavy files; both reuse - existing CombineCtx/addr-fold machinery. - -**Definition of done per item:** measurable `__TEXT`/instruction reduction on the -affected corpus files (via `scripts/o1_quality.sh`), sqlite `-O1` compile time -unchanged (linearity held — the §8 synthetic sweep), all correctness gates green -(`make test-opt test-toy`, ecosystem `-O0`/`-O1` vs clang, smoke-x64/rv64), and a -`test/opt/` structural disasm guard added. - -> Sequencing note: P1 + P3 are the two biggest size levers and touch disjoint files -> (`pass_combine.c` vs `aa64/native.c`) → parallelizable. P2/P4/P5/P6 share -> `pass_combine.c`/`pass_simplify.c` → one owner, land + re-measure one at a time -> (gate = correctness, not byte-identity). The true ceiling on lvm/sqlite/miniz/lz4 -> remains the SSA register allocator (§3) — these linear items close the *local* -> waste, not the over-spilling. diff --git a/doc/plan/O1.md b/doc/plan/O1.md @@ -1,876 +0,0 @@ -# O1 code quality: closing the gap to clang `-O1` - -**Goal.** Improve the *runtime quality* (density + efficiency) of kit's `-O1` -emitted code **without making the `-O1` compile superlinear**. `-O1` is, by -design, a no-SSA pipeline (Section 3 of [doc/OPT.md](../OPT.md)): local + linear-scan -machinery only, no dominance-frontier phi insertion, no value numbering. Every -item here must keep that property — a transform that needs SSA, a full -interference graph, or any O(n²) analysis belongs in the parked **O2 mid-end**, -not here. - -This doc is the forward-looking worklist. The design reference for the passes it -touches is [doc/OPT.md](../OPT.md); the *compile-speed* roadmap (a separate axis — -that doc explicitly does not care about generated-code quality) is -[PERF.md](PERF.md). The broader optimizer roadmap (completing the O2 SSA mid-end, -machine register-constraint work) is [OPTIMIZER.md](OPTIMIZER.md) — this doc is -the narrow, near-term slice: `-O1` density within the linear/no-SSA budget. Read -the first two before starting. - -Two rules, mirroring PERF.md: - -- **A superlinear axis is a bug.** `-O1` compile time must stay ≈ linear in - function size/count. A small per-function sort over side tables (for example, - frame slots) is acceptable only if it stays immaterial in sqlite timing; no - transform should add an O(n²) or per-instruction superlinear axis. Validate - with the synthetic sweep in OPT.md §8 and by timing sqlite `-O1` before/after - (it is ~1.6 s today; a quality change should not move it materially). -- **Gate every change on correctness, not byte-identity.** These changes - deliberately alter emitted bytes, so the gate is: `make test-opt test-toy`, - the ecosystem golden + vs-clang run at `-O0`/`-O1` (`make test-ecosystem`, - 28/0), and `make test-smoke-x64 test-smoke-rv64`. Add a structural disasm - guard per change under `test/opt/` (see `redundant_copy_ext.sh`). - ---- - -## 1. Methodology - -All numbers below: Apple-silicon arm64 / Darwin, `-O1`, release kit at -**`build/release/kit`** (`make bin RELEASE=1`). Pitfalls that cost real time: - -- `make bin RELEASE=1` writes **`build/release/kit`**, *not* `build/kit` - (`build/kit` may be a stale debug binary). Measure with the release binary. -- A kit binary copied outside the build tree fails with `support dir not found` - (the rt/support dir resolves relative to the exe path). To A/B two builds, keep - both binaries **inside `build/release/`** (e.g. `kit_baseline`, `kit_fixed`). -- clang on macOS needs `-isysroot "$(xcrun --sdk macosx --show-sdk-path)"`; kit - needs `--sysroot "$SDK"`. - -Per-file comparison (text size + instruction mix): - -```sh -SDK="$(xcrun --sdk macosx --show-sdk-path)" -kit cc -O1 -c f.c -I... --sysroot "$SDK" -o k.o -clang -O1 -c f.c -I... -isysroot "$SDK" -o c.o -size -m k.o | awk '/__text/{print $NF}' # __TEXT bytes -kit objdump -d k.o | grep -cE ':\t' # instruction count -``` - -The ecosystem sources are provisioned at `scripts/ecosystem.sh srcdir <name>` -(lua, sqlite, cjson, lz4, miniz, yyjson, tinyexpr). **TODO:** codify the A/B -harness as `scripts/o1_quality.sh` (build baseline+fixed into `build/release/`, -diff text/mix across the corpus) — it was ad-hoc for this analysis. - ---- - -## 2. Current standings - -> **Update (2026-06-16): the §3 worklist is fully landed (see §5).** Aggregate -> `__TEXT` is now **≈ 1.09× clang `-O1`** (down from 1.25×, a −12.8% reduction in -> kit's emitted `__TEXT`). The dominant symptom below — spill-address `sub x29` -> at 36% of lvm / 12% of sqlite — is **eliminated** on aa64 (lvm 8,774 → 0, -> sqlite 35,360 → 0; lvm `__TEXT` 96,788 → 59,276, **4.66× → 2.86×**; sqlite -> 1.27× → 1.11×; miniz 1.94× → 1.67×; the clang-favored lz4/yyjson held at -> 0.38×/0.86×). sqlite `-O1` compile stayed ≈1.6→1.67s (linear; W3 the main -> contributor). The pre-campaign analysis below is retained for context. **The -> residual gap is now catalogued in [O1-PATTERNS.md](O1-PATTERNS.md)** — a -> kit-vs-clang disassembly audit of what remains, ranking the further linear/ -> no-SSA wins (L1–L10) and separating them from the parked-O2 SSA gaps. - -**[Pre-campaign] Aggregate `__TEXT` ≈ 1.25× clang `-O1`** across the corpus — but -the ratio is bimodal and the aggregate understates the truth: - -| file | kit/clang | note | -|---------|----------:|------| -| lz4 | **0.39×** | clang unrolls/inlines hot loops → *clang* is bigger | -| lapi | 0.83× | kit smaller | -| yyjson | 0.88× | kit smaller | -| lparser | 1.09× | ~par | -| sqlite | 1.27× | | -| cjson | 1.33× | | -| miniz | 1.94× | | -| lvm | **4.66×** | pathological — the lua interpreter loop | - -On **small-function code kit is competitive or smaller** than clang (clang `-O1` -trades size for speed via inlining + loop unrolling — not a kit deficiency). The -real gap — *removable work kit emits that clang does not* — concentrates in -**large, high-register-pressure functions**: - -- `luaV_execute` alone is **5.6× clang (22,175 vs 3,980 insns)** and is **92% of - lvm's gap**. sqlite's big interpreter-style functions behave the same. - -### The dominant symptom: spilling + spill addressing - -| file | insns | `sub xN,x29,#` | % of insns | `[x17]` spill accesses | -|--------|--------:|---------------:|-----------:|-----------------------:| -| lvm | 24,197 | 8,774 | **36%** | 8,053 | -| sqlite | 292,039 | 35,426 | **12%** | 29,361 | -| miniz | 29,074 | 2,181 | 8% | 2,014 | -| cjson | 5,302 | 33 | ~1% | 0 | -| yyjson/lz4/lapi/lparser | — | ~0 | ~0% | ~0 | - -Two compounding problems, both visible in `luaV_execute`: - -1. **kit spills far more than clang.** lvm: ~8,053 stack accesses (kit) vs ~236 - (clang) — a ~34× difference in stack traffic. Root cause: a giant - post-inlining function + a linear-scan allocator with no splitting/coalescing. -2. **Each spill is addressed with two instructions.** Spill slots sit at - *negative* offsets from the frame pointer `x29`, beyond `ldur`'s ±256 unscaled - range, so kit emits: - - ```asm - sub x17, x29, #552 ; recompute slot address - stur x11, [x17] - sub x17, x29, #552 ; ← recomputed again for the very next access - ldur x9, [x17] - ``` - - clang addresses the same slots as `str [sp, #0x88]` — a single **scaled, - positive** offset (`ldr`/`str` reach 0..32760). ~**2,478 of lvm's subs are - immediately redundant** (same offset recomputed back-to-back). - -Instruction-mix delta that follows from this (lvm, kit vs clang): `sub` 8,887 vs -78; everything else is within ~1.1–1.5× (`ldr` 1,072 vs 933, `mov` 820 vs 691, -`add` 573 vs 512). **The gap is not broad — it is spill addressing on big -functions, plus the spilling that feeds it.** - -Problem (2) is **arch-specific**, which shapes W1 (see §3): it is acute on aa64 -(`ldur` reaches only ±256, so any far slot needs an address recompute) and -present-but-milder on rv64 (`ld` reaches ±2 KB). **x64 has it for free** — `mov -[rbp-disp32]` always addresses any slot in one instruction (and auto-selects the -1-byte `disp8` form when in range), so x64 emits no frame-address recompute at -all. Problem (1), the over-spilling, is arch-neutral. - ---- - -## 3. Worklist (linear-feasible), in priority order - -Each item: mechanism · where · expected impact · why it stays linear · risks. - -### W1 — Optimal known-frame layout: hot-slot-low ordering + positive-offset addressing ★ highest leverage - -**The reframing.** At `-O1` the frame is *fully known before the body is emitted* -— `*_func_begin_known_frame` fixes `frame_size_final` and sets `frame.frame_final` -before any body instruction (aa64 `native.c:2070/2094`). That is a standing asset -the `-O0` single-pass path lacks, and the optimal known-frame design exploits it -two ways — one shared across all three arches, one aa64-specific: - -1. **(shared) Hot-slot-low frame ordering.** Order the body frame slots so the - most-frequently-accessed spills get the *smallest final displacement from the - addressing base used by that layout*. Small offsets are cheapest to encode on - every arch: `disp8` vs `disp32` on x64 (3-byte vs 7-byte access), inside the - scaled reach on aa64, inside the ±2 KB `imm12` window on rv64. This is a - layout *choice* — zero added analysis; just do not confuse raw slot-allocation - order with the final displacement when a backend uses a bottom-record or - positive-offset layout. -2. **(aa64) A uniform x29-at-bottom frame.** Anchor x29 below the static slots so - every slot is a one-instruction positive `ldr/str [x29,#k]`, for every known - frame — replacing the `sub x17,x29,#k; ldur` fallback. x29 is the (always - reserved, alloca-stable) frame pointer, so this needs no register and no - regalloc change, and alloca falls out for free (W1.1). - -The addressing story differs sharply per arch — **measure before assuming a gap**: - -| arch | in-range access | far-slot path today | W1 work | -|------|-----------------|---------------------|---------| -| aa64 | `ldur` ±256 only | `sub x17,x29,#k; ldur` (2–4 insns, recomputed each access) | **uniform x29-at-bottom (big)** + ordering | -| rv64 | `ld off(s0)` ±2 KB | `lui;addi;add x; ld 0(x)` (2–4 insns, each access) | ordering (then assess positive layout) | -| x64 | `mov [rbp-disp32]` (always 1 insn, auto disp8) | — none — | ordering only (disp8 density) | - -x64 has **no** address-recompute path (`x64_emit_mem`, `x64/native.c:638`; disp8/ -disp32 auto-selected in `x64/isa.h:414`), so for x64, W1 is *only* the shared -ordering and purely a density micro-win. - -#### W1.0 — Hot-slot-low frame ordering (shared; all arches) - -**Mechanism.** Thread a per-slot priority from the allocator's spill-cost metric -into frame layout, and present body slots to the backend in the order that gives -hot slots the smallest final displacement. For today's x64/rv64/fp-relative -layouts this is the same as handing the bump allocator -(`native_frame_slot_alloc`, `cg/native_frame.c:45` — monotonic `cum_off`) hot -slots first; for a positive/bottom-record layout, verify the final offset formula -before assuming the same order. - -**Where + how.** -- Add a priority field to `IRFrameSlot` (`src/opt/ir.h`) and to - `NativeFrameSlotDesc` (`src/arch/native_target.h:41` — there is a spare `u8 pad` - at line 49 to widen). -- Stamp it from the allocator's final spill-slot assignment, not just first - creation. `spill_slot_for` (`pass_lower.c:578`) sees the first PReg that creates - a frame slot, but `alloc_assign_group_stack` can later reuse that same slot for - non-overlapping PRegs. The slot priority should aggregate the traffic of every - PReg/group assigned to that slot (saturating sum, or max if measurement shows - sum is too noisy), using the metric `pass_live.c:844` computes: - `2*use_freq + def_freq + live_across_call_freq + live_block_freq`. Homed - (address-taken) locals keep priority 0 for v1 — the hot traffic is spills, not - homed locals. -- Copy `priority` through the desc-build loop in `opt_emit_native` - (`pass_native_emit.c:~1513`). -- Just before `t->func_begin_known_frame(...)` (`pass_native_emit.c:~1544`), build - an index permutation over the slot descs, **stable-sort by descending - priority** (skip any `NATIVE_FRAME_SLOT_FIXED_OFFSET` slot), and present descs - in that order. Map each returned `NativeFrameSlot` back through the permutation - so `e->slot_map[frame_slots[order[k]].id]` stays correct. - -The backend body list (`frame->slots[]`) holds only LOCAL/SPILL slots; the -constrained slots (callee-saves, sret/va homes, aa64 atomic scratch, -alloca/outgoing areas) are reserved by the backend *outside* this list and are -untouched by the sort. No target analysis changes, but validate the final offset -formula per backend (especially after W1.1's bottom-record aa64 layout) before -claiming a slot is "low." - -**Impact.** rv64: keeps the hottest spills inside the ±2 KB `imm12` window so they -stay single-instruction. x64: more accesses encode as disp8 (−4 bytes each) — -density only. aa64: mostly subsumed by W1.1 (every slot ≤32 KB is already -single-instruction there); ordering is insurance only for >32 KB frames. - -**Linear?** Effectively yes — one `O(nslots·log nslots)` sort per function, off -the hot per-instruction path. If the synthetic sweep or sqlite timing shows this -is visible, switch to a linear bucket/radix ordering over the integer priority. - -**Risk.** Low — the gate is correctness, not byte-identity, so reordering internal -slot offsets is free. The hazards: the `slot_map` permutation must be exact (a -transposed mapping silently miscompiles every spill), and priority aggregation -must include reused spill slots rather than only the first PReg that allocated -the slot. Add an assert that every IR slot id maps to a distinct native slot. - -#### W1.1 — aa64 uniform x29-at-bottom known-frame layout (the big win) - -**Root cause (measured).** aa64's only wide single-instruction memory form is the -*unsigned scaled* `ldr/str [base,#pos]` (reach 0..32760 by size); its signed -unscaled `ldur` reaches only ±256. So one-instruction slot access requires the -base to sit **below** the slots (positive offsets). Today the known-frame path -anchors x29 at the *top* (top-record), so slots are at *negative* x29 offsets past -±256 → the `sub x17,x29,#k; ldur` fallback (`native.c:1009`) that is 36% of lvm / -12% of sqlite. The existing positive forms don't cover the functions that matter: -`fp_at_bottom` is gated to ≤504-byte, `out_stack==0` frames (`native.c:2085`), and -the single-pass `slot_sp_base` far-slot patch can't run on the known-frame path -(it *panics* if a patch is left pending, `native.c:1923`). `luaV_execute` — big -frame + `out_stack>0` — qualifies for none of them. - -**Design: one stable base below the slots, for every known frame.** Anchor x29 at -the bottom of the static slots (just above the outgoing-arg area), uniformly, so -every slot is `ldr/str [x29,#pos]` — one instruction within the 32 KB scaled -reach, with the `add x16,x29,#hi; ldr [x16,#lo]` build only past it. The payoff is -that x29 is the **frame pointer**: already reserved (never in `aa_int_allocable`, -`native.c:4128`) and **stable across alloca** (set once; the body never moves it). -Two consequences make this the clean choice: - -- **No optimizer/regalloc change — contained in the aa64 backend.** Unlike an - sp-base + x28-anchor scheme, there is no register to reserve and `machinize` is - untouched. The known-frame contract is honored by construction: - `aa_func_begin_known_frame` already has the *complete* frame - (`frame_size_final`, `max_outgoing`, `has_alloca`, the slot list, callee-saves) - and emits the final prologue in one pass — exactly what "known frame" means. -- **alloca falls out for free.** x29 never moves, so `[x29,#k]` stays valid after - `alloca` lowers sp. No anchor register, no special path, no v2. - -**Layout** (`os` = `out_stack` = max outgoing-arg bytes, `fs` = frame_size): - -``` -high addr - incoming stack args (caller) ← CFA = x29 + (fs − os) - saved x29, x30 ← at [x29] / [x29+8] (frame-pointer chain) - static slots (locals/spills/saves/va-save) ← [x29+16 …], positive - x29 ───────────────────────────── ← anchor = sp_entry + os - outgoing args (os) ← [sp, #0 …], sp-relative - sp -low addr -``` - -The saved pair sits **at** x29 so `[x29]` = caller fp and `[x29+8]` = caller lr — -the frame-pointer chain kit's unwinder / `__kit_backtrace` walks (uniform -fp[0]/fp[1]). This invariant is non-negotiable; it is *why* the pair is -co-located with the anchor rather than left at the top. Outgoing args stay -sp-relative (`[sp,#k]`), so calls after an alloca still address their arg area at -the current sp. - -**Where + how (aa64 backend only — `src/arch/aa64/native.c`).** -1. **Generalize the bottom-record layout** beyond today's `fp_at_bottom` gate: - make it the layout for *all* known frames except the Windows-variadic - `top_home` case (its GP home area must sit above the saved pair → keep that one - on top-record / fp-relative). Anchor `x29 = sp_entry + os`; `aa_fp_off_slot` - already returns a positive value in this mode (`frame_size − slot_off`), - generalized for the `+ os` shift. -2. **Prologue** (`aa_build_prologue_words`): general form `sub sp,sp,#fs` (scratch- - materialized / Windows-probed for huge `fs`) · `stp x29,x30,[sp,#os]` · - `add x29,sp,#os` · callee-save stores at positive x29 offsets. Keep today's - folded `stp [sp,#-fs]!` + `mov x29,sp` as an encoding fast-path when - `os==0 && fs≤504`. -3. **Epilogue** — uniform sp recovery from the anchor, correct with or without - alloca (replaces the current two-path teardown; the post-indexed `ldp [sp],#fs` - can't be used once alloca floated sp): after callee-restores, - `mov x16,x29` · `ldp x29,x30,[x16]` · `add sp,x16,#(fs − os)` · `ret`. -4. **Addressing** (`aa_emit_mem`): a frame slot is always `ldr/str [x29,#base_off]`, - `base_off = aa_fp_off_slot(slot) (+ extra)` (positive), emitted **directly** - (frame is final) — scaled when `≤32760`, else `add x16,x29,#hi; ldr/str - [x16,#lo]`. No `slot_sp_base`, no deferred `AA_PATCH_SLOT`, no `sub x17` on the - known-frame path. (Single-pass `-O0` keeps `slot_sp_base` + the patch — its - frame isn't final at emit time.) -5. **CFI**: `CFA = x29 + (fs − os)`; saved fp/lr at `[x29]`/`[x29+8]`. Generalizes - the existing bottom-record CFI (the `os==0` case, `native.c:1929–1936`) to - `os > 0`. - -**Impact.** Removes essentially all of lvm's 8,774 and sqlite's 35,426 -frame-address `sub`s — for *every* function, alloca included. The single biggest -`-O1` density lever; each slot access 2–4 insns → 1. - -**Linear?** Yes — a layout + per-access addressing choice; zero added analysis. - -**Risk — the highest blast radius in this doc.** It touches the prologue/epilogue, -CFI, the bottom-record gate, tail calls (`AA_PATCH_TAIL`) and the slim tiers. Gate -hard: aa64 smoke + toy corpus + ecosystem run-and-diff + `test/opt/prologue_tier.sh` -+ an unwinding/backtrace check (the fp-chain invariant) + a `test/opt/` disasm -guard asserting large *and* alloca functions emit `ldr/str [x29,#…]` (or the -add-build) and **no** `sub xN,x29,#…` for spill access. Re-verify the slim/leaf -tiers and tail-call teardown against the generalized epilogue. Include structural -cases at the scaled-offset boundary, past the 32 KB scaled reach, signed narrow -loads, under-aligned aggregate chunks, `top_home`/Windows-variadic fallback, and -an alloca-after-call path that proves outgoing args remain addressed from the -current sp. - -#### W1.2 — rv64: order first, assess positive layout - -**Assessment first.** rv64's `imm12` window is ±2 KB — 8× aa64's `ldur` ±256 — so -a hot slot is single-instruction (`ld off(s0)`, `rv64/native.c:819`) for any -realistic hot working set. After W1.0 lands, **measure** rv64 far-slot traffic -(`lui;…;add` builds, `riscv/native.c:807`) in hot loops on the corpus before -building more. Expectation: hot-low ordering captures most of the win; the -residual is cold-slot tail. - -**If measurement shows a real gap:** rv64 has no positive-offset layout and no -patch infra today (all `s0`-relative negative). Two options, in order: (a) the -W1a per-access local CSE (don't rebuild the identical `lui;addi;add` for -back-to-back accesses to the same far slot); (b) a known-frame positive layout -(slots at `frame_size - off` above the saved pair, `s0 = sp`) gated like aa64's, -with `rv_s0_off_slot` branching on a layout flag — heavier, lower ROI given the -wide window. Do **not** build (b) speculatively. - -#### W1.3 — x64: ordering only - -W1.0 is the entire x64 story: more spill accesses fall in `disp8` range -(−4 bytes/access). No addressing change — `x64_emit_mem` is already -single-instruction. Skip if x64 density is not a priority. - -#### W1a — local frame-address-`sub` CSE (interim / harness bootstrap) - -**Status: largely subsumed by W1.1.** Once aa64 addresses far slots positively, -the back-to-back `sub x17,x29,#k` recomputes vanish for spill slots. W1a keeps two -narrow uses: (a) a **safe first PR** that stands up the gate + a `test/opt/` -structural guard before the larger W1.1 change, and (b) mop-up for the *residual* -recompute cases W1.1 doesn't touch — far slots past the scaled reach (huge -frames), GOT/global address rebuilds, and the rv64 `lui;…;add` rebuild (W1.2a). - -**Mechanism.** A per-block post-emit peephole: track "x17 currently holds base−K"; -drop a re-`sub`/re-build of the same K while x17 is unclobbered. Natural home: -`mir_combine` (`src/opt/pass_combine.c`) tracking, or a tiny aa64/rv64 post-emit -peephole. Bounded per-block state → linear. - -### W2 — Rematerialization instead of spilling - -**Mechanism.** At a use of a spilled value whose single definition is *cheaper to -recompute than to reload*, emit the recompute at the use instead of a slot -reload; drop the spill store when every use rematerializes. Conservative v1 set -(input-less or frame-only producers, recompute ≤ reload cost): -- `IR_LOAD_IMM` whose immediate is ≤2 insns (`movz`, or `movz;movk`); -- `IR_ADDR_OF` of an `OPK_LOCAL` (one `add` off the frame base). - -Defer `IR_ADDR_OF(global)` (2 insns + reloc; `addr_of_global_cse` already hoists -it) and large `IR_LOAD_CONST` (a 4-insn `movz;movk;movk;movk` is *worse* than a -1-insn reload) for v1. - -**Where + how.** -1. Linear pre-pass `opt_mark_remat(f)` (new, in `pass_lower.c`): for each PReg - record `remat_def[v] = InstId` iff it has exactly one def and that def is in - the v1 set above (no register inputs). One scan over all insts. -2. Add one shared helper for "materialize this spilled/remat PReg at this use", - and route **every** spilled-use path through it. Do not only hook - `rewrite_one_operand` (`pass_lower.c:1383`): call args - (`rewrite_call_arg_operand`) and store values (`rewrite_store_value_operand`) - currently turn spilled PRegs into direct frame operands, and would still read - the slot if the defining store were removed. -3. At ordinary reload/store insertion: - - On a **use** of a spilled remat PReg: clone the recorded def into `c->before` - with `dst = scratch` instead of appending an `IR_LOAD`. - - On the **def** of a spilled remat PReg: rewrite the def operand to scratch. - **Skip** the `IR_STORE` only when every later use is known to rematerialize; - otherwise keep the store for the remaining frame-use sites. The original - pure def then writes a dead scratch and `mir_dce` (already in the pipeline) - removes it when the store is skipped. - -**Impact.** Cuts spill reloads and, when every use rematerializes, spill stores on -high-pressure functions; compounds with W1. A v1 implemented during MIR rewrite -does **not** reduce allocator pressure by itself — it attacks stack traffic after -the spill decision, and frame size only if a later cleanup removes now-unused -slots. Reducing spill count requires feeding remat costs back into allocation -later. - -**Linear?** Yes — a per-def classification pre-pass + a local choice at each spill -site. No global analysis. - -**Risk.** The recompute must be input-less so it is valid anywhere (the v1 set is). -A constant bigger than its reload must *not* rematerialize — the ≤2-insn cost gate -enforces it. The correctness trap is dropping the spill store while any direct -frame use remains. Keep the set conservative; measure reload/store counts before/ -after, separately from total spill-slot count. - -### W3 — Linear move coalescing - -**The hook is already there.** The O1 allocator consults a union-find via -`alloc_coalesce_root` / `alloc_group_member` (`pass_lower.c:742/755`) and assigns -one location per class (`alloc_assign_group_hard/_stack`). At `-O1` -`f->opt_coalesce_parent` is NULL, so every PReg is its own root (identity → no -coalescing). W3 is a **linear pass that populates that parent array before -`opt_assign_ranges`** — the allocator then merges copy-related values onto one -register for free. The existing `opt_coalesce_ranges` (`pass_coalesce.c:257`) does -this *correctly* but builds an O(n²) conflict **matrix** — that is the O2-only -path; W3 must not call it. - -**Mechanism (linear).** Reuse unchanged: `collect_move` (eligible `IR_COPY`: same -class+type, both have ranges, not `IRF_NO_COALESCE`; `pass_coalesce.c:238`), -`coalesce_find`/`coalesce_union` (`:26/:209`), and `opt_ranges_overlap_kind` (`:54`, -the cheap on-demand range-overlap test). New driver: -1. Init `opt_coalesce_parent[v]=v`, `opt_coalesce_size[v]=1` (as `:265`). -2. Per root, maintain its **merged live-range list** (sorted by `raw_start`, - initially the PReg's own ranges) and **aggregated constraint masks** (`tied` / - `forbidden` / `allowed`). -3. Collect moves; sort hottest-first by block frequency. -4. For each move (dst,src): `ra=find(dst)`, `rb=find(src)`; skip if equal. Test - `can_merge(ra,rb)`: - - merge-scan the two sorted range lists: any overlap wider than one point, or a - *second* unit-length overlap, is a conflict (this mirrors - `opt_ranges_overlap_kind`'s 0/1/2 result lifted to the group level — the one - benign unit overlap is the copy itself); - - constraint check: `forbidden_a | forbidden_b` compatible with the merged - `allowed` and any `tied` (mirrors `group_constraints_compatible`, `:176`). - If mergeable, `coalesce_union`, then merge the smaller root's range list and OR - its masks into the larger (union-by-size). - -**Why this is linear (not the O2 matrix).** No `nrelated²` conflict bitmap. Each -merge-scan is `O(|A|+|B|)`; union-by-size makes total work `O(N log N)` in ranges. -**Conservative fallback** if the range-list bookkeeping proves fiddly: cap class -size at a constant K and do the K²-bounded pairwise `opt_ranges_overlap_kind` per -merge — still linear in moves, missing coalesces only in classes > K. - -**Impact.** Removes the cross-block `mov x,x` copies the per-block `mir_combine` -cannot retire (~668 in lvm) *and* lowers pressure (→ fewer spills), compounding -with W1/W2. - -**Linear?** Yes (above). Never coalesce an `IRF_NO_COALESCE` (phi-edge) copy. - -**Risk — bounded, but not zero.** Non-overlapping ranges mean the merged values are -never simultaneously live, so the live set does not grow at any program point. -But forcing two disjoint values to share one location can still make the merged -group harder to place (combined constraints, one common hard register, callee-save -costs) and can spill a group that two independent values would not have spilled. -Mitigate by merging only when the combined constraints leave at least one plausible -hard register, or by rolling back / splitting a coalesced class that falls to the -stack. Gate on correctness + spill-count-not-worse on the corpus. - -Two implementation details must be made explicit: -- `opt_verify_alloc` currently describes and checks the "O1 no-coalesce" shape; - it must treat PRegs in the same coalesce root as one value, not as an - interference violation. -- `mir_combine` currently uses `!f->opt_coalesce_parent` as a proxy for "O1 may - fold `load_imm` through copies". Once O1 has a union-find, replace that with an - explicit mode/flag so enabling coalescing does not silently disable useful O1 - immediate folding. - -### W4 — Inline-pressure cap - -**Mechanism.** A size/pressure-aware inline cost cap — back off inlining into an -already-large caller, or whose inlining pushes estimated live-set past the -register file — trims pressure before regalloc. This is **not** the primary lvm -fix in the current measurements: a targeted `lvm.c` metrics run showed -`opt.inline.inlined=0` (75 candidates refused by shape), so `luaV_execute` is -large from source structure rather than O1 inlining. The policy knob is still -relevant for sqlite-style files, where the whole-program inliner does inline many -small callees and there is no SSA post-inline cleanup at `-O1`. - -**Where.** `src/opt/pass_inline.c` (the `opt_inline` cost/growth/policy gates; -see the policy table in OPT.md §8). A new "caller already huge / pressure" gate -alongside the existing cost caps. - -**Impact.** Indirect; potentially useful on sqlite/inline-heavy files, near-zero -on lvm as currently measured. Tune against the corpus — must not regress the -files where inlining is currently a win (lz4/yyjson). - -**Linear?** Yes — a cheap size/pressure estimate per call site. - -**Risk.** Regressing runtime speed on hot inlined loops. This is a policy knob; -measure both size *and* (ideally) a runtime proxy before committing thresholds. - -### W5 — Local (same-block) redundant-load + CSE elimination - -**Mechanism.** A forward scan within a block that reuses the last load of an -address (and the last result of a pure expression) until a store / call / memory -barrier invalidates it. Catches same-block reload and recompute redundancy. - -**Where.** `mir_combine` (`src/opt/pass_combine.c`) already tracks per-block -producers and a clobber barrier (`inst_is_clobber_barrier`, `inst_writes_memory`) -— extend it with an address→last-load map and a value→last-compute map. Keep this -distinct from the existing adjacent spill compaction in `opt_combine_compact_block` -(store/load, load/store, load/load, store/store pairs); W5 is the broader -same-block map-based form. - -**Impact.** Modest at `-O1` (much redundancy in lvm is *cross-block* → needs GVN, -out of scope), but cheap and broadly applicable. - -**Linear?** Yes — single forward pass, bounded per-block state. - -**Risk.** Alias correctness — only reuse a load when no intervening store may -alias (reuse the existing `AliasRoot`/`inst_writes_memory` conservativism; -calls/volatile/atomics are barriers). - -### W6 — Peephole tightening (modest, easy) - -- **cmp-immediate folding.** Register-compares like `movz w9,#k; cmp w8,w9` that - fit the target immediate form should be `cmp w8,#k`. The current aa64 immediate - policy already knows `NATIVE_IMM_CMP`; the missed cases are materialized too - late for the existing operand-immediate path. A recent targeted audit found the - opportunity mostly in sqlite (hundreds of small constants) rather than lvm after - the latest copy/extension folds. Fold a `load_imm` feeding a `cmp` into the - cmp's immediate operand when legal. -- **Address-mode folding gaps.** `add xN,xM,#k; ldr [xN,#j]` → - `ldr [xM,#k+j]` when `k+j` is a legal scaled offset. `mir_combine`'s - addressing-mode synthesis already does most of this; close the constant-add + - offset case. - -**Where.** `src/opt/pass_combine.c` (`try_addr_synth`, and a new const-into-cmp -fold). **Linear?** Yes — per-instruction peephole. - -### W7 — Switch-chain immediate compares - -**Mechanism.** `IR_SWITCH` replay currently materializes every case value into a -scratch register before `cmp_branch`: - -```c -load_imm scratch, case_value -cmp_branch EQ, selector, scratch, case_label -``` - -That loses the immediate path the normal `IR_CMP_BRANCH` emitter already has, and -it differs from the CG fallback switch chain (`cg/control.c`), which passes the -case value as an immediate operand. Replay should build an immediate operand per -case and route it through the same `operand_imm_or_reg(..., NATIVE_IMM_CMP, ...)` -logic as `IR_CMP_BRANCH`; only materialize to a scratch when the target says the -compare immediate is illegal. - -**Where.** `IR_SWITCH` in `src/opt/pass_native_emit.c` (`emit_one`, around the -case loop). This is an emitter-local change; no IR analysis required. - -**Impact.** Removes one constant materialization per case in non-jump-table switch -chains, especially sparse switches and small chains below the jump-table -threshold. On aa64 this is often a `movz` before every `cmp_branch`; x64 already -has rich cmp-immediate forms and benefits from density; rv64 will still -materialize when the immediate cannot fit the branch/compare lowering. - -**Linear?** Yes — still one pass over switch cases. - -**Risk.** Keep the fallback materialization path for targets/immediates that do -not accept the case value directly. Preserve selector pinning: the selector should -still be materialized once before the case chain, not reloaded per case. - -### W8 — Same-block stack dead-store elimination - -**Mechanism.** A forward MIR scan deletes a stack store when a later store in the -same block fully overwrites the same stack slot before any possible read: - -```text -store spill#17, r8 -... no read/barrier for spill#17 ... -store spill#17, r9 ; first store is dead -``` - -This is deliberately narrower than O2 DSE. V1 should handle exact direct frame -stores only: -- `IR_STORE` with `opnds[0] == OPK_LOCAL(slot)`; -- `!opt_mem_observable(&mem)`, `mem.alias.kind == ALIAS_LOCAL`, and - `mem.alias.v.local_id == slot`; -- exact same `{slot, size, addr_space}` and no bit-field rider; -- start with `FS_SPILL` slots only. Optionally add `FS_LOCAL` later only when the - slot is proven non-escaped (`!FSF_ADDR_TAKEN`) and every access remains direct. - -**Fast design.** Do not use a hash table per block. Frame slot ids are dense, so -keep per-function side arrays indexed by `FrameSlot`: -- `seen_gen[slot]`, `last_store_idx[slot]`, `last_size[slot]`, - `last_addr_space[slot]`; -- increment `gen` at each block start and on a full memory barrier instead of - clearing `O(nslots)` state; -- keep a touched-slot list only if compaction/reset bookkeeping needs it. - -On an accepted store: -1. If `seen_gen[slot] == gen` and the stored key matches, mark the previous store - `IR_NOP`. -2. Record the current store as the new `last_store_idx[slot]`. -3. If the same slot is accessed with a different size/address-space/bit-field - shape, clear that slot's entry rather than reasoning about partial overlap. - -On an accepted direct load from the same slot, clear that slot's entry. On any -unknown memory op, call, asm, intrinsic, atomic, volatile access, aggregate op, or -non-direct local access, bump `gen` and forget all pending stores in O(1). - -**Where.** `src/opt/pass_combine.c`, either as a new helper called from -`opt_combine_block`/`opt_combine_compact_block`, or as a separate same-block -MIR cleanup immediately before the existing compact pass. Let the existing NOP -compaction and `mir_dce` clean up the store's now-dead source producer. - -**Impact.** Smaller than W1/W2, but it directly cuts spill/local stack traffic and -can expose dead rematerializations/copies to `mir_dce`. It compounds with W2: -rematerialization removes reloads; stack DSE removes overwritten stores that -remain. - -**Linear?** Yes — one scan over block instructions, O(1) per instruction, no -per-block clear over all frame slots. - -**Risk.** The correctness boundary is aliasing and partial overlap. Keep v1 exact -and spill-only; treat anything uncertain as a barrier. Do not delete volatile, -atomic, bit-field, aggregate, or escaped-local stores. Add structural tests for: -same-slot overwrite deleted, intervening load preserved, different size preserved, -call/asm/unknown store barrier preserved. - -### W9 — One-pass branch cleanup subset - -**Mechanism.** O2 has `opt_jump_opt`, but its fixed-point loop is intentionally -not an O1 fit. O1 can still import the single-pass, obviously-linear subset: -1. Collapse `IR_CONDBR` / `IR_CMP_BRANCH` whose two successors are the same into - a plain `IR_BR`. -2. Forward successors through trivial pass-through blocks once: - - unconditional `IR_BR` targets; - - conditional taken targets (`succ[0]`); - - conditional fallthrough targets (`succ[1]`) only when preserving fallthrough - semantics is explicit: the old fallthrough block is physically next and is - empty/pass-through, or the predecessor already had to emit an explicit jump - to the false target. -3. Optionally include `IR_SWITCH` successor forwarding through single-jump blocks, - using the same label-address guard as existing jump cleanup. - -**Fast design.** Reuse `JumpCleanupCtx` and its memoized `forward_jump_target_ex` -style, but run it once. No `for (iter < nblocks)` loop, no repeated CFG rebuilds. -The shape should be: - -```text -build_cfg -one_pass_forward_branch_targets -one_pass_collapse_same_target_branches -build_cfg if changed -existing cleanup_branch_targets / layout cleanup -``` - -The forwarding walk is bounded by `nblocks` per queried target today, but the -memo table makes repeated queries amortized linear for the function. Keep the -label-address guard (`has_label_addr_ref`) so computed-goto-visible blocks are -not bypassed. - -**Where.** `src/opt/pass_jump.c`. Either extend `OPT_JUMP_CLEANUP_CFG` with this -one-shot subset or add a named O1 cleanup helper called from the O1 prepare path -after CFG construction. Do **not** call `opt_jump_opt` from O1. - -**Impact.** Mostly density/control-flow cleanup: fewer jump-only blocks survive -to layout, fewer explicit jumps after branch forwarding, and better fallthrough -shape for later MIR layout cleanup. It should also reduce verifier/debug noise by -canonicalizing same-target branches early. - -**Linear?** Yes if it is one shot with memoized forwarding and at most one CFG -rebuild after changes. - -**Risk.** Fallthrough correctness. Rewriting a conditional false edge can turn an -implicit fallthrough into an explicit jump or skip a block; allow it only for -empty/pass-through blocks with no label-address references. Accept missed -multi-hop opportunities rather than adding a fixed-point loop. - -### W10 — Constant `cmp_branch` folding - -**Mechanism.** Fold branch conditions that are statically known without SSA: -- `IR_CMP_BRANCH` with two immediate operands; -- integer same-register comparisons (`x == x`, `x <= x`, `x < x`, etc.). - -Rewrite the terminator to `IR_BR` targeting the selected successor. This is the -branch form of the existing local `IR_CMP x,x -> load_imm` simplification, but it -must update CFG successors, so it belongs with branch cleanup rather than as a -pure expression fold. - -**Where.** `src/opt/pass_jump.c` near the W9 one-pass cleanup. Use the shared -integer compare evaluator/masking helpers where possible. V1 should not chase -`IR_LOAD_IMM` definitions in PReg form; O1 PRegs are mutable, so direct operands -and same-reg identities keep the pass local and safe. - -**Impact.** Low ceiling, but cheap. It removes dead conditional branches produced -by macro/static-configuration code and exposes unreachable blocks to the existing -CFG cleanup. - -**Linear?** Yes — a local terminator scan. - -**Risk.** Do not fold FP same-register comparisons: NaN makes `x == x` and -ordered relations non-trivial. For immediate/immediate compares, use the operand -type width and signedness semantics; if the width is unknown, skip. - ---- - -## 4. Out of scope (SSA-only — belongs in the O2 mid-end) - -These are where most of clang's *remaining* advantage on value-heavy code lives, -and none is cheaply doable without SSA. They are already designed/parked in the -O2 schedule (OPT.md §3, `src/opt/pass_o2.c`): - -- **Global GVN** — cross-block redundant load / value elimination (the bulk of - the lvm redundancy that W5 can't reach). -- **General DSE** — cross-block, alias-aware dead-store elimination. W8 is only - exact same-block stack overwrite cleanup. -- **LICM** — loop-invariant hoisting. -- **Induction-variable strength reduction.** - -If a chunk of the gap turns out to *require* one of these, the answer is to wire -up / ship the O2 path for that workload, not to bolt a non-linear analysis onto -`-O1`. - ---- - -## 5. Already landed - -### The §3 worklist (2026-06-16) — all items shipped - -Implemented in parallel (worktree-isolated), merged one-at-a-time against the -`scripts/o1_quality.sh` harness, each correctness-gated (`test-opt` structural -guard + `test-toy` 1392/0 + ecosystem 28/0 + smoke-x64/rv64). **Cumulative: -aggregate `__TEXT` 1.25× → 1.09× clang; lvm 4.66× → 2.86×; sqlite `sub x29` -35,360 → 0; sqlite `-O1` compile ≈1.6s → ≈1.67s (linear).** - -- **W1a** `c6baf36f` — local frame-address `add`/`sub`-CSE (`pass_combine.c`): - back-to-back rebuilds of the same frame address become a `mov`. Subsumed on - aa64 spill slots by W1.1; still fires on the positive `add xN,x29,#k` form. - Guard `redundant_frame_sub.sh`. -- **W1.0** `d9902a7e` — hot-slot-low frame ordering (`pass_native_emit.c` + - `ir.h`/`native_target.h` priority field + `pass_lower.c` aggregation): hottest - spills get the smallest displacement. **aa64 −4.9% aggregate alone**; x64 - disp8 density; rv64 keeps hot slots in the imm12 window. Guard - `hot_slot_order.sh`. -- **W1.1** `9a99a0aa` — aa64 uniform x29-at-bottom known-frame layout - (`aa64/native.c`): every spill slot is one-instruction positive `ldr/str - [x29,#k]`; the `sub x17,x29,#k` fallback is gone for every known frame, alloca - included; fp-chain/CFI/tail-call preserved. **The big win: lvm −36%, sqlite - −11%, `sub x29` → 0.** Guard `aa64_x29_bottom.sh`. -- **W3** `6f4cd59a` — linear move coalescing (`pass_coalesce.c`, populating - `opt_coalesce_parent` before `opt_assign_ranges`; no O(n²) matrix). Taught - `opt_verify_alloc` about coalesce roots and replaced the `mir_combine` - `!opt_coalesce_parent` proxy with an explicit `opt_o1_coalescing` flag. −1.43% - aggregate, spills not worse; +~7% sqlite `-O1` compile (the main cost). Guard - `o1_coalesce.sh`. -- **W2** `a6992971` — rematerialization instead of spilling (`pass_lower.c`): - small `load_imm` / `addr_of[local]` recompute at the use; spill store dropped - when every use remats. sqlite −0.58% (801 stores dropped). Guard `o1_remat.sh`. -- **W7** `9f3f9a01` — switch-chain immediate compares (`pass_native_emit.c`): - case values fold to `cmp #imm` instead of materializing a scratch; selector - pinned; per-arch fallback. cjson −1.0%, lua subset −0.47%. Guard - `o1_switch_imm.sh`. -- **W6+W8** `fb2cb4c3` — cmp-imm + add-offset folds, and same-block spill - dead-store elimination (`pass_combine.c`). sqlite −0.21%, lvm −0.61%. W8 is a - correct/linear net that currently finds 0 sites (W2/DCE clear the pattern - upstream — the "compounds with W2" case). Guards `o1_cmp_imm.sh`, - `o1_stack_dse.sh`. -- **W5** `235a72c7` — local same-block redundant-load + pure-expression CSE - (`pass_combine.c`): reuse the last load of an address / last compute of a pure - op until a may-alias store / call / barrier invalidates it (covers - indirect/global loads; spill/frame loads ceded to W8/compaction). Small/flat as - predicted (most redundancy is cross-block → GVN/O2). Found+fixed two MIR - value-reuse traps: self-clobbering loads and reuse of native-emit scratch - registers. Guard `o1_local_cse.sh`. -- **W9+W10** `f97f2c08` — one-pass O1 branch cleanup + constant `cmp_branch` - folding (`pass_jump.c`): same-target collapse, pass-through forwarding (one - shot, ≤1 CFG rebuild), `x==x`/imm-imm fold; FP same-reg never folded; computed - goto preserved. Low ceiling on real C as predicted. Guard `o1_branch_cleanup.sh`. -- **W4** `d4eeb840` — inline-pressure cap (`pass_inline.c`): the whole-program - inliner backs off into already-huge/high-pressure callers (cap 512), small + - `always_inline` still fuse. Byte-identical on the win files (lz4/yyjson - preserved); sqlite −324B / −75 spill insns; inliner phase faster. Guard - `o1_inline_cap.sh`. -- **W1.2** `a1ccdbcb` — rv64 far-slot assessment (no codegen change): measured - **zero** far-slot traffic in lvm/sqlite hot loops (±2KB window + W1.0 ordering - cover it), so no residual work warranted, per the doc. Guard `rv64_far_slot.sh`. -- **W1.3** — x64 is W1.0 alone (disp8 density); validated, no separate change. -- **Harness** `b1950955` — `scripts/o1_quality.sh`, the A/B `-O1` quality - measurement (the §1 TODO). - -### Earlier - -- **commit 8826982d** — three local, target-agnostic copy/extension folds: - `addr_of [base+0] → copy` and same-width same-class `convert → copy` - (`pass_simplify.c`), and `ZEXT(zero-extending load) → copy` - (`pass_combine.c`). **−2.4% `__TEXT`** across the corpus (lapi −9.1%, yyjson - −4.3%, sqlite −1.9%, lvm −3.2%), compile time unchanged. Guarded by - `test/opt/redundant_copy_ext.sh`. See OPT.md §4/§8. - ---- - -## 6. Suggested sequencing - -The structural set (the user-prioritized work) is W1a → W1 → W3 → W2. W4–W10 -remain the lighter / opportunistic sketches above, scheduled after or alongside -that set when file ownership allows. - -1. **W1a** (local `sub`-CSE) — first PR: stands up the gate + a `test/opt/` - structural guard with a small, safe aa64/rv64 peephole. Proves the harness. - Keep it minimal — W1.1 subsumes most of it. -2. **W1.0** (hot-slot-low ordering) — shared frequency-threaded layout; lands - mostly in shared code, with backend offset validation. Immediate disp8/window - wins and sets up W1.1. -3. **W1.1** (aa64 uniform x29-at-bottom known-frame layout) — the big structural - win (~36% lvm / ~12% sqlite `sub`s), and the highest blast radius - (prologue/epilogue/CFI). Contained in the aa64 backend; alloca included, no - regalloc change. -4. **W3** (linear coalescing) — lowers pressure → fewer spills → fewer slots for - W1 to place; removes surviving cross-block moves. -5. **W2** (rematerialization) — attacks spill *traffic* directly; feed its costs - into allocation later if spill count remains the bottleneck. -6. **W1.2** (rv64) — assess far-slot traffic after W1.0; build the rv64 residual - work only if measurement shows a gap. **W1.3** (x64) is W1.0 alone. - -Then, opportunistically: **W7** (switch immediates — tiny emitter PR), **W10** -(constant branch folding), **W9** (one-pass branch cleanup), **W8** (same-block -stack DSE), **W6** (peepholes — cheap polish, anytime, and a good small PR if -W1.1 is too large to take first), **W4** (inline cap — needs threshold tuning -against the corpus + ideally a runtime proxy), **W5** (local load/CSE — low -ceiling at `-O1`). - -**Definition of done per item:** measurable `__TEXT`/instruction reduction on the -affected corpus files, sqlite `-O1` compile time unchanged (linearity held), all -correctness gates green, and a `test/opt/` structural guard added. - -### Parallel flow (agent fan-out) - -Parallelism is bounded by **file ownership** (kit's parallel-agent rule: disjoint -files or worktree isolation), not by logical dependencies — the only hard -ordering is W1.2 after W1.0. - -| item | exclusive owner | also touches (shared) | -|------|-----------------|------------------------| -| Harness (`o1_quality.sh`) | `scripts/` + `test/opt/` | — | -| W1.1 aa64 | `src/arch/aa64/native.c` | — | -| W1a sub-CSE | `src/opt/pass_combine.c` + `test/opt/` | — | -| W3 coalescer | `src/opt/pass_coalesce.c` | `pass_lower.c` (1-line call site, ~:1887) | -| W1.0 ordering | `src/opt/pass_native_emit.c` | `ir.h` + `native_target.h` (add field); `pass_lower.c` (`spill_slot_for` ~:578) | -| W2 remat | `src/opt/pass_lower.c` (`rewrite_one_operand` ~:1383 + new fn) | — | -| W1.2 rv64 | `src/arch/rv64/native.c` | — | -| W7 switch immediates | `src/opt/pass_native_emit.c` | conflicts with W1.0 owner | -| W8 stack DSE | `src/opt/pass_combine.c` | conflicts with W1a/W5/W6 owner | -| W9/W10 branch cleanup | `src/opt/pass_jump.c` | — | - -**Wave 1 (up to 6 agents, worktree-isolated):** Harness, W1.1, W1a, W1.0, W2, W3. -- Harness, W1.1, W1a own fully private files → trivially parallel. (W1a must take - the `pass_combine.c` route, **not** an aa64 peephole, so W1.1 stays the sole - owner of `native.c`.) -- W1.0 / W2 / W3 share `pass_lower.c` but in **disjoint functions** → worktrees - merge cleanly in any order; if you want zero friction, let W2 own `pass_lower.c` - and fold in W1.0/W3's one-spot edits. - -**Wave 2 (gated):** W1.2 — only after W1.0 lands *and* a measurement shows rv64 -far-slot traffic worth removing (may be a no-op). W1.3 (x64) is W1.0 alone. - -**Opportunistic wave:** W7 and W9/W10 are file-disjoint from the structural work -except W7's `pass_native_emit.c` overlap with W1.0; land them as small isolated -PRs. W8 should share the `pass_combine.c` owner with W1a/W5/W6 to avoid churn in -the same post-RA cleanup machinery. - -**Merge discipline:** parallelize *development*, but **merge + re-measure one at a -time** against the harness — these change emitted bytes (gate = correctness, not -byte-identity), so single-item integration keeps any regression attributable. -Land the harness first. diff --git a/doc/plan/OPTIMIZER.md b/doc/plan/OPTIMIZER.md @@ -89,6 +89,29 @@ The path to flipping the switch: rewrite the rest. `opt_pressure_relief` only sinks immediate/const candidates; extend it to any single-cross-block-use move. Compute DSE memory liveness once before the pass instead of an inline fixpoint per invocation. +- **If-conversion (csel/csinc/ccmp selection).** kit emits **zero** + `csel`/`csinc`/`ccmp`; clang uses them heavily, so every `cond ? a : b`, + min/max clamp, and saturating cast becomes a compare-and-branch diamond with + the selected value threaded through the frame (feeding the spill gap). Doing it + well needs a select op in the IR plus a profitability/legality gate that proves + both arms side-effect-free and reasons about the merge of two definitions — + inherently the SSA domain. (A narrow peephole for the exact + `cmp;b.cc;mov-imm;b;mov-imm` diamond could fit `pass_jump.c`, but it is fragile + and low-value next to the linear peepholes; not a priority.) +- **`tbz`/`tbnz` from `and #(1<<k); cbz/cbnz`.** A single-bit-AND feeding an + adjacent branch folds to one `tbz`/`tbnz`; kit emits zero. The narrow + single-bit, adjacent-branch case is arguably a linear `pass_native_emit`/ + `pass_jump` peephole, but it sits inside the SSA-shaped if-conversion family, so + the narrow slice should be validated before it is committed. + +The cross-block redundancies that the linear `-O1` peepholes deliberately leave +on the table all close here once these passes ship: cross-block redundant +re-extension (repeated `sxtw` of an unchanged source across blocks/calls) closes +under GVN; cross-block redundant loads close under the memory-aware GVN; and the +dominant residual — values kept in registers across whole live ranges instead of +spilled to the frame — closes under the SSA register allocator with live-range +splitting (Section 2) plus global copy propagation. These are the bulk of clang's +remaining advantage on value-heavy and interpreter-style code. ## 2. Live-range splitting and coalescing (the O2 allocator layer) @@ -149,6 +172,46 @@ still trail MIR `O1`, and the gaps are specific and individually addressable. codegen is at parity, so the loss is PLT/GOT indirection for `malloc`/`free`; a `-fno-plt`-style direct-call or intra-image link resolution is a link-path question more than a codegen one — see [LINKER.md](LINKER.md). +- **Fold FP constants at the result width.** Constants like `NAN = 0.0/0.0` / + `INFINITY = 1.0/0.0` fold to a *float* NaN/Inf, materialize into a GPR, then + `fcvt d,s` to widen — folding at the result width drops the widen. The fix is a + width-selection change in the constant-fold path (`cg_ir_lower.c`), not a + peephole, and it needs care that float→double of the folded constant is the + same value (canonical NaN payload); low priority. + +The linear `-O1` peepholes (OPT.md §"The shape of `-O1` quality work") close the +*local* waste — redundant copies, extensions, and same-block reloads. The +structural ceiling that remains on large, high-register-pressure functions is the +**over-spilling itself**: kit's no-SSA linear-scan allocator (no splitting) falls +to frame residency in big bodies where clang keeps loop-carried +accumulators/pointers in callee-saved registers across the whole live range. That +gap is not a `-O1` peephole — it is the SSA register allocator of Section 2 and +the GVN/copy-propagation of Section 1, and it is where the bulk of the remaining +quality gap on interpreter-style code lives. + +### Known non-deficiencies (do not chase) + +A disassembly audit of the ecosystem corpus turned up several `-O1` vs clang +size deltas that are *not* kit gaps — recorded here so they are not mistaken for +work: + +- **clang inlines/unrolls far more at `-O1`.** On lz4, yyjson, and lapi kit is + *smaller* than clang precisely because clang inlines aggressively; every + clang-heavy mnemonic on those files is an inline-site materialization artifact. + Tuning kit's inline-pressure cap *upward* would increase total `__TEXT`, not + decrease it. +- **`ret` / function-count deltas are inlining shape.** kit emits exactly one + `ret` per function (optimal per-function, no duplicated epilogues); a higher + function count vs clang reflects clang's inlining, not kit waste. This belongs + to the parked O2 inliner / tail-call work, not to a size cleanup. +- **NEON / vectorized columns.** clang's `umulh`/`smulh` magic-multiply divides, + `movi` vector zeroing, and `stp q` runs are vectorization / strength-reduction + kit does not do. (The scalar zero-init half is independently recoverable as a + wider `aa_set_bytes` expander; the magic-multiply half is the small-divisor + strength-reduction above.) +- **Raw branch-mnemonic deltas.** A raw `cbz`/`cbnz` count difference favoring + clang on a given file does not mean kit over-emits branches — kit fuses many + compares directly into `cbz`/`b.cc` where clang materializes a bool first. ## 4. O0 generated-code quality diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md @@ -1,657 +0,0 @@ -# Performance: the fastest C compiler - -**Goal.** kit should be *the fastest* C compiler at `-O0`, and emit code at least -as dense as the bar. Generated-code *quality* (runtime speed) is irrelevant; only -correctness, **compile+link throughput**, and **emitted code size** matter. The -bar on both axes is **tcc** — the fastest mainstream C compiler. The structural -bet is already in place (single-pass no-AST C frontend, single-pass emission with -patch-ups, a format-neutral linker), so the work is measurement-driven: profile a -real workload, find where the instructions/bytes go, remove them. - -Two rules hold everywhere: - -- **A superlinear axis is a bug.** It means an inner step is O(n²)-ish — a linear - scan run per item, a table that rebuilds, a list re-walked. Catch these with the - synthetic scaling benchmark (below). -- **Gate every change.** A perf change must produce a **bit-for-bit identical** - object (and `-E` / `-g` / diagnostics) vs a snapshot of the pre-change binary — - *unless* it deliberately changes emitted bytes (a code-size lever), in which case - the gate is **run-correctness + determinism** (compile twice + `cmp`, the test - suites, sqlite e2e at `-O0`+`-O1` vs clang, per-arch clang-differential probes). - ASan can't see a premature arena reuse or an uninit read; the output diff can. - -The real workload is the **sqlite amalgamation** — one ~9 MB / 263 K-line C file -(`tmp/projects/sqlite-amalg/sqlite3.c`, v3.50.2): real, huge, declaration/macro/ -type-heavy, and doubling as a correctness test (compile + link the shell, run a -query). All commands assume `SDK=$(xcrun --sdk macosx --show-sdk-path)` and a -release kit at `build/release/kit` (`make bin RELEASE=1`). - ---- - -## 1. Current standings - -### Compile speed — the open frontier (~2.6× tcc) - -Apple-silicon arm64 / Darwin 25.3, refreshed 2026-06-15. **`instructions` is the -metric to trust** — load-independent (`/usr/bin/time -l`, best-of-7); -cycles/wall are low-load readings shown only for context. The kit row is **after -the CG-type-interaction pass + the arena geometric/retain rework** (see §3). Each -perf step left the sqlite object byte-identical, but the object has grown to -1.75 MB (from ~1.70 MB) because aa64 codegen *correctness* fixes landed in the -same window — the far-slot address-build fallback and the aggregate-return place -fix — which add a fixed per-function reserved patch region (see *Code size* below). - -| compiler | instructions | cycles † | wall † | object | -|---|--:|--:|--:|--:| -| **tcc 0.9.28rc** | 0.663 B | 0.190 B | 0.06 s | 2.11 MB | -| **kit** | 1.744 B | 0.522 B | 0.16 s | 1.75 MB | -| Apple clang 21 | 8.824 B | 2.775 B | 0.88 s | 1.50 MB | - -† low-load; instructions is the figure to trust. kit instructions over the -CG-type-interaction pass: **1.939 B → 1.864 B** (slot-query dedup + memory-align -contract + builtin ABI-layout cache + resolve/predicate fast paths) **→ 1.760 B** -(`abi_cg_type_info` inline builtin fast path + by-pointer layout cache) = -**−179 M total (−9.2 %)**; then the arena geometric-block/retain-on-reset rework -+ the delayed-arena block-size fix shaved another **−16 M → 1.744 B**, all -byte-identical (each perf step left the sqlite object bit-for-bit unchanged — the -object's growth to 1.75 MB is the separate codegen correctness fixes noted above). - -kit beats clang and is the fastest *general* backend, but **tcc is ~2.6× -(instructions) ahead** — this is the whole game. **The gap is instructions, not -cache:** kit's IPC (~3.3) is on par with tcc's (~3.5), so both are compute-bound -on a wide core and there is no hidden cache-miss penalty to claw back. The payoff -mechanism is **fewer retired instructions** (fewer copies, indirect calls, -redundant recomputations) — denser structures help only because they cost fewer -load/store/move *instructions*. - -> **Lean-token lexer/PP rewrite (landed, byte-identical, net win).** The -> lexer/preprocessor boundary was rewritten to the `doc/plan/LEX-PP-API.md` "lean -> token" design (compact `LocRef` byte-offset locations materialized on demand; -> `TextRef` spellings; slot-based pulls). sqlite `-E`, `-c` object, `-g`, `-S`, -> and diagnostics are **bit-for-bit identical** to the pre-rewrite snapshot -> (`perf-gate` green incl. the splice/`#line`/diagnostic battery), so **code size -> is unchanged**. The boundary-only landing was initially a −3.35 % `-c` -> regression — lazy line/col was a *loss* because line numbers are needed in the -> happy path anyway (sqlite expands `__LINE__` pervasively, and the parser stamps -> a CG loc per statement) and the first cut built the line index with a scalar -> `\n` rescan + a per-loc binary search. Fixing the *implementation* of laziness -> (build the index with memchr; cursor the materializer — §4.1) turned it into a -> campaign-local **−24 M `-c` win** (1.865 B → 1.841 B), since the scanner no -> longer builds a `SrcLoc` per token. The remaining ~+19 M parse+CG residual -> (per-statement loc lookups the old eager-on-token loc got for free) is the -> optional Stage-2 target in §4.1. - -### Code size — below tcc (0.986×) - -The honest metric is **`.text` machine code** (the object file is format-skewed — -Mach-O vs tcc's ELF — and not comparable; kit's *object* is actually smaller). - -| metric | kit | tcc | ratio | -|---|--:|--:|--:| -| **`.text`** | **1,352,356 B / 338,089 4-byte slots** | **1,370,940 B / 342,735 4-byte slots** | **0.986×** (−4,646 slots) | - -kit still clears the code-size bar overall, but the margin shrank from 0.951×: -the recent aa64 far-slot/large-frame fix reserves a **branched-over 5-instruction -patch region** in every qualifying function's prologue (a `b` over five `nop`s -when the long address-build path is unused), and ~2,587 functions pay it. That -alone adds **+12,897 `nop`s (~52 KB)** — i.e. the *entire* `.text` growth is this -padding; net of nops, real instructions actually *shrank* slightly. The genuine -excess remains the same instruction families as before (per-mnemonic, kit − tcc): - -| heavier in kit | Δ | what it is | -|---|--:|---| -| `stur`+`ldur`+`str` (spills) | **+47,095** | **#1: register pressure** — the single-pass NDT spills more than tcc keeps resident across statements / control-flow joins | -| `mov` | **+13,666** | residual arg / value-stack copies the arg0-first placement can't reach (nested-call results already in x0, pressure spills) | -| `nop` | **+12,897** | **new** — branched-over 5-`nop` patch region reserved per qualifying function by the far-slot/large-frame fallback; likely over-reserved (see §4.2) | -| `sub`+`movk` | +3,474 | residual far-frame addressing (byte/half slots, `&local` in big frames) | - -…offset by where **kit already beats tcc** (structural wins — do not touch): -`ldr` −24,214 (far slots fold into `[sp,#scaled]`), `add` −20,899 (folds offsets -into displacements), `cset` −17,226 / `cbnz` −10,817 / `cmp` −9,421 (kit fuses -compares straight into `cbz`/`b.cc`; tcc materializes a bool then tests — the cost -side is `cbz` +6,294 and the `b.eq`/`b.ne`/`b.ge`/`b.le`/`b.lt` family +6,936, -still a large net win), `movn` −6,720 / `movz` −4,644 / `stp` −3,722. The mnemonic -histogram nets **−5,058 decoded instructions**; kit also has 412 4-byte padding -slots, so the byte metric nets **−4,646 slots** — the +12,897 reserved-patch -`nop`s are what eroded the old −16,897. - ---- - -## 2. Where the cost is (current profile) - -### Compile is frontend-bound — lex+pp ~2.0× tcc, post-PP ~3.6× tcc - -Phase split measured directly (instructions, best-of-7), not estimated from `-E`. -`-E` is a **bad** lex+pp proxy: it re-serializes the token stream to text, work -neither compiler does at `-c`, and the two serialize at wildly different cost -(tcc's `-E` 1.17 B is ~0.79 B *serialization* via `get_tok_str` re-stringify + -whitespace logic, so tcc `-E` even exceeds tcc `-c`; kit's `-E` serializes for -~0.06 B since tokens carry `TEXT_SRC` spans → emit is a `memcpy`). To isolate -lex+pp we **drain the token stream to EOF with no parse and no output** in both -compilers (tcc: `-bench` hook, patched to use `-c` `parse_flags`; kit: -`KIT_PP_DRAIN`, see §3): - -| phase | kit `-c` | tcc `-c` | kit / tcc | -|---|--:|--:|--:| -| **lex + pp** | **0.80 B** | **0.40 B** | **~2.0×** | -| parse + sema + types + CG-drive | 0.71 B | ┐ 0.26 B | — | -| native emit + object write | 0.24 B | ┘ (post-PP) | — | -| **post-PP total** | **0.94 B** | **0.26 B** | **~3.6×** | -| **total** | **1.74 B** | **0.66 B** | **~2.6×** | - -So **both halves are real frontiers** — lex+pp is *not* at parity (an earlier -claim from the misleading `-E` proxy was wrong). The post-PP ratio is larger, but -lex+pp is ~46 % of kit's `-c` and a clean 2.0× behind. Note kit's lex+pp drain -does *less* than tcc's — kit defers number/string decode to the parser (tcc decodes -in the lexer; bare tcc scan+expand is 0.38 B, +decode 0.40 B) — yet is still ~2.0× -heavier, so the gap is pure per-token engine overhead, not extra work: - -- **tcc: one mutable global token** (`int tok` + `CValue tokc` + `tok_flags`) — - the lexer and macro replayer write it in place; no per-token struct is built or - copied. kit writes a 32-byte `Tok` (kind/flags/aux/`LocRef`/`TextRef`) per token - and copies it lex→pp→parser through slots. -- **tcc: no per-token location** (diagnostics read a `file->line_num` counter); - kit stores a `LocRef` per token (cheap, but ≠ free) and the pp checks it. -- **tcc: macro bodies are `int[]` token streams** replayed via a `macro_ptr` - cursor with **no hidesets** (a cheap nested-macro guard); kit replays `Tok[]` - with a Prosser hideset side-channel (hash-consed ids, union/dedup per expansion). -- **tcc: `TokenSym` caches direct `Sym*` pointers** (define/ident/struct/label) on - the interned token, so define-lookup is a pointer-follow; kit re-probes - (`pool_intern_slice` 3.9 %, the Sym-indexed macro table 1 load). -- **tcc: one `next()`** does lex + pp + feeds the parser — no inter-stage boundary; - kit layers lex → pp (source stack, `src_next_raw_into`→`pp_pull_into`) → - `pp_next_parse` → parser, and the pp re-checks directive/`defined`/dynamic-macro - state per token. This is the [[frontend-instruction-halving-pathb]] lever: fewer - per-token ops, not less work. - -The post-PP 3.6× is the bigger slice: tcc drives a thin `SValue[]` straight into a -one-pass emitter, while kit routes through the `CgTarget`→`NativeTarget`→`MCEmitter` -seam and a richer type/ABI layer (§4). Closing either is a campaign, not one hot -function. - -In the lean-token rewrite + loc-materialization fix A/B (golden→candidate, -best-of-7): **lex+pp −44 M** (the scanner no longer builds a `SrcLoc` per token — -`loc` is a byte offset), **parse+sema+CG +19 M** (the residual: a per-statement -cursor lookup at `pcg_set_loc` the old eager-on-token loc got for free), emit -flat → **−24 M total (−1.3 %)**. - -**Linux callgrind** (inclusive, instruction-grounded — the tool that sees what -wall-clock `sample` hides; total **1.547 B `Ir`**, glibc/ELF; self-`Ir` summed -across callgrind's `'2` symbol splits). This is **after the CG-type-interaction -pass** (slot-query dedup + memory-align contract + builtin ABI-layout cache + -resolve/predicate fast paths + the `api_type_layout_get` follow-up) **and the -arena geometric/retain rework**: the type cluster went **1.741 B → 1.608 B → -1.566 B (−175 M, −10.1 %)**, then the arena change shaved heap/`memset` churn to -**1.547 B (−19 M)**, all byte-identical for the perf steps (perf-identity gate -green on every non-`-g` category; the Linux/ELF total is not comparable to the -macOS hardware count — different libc/format — but the *distribution* is the point). - -| self % | function(s) | subsystem | -|--:|---|---| -| **12.3** | `lex_next` | scanner | -| 6.4 | `malloc` | hosted heap | -| 4.5 / 4.4 | `src_next_raw_into` / `pool_intern_slice` | pp + interning | -| 3.9 | `pp_pull_into` | preprocessor | -| 2.8 | `finish_ident` | scanner | -| 2.0 / 1.6 / 0.3 | `__GI_memset` / `__GI_memchr` / `__GI_memcpy` | libc memory | -| 1.9 / 1.8 | `abi_cg_type_info` / `api_type_pred_bits` | types/ABI | -| 1.6 / 1.3 / 1.2 | `pp_materialize_loc` / `api_const_from_sv` / `api_sv_adjust_refs` | lazy-loc / value-stack | -| 1.2 | `arena_alloc` | arena bump path | -| 1.1 / 1.0 / 1.0 / 0.4 | `cg_type_get` / `api_type_layout_ref` / `api_type_class` / `resolve_type` | types | -| 1.1 | `aa_emit_mem` | codegen | -| 0.78 (Σ) | `kit_cg_slot_lang_type` 0.53 / `kit_cg_slot_lang_flags` 0.16 / `kit_cg_slot_cg_type` 0.09 | CG slot queries (zero-copy accessors) | - -**What moved (vs the pre-pass profile above each arrow):** -`kit_cg_slot_info` **3.2 % → gone** (the by-value struct copy is retired; the C -frontend now reads single facts through the narrow accessors above, Σ 0.75 %), -`cg_type_get` 3.0 → 1.1, `resolve_type` 1.8 → 0.4, `__GI_memset` 3.0 → 1.9. The -ABI-layout lookup was first **consolidated** into `api_type_layout_get` (1.3 → -3.9 in the intermediate profile) — one memoized indexed load per type for -builtins *and* user types instead of the `abi_cg_type_info_compute` → -`cg_type_get` → per-call `resolve_type` spread — then the **follow-up** retired -that 5-field out-param marshalling: `api_type_layout_ref` returns a *borrowed -pointer* to the cached `ABITypeInfo`, and `abi_cg_type_info` resolves a builtin -with **one inline indexed load** off `Compiler.cg_builtin_layout` (no cross-TU -call). Combined `abi_cg_type_info` + layout cache: **5.5 % → 2.9 % (−43 M, the -whole program-total drop this step)**; `api_type_layout_get` 3.9 → `_ref` 1.0, -`abi_cg_type_info` 1.6 → 1.9 (it absorbed the inline load). Net of the targeted -leaves: **15.0 % → ~9 %**. - -**Subsystem rollup:** scanner ~15 % (`lex_next`+`finish_ident`), pp+interning -~13 % (`src_next_raw_into`+`pp_pull_into`+`pool_intern_slice`), CG/types/ABI -metadata ~9 % (down from ~15 %), libc allocation/memory helpers ~10 %, lazy-loc -(`pp_materialize_loc`) ~1.6 %, arena bump path (`arena_alloc`) ~1.2 %, direct -codegen emit ~1 %. The type layer is no longer a top-tier frontier — the remaining -leaves (`abi_cg_type_info` register-struct returns, `api_type_pred_bits`, -`cg_type_get`) are already at/under ~2 % and were explicitly *not* micro-optimized. -The next frontiers are the scanner (`lex_next` ~15 %) and the hosted heap (`malloc` -~6 %, call frequency already addressed by the arena rework — see below). Trust -callgrind for *where*; trust macOS instructions for *how much*. - -**Allocator (hosted heap).** `malloc` is ~6 % of `Ir` but only **~4,278 calls** -for the whole sqlite TU (`KIT_METRICS=1` heap counters): the cost is in large, -churned blocks, **not** call frequency. Most allocation never reaches the heap — -arenas bump-allocate, and the variable-count structures (interner table/entries, -vectors, segvecs) already grow geometrically (**351 reallocs total**, all grows). -The one gap was **fixed-size 64 KiB arena blocks**. The arena now (a) grows -blocks **geometrically** (64 KiB doubling to a 1 MiB cap, so a large arena needs -O(log n) heap calls not O(n)) and (b) `arena_reset` **retains the high-water -blocks** — it rewinds the bump cursor and frees nothing; only `arena_fini` -returns memory, so reset/refill cycles (per-statement fold, per-function MC, -per-expansion pp scratch) reuse their blocks with zero heap traffic. Effect on -the sqlite compile (`KIT_METRICS`): large (≥32 KiB) block allocs **440 → 124 -(−72 %)**, total allocs 4,278 → 3,967, frees 4,571 → 4,260; **macOS instructions -−0.22 %, byte-identical** (isolated golden-vs-candidate). Small on the -instruction metric (Apple malloc is cheap) but it corrects the reset-vs-free -semantics and cuts heap pressure/fragmentation. Heap counters reuse the -`KitProfiler` machinery (embedder counter range), opt-in via `KIT_METRICS=1`. - -**SQLite `-O1` optimizer profile** (Linux callgrind, self `Ir`, -optimizer-finalize only). Full sqlite `-O1` callgrind collection on the default -2 GiB Podman VM overflows Valgrind's brk segment and dies with rc 137, so the -useful profile toggles collection at `opt_on_finalize` and forces glibc malloc -onto `mmap` (`MALLOC_MMAP_THRESHOLD_=1 MALLOC_ARENA_MAX=1`). That captures the -whole-program O1 sweep (reachability/internalization, CGIR lowering, inlining, -per-function O1 pipeline, native emit) but **excludes frontend recording**, so it -is a distribution profile, not a total comparable to the `-O0` full-compile -profile above. Command and raw output are in -`build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.*`. - -Profiled command: -`kit cc -O1 -c sqlite3.c -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -lc`. -Total collected inside `opt_on_finalize`: **15.496 B Ir**. - -| self % | function(s) | subsystem | -|--:|---|---| -| **24.5** | `opt_regalloc_place` | physical register placement | -| **18.7** | `opt_func_from_cg_ir` | CGIR -> optimizer Func lowering | -| **7.8** | `alloc_assign_group_hard` | hard-register assignment grouping | -| **7.4** | `ir_note_emit` | IR/lowering record construction | -| 4.5 | `opt_build_loop_tree` | loop analysis | -| 3.2 / 1.7 / 1.6 / 1.5 | `opt_bitset_has` / `opt_bitset_union` / `opt_bitset_copy` / `opt_bitset_union_and_not` | liveness/range bitsets | -| 2.4 | `opt_live_ranges_build` | live-range construction | -| 2.1 / 0.8 | `msort_with_tmp` / `u32_cmp` | sorting in optimizer hot paths | -| 1.6 / 0.3 | `__GI_memset` / `__GI_memcpy` | libc memory | -| 1.5 / 0.2 | `inline_call_site` / `opt_inline` | whole-program inliner | -| 1.2 / 1.0 / 0.3 / 0.2 | `metrics_count` / `metrics_scope_from_name` / `metrics_scope_from_name'2` / `metrics_count'2` | profiling string-name lookup | -| 1.1 | `arena_alloc` | arena bump path | -| 1.0 | `opt_addr_xform_pregs` | address transform | -| 0.1 | `opt_emit_native` | native emit self-time | - -Interpretation: sqlite `-O1` compile time is dominated by the optimizer's -middle/back half, not native emission. Register allocation alone is at least -**32 %** (`opt_regalloc_place` + `alloc_assign_group_hard`, excluding the shared -bitset/range helpers), while CGIR lowering plus record construction is another -**26 %**. Built-in metrics on the same TU report **2,522 functions**, -**121,075 blocks**, **98,516 PRegs**, **1,049,079 ranges**, and **29.1 M live -bitset words touched**, which explains why the liveness/range bitset helpers are -visible. A cheap cleanup from this profile was the disabled metrics fast path: -`metrics_scope_from_name` and `metrics_count` burned ~2.7 % of O1 finalization -with `KIT_METRICS` unset because each hot scope/counter call string-decoded the -name before discovering the profiler sink was null. `metrics_*` now checks the -sink before name decoding; enum-specific wrappers would still make metrics-on -collection cheaper. - -Follow-up cleanup on the same sqlite `-O1` workload made the hot shapes cheaper: -CGIR lowering precomputes local address-use, param lookup, emit order, and -fallthrough successors; regalloc builds coalesce-group member lists before -candidate scoring and uses them in group scans; loop-tree construction -precomputes backedges and uses generation-marked loop bodies. Bounded macOS -release timing for the single amalgamation compile is now **0.93 s best / 0.96 s -mean** (7 runs, 5 s per-run timeout); the ecosystem-style two-TU sqlite O1 build -is **1.20 s best / 1.22 s mean** (5 runs, 8 s timeout), down from the previous -**2.78 s** best row. `KIT_METRICS=1` ticks on the same single-amalgamation -command: `opt.o1.total` **27.09 M → 16.78 M** (−38 %), `opt.regalloc` -**13.81 M → 7.83 M** (−43 %), `opt.o1.cg_ir_lower` **4.94 M → 1.16 M** (−77 %), -and `opt.build_loop_tree` **1.41 M → 0.20 M** (−86 %). Linux callgrind rerun was -blocked in this pass because `podman machine` started then immediately stopped -at 3 GiB, 2.5 GiB, and the restored 2 GiB setting; the VM was left configured at -2 GiB/stopped. - -### Code size is still locally spill-bound - -Even with aggregate `.text` now smaller than tcc, the remaining per-mnemonic -excess is dominated by **spills** (register pressure in the single-pass cache, -+47 K) then residual **`mov`s** (+14 K). Both concentrate in the giant functions -(`_sqlite3VdbeExec` etc.) where per-function pressure is highest. - ---- - -## 3. Reproducing the metrics - -**Build tcc** (the bar) from the mob mirror as an optimized binary: - -```sh -git clone --depth 1 https://github.com/tinycc/tinycc tmp/tinycc && cd tmp/tinycc -export SDKROOT=$(xcrun --sdk macosx --show-sdk-path) # else "stdio.h not found" -sed -i 's|^CFLAGS=.*|CFLAGS=-Wall -O3 -DNDEBUG -Wdeclaration-after-statement|' config.mak -./configure ; make clean ; make CC=cc # CC=cc = Apple clang (xcrun-aware) -# binary at tmp/tinycc/tcc ; compile with: tcc -c sqlite3.c -o x.o -``` - -**Instructions / cycles / wall** — `/usr/bin/time -l` is the macOS `perf stat`; -best-of-N filters scheduling noise: - -```sh -m(){ local bi=9e18 bc=9e18 br=9e18; for i in $(seq 1 7); do - /usr/bin/time -l "$@" >/dev/null 2>/tmp/m.txt - local I=$(awk '/instructions retired/{print $1}' /tmp/m.txt) - local C=$(awk '/cycles elapsed/{print $1}' /tmp/m.txt) - local R=$(awk '/real/{print $1}' /tmp/m.txt) - awk "BEGIN{exit !($I<$bi)}"&&bi=$I; awk "BEGIN{exit !($C<$bc)}"&&bc=$C - awk "BEGIN{exit !($R<$br)}"&&br=$R; done; echo "instr=$bi cycles=$bc real=${br}s"; } -cd tmp/projects/sqlite-amalg -m build/release/kit cc -c sqlite3.c -o /tmp/k.o --sysroot "$SDK" -m tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o -``` - -Caveat: Apple clang spawns a `cc1` that `/usr/bin/time` does **not** count — -measure it with `cc -fintegrated-cc1 -c …`. kit and tcc are single-process. - -**Phase decomposition.** Do **not** use `-E` as the lex+pp number — it adds -token→text serialization neither compiler does at `-c`, and tcc/kit serialize at -very different cost (§2). Instead **drain the token stream to EOF** (lex+pp only, -no parse, no output) in each compiler: - -```sh -# kit: KIT_PP_DRAIN runs pp_next_parse to EOF then returns (lang/c/c.c) -m env KIT_PP_DRAIN=1 build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /dev/null # lex+pp -m build/release/kit cc -fsyntax-only sqlite3.c --sysroot "$SDK" # lex+pp +parse/sema/types/CG -m build/release/kit cc -c -o /tmp/k.o sqlite3.c --sysroot "$SDK" # +emit+objwrite -# tcc: the -bench hook drains next() to EOF; patched (tccpp.c tcc_preprocess) -# to use -c parse_flags. TCC_DRAIN_FLAGS=c also decodes literals in the lexer. -m env TCC_DRAIN_FLAGS=c tmp/tinycc/tcc -E -bench sqlite3.c -o /dev/null # tcc lex+pp -m tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o # tcc full -c -``` - -**macOS hotspot sample** (self-time leaves only — see the callgrind caveat). One -`-c` is too fast for `sample`; merge ~80 runs: - -```sh -make RELEASE=1 PROFILE=1 CC=clang BUILD_DIR=build/bench bin # -g + frame ptrs, no strip -cd tmp/projects/sqlite-amalg ; KIT=build/bench/kit ; rm -f /tmp/p_*.txt -for i in $(seq 1 80); do - $KIT cc -c sqlite3.c -o /tmp/kk.o --sysroot "$SDK" 2>/dev/null & pid=$! - sample $pid 5 1 -file /tmp/p_$i.txt -mayDie >/dev/null 2>&1 ; wait $pid; done -for f in /tmp/p_*.txt; do awk '/Sort by top of stack/{f=1;next}/Binary Images/{f=0}f' "$f"; done \ - | sed -E 's/\(in [^)]*\)//' | grep -oE '[A-Za-z_][A-Za-z0-9_]*[[:space:]]+[0-9]+' \ - | awk '{c[$1]+=$2;t+=$2} END{for(k in c) printf "%.1f%% %s\n",100*c[k]/t,k}' | sort -rn | head -25 -``` - -**Linux callgrind** — the **inclusive, instruction-grounded** profiler (no -valgrind on macOS). `scripts/perf_callgrind.sh` codifies the recipe; one-time -`scripts/perf_callgrind.sh image` bakes the container, then: - -```sh -scripts/perf_callgrind.sh run <tag> # builds kit in-container, runs callgrind, prints total Ir - # → build/linux-prof/cg.<tag>.annot.txt (self Ir + callers) -``` - -For the sqlite `-O1` optimizer-finalize profile, use `podman machine` explicitly -to give the VM enough memory for Valgrind, and force malloc away from brk: - -```sh -podman machine stop || true -podman machine set --memory 3072 -podman machine start -podman run --rm --platform linux/arm64 -v "$PWD":/work:Z kit-prof sh -c ' - set -eu - cd /work/tmp/projects/sqlite-amalg - env MALLOC_MMAP_THRESHOLD_=1 MALLOC_ARENA_MAX=1 \ - valgrind --tool=callgrind --cache-sim=no --branch-sim=no --dump-instr=no \ - --collect-jumps=no --collect-atstart=no --toggle-collect=opt_on_finalize \ - --callgrind-out-file=/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.out \ - /work/build/linux-prof/kit cc -O1 -c sqlite3.c \ - -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -lc -o /tmp/sqlite_o1.o \ - 2>/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.vg.log - callgrind_annotate --threshold=99.5 \ - /work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.out \ - >/work/build/linux-prof/cg.sqlite_o1.opt_finalize.mmap.annot.txt -' -# restore the default local VM shape after the run -podman machine stop -podman machine set --memory 2048 -podman machine start -``` - -Gotchas baked into the script (do not relearn): **(a)** pass `-lc` so kit finds -the glibc sysroot even for `-c`; use **bookworm** (glibc 2.36), not ubuntu 24.04 -(glibc 2.39's `bits/math-vector.h` uses a vector-typedef attribute the C frontend -rejects). **(b)** `strip --strip-debug` the kit binary in place before profiling -(valgrind 3.19 chokes on clang DWARF5; callgrind needs only `.symtab`) — strip *in -place*, kit resolves `support/rt` relative to its own path. **(c)** trust -self/exclusive `Ir`, not inclusive % (recursion in the recursive-descent parser -makes `--inclusive=yes` double-count to absurd numbers). The Linux/ELF total is -not comparable to the macOS/Mach-O hardware figure (different libc, sysroot, -format; counts glibc + loader + the `-lc` probe) — the *distribution* is the point. - -**Heap allocation counts** — `KIT_METRICS=1` prints the hosted-heap counters -(allocs / large allocs ≥32 KiB / reallocs / frees) at exit; the counts are -host-independent (kit issues the same `h->alloc` calls everywhere): - -```sh -KIT_METRICS=1 build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /tmp/k.o -# -> kit heap metrics: heap.allocs=… heap.large_allocs=… heap.reallocs=… heap.frees=… -``` - -**Code size** (`.text` machine code, the honest metric): - -```sh -build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /tmp/k.o ; build/release/kit size /tmp/k.o -tmp/tinycc/tcc -c sqlite3.c -o /tmp/t.o ; build/release/kit size /tmp/t.o -# per-mnemonic excess: -hist(){ build/release/kit objdump -d "$1" | grep -E '^[[:space:]]+[0-9a-f]+:' \ - | sed -E 's/.*\t([a-z][a-z0-9._]*).*/\1/' | grep -E '^[a-z]' | sort | uniq -c | sort -rn; } -diff <(hist /tmp/k.o) <(hist /tmp/t.o) -``` - -**End-to-end correctness** (sqlite as a correctness test, both opt levels): - -```sh -build/release/kit cc sqlite3.c shell.c -o /tmp/sq --sysroot "$SDK" -lc -/tmp/sq :memory: "create table t(a,b); insert into t values(2,40),(10,32); - select sum(a)+sum(b), count(*) from t;" # -> 84|2 -``` - -**Synthetic scaling guard** (`make bench-cc`): sweeps each input dimension -(`fn-count`, `body-size`, `global-decl`, `type-decl`, `locals-per-fn`, `pp-macro`, -`pp-include`, `ref-density`, `obj-count`, `symbol-count`) over a geometric series, -fits a scaling exponent (`p ≈ 1.0` goal, `p ≥ 1.4` flagged `SUPERLINEAR` — treat -as a bug), samples hotspots, and records a clang reference. Output in -`build/bench/cc/` (`scaling.md`, `hotspots.md`). Use it to catch a re-introduced -O(n²); use the sqlite profile to decide *where* to optimize (the synthetic axes -over-weight codegen vs real code). Sources of truth: -`scripts/cc_bench_gen.py` (axis catalog), `scripts/cc_bench.sh` (harness), -`scripts/cc_bench_report.py` (fit/verdicts). - ---- - -## 4. Ideas for improvement (forward-looking, ranked) - -Ordered by instruction/byte-payoff × structural leverage. The architecture's asset -— the `CgTarget`→`NativeTarget` polymorphism (one frontend; aa64/x64/rv64/rv32 + -wasm + c_target + check backends, plus the opt-IR recorder at `-O1`) — **stays**. -Everything below lives in one of: the front half, the type subsystem, *inside* the -concrete `NativeDirectTarget`/`MCEmitter` (the -O0 register machinery + byte sink, -not the swappable vtable), or the code-size track (which *bytes* the NDT emits). - -### 4.1 Compile speed (frontend-bound) - -**Lean-token loc-materialization fix — Stage 1 DONE (`-c` −24 M net win).** The -boundary rewrite's initial +62 M `-c` regression was two lazy-loc implementation -costs, both now fixed byte-identically (frontend-only, no CG-API change): -- ✅ **memchr the line-index build.** `srcinfo_build_lines` built the line-start - index with a scalar `\n` byte loop; replaced with a memchr sweep (5.3 % → 0.6 % - self-`Ir`). The build is genuinely needed in the happy path — `__LINE__` (which - sqlite expands pervasively) and diagnostics need line numbers even without - `-g` — so the lever was to make it cheap, not to skip it. -- ✅ **cursor `pp_materialize_loc`.** Per-loc lookup was a fresh binary search; - now a `SrcInfo.line_cursor` caches the last line index, so the near-monotonic - access pattern (parser advances forward) is O(1), binary search only on a - backward jump (2.6 % → 0.9 %). - -Result: lex+pp −44 M (the scanner no longer builds a `SrcLoc` per token), -parse+CG +19 M residual, **net −24 M** (§1, §2), `perf-gate` byte-identical. - -**Stage 2 (optional, ~+19 M parse+CG residual).** The residual is the -per-statement loc lookup at `pcg_set_loc` (CG source-loc) that the old -eager-on-token loc got for free. `g->cur_loc` is consumed only by DWARF (gated on -`g->debug`), descriptor locs, and cold CG `compiler_panic`s — so with `-g` off and -no error it is discarded. Carrying it as a `LocRef` (a `kit_cg_set_locref` + -a "resolve `LocRef`→`SrcLoc`" callback the frontend registers, so cold CG -diagnostics still materialize exactly) would skip those per-statement lookups -entirely. Smaller payoff than Stage 1, needs a contained CG-API addition + the -parser loc-plumbing; gate: byte-identical (`-g` DWARF + diag battery unchanged). - -**Status (prior campaign).** Most of §4.1 has now landed, byte-identical, for a -combined **−43.7 M `-c` instructions (−2.29 %)** on sqlite vs the pre-campaign -snapshot (sqlite `.o` + `-E` bit-for-bit identical; 60/60 gate; full parse/pp/cg -/toy/smoke-x64/rv64/debug/dwarf suites green). Item-by-item below: #1 ✅ (landed -earlier, `5ff33ea3`); #2 ◐ (a+c landed `d07e43a3`+`c8ee9133`; b deferred — -near-zero payoff, see note); #3 ✅ (object-macro replay `7ada3f62`, function-macro -replay `d59dff08`, lexer-level newline elision `811351c2`); #4 ◐ (Cut A -`f368c7f8` + Cut B `94e642e3` landed; the dead paste-file-id register, Cut C, left -as a small follow-on); #5 ○ open. - -1. ✅ **[DONE — `5ff33ea3`]** **Symbol-binding cache on the interned `Sym`** — the highest-value front-half - brick. `scope_lookup` is an N-scope chain walk per identifier (`lang/c/parse/ - parse.c`, ~12 call sites); tcc caches the binding on the interned token. A - `Sym`-keyed binding stack with push/restore on scope enter/exit makes resolution - a pointer load. Medium-high risk (save/restore + shadow/redef/typedef-vs-ident - discipline); byte-identical gate, full parse corpus. -2. ◐ **[a+c DONE; b deferred]** **Type subsystem (#2 self-`Ir` cluster, ~14 %).** - *Done:* single-decode the per-op predicate gauntlet — hoist one - `api_type_pred_bits` per id and test `API_PRED_*` masks locally instead of - re-calling `cg_type_is_*` (each a fresh `id→entry`+unalias decode) on the - load/store/convert hot paths (`d07e43a3`); fuse the redundant `resolve_type` - into the following `api_unalias_type`, and route the hot `cg_adapter` helpers - through the slot's cached `cg_id` instead of the uncached `pcg_tid` bridge - (`c8ee9133`). *Deferred:* collapsing the duplicate `c_abi_record_layout` - (frontend) vs `abi_cg_record_layout` (backend) memos — both are already - memoized at O(#distinct records), so the collapse saves only the duplicate - build (< 0.01 % of total) against real boundary/byte-identity risk; not worth - it now. Optional remaining: cache `pred_bits` on the `ApiSValue` slot itself - (free `ApiBitField.pad` bytes) — measure first, retype-invalidation surface. -3. ✅ **[DONE]** **Token relay (lex+pp).** Macro bodies with no `##` now replay by - pointer (object-like `7ada3f62`; function-like skips the `subst_phase2` copy, - `d59dff08`). Non-directive newlines are no longer *materialized* by the lexer on - the cc path (`811351c2`, −32 M `-c`): an `emit_newlines` lexer mode suppresses - them while still emitting the one newline that terminates a directive line - (Option B2 — no cross-frame coupling); `-E`/cpp keep them. Line numbers safe by - construction (counter advances on byte-consume, `loc` rides every token); - verified bit-identical on `-E`, `-c`, and `-c -g` DWARF. -4. ◐ **[Cut A+B DONE; Cut C open]** **`lex_open_mem` per-open work** (~6,000 opens: - 3,140 files + 2,796 macro-paste buffers). *Done:* paste buffers skip the - `lex_fold_splices` memchr scan via a guarded splice-free fast path (`f368c7f8`, - they are interned already-folded spellings — provably splice-free); the - per-file-open full-struct `memset` is right-sized to just the `punct_sym` - sentinel array (`94e642e3`). *Open (Cut C):* the dead paste file-id - registration could become a bare `nfiles++` after auditing no diagnostic ever - queries a paste file-id. -5. ◑ **[arena churn LANDED; memset OPEN]** **`memset` / arena churn (~2–3 %).** - Arena churn done: blocks now grow geometrically and `arena_reset` retains the - high-water capacity instead of freeing all-but-head (large block allocs - **−72 %**, byte-identical) — see §2 *Allocator*. *Still open:* right-size - per-expression / per-emit struct zeroing (designated-init the per-op clears). - (`memset` here is explicit zero-init, **not** `-ftrivial-auto-var-init` — - proven by rebuilding with the flag off; attack call sites, keep the hardening - flag.) - -Near-dead-ends for *instructions* (revisit only under a cache-stall study, not -expected to pay): the `pool_intern_slice` probe/insert side (the self-sufficient -`{hash,sym}` slot measured ~0 `Ir`); `lex_next` itself (raw-cursor scanner is -optimal); `aa_emit_mem` typed-store micro-levers. - -### 4.2 Code size (run-correctness-gated; multiplicative — each byte cut shrinks emit + objwrite + assemble together) - -0. **Trim the over-reserved far-slot/large-frame patch region (new, +12,897 `nop` - / ~52 KB — the whole 0.951×→0.986× erosion).** The aa64 far-slot address-build - / large-frame fallback now reserves a fixed **branched-over 5-instruction patch - region** in ~2,587 functions' prologues (a `b` over five `nop`s when the long - path is unused), so every qualifying function pays 24 B of padding even when - the short path suffices. This is correctness-first and *probably* over-reserved: - reserve only when the frame/slot actually needs the long form, or shrink the - reservation to the real worst case (and patch the branch away when unused). - Investigate before touching spills — it's likely the cheapest byte win on the - board. Gate: run-correctness + determinism (this is the codegen path the recent - correctness fixes added). -1. **Spill reduction — the #1 remaining local code-size excess (+47 K).** The - single-pass NDT spills more than tcc keeps resident across statements and - control-flow joins. The no-new-analysis wins (dead-operand drop, - materialize-before-flush) are done; deeper residency = keeping a *live* value - in a register across a join, which is register allocation and out of simple - single-pass scope. Pursue as a separate `-O1`-style pass over the NDT, or a - cheaper "pin the hot local across a straight-line run" heuristic. -2. **Positive-offset frame addressing** (kills the residual `sub xN,x29`+`movk`, - ~3.5 K). The machinery already exists — aa64 has a bottom-record layout - (`fp_at_bottom`, `frame_size−off`) + an `AAPatch` deferred-patch list, currently - gated to `-O1` known-frame. Extend to `-O0` by deferring per-slot offsets (emit - scaled `str`/`ldr` with an imm12 placeholder, patch `(N−off)>>scale` at - `func_end`; scaled imm12 reaches 32,760 B so the placeholder is always one - word). alloca-safe via a stable base anchor (the fp-at-bottom / a callee-saved - frame base plays tcc's stable-x29 role). Compile-speed risk (per-access - bookkeeping) — gate it. -3. **Residual arg/value `mov`s (+13.7 K).** Forward-order materialization places - straightforward args directly in their ABI register; what's left is args it - can't place — a nested-call result already in x0, or a value spilled under - pressure. A per-arg **destination hint** (abstract arg-slot threaded - frontend→CG-API→backend, resolved to the physical reg) would let the producer - target the exact register in those cases. High plumbing, high correctness - surface; the win is the residual only. -4. **Signed load-with-extend** `ldrb;sxtb`→`ldrsb` (the unsigned zero-extend cases - are done). Structurally **blocked**: needs a load-with-extend rider on - `MemAccess` (kit's CG integer types are sign-agnostic, so `nd_load` can't know - the value feeds a sign-extend) — a frontend widening signed load, not a register - rename. -5. Smaller, self-contained: **indexed-addressing fold** (collapse base+index+disp - into one EA where a member offset currently forces an extra `add`), - **NOP/alignment-pad trimming**, **call-argument stack-spill** reduction. - -### 4.3 The end-state bet (large lift, optional) - -tcc's *shape*: a shared mutable token slot fed by both scanner and macro replayer -(no per-stage `Tok` structs), identifiers resolved through symbol pointers cached -on the interned token, a thin `SValue[]` the parser drives by calling emit -directly. This is the "clean structural redesign" the project prizes and subsumes -§4.1. **But** the value-stack→direct-emit vtable collapse is **explicitly out of -scope** here: the two real indirect calls per primitive are the price of the -seven-backend modularity and are kept. The seam-preserving items above plus the -code-size track are the campaign from here; the ~2× headroom a full tcc-shape -collapse might reach is not pursued. - -### Measured dead ends — do not retry - -- **16-byte `Tok`** (LocId side-table / register return): byte-identical but a net - `-c` regression (every token pays a LocId store+resolve that outweighs the - smaller copy; `-c` dominates and is loc-heavy). -- **Fused lex→pp→parse pull pipeline**: the pull wrappers are only ~5 % of frontend - self-time; `pp_next`'s real cost (macro/directive logic) survives the rewrite. -- **`nd_grow_*` non-zeroing**: uninit-read risk no sanitizer catches. -- **Word-at-a-time intern hash · lower hash load factor · inline-prefix entry - cache**: all slower or negligible for the short identifiers real code uses. - ---- - -## 5. Methodology notes - -- **Measure on a RELEASE build** (`make bin RELEASE=1`, or `PROFILE=1` for - sampling), never the default `make bin` (`RELEASE=0` → ASan/UBSan): ASan - amplifies *memory*-op savings and dilutes *compute* savings, inverting the - ranking. Gates are build-mode-independent; timers are not. -- **Byte-identity gate tooling.** `scripts/perf_identity_gate.sh` (60-category - byte-identical gate, golden-vs-candidate) + `scripts/perf_axis_time.py` (focused - A/B axis timer). For lexer changes the diff must include a line-splice battery - (mid-token / mid-string / leading / trailing / consecutive continuations) plus a - diagnostic whose line number falls across a splice. Flow: `make perf-golden` - (snapshot pre-change RELEASE build) → edit → `make bin RELEASE=1` → `make - perf-gate`. The golden carries its own `support/rt` sibling, so both binaries - resolve their runtime from any cwd. -- **Code-size gate** (for emitted-byte changes): determinism (compile twice + - `cmp`) + `make test-toy test-cg-api test-opt test-smoke-x64 test-smoke-rv64 - test-parse-ok test-parse-err test-dwarf test-debug` + sqlite e2e at `-O0`+`-O1` - vs clang + per-arch clang-differential probes. **Verify shared-NDT changes on - x64 and rv64, not just aa64** — a register-residency change can be a win on the - reference arch and a silent miscompile elsewhere (x86-64 RAX is an implicit - div/mul operand; the result-stable capability is gated per-arch for this reason). -- **Re-profile after each landing and re-rank** — the highest lever moves as work - is removed. diff --git a/doc/plan/PORT.md b/doc/plan/PORT.md @@ -1,278 +0,0 @@ -# Portability test surface: `cross` + `selfhost` over one support set - -This doc is the spec for kit's portability test surface — the harmonized -top-level make targets and the scripts beneath them that answer two questions: - -1. **cross** — can kit, running on the dev host, cross-compile a *correct* - executable for every target in its support set? -2. **selfhost** — can kit be built to *run on* each target, and then compile + - run a program there? - -It supersedes the old scattered targets (`test-hosted*`, `test-libc*`, -`test-freebsd*`, `test-coff-windows-*`, `test-toy-*-vm`, `test-link-x64`, -`test-parse-rv64-wide`, `bootstrap-linux*`, `bootstrap-freebsd`, the standalone -`windows_cross.sh`, and the freestanding smokes). See "Migration" at the end. - -Related: [BOOTSTRAP.md](BOOTSTRAP.md) (the 3-stage self-build mechanism), -[SYSROOTS.md](SYSROOTS.md) (sysroot provisioning), [windows.md](windows.md). - -## Model: two modes over one matrix - -``` - support set (one canonical list in scripts/hosted.sh) - ┌──────────────────────────────────────────────────────┐ - mode cross │ host kit builds for T → artifact runs correctly on T │ - mode selfhost│ build a kit that runs on T → it builds + runs a program │ - │ on T │ - └──────────────────────────────────────────────────────┘ - substrate (native / qemu-user / podman / VM / qemu-system) = IMPLICIT, - resolved per target by the exec seam; never named in a target. -``` - -The support set, one token grammar everywhere: **`<os>[-<libc>]-<arch>`**. - -``` -cross matrix (16): - linux-{glibc,musl}-{aa64,x64,rv64} (6) - freebsd-{aarch64,amd64,riscv64} (3) - windows-{aarch64,x64} (2) - macos-aarch64 (1) - freestanding-{aa64,x64,rv64,rv32} (4) ← rv32 lives here only - -selfhost matrix (12): the same, minus freestanding -``` - -Freestanding has **no OS**, so there is nothing to run a compiler *on*: it -participates in `cross` only. `selfhost` of a `freestanding-*` target is a hard -error. - -Arch tokens are the short forms **`aa64` / `x64` / `rv64`** (plus `rv32`, -freestanding-only). The long forms (`aarch64`/`amd64`/`x86_64`/`riscv64`) are -accepted as input aliases by `scripts/hosted.sh`. - -## Top-level make targets - -``` -make test-port # test-cross + test-selfhost -make test-cross [TARGET=…] [DEPTH=…] [KIT_VM=…] [RUN=0|1] -make test-selfhost [TARGET=…] [DEPTH=…] [KIT_VM=…] -make kit-cross [TARGET=…] [CROSS_CC=kit|clang] [VERIFY=0|1] -make provision [TARGET=…] [KIT_VM=…] -``` - -Defaults: `TARGET ?= all`, `DEPTH ?= smoke`, `KIT_VM ?= 1`, `RUN ?= 1`, -`CROSS_CC ?= kit`, `VERIFY ?= 0`. - -`test-port` is **not** part of the default `make test` (it is heavy and needs -provisioning). The native 3-stage `bootstrap` + `test-bootstrap-toy` stay in the -default suite as the fast self-reproduction check. - -### `TARGET=` selector grammar - -Resolved centrally by `scripts/hosted.sh expand <selector> [--mode=cross|selfhost]`: - -| Selector | Expands to | -|---|---| -| `all` | every support-set token (mode- and `KIT_VM`-filtered) | -| `linux` / `freebsd` / `windows` / `macos` / `freestanding` | all configs for that OS | -| `linux-musl` / `linux-glibc` | that libc, all 3 arches | -| `linux-glibc-x64`, `freebsd-aarch64`, `freestanding-rv32`, … | one exact config | -| `a,b,c` | comma list of any of the above | - -`--mode=selfhost` drops `freestanding-*` from the expansion. `KIT_VM=0` drops -`freebsd-*` and `windows-*`. - -### `DEPTH` - -`test-cross` accepts: - -- `coarse` — cross-compile and link the smoke artifacts across the expanded - support set, but do not execute them. This is the named form of - `make test-cross RUN=0`. -- `smoke` (default) — two cases, building up platform coverage: - 1. `exit` — an `#include`-free exit-code program (`test/cross/cases/exit.c`, - returns 42): toolchain + link + crt + the exit-code path; on freestanding - the bare-metal exit oracle. Runs on **every** config (the only case - freestanding can run — no libc there). - 2. `hello` — the libc hello-world (`hello.c`, prints + returns 0): the - **sysroot headers + libc + stdout**. Hosted configs only. - - Each case runs once per **link mode** (`link_modes`): **musl exercises both - `static` (libc.a) and `dynamic` (libc.so)** — the two ways the sysroot is - consumed; lanes are suffixed `:static`/`:dynamic`. FreeBSD is static; glibc is - dynamic-only (static glibc is discouraged / NSS-fragile); macOS/Windows use - their single default shape. So a musl target yields four smoke lanes - (exit/hello × static/dynamic), a glibc target two, freestanding one. -- `full` — the two smoke cases + the toy and parse corpora (cross-compiled and - run on the target) + the libc cases (linux only). Orchestrates the existing - mature `test/{toy,parse,libc}` runners with the right arch/tag; it does not - reimplement them. - -`smoke` is the default to honor "prefer targeted runs over mass runs" -(CLAUDE.md). `DEPTH=full` is the deliberate heavy run, usually with a scoped -`TARGET`. - -`test-selfhost` accepts `smoke` and `full`; it has no build-only `coarse` lane -because its purpose is to run the newly built compiler on the target. - -### `kit-cross` — cross-build the compiler itself - -`test-cross` asks "can kit cross-compile a *program* for a target?"; -`kit-cross` asks "can kit (or clang) cross-compile *kit itself* to run on a -target?". It is the general form of the old `windows_cross.sh`: pick any hosted -token and a backend, and it produces a runnable `kit` binary. - -``` -make kit-cross TARGET=windows-x64 # kit.exe for windows-x64, via kit -make kit-cross TARGET=linux CROSS_CC=clang VERIFY=1 -``` - -- `CROSS_CC=kit` (default) dogfoods kit as the cross-compiler; `CROSS_CC=clang` - uses an independent clang + lld (and llvm-mingw's compiler-rt for Windows) - toolchain — a useful differential check, since clang's strictness surfaces - source issues kit's frontend tolerates. -- `VERIFY=1` runs the freshly built `kit` *on the target* through the same exec - seam the tests use (`kit_cross_verify.sh`: bare `kit` prints its banner and - exits 0 — exercises load + dynamic-linker + libc init + main). -- `freestanding-*` is excluded (kit needs an OS to run on): the selector uses - `expand --mode=selfhost`. -- Output: `build/kit-cross/<backend>/<target>/kit[.exe]`. The per-target engine - is `scripts/kit_cross.sh`; the makefile loop is in `mk/port.mk`. - -The two backends differ only in toolchain wiring, not source: the Windows -sources are plain-clang-clean (no `__try`, no `_environ` extern, `__thread` -rather than `__declspec(thread)`), and `kit cc` auto-appends `.exe` to an -extension-less `-o` for a Windows target exactly as gcc/clang-mingw do, so both -backends write `kit.exe` directly. The GC flag is the one linker-specific knob: -GNU/lld and kit ld take `--gc-sections`, Apple ld (clang on darwin) takes -`-dead_strip`. - -## Architecture - -### Source of truth — `scripts/hosted.sh` - -Already owns the support set and the `triple`/`path`/`tag` resolvers. Extended -with: - -- `hosted.sh list [selector]` — emit tokens (one per line). -- `hosted.sh expand <selector> [--mode=…]` — the selector grammar above. - -The Makefile reads it via `$(shell …)`; nothing else re-encodes the matrix. - -### Two orchestrators - -- `scripts/cross_test.sh <selector> [DEPTH]` — expand (mode=cross) → **fail-fast - provisioning pre-flight** → compile/link all smoke artifacts → optional - execution phase (`DEPTH=coarse`, `RUN=0`, or `KIT_CROSS_RUN=0` disables it) - → aggregate report. -- `scripts/selfhost.sh <selector> [DEPTH]` — expand (mode=selfhost) → pre-flight - → per-token self-host (dispatch by OS) → on-target corpus → report. - -Both share `test/lib/kit_sh_report.sh` for the verdict/summary layer. - -### Provision-or-error - -Provisioning (network + VM prepare) is **separate** from running, and a requested -target whose sysroot / image / VM is missing is a **hard error**, never a silent -skip. The orchestrators do an aggregated pre-flight: they collect *all* missing -provisioning for the expanded TARGET set, print each with the exact -`make provision TARGET=…` (or host-tool install) to fix, and exit non-zero -before running anything. - -`make provision [TARGET=…]` wraps `hosted.sh prepare`, `make test-images`, the -glibc run images, the Windows UCRT sysroots, and VM prepare — scoped by TARGET -and KIT_VM. rt archives are local build artifacts (not network): the runners -build them on demand via idempotent `make rt-…`. - -### One exec front door, three backends - -`test/lib/exec_target.sh` is the single tag-dispatched front door. A tag is -`<arch>-<os>[-<libc>]`. It routes to one of three backends: - -``` -exec_target.sh -├── stateless os ∈ {linux, macos} → native / qemu-user / podman -├── exec_vm.sh os ∈ {freebsd, windows} → boot VM, run -└── exec_bare.sh os == freestanding → qemu-system bare-metal (NEW) -``` - -`exec_bare.sh` is the consolidated owner of all per-arch bare-metal scaffolding -(reset stub + linker script + exit-code oracle + `qemu-system` invocation), -generalizing the old `exec_rv32_bare.sh` to four arches and absorbing the stubs -that were inlined in `freestanding_system.sh`. It exposes two contracts: - -- `exec_bare_run_image <arch> <kernel.elf> <out> <err> <rc>` — run a ready - bootable image (what `test/link`'s `kernel_image` cases need; `exec_kernel.sh` - is now a thin shim over this). -- `exec_bare_setup <arch> <work>` + `exec_bare_run <arch> <obj> <work> <rc>` — - link a corpus `.o` (entry `main`, returns the exit code) with the per-arch stub - + rt into a bootable image, then run it (what toy/parse need). - -Exit-code oracle per arch (so callers compare `rc == expected` uniformly): - -| arch | mechanism | decode | -|---|---|---| -| aa64 | ARM semihosting `hlt #0xf000` + `ADP_Stopped_ApplicationExit` | qemu rc = guest code | -| rv64/rv32 | SiFive test finisher MMIO at `0x100000` | stub writes `0x3333\|(code<<16)` (or `0x5555` for 0); qemu rc = code | -| x64 | `isa-debug-exit` (iobase `0x501`) | qemu rc = `(code<<1)\|1` → code = `(rc-1)>>1` | - -## Self-host shapes (the substrate asymmetry) - -`selfhost.sh` dispatches by OS, because "build a kit that runs on T" differs: - -- **macos** → native 3-stage `bootstrap` here, then the corpus through stage3. -- **linux** → native 3-stage in a podman container (emulated for non-host arch), - then the corpus. Generalizes `scripts/linux_bootstrap.sh` to (arch, libc). - **rv64 uses a hybrid seed**: an in-container clang stage1 would itself run - emulated (slow), so the stage1 seed is cross-built on the host with clang - (`kit_cross.sh linux-musl-rv64 --cc=clang`, native speed) and fed to - `mk/bootstrap.mk` via `BOOTSTRAP_SEED`; only stages 2/3 run emulated. They are - still byte-identical because the seed and stage2 are the same kit source, hence - functionally identical compilers. Emulated builds write objects to - container-local storage (the `:Z` virtiofs mount flakes new-file creates under - sustained emulated write load) and copy the stage kits back. -- **freebsd** → native 3-stage in the VM, then the corpus - (`scripts/freebsd_bootstrap.sh`). -- **windows** → **cross**-build `kit.exe` on the host (the VM has no seed - compiler), then run it on the VM to cc + run a program - (`scripts/windows_cross.sh`). - -Every selfhost leaf ends with the same contract: *a kit running on the target -compiled and ran a program there.* - -## Status & phased backlog - -`cross`: - -| Config | coarse | smoke | full | -|---|---|---|---| -| linux-{glibc,musl}-{aa64,x64,rv64} | ready | ready | ready (toy X + parse E + libc) | -| macos-aarch64 | ready | ready (native) | ready (native toy/parse) | -| freebsd-{aarch64,amd64,riscv64} | ready | ready (VM) | ready (toy via VM) | -| windows-{aarch64,x64} | ready | ready (VM) | ready (toy via VM) | -| freestanding-rv32 | ready | ready (bare) | ready (toy X + parse E, bare) | -| freestanding-{aa64,x64,rv64} | ready | ready (bare smoke) | **deferred** | - -`selfhost`: macos (native), linux musl/glibc aarch64 (container), linux x64/rv64 -(container under emulation), freebsd aarch64/amd64 (VM), windows aa64/x64 (cross -+ VM). - -### Backlog: freestanding `DEPTH=full` for aa64 / x64 / rv64 - -Running the toy/parse corpora bare-metal on these three arches is new capability -beyond the smoke payload. The smoke stubs in `exec_bare.sh` are minimal; the -corpus exercises TLS, soft-float, large frames, i128, etc., which need hardened -per-arch stubs (the rv32 path already has this — its reset stub seeds a static -TLS image and enables the FPU). Work items: - -1. Harden the aa64/rv64 `exec_bare` stubs (TLS image seed + thread-pointer setup, - matching the rv32 stub and the toy X-lane `start.c`). -2. Build the x64 long-mode corpus stub (the rv32/aa64 reset path + a full - exit-code oracle via `isa-debug-exit`). -3. Wire a bare lane into `test/{toy,parse}/run.sh` for aa64/x64/rv64 (the rv32 - "X / V" path generalized), gated on `qemu-system-<arch>`. -4. Flip the table rows above from **deferred** to **ready** and drop this section. - -Until then, `test-cross TARGET=freestanding-{aa64,x64,rv64} DEPTH=full` runs the -smoke payload and `log`s that corpus-depth is not yet wired for the arch (it does -not silently claim full coverage). diff --git a/doc/plan/PROF.md b/doc/plan/PROF.md @@ -0,0 +1,85 @@ +# Sampling profiler — `kit prof` (planned work) + +Forward-looking design for `kit prof`, a host-native statistical CPU profiler. +It reuses the interactive JIT debugger's host signal infrastructure +([../DBG.md](../DBG.md), [DEBUG.md](DEBUG.md)): SIGPROF fires on the debugger's +worker thread, the handler walks the frame-pointer chain into a pre-allocated +ring buffer and returns **without parking** — the one property that keeps +sampling cheap and guest timing undisturbed — and PCs are symbolicated after the +guest exits. + +Nothing exists yet: no `prof` subcommand in `driver/main.c`, no +`src/dbg/prof.c`, no `on_sample` field on `KitDbgSignalOps`. This document is the +worklist for building it. + +> **Scope — this is the *sampling* profiler, deliberately on the host-native +> substrate.** It is one of two complementary profilers, and it is **not** being +> rebased onto the emulator. This one measures real wall/CPU time by +> statistically sampling natively-run kit code; the *deterministic* +> instruction-counting profiler (callgrind-style — exact, reproducible, any +> guest arch) lives on the emulator substrate ([INSTRUMENT.md](INSTRUMENT.md) +> §9, §12.1). The two share a back-end (symbolication + folded / flat output), +> not a collector. See INSTRUMENT.md §9 for the perf-vs-callgrind split and the +> one-core-two-collectors factoring. + +## 1. Public API (`include/kit.h`) + +- Add `on_sample(void* session, void* ucontext)` to `KitDbgSignalOps` (NULL = + ignore SIGPROF); it receives the raw `ucontext_t*`, not a marshalled frame, + because it extracts only PC and FP on the hot path. +- Declare `KitProfBuf` (fixed-capacity sample ring: `pcs[PROF_MAX_DEPTH]` per + sample, `count`/`cap`/`dropped`) and `KitProfWriter` (post-run symbolication + callback vtable), plus `kit_dbg_session_prof_attach(session, buf)` (before + `session_call`) and `kit_dbg_session_prof_collect(session, buf, writer)`. + +## 2. Library (`src/dbg/prof.c`, freestanding C11) + +- `dbg_fp_walk(ucontext, sample)`: frame-pointer walk via + `dbg_os->guarded_copy` for every dereference; terminate on NULL / misaligned + / non-advancing FP or `PROF_MAX_DEPTH`. The three frame layouts are identical + (`[FP]` = saved FP, `[FP+8]` = saved LR/return addr); FP is x29 / rbp / + s0(x8). WHY: no DWARF or symbol lookup on the signal path — raw PCs only. +- `on_sample` body: capacity check, walk, append or bump `dropped` (non-atomic; + the single worker makes that safe). `prof_attach` / `prof_collect` bodies; + `prof_collect` symbolicates each PC via `kit_jit_addr_to_sym` + + `kit_dwarf_addr_to_line`, dispatching to the writer (may allocate freely). + +## 3. Host adapter (`driver/env/posix_dbg.c` and `windows.c`) + +- Add SIGPROF to the POSIX handler's signal set with an early-return path: + `if (signo == SIGPROF && on_sample) { on_sample(...); return; }` — no + park/unpark. SIGPROF joins the blocked cohort so it does not recurse. Timer + arming (`setitimer(ITIMER_PROF)`) and thread targeting stay driver-side and + do **not** belong behind `KitDbgOs`. +- Decide the Windows sampling mechanism (no SIGPROF): a periodic + `SuspendThread` + `GetThreadContext` sampler thread is the natural analog of + the VEH interrupt path. WHY: the SIGPROF design has no direct Windows + equivalent, so this is a genuine open design question, not a port. + +## 4. Driver (`driver/cmd/prof.c`) + +Wired into the multi-call dispatch in `driver/main.c`: + +- Flags `--rate` (default 1ms), `--depth` (64), `--cap` (1M), `--output` + (`prof.folded`), `--no-folded`, `--no-flat`. Input handling mirrors + `kit run`, with `-g` forced on so symbolication always has DWARF. +- Arm the timer before `session_call`, disarm after, then `prof_collect`. + Emit folded stacks (sorted + RLE for `flamegraph.pl`) plus a flat + self%/cumul% report to stdout and a dropped-sample warning. + +## 5. Tests + +- `test/smoke/prof_hello`: assert `prof.folded` is non-empty and `main` appears. +- `test/dbg/fp_walk_*`: canned frame chains per arch, assert the PC sequence and + termination. +- `test/dbg/prof_buf_overflow`: fill to capacity, assert `dropped` increments + and `count` caps. + +## 6. Follow-ons (deferred) + +- Per-thread timers via `timer_create(CLOCK_THREAD_CPUTIME_ID)` + + `SIGEV_THREAD_ID` for multi-thread guests. This depends on widening + `KitDbgOs` for multi-threaded guests ([DEBUG.md](DEBUG.md) §7). +- An `ITIMER_REAL` wall-clock mode for I/O-bound programs. +- Allocation profiling via a conditional breakpoint on the allocator. +- SpeedScope / pprof output. diff --git a/doc/plan/README.md b/doc/plan/README.md @@ -4,30 +4,31 @@ Forward-looking roadmaps: what is intended, why, the open problems, and the design of features not yet built. These are distinct from the design docs one level up (`../`), which describe the system *as it is* — when a feature here ships, its durable design moves up to the matching design doc and the entry here -shrinks to whatever remains open. +shrinks to whatever remains open (and is deleted once nothing remains open). | Roadmap | Scope | Design doc | |---------|-------|------------| -| [RELEASE.md](RELEASE.md) | Cross-cutting initial-release punchlist: release scope, deferred features, and per-subsystem completion/validation items. | — | -| [OPTIMIZER.md](OPTIMIZER.md) | Completing the O2 SSA mid-end, expanded inlining, -O0/-O1 performance work, machine register-constraint improvements. | [../OPT.md](../OPT.md) | -| [O1.md](O1.md) | Closing the `-O1` *generated-code quality* gap to clang `-O1` within the no-SSA/linear-compile constraint: measured gap, the spill-addressing root cause, and a ranked worklist (positive-offset spill addressing, rematerialization, linear coalescing, peepholes). | [../OPT.md](../OPT.md) | -| [PERF.md](PERF.md) | Making kit the fastest `-O0` C compiler with code as dense as tcc: current compile-speed + code-size standings, how to reproduce them (macOS instruction counts, Linux callgrind, the `make bench-cc` scaling guard), and the ranked forward-looking levers on both axes. | [../ARCH.md](../ARCH.md) | -| [CG-TYPES.md](CG-TYPES.md) | Clean-break redesign of the public CG type/type-id system: direct-index handles, real void types, explicit alias/storage semantics, ABI-owned layout, and two-phase nominal records. | [../CODEGEN.md](../CODEGEN.md), [../INTERFACES.md](../INTERFACES.md) | -| [CG-TYPE-DEBUG-SPLIT.md](CG-TYPE-DEBUG-SPLIT.md) | Make `KitCgTypeId` storage/ABI-only and move source spelling (primitive sign+name, typedefs, enum underlying) onto a separate debug-type channel: deletes `ALIAS`/`SOURCE_BASE`, `api_unalias_type`, and the per-backend unalias boilerplate. Resolves CG-TYPES.md §10 Q1. | [../CODEGEN.md](../CODEGEN.md), [../DWARF.md](../DWARF.md) | -| [CG-STACK-API.md](CG-STACK-API.md) | Refactoring the parser-to-CG expression seam: keep stack-based liveness, move the C frontend's duplicated `pcg` slot state onto CG-owned stack slots, and add parser-shaped place/load/store operations without backend fusion. | [../FRONTENDS.md](../FRONTENDS.md) | -| [LEX-PP-API.md](LEX-PP-API.md) | Redesigning the lexer -> preprocessor -> parser boundary around lean tokens, lazy spelling/location materialization, and slot-based handoffs for a high-performance lexer/preprocessor rewrite without frontend layer fusion. | [../FRONTENDS.md](../FRONTENDS.md) | -| [LINKER.md](LINKER.md) | Incremental linking: the file-based object-link redesign and remaining non-ELF format coverage. | [../LINK.md](../LINK.md) | -| [LINKER-COMPAT.md](LINKER-COMPAT.md) | Completing system-linker compatibility across the support set: ordered DSO selection, ELF TLS/TLSDESC, shared libraries, relocatable links, runtime/sysroot interoperability, and Rust/toolchain validation. | [../LINK.md](../LINK.md), [../OBJ.md](../OBJ.md), [../DRIVER.md](../DRIVER.md) | -| [KERNEL.md](KERNEL.md) | Freestanding kernel build and image pipeline: build-obj/build-exe support, linker-script growth, map/symbol side outputs, and image emission via kit image / objcopy -O binary. | [../DRIVER.md](../DRIVER.md), [../LINK.md](../LINK.md), [../OBJ.md](../OBJ.md), [../RUNTIME.md](../RUNTIME.md) | -| [JIT.md](JIT.md) | Function-level hot reload, Go-runtime-style codegen support, and remaining JIT host-portability work. | [../JIT.md](../JIT.md) | -| [DEBUG.md](DEBUG.md) | The Windows debugger host adapter, x64/rv64 displaced single-step, profiling, and DWARF gaps. | [../DBG.md](../DBG.md), [../DWARF.md](../DWARF.md) | +| [RELEASE.md](RELEASE.md) | Cross-cutting initial-release punchlist: remaining release blockers and per-subsystem validation gaps (completed items are removed, not checked off). | — | +| [OPTIMIZER.md](OPTIMIZER.md) | Completing the O2 SSA mid-end, live-range splitting/coalescing, the residual O1 generated-code-quality gaps that need SSA, -O0 quality, machine register-constraint improvements, and broader inlining. Carries the known non-deficiencies (what *not* to chase). | [../OPT.md](../OPT.md) | +| [LINKER.md](LINKER.md) | Incremental linking (JIT append + the unbuilt file-based "m2" redesign) **and** system-linker compatibility (ordered DSO/`--as-needed`, ELF TLS planning + TLSDESC, shared/relocatable links, sysroot interop, toolchain validation). | [../LINK.md](../LINK.md), [../OBJ.md](../OBJ.md) | +| [DEBUG.md](DEBUG.md) | The interactive JIT debugger + DWARF: x64/rv64 session parity, displaced-step instruction coverage, unit/smoke tests, REPL polish, Toy/C REPL frontends, and DWARF producer/consumer gaps (loclists, CFI register recovery, composite locations). | [../DBG.md](../DBG.md), [../DWARF.md](../DWARF.md) | +| [PROF.md](PROF.md) | The not-yet-built host-native sampling profiler (`kit prof`): SIGPROF frame-pointer walk reusing the debugger's signal infrastructure, the `KitProfBuf`/`KitProfWriter` API, and folded/flat output. Complementary to (not rebased on) the emulator callgrind in INSTRUMENT.md. | [../DBG.md](../DBG.md) | | [INSTRUMENT.md](INSTRUMENT.md) | Debugging/profiling tools in the Valgrind/callgrind/asan/ubsan tradition: the emulator as a dynamic-binary-instrumentation substrate (the `EmuToolHooks` ABI, shadow planes, the memory seam, a guest debugger), plus the cross-frontend compiler-sanitizer story. | [../EMU.md](../EMU.md) | -| [WASM.md](WASM.md) | Completing the Wasm object backend and remaining parser/validator coverage. | [../WASM.md](../WASM.md) | -| [ARCH.md](ARCH.md) | Remaining native-backend completeness for x64/rv64 relative to the aa64 reference, and per-call cost follow-ups. | [../ARCH.md](../ARCH.md) | -| [BOOTSTRAP.md](BOOTSTRAP.md) | The 3-stage self-build reproducibility goal and the open `-O1` issues blocking it. | [../BUILD.md](../BUILD.md) | -| [windows.md](windows.md) | Self-hosting kit on Windows: cross-built `kit.exe` runs `cc` + JIT on aarch64-windows; the open self-host miscompile crash, JIT printf, x64 parity, compile-on-VM lane, and the Windows 3-stage bootstrap. | [../WINDOWS.md](../WINDOWS.md) | -| [BUILD.md](BUILD.md) | A new content-addressed build coordinator (Bazel/Nix-style incremental builds layered on the CAS) — storage state machine, caching algorithm, recipe protocol. Distinct from `../BUILD.md` (kit's own Makefile build). | — (new subsystem) | -| [BUILD_COMMANDS.md](BUILD_COMMANDS.md) | The kit-native `build-exe`/`build-lib`/`build-obj` verbs that replace `compile`: polyglot, in-memory compile+link with `--group` flag scoping and full link-flag control. Distinct from `BUILD.md` (the CAS coordinator). | [../DRIVER.md](../DRIVER.md) | -| [LLGEN_IMPORT.md](LLGEN_IMPORT.md) | Importing the standalone LL(1)/Pratt parser and lexer generator into libkit, including public API renames, file moves, build gates, and a `kit llgen` command. | — | -| [ARM32.md](ARM32.md) | 32-bit ARM (`arm-none-eabi`, ARMv7-M/ARMv7E-M Thumb-2, Cortex-M3/M4/M7) freestanding backend: the supported ISA spec, AAPCS32 ABI, ARM ELF relocations, runtime reuse, and the `qemu-system-arm` cross-test lane. | [../ARCH.md](../ARCH.md), [PORT.md](PORT.md) | -| [TODO.md](TODO.md) | Open deferred fixes and code smells only. Completed items are removed instead of checked off. Not a roadmap; a current backlog. | — | +| [JIT.md](JIT.md) | Function-level hot reload, Go-runtime-style managed codegen support, and remaining JIT host-portability work. | [../JIT.md](../JIT.md) | +| [ARM32.md](ARM32.md) | 32-bit ARM (`arm-none-eabi`, ARMv7-M/ARMv7E-M Thumb-2, Cortex-M3/M4/M7) freestanding backend: Phase 1 (walking skeleton) is landed; Phase 2 tracks the remaining ops, the -O1 known-frame path, 64-bit, atomics, TLS, and the `qemu-system-arm` cross-test lane. | [../ARCH.md](../ARCH.md), [../PORT.md](../PORT.md) | +| [SYSROOTS.md](SYSROOTS.md) | Cross-compile sysroot packaging: minimal per-target stubs/headers/CRT objects distributed via `.kpkg` for the support set. Design complete, implementation not yet started. | — | +| [BUILD.md](BUILD.md) | A new content-addressed build coordinator (Bazel/Nix-style incremental builds layered on the CAS) — storage state machine, caching algorithm, recipe protocol. Design, not yet built. Distinct from `../BUILD.md` (kit's own Makefile build). | — (new subsystem) | +| [LLGEN_IMPORT.md](LLGEN_IMPORT.md) | Importing the standalone LL(1)/Pratt parser and lexer generator into libkit, including public API renames, file moves, build gates, and a `kit llgen` command. Not yet started. | — | +| [TODO.md](TODO.md) | Open deferred fixes and code smells, plus terse backlog folded from retired plan docs (arch-backend parity, Wasm object backend, Windows x64 self-host, bootstrap breadth). Completed items are removed instead of checked off. A current backlog, not a roadmap. | — | + +Speculative, not-committed designs (no code, parked) live in [`../ideas/`](../ideas/) +— currently the RQL query language and the RSN scripting language. + +Plan docs that shipped and were retired into the design set: the `-O1` quality +worklist and frontend/CG redesigns (→ [../OPT.md](../OPT.md), +[../CODEGEN.md](../CODEGEN.md), [../FRONTENDS.md](../FRONTENDS.md)); `build-exe`/ +`build-lib`/`build-obj` (→ [../DRIVER.md](../DRIVER.md)); the freestanding kernel/ +image pipeline (→ [../KERNEL.md](../KERNEL.md)); the bootstrap fixed point +(→ [../BUILD.md](../BUILD.md)); aarch64 Windows self-host (→ [../WINDOWS.md](../WINDOWS.md)); +the portability test surface (→ [../PORT.md](../PORT.md)); and the compile-speed/ +code-size benchmarking methodology (→ [../BENCHMARKING.md](../BENCHMARKING.md)). diff --git a/doc/plan/RELEASE.md b/doc/plan/RELEASE.md @@ -78,11 +78,6 @@ For each target in the release set: Target-specific work: -- [x] Add/validate FreeBSD target parsing and serialization for arm64/x64/rv64. -- [x] Add/validate FreeBSD hosted profiles against a real FreeBSD rootfs for - arm64/x64/rv64. -- [x] Add/validate runtime variants for arm64 FreeBSD, x64 FreeBSD, rv64 - FreeBSD, arm64 freestanding, and x64 freestanding. - [ ] Validate Windows arm64/x64 object/link/runtime support, including UCRT import libraries and large-frame stack probing. - [ ] Validate Linux arm64/x64/rv64 hosted static and dynamic-executable links. @@ -96,20 +91,12 @@ Target-specific work: Release validation is orchestrated from a macOS/aarch64 host. Prepare each execution environment explicitly so test skips mean "unsupported by this lane", -not "runner missing". - -Verified native/VM run signoff: - -- [x] macOS arm64. -- [x] Windows arm64/x64. -- [x] FreeBSD arm64/rv64/x64. -- [x] Linux arm64/rv64/x64, alpine (musl static and dynamic) and debian (glibc). -- [x] Freestanding arm64/rv64/x64/rv32. +not "runner missing". Native/VM run signoff is in place for macOS arm64, Windows +arm64/x64, FreeBSD arm64/rv64/x64, Linux arm64/rv64/x64 (alpine musl +static+dynamic, debian glibc), and freestanding arm64/rv64/x64/rv32. ## Runtime -- [x] Fill the runtime variant table in `mk/rt.mk` and `driver/lib/runtime.c` for - every release target. - [ ] Validate compiler-rt integer/fp helpers for every release data model: LP64, LLP64, and ILP32. - [ ] Validate atomic helper coverage, including non-lock-free widths. @@ -139,21 +126,16 @@ Verified native/VM run signoff: ## LTO and optimizer -- [ ] Merge the LTO work to main without regressing the current build commands, - RV32 work, or test layout. -- [ ] Keep release optimization levels to `-O0` and `-O1`; LTO must be available - at `-O1`. +LTO is on main (`-flto` wired through `cc`/build verbs; preserved/export-set +computation, non-preserved internalization, cross-TU inlining, and LTO tests +landed). Remaining: + +- [ ] Keep release optimization levels to `-O0` and `-O1`; confirm LTO stays + available at `-O1` only (no `-O2` path). - [ ] Decide the release spelling (`-flto`, plus any rejected aliases) and make diagnostics precise. -- [x] Finish link-picture preserved/export set computation for LTO: - entry symbol, dynamic imports used by executable links, opaque object/asm - references, `used`, init/fini, IFUNC, address-significant symbols, and - visibility. -- [x] Internalize non-preserved globals and re-run whole-module reachability. -- [ ] Keep shared-library creation out of scope; make `-shared -flto` reject - cleanly as part of the general dynamic-library creation policy. -- [x] Validate cross-TU inlining and interposition safety on arm64/x64/rv64. -- [x] Add LTO tests for `cc`, `build-exe`, `build-lib`, and `build-obj`. +- [ ] Make `-shared -flto` reject cleanly as part of the general dynamic-library + creation policy (currently a "not supported yet" diagnostic). - [ ] Refresh O0/O1 benchmark baselines and record LTO impact separately. ## Build coordinator @@ -195,7 +177,7 @@ Verified native/VM run signoff: - [ ] Add Wasm/WASI smoke tests that do not depend on Toy. - [ ] Make unsupported Wasm proposals and unsupported WASI calls fail with feature-naming diagnostics. -- [ ] Update `doc/WASM.md`, `doc/plan/WASM.md`, driver help, and README to match +- [ ] Update `doc/WASM.md`, driver help, and README to match the v1 Wasm/WASI surface. ## Interactive debugger @@ -208,7 +190,6 @@ Verified native/VM run signoff: - [ ] Bring x64 and rv64 session integration to parity with arm64: fault classification, trap PC normalization, register marshalling, and displaced single-step. -- [x] Add or extend ucontext/register marshalling for FreeBSD x64/arm64/rv64. - [ ] Handle known declined displaced-step forms or diagnose them cleanly. - [ ] Add scripted transcript tests and low-level unit tests for breakpoint patch round-trip, guarded copy, displaced stepping, and source stepping. @@ -231,29 +212,13 @@ Verified native/VM run signoff: ## C ecosystem gates -The reproducible harness landed: `test/ecosystem/` + `scripts/ecosystem.sh`, -driven by `make provision-ecosystem` (network: fetch + sha256-verify + extract -pinned upstream sources into the kit cache dir) and `make test-ecosystem` -(offline: build each project's TUs with `kit cc`, archive with `kit ar`, link a -driver, run it, and diff the output against a checked-in golden AND against the -same program built with clang). Both `-O0` and `-O1` run for every project. -Green at both levels and byte-identical to clang: cJSON, Lua, LZ4, miniz, -tinyexpr (and SQLite at `-O0`). Two real kit bugs are kept RED on purpose and -documented in `test/ecosystem/known_bugs/`: (A) `MCEmitter: label … placed -twice` compiling yyjson at `-O0`; (B) `kit cc src.c archive.a` at `-O1` drops a -local `.Lkit_ro.N` symbol (hits SQLite + yyjson at `-O1`). - -- [x] Add a small, curated `examples/` or `test/ecosystem/` suite that proves kit - can compile real C projects with ordinary headers, build scripts, archives, - and hosted system libraries. -- [x] Keep the suite deterministic: pinned upstream versions, checked hashes, - patch files kept minimal and documented, no network access in default test - runs. -- [x] Add a source-built library gate for SQLite: compile the amalgamation, - archive `libsqlite3.a`, build the shell or a focused smoke app, and validate - basic SQL execution. (Green at `-O0`; `-O1` link kept red — known bug B.) -- [x] Add a source-built compression/library gate such as zlib or libpng to cover - common configure-style C and archive linking. (miniz + LZ4, both `-O0`/`-O1`.) +The reproducible harness is landed (`test/ecosystem/` + `scripts/ecosystem.sh`, +`make provision-ecosystem` / `make test-ecosystem`): build each project with +`kit cc`/`kit ar`, run a driver, and diff against a checked-in golden AND against +clang, at both `-O0` and `-O1`. All seven projects — cJSON, Lua, LZ4, miniz, +tinyexpr, yyjson, and SQLite — are green at both levels and byte-identical to +clang. Remaining gates extend hosted system-library coverage: + - [ ] Add a hosted SDL2 gate: compile a minimal C app and link it through the platform mechanism (`pkg-config`/`sdl2-config`, framework, or import library), with an offscreen/headless smoke mode where possible. @@ -266,13 +231,8 @@ local `.Lkit_ro.N` symbol (hits SQLite + yyjson at `-O1`). gate is still pending.) - [ ] Add Windows hosted link gates for system/import libraries used by the release support set. -- [x] Run the ecosystem suite at `-O0` and `-O1`; add an LTO lane for at least - SQLite once LTO lands. (Both opt levels run; LTO lane still TODO.) -- [x] Decide which gates are required in default release CI and which are - opt-in because they require host packages or graphics/display access. - (Opt-in: needs provisioned sources + a host clang; not in DEFAULT_TEST_TARGETS.) -- [x] Document the examples as supported smoke builds, with exact commands users - can run after installing kit. (`test/ecosystem/README.md`.) +- [ ] Add an LTO ecosystem lane for at least SQLite (both opt levels already run; + LTO lane still TODO). ## Release validation diff --git a/doc/plan/TODO.md b/doc/plan/TODO.md @@ -161,6 +161,64 @@ Add new deferred fixes below as they are discovered. module-setup/opcode-switch split remains.) - `c_emit_data_symbol` (`src/arch/c_target/c_emit.c`, ~306 lines; doubled reloc-walk). +## Deferred subsystem work (folded from retired plan docs) + +Terse backlog rescued from plan docs that were deleted once their shipped design +moved up to the design set. Fuller context for each is in git history (the named +doc at `HEAD~`). + +### Arch-backend parity — x64/rv64 vs the aa64 reference (was `ARCH.md`) + +- **x64/rv64 tail-call realization.** `x64_no_tail` / `rv_no_tail` still bail on + `frame.ncallee_saves != 0`; aa64 gates on the outgoing-stack-arg size check + alone. The restore-before-jump machinery (`x64_emit_tail_site` / + `rv_emit_tail_site`) already exists — remove the callee-saves guard so it runs, + add callee-saves-live tail-call corpus cases, and validate Win64 callee-save + XMMs + forwarded sret and the rv64 s2–s11/fs2–fs11/s0/ra restore-then-`jr`. +- **Cost-model alignment.** As the tail paths land, verify the optimizer's + per-call cost estimates (`signature_stack_bytes`/`call_stack_bytes`) reflect + the cheaper known-frame shapes. Also: `cleanup_layout_fallthrough_branches` + doesn't yet thread `b A; A: b B` chains (arch-shared optimizer pass fix). +- **x64 debugger step-out / unwind.** `kit_dwarf_unwind_step` has no memory + provider and x64 has no link register, so step-out can't recover the return + address; also populate `.eh_frame` (or equivalent CFI) for JIT in-process + images. +- **Niche `as` / inline-`asm()` encode-decode gaps** (blocks no build): aa64 + `CASP`, LSE min/max (`ldsmax`/`ldsmin`/`ldumax`/`ldumin`), `LDAPR`/`STLLR` not + encoded; disasm rows missing for the new encode-only exclusive/LSE/reg-offset/ + writeback forms (render as `.inst`); TLS reloc modifiers `:tprel_*:` (aa64) / + `%tls_*` (rv64) not accepted in operands; `.L`-prefixed local labels in operand + references. + +### Wasm object backend + linker (was `WASM.md`) + +- **Object backend** (largest gap): `src/obj/wasm` lacks relocatable-object + + linker-metadata support at ELF/Mach-O/COFF parity. +- **Static linker**: no Wasm linker — `kit_link_exe` always builds a native + `Linker`; need multi-TU Wasm merge + relocation apply. +- **Feature gaps**: cross-TU references, atomics, wrapper ABI; frontend lowering + beyond the staged MVP; validator diagnostics for unsupported proposals. +- **wasm64 + WASI**: recognized-but-unsupported until the wasm32 object/link path + exists. **Cleanup**: move the shared Wasm core `lang/wasm/` → `src/wasm/`. + +### Windows x64 self-host + bootstrap (was `windows.md`) + +- **x64 self-host `kit.exe` crashes (`0xC0000005`)** on most subcommands + (`nm`/`size`/`cpp`/`as`); aarch64-windows self-host works (design in + `../WINDOWS.md`). Related: x64 `*sret` tail-call crash at `-O1`, + `118_decl_extra_attrs` ADRP-range link issue. +- Open: a committed **compile-on-VM test lane**, **default sysroot/distribution**, + the **Windows 3-stage bootstrap**, and the **SEH fault-guard** + (`driver_run_with_crash_guard` is a no-op on Windows — a crashing `kit run` + takes down `kit.exe`). + +### Bootstrap breadth (was `BOOTSTRAP.md`; fixed point + triage playbook now in `../BUILD.md`) + +- Widen the 3-stage byte-identical self-build beyond aarch64 (done on macOS / + Linux musl+glibc / FreeBSD at `-O0` and `-O1`) to **x86-64 (ELF + Mach-O)** and + **rv64 (ELF)**, and run it as **CI on the reference host**. Cross-bootstrap is a + stretch goal. + ## Cross-platform test failures (Populated by the serial cross-platform run — macOS native, Linux, FreeBSD, Windows.) diff --git a/doc/plan/WASM.md b/doc/plan/WASM.md @@ -1,205 +0,0 @@ -# WebAssembly (planned work) - -kit treats WebAssembly as both an input language and an output target, -sharing one binary/module layer between the two directions. The frontend path -(Wasm in, native object/JIT out) and the minimal final-module backend (C/toy -in, single-TU `.wasm` out) are working baselines. The remaining work is -concentrated in three areas: completing the `obj/wasm` object backend so it can -read and write tool-conventions *relocatable* objects (today it only mirrors -raw section bytes); building the Wasm static linker; and closing the last -frontend/backend feature gaps (relocations for cross-TU references, atomics -sub-word coverage, the C-facing exported wrapper ABI, and the unsupported-proposal -diagnostics). This doc is the forward-looking plan. Design rationale, the shared -module model, and the API sketch live in [../WASM.md](../WASM.md); related -design docs are [../OBJ.md](../OBJ.md) and [../LINK.md](../LINK.md), and the -sibling plan is [LINKER.md](LINKER.md). - -Baseline already in place (do not re-plan): `src/wasm` core decode/validate/ -encode/wat; `lang/wasm` frontend with native lowering through `KitCg` and an -explicit `KitWasmInstance*` ABI; `src/arch/wasm` with `arch_impl_wasm`, a -wasm32 BasicCABI vtable, a structured-CG + CFG-structurer backend, and a -single-TU `emit_wasm`; host-import binding via `kit_wasm_set_host_imports`. -`read_wasm`/`emit_wasm` are real (no longer stubbed) but partial. - -## Object backend (largest gap) - -`src/obj/wasm` is far smaller than the ELF/Mach-O/COFF backends. `read.c` -mirrors each Wasm section into an `ObjBuilder` section carrying raw payload -bytes and synthesizes one function symbol per defined function (enough for -`objdump -h/-s/-d/-t`). `emit.c` flushes a `WasmModule` attached under -`OBJ_EXT_WASM` via `wasm_encode`, or an empty magic+version header. Neither -understands tool-conventions object metadata: there is no symbol-table decode, -no relocation decode/encode, no `linking`/`reloc.*` custom-section handling, -and no `WasmObjMeta`. - -Work items: - -- Add the typed `WasmObjMeta` extension payload (module graph, symbol table, - relocations, data-segment metadata, target features, init funcs) and hang it - off the builder under `OBJ_EXT_WASM`. Today only a bare `WasmModule*` is - stored there; the linker and relocatable emitter need the richer struct. -- Extend `emit_wasm` to produce tool-conventions relocatable objects: a - required `linking` custom section, `reloc.CODE`/`reloc.DATA` custom sections, - symbol-info subsections, data-segment-info subsections, and target-feature - metadata. Relocatable objects must use padded-LEB immediate encodings so - relocations can rewrite immediates without re-disassembling the code section. -- Extend `read_wasm` from raw-byte mirroring to a real relocatable-object - reader: decode the symbol table into generic `ObjBuilder` symbols (so - archives and generic symbol inspection keep working), decode `reloc.*` into - generic relocations plus `WasmObjMeta`, and preserve unknown custom sections - by name and bytes for lossless roundtrip. -- Map the existing internal `RelocKind` values (`R_WASM_FUNCIDX`, - `R_WASM_TABLEIDX`, `R_WASM_MEMOFS`, `R_WASM_TYPEIDX`) onto the - tool-conventions wire relocation numbers, plus the data-symbol and - table-index variants the cross-TU work needs - (`R_WASM_MEMORY_ADDR_{LEB,SLEB,I32,I64}`, `R_WASM_TABLE_INDEX_{SLEB,I32}`). - Add new `RelocKind` values only where the wire format needs a distinction the - current names cannot express; unsupported kinds fail with a diagnostic naming - the kind. -- Add `make test-wasm-obj`: objects roundtrip through reader/writer/`objdump` - without losing sections, symbols, relocations, or unknown custom sections. - -## Static linker - -There is no Wasm linker yet. `kit_link_exe` always builds a native `Linker` -and emits a native `LinkImage`; a Wasm final module is not a virtual-addressed -native image and must not go through that segment layout. - -Work items: - -- Add `WasmLinkImage`, `wasm_link_resolve(Linker*)`, `wasm_link_emit`, and - `wasm_link_image_free`. Dispatch to this path from `kit_link_exe` when - `target.obj == KIT_OBJ_WASM`, after `build_linker` and before - `link_resolve`, so existing input handling, archive loading, entry selection, - and diagnostics stay shared. -- Merge index spaces across objects: renumber functions, globals, tables, - memories, data segments, and types; merge type/import/function/table/global/ - element/data/custom sections. -- Resolve undefined function/data/global/table symbols and apply Wasm - relocations against the merged module without disassembling the code section. -- Merge compatible target-feature sections; diagnose incompatible feature sets. -- Synthesize `__wasm_call_ctors`, stack/memory symbols, and exports per the - selected output mode; participate in archive demand loading. -- Add `make test-wasm-link`: multiple objects link into one valid module; - archives demand-load; imports, exports, ctors, and memory/table layout are - deterministic. - -This is the path that unblocks multi-TU Wasm output. The single-TU final -module the backend produces today needs no relocations; everything cross-TU -depends on the object backend and linker above. - -## Frontend validator coverage - -The shared validator (`src/wasm/validate.c`) and WAT/binary readers already -cover the accepted feature subset with typed operand/control stacks, section -ordering and index-space checks, branch arity, `br_table`, limits, segment -rules, and start-function signatures, plus the staged proposal gates (threads, -typed function refs, tail calls, multi-memory, memory64, bulk memory, -non-trapping float-to-int) behind `WasmFeatureSet`. - -Remaining work item: - -- Add explicit "outside the support plan" diagnostics for SIMD, exceptions/ - tags, GC, and the component model, so a module using them fails with a - feature-naming message rather than a generic stack/opcode error. These - proposals stay rejected, not lowered. - -## Frontend lowering gaps - -Native lowering covers the MVP numeric/control/memory subset plus the staged -proposals (threads, typed refs, tail calls, multi-memory, memory64, bulk -memory), all routed through the explicit `KitWasmInstance*` ABI with import -slots and runtime table storage. - -Remaining work items: - -- Define and implement the C-facing exported wrapper ABI: host-callable thunks - that keep the instance parameter explicit but use C-friendly scalar types and - symbol names. Today only the internal `export_name(KitWasmInstance*, ...)` - ABI exists. Embedders are a first-class use case; do not generate wrappers - that hide or globalize the instance. -- Once `read_wasm` handles relocatable objects, consider relaxing the frontend - rule that rejects modules with a `linking` custom section (currently directed - to be supplied as an object input instead). - -## Backend feature gaps - -The `wasm32-none` backend emits valid single-TU final modules: scalar + -indirect-aggregate BasicCABI, structured control flow with a reducible-CFG -structurer and `br_table` switches, linear memory + `__stack_pointer`, -compact data layout with intra-module `R_ABS32` relocations, varargs via a -caller-packed linear-memory buffer, the bit/overflow intrinsics, atomics via -wasm-threads opcodes, `memory.copy`/`memory.fill` lowering, inline asm with -WAT templates, conventional `"memory"` export, and `(import "env" ...)` -declarations with `import_module`/`import_name` attribute overrides. - -Remaining work items, mostly blocked on the object/linker layer or on wider -ABI support: - -- Cross-TU references. Address-of an undefined symbol and address-of a - cross-TU function currently diagnose ("address of undefined symbol not yet - implemented"). The fix is the object linking section plus relocations: - data symbols via `R_WASM_MEMORY_ADDR_*`, address-taken functions placed in - the indirect-call table via `R_WASM_TABLE_INDEX_*`, with the undefined symbol - carrying `WASM_SYM_UNDEFINED` (and `WASM_SYM_BINDING_WEAK` for weak undefs). - Resolved at link time. Shared machinery for any cross-TU function pointer. -- `R_ABS64` and non-`R_ABS32` data relocation kinds in the linear-memory image - (currently diagnosed explicitly). -- `&&label` addresses in static-data initializers (diagnosed early today). -- Wider scalar ABI: `__int128` (wasm32 ABI rejects 16-byte scalars) and - `long double`/binary128 (advertised but fatals on materialization). These are - phased-rollout SKIPs in the C corpus W path. -- 64-bit checked-overflow multiply (`__builtin_*mul_overflow` on i64), lowerable - as a software 64x64->128 product splitting each i64 into i32 halves and - checking the high word; i32 already widens through i64. -- Atomics: sub-word (8/16-bit) RMW and cmpxchg, and atomic NAND — blocked on - the 8/16-bit atomic RMW opcodes not yet defined in kit's Wasm core. Full - i32/i64 width is covered. Memory order is ignored (Wasm models seq_cst only). -- TLS and bitfields diagnose-and-fail; WASI startup, and irreducible control - flow / computed goto still produce raw rather than wasm-specific diagnostics - — tighten the messages later. - -## Wasm-to-Wasm - -A Toy/C -> Wasm -> run roundtrip exists via `make test-wasm-toy`/`test-wasm-c`, -but it routes the backend's output back through the lang/wasm frontend's native -JIT (Wasm-to-native), not a direct Wasm-to-Wasm path. - -Work items: - -- Wire the lang/wasm frontend to the Wasm target backend instead of native CG - for a direct Wasm-input -> Wasm-output normalization mode. Re-emit - semantically (not byte-preserving; object roundtrip tests cover preservation); - unsupported features still fail before emission. -- Add an explicit validate-only pass over Wasm-backend output (the frontend - validator currently only runs on Toy-produced `.wasm` during `kit run`). - -## wasm64 and WASI - -Both stay recognized-but-unsupported until the freestanding wasm32 object/link -path is solid: - -- `wasm64`/memory64 parses and the frontend lowers it, but the wasm64 *target* - ABI panics in `compute_func_info`. Keep 64-bit tool-convention relocations and - the wasm64 backend behind explicit capability checks until the object/linker - story is stable. -- `wasm32-wasi` parses but is unsupported as a target: it must diagnose clearly - until WASI imports, startup, argv/env, and libc policy are specified. Decision - stands: freestanding `wasm32-unknown-unknown`/`wasm32-none` first. - -## Cleanup - -- Move the shared Wasm core from `lang/wasm/` to `src/wasm/` per the layout in - [../WASM.md](../WASM.md). The core (decode/encode/validate/insn/module/wat) - already lives under `src/wasm/`; the obj-layer glue is under `src/obj/wasm/`. - Confirm no TU still reaches the core through `-Ilang/wasm` and retire that - include path. - -## Test targets - -Present and green: `test-wasm-front`, `test-wasm-target`, `test-wasm-toy`, -`test-wasm-c` (aggregated under `test-wasm`). Still to add as the object and -link layers land: `make test-wasm-obj` and `make test-wasm-link`. Prefer small -named fixtures over broad corpus runs; keep external validators (wasm-tools, -WABT) as optional comparison oracles, never a hard dependency — kit's own -validator is the semantic gate. See [TESTING.md](../TESTING.md). diff --git a/doc/plan/windows.md b/doc/plan/windows.md @@ -1,178 +0,0 @@ -# Windows Self-Host (current state and roadmap) - -This roadmap tracks bringing kit up as a self-hosted **Windows** toolchain: -cross-compiling the kit binary into a PE/COFF `kit.exe` and running `kit cc` -(AOT) and `kit run` (JIT) natively on Windows. The hosted target profile, -sysroot mechanics, and VM are described in [../WINDOWS.md](../WINDOWS.md); the -ABI work is in [windows-abi context]; this document tracks the self-host goal, -the baseline already in tree, and the genuinely-open follow-ups. - -Targets are PE/COFF, 64-bit only: `aarch64-windows` (the reference, and what -runs natively on the Apple-silicon ARM64 Win11 VM) and `x86_64-windows` (runs -via the in-box x64 emulator on the same VM). The hosted profile is mingw-w64 -UCRT via llvm-mingw (not MSVC): kit advertises `__MINGW32__/__MINGW64__`, never -`_MSC_VER`. - -## Baseline (done — context, not planned work) - -Cross-build + native cc + native JIT all work on aarch64-windows, verified on -the VM. - -- **Cross-build**: `scripts/windows_cross.sh aarch64` builds `kit.exe` - (PE32+ console, ~20 MB) with the host `build/kit` as the cross-compiler - against the llvm-mingw UCRT sysroot, overriding `HOST_OS=windows - HOST_ARCH=aarch64` so `mk/env.mk` selects `driver/env/windows.c`. (Windows - can't bootstrap natively — no seed C compiler in the VM — so the binary is - cross-produced on the dev host; contrast the native Linux/FreeBSD - bootstraps.) -- **`kit cc`**: compiles + links real C programs natively on the VM (`hello.c` - → a `hello.exe` that prints and returns the right exit code), compiling - `libkit_rt` on demand. -- **`kit run` (JIT)**: executes self-contained programs (compute, data/globals), - external calls (`rand`), and **libc I/O via dlsym** (`puts`). -- **Toy corpus on the VM** (native `kit.exe`, compare `.expected`): - - AOT (`kit cc` compile + link + execute): **166 / 166 pass**, 0 miscompile, - 0 crash, at `-O0`. - - JIT (`kit run`): **166 / 166** — the printf-family and thread-local gaps - (§1) are fixed; the two thread-local cases (`141`, `142`) now return their - values (43, 134). - -The items below are what is **not** yet done. The concrete open bugs/blockers -are collected first; the numbered sections are the larger roadmap items. - -## Open bugs / blockers - -Concrete defects surfaced during bring-up, each blocking a roadmap item below. - -- **x64 self-host `kit.exe` crashes (`0xC0000005`) on most subcommands — - OPEN.** The x64 `kit.exe` loads and dispatches (help, hash work), but - `nm`/`size`/`cpp`/`as` crash. Suspected root cause: the kit.exe binary - itself still imports `__intrinsic_setjmp` from - `api-ms-win-crt-private-l1-1-0.dll` (a CRT-private api-set that may not - load cleanly on Prism). Despite the two-archive rt placement fix (§2) that - resolves the issue for kit-compiled programs, something in kit.exe's own - link is still pulling the COFF WEAK_EXTERNAL `_setjmp → __intrinsic_setjmp` - alias from libucrt.a. Exact trigger still under investigation (the strong - `_setjmp` from libkit_rt.a does appear defined before libucrt.a scans, yet - the import stub is pulled anyway — possibly via `__imp___intrinsic_setjmp` - or a symbol not yet traced). - -## 2. x86_64-windows parity - -aarch64-windows is the reference. x64 codegen now shares the large-frame -stack-probe gate (§Baseline) via `abi_stack_probe_interval`, and the cross-link -now succeeds end to end. - -**What landed (this cycle):** - -- `rt/lib/coro/x86_64_win.c` now exports **strong** `_setjmp` and `_setjmpex` - globals (in addition to the existing weak `setjmp`). mingw x64's - `setjmp.h` expands `setjmp(BUF)` to `_setjmp(BUF, frame)` and declares - `_setjmp` as a COFF WEAK_EXTERNAL aliasing `__intrinsic_setjmp` from - `api-ms-win-crt-private-l1-1-0.dll` — a private CRT api-set that fails to - load on some runtimes (e.g. `0xC0000139` on Prism). Providing a strong - `_setjmp` in rt preempts the alias. - -- `driver/cmd/cc.c` places `libkit_rt.a` at **two** positions in the - Windows link order: - - **rt#1**: immediately before the hosted `after` group (before libmingw32.a, - libucrt.a, …). This ensures `_setjmp` is in `defined` before libucrt.a's - lazy-pull loop runs, so the WEAK_EXTERNAL member that introduces - `__intrinsic_setjmp` is never pulled. - - **rt#2**: before `crtend.o` only (the original position). libucrt.a pulls - libc members (printf, malloc, …) that contain large GCC-compiled stack - frames whose `___chkstk_ms` undefs appear only after libucrt.a's own scan - completes; the second rt entry catches those late undefs. Because archive - scanning is lazy, no symbol is ever defined twice. - -- `driver/env/windows.c`: fixed a forward-reference to `driver_join_path` - (used at its call site before the definition appeared in the same file). - -**VM verification status (Prism, Win11 25H2 ARM64):** -- x64 `kit.exe` loads and dispatches (`kit` with no args prints the multitool - help). ✓ -- x64 `kit.exe` `hash` works (compute + file I/O, exit 0, correct digest). ✓ -- A kit-compiled x64 **non-setjmp** hosted program (printf `hello`) runs and - returns its exit code. ✓ -- A kit-compiled x64 **setjmp** program runs end-to-end. ✓ (Fixed by the - strong `_setjmp` in rt + two-archive placement.) -- **x64 Toy AOT corpus** (`kit cc` compile + link + execute, compare - `.expected`): **164 / 165** at both `-O0` and `-O1`. The one failure - (`118_decl_extra_attrs`) is an aarch64-only ADRP-range link issue — - pre-existing, not an x64 regression. -- **The x64 self-host `kit.exe` itself** is not yet usable as a compiler on - the VM — `nm`/`size`/`cpp`/`as` crash (see *Open bugs*). Running the Toy - AOT corpus through a native x64 `kit.exe` is blocked on fixing the - `__intrinsic_setjmp` import. -- Known x64-windows codegen gaps already on file (from the toy VM lanes): `36/37` - `*sret` tail-call crash at **-O1**, and `118_decl_extra_attrs` ADRP-range link - is aarch64-only. Re-confirm against a native x64 `kit.exe` once the above are - triaged. - -## 3. A committed "compile-on-VM" test lane - -`test/toy/vm.sh windows` today cross-compiles the toy cases on the *host* and -only executes on the VM, so it cannot catch self-host compile bugs (the §Baseline -stack-probe crash, for one, was invisible to it). The native self-host path -(kit.exe compiling on the VM) has been exercised ad hoc. Generalize it into a -committed lane (e.g. `test/toy/vm.sh windows --native` or a new harness) that -ships the case sources + a `<bindir>/support/rt` + the mingw sysroot to the VM, -compiles with `kit.exe` there, runs, and compares `.expected`. Gotchas to bake -in: -- PowerShell `Start-Process -PassThru` *without* `-Wait` reports `.ExitCode = 0` - always — use a `[Diagnostics.Process]` with `WaitForExit(ms)` + `.ExitCode`, - or `& exe; $LASTEXITCODE` with a Defender path exclusion. Likewise `cmd` - `%ERRORLEVEL%` expands at parse time — use `cmd /v:on` + `!ERRORLEVEL!`. -- macOS `tar` adds AppleDouble `._*` sidecars — ship case sources with - `COPYFILE_DISABLE=1` (same trap as the FreeBSD bootstrap). -- The mingw sysroot's `<arch>-w64-mingw32/include` is a symlink to - `generic-w64-mingw32/include`; dereference (`cp -RL`) before shipping. - -## 4. Distribution + default sysroot - -- Ship the Windows distribution as `<bindir>/support/rt` (what `kit install` - produces); rt now resolves from the real image path, so no cwd dependence. -- The Windows hosted profile **requires** `--sysroot`/`KIT_SYSROOT` (no - default). Windows ships the runtime DLLs (`ucrtbase.dll`, the - `api-ms-win-crt-*` api-sets, `kernel32.dll`) but **no headers or import - libs**, so a sysroot is mandatory. Consider: bundling the mingw UCRT - headers + import libs into the kit distribution (a baked sysroot), and/or - teaching the COFF linker to synthesize imports directly from a system DLL's - export table (drops the import-lib half of the sysroot, but the `crt2.o` - startup + `libmingwex` helpers — `__mingw_setjmp`, - `__local_stdio_printf_options`, … — still have no DLL home and would have to - move into `libkit_rt` for Windows). - -## 5. Windows 3-stage bootstrap - -The self-host milestone: use the cross-built `kit.exe` on the VM to compile -kit's own sources into a stage-2 `kit.exe`, then stage-3, and assert stage-2 == -stage-3 byte-for-byte (cf. [BOOTSTRAP.md](BOOTSTRAP.md), and the -native-bootstrap analogs `scripts/{linux,freebsd}_bootstrap.sh`). **Unblocked** -now that the large-frame stack-probe crash *and* the `__FILE__` `malformed UCN` -bug are fixed — kit.exe compiles + links + runs the full Toy AOT corpus -(166/166) on the VM, and now compiles the rt tree on the VM cleanly (the -on-demand `kit cc` rt build succeeds). The `malformed UCN` bug turned out to be -a host-path stringization defect, **not** a self-compile codegen defect, so it -does not threaten stage-2==stage-3 identity. Next: confirm kit.exe can compile -the full libkit/driver source set on the VM, then add a -`scripts/windows_bootstrap.sh` to drive the VM-side stages and the -stage-2==stage-3 byte-identity check. - -## 6. Debugger fault-guard / SEH on Windows - -`driver/env/windows.c`'s `driver_run_with_crash_guard` is a no-op on Windows -(the POSIX path uses `sigaction` + `sigsetjmp`); a crashing `kit run` program -takes down kit.exe instead of reporting `on_crash`. The dbg `guarded_copy` also -relies on the VEH backstop rather than `__try` (kit's C frontend has no SEH). -A proper vectored-exception-handler port would give `kit run`/`kit dbg` -fault isolation on Windows. - -## Operational notes - -- Build: `scripts/windows_cross.sh [aarch64|x64]` (needs `make bin`, the mingw - sysroot via `scripts/llvm_mingw_sysroot.sh prepare <arch>`, and the rt - variant). kit cc emits no `-MMD` depfiles, so the script wipes objects for a - correct full rebuild each run. -- VM: `scripts/windows_vm.sh boot|wait-ssh|run|ssh|stop` (see - [../WINDOWS.md](../WINDOWS.md)). One ARM64 VM serves both arches.