diff --git a/crates/xenia-app/src/main.rs b/crates/xenia-app/src/main.rs index d88e2d5..07ec4d8 100644 --- a/crates/xenia-app/src/main.rs +++ b/crates/xenia-app/src/main.rs @@ -3139,7 +3139,18 @@ fn run_superblock( let _prof_t0 = xenia_gpu::prof::is_on().then(std::time::Instant::now); let result = { let ctx = kernel.scheduler.ctx_mut_ref(thread_ref); - step_block(ctx, mem, block) + // JIT seam (M0). run_superblock's chain-loop + stop-conditions are + // unchanged; only the block body is routed: + // - XENIA_JIT_DIFF: interpreter authoritative + JIT checked in-process, + // - XENIA_JIT: JIT authoritative (M0 = interpreter fallback), + // - else: interpreter directly. + if xenia_cpu::recompiler::diff_enabled() { + xenia_cpu::recompiler::diff_step(ctx, mem, block) + } else if xenia_cpu::recompiler::jit_enabled() { + xenia_cpu::recompiler::run_block(ctx, mem, block) + } else { + step_block(ctx, mem, block) + } }; let executed = kernel .scheduler @@ -4383,6 +4394,8 @@ fn dump_thread_diagnostic( if xenia_gpu::prof::enabled() { xenia_gpu::prof::report(0); } + // JIT differential harness: checked/skipped/mismatch tally at clean exit. + xenia_cpu::recompiler::report_diff_summary(); // STEP-10 diagnostic (observe-only, env-gated `XENIA_DUMP_SLOTS=1`). // Prints each scheduler slot's full runqueue with the fields needed to diff --git a/crates/xenia-cpu/src/context.rs b/crates/xenia-cpu/src/context.rs index 60e658f..c3812d4 100644 --- a/crates/xenia-cpu/src/context.rs +++ b/crates/xenia-cpu/src/context.rs @@ -62,6 +62,11 @@ pub const VSCR_SAT_MASK: u32 = 0x0000_0001; /// PowerPC processor context. Holds all register state for one guest thread. /// Mirrors PPCContext from ppc_context.h, minus JIT-specific fields. +// `Clone` supports the recompiler's differential harness, which snapshots a +// context to run the JIT speculatively against the interpreter. Cloning shares +// the `reservation_table` Arc; the harness clears it on the speculative copy so +// a speculative lwarx/stwcx cannot perturb the authoritative reservation state. +#[derive(Clone)] #[repr(C, align(64))] pub struct PpcContext { // General purpose registers (R0-R31) diff --git a/crates/xenia-cpu/src/interpreter.rs b/crates/xenia-cpu/src/interpreter.rs index c9a234c..e3bb38a 100644 --- a/crates/xenia-cpu/src/interpreter.rs +++ b/crates/xenia-cpu/src/interpreter.rs @@ -214,7 +214,11 @@ pub fn step_block( } /// Execute a decoded instruction, updating context and memory. -fn execute(ctx: &mut PpcContext, mem: &dyn MemoryAccess, instr: &DecodedInstr) -> StepResult { +/// +/// `pub(crate)` so the recompiler ([`crate::recompiler`]) can reuse the exact +/// per-opcode semantics as its interpreter-fallback path — the JIT lowers from +/// this same source of truth, and uncompiled opcodes route straight back here. +pub(crate) fn execute(ctx: &mut PpcContext, mem: &dyn MemoryAccess, instr: &DecodedInstr) -> StepResult { match instr.opcode { // ===== ALU: Immediate ===== PpcOpcode::addi => { diff --git a/crates/xenia-cpu/src/lib.rs b/crates/xenia-cpu/src/lib.rs index b7bfa39..03bf341 100644 --- a/crates/xenia-cpu/src/lib.rs +++ b/crates/xenia-cpu/src/lib.rs @@ -8,6 +8,7 @@ pub mod interpreter; pub mod opcode; pub mod overflow; pub mod phaser; +pub mod recompiler; pub mod reservation; pub mod scheduler; pub mod trap; diff --git a/crates/xenia-cpu/src/recompiler.rs b/crates/xenia-cpu/src/recompiler.rs new file mode 100644 index 0000000..8191767 --- /dev/null +++ b/crates/xenia-cpu/src/recompiler.rs @@ -0,0 +1,328 @@ +//! PPC block recompiler (JIT) — **M0 foundation**. +//! +//! This is the seam a staged block-recompiler plugs into. M0 ships **no real +//! codegen**: [`run_block`] executes a block by falling back to the +//! interpreter's [`crate::interpreter::execute`] for every instruction, so it +//! is bit-identical to [`crate::interpreter::step_block`] by construction. Its +//! purpose is to prove the integration seam and the **differential harness** +//! before any lowering exists. Later stages replace the per-instruction +//! fallback with lowered ops (closure-threaded, then Cranelift machine code) +//! opcode-by-opcode, each gated through the harness below. +//! +//! ## Determinism is the whole game +//! `cycle_count`/`timebase` are bumped once per guest instruction and drive the +//! IPM clock the movie/scheduler depend on. [`run_block`] reproduces +//! `step_block`'s counting and stop semantics **exactly**. The interpreter is +//! the permanent reference oracle; the recompiler must never diverge from it. +//! +//! ## Differential harness (`XENIA_JIT_DIFF`) — in-process +//! The guest is only *coarsely* deterministic: `coord_idle_advance` ticks vsync +//! from wall-clock when the scheduler idles, so vblank ISRs land at +//! wall-clock-dependent instruction boundaries and two separate runs are **not** +//! bit-identical. Comparing across runs would measure that jitter, not JIT +//! divergence. So the harness is **in-process** ([`diff_step`]): +//! +//! * the **interpreter is authoritative** — it drives the real context and +//! memory, so a JIT bug can never corrupt the run; +//! * the **JIT runs speculatively** on a *clone* of the pre-block context +//! against an [`OverlayMemory`] (writes buffered, reads fall through to real +//! *pre-block* memory), then its registers are compared to the +//! interpreter's; +//! * blocks that touch **MMIO** (a device callback can't be run twice) or are +//! **`sync_sensitive`** (reservation/barrier ops share cross-thread state) +//! are skipped, not compared. +//! +//! Run with `XENIA_JIT_DIFF=1`; [`report_diff_summary`] prints checked/skipped/ +//! mismatch counts at exit. Zero mismatches ⇒ the recompiler matches the +//! interpreter on every comparable block. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; + +use crate::block_cache::DecodedBlock; +use crate::context::PpcContext; +use crate::interpreter::{execute, step_block, StepResult}; +use xenia_memory::MemoryAccess; + +/// Cached env gate. 0 = uninitialised, 1 = on, 2 = off. +#[inline] +fn cached_flag(cell: &AtomicU8, var: &str) -> bool { + match cell.load(Ordering::Relaxed) { + 1 => true, + 2 => false, + _ => { + let on = std::env::var_os(var).is_some(); + cell.store(if on { 1 } else { 2 }, Ordering::Relaxed); + on + } + } +} + +/// `XENIA_JIT` — route block execution through the recompiler instead of the +/// interpreter's `step_block`. Opt-in; cheap cached check on the hot path. +#[inline] +pub fn jit_enabled() -> bool { + static F: AtomicU8 = AtomicU8::new(0); + cached_flag(&F, "XENIA_JIT") +} + +/// `XENIA_JIT_DIFF` — enable the in-process differential harness ([`diff_step`]). +#[inline] +pub fn diff_enabled() -> bool { + static F: AtomicU8 = AtomicU8::new(0); + cached_flag(&F, "XENIA_JIT_DIFF") +} + +/// Execute one decoded block. **M0: interpreter fallback for every opcode.** +/// +/// Byte-for-byte the same loop as [`crate::interpreter::step_block`]: bump +/// `cycle_count`/`timebase` per instruction, bail on the first non-`Continue` +/// result, and stop on a PC discontinuity (only the terminator may branch). +pub fn run_block( + ctx: &mut PpcContext, + mem: &dyn MemoryAccess, + block: &DecodedBlock, +) -> StepResult { + let mut result = StepResult::Continue; + for instr in &block.instrs { + let expected_next = instr.addr.wrapping_add(4); + // M0 fallback: identical to the interpreter. Future stages dispatch + // lowered ops here and only fall back for uncompiled opcodes. + result = execute(ctx, mem, instr); + ctx.cycle_count += 1; + ctx.timebase += 1; + if !matches!(result, StepResult::Continue) { + return result; + } + if ctx.pc != expected_next { + break; + } + } + result +} + +// ---- in-process differential harness ------------------------------------- + +static CHECKED: AtomicU64 = AtomicU64::new(0); +static SKIPPED: AtomicU64 = AtomicU64::new(0); +static MISMATCH: AtomicU64 = AtomicU64::new(0); +const MAX_MISMATCH_PRINTS: u64 = 20; + +/// Run one block under the differential harness. The **interpreter is +/// authoritative** (drives `ctx`+`mem`); the JIT runs speculatively on a clone +/// against an overlay and its registers are compared. Returns the interpreter's +/// `StepResult`. +pub fn diff_step( + ctx: &mut PpcContext, + mem: &dyn MemoryAccess, + block: &DecodedBlock, +) -> StepResult { + // Reservation/barrier blocks share cross-thread state; don't speculate. + if block.sync_sensitive { + SKIPPED.fetch_add(1, Ordering::Relaxed); + return step_block(ctx, mem, block); + } + + // Speculative JIT run on a clone against buffered/overlay memory. Runs + // BEFORE the authoritative interpreter so its reads see pre-block memory. + let mut cand = ctx.clone(); + cand.reservation_table = None; // never touch the shared reservation table + let overlay = OverlayMemory::new(mem); + let _ = run_block(&mut cand, &overlay, block); + let touched_mmio = overlay.touched_mmio.get(); + + // Authoritative interpreter run: commits real ctx + memory. + let auth = step_block(ctx, mem, block); + + if touched_mmio { + SKIPPED.fetch_add(1, Ordering::Relaxed); + } else { + CHECKED.fetch_add(1, Ordering::Relaxed); + compare(&cand, ctx, block.start_pc); + } + auth +} + +/// Compare speculative-JIT (`cand`) vs authoritative-interpreter (`auth`) +/// register state. Reports the first [`MAX_MISMATCH_PRINTS`] divergent blocks. +/// Covers the integer + FP + control state (M0/M1 scope); vector regs join when +/// VMX opcodes are lowered. +fn compare(cand: &PpcContext, auth: &PpcContext, start_pc: u32) { + let mut diffs: Vec = Vec::new(); + for i in 0..32 { + if cand.gpr[i] != auth.gpr[i] { + diffs.push(format!("r{i} jit={:#018x} int={:#018x}", cand.gpr[i], auth.gpr[i])); + } + } + for i in 0..32 { + if cand.fpr[i].to_bits() != auth.fpr[i].to_bits() { + diffs.push(format!( + "f{i} jit={:#018x} int={:#018x}", + cand.fpr[i].to_bits(), + auth.fpr[i].to_bits() + )); + } + } + if cand.lr != auth.lr { + diffs.push(format!("lr jit={:#x} int={:#x}", cand.lr, auth.lr)); + } + if cand.ctr != auth.ctr { + diffs.push(format!("ctr jit={:#x} int={:#x}", cand.ctr, auth.ctr)); + } + if cand.pc != auth.pc { + diffs.push(format!("pc jit={:#x} int={:#x}", cand.pc, auth.pc)); + } + for i in 0..8 { + if cand.cr[i].as_u8() != auth.cr[i].as_u8() { + diffs.push(format!("cr{i} jit={:#x} int={:#x}", cand.cr[i].as_u8(), auth.cr[i].as_u8())); + } + } + if (cand.xer_ca, cand.xer_ov, cand.xer_so, cand.xer_tbc) + != (auth.xer_ca, auth.xer_ov, auth.xer_so, auth.xer_tbc) + { + diffs.push("xer".into()); + } + if cand.fpscr != auth.fpscr { + diffs.push(format!("fpscr jit={:#x} int={:#x}", cand.fpscr, auth.fpscr)); + } + if cand.cycle_count != auth.cycle_count { + diffs.push(format!("cycles jit={} int={}", cand.cycle_count, auth.cycle_count)); + } + + if !diffs.is_empty() { + let n = MISMATCH.fetch_add(1, Ordering::Relaxed); + if n < MAX_MISMATCH_PRINTS { + eprintln!("JIT-DIFF MISMATCH block={start_pc:#010x}: {}", diffs.join(", ")); + } + } +} + +/// Print checked/skipped/mismatch tallies (call at clean exit). +pub fn report_diff_summary() { + if !diff_enabled() { + return; + } + let (c, s, m) = ( + CHECKED.load(Ordering::Relaxed), + SKIPPED.load(Ordering::Relaxed), + MISMATCH.load(Ordering::Relaxed), + ); + eprintln!( + "=== JIT-DIFF SUMMARY: checked={c} skipped(mmio/sync)={s} MISMATCHES={m} — {} ===", + if m == 0 { "CLEAN ✓" } else { "DIVERGENCE ✗" } + ); +} + +// ---- overlay memory (speculative-write buffer) --------------------------- + +/// A [`MemoryAccess`] wrapper for speculative JIT execution: reads fall through +/// to the real (pre-block) memory, writes are buffered in a byte-granular +/// overlay (never committed), and any MMIO touch sets `touched_mmio` so the +/// caller can skip comparing that block. Big-endian byte order throughout, +/// matching `GuestMemory`. +struct OverlayMemory<'a> { + real: &'a dyn MemoryAccess, + overlay: RefCell>, + touched_mmio: Cell, +} + +impl<'a> OverlayMemory<'a> { + fn new(real: &'a dyn MemoryAccess) -> Self { + Self { real, overlay: RefCell::new(HashMap::new()), touched_mmio: Cell::new(false) } + } + #[inline] + fn rb(&self, addr: u32) -> u8 { + match self.overlay.borrow().get(&addr) { + Some(&b) => b, + None => self.real.read_u8(addr), + } + } + #[inline] + fn wb(&self, addr: u32, b: u8) { + self.overlay.borrow_mut().insert(addr, b); + } + /// Returns true (and flags the block) if `addr` is MMIO — callers must not + /// touch it speculatively. + #[inline] + fn is_mmio_stop(&self, addr: u32) -> bool { + if self.real.is_mmio(addr) { + self.touched_mmio.set(true); + true + } else { + false + } + } +} + +impl<'a> MemoryAccess for OverlayMemory<'a> { + fn read_u8(&self, a: u32) -> u8 { + if self.is_mmio_stop(a) { + return 0; + } + self.rb(a) + } + fn read_u16(&self, a: u32) -> u16 { + if self.is_mmio_stop(a) { + return 0; + } + ((self.rb(a) as u16) << 8) | self.rb(a.wrapping_add(1)) as u16 + } + fn read_u32(&self, a: u32) -> u32 { + if self.is_mmio_stop(a) { + return 0; + } + ((self.rb(a) as u32) << 24) + | ((self.rb(a.wrapping_add(1)) as u32) << 16) + | ((self.rb(a.wrapping_add(2)) as u32) << 8) + | self.rb(a.wrapping_add(3)) as u32 + } + fn read_u64(&self, a: u32) -> u64 { + if self.is_mmio_stop(a) { + return 0; + } + let hi = self.read_u32(a) as u64; + let lo = self.read_u32(a.wrapping_add(4)) as u64; + (hi << 32) | lo + } + fn write_u8(&self, a: u32, v: u8) { + if self.is_mmio_stop(a) { + return; + } + self.wb(a, v); + } + fn write_u16(&self, a: u32, v: u16) { + if self.is_mmio_stop(a) { + return; + } + self.wb(a, (v >> 8) as u8); + self.wb(a.wrapping_add(1), v as u8); + } + fn write_u32(&self, a: u32, v: u32) { + if self.is_mmio_stop(a) { + return; + } + self.wb(a, (v >> 24) as u8); + self.wb(a.wrapping_add(1), (v >> 16) as u8); + self.wb(a.wrapping_add(2), (v >> 8) as u8); + self.wb(a.wrapping_add(3), v as u8); + } + fn write_u64(&self, a: u32, v: u64) { + self.write_u32(a, (v >> 32) as u32); + self.write_u32(a.wrapping_add(4), v as u32); + } + // execute() never calls translate/translate_mut for real memory (only the + // test mock does), so returning None is safe here. + fn translate(&self, _a: u32) -> Option<*const u8> { + None + } + fn translate_mut(&self, _a: u32) -> Option<*mut u8> { + None + } + fn page_version(&self, a: u32) -> u64 { + self.real.page_version(a) + } + fn is_mmio(&self, a: u32) -> bool { + self.real.is_mmio(a) + } +} diff --git a/crates/xenia-memory/src/access.rs b/crates/xenia-memory/src/access.rs index 7359b0d..c099568 100644 --- a/crates/xenia-memory/src/access.rs +++ b/crates/xenia-memory/src/access.rs @@ -48,6 +48,16 @@ pub trait MemoryAccess { } } + /// True if `addr` falls in an MMIO region (a load/store there invokes a + /// device callback with side effects rather than touching backing RAM). + /// + /// Default `false` (mock memories have no MMIO). `GuestMemory` overrides it. + /// Used by the recompiler's differential harness to *skip* comparing blocks + /// that touch MMIO — a device callback cannot be safely executed twice. + fn is_mmio(&self, _addr: u32) -> bool { + false + } + /// Get a direct host pointer for the given guest address. /// Returns None if the address is invalid or in an MMIO region. fn translate(&self, addr: u32) -> Option<*const u8>; diff --git a/crates/xenia-memory/src/heap.rs b/crates/xenia-memory/src/heap.rs index e500916..01cc481 100644 --- a/crates/xenia-memory/src/heap.rs +++ b/crates/xenia-memory/src/heap.rs @@ -504,6 +504,11 @@ impl GuestMemory { } impl MemoryAccess for GuestMemory { + #[inline] + fn is_mmio(&self, addr: u32) -> bool { + self.find_mmio(addr).is_some() + } + // Tier-3 perf: `#[inline]` on the hot read/write paths lets LLVM // fold the MMIO + mapping checks into the interpreter's load/store // handlers, hoisting the "not-MMIO, mapped" branch out of the loop