[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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
328
crates/xenia-cpu/src/recompiler.rs
Normal file
328
crates/xenia-cpu/src/recompiler.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user