Skip to main content

cubek_std/
hardware.rs

1//! The device as a routine sees it: cubecl's properties, read as they are, and the questions a
2//! routine asks of them.
3
4use core::cmp::Reverse;
5use std::sync::Arc;
6
7use cubecl::tile::launch::Refusal;
8use cubecl::{
9    CubeDim,
10    client::Client,
11    features::{AtomicUsage, MmaConfig, Plane, ScaledMmaConfig, Tma},
12    ir::{DeviceProperties, ElemType, FloatKind, OpaqueType, TargetProperties, Type},
13};
14
15/// The device, as the routines see it.
16///
17/// A value rather than a client, so a routine's geometry is unit-tested against a device that is
18/// not in the room. Cloning one costs a count increment: the properties are the client's own.
19#[derive(Clone, Debug)]
20pub struct Hardware {
21    /// What the runtime reports about the device. Every capability and limit a routine adapts to
22    /// is read here, as cubecl states it.
23    pub properties: Arc<DeviceProperties>,
24    /// Whether an `mma` accumulator holds its cells where the `A` operand of a contraction as
25    /// deep as it is wide holds them, so a product contracted again reads it as that operand in
26    /// the same registers (the tile DSL's `accumulator_as_a`).
27    ///
28    /// True on CUDA, and on Metal, whose simdgroup matrix gives every lane two cells of one row
29    /// in each operand. False on RDNA, whose `A` runs along its rows against an accumulator down
30    /// its columns, and whose RDNA3 `A` is held twice across the wave.
31    pub accumulator_as_a: bool,
32}
33
34/// Bytes one coalesced memory transaction moves on the GPUs cubek has met: 128 on CUDA, HIP,
35/// Metal and Vulkan hardware alike, at wave32 and wave64. A property of the memory system, not of
36/// the plane width that fetches through it.
37pub const GPU_TRANSACTION_BYTES: usize = 128;
38
39/// A CPU's transaction is its cache line: 64 bytes on every x86 and Apple core a CPU runtime
40/// runs on.
41pub const CPU_CACHE_LINE_BYTES: usize = 64;
42
43impl Hardware {
44    /// The device `properties` describe, compiling for `target`.
45    pub fn new(properties: Arc<DeviceProperties>, target: &TargetProperties) -> Self {
46        let mma = &target.mma;
47        Self {
48            properties,
49            accumulator_as_a: mma.register_layout_a == mma.register_layout_acc
50                && mma.register_duplication_a == mma.register_duplication_acc,
51        }
52    }
53
54    /// The device `client` runs on, compiling for its target.
55    pub fn from_client(client: &Client) -> Self {
56        Self::new(client.properties_shared(), client.target_properties())
57    }
58
59    /// The exact plane width, for a kernel whose correctness depends on one.
60    ///
61    /// The only way a routine reaches a plane width, and fallible, because both shortcuts are
62    /// wrong. `plane_size_max > 1` does not mean planes work: the wgpu runtime reports
63    /// `8..=128` for an adapter with no subgroups at all, and a `plane_sum` there is undefined.
64    /// And `plane_size_max` is not the width: wgpu reports a range on AMD RDNA (32 to 64) and
65    /// Intel (8 to 32), and a cube sized to the max sums over a fraction of each row.
66    ///
67    /// # Errors
68    ///
69    /// [`Refusal::PlanesUnavailable`] without plane operations, [`Refusal::ImprecisePlaneDim`]
70    /// where the runtime reports a range.
71    pub fn plane_dim(&self) -> Result<u32, Refusal> {
72        let hardware = &self.properties.hardware;
73        if !self.properties.features.plane.contains(Plane::Ops) {
74            return Err(Refusal::PlanesUnavailable);
75        }
76        if hardware.plane_size_min != hardware.plane_size_max {
77            return Err(Refusal::ImprecisePlaneDim {
78                min: hardware.plane_size_min,
79                max: hardware.plane_size_max,
80            });
81        }
82        Ok(hardware.plane_size_max)
83    }
84
85    /// Units that cooperate as one plane: the device's width where it commits to one, and `1`
86    /// where a plane is a single unit.
87    ///
88    /// The width a derivation asks for. [`plane_dim`](Self::plane_dim) is the width a kernel asks
89    /// for when it issues plane instructions; a geometry that only divides a microkernel between
90    /// cooperating units gets `1` on a runtime with none, the width the tile engine builds its
91    /// cube from there. A single-unit plane is read off what makes it one, a committed width of
92    /// one and no plane operations, as cubecl's CPU runtime reports.
93    ///
94    /// # Errors
95    ///
96    /// [`plane_dim`](Self::plane_dim)'s, for a fabricated width or a range.
97    pub fn plane_size(&self) -> Result<u32, Refusal> {
98        let hardware = &self.properties.hardware;
99        let single_unit = !self.properties.features.plane.contains(Plane::Ops)
100            && hardware.plane_size_min == 1
101            && hardware.plane_size_max == 1;
102        match single_unit {
103            true => Ok(1),
104            false => self.plane_dim(),
105        }
106    }
107
108    /// The width the engine builds a cube from, whatever the device committed to: sound only
109    /// where no axis of the space rides `Unit` scope.
110    ///
111    /// cubek builds `cube_dim.x` from `plane_size_max` and asserts only that the axes riding
112    /// `Unit` scope multiply to it. A cut that hands disjoint work to whole planes has no `Unit`
113    /// axis, so this is the width the launch is going to use rather than a guess about the
114    /// device. Units that share an item and sum across each other need
115    /// [`plane_size`](Self::plane_size) instead.
116    pub fn dispatch_plane_size(&self) -> u32 {
117        self.properties.hardware.plane_size_max.max(1)
118    }
119
120    /// The most planes of the width the device dispatches that one cube holds: a cube as full as
121    /// the device allows. On a CPU runtime, where a plane is one unit, a unit on every core.
122    pub fn full_cube_planes(&self) -> usize {
123        (self.properties.hardware.max_units_per_cube / self.dispatch_plane_size()).max(1) as usize
124    }
125
126    /// A flat cube of `units`, clamped to what the device spawns: for the element-wise kernels
127    /// that want a reasonable block, where the count is a throughput preference.
128    pub fn cube_dim_1d(&self, units: u32) -> CubeDim {
129        CubeDim::new_1d(
130            units
131                .min(self.properties.hardware.max_units_per_cube)
132                .max(1),
133        )
134    }
135
136    /// Shared memory a cube may claim: the reported maximum, less what the driver reserves for
137    /// cube-scoped matrices. Every budget goes through here: reading the maximum directly
138    /// over-subscribes on a runtime that reserves, as Vulkan does, and fails the launch.
139    pub fn shared_memory_budget(&self) -> usize {
140        let hardware = &self.properties.hardware;
141        hardware
142            .max_shared_memory_size
143            .saturating_sub(hardware.cube_mma_reserved_shared_memory)
144    }
145
146    /// Independent workers to spread across: a CPU runtime's cores, or a GPU's compute units.
147    /// `None` where nothing reports one, and a caller keeps its behaviour there rather than guess.
148    pub fn workers(&self) -> Option<u32> {
149        let hardware = &self.properties.hardware;
150        hardware
151            .num_cpu_cores
152            .or(hardware.num_streaming_multiprocessors)
153    }
154
155    /// Units a launch wants in flight to keep this device busy, or `None` when the device does
156    /// not say how wide it is.
157    ///
158    /// Units, not cubes: a worker hides memory latency behind other units' requests, so one cube
159    /// per worker starves it however large the cube, and counting cubes would make the answer
160    /// depend on a cube size decided after it. A CPU runtime's units are threads, one per core.
161    ///
162    /// The per-worker figure is a floor no runtime reports. Two measurements agree on it: a
163    /// shared-memory gemv's best target was 8192 units, and the decode attention that measured
164    /// fastest on an M2 Pro ran 256 single-plane cubes, also 8192 units, about 431 per worker.
165    pub fn units_to_fill(&self) -> Option<u32> {
166        const UNITS_PER_GPU_WORKER: u32 = 512;
167        let hardware = &self.properties.hardware;
168        match hardware.num_cpu_cores {
169            Some(cores) => Some(cores.max(1)),
170            None => Some(
171                hardware
172                    .num_streaming_multiprocessors?
173                    .saturating_mul(UNITS_PER_GPU_WORKER)
174                    .max(1),
175            ),
176        }
177    }
178
179    /// Cubes of `units_per_cube` units that fill this device, or `None` when it does not say how
180    /// wide it is. A preference: the grid may exceed it.
181    pub fn cubes_to_fill(&self, units_per_cube: u32) -> Option<u32> {
182        Some((self.units_to_fill()? / units_per_cube.max(1)).max(1))
183    }
184
185    /// How many values of `elem` one load instruction holds: what a step counts its loads in.
186    pub fn load_values(&self, elem: ElemType) -> usize {
187        (self.properties.hardware.load_width as usize / elem.size_bits().max(1)).max(1)
188    }
189
190    /// The widest vector this device reads `elem` in: its IO width over the element's, capped by
191    /// the most components a vector takes. The device's half of a ceiling: a launch reads at the
192    /// microkernel's, narrowed until the operands admit it.
193    pub fn vector_ceiling(&self, elem: ElemType) -> usize {
194        (self.properties.io_width() as usize / elem.size_bits().max(1))
195            .max(1)
196            .min(self.properties.hardware.max_vector_size.max(1))
197    }
198
199    /// Bytes the memory system moves in one transaction, whatever share of them a read wants:
200    /// [`GPU_TRANSACTION_BYTES`] on a GPU, [`CPU_CACHE_LINE_BYTES`] on a CPU runtime. No runtime
201    /// reports one, so it is stated per kind of runtime.
202    ///
203    /// What it bounds is waste: a walk whose units read consecutive addresses is coalesced
204    /// however many they are, and this decides whether the run they cover per step fills a
205    /// transaction.
206    pub fn transaction_bytes(&self) -> usize {
207        match self.properties.hardware.num_cpu_cores {
208            Some(_) => CPU_CACHE_LINE_BYTES,
209            None => GPU_TRANSACTION_BYTES,
210        }
211    }
212
213    /// The distance, in elements, between the starts of consecutive runs of `run` elements of
214    /// `elem` laid one after another: a whole and odd number of transactions.
215    ///
216    /// Odd because a memory system that picks a channel from low power-of-two address bits sends
217    /// runs a power of two bytes apart to one channel; an odd step reaches every slot of such an
218    /// interleave. It costs at most one transaction of padding a run.
219    pub fn staggered_pitch(&self, run: usize, elem: ElemType) -> usize {
220        let elem_bits = elem.size_bits().max(1);
221        let transaction_bits = self.transaction_bytes() * 8;
222        let transactions = (run * elem_bits).div_ceil(transaction_bits).max(1);
223        let odd = match transactions.is_multiple_of(2) {
224            true => transactions + 1,
225            false => transactions,
226        };
227        odd * transaction_bits / elem_bits
228    }
229
230    /// Planes per cube for a plane-parallel launch of `working_units` units: on a CPU runtime
231    /// capped at the core count, on a GPU a power of two up to eight, and never a cube of no
232    /// unit on a device whose unit budget is narrower than its plane.
233    pub fn plane_count_per_cube(&self, working_units: usize, plane_dim: u32) -> u32 {
234        const NUM_PLANE_MAX: u32 = 8;
235        let hardware = &self.properties.hardware;
236        let plane_count = match hardware.num_cpu_cores {
237            Some(num_cores) => num_cores.min(working_units as u32),
238            None => {
239                let plane_count_max = (working_units / plane_dim as usize).max(1);
240                2u32.pow(NUM_PLANE_MAX.ilog2().min(plane_count_max.ilog2()))
241            }
242        };
243        let max_plane_per_cube = (hardware.max_units_per_cube / plane_dim).max(1);
244        plane_count.min(max_plane_per_cube).max(1)
245    }
246
247    /// The tallest cube, in planes of `plane_size`, the device spawns once the plane width has
248    /// taken `cube_dim.x`: the cap on `cube_dim.y`.
249    ///
250    /// A budget, not a preference. A bound that lowers what the caller asked for would also erase
251    /// the differences between the values autotune measures, so what a taller cube is worth is
252    /// measured and only what the device can spawn is stated here.
253    pub fn strip_budget(&self, plane_size: usize) -> usize {
254        let hardware = &self.properties.hardware;
255        let cube = hardware.max_units_per_cube as usize / plane_size.max(1);
256        cube.min(hardware.max_cube_dim.1 as usize).max(1)
257    }
258
259    /// Whether a cube's shared memory takes an atomic add on an `f32`: what a cube-scope
260    /// microkernel sums its planes' partials through.
261    pub fn shared_f32_atomic_add(&self) -> bool {
262        self.properties
263            .shared_atomic_type_usage(Type::atomic(ElemType::Float(FloatKind::F32)))
264            .contains(AtomicUsage::Add)
265    }
266
267    /// Whether a stage may be filled by the TMA engine: the bulk tensor copy, and the barriers its
268    /// copies land on.
269    pub fn bulk_tensor_copy(&self) -> bool {
270        self.properties.features.tma.contains(Tma::Base) && self.barrier_ops()
271    }
272
273    /// Whether a stage may be filled by the units' async copies: the copy engine, and the
274    /// barriers its copies land on.
275    pub fn unit_async_copy(&self) -> bool {
276        self.properties.features.copy_async && self.barrier_ops()
277    }
278
279    /// Whether the runtime carries a barrier type, which is how a stage's producer and consumer
280    /// meet when they are different planes.
281    pub fn barrier_ops(&self) -> bool {
282        self.properties
283            .features
284            .types
285            .opaque
286            .contains(&OpaqueType::Barrier)
287    }
288}
289
290impl From<&Client> for Hardware {
291    fn from(client: &Client) -> Self {
292        Self::new(client.properties_shared(), client.target_properties())
293    }
294}
295
296/// The ranking of a device's fragment contractions: an iterator over one of cubecl's
297/// `MatmulFeatures` sets (`cmma`, `mma`), narrowed and ordered the way a routine picks from it.
298pub trait Instructions: Iterator<Item = MmaConfig> + Clone {
299    /// The shapes multiplying `lhs` by `rhs` into `acc`, the accumulate element: a half-precision
300    /// output accumulates in `f32` and casts on drain, so asking with the stored type hides the
301    /// `f16 × f16 → f32` shape a product wants.
302    fn for_triple(self, lhs: ElemType, rhs: ElemType, acc: ElemType) -> impl Instructions {
303        self.filter(move |c| c.a_type == lhs && c.b_type == rhs && c.cd_type == acc)
304    }
305
306    /// The shapes an attention contracts both its products on, for operands of `elem`:
307    /// `elem × elem → f32`. The score is that triple because its operands are; the mix is that
308    /// triple because the probabilities are cast to the operands' element before it, the softmax
309    /// staying at `f32`.
310    fn for_attention(self, elem: ElemType) -> impl Instructions {
311        self.for_triple(elem, elem, ElemType::Float(FloatKind::F32))
312    }
313
314    /// The largest block offered, by area and then by depth: the reuse a block buys grows with
315    /// the block. `None` where nothing is offered.
316    fn largest(self) -> Option<MmaConfig> {
317        self.max_by_key(|shape| (shape.m as usize * shape.n as usize, shape.k))
318    }
319
320    /// Every shape, the one that contracts the most per call first: by `m · n · k`, then the
321    /// squarest, then by `m`, `n` and `k`. Among shapes contracting as much, a square one reads a
322    /// rhs laid along `k` as well as one laid along `n`, where a wide one reads it in narrow
323    /// strips: on an RTX 2060, `16×16×16` beats `8×32×16` on a col weight and ties it on a row
324    /// one. Each next shape is the least one ranked after the last, so nothing is allocated.
325    fn largest_first(self) -> impl Iterator<Item = MmaConfig> {
326        let first = self.clone().min_by_key(size_rank);
327        core::iter::successors(first, move |last| {
328            self.clone()
329                .filter(|shape| size_rank(shape) > size_rank(last))
330                .min_by_key(size_rank)
331        })
332    }
333
334    /// These shapes less every one offered beside a deeper one of the same tile and elements: the
335    /// deeper contracts the tile in fewer calls. The shallower stays in the device's set for what
336    /// reads its operand from an accumulator of that tile, as an attention's value from its score.
337    fn deepest(self) -> impl Instructions {
338        let offered = self.clone();
339        self.filter(move |shape| {
340            !offered.clone().any(|other| {
341                other.k > shape.k
342                    && MmaConfig {
343                        k: shape.k,
344                        ..other
345                    } == *shape
346            })
347        })
348    }
349}
350
351impl<I: Iterator<Item = MmaConfig> + Clone> Instructions for I {}
352
353/// [`Instructions::largest_first`]'s order, the shape itself breaking a tie so every shape has
354/// its own place.
355#[allow(clippy::type_complexity)]
356fn size_rank(shape: &MmaConfig) -> (Reverse<usize>, usize, u32, u32, u32, MmaConfig) {
357    let (m, n, k) = (shape.m as usize, shape.n as usize, shape.k as usize);
358    (
359        Reverse(m * n * k),
360        m.abs_diff(n),
361        shape.m,
362        shape.n,
363        shape.k,
364        *shape,
365    )
366}
367
368/// The fragment contractions cubek's own transport over the matrix intrinsics (`mma`) runs on
369/// `hw`: the runtime's `mma` set, and its block-scaled instructions over NVFP4, `e2m1` operands
370/// under an `e4m3` scale every [`NVFP4_BLOCK`] values of `k`, which the transport runs on the
371/// stored words as they lie.
372///
373/// The runtime states a block-scaled instruction's operands as the `e2m1x2` pairs a register
374/// holds; here they are the `e2m1` values a work item stores. An `e2m1` value means nothing
375/// without the scale of its block, so the `mma` set's unscaled `e2m1` shapes are left out.
376pub fn manual_mma(hw: &Hardware) -> impl Instructions + '_ {
377    let matmul = &hw.properties.features.matmul;
378    let e2m1 = ElemType::Float(FloatKind::E2M1);
379    matmul
380        .mma
381        .iter()
382        .copied()
383        .filter(move |config| config.a_type != e2m1 && config.b_type != e2m1)
384        .chain(matmul.scaled_mma.iter().filter_map(nvfp4_instruction))
385}
386
387/// Whether `hw` contracts NVFP4 as it is stored: a block-scaled instruction over `e2m1`
388/// operands among its [`manual_mma`] shapes.
389pub fn block_scaled_nvfp4(hw: &Hardware) -> bool {
390    let e2m1 = ElemType::Float(FloatKind::E2M1);
391    manual_mma(hw)
392        .for_triple(e2m1, e2m1, ElemType::Float(FloatKind::F32))
393        .next()
394        .is_some()
395}
396
397/// The shape `config` contracts, where it is a block-scaled instruction over NVFP4.
398fn nvfp4_instruction(config: &ScaledMmaConfig) -> Option<MmaConfig> {
399    let fp4 = ElemType::Float(FloatKind::E2M1x2);
400    let nvfp4 = config.a_type == fp4
401        && config.b_type == fp4
402        && config.scales_type == ElemType::Float(FloatKind::E4M3)
403        && config.scales_factor as usize * NVFP4_BLOCK == config.k as usize;
404    nvfp4.then_some(MmaConfig {
405        a_type: ElemType::Float(FloatKind::E2M1),
406        b_type: ElemType::Float(FloatKind::E2M1),
407        cd_type: config.cd_type,
408        m: config.m,
409        n: config.n,
410        k: config.k,
411    })
412}
413
414/// Bytes a descriptor's start address and strides are whole multiples of, and the least a box's
415/// inner row moves.
416pub const TMA_ALIGNMENT_BYTES: usize = 16;
417
418/// Bytes of a box's inner row the TMA engine copies at full speed: shorter rows land as many more,
419/// smaller requests. A measurement rather than a derivation, the engine's speed being the
420/// device's: on the RTX PRO 6000 a stage landed in 32-byte rows copies about 40% slower than the
421/// same stage in 64-byte rows (the NVFP4 product with no contraction, Qwen3-4B's down projection
422/// at 2048 rows, 126 against 89 µs). The step rule cuts a stage the engine fills to it
423/// (`MatmulStep::filled_by_tma` in cubek-matmul).
424pub const TMA_ROW_BYTES: usize = 64;
425
426/// 32-bit registers one unit addresses before a kernel spills to memory: NVIDIA's 255. The NVFP4
427/// settings read it, which a device offers only with NVIDIA's block-scaled instruction, and a unit's
428/// register block unrolls no more cells than it.
429pub const UNIT_REGISTERS: u32 = 255;
430
431/// Values of `k` one NVFP4 block scale covers.
432pub const NVFP4_BLOCK: usize = 16;
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::devices;
438    use cubecl::{
439        features::Features,
440        ir::{DeviceIdentity, HardwareProperties, MemoryDeviceProperties},
441    };
442    use cubecl_common::profile::TimingMethod;
443
444    /// A device reporting `plane_size_min..=plane_size_max` planes, with plane operations or
445    /// without, on `num_cpu_cores` cores where it is a CPU runtime.
446    fn device(
447        plane_size_min: u32,
448        plane_size_max: u32,
449        plane_ops: bool,
450        num_cpu_cores: Option<u32>,
451    ) -> Hardware {
452        let mut features = Features::default();
453        if plane_ops {
454            features.plane.insert(Plane::Ops);
455        }
456        let hardware = HardwareProperties {
457            load_width: 128,
458            vector_register_count: None,
459            plane_size_min,
460            plane_size_max,
461            max_bindings: 8,
462            max_shared_memory_size: 64 * 1024,
463            max_cube_count: (u32::MAX, u32::MAX, u32::MAX),
464            max_units_per_cube: 1024,
465            max_cube_dim: (1024, 1024, 1024),
466            num_streaming_multiprocessors: num_cpu_cores.is_none().then_some(19),
467            num_cpu_cores,
468            last_level_cache_size: None,
469            num_tensor_cores: None,
470            min_tensor_cores_dim: None,
471            max_vector_size: 4,
472            cube_mma_reserved_shared_memory: 0,
473        };
474        let identity = DeviceIdentity {
475            name: "fixture".into(),
476            fingerprint: "fixture".into(),
477            physical: None,
478        };
479        Hardware {
480            properties: Arc::new(DeviceProperties::new(
481                features,
482                MemoryDeviceProperties::new(u64::MAX, 4),
483                hardware,
484                TimingMethod::System,
485                identity,
486            )),
487            accumulator_as_a: false,
488        }
489    }
490
491    fn shape(m: u32, n: u32, k: u32) -> MmaConfig {
492        let f16 = ElemType::Float(FloatKind::F16);
493        MmaConfig {
494            a_type: f16,
495            b_type: f16,
496            cd_type: ElemType::Float(FloatKind::F32),
497            m,
498            n,
499            k,
500        }
501    }
502
503    /// Runs start an odd number of transactions apart: a power-of-two pitch takes one
504    /// transaction more, a pitch already odd keeps its length, a run shorter than a transaction
505    /// takes a whole one, and a CPU's transaction is its cache line.
506    #[test]
507    fn a_staggered_pitch_is_an_odd_count_of_transactions() {
508        let f16 = ElemType::Float(FloatKind::F16);
509        let f32 = ElemType::Float(FloatKind::F32);
510        let gpu = device(32, 32, true, None);
511        assert_eq!(gpu.staggered_pitch(4096, f16), 4096 + 64);
512        assert_eq!(gpu.staggered_pitch(12288, f16), 12288 + 64);
513        assert_eq!(gpu.staggered_pitch(4096, f32), 4096 + 32);
514        assert_eq!(gpu.staggered_pitch(4160, f16), 4160);
515        assert_eq!(gpu.staggered_pitch(100, f16), 192);
516        assert_eq!(gpu.staggered_pitch(8, f16), 64);
517        let cpu = device(1, 1, false, Some(12));
518        assert_eq!(cpu.staggered_pitch(4096, f16), 4096 + 32);
519        for run in [1, 63, 64, 65, 1000, 4096, 12288] {
520            let pitch = gpu.staggered_pitch(run, f16);
521            let transactions = pitch * 2 / gpu.transaction_bytes();
522            assert!(pitch >= run && transactions % 2 == 1, "{run} -> {pitch}");
523        }
524    }
525
526    /// A single-unit plane is read off its properties, a fabricated width or a range is refused,
527    /// and an exact width is the width.
528    #[test]
529    fn only_a_committed_plane_width_is_a_width() {
530        assert_eq!(device(32, 32, true, None).plane_dim(), Ok(32));
531        assert_eq!(device(1, 1, false, Some(12)).plane_size(), Ok(1));
532        assert_eq!(
533            device(8, 128, false, None).plane_dim(),
534            Err(Refusal::PlanesUnavailable)
535        );
536        assert_eq!(
537            device(32, 64, true, None).plane_dim(),
538            Err(Refusal::ImprecisePlaneDim { min: 32, max: 64 })
539        );
540    }
541
542    /// A fabricated width wider than the unit budget still yields a cube that launches.
543    #[test]
544    fn plane_count_never_collapses_to_zero() {
545        let mut narrow = device(8, 128, false, None);
546        Arc::make_mut(&mut narrow.properties)
547            .hardware
548            .max_units_per_cube = 64;
549        assert!(narrow.plane_count_per_cube(1024, 128) >= 1);
550    }
551
552    /// A device that does not say how wide it is is not sized for a guess, a CPU core runs one
553    /// unit, and a wider cube fills the same device in fewer of itself.
554    #[test]
555    fn a_fill_counts_units_on_the_workers_reported() {
556        let mut unknown = device(32, 32, true, None);
557        Arc::make_mut(&mut unknown.properties)
558            .hardware
559            .num_streaming_multiprocessors = None;
560        assert_eq!(unknown.units_to_fill(), None);
561        assert_eq!(unknown.cubes_to_fill(32), None);
562        assert_eq!(device(1, 1, false, Some(12)).units_to_fill(), Some(12));
563        let gpu = device(32, 32, true, None);
564        assert_eq!(
565            gpu.cubes_to_fill(32).unwrap(),
566            gpu.cubes_to_fill(256).unwrap() * 8
567        );
568    }
569
570    /// A shallower shape of a tile drops out where a deeper one of the same tile and elements is
571    /// offered, and stays where none is.
572    #[test]
573    fn only_the_deepest_shape_of_a_tile_is_a_candidate() {
574        let bf16 = ElemType::Float(FloatKind::BF16);
575        let shallow_bf16 = MmaConfig {
576            a_type: bf16,
577            b_type: bf16,
578            ..shape(16, 8, 8)
579        };
580        let offered = [shape(16, 8, 16), shape(16, 8, 8), shallow_bf16];
581        assert_eq!(
582            offered.into_iter().deepest().collect::<Vec<_>>(),
583            vec![shape(16, 8, 16), shallow_bf16]
584        );
585    }
586
587    /// The shape that contracts the most per call comes first whatever order the set holds them
588    /// in; among shapes contracting as much, the square one, then the one fewest rows high.
589    #[test]
590    fn the_largest_shape_is_offered_first() {
591        let order = |offered: [MmaConfig; 3]| -> Vec<_> {
592            offered
593                .into_iter()
594                .largest_first()
595                .map(|shape| (shape.m, shape.n, shape.k))
596                .collect()
597        };
598        assert_eq!(
599            order([shape(16, 16, 16), shape(32, 32, 8), shape(16, 16, 32)]),
600            [(16, 16, 32), (32, 32, 8), (16, 16, 16)]
601        );
602        assert_eq!(
603            order([shape(8, 32, 16), shape(32, 8, 16), shape(16, 16, 16)]),
604            [(16, 16, 16), (8, 32, 16), (32, 8, 16)]
605        );
606    }
607
608    /// The NVFP4 offer is the block-scaled instruction stated over `e2m1` values, and an unscaled
609    /// `e2m1` shape is no offer.
610    #[test]
611    fn a_block_scaled_instruction_is_offered_over_its_values() {
612        let e2m1 = ElemType::Float(FloatKind::E2M1);
613        let unscaled = MmaConfig {
614            a_type: e2m1,
615            b_type: e2m1,
616            cd_type: ElemType::Float(FloatKind::F32),
617            m: 16,
618            n: 8,
619            k: 32,
620        };
621        let hw = devices::offering_mma(devices::h100(), [unscaled]);
622        assert!(!block_scaled_nvfp4(&hw));
623        let hw = devices::offering_nvfp4(hw);
624        assert_eq!(
625            manual_mma(&hw).collect::<Vec<_>>(),
626            [MmaConfig { k: 64, ..unscaled }]
627        );
628        assert!(block_scaled_nvfp4(&hw));
629    }
630}