[iterate-4A] diagnostics: XENIA_PROFILE wall-time profiler + probe/tooling snapshot

Handoff snapshot of the env-gated diagnostic scaffolding used across the
intro-video RE. Kept out of the milestone commits (645feb8..5573ac1) to keep
those clean; committed here so nothing is lost on handoff.

New — XENIA_PROFILE wall-time profiler (crates/xenia-gpu/src/prof.rs):
  Coarse buckets attributing playback wall time to interpreter (step_block),
  kernel HLE (call_export), block decode/cache (lookup_or_build), texture
  decode, host draw, and present; prints periodic snapshots (every 500M guest
  instr, or every 500 presents) + a clean-exit report. Hot path is gated on a
  cached is_on() (one relaxed load) so it is zero-cost when XENIA_PROFILE is
  unset. Call sites: main.rs run_superblock / parallel worker (step_block,
  lookup_or_build, call_export), texture_cache ensure_cached, render.rs present
  + dispatch_xenos_draws.

  First profile (movie playback, headless single-thread lockstep): effective
  ~35 MIPS; interpreter body ~40% @ ~95-102 MIPS; texture decode 0.3% (cache
  works); present ~0%; the rest is per-block dispatch + scheduler plumbing
  (~13 instr/block over 229M blocks). Overhead-bound, not interpreter-body
  bound; the levers are coarser execution units (superblock chaining) and
  ultimately a JIT.

Pre-existing read-only probe knobs (were uncommitted; env-gated, observe-only):
  XENIA_RET_CAPTURE_PC/_REG/_MEM, LOG_RESUMES, LOG_WAITS, LOG_SIGNAL,
  FORCE_TID, STARVE_LIMIT, INCUMBENT_PICK, INSTR_PER_MS, DUMP_FRAME,
  DUMP_WGSL, BIND_LOG, CONST_LOG, DISPATCH_REC, AUDIT_PC_TRACE.

Tooling: sylph-run.sh (movie oracle loop, 180s default timeout),
  zq.py (DuckDB disasm/xref helper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-03 07:43:53 +02:00
parent 5573ac1c43
commit 2c883e9d5e
13 changed files with 800 additions and 14 deletions

View File

@@ -15,6 +15,58 @@ use xenia_memory::MemoryAccess;
/// `reserved_line = ea & !RESERVATION_MASK` in [context::PpcContext].
pub const RESERVATION_MASK: u32 = 0x7F;
/// RE diagnostic (milestone-2 intro-video). `XENIA_RET_CAPTURE_PC=0x..`
/// names a guest block-head PC at which to log r3/r4/r5 every time the
/// block is entered. Unlike the block-/slot-level audit probes in
/// `KernelState`, this fires in `step_block` at *every* block entry, so
/// it reaches mid-function return points (e.g. the instruction right
/// after a `bcctrl`) that the slot-visit probes miss. Read-only: only
/// emits a `println!`, mutates no guest state, so a captured run is
/// byte-identical to an unprobed one. 0 (unset env) → inert (one cached
/// `OnceLock` load + a `!= 0` branch per block).
fn ret_capture_pc() -> u32 {
use std::sync::OnceLock;
static RET_CAPTURE_PC: OnceLock<u32> = OnceLock::new();
*RET_CAPTURE_PC.get_or_init(|| {
std::env::var("XENIA_RET_CAPTURE_PC")
.ok()
.and_then(|s| {
let t = s.trim();
u32::from_str_radix(t.trim_start_matches("0x"), 16).ok()
})
.unwrap_or(0)
})
}
/// RE diagnostic companion to [`ret_capture_pc`]. When `XENIA_RET_CAPTURE_MEM=1`
/// the RET-CAPTURE site also dumps the guest words at `r3+0x30 .. r3+0x48`
/// (the decoder *track* ring header: `+0x34` write cursor, `+0x44` read
/// cursor — fill = write read). Read-only (`mem.read_u32` + `println!`).
fn ret_capture_mem() -> bool {
use std::sync::OnceLock;
static RET_CAPTURE_MEM: OnceLock<bool> = OnceLock::new();
*RET_CAPTURE_MEM.get_or_init(|| {
std::env::var("XENIA_RET_CAPTURE_MEM")
.map(|s| s.trim() == "1")
.unwrap_or(false)
})
}
/// Which GPR holds the base pointer for the `XENIA_RET_CAPTURE_MEM` dump.
/// Defaults to r3; set `XENIA_RET_CAPTURE_REG=21` to dump the pointee of r21
/// (e.g. the ring element the pump pops). Read-only.
fn ret_capture_reg() -> usize {
use std::sync::OnceLock;
static RET_CAPTURE_REG: OnceLock<usize> = OnceLock::new();
*RET_CAPTURE_REG.get_or_init(|| {
std::env::var("XENIA_RET_CAPTURE_REG")
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n < 32)
.unwrap_or(3)
})
}
/// Result of executing a single instruction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepResult {
@@ -97,6 +149,48 @@ pub fn step_block(
mem: &dyn MemoryAccess,
block: &crate::block_cache::DecodedBlock,
) -> StepResult {
// RE diagnostic: log r3/r4/r5 at a configured block-head PC (e.g. the
// return point after a `bcctrl`). Read-only; inert unless the env is set.
let cap = ret_capture_pc();
if cap != 0 && block.start_pc == cap {
// KeTimeStampBundle tick_count (ms): [([0x820007D0]) + 0x10]. Read-only;
// lets a capture correlate guest progress with the guest-visible clock.
let bundle = mem.read_u32(0x820007D0);
let tick_ms = if bundle != 0 { mem.read_u32(bundle.wrapping_add(0x10)) } else { 0 };
println!("RET-CAPTURE-TICK pc={:#010x} tid={} tick_ms={} cycle={}", block.start_pc, ctx.thread_id, tick_ms, ctx.cycle_count);
println!(
"RET-CAPTURE pc={:#010x} tid={} r3={:#010x} r4={:#010x} r5={:#010x} r21={:#010x} lr={:#010x} cycle={}",
block.start_pc,
ctx.thread_id,
ctx.gpr[3] as u32,
ctx.gpr[4] as u32,
ctx.gpr[5] as u32,
ctx.gpr[21] as u32,
ctx.lr as u32,
ctx.cycle_count,
);
if ret_capture_mem() {
let reg = ret_capture_reg();
let base = ctx.gpr[reg] as u32;
let w = |off: u32| mem.read_u32(base.wrapping_add(off));
// Dump +0x00 (vtable ptr) .. +0x48 so callers can inspect either a
// ring element (60-byte struct, vtable at +0) or the track header
// (+0x34 write / +0x44 read cursors).
println!(
"RET-CAPTURE-MEM r{} base={:#010x} +00={:#010x} +04={:#010x} +08={:#010x} +0c={:#010x} +10={:#010x} +14={:#010x} +18={:#010x} +1c={:#010x} +20={:#010x} +24={:#010x} +28={:#010x} +2c={:#010x} +30={:#010x} +34={:#010x} +38={:#010x} +3c={:#010x} +40={:#010x} +44={:#010x} +48={:#010x}",
reg, base,
w(0x00), w(0x04), w(0x08), w(0x0c), w(0x10), w(0x14), w(0x18), w(0x1c),
w(0x20), w(0x24), w(0x28), w(0x2c), w(0x30), w(0x34), w(0x38), w(0x3c),
w(0x40), w(0x44), w(0x48),
);
// Extra slots: stack locals [r1+0x50]/[r1+0x54] when reg=1; engine
// worker-resume gate +0xF0 (pending flag) / +0x110 (worker handle).
println!(
"RET-CAPTURE-MEM2 r{} base={:#010x} +50={:#010x} +54={:#010x} +f0={:#010x} +110={:#010x}",
reg, base, w(0x50), w(0x54), w(0xF0), w(0x110),
);
}
}
let mut result = StepResult::Continue;
for instr in &block.instrs {
let expected_next = instr.addr.wrapping_add(4);

View File

@@ -49,10 +49,27 @@ pub const QUANTUM_DEFAULT: u32 = 50_000;
/// guarantees *bounded* forward progress, it does not invert priority.
pub const STARVE_LIMIT: u32 = 4096;
/// Toggle for the `pick_runnable` incumbent-preference tiebreak (the fix).
/// Default on. `XENIA_INCUMBENT_PICK=0` reverts to the old lowest-index
/// tiebreak — a rollback knob to A/B the fix's effect on boot rendering vs
/// the movie feeder-starvation cure. Inert unless explicitly set to 0.
/// RE diagnostic: `XENIA_STARVE_LIMIT=<n>` overrides [`STARVE_LIMIT`] at
/// runtime (cached). Lets a fairness test boost starved co-located threads
/// (e.g. the feeder tid24 on hw=1) much sooner without a full monopoly —
/// distinguishing "fair scheduling fixes the deadlock" from "needs more".
/// Unset → the compiled 4096 default (inert).
fn starve_limit() -> u32 {
use std::sync::OnceLock;
static SL: OnceLock<u32> = OnceLock::new();
*SL.get_or_init(|| {
std::env::var("XENIA_STARVE_LIMIT")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|&n| n > 0)
.unwrap_or(STARVE_LIMIT)
})
}
/// Diagnostic A/B toggle for the `pick_runnable` incumbent-preference
/// tiebreak. Default on (the fix). `XENIA_INCUMBENT_PICK=0` reverts to the
/// old lowest-index tiebreak, to measure the fix's effect on boot rendering
/// vs the movie feeder-starvation cure. Inert unless explicitly set to 0.
fn incumbent_pick() -> bool {
use std::sync::OnceLock;
static IP: OnceLock<bool> = OnceLock::new();
@@ -200,6 +217,28 @@ pub enum HwState {
ServicingIrq(BlockReason),
}
/// RE diagnostic: gate for the `XENIA_LOG_WAITS=1` `PARK` logger in
/// [`Scheduler::park_current`]. Cached; inert unless the env is set.
fn log_waits_enabled() -> bool {
use std::sync::OnceLock;
static EN: OnceLock<bool> = OnceLock::new();
*EN.get_or_init(|| std::env::var("XENIA_LOG_WAITS").is_ok())
}
/// RE diagnostic: `XENIA_FORCE_TID=<n>` — guest tid that `pick_runnable`
/// always prefers when Ready (un-starve test for the feeder deadlock).
/// 0 / unset = inert. Cached.
fn force_tid() -> u32 {
use std::sync::OnceLock;
static FT: OnceLock<u32> = OnceLock::new();
*FT.get_or_init(|| {
std::env::var("XENIA_FORCE_TID")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0)
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockReason {
Suspended,
@@ -274,6 +313,20 @@ impl HwSlot {
/// `STARVE_LIMIT` visits). The boost is a pure function of the per-thread
/// counters/priority/index, so picks stay deterministic.
pub fn pick_runnable(&self) -> Option<usize> {
// RE diagnostic (milestone-2 intro-video). `XENIA_FORCE_TID=<n>` makes
// this slot always prefer the named guest tid whenever it is Ready on
// this slot — fully un-starving it. Used to separate "tid is starved"
// from "tid is genuinely stuck" for the feeder tid24 deadlock. Slots
// not carrying that tid fall through to normal selection. Behaviour-
// changing, so strictly env-gated (inert when unset).
let force = force_tid();
if force != 0 {
if let Some((i, _)) = self.runqueue.iter().enumerate().find(|(_, t)| {
t.tid == force && matches!(t.state, HwState::Ready | HwState::ServicingIrq(_))
}) {
return Some(i);
}
}
// Tiebreak among equal-effective-priority Ready threads PREFERS THE
// INCUMBENT (`running_idx`) over the lowest index. `decrement_quantum`
// rotates `running_idx` to the next same-priority peer when the 50k
@@ -307,7 +360,7 @@ impl HwSlot {
/// visits is lifted to `i32::MAX` so it wins the next pick regardless of
/// peer priority; otherwise its nominal priority is used unchanged.
fn effective_priority(t: &GuestThread) -> i32 {
if t.steps_starved >= STARVE_LIMIT {
if t.steps_starved >= starve_limit() {
i32::MAX
} else {
t.priority
@@ -991,7 +1044,7 @@ impl Scheduler {
if i == me {
t.steps_starved = 0;
} else if matches!(t.state, HwState::Ready | HwState::ServicingIrq(_)) {
t.steps_starved = STARVE_LIMIT;
t.steps_starved = starve_limit();
promoted = true;
}
}
@@ -1015,6 +1068,18 @@ impl Scheduler {
self.timed_waits.push((d, r));
self.timed_waits.sort_by_key(|&(d, _)| d);
}
// RE diagnostic (milestone-2 intro-video). `XENIA_LOG_WAITS=1` logs a
// `PARK` line (tid, pc, cycle, block reason incl. waited handles) every
// time a thread blocks — the symmetric counterpart of the resume log,
// for pinning the producer↔consumer deadlock (what does the feeder
// tid24 wait on after its one pass). Read-only diagnostic.
if log_waits_enabled() {
let t = self.thread_mut(r);
println!(
"PARK tid={} pc={:#010x} cycle={} reason={:?}",
t.tid, t.ctx.pc, t.ctx.cycle_count, reason,
);
}
self.thread_mut(r).state = HwState::Blocked(reason);
self.recompute_slot_runnable(r.hw_id);
}