Skip to main content

cubecl_std/throughput/runners/
memory_read.rs

1use cubecl::prelude::*;
2use cubecl_core as cubecl;
3use cubecl_runtime::throughput::{KernelConfig, MemorySpec, ThroughputError, ThroughputKey};
4
5use crate::throughput::{
6    LaunchConfig,
7    memory_probe::{self, MemoryProbe},
8};
9
10/// Builds the read-only streaming kernel, moving `working_set` bytes per pass,
11/// all of them read.
12///
13/// This is [`memory_direct`](super::memory_direct) with the store removed. The
14/// copy kernel moves a line in and a line back out, and counts both directions
15/// in `ops_count`, so what it reports is total traffic across the memory
16/// interface. That is the right ceiling for a kernel that also writes what it
17/// reads, and the wrong one for a kernel that only reads — a weight stream, a
18/// reduction, a gather. Those legitimately exceed the copy figure, because half
19/// of the copy's traffic is a direction they never use.
20///
21/// Reported `ops_count` is the read count alone. Exactly one line is written,
22/// by one thread, to keep the loads from being eliminated (see the kernel); at
23/// hundreds of megabytes read that is not worth counting and is deliberately
24/// left out of `ops_count` rather than approximated.
25pub fn build_kernel(
26    client: &Client,
27    key: ThroughputKey,
28    config: LaunchConfig,
29    spec: MemorySpec,
30) -> Result<KernelConfig, ThroughputError> {
31    let client = client.clone();
32    let dtype = key.dtype();
33
34    let line_bytes = config.vector_size * dtype.size();
35    let probe = MemoryProbe::new(&client, config, line_bytes, spec);
36
37    // One line out: the kernel writes from a single thread, only to anchor the reads.
38    let [in_handle, out_handle] = memory_probe::reserve(&client, [probe.buffer_bytes, line_bytes])?;
39    memory_probe::prime(&client, &in_handle, probe.pool_lines, config, dtype);
40
41    let (verifier, written) = (client.clone(), out_handle.clone());
42    let sample = Box::new(move |iterations: usize| {
43        let start = cubecl_common::profile::Instant::now();
44        unsafe {
45            memory_read_throughput::launch_unchecked(
46                &client,
47                CubeCount::Static(probe.cube_count as u32, 1, 1),
48                config.cube_dim,
49                config.vector_size,
50                BufferArg::from_raw_parts(in_handle.clone(), probe.pool_lines),
51                BufferArg::from_raw_parts(out_handle.clone(), 1),
52                probe.window_lines,
53                iterations,
54                probe.blocked,
55                dtype,
56            )
57        };
58        // A failure is not this sync's to report: `verify` asked the output.
59        let _ = cubecl_core::future::block_on(client.sync());
60        start.elapsed()
61    });
62    memory_probe::verify(&verifier, &sample, &written)?;
63
64    // Reads only — no `2 *`. That factor is the whole difference from the copy.
65    let ops_count = probe.window_lines * config.vector_size;
66
67    Ok(KernelConfig {
68        sample,
69        ops_count,
70        min_iterations: probe.min_iterations(),
71    })
72}
73
74#[cube(launch_unchecked)]
75pub fn memory_read_throughput<I: Numeric, N: Size>(
76    input: &[Vector<I, N>],
77    output: &mut [Vector<I, N>],
78    window: usize,
79    n_iter: usize,
80    #[comptime] blocked: bool,
81    #[define(I)] _dtype: ElemType,
82) {
83    let len = input.len();
84    let stride = CUBE_DIM as usize * CUBE_COUNT;
85
86    // From `window` alone rather than from `window - ABSOLUTE_POS`, which
87    // underflows for a thread past the end of a window smaller than the launch.
88    // High threads get one step too many and the bounds check drops it.
89    let steps = window.div_ceil(stride).max(1);
90
91    // Sum what is read. A load whose result is never used is dead code, and a
92    // compiler that removes it turns this into a launch-overhead measurement
93    // reporting an absurd bandwidth — so the reads have to reach an observable.
94    let mut acc = Vector::<I, N>::empty();
95    let lanes = acc.vector_size();
96    #[unroll]
97    for lane in 0..lanes {
98        acc.insert(lane, I::cast_from(0));
99    }
100
101    // One accumulator, unlike `compute_direct`'s four. That kernel needs
102    // independent chains because it is ALU-bound and would otherwise stall on
103    // add latency; here the adds are free next to memory latency, and the
104    // hiding comes from thread-level parallelism — `cube_count * cube_dim`
105    // threads each with an independent address.
106    //
107    // Each pass reads the *next* window of the buffer, not the same one again.
108    // A window read repeatedly would be served from cache after the first pass,
109    // and every working set below the cache would report cache bandwidth
110    // instead of what a kernel of that size moves; coming back to a window only
111    // after a whole buffer of traffic keeps it cold.
112    //
113    // It also keeps the addresses moving. A window small enough that every
114    // thread reads a single line would otherwise be loop-invariant, and the
115    // compiler is free to hoist such a load out of the loop — leaving the probe
116    // reporting the speed of adding a register to itself.
117    let mut start = 0;
118
119    for _ in 0..n_iter {
120        for step in 0..steps {
121            // Coalesced spreads one step's addresses across adjacent threads,
122            // which is only fast where those threads share a real plane. A
123            // CPU worker has no such neighbour, so it instead gets a run of
124            // `steps` lines entirely its own.
125            let base = if blocked {
126                ABSOLUTE_POS * steps + step
127            } else {
128                ABSOLUTE_POS + (step * stride)
129            };
130
131            if base < window {
132                let mut idx = start + base;
133                if idx >= len {
134                    idx -= len;
135                }
136
137                acc += input[idx];
138            }
139        }
140
141        start += window;
142        if start >= len {
143            start -= len;
144        }
145    }
146
147    // Guarded so the store cannot be hoisted out of the loop, and so the write
148    // traffic is one line rather than one per thread. The compiler cannot prove
149    // any given thread is not thread 0, so no thread's loads are dead.
150    if ABSOLUTE_POS == 0 {
151        output[0] = acc;
152    }
153}