Files
benilla-octo/crates/benilla-app/src/perf/gpu.rs
T
SamandClaude Fable 5.1 8562fb0ef1 a crash leaves a report, and the wire's counts are capped
- a panic writes benilla-config/Diagnostics/crash-<time>.txt with the build id, the location, a backtrace and the last 200 log lines, and names the file as its last line, so a bug report can attach it (2266)
- every wire-count allocation hint is capped and the compressed update object reads through the cursor, so a malformed packet can no longer ask for gigabytes (2266)
- the 23 in-world run conditions are one set, the 45 probe variables one registry, the install-gated tests say when they skip, and every gate is timed (2266)
- one workspace-level lint allowance replaces 413 per-site attributes, and the decision corpus gets a lint in the gate (2266)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:38:59 +07:00

442 lines
20 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.
//! **`WOW_GPU_MS=1` — the whole-frame GPU meter** (1389's scoped follow-on, built 2026-08-18;
//! the drastic-options census' unknown #2 was "GPU frame time and VRAM headroom — no instrument
//! exists").
//!
//! Two plain render-graph nodes bracket the camera driver — `begin` writes timestamp 0 into a
//! two-slot query set from its command encoder, `end` writes timestamp 1 — legal on this machine
//! because we hold `TIMESTAMP_QUERY_INSIDE_ENCODERS` (Apple Silicon samples at stage boundaries;
//! bevy's own `RenderDiagnosticsPlugin` needs INSIDE_PASSES and reads zero GPU spans here — the
//! `perf` module header's ⚠ note, measured in 1389).
//!
//! **The resolve rides the NEXT frame's command buffer, ahead of that frame's own stamps.**
//! 1389's recorded trap: resolving the query set in the SAME command buffer as the timed work
//! returns zeros on Metal (5 of 6 reads; the 6th a plausible-looking near-miss) — resolve on a
//! later submission. The first build of this meter did that with a submission of its own: a
//! fresh encoder, a second `queue.submit` and an explicit `device.poll` in `Cleanup`, every
//! frame — a per-frame poll that traced at 1.8 ms on Intel's DX12 driver (2236) and priced
//! untraced in 2254. So the query set holds TWO stamp pairs and the frames alternate between
//! them ([`pairs`]): the `begin` node first resolves the pair the previous frame wrote — a later
//! submission, 1389's rule kept — and copies it into a free slot of a small ring of MAP_READ
//! buffers, on the graph's own shared encoder; then it writes this frame's pair. `Cleanup` only
//! kicks the map on the slot the graph just filled, and that map lands inside the NEXT frame's
//! own `queue.submit`, which runs wgpu's device maintenance as part of every submit
//! (`wgpu_core::device::queue::Queue::submit` ends in `Device::maintain(Poll)`) — the shape of
//! bevy's own `DiagnosticsRecorder`, and no submission or poll of the meter's own. The
//! published number is the freshest mapped delta, nanoseconds in one `AtomicU64` shared with
//! the main world — `FPS_PROBE` samples it per frame under the same env and prints its own
//! percentiles, so a leg gets `gpu_p50/gpu_p99` beside `cpu_ms` with zero cost when the env is
//! off (nothing registers).
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::Arc;
use bevy::core_pipeline::core_2d::{Opaque2d, Transparent2d};
use bevy::core_pipeline::core_3d::{AlphaMask3d, Opaque3d, Transparent3d};
use bevy::pbr::Shadow;
use bevy::prelude::*;
use bevy::render::graph::CameraDriverLabel;
use bevy::render::render_graph::{
Node, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel,
};
use bevy::render::render_phase::{ViewBinnedRenderPhases, ViewSortedRenderPhases};
use bevy::render::render_resource::{Buffer, BufferDescriptor, BufferUsages, MapMode};
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
use bevy::render::{Render, RenderApp, RenderSystems};
use bevy::ui_render::TransparentUi;
use wgpu::{QuerySet, QuerySetDescriptor, QueryType};
/// Is the meter armed? One read; everything registers behind it.
pub(crate) fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_GPU_MS").as_deref() == Ok("1"))
}
/// The freshest whole-frame GPU duration, nanoseconds. Written by the render app's readback,
/// read by `FPS_PROBE`/whoever in the main world. 0 = no reading yet.
#[derive(Resource, Clone)]
pub(crate) struct GpuMsShared(pub Arc<AtomicU64>);
/// **The wgpu resource census** — live buffers, textures and bind groups, off wgpu-core's own
/// registry report, refreshed once a frame by the render app and read by `FPS_PROBE` under the
/// same env (`wgpu_bufs=… wgpu_texs=… wgpu_bgs=…`). It is on the GPU meter because it prices
/// the GPU *lane's* CPU: every render pass wgpu encodes allocates and clears usage trackers
/// sized to the device's live buffer and texture counts (`wgpu_core::track::UsageScope`), so a
/// scene's pass-encode CPU carries a term that scales with residency, not with what is drawn —
/// a sampled profile of the crowd rig (1929's follow-on) put that term at roughly half of
/// `encode_render_pass`. The count is the number that lever moves.
#[derive(Resource, Clone)]
pub(crate) struct WgpuCensusShared(pub Arc<WgpuCensus>);
#[derive(Default)]
pub(crate) struct WgpuCensus {
pub buffers: AtomicU64,
pub textures: AtomicU64,
pub bind_groups: AtomicU64,
/// Draw calls the render phases will issue this frame, per family — see [`count_draws`].
pub draws_opaque: AtomicU64,
pub draws_transparent: AtomicU64,
pub draws_shadow: AtomicU64,
pub draws_ui: AtomicU64,
}
/// Render world, `Cleanup`: refresh the census. wgpu-core's report takes each registry's read
/// lock and counts live ids — microseconds, and only under the meter's env.
fn census(instance: Res<bevy::render::renderer::RenderInstance>, shared: Res<WgpuCensusShared>) {
let Some(report) = instance.generate_report() else {
return;
};
let hub = &report.hub;
let c = &shared.0;
c.buffers
.store(hub.buffers.num_allocated as u64, Ordering::Relaxed);
c.textures
.store(hub.textures.num_allocated as u64, Ordering::Relaxed);
c.bind_groups
.store(hub.bind_groups.num_allocated as u64, Ordering::Relaxed);
}
/// Render world, `Cleanup`: the frame's draw-call count per phase family, off the phases bevy
/// just rendered — the number the pass-encode CPU on the render thread scales with (wgpu-core
/// validates and wgpu-hal encodes per draw, and on Metal a draw is also what the driver's
/// kernel-side work is measured in). A binned phase issues one draw per bin (Metal has no
/// multi-draw indirect, so a multidrawable batch set still draws bin by bin) plus one per
/// unbatchable entity and per non-mesh item; a sorted phase issues one per item whose batch
/// range survived batching (a merged item's range is emptied into its predecessor's).
#[allow(clippy::type_complexity)]
fn count_draws(
shared: Res<WgpuCensusShared>,
opaque: Option<Res<ViewBinnedRenderPhases<Opaque3d>>>,
mask: Option<Res<ViewBinnedRenderPhases<AlphaMask3d>>>,
transparent: Option<Res<ViewSortedRenderPhases<Transparent3d>>>,
shadow: Option<Res<ViewBinnedRenderPhases<Shadow>>>,
ui: Option<Res<ViewSortedRenderPhases<TransparentUi>>>,
two_d: Option<Res<ViewSortedRenderPhases<Transparent2d>>>,
opaque_2d: Option<Res<ViewBinnedRenderPhases<Opaque2d>>>,
) {
fn binned<BPI: bevy::render::render_phase::BinnedPhaseItem>(
phases: Option<Res<ViewBinnedRenderPhases<BPI>>>,
) -> u64 {
phases
.map(|p| {
p.0.values()
.map(|phase| {
let multi: usize =
phase.multidrawable_meshes.values().map(|b| b.len()).sum();
let unbatchable: usize = phase
.unbatchable_meshes
.values()
.map(|u| u.entities.len())
.sum();
let non_mesh: usize = phase
.non_mesh_items
.values()
.map(|n| n.entities.len())
.sum();
multi + phase.batchable_meshes.len() + unbatchable + non_mesh
})
.sum::<usize>() as u64
})
.unwrap_or(0)
}
fn sorted<SPI: bevy::render::render_phase::SortedPhaseItem>(
phases: Option<Res<ViewSortedRenderPhases<SPI>>>,
) -> u64 {
phases
.map(|p| {
p.0.values()
.map(|phase| {
phase
.items
.iter()
.filter(|i| !i.batch_range().is_empty())
.count()
})
.sum::<usize>() as u64
})
.unwrap_or(0)
}
let c = &shared.0;
c.draws_opaque
.store(binned(opaque) + binned(mask), Ordering::Relaxed);
c.draws_transparent
.store(sorted(transparent), Ordering::Relaxed);
c.draws_shadow.store(binned(shadow), Ordering::Relaxed);
c.draws_ui.store(
sorted(ui) + sorted(two_d) + binned(opaque_2d),
Ordering::Relaxed,
);
}
const RING: usize = 4;
/// Ring-slot states: a copy may only target a FREE slot — submitting into a mapped (or
/// map-pending) buffer is a wgpu validation error, which the first live run demonstrated.
const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
/// Which stamp pair a graph run writes and which it resolves, for the [`GpuStamp::frame`] it
/// runs under: the two pairs alternate, and a run resolves the pair the run before it wrote —
/// a later submission than the timed work (1389's rule), and one whose queries the previous
/// command buffer reset before writing (a resolve of a never-reset query is 2211's hang). Pair
/// `p` is queries `2p` (begin) and `2p + 1` (end).
fn pairs(frame: usize) -> (u32, u32) {
#[allow(clippy::cast_possible_truncation)]
let write = (frame % 2) as u32;
(write, 1 - write)
}
/// Render-world state: the query set (two stamp pairs, alternating by frame), the resolve
/// buffer, and the mapping ring.
#[derive(Resource)]
struct GpuStamp {
query_set: QuerySet,
/// A 1×1 target the sentinel passes clear — real (if trivial) work, because an EMPTY pass
/// never reaches the Metal encoder and samples nothing (this meter's second zero-read).
sentinel_view: wgpu::TextureView,
resolve: Buffer,
ring: Vec<(Buffer, Arc<AtomicU8>)>,
/// `Cleanup`s this resource has seen, counting the one that created it; the graph run that
/// follows each writes pair `frame % 2` and resolves the other ([`pairs`]). **The creation
/// `Cleanup` precedes the first stamped frame**: `init_stamp` inserts the resource in
/// `Cleanup`, the scheduler's sync point applies it before `readback` runs in the same set,
/// and the sentinel nodes that write — and, in wgpu, reset — the queries only run in the
/// NEXT frame's graph. A resolve on that first run would read a query pool no command has
/// ever reset, which Vulkan forbids (`VUID-vkCmdCopyQueryPoolResults-None-09402`) and
/// answers, on NVIDIA's driver, by waiting on it forever — a GPU hang, the TDR, and the
/// lost device 2205 met at the swapchain acquire. Metal hands back zeros for the same read,
/// which is why the meter's first live runs (1389) saw "zeros" and never a hang. Decision
/// 2211. So the first run is armed with no [`Self::slot`] and resolves nothing.
frame: usize,
/// The ring slot the NEXT graph run copies the previous run's stamps into: chosen in
/// `Cleanup` from the FREE slots — `None` while no run has written a pair yet, or while the
/// ring is full (that sample is skipped; the meter is a meter). The `begin` node reads it,
/// the `Cleanup` after that run maps it.
slot: Option<usize>,
period: f32,
shared: Arc<AtomicU64>,
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct GpuStampBeginLabel;
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct GpuStampEndLabel;
struct GpuStampNode {
index: u32,
}
impl Node for GpuStampNode {
fn run(
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext,
world: &World,
) -> Result<(), NodeRunError> {
if let Some(stamp) = world.get_resource::<GpuStamp>() {
let (write, resolve) = pairs(stamp.frame);
if self.index == 0 {
if let Some(slot) = stamp.slot {
// The previous run's pair, resolved and copied out on the graph's own
// encoder: a later submission than the stamps it reads (1389), and none of
// the meter's own.
let encoder = render_context.command_encoder();
encoder.resolve_query_set(
&stamp.query_set,
resolve * 2..resolve * 2 + 2,
&stamp.resolve,
0,
);
encoder.copy_buffer_to_buffer(&stamp.resolve, 0, &stamp.ring[slot].0, 0, 16);
}
}
// A SENTINEL PASS, not a bare `write_timestamp`: Apple Silicon samples counters only
// at stage boundaries, and an encoder-level stamp with no stages around it resolves
// to zero (measured on this meter's first live run — 1389's "two sentinel nodes"
// meant exactly this). An empty compute pass is the cheapest stage boundary there is.
let (begin, end) = if self.index == 0 {
(Some(write * 2), None)
} else {
(None, Some(write * 2 + 1))
};
render_context
.command_encoder()
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("gpu-ms sentinel"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &stamp.sentinel_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: Some(wgpu::RenderPassTimestampWrites {
query_set: &stamp.query_set,
beginning_of_pass_write_index: begin,
end_of_pass_write_index: end,
}),
occlusion_query_set: None,
});
}
Ok(())
}
}
/// Build [`GpuStamp`] on the first render frame — the device does not exist yet at plugin
/// build time (`RenderPlugin` creates it in `finish`), which a startup-eager version learned
/// as a `RenderDevice does not exist` panic on its first live run.
fn init_stamp(
mut commands: Commands,
stamp: Option<Res<GpuStamp>>,
device: Option<Res<RenderDevice>>,
queue: Option<Res<RenderQueue>>,
shared: Res<GpuMsShared>,
) {
if stamp.is_some() {
return;
}
let (Some(device), Some(queue)) = (device, queue) else {
return;
};
let query_set = device.wgpu_device().create_query_set(&QuerySetDescriptor {
label: Some("gpu-ms stamps"),
ty: QueryType::Timestamp,
count: 4,
});
let resolve = device.create_buffer(&BufferDescriptor {
label: Some("gpu-ms resolve"),
size: 16,
usage: BufferUsages::QUERY_RESOLVE | BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let ring = (0..RING)
.map(|_| {
(
device.create_buffer(&BufferDescriptor {
label: Some("gpu-ms read"),
size: 16,
usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
Arc::new(AtomicU8::new(FREE)),
)
})
.collect();
let sentinel = device
.wgpu_device()
.create_texture(&wgpu::TextureDescriptor {
label: Some("gpu-ms sentinel"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
commands.insert_resource(GpuStamp {
query_set,
sentinel_view: sentinel.create_view(&wgpu::TextureViewDescriptor::default()),
resolve,
ring,
frame: 0,
slot: None,
period: queue.get_timestamp_period(),
shared: shared.0.clone(),
});
}
/// `Cleanup`, after the graph's own submission: kick the map on the slot the graph just filled,
/// publish every landed mapping, and arm a slot for the next run. No encoder, no submit and no
/// poll of the meter's own — the map lands in the next frame's `queue.submit`, which maintains
/// the device (the module header).
fn readback(stamp: Option<ResMut<GpuStamp>>) {
let Some(mut stamp) = stamp else { return };
// The slot the graph run just copied into: FREE when armed, so that submission is the only
// one in flight on it, and it is out of the arming below until the mapping is read and
// released — submitting into a mapped or map-pending buffer is a wgpu validation error,
// which the first live run demonstrated.
if let Some(i) = stamp.slot.take() {
let flag = stamp.ring[i].1.clone();
flag.store(PENDING, Ordering::Relaxed);
stamp.ring[i]
.0
.slice(..)
.map_async(MapMode::Read, move |r| {
flag.store(if r.is_ok() { MAPPED } else { FREE }, Ordering::Relaxed);
});
}
// Publish every landed mapping; slots return to FREE.
for i in 0..RING {
if stamp.ring[i].1.load(Ordering::Relaxed) == MAPPED {
let (t0, t1) = {
let data = stamp.ring[i].0.slice(..).get_mapped_range();
let words: &[u64] = bytemuck::cast_slice(&data);
(words[0], words[1])
};
stamp.ring[i].0.unmap();
stamp.ring[i].1.store(FREE, Ordering::Relaxed);
if t1 > t0 {
let ns = (t1 - t0) as f64 * f64::from(stamp.period);
stamp.shared.store(ns as u64, Ordering::Relaxed);
}
}
}
// Arm the next run — which resolves the pair the run that just finished wrote — unless no
// run has written one yet (the creation `Cleanup`; see `frame`). None FREE → no sample.
if stamp.frame >= 1 {
stamp.slot = (0..RING).find(|&i| stamp.ring[i].1.load(Ordering::Relaxed) == FREE);
}
stamp.frame += 1;
}
pub(crate) fn plugin(app: &mut App) {
if !enabled() {
return;
}
let shared = Arc::new(AtomicU64::new(0));
let counts = Arc::new(WgpuCensus::default());
app.insert_resource(GpuMsShared(shared.clone()));
app.insert_resource(WgpuCensusShared(counts.clone()));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app.insert_resource(GpuMsShared(shared));
render_app.insert_resource(WgpuCensusShared(counts));
render_app.add_systems(
Render,
(init_stamp, readback, census, count_draws)
.chain()
.in_set(RenderSystems::Cleanup),
);
let mut graph = render_app.world_mut().resource_mut::<RenderGraph>();
graph.add_node(GpuStampBeginLabel, GpuStampNode { index: 0 });
graph.add_node(GpuStampEndLabel, GpuStampNode { index: 1 });
graph.add_node_edge(GpuStampBeginLabel, CameraDriverLabel);
graph.add_node_edge(CameraDriverLabel, GpuStampEndLabel);
}
#[cfg(test)]
mod tests {
use super::pairs;
/// The ordering law the Vulkan hang (2211) and the Metal zeros (1389) both hang on: a run
/// never resolves the pair it writes, and always resolves the one the run before it wrote.
#[test]
fn a_run_resolves_the_pair_the_run_before_it_wrote() {
for frame in 1..12 {
let (write, resolve) = pairs(frame);
assert_ne!(write, resolve);
assert!(write < 2 && resolve < 2);
assert_eq!(resolve, pairs(frame - 1).0);
}
}
}