kit

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

commit 907a6891029bb98c2264ba18f327e045e4e68acd
parent d68f00cb6e8b4d2d0090f04ed4edcffdaf6c2ef5
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Wed, 10 Jun 2026 18:33:24 -0700

perf(pp): bound macro-expansion memory to O(depth), not O(expansions)

The preprocessor allocated every macro expansion's substitution buffer
into pp->arena, which "lives until pp_free" -- so -E (and -c of
macro-heavy code) used memory linear in the number of expansions. Peak
RSS was 578 MB for 4.8 MB of -E output (120x), 1.18 GB at the largest
benchmark size. This is also what made arena_fini the #2 teardown
hotspot.

(A heap block free-list was considered for that teardown but measurement
ruled it out: large-block reuse on a single compile is 1.00x -- blocks
accumulate and free once, so a cache only defers the same frees. The
accumulation itself was the bug.)

Add a second arena pp->xarena for the transient expansion scratch only:
the tv_grow/hsv_grow token+hideset buffers behind every SRC_BUF, arg
slices, and paste/stringize buffers. pp_next_raw resets it to its
high-water mark whenever the source stack drains back to a lexer -- a
SRC_BUF always sits above every SRC_LEX (a macro can't #include), so a
lexer on top means no expansion buffer is live, nothing in xarena is
referenced, and the token just returned was a value copy with a
pool-interned spelling. Permanent data (macro bodies/params, hidesets,
#include text) stays in pp->arena. The #if condition path reads its
transient buffers inline rather than through the stack, so it runs under
pp->in_if_expansion, which the reset skips.

pp-macro -E: peak RSS 578 MB -> 5 MB @128k (115x), 1178 MB -> 16 MB
@512k; live >=16KB blocks 73,148 -> 6; mallocs ~75k -> 683; vs clang -E
0.76x -> 0.47x. Byte-identical: test-pp, test-parse, test-smoke-x64,
the 101-file -E corpus, and pp-macro at 4k/32k/128k.

Diffstat:
Mdoc/plan/PERF.md | 35+++++++++++++++++++++++++++++++++++
Mlang/cpp/pp/pp.c | 10+++++++---
Mlang/cpp/pp/pp_expand.c | 34++++++++++++++++++++++++----------
Mlang/cpp/pp/pp_priv.h | 19++++++++++++++-----
4 files changed, 80 insertions(+), 18 deletions(-)

diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md @@ -75,6 +75,41 @@ Quick wire-check: `KIT_CC_BENCH_SIZES='8 16 32' KIT_CC_BENCH_SAMPLE=0 make bench ## Findings +### Round 3 — bounded preprocessor memory (O(expansions) → O(depth)) + +> 2026-06-10. Round 2's note proposed a heap block free-list to kill the +> residual `arena_fini` teardown. **Measurement vetoed it**: large-block reuse +> on a single compile is 1.00× (pp-macro: 73,148 allocs, 73,148 peak-live) — +> blocks accumulate and free once, so a cache only defers the same frees. The +> real bug it exposed: **`-E` (and `-c` of macro-heavy code) used O(expansions) +> memory.** Every `subst_phase2` expansion buffer lived in `pp->arena`, which +> "lives until pp_free" — so peak RSS was **578 MB for 4.8 MB of output** +> (120×), 1.18 GB at the largest size. +> +> Fix: a second arena `pp->xarena` holds only the transient expansion scratch +> (the `tv_grow`/`hsv_grow` token/hideset buffers behind every `SRC_BUF`, arg +> slices, paste/stringize buffers); `pp_next_raw` **resets it to its high-water +> mark whenever the source stack drains back to a lexer** (a `SRC_BUF` always +> sits above every `SRC_LEX`, so a lexer on top ⇒ no expansion buffer live ⇒ +> nothing in xarena is referenced; returned tokens are value copies with +> pool-interned spellings). Permanent data (macro bodies/params, hidesets, +> `#include` text) stays in `pp->arena`. The `#if` condition path is the one +> place transient buffers are read inline rather than via the stack — it runs +> under `pp->in_if_expansion`, which the reset skips. + +| pp-macro `-E` | before → after | +|---|---| +| peak RSS @128k | 578 MB → **5 MB** (115×) | +| peak RSS @512k | 1178 MB → **16 MB** (74×) | +| live ≥16 KB blocks @512k | 73,148 → **6** | +| total mallocs @512k | ~75 k → **683** | +| vs clang `-E` | 0.76× → **0.47× (2.1× faster)** | + +Memory is now ~constant in expansion count. Verified byte-identical: test-pp, +test-parse, test-smoke-x64, 101-file `-E` corpus, pp-macro at 4k/32k/128k. (ASan +can't catch a premature reset — `arena_reset` reuses memory without freeing — +so output-diff is the gate.) + ### Round 2 — constant factor: zero-churn allocation + buffered IO > 2026-06-10, M1 (8-core), clang-built `PROFILE=1` release kit, best-of-3. diff --git a/lang/cpp/pp/pp.c b/lang/cpp/pp/pp.c @@ -144,8 +144,8 @@ Tok pp_next(Pp* pp) { } /* Not a pragma — push the peeked token back as a 1-element buffer * so the next pp_next_raw returns it, and surface the hash now. */ - Tok* keep = arena_array(pp->arena, Tok, 1); - HidesetId* hs = arena_array(pp->arena, HidesetId, 1); + Tok* keep = arena_array(pp->xarena, Tok, 1); + HidesetId* hs = arena_array(pp->xarena, HidesetId, 1); keep[0] = t2; hs[0] = HS_EMPTY; push_buf(pp, keep, hs, 1); @@ -764,10 +764,13 @@ Pp* pp_new(Compiler* c) { pp->c = c; pp->pool = c_pool_new(c); pp->arena = NULL; + pp->xarena = NULL; (void)kit_arena_new(h, 64 * 1024, &pp->arena); - if (!pp->pool || !pp->arena) { + (void)kit_arena_new(h, 64 * 1024, &pp->xarena); + if (!pp->pool || !pp->arena || !pp->xarena) { c_pool_free(pp->pool); kit_arena_free(pp->arena); + kit_arena_free(pp->xarena); h->free(h, pp, sizeof(*pp)); return NULL; } @@ -799,6 +802,7 @@ void pp_free(Pp* pp) { pp_xfree(pp, pp->inc_dirs, sizeof(*pp->inc_dirs) * pp->inc_dirs_cap); c_pool_free(pp->pool); kit_arena_free(pp->arena); + kit_arena_free(pp->xarena); h->free(h, pp, sizeof(*pp)); } diff --git a/lang/cpp/pp/pp_expand.c b/lang/cpp/pp/pp_expand.c @@ -346,7 +346,7 @@ static void expand_object_macro(Pp* pp, const Macro* m, const Tok* invoke, } /* Run the body through the paste phase: object-like macros may use * `##`. There are no parameters, so phase 1 reduces to a copy. */ - tmp = arena_array(pp->arena, Tok, m->body_len); + tmp = arena_array(pp->xarena, Tok, m->body_len); for (i = 0; i < m->body_len; ++i) tmp[i] = m->body[i]; subst_phase2(pp, tmp, m->body_len, invoke, &body); @@ -359,7 +359,7 @@ static void expand_object_macro(Pp* pp, const Macro* m, const Tok* invoke, for (i = 0; i < body.n; ++i) body.data[i].loc = invoke->loc; hs = hs_add(pp, invoke_hs, m->name); - hids = arena_array(pp->arena, HidesetId, body.n); + hids = arena_array(pp->xarena, HidesetId, body.n); for (i = 0; i < body.n; ++i) hids[i] = hs; push_buf(pp, body.data, hids, body.n); } @@ -473,7 +473,7 @@ static Tok read_invocation_args(Pp* pp, const Macro* m, SrcLoc invoke_loc, Tok close_tok; memset(out, 0, sizeof(*out)); - starts = arena_array(pp->arena, u32, 8); + starts = arena_array(pp->xarena, u32, 8); starts_cap = 8; starts[0] = 0; @@ -508,7 +508,7 @@ static Tok read_invocation_args(Pp* pp, const Macro* m, SrcLoc invoke_loc, if (want_slot) { if (n_args + 1 >= starts_cap) { u32 nc = starts_cap * 2; - u32* nb = arena_array(pp->arena, u32, nc); + u32* nb = arena_array(pp->xarena, u32, nc); memcpy(nb, starts, sizeof(u32) * starts_cap); starts = nb; starts_cap = nc; @@ -533,7 +533,7 @@ static Tok read_invocation_args(Pp* pp, const Macro* m, SrcLoc invoke_loc, /* Close current arg, start next. */ if (n_args + 1 >= starts_cap) { u32 nc = starts_cap * 2; - u32* nb = arena_array(pp->arena, u32, nc); + u32* nb = arena_array(pp->xarena, u32, nc); memcpy(nb, starts, sizeof(u32) * starts_cap); starts = nb; starts_cap = nc; @@ -568,7 +568,7 @@ done: if (n_args + 1 == expected) { if (n_args + 1 >= starts_cap) { u32 nc = starts_cap * 2; - u32* nb = arena_array(pp->arena, u32, nc); + u32* nb = arena_array(pp->xarena, u32, nc); memcpy(nb, starts, sizeof(u32) * starts_cap); starts = nb; starts_cap = nc; @@ -598,7 +598,7 @@ static void preexpand_args(Pp* pp, ArgList* a) { TokVec exp = {0}; u32* exp_start; u32 i; - exp_start = arena_array(pp->arena, u32, a->n_args + 1); + exp_start = arena_array(pp->xarena, u32, a->n_args + 1); exp_start[0] = 0; for (i = 0; i < a->n_args; ++i) { u32 lo = a->raw_start[i]; @@ -606,7 +606,7 @@ static void preexpand_args(Pp* pp, ArgList* a) { if (hi > lo) { /* Copy the slice into a fresh buffer so expand_arg_to_eof can * own it without aliasing. */ - Tok* slice = arena_array(pp->arena, Tok, hi - lo); + Tok* slice = arena_array(pp->xarena, Tok, hi - lo); memcpy(slice, &a->raw[lo], sizeof(Tok) * (hi - lo)); expand_arg_to_eof(pp, slice, a->raw_hs ? &a->raw_hs[lo] : NULL, hi - lo, &exp); @@ -881,7 +881,7 @@ static int try_expand_func_macro(Pp* pp, const Macro* m, const Tok* invoke, { u32 i; - HidesetId* hids = arena_array(pp->arena, HidesetId, body.n ? body.n : 1); + HidesetId* hids = arena_array(pp->xarena, HidesetId, body.n ? body.n : 1); for (i = 0; i < body.n; ++i) { hids[i] = (HidesetId)hsvec.data[i].spelling; } @@ -903,6 +903,20 @@ Tok pp_next_raw(Pp* pp) { HidesetId hs; u8 src_kind; for (;;) { + /* Reclaim the transient expansion scratch whenever the source stack has + * drained back to a lexer. A SRC_BUF always sits above every SRC_LEX (a + * macro can't #include), so a lexer on top means no expansion buffer is + * live -- nothing in xarena is referenced, and the previously returned + * token was a value copy with a pool-interned spelling. This is the only + * reset site; it never fires mid-expansion (a SRC_BUF, incl. the + * scope_top arg-prescan buffer, is on top then) nor during raw argument + * collection (which uses src_next_raw, not this function). Skipped inside + * #if expansion, whose bounded condition keeps its scratch live. */ + if (!pp->in_if_expansion && + (pp->nsources == 0 || + pp->sources[pp->nsources - 1].kind == SRC_LEX)) { + kit_arena_reset(pp->xarena); + } t = src_next_raw(pp, &hs, &src_kind); if (t.kind == TOK_EOF) return t; if (t.kind == TOK_PP_HASH && (t.flags & TF_AT_BOL) && src_kind == SRC_LEX) { @@ -999,7 +1013,7 @@ Tok pp_next_raw(Pp* pp) { { size_t bn = 0; size_t i; - buf = (char*)arena_alloc(pp->arena, nlen * 2 + 2, 1); + buf = (char*)arena_alloc(pp->xarena, nlen * 2 + 2, 1); buf[bn++] = '"'; for (i = 0; i < nlen; ++i) { char ch = nstr[i]; diff --git a/lang/cpp/pp/pp_priv.h b/lang/cpp/pp/pp_priv.h @@ -126,10 +126,19 @@ struct Pp { u32 pack_stack[16]; u32 pack_stack_n; - /* Internal arena: macro bodies, hidesets, expansion buffers, file - * data for #include. Lives until pp_free. */ + /* Permanent arena: macro bodies, params, hidesets, #include file data. + * Lives until pp_free. */ KitArena* arena; + /* Transient expansion scratch: the macro-substitution token/hideset buffers + * (tv_grow/hsv_grow), arg slices, paste/stringize buffers -- everything that + * backs a SRC_BUF or is consumed within one expansion. Reset to its + * high-water mark by pp_next_raw whenever the source stack drains back to a + * lexer (no SRC_BUF live), so memory is O(expansion depth), not + * O(expansions). Nothing the caller keeps points in here: tokens are + * returned by value and spellings are interned in the pool. */ + KitArena* xarena; + /* Cached interned identifiers used for directive recognition. */ Sym sym_define; Sym sym_undef; @@ -230,7 +239,7 @@ static inline void tv_grow(Pp* pp, TokVec* v, u32 want) { nc = v->cap ? v->cap * 2 : 8; while (nc < want) nc *= 2; { - Tok* nb = arena_array(pp->arena, Tok, nc); + Tok* nb = arena_array(pp->xarena, Tok, nc); if (v->n) memcpy(nb, v->data, sizeof(Tok) * v->n); v->data = nb; v->cap = nc; @@ -248,7 +257,7 @@ static inline void hsv_grow(Pp* pp, HsVec* v, u32 want) { nc = v->cap ? v->cap * 2 : 8; while (nc < want) nc *= 2; { - HidesetId* nb = arena_array(pp->arena, HidesetId, nc); + HidesetId* nb = arena_array(pp->xarena, HidesetId, nc); if (v->n) memcpy(nb, v->data, sizeof(HidesetId) * v->n); v->data = nb; v->cap = nc; @@ -272,7 +281,7 @@ static inline void cb_append(Pp* pp, CharBuf* b, const char* s, u32 n) { u32 nc = b->cap ? b->cap * 2 : 64; while (nc < b->len + n) nc *= 2; { - char* nb = (char*)arena_alloc(pp->arena, nc, 1); + char* nb = (char*)arena_alloc(pp->xarena, nc, 1); if (b->len) memcpy(nb, b->data, b->len); b->data = nb; b->cap = nc;