kit

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

commit bed853f9f7ddfc1e2dfa97e23198932522268b6c
parent f2841b12dee3f171dfab2888e0ebc619060a50b3
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Thu, 11 Jun 2026 16:02:39 -0700

perf(cg): memoize per-type value-stack classification + designated-init nodes

Two byte-identical constant-factor wins on the value-stack frontend (above the
CgTarget seam), gated on scripts/perf_identity_gate.sh (60-category, all PASS):

(b) Per-type classification memo. R6 cached the wide_kind result on each value
    node, but api_push still re-derived it on every operand push — a string of
    cg_type_get + alias-chase + ABI round trips (api_wide_kind_for) plus a
    cg_type_is_aggregate query. Both are pure functions of (type, target), so
    they are now computed once per type id and packed into one byte:
    builtins precomputed at cg_api init (i32/i64/f64/ptr classify with a single
    indexed load), user types filled lazily in the reclaimed CgApiType pad.
    api_push reads the aggregate-place bit and the wide/soft class from it.

(c) Designated-init value nodes. The api_op_* / api_make_* constructors drop
    their memset of the 112-byte ApiSValue for a designated compound literal, so
    the compiler emits only live-field stores and elides immediately-overwritten
    ones (same idiom as the NativeLoc constructors).

Measured (paired best-of vs the pre-change binary): body-size +3.5%,
ref-density +2.4%, locals-per-fn +2.3%, fn-count +1.6%. (b) is the bulk; (c)
~+0.75% (the delayed-union zeroing survives -ftrivial-auto-var-init=zero, so
(c) only reclaims the memset call). Gate PASS; test-cg-api/opt/toy and
test-smoke-x64 green. PERF.md Round-7 note added.

Diffstat:
Mdoc/plan/PERF.md | 19+++++++++++++++++++
Msrc/cg/fold.c | 63+++++++++++++++++++++++++++------------------------------------
Msrc/cg/internal.h | 12+++++++++++-
Msrc/cg/type.c | 55++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/cg/value.c | 101++++++++++++++++++++++++++++++++++---------------------------------------------
5 files changed, 153 insertions(+), 97 deletions(-)

diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md @@ -106,6 +106,25 @@ change: only the UUID/build-id bytes move). New tooling: `scripts/perf_identity_gate.sh` (60-category byte-identical gate) and `scripts/perf_axis_time.py` (focused A/B axis timer). +**Round 7 (value-stack frontend, byte-identical).** Two further constant-factor +wins on the value-stack layer that sits *above* the `CgTarget` seam, gated on the +same 60-category identity gate: **(b) per-type classification memo** — R6 cached +the `wide_kind` result on each node but `api_push` still re-derived it (a string of +`cg_type_get` + alias-chase + ABI round trips) on every operand; the wide/soft +class and the aggregate-place bit are now computed once per type id (builtins +precomputed at `cg_api` init, user types filled lazily in the `CgApiType` slack) +and `api_push` reads both from one packed byte. **(c) designated-init value nodes** +— the `api_op_*` / `api_make_*` constructors drop their `memset` of the (112-byte) +`ApiSValue` for a compound literal, so the compiler stores only live fields and +elides immediately-overwritten ones. Measured (paired vs the Round-6 binary): +body-size **+3.5%**, ref-density **+2.4%**, locals-per-fn **+2.3%**, fn-count +**+1.6%**; (b) is the bulk, (c) ~+0.75% (the delayed-union zeroing survives under +`-ftrivial-auto-var-init=zero`, so (c) only reclaims the memset *call*). The +node-shrink (move the 64-byte delayed cmp/arith union off the hot node) was +assessed and **deferred**: the `-O0` fold machinery relies on by-value deep-copy of +the delayed payload (`*out = *a` then divergent clears in `fold.c`), which a shared +pointer would break — a larger, riskier change than the ~1–3% it would add. + The structural bet is fully in place: a single-pass no-AST C frontend, single-pass code emission with patch-ups, and a format-neutral linker. The scaling bugs are gone (no axis is superlinear) and the big constant factors with them — per-unit diff --git a/src/cg/fold.c b/src/cg/fold.c @@ -97,18 +97,15 @@ int api_try_fold_int_cmp(KitCg* g, CmpOp op, KitCgTypeId ty, i64 a, i64 b, ApiSValue api_make_cmp(CmpOp op, Operand a, Operand b, KitCgTypeId result_ty, int a_owned, int b_owned) { - ApiSValue sv; - memset(&sv, 0, sizeof sv); - sv.kind = SV_CMP; - sv.type = result_ty; - sv.delayed.cmp.op = op; - sv.delayed.cmp.a = a; - sv.delayed.cmp.b = b; - sv.delayed.cmp.a_owned = a_owned ? 1u : 0u; - sv.delayed.cmp.b_owned = b_owned ? 1u : 0u; - sv.res = RES_INHERENT; - sv.source_local = KIT_CG_LOCAL_NONE; - return sv; + return (ApiSValue){.kind = SV_CMP, + .type = result_ty, + .res = RES_INHERENT, + .source_local = KIT_CG_LOCAL_NONE, + .delayed.cmp = {.op = op, + .a = a, + .b = b, + .a_owned = a_owned ? 1u : 0u, + .b_owned = b_owned ? 1u : 0u}}; } CmpOp api_invert_cmp(CmpOp op) { @@ -199,34 +196,28 @@ void api_materialize_cmp_to(KitCg* g, ApiSValue* sv, Operand dst) { * ============================================================ */ ApiSValue api_make_arith_unop(UnOp op, Operand a, KitCgTypeId ty, int a_owned) { - ApiSValue sv; - memset(&sv, 0, sizeof sv); - sv.kind = SV_ARITH; - sv.delayed.arith.kind = API_DELAYED_UNOP; - sv.type = ty; - sv.delayed.arith.un_op = op; - sv.delayed.arith.a = a; - sv.delayed.arith.a_owned = a_owned ? 1u : 0u; - sv.res = RES_INHERENT; - sv.source_local = KIT_CG_LOCAL_NONE; - return sv; + return (ApiSValue){.kind = SV_ARITH, + .type = ty, + .res = RES_INHERENT, + .source_local = KIT_CG_LOCAL_NONE, + .delayed.arith = {.kind = API_DELAYED_UNOP, + .un_op = op, + .a = a, + .a_owned = a_owned ? 1u : 0u}}; } ApiSValue api_make_arith_binop(BinOp op, Operand a, Operand b, KitCgTypeId ty, int a_owned, int b_owned) { - ApiSValue sv; - memset(&sv, 0, sizeof sv); - sv.kind = SV_ARITH; - sv.delayed.arith.kind = API_DELAYED_BINOP; - sv.type = ty; - sv.delayed.arith.bin_op = op; - sv.delayed.arith.a = a; - sv.delayed.arith.b = b; - sv.delayed.arith.a_owned = a_owned ? 1u : 0u; - sv.delayed.arith.b_owned = b_owned ? 1u : 0u; - sv.res = RES_INHERENT; - sv.source_local = KIT_CG_LOCAL_NONE; - return sv; + return (ApiSValue){.kind = SV_ARITH, + .type = ty, + .res = RES_INHERENT, + .source_local = KIT_CG_LOCAL_NONE, + .delayed.arith = {.kind = API_DELAYED_BINOP, + .bin_op = op, + .a = a, + .b = b, + .a_owned = a_owned ? 1u : 0u, + .b_owned = b_owned ? 1u : 0u}}; } void api_release_arith(KitCg* g, ApiSValue* sv) { diff --git a/src/cg/internal.h b/src/cg/internal.h @@ -78,6 +78,14 @@ typedef enum ApiWideKind { WK_SOFT_SINGLE, /* f32 on a pure-soft target (no FP unit) */ } ApiWideKind; +/* Packed per-type classification memoized for the value stack. The wide/soft + * class and the aggregate-place property are both pure functions of (type, + * target), so they are computed once per type id (builtins precomputed at cg_api + * init, user types filled lazily) instead of re-derived on every api_push. The + * low bits hold the ApiWideKind; bit 3 marks an aggregate (record) type. */ +#define API_TYPE_CLASS_WIDE_MASK 0x07u +#define API_TYPE_CLASS_AGGREGATE 0x08u + /* Bit-field geometry carried by a bit-field PLACE. `kit_cg_field` fills this * from the record layout when it projects to a bit-field; a plain load/store on * the carrying place then performs the extract/insert. The storage MemAccess is @@ -422,7 +430,9 @@ int api_is_f128_type(Compiler* c, KitCgTypeId ty); int api_is_i128_type(Compiler* c, KitCgTypeId ty); int api_is_wide16_scalar_type(Compiler* c, KitCgTypeId ty); int api_is_wide8_scalar_type(Compiler* c, KitCgTypeId ty); -u8 api_wide_kind_for(KitCg* g, KitCgTypeId ty); +u8 api_wide_kind_for(Compiler* c, KitCgTypeId ty); +/* Memoized fetch of the packed API_TYPE_CLASS_* byte for `ty` (see above). */ +u8 api_type_class(KitCg* g, KitCgTypeId ty); Operand api_op_imm(i64 v, KitCgTypeId ty); Operand api_op_local(CGLocal r, KitCgTypeId ty); Operand api_op_global(ObjSymId sym, i64 addend, KitCgTypeId ty); diff --git a/src/cg/type.c b/src/cg/type.c @@ -26,15 +26,26 @@ typedef struct CgApiType { KitCgCallConv call_conv; u8 kind; u8 abi_variadic; - u8 pad[2]; + /* Lazily-filled packed API_TYPE_CLASS_* memo (0 == not yet computed; the + * VALID bit distinguishes a computed all-zero class from unfilled). Reuses + * the former pad, so the entry size is unchanged. */ + u8 cached_class; + u8 pad[1]; } CgApiType; +/* High bit of CgApiType.cached_class: set once the class has been computed, so + * a genuine {WK_NARROW, non-aggregate} (packed 0) is told apart from unfilled. */ +#define API_TYPE_CLASS_CACHED 0x80u + SEGVEC_DEFINE(CgApiTypes, CgApiType, CG_API_TYPE_SEG_SHIFT); typedef struct CgApiState { Heap* heap; CgApiTypes types; CgType builtins[KIT_CG_BUILTIN_COUNT]; + /* Packed API_TYPE_CLASS_* per builtin, precomputed at init so the hottest + * operand types (i32/i64/f64/ptr/...) classify with a single indexed load. */ + u8 builtin_class[KIT_CG_BUILTIN_COUNT]; u8 builtins_init; u8 pad[3]; } CgApiState; @@ -149,14 +160,54 @@ static void builtin_cg_type_init(Compiler* c, CgType* out, KitCgBuiltinType t) { } } +static CgApiState* cg_api_get(Compiler* c); +static CgApiType* api_type_from_id(Compiler* c, KitCgTypeId id); + +/* Compute the packed API_TYPE_CLASS_* byte for `ty` from scratch: the wide / + * soft-float dispatch class plus the aggregate-place bit. Both are pure + * functions of (type, target), so callers memoize the result (per builtin at + * init, per user type lazily) rather than re-run this on every value push. */ +static u8 api_compute_type_class(Compiler* c, KitCgTypeId ty) { + u8 cls = api_wide_kind_for(c, ty) & API_TYPE_CLASS_WIDE_MASK; + if (cg_type_is_aggregate(c, ty)) cls |= API_TYPE_CLASS_AGGREGATE; + return cls; +} + static void cg_api_init_builtins(Compiler* c, CgApiState* s) { if (s->builtins_init) return; for (u32 i = 0; i < KIT_CG_BUILTIN_COUNT; ++i) { builtin_cg_type_init(c, &s->builtins[i], (KitCgBuiltinType)i); } + /* Precompute each builtin's class now that the builtin table and the ABI are + * both live (builtin_cg_type_init already consults c->abi for the va_list + * builtin, so it is ready). */ + for (u32 i = 0; i < KIT_CG_BUILTIN_COUNT; ++i) { + s->builtin_class[i] = api_compute_type_class(c, builtin_id((KitCgBuiltinType)i)); + } s->builtins_init = 1; } +u8 api_type_class(KitCg* g, KitCgTypeId ty) { + Compiler* c = g->c; + CgApiState* s; + CgApiType* e; + u32 seg; + if (ty == KIT_CG_TYPE_NONE) return 0; + s = c->cg_api ? (CgApiState*)c->cg_api : cg_api_get(c); + if (!s) return api_compute_type_class(c, ty); + seg = ty >> CG_API_TYPE_SEG_SHIFT; + if (seg == CG_API_TYPE_BUILTIN_SEG) { + u32 off = ty & CG_API_TYPE_SEG_MASK; + if (off < KIT_CG_BUILTIN_COUNT) return s->builtin_class[off]; + return api_compute_type_class(c, ty); + } + e = api_type_from_id(c, ty); + if (!e) return api_compute_type_class(c, ty); + if (!(e->cached_class & API_TYPE_CLASS_CACHED)) + e->cached_class = API_TYPE_CLASS_CACHED | api_compute_type_class(c, ty); + return (u8)(e->cached_class & ~API_TYPE_CLASS_CACHED); +} + static CgApiState* cg_api_get(Compiler* c) { Heap* h; CgApiState* s; @@ -174,8 +225,6 @@ static CgApiState* cg_api_get(Compiler* c) { return s; } -static CgApiType* api_type_from_id(Compiler* c, KitCgTypeId id); - const CgType* cg_type_get(Compiler* c, KitCgTypeId id) { u32 seg; u32 off; diff --git a/src/cg/value.c b/src/cg/value.c @@ -68,11 +68,9 @@ int api_is_wide8_scalar_type(Compiler* c, KitCgTypeId ty) { * float (e.g. double on a 32-bit ABI) bears int width 0 so it is not WK_WIDE8 — * it falls through to WK_SOFT_DOUBLE, exactly as the original int-width guard in * api_int_is_wide64 intended. */ -u8 api_wide_kind_for(KitCg* g, KitCgTypeId ty) { - Compiler* c; +u8 api_wide_kind_for(Compiler* c, KitCgTypeId ty) { u8 fa; - if (!g || !ty) return WK_NARROW; - c = g->c; + if (!c || !ty) return WK_NARROW; if (api_is_i128_type(c, ty)) return WK_I128; if (api_is_f128_type(c, ty)) return WK_F128; /* Int split into two 32-bit lanes by the ABI. The original api_int_is_wide64 @@ -97,57 +95,41 @@ u8 api_wide_kind_for(KitCg* g, KitCgTypeId ty) { return WK_NARROW; } +/* The operand/value constructors run on the hottest per-operand codegen path. + * Each uses a designated compound literal rather than memset + field stores: it + * is value-identical (the unnamed fields zero-fill) but lets the compiler emit + * only the needed stores — and drop those it can see are immediately + * overwritten — instead of an out-of-line memset of the whole struct. Same + * idiom as the NativeLoc constructors in arch/native_target.h. */ Operand api_op_imm(i64 v, KitCgTypeId ty) { - Operand o; - memset(&o, 0, sizeof o); - o.kind = OPK_IMM; - o.type = ty; - o.v.imm = v; - return o; + return (Operand){.kind = OPK_IMM, .type = ty, .v.imm = v}; } Operand api_op_local(CGLocal local, KitCgTypeId ty) { - Operand o; - memset(&o, 0, sizeof o); - o.kind = OPK_LOCAL; - o.type = ty; - o.v.local = local; - return o; + return (Operand){.kind = OPK_LOCAL, .type = ty, .v.local = local}; } Operand api_op_global(ObjSymId sym, i64 addend, KitCgTypeId ty) { - Operand o; - memset(&o, 0, sizeof o); - o.kind = OPK_GLOBAL; - o.type = ty; - o.v.global.sym = sym; - o.v.global.addend = addend; - return o; + return (Operand){.kind = OPK_GLOBAL, + .type = ty, + .v.global = {.sym = sym, .addend = addend}}; } Operand api_op_indirect(CGLocal base, i32 ofs, KitCgTypeId ty) { - Operand o; - memset(&o, 0, sizeof o); - o.kind = OPK_INDIRECT; - o.type = ty; - o.v.ind.base = base; - o.v.ind.index = CG_LOCAL_NONE; - o.v.ind.log2_scale = 0; - o.v.ind.ofs = ofs; - return o; + return (Operand){ + .kind = OPK_INDIRECT, + .type = ty, + .v.ind = {.base = base, .index = CG_LOCAL_NONE, .ofs = ofs}}; } Operand api_op_indirect_indexed(CGLocal base, CGLocal index, u8 log2_scale, i32 ofs, KitCgTypeId ty) { - Operand o; - memset(&o, 0, sizeof o); - o.kind = OPK_INDIRECT; - o.type = ty; - o.v.ind.base = base; - o.v.ind.index = index; - o.v.ind.log2_scale = log2_scale; - o.v.ind.ofs = ofs; - return o; + return (Operand){.kind = OPK_INDIRECT, + .type = ty, + .v.ind = {.base = base, + .index = index, + .log2_scale = log2_scale, + .ofs = ofs}}; } u8 api_residency_for(const Operand* o) { @@ -156,14 +138,15 @@ u8 api_residency_for(const Operand* o) { } ApiSValue api_make_sv(Operand op, KitCgTypeId ty) { - ApiSValue sv; - memset(&sv, 0, sizeof sv); - sv.kind = SV_OPERAND; - sv.op = op; - sv.type = ty; - sv.res = api_residency_for(&op); - sv.source_local = KIT_CG_LOCAL_NONE; - return sv; + /* Designated compound literal instead of memset: the unnamed fields (the + * 64-byte delayed union, the bitfield rider, the flags) still zero-fill, but + * the compiler emits direct stores for the live fields and skips the + * out-of-line memset of the whole node on this hot per-operand path. */ + return (ApiSValue){.kind = SV_OPERAND, + .op = op, + .type = ty, + .res = api_residency_for(&op), + .source_local = KIT_CG_LOCAL_NONE}; } ApiSValue api_make_lv(Operand op, KitCgTypeId ty) { @@ -254,21 +237,25 @@ void api_stack_grow(KitCg* g, u32 want) { void api_push(KitCg* g, ApiSValue v) { KitCgTypeId ty = api_sv_type(&v); + /* One memoized fetch gives both the aggregate-place property and the + * wide/soft-float dispatch class — both pure functions of the type, computed + * once per type id rather than re-derived (cg_type_get + alias-chase + ABI + * round trips) on every push. */ + u8 cls = api_type_class(g, ty); /* An aggregate (record) can only ever be a PLACE: it is addressed, loaded, * and passed by SRET/BYVAL/BYREF, never materialized as a scalar VALUE. Catch * any aggregate VALUE at the point it would enter the stack. i128/f128 are - * scalars (cg_type_is_aggregate is false for them), so they remain valid - * VALUEs and are unaffected. */ - if (cg_type_is_aggregate(g->c, ty) && !api_is_lvalue_sv(&v)) { + * scalars (not aggregates), so they remain valid VALUEs and are unaffected. */ + if ((cls & API_TYPE_CLASS_AGGREGATE) && !api_is_lvalue_sv(&v)) { compiler_panic(g->c, g->cur_loc, "KitCg: aggregate must be a place, not a value; load the " "place or pass it by reference"); } - /* Cache the wide/soft-float dispatch class once, here at the sole stack - * writer; the wide and soft-float predicates in arith.c read it back instead - * of re-deriving it per operand. dup/swap/rot copy the whole node (type and - * tag together), so it stays valid for every later reader. */ - v.bitfield.wide_kind = api_wide_kind_for(g, ty); + /* Cache the wide/soft-float dispatch class on the node; the wide and + * soft-float predicates in arith.c read it back instead of re-deriving it per + * operand. dup/swap/rot copy the whole node (type and tag together), so it + * stays valid for every later reader. */ + v.bitfield.wide_kind = cls & API_TYPE_CLASS_WIDE_MASK; api_stack_grow(g, g->sp + 1); g->stack[g->sp++] = v; }