diff --git a/Cargo.lock b/Cargo.lock index a82b968..9b95f22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d00a940..5b21e6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/xenia-app/Cargo.toml b/crates/xenia-app/Cargo.toml index 4bdb984..2bf3037 100644 --- a/crates/xenia-app/Cargo.toml +++ b/crates/xenia-app/Cargo.toml @@ -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 } diff --git a/crates/xenia-app/src/main.rs b/crates/xenia-app/src/main.rs index b663d18..f42eb7f 100644 --- a/crates/xenia-app/src/main.rs +++ b/crates/xenia-app/src/main.rs @@ -2499,15 +2499,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, } 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, } } } @@ -3151,7 +3165,15 @@ fn run_superblock( let (result, executed) = { let ctx = kernel.scheduler.ctx_mut_ref(thread_ref); 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) }; diff --git a/crates/xenia-cpu/src/interpreter.rs b/crates/xenia-cpu/src/interpreter.rs index c9a234c..85533d1 100644 --- a/crates/xenia-cpu/src/interpreter.rs +++ b/crates/xenia-cpu/src/interpreter.rs @@ -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 { diff --git a/crates/xenia-jit/Cargo.toml b/crates/xenia-jit/Cargo.toml new file mode 100644 index 0000000..5691fd5 --- /dev/null +++ b/crates/xenia-jit/Cargo.toml @@ -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 } diff --git a/crates/xenia-jit/src/lib.rs b/crates/xenia-jit/src/lib.rs new file mode 100644 index 0000000..2e19473 --- /dev/null +++ b/crates/xenia-jit/src/lib.rs @@ -0,0 +1,300 @@ +//! PPC→x64 block JIT for xenia-rs (`XENIA_JIT`, default OFF). +//! +//! **Phase 0 — skeleton.** This lands the whole runtime substrate (the +//! compiled-block ABI, the interpreter-fallback helper, per-instruction counter +//! bumps, block-exit semantics, and a per-slot code cache) while porting +//! **zero** opcodes to native code: every guest instruction is emitted as a +//! `call` into the interpreter (`xenia_cpu::interpreter::interpret_one`). This +//! makes a JIT-compiled block **byte-identical** to `step_block` by +//! construction, so the golden regression proves the ABI before any opcode is +//! hand-written. Later phases replace individual `call interpret_one` sites +//! with native x64 for the hot opcodes; un-ported opcodes keep falling back. +//! +//! ## Design (context-threading, dynasm-rs) +//! Guest state lives in `PpcContext`; a compiled block is an +//! `extern "C" fn(*mut JitEnv) -> u32` returning a [`StepResult`] discriminant +//! (0 = `Continue`). Emitted code keeps the `PpcContext` pointer in `r15` and +//! the `JitEnv` pointer in `rbx` (both callee-saved, so they survive the helper +//! `call`s). Memory access + un-ported opcodes go through `extern "C"` helpers +//! that receive `JitEnv` and reconstruct `&dyn MemoryAccess` from the fat raw +//! pointer stored in it — no fat-pointer transmute. +//! +//! ## Determinism (the load-bearing invariant) +//! After **every** retired instruction (native or fallback) the block bumps +//! `ctx.cycle_count` and `ctx.timebase` by 1, matching +//! `interpreter.rs::step_block` exactly. Blocks stop at the same instruction +//! the interpreter would (non-`Continue` result, or a taken branch that makes +//! `pc != expected_next`). The JIT code cache mirrors the interpreter block +//! cache's `(start_pc, page_version)` invalidation, and each compiled block +//! **owns a copy** of its decoded instructions so baked instruction pointers +//! can never dangle after a block-cache eviction. + +use dynasmrt::{DynasmApi, DynasmLabelApi, dynasm}; + +use xenia_cpu::block_cache::DecodedBlock; +use xenia_cpu::context::PpcContext; +use xenia_cpu::decoder::DecodedInstr; +use xenia_cpu::interpreter::{StepResult, interpret_one}; +use xenia_memory::MemoryAccess; + +/// Runtime environment handed to a compiled block. The emitted prologue reads +/// only `ctx` (via `offset_of!`); `mem` and `last_result` are touched solely by +/// the Rust helpers. `mem` is a real fat raw pointer, so no transmute is needed +/// to reconstruct the `&dyn` in the helpers. +pub struct JitEnv { + /// Guest CPU state — loaded into `r15` by the block prologue. + ctx: *mut PpcContext, + /// The guest memory the block runs against (fat raw pointer). Only the + /// `extern "C"` helpers dereference this. + mem: *const dyn MemoryAccess, + /// The exact `StepResult` of the last instruction the block ran. The Rust + /// wrapper reads this on a non-`Continue` exit so the full payload (e.g. + /// `Unimplemented(op)`) is preserved without serializing it through the + /// `u32` return channel. + last_result: StepResult, +} + +/// A compiled block's callable form. First arg (`rdi`) is the `JitEnv`; the +/// return value (`eax`) is a [`StepResult`] discriminant (0 = `Continue`). +type JitBlockFn = unsafe extern "C" fn(*mut JitEnv) -> u32; + +/// Map a `StepResult` to the block's `u32` return channel. Only the +/// `Continue == 0` vs non-zero distinction is load-bearing (the wrapper reads +/// `JitEnv::last_result` for the actual non-`Continue` value); the specific +/// codes are for clarity/debugging. +#[inline] +fn sr_code(r: StepResult) -> u32 { + match r { + StepResult::Continue => 0, + StepResult::SystemCall => 1, + StepResult::Unimplemented(_) => 2, + StepResult::Trap => 3, + StepResult::Halted => 4, + StepResult::Yield => 5, + } +} + +/// Interpreter fallback for one instruction, called from emitted code. +/// +/// SAFETY: invoked only from a compiled block created by [`compile_block`], +/// which passes a `JitEnv` that is live on [`run_jit_block`]'s stack and an +/// `instr` pointing into the owning `CompiledBlock`'s instruction copy (kept +/// alive for the duration of the call). Reconstructs the `&mut PpcContext` and +/// `&dyn MemoryAccess` from the env. Does not bump counters — the block does. +unsafe extern "C" fn jit_interpret_one(env: *mut JitEnv, instr: *const DecodedInstr) -> u32 { + // SAFETY: see function contract. + let env = unsafe { &mut *env }; + let ctx = unsafe { &mut *env.ctx }; + let mem: &dyn MemoryAccess = unsafe { &*env.mem }; + let instr = unsafe { &*instr }; + let r = interpret_one(ctx, mem, instr); + env.last_result = r; + sr_code(r) +} + +/// One JIT-compiled block. Owns everything the emitted code references so the +/// code, its instruction pointers, and its cache-key metadata share one +/// lifetime. +struct CompiledBlock { + start_pc: u32, + /// `DecodedBlock::page_version` at compile time; mismatch on lookup forces + /// recompilation (mirrors the interpreter block cache invalidation). + page_version: u64, + /// Owned copy of the block's decoded instructions. The emitted `call`s bake + /// raw pointers to these elements, so this boxed slice (stable address) + /// MUST outlive `func`. Kept alive as a field; not read directly. + _instrs: Box<[DecodedInstr]>, + /// Backing executable mapping for `func`. Kept alive as a field. + _buf: dynasmrt::ExecutableBuffer, + /// Entry point into `_buf`. + func: JitBlockFn, +} + +// SAFETY: `CompiledBlock` is only ever created, stored, and invoked on the +// single owning HW-slot thread (each `WorkerCtx` has its own `JitCache`), the +// same discipline as the interpreter's per-slot `BlockCache`. The raw pointers +// it holds are self-owned. It is never shared across threads. +unsafe impl Send for CompiledBlock {} + +/// Compile `block` into a `CompiledBlock`. Phase 0: every instruction is a +/// `call jit_interpret_one` + the mandatory counter/exit postlude. +fn compile_block(block: &DecodedBlock) -> CompiledBlock { + // Own the instruction stream first, then bake pointers into the *owned* + // copy (its addresses are final once boxed). + let instrs: Box<[DecodedInstr]> = block.instrs.clone().into_boxed_slice(); + + // Field offsets resolved at compile time — robust to struct layout. + let off_ctx = core::mem::offset_of!(JitEnv, ctx) as i32; + let off_cycle = core::mem::offset_of!(PpcContext, cycle_count) as i32; + let off_timebase = core::mem::offset_of!(PpcContext, timebase) as i32; + let off_pc = core::mem::offset_of!(PpcContext, pc) as i32; + let helper = jit_interpret_one as usize as i64; + + let mut ops = dynasmrt::x64::Assembler::new().expect("dynasm assembler"); + let entry = ops.offset(); + let l_exit = ops.new_dynamic_label(); + let l_cont = ops.new_dynamic_label(); + + // Prologue: save callee-saved regs we use, keep the stack 16-aligned before + // the helper calls (entry rsp%16==8; two pushes -> 8; `sub 8` -> 0), pin + // env in rbx and ctx in r15. + dynasm!(ops + ; .arch x64 + ; push rbx + ; push r15 + ; sub rsp, 8 + ; mov rbx, rdi + ; mov r15, [rbx + off_ctx] + ); + + for instr in instrs.iter() { + let instr_ptr = instr as *const DecodedInstr as usize as i64; + let expected_next = instr.addr.wrapping_add(4) as i32; + dynasm!(ops + ; .arch x64 + // fallback: eax = jit_interpret_one(env, &instr); env.last_result set + ; mov rdi, rbx + ; mov rsi, QWORD instr_ptr + ; mov rax, QWORD helper + ; call rax + // determinism postlude: cycle_count += 1; timebase += 1 + ; inc QWORD [r15 + off_cycle] + ; inc QWORD [r15 + off_timebase] + // non-Continue result -> exit returning the discriminant in eax + ; test eax, eax + ; jnz =>l_exit + // taken-branch (pc discontinuity) -> stop the block, return Continue + ; cmp DWORD [r15 + off_pc], expected_next + ; jne =>l_cont + ); + } + + // Natural end / discontinuity exit: Continue (eax=0). Shared epilogue. + dynasm!(ops + ; .arch x64 + ; =>l_cont + ; xor eax, eax + ; =>l_exit + ; add rsp, 8 + ; pop r15 + ; pop rbx + ; ret + ); + + let buf = ops.finalize().expect("dynasm finalize"); + // SAFETY: `entry` is a valid offset into `buf`; the emitted code matches + // the `JitBlockFn` ABI (System V, first arg rdi, return eax). + let func: JitBlockFn = unsafe { std::mem::transmute::<*const u8, JitBlockFn>(buf.ptr(entry)) }; + + CompiledBlock { + start_pc: block.start_pc, + page_version: block.page_version, + _instrs: instrs, + _buf: buf, + func, + } +} + +/// Run a compiled block against `ctx`/`mem`, returning the same `StepResult` +/// the interpreter's `step_block` would. The block bumps `cycle_count`/ +/// `timebase` and updates `ctx.pc` in place, exactly like the interpreter. +fn run_jit_block(cb: &CompiledBlock, ctx: &mut PpcContext, mem: &dyn MemoryAccess) -> StepResult { + // Erase the borrow lifetime so it fits `JitEnv::mem` (a raw + // `*const dyn MemoryAccess`, i.e. `+ 'static`). This is a lifetime-only + // transmute — the fat-pointer representation is unchanged — and is sound + // because `env` does not escape: the block runs synchronously and returns + // before `mem`'s borrow ends. + let mem_static: &'static dyn MemoryAccess = + unsafe { std::mem::transmute::<&dyn MemoryAccess, &'static dyn MemoryAccess>(mem) }; + let mut env = JitEnv { + ctx: ctx as *mut PpcContext, + mem: mem_static as *const dyn MemoryAccess, + last_result: StepResult::Continue, + }; + // SAFETY: `func` is code emitted by `compile_block` for the JitBlockFn ABI; + // `env` outlives the call; `cb` (and its owned instrs the code references) + // is borrowed for the whole call. + let code = unsafe { (cb.func)(&mut env as *mut JitEnv) }; + if code == 0 { + StepResult::Continue + } else { + env.last_result + } +} + +// Matches the interpreter's `BlockCache` (64K direct-mapped, pc-indexed). +const JIT_CACHE_SIZE: usize = 1 << 16; +const JIT_CACHE_MASK: u32 = (JIT_CACHE_SIZE as u32) - 1; + +/// Per-HW-slot JIT code cache. Direct-mapped by guest PC, gated on +/// `(start_pc, page_version)` so it invalidates in lock-step with the +/// interpreter block cache (self-modifying / reloaded code recompiles). +pub struct JitCache { + slots: Box<[Option]>, + compiles: u64, + hits: u64, +} + +impl Default for JitCache { + fn default() -> Self { + Self::new() + } +} + +impl JitCache { + pub fn new() -> Self { + let mut v: Vec> = Vec::with_capacity(JIT_CACHE_SIZE); + v.resize_with(JIT_CACHE_SIZE, || None); + Self { + slots: v.into_boxed_slice(), + compiles: 0, + hits: 0, + } + } + + pub fn compiles(&self) -> u64 { + self.compiles + } + pub fn hits(&self) -> u64 { + self.hits + } + + /// Look up (or compile) the JIT block for `block` and run it. `block` is the + /// freshly-validated `DecodedBlock` from the interpreter cache, so its + /// `page_version` is current; we key on it directly. + pub fn run_or_compile( + &mut self, + block: &DecodedBlock, + ctx: &mut PpcContext, + mem: &dyn MemoryAccess, + ) -> StepResult { + let idx = ((block.start_pc >> 2) & JIT_CACHE_MASK) as usize; + let fresh = matches!( + &self.slots[idx], + Some(cb) if cb.start_pc == block.start_pc && cb.page_version == block.page_version + ); + if fresh { + self.hits += 1; + } else { + self.compiles += 1; + self.slots[idx] = Some(compile_block(block)); + } + let cb = self.slots[idx].as_ref().expect("just populated"); + run_jit_block(cb, ctx, mem) + } +} + +/// Whether the JIT is enabled this run (`XENIA_JIT=1|true|yes`), cached once. +pub fn env_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| { + std::env::var("XENIA_JIT") + .ok() + .map(|v| { + let v = v.trim().to_ascii_lowercase(); + v == "1" || v == "true" || v == "yes" + }) + .unwrap_or(false) + }) +}