Skip to main content

henad_explore/
cursor.rs

1//! Run cursors, each holding one CPU run of a sweep from its build to its [`RunOutcome`].
2//!
3//! A cursor steps its run a slice at a time, fires the run's actions and samples it at every tick its
4//! [`MeasurePlan`] specifies. The CPU executors drive their runs through cursors. A GPU run steps on a track of
5//! `exec::gpu` instead, and both call `run_outcome` to end a run.
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use web_time::Instant;
11
12use henad_compute::entry::{ModelEntry, ModelState};
13use henad_compute::fault::{BUILDING, Fault, FaultKind, STEPPING, catching};
14use henad_core::action::{RefusedActions, Schedule, Scheduled};
15use henad_core::explore::measure::{MeasurePlan, Sampler};
16use henad_core::explore::outcome::{PlannedRun, RunOutcome, RunStatus, StopReason};
17use henad_core::export::StatsWriteError;
18use henad_core::model::SimState;
19
20use crate::exec::RunRequest;
21
22/// Reason a cursor rejects a GPU model without building it.
23#[cfg(not(target_arch = "wasm32"))]
24const GPU_REFUSAL: &str = "a GPU model cannot run on a CPU lane";
25
26/// Reason a cursor rejects a GPU model without building it. A browser cannot block on the device.
27#[cfg(target_arch = "wasm32")]
28const GPU_REFUSAL: &str = "a GPU sweep needs a native build";
29
30/// Result of one [`RunCursor::advance`].
31#[derive(Debug)]
32pub enum CursorState {
33    /// The run has ticks left to step.
34    Running,
35    /// The run has ended, at its final tick, on its stop condition, on a fault or past its timeout.
36    Finished(RunOutcome),
37}
38
39/// One CPU run of a sweep, built and stepped a slice at a time.
40///
41/// A run steps from tick 0 to the plan's total, unless its stop condition holds at a sample first. The actions due
42/// at tick 0 fire before the first step, and those due at a later tick after the step that reaches it.
43pub struct RunCursor {
44    run: PlannedRun,
45    run_key: u64,
46    phase: Phase,
47}
48
49/// Prints the run, and leaves out the live simulation.
50impl std::fmt::Debug for RunCursor {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("RunCursor")
53            .field("run", &self.run)
54            .field("run_key", &self.run_key)
55            .finish_non_exhaustive()
56    }
57}
58
59enum Phase {
60    Live(Box<LiveRun>),
61    /// A run whose build failed, holding the outcome to return.
62    BuildFailed(Box<RunOutcome>),
63    /// A run whose outcome was returned.
64    Spent,
65}
66
67/// A built model and the ticks its run has reached.
68struct LiveRun {
69    state: Box<dyn SimState>,
70    timeline: Timeline,
71    schedule: Schedule,
72    build_ms: f64,
73    /// Time spent stepping and sampling.
74    wall: Duration,
75    /// Time spent stepping and sampling after which the run is abandoned, checked between slices.
76    timeout: Option<Duration>,
77}
78
79/// Ticks a run has reached, and the samples taken on the way.
80struct Timeline {
81    sampler: Sampler,
82    /// Tick the model has reached.
83    tick: u64,
84    /// Next tick to sample, `None` once the run has taken its last sample.
85    next_sample: Option<u64>,
86    /// Population at the latest sample.
87    population: u64,
88    /// Whether the actions due at tick 0 have fired.
89    fired_start_actions: bool,
90    /// One note per action the model rejected, in the order they were due.
91    refusals: Vec<String>,
92}
93
94/// Status and note of a run that ended on a fault.
95#[derive(Debug, Clone)]
96pub(crate) struct RunFailure {
97    pub(crate) status: RunStatus,
98    pub(crate) note: String,
99}
100
101impl From<Fault> for RunFailure {
102    fn from(fault: Fault) -> Self {
103        let status = match fault.kind {
104            FaultKind::Panic { .. } => RunStatus::Panicked,
105            FaultKind::Refused(_) => RunStatus::Refused,
106            // Any other fault is a device error, a failed wait or a lost device.
107            _ => RunStatus::GpuError,
108        };
109        Self {
110            status,
111            note: fault.to_string(),
112        }
113    }
114}
115
116impl From<StatsWriteError> for RunFailure {
117    fn from(error: StatsWriteError) -> Self {
118        Self {
119            status: RunStatus::ShapeError,
120            note: error.to_string(),
121        }
122    }
123}
124
125/// End of a run whose model was built.
126pub(crate) enum RunEnd {
127    /// The run ended after its last sample, with the reason it stopped.
128    Stopped(StopReason),
129    Failed(RunFailure),
130    /// The run passed its timeout with ticks left.
131    TimedOut,
132}
133
134/// Parts of a [`RunOutcome`] gathered while its run stepped.
135pub(crate) struct OutcomeParts {
136    pub(crate) sampler: Sampler,
137    /// Tick the run ended on.
138    pub(crate) ticks: u64,
139    /// Population at the latest sample.
140    pub(crate) population: u64,
141    pub(crate) build_ms: f64,
142    /// Time the run was live, builds and pauses left out. A GPU track counts an even share of each round among the
143    /// live tracks.
144    pub(crate) wall: Duration,
145    pub(crate) timeout: Option<Duration>,
146    /// One note per action the model rejected, in the order they were due.
147    pub(crate) refusals: Vec<String>,
148}
149
150/// Returns the outcome of `run` once it ends as `end`.
151///
152/// A run that reached its end with a non-finite sample is `non_finite`. Notes for rejected actions come
153/// before the note for the end.
154pub(crate) fn run_outcome(run: PlannedRun, run_key: u64, end: RunEnd, parts: OutcomeParts) -> RunOutcome {
155    let ticks = parts.ticks;
156    let measured = parts.sampler.finish();
157    let (status, stop_reason, note) = match (end, measured.non_finite) {
158        (RunEnd::Failed(failure), _) => (failure.status, StopReason::Fault, Some(failure.note)),
159        (RunEnd::TimedOut, _) => {
160            let seconds = parts.timeout.unwrap_or_default().as_secs_f64();
161            let note = format!("timed out after {seconds} s at tick {ticks}");
162            (RunStatus::TimedOut, StopReason::Timeout, Some(note))
163        }
164        (RunEnd::Stopped(reason), Some(sample)) => (RunStatus::NonFinite, reason, Some(sample.to_string())),
165        (RunEnd::Stopped(reason), None) => (RunStatus::Ok, reason, None),
166    };
167    let mut notes = parts.refusals;
168    notes.extend(note);
169    RunOutcome {
170        run,
171        run_key,
172        status,
173        stop_reason,
174        ticks,
175        population: parts.population,
176        build_ms: parts.build_ms,
177        wall_ms: milliseconds(parts.wall),
178        reducers: measured.reducers,
179        series: measured.series,
180        note: (!notes.is_empty()).then(|| notes.join("; ")),
181    }
182}
183
184/// Returns the outcome of `run`, whose build failed with `failure` after `build_ms` milliseconds.
185pub(crate) fn failed_build_outcome(
186    run: PlannedRun,
187    run_key: u64,
188    measure: &Arc<MeasurePlan>,
189    build_ms: f64,
190    failure: RunFailure,
191) -> RunOutcome {
192    let measured = Sampler::new(Arc::clone(measure)).finish();
193    RunOutcome {
194        run,
195        run_key,
196        status: failure.status,
197        stop_reason: StopReason::Fault,
198        ticks: 0,
199        population: 0,
200        build_ms,
201        wall_ms: 0.0,
202        reducers: measured.reducers,
203        series: measured.series,
204        note: Some(failure.note),
205    }
206}
207
208/// Returns the note for `action`, which the model rejected.
209pub(crate) fn refusal_note(action: &Scheduled) -> String {
210    format!("model refused action '{}' at tick {}", action.id, action.tick)
211}
212
213impl RunCursor {
214    /// Builds the model of `request` with the request's seed, and times the build.
215    ///
216    /// A run ends with the slice in which its stepping and sampling pass `timeout`. A cursor whose build failed
217    /// finishes on its first [`Self::advance`], with the failure as its status. A GPU model is rejected.
218    pub fn new(
219        entry: &ModelEntry,
220        measure: &Arc<MeasurePlan>,
221        request: &RunRequest<'_>,
222        timeout: Option<Duration>,
223    ) -> Self {
224        let started = Instant::now();
225        let built = if entry.gpu_needs().is_some() {
226            Err(Fault::refused(BUILDING, GPU_REFUSAL))
227        } else {
228            entry
229                .build(request.params, Some(request.run.seed), None)
230                .and_then(cpu_state)
231        };
232        let build_ms = milliseconds(started.elapsed());
233        let phase = match built {
234            Ok(state) => Phase::Live(Box::new(LiveRun {
235                state,
236                timeline: Timeline {
237                    sampler: Sampler::new(Arc::clone(measure)),
238                    tick: 0,
239                    next_sample: Some(measure.first_sample()),
240                    population: 0,
241                    fired_start_actions: false,
242                    refusals: Vec::new(),
243                },
244                schedule: request.schedule.clone(),
245                build_ms,
246                wall: Duration::ZERO,
247                timeout,
248            })),
249            Err(fault) => Phase::BuildFailed(Box::new(failed_build_outcome(
250                request.run,
251                request.run_key,
252                measure,
253                build_ms,
254                fault.into(),
255            ))),
256        };
257        Self {
258            run: request.run,
259            run_key: request.run_key,
260            phase,
261        }
262    }
263
264    /// Tick the run has reached, 0 for a run whose build failed.
265    pub fn tick(&self) -> u64 {
266        match &self.phase {
267            Phase::Live(live) => live.timeline.tick,
268            Phase::BuildFailed(_) | Phase::Spent => 0,
269        }
270    }
271
272    /// Steps the run by at most `max_steps` steps, firing the actions and taking the samples due on the way.
273    ///
274    /// A sample due at the tick the run has reached is taken even when `max_steps` is 0. A fault ends the run, and
275    /// its outcome keeps the samples taken before it.
276    ///
277    /// # Panics
278    ///
279    /// Panics when called again after it returned [`CursorState::Finished`].
280    pub fn advance(&mut self, max_steps: u64) -> CursorState {
281        match std::mem::replace(&mut self.phase, Phase::Spent) {
282            Phase::Live(mut live) => match live.advance(max_steps) {
283                None => {
284                    self.phase = Phase::Live(live);
285                    CursorState::Running
286                }
287                Some(end) => CursorState::Finished(live.finish(self.run, self.run_key, end)),
288            },
289            Phase::BuildFailed(outcome) => CursorState::Finished(*outcome),
290            Phase::Spent => panic!("run {} already finished", self.run.run_id),
291        }
292    }
293}
294
295impl LiveRun {
296    /// Steps by at most `max_steps` steps, and returns the end of the run once the run ends.
297    fn advance(&mut self, max_steps: u64) -> Option<RunEnd> {
298        let started = Instant::now();
299        let (timeline, schedule, state) = (&mut self.timeline, &self.schedule, &mut self.state);
300        let driven = catching(STEPPING, || timeline.drive(&mut **state, schedule, max_steps));
301        self.wall += started.elapsed();
302        match driven {
303            Ok(Ok(None)) if self.timeout.is_some_and(|timeout| self.wall >= timeout) => Some(RunEnd::TimedOut),
304            Ok(Ok(None)) => None,
305            Ok(Ok(Some(reason))) => Some(RunEnd::Stopped(reason)),
306            Ok(Err(failure)) => Some(RunEnd::Failed(failure)),
307            Err(fault) => Some(RunEnd::Failed(fault.into())),
308        }
309    }
310
311    fn finish(self, run: PlannedRun, run_key: u64, end: RunEnd) -> RunOutcome {
312        let ticks = match &end {
313            RunEnd::Stopped(_) | RunEnd::TimedOut => self.timeline.tick,
314            RunEnd::Failed(_) => self.state.tick(),
315        };
316        let parts = OutcomeParts {
317            sampler: self.timeline.sampler,
318            ticks,
319            population: self.timeline.population,
320            build_ms: self.build_ms,
321            wall: self.wall,
322            timeout: self.timeout,
323            refusals: self.timeline.refusals,
324        };
325        run_outcome(run, run_key, end, parts)
326    }
327}
328
329impl Timeline {
330    /// Steps, fires and samples until `max_steps` steps are spent or the run takes its last sample.
331    ///
332    /// Returns the reason the run ended, or `None` while it has ticks left.
333    fn drive(
334        &mut self,
335        state: &mut dyn SimState,
336        schedule: &Schedule,
337        max_steps: u64,
338    ) -> Result<Option<StopReason>, RunFailure> {
339        if !self.fired_start_actions {
340            self.fired_start_actions = true;
341            note_refused(schedule.run_due(state), &mut self.refusals);
342        }
343        let mut budget = max_steps;
344        loop {
345            let Some(next_sample) = self.next_sample else {
346                return Ok(Some(StopReason::Steps));
347            };
348            if self.tick == next_sample {
349                // The view is prepared first, as a publish does. A stat that walks the graph is computed there.
350                state.prepare_view();
351                let stats = state.stats();
352                self.population = state.population();
353                let stops = self.sampler.push(self.tick, &stats)?;
354                if stops {
355                    self.next_sample = None;
356                    return Ok(Some(StopReason::Condition));
357                }
358                self.next_sample = self.sampler.plan().next_sample(self.tick);
359            } else if budget == 0 {
360                return Ok(None);
361            } else {
362                let count = (next_sample - self.tick).min(budget);
363                step_by(state, count, schedule, &mut self.refusals);
364                self.tick += count;
365                budget -= count;
366            }
367        }
368    }
369}
370
371/// Steps `state` by `count` ticks, firing the actions due after each step, and adds a note to `refusals` for each
372/// action that the model rejects.
373fn step_by(state: &mut dyn SimState, count: u64, schedule: &Schedule, refusals: &mut Vec<String>) {
374    if schedule.is_empty() {
375        for _ in 0..count {
376            state.step();
377        }
378    } else {
379        for _ in 0..count {
380            state.step();
381            note_refused(schedule.run_due(state), refusals);
382        }
383    }
384}
385
386/// Adds a note to `refusals` for each action in `refused`.
387fn note_refused(refused: RefusedActions<'_>, refusals: &mut Vec<String>) {
388    refusals.extend(refused.into_iter().map(refusal_note));
389}
390
391/// Returns the CPU state of `state`, rejecting a GPU model.
392fn cpu_state(state: ModelState) -> Result<Box<dyn SimState>, Fault> {
393    match state {
394        ModelState::Cpu(state) => Ok(state),
395        ModelState::Gpu(_) => Err(Fault::refused(BUILDING, GPU_REFUSAL)),
396    }
397}
398
399pub(crate) fn milliseconds(duration: Duration) -> f64 {
400    duration.as_secs_f64() * 1000.0
401}
402
403#[cfg(test)]
404mod tests {
405    use std::sync::Arc;
406
407    use henad_compute::entry::{ModelEntry, ModelState, register_grid_model};
408    use henad_compute::fault::install_panic_hook;
409    use henad_core::action::Schedule;
410    use henad_core::explore::measure::MeasurePlan;
411    use henad_core::explore::outcome::{PlannedRun, RunStatus, StopReason};
412    use henad_core::explore::spec::{MeasureSettings, RunSettings};
413    use henad_core::export::StatColumns;
414    use henad_core::model::SimState;
415    use henad_core::params::ParamValue;
416    use henad_models::example_models;
417
418    use super::{CursorState, RunCursor};
419    use crate::exec::RunRequest;
420    use crate::tests::broken::DividesByParam;
421
422    const SEED: u64 = 11;
423
424    fn entry(id: &str) -> ModelEntry {
425        example_models().get(id).cloned().expect("the model is registered")
426    }
427
428    fn cpu_state(entry: &ModelEntry, params: &[ParamValue]) -> Box<dyn SimState> {
429        match entry.build(params, Some(SEED), None) {
430            Ok(ModelState::Cpu(state)) => state,
431            _ => panic!("{} builds on the CPU", entry.id()),
432        }
433    }
434
435    fn params(entry: &ModelEntry, width: u32) -> Vec<ParamValue> {
436        let mut params: Vec<ParamValue> = entry
437            .param_descriptors()
438            .iter()
439            .map(|descriptor| descriptor.kind.default_value())
440            .collect();
441        params[0] = ParamValue::U32(width);
442        params[1] = ParamValue::U32(width);
443        params
444    }
445
446    fn measure(entry: &ModelEntry, params: &[ParamValue], warmup: u64, steps: u64, stats_every: u64) -> MeasurePlan {
447        let mut probe = cpu_state(entry, params);
448        probe.prepare_view();
449        let run = RunSettings {
450            steps,
451            warmup,
452            ..RunSettings::default()
453        };
454        let settings = MeasureSettings {
455            stats_every,
456            series_every: stats_every,
457            ..MeasureSettings::default()
458        };
459        MeasurePlan::new(&run, &settings, StatColumns::plan(&probe.stats())).expect("the columns bind")
460    }
461
462    fn request(params: &[ParamValue]) -> RunRequest<'_> {
463        RunRequest {
464            run: PlannedRun {
465                run_id: 3,
466                config_id: 1,
467                rep: 0,
468                seed: SEED,
469            },
470            run_key: 99,
471            params,
472            schedule: Schedule::from_entries(Vec::new()),
473        }
474    }
475
476    /// Advances `cursor` in slices of `slice` steps until it finishes.
477    fn drive(cursor: &mut RunCursor, slice: u64) -> henad_core::explore::outcome::RunOutcome {
478        loop {
479            if let CursorState::Finished(outcome) = cursor.advance(slice) {
480                return outcome;
481            }
482        }
483    }
484
485    #[test]
486    fn a_cursor_samples_what_a_hand_stepped_run_gives() {
487        let entry = entry("sir");
488        let params = params(&entry, 24);
489        let plan = Arc::new(measure(&entry, &params, 2, 20, 6));
490        let mut cursor = RunCursor::new(&entry, &plan, &request(&params), None);
491        let outcome = drive(&mut cursor, 5);
492        assert_eq!(outcome.status, RunStatus::Ok);
493        assert_eq!(outcome.stop_reason, StopReason::Steps);
494        assert_eq!((outcome.ticks, outcome.run.run_id, outcome.run_key), (22, 3, 99));
495        assert_eq!(outcome.series.ticks(), [2, 8, 14, 20, 22]);
496
497        let mut state = cpu_state(&entry, &params);
498        let mut row = Vec::new();
499        for (i, &tick) in outcome.series.ticks().iter().enumerate() {
500            while state.tick() < tick {
501                state.step();
502            }
503            state.prepare_view();
504            plan.columns()
505                .extract(tick, &state.stats(), &mut row)
506                .expect("the layout holds");
507            assert_eq!(outcome.series.row(i), row.as_slice(), "tick {tick}");
508        }
509        assert_eq!(outcome.population, state.population());
510    }
511
512    #[test]
513    fn the_slice_size_does_not_change_the_outcome() {
514        let entry = entry("game_of_life");
515        let params = params(&entry, 16);
516        let plan = Arc::new(measure(&entry, &params, 0, 30, 4));
517        let outcomes: Vec<_> = [1, 3, 1000]
518            .into_iter()
519            .map(|slice| {
520                let mut cursor = RunCursor::new(&entry, &plan, &request(&params), None);
521                let outcome = drive(&mut cursor, slice);
522                (outcome.reducers, outcome.series, outcome.ticks)
523            })
524            .collect();
525        assert_eq!(outcomes[0], outcomes[1]);
526        assert_eq!(outcomes[0], outcomes[2]);
527    }
528
529    #[test]
530    fn a_failed_build_finishes_on_the_first_advance() {
531        install_panic_hook();
532        let entry = register_grid_model::<DividesByParam>();
533        let good = params(&entry, 8);
534        let plan = Arc::new(measure(&entry, &good, 0, 10, 5));
535        let mut broken = good.clone();
536        broken[3] = ParamValue::U32(0);
537        let mut cursor = RunCursor::new(&entry, &plan, &request(&broken), None);
538        let CursorState::Finished(outcome) = cursor.advance(0) else {
539            panic!("a failed build has nothing to step");
540        };
541        assert_eq!(outcome.status, RunStatus::Panicked);
542        assert_eq!((outcome.stop_reason, outcome.ticks), (StopReason::Fault, 0));
543        assert!(outcome.series.is_empty());
544        assert!(outcome.reducers.iter().all(Option::is_none));
545        let note = outcome.note.unwrap_or_default();
546        assert!(note.starts_with("while building the model"), "{note}");
547        assert!(note.contains("broken.rs:"), "{note}");
548    }
549
550    #[test]
551    fn a_panic_mid_run_keeps_the_samples_before_it() {
552        install_panic_hook();
553        let entry = register_grid_model::<DividesByParam>();
554        let good = params(&entry, 8);
555        let plan = Arc::new(measure(&entry, &good, 0, 10, 5));
556        let mut broken = good.clone();
557        broken[2] = ParamValue::U32(0);
558        let mut cursor = RunCursor::new(&entry, &plan, &request(&broken), None);
559        let outcome = drive(&mut cursor, 100);
560        assert_eq!(outcome.status, RunStatus::Panicked);
561        assert_eq!((outcome.stop_reason, outcome.ticks), (StopReason::Fault, 0));
562        assert_eq!(
563            outcome.series.ticks(),
564            [0],
565            "the sample at tick 0 came before the first step"
566        );
567        assert_eq!(outcome.reducers[0], Some(1.0), "Cells:final");
568        let note = outcome.note.unwrap_or_default();
569        assert!(note.starts_with("while stepping the simulation"), "{note}");
570        assert!(note.contains("broken.rs:"), "{note}");
571
572        let mut cursor = RunCursor::new(&entry, &plan, &request(&good), None);
573        assert_eq!(
574            drive(&mut cursor, 100).status,
575            RunStatus::Ok,
576            "a divisor of 1 runs clean"
577        );
578    }
579}