Skip to main content

cubecl_runtime/throughput/
roofline.rs

1use core::time::Duration;
2
3use alloc::vec::Vec;
4
5/// A resource's roofline record: how much of it a run must move, against the
6/// peak rate that resource can sustain.
7///
8/// Neutral by design: nothing here says whether `amount` counts bytes or
9/// operations, or where `peak_per_s` came from, measured or modeled. That is
10/// what lets [`AutotuneBound`](crate::tune::AutotuneBound) compose this with
11/// a threshold instead of reimplementing the arithmetic.
12#[derive(Debug, Clone, Copy, PartialEq)]
13#[cfg_attr(autotune_persistence, derive(serde::Serialize, serde::Deserialize))]
14pub struct ResourceBound {
15    /// How much of the resource the run must move.
16    pub amount: usize,
17    /// The peak rate this resource can sustain, in the same unit as `amount`
18    /// per second.
19    pub peak_per_s: f64,
20}
21
22impl ResourceBound {
23    /// Time `amount` would take running at `peak_per_s`, with no allowance
24    /// for anything else the kernel does concurrently.
25    ///
26    /// `None` for a `peak_per_s` that is zero, negative, `NaN`, or infinite:
27    /// none of those describe a real ceiling to divide by.
28    pub fn time_at_peak(&self) -> Option<Duration> {
29        if self.peak_per_s.is_normal() {
30            Some(Duration::from_secs_f64(
31                self.amount as f64 / self.peak_per_s,
32            ))
33        } else {
34            None
35        }
36    }
37}
38
39/// The roofline convention this module uses throughout: a run cannot finish
40/// faster than its slowest resource allows even under perfect overlap
41/// between them, so that resource is the one binding the achievable
42/// duration, whatever the others manage. [`binding_achieved`] applies the
43/// same reduction to a run's actually measured rates.
44///
45/// The resource requiring the most time even at its own peak: the entry with
46/// the largest [`ResourceBound::time_at_peak`].
47///
48/// `None` if every entry's `time_at_peak` is `None`, or `bounds` is empty.
49pub fn binding_resource(bounds: &[ResourceBound]) -> Option<&ResourceBound> {
50    bounds
51        .iter()
52        .filter(|bound| bound.time_at_peak().is_some())
53        .max_by_key(|bound| bound.time_at_peak())
54}
55
56/// One resource's achieved rate during a measured run, against its modeled
57/// peak.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct AchievedThroughput {
60    /// Achieved rate: `amount / duration`.
61    pub achieved_per_s: f64,
62    /// `achieved_per_s / peak_per_s`, as a fraction. Not clamped to `[0, 1]`:
63    /// a run beating the modeled peak is a finding about the model, not an
64    /// error to hide.
65    pub fraction_of_peak: f64,
66}
67
68/// Scores an actual run against a set of [`ResourceBound`]s, one resource at
69/// a time.
70///
71/// `duration` is the run's actual, measured duration, shared by every bound:
72/// they all describe the same execution, so the same wall time buys each
73/// resource a different achieved rate against its own peak. Results come
74/// back in `bounds` order. A zero duration yields `NaN` achieved rates rather
75/// than dividing by zero.
76pub fn score_resources(duration: Duration, bounds: &[ResourceBound]) -> Vec<AchievedThroughput> {
77    bounds
78        .iter()
79        .map(|bound| {
80            let achieved_per_s = if duration.is_zero() {
81                f64::NAN
82            } else {
83                bound.amount as f64 / duration.as_secs_f64()
84            };
85
86            AchievedThroughput {
87                achieved_per_s,
88                fraction_of_peak: achieved_per_s / bound.peak_per_s,
89            }
90        })
91        .collect()
92}
93
94/// The resource that actually governed the run's duration: [`binding_resource`]'s
95/// reduction applied to achieved scores instead of raw bounds. Every entry
96/// here shares one `duration`, so ranking by `fraction_of_peak` gives the
97/// same order ranking the underlying bounds by `time_at_peak` would.
98///
99/// `NaN` entries (a zero duration, or a zero, negative, or non-finite peak)
100/// cannot be compared and are skipped; `None` if every entry is such, or
101/// `scores` is empty.
102pub fn binding_achieved(scores: &[AchievedThroughput]) -> Option<&AchievedThroughput> {
103    scores
104        .iter()
105        .filter(|score| score.fraction_of_peak.is_finite())
106        .max_by(|a, b| a.fraction_of_peak.total_cmp(&b.fraction_of_peak))
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn bound(amount: usize, peak_per_s: f64) -> ResourceBound {
114        ResourceBound { amount, peak_per_s }
115    }
116
117    #[test]
118    fn time_at_peak_is_amount_over_peak() {
119        assert_eq!(bound(8, 4.0).time_at_peak(), Some(Duration::from_secs(2)));
120    }
121
122    #[test]
123    fn time_at_peak_is_none_for_a_non_normal_peak() {
124        assert_eq!(bound(8, 0.0).time_at_peak(), None);
125        assert_eq!(bound(8, f64::NAN).time_at_peak(), None);
126        assert_eq!(bound(8, f64::INFINITY).time_at_peak(), None);
127    }
128
129    #[test]
130    fn binding_resource_is_the_one_needing_the_most_time_at_peak() {
131        // 8 ops at 4 ops/s takes 2s at peak; 8 ops at 8 ops/s takes 1s: the
132        // first resource is the one that would still take longer even
133        // running flat out, so it is the one that binds.
134        let slower = bound(8, 4.0);
135        let faster = bound(8, 8.0);
136
137        assert_eq!(binding_resource(&[slower, faster]), Some(&slower));
138    }
139
140    #[test]
141    fn binding_resource_skips_non_normal_peaks_and_is_none_if_all_are() {
142        let unusable = bound(8, 0.0);
143        let usable = bound(8, 4.0);
144
145        assert_eq!(binding_resource(&[unusable, usable]), Some(&usable));
146        assert_eq!(binding_resource(&[unusable]), None);
147        assert_eq!(binding_resource(&[]), None);
148    }
149
150    #[test]
151    fn score_resources_reports_achieved_rate_and_fraction_of_peak() {
152        let bounds = [bound(100, 200.0), bound(400, 800.0)];
153
154        let scores = score_resources(Duration::from_secs(1), &bounds);
155
156        assert_eq!(scores[0].achieved_per_s, 100.0);
157        assert_eq!(scores[0].fraction_of_peak, 0.5);
158        assert_eq!(scores[1].achieved_per_s, 400.0);
159        assert_eq!(scores[1].fraction_of_peak, 0.5);
160    }
161
162    #[test]
163    fn a_zero_duration_reports_nan_instead_of_dividing_by_zero() {
164        let scores = score_resources(Duration::ZERO, &[bound(100, 200.0)]);
165
166        assert!(scores[0].achieved_per_s.is_nan());
167        assert!(scores[0].fraction_of_peak.is_nan());
168    }
169
170    /// A matmul-shaped run: large A/B reads against a read peak, a small C
171    /// write against a write peak. The two must score independently, and the
172    /// read, which alone would still need more time even at its own peak
173    /// (0.9s vs the write's 0.5s), must be the one that binds even though it
174    /// moves far more bytes than the write, not fewer.
175    #[test]
176    fn resources_with_different_peaks_score_independently_and_pick_the_slower_one() {
177        let duration = Duration::from_secs(1);
178        let read = bound(900_000, 1_000_000.0); // 0.9s at peak
179        let write = bound(100_000, 200_000.0); // 0.5s at peak
180
181        assert_eq!(binding_resource(&[read, write]), Some(&read));
182
183        let scores = score_resources(duration, &[read, write]);
184
185        assert_eq!(scores[0].achieved_per_s, 900_000.0);
186        assert_eq!(scores[0].fraction_of_peak, 0.9);
187        assert_eq!(scores[1].achieved_per_s, 100_000.0);
188        assert_eq!(scores[1].fraction_of_peak, 0.5);
189
190        let binding = binding_achieved(&scores).unwrap();
191        assert_eq!(binding.fraction_of_peak, 0.9);
192    }
193
194    #[test]
195    fn binding_achieved_skips_non_finite_entries_and_is_none_if_all_are() {
196        let finite = AchievedThroughput {
197            achieved_per_s: 10.0,
198            fraction_of_peak: 0.4,
199        };
200        let non_finite = AchievedThroughput {
201            achieved_per_s: f64::NAN,
202            fraction_of_peak: f64::NAN,
203        };
204
205        assert_eq!(
206            binding_achieved(&[non_finite, finite])
207                .unwrap()
208                .fraction_of_peak,
209            0.4
210        );
211        assert!(binding_achieved(&[non_finite]).is_none());
212        assert!(binding_achieved(&[]).is_none());
213    }
214}