kit

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

commit a69929714a36eaaad30511e1582b14c81e63cab1
parent d9902a7ea9f62a06393ee58db352fe8f635e88de
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Mon, 15 Jun 2026 23:34:10 -0700

opt: rematerialization instead of spilling at O1 (O1.md W2)

Diffstat:
Mmk/test.mk | 8+++++++-
Msrc/opt/pass_lower.c | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Atest/opt/o1_remat.sh | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 281 insertions(+), 13 deletions(-)

diff --git a/mk/test.mk b/mk/test.mk @@ -859,7 +859,7 @@ test-macho: lib $(TEST_RT_DEP) $(ROUNDTRIP_BIN_MACHO) $(LINK_EXE_RUNNER) $(JIT_R OPT_TEST_BIN = build/test/cg_ir_lower_test TINY_INLINE_TEST_BIN = build/test/tiny_inline_test -test-opt: bin $(OPT_TEST_BIN) test-opt-tiny-inline test-opt-inline test-opt-zero-arg test-opt-static-prune-aa64 test-opt-aa64-tail test-opt-x64-win-tail-sret test-opt-prologue-tier test-opt-whole-program-inline test-opt-lto-phase1 test-opt-redundant-copy-ext test-opt-redundant-frame-sub test-opt-o1-branch-cleanup test-opt-hot-slot-order +test-opt: bin $(OPT_TEST_BIN) test-opt-tiny-inline test-opt-inline test-opt-zero-arg test-opt-static-prune-aa64 test-opt-aa64-tail test-opt-x64-win-tail-sret test-opt-prologue-tier test-opt-whole-program-inline test-opt-lto-phase1 test-opt-redundant-copy-ext test-opt-redundant-frame-sub test-opt-o1-branch-cleanup test-opt-hot-slot-order test-opt-o1-remat $(OPT_TEST_BIN) @@ -887,6 +887,12 @@ test-opt-o1-branch-cleanup: bin test-opt-hot-slot-order: bin @KIT=$(abspath $(BIN)) bash test/opt/hot_slot_order.sh +# Structural disasm check: O1 rematerialization recomputes cheap spilled values +# (small load_imm, addr_of[local]) at the use instead of reloading from a slot. +.PHONY: test-opt-o1-remat +test-opt-o1-remat: bin + @KIT=$(abspath $(BIN)) bash test/opt/o1_remat.sh + test-opt-tiny-inline: bin $(TINY_INLINE_TEST_BIN) $(TINY_INLINE_TEST_BIN) diff --git a/src/opt/pass_lower.c b/src/opt/pass_lower.c @@ -1398,12 +1398,149 @@ static Reg scratch_for(Func* f, u8 cls, u32* next) { return r; } +/* --------------------------------------------------------------------------- + * Rematerialization (doc/plan/O1.md W2). + * + * A spilled value whose single def is *cheaper to recompute than to reload* is + * recomputed at each use instead of reloaded from its slot; the spill store is + * dropped entirely (the original def then writes a dead scratch, removed by + * mir_dce). The v1 set is restricted to input-less producers whose recompute is + * <= reload cost, so the recompute is valid at any use site: + * - IR_LOAD_IMM whose immediate materializes in <= 2 instructions + * (movz, or movz;movk -- i.e. <= 2 nonzero 16-bit halfwords); + * - IR_ADDR_OF of an OPK_LOCAL (one add off the stable frame base). + * IR_ADDR_OF(global) (hoisted by addr_of_global_cse) and large IR_LOAD_CONST + * (4-insn materialize, worse than a 1-insn reload) are deliberately excluded. + * + * The classification is a single linear pre-pass (opt_mark_remat) over all + * instructions; the choice at each spill site is local. No global analysis. + * ------------------------------------------------------------------------- */ + +/* Cost gate: how many machine instructions an immediate of `type` takes to + * materialize, using the movz/movk model (count of nonzero 16-bit halfwords). + * This matches aa64's aa_load_imm_words exactly and is conservative for the + * other backends (a 2-halfword imm is at most a 2-3 insn rebuild everywhere), + * so a constant that costs more than its slot reload never rematerializes. */ +static u32 remat_imm_insn_count(const Func* f, KitCgTypeId type, i64 imm) { + u32 width = type_size_fallback(f, type); + u32 halfwords = (width > 4u) ? 4u : 2u; + u64 v = (u64)imm; + u32 n = 0; + for (u32 i = 0; i < halfwords; ++i) + if (((v >> (i * 16u)) & 0xffffu) != 0u) ++n; + return n ? n : 1u; /* zero is one movz */ +} + +/* True iff `in` is in the conservative v1 rematerialization set. */ +static int remat_inst_is_candidate(const Func* f, const Inst* in) { + switch ((IROp)in->op) { + case IR_LOAD_IMM: + return remat_imm_insn_count(f, in->type, in->extra.imm) <= 2u; + case IR_ADDR_OF: + return in->nopnds >= 2 && in->opnds[1].kind == OPK_LOCAL; + default: + return 0; + } +} + +/* Per-function rematerialization table, computed once by opt_mark_remat and + * read at every spill site. remat_def[v] is an arena snapshot of v's single + * defining inst when v is a remat candidate, else NULL. */ +typedef struct RematInfo { + Inst** remat_def; /* indexed by PReg; NULL entry = not a remat candidate */ + u32 nregs; +} RematInfo; + +typedef struct RematCountCtx { + u8* def_count; /* saturating at 2 per PReg */ +} RematCountCtx; + +static void remat_count_def(Func* f, Inst* in, Operand* op, int is_def, + void* arg) { + (void)in; + if (!is_def || op->kind != OPK_REG) return; + PReg v = (PReg)op->v.reg; + if (v == PREG_NONE || v == 0 || v >= opt_reg_count(f)) return; + RematCountCtx* c = (RematCountCtx*)arg; + if (c->def_count[v] < 2u) ++c->def_count[v]; +} + +/* Linear pre-pass: record remat_def[v] iff v has exactly one def and that def + * is in the v1 set. One walk to count defs + snapshot candidate defs, one PReg + * sweep to drop multiply-defined entries. */ +static void opt_mark_remat(Func* f, RematInfo* ri) { + u32 nregs = opt_reg_count(f); + memset(ri, 0, sizeof *ri); + ri->nregs = nregs; + if (nregs == 0) return; + ri->remat_def = arena_zarray(f->arena, Inst*, nregs); + u8* def_count = arena_zarray(f->arena, u8, nregs); + RematCountCtx cc = {def_count}; + for (u32 b = 0; b < f->nblocks; ++b) { + Block* bl = &f->blocks[b]; + for (u32 i = 0; i < bl->ninsts; ++i) { + Inst* in = &bl->insts[i]; + opt_walk_inst_operands(f, in, remat_count_def, &cc); + if (!remat_inst_is_candidate(f, in)) continue; + PReg dv = (PReg)in->def; + if (dv == PREG_NONE || dv == 0 || dv >= nregs) continue; + /* Only the single-def case is sound; the count sweep below nullifies any + * PReg that ends up with more than one def. Snapshot the inst (and its + * operand array) so later block mutation cannot disturb the recipe. */ + Inst* snap = arena_znew(f->arena, Inst); + *snap = *in; + if (in->nopnds) { + snap->opnds = arena_array(f->arena, Operand, in->nopnds); + memcpy(snap->opnds, in->opnds, sizeof(Operand) * in->nopnds); + } + ri->remat_def[dv] = snap; + } + } + for (u32 v = 0; v < nregs; ++v) + if (def_count[v] != 1u) ri->remat_def[v] = NULL; +} + +static Inst* remat_def_for(const RematInfo* ri, PReg v) { + if (!ri || !ri->remat_def || v == PREG_NONE || v == 0 || v >= ri->nregs) + return NULL; + return ri->remat_def[v]; +} + typedef struct RewriteCtx { RewriteList* before; RewriteList* after; + const RematInfo* remat; u32 next_scratch[OPT_REG_CLASSES]; } RewriteCtx; +/* Shared "materialize this spilled PReg at this use" helper. Every spilled-use + * rewrite path routes through this. When v rematerializes, clone its recorded + * def into `before` with dst = scratch; otherwise append an IR_LOAD from the + * slot. The caller has already pointed the operand at `scratch`. */ +static void remat_or_reload_use(Func* f, RewriteCtx* c, Operand* op, PReg v, + Reg scratch) { + Inst* def = remat_def_for(c->remat, v); + if (def) { + Inst* re = list_push(f, c->before, (IROp)def->op); + InstId id = re->id; + *re = *def; + re->id = id; + re->opnds = arena_array(f->arena, Operand, def->nopnds ? def->nopnds : 1u); + if (def->nopnds) + memcpy(re->opnds, def->opnds, sizeof(Operand) * def->nopnds); + re->opnds[0] = *op; /* dst already rewritten to scratch */ + re->def = (Val)scratch; + re->type = op->type; + return; + } + Inst* ld = list_push(f, c->before, IR_LOAD); + ld->opnds = arena_array(f->arena, Operand, 2); + ld->opnds[0] = *op; + ld->opnds[1] = spill_addr(f, v); + ld->nopnds = 2; + ld->extra.mem = spill_mem(f, v); +} + static void rewrite_one_operand(Func* f, Inst* owner, Operand* op, int is_def, void* arg) { RewriteCtx* c = (RewriteCtx*)arg; @@ -1426,12 +1563,12 @@ static void rewrite_one_operand(Func* f, Inst* owner, Operand* op, int is_def, } op->v.reg = scratch; if (!is_def) { - Inst* ld = list_push(f, c->before, IR_LOAD); - ld->opnds = arena_array(f->arena, Operand, 2); - ld->opnds[0] = *op; - ld->opnds[1] = spill_addr(f, v); - ld->nopnds = 2; - ld->extra.mem = spill_mem(f, v); + remat_or_reload_use(f, c, op, v, scratch); + } else if (remat_def_for(c->remat, v)) { + /* Every use of this PReg rematerializes (the producer is input-less and all + * three spilled-use paths route through remat_or_reload_use), so no frame + * use of the slot remains -- drop the spill store. The original def now + * writes a dead scratch that mir_dce removes. */ } else { Inst* st = list_push(f, c->after, IR_STORE); st->opnds = arena_array(f->arena, Operand, 2); @@ -1442,16 +1579,31 @@ static void rewrite_one_operand(Func* f, Inst* owner, Operand* op, int is_def, } } -static void rewrite_call_arg_operand(Func* f, Operand* op) { +/* Spilled call-arg use. Without remat the slot becomes a direct OPK_LOCAL frame + * operand; with remat we recompute into a scratch so the dropped spill store + * leaves no dangling frame read. */ +static void rewrite_call_arg_operand(Func* f, Operand* op, RewriteCtx* ctx) { if (!op || op->kind != OPK_REG) return; PReg v = (PReg)op->v.reg; if (v == PREG_NONE || v == 0 || v >= opt_reg_count(f)) return; u8 alloc_kind = opt_preg_alloc_kind(f, v); if (alloc_kind == OPT_ALLOC_HARD) { op->v.reg = opt_preg_hard_reg(f, v); - } else if (alloc_kind == OPT_ALLOC_SPILL) { - *op = spill_addr(f, v); + return; } + if (alloc_kind != OPT_ALLOC_SPILL) return; + if (remat_def_for(ctx->remat, v)) { + u8 cls = opt_preg_loc_cls(f, v); + Reg scratch = scratch_for(f, cls, &ctx->next_scratch[cls]); + if (scratch == (Reg)REG_NONE) + compiler_panic(f->c, (SrcLoc){0, 0, 0}, + "opt rewrite: no scratch register for spilled class %u", + (unsigned)cls); + op->v.reg = scratch; + remat_or_reload_use(f, ctx, op, v, scratch); + return; + } + *op = spill_addr(f, v); } static void rewrite_store_value_operand(Func* f, Inst* owner, Operand* op, @@ -1467,6 +1619,12 @@ static void rewrite_store_value_operand(Func* f, Inst* owner, Operand* op, return; } if (alloc_kind == OPT_ALLOC_SPILL) { + /* A remat candidate must recompute into a scratch (the slot may no longer + * be written); a non-remat spill may fold into a direct frame operand. */ + if (remat_def_for(ctx->remat, v)) { + rewrite_one_operand(f, owner, op, 0, ctx); + return; + } *op = spill_addr(f, v); return; } @@ -1488,11 +1646,11 @@ static void rewrite_call_arg_value(Func* f, Inst* owner, CGABIValue* v, RewriteCtx* ctx) { if (!v) return; rewrite_call_arg_indirect_base(f, owner, &v->storage, ctx); - rewrite_call_arg_operand(f, &v->storage); + rewrite_call_arg_operand(f, &v->storage, ctx); for (u32 i = 0; i < v->nparts; ++i) { Operand* op = (Operand*)&v->parts[i].op; rewrite_call_arg_indirect_base(f, owner, op, ctx); - rewrite_call_arg_operand(f, op); + rewrite_call_arg_operand(f, op, ctx); } } @@ -1566,6 +1724,8 @@ static void rewrite_func(Func* f, const OptLiveInfo* live_info) { u64* live = arena_zarray(f->arena, u64, words ? words : 1u); u32 ncall_save_pregs = 0; PReg* call_save_pregs = rewrite_collect_call_save_pregs(f, &ncall_save_pregs); + RematInfo remat; + opt_mark_remat(f, &remat); InstRefs refs; memset(&refs, 0, sizeof refs); u32 live_active_words = 0; @@ -1599,6 +1759,7 @@ static void rewrite_func(Func* f, const OptLiveInfo* live_info) { memset(&ctx, 0, sizeof ctx); ctx.before = &before; ctx.after = &after; + ctx.remat = &remat; if ((IROp)in.op == IR_CALL) { IRCallAux* aux = (IRCallAux*)in.extra.aux; if (aux) { @@ -1607,7 +1768,7 @@ static void rewrite_func(Func* f, const OptLiveInfo* live_info) { for (u32 k = 0; k < aux->plan.nargs; ++k) { rewrite_call_arg_indirect_base(f, &in, &aux->plan.args[k].src, &ctx); - rewrite_call_arg_operand(f, &aux->plan.args[k].src); + rewrite_call_arg_operand(f, &aux->plan.args[k].src, &ctx); } for (u32 k = 0; k < aux->plan.nrets; ++k) rewrite_one_operand(f, &in, &aux->plan.rets[k].dst, 1, &ctx); diff --git a/test/opt/o1_remat.sh b/test/opt/o1_remat.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Structural check for -O1 rematerialization (doc/plan/O1.md W2). +# +# At a use of a spilled value whose single def is cheaper to recompute than to +# reload, kit recomputes at the use instead of reloading from the slot, and +# drops the spill store entirely when every use rematerializes. The conservative +# v1 set is input-less producers whose recompute is <= reload cost: +# - IR_LOAD_IMM whose immediate is <= 2 instructions (movz / movz;movk); +# - IR_ADDR_OF of a local (one `add` off the stable frame base). +# +# The probe drives a small marker constant (KMARK, one movz) and the address of +# a local (one `add xN, x29, #off`) to spill: both are low-frequency but live +# across two trailing calls, while sixteen high-frequency array values win every +# callee-save register. After W2 each spilled use of the constant / address must +# RECOMPUTE (a `movz #KMARK` / a repeated `add xN, x29, #off`) rather than reload +# from a slot, and the spill store must be gone (no `str` of the recomputed +# value into a frame slot it is never read from). +# +# Pinned on aarch64 (the reference backend) where the mnemonics are stable. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +KIT="${KIT:-$ROOT/build/kit}" +WORK="$ROOT/build/test/opt/o1_remat" +mkdir -p "$WORK" + +SRC="$WORK/case.c" +cat > "$SRC" <<'EOF' +extern void use_ptr(int *); +extern void sink(long); +extern void barrier(void); + +/* KMARK = 0x4321 is a single-movz constant; &loc is one add off the frame base. + * Both are low-frequency but live across the two trailing calls, so the + * sixteen high-frequency array values displace them from the callee-saves and + * they spill. W2 should recompute both at their uses instead of reloading. */ +long remat_probe(long *arr) { + int loc; + const long KMARK = 0x4321; + long a=arr[0],b=arr[1],c=arr[2],d=arr[3],e=arr[4],f=arr[5],g=arr[6],h=arr[7]; + long i=arr[8],j=arr[9],k=arr[10],l=arr[11],m=arr[12],o=arr[13],p=arr[14],q=arr[15]; + barrier(); + long s = a*a+b*b+c*c+d*d+e*e+f*f+g*g+h*h+i*i+j*j+k*k+l*l+m*m+o*o+p*p+q*q; + s += a*b+b*c+c*d+d*e+e*f+f*g+g*h+h*i+i*j+j*k+k*l+l*m+m*o+o*p+p*q+q*a; + s += a+b+c+d+e+f+g+h+i+j+k+l+m+o+p+q; + use_ptr(&loc); /* use 1: address of a local as a call arg */ + sink(KMARK); /* use 2: the constant as a call arg */ + use_ptr(&loc); /* use 3: the address again */ + return s + KMARK; /* use 4: the constant again */ +} +EOF + +OBJ="$WORK/case.o" +"$KIT" cc -target aarch64-linux-gnu -O1 -std=c11 -c "$SRC" \ + -o "$OBJ" > "$WORK/cc.out" 2>&1 +"$KIT" objdump -d "$OBJ" > "$WORK/dis.out" 2>&1 + +BODY="$(awk ' + /<remat_probe>:/ { in_fn = 1; next } + in_fn && /^[0-9a-f]+ </ { exit } + in_fn { print } +' "$WORK/dis.out")" + +fail() { + printf 'o1_remat FAILED: %s\n' "$1" >&2 + printf ' --- remat_probe disassembly ---\n' >&2 + printf '%s\n' "$BODY" | sed 's/^/ | /' >&2 + exit 1 +} + +[ -n "$BODY" ] || fail "remat_probe not found in disassembly" + +# (1) Constant rematerialization: KMARK (0x4321) is recomputed at each of its +# two uses. A reload-based baseline would materialize it once and reload via +# ldr; remat emits the `movz #0x4321` again at every use -> count >= 2. +movz_n="$(printf '%s\n' "$BODY" | grep -cE 'movz[ \t]+x[0-9]+, 0x4321' || true)" +[ "$movz_n" -ge 2 ] || fail "constant KMARK not rematerialized (movz 0x4321 x$movz_n, expected >=2)" + +# (2) Address rematerialization: the same `add xN, x29, #off` recompute appears +# at both address uses. Find the most common add-off-frame-base offset and +# require it to recur (a single def would appear once). +addr_off="$(printf '%s\n' "$BODY" \ + | grep -oE 'add x[0-9]+, x29, #[0-9]+$' \ + | grep -oE '#[0-9]+$' | sort | uniq -c | sort -rn | head -1 | awk '{print $2}')" +[ -n "$addr_off" ] || fail "no 'add xN, x29, #off' (addr_of local) recompute found" +addr_n="$(printf '%s\n' "$BODY" | grep -cE "add x[0-9]+, x29, ${addr_off}\$" || true)" +[ "$addr_n" -ge 2 ] || fail "local address not rematerialized (add x29,${addr_off} x$addr_n, expected >=2)" + +# (3) Store-drop: the rematerialized constant and address must leave no spill +# store behind. With every use rematerialized there must be no `str` of the +# KMARK value or the recomputed address into a slot. We approximate by +# requiring that the marker constant is never stored: a kept spill store +# would be `movz xN,#0x4321 ; str xN,[x29,#..]` -- i.e. a movz immediately +# followed by a str of the same register. None should remain. +if printf '%s\n' "$BODY" | grep -A1 -E 'movz[ \t]+x[0-9]+, 0x4321' \ + | grep -qE 'str[ \t]+x[0-9]+, \[x29'; then + fail "spill store for rematerialized constant survived (movz;str)" +fi + +printf 'o1_remat: OK (const movz x%s, addr add-off-x29 x%s, spill stores dropped)\n' \ + "$movz_n" "$addr_n"