kit

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

commit 2e236aa0196bbee9a2c39a5e4c4f9786fd963b99
parent 701328a47fd27f1af9f38ce77ee463c3e3cd47e6
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 13:42:11 -0700

perf(cpp,parse): sym-centric bindings + O(1) hideset dedup (-16% -c on sqlite)

Frontend-bound -O0 throughput work toward the tcc bar. Three byte-identical
structural changes (perf_identity_gate 60/60, sqlite -E bit-identical over
119263 lines, test-pp 110/110, test-parse green):

- New KIT_SYMTAB primitive (include/kit/support/symtab.h): a Sym-indexed dense
  value table. kit's Sym ids are dense+monotonic, so a flat array is the
  kit-native form of tcc's "binding hung off the interned token" -- one load,
  no hashing -- while each layer keeps its own table (cpp<->c boundary intact).
- pp macro table: MacroMap (Sym->Macro* hashmap, mt_get per identifier) ->
  MacroTab SymTab (inlined array load).
- parser keyword map: KwMap (Sym->u8 hashmap) -> KwTab SymTab.
- pp hideset dedup: hs_register linearly scanned every hideset in the TU per
  macro invocation (the real cost behind the profile's 17.5% hs_add) -> O(1)
  content index (Pp.hs_index). Ids still assigned in first-appearance order,
  so output is bit-identical.
- pp per-token hideset: uniform macro-body buffers now share one
  TokSrc.hs_uniform (push_buf_uniform) instead of a parallel HidesetId array;
  only the non-uniform arg-prescan path keeps the array. Perf-neutral,
  memory/structure cleanup.

sqlite -c 3.37B -> 2.84B instructions (~5.0x -> ~4.2x tcc); -E 1.89B -> 1.42B
(-24.6%). PERF.md refreshed with the post-change profile (lex_next now ~56%,
the new frontier) and a Resolved-levers section.

Diffstat:
Mdoc/plan/PERF.md | 146++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Ainclude/kit/support/symtab.h | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlang/c/parse/parse.c | 20++++++++++----------
Mlang/c/parse/parse_priv.h | 14+++++++-------
Mlang/cpp/pp/pp.c | 27++++++++++++++++++++++++---
Mlang/cpp/pp/pp_expand.c | 101++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mlang/cpp/pp/pp_priv.h | 48+++++++++++++++++++++++++++++++++++++++---------
7 files changed, 322 insertions(+), 124 deletions(-)

diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md @@ -35,15 +35,18 @@ recent low-load reading. | compiler | instructions | cycles † | wall † | object | |---|--:|--:|--:|--:| | **tcc 0.9.28** | 0.67 B | 0.22 B | 0.06 s | 2.11 MB | -| **kit (current)** | 3.40 B | 0.89 B | 0.28 s | 5.12 MB | +| **kit (current)** | 2.84 B | — | — | 5.12 MB | +| kit (pre-sym-centric) | 3.37 B | 0.89 B | 0.28 s | 5.12 MB | | clang 22 | 8.73 B | 2.57 B | 0.81 s | 1.50 MB | -† low-load reading; re-confirm on a quiet machine. +† low-load reading; re-confirm on a quiet machine. cycles/wall for the current +row are pending a quiet-machine re-read (the −16% is an instruction A/B). kit beats clang on compile speed and is the fastest *general* backend here, but -**tcc is the bar**: ~5.1× instructions (~4× cycles / ~4.7× wall) ahead. Closing -that is the whole game. (kit's object is larger because `-O0` codegen is -deliberately unoptimized — irrelevant to this goal.) +**tcc is the bar**: now ~4.2× instructions ahead (down from ~5.0×). The last +−16 % came from the sym-centric / hideset work below; closing the rest is the +whole game. (kit's object is larger because `-O0` codegen is deliberately +unoptimized — irrelevant to this goal.) ### Reproducing the detailed measurements @@ -96,9 +99,11 @@ m build/release/kit cc -c -o /tmp/k.o sqlite3.c --sysroot "$SDK" # + codegen `-fsyntax-only` still drives the full CG value-stack and type lowering (routed to the no-op check backend), so `(-c) − (-fsyntax-only)` isolates **native emit + -object write** only. Current binary: `-E` 1.90 B, `-fsyntax-only` 2.91 B, `-c` -3.40 B → that delta is ~0.49 B / ~14 % — i.e. **codegen+emit is a small slice; -the frontend is ~86 %.** +object write** only. Current binary: `-E` 1.42 B, `-fsyntax-only` 2.35 B, `-c` +2.83 B → that delta is ~0.48 B / ~17 % — i.e. **codegen+emit is a small slice; +the frontend is the rest.** Within that frontend, `-E` alone (1.42 B) is now the +majority, and the scanner (`lex_next`) is the bulk of it — see *Where the time +goes*. **4. Hotspot profile** (self-time per function). The single run is too fast for `sample`, so merge the `Sort by top of stack` sections across many runs: @@ -127,14 +132,41 @@ build/release/kit cc sqlite3.c shell.c -o /tmp/sq --sysroot "$SDK" -lc ## Current state (2026-06-12) -**The headline finding: real-world compilation is frontend-bound, not -codegen-bound.** The phase split (current binary, instructions: `-E` 1.90 B, -`-fsyntax-only` 2.91 B, `-c` 3.40 B) puts native codegen + emit + object-write at -the `-c` − `-fsyntax-only` delta of **~0.49 B (~14 %)**, of which pure native -codegen is **~6 %** of self-time. The frontend — preprocessor, lexer, interner, -parser, semantic analysis, and the CG value-stack/type-lowering it drives — is -the other **~86 %**. (The synthetic `bench-cc` axes below over-weight codegen by -construction; trust the sqlite profile for where to spend effort.) +### Landed 2026-06-12: sym-centric bindings + hideset O(1) dedup (−16 % `-c`) + +Three byte-identical structural changes (gated 60/60 across objects/`-E`/`-S`/ +`-g`/diagnostics/exe/splice, sqlite `-E` bit-identical, test-pp 110/110): + +1. **Sym-centric bindings** (`KIT_SYMTAB_DEFINE`, `include/kit/support/symtab.h`). + kit's `Sym` ids are dense + monotonic, so the "binding hung off the interned + token" that tcc gets from `TokenSym` fields is just a flat `Sym`-indexed + array — one bounds-check + one load, no hashing. The preprocessor macro table + (was a `Sym→Macro*` hashmap, `mt_get` per identifier) and the parser keyword + map (was a `Sym→u8` hashmap, probed per identifier) are now `SymTab`s. Each + layer keeps its own table, so the cpp↔c boundary stays intact. **−4.3 % `-c`.** +2. **Hideset dedup O(n²) → O(1)** (`hs_register`, `pp_expand.c`). The per-macro- + invocation dedup linearly scanned *every* hideset ever created in the TU — the + real cost behind the 17.5 % `hs_add` (kit doesn't even do the Prosser + intersection, so this scan was the whole expense). Now a content-addressed + open-addressed index (`Pp.hs_index`, keyed on the hideset's stored hash). Ids + are still assigned in first-appearance order, so output is bit-identical. + **The big win: −11.5 % `-c` over (1); −24.6 % on `-E`.** +3. **Per-token hideset → per-buffer scalar** (`TokSrc.hs_uniform`, + `push_buf_uniform`). Macro-body buffers share one hideset, so they no longer + materialize a parallel `HidesetId` array; only the (genuinely non-uniform) + argument-prescan path keeps the array. Byte-identical, **perf-neutral** (the + array fill was cheap xarena bump traffic; the win was all in (2)) — kept as a + memory/structure simplification. + +Net: sqlite `-c` 3.37 B → **2.84 B**, `-E` 1.89 B → **1.42 B**; **~5.0× → ~4.2× tcc**. + +**The headline finding still holds: real-world compilation is frontend-bound, not +codegen-bound.** The phase split (instructions: `-E` 1.42 B, `-c` 2.84 B) puts +native codegen + emit + object-write at well under ~20 %; the frontend — +preprocessor, lexer, interner, parser, semantic analysis, and the CG +value-stack/type-lowering it drives — is the rest. (The synthetic `bench-cc` axes +below over-weight codegen by construction; trust the sqlite profile for where to +spend effort.) **The type system is no longer a bottleneck.** The derived-type dedup paths (ptr/array/func CG types, and the ABI func/record caches) used to linearly scan a @@ -144,46 +176,54 @@ system has dropped out of the profile entirely. ### Where the time goes (self-time, real sqlite `-c`) -24-run merged `sample` (current binary; % of total samples). The profile is flat -— no single dominant hotspot, and the type-lowering hotspots are gone. - -| function | self % | stage | -|---|--:|---| -| `lex_next` | 19.8 | lexer (the scanner) | -| `hs_add` | 17.5 | **preprocessor hideset** (macro-recursion tracking) | -| `pool_intern_slice` | 13.2 | identifier interning | -| `type_unqual` | 7.6 | frontend type query | -| `pp_next_raw_into` | 4.8 | preprocessor token pump | -| `_platform_memset` | 3.6 | zeroing | -| `kit_cg_type_record_field` | 2.2 | frontend type lowering | -| `type_cg_lower` / `type_qualified` / `api_type_class` | ~4 | frontend type lowering | -| `nd_*` / `aa_*` (codegen) | ~6 | native codegen + emit | -| `src_next_raw_into` / `skip_until_active` | ~2 | preprocessor | -| `scope_lookup` | 0.9 | symbol lookup (already cheap — lazy scope index) | -| `__rename` / write | ~1 | object output (atomic temp+rename) | - -Rolled up: **lexer + preprocessor + interner ≈ 57 %** (lex 20 + hideset 17.5 + -intern 13 + pp ~6), **frontend type queries/lowering ≈ 16 %**, **native -codegen+emit ≈ 6 %**. sqlite is macro-heavy, so the hideset (`hs_add`) and the -token pump are large; the lexer interns *every* token spelling including -punctuators, which tcc avoids (operators are bare token codes there). +**Fresh 24-run merged `sample`** on the current binary (punct-cache `11fddf5b` + +CG-type memo `d881c8d9` + hideset-O(1)/sym-centric, this pass). The earlier table +is obsolete: the three biggest old rows are gone, and the scanner now dominates. + +| function | self % | stage | note | +|---|--:|---|---| +| `lex_next` | **56.2** | lexer (the scanner) | **the frontier** — was 19.8 % | +| `pool_intern_slice` | 11.2 | identifier interning | mostly identifiers (puncts now cached) | +| `_platform_memset` | 7.6 | zeroing | callers are **parse + codegen**, not the lexer | +| `pp_next_raw_into` | 6.4 | preprocessor token pump | | +| `_platform_memmove` | 5.1 | buffer moves | callers parse/codegen (`m_emit_bytes`, cg) | +| `src_next_raw_into` | 4.2 | preprocessor source stack | | +| `scope_lookup` | 2.1 | symbol lookup | cheap (lazy index) | +| `type_cg_lower` / `kit_cg_type_record_field` | ~3 | type lowering | **was ~16 %** — memoized (`d881c8d9`) | +| `hs_add` | **0** | preprocessor hideset | **RESOLVED** — O(1) dedup, this pass | + +The profile has gone from flat to **single-peaked**: `lex_next` is 56 % and +everything downstream of it has been flattened. At ~2.6 M tokens for sqlite that +is ~600 instructions/token in the scanner alone — the gap to tcc is now almost +entirely *the scanner loop itself*. + +### Resolved (don't re-propose) + +- **Punctuator interning** — `11fddf5b` caches the spelling `Sym` per lexer + (`Lexer.punct_sym[]`, `punct_spelling()`); digraphs intern verbatim. Near- + optimal; the only residual is a negligible per-`#include` cache re-warm. +- **CG-type lowering** — `d881c8d9` memoizes the lowered `KitCgTypeId` + the + complete-record `type_unqual` on the pool. The ~16 % type bucket is now ~3 %. +- **Preprocessor hideset** — O(n²) dedup → O(1) content index; per-token hideset + array → per-buffer scalar (this pass). +- **Sym-centric macro + keyword tables** — `SymTab` array loads (this pass). ### Next levers (ranked) -1. **Lexer / preprocessor diet.** Stop interning punctuator spellings; trim the - per-token 24-byte `Tok` copies across the lex→pp→parse layers; lighten the - macro-expansion hideset (`hs_add`/`hs_contains`) which sqlite leans on heavily. - This is the current ~40 % bucket. -2. **Frontend type construction** (`type_unqual`, `TypeInternSet`). The CG-layer - re-lowering is gone; the *frontend* `Type` hash-cons still runs per - construction. Caching the lowered `KitCgTypeId` on the canonical `Type` would - remove the remaining per-use re-walk (watch record-completeness: incomplete - records must not be memoized). -3. **Sym-centric bindings** (tcc's core trick). tcc resolves identifier→decl, - macro, keyword, and typedef-ness with O(1) pointer loads off the interned - token; kit still pays a keyword-map probe per identifier and a scope probe per - level. `scope_lookup` is already cheap (lazy index), so this is lower-priority - now, but it removes the residual per-identifier probes. +1. **The scanner loop** (`lex_next`, 56 % — now THE lever by a wide margin). The + per-token cost is `skip_ws_and_comments` + the first-char `switch` dispatch + + `scan_ident_run`; `lex_here`/`scan_ident_run` are already tight splice-free + loops, so the win is in the *dispatch and classification*. Replace the branchy + `is_space`/`is_alnum`/punct cascade with a single 256-entry **char-class table** + (one load → ident-start / ident-cont / digit / space / punct), tcc-style; fuse + the whitespace skip into the same table walk so a token with no leading space + pays nothing. Gate byte-identical (`-E` + objects + the splice battery). +2. **`memset`/`memmove` in parse + codegen** (~13 %, callers `cg_adapter` / + `native` / `m_emit_bytes` / `parse_*`, **not** the lexer). Right-size the struct + zeroing and buffer copies on the per-expression / per-emit path. +3. **Identifier interning** (`pool_intern_slice`, 11 %). Mostly fundamental + (identifiers must be interned); word-at-a-time hashing was a measured dead end + for short ids (see below). Low ceiling — attack only after (1). ## The synthetic scaling benchmark (`make bench-cc`) diff --git a/include/kit/support/symtab.h b/include/kit/support/symtab.h @@ -0,0 +1,90 @@ +#ifndef KIT_SUPPORT_SYMTAB_H +#define KIT_SUPPORT_SYMTAB_H + +/* Sym-indexed dense value table — the O(1) "binding hung off the interned + * symbol", expressed for kit's dense-Sym world. + * + * The interner hands out Sym ids that are small, dense, and monotonic. That + * makes a flat array indexed by Sym the kit-native form of the trick a + * monolithic compiler plays by storing a field on each interned token (tcc's + * TokenSym.sym_define / sym_identifier, etc.): one bounds check and one load, + * no hashing and no probe loop. Crucially, each consumer keeps its OWN table, + * so a layer can bind a Sym (the preprocessor: Sym -> Macro*; the parser: + * Sym -> keyword id) without any shared per-symbol struct — the module + * boundary stays intact and there is no global state. + * + * Use a SymTab for a hot per-identifier "what is bound to this Sym" lookup + * that a KIT_HASHMAP keyed on Sym would otherwise serve. Prefer KIT_HASHMAP + * when the key is not a dense Sym, when the table is sparse over a huge key + * space, or when iteration over entries is needed (a SymTab does not + * enumerate). Pair this with the heap conventions of kit/support/hashmap.h. + * + * KIT_SYMTAB_DEFINE(NAME, T) + * NAME — typedef name for the table instance. + * T — element (value) type. The zero value of T is the "unbound" + * sentinel returned for any Sym never set: NULL for a pointer T, + * 0 for an integer T. Pick T so that 0 means "no binding". + * + * Emits typedef NAME and these static functions: + * void NAME##_init (NAME*, KitHeap*) + * void NAME##_fini (NAME*) + * T NAME##_get (const NAME*, uint32_t sym) — zero(T) if sym unbound + * void NAME##_set (NAME*, uint32_t sym, T val) — grows to cover sym + * + * _get never allocates and never grows; it is the read side of the hot path. + * _set grows the backing array (doubling, new region zero-filled) so a later + * _get of any sym set so far is a plain in-range load. Storage is one + * contiguous heap block; growth reallocs through the KitHeap (which frees the + * old block), so a long-lived table set across a wide Sym range does not leak + * intermediate buffers the way an arena-backed grow would. + */ + +#include <kit/core.h> +#include <stdint.h> +#include <string.h> + +#if defined(__GNUC__) || defined(__clang__) +#define KIT_SYMTAB_UNUSED __attribute__((unused)) +#else +#define KIT_SYMTAB_UNUSED +#endif + +#define KIT_SYMTAB_DEFINE(NAME, T) \ + typedef struct NAME { \ + KitHeap* heap; \ + T* v; /* dense, indexed by Sym; [0,cap) live + zero-defaulted */ \ + uint32_t cap; /* number of slots allocated */ \ + } NAME; \ + \ + KIT_SYMTAB_UNUSED static inline void NAME##_init(NAME* t, KitHeap* h) { \ + t->heap = h; \ + t->v = NULL; \ + t->cap = 0; \ + } \ + \ + KIT_SYMTAB_UNUSED static inline void NAME##_fini(NAME* t) { \ + if (t->v) t->heap->free(t->heap, t->v, sizeof(T) * t->cap); \ + t->v = NULL; \ + t->cap = 0; \ + } \ + \ + KIT_SYMTAB_UNUSED static inline T NAME##_get(const NAME* t, uint32_t sym) { \ + return sym < t->cap ? t->v[sym] : (T)0; \ + } \ + \ + KIT_SYMTAB_UNUSED static inline void NAME##_set(NAME* t, uint32_t sym, \ + T val) { \ + if (sym >= t->cap) { \ + uint32_t nc = t->cap ? t->cap * 2u : 64u; \ + while (nc <= sym) nc *= 2u; \ + t->v = (T*)t->heap->realloc(t->heap, t->v, sizeof(T) * t->cap, \ + sizeof(T) * nc, _Alignof(T)); \ + memset(t->v + t->cap, 0, sizeof(T) * (nc - t->cap)); \ + t->cap = nc; \ + } \ + t->v[sym] = val; \ + } \ + /* trailing struct decl swallows the macro-call's semicolon */ \ + struct NAME + +#endif /* KIT_SUPPORT_SYMTAB_H */ diff --git a/lang/c/parse/parse.c b/lang/c/parse/parse.c @@ -1637,11 +1637,11 @@ void parse_c(Compiler* c, Pool* pool, Pp* pp, DeclTable* decls, CG* cg, /* Index storage for the scope/tag tables and the external-function table all * comes from the arena via the pool's shared arena-heap facade. */ ExternalFuncMap_init(&p.external_funcs, &p.pool->arena_heap); - KwMap_init(&p.kw_map, &p.pool->arena_heap); + KwTab_init(&p.kw_map, &p.pool->arena_heap); for (i = (CKw)1; i < KW_COUNT; ++i) { p.kw_sym[i] = kit_sym_intern(p.pool->c, kit_slice_cstr(kw_names[i])); - (void)KwMap_set(&p.kw_map, p.kw_sym[i], (u8)i); /* canonical keyword */ + (void)KwTab_set(&p.kw_map, p.kw_sym[i], (u8)i); /* canonical keyword */ } p.sym_b_alloca = kit_sym_intern(p.pool->c, KIT_SLICE_LIT("__builtin_alloca")); @@ -1734,14 +1734,14 @@ void parse_c(Compiler* c, Pool* pool, Pp* pp, DeclTable* decls, CG* cg, /* GNU alias spellings -> their canonical CKw. Registered after the canonical * keywords above so the canonical mapping always wins (matching the old * ident_kw_inline fall-through, which scanned kw_sym[] before the aliases). */ - (void)KwMap_set(&p.kw_map, p.sym_alignof_alias, (u8)KW_ALIGNOF); - (void)KwMap_set(&p.kw_map, p.sym_asm_alias, (u8)KW_BUILTIN_ASM); - (void)KwMap_set(&p.kw_map, p.sym_inline_alias, (u8)KW_INLINE); - (void)KwMap_set(&p.kw_map, p.sym_inline_alias2, (u8)KW_INLINE); - (void)KwMap_set(&p.kw_map, p.sym_restrict_alias, (u8)KW_RESTRICT); - (void)KwMap_set(&p.kw_map, p.sym_restrict_alias2, (u8)KW_RESTRICT); - (void)KwMap_set(&p.kw_map, p.sym_thread_alias, (u8)KW_THREAD_LOCAL); - (void)KwMap_set(&p.kw_map, p.sym_volatile_alias, (u8)KW_VOLATILE); + (void)KwTab_set(&p.kw_map, p.sym_alignof_alias, (u8)KW_ALIGNOF); + (void)KwTab_set(&p.kw_map, p.sym_asm_alias, (u8)KW_BUILTIN_ASM); + (void)KwTab_set(&p.kw_map, p.sym_inline_alias, (u8)KW_INLINE); + (void)KwTab_set(&p.kw_map, p.sym_inline_alias2, (u8)KW_INLINE); + (void)KwTab_set(&p.kw_map, p.sym_restrict_alias, (u8)KW_RESTRICT); + (void)KwTab_set(&p.kw_map, p.sym_restrict_alias2, (u8)KW_RESTRICT); + (void)KwTab_set(&p.kw_map, p.sym_thread_alias, (u8)KW_THREAD_LOCAL); + (void)KwTab_set(&p.kw_map, p.sym_volatile_alias, (u8)KW_VOLATILE); p.sym_int128 = kit_sym_intern(p.pool->c, KIT_SLICE_LIT("__int128")); p.sym_int128_t = kit_sym_intern(p.pool->c, KIT_SLICE_LIT("__int128_t")); p.sym_uint128_t = kit_sym_intern(p.pool->c, KIT_SLICE_LIT("__uint128_t")); diff --git a/lang/c/parse/parse_priv.h b/lang/c/parse/parse_priv.h @@ -7,6 +7,7 @@ #pragma once #include <kit/support/hashmap.h> +#include <kit/support/symtab.h> #include <stdarg.h> #include <string.h> @@ -166,11 +167,11 @@ KIT_HASHMAP_DEFINE(TagEntryMap, Sym, TagEntry*, kit_hash_u32); KIT_HASHMAP_DEFINE(ExternalFuncMap, Sym, SymEntry*, kit_hash_u32); /* Interned keyword/alias Sym -> CKw (stored as u8), built once in parse_c. - * Replaces the per-identifier linear scan of kw_sym[] (plus the alias compares) - * with a single O(1) probe: every non-keyword identifier (variable / typedef / - * field name) previously scanned all ~52 keyword syms before returning KW_NONE. + * Sym-indexed dense table: classifying an identifier is one in-range load, + * no hashing. Most identifiers in a body are interned after the keyword set + * (higher Sym than kw_cap), so they classify as KW_NONE without even a load. * Storage comes from the pool's shared arena-heap facade (Pool.arena_heap). */ -KIT_HASHMAP_DEFINE(KwMap, Sym, u8, kit_hash_u32); +KIT_SYMTAB_DEFINE(KwTab, u8); typedef struct Scope Scope; struct Scope { @@ -243,7 +244,7 @@ typedef struct Parser { int has_pending; Sym kw_sym[KW_COUNT]; - KwMap kw_map; /* keyword/alias Sym -> CKw; built once, see ident_kw_inline */ + KwTab kw_map; /* keyword/alias Sym -> CKw; built once, see ident_kw_inline */ Sym sym_b_alloca; Sym sym_b_ctz; @@ -462,8 +463,7 @@ static inline int is_pp_hash(const Tok* t) { return t->kind == TOK_PP_HASH; } * with no separate "alias-aware" path. Everything below (and the per-token * classify_kw / is_kw adapters) routes through this. */ static inline CKw ident_kw_inline(const Parser* p, Sym name) { - const u8* v = name ? KwMap_get(&p->kw_map, name) : NULL; - return v ? (CKw)*v : KW_NONE; + return name ? (CKw)KwTab_get(&p->kw_map, name) : KW_NONE; } /* A token's keyword identity (KW_NONE for a non-identifier or non-keyword). The diff --git a/lang/cpp/pp/pp.c b/lang/cpp/pp/pp.c @@ -63,7 +63,7 @@ void src_next_raw_into(Pp* pp, Tok* out, HidesetId* hs_out, u8* src_kind_out) { if (s->kind == SRC_BUF) { if (s->i < s->n) { *out = s->toks[s->i]; - if (hs_out) *hs_out = s->hs ? s->hs[s->i] : HS_EMPTY; + if (hs_out) *hs_out = s->hs ? s->hs[s->i] : s->hs_uniform; if (src_kind_out) *src_kind_out = SRC_BUF; ++s->i; return; @@ -138,6 +138,21 @@ void push_buf(Pp* pp, Tok* toks, HidesetId* hs, u32 n) { src_push(pp, s); } +/* Push a buffer whose tokens all share one hideset (the common macro-body + * case): no per-token array, just the scalar in hs_uniform (read when + * TokSrc.hs is NULL). */ +void push_buf_uniform(Pp* pp, Tok* toks, u32 n, HidesetId hs_uniform) { + TokSrc s; + memset(&s, 0, sizeof(s)); + s.kind = SRC_BUF; + s.toks = toks; + s.hs = NULL; + s.hs_uniform = hs_uniform; + s.i = 0; + s.n = n; + src_push(pp, s); +} + /* ============================================================ * Public streaming entries * ============================================================ */ @@ -834,7 +849,12 @@ Pp* pp_new(Compiler* c) { pp, NULL, 0, sizeof(Hideset*) * pp->hsets_cap, _Alignof(Hideset*)); pp->hsets[0] = NULL; pp->hsets_n = 1; - MacroMap_init_cap(&pp->mtab, h, 32u); + pp->hs_index_cap = 64; + pp->hs_index_used = 0; + pp->hs_index = (HidesetId*)pp_xrealloc( + pp, NULL, 0, sizeof(HidesetId) * pp->hs_index_cap, _Alignof(HidesetId)); + memset(pp->hs_index, 0, sizeof(HidesetId) * pp->hs_index_cap); + MacroTab_init(&pp->macros, h); pp_intern_keywords(pp); compute_date_time(pp); pp_register_static_predefined(pp); @@ -849,8 +869,9 @@ void pp_free(Pp* pp) { /* Pop / close any remaining lex sources. */ while (pp->nsources) src_pop(pp); pp_xfree(pp, pp->sources, sizeof(TokSrc) * pp->sources_cap); - MacroMap_fini(&pp->mtab); + MacroTab_fini(&pp->macros); pp_xfree(pp, pp->hsets, sizeof(Hideset*) * pp->hsets_cap); + pp_xfree(pp, pp->hs_index, sizeof(HidesetId) * pp->hs_index_cap); pp_xfree(pp, pp->ifstk, sizeof(IfFrame) * pp->ifstk_cap); pp_xfree(pp, pp->inc_dirs, sizeof(*pp->inc_dirs) * pp->inc_dirs_cap); c_pool_free(pp->pool); diff --git a/lang/cpp/pp/pp_expand.c b/lang/cpp/pp/pp_expand.c @@ -17,21 +17,56 @@ static int sym_in_array(const Sym* a, u32 n, Sym s) { return 0; } +/* FNV-1a over the (sorted) Sym array — the dedup index key. */ +static u32 hs_hash(const Sym* names, u32 n) { + u32 h = 0x811C9DC5u, i; + for (i = 0; i < n; ++i) { + h ^= names[i]; + h *= 0x01000193u; + } + return h; +} + +/* Re-place every live hideset (ids 1..hsets_n-1) into a freshly zeroed index + * of capacity nc (a power of two). Called when the load factor crosses 3/4. */ +static void hs_index_rebuild(Pp* pp, u32 nc) { + u32 mask, k; + pp->hs_index = (HidesetId*)pp_xrealloc( + pp, pp->hs_index, sizeof(HidesetId) * pp->hs_index_cap, + sizeof(HidesetId) * nc, _Alignof(HidesetId)); + for (k = 0; k < nc; ++k) pp->hs_index[k] = 0; + pp->hs_index_cap = nc; + mask = nc - 1; + for (k = 1; k < pp->hsets_n; ++k) { + u32 i = pp->hsets[k]->hash & mask; + while (pp->hs_index[i] != 0) i = (i + 1) & mask; + pp->hs_index[i] = (HidesetId)k; + } +} + +/* Intern a hideset: return the canonical id for `names` (content-deduplicated), + * allocating a new one on first sight. Ids are assigned in first-appearance + * order (identical to the historical linear scan — the id is internal-only, but + * the order match keeps the dedup behavior bit-for-bit). */ static HidesetId hs_register(Pp* pp, const Sym* names, u32 n) { Hideset* h; - u32 i; + u32 hash, mask, i, j; + HidesetId id; if (n == 0) return HS_EMPTY; - /* Linear search for an existing identical hideset. Hidesets are tiny. */ - for (i = 1; i < pp->hsets_n; ++i) { - Hideset* e = pp->hsets[i]; - if (e->n != n) continue; - { - u32 j; + hash = hs_hash(names, n); + mask = pp->hs_index_cap - 1; + i = hash & mask; + /* Probe for an existing identical hideset; the loop also lands `i` on the + * first empty slot for this hash, which is where a miss inserts. */ + while ((id = pp->hs_index[i]) != 0) { + Hideset* e = pp->hsets[id]; + if (e->hash == hash && e->n == n) { for (j = 0; j < n; ++j) if (e->names[j] != names[j]) break; - if (j == n) return (HidesetId)i; + if (j == n) return id; } + i = (i + 1) & mask; } if (pp->hsets_n == pp->hsets_cap) { @@ -45,9 +80,15 @@ static HidesetId hs_register(Pp* pp, const Sym* names, u32 n) { sizeof(Hideset) + sizeof(Sym) * (n ? n - 1 : 0), _Alignof(Hideset)); h->n = n; - for (i = 0; i < n; ++i) h->names[i] = names[i]; - pp->hsets[pp->hsets_n] = h; - return (HidesetId)pp->hsets_n++; + h->hash = hash; + for (j = 0; j < n; ++j) h->names[j] = names[j]; + id = (HidesetId)pp->hsets_n; + pp->hsets[pp->hsets_n++] = h; + /* Insert at the empty slot the probe found, then grow+rehash if loaded. */ + pp->hs_index[i] = id; + if (++pp->hs_index_used * 4u >= pp->hs_index_cap * 3u) + hs_index_rebuild(pp, pp->hs_index_cap * 2u); + return id; } int hs_contains(Pp* pp, HidesetId id, Sym s) { @@ -87,25 +128,10 @@ HidesetId hs_add(Pp* pp, HidesetId id, Sym s) { } /* ============================================================ - * Macro table - * ============================================================ */ - -/* Thin wrappers over the generated MacroMap_* functions; preserved - * because the call sites are tagged "mt_*" throughout this TU. */ -Macro* mt_get(Pp* pp, Sym name) { - Macro** v = MacroMap_get(&pp->mtab, name); - return v ? *v : NULL; -} - -void mt_put(Pp* pp, Sym name, Macro* m) { - (void)MacroMap_set(&pp->mtab, name, m); -} - -void mt_del(Pp* pp, Sym name) { MacroMap_del(&pp->mtab, name); } - -/* ============================================================ * #define / #undef * ============================================================ */ +/* mt_get / mt_put / mt_del are inlined Sym-indexed loads in pp_priv.h + * (MacroTab, a SYMTAB mapping Sym -> Macro*). */ void do_define(Pp* pp, const Tok* line, u32 n) { Macro* m; @@ -338,7 +364,6 @@ static void expand_object_macro(Pp* pp, const Macro* m, const Tok* invoke, TokVec body = {0}; Tok* tmp; HidesetId hs; - HidesetId* hids; u32 i; if (m->body_len == 0) { @@ -359,9 +384,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->xarena, HidesetId, body.n); - for (i = 0; i < body.n; ++i) hids[i] = hs; - push_buf(pp, body.data, hids, body.n); + push_buf_uniform(pp, body.data, body.n, hs); } /* ============================================================ @@ -888,15 +911,9 @@ static int try_expand_func_macro(Pp* pp, const Macro* m, const Tok* invoke, result_hs = hs_add(pp, invoke_hs, m->name); substitute_body(pp, m, &args, invoke, &body); - { - u32 i; - HidesetId* hids = arena_array(pp->xarena, HidesetId, body.n ? body.n : 1); - /* result_hs is uniform across the entire substituted body, so fill it - * directly — like the object-macro path — instead of materializing a - * parallel hideset token vector. */ - for (i = 0; i < body.n; ++i) hids[i] = result_hs; - push_buf(pp, body.data, hids, body.n); - } + /* result_hs is uniform across the entire substituted body, so push it as a + * single-hideset buffer — no per-token parallel array. */ + push_buf_uniform(pp, body.data, body.n, result_hs); return 1; } @@ -943,7 +960,7 @@ void pp_next_raw_into(Pp* pp, Tok* out) { (s = &pp->sources[pp->nsources - 1])->kind == SRC_BUF && s->i < s->n) { *out = s->toks[s->i]; - hs = s->hs ? s->hs[s->i] : HS_EMPTY; + hs = s->hs ? s->hs[s->i] : s->hs_uniform; src_kind = SRC_BUF; ++s->i; } else { diff --git a/lang/cpp/pp/pp_priv.h b/lang/cpp/pp/pp_priv.h @@ -8,6 +8,8 @@ #include <stdlib.h> #include <string.h> +#include <kit/support/symtab.h> + #include "cpp_support.h" #include "pp/pp.h" @@ -35,11 +37,17 @@ typedef struct Macro { u32 body_len; } Macro; +/* MacroTab = Sym -> Macro* (NULL = not a macro). Sym-indexed dense table: + * the macro binding is read with one in-range load on every identifier in + * the expander's hot loop, replacing a per-identifier hashmap probe. */ +KIT_SYMTAB_DEFINE(MacroTab, Macro*); + typedef u32 HidesetId; #define HS_EMPTY 0u typedef struct Hideset { u32 n; + u32 hash; /* content hash of names[0..n) — dedup index key (see hs_register) */ Sym names[1]; /* flexible; allocated with extra trailing slots */ } Hideset; @@ -57,7 +65,14 @@ typedef struct TokSrc { Lexer* lex; /* SRC_BUF */ Tok* toks; + /* Per-token hidesets. When `hs` is non-NULL it is a parallel array (one id + * per token) — used only by the argument-prescan path, where tokens can + * carry differing hidesets. When `hs` is NULL the whole buffer shares the + * single `hs_uniform` id: macro-body expansions (object- and function-like) + * are uniform, so they take this path and skip the per-token array entirely + * (no alloc, no fill, one scalar instead of n copies). */ HidesetId* hs; + HidesetId hs_uniform; u32 i; u32 n; /* #line state (SRC_LEX only). line_delta is added to every emitted @@ -88,11 +103,8 @@ typedef struct IfFrame { SrcLoc loc; } IfFrame; -/* MacroMap = Sym -> Macro*. Generated open-addressed hashmap with - * deletion (#undef). See core/hashmap.h. */ +/* Sym-keyed hashmaps below (include caches). See core/hashmap.h. */ #include <kit/support/hashmap.h> -static inline u32 macro_hash_(Sym s) { return kit_hash_u32((u32)s); } -KIT_HASHMAP_DEFINE(MacroMap, Sym, Macro*, macro_hash_); /* IncCache = Sym(resolved-path) -> cached header bytes. The same guarded * header included many times across a TU is read from disk once; later @@ -137,8 +149,8 @@ struct Pp { u32 nsources; u32 sources_cap; - /* Macro table (open-addressed; key = Sym, value = Macro*). */ - MacroMap mtab; + /* Macro table (Sym-indexed dense; value = Macro*, NULL = not a macro). */ + MacroTab macros; /* Header-content cache (key = interned resolved-path Sym, value = * arena-resident bytes). Collapses N re-includes of the same header to @@ -162,6 +174,13 @@ struct Pp { Hideset** hsets; u32 hsets_n; u32 hsets_cap; + /* Content-addressed dedup index over hsets: open-addressed, power-of-two, + * slot holds a HidesetId (0 = empty). Replaces the O(n) linear scan in + * hs_register with an O(1) probe — the per-macro-invocation dedup was the + * superlinear cost on macro-heavy input. */ + HidesetId* hs_index; + u32 hs_index_cap; + u32 hs_index_used; /* Include directories (stage 9). */ struct { @@ -360,6 +379,7 @@ Tok src_next_raw(Pp* pp, HidesetId* hs_out, u8* src_kind_out); void src_push(Pp* pp, TokSrc s); void src_pop(Pp* pp); void push_buf(Pp* pp, Tok* toks, HidesetId* hs, u32 n); +void push_buf_uniform(Pp* pp, Tok* toks, u32 n, HidesetId hs_uniform); /* pp_next_raw is the mutual-recursion entry: expand_arg_to_eof calls it, * and pp_next_raw drives directives and expansion. Declared non-static so @@ -372,11 +392,21 @@ Tok pp_next_raw(Pp* pp); /* --- pp_expand.c → pp.c, pp_directive.c --- */ HidesetId hs_add(Pp* pp, HidesetId id, Sym s); int hs_contains(Pp* pp, HidesetId id, Sym s); -Macro* mt_get(Pp* pp, Sym name); -void mt_put(Pp* pp, Sym name, Macro* m); -void mt_del(Pp* pp, Sym name); void expand_arg_to_eof(Pp* pp, Tok* in, HidesetId* hs, u32 nin, TokVec* out); +/* Macro binding lookup/define/undef. Inlined Sym-indexed loads (no hashmap + * probe): mt_get runs on every identifier the expander sees. NULL/absent = + * not a macro; #undef stores NULL. */ +static inline Macro* mt_get(Pp* pp, Sym name) { + return MacroTab_get(&pp->macros, name); +} +static inline void mt_put(Pp* pp, Sym name, Macro* m) { + MacroTab_set(&pp->macros, name, m); +} +static inline void mt_del(Pp* pp, Sym name) { + MacroTab_set(&pp->macros, name, NULL); +} + /* --- pp_directive.c → pp_expand.c --- */ i64 eval_if_expr(Pp* pp, const Tok* line, u32 n, SrcLoc loc); void process_directive(Pp* pp, SrcLoc hash_loc);