kit

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

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:
Mdoc/plan/PERF.md | 47+++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/cc_bench_stages.py | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()