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