From 79d0026a3177e790671b347e3141322f68c47632 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 3 Jul 2026 21:21:51 +0200 Subject: [PATCH] [iterate-4B] --ui perf: bulk frontbuffer detile + present knob + shader/blob caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/xenia-gpu/src/gpu_system.rs | 13 +++++ crates/xenia-kernel/src/exports.rs | 49 +++++++++------- crates/xenia-kernel/src/ui_bridge.rs | 7 ++- crates/xenia-ui/src/bridge.rs | 8 ++- crates/xenia-ui/src/render.rs | 84 +++++++++++++++++++++++----- 5 files changed, 124 insertions(+), 37 deletions(-) diff --git a/crates/xenia-gpu/src/gpu_system.rs b/crates/xenia-gpu/src/gpu_system.rs index 3dd909e..d1bcb61 100644 --- a/crates/xenia-gpu/src/gpu_system.rs +++ b/crates/xenia-gpu/src/gpu_system.rs @@ -369,6 +369,14 @@ pub struct GpuSystem { ib_stack: Vec, /// Cached shader blobs keyed by the raw CP register address that loaded them. pub shader_blobs: HashMap, + /// 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 { diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs index 5ac51de..dbd92e1 100644 --- a/crates/xenia-kernel/src/exports.rs +++ b/crates/xenia-kernel/src/exports.rs @@ -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> = 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>> = + 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]; diff --git a/crates/xenia-kernel/src/ui_bridge.rs b/crates/xenia-kernel/src/ui_bridge.rs index 59798b3..e099cdf 100644 --- a/crates/xenia-kernel/src/ui_bridge.rs +++ b/crates/xenia-kernel/src/ui_bridge.rs @@ -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>, XenosConstantsBlock) + Send + Sync>, + Arc>>, 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>, + blobs: Option>>, constants: XenosConstantsBlock, ) { (self.publish_xenos_assets)(blobs, constants); diff --git a/crates/xenia-ui/src/bridge.rs b/crates/xenia-ui/src/bridge.rs index e27d47d..cd713c7 100644 --- a/crates/xenia-ui/src/bridge.rs +++ b/crates/xenia-ui/src/bridge.rs @@ -124,8 +124,12 @@ pub fn build(proxy: EventLoopProxy) -> (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; diff --git a/crates/xenia-ui/src/render.rs b/crates/xenia-ui/src/render.rs index 67cbd0f..fdc929b 100644 --- a/crates/xenia-ui/src/render.rs +++ b/crates/xenia-ui/src/render.rs @@ -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, + packed_shader_cache: std::collections::HashMap>, } 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::().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,