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}