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