Skip to main content

t4a_cubecl_std/throughput/
base.rs

1use cubecl_core::ir::{ElemType, features::Features};
2use cubecl_runtime::{
3    client::Client,
4    runtime::Runtime,
5    throughput::{
6        MemoryAccess, MemoryCurve, MemoryPoint, MemorySpec, ThroughputError, ThroughputKey,
7        ThroughputMode, ThroughputValue, sweep_size, working_set_sweep,
8    },
9    tune::{AutotuneBound, Bounds, ResourceBound, Thresholds, Work},
10};
11
12use crate::throughput::{
13    Arithmetic, CooperativeMatrix, LaunchConfig, PooledProbes, ShapeSweep, WorkerSweep,
14    compute_cmma, compute_direct, launch_overhead, memory_direct, memory_probe, memory_read,
15    memory_write,
16};
17
18/// Measure peak throughput on `device` for each of the given `keys`.
19pub fn device_throughput<R: Runtime>(
20    device: &R::Device,
21    keys: &[ThroughputKey],
22) -> alloc::vec::Vec<Result<ThroughputValue, ThroughputError>> {
23    let client = R::client(device);
24    keys.iter()
25        .map(|key| measure_peak_throughput(&client, *key))
26        .collect()
27}
28
29/// Measure the memory ceiling across a range of working sets, from a few
30/// kilobytes up to as much as the device will allocate.
31///
32/// One point per size in [`working_set_sweep`], each measured and cached
33/// exactly like the single-size probe, so a curve costs one probe per size on
34/// the first run and nothing afterwards.
35///
36/// Returns an empty curve on WASM.
37pub fn measure_memory_curve(client: &Client, access: MemoryAccess) -> MemoryCurve {
38    let (points, probed) = {
39        // Every point of a sweep asks for the same pool, so the sweep holds one.
40        let _pooled = PooledProbes::enter(client);
41
42        sweep(client, access, |bytes| {
43            ThroughputMode::Memory(MemorySpec::new(access, bytes))
44        })
45    };
46
47    if probed {
48        PooledProbes::cleanup_unless_held(client);
49    }
50
51    MemoryCurve::new(access, points)
52}
53
54fn sweep(
55    client: &Client,
56    access: MemoryAccess,
57    mode: impl Fn(u64) -> ThroughputMode,
58) -> (alloc::vec::Vec<MemoryPoint>, bool) {
59    let mut probed = false;
60
61    let points = working_set_sweep(working_set_cap(client, access))
62        .into_iter()
63        .filter_map(|bytes| {
64            let key = ThroughputKey { mode: mode(bytes) };
65            let (value, ran) = measure(client, key);
66            probed |= ran;
67
68            Some(MemoryPoint {
69                bytes,
70                value: value.ok()?,
71            })
72        })
73        .collect();
74
75    (points, probed)
76}
77
78/// The largest working set `access` can be probed at: the largest window one
79/// buffer holds, times the buffers the access touches.
80fn working_set_cap(client: &Client, access: MemoryAccess) -> u64 {
81    let max_alloc = client.properties().memory.max_page_size;
82
83    memory_probe::window_cap(max_alloc) * access.buffers()
84}
85
86/// Computes the peak throughput for a given runtime and key.
87///
88/// Native only: a probe blocks on the device, which the browser can't do.
89/// There this reports [`Unsupported`](ThroughputError::Unsupported), so a
90/// roofline bound built from it has no peak and no time limit, and the tune
91/// runs without one rather than not at all.
92///
93/// # Errors
94///
95/// [`Unsupported`](ThroughputError::Unsupported) where the device implements
96/// no such operation, [`NoTiming`](ThroughputError::NoTiming) where it does
97/// and reported no elapsed time, [`Allocation`](ThroughputError::Allocation)
98/// where it has no room for the probe's buffers, [`Launch`](ThroughputError::Launch)
99/// where a probe's kernel did not run. None of them is cached, so a
100/// device that was full is measured the next time it is asked.
101pub fn measure_peak_throughput(
102    client: &Client,
103    key: ThroughputKey,
104) -> Result<ThroughputValue, ThroughputError> {
105    let (value, probed) = measure(client, key);
106
107    if probed {
108        PooledProbes::cleanup_unless_held(client);
109    }
110
111    value
112}
113
114/// The value for `key`, and whether a probe ran for it rather than the cache
115/// answering or the platform declining. Only a probe leaves pools with the
116/// allocator.
117fn measure(
118    client: &Client,
119    key: ThroughputKey,
120) -> (Result<ThroughputValue, ThroughputError>, bool) {
121    #[cfg(target_family = "wasm")]
122    {
123        let _ = (client, key);
124        (Err(ThroughputError::Unsupported), false)
125    }
126
127    #[cfg(not(target_family = "wasm"))]
128    {
129        // Errors are never cached, so a declined key would otherwise take
130        // the device on every call.
131        if declined(&client.properties().features, key) {
132            return (Err(ThroughputError::Unsupported), false);
133        }
134
135        let mut probed = false;
136        let value = client.measure_throughput(key, || {
137            // The probe's stream executes whatever the process mode drops: resolved
138            // where its launches are issued, which here is the runner.
139            let measuring = cubecl_runtime::execution::StreamModeOverride::new(
140                cubecl_runtime::execution::StreamMode::Execute,
141                client,
142            );
143
144            probed = true;
145            let value = probe(client, key);
146            core::mem::drop(measuring);
147            value
148        });
149
150        (value, probed)
151    }
152}
153
154/// Measures `key`, in the fastest shape its probe can be launched in.
155fn probe(client: &Client, key: ThroughputKey) -> Result<ThroughputValue, ThroughputError> {
156    let launch_config = LaunchConfig::for_device(client, key.dtype());
157
158    match key.mode {
159        ThroughputMode::ComputeDirect { dtype } => {
160            ShapeSweep::new(compute_direct_shapes(client, dtype, launch_config))
161                .fastest(|(dtype, config)| compute_direct::build_kernel(client, dtype, config))
162                .map(|(value, _)| value)
163        }
164        ThroughputMode::ComputeCmma {
165            config: cmma_config,
166            ..
167        } => ShapeSweep::new(alloc::vec![launch_config])
168            .fastest(|config| compute_cmma::build_kernel(client, key, cmma_config, config))
169            .map(|(value, _)| value),
170        ThroughputMode::Memory(spec) => {
171            let (value, fastest) = ShapeSweep::new(WorkerSweep::shapes(
172                client,
173                launch_config,
174                spec.access,
175            ))
176            .fastest(|config| match spec.access {
177                MemoryAccess::Copy => memory_direct::build_kernel(client, key, config, spec),
178                MemoryAccess::Read => memory_read::build_kernel(client, key, config, spec),
179                MemoryAccess::Write => memory_write::build_kernel(client, key, config, spec),
180            })?;
181
182            WorkerSweep::remember(client, spec.access, fastest.cube_dim.num_elems());
183
184            Ok(value)
185        }
186        ThroughputMode::Launch => ShapeSweep::new(alloc::vec![launch_config])
187            .fastest(|config| Ok(launch_overhead::build_kernel(client, key, config)))
188            .map(|(value, _)| value),
189    }
190}
191
192/// Whether the device properties alone refuse `key`. A type the backend
193/// cannot lower panics rather than answering, so this runs before any probe.
194fn declined(features: &Features, key: ThroughputKey) -> bool {
195    match key.mode {
196        ThroughputMode::ComputeDirect { dtype } => !features.supports_type(dtype),
197        ThroughputMode::ComputeCmma { dtype, config } => {
198            !CooperativeMatrix::implemented(features, dtype, config)
199        }
200        ThroughputMode::Memory(_) | ThroughputMode::Launch => false,
201    }
202}
203
204/// Every operand type and vector width the arithmetic ceiling for `dtype` is
205/// measured over.
206fn compute_direct_shapes(
207    client: &Client,
208    dtype: ElemType,
209    launch_config: LaunchConfig,
210) -> alloc::vec::Vec<(ElemType, LaunchConfig)> {
211    Arithmetic::dtypes(client, dtype)
212        .into_iter()
213        .flat_map(|dtype| {
214            Arithmetic::widths(client, dtype)
215                .into_iter()
216                .map(move |vector_size| {
217                    (
218                        dtype,
219                        LaunchConfig {
220                            vector_size,
221                            ..launch_config
222                        },
223                    )
224                })
225        })
226        .collect()
227}
228
229/// Both halves of the roofline for a [`Work`] amount, with the memory ceiling a copy's.
230///
231/// A copy's traffic runs in both directions, which is the ceiling for a kernel that reads
232/// and writes alike. A kernel bound by one resource states that half alone with
233/// [`compute_bound`] or [`memory_bound`].
234pub fn roofline_bounds(
235    client: &Client,
236    compute_key: ThroughputKey,
237    work: Work,
238    thresholds: Thresholds,
239) -> Bounds {
240    Bounds {
241        bounds: alloc::vec![
242            compute_bound(client, compute_key, work, thresholds.compute),
243            memory_bound(client, MemoryAccess::Copy, work, thresholds.memory),
244        ],
245        launch_overhead: measure_launch_overhead(client),
246    }
247}
248
249/// The compute half of a roofline: `work`'s operations against the peak of `compute_key`,
250/// close enough to it past `threshold`.
251pub fn compute_bound(
252    client: &Client,
253    compute_key: ThroughputKey,
254    work: Work,
255    threshold: f32,
256) -> AutotuneBound {
257    // An unmeasurable ceiling is zero, which `time_at_peak` declines.
258    let peak = measure_peak_throughput(client, compute_key).unwrap_or(ThroughputValue::ZERO);
259    AutotuneBound {
260        resource: ResourceBound {
261            amount: work.compute_ops,
262            peak_per_s: peak.ops_per_s(),
263        },
264        threshold,
265    }
266}
267
268/// The memory half of a roofline: `work`'s bytes against the ceiling in the direction
269/// `access` names, at the work's own footprint.
270///
271/// The direction is the caller's because a read-dominated kernel exceeds a copy's
272/// bandwidth, half of which is a direction it never uses. Bounding one by
273/// [`MemoryAccess::Copy`] passes a candidate at a fraction of the bus for close to peak.
274pub fn memory_bound(
275    client: &Client,
276    access: MemoryAccess,
277    work: Work,
278    threshold: f32,
279) -> AutotuneBound {
280    // Past what the device will allocate the probe measures the cap regardless,
281    // so capping the ask keeps one cache entry rather than one per kernel.
282    let footprint = (work.bytes as u64).min(working_set_cap(client, access));
283    let memory_key = ThroughputKey {
284        mode: ThroughputMode::Memory(MemorySpec::new(access, sweep_size(footprint))),
285    };
286    let peak = measure_peak_throughput(client, memory_key).unwrap_or(ThroughputValue::ZERO);
287    AutotuneBound {
288        resource: ResourceBound {
289            amount: work.bytes,
290            peak_per_s: peak.bytes_per_s(&memory_key),
291        },
292        threshold,
293    }
294}
295
296/// What one launch costs on top of its kernel, which a time limit allows for.
297pub fn measure_launch_overhead(client: &Client) -> core::time::Duration {
298    let launch_key = ThroughputKey {
299        mode: ThroughputMode::Launch,
300    };
301    measure_peak_throughput(client, launch_key)
302        .map(|value| value.duration_per_op())
303        .unwrap_or_default()
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use cubecl_core::ir::{
310        FloatKind,
311        features::{MmaConfig, TypeUsage},
312    };
313    use cubecl_runtime::throughput::{CmmaDims, ComputeCmmaConfig};
314
315    const F16: ElemType = ElemType::Float(FloatKind::F16);
316    const F32: ElemType = ElemType::Float(FloatKind::F32);
317
318    const DIMS: CmmaDims = CmmaDims {
319        m: 16,
320        n: 16,
321        k: 16,
322    };
323
324    fn cmma_key(dims: CmmaDims) -> ThroughputKey {
325        ThroughputKey {
326            mode: ThroughputMode::ComputeCmma {
327                dtype: F16,
328                config: ComputeCmmaConfig {
329                    accumulator_type: F32,
330                    cmma_dims: dims,
331                },
332            },
333        }
334    }
335
336    fn cmma_features(dims: CmmaDims) -> Features {
337        let mut features = Features::default();
338
339        features.matmul.cmma.insert(MmaConfig {
340            a_type: F16,
341            b_type: F16,
342            cd_type: F32,
343            m: dims.m as u32,
344            n: dims.n as u32,
345            k: dims.k as u32,
346        });
347
348        features
349    }
350
351    #[test]
352    fn a_type_the_device_cannot_lower_is_declined() {
353        let key = ThroughputKey {
354            mode: ThroughputMode::ComputeDirect { dtype: F16 },
355        };
356
357        assert!(declined(&Features::default(), key));
358
359        let mut features = Features::default();
360        features
361            .types
362            .elem
363            .insert(F16, TypeUsage::Arithmetic.into());
364
365        assert!(!declined(&features, key));
366    }
367
368    #[test]
369    fn a_cmma_shape_the_device_does_not_have_is_declined() {
370        let features = cmma_features(DIMS);
371
372        assert!(!declined(&features, cmma_key(DIMS)));
373        assert!(declined(&features, cmma_key(CmmaDims { k: 8, ..DIMS })));
374        assert!(declined(&Features::default(), cmma_key(DIMS)));
375    }
376
377    #[test]
378    fn memory_and_launch_are_left_to_the_probe() {
379        let features = Features::default();
380
381        for access in [MemoryAccess::Copy, MemoryAccess::Read, MemoryAccess::Write] {
382            let key = ThroughputKey {
383                mode: ThroughputMode::Memory(MemorySpec::new(access, 1024)),
384            };
385
386            assert!(!declined(&features, key));
387        }
388
389        assert!(!declined(
390            &features,
391            ThroughputKey {
392                mode: ThroughputMode::Launch
393            }
394        ));
395    }
396}