cubecl_std/quant/round.rs
1use cubecl::prelude::*;
2use cubecl_common::quant::scheme::{F32Grid, ScaleDtype};
3use cubecl_core as cubecl;
4
5/// The smallest value representable in `dtype` that is not below `scale`, in a kernel.
6///
7/// Device-side counterpart of [`ScaleDtype::round_up`], and the two have to agree: a tensor
8/// quantized on one backend has to reconstruct the same on another.
9///
10/// Returned as `F` rather than the storage type because the result is exactly representable in
11/// `dtype`, so the caller's cast to it is lossless.
12///
13/// `F` only carries the value in and out. The rule runs in f32, so a narrow `F` cannot turn the
14/// saturation bound into an infinity or the subnormal spacing into a flushed zero.
15///
16/// `scale` must not be negative, as with the host rule.
17#[cube]
18pub fn round_up_to_dtype<F: Float>(scale: F, #[comptime] dtype: ScaleDtype) -> F {
19 #[comptime]
20 match dtype {
21 ScaleDtype::F32 => scale,
22 ScaleDtype::F16 | ScaleDtype::BF16 | ScaleDtype::UE4M3 => {
23 F::cast_from(step_up(f32::cast_from(scale), dtype))
24 }
25 ScaleDtype::UE8M0 => F::cast_from(step_up_to_power_of_two(f32::cast_from(scale))),
26 }
27}
28
29/// The `ue8m0` arm of [`round_up_to_dtype`]: the smallest power of two not below `scale`.
30///
31/// Kept apart from [`step_up`] because both of `ue8m0`'s ends need clamping before the shared
32/// stepping means anything — its bottom, 2^-127, is subnormal in f32, and it has no zero for a
33/// fully-zero block to calibrate to. Mirrors the host's `round_up_to_power_of_two`, and the two
34/// have to keep agreeing or a tensor quantized on one backend reconstructs differently on another.
35#[cube]
36fn step_up_to_power_of_two(scale: f32) -> f32 {
37 let min = comptime!(ScaleDtype::UE8M0_MIN);
38 let max = comptime!(ScaleDtype::UE8M0_MAX);
39
40 if scale <= min {
41 min
42 } else if scale >= max {
43 max
44 } else {
45 round_up_on_grid(scale, comptime!(ScaleDtype::UE8M0.f32_grid()))
46 }
47}
48
49#[cube]
50fn step_up(scale: f32, #[comptime] dtype: ScaleDtype) -> f32 {
51 // Mirrors ScaleDtype::round_up, saturating at the top rather than converting past it: above the
52 // maximum a conversion gives an infinity, and every value scaled by it then reconstructs wrong.
53 // Both paths below work on the f32 bit pattern rather than the storage type, because the
54 // narrowing conversion that would replace them is one the WGSL path leaves unrounded.
55 let grid = comptime!(dtype.f32_grid());
56 let max = comptime!(dtype.max_representable());
57
58 if scale >= max {
59 max
60 } else if comptime!(grid.subnormals.is_some()) {
61 let subnormals = comptime!(grid.subnormals.unwrap());
62 let spacing = comptime!(subnormals.spacing);
63
64 // Below the minimum normal the spacing stops halving, so the answer is a count of steps.
65 if scale < comptime!(subnormals.min_normal) {
66 f32::ceil(scale / spacing) * spacing
67 } else {
68 round_up_on_grid(scale, grid)
69 }
70 } else {
71 round_up_on_grid(scale, grid)
72 }
73}
74
75/// Rounds `scale` up onto `grid`, for a value in the dtype's normal range and below its maximum.
76///
77/// Truncating the low f32 mantissa bits lands on the grid, and biasing first turns that truncation
78/// into a round up.
79#[cube]
80fn round_up_on_grid(scale: f32, #[comptime] grid: F32Grid) -> f32 {
81 let bits = u32::reinterpret(scale);
82 let up_bits = (bits + comptime!(grid.round_up_bias())) & comptime!(grid.truncate_mask());
83 f32::reinterpret(up_bits)
84}