Compare commits

...

4 Commits

Author SHA1 Message Date
MechaCat02
77c2d7bce9 [iterate-4B] spike: multi-core feasibility measurement (concurrency probe)
Throwaway diagnostic for the multi-core-vs-JIT decision. Two real numbers:

1. Existing --parallel is ~25x SLOWER, not faster: 100.9s wall vs 4.0s
   lockstep at -n 200M --gpu-inline (237s user across 6 threads burned on
   coarse-mutex + phaser contention). Naive coarse-locking inverts the win.

2. XENIA_CONCURRENCY_PROBE (env-gated, zero cost off): per-round histogram
   of runnable HW-slot width. Sylpheed at -n 300M: AVG WIDTH = 3.83
   (width>=4 in 77% of rounds) => a *perfect* host-thread-per-guest-thread
   design has a ~3.8x Amdahl ceiling. The parallelism is real; the existing
   vehicle just can't capture it.

Golden n200m byte-identical (probe inert unless env set).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:10:07 +02:00
MechaCat02
9851873e42 [iterate-4B] perf: quick hot-path wins (bulk quantum-decrement + ctx coalesce)
Two byte-identical interpreter-dispatch optimizations (headless golden
n200m unchanged). Together ~13% faster on the -n 200M --gpu-inline
benchmark (4.33s -> ~3.8s, ~46 -> ~53 MIPS).

- scheduler.rs: `decrement_quantum_by(n)` — the superblock epilogue looped
  `for _ in 0..executed { decrement_quantum() }` (~one bounds-checked call
  per retired guest instruction, the largest fixed per-superblock cost).
  QUANTUM_DEFAULT (50k) >> a superblock's instr count, so the quantum
  boundary is crossed at most once/call: common path is one subtraction,
  the rare boundary step defers to decrement_quantum for exact rotation
  (reload + same-priority peer hand-off) semantics. Byte-identical.
- main.rs run_superblock: resolve the running thread's PpcContext ONCE per
  block (was three `ctx_mut_ref` double-indexed slot lookups: cycle-before,
  the step, cycle-after).

Profiled remaining breakdown (-n 300M --gpu-inline): step_block body 50%
(67 MIPS, the JIT target), block decode/cache 9% (page-version-bound),
kernel HLE 8%, scheduler/lock remainder ~31%. Sub-10% cheap wins remain
(block-linking is page-version-gated so only partial); the 5-10x lift is
the JIT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:03:46 +02:00
MechaCat02
913b566a26 [iterate-4B] A.5: threaded GPU under --ui (opt-in XENIA_UI_GPU_THREAD)
Moves the per-frame GPU work (PM4 drain: draws, YUV texture decodes,
resolves — ~12 ms/swap) AND the per-swap UI publish off the emulation
thread onto the GPU worker thread when --ui is combined with
XENIA_UI_GPU_THREAD=1. This is the fix for the dominant remaining --ui
cost identified after A.1 (the inline drain at exports.rs:3145).

Default OFF: plain --ui still forces the inline backend (the safe,
milestone-2-verified path), so this cannot regress the shipped result.

- xenia-gpu/handle.rs: new UiPublishHooks + WorkerSwapInfo (pure xenia-gpu
  types so the worker needs no kernel dep); GpuCommand::InstallUiHooks;
  GpuWorker gains ui_hooks + last_published_swaps; the worker runs
  GpuSystem::run_ui_publish (blobs/constants/texture/geometry + bulk
  frontbuffer detile + notify) level-triggered on stats.swaps_seen.
- xenia-kernel/exports.rs: vd_swap returns immediately in threaded+ui mode
  (no blocking drain, no emulation-thread publish); the worker discovers
  the in-stream PM4_XE_SWAP and publishes. Inline/headless paths untouched.
- xenia-app/main.rs: XENIA_UI_GPU_THREAD knob; run_with_ui takes
  Arc<GuestMemory> (removes the old --ui+gpu-thread try_unwrap panic);
  builds the publish hooks from the live UiBridge and installs them on the
  worker; enables frame capture on the worker's GpuSystem before spawn.

Headless golden sylpheed_n200m byte-identical (inline path unchanged);
threaded headless smoke clean. Threaded --ui visual correctness pending
human verification (no self-screenshots).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:24:59 +02:00
MechaCat02
79d0026a31 [iterate-4B] --ui perf: bulk frontbuffer detile + present knob + shader/blob caches
Windowed (--ui) mode forces inline GPU, so VdSwap's per-swap UI publish runs
on the emulation thread — profiled at ~87% of it (~4 MIPS effective vs ~35
headless). This lands the low-risk, headless-untouched wins (golden n200m
byte-identical):

- A.1 (biggest): VdSwap frontbuffer detile now uses one bounded GuestMemory
  ::read_bulk instead of ~3.7MB of per-byte read_u8 through the MMIO handler
  (~15 ms/swap). Bounds-checked to stay in the committed backing window.
- A.4: XENIA_PRESENT_MODE (immediate|mailbox|fifo) + XENIA_FRAME_LATENCY knobs
  (render.rs); default (Mailbox-else-Fifo, latency 2) unchanged.
- A.3a: cache parse_shader/pack_for_wgsl per blob key on RenderState instead
  of re-parsing every draw every frame (blobs are immutable) — the
  movie-relevant UI-thread win.
- A.2: publish shader-blob map to the UI only when it changed
  (shader_blobs_version on GpuSystem; publish_xenos_assets blobs arg is now
  Option, None = keep previous). Constants still published every swap.

Deferred (profiling-justified — target the measured bottlenecks, not these):
- A.2 texture-gate, A.3b bind-group cache: zero benefit for the movie (its
  texture keys rotate every frame → always-miss) + staleness/leak risk.
- A.3c submit-batching: the UI thread's bottleneck is the vsync-blocked
  present, not per-draw submits; GPUBUG-111 regression risk not justified.
- A.5 (threaded GPU under --ui): the structural win; separate follow-up
  (needs the publish bridge moved to the worker + human visual verification).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:21:51 +02:00
9 changed files with 586 additions and 70 deletions

View File

@@ -966,8 +966,21 @@ fn cmd_exec_inner(
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes"
});
let force_inline = gpu_inline || env_inline || ui;
let force_thread = gpu_thread || env_thread;
// A.5 — opt-in threaded GPU under `--ui`. Off by default: `--ui` still
// forces the inline backend (the safe, milestone-verified path). When
// `XENIA_UI_GPU_THREAD=1` is set alongside `--ui`, the GPU command
// processing + per-swap UI publish move to the worker thread, freeing the
// emulation thread from the ~12 ms/frame inline PM4 drain. See
// `run_with_ui` (hook install) and `GpuSystem::run_ui_publish`.
let env_ui_thread = std::env::var("XENIA_UI_GPU_THREAD")
.ok()
.is_some_and(|v| {
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes"
});
let ui_threaded = ui && env_ui_thread;
let force_inline = gpu_inline || env_inline || (ui && !ui_threaded);
let force_thread = gpu_thread || env_thread || ui_threaded;
let use_threaded = if force_inline {
false
} else if force_thread {
@@ -1768,8 +1781,17 @@ fn cmd_exec_inner(
// `xenia_gpu::handle::GpuWorker::run` for the concurrency model.
// M1.3's `spawn_noop_worker` is now superseded for the threaded
// path; the no-op helper is retained for unit tests.
let gpu_thread_resources = if let Some(worker) = maybe_gpu_worker.take() {
let gpu_thread_resources = if let Some(mut worker) = maybe_gpu_worker.take() {
info!("gpu: threaded backend — spawning worker thread");
// A.5 threaded `--ui`: the UI replays real per-draw geometry, so the
// worker's `GpuSystem` needs frame capture on before it starts
// draining. (Inline `--ui` enables this inside `run_with_ui` via
// `as_inline_mut`; the threaded worker owns the system exclusively
// once spawned, so we flip it here first.) Harmless in headless
// threaded mode — `ui` is false there.
if ui {
worker.system.enable_frame_capture();
}
let join = xenia_gpu::spawn_gpu_worker(worker, mem_arc.clone());
Some((shutdown_arc.clone(), join))
} else {
@@ -1791,17 +1813,14 @@ fn cmd_exec_inner(
let result = if ui {
run_with_ui(
path,
// `run_with_ui` consumes `GuestMemory` by value today; M1.4
// keeps that path on the inline backend until the UI worker
// is migrated to the Arc-shared model. Recover ownership via
// `Arc::try_unwrap` — succeeds because the GPU worker is not
// spawned in inline mode (`maybe_gpu_worker` is `None`).
std::sync::Arc::try_unwrap(mem_arc).unwrap_or_else(|_| {
panic!(
"M1.4: --ui + --gpu-thread cohabitation not yet wired; \
choose one"
)
}),
// A.5: `run_with_ui` now takes the shared `Arc<GuestMemory>`
// directly (previously it recovered sole ownership via
// `Arc::try_unwrap`, which panicked if a GPU worker held a
// clone). Both the CPU worker inside `run_with_ui` and the GPU
// worker (threaded `--ui`) borrow `&*mem_arc`; writes are `&self`
// post the M1.4(b) trait flip, so concurrent disjoint-range
// access is sound.
mem_arc.clone(),
kernel,
debugger,
thunk_map,
@@ -2927,9 +2946,9 @@ fn worker_epilogue(
stats.instruction_count = stats.instruction_count.wrapping_add(executed);
for _ in 0..executed {
kernel.scheduler.decrement_quantum();
}
// PERF: byte-identical bulk decrement (was `for _ in 0..executed`), the
// largest fixed per-superblock cost — see `Scheduler::decrement_quantum_by`.
kernel.scheduler.decrement_quantum_by(executed);
match result {
StepResult::Continue => {}
@@ -3123,19 +3142,19 @@ fn run_superblock(
let mut total_executed: u64 = 0;
let (result, last_block_ptr, last_pc_before) = loop {
let cycle_before = kernel.scheduler.ctx_mut_ref(thread_ref).cycle_count;
let mmio_before = mem.mmio_access_count();
let block = unsafe { &*block_ptr };
let _prof_t0 = xenia_gpu::prof::is_on().then(std::time::Instant::now);
let result = {
// PERF: resolve the running thread's context ONCE per block (was three
// `ctx_mut_ref` slot lookups — for cycle-before, the step, and
// cycle-after — each a double bounds-checked index). Byte-identical.
let (result, executed) = {
let ctx = kernel.scheduler.ctx_mut_ref(thread_ref);
step_block(ctx, mem, block)
let cycle_before = ctx.cycle_count;
let result = step_block(ctx, mem, block);
let executed = ctx.cycle_count.saturating_sub(cycle_before);
(result, executed)
};
let executed = kernel
.scheduler
.ctx_mut_ref(thread_ref)
.cycle_count
.saturating_sub(cycle_before);
if let Some(t0) = _prof_t0 {
use xenia_gpu::prof;
prof::add(&prof::STEP_NS, t0.elapsed().as_nanos() as u64);
@@ -3277,6 +3296,17 @@ fn run_execution(
// loop doesn't heap-allocate a `Vec<u8>` every iteration.
let mut order_buf = [0u8; xenia_cpu::scheduler::HW_THREAD_COUNT];
// SPIKE (multi-core feasibility, 2026-07-03): env-gated histogram of the
// per-round "runnable width" — how many HW slots hold a Ready thread at
// once. The average width bounds the best-case multi-core speedup (Amdahl):
// lockstep runs the round's slots serially; a perfect host-thread-per-guest
// design runs them concurrently, so wall shrinks by ~avg-width. Zero cost
// unless `XENIA_CONCURRENCY_PROBE` is set. Throwaway diagnostic.
let concurrency_probe = std::env::var("XENIA_CONCURRENCY_PROBE").is_ok();
let mut width_hist = [0u64; xenia_cpu::scheduler::HW_THREAD_COUNT + 1];
let mut rounds_with_work = 0u64;
let mut slot_visits = 0u64;
'outer: loop {
// Per-round prologue: budget / shutdown / heartbeat / vsync /
// timers / audio-interrupt injection. Carved into
@@ -3338,6 +3368,14 @@ fn run_execution(
let order_n = kernel.scheduler.round_schedule_into(&mut order_buf);
let order = &order_buf[..order_n];
if concurrency_probe {
width_hist[order_n] += 1;
if order_n > 0 {
rounds_with_work += 1;
slot_visits += order_n as u64;
}
}
if order.is_empty() {
// No Ready threads — advance time to the earliest pending
// deadline, fire timers, handle deadline wakes, and on hard
@@ -3413,6 +3451,31 @@ fn run_execution(
RoundCtl::Continue => {}
}
}
if concurrency_probe {
let avg_width = if rounds_with_work > 0 {
slot_visits as f64 / rounds_with_work as f64
} else {
0.0
};
eprintln!("=== XENIA_CONCURRENCY_PROBE (multi-core Amdahl ceiling) ===");
for (w, &c) in width_hist.iter().enumerate() {
let pct = if rounds_with_work + width_hist[0] > 0 {
100.0 * c as f64 / (rounds_with_work + width_hist[0]) as f64
} else {
0.0
};
eprintln!(" runnable-width {w}: {c:>12} rounds ({pct:>5.1}%)");
}
eprintln!(
" rounds_with_work={rounds_with_work} slot_visits={slot_visits} \
idle_rounds={}",
width_hist[0]
);
eprintln!(
" AVG RUNNABLE WIDTH = {avg_width:.3} => best-case multi-core \
speedup ceiling ~= {avg_width:.2}x (concurrent slots / round)"
);
}
stats
}
@@ -4878,7 +4941,7 @@ fn dump_thread_diagnostic(
#[instrument(skip_all, fields(title))]
fn run_with_ui(
title: &str,
mut mem: xenia_memory::GuestMemory,
mem: std::sync::Arc<xenia_memory::GuestMemory>,
mut kernel: xenia_kernel::KernelState,
mut debugger: xenia_debugger::Debugger,
thunk_map: HashMap<u32, (ModuleId, u16, String)>,
@@ -4895,10 +4958,68 @@ fn run_with_ui(
.build()
.map_err(|e| anyhow::anyhow!("winit event loop build failed: {e}"))?;
let (ui_handles, kernel_bridge) = xenia_ui::build(event_loop.create_proxy());
// A.5 threaded `--ui`: if the GPU runs on the worker thread, the worker
// owns the `GpuSystem`, so the per-swap UI publish must run there. Build
// the publish closures (mapped into `xenia-gpu` types) from the live
// bridge and install them on the worker via the command channel. No-op on
// the inline backend — `install_ui_hooks` only sends under
// `GpuBackend::Threaded`, and inline `--ui` keeps publishing directly from
// `vd_swap`. Built from `&kernel_bridge` before it moves into `kernel.ui`.
{
use std::sync::atomic::Ordering;
let instr = std::sync::Arc::clone(&ui_handles.instructions_counter);
let post = std::sync::Arc::clone(&kernel_bridge.post_swap);
let hooks = xenia_gpu::UiPublishHooks {
publish_assets: std::sync::Arc::clone(&kernel_bridge.publish_xenos_assets),
publish_texture: std::sync::Arc::clone(&kernel_bridge.publish_texture),
publish_geometry: std::sync::Arc::clone(&kernel_bridge.publish_geometry),
publish_frontbuffer: std::sync::Arc::clone(
&kernel_bridge.publish_frontbuffer,
),
notify_swap: std::sync::Arc::new(
move |w: xenia_gpu::WorkerSwapInfo,
m: &dyn xenia_memory::MemoryAccess| {
let info = xenia_kernel::SwapInfo {
frontbuffer_addr: w.frontbuffer_addr,
width: w.width,
height: w.height,
// HUD-only; the worker can't see the guest fetch
// pointers `vd_swap` reads these from.
texture_format: 0,
color_space: 0,
frame_index: w.frame_index,
draws_total: w.draws_total,
packets_total: w.packets_total,
last_draw_prim: w.last_draw_prim,
last_draw_vertex_count: w.last_draw_vertex_count,
indirect_buffer_jumps: w.indirect_buffer_jumps,
wait_reg_mem_blocks: w.wait_reg_mem_blocks,
instructions_total: instr.load(Ordering::Relaxed),
vs_blob_key: w.vs_blob_key,
ps_blob_key: w.ps_blob_key,
resolves_total: w.resolves_total,
resolves_copied_total: w.resolves_copied_total,
resolves_skipped_total: w.resolves_skipped_total,
unique_render_targets: w.unique_render_targets,
// HUD-only; kernel interrupt bookkeeping isn't visible
// to the worker on this path.
interrupts_delivered: 0,
interrupts_dropped: 0,
};
(post)(info, m);
},
),
};
kernel.gpu.install_ui_hooks(hooks);
}
kernel.ui = Some(kernel_bridge);
// iterate-3O: enable per-draw geometry capture so the UI can replay real
// guest draws. Only on the `--ui` path; headless `check` never gets here,
// so the deterministic core/golden stays untouched.
// so the deterministic core/golden stays untouched. Threaded `--ui`
// enables capture on the worker's `GpuSystem` before spawn (see
// `cmd_exec_inner`); this covers the inline backend.
if let Some(gpu) = kernel.gpu.as_inline_mut() {
gpu.enable_frame_capture();
}
@@ -4911,12 +5032,15 @@ fn run_with_ui(
.to_string();
let worker_span = tracing::info_span!("cpu_worker");
// A.5: the CPU worker borrows the shared `Arc<GuestMemory>` (`&*mem_w`)
// instead of owning the buffer, so a threaded GPU worker can share it.
let mem_w = std::sync::Arc::clone(&mem);
let worker = std::thread::Builder::new()
.name("xenia-cpu".into())
.spawn(move || -> Result<(ExecStats, xenia_memory::GuestMemory, xenia_kernel::KernelState, xenia_debugger::Debugger, Option<xenia_analysis::DbWriter>)> {
.spawn(move || -> Result<(ExecStats, xenia_kernel::KernelState, xenia_debugger::Debugger, Option<xenia_analysis::DbWriter>)> {
let _guard = worker_span.enter();
let stats = run_execution(
&mut mem,
&mem_w,
&mut kernel,
&mut debugger,
&thunk_map,
@@ -4930,7 +5054,7 @@ fn run_with_ui(
if let Some(ref mut db) = db_writer {
db.finalize_traces()?;
}
Ok((stats, mem, kernel, debugger, db_writer))
Ok((stats, kernel, debugger, db_writer))
})
.map_err(|e| anyhow::anyhow!("spawn CPU worker: {e}"))?;
@@ -4938,7 +5062,7 @@ fn run_with_ui(
// flips the shutdown flag itself (e.g. after max_instructions).
xenia_ui::run(event_loop, ui_handles, &title_owned)?;
let (stats, mem, kernel, debugger, db_writer) = match worker.join() {
let (stats, kernel, debugger, db_writer) = match worker.join() {
Ok(res) => res?,
Err(_) => {
return Err(anyhow::anyhow!("CPU worker thread panicked"));
@@ -4946,7 +5070,7 @@ fn run_with_ui(
};
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, &mem, quiet);
dump_thread_diagnostic(&kernel, &*mem, quiet);
info!(
wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count,

View File

@@ -1016,6 +1016,52 @@ impl Scheduler {
false
}
/// Bulk equivalent of calling [`Self::decrement_quantum`] exactly `n`
/// times, producing a **byte-identical** final scheduler state. PERF: the
/// superblock epilogue used to loop `for _ in 0..executed { decrement_quantum() }`
/// — up to ~128 bounds-checked calls per superblock, ~one per retired guest
/// instruction across the whole run (the single largest fixed per-superblock
/// cost). Since `QUANTUM_DEFAULT` (50_000) ≫ a superblock's instruction
/// count, the quantum boundary is crossed at most once per call, so the
/// common path is a single subtraction (O(1)); only the rare
/// boundary-crossing step defers to `decrement_quantum` to reproduce the
/// exact rotation semantics (reload + same-priority peer hand-off).
pub fn decrement_quantum_by(&mut self, mut n: u64) {
while n > 0 {
let Some(r) = self.current else {
return;
};
let Some(t) = self.slots[r.hw_id as usize]
.runqueue
.get_mut(r.idx as usize)
else {
return;
};
let q = t.quantum_remaining as u64;
if q > n {
// No quantum boundary within these `n` steps — the common
// case. Identical to `n` plain decrements that each hit the
// early `quantum_remaining != 0` return.
t.quantum_remaining = (q - n) as u32;
return;
}
// A rotation (quantum reload + optional peer hand-off) occurs
// within these `n` steps. Collapse the `q` leading no-op
// decrements into one rotating single-step: set the quantum to 1
// so the next `decrement_quantum` drives it to 0 and rotates with
// identical semantics (for `q == 0` the first single-step already
// rotates, consuming exactly one step).
let consumed = if q > 0 {
t.quantum_remaining = 1;
q
} else {
1
};
self.decrement_quantum();
n -= consumed;
}
}
/// Cooperative yield: the currently-running thread executed a `db16cyc`
/// spin-wait hint (see `StepResult::Yield`). It is busy-spinning on a
/// guest spinlock/barrier whose release depends on a *co-located* peer

View File

@@ -369,6 +369,14 @@ pub struct GpuSystem {
ib_stack: Vec<RingBufferView>,
/// Cached shader blobs keyed by the raw CP register address that loaded them.
pub shader_blobs: HashMap<u32, ShaderBlob>,
/// PERF (--ui): monotonic counter bumped on every `shader_blobs`
/// mutation (insert / overwrite / evict). `vd_swap` compares it against
/// `last_published_blobs_version` to skip re-cloning + re-publishing the
/// whole blob map to the UI when nothing changed this swap.
pub shader_blobs_version: u64,
/// Version last handed to the UI bridge (see above). Starts at `u64::MAX`
/// so the very first swap always publishes.
pub last_published_blobs_version: u64,
/// P8 — FIFO of blob keys for bounded eviction. On `IM_LOAD*` the
/// new key is pushed to the back; if the blob count exceeds
/// [`SHADER_BLOB_CAP`], the front is popped and removed from
@@ -451,6 +459,8 @@ impl GpuSystem {
ring: RingBufferView::new(),
ib_stack: Vec::new(),
shader_blobs: HashMap::new(),
shader_blobs_version: 0,
last_published_blobs_version: u64::MAX,
shader_blob_order: std::collections::VecDeque::with_capacity(SHADER_BLOB_CAP + 1),
swap_counter: 0,
last_swap: None,
@@ -486,6 +496,9 @@ impl GpuSystem {
/// Never evicts the currently-active VS/PS blobs (if they ended up at
/// the front of the queue, we skip past them).
fn insert_shader_blob(&mut self, key: u32, blob: ShaderBlob) {
// PERF (--ui): every path through here mutates the published map
// (insert/overwrite above, eviction below), so bump once here.
self.shader_blobs_version = self.shader_blobs_version.wrapping_add(1);
let already_present = self.shader_blobs.contains_key(&key);
self.shader_blobs.insert(key, blob);
if !already_present {

View File

@@ -24,6 +24,7 @@
//! `into_handle` on the live `KernelState.gpu` — the constructor exists for
//! the unit test below and for the synthetic-test path.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
@@ -33,7 +34,10 @@ use crossbeam_channel::{Receiver, Sender, bounded, unbounded};
use xenia_memory::GuestMemory;
use crate::draw_capture::DrawCapture;
use crate::gpu_system::{ExecOutcome, GpuMmio, GpuStats, GpuSystem, PendingInterrupt};
use crate::texture_cache::TextureKey;
use crate::xenos_constants::XenosConstantsBlock;
/// Reply channel for a [`GpuCommand::DrainFence`]. Single-shot
/// `bounded(1)` — the GPU sends `()` once it's drained the ring up to the
@@ -41,6 +45,57 @@ use crate::gpu_system::{ExecOutcome, GpuMmio, GpuStats, GpuSystem, PendingInterr
/// is the first user of this; step 1 only validates the type fits.
pub type DrainReply = crossbeam_channel::Sender<()>;
/// GPU-derived swap metadata the worker hands to the UI `notify_swap` hook
/// under threaded `--ui` (A.5). The app-side glue maps this into the kernel's
/// `SwapInfo`, filling the two non-GPU fields itself (`instructions_total`
/// from the shared instruction counter; the interrupt counts are HUD-cosmetic
/// and passed as 0 on this path — the worker has no view of kernel interrupt
/// bookkeeping). `texture_format`/`color_space` likewise aren't visible to the
/// worker (they come from guest pointers in `vd_swap`'s args) and are HUD-only.
#[derive(Debug, Clone, Copy, Default)]
pub struct WorkerSwapInfo {
pub frontbuffer_addr: u32,
pub width: u32,
pub height: u32,
pub frame_index: u64,
pub draws_total: u64,
pub packets_total: u64,
pub last_draw_prim: u32,
pub last_draw_vertex_count: u32,
pub indirect_buffer_jumps: u64,
pub wait_reg_mem_blocks: u64,
pub vs_blob_key: u32,
pub ps_blob_key: u32,
pub resolves_total: u64,
pub resolves_copied_total: u64,
pub resolves_skipped_total: u64,
pub unique_render_targets: u64,
}
/// UI publish closures the GPU worker calls when it consumes a swap under
/// threaded `--ui` (A.5). These mirror the kernel `UiBridge`'s publish
/// closures but are expressed purely in `xenia-gpu` types so the worker
/// (which lives in this crate and can't depend on `xenia-kernel`) can hold
/// them. Built app-side from the live `UiBridge` and installed on the worker
/// via [`GpuCommand::InstallUiHooks`]. All closures are `Send + Sync` and are
/// invoked from the GPU worker thread, never the emulation thread.
#[derive(Clone)]
pub struct UiPublishHooks {
pub publish_assets:
Arc<dyn Fn(Option<HashMap<u32, Vec<u32>>>, XenosConstantsBlock) + Send + Sync>,
pub publish_texture: Arc<dyn Fn(Option<(TextureKey, Vec<u8>)>) + Send + Sync>,
pub publish_geometry: Arc<dyn Fn(Vec<DrawCapture>) + Send + Sync>,
pub publish_frontbuffer: Arc<dyn Fn(u32, u32, Vec<u8>) + Send + Sync>,
pub notify_swap:
Arc<dyn Fn(WorkerSwapInfo, &dyn xenia_memory::MemoryAccess) + Send + Sync>,
}
impl std::fmt::Debug for UiPublishHooks {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("UiPublishHooks { .. }")
}
}
/// Control-plane RPC the CPU thread sends to the GPU thread. Data-plane
/// signals (WPTR/RPTR/INT_STATUS) ride atomic mailboxes instead — see
/// [`GpuMmio`]. Channels are for events that need ordered delivery and
@@ -80,6 +135,12 @@ pub enum GpuCommand {
width: u32,
height: u32,
},
/// A.5 threaded `--ui`: install the UI publish closures on the worker so
/// it can run the per-swap capture/publish itself (blobs, constants,
/// texture, geometry, frontbuffer detile, notify) off the emulation
/// thread. Sent once by `run_with_ui` after the UI bridge is built.
/// Boxed to keep [`GpuCommand`] small (the hooks carry five `Arc`s).
InstallUiHooks(Box<UiPublishHooks>),
/// Tear-down signal. The worker drains any in-flight reply channels,
/// drops its `GpuSystem`, and the host thread joins.
Shutdown,
@@ -189,6 +250,15 @@ pub struct GpuWorker {
/// Shutdown flag. Set by `shutdown_and_join_with_timeout`; the worker
/// loop checks `Acquire` each iteration.
pub shutdown: Arc<AtomicBool>,
/// A.5: UI publish closures, installed via [`GpuCommand::InstallUiHooks`].
/// `None` in headless / inline modes → the worker does zero UI publish
/// (byte-identical to the pre-A.5 headless-threaded path).
pub ui_hooks: Option<UiPublishHooks>,
/// A.5: last `swaps_seen` value the worker ran the UI publish for. The
/// publish is level-triggered on this counter advancing, so multiple
/// swaps consumed in one iteration collapse to a single publish of the
/// latest state.
pub last_published_swaps: u64,
}
impl GpuSystem {
@@ -220,6 +290,8 @@ impl GpuSystem {
int_tx,
digest: digest.clone(),
shutdown: shutdown.clone(),
ui_hooks: None,
last_published_swaps: 0,
};
let handle = GpuHandle {
cmd_tx,
@@ -427,6 +499,16 @@ impl GpuBackend {
}
}
/// A.5 threaded `--ui`: hand the worker the UI publish closures so it can
/// run the per-swap capture/publish on its own thread. No-op on the inline
/// backend (that path publishes directly from `vd_swap` on the emulation
/// thread and never needs the hooks).
pub fn install_ui_hooks(&self, hooks: UiPublishHooks) {
if let GpuBackend::Threaded(h) = self {
let _ = h.send_cmd(GpuCommand::InstallUiHooks(Box::new(hooks)));
}
}
/// Bump `swaps_seen` + record `last_swap` + push a swap interrupt.
/// Inline calls directly. Threaded sends `NotifyXeSwap` over the
/// command channel — fire-and-forget; the worker handles it on its
@@ -616,6 +698,9 @@ impl GpuWorker {
self.system
.notify_xe_swap(frontbuffer_phys, width, height);
}
GpuCommand::InstallUiHooks(hooks) => {
self.ui_hooks = Some(*hooks);
}
GpuCommand::Shutdown => {
self.shutdown.store(true, Ordering::Release);
return;
@@ -662,6 +747,21 @@ impl GpuWorker {
*g = snap;
}
}
// (5c) A.5 threaded `--ui`: if a swap was consumed this iteration
// (either an in-stream PM4_XE_SWAP during the drain above or a
// `NotifyXeSwap` safety-net command), run the per-swap UI
// publish on THIS worker thread — shader blobs, constants,
// texture, geometry, frontbuffer detile, and `notify_swap`.
// Level-triggered on `swaps_seen` so it fires exactly once per
// new frame. Inline / headless modes leave `ui_hooks == None`
// and skip this entirely.
if let Some(hooks) = self.ui_hooks.as_ref() {
let cur = self.system.stats.swaps_seen;
if cur > self.last_published_swaps {
self.last_published_swaps = cur;
self.system.run_ui_publish(&memory, hooks);
}
}
// (6) M1.7 parker — `park_timeout` replaces the polling
// sleep. The standard parker idiom defends against the
// producer-races-park lost-wakeup:
@@ -794,6 +894,150 @@ pub fn shutdown_and_join_with_timeout(
}
}
impl GpuSystem {
/// A.5 worker-side UI publish. Mirrors the inline `vd_swap` publish block
/// (`crates/xenia-kernel/src/exports.rs`) but runs on the GPU worker
/// thread against this worker's own `GpuSystem` state + the shared guest
/// memory. Called once per consumed swap (level-triggered on
/// `stats.swaps_seen`). Takes the concrete `&GuestMemory` because
/// `max_page_version`/`read_bulk` are inherent methods, not on the
/// `MemoryAccess` trait.
///
/// Field-for-field parity with the inline path is intentional: the same
/// publish-on-change blob gating (A.2), the same slot-0 texture fallback,
/// the same bulk frontbuffer detile (A.1). The only differences are the
/// two HUD-only fields the worker can't see (see [`WorkerSwapInfo`]).
pub fn run_ui_publish(&mut self, mem: &GuestMemory, hooks: &UiPublishHooks) {
use crate::gpu_system::{CONST_BASE_FETCH, SwapNotification};
// Source of truth for this frame is whatever the executor recorded
// from the in-stream PM4_XE_SWAP (or the NotifyXeSwap safety net).
let swap = self.last_swap.unwrap_or(SwapNotification {
frame_index: self.swap_counter,
frontbuffer_phys: 0,
width: 0,
height: 0,
});
// Shader blobs: rebuild + clone only on change; constants always.
let blobs: Option<HashMap<u32, Vec<u32>>> =
if self.shader_blobs_version != self.last_published_blobs_version {
self.last_published_blobs_version = self.shader_blobs_version;
Some(
self.shader_blobs
.iter()
.map(|(k, b)| (*k, b.dwords.clone()))
.collect(),
)
} else {
None
};
let constants = XenosConstantsBlock::snapshot(&self.register_file);
(hooks.publish_assets)(blobs, constants);
// Primary texture: prefer the last draw's sampled texture; else probe
// fetch-constant slot 0 directly (flat-shader frames).
let published = self
.last_draw_textures
.first()
.map(|(_slot, k, _v, b)| (*k, b.clone()))
.or_else(|| {
const TEX_SLOT: u32 = 0;
let mut fetch6 = [0u32; 6];
for (i, slot) in fetch6.iter_mut().enumerate() {
*slot = self
.register_file
.read(CONST_BASE_FETCH + TEX_SLOT * 6 + i as u32);
}
let key = crate::texture_cache::decode_fetch_constant(fetch6)?;
let bi = key.format.block_info();
let span_bytes = (key.pitch_texels as u32)
* (key.height as u32)
* (bi.bytes_per_block as u32)
/ (bi.block_w as u32);
let version = mem.max_page_version(key.base_address, span_bytes.max(4));
match self.texture_cache.ensure_cached(key, version, mem) {
Ok(entry) => Some((entry.key, entry.bytes.clone())),
Err(_) => None,
}
});
(hooks.publish_texture)(published);
// Geometry: drain this frame's captured per-draw geometry.
if let Some(caps) = self.frame_captures.as_mut() {
let drained = std::mem::take(caps);
(hooks.publish_geometry)(drained);
}
// Frontbuffer: bulk read the tiled k_8_8_8_8 image and detile (A.1).
if swap.frontbuffer_phys != 0 && swap.width > 0 && swap.height > 0 {
let pitch_aligned =
crate::tiled_address::align_pitch_to_macro_tile(swap.width);
let total_tiled_bytes = (pitch_aligned * swap.height * 4) as usize;
let fb_backing = crate::physical_to_backing(swap.frontbuffer_phys);
let ok = (fb_backing as u64)
.checked_add(total_tiled_bytes as u64)
.is_some_and(|end| end <= 0x1_0000_0000);
if ok {
let mut tiled = vec![0u8; total_tiled_bytes];
mem.read_bulk(fb_backing, &mut tiled);
let mut linear = vec![0u8; (swap.width * swap.height * 4) as usize];
if crate::tiled_address::detile_2d(
&tiled,
&mut linear,
swap.width,
swap.height,
pitch_aligned,
4,
)
.is_ok()
{
(hooks.publish_frontbuffer)(swap.width, swap.height, linear);
}
}
}
// Notify: assemble the GPU-derived swap metadata for the UI redraw.
let (last_draw_prim, last_draw_vertex_count) = match self.last_draw {
Some(ds) => {
let code = match ds.primitive {
crate::draw_state::PrimitiveType::None => 0,
crate::draw_state::PrimitiveType::PointList => 1,
crate::draw_state::PrimitiveType::LineList => 2,
crate::draw_state::PrimitiveType::LineStrip => 3,
crate::draw_state::PrimitiveType::TriangleList => 4,
crate::draw_state::PrimitiveType::TriangleFan => 5,
crate::draw_state::PrimitiveType::TriangleStrip => 6,
crate::draw_state::PrimitiveType::RectangleList => 8,
crate::draw_state::PrimitiveType::QuadList => 13,
crate::draw_state::PrimitiveType::Unknown(x) => x as u32,
};
(code, ds.vertex_count)
}
None => (0, 0),
};
let wsi = WorkerSwapInfo {
frontbuffer_addr: swap.frontbuffer_phys,
width: swap.width,
height: swap.height,
frame_index: swap.frame_index,
draws_total: self.stats.draws_seen,
packets_total: self.stats.packets_executed,
last_draw_prim,
last_draw_vertex_count,
indirect_buffer_jumps: self.stats.indirect_buffer_jumps,
wait_reg_mem_blocks: self.stats.wait_reg_mem_blocks,
vs_blob_key: self.active_vs_key.unwrap_or(0),
ps_blob_key: self.active_ps_key.unwrap_or(0),
resolves_total: self.stats.resolves_total,
resolves_copied_total: self.stats.resolves_copied_total,
resolves_skipped_total: self.stats.resolves_skipped_total,
unique_render_targets: self.stats.unique_render_targets,
};
(hooks.notify_swap)(wsi, mem);
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -40,7 +40,8 @@ pub use gpu_system::{
};
pub use handle::{
DrainReply, GpuBackend, GpuCommand, GpuDigestSnapshot, GpuHandle, GpuWorker,
shutdown_and_join_with_timeout, spawn_gpu_worker, spawn_noop_worker,
UiPublishHooks, WorkerSwapInfo, shutdown_and_join_with_timeout, spawn_gpu_worker,
spawn_noop_worker,
};
pub use mmio_region::build_region as build_mmio_region;
pub use pm4::{

View File

@@ -3136,6 +3136,20 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// comment above). The drain below consumes only the packets the game has
// legitimately advanced the write-pointer over.
// A.5 threaded `--ui`: when the GPU runs on the worker thread AND a UI is
// attached, vd_swap must not block-drain or publish on the emulation
// thread. The worker drains the ring continuously and runs the entire
// per-swap UI publish itself (see `GpuSystem::run_ui_publish`) when it
// consumes the in-stream PM4_XE_SWAP. So here we've only filled the
// reserved ring slot (above) and return immediately — this is the A.5
// decoupling that lifts the ~12 ms/frame GPU work off the CPU thread.
// Inline `--ui` (the default) and headless-threaded are unaffected:
// `as_inline()` is `Some` for inline, and `state.ui` is `None` headless.
if state.ui.is_some() && state.gpu.as_inline().is_none() {
ctx.gpr[3] = 0;
return;
}
// Drain the ring up to whatever the game has actually submitted; any
// in-stream `PM4_INTERRUPT` / draw packets execute in order. The
// reserved-slot PM4_XE_SWAP is consumed by the GPU only once the game
@@ -3185,11 +3199,22 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// Do this before `notify_swap` so by the time the UI processes the
// SwapInfo the matching assets are visible through `UiHandles`.
if let Some(ref ui) = state.ui {
let blobs: std::collections::HashMap<u32, Vec<u32>> = gpu_inline
.shader_blobs
.iter()
.map(|(k, b)| (*k, b.dwords.clone()))
.collect();
// PERF (--ui): only rebuild + clone the shader-blob map when it
// actually changed since the last swap; otherwise pass `None` and
// the UI keeps its previous map. Constants are always published.
let blobs: Option<std::collections::HashMap<u32, Vec<u32>>> =
if gpu_inline.shader_blobs_version != gpu_inline.last_published_blobs_version {
gpu_inline.last_published_blobs_version = gpu_inline.shader_blobs_version;
Some(
gpu_inline
.shader_blobs
.iter()
.map(|(k, b)| (*k, b.dwords.clone()))
.collect(),
)
} else {
None
};
let constants = xenia_gpu::xenos_constants::XenosConstantsBlock::snapshot(
&gpu_inline.register_file,
);
@@ -3309,25 +3334,25 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
let pitch_aligned =
xenia_gpu::tiled_address::align_pitch_to_macro_tile(swap.width);
let total_tiled_bytes = (pitch_aligned * swap.height * 4) as usize;
// The guest address is 32-bit virtual but in the physical heap;
// safer to cap the read at the known total size to avoid OOB.
let mut tiled = Vec::with_capacity(total_tiled_bytes);
let mut ok = true;
// The frontbuffer is a guest *physical* address; project onto the
// committed backing window (see `xenia_gpu::physical_to_backing`)
// so the present reads the pixels the GPU resolved, not a stale /
// zero mirror page.
let fb_backing = xenia_gpu::physical_to_backing(swap.frontbuffer_phys);
for i in 0..total_tiled_bytes {
// read_u8 is cheap — the VirtualMemory handler returns 0
// for unmapped pages so we get a recognisable dark frame
// rather than a crash if the address turned out bogus.
let addr = fb_backing.wrapping_add(i as u32);
tiled.push(mem.read_u8(addr));
if addr < fb_backing {
ok = false;
break;
}
// PERF (--ui): read the whole tiled frontbuffer in one bulk copy
// instead of ~3.7 MB of per-byte `read_u8` through the MMIO
// handler — that byte loop dominated the emulation thread under
// `--ui` (~15 ms/swap). Guard against a u32-wrap / out-of-window
// read (the physical backing lives in [0x4000_0000, 0x5FFF_FFFF]
// within the 4 GiB reservation), reproducing the old wrap
// sentinel. Uncommitted pages inside the window read as host-zero
// (untouched mmap), matching the old "dark frame" fallback.
let ok = (fb_backing as u64)
.checked_add(total_tiled_bytes as u64)
.is_some_and(|end| end <= 0x1_0000_0000);
let mut tiled = vec![0u8; total_tiled_bytes];
if ok {
mem.read_bulk(fb_backing, &mut tiled);
}
if ok {
let mut linear = vec![0u8; (swap.width * swap.height * 4) as usize];

View File

@@ -118,8 +118,11 @@ pub struct UiBridge {
/// execute the guest draw. Split from `post_swap` so the asset wire
/// stays optional — if the UI doesn't need them (headless mode) the
/// closure is a no-op.
/// `blobs` is `Some` only when the shader-blob map changed since the
/// last publish (PERF: avoids re-cloning it every swap); `None` = the UI
/// keeps its previous map. Constants are always published.
pub publish_xenos_assets:
Arc<dyn Fn(HashMap<u32, Vec<u32>>, XenosConstantsBlock) + Send + Sync>,
Arc<dyn Fn(Option<HashMap<u32, Vec<u32>>>, XenosConstantsBlock) + Send + Sync>,
/// P4 frontbuffer publish: at each `VdSwap`, the kernel CPU-side
/// detiles the guest frontbuffer (k_8_8_8_8 Tiled2D) into a linear
/// RGBA8 buffer and hands it to the UI. The closure receives
@@ -168,7 +171,7 @@ impl UiBridge {
/// draw captured in this frame.
pub fn publish_assets(
&self,
blobs: HashMap<u32, Vec<u32>>,
blobs: Option<HashMap<u32, Vec<u32>>>,
constants: XenosConstantsBlock,
) {
(self.publish_xenos_assets)(blobs, constants);

View File

@@ -124,8 +124,12 @@ pub fn build(proxy: EventLoopProxy<SwapEvent>) -> (UiHandles, UiBridge) {
let blobs = Arc::clone(&shader_blobs);
let consts = Arc::clone(&xenos_constants);
Arc::new(move |new_blobs, new_consts| {
if let Ok(mut g) = blobs.lock() {
*g = new_blobs;
// `new_blobs` is `Some` only when the map changed since the
// last publish; `None` keeps the previous map (PERF).
if let Some(new_blobs) = new_blobs {
if let Ok(mut g) = blobs.lock() {
*g = new_blobs;
}
}
if let Ok(mut g) = consts.lock() {
*g = new_consts;

View File

@@ -97,6 +97,14 @@ pub struct RenderState {
/// and hands the view to the xenos pipeline's `@group(1) @binding(0)`
/// slot.
host_texture_cache: crate::texture_cache_host::TextureCacheHost,
/// PERF (--ui): parsed + WGSL-packed shader caches keyed on the guest
/// blob key. Shader microcode blobs are immutable once loaded, so we
/// parse/pack each key exactly once instead of every draw every frame
/// (`dispatch_xenos_captures` re-parsed on the hot path). Keyed on the
/// u32 blob key; key 0 is the empty/flat shader.
parsed_shader_cache: std::collections::HashMap<u32, xenia_gpu::ucode::ParsedShader>,
packed_shader_cache: std::collections::HashMap<u32, Vec<u32>>,
}
impl RenderState {
@@ -137,11 +145,32 @@ impl RenderState {
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let present_mode = if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
wgpu::PresentMode::Mailbox
} else {
wgpu::PresentMode::Fifo
};
// Default: Mailbox if available (non-vsync-blocking), else Fifo.
// `XENIA_PRESENT_MODE=immediate|mailbox|fifo` overrides — but only
// if the surface actually supports the requested mode; otherwise we
// fall back to the default so a bad env value can't break present.
let default_present_mode =
if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
wgpu::PresentMode::Mailbox
} else {
wgpu::PresentMode::Fifo
};
let present_mode = std::env::var("XENIA_PRESENT_MODE")
.ok()
.and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
"immediate" => Some(wgpu::PresentMode::Immediate),
"mailbox" => Some(wgpu::PresentMode::Mailbox),
"fifo" => Some(wgpu::PresentMode::Fifo),
"fifo-relaxed" | "fifo_relaxed" => Some(wgpu::PresentMode::FifoRelaxed),
_ => None,
})
.filter(|m| surface_caps.present_modes.contains(m))
.unwrap_or(default_present_mode);
let frame_latency = std::env::var("XENIA_FRAME_LATENCY")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.filter(|&n| n >= 1)
.unwrap_or(2);
let max_dim = adapter_limits.max_texture_dimension_2d.max(1);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
@@ -151,7 +180,7 @@ impl RenderState {
present_mode,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
desired_maximum_frame_latency: frame_latency,
};
surface.configure(&device, &config);
@@ -456,6 +485,8 @@ impl RenderState {
first_dispatch_logged: false,
first_translator_compile_logged: false,
host_texture_cache: crate::texture_cache_host::TextureCacheHost::new(),
parsed_shader_cache: std::collections::HashMap::new(),
packed_shader_cache: std::collections::HashMap::new(),
})
}
@@ -809,18 +840,43 @@ impl RenderState {
xenos_pipeline.set_texture_slots(device, &slot_views);
}
}
let raw_vs = shader_blobs.get(&cap.vs_key).cloned().unwrap_or_default();
let raw_ps = shader_blobs.get(&cap.ps_key).cloned().unwrap_or_default();
let parsed_vs = xenia_gpu::ucode::parse_shader(&raw_vs);
let parsed_ps = xenia_gpu::ucode::parse_shader(&raw_ps);
if seen.insert((0u8, cap.vs_key)) {
// PERF (--ui): parse + pack each shader blob ONCE (blobs are
// immutable once loaded) instead of every draw every frame.
// Clone the cached values out so the mutable borrow of the caches
// is released before the `self.xenos_pipeline` uses below.
let (vs_key, ps_key) = (cap.vs_key, cap.ps_key);
let parsed_vs = self
.parsed_shader_cache
.entry(vs_key)
.or_insert_with(|| {
let raw = shader_blobs.get(&vs_key).cloned().unwrap_or_default();
xenia_gpu::ucode::parse_shader(&raw)
})
.clone();
let parsed_ps = self
.parsed_shader_cache
.entry(ps_key)
.or_insert_with(|| {
let raw = shader_blobs.get(&ps_key).cloned().unwrap_or_default();
xenia_gpu::ucode::parse_shader(&raw)
})
.clone();
if seen.insert((0u8, vs_key)) {
xenia_gpu::shader_metrics::emit_for(&parsed_vs, "vs");
}
if seen.insert((1u8, cap.ps_key)) {
if seen.insert((1u8, ps_key)) {
xenia_gpu::shader_metrics::emit_for(&parsed_ps, "ps");
}
let vs_packed = xenia_gpu::ucode::pack_for_wgsl(&parsed_vs);
let ps_packed = xenia_gpu::ucode::pack_for_wgsl(&parsed_ps);
let vs_packed = self
.packed_shader_cache
.entry(vs_key)
.or_insert_with(|| xenia_gpu::ucode::pack_for_wgsl(&parsed_vs))
.clone();
let ps_packed = self
.packed_shader_cache
.entry(ps_key)
.or_insert_with(|| xenia_gpu::ucode::pack_for_wgsl(&parsed_ps))
.clone();
// Upload this draw's shader + constants + real vertex window.
self.xenos_pipeline.upload_shader_and_constants(
&self.queue,