use std::time::Duration;
pub const DEFAULT_BATCH_SIZE: u32 = 64;
pub const DEFAULT_TARGET_MS: f64 = 8.0;
pub const ADAPTIVE_EMA_ALPHA: f64 = 0.25;
pub const MAX_BATCH_SIZE: u32 = 4096;
#[derive(Debug)]
pub struct TimestampQuery {
query_set: wgpu::QuerySet,
resolve_buffer: wgpu::Buffer,
readback_buffer: wgpu::Buffer,
period_ns: f32,
}
impl TimestampQuery {
const BUFFER_SIZE: u64 = 2 * std::mem::size_of::<u64>() as u64;
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option<Self> {
if !device.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
return None;
}
let query_set = device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("henad_gpu_timestamp_query_set"),
ty: wgpu::QueryType::Timestamp,
count: 2,
});
let resolve_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("henad_gpu_timestamp_resolve"),
size: Self::BUFFER_SIZE,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("henad_gpu_timestamp_readback"),
size: Self::BUFFER_SIZE,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Some(Self {
query_set,
resolve_buffer,
readback_buffer,
period_ns: queue.get_timestamp_period(),
})
}
pub fn query_set(&self) -> &wgpu::QuerySet {
&self.query_set
}
pub fn resolve_after(&self, device: &wgpu::Device, queue: &wgpu::Queue, write_submission: wgpu::SubmissionIndex) {
drop(device.poll(wgpu::PollType::Wait {
submission_index: Some(write_submission),
timeout: None,
}));
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("henad_gpu_timestamp_resolve_encoder"),
});
encoder.resolve_query_set(&self.query_set, 0..2, &self.resolve_buffer, 0);
encoder.copy_buffer_to_buffer(&self.resolve_buffer, 0, &self.readback_buffer, 0, Self::BUFFER_SIZE);
queue.submit(Some(encoder.finish()));
}
pub fn read_gpu_us_per_step(&self, device: &wgpu::Device, batch_size: u32) -> Option<f64> {
let slice = self.readback_buffer.slice(..);
let (tx, rx) = flume::bounded(1);
slice.map_async(wgpu::MapMode::Read, move |result| {
drop(tx.send(result));
});
let received = match device.poll(wgpu::PollType::wait_indefinitely()) {
Ok(_) => rx.recv().ok(),
Err(_) => rx.try_recv().ok(),
};
let Some(result) = received else {
self.readback_buffer.unmap();
return None;
};
result.ok()?;
let Ok(data) = slice.get_mapped_range() else {
self.readback_buffer.unmap();
return None;
};
let ticks: &[u64] = bytemuck::cast_slice(&data);
let (start, end) = (ticks[0], ticks[1]);
drop(data);
self.readback_buffer.unmap();
let elapsed_ns = end.saturating_sub(start) as f64 * f64::from(self.period_ns);
Some(elapsed_ns / 1000.0 / f64::from(batch_size.max(1)))
}
}
pub fn ema_update(prev: Option<f64>, sample: f64, alpha: f64) -> f64 {
match prev {
Some(prev) => alpha.mul_add(sample, (1.0 - alpha) * prev),
None => sample,
}
}
pub fn next_batch_size(ema_ms: f64, target_ms: f64) -> u32 {
let raw = target_ms / ema_ms.max(f64::EPSILON);
(raw as u32).clamp(1, MAX_BATCH_SIZE)
}
pub fn time_per_step_ms(elapsed: Duration, batch_size_submitted: u32) -> f64 {
elapsed.as_secs_f64() * 1000.0 / f64::from(batch_size_submitted.max(1))
}
pub const MIN_TPS_WINDOW: Duration = Duration::from_millis(100);
pub fn tps_over(step_count: u64, elapsed: Duration) -> Option<f64> {
(elapsed >= MIN_TPS_WINDOW).then(|| step_count as f64 / elapsed.as_secs_f64())
}
#[cfg(test)]
mod tps_window_tests {
use super::{MIN_TPS_WINDOW, tps_over};
use std::time::Duration;
#[test]
fn a_normal_window_reports_the_rate() {
let tps = tps_over(320, Duration::from_secs(1)).expect("a one second window is usable");
assert!((tps - 320.0).abs() < 1e-9, "expected 320, got {tps}");
}
#[test]
fn a_near_zero_window_reports_nothing() {
assert_eq!(tps_over(64, Duration::from_nanos(42)), None);
assert_eq!(tps_over(64, Duration::ZERO), None);
}
#[test]
fn the_cutoff_itself_is_usable() {
assert!(tps_over(10, MIN_TPS_WINDOW).is_some());
assert_eq!(tps_over(10, MIN_TPS_WINDOW / 2), None);
}
}
#[cfg(test)]
mod adaptive_controller_tests {
use super::{MAX_BATCH_SIZE, ema_update, next_batch_size};
#[test]
fn ema_first_sample_seeds_directly() {
assert!((ema_update(None, 3.7, 0.25) - 3.7).abs() < f64::EPSILON);
}
#[test]
fn ema_blends_subsequent_samples() {
let ema = ema_update(Some(4.0), 8.0, 0.25);
assert!((ema - 5.0).abs() < 1e-9, "expected 5.0, got {ema}");
}
#[test]
fn ema_alpha_zero_ignores_new_samples() {
let ema = ema_update(Some(4.0), 100.0, 0.0);
assert!((ema - 4.0).abs() < f64::EPSILON);
}
#[test]
fn ema_alpha_one_tracks_new_sample_exactly() {
let ema = ema_update(Some(4.0), 100.0, 1.0);
assert!((ema - 100.0).abs() < f64::EPSILON);
}
#[test]
fn controller_known_ratio_produces_expected_batch_size() {
assert_eq!(next_batch_size(0.1, 8.0), 80);
}
#[test]
fn controller_very_cheap_step_clamps_to_max() {
assert_eq!(next_batch_size(0.000_001, 8.0), MAX_BATCH_SIZE);
}
#[test]
fn controller_very_expensive_step_clamps_to_one() {
assert_eq!(next_batch_size(1_000.0, 8.0), 1);
}
#[test]
fn controller_exact_budget_match_rounds_down_not_up() {
assert_eq!(next_batch_size(3.0, 8.0), 2);
}
#[test]
fn controller_never_returns_zero_even_at_zero_ema() {
assert_eq!(next_batch_size(0.0, 8.0), MAX_BATCH_SIZE);
}
}