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    let mut results = match Results::load(results_path) {
93        Some(existing) if existing.schema == "results.v1" => {
94            progress(&format!(
95                "resuming: {} candidates already measured",
96                existing.candidates.len()
97            ));
98            existing
99        }
100        _ => Results {
101            schema: "results.v1".into(),
102            kernel: plan.kernel.clone(),
103            entry: plan.entry.clone(),
104            plan_cc: plan.cc.clone(),
105            device_name: device.name.clone(),
106            device_cc: device.cc.clone(),
107            driver_version: device.driver_version.clone(),
108            candidates: Vec::new(),
109            total_gpu_seconds: 0.0,
110            strategy: options.strategy.clone(),
111            budget_exhausted: false,
112        },
113    };
114    results.budget_exhausted = false;
115
116    let heartbeat_stop = start_heartbeat();
117    let sweep_started = Instant::now();
118
119    for &index in &options.order {
120        let Some(candidate) = plan.candidates.get(index) else {
121            return Err(format!("order index {index} out of range"));
122        };
123        if results.candidates.iter().any(|c| c.id == candidate.id) {
124            continue;
125        }
126        if let Some(budget) = options.budget_secs
127            && sweep_started.elapsed().as_secs_f64() >= budget
128        {
129            results.budget_exhausted = true;
130            progress(&format!(
131                "budget exhausted after {:.1}s: {} of {} candidates measured (resumable)",
132                sweep_started.elapsed().as_secs_f64(),
133                results.candidates.len(),
134                plan.candidates.len()
135            ));
136            break;
137        }
138        // A gate-refused candidate may genuinely hang (that is why it was
139        // refused). Checkpoint a `timeout` record BEFORE launching, so a
140        // watchdog abort (or a wedged GPU context) leaves a resumable
141        // truth on disk; overwrite it with the real outcome if we survive.
142        let watchdog = if candidate.unsafe_candidate {
143            results.candidates.push(CandidateResult {
144                id: candidate.id.clone(),
145                config: candidate.config.clone(),
146                status: "timeout".into(),
147                error: Some(format!(
148                    "unsafe candidate did not complete within {UNSAFE_TIMEOUT_SECS}s;                      presumed hung (this is the failure mode the gate predicts)"
149                )),
150                warmup: candidate.warmup,
151                repeats: candidate.repeats,
152                times_ms: Vec::new(),
153                summary: None,
154                gpu_seconds: unsafe_timeout_secs() as f64,
155            });
156            results.checkpoint(results_path)?;
157            progress(&format!(
158                "{} UNSAFE candidate: watchdog armed at {}s",
159                candidate.id,
160                unsafe_timeout_secs()
161            ));
162            Some(arm_watchdog(unsafe_timeout_secs()))
163        } else {
164            None
165        };
166        let started = Instant::now();
167        let outcome = run_candidate(&device, plan, plan_dir, candidate);
168        if let Some(armed) = watchdog {
169            armed.store(true, Ordering::Relaxed); // disarm
170            // Replace the pre-checkpointed timeout record with the truth.
171            results.candidates.retain(|c| c.id != candidate.id);
172        }
173        let gpu_seconds = started.elapsed().as_secs_f64();
174        let result = match outcome {
175            Ok(times_ms) => {
176                let summary = summarize(&times_ms);
177                progress(&format!(
178                    "{} {}: median {} over {} repeats ({:.1}s)",
179                    candidate.id,
180                    candidate.config,
181                    summary
182                        .as_ref()
183                        .map(|s| format!(
184                            "{:.4} ms [{:.4}, {:.4}]",
185                            s.median_ms, s.ci95_lo_ms, s.ci95_hi_ms
186                        ))
187                        .unwrap_or_else(|| "n/a".into()),
188                    candidate.repeats,
189                    gpu_seconds,
190                ));
191                CandidateResult {
192                    id: candidate.id.clone(),
193                    config: candidate.config.clone(),
194                    status: "ok".into(),
195                    error: None,
196                    warmup: candidate.warmup,
197                    repeats: candidate.repeats,
198                    times_ms,
199                    summary,
200                    gpu_seconds,
201                }
202            }
203            Err(e) => {
204                progress(&format!("{} ERROR: {e}", candidate.id));
205                CandidateResult {
206                    id: candidate.id.clone(),
207                    config: candidate.config.clone(),
208                    status: "error".into(),
209                    error: Some(e),
210                    warmup: candidate.warmup,
211                    repeats: candidate.repeats,
212                    times_ms: Vec::new(),
213                    summary: None,
214                    gpu_seconds,
215                }
216            }
217        };
218        results.candidates.push(result);
219        results.total_gpu_seconds = sweep_started.elapsed().as_secs_f64();
220        results.checkpoint(results_path)?;
221    }
222
223    heartbeat_stop.store(true, Ordering::Relaxed);
224    results.total_gpu_seconds = sweep_started.elapsed().as_secs_f64();
225    results.checkpoint(results_path)?;
226    Ok(results)
227}
228
229fn run_candidate(
230    device: &Device,
231    plan: &BenchPlan,
232    plan_dir: &Path,
233    candidate: &Candidate,
234) -> Result<Vec<f64>, String> {
235    let ptx_path = plan_dir.join(&candidate.ptx);
236    let ptx = std::fs::read_to_string(&ptx_path)
237        .map_err(|e| format!("reading {}: {e}", ptx_path.display()))?;
238    let module = device.load_module(&ptx, &plan.entry)?;
239
240    // Materialize buffers and the param pointer table, in ArgSpec order.
241    // Each ArgSpec is exactly one .param slot.
242    let mut buffers = Vec::new(); // (arg index, Buffer)
243    for (i, arg) in candidate.args.iter().enumerate() {
244        match arg {
245            ArgSpec::InF32 { len } => {
246                let host: Vec<f32> = deterministic_f32(*len);
247                let buf = device.alloc(host.len() * 4)?;
248                device.copy_in(&buf, cast_bytes(&host))?;
249                buffers.push((i, buf));
250            }
251            ArgSpec::InU32 { len, modulo } => {
252                let host: Vec<u32> = deterministic_u32(*len, *modulo);
253                let buf = device.alloc(host.len() * 4)?;
254                device.copy_in(&buf, cast_bytes(&host))?;
255                buffers.push((i, buf));
256            }
257            ArgSpec::OutF32 { len } | ArgSpec::OutU32 { len } => {
258                let zero = vec![0u8; (*len as usize) * 4];
259                let buf = device.alloc(zero.len())?;
260                device.copy_in(&buf, &zero)?;
261                buffers.push((i, buf));
262            }
263            ArgSpec::LenOf { .. } | ArgSpec::U32 { .. } | ArgSpec::U64 { .. } => {}
264        }
265    }
266
267    // Scalar storage must outlive the launch; the params table points into
268    // these vectors and the buffers' device pointers.
269    let mut ptr_slots: Vec<u64> = Vec::new();
270    let mut u32_slots: Vec<u32> = Vec::new();
271    let mut u64_slots: Vec<u64> = Vec::new();
272    #[derive(Clone, Copy)]
273    enum Slot {
274        Ptr(usize),
275        U32(usize),
276        U64(usize),
277    }
278    let mut slots = Vec::with_capacity(candidate.args.len());
279    for (i, arg) in candidate.args.iter().enumerate() {
280        match arg {
281            ArgSpec::InF32 { .. }
282            | ArgSpec::InU32 { .. }
283            | ArgSpec::OutF32 { .. }
284            | ArgSpec::OutU32 { .. } => {
285                let buf = &buffers
286                    .iter()
287                    .find(|(idx, _)| *idx == i)
288                    .expect("buffer materialized")
289                    .1;
290                ptr_slots.push(buf.ptr);
291                slots.push(Slot::Ptr(ptr_slots.len() - 1));
292            }
293            ArgSpec::LenOf { of } => {
294                let len = match candidate.args.get(*of) {
295                    Some(ArgSpec::InF32 { len })
296                    | Some(ArgSpec::OutF32 { len })
297                    | Some(ArgSpec::OutU32 { len })
298                    | Some(ArgSpec::InU32 { len, .. }) => *len,
299                    other => return Err(format!("len_of {of} points at {other:?}")),
300                };
301                u64_slots.push(len);
302                slots.push(Slot::U64(u64_slots.len() - 1));
303            }
304            ArgSpec::U32 { value } => {
305                u32_slots.push(*value as u32);
306                slots.push(Slot::U32(u32_slots.len() - 1));
307            }
308            ArgSpec::U64 { value } => {
309                u64_slots.push(*value);
310                slots.push(Slot::U64(u64_slots.len() - 1));
311            }
312        }
313    }
314    let mut params: Vec<*mut std::ffi::c_void> = slots
315        .iter()
316        .map(|slot| match slot {
317            Slot::Ptr(k) => std::ptr::from_mut(&mut ptr_slots[*k]).cast(),
318            Slot::U32(k) => std::ptr::from_mut(&mut u32_slots[*k]).cast(),
319            Slot::U64(k) => std::ptr::from_mut(&mut u64_slots[*k]).cast(),
320        })
321        .collect();
322
323    for _ in 0..candidate.warmup {
324        device.timed_launch(&module, candidate.grid, candidate.block, &mut params)?;
325    }
326    device.synchronize()?;
327
328    let mut times = Vec::with_capacity(candidate.repeats as usize);
329    for _ in 0..candidate.repeats {
330        times.push(device.timed_launch(&module, candidate.grid, candidate.block, &mut params)?);
331    }
332    device.synchronize()?;
333    Ok(times)
334}
335
336fn cast_bytes<T>(data: &[T]) -> &[u8] {
337    unsafe { std::slice::from_raw_parts(data.as_ptr().cast(), std::mem::size_of_val(data)) }
338}
339
340/// Deterministic xorshift-seeded data: reproducible across runs and hosts.
341fn deterministic_f32(len: u64) -> Vec<f32> {
342    let mut state = 0x9e3779b97f4a7c15u64;
343    (0..len)
344        .map(|_| {
345            state ^= state << 13;
346            state ^= state >> 7;
347            state ^= state << 17;
348            ((state >> 40) as f32) / ((1u64 << 24) as f32)
349        })
350        .collect()
351}
352
353fn deterministic_u32(len: u64, modulo: u64) -> Vec<u32> {
354    let modulo = modulo.max(1);
355    let mut state = 0x2545f4914f6cdd1du64;
356    (0..len)
357        .map(|_| {
358            state ^= state << 13;
359            state ^= state >> 7;
360            state ^= state << 17;
361            (state % modulo) as u32
362        })
363        .collect()
364}
365
366impl Results {
367    pub fn load(path: &Path) -> Option<Self> {
368        let text = std::fs::read_to_string(path).ok()?;
369        serde_json::from_str(&text).ok()
370    }
371
372    /// Atomic checkpoint: write to a temp file, then rename.
373    pub fn checkpoint(&self, path: &Path) -> Result<(), String> {
374        let tmp = path.with_extension("json.tmp");
375        std::fs::write(
376            &tmp,
377            serde_json::to_string_pretty(self).expect("results serialize"),
378        )
379        .map_err(|e| e.to_string())?;
380        std::fs::rename(&tmp, path).map_err(|e| e.to_string())
381    }
382}
383
384const UNSAFE_TIMEOUT_SECS: u64 = 10;
385
386fn unsafe_timeout_secs() -> u64 {
387    std::env::var("LAUNCHBOUND_UNSAFE_TIMEOUT_SECS")
388        .ok()
389        .and_then(|v| v.parse().ok())
390        .unwrap_or(UNSAFE_TIMEOUT_SECS)
391}
392
393/// Watchdog for unsafe candidates: if not disarmed within the deadline the
394/// process exits (a hung kernel cannot be cancelled from user code). The
395/// pre-checkpointed `timeout` record makes the rerun skip it.
396fn arm_watchdog(deadline_secs: u64) -> std::sync::Arc<AtomicBool> {
397    let disarmed = std::sync::Arc::new(AtomicBool::new(false));
398    let flag = disarmed.clone();
399    std::thread::spawn(move || {
400        let start = Instant::now();
401        while start.elapsed().as_secs() < deadline_secs {
402            if flag.load(Ordering::Relaxed) {
403                return;
404            }
405            std::thread::sleep(std::time::Duration::from_millis(100));
406        }
407        if !flag.load(Ordering::Relaxed) {
408            eprintln!(
409                "watchdog: unsafe candidate exceeded {deadline_secs}s; exiting so the                  checkpointed timeout record stands (exit 3, rerun to continue)"
410            );
411            std::process::exit(3);
412        }
413    });
414    disarmed
415}
416
417/// A CPU heartbeat: the box's idle alarm terminates on CPU <5% for 30 min,
418/// and a GPU-bound loop can look idle. Burn a configurable duty cycle on
419/// one core (LAUNCHBOUND_HEARTBEAT_PCT, default 40) until stopped.
420fn start_heartbeat() -> &'static AtomicBool {
421    static STOP: AtomicBool = AtomicBool::new(false);
422    STOP.store(false, Ordering::Relaxed);
423    let duty: u64 = std::env::var("LAUNCHBOUND_HEARTBEAT_PCT")
424        .ok()
425        .and_then(|v| v.parse().ok())
426        .unwrap_or(40)
427        .clamp(1, 100);
428    std::thread::spawn(move || {
429        let mut sink = 0u64;
430        while !STOP.load(Ordering::Relaxed) {
431            let spin = Instant::now();
432            while spin.elapsed().as_millis() < duty as u128 {
433                sink = sink.wrapping_mul(6364136223846793005).wrapping_add(1);
434            }
435            std::hint::black_box(sink);
436            std::thread::sleep(std::time::Duration::from_millis(100 - duty.min(99)));
437        }
438    });
439    &STOP
440}