Skip to main content

t4a_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 even within one call.
90    let seed = I::cast_from(n_iter);
91    let mut line = Vector::<I, N>::empty();
92    let lanes = line.vector_size();
93    #[unroll]
94    for lane in 0..lanes {
95        line.insert(lane, seed + I::cast_from(lane));
96    }
97
98    // Each pass writes the *next* window of the buffer, not the same one
99    // again. A window written repeatedly would stay resident in cache after
100    // the first pass, and every working set below the cache would report
101    // cache bandwidth instead of what a kernel of that size moves; coming
102    // back to a window only after a whole buffer of traffic keeps it cold.
103    //
104    // It also keeps the addresses moving. A window small enough that every
105    // thread writes a single line would otherwise be loop-invariant, and the
106    // compiler is free to sink such a store out of the loop and perform it
107    // once, leaving the probe reporting a bandwidth the hardware never moved.
108    let mut start = 0;
109
110    for _ in 0..n_iter {
111        for step in 0..steps {
112            // Coalesced spreads one step's addresses across adjacent threads,
113            // which is only fast where those threads share a real plane. A
114            // CPU worker has no such neighbour, so it instead gets a run of
115            // `steps` lines entirely its own.
116            let base = if blocked {
117                ABSOLUTE_POS * steps + step
118            } else {
119                ABSOLUTE_POS + (step * stride)
120            };
121
122            if base < window {
123                let mut idx = start + base;
124                if idx >= len {
125                    idx -= len;
126                }
127
128                output[idx] = line;
129            }
130        }
131
132        start += window;
133        if start >= len {
134            start -= len;
135        }
136    }
137}