Skip to main content

cubecl_std/quant/
fp4.rs

1//! Software `e2m1` conversion, on `u32` bit patterns only, so a backend with no 4-bit float type
2//! can still decode and encode fp4 from the bits in a word.
3//!
4//! This is the fp4 counterpart of `cubecl_core::post_processing::minifloat`, and it exists for the
5//! same reason: `e2m1` conversion is a CUDA intrinsic and nothing else. Every other backend either
6//! has no 4-bit float type at all or can only move one around, so a quantized kernel that reaches
7//! for `e2m1x2::from_bits` runs on one vendor. The arithmetic below runs everywhere.
8//!
9//! It does not go through the general minifloat path. That one reconstructs an `f32` bit pattern
10//! field by field, which is the only tractable way to cover eight exponent bits; `e2m1` has four
11//! codes per sign and its eight magnitudes are `{0, 0.5, 1, 1.5, 2, 3, 4, 6}`, small enough that
12//! decoding is one select over the subnormal arm and encoding is a count of the midpoints a
13//! magnitude clears.
14
15use cubecl::prelude::*;
16use cubecl_core as cubecl;
17
18/// The sign bit of an `e2m1` code.
19const SIGN: u32 = 0x8;
20/// The single mantissa bit.
21const MANTISSA: u32 = 0x1;
22/// The low nibble of a byte, one `e2m1` code.
23const NIBBLE: u32 = 0xF;
24/// A code's magnitude: its exponent and its mantissa, the sign left behind.
25const MAGNITUDE: u32 = 0x7;
26
27/// Where a code's magnitude bits land in an `f32`.
28///
29/// Both formats lay a number out the same way — sign, exponent, mantissa, most significant
30/// first — so a code's three magnitude bits are already an `f32`'s three most significant value
31/// bits, in order. An `f32`'s exponent field starts at bit 23 and a code's exponent is two bits
32/// wide, so the whole magnitude moves up by this one shift and the mantissa bit lands at 22.
33const MAGNITUDE_SHIFT: u32 = 22;
34
35/// The `f32` an `e2m1` exponent of zero would name, which is both the bias the normal arm adds
36/// and the one non-zero subnormal magnitude.
37///
38/// As a bias it is `126 << 23`: a code's exponent `e` means `2^(e-1)`, and an `f32`'s field of
39/// `126 + e` means the same. As a value it is `0.5` — a field of 126 with an empty mantissa —
40/// which is the only magnitude the subnormal arm has besides zero. One constant serves both
41/// because they are the same number for the same reason.
42const EXPONENT_BIAS: u32 = 126 << 23;
43
44/// The shift from a code's sign bit to an `f32`'s.
45const SIGN_SHIFT: u32 = 28;
46
47/// Decode one `e2m1` code per lane, held in the low nibble of each lane of `code`.
48///
49/// The upper bits of a lane are ignored, so a caller may hand over an unmasked field.
50///
51/// The decode is an assembly of the `f32`'s bits, not arithmetic over its value. `e2m1` and
52/// `f32` are the same shape of number, so a code's magnitude bits are already an `f32`'s top
53/// value bits and only have to be moved into place and biased — where computing `(1 + m/2) *
54/// 2^(e-1)` term by term costs two integer-to-float conversions and three multiplies to reach
55/// one of sixteen possible numbers. The subnormal arm is the one place the two layouts
56/// genuinely disagree and the one place a select is owed.
57#[cube]
58pub fn e2m1_bits_to_float<F: Numeric, N: Size>(code: Vector<u32, N>) -> Vector<F, N> {
59    let magnitude = code & Vector::new(MAGNITUDE);
60
61    // `exp >= 1` is `(1 + m/2) * 2^(exp-1)`, which is what an `f32` with exponent field
62    // `126 + exp` and mantissa bit `m` already means. The add cannot carry out of the exponent
63    // field: `exp` is at most three, and `126 + 3` still fits it.
64    let normal = (magnitude << Vector::new(MAGNITUDE_SHIFT)) + Vector::new(EXPONENT_BIAS);
65
66    // `exp == 0` is the subnormal arm, `m * 0.5`, so its two codes are `0.0` and `0.5` where
67    // the assembly above reads `0.5` and `0.75`. Both of those are the bias constant, kept or
68    // cleared by the mantissa bit.
69    let mantissa = code & Vector::new(MANTISSA);
70    let subnormal = select_many(
71        mantissa.equal(&Vector::new(MANTISSA)),
72        Vector::new(EXPONENT_BIAS),
73        Vector::new(0u32),
74    );
75
76    // A magnitude above one is a non-zero exponent, the mantissa bit being all that lies below.
77    let bits = select_many(
78        magnitude.greater_than(&Vector::new(MANTISSA)),
79        normal,
80        subnormal,
81    );
82
83    // The sign rides as the bit it is rather than negating a magnitude. That is a shift and an
84    // or against a compare, a negate and a select — and it is also the only form that reaches
85    // `-0.0`, which code `0x8` names and the host codec produces.
86    let sign = (code & Vector::new(SIGN)) << Vector::new(SIGN_SHIFT);
87
88    Vector::<F, N>::cast_from(Vector::<f32, N>::reinterpret(bits | sign))
89}
90
91/// Decode the `N` `e2m1` codes packed into the low `4 * N` bits of `word`, lowest nibble first.
92///
93/// The storage order is the host `e2m1x2`'s: element 0 in the low nibble, element 1 in the high
94/// one. Written against `N` rather than fixed at two so a wider native pack decodes the same way.
95#[cube]
96pub fn e2m1_packed_bits_to_float<F: Numeric, N: Size>(word: u32) -> Vector<F, N> {
97    let mut codes = Vector::<u32, N>::empty();
98    #[unroll]
99    for lane in 0..N::value() {
100        codes.insert(lane, (word >> (4 * lane as u32)) & NIBBLE);
101    }
102    e2m1_bits_to_float::<F, N>(codes)
103}
104
105/// Encode one `e2m1` code per lane into the low nibble of each lane, rounding to nearest with
106/// ties to even and saturating at `±6`.
107///
108/// Ties to even is not a detail here. `e2m1`'s magnitudes are so far apart that a tie is a common
109/// input rather than a rare one — `0.75` and `2.5` are both exact midpoints — and rounding them
110/// all outward would bias every quantized block upward.
111///
112/// The rounding is expressed as a count of the midpoints the magnitude clears, which puts the
113/// whole codec in comparisons and adds. The comparisons alternate strict and non-strict on
114/// purpose: that is what lands each tie on the even code (`0.75 -> 1.0`, `2.5 -> 2.0`) without a
115/// separate parity fixup.
116#[cube]
117pub fn float_to_e2m1_bits<F: Numeric, N: Size>(value: Vector<F, N>) -> Vector<u32, N> {
118    let value = Vector::<f32, N>::cast_from(value);
119
120    // The sign comes off the bit pattern rather than a comparison against zero. `-0.0` is not
121    // less than zero, so a comparison calls it positive and drops it on code `0x0`, where
122    // [`e2m1_bits_to_float`] and the host codec both name it `0x8`. The negative zero a decode
123    // produces has to encode back to the code it came from.
124    let sign_bit = Vector::new(0x8000_0000u32);
125    let negative = (Vector::<u32, N>::reinterpret(value) & sign_bit).equal(&sign_bit);
126    let magnitude = select_many(negative, -value, value);
127
128    // The midpoints of {0, 0.5, 1, 1.5, 2, 3, 4, 6}, in order.
129    let mut code = cleared::<N>(magnitude.greater_than(&Vector::new(0.25f32)));
130    code += cleared::<N>(magnitude.greater_equal(&Vector::new(0.75f32)));
131    code += cleared::<N>(magnitude.greater_than(&Vector::new(1.25f32)));
132    code += cleared::<N>(magnitude.greater_equal(&Vector::new(1.75f32)));
133    code += cleared::<N>(magnitude.greater_than(&Vector::new(2.5f32)));
134    code += cleared::<N>(magnitude.greater_equal(&Vector::new(3.5f32)));
135    code += cleared::<N>(magnitude.greater_than(&Vector::new(5.0f32)));
136
137    // A NaN clears no threshold and encodes as zero. `e2m1` has no NaN code to carry it to, so
138    // every codec has to pick something; zero is what the saturating comparisons already give.
139    code | (cleared::<N>(negative) * Vector::new(SIGN))
140}
141
142/// One per lane where the lane cleared its threshold, zero elsewhere — the term
143/// [`float_to_e2m1_bits`] sums to reach a code.
144#[cube]
145fn cleared<N: Size>(above: Vector<bool, N>) -> Vector<u32, N> {
146    select_many(above, Vector::new(1u32), Vector::new(0u32))
147}
148
149#[cfg(test)]
150mod tests {
151    use cubecl_common::e2m1;
152
153    /// The rounding this module implements is round-to-nearest with ties to even, which is what
154    /// `e2m1` itself does. The kernel reaches it by counting cleared midpoints and `e2m1` by a
155    /// different route, so the two agreeing is the specification holding rather than a tautology.
156    ///
157    /// Ties are not an edge case on a grid this coarse: `0.75` and `2.5` are both exact midpoints
158    /// of neighbouring code points, and rounding them all outward would bias every quantized block
159    /// upward by a visible amount rather than by a rounding error.
160    #[test]
161    fn the_midpoints_round_to_even() {
162        for (midpoint, expected) in [
163            (0.25f32, 0.0f32),
164            (0.75, 1.0),
165            (1.25, 1.0),
166            (1.75, 2.0),
167            (2.5, 2.0),
168            (3.5, 4.0),
169            (5.0, 4.0),
170        ] {
171            let landed = e2m1::from_f32(midpoint).to_f32();
172            assert_eq!(landed, expected, "{midpoint} rounded to {landed}");
173        }
174    }
175
176    /// Everything past the top magnitude saturates rather than wrapping or reaching a NaN code —
177    /// `e2m1` has neither an infinity nor a NaN to land on. The kernel's count of cleared
178    /// midpoints saturates by construction, so this pins the reference it is checked against.
179    #[test]
180    fn magnitudes_past_the_maximum_saturate() {
181        for value in [6.0f32, 6.1, 100.0, f32::MAX, f32::INFINITY] {
182            assert_eq!(e2m1::from_f32(value).to_f32(), 6.0);
183            assert_eq!(e2m1::from_f32(-value).to_f32(), -6.0);
184        }
185    }
186
187    /// The sign is carried onto the negative zero that code `0x8` names, which is the one code
188    /// telling `-0.0` from `0.0` depends on.
189    #[test]
190    fn the_sign_bit_survives_a_round_trip() {
191        for code in 8..16u8 {
192            let value = e2m1::from_bits(code).to_f32();
193            assert!(value.is_sign_negative(), "code {code} decoded as {value}");
194            assert_eq!(e2m1::from_f32(value).to_bits(), code);
195        }
196    }
197}