- 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>
442 lines
20 KiB
Rust
442 lines
20 KiB
Rust
//! **`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);
|
||
}
|
||
}
|
||
}
|