Skip to main content

henad_compute/gpu/
sim_thread.rs

1//! The GPU runner [`GpuSimThread`]. It steps a GPU-resident state in batched submissions.
2//!
3//! [`GpuSimThread`] is the counterpart of [`crate::cpu::sim_thread::SimThread`], and [`crate::runner`] drives it the
4//! same way. A batch of `batch_size` steps goes out in submissions of at most
5//! [`crate::gpu::MAX_STEPS_PER_SUBMISSION`] steps each, with one batch outstanding at a time. The display and stats
6//! passes run once per snapshot interval, whatever the batch size. In adaptive mode the batch size tracks a wall-clock
7//! budget per batch, `target_ms`.
8use 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
15/// Runner interface of a GPU state, driven by [`GpuSimThread`] on top of [`SimState`].
16///
17/// A model implements an authoring trait, such as `henad_core::authoring::model::gpu_grid_model::GpuGridModel`,
18/// instead of this trait. A GPU model's state never leaves the GPU, so `SimState::stats()` reports whatever the last
19/// completed [`Self::poll_stats_readback`] produced, a few milliseconds stale.
20pub trait GpuSimState: SimState {
21    /// Records `count` steps into `encoder`, advancing the model's own tick counter by `count`.
22    ///
23    /// `count` must not exceed [`crate::gpu::MAX_STEPS_PER_SUBMISSION`] for one submission.
24    ///
25    /// When `timestamps` is `Some`, the state stamps the beginning of the first step and the end of the last
26    /// into query indices 0 and 1, so the caller can measure GPU time over `count` steps.
27    fn encode_steps(&mut self, encoder: &mut wgpu::CommandEncoder, count: u32, timestamps: Option<&wgpu::QuerySet>);
28
29    /// Records the model's declared action at `index`, and returns whether the model declares one.
30    ///
31    /// Nothing is recorded when it returns false, so the caller drops the encoder unsubmitted.
32    ///
33    /// Note that the action's seed is written through the queue, and the write takes effect when the next
34    /// submission starts. Two presses of one action recorded into one encoder would both read the
35    /// second seed, so each press needs its own submission.
36    fn encode_action(&mut self, _encoder: &mut wgpu::CommandEncoder, _index: usize) -> bool {
37        false
38    }
39
40    /// Records the display pass (state -> display texture) and the stats-reduction pass
41    /// (state -> a handful of numbers), at the snapshot cadence rather than every step.
42    fn encode_snapshot_passes(&mut self, encoder: &mut wgpu::CommandEncoder);
43
44    /// Records the stats-reduction passes of [`Self::encode_snapshot_passes`], without its display pass.
45    ///
46    /// The default records the display pass too.
47    fn encode_stats_passes(&mut self, encoder: &mut wgpu::CommandEncoder) {
48        self.encode_snapshot_passes(encoder);
49    }
50
51    /// Starts the async stats readback.
52    ///
53    /// Call it immediately after the submission that [`Self::encode_snapshot_passes`] or [`Self::encode_stats_passes`]
54    /// was recorded into. Mapping earlier races the copy.
55    fn begin_stats_readback(&mut self);
56
57    /// Completes an in-flight stats readback, updating what `SimState::stats()` returns.
58    ///
59    /// With `block = false` this must not wait on the GPU. It runs every loop iteration, and the runner
60    /// exists to avoid a stall until the queue drains. `block = true` is for one-shot snapshots and samples, where a
61    /// real value in the stats panel beats a few ms of latency.
62    ///
63    /// Returns the state of the readback after the poll.
64    fn poll_stats_readback(&mut self, device: &wgpu::Device, block: bool) -> StatsPoll;
65
66    /// Returns whether a readback started by [`Self::begin_stats_readback`] has not completed yet.
67    ///
68    /// In a browser `block = true` above cannot block, so a one-shot snapshot publishes before its
69    /// readback arrives and the loop has to come back for it.
70    fn stats_readback_pending(&self) -> bool;
71
72    /// Returns the layers the UI draws.
73    ///
74    /// The result is cloned into every snapshot. Keep it to `Arc` clones of things built once at construction.
75    fn view(&self) -> GpuSnapshot;
76}
77
78/// Live GPU-runner numbers that have no CPU counterpart, polled by the UI once per frame.
79///
80/// Everything with a CPU equivalent (tick, TPS, population, stats) travels in the [`Snapshot`] instead.
81#[derive(Clone, Copy, Debug)]
82pub struct GpuStats {
83    /// GPU execution time per step in microseconds, if the adapter supports timestamp queries.
84    pub gpu_us_per_step: Option<f64>,
85    /// Live batch size. The fixed value in fixed mode, the controller's output in adaptive mode.
86    pub batch_size: u32,
87    /// Whether the batch size is under adaptive control.
88    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/// GPU-runner-specific commands, on top of the shared [`crate::cpu::sim_thread::SimCommand`].
102#[derive(Debug)]
103pub enum GpuCommand {
104    /// Set the fixed batch size, at least 1.
105    SetBatchSize(u32),
106    /// Switch adaptive batch sizing on or off.
107    SetAdaptive(bool),
108    /// Set the wall-clock budget of one adaptive batch in milliseconds, at least 0.1.
109    SetTargetMs(f64),
110}
111
112/// Initial adaptive-batching settings, so a freshly spawned thread starts in whatever mode the UI
113/// is already showing rather than snapping back to the default.
114#[derive(Clone, Copy, Debug)]
115pub struct GpuBatchSettings {
116    /// Whether the batch size is under adaptive control.
117    pub adaptive: bool,
118    /// Fixed batch size, used while `adaptive` is off.
119    pub batch_size: u32,
120    /// Wall-clock budget of one adaptive batch in milliseconds.
121    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
134/// Shortest time between two snapshots of a playing sim, whatever the batch size and the speed of the sim.
135const SNAPSHOT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16);
136
137/// Refresh cadence for wall-clock TPS and the GPU timestamp readback.
138const 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
150/// Time to leave an outstanding batch alone before looking again.
151///
152/// The loop waits this long only where the wait cannot block. See [`Loop::await_previous`].
153const OUTSTANDING_POLL: std::time::Duration = std::time::Duration::from_millis(1);
154
155enum Command {
156    Sim(SimCommand),
157    Gpu(GpuCommand),
158}
159
160/// The batch the GPU is working on.
161///
162/// Native waits on the submission itself, which is both the completion signal and the point the
163/// sample is taken. See [`Loop::await_previous`].
164#[cfg(not(target_arch = "wasm32"))]
165struct InFlight {
166    submission: wgpu::SubmissionIndex,
167    steps: u32,
168    started_at: Instant,
169}
170
171/// The batch the GPU is working on.
172///
173/// `on_submitted_work_done` is the only completion signal in a browser, and the callback stamps
174/// the duration itself. Nothing here can look sooner than the next frame, and measuring up to that
175/// point would put a frame on every batch however fast the GPU was, walking the batch size to 1.
176///
177/// Zero means still running, so a real duration is stored as microseconds plus one.
178#[cfg(target_arch = "wasm32")]
179struct InFlight {
180    elapsed_us: Arc<std::sync::atomic::AtomicU64>,
181    steps: u32,
182}
183
184/// Steps a GPU-resident state in batched submissions. [`Driver`] decides what drives it.
185struct 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    /// Whether the batch size is under adaptive control. The manual size is remembered while adaptive runs, and the
193    /// target survives switching back to fixed.
194    adaptive: bool,
195    /// Manual batch size, used verbatim when `adaptive` is false.
196    fixed_batch_size: u32,
197    /// Per-batch wall-clock budget in milliseconds, used by the controller when `adaptive`.
198    target_ms: f64,
199    /// Size of the next batch. `fixed_batch_size` when not adaptive, the controller's last output
200    /// when adaptive.
201    batch_size: u32,
202    /// EMA of measured wall-clock time per step, in milliseconds. `None` until the first batch has
203    /// been measured.
204    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    /// Number of snapshots published.
213    serial: u64,
214    /// Timestamp query resources, `None` where the device granted no `TIMESTAMP_QUERY`, as in every browser.
215    timestamp_query: Option<TimestampQuery>,
216    /// Actions recorded after the step that reaches their tick.
217    schedule: Schedule,
218    /// Highest tick whose actions have had their turn to fire. `None` until the first step or schedule.
219    fired_through: Option<u64>,
220    /// Tick a pending [`SimCommand::RunTo`] stops at.
221    run_to_target: Option<u64>,
222    /// Whether the last one-shot snapshot went out before its readback completed, with the previous readback's stats.
223    /// Only a browser sets it. A native one-shot snapshot waits for its readback.
224    late_stats: bool,
225}
226
227impl SimLoop for Loop {
228    type Command = Command;
229
230    /// Publishes before anything runs, so the viewport shows the seeded grid the moment the model is
231    /// loaded and does not stay blank until Play.
232    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            // These have no GPU analogue. The loop paces itself with the batch-size controller instead of
264            // a tick cap, and publishes on a wall-clock cadence. They are accepted, so a host can send the
265            // same `SimCommand` stream to either backend.
266            Command::Sim(
267                SimCommand::SetTargetTps(_)
268                | SimCommand::SetUncapped(_)
269                | SimCommand::SetTicksPerSnapshot(_)
270                // No GPU model has a layout.
271                | 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        // A device error raised by `submit` is stored in the sink rather than unwinding. The loop finds
318        // out about it here. Continuing to step would only add more errors.
319        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        // The tick of a one-shot snapshot is published again once its stats arrive, before a step moves past it.
332        // Otherwise the host keeps the previous readback's stats, zeros after a build, as that tick's row.
333        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    /// Returns a paused loop over `state` that publishes into `slot`.
355    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    /// Returns whether nothing is outstanding. False leaves the batch running, which only happens where
401    /// the wait cannot block.
402    ///
403    /// Waits on the submission itself, which is both the completion signal and the point the sample
404    /// is taken. The sample is one loop period, from the start of encoding a batch to the GPU
405    /// finishing it. Timing encode plus submit alone measures CPU dispatch cost, orders of magnitude too low.
406    #[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    /// Reads the duration the completion callback stamped, and reports the batch still running
420    /// until it completes. WebGPU's `Device::poll` returns immediately whatever the queue is doing, so
421    /// there is nothing here to wait on.
422    #[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    /// Folds a batch's measured cost into the EMA that the controller uses to size the next batch.
439    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    /// Remembers the batch just submitted, so the next pump can wait on it.
448    #[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    /// Remembers the batch just submitted. One completion callback covers every submission above.
458    #[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    /// Picks up a readback a one-shot snapshot could not wait for, and republishes once it completes.
472    ///
473    /// Only a browser needs it. `poll_stats_readback(block = true)` returns without waiting there, so the
474    /// snapshot goes out reporting whatever the previous readback left, zeroes after a build.
475    /// Nothing else looks again while paused, and the wake brings a frame-driven loop back.
476    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    /// Returns the number of steps from the state's tick to the next tick at which an action is due.
494    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    /// Refreshes the display texture and stats and publishes a snapshot right now.
500    ///
501    /// The loop calls it for one-shot updates, such as the first publish, a pause or a single step.
502    ///
503    /// The outstanding batch is dropped rather than timed. Its sample would cover the one-shot work
504    /// too, and on native the readback has drained the queue underneath it anyway.
505    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    /// Records and submits one batch of steps, plus the display, stats and timestamp-resolve work
520    /// at their own cadences, then updates published state.
521    ///
522    /// Only the first submission of a batch carries the timestamps, so the reported per-step time
523    /// divides by that chunk rather than by the batch.
524    ///
525    /// The timestamp resolve deliberately does not share a command buffer with the writes. See
526    /// `TimestampQuery::resolve_after`.
527    ///
528    /// A batch stops at a pending run-to target, and a submission ends at each tick an action is due at. The actions
529    /// follow in their own submissions, and the snapshot passes after them.
530    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        // The batch that reaches the target leaves the snapshot to the blocking snapshot that the next pump takes
543        // at the target.
544        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        // Every tick the batch passed has had its turn. A due action ended its chunk and fired above.
587        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        // The poll does not block. It picks up a readback started on an earlier pump if the GPU has
595        // caught up with it by now.
596        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            // No GPU timing on this device, so refresh wall-clock TPS on the same cadence anyway,
610            // to keep the UI updating.
611            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    /// Resets both clocks together. `want_timing` is gated on `last_stats_publish` but divides by
624    /// `tps_timer`, so resetting one without the other makes the next refresh divide a whole batch
625    /// by whatever tiny gap is between them.
626    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            // Leave the window open rather than reporting a rate over nothing. `step_count` keeps
635            // accumulating, so the next refresh covers this window and the next.
636            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            // The GPU path has no `prepare_view`.
648            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            // The GPU cost per step fills the field that holds CPU engine time for a CPU model. It is 0,
654            // meaning unknown, when the adapter has no timestamp support.
655            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        // After the lock, so waking the UI can never make it block on us.
661        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
677/// Records the actions of `schedule` due at the tick of `state`, each in its own submission as
678/// [`GpuSimState::encode_action`] requires, and returns the last of those submissions.
679fn 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
695/// Handle on a running GPU simulation, driven by the same commands as a
696/// [`SimThread`](crate::cpu::sim_thread::SimThread).
697pub 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    /// Starts a paused runner over `state` on `ctx`, in the batch mode that `settings` specifies.
711    ///
712    /// A panic out of the loop is stored in `ctx.faults`.
713    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        // The slot starts empty. `start` publishes the first snapshot after a blocking readback, and
721        // anything built here would report stats the GPU has not produced yet.
722        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    /// Sends a command to the loop.
748    pub fn send(&mut self, cmd: SimCommand) {
749        self.driver.send(Command::Sim(cmd));
750    }
751
752    /// Takes the latest snapshot, or `None` when nothing new has been published since the last take.
753    pub fn take_snapshot(&mut self) -> Option<Snapshot> {
754        crate::runner::take_snapshot(&self.slot)
755    }
756
757    /// Sends [`SimCommand::Play`].
758    pub fn play(&mut self) {
759        self.send(SimCommand::Play);
760    }
761
762    /// Sends [`SimCommand::Pause`].
763    pub fn pause(&mut self) {
764        self.send(SimCommand::Pause);
765    }
766
767    /// Sends [`SimCommand::StepOnce`].
768    pub fn step_once(&mut self) {
769        self.send(SimCommand::StepOnce);
770    }
771
772    /// Sends [`SimCommand::SetSchedule`].
773    pub fn set_schedule(&mut self, schedule: Schedule) {
774        self.send(SimCommand::SetSchedule(schedule));
775    }
776
777    /// Sends [`SimCommand::RunTo`].
778    pub fn run_to(&mut self, tick: u64) {
779        self.send(SimCommand::RunTo(tick));
780    }
781
782    /// Sets the manual batch size used in fixed mode. It has no visible effect while adaptive mode is
783    /// on, and is kept for when it is turned back off.
784    pub fn set_batch_size(&mut self, batch_size: u32) {
785        self.driver.send(Command::Gpu(GpuCommand::SetBatchSize(batch_size)));
786    }
787
788    /// Turns adaptive batching on or off. Fixed mode's manual batch size and adaptive mode's target
789    /// are each preserved independently across toggles.
790    pub fn set_adaptive(&mut self, enabled: bool) {
791        self.driver.send(Command::Gpu(GpuCommand::SetAdaptive(enabled)));
792    }
793
794    /// Sets the per-batch wall-clock budget (ms) used by the adaptive controller.
795    pub fn set_target_ms(&mut self, target_ms: f64) {
796        self.driver.send(Command::Gpu(GpuCommand::SetTargetMs(target_ms)));
797    }
798
799    /// Latest GPU-runner numbers.
800    pub fn gpu_stats(&self) -> GpuStats {
801        self.gpu_stats.lock().map(|s| *s).unwrap_or_default()
802    }
803
804    /// Advances the sim when the driver runs inside the frame loop, and does nothing when the driver has its own
805    /// thread.
806    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    /// Population arrives on the second poll rather than on the blocking one, as in a browser, where
833    /// `poll_blocking` is an ordinary poll and the map resolves a frame later.
834    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    /// As the web build shows it, the initial snapshot goes out before the readback completes. A paused
887    /// loop has to look again, or population and the stats panel read zero until the first Play.
888    #[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    /// Counts encoded steps as its tick, and completes each readback on the third poll, as a browser completes it a
914    /// frame late. Its one stat reads 1 once the first readback has completed.
915    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    /// As the web build shows it, a run-to sent straight after the build must not step past tick 0 before its
971    /// readback completes. Otherwise the host keeps the build's zeros as the row of tick 0.
972    #[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        // The host's history replaces the newest row with a publish at its tick.
1000        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    /// Counts encoded steps as its tick, and records the tick each action is encoded at.
1030    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    /// Returns a thread over an [`ActionRecorder`] state in batches of a fixed 100 steps, its tick and its action log.
1086    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    /// Takes snapshots until one reports `tick`, or gives up after five seconds, and returns it with the highest
1102    /// tick seen on the way.
1103    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    /// Waits long enough that nothing else can publish once the loop has paused.
1118    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        // Batches of 100 go out as submissions of 64 and 36, and the target falls inside the eleventh batch.
1130        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    /// A second schedule must not record the actions of the tick the loop sits at a second time.
1177    #[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        // Tick 150 ends a batch with nothing due, and has had its turn all the same.
1213        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}