metrale_bench/
executor.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: [`drive`], the loop that turns a benchmark into a stream of
4//! frames, and [`BenchmarkExecutor`], which runs a benchmark's lifecycle as a
5//! task on a given tokio runtime.
6//!
7//! The run's frames and plugin events cross to the caller over
8//! `std::sync::mpsc` channels, which [`RunHandle::drain`] reads without
9//! blocking, so a render loop never awaits the run.
10//!
11//! Owner: bench.
12//! Invariants:
13//! - [`drive`] yields nothing after the first terminal frame or the first
14//!   error.
15//! - Every return path of a run's task calls the benchmark's `cleanup()` once
16//!   and then sets [`RunHandle::is_finished`].
17
18use std::sync::Arc;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::mpsc::{Receiver, Sender, channel};
21use std::time::{Duration, Instant};
22
23use anyhow::Result;
24use futures::{Stream, StreamExt};
25
26use crate::artifacts::ArtifactStore;
27use crate::benchmark::BenchmarkDescriptor;
28use crate::coherence::CoherencePolicy;
29use crate::dynamic::DynBenchmark;
30use crate::hardware::policy;
31use crate::hardware::report::HardwareStateReport;
32use crate::hardware::state::HardwareState;
33use crate::params::ParamValues;
34use crate::plugin::{PluginEvent, PluginHandle, TargetEndpoint};
35use crate::result::{BenchmarkResult, LogLine, RunStatus};
36
37/// 2026-09-26: Drive `bench` by calling `next()` until it reports a terminal
38/// status or errors. `Benchmark::run`'s default body and the executor both use
39/// it.
40pub fn drive(bench: &mut dyn DynBenchmark) -> impl Stream<Item = Result<BenchmarkResult>> + '_ {
41    futures::stream::unfold(Some(bench), |state| async move {
42        let bench = state?;
43        match bench.next().await {
44            Ok(frame) => {
45                let finished = frame.status.is_terminal();
46                Some((Ok(frame), if finished { None } else { Some(bench) }))
47            }
48            // 2026-09-26: An `Err` ends the stream.
49            Err(e) => Some((Err(e), None)),
50        }
51    })
52}
53
54/// 2026-09-26: One message drained from a run. `Frame` is boxed so that an
55/// `Event` does not carry the size of a [`BenchmarkResult`].
56pub enum ExecutorMessage {
57    Event(PluginEvent),
58    Frame(Box<BenchmarkResult>),
59}
60
61/// 2026-09-26: The caller's control surface for one in-flight run.
62pub struct RunHandle {
63    events: Receiver<PluginEvent>,
64    frames: Receiver<BenchmarkResult>,
65    cancel: Arc<AtomicBool>,
66    finished: Arc<AtomicBool>,
67}
68
69impl RunHandle {
70    /// 2026-09-26: Non-blocking drain of everything queued: all events first,
71    /// then all frames.
72    pub fn drain(&self) -> Vec<ExecutorMessage> {
73        let mut out: Vec<ExecutorMessage> =
74            self.events.try_iter().map(ExecutorMessage::Event).collect();
75        out.extend(
76            self.frames
77                .try_iter()
78                .map(|f| ExecutorMessage::Frame(Box::new(f))),
79        );
80        out
81    }
82
83    /// 2026-09-26: Ask the run to stop. The benchmark can observe this through
84    /// `PluginHandle::check_cancelled`, and the executor checks it after each
85    /// frame and then ends the run with a "cancelled" frame.
86    pub fn cancel(&self) {
87        self.cancel.store(true, Ordering::Relaxed);
88    }
89
90    pub fn is_cancelled(&self) -> bool {
91        self.cancel.load(Ordering::Relaxed)
92    }
93
94    /// 2026-09-26: True once the run's `cleanup()` has returned.
95    pub fn is_finished(&self) -> bool {
96        self.finished.load(Ordering::Relaxed)
97    }
98}
99
100/// 2026-09-26: Starts benchmark runs on an existing tokio runtime.
101#[derive(Clone)]
102pub struct BenchmarkExecutor {
103    runtime: tokio::runtime::Handle,
104    artifacts: ArtifactStore,
105    /// 2026-09-26: The next run id for `PluginHandle`, counted from 1 and
106    /// shared by every clone of this executor, so ids are unique among the
107    /// runs one executor starts. See `PluginHandle::run_id`.
108    next_run_id: Arc<std::sync::atomic::AtomicU64>,
109}
110
111impl BenchmarkExecutor {
112    pub fn new(runtime: tokio::runtime::Handle, artifacts: ArtifactStore) -> Self {
113        Self {
114            runtime,
115            artifacts,
116            next_run_id: Arc::new(std::sync::atomic::AtomicU64::new(1)),
117        }
118    }
119
120    /// 2026-09-26: The runtime this executor spawns runs on, for callers that
121    /// need to run a short async check on the same one.
122    pub fn runtime(&self) -> &tokio::runtime::Handle {
123        &self.runtime
124    }
125
126    pub fn artifacts(&self) -> &ArtifactStore {
127        &self.artifacts
128    }
129
130    /// 2026-09-26: Spawn the run: hardware precheck, coherence probe, build,
131    /// load, configure, drive, clean up. `cleanup()` runs on every return path
132    /// of the task, including a refused precheck, a failed `load()` and
133    /// cancellation.
134    pub fn start(
135        &self,
136        descriptor: &'static BenchmarkDescriptor,
137        values: ParamValues,
138        target: TargetEndpoint,
139        coherence: CoherencePolicy,
140        ceilings: Option<crate::hardware::policy::TempCeilings>,
141    ) -> RunHandle {
142        let (event_tx, event_rx) = channel();
143        let (frame_tx, frame_rx) = channel();
144        let cancel = Arc::new(AtomicBool::new(false));
145        let finished = Arc::new(AtomicBool::new(false));
146        let handle = PluginHandle::new(
147            self.next_run_id
148                .fetch_add(1, std::sync::atomic::Ordering::Relaxed),
149            target,
150            self.artifacts.clone(),
151            event_tx.clone(),
152            cancel.clone(),
153        );
154        let task = RunTask {
155            descriptor,
156            values,
157            coherence,
158            handle,
159            events: event_tx,
160            frames: frame_tx,
161            cancel: cancel.clone(),
162            finished: finished.clone(),
163            ceilings,
164        };
165        self.runtime.spawn(task.execute());
166        RunHandle {
167            events: event_rx,
168            frames: frame_rx,
169            cancel,
170            finished,
171        }
172    }
173}
174
175/// 2026-09-26: The per-request timeout of the coherence probe, whose
176/// questions ask for at most 96 tokens (`coherence::ask`).
177const COHERENCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
178
179struct RunTask {
180    descriptor: &'static BenchmarkDescriptor,
181    values: ParamValues,
182    handle: PluginHandle,
183    events: Sender<PluginEvent>,
184    frames: Sender<BenchmarkResult>,
185    cancel: Arc<AtomicBool>,
186    finished: Arc<AtomicBool>,
187    coherence: CoherencePolicy,
188    /// 2026-09-26: The box class's temperature ceilings for the pre-run
189    /// capture, when the caller has them.
190    ceilings: Option<crate::hardware::policy::TempCeilings>,
191}
192
193impl RunTask {
194    /// 2026-09-26: Run the coherence probe unless the policy is `Skip`. Any
195    /// concern becomes a warning in the run log; nothing here stops the run.
196    async fn probe_coherence(&self) {
197        if self.coherence == CoherencePolicy::Skip {
198            return;
199        }
200        self.handle.status("checking the endpoint".to_string());
201        let report = crate::coherence::probe_for(
202            self.handle.target(),
203            self.descriptor.intended_for,
204            COHERENCE_TIMEOUT,
205        )
206        .await;
207        match report.concern(self.handle.target()) {
208            Some(concern) => self.handle.warn(concern),
209            None => {
210                for a in &report.answers {
211                    self.handle
212                        .info(format!("endpoint check {}: {:?}", a.label, a.answer.trim()));
213                }
214            }
215        }
216    }
217
218    /// 2026-09-26: Capture the box state and open the hardware report, whose
219    /// precheck decides whether this run may start.
220    ///
221    /// `None` when the capture task failed (it panicked or was cancelled). The
222    /// capture runs on `spawn_blocking` because `HardwareState::collect` runs
223    /// `nvidia-smi` as a synchronous subprocess.
224    async fn open_hardware_report(&self) -> Option<HardwareStateReport> {
225        let sensitivity = self.descriptor.sensitivity;
226        let before = tokio::task::spawn_blocking(HardwareState::collect)
227            .await
228            .ok()?;
229        self.handle
230            .info(format!("box state: {}", before.one_line()));
231        let report = HardwareStateReport::opened(sensitivity, before, self.ceilings);
232        if policy::PolicyOptions::from_env().kill_switch {
233            // 2026-09-26: Warned on every run while the kill switch is set, so
234            // the run log of every number shows it.
235            self.handle.warn(format!(
236                "{}=1 — the hardware pre-check CANNOT refuse this run. Whatever it found is \
237                 recorded below and travels with the record.",
238                policy::KILL_SWITCH_ENV
239            ));
240        }
241        for concern in &report.precheck.concerns {
242            self.handle.warn(format!("hardware: {concern}"));
243        }
244        Some(report)
245    }
246
247    /// 2026-09-26: Take the after-capture, close the report and attach it to
248    /// the terminal frame. Without an open report the frame is returned as is;
249    /// a failed after-capture leaves the report unclosed.
250    async fn close_hardware(
251        &self,
252        mut frame: BenchmarkResult,
253        report: Option<HardwareStateReport>,
254    ) -> BenchmarkResult {
255        let Some(mut report) = report else {
256            return frame;
257        };
258        if let Ok(after) = tokio::task::spawn_blocking(HardwareState::collect).await {
259            report.close(after);
260        }
261        if let Some(post) = &report.postcheck {
262            for concern in &post.concerns {
263                self.handle.warn(format!("hardware: {concern}"));
264            }
265        }
266        // 2026-09-26: Only a completed run has numbers to invalidate.
267        if report.invalidated() && frame.status == RunStatus::Completed {
268            self.handle.warn(
269                "hardware: this run's SPEED numbers are marked INVALID — the box throttled \
270                 while it was measuring. Re-run on a box that does not."
271                    .to_string(),
272            );
273        }
274        frame.hardware_state = Some(report);
275        frame
276    }
277
278    async fn execute(self) {
279        let started = Instant::now();
280        self.handle.set_glow(true);
281        let mut bench = self.descriptor.build();
282
283        // 2026-09-26: The hardware precheck comes first, so a refused run has
284        // touched neither the endpoint nor the artifacts.
285        let hardware = self.open_hardware_report().await;
286        if let Some(report) = hardware.as_ref().filter(|r| r.refuses()) {
287            let frame = BenchmarkResult::failed(
288                "hardware precheck",
289                format!(
290                    "this box is not in a state to produce a comparable SPEED number: {}. \
291                     Set {}=1 to measure anyway.",
292                    report.precheck.concerns.join("; "),
293                    policy::KILL_SWITCH_ENV
294                ),
295                started.elapsed(),
296            );
297            // 2026-09-26: The report is attached unclosed: nothing ran, so
298            // there is no after-capture and no validity to judge.
299            let _ = self.frames.send(frame.with_hardware_state(report.clone()));
300            self.teardown(bench.as_mut()).await;
301            return;
302        }
303
304        // 2026-09-26: Probe, then load, then configure; a setup error becomes
305        // a terminal "setup" frame. The probe runs before `load()`, which for
306        // BFCL provisions a venv and dataset, so its warning is in the log
307        // before that wait.
308        self.probe_coherence().await;
309        let setup = async {
310            bench.load(self.handle.clone()).await?;
311            bench.configure(&self.values)
312        }
313        .await;
314        if let Err(e) = setup {
315            self.emit_failed("setup", e, started.elapsed(), hardware)
316                .await;
317            self.teardown(bench.as_mut()).await;
318            return;
319        }
320
321        // 2026-09-26: Drive. The terminal frame is held back until the
322        // after-capture is attached to it (`close_hardware`).
323        let mut terminal: Option<BenchmarkResult> = None;
324        {
325            let stream = drive(bench.as_mut());
326            futures::pin_mut!(stream);
327            while let Some(item) = stream.next().await {
328                match item {
329                    Ok(frame) => {
330                        if frame.status.is_terminal() {
331                            terminal = Some(frame);
332                            break;
333                        }
334                        if self.frames.send(frame).is_err() {
335                            break;
336                        }
337                    }
338                    // 2026-09-26: `{:#}` prints the whole anyhow context chain.
339                    Err(e) => {
340                        terminal = Some(BenchmarkResult::failed(
341                            "run",
342                            format!("{e:#}"),
343                            started.elapsed(),
344                        ));
345                        break;
346                    }
347                }
348                if self.cancel.load(Ordering::Relaxed) {
349                    terminal = Some(BenchmarkResult::failed(
350                        "cancelled",
351                        "cancelled by user",
352                        started.elapsed(),
353                    ));
354                    break;
355                }
356            }
357        }
358        if let Some(frame) = terminal {
359            let frame = self.close_hardware(frame, hardware).await;
360            let _ = self.frames.send(frame);
361        }
362
363        self.teardown(bench.as_mut()).await;
364    }
365
366    async fn emit_failed(
367        &self,
368        phase: &str,
369        error: anyhow::Error,
370        elapsed: Duration,
371        hardware: Option<HardwareStateReport>,
372    ) {
373        let frame = BenchmarkResult::failed(phase, format!("{error:#}"), elapsed);
374        let frame = self.close_hardware(frame, hardware).await;
375        let _ = self.frames.send(frame);
376    }
377
378    async fn teardown(&self, bench: &mut dyn DynBenchmark) {
379        if let Err(e) = bench.cleanup().await {
380            let _ = self
381                .events
382                .send(PluginEvent::Log(LogLine::warn(format!("cleanup: {e:#}"))));
383        }
384        let _ = self.events.send(PluginEvent::Glow(false));
385        self.finished.store(true, Ordering::Relaxed);
386    }
387}
388
389/// 2026-09-26: True when a frame ends the run, by the same rule [`drive`]
390/// stops on.
391pub fn is_final(frame: &BenchmarkResult) -> bool {
392    frame.status.is_terminal()
393}
394
395/// 2026-09-26: `Completed` when `ok`, else `Failed`.
396pub fn terminal_status(ok: bool) -> RunStatus {
397    if ok {
398        RunStatus::Completed
399    } else {
400        RunStatus::Failed
401    }
402}
403
404#[cfg(test)]
405#[path = "executor_tests.rs"]
406mod tests;