Skip to main content

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}