kit

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

commit 7336010e1c2df57fdc23b794f497d361843c796a
parent bd148cea9313867a40abbbf442aaa29591a41d9c
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Wed, 10 Jun 2026 17:05:19 -0700

perf(bench): add -O0 compile+link scaling benchmark + hotspot sampler

make bench-cc builds a profileable release kit (new PROFILE=1: -O2 with -g and
no -Wl,-S strip, so codegen is unchanged but `sample` can attribute hotspots)
and sweeps each input axis over a geometric size series, fitting a scaling
exponent per axis (~1.0 = linear goal, >=1.4 = O(n^2) hotspot) against a
clang -O0 reference, with macOS `sample` self-time hotspot tables.

- scripts/cc_bench_gen.py: axis catalog + synthetic C generator (single source
  of truth for the axes).
- scripts/cc_bench.sh: harness -- overhead baseline, best-of-N timing, adaptive
  wall-time cap, clang/system-ld reference, per-axis sampling.
- scripts/cc_bench_report.py: power-law fit + sample call-tree -> self-time.
- mk/maint.mk: bench-cc target. mk/flags.mk: PROFILE=1 switch.
- doc/plan/PERF.md (+ README index): methodology and the findings that drove the
  O(n^2) -> O(1) fixes.

Output lands under build/bench/cc/ (gitignored).

Diffstat:
Adoc/plan/PERF.md | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdoc/plan/README.md | 1+
Mmk/flags.mk | 14++++++++++++--
Mmk/maint.mk | 10+++++++++-
Ascripts/cc_bench.sh | 349+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/cc_bench_gen.py | 309+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/cc_bench_report.py | 353+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1177 insertions(+), 3 deletions(-)

diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md @@ -0,0 +1,144 @@ +# Performance: the fastest C compiler + +**Goal.** kit should be *the fastest* C compiler at `-O0`. Generated code quality +is irrelevant; only correctness and **compile+link throughput** matter. The +structural bet is already in place — a single-pass, no-AST C frontend with +single-pass code emission and patch-ups, and a format-neutral linker — so the +remaining work is measurement-driven: find where the time goes and keep every +input dimension scaling **linearly**. + +A superlinear axis is never acceptable here: it means some inner step is +O(n²)-ish (a linear scan that runs once per item, a table that rebuilds, a list +that is re-walked). Those are the bugs this benchmark exists to surface. + +## The benchmark + +`make bench-cc` builds a profileable release kit (clang, `-O2`, but `PROFILE=1` +keeps `-g` + frame pointers and skips the `-Wl,-S` strip — codegen is identical +to the shipped release, so timings are representative) and runs +`scripts/cc_bench.sh`, which: + +1. **Sweeps each input axis** over a geometric size series, timing `kit cc -O0 + -c` / `-E` (compile) and `kit ld` (link) best-of-N. Link axes pre-build the + object set untimed, so only the linker is on the clock. +2. **Fits a scaling exponent** per axis (`scripts/cc_bench_report.py`): on + overhead-subtracted times, least-squares `log t = log a + p·log n`. `p ≈ 1.0` + is the goal; `p ≥ 1.4` is flagged `SUPERLINEAR`. +3. **Samples hotspots** on the largest input of each axis with macOS `sample` + (no sudo; arm64 frame pointers make `-O2` stacks reliable), reduced to a flat + **self-time per function** table. +4. Records a **clang `-O0` reference** (same sources for compile; the same kit + objects through the system linker for link) to quantify the gap to "fastest". + +### Components (single source of truth) + +| File | Role | +|------|------| +| `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_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. | + +### Axes + +Each varies *one* dimension; the rest are held fixed. + +**Compile** (`kit cc -O0 -c`, or `-E` for the preprocessor axes): +`fn-count` (functions) · `body-size` (statements in one function) · `global-decl` +(file-scope globals) · `type-decl` (distinct struct typedefs) · `locals-per-fn` +(locals in one function) · `pp-macro` (macro expansions) · `pp-include` (distinct +headers) · `ref-density` (distinct extern calls in one function). + +**Link** (`kit ld`, objects pre-built untimed): +`obj-count` (object files) · `symbol-count` (total symbols/relocs, object count +fixed). + +### Output (`build/bench/cc/`) + +- `scaling.md` — linearity summary (exponent + verdict + kit/clang ratio per + axis) and per-axis detail (per-unit time, net-of-overhead, clang comparison). +- `hotspots.md` — top self-time functions per axis. Read these for the axes + flagged `SUPERLINEAR`: the dominant function *is* the O(n²). +- `scaling.csv` — raw rows; `raw/<axis>.sample.txt` — full `sample` reports; + `logs/` — per-run stdout/stderr. + +### Env knobs + +`KIT` (binary) · `CLANG` · `KIT_CC_BENCH_AXES` (subset) · `KIT_CC_BENCH_SIZES` +(override series) · `KIT_CC_BENCH_REPEATS` (default 3) · `KIT_CC_BENCH_MAX_MS` +(adaptive cap: stop growing an axis past this wall-time, default 4000 — keeps the +run bounded even when an axis is badly superlinear) · `KIT_CC_BENCH_SKIP_CLANG` · +`KIT_CC_BENCH_SAMPLE` (default 1) · `KIT_CC_BENCH_DTRACE` (flat histogram; needs +sudo) · `KIT_CC_BENCH_OUT`. + +Quick wire-check: `KIT_CC_BENCH_SIZES='8 16 32' KIT_CC_BENCH_SAMPLE=0 make bench-cc`. + +## Findings + +> 2026-06-10, M1 (8-core), clang-built `PROFILE=1` release kit, best-of-3. +> **All superlinear axes have been fixed** — every axis is now LINEAR. The table +> shows the exponent before → after the fixes below, and kit's speed vs clang +> `-O0` at the largest measured size. + +| axis | exponent before → after | kit/clang before → after | was | +|------|:-----------------------:|:------------------------:|-----| +| type-decl | 2.14 → **1.09** | 70.1× slower → **0.62× (1.6× faster)** | `parse_c` typedef intern | +| fn-count | 1.92 → **1.04** | 7.5× slower → **0.33× (3× faster)** | `declare_function` scan | +| ref-density | 1.90 → **1.04** | 6.0× slower → **0.16× (6× faster)** | `declare_function`+`scope_lookup` | +| locals-per-fn | 1.45 → **0.96** | → **0.04× (25× faster)** | `make_local_aligned` scan | +| global-decl | 1.44 → **1.01** | 24.4× → 5.3× slower (linear) | global-scope scan | +| pp-include | 1.25 → **1.06** | 1.8× slower | file-I/O bound | +| body-size | 0.98 → **1.03** | 1.30× slower | (already linear) | +| pp-macro | 1.00 → **1.01** | 0.05× (22× faster) | `-E` output I/O | +| obj-count | linear | **0.27× (3.7× faster than ld64)** | linker | +| symbol-count | 1.03 → **1.03** | 1.45× slower (linear) | linker | + +The remaining axes where kit is slower than clang (global-decl 5.3×, symbol-count +1.45×, body-size 1.3×, pp-include 1.8×) are all **linear** — constant-factor +codegen/IO differences, not scaling problems. On every axis that was O(n²), kit +went from 6–70× slower to **as fast as or faster than clang**. + +### Fixes applied (all landed) + +The root cause was systemic: every place that added N entries to a *symbol / +scope / type* table did a **linear scan per item**. Three changes: + +1. **C-frontend scope/tag/external indexes** (`lang/c/parse/parse.c`, + `parse_priv.h`). `Scope.entries`, `Scope.tags`, and the file-scope + `external_funcs` list were LIFO linked lists scanned linearly by + `scope_lookup` / `scope_lookup_current` / `tag_lookup` / `external_func_*` + (and `make_local_aligned`'s redefinition check). Each now carries a generated + `KIT_HASHMAP_DEFINE` index keyed on the interned `Sym`. Scopes keep the list + for ordering and only build the index past a small threshold (tiny block + scopes pay nothing); indexes allocate through an arena-heap facade so there is + no teardown. → fixed type-decl, fn-count, ref-density, global-decl, locals. +2. **Chunked-buffer random access** (`src/core/buf.c`, `buf.h`). `buf_patch` / + `buf_read` walked the chunk list from the head — O(N_chunks) — so single-pass + emission patch-ups over a multi-MB `.text` were O(n²) (this was the residual + that kept fn-count at 1.18 after fix #1, 70% of its self-time). Added a sorted + chunk-start directory + binary search → O(log N_chunks); append stays O(1). +3. **Object section find-or-create** (`src/obj/obj.c`). `obj_section` linearly + scanned all sections to dedup by `(name, kind)`; added a composite-key + `SecKeyIndex` so the find is O(1). Latent today (kit's `cc` emits one `.text`) + but O(n²) under `-ffunction-sections` / many distinct sections. + +Verified: `test-parse` 3880×4 modes, `toy` 1392, all `cg_*`, `test-link`, +`test-elf`/`macho`, `test-debug`/`dwarf`, `test-smoke-x64`/`rv64` — all pass +under ASan/UBSan; benchmark exponents all ≤ ~1.1. + +### Deliberately left (genuinely bounded, not O(n²) in practice) + +- **COFF weak-alias** lookup (`obj_set/get_weak_alias`): a linear scan, but only + over the handful of `WEAK_EXTERNAL`s in an import-archive member — does not + scale with compiler input. +- **Linker COMDAT atom fallback** (`link_resolve.c`): O(natom) only for + COMDAT/GROUP symbols (rare in C); the normal path is already an O(1) hash hit. + +### Resolved out-of-band + +The macOS system linker (`ld64`) previously rejected kit's Mach-O objects +(`empty CIE`); it now accepts them since kit stops emitting a raw `__eh_frame` on +Mach-O (`KitTargetSpec.emits_eh_frame`, `src/api/core.c`). The benchmark's link +axes therefore now carry a real system-`ld` reference: **kit's linker is ~3.7× +faster than ld64** at 1024 objects (40 ms vs 146 ms; ld64 has ~123 ms of fixed +overhead). diff --git a/doc/plan/README.md b/doc/plan/README.md @@ -10,6 +10,7 @@ shrinks to whatever remains open. |---------|-------|------------| | [RELEASE.md](RELEASE.md) | Cross-cutting initial-release punchlist: release scope, deferred features, and per-subsystem completion/validation items. | — | | [OPTIMIZER.md](OPTIMIZER.md) | Completing the O2 SSA mid-end, expanded inlining, -O0/-O1 performance work, machine register-constraint improvements. | [../OPT.md](../OPT.md) | +| [PERF.md](PERF.md) | Making kit the fastest `-O0` C compiler: the compile+link scaling benchmark (`make bench-cc`), per-axis linearity goals, hotspot sampling, and the open superlinear-axis fixes. | — | | [LINKER.md](LINKER.md) | Incremental linking: the file-based object-link redesign and remaining non-ELF format coverage. | [../LINK.md](../LINK.md) | | [JIT.md](JIT.md) | Function-level hot reload, Go-runtime-style codegen support, and remaining JIT host-portability work. | [../JIT.md](../JIT.md) | | [DEBUG.md](DEBUG.md) | The Windows debugger host adapter, x64/rv64 displaced single-step, profiling, and DWARF gaps. | [../DBG.md](../DBG.md), [../DWARF.md](../DWARF.md) | diff --git a/mk/flags.mk b/mk/flags.mk @@ -11,10 +11,20 @@ ifeq ($(RELEASE),1) HOST_OPTFLAGS ?= -O2 HOST_MODE_CPPFLAGS = -DNDEBUG HOST_MODE_CFLAGS = -ffunction-sections -fdata-sections +# PROFILE=1: an optimized build that stays profileable. Keeps -g/DWARF + frame +# pointers and skips the -Wl,-S debug strip so sampling profilers (e.g. +# `make bench-cc`) can attribute hotspots to functions and source lines. Codegen +# is unchanged (-O2), so timings still reflect the shipped release. +ifeq ($(PROFILE),1) +HOST_MODE_CFLAGS += -g -fno-omit-frame-pointer +endif ifeq ($(HOST_OS),darwin) -HOST_MODE_LDFLAGS = -Wl,-dead_strip -Wl,-S +HOST_MODE_LDFLAGS = -Wl,-dead_strip else -HOST_MODE_LDFLAGS = -Wl,--gc-sections -Wl,-S +HOST_MODE_LDFLAGS = -Wl,--gc-sections +endif +ifneq ($(PROFILE),1) +HOST_MODE_LDFLAGS += -Wl,-S endif else HOST_OPTFLAGS ?= -O0 diff --git a/mk/maint.mk b/mk/maint.mk @@ -3,7 +3,7 @@ # Developer maintenance targets: source formatting, the clangd compilation # database, optimizer benchmarking, and clean. -.PHONY: format compile-commands bench-opt code-size clean +.PHONY: format compile-commands bench-opt bench-cc code-size clean # Format only the .c/.h files changed in the working tree (staged, unstaged, or # new/untracked), restricted to the formatted roots and excluding test/pp. When @@ -32,6 +32,14 @@ bench-opt: $(MAKE) RELEASE=1 bin @KIT='$(abspath build/release/kit)' bash scripts/opt_bench.sh +# -O0 C compile+link throughput/scaling benchmark + hotspot sampling. Builds a +# release kit with clang (-O2, but with -g and without the symbol strip so +# `sample` can attribute hotspots) into build/bench/kit, then sweeps each input +# axis and fits a scaling exponent. Design + findings: doc/plan/PERF.md. +bench-cc: + $(MAKE) RELEASE=1 PROFILE=1 CC=clang BUILD_DIR=build/bench bin + @KIT='$(abspath build/bench/kit)' bash scripts/cc_bench.sh + # Regenerate doc/CODE_SIZE.md: per-component cloc line counts plus a binary # code-size section attributed from the release linker map. Requires cloc. # The map step relinks the release objects explicitly (not via libkit.a) so the diff --git a/scripts/cc_bench.sh b/scripts/cc_bench.sh @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +# -O0 C compile + link scaling benchmark for kit. +# +# Goal: kit is meant to be the *fastest* C compiler. This harness sweeps each +# input axis (see scripts/cc_bench_gen.py --list) over a geometric size series, +# times `kit cc -O0 -c/-E` (compile) and `kit ld` (link) best-of-N, records a +# clang -O0 reference for context, and samples the largest input per axis to +# produce a flat self-time hotspot table. The reporter (cc_bench_report.py) fits +# a scaling exponent per axis: ~1.0 is the linear goal, >=1.4 is an O(n^2) trap. +# +# Output under build/bench/cc/: scaling.csv (raw), plus scaling.md + hotspots.md +# written by the reporter. +# +# Env knobs: +# KIT kit binary (default build/bench/kit, else build/release/kit) +# CLANG clang binary (default clang) +# KIT_CC_BENCH_AXES space-separated subset of axes (default: all) +# KIT_CC_BENCH_SIZES override size series for ALL axes (default: per-axis) +# KIT_CC_BENCH_REPEATS best-of-N timing repeats (default 3) +# KIT_CC_BENCH_SKIP_CLANG 1 to skip the clang reference (default 0) +# KIT_CC_BENCH_SAMPLE 1 to sample hotspots (default 1) +# KIT_CC_BENCH_SAMPLE_AXES subset of axes to sample (default: all run) +# KIT_CC_BENCH_SAMPLE_MIN_MS skip sampling if max-size run is faster (default 400) +# KIT_CC_BENCH_DTRACE 1 to use dtrace flat histogram instead of sample (needs sudo) +# KIT_CC_BENCH_OUT output root (default build/bench/cc) +# KIT_SYSROOT sysroot passed to kit (default: macOS SDK via xcrun) +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +GEN="$ROOT/scripts/cc_bench_gen.py" +REPORT="$ROOT/scripts/cc_bench_report.py" + +KIT="${KIT:-}" +if [ -z "$KIT" ]; then + if [ -x "$ROOT/build/bench/kit" ]; then KIT="$ROOT/build/bench/kit" + else KIT="$ROOT/build/release/kit"; fi +fi +CLANG="${CLANG:-clang}" +OUT_DIR="${KIT_CC_BENCH_OUT:-$ROOT/build/bench/cc}" +REPEATS="${KIT_CC_BENCH_REPEATS:-3}" +SKIP_CLANG="${KIT_CC_BENCH_SKIP_CLANG:-0}" +DO_SAMPLE="${KIT_CC_BENCH_SAMPLE:-1}" +SAMPLE_MIN_MS="${KIT_CC_BENCH_SAMPLE_MIN_MS:-400}" +USE_DTRACE="${KIT_CC_BENCH_DTRACE:-0}" +# Adaptive cap: once a size's kit time exceeds this, stop growing that axis. Keeps +# the run bounded even when an axis is badly superlinear (we still collect the +# smaller points needed to fit + flag the exponent). 0 disables. +MAX_MS="${KIT_CC_BENCH_MAX_MS:-4000}" + +# kit needs a sysroot to find the host libc for linking. Match opt_bench.sh. +KIT_SYSROOT="${KIT_SYSROOT:-}" +if [ -z "$KIT_SYSROOT" ] && [ "$(uname -s)" = "Darwin" ] && command -v xcrun >/dev/null 2>&1; then + KIT_SYSROOT="$(xcrun --show-sdk-path 2>/dev/null || true)" +fi +KIT_SYSROOT_ARGS=() +[ -n "$KIT_SYSROOT" ] && KIT_SYSROOT_ARGS=(--sysroot "$KIT_SYSROOT") +# Homebrew clang ships a default config pointing at a (often nonexistent) SDK; +# pass the real one so the reference build is clean. Compile needs no headers +# (generated sources have none), but this silences the stale-sysroot warning. +CLANG_SYSROOT_ARGS=() +[ -n "$KIT_SYSROOT" ] && CLANG_SYSROOT_ARGS=(-isysroot "$KIT_SYSROOT") + +CSV="$OUT_DIR/scaling.csv" +GEN_DIR="$OUT_DIR/gen" +RAW_DIR="$OUT_DIR/raw" +LOG_DIR="$OUT_DIR/logs" + +if [ ! -x "$KIT" ]; then + printf 'cc-bench: kit binary not found/executable: %s\n' "$KIT" >&2 + printf 'cc-bench: run `make bench-cc` (builds a profiling kit) or set KIT=...\n' >&2 + exit 2 +fi + +rm -rf "$GEN_DIR" "$RAW_DIR" "$LOG_DIR" +mkdir -p "$OUT_DIR" "$GEN_DIR" "$RAW_DIR" "$LOG_DIR" + +# --------------------------------------------------------------------------- +# Timing: run a command, capturing rc + wall-clock ms of exactly the child. +# python times only the subprocess, so its own startup is not counted. +# --------------------------------------------------------------------------- +time_cmd() { # $1=out $2=err ; rest=cmd ; sets TIME_RC, TIME_MS + local out="$1" err="$2"; shift 2 + local line + line=$(python3 - "$out" "$err" "$@" <<'PY' +import subprocess, sys, time +out, err = sys.argv[1], sys.argv[2] +cmd = sys.argv[3:] +with open(out, "wb") as o, open(err, "wb") as e: + t0 = time.monotonic_ns() + rc = subprocess.call(cmd, stdout=o, stderr=e) + t1 = time.monotonic_ns() +print("%d %.3f" % (rc, (t1 - t0) / 1e6)) +PY +) + TIME_RC="${line%% *}" + TIME_MS="${line#* }" +} + +min_ms() { # echo min of $1,$2 (either may be "" meaning unset) + awk -v a="$1" -v b="$2" 'BEGIN{ + if (a=="") {print b; exit} + if (b=="") {print a; exit} + print (b+0 < a+0) ? b : a + }' +} + +csv_field() { printf '%s' "$1" | sed 's/"/""/g; s/^/"/; s/$/"/'; } +record() { # axis phase tool mode n unit time_ms status exit note + { csv_field "$1"; printf ','; csv_field "$2"; printf ','; csv_field "$3"; printf ','; + csv_field "$4"; printf ','; csv_field "$5"; printf ','; csv_field "$6"; printf ','; + csv_field "$7"; printf ','; csv_field "$8"; printf ','; csv_field "$9"; printf ','; + csv_field "${10}"; printf '\n'; } >>"$CSV" +} + +# best-of-N: run "$@" REPEATS times, set BEST_MS (min over reps) + LAST_RC. +best_of() { # out_base, cmd... + local out_base="$1"; shift + local rep best="" + LAST_RC=0 + for rep in $(seq 1 "$REPEATS"); do + time_cmd "$out_base.$rep.out" "$out_base.$rep.err" "$@" + LAST_RC="$TIME_RC" + [ "$TIME_RC" -ne 0 ] && { best=""; return 1; } + best="$(min_ms "$best" "$TIME_MS")" + done + BEST_MS="$best" + return 0 +} + +# --------------------------------------------------------------------------- +# kit / clang command builders (mode -> argv). kit links via `kit ld ... -l c`. +# --------------------------------------------------------------------------- +kit_compile_cmd() { KIT_CMD=("$KIT" cc -O0 -c "$1" -o "$2"); } +kit_preprocess_cmd() { KIT_CMD=("$KIT" cc -O0 -E "$@"); } # args: [-I dir] src -o out +kit_link_cmd() { KIT_CMD=("$KIT" ld "$@" -l c "${KIT_SYSROOT_ARGS[@]}"); } # args: objs... -o app +clang_compile_cmd() { CLANG_CMD=("$CLANG" "${CLANG_SYSROOT_ARGS[@]}" -O0 -c "$1" -o "$2"); } +clang_preprocess_cmd() { CLANG_CMD=("$CLANG" "${CLANG_SYSROOT_ARGS[@]}" -O0 -E "$@"); } +clang_link_cmd() { CLANG_CMD=("$CLANG" "${CLANG_SYSROOT_ARGS[@]}" "$@"); } # kit objs... -o app + +# --------------------------------------------------------------------------- +# Axis runners. Each generates the instance, times kit (+clang ref), checks +# correctness, records rows. Sets KIT_BEST_MS for the sampling gate. +# --------------------------------------------------------------------------- +read_manifest() { # $1=manifest.json $2=key -> echo value (python) + python3 -c 'import json,sys; v=json.load(open(sys.argv[1])).get(sys.argv[2],""); print(v if not isinstance(v,list) else " ".join(map(str,v)))' "$1" "$2" +} + +run_compile_axis() { # axis mode unit n dir + local axis="$1" mode="$2" unit="$3" n="$4" dir="$5" + local src="$dir/gen.c" incdir status="OK" + incdir="$(read_manifest "$dir/manifest.json" incdir)" + local inc_args=(); [ -n "$incdir" ] && inc_args=(-I "$dir/$incdir") + local logb="$LOG_DIR/$axis.n$n.kit" + + # kit + if [ "$mode" = preprocess ]; then + kit_preprocess_cmd "${inc_args[@]}" "$src" -o "$dir/gen.i" + else + kit_compile_cmd "$src" "$dir/gen.o" + fi + KIT_BEST_MS="" + if best_of "$logb" "${KIT_CMD[@]}"; then + # correctness: output produced + local artifact="$dir/gen.o"; [ "$mode" = preprocess ] && artifact="$dir/gen.i" + [ -s "$artifact" ] || status="OUTPUT_FAIL" + KIT_BEST_MS="$BEST_MS" + else + status="KIT_FAIL" + fi + record "$axis" compile kit "$mode" "$n" "$unit" "${BEST_MS:-NA}" "$status" "$LAST_RC" "$logb.1.err" + + # clang reference + if [ "$SKIP_CLANG" != 1 ]; then + local clogb="$LOG_DIR/$axis.n$n.clang" cstatus="OK" + if [ "$mode" = preprocess ]; then + clang_preprocess_cmd "${inc_args[@]}" "$src" -o "$dir/gen.clang.i" + else + clang_compile_cmd "$src" "$dir/gen.clang.o" + fi + if best_of "$clogb" "${CLANG_CMD[@]}"; then :; else cstatus="CLANG_FAIL"; fi + record "$axis" compile clang "$mode" "$n" "$unit" "${BEST_MS:-NA}" "$cstatus" "$LAST_RC" "$clogb.1.err" + fi +} + +run_link_axis() { # axis unit n dir + local axis="$1" unit="$2" n="$3" dir="$4" status="OK" + local prebuild link_order expected logb="$LOG_DIR/$axis.n$n.kit" + prebuild="$(read_manifest "$dir/manifest.json" prebuild)" + link_order="$(read_manifest "$dir/manifest.json" link_order)" + expected="$(read_manifest "$dir/manifest.json" expected_exit)" + + # Pre-build kit objects (UNTIMED). Shared by both linkers. + local s o objs=() + for s in $prebuild; do + o="$dir/${s%.c}.o" + if ! "$KIT" cc -O0 -c "$dir/$s" -o "$o" 2>"$LOG_DIR/$axis.n$n.prebuild.err"; then + record "$axis" link kit link "$n" "$unit" NA PREBUILD_FAIL 1 "$LOG_DIR/$axis.n$n.prebuild.err" + KIT_BEST_MS=""; return + fi + done + for o in $link_order; do objs+=("$dir/$o"); done + + # kit ld (TIMED): relink the prebuilt objects best-of-N. + kit_link_cmd "${objs[@]}" -o "$dir/app" + KIT_BEST_MS="" + if best_of "$logb" "${KIT_CMD[@]}"; then + "$dir/app" >/dev/null 2>&1; local got=$? + [ "$got" = "$expected" ] || status="OUTPUT_FAIL($got!=$expected)" + KIT_BEST_MS="$BEST_MS" + else + status="KIT_FAIL" + fi + record "$axis" link kit link "$n" "$unit" "${BEST_MS:-NA}" "$status" "$LAST_RC" "$logb.1.err" + + # System-ld reference: link the SAME kit objects through the clang driver. + # (kit's Mach-O objects no longer carry a raw __eh_frame, so ld64 accepts + # them.) If the system linker still rejects an object, it's recorded N/A and + # the run continues. + if [ "$SKIP_CLANG" != 1 ]; then + local clogb="$LOG_DIR/$axis.n$n.clang" cstatus="OK" + clang_link_cmd "${objs[@]}" -o "$dir/app.clang" + if best_of "$clogb" "${CLANG_CMD[@]}"; then + "$dir/app.clang" >/dev/null 2>&1 + local cgot=$? + [ "$cgot" = "$expected" ] || cstatus="OUTPUT_FAIL($cgot!=$expected)" + else + cstatus="CLANG_LINK_NA" + fi + record "$axis" link clang link "$n" "$unit" "${BEST_MS:-NA}" "$cstatus" \ + "$LAST_RC" "$clogb.1.err" + fi +} + +# --------------------------------------------------------------------------- +# Hotspot sampling: run kit on the largest input in the background, sample by +# PID until it exits. No sudo. dtrace path (flat histogram) is opt-in. +# --------------------------------------------------------------------------- +sample_cmd() { # axis raw_path cmd... + local axis="$1" raw="$2"; shift 2 + if [ "$USE_DTRACE" = 1 ]; then + # Flat user-function histogram for exactly the target process. + sudo dtrace -x ustackframes=64 -o "$raw" \ + -n 'profile-997 /pid == $target/ { @[ufunc(arg1)] = count(); } END { printa(@); }' \ + -c "$(printf '%q ' "$@")" >/dev/null 2>"$raw.dtrace.err" || \ + printf 'cc-bench: dtrace sample failed for %s (see %s.dtrace.err)\n' "$axis" "$raw" >&2 + return + fi + "$@" >/dev/null 2>"$RAW_DIR/$axis.sample.cmd.err" & + local pid=$! + # Attach and sample at 1ms until the process dies (-mayDie). 3600s upper bound. + sample "$pid" 3600 1 -mayDie -file "$raw" >/dev/null 2>"$RAW_DIR/$axis.sample.err" + wait "$pid" 2>/dev/null +} + +# =========================================================================== +printf 'axis,phase,tool,mode,n,unit,time_ms,status,exit_code,note\n' >"$CSV" + +printf 'cc-bench: kit: %s\n' "$KIT" +"$KIT" --version 2>/dev/null | head -1 | sed 's/^/cc-bench: /' || true +printf 'cc-bench: clang: %s (skip=%s)\n' "$CLANG" "$SKIP_CLANG" +printf 'cc-bench: out: %s\n' "$OUT_DIR" +printf 'cc-bench: repeats=%s sample=%s dtrace=%s\n' "$REPEATS" "$DO_SAMPLE" "$USE_DTRACE" + +# Fixed per-invocation overhead: empty TU compile, empty TU preprocess, and a +# trivial 1-object link. Subtracted by the reporter so process startup doesn't +# masquerade as sub-linear scaling. +OV_DIR="$GEN_DIR/__overhead__"; mkdir -p "$OV_DIR" +printf 'int kit_bench_empty(void){return 0;}\n' >"$OV_DIR/empty.c" +printf 'int main(void){return 0;}\n' >"$OV_DIR/main.c" +# Warm up: the very first kit invocation pays dyld/page-in cold-start cost that +# would inflate the overhead baseline. Discard one run first. +"$KIT" cc -O0 -c "$OV_DIR/empty.c" -o "$OV_DIR/empty.o" >/dev/null 2>&1 +best_of "$LOG_DIR/ov.compile" "$KIT" cc -O0 -c "$OV_DIR/empty.c" -o "$OV_DIR/empty.o" && \ + record __overhead__ compile kit compile 0 functions "$BEST_MS" OK 0 "" +best_of "$LOG_DIR/ov.pp" "$KIT" cc -O0 -E "$OV_DIR/empty.c" -o "$OV_DIR/empty.i" && \ + record __overhead__ compile kit preprocess 0 headers "$BEST_MS" OK 0 "" +"$KIT" cc -O0 -c "$OV_DIR/main.c" -o "$OV_DIR/main.o" 2>/dev/null +best_of "$LOG_DIR/ov.link" "$KIT" ld "$OV_DIR/main.o" -l c "${KIT_SYSROOT_ARGS[@]}" -o "$OV_DIR/app" && \ + record __overhead__ link kit link 0 objects "$BEST_MS" OK 0 "" + +# Axis catalog (JSON) -> drive the sweep. +AXES_JSON="$(python3 "$GEN" --list)" +ALL_AXES="$(printf '%s' "$AXES_JSON" | python3 -c 'import json,sys; print(" ".join(a["axis"] for a in json.load(sys.stdin)))')" +SEL_AXES="${KIT_CC_BENCH_AXES:-$ALL_AXES}" +SAMPLE_AXES="${KIT_CC_BENCH_SAMPLE_AXES:-$SEL_AXES}" + +declare -A AXIS_MAXMS AXIS_MAXN AXIS_PHASE AXIS_MODE AXIS_UNIT +for axis in $SEL_AXES; do + phase="$(printf '%s' "$AXES_JSON" | python3 -c 'import json,sys; a={x["axis"]:x for x in json.load(sys.stdin)}["'"$axis"'"]; print(a["phase"])')" + mode="$(printf '%s' "$AXES_JSON" | python3 -c 'import json,sys; a={x["axis"]:x for x in json.load(sys.stdin)}["'"$axis"'"]; print(a["mode"])')" + unit="$(printf '%s' "$AXES_JSON" | python3 -c 'import json,sys; a={x["axis"]:x for x in json.load(sys.stdin)}["'"$axis"'"]; print(a["unit"])')" + sizes="${KIT_CC_BENCH_SIZES:-$(printf '%s' "$AXES_JSON" | python3 -c 'import json,sys; a={x["axis"]:x for x in json.load(sys.stdin)}["'"$axis"'"]; print(" ".join(map(str,a["sizes"])))')}" + AXIS_PHASE[$axis]="$phase"; AXIS_MODE[$axis]="$mode"; AXIS_UNIT[$axis]="$unit" + maxn=0 + printf '\n===== %s (%s, %s) sizes: %s =====\n' "$axis" "$phase" "$mode" "$sizes" + for n in $sizes; do + dir="$GEN_DIR/$axis/n$n"; mkdir -p "$dir" + python3 "$GEN" --axis "$axis" --n "$n" --out "$dir" >"$dir/manifest.json" + if [ "$phase" = link ]; then + run_link_axis "$axis" "$unit" "$n" "$dir" + else + run_compile_axis "$axis" "$mode" "$unit" "$n" "$dir" + fi + printf ' n=%-7s kit=%-10s\n' "$n" "${KIT_BEST_MS:-FAIL}" + [ "$n" -gt "$maxn" ] && { maxn="$n"; AXIS_MAXMS[$axis]="${KIT_BEST_MS:-}"; } + # Adaptive cap: stop growing this axis once a run blows past the ceiling. + if [ "$MAX_MS" != 0 ] && [ -n "${KIT_BEST_MS:-}" ] && \ + awk -v m="$KIT_BEST_MS" -v c="$MAX_MS" 'BEGIN{exit !(m+0 > c+0)}'; then + printf ' (cap: %.0fms > %sms -> stop growing %s)\n' "$KIT_BEST_MS" "$MAX_MS" "$axis" + break + fi + done + AXIS_MAXN[$axis]="$maxn" +done + +# ---- sampling pass: largest input per axis ---- +if [ "$DO_SAMPLE" = 1 ]; then + printf '\ncc-bench: sampling hotspots (largest input per axis)\n' + for axis in $SAMPLE_AXES; do + [ -n "${AXIS_PHASE[$axis]:-}" ] || continue + maxn="${AXIS_MAXN[$axis]}"; maxms="${AXIS_MAXMS[$axis]:-0}" + raw="$RAW_DIR/$axis.sample.txt" + if [ -z "$maxms" ]; then printf ' %-14s SKIP (run failed)\n' "$axis"; continue; fi + if awk -v m="$maxms" -v t="$SAMPLE_MIN_MS" 'BEGIN{exit !(m+0 < t+0)}'; then + printf ' %-14s SKIP (%.0fms < %sms)\n' "$axis" "$maxms" "$SAMPLE_MIN_MS" + printf 'skipped: %s ran in %sms (< %sms), too short to sample\n' "$axis" "$maxms" "$SAMPLE_MIN_MS" >"$raw" + continue + fi + dir="$GEN_DIR/$axis/n$maxn" + if [ "${AXIS_PHASE[$axis]}" = link ]; then + link_order="$(read_manifest "$dir/manifest.json" link_order)" + objs=(); for o in $link_order; do objs+=("$dir/$o"); done # objects already built in sweep + printf ' %-14s sampling kit ld (n=%s, ~%.0fms)\n' "$axis" "$maxn" "$maxms" + sample_cmd "$axis" "$raw" "$KIT" ld "${objs[@]}" -l c "${KIT_SYSROOT_ARGS[@]}" -o "$dir/app.sample" + else + incdir="$(read_manifest "$dir/manifest.json" incdir)" + inc_args=(); [ -n "$incdir" ] && inc_args=(-I "$dir/$incdir") + printf ' %-14s sampling kit cc (n=%s, ~%.0fms)\n' "$axis" "$maxn" "$maxms" + if [ "${AXIS_MODE[$axis]}" = preprocess ]; then + sample_cmd "$axis" "$raw" "$KIT" cc -O0 -E "${inc_args[@]}" "$dir/gen.c" -o "$dir/gen.sample.i" + else + sample_cmd "$axis" "$raw" "$KIT" cc -O0 -c "$dir/gen.c" -o "$dir/gen.sample.o" + fi + fi + done +fi + +printf '\ncc-bench: wrote %s\n' "$CSV" +python3 "$REPORT" "$OUT_DIR" || true diff --git a/scripts/cc_bench_gen.py b/scripts/cc_bench_gen.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Synthetic C input generator for the -O0 compile+link scaling benchmark. + +This is the single source of truth for the benchmark's *axes*: each axis varies +one input dimension (function count, statement count, object-file count, ...) so +the harness can sweep it over a geometric size series and the reporter can fit a +scaling exponent. The goal is linear scaling on every axis; a superlinear axis +points straight at an O(n^2) trap in the single-pass frontend or the linker. + +Two modes: + + cc_bench_gen.py --list + Print a JSON array describing every axis: name, phase (compile|link), + unit label, timed mode (compile=-c, preprocess=-E, link), and the default + geometric size series. The harness drives the sweep from this. + + cc_bench_gen.py --axis AXIS --n N --out DIR + Write the source/header files for AXIS at size N into DIR and print a JSON + manifest describing how to build/time/check this instance. The manifest + names *intent* (mode, source, prebuild list, link order, expected exit), + never tool-specific flags -- the harness turns that into kit/clang command + lines so the axis definitions stay tool-neutral. + +Generated code is dead-simple C11 with no platform headers (integer arithmetic ++ a deterministic main return % 256), so compile time is frontend/codegen-bound +and identical for kit and the clang reference. +""" +import argparse +import json +import os +import sys + +# --------------------------------------------------------------------------- +# Axis catalog. Each entry: phase, unit label, timed mode, default size series. +# Sizes are tuned per axis: large where the work is cheap per unit (statements, +# macro expansions), smaller where an O(n^2) trap would blow up fast (locals, +# decls) or where each unit is its own file (objects, headers). +# --------------------------------------------------------------------------- +AXES = { + # ---- compile axes (kit cc -O0 -c, or -E for the preprocessor axes) ---- + "fn-count": { + "phase": "compile", "unit": "functions", "mode": "compile", + "sizes": [1000, 2000, 4000, 8000, 16000, 32000], + "blurb": "N tiny functions -> symbol-table inserts, per-fn codegen+emit", + }, + "body-size": { + "phase": "compile", "unit": "statements", "mode": "compile", + "sizes": [4000, 8000, 16000, 32000, 64000, 128000], + "blurb": "one function, N statements -> per-stmt codegen, value stack, " + "BB patch-ups, const-tracker", + }, + "global-decl": { + "phase": "compile", "unit": "globals", "mode": "compile", + "sizes": [2000, 4000, 8000, 16000, 32000, 64000], + "blurb": "N file-scope globals -> global scope table inserts", + }, + "type-decl": { + "phase": "compile", "unit": "types", "mode": "compile", + "sizes": [2000, 4000, 8000, 16000, 32000, 64000], + "blurb": "N distinct struct typedefs -> type/typedef interning", + }, + "locals-per-fn": { + "phase": "compile", "unit": "locals", "mode": "compile", + "sizes": [1000, 2000, 4000, 8000, 16000, 32000], + "blurb": "one function, N locals -> local scope table, frame slots", + }, + "pp-macro": { + "phase": "compile", "unit": "expansions", "mode": "preprocess", + "sizes": [4000, 8000, 16000, 32000, 64000, 128000], + "blurb": "N function-like macro expansions -> macro expander, token buf", + }, + "pp-include": { + "phase": "compile", "unit": "headers", "mode": "preprocess", + "sizes": [500, 1000, 2000, 4000, 8000], + "blurb": "N distinct headers included once -> lexer/pp file handling", + }, + "ref-density": { + "phase": "compile", "unit": "calls", "mode": "compile", + "sizes": [2000, 4000, 8000, 16000, 32000, 64000], + "blurb": "one function, N calls to N distinct externs -> call lowering, " + "relocation emission", + }, + # ---- link axes (objects pre-built untimed; only kit ld is timed) ---- + "obj-count": { + "phase": "link", "unit": "objects", "mode": "link", + "sizes": [32, 64, 128, 256, 512, 1024], + "blurb": "N objects linked to one exe -> input handling, symbol resolve " + "map, layout/section merge", + }, + "symbol-count": { + "phase": "link", "unit": "symbols", "mode": "link", + "sizes": [2000, 4000, 8000, 16000, 32000, 64000], + "blurb": "fixed object count, N total symbols/relocs -> global symbol " + "hash map + reloc apply", + }, +} + + +def _write(path, lines): + """Write a file from an iterable of lines (joined, single write).""" + with open(path, "w") as f: + f.write("\n".join(lines)) + f.write("\n") + + +# --------------------------------------------------------------------------- +# Per-axis source generators. Each returns the manifest dict (minus axis/n/unit/ +# phase, which the caller fills in) after writing files into `out`. +# --------------------------------------------------------------------------- +def gen_fn_count(n, out): + lines = ["/* fn-count n=%d */" % n] + for i in range(n): + lines.append("int f_%d(int x){return x + %d;}" % (i, i % 251)) + _write(os.path.join(out, "gen.c"), lines) + return {"mode": "compile", "source": "gen.c"} + + +def gen_body_size(n, out): + lines = ["/* body-size n=%d */" % n, "int body(int x){", " int acc = x;"] + for i in range(n): + lines.append(" acc = acc * 1000003 + %d;" % (i % 1000)) + lines += [" return acc;", "}"] + _write(os.path.join(out, "gen.c"), lines) + return {"mode": "compile", "source": "gen.c"} + + +def gen_global_decl(n, out): + lines = ["/* global-decl n=%d */" % n] + for i in range(n): + lines.append("int g_%d = %d;" % (i, i % 257)) + # Touch the first and last so the TU is unambiguously meaningful. + lines.append("int gd_use(void){return g_0 + g_%d;}" % (n - 1)) + _write(os.path.join(out, "gen.c"), lines) + return {"mode": "compile", "source": "gen.c"} + + +def gen_type_decl(n, out): + lines = ["/* type-decl n=%d */" % n] + for i in range(n): + lines.append("typedef struct { int a; int b; } T_%d;" % i) + # Use the last type so the table is actually consulted, not just filled. + lines.append("T_%d g_last;" % (n - 1)) + lines.append("int td_use(void){return g_last.a + g_last.b;}") + _write(os.path.join(out, "gen.c"), lines) + return {"mode": "compile", "source": "gen.c"} + + +def gen_locals_per_fn(n, out): + lines = ["/* locals-per-fn n=%d */" % n, "int locals(int x){"] + lines.append(" int v0 = x;") + for i in range(1, n): + lines.append(" int v%d = v%d + %d;" % (i, i - 1, i % 251)) + lines.append(" return v0 ^ v%d;" % (n - 1)) + lines.append("}") + _write(os.path.join(out, "gen.c"), lines) + return {"mode": "compile", "source": "gen.c"} + + +def gen_pp_macro(n, out): + lines = ["/* pp-macro n=%d */" % n, + "#define SQ(x) ((x)*(x))", + "#define MIX(a,b) (SQ(a) + SQ(b) - (a)*(b))", + "int pp_macro(int x){", " int a = 0;"] + for i in range(n): + lines.append(" a += MIX(x + %d, x - %d);" % (i % 997, i % 991)) + lines += [" return a;", "}"] + _write(os.path.join(out, "gen.c"), lines) + return {"mode": "preprocess", "source": "gen.c"} + + +def gen_pp_include(n, out): + for i in range(n): + guard = "H_%d_H" % i + _write(os.path.join(out, "h_%d.h" % i), [ + "#ifndef %s" % guard, "#define %s" % guard, + "typedef int hi_%d_t;" % i, + "extern hi_%d_t hv_%d;" % (i, i), + "#endif", + ]) + lines = ["/* pp-include n=%d */" % n] + for i in range(n): + lines.append('#include "h_%d.h"' % i) + lines.append("int pp_include(void){return 0;}") + _write(os.path.join(out, "gen.c"), lines) + return {"mode": "preprocess", "source": "gen.c", "incdir": "."} + + +def gen_ref_density(n, out): + lines = ["/* ref-density n=%d */" % n] + for i in range(n): + lines.append("extern int rf_%d(void);" % i) + lines += ["int hub(void){", " int s = 0;"] + for i in range(n): + lines.append(" s += rf_%d();" % i) + lines += [" return s;", "}"] + _write(os.path.join(out, "gen.c"), lines) + return {"mode": "compile", "source": "gen.c"} + + +def gen_obj_count(n, out): + # N leaf objects, each defining leaf_i returning i. A main sums them all and + # returns the total % 256. Objects are pre-built untimed; only the link of + # all N+1 objects is timed. + prebuild = [] + link_order = [] + for i in range(n): + _write(os.path.join(out, "obj_%d.c" % i), + ["int leaf_%d(void){return %d;}" % (i, i % 256)]) + prebuild.append("obj_%d.c" % i) + link_order.append("obj_%d.o" % i) + main = ["/* obj-count main n=%d */" % n] + for i in range(n): + main.append("extern int leaf_%d(void);" % i) + main += ["int main(void){", " long s = 0;"] + for i in range(n): + main.append(" s += leaf_%d();" % i) + expected = (sum(i % 256 for i in range(n))) % 256 + main += [" return (int)(s % 256);", "}"] + _write(os.path.join(out, "main.c"), main) + prebuild.append("main.c") + link_order.append("main.o") + return {"mode": "link", "prebuild": prebuild, "link_order": link_order, + "expected_exit": expected} + + +def gen_symbol_count(n, out): + # Fixed object count (2): defs.c defines N functions, main.c references all + # N. Link must resolve N symbols and apply N relocations -> stresses the + # global symbol hash map + reloc apply with object count held constant. + defs = ["/* symbol-count defs n=%d */" % n] + for i in range(n): + defs.append("int s_%d(void){return %d;}" % (i, i % 256)) + _write(os.path.join(out, "defs.c"), defs) + main = ["/* symbol-count main n=%d */" % n] + for i in range(n): + main.append("extern int s_%d(void);" % i) + main += ["int main(void){", " long s = 0;"] + for i in range(n): + main.append(" s += s_%d();" % i) + main += [" return (int)(s % 256);", "}"] + _write(os.path.join(out, "main.c"), main) + expected = (sum(i % 256 for i in range(n))) % 256 + return {"mode": "link", "prebuild": ["defs.c", "main.c"], + "link_order": ["defs.o", "main.o"], "expected_exit": expected} + + +GENERATORS = { + "fn-count": gen_fn_count, + "body-size": gen_body_size, + "global-decl": gen_global_decl, + "type-decl": gen_type_decl, + "locals-per-fn": gen_locals_per_fn, + "pp-macro": gen_pp_macro, + "pp-include": gen_pp_include, + "ref-density": gen_ref_density, + "obj-count": gen_obj_count, + "symbol-count": gen_symbol_count, +} + + +def cmd_list(): + out = [] + for name, spec in AXES.items(): + out.append({ + "axis": name, + "phase": spec["phase"], + "unit": spec["unit"], + "mode": spec["mode"], + "sizes": spec["sizes"], + "blurb": spec["blurb"], + }) + json.dump(out, sys.stdout, indent=2) + sys.stdout.write("\n") + + +def cmd_gen(axis, n, out): + if axis not in GENERATORS: + sys.exit("cc_bench_gen: unknown axis %r" % axis) + if n < 1: + sys.exit("cc_bench_gen: n must be >= 1") + os.makedirs(out, exist_ok=True) + spec = AXES[axis] + manifest = GENERATORS[axis](n, out) + manifest.update({ + "axis": axis, "n": n, "phase": spec["phase"], "unit": spec["unit"], + }) + json.dump(manifest, sys.stdout) + sys.stdout.write("\n") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--list", action="store_true", + help="print the axis catalog as JSON and exit") + ap.add_argument("--axis", help="axis name (see --list)") + ap.add_argument("--n", type=int, help="size for this axis") + ap.add_argument("--out", help="output directory for generated files") + args = ap.parse_args() + + if args.list: + cmd_list() + return + if not (args.axis and args.n is not None and args.out): + ap.error("need --axis, --n and --out (or --list)") + cmd_gen(args.axis, args.n, args.out) + + +if __name__ == "__main__": + main() diff --git a/scripts/cc_bench_report.py b/scripts/cc_bench_report.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Reporter for the -O0 compile+link scaling benchmark. + +Reads <out>/scaling.csv (written by cc_bench.sh) and produces: + + <out>/scaling.md per-axis scaling exponent + verdict + clang ratio + <out>/hotspots.md flat self-time hotspot table per axis, parsed from the + `sample` call-trees in <out>/raw/<axis>.sample.txt + +Scaling model: for each axis we fit log(t_net) = log(a) + p*log(n), where t_net +is best-of-N wall time minus the fixed per-invocation overhead (empty-TU compile +/ preprocess / trivial link). The exponent p is the headline: + + p < 1.15 LINEAR (the goal) + p < 1.40 NEAR-LINEAR + p >= 1.40 SUPERLINEAR (an O(n^2)-ish trap -> a hotspot to fix) + +A low R^2 (<0.9) tags the fit NOISY (rerun with more repeats / bigger sizes). +""" +import csv +import math +import os +import re +import sys +from collections import defaultdict, OrderedDict + + +# --------------------------------------------------------------------------- +# CSV loading +# --------------------------------------------------------------------------- +def fnum(v): + try: + return float(v) + except (TypeError, ValueError): + return None + + +def load(csv_path): + with open(csv_path, newline="") as f: + return list(csv.DictReader(f)) + + +# --------------------------------------------------------------------------- +# Power-law fit +# --------------------------------------------------------------------------- +def linfit(xs, ys): + """Least-squares slope/intercept/R^2 of ys ~ a + b*xs.""" + npt = len(xs) + if npt < 2: + return None + mx = sum(xs) / npt + my = sum(ys) / npt + sxx = sum((x - mx) ** 2 for x in xs) + sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) + if sxx == 0: + return None + b = sxy / sxx + a = my - b * mx + syy = sum((y - my) ** 2 for y in ys) + ss_res = sum((y - (a + b * x)) ** 2 for x, y in zip(xs, ys)) + r2 = 1.0 - ss_res / syy if syy > 0 else 1.0 + return a, b, r2 + + +def fmt(v, spec="%.1f", na="NA"): + return (spec % v) if v is not None else na + + +def verdict(p, r2): + if p is None: + return "NA" + tag = "LINEAR" if p < 1.15 else ("NEAR-LINEAR" if p < 1.40 else "SUPERLINEAR") + if r2 is not None and r2 < 0.9: + tag += " (noisy)" + return tag + + +def fit_axis(points, overhead): + """points: list of (n, ms). Returns dict with exponent/r2/used info.""" + pts = sorted((n, t) for n, t in points if t is not None and t > 0) + # Net time after subtracting fixed overhead. + net = [(n, t - overhead) for n, t in pts] + # Prefer points where the signal clears the overhead noise floor. + strong = [(n, d) for n, d in net if d > max(2 * overhead, 1e-6)] + used = strong if len(strong) >= 3 else [(n, d) for n, d in net if d > 0] + low_conf = len(strong) < 3 + if len(used) < 2: + return {"p": None, "r2": None, "used": used, "low_conf": True, + "double": None, "pts": pts, "net": net} + fit = linfit([math.log(n) for n, _ in used], [math.log(d) for _, d in used]) + p = fit[1] if fit else None + r2 = fit[2] if fit else None + # Doubling ratio sanity check: t(2n)/t(n) ~ 2 linear, ~4 quadratic. + dbl = None + for (n1, d1), (n2, d2) in zip(used, used[1:]): + if d1 > 0 and 1.7 <= n2 / n1 <= 2.3: + r = (d2 / d1) / (n2 / n1) # normalize to a pure 2x step + dbl = r if dbl is None else max(dbl, r) + return {"p": p, "r2": r2, "used": used, "low_conf": low_conf, + "double": dbl, "pts": pts, "net": net} + + +# --------------------------------------------------------------------------- +# scaling.md +# --------------------------------------------------------------------------- +def write_scaling(out_dir, rows): + overhead = {"compile": 0.0, "preprocess": 0.0, "link": 0.0} + for r in rows: + if r["axis"] == "__overhead__" and r["status"] == "OK": + overhead[r["mode"]] = fnum(r["time_ms"]) or 0.0 + + # axis -> tool -> mode/phase/unit + list of (n, ms) + axes = OrderedDict() + for r in rows: + if r["axis"] == "__overhead__": + continue + a = r["axis"] + ax = axes.setdefault(a, {"phase": r["phase"], "mode": r["mode"], + "unit": r["unit"], "kit": [], "clang": []}) + ms = fnum(r["time_ms"]) if r["status"] == "OK" else None + if r["tool"] == "kit": + ax["kit"].append((int(r["n"]), ms)) + elif r["tool"] == "clang": + ax["clang"].append((int(r["n"]), ms)) + + L = [] + L.append("# kit -O0 compile + link scaling") + L.append("") + L.append("Goal: **linear scaling** on every axis (exponent ~1.0). " + "`SUPERLINEAR` (exponent >= 1.4) marks an O(n^2)-ish hotspot to fix.") + L.append("") + L.append(f"Fixed per-invocation overhead (subtracted before fitting): " + f"compile **{overhead['compile']:.2f} ms**, preprocess " + f"**{overhead['preprocess']:.2f} ms**, link " + f"**{overhead['link']:.2f} ms**.") + L.append("") + L.append("## Linearity summary") + L.append("") + L.append("| axis | phase | unit | pts | exponent | R² | 2x-ratio | verdict " + "| kit @maxN | clang @maxN | kit/clang |") + L.append("| --- | --- | --- | ---: | ---: | ---: | ---: | --- | ---: | ---: | ---: |") + + details = [] + for a, ax in axes.items(): + ov = overhead.get(ax["mode"], 0.0) + fit = fit_axis(ax["kit"], ov) + kit_by_n = {n: t for n, t in ax["kit"]} + clang_by_n = {n: t for n, t in ax["clang"]} + maxn = max(kit_by_n) if kit_by_n else None + kit_max = kit_by_n.get(maxn) + clang_max = clang_by_n.get(maxn) + ratio = (kit_max / clang_max) if (kit_max and clang_max) else None + p_s = fmt(fit["p"], "%.2f") + r2_s = fmt(fit["r2"], "%.3f") + d_s = fmt(fit["double"], "%.2f") + v = verdict(fit["p"], fit["r2"]) + (" *low-n*" if fit["low_conf"] else "") + L.append(f"| {a} | {ax['phase']} | {ax['unit']} | {len(fit['used'])} | " + f"{p_s} | {r2_s} | {d_s} | {v} | " + f"{fmt(kit_max)} | {fmt(clang_max)} | {fmt(ratio, '%.2fx')} |") + + # Per-axis detail block + d = [f"### {a} ({ax['phase']}, per {ax['unit'][:-1] if ax['unit'].endswith('s') else ax['unit']})", "", + f"overhead {ov:.2f} ms subtracted. exponent **{p_s}** ({verdict(fit['p'], fit['r2'])}).", "", + f"| n | kit ms | kit net ms | kit ns/{ax['unit'][:-1] if ax['unit'].endswith('s') else ax['unit']} | clang ms | kit/clang |", + "| ---: | ---: | ---: | ---: | ---: | ---: |"] + net_by_n = {n: v for n, v in fit["net"]} + for n in sorted(kit_by_n): + kt = kit_by_n[n] + ct = clang_by_n.get(n) + net = net_by_n.get(n) + per = (net * 1e6 / n) if (net and net > 0 and n) else None + rr = (kt / ct) if (kt and ct) else None + d.append(f"| {n} | {('%.2f' % kt) if kt else 'FAIL'} | " + f"{('%.2f' % net) if net is not None else 'NA'} | " + f"{('%.1f' % per) if per else 'NA'} | " + f"{('%.2f' % ct) if ct else 'NA'} | " + f"{('%.2fx' % rr) if rr else 'NA'} |") + d.append("") + details.append("\n".join(d)) + + L.append("") + L.append("kit/clang > 1 means kit is **slower** than the clang -O0 reference " + "at the largest size (lower is better; <1 means kit is faster).") + L.append("") + L.append("## Per-axis detail") + L.append("") + L.extend(details) + L.append(f"Raw data: `{os.path.relpath(os.path.join(out_dir, 'scaling.csv'))}` " + f"· hotspots: `{os.path.relpath(os.path.join(out_dir, 'hotspots.md'))}`") + + path = os.path.join(out_dir, "scaling.md") + with open(path, "w") as f: + f.write("\n".join(L) + "\n") + return path, axes + + +# --------------------------------------------------------------------------- +# sample call-tree -> self-time per function +# --------------------------------------------------------------------------- +FRAME_RE = re.compile(r"^(?P<indent>[ |+!:]*)(?P<count>\d+)\s+(?P<rest>.*\S)\s*$") + + +def parse_sample(text): + """Return (self_by_func dict, total_samples) from a `sample` report, or + (None, 0) if the file isn't a parseable call graph. + + Each call-graph line is `<indent><count> <symbol> (in <image>) ...`. A + node's count is the samples that passed through it; its direct children sum + to <= that, and the difference is the samples whose stack *ended* there — + i.e. self time. We reconstruct the tree via an indent stack and accumulate + self time per (symbol, image).""" + 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 = [] # per node: indentation width + counts = [] # per node: total sample count + child_sum = [] # per node: summed direct-children counts + names = [] # per node: (symbol, image) + stack = [] # node indices, increasing indent + 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: # thread/root header or unresolved frame + 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)): + self_t = max(0, counts[i] - child_sum[i]) + sym, img = names[i] + if not sym: + continue + self_by[(sym, img)] += self_t + total += self_t + return self_by, total + + +def parse_dtrace(text): + """Flat `ufunc count` histogram from a dtrace profile run.""" + self_by = defaultdict(float) + total = 0.0 + for ln in text.splitlines(): + s = ln.strip() + if not s: + continue + parts = s.split() + if len(parts) < 2 or not parts[-1].isdigit(): + continue + cnt = float(parts[-1]) + sym = " ".join(parts[:-1]) + img = "" + if "`" in sym: + img, sym = sym.split("`", 1) + self_by[(sym, img)] += cnt + total += cnt + return self_by, total + + +def write_hotspots(out_dir, axes): + raw_dir = os.path.join(out_dir, "raw") + L = ["# kit -O0 compile + link hotspots", "", + "Flat **self-time** per function (samples whose stack *ends* in that " + "function), from `sample` on the largest input of each axis. Use these " + "to see where time goes when an axis is stressed — especially axes the " + "linearity summary flagged SUPERLINEAR.", ""] + if not os.path.isdir(raw_dir): + L.append("_No sample data._") + with open(os.path.join(out_dir, "hotspots.md"), "w") as f: + f.write("\n".join(L) + "\n") + return + for a in axes: + raw = os.path.join(raw_dir, f"{a}.sample.txt") + if not os.path.exists(raw): + continue + text = open(raw, errors="replace").read() + if text.startswith("skipped:"): + L.append(f"## {a}") + L.append("") + L.append("_" + text.strip() + "_") + L.append("") + continue + if text.lstrip().startswith("Analysis of sampling"): + self_by, total = parse_sample(text) + else: + self_by, total = parse_dtrace(text) + L.append(f"## {a}") + L.append("") + if not self_by or total <= 0: + L.append("_No parseable samples._") + L.append("") + continue + L.append(f"{int(total)} samples.") + L.append("") + L.append("| self % | samples | function | image |") + L.append("| ---: | ---: | --- | --- |") + top = sorted(self_by.items(), key=lambda kv: kv[1], reverse=True)[:30] + for (sym, img), s in top: + if s <= 0: + continue + L.append(f"| {100.0 * s / total:.1f}% | {int(s)} | `{sym}` | {img} |") + L.append("") + with open(os.path.join(out_dir, "hotspots.md"), "w") as f: + f.write("\n".join(L) + "\n") + + +def main(): + out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "build", "bench", "cc") + csv_path = os.path.join(out_dir, "scaling.csv") + if not os.path.exists(csv_path): + sys.exit(f"cc_bench_report: no CSV at {csv_path}") + rows = load(csv_path) + scaling_path, axes = write_scaling(out_dir, rows) + write_hotspots(out_dir, axes) + print(f"cc_bench_report: wrote {scaling_path}") + print(f"cc_bench_report: wrote {os.path.join(out_dir, 'hotspots.md')}") + + +if __name__ == "__main__": + main()