#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppleGpuFamily {
Unknown,
M1, M1Pro, M2, M3, M4, }
impl AppleGpuFamily {
pub fn from_name(name: &str) -> Self {
let lower = name.to_lowercase();
if lower.contains("m4") {
Self::M4
} else if lower.contains("m3") {
Self::M3
} else if lower.contains("m2") {
Self::M2
} else if lower.contains("m1 pro") || lower.contains("m1 max") || lower.contains("m1 ultra")
{
Self::M1Pro
} else if lower.contains("m1") {
Self::M1
} else {
Self::Unknown
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Point {
pub chip: &'static str,
pub via: &'static str,
pub threadgroup_bytes: usize,
pub relative: f64,
}
pub const EVIDENCE: &[Point] = &[
Point {
chip: "M4 Pro",
via: "MSL",
threadgroup_bytes: 2048,
relative: 1.000,
},
Point {
chip: "M4 Pro",
via: "MSL",
threadgroup_bytes: 4096,
relative: 0.906,
},
Point {
chip: "M4 Pro",
via: "MSL",
threadgroup_bytes: 6144,
relative: 0.842,
},
Point {
chip: "M4 Pro",
via: "WGSL",
threadgroup_bytes: 4096,
relative: 1.000,
},
Point {
chip: "M4 Pro",
via: "WGSL",
threadgroup_bytes: 8192,
relative: 0.952,
},
Point {
chip: "M4 Pro",
via: "WGSL",
threadgroup_bytes: 12288,
relative: 0.811,
},
];
pub const LOSS_PER_DOUBLING: f64 = 0.095;
pub const DEAD_BAND: f64 = 0.05;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Verdict {
Faster(f64),
Slower(f64),
TooCloseToCall,
Unknown(&'static str),
}
impl Verdict {
pub fn ratio(self) -> Option<f64> {
match self {
Self::Faster(x) | Self::Slower(x) => Some(x),
_ => None,
}
}
pub fn worth_measuring(self) -> bool {
!matches!(self, Self::Slower(_))
}
}
pub fn predict(chip: AppleGpuFamily, baseline_bytes: usize, candidate_bytes: usize) -> Verdict {
match chip {
AppleGpuFamily::M4 => {}
AppleGpuFamily::Unknown => {
return Verdict::Unknown("unrecognised Apple GPU — no occupancy calibration");
}
_ => {
return Verdict::Unknown(
"occupancy is calibrated on M4 only; earlier families have different \
threadgroup-memory-per-core and were not measured",
);
}
}
if baseline_bytes == 0 || candidate_bytes == 0 {
return Verdict::Unknown("a zero threadgroup footprint has no ratio");
}
let doublings = (candidate_bytes as f64 / baseline_bytes as f64).log2();
let ratio = (1.0 - LOSS_PER_DOUBLING * doublings).max(0.0);
if (ratio - 1.0).abs() <= DEAD_BAND {
Verdict::TooCloseToCall
} else if ratio > 1.0 {
Verdict::Faster(ratio)
} else {
Verdict::Slower(ratio)
}
}
pub fn explain(chip: AppleGpuFamily, baseline_bytes: usize, candidate_bytes: usize) -> String {
match predict(chip, baseline_bytes, candidate_bytes) {
Verdict::Faster(x) => format!(
"predicted {x:.3}x ({baseline_bytes} -> {candidate_bytes} B threadgroup) — worth measuring"
),
Verdict::Slower(x) => format!(
"predicted {x:.3}x ({baseline_bytes} -> {candidate_bytes} B threadgroup) — \
occupancy cost exceeds the gain on this chip"
),
Verdict::TooCloseToCall => {
format!("within +/-{:.0}% — measure it", DEAD_BAND * 100.0)
}
Verdict::Unknown(why) => format!("no prediction: {why}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn msl(bytes: usize) -> f64 {
EVIDENCE
.iter()
.find(|p| p.via == "MSL" && p.threadgroup_bytes == bytes)
.expect("evidence point")
.relative
}
#[test]
fn the_model_reproduces_the_msl_run_it_was_fitted_on() {
for bytes in [4096usize, 6144] {
let got = predict(AppleGpuFamily::M4, 2048, bytes)
.ratio()
.expect("M4 is calibrated");
let want = msl(bytes);
assert!(
(got - want).abs() < 0.02,
"{bytes} B: model {got:.3} vs measured {want:.3}"
);
}
}
#[test]
fn the_model_predicts_the_held_out_wgsl_run() {
for (bytes, measured) in [(8192usize, 0.952), (12288usize, 0.811)] {
let got = predict(AppleGpuFamily::M4, 4096, bytes)
.ratio()
.expect("M4 is calibrated");
assert!(
(got - measured).abs() < 0.06,
"held-out {bytes} B: model {got:.3} vs measured {measured:.3} — the fit does \
not generalise across toolchains"
);
}
}
#[test]
fn the_pipelined_rotation_is_filtered_out_on_apple() {
for stages in 2..=4 {
let v = predict(AppleGpuFamily::M4, 2048, 2048 * stages);
assert!(
matches!(v, Verdict::Slower(_)),
"{stages} stages should be predicted slower on Apple, got {v:?}"
);
assert!(!v.worth_measuring());
}
}
#[test]
fn deeper_rotations_are_predicted_worse() {
let mut last = f64::INFINITY;
for stages in 1..=6 {
let r = predict(AppleGpuFamily::M4, 2048, 2048 * stages)
.ratio()
.unwrap_or(1.0);
assert!(r < last, "{stages} stages: {r:.3} not below {last:.3}");
last = r;
}
}
#[test]
fn an_uncalibrated_chip_refuses_to_predict() {
for chip in [
AppleGpuFamily::Unknown,
AppleGpuFamily::M1,
AppleGpuFamily::M3,
] {
let v = predict(chip, 2048, 6144);
assert!(matches!(v, Verdict::Unknown(_)), "{chip:?} predicted {v:?}");
assert!(v.ratio().is_none());
assert!(
v.worth_measuring(),
"{chip:?} must not filter on no evidence"
);
}
}
#[test]
fn an_unchanged_footprint_is_too_close_to_call() {
assert_eq!(
predict(AppleGpuFamily::M4, 4096, 4096),
Verdict::TooCloseToCall
);
}
#[test]
fn every_evidence_point_names_its_chip_and_toolchain() {
assert!(EVIDENCE.len() >= 6);
for p in EVIDENCE {
assert!(!p.chip.is_empty() && !p.via.is_empty());
assert!(p.threadgroup_bytes > 0);
assert!(p.relative > 0.0 && p.relative <= 1.0);
}
}
}