cubecl_std/throughput/runners/memory_probe.rs
1use cubecl::prelude::*;
2use cubecl_core::{self as cubecl, ir::ElemType};
3use cubecl_runtime::{
4 server::Handle,
5 throughput::{DEFAULT_WORKING_SET_BYTES, MemorySpec, ThroughputError},
6};
7
8use crate::throughput::LaunchConfig;
9use cubecl_common::profile::Duration;
10
11/// Windows the pool holds. What decides whether a rewritten line is still
12/// resident is the pool's size against the last level cache, not the positions
13/// the window walks, so two is a floor a probe that cannot read that cache size
14/// can justify rather than a ratio known to be enough.
15const POOL_WINDOWS: usize = 2;
16
17/// The largest window one buffer is probed at: the default working set, or as
18/// much of the device's allocation as leaves room for the pool around it.
19pub(crate) fn window_cap(max_alloc: u64) -> u64 {
20 (DEFAULT_WORKING_SET_BYTES.min(max_alloc / POOL_WINDOWS as u64)).max(1)
21}
22
23/// The buffer geometry and launch shape a memory probe uses to move a working
24/// set of a given size in one pass.
25///
26/// Shared by the copy and read probes: both walk one line per thread per step
27/// over buffers of identical size, and both measure a small working set by
28/// moving a small *window* over a large buffer rather than by allocating a
29/// small buffer.
30///
31/// The window is what makes a small working set mean anything. A small buffer
32/// read over and over stays in cache after the first pass, so the probe would
33/// report cache bandwidth for every size below the cache and the curve would
34/// describe residency instead of size. Reading a fresh window each pass keeps
35/// the data cold — by the time the window comes back around, a whole buffer of
36/// traffic has evicted it — so what varies across the sweep is how much a pass
37/// moves, which is the thing being measured.
38///
39/// # The pool is sized against the cache, not against the window
40///
41/// A store into a line a recent pass left in the last level cache never reaches
42/// memory, so a pool the cache holds a large share of reports write traffic the
43/// bus never carried: 747 GB/s across a 672 GB/s bus on an RTX 4070 Ti SUPER,
44/// at every working set from 32 MiB up. More positions did not fix that — the
45/// 32 MiB window already walked sixteen of them — and doubling the pool did.
46#[derive(Clone, Copy, Debug)]
47pub struct MemoryProbe {
48 /// Lines of each buffer the probe walks: what the window is cold against.
49 pub pool_lines: usize,
50 /// Lines one pass moves through, per buffer. Always a fraction of
51 /// [`pool_lines`](Self::pool_lines), the top of the sweep included.
52 pub window_lines: usize,
53 /// Bytes in each buffer, which is exactly the pool. The probe kernels take
54 /// their wrap-around from `len()`, so a buffer longer than the pool would
55 /// walk them past what [`prime`] wrote.
56 pub buffer_bytes: usize,
57 /// Cubes to dispatch, which is [`LaunchConfig::cube_count`] unless the
58 /// window is too small to give every thread a line.
59 pub cube_count: usize,
60 /// Whether the probe kernels address lines as per-thread contiguous runs
61 /// rather than coalesced across threads. Set on plane-1 runtimes, where a
62 /// worker has no plane neighbour to coalesce with.
63 pub blocked: bool,
64}
65
66/// All [`MemoryProbe::sized`] needs of a device, so the sizing can be exercised
67/// without one.
68#[derive(Clone, Copy)]
69struct DeviceShape {
70 /// Bytes the device will hand out in a single allocation.
71 max_alloc: usize,
72 /// Threads per cube, and cubes, of the probe's launch.
73 cube_dim: usize,
74 cube_count: usize,
75}
76
77impl MemoryProbe {
78 /// Passes a launch carries for the window to come back to bytes a whole
79 /// pool of traffic has since evicted, which is what keeps a window smaller
80 /// than the cache cold.
81 pub fn min_iterations(&self) -> usize {
82 self.pool_lines.div_ceil(self.window_lines)
83 }
84
85 /// Sizes a probe moving `working_set` bytes per pass, split evenly across
86 /// the buffers `access` touches.
87 ///
88 /// A blocked probe pins the launch to one cube: on the backend that
89 /// addressing is for, a cube position is a loop wrapped around the whole
90 /// kernel body, `n_iter` included, so more than one turns the window
91 /// rotation between passes into a replay of the same narrow,
92 /// blocked-addressed slice instead of a walk across the buffer, and cache
93 /// serves it. A coalesced launch spreads one cube position's addresses
94 /// across the full window regardless of cube count, so it has no such
95 /// limit.
96 pub fn new(client: &Client, config: LaunchConfig, line_bytes: usize, spec: MemorySpec) -> Self {
97 let blocked = config.plane_size == 1;
98 let shape = DeviceShape {
99 max_alloc: client.properties().memory.max_page_size as usize,
100 cube_dim: config.cube_dim.num_elems() as usize,
101 cube_count: if blocked { 1 } else { config.cube_count },
102 };
103
104 Self::sized(shape, line_bytes, spec, blocked)
105 }
106
107 /// The geometry itself, with the device reduced to its allocation limit and
108 /// launch shape so the sizing can be exercised without one.
109 ///
110 /// The launch shrinks with the window instead of the window growing to fill
111 /// the launch. A small window measured with the full dispatch would either
112 /// hand many threads the same line or be padded back up to a large one, and
113 /// neither is the small-kernel behaviour the curve exists to describe: a
114 /// kernel that moves little has little in flight, and that is precisely
115 /// what limits it.
116 fn sized(shape: DeviceShape, line_bytes: usize, spec: MemorySpec, blocked: bool) -> Self {
117 let buffers = spec.access.buffers() as usize;
118 let window_bytes = (spec.bytes.min(usize::MAX as u64) as usize) / buffers;
119
120 let cap_lines = (window_cap(shape.max_alloc as u64) as usize / line_bytes).max(1);
121 let window_lines = (window_bytes / line_bytes).clamp(1, cap_lines);
122
123 let pool_lines = cap_lines * POOL_WINDOWS;
124
125 let cube_count = (window_lines / shape.cube_dim).clamp(1, shape.cube_count);
126
127 Self {
128 pool_lines,
129 window_lines,
130 buffer_bytes: pool_lines * line_bytes,
131 cube_count,
132 blocked,
133 }
134 }
135}
136
137/// Reserves a probe's buffers in the persistent pool rather than the dynamic
138/// ones.
139///
140/// An installed pool layout is sized to a workload, and refuses or caps
141/// allocations the device could host: a probe's gigabyte is no part of what it
142/// was planned for. The persistent pool is exact-fit and uncapped whatever the
143/// layout, so every buffer a probe holds is served, and none of them counts
144/// against the layout's budget or its high-water marks.
145///
146/// Only an explicit [`Client::memory_cleanup`] returns persistent memory to the
147/// device. [`measure_peak_throughput`](crate::throughput::measure_peak_throughput)
148/// runs one, and a caller building a probe kernel directly owes it.
149///
150/// # Errors
151///
152/// [`ThroughputError::Allocation`] when the device has no room for them. A
153/// reservation is only enqueued, and one that fails still leaves a handle, over
154/// which a launch does nothing: the probe would time an empty pass and cache it
155/// as the device's peak.
156pub fn reserve<const N: usize>(
157 client: &Client,
158 bytes: [usize; N],
159) -> Result<[Handle; N], ThroughputError> {
160 let handles =
161 client.memory_persistent_allocation((), |_| bytes.map(|bytes| client.empty(bytes)));
162
163 client
164 .check(&handles)
165 .map_err(|_| ThroughputError::Allocation)?;
166
167 Ok(handles)
168}
169
170/// Runs one pass and confirms it wrote `written`, before any pass is timed.
171///
172/// A launch that fails leaves its failure on the buffers it never wrote, and
173/// `sync` answers `Ok` regardless. A sample has no way to say so, and would
174/// time the launch overhead and report it as bandwidth.
175///
176/// # Errors
177///
178/// [`ThroughputError::Launch`] when the pass did not run. The cause is logged
179/// by the device where it happened.
180pub fn verify(
181 client: &Client,
182 sample: impl Fn(usize) -> Duration,
183 written: &Handle,
184) -> Result<(), ThroughputError> {
185 sample(1);
186
187 cubecl_core::future::block_on(client.sync_buffers([written]))
188 .map_err(|_| ThroughputError::Launch)
189}
190
191/// Writes every line of `handle`, once, before it is handed to a probe that
192/// only reads it.
193///
194/// A fresh allocation is backed by the same physical zero page until its
195/// first write, so every unwritten line a read-only probe visits is served
196/// from that one cached page rather than from DRAM, inflating its reported
197/// bandwidth well past the device's real ceiling. Writing real data in first
198/// gives each line its own page, the way a buffer a real kernel reads
199/// already got one from whoever produced it.
200pub fn prime(
201 client: &Client,
202 handle: &Handle,
203 pool_lines: usize,
204 config: LaunchConfig,
205 dtype: ElemType,
206) {
207 unsafe {
208 prime_buffer::launch_unchecked(
209 client,
210 CubeCount::Static(config.cube_count as u32, 1, 1),
211 config.cube_dim,
212 config.vector_size,
213 BufferArg::from_raw_parts(handle.clone(), pool_lines),
214 pool_lines,
215 dtype,
216 );
217 }
218 let _ = cubecl_core::future::block_on(client.sync());
219}
220
221#[cube(launch_unchecked)]
222fn prime_buffer<I: Numeric, N: Size>(
223 output: &mut [Vector<I, N>],
224 len: usize,
225 #[define(I)] _dtype: ElemType,
226) {
227 let stride = CUBE_DIM as usize * CUBE_COUNT;
228 let steps = len.div_ceil(stride).max(1);
229
230 for step in 0..steps {
231 let idx = ABSOLUTE_POS + step * stride;
232 if idx < len {
233 output[idx] = Vector::<I, N>::empty();
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use cubecl_runtime::throughput::MemoryAccess;
242
243 const KB: usize = 1024;
244 const MB: usize = 1024 * 1024;
245
246 /// A gigabyte of allocation, 256-thread cubes, and a full dispatch of
247 /// 2048 cubes.
248 const DEVICE: DeviceShape = DeviceShape {
249 max_alloc: 1024 * MB,
250 cube_dim: 256,
251 cube_count: 2048,
252 };
253
254 /// A probe of that device, in the 16-byte lines a `vec4` of f32 comes in.
255 fn probe(working_set: usize, access: MemoryAccess) -> MemoryProbe {
256 let spec = MemorySpec::new(access, working_set as u64);
257 MemoryProbe::sized(DEVICE, 16, spec, false)
258 }
259
260 #[test]
261 fn the_pool_stays_large_however_small_the_window() {
262 // 256 KiB of traffic, but still a gigabyte to be cold against.
263 let small = probe(256 * KB, MemoryAccess::Read);
264 assert_eq!(small.pool_lines, 1024 * MB / 16);
265 assert_eq!(small.window_lines, 256 * KB / 16);
266
267 // A copy splits its working set across two buffers, so the same traffic
268 // is half the window per buffer.
269 let copy = probe(256 * KB, MemoryAccess::Copy);
270 assert_eq!(copy.pool_lines, 1024 * MB / 16);
271 assert_eq!(copy.window_lines, 128 * KB / 16);
272 }
273
274 /// Every window is a fraction of the pool, the top of the sweep included,
275 /// so no point of the sweep measures a window rewriting its own bytes pass
276 /// after pass.
277 #[test]
278 fn no_window_stops_walking() {
279 for bytes in [256 * KB, 64 * MB, 256 * MB, 512 * MB, 8 * 1024 * MB] {
280 let probe = probe(bytes, MemoryAccess::Read);
281 assert_eq!(probe.pool_lines, 1024 * MB / 16, "at {bytes} bytes");
282 assert!(probe.window_lines < probe.pool_lines, "at {bytes} bytes");
283 assert_eq!(probe.buffer_bytes, 1024 * MB, "at {bytes} bytes");
284 }
285 }
286
287 #[test]
288 fn a_small_window_carries_the_passes_that_walk_the_pool() {
289 // 8 KiB of a gigabyte pool: 131072 passes to come back round.
290 let small = probe(8 * KB, MemoryAccess::Read);
291 assert_eq!(small.min_iterations(), 1024 * MB / (8 * KB));
292
293 // The top of the sweep still carries the passes its own walk needs.
294 let whole = probe(512 * MB, MemoryAccess::Read);
295 assert_eq!(whole.min_iterations(), 2);
296 }
297
298 #[test]
299 fn walking_the_pool_costs_the_pool_whatever_the_window() {
300 // Every pass moves one window, so the traffic a launch must carry is
301 // the pool, and a small window buys passes rather than time.
302 for bytes in [8 * KB, 256 * KB, 4 * MB, 512 * MB] {
303 let probe = probe(bytes, MemoryAccess::Read);
304 let walked = probe.min_iterations() * probe.window_lines;
305 assert_eq!(walked, probe.pool_lines, "at {bytes} bytes");
306 }
307 }
308
309 #[test]
310 fn the_window_stops_at_the_default_working_set() {
311 // The single-size probe every peak is read from asks for the default
312 // working set, and gets it whole.
313 let read = probe(512 * MB, MemoryAccess::Read);
314 assert_eq!(read.window_lines, 512 * MB / 16);
315
316 // Asking for more buys nothing: the pool has to stay larger.
317 let huge = probe(8 * 1024 * MB, MemoryAccess::Read);
318 assert_eq!(huge.window_lines, read.window_lines);
319 }
320
321 #[test]
322 fn the_launch_shrinks_with_the_window() {
323 // 16384 lines over 256-thread cubes is 64 cubes, not the full 2048:
324 // a kernel this small has that little in flight, and that is the point.
325 assert_eq!(probe(256 * KB, MemoryAccess::Read).cube_count, 64);
326
327 // A window bigger than the dispatch keeps the full launch.
328 assert_eq!(probe(512 * MB, MemoryAccess::Read).cube_count, 2048);
329
330 // And a window smaller than a single cube still dispatches one, so the
331 // kernel always has a thread to run.
332 assert_eq!(probe(64, MemoryAccess::Read).cube_count, 1);
333 }
334
335 #[test]
336 fn a_device_that_allocates_little_shrinks_the_window_with_it() {
337 // The pool is the allocation limit, and the window drops below it
338 // rather than asking for memory the device does not have.
339 let shape = DeviceShape {
340 max_alloc: 4 * MB,
341 ..DEVICE
342 };
343 let spec = MemorySpec::new(MemoryAccess::Read, 512 * MB as u64);
344 let probe = MemoryProbe::sized(shape, 16, spec, false);
345
346 assert_eq!(probe.buffer_bytes, 4 * MB);
347 assert_eq!(probe.window_lines, 2 * MB / 16);
348 }
349}