Slimming the vtable stack to close the tcc gap — a redesign plan
Companion to PERF.md (running standings) and PERF-TCC-GAP.md (structural
diagnosis). Those establish where the 2.22 B vs 0.66 B (3.35×) instruction gap
lives and why. This doc is the redesign: a concrete, ranked, dependency-ordered
campaign to slim every layer of the pipeline so each does tcc-spare work per
operation — keeping the layered architecture, not deleting it.
The brief that scoped this plan: keep the vtable stack, but slim it down significantly to match tcc's more spare construction; structural wins, 3× fewer instructions. Written after a six-strand code-path audit (lex+pp+intern, parse+ sema+cg_adapter, the CG value-stack/type machinery, NativeDirectTarget, the CgTarget seam, MCEmitter+objwrite).
1. The shape we drive toward — keep the layers, remove the redundancy
The architecture is the asset; the per-operation work is the liability. tcc is
fast because each source byte is touched ~once, each token is an int, each
operator is a vtop adjustment plus ≤1 four-byte store, and there is no second
copy of the program anywhere. kit pays for pluggability (multiple backends share
one frontend through the CgTarget seam) and for a typed value model — and those
are worth keeping. What is not worth keeping is the redundant work piled on top:
re-interning every token, re-decoding every type 8–12× per statement, copying a
56 B value node and a 48 B location descriptor per operand, and crossing three
indirect-call layers per emitted primitive where one would do.
The single most important structural finding — and where an early framing had to be corrected: not all three "layers" are real vtables. Two are genuinely polymorphic and earn their indirection; one is monomorphic and the indirection is pure overhead.
- CgTarget is polymorphic —
NativeDirectTarget, the opt IR recorder,c_target,wasm, andcheckeach install one (NativeDirectTargetis-aCgTarget,native_direct_target.c:112). One indirect call at the seam. Keep. - NativeTarget is polymorphic per arch — separate impls in
src/arch/{aa64,x64,riscv}/native.c(+check_target.c). NDT is the arch-neutral -O0 driver and holdsNativeTarget* native(native_direct_target.h:114);nd_binop → d->native->binopis a real arch dispatch, not redundant. It is not removable without compile-time monomorphizing NDT per arch (a much larger change, out of scope). Keep the vtable. What is removable here is the 48 BNativeLocmarshalling round-trip (NDT already resolved the operand to aReg; it wraps it in a NativeLoc, the arch immediately unwraps it back to a register number) and the number of crossings per statement — copies and counts, not the indirect call. - MCEmitter is monomorphic —
mc.h:10states it outright ("One generic implementation insrc/arch/mc.cserves every machine-code arch"), and onlymc.cever assigns the slots;mc_newis the sole constructor. All 16 function pointers point at the singlem_*function. 386 call sites pay an indirect call for zero polymorphism. This vtable should not exist — devirtualize it.
So "keep the vtable stack, slim it" resolves to:
| Layer | Polymorphic? | Decision |
|---|---|---|
| CgTarget | yes (NDT / opt-recorder / c_target / wasm / check) | KEEP — one vtable, untouched signatures |
| NativeTarget | yes — per arch (aa64 / x64 / rv64 / check) | KEEP — NDT correctly holds it; slim the NativeLoc marshalling + crossing count, not the dispatch |
| MCEmitter | no — one impl (mc.c) |
DEVIRTUALIZE — static inline the hot base-only ops, direct-call the rest, delete the 16 pointers |
At -O0 the descent becomes one indirect call (CgTarget seam) + one indirect arch
dispatch (NativeTarget, real) + one inline byte store (MCEmitter, devirtualized),
instead of three indirect calls + a NativeLoc marshalling round-trip. Two real
vtables survive; the fake one and the copies do not.
2. The honest arithmetic — what this gets us, and what it does not
Summing the strands' own (deliberately conservative) per-track estimates, with overlaps de-duplicated:
| phase | now | est. after campaign | residual ratio vs tcc |
|---|---|---|---|
| lex + pp | 0.97 B | ~0.74 B | ~2.1× |
| parse + sema + CG-drive | 0.87 B | ~0.54 B | ~2.2× |
| native emit + objwrite | 0.37 B | ~0.24 B | ~4.8× |
| total | 2.22 B | ~1.52 B | ~2.3× |
The emit-phase number is deliberately conservative: keeping the NativeTarget
per-arch dispatch (it is real, §1) means the indirection savings there come only
from devirtualizing the monomorphic MCEmitter (F2) and slimming marshalling (F3) —
not from collapsing the whole descent. The bulk of the remaining emit headroom is
the o0-codesize track (fewer emitted bytes), which is out of this plan's scope
but compounds with it (below).
This campaign, fully landed, credibly takes kit from 3.35× to ~2.3× tcc — it does not reach parity by itself. That is the truth the plan must own. Closing the last ~2× requires two things this plan sets up but does not finish:
- The o0-codesize track (Track B) compounding. kit emits ~2× tcc's machine bytes; that inflates emit, objwrite, and assemble multiplicatively. Track B has already taken the object 3.45×→1.71× tcc; every further byte removed shrinks the 0.37 B emit phase below the per-op wins counted above. The two tracks multiply, they don't add.
- Re-profiling after each landing. The phase shares shift as work is removed; the highest lever moves. Treat the estimates as a starting order, not a budget — re-measure on RELEASE best-of-7 after every shippable step and re-rank.
The destination beyond this plan is PERF-TCC-GAP.md Track 4 (tcc's single-pass
data-flow shape: a shared mutable token slot, symbols resolved through pointers on
the interned token, a thin value stack the parser drives by calling emit directly
into the final section image). The moves below are independently shippable,
byte-identical-gated steps toward that shape — several of them (the contiguous
section buffer, the binding-on-Sym cache, the intern-once cache) are literally the
first bricks of Track 4.
3. Foundational shared changes — do these first, they unblock multiple tracks
Three changes are depended on by more than one strand. Land them first so the per-phase tracks build on them and nobody double-implements.
F1. One packed {size, align, regclass} memo on the type id
The highest shared-leverage change in the whole plan. Today abi_cg_type_info
(src/abi/abi.c:27) is an uncached recursive switch with alias/enum chasing;
abi_cg_sizeof/abi_cg_alignof (abi.c:90-95) each call it independently, and it
is hit several times per statement from all three downstream layers:
- parser:
pcg_mem/pcg_sizeof/pcg_alignofon every load/store/convert (cg_adapter.c:9-39, 17 sites) — andpcg_memcalls size+align twice; - cg:
api_alloc_temp_localcalls sizeof and alignof per temp (value.c:225-226),api_mem_for_lvalueper memop (value.c:246-247); - NDT:
nd_type_memcalls size+align per native frame load/store (native_direct_target.c:477-479), plus the arch's ownloc_is_64/type_size32re-query yet again (aa64/native.c:349,374).
Only the 1-byte api_type_class is memoized today (type.c:289-307, with a CACHED
bit + builtin fast path). Extend exactly that proven mechanism to a packed
{u32 size, u16 align, u8 regclass} word on CgApiType, primed for builtins in
cg_api_init_builtins and filled lazily for user types. Route abi_cg_sizeof/
abi_cg_alignof/abi_cg_type_info and the parser's pcg_* through it; collapse
the double-call sites to one read.
- Payoff: ~0.08–0.12 B across the parse+sema phase and part of the emit phase (it serves NDT too). Single biggest middle-phase lever.
- Gate: byte-identical (pure memoization of a deterministic function).
- Owner: cg strand (lives in
type.c/abi.c). Consumed by parser + NDT — they must not keep their own parallel size/align paths after this lands.
F2. Devirtualize MCEmitter (the fake vtable) — direct calls + static inline hot path
MCEmitter is monomorphic (mc.h:10; only mc.c assigns slots; mc_new is the
sole constructor) — its 16 function pointers are pure indirection across 386 call
sites with zero polymorphism. Convert:
static inlinein mc.h the 3 hot, base-only ops — they touch only the publicMCEmitterbase struct (cur_bytes,loc,obj;mc.h:49-65):emit_bytes(155 sites —mc.c:298, justbuf_write/obj_write, this is tcc'so()),pos(70 sites),set_loc(3 sites). These three cover 228/386 sites. While here, cache a{u8* cur; u8* end;}cursor on the base soemit_bytes/a newmc_emit32/mc_emit_wordsdoif (cur<end){ store; cur+=n; } else slow()— bounds-test + store + bump, no staging, no memcpy.- Direct extern call the other 13 — they need
MCImplprivate state (labels/fdes) or are cold: un-staticthem_*bodies, declaremc_*in mc.h, rewritemc->X(mc,…)→mc_X(mc,…). Sites:emit_reloc_at55,label_new/label_place27/27,emit_label_ref20,cfi_*32,set_section6,emit_align/fill/label_data_reloc/reloc/destroy~8. - Delete the 16 pointers from
struct MCEmitter(−128 B).
This removes an indirect call plus its caller-save churn from every emitted instruction. It is the one genuinely-redundant indirection of the three layers.
- Payoff: ~5–10 M from the call-shape change + the inline cursor; larger in
practice because
emit_bytesis the single highest-frequency call in the backend (one+ per emitted word). It is also the sink F3 lands in. - Gate: byte-identical (same functions, same order; only call shape changes).
- Owner: MCEmitter strand. The cursor is shared with D5's section-buffer decision — settle the storage type once.
F3. Slim the NDT ↔ NativeTarget seam — kill the NativeLoc round-trip, batch crossings (NOT the dispatch)
NativeTarget is a real per-arch vtable — keep it. The waste is the
marshalling and the crossing count, not the indirect call. NDT already resolves
operands to a Reg (nd_dst_reg/nd_materialize_operand), then wraps each in a
48 B NativeLoc, passes it by value, and the arch immediately unwraps it to a
register number (aa64/native.c:2288). Two moves, both keeping d->native->X:
- Narrow register-only entry points —
binop_rr(NativeTarget*, BinOp, RegLoc dst, RegLoc a, RegLoc b)with a 12 BRegLoc, for the post-materialization ops (the contract already guarantees they receive onlyNATIVE_LOC_REG,native_target.h:484-488). Keep the fatbinopfor the opt replay path (opt.c:990-1018, unchanged). - Batch the cold frame-operand case into one fused NT call (
binop_mem) instead ofload+load+binop, so a colda+bis one crossing not three.
- Payoff: ~3–5 % of the emit phase from the marshalling (48 B→12 B × 3/op, retired stores at IPC 3.7), more on the batched cold path.
- Gate: byte-identical for the narrow-hook refactor; run-correctness + determinism for the x64 mem-operand fused form (different instruction selection).
- Owner: NDT strand + per-arch backends (one additive
NativeTargetchange). - Multi-backend safety: additive hooks only; opt/-O1 keeps the fat vtable, gate
the -O0 fast paths on
opt_level == 0. C-source/Wasm never touch NDT.
4. Track A — lex + pp + intern (0.97 B)
The binding/resolution model is already tcc-shaped — macros (MacroTab) and
keywords (KwTab) are O(1) Sym-indexed loads via KIT_SYMTAB (the Round-8
sym-centric work). Do not rebuild resolution. The gap is production-side
intern-once and copy density.
| # | Change | Payoff | Gate | Notes |
|---|---|---|---|---|
| A1 | Per-lexer last-spelling intern cache + promote the punct cache to the PP layer so includes don't re-intern. Real C is enormously repetitive at the token level; a 16–64 entry direct-mapped cache keyed on (first_byte,len) catches the dominant fraction before pool_intern_slice (lex.c:594,573,784,679,1029; pool.c:120). |
−0.06…0.10 B | byte-identical (full byte-compare on hit; never cache digraphs) | biggest lex+pp lever; pool_intern_slice is 14% of self-time |
| A2 | Self-sufficient interner probe slot — put {hash, sym} (optionally +len+SSO prefix) in the table slot so a probe rejects without the random entries[sym] load (pool.c:139-142). {hash,sym} is the safe floor; SSO width needs an A/B (PERF.md's "inline-prefix" dead-end was entry-side, not slot-side — the distinction matters). |
−0.02…0.05 B | byte-identical | complements A1 (A1 = hits, A2 = misses/probes) |
| A3 | pp_next_into(Pp*, Tok*) out-pointer mirroring pp_next_raw_into; collapse the 3 sret copies in pp_next→fetch_tok→advance. |
−0.02…0.04 B | byte-identical | bounded (pull-fusion was ~5%); co-owned with the parser strand (it owns fetch_tok/advance/p->cur) |
| A4 | Tok 24→16 B via an inline-packed 32-bit position (NOT a side table — the side-table LocId attempt was the documented regression), lazy (file,line,col) decode only at diagnostics + pcg_set_loc. |
−0.03…0.06 B or neutral | byte-identical, gate HARD (DWARF, -E line markers, __LINE__/#line, diag-across-splice) |
highest-risk item. Needs a genuinely O(1) decode helper at the warm pcg_set_loc site (34 expr sites) or it repeats history. Do after A1–A3 + jointly with the parser strand |
| A5 | Kill the manufactured TOK_NEWLINE on the cc path (fold BOL/space into the next token's flags + a line counter; -E keeps a newline-emitting variant) and skip the macro-body pre-copy for paste-free bodies (has_paste flag set at definition; replay immutable body directly — pp_expand.c:374,633). |
−0.03…0.07 B | byte-identical (-E newline placement; macro corpus) |
lower priority; BOL/directive/invocation semantics are subtle |
Track A subtotal: ~0.16–0.32 B.
5. Track B — parse + sema + cg_adapter (0.87 B)
No re-introduced superlinear axis was found (scope_lookup, declare_function, typedef/global intern, make_local_aligned are all O(1)-amortized). The gap is pure constant factor: redundant type queries, struct zeroing, and stack-copy bandwidth.
| # | Change | Payoff | Gate | Notes |
|---|---|---|---|---|
| B1 | Route pcg_mem/pcg_sizeof/pcg_alignof through F1's memo instead of raw kit_cg_type_size/align cross-API calls; build KitCgMemAccess from one c_abi_type_info call (cg_adapter.c:9-39). Halves the per-memop type work. |
~3–6% of phase | byte-identical | depends on F1; keep volatile read inline |
| B2 | Lazy/right-sized PcgLvAux clear — most pushes are rvalues that never read aux; clear the 32 B aux only when a slot is tagged lvalue, drop the 5 explicit pad[] writes, use designated-init (cg_adapter.c:41-51,497,678,993). Attacks the 8% memset. |
~4–7% of phase | byte-identical (audit aux readers behind was_lvalue/lvalue tag) |
parser-side, independent |
| B3 | Shrink PcgSlot 48→~24 B (bit-field/subobject riders off-node) in lockstep with cg's ApiSValue shrink (C3); memoize {rank,signed,bits} in a 24-entry TypeKind table so integer usual-arithmetic-conversions read array slots, not 6 hashmap probes (cg_adapter.h:65, cint_common_type 613-627). |
~3–5% of phase | low (table) / medium (slot split — bitfield+member corpus) | co-own with cg strand — the two stacks are kept symmetric (cg_adapter.h:8) |
| B4 | Classify the leading keyword once into a CKw then switch, instead of a ~13-deep is_kw chain in parse_stmt/parse_unary (parse_stmt.c:885-959). tcc's switch(tok). |
~1–3% of phase | very low (pure control-flow) | independent |
| B5 | Cache the resolved binding on the interned Sym (parser-side KIT_SYMTAB(BindingTab, SymEntry*) with push/restore on scope enter/exit) so scope_lookup becomes one pointer load instead of an N-scope hash walk (parse.c:369-380). tcc's sym_identifier. |
~5–10% of phase (depth-dependent) | medium-high (save/restore + shadow/redef discipline; full corpus) | parser-side, no pool change needed — coordinate with intern strand only if you prefer the on-Sym layout |
| B6 | Hoist pcg_convert's src==dst early-out above its size/tid queries (cg_adapter.c:882-892). |
~1–2% of phase | very low | stacks with B1 |
Track B subtotal: ~0.17–0.33 B (some also visible in -fsyntax-only).
6. Track C — the CG value-stack + type machinery (part of 0.87 B)
The CgTarget seam sits above all this, so even -fsyntax-only pays it. Note
the load-bearing caveat resolved in §8: the fold ladder is a deliberate -O0
peephole, not dead weight — slim the decision, keep the effect.
| # | Change | Payoff | Gate | Notes |
|---|---|---|---|---|
| C1 | F1 applied inside CG — collapse api_alloc_temp_local's double size+align decode and api_mem_for_lvalue to single memo reads. |
~0.05–0.10 B (counted in F1) | byte-identical | this is F1's CG-side consumption |
| C2 | Single type-classify per binop — stamp {foldable-width, is_int, is_float} on the value node at api_push (alongside the existing cached wide_kind), then read once in api_cg_binop; pass the decoded width into api_can_delay_int_arith/api_try_strength_reduce/api_try_collapse_binop_identity so they skip re-deriving it. Removes 6–9 alias-chases per integer binop (arith.c:29-107). Also drop the 4 i128/wide64 stack probes per op in kit_cg_int_binop (arith.c:1103-1121) for the i32/i64-native common case. |
~0.04–0.08 B | low (cached width must match api_int_like_width's int-vs-int-like distinction exactly) |
the correct form of "slim the fold ladder" — see §8 |
| C3 | Shrink ApiSValue 56→~24 B — move bitfield (12 B) + source_local off-node behind a sparse rider keyed by slot (PLACEs are the minority); make pinned a transient local in dup (its only reader). Lockstep with B3. |
~0.02–0.06 B | medium (riders must survive dup/swap/rot3/pop; bitfield + source-local corpus) | PERF Round 7 found the delayed-payload shrink neutral — do this for copy bandwidth, measure before committing; most invasive, land after C1/C2 |
| C4 | Designated-init the per-op memsets in api_alloc_temp_local / api_mem_for_lvalue (the idiom api_op_imm already uses, value.c:22-25). |
~0.01–0.025 B | very low | stacks with C1 |
Track C subtotal: ~0.07–0.18 B net new (excl. the F1 portion already counted).
7. Track D — NDT register discipline + descriptor + emit/objwrite
| # | Change | Payoff | Gate | Notes |
|---|---|---|---|---|
| D1 | F2 (MCEmitter devirtualization — direct/inline the monomorphic byte sink). | counted in F2 | byte-identical | the genuine indirection win; highest-frequency call in the backend |
| D2 | Replace the LRU register-cache with a fixed round-robin write-back cache — drop use_tick/last_use/reg_last_use/nd_touch_local and the linear nd_pick_cache_victim scan; keep the consumer-resident write-back mechanism the recent commits (f75874cb/3516b3d3) built — swap only the victim policy (native_direct_target.c:516-930). |
~5–10% of emit phase | run-correctness + determinism (changes reg selection → different bytes; compile sqlite twice + cmp, run 84|2, toy/parse/smoke) |
coarser eviction may cost a few emitted bytes — measure both ways |
| D3 | F3 (slim the NDT↔NativeTarget marshalling: NativeLoc 48 B → 12 B RegLoc register-only hooks + batch the cold frame-operand crossings; native_target.h:234,258,484-488). Keep the per-arch dispatch — it is real. |
counted in F3 | byte-identical (narrow hooks) / run-correctness (x64 fused mem-operand) | coordinate the NativeTarget struct change once across archs + the opt path |
| D4 | NDT reads F1's memo for nd_type_mem, and carries is64/size_log2 on the NativeLoc pad so the arch's loc_is_64/type_size32 stop re-querying (aa64/native.c:349,374). |
~0.02 B | byte-identical | depends on F1 |
| D5 | Zero-copy section write — buf_write_to_writer(Buf*, Writer*) walks chunks straight to the Writer, eliminating the buf_flatten temp-buffer second copy of all machine code (macho/emit.c:814-829, elf/emit.c:730-742). Stretch: a single contiguous geometric section buffer so mc_emit32 stores into the final image (full tcc shape) — larger lift, shared cursor with F3. |
~4–8 M (chunk-walk) up to ~8 M (contiguous) | byte-identical (test-elf test-macho test-link) |
decide the buffer type once with F3 |
| D6 | Index relocations by section at obj_finalize (stable bucketing) to kill the O(n_sections × n_relocs) rescan in object write (obj.c:1121-1128, macho/emit.c:538-550, elf/emit.c:468-479). A latent superlinear axis — grows with -ffunction-sections/literal fan-out. |
tens of M on section-heavy objects | byte-identical (stable buckets preserve order) | independent; a "bug" by PERF.md's rule |
| D7 | Single-pass symbol-table emission + chunk-walk the string table (drop the recount pass + the strtab flatten, macho/emit.c:392-534,854-857); add the rv64 if (mc->debug) guard aa_emit32 already has (riscv/native.c:81); mc_emit_words for multi-word idioms (F3). |
~0.01–0.02 B | byte-identical | mop-up, all in the 7× phase |
Track D subtotal: ~0.16–0.27 B (incl. the F2 portion).
8. The one resolved conflict — do NOT gate the fold ladder "off"
The CgTarget strand proposed gating the -O0 fold/strength-reduce/identity ladder off; the CG strand flagged this as a trap, and it is right:
The ladder at
arith.c:48-99is not a no-op at -O0. It const-folds immediates, strength-reduces, and collapses identities — which reduces emitted machine bytes. The o0-codesize track (Track B in the standings, 3.45×→1.71× object) depends on it. Gating it off would change emitted bytes, fail the byte-identity gate, and regress the worst-ratio emit phase — moving backward on the exact phase with the most headroom.
Resolution: keep the ladder's byte-producing effects; slim only the
decision cost. That is C2 (classify once, drop the delay-node bookkeeping
whose product is never read at -O0), not "turn it off." Any future "fuse the seam"
work must preserve the ladder's output and the untyped_values bitcast-elision
flag (arith.c:276). This is the one place a naive implementation shoots itself in
the foot — flagged here so it doesn't.
9. Sequencing & dependency graph
WAVE 0 (foundations, byte-identical, unblock everything):
F1 (type memo) ──> B1, C1, C4, D4
F2 (MCEmitter devirt + inline cursor) [the fake-vtable removal; F3's sink]
D6 (reloc index, independent superlinear fix)
WAVE 1 (high-leverage, mostly byte-identical):
F3/D3 (NDT↔NativeTarget marshalling slim) [lands in F2's inline emit]
C2 (classify-once binop) [independent of F1, but co-measure]
A1 (intern last-spelling cache)
B2 (PcgLvAux right-size), B4 (kw switch), B6 (convert early-out)
D5 (zero-copy section write), D7 (symtab/emit mop-up)
WAVE 2 (medium risk, lockstep pairs):
B3 + C3 (PcgSlot + ApiSValue shrink — MUST land together, symmetric stacks)
A2 (probe slot), A3 (pp_next_into — co-own fetch_tok with parser)
D2 (round-robin cache — switches to run-correctness gate)
WAVE 3 (highest risk, gate hard, do last & jointly):
A4 (Tok 16B — needs O(1) decode at pcg_set_loc, joint lex+parser)
B5 (binding-on-Sym cache)
A5 (TOK_NEWLINE / macro pre-copy)
D5-stretch (contiguous section buffer → emit into final image)
Re-profile (RELEASE best-of-7, tmp/projects/sqlite-amalg/sqlite3.c) after each
wave and re-rank — the phase shares shift as work is removed.
10. Gate strategy
- Byte-identical (the default): every change that restructures how work is
done without changing emitted bytes — F1, F2, F3, A1–A5, B1–B6, C1–C4, D1, D3,
D4, D5, D6, D7. Use
scripts/perf_identity_gate.sh(60-category golden-vs-candidate;-Etext,-gDWARF, objects, diagnostics). Lexer changes (A1–A5) additionally need the line-splice battery + a diagnostic line across a splice. - Run-correctness + determinism (only where bytes legitimately change): D2
(round-robin register selection) and the x64 fused mem-operand sub-case of F3/D3
(the register-only narrow hooks stay byte-identical). Compile sqlite twice and
cmpthe objects (self-identical across runs), plusmake test-toy test-parse test-smoke-x64 test-smoke-rv64and the sqlite end-to-end (84|2). - A/B before committing the uncertain ones: A2 (SSO width), A4 (decode cost), C3 (Round 7 found the analogous shrink neutral). Build them, measure on RELEASE, keep only if positive — the same discipline that caught the de-intern and 16 B-Tok dead ends.
- Measure on RELEASE (
make bin RELEASE=1,PROFILE=1for sampling), never the ASan default. Instructions via/usr/bin/time -lbest-of-7; trust instructions over wall/cycles.
10b. Measured results (first implementation pass)
Wave 0 + the reloc fix were implemented and measured on sqlite3.c -c
(best-of-7, instruction metric is deterministic to ±0.2M on a fixed binary;
each change byte-identical via the 60-category gate). The actuals came in 1–2
orders of magnitude below §2's estimates — the estimates assumed the hot paths
were fat, but they were already tight:
| change | estimate | measured | note |
|---|---|---|---|
| D6 (reloc index) | tens of M | −5.6M | also a real superlinear fix; grows with section count |
| F1 (type memo) | ~80–120M | −2.1M | cg_type_get already memoized size/align on the CgType; only the alias/enum recursion + double-calls remained |
| F2 (MCEmitter devirt) | ~5–10M | −1.2M | the indirect calls were single-target → branch-predicted to near-free; the win is removing the fn-ptr load |
| cumulative D6+F1+F2 | −8.9M (−0.41%) | 2160.7M → 2151.8M |
Two findings worth keeping:
- F2 must be a direct call, not
static inline. Inlining the hotemit_bytes/posinto the per-arch leaf emitters bloats them enough to break the inliner's own cascade (e.g.aa_emit32stops being inlined), a measured net +21M regression. Direct extern call removes the indirection without that effect. - The metric is deterministic, not noisy — so these small deltas are real,
and a per-operand tracking pass added elsewhere shows up immediately (a
concurrent in-tree
-O0transient-liveness/coalescing WIP was measured adding ~+26M to-fsyntax-onlywhile disabled, which is the actual high-value lever in this subsystem).
Implication for the remaining micro-levers (F3, the rest of Track A/B/C): they will each land in the single-digit-M range for substantial churn. The instruction gap to tcc (~1500M) is not closable by per-operation slimming; it needs the codesize track (fewer emitted bytes → less emit/objwrite/ assemble, the multiplicative lever) and the value-stack/coalescing redesign. Treat the micro-levers as cleanups that compound, not as the path to parity.
10c. Measured results — -O0 value-stack residency (the high-value lever)
§10b flagged the real lever as "the value-stack/coalescing redesign," not the
per-op micro-slimming. That redesign landed as four staged, run-correctness +
determinism-gated commits on the brief "stop homing intermediates in frame
slots: keep them register-resident on the value stack and spill only the live
set at calls." The diagnosis (from emitted -O0 asm) was three round-trip
classes, all instances of frame-home every intermediate + flush-everything at
every barrier:
| commit | change | sqlite3.c -c -O0 effect |
|---|---|---|
93aafda3 |
register-resident return — nd_ret sources the value from its live register and drops the cache without spilling (every cached local is dead at a return); was stur w8,[home]; ldur x0,[home] |
emitted 526,681 → 520,854; obj −0.9% |
2b1b03c2 |
assignment coalescing — pcg_store_void (no dup of a discarded assignment result) + kit_cg_store materializes a delayed arith/cmp straight into the destination local; kills the temp→local routing mov for x = <expr> |
compile −5.9M; emitted → 518,757 |
98b1f010 |
register-resident call args + spill-only-live-set — a provably-dead arg (CGCallDesc.arg_dead_mask from api_temp_dead) flows reg→arg-reg via the existing native_arg_shuffle; only the live-across set is spilled, not the whole cache |
compile −10.4M; emitted → 479,263; obj −6.4% |
f61d252b |
register-resident call result — the scalar result is cached (claimed reg, written by the post-call ret move) instead of stored to its home + reloaded | emitted → 474,752; obj → 2.29 MB |
Cumulative: compile 2,153M → 2,134M (−18.8M, −0.87%); emitted instructions
526,681 → 474,752 (−9.9%); object 2,501,224 → 2,293,504 B (−8.3%, now 1.09×
tcc); emitted-instruction count 1.54× → 1.39× tcc. All four byte-deterministic;
green across toy/parse/smoke-x64/smoke-rv64/cg-api/libc(musl+glibc)/opt, sqlite
e2e 84|2 at -O0 and -O1, and a clang-differential call probe (shared-arg-
live-across, >8-arg stack calls, varargs, struct sret/byval, recursion,
fn-pointer dispatch).
Two lessons:
- The emitted-code win (−9.9%) dwarfs the compile-time win (−0.87%). Removing a store+reload round-trip is mostly fewer emitted bytes; the compile-time saving is one fewer op emitted. This is squarely the codesize track (§2.1) — it compounds with emit/objwrite/assemble — and it moved the object to 1.09× tcc, far more than the per-op levers did.
- The arg round-trip was the single biggest class (−10.4M compile, −39.5K emitted): every call argument was computed into a register, spilled to its home, and reloaded into the ABI arg register, behind a flush-everything that also spilled non-live cached locals.
10d. Measured results — lazy transient frame homes (Stage 4)
The §10c-deferred follow-up landed: a plain transient temp
(CG_LOCAL_TRANSIENT && !address_taken && !memory_required) reserves no frame
slot at creation; nd_alloc_local leaves home = NONE and a single
ensure-accessor nd_home() mints the slot on first demand. Every NDT read of a
transient's home (loc/addr builders, the spill in nd_flush_local, the
scratch→home store, the nd_call capture-then-flush, nd_ret) routes through
nd_home; the va_arg/va_*/inline-asm boundaries call nd_home_operand on each
operand before crossing into arch code that reads home directly (the va_arg
destination and asm reg outputs are fresh store-target transients with no prior
spill — without this they hit NATIVE_FRAME_SLOT_NONE → "bad frame slot"). The
lazily-minted slots carry NATIVE_FRAME_SLOT_TRANSIENT, so they feed the
existing statement-boundary free-list reuse (nd_reclaim_temps) with no new
mechanism. Declared locals/params and address-taken/wide8 temps keep eager homes
(debug-loc + stable-address needs; they also spill at the first barrier anyway).
sqlite3.c -c -O0, arm64-mac, RELEASE, vs the copy+convert-coalescing baseline:
| metric | base | lazy | Δ |
|---|---|---|---|
| emitted instructions (text/4) | 457,115 | 455,546 | −0.34% |
| object bytes | 2,222,960 | 2,216,680 | −0.28% |
| mean frame size | 136 B | 112 B | −17.6% |
| frames > 256 B (need x29-relative addr) | 236 | 181 | −23.3% |
sub xNN, x29 (frame-address building) |
27,403 | 25,954 | −5.3% |
| functions with a stack frame | 2,630 | 2,605 | 25 frameless |
Exactly as §10c predicted: the emitted-instruction win is small (coalescing
already prevents most spills, so eager homes were near-benign for the instruction
count), but the frame shrinks materially — mean −17.6 %, a quarter fewer
frames past the ±256 unscaled window, and 25 functions become frameless. That
chips the Fix-B sub xNN,x29 residual (§ in PERF-TCC-GAP) by 5.3 % without
touching addressing. Byte-deterministic (two compiles cmp-identical);
compile-time within wall-clock noise. Green across
toy(1392)/parse-ok(3920)/parse-err(129)/cg-api/opt/smoke-x64/smoke-rv64/debug/
dwarf/libc(musl 18+glibc 9), plus a clang-differential probe (va_arg mixed
int/double lanes, inline-asm reg output, struct return, shared-arg-live-across).
11. What this is not
- Not Track 4. This keeps the typed value model and the seam; it does not adopt tcc's shared-mutable-token-slot single pass. It builds toward it (A1/A4/B5/D5 are the first bricks) but stops short.
- Not the codesize track. Track B (o0-codesize) shrinks emitted bytes and must run in parallel — it compounds with §3–§7 multiplicatively and is most of the remaining gap to parity after this plan (§2).
- Not parity by itself. ~3.35× → ~2.3× is the credible reach of this campaign; parity needs codesize compounding + iteration. Stated plainly so the goal stays honest.