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_BUFFER_BYTES, MemoryAccess},
6};
7
8use crate::throughput::LaunchConfig;
9
10/// The buffer geometry and launch shape a memory probe uses to move a working
11/// set of a given size in one pass.
12///
13/// Shared by the copy and read probes: both walk one line per thread per step
14/// over buffers of identical size, and both measure a small working set by
15/// moving a small *window* over a large buffer rather than by allocating a
16/// small buffer.
17///
18/// The window is what makes a small working set mean anything. A small buffer
19/// read over and over stays in cache after the first pass, so the probe would
20/// report cache bandwidth for every size below the cache and the curve would
21/// describe residency instead of size. Reading a fresh window each pass keeps
22/// the data cold — by the time the window comes back around, a whole buffer of
23/// traffic has evicted it — so what varies across the sweep is how much a pass
24/// moves, which is the thing being measured.
25#[derive(Clone, Copy, Debug)]
26pub struct MemoryProbe {
27 /// Lines in each buffer: as much as the device will allocate, whatever the
28 /// working set, so a window has somewhere cold to come back to.
29 pub pool_lines: usize,
30 /// Lines one pass moves through, per buffer. Never more than
31 /// [`pool_lines`](Self::pool_lines); equal to it at the top of the sweep,
32 /// where the working set is the whole buffer.
33 pub window_lines: usize,
34 /// Bytes in each buffer.
35 pub buffer_bytes: usize,
36 /// Cubes to dispatch, which is [`LaunchConfig::cube_count`] unless the
37 /// window is too small to give every thread a line.
38 pub cube_count: usize,
39 /// Whether the probe kernels address lines as per-thread contiguous runs
40 /// rather than coalesced across threads. Set on plane-1 runtimes, where a
41 /// worker has no plane neighbour to coalesce with.
42 pub blocked: bool,
43}
44
45impl MemoryProbe {
46 /// Sizes a probe moving `working_set` bytes per pass, split evenly across
47 /// the buffers `access` touches.
48 ///
49 /// A blocked probe pins the launch to one cube: on the backend that
50 /// addressing is for, a cube position is a loop wrapped around the whole
51 /// kernel body, `n_iter` included, so more than one turns the window
52 /// rotation between passes into a replay of the same narrow,
53 /// blocked-addressed slice instead of a walk across the buffer, and cache
54 /// serves it. A coalesced launch spreads one cube position's addresses
55 /// across the full window regardless of cube count, so it has no such
56 /// limit.
57 pub fn new<R: Runtime>(
58 client: &ComputeClient<R>,
59 config: LaunchConfig,
60 line_bytes: usize,
61 access: MemoryAccess,
62 working_set: usize,
63 ) -> Self {
64 let max_alloc = client.properties().memory.max_page_size as usize;
65 let blocked = config.plane_size == 1;
66 let cube_count = if blocked { 1 } else { config.cube_count };
67
68 Self::sized(
69 max_alloc,
70 config.cube_dim,
71 cube_count,
72 line_bytes,
73 access,
74 working_set,
75 blocked,
76 )
77 }
78
79 /// The geometry itself, with the device reduced to its allocation limit and
80 /// launch shape so the sizing can be exercised without one.
81 ///
82 /// The launch shrinks with the window instead of the window growing to fill
83 /// the launch. A small window measured with the full dispatch would either
84 /// hand many threads the same line or be padded back up to a large one, and
85 /// neither is the small-kernel behaviour the curve exists to describe: a
86 /// kernel that moves little has little in flight, and that is precisely
87 /// what limits it.
88 fn sized(
89 max_alloc: usize,
90 cube_dim: usize,
91 cube_count: usize,
92 line_bytes: usize,
93 access: MemoryAccess,
94 working_set: usize,
95 blocked: bool,
96 ) -> Self {
97 let buffers = access.buffers() as usize;
98
99 // As large as allowed regardless of the working set: the pool is what
100 // the window is cold against.
101 let pool_bytes = max_alloc.min(DEFAULT_BUFFER_BYTES as usize);
102 let pool_lines = (pool_bytes / line_bytes).max(1);
103
104 let window_bytes = working_set / buffers;
105 let window_lines = (window_bytes / line_bytes).max(1).min(pool_lines);
106
107 let cube_count = (window_lines / cube_dim).clamp(1, cube_count);
108
109 Self {
110 pool_lines,
111 window_lines,
112 buffer_bytes: pool_lines * line_bytes,
113 cube_count,
114 blocked,
115 }
116 }
117}
118
119/// Writes every line of `handle`, once, before it is handed to a probe that
120/// only reads it.
121///
122/// A fresh allocation is backed by the same physical zero page until its
123/// first write, so every unwritten line a read-only probe visits is served
124/// from that one cached page rather than from DRAM, inflating its reported
125/// bandwidth well past the device's real ceiling. Writing real data in first
126/// gives each line its own page, the way a buffer a real kernel reads
127/// already got one from whoever produced it.
128pub fn prime<R: Runtime>(
129 client: &ComputeClient<R>,
130 handle: &Handle,
131 pool_lines: usize,
132 config: LaunchConfig,
133 dtype: ElemType,
134) {
135 unsafe {
136 prime_buffer::launch_unchecked(
137 client,
138 CubeCount::Static(config.cube_count as u32, 1, 1),
139 CubeDim::new(client, config.cube_dim),
140 config.vector_size,
141 BufferArg::from_raw_parts(handle.clone(), pool_lines),
142 pool_lines,
143 dtype,
144 );
145 }
146 let _ = cubecl_core::future::block_on(client.sync());
147}
148
149#[cube(launch_unchecked)]
150fn prime_buffer<I: Numeric, N: Size>(
151 output: &mut [Vector<I, N>],
152 len: usize,
153 #[define(I)] _dtype: ElemType,
154) {
155 let stride = CUBE_DIM as usize * CUBE_COUNT;
156 let steps = len.div_ceil(stride).max(1);
157
158 for step in 0..steps {
159 let idx = ABSOLUTE_POS + step * stride;
160 if idx < len {
161 output[idx] = Vector::<I, N>::empty();
162 }
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 const KB: usize = 1024;
171 const MB: usize = 1024 * 1024;
172
173 fn probe(working_set: usize, access: MemoryAccess) -> MemoryProbe {
174 // 16-byte lines and 256-thread cubes, as a device with `vec4` f32 and a
175 // full dispatch of 2048 cubes reports.
176 MemoryProbe::sized(512 * MB, 256, 2048, 16, access, working_set, false)
177 }
178
179 #[test]
180 fn the_pool_stays_large_however_small_the_window() {
181 // 256 KiB of traffic, but still half a gigabyte to be cold against.
182 let small = probe(256 * KB, MemoryAccess::Read);
183 assert_eq!(small.buffer_bytes, 512 * MB);
184 assert_eq!(small.window_lines, 256 * KB / 16);
185
186 // A copy splits its working set across two buffers, so the same traffic
187 // is half the window per buffer.
188 let copy = probe(256 * KB, MemoryAccess::Copy);
189 assert_eq!(copy.buffer_bytes, 512 * MB);
190 assert_eq!(copy.window_lines, 128 * KB / 16);
191 }
192
193 #[test]
194 fn the_window_fills_the_pool_at_the_top_of_the_sweep() {
195 // The default working set is the whole buffer, where the window has
196 // nowhere to move and the probe is the single-size one.
197 let read = probe(512 * MB, MemoryAccess::Read);
198 assert_eq!(read.window_lines, read.pool_lines);
199
200 // And a window can never exceed the pool, whatever it is asked for.
201 let huge = probe(8 * 1024 * MB, MemoryAccess::Read);
202 assert_eq!(huge.window_lines, huge.pool_lines);
203 }
204
205 #[test]
206 fn the_launch_shrinks_with_the_window() {
207 // 16384 lines over 256-thread cubes is 64 cubes, not the full 2048:
208 // a kernel this small has that little in flight, and that is the point.
209 assert_eq!(probe(256 * KB, MemoryAccess::Read).cube_count, 64);
210
211 // A window bigger than the dispatch keeps the full launch.
212 assert_eq!(probe(512 * MB, MemoryAccess::Read).cube_count, 2048);
213
214 // And a window smaller than a single cube still dispatches one, so the
215 // kernel always has a thread to run.
216 assert_eq!(probe(64, MemoryAccess::Read).cube_count, 1);
217 }
218
219 #[test]
220 fn a_device_that_allocates_little_shrinks_the_pool_with_it() {
221 // The pool is the allocation limit, and the window follows it down
222 // rather than asking for memory the device does not have.
223 let probe = MemoryProbe::sized(4 * MB, 256, 2048, 16, MemoryAccess::Read, 512 * MB, false);
224
225 assert_eq!(probe.buffer_bytes, 4 * MB);
226 assert_eq!(probe.window_lines, probe.pool_lines);
227 }
228}