Skip to main content

launchbound_bench/
plan.rs

1//! Bench plans (`plan.v1`): everything the runner needs, self-contained —
2//! candidate configs, PTX paths, launch geometry, and the argument layout
3//! matching cuda-oxide's PTX parameter lowering (each slice becomes a
4//! `ptr, len` pair of `.param` slots; scalars are single slots).
5//!
6//! A kernel's `[bench]` section in kernel.toml declares the workload;
7//! sizes and grid shapes are arithmetic expressions over the kernel's
8//! dimensions plus the `elements` variable.
9
10use launchbound_space::{Config, KernelSpec, eval_arith_expr};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::Path;
14
15/// What can go wrong turning a spec into a benchmark plan.
16#[derive(Debug, thiserror::Error)]
17pub enum PlanError {
18    /// The `[bench]` section is missing, malformed, or names a dimension
19    /// or buffer the spec does not declare.
20    #[error("kernel.toml [bench]: {0}")]
21    Spec(String),
22    /// The spec or one of its constraints did not load.
23    #[error(transparent)]
24    Space(#[from] launchbound_space::SpaceError),
25    /// The plan could not be read, written or deserialized.
26    #[error("plan io: {0}")]
27    Io(String),
28}
29
30/// One PTX kernel argument slot group, in PTX parameter order.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum ArgSpec {
34    /// Device buffer of f32, copied in (seeded deterministic init).
35    /// One `.param` slot (the pointer).
36    InF32 {
37        /// Elements, not bytes.
38        len: u64,
39    },
40    /// Device buffer of u32, values `seed % modulo` (histogram bins etc.).
41    InU32 {
42        /// Elements, not bytes.
43        len: u64,
44        /// Values are `seed % modulo`, so a histogram's inputs land in
45        /// range without the plan carrying the data.
46        modulo: u64,
47    },
48    /// Device buffer of f32, zero-filled output. One slot.
49    OutF32 {
50        /// Elements, not bytes.
51        len: u64,
52    },
53    /// Device buffer of u32, zero-filled output. One slot.
54    OutU32 {
55        /// Elements, not bytes.
56        len: u64,
57    },
58    /// The length of the buffer at `of` (0-based ArgSpec index), as u64.
59    LenOf {
60        /// 0-based index into the candidate's `args` of the buffer whose
61        /// length this passes.
62        of: usize,
63    },
64    /// Scalar u32.
65    U32 {
66        /// The value, held as `u64` and narrowed at launch.
67        value: u64,
68    },
69    /// Scalar u64.
70    U64 {
71        /// The value.
72        value: u64,
73    },
74}
75
76/// One configuration, ready to launch: everything the runner needs and
77/// nothing it has to recompute.
78///
79/// A plan is deliberately self-contained. The runner takes a plan and a
80/// directory of PTX and needs neither the kernel source, nor cuda-oxide,
81/// nor reconverge — which is what lets the measurement leg run on a GPU box
82/// that has none of them installed.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Candidate {
85    /// Its canonical `config.v1` ID, matching `verdicts.v1`.
86    pub id: String,
87    /// Its dimension assignments, e.g. `block_x=128 tile=256`.
88    pub config: String,
89    /// PTX file path, relative to the plan file.
90    pub ptx: String,
91    /// True for a gate-refused candidate measured under --allow-unsafe:
92    /// the runner guards it with a watchdog, because the refusal means it
93    /// may genuinely hang.
94    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
95    pub unsafe_candidate: bool,
96    /// Grid dimensions `[x, y, z]`, resolved from the `[bench]`
97    /// expressions for this configuration.
98    pub grid: [u32; 3],
99    /// Block dimensions `[x, y, z]` — the launch shape the gate judged.
100    pub block: [u32; 3],
101    /// Kernel arguments in PTX parameter order.
102    pub args: Vec<ArgSpec>,
103    /// Untimed launches before measurement, to settle clocks and caches.
104    pub warmup: u32,
105    /// Timed launches. Each contributes one sample to the summary.
106    pub repeats: u32,
107}
108
109/// A `plan.v1` document: every candidate to measure, and how.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct BenchPlan {
112    /// Schema tag; always `plan.v1`.
113    pub schema: String,
114    /// The kernel being tuned.
115    pub kernel: String,
116    /// The `#[kernel]` entry point to launch, as named in the PTX.
117    pub entry: String,
118    /// The capability the gate ran at, carried so the runner can refuse a
119    /// plan built for a different target than the device it holds.
120    pub cc: String,
121    /// Present iff the plan includes gate-refused candidates: the explicit,
122    /// recorded reason the operator gave to --allow-unsafe (see the README). Never a
123    /// default.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub allow_unsafe_reason: Option<String>,
126    /// The candidates to measure, in plan order. A strategy visits them
127    /// in its own order without reordering this list.
128    pub candidates: Vec<Candidate>,
129}
130
131/// The `[bench]` section of kernel.toml.
132#[derive(Debug, Deserialize)]
133struct RawBench {
134    elements: u64,
135    #[serde(default = "default_one")]
136    grid_x: toml::Value,
137    #[serde(default = "default_one")]
138    grid_y: toml::Value,
139    #[serde(default = "default_one")]
140    grid_z: toml::Value,
141    #[serde(default = "default_warmup")]
142    warmup: u32,
143    #[serde(default = "default_repeats")]
144    repeats: u32,
145    args: Vec<RawArg>,
146}
147
148fn default_one() -> toml::Value {
149    toml::Value::Integer(1)
150}
151fn default_warmup() -> u32 {
152    20
153}
154fn default_repeats() -> u32 {
155    100
156}
157
158#[derive(Debug, Deserialize)]
159struct RawArg {
160    kind: String,
161    #[serde(default)]
162    len: Option<toml::Value>,
163    #[serde(default)]
164    of: Option<usize>,
165    #[serde(default)]
166    value: Option<toml::Value>,
167    #[serde(default)]
168    modulo: Option<toml::Value>,
169}
170
171/// The `[bench]` section, parsed once and used to build every candidate.
172///
173/// It holds the grid expressions and argument declarations, which are the
174/// same for the whole space; only the values they resolve against change
175/// per configuration.
176#[derive(Debug)]
177pub struct BenchSpec {
178    raw: RawBench,
179}
180
181impl BenchSpec {
182    /// Load the `[bench]` section from the kernel's kernel.toml.
183    pub fn load(spec: &KernelSpec) -> Result<Self, PlanError> {
184        let path = spec.dir.join("kernel.toml");
185        let text =
186            std::fs::read_to_string(&path).map_err(|e| PlanError::Io(format!("{path:?}: {e}")))?;
187        let table: toml::Value =
188            toml::from_str(&text).map_err(|e| PlanError::Spec(e.to_string()))?;
189        let bench = table
190            .get("bench")
191            .ok_or_else(|| PlanError::Spec("kernel.toml has no [bench] section".into()))?;
192        let raw: RawBench = bench
193            .clone()
194            .try_into()
195            .map_err(|e| PlanError::Spec(format!("{e}")))?;
196        Ok(BenchSpec { raw })
197    }
198
199    /// Render one candidate: evaluate every expression against the config.
200    pub fn candidate(
201        &self,
202        _spec: &KernelSpec,
203        config: &Config,
204        ptx_relative: &str,
205    ) -> Result<Candidate, PlanError> {
206        let mut extra = BTreeMap::new();
207        extra.insert("elements".to_string(), self.raw.elements);
208        let eval = |v: &toml::Value, what: &str| -> Result<u64, PlanError> {
209            match v {
210                toml::Value::Integer(n) if *n >= 0 => Ok(*n as u64),
211                toml::Value::String(expr) => Ok(eval_arith_expr(expr, config, &extra)?),
212                other => Err(PlanError::Spec(format!(
213                    "{what} must be a non-negative integer or expression string, got {other}"
214                ))),
215            }
216        };
217
218        let grid = [
219            eval(&self.raw.grid_x, "grid_x")? as u32,
220            eval(&self.raw.grid_y, "grid_y")? as u32,
221            eval(&self.raw.grid_z, "grid_z")? as u32,
222        ];
223        let block_dim = |name: &str| -> u32 {
224            match config.get(name) {
225                Some(launchbound_space::Value::Int(n)) => *n as u32,
226                _ => 1,
227            }
228        };
229        let block = [
230            block_dim("block_x"),
231            block_dim("block_y"),
232            block_dim("block_z"),
233        ];
234
235        let mut args = Vec::with_capacity(self.raw.args.len());
236        for (i, raw) in self.raw.args.iter().enumerate() {
237            let need = |v: &Option<toml::Value>, field: &str| -> Result<u64, PlanError> {
238                let v = v.as_ref().ok_or_else(|| {
239                    PlanError::Spec(format!("args[{i}] kind {} needs `{field}`", raw.kind))
240                })?;
241                eval(v, field)
242            };
243            let arg = match raw.kind.as_str() {
244                "in_f32" => ArgSpec::InF32 {
245                    len: need(&raw.len, "len")?,
246                },
247                "in_u32" => ArgSpec::InU32 {
248                    len: need(&raw.len, "len")?,
249                    modulo: need(&raw.modulo, "modulo")?,
250                },
251                "out_f32" => ArgSpec::OutF32 {
252                    len: need(&raw.len, "len")?,
253                },
254                "out_u32" => ArgSpec::OutU32 {
255                    len: need(&raw.len, "len")?,
256                },
257                "len_of" => ArgSpec::LenOf {
258                    of: raw
259                        .of
260                        .ok_or_else(|| PlanError::Spec(format!("args[{i}] len_of needs `of`")))?,
261                },
262                "u32" => ArgSpec::U32 {
263                    value: need(&raw.value, "value")?,
264                },
265                "u64" => ArgSpec::U64 {
266                    value: need(&raw.value, "value")?,
267                },
268                other => {
269                    return Err(PlanError::Spec(format!(
270                        "args[{i}]: unknown kind {other:?}"
271                    )));
272                }
273            };
274            args.push(arg);
275        }
276
277        Ok(Candidate {
278            id: config.id().as_str().to_string(),
279            config: config.to_string(),
280            ptx: ptx_relative.to_string(),
281            unsafe_candidate: false,
282            grid,
283            block,
284            args,
285            warmup: self.raw.warmup,
286            repeats: self.raw.repeats,
287        })
288    }
289}
290
291impl BenchPlan {
292    /// Write the plan as pretty JSON.
293    pub fn write(&self, path: &Path) -> Result<(), PlanError> {
294        let json = serde_json::to_string_pretty(self)
295            .map_err(|e| PlanError::Io(format!("serializing plan: {e}")))?;
296        std::fs::write(path, json).map_err(|e| PlanError::Io(format!("{path:?}: {e}")))
297    }
298
299    /// Read a plan, refusing any schema but `plan.v1`.
300    ///
301    /// The schema check is not a formality: the runner launches whatever a
302    /// plan tells it to, on a real device, so a document it half-understands
303    /// is worse than one it rejects.
304    pub fn load(path: &Path) -> Result<Self, PlanError> {
305        let text =
306            std::fs::read_to_string(path).map_err(|e| PlanError::Io(format!("{path:?}: {e}")))?;
307        let plan: BenchPlan =
308            serde_json::from_str(&text).map_err(|e| PlanError::Io(e.to_string()))?;
309        if plan.schema != "plan.v1" {
310            return Err(PlanError::Spec(format!(
311                "unsupported plan schema {:?}",
312                plan.schema
313            )));
314        }
315        Ok(plan)
316    }
317
318    /// Number of `.param` slots this candidate's args expand to (each
319    /// ArgSpec is exactly one slot; slices appear as explicit ptr + len_of
320    /// pairs). Used to validate against the PTX entry signature.
321    pub fn param_slots(candidate: &Candidate) -> usize {
322        candidate.args.len()
323    }
324}