1use henad_core::action::Schedule;
9use henad_core::model::SimState;
10
11use crate::gpu::primitives::readback::StatsPoll;
12use crate::gpu::timing::{DEFAULT_BATCH_SIZE, DEFAULT_TARGET_MS};
13use crate::snapshot::GpuSnapshot;
14
15pub trait GpuSimState: SimState {
21 fn encode_steps(&mut self, encoder: &mut wgpu::CommandEncoder, count: u32, timestamps: Option<&wgpu::QuerySet>);
28
29 fn encode_action(&mut self, _encoder: &mut wgpu::CommandEncoder, _index: usize) -> bool {
37 false
38 }
39
40 fn encode_snapshot_passes(&mut self, encoder: &mut wgpu::CommandEncoder);
43
44 fn encode_stats_passes(&mut self, encoder: &mut wgpu::CommandEncoder) {
48 self.encode_snapshot_passes(encoder);
49 }
50
51 fn begin_stats_readback(&mut self);
56
57 fn poll_stats_readback(&mut self, device: &wgpu::Device, block: bool) -> StatsPoll;
65
66 fn stats_readback_pending(&self) -> bool;
71
72 fn view(&self) -> GpuSnapshot;
76}
77
78#[derive(Clone, Copy, Debug)]
82pub struct GpuStats {
83 pub gpu_us_per_step: Option<f64>,
85 pub batch_size: u32,
87 pub adaptive: bool,
89}
90
91impl Default for GpuStats {
92 fn default() -> Self {
93 Self {
94 gpu_us_per_step: None,
95 batch_size: DEFAULT_BATCH_SIZE,
96 adaptive: false,
97 }
98 }
99}
100
101#[derive(Debug)]
103pub enum GpuCommand {
104 SetBatchSize(u32),
106 SetAdaptive(bool),
108 SetTargetMs(f64),
110}
111
112#[derive(Clone, Copy, Debug)]
115pub struct GpuBatchSettings {
116 pub adaptive: bool,
118 pub batch_size: u32,
120 pub target_ms: f64,
122}
123
124impl Default for GpuBatchSettings {
125 fn default() -> Self {
126 Self {
127 adaptive: true,
128 batch_size: DEFAULT_BATCH_SIZE,
129 target_ms: DEFAULT_TARGET_MS,
130 }
131 }
132}
133
134const SNAPSHOT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16);
136
137const STATS_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
139
140use std::sync::{Arc, Mutex};
141
142use web_time::Instant;
143
144use crate::cpu::sim_thread::{SimCommand, WakeFn};
145use crate::gpu::timing::{ADAPTIVE_EMA_ALPHA, TimestampQuery, ema_update, next_batch_size, time_per_step_ms, tps_over};
146use crate::gpu::{GpuContext, MAX_STEPS_PER_SUBMISSION};
147use crate::runner::{Driver, Pace, RUN_TO_PUBLISH_INTERVAL, SharedSlot, SimLoop, SnapshotSlot};
148use crate::snapshot::{Snapshot, SnapshotView};
149
150const OUTSTANDING_POLL: std::time::Duration = std::time::Duration::from_millis(1);
154
155enum Command {
156 Sim(SimCommand),
157 Gpu(GpuCommand),
158}
159
160#[cfg(not(target_arch = "wasm32"))]
165struct InFlight {
166 submission: wgpu::SubmissionIndex,
167 steps: u32,
168 started_at: Instant,
169}
170
171#[cfg(target_arch = "wasm32")]
179struct InFlight {
180 elapsed_us: Arc<std::sync::atomic::AtomicU64>,
181 steps: u32,
182}
183
184struct Loop {
186 ctx: GpuContext,
187 state: Box<dyn GpuSimState>,
188 slot: SharedSlot,
189 gpu_stats: Arc<Mutex<GpuStats>>,
190 wake: Option<WakeFn>,
191 running: bool,
192 adaptive: bool,
195 fixed_batch_size: u32,
197 target_ms: f64,
199 batch_size: u32,
202 ema_time_per_step_ms: Option<f64>,
205 in_flight: Option<InFlight>,
206 step_count: u64,
207 actual_tps: f64,
208 gpu_us_per_step: Option<f64>,
209 tps_timer: Instant,
210 last_snapshot_publish: Instant,
211 last_stats_publish: Instant,
212 serial: u64,
214 timestamp_query: Option<TimestampQuery>,
216 schedule: Schedule,
218 fired_through: Option<u64>,
220 run_to_target: Option<u64>,
222 late_stats: bool,
225}
226
227impl SimLoop for Loop {
228 type Command = Command;
229
230 fn start(&mut self) {
233 self.snapshot_now();
234 }
235
236 fn handle_command(&mut self, cmd: Command) -> bool {
237 match cmd {
238 Command::Sim(SimCommand::Play) => {
239 self.run_to_target = None;
240 self.running = true;
241 self.reset_tps_window(Instant::now());
242 }
243 Command::Sim(SimCommand::Pause) => {
244 self.run_to_target = None;
245 self.running = false;
246 self.actual_tps = 0.0;
247 self.snapshot_now();
248 }
249 Command::Sim(SimCommand::StepOnce) => {
250 self.run_to_target = None;
251 let mut encoder = self.encoder("henad_gpu_step_once");
252 self.state.encode_steps(&mut encoder, 1, None);
253 self.ctx.queue.submit(Some(encoder.finish()));
254 fire_due(&mut *self.state, &self.ctx, &self.schedule);
255 self.fired_through = Some(self.state.tick());
256 self.snapshot_now();
257 }
258 Command::Sim(SimCommand::SetParam { index, value }) => {
259 if !self.state.set_param(index, &value) {
260 log::warn!("Failed to set param index {index} to {value:?}");
261 }
262 }
263 Command::Sim(
267 SimCommand::SetTargetTps(_)
268 | SimCommand::SetUncapped(_)
269 | SimCommand::SetTicksPerSnapshot(_)
270 | SimCommand::SetLayout { .. },
272 ) => {}
273 Command::Sim(SimCommand::Act(index)) => {
274 let mut encoder = self.encoder("henad_gpu_action");
275 if self.state.encode_action(&mut encoder, index) {
276 self.ctx.queue.submit(Some(encoder.finish()));
277 self.snapshot_now();
278 } else {
279 log::warn!("Model has no action at index {index}");
280 }
281 }
282 Command::Sim(SimCommand::SetSchedule(schedule)) => {
283 self.schedule = schedule;
284 if self.fired_through.is_none_or(|through| through < self.state.tick()) {
285 fire_due(&mut *self.state, &self.ctx, &self.schedule);
286 self.fired_through = Some(self.state.tick());
287 }
288 self.snapshot_now();
289 }
290 Command::Sim(SimCommand::RunTo(target)) => {
291 self.run_to_target = Some(target);
292 self.running = false;
293 self.reset_tps_window(Instant::now());
294 }
295 Command::Sim(SimCommand::Shutdown) => return true,
296 Command::Gpu(GpuCommand::SetBatchSize(n)) => {
297 self.fixed_batch_size = n.max(1);
298 if !self.adaptive {
299 self.batch_size = self.fixed_batch_size;
300 }
301 }
302 Command::Gpu(GpuCommand::SetAdaptive(enabled)) => {
303 self.adaptive = enabled;
304 if !enabled {
305 self.batch_size = self.fixed_batch_size;
306 }
307 self.publish_gpu_stats();
308 }
309 Command::Gpu(GpuCommand::SetTargetMs(target_ms)) => {
310 self.target_ms = target_ms.max(0.1);
311 }
312 }
313 false
314 }
315
316 fn pump(&mut self) -> Pace {
317 if self.ctx.faults.is_set() {
320 if self.running || self.run_to_target.is_some() {
321 self.running = false;
322 self.run_to_target = None;
323 self.actual_tps = 0.0;
324 self.publish_snapshot();
325 }
326 return Pace::Idle;
327 }
328 if !self.running && self.run_to_target.is_none() {
329 return self.collect_late_stats();
330 }
331 if self.late_stats {
334 let pace = self.collect_late_stats();
335 if self.late_stats {
336 return pace;
337 }
338 }
339 if !self.await_previous() {
340 return Pace::After(OUTSTANDING_POLL);
341 }
342 if self.run_to_target.is_some_and(|target| self.state.tick() >= target) {
343 self.run_to_target = None;
344 self.actual_tps = 0.0;
345 self.snapshot_now();
346 return self.collect_late_stats();
347 }
348 self.step_batch();
349 Pace::Now
350 }
351}
352
353impl Loop {
354 fn new(
356 ctx: GpuContext,
357 state: Box<dyn GpuSimState>,
358 settings: &GpuBatchSettings,
359 slot: SharedSlot,
360 gpu_stats: Arc<Mutex<GpuStats>>,
361 wake: Option<WakeFn>,
362 ) -> Self {
363 let batch_size = settings.batch_size.max(1);
364 let timestamp_query = TimestampQuery::new(&ctx.device, &ctx.queue);
365 let now = Instant::now();
366 Self {
367 ctx,
368 state,
369 slot,
370 gpu_stats,
371 wake,
372 running: false,
373 adaptive: settings.adaptive,
374 fixed_batch_size: batch_size,
375 target_ms: settings.target_ms,
376 batch_size,
377 ema_time_per_step_ms: None,
378 in_flight: None,
379 step_count: 0,
380 actual_tps: 0.0,
381 gpu_us_per_step: None,
382 tps_timer: now,
383 last_snapshot_publish: now,
384 last_stats_publish: now,
385 serial: 0,
386 timestamp_query,
387 schedule: Schedule::default(),
388 fired_through: None,
389 run_to_target: None,
390 late_stats: false,
391 }
392 }
393
394 fn encoder(&self, label: &str) -> wgpu::CommandEncoder {
395 self.ctx
396 .device
397 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) })
398 }
399
400 #[cfg(not(target_arch = "wasm32"))]
407 fn await_previous(&mut self) -> bool {
408 let Some(prev) = self.in_flight.take() else {
409 return true;
410 };
411 drop(self.ctx.device.poll(wgpu::PollType::Wait {
412 submission_index: Some(prev.submission),
413 timeout: None,
414 }));
415 self.record_sample(time_per_step_ms(prev.started_at.elapsed(), prev.steps));
416 true
417 }
418
419 #[cfg(target_arch = "wasm32")]
423 fn await_previous(&mut self) -> bool {
424 use std::sync::atomic::Ordering;
425
426 let Some(prev) = self.in_flight.as_ref() else {
427 return true;
428 };
429 let Some(elapsed_us) = prev.elapsed_us.load(Ordering::Relaxed).checked_sub(1) else {
430 return false;
431 };
432 let steps = prev.steps;
433 self.in_flight = None;
434 self.record_sample(time_per_step_ms(std::time::Duration::from_micros(elapsed_us), steps));
435 true
436 }
437
438 fn record_sample(&mut self, sample: f64) {
440 let ema = ema_update(self.ema_time_per_step_ms, sample, ADAPTIVE_EMA_ALPHA);
441 self.ema_time_per_step_ms = Some(ema);
442 if self.adaptive {
443 self.batch_size = next_batch_size(ema, self.target_ms);
444 }
445 }
446
447 #[cfg(not(target_arch = "wasm32"))]
449 fn track(&mut self, submission: Option<wgpu::SubmissionIndex>, steps: u32, started_at: Instant) {
450 self.in_flight = submission.map(|submission| InFlight {
451 submission,
452 steps,
453 started_at,
454 });
455 }
456
457 #[cfg(target_arch = "wasm32")]
459 fn track(&mut self, _submission: Option<wgpu::SubmissionIndex>, steps: u32, started_at: Instant) {
460 use std::sync::atomic::{AtomicU64, Ordering};
461
462 let elapsed_us = Arc::new(AtomicU64::new(0));
463 let signal = Arc::clone(&elapsed_us);
464 self.ctx.queue.on_submitted_work_done(move || {
465 let micros = started_at.elapsed().as_micros().min(u128::from(u64::MAX - 1)) as u64;
466 signal.store(micros + 1, Ordering::Relaxed);
467 });
468 self.in_flight = Some(InFlight { elapsed_us, steps });
469 }
470
471 fn collect_late_stats(&mut self) -> Pace {
477 if !self.state.stats_readback_pending() {
478 self.late_stats = false;
479 return Pace::Idle;
480 }
481 self.state.poll_stats_readback(&self.ctx.device, false);
482 if self.state.stats_readback_pending() {
483 if let Some(wake) = &self.wake {
484 wake();
485 }
486 return Pace::After(OUTSTANDING_POLL);
487 }
488 self.late_stats = false;
489 self.publish_snapshot();
490 Pace::Idle
491 }
492
493 fn steps_to_next_action(&self) -> Option<u64> {
495 let tick = self.state.tick();
496 self.schedule.next_due_after(tick).map(|due| due - tick)
497 }
498
499 fn snapshot_now(&mut self) {
506 let mut encoder = self.encoder("henad_gpu_snapshot_now");
507 self.state.encode_snapshot_passes(&mut encoder);
508 self.ctx.queue.submit(Some(encoder.finish()));
509 self.state.begin_stats_readback();
510 self.state.poll_stats_readback(&self.ctx.device, true);
511 self.late_stats = self.state.stats_readback_pending();
512 self.in_flight = None;
513
514 self.last_snapshot_publish = Instant::now();
515 self.publish_snapshot();
516 self.publish_gpu_stats();
517 }
518
519 fn step_batch(&mut self) {
531 let now = Instant::now();
532 let want_timing =
533 self.timestamp_query.is_some() && now.duration_since(self.last_stats_publish) >= STATS_INTERVAL;
534 let (batch_size_submitted, snapshot_interval) = match self.run_to_target {
535 Some(target) => {
536 let remaining = target.saturating_sub(self.state.tick());
537 let steps = u64::from(self.batch_size).min(remaining) as u32;
538 (steps, RUN_TO_PUBLISH_INTERVAL)
539 }
540 None => (self.batch_size, SNAPSHOT_INTERVAL),
541 };
542 let reaches_target = self
545 .run_to_target
546 .is_some_and(|target| self.state.tick() + u64::from(batch_size_submitted) >= target);
547 let want_snapshot = !reaches_target && now.duration_since(self.last_snapshot_publish) >= snapshot_interval;
548
549 let query_set = if want_timing {
550 self.timestamp_query.as_ref().map(TimestampQuery::query_set)
551 } else {
552 None
553 };
554
555 let mut submitted = 0;
556 let mut stamped_steps = None;
557 let mut write_submission = None;
558 while submitted < batch_size_submitted {
559 let steps_to_action = self.steps_to_next_action();
560 let chunk = MAX_STEPS_PER_SUBMISSION.min(batch_size_submitted - submitted);
561 let chunk = steps_to_action.map_or(chunk, |steps| steps.min(u64::from(chunk)) as u32);
562 let action_due = steps_to_action == Some(u64::from(chunk));
563 let mut encoder = self.encoder("henad_gpu_sim_encoder");
564
565 let stamp = query_set.filter(|_| stamped_steps.is_none());
566 if stamp.is_some() {
567 stamped_steps = Some(chunk);
568 }
569 self.state.encode_steps(&mut encoder, chunk, stamp);
570
571 submitted += chunk;
572 let snapshot_here = want_snapshot && submitted >= batch_size_submitted;
573 if snapshot_here && !action_due {
574 self.state.encode_snapshot_passes(&mut encoder);
575 }
576 write_submission = Some(self.ctx.queue.submit(Some(encoder.finish())));
577 if action_due {
578 write_submission = fire_due(&mut *self.state, &self.ctx, &self.schedule).or(write_submission);
579 if snapshot_here {
580 let mut encoder = self.encoder("henad_gpu_snapshot");
581 self.state.encode_snapshot_passes(&mut encoder);
582 write_submission = Some(self.ctx.queue.submit(Some(encoder.finish())));
583 }
584 }
585 }
586 self.fired_through = Some(self.state.tick());
588
589 self.step_count += u64::from(batch_size_submitted);
590
591 if want_snapshot {
592 self.state.begin_stats_readback();
593 }
594 self.state.poll_stats_readback(&self.ctx.device, false);
597
598 if want_timing && let (Some(tq), Some(submission)) = (self.timestamp_query.as_ref(), write_submission.clone()) {
599 tq.resolve_after(&self.ctx.device, &self.ctx.queue, submission);
600 }
601
602 if want_timing {
603 if let (Some(tq), Some(steps)) = (self.timestamp_query.as_ref(), stamped_steps) {
604 self.gpu_us_per_step = tq.read_gpu_us_per_step(&self.ctx.device, steps);
605 }
606 self.refresh_tps(now);
607 self.last_stats_publish = now;
608 } else if self.timestamp_query.is_none() && now.duration_since(self.tps_timer) >= STATS_INTERVAL {
609 self.refresh_tps(now);
612 }
613
614 if want_snapshot {
615 self.last_snapshot_publish = now;
616 self.publish_snapshot();
617 self.publish_gpu_stats();
618 }
619
620 self.track(write_submission, batch_size_submitted, now);
621 }
622
623 fn reset_tps_window(&mut self, now: Instant) {
627 self.tps_timer = now;
628 self.last_stats_publish = now;
629 self.step_count = 0;
630 }
631
632 fn refresh_tps(&mut self, now: Instant) {
633 let Some(tps) = tps_over(self.step_count, now.duration_since(self.tps_timer)) else {
634 return;
637 };
638 self.actual_tps = tps;
639 self.step_count = 0;
640 self.tps_timer = now;
641 }
642
643 fn publish_snapshot(&mut self) {
644 self.serial += 1;
645 let snap = Snapshot {
646 serial: self.serial,
647 view_ms: 0.0,
649 tick: self.state.tick(),
650 population: self.state.population(),
651 heap_bytes: self.state.heap_bytes(),
652 actual_tps: self.actual_tps,
653 engine_ms: self.gpu_us_per_step.unwrap_or(0.0) / 1000.0,
656 view: SnapshotView::Gpu(self.state.view()),
657 stats: self.state.stats(),
658 };
659 crate::runner::publish(&self.slot, snap);
660 if let Some(wake) = &self.wake {
662 wake();
663 }
664 }
665
666 fn publish_gpu_stats(&self) {
667 if let Ok(mut stats) = self.gpu_stats.lock() {
668 *stats = GpuStats {
669 gpu_us_per_step: self.gpu_us_per_step,
670 batch_size: self.batch_size,
671 adaptive: self.adaptive,
672 };
673 }
674 }
675}
676
677fn fire_due(state: &mut dyn GpuSimState, ctx: &GpuContext, schedule: &Schedule) -> Option<wgpu::SubmissionIndex> {
680 let tick = state.tick();
681 let mut last = None;
682 for action in schedule.due(tick) {
683 let mut encoder = ctx.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
684 label: Some("henad_gpu_scheduled_action"),
685 });
686 if state.encode_action(&mut encoder, action.index) {
687 last = Some(ctx.queue.submit(Some(encoder.finish())));
688 } else {
689 log::warn!("Model refused action '{}' at tick {tick}", action.id);
690 }
691 }
692 last
693}
694
695pub struct GpuSimThread {
698 driver: Driver<Loop>,
699 slot: SharedSlot,
700 gpu_stats: Arc<Mutex<GpuStats>>,
701}
702
703impl std::fmt::Debug for GpuSimThread {
704 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
705 f.debug_struct("GpuSimThread").finish_non_exhaustive()
706 }
707}
708
709impl GpuSimThread {
710 pub fn new(ctx: GpuContext, state: Box<dyn GpuSimState>, settings: GpuBatchSettings, wake: Option<WakeFn>) -> Self {
714 let batch_size = settings.batch_size.max(1);
715 let gpu_stats = Arc::new(Mutex::new(GpuStats {
716 gpu_us_per_step: None,
717 batch_size,
718 adaptive: settings.adaptive,
719 }));
720 let slot = SnapshotSlot::empty();
723 let faults = ctx.faults.clone();
724 let sim = Loop::new(
725 ctx,
726 state,
727 &settings,
728 SharedSlot::clone(&slot),
729 Arc::clone(&gpu_stats),
730 wake.clone(),
731 );
732
733 let driver = Driver::spawn(sim, move |fault| {
734 faults.set_once(fault);
735 if let Some(wake) = &wake {
736 wake();
737 }
738 });
739
740 Self {
741 driver,
742 slot,
743 gpu_stats,
744 }
745 }
746
747 pub fn send(&mut self, cmd: SimCommand) {
749 self.driver.send(Command::Sim(cmd));
750 }
751
752 pub fn take_snapshot(&mut self) -> Option<Snapshot> {
754 crate::runner::take_snapshot(&self.slot)
755 }
756
757 pub fn play(&mut self) {
759 self.send(SimCommand::Play);
760 }
761
762 pub fn pause(&mut self) {
764 self.send(SimCommand::Pause);
765 }
766
767 pub fn step_once(&mut self) {
769 self.send(SimCommand::StepOnce);
770 }
771
772 pub fn set_schedule(&mut self, schedule: Schedule) {
774 self.send(SimCommand::SetSchedule(schedule));
775 }
776
777 pub fn run_to(&mut self, tick: u64) {
779 self.send(SimCommand::RunTo(tick));
780 }
781
782 pub fn set_batch_size(&mut self, batch_size: u32) {
785 self.driver.send(Command::Gpu(GpuCommand::SetBatchSize(batch_size)));
786 }
787
788 pub fn set_adaptive(&mut self, enabled: bool) {
791 self.driver.send(Command::Gpu(GpuCommand::SetAdaptive(enabled)));
792 }
793
794 pub fn set_target_ms(&mut self, target_ms: f64) {
796 self.driver.send(Command::Gpu(GpuCommand::SetTargetMs(target_ms)));
797 }
798
799 pub fn gpu_stats(&self) -> GpuStats {
801 self.gpu_stats.lock().map(|s| *s).unwrap_or_default()
802 }
803
804 pub fn update(&mut self, dt: f64) {
807 self.driver.update(dt);
808 }
809}
810
811impl Drop for GpuSimThread {
812 fn drop(&mut self) {
813 self.driver.shutdown(Command::Sim(SimCommand::Shutdown));
814 }
815}
816
817#[cfg(all(test, not(target_arch = "wasm32")))]
818mod tests {
819 use std::sync::atomic::{AtomicU64, Ordering};
820 use std::sync::{Arc, Mutex};
821
822 use super::{Command, GpuBatchSettings, GpuSimState, GpuSimThread, GpuStats, Loop, StatsPoll};
823 use crate::cpu::sim_thread::SimCommand;
824 use crate::gpu::{GpuContext, headless_context};
825 use crate::runner::{SharedSlot, SimLoop as _, SnapshotSlot};
826 use crate::snapshot::{GpuSnapshot, Snapshot};
827 use henad_core::action::{Schedule, Scheduled};
828 use henad_core::model::SimState;
829 use henad_core::params::ParamValue;
830 use henad_core::view::{StatEntry, StatValue};
831
832 struct LateStats {
835 polls_left: u32,
836 population: u64,
837 }
838
839 const LANDED: u64 = 7;
840
841 impl SimState for LateStats {
842 fn step(&mut self) {}
843 fn tick(&self) -> u64 {
844 0
845 }
846 fn stats(&self) -> Vec<StatEntry> {
847 Vec::new()
848 }
849 fn set_param(&mut self, _index: usize, _value: &ParamValue) -> bool {
850 false
851 }
852 fn population(&self) -> u64 {
853 self.population
854 }
855 fn heap_bytes(&self) -> usize {
856 0
857 }
858 }
859
860 impl GpuSimState for LateStats {
861 fn encode_steps(&mut self, _encoder: &mut wgpu::CommandEncoder, _count: u32, _t: Option<&wgpu::QuerySet>) {}
862 fn encode_snapshot_passes(&mut self, _encoder: &mut wgpu::CommandEncoder) {}
863 fn begin_stats_readback(&mut self) {}
864
865 fn poll_stats_readback(&mut self, _device: &wgpu::Device, _block: bool) -> StatsPoll {
866 self.polls_left = self.polls_left.saturating_sub(1);
867 if self.polls_left > 0 {
868 return StatsPoll::Pending;
869 }
870 self.population = LANDED;
871 StatsPoll::Landed
872 }
873
874 fn stats_readback_pending(&self) -> bool {
875 self.polls_left > 0
876 }
877
878 fn view(&self) -> GpuSnapshot {
879 GpuSnapshot {
880 display: None,
881 agents: None,
882 }
883 }
884 }
885
886 #[test]
889 fn a_readback_landing_after_the_initial_snapshot_is_published() {
890 let Some(ctx) = headless_context("henad_late_stats_test", wgpu::Features::empty()) else {
891 log::warn!("skipping a_readback_landing_after_the_initial_snapshot_is_published: no adapter");
892 return;
893 };
894 let state = LateStats {
895 polls_left: 2,
896 population: 0,
897 };
898 let mut thread = GpuSimThread::new(ctx, Box::new(state), GpuBatchSettings::default(), None);
899
900 let mut seen = None;
901 for _ in 0..200 {
902 if let Some(snap) = thread.take_snapshot() {
903 seen = Some(snap.population);
904 if seen == Some(LANDED) {
905 break;
906 }
907 }
908 std::thread::sleep(std::time::Duration::from_millis(10));
909 }
910 assert_eq!(seen, Some(LANDED), "the late readback was never published");
911 }
912
913 struct LateTickStats {
916 tick: u64,
917 polls_left: u32,
918 }
919
920 impl SimState for LateTickStats {
921 fn step(&mut self) {
922 self.tick += 1;
923 }
924 fn tick(&self) -> u64 {
925 self.tick
926 }
927 fn stats(&self) -> Vec<StatEntry> {
928 vec![StatEntry {
929 label: "Landed",
930 value: StatValue::Scalar(if self.polls_left == 0 { 1.0 } else { 0.0 }),
931 color: [0; 4],
932 }]
933 }
934 fn set_param(&mut self, _index: usize, _value: &ParamValue) -> bool {
935 false
936 }
937 fn population(&self) -> u64 {
938 0
939 }
940 fn heap_bytes(&self) -> usize {
941 0
942 }
943 }
944
945 impl GpuSimState for LateTickStats {
946 fn encode_steps(&mut self, _encoder: &mut wgpu::CommandEncoder, count: u32, _t: Option<&wgpu::QuerySet>) {
947 self.tick += u64::from(count);
948 }
949 fn encode_snapshot_passes(&mut self, _encoder: &mut wgpu::CommandEncoder) {}
950 fn begin_stats_readback(&mut self) {}
951 fn poll_stats_readback(&mut self, _device: &wgpu::Device, _block: bool) -> StatsPoll {
952 self.polls_left = self.polls_left.saturating_sub(1);
953 if self.polls_left > 0 {
954 StatsPoll::Pending
955 } else {
956 StatsPoll::Landed
957 }
958 }
959 fn stats_readback_pending(&self) -> bool {
960 self.polls_left > 0
961 }
962 fn view(&self) -> GpuSnapshot {
963 GpuSnapshot {
964 display: None,
965 agents: None,
966 }
967 }
968 }
969
970 #[test]
973 fn a_run_to_after_the_build_republishes_tick_zero_first() {
974 let Some(ctx) = headless_context("henad_late_tick_zero_test", wgpu::Features::empty()) else {
975 log::warn!("skipping a_run_to_after_the_build_republishes_tick_zero_first: no adapter");
976 return;
977 };
978 let settings = GpuBatchSettings {
979 adaptive: false,
980 batch_size: 100,
981 ..GpuBatchSettings::default()
982 };
983 let slot = SnapshotSlot::empty();
984 let gpu_stats = Arc::new(Mutex::new(GpuStats {
985 gpu_us_per_step: None,
986 batch_size: settings.batch_size,
987 adaptive: settings.adaptive,
988 }));
989 let state = LateTickStats { tick: 0, polls_left: 3 };
990 let mut sim = Loop::new(
991 ctx,
992 Box::new(state),
993 &settings,
994 SharedSlot::clone(&slot),
995 gpu_stats,
996 None,
997 );
998
999 let mut rows: Vec<(u64, f64)> = Vec::new();
1001 let record = |rows: &mut Vec<(u64, f64)>| {
1002 if let Some(snap) = crate::runner::take_snapshot(&slot) {
1003 let landed = snap.stats[0].value.scalar();
1004 match rows.last_mut() {
1005 Some(row) if row.0 == snap.tick => row.1 = landed,
1006 _ => rows.push((snap.tick, landed)),
1007 }
1008 }
1009 };
1010 sim.start();
1011 record(&mut rows);
1012 sim.handle_command(Command::Sim(SimCommand::RunTo(10)));
1013 for _ in 0..10 {
1014 sim.pump();
1015 record(&mut rows);
1016 }
1017 assert_eq!(
1018 rows.last().map(|row| row.0),
1019 Some(10),
1020 "the run reached its target: {rows:?}"
1021 );
1022 assert_eq!(
1023 rows.first(),
1024 Some(&(0, 1.0)),
1025 "tick 0 kept the stats from before its readback: {rows:?}"
1026 );
1027 }
1028
1029 struct ActionRecorder {
1031 ticks: Arc<AtomicU64>,
1032 fired: Arc<Mutex<Vec<u64>>>,
1033 }
1034
1035 impl SimState for ActionRecorder {
1036 fn step(&mut self) {
1037 self.ticks.fetch_add(1, Ordering::Relaxed);
1038 }
1039 fn tick(&self) -> u64 {
1040 self.ticks.load(Ordering::Relaxed)
1041 }
1042 fn stats(&self) -> Vec<StatEntry> {
1043 Vec::new()
1044 }
1045 fn set_param(&mut self, _index: usize, _value: &ParamValue) -> bool {
1046 false
1047 }
1048 fn population(&self) -> u64 {
1049 0
1050 }
1051 fn heap_bytes(&self) -> usize {
1052 0
1053 }
1054 }
1055
1056 impl GpuSimState for ActionRecorder {
1057 fn encode_steps(
1058 &mut self,
1059 _encoder: &mut wgpu::CommandEncoder,
1060 count: u32,
1061 _timestamps: Option<&wgpu::QuerySet>,
1062 ) {
1063 self.ticks.fetch_add(u64::from(count), Ordering::Relaxed);
1064 }
1065 fn encode_action(&mut self, _encoder: &mut wgpu::CommandEncoder, _index: usize) -> bool {
1066 self.fired.lock().expect("action log").push(self.tick());
1067 true
1068 }
1069 fn encode_snapshot_passes(&mut self, _encoder: &mut wgpu::CommandEncoder) {}
1070 fn begin_stats_readback(&mut self) {}
1071 fn poll_stats_readback(&mut self, _device: &wgpu::Device, _block: bool) -> StatsPoll {
1072 StatsPoll::Landed
1073 }
1074 fn stats_readback_pending(&self) -> bool {
1075 false
1076 }
1077 fn view(&self) -> GpuSnapshot {
1078 GpuSnapshot {
1079 display: None,
1080 agents: None,
1081 }
1082 }
1083 }
1084
1085 fn action_recorder(ctx: GpuContext) -> (GpuSimThread, Arc<AtomicU64>, Arc<Mutex<Vec<u64>>>) {
1087 let ticks = Arc::new(AtomicU64::new(0));
1088 let fired = Arc::new(Mutex::new(Vec::new()));
1089 let state = ActionRecorder {
1090 ticks: Arc::clone(&ticks),
1091 fired: Arc::clone(&fired),
1092 };
1093 let settings = GpuBatchSettings {
1094 adaptive: false,
1095 batch_size: 100,
1096 ..GpuBatchSettings::default()
1097 };
1098 (GpuSimThread::new(ctx, Box::new(state), settings, None), ticks, fired)
1099 }
1100
1101 fn snapshot_at(thread: &mut GpuSimThread, tick: u64) -> (Option<Snapshot>, u64) {
1104 let mut highest = 0;
1105 for _ in 0..500 {
1106 if let Some(snap) = thread.take_snapshot() {
1107 highest = highest.max(snap.tick);
1108 if snap.tick == tick {
1109 return (Some(snap), highest);
1110 }
1111 }
1112 std::thread::sleep(std::time::Duration::from_millis(10));
1113 }
1114 (None, highest)
1115 }
1116
1117 fn settle() {
1119 std::thread::sleep(std::time::Duration::from_millis(100));
1120 }
1121
1122 #[test]
1123 fn run_to_stops_at_the_target() {
1124 let Some(ctx) = headless_context("henad_run_to_test", wgpu::Features::empty()) else {
1125 log::warn!("skipping run_to_stops_at_the_target: no adapter");
1126 return;
1127 };
1128 let (mut thread, ticks, _) = action_recorder(ctx);
1129 thread.run_to(1003);
1131 let (reached, highest) = snapshot_at(&mut thread, 1003);
1132 let reached = reached.expect("the run never published its target");
1133 assert_eq!(highest, 1003, "a snapshot went past the target");
1134 assert_eq!(reached.actual_tps, 0.0, "a paused run reported a rate");
1135 settle();
1136 assert_eq!(ticks.load(Ordering::Relaxed), 1003, "the run went past its target");
1137 assert!(thread.take_snapshot().is_none(), "a paused run kept publishing");
1138
1139 thread.run_to(10);
1140 assert!(
1141 snapshot_at(&mut thread, 1003).0.is_some(),
1142 "a run to a tick behind the current one pauses and publishes"
1143 );
1144 settle();
1145 assert_eq!(ticks.load(Ordering::Relaxed), 1003);
1146 }
1147
1148 #[test]
1149 fn a_gpu_schedule_fires_once_per_tick_after_the_step_reaching_it() {
1150 let Some(ctx) = headless_context("henad_gpu_schedule_test", wgpu::Features::empty()) else {
1151 log::warn!("skipping a_gpu_schedule_fires_once_per_tick_after_the_step_reaching_it: no adapter");
1152 return;
1153 };
1154 let (mut thread, ticks, fired) = action_recorder(ctx);
1155 let entries = [0, 5, 64, 64, 100, 101, 250]
1156 .map(|tick| Scheduled {
1157 index: 0,
1158 id: "mark".to_owned(),
1159 tick,
1160 })
1161 .to_vec();
1162 thread.set_schedule(Schedule::from_entries(entries));
1163 thread.run_to(100);
1164 assert!(snapshot_at(&mut thread, 100).0.is_some());
1165 let fired_ticks = || fired.lock().expect("action log").clone();
1166 assert_eq!(fired_ticks(), [0, 5, 64, 64, 100], "each action fires at its own tick");
1167
1168 thread.step_once();
1169 assert!(snapshot_at(&mut thread, 101).0.is_some());
1170 thread.run_to(200);
1171 assert!(snapshot_at(&mut thread, 200).0.is_some());
1172 assert_eq!(fired_ticks(), [0, 5, 64, 64, 100, 101]);
1173 assert_eq!(ticks.load(Ordering::Relaxed), 200);
1174 }
1175
1176 #[test]
1178 fn a_replaced_gpu_schedule_never_fires_a_tick_again() {
1179 let Some(ctx) = headless_context("henad_gpu_replaced_schedule_test", wgpu::Features::empty()) else {
1180 log::warn!("skipping a_replaced_gpu_schedule_never_fires_a_tick_again: no adapter");
1181 return;
1182 };
1183 let (mut thread, _, fired) = action_recorder(ctx);
1184 let schedule = |ticks: &[u64]| {
1185 let entries = ticks
1186 .iter()
1187 .map(|&tick| Scheduled {
1188 index: 0,
1189 id: "mark".to_owned(),
1190 tick,
1191 })
1192 .collect();
1193 Schedule::from_entries(entries)
1194 };
1195 let fired_ticks = || fired.lock().expect("action log").clone();
1196
1197 thread.set_schedule(schedule(&[0, 40]));
1198 thread.set_schedule(schedule(&[0, 40, 90]));
1199 thread.run_to(40);
1200 assert!(snapshot_at(&mut thread, 40).0.is_some());
1201 assert_eq!(fired_ticks(), [0, 40], "a second schedule at tick 0 fired it again");
1202
1203 thread.set_schedule(schedule(&[40, 90]));
1204 thread.run_to(90);
1205 assert!(snapshot_at(&mut thread, 90).0.is_some());
1206 assert_eq!(
1207 fired_ticks(),
1208 [0, 40, 90],
1209 "a schedule at a tick a batch reached fired it again"
1210 );
1211
1212 thread.run_to(150);
1214 assert!(snapshot_at(&mut thread, 150).0.is_some());
1215 thread.set_schedule(schedule(&[150, 151]));
1216 thread.step_once();
1217 assert!(snapshot_at(&mut thread, 151).0.is_some());
1218 assert_eq!(fired_ticks(), [0, 40, 90, 151]);
1219 }
1220}