commit 0bbb3b5f5425ee6015266f19c6b21dd092f6228c
parent cd997d0fecd8f80e5c141064ec4c1c4b5ff2f26a
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Wed, 10 Jun 2026 22:59:37 -0700
docs(perf): record Round 5 (per-segment structural pass) + add hotspot sampler
Round 5 documents the cross-segment optimization pass (types/codegen/emit/link/
pp/lexer/core) and the methodology: hotspot-driven, byte-identical-gated, with
the auto-init experiment that proved memset is explicit (not -ftrivial-auto-var-
init). Adds scripts/cc_bench_hot.sh — drives one axis at a tuned large size so
macOS `sample` captures a real call graph now that the normal sweep runs sub-
400ms everywhere.
Diffstat:
2 files changed, 87 insertions(+), 0 deletions(-)
diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md
@@ -36,6 +36,7 @@ to the shipped release, so timings are representative) and runs
|------|------|
| `scripts/cc_bench_gen.py` | Axis catalog + synthetic source/object generator. `--list` prints the axes; `--axis/--n/--out` writes an instance + JSON manifest. **All axis definitions live here.** |
| `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`. |
| `mk/maint.mk: bench-cc` | Builds the `PROFILE=1` kit and runs the harness. |
@@ -75,6 +76,49 @@ Quick wire-check: `KIT_CC_BENCH_SIZES='8 16 32' KIT_CC_BENCH_SAMPLE=0 make bench
## Findings
+### Round 5 — per-segment structural pass (lexer / pp / parser-types / cg / emit / link)
+
+> 2026-06-10, M1, clang-built `PROFILE=1` kit. A measurement-driven sweep across
+> every segment, grounded in fresh hotspots (multi-second runs via
+> `scripts/cc_bench_hot.sh`, which drives one axis at a tuned large size so
+> macOS `sample` captures a real call graph — the normal sweep is now too fast
+> to sample). A decisive experiment first: rebuilding with
+> `-ftrivial-auto-var-init=zero` **off** left `memset` self-time unchanged, so
+> the pervasive #1 cost is *explicit* zero-init, attacked at the call sites (the
+> hardening flag stays). Every change is object/`-E`/`-g`/diagnostic
+> byte-identical to HEAD (the gate, since ASan can't see a premature-reuse or
+> uninit read) except the two genuine latent O(n²) fixes, which only change
+> *timing*. Headline (original HEAD → after, same machine + sizes):
+
+| segment | change | result |
+|---|---|---|
+| **types** | hash-cons structural types (ptr/array/func) — kills an O(types) per-derivation scan of one flat list; memoize ABI facts + record layouts | type-decl 1229→976 ms (−21%); record-heavy TU 904→41 ms (**22×**, was O(records²)); pointer-population stress 29.5 s→0.27 s (**108×**) |
+| **codegen** | build `NativeLoc`/`NativeAddr`/`MemAccess` via designated compound literals, not `memset`+stores (the ~64 B descriptor zeroing was the top self-time on cg-heavy axes) | body-size −8%, locals −8%, ref-density −4% |
+| **emit** | tail-chunk fast path in the chunked buffer; batch the aa64 prologue patch + 32-NOP reserve into one op each (x64 already did); skip per-instruction `obj_pos` without `-g` | fn-count 1056→752 ms (−29%) |
+| **link** | per-section delta table + a `link_sec_id→MSec*` index replace two per-section rescans of the whole reloc+sym vectors in the Mach-O finalizer | obj-count restored to LINEAR; 16384 objs 1045→379 ms (2.76×, gap widens with N) |
+| **pp** | drop the degenerate per-token hideset vector; batch `-E` output into a local buffer; pre-size the substitution token vectors | pp-macro `-E` 1316→985 ms (−25%) |
+| **lexer** | **redesign**: fold translation-phase-2 line splices out once up front so the scanner walks a clean buffer with plain direct indexing — no per-byte splice re-test in `peek`/`bump`; splice-free input is zero-copy, spliced input keeps exact line/col via a fold-offset array | body-size −7%, type-decl −9%, pp-macro −6%, fn-count −4.5% on top of the above |
+| **core** | hoist the table/entries bases out of the per-identifier intern probe | small, free, byte-identical |
+
+Net kit/clang `-O0` (lower = faster, all axes LINEAR): fn-count 0.12→0.08×,
+body-size 0.33→0.27×, **type-decl 0.68→0.55×** (was the weakest compile axis),
+pp-macro 0.48→0.37×, global-decl 0.42→0.34×, ref-density 0.08→0.07×, obj-count
+0.26→0.23×. pp-include (file-I/O bound) and symbol-count (image-byte bound)
+unchanged.
+
+**Consistency cleanup (per review):** added `KIT_HASHSET_DEFINE` (open-addressed
+set with custom hash+eq) to `kit/support/hashmap.h` for the structural type
+intern, routed the ABI/record memos through the existing `KIT_HASHMAP_DEFINE`,
+and gave `Pool` one shared arena-heap facade (`Pool.arena_heap`) so all per-pool
+maps — including the parser's scope/tag/extern indexes, migrated off their
+duplicate local facade — get arena lifetime with no teardown.
+
+**Deliberately deferred.** image-id FNV→HW-SHA (a real symbol-count win but
+changes every executable's LC_UUID/build-id bytes); `nd_grow_*` non-zeroing
+(uninit-read risk without MSan); word-at-a-time intern hash / lower load factor /
+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.
+
### 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_hot.sh b/scripts/cc_bench_hot.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Targeted hotspot sampler: drive cc_bench.sh one axis at a time at a size tuned
+# for a ~1.5-3s run, so macOS `sample` captures a reliable call graph (it needs
+# >=1s). The normal sweep tops out under 400ms now (Rounds 1-4), so sampling is
+# skipped there. Output: build/bench/hot/<axis>/{raw/<axis>.sample.txt,scaling.*}.
+#
+# Usage: KIT=build/bench/kit bash scripts/cc_bench_hot.sh [axis ...]
+set -uo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+KIT="${KIT:-$ROOT/build/bench/kit}"
+
+# Per-axis single big size tuned for ~1.5-3s at the per-unit ns measured in the
+# normal sweep. Two points where cheap so the reporter can still fit an exponent.
+declare -A SIZES=(
+ [fn-count]="256000 512000"
+ [body-size]="512000 1024000"
+ [global-decl]="1024000 2048000"
+ [type-decl]="512000 1024000"
+ [locals-per-fn]="512000 1024000"
+ [pp-macro]="512000 1024000"
+ [pp-include]="64000 128000"
+ [ref-density]="256000 512000"
+ [symbol-count]="1024000 2048000"
+ [obj-count]="4096 8192"
+)
+
+AXES="${*:-fn-count body-size global-decl type-decl locals-per-fn pp-macro pp-include ref-density symbol-count obj-count}"
+for axis in $AXES; do
+ sizes="${SIZES[$axis]:-}"
+ [ -z "$sizes" ] && { echo "skip unknown axis $axis"; continue; }
+ echo "===== hot: $axis sizes=$sizes ====="
+ KIT="$KIT" \
+ KIT_CC_BENCH_OUT="$ROOT/build/bench/hot/$axis" \
+ KIT_CC_BENCH_AXES="$axis" \
+ KIT_CC_BENCH_SIZES="$sizes" \
+ KIT_CC_BENCH_REPEATS=2 \
+ KIT_CC_BENCH_SKIP_CLANG=1 \
+ KIT_CC_BENCH_MAX_MS=0 \
+ KIT_CC_BENCH_SAMPLE=1 \
+ KIT_CC_BENCH_SAMPLE_MIN_MS=800 \
+ bash "$ROOT/scripts/cc_bench.sh"
+done
+echo "===== hot sampling done ====="