kit

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

commit a66c55af01ab541d4e379ef57d1ab8becdcf26b3
parent ea7ea2bdedb2a918da1550a32e7362d9c21e3706
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Mon, 15 Jun 2026 18:35:47 -0700

driver+opt: surface O1 compile metrics via KIT_METRICS; document+profile the O1 path

Wire a process-wide KitProfiler into the AOT compile path (kit cc / kit
compile), gated by KIT_METRICS, unified with the existing heap-allocator
counters. The metrics_scope_*/metrics_count brackets already present throughout
opt_run_o1_native and the finalize sweep were previously only surfaced via
'kit run --time'; now KIT_METRICS=1 dumps every non-zero scope timer + counter
to stderr at exit for the path that produces shipped objects. Inert (single
NULL check) when KIT_METRICS is unset.

doc/OPT.md: enumerate the O1 optimization set (no-SSA framing), correct two
stale claims (whole-program mode is on for all arches at O1, so all funcs defer
to the finalize sweep; O2 is disabled because opt_cleanup has no caller, not via
o->level=1), document the KIT_METRICS surface, and add section 8 profiling the
O1 path: on sqlite, -O1 is ~70x slower than -O0 (11.2s vs 0.16s), 86% of which
is the whole-program inliner, which scales ~O(n^2.5-3) due to linear-scan
callee/SCC lookups in pass_inline.c. -O1 code is 10.6% denser than -O0 and
smaller than tcc; the density is bought by the cheap per-function passes, not
the inliner's 730 inlines.

Diffstat:
Mdoc/OPT.md | 216++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mdriver/env/common.c | 76+++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mdriver/env/env_internal.h | 11+++++++++++
Mdriver/env/posix.c | 5++++-
Mdriver/env/windows.c | 5++++-
5 files changed, 244 insertions(+), 69 deletions(-)

diff --git a/doc/OPT.md b/doc/OPT.md @@ -46,23 +46,30 @@ the resolved `NativeTarget*`, an optional dump writer, and a per-translation- unit registry of recorded `CgIrFunc`s (with a parallel lazily-lowered-`Func` cache) used for streaming tiny-callee inline lookup. -### When each function is processed: streaming vs. finalization - -Two scheduling regimes exist, chosen by target architecture: - -- **Per-function streaming (x64, rv64).** As the recorder completes each - function it fires the optimizer's per-function callback, which lowers and fully - processes that one function immediately. Functions flow through the pipeline in - recording order, one at a time, before the module is finalized. -- **Finalization-time, reachability-driven (ARM_64).** The per-function callback - registers the recorded `CgIrFunc` but does no lowering. All processing is - deferred to module finalization, where a reachability sweep over the - call/data-reloc graph computes the set of functions actually referenced from a - root, prunes the rest, and only then lowers and processes the survivors. +### When each function is processed: finalization-time, all arches + +At `opt_level >= 1` the optimizer runs in **whole-program mode** +(`o->whole_program = (level >= 1)` in `opt_cgtarget_new`). In that mode the +per-function callback only *registers* the recorded `CgIrFunc`; it does no +lowering (`opt_on_func` returns early). All processing is deferred to module +finalization (`opt_on_finalize` -> `opt_whole_module_finalize`), where a +reachability sweep over the call/data-reloc graph computes the set of functions +actually referenced from a root, prunes the rest, runs the cross-function +inliner over the survivors, and only then lowers and processes each one. This is +the single live regime today and it is identical on every architecture — the +ARM_64-style finalize path generalized to all targets. + +A second regime exists in the code but is dormant: if whole-program mode were +off, the per-function callback would lower and fully process each function +eagerly as it is recorded (per-function streaming), leaving dead-static +elimination to the linker. Because the recorder only exists at `level >= 1` and +`level >= 1` always sets `whole_program`, no shipped configuration reaches the +eager path; it is a fallback for a hypothetical non-whole-program build. Both regimes converge on the same lowering path and backend tail; they differ -only in *when* a function is lowered and whether dead local functions are dropped -before lowering or left for the linker (Section 3.1). +only in *when* a function is lowered, whether the cross-function inliner runs, +and whether dead local functions are dropped before lowering or left for the +linker (Section 3.1). ### The recording/optimizing boundary @@ -189,15 +196,17 @@ consumer choose how far down it the function travels. ### O1 native (`opt_run_o1_native`) -This is the live optimized path for compiled output. `opt_run_o1_native` -(`src/opt/opt.c`) is the per-function driver; how a function reaches it depends -on the scheduling regime of Section 1. On x64/rv64 the per-function callback -lowers the recorded function and calls `opt_run_o1_native` directly as each -function is recorded. On ARM_64 the callback only registers the function; -lowering and the call to `opt_run_o1_native` happen at finalization, once the -reachability sweep has selected the function. Either way the function travels the -same pipeline, entirely in the PReg namespace (`opt_reg_ssa == 0`) — no SSA -construction, no value numbering. In source order: +This is the live optimized path for compiled output, and (with O2 disabled, +below) the *only* optimization pipeline any `-O1`/`-O2` compile runs. The +per-function driver is the `opt_o1_native_prepare` + `opt_o1_native_finish` pair +(`opt_run_o1_native` is the streaming wrapper around both; the finalize sweep +calls the halves directly so the cross-function inliner can run between them). +Every function is reached the same way today — through the finalize sweep of +Section 1 — and travels the same pipeline entirely in the PReg namespace +(`opt_reg_ssa == 0`): **no SSA construction, no value numbering, no +dominance-frontier phi insertion.** That is the deliberate `-O1` design point — +fast, reasonable code from local + linear-scan machinery, paying none of the SSA +build/destroy cost. In source order: ```text build_cfg -> jump_cleanup(CFG) -> build_cfg -> simplify_local @@ -228,25 +237,57 @@ all handled here. Most stages are bracketed by an `opt_verify` / `opt_mir_verify` checkpoint with a stage tag, and `KIT_DUMP=<tag>` dumps the IR at the matching stage (`entry` before any pass, `pre-emit` just before emit). -The reachability decision lives *outside* this pipeline and is per-architecture -(Section 1). At module finalization (`opt_on_finalize`) file-scope asm blocks -captured during recording are replayed on every target. On ARM_64, finalization -additionally runs the reachability sweep that selects which functions are lowered -at all, so dead local functions/data are never lowered or emitted; the survivors -then each run the full pipeline above. On x64/rv64 every recorded function was -already lowered and emitted during streaming, so dead-static elimination is left -to the linker rather than performed here. +**The O1 optimization set.** Stripping out the analyses (`build_cfg`, +`build_loop_tree`, `live_blocks`), the verifiers, and pure lowering +(`machinize_native`, `lower_to_mir`, `emit_native`), the transforms that actually +improve the code are exactly these, none of which needs SSA: + +- **`simplify_local`** — local algebraic/addressing canonicalization (the + no-SSA-required cleanup, also used by the interpreter tap). +- **Cross-function inlining** — `try_tiny_inline` per function in this pipeline, + plus the whole-program `opt_inline` that runs once at finalize *before* this + pipeline (Section 1). This is the only interprocedural transform. +- **`addr_xform_pregs`** — fold `ADDR_OF(local)` into direct `OPK_LOCAL` + load/store operands and clear `FSF_ADDR_TAKEN` when every such def retires. +- **`promote_scalar_locals`** — lift a non-escaped scalar frame slot into a + mutable PReg, turning its stores/loads into register copies. +- **`addr_of_global_cse`** — hoist one `ADDR_OF(global)` compute to the entry + block and reuse it. +- **`lower_loop_imm_operands` + `hoist_loop_consts`** — materialize + 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 or move coalescing (the O2-only quality knobs stay off). +- **`mir_combine`** — post-RA peephole + addressing-mode synthesis (the same + `opt_combine` used in O2's SSA combine, here gated on physical-register + liveness). +- **`mir_dce`** — post-RA dead-code elimination. +- **`jump_cleanup` / `mir_jump_cleanup`** — unreachable-block drop, jump-chain + collapse, and (LAYOUT mode) block reordering for fallthrough + loop rotation. + +Everything else under §4 (`build_ssa`, `gvn`, `dse`, `licm`, `copy_prop`, +`simplify`, live-range splitting, coalescing) is O2-only and never runs. + +The reachability decision lives *outside* this pipeline, in the finalize sweep +(Section 1), and is now identical on every architecture. At module finalization +(`opt_on_finalize`) file-scope asm blocks captured during recording are replayed +on every target, then the reachability sweep selects which functions are lowered +at all — so dead local functions/data are never lowered or emitted — and the +survivors each run the full pipeline above. ### O2 mid-end (`opt_cleanup` + shared lowering) The O2 mid-end is the SSA-based optimization schedule defined in `opt_cleanup` (`src/opt/pass_o2.c`). It is the intended mid-end architecture and is fully -implemented, but it is not on the shipped code path: `opt_cgtarget_new` normalizes -every requested `opt_level` to `1` (the line `o->level = 1` in `src/opt/opt.c`), -so no compilation ever selects O2 and every `opt_level >= 1` request runs the O1 -native path. - -The rationale for this normalization is isolation. Keeping the O2 schedule +implemented, but it is not on the shipped code path: **`opt_cleanup` has no +caller.** The finalize sweep (`opt_whole_module_finalize`) always runs the O1 +native pipeline (`opt_o1_native_prepare`/`_finish`) regardless of the requested +`opt_level`, so no compilation ever selects the SSA schedule and every +`opt_level >= 1` request runs the O1 native path. (`-O2` therefore produces the +same output as `-O1` today; the level is recorded on `OptImpl` but only gates +whole-program mode, which `-O1` already enables.) + +The rationale for keeping it parked is isolation. Keeping the O2 schedule defined and its passes maintained means the SSA representation and its incremental def-use can stabilize against targeted optimizer tests independently, without an SSA-construction or value-numbering bug affecting shipped output. The @@ -502,5 +543,100 @@ relevant analysis. Observability hooks: `KIT_DUMP=<tag>` dumps the optimizer IR at a named stage, `KIT_DUMPCG=1` dumps the recorded semantic tape before lowering, `KIT_DUMP_INTERP` dumps the interpreter-tap `Func`, and the optimizer emits -scoped timing/count metrics (visible through `kit run --time`) for the -frontend, each pass scope, allocation, and emit. +scoped timing/count metrics for the frontend, each pass scope, allocation, and +emit. Those metrics surface two ways: through `kit run --time` (the JIT path), +and — for the AOT path that produces shipped objects (`kit cc` / `kit compile`) +— by setting `KIT_METRICS=1` in the environment, which wires a process-wide +`KitProfiler` into the compile and dumps every non-zero scope timer and counter +to stderr at exit. Section 8 walks the output. + +## 8. Profiling the O1 path + +`KIT_METRICS=1` is the way to see where an `-O1` compile spends its time. Each +`metrics_scope_*` bracket in `opt_run_o1_native` and the finalize sweep becomes +one `<scope> <ticks> ticks (<calls> calls)` line; the counters (`opt.funcs`, +`opt.blocks`, `opt.pregs`, the inliner refusal histogram, heap allocs, …) follow. +Ticks are the raw host cycle counter (`cntvct_el0` on arm64 ≈ 24 MHz, so divide +by `hw.tbfrequency` for wall-seconds); within one run the scopes are directly +comparable. The named scopes nest — `opt.inline.total` and `opt.o1.total` are the +two roots of the finalize sweep, and `compile.tu` covers only the frontend +recording, *not* the finalize sweep that follows it. + +``` +$ KIT_METRICS=1 kit cc -O1 -c sqlite3.c --sysroot "$SDK" -o sqlite3.o +kit metrics: + compile.tu 4.7M ticks (frontend: lex+pp+parse+CG recording) + opt.inline.total 230.6M ticks (whole-program inliner, 1 call) + opt.o1.total 25.7M ticks (per-function pipeline, summed over 2580 funcs) + opt.regalloc 11.5M ticks (biggest per-function pass) + ... + opt.funcs=2580 opt.blocks=122208 opt.tiny_inline.inlined=13 opt.inline.inlined=730 +``` + +### Measured cost and scaling (sqlite amalgamation, arm64/Darwin) + +Compiling the ~263 K-line sqlite amalgamation (`-c`, release kit) the wall-clock +split is stark — `-O1` is **~70× slower than `-O0`** (11.2 s vs 0.16 s; tcc does +the whole file in 0.06 s): + +| phase (24 MHz ticks → s) | seconds | share | +|-------------------------------------|----------:|------:| +| frontend (`compile.tu`) | 0.20 | 2% | +| CG→opt lowering (`cg_ir_lower`) | 0.23 | 2% | +| **whole-program inliner** (`opt.inline.total`) | **9.61** | **86%** | +| per-function pipeline (`opt.o1.total`) | 1.07 | 10% | +| — of which regalloc + live-ranges | 0.64 | | + +The whole-program inliner dominates, and it is **superlinear**. A synthetic +sweep (N small functions in a dense call graph, best-of-3) shows the `-O1` time +exploding while `-O0` stays flat: + +| funcs | `-O0` | `-O1` | O1/O0 | +|------:|------:|------:|------:| +| 200 | 0.027 | 0.060 | 2.3× | +| 400 | 0.027 | 0.240 | 8.8× | +| 800 | 0.030 | 1.524 | 50.5× | +| 1600 | 0.035 | 11.48 | 332× | + +Doubling the function count multiplies `-O1` time by ~6–8× — an exponent near +2.5–3. The root cause is in `pass_inline.c`: the inliner runs up to four +fixpoint iterations over the whole `FuncSet`, and its hot gates resolve callees +and check for recursion/SCC membership with **linear scans** — `funcset_find` +and `funcset_index` walk all functions on every call, and `recursive_or_scc` → +`func_reaches` is a per-candidate DFS whose every hop pays that O(n) lookup. With +2580 functions and ~24 K candidate call sites this is the textbook +"superlinear axis = bug" trap (`doc/plan/PERF.md`). The payoff is also small: +730 of 24 K candidates inline. + +By contrast the per-function pipeline is well-behaved (~1 s total, linear in +function count); within it the linear-scan allocator + live-range construction +are the largest line items, as expected for a no-SSA `-O1`. + +**Implication for the "fast O1" goal.** The non-SSA per-function pipeline already +meets the bar — strip the inliner and `-O1` would be ~1.5 s on sqlite (linear, +~10× `-O0`). The whole-program inliner is what breaks it. The fixes are +self-contained and do not touch correctness: (1) index the `FuncSet` by symbol +(hash map) so `funcset_find`/`funcset_index` are O(1); (2) memoize the +reachability/SCC check instead of re-running `func_reaches` per candidate; and/or +(3) reconsider whether whole-program inlining belongs at `-O1` at all versus +behind `-O2`/`-flto`, leaving `-O1` with only the cheap streaming `try_tiny_inline` +(0.28 s on sqlite). Any of these restores the linear, "fast" `-O1` design point. + +### Code quality + +`-O1` produces *denser* code than `-O0` despite doing no SSA optimization, and is +already smaller than tcc — the "reasonable code" half of the goal holds: + +| metric (sqlite3.o) | `-O0` | `-O1` | Δ vs O0 | +|---------------------------|--------:|--------:|--------:| +| `__TEXT` (code) | 1.41 MB | 1.26 MB | −10.6% | +| total object | 1.75 MB | 1.62 MB | −7.3% | +| (tcc total object, ref) | — | 2.11 MB | — | + +Most of the density comes from the cheap per-function transforms +(`promote_scalar_locals`, the address folds, `dead_def_elim`, `mir_combine`, +linear-scan allocation), not from the inliner's 730 inlines — i.e. the bulk of +the quality is bought by the ~1 s of work, not the ~9.6 s. The `-O1` object links +and runs correctly (the ecosystem gate compiles + runs sqlite at `-O0` and `-O1` +against clang; a spot link-and-query of the `-O1` object returns the right +result). diff --git a/driver/env/common.c b/driver/env/common.c @@ -47,47 +47,69 @@ static void driver_heap_metrics_define(KitProfiler* pr) { "heap.frees"); } -/* Process-wide heap metrics, opt-in via KIT_METRICS. The heap is global, so its - * stats are too; this stays NULL/inert unless asked. When an external profiler - * is already attached (e.g. a future `kit run --metrics` pointing KitHeap.user - * at its per-run profiler), we leave it alone and merge into that instead. */ -static KitProfiler* g_heap_metrics_prof; -static int g_heap_metrics_inited; - -static void driver_heap_metrics_dump(void) { - KitProfiler* pr = g_heap_metrics_prof; +/* Process-wide compile metrics, opt-in via KIT_METRICS. One KitProfiler backs + * both the heap-allocator counters (wired through KitHeap.user, below) and the + * libkit scope timers/counters (wired through DriverEnv.profiler -> + * KitContext.profiler, so the metrics_scope and metrics_count calls the + * optimizer, linker, and JIT already emit accumulate here). The heap is global, + * so its + * stats are too; this stays NULL/inert unless KIT_METRICS asks for it. */ +static KitProfiler* g_metrics_prof; +static int g_metrics_inited; + +static void driver_metrics_dump(void) { + KitProfiler* pr = g_metrics_prof; uint32_t id; if (!pr) return; - fprintf(stderr, "kit heap metrics:\n"); - for (id = KIT_PROFILE_COUNTER_EXTERNAL_FIRST; id <= (uint32_t)HEAP_C_FREES; - ++id) { - const char* name = kit_profiler_counter_name(pr, (KitProfileCounter)id); - fprintf(stderr, " %s=%llu\n", name ? name : "heap.?", - (unsigned long long)kit_profiler_counter_value( - pr, (KitProfileCounter)id)); + fprintf(stderr, "kit metrics:\n"); + /* Scope timers first (raw host cycle-counter ticks + call count), in id order + * so the compile pipeline reads top-to-bottom. Names resolve through the + * kit-core scope table; unnamed scopes are skipped silently. */ + for (id = 1; id < KIT_PROFILE_ID_COUNT; ++id) { + uint64_t count = kit_profiler_scope_count(pr, (KitProfileScope)id); + uint64_t ticks = kit_profiler_scope_ticks(pr, (KitProfileScope)id); + const char* name; + if (!count) continue; + name = kit_profiler_scope_name(pr, (KitProfileScope)id); + if (!name) continue; + fprintf(stderr, " %s %llu ticks (%llu calls)\n", name, + (unsigned long long)ticks, (unsigned long long)count); + } + /* Then every non-zero counter (heap.* in the external range, opt.* and link.* + * in the kit range). */ + for (id = 1; id < KIT_PROFILE_ID_COUNT; ++id) { + uint64_t value = kit_profiler_counter_value(pr, (KitProfileCounter)id); + const char* name; + if (!value) continue; + name = kit_profiler_counter_name(pr, (KitProfileCounter)id); + if (!name) continue; + fprintf(stderr, " %s=%llu\n", name, (unsigned long long)value); } } -static void driver_heap_metrics_maybe_begin(KitHeap* h) { +KitProfiler* driver_metrics_profiler(void) { const char* e; - if (g_heap_metrics_inited) return; - g_heap_metrics_inited = 1; - if (!h || h->user) return; /* already wired to an external profiler */ + if (g_metrics_inited) return g_metrics_prof; + g_metrics_inited = 1; e = getenv("KIT_METRICS"); - if (!(e && e[0] && e[0] != '0')) return; + if (!(e && e[0] && e[0] != '0')) return NULL; /* Raw libc alloc (not through the vtable) so the profiler storage itself is * not counted and there is no reentrancy. */ - g_heap_metrics_prof = (KitProfiler*)calloc(1, sizeof(*g_heap_metrics_prof)); - if (!g_heap_metrics_prof) return; - driver_heap_metrics_define(g_heap_metrics_prof); - h->user = g_heap_metrics_prof; - atexit(driver_heap_metrics_dump); + g_metrics_prof = (KitProfiler*)calloc(1, sizeof(*g_metrics_prof)); + if (!g_metrics_prof) return NULL; + driver_heap_metrics_define(g_metrics_prof); + /* Wire the heap counter sink unless something already claimed it. */ + if (!g_heap_libc.user) g_heap_libc.user = g_metrics_prof; + atexit(driver_metrics_dump); + return g_metrics_prof; } static void* heap_libc_alloc(KitHeap* h, size_t size, size_t align) { KitProfiler* pr; (void)align; /* malloc satisfies all max_align_t alignments */ - if (!g_heap_metrics_inited) driver_heap_metrics_maybe_begin(h); + /* Lazily arm metrics for any allocation that precedes driver_env_init (the + * usual arming site); idempotent and a single branch once inited. */ + if (!g_metrics_inited) (void)driver_metrics_profiler(); pr = h ? (KitProfiler*)h->user : NULL; if (pr && size) { kit_profiler_count(pr, (KitProfileCounter)HEAP_C_ALLOCS, 1); diff --git a/driver/env/env_internal.h b/driver/env/env_internal.h @@ -33,6 +33,17 @@ extern KitHeap g_heap_libc; extern KitDiagSink g_diag_stderr; +/* ---- compile metrics (common.c) ---------------------------------------- + * Process-wide KitProfiler storage, opt-in via the KIT_METRICS env var. The + * first call (from driver_env_init, before any compile runs) lazily allocates + * the profiler, wires the heap vtable's counter sink to it, and registers an + * atexit dump of every non-zero scope timer / counter. Returns NULL when + * KIT_METRICS is unset, so each host's driver_env_init can assign the result + * straight to DriverEnv.profiler (and thus KitContext.profiler) unconditionally + * -- libkit's metrics_scope and metrics_count hot path is a single NULL check + * when profiling is off. Idempotent: repeated calls return the same pointer. */ +KitProfiler* driver_metrics_profiler(void); + /* ---- icache (icache_<arch>.c) ------------------------------------------ * Arch-only, OS-neutral. The POSIX dbg path delegates here from * os_dbg_flush_icache; Windows uses FlushInstructionCache directly and diff --git a/driver/env/posix.c b/driver/env/posix.c @@ -1456,7 +1456,10 @@ void driver_env_init(DriverEnv* e) { e->execmem = &g_execmem_posix; e->dbg_os = &g_dbg_os_posix; - e->profiler = NULL; + /* Opt-in compile metrics (KIT_METRICS): one process-wide profiler backs both + * the heap counters and libkit's scope timers. NULL when unset -- the metrics + * hot path is then a single pointer check. */ + e->profiler = driver_metrics_profiler(); { const char* xdg = getenv("XDG_CACHE_HOME"); diff --git a/driver/env/windows.c b/driver/env/windows.c @@ -2077,7 +2077,10 @@ void driver_env_init(DriverEnv* e) { e->execmem = &g_execmem_win; e->dbg_os = &g_dbg_os_win; - e->profiler = NULL; + /* Opt-in compile metrics (KIT_METRICS): one process-wide profiler backs both + * the heap counters and libkit's scope timers. NULL when unset -- the metrics + * hot path is then a single pointer check. */ + e->profiler = driver_metrics_profiler(); { /* XDG_CACHE_HOME wins if set (cross-platform tooling convention),