matiaszanolli

Performance Audit

"Audit GPU/CPU performance — hot paths, allocations, draw batching, GPU memory pressure, SSBO sizing, telemetry"

matiaszanolli 28 3 Updated 1w ago
GitHub

Install

npx skillscat add matiaszanolli/byroredux/claude-commands-audit-performance

Install via the SkillsCat registry.

SKILL.md

Performance Audit

Read .claude/commands/_audit-common.md and .claude/commands/_audit-severity.md for shared protocol (layout, methodology, dedup, context rules, path-reference convention, finding format, hot-path hashing rule).

Audit ByroRedux for CPU hot-path inefficiency, per-frame allocation churn, draw/instancing waste, GPU memory pressure + eviction thrash, SSBO sizing/upload cost, pass cost and streaming stalls. Do not re-derive memory ceilings — docs/engine/memory-budget.md owns every size, threshold, reserve floor and VRAM budget; cite it.

Architecture: Orchestrator. Each dimension runs as a Task agent (max 3 concurrent).

Hardware target: RTX 4070 Ti (12 GB) + Ryzen 7950X (16c/32t); RT VRAM minimum 6 GB. A CPU bottleneck here is a bug, not a tuning gap — dimensions are ordered by per-frame impact.

Parameters

  • --focus <dimensions>: comma-separated numbers (e.g. 1,3,5). Default all 8. --depth shallow|deep: pattern scan vs traced + quantified. Default deep.

Extra Per-Finding Fields

  • Dimension: CPU Hot Paths | Draw & Instancing | GPU Memory Pressure | SSBO Sizing & Upload | GPU Pipeline | Skinning & BLAS | Streaming & Cells | NIF Parse | Telemetry & Origin Cost

Bench discipline (never hard-code numbers)

  • ROADMAP.md carries the live Bench-of-record (scripts/fsr-bench-matrix.sh 3 300, renderer-stepped, five scenes × TAA/FSR presets, with a same-machine control of the outgoing record): read it, its open regressions (R6a-regress-22: the "FO4 frame time doubled inside 4c9a5b36..99933f87b" reading was b9e961eeb moving the bench camera's spawn origin into the room, not code — #5128's pinned-pose control; the known-open residual is ≈ +0.7 ms / fence +2.6 ms from the old pose. Before comparing two bench records, check their camera_pos columns match; and R6a-stale-25: the a37fcba3c record predates every frame-path change since #5154, so no HEAD frame-time claim is current until it is re-run with a same-machine control) and the compat table; report observed-vs-ROADMAP deltas — a regression is a delta. Scenes: Cornell (redistributable control), Prospector (FNV), WhiterunBanneredMare (Skyrim), MedTekResearch01 + Dugout Inn (FO4).
  • Run shape: --bench-frames N [--bench-hold] [--bench-mode renderer-static|renderer-stepped|system-live]. Compare only within one mode (baseline TSVs in .claude/audit-baselines/runtime/ are renderer-static; the record is renderer-stepped); the mode owns delta-time, so never set BYROREDUX_FIXED_DT. Point BYROREDUX_SETTINGS_PATH at a fresh file and pass --upscaler explicitly, or a persisted render.upscaler changes the run (#4947); the bench: line reports upscaler=/render_extent=/output_extent=. Measure the noise floor first (scripts/bench-variability-envelope.sh); a delta inside it is not a finding.
  • Harness byte-stability. scripts/fsr-bench-matrix.sh + scripts/fsr_bench_report.py must stay byte-stable across commits or cross-commit comparisons mean nothing. Never assert it from memory (#4024): run scripts/check-bench-harness-provenance.sh [--strict] [<record-commit>] (full history required) and quote the verdict; a DIVERGED verdict → re-bench both sides. An edit to either file that was not itself re-benched is a finding (against a37fcba3c the verdict is DIVERGED with two edits, each carrying a recorded disposition the script prints — #5451, fixed in 4b9472909: the Prospector Update.bsa mount was shown measurement-neutral by a same-session interleaved control, the camera_pos columns are append-only; the verdict, exit code and --strict are unchanged and the full re-bench stays tracked as R6a-stale-27).
  • Adaptive ray budget: the RT quality tier follows GPU time (AdaptiveRayBudget), so a lighting-cost delta is uninterpretable without it — pin with --rt-test-ray-quality-tier N for A/B, else report the tier.
  • Hooks (byro-dbg after --bench-hold; no bench-stats command exists): the once-a-second gpu_ms: / cpu_ms: lines, MetricsSnapshot, console ctx.scratch, skin.coverage, rt.integrity, depth.stats. Cite a timer, not a guess.

Regression-guard posture

Each dimension lists landed fixes as Guard: lines — verify the symbol/test still exists and holds; never re-propose a landed fix (noise); an eroded guard is a real finding, reported apart from new issues. Std HashMap/HashSet on a per-frame per-entity keyspace is a fresh regression (the Fx conversion is finished). Falsified by measurement, re-propose only with new numbers: decorate-sort-undecorate for the raster sort (#4194, manual_bench_draw_sort_decorate_sort_undecorate, render/draw_sort_key_tests.rs).

Alloc coverage: NIF parse/import allocation is dhat-testable (dhat-heap feature; bounds in crates/nif/tests/heap_allocation_bounds*.rs) — propose a bound, don't estimate. Per-frame render/ECS paths have NO dhat coverage: flag "no quantitative guard exists for this site".

Per-frame pass inventory (verify against code; a pass missing here is itself a finding)

Command-buffer order (draw_frame phases, vulkan/context/). Timer = GpuTimerSnapshot field (28 brackets: 23 named + 5 main-render sub-phases); a new pass needs a bracket, a row here and a memory-budget.md row. Nested brackets (main_opaque_ms / main_blended_ms / main_water_ms / groundcover_model_draw_ms / groundcover_blade_draw_ms inside main_render_ms; volumetrics_inject_ms / volumetrics_integrate_ms inside volumetrics_ms) are never summed with their parent.

Pass Where Runs when Scales with Timer
Static-BLAS recovery restore_missing_static_blas_for_draws via step_static_blas_restore (app_step.rs), between frames missing rigid BLAS; deadline-bounded chunks; skipped if the set cannot fit the budget missing meshes (~0.2–0.5 ms each, blocking) CPU only
Dynamic-RGBA copies, reservoir clear begin_frame_recording (texture_registry/dynamic_rgba.rs, ReservoirBuffers::begin_frame) pending same-size RGBA updates; every frame changed textures; pixels none
Instance/material/light upload build_and_upload_instances.rs, scene_buffer/upload.rs every frame; hash dirty gates live draws, unique materials ssbo_build, parameter_upload (CPU)
Skin palette skin_palette.comp via plan_palette_dispatch (dispatch_skin_and_cluster.rs) dirty slot runs only changed bone slots skin_palette_ms
Skin vertices skin_vertices.comp per entity pose-hash dirty gate dirty entities × verts skin_dispatch_ms
Skinned BLAS refit record_skinned_blas_refit dirty gate; rebuild after 600 + jitter refits dirty entities; shared scratch serializes skin_blas_refit_ms
TLAS build/refit build_tlas every frame; UPDATE vs BUILD by address sequence instances tlas_build_ms
Cluster cull cluster_cull.comp every frame lights × clusters cluster_cull_ms
Ground-cover scatter groundcover_interaction.comp + groundcover_scatter.comp (TLAS ray query per candidate); dev bench: groundcover_bench_ms chunks resident (exterior) chunks × candidate budget groundcover_scatter_ms
Sky-cube bake SkyCubePipeline::record_bake: sky_cube.comp (6×128², cloud march) + prefilter mips + SH every frame the pipeline exists (interiors too) fixed sky_cube_ms
Ground-cover model tier groundcover_models.comp via record_groundcover_models (PLACE/LAYOUT/EMIT into the instance tail) exterior, chunks resident candidates groundcover_models_ms
Main geometry pass triangle.vert/frag + early-depth triangle_early.frag.spv for opaque draws that allow it (RT shadows/reflection/GI/glass), water, ground-cover models + blades every frame pixels × lights × rays (adaptive tier) main_render_ms (+ 5 sub-phases)
Depth-history copy copy_depth_to_history only if scene_has_effect_soft_material (cached, SceneEffectSoftCache) pixels depth_history_copy_ms
SVGF temporal + 3 à-trous dispatches every frame pixels svgf_ms (one bracket)
Caustic splat caustic_splat.comp caustic sources present refractive pixels caustic_splat_ms
Volumetrics inject + integrate, combustion-moment readback (one frame behind); host fog_cluster prep VOLUMETRIC_OUTPUT_CONSUMED froxels (resolution-derived) × rays per froxel (fogRayQualityTier) volumetrics_ms (+ inject/integrate)
SSAO, composite ssao.comp; composite.frag (linear HDR) every frame render pixels ssao_ms, composite_ms
Bloom down/up chain + bloom_apply.comp skipped under raw debug views pixels bloom_ms
TAA taa.comp only --upscaler taa pixels taa_ms
Exposure meter exposure_meter.comp via record_exposure_meter_pass (one workgroup; fixed mode writes a constant, auto mode does 4,096 scattered fetches); skipped on raw-debug views every frame fixed exposure_meter_ms
Upscale, presentation FSR 3.1 or native blit; tone-map (ACES / AgX) × exposure texel (+ UI quad) every frame output pixels (presentation does not shrink with FSR presets) upscale_ms, presentation_ms

Outside the loop: lazy blend-pipeline variant compilation (seconds each on a cold driver cache; CPU span pipeline_compile), the post-present scratch shrink (shrink_frame_scratch.rs, CPU span post_present) and the streaming/teardown paths (Dim 7).

Phase 1: Setup

  1. Parse $ARGUMENTS. mkdir -p /tmp/audit/performance.
  2. Dedup baseline: gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/performance/issues.json.
  3. Read ROADMAP.md Bench-of-record + compat table, and the newest docs/audits/AUDIT_PERFORMANCE_*.md.
  4. Delta scoping: run each dimension's First step: with --since=<last report date>; untouched Paths get a guard check + skim.

Phase 2: Dimensions

Dimension 1: CPU per-frame allocations & hot paths

The highest-yield surface: a 16-core Ryzen must never be the bottleneck.
Paths: byroredux/src/systems/{animation,bounds,billboard,particle,cinematic,locomotion,sandbox,wander,travel,follow,escort,guard,patrol,walk_anim,combat_ai,restoration,eat_sleep,forcegreet,story_events,npc_dialogue,dialogue_voice}.rs, byroredux/src/npc_spawn/ai_package.rs (ambient_ai_package_system), crates/core/src/ecs/systems.rs (make_transform_propagation_system), crates/core/src/ecs/packed.rs, byroredux/src/render/{mod,particles,lights,static_meshes,skinned}.rs, crates/core/src/ecs/resources/skin_slot_pool.rs.
First step: git log --since=… --format='%h %s' -- byroredux/src/systems byroredux/src/render crates/core/src/ecs; scheduler timings in MetricsSnapshot name the slow system.
Method: per-frame Vec/HashMap/String allocation that should be a persistent scratch; collect() where clear()+extend works; mem::take capacity churn (0→N regrowth); per-entity allocation or component deep-clone inside a per-frame loop; whole-world scans with no change-detection key; sparse-set probes repeated per entity. Ambient locomotion/AI/cinematic systems run every frame on every NPC: no component clones (NavPath and ScenePlayer clones were real findings), persistent scratch not fresh Vecs. Regression guards, fixed (do not re-file): story_change_location_system early-outs on an empty/absent SmTree and hashes the typed LCTN/grid fields without format! (#5447, 7bba019e1); ambient_ai_package_system reads BYRO_M42_DEBUG once through a OnceLock and builds its due list without a per-frame Vec (#5448, 9cc748ef2); make_eat_sleep_system keeps a persistent scratch — a per-actor SEAT_RETRY_SECS retry clock, one shared sit/sleep marker gather per frame (#5449, 35a009431), an ANCHOR_RETRY_SECS clock for an unresolved In-Cell anchor and one query pass per storage for the walk leg (#5527, 959b76f55). A per-frame whole-world Furniture walk or a per-actor world.get round-trip in these systems is the regression.
Guard: drain_dirty_into_preserves_storage_capacity (packed.rs; take_dirty zero-capacities — its use in a per-frame system is the regression); make_animation_system persistent scratches (entities_scratch, playback_scratch); make_billboard_system skips its loop when last_cam is unchanged (re-arming GlobalTransform TRACK_CHANGES every frame defeats incremental bounds); make_transform_propagation_system change-detection fast path (skips when roots, Parent/Children generations and Transform dirty are unchanged); build_debug_ui_snapshot clone gated on debug UI visible; SkinSlotPool free_list contraction so max_used_slot() shrinks after a high-NPC unload; bone_world not .clear()ed per frame (identity-fills new tail slots only); emit_particles takes no dead GlobalTransform query; scene_has_effect_soft_material served from SceneEffectSoftCache while storages are structurally unchanged.
Output: /tmp/audit/performance/dim_1.md

Dimension 2: Draw-call & instancing efficiency

Paths: byroredux/src/render/{mod,static_meshes,visibility}.rs (build_render_data, draw_sort_key, sort_draw_commands, raster_visible), crates/renderer/src/vulkan/context/{build_and_upload_instances,draw,frame_params}.rs (batching; group_state / should_use_indirect_draws live in frame_params.rs since #5087).
First step: git log --since=… --format='%h %s' -- byroredux/src/render crates/renderer/src/vulkan/context/draw.rs; cargo test -p byroredux draw_sort_key.
Guard: render/draw_sort_key_tests.rs, render/sort_key_tests.rs.
Checklist:

  • draw_sort_key is a 12-tuple (u8×4, u32×8): slot 0 rt_only (off-frustum RT occluders last, so the MAX_INSTANCES cap drops them before raster), 1 composition phase, 2 blend class, 3 no_sorter (true alpha-over only; opaque/additive write 0 — #4191), then state/mesh/depth. The opaque depth slot is opaque_depth_bucket (raw depth = camera-driven sort churn that defeats the SSBO dirty-gate hashes, #3663). Sorted alpha-over is depth-primary on purpose: its higher bench_draws_* counts are accepted cost. Every PipelineKey axis needs a sort slot (blend_pipeline_slot, pack_depth_state).
  • Verify the sort minimizes pipeline/descriptor rebinds; draw count vs entity count (compat table reports both); same-mesh instances collapse to one indirect draw; no per-draw state churn (cmd_set_depth_bias). R6a-regress-22's apparent batch-merge collapse (Dugout 1935/326b/12c → 2367/659b/43c) was a camera move (#5128): pinned to one pose, 99933f87b merges more (330b → 168b), so a batching finding must compare same-pose captures.
  • DRAW_SORT_PARALLEL_THRESHOLD = 3000 (sort_draw_commands; measured crossover 2750–3000). Check it against the bench_draws_raster_cmds column of the baseline TSVs — bench_draws_cmds also counts RT-only occluders that never enter the sorter; a row with bench_draws_batches > bench_draws_raster_cmds cannot come from one capture.
  • Static-mesh loop probes GlobalTransform before the visibility/bounds siblings (#1377); build_instance_map reuses a caller scratch (#4193).
  • needs_two_sided_blend_split = is_blend && two_sided && order_dependent_glass (no z_write limb); structurally dormant for engine-classified glass (collect_static_mesh_draws clears two_sided for MATERIAL_KIND_GLASS), so split items cannot explain batch-count movement (#2691); a split on non-glass batches is the regression. The indirect group key carries split eligibility (needs_two_sided_blend_split), not the raw glass flag, so opaque glass runs merge (#2764, opaque_glass_flag_does_not_split_groups; #2165's blended_glass_flag_still_splits_groups still holds).
    Output: /tmp/audit/performance/dim_2.md

Dimension 3: GPU memory pressure & eviction thrash

Paths: crates/renderer/src/vulkan/acceleration/{predicates,blas_static,constants}.rs, context/resources.rs, crates/renderer/src/texture_registry/, crates/renderer/src/mesh.rs, byroredux/src/asset_provider/material/{provider,cdb,loose_mat}.rs, crates/sfmaterial/src/index.rs (the Phase-2 MaterialIndex), crates/renderer/src/deferred_destroy.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/acceleration crates/renderer/src/texture_registry crates/renderer/src/vulkan/context/resources.rs; rt.integrity shows BLAS residency, missing/evicted counts and pending-destroy backlog.
Guard: cargo test -p byroredux-renderer --lib acceleration (admission/eviction/residency tests), static_blas_recovery_covers_both_sources_and_protects_current_draws (resources.rs), bin static_blas_recovery_runs_between_frames_not_in_the_render_driver (app_step.rs); has_any_view_of_path_finds_every_key_variant (#5053: the prefetch planner's residency probe rewrites one reused key buffer, never 16 Strings per probe).
Checklist (ceilings: memory-budget.md):

  • BLAS budget is dynamic: blas_budget_for_heap = (DEVICE_LOCAL heap − screen_scaled_reservation_bytes) / 3, clamped to [MIN_BLAS_BUDGET_BYTES 256 MB, MAX_BLAS_BUDGET_BYTES 1 GB]; the reservation bills every render-extent pass, the upscaler's output-extent images and FSR SDK bytes and is re-derived on resize/upscaler switch. Eviction runs pre- and mid-batch (blas_over_budget counts the batch's pending_bytes; BATCH_EVICTION_CHECK_INTERVAL = 64); admission is bounded on resident bytes (admit_next_static_blas, blas_admission_exhausted) — allocating past residency, or evicting against bytes still in pending_destroy_blas, is the regression. LRU must not rebuild-thrash during streaming.
  • Static-BLAS recovery stays a deadline-bounded between-frames step; plan_static_blas_restore must still skip a set that cannot fit the budget (else evict→rebuild never converges — the ~95 k-draw Starfield city). Current-frame BLAS ownership is StaticBlasWorkingSet (acceleration/static_working_set.rs: dense handle-indexed generation stamps, no per-frame hashing, #4804). Documented ceilings, quantify first: --grid false-evict burst, shared-scratch serialization (/audit-renderer Dim 1).
  • Scratch high-water: shrink_blas_scratch_to_fit (cell unload, resize), shrink_tlas_to_fit / shrink_tlas_scratch_to_fit (draw_frame tail, on the next slot) respect the reserve floors (MIN_TLAS_INSTANCE_RESERVE = WORKING_SET_FLOOR = 8192).
  • check_pool_growth() warns/errors on vertex/index pool caps per upload; exact-content geometry sharing (mesh/geometry_sharing.rs, fingerprint reused for batch dedup and registration, #4803) and the live-budget-gated chunked geometry rebuild (geometry_rebuild_needs_idle) keep pool growth O(unique geometry). Texture staging is bounded by a byte budget (DEFAULT_STAGING_BUDGET_BYTES), not a texture count. BGSM/BGEM cache half-evicts (a full flush reintroduces the cold-restart herd). NifImportRegistry LRU (BYRO_NIF_CACHE_MAX, 2048). The Starfield CDB MaterialIndex builds on a named background thread started at discover_starfield_cdbs, single-flight per CDB (CdbIndexSlot = Arc<OnceLock<…>>) and serialised process-wide so N CDBs never stack N peaks — a first .mat lookup that builds inline on the main thread is the regression (#5291, fixed in dd759b762; re-measured 1.6 s / 247 MB peak / ~115 MB resident per CDB, memory-budget.md "Material index" row; #5277's per-object effect_blend String map is part of that footprint). Archive handles are shared process-wide (Archive::open_shared (asset_provider/archive.rs), validated by length + mtime), so a provider rebuild at a door transition costs ~0.1 ms, not ~125 ms (#5288, 5402b2530) — a loader re-parsing file tables is the regression. Dynamic-RGBA staging (dynamic_rgba.rs): retry cadence under a persistent host-visible failure is part of the #4889 degrade contract — #5275 scoped the warn to the episode and #5528 (e588e5e67) backs the arena growth off for RETRY_INTERVAL_FRAMES (120) after a failed attempt, unless the request shrinks (growth_retries_back_off_during_a_failure_episode, growth_gate_sits_before_the_allocation); a failing vkAllocateMemory every frame is the regression.
  • Deferred-destroy countdown = MAX_FRAMES_IN_FLIGHT; freeing earlier writes in-flight GPU memory (CRITICAL). Backlog counters must be read by rt.integrity.
    Output: /tmp/audit/performance/dim_3.md

Dimension 4: SSBO sizing & per-frame upload

Paths: crates/renderer/src/vulkan/scene_buffer/{constants,upload,buffers}.rs, crates/renderer/src/vulkan/material.rs, byroredux/src/material_translate.rs, crates/core/src/ecs/components/material.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/scene_buffer crates/renderer/src/vulkan/material.rs; ctx.scratch on a bench cell.
Guard: gpu_instance_is_160_bytes_std430_compatible, gpu_instance_does_not_re_expand_with_per_material_fields; the dirty-gate tests {instance,indirect,light,material}_hash_tests.rs, per_frame_light_upload_does_not_use_siphash (light_history.rs); bone_world_slot_needs_copy tests (upload.rs); hash_gpu_material_fields_covers_every_gpu_material_field.
Checklist (sizes: memory-budget.md — cite its current total; it moved when instance SSBOs became grow-on-demand):

  • Upload cost is O(live data), never O(capacity): instance SSBOs start at INITIAL_INSTANCE_CAPACITY and double to MAX_INSTANCES; eager MAX_INSTANCES allocation or a full-capacity memcpy of a near-empty buffer is the waste. MAX_MATERIALS 16 384; MAX_INDIRECT_DRAWS = MAX_INSTANCES. GpuInstance carries per-draw data only (layout drift: /audit-renderer Dim 3).
  • Content-hash dirty gates skip copy + flush on unchanged lights (hash_light_upload, which must also cover the LightHistory::remap table), instances (hash_instance_slice, hash_previous_model_slice), materials and indirect draws — steady state should skip. A gate that never hits (unstable sort key or material bytes, per-frame jitter in a hashed field) or is not invalidated when buffers are replaced (grow, resize) is the finding. hash_gpu_material_fields is one FxHasher::write over GpuMaterial::as_bytes (#4201, ~0.17 ms at 7 k draws) — check no second build/hash per draw survives (#4442).
  • Material dedup: N placements of one material → one GpuMaterial; report the ratio per cell (ctx.scratch); MaterialTable::intern O(1) amortized; upload O(unique) (min(table.len(), MAX_MATERIALS), not interned_count()).
  • PBR is resolved ONCE at import (Material::resolve_pbr); the draw loop must not call classify_pbr_keyword (/audit-nifal Dim 1).
  • Bone-world upload is O(dirty slots): upload_bone_worlds takes dirty_slot_offsets from pose_dirty and stages vk::BufferCopy only for changed slots (bone_world_slot_states); a full-range copy is the regression.
    Output: /tmp/audit/performance/dim_4.md

Dimension 5: GPU pipeline & pass efficiency

Paths: the inventory table — context/{draw,geometry_pass,post_passes,dispatch_skin_and_cluster}.rs, crates/renderer/shaders/, vulkan/{volumetrics,bloom,svgf,taa,composite,ssao,sky_cube,groundcover,groundcover_models,exposure_meter}.rs; docs/audits/PERFORMANCE_FIX_STATUS_2026-09-26.md records which perf issues are implemented-but-unmeasured.
First step: git log --since=… --format='%h %s' -- crates/renderer/shaders crates/renderer/src/vulkan/context; read gpu_ms: on a control bench and diff against ROADMAP.
Checklist:

  • Every pass is O(pixels) / O(froxels) / fixed, never O(meshes). Froxels are resolution-derived (froxel_extent; VolumetricsConfig::default in upscaling.rs), and per-froxel ray count is gated (sun term on sunTermLive, local rays skipped at zero scattering, rim probes above tier 0, known-empty transport slots skip history work); filter_fog_volumes_for_grid culls distant emitters before dispatch. Bloom is a pure O(pixels) pyramid (2×2-box downsample + in-place apply). SVGF = 1 temporal + 3 à-trous dispatches under one bracket.
  • The sky-cube bake is a fixed per-frame cost that also runs in interiors that never sample it, and ground-cover scatter traces the TLAS per candidate: quantify with sky_cube_ms / groundcover_scatter_ms before proposing a gate (a gate interacts with the exterior_sky_tint.w ready flag — /audit-renderer Dim 2).
  • TLAS rebuild-vs-refit frequency; a missing AS build→read barrier is HIGH.
  • G-buffer bandwidth: 8 colour attachments × 2 FIF; under --upscaler taa the reactive mask is sampled by TAA (#4944) but the T&C mask is written unsampled (known cost). Opaque draws that allow early fragment tests go through pipeline_early (triangle_early.frag.spv); eligibility admits material kinds 0..=MATERIAL_KIND_MAX_LIGHTING_SHADER (16, #5057; early_fragment_kinds_have_no_discard_or_depth_write_path) — a draw losing that eligibility pays late-Z shading.
  • Disney ALU is in include/pbr.glsl — lobes must not run for fragments that never reach them. #5249's transmission shadow trace (up to MAX_TRANSMISSION_SELF_SKIPS 8 self-skips, plus the glass leg of the remaining segment since #5455/#5565) is not governed by the ray tier, but #5401 measured it: a pinned-tier A/B with the trace gated off moved gpu_main_render by at most ~3 % on FO4 InstituteConcourse/MedTekResearch01 and Skyrim WhiterunBanneredMare — inside noise, no change made. Re-open only with a worst-case view (large on-screen share of SOFT_LIGHTING/BACK_LIGHTING/TRANSLUCENCY materials, light behind) and the same A/B. Legacy WRS arrays exist only with ENABLE_LEGACY_WRS (shipped 0; NUM_RESERVOIRS applies only then) — a shipped triangle.frag.spv / triangle_early.frag.spv built with it on taxes every frame (scripts/check-shader-artifacts.sh rebuilds both). inv_vp is computed once on the CPU (UBO); a shader-side per-invocation inverse() is the regression.
  • Depth-history copy runs only when scene_has_effect_soft_material; an every-frame copy is the regression. A perf "win" that is only an adaptive-ray-tier drop is not a win (bench discipline).
  • Cold blend-variant compile is the multi-second stall: the pipeline cache must keep persisting when variants are created (save_pipeline_cache_if_grown).
  • Speculative-Vulkan caveat: render-pass/barrier/pipeline findings invisible to cargo test need RenderDoc evidence or a revert plan — state confidence.
    Output: /tmp/audit/performance/dim_5.md

Dimension 6: Skinning & BLAS cost

Paths: crates/renderer/src/vulkan/skin_compute.rs, shaders/{skin_palette,skin_vertices}.comp, acceleration/blas_skinned.rs, context/{skinned_blas_refit,dispatch_skin_and_cluster}.rs, crates/core/src/ecs/resources/skin_slot_pool.rs (pose_dirty, try_mark_pose_dirty, clear_pose_dirty), byroredux/src/render/skinned.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/skin_compute.rs crates/renderer/src/vulkan/context/skinned_blas_refit.rs crates/core/src/ecs/resources/skin_slot_pool.rs; skin.coverage + skin_*_ms quantify every dispatch-count claim.
Guard: bin skin_dispatch_ran_rollback_scope_tests (app_frame.rs), palette_dispatch_uses_the_dirty_plan_and_rearms_late_bind_inverses (dispatch_skin_and_cluster.rs), a_reused_plan_scratch_keeps_capacity_and_drops_the_previous_plan (skin_compute.rs), skinned_blas_stays_fx_hashed.
Checklist (landed fixes — do not re-propose):

  • The palette multiply is its own pass and dispatches only dirty slot runs (plan_palette_dispatch); a dense full-range dispatch every frame is the regression. bind_inverses is persistent, uploaded once at first sight (per-frame upload O(first-sight entities)).
  • Dispatch-dirty gate: skip when the bone-pose hash (FNV-1a over the bone-world slice) is unchanged; pose_dirty is an FxHashSet on SkinSlotPool; the gate must never skip before SkinSlot.has_populated_output flips.
  • BLAS refit gate: refit_skinned_blas skips on built_this_frame or (has_populated_output && !is_dirty && has_skinned_blas); refit dominates, rebuild only on bone-count change or after SKINNED_BLAS_REFIT_THRESHOLD (600) + SKINNED_BLAS_REFIT_JITTER (60) via skinned_blas_refit_limit(entity_id) — a flat shared threshold makes every entity rebuild the same frame. SKINNED_BLAS_FLAGS is FAST_BUILD on purpose (measured; memory-budget.md).
  • Steady state writes 0 descriptors (SkinSlot.descriptor_bindings[frame] match skips vkUpdateDescriptorSets); a bailed draw_frame rolls back the pose-hash commit and requeues first-sight bind_inverses (skin_dispatch_ran, bind_inverse_upload_failed, app_frame.rs); morph deltas are shared per mesh (morph_delta_cache); MorphSlot eviction drains outside the skin_compute + accel_manager guard.
  • Known ceiling, quantify first: all skinned builds/refits share one scratch and serialize (skin.coverage, gpu_skin_blas_refit_ms).
    Output: /tmp/audit/performance/dim_6.md

Dimension 7: World streaming, cell transitions & parse cost

Paths: byroredux/src/streaming/, byroredux/src/{streaming_helpers,app_step,loading_screen}.rs, byroredux/src/env_translate/, byroredux/src/cell_loader/{unload,transition,load,work_budget,precombined,nif_import_registry,partial,water,reference_state,legacy_lod_index,lod_bands}.rs, byroredux/src/scene/world_setup.rs (per-frame apply_cell_climate_override / apply_cell_region_ambient), byroredux/src/asset_provider/texture_prefetch.rs, byroredux/src/npc_spawn.rs, crates/sfmaterial/src/reader.rs, crates/nif/src/{stream.rs,import/,blocks/}, crates/plugin/src/esm/records/{parse,index}.rs (index.rs: the load-order fold, merge_from).
First step: git log --since=… --format='%h %s' -- byroredux/src/streaming byroredux/src/cell_loader byroredux/src/scene/world_setup.rs crates/nif/src/stream.rs crates/plugin/src/esm; the cpu_ms: split classifies a stall (Dim 8).
Guard: byroredux/src/streaming_tests.rs; NIF dhat bounds (crates/nif/tests/heap_allocation_bounds*.rs, a sequential harness = false main since #5050); mid-life equip and corpse appearance share one AppearanceProviders archive set (#5061) — a loader opening its own archives on the main thread is the regression.
Checklist:

  • Boundary-cross stall. pre_parse_cell runs each fresh NIF / FO4 precombine as a pool task that extracts its own input (pre_parse_one; archive reads are lock-free positional reads) after the coordinator admits it against STREAM_PARSE_INPUT_BYTES (64 MiB) using the archive-declared size (mesh_declared_size) — a serial extract on the coordinator, or allocating ahead of admission, is the regression; serial fast path below PRE_PARSE_RAYON_MIN (8). FO4 precombines are CSG-decoded on the worker (decode_precombine_csg), so PrecombinedSpawnJob only spawns; the material merge is not on the worker — merge_precombine_materials (precombines) and merge_external_materials (ordinary partial imports) run on the main thread in finish_partial_import (cell_loader/partial.rs; pre-existing, quantify before filing). Streamed NIFs' textures are prefetched on the stream pool (PrefetchStore, STAGED_BYTE_CAP 256 MiB, cleared when the cell's apply ends); main-thread texture extraction per apply slice is the telemetry to diff. The worker's cross-request batch_keys memo (recv_next_batch_request, worker_batch_duplicate_skips) prevents duplicate parses in a burst. NIF mesh/collision import runs on the worker (PartialNifImport); the main-thread drain re-interns strings (reintern_imported_meshes) and runs the material merge above — any further import work back on the main thread is the regression.
  • Exterior apply and the interior door path are budgeted: STREAMING_APPLY_BUDGET seeds a FrameTimeBudget that load_references_budgeted yields against (placements, NPC sub-NIFs); InteriorCellApplyJob keeps the cursor across frames and cancels via unload_cell. Boot, save-load restore and debug loads (load_cell_with_masters) are unbudgeted by design. NPC spawn cost: npc_spawn_wall in the end-of-cell log.
  • Per-crossing costs that were once per-frame or per-rebuild (regression guards): apply_cell_climate_override resolves a grid cell once, success or failure (WorldStreamingState::climate_override_grid, same shape as applied_region_ambient; #5529, 2fd7ee270) — a failed XCCM re-resolving plus a warn! per frame is the regression; LOD-water recentering reuses the projection computed once in spawn_lod_water_plane (carried on LodWaterPlane) and uploads through upload_scene_meshes_batched with one fence wait (#5289, 310723850); the precombine job treats the worker's negative cache entry as terminal (lookup_precombine_cache → PrecombineCacheLookup::KnownFailure, #5522, 2ca9bc313) — a main-thread re-parse there has no catch_unwind; stale streaming payloads salvage their parsed imports (#5512, b5ae3e5cb).
  • Batched teardown: a crossing unloads its ring via unload_cells (one global shrink_storages + shrink_blas_scratch_to_fit after the last victim), despawning through World::despawn_batch → PackedStorage::remove_entities_erased (one sorted-merge pass, in-place swap+truncate); a per-entity Vec::remove loop is the quadratic regression. detach_victims_from_surviving_parents reads every victim's Parent under one query guard and tests membership by binary search (#5418, 9bbe9304e, detach_victims_takes_one_parent_guard_for_the_whole_sweep) — a per-victim world.get::<Parent> is the regression. UnloadPhaseTimings is wired only on the app_step.rs batch path.
  • NIF import-cache hit rate across cells; CDB parsed once per archive load. Parse allocation (dhat-testable — propose bounds): bulk arrays via read_pod_vec, allocate_vec results used (#[must_use]), block counters via entry().get_mut()/insert not or_insert(name.to_string()), skin/SSE scratches reserved up front, emitter extraction import-time only.
  • ESM parse on the first-cell path: parse_record_indexes_in_load_order walks each plugin (parse_esm_with_load_order, single-threaded: 1.3–2.0 s per vanilla master, SeventySix.esm 4.7 s, Starfield.esm 5.3 s) on the rayon pool and folds them in load order (#3813), so a whole order costs about its largest walk plus the fold (2026-09-28: FO4 + 7 DLC 3.69 → 2.85 s, Starfield 14 plugins 11.85 → 7.72 s; parse-time peak RSS + roughly the largest plugin's file size; table in docs/engine/memory-budget.md "ESM Index"). Regressions: anything order-dependent moving into the walk (slot/remap allocation, read_archive, merge_from — #3403, #3384, #3543), a walk touching shared state, or a caller installing StringsTableGuard on its own thread; load_order_parallel_tests digests the parallel index against the inline walk (synthetic chain always, real FNV/FO3/Skyrim SE/FO4 orders --ignored).
    Output: /tmp/audit/performance/dim_7.md

Dimension 8: Telemetry & camera-relative origin cost

Paths: crates/renderer/src/vulkan/gpu_timers.rs, crates/core/src/ecs/resources/mod.rs (ScratchTelemetry), byroredux/src/systems/{debug,metrics}.rs, byroredux/src/render/camera.rs, crates/renderer/src/vulkan/context/{build_and_upload_instances,draw,telemetry}.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/gpu_timers.rs byroredux/src/systems/debug.rs byroredux/src/systems/metrics.rs.
Checklist:

  • GPU timers: brackets read back via read_and_reset into GpuTimerSnapshot, MAX_FRAMES_IN_FLIGHT behind (never stalls); readers honour the _active flag (skipped pass ⇒ _ms == 0); the documented bracket count, QUERIES_PER_FRAME (56) and the inventory table must equal the live ones (doc-count rot recurs); BENCH_GPU_KEYS (main.rs) must follow the bracket order (bench_gpu_keys_match_the_reported_bracket_order); a sum over brackets must skip nested children (#4977). BYRO_PROFILE adds a PIPELINE_STATISTICS pool (opaque_fragment_invocations).
  • cpu_ms: (log_stats_system via cpu_breakdown) splits the frame into fence_wait / acquire / submit_present / ssbo_build / pipeline_compile / parameter_upload / fog_cluster / post_present / geom_rebuild / tlas_build / cmd_record / rof_* / atw_pre / atw_scheduler / atw_post / between_frames (fog_cluster is subtracted from cmd_record, #4810). Buckets nest, not sum: atw_pre / atw_scheduler / atw_post are sequential siblings, and atw_post ⊇ the whole render_one_frame (rof_pre_draw + rof_draw_call + rof_post_draw, so every draw_frame sub-bucket too; pinned by atw_bracket_nesting_tests, #4208); only between_frames lies outside render_one_frame (compositor throttle, loop sleep). Large fence_wait ⇒ GPU stuck on a prior submit; large atw_scheduler with fence_wait≈0 ⇒ pure CPU; large atw_post with small rof_* ⇒ post-scheduler cell-load steps / uploads (with large rof_* it is the render path); large between_frames alone ⇒ not the engine. Release builds for dense cells.
  • ScratchTelemetry (ctx.scratch): capacity vs used vs wasted for renderer scratches and, after renderer_row_count, the engine's build_render_data scratches; diff for over-reserved scratch and for a scratch with no row.
  • Camera-relative origin: the origin snaps to the 4096-unit cell grid (RENDER_ORIGIN_SNAP); assemble_camera adds one look_at_mat4 (glam 0.33's math::camera::rh::view, c6b8d4584) and rebase_model_matrix runs inside the existing O(visible-instances) loop — verify no second pass. On a grid crossing origin_corrected_prev_view_proj preserves TAA/SVGF/FSR history (#1489); dropping history per crossing is a HIGH perf+quality regression.
    Output: /tmp/audit/performance/dim_8.md

Phase 3: Merge

  1. Read all /tmp/audit/performance/dim_*.md.
  2. Combine into docs/audits/AUDIT_PERFORMANCE_<TODAY>.md: Executive Summary (findings by severity; observed-vs-ROADMAP delta, not absolute FPS) · Hot Path Analysis (per-frame CPU + per-pass GPU cost table from gpu_timers / ScratchTelemetry, with the RT quality tier stated) · Findings (CRITICAL first, deduplicated; eroded guards separate from new issues) · Prioritized Fix Order (quick wins — scratch reuse, preallocation, gate restoration — before architectural changes) · Stale skill premises.
  3. Remove cross-dimension duplicates.

Phase 4: Cleanup

  1. rm -rf /tmp/audit/performance — skip when launched by /audit-suite, which reconciles the report against the scratch files. 2. Tell the user the report is ready. 3. Suggest /audit-publish docs/audits/AUDIT_PERFORMANCE_<TODAY>.md.