"Audit GPU/CPU performance — hot paths, allocations, draw batching, GPU memory pressure, SSBO sizing, telemetry"
Install
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-performance Install via the SkillsCat registry.
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. Defaultdeep.
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 inside4c9a5b36..99933f87b" reading wasb9e961eebmoving 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 theircamera_poscolumns match; and R6a-stale-25: thea37fcba3crecord 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 setBYROREDUX_FIXED_DT. PointBYROREDUX_SETTINGS_PATHat a fresh file and pass--upscalerexplicitly, or a persistedrender.upscalerchanges the run (#4947); thebench:line reportsupscaler=/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.pymust stay byte-stable across commits or cross-commit comparisons mean nothing. Never assert it from memory (#4024): runscripts/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 (againsta37fcba3cthe verdict is DIVERGED with two edits, each carrying a recorded disposition the script prints — #5451, fixed in4b9472909: the ProspectorUpdate.bsamount was shown measurement-neutral by a same-session interleaved control, thecamera_poscolumns are append-only; the verdict, exit code and--strictare 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 Nfor A/B, else report the tier. - Hooks (
byro-dbgafter--bench-hold; nobench-statscommand exists): the once-a-secondgpu_ms:/cpu_ms:lines,MetricsSnapshot, consolectx.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
- Parse
$ARGUMENTS.mkdir -p /tmp/audit/performance. - Dedup baseline:
gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/performance/issues.json. - Read ROADMAP.md Bench-of-record + compat table, and the newest
docs/audits/AUDIT_PERFORMANCE_*.md. - 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_keyis a 12-tuple(u8×4, u32×8): slot 0rt_only(off-frustum RT occluders last, so theMAX_INSTANCEScap drops them before raster), 1 composition phase, 2 blend class, 3no_sorter(true alpha-over only; opaque/additive write 0 — #4191), then state/mesh/depth. The opaque depth slot isopaque_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 higherbench_draws_*counts are accepted cost. EveryPipelineKeyaxis 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 (Dugout1935/326b/12c→2367/659b/43c) was a camera move (#5128): pinned to one pose,99933f87bmerges 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 thebench_draws_raster_cmdscolumn of the baseline TSVs —bench_draws_cmdsalso counts RT-only occluders that never enter the sorter; a row withbench_draws_batches > bench_draws_raster_cmdscannot come from one capture.- Static-mesh loop probes
GlobalTransformbefore the visibility/bounds siblings (#1377);build_instance_mapreuses a caller scratch (#4193). needs_two_sided_blend_split=is_blend && two_sided && order_dependent_glass(noz_writelimb); structurally dormant for engine-classified glass (collect_static_mesh_drawsclearstwo_sidedforMATERIAL_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'sblended_glass_flag_still_splits_groupsstill 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_BYTES256 MB,MAX_BLAS_BUDGET_BYTES1 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_budgetcounts the batch'spending_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 inpending_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_restoremust still skip a set that cannot fit the budget (else evict→rebuild never converges — the ~95 k-draw Starfield city). Current-frame BLAS ownership isStaticBlasWorkingSet(acceleration/static_working_set.rs: dense handle-indexed generation stamps, no per-frame hashing, #4804). Documented ceilings, quantify first:--gridfalse-evict burst, shared-scratch serialization (/audit-rendererDim 1). - Scratch high-water:
shrink_blas_scratch_to_fit(cell unload, resize),shrink_tlas_to_fit/shrink_tlas_scratch_to_fit(draw_frametail, 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).NifImportRegistryLRU (BYRO_NIF_CACHE_MAX, 2048). The Starfield CDBMaterialIndexbuilds on a named background thread started atdiscover_starfield_cdbs, single-flight per CDB (CdbIndexSlot=Arc<OnceLock<…>>) and serialised process-wide so N CDBs never stack N peaks — a first.matlookup that builds inline on the main thread is the regression (#5291, fixed indd759b762; re-measured 1.6 s / 247 MB peak / ~115 MB resident per CDB,memory-budget.md"Material index" row; #5277's per-objecteffect_blendStringmap 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 forRETRY_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 failingvkAllocateMemoryevery frame is the regression.- Deferred-destroy countdown =
MAX_FRAMES_IN_FLIGHT; freeing earlier writes in-flight GPU memory (CRITICAL). Backlog counters must be read byrt.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_CAPACITYand double toMAX_INSTANCES; eagerMAX_INSTANCESallocation or a full-capacity memcpy of a near-empty buffer is the waste.MAX_MATERIALS16 384;MAX_INDIRECT_DRAWS=MAX_INSTANCES.GpuInstancecarries per-draw data only (layout drift:/audit-rendererDim 3). - Content-hash dirty gates skip copy + flush on unchanged lights (
hash_light_upload, which must also cover theLightHistory::remaptable), 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_fieldsis oneFxHasher::writeoverGpuMaterial::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::internO(1) amortized; upload O(unique) (min(table.len(), MAX_MATERIALS), notinterned_count()). - PBR is resolved ONCE at import (
Material::resolve_pbr); the draw loop must not callclassify_pbr_keyword(/audit-nifalDim 1). - Bone-world upload is O(dirty slots):
upload_bone_worldstakesdirty_slot_offsetsfrompose_dirtyand stagesvk::BufferCopyonly 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::defaultinupscaling.rs), and per-froxel ray count is gated (sun term onsunTermLive, local rays skipped at zero scattering, rim probes above tier 0, known-empty transport slots skip history work);filter_fog_volumes_for_gridculls 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_msbefore proposing a gate (a gate interacts with theexterior_sky_tint.wready flag —/audit-rendererDim 2). - TLAS rebuild-vs-refit frequency; a missing AS build→read barrier is HIGH.
- G-buffer bandwidth: 8 colour attachments × 2 FIF; under
--upscaler taathe 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 throughpipeline_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 toMAX_TRANSMISSION_SELF_SKIPS8 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 movedgpu_main_renderby 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 withENABLE_LEGACY_WRS(shipped 0;NUM_RESERVOIRSapplies only then) — a shippedtriangle.frag.spv/triangle_early.frag.spvbuilt with it on taxes every frame (scripts/check-shader-artifacts.shrebuilds both).inv_vpis computed once on the CPU (UBO); a shader-side per-invocationinverse()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 testneed 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_inversesis 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_dirtyis anFxHashSetonSkinSlotPool; the gate must never skip beforeSkinSlot.has_populated_outputflips. - BLAS refit gate:
refit_skinned_blasskips onbuilt_this_frameor (has_populated_output && !is_dirty && has_skinned_blas); refit dominates, rebuild only on bone-count change or afterSKINNED_BLAS_REFIT_THRESHOLD(600) +SKINNED_BLAS_REFIT_JITTER(60) viaskinned_blas_refit_limit(entity_id)— a flat shared threshold makes every entity rebuild the same frame.SKINNED_BLAS_FLAGSisFAST_BUILDon purpose (measured; memory-budget.md). - Steady state writes 0 descriptors (
SkinSlot.descriptor_bindings[frame]match skipsvkUpdateDescriptorSets); a baileddraw_framerolls back the pose-hash commit and requeues first-sightbind_inverses(skin_dispatch_ran,bind_inverse_upload_failed,app_frame.rs); morph deltas are shared per mesh (morph_delta_cache);MorphSloteviction drains outside theskin_compute+accel_managerguard. - 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_cellruns 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 againstSTREAM_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 belowPRE_PARSE_RAYON_MIN(8). FO4 precombines are CSG-decoded on the worker (decode_precombine_csg), soPrecombinedSpawnJobonly spawns; the material merge is not on the worker —merge_precombine_materials(precombines) andmerge_external_materials(ordinary partial imports) run on the main thread infinish_partial_import(cell_loader/partial.rs; pre-existing, quantify before filing). Streamed NIFs' textures are prefetched on the stream pool (PrefetchStore,STAGED_BYTE_CAP256 MiB, cleared when the cell's apply ends); main-thread texture extraction per apply slice is the telemetry to diff. The worker's cross-requestbatch_keysmemo (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_BUDGETseeds aFrameTimeBudgetthatload_references_budgetedyields against (placements, NPC sub-NIFs);InteriorCellApplyJobkeeps the cursor across frames and cancels viaunload_cell. Boot, save-load restore and debug loads (load_cell_with_masters) are unbudgeted by design. NPC spawn cost:npc_spawn_wallin the end-of-cell log. - Per-crossing costs that were once per-frame or per-rebuild (regression guards):
apply_cell_climate_overrideresolves a grid cell once, success or failure (WorldStreamingState::climate_override_grid, same shape asapplied_region_ambient; #5529,2fd7ee270) — a failed XCCM re-resolving plus awarn!per frame is the regression; LOD-water recentering reuses the projection computed once inspawn_lod_water_plane(carried onLodWaterPlane) and uploads throughupload_scene_meshes_batchedwith 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 nocatch_unwind; stale streaming payloads salvage their parsed imports (#5512,b5ae3e5cb). - Batched teardown: a crossing unloads its ring via
unload_cells(one globalshrink_storages+shrink_blas_scratch_to_fitafter the last victim), despawning throughWorld::despawn_batch→PackedStorage::remove_entities_erased(one sorted-merge pass, in-place swap+truncate); a per-entityVec::removeloop is the quadratic regression.detach_victims_from_surviving_parentsreads every victim'sParentunder one query guard and tests membership by binary search (#5418,9bbe9304e,detach_victims_takes_one_parent_guard_for_the_whole_sweep) — a per-victimworld.get::<Parent>is the regression.UnloadPhaseTimingsis wired only on theapp_step.rsbatch 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_vecresults used (#[must_use]), block counters viaentry().get_mut()/insertnotor_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_orderwalks each plugin (parse_esm_with_load_order, single-threaded: 1.3–2.0 s per vanilla master,SeventySix.esm4.7 s,Starfield.esm5.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 indocs/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 installingStringsTableGuardon its own thread;load_order_parallel_testsdigests 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_resetintoGpuTimerSnapshot,MAX_FRAMES_IN_FLIGHTbehind (never stalls); readers honour the_activeflag (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_PROFILEadds a PIPELINE_STATISTICS pool (opaque_fragment_invocations). cpu_ms:(log_stats_systemviacpu_breakdown) splits the frame intofence_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_clusteris subtracted fromcmd_record, #4810). Buckets nest, not sum:atw_pre/atw_scheduler/atw_postare sequential siblings, andatw_post⊇ the wholerender_one_frame(rof_pre_draw+rof_draw_call+rof_post_draw, so everydraw_framesub-bucket too; pinned byatw_bracket_nesting_tests, #4208); onlybetween_frameslies outsiderender_one_frame(compositor throttle, loop sleep). Largefence_wait⇒ GPU stuck on a prior submit; largeatw_schedulerwithfence_wait≈0⇒ pure CPU; largeatw_postwith smallrof_*⇒ post-scheduler cell-load steps / uploads (with largerof_*it is the render path); largebetween_framesalone ⇒ not the engine. Release builds for dense cells.ScratchTelemetry(ctx.scratch): capacity vs used vs wasted for renderer scratches and, afterrenderer_row_count, the engine'sbuild_render_datascratches; 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_cameraadds onelook_at_mat4(glam 0.33'smath::camera::rh::view,c6b8d4584) andrebase_model_matrixruns inside the existing O(visible-instances) loop — verify no second pass. On a grid crossingorigin_corrected_prev_view_projpreserves 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
- Read all
/tmp/audit/performance/dim_*.md. - 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 fromgpu_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. - Remove cross-dimension duplicates.
Phase 4: Cleanup
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.