Skip to main content

gam_gpu/
calibration.rs

1use crate::device::GpuDeviceInfo;
2use crate::gpu_error::GpuError;
3use crate::policy::GpuDispatchPolicy;
4use faer::Side;
5use gam_linalg::faer_ndarray::FaerCholesky;
6use gam_runtime::warm_start::{Fingerprint, Fingerprinter};
7use ndarray::{Array1, Array2};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11use std::time::Instant;
12
13const SCHEMA_VERSION: u32 = 1;
14const CACHE_ROOT_COMPONENTS: [&str; 4] = ["gam", "gpu", "policy", "v1"];
15const GEMM_DIMS: [usize; 3] = [64, 128, 256];
16const POTRF_DIMS: [usize; 3] = [64, 128, 256];
17const XTWX_DIMS: [(usize, usize); 3] = [(2048, 32), (4096, 64), (8192, 96)];
18const GPU_WIN_RATIO: f64 = 0.95;
19
20// Pin the pre-probe admission floors to the smallest calibration measurements:
21// `crossover_flops` / `crossover_rows` can lower `gemm_min_flops` /
22// `potrf_min_p` at most to the smallest measured GEMM's flop count / smallest
23// POTRF dimension. `DispatchOp::admissible_under_any_policy` (and every
24// pre-probe size gate built on these constants) relies on that bound, so a
25// change to the measurement grid must consciously update the constants.
26const _: () = assert!(
27    2 * (GEMM_DIMS[0] as u128) * (GEMM_DIMS[0] as u128) * (GEMM_DIMS[0] as u128)
28        == GpuDispatchPolicy::MIN_CALIBRATABLE_GEMM_FLOPS
29);
30const _: () = assert!(POTRF_DIMS[0] == GpuDispatchPolicy::MIN_CALIBRATABLE_POTRF_P);
31const _: () = assert!(XTWX_DIMS[0].0 == GpuDispatchPolicy::MIN_CALIBRATABLE_ROW_KERNEL_N);
32const _: () = assert!(
33    XTWX_DIMS[0].0 * 2 == GpuDispatchPolicy::MIN_CALIBRATABLE_FUSED_KERNEL_N
34);
35
36#[derive(Clone, Debug, Serialize, Deserialize)]
37struct CachedCalibration {
38    schema_version: u32,
39    device_fingerprint: String,
40    policy: GpuDispatchPolicy,
41    measurements: Vec<MeasurementRecord>,
42}
43
44#[derive(Clone, Debug, Serialize, Deserialize)]
45struct MeasurementRecord {
46    operation: String,
47    rows: usize,
48    cols: usize,
49    inner: usize,
50    flops: usize,
51    cpu_seconds: f64,
52    gpu_seconds: f64,
53}
54
55#[derive(Clone, Debug)]
56struct Measurement {
57    operation: &'static str,
58    rows: usize,
59    cols: usize,
60    inner: usize,
61    flops: usize,
62    cpu_seconds: f64,
63    gpu_seconds: f64,
64}
65
66pub(crate) fn calibrated_policy_for_device(device: &GpuDeviceInfo) -> GpuDispatchPolicy {
67    let fingerprint = device_fingerprint(device);
68    if let Some(cached) = load_cached_policy(fingerprint) {
69        log::info!(
70            "[GPU] loaded calibrated dispatch policy for {} ({fingerprint})",
71            device.name
72        );
73        return cached;
74    }
75
76    match calibrate_device(device, fingerprint) {
77        Ok(record) => {
78            let policy = record.policy.clone();
79            store_cached_policy(fingerprint, &record);
80            policy
81        }
82        Err(err) => {
83            log::warn!(
84                "[GPU] dispatch calibration unavailable for {}: {}; using default policy",
85                device.name,
86                err
87            );
88            GpuDispatchPolicy::default()
89        }
90    }
91}
92
93fn calibrate_device(
94    device: &GpuDeviceInfo,
95    fingerprint: Fingerprint,
96) -> Result<CachedCalibration, GpuError> {
97    let mut measurements = Vec::new();
98    measurements.extend(measure_gemm(device.ordinal)?);
99    measurements.extend(measure_potrf(device.ordinal)?);
100    measurements.extend(measure_xtwx(device.ordinal)?);
101    if measurements.is_empty() {
102        return Err(GpuError::CalibrationFailed {
103            reason: "no GPU calibration measurements completed".to_string(),
104        });
105    }
106
107    let mut policy = GpuDispatchPolicy::default();
108    if let Some(flops) = crossover_flops(&measurements, "gemm", policy.gemm_min_flops) {
109        policy.gemm_min_flops = flops;
110    }
111    if let Some(flops) = crossover_flops(&measurements, "xtwx", policy.xtwx_flops_min) {
112        policy.xtwx_flops_min = flops;
113    }
114    if let Some(rows) = crossover_rows(&measurements, "xtwx", policy.xtwx_n_min) {
115        policy.xtwx_n_min = rows;
116        policy.row_kernel_min_n = rows;
117        policy.fused_kernel_min_n = rows.saturating_mul(2);
118    }
119    if let Some(p) = crossover_rows(&measurements, "potrf", policy.potrf_min_p) {
120        policy.potrf_min_p = p;
121        policy.prefer_gpu_factorization_min_p = p;
122    }
123
124    log::info!(
125        "[GPU] calibrated dispatch policy for {} ({fingerprint}) from {} measurements",
126        device.name,
127        measurements.len()
128    );
129
130    Ok(CachedCalibration {
131        schema_version: SCHEMA_VERSION,
132        device_fingerprint: fingerprint.to_hex(),
133        policy,
134        measurements: measurements
135            .into_iter()
136            .map(Measurement::into_record)
137            .collect(),
138    })
139}
140
141fn measure_gemm(ordinal: usize) -> Result<Vec<Measurement>, GpuError> {
142    let mut out = Vec::with_capacity(GEMM_DIMS.len());
143    for dim in GEMM_DIMS {
144        let a = deterministic_matrix(dim, dim, 0.13);
145        let b = deterministic_matrix(dim, dim, 0.37);
146        let cpu_seconds = time_cpu(|| a.dot(&b))?;
147        let gpu_seconds = time_gpu(|| {
148            crate::blas::gemm_on_ordinal_cuda(ordinal, a.view(), b.view(), false, false)
149        })?;
150        out.push(Measurement {
151            operation: "gemm",
152            rows: dim,
153            cols: dim,
154            inner: dim,
155            flops: 2usize
156                .saturating_mul(dim)
157                .saturating_mul(dim)
158                .saturating_mul(dim),
159            cpu_seconds,
160            gpu_seconds,
161        });
162    }
163    Ok(out)
164}
165
166fn measure_potrf(ordinal: usize) -> Result<Vec<Measurement>, GpuError> {
167    let mut out = Vec::with_capacity(POTRF_DIMS.len());
168    for dim in POTRF_DIMS {
169        let a = deterministic_spd_matrix(dim);
170        let cpu_seconds = time_gpu_result(|| {
171            a.cholesky(Side::Lower)
172                .map(|factor| factor.lower_triangular())
173                .map_err(|err| format!("cpu POTRF failed: {err}"))
174        })?;
175        let gpu_seconds =
176            time_gpu_result(|| crate::solver::cholesky_lower_on_ordinal_gpu(ordinal, a.view()))?;
177        out.push(Measurement {
178            operation: "potrf",
179            rows: dim,
180            cols: dim,
181            inner: dim,
182            flops: dim.saturating_mul(dim).saturating_mul(dim) / 3,
183            cpu_seconds,
184            gpu_seconds,
185        });
186    }
187    Ok(out)
188}
189
190fn measure_xtwx(ordinal: usize) -> Result<Vec<Measurement>, GpuError> {
191    let mut out = Vec::with_capacity(XTWX_DIMS.len());
192    for (n, p) in XTWX_DIMS {
193        let x = deterministic_matrix(n, p, 0.61);
194        let w = deterministic_weights(n);
195        let cpu_seconds = time_cpu(|| cpu_xtwx(&x, &w))?;
196        let gpu_seconds =
197            time_gpu(|| crate::blas::xt_diag_x_on_ordinal_cuda(ordinal, x.view(), w.view()))?;
198        out.push(Measurement {
199            operation: "xtwx",
200            rows: n,
201            cols: p,
202            inner: p,
203            flops: 2usize.saturating_mul(n).saturating_mul(p).saturating_mul(p),
204            cpu_seconds,
205            gpu_seconds,
206        });
207    }
208    Ok(out)
209}
210
211fn time_cpu<F>(mut f: F) -> Result<f64, GpuError>
212where
213    F: FnMut() -> Array2<f64>,
214{
215    time_gpu_result(|| Result::<Array2<f64>, GpuError>::Ok(f()))
216}
217
218fn time_gpu<F>(mut f: F) -> Result<f64, GpuError>
219where
220    F: FnMut() -> Option<Array2<f64>>,
221{
222    time_gpu_result(|| {
223        f().ok_or_else(|| GpuError::CalibrationFailed {
224            reason: "GPU calibration kernel returned no result".to_string(),
225        })
226    })
227}
228
229fn time_gpu_result<F, E>(mut f: F) -> Result<f64, GpuError>
230where
231    F: FnMut() -> Result<Array2<f64>, E>,
232    E: std::fmt::Display,
233{
234    let start = Instant::now();
235    let out = f().map_err(|err| GpuError::CalibrationFailed {
236        reason: err.to_string(),
237    })?;
238    let elapsed = start.elapsed().as_secs_f64();
239    let checksum = out.iter().fold(0.0, |acc, value| acc + value.abs());
240    if elapsed.is_finite() && elapsed > 0.0 && checksum.is_finite() {
241        Ok(elapsed)
242    } else {
243        Err(GpuError::CalibrationFailed {
244            reason: format!(
245                "invalid calibration timing/checksum: elapsed={elapsed}, checksum={checksum}"
246            ),
247        })
248    }
249}
250
251fn crossover_flops(
252    measurements: &[Measurement],
253    operation: &'static str,
254    fallback: usize,
255) -> Option<usize> {
256    crossover_measurement(measurements, operation)
257        .map(|measurement| measurement.flops.max(1))
258        .or_else(|| {
259            measurements
260                .iter()
261                .filter(|measurement| measurement.operation == operation)
262                .map(|measurement| measurement.flops)
263                .max()
264                .map(|max_seen| fallback.max(max_seen.saturating_mul(2)))
265        })
266}
267
268fn crossover_rows(
269    measurements: &[Measurement],
270    operation: &'static str,
271    fallback: usize,
272) -> Option<usize> {
273    crossover_measurement(measurements, operation)
274        .map(|measurement| measurement.rows.max(1))
275        .or_else(|| {
276            measurements
277                .iter()
278                .filter(|measurement| measurement.operation == operation)
279                .map(|measurement| measurement.rows)
280                .max()
281                .map(|max_seen| fallback.max(max_seen.saturating_mul(2)))
282        })
283}
284
285fn crossover_measurement<'a>(
286    measurements: &'a [Measurement],
287    operation: &'static str,
288) -> Option<&'a Measurement> {
289    measurements
290        .iter()
291        .filter(|measurement| measurement.operation == operation)
292        .find(|measurement| measurement.gpu_seconds <= measurement.cpu_seconds * GPU_WIN_RATIO)
293}
294
295fn deterministic_matrix(rows: usize, cols: usize, phase: f64) -> Array2<f64> {
296    Array2::from_shape_fn((rows, cols), |(row, col)| {
297        let x = (row as f64 + 1.0) * 0.017 + (col as f64 + 1.0) * 0.031 + phase;
298        x.sin() + 0.25 * (2.0 * x).cos()
299    })
300}
301
302fn deterministic_spd_matrix(dim: usize) -> Array2<f64> {
303    let a = deterministic_matrix(dim, dim, 0.89);
304    let mut spd = a.t().dot(&a);
305    for idx in 0..dim {
306        spd[[idx, idx]] += dim as f64;
307    }
308    spd
309}
310
311fn deterministic_weights(n: usize) -> Array1<f64> {
312    Array1::from_shape_fn(n, |idx| 0.5 + ((idx as f64 + 1.0) * 0.019).sin().abs())
313}
314
315fn cpu_xtwx(x: &Array2<f64>, w: &Array1<f64>) -> Array2<f64> {
316    let mut weighted = x.clone();
317    for (mut row, weight) in weighted.outer_iter_mut().zip(w.iter()) {
318        row *= *weight;
319    }
320    x.t().dot(&weighted)
321}
322
323fn load_cached_policy(fingerprint: Fingerprint) -> Option<GpuDispatchPolicy> {
324    let path = cache_path(fingerprint);
325    let bytes = fs::read(path).ok()?;
326    let record: CachedCalibration = serde_json::from_slice(&bytes).ok()?;
327    if record.schema_version == SCHEMA_VERSION && record.device_fingerprint == fingerprint.to_hex()
328    {
329        Some(record.policy)
330    } else {
331        None
332    }
333}
334
335fn store_cached_policy(fingerprint: Fingerprint, record: &CachedCalibration) {
336    let path = cache_path(fingerprint);
337    if let Some(parent) = path.parent() {
338        if let Err(err) = fs::create_dir_all(parent) {
339            log::warn!("[GPU] unable to create calibration cache dir: {err}");
340            return;
341        }
342    }
343    let tmp = path.with_extension("json.tmp");
344    let bytes = match serde_json::to_vec_pretty(record) {
345        Ok(bytes) => bytes,
346        Err(err) => {
347            log::warn!("[GPU] unable to serialize calibration cache: {err}");
348            return;
349        }
350    };
351    if let Err(err) = fs::write(&tmp, bytes).and_then(|_| fs::rename(&tmp, &path)) {
352        log::warn!("[GPU] unable to write calibration cache: {err}");
353    }
354}
355
356fn cache_path(fingerprint: Fingerprint) -> PathBuf {
357    let mut root = std::env::temp_dir();
358    for component in CACHE_ROOT_COMPONENTS {
359        root.push(component);
360    }
361    root.push(format!("{fingerprint}.json"));
362    root
363}
364
365fn device_fingerprint(device: &GpuDeviceInfo) -> Fingerprint {
366    let mut fp = Fingerprinter::new();
367    fp.absorb_tag(b"gpu-dispatch-calibration");
368    fp.absorb_u64(b"schema-version", u64::from(SCHEMA_VERSION));
369    fp.absorb_str(b"name", &device.name);
370    fp.absorb_u64(
371        b"compute-major",
372        u64::try_from(device.capability.compute_major).unwrap_or(0),
373    );
374    fp.absorb_u64(
375        b"compute-minor",
376        u64::try_from(device.capability.compute_minor).unwrap_or(0),
377    );
378    fp.absorb_u64(b"sm-count", u64::try_from(device.sm_count).unwrap_or(0));
379    fp.absorb_u64(
380        b"max-threads-per-sm",
381        u64::try_from(device.max_threads_per_sm).unwrap_or(0),
382    );
383    fp.absorb_u64(
384        b"max-shared-mem-per-block",
385        device.max_shared_mem_per_block as u64,
386    );
387    fp.absorb_u64(b"l2-cache-bytes", device.l2_cache_bytes as u64);
388    fp.absorb_u64(b"total-mem-bytes", device.total_mem_bytes as u64);
389    fp.absorb_u64(b"ecc-enabled", bool_fingerprint_value(device.ecc_enabled));
390    fp.absorb_u64(b"integrated", bool_fingerprint_value(device.integrated));
391    fp.absorb_u64(b"mig-mode", bool_fingerprint_value(device.mig_mode));
392    fp.finalize()
393}
394
395const fn bool_fingerprint_value(value: bool) -> u64 {
396    if value { 1 } else { 0 }
397}
398
399impl Measurement {
400    fn into_record(self) -> MeasurementRecord {
401        MeasurementRecord {
402            operation: self.operation.to_string(),
403            rows: self.rows,
404            cols: self.cols,
405            inner: self.inner,
406            flops: self.flops,
407            cpu_seconds: self.cpu_seconds,
408            gpu_seconds: self.gpu_seconds,
409        }
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::device::GpuCapability;
417
418    fn measurement(
419        operation: &'static str,
420        rows: usize,
421        cols: usize,
422        flops: usize,
423        cpu_seconds: f64,
424        gpu_seconds: f64,
425    ) -> Measurement {
426        Measurement {
427            operation,
428            rows,
429            cols,
430            inner: cols,
431            flops,
432            cpu_seconds,
433            gpu_seconds,
434        }
435    }
436
437    #[test]
438    fn calibration_crossover_uses_first_measured_gpu_win() {
439        let measurements = vec![
440            measurement("gemm", 64, 64, 524_288, 0.001, 0.004),
441            measurement("gemm", 128, 128, 4_194_304, 0.010, 0.009),
442            measurement("gemm", 256, 256, 33_554_432, 0.080, 0.010),
443        ];
444
445        assert_eq!(
446            crossover_flops(&measurements, "gemm", 100_000_000),
447            Some(4_194_304)
448        );
449    }
450
451    #[test]
452    fn calibration_crossover_raises_threshold_when_gpu_never_wins() {
453        let measurements = vec![
454            measurement("xtwx", 2_048, 32, 4_194_304, 0.001, 0.004),
455            measurement("xtwx", 4_096, 64, 33_554_432, 0.010, 0.040),
456            measurement("xtwx", 8_192, 96, 150_994_944, 0.080, 0.400),
457        ];
458
459        assert_eq!(
460            crossover_flops(&measurements, "xtwx", 100_000_000),
461            Some(301_989_888)
462        );
463        assert_eq!(crossover_rows(&measurements, "xtwx", 50_000), Some(50_000));
464    }
465
466    #[test]
467    fn calibration_cache_key_tracks_device_fingerprint() {
468        let device = GpuDeviceInfo {
469            ordinal: 0,
470            name: "unit-test GPU".to_string(),
471            capability: GpuCapability::from_compute_capability(8, 0),
472            sm_count: 108,
473            max_threads_per_sm: 2048,
474            max_shared_mem_per_block: 99_328,
475            l2_cache_bytes: 40 * 1024 * 1024,
476            total_mem_bytes: 80 * 1024 * 1024 * 1024,
477            free_mem_bytes: 70 * 1024 * 1024 * 1024,
478            ecc_enabled: true,
479            integrated: false,
480            mig_mode: false,
481        };
482
483        let fingerprint = device_fingerprint(&device);
484        let path = cache_path(fingerprint);
485        assert!(path.ends_with(format!("{}.json", fingerprint.to_hex())));
486        assert!(
487            path.components()
488                .map(|component| component.as_os_str().to_string_lossy().into_owned())
489                .collect::<Vec<_>>()
490                .windows(CACHE_ROOT_COMPONENTS.len())
491                .any(|window| window == CACHE_ROOT_COMPONENTS)
492        );
493    }
494}