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
9use launchbound_space::{Config, KernelSpec, eval_arith_expr};
10use serde::Serialize;
11use std::collections::BTreeMap;
12
13#[derive(Debug, thiserror::Error)]
14pub enum ModelError {
15    #[error("unknown compute capability {0:?} — the model has no device table for it")]
16    UnknownCc(String),
17    #[error("kernel.toml [model]: {0}")]
18    Spec(String),
19    #[error(transparent)]
20    Space(#[from] launchbound_space::SpaceError),
21}
22
23/// Per-SM limits by compute capability. Only the parts this project has
24/// measured on are listed; an unknown cc is an error, never a guess.
25#[derive(Debug, Clone, Copy)]
26pub struct DeviceParams {
27    pub cc: &'static str,
28    pub sm_count: u32,
29    pub max_threads_per_sm: u32,
30    pub max_warps_per_sm: u32,
31    pub max_blocks_per_sm: u32,
32    pub smem_per_block_default: u64,
33    pub smem_per_sm: u64,
34}
35
36pub const DEVICES: &[DeviceParams] = &[
37    // NVIDIA A10G (GA102, cc 8.6)
38    DeviceParams {
39        cc: "8.6",
40        sm_count: 80,
41        max_threads_per_sm: 1536,
42        max_warps_per_sm: 48,
43        max_blocks_per_sm: 16,
44        smem_per_block_default: 49_152,
45        smem_per_sm: 102_400,
46    },
47    // NVIDIA T4 (TU104, cc 7.5)
48    DeviceParams {
49        cc: "7.5",
50        sm_count: 40,
51        max_threads_per_sm: 1024,
52        max_warps_per_sm: 32,
53        max_blocks_per_sm: 16,
54        smem_per_block_default: 49_152,
55        smem_per_sm: 65_536,
56    },
57];
58
59pub fn device(cc: &str) -> Result<DeviceParams, ModelError> {
60    DEVICES
61        .iter()
62        .find(|d| d.cc == cc)
63        .copied()
64        .ok_or_else(|| ModelError::UnknownCc(cc.to_string()))
65}
66
67/// One candidate's estimate. `cost` is a unitless relative score within a
68/// kernel's space — smaller is predicted faster. It is NOT a time.
69#[derive(Debug, Clone, Serialize)]
70pub struct Estimate {
71    pub id: String,
72    pub config: String,
73    pub cost: f64,
74    pub occupancy: f64,
75    pub waves: f64,
76    pub smem_bytes: u64,
77    /// Always "estimated" (docs/LIMITATIONS.md); serialized so every surface carries it.
78    pub kind: &'static str,
79}
80
81/// Shared-memory bytes per block for a candidate: the `[model]`
82/// `smem_bytes` expression in kernel.toml, over the kernel's dimensions.
83pub fn smem_bytes(spec: &KernelSpec, config: &Config) -> Result<u64, ModelError> {
84    let path = spec.dir.join("kernel.toml");
85    let text = std::fs::read_to_string(&path)
86        .map_err(|e| ModelError::Spec(format!("{}: {e}", path.display())))?;
87    let table: toml::Value = toml::from_str(&text).map_err(|e| ModelError::Spec(e.to_string()))?;
88    let Some(expr) = table
89        .get("model")
90        .and_then(|m| m.get("smem_bytes"))
91        .and_then(|v| v.as_str())
92    else {
93        return Ok(0);
94    };
95    Ok(eval_arith_expr(expr, config, &BTreeMap::new())?)
96}
97
98/// Grid blocks for a candidate, from the [bench] grid expressions.
99fn grid_blocks(spec: &KernelSpec, config: &Config) -> Result<u64, ModelError> {
100    let path = spec.dir.join("kernel.toml");
101    let text = std::fs::read_to_string(&path)
102        .map_err(|e| ModelError::Spec(format!("{}: {e}", path.display())))?;
103    let table: toml::Value = toml::from_str(&text).map_err(|e| ModelError::Spec(e.to_string()))?;
104    let bench = table
105        .get("bench")
106        .ok_or_else(|| ModelError::Spec("no [bench] section".into()))?;
107    let elements = bench
108        .get("elements")
109        .and_then(|v| v.as_integer())
110        .unwrap_or(1) as u64;
111    let mut extra = BTreeMap::new();
112    extra.insert("elements".to_string(), elements);
113    let mut blocks = 1u64;
114    for axis in ["grid_x", "grid_y", "grid_z"] {
115        let value = match bench.get(axis) {
116            Some(toml::Value::Integer(n)) => *n as u64,
117            Some(toml::Value::String(expr)) => eval_arith_expr(expr, config, &extra)?,
118            None => 1,
119            Some(other) => return Err(ModelError::Spec(format!("{axis}: bad value {other}"))),
120        };
121        blocks = blocks.saturating_mul(value.max(1));
122    }
123    Ok(blocks)
124}
125
126/// Estimate one candidate. Model: blocks-per-SM limited by threads, smem
127/// and the block cap; cost = waves / occupancy — a candidate that needs
128/// more waves of less-occupied SMs is predicted slower.
129pub fn estimate(
130    spec: &KernelSpec,
131    config: &Config,
132    dev: &DeviceParams,
133) -> Result<Estimate, ModelError> {
134    let threads = config.block_threads().max(1);
135    let warps_per_block = threads.div_ceil(32);
136    let smem = smem_bytes(spec, config)?;
137
138    let by_threads = (dev.max_threads_per_sm as u64) / threads;
139    let by_smem = dev.smem_per_sm.checked_div(smem).unwrap_or(u64::MAX);
140    let blocks_per_sm = by_threads.min(by_smem).min(dev.max_blocks_per_sm as u64);
141
142    if blocks_per_sm == 0 || smem > dev.smem_per_block_default {
143        // Unlaunchable at this device's limits: infinite cost, not an error
144        // — the ranking must place it last, the gate refuses it elsewhere.
145        return Ok(Estimate {
146            id: config.id().as_str().to_string(),
147            config: config.to_string(),
148            cost: f64::INFINITY,
149            occupancy: 0.0,
150            waves: f64::INFINITY,
151            smem_bytes: smem,
152            kind: "estimated",
153        });
154    }
155
156    let occupancy = (blocks_per_sm * warps_per_block) as f64 / dev.max_warps_per_sm as f64;
157    let occupancy = occupancy.min(1.0);
158    let grid = grid_blocks(spec, config)? as f64;
159    let waves = (grid / (blocks_per_sm * dev.sm_count as u64) as f64).max(1.0);
160    // Work per block scales with the per-thread element count when a block
161    // covers a fixed share of the workload; within one kernel's space that
162    // is captured by waves already. Cost: waves penalized by low occupancy.
163    let cost = waves / occupancy.max(1e-6);
164
165    Ok(Estimate {
166        id: config.id().as_str().to_string(),
167        config: config.to_string(),
168        cost,
169        occupancy,
170        waves,
171        smem_bytes: smem,
172        kind: "estimated",
173    })
174}
175
176/// Spearman rank correlation between two paired samples (average ranks for
177/// ties). Returns None below 3 pairs — a correlation of two points is
178/// noise dressed up as a number.
179pub fn spearman(xs: &[f64], ys: &[f64]) -> Option<f64> {
180    if xs.len() != ys.len() || xs.len() < 3 {
181        return None;
182    }
183    let rx = ranks(xs);
184    let ry = ranks(ys);
185    let n = rx.len() as f64;
186    let mean = (n + 1.0) / 2.0;
187    let (mut num, mut dx, mut dy) = (0.0, 0.0, 0.0);
188    for (a, b) in rx.iter().zip(&ry) {
189        num += (a - mean) * (b - mean);
190        dx += (a - mean).powi(2);
191        dy += (b - mean).powi(2);
192    }
193    if dx == 0.0 || dy == 0.0 {
194        return None;
195    }
196    Some(num / (dx * dy).sqrt())
197}
198
199fn ranks(values: &[f64]) -> Vec<f64> {
200    let mut order: Vec<usize> = (0..values.len()).collect();
201    order.sort_by(|&a, &b| values[a].partial_cmp(&values[b]).expect("no NaN"));
202    let mut out = vec![0.0; values.len()];
203    let mut i = 0;
204    while i < order.len() {
205        let mut j = i;
206        while j + 1 < order.len() && values[order[j + 1]] == values[order[i]] {
207            j += 1;
208        }
209        let avg_rank = (i + j) as f64 / 2.0 + 1.0;
210        for &k in &order[i..=j] {
211            out[k] = avg_rank;
212        }
213        i = j + 1;
214    }
215    out
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn spearman_perfect_and_inverse_and_ties() {
224        assert_eq!(
225            spearman(&[1.0, 2.0, 3.0, 4.0], &[10.0, 20.0, 30.0, 40.0]),
226            Some(1.0)
227        );
228        assert_eq!(
229            spearman(&[1.0, 2.0, 3.0, 4.0], &[40.0, 30.0, 20.0, 10.0]),
230            Some(-1.0)
231        );
232        assert!(spearman(&[1.0, 2.0], &[1.0, 2.0]).is_none());
233        let r = spearman(&[1.0, 1.0, 2.0, 3.0], &[5.0, 5.0, 7.0, 9.0]).unwrap();
234        assert!(r > 0.99);
235    }
236
237    #[test]
238    fn device_table_is_closed() {
239        assert!(device("8.6").is_ok());
240        assert!(device("7.5").is_ok());
241        assert!(
242            device("9.0").is_err(),
243            "an unknown cc is an error, never a guess"
244        );
245    }
246}