Skip to main content

cubecl_core/post_processing/
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_ir::{
16    NamedRewrite, Scope,
17    dialect::{base::OperationPtrExt, general::CastOp},
18    interfaces::TypedExt,
19    prelude::*,
20    types::scalar::Float4E2M1x2Type,
21};
22use half::f16;
23use pliron::r#type::TypeHandle;
24
25use crate::post_processing::minifloat::Fp8Container;
26use crate::{self as cubecl, prelude::*};
27
28/// The sign bit of an `e2m1` code.
29const SIGN: u32 = 0x8;
30/// The single mantissa bit.
31const MANTISSA: u32 = 0x1;
32/// The low nibble of a byte, one `e2m1` code.
33const NIBBLE: u32 = 0xF;
34/// A code's magnitude: its exponent and its mantissa, the sign left behind.
35const MAGNITUDE: u32 = 0x7;
36
37/// Where a code's magnitude bits land in an `f32`.
38///
39/// Both formats lay a number out the same way — sign, exponent, mantissa, most significant
40/// first — so a code's three magnitude bits are already an `f32`'s three most significant value
41/// bits, in order. An `f32`'s exponent field starts at bit 23 and a code's exponent is two bits
42/// wide, so the whole magnitude moves up by this one shift and the mantissa bit lands at 22.
43const MAGNITUDE_SHIFT: u32 = 22;
44
45/// The `f32` an `e2m1` exponent of zero would name, which is both the bias the normal arm adds
46/// and the one non-zero subnormal magnitude.
47///
48/// As a bias it is `126 << 23`: a code's exponent `e` means `2^(e-1)`, and an `f32`'s field of
49/// `126 + e` means the same. As a value it is `0.5` — a field of 126 with an empty mantissa —
50/// which is the only magnitude the subnormal arm has besides zero. One constant serves both
51/// because they are the same number for the same reason.
52const EXPONENT_BIAS: u32 = 126 << 23;
53
54/// The shift from a code's sign bit to an `f32`'s.
55const SIGN_SHIFT: u32 = 28;
56
57/// Decode one `e2m1` code per lane, held in the low nibble of each lane of `code`.
58///
59/// The upper bits of a lane are ignored, so a caller may hand over an unmasked field.
60///
61/// The decode is an assembly of the `f32`'s bits, not arithmetic over its value. `e2m1` and
62/// `f32` are the same shape of number, so a code's magnitude bits are already an `f32`'s top
63/// value bits and only have to be moved into place and biased — where computing `(1 + m/2) *
64/// 2^(e-1)` term by term costs two integer-to-float conversions and three multiplies to reach
65/// one of sixteen possible numbers. The subnormal arm is the one place the two layouts
66/// genuinely disagree and the one place a select is owed.
67#[cube]
68pub fn e2m1_bits_to_float<F: Numeric, N: Size>(code: Vector<u32, N>) -> Vector<F, N> {
69    let magnitude = code & Vector::new(MAGNITUDE);
70
71    // `exp >= 1` is `(1 + m/2) * 2^(exp-1)`, which is what an `f32` with exponent field
72    // `126 + exp` and mantissa bit `m` already means. The add cannot carry out of the exponent
73    // field: `exp` is at most three, and `126 + 3` still fits it.
74    let normal = (magnitude << Vector::new(MAGNITUDE_SHIFT)) + Vector::new(EXPONENT_BIAS);
75
76    // `exp == 0` is the subnormal arm, `m * 0.5`, so its two codes are `0.0` and `0.5` where
77    // the assembly above reads `0.5` and `0.75`. Both of those are the bias constant, kept or
78    // cleared by the mantissa bit.
79    let mantissa = code & Vector::new(MANTISSA);
80    let subnormal = select_many(
81        mantissa.equal(&Vector::new(MANTISSA)),
82        Vector::new(EXPONENT_BIAS),
83        Vector::new(0u32),
84    );
85
86    // A magnitude above one is a non-zero exponent, the mantissa bit being all that lies below.
87    let bits = select_many(
88        magnitude.greater_than(&Vector::new(MANTISSA)),
89        normal,
90        subnormal,
91    );
92
93    // The sign rides as the bit it is rather than negating a magnitude. That is a shift and an
94    // or against a compare, a negate and a select — and it is also the only form that reaches
95    // `-0.0`, which code `0x8` names and the host codec produces.
96    let sign = (code & Vector::new(SIGN)) << Vector::new(SIGN_SHIFT);
97
98    Vector::<F, N>::cast_from(Vector::<f32, N>::reinterpret(bits | sign))
99}
100
101/// Decode the `N` `e2m1` codes packed into the low `4 * N` bits of `word`, lowest nibble first.
102///
103/// The storage order is the host `e2m1x2`'s: element 0 in the low nibble, element 1 in the high
104/// one. Written against `N` rather than fixed at two so a wider native pack decodes the same way.
105#[cube]
106pub fn e2m1_packed_bits_to_float<F: Numeric, N: Size>(word: u32) -> Vector<F, N> {
107    let mut codes = Vector::<u32, N>::empty();
108    #[unroll]
109    for lane in 0..N::value() {
110        codes.insert(lane, (word >> (4 * lane as u32)) & NIBBLE);
111    }
112    e2m1_bits_to_float::<F, N>(codes)
113}
114
115/// Encode one `e2m1` code per lane into the low nibble of each lane, rounding to nearest with
116/// ties to even and saturating at `±6`.
117///
118/// Ties to even is not a detail here. `e2m1`'s magnitudes are so far apart that a tie is a common
119/// input rather than a rare one — `0.75` and `2.5` are both exact midpoints — and rounding them
120/// all outward would bias every quantized block upward.
121///
122/// The rounding is expressed as a count of the midpoints the magnitude clears, which puts the
123/// whole codec in comparisons and adds. The comparisons alternate strict and non-strict on
124/// purpose: that is what lands each tie on the even code (`0.75 -> 1.0`, `2.5 -> 2.0`) without a
125/// separate parity fixup.
126#[cube]
127pub fn float_to_e2m1_bits<F: Numeric, N: Size>(value: Vector<F, N>) -> Vector<u32, N> {
128    let value = Vector::<f32, N>::cast_from(value);
129
130    // The sign comes off the bit pattern rather than a comparison against zero. `-0.0` is not
131    // less than zero, so a comparison calls it positive and drops it on code `0x0`, where
132    // [`e2m1_bits_to_float`] and the host codec both name it `0x8`. The negative zero a decode
133    // produces has to encode back to the code it came from.
134    let sign_bit = Vector::new(0x8000_0000u32);
135    let negative = (Vector::<u32, N>::reinterpret(value) & sign_bit).equal(&sign_bit);
136    let magnitude = select_many(negative, -value, value);
137
138    // The midpoints of {0, 0.5, 1, 1.5, 2, 3, 4, 6}, in order.
139    let mut code = cleared::<N>(magnitude.greater_than(&Vector::new(0.25f32)));
140    code += cleared::<N>(magnitude.greater_equal(&Vector::new(0.75f32)));
141    code += cleared::<N>(magnitude.greater_than(&Vector::new(1.25f32)));
142    code += cleared::<N>(magnitude.greater_equal(&Vector::new(1.75f32)));
143    code += cleared::<N>(magnitude.greater_than(&Vector::new(2.5f32)));
144    code += cleared::<N>(magnitude.greater_equal(&Vector::new(3.5f32)));
145    code += cleared::<N>(magnitude.greater_than(&Vector::new(5.0f32)));
146
147    // A NaN clears no threshold and encodes as zero. `e2m1` has no NaN code to carry it to, so
148    // every codec has to pick something; zero is what the saturating comparisons already give.
149    code | (cleared::<N>(negative) * Vector::new(SIGN))
150}
151
152/// One per lane where the lane cleared its threshold, zero elsewhere — the term
153/// [`float_to_e2m1_bits`] sums to reach a code.
154#[cube]
155fn cleared<N: Size>(above: Vector<bool, N>) -> Vector<u32, N> {
156    select_many(above, Vector::new(1u32), Vector::new(0u32))
157}
158
159/// What a code placed as an `f16` near the bottom of its range ([`f16_pair_bits`]) is short of
160/// its value by: `2^14`, the gap between an `e2m1` exponent of zero and an `f16` one.
161const F16_LIFT: f32 = 16384.0;
162
163/// The `f16` pair two codes name, from a word holding one code in its low nibble and the other
164/// sixteen bits up.
165///
166/// An `e2m1` code is already an `f16`'s top bits in order, sign, exponent, mantissa, so each
167/// lands by one mask and one shift: the magnitude where an `f16`'s exponent ends, the sign on its
168/// sign bit. What it lands on is the value `2^14` times too small, and exactly that: the exponent
169/// field is the code's own, so a code of exponent zero is an `f16` subnormal, which is the
170/// format's own subnormal arm with no select. Two codes sixteen bits apart move together, so one
171/// pair costs what one code does.
172#[cube]
173fn f16_pair_bits(codes: u32) -> u32 {
174    ((codes & 0x0007_0007) << 9) | ((codes & 0x0008_0008) << 12)
175}
176
177/// Decode the eight codes of each word of `words`, lowest nibble first, to `f16`.
178///
179/// A word's codes `j` and `j + 4` sit sixteen bits apart, so a word is four pairs and four
180/// [`f16_pair_bits`]; the pairs land on their lanes by compile-time inserts. One multiply for
181/// every lane lifts them to their values, exactly, the factor being a power of two.
182#[cube]
183pub fn e2m1_words_to_f16<W: Size, V: Size>(words: Vector<u32, W>) -> Vector<f16, V> {
184    let mut values = Vector::<f16, V>::empty();
185    #[unroll]
186    for w in 0..W::value() {
187        let word = words.extract(w);
188        #[unroll]
189        for j in 0..4usize {
190            let shift = comptime![4 * j as u32];
191            let pair = Vector::<f16, Const<2>>::reinterpret(f16_pair_bits(word >> shift));
192            values.insert(8 * w + j, pair.extract(0usize));
193            values.insert(8 * w + j + 4, pair.extract(1usize));
194        }
195    }
196    values * Vector::new(f16::new(F16_LIFT))
197}
198
199/// [`e2m1_words_to_f16`] for bytes that do not fill a word, one `e2m1x2` per lane of `bytes`:
200/// the high nibble moves up to the second half of the word first, one more mask and shift a pair.
201#[cube]
202pub fn e2m1_bytes_to_f16<B: Size, V: Size>(bytes: Vector<u32, B>) -> Vector<f16, V> {
203    let mut values = Vector::<f16, V>::empty();
204    #[unroll]
205    for b in 0..B::value() {
206        let byte = bytes.extract(b);
207        let codes = (byte & 0xF) | ((byte & 0xF0) << 12);
208        let pair = Vector::<f16, Const<2>>::reinterpret(f16_pair_bits(codes));
209        values.insert(2 * b, pair.extract(0usize));
210        values.insert(2 * b + 1, pair.extract(1usize));
211    }
212    values * Vector::new(f16::new(F16_LIFT))
213}
214
215/// The codes of the `e2m1x2` bytes in `bytes`, one a lane, low nibble first: what the `f32`
216/// decode reads.
217#[cube]
218fn bytes_to_codes<B: Size, V: Size>(bytes: Vector<u32, B>) -> Vector<u32, V> {
219    let mut codes = Vector::<u32, V>::empty();
220    #[unroll]
221    for b in 0..B::value() {
222        let byte = bytes.extract(b);
223        codes.insert(2 * b, byte & NIBBLE);
224        codes.insert(2 * b + 1, (byte >> 4) & NIBBLE);
225    }
226    codes
227}
228
229/// The `e2m1x2` bytes two codes a lane make, low nibble first: what an encode stores.
230#[cube]
231fn codes_to_bytes<V: Size, B: Size>(codes: Vector<u32, V>) -> Vector<u32, B> {
232    let mut bytes = Vector::<u32, B>::empty();
233    #[unroll]
234    for b in 0..B::value() {
235        bytes.insert(
236            b,
237            (codes.extract(2 * b) & NIBBLE) | ((codes.extract(2 * b + 1) & NIBBLE) << 4),
238        );
239    }
240    bytes
241}
242
243/// Bytes, one a lane, packed four to a word, lane 0 in the low byte.
244#[cube]
245fn bytes_to_words<B: Size, W: Size>(bytes: Vector<u32, B>) -> Vector<u32, W> {
246    let mut words = Vector::<u32, W>::empty();
247    #[unroll]
248    for w in 0..W::value() {
249        let mut word = 0u32;
250        #[unroll]
251        for offset in 0..4usize {
252            let shift = comptime![8 * offset as u32];
253            word |= (bytes.extract(4 * w + offset) & 0xFF) << shift;
254        }
255        words.insert(w, word);
256    }
257    words
258}
259
260/// Words, each four bytes, as one byte a lane, lane 0 in the low byte.
261#[cube]
262fn words_to_bytes<W: Size, B: Size>(words: Vector<u32, W>) -> Vector<u32, B> {
263    let mut bytes = Vector::<u32, B>::empty();
264    #[unroll]
265    for b in 0..B::value() {
266        let shift = comptime![8 * (b % 4) as u32];
267        bytes.insert(b, (words.extract(b / 4) >> shift) & 0xFF);
268    }
269    bytes
270}
271
272define_size!(B);
273define_size!(V);
274define_size!(W);
275
276pub type LowerFp4CastPass = MatchRewritePass<LowerFp4Cast>;
277
278/// Lowers every cast from or to `e2m1x2` onto the software codec, for a backend that has no
279/// native fp4 conversion.
280///
281/// The decode goes through `f16` pairs ([`e2m1_words_to_f16`]) where the backend has `f16`, and
282/// through `f32` ([`e2m1_bits_to_float`]) where it does not: the pairs decode two codes for what
283/// the `f32` path spends on one and need no select, and the backend is what knows whether `f16`
284/// exists. A cast whose bytes fill whole words decodes a word at a time, codes four apart
285/// sharing a pair. The encode is [`float_to_e2m1_bits`] either way.
286#[derive(new, Clone, Copy, Debug, Default, NamedRewrite)]
287pub struct LowerFp4Cast {
288    /// Whether the backend computes in `f16`.
289    half: bool,
290    container: Fp8Container,
291}
292
293fn is_e2m1x2(ctx: &Context, ty: TypeHandle) -> bool {
294    ty.scalar_ty(ctx).deref(ctx).is::<Float4E2M1x2Type>()
295}
296
297impl MatchRewrite for LowerFp4Cast {
298    fn r#match(&mut self, ctx: &Context, op: Ptr<Operation>) -> bool {
299        if !op.is_op::<CastOp>(ctx) {
300            return false;
301        }
302        is_e2m1x2(ctx, op.operand(ctx, 0).get_type(ctx))
303            || is_e2m1x2(ctx, op.result(ctx).get_type(ctx))
304    }
305
306    fn rewrite(
307        &mut self,
308        ctx: &mut Context,
309        rewriter: &mut MatchRewriter,
310        op: Ptr<Operation>,
311    ) -> Result<()> {
312        let scope = Scope::from_context_and_inserter(ctx, rewriter);
313        let input = op.operand(ctx, 0);
314        let result_ty = op.result(ctx).get_type(ctx);
315        let value = if is_e2m1x2(ctx, input.get_type(ctx)) {
316            self.decode(&scope, input, result_ty)
317        } else {
318            self.encode(&scope, input, result_ty)
319        };
320        rewriter.replace_operation_with_values(ctx, op, vec![value]);
321        Ok(())
322    }
323}
324
325impl LowerFp4Cast {
326    /// `input`'s bytes as `u32` lanes, one byte a lane.
327    fn byte_lanes(&self, scope: &Scope, input: Value, bytes: usize) -> Value {
328        match self.container {
329            Fp8Container::Bytes => {
330                let lanes =
331                    reinterpret_value(scope, input, Vector::<u8, B>::__expand_as_type(scope));
332                cast_value(scope, lanes, Vector::<u32, B>::__expand_as_type(scope))
333            }
334            Fp8Container::Words => {
335                let words = reinterpret_value(scope, input, self.words_type(scope, bytes));
336                words_to_bytes::expand::<W, B>(scope, words.into()).read_value(scope)
337            }
338        }
339    }
340
341    /// Registers `W`, the words `bytes` bytes fill, and names their type.
342    fn words_type(&self, scope: &Scope, bytes: usize) -> TypeHandle {
343        assert!(
344            bytes.is_multiple_of(4),
345            "fp4 is packed four bytes to a u32 on this backend: vectors of {bytes} e2m1x2 lanes \
346             are not supported, use a multiple of four"
347        );
348        scope.register_size::<W>(bytes / 4);
349        Vector::<u32, W>::__expand_as_type(scope)
350    }
351
352    fn decode(&self, scope: &Scope, input: Value, result_ty: TypeHandle) -> Value {
353        let ctx = scope.ctx();
354        let bytes = input.vector_size(ctx);
355        let values = result_ty.vector_size(ctx);
356        debug_assert_eq!(values, 2 * bytes, "an e2m1x2 lane casts to two values");
357        scope.register_size::<B>(bytes);
358        scope.register_size::<V>(values);
359        let decoded = match (self.half, bytes.is_multiple_of(4)) {
360            (true, true) => {
361                let words = reinterpret_value(scope, input, self.words_type(scope, bytes));
362                e2m1_words_to_f16::expand::<W, V>(scope, words.into()).read_value(scope)
363            }
364            (true, false) => {
365                let lanes = self.byte_lanes(scope, input, bytes);
366                e2m1_bytes_to_f16::expand::<B, V>(scope, lanes.into()).read_value(scope)
367            }
368            (false, _) => {
369                let lanes = self.byte_lanes(scope, input, bytes);
370                let codes = bytes_to_codes::expand::<B, V>(scope, lanes.into());
371                e2m1_bits_to_float::expand::<f32, V>(scope, codes).read_value(scope)
372            }
373        };
374        cast_value(scope, decoded, result_ty)
375    }
376
377    fn encode(&self, scope: &Scope, input: Value, result_ty: TypeHandle) -> Value {
378        let ctx = scope.ctx();
379        let values = input.vector_size(ctx);
380        let bytes = result_ty.vector_size(ctx);
381        debug_assert_eq!(values, 2 * bytes, "two values cast to an e2m1x2 lane");
382        scope.register_size::<B>(bytes);
383        scope.register_size::<V>(values);
384        let value = cast_value(scope, input, Vector::<f32, V>::__expand_as_type(scope));
385        let codes = float_to_e2m1_bits::expand::<f32, V>(scope, value.into());
386        let packed = codes_to_bytes::expand::<V, B>(scope, codes);
387        let container = match self.container {
388            Fp8Container::Bytes => cast_value(
389                scope,
390                packed.read_value(scope),
391                Vector::<u8, B>::__expand_as_type(scope),
392            ),
393            Fp8Container::Words => {
394                self.words_type(scope, bytes);
395                bytes_to_words::expand::<B, W>(scope, packed).read_value(scope)
396            }
397        };
398        reinterpret_value(scope, container, result_ty)
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use cubecl_common::e2m1;
405
406    /// The rounding this module implements is round-to-nearest with ties to even, which is what
407    /// `e2m1` itself does. The kernel reaches it by counting cleared midpoints and `e2m1` by a
408    /// different route, so the two agreeing is the specification holding rather than a tautology.
409    ///
410    /// Ties are not an edge case on a grid this coarse: `0.75` and `2.5` are both exact midpoints
411    /// of neighbouring code points, and rounding them all outward would bias every quantized block
412    /// upward by a visible amount rather than by a rounding error.
413    #[test]
414    fn the_midpoints_round_to_even() {
415        for (midpoint, expected) in [
416            (0.25f32, 0.0f32),
417            (0.75, 1.0),
418            (1.25, 1.0),
419            (1.75, 2.0),
420            (2.5, 2.0),
421            (3.5, 4.0),
422            (5.0, 4.0),
423        ] {
424            let landed = e2m1::from_f32(midpoint).to_f32();
425            assert_eq!(landed, expected, "{midpoint} rounded to {landed}");
426        }
427    }
428
429    /// Everything past the top magnitude saturates rather than wrapping or reaching a NaN code —
430    /// `e2m1` has neither an infinity nor a NaN to land on. The kernel's count of cleared
431    /// midpoints saturates by construction, so this pins the reference it is checked against.
432    #[test]
433    fn magnitudes_past_the_maximum_saturate() {
434        for value in [6.0f32, 6.1, 100.0, f32::MAX, f32::INFINITY] {
435            assert_eq!(e2m1::from_f32(value).to_f32(), 6.0);
436            assert_eq!(e2m1::from_f32(-value).to_f32(), -6.0);
437        }
438    }
439
440    /// The sign is carried onto the negative zero that code `0x8` names, which is the one code
441    /// telling `-0.0` from `0.0` depends on.
442    #[test]
443    fn the_sign_bit_survives_a_round_trip() {
444        for code in 8..16u8 {
445            let value = e2m1::from_bits(code).to_f32();
446            assert!(value.is_sign_negative(), "code {code} decoded as {value}");
447            assert_eq!(e2m1::from_f32(value).to_bits(), code);
448        }
449    }
450}