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