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:
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;