[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>
This commit is contained in:
MechaCat02
2026-07-03 23:38:28 +02:00
parent 77c2d7bce9
commit 1d56218d83
7 changed files with 418 additions and 1 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

@@ -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<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,
}
}
}
@@ -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)
};

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 }

300
crates/xenia-jit/src/lib.rs Normal file
View File

@@ -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<CompiledBlock>]>,
compiles: u64,
hits: u64,
}
impl Default for JitCache {
fn default() -> Self {
Self::new()
}
}
impl JitCache {
pub fn new() -> Self {
let mut v: Vec<Option<CompiledBlock>> = 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<bool> = 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)
})
}