Skip to main content

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}