commit 171985939029a12c44c272cc02d063a96fb3e599
parent 0bbb3b5f5425ee6015266f19c6b21dd092f6228c
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Wed, 10 Jun 2026 23:43:20 -0700
docs(perf): Round 5 per-stage breakdown + ranked next levers
Add the post-Round-5 self-time-by-stage table (codegen now dominates the
per-statement axes at 27-42%; parser dominates type-decl at 33%; the token pump +
memset that led before are cut) and the four concrete remaining levers, ranked:
the per-operand type-query gauntlet (wide_kind cache), the -O0 register-cache
scan, residual per-function memset (aa_plan_ret), and the Tok-by-value sret tax.
Adds scripts/cc_bench_stages.py, which buckets sample self-time into pipeline
stages.
Diffstat:
2 files changed, 204 insertions(+), 0 deletions(-)
diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md
@@ -38,6 +38,7 @@ to the shipped release, so timings are representative) and runs
| `scripts/cc_bench.sh` | Harness: build/locate kit, measure overhead, sweep, time kit + clang, correctness-check, sample. Writes `scaling.csv`. |
| `scripts/cc_bench_hot.sh` | Hotspot sampler: drives one axis at a time at a size tuned for a ~1.5–3 s run (the normal sweep is now too fast to sample), so `sample` captures a real call graph. Writes `build/bench/hot/<axis>/`. |
| `scripts/cc_bench_report.py` | Exponent fit + verdicts + clang ratios → `scaling.md`; parses `sample` call-trees → `hotspots.md`. |
+| `scripts/cc_bench_stages.py` | Buckets each function's self-time (from `build/bench/hot/<axis>/raw/`) into a pipeline **stage** (lexer / pp / intern / parser / types-abi / codegen / emit / arena / libc) so the breakdown is per-stage, not per-function. |
| `mk/maint.mk: bench-cc` | Builds the `PROFILE=1` kit and runs the harness. |
### Axes
@@ -119,6 +120,52 @@ changes every executable's LC_UUID/build-id bytes); `nd_grow_*` non-zeroing
inline-prefix cache (all measured *slower* or negligible for real short
identifiers). The residual top frame on the largest links is the image-id FNV.
+**Where the time goes now (post-Round-5 self-time, `scripts/cc_bench_stages.py`).**
+The bottleneck has *moved*: the token pump and the explicit-`memset` overhead that
+dominated before are cut, exposing **codegen** as the floor on the per-statement
+axes and the **parser** as the floor on `type-decl`. Self-time % by stage (each
+axis isolates one dimension):
+
+| stage | body-size | locals | ref-density | fn-count | type-decl | pp-macro `-E` |
+|---|--:|--:|--:|--:|--:|--:|
+| **codegen** (`nd_*` regcache, `api_*`, `cg_type_get`) | **42** | **31** | **27** | 19 | – | – |
+| **parser** (`parse_decl_specs`/`_declarator`, `reject_redef`) | 12 | 15 | 13 | 14 | **33** | – |
+| **emit** (`buf_write`, `aa_emit_*`, `obj_*`) | 9 | 8 | **18** | 12 | – | – |
+| **lexer** (`lex_next`, `scan_pp_number`) | 7 | 9 | 5 | 6 | 17 | 9 |
+| **pp** (`pp_next_raw`, `src_next_raw`, `subst_phase2`) | 5 | 5 | 6 | 7 | 15 | **62** |
+| **intern** (`pool_intern_slice`) | 3 | 7 | 6 | 10 | 10 | 13 |
+| **types/abi** (`c_abi_*`, `type_cg_*`) | 6 | 6 | 3 | 5 | 2 | – |
+| **libc memset/memmove** | 10 | 13 | 13 | **16** | 8 | 13 |
+| **libc io/vm** (`write`) | 3 | 1 | 4 | 4 | – | – |
+
+(`pp-macro` was a thin 69-sample run — noisier; its 62 % pp is the irreducible
+token-pump + substitution that *is* `-E`'s work.)
+
+**Possible gains — the concrete next levers, ranked.** All four are the
+higher-risk codegen/representation findings deliberately left this round:
+
+1. **Per-operand type-query gauntlet** (codegen, biggest compile-axis lever).
+ `kit_cg_int_binop` runs `api_i128_stack_top`×2 + `api_wide64_stack_top`×2 —
+ each a `cg_type_get` + `api_unalias_type` round-trip — on *every* int binop,
+ only to detect a rare wide case. These (`api_unalias_type`/`cg_type_get`) are
+ the top frames inside the 27–42 % codegen bucket. Fix: compute a small
+ `wide_kind` flag once when a value is pushed and store it on the value node;
+ the predicates become a load+compare. Touches the rv64/i128 split paths →
+ verify on `test-smoke-rv64`.
+2. **`-O0` register-cache scan** (`nd_pick_cache_victim`, `nd_dst_reg`). A
+ per-pressured-op linear scan over the 16 allocable regs that re-derefs
+ `nd_local(owner)->last_use` with a bounds-check each iteration. Fix: a flat
+ `reg_last_use[cls][reg]` mirror so the scan reads one array. Victim *selection*
+ can change regalloc → gate on run-correctness, not byte-identity.
+3. **Residual per-function `memset`** (still 8–16 %). The remaining explicit
+ zero-init is `aa_plan_ret`'s `arena_zarray(NativeCallPlanRet, 4)` (512 B/fn,
+ ≤1 used) and per-function arena scratch — right-size / lazily-zero them.
+4. **Token-pump sret tax** (deepest, pp-macro lever). `lex_next` / `src_next_raw`
+ return a 24-byte `Tok` by value, so each token round-trips through an x8
+ indirect-result stack slot. Converting the hottest readers to an out-pointer
+ (or shrinking `Tok` ≤ 16 B by packing `SrcLoc`) returns it in registers — but
+ `SrcLoc` is load-bearing frontend-wide, so bench-gate it in isolation.
+
### Round 4 — CPU work: linker output hashing + the codegen type-query path
> 2026-06-10, M1, clang-built `PROFILE=1` kit. With memory + IO handled
diff --git a/scripts/cc_bench_stages.py b/scripts/cc_bench_stages.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""Attribute `sample` self-time to compiler STAGES for the cc benchmark.
+
+Reads build/bench/hot/<axis>/raw/<axis>.sample.txt (a macOS `sample` call graph),
+reconstructs flat self-time per function (same as cc_bench_report.py), then
+buckets each function into a pipeline stage by name/image so we can see where the
+time goes per stage rather than per function.
+"""
+import os
+import re
+import sys
+from collections import defaultdict
+
+FRAME_RE = re.compile(r"^(?P<indent>[ |+!:]*)(?P<count>\d+)\s+(?P<rest>.*\S)\s*$")
+
+
+def parse_sample(text):
+ lines = text.splitlines()
+ start = None
+ for i, ln in enumerate(lines):
+ if ln.strip() == "Call graph:":
+ start = i + 1
+ break
+ if start is None:
+ return None, 0
+ indents, counts, child_sum, names, stack = [], [], [], [], []
+ started = False
+ for ln in lines[start:]:
+ if not ln.strip():
+ if started:
+ break
+ continue
+ m = FRAME_RE.match(ln)
+ if not m:
+ if started:
+ break
+ continue
+ started = True
+ indent = len(m.group("indent"))
+ cnt = int(m.group("count"))
+ rest = m.group("rest")
+ if " (in " in rest:
+ sym = rest.split(" (in ", 1)[0].strip()
+ img = rest.split(" (in ", 1)[1].split(")", 1)[0].strip()
+ else:
+ sym = rest.split(" ")[0].strip()
+ img = ""
+ idx = len(counts)
+ indents.append(indent); counts.append(cnt); child_sum.append(0)
+ names.append((sym, img))
+ while stack and indent <= indents[stack[-1]]:
+ stack.pop()
+ if stack:
+ child_sum[stack[-1]] += cnt
+ stack.append(idx)
+ self_by = defaultdict(float)
+ total = 0.0
+ for i in range(len(counts)):
+ st = max(0, counts[i] - child_sum[i])
+ sym, img = names[i]
+ if not sym:
+ continue
+ self_by[(sym, img)] += st
+ total += st
+ return self_by, total
+
+
+# Stage classifier: ordered (first match wins). Each entry is (stage, predicate).
+def classify(sym, img):
+ s = sym
+ if "libsystem" in img or "dyld" in img.lower() or img.endswith(".dylib"):
+ if "memset" in s or "memmove" in s or "bzero" in s or "memcpy" in s:
+ return "libc:mem"
+ if "write" in s or "read" in s or "mmap" in s or "munmap" in s or \
+ "madvise" in s or "reclaim" in s or "fault" in s or "kevent" in s:
+ return "libc:io/vm"
+ return "libc:other"
+ if s.startswith("DYLD-STUB"):
+ return "libc:mem" if "mem" in s else "libc:other"
+ # kit stages by symbol name
+ LEX = ("lex_next", "peek", "bump", "scan_pp_number", "ucn_len",
+ "skip_ws_and_comments", "lex_intern", "lex_fold_splices",
+ "lex_sync_splices", "scan_quoted", "pp_number_is_float",
+ "matches_include", "lex_here", "is_alnum", "is_alpha", "lex_loc")
+ PP = ("pp_next", "src_next", "subst_phase", "substitute_body", "pp_emit",
+ "push_buf", "src_push", "hs_add", "hs_register", "expand_", "preexpand",
+ "read_invocation", "peek_for_invoke", "fetch_tok", "macro_lookup",
+ "do_define", "do_directive", "pp_", "fdw_write", "w_str", "tv_grow",
+ "tv_push", "kit_writer")
+ INTERN = ("pool_intern", "fnv1a", "sym_eq", "kit_sym_intern", "kit_sym_str",
+ "table_rehash", "pool_slice", "entries_grow")
+ TYPES = ("type_ptr", "type_func", "type_array", "type_qualified",
+ "type_unqual", "type_cg", "type_struct", "type_intern", "alloc_type",
+ "alloc_struct", "c_abi", "abi_", "cache_get", "type_record",
+ "type_compatible", "type_composite", "AbiInfoMap", "CgRecordMap",
+ "TypeInternSet", "type_kind", "same_record", "type_promoted",
+ "type_is_", "type_tag")
+ PARSE = ("parse_", "declare_function", "scope_", "reject_", "expect_",
+ "tag_", "external_func", "advance", "ident_kw", "SymEntryMap",
+ "TagEntryMap", "ExternalFuncMap", "make_local", "p_", "perr",
+ "decl_", "sem_")
+ CG = ("nd_", "api_", "cg_", "native_", "kit_cg", "pcg_", "fold", "value",
+ "arith", "abi_cg", "native_argmove", "make_sv")
+ EMIT = ("aa_", "aa64_", "buf_", "obj_", "m_label", "emit_macho", "emit_",
+ "mc_", "debug_emit", "debug_func", "wr_u32", "macho_", "elf_",
+ "Sections_", "reloc", "obj_patch", "obj_write", "obj_pos",
+ "section", "kit_debug", "x64_", "rv_")
+ ARENA = ("arena_", "kit_arena", "c_pool", "heap_libc", "Pool", "buf_init")
+
+ def has(prefixes):
+ return any(s == p or s.startswith(p) for p in prefixes)
+
+ if has(LEX): return "lexer"
+ if has(INTERN): return "intern"
+ if has(PP): return "pp"
+ if has(TYPES): return "types/abi"
+ if has(PARSE): return "parser"
+ if has(CG): return "codegen"
+ if has(EMIT): return "emit"
+ if has(ARENA): return "arena/alloc"
+ return "other:" + s
+
+
+def main():
+ root = sys.argv[1] if len(sys.argv) > 1 else "build/bench/hot"
+ axes = sys.argv[2:] if len(sys.argv) > 2 else sorted(
+ d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d)))
+ for axis in axes:
+ raw = os.path.join(root, axis, "raw", axis + ".sample.txt")
+ if not os.path.exists(raw):
+ continue
+ text = open(raw, errors="replace").read()
+ if text.startswith("skipped:") or "Call graph:" not in text:
+ print(f"\n## {axis}: (no sample)")
+ continue
+ self_by, total = parse_sample(text)
+ if not self_by or total <= 0:
+ print(f"\n## {axis}: (unparseable)")
+ continue
+ stage = defaultdict(float)
+ topfn = defaultdict(list)
+ for (sym, img), v in self_by.items():
+ st = classify(sym, img)
+ stage[st] += v
+ topfn[st].append((v, sym))
+ print(f"\n## {axis} ({int(total)} samples)")
+ for st in sorted(stage, key=lambda k: stage[k], reverse=True):
+ pct = 100.0 * stage[st] / total
+ if pct < 0.5:
+ continue
+ tops = sorted(topfn[st], reverse=True)[:3]
+ tags = ", ".join("%s %.0f%%" % (n, 100.0 * c / total) for c, n in tops if c)
+ print(f" {pct:5.1f}% {st:14s} [{tags}]")
+
+
+if __name__ == "__main__":
+ main()