kit

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

commit 3164b12b2ead57f2e27ca6378976369d5c004373
parent c7e600b5d08b758d2493e2ac839dcb62da456a4a
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 14:50:01 -0700

perf(cg): denser -O0 code — cache load results, elide no-op bitcasts

Two structural changes to the single-pass native backend (-O0), cutting sqlite3.c
object text ~14% (3.45x -> 2.97x of tcc's, on arm64-macOS):

1. nd_load routes its result through the write-back register cache (nd_dst_reg /
   nd_dst_writeback) like a compute result, instead of storing straight to the
   frame home. A loaded value feeds an immediate consumer far more often than
   not, so this kills the store-then-reload pair that every field/element read
   used to emit. Falls back to the home store (via nd_dst_writeback) when the
   destination is not cacheable.

2. A same-width, same-register-class CV_BITCAST (pointer<->intptr,
   pointer<->pointer) moves no bits on a machine-word target, so the cg layer
   makes the result value *be* the source, retyped in place — no temp, no mov.
   This removes the register copies the frontend's ptr_to_int/int_to_ptr round
   trip put around every field-offset add. It is a value-identity rewrite (the
   source is dead — released immediately after), which only the cg layer can do:
   the backend sees convert(dst, src) as two independent locals with no liveness
   info. The one backend-specific fact — whether the cast moves bits — is the new
   CgTarget.untyped_values bit, set by NativeDirectTarget; the typed backends
   (C-source emitter, optimizer recorder) leave it 0 and keep the explicit cast.

Verified: test-toy 1392/0, test-parse-ok 3920/0, kit-built sqlite runs correctly.

Diffstat:
Msrc/cg/arith.c | 26++++++++++++++++++++++++++
Msrc/cg/cgtarget.h | 9+++++++++
Msrc/cg/native_direct_target.c | 11++++++++---
3 files changed, 43 insertions(+), 3 deletions(-)

diff --git a/src/cg/arith.c b/src/cg/arith.c @@ -256,6 +256,32 @@ void api_cg_convert_kind(KitCg* g, KitCgTypeId dst_type, ConvKind ck) { } if (api_try_i128_convert(g, ck, sty, dty, &v)) return; if (api_try_wide8_convert(g, ck, sty, dty, &v)) return; + /* A bitcast between two narrow types of equal width and register class — + * pointer<->pointer-width integer (the frontend's ptr_to_int/int_to_ptr that + * brackets every field-offset add) or pointer<->pointer — reinterprets the + * bits without moving them, so retype the value in place instead of forcing + * it to a fresh temp and emitting a register copy. int<->float of equal width + * is excluded: it crosses the GPR/FPR banks and needs a real fmov. The wide + * (i128 / split-lane) cases are already handled above. */ + /* On a machine-word target, a same-width / same-register-class bitcast moves + * no bits, so make the result value *be* the source, retyped — no temp, no + * register copy. This is a value-identity rewrite (the source is dead: it is + * released just below), which only the cg layer can do; the backend sees + * convert(dst, src) as two independent locals and cannot know src is + * substitutable. untyped_values reports the one backend-specific fact it + * needs — typed backends (the C emitter, the optimizer recorder) leave it 0 + * and keep the explicit convert. OPK_IMM is excluded: a bitcast immediate + * that becomes an address base must reach a register (an INDIRECT base cannot + * be an immediate), an invariant the aggregate-arg lowering relies on. */ + if (ck == CV_BITCAST && T->untyped_values && v.op.kind != OPK_IMM && + !cg_type_is_aggregate(g->c, sty) && !cg_type_is_aggregate(g->c, dty) && + abi_cg_sizeof(g->c->abi, sty) == abi_cg_sizeof(g->c->abi, dty) && + api_type_is_float(g->c, sty) == api_type_is_float(g->c, dty)) { + v.type = dty; + v.op.type = dty; + api_push(g, v); + return; + } if (ck == CV_BITCAST && abi_cg_sizeof(g->c->abi, sty) == 16 && abi_cg_sizeof(g->c->abi, dty) == 16 && (api_is_f128_type(g->c, sty) || api_is_f128_type(g->c, dty))) { diff --git a/src/cg/cgtarget.h b/src/cg/cgtarget.h @@ -34,6 +34,15 @@ struct CgTarget { CgFinishPolicy finish_policy; + /* The target represents values as untyped machine words (registers/stack + * slots), so a same-width, same-register-class bitcast — pointer<->intptr, + * pointer<->pointer — moves no bits and the cg layer may elide it by retyping + * the value in place (api_cg_convert_kind). Set by the single-pass native + * backend (NativeDirectTarget). Left 0 by the typed backends — the C-source + * target needs the explicit cast to emit valid C, and the recording target + * keeps the convert so the optimizer's own copy-propagation owns the call. */ + u8 untyped_values; + /* ---- function lifecycle ---- */ void (*func_begin)(CgTarget*, const CGFuncDesc*); void (*func_end)(CgTarget*); diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -1361,11 +1361,15 @@ static void nd_load(CgTarget* t, Operand dst, Operand addr, MemAccess mem) { nd_addr_temps_release(d, &temps); return; } - NativeLoc reg = nd_dst_scratch(d, dst); + /* The loaded value goes through the write-back cache (like a compute result) + * rather than straight to the frame home: a load feeds an immediate consumer + * far more often than not, so keeping it resident kills the store-then-reload + * pair. nd_dst_reg avoids the address temps (still pinned here) and falls back + * to a scratch + home store via nd_dst_writeback when dst is not cacheable. */ + NativeLoc reg = nd_dst_reg(d, dst); ND_REQUIRE_NATIVE(d, load, "target does not emit loads"); d->native->load(d->native, reg, naddr, mem); - nd_store_operand_from_reg(d, dst, reg); - nd_release_materialized(d, reg); + nd_dst_writeback(d, dst, reg); nd_addr_temps_release(d, &temps); } @@ -2013,6 +2017,7 @@ CgTarget* native_direct_target_new(Compiler* c, ObjBuilder* obj, d->base.unop = nd_unop; d->base.cmp = nd_cmp; d->base.convert = nd_convert; + d->base.untyped_values = 1; /* register-resident values: no-op bitcasts elide */ d->base.call = nd_call; d->base.tail_call_unrealizable_reason = nd_tail_call_unrealizable_reason; d->base.ret = nd_ret;