[iterate-4C] JIT Phase 1: native integer ALU emitters + differential tests

crates/xenia-jit/src/emit.rs: native x64 for the hot non-recording integer
ALU ops — addi, addis, ori, oris, xori, xoris, or(x), and(x), xor(x),
add(x), subf(x), neg(x). Each mirrors its interpreter arm exactly, then
does pc+=4 and the cycle/timebase bumps. Per-instance guards fall back to
the interpreter for forms the emitter can't yet reproduce faithfully:
recording (`.`/Rc) forms, OE overflow forms, and the db16cyc spin hint
(or r31,r31,r31 -> Yield). RA=0 literal-zero rule handled at emit time.

Differential harness (tests.rs): each opcode run through interpret_one vs a
JIT-compiled 1-instr block over 2000 random register seeds (+ edge values
0/0xFFFFFFFF/0x80000000/INT64 boundaries); asserts full GPR/PC/XER/cycle/
timebase/StepResult equality. Plus a guard test that recording/OE/db16cyc
forms are NOT natively emitted.

Gate: golden n200m BYTE-IDENTICAL with XENIA_JIT=1; `cargo test -p
xenia-jit` green. Throughput -n 200M --gpu-inline: 4.3s interp, 6.2s JIT
(down from 7.1s all-fallback skeleton). Still fallback-bound on
loads/stores/branches -> Phase 2 is the crossover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-03 23:47:06 +02:00
parent 1d56218d83
commit 231c35a28f
3 changed files with 476 additions and 8 deletions

View File

@@ -0,0 +1,237 @@
//! Native x64 emitters for individual PPC opcodes (context-threading).
//!
//! Each emitter mirrors exactly one interpreter arm
//! (`xenia-cpu/src/interpreter.rs`) and leaves guest state in `PpcContext`
//! identical to what the interpreter would produce, then advances `pc` by 4 and
//! bumps the cycle/timebase counters. Emitters cover only the cases they can
//! reproduce faithfully; any variant they can't (recording `.` forms, `OE`
//! overflow forms, the `db16cyc` spin hint) returns `false` from
//! [`try_emit_native`] so the block falls back to the interpreter for that
//! instruction — keeping every result byte-identical.
//!
//! Register convention (set up by the block prologue): `r15` = `*mut
//! PpcContext`, `rbx` = `*mut JitEnv`. Emitters use `rax`/`rcx` as scratch
//! (caller-saved; no cross-instruction state — all guest state lives in
//! `PpcContext`). PPC operand roles follow the ISA: arithmetic D-forms write
//! `rD` (`instr.rd()`); logical forms write `rA` (`instr.ra()`) from `rS`
//! (`instr.rs()`).
use dynasmrt::{DynasmApi, dynasm};
use xenia_cpu::PpcOpcode;
use xenia_cpu::context::PpcContext;
use xenia_cpu::decoder::DecodedInstr;
use crate::JitEnv;
/// Byte offsets into `JitEnv`/`PpcContext`, resolved once per block compile.
pub struct Offsets {
pub env_ctx: i32,
gpr: i32,
pub pc: i32,
pub cycle: i32,
pub timebase: i32,
}
impl Offsets {
pub fn resolve() -> Self {
Offsets {
env_ctx: core::mem::offset_of!(JitEnv, ctx) as i32,
gpr: core::mem::offset_of!(PpcContext, gpr) as i32,
pc: core::mem::offset_of!(PpcContext, pc) as i32,
cycle: core::mem::offset_of!(PpcContext, cycle_count) as i32,
timebase: core::mem::offset_of!(PpcContext, timebase) as i32,
}
}
/// Byte offset of guest register `i` (`gpr[i]`, 8 bytes each).
#[inline]
fn gpr(&self, i: usize) -> i32 {
self.gpr + (i as i32) * 8
}
}
type Asm = dynasmrt::x64::Assembler;
/// Emit `pc += 4; cycle_count += 1; timebase += 1` — the postlude every native
/// (non-branch) opcode shares, matching `interpreter.rs` (`execute` does
/// `ctx.pc += 4`, `step_block` bumps the counters after each instruction).
#[inline]
fn advance_and_count(ops: &mut Asm, off: &Offsets) {
dynasm!(ops
; .arch x64
; add DWORD [r15 + off.pc], 4
; inc QWORD [r15 + off.cycle]
; inc QWORD [r15 + off.timebase]
);
}
/// Try to emit native x64 for `instr`. Returns `true` if it fully handled the
/// instruction (computation + `advance_and_count`); `false` if the caller must
/// fall back to the interpreter for it.
pub fn try_emit_native(ops: &mut Asm, off: &Offsets, instr: &DecodedInstr) -> bool {
let ra = instr.ra();
let rb = instr.rb();
let rd = instr.rd(); // == rs()
match instr.opcode {
// rD = (rA==0 ? 0 : gpr[rA]) + EXTS(SIMM) [64-bit; never records]
PpcOpcode::addi => {
let simm = instr.simm16() as i32; // fits i32 (16-bit sign-extended)
load_ra_or_zero(ops, off, ra);
if simm != 0 {
dynasm!(ops ; .arch x64 ; add rax, simm);
}
dynasm!(ops ; .arch x64 ; mov [r15 + off.gpr(rd)], rax);
advance_and_count(ops, off);
true
}
// rD = (rA==0 ? 0 : gpr[rA]) + (EXTS(SIMM) << 16) [64-bit]
PpcOpcode::addis => {
// (i16 sign-extended) << 16 occupies bits 16..31 with the sign in
// bit 31 — exactly an i32, so it fits an `add r64, imm32`.
let simm = (instr.simm16() as i32) << 16;
load_ra_or_zero(ops, off, ra);
if simm != 0 {
dynasm!(ops ; .arch x64 ; add rax, simm);
}
dynasm!(ops ; .arch x64 ; mov [r15 + off.gpr(rd)], rax);
advance_and_count(ops, off);
true
}
// gpr[rA] = gpr[rS] | ZEXT(UIMM) [never records]
PpcOpcode::ori => {
let uimm = instr.uimm16() as i32; // 0..65535 -> positive i32 (zext == sext)
dynasm!(ops ; .arch x64 ; mov rax, [r15 + off.gpr(rd)]);
if uimm != 0 {
dynasm!(ops ; .arch x64 ; or rax, uimm);
}
dynasm!(ops ; .arch x64 ; mov [r15 + off.gpr(ra)], rax);
advance_and_count(ops, off);
true
}
// gpr[rA] = gpr[rS] | (ZEXT(UIMM) << 16)
PpcOpcode::oris => {
let imm = (instr.uimm16() as u32) << 16;
dynasm!(ops ; .arch x64
; mov rax, [r15 + off.gpr(rd)]
; mov ecx, imm as i32 // zero-extends into rcx
; or rax, rcx
; mov [r15 + off.gpr(ra)], rax
);
advance_and_count(ops, off);
true
}
// gpr[rA] = gpr[rS] ^ ZEXT(UIMM)
PpcOpcode::xori => {
let uimm = instr.uimm16() as i32;
dynasm!(ops ; .arch x64 ; mov rax, [r15 + off.gpr(rd)]);
if uimm != 0 {
dynasm!(ops ; .arch x64 ; xor rax, uimm);
}
dynasm!(ops ; .arch x64 ; mov [r15 + off.gpr(ra)], rax);
advance_and_count(ops, off);
true
}
// gpr[rA] = gpr[rS] ^ (ZEXT(UIMM) << 16)
PpcOpcode::xoris => {
let imm = (instr.uimm16() as u32) << 16;
dynasm!(ops ; .arch x64
; mov rax, [r15 + off.gpr(rd)]
; mov ecx, imm as i32
; xor rax, rcx
; mov [r15 + off.gpr(ra)], rax
);
advance_and_count(ops, off);
true
}
// gpr[rA] = gpr[rS] | gpr[rB] [64-bit]. Skip the db16cyc spin hint
// (returns Yield) and the recording form.
PpcOpcode::orx => {
if instr.rc_bit() || instr.raw == 0x7FFF_FB78 {
return false;
}
dynasm!(ops ; .arch x64
; mov rax, [r15 + off.gpr(rd)]
; or rax, [r15 + off.gpr(rb)]
; mov [r15 + off.gpr(ra)], rax
);
advance_and_count(ops, off);
true
}
// gpr[rA] = gpr[rS] & gpr[rB] [64-bit]
PpcOpcode::andx => {
if instr.rc_bit() {
return false;
}
dynasm!(ops ; .arch x64
; mov rax, [r15 + off.gpr(rd)]
; and rax, [r15 + off.gpr(rb)]
; mov [r15 + off.gpr(ra)], rax
);
advance_and_count(ops, off);
true
}
// gpr[rA] = gpr[rS] ^ gpr[rB] [64-bit]
PpcOpcode::xorx => {
if instr.rc_bit() {
return false;
}
dynasm!(ops ; .arch x64
; mov rax, [r15 + off.gpr(rd)]
; xor rax, [r15 + off.gpr(rb)]
; mov [r15 + off.gpr(ra)], rax
);
advance_and_count(ops, off);
true
}
// rD = gpr[rA] + gpr[rB] [64-bit]; skip OE/recording forms.
PpcOpcode::addx => {
if instr.oe() || instr.rc_bit() {
return false;
}
dynasm!(ops ; .arch x64
; mov rax, [r15 + off.gpr(ra)]
; add rax, [r15 + off.gpr(rb)]
; mov [r15 + off.gpr(rd)], rax
);
advance_and_count(ops, off);
true
}
// rD = gpr[rB] - gpr[rA] [64-bit]; skip OE/recording forms.
PpcOpcode::subfx => {
if instr.oe() || instr.rc_bit() {
return false;
}
dynasm!(ops ; .arch x64
; mov rax, [r15 + off.gpr(rb)]
; sub rax, [r15 + off.gpr(ra)]
; mov [r15 + off.gpr(rd)], rax
);
advance_and_count(ops, off);
true
}
// rD = 0 - gpr[rA] [64-bit]; skip OE/recording forms.
PpcOpcode::negx => {
if instr.oe() || instr.rc_bit() {
return false;
}
dynasm!(ops ; .arch x64
; mov rax, [r15 + off.gpr(ra)]
; neg rax
; mov [r15 + off.gpr(rd)], rax
);
advance_and_count(ops, off);
true
}
_ => false,
}
}
/// Load `gpr[ra]` into `rax`, or zero `rax` when `ra == 0` (the PPC
/// arithmetic-D "RA=0 means literal 0" rule, statically known here).
#[inline]
fn load_ra_or_zero(ops: &mut Asm, off: &Offsets, ra: usize) {
if ra == 0 {
dynasm!(ops ; .arch x64 ; xor eax, eax);
} else {
dynasm!(ops ; .arch x64 ; mov rax, [r15 + off.gpr(ra)]);
}
}

View File

@@ -37,6 +37,10 @@ use xenia_cpu::decoder::DecodedInstr;
use xenia_cpu::interpreter::{StepResult, interpret_one};
use xenia_memory::MemoryAccess;
mod emit;
#[cfg(test)]
mod tests;
/// 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
@@ -124,10 +128,7 @@ fn compile_block(block: &DecodedBlock) -> CompiledBlock {
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 off = emit::Offsets::resolve();
let helper = jit_interpret_one as usize as i64;
let mut ops = dynasmrt::x64::Assembler::new().expect("dynasm assembler");
@@ -144,10 +145,17 @@ fn compile_block(block: &DecodedBlock) -> CompiledBlock {
; push r15
; sub rsp, 8
; mov rbx, rdi
; mov r15, [rbx + off_ctx]
; mov r15, [rbx + off.env_ctx]
);
for instr in instrs.iter() {
// Native fast path: emitters fully handle the instruction (compute +
// pc+=4 + counter bumps) and never diverge control flow, so no exit
// checks are needed after them.
if emit::try_emit_native(&mut ops, &off, instr) {
continue;
}
// Interpreter fallback for un-ported opcodes.
let instr_ptr = instr as *const DecodedInstr as usize as i64;
let expected_next = instr.addr.wrapping_add(4) as i32;
dynasm!(ops
@@ -158,13 +166,13 @@ fn compile_block(block: &DecodedBlock) -> CompiledBlock {
; mov rax, QWORD helper
; call rax
// determinism postlude: cycle_count += 1; timebase += 1
; inc QWORD [r15 + off_cycle]
; inc QWORD [r15 + off_timebase]
; 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
; cmp DWORD [r15 + off.pc], expected_next
; jne =>l_cont
);
}

View File

@@ -0,0 +1,223 @@
//! Differential opcode tests: for each natively-emitted opcode, run a random
//! register state through the interpreter (`interpret_one`, then the same
//! per-instruction counter bump `step_block` does) and through a JIT-compiled
//! one-instruction block, and assert the resulting `PpcContext` (GPRs, PC,
//! XER, CR, cycle/timebase) and `StepResult` are identical. This makes the
//! hand-written x64 as trustworthy as the interpreter.
use super::{CompiledBlock, compile_block, run_jit_block};
use crate::emit;
use xenia_cpu::block_cache::DecodedBlock;
use xenia_cpu::context::PpcContext;
use xenia_cpu::decode;
use xenia_cpu::decoder::DecodedInstr;
use xenia_cpu::interpreter::interpret_one;
use xenia_memory::MemoryAccess;
/// Memory that must never be touched — the ALU opcodes tested here don't
/// access guest memory, so any call is a bug in the emitter.
struct NoMem;
impl MemoryAccess for NoMem {
fn read_u8(&self, _: u32) -> u8 {
unreachable!("ALU emitter touched memory (read_u8)")
}
fn read_u16(&self, _: u32) -> u16 {
unreachable!("ALU emitter touched memory (read_u16)")
}
fn read_u32(&self, _: u32) -> u32 {
unreachable!("ALU emitter touched memory (read_u32)")
}
fn read_u64(&self, _: u32) -> u64 {
unreachable!("ALU emitter touched memory (read_u64)")
}
fn write_u8(&self, _: u32, _: u8) {
unreachable!("ALU emitter touched memory (write_u8)")
}
fn write_u16(&self, _: u32, _: u16) {
unreachable!("ALU emitter touched memory (write_u16)")
}
fn write_u32(&self, _: u32, _: u32) {
unreachable!("ALU emitter touched memory (write_u32)")
}
fn write_u64(&self, _: u32, _: u64) {
unreachable!("ALU emitter touched memory (write_u64)")
}
fn translate(&self, _: u32) -> Option<*const u8> {
None
}
fn translate_mut(&self, _: u32) -> Option<*mut u8> {
None
}
}
// ---- instruction encoders (PPC big-endian field layout; `raw` is the u32) ----
/// D-form: `op | b6_10<<21 | b11_15<<16 | imm16`.
fn enc_d(op: u32, b6_10: u32, b11_15: u32, imm16: u16) -> u32 {
(op << 26) | (b6_10 << 21) | (b11_15 << 16) | (imm16 as u32)
}
/// X-form (logical register): `op | b6_10<<21 | b11_15<<16 | b16_20<<11 | xo<<1 | rc`.
fn enc_x(op: u32, b6_10: u32, b11_15: u32, b16_20: u32, xo: u32, rc: u32) -> u32 {
(op << 26) | (b6_10 << 21) | (b11_15 << 16) | (b16_20 << 11) | (xo << 1) | rc
}
/// XO-form (arithmetic register): X-form plus the OE bit at position 10.
fn enc_xo(op: u32, b6_10: u32, b11_15: u32, b16_20: u32, oe: u32, xo: u32, rc: u32) -> u32 {
(op << 26) | (b6_10 << 21) | (b11_15 << 16) | (b16_20 << 11) | (oe << 10) | (xo << 1) | rc
}
/// Deterministic PRNG (splitmix64-ish) so failures are reproducible.
fn rng(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn ctx_from_gpr(gpr: [u64; 32], pc: u32) -> PpcContext {
let mut c = PpcContext::new();
c.gpr = gpr;
c.pc = pc;
c
}
/// Assert the JIT emits `raw` natively and reproduces the interpreter exactly
/// for the given register seed.
fn check(raw: u32, gpr: [u64; 32]) {
let pc = 0x8200_1000u32;
let instr: DecodedInstr = decode(raw, pc);
let off = emit::Offsets::resolve();
// Guard: the opcode must actually be natively emitted (else this test is
// vacuous — a fallback would trivially match).
let mut probe = dynasmrt::x64::Assembler::new().unwrap();
assert!(
emit::try_emit_native(&mut probe, &off, &instr),
"opcode not natively emitted for raw={raw:#010x} ({:?})",
instr.opcode
);
// Interpreter reference: interpret_one, then the counter bump step_block does.
let mem = NoMem;
let mut a = ctx_from_gpr(gpr, pc);
let ra = interpret_one(&mut a, &mem, &instr);
a.cycle_count += 1;
a.timebase += 1;
// JIT.
let mut b = ctx_from_gpr(gpr, pc);
let block = DecodedBlock {
start_pc: pc,
end_pc: pc.wrapping_add(4),
page_version: 0,
instrs: vec![instr],
sync_sensitive: false,
};
let cb: CompiledBlock = compile_block(&block);
let rb = run_jit_block(&cb, &mut b, &mem);
assert_eq!(a.gpr, b.gpr, "gpr mismatch raw={raw:#010x} ({:?})", instr.opcode);
assert_eq!(a.pc, b.pc, "pc mismatch raw={raw:#010x}");
assert_eq!(a.cycle_count, b.cycle_count, "cycle mismatch raw={raw:#010x}");
assert_eq!(a.timebase, b.timebase, "timebase mismatch raw={raw:#010x}");
assert_eq!(a.xer_ca, b.xer_ca, "xer_ca mismatch raw={raw:#010x}");
assert_eq!(a.xer_ov, b.xer_ov, "xer_ov mismatch raw={raw:#010x}");
assert_eq!(a.xer_so, b.xer_so, "xer_so mismatch raw={raw:#010x}");
assert_eq!(ra, rb, "StepResult mismatch raw={raw:#010x}");
}
/// Random register seeds, including hard boundary values in a few slots.
fn fuzz_gpr(seed: &mut u64) -> [u64; 32] {
let mut g = [0u64; 32];
for slot in g.iter_mut() {
*slot = rng(seed);
}
// Sprinkle in edge values.
g[0] = 0;
g[3] = 0xFFFF_FFFF;
g[4] = 0x8000_0000;
g[5] = 0xFFFF_FFFF_FFFF_FFFF;
g[6] = 0x0000_0000_8000_0000;
g
}
const ITERS: usize = 2000;
#[test]
fn addi_matches() {
let mut s = 0x1234u64;
for _ in 0..ITERS {
let g = fuzz_gpr(&mut s);
let rd = (rng(&mut s) % 32) as u32;
let ra = (rng(&mut s) % 32) as u32;
let imm = rng(&mut s) as u16;
check(enc_d(14, rd, ra, imm), g); // addi
check(enc_d(15, rd, ra, imm), g); // addis
}
}
#[test]
fn logical_imm_matches() {
let mut s = 0x5678u64;
for _ in 0..ITERS {
let g = fuzz_gpr(&mut s);
let rs = (rng(&mut s) % 32) as u32;
let ra = (rng(&mut s) % 32) as u32;
let uimm = rng(&mut s) as u16;
check(enc_d(24, rs, ra, uimm), g); // ori
check(enc_d(25, rs, ra, uimm), g); // oris
check(enc_d(26, rs, ra, uimm), g); // xori
check(enc_d(27, rs, ra, uimm), g); // xoris
}
}
#[test]
fn logical_reg_matches() {
let mut s = 0x9abcu64;
for _ in 0..ITERS {
let g = fuzz_gpr(&mut s);
let rs = (rng(&mut s) % 32) as u32;
let ra = (rng(&mut s) % 32) as u32;
let rb = (rng(&mut s) % 32) as u32;
check(enc_x(31, rs, ra, rb, 444, 0), g); // or
check(enc_x(31, rs, ra, rb, 28, 0), g); // and
check(enc_x(31, rs, ra, rb, 316, 0), g); // xor
}
}
#[test]
fn arith_reg_matches() {
let mut s = 0xdef0u64;
for _ in 0..ITERS {
let g = fuzz_gpr(&mut s);
let rd = (rng(&mut s) % 32) as u32;
let ra = (rng(&mut s) % 32) as u32;
let rb = (rng(&mut s) % 32) as u32;
check(enc_xo(31, rd, ra, rb, 0, 266, 0), g); // add
check(enc_xo(31, rd, ra, rb, 0, 40, 0), g); // subf
check(enc_xo(31, rd, ra, rb, 0, 104, 0), g); // neg (rb ignored)
}
}
/// The recording/OE forms and the db16cyc hint must NOT be natively emitted
/// (they fall back to the interpreter). Guards against a future emitter
/// accidentally handling a form it can't reproduce.
#[test]
fn recording_and_hint_forms_fall_back() {
let off = emit::Offsets::resolve();
let mut probe = dynasmrt::x64::Assembler::new().unwrap();
let cases = [
enc_xo(31, 3, 4, 5, 0, 266, 1), // add.
enc_xo(31, 3, 4, 5, 1, 266, 0), // addo
enc_x(31, 3, 4, 5, 444, 1), // or.
0x7FFF_FB78u32, // db16cyc (or r31,r31,r31)
];
for raw in cases {
let instr = decode(raw, 0x8200_1000);
assert!(
!emit::try_emit_native(&mut probe, &off, &instr),
"raw={raw:#010x} ({:?}) should fall back, not native",
instr.opcode
);
}
}