Skip to main content

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}