Skip to main content

launchbound_model/
lib.rs

1//! The analytical model behind `--backend model` (S6).
2//!
3//! It estimates *relative* cost within one kernel's space from occupancy
4//! and wave count — nothing else. Its output is labelled `estimated` on
5//! every surface, and it ships only with its measured Spearman rank
6//! correlation against real hardware attached (docs/LIMITATIONS.md): the model
7//! is gated on measured quality, not on plausibility.
8
9#![warn(missing_docs)]
10
11use launchbound_space::{Config, KernelSpec, eval_arith_expr};
12use serde::Serialize;
13use std::collections::BTreeMap;
14
15/// What can go wrong estimating a candidate.
16#[derive(Debug, thiserror::Error)]
17pub enum ModelError {
18    // The known list is computed in the message, not carried in a second
19    // field: adding a field to a public enum variant is a breaking change,
20    // and 2.2.0 is a minor bump.
21    #[error("unknown compute capability {cc:?} — the model has no device table for it; known: {known}", cc = .0, known = known_capabilities())]
22    /// A `--cc` with no row in [`DEVICES`]. The message lists the
23    /// capabilities that would have worked.
24    UnknownCc(String),
25    /// The kernel's `[model]` section is missing, malformed, or names a
26    /// dimension the spec does not declare.
27    #[error("kernel.toml [model]: {0}")]
28    Spec(String),
29    /// The spec or one of its constraints did not load.
30    #[error(transparent)]
31    Space(#[from] launchbound_space::SpaceError),
32}
33
34/// Per-SM limits by compute capability.
35///
36/// Every field but `sm_count` is a **compute-capability fact**, taken from
37/// the CUDA C++ Programming Guide's "Technical Specifications per Compute
38/// Capability" table. `sm_count` is a **product fact** — two parts at the
39/// same capability differ — so each entry names the part its count came from.
40///
41/// An unknown cc is an error, never a guess: a fabricated capacity would
42/// produce an occupancy number, and an occupancy number is exactly the sort
43/// of thing a reader believes.
44#[derive(Debug, Clone, Copy)]
45pub struct DeviceParams {
46    /// The capability this row describes, as `"<major>.<minor>"` — the
47    /// string `--cc` is matched against.
48    pub cc: &'static str,
49    /// Streaming multiprocessors on the named part. Not a capability fact.
50    ///
51    /// Ranking within one kernel's space is barely sensitive to it: it enters
52    /// only through `waves = grid / (blocks_per_sm * sm_count)`, a constant
53    /// divisor that scales every candidate's cost alike, and it changes an
54    /// ordering only where the `.max(1.0)` clamp on waves bites. It matters
55    /// for reading `waves` as a number, not for choosing between candidates.
56    pub sm_count: u32,
57    /// Resident threads per SM. With `max_warps_per_sm` this is the same
58    /// fact twice — a warp is 32 threads — and a test holds them equal.
59    pub max_threads_per_sm: u32,
60    /// Resident warps per SM; the occupancy denominator.
61    pub max_warps_per_sm: u32,
62    /// Resident thread blocks per SM. Binds before the thread limit for
63    /// small blocks, which is why a 32-thread block rarely fills an SM.
64    pub max_blocks_per_sm: u32,
65    /// Statically allocatable shared memory per block, without the dynamic
66    /// opt-in. 48 KiB on every architecture here — deliberately flat.
67    pub smem_per_block_default: u64,
68    /// Shared memory per SM. Note this is the *per-SM* capacity, one KiB
69    /// above the per-block opt-in maximum on Ampere and later, where the
70    /// driver reserves 1 KiB.
71    pub smem_per_sm: u64,
72}
73
74/// Ascending by compute capability. A test enforces both the order and the
75/// internal consistency of every row.
76///
77/// # Why this is not shared with reconverge
78///
79/// reconverge's `cc.rs` carries a capability table too, and #52 asked whether
80/// a shared `simt-device-table` crate should own both. The answer for 2.2.0
81/// is no, for three reasons:
82///
83/// 1. **They answer different questions.** reconverge needs
84///    `max_per_block` — the dynamic opt-in ceiling — because RC004 asks
85///    "could this allocation ever load". This needs per-SM occupancy
86///    capacity: threads, warps, blocks and shared memory *per SM*. Only
87///    shared memory overlaps at all, and even there the numbers differ by
88///    the 1 KiB the driver reserves on Ampere and later.
89/// 2. **It would be a third pin.** A shared crate joins the lockstep set,
90///    in a project whose headline 2.2.0 issue was that the pin set went 133
91///    commits stale. Adding a pin to reduce duplication of eleven numbers is
92///    a poor trade.
93/// 3. **The drift is checkable without it.** The overlapping figures were
94///    cross-checked by hand against reconverge 0.6.0's table when these rows
95///    were written, and they agree exactly, per-SM minus the reserved KiB:
96///
97///    | cc | reconverge `max_per_block` | here `smem_per_sm` |
98///    |---|---|---|
99///    | 7.5 | 64 KiB | 64 KiB (no reservation pre-Ampere) |
100///    | 8.0 | 163 KiB | 164 KiB |
101///    | 8.6 | 99 KiB | 100 KiB |
102///    | 8.9 | 99 KiB | 100 KiB |
103///    | 9.0 | 227 KiB | 228 KiB |
104///    | 10.0 | 227 KiB | 228 KiB |
105///
106/// Revisit if a third consumer appears, or if the two tables are ever found
107/// to disagree — that would be the evidence this reasoning is wrong.
108pub const DEVICES: &[DeviceParams] = &[
109    // NVIDIA T4 (TU104, cc 7.5) — 40 SMs.
110    DeviceParams {
111        cc: "7.5",
112        sm_count: 40,
113        max_threads_per_sm: 1024,
114        max_warps_per_sm: 32,
115        max_blocks_per_sm: 16,
116        smem_per_block_default: 49_152,
117        smem_per_sm: 65_536, // 64 KiB
118    },
119    // NVIDIA A100 (GA100, cc 8.0) — 108 SMs on the 40 GB and 80 GB parts.
120    DeviceParams {
121        cc: "8.0",
122        sm_count: 108,
123        max_threads_per_sm: 2048,
124        max_warps_per_sm: 64,
125        max_blocks_per_sm: 32,
126        smem_per_block_default: 49_152,
127        smem_per_sm: 167_936, // 164 KiB
128    },
129    // NVIDIA A10G (GA102, cc 8.6) — 80 SMs.
130    DeviceParams {
131        cc: "8.6",
132        sm_count: 80,
133        max_threads_per_sm: 1536,
134        max_warps_per_sm: 48,
135        max_blocks_per_sm: 16,
136        smem_per_block_default: 49_152,
137        smem_per_sm: 102_400, // 100 KiB
138    },
139    // NVIDIA L4 (AD104, cc 8.9) — 58 SMs. The L40 is the same capability
140    // with 142; pass the one you are running on.
141    DeviceParams {
142        cc: "8.9",
143        sm_count: 58,
144        max_threads_per_sm: 1536,
145        max_warps_per_sm: 48,
146        max_blocks_per_sm: 24,
147        smem_per_block_default: 49_152,
148        smem_per_sm: 102_400, // 100 KiB
149    },
150    // NVIDIA H100 SXM5 (GH100, cc 9.0) — 132 SMs. The PCIe part has 114.
151    DeviceParams {
152        cc: "9.0",
153        sm_count: 132,
154        max_threads_per_sm: 2048,
155        max_warps_per_sm: 64,
156        max_blocks_per_sm: 32,
157        smem_per_block_default: 49_152,
158        smem_per_sm: 233_472, // 228 KiB
159    },
160    // NVIDIA B200 (GB100, cc 10.0) — 148 SMs.
161    DeviceParams {
162        cc: "10.0",
163        sm_count: 148,
164        max_threads_per_sm: 2048,
165        max_warps_per_sm: 64,
166        max_blocks_per_sm: 32,
167        smem_per_block_default: 49_152,
168        smem_per_sm: 233_472, // 228 KiB
169    },
170];
171
172/// `("8.6")` -> `(8, 6)`, for ordering and range checks. Returns `None` for
173/// anything that is not `<int>.<int>`.
174fn cc_parts(cc: &str) -> Option<(u32, u32)> {
175    let (major, minor) = cc.split_once('.')?;
176    Some((major.parse().ok()?, minor.parse().ok()?))
177}
178
179/// Look up the capacity figures for a compute capability.
180///
181/// `cc` is matched exactly against [`DEVICES`], so `"8.6"` resolves and
182/// `"8.60"`, `"86"` and `"8"` do not. An unlisted capability is an error
183/// naming the ones that would have worked — never a nearest-neighbour
184/// guess, because a fabricated capacity still yields an occupancy number
185/// and an occupancy number is the sort of thing a reader believes.
186///
187/// ```
188/// let a10g = launchbound_model::device("8.6").unwrap();
189/// assert_eq!(a10g.max_warps_per_sm, 48);
190/// assert!(launchbound_model::device("6.1").is_err());
191/// ```
192pub fn device(cc: &str) -> Result<DeviceParams, ModelError> {
193    DEVICES
194        .iter()
195        .find(|d| d.cc == cc)
196        .copied()
197        .ok_or_else(|| ModelError::UnknownCc(cc.to_string()))
198}
199
200/// The capabilities `device` will accept, ascending, for error messages.
201///
202/// Worth saying out loud rather than leaving the reader to guess: the gate
203/// (reconverge) knows more capabilities than the model does, so `prune --cc`
204/// can succeed at a value `tune --backend model --cc` refuses. That gap is
205/// real and narrower than it was, and the message is where a reader meets it.
206#[must_use]
207pub fn known_capabilities() -> String {
208    let mut ccs: Vec<&str> = DEVICES.iter().map(|d| d.cc).collect();
209    ccs.sort_by_key(|cc| cc_parts(cc));
210    ccs.join(", ")
211}
212
213/// One candidate's estimate. `cost` is a unitless relative score within a
214/// kernel's space — smaller is predicted faster. It is NOT a time.
215#[derive(Debug, Clone, Serialize)]
216pub struct Estimate {
217    /// The candidate's canonical `config.v1` ID, matching `verdicts.v1`
218    /// and `results.v1` for the same configuration.
219    pub id: String,
220    /// Human-readable dimension assignments, e.g. `block_x=128 tile=256`.
221    pub config: String,
222    /// Relative score, smaller predicted faster. Unitless, comparable only
223    /// within one kernel's space, and **not a time** — see
224    /// `docs/LIMITATIONS.md` for the measured rank correlation.
225    pub cost: f64,
226    /// Achieved occupancy in `0.0..=1.0`: resident warps over the device
227    /// maximum, capped at 1.
228    pub occupancy: f64,
229    /// Grid blocks divided by the blocks resident across all SMs, floored
230    /// at 1 — how many times the whole machine must be refilled.
231    pub waves: f64,
232    /// Static shared memory this configuration requests, in bytes.
233    pub smem_bytes: u64,
234    /// Always "estimated" (docs/LIMITATIONS.md); serialized so every surface carries it.
235    pub kind: &'static str,
236}
237
238/// Shared-memory bytes per block for a candidate: the `[model]`
239/// `smem_bytes` expression in kernel.toml, over the kernel's dimensions.
240pub fn smem_bytes(spec: &KernelSpec, config: &Config) -> Result<u64, ModelError> {
241    let path = spec.dir.join("kernel.toml");
242    let text = std::fs::read_to_string(&path)
243        .map_err(|e| ModelError::Spec(format!("{}: {e}", path.display())))?;
244    let table: toml::Value = toml::from_str(&text).map_err(|e| ModelError::Spec(e.to_string()))?;
245    let Some(expr) = table
246        .get("model")
247        .and_then(|m| m.get("smem_bytes"))
248        .and_then(|v| v.as_str())
249    else {
250        return Ok(0);
251    };
252    Ok(eval_arith_expr(expr, config, &BTreeMap::new())?)
253}
254
255/// Grid blocks for a candidate, from the [bench] grid expressions.
256fn grid_blocks(spec: &KernelSpec, config: &Config) -> Result<u64, ModelError> {
257    let path = spec.dir.join("kernel.toml");
258    let text = std::fs::read_to_string(&path)
259        .map_err(|e| ModelError::Spec(format!("{}: {e}", path.display())))?;
260    let table: toml::Value = toml::from_str(&text).map_err(|e| ModelError::Spec(e.to_string()))?;
261    let bench = table
262        .get("bench")
263        .ok_or_else(|| ModelError::Spec("no [bench] section".into()))?;
264    let elements = bench
265        .get("elements")
266        .and_then(|v| v.as_integer())
267        .unwrap_or(1) as u64;
268    let mut extra = BTreeMap::new();
269    extra.insert("elements".to_string(), elements);
270    let mut blocks = 1u64;
271    for axis in ["grid_x", "grid_y", "grid_z"] {
272        let value = match bench.get(axis) {
273            Some(toml::Value::Integer(n)) => *n as u64,
274            Some(toml::Value::String(expr)) => eval_arith_expr(expr, config, &extra)?,
275            None => 1,
276            Some(other) => return Err(ModelError::Spec(format!("{axis}: bad value {other}"))),
277        };
278        blocks = blocks.saturating_mul(value.max(1));
279    }
280    Ok(blocks)
281}
282
283/// Estimate one candidate. Model: blocks-per-SM limited by threads, smem
284/// and the block cap; cost = waves / occupancy — a candidate that needs
285/// more waves of less-occupied SMs is predicted slower.
286pub fn estimate(
287    spec: &KernelSpec,
288    config: &Config,
289    dev: &DeviceParams,
290) -> Result<Estimate, ModelError> {
291    let threads = config.block_threads().max(1);
292    let warps_per_block = threads.div_ceil(32);
293    let smem = smem_bytes(spec, config)?;
294
295    let by_threads = (dev.max_threads_per_sm as u64) / threads;
296    let by_smem = dev.smem_per_sm.checked_div(smem).unwrap_or(u64::MAX);
297    let blocks_per_sm = by_threads.min(by_smem).min(dev.max_blocks_per_sm as u64);
298
299    if blocks_per_sm == 0 || smem > dev.smem_per_block_default {
300        // Unlaunchable at this device's limits: infinite cost, not an error
301        // — the ranking must place it last, the gate refuses it elsewhere.
302        return Ok(Estimate {
303            id: config.id().as_str().to_string(),
304            config: config.to_string(),
305            cost: f64::INFINITY,
306            occupancy: 0.0,
307            waves: f64::INFINITY,
308            smem_bytes: smem,
309            kind: "estimated",
310        });
311    }
312
313    let occupancy = (blocks_per_sm * warps_per_block) as f64 / dev.max_warps_per_sm as f64;
314    let occupancy = occupancy.min(1.0);
315    let grid = grid_blocks(spec, config)? as f64;
316    let waves = (grid / (blocks_per_sm * dev.sm_count as u64) as f64).max(1.0);
317    // Work per block scales with the per-thread element count when a block
318    // covers a fixed share of the workload; within one kernel's space that
319    // is captured by waves already. Cost: waves penalized by low occupancy.
320    let cost = waves / occupancy.max(1e-6);
321
322    Ok(Estimate {
323        id: config.id().as_str().to_string(),
324        config: config.to_string(),
325        cost,
326        occupancy,
327        waves,
328        smem_bytes: smem,
329        kind: "estimated",
330    })
331}
332
333/// Spearman rank correlation between two paired samples (average ranks for
334/// ties). Returns None below 3 pairs — a correlation of two points is
335/// noise dressed up as a number.
336pub fn spearman(xs: &[f64], ys: &[f64]) -> Option<f64> {
337    if xs.len() != ys.len() || xs.len() < 3 {
338        return None;
339    }
340    let rx = ranks(xs);
341    let ry = ranks(ys);
342    let n = rx.len() as f64;
343    let mean = (n + 1.0) / 2.0;
344    let (mut num, mut dx, mut dy) = (0.0, 0.0, 0.0);
345    for (a, b) in rx.iter().zip(&ry) {
346        num += (a - mean) * (b - mean);
347        dx += (a - mean).powi(2);
348        dy += (b - mean).powi(2);
349    }
350    if dx == 0.0 || dy == 0.0 {
351        return None;
352    }
353    Some(num / (dx * dy).sqrt())
354}
355
356fn ranks(values: &[f64]) -> Vec<f64> {
357    let mut order: Vec<usize> = (0..values.len()).collect();
358    order.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
359    let mut out = vec![0.0; values.len()];
360    let mut i = 0;
361    while i < order.len() {
362        let mut j = i;
363        while j + 1 < order.len() && values[order[j + 1]] == values[order[i]] {
364            j += 1;
365        }
366        let avg_rank = (i + j) as f64 / 2.0 + 1.0;
367        for &k in &order[i..=j] {
368            out[k] = avg_rank;
369        }
370        i = j + 1;
371    }
372    out
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn spearman_perfect_and_inverse_and_ties() {
381        assert_eq!(
382            spearman(&[1.0, 2.0, 3.0, 4.0], &[10.0, 20.0, 30.0, 40.0]),
383            Some(1.0)
384        );
385        assert_eq!(
386            spearman(&[1.0, 2.0, 3.0, 4.0], &[40.0, 30.0, 20.0, 10.0]),
387            Some(-1.0)
388        );
389        assert!(spearman(&[1.0, 2.0], &[1.0, 2.0]).is_none());
390        let r = spearman(&[1.0, 1.0, 2.0, 3.0], &[5.0, 5.0, 7.0, 9.0]).unwrap();
391        assert!(r > 0.99);
392    }
393
394    // `spearman` is public and takes any `&[f64]` a caller has. Its `ranks`
395    // helper sorted with `partial_cmp(..).expect("no NaN")`, so a NaN
396    // argument -- a correlation against a column with one missing
397    // measurement, say -- took the process down from safe code. `total_cmp`
398    // orders it instead; the correlation that comes back is meaningless, but
399    // it is a value, and the caller is still running to notice.
400    #[test]
401    fn a_nan_in_either_sample_does_not_panic() {
402        let xs = [1.0, 2.0, f64::NAN, 4.0, 5.0];
403        let ys = [10.0, 20.0, 30.0, 40.0, 50.0];
404        let _ = spearman(&xs, &ys);
405        let _ = spearman(&ys, &xs);
406        let _ = spearman(&xs, &xs);
407        let both_nan = [f64::NAN; 5];
408        let _ = spearman(&both_nan, &ys);
409        // Infinities were always orderable, but they share the code path.
410        let inf = [1.0, f64::INFINITY, 3.0, f64::NEG_INFINITY, 5.0];
411        let _ = spearman(&inf, &ys);
412    }
413
414    // Ranking is still correct for ordinary input -- `total_cmp` and
415    // `partial_cmp` agree on every pair of non-NaN floats.
416    #[test]
417    fn total_cmp_did_not_change_the_ranking_of_ordinary_samples() {
418        assert_eq!(
419            spearman(&[1.0, 2.0, 3.0, 4.0], &[10.0, 20.0, 30.0, 40.0]),
420            Some(1.0)
421        );
422        assert_eq!(
423            spearman(&[3.0, 1.0, 4.0, 1.5], &[3.0, 1.0, 4.0, 1.5]),
424            Some(1.0)
425        );
426    }
427
428    /// The two capabilities the issue names, which used to be model errors.
429    #[test]
430    fn hopper_and_blackwell_are_in_the_table() {
431        let h = device("9.0").expect("cc 9.0 (Hopper) must be known");
432        assert_eq!(h.max_threads_per_sm, 2048);
433        assert_eq!(h.max_warps_per_sm, 64);
434        assert_eq!(h.max_blocks_per_sm, 32);
435        assert_eq!(h.smem_per_sm, 228 * 1024);
436
437        let b = device("10.0").expect("cc 10.0 (Blackwell) must be known");
438        assert_eq!(b.max_threads_per_sm, 2048);
439        assert_eq!(b.smem_per_sm, 228 * 1024);
440    }
441
442    /// Ordering and internal consistency of every row, so a future entry
443    /// cannot be pasted in with a transposed digit and go unnoticed. These
444    /// are the CUDA Programming Guide's documented ranges, not opinions.
445    #[test]
446    fn every_device_row_is_ordered_and_within_the_documented_ranges() {
447        let mut previous: Option<(u32, u32)> = None;
448        for d in DEVICES {
449            let parts = cc_parts(d.cc).unwrap_or_else(|| panic!("cc {:?} does not parse", d.cc));
450
451            // Ascending, and numerically: "10.0" sorts before "8.6" as a
452            // string, which is exactly the trap a naive check falls into.
453            if let Some(prev) = previous {
454                assert!(
455                    parts > prev,
456                    "DEVICES must ascend by capability: {parts:?} follows {prev:?}"
457                );
458            }
459            previous = Some(parts);
460
461            // A warp is 32 threads on every NVIDIA part that has ever
462            // shipped; the two limits are the same fact twice.
463            assert_eq!(
464                d.max_warps_per_sm * 32,
465                d.max_threads_per_sm,
466                "cc {}: {} warps x 32 != {} threads",
467                d.cc,
468                d.max_warps_per_sm,
469                d.max_threads_per_sm
470            );
471
472            assert!(
473                (1024..=2048).contains(&d.max_threads_per_sm),
474                "cc {}: threads/SM {} outside the documented 1024..=2048",
475                d.cc,
476                d.max_threads_per_sm
477            );
478            assert!(
479                (8..=32).contains(&d.max_blocks_per_sm),
480                "cc {}: blocks/SM {} outside the documented 8..=32",
481                d.cc,
482                d.max_blocks_per_sm
483            );
484
485            // Static shared memory is capped at 48 KiB per block on every
486            // architecture listed; anything above it needs the dynamic
487            // opt-in, which is a launch-time decision this model does not
488            // make. Flat, deliberately.
489            assert_eq!(
490                d.smem_per_block_default,
491                48 * 1024,
492                "cc {}: the static per-block cap is 48 KiB everywhere",
493                d.cc
494            );
495            assert!(
496                d.smem_per_sm >= d.smem_per_block_default,
497                "cc {}: an SM cannot hold less than one block's worth",
498                d.cc
499            );
500            assert!(
501                d.smem_per_sm <= 228 * 1024,
502                "cc {}: smem/SM {} above the largest documented capacity",
503                d.cc,
504                d.smem_per_sm
505            );
506
507            assert!(d.sm_count > 0, "cc {}: sm_count is a real part", d.cc);
508        }
509    }
510
511    /// Every row is reachable by the name it carries, and no capability is
512    /// listed twice — a duplicate would shadow silently, since `device`
513    /// takes the first match.
514    #[test]
515    fn every_row_is_reachable_and_unique() {
516        let mut seen = std::collections::BTreeSet::new();
517        for d in DEVICES {
518            assert!(seen.insert(d.cc), "cc {} appears twice", d.cc);
519            let found = device(d.cc).expect("a listed cc resolves");
520            assert_eq!(found.cc, d.cc);
521            assert_eq!(found.sm_count, d.sm_count);
522        }
523        assert_eq!(seen.len(), DEVICES.len());
524    }
525
526    /// The unknown-cc error names what would have worked. A reader who
527    /// mistypes `8.60` should not have to read the source to find `8.6`.
528    #[test]
529    fn an_unknown_capability_lists_the_known_ones() {
530        let err = device("11.5").expect_err("11.5 is not in the table");
531        let msg = err.to_string();
532        for cc in ["7.5", "8.0", "8.6", "8.9", "9.0", "10.0"] {
533            assert!(msg.contains(cc), "message must name {cc}: {msg}");
534        }
535        // Ascending numerically, so 10.0 comes last rather than after 8.6.
536        assert!(
537            msg.find("9.0").unwrap() < msg.find("10.0").unwrap(),
538            "known list must ascend numerically: {msg}"
539        );
540    }
541
542    #[test]
543    fn device_table_is_closed() {
544        assert!(device("8.6").is_ok());
545        assert!(device("7.5").is_ok());
546        // This used to assert on 9.0, which 2.2.0 added — the example moved,
547        // the rule did not. Pascal is deliberately out of scope (no corpus
548        // kernel targets it and nothing here has run on one), and 99.9 is
549        // not a capability at all.
550        assert!(
551            device("6.1").is_err(),
552            "an untabulated cc is an error, never a guess"
553        );
554        assert!(device("99.9").is_err());
555        // Nor is a well-formed prefix of a known one: "8" is not "8.0".
556        assert!(device("8").is_err());
557    }
558}