From 33a7bf836ba5095c6baa302a0aa42c30406c24ab Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Wed, 12 Aug 2026 18:57:50 +0000 Subject: [PATCH] perf(mesh): memoise the whole-container decode Filtering after the assignment made every subset query a full decode (~15s on a 50MB container). full_decode_cached memoises it per container -- fingerprint is length plus three sampled 4KB windows, keyed with min_consistency, last four kept. Decoding five ships from Stage_S02 in turn: 10.5s for the first, then 48us-1.4ms. A stage now costs one decode rather than one per ship. Ten suites green, viewer builds, and a spot-checked resource still lands on the same offset. Co-Authored-By: Claude Opus 5 (1M context) --- .../sylpheed-formats/examples/bench_ships.rs | 22 +++++++ crates/sylpheed-formats/src/mesh.rs | 64 +++++++++++++++++++ docs/re/structures/xbg7-mesh.md | 23 +++++-- 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 crates/sylpheed-formats/examples/bench_ships.rs diff --git a/crates/sylpheed-formats/examples/bench_ships.rs b/crates/sylpheed-formats/examples/bench_ships.rs new file mode 100644 index 0000000..dc7c89c --- /dev/null +++ b/crates/sylpheed-formats/examples/bench_ships.rs @@ -0,0 +1,22 @@ +//! Time repeated per-ship decodes of one container, as the viewer does. +use std::collections::HashSet; +use std::time::Instant; +use sylpheed_formats::mesh::Xbg7Model; +use sylpheed_formats::ship::{is_base_part, ship_id_of}; +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).unwrap(); + let names = sylpheed_formats::mesh::xbg7_resource_names(&bytes); + let ids: Vec = { + let mut v: Vec = names.iter().filter(|n| is_base_part(n)) + .filter_map(|n| ship_id_of(n).map(|s| s.to_string())).collect(); + v.sort(); v.dedup(); v.truncate(5); v + }; + for id in &ids { + let want: HashSet = names.iter() + .filter(|n| ship_id_of(n) == Some(id.as_str())).cloned().collect(); + let t = Instant::now(); + let got = Xbg7Model::models_named(&bytes, &want, &|| false); + println!("{id}: {} models in {:?}", got.len(), t.elapsed()); + } +} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 64ad55e..b5f75a0 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -463,11 +463,32 @@ impl Xbg7Model { Self::anchor_models_filtered(bytes, min_consistency, should_cancel, None) } + /// Decode the whole container (cached), then hand back the requested subset. + /// + /// The decode itself must always see every resource — distinct assignment + /// resolves collisions against the whole population, and pruning first made + /// the answer depend on the request (see the module history). That makes a + /// single-resource query as expensive as a full decode, so the full decode is + /// memoised per container: the viewer asks for one ship's parts at a time and + /// would otherwise re-anchor 6 000 resources per ship. fn anchor_models_filtered( bytes: &[u8], min_consistency: f32, should_cancel: &(dyn Fn() -> bool + Sync), wanted: Option<&std::collections::HashSet>, + ) -> Vec { + let Some(w) = wanted else { + return Self::anchor_models_uncached(bytes, min_consistency, should_cancel, None); + }; + let full = full_decode_cached(bytes, min_consistency, should_cancel); + full.iter().filter(|m| w.contains(&m.name)).cloned().collect() + } + + fn anchor_models_uncached( + bytes: &[u8], + min_consistency: f32, + should_cancel: &(dyn Fn() -> bool + Sync), + wanted: Option<&std::collections::HashSet>, ) -> Vec { let mut out = Vec::new(); if bytes.len() < 16 || &bytes[..4] != b"XPR2" { @@ -1138,6 +1159,49 @@ pub fn debug_resource_params(bytes: &[u8], name: &str) -> Option<(Vec<(usize, us None } +/// Memoised whole-container decode, keyed by a cheap fingerprint of the bytes +/// plus the consistency setting. Holds the last few containers; a stage decode is +/// a handful of MB, and the alternative is re-anchoring every resource for every +/// ship the viewer shows. +fn full_decode_cached( + bytes: &[u8], + min_consistency: f32, + should_cancel: &(dyn Fn() -> bool + Sync), +) -> std::sync::Arc> { + use std::sync::{Arc, Mutex, OnceLock}; + // Fingerprint: length plus three sampled 4 KB windows. Two different + // containers agreeing on all of that is not a case this format produces. + let mut fp: u64 = 0xcbf2_9ce4_8422_2325 ^ bytes.len() as u64; + let windows = [0usize, bytes.len() / 2, bytes.len().saturating_sub(4096)]; + for w in windows { + for b in bytes.iter().skip(w).take(4096) { + fp = (fp ^ *b as u64).wrapping_mul(0x100_0000_01b3); + } + } + let key = (fp, min_consistency.to_bits()); + #[allow(clippy::type_complexity)] + static CACHE: OnceLock>)>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(Vec::new())); + if let Ok(c) = cache.lock() { + if let Some((_, v)) = c.iter().find(|(k, _)| *k == key) { + return Arc::clone(v); + } + } + let models = Arc::new(Xbg7Model::anchor_models_uncached( + bytes, + min_consistency, + should_cancel, + None, + )); + if let Ok(mut c) = cache.lock() { + c.push((key, Arc::clone(&models))); + if c.len() > 4 { + c.remove(0); + } + } + models +} + /// Diagnostic: the candidate vertex-buffer starts the stage anchor scan will /// consider for a given `stride`, for one container. A runtime capture names the /// offsets the engine really drew from (see `examples/shared_vbase_check.rs`), so diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index 855133e..c2e1328 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -1113,11 +1113,24 @@ always runs over the whole container, and a subset is now a subset of the container's own answer. Verified: one-name, three-name and full decodes now return the identical offset for the same resource. -**Cost, stated plainly:** a single-resource query on a 50 MB container went from -near-instant to **~15 s**, because it now anchors every resource. The viewer -calls this once per ship view (with cancellation), so it is a slow prepare step -rather than a per-frame cost — but caching the per-container decode is the -obvious follow-up and is not done here. +**Cost, and the cache that pays it back.** Because the assignment must see every +resource, a single-resource query became as expensive as a full decode (~15 s on +a 50 MB container). `full_decode_cached` memoises the whole-container decode, +keyed by a fingerprint of the bytes (length + three sampled 4 KB windows) and the +consistency setting, holding the last four containers. Decoding five ships from +`Stage_S02` in turn, as the viewer does: + +``` +e007: 2 models in 10.494 s ← the one full decode +e010: 2 models in 48.6 µs +e105: 37 models in 1.42 ms +e106: 32 models in 92.7 µs +e108: 13 models in 73.7 µs +``` + +So a stage costs one decode, not one per ship. The whole-container path +(`anchor_models_cancellable`, what the sweeps use) stays uncached — it is already +the thing being measured. ### The consistency figure is mostly bounding boxes — real disagreement is ONE resource