[iterate-4A] jit M0: recompiler seam + in-process differential harness

Foundation for a staged PPC block-recompiler (plan: closure-threaded ->
Cranelift -> block-linking). No codegen yet — proves the integration seam and
the correctness harness before any lowering exists.

New crates/xenia-cpu/src/recompiler.rs:
- run_block: executes a DecodedBlock; M0 falls back to the interpreter's
  execute() for every opcode, so it is bit-identical to step_block. Later
  stages dispatch lowered ops here and fall back only for uncompiled opcodes.
- gates jit_enabled()/diff_enabled() (XENIA_JIT / XENIA_JIT_DIFF, cached).
- In-process differential harness (diff_step): the interpreter is AUTHORITATIVE
  (drives real ctx+mem, so a JIT bug can never corrupt a run); the JIT runs
  SPECULATIVELY on a ctx clone against OverlayMemory (writes buffered in a byte
  HashMap, reads fall through to real pre-block memory), then registers are
  compared. Blocks touching MMIO or sync_sensitive (reservation/barrier) are
  skipped — a device callback can't be run twice and reservation state is
  shared cross-thread. report_diff_summary() prints checked/skipped/mismatch.

Why in-process: the guest is only COARSELY deterministic — coord_idle_advance
ticks vsync from wall-clock when idle, so two separate runs are not bit-exact
and a cross-run signature compare would measure jitter, not JIT divergence.

Supporting changes: PpcContext #[derive(Clone)] (harness clears the speculative
clone's reservation_table Arc); is_mmio() on the MemoryAccess trait (default
false) + GuestMemory impl via find_mmio; execute() made pub(crate); run_superblock
routes the block body (DIFF->diff_step, JIT->run_block, else step_block).

Validated: full boot+movie XENIA_JIT_DIFF=1 run = checked 218.4M blocks,
skipped(mmio/sync) 511.6K (0.23%), MISMATCHES=0 CLEAN; movie plays (ADVreads=30,
tid25 resumes); diff-mode ~2x slower (test-only path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 20:25:18 +02:00
parent e07f93ed0a
commit ac289bca5e
7 changed files with 368 additions and 2 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 => {

View File

@@ -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;

View File

@@ -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<String> = 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<HashMap<u32, u8>>,
touched_mmio: Cell<bool>,
}
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)
}
}

View File

@@ -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>;

View File

@@ -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