[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>
This commit is contained in:
@@ -966,8 +966,21 @@ fn cmd_exec_inner(
|
|||||||
let v = v.trim().to_ascii_lowercase();
|
let v = v.trim().to_ascii_lowercase();
|
||||||
v == "1" || v == "true" || v == "yes"
|
v == "1" || v == "true" || v == "yes"
|
||||||
});
|
});
|
||||||
let force_inline = gpu_inline || env_inline || ui;
|
// A.5 — opt-in threaded GPU under `--ui`. Off by default: `--ui` still
|
||||||
let force_thread = gpu_thread || env_thread;
|
// 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 {
|
let use_threaded = if force_inline {
|
||||||
false
|
false
|
||||||
} else if force_thread {
|
} else if force_thread {
|
||||||
@@ -1768,8 +1781,17 @@ fn cmd_exec_inner(
|
|||||||
// `xenia_gpu::handle::GpuWorker::run` for the concurrency model.
|
// `xenia_gpu::handle::GpuWorker::run` for the concurrency model.
|
||||||
// M1.3's `spawn_noop_worker` is now superseded for the threaded
|
// M1.3's `spawn_noop_worker` is now superseded for the threaded
|
||||||
// path; the no-op helper is retained for unit tests.
|
// 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");
|
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());
|
let join = xenia_gpu::spawn_gpu_worker(worker, mem_arc.clone());
|
||||||
Some((shutdown_arc.clone(), join))
|
Some((shutdown_arc.clone(), join))
|
||||||
} else {
|
} else {
|
||||||
@@ -1791,17 +1813,14 @@ fn cmd_exec_inner(
|
|||||||
let result = if ui {
|
let result = if ui {
|
||||||
run_with_ui(
|
run_with_ui(
|
||||||
path,
|
path,
|
||||||
// `run_with_ui` consumes `GuestMemory` by value today; M1.4
|
// A.5: `run_with_ui` now takes the shared `Arc<GuestMemory>`
|
||||||
// keeps that path on the inline backend until the UI worker
|
// directly (previously it recovered sole ownership via
|
||||||
// is migrated to the Arc-shared model. Recover ownership via
|
// `Arc::try_unwrap`, which panicked if a GPU worker held a
|
||||||
// `Arc::try_unwrap` — succeeds because the GPU worker is not
|
// clone). Both the CPU worker inside `run_with_ui` and the GPU
|
||||||
// spawned in inline mode (`maybe_gpu_worker` is `None`).
|
// worker (threaded `--ui`) borrow `&*mem_arc`; writes are `&self`
|
||||||
std::sync::Arc::try_unwrap(mem_arc).unwrap_or_else(|_| {
|
// post the M1.4(b) trait flip, so concurrent disjoint-range
|
||||||
panic!(
|
// access is sound.
|
||||||
"M1.4: --ui + --gpu-thread cohabitation not yet wired; \
|
mem_arc.clone(),
|
||||||
choose one"
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
kernel,
|
kernel,
|
||||||
debugger,
|
debugger,
|
||||||
thunk_map,
|
thunk_map,
|
||||||
@@ -4878,7 +4897,7 @@ fn dump_thread_diagnostic(
|
|||||||
#[instrument(skip_all, fields(title))]
|
#[instrument(skip_all, fields(title))]
|
||||||
fn run_with_ui(
|
fn run_with_ui(
|
||||||
title: &str,
|
title: &str,
|
||||||
mut mem: xenia_memory::GuestMemory,
|
mem: std::sync::Arc<xenia_memory::GuestMemory>,
|
||||||
mut kernel: xenia_kernel::KernelState,
|
mut kernel: xenia_kernel::KernelState,
|
||||||
mut debugger: xenia_debugger::Debugger,
|
mut debugger: xenia_debugger::Debugger,
|
||||||
thunk_map: HashMap<u32, (ModuleId, u16, String)>,
|
thunk_map: HashMap<u32, (ModuleId, u16, String)>,
|
||||||
@@ -4895,10 +4914,68 @@ fn run_with_ui(
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| anyhow::anyhow!("winit event loop build failed: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("winit event loop build failed: {e}"))?;
|
||||||
let (ui_handles, kernel_bridge) = xenia_ui::build(event_loop.create_proxy());
|
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);
|
kernel.ui = Some(kernel_bridge);
|
||||||
// iterate-3O: enable per-draw geometry capture so the UI can replay real
|
// 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,
|
// 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() {
|
if let Some(gpu) = kernel.gpu.as_inline_mut() {
|
||||||
gpu.enable_frame_capture();
|
gpu.enable_frame_capture();
|
||||||
}
|
}
|
||||||
@@ -4911,12 +4988,15 @@ fn run_with_ui(
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let worker_span = tracing::info_span!("cpu_worker");
|
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()
|
let worker = std::thread::Builder::new()
|
||||||
.name("xenia-cpu".into())
|
.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 _guard = worker_span.enter();
|
||||||
let stats = run_execution(
|
let stats = run_execution(
|
||||||
&mut mem,
|
&mem_w,
|
||||||
&mut kernel,
|
&mut kernel,
|
||||||
&mut debugger,
|
&mut debugger,
|
||||||
&thunk_map,
|
&thunk_map,
|
||||||
@@ -4930,7 +5010,7 @@ fn run_with_ui(
|
|||||||
if let Some(ref mut db) = db_writer {
|
if let Some(ref mut db) = db_writer {
|
||||||
db.finalize_traces()?;
|
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}"))?;
|
.map_err(|e| anyhow::anyhow!("spawn CPU worker: {e}"))?;
|
||||||
|
|
||||||
@@ -4938,7 +5018,7 @@ fn run_with_ui(
|
|||||||
// flips the shutdown flag itself (e.g. after max_instructions).
|
// flips the shutdown flag itself (e.g. after max_instructions).
|
||||||
xenia_ui::run(event_loop, ui_handles, &title_owned)?;
|
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?,
|
Ok(res) => res?,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Err(anyhow::anyhow!("CPU worker thread panicked"));
|
return Err(anyhow::anyhow!("CPU worker thread panicked"));
|
||||||
@@ -4946,7 +5026,7 @@ fn run_with_ui(
|
|||||||
};
|
};
|
||||||
|
|
||||||
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
|
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
|
||||||
dump_thread_diagnostic(&kernel, &mem, quiet);
|
dump_thread_diagnostic(&kernel, &*mem, quiet);
|
||||||
info!(
|
info!(
|
||||||
wall_ms = started.elapsed().as_millis() as u64,
|
wall_ms = started.elapsed().as_millis() as u64,
|
||||||
instructions = stats.instruction_count,
|
instructions = stats.instruction_count,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
//! `into_handle` on the live `KernelState.gpu` — the constructor exists for
|
//! `into_handle` on the live `KernelState.gpu` — the constructor exists for
|
||||||
//! the unit test below and for the synthetic-test path.
|
//! the unit test below and for the synthetic-test path.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
@@ -33,7 +34,10 @@ use crossbeam_channel::{Receiver, Sender, bounded, unbounded};
|
|||||||
|
|
||||||
use xenia_memory::GuestMemory;
|
use xenia_memory::GuestMemory;
|
||||||
|
|
||||||
|
use crate::draw_capture::DrawCapture;
|
||||||
use crate::gpu_system::{ExecOutcome, GpuMmio, GpuStats, GpuSystem, PendingInterrupt};
|
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
|
/// Reply channel for a [`GpuCommand::DrainFence`]. Single-shot
|
||||||
/// `bounded(1)` — the GPU sends `()` once it's drained the ring up to the
|
/// `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.
|
/// is the first user of this; step 1 only validates the type fits.
|
||||||
pub type DrainReply = crossbeam_channel::Sender<()>;
|
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
|
/// Control-plane RPC the CPU thread sends to the GPU thread. Data-plane
|
||||||
/// signals (WPTR/RPTR/INT_STATUS) ride atomic mailboxes instead — see
|
/// signals (WPTR/RPTR/INT_STATUS) ride atomic mailboxes instead — see
|
||||||
/// [`GpuMmio`]. Channels are for events that need ordered delivery and
|
/// [`GpuMmio`]. Channels are for events that need ordered delivery and
|
||||||
@@ -80,6 +135,12 @@ pub enum GpuCommand {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: 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,
|
/// Tear-down signal. The worker drains any in-flight reply channels,
|
||||||
/// drops its `GpuSystem`, and the host thread joins.
|
/// drops its `GpuSystem`, and the host thread joins.
|
||||||
Shutdown,
|
Shutdown,
|
||||||
@@ -189,6 +250,15 @@ pub struct GpuWorker {
|
|||||||
/// Shutdown flag. Set by `shutdown_and_join_with_timeout`; the worker
|
/// Shutdown flag. Set by `shutdown_and_join_with_timeout`; the worker
|
||||||
/// loop checks `Acquire` each iteration.
|
/// loop checks `Acquire` each iteration.
|
||||||
pub shutdown: Arc<AtomicBool>,
|
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 {
|
impl GpuSystem {
|
||||||
@@ -220,6 +290,8 @@ impl GpuSystem {
|
|||||||
int_tx,
|
int_tx,
|
||||||
digest: digest.clone(),
|
digest: digest.clone(),
|
||||||
shutdown: shutdown.clone(),
|
shutdown: shutdown.clone(),
|
||||||
|
ui_hooks: None,
|
||||||
|
last_published_swaps: 0,
|
||||||
};
|
};
|
||||||
let handle = GpuHandle {
|
let handle = GpuHandle {
|
||||||
cmd_tx,
|
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.
|
/// Bump `swaps_seen` + record `last_swap` + push a swap interrupt.
|
||||||
/// Inline calls directly. Threaded sends `NotifyXeSwap` over the
|
/// Inline calls directly. Threaded sends `NotifyXeSwap` over the
|
||||||
/// command channel — fire-and-forget; the worker handles it on its
|
/// command channel — fire-and-forget; the worker handles it on its
|
||||||
@@ -616,6 +698,9 @@ impl GpuWorker {
|
|||||||
self.system
|
self.system
|
||||||
.notify_xe_swap(frontbuffer_phys, width, height);
|
.notify_xe_swap(frontbuffer_phys, width, height);
|
||||||
}
|
}
|
||||||
|
GpuCommand::InstallUiHooks(hooks) => {
|
||||||
|
self.ui_hooks = Some(*hooks);
|
||||||
|
}
|
||||||
GpuCommand::Shutdown => {
|
GpuCommand::Shutdown => {
|
||||||
self.shutdown.store(true, Ordering::Release);
|
self.shutdown.store(true, Ordering::Release);
|
||||||
return;
|
return;
|
||||||
@@ -662,6 +747,21 @@ impl GpuWorker {
|
|||||||
*g = snap;
|
*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
|
// (6) M1.7 parker — `park_timeout` replaces the polling
|
||||||
// sleep. The standard parker idiom defends against the
|
// sleep. The standard parker idiom defends against the
|
||||||
// producer-races-park lost-wakeup:
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ pub use gpu_system::{
|
|||||||
};
|
};
|
||||||
pub use handle::{
|
pub use handle::{
|
||||||
DrainReply, GpuBackend, GpuCommand, GpuDigestSnapshot, GpuHandle, GpuWorker,
|
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 mmio_region::build_region as build_mmio_region;
|
||||||
pub use pm4::{
|
pub use pm4::{
|
||||||
|
|||||||
@@ -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
|
// comment above). The drain below consumes only the packets the game has
|
||||||
// legitimately advanced the write-pointer over.
|
// 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
|
// Drain the ring up to whatever the game has actually submitted; any
|
||||||
// in-stream `PM4_INTERRUPT` / draw packets execute in order. The
|
// in-stream `PM4_INTERRUPT` / draw packets execute in order. The
|
||||||
// reserved-slot PM4_XE_SWAP is consumed by the GPU only once the game
|
// reserved-slot PM4_XE_SWAP is consumed by the GPU only once the game
|
||||||
|
|||||||
Reference in New Issue
Block a user