Skip to main content

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