kit

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

commit 0f9b477324a56ef9ee19c54b592b703c5d5796e6
parent 51c694af85e1ba3b7eae6763b22431cd5802087b
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Mon, 15 Jun 2026 10:03:27 -0700

docs: define frontend shared cell shape

Diffstat:
Mdoc/plan/FRONTEND-SHAPE.md | 1114+++++++++++++++++++++++++++++--------------------------------------------------
1 file changed, 408 insertions(+), 706 deletions(-)

diff --git a/doc/plan/FRONTEND-SHAPE.md b/doc/plan/FRONTEND-SHAPE.md @@ -1,731 +1,433 @@ -# 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. - -> **Superseded for the instruction goal (2026-06-14).** The §4.3 tiers below were -> built and measured (three parallel worktree branches, all byte-identical) — they -> are real but **marginal**: Tier 1 (1A+1B) −0.70% on `-c`, Tier 2 −0.49%, Tier 3 -> instruction-neutral; ≈ **−1.2% combined**. They remain worthwhile *cleanup*, but -> they do not move the real number. The committed direction for the actual target — -> **halving `-c` instructions (within 1.5× of tcc)** — is **§D** immediately below. -> Treat §0 and the Tier plan as the landed/landable cleanup record beneath it. - ---- - -## D. Direction (2026-06-14): halving instructions — Path B, no layer fusion - -The goal is no longer "reach §4.3's shape" (mostly landed, and marginal). It is -**halve `-c` retired instructions** — 1.91 B → ~0.95 B, i.e. within 1.5× of tcc -(0.665 B). This section records what governs that, what was ruled out, and the -committed bet. - -### D.1 The arithmetic that governs everything - -| phase | kit `-c` | share | vs tcc | -|---|--:|--:|--:| -| **lex + pp** | **~0.93 B** | **49%** | ~2.8× | -| parse + sema + types + CG-drive | ~0.71 B | 37% | ~3.5× | -| native emit + objwrite | ~0.27 B | 14% | ~7× | -| **total** | **1.91 B** | | **2.87×** | - -**kit's preprocessor alone (0.93 B) is 1.4× tcc's entire compile (0.665 B).** So -the target is unreachable by improving parse/types/codegen alone — even zeroing -emit leaves 1.64 B (2.5×). Halving the total means **roughly halving every phase**; -the cost is **uniform ~2.9× with no single dominant hot function** (the doc's -long-standing observation). That uniformity is the whole problem: there is nothing -to "crush," only a pervasive per-token / per-operation tax to remove everywhere. - -### D.2 What was ruled out (measured, not assumed) - -- **Micro-opts / seam-preserving copy elimination — exhausted.** The three §4.3 - tiers move `-c` ~1.2% combined. This *is* the data point: the incremental surface - is spent. -- **Path A (precompute / cache) — DEAD, spiked marginal.** Two validation spikes: - *lever 1* — kill per-token `SrcLoc`, recover location lazily — **marginal**; - *lever 3* — classify-once type facts (kill the `unalias`/`pred_bits`/`class`/ - `size` re-derivation), carried inline — **marginal**. The spikes prove the cost - is **not the per-item work** (which caching removes) but the **operation count - and the dispatch structure itself**. Making each operation cheaper is futile when - the number of operations is the tax. -- **Layer fusion — ruled out by decision** (see D.3). Collapsing lex→pp→parse→emit - into one mega-pass would buy the uniform inner-loop win, but at the cost of the - architecture's legibility and reuse (the shared cpp backing `cc -E`/`cpp`, the - separable parser, the CgTarget seam + seven backends). Not pursued. - -### D.3 The committed bet: Path B = a lean hot-path *representation*, not control fusion - -**Decision: Path B only, and explicitly without layer fusion.** Path B is a -**representation pivot, not a control-flow pivot.** - -- **Lean wholesale representation.** The C `-O0` hot path stops paying for - generality: a **lean token** (no per-token heft/loc-stamp), an **int-bitmask - type** carried directly on a **lean value slot**, bypassing the general CG type - universe and the public `ApiSValue` value-stack API. The wager is that the lean - representation's savings **compound when adopted wholesale** — the inner loop gets - uniformly cheaper across *all* phases at once — in a way the incremental spikes - (a lean *cache layered on top of* the heavy objects) structurally could not show. -- **No layer fusion — but APIs and handoffs are fair game.** The pipeline stays - factored: **lexer | preprocessor | parser | CG | emit remain distinct, - separately-reusable modules** — the test is that the cpp still stands alone for - `cc -E`/`cpp`, the parser stays separable, and the `CgTarget` seam + seven - backends stay. What is preserved is module **separability**, *not* the current - interfaces: the **inter-module APIs and per-token handoffs are explicitly in scope - to redesign** (e.g. call/return-per-token → a shared cursor over a lean token - buffer, or a leaner pull). We do **not** merge the stages' control flow into one - pass. **The win comes from the data that flows and how it is handed off, not from - merging the loops.** - -In one line: **replace the heavy general data objects with lean ones, and the -per-token handoffs with cheap ones; leave the modules themselves as separable as -they are.** - -### D.4 The load-bearing risk — validate before building the full lane - -The spikes were marginal; Path B bets that **wholesale adoption compounds where -incremental did not.** That bet is **unproven**, and Path B is expensive (a parallel -lean lane plus a fast/slow split to gate). So the next step is **not** to build the -lane — it is to **build a minimal end-to-end lean prototype** (a reduced-grammar -path carrying lean token + int-type + lean value slot from bytes to emitted bytes) -and measure whether the compounding actually appears on a representative slice. If -it does not, Path B is also marginal and the honest conclusion is that **~2× is the -floor for a modular C compiler** and we stop there. This validation gates the spend. - -### D.5 Open design questions for the lean lane (after D.4 validates) - -- **Inter-layer handoff (sanctioned lever, not just open):** redesign how separate - modules pass the lean representation — kill the per-token call/copy tax - (call/return-per-token → a shared cursor over a lean token buffer, or a leaner - pull) *without* collapsing the loops. Module **separability** is the only - invariant; the interfaces between modules are free to change. -- **Int-bitmask type on the hot path:** the encoding, and how it **falls back** to - the full `Type` / CG-type universe for what a bitmask can't carry (aggregates, - bitfields, VLAs, `_Atomic`, wide/`__int128`). -- **Lean value slot:** what the parser drives that still resolves to the **kept** - `CgTarget` seam for emission (the seam is cheap — indirection was never the cost). -- **Fast/slow split + gate:** which constructs take the lean lane vs fall back, and - how the gate proves the two lanes emit **byte-identical** output. - -### D.6 Results — P1 negative; the decisive probe P1 left unmeasured - -**P1 (lean-lexer head-to-head, 2026-06-14) — NEGATIVE.** A throwaway tcc-shape lean -lexer (int kind + small value, byte-offset loc only, interns identifiers only), -faithful in lexical coverage (byte-identical token histogram to the real lexer — -1,150,591 tokens over `sqlite3.c`), measured against the real `lex_next`: - -| lexer | instr/token | -|---|--:| -| real (28-byte `Tok`, per-token `SrcLoc`, interns spellings) | 425.7 | -| lean (int + slice, byte-offset loc, idents-only intern) | 401.3 | -| **ratio** | **1.06×** | - -1.19× even at the physically-impossible "intern *nothing*" ceiling — both well -below the 1.3× floor. **Representation overhead is only ~6 % of the lexer's cost;** -the rest is inherent scanning + per-token dispatch that both representations pay -equally. This **falsifies "wholesale lean representation compounds" on the biggest, -most self-contained phase** and, with the Path-A spikes (§D.2), strongly indicates -Path B is also marginal. (Throwaway: branch `worktree-agent-a2c47ce2c0d4ab45f`, -`experiments/p1_leanlex/`, unmerged.) - -**The number P1 did NOT measure — P1b, the actually-decisive probe.** P1 compared -lean-kit vs real-kit; it never measured **tcc's** lexer. kit's lexer is ~425 -instr/token *regardless of representation*. Whether that is **above tcc's floor** -(→ the lever is the scanning/dispatch **algorithm** — word-at-a-time / computed-goto -— an axis *no* experiment has tested) or **at it** (→ the lexer is not the gap; the -2.9× lives in pp/parse/types) is unknown and decides where to look next. For -context, kit's raw lexer alone (~0.49 B over sqlite) already exceeds tcc's *entire* -lex+pp (~0.33 B), so the lexer is implicated — but only P1b says whether the cause -is algorithm or is mis-attributed. **P1b = add a tcc-lexer (`next_nomacro`) variant -to the same micro-harness; measure its instr/token.** Cheap; completes the triangle. - -**P1b (tcc lexer, 2026-06-14) — POSITIVE; the lever is real but bounded.** Same -micro-harness, three lexers over `sqlite3.c` (9,281,384 B), back-to-back, best-of-7, -**instr/byte** (robust to token-segmentation differences between lexers): - -| lexer | instr/byte | instr/token | -|---|--:|--:| -| kit real `lex_next` | 52.8 | 425 | -| kit lean (P1) | 49.7 | 401 | -| **tcc `next_nomacro` (raw)** | **19.6** | **160** | - -**kit/tcc = 2.69× per byte** (2.53× even for *lean*-kit; stable across 3 sessions). -The lexer carries a real ~2.7× excess that is **not** representation (P1: lean-vs-real -is 6 %) and **not** deferred number/string decode (eager tcc decode is only +9.7 %). -Decisive reconciliation: **lean-kit (401) is still 2.5× tcc (160)** despite a lean -rep → the bulk is the **scan/dispatch algorithm**, not the token record — tcc's -one-switch-into-per-class-inner-loops vs kit's cclass-table + a 28-byte `Tok` and a -per-token `SrcLoc` built every token. **The lever: a word-at-a-time / computed-goto -scanner that also drops per-token loc + punctuator spelling-intern** (puncts are -53 % of the stream, interned for nothing — the parser switches on the code). -**Bounded:** the lexer is ~24 % of `-c`, so a tcc-class scanner caps at ≈ **−12 % -of total** — real and worth doing, but *not* a halving on its own. Throwaway: branch -`worktree-agent-a99ab2852f8e4e000`, `experiments/p1b_tcclex/`. - -### D.7 Synthesis (post-P1b) and the next probe - -Three experiments trilaterate the gap: representation (P1, 1.06×), per-item caching -(Path-A spikes, marginal), and copy elimination (tiers, ~1.2 %) are **all marginal**; -the lexer carries a real **2.7× algorithmic** excess (P1b). So the 2.9× is **pervasive -per-operation instruction density** — kit emits ~2.5–2.7× the instructions per unit -work as tcc's hyper-tuned monolith — *not* one fixable layer, and *not* fixable by a -leaner representation. The lexer is simply the first lever **measured** to be real -(≤ −12 % ceiling). - -**The §D goal now hinges on one unanswered question:** does the same ~2.5× density -excess exist — and is it *tunable* — in **pp + parse + types** (the other ~76 %)? If -yes, a coordinated per-phase re-tuning campaign (scanner first) can approach the goal. -If those phases' cost is **structural generality** (the CG type universe, the -value-stack API, context-struct indirection) rather than tunable code density, then -~2.5× is the floor and no amount of scanner work changes the total. **P1c (next -probe): a kit-vs-tcc per-phase instruction breakdown** — `tcc -E` is the one missing -number; combined with kit `-E`/`-c` and the P1/P1b raw-lex isolations it splits the -gap into lex / pp / parse+emit and says where the remaining excess lives and whether -it is attackable. Until P1c, do **not** invest in the scanner build — a −12 % lever is -only worth it if the other phases are also tunable; otherwise the floor is ~2.5×. - -### D.8 P1c result (2026-06-14): the gap is a UNIFORM constant factor — floor confirmed - -`-E` is the **wrong instrument**: it is dominated by **text serialization**, and kit's -serializer is *cheaper* than tcc's — kit `-E` 0.93 B **<** tcc `-E` 1.15 B — which -masks the compile-path gap entirely. **Discard the §2 `-E`-derived phase ratios -("lex+pp 2.8×, emit 7×") — serialization-contaminated.** Using only the clean -isolations (raw-lex harnesses + full `-c`, no serialization; tcc `-c` re-measured = -0.6635 B): - -| slice | kit | tcc | ratio | share of kit | -|---|--:|--:|--:|--:| -| raw lex (harness) | 0.489 B | 0.182 B | **2.69×** | 26 % | -| everything else (`-c` − raw-lex) | 1.42 B | 0.482 B | **2.95×** | 74 % | -| **whole `-c`** | **1.91 B** | **0.664 B** | **2.88×** | 100 % | - -**The gap is a uniform ~2.8× constant factor.** kit and tcc spend their instructions -in nearly identical *proportions* (~26 % lex / ~74 % rest in **both**); kit is simply -~2.8× denser **everywhere**. There is **no disproportionately-bad phase to crush** — -"emit 7×" was a serialization artifact. This is the signature of **pervasive -engineering-style overhead** (context-struct indirection, bounds checks, composable -functions + call overhead, growable containers, the public-API / value-stack / type- -universe layers, the kept vtable seam) vs tcc's hyper-tuned monolith — *not* an -algorithm or representation localizable to one place. - -**Conclusion — the answer to "what halves `-c`":** nothing structural does. Halving -the total means roughly halving kit's instruction density **pervasively, in every -phase** — i.e. tcc's monolithic engineering — incompatible with the standing -constraints (keep module separability, the seven backends, the `CgTarget` seam; no -fusion). What *is* available: -- **One concrete one-time lever:** a tcc-class scanner (word-at-a-time / computed-goto; - drop per-token loc + punctuator spelling-intern) → lexer 2.69×→~1×, i.e. **≈ −12 %** - of `-c`. Real, worth banking; not a halving. -- **The incremental touch-once grind** (the PERF campaign) is the only thing shown to - move the *constant factor*: 3.08×→2.64× tcc over ~22 commits (~−14 % Ir). Reaching - 1.5× that way is **many more multi-week waves** — a long-horizon destination, not a - single refactor. - -**1.5× tcc is a long-horizon incremental goal, not a structural one.** Recommended -posture: bank the scanner lever (−12 %), continue the touch-once grind, treat "within -1.5× of tcc" as a multi-campaign destination — unless the modularity constraints are -relaxed (a tcc-style monolithic hot path), which is explicitly out of scope. **The -structural hunt (§D) is closed; the path forward is incremental.** *(Reopened -2026-06-15 — §E: direction to relax the no-fusion constraint, the one lever §D.8 -named as able to move the uniform-density factor.)* - ---- - -## E. Direction (2026-06-15): front-half fusion around a shared tagged cell - -§D.8 is the premise, not a contradiction: the gap is a **uniform ~2.8× -per-operation density** factor — kit and tcc spend instructions in nearly the same -*proportions*, kit is ~2.8× denser **everywhere** — so there is no one phase to -crush, and the only lever that reduces density *across every phase at once* is the -one §D.2 named and then declined: **fusion**. It removes the per-token / per-op -*overhead* — inter-stage call/return per token, the 32 B token copied through -slots, the parallel value stacks, the public value-stack API layer, the CG -type-universe re-query, growable-container bounds checks — which is exactly the -pervasive tax §D.8 measured. The landed lean-token boundary (LEX-PP-API.md) is the -confirming negative: a lean *representation that still copies through separate -layers* nets ≈ −1.3 % (drain-measured lex+pp still **2.1× tcc**), precisely as -§D.6/§D.8 predicted a representation-only change would. **Representation was never -the lever; the handoff and the per-op layering are.** This section relaxes the -no-fusion constraint under explicit direction and specifies the fused design. - -### E.1 What is relaxed; what stays invariant - -**Relaxed.** The front half — **lexer + preprocessor + parser** — fuses around one -shared, in-place **cell** and one value stack. The parser stops being a module that -consumes an abstract token stream *by value*; it shares the cell representation with -the pp output and drives emit off it directly. The standing "leave the modules as -separable as they are" rule (§D.3) is dropped *for the front half*. - -**Invariant — two hard constraints the fusion must preserve:** -1. **The preprocessor stays independently drainable.** `cpp` / `cc -E` / - `KIT_PP_DRAIN` must run lexer+pp to a token stream with **no parser attached**. - Mechanically: the cell's *lexeme* fields are self-contained, the pp reads/writes - only those, and `pp_next` remains the standalone pull. The parser's *semantic* - tags live in a region the pp never touches. -2. **The `CgTarget`→`NativeTarget` backend seam + seven backends stay** (§4.3). The - fused value stack drives emission *through* the kept seam (its two indirect calls - per primitive are not the cost — §D.8). We fuse the *front half*, not the backend. - -One line: **one record flows bytes→token→value with no copy and no re-derivation; -the pp can still be drained at the token stage; the backend seam is untouched.** - -### E.2 The shared cell - -One record serves the whole front half. The lexer/pp fill the **lexeme** view; the -parser resolves a primary **in place** into the **value** view, at which point the -cell *is* a value-stack entry (kit's `ApiSValue`, tcc's `SValue`). The two views -overlap — a resolved value no longer needs spelling/`aux` — so the record stays -small; `loc` and `flags` survive both. Exact layout is an implementation detail to -size against the < 64 B value-node discipline; the shape: +# 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 struct Cell { - u16 kind; /* token kind in lexeme view; value-node kind once resolved */ - u16 flags; /* lexeme: BOL/space/suffix/encoding · value: lvalue/const/... - * (disjoint bit ranges — the two views do not co-live) */ - u32 loc; /* byte offset; kept through to emit for diagnostics + DWARF */ +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 { - struct { /* LEXEME — lexer/pp own; the pp drain reads ONLY this */ - u32 aux; /* IDENT: interned Sym · PUNCT/#/##: code · PARAM: idx */ - u32 text; /* spelling ref: (len @loc) for source, or a Sym, tagged */ - } t; - struct { /* VALUE — parser owns; the pp NEVER reads/writes this */ - u32 type; /* int-bitmask scalar type, or a CG type-id (fallback) */ - u32 r; /* storage class / value-stack location (local/reg/const)*/ - i64 c; /* immediate when constant-known (parse-time fold) */ - } v; + TextRef text; /* lexeme spelling ref; lexer/pp/drain view */ + CFeSem sem; /* parser-only semantic seed; pp never reads this */ } u; -} Cell; +} 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); ``` -- **`type` is an int-bitmask for the scalar common case** (signedness × width × - float/ptr/bool), carried on the cell so the hot path never calls - `api_unalias_type`/`pred_bits`/`class`/`size` — the §D.2 "classify-once" facts, - now *inline on the flowing value* rather than a cache beside the heavy `Type`. - Anything a bitmask can't carry (aggregate, bitfield, VLA, `_Atomic`, wide / - `__int128`) sets a `FALLBACK` bit and `type` indexes the full CG type universe; - the slow path is unchanged. This is the §D "int-bitmask type on a lean value slot, - with fallback" made concrete on the unified cell. -- **No separate `PcgSlot` + `ApiSValue`.** Today the front half keeps two parallel - value stacks in lockstep (§0 Pillar 3). The cell *is* the single stack entry; - Tier 3's "CG-owns-one-stack-with-an-opaque-aux-slot" is subsumed — the aux *is* - the value view of the cell. - -### E.3 Data flow (explicit, end to end) +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); ``` - source bytes - │ scan (cclass / word-at-a-time) ── LEXER ── - ▼ - Cell.t ← {kind, flags(BOL/space/suffix), loc=off, aux=Sym|punct, text=span} - │ written into a small ring slot (cur / next / pending); NOT returned by value - ▼ - ── PREPROCESSOR ── reads/writes only Cell.t - │ · directive recognition, #if skip, include push (source stack) - │ · macro expansion: bodies are Cell[] streams, replayed by pointer; - │ hidesets ride a side-channel keyed by stream position (never on the cell) - │ · __LINE__/__FILE__/paste/stringize synthesize Cell.t (text = interned Sym) - ▼ - pp_next(pp, &slot) ── the drain boundary ── - ├───────────────► cpp / cc -E / KIT_PP_DRAIN : consume Cell.t, serialize/discard. - │ No parser, no Cell.v ever touched. - ▼ - ── PARSER ── tags the slot IN PLACE, then it becomes a value-stack entry - │ primary IDENT: resolve Sym → binding (BindingTab, O(1)) → write Cell.v - │ {type(bitmask|fallback), r=storage, c=const?}; push. - │ primary literal: decode text → Cell.v {type, c}; push. - │ operator: reduce the top Cell.v's on the value stack, driving emit. - ▼ - ── CG-DRIVE ── the value-stack Cell.v's call the CgTarget seam directly - │ (no public ApiSValue push/pop API in between; the cell carries r/type/c) - ▼ - CgTarget → NativeTarget → MCEmitter (KEPT seam; emits bytes) + +## 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 */ ``` -The copies that exist today and disappear: (a) `lex_next` sret → `pp` slot → parser -`fetch_tok` by-value return → `p->cur` (three hops → one in-place write); (b) token -→ separate `PcgSlot` *and* `ApiSValue` derivation (→ the primary's cell *is* the -value entry); (c) the per-op `Type`-universe queries (→ bitmask on the cell). The -remaining unavoidable move is `next → cur` promotion for LL(2) lookahead (one struct -move), as today. - -### E.4 Tagging: token → value, in place - -This is the fused step the boundary rewrite set up but did not take. When the parser -accepts a **primary**: - -- **Identifier.** `kind==IDENT`, `u.t.aux` is the interned `Sym`. Resolve once: - keyword via `kw_map`; else binding via `BindingTab` (already O(1), §0 Pillar 2 — - the kit-native `TokenSym.sym_identifier`). Overwrite the cell's value view: - `u.v.type` = the binding's int-bitmask type (or fallback id), `u.v.r` = its storage - (local frame slot / global sym / enum const → `c`). The cell is now an SValue; - push it. **No second lookup, no `PcgSlot`+`ApiSValue` pair, no `Type`-universe - call** for the scalar case. -- **Literal.** Decode `u.t.text` (via the text helper) into `u.v.c` + `u.v.type` - from the suffix/encoding `flags`; push. (Decode stays lazy — only literals that - reach a value are decoded, as now.) - -Operators never "tag a token" — they consume value-cells off the stack and call the -seam. So "the parser tags the token with symbol/type" is precisely: **a primary's -lexeme cell is resolved in place into the value cell it becomes**, fusing the -token stream and the value stack into one representation with one resolution. - -Diagnostics/DWARF: `loc` rides the cell into `u.v`, so `pcg_set_loc` reads it off the -live value entry; line/col still materialize lazily through the pp (LEX-PP-API.md). - -### E.5 How pp drainability survives the fusion - -- The **lexeme view (`Cell.t`) is closed under lex+pp**: every pp operation - (directives, macro expansion, paste/stringize, `__LINE__`) produces and consumes - only `Cell.t`. Nothing in the pp reads `Cell.v`. -- `pp_next(pp, &slot)` is the **single standalone entry**: `cpp`, `cc -E`, and - `KIT_PP_DRAIN` call it in a loop over a slot they own and never tag. The parser is - just *another* caller that happens to then tag the slot. Fusing the pp's internal - pull chain (`lex_next`→`src_next_raw_into`→`pp_pull_into` → one tight `pp_next`, - tcc's `next_nomacro`+`next` shape) is **internal** to the pp module and does not - touch this boundary. -- So "fused" here means **shared representation + in-place tagging + a collapsed - internal pull**, *not* one mega-function. The pp stays a module with a drain entry; - the parser stays a module that drives the seam. What's gone is the *by-value - handoff and the duplicate value representation* between them. - -### E.6 Sequenced build (each byte-identity-gated) - -Order matters: land the cheap copy/handoff collapses first (they're the Tier 1/2/3 -work below, now repurposed as fusion precursors), then the two genuinely-fused steps. +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; ``` -E-pre Tier 1B + 1A (macro-arg copies; newline→BOL) — relay slimming, byte-id - Tier 2 (out-pointer pull into parser ring) — kills the by-value copy -E-a collapse the pp internal pull chain into one pp_next (next_nomacro shape); - pp stays drainable. Internal-only; byte-id. -E-b introduce Cell; lexer/pp fill Cell.t; parser ring holds Cells; pp drain - reads Cell.t. (Still derives PcgSlot/ApiSValue separately — no value fusion - yet.) Byte-id; this is the representation swap, isolated. -E-c value fusion: a primary's Cell.t resolves in place to Cell.v; Cell IS the - single value-stack entry; delete cg_slot_stack + the ApiSValue duplicate; - drive the CgTarget seam off Cell.v. Byte-id; the big structural step. -E-d int-bitmask type on Cell.v for scalars + fallback bit to the CG type - universe. Byte-id; this is the type-density lever. + +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; ``` -Every step keeps the emitted object **bit-for-bit identical** (it changes how data -flows, not what is emitted) — same gate as the boundary rewrite: `make perf-golden` -→ edit → `make perf-gate` (incl. the splice/`#line`/diagnostic battery), full -pp/parse suites, and verify on **x64 + rv64** for the value-stack steps (shared NDT -infra, §5). `KIT_PP_DRAIN` + the tcc `-bench` drain (PERF.md §3) measure lex+pp after -each step to confirm the gap is actually closing. - -### E.7 Prototype-first; payoff; the floor - -Per §D.4's discipline, **validate the compounding before building the full lane.** -The decisive prototype is **E-c on a reduced grammar slice** (scalar locals + arith + -calls, end to end bytes→emit on the unified cell, no fallback paths) measured -instr/byte against tcc — because §D.6 proved representation alone is ~6 %, the -*fusion* (removed handoff + removed dual stack + removed type re-query) is the -untested variable. If a fused scalar slice does not move materially toward tcc's -density, fusion is also marginal and ~2.5× is the floor for kit's engineering style -(the honest §D.4 stop). If it does, build E-a..E-d in order. - -Payoff and floor, stated honestly: -- This attacks the **pervasive** factor §D.8 found, so unlike the −12 % scanner lever - it is *not* bounded to one phase — it is the credible path toward 1.5× tcc. -- It is **unproven and expensive** (a front-half rewrite, larger than the boundary - rewrite), and it **spends the front-half modularity** (the parser fuses with the - pp output; only pp-drain + the backend seam remain as boundaries). -- The **kept floor**: the `CgTarget` seam's two indirect calls per primitive and the - seven-backend generality remain by §4.3 decision — full tcc parity (a single - monolithic hot path through emission) is still out of scope; the target is *1.5×*, - not parity. - ---- - -## 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: +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 -// lang/c/parse/parse.c:388 -SymEntry* scope_lookup(Parser* p, Sym name) { - return name ? BindingTab_get(&p->bind, name) : NULL; // O(1) +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; + } } ``` -`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):** +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 -// 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); +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 */ +} ``` -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 +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; +} ``` -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 + +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 ``` -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. +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.