kit

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

commit 1943e48b626acbd09b4728d0abd8a9d95140399d
parent b6c5177e9033c2f2240fe03f2093cb500b033444
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri, 12 Jun 2026 11:22:18 -0700

perf(cg): tcc-style -O0 prologue on aa64; caller-saved-only NDT invariant

The single-pass (-O0) NativeDirectTarget path reserved a worst-case 32-word
prologue region per function (sized for a fat frame + callee-save area +
Windows probe), patched the real ~4-word prologue in, and branched over the
rest — ~27 NOP words/function. Measured: 70,836 NOP words across sqlite3.o
(6.0% of text).

Root cause: the region had to cover a *variable* prologue because the frame
size and callee-save set are unknown until the body is emitted. But the -O0
path never actually allocates a callee-saved register (0/2633 functions in
sqlite, like tcc). So:

1. Make that an enforced invariant: nd_scratch_acquire never takes a
   callee-saved register (the allocable-pass fallback skipped them; pressure is
   absorbed by evicting a cached local instead), and nd_func_end asserts
   callee_saved_used==0. The optimizer/known-frame path is untouched — it drives
   the NativeTarget directly, never through NDT, and keeps full callee-save
   freedom (69% of sqlite functions use callee-saves at -O1).

2. With no callee-saves, only the frame  (+ Windows page probe) is
   deferred. Restructure the aa64 prologue tcc-style: emit the frame-independent
   entry live (stp x29,x30,[sp,#-(16+top_home)]! ; mov x29,sp), then reserve
   only a tiny region (5 words, or 12 with a probe) for the deferred sub. The
   frame is byte-identical in memory — same fp/sp, same fp-relative offsets,
   same epilogue — only the instruction sequence changes; CFI advances by the
   live entry plus the reserved region.

sqlite3.o: 70,836 -> 7,793 NOP words (6.0% -> 0.7%, fewer than tcc's 13,757);
text 4.73MB -> 4.47MB. Validated: test-parse 3920/0, test-toy 1392/0,
test-dwarf + debug roundtrip + CFI unit all OK.

Diffstat:
Msrc/arch/aa64/native.c | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/cg/native_direct_target.c | 24++++++++++++++++--------
2 files changed, 91 insertions(+), 42 deletions(-)

diff --git a/src/arch/aa64/native.c b/src/arch/aa64/native.c @@ -69,6 +69,17 @@ enum { * aa_words_stack_probe). */ AA_PROLOGUE_WORDS = 32u, AA_TAIL_WORDS = 32u, + /* Single-pass (-O0) tcc-style prologue. The frame-independent entry + * `stp x29,x30,[sp,#-(16+top_home)]! ; mov x29,sp` (AA_NDT_FIXED_ENTRY_WORDS) + * is emitted live in aa_func_begin; only the `sub sp` that grows the rest of + * the frame below the saved pair is deferred (patched once frame_size is + * final), plus — on stack-probing targets (Windows) — the page probe. The + * single-pass path uses no callee-saved registers (enforced in + * nd_scratch_acquire), so nothing else is deferred and the reserved region is + * tiny: worst-case sub = load_imm(2)+3 = 5; +probe load_imm(2)+5 = 7 → 12. */ + AA_NDT_FIXED_ENTRY_WORDS = 2u, + AA_NDT_SUB_WORDS = 12u, /* region buffer: worst case (probe + sub) */ + AA_NDT_SUB_WORDS_NOPROBE = 5u, /* reserved region when no stack probe */ }; /* Windows/AArch64 TLS Local-Exec. The TEB pointer lives in the reserved @@ -226,6 +237,7 @@ typedef struct AANativeTarget { u32 func_start; u32 prologue_pos; + u32 prologue_region_words; /* single-pass: reserved deferred-`sub` region */ u32 minimal_prologue_words; /* opt path: exact prologue length, else 0 */ MCLabel epilogue_label; @@ -1147,21 +1159,34 @@ static void aa_func_begin_common(NativeTarget* t, const CGFuncDesc* fd) { static void aa_func_begin(NativeTarget* t, const CGFuncDesc* fd) { AANativeTarget* a = aa_of(t); MCEmitter* mc = t->mc; + u32 region; aa_func_begin_common(t, fd); - /* Reserve the worst-case prologue as one bulk emit (32 NOP words) instead of - * 32 single-word aa_emit32 calls. Replicate the per-instruction -g line rows - * so debug output is unchanged. */ + /* tcc-style prologue. Emit the frame-independent entry live: save the fp/lr + * pair via a (16 + top_home)-byte pre-decrement and anchor fp at the pair. + * top_home (the Windows-variadic GP home area, 0 elsewhere) is known here, so + * the pair and fp land at exactly the addresses every fp-relative offset + * already assumes — the frame is byte-identical to the old single big-`sub` + * prologue, only the instruction sequence changes. */ + aa_emit32(mc, aa64_stp64_pre(AA_FP, AA_LR, AA_SP, + -(i32)((AA_FRAME_SAVE_SIZE + a->top_home_bytes) / + 8u))); + aa_emit32(mc, aa64_add_imm(1, AA_FP, AA_SP, 0, 0)); /* mov x29, sp */ + /* Reserve only the deferred `sub sp` (+ Windows probe), patched in + * aa_func_end. The region starts here; record it for the patch and CFI. */ + region = abi_stack_probe_interval(a->base.c->abi) ? AA_NDT_SUB_WORDS + : AA_NDT_SUB_WORDS_NOPROBE; + a->prologue_pos = mc->pos(mc); + a->prologue_region_words = region; { - u8 nops[AA_PROLOGUE_WORDS * 4u]; - for (u32 i = 0; i < AA_PROLOGUE_WORDS; ++i) - wr_u32_le(nops + i * 4u, 0xd503201fu); + u8 nops[AA_NDT_SUB_WORDS * 4u]; + for (u32 i = 0; i < region; ++i) wr_u32_le(nops + i * 4u, 0xd503201fu); if (mc->debug) { u32 ofs = obj_pos(mc->obj, mc->section_id); - mc->emit_bytes(mc, nops, sizeof nops); - for (u32 i = 0; i < AA_PROLOGUE_WORDS; ++i) + mc->emit_bytes(mc, nops, region * 4u); + for (u32 i = 0; i < region; ++i) debug_emit_row(mc->debug, mc->section_id, ofs + i * 4u, mc->loc); } else { - mc->emit_bytes(mc, nops, sizeof nops); + mc->emit_bytes(mc, nops, region * 4u); } } aa_emit_entry_saves(a); @@ -1501,31 +1526,45 @@ static u32 aa_build_prologue_words(AANativeTarget* a, const AAFrameLayout* L, return n; } -/* Patch the reserved prologue region (`region` words at prologue_pos) with the - * real prologue for `L`. Used by the NativeDirectTarget single-pass path, - * which reserves AA_PROLOGUE_WORDS up front before the frame is known. The - * optimizer path reserves exactly the words it needs, so `region` equals - * the real prologue length and no tail remains. */ -static void aa_patch_prologue(AANativeTarget* a, const AAFrameLayout* L, - u32 region) { - u32 words[AA_PROLOGUE_WORDS]; +/* Build the deferred portion of the single-pass (-O0) prologue: the optional + * Windows page probe and the `sub sp` that grows the frame below the fp/lr pair + * the live entry (aa_func_begin) already saved and anchored fp at. Only + * `frame_size - 16 - top_home` (= aa_sp_off_saved_pair) remains to subtract. */ +static u32 aa_build_ndt_sub_words(AANativeTarget* a, const AAFrameLayout* L, + u32* words, u32 cap) { + u32 n = 0; + u32 sub_bytes = aa_sp_off_saved_pair(L); + if (!sub_bytes) return 0; + { + u32 interval = abi_stack_probe_interval(a->base.c->abi); + if (interval && sub_bytes > interval) + aa_words_stack_probe(a, words, cap, &n, sub_bytes, interval); + } + aa_words_sub_sp_frame(a, words, cap, &n, sub_bytes); + return n; +} + +/* Patch the reserved deferred-`sub` region (prologue_region_words at + * prologue_pos) once the frame is final. Single-pass (NativeDirectTarget) path + * only — the optimizer path emits its prologue final and never patches. The + * fixed entry (pair save + fp anchor) was already emitted live, so this writes + * just the probe + `sub sp` and branches over any unused tail. */ +static void aa_patch_prologue(AANativeTarget* a, const AAFrameLayout* L) { + u32 words[AA_NDT_SUB_WORDS]; + u32 region = a->prologue_region_words; u32 n; ObjSecId sec = a->func->text_section_id; - if (region > AA_PROLOGUE_WORDS) aa_panic(a, "prologue region too large"); + if (region > AA_NDT_SUB_WORDS) aa_panic(a, "prologue region too large"); memset(words, 0, sizeof words); - n = aa_build_prologue_words(a, L, words, region); - /* If the real prologue is shorter than the reserved region (the worst-case - * NDT reservation), branch straight to the body rather than leaving the - * trailing slots as NOPs that fall through and execute on every call. */ + n = aa_build_ndt_sub_words(a, L, words, region); + /* If the deferred prologue is shorter than the reserved region, branch + * straight to the entry saves rather than executing the trailing NOPs. */ if (n < region) { words[n] = aa64_b(region - n); for (u32 i = n + 1u; i < region; ++i) words[i] = 0xd503201fu; } - /* One contiguous patch over the reserved region rather than `region` - * separate obj_patch calls (each a chunk-directory lookup + small memcpy); - * mirrors the x64 backend. */ { - u8 pbytes[AA_PROLOGUE_WORDS * 4u]; + u8 pbytes[AA_NDT_SUB_WORDS * 4u]; for (u32 i = 0; i < region; ++i) wr_u32_le(pbytes + i * 4u, words[i]); obj_patch(a->base.obj, sec, a->prologue_pos, pbytes, (size_t)region * 4u); } @@ -1603,12 +1642,14 @@ static void aa_func_end(NativeTarget* t) { a->top_home_bytes); /* known_frame (optimizer): prologue, allocas, and tail epilogues were emitted * final and slim eligibility was settled in aa_func_begin_known_frame — there - * is nothing to patch. Single-pass (NDT): a worst-case prologue region was - * reserved and the deferred patches recorded; resolve them now that the frame - * is final. The NDT path always uses the fat prologue/epilogue (slim_* left 0 - * by aa_func_begin_common, since its reserved region is much larger). */ - u32 prologue_region = - a->frame.known_frame ? a->minimal_prologue_words : AA_PROLOGUE_WORDS; + * is nothing to patch. Single-pass (NDT): the frame-independent entry was + * emitted live and only the deferred `sub` region + patches remain; resolve + * them now that the frame is final. The advance past the prologue (for CFI) + * is the live fixed entry plus the reserved deferred region. */ + u32 prologue_advance_words = + a->frame.known_frame + ? a->minimal_prologue_words + : (AA_NDT_FIXED_ENTRY_WORDS + a->prologue_region_words); mc->label_place(mc, a->epilogue_label); aa_emit_callee_restores(a); aa_emit_restore_frame(a, &L); @@ -1619,12 +1660,12 @@ static void aa_func_end(NativeTarget* t) { * the final prologue never saw. */ if (a->npatches != 0) aa_panic(a, "known-frame path left deferred patches"); } else { - aa_patch_prologue(a, &L, prologue_region); + aa_patch_prologue(a, &L); aa_apply_patches(a, &L); } if (mc->cfi_set_next_pc_offset && mc->cfi_def_cfa && mc->cfi_offset) { i32 cfa = aa_cfa_off(a); - mc->cfi_set_next_pc_offset(mc, prologue_region * 4u); + mc->cfi_set_next_pc_offset(mc, prologue_advance_words * 4u); /* CFA = caller's sp, an fp-relative offset that depends on the layout: * fp+16 (top-record) or fp+frame_size (bottom-record). saved fp/lr live at * [fp]/[fp+8] in both, hence at CFA-cfa / CFA-cfa+8. */ diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c @@ -163,6 +163,12 @@ static Reg nd_scratch_acquire(NativeDirectTarget* d, NativeAllocClass cls) { for (u32 i = 0; i < nregs; ++i) { Reg r = regs[i]; if (r >= 32u) continue; + /* The single-pass (-O0) path is caller-saved only: never take a + * callee-saved register as scratch (the `allocable` pass would otherwise + * offer them under pressure). Pressure is absorbed by evicting a cached + * local below instead — keeping callee_saved_used empty so each backend + * can reserve only a tiny deferred-prologue region. See nd_func_end. */ + if (nd_callee_saved_mask(d, cls) & (1u << r)) continue; if ((d->scratch_used[cls] & (1u << r)) == 0 && d->reg_owner[cls][r] == CG_LOCAL_NONE) { d->scratch_used[cls] |= 1u << r; @@ -938,17 +944,19 @@ static void nd_func_begin(CgTarget* t, const CGFuncDesc* fd) { static void nd_func_end(CgTarget* t) { NativeDirectTarget* d = nd_of(t); NativeFramePatchState frame; - u32 ncallee_classes = 0; memset(&frame, 0, sizeof frame); frame.max_outgoing = d->max_outgoing; + /* Invariant: the single-pass (-O0) path never allocates a callee-saved + * register — nd_scratch_acquire and the local cache restrict themselves to + * caller-saved regs (see nd_scratch_acquire / nd_cache_alloc). The prologue + * therefore never saves/restores any callee-save, which is exactly what lets + * each backend reserve only a small fixed deferred-prologue region (just the + * frame `sub`, plus a Windows probe) instead of a worst-case callee-save + * area. The optimizer/known-frame path keeps full callee-save freedom: it + * drives the NativeTarget directly and never routes through here. */ for (u32 cls = 0; cls < 3u; ++cls) { - if (d->callee_saved_used[cls]) ncallee_classes = cls + 1u; - } - if (ncallee_classes) { - if (!d->native || !d->native->reserve_callee_saves) - nd_panic(d, "target cannot preserve callee-saved scratch registers"); - d->native->reserve_callee_saves(d->native, d->callee_saved_used, - ncallee_classes); + if (d->callee_saved_used[cls]) + nd_panic(d, "single-pass (-O0) path must not use callee-saved registers"); } if (d->native && d->native->note_frame_state) d->native->note_frame_state(d->native, &frame);