Skip to content

Performance backlog#372

Open
kromych wants to merge 46 commits into
masterfrom
perf/campaign-2
Open

Performance backlog#372
kromych wants to merge 46 commits into
masterfrom
perf/campaign-2

Conversation

@kromych

@kromych kromych commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Perf-lever campaign round 1: the S/M-cost levers from the 2026-07 audit, plus every miscompile the campaign surfaced.

Codegen / mid-end

  • F32 compound assignment on the StoreLocal fast path (stb −29%)
  • f-suffixed float literals typed float per C99 6.4.4.2 (conformance + double-hop removal)
  • dead-local frame elision sharing the emit's dead-pure predicate
  • aarch64: every indirect call marshals through the host ABI (sqlite_bench arm64 −7.7%)
  • aarch64: callee saves pair as stp/ldp, locals allocation folds into the pre-index
  • regalloc: class must_callee from CFG-precise per-value flags (qsort x64 hot-IV spill eliminated)
  • store_forward: frame-slot stores forward to same-block reloads
  • post-inline constant folding through a shared VM evaluator (vm/eval.rs; fold == --interp == native by construction)
  • -O now predefines NDEBUG and OPTIMIZE; demos and the perf comparisons build optimized legs with NDEBUG

Loop/switch codegen

Miscompiles found and fixed

  • static-local arrays with &&label initializers re-ran their initializer every call
  • brace initializers dropped intermediate cast narrowing (+ float-bits and precedence riders)
  • I8/I16 parameter entry conversion skipped on a bits-32..63 liveness proof
  • spill-slot sharing across returns-twice calls (vfork/setjmp) clobbered second-return values
  • CPython stack-limit flakiness: pthread stack-bounds functions declared/bound + GLIBC gate + a recursion margin sized to badc's frames

Closes #347
Closes #348
Closes #349
Closes #351
Closes #352
Closes #353
Closes #354
Closes #355
Closes #365
Closes #366
Closes #374
Closes #375
Closes #357
Closes #358

Found and filed, not fixed here: #373 (snapshot path dependence), #376 (GLIBC predefine policy), #377 (python demo obj-cache keying).

🤖 Generated with Claude Code

kromych and others added 30 commits July 5, 2026 20:24
The RmwPlace::Slot exclusion of StoreKind::F32 dated from before the
backends lowered fused F32 StoreLocal/LoadLocal; both do now, the SSA
interpreter handles them, and the walker already narrows to single
precision before every F32 store, so the slot promotes like the
x = x op k form.

Fixes #349

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ImmData offsets drifted with earlier header/runtime binding
changes; the current compiler already produces these values, so this
is regeneration only, no codegen change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C99 6.4.4.2p4: an unsuffixed floating constant has type double, f/F
float, l/L long double (which c5 represents as double). The lexer
consumed the suffix and discarded it, so every floating constant was
typed double: sizeof(1.0f) reported 8, and float arithmetic against
an f-suffixed literal ran a widen / op-in-double / narrow hop.

The lexer now records the f/F suffix and rounds the constant to
single precision at the literal (6.4.4.2p5), keeping the value as
f64 bits so the const-expression evaluator and static initializers
consume it unchanged. The parser types the literal Ty::Float; the
walker emits it through the new SsaBuilder::imm_f32, cached apart
from integer imms of equal payload because the f32 mark changes the
value's materialisation. The SSA interpreter widens an f32-marked
imm at its definition to match its f64-pattern register convention.

Fixes #348

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The badc -O / clang -O2 / cl /O2 rows of tests/perf/run.py and both -O
legs of demos/python/compare_compilers.py now build with NDEBUG,
matching release-build practice; unoptimized legs keep asserts live.
Perf-table history is not comparable across this commit for the
assert-carrying fixtures (sqlite, quickjs, compress, CPython).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The brace-element parser skipped a leading (TYPE) cast and recursed on
the operand, so every intermediate conversion was lost:
(long)(int)0x92492493 stored the unextended 0x92492493 in static and
automatic arrays and structs, (int)2.75 kept the float bits, and a
binary operator after the cast folded before it instead of after.
Route a cast of an arithmetic operand through the constant-expression
evaluator, which converts at each cast's width and signedness per C99
6.5.4/6.3.1.3 before the 6.7.8p11 conversion to the member type; only a
cast of a relocation-bearing leaf keeps the skip-and-recurse path where
the value is the leaf's address. The shared reloc-leaf probe now sees
through grouping parens plus nested casts and restores string bytes it
lexed while scanning.

Fixes #366

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A static-local array initializer carrying a `&&` token (a `&&label`
element, or logical AND inside a constant element) routes to
emit_static_array_init_runtime, which emitted unguarded per-element
stores at the declaration point: the initializer re-ran on every call,
clobbering user writes to the table. C99 6.2.4p3 gives an object with
static storage duration exactly one initialization, before program
startup, for the whole program run.

Wrap the stores in a hidden once-guard byte allocated directly after
the array in the same data object (so data DCE and linker rebase move
it with the array), lowered as the single statement
`guard ? 0 : (stores..., guard = 1)`; the guard is ordinary loads,
stores, and branches in the IR, so the native backends, the JIT, and
the SSA interpreter all honor it. quickjs_bench -O on the arm64 host:
325.2 -> 207.7 ms median of 5 (-36%).

The generic form -- resolving `&&label` elements in the data image via
label relocations at translation time, removing the runtime stores
entirely -- remains as follow-up work on the issue.

Fixes #365

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A single -O now selects release semantics: the optimization passes plus
NDEBUG=1 and __OPTIMIZE__=1 predefines, installed ahead of the CLI -D/-U
lists so an explicit -D NDEBUG=<v> keeps the user's value and -U NDEBUG
re-enables assert. Demo builds that compiled without optimization now
pass -O on every badc compile (dual-lane demos keep both lanes); the
perf harnesses drop the now-redundant -DNDEBUG from their badc rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	tests/snapshots/ssa/object_macro_to_fn_macro_rescan.ssa
compute_frame_base counted every LoadLocal / StoreLocal / LocalAddr as a
local access, so a promotion-orphaned LoadLocal with no consumers --
which the per-inst emit dispatch skips via is_dead_pure and which
produces no machine code -- still forced frame_bytes != 0 and a full
prologue on an otherwise frameless leaf. Gate the scan with the same
is_dead_pure predicate the emit uses, so the frame decision and the
emit skip cannot diverge.

The rewritten scan also counts locals-region references that reach the
emit outside the slot-access instructions: AllocaInit's arena
bookkeeping slot, a call's aggregate-return result temp
(ret_slot_local), and the by-value aggregate parameter scatter into a
body local (param_aggs / param_local_slots), alongside the existing
indirect_result_slot case. Volatile slot accesses are not pure and keep
the frame.

Snapshot drift: 512 asm files, 616 functions; every change is a frame
loss (424) or a frame allocation shrink (192), no function gains code.

Fixes #352

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	tests/snapshots/asm/object_macro_to_fn_macro_rescan.aarch64.asm
#	tests/snapshots/asm/object_macro_to_fn_macro_rescan.x64.asm
Regenerated with a binary built from the branch base; the recorded
ImmData offsets were one byte behind the current front end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ParamRef entry sign-extension was gated on high_observed, which
tracks reads of bits 32..63 only. An I8/I16 parameter conversion also
rewrites bits 8..31, so a consumer reading the low word through a
32-bit truncation observed the caller's unconverted value; direct and
indirect calls miscompiled alike under -O. Keep the gate for I32,
whose sxtw/movsxd touches only bits 32..63.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
emit_call_indirect took the marshal branch only for variadic and
aggregate cases; every non-variadic scalar-arg indirect call fell to
the c5-stack push shape, spending 2n+2 memory operations per blr on
pushes the callee never reads. The variadic disjunction is exhaustive
on aarch64, so the push shape was reachable only for non-variadic
scalar calls: widen the gate to marshal unconditionally and delete
the push shape. A non-variadic call plans every argument as fixed,
mirroring the direct-call path; x86_64 already marshals every
indirect call.

sqlite_bench -O on the macOS arm64 host: median 0.65s -> 0.60s.

Fixes #351

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plain long is 32-bit on LLP64 targets and plain char is unsigned on
AArch64 Linux/Windows, so the host-derived expected values failed
there. Explicitly signed fixed-width types keep one expectation set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	src/c5/tests/jit.rs
#	src/c5/tests/native.rs
#	src/c5/tests/native_elf.rs
#	src/c5/tests/native_elf_x64.rs
#	src/c5/tests/native_pe_arm64.rs
#	src/c5/tests/native_pe_x64.rs
#	src/c5/tests/programs.rs
#	tests/snapshots/ssa/object_macro_to_fn_macro_rescan.ssa
…ossing flags

promote_calls_after_def_to_classes re-derived call-crossing per phi
class from a pc-interval test (first call pc between the class root's
def and the class's combined last use). extend_last_use_across_blocks
raises loop-resident values' last_use past call pcs whenever a call
block is laid out at a lower pc than the loop body, so the interval
covered calls the class never crosses in the CFG. Values the precise
values_live_across_calls pass leaves unflagged were still forced into
the callee bank; with more loop-live values than callee registers the
hot induction variable spilled, carrying a store->reload through every
loop iteration.

The class flag is now the OR of the members' per-value flags from
values_live_across_calls, which already models TlsAddr-as-call and the
setjmp barrier. The pc-interval machinery and the class_last_use table
it read become dead and are removed.

On the recursive-partition fixture (linux-x64) the spill disappears
(spill_count 1 -> 0, callee regs 5 -> 3) and the aarch64 prologue drops
from 6 saves to 3. 59 fixture snapshots drift; every change is a pure
register reassignment out of the callee bank, no snapshot gains stack
traffic, net spill_count -4 corpus-wide.

Fixes #355

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	tests/snapshots/asm/block_scope_function_declaration.aarch64.asm
#	tests/snapshots/asm/block_scope_function_declaration.x64.asm
#	tests/snapshots/asm/c4.aarch64.asm
#	tests/snapshots/asm/c4.x64.asm
#	tests/snapshots/asm/comparison_imm_lhs_swap.x64.asm
#	tests/snapshots/asm/designator_override_and_braced_string.x64.asm
#	tests/snapshots/asm/dlopen_atoi.aarch64.asm
#	tests/snapshots/asm/fn_ptr_ternary_call_return.aarch64.asm
#	tests/snapshots/asm/global_init_midexpr_cast_narrow.aarch64.asm
#	tests/snapshots/asm/global_init_midexpr_cast_narrow.x64.asm
#	tests/snapshots/asm/inline_forward_ref_value.aarch64.asm
#	tests/snapshots/asm/inline_forward_ref_value.x64.asm
#	tests/snapshots/asm/integer_ops_exhaustive.aarch64.asm
#	tests/snapshots/asm/integer_ops_exhaustive.x64.asm
#	tests/snapshots/asm/mem2reg_value_across_call.aarch64.asm
#	tests/snapshots/asm/mmap_anonymous.aarch64.asm
#	tests/snapshots/asm/mmap_anonymous.x64.asm
#	tests/snapshots/asm/slot_coalesce_declared.x64.asm
#	tests/snapshots/asm/ssa_fp_compare_nan.aarch64.asm
#	tests/snapshots/asm/ssa_fp_compare_nan.x64.asm
#	tests/snapshots/asm/switch_nested_case_in_compound.aarch64.asm
#	tests/snapshots/asm/switch_nested_case_in_compound.x64.asm
#	tests/snapshots/asm/sysconf_pagesize.aarch64.asm
#	tests/snapshots/asm/sysconf_pagesize.x64.asm
#	tests/snapshots/asm/tentative_array_definition.aarch64.asm
#	tests/snapshots/asm/tentative_array_definition.x64.asm
#	tests/snapshots/asm/tentative_array_use_before_init.aarch64.asm
#	tests/snapshots/asm/tentative_array_use_before_init.x64.asm
#	tests/snapshots/asm/unsigned_char_array.aarch64.asm
#	tests/snapshots/asm/unsigned_char_array.x64.asm
#	tests/snapshots/asm/unsigned_compare.aarch64.asm
#	tests/snapshots/asm/unsigned_compare.x64.asm
store_forward cleared its tracking on every StoreLocal instead of
recording the stored value, so a same-slot StoreLocal -> LoadLocal
pair survived as a store plus a dependent reload. Track slots in a
second intra-block table under the existing width / volatile /
distance discipline. A slot participates only when nothing but
LoadLocal / StoreLocal can reach it: mem2reg::promotable_slots (no
LocalAddr, no volatile access, no alloca arena) minus the slots
written through FunctionSsa fields or a call's ret_slot_local. Its
entries survive pointer stores, Mcpy, and atomics -- the slot's
address is never a value -- and die at a same-slot StoreLocal, at a
call (forwarding across one holds the value live over the call, which
spills), and at block boundaries. GotoIndirect targets now resolve
through the redirect map so a forwarded slot load feeding a computed
goto goes dead.

Fixes #354

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	tests/snapshots/asm/indirect_struct_return_outptr.x64.asm
#	tests/snapshots/asm/label_addr_array_init.aarch64.asm
#	tests/snapshots/asm/out_pointer_return_float_args.x64.asm
#	tests/snapshots/ssa/label_addr_array_init.ssa
Move apply_binop, round_if_f32, and the Extend / FpCast / Fneg / Fma
arm bodies verbatim into a shared module so the interpreter and the
compile-time constant folder use one implementation of the operator
semantics. Division traps become the EvalTrap enum; the VM re-wraps
the same message strings into C5Error::Runtime. Behavior-neutral.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Baseline regen: the merge of the ten mid-end changes did not refresh
tests/snapshots, so the tree lagged the compiler's output (54 files;
store-forwarded reload shapes and shorter block bodies). Generated
with the pre-extraction compiler; no compiler change in this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inliner substitutes call-site immediates into callees and
materialises callee-narrows extends on them, leaving Extend(Imm) /
Binop(Imm, Imm) / BinopI(Imm, k) chains no stage re-folded, so the
rotate matcher's constant arm and the immediate-form encoders never
saw them. The new constfold pass (between struct_return_reg and
rotate in both pipelines) folds those shapes through eval::fold_binop
-- the interpreter's own evaluator -- and rewrites immediate-operand
binops to BinopI when the op is immediate-safe in both emitters and
the immediate either encodes without a per-use scratch
materialisation or has no other use. Folds the evaluator computes
but native code traps on (division by zero, i64::MIN / -1) are
refused; f32-flagged and address-bearing immediates never fold.

Corpus effect: 2815 instructions fold to Imm across 180 fixtures;
snapshot text drops 12 percent of instructions with no function
growing beyond padding artifacts; the crypto fixture's 21
register-count rotates become 20 immediate-form rotates plus the one
genuine runtime count.

Fixes #347

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…functions

A value dead on the first-return path of a setjmp-family or vfork call
is still read after the second return (C99 7.13.2.1p3; under vfork the
child writes the parent's shared stack), so slot reuse and frame-slot
coalescing clobber it; such bodies also stay out of line. The flag is
set for the setjmp intrinsic and the {vfork, setjmp, _setjmp, sigsetjmp,
__sigsetjmp} externs.

Fixes #374

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Saves and restores at contiguous offsets pair into stp/ldp; frames
within the imm7 scaled range fold the locals allocation into a
pre-indexed stp, keeping x29-relative addressing unchanged. Larger
frames keep the split shape. The x86_64 push/pop form is deferred: PE
UNWIND_INFO would need save codes the writer does not emit yet.

Fixes #353

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	tests/snapshots/asm/block_scope_function_declaration.x64.asm
#	tests/snapshots/asm/indirect_call_mixed_fp_int_args.x64.asm
#	tests/snapshots/asm/integer_ops_exhaustive.aarch64.asm
#	tests/snapshots/asm/integer_ops_exhaustive.x64.asm
#	tests/snapshots/asm/switch_nested_case_in_compound.x64.asm
#	tests/snapshots/asm/tentative_array_definition.x64.asm
#	tests/snapshots/asm/unsigned_compare.x64.asm
# Conflicts:
#	tests/snapshots/asm/binop_imm_chain_fold.aarch64.asm
#	tests/snapshots/asm/block_scope_function_declaration.aarch64.asm
#	tests/snapshots/asm/builtin_bswap_expect.aarch64.asm
#	tests/snapshots/asm/c4.aarch64.asm
#	tests/snapshots/asm/c99_arith_common_width.aarch64.asm
#	tests/snapshots/asm/comparison_imm_lhs_swap.aarch64.asm
#	tests/snapshots/asm/divmod_preserves_rdx.aarch64.asm
#	tests/snapshots/asm/dlopen_atoi.aarch64.asm
#	tests/snapshots/asm/float_pointer_basics.aarch64.asm
#	tests/snapshots/asm/fn_ptr_decay_inside_block.aarch64.asm
#	tests/snapshots/asm/fn_ptr_multi_deref.aarch64.asm
#	tests/snapshots/asm/fn_ptr_ternary_call_return.aarch64.asm
#	tests/snapshots/asm/function_type_typedef_declaration.aarch64.asm
#	tests/snapshots/asm/global_init_midexpr_cast_narrow.aarch64.asm
#	tests/snapshots/asm/gnu_extension_keyword.aarch64.asm
#	tests/snapshots/asm/include_macro_operand.aarch64.asm
#	tests/snapshots/asm/indirect_call_mixed_fp_int_args.aarch64.asm
#	tests/snapshots/asm/indirect_call_narrow_scalar_args.aarch64.asm
#	tests/snapshots/asm/indirect_call_ten_scalar_args.aarch64.asm
#	tests/snapshots/asm/indirect_call_variadic_fp_control.aarch64.asm
#	tests/snapshots/asm/inline_arg_count_mismatch.aarch64.asm
#	tests/snapshots/asm/inline_forward_ref_value.aarch64.asm
#	tests/snapshots/asm/integer_boundary_c99.aarch64.asm
#	tests/snapshots/asm/integer_ops_exhaustive.aarch64.asm
#	tests/snapshots/asm/mem2reg_value_across_call.aarch64.asm
#	tests/snapshots/asm/mmap_anonymous.aarch64.asm
#	tests/snapshots/asm/mul_pow2_to_shift.aarch64.asm
#	tests/snapshots/asm/parenthesized_function_declarator.aarch64.asm
#	tests/snapshots/asm/preinc_narrow_lvalue_wraps.aarch64.asm
#	tests/snapshots/asm/signed_cast_extends.aarch64.asm
#	tests/snapshots/asm/ssa_fp_compare_nan.aarch64.asm
#	tests/snapshots/asm/static_init_once_guard.x64.asm
#	tests/snapshots/asm/store_to_load_forward.aarch64.asm
#	tests/snapshots/asm/switch_nested_case_in_compound.aarch64.asm
#	tests/snapshots/asm/sysconf_pagesize.aarch64.asm
#	tests/snapshots/asm/tentative_array_definition.aarch64.asm
#	tests/snapshots/asm/type_warning_arity.aarch64.asm
#	tests/snapshots/asm/unsigned_char_array.aarch64.asm
#	tests/snapshots/asm/unsigned_compare.aarch64.asm
#	tests/snapshots/asm/unsigned_compound_assign.aarch64.asm
#	tests/snapshots/asm/zero_sign_extension_32bit.aarch64.asm
Cross-branch merges resolved snapshot conflicts by taking one side;
this regen reflects the merged compiler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kromych and others added 16 commits July 6, 2026 07:05
The shape exposes a pre-existing Win64 positional-slot marshalling
defect in indirect calls; tracked separately, re-enable with the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pairing fold emits the x29/x30 pair pre-indexed or at a signed
offset into an already-adjusted frame depending on pressure; the
sentinel was pinned to the #-0x10 pre-indexed word. Dump the preamble
on failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pthread_attr_getstack and pthread_attr_getguardsize on both platforms,
pthread_getattr_np on Linux (GNU extension). CPython's recursion guard
derives real stack limits from these; without them it falls back to a
4 MB estimate from the current stack pointer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Define __GLIBC__ in the Linux target configs so CPython's guard takes
the pthread_getattr_np path instead of estimating the stack from sp
(flaky aborts under ASLR); raise _PyOS_LOG2_STACK_MARGIN to 2^13 words
because the check-to-check stride must exceed one eval frame and badc's
frames outsize the reference compilers' until live ranges split; keep
the head of a failing slice's output where the fatal message lives.
Verified: the tier2 slice went from ~50% SIGABRT to 8/8 green on
linux-aarch64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ells

The walker's callee entry reads a float parameter from its argument
cell as an f32 bit pattern (LoadLocal { F32 } for a stack-passed one,
matching what native callers store), but the SSA interpreter's call
path wrote the f64-pattern register convention into every cell, so a
float parameter past the FP argument registers read back as 0.0f.
Win64 assigns one argument slot per position (rcx/xmm0 .. r9/xmm3)
rather than SysV's independent int/FP banks, so the mixed int/fp
indirect-call fixture hit the divergence at argument position 4 on the
win-x64 host lane; SysV needs nine FP arguments to reach it. Narrow
f32-typed arguments at the cell boundary and widen them back in
ParamRef { F32 }; the emitted native code is unchanged.

Fixes #375

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… inversion

The For lowering allocates (header, post, body, after) with the
header's exit test branching over post, so every for / while
iteration runs three taken branches, plus jmp-to-jmp chains where
loops nest. Add a late layout pass to both pipelines, after
store_forward, that only reorders blocks and remaps block ids;
instructions never move between blocks:

* thread edges through chains of empty Jmp blocks, stopping one hop
  short of a phi-carrying target (that hop holds the edge's moves);
* place blocks depth-first with each natural loop's body contiguous;
* rotate a loop whose header conditionally exits the loop to
  bottom-test form: header last in its chain, an unconditional latch
  directly before it so the back edge falls through;
* invert a Bz / Bnz whose taken target is the next block in layout
  (successor set unchanged, so no new critical edges).

Functions with a computed goto or an irreducible loop keep source
order. Block-id remapping (terminator targets, phi incomings,
BlockAddr, computed_goto_targets) lives in passes/remap_blocks.rs
as a shared utility. The .ssa snapshots drift because the dump
prints the new block order; the .asm snapshots drift because the
emitters elide jumps to the new next block.

Measured -O medians (7 runs): arm64 sieve -20.8%, crypto -25.1%,
compress -12.1%, sqlite_bench -20.3%, qsort +2.6% (x64 qsort -1.4%;
the static per-scan branch counts are equal, entry hops into the
rotated scan loops offset the rotation win); x64 sieve -21.4%,
crypto -45.5%, sqlite_bench -14.9%. A CFG-reconstructing checker
over the emitted code shows no function gained an unconditional
branch inside a loop body on either arch.

Fixes #357

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A switch dispatcher previously lowered only to a balanced compare
tree, so a many-case dense switch (a bytecode interpreter's opcode
dispatch) paid O(log n) compare-and-branch levels per dispatch, each
level reloading the scrutinee. A dense case set (>= 8 cases, span
under twice the case count) now lowers to a bias subtract, a single
unsigned bounds check to default, and Terminator::JumpTable -- an
indexed branch through a per-function side table (Terminator stays
Copy; the target lists live in FunctionSsa::jump_tables, the
computed_goto_targets precedent). Sparse and small sets keep the tree.

The table is emitted in .text right after the terminator and resolved
by the per-function block-offset fixups; no object-writer or
relocation changes. Entries are 32-bit offsets relative to the table
base: aarch64 dispatches via adr / ldrsw scaled / add / br (the
embedded table keeps adr trivially inside its +/-1 MiB reach), x86_64
via lea rip-relative / movsxd SIB / add / jmp. The bounds check
proves the index in range, so out-of-range and negative scrutinees
(including the promoted-width unsigned wrap cases) fall to default
before the indexed branch.

Every terminator consumer gains the arm: the VM interpreter (interp /
JIT parity), liveness and the allocator's use walks treat the index as
a terminator operand, mem2reg / slot_coalesce / the passes' successor
enumeration read the table's distinct targets, constfold_branch folds
a constant in-range index to Jmp, and the inliner rejects jump-table
callees and skips multi-block splicing for jump-table callers (block
ids shift; a shared BlockId remap utility can lift this later).
split_crit_edges retargets table entries onto trampoline blocks: a
dispatcher edge into a phi-carrying case block would otherwise emit
every successor's phi moves at the dispatcher exit and clobber values
live on the other edges.

Perf (7-run medians, -O): sqlite_bench 602.0 -> 535.5 ms (-11.0%) on
macOS arm64 and 715.1 -> 655.4 ms (-8.4%) on Linux x64; the compress
canary is neutral on both. The clang oracle isolates the same cost:
-fno-jump-tables slows its sqlite_bench build by 12.1%.

Fixes #358

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	tests/snapshots/asm/duff_switch_into_loop.aarch64.asm
#	tests/snapshots/asm/duff_switch_into_loop.x64.asm
#	tests/snapshots/ssa/duff_switch_into_loop.ssa
The chain builder enumerates JumpTable successors in table order and
permute_blocks remaps the jump_tables target lists with the rest of
the block-id surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…inery

Models a natural loop's body as a pseudo-callee whose parameters are the
header phis and whose returns are the latch back-edge values, then clones
trip-count copies with the inliner's remap primitives; trip counts are
evaluated with the shared VM evaluator. v1 unrolls single-block bodies
(trip <= 16, size-capped) without scalar promotion, leaving the existing
index_fold/store_forward/constfold to clean up. crypto 700 -> 310 ms
(2.15x) on host arm64, checksum unchanged; compress unaffected.

Refs #360

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The phi_join switch fixture also carries a const-trip loop that the
unroll pass now expands; runtime behaviour is unchanged (suite green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fixture computed a 64-bit golden value with 'long', which is
32-bit on LLP64 (Windows); the arithmetic overflowed there and the
check failed. Use 'long long' for 64-bit width on every target. #358's
jump-table codegen is unaffected (LP64 snapshot targets unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment