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:
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;