cubecl_std/throughput/runners/memory_write.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 write-only streaming kernel, moving `working_set` bytes per
12/// pass, all of them written.
13///
14/// This is [`memory_direct`](super::memory_direct) with the load removed. The
15/// copy kernel moves a line in and a line back out, and counts both
16/// directions in `ops_count`, so what it reports is total traffic across the
17/// memory interface. That is the right ceiling for a kernel that also reads
18/// what it writes, and the wrong one for a kernel that only writes: an RNG
19/// fill, a memset, a broadcast. Those legitimately exceed the copy figure,
20/// because half of the copy's traffic is a direction they never use.
21///
22/// Reported `ops_count` is the write count alone.
23pub fn build_kernel(
24 client: &Client,
25 key: ThroughputKey,
26 config: LaunchConfig,
27 spec: MemorySpec,
28) -> Result<KernelConfig, ThroughputError> {
29 let client = client.clone();
30 let dtype = key.dtype();
31
32 let line_bytes = config.vector_size * dtype.size();
33 let probe = MemoryProbe::new(&client, config, line_bytes, spec);
34
35 let [out_handle] = memory_probe::reserve(&client, [probe.buffer_bytes])?;
36
37 let (verifier, written) = (client.clone(), out_handle.clone());
38 let sample = Box::new(move |iterations: usize| {
39 let start = cubecl_common::profile::Instant::now();
40 unsafe {
41 memory_write_throughput::launch_unchecked(
42 &client,
43 CubeCount::Static(probe.cube_count as u32, 1, 1),
44 config.cube_dim,
45 config.vector_size,
46 BufferArg::from_raw_parts(out_handle.clone(), probe.pool_lines),
47 probe.window_lines,
48 iterations,
49 probe.blocked,
50 dtype,
51 )
52 };
53 // A failure is not this sync's to report: `verify` asked the output.
54 let _ = cubecl_core::future::block_on(client.sync());
55 start.elapsed()
56 });
57 verify(&verifier, &sample, &written)?;
58
59 // Writes only, no `2 *`. That factor is the whole difference from the copy.
60 let ops_count = probe.window_lines * config.vector_size;
61
62 Ok(KernelConfig {
63 sample,
64 ops_count,
65 min_iterations: probe.min_iterations(),
66 })
67}
68
69#[cube(launch_unchecked)]
70pub fn memory_write_throughput<I: Numeric, N: Size>(
71 output: &mut [Vector<I, N>],
72 window: usize,
73 n_iter: usize,
74 #[comptime] blocked: bool,
75 #[define(I)] _dtype: ElemType,
76) {
77 let len = output.len();
78 let stride = CUBE_DIM as usize * CUBE_COUNT;
79
80 // From `window` alone rather than from `window - ABSOLUTE_POS`, which
81 // underflows for a thread past the end of a window smaller than the launch.
82 // High threads get one step too many and the bounds check drops it.
83 let steps = window.div_ceil(stride).max(1);
84
85 // Read once, write everywhere: the reverse of `memory_read`, which reads
86 // everywhere and writes once. `n_iter` is a scalar the kernel compiler
87 // sees only at launch time, so it cannot fold the stores into a single
88 // known-pattern fill, and the per-lane offset keeps the written line from
89 // being a uniform value within one line; each store then offsets it by its
90 // own index, so no two neighbouring lines repeat (see the store).
91 let seed = I::cast_from(n_iter);
92 let mut line = Vector::<I, N>::empty();
93 let lanes = line.vector_size();
94 #[unroll]
95 for lane in 0..lanes {
96 line.insert(lane, seed + I::cast_from(lane));
97 }
98
99 // Each pass writes the *next* window of the buffer, not the same one
100 // again. A window written repeatedly would stay resident in cache after
101 // the first pass, and every working set below the cache would report
102 // cache bandwidth instead of what a kernel of that size moves; coming
103 // back to a window only after a whole buffer of traffic keeps it cold.
104 //
105 // It also keeps the addresses moving. A window small enough that every
106 // thread writes a single line would otherwise be loop-invariant, and the
107 // compiler is free to sink such a store out of the loop and perform it
108 // once, leaving the probe reporting a bandwidth the hardware never moved.
109 let mut start = 0;
110 // The cubes each cube has moved along the launch, once per walk of the
111 // buffer, so a line comes back only after a whole buffer of traffic (see
112 // `memory_probe::moved_pos`).
113 let mut moved = 0usize;
114
115 for _ in 0..n_iter {
116 let pos = memory_probe::moved_pos(moved);
117 for step in 0..steps {
118 // Coalesced spreads one step's addresses across adjacent threads,
119 // which is only fast where those threads share a real plane. A
120 // CPU worker has no such neighbour, so it instead gets a run of
121 // `steps` lines entirely its own.
122 let base = if blocked {
123 ABSOLUTE_POS * steps + step
124 } else {
125 pos + (step * stride)
126 };
127
128 if base < window {
129 let mut idx = start + base;
130 if idx >= len {
131 idx -= len;
132 }
133
134 // Every line holds its own value. Lines that all repeat one
135 // value do not all reach memory: an RTX PRO 6000 reported
136 // 3.8 TB/s writing them on a 1.8 TB/s bus, and 1.65 TB/s once
137 // each line differed. A multiplicative hash of the index
138 // spreads neighbouring lines apart at the cost of one integer
139 // multiply, which the store's latency hides.
140 let scrambled = u32::cast_from(idx) * 2654435761u32;
141 output[idx] = line + Vector::cast_from(I::cast_from(scrambled));
142 }
143 }
144
145 start += window;
146 if start >= len {
147 start -= len;
148 moved = memory_probe::moved_further(moved);
149 }
150 }
151}