kit

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

commit d3e8b7f09626f91da77683a4c532256088448400
parent 492813f8d4bc58c72af70327f281a08e832ca959
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 14:00:51 -0700

refactor(c): semantic tagged-type interning; move CG-id cache onto the node

Replace the two memcmp(&n->ty, &tmpl, sizeof(Type)) interning probes in
type_qualified/type_unqual with a semantic type_tagged_eq (kind + qual +
the rec/enm identity payload). The memcmp only worked because every tagged
node carries zeroed padding (alloc_type_node memsets, struct assignment
propagates) — a latent footgun, and the thing that forced the just-added
lowered-id cache onto a side map. The swap is provably output-identical:
zeroed padding means memcmp and a field compare can never disagree.

With interning no longer a whole-struct compare, the lowered KitCgTypeId
moves from the CgTypeMemo side-table onto a cg_id field in Type — it fits
the alignment hole before the union (0 size growth) and is a field read
instead of a hash lookup. It is not part of type identity (neither the
structural intern set nor type_tagged_eq looks at it), and the
incomplete-free guard (TypeCgLower.saw_incomplete) is unchanged, so a
pointer-to-incomplete-record still isn't cached until the record completes.

Byte-identical: perf_identity_gate.sh (all categories incl. linked exe);
sqlite3.c -O0/-O1/-g; forward-decl + const-record hazard battery. sqlite
-O0 +1.3% vs the side-table (~+8% vs pre-Lever-3); side-table removed.
test-parse D/R/J 3069/0 + test-{cg-api,toy,opt,debug,dwarf,pp} clean
(the /E lane needs podman, unavailable from a /tmp worktree).

Diffstat:
Mlang/c/type/type.c | 93+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mlang/c/type/type.h | 6++++++
2 files changed, 59 insertions(+), 40 deletions(-)

diff --git a/lang/c/type/type.c b/lang/c/type/type.c @@ -111,16 +111,6 @@ static inline u32 type_ptr_hash(const Type* t) { return kit_hash_u64((uint64_t)(uintptr_t)t); } -/* Lowered KitCgTypeId memo, keyed by the canonical (interned) Type*. type_cg_lower - * re-derives ptr/array/func/enum on every pcg_tid() call (per value-push, per - * load/store, per op); since types intern to one canonical node, the lowered id - * can be cached directly against that node. Records keep their own tag-id memo - * (cg_records) because one layout is shared across a tag's many Type nodes. Only - * VALUE-mode results are cached (TYPE_CG_RECORD_FIELD collapses pointers to - * void*), and only when the lowering touched no still-incomplete record — see - * type_cg_lower. Pool is 1:1 with a compiler, so a bare Type* key suffices. */ -KIT_HASHMAP_DEFINE(CgTypeMemo, const Type*, KitCgTypeId, type_ptr_hash); - /* Canonical-unqualified Type* memo, keyed by the qualified Type*. Collapses the * O(derived) linear scan type_unqual runs for a complete tagged type (the hot * const-record path) to one lookup. Only stable answers are inserted (a complete @@ -139,8 +129,6 @@ typedef struct PoolTypeCache { TypeListNode* derived; /* Completed record layout ids, keyed by tag id. */ CgRecordMap cg_records; - /* Lowered KitCgTypeId per canonical Type* (VALUE-mode, incomplete-free). */ - CgTypeMemo cg_types; /* Canonical-unqualified node per qualified Type* (stable answers only). */ CgUnqualMemo unqual; /* Tag id allocator (1-based; TAG_NONE = 0). */ @@ -159,7 +147,6 @@ static PoolTypeCache* cache_get(Pool* p) { * needed. Lazy (cap 0): the first insert allocates. */ TypeInternSet_init_cap(&c->structural, &p->arena_heap, 0); CgRecordMap_init_cap(&c->cg_records, &p->arena_heap, 0); - CgTypeMemo_init_cap(&c->cg_types, &p->arena_heap, 0); CgUnqualMemo_init_cap(&c->unqual, &p->arena_heap, 0); p->type_cache = c; return c; @@ -178,7 +165,15 @@ static Type* alloc_type_node(Pool* p, PoolTypeCache* c) { * zeroed. Each constructor writes exactly the fields its kind reads; the * structural set compares those fields (not raw bytes), so the union tail and * padding may stay uninitialized. */ -static Type* alloc_struct_type(Pool* p) { return arena_new(p->arena, Type); } +static Type* alloc_struct_type(Pool* p) { + Type* t = arena_new(p->arena, Type); + /* cg_id is read by the on-node lowering cache, so it must be initialized even + * though the union tail is intentionally left undefined (see the header). The + * qualified/unqual paths that do `*t = *base` then inherit base's cached id, + * which is correct since lowering ignores qualifiers. */ + if (t) t->cg_id = KIT_CG_TYPE_NONE; + return t; +} const Type* type_void(Pool* p) { return type_prim(p, TY_VOID); } @@ -275,33 +270,52 @@ const Type* type_func(Pool* p, const Type* ret, const Type** params, u16 n, return t; } +/* Semantic identity of a tagged type for interning: kind + qual + the rec/enm + * identity payload. Replaces a memcmp over sizeof(Type) — which worked only + * because every tagged node carries zeroed padding (alloc_type_node memsets and + * struct assignment propagates it), a latent footgun that also forced the + * lowered-id cache off the node onto a side map. `incomplete` is part of the + * key (unlike type_compatible) because the qualified-snapshot model keeps an + * incomplete and a later-completed `const struct S` as distinct nodes. `n` is + * an interned node; `base` + the wanted `qual` describe the target (the payload + * is base's). */ +static int type_tagged_eq(const Type* n, const Type* base, u16 qual) { + if (n->kind != base->kind || n->qual != qual) return 0; + switch ((TypeKind)base->kind) { + case TY_STRUCT: + case TY_UNION: + return n->rec.tag_id == base->rec.tag_id && n->rec.tag == base->rec.tag && + n->rec.fields == base->rec.fields && + n->rec.nfields == base->rec.nfields && + n->rec.incomplete == base->rec.incomplete && + n->rec.packed == base->rec.packed && + n->rec.max_align == base->rec.max_align && + n->rec.align_override == base->rec.align_override; + case TY_ENUM: + return n->enm.tag_id == base->enm.tag_id && + n->enm.tag == base->enm.tag && n->enm.base == base->enm.base; + default: + return 0; + } +} + const Type* type_qualified(Pool* p, const Type* base, u16 qual) { PoolTypeCache* c; - Type tmpl; Type* t; if (!base || qual == 0) return base; c = cache_get(p); if (!c) return NULL; if (base->kind == TY_STRUCT || base->kind == TY_UNION || base->kind == TY_ENUM) { - /* Tagged types are identity-interned on `derived`. Build the comparison - * template via assignment (not aggregate initialization). Struct assignment - * copies the full object representation, including padding bytes, so the - * memcmp below is byte-stable against the interned nodes (which are likewise - * produced by `*t = tmpl`). An aggregate *initializer* (`Type tmpl = - * *base;`) is lowered as a field-by-field copy that leaves padding - * unspecified, which would make this memcmp miss and intern a duplicate. */ - tmpl = *base; - tmpl.qual = qual; + /* Tagged types are identity-interned on `derived` by semantic payload + * (type_tagged_eq), not a raw-byte compare. */ for (TypeListNode* n = c->derived; n; n = n->next) { - if (n->ty.kind == base->kind && n->ty.qual == qual && - memcmp(&n->ty, &tmpl, sizeof(Type)) == 0) { - return &n->ty; - } + if (type_tagged_eq(&n->ty, base, qual)) return &n->ty; } t = alloc_type_node(p, c); if (!t) return NULL; - *t = tmpl; + *t = *base; + t->qual = qual; return t; } /* Non-tagged: structurally interned (qualified prim / ptr / array / func). */ @@ -434,7 +448,6 @@ const Type* type_enum(Pool* p, TagId tag_id, Sym tag, const Type* base) { const Type* type_unqual(Pool* p, const Type* t) { PoolTypeCache* c; - Type tmpl; Type* nt; if (!t || t->qual == 0) return t; if ((unsigned)t->kind < NUM_PRIM_KINDS) @@ -461,14 +474,13 @@ const Type* type_unqual(Pool* p, const Type* t) { } } } - tmpl = *t; - tmpl.qual = 0; for (TypeListNode* n = c->derived; n; n = n->next) { - if (memcmp(&n->ty, &tmpl, sizeof(Type)) == 0) return &n->ty; + if (type_tagged_eq(&n->ty, t, 0)) return &n->ty; } nt = alloc_type_node(p, c); if (!nt) return NULL; - *nt = tmpl; + *nt = *t; + nt->qual = 0; return nt; } /* Non-tagged: structurally interned (qualified prim is handled above as a @@ -830,11 +842,9 @@ static KitCgTypeId type_cg_lower(TypeCgLower* l, const Type* t, /* Only VALUE-mode lowering is memoized on the node: TYPE_CG_RECORD_FIELD * collapses pointers to void*, so the same Type* lowers differently there * (and that mode is used only transiently for record fields / func params). */ - cacheable = (mode == TYPE_CG_VALUE && l->cache != NULL); - if (cacheable) { - const KitCgTypeId* hit = CgTypeMemo_get(&l->cache->cg_types, t); - if (hit) return *hit; /* cached => was incomplete-free => still stable */ - } + cacheable = (mode == TYPE_CG_VALUE); + if (cacheable && t->cg_id != KIT_CG_TYPE_NONE) + return t->cg_id; /* cached => was incomplete-free => still stable */ /* Scope saw_incomplete to this subtree so the post-recursion check reflects * only whether *this* node's lowering touched an incomplete record. */ outer_incomplete = l->saw_incomplete; @@ -889,7 +899,10 @@ static KitCgTypeId type_cg_lower(TypeCgLower* l, const Type* t, break; } if (cacheable && id != KIT_CG_TYPE_NONE && !l->saw_incomplete) { - CgTypeMemo_set(&l->cache->cg_types, t, id); + /* The node is the canonical interned type and we own its storage (arena); + * the const is only the public contract. Stashing the stable lowering here + * is exactly what dropping the memcmp interning bought us. */ + ((Type*)t)->cg_id = id; } /* Propagate this subtree's incompleteness to the enclosing lowering. */ l->saw_incomplete |= outer_incomplete; diff --git a/lang/c/type/type.h b/lang/c/type/type.h @@ -86,6 +86,12 @@ typedef struct Field { struct Type { u16 kind; u16 qual; + /* Memoized VALUE-mode CG lowering of this canonical node (0 = not yet + * lowered). Sits in the alignment hole before the 8-aligned union, so the + * node stays the same size. NOT part of type identity: the structural intern + * set compares named fields and type_tagged_eq compares the rec/enm payload, + * so this mutable cache never participates in interning. See type_cg_lower. */ + KitCgTypeId cg_id; union { struct { const Type* pointee;