kit

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

commit d9902a7ea9f62a06393ee58db352fe8f635e88de
parent f97f2c08ba699563f1c112562e79ee6d6d1a93c1
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Mon, 15 Jun 2026 23:32:49 -0700

opt: hot-slot-low frame ordering at O1 (O1.md W1.0)

Diffstat:
Mmk/test.mk | 8+++++++-
Msrc/arch/native_target.h | 6++++++
Msrc/opt/ir.h | 9+++++++++
Msrc/opt/pass_lower.c | 28++++++++++++++++++++++++++--
Msrc/opt/pass_native_emit.c | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Atest/opt/hot_slot_order.sh | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 236 insertions(+), 5 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: 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 $(OPT_TEST_BIN) @@ -881,6 +881,12 @@ test-opt-redundant-frame-sub: bin test-opt-o1-branch-cleanup: bin @KIT=$(abspath $(BIN)) bash test/opt/o1_branch_cleanup.sh +# Structural disasm check: hot-slot-low frame ordering keeps the hottest spill +# slots in cheap displacement range (x64 disp8, rv64 imm12 single-instruction). +.PHONY: test-opt-hot-slot-order +test-opt-hot-slot-order: bin + @KIT=$(abspath $(BIN)) bash test/opt/hot_slot_order.sh + test-opt-tiny-inline: bin $(TINY_INLINE_TEST_BIN) $(TINY_INLINE_TEST_BIN) diff --git a/src/arch/native_target.h b/src/arch/native_target.h @@ -45,6 +45,12 @@ typedef struct NativeFrameSlotDesc { u32 size; u32 align; i32 fixed_offset; + /* Spill-traffic priority (O1.md W1.0), copied from IRFrameSlot.priority. The + * known-frame emitter (opt_emit_native) presents slot descs to the backend in + * descending-priority order, so the bump allocator (native_frame_slot_alloc) + * gives the hottest spills the smallest final displacement. The backend itself + * ignores this field — it is purely an ordering signal for the emitter. */ + u32 priority; u8 kind; /* NativeFrameSlotKind */ u8 pad; u16 flags; /* NativeFrameSlotFlag */ diff --git a/src/opt/ir.h b/src/opt/ir.h @@ -474,6 +474,15 @@ typedef struct IRFrameSlot { SrcLoc loc; u32 size; u32 align; + /* Spill-traffic priority (O1.md W1.0). Aggregated from the allocator's final + * spill-slot assignment: the saturating sum of the spill cost of every + * coalesce group assigned to this (possibly reused) slot, where the per-group + * cost is `2*use_freq + def_freq + live_across_call_freq + live_block_freq` + * (pass_live.c). The known-frame emitter presents slots to the backend in + * descending-priority order so the hottest spills get the smallest final frame + * displacement (disp8 on x64, inside the scaled reach on aa64, inside the + * +/-2KB imm12 window on rv64). 0 for homed/address-taken locals (v1). */ + u32 priority; u8 kind; /* FrameSlotKind */ u8 pad; u16 flags; /* FrameSlotFlag */ diff --git a/src/opt/pass_lower.c b/src/opt/pass_lower.c @@ -588,6 +588,21 @@ static FrameSlot spill_slot_for(Func* f, PReg v) { return f->preg_info[v].spill_slot; } +/* Aggregate one coalesce group's spill-traffic cost onto its final spill slot's + * IRFrameSlot.priority (O1.md W1.0). Called from the allocator's final + * assignment, including the slot-reuse path, so a slot shared by several + * non-overlapping groups accumulates the traffic of all of them. Saturating sum: + * priority is purely an ordering key for the known-frame emitter (hot slot -> + * smallest displacement), so wrap-free saturation at u32 max is sufficient and + * keeps the hottest slots ordered ahead of cold ones. Slots outside [1, + * nframe_slots] (none today) are ignored defensively. */ +static void spill_slot_add_priority(Func* f, FrameSlot slot, u32 cost) { + if (slot == FRAME_SLOT_NONE || slot > f->nframe_slots || cost == 0) return; + IRFrameSlot* s = &f->frame_slots[slot - 1u]; + u32 sum = s->priority + cost; + s->priority = sum < s->priority ? 0xffffffffu : sum; /* saturate on overflow */ +} + static u32 hard_loc_bit(u8 cls, Reg r) { return ((u32)cls * 32u) + (u32)r; } typedef struct OptAllocGroupInfo { @@ -990,7 +1005,8 @@ static void alloc_assign_group_hard(Func* f, OptAllocator* a, } static void alloc_assign_group_stack(Func* f, OptAllocator* a, - const OptLiveRangeSet* ranges, PReg root) { + const OptLiveRangeSet* ranges, PReg root, + u32 group_priority) { /* Try to reuse an existing stack slot whose bit is clear in conflict_locs * and whose frame slot is compatible. The conflict_locs scratch must * already be populated for `root` by the caller. */ @@ -1009,6 +1025,11 @@ static void alloc_assign_group_stack(Func* f, OptAllocator* a, * conflict_locs (callers don't reuse it after this). */ } FrameSlot slot = a->stack_slots[stack_idx]; + /* W1.0: accumulate this group's spill-traffic onto the slot's priority. This + * is the FINAL assignment, and a slot reused for several non-overlapping + * groups sums all of their traffic — so the emitter's hot-slot-low ordering + * sees the slot's total demand, not just the first group that created it. */ + spill_slot_add_priority(f, slot, group_priority); for (PReg v = 1; v < opt_reg_count(f); ++v) { if (ranges->first_range_by_preg[v] == OPT_RANGE_NONE) continue; if (!alloc_group_member(f, root, v)) continue; @@ -1227,7 +1248,10 @@ static void opt_assign_ranges(Func* f, const OptLiveRangeSet* ranges, compiler_panic(f->c, loc, "opt regalloc: no hard register satisfies asm constraint"); } else { - alloc_assign_group_stack(f, a, ranges, v); + /* gi.spill_cost is the group's aggregated spill-traffic metric + * (alloc_group_info sums each member's frequency/spill_cost). Thread it to + * the slot so the known-frame emitter can order hot slots low (W1.0). */ + alloc_assign_group_stack(f, a, ranges, v, gi.spill_cost); } } diff --git a/src/opt/pass_native_emit.c b/src/opt/pass_native_emit.c @@ -1524,10 +1524,65 @@ static void plan_frame(NativeEmitCtx* e, const CGFuncDesc* fd) { d->loc = s->loc; d->size = s->size; d->align = s->align; + d->priority = s->priority; d->kind = s->kind; d->flags = s->flags; } } + /* W1.0 hot-slot-low ordering. The backend's known-frame path bump-allocates + * each body slot in the order it is presented (cum_off is monotonic, see + * native_frame_slot_alloc), so slots presented first get the smallest final + * frame displacement. Present the body slots in DESCENDING priority order so + * the hottest spills land low: disp8 on x64 (-4 bytes/access), inside the + * scaled reach on aa64, inside the +/-2KB imm12 window on rv64. + * + * `order[k]` is the original slot index now presented at position k. Fixed- + * offset slots keep their original positions (their displacement is not + * order-derived); the remaining slots are stable-sorted by descending priority + * with an original-index tie-break (so the sort is deterministic and, among + * equal priorities, byte-stable). The native slot the backend returns for + * position k therefore belongs to original IR slot `order[k]`, and the slot_map + * is written back through `order` so e->slot_map[ir_id] stays exact -- a + * transposed map silently miscompiles every spill, so it is asserted below. */ + u32 nfs = e->f->nframe_slots; + u32* slot_order = NULL; /* slot_order[k] = original IR slot index at position k */ + if (nfs > 1u) { + u32* order = arena_array(e->f->arena, u32, nfs); + u32* movable = arena_array(e->f->arena, u32, nfs); + u32 nmovable = 0; + for (u32 i = 0; i < nfs; ++i) { + order[i] = i; /* fixed slots stay pinned at their original index */ + if (!(slots[i].flags & NATIVE_FRAME_SLOT_FIXED_OFFSET)) + movable[nmovable++] = i; + } + /* Stable descending-priority sort of the movable indices (insertion sort: + * nslots is small per function and this is off the per-instruction path; if + * a profile shows it material, swap in a radix over the integer priority -- + * see O1.md W1.0 "Linear?"). Insertion sort is naturally stable, preserving + * original order among equal priorities. */ + for (u32 i = 1; i < nmovable; ++i) { + u32 cur = movable[i]; + u32 cur_pri = slots[cur].priority; + u32 j = i; + while (j > 0 && slots[movable[j - 1]].priority < cur_pri) { + movable[j] = movable[j - 1]; + --j; + } + movable[j] = cur; + } + /* Drop the sorted movable indices back into the non-fixed positions. */ + u32 mi = 0; + for (u32 k = 0; k < nfs; ++k) + if (!(slots[k].flags & NATIVE_FRAME_SLOT_FIXED_OFFSET)) + order[k] = movable[mi++]; + /* Reorder the desc array in place via a scratch copy so frame.slots is the + * contiguous, permuted view the backend iterates. */ + NativeFrameSlotDesc* ordered = + arena_array(e->f->arena, NativeFrameSlotDesc, nfs); + for (u32 k = 0; k < nfs; ++k) ordered[k] = slots[order[k]]; + slot_order = order; + slots = ordered; + } frame.slots = slots; frame.nslots = e->f->nframe_slots; frame.max_outgoing = max_outgoing; @@ -1542,8 +1597,40 @@ static void plan_frame(NativeEmitCtx* e, const CGFuncDesc* fd) { frame.nasm_clobbers = nasm_clob; frame.asm_clobber_abi_sets = asm_clobber_abi_sets; t->func_begin_known_frame(t, fd, &frame, out_slots); - for (u32 i = 0; i < e->f->nframe_slots; ++i) - e->slot_map[e->f->frame_slots[i].id] = out_slots[i]; + /* Map each backend native slot (returned for presentation position k) back to + * its original IR frame slot id. With ordering, position k holds IR slot + * order[k]; without it (nfs<=1 or no reorder), position k == IR slot k. */ + for (u32 k = 0; k < e->f->nframe_slots; ++k) { + u32 ir_idx = slot_order ? slot_order[k] : k; + e->slot_map[e->f->frame_slots[ir_idx].id] = out_slots[k]; + } +#ifndef NDEBUG + /* The slot_map must stay an exact 1:1 of IR frame slots to the native slots + * the backend returned -- a transposed/dropped map silently miscompiles every + * spill. The actual native slot ids are NOT bounded by nframe_slots (the + * backend reserves callee-saves / entry-saves / scratch around the body slots, + * so body native ids are an arbitrary distinct block), so verify the real + * correctness condition instead: `order` is a permutation of [0, nfs) -- i.e. + * every IR frame slot is presented exactly once -- and each presented position + * received a non-NONE native slot. Both are O(nslots) (seen array sized by the + * IR index domain), off the per-instruction path. With identity ordering this + * is vacuously the original code's invariant. */ + if (nfs) { + u8* seen = arena_zarray(e->f->arena, u8, nfs); + for (u32 k = 0; k < nfs; ++k) { + u32 ir_idx = slot_order ? slot_order[k] : k; + if (ir_idx >= nfs || seen[ir_idx]) + compiler_panic(e->c, (SrcLoc){0, 0, 0}, + "opt W1.0: slot order is not a permutation at pos %u", + (unsigned)k); + seen[ir_idx] = 1u; + if (out_slots[k] == NATIVE_FRAME_SLOT_NONE) + compiler_panic(e->c, (SrcLoc){0, 0, 0}, + "opt W1.0: backend returned no native slot for pos %u", + (unsigned)k); + } + } +#endif } void opt_emit_native(Compiler* c, Func* f, NativeTarget* target) { diff --git a/test/opt/hot_slot_order.sh b/test/opt/hot_slot_order.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Structural check for W1.0 hot-slot-low frame ordering (doc/plan/O1.md #### W1.0). +# +# At -O1 the frame is fully known before the body is emitted, so the optimizer +# presents the body spill slots to the backend in DESCENDING spill-traffic +# priority order (pass_native_emit.c). The bump allocator (native_frame_slot_alloc) +# then gives the hottest spills the smallest final displacement -- which is the +# cheapest to encode on every arch: +# +# x64 : disp8 [rbp - k] (1 disp byte, -128..127) vs disp32 (4 disp bytes). +# rv64 : single-instruction `ld/sd off(s0)` while off fits the +/-2KB imm12 +# window; a far slot needs a 3-instruction `lui; addiw; add; ld 0(t)` +# build. +# +# The probe is a function with 16 hot accumulators updated EVERY loop iteration +# (so several must spill to the frame and they carry by far the highest spill +# cost) plus ~280 cold scalars that survive to the end. The cold pile pushes the +# total frame WELL past x64 disp8 (128B) and past rv64's imm12 window (2KB), so +# the layout MUST choose which slots get the cheap low offsets. W1.0 makes that +# choice the hot accumulators: the hot loop body then addresses them with the +# short/single-instruction form while the cold tail takes the far offsets. +# +# Gate is correctness, not byte-identity (W1.0 deliberately reorders slots); this +# guard pins the resulting addressing shape on x64 + rv64 (the arches where the +# doc expects a measurable win; aa64 is mostly subsumed by W1.1). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +KIT="${KIT:-$ROOT/build/kit}" +WORK="$ROOT/build/test/opt/hot_slot_order" +mkdir -p "$WORK" + +SRC="$WORK/case.c" +{ + echo 'typedef unsigned long long u64;' + echo 'extern u64 sink(u64 *);' + echo 'u64 hot_loop(const u64 *in, int n) {' + # 16 hot accumulators (more than the GP register file) -> the allocator must + # spill several; all carry the highest spill cost (touched every iteration). + for i in $(seq 0 15); do echo " u64 h$i = in[$i];"; done + # ~280 cold scalars -> inflates the frame past disp8 (128B) and imm12 (2KB). + for i in $(seq 0 279); do echo " u64 k$i = in[$((i % 64))] + ${i}u;"; done + echo ' for (int i = 0; i < n; i++) {' + echo ' u64 x = in[i & 63];' + for i in $(seq 0 15); do echo " h$i = (h$i ^ x) + h$(((i + 1) % 16));"; done + echo ' }' + # Keep the cold scalars live across the loop (low priority, far offsets). + echo " u64 ks[280];" + for i in $(seq 0 279); do echo " ks[$i] = k$i;"; done + echo ' sink(ks);' + printf ' return' + for i in $(seq 0 15); do printf ' h%d ^' "$i"; done + echo ' 0;' + echo '}' +} > "$SRC" + +dump_for() { # $1 = -target triple, $2 = out tag + local triple="$1" tag="$2" + "$KIT" cc -target "$triple" -O1 -std=c11 -ffreestanding -nostdinc \ + -I"$ROOT/rt/include" -c "$SRC" -o "$WORK/$tag.o" > "$WORK/$tag.cc.out" 2>&1 \ + || { printf 'hot_slot_order FAILED: %s cc failed\n' "$tag" >&2 + sed 's/^/ | /' "$WORK/$tag.cc.out" >&2; exit 1; } + "$KIT" objdump -d "$WORK/$tag.o" > "$WORK/$tag.dis" 2>&1 +} + +fail() { + printf 'hot_slot_order FAILED: %s\n' "$1" >&2 + printf ' --- %s disassembly (head) ---\n' "${2:-}" >&2 + [ -n "${2:-}" ] && sed -n '1,80p' "$WORK/$2.dis" | sed 's/^/ | /' >&2 + exit 1 +} + +# ---- x64: hot accumulators must land in disp8 range ----------------------- +# Count frame accesses by displacement magnitude: disp8 = |off| <= 127, disp32 +# otherwise. A large frame (cold tail) guarantees disp32 accesses exist; W1.0 +# must also produce a healthy block of disp8 accesses -- the hot accumulators +# the loop touches every iteration. (Without ordering the hot slots could be +# pushed entirely into disp32, so a substantial disp8 count is the signal.) +dump_for x86_64-macos x64 +x64_d8="$(grep -oE '\-?[0-9]+\(%rbp\)' "$WORK/x64.dis" | sed -E 's/\(%rbp\)//' \ + | awk '{v=$1<0?-$1:$1} v<=127{n++} END{print n+0}')" +x64_d32="$(grep -oE '\-?[0-9]+\(%rbp\)' "$WORK/x64.dis" | sed -E 's/\(%rbp\)//' \ + | awk '{v=$1<0?-$1:$1} v>127{n++} END{print n+0}')" +[ "$x64_d32" -gt 0 ] || fail "x64 frame not large enough (no disp32 access -- probe too small)" x64 +[ "$x64_d8" -ge 8 ] || fail "x64 hot slots not in disp8 range (disp8=$x64_d8 disp32=$x64_d32)" x64 + +# ---- rv64: hot slots must stay single-instruction (inside imm12) ---------- +# A single-instruction frame access is `ld/sd reg, off(s0)`; a far slot needs a +# `lui; addiw; add; ld 0(t)` build. The big frame guarantees `lui` far-slot +# builds exist (cold tail past +/-2KB); W1.0 must keep the hot accumulators +# single-instruction, so the count of direct `(s0)` accesses must dominate. +dump_for riscv64-linux-gnu rv64 +rv_single="$(grep -cE '(ld|sd) [a-z0-9]+, -?[0-9]+\(s0\)' "$WORK/rv64.dis" || true)" +rv_lui="$(grep -cE ' lui ' "$WORK/rv64.dis" || true)" +[ "${rv_lui:-0}" -gt 0 ] || fail "rv64 frame not large enough (no lui far-slot build -- probe too small)" rv64 +[ "${rv_single:-0}" -ge 16 ] || fail "rv64 hot slots not single-instruction (single=$rv_single lui=$rv_lui)" rv64 + +printf 'hot_slot_order: OK (x64 disp8=%s/disp32=%s, rv64 single=%s/lui=%s)\n' \ + "$x64_d8" "$x64_d32" "$rv_single" "$rv_lui"