Compare commits

..

20 Commits

Author SHA1 Message Date
MechaCat02
eb7c6f98cd [iterate-4C] JIT native FP-arith Phase 2: fused madd family (the 27.7% op)
Native fmadds/fmsubs/fnmadds/fnmsubs + double siblings via hardware FMA
(vfmadd213sd/vfmsub213sd — single rounding, matching the interpreter's
correctly-rounded f64::mul_add; negated forms flip the result sign with xorpd,
and NaN — which the interpreter preserves unnegated — is caught by the finite
guard → deopt, so unconditional negate is exact on the fast path). Gated on a
runtime cpuid FMA3 check (emit::host_has_fma, cached OnceLock); on a non-FMA
host these arms fall back to the interpreter's software FMA — byte-identical, so
goldens hold on any host (NEVER emit mulsd+addsd, which double-rounds).

fp_arith_matches extended to the madd family (guarded on host FMA3). All 6-config
GOLDEN n200m BYTE-IDENTICAL; 24 xenia-jit tests green.

MEASURED (2B-instr run through the video, --gpu-inline, best-of-3) — CLEAN 3-way:
  interp                    72.6s  (27.5 MIPS)
  region JIT (pre-FP)       27.3s  (73.2 MIPS)  = 2.66x over interp
  region JIT + native FP    23.3s  (85.8 MIPS)  = 3.12x over interp
So native FP's OWN contribution is ~1.17x (+17%) on top of the region JIT — it
removed 62% of all interpreter fallbacks (306.6M→117.3M, the FP-arith share) but
that translates to only ~17% wall-time here because the region JIT already made
non-FP code fast and the --gpu-inline drain + plumbing dominate the remainder.
This matches the conservative ~1.3-1.5x plan estimate (region JIT is the bigger
CPU lever on the video; FP is a solid, deterministic increment and a multiplier
for a future multi-core mode). Video still ~7x from real-time → multi-core next.
2026-07-05 15:17:12 +02:00
MechaCat02
0672f37c9f [iterate-4C] JIT native FP-arith Phase 1: fadds/fsubs/fmuls/fdivs (+doubles)
Emit native x64 for the hot FP-arith ops (currently 61.6% of interpreter
fallbacks through the intro video; the JIT emitted ZERO native FP arithmetic
before). Phase 1 = add/sub/mul/div, single (op59) + double (op63), Rc=0.

Correctness rests on a lemma (validated vs fpscr.rs): if the final result is
±NORMAL or ±ZERO, the interpreter set NO FPSCR exception bit and
FPRF=classify(result). So the fast path is: RN-nearest guard (FPSCR RN_MASK=0x3,
low 2 bits — NOT 0xC0000000) → native movsd/addsd/subsd/mulsd/divsd in xmm
(single narrows via cvtsd2ss;cvtss2sd = to_single at RN-nearest, bit-exact) →
result guard (reject inf/NaN; for doubles also reject subnormal → UX+DENORMAL) →
store + cheap inline FPRF (sign+iszero → one of ±NORMAL/±ZERO codes). Every
guarded (rare) case DEOPTS to jit_interpret_one for that one instruction —
counter-neutral (state.retire() defers the +1 on both paths; interp_one bumps no
counter and touches only fpr/fpscr/pc, never ctx.gpr, so no RegCache flush).
Rc=1 forms fall back (update_cr1 not replicated). FMA (madd) family deferred to
Phase 2 (needs hardware FMA3).

Infra: Offsets.fpscr; MemHelpers.interpret_one; emit_fp_arith + FpArith enum +
emit_fprf_normal_or_zero (emit.rs); DynasmLabelApi import (dynamic labels/relocs
in emit.rs). check_fp now SEEDS and ASSERTS fpscr (the real safety net) — added
check_fp_seeded. New tests: fp_arith_matches (fuzz + edge floats),
fp_arith_directed_rounding_matches (RN!=nearest → deopt), fp_arith_block_matches
(multi-op native↔deopt↔fallback boundary). 24 xenia-jit tests green.

GOLDEN n200m BYTE-IDENTICAL across all 6 configs (interp / JIT / +CHAIN(region)
/ +REGCACHE / +REGION_MAX=1 / +budget=1==interp+budget=1). Whole-video speedup
measured after Phase 2 (the fmadds family is the single biggest op at 27.7%).
2026-07-05 15:03:33 +02:00
MechaCat02
3f509ebc5c [iterate-4C] --ui: make threaded GPU the default (was opt-in XENIA_UI_GPU_THREAD)
Measured ~5× faster boot under --ui (~12 → ~63 MIPS) by moving the per-frame
PM4 drain (draws, YUV texture decode, resolves) + UI publish off the emulation
thread onto the GPU worker — canary's async-command-thread model. User-confirmed
visuals identical to the inline path (splash + intro video render correctly).

Flip the default: `--ui` now uses the threaded GPU backend. Opt back to inline
with `--gpu-inline` or `XENIA_UI_GPU_INLINE=1`. The deterministic golden path
already passes `--gpu-inline`, so goldens are unaffected (n2m re-checked MATCH).
`XENIA_UI_GPU_THREAD` retained as a no-op alias. Headless default (M1.9 threaded)
unchanged.

Note: threading the GPU only helps GPU-bound phases (boot). The intro VIDEO is
CPU-bound on multi-threaded 720p-YUV software decode (FP-arith-heavy, mostly JIT
fallback), so it stays ~real-video-length; that needs the multi-core / native-FP
levers, not GPU threading.
2026-07-05 14:16:12 +02:00
MechaCat02
0e24e980fb [iterate-4C] M3: default connected controller for user 0 (reach the Press A title loop)
Sylpheed's boot reaches its front-end TITLE shell (steady frame loop — 2219
swaps over 5B instr; XamResetInactivity/XamEnableInactivityProcessing/
XamUserGetSigninState front-end mgmt; XamInputGetCapabilities polled 8880x) but
then STALLS: headless has no HID device, so GetCapabilities returns
DEVICE_NOT_CONNECTED and the game loops forever waiting for a controller. It
never advances to read input (XamInputGetState / XamInputGetKeystrokeEx = 0).

Canary reaches the "Press A" menu because its default input drivers (xinput /
winkey) present an always-connected controller. Match that: present a default
always-connected, IDLE (no-buttons) virtual controller for user 0 (a console
always exposes controller slots; a real gamepad under --ui still supplies actual
button state — see xam_input_get_state, which prefers ui.snapshot_gamepad()).
Set XENIA_NO_PAD=1 to present no controller (the raw headless no-HID trajectory).

MEASURED (headless, this is not inference): with the controller connected the
game advances into the interactive input-poll loop —
  XamInputGetKeystrokeEx  0 -> 2164  (once per frame)
  XamInputGetState        0 -> 2165
  XamInputSetState (rumble) 0 -> 1
i.e. the "Press A" title-screen loop (attract video repeats while it polls for
A), exactly the M3 milestone behavior. Visual confirmation of the on-screen
"Press A" prompt is the remaining step (--ui + frame readback).

GOLDEN RE-BASELINE (deliberate): a connected controller changes the guest
trajectory WITHIN the 200M window (the title polls input before 200M), so both
sylpheed_n200m.json and sylpheed_n2m.json are re-baselined. Change is proven
ISOLATED: XENIA_NO_PAD=1 reproduces the OLD goldens byte-identical at both n.
New n200m: instructions 200000203, imports 575647, draws 3208, swaps 910
(was 200000239 / 575447 / 3165 / 895 — small front-end-input increments; RTs/
shaders/textures unchanged). All 6 JIT configs match the new golden byte-
identical (interp / JIT / JIT+CHAIN / +REGCACHE / REGION_MAX=1 / budget=1==
interp+budget=1); 131 xenia-kernel + 21 xenia-jit tests green.
2026-07-05 13:29:52 +02:00
MechaCat02
f562e3ec2b [iterate-4C] JIT Phase B incr.2: region-granularity compilation (the real dispatch win)
Compile a straight-line/same-page REGION rooted at a block into one
CompiledBlock (XENIA_JIT_CHAIN): the set of same-page blocks reachable by
compile-time-known direct branches (BFS, capped by XENIA_JIT_REGION_MAX=32
+ 512-instr budget). Internal transitions between member blocks become plain
native jmps to dynasm labels — no per-block prologue/epilogue, no
jit_chain_next lookup, no stack churn — while the lockstep yield guards
(chain-enabled / budget / mmio) are evaluated inline at each block boundary
and a sync-sensitive block ends the region. Any successor not inlined
(indirect target, cross-page) falls to the increment-1 boundary chain:
jit_chain_next tail-jumps to the next region on a hit, or signals Rust to
build+compile it on a miss.

Byte-identical schedule to run_superblock: the SAME blocks run in the SAME
order with the SAME per-block budget/mmio/sync checks — only the dispatch
MECHANISM differs. Inlining is purely an optimization; the boundary chain is
the always-correct fallback, and inlined successors are same-page (⇒ mapped,
non-thunk) with the LR_HALT sentinel filtered, so next_pc_breaks_chain's
runtime arms are statically satisfied.

THROUGHPUT (best-of-5, n=200M --gpu-inline): interp 3.76s · plain JIT 3.98s ·
increment-1 chain 3.97s · REGION 3.49s. Region is ~1.08-1.14x FASTER than the
interpreter (first clear JIT win on the boot bench) and ~1.14x faster than
increment-1's throughput-neutral block chaining — confirming the thesis that
the real dispatch cost was the per-block prologue/epilogue increment 1 kept.
Region-size sweep: gains saturate by max=32 (max=64 identical).

GOLDEN n200m BYTE-IDENTICAL across 6 configs: interp / JIT / JIT+CHAIN(region)
/ JIT+CHAIN+REGCACHE / JIT+CHAIN+REGION_MAX=1 / JIT+CHAIN+budget=1 ==
interp+budget=1. 21 xenia-jit tests (added region_multiblock_matches_interp_
superblock + region_sync_block_ends_superblock).

Bug found+fixed during bring-up: a region chained PAST a sync-sensitive block
via the boundary chain instead of ending the superblock (compile_block gives
sync blocks the plain epilogue; the region must too) — this diverged the whole
200M schedule (draws 3201 vs 3165). Fix: sync block tail = unconditional
jmp l_region_ret; BFS never expands past a sync block. The sync regression
test asserts the region stops after A+B (cycle==4) instead of looping to budget.

New: xenia_cpu::block_cache::decode_block (pub standalone decoder for the
region builder) + DecodedBlock: Clone. Diagnostic knobs XENIA_JIT_REGION_MAX
(cap, default 32; =1 reproduces increment-1 chaining) and XENIA_JIT_NOREGION
(route chain compiles through compile_block — the A/B knob that isolated the
sync bug). Default XENIA_JIT/XENIA_JIT_CHAIN both still OFF; shipping path
unchanged.
2026-07-04 23:18:15 +02:00
MechaCat02
d132fb6d8d [iterate-4C] JIT Phase B incr.1: native tail-chaining (XENIA_JIT_CHAIN, default off)
Compiled blocks tail-jump straight to their successor's compiled code
(canary-style indirection dispatch) instead of returning to the Rust
superblock loop between blocks, checking the lockstep yield guards
INLINE. Gated behind XENIA_JIT_CHAIN (default off) — the shipping JIT
path (Phase A) is byte-for-byte unchanged.

Mechanism:
- JitEnv gains chain_{enabled,stop,deadline,mmio_ptr,mmio_before,cache}.
- Chaining epilogue (non-sync blocks only, when chain_active): flush cache
  to ctx, then inline guards — budget (ctx.cycle_count >= start+budget,
  == interp total_executed>=budget), mmio (per-block prologue snapshot vs
  live counter), chain_enabled gate — then jit_chain_next(env, next_pc)
  (pure JIT-cache freshness lookup, returns host entry or null). On a hit:
  restore env->rdi, unwind THIS frame (pop to entry rsp), tail-jmp to the
  successor (no stack growth; the return addr rides through to the final
  block's ret). On null: set chain_stop=Miss, return to Rust.
- run_jit_chain drives it; run_superblock_jit_chained (used when chaining
  compiled in + budget>1 + no probe/mem-watch armed) re-enters on a Miss to
  build+compile the chainable next block. sync blocks keep the plain
  epilogue (end the superblock like interp). GuestMemory::
  mmio_access_count_ptr exposes the counter for the inline check.

BYTE-IDENTICAL golden n200m across: interp, JIT, JIT+REGCACHE, JIT+CHAIN,
JIT+CHAIN+REGCACHE, and JIT+CHAIN+budget=1 == interp+budget=1. 19 jit
tests green. (Fixed: prologue mmio snapshot must be chain_enabled-guarded
— a chaining-compiled block is also run via run_jit_block with a null
mmio_ptr on the Phase A / budget==1 / probes-armed paths.)

THROUGHPUT: NEUTRAL on the boot bench (JIT vs JIT+CHAIN statistically
equal, within noise). As the canary study predicted: block-granularity
chaining keeps the per-block prologue/epilogue (unwind+rewind per
boundary) and jit_chain_next ~= the run_fresh it replaces, so the
reclaimed Rust-loop overhead is Amdahl-swamped. The determinism-critical
machinery (inline yield guards, tail-jmp discipline, dispatch ABI) is now
PROVEN byte-identical — the foundation for region/function-granularity
compilation (increment 2), which is where the real dispatch win lives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:38:53 +02:00
MechaCat02
fec9e8de28 [iterate-4C] JIT Phase A: skip redundant block_cache lookup on chained hits
The JIT was ~1.077x SLOWER than the interpreter on the boot bench because
it paid a DOUBLE lookup per block: block_cache.lookup_or_build (to get the
DecodedBlock — the ~9.7% "block decode/cache" bucket) THEN JitCache's own
lookup. On a JIT-cache HIT the DecodedBlock is not needed: the freshness
key is (start_pc, mem.page_version(pc)), reconstructible from mem alone
(build_block stops at the 4 KiB page boundary, so both caches key on the
same single-page version).

Phase A adds a JIT-specialized superblock runner that runs chained
(2nd..Nth) blocks straight from the JIT cache, skipping block_cache on
hits:
- CompiledBlock now carries sync_sensitive (copied from DecodedBlock) —
  the chain STOP guard needs it and a JIT hit has no DecodedBlock.
- JitCache::run_fresh(pc, ctx, mem): lookup-only fast path; computes pv
  itself via mem.page_version (SMC coherence); Some((result, sync)) on a
  fresh hit, None on a miss (never compiles — compilation stays on the
  DecodedBlock path). New unit test run_fresh_hit_miss.
- run_superblock_jit (parallel to run_superblock, used when jit_cache is
  Some): first block + JIT misses use block_cache.lookup_or_build +
  run_or_compile (rebuild inline on miss so chain length — and the
  schedule — is unchanged); chained hits use run_fresh. Non-Continue
  break lazily rebuilds a block_ptr for worker_epilogue's SYSCALL/Trap
  diagnostics. Same raw-ctx-ptr discipline; shared next_pc_breaks_chain
  helper keeps both loops' chaining decisions in lockstep. Interp
  run_superblock untouched except the extracted helper call.

Results (n=200M --gpu-inline): block_cache calls 11.4M -> 991k (-91% on
the JIT run — run_fresh handles 91% of block acquisitions). Throughput:
JIT 1.077x slower -> ~1.03x FASTER than interp (best-of-8 interleaved:
JIT 3.73s vs interp 3.84s) — first time the JIT beats the interpreter.
Golden n200m BYTE-IDENTICAL: interp==golden (path untouched), JIT==golden,
JIT+REGCACHE==golden, and JIT budget=1 == interp budget=1. 19 jit tests
green.

Phase B (native inline chaining, targets the 23.6% loop body) deferred —
see plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:00:38 +02:00
MechaCat02
cce35658a5 [iterate-4C] JIT: native update-form loads/stores (lwzu/stwu/ldu/stdu/...)
Port the D/DS-form update loads (lwzu, lhzu, lhau, lbzu, ldu) and stores
(stwu, sthu, stbu, stdu) — ~10% of the boot fallback executions (stwu 6.1%
+ lwzu 2.4% + ldu/stdu 1.4% + ...). EA = gpr[rA] + EXTS(disp) with rA read
directly (update forms are invalid for rA==0, and interp reads gpr[rA]
with no 0-substitution); rD/mem then gpr[rA]=ea truncated to u32 and
zero-extended (matches interp `... as u32; gpr[ra]=ea as u64`). Stores
capture rS into rdx BEFORE the rA writeback so `stXu rS,d(rA)` with rS==rA
stores the OLD rA value (interp order). New emit helpers
emit_update_ea_writeback / emit_load_update / emit_load64_update /
emit_store_update; all cache-aware (gld64/gst64 accessors).

New differential test update_loadstore_matches (regcache forced on),
incl. the store rS==rA case. 18 tests green.

Fallback executions 25.55M -> 22.97M (-10.1%, n=200M). Golden n200m
BYTE-IDENTICAL under interp, XENIA_JIT=1, and +XENIA_JIT_REGCACHE=1.
Whole-run ratio 1.079x -> 1.077x: as prior evidence predicted, opcode
coverage does not flip the JIT on this FP-video-decode-bound boot bench
(62% of remaining fallbacks are fmadds/fmuls/fadds/fsubs, expensive in
both paths). Correctness/coverage win; real speed lever is dispatch
elimination (block-linking), not more opcodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:34:43 +02:00
MechaCat02
7ec6941fe8 [iterate-4C] perf: cache running-thread ctx ptr across superblock chain
run_superblock re-resolved the running thread's PpcContext on every
chained block via two bounds-checked slot lookups (ctx_mut_ref for the
step + ctx(hw_id) for next_pc) — ~20M double-lookups per 100M-instr
window. The running thread is FIXED for the whole chain (an import
thunk or any sync-sensitive/MMIO op breaks the chain before any kernel
mutation could restructure the runqueue; step_block is pure guest
interpretation; the probe closure is read-only), so its context heap
slot is stable. Resolve the raw *mut PpcContext once before the loop
and reuse it — same raw-pointer discipline as block_ptr.

~3% faster clean wall (1.91s -> 1.85s, n=100M, best of 5). Golden
sylpheed_n200m BYTE-IDENTICAL under interp, XENIA_JIT=1, and
XENIA_JIT=1+XENIA_JIT_REGCACHE=1.

Found via exclusive-attribution profiling: the run is overhead-bound
with the non-step cost spread thin across the per-block chaining loop
(no single dominant lever); step_block itself is ~46% and only the JIT
attacks it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:18:18 +02:00
MechaCat02
1ffa3fb56d [iterate-4C] JIT Phase 4c: per-block register caching (XENIA_JIT_REGCACHE)
Pin the 3 hottest guest GPRs (static ra/rb/rd frequency) into callee-saved
host regs r12/r13/r14 across a block. RegCache + gld64/gld32/gst64
accessors (dynasm Rq/Rd dynamic regs); ALL 75 GPR-access sites in the
emitters routed through the accessors. Prologue loads cached regs / epilogue
flushes (both l_cont+l_exit merge); fallback path flushes-before /
reloads-after (interpreter touches ctx.gpr; mem/FP helpers don't, and cached
regs are callee-saved, so no flush around those). RA=0 literal-0 rule
unaffected (emit_ea/load_ra_or_zero never read gpr[0]). Gated by
XENIA_JIT_REGCACHE (default off; forced on under cfg(test)); conditional
prologue keeps the cache-off codegen unchanged.

Golden n200m BYTE-IDENTICAL in all 3 configs (interp / JIT / JIT+RC); 17
differential tests pass with the cache FORCED ON. Measured (min of 6):
JIT+RC 4.16s vs JIT 4.14s vs interp 3.80s -> NEUTRAL on this benchmark:
per-block setup (5 push/pop + 3 load/flush) + fallback flush/reload (+6
mem ops each) cancels the intra-block savings (blocks ~13 instrs, fallbacks
frequent). Correct + default-off; may help more CPU-bound workloads
(gameplay, larger hot loops). Definitively: no JIT technique (coverage,
deferral, regcache) beats interp on this ~60%-overhead boot/render bench.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:50:39 +02:00
MechaCat02
506b9554a5 [iterate-4C] JIT Phase 4b: indexed loads/stores + shifts + mflr/mtlr
Native lwzx/lhzx/lhax/lbzx/stwx/sthx/stbx (via refactored emit_load_tail/
emit_store_tail + emit_ea_x), slw/srw/sld/srd (variable shifts, non-rc,
cmovae zeroes for count>=width), and mfspr/mtspr for the pure LR(8)/CTR(9)
registers only (other SPRs have side effects -> fallback). Differential
tests indexed_loadstore_matches / shifts_match (sh spanning width
boundaries) / spr_lr_ctr_matches. Golden n200m BYTE-IDENTICAL with
XENIA_JIT (17 tests green).

Measured: fallback execs 13.5M->10.8M (64% below the original 29.7M).
BUT throughput ratio only 1.10->1.09 -> DEFINITIVE: on this boot/render
benchmark opcode coverage alone can't beat interp. Cutting fallbacks 64%
moved the ratio 7 points because the run is ~60% non-CPU-step overhead
(Amdahl) AND the remaining fallback is FP-arith (fmadds/fmuls/fadds ~57%),
expensive in BOTH paths (small wrapper delta) + FPSCR determinism risk.
Below-1.0 needs register caching (attacks native-op cost directly) or a
more CPU-bound workload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:27:18 +02:00
MechaCat02
4194ed77a3 [iterate-4C] JIT Phase 4a: native FP + 64-bit loads/stores
Native lfs/lfsx/lfd/lfdx/stfs/stfsx/stfd/stfdx and ld/ldx. FP goes through
dedicated helpers (jit_read_f32_as_f64/read_f64/store_f32/store_f64) that
call the EXACT interpreter mem methods + as-casts, so the f32<->f64
conversion is bit-identical by construction (no hand-written cvtss2sd /
NaN-payload risk); FP stores replicate the reservation-invalidation.
emit_ea_x (indexed EA), emit_fp_load/store, emit_int_load64 helpers; fpr
offset added. Differential test fp_loadstore_matches (2000 seeds, FPR
edge patterns 0/-0/inf/NaN/denormal/rounding, asserts fpr-by-bits + mem).
Golden n200m BYTE-IDENTICAL with XENIA_JIT (14 tests green).

Measured (XENIA_JIT_STATS): total fallback execs 29.7M->13.5M (-55%);
throughput ratio vs interp 1.16->1.10. Remaining fallback now FP-arith-
dominated: fmadds 20% + fmuls 16% + fadds 8% (~44%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:20:55 +02:00
MechaCat02
afc3692223 [iterate-4C] JIT: gated fallback-opcode histogram (XENIA_JIT_STATS)
jit_interpret_one tallies fallback executions per opcode (behind
XENIA_JIT_STATS, off by default); dump_fallback_stats() prints the top 30
after a check run. Measured on Sylpheed n100M: the fallback tax is FP-
dominated — lfs/stfs/lfsx/stfsx ~50%, fmadds/fmuls/fadds/fmr ~21%,
ld 4.5%, shifts ~4%, mtspr/mfspr only ~3%. Refutes the earlier
"mflr/mtlr dominates" guess; FP is the crossover lever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:14:46 +02:00
MechaCat02
e8d0dc4a2d [iterate-4C] JIT Phase 3b: defer pc/counter accounting per block
Strip the 3 per-instruction memory RMWs (pc+=4, cycle++, timebase++) from
straight-line native ops. EmitState.pending accumulates retired native
instrs at compile time; counters are materialized in bulk (add [cycle],N)
only at observability points, and pc is written absolutely (from the known
addr) only where read. Invariant: at every block-exit edge ctx.pc/
cycle_count/timebase are exactly the interpreter's values (nothing observes
them mid-block; fallbacks flush+set-pc first; native branches set pc
absolutely from addr and flush). Branches rewritten to use compile-time
addr (no more [pc] reads). New multi_instr_block_matches test diffs a
JIT block vs the real step_block (native+fallback+branch mix). Golden
n200m BYTE-IDENTICAL with and without XENIA_JIT (13 tests green).
Throughput 4.55->4.44s; ratio vs interp still ~1.16 -> gap is fallback
tax (mflr/mtlr, indexed/update ld-st, shifts still fallback) + the run
being only ~40% CPU-step, NOT the native-path RMWs. Deferral is also the
flush-discipline substrate for register caching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:08:15 +02:00
MechaCat02
781a82cc0a [iterate-4C] JIT Phase 3a: native rotate/mask family
Native rlwinm/rlwimi/rlwnm (32-bit rol+and, mask folded to a compile-time
constant) and rldicl/rldicr (64-bit rol + 64-bit mask), including the Rc
recording forms via emit_cr0_from_reg (test + signed setcc into cr[0],
reusing the compare CR path). rlw_mask/rld_mask replicated as
compile-time helpers. Differential test rotates_match: 2000 seeds x
{rc,non-rc} x {32,64-bit} x xer_so, asserting full GPR+CR+counters.
Golden n200m BYTE-IDENTICAL with and without XENIA_JIT (12 tests green).
Throughput 4.7->4.55s (interp ~3.9s): opcode coverage now at diminishing
returns; the remaining gap is memory-traffic-per-instruction (no register
caching) + fallback tax, not missing opcodes -> Phase 4 (register cache)
is the crossover lever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:54:51 +02:00
MechaCat02
701e4c399a [iterate-4C] JIT Phase 2b-ii: native branches (bx/bcx/bclrx)
try_emit_native now returns a 3-state Emit enum (Fallback/Native/Branch);
compile_block appends the pc==expected_next discontinuity check after a
native branch, mirroring step_block. bx (aa/rel, lk), bcx (CTR
decrement + CTR/CR condition, cmov-selected target), bclrx (return via
lr & !3) emitted natively; bcctrx stays on fallback (dispatch_rec side
effect). count_only postlude (counters, no pc+=4) for branches.
Differential tests: unconditional/conditional/return branches over
exhaustive BO bits, CTR==1->0 boundary, lr alignment mask, both lk
states, asserting pc/lr/ctr/cr/counters. Golden n200m BYTE-IDENTICAL
with and without XENIA_JIT (11 tests green). Throughput 4.85->4.7s
(interp ~3.9s); crossover still pending rotate/shift (Phase 3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:48:47 +02:00
MechaCat02
5e521f7f53 [iterate-4C] JIT Phase 2b-i: native compares + CR field emission
Native cmpi/cmpli/cmp/cmpl via x64 cmp + setcc into cr[bf].{lt,gt,eq},
so from xer_so. CrField flag offsets resolved with offset_of! (the type
is not repr(C), so byte positions are not assumed). Both 32/64-bit (L)
widths handled. Differential test compares_match (2000 seeds x both
widths x both xer_so values) asserts full CR nibble + counters; check()
now also asserts CR for all opcodes. Golden n200m BYTE-IDENTICAL with
and without XENIA_JIT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:39:16 +02:00
MechaCat02
0f1130e2e6 [iterate-4C] JIT Phase 2a: native loads/stores + backed-memory diff tests
emit.rs: native x64 for the hot D/DS-form memory ops — lbz, lhz, lha, lwz,
stb, sth, stw, std. EA = (rA==0?0:gpr[rA]) + EXTS(disp) computed inline;
the access itself calls a focused extern "C" helper (jit_read_u*/
jit_store_u*) that reconstructs &dyn MemoryAccess (and &PpcContext for
stores) from JitEnv and calls the SAME big-endian trait methods the
interpreter uses -> MMIO routing / mem-watch / page-version bumps identical.
Stores replicate the interpreter arms' reservation-invalidation prologue
exactly (no-op without a reservation table). Load extension (zx8/zx16/
sx16/zx32) via movzx/movsx, matching each arm.

tests.rs: VecMem (backed big-endian mock) + loads_match/stores_match diff
tests (2000 seeds each) asserting GPR + full memory image + counters vs the
interpreter, incl. RA=0 and byte-swap.

Gate: golden n200m BYTE-IDENTICAL with XENIA_JIT=1; cargo test -p xenia-jit
green (9 tests). Throughput -n 200M --gpu-inline: 3.6s interp, 4.85s JIT
(down from 6.2s at Phase 1). Remaining fallback = branches (every block
terminator) + compares -> the crossover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:55:16 +02:00
MechaCat02
231c35a28f [iterate-4C] JIT Phase 1: native integer ALU emitters + differential tests
crates/xenia-jit/src/emit.rs: native x64 for the hot non-recording integer
ALU ops — addi, addis, ori, oris, xori, xoris, or(x), and(x), xor(x),
add(x), subf(x), neg(x). Each mirrors its interpreter arm exactly, then
does pc+=4 and the cycle/timebase bumps. Per-instance guards fall back to
the interpreter for forms the emitter can't yet reproduce faithfully:
recording (`.`/Rc) forms, OE overflow forms, and the db16cyc spin hint
(or r31,r31,r31 -> Yield). RA=0 literal-zero rule handled at emit time.

Differential harness (tests.rs): each opcode run through interpret_one vs a
JIT-compiled 1-instr block over 2000 random register seeds (+ edge values
0/0xFFFFFFFF/0x80000000/INT64 boundaries); asserts full GPR/PC/XER/cycle/
timebase/StepResult equality. Plus a guard test that recording/OE/db16cyc
forms are NOT natively emitted.

Gate: golden n200m BYTE-IDENTICAL with XENIA_JIT=1; `cargo test -p
xenia-jit` green. Throughput -n 200M --gpu-inline: 4.3s interp, 6.2s JIT
(down from 7.1s all-fallback skeleton). Still fallback-bound on
loads/stores/branches -> Phase 2 is the crossover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:47:06 +02:00
MechaCat02
1d56218d83 [iterate-4C] JIT Phase 0: dynasm skeleton (all-fallback, golden byte-identical)
New crate `xenia-jit` — the PPC->x64 block-JIT runtime substrate, behind
default-OFF XENIA_JIT. Phase 0 ports ZERO opcodes to native: every guest
instruction is emitted as `call jit_interpret_one` (the interpreter), so a
JIT-compiled block is byte-identical to step_block by construction. This
proves the ABI/counters/exit-semantics/mem-helpers/code-cache before any
opcode is hand-written.

- JitEnv{ctx, mem (fat raw ptr), last_result}; compiled block =
  extern "C" fn(*mut JitEnv)->u32 (StepResult discriminant, 0=Continue).
  Emitted code pins ctx in r15 + env in rbx (callee-saved across calls),
  offsets via offset_of!.
- Determinism postlude: cycle_count/timebase +=1 after every retired
  instruction; block stops at the same instruction as the interpreter
  (non-Continue result, or taken-branch pc discontinuity).
- Per-slot JitCache mirrors BlockCache's (start_pc, page_version) gate;
  each CompiledBlock OWNS a copy of its decoded instrs so baked instr
  pointers can't dangle after a block-cache eviction.
- xenia-cpu: `pub fn interpret_one` (execute without the cycle bump — the
  JIT owns counting).
- Seam: run_superblock main.rs:3154 dispatches to the JIT when enabled;
  WorkerCtx gains an Option<JitCache> (Some only when XENIA_JIT set and
  RET-CAPTURE debug env unset). observe_per_instruction gate unchanged, so
  tooling runs never reach the JIT.

Gate: golden n200m BYTE-IDENTICAL both with and without XENIA_JIT=1.
Throughput -n 200M --gpu-inline: 4.5s interp vs 7.1s all-fallback skeleton
(the per-instruction call overhead Phase 1 removes for hot opcodes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:38:28 +02:00
13 changed files with 4569 additions and 42 deletions

61
Cargo.lock generated
View File

@@ -1056,6 +1056,33 @@ dependencies = [
"strum",
]
[[package]]
name = "dynasm"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7d4c414c94bc830797115b8e5f434d58e7e80cb42ba88508c14bc6ea270625"
dependencies = [
"bitflags 2.11.0",
"byteorder",
"lazy_static",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "dynasmrt"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "602f7458a3859195fb840e6e0cce5f4330dd9dfbfece0edaf31fe427af346f55"
dependencies = [
"byteorder",
"dynasm",
"fnv",
"memmap2",
]
[[package]]
name = "endian-type"
version = "0.1.2"
@@ -2888,6 +2915,28 @@ dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -5047,6 +5096,7 @@ dependencies = [
"xenia-debugger",
"xenia-gpu",
"xenia-hid",
"xenia-jit",
"xenia-kernel",
"xenia-memory",
"xenia-types",
@@ -5117,6 +5167,17 @@ dependencies = [
"xenia-types",
]
[[package]]
name = "xenia-jit"
version = "0.1.0"
dependencies = [
"dynasm",
"dynasmrt",
"tracing",
"xenia-cpu",
"xenia-memory",
]
[[package]]
name = "xenia-kernel"
version = "0.1.0"

View File

@@ -4,6 +4,7 @@ members = [
"crates/xenia-types",
"crates/xenia-memory",
"crates/xenia-cpu",
"crates/xenia-jit",
"crates/xenia-xex",
"crates/xenia-vfs",
"crates/xenia-kernel",
@@ -26,6 +27,7 @@ license = "BSD-3-Clause"
xenia-types = { path = "crates/xenia-types" }
xenia-memory = { path = "crates/xenia-memory" }
xenia-cpu = { path = "crates/xenia-cpu" }
xenia-jit = { path = "crates/xenia-jit" }
xenia-xex = { path = "crates/xenia-xex" }
xenia-vfs = { path = "crates/xenia-vfs" }
xenia-kernel = { path = "crates/xenia-kernel" }
@@ -37,6 +39,9 @@ xenia-analysis = { path = "crates/xenia-analysis" }
xenia-ui = { path = "crates/xenia-ui" }
# External dependencies
# JIT (PPC->x64 block recompiler; runtime-gated by XENIA_JIT)
dynasm = "3"
dynasmrt = "3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "registry"] }
tracing-appender = "0.2"

View File

@@ -12,6 +12,7 @@ path = "src/main.rs"
xenia-types = { workspace = true }
xenia-memory = { workspace = true }
xenia-cpu = { workspace = true }
xenia-jit = { workspace = true }
xenia-xex = { workspace = true }
xenia-vfs = { workspace = true }
xenia-kernel = { workspace = true }

View File

@@ -966,19 +966,23 @@ fn cmd_exec_inner(
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes"
});
// A.5 — opt-in threaded GPU under `--ui`. Off by default: `--ui` still
// forces the inline backend (the safe, milestone-verified path). When
// `XENIA_UI_GPU_THREAD=1` is set alongside `--ui`, the GPU command
// processing + per-swap UI publish move to the worker thread, freeing the
// emulation thread from the ~12 ms/frame inline PM4 drain. See
// `run_with_ui` (hook install) and `GpuSystem::run_ui_publish`.
let env_ui_thread = std::env::var("XENIA_UI_GPU_THREAD")
// A.5 — threaded GPU under `--ui`, now the DEFAULT. The GPU command
// processing + per-swap UI publish run on the worker thread, freeing the
// emulation thread from the ~12 ms/frame inline PM4 drain — measured ~5×
// faster boot under `--ui` (~12 → ~63 MIPS), visuals confirmed identical to
// the inline path. See `run_with_ui` (hook install) and
// `GpuSystem::run_ui_publish`. Opt back to the inline backend with
// `--gpu-inline` or `XENIA_UI_GPU_INLINE=1` (the deterministic golden path
// already uses `--gpu-inline`, so goldens are unaffected). `XENIA_UI_GPU_THREAD`
// is retained as a no-op alias for back-compat.
let env_ui_inline = std::env::var("XENIA_UI_GPU_INLINE")
.ok()
.is_some_and(|v| {
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes"
});
let ui_threaded = ui && env_ui_thread;
let ui_inline_optout = gpu_inline || env_inline || env_ui_inline;
let ui_threaded = ui && !ui_inline_optout;
let force_inline = gpu_inline || env_inline || (ui && !ui_threaded);
let force_thread = gpu_thread || env_thread || ui_threaded;
let use_threaded = if force_inline {
@@ -1998,6 +2002,10 @@ fn cmd_exec_inner(
info!("run digest matches golden");
}
}
// Diagnostic (XENIA_JIT_STATS): dump the JIT fallback-opcode
// histogram so we can see which un-ported opcodes dominate. No-op
// unless the env var is set.
xenia_jit::dump_fallback_stats();
Ok(())
})()
};
@@ -2499,15 +2507,29 @@ struct WorkerCtx {
block_cache: xenia_cpu::block_cache::BlockCache,
decode_cache: xenia_cpu::decoder::DecodeCache,
force_per_instr: bool,
/// PPC→x64 JIT code cache for this HW slot. `Some` only when `XENIA_JIT`
/// is set (and the RET-CAPTURE debug env is not — the JIT's fallback path
/// bypasses `step_block`'s head-of-block capture print). Substitutes for
/// the `step_block` call in `run_superblock`; produces byte-identical
/// state so goldens are unaffected.
jit_cache: Option<xenia_jit::JitCache>,
}
impl WorkerCtx {
fn new(hw_id: u8, force_per_instr: bool) -> Self {
let jit_cache = if xenia_jit::env_enabled()
&& std::env::var("XENIA_RET_CAPTURE_PC").is_err()
{
Some(xenia_jit::JitCache::new())
} else {
None
};
Self {
hw_id,
block_cache: xenia_cpu::block_cache::BlockCache::new(),
decode_cache: xenia_cpu::decoder::DecodeCache::new(),
force_per_instr,
jit_cache,
}
}
}
@@ -3083,7 +3105,6 @@ fn run_superblock(
first_pc_before: u32,
) -> SlotOutcome {
use xenia_cpu::interpreter::{step_block, StepResult};
const LR_HALT: u32 = xenia_cpu::context::LR_HALT_SENTINEL as u32;
let budget = superblock_budget();
@@ -3141,6 +3162,20 @@ fn run_superblock(
let mut pc_before = first_pc_before;
let mut total_executed: u64 = 0;
// PERF: the running thread (`thread_ref`) is FIXED for the entire
// superblock chain — no thread spawn/exit/migration happens between
// iterations. `step_block` is pure guest interpretation; an import thunk
// or any sync-sensitive/MMIO op BREAKS the chain (below) before any kernel
// mutation could restructure the runqueue; `fire_block_entry_probes` is
// read-only; `block_cache.lookup_or_build` touches only `wc`. So this
// thread's `PpcContext` heap slot is stable for the whole loop and we can
// resolve it ONCE here — instead of a bounds-checked double slot lookup
// (`ctx_mut_ref` + `ctx`) on EVERY chained block (~10M/round). Same
// raw-pointer discipline as `block_ptr`; and `ctx(hw_id) ==
// ctx_mut_ref(thread_ref)` throughout because `running_idx` is unchanged.
// Byte-identical.
let ctx_ptr: *mut xenia_cpu::PpcContext = kernel.scheduler.ctx_mut_ref(thread_ref);
let (result, last_block_ptr, last_pc_before) = loop {
let mmio_before = mem.mmio_access_count();
let block = unsafe { &*block_ptr };
@@ -3149,9 +3184,17 @@ fn run_superblock(
// `ctx_mut_ref` slot lookups — for cycle-before, the step, and
// cycle-after — each a double bounds-checked index). Byte-identical.
let (result, executed) = {
let ctx = kernel.scheduler.ctx_mut_ref(thread_ref);
let ctx = unsafe { &mut *ctx_ptr };
let cycle_before = ctx.cycle_count;
let result = step_block(ctx, mem, block);
// JIT seam (XENIA_JIT): run the JIT-compiled block if enabled, else
// the interpreter. The JIT leaves ctx.cycle_count/pc and
// mmio_access_count in exactly the interpreter's state, so all the
// surrounding accounting (executed, sync/MMIO/budget chain checks)
// is untouched and goldens stay byte-identical.
let result = match wc.jit_cache.as_mut() {
Some(jit) => jit.run_or_compile(block, ctx, mem),
None => step_block(ctx, mem, block),
};
let executed = ctx.cycle_count.saturating_sub(cycle_before);
(result, executed)
};
@@ -3179,12 +3222,10 @@ fn run_superblock(
// Decide whether the NEXT PC is an ordinary guest block we can
// chain into. Anything else (thunk / halt sentinel / unmapped)
// needs the full prologue dispatch next round.
let next_pc = kernel.scheduler.ctx(wc.hw_id).pc;
if next_pc == LR_HALT
|| (kernel.pc_in_thunk_band(next_pc) && thunk_map.contains_key(&next_pc))
|| !mem.is_mapped(next_pc)
{
// needs the full prologue dispatch next round. `ctx_ptr` aliases the
// running thread's context (stable for the chain — see above).
let next_pc = unsafe { (*ctx_ptr).pc };
if next_pc_breaks_chain(kernel, mem, thunk_map, next_pc) {
break (result, block_ptr, pc_before);
}
@@ -3227,6 +3268,308 @@ fn run_superblock(
)
}
/// Shared chain-break predicate for the NEXT pc: anything that is not an
/// ordinary, mapped, non-thunk guest block ends the superblock (the next slot
/// visit re-dispatches it through the full prologue). Used by BOTH the
/// interpreter `run_superblock` and the JIT `run_superblock_jit` so their
/// chaining decisions can never drift apart.
#[inline]
fn next_pc_breaks_chain(
kernel: &xenia_kernel::KernelState,
mem: &xenia_memory::GuestMemory,
thunk_map: &HashMap<u32, (ModuleId, u16, String)>,
next_pc: u32,
) -> bool {
const LR_HALT: u32 = xenia_cpu::context::LR_HALT_SENTINEL as u32;
next_pc == LR_HALT
|| (kernel.pc_in_thunk_band(next_pc) && thunk_map.contains_key(&next_pc))
|| !mem.is_mapped(next_pc)
}
/// JIT-specialized superblock runner (used when `wc.jit_cache.is_some()`).
///
/// Identical scheduling/accounting to `run_superblock`, with ONE optimization:
/// chained (2nd..Nth) blocks run straight from the per-slot JIT cache via
/// `JitCache::run_fresh`, SKIPPING the interpreter `BlockCache.lookup_or_build`
/// (the ~9.7% "block decode/cache" bucket) whenever the compiled block is fresh.
/// The interpreter path (`run_superblock`) does a DOUBLE lookup per block
/// (BlockCache to get the `DecodedBlock`, then the JIT cache); on a JIT hit the
/// `DecodedBlock` is not needed at all (the freshness key is
/// `(start_pc, mem.page_version(pc))`, reconstructible from `mem`).
///
/// Byte-identical to `run_superblock` under `XENIA_JIT`: `run_fresh` runs the
/// exact same compiled block `run_or_compile` would (same slot, same
/// `(pc, page_version)` gate); a JIT miss rebuilds via `BlockCache` inline so
/// the chain length — and therefore the schedule — is unchanged. `sync_sensitive`
/// travels on the `CompiledBlock`; SYSCALL/Trap epilogue diagnostics get a valid
/// `block_ptr` via a lazy rebuild on the (rare) non-Continue break.
#[allow(clippy::too_many_arguments)]
fn run_superblock_jit(
wc: &mut WorkerCtx,
kernel: &mut xenia_kernel::KernelState,
mem: &xenia_memory::GuestMemory,
debugger: &mut xenia_debugger::Debugger,
thunk_map: &HashMap<u32, (ModuleId, u16, String)>,
stats: &mut ExecStats,
tid: Option<u32>,
thread_ref: xenia_cpu::ThreadRef,
first_block_ptr: *const xenia_cpu::block_cache::DecodedBlock,
first_pc_before: u32,
) -> SlotOutcome {
use xenia_cpu::block_cache::DecodedBlock;
use xenia_cpu::interpreter::StepResult;
let budget = superblock_budget();
let chain_allowed = budget > 1;
// Same per-block-entry diagnostic observation as `run_superblock` (see the
// detailed rationale there): fired at every chained block's entry PC so
// arming a probe/mem-watch never changes chaining (and thus the schedule).
let probe_hw_id = wc.hw_id;
let fire_block_entry_probes =
|kernel: &mut xenia_kernel::KernelState, mem: &xenia_memory::GuestMemory| {
let hw_id = probe_hw_id;
if kernel.any_probe_active() {
kernel.fire_ctor_probe_if_match(hw_id, mem);
kernel.fire_branch_probe_if_match(hw_id);
kernel.fire_audit_pc_probe_if_match(hw_id, mem);
kernel.fire_lr_trace_if_match(hw_id);
}
if mem.has_mem_watch() {
let ctx = kernel.scheduler.ctx(hw_id);
let tid_w = kernel.scheduler.tid(hw_id).unwrap_or(0);
xenia_memory::set_writer_ctx(tid_w, ctx.pc, ctx.lr as u32);
}
};
// Running thread is fixed for the chain — resolve its context ptr ONCE (same
// raw-pointer discipline + justification as `run_superblock`).
let ctx_ptr: *mut xenia_cpu::PpcContext = kernel.scheduler.ctx_mut_ref(thread_ref);
// Native block-chaining (XENIA_JIT_CHAIN): when compiled in AND no
// probe/mem-watch is armed (native chaining skips the per-block-entry
// observation — which is a no-op when nothing is armed), run the superblock
// as a native tail-chain (blocks jmp straight to their successors, checking
// the budget/mmio/sync yield guards inline) instead of the per-block Rust
// loop. Same schedule; far less per-block dispatch. Falls back to the loop
// below when disabled / a probe is armed / budget==1.
if xenia_jit::chain_active()
&& chain_allowed
&& !kernel.any_probe_active()
&& !mem.has_mem_watch()
{
return run_superblock_jit_chained(
wc, kernel, mem, debugger, thunk_map, stats, tid, thread_ref, ctx_ptr,
first_block_ptr, first_pc_before, budget,
);
}
let mut pc_before = first_pc_before;
let mut total_executed: u64 = 0;
// `Some(bp)` = a `DecodedBlock` is already in hand for the block at
// `pc_before` (the first block) → run via `run_or_compile`. `None` = chained
// block → run from the JIT cache directly (`run_fresh`); on a miss, rebuild
// that one block via `BlockCache`.
let mut pending_block: Option<*const DecodedBlock> = Some(first_block_ptr);
// Last VALID DecodedBlock ptr for `worker_epilogue`'s SYSCALL/Trap
// diagnostics; kept current whenever a block runs from a `DecodedBlock`.
let mut last_block_ptr: *const DecodedBlock = first_block_ptr;
let (result, epilogue_block_ptr, last_pc_before) = loop {
let mmio_before = mem.mmio_access_count();
let _prof_t0 = xenia_gpu::prof::is_on().then(std::time::Instant::now);
// Run the block at `pc_before`. `ran_fresh` = it ran via a JIT-cache hit
// (no live `DecodedBlock` ptr → lazy rebuild if it breaks non-Continue).
let (result, executed, sync_sensitive, ran_fresh) = {
let ctx = unsafe { &mut *ctx_ptr };
let cycle_before = ctx.cycle_count;
let (r, sync, ran_fresh) = match pending_block {
Some(bp) => {
let block = unsafe { &*bp };
let jit = wc.jit_cache.as_mut().expect("jit active in run_superblock_jit");
(jit.run_or_compile(block, ctx, mem), block.sync_sensitive, false)
}
None => {
match wc
.jit_cache
.as_mut()
.expect("jit active in run_superblock_jit")
.run_fresh(pc_before, ctx, mem)
{
// JIT-cache hit — skipped BlockCache entirely.
Some((r, sync)) => (r, sync, true),
// JIT miss: rebuild this one block via BlockCache (times
// as BUILD), compile+run. Disjoint field borrows
// (block_cache vs jit_cache).
None => {
let _pt = xenia_gpu::prof::is_on().then(|| {
xenia_gpu::prof::ScopeTimer::new(
&xenia_gpu::prof::BUILD_NS,
&xenia_gpu::prof::BUILD_CALLS,
)
});
let block = wc.block_cache.lookup_or_build(pc_before, mem);
let bp = block as *const DecodedBlock;
let sync = block.sync_sensitive;
let r = wc
.jit_cache
.as_mut()
.expect("jit active")
.run_or_compile(block, ctx, mem);
last_block_ptr = bp;
(r, sync, false)
}
}
}
};
let executed = ctx.cycle_count.saturating_sub(cycle_before);
(r, executed, sync, ran_fresh)
};
if !ran_fresh {
// Ran from a DecodedBlock (first block or miss-rebuild) — that ptr is
// `last_block_ptr` (set above / initialized to first_block_ptr).
last_block_ptr = if let Some(bp) = pending_block { bp } else { last_block_ptr };
}
if let Some(t0) = _prof_t0 {
use xenia_gpu::prof;
prof::add(&prof::STEP_NS, t0.elapsed().as_nanos() as u64);
prof::add(&prof::STEP_INSTR, executed);
prof::add(&prof::STEP_CALLS, 1);
prof::maybe_report_by_instr();
}
total_executed = total_executed.saturating_add(executed);
// STOP conditions — identical order/semantics to `run_superblock`, with
// `sync_sensitive` sourced from the run (the `CompiledBlock` on a JIT hit).
if !chain_allowed
|| !matches!(result, StepResult::Continue)
|| sync_sensitive
|| mem.mmio_access_count() != mmio_before
|| total_executed >= budget
{
// Epilogue diagnostics (SYSCALL/Trap) read `block.instrs.last()`; if
// the breaking block ran via a JIT hit (no ptr) AND the result is
// non-Continue, lazily rebuild it. For Continue breaks the epilogue
// never touches the block, so a stale `last_block_ptr` is unused.
let epilogue_bp = if ran_fresh && !matches!(result, StepResult::Continue) {
wc.block_cache.lookup_or_build(pc_before, mem) as *const _
} else {
last_block_ptr
};
break (result, epilogue_bp, pc_before);
}
// Next-pc chain-break decision (shared helper — identical to interp).
let next_pc = unsafe { (*ctx_ptr).pc };
if next_pc_breaks_chain(kernel, mem, thunk_map, next_pc) {
break (result, last_block_ptr, pc_before);
}
// Chain into the next block: fire the per-block-entry observation at its
// entry PC, then loop to run it from the JIT cache (no BlockCache on hit).
pc_before = next_pc;
fire_block_entry_probes(kernel, mem);
pending_block = None;
};
worker_epilogue(
wc,
kernel,
debugger,
stats,
tid,
thread_ref,
epilogue_block_ptr,
last_pc_before,
result,
total_executed,
)
}
/// Native tail-chaining superblock runner (XENIA_JIT_CHAIN increment). Drives
/// `xenia_jit::run_jit_chain`: compile+enter the first block, whose chaining
/// epilogue tail-jumps through subsequent fresh compiled blocks (checking the
/// budget/mmio/sync yield guards INLINE) until it yields or hits an
/// uncompiled-but-chainable block (a "miss"), which returns here to build+compile
/// it and re-enter. Byte-identical schedule to `run_superblock_jit` (same yield
/// guards evaluated at the same per-block boundaries, same chain length); only
/// the per-block dispatch mechanism differs (native jmp vs Rust loop).
///
/// Preconditions (checked by the caller): chaining compiled in, budget>1, and no
/// probe/mem-watch armed — native chaining skips the per-block-entry observation,
/// which is a no-op precisely when nothing is armed.
#[allow(clippy::too_many_arguments)]
fn run_superblock_jit_chained(
wc: &mut WorkerCtx,
kernel: &mut xenia_kernel::KernelState,
mem: &xenia_memory::GuestMemory,
debugger: &mut xenia_debugger::Debugger,
thunk_map: &HashMap<u32, (ModuleId, u16, String)>,
stats: &mut ExecStats,
tid: Option<u32>,
thread_ref: xenia_cpu::ThreadRef,
ctx_ptr: *mut xenia_cpu::PpcContext,
first_block_ptr: *const xenia_cpu::block_cache::DecodedBlock,
first_pc_before: u32,
budget: u64,
) -> SlotOutcome {
use xenia_cpu::block_cache::DecodedBlock;
use xenia_cpu::interpreter::StepResult;
use xenia_jit::ChainStop;
// Budget is measured over the whole slot visit: yield when
// cycle_count >= start + budget (== interp's `total_executed >= budget`).
let slot_start_cycle = unsafe { (*ctx_ptr).cycle_count };
let deadline = slot_start_cycle.wrapping_add(budget);
let mmio_ptr = mem.mmio_access_count_ptr();
let cache_ptr = wc.jit_cache.as_mut().expect("jit active").as_ptr();
// Compile the first block; get its entry func.
let mut cur_func = {
let block = unsafe { &*first_block_ptr };
wc.jit_cache.as_mut().expect("jit active").ensure_compiled(block, mem)
};
// Diagnostics ptr for worker_epilogue (only SYSCALL/Trap read block.instrs;
// scheduling-affecting handling uses `result`, not this). Tracks the last
// block THIS loop built — the golden boot yields Continue so it's unused.
let mut last_block_ptr: *const DecodedBlock = first_block_ptr;
let mut last_pc_before = first_pc_before;
let result = loop {
let ctx = unsafe { &mut *ctx_ptr };
let (result, stop) =
xenia_jit::run_jit_chain(cur_func, ctx, mem, cache_ptr, deadline, mmio_ptr, true);
match stop {
// Dispatch miss on a Continue: next_pc is either a chainable but
// not-yet-JIT-compiled block, or halt/thunk/unmapped (which are never
// compiled → also a miss). Distinguish exactly like the interp loop.
ChainStop::Miss if matches!(result, StepResult::Continue) => {
let next_pc = unsafe { (*ctx_ptr).pc };
if next_pc_breaks_chain(kernel, mem, thunk_map, next_pc) {
break result; // halt/thunk/unmapped → end the superblock
}
// Build + compile the chainable next block, then re-enter from it.
last_pc_before = next_pc;
let block = wc.block_cache.lookup_or_build(next_pc, mem);
last_block_ptr = block as *const DecodedBlock;
cur_func = wc.jit_cache.as_mut().expect("jit active").ensure_compiled(block, mem);
}
// Yield (budget/mmio/sync — handled inline) or any non-Continue
// result: end the superblock. `result` drives worker_epilogue.
_ => break result,
}
};
let total_executed = unsafe { (*ctx_ptr).cycle_count }.wrapping_sub(slot_start_cycle);
worker_epilogue(
wc, kernel, debugger, stats, tid, thread_ref, last_block_ptr, last_pc_before,
result, total_executed,
)
}
#[instrument(skip_all, fields(max = ?max_instructions, ips = ?ips_limit))]
fn run_execution(
mem: &xenia_memory::GuestMemory,
@@ -3423,18 +3766,22 @@ fn run_execution(
// the per-round (timebase / coord / round_schedule)
// and per-slot (prologue) tax over hundreds of
// instructions instead of ~6. See `run_superblock`.
match run_superblock(
wc,
kernel,
mem,
debugger,
thunk_map,
&mut stats,
tid,
thread_ref,
block_ptr,
pc_before,
) {
//
// When the JIT is active, use `run_superblock_jit` — same
// scheduling, but chained blocks run straight from the JIT
// cache (skipping the redundant BlockCache lookup on hits).
let outcome = if wc.jit_cache.is_some() {
run_superblock_jit(
wc, kernel, mem, debugger, thunk_map, &mut stats, tid,
thread_ref, block_ptr, pc_before,
)
} else {
run_superblock(
wc, kernel, mem, debugger, thunk_map, &mut stats, tid,
thread_ref, block_ptr, pc_before,
)
};
match outcome {
SlotOutcome::Continue => continue,
SlotOutcome::BreakOuter => break 'outer,
}

View File

@@ -1,9 +1,9 @@
{
"instructions": 200000239,
"imports": 575447,
"instructions": 200000203,
"imports": 575647,
"unimpl": 0,
"draws": 3165,
"swaps": 895,
"draws": 3208,
"swaps": 910,
"unique_render_targets": 2,
"shader_blobs_live": 6,
"texture_cache_entries": 1

View File

@@ -63,7 +63,7 @@ const GUEST_PAGE_MASK: u32 = !(GUEST_PAGE_SIZE - 1);
/// One cached basic block. Owned by [`BlockCache`]; a `&DecodedBlock`
/// is handed to the interpreter via [`BlockCache::lookup_or_build`] and
/// stays valid until the next `lookup_or_build` on the same slot.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct DecodedBlock {
/// Guest PC at which this block starts. Used as the slot tag.
pub start_pc: u32,
@@ -185,6 +185,18 @@ impl BlockCache {
}
}
/// Decode a standalone `DecodedBlock` at `start_pc` against `mem`, computing
/// its `page_version` from memory (the same `(start_pc, page_version)` key the
/// `BlockCache` uses). Unlike [`BlockCache::lookup_or_build`] this does not
/// touch any cache — it is used by the JIT region compiler to decode the
/// straight-line/same-page successor blocks it stitches into one compiled
/// region. `build_block` stops at the 4 KiB page boundary, so the returned
/// block is fully contained in the page whose version this records.
pub fn decode_block(start_pc: u32, mem: &dyn MemoryAccess) -> DecodedBlock {
let page_version = mem.page_version(start_pc);
build_block(start_pc, mem, page_version)
}
/// Walk forward from `pc`, decoding instructions and collecting them
/// into a `DecodedBlock`. The walk stops on the first of:
/// - a [`PpcOpcode::terminates_block`] true (the terminator IS

View File

@@ -213,6 +213,22 @@ pub fn step_block(
result
}
/// Execute exactly one already-decoded instruction — the JIT's interpreter
/// fallback (`xenia-jit`). Identical to the body of [`step`]/`step_block`
/// EXCEPT it does **not** bump `cycle_count`/`timebase`: the JIT owns the
/// per-instruction counter increments so that a mix of native and
/// fallback opcodes retires exactly one tick each, in order, byte-identical
/// to the interpreter. `execute` itself advances `ctx.pc` (each arm does
/// `ctx.pc += 4` or sets a branch target), same as the interpreter path.
#[inline]
pub fn interpret_one(
ctx: &mut PpcContext,
mem: &dyn MemoryAccess,
instr: &DecodedInstr,
) -> StepResult {
execute(ctx, mem, instr)
}
/// Execute a decoded instruction, updating context and memory.
fn execute(ctx: &mut PpcContext, mem: &dyn MemoryAccess, instr: &DecodedInstr) -> StepResult {
match instr.opcode {

View File

@@ -0,0 +1,12 @@
[package]
name = "xenia-jit"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
xenia-cpu = { workspace = true }
xenia-memory = { workspace = true }
dynasm = { workspace = true }
dynasmrt = { workspace = true }
tracing = { workspace = true }

1514
crates/xenia-jit/src/emit.rs Normal file

File diff suppressed because it is too large Load Diff

1252
crates/xenia-jit/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -125,6 +125,29 @@ fn gamepad_key(state: &xenia_hid::GamepadState) -> u128 {
u128::from_be_bytes(bytes)
}
/// Whether user 0 has a default always-connected, idle (no-buttons) virtual
/// controller. **Default ON** — matches canary, whose default input drivers
/// (xinput / winkey) present a connected pad, so a title screen advances to its
/// "Press A" input-poll state instead of stalling forever on
/// DEVICE_NOT_CONNECTED (a console always exposes controller slots; a real
/// gamepad under `--ui` supplies actual button state, otherwise the pad is idle).
/// Set `XENIA_NO_PAD=1` to present NO controller (reproduces the raw headless
/// no-HID trajectory).
fn virtual_pad_enabled() -> bool {
use std::sync::OnceLock;
static V: OnceLock<bool> = OnceLock::new();
*V.get_or_init(|| {
!std::env::var("XENIA_NO_PAD")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
})
}
/// True iff user 0 has a controller: a real host gamepad, or the default pad.
fn user_connected(state: &KernelState, user: u32) -> bool {
state.ui.as_ref().is_some_and(|ui| ui.is_connected(user)) || (user == 0 && virtual_pad_enabled())
}
fn xam_input_get_capabilities(
ctx: &mut PpcContext,
mem: &GuestMemory,
@@ -133,8 +156,7 @@ fn xam_input_get_capabilities(
// r3 = user_index, r4 = flags, r5 = out X_INPUT_CAPABILITIES*
let user = ctx.gpr[3] as u32;
let out_ptr = ctx.gpr[5] as u32;
let connected = state.ui.as_ref().is_some_and(|ui| ui.is_connected(user));
if !connected {
if !user_connected(state, user) {
ctx.gpr[3] = xenia_hid::errors::DEVICE_NOT_CONNECTED as u64;
return;
}
@@ -146,15 +168,16 @@ fn xam_input_get_state(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kern
// r3 = user_index, r4 = flags, r5 = out X_INPUT_STATE*
let user = ctx.gpr[3] as u32;
let out_ptr = ctx.gpr[5] as u32;
let Some(ui) = state.ui.as_ref() else {
ctx.gpr[3] = xenia_hid::errors::DEVICE_NOT_CONNECTED as u64;
return;
};
if !ui.is_connected(user) {
if !user_connected(state, user) {
ctx.gpr[3] = xenia_hid::errors::DEVICE_NOT_CONNECTED as u64;
return;
}
let gamepad = ui.snapshot_gamepad();
// Real host gamepad if present, else the idle virtual pad (XENIA_VIRTUAL_PAD).
let gamepad = state
.ui
.as_ref()
.map(|ui| ui.snapshot_gamepad())
.unwrap_or_default();
let key = gamepad_key(&gamepad);
if key != state.last_input_bytes {
state.input_packet_number = state.input_packet_number.wrapping_add(1);

View File

@@ -155,6 +155,15 @@ impl GuestMemory {
.load(std::sync::atomic::Ordering::Relaxed)
}
/// Raw address of the MMIO access counter, as `*const u64`. The JIT's native
/// block-chaining epilogue reads it with a plain aligned load to detect a
/// mid-block MMIO touch inline (equivalent to a `Relaxed` load on x86-64 —
/// `AtomicU64` has the same layout as `u64`). Single-thread use only.
#[inline]
pub fn mmio_access_count_ptr(&self) -> *const u64 {
self.mmio_access_count.as_ptr() as *const u64
}
#[inline]
fn bump_mmio_access(&self) {
self.mmio_access_count