Skip to content

perf: stage open, traversal and status queries - #142

Open
bresilla wants to merge 9 commits into
mxpv:mainfrom
bresilla:perf/stage-traversal
Open

bresilla wants to merge 9 commits into
mxpv:mainfrom
bresilla:perf/stage-traversal

Conversation

@bresilla

@bresilla bresilla commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Last one from the batch (after #139, #140 and #141): performance. No behaviour change, one thing per commit, and every commit is measured below so you can take only the ones you like. 7 sits on 6 and 9 on 5 and 7, the rest are standalone.

perf(ar): hand over cursor buffer in read_all: a package entry is read into a Cursor<Vec<u8>>, and read_all then copied it once more before decoding. A cursor still at its start now hands its buffer over. Opening oxbo_harvester.usdz (one 166 MB text layer) peaks at 273 MiB instead of 319.

perf(ar): share asset bytes with file formats: Asset::shared_bytes (C++ ArAsset::GetBuffer) lets a resolver that already holds an asset in memory share it, and the crate format decodes from those bytes instead of a copy. Through an in-memory resolver, a stage open on a 30 MB .usdc holds 18.6 MiB instead of 48.2. The default resolver doesn't use it; it's for resolvers like mine that keep documents in memory.

perf(sdf): share a path's text between clones: Path keeps its text in an Arc, so a clone is a pointer copy (C++ SdfPath copies share a pooled node). Traversal and status queries get 6 to 14% faster, while building a new path costs one more allocation, so opening a scene is a little slower.

perf(usd): read root layers once when opening: the TODO(perf) in own_expression_variables. The root and session layers were parsed once for their expression variables and again for the layer stack, and now the first read is handed to the stack. Opening the scene takes half the time, and opening the 18 machines about 11% less.

perf(usd): reuse the active check for loaded: asking for active and loaded together walked the ancestors for active twice. 14% off a warm traversal.

perf(usd): resolve abstract ancestry in one query: is_abstract went through the stage once per ancestor, now it is one cache query like is_defined, with the same prototype root rule. 14% off is_abstract.

perf(usd): resolve defined and abstract in one walk: every default traversal asks both, so they now share one ancestor walk. 29% off a warm traversal.

perf(usd): sort properties in one cache query: attributes() and relationships() asked the stage for each property's spec type separately, now it is one query. 12% off listing them.

perf(usd): carry parent status through traversal: the TODO(perf) in traverse. A prim whose parent resolved active, loaded, defined and not abstract now reads only its own opinions instead of walking all its ancestors, which is how C++ Usd_PrimData composes a prim's flags from its parent's. It only takes that shortcut while the population epoch is unchanged and falls back to the full walk otherwise. A warm default traversal goes from 257 to 107 ms.

How I measured: release build, median of 7 runs, on 18 real machine packages (889 MB of .usdz, mostly text layers) and a generated scene of 65,556 prims (10,000 groups four levels deep, each referencing a five-prim part, plus inactive, over and class branches). Milliseconds after each commit:

machines open scene open (usda) traverse cold traverse warm prim_status every prim is_abstract every prim attributes + relationships
main 3316 37.8 1026 507 493 161 348
read_all handover 3270 37.4 1009 491 486 164 344
shared asset bytes 3261 38.9 1038 490 500 166 357
shared path text 3306 41.2 935 459 443 143 342
root layers once 2929 21.2 930 447 437 142 336
active once 2974 21.1 857 383 370 143 334
abstract in one query 2996 20.8 839 368 349 122 340
defined and abstract together 3128 21.0 736 260 252 121 347
properties in one query 2997 20.7 730 257 248 122 307
parent status in traversal 2871 20.5 577 107 248 122 313

The machine opens move by up to about 5% from run to run, so only the root layer commit is a real change there; the first two commits show up in memory rather than time. The benchmark program isn't in the PR, I'm happy to add it as an example or a bench if you want one.

Copilot AI balanced review requested due to automatic review settings October 4, 2026 17:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The traversal epoch fallback needs mutation coverage, and several additions violate repository naming, import, and documentation conventions.

Review effort: Balanced
Findings: 1 Medium severity · 17 Low severity

Open (18)
What changed in this PR

Improves stage opening, traversal, status queries, and asset memory usage without intended behavior changes.

Changes:

  • Shares asset and path storage to reduce copying.
  • Reuses prepared layers and combined cache queries.
  • Threads inherited prim status through traversal with epoch validation.
File Description
crates/​openusd/​src/​ar.rs Adds shared asset bytes and cursor handoff.
crates/​openusd/​src/​sdf/​file_format.rs Supports shared-byte decoding.
crates/​openusd/​src/​sdf/​layer_registry.rs Reuses prepared root layers.
crates/​openusd/​src/​sdf/​path.rs Makes cloned paths share text.
crates/​openusd/​src/​usdc/​mod.rs Decodes USDC from shared bytes.
crates/​openusd/​src/​pcp/​index_cache.rs Combines and localizes status queries.
crates/​openusd/​src/​usd/​prim.rs Reuses status and property cache work.
crates/​openusd/​src/​usd/​stage.rs Optimizes opening and traversal.
crates/​openusd/​tests/​stage.rs Adds performance-path regressions.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +3456 to +3459
let status = if parent == Some(epoch) {
self.child_status_masked(&path, needed)?
} else {
self.prim_status_masked(&path, needed)?
Comment thread crates/openusd/src/ar.rs
Comment on lines +121 to +124
/// The complete asset as bytes shared with the caller, whatever the
/// cursor position, for an asset already held in memory (C++
/// `ArAsset::GetBuffer`). A format that decodes in place keeps them
/// instead of copying; `None`, the default, has the asset read instead.
Comment thread crates/openusd/src/ar.rs
/// cursor position, for an asset already held in memory (C++
/// `ArAsset::GetBuffer`). A format that decodes in place keeps them
/// instead of copying; `None`, the default, has the asset read instead.
fn shared_bytes(&self) -> Option<std::sync::Arc<[u8]>> {
Comment thread crates/openusd/src/ar.rs
self.read_to_end(&mut buf)?;
return Ok(buf);
}
Ok(std::mem::take(self.get_mut()))
Comment thread crates/openusd/src/ar.rs
/// A cursor at its start hands its buffer over; one already read into
/// returns the rest.
#[test]
fn cursor_asset_read_all_moves_buffer() {
Comment on lines +3220 to +3223
/// [`prim_status_masked`](Self::prim_status_masked) for a prim whose
/// parent resolved active, loaded, defined and not abstract. Those bits
/// then come from the prim's own opinions, with no walk up its ancestors,
/// as C++ `Usd_PrimData` composes a prim's flags from its parent's.

fn read_shared_bytes(
&self,
bytes: std::sync::Arc<[u8]>,
Comment on lines +462 to +463
/// A crate layer read from an asset that shares its bytes decodes from
/// those bytes, holding a reference rather than a copy.
/// Opening a stage reads its root and session layers once each: the read that
/// composes their expression variables is the one their stacks are built from.
#[test]
fn root_and_session_layers_read_once() -> Result<()> {
Comment on lines +3229 to +3230
std::fs::write(&root, "#usda 1.0\ndef \"World\" {}\n")?;
std::fs::write(&session, "#usda 1.0\n")?;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants