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}