kit

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

commit 8e89fac12c3e6ef815053b98ab80f1d5420baabe
parent 70031256ffce48755c4e8aa0b17a3d4c110fa107
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Sat, 13 Jun 2026 21:28:07 -0700

doc(plan): FRONTEND-SHAPE.md — sequenced campaign to tcc frontend shape (§4.3)

Audit finding: §4.3 is mostly already landed. Pillar 2 (symbol cache) is done
(BindingTab/KIT_SYMTAB makes scope_lookup O(1)); Pillar 1 (shared token slot) is
~70% (one Tok, out-pointer relay, no-## pointer-replay); Pillar 3 (thin SValue)
is forked (two parallel value stacks kept in lockstep). Plan sequences the real
remaining work into 5 independently-landable, byte-identical-gated tiers.

Diffstat:
Adoc/plan/FRONTEND-SHAPE.md | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 297 insertions(+), 0 deletions(-)

diff --git a/doc/plan/FRONTEND-SHAPE.md b/doc/plan/FRONTEND-SHAPE.md @@ -0,0 +1,297 @@ +# Frontend shape: closing the gap to tcc's frontend (PERF.md §4.3) + +**Goal.** Reach tcc's *frontend shape* — the structural end-state PERF.md §4.3 +names: a shared mutable token slot fed by both scanner and macro replayer, +identifiers resolved through symbol pointers cached on the interned token, and a +thin `SValue[]` the parser drives by calling emit directly. The `CgTarget` → +`NativeTarget` vtable polymorphism **stays** (the two indirect calls per +primitive are the price of the seven-backend modularity, per §4.3); this plan is +about the *front half and the value-stack seam*, not the backend vtable collapse. + +This doc is the sequenced campaign. It is grounded in a code audit (2026-06-13) +whose headline finding reframes the work: **most of §4.3 is already landed.** The +"large refactor" is, in reality, one finished pillar, one ~70%-done pillar, and +one genuine-but-bounded structural change. Each tier below is independently +landable and individually gated. + +--- + +## 0. Verified current state (what §4.3 already bought) + +### Pillar 2 — "symbol pointers cached on the interned token" — **DONE** + +PERF.md §4.1 #1 (the "highest-value front-half brick," describing `scope_lookup` +as "an N-scope chain walk per identifier") is **stale**. Resolution is already +one array load: + +```c +// lang/c/parse/parse.c:388 +SymEntry* scope_lookup(Parser* p, Sym name) { + return name ? BindingTab_get(&p->bind, name) : NULL; // O(1) +} +``` + +`BindingTab` (`KIT_SYMTAB_DEFINE`, a Sym-indexed dense array; +`parse_priv.h:360`) is the kit-native form of tcc's `TokenSym.sym_identifier`. +`SymEntry.shadowed` (`parse.c:363`) is the push/restore stack; `scope_pop` +(`parse.c:308-321`) unwinds it. `kit/support/symtab.h`'s own header comment +explains why kit's *per-consumer* table is **better** than tcc's field-on-the- +token: the pp keeps `Sym→Macro*`, the parser keeps `Sym→SymEntry*`, with no +shared per-symbol struct and no global state — the module boundary stays intact. +**Nothing structural remains here.** (Action: correct §4.1 #1 / §4.3 — see Tier 0.) + +### Pillar 1 — "shared mutable token slot fed by scanner + replayer" — **~70%** + +- One 28-byte `Tok` (`lang/cpp/lex/lex.h:67`), **no per-stage variants**. +- `src_next_raw_into(Pp*, Tok* out, …)` already writes `lex_next`'s sret straight + into the caller's slot (`pp.c:72`, see the out-pointer rationale comment at + `pp.c:61-71`). +- No-`##` object macros already replay **by pointer** — `push_buf_replay` + (`pp.c:193`) const-casts the immutable body; the two read sites load by value + and never write back (`pp.c:187-192`). + +Three copy-sources remain (the Tier 1 / Tier 2 work): +1. **Newlines** (~51 % of lexer outputs) are materialized as `TOK_NEWLINE` then + drained (`pp_expand.c:1040`). → Tier 1. +2. **Function-macro args** still cost 3–4 copies/token (raw-collect → + pre-expand → phase-1 subst → phase-2 paste → read). → Tier 1. +3. The **parser pulls tokens by value** into `p->cur`/`p->next`/`p->pending` + (`fetch_tok`/`advance`/`peek1`, `parse.c:165-210`). → Tier 2. + +### Pillar 3 — "thin SValue the parser drives by calling emit" — **partial / forked** + +`ApiSValue` is already 40 B (`src/cg/internal.h:99`, enforced < 64). The parser +drives codegen inline (no AST). **But there are two parallel value stacks**, kept +in lockstep: + +- Frontend: `PcgSlot cg_slot_stack[]` (`cg_adapter.h:73`, `parse_priv.h:250`) — + `{const Type*, PcgLvAux, KitCgTypeId cg_id, u8 flags}`. +- CG API: `ApiSValue stack[]` inside `KitCg` (`internal.h:189`). + +tcc has *one* `SValue[]`. Unifying them is the Tier 3 work, using the user's +design: **the CG API exposes an opaque per-entry aux slot the frontend tags**, so +the language-neutral seam is preserved while the parallel stack disappears. + +> Note: `doc/FRONTENDS.md` is also stale here — it still describes three parallel +> arrays `cg_type_stack` / `cg_value_flags` / `cg_lv_aux`; those were merged into +> the single `PcgSlot` array. Fix in Tier 0. + +--- + +## Tier 0 — re-baseline (prerequisite, ~½ day) + +Cheap, do first; everything else re-ranks off it. + +1. **Re-profile** per PERF.md §3 (`scripts/perf_callgrind.sh run shape-base`) and + `make perf-golden` for the byte-identity gate snapshot. Record the fresh + self-`Ir` table; the §2 numbers predate recent landings. +2. **Correct the stale docs** (no code): PERF.md §4.1 #1 (mark symbol-binding + cache *landed*, point at `BindingTab`), §4.3 (note Pillar 2 done, Pillar 1 + ~70 %), and `doc/FRONTENDS.md` §"Parser" (the `PcgSlot` single stack, not the + three-array description). + +Gate: docs only + golden snapshot exists. No behavior change. + +--- + +## Tier 1 — finish the relay (seam-preserving copy elimination) + +Two levers, **byte-identical-gated** (`make perf-golden` → edit → `make perf-gate`, +plus the line-splice battery for any lexer touch, §5). Order 1B before 1A (1B is +lower-risk and de-risks the gate harness; 1A is the riskiest lever in the plan). + +### 1B — cut function-macro argument copies + +**Obstacle.** A function-macro invocation copies each argument token 3–4×: +raw-collect (`read_invocation_args`, `pp_expand.c:566/586` `tv_push`), per-arg +`memcpy` into a pre-expansion slice (`preexpand_args`, `pp_expand.c:648`), +pre-expand output (`expand_arg_to_eof`, `pp_expand.c:475`), then phase-1 subst +(`pp_expand.c:790…`) and phase-2 paste (`pp_expand.c:866`). + +**Change (incremental, each independently gated):** +- Extend the no-`##` pointer-replay idea (already used for object bodies) to the + *substituted body*: when `m->has_paste == 0` **and** no argument needs both raw + and pre-expanded forms, build the result by **pushing argument slices by + reference** rather than copying each token into one flat buffer. The + `has_paste` flag is already set at definition time (`pp_expand.c:264`), so the + branch is free. +- Where a copy is unavoidable (paste/stringize present), collapse the + phase-1→phase-2 hand-off so a token is written once, not staged then re-walked + (today phase 2 re-copies every non-`##` token, `pp_expand.c:870`). +- The pre-expansion `memcpy` (`pp_expand.c:648`) exists to give + `expand_arg_to_eof` a stable buffer; check whether it can read the raw range + in place (the raw buffer outlives the pre-expand for that arg). + +**Gate:** byte-identical on the full pp corpus + sqlite `-E`/`-c`. Macro-heavy +inputs (`#define` batteries, nested/variadic, `##`/`#`) must be in the diff set. +**Payoff:** bounded by macro density; real on sqlite (declaration/macro-heavy). +`pp_pull_into` 4.4 % + the arg machinery feed it. **Risk:** medium — argument +lifetime + hideset (`s->hs`/`hs_uniform`) must ride along with by-reference +slices; the Prosser rescan correctness is the thing to protect. + +### 1A — stop materializing non-directive newlines on the cc path + +**Obstacle.** The lexer emits a `TOK_NEWLINE` per physical line +(`lex.c:704-717`) so the pp can (a) delimit directive lines (`collect_line`, +`pp_directive.c:36-48`; the `do…while(kind!=TOK_NEWLINE)` scans, +`pp_directive.c:533`) and (b) recognize `#`-at-BOL. But **`TF_AT_BOL` already +carries the same signal** on the next real token (`pp.c:124-125`, +`pp_directive.c:553`). The parser never sees newlines (drained, `pp_expand.c:1040`). +So the newline token is redundant for cc *except* as a line-end sentinel inside +directive parsing, and for `-E` (which reconstructs line structure, +`pp_emit_text`, `pp.c:289-316`). + +**Change.** Make `TOK_NEWLINE` an `-E`-only product: +- Add a lexer mode flag (cc vs `-E`). In cc mode, **do not emit `TOK_NEWLINE`**; + instead the first token of each line is tagged `TF_AT_BOL` (already happens) and + the lexer threads "saw a line boundary" into that flag. +- Rewrite directive line-termination to **"read until the next `TF_AT_BOL` + token (or `TOK_EOF`)"** instead of "until `TOK_NEWLINE`." The `#if`/`#define`/ + `#include`/`#line`/`#pragma`/`#error`/`#embed` collectors in `pp_directive.c` + all funnel through `collect_line`; convert that one helper and audit the few + direct `TOK_NEWLINE` checks (`pp_directive.c:533/1441`, `pp.c:243`, + `pp_expand.c:424/470/524` — macro-arg newline-as-whitespace, which simply + disappears in cc mode). +- Keep `-E` exactly as today (emit newlines): the path already splits on + `skip_nl` / `pp_next_raw_into`, so this is a mode flag, not a fork of the + source. + +**Gate:** byte-identical on `-c` **and** `-E` (the `-E` path must be untouched); +full pp/parse corpora; the line-splice battery + a diagnostic whose line number +falls across a splice (line tracking must survive losing the newline token — +verify `loc.line` still advances via `lex_catchup_splices`/`l->line`, not via the +consumer counting newlines). **Payoff:** the largest Tier-1 instruction win — +removes ~51 % of `lex_next` outputs (16.9 % self-`Ir`) and the cc drain loop. +**Risk:** **high** — the directive-line contract is subtle (continuation, +empty directives, `#` alone on a line, `_Pragma`). This is the one lever to land +last and gate hardest. + +--- + +## Tier 2 — single shared mutable token slot at the parser boundary + +**End-state.** The pp writes each preprocessed token **directly into the +parser's current-token slot** (out-pointer all the way through), eliminating the +by-value `pp_next` return and minimizing the lookahead shuffle — the literal +"shared mutable slot fed by scanner and replayer," extended to the consumer. + +**Obstacles (what the slot must keep doing).** +- **`pp_next` returns `Tok` by value** (`pp.h:31`); `fetch_tok` stores it into + `p->cur`/`p->next`/`p->pending` (`parse.c:165-210`). The by-value return is the + copy to kill — `pp_next_into(pp, Tok* out)` already exists (`pp_expand.c:1199`). +- **LL(2) lookahead:** `peek1` needs one token ahead (`p->next`/`has_next`); a few + sites need two. The slot model keeps a tiny ring (`cur` + `next`), not a single + global like tcc — kit's grammar genuinely needs the second token. +- **String-literal fusion:** `fetch_tok` collapses adjacent `TOK_STR` runs + (`parse.c:164-183`), using `p->pending` as a one-token pushback. Must survive. +- **Replay sources:** `advance` already sources `p->cur` from *either* the replay + buffer *or* pp (`parse.c:186-198`). Replay backs initializer re-parse + (`parse_init.c:71-151`) and a `_Generic`/builtin save-restore + (`parse_expr.c:2785-2805`). The slot model must treat replay as just another + source feeding the slot (it already is). + +**Change.** +1. Convert `fetch_tok`'s pp pull to the out-pointer form: pull straight into the + destination slot (`pp_next_into(p->pp, &slot)`), dropping the by-value return + and its 28-byte sret copy. Keep string fusion by fusing in place into the slot. +2. Make the lookahead shuffle move *slots*, not re-pull: `advance` promotes + `next → cur` by struct move (unavoidable for LL(2)); ensure no redundant copy + beyond that one. +3. Leave replay as a source that fills the same slot. No new buffering. + +This is **not** tcc's single global `tok` — kit's LL(2) + string fusion + replay +make a one-word slot wrong. The faithful kit form is "one out-pointer relay from +lexer through pp into a 2-slot parser ring." That is the achievable shape; pursue +no further collapse here. + +**Gate:** byte-identical, full parse corpus + the string-fusion and replay cases +(`_Generic`, designated/array-string initializers) explicitly in the diff set. +**Payoff:** removes the per-token by-value copy on the hottest path (the whole +parse cluster, ~37 % of `-c`); modest but uniform. **Risk:** medium — lookahead +and pushback are easy to get subtly wrong; the replay save/restore +(`parse_expr.c:2785`) must round-trip identically. + +--- + +## Tier 3 — unify the value stacks via a CG-provided aux hook + +**End-state (user's design).** One value stack, owned by the CG API. The CG +exposes an **opaque per-entry aux slot** that the C frontend tags with its +ancillary info (`const Type*`, `PcgLvAux`, value flags). The frontend stops +keeping `cg_slot_stack` in lockstep; structural ops (`dup`/`swap`/`drop`/`rot`) +carry the aux **for free** because the CG owns the single stack. The seam stays +intact: the CG never interprets the aux bytes. + +**Why it's worth doing.** It deletes an entire bug class (the two stacks +"drifting") and the lockstep mirroring code in `cg_adapter.c` +(`pcg_dup`/`pcg_swap`/`pcg_rot3`, `cg_adapter.c:113-142`), and turns +`pcg_top_type` / `pcg_aux_top` (`cg_adapter.c:174-204`) into a direct read of the +live entry instead of a parallel-array index. The instruction payoff is the +removed mirroring; the larger payoff is structural cleanliness (the project's +stated preference, `[[clean-structural-redesign-pref]]`). + +**API additions (new public CG surface, kept opaque):** +```c +// configured once at cg-open: the frontend declares its per-entry aux size. +KitCg* kit_cg_open(..., uint32_t sv_aux_size, uint32_t sv_aux_align); +// returns a pointer to the live aux bytes for the entry `depth` from TOS. +void* kit_cg_sv_aux(KitCg*, uint32_t depth); +``` +The CG widens its stack node to `{ApiSValue core; <aux stride>}` and `memcpy`s +the aux on every structural op. The frontend reads/writes `*(PcgSlot_payload*) +kit_cg_sv_aux(g, 0)`. + +**Key design decisions to settle during implementation:** +- **Aux storage: inline stride vs `void*` cookie.** Inline (the node grows by the + ~40-byte C payload) keeps the stack contiguous and dup/swap a single `memcpy`, + but widens the hot `ApiSValue` node — measure against the < 64 B discipline + (the node is currently 40 B specifically to keep `delayed` off-node). A `void*` + cookie keeps the core node small but adds an indirection and per-push aux + allocation. **Recommendation:** inline stride, frontend-declared, because it + collapses two stacks (40 B + ~48 B) into one and removes the second growth/ + allocation entirely; gate the node-size impact on the value-stack microbench. +- **Suppressed-codegen mode.** The frontend runs sizeof/constant contexts with + emit disabled (`suppress_codegen`, `parse_priv.h:390`; `pcg_emit_enabled`) + while still tracking types on `cg_slot_stack`. With a unified stack the CG must + still maintain the *aux* layer (and a stack-depth counter) when codegen is + suppressed — i.e. push/pop the entry and its aux without emitting. Define a + "type-only" push on the CG stack, or have suppression gate emission only, never + the stack bookkeeping. +- **lvalue-aux folding without an emit.** `pcg_lv_member`/`pcg_lv_subscript` + mutate the TOS aux *without* a CG op (`cg_adapter.c`, the `field`/`index` + folding). Under the unified stack these become in-place writes through + `kit_cg_sv_aux(g, 0)` — same effect, no parallel array. + +**Migration (mechanical, large surface):** rewrite every `pcg_*` helper to read/ +write aux through the hook instead of `cg_slot_stack`; delete `cg_slot_stack` / +`cg_type_sp` and the mirroring. The pcg API to the parser is unchanged, so +`parse_expr.c` / `parse_stmt.c` / etc. are untouched — the churn is confined to +`cg_adapter.c` + the CG stack internals. + +**Gate:** **byte-identical** — this is a representational change, not an emitted- +byte change, so the strict gate applies (it must not perturb output). Full +suite + sqlite `-E`/`-c` identical to golden. Verify on **x64 and rv64**, not +just aa64 (the value stack is shared NDT infrastructure; §5). **Risk:** medium- +high plumbing, low semantic — the emitted code should be identical; the risk is a +missed mirror site or an aux-not-carried-on-rot bug, which the byte gate catches. + +--- + +## Sequencing & recommendation + +``` +Tier 0 re-baseline + doc fixes (½ day, no behavior change) ← do first +Tier 1B macro-arg copy reduction (byte-identical) ← lowest risk +Tier 1A newline → BOL flag, cc path (byte-identical, hard gate) ← riskiest; land last in T1 +Tier 2 single token slot at parser (byte-identical) +Tier 3 unify value stacks via aux hook(byte-identical, x64+rv64) ← biggest structural payoff +``` + +Each tier is independently landable and revert-safe; re-profile and re-rank after +each (§5 — the highest lever moves as work is removed). The honest expectation, +consistent with PERF.md §2 (the frontend gap is ~uniform 3× with no single hot +function): **no tier is a silver bullet.** Tier 1A is the largest single +instruction win; Tier 3 is the largest *structural* win (deletes the dual-stack +bug class) for a modest instruction gain. The ~2× headroom a full tcc-shape +collapse *including the vtable* might reach stays out of scope by §4.3's standing +decision — the seven-backend modularity is worth its two indirect calls.