cubecl_runtime/throughput/base.rs
1use crate::throughput::{CmmaDims, ComputeCmmaConfig};
2use alloc::{format, string::String};
3use core::time::Duration;
4use cubecl_ir::{ElemType, FloatKind};
5
6/// Bytes per buffer of the single-size memory probes, [`ThroughputMode::Memory`],
7/// [`ThroughputMode::MemoryRead`], and [`ThroughputMode::MemoryWrite`]. Clamped
8/// to the device's maximum allocation when the probe runs.
9pub const DEFAULT_BUFFER_BYTES: u64 = 512 * 1024 * 1024;
10
11/// Which directions of traffic a memory probe issues.
12#[derive(Eq, PartialEq, Clone, Hash, Debug, Copy)]
13#[cfg_attr(std_io, derive(serde::Serialize, serde::Deserialize))]
14pub enum MemoryAccess {
15 /// Reads every byte and writes it back out. The ceiling for a kernel that
16 /// both loads and stores.
17 Copy,
18 /// Reads only, storing nothing. The ceiling for a kernel that streams data
19 /// it does not write back — a weight stream, a reduction, a gather. Such a
20 /// kernel legitimately exceeds [`Copy`](Self::Copy), because half of the
21 /// copy's traffic is a direction it never uses.
22 Read,
23 /// Writes only, reading nothing at the software level. The ceiling for a
24 /// kernel that streams stores it never reads back: an RNG fill, a memset,
25 /// a broadcast. Ordinary stores still carry read-for-ownership traffic on
26 /// cache-coherent hardware, and this probe's stores do too, which is what
27 /// makes it the honest ceiling for a kernel that uses ordinary stores
28 /// rather than a non-temporal one.
29 Write,
30}
31
32impl MemoryAccess {
33 /// How many buffers of equal size one pass touches: two for a copy (one in,
34 /// one out), one for a read or a write.
35 pub const fn buffers(&self) -> u64 {
36 match self {
37 Self::Copy => 2,
38 Self::Read | Self::Write => 1,
39 }
40 }
41
42 /// The working set of the single-size probe for this access, in bytes moved
43 /// per pass: [`DEFAULT_BUFFER_BYTES`] per buffer touched.
44 pub const fn default_working_set(&self) -> u64 {
45 DEFAULT_BUFFER_BYTES * self.buffers()
46 }
47}
48
49/// Represents the mode of a throughput computation.
50#[derive(Eq, PartialEq, Clone, Hash, Debug, Copy)]
51#[cfg_attr(std_io, derive(serde::Serialize, serde::Deserialize))]
52pub enum ThroughputMode {
53 /// Compute direct calculation without special hardware acceleration.
54 ComputeDirect {
55 /// The data type of the computation.
56 dtype: ElemType,
57 },
58 /// Compute cmma calculation with CMMA hardware acceleration.
59 ComputeCmma {
60 /// The data type of the computation.
61 dtype: ElemType,
62 /// The configuration of the CMMA operation.
63 config: ComputeCmmaConfig,
64 },
65 /// Memory input reads and output writes — a copy, at the default working
66 /// set. `ops_count` counts both directions, so this is total traffic across
67 /// the memory interface.
68 Memory,
69 /// Memory input reads only, no store, at the default working set. The
70 /// ceiling for a kernel that streams data it does not write back — a weight
71 /// stream, a reduction, a gather. Such a kernel can legitimately exceed
72 /// [`Memory`](Self::Memory), which is why it needs its own probe rather than
73 /// a correction factor.
74 MemoryRead,
75 /// Memory output writes only, no read, at the default working set. The
76 /// ceiling for a kernel that streams stores it never reads back: an RNG
77 /// fill, a memset, a broadcast. Such a kernel can legitimately exceed
78 /// [`Memory`](Self::Memory), which is why it needs its own probe rather
79 /// than a correction factor.
80 MemoryWrite,
81 /// One point of a memory curve: `access` over a working set of exactly
82 /// `bytes`.
83 ///
84 /// `bytes` is the traffic one pass moves across the interface, the same
85 /// currency the measured rate is in — a [`Copy`](MemoryAccess::Copy) splits
86 /// it evenly between two buffers of `bytes / 2`, a
87 /// [`Read`](MemoryAccess::Read) moves all of it out of one buffer of
88 /// `bytes`. So a kernel that touches N bytes asks about N regardless of
89 /// which access it resembles.
90 ///
91 /// The probe reads cold: every pass moves to a fresh window of a buffer far
92 /// larger than the working set, so a small size measures what a kernel that
93 /// size moves rather than the speed of re-reading something already in
94 /// cache. A size too small to keep the interface busy therefore reports
95 /// less than the hardware can do, which is not an error — it is the ceiling
96 /// for a kernel with that little in flight.
97 ///
98 /// Sweeping this over a range of sizes is
99 /// [`MemoryCurve`](crate::throughput::MemoryCurve).
100 MemoryWorkingSet {
101 /// Which directions of traffic the probe issues.
102 access: MemoryAccess,
103 /// The bytes moved in one pass.
104 bytes: u64,
105 },
106 /// Launch overhead measurement.
107 Launch,
108}
109
110impl ThroughputMode {
111 /// The access pattern and working set this mode probes, or `None` for the
112 /// modes that don't measure memory.
113 ///
114 /// The one place the single-size variants are mapped onto their access and
115 /// size, so nothing downstream has to repeat the equivalence.
116 pub const fn memory_probe(&self) -> Option<(MemoryAccess, u64)> {
117 match self {
118 Self::Memory => Some((MemoryAccess::Copy, MemoryAccess::Copy.default_working_set())),
119 Self::MemoryRead => {
120 Some((MemoryAccess::Read, MemoryAccess::Read.default_working_set()))
121 }
122 Self::MemoryWrite => Some((
123 MemoryAccess::Write,
124 MemoryAccess::Write.default_working_set(),
125 )),
126 Self::MemoryWorkingSet { access, bytes } => Some((*access, *bytes)),
127 Self::ComputeDirect { .. } | Self::ComputeCmma { .. } | Self::Launch => None,
128 }
129 }
130}
131
132/// Represents a key/configuration used to identify the throughput of a computation.
133#[derive(Eq, PartialEq, Clone, Hash, Debug, Copy)]
134#[cfg_attr(std_io, derive(serde::Serialize, serde::Deserialize))]
135// Reject cached entries from an older key layout instead of silently ignoring their extra fields.
136#[cfg_attr(std_io, serde(deny_unknown_fields))]
137pub struct ThroughputKey {
138 /// The mode of the throughput computation.
139 pub mode: ThroughputMode,
140}
141
142impl ThroughputKey {
143 /// Returns the data type of the computation.
144 pub fn dtype(&self) -> ElemType {
145 match self.mode {
146 ThroughputMode::ComputeDirect { dtype } => dtype,
147 ThroughputMode::ComputeCmma { dtype, .. } => dtype,
148 // For memory and launch throughput, we use a default element type (F32).
149 ThroughputMode::Memory
150 | ThroughputMode::MemoryRead
151 | ThroughputMode::MemoryWrite
152 | ThroughputMode::MemoryWorkingSet { .. }
153 | ThroughputMode::Launch => ElemType::Float(FloatKind::F32),
154 }
155 }
156}
157
158/// Represents the throughput of a computation, including the number of operations and the duration.
159#[derive(Eq, PartialEq, Clone, Copy, Debug)]
160#[cfg_attr(std_io, derive(serde::Serialize, serde::Deserialize))]
161pub struct ThroughputValue {
162 /// The number of operations performed depending of the mode during the computation.
163 pub ops_count: usize,
164 /// The duration of the computation.
165 pub duration: Duration,
166}
167
168impl ThroughputValue {
169 /// A zero-initialized throughput value, representing no operations or duration.
170 pub const ZERO: Self = Self {
171 ops_count: 0,
172 duration: Duration::ZERO,
173 };
174
175 /// Returns the operations per second.
176 pub fn ops_per_s(&self) -> f64 {
177 if self.duration.is_zero() {
178 return f64::NAN;
179 }
180 self.ops_count as f64 / self.duration.as_secs_f64()
181 }
182
183 /// Returns the bytes per second.
184 pub fn bytes_per_s(&self, key: &ThroughputKey) -> f64 {
185 if self.duration.is_zero() {
186 return f64::NAN;
187 }
188 (self.ops_count * key.dtype().size()) as f64 / self.duration.as_secs_f64()
189 }
190
191 /// Returns the duration per operation.
192 pub fn duration_per_op(&self) -> Duration {
193 if self.ops_count == 0 {
194 Duration::ZERO
195 } else {
196 Duration::from_secs_f64(self.duration.as_secs_f64() / self.ops_count as f64)
197 }
198 }
199
200 /// Formats the throughput value as a clean human-readable string.
201 pub fn format(&self, key: &ThroughputKey) -> String {
202 let (mut val_per_s, unit) = match key.mode {
203 ThroughputMode::ComputeDirect { .. } | ThroughputMode::ComputeCmma { .. } => {
204 (self.ops_per_s(), "OPS")
205 }
206 ThroughputMode::Memory
207 | ThroughputMode::MemoryRead
208 | ThroughputMode::MemoryWrite
209 | ThroughputMode::MemoryWorkingSet { .. } => (self.bytes_per_s(key), "bytes"),
210 ThroughputMode::Launch => {
211 let dur = self.duration_per_op();
212 if dur.is_zero() {
213 return String::from("N/A");
214 }
215 return format!("{dur:?}/launch");
216 }
217 };
218
219 if val_per_s.is_nan() {
220 return String::from("N/A");
221 }
222
223 let suffixes = ["", "K", "M", "G", "T", "P", "E", "Z", "Y", "R", "Q"];
224 let mut suffix_idx = 0;
225
226 for _ in 0..suffixes.len() - 1 {
227 if val_per_s < 1000.0 {
228 break;
229 }
230 val_per_s /= 1000.0;
231 suffix_idx += 1;
232 }
233
234 format!("{val_per_s:.4} {}{unit}/s", suffixes[suffix_idx])
235 }
236}
237
238/// Constructs a compute [`ThroughputKey`] based on CMMA tile availability and types.
239pub fn compute_throughput_key(
240 cmma_tile: Option<(u32, u32, u32)>,
241 input_elem_type: ElemType,
242 acc_elem_type: ElemType,
243) -> ThroughputKey {
244 let mode = match cmma_tile {
245 Some((tile_m, tile_n, tile_k)) => ThroughputMode::ComputeCmma {
246 dtype: input_elem_type,
247 config: ComputeCmmaConfig {
248 accumulator_type: acc_elem_type,
249 cmma_dims: CmmaDims {
250 m: tile_m as usize,
251 n: tile_n as usize,
252 k: tile_k as usize,
253 },
254 },
255 },
256 None => ThroughputMode::ComputeDirect {
257 dtype: acc_elem_type,
258 },
259 };
260
261 ThroughputKey { mode }
262}
263
264#[cfg(all(test, std_io))]
265mod tests {
266 use super::*;
267
268 /// The throughput cache keys on the serialized key, so a layout change
269 /// drops every measurement users already paid for. Adding a variant must
270 /// leave the existing ones encoded exactly as before.
271 #[test]
272 fn existing_keys_keep_their_serialized_form() {
273 let encode = |mode| serde_json::to_string(&ThroughputKey { mode }).unwrap();
274
275 assert_eq!(encode(ThroughputMode::Memory), r#"{"mode":"Memory"}"#);
276 assert_eq!(
277 encode(ThroughputMode::MemoryRead),
278 r#"{"mode":"MemoryRead"}"#
279 );
280 assert_eq!(
281 encode(ThroughputMode::MemoryWrite),
282 r#"{"mode":"MemoryWrite"}"#
283 );
284
285 // And an entry written before the variant existed still reads back.
286 let cached: ThroughputKey = serde_json::from_str(r#"{"mode":"Memory"}"#).unwrap();
287 assert_eq!(cached.mode, ThroughputMode::Memory);
288 }
289}