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}