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}