Skip to main content

henad_explore/
benchmark.rs

1//! Benchmarks that time repetitions of one model's step loop, reporting each repetition as it finishes. Native only.
2//!
3//! Each repetition builds a fresh state, steps `warmup` ticks untimed, then times `steps` ticks of nothing but the
4//! model's step. A CPU repetition steps its warm-up inside one `rayon::scope` on the caller's pool, and its timed
5//! steps inside another. A GPU repetition waits for the uploads its build queued, then submits its steps in batches
6//! and times them up to the wait that drains the device.
7//!
8//! Actions fire under [`BENCH_FIRE`], before the step that leaves their tick. An action due from the end of warm-up
9//! up to, but not including, the tick a repetition stops on is timed with the steps, and one due on that tick fires
10//! after the timer stops.
11
12use std::time::{Duration, Instant};
13
14use henad_compute::entry::ModelState;
15use henad_compute::fault::{Fault, STEPPING, catching};
16use henad_compute::gpu::fault::catching_on;
17use henad_compute::gpu::{GpuContext, GpuSimState, stepping};
18use henad_compute::simulation::RunSetup;
19use henad_core::action::{Fire, RefusedActions};
20use henad_core::metadata::{Backend, Structure};
21use henad_core::model::SimState;
22use henad_core::params::{ParamDescriptor, ParamValue};
23
24/// Rule under which a repetition's warm-up and timed steps fire actions.
25pub const BENCH_FIRE: Fire = Fire::BeforeStep;
26
27/// Model, setup and lengths of one benchmark.
28#[derive(Debug, Clone)]
29#[non_exhaustive]
30pub struct BenchmarkSettings {
31    /// Values, base seed and scheduled actions of every repetition.
32    pub setup: RunSetup,
33    /// Timed steps per repetition.
34    pub steps: u64,
35    /// Untimed steps before each repetition's timed ones, on that repetition's own state.
36    ///
37    /// Note that a GPU repetition with no warm-up times wgpu's zero-fill of every buffer its build left unseeded,
38    /// which runs in the first submission that touches the buffer. A warm-up of one step keeps it out of the timed
39    /// steps.
40    pub warmup: u64,
41    /// Untimed steps of one throwaway state before the first repetition, to bring the hardware off its idle clocks.
42    pub global_warmup: u64,
43    /// Number of timed repetitions, each on a freshly built state.
44    pub repetitions: u64,
45}
46
47impl BenchmarkSettings {
48    /// Returns settings that time `steps` steps of `setup` once, with no warm-up.
49    pub fn new(setup: RunSetup, steps: u64) -> Self {
50        Self {
51            setup,
52            steps,
53            warmup: 0,
54            global_warmup: 0,
55            repetitions: 1,
56        }
57    }
58}
59
60/// One event of a running benchmark, in the order they happen.
61#[derive(Debug)]
62#[non_exhaustive]
63pub enum BenchmarkEvent<'a> {
64    /// Reported before any repetition, after a probe build of a CPU model. A GPU model is not probed.
65    Started {
66        /// Backend the model runs on.
67        backend: Backend,
68        /// Number of jobs one step of the probe build split into. `None` for a GPU model.
69        parallel_jobs: Option<usize>,
70    },
71    /// The global warm-up is about to build its state and run its steps.
72    GlobalWarmupStarted,
73    /// The global warm-up ran its steps in this time.
74    GlobalWarmupFinished(Duration),
75    /// The repetition at this position, from 0, is about to build its state, run its warm-up and time its steps.
76    RepetitionStarted(u64),
77    /// A repetition ended, its timed steps and the actions due on its last tick included.
78    RepetitionFinished(&'a RepetitionReport),
79}
80
81/// One timed repetition.
82#[derive(Debug, Clone)]
83#[non_exhaustive]
84pub struct RepetitionReport {
85    /// Position of the repetition, from 0.
86    pub index: u64,
87    /// Seed of the repetition's build. `None` for the model's default seed, as in [`RunSetup::seed`].
88    pub seed: Option<u64>,
89    /// Time the timed steps took.
90    pub elapsed: Duration,
91    /// Population once the warm-up ended, the denominator of updates per second. A grid model's population is its
92    /// cell count.
93    pub population_after_warmup: u64,
94    /// Population once the timed steps and the actions due on the last tick ended.
95    pub population_after_steps: u64,
96    /// Heap the state held after warm-up. `None` for a GPU model, whose state lives on the device.
97    pub heap_bytes: Option<usize>,
98}
99
100/// Timings of every repetition, with the job count and grid size of the model benchmarked.
101#[derive(Debug, Clone)]
102#[non_exhaustive]
103pub struct BenchmarkReport {
104    /// Every repetition, in the order they ran.
105    pub repetitions: Vec<RepetitionReport>,
106    /// Number of jobs one step of the probe build split into. `None` for a GPU model.
107    pub parallel_jobs: Option<usize>,
108    /// The live state's grid on the CPU. On the GPU, the values of a grid model's parameters named `grid_width` and
109    /// `grid_height`, the names Henad's models and the template use. `None` for a model without a grid, and for a GPU
110    /// grid model that specifies its size another way.
111    pub grid_size: Option<(u32, u32)>,
112}
113
114/// Times `settings.repetitions` runs of `settings.steps` steps, reporting each repetition as it finishes.
115///
116/// Repetition `i` builds with the seed `base + i`, wrapping, where `base` is the setup's seed. A setup on the
117/// default seed runs every repetition on the default seed. `on_event` runs on the calling thread between repetitions,
118/// outside the timed steps.
119///
120/// A tick-0 action fires once in each repetition, before its first step. Without the template's profile block, a
121/// debug build runs the kernels at opt-level 0 in the crate that registers the model, or in henad-models for an
122/// example entry, and only a `--release` build gives timings worth comparing.
123///
124/// # Errors
125///
126/// Returns a [`Fault`] when a build fails, the model panics, the device reports an error or the model rejects a
127/// scheduled action. A GPU model with no device fails its first build.
128pub fn run_benchmark(
129    settings: &BenchmarkSettings,
130    gpu: Option<&GpuContext>,
131    on_event: &mut dyn FnMut(BenchmarkEvent<'_>),
132) -> Result<BenchmarkReport, Fault> {
133    let setup = &settings.setup;
134    let entry = setup.entry();
135    let (backend, parallel_jobs) = match (entry.metadata().backend, gpu) {
136        // A GPU state reports no jobs. Its probe would hold a whole state on the device into the next build.
137        (Backend::Gpu, Some(_)) => (Backend::Gpu, None),
138        _ => match entry.build(setup.values(), setup.seed(), gpu)? {
139            ModelState::Gpu(_) => (Backend::Gpu, None),
140            ModelState::Cpu(state) => (Backend::Cpu, state.parallel_jobs()),
141        },
142    };
143    on_event(BenchmarkEvent::Started { backend, parallel_jobs });
144
145    if settings.global_warmup > 0 {
146        on_event(BenchmarkEvent::GlobalWarmupStarted);
147        let elapsed = match entry.build(setup.values(), setup.seed(), gpu)? {
148            ModelState::Cpu(mut state) => catching(STEPPING, || {
149                let start = Instant::now();
150                rayon::scope(|_| {
151                    for _ in 0..settings.global_warmup {
152                        state.step();
153                    }
154                });
155                start.elapsed()
156            })?,
157            ModelState::Gpu(mut state) => {
158                let ctx = device(gpu)?;
159                catching_on(ctx, STEPPING, || {
160                    let start = Instant::now();
161                    stepping::run_steps(&mut *state, ctx, settings.global_warmup)?;
162                    Ok(start.elapsed())
163                })??
164            }
165        };
166        on_event(BenchmarkEvent::GlobalWarmupFinished(elapsed));
167    }
168
169    // The list grows as repetitions finish. A count large enough to run until interrupted would abort a reservation
170    // up front.
171    let mut repetitions = Vec::new();
172    let mut grid_size = None;
173    for index in 0..settings.repetitions {
174        on_event(BenchmarkEvent::RepetitionStarted(index));
175        let seed = setup.seed().map(|seed| seed.wrapping_add(index));
176        let report = match entry.build(setup.values(), seed, gpu)? {
177            ModelState::Cpu(mut state) => {
178                let (report, grid) = catching(STEPPING, || cpu_repetition(&mut *state, settings, index, seed))??;
179                grid_size = grid;
180                report
181            }
182            ModelState::Gpu(mut state) => {
183                let ctx = device(gpu)?;
184                catching_on(ctx, STEPPING, || {
185                    gpu_repetition(&mut *state, ctx, settings, index, seed)
186                })??
187            }
188        };
189        on_event(BenchmarkEvent::RepetitionFinished(&report));
190        repetitions.push(report);
191    }
192    if matches!(entry.metadata().structure, Structure::GpuGrid { .. }) {
193        grid_size = grid_size_from_params(entry.param_descriptors(), setup.values());
194    }
195    Ok(BenchmarkReport {
196        repetitions,
197        parallel_jobs,
198        grid_size,
199    })
200}
201
202/// Returns the device a GPU state was built on.
203fn device(gpu: Option<&GpuContext>) -> Result<&GpuContext, Fault> {
204    gpu.ok_or_else(|| Fault::refused(STEPPING, "a GPU model was built with no device"))
205}
206
207/// Runs one CPU repetition on `state`, and returns its report and the grid the state holds after it.
208///
209/// Timing wraps only the step loop, so building and warm-up allocation stay out of the measured window.
210fn cpu_repetition(
211    state: &mut dyn SimState,
212    settings: &BenchmarkSettings,
213    index: u64,
214    seed: Option<u64>,
215) -> Result<(RepetitionReport, Option<(u32, u32)>), Fault> {
216    let schedule = settings.setup.schedule();
217    let mut refusals = Refusals::default();
218    // The steps run inside the pool. A caller outside it would otherwise inject every parallel pass a kernel runs, and
219    // park until it finishes. One inject per loop replaces one per pass per step.
220    rayon::scope(|_| {
221        for _ in 0..settings.warmup {
222            refusals.note(&schedule.run_due(state));
223            state.step();
224        }
225    });
226    // A grid model's population is its cell count, and an agent model's population is its agent count. Either way it
227    // is the right denominator for updates per second.
228    let population_after_warmup = state.population();
229    let heap_bytes = state.heap_bytes();
230    let grid = state.grid_view().map(|grid| (grid.width, grid.height));
231
232    let start = Instant::now();
233    rayon::scope(|_| {
234        for _ in 0..settings.steps {
235            refusals.note(&schedule.run_due(state));
236            state.step();
237        }
238    });
239    let elapsed = start.elapsed();
240    // This runs outside the timer, so an action on the last tick still fires without being measured.
241    refusals.note(&schedule.run_due(state));
242    refusals.check()?;
243    let report = RepetitionReport {
244        index,
245        seed,
246        elapsed,
247        population_after_warmup,
248        population_after_steps: state.population(),
249        heap_bytes: Some(heap_bytes),
250    };
251    Ok((report, grid))
252}
253
254/// Runs one GPU repetition on `state`, `warmup` untimed steps and then `steps` timed ones, both under [`BENCH_FIRE`].
255///
256/// An action due on the tick the repetition stops on fires after the timer stops, and is waited for there. Otherwise
257/// its work would run in the next repetition, inside the timer when that one has no warm-up, and a fault it raised
258/// would be reported late or not at all.
259fn gpu_repetition(
260    state: &mut dyn GpuSimState,
261    ctx: &GpuContext,
262    settings: &BenchmarkSettings,
263    index: u64,
264    seed: Option<u64>,
265) -> Result<RepetitionReport, Fault> {
266    let schedule = settings.setup.schedule();
267    let mut refusals = Refusals::default();
268    refusals.note(&stepping::run_steps_acting(
269        state,
270        ctx,
271        settings.warmup,
272        schedule,
273        BENCH_FIRE,
274    )?);
275    let population_after_warmup = state.population();
276    // A repetition with no warm-up would otherwise time the uploads its build queued.
277    ctx.queue.submit([]);
278    stepping::wait(ctx)?;
279
280    let start = Instant::now();
281    let refused = stepping::run_steps_acting(state, ctx, settings.steps, schedule, BENCH_FIRE)?;
282    let elapsed = start.elapsed();
283    refusals.note(&refused);
284    refusals.note(&stepping::run_due(state, ctx, schedule));
285    stepping::wait(ctx)?;
286    refusals.check()?;
287    Ok(RepetitionReport {
288        index,
289        seed,
290        elapsed,
291        population_after_warmup,
292        population_after_steps: state.population(),
293        heap_bytes: None,
294    })
295}
296
297/// First scheduled action that a state rejected, kept until the repetition ends.
298///
299/// A setup checks every action id before it stores an entry, and an engine rejects only an index past its model's
300/// actions. A rejection is therefore an engine contract violation.
301#[derive(Default)]
302struct Refusals {
303    first: Option<(String, u64)>,
304}
305
306impl Refusals {
307    #[inline]
308    fn note(&mut self, refused: &RefusedActions<'_>) {
309        if let Some(action) = refused.first()
310            && self.first.is_none()
311        {
312            self.first = Some((action.id.clone(), action.tick));
313        }
314    }
315
316    fn check(self) -> Result<(), Fault> {
317        match self.first {
318            None => Ok(()),
319            Some((id, tick)) => Err(Fault::refused(
320                STEPPING,
321                format!("the model refused its own action '{id}' at tick {tick}"),
322            )),
323        }
324    }
325}
326
327/// Returns the grid a GPU grid model was built with, read from its parameters named `grid_width` and `grid_height`.
328///
329/// A GPU state exposes no grid view. Nothing is prepended to a GPU model's parameters, and the two names are the
330/// convention Henad's models and the template follow. For a model that specifies its size another way, this returns
331/// `None`.
332fn grid_size_from_params(descriptors: &[ParamDescriptor], values: &[ParamValue]) -> Option<(u32, u32)> {
333    let find = |id: &str| -> Option<u32> {
334        let index = descriptors.iter().position(|descriptor| descriptor.id == id)?;
335        match values.get(index) {
336            Some(ParamValue::U32(value)) => Some(*value),
337            _ => None,
338        }
339    };
340    Some((find("grid_width")?, find("grid_height")?))
341}
342
343#[cfg(test)]
344mod tests {
345    use std::panic::AssertUnwindSafe;
346    use std::sync::atomic::{AtomicUsize, Ordering};
347    use std::sync::{Arc, Mutex};
348
349    use henad_compute::entry::{ModelState, register_grid_model};
350    use henad_compute::gpu::{GpuContext, stepping};
351    use henad_core::action::{ActionDescriptor, Fire, Schedule, Scheduled};
352    use henad_core::authoring::model::grid_model::GridModel;
353    use henad_core::grid::Grid2D;
354    use henad_core::metadata::Backend;
355    use henad_core::params::{ParamDescriptor, ParamValue};
356    use henad_core::topology::NeighborhoodKind;
357    use henad_core::view::{StatDescriptor, StatValue};
358
359    use super::{BENCH_FIRE, BenchmarkEvent, BenchmarkSettings, gpu_repetition, run_benchmark};
360    use crate::tests::support::{entry, headless_device};
361
362    /// Ticks at which [`Counter`]'s action saw the grid, in the order it fired.
363    static SEEN: Mutex<Vec<u64>> = Mutex::new(Vec::new());
364
365    /// Counts every cell up by one a step, so each cell holds the tick, and records that count when its action runs.
366    struct Counter;
367
368    impl GridModel for Counter {
369        const NAME: &'static str = "Counter";
370        const ID: &'static str = "counter";
371        const DESCRIPTION: &'static str = "A model that records the tick its action sees, registered only by tests";
372        const PALETTE: &'static [[u8; 4]] = &[[0, 0, 0, 0xFF]];
373        const NEIGHBORHOOD: NeighborhoodKind = NeighborhoodKind::Moore;
374        const STATS: &'static [StatDescriptor] = &[StatDescriptor::new("Count", [0xFF, 0xFF, 0xFF, 0xFF])];
375        const ACTIONS: &'static [ActionDescriptor] = &[ActionDescriptor::new("record", "Record")];
376        type Params = ();
377
378        fn param_descriptors() -> Vec<ParamDescriptor> {
379            Vec::new()
380        }
381
382        fn from_params(_params: &[ParamValue]) {}
383
384        fn init(grid: &mut Grid2D<u8>, _params: &[ParamValue], _rng: &mut u64) {
385            grid.current_mut().fill(0);
386        }
387
388        fn step_cell(cell: u8, _neighbors: &[u8], _params: &(), _rng: &mut u64) -> u8 {
389            cell.wrapping_add(1)
390        }
391
392        fn act(_action: usize, grid: &mut Grid2D<u8>, _params: &[ParamValue], _rng: &mut u64) {
393            SEEN.lock()
394                .expect("no test panicked holding it")
395                .push(u64::from(grid.current()[0]));
396        }
397
398        fn stats(grid: &Grid2D<u8>) -> Vec<StatValue> {
399            vec![StatValue::Scalar(f64::from(grid.current()[0]))]
400        }
401    }
402
403    /// Checks that each repetition fires each action once, before the step that leaves its tick, and the action on
404    /// the tick a repetition stops on after the timed steps. A tick-0 action fires once.
405    ///
406    /// The model records the tick each action sees, so no clock is read.
407    #[test]
408    fn a_benchmark_fires_each_action_before_its_step() {
409        let counter = register_grid_model::<Counter>();
410        let mut setup = counter
411            .setup()
412            .set("grid_width", 4u32)
413            .and_then(|setup| setup.set("grid_height", 4u32))
414            .expect("the grid fits")
415            .with_seed(40);
416        for tick in [0, 2, 3, 5, 9] {
417            setup = setup.act_at("record", tick).expect("the model declares it");
418        }
419        let mut settings = BenchmarkSettings::new(setup, 3);
420        settings.warmup = 2;
421        settings.global_warmup = 4;
422        settings.repetitions = 3;
423        SEEN.lock().expect("no test panicked holding it").clear();
424        let mut events = Vec::new();
425        let report = run_benchmark(&settings, None, &mut |event| {
426            events.push(match event {
427                BenchmarkEvent::Started { .. } => "started".to_owned(),
428                BenchmarkEvent::GlobalWarmupStarted => "warming up".to_owned(),
429                BenchmarkEvent::GlobalWarmupFinished(_) => "warmed up".to_owned(),
430                BenchmarkEvent::RepetitionStarted(index) => format!("rep {index} starts"),
431                BenchmarkEvent::RepetitionFinished(repetition) => format!("rep {}", repetition.index),
432            });
433        })
434        .expect("the benchmark runs");
435
436        let seen = SEEN.lock().expect("no test panicked holding it").clone();
437        assert_eq!(
438            seen,
439            [0, 2, 3, 5].repeat(3),
440            "warm-up fires 0, the timed steps 2 and 3, the end 5"
441        );
442        assert_eq!(
443            events,
444            [
445                "started",
446                "warming up",
447                "warmed up",
448                "rep 0 starts",
449                "rep 0",
450                "rep 1 starts",
451                "rep 1",
452                "rep 2 starts",
453                "rep 2"
454            ]
455        );
456        let seeds: Vec<Option<u64>> = report.repetitions.iter().map(|repetition| repetition.seed).collect();
457        assert_eq!(seeds, [Some(40), Some(41), Some(42)], "repetition i takes base + i");
458        assert_eq!(report.grid_size, Some((4, 4)));
459
460        let mut default_seed = BenchmarkSettings::new(counter.setup(), 1);
461        default_seed.repetitions = 2;
462        let report = run_benchmark(&default_seed, None, &mut |_| {}).expect("the benchmark runs");
463        assert!(report.repetitions.iter().all(|repetition| repetition.seed.is_none()));
464    }
465
466    /// Checks that a repetition count large enough to run until interrupted reports its first repetition. A
467    /// reservation for every report up front would abort the process before the first report.
468    #[test]
469    fn a_benchmark_of_endless_repetitions_reports_the_first() {
470        let setup = register_grid_model::<Counter>()
471            .setup()
472            .set("grid_width", 4u32)
473            .and_then(|setup| setup.set("grid_height", 4u32))
474            .expect("the grid fits");
475        let mut settings = BenchmarkSettings::new(setup, 1);
476        settings.repetitions = 1 << 56;
477        let mut reported = 0;
478        let interrupted = std::panic::catch_unwind(AssertUnwindSafe(|| {
479            run_benchmark(&settings, None, &mut |event| {
480                if let BenchmarkEvent::RepetitionFinished(_) = event {
481                    reported += 1;
482                    panic!("the host interrupts the benchmark");
483                }
484            })
485        }));
486        assert!(interrupted.is_err());
487        assert_eq!(reported, 1);
488    }
489
490    /// Checks that a GPU benchmark builds one state per repetition and no probe, and that a GPU model with no
491    /// device fails before it starts.
492    #[test]
493    fn a_gpu_benchmark_builds_no_probe() {
494        let Some(ctx) = headless_device() else {
495            return;
496        };
497        let builds = Arc::new(AtomicUsize::new(0));
498        let counted = Arc::clone(&builds);
499        let sir = entry("gpu_sir", Some(&ctx)).wrap_factory(move |create| {
500            Arc::new(
501                move |params: &[ParamValue], seed: Option<u64>, gpu: Option<&GpuContext>| {
502                    counted.fetch_add(1, Ordering::Relaxed);
503                    create(params, seed, gpu)
504                },
505            )
506        });
507        let setup = sir
508            .setup()
509            .set("grid_width", 32u32)
510            .and_then(|setup| setup.set("grid_height", 32u32))
511            .expect("the grid fits");
512        let mut settings = BenchmarkSettings::new(setup, 2);
513        settings.repetitions = 2;
514        let mut started = Vec::new();
515        let mut on_event = |event: BenchmarkEvent<'_>| {
516            if let BenchmarkEvent::Started { backend, parallel_jobs } = event {
517                started.push((backend, parallel_jobs));
518            }
519        };
520        let report = run_benchmark(&settings, Some(&ctx), &mut on_event).expect("the benchmark runs");
521        assert_eq!(builds.load(Ordering::Relaxed), 2, "one build per repetition");
522        assert_eq!(report.grid_size, Some((32, 32)));
523        assert!(run_benchmark(&settings, None, &mut on_event).is_err());
524        assert_eq!(started, [(Backend::Gpu, None)], "the run with no device never starts");
525    }
526
527    fn schedule_at(ticks: &[u64]) -> Schedule {
528        let entries = ticks
529            .iter()
530            .map(|&tick| Scheduled {
531                index: 0,
532                id: "act".to_owned(),
533                tick,
534            })
535            .collect();
536        Schedule::from_entries(entries)
537    }
538
539    /// Returns the ticks each run fires when runs of `counts` steps go back to back from tick 0.
540    fn fire_runs(schedule: &Schedule, counts: &[u64], fire: Fire) -> Vec<Vec<u64>> {
541        let mut start = 0;
542        counts
543            .iter()
544            .map(|&count| {
545                let fired = schedule.fire_ticks(start, count, fire);
546                start += count;
547                fired
548            })
549            .collect()
550    }
551
552    /// Checks that back-to-back runs under [`BENCH_FIRE`] fire the ticks the CPU loop fires, leaving the end tick to
553    /// the caller.
554    ///
555    /// A GPU repetition is a warm-up run and a timed run under that rule, and fires the tick it stops on after the
556    /// timer. The test reads the rule alone, and times nothing.
557    #[test]
558    fn a_timed_run_fires_the_ticks_the_cpu_loop_times() {
559        let schedule = schedule_at(&(0..=8).collect::<Vec<u64>>());
560        for warmup in 0..4 {
561            for steps in 0..4 {
562                let runs = fire_runs(&schedule, &[warmup, steps], BENCH_FIRE);
563                let expected: [Vec<u64>; 2] = [(0..warmup).collect(), (warmup..warmup + steps).collect()];
564                assert_eq!(runs, expected, "--warmup {warmup} --steps {steps}");
565            }
566        }
567    }
568
569    /// Checks that a GPU repetition fires each action once and on its own tick, the tick it stops on included.
570    ///
571    /// Its counts have to match a simulation stepped over the same ticks. `seed_outbreak` infects a share of the
572    /// cells still susceptible, so a press that is missed, repeated or moved to another tick changes the counts.
573    #[test]
574    fn a_gpu_benchmark_repetition_fires_every_action_once() {
575        let Some(ctx) = headless_device() else {
576            return;
577        };
578        let sir = entry("gpu_sir", Some(&ctx));
579        let mut setup = sir
580            .setup()
581            .set("grid_width", 64u32)
582            .and_then(|setup| setup.set("grid_height", 64u32))
583            .expect("the grid fits")
584            .with_seed(1);
585        for tick in [0, 2, 3, 5] {
586            setup = setup.act_at("seed_outbreak", tick).expect("gpu_sir declares it");
587        }
588        let counts = |entries: &[henad_core::view::StatEntry]| -> Vec<f64> {
589            entries.iter().map(|entry| entry.value.scalar()).collect()
590        };
591        for (warmup, steps) in [(0, 0), (0, 3), (2, 0), (2, 3)] {
592            let mut settings = BenchmarkSettings::new(setup.clone(), steps);
593            settings.warmup = warmup;
594            let Ok(ModelState::Gpu(mut state)) = sir.build(setup.values(), setup.seed(), Some(&ctx)) else {
595                panic!("gpu_sir builds as a GPU model");
596            };
597            gpu_repetition(&mut *state, &ctx, &settings, 0, setup.seed()).expect("a repetition");
598            let after_repetition = counts(&stepping::sample_stats(&mut *state, &ctx).expect("a sample"));
599            let mut simulation = setup.build(Some(&ctx)).expect("gpu_sir builds");
600            simulation.run_to(warmup + steps).expect("gpu_sir steps");
601            let stepped = counts(simulation.stats().expect("a sample").entries());
602            assert_eq!(after_repetition, stepped, "--warmup {warmup} --steps {steps}");
603        }
604    }
605}