Files
benilla-octo/crates/benilla-app/src/perf/mod.rs
T
SamandClaude Fable 5 a51f29b9d3 the perf pill leaves egui, and the field store goes flat
- the perf pill is ~25 quads on the player-UI pass — `{fps} fps {cpu} ms` and nothing else — so the egui lane sleeps whenever no dev panel is open, instead of the collapsed pill paying ~1 ms for egui's whole per-frame train
- the pill is the whole HUD: the expanded readout, the spike arrow and the spike-detection engine behind it are removed; a >250 ms hitch still logs
- WOW_CPU_CENSUS=<at>:<secs> — a per-thread CPU census that sums to the process total exactly, user/sys split; in Lower Blackrock Spire the main thread is ~41% of ~14.5 ms, the render thread ~17%, the compute pool ~25%, Metal's own workers ~12%
- the object field store goes flat: the wire's presence mask plus a dense value array replace a BTreeMap, so 5–10k descriptor probes a frame are two array indexings — the main thread ~0.5 ms/frame lighter at the same pin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:07:34 +07:00

145 lines
7.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `perf` — performance instrumentation + the standing dev HUD ([`PerfPlugin`]).
//!
//! Owns the **frame-cost measurement layer** that every future subsystem is measured against (the
//! standard), and draws the always-on cost pill (top-center) — the whole HUD since 1454; anything
//! deeper is an instrument's job (the journal, the probes, Tracy). The **dev chord + `P`** toggles
//! it (`Ctrl+Shift+P` — decisions 0585/0867/0870).
//!
//! The concerns, one file each:
//! - [`clock`] — the three CPU clocks (process, main thread, machine) every number is denominated in,
//! - [`stats`] — the rolling windows the pill reads,
//! - [`hud`] — the standing cost pill,
//! - [`trace`] / [`journal`] — the two CSV instruments (`WOW_STREAM_TRACE`, `WOW_FPS_JOURNAL`),
//! - [`stall`] — the stuck-main-thread self-sampler (macOS),
//! - [`census`] — the env-gated premise counters.
//!
//! **The law the whole surface obeys (0717): while synced, wall frame time measures the display's
//! present grant, not our cost.** Only the CPU series measure work. That is why the pill's headline
//! is `cpu ms` and not fps — on a 120 Hz-adaptive panel, cost can double with the granted interval
//! unchanged and framerate unmoved, and the pill's old red threshold (fps < 58) sat ~5.7× above a
//! healthy frame. fps stays on the pill — dim and small, the familiar anchor, never the thing
//! being watched (1448).
//!
//! The budget is a **60 fps floor = 16.7 ms; no frame should exceed it** — but note that the
//! *missed-interval* threshold is derived from the interval we actually observe, not from this
//! constant (see [`stats::DROPPED_FACTOR`]). Deep per-system attribution is via Tracy
//! (`cargo run --features tracy`) — the `info_span!` markers on the hot systems feed it.
//!
//! ⚠️ **macOS/Metal — GPU timing.** We have `Features::TIMESTAMP_QUERY` on this machine, but
//! **`TIMESTAMP_QUERY_INSIDE_PASSES` is `false`**: Apple GPUs sample counters only at stage
//! boundaries (`MTLCounterSamplingPoint::AtStageBoundary`). Bevy's `RenderDiagnosticsPlugin` writes
//! its timestamps *inside* passes, so on Apple Silicon every span falls through to the CPU-only
//! branch and the store carries **zero** `elapsed_gpu` paths — verified on a live run: 14 diagnostic
//! paths, all `elapsed_cpu`. (bevy_render's own "Vulkan and DX12 only" comment is stale as a
//! statement about the *platform* — an Intel Mac would report `INSIDE_PASSES` and emit GPU spans.)
//! Pass-boundary `timestamp_writes` **does** work here and is the route to real GPU-ms — measured
//! 0.015/0.019/0.049 ms across 256²/1920×1080/4096² clears, 12/12 reproducible. It needs two
//! sentinel render-graph nodes at ~0.03 ms/frame, and the query set must be resolved in a
//! *different* command buffer than the timed pass or Metal returns zeros. Until that is built, a
//! GPU-bound frame is identified rather than timed: it is the one that runs long while the CPU
//! meters stay flat (read them side by side in the journal or a probe line).
mod census;
mod clock;
mod gpu;
mod hud;
mod journal;
#[cfg(target_os = "macos")]
mod stall;
mod stats;
mod trace;
use bevy::prelude::*;
use bevy::render::diagnostic::RenderDiagnosticsPlugin;
pub(crate) use clock::{process_cpu_secs, system_cpu_ticks};
pub(crate) use gpu::GpuMsShared;
pub(crate) use hud::PerfHud;
pub(crate) use journal::FpsJournalPlugin;
/// The frame budget: a 60 fps floor. No frame should exceed this.
pub const FRAME_BUDGET_MS: f32 = 1000.0 / 60.0;
pub struct PerfPlugin;
impl Plugin for PerfPlugin {
fn build(&self, app: &mut App) {
// Render-pass timing (CPU-only on Apple Silicon — see the module header). Harmless if
// the backend can't record it; also the hook Tracy GPU uses on Vulkan/DX12.
app.add_plugins(RenderDiagnosticsPlugin)
.init_resource::<stats::FrameStats>()
.init_resource::<PerfHud>()
.insert_resource(trace::StreamTrace {
path: std::env::var("WOW_STREAM_TRACE").unwrap_or_default(),
frame: 0,
log_until: 0,
prev_cpu_secs: None,
prev_pipes_created: 0,
})
.add_systems(
Update,
// `toggle_hud` needs no ordering against the UI keyboard feed any more: its dev
// chord can't be typed text, so there's no `UiKeyboardCapture` to read (decision 0585).
(
hud::toggle_hud,
hud::refresh_hud_snapshot,
stats::sample_frame_time,
),
)
// The pill rides the player-UI quad pass (1453): appended with the other overlay
// producers, so the HUD costs a Vec clone and never touches the egui lane (1454).
.add_systems(Update, hud::pill_quads.in_set(crate::ui_pass::UiQuadAppend))
// `Last`, so a row carries everything this frame's Stream chain did — and the reset runs
// whether or not anything is tracing, or the counters would accumulate forever.
.add_systems(Last, trace::trace_stream);
// `WOW_MESH_EVENTS=1` — who churns Mesh assets per frame? The premise counter behind
// the Stormwind trace's `allocate_and_free_meshes` row (0.86 ms/frame at a PARKED pin):
// the allocator answers every Modified with a free+realloc, so a steady scene should
// show ~zero here. Printed once a second with the top mutated ids' first sighting.
if std::env::var_os("WOW_MESH_EVENTS").is_some() {
app.add_systems(Update, census::count_mesh_events);
}
// `WOW_CAM_CHANGED=1` — is the world camera's transform bit-stable at a parked pin?
// The premise counter for gating the per-submesh visibility sweep on frame-stable
// camera inputs: if the controller rewrites an equal transform every frame, change
// detection fires anyway and the gate can never hold — the controller's no-op write
// is then the first fix, not the sweep's.
if std::env::var_os("WOW_CAM_CHANGED").is_some() {
app.add_systems(bevy::app::PostUpdate, census::count_camera_changes);
}
// `WOW_ARCH_CENSUS=<secs>` — one archetype census at t=secs: every non-empty
// archetype's entity count beside its component set, largest first. The exact by-lane
// entity picture (1354's anchor census generalized to every lane at once), for sizing
// which populations are worth collapsing before designing any collapse.
if let Some(at) = std::env::var("WOW_ARCH_CENSUS")
.ok()
.and_then(|v| v.parse::<f32>().ok())
{
app.insert_resource(census::ArchCensusAt(at));
app.add_systems(Last, census::arch_census);
}
// `WOW_ROW_BLOAT=<n>` — spawn n inert clones of a live static row (see the system doc):
// the d(cpu_ms)/d(rows) instrument the consolidation option divides by.
if std::env::var("WOW_ROW_BLOAT")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.is_some_and(|n| n > 0)
{
app.add_systems(Update, census::row_bloat);
}
// `WOW_CPU_CENSUS=<at>:<secs>` — the per-thread CPU census (see its module doc): where
// the pill's cpu-ms goes, thread by thread, summing exactly to the process total.
#[cfg(target_os = "macos")]
if let Some(c) = census::cpu_census::CpuCensus::from_env() {
app.insert_resource(c);
app.add_systems(Update, census::cpu_census::cpu_census);
}
#[cfg(target_os = "macos")]
stall::plugin(app);
// `WOW_GPU_MS=1` — the whole-frame GPU meter (its module doc owns the design and the
// 1389 resolve-on-a-later-submission trap). Registers nothing when off, so campaign
// anchors never carry its ~0.03 ms sentinel cost uninvited.
gpu::plugin(app);
}
}