[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>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -3185,11 +3185,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 +3320,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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user