Skip to main content

launchbound_space/
lib.rs

1//! Configuration space model for launchbound.
2//!
3//! A kernel declares its tunable dimensions in a `kernel.toml` next to its
4//! source. This crate loads that spec, enumerates the (constraint-filtered)
5//! configuration space deterministically, and gives every configuration a
6//! canonical, stable, hashable ID. Enumeration is a pure function of the
7//! spec: same spec, same order, byte for byte.
8
9#![warn(missing_docs)]
10
11mod constraint;
12mod spec;
13
14pub use constraint::{Constraint, eval_arith_expr};
15pub use spec::{Dim, DimRole, KernelSpec, SafetyExpectation, Value};
16
17use sha2::{Digest, Sha256};
18use std::collections::BTreeMap;
19use std::fmt;
20
21/// What can go wrong loading a spec or enumerating its space.
22#[derive(Debug, thiserror::Error)]
23pub enum SpaceError {
24    /// `kernel.toml` could not be read.
25    #[error("failed to read {path}: {source}")]
26    Io {
27        /// The file that could not be read.
28        path: String,
29        /// The underlying I/O failure.
30        source: std::io::Error,
31    },
32    /// `kernel.toml` is not valid TOML.
33    #[error("failed to parse {path}: {source}")]
34    Parse {
35        /// The file that would not parse.
36        path: String,
37        /// The TOML error, boxed because it is large and this variant is
38        /// rare.
39        source: Box<toml::de::Error>,
40    },
41    /// The TOML parsed but does not describe a usable space: a bad
42    /// dimension name, an empty or duplicated value list, a block axis
43    /// above the CUDA limit, or no dimensions at all.
44    #[error("invalid kernel spec: {0}")]
45    Invalid(String),
46    /// A `[constraints]` expression could not be parsed or evaluated —
47    /// including arithmetic that would overflow or divide by zero, which
48    /// is an error rather than a verdict.
49    #[error("invalid constraint `{expr}`: {reason}")]
50    Constraint {
51        /// The expression as written in `kernel.toml`.
52        expr: String,
53        /// What was wrong with it.
54        reason: String,
55    },
56}
57
58/// One point in a kernel's configuration space: a total assignment of every
59/// declared dimension. Dimensions are kept sorted by name, which is what
60/// makes the canonical ID canonical.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Config {
63    kernel: String,
64    values: BTreeMap<String, Value>,
65}
66
67impl Config {
68    /// The kernel this configuration belongs to.
69    pub fn kernel(&self) -> &str {
70        &self.kernel
71    }
72
73    /// The value assigned to one dimension, or `None` if the spec does not
74    /// declare it.
75    pub fn get(&self, dim: &str) -> Option<&Value> {
76        self.values.get(dim)
77    }
78
79    /// Every `(dimension, value)` pair, ascending by dimension name.
80    ///
81    /// The order is the sorted order, not the declaration order, and it is
82    /// what makes [`Config::id`] canonical: two configurations that assign
83    /// the same values hash identically however their spec was written.
84    pub fn values(&self) -> impl Iterator<Item = (&str, &Value)> {
85        self.values.iter().map(|(k, v)| (k.as_str(), v))
86    }
87
88    /// Total threads per block implied by this configuration. Absent block
89    /// dimensions default to 1, matching CUDA launch semantics.
90    ///
91    /// Saturating, like [`grid_blocks`] in `launchbound-model`, and for the
92    /// same reason: the factors come from `kernel.toml`, and `.product()` over
93    /// three attacker-shaped `u64`s panics in a debug build and wraps in a
94    /// release one — where a wrapped value would then flow into `estimate` and
95    /// into the gate's `threads > WARP_SIZE` test and be believed.
96    ///
97    /// A valid spec cannot reach the saturation point: `KernelSpec` rejects a
98    /// block dimension above the CUDA per-axis limit at load, so the largest
99    /// product this can be asked for is 1024 x 1024 x 64. The saturation is
100    /// the floor under a `Config` built by some other route.
101    ///
102    /// [`grid_blocks`]: https://docs.rs/launchbound-model
103    pub fn block_threads(&self) -> u64 {
104        ["block_x", "block_y", "block_z"]
105            .iter()
106            .map(|d| match self.values.get(*d) {
107                Some(Value::Int(n)) => *n,
108                _ => 1,
109            })
110            .fold(1u64, |acc, n| acc.saturating_mul(n))
111    }
112
113    /// The canonical, stable ID: a versioned SHA-256 over the kernel name
114    /// and the sorted dimension assignments. Changing the encoding is a
115    /// breaking change and must bump the `config.v1` tag.
116    pub fn id(&self) -> ConfigId {
117        let mut hasher = Sha256::new();
118        hasher.update(b"launchbound.config.v1\0");
119        hasher.update(self.kernel.as_bytes());
120        hasher.update(b"\0");
121        for (name, value) in &self.values {
122            hasher.update(name.as_bytes());
123            hasher.update(b"=");
124            match value {
125                Value::Int(n) => hasher.update(n.to_string().as_bytes()),
126                Value::Str(s) => hasher.update(s.as_bytes()),
127            }
128            hasher.update(b"\n");
129        }
130        let digest = hasher.finalize();
131        let mut hex = String::with_capacity(16);
132        for byte in &digest[..8] {
133            hex.push_str(&format!("{byte:02x}"));
134        }
135        ConfigId(format!("c1-{hex}"))
136    }
137
138    /// Only the compile-time specialization dimensions, sorted. Candidates
139    /// sharing this key share generated source, and therefore share one
140    /// reconverge verdict and one compiled artifact.
141    pub fn spec_key(&self, spec: &KernelSpec) -> String {
142        let mut parts = Vec::new();
143        for (name, value) in &self.values {
144            if spec.dim(name).is_some_and(|d| d.role == DimRole::Spec) {
145                parts.push(format!("{name}={value}"));
146            }
147        }
148        parts.join(",")
149    }
150}
151
152impl fmt::Display for Config {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        let mut first = true;
155        for (name, value) in &self.values {
156            if !first {
157                write!(f, " ")?;
158            }
159            write!(f, "{name}={value}")?;
160            first = false;
161        }
162        Ok(())
163    }
164}
165
166/// Canonical configuration identifier, e.g. `c1-9f2a4c1e77b0d3a5`.
167#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
168pub struct ConfigId(String);
169
170impl ConfigId {
171    /// The ID as it appears in `verdicts.v1`, `plan.v1`, `results.v1` and
172    /// `report.v1` — `c1-` followed by 16 hex digits.
173    pub fn as_str(&self) -> &str {
174        &self.0
175    }
176}
177
178impl fmt::Display for ConfigId {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.write_str(&self.0)
181    }
182}
183
184/// Enumerate the full constraint-filtered space, in canonical order.
185///
186/// Order: dimensions sorted by name; values in declared order; the last
187/// dimension varies fastest (odometer). Constraints filter, never reorder.
188pub fn enumerate(spec: &KernelSpec) -> Result<Vec<Config>, SpaceError> {
189    let dims: Vec<&Dim> = spec.dims_sorted();
190    let mut out = Vec::new();
191    if dims.is_empty() {
192        return Ok(out);
193    }
194    let mut indices = vec![0usize; dims.len()];
195    'outer: loop {
196        let mut values = BTreeMap::new();
197        for (dim, &idx) in dims.iter().zip(&indices) {
198            values.insert(dim.name.clone(), dim.values[idx].clone());
199        }
200        let config = Config {
201            kernel: spec.name.clone(),
202            values,
203        };
204        if spec
205            .constraints
206            .iter()
207            .try_fold(true, |ok, c| c.eval(&config).map(|v| ok && v))?
208        {
209            out.push(config);
210        }
211        // Odometer increment, last dimension fastest.
212        for pos in (0..dims.len()).rev() {
213            indices[pos] += 1;
214            if indices[pos] < dims[pos].values.len() {
215                continue 'outer;
216            }
217            indices[pos] = 0;
218        }
219        break;
220    }
221    Ok(out)
222}
223
224/// The size of the unfiltered space (product of value counts).
225pub fn raw_size(spec: &KernelSpec) -> u64 {
226    spec.dims_sorted()
227        .iter()
228        .map(|d| d.values.len() as u64)
229        .product()
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn toy_spec() -> KernelSpec {
237        KernelSpec::from_toml_str(
238            "toy",
239            r#"
240            [kernel]
241            name = "toy"
242            entry = "toy"
243            domain = 1
244            [dims.block_x]
245            values = [32, 64, 128]
246            [dims.tile]
247            role = "spec"
248            values = [128, 256]
249            [constraints]
250            exprs = ["tile % block_x == 0"]
251            "#,
252        )
253        .unwrap()
254    }
255
256    #[test]
257    fn enumeration_is_deterministic_and_filtered() {
258        let spec = toy_spec();
259        let a = enumerate(&spec).unwrap();
260        let b = enumerate(&spec).unwrap();
261        assert_eq!(a, b);
262        // 3*2 = 6 raw; tile % block_x == 0 removes (128, tile=128)? no:
263        // 128 % 128 == 0 keeps it; removed are none for 32/64; block_x=128
264        // with tile=128 ok, tile=256 ok. Everything passes here except none.
265        assert_eq!(raw_size(&spec), 6);
266        assert_eq!(a.len(), 6);
267    }
268
269    #[test]
270    fn constraint_actually_filters() {
271        let spec = KernelSpec::from_toml_str(
272            "toy",
273            r#"
274            [kernel]
275            name = "toy"
276            entry = "toy"
277            domain = 1
278            [dims.block_x]
279            values = [32, 48]
280            [dims.tile]
281            values = [64]
282            [constraints]
283            exprs = ["tile % block_x == 0"]
284            "#,
285        )
286        .unwrap();
287        let configs = enumerate(&spec).unwrap();
288        assert_eq!(configs.len(), 1);
289        assert_eq!(configs[0].get("block_x"), Some(&Value::Int(32)));
290    }
291
292    #[test]
293    fn ids_are_stable_and_distinct() {
294        let spec = toy_spec();
295        let configs = enumerate(&spec).unwrap();
296        let ids: Vec<_> = configs.iter().map(|c| c.id()).collect();
297        let mut unique = ids.clone();
298        unique.sort();
299        unique.dedup();
300        assert_eq!(unique.len(), ids.len(), "duplicate config IDs");
301        // Golden: the first canonical config of this exact spec. If this
302        // changes, the ID encoding changed and config.v1 must be bumped.
303        let first = &configs[0];
304        assert_eq!(first.get("block_x"), Some(&Value::Int(32)));
305        assert_eq!(first.get("tile"), Some(&Value::Int(128)));
306        assert_eq!(first.id().as_str(), configs[0].id().as_str());
307        assert!(first.id().as_str().starts_with("c1-"));
308        assert_eq!(first.id().as_str().len(), 3 + 16);
309    }
310
311    #[test]
312    fn block_threads_multiplies_and_defaults() {
313        let spec = toy_spec();
314        let configs = enumerate(&spec).unwrap();
315        assert_eq!(configs[0].block_threads(), 32);
316    }
317
318    // `block_threads` folds three `kernel.toml` integers. `.product()`
319    // panicked in debug and wrapped in release, and a wrapped value went on
320    // to feed `estimate` and the gate's `threads > WARP_SIZE` test — a
321    // silently wrong launch shape, which is the one kind of wrong this
322    // project cannot ship. Saturating matches `grid_blocks`, its sibling.
323    //
324    // This constructs `Config` directly because a spec can no longer express
325    // these values: the load-time axis check rejects them. That is the point
326    // — belt and braces, and the proptest guards the braces.
327    proptest::proptest! {
328        #[test]
329        fn block_threads_never_panics_and_never_wraps(
330            x in proptest::prelude::any::<u64>(),
331            y in proptest::prelude::any::<u64>(),
332            z in proptest::prelude::any::<u64>(),
333        ) {
334            let mut values = BTreeMap::new();
335            values.insert("block_x".to_string(), Value::Int(x));
336            values.insert("block_y".to_string(), Value::Int(y));
337            values.insert("block_z".to_string(), Value::Int(z));
338            let config = Config { kernel: "proptest".to_string(), values };
339
340            let threads = config.block_threads();
341
342            if x == 0 || y == 0 || z == 0 {
343                proptest::prop_assert_eq!(threads, 0, "a zero axis is a zero block");
344            } else {
345                match x.checked_mul(y).and_then(|p| p.checked_mul(z)) {
346                    // Exact whenever the true product fits: saturating must
347                    // not change any answer that was already right.
348                    Some(exact) => proptest::prop_assert_eq!(threads, exact),
349                    // Otherwise pinned at the ceiling, never wrapped around to
350                    // a small number that would read as a legal block.
351                    None => proptest::prop_assert_eq!(threads, u64::MAX),
352                }
353            }
354        }
355    }
356
357    /// The specific value the issue names.
358    #[test]
359    fn a_block_axis_above_the_cuda_limit_is_refused_at_load() {
360        let err = KernelSpec::from_toml_str(
361            "toy",
362            r#"
363            [kernel]
364            name = "toy"
365            entry = "toy"
366            domain = 1
367            [dims.block_x]
368            values = [32, 2048]
369            "#,
370        )
371        .expect_err("block_x = 2048 must not load");
372        let msg = err.to_string();
373        assert!(
374            msg.contains("2048"),
375            "message names the offending value: {msg}"
376        );
377        assert!(msg.contains("1024"), "message names the limit: {msg}");
378    }
379
380    /// z has a different limit (64), and saying "1024" there would be wrong.
381    #[test]
382    fn the_z_axis_limit_is_sixty_four() {
383        let err = KernelSpec::from_toml_str(
384            "toy",
385            r#"
386            [kernel]
387            name = "toy"
388            entry = "toy"
389            domain = 1
390            [dims.block_z]
391            values = [65]
392            "#,
393        )
394        .expect_err("block_z = 65 must not load");
395        assert!(err.to_string().contains("64"), "{err}");
396        // And 64 itself is fine.
397        KernelSpec::from_toml_str(
398            "toy",
399            r#"
400            [kernel]
401            name = "toy"
402            entry = "toy"
403            domain = 1
404            [dims.block_z]
405            values = [64]
406            "#,
407        )
408        .expect("block_z = 64 is the limit, not past it");
409    }
410
411    /// The limit applies to launch axes only; a spec dimension may be large.
412    #[test]
413    fn a_spec_dimension_is_not_capped_by_the_block_limit() {
414        KernelSpec::from_toml_str(
415            "toy",
416            r#"
417            [kernel]
418            name = "toy"
419            entry = "toy"
420            domain = 1
421            [dims.block_x]
422            values = [32]
423            [dims.elements]
424            role = "spec"
425            values = [1048576]
426            "#,
427        )
428        .expect("a spec dimension is not a block axis");
429    }
430
431    #[test]
432    fn spec_key_covers_only_spec_dims() {
433        let spec = toy_spec();
434        let configs = enumerate(&spec).unwrap();
435        assert_eq!(configs[0].spec_key(&spec), "tile=128");
436    }
437}