kit

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

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

perf: linearize C-frontend declarations, buf patch, and section dedup

The -O0 compile+link scaling benchmark (make bench-cc) found five input axes
were O(n^2): every path that adds N entries to a symbol/scope/type table did a
linear scan per item. Make them O(1)/O(log n).

- C frontend (lang/c/parse): Scope.entries, Scope.tags and the file-scope
  external_funcs list were LIFO linked lists scanned by scope_lookup*,
  tag_lookup*, external_func_* and make_local_aligned's redefinition check.
  Each now carries a KIT_HASHMAP_DEFINE index keyed on the interned Sym, built
  lazily once a scope exceeds a small threshold (tiny block scopes stay on the
  cheap list) and allocated through an arena-heap facade so there is nothing to
  free.

- Chunked buffer (src/core/buf): buf_patch/buf_read walked chunks from the head
  (O(N_chunks)), so single-pass emission patch-ups over a multi-MB .text were
  O(n^2). Add a sorted chunk-start directory + binary search -> O(log n);
  append stays O(1).

- Object builder (src/obj/obj): obj_section's find-or-create linearly scanned
  every section; add a composite-key SecKeyIndex. Latent today (cc emits one
  .text) but O(n^2) under -ffunction-sections.

All axes are now linear. On the formerly-O(n^2) axes kit went from 6-70x slower
than clang -O0 to as fast or faster (type-decl 70x slower -> 1.6x faster;
fn-count 7.5x -> 3x faster; ref-density 6x -> 6x faster; locals 25x faster).
Full frontend/cg/link/elf/macho/debug/dwarf/smoke suites pass under ASan/UBSan.

Diffstat:
Mlang/c/parse/parse.c | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mlang/c/parse/parse_priv.h | 30++++++++++++++++++++++--------
Msrc/core/buf.c | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/core/buf.h | 15+++++++++++++++
Msrc/obj/obj.c | 40+++++++++++++++++++++++++++++++++-------
5 files changed, 245 insertions(+), 67 deletions(-)

diff --git a/lang/c/parse/parse.c b/lang/c/parse/parse.c @@ -287,11 +287,44 @@ u32 count_recorded_top_level_items(const Tok* vec, u32 len) { * Scopes * ============================================================ */ +/* A scope only builds its hashmap index once it holds more than this many + * entries; below it the linear LIFO scan is cheaper than a hashed lookup and + * needs no allocation. The bound is a constant, so even pre-index scopes are + * O(1) per lookup. */ +#define SCOPE_INDEX_THRESHOLD 12u + +/* KitHeap facade over the pool's arena: the scope/tag/external indexes allocate + * through this so all their storage lives in the per-TU arena and is reclaimed + * in bulk when the pool is freed. The file scope is never popped, so this also + * removes any per-scope teardown bookkeeping. `free` is a no-op (the arena owns + * the memory); resize orphans the old table in the arena, which is bounded by + * the final table size and negligible against the rest of the TU's arena use. + */ +static void* c_arena_heap_alloc(KitHeap* h, size_t n, size_t align) { + return kit_arena_alloc((KitArena*)h->user, n, align); +} +static void* c_arena_heap_realloc(KitHeap* h, void* old, size_t old_n, + size_t new_n, size_t align) { + void* q = kit_arena_alloc((KitArena*)h->user, new_n, align); + if (q && old && old_n) memcpy(q, old, old_n < new_n ? old_n : new_n); + return q; +} +static void c_arena_heap_free(KitHeap* h, void* p, size_t n) { + (void)h; + (void)p; + (void)n; +} +static void c_arena_heap_init(Parser* p) { + p->arena_heap.alloc = c_arena_heap_alloc; + p->arena_heap.realloc = c_arena_heap_realloc; + p->arena_heap.free = c_arena_heap_free; + p->arena_heap.user = p->pool->arena; +} + Scope* scope_new(Parser* p, Scope* parent) { Scope* s = arena_new(p->pool->arena, Scope); if (!s) perr(p, "out of memory in scope_new"); - s->entries = NULL; - s->tags = NULL; + memset(s, 0, sizeof *s); s->parent = parent; s->saved_vla_mark = p->vla_mark; return s; @@ -306,35 +339,62 @@ void scope_pop(Parser* p) { } } +/* Build a scope's name->entry index from its existing LIFO list. Insert + * newest-first with try_insert (which keeps the first writer) so the head — the + * current binding a linear scan would return — wins on shadowing. */ +static void scope_entries_index_build(Parser* p, Scope* s) { + SymEntry* e; + SymEntryMap_init_cap(&s->emap, &p->arena_heap, 64u); + for (e = s->entries; e; e = e->next) + if (e->name) (void)SymEntryMap_try_insert(&s->emap, e->name, e, NULL); +} + +static void scope_entries_index_put(Parser* p, Scope* s, SymEntry* e) { + if (!e->name) + return; /* anonymous: never name-looked-up, keep off the index */ + if (s->emap.cap) { + (void)SymEntryMap_set(&s->emap, e->name, e); /* newest define wins */ + } else if (s->nentries > SCOPE_INDEX_THRESHOLD) { + scope_entries_index_build(p, s); + } +} + +static SymEntry* scope_entries_find(Scope* s, Sym name) { + SymEntry* e; + if (name && s->emap.cap) { + SymEntry** v = SymEntryMap_get(&s->emap, name); + return v ? *v : NULL; + } + for (e = s->entries; e; e = e->next) + if (e->name == name) return e; + return NULL; +} + SymEntry* scope_define(Parser* p, Sym name, SymEntryKind kind, const Type* type) { SymEntry* e = arena_new(p->pool->arena, SymEntry); + Scope* s = p->scope; if (!e) perr(p, "out of memory in scope_define"); memset(e, 0, sizeof *e); e->name = name; e->kind = (u8)kind; e->type = type; - e->next = p->scope->entries; - p->scope->entries = e; + e->next = s->entries; + s->entries = e; + s->nentries++; + scope_entries_index_put(p, s, e); return e; } SymEntry* scope_lookup_current(Parser* p, Sym name) { - SymEntry* e; - if (!p->scope) return NULL; - for (e = p->scope->entries; e; e = e->next) { - if (e->name == name) return e; - } - return NULL; + return p->scope ? scope_entries_find(p->scope, name) : NULL; } SymEntry* scope_lookup(Parser* p, Sym name) { Scope* s; for (s = p->scope; s; s = s->parent) { - SymEntry* e; - for (e = s->entries; e; e = e->next) { - if (e->name == name) return e; - } + SymEntry* e = scope_entries_find(s, name); + if (e) return e; } return NULL; } @@ -350,28 +410,13 @@ static void sym_set_decl(SymEntry* e, DeclId id, DeclStorage storage, } static SymEntry* external_func_lookup(Parser* p, Sym name) { - ExternalFuncDecl* f; - for (f = p->external_funcs; f; f = f->next) { - if (f->name == name) return f->entry; - } - return NULL; + SymEntry** v = name ? ExternalFuncMap_get(&p->external_funcs, name) : NULL; + return v ? *v : NULL; } static void external_func_remember(Parser* p, Sym name, SymEntry* entry) { - ExternalFuncDecl* f; - if (!entry) return; - for (f = p->external_funcs; f; f = f->next) { - if (f->name == name) { - f->entry = entry; - return; - } - } - f = arena_new(p->pool->arena, ExternalFuncDecl); - if (!f) perr(p, "out of memory in external_func_remember"); - f->name = name; - f->entry = entry; - f->next = p->external_funcs; - p->external_funcs = f; + if (!entry || !name) return; + (void)ExternalFuncMap_set(&p->external_funcs, name, entry); /* newest wins */ } static int is_ordinary_decl_kind(SymEntryKind k) { @@ -391,37 +436,61 @@ static void reject_same_scope_redefinition(Parser* p, Sym name, perr(p, "redefinition of identifier"); } +static void tag_index_build(Parser* p, Scope* s) { + TagEntry* e; + TagEntryMap_init_cap(&s->tmap, &p->arena_heap, 64u); + for (e = s->tags; e; e = e->next) + if (e->name) (void)TagEntryMap_try_insert(&s->tmap, e->name, e, NULL); +} + +static void tag_index_put(Parser* p, Scope* s, TagEntry* e) { + if (!e->name) return; /* anonymous struct/union: not name-looked-up */ + if (s->tmap.cap) { + (void)TagEntryMap_set(&s->tmap, e->name, e); + } else if (s->ntags > SCOPE_INDEX_THRESHOLD) { + tag_index_build(p, s); + } +} + +static TagEntry* tag_find_in(Scope* s, Sym name) { + TagEntry* e; + if (name && s->tmap.cap) { + TagEntry** v = TagEntryMap_get(&s->tmap, name); + return v ? *v : NULL; + } + for (e = s->tags; e; e = e->next) + if (e->name == name) return e; + return NULL; +} + TagEntry* tag_define(Parser* p, Sym name, TagDeclKind kind, Type* type, int complete) { TagEntry* e = arena_new(p->pool->arena, TagEntry); + Scope* s = p->scope; if (!e) perr(p, "out of memory in tag_define"); memset(e, 0, sizeof *e); e->name = name; e->kind = (u8)kind; e->complete = (u8)(complete ? 1 : 0); e->type = type; - e->next = p->scope->tags; - p->scope->tags = e; + e->next = s->tags; + s->tags = e; + s->ntags++; + tag_index_put(p, s, e); return e; } TagEntry* tag_lookup(Parser* p, Sym name) { Scope* s; for (s = p->scope; s; s = s->parent) { - TagEntry* e; - for (e = s->tags; e; e = e->next) { - if (e->name == name) return e; - } + TagEntry* e = tag_find_in(s, name); + if (e) return e; } return NULL; } TagEntry* tag_lookup_local(Parser* p, Sym name) { - TagEntry* e; - for (e = p->scope->tags; e; e = e->next) { - if (e->name == name) return e; - } - return NULL; + return tag_find_in(p->scope, name); } /* ============================================================ @@ -1542,6 +1611,11 @@ void parse_c(Compiler* c, Pool* pool, Pp* pp, DeclTable* decls, CG* cg, p.default_visibility = parser_default_visibility(default_visibility); p.auto_var_init = (u8)auto_var_init; + /* Index storage for the scope/tag tables and the external-function table all + * comes from the arena via this facade; the maps need it before first use. */ + c_arena_heap_init(&p); + ExternalFuncMap_init(&p.external_funcs, &p.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])); } diff --git a/lang/c/parse/parse_priv.h b/lang/c/parse/parse_priv.h @@ -6,6 +6,7 @@ #pragma once +#include <kit/support/hashmap.h> #include <stdarg.h> #include <string.h> @@ -88,7 +89,6 @@ typedef enum SymEntryKind { typedef struct SymEntry SymEntry; typedef struct VLABound VLABound; -typedef struct ExternalFuncDecl ExternalFuncDecl; typedef struct StaticReloc StaticReloc; struct VLABound { const Type* array_ty; @@ -142,12 +142,6 @@ struct SymEntry { SymEntry* next; }; -struct ExternalFuncDecl { - Sym name; - SymEntry* entry; - ExternalFuncDecl* next; -}; - typedef struct TagEntry TagEntry; struct TagEntry { Sym name; @@ -159,12 +153,28 @@ struct TagEntry { TagEntry* next; }; +/* Name -> entry indexes, keyed on the interned Sym (Sym 0 = "none" doubles as + * the hashmap empty sentinel). Each scope keeps its LIFO list as the source of + * truth and ordering, and lazily builds an index once it grows past + * SCOPE_INDEX_THRESHOLD, so the O(n^2) "scan the whole scope per declaration" + * cost (file scopes with thousands of globals/typedefs, functions with + * thousands of locals) collapses to O(1) average lookups while tiny block + * scopes stay on the cheap linear list. The maps allocate through the parser's + * arena-heap facade (see parse.c) so there is nothing to free. */ +KIT_HASHMAP_DEFINE(SymEntryMap, Sym, SymEntry*, kit_hash_u32); +KIT_HASHMAP_DEFINE(TagEntryMap, Sym, TagEntry*, kit_hash_u32); +KIT_HASHMAP_DEFINE(ExternalFuncMap, Sym, SymEntry*, kit_hash_u32); + typedef struct Scope Scope; struct Scope { SymEntry* entries; /* LIFO */ TagEntry* tags; /* LIFO */ Scope* parent; u32 saved_vla_mark; + u32 nentries; /* count in `entries`; index built once it exceeds threshold */ + u32 ntags; /* count in `tags`; index built once it exceeds threshold */ + SymEntryMap emap; /* name -> entry; active (cap != 0) once built */ + TagEntryMap tmap; /* name -> tag; active (cap != 0) once built */ }; /* ============================================================ @@ -308,7 +318,11 @@ typedef struct Parser { Sym sym_a_signal_fence; Scope* scope; - ExternalFuncDecl* external_funcs; + /* name -> current file-scope function entry. Replaces what was an O(n) linear + * list walked on every function declaration/reference. */ + ExternalFuncMap external_funcs; + /* KitHeap facade over pool->arena backing the scope/tag/external indexes. */ + KitHeap arena_heap; CGLabel cur_break; CGLabel cur_continue; diff --git a/src/core/buf.c b/src/core/buf.c @@ -1,6 +1,8 @@ -/* Chunked byte buffer. Append is O(1) (spills to a fresh chunk when the - * tail fills). Random-access patch is O(N_chunks) — sections rarely - * cross more than a handful of chunks, so a linear walk is fine. */ +/* Chunked byte buffer. Append is O(1) (spills to a fresh chunk when the tail + * fills). Random-access patch/read is O(log N_chunks) via a sorted directory of + * chunk start offsets — large sections (e.g. a multi-megabyte .text emitted for + * tens of thousands of functions) span hundreds of chunks, and per-function + * patch-ups would otherwise be O(N_chunks) each, i.e. O(n^2) overall. */ #include "core/buf.h" @@ -21,6 +23,9 @@ void buf_init(Buf* b, Heap* h) { b->head = NULL; b->tail = NULL; b->total = 0; + b->dir = NULL; + b->ndir = 0; + b->dir_cap = 0; } void buf_fini(Buf* b) { @@ -30,8 +35,33 @@ void buf_fini(Buf* b) { b->heap->free(b->heap, c, sizeof(BufChunk) + c->cap); c = next; } + if (b->dir) + b->heap->free(b->heap, b->dir, (size_t)b->dir_cap * sizeof(BufChunkRef)); b->head = b->tail = NULL; b->total = 0; + b->dir = NULL; + b->ndir = b->dir_cap = 0; +} + +/* Record a newly created chunk in the directory. Best-effort: on allocation + * failure the chunk is still reachable via the `next` chain, so buf_walk stays + * correct (just slower for offsets past the last indexed chunk). */ +static void buf_dir_push(Buf* b, BufChunk* c, u32 start) { + if (b->ndir == b->dir_cap) { + u32 ncap = b->dir_cap ? b->dir_cap * 2u : 16u; + BufChunkRef* nd = (BufChunkRef*)b->heap->alloc( + b->heap, (size_t)ncap * sizeof(BufChunkRef), _Alignof(BufChunkRef)); + if (!nd) return; + if (b->dir) { + memcpy(nd, b->dir, (size_t)b->ndir * sizeof(BufChunkRef)); + b->heap->free(b->heap, b->dir, (size_t)b->dir_cap * sizeof(BufChunkRef)); + } + b->dir = nd; + b->dir_cap = ncap; + } + b->dir[b->ndir].start = start; + b->dir[b->ndir].chunk = c; + b->ndir++; } static int buf_ensure_tail(Buf* b, size_t need) { @@ -41,6 +71,8 @@ static int buf_ensure_tail(Buf* b, size_t need) { cap = need > BUF_CHUNK ? need : BUF_CHUNK; c = chunk_new(b->heap, cap); if (!c) return 1; + /* The new chunk starts at the current total (all prior chunks are frozen). */ + buf_dir_push(b, c, b->total); if (!b->head) b->head = c; if (b->tail) b->tail->next = c; b->tail = c; @@ -79,11 +111,28 @@ u32 buf_pos(const Buf* b) { return b->total; } * * `direction`: 0 = copy from `external` into chunk; 1 = copy from chunk * into `external`. Inlined at both call sites by clang. */ -static inline void buf_walk(BufChunk* head, u32 ofs, void* external, size_t n, +static inline void buf_walk(const Buf* b, u32 ofs, void* external, size_t n, int from_chunk) { - BufChunk* c = head; - u32 chunk_start = 0; + BufChunk* c; + u32 chunk_start; u8* ext = (u8*)external; + /* Binary-search the directory for the last chunk whose start is <= ofs, then + * walk forward from there (the range may span into following chunks). */ + u32 lo = 0, hi = b->ndir; + while (lo < hi) { + u32 mid = lo + ((hi - lo) >> 1); + if (b->dir[mid].start <= ofs) + lo = mid + 1; + else + hi = mid; + } + if (lo == 0) { + c = b->head; + chunk_start = 0; + } else { + c = b->dir[lo - 1].chunk; + chunk_start = b->dir[lo - 1].start; + } while (c && n) { u32 chunk_end = chunk_start + c->used; if (ofs < chunk_end) { @@ -107,11 +156,11 @@ static inline void buf_walk(BufChunk* head, u32 ofs, void* external, size_t n, } void buf_patch(Buf* b, u32 ofs, const void* data, size_t n) { - buf_walk(b->head, ofs, (void*)data, n, /*from_chunk=*/0); + buf_walk(b, ofs, (void*)data, n, /*from_chunk=*/0); } void buf_read(const Buf* b, u32 ofs, void* dst, size_t n) { - buf_walk(b->head, ofs, dst, n, /*from_chunk=*/1); + buf_walk(b, ofs, dst, n, /*from_chunk=*/1); } void buf_flatten(const Buf* b, u8* dst) { diff --git a/src/core/buf.h b/src/core/buf.h @@ -14,11 +14,26 @@ struct BufChunk { u8 data[]; }; +/* Directory entry: a chunk and the buffer offset of its first byte. Chunk + * starts increase monotonically, so the directory is a sorted index that lets + * buf_patch / buf_read binary-search to the owning chunk. */ +typedef struct BufChunkRef { + u32 start; + BufChunk* chunk; +} BufChunkRef; + typedef struct Buf { Heap* heap; BufChunk* head; BufChunk* tail; u32 total; /* sum of used across all chunks */ + /* Sorted index of chunk start offsets, appended to as chunks are created. + * Turns random-access patch/read from O(n_chunks) (walk from head) into + * O(log n_chunks). It is purely an accelerator: if a push ever fails, the + * forward `next` walk from the nearest indexed chunk still finds the byte. */ + BufChunkRef* dir; + u32 ndir; + u32 dir_cap; } Buf; void buf_init(Buf*, Heap*); diff --git a/src/obj/obj.c b/src/obj/obj.c @@ -28,6 +28,14 @@ SEGVEC_DEFINE(Symbols, ObjSym, 6); /* 64 entries per segment */ * obj_symbol_rename keeps the index exact (re-homing or dropping a renamed * symbol's entry), so the lookup never sees a stale hit. */ HASHMAP_DEFINE(SymNameIndex, Sym, ObjSymId, hash_u32); + +/* (name, kind) -> first PROGBITS ObjSecId. obj_section is a find-or-create that + * collapses repeated requests for the same logical section (e.g. one .rodata + * per literal) onto a single Section; without this index that find is a linear + * scan of every section, which is O(n^2) once a build emits many distinct + * sections (e.g. -ffunction-sections). The key packs (name, kind) and is always + * odd so it never collides with the hashmap's 0 empty-slot sentinel. */ +HASHMAP_DEFINE(SecKeyIndex, u64, ObjSecId, hash_u64); SEGVEC_DEFINE(Relocs, Reloc, 6); /* 64 entries per segment */ SEGVEC_DEFINE(Groups, ObjGroup, 3); /* 8 entries per segment */ SEGVEC_DEFINE(Atoms, ObjAtom, 5); /* 32 entries per segment */ @@ -58,6 +66,7 @@ struct KitObjBuilder { Groups groups; /* index 0 reserved as "none" */ Atoms atoms; /* index 0 reserved as "none" */ SymNameIndex sym_by_name; /* name -> first ObjSymId; accelerates find */ + SecKeyIndex sec_by_key; /* (name,kind) -> first PROGBITS ObjSecId */ /* Format-specific ELF e_flags. Set by read_elf to the input's * e_flags (e.g. on RISC-V, EF_RISCV_RVC | EF_RISCV_FLOAT_ABI_DOUBLE); * consumed by emit_elf to round-trip. Zero when not set — emit_elf @@ -115,6 +124,7 @@ ObjBuilder* obj_new(Compiler* c) { Groups_init(&ob->groups, h); Atoms_init(&ob->atoms, h); SymNameIndex_init(&ob->sym_by_name, h); + SecKeyIndex_init(&ob->sec_by_key, h); WeakAliases_init(&ob->weak_aliases, h); /* Reserve index 0 in each id space as the "none" sentinel. SegVec @@ -167,6 +177,7 @@ void obj_free(ObjBuilder* ob) { Groups_fini(&ob->groups); Atoms_fini(&ob->atoms); SymNameIndex_fini(&ob->sym_by_name); + SecKeyIndex_fini(&ob->sec_by_key); WeakAliases_fini(&ob->weak_aliases); obj_image_free_(ob); ob->heap->free(ob->heap, ob, sizeof(*ob)); @@ -466,6 +477,13 @@ void obj_ext_clear(ObjBuilder* ob, ObjExtKind kind) { /* ---- write side ---- */ +/* Pack (name, kind) into a nonzero hashmap key. The low bit is always set so + * the key is never 0 (the empty-slot sentinel), and name/kind occupy disjoint + * bit ranges so distinct (name, kind) pairs never collide. */ +static u64 sec_progbits_key(Sym name, SecKind kind) { + return (((u64)name) << 17) | (((u64)(u16)kind) << 1) | 1u; +} + ObjSecId obj_section(ObjBuilder* ob, Sym name, SecKind kind, u16 flags, u32 align) { /* Find-or-create by (name, kind, sem=PROGBITS). Repeated calls for the @@ -473,12 +491,13 @@ ObjSecId obj_section(ObjBuilder* ob, Sym name, SecKind kind, u16 flags, * .data per static initializer — collapse onto a single Section and * accumulate bytes into it instead of emitting a fan-out of identically- * named output sections. Merge align (max) and flags (union) so a - * stricter requirement from a later caller wins. */ - u32 n = Sections_count(&ob->sections); - for (u32 i = 1; i < n; ++i) { - Section* s = Sections_at(&ob->sections, i); - if (s && s->name == name && s->kind == (u16)kind && - s->sem == SSEM_PROGBITS) { + * stricter requirement from a later caller wins. The PROGBITS find is an + * O(1) index lookup (sec_by_key), kept exact by obj_section_ex. */ + ObjSecId* hit = + SecKeyIndex_get(&ob->sec_by_key, sec_progbits_key(name, kind)); + if (hit) { + Section* s = Sections_at(&ob->sections, *hit); + if (s) { if (align > s->align) s->align = align; s->flags = (u16)(s->flags | flags); /* Pad to align so the next obj_reserve / obj_write lands at an @@ -497,7 +516,7 @@ ObjSecId obj_section(ObjBuilder* ob, Sym name, SecKind kind, u16 flags, if (dst) memset(dst, 0, pad); } } - return (ObjSecId)i; + return *hit; } } return obj_section_ex(ob, name, kind, SSEM_PROGBITS, flags, align, 0, @@ -522,6 +541,13 @@ ObjSecId obj_section_ex(ObjBuilder* ob, Sym name, SecKind kind, SecSem sem, s->bss_size = 0; s->addr = 0; buf_init(&s->bytes, ob->heap); + /* Index PROGBITS sections so obj_section's find is O(1). try_insert keeps the + * first id for a (name, kind), matching the old scan's first-match semantics + * (and leaving distinct same-named COMDAT sections, created directly here, to + * resolve to the first — exactly as the linear scan did). */ + if (sem == SSEM_PROGBITS) + (void)SecKeyIndex_try_insert(&ob->sec_by_key, sec_progbits_key(name, kind), + (ObjSecId)id, NULL); return (ObjSecId)id; }