Skip to main content

launchbound_bench/
run.rs

1//! Plan execution: resumable, checkpointed after every candidate (the box
2//! dies — idle guard, dead-man switch, spot reclaim — so the harness is
3//! resumable or it is broken, docs/ARCHITECTURE.md), with a CPU heartbeat so a
4//! GPU-bound sweep never looks idle to the 30-minute CPU alarm.
5
6use crate::cuda::Device;
7use crate::plan::{ArgSpec, BenchPlan, Candidate};
8use crate::stats::{Summary, summarize};
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::time::Instant;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CandidateResult {
16    pub id: String,
17    pub config: String,
18    pub status: String, // "ok" | "error"
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub error: Option<String>,
21    pub warmup: u32,
22    pub repeats: u32,
23    #[serde(default)]
24    pub times_ms: Vec<f64>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub summary: Option<Summary>,
27    /// Wall-clock seconds this candidate consumed on the GPU host.
28    pub gpu_seconds: f64,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Results {
33    pub schema: String,
34    pub kernel: String,
35    pub entry: String,
36    pub plan_cc: String,
37    pub device_name: String,
38    pub device_cc: String,
39    pub driver_version: String,
40    pub candidates: Vec<CandidateResult>,
41    pub total_gpu_seconds: f64,
42    /// Strategy that produced the visiting order (`exhaustive` | `random:<seed>`).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub strategy: Option<String>,
45    /// True when the sweep stopped because the wall budget ran out.
46    #[serde(default)]
47    pub budget_exhausted: bool,
48}
49
50pub struct RunOptions {
51    /// Visiting order over plan.candidates indices (a permutation or
52    /// prefix); defaults to plan order.
53    pub order: Vec<usize>,
54    /// Wall-clock budget; the sweep stops (resumably) when it is spent.
55    pub budget_secs: Option<f64>,
56    /// Recorded in results for provenance.
57    pub strategy: Option<String>,
58}
59
60impl RunOptions {
61    pub fn exhaustive(plan: &BenchPlan) -> Self {
62        RunOptions {
63            order: (0..plan.candidates.len()).collect(),
64            budget_secs: None,
65            strategy: Some("exhaustive".into()),
66        }
67    }
68}
69
70/// Execute `plan`, appending to `results_path` (resume: candidates already
71/// present are skipped). Writes the results file after every candidate.
72pub fn run_plan(
73    plan: &BenchPlan,
74    plan_dir: &Path,
75    results_path: &Path,
76    options: &RunOptions,
77    progress: &mut dyn FnMut(&str),
78) -> Result<Results, String> {
79    let device = Device::open()?;
80    progress(&format!(
81        "device: {} (cc {}, driver {})",
82        device.name, device.cc, device.driver_version
83    ));
84    if device.cc != plan.cc {
85        progress(&format!(
86            "WARNING: plan was gated at cc {}, device is cc {} — verdicts do not transfer \
87             across parts (docs/SAFETY.md); results will be labelled with the device cc",
88            plan.cc, device.cc
89        ));
90    }
91
92    // A checkpoint that cannot be read is not "start over": resuming would
93    // silently discard measurements that cost GPU time, and the file is the
94    // only record of them. Say what is wrong and stop.
95    let existing = Results::load(results_path)?;
96    let mut results = match existing {
97        Some(existing) => {
98            progress(&format!(
99                "resuming: {} candidates already measured",
100                existing.candidates.len()
101            ));
102            existing
103        }
104        None => Results {
105            schema: "results.v1".into(),
106            kernel: plan.kernel.clone(),
107            entry: plan.entry.clone(),
108            plan_cc: plan.cc.clone(),
109            device_name: device.name.clone(),
110            device_cc: device.cc.clone(),
111            driver_version: device.driver_version.clone(),
112            candidates: Vec::new(),
113            total_gpu_seconds: 0.0,
114            strategy: options.strategy.clone(),
115            budget_exhausted: false,
116        },
117    };
118    results.budget_exhausted = false;
119
120    let heartbeat_stop = start_heartbeat();
121    let sweep_started = Instant::now();
122
123    for &index in &options.order {
124        let Some(candidate) = plan.candidates.get(index) else {
125            return Err(format!("order index {index} out of range"));
126        };
127        if results.candidates.iter().any(|c| c.id == candidate.id) {
128            continue;
129        }
130        if let Some(budget) = options.budget_secs
131            && sweep_started.elapsed().as_secs_f64() >= budget
132        {
133            results.budget_exhausted = true;
134            progress(&format!(
135                "budget exhausted after {:.1}s: {} of {} candidates measured (resumable)",
136                sweep_started.elapsed().as_secs_f64(),
137                results.candidates.len(),
138                plan.candidates.len()
139            ));
140            break;
141        }
142        // A gate-refused candidate may genuinely hang (that is why it was
143        // refused). Checkpoint a `timeout` record BEFORE launching, so a
144        // watchdog abort (or a wedged GPU context) leaves a resumable
145        // truth on disk; overwrite it with the real outcome if we survive.
146        let watchdog = if candidate.unsafe_candidate {
147            results.candidates.push(CandidateResult {
148                id: candidate.id.clone(),
149                config: candidate.config.clone(),
150                status: "timeout".into(),
151                error: Some(format!(
152                    "unsafe candidate did not complete within {UNSAFE_TIMEOUT_SECS}s;                      presumed hung (this is the failure mode the gate predicts)"
153                )),
154                warmup: candidate.warmup,
155                repeats: candidate.repeats,
156                times_ms: Vec::new(),
157                summary: None,
158                gpu_seconds: unsafe_timeout_secs() as f64,
159            });
160            results.checkpoint(results_path)?;
161            progress(&format!(
162                "{} UNSAFE candidate: watchdog armed at {}s",
163                candidate.id,
164                unsafe_timeout_secs()
165            ));
166            Some(arm_watchdog(unsafe_timeout_secs()))
167        } else {
168            None
169        };
170        let started = Instant::now();
171        let outcome = run_candidate(&device, plan, plan_dir, candidate);
172        if let Some(armed) = watchdog {
173            armed.store(true, Ordering::Relaxed); // disarm
174            // Replace the pre-checkpointed timeout record with the truth.
175            results.candidates.retain(|c| c.id != candidate.id);
176        }
177        let gpu_seconds = started.elapsed().as_secs_f64();
178        let result = match outcome {
179            Ok(times_ms) => {
180                let summary = summarize(&times_ms);
181                progress(&format!(
182                    "{} {}: median {} over {} repeats ({:.1}s)",
183                    candidate.id,
184                    candidate.config,
185                    summary
186                        .as_ref()
187                        .map(|s| format!(
188                            "{:.4} ms [{:.4}, {:.4}]",
189                            s.median_ms, s.ci95_lo_ms, s.ci95_hi_ms
190                        ))
191                        .unwrap_or_else(|| "n/a".into()),
192                    candidate.repeats,
193                    gpu_seconds,
194                ));
195                CandidateResult {
196                    id: candidate.id.clone(),
197                    config: candidate.config.clone(),
198                    status: "ok".into(),
199                    error: None,
200                    warmup: candidate.warmup,
201                    repeats: candidate.repeats,
202                    times_ms,
203                    summary,
204                    gpu_seconds,
205                }
206            }
207            Err(e) => {
208                progress(&format!("{} ERROR: {e}", candidate.id));
209                CandidateResult {
210                    id: candidate.id.clone(),
211                    config: candidate.config.clone(),
212                    status: "error".into(),
213                    error: Some(e),
214                    warmup: candidate.warmup,
215                    repeats: candidate.repeats,
216                    times_ms: Vec::new(),
217                    summary: None,
218                    gpu_seconds,
219                }
220            }
221        };
222        results.candidates.push(result);
223        results.total_gpu_seconds = sweep_started.elapsed().as_secs_f64();
224        results.checkpoint(results_path)?;
225    }
226
227    heartbeat_stop.store(true, Ordering::Relaxed);
228    results.total_gpu_seconds = sweep_started.elapsed().as_secs_f64();
229    results.checkpoint(results_path)?;
230    Ok(results)
231}
232
233fn run_candidate(
234    device: &Device,
235    plan: &BenchPlan,
236    plan_dir: &Path,
237    candidate: &Candidate,
238) -> Result<Vec<f64>, String> {
239    let ptx_path = plan_dir.join(&candidate.ptx);
240    let ptx = std::fs::read_to_string(&ptx_path)
241        .map_err(|e| format!("reading {}: {e}", ptx_path.display()))?;
242    let module = device.load_module(&ptx, &plan.entry)?;
243
244    // Materialize buffers and the param pointer table, in ArgSpec order.
245    // Each ArgSpec is exactly one .param slot.
246    let mut buffers = Vec::new(); // (arg index, Buffer)
247    for (i, arg) in candidate.args.iter().enumerate() {
248        match arg {
249            ArgSpec::InF32 { len } => {
250                let host: Vec<f32> = deterministic_f32(*len);
251                let buf = device.alloc(host.len() * 4)?;
252                device.copy_in(&buf, cast_bytes(&host))?;
253                buffers.push((i, buf));
254            }
255            ArgSpec::InU32 { len, modulo } => {
256                let host: Vec<u32> = deterministic_u32(*len, *modulo);
257                let buf = device.alloc(host.len() * 4)?;
258                device.copy_in(&buf, cast_bytes(&host))?;
259                buffers.push((i, buf));
260            }
261            ArgSpec::OutF32 { len } | ArgSpec::OutU32 { len } => {
262                let zero = vec![0u8; (*len as usize) * 4];
263                let buf = device.alloc(zero.len())?;
264                device.copy_in(&buf, &zero)?;
265                buffers.push((i, buf));
266            }
267            ArgSpec::LenOf { .. } | ArgSpec::U32 { .. } | ArgSpec::U64 { .. } => {}
268        }
269    }
270
271    // Scalar storage must outlive the launch; the params table points into
272    // these vectors and the buffers' device pointers.
273    let mut ptr_slots: Vec<u64> = Vec::new();
274    let mut u32_slots: Vec<u32> = Vec::new();
275    let mut u64_slots: Vec<u64> = Vec::new();
276    #[derive(Clone, Copy)]
277    enum Slot {
278        Ptr(usize),
279        U32(usize),
280        U64(usize),
281    }
282    let mut slots = Vec::with_capacity(candidate.args.len());
283    for (i, arg) in candidate.args.iter().enumerate() {
284        match arg {
285            ArgSpec::InF32 { .. }
286            | ArgSpec::InU32 { .. }
287            | ArgSpec::OutF32 { .. }
288            | ArgSpec::OutU32 { .. } => {
289                let buf = &buffers
290                    .iter()
291                    .find(|(idx, _)| *idx == i)
292                    .expect("buffer materialized")
293                    .1;
294                ptr_slots.push(buf.ptr);
295                slots.push(Slot::Ptr(ptr_slots.len() - 1));
296            }
297            ArgSpec::LenOf { of } => {
298                let len = match candidate.args.get(*of) {
299                    Some(ArgSpec::InF32 { len })
300                    | Some(ArgSpec::OutF32 { len })
301                    | Some(ArgSpec::OutU32 { len })
302                    | Some(ArgSpec::InU32 { len, .. }) => *len,
303                    other => return Err(format!("len_of {of} points at {other:?}")),
304                };
305                u64_slots.push(len);
306                slots.push(Slot::U64(u64_slots.len() - 1));
307            }
308            ArgSpec::U32 { value } => {
309                u32_slots.push(*value as u32);
310                slots.push(Slot::U32(u32_slots.len() - 1));
311            }
312            ArgSpec::U64 { value } => {
313                u64_slots.push(*value);
314                slots.push(Slot::U64(u64_slots.len() - 1));
315            }
316        }
317    }
318    let mut params: Vec<*mut std::ffi::c_void> = slots
319        .iter()
320        .map(|slot| match slot {
321            Slot::Ptr(k) => std::ptr::from_mut(&mut ptr_slots[*k]).cast(),
322            Slot::U32(k) => std::ptr::from_mut(&mut u32_slots[*k]).cast(),
323            Slot::U64(k) => std::ptr::from_mut(&mut u64_slots[*k]).cast(),
324        })
325        .collect();
326
327    for _ in 0..candidate.warmup {
328        device.timed_launch(&module, candidate.grid, candidate.block, &mut params)?;
329    }
330    device.synchronize()?;
331
332    let mut times = Vec::with_capacity(candidate.repeats as usize);
333    for _ in 0..candidate.repeats {
334        times.push(device.timed_launch(&module, candidate.grid, candidate.block, &mut params)?);
335    }
336    device.synchronize()?;
337    Ok(times)
338}
339
340fn cast_bytes<T>(data: &[T]) -> &[u8] {
341    unsafe { std::slice::from_raw_parts(data.as_ptr().cast(), std::mem::size_of_val(data)) }
342}
343
344/// Deterministic xorshift-seeded data: reproducible across runs and hosts.
345fn deterministic_f32(len: u64) -> Vec<f32> {
346    let mut state = 0x9e3779b97f4a7c15u64;
347    (0..len)
348        .map(|_| {
349            state ^= state << 13;
350            state ^= state >> 7;
351            state ^= state << 17;
352            ((state >> 40) as f32) / ((1u64 << 24) as f32)
353        })
354        .collect()
355}
356
357fn deterministic_u32(len: u64, modulo: u64) -> Vec<u32> {
358    let modulo = modulo.max(1);
359    let mut state = 0x2545f4914f6cdd1du64;
360    (0..len)
361        .map(|_| {
362            state ^= state << 13;
363            state ^= state >> 7;
364            state ^= state << 17;
365            (state % modulo) as u32
366        })
367        .collect()
368}
369
370impl Results {
371    /// Read `results.v1` from a run directory.
372    ///
373    /// `Ok(None)` means one thing only: **the file is not there**, so the
374    /// measurement box has not run yet. Everything else is an error.
375    ///
376    /// This used to be `read_to_string(path).ok()?` then `from_str().ok()`,
377    /// so a truncated file, an empty one, `null`, `[]`, a `results.v2` from
378    /// a newer runner and a *directory* named `results.json` all collapsed
379    /// into that same `None` — and `report` rendered "nothing measured yet",
380    /// exit 0, nothing on stderr, with a JSON report that validated. The run
381    /// directory is the hand-off between two machines, and the two
382    /// conditions call for opposite actions: wait, or go and look. Nothing
383    /// told them apart.
384    ///
385    /// `verdicts.v1` two lines away in the report builder already checked
386    /// its schema tag by name; this is the same check, so a future
387    /// `results.v2` is refused by name rather than read as nothing.
388    ///
389    /// # Errors
390    ///
391    /// Any I/O error that is not `NotFound`, any parse failure, and any
392    /// document whose `schema` is not `results.v1` — each naming the path.
393    pub fn load(path: &Path) -> Result<Option<Self>, String> {
394        let text = match std::fs::read_to_string(path) {
395            Ok(text) => text,
396            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
397            Err(e) => return Err(format!("{}: {e}", path.display())),
398        };
399        // The tag first, so a wrong-schema document is named as one rather
400        // than as whatever field happens to be missing from it.
401        let value: serde_json::Value = serde_json::from_str(&text)
402            .map_err(|e| format!("{}: not JSON: {e}", path.display()))?;
403        let declared = value.get("schema").and_then(|s| s.as_str());
404        match declared {
405            Some("results.v1") => {}
406            Some(other) => {
407                return Err(format!(
408                    "{}: unsupported results schema `{other}` (expected `results.v1`)",
409                    path.display()
410                ));
411            }
412            None => {
413                return Err(format!(
414                    "{}: not a results.v1 document (no `schema` field)",
415                    path.display()
416                ));
417            }
418        }
419        serde_json::from_str(&text)
420            .map(Some)
421            .map_err(|e| format!("{}: not a results.v1 document: {e}", path.display()))
422    }
423
424    /// Atomic checkpoint: write to a temp file, then rename.
425    pub fn checkpoint(&self, path: &Path) -> Result<(), String> {
426        let tmp = path.with_extension("json.tmp");
427        std::fs::write(
428            &tmp,
429            serde_json::to_string_pretty(self).expect("results serialize"),
430        )
431        .map_err(|e| e.to_string())?;
432        std::fs::rename(&tmp, path).map_err(|e| e.to_string())
433    }
434}
435
436const UNSAFE_TIMEOUT_SECS: u64 = 10;
437
438fn unsafe_timeout_secs() -> u64 {
439    std::env::var("LAUNCHBOUND_UNSAFE_TIMEOUT_SECS")
440        .ok()
441        .and_then(|v| v.parse().ok())
442        .unwrap_or(UNSAFE_TIMEOUT_SECS)
443}
444
445/// Watchdog for unsafe candidates: if not disarmed within the deadline the
446/// process exits (a hung kernel cannot be cancelled from user code). The
447/// pre-checkpointed `timeout` record makes the rerun skip it.
448fn arm_watchdog(deadline_secs: u64) -> std::sync::Arc<AtomicBool> {
449    let disarmed = std::sync::Arc::new(AtomicBool::new(false));
450    let flag = disarmed.clone();
451    std::thread::spawn(move || {
452        let start = Instant::now();
453        while start.elapsed().as_secs() < deadline_secs {
454            if flag.load(Ordering::Relaxed) {
455                return;
456            }
457            std::thread::sleep(std::time::Duration::from_millis(100));
458        }
459        if !flag.load(Ordering::Relaxed) {
460            eprintln!(
461                "watchdog: unsafe candidate exceeded {deadline_secs}s; exiting so the                  checkpointed timeout record stands (exit 3, rerun to continue)"
462            );
463            std::process::exit(3);
464        }
465    });
466    disarmed
467}
468
469/// A CPU heartbeat: the box's idle alarm terminates on CPU <5% for 30 min,
470/// and a GPU-bound loop can look idle. Burn a configurable duty cycle on
471/// one core (LAUNCHBOUND_HEARTBEAT_PCT, default 40) until stopped.
472fn start_heartbeat() -> &'static AtomicBool {
473    static STOP: AtomicBool = AtomicBool::new(false);
474    STOP.store(false, Ordering::Relaxed);
475    let duty: u64 = std::env::var("LAUNCHBOUND_HEARTBEAT_PCT")
476        .ok()
477        .and_then(|v| v.parse().ok())
478        .unwrap_or(40)
479        .clamp(1, 100);
480    std::thread::spawn(move || {
481        let mut sink = 0u64;
482        while !STOP.load(Ordering::Relaxed) {
483            let spin = Instant::now();
484            while spin.elapsed().as_millis() < duty as u128 {
485                sink = sink.wrapping_mul(6364136223846793005).wrapping_add(1);
486            }
487            std::hint::black_box(sink);
488            std::thread::sleep(std::time::Duration::from_millis(100 - duty.min(99)));
489        }
490    });
491    &STOP
492}