Skip to main content

cubecl_std/throughput/runners/
memory_write.rs

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