facett-core/src/render/gpu/pass_clock.rs
//! **The in-GPU pass clock** — device timestamps around a lane's encoded passes,
//! resolved through a small non-blocking readback ring into
//! [`crate::render::lane`]'s per-lane clock stats.
//!
//! # Why this exists
//!
//! korp clocks the CPU legs of a mousewheel→pixels TRACE line itself; the one leg no
//! host can see is what the GPU actually spent executing the lane's passes. wgpu's
//! `TIMESTAMP_QUERY` is the only honest source for that number — a wall clock around
//! `queue.submit` measures driver queuing, not execution.
//!
//! # The honesty rules (LAW 2 shapes, written down)
//!
//! * **Feature-detect, never fake.** A device without `TIMESTAMP_QUERY` (llvmpipe
//! sometimes, every kittest default harness — its `DeviceDescriptor` requests no
//! features) gets `last_gpu_pass_us = None` and `gpu_timestamps = Some(false)`.
//! Never `0`: a zero here is indistinguishable from an infinitely fast GPU, the
//! identity-value trap.
//! * **Never stall the frame.** The two timestamps are resolved into a buffer and
//! copied to one of [`CLOCK_RING`] staging slots; the slot is `map_async`'d the
//! *next* frame (after egui has submitted the encoder) and read the frame after
//! that. A sample therefore lands ~2–3 frames late, and that is the contract —
//! `last_gpu_pass_us` is "a recent frame", not "this frame". No `device.poll(Wait)`
//! anywhere on the live path.
//! * **A zero delta is not a sample.** Two equal timestamps mean the device's
//! counter did not tick across the window (or a discarded encoder left a stale
//! slot); reporting it would publish `0 µs` as a measurement. It is skipped, and
//! the previous real sample stands.
//!
//! # What it costs when idle
//!
//! A lane on a featureless device holds a [`PassClockSlot::Unavailable`] — one enum
//! check per frame. On a device WITH the feature: two `write_timestamp`s, one
//! 16-byte resolve+copy, one `map_async` (its boxed callback is the only per-frame
//! allocation on the active path) and one mutex-guarded stats note per resolved
//! sample.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use egui_wgpu::wgpu;
/// Staging slots in flight. 3 covers the record → submit → map → read pipeline with
/// one spare, so a slow mapping never blocks arming the next frame (that frame just
/// goes unmeasured — skipping a sample is cheaper than stalling a frame).
const CLOCK_RING: usize = 3;
/// Two u64 timestamps.
const QUERY_BYTES: u64 = 16;
/// The features the clock needs: `TIMESTAMP_QUERY` for the query set + resolve, and
/// `TIMESTAMP_QUERY_INSIDE_ENCODERS` for `CommandEncoder::write_timestamp` — the
/// encoder-level write is what lets ONE clock bracket a lane's whole recorded work
/// (3D: shadow→post, many conditional passes) without threading `timestamp_writes`
/// through every pass descriptor.
pub const PASS_CLOCK_FEATURES: wgpu::Features = wgpu::Features::TIMESTAMP_QUERY
.union(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS);
/// Where one staging slot is in the record→map→read pipeline.
enum SlotState {
/// Not in flight — may be armed for this frame's copy.
Free,
/// A resolve+copy into this slot was recorded; `map_async` next frame (by then
/// egui has submitted the encoder — mapping before submission is a validation
/// error waiting to happen).
Copied,
/// `map_async` issued; `mapped` flips true when the callback fires.
Mapping,
}
struct ClockSlot {
staging: wgpu::Buffer,
state: SlotState,
/// Set by the `map_async` callback (fired during `device.poll`); read on the
/// following frame. `Arc` because the callback outlives the borrow.
mapped: Arc<AtomicBool>,
}
/// One lane's pass clock. Construct through [`PassClockSlot::get_or_init`] so the
/// feature probe (and its honest `note_gpu_timestamps` verdict) cannot be skipped.
pub struct PassClock {
/// The lane the samples are filed under (`lane_name()` of the owning renderer).
lane: &'static str,
query_set: wgpu::QuerySet,
/// `QUERY_RESOLVE | COPY_SRC` — the resolve target the per-slot copies read from.
resolve_buf: wgpu::Buffer,
/// Nanoseconds per timestamp tick (`Queue::get_timestamp_period`), captured once.
period_ns: f32,
slots: [ClockSlot; CLOCK_RING],
/// The slot this frame's `end` will copy into (`begin` armed it), if any.
armed: Option<usize>,
/// `(frame, slot)` of the bracket that opened THIS frame — lane GG #37.
///
/// Two map panes in one frame call `begin` twice before egui submits the
/// encoder. The ring advance in `begin` promotes every `Copied` slot to
/// `map_async` on the assumption that its copy has been submitted — which is true
/// once per frame and false for the second bracket of the same frame: the first
/// bracket's copy is still in the encoder in hand, and `Queue::submit` then fails
/// validation with "buffer is still mapped". MEASURED on t14s / Radeon 890M,
/// `facett-map/tests/gpu_lane_two_panes.rs`, 2026-09-05. A second `begin` in the
/// same frame therefore advances nothing and re-arms the same slot, so the window
/// simply widens to the last `end` of the frame.
bracketed: Option<(u64, usize)>,
}
impl PassClock {
/// Does this DEVICE carry what the clock needs? Checked against the device, not
/// the adapter: a device *requested without* the feature must read unsupported
/// even on a 4090 — that is the test's forced-unavailable arm, and the live
/// kittest-default-harness shape.
#[must_use]
pub fn supported(device: &wgpu::Device) -> bool {
device.features().contains(PASS_CLOCK_FEATURES)
}
/// Build against a device that [`supported`](Self::supported) said yes to.
/// Private on purpose — [`PassClockSlot::get_or_init`] is the one constructor
/// path, so the probe verdict always reaches the lane registry.
fn new(device: &wgpu::Device, queue: &wgpu::Queue, lane: &'static str) -> Self {
let query_set = device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("lane_pass_clock"),
ty: wgpu::QueryType::Timestamp,
count: 2,
});
let resolve_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("lane_pass_clock_resolve"),
size: QUERY_BYTES,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let slots = std::array::from_fn(|_| ClockSlot {
staging: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("lane_pass_clock_staging"),
size: QUERY_BYTES,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
state: SlotState::Free,
mapped: Arc::new(AtomicBool::new(false)),
});
Self {
lane,
query_set,
resolve_buf,
period_ns: queue.get_timestamp_period(),
slots,
armed: None,
bracketed: None,
}
}
/// **Start of the lane's encoded work.** Advances the ring (reads any sample
/// whose mapping completed → `note_gpu_pass`; issues `map_async` for last
/// frame's copy), then writes the opening timestamp if a free slot exists.
///
/// `frame` is the host's frame number (egui's `cumulative_frame_nr`, or any counter
/// that advances once per submit): a second `begin` under the SAME frame is a
/// second pane, not a new frame, and must not advance the ring — see
/// [`Self::bracketed`]. It re-arms the frame's slot and returns `true`.
///
/// The `device.poll(Poll)` is non-blocking — it only pumps callbacks that are
/// already done. Returns whether this frame is being measured (a full ring says
/// no, and the frame simply goes unmeasured).
pub fn begin(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, frame: u64) -> bool {
if let Some((f, i)) = self.bracketed && f == frame {
self.armed = Some(i);
return true;
}
let _ = device.poll(wgpu::PollType::Poll);
for slot in &mut self.slots {
match slot.state {
SlotState::Mapping if slot.mapped.load(Ordering::Acquire) => {
{
let data = slot.staging.slice(..).get_mapped_range().expect("wgpu: get_mapped_range on a mapped buffer");
let ts: &[u64] = bytemuck::cast_slice(&data);
let delta_ns =
(ts[1].saturating_sub(ts[0]) as f64) * f64::from(self.period_ns);
// Ceiling to µs so a real sub-microsecond pass reads 1, not 0
// — and a zero DELTA (counter never ticked / stale slot from a
// discarded encoder) is not published at all; see module doc.
let us = (delta_ns / 1000.0).ceil() as u64;
if us > 0 {
crate::render::lane::note_gpu_pass(self.lane, us);
}
}
slot.staging.unmap();
slot.state = SlotState::Free;
}
SlotState::Copied => {
let flag = Arc::clone(&slot.mapped);
flag.store(false, Ordering::Release);
slot.staging.slice(..).map_async(wgpu::MapMode::Read, move |r| {
if r.is_ok() {
flag.store(true, Ordering::Release);
}
});
slot.state = SlotState::Mapping;
}
_ => {}
}
}
self.armed = self.slots.iter().position(|s| matches!(s.state, SlotState::Free));
if let Some(i) = self.armed {
encoder.write_timestamp(&self.query_set, 0);
self.bracketed = Some((frame, i));
}
self.armed.is_some()
}
/// **End of the lane's encoded work**: closing timestamp, resolve, copy into the
/// armed slot. No-op when [`begin`](Self::begin) found no free slot (or was never
/// called this frame) — an early-returned `prepare` leaves nothing dangling.
pub fn end(&mut self, encoder: &mut wgpu::CommandEncoder) {
let Some(i) = self.armed.take() else { return };
encoder.write_timestamp(&self.query_set, 1);
encoder.resolve_query_set(&self.query_set, 0..2, &self.resolve_buf, 0);
encoder.copy_buffer_to_buffer(&self.resolve_buf, 0, &self.slots[i].staging, 0, QUERY_BYTES);
self.slots[i].state = SlotState::Copied;
}
}
/// **A lane's lazily-probed clock slot** — the field a renderer embeds.
///
/// Lazy because a renderer is constructed from a device alone
/// (`install_renderer`'s `make(&device, format)`) while the clock also needs the
/// queue's timestamp period; the first `prepare` (which has both) probes once. The
/// probe's verdict is written to the lane registry either way, so "unavailable" is a
/// recorded fact, not an absence.
#[derive(Default)]
#[allow(clippy::large_enum_variant)] // public enum a renderer embeds once; boxing `Ready` would change its public shape
pub enum PassClockSlot {
/// No `prepare` has run yet — the lane registry still says `None` for this lane.
#[default]
Untried,
/// Probed: the device lacks [`PASS_CLOCK_FEATURES`]. One enum check per frame
/// from here on — the "~zero overhead when nobody can read" arm.
Unavailable,
/// Probed: timestamps are real and this clock is writing them.
Ready(PassClock),
}
impl PassClockSlot {
/// The clock, probing on first use. `None` = this device cannot measure (already
/// recorded as `gpu_timestamps = Some(false)` for `lane`).
pub fn get_or_init(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
lane: &'static str,
) -> Option<&mut PassClock> {
if matches!(self, PassClockSlot::Untried) {
*self = if PassClock::supported(device) {
crate::render::lane::note_gpu_timestamps(lane, true);
PassClockSlot::Ready(PassClock::new(device, queue, lane))
} else {
crate::render::lane::note_gpu_timestamps(lane, false);
PassClockSlot::Unavailable
};
}
self.ready_mut()
}
/// The clock if the probe said yes — for the `end` half of a bracket, where
/// re-probing would be wrong (state was decided at `begin`).
pub fn ready_mut(&mut self) -> Option<&mut PassClock> {
match self {
PassClockSlot::Ready(c) => Some(c),
_ => None,
}
}
}