Skip to main content

cubecl_runtime/throughput/
curve.rs

1//! Memory throughput as a function of working set size.
2//!
3//! A single bandwidth number describes one working set — in practice a large
4//! one, chosen so the interface is saturated. A kernel that touches far less
5//! than that cannot reach it no matter how it is written: there is not enough
6//! traffic in flight to keep the interface busy. Scoring such a kernel against
7//! the large-working-set figure reports good code as bad code.
8//!
9//! So the probes measure a *curve*: the same kernel at a range of sizes, from a
10//! few kilobytes to hundreds of megabytes, every point on data no earlier pass
11//! left in cache. [`MemoryCurve::ceiling_at`] answers the question a consumer
12//! actually has — what can a kernel moving this many bytes reach.
13
14use alloc::vec::Vec;
15
16use crate::throughput::{MemoryAccess, ThroughputKey, ThroughputMode, ThroughputValue};
17
18/// The smallest working set a curve is measured at, in bytes moved per pass.
19///
20/// Low enough to catch the bottom of the ramp, which is further down than it
21/// looks: on an M2 Pro the curve is already within 12% of the bus figure at
22/// 256 KiB and only falls away below that — 146 GB/s at 128 KiB, 94 at 64 KiB,
23/// 11 at 8 KiB. A sweep starting a few hundred kilobytes up would report an
24/// almost flat curve and miss the effect it exists to measure.
25pub const MIN_WORKING_SET: u64 = 8 * 1024;
26
27/// The working sets a curve is measured at: powers of two from
28/// [`MIN_WORKING_SET`] up to `cap`, which is where the device runs out of
29/// allocation.
30///
31/// Powers of two because the interesting structure is spread over orders of
32/// magnitude, not over any linear span. A `cap` below [`MIN_WORKING_SET`]
33/// yields the cap alone, so a tiny device still gets a curve rather than an
34/// empty one.
35pub fn working_set_sweep(cap: u64) -> Vec<u64> {
36    if cap < MIN_WORKING_SET {
37        return alloc::vec![cap];
38    }
39
40    let mut sizes = Vec::new();
41    let mut bytes = MIN_WORKING_SET;
42
43    while bytes <= cap {
44        sizes.push(bytes);
45        // The last doubling before overflow would wrap to zero and loop forever.
46        match bytes.checked_mul(2) {
47            Some(next) => bytes = next,
48            None => break,
49        }
50    }
51
52    sizes
53}
54
55/// One measured point of a [`MemoryCurve`].
56#[derive(PartialEq, Clone, Copy, Debug)]
57pub struct MemoryPoint {
58    /// The working set the probe ran at, in bytes moved per pass.
59    pub bytes: u64,
60    /// What the probe measured.
61    pub value: ThroughputValue,
62}
63
64/// Memory throughput measured across a range of working sets.
65///
66/// Built by sweeping one probe over [`working_set_sweep`]; ask it for the
67/// ceiling of a given working set with [`ceiling_at`](Self::ceiling_at).
68#[derive(PartialEq, Clone, Debug)]
69pub struct MemoryCurve {
70    access: MemoryAccess,
71    /// Ascending by `bytes`, deduplicated, and every rate finite and positive.
72    points: Vec<MemoryPoint>,
73}
74
75impl MemoryCurve {
76    /// Assembles a curve from points measured with `access`.
77    ///
78    /// A point with an empty working set, or a rate that isn't finite and
79    /// positive (an unsupported or failed probe), describes nothing and is
80    /// dropped; duplicated working sets keep the first point.
81    pub fn new(access: MemoryAccess, points: impl IntoIterator<Item = MemoryPoint>) -> Self {
82        let mut curve = Self {
83            access,
84            points: Vec::new(),
85        };
86
87        curve.points = points
88            .into_iter()
89            .filter(|point| {
90                let rate = curve.rate(point);
91                point.bytes > 0 && rate.is_finite() && rate > 0.0
92            })
93            .collect();
94
95        curve.points.sort_unstable_by_key(|point| point.bytes);
96        curve.points.dedup_by_key(|point| point.bytes);
97
98        curve
99    }
100
101    /// The measured points, ascending by working set.
102    pub fn points(&self) -> &[MemoryPoint] {
103        &self.points
104    }
105
106    /// The ceiling for a kernel moving `bytes`, in bytes moved per second,
107    /// interpolated between the two measured points that bracket it and clamped
108    /// to the ends of the sweep. `None` if nothing was measured.
109    ///
110    /// Interpolation is linear in `log2(bytes)`, matching the geometric spacing
111    /// the curve is sampled at; it is exact at the measured points.
112    ///
113    /// Below the smallest measured working set the answer is that point's rate,
114    /// the least the sweep saw. Above the largest it is the bus figure — that
115    /// part of the curve is flat, which is why the sweep stops there.
116    pub fn ceiling_at(&self, bytes: u64) -> Option<f64> {
117        let first = self.points.first()?;
118        let last = self.points.last()?;
119
120        if bytes <= first.bytes {
121            return Some(self.rate(first));
122        }
123        if bytes >= last.bytes {
124            return Some(self.rate(last));
125        }
126
127        // `bytes` is strictly inside the sweep, so this lands on an interior
128        // index and both neighbours exist.
129        let above = self.points.partition_point(|point| point.bytes <= bytes);
130        let (low, high) = (&self.points[above - 1], &self.points[above]);
131
132        let span = log2(high.bytes) - log2(low.bytes);
133        let weight = (log2(bytes) - log2(low.bytes)) / span;
134
135        Some(self.rate(low) + weight * (self.rate(high) - self.rate(low)))
136    }
137
138    /// What a point measured, in bytes moved per second.
139    fn rate(&self, point: &MemoryPoint) -> f64 {
140        point.value.bytes_per_s(&ThroughputKey {
141            mode: ThroughputMode::MemoryWorkingSet {
142                access: self.access,
143                bytes: point.bytes,
144            },
145        })
146    }
147}
148
149/// `log2`, piecewise-linear across each octave.
150///
151/// `f64::log2` lives in `std` and this crate is `no_std`, so the exponent comes
152/// from the bit width and the mantissa is interpolated linearly. Exact on
153/// powers of two — which is every point the sweep measures — and monotonic
154/// everywhere, which is all the interpolation needs.
155fn log2(bytes: u64) -> f64 {
156    let bytes = bytes.max(1);
157    let exponent = 63 - bytes.leading_zeros();
158    let mantissa = bytes as f64 / (1u64 << exponent) as f64;
159
160    exponent as f64 + (mantissa - 1.0)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use core::time::Duration;
167
168    const MB: u64 = 1024 * 1024;
169
170    /// A curve whose points measured the given rates.
171    fn curve(points: &[(u64, f64)]) -> MemoryCurve {
172        MemoryCurve::new(
173            MemoryAccess::Read,
174            points.iter().map(|&(bytes, bytes_per_s)| MemoryPoint {
175                bytes,
176                // The rate is `ops_count * dtype.size() / duration`, and every
177                // memory mode keys on F32.
178                value: ThroughputValue {
179                    ops_count: (bytes / 4) as usize,
180                    duration: Duration::from_secs_f64(bytes as f64 / bytes_per_s),
181                },
182            }),
183        )
184    }
185
186    #[test]
187    fn sweep_covers_powers_of_two_up_to_the_cap() {
188        let min = MIN_WORKING_SET;
189        let expected = alloc::vec![min, 2 * min, 4 * min, 8 * min];
190
191        assert_eq!(working_set_sweep(8 * min), expected);
192
193        // A cap between two powers of two stops at the last one that fits: the
194        // probe must never be asked for more than the device can allocate.
195        assert_eq!(working_set_sweep(12 * min), expected);
196
197        // Below the minimum the cap is all there is, and a curve of one point
198        // still answers queries.
199        assert_eq!(working_set_sweep(min / 4), alloc::vec![min / 4]);
200    }
201
202    #[test]
203    fn ceiling_interpolates_between_measured_points() {
204        let curve = curve(&[(MB, 100.0), (4 * MB, 200.0)]);
205
206        // The geometric midpoint of the octave span, so half the rate span.
207        assert!((curve.ceiling_at(2 * MB).unwrap() - 150.0).abs() < 1e-6);
208
209        // Measured points come back exactly, not smoothed.
210        assert!((curve.ceiling_at(MB).unwrap() - 100.0).abs() < 1e-6);
211        assert!((curve.ceiling_at(4 * MB).unwrap() - 200.0).abs() < 1e-6);
212    }
213
214    #[test]
215    fn ceiling_clamps_outside_the_sweep() {
216        let curve = curve(&[(MB, 100.0), (4 * MB, 200.0)]);
217
218        // Below the sweep: the smallest measured rate. Above it: the bus
219        // figure, since the curve is flat past saturation.
220        assert!((curve.ceiling_at(1).unwrap() - 100.0).abs() < 1e-6);
221        assert!((curve.ceiling_at(u64::MAX).unwrap() - 200.0).abs() < 1e-6);
222    }
223
224    #[test]
225    fn unusable_points_are_dropped() {
226        // A probe that never ran reports a zero duration, whose rate is NaN.
227        let curve = MemoryCurve::new(
228            MemoryAccess::Read,
229            [MemoryPoint {
230                bytes: MB,
231                value: ThroughputValue::ZERO,
232            }],
233        );
234
235        assert!(curve.points().is_empty());
236        assert_eq!(curve.ceiling_at(MB), None);
237    }
238
239    #[test]
240    fn log2_is_exact_on_powers_of_two_and_monotonic_between() {
241        assert_eq!(log2(1), 0.0);
242        assert_eq!(log2(MB), 20.0);
243        assert_eq!(log2(1 << 63), 63.0);
244        // Zero has no logarithm; the floor keeps the interpolation finite.
245        assert_eq!(log2(0), 0.0);
246
247        assert!(log2(3 * MB) > log2(2 * MB));
248        assert!(log2(3 * MB) < log2(4 * MB));
249    }
250}