commit f1221981c527ac1c956d1f182a658f7153dd3255
parent 8a6cde7b8cd3e0c8c7ed704b78f89df57a99b45c
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 10:51:43 -0700
doc(perf): catalog seam-preserving opportunities — front-half bricks, type, NDT-internal, codesize track (modularity kept)
Diffstat:
1 file changed, 100 insertions(+), 0 deletions(-)
diff --git a/doc/plan/PERF-IDEAL.md b/doc/plan/PERF-IDEAL.md
@@ -479,6 +479,106 @@ the last 2.69× → ~2× needs the **codesize track** (fewer emitted bytes, the
multiplicative lever) and/or Track 4 (tcc's shared-token single-pass shape), not
more per-op slimming.
+## Remaining opportunities — modularity-preserving (the backend seam STAYS)
+
+The `CgTarget` → `NativeTarget` polymorphism is the architecture's asset (one
+frontend, seven backends: aa64/rv64/rv32/x64 native + wasm + c_target + check, plus
+the opt-IR recorder at -O1) and is **out of scope** — we are not collapsing the
+value-stack→direct-emit seam, not monomorphizing it, not adding a parallel
+-O0 fast path that bypasses the public `kit_cg_*` API. Everything below respects
+that line. It lives in one of four places that are **not** the swappable seam:
+(a) the front half (lex/pp/intern/parse), (b) *inside* the concrete
+`NativeDirectTarget` and the monomorphic `MCEmitter` (the -O0 register machinery
+and byte sink — these are not the polymorphic vtable), (c) the type subsystem, or
+(d) the codesize track (which *bytes* the NDT chooses to emit, not how it dispatches).
+
+> **Reframe that governs this list (verified):** kit is *already* AST-free and
+> IR-free at -O0 (`session.c:148` gates the opt IR behind `opt_level > 0`; the
+> parser emits as it parses; the `p->replay[]` buffer is bounded local lookahead
+> for incomplete-array brace-init sizing only, `parse_type.c:1746`). tcc's "no
+> second copy of the program" property is already true here. So none of the items
+> below is "delete a second representation" — they are **thin-the-unit /
+> classify-once / emit-fewer-bytes**, the same conservation laws as the rest of
+> this doc.
+
+The re-profile shows no remaining single function of the WAVE-1 class. The biggest
+remaining *lever* is the codesize track (§R-D, multiplicative); the highest-value
+single *brick* is the symbol-binding cache (§R-A1).
+
+### §R-A — Front half: lex / pp / intern / parse (~50% of self-Ir)
+
+| # | opportunity | where | character | gate |
+|---|---|---|---|---|
+| **A1** | **Symbol-binding cache on the interned `Sym`** (the front-half "single-pass brick"; SLIM §B5). `scope_lookup` is an N-scope chain walk per identifier (`parse.c:378`, 12 call sites); tcc caches `sym_identifier` on the interned token. A `Sym`-keyed binding stack with push/restore on scope enter/exit makes resolution a pointer load. | `parse.c:301-380` (scope), interner | **structural, high value** (depth-dependent, ~5-10% of parse phase) | byte-identical; **medium-high risk** — save/restore + shadow/redef/typedef-vs-ident discipline; full parse corpus |
+| A2 | **Re-diagnose `lex_open_mem`'s 79M** (WAVE-3 proved it is NOT the memset). It is genuine per-open work over ~6,000 opens (3,140 files + 2,796 macro-paste buffers): field init, the splice-fold loop control, `kit_source_add_memory`, `lex_catchup_splices`. Profile *what*, then cut per-open setup — esp. whether macro-paste buffers (`<paste>`) need a full `lex_open_mem` at all. | `lex.c:281-305`, `pp_expand.c` paste sites | unknown until re-profiled; potentially structural | byte-identical |
+| A3 | **Macro body replay by pointer-swap** (gap #3 / SLIM §A5). `subst_phase2` copies the object-macro body even with no `##`; function-macro args take 3-4 copies/token. A `has_paste` flag set at definition → replay the immutable body directly (tcc's model). | `pp_expand.c` (`subst_phase2`, arg subst) | structural, PP-contained | byte-identical (macro corpus) |
+| A4 | **Stop materializing non-directive newlines** (deeper §C.3b). Today they are drained in place (`bfa30f77`); the full version never builds them as `Tok`s on the cc path (lexer sets `TF_AT_BOL`; `-E` keeps a newline-emitting variant). ~51% of lexer outputs are newlines. | `lex.c:644`, `pp.c`/`pp_directive.c` | structural, **med-high risk** (cc-vs-`-E` split; directive-line contract) | byte-identical, gate the `-E` categories hard |
+| A5 | **PP per-token micro-levers**: gate the `kit_arena_reset` reset-check on "xarena actually grew" (fires ~every 5 tokens); tighten the `#line`-delta + include-guard state machine on the `src_next_raw_into`/`pp_pull_into` hot path. | `pp.c:949`, `pp_expand.c:935` | cleanup (~1-3M) | byte-identical |
+| A6 | **`pool_intern_slice` probe/insert side** (71.6M; the WAVE-1 cache took the hash side). Diminishing — the self-sufficient `{hash,sym}` slot is measured **~0 Ir** (a D1/wall-clock play, not instructions). Only revisit under a cache-stall study. | `pool.c` | **near-dead-end for Ir** | — |
+| — | `lex_next` (16.5%, the dominant fn) and the `Tok` relay (gap #1) are **already optimal** — raw-cursor scanner; `_into` out-pointer relay; `Tok`-16B was a measured dead end. No structural lever left. | — | done | — |
+
+### §R-B — Type subsystem (~14% of self-Ir)
+
+| # | opportunity | where | character | gate |
+|---|---|---|---|---|
+| B1 | **Kill the per-call `id → entry` decode** that is now the residual hub cost (`api_type_pred` 61.9M, `cg_type_get` 26.6M, `api_unalias_type` 53.7M). §D.2 made the *result* O(1); §D.3b carried the `CgId` on the parser slot — go one further and carry the **decoded `CgApiType*` entry** (or inline `api_type_from_id`'s segvec double-index) so a predicate is a direct field read, not a decode. | `src/cg/type.c` (`api_type_from_id`, `api_type_pred`), `cg_adapter` | structural-ish (the next type lever) | byte-identical |
+| B2 | **Collapse `c_abi_type_info` into a descriptor reader** (§D.4, deferred). Two memos compute the same facts on two keys (`Type*` vs `CgId`). Blocked by a layer boundary (frontend has no `KitCompiler*→TargetABI*` accessor) + the `signed_` divergence + a `scalar_kind` ALIAS edge — needs a small public accessor first. | `lang/c/abi/c_abi.c:34`, `src/abi/abi.c` | cleanup (~8M), boundary work | byte-identical |
+| B3 | **`resolve_type` 26.7M / `type_cg_lower` 16M / `type_cg_id_in_pool` 13.4M** — the `Type*`→`CgId` bridge. §D.3b cut the *operand* re-crossings; `resolve_type` (frontend type resolution) is separate and still hot. Audit for a re-resolve that a per-`Type` cache would remove. | `lang/c/type/type.c`, `cg_adapter` | cleanup | byte-identical |
+
+### §R-C — Inside the concrete NDT + the monomorphic MCEmitter (NOT the vtable, ~7%)
+
+These are the -O0 register machinery and byte sink *within* `NativeDirectTarget` /
+`MCEmitter` — concrete, not the swappable dispatch. Fair game.
+
+| # | opportunity | where | character | gate |
+|---|---|---|---|---|
+| C1 | **Free-register bitmap** for `nd_cache_alloc`'s free-pool scan (the policy-neutral half — find-first-set on `caller_saved & ~owned` instead of the linear scan). The LRU victim policy (D2 round-robin) was **dropped** — it is inlined/invisible to Ir and risks the codesize track. | `native_direct_target.c` (`nd_cache_alloc` 0.75%, `nd_dst_reg`, `nd_dst_writeback`) | cleanup (~1-2M) | byte-identical |
+| C2 | **Typed-store for memory-op emit** — §E.1's `mc_emit32` covered the fixed-width path; `aa_emit_mem` (0.81%) and the other multi-byte idioms still stage+`mc_emit_bytes`. Extend the cursor store to them. | `aa64/native.c` (`aa_emit_mem`), `mc.c` | cleanup (~few M) | byte-identical |
+| C3 | **Contiguous section image** (gap #5 / D5-stretch) — a single geometric section buffer so `mc_emit32` stores into the *final* image (the full tcc shape for emit), eliminating `buf_flatten` at objwrite. MCEmitter is monomorphic, so this never touches the seam. | `mc.c` (cursor), `obj/{elf,macho}/emit.c` | structural (the emit brick); Ir win small (~0.25M flatten) but it is the last "emit into final image" piece | byte-identical (`test-elf/macho/link`) |
+| C4 | **objwrite mop-up** (D7): single-pass symtab emission, chunk-walk the strtab. | `obj/macho/emit.c`, `obj/elf/emit.c` | cleanup | byte-identical |
+| — | **`ApiSValue` 56→24 B shrink (C3)** — the value node is CG-layer infra (not the vtable, so technically in scope) but the shrink was **measured neutral** (Round 7) and `api_sv_adjust_refs` (0.77%) is the load-bearing liveness refcount that drives coalescing, not copy bandwidth. Leave it. | `src/cg/internal.h`, `value.c` | measured-neutral | — |
+
+### §R-D — The codesize track (the BIGGEST remaining lever — multiplicative, seam-independent)
+
+kit's -O0 `__TEXT` for sqlite is **1.88 MB, ~2.4× tcc's** (`o0-codesize-vs-tcc`).
+This is the *what-bytes-NDT-emits* axis — it does **not** touch the dispatch seam,
+and it is **multiplicative**: every byte removed cuts emit + objwrite + assemble
+together. The value-stack residency + coalescing + lazy-homes work already took the
+object 4.26 MB → 2.22 MB (~1.0× tcc object); the remaining ~2.4× *text* bloat is
+the single largest lever left after the front half. Open items (from
+`PERF-TCC-SLIM` Track B / the `o0-codesize` track):
+
+- **Signed-load fold** `ldrb;sxtb` → `ldrsb` (and the zero-extend cases) —
+ structurally **blocked**: needs a load-with-extend rider on `MemAccess`, not a
+ register rename (the copy/convert-coalescing work flipped the register cases but
+ could not reach the load itself).
+- **Call-argument spill** reduction (fewer stack stores around calls).
+- **Indexed-addressing fold** (collapse base+index into one addressing mode).
+- **NOP / alignment pad** trimming.
+- Each is **run-correctness + determinism**-gated (it deliberately changes emitted
+ bytes), not byte-identical — and each compounds with the per-op emit costs in §R-C.
+
+This is the track to open next: it is the largest remaining lever, it is entirely
+within the NDT's byte choices (seam untouched), and it directly shrinks the worst-
+ratio phase.
+
+### §R-E — Memset / arena churn (~2-3%)
+
+| # | opportunity | where | character |
+|---|---|---|---|
+| E1 | Right-size the per-expression / per-emit struct zeroing (`__GI_memset` ~26.5M); designated-init the per-op clears (the idiom `api_op_imm`/the B2 `PcgLvAux` work already use). | `cg_adapter.c`, `value.c`, `native_direct_target.c` | cleanup |
+| E2 | Arena churn (`arena_alloc` 20.4M, `arena_reset` 14.2M) — audit per-statement/per-temp allocation vs reuse. | `src/core/arena.c` consumers | cleanup |
+
+### Explicitly OUT of scope (the seam stays modular)
+
+The gap-#4 value-stack→direct-emit collapse — removing/monomorphizing the
+`CgTarget`/`NativeTarget` vtable indirection, or adding a parallel -O0 native path
+that bypasses the public `kit_cg_*` API. The two real indirect calls per primitive
+(the `CgTarget` seam + the per-arch `NativeTarget` dispatch) are the price of the
+seven-backend modularity and are **kept**. The ~2× instruction headroom that a full
+tcc-shape collapse might reach is **not** pursued; the seam-preserving items above
+plus the codesize track are the campaign from here.
+
## Appendix — claims verified against source (not docs)
- §A scalar splice loop + no NEON: `lex.c:156-157`, disasm of the profiled binary. ✓