use henad_core::action::Schedule;
use henad_core::model::SimState;
use crate::gpu::primitives::readback::StatsPoll;
use crate::gpu::timing::{DEFAULT_BATCH_SIZE, DEFAULT_TARGET_MS};
use crate::snapshot::GpuSnapshot;
pub trait GpuSimState: SimState {
fn encode_steps(&mut self, encoder: &mut wgpu::CommandEncoder, count: u32, timestamps: Option<&wgpu::QuerySet>);
fn encode_action(&mut self, _encoder: &mut wgpu::CommandEncoder, _index: usize) -> bool {
false
}
fn encode_snapshot_passes(&mut self, encoder: &mut wgpu::CommandEncoder);
fn encode_stats_passes(&mut self, encoder: &mut wgpu::CommandEncoder) {
self.encode_snapshot_passes(encoder);
}
fn begin_stats_readback(&mut self);
fn poll_stats_readback(&mut self, device: &wgpu::Device, block: bool) -> StatsPoll;
fn stats_readback_pending(&self) -> bool;
fn view(&self) -> GpuSnapshot;
}
#[derive(Clone, Copy, Debug)]
pub struct GpuStats {
pub gpu_us_per_step: Option<f64>,
pub batch_size: u32,
pub adaptive: bool,
}
impl Default for GpuStats {
fn default() -> Self {
Self {
gpu_us_per_step: None,
batch_size: DEFAULT_BATCH_SIZE,
adaptive: false,
}
}
}
#[derive(Debug)]
pub enum GpuCommand {
SetBatchSize(u32),
SetAdaptive(bool),
SetTargetMs(f64),
}
#[derive(Clone, Copy, Debug)]
pub struct GpuBatchSettings {
pub adaptive: bool,
pub batch_size: u32,
pub target_ms: f64,
}
impl Default for GpuBatchSettings {
fn default() -> Self {
Self {
adaptive: true,
batch_size: DEFAULT_BATCH_SIZE,
target_ms: DEFAULT_TARGET_MS,
}
}
}
const SNAPSHOT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16);
const STATS_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
use std::sync::{Arc, Mutex};
use web_time::Instant;
use crate::cpu::sim_thread::{SimCommand, WakeFn};
use crate::gpu::timing::{ADAPTIVE_EMA_ALPHA, TimestampQuery, ema_update, next_batch_size, time_per_step_ms, tps_over};
use crate::gpu::{GpuContext, MAX_STEPS_PER_SUBMISSION};
use crate::runner::{Driver, Pace, RUN_TO_PUBLISH_INTERVAL, SharedSlot, SimLoop, SnapshotSlot};
use crate::snapshot::{Snapshot, SnapshotView};
const OUTSTANDING_POLL: std::time::Duration = std::time::Duration::from_millis(1);
enum Command {
Sim(SimCommand),
Gpu(GpuCommand),
}
#[cfg(not(target_arch = "wasm32"))]
struct InFlight {
submission: wgpu::SubmissionIndex,
steps: u32,
started_at: Instant,
}
#[cfg(target_arch = "wasm32")]
struct InFlight {
elapsed_us: Arc<std::sync::atomic::AtomicU64>,
steps: u32,
}
struct Loop {
ctx: GpuContext,
state: Box<dyn GpuSimState>,
slot: SharedSlot,
gpu_stats: Arc<Mutex<GpuStats>>,
wake: Option<WakeFn>,
running: bool,
adaptive: bool,
fixed_batch_size: u32,
target_ms: f64,
batch_size: u32,
ema_time_per_step_ms: Option<f64>,
in_flight: Option<InFlight>,
step_count: u64,
actual_tps: f64,
gpu_us_per_step: Option<f64>,
tps_timer: Instant,
last_snapshot_publish: Instant,
last_stats_publish: Instant,
serial: u64,
timestamp_query: Option<TimestampQuery>,
schedule: Schedule,
fired_through: Option<u64>,
run_to_target: Option<u64>,
late_stats: bool,
}
impl SimLoop for Loop {
type Command = Command;
fn start(&mut self) {
self.snapshot_now();
}
fn handle_command(&mut self, cmd: Command) -> bool {
match cmd {
Command::Sim(SimCommand::Play) => {
self.run_to_target = None;
self.running = true;
self.reset_tps_window(Instant::now());
}
Command::Sim(SimCommand::Pause) => {
self.run_to_target = None;
self.running = false;
self.actual_tps = 0.0;
self.snapshot_now();
}
Command::Sim(SimCommand::StepOnce) => {
self.run_to_target = None;
let mut encoder = self.encoder("henad_gpu_step_once");
self.state.encode_steps(&mut encoder, 1, None);
self.ctx.queue.submit(Some(encoder.finish()));
fire_due(&mut *self.state, &self.ctx, &self.schedule);
self.fired_through = Some(self.state.tick());
self.snapshot_now();
}
Command::Sim(SimCommand::SetParam { index, value }) => {
if !self.state.set_param(index, &value) {
log::warn!("Failed to set param index {index} to {value:?}");
}
}
Command::Sim(
SimCommand::SetTargetTps(_)
| SimCommand::SetUncapped(_)
| SimCommand::SetTicksPerSnapshot(_)
| SimCommand::SetLayout { .. },
) => {}
Command::Sim(SimCommand::Act(index)) => {
let mut encoder = self.encoder("henad_gpu_action");
if self.state.encode_action(&mut encoder, index) {
self.ctx.queue.submit(Some(encoder.finish()));
self.snapshot_now();
} else {
log::warn!("Model has no action at index {index}");
}
}
Command::Sim(SimCommand::SetSchedule(schedule)) => {
self.schedule = schedule;
if self.fired_through.is_none_or(|through| through < self.state.tick()) {
fire_due(&mut *self.state, &self.ctx, &self.schedule);
self.fired_through = Some(self.state.tick());
}
self.snapshot_now();
}
Command::Sim(SimCommand::RunTo(target)) => {
self.run_to_target = Some(target);
self.running = false;
self.reset_tps_window(Instant::now());
}
Command::Sim(SimCommand::Shutdown) => return true,
Command::Gpu(GpuCommand::SetBatchSize(n)) => {
self.fixed_batch_size = n.max(1);
if !self.adaptive {
self.batch_size = self.fixed_batch_size;
}
}
Command::Gpu(GpuCommand::SetAdaptive(enabled)) => {
self.adaptive = enabled;
if !enabled {
self.batch_size = self.fixed_batch_size;
}
self.publish_gpu_stats();
}
Command::Gpu(GpuCommand::SetTargetMs(target_ms)) => {
self.target_ms = target_ms.max(0.1);
}
}
false
}
fn pump(&mut self) -> Pace {
if self.ctx.faults.is_set() {
if self.running || self.run_to_target.is_some() {
self.running = false;
self.run_to_target = None;
self.actual_tps = 0.0;
self.publish_snapshot();
}
return Pace::Idle;
}
if !self.running && self.run_to_target.is_none() {
return self.collect_late_stats();
}
if self.late_stats {
let pace = self.collect_late_stats();
if self.late_stats {
return pace;
}
}
if !self.await_previous() {
return Pace::After(OUTSTANDING_POLL);
}
if self.run_to_target.is_some_and(|target| self.state.tick() >= target) {
self.run_to_target = None;
self.actual_tps = 0.0;
self.snapshot_now();
return self.collect_late_stats();
}
self.step_batch();
Pace::Now
}
}
impl Loop {
fn new(
ctx: GpuContext,
state: Box<dyn GpuSimState>,
settings: &GpuBatchSettings,
slot: SharedSlot,
gpu_stats: Arc<Mutex<GpuStats>>,
wake: Option<WakeFn>,
) -> Self {
let batch_size = settings.batch_size.max(1);
let timestamp_query = TimestampQuery::new(&ctx.device, &ctx.queue);
let now = Instant::now();
Self {
ctx,
state,
slot,
gpu_stats,
wake,
running: false,
adaptive: settings.adaptive,
fixed_batch_size: batch_size,
target_ms: settings.target_ms,
batch_size,
ema_time_per_step_ms: None,
in_flight: None,
step_count: 0,
actual_tps: 0.0,
gpu_us_per_step: None,
tps_timer: now,
last_snapshot_publish: now,
last_stats_publish: now,
serial: 0,
timestamp_query,
schedule: Schedule::default(),
fired_through: None,
run_to_target: None,
late_stats: false,
}
}
fn encoder(&self, label: &str) -> wgpu::CommandEncoder {
self.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) })
}
#[cfg(not(target_arch = "wasm32"))]
fn await_previous(&mut self) -> bool {
let Some(prev) = self.in_flight.take() else {
return true;
};
drop(self.ctx.device.poll(wgpu::PollType::Wait {
submission_index: Some(prev.submission),
timeout: None,
}));
self.record_sample(time_per_step_ms(prev.started_at.elapsed(), prev.steps));
true
}
#[cfg(target_arch = "wasm32")]
fn await_previous(&mut self) -> bool {
use std::sync::atomic::Ordering;
let Some(prev) = self.in_flight.as_ref() else {
return true;
};
let Some(elapsed_us) = prev.elapsed_us.load(Ordering::Relaxed).checked_sub(1) else {
return false;
};
let steps = prev.steps;
self.in_flight = None;
self.record_sample(time_per_step_ms(std::time::Duration::from_micros(elapsed_us), steps));
true
}
fn record_sample(&mut self, sample: f64) {
let ema = ema_update(self.ema_time_per_step_ms, sample, ADAPTIVE_EMA_ALPHA);
self.ema_time_per_step_ms = Some(ema);
if self.adaptive {
self.batch_size = next_batch_size(ema, self.target_ms);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn track(&mut self, submission: Option<wgpu::SubmissionIndex>, steps: u32, started_at: Instant) {
self.in_flight = submission.map(|submission| InFlight {
submission,
steps,
started_at,
});
}
#[cfg(target_arch = "wasm32")]
fn track(&mut self, _submission: Option<wgpu::SubmissionIndex>, steps: u32, started_at: Instant) {
use std::sync::atomic::{AtomicU64, Ordering};
let elapsed_us = Arc::new(AtomicU64::new(0));
let signal = Arc::clone(&elapsed_us);
self.ctx.queue.on_submitted_work_done(move || {
let micros = started_at.elapsed().as_micros().min(u128::from(u64::MAX - 1)) as u64;
signal.store(micros + 1, Ordering::Relaxed);
});
self.in_flight = Some(InFlight { elapsed_us, steps });
}
fn collect_late_stats(&mut self) -> Pace {
if !self.state.stats_readback_pending() {
self.late_stats = false;
return Pace::Idle;
}
self.state.poll_stats_readback(&self.ctx.device, false);
if self.state.stats_readback_pending() {
if let Some(wake) = &self.wake {
wake();
}
return Pace::After(OUTSTANDING_POLL);
}
self.late_stats = false;
self.publish_snapshot();
Pace::Idle
}
fn steps_to_next_action(&self) -> Option<u64> {
let tick = self.state.tick();
self.schedule.next_due_after(tick).map(|due| due - tick)
}
fn snapshot_now(&mut self) {
let mut encoder = self.encoder("henad_gpu_snapshot_now");
self.state.encode_snapshot_passes(&mut encoder);
self.ctx.queue.submit(Some(encoder.finish()));
self.state.begin_stats_readback();
self.state.poll_stats_readback(&self.ctx.device, true);
self.late_stats = self.state.stats_readback_pending();
self.in_flight = None;
self.last_snapshot_publish = Instant::now();
self.publish_snapshot();
self.publish_gpu_stats();
}
fn step_batch(&mut self) {
let now = Instant::now();
let want_timing =
self.timestamp_query.is_some() && now.duration_since(self.last_stats_publish) >= STATS_INTERVAL;
let (batch_size_submitted, snapshot_interval) = match self.run_to_target {
Some(target) => {
let remaining = target.saturating_sub(self.state.tick());
let steps = u64::from(self.batch_size).min(remaining) as u32;
(steps, RUN_TO_PUBLISH_INTERVAL)
}
None => (self.batch_size, SNAPSHOT_INTERVAL),
};
let reaches_target = self
.run_to_target
.is_some_and(|target| self.state.tick() + u64::from(batch_size_submitted) >= target);
let want_snapshot = !reaches_target && now.duration_since(self.last_snapshot_publish) >= snapshot_interval;
let query_set = if want_timing {
self.timestamp_query.as_ref().map(TimestampQuery::query_set)
} else {
None
};
let mut submitted = 0;
let mut stamped_steps = None;
let mut write_submission = None;
while submitted < batch_size_submitted {
let steps_to_action = self.steps_to_next_action();
let chunk = MAX_STEPS_PER_SUBMISSION.min(batch_size_submitted - submitted);
let chunk = steps_to_action.map_or(chunk, |steps| steps.min(u64::from(chunk)) as u32);
let action_due = steps_to_action == Some(u64::from(chunk));
let mut encoder = self.encoder("henad_gpu_sim_encoder");
let stamp = query_set.filter(|_| stamped_steps.is_none());
if stamp.is_some() {
stamped_steps = Some(chunk);
}
self.state.encode_steps(&mut encoder, chunk, stamp);
submitted += chunk;
let snapshot_here = want_snapshot && submitted >= batch_size_submitted;
if snapshot_here && !action_due {
self.state.encode_snapshot_passes(&mut encoder);
}
write_submission = Some(self.ctx.queue.submit(Some(encoder.finish())));
if action_due {
write_submission = fire_due(&mut *self.state, &self.ctx, &self.schedule).or(write_submission);
if snapshot_here {
let mut encoder = self.encoder("henad_gpu_snapshot");
self.state.encode_snapshot_passes(&mut encoder);
write_submission = Some(self.ctx.queue.submit(Some(encoder.finish())));
}
}
}
self.fired_through = Some(self.state.tick());
self.step_count += u64::from(batch_size_submitted);
if want_snapshot {
self.state.begin_stats_readback();
}
self.state.poll_stats_readback(&self.ctx.device, false);
if want_timing && let (Some(tq), Some(submission)) = (self.timestamp_query.as_ref(), write_submission.clone()) {
tq.resolve_after(&self.ctx.device, &self.ctx.queue, submission);
}
if want_timing {
if let (Some(tq), Some(steps)) = (self.timestamp_query.as_ref(), stamped_steps) {
self.gpu_us_per_step = tq.read_gpu_us_per_step(&self.ctx.device, steps);
}
self.refresh_tps(now);
self.last_stats_publish = now;
} else if self.timestamp_query.is_none() && now.duration_since(self.tps_timer) >= STATS_INTERVAL {
self.refresh_tps(now);
}
if want_snapshot {
self.last_snapshot_publish = now;
self.publish_snapshot();
self.publish_gpu_stats();
}
self.track(write_submission, batch_size_submitted, now);
}
fn reset_tps_window(&mut self, now: Instant) {
self.tps_timer = now;
self.last_stats_publish = now;
self.step_count = 0;
}
fn refresh_tps(&mut self, now: Instant) {
let Some(tps) = tps_over(self.step_count, now.duration_since(self.tps_timer)) else {
return;
};
self.actual_tps = tps;
self.step_count = 0;
self.tps_timer = now;
}
fn publish_snapshot(&mut self) {
self.serial += 1;
let snap = Snapshot {
serial: self.serial,
view_ms: 0.0,
tick: self.state.tick(),
population: self.state.population(),
heap_bytes: self.state.heap_bytes(),
actual_tps: self.actual_tps,
engine_ms: self.gpu_us_per_step.unwrap_or(0.0) / 1000.0,
view: SnapshotView::Gpu(self.state.view()),
stats: self.state.stats(),
};
crate::runner::publish(&self.slot, snap);
if let Some(wake) = &self.wake {
wake();
}
}
fn publish_gpu_stats(&self) {
if let Ok(mut stats) = self.gpu_stats.lock() {
*stats = GpuStats {
gpu_us_per_step: self.gpu_us_per_step,
batch_size: self.batch_size,
adaptive: self.adaptive,
};
}
}
}
fn fire_due(state: &mut dyn GpuSimState, ctx: &GpuContext, schedule: &Schedule) -> Option<wgpu::SubmissionIndex> {
let tick = state.tick();
let mut last = None;
for action in schedule.due(tick) {
let mut encoder = ctx.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("henad_gpu_scheduled_action"),
});
if state.encode_action(&mut encoder, action.index) {
last = Some(ctx.queue.submit(Some(encoder.finish())));
} else {
log::warn!("Model refused action '{}' at tick {tick}", action.id);
}
}
last
}
pub struct GpuSimThread {
driver: Driver<Loop>,
slot: SharedSlot,
gpu_stats: Arc<Mutex<GpuStats>>,
}
impl std::fmt::Debug for GpuSimThread {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GpuSimThread").finish_non_exhaustive()
}
}
impl GpuSimThread {
pub fn new(ctx: GpuContext, state: Box<dyn GpuSimState>, settings: GpuBatchSettings, wake: Option<WakeFn>) -> Self {
let batch_size = settings.batch_size.max(1);
let gpu_stats = Arc::new(Mutex::new(GpuStats {
gpu_us_per_step: None,
batch_size,
adaptive: settings.adaptive,
}));
let slot = SnapshotSlot::empty();
let faults = ctx.faults.clone();
let sim = Loop::new(
ctx,
state,
&settings,
SharedSlot::clone(&slot),
Arc::clone(&gpu_stats),
wake.clone(),
);
let driver = Driver::spawn(sim, move |fault| {
faults.set_once(fault);
if let Some(wake) = &wake {
wake();
}
});
Self {
driver,
slot,
gpu_stats,
}
}
pub fn send(&mut self, cmd: SimCommand) {
self.driver.send(Command::Sim(cmd));
}
pub fn take_snapshot(&mut self) -> Option<Snapshot> {
crate::runner::take_snapshot(&self.slot)
}
pub fn play(&mut self) {
self.send(SimCommand::Play);
}
pub fn pause(&mut self) {
self.send(SimCommand::Pause);
}
pub fn step_once(&mut self) {
self.send(SimCommand::StepOnce);
}
pub fn set_schedule(&mut self, schedule: Schedule) {
self.send(SimCommand::SetSchedule(schedule));
}
pub fn run_to(&mut self, tick: u64) {
self.send(SimCommand::RunTo(tick));
}
pub fn set_batch_size(&mut self, batch_size: u32) {
self.driver.send(Command::Gpu(GpuCommand::SetBatchSize(batch_size)));
}
pub fn set_adaptive(&mut self, enabled: bool) {
self.driver.send(Command::Gpu(GpuCommand::SetAdaptive(enabled)));
}
pub fn set_target_ms(&mut self, target_ms: f64) {
self.driver.send(Command::Gpu(GpuCommand::SetTargetMs(target_ms)));
}
pub fn gpu_stats(&self) -> GpuStats {
self.gpu_stats.lock().map(|s| *s).unwrap_or_default()
}
pub fn update(&mut self, dt: f64) {
self.driver.update(dt);
}
}
impl Drop for GpuSimThread {
fn drop(&mut self) {
self.driver.shutdown(Command::Sim(SimCommand::Shutdown));
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use super::{Command, GpuBatchSettings, GpuSimState, GpuSimThread, GpuStats, Loop, StatsPoll};
use crate::cpu::sim_thread::SimCommand;
use crate::gpu::{GpuContext, headless_context};
use crate::runner::{SharedSlot, SimLoop as _, SnapshotSlot};
use crate::snapshot::{GpuSnapshot, Snapshot};
use henad_core::action::{Schedule, Scheduled};
use henad_core::model::SimState;
use henad_core::params::ParamValue;
use henad_core::view::{StatEntry, StatValue};
struct LateStats {
polls_left: u32,
population: u64,
}
const LANDED: u64 = 7;
impl SimState for LateStats {
fn step(&mut self) {}
fn tick(&self) -> u64 {
0
}
fn stats(&self) -> Vec<StatEntry> {
Vec::new()
}
fn set_param(&mut self, _index: usize, _value: &ParamValue) -> bool {
false
}
fn population(&self) -> u64 {
self.population
}
fn heap_bytes(&self) -> usize {
0
}
}
impl GpuSimState for LateStats {
fn encode_steps(&mut self, _encoder: &mut wgpu::CommandEncoder, _count: u32, _t: Option<&wgpu::QuerySet>) {}
fn encode_snapshot_passes(&mut self, _encoder: &mut wgpu::CommandEncoder) {}
fn begin_stats_readback(&mut self) {}
fn poll_stats_readback(&mut self, _device: &wgpu::Device, _block: bool) -> StatsPoll {
self.polls_left = self.polls_left.saturating_sub(1);
if self.polls_left > 0 {
return StatsPoll::Pending;
}
self.population = LANDED;
StatsPoll::Landed
}
fn stats_readback_pending(&self) -> bool {
self.polls_left > 0
}
fn view(&self) -> GpuSnapshot {
GpuSnapshot {
display: None,
agents: None,
}
}
}
#[test]
fn a_readback_landing_after_the_initial_snapshot_is_published() {
let Some(ctx) = headless_context("henad_late_stats_test", wgpu::Features::empty()) else {
log::warn!("skipping a_readback_landing_after_the_initial_snapshot_is_published: no adapter");
return;
};
let state = LateStats {
polls_left: 2,
population: 0,
};
let mut thread = GpuSimThread::new(ctx, Box::new(state), GpuBatchSettings::default(), None);
let mut seen = None;
for _ in 0..200 {
if let Some(snap) = thread.take_snapshot() {
seen = Some(snap.population);
if seen == Some(LANDED) {
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(seen, Some(LANDED), "the late readback was never published");
}
struct LateTickStats {
tick: u64,
polls_left: u32,
}
impl SimState for LateTickStats {
fn step(&mut self) {
self.tick += 1;
}
fn tick(&self) -> u64 {
self.tick
}
fn stats(&self) -> Vec<StatEntry> {
vec![StatEntry {
label: "Landed",
value: StatValue::Scalar(if self.polls_left == 0 { 1.0 } else { 0.0 }),
color: [0; 4],
}]
}
fn set_param(&mut self, _index: usize, _value: &ParamValue) -> bool {
false
}
fn population(&self) -> u64 {
0
}
fn heap_bytes(&self) -> usize {
0
}
}
impl GpuSimState for LateTickStats {
fn encode_steps(&mut self, _encoder: &mut wgpu::CommandEncoder, count: u32, _t: Option<&wgpu::QuerySet>) {
self.tick += u64::from(count);
}
fn encode_snapshot_passes(&mut self, _encoder: &mut wgpu::CommandEncoder) {}
fn begin_stats_readback(&mut self) {}
fn poll_stats_readback(&mut self, _device: &wgpu::Device, _block: bool) -> StatsPoll {
self.polls_left = self.polls_left.saturating_sub(1);
if self.polls_left > 0 {
StatsPoll::Pending
} else {
StatsPoll::Landed
}
}
fn stats_readback_pending(&self) -> bool {
self.polls_left > 0
}
fn view(&self) -> GpuSnapshot {
GpuSnapshot {
display: None,
agents: None,
}
}
}
#[test]
fn a_run_to_after_the_build_republishes_tick_zero_first() {
let Some(ctx) = headless_context("henad_late_tick_zero_test", wgpu::Features::empty()) else {
log::warn!("skipping a_run_to_after_the_build_republishes_tick_zero_first: no adapter");
return;
};
let settings = GpuBatchSettings {
adaptive: false,
batch_size: 100,
..GpuBatchSettings::default()
};
let slot = SnapshotSlot::empty();
let gpu_stats = Arc::new(Mutex::new(GpuStats {
gpu_us_per_step: None,
batch_size: settings.batch_size,
adaptive: settings.adaptive,
}));
let state = LateTickStats { tick: 0, polls_left: 3 };
let mut sim = Loop::new(
ctx,
Box::new(state),
&settings,
SharedSlot::clone(&slot),
gpu_stats,
None,
);
let mut rows: Vec<(u64, f64)> = Vec::new();
let record = |rows: &mut Vec<(u64, f64)>| {
if let Some(snap) = crate::runner::take_snapshot(&slot) {
let landed = snap.stats[0].value.scalar();
match rows.last_mut() {
Some(row) if row.0 == snap.tick => row.1 = landed,
_ => rows.push((snap.tick, landed)),
}
}
};
sim.start();
record(&mut rows);
sim.handle_command(Command::Sim(SimCommand::RunTo(10)));
for _ in 0..10 {
sim.pump();
record(&mut rows);
}
assert_eq!(
rows.last().map(|row| row.0),
Some(10),
"the run reached its target: {rows:?}"
);
assert_eq!(
rows.first(),
Some(&(0, 1.0)),
"tick 0 kept the stats from before its readback: {rows:?}"
);
}
struct ActionRecorder {
ticks: Arc<AtomicU64>,
fired: Arc<Mutex<Vec<u64>>>,
}
impl SimState for ActionRecorder {
fn step(&mut self) {
self.ticks.fetch_add(1, Ordering::Relaxed);
}
fn tick(&self) -> u64 {
self.ticks.load(Ordering::Relaxed)
}
fn stats(&self) -> Vec<StatEntry> {
Vec::new()
}
fn set_param(&mut self, _index: usize, _value: &ParamValue) -> bool {
false
}
fn population(&self) -> u64 {
0
}
fn heap_bytes(&self) -> usize {
0
}
}
impl GpuSimState for ActionRecorder {
fn encode_steps(
&mut self,
_encoder: &mut wgpu::CommandEncoder,
count: u32,
_timestamps: Option<&wgpu::QuerySet>,
) {
self.ticks.fetch_add(u64::from(count), Ordering::Relaxed);
}
fn encode_action(&mut self, _encoder: &mut wgpu::CommandEncoder, _index: usize) -> bool {
self.fired.lock().expect("action log").push(self.tick());
true
}
fn encode_snapshot_passes(&mut self, _encoder: &mut wgpu::CommandEncoder) {}
fn begin_stats_readback(&mut self) {}
fn poll_stats_readback(&mut self, _device: &wgpu::Device, _block: bool) -> StatsPoll {
StatsPoll::Landed
}
fn stats_readback_pending(&self) -> bool {
false
}
fn view(&self) -> GpuSnapshot {
GpuSnapshot {
display: None,
agents: None,
}
}
}
fn action_recorder(ctx: GpuContext) -> (GpuSimThread, Arc<AtomicU64>, Arc<Mutex<Vec<u64>>>) {
let ticks = Arc::new(AtomicU64::new(0));
let fired = Arc::new(Mutex::new(Vec::new()));
let state = ActionRecorder {
ticks: Arc::clone(&ticks),
fired: Arc::clone(&fired),
};
let settings = GpuBatchSettings {
adaptive: false,
batch_size: 100,
..GpuBatchSettings::default()
};
(GpuSimThread::new(ctx, Box::new(state), settings, None), ticks, fired)
}
fn snapshot_at(thread: &mut GpuSimThread, tick: u64) -> (Option<Snapshot>, u64) {
let mut highest = 0;
for _ in 0..500 {
if let Some(snap) = thread.take_snapshot() {
highest = highest.max(snap.tick);
if snap.tick == tick {
return (Some(snap), highest);
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
(None, highest)
}
fn settle() {
std::thread::sleep(std::time::Duration::from_millis(100));
}
#[test]
fn run_to_stops_at_the_target() {
let Some(ctx) = headless_context("henad_run_to_test", wgpu::Features::empty()) else {
log::warn!("skipping run_to_stops_at_the_target: no adapter");
return;
};
let (mut thread, ticks, _) = action_recorder(ctx);
thread.run_to(1003);
let (reached, highest) = snapshot_at(&mut thread, 1003);
let reached = reached.expect("the run never published its target");
assert_eq!(highest, 1003, "a snapshot went past the target");
assert_eq!(reached.actual_tps, 0.0, "a paused run reported a rate");
settle();
assert_eq!(ticks.load(Ordering::Relaxed), 1003, "the run went past its target");
assert!(thread.take_snapshot().is_none(), "a paused run kept publishing");
thread.run_to(10);
assert!(
snapshot_at(&mut thread, 1003).0.is_some(),
"a run to a tick behind the current one pauses and publishes"
);
settle();
assert_eq!(ticks.load(Ordering::Relaxed), 1003);
}
#[test]
fn a_gpu_schedule_fires_once_per_tick_after_the_step_reaching_it() {
let Some(ctx) = headless_context("henad_gpu_schedule_test", wgpu::Features::empty()) else {
log::warn!("skipping a_gpu_schedule_fires_once_per_tick_after_the_step_reaching_it: no adapter");
return;
};
let (mut thread, ticks, fired) = action_recorder(ctx);
let entries = [0, 5, 64, 64, 100, 101, 250]
.map(|tick| Scheduled {
index: 0,
id: "mark".to_owned(),
tick,
})
.to_vec();
thread.set_schedule(Schedule::from_entries(entries));
thread.run_to(100);
assert!(snapshot_at(&mut thread, 100).0.is_some());
let fired_ticks = || fired.lock().expect("action log").clone();
assert_eq!(fired_ticks(), [0, 5, 64, 64, 100], "each action fires at its own tick");
thread.step_once();
assert!(snapshot_at(&mut thread, 101).0.is_some());
thread.run_to(200);
assert!(snapshot_at(&mut thread, 200).0.is_some());
assert_eq!(fired_ticks(), [0, 5, 64, 64, 100, 101]);
assert_eq!(ticks.load(Ordering::Relaxed), 200);
}
#[test]
fn a_replaced_gpu_schedule_never_fires_a_tick_again() {
let Some(ctx) = headless_context("henad_gpu_replaced_schedule_test", wgpu::Features::empty()) else {
log::warn!("skipping a_replaced_gpu_schedule_never_fires_a_tick_again: no adapter");
return;
};
let (mut thread, _, fired) = action_recorder(ctx);
let schedule = |ticks: &[u64]| {
let entries = ticks
.iter()
.map(|&tick| Scheduled {
index: 0,
id: "mark".to_owned(),
tick,
})
.collect();
Schedule::from_entries(entries)
};
let fired_ticks = || fired.lock().expect("action log").clone();
thread.set_schedule(schedule(&[0, 40]));
thread.set_schedule(schedule(&[0, 40, 90]));
thread.run_to(40);
assert!(snapshot_at(&mut thread, 40).0.is_some());
assert_eq!(fired_ticks(), [0, 40], "a second schedule at tick 0 fired it again");
thread.set_schedule(schedule(&[40, 90]));
thread.run_to(90);
assert!(snapshot_at(&mut thread, 90).0.is_some());
assert_eq!(
fired_ticks(),
[0, 40, 90],
"a schedule at a tick a batch reached fired it again"
);
thread.run_to(150);
assert!(snapshot_at(&mut thread, 150).0.is_some());
thread.set_schedule(schedule(&[150, 151]));
thread.step_once();
assert!(snapshot_at(&mut thread, 151).0.is_some());
assert_eq!(fired_ticks(), [0, 40, 90, 151]);
}
}