Skip to main content

cubecl_core/post_processing/
minifloat.rs

1//! Software fp8 conversion, on `u32` bit patterns only so that a backend with no 8- or 16-bit types
2//! can still call it on the bits in a word.
3
4use cubecl_ir::EnumSet;
5use cubecl_ir::{
6    NamedRewrite, Scope,
7    dialect::{
8        base::OperationPtrExt,
9        cmp::{FEqualOp, FNotEqualOp},
10        general::CastOp,
11    },
12    interfaces::TypedExt,
13    prelude::*,
14    types::Fp8Format,
15};
16use pliron::r#type::TypeHandle;
17
18use crate::{self as cubecl, prelude::*};
19
20define_size!(N);
21
22const F32_MANTISSA_BITS: u32 = f32::MANTISSA_DIGITS - 1;
23const F32_MANTISSA_MASK: u32 = (1 << F32_MANTISSA_BITS) - 1;
24const F32_MAGNITUDE_MASK: u32 = u32::MAX >> 1;
25const F32_EXPONENT_BIAS: u32 = (f32::MAX_EXP - 1) as u32;
26const F32_INFINITY_BITS: u32 = f32::INFINITY.to_bits();
27const F32_NAN_BITS: u32 = f32::NAN.to_bits();
28const FP8_SIGN_BIT: u32 = 1 << (u8::BITS - 1);
29const FP8_MAGNITUDE_MASK: u32 = FP8_SIGN_BIT - 1;
30const SIGN_SHIFT: u32 = u32::BITS - u8::BITS;
31
32/// Bits above the low byte are ignored.
33#[cube]
34pub fn fp8_bits_to_f32<N: Size>(
35    bits: Vector<u32, N>,
36    #[comptime] format: Fp8Format,
37) -> Vector<f32, N> {
38    let mantissa_bits = comptime![format.mantissa_bits()];
39    let exponent_mask = comptime![(1u32 << format.exponent_bits()) - 1];
40    let mantissa_mask = comptime![(1u32 << mantissa_bits) - 1];
41    let rebias = comptime![F32_EXPONENT_BIAS - format.bias()];
42    let mantissa_shift = comptime![F32_MANTISSA_BITS - mantissa_bits];
43    let subnormal_step = comptime![format.subnormal_step()];
44
45    let sign = (bits & Vector::new(FP8_SIGN_BIT)) << Vector::new(SIGN_SHIFT);
46    let exponent = (bits >> Vector::new(mantissa_bits)) & Vector::new(exponent_mask);
47    let mantissa = bits & Vector::new(mantissa_mask);
48
49    let normal = sign
50        | ((exponent + Vector::new(rebias)) << Vector::new(F32_MANTISSA_BITS))
51        | (mantissa << Vector::new(mantissa_shift));
52    let subnormal = sign
53        | Vector::<u32, N>::reinterpret(
54            Vector::<f32, N>::cast_from(mantissa) * Vector::new(subnormal_step),
55        );
56    let value = select_many(exponent.equal(&Vector::new(0u32)), subnormal, normal);
57
58    let nan = sign | Vector::new(F32_NAN_BITS);
59    let result = if comptime![format.has_infinity()] {
60        let inf = sign | Vector::new(F32_INFINITY_BITS);
61        let special = select_many(mantissa.equal(&Vector::new(0u32)), inf, nan);
62        select_many(exponent.equal(&Vector::new(exponent_mask)), special, value)
63    } else {
64        let magnitude = bits & Vector::new(FP8_MAGNITUDE_MASK);
65        select_many(
66            magnitude.equal(&Vector::new(FP8_MAGNITUDE_MASK)),
67            nan,
68            value,
69        )
70    };
71
72    Vector::<f32, N>::reinterpret(result)
73}
74
75/// Round to nearest even; overflow and infinities saturate to the largest finite value, as the host
76/// codecs do.
77#[cube]
78pub fn f32_to_fp8_bits<N: Size>(
79    value: Vector<f32, N>,
80    #[comptime] format: Fp8Format,
81) -> Vector<u32, N> {
82    let mantissa_bits = comptime![format.mantissa_bits()];
83    let mantissa_shift = comptime![F32_MANTISSA_BITS - mantissa_bits];
84    let rebias = comptime![format.bias().wrapping_sub(F32_EXPONENT_BIAS)];
85    let half_ulp = comptime![1u32 << (mantissa_shift - 1)];
86    let subnormal_scale = comptime![1.0 / format.subnormal_step()];
87    let min_normal = comptime![format.min_normal()];
88    let max_value = comptime![format.max_value()];
89    let max_code = comptime![format.max_code()];
90    let nan_code = comptime![format.nan_code()];
91
92    let bits = Vector::<u32, N>::reinterpret(value);
93    let sign = (bits >> Vector::new(SIGN_SHIFT)) & Vector::new(FP8_SIGN_BIT);
94    let magnitude_bits = bits & Vector::new(F32_MAGNITUDE_MASK);
95    let magnitude = Vector::<f32, N>::reinterpret(magnitude_bits);
96
97    // Rounding by hand: the usual magic-number trick does not survive fast-math reassociation.
98    // `steps` overflows for normal magnitudes, which only feeds the lane the select below
99    // discards; no backend traps on a float-to-int overflow.
100    let steps = magnitude * Vector::new(subnormal_scale);
101    let truncated = Vector::<u32, N>::cast_from(steps);
102    let fraction = steps - Vector::<f32, N>::cast_from(truncated);
103    let above_half = fraction.greater_than(&Vector::new(0.5f32));
104    let tie_to_odd = fraction
105        .equal(&Vector::new(0.5f32))
106        .vec_and((truncated & Vector::new(1u32)).equal(&Vector::new(1u32)));
107    let round_up = Vector::<u32, N>::cast_from(above_half.or(tie_to_odd));
108    let subnormal = truncated + round_up;
109
110    let exponent = (magnitude_bits >> Vector::new(F32_MANTISSA_BITS)) + Vector::new(rebias);
111    let mantissa = magnitude_bits & Vector::new(F32_MANTISSA_MASK);
112    let lsb = (mantissa >> Vector::new(mantissa_shift)) & Vector::new(1u32);
113    let rounded =
114        ((exponent << Vector::new(F32_MANTISSA_BITS)) | mantissa) + Vector::new(half_ulp - 1) + lsb;
115    let normal = rounded >> Vector::new(mantissa_shift);
116
117    let code = select_many(
118        magnitude.less_than(&Vector::new(min_normal)),
119        subnormal,
120        normal,
121    );
122    let code = select_many(
123        magnitude.greater_than(&Vector::new(max_value)),
124        Vector::new(max_code),
125        code,
126    );
127    let code = select_many(
128        magnitude_bits.greater_than(&Vector::new(F32_INFINITY_BITS)),
129        Vector::new(nan_code),
130        code,
131    );
132
133    code | sign
134}
135
136/// Decode `ue8m0` codes: a bare exponent, so the code *is* the f32 exponent field.
137///
138/// Kept apart from [`fp8_bits_to_f32()`] rather than folded into it. That one splits a byte into
139/// sign, exponent and mantissa; `ue8m0` has only the exponent, spends the top bit on it, and puts
140/// its bottom code where the other formats put zero. Threading those three differences through the
141/// generic body would put a branch on every line of a function two working formats depend on.
142///
143/// Bits above the low byte are ignored.
144#[cube]
145pub fn ue8m0_bits_to_f32<N: Size>(bits: Vector<u32, N>) -> Vector<f32, N> {
146    let code = bits & Vector::new(0xFFu32);
147
148    // Codes 1..=254 are exactly f32's own exponent field, so shifting into place is the decode.
149    let shifted = code << Vector::new(F32_MANTISSA_BITS);
150
151    // The all-ones code is the format's NaN, and shifting it lands one mantissa bit short, on
152    // f32's infinity. Corrected here on the bit pattern rather than by selecting an `f32::NAN`
153    // afterwards: WGSL has no NaN literal to lower that constant to, so a float-domain select
154    // produces a shader that does not compile. `fp8_bits_to_f32` reaches its NaN the same way.
155    let quiet_bit = Vector::new(1u32 << (F32_MANTISSA_BITS - 1));
156    let is_nan = code.equal(&Vector::new(comptime![Fp8Format::UE8M0.nan_code()]));
157    let word = select_many(is_nan, shifted | quiet_bit, shifted);
158
159    // Code 0 is 2^-127, which is subnormal in f32 — the shift would land on zero instead.
160    let min = comptime![Fp8Format::UE8M0.min_normal()];
161    select_many(
162        code.equal(&Vector::new(0u32)),
163        Vector::new(min),
164        Vector::<f32, N>::reinterpret(word),
165    )
166}
167
168/// Encode to `ue8m0`, rounding **up** to the next power of two and saturating at both ends.
169///
170/// Up, not to nearest, and that is the host codec's rule rather than a choice made here: a value
171/// between two powers of two takes the one above. It suits what the format is for — a `ue8m0` is a
172/// quantization scale, and a scale rounded down puts every value at the block maximum outside the
173/// quantization range — but the reason to match it is that a tensor quantized on one backend has
174/// to reconstruct the same on another.
175#[cube]
176pub fn f32_to_ue8m0_bits<N: Size>(value: Vector<f32, N>) -> Vector<u32, N> {
177    let min = comptime![Fp8Format::UE8M0.min_normal()];
178    let max = comptime![Fp8Format::UE8M0.max_value()];
179    let max_code = comptime![Fp8Format::UE8M0.max_code()];
180
181    // Rounding up on the bit pattern: biasing by one ulp-below then clearing the mantissa carries
182    // into the exponent exactly when the mantissa was non-zero.
183    let bits = Vector::<u32, N>::reinterpret(value);
184    let carried = (bits + Vector::new(F32_MANTISSA_MASK)) & Vector::new(!F32_MANTISSA_MASK);
185    let code = carried >> Vector::new(F32_MANTISSA_BITS);
186
187    // Both ends saturate. The bottom catches zero too, which has no `ue8m0` code and which a
188    // fully-zero block calibrates to; the reconstruction is zero at any scale, so the clamp costs
189    // nothing. A NaN clears neither comparison and falls through to the NaN code.
190    let code = select_many(value.less_equal(&Vector::new(min)), Vector::new(0u32), code);
191    let code = select_many(
192        value.greater_equal(&Vector::new(max)),
193        Vector::new(max_code),
194        code,
195    );
196    select_many(
197        value.not_equal(&value),
198        Vector::new(comptime![Fp8Format::UE8M0.nan_code()]),
199        code,
200    )
201}
202
203/// How a backend without a native fp8 type holds the bytes of an fp8 vector.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
205pub enum Fp8Container {
206    /// One 8-bit integer per lane.
207    #[default]
208    Bytes,
209    /// Four lanes per `u32`, lane 0 in the low byte, for backends with no 8-bit type at all.
210    /// fp8 vectors must then be a multiple of four lanes wide.
211    Words,
212}
213
214pub type LowerMinifloatCastPass = MatchRewritePass<LowerMinifloatCast>;
215
216/// Lowers every cast from or to an fp8 format the backend does not convert natively onto the
217/// software polyfill, through `f32`.
218#[derive(new, Clone, Copy, Debug, Default, NamedRewrite)]
219pub struct LowerMinifloatCast {
220    native: EnumSet<Fp8Format>,
221    container: Fp8Container,
222}
223
224impl LowerMinifloatCast {
225    fn emulated(&self, ctx: &Context, value: impl Typed) -> Option<Fp8Format> {
226        Fp8Format::of_type(ctx, value.scalar_ty(ctx))
227            .filter(|format| !self.native.contains(*format))
228    }
229}
230
231impl MatchRewrite for LowerMinifloatCast {
232    fn r#match(&mut self, ctx: &Context, op: Ptr<Operation>) -> bool {
233        if !op.is_op::<CastOp>(ctx) {
234            return false;
235        }
236        self.emulated(ctx, op.operand(ctx, 0)).is_some()
237            || self.emulated(ctx, op.result(ctx)).is_some()
238    }
239
240    fn rewrite(
241        &mut self,
242        ctx: &mut Context,
243        rewriter: &mut MatchRewriter,
244        op: Ptr<Operation>,
245    ) -> Result<()> {
246        let scope = Scope::from_context_and_inserter(ctx, rewriter);
247        let input = op.operand(ctx, 0);
248        let result_ty = op.result(ctx).get_type(ctx);
249        let lanes = input.vector_size(ctx);
250        debug_assert_eq!(
251            lanes,
252            result_ty.vector_size(ctx),
253            "A cast keeps its vectorization, so one `N` describes both sides"
254        );
255        scope.register_size::<N>(lanes);
256
257        // Bool sources and targets go through the `f32` cast the backends already lower.
258        let mut value = input;
259        if let Some(format) = self.emulated(ctx, input) {
260            value = self.decode(&scope, value, format);
261        }
262        let value = match self.emulated(ctx, result_ty) {
263            Some(format) => self.encode(&scope, value, format, result_ty),
264            None => cast_value(&scope, value, result_ty),
265        };
266        rewriter.replace_operation_with_values(ctx, op, vec![value]);
267        Ok(())
268    }
269}
270
271impl LowerMinifloatCast {
272    fn decode(&self, scope: &Scope, value: Value, format: Fp8Format) -> Value {
273        let bits = match self.container {
274            Fp8Container::Bytes => {
275                let bytes =
276                    reinterpret_value(scope, value, Vector::<u8, N>::__expand_as_type(scope));
277                cast_value(scope, bytes, Vector::<u32, N>::__expand_as_type(scope))
278            }
279            Fp8Container::Words => {
280                let words = reinterpret_value(scope, value, words_type(scope));
281                unpack_words::expand::<N, W>(scope, words.into()).read_value(scope)
282            }
283        };
284        match format {
285            Fp8Format::UE8M0 => ue8m0_bits_to_f32::expand::<N>(scope, bits.into()),
286            _ => fp8_bits_to_f32::expand::<N>(scope, bits.into(), format),
287        }
288        .read_value(scope)
289    }
290
291    fn encode(
292        &self,
293        scope: &Scope,
294        value: Value,
295        format: Fp8Format,
296        result_ty: TypeHandle,
297    ) -> Value {
298        let value = cast_value(scope, value, Vector::<f32, N>::__expand_as_type(scope));
299        let bits = match format {
300            Fp8Format::UE8M0 => f32_to_ue8m0_bits::expand::<N>(scope, value.into()),
301            _ => f32_to_fp8_bits::expand::<N>(scope, value.into(), format),
302        }
303        .read_value(scope);
304        let container = match self.container {
305            Fp8Container::Bytes => {
306                cast_value(scope, bits, Vector::<u8, N>::__expand_as_type(scope))
307            }
308            Fp8Container::Words => {
309                register_words_size(scope);
310                pack_words::expand::<N, W>(scope, bits.into()).read_value(scope)
311            }
312        };
313        reinterpret_value(scope, container, result_ty)
314    }
315}
316
317pub type LowerMinifloatComparePass = MatchRewritePass<LowerMinifloatCompare>;
318
319/// Lowers fp8 equality onto the lanes' bit patterns.
320///
321/// No backend compares fp8 as a float. `VK_EXT_shader_float8` allows conversion, cooperative
322/// matrix multiply, and the operations that only move bits around, so a float comparison is out
323/// even where fp8 is native; without it fp8 is an integer, which a float comparison cannot read
324/// either. Comparing the bits is what a CUDA kernel already gets, where fp8 is the raw
325/// `__nv_fp8_storage_t` byte and `__nv_fp8_e4m3` declares no comparison operators at all.
326///
327/// Bit equality parts from float equality in exactly two places: `0.0` and `-0.0` are equal as
328/// floats and different as bits, and a NaN equals itself here where a float NaN does not. Scale
329/// factors, where fp8 sees most of its use, are non-negative and never NaN, so neither case
330/// reaches them.
331#[derive(new, Clone, Copy, Debug, Default, NamedRewrite)]
332pub struct LowerMinifloatCompare {
333    container: Fp8Container,
334}
335
336impl MatchRewrite for LowerMinifloatCompare {
337    fn r#match(&mut self, ctx: &Context, op: Ptr<Operation>) -> bool {
338        if !op.is_op::<FEqualOp>(ctx) && !op.is_op::<FNotEqualOp>(ctx) {
339            return false;
340        }
341        Fp8Format::of_type(ctx, op.operand(ctx, 0).scalar_ty(ctx)).is_some()
342    }
343
344    fn rewrite(
345        &mut self,
346        ctx: &mut Context,
347        rewriter: &mut MatchRewriter,
348        op: Ptr<Operation>,
349    ) -> Result<()> {
350        let equal = op.is_op::<FEqualOp>(ctx);
351        let scope = Scope::from_context_and_inserter(ctx, rewriter);
352        let lhs = op.operand(scope.ctx(), 0);
353        let rhs = op.operand(scope.ctx(), 1);
354        scope.register_size::<N>(lhs.vector_size(scope.ctx()));
355
356        let lhs = self.lanes(&scope, lhs);
357        let rhs = self.lanes(&scope, rhs);
358        let value = match self.container {
359            Fp8Container::Bytes => compare_lanes::<u8>(&scope, equal, lhs, rhs),
360            Fp8Container::Words => compare_lanes::<u32>(&scope, equal, lhs, rhs),
361        };
362        rewriter.replace_operation_with_values(ctx, op, vec![value]);
363        Ok(())
364    }
365}
366
367impl LowerMinifloatCompare {
368    /// One lane per lane, in whichever integer the container leaves them addressable in. Packed
369    /// lanes have to come apart first: comparing the words would answer once for four lanes.
370    fn lanes(&self, scope: &Scope, value: Value) -> Value {
371        match self.container {
372            Fp8Container::Bytes => {
373                reinterpret_value(scope, value, Vector::<u8, N>::__expand_as_type(scope))
374            }
375            Fp8Container::Words => {
376                let words = reinterpret_value(scope, value, words_type(scope));
377                unpack_words::expand::<N, W>(scope, words.into()).read_value(scope)
378            }
379        }
380    }
381}
382
383fn compare_lanes<T: Int>(scope: &Scope, equal: bool, lhs: Value, rhs: Value) -> Value {
384    match equal {
385        true => bits_equal::expand::<T>(scope, lhs.into(), rhs.into()).read_value(scope),
386        false => bits_not_equal::expand::<T>(scope, lhs.into(), rhs.into()).read_value(scope),
387    }
388}
389
390#[cube]
391fn bits_equal<T: Int>(lhs: Vector<T, N>, rhs: Vector<T, N>) -> Vector<bool, N> {
392    lhs.equal(&rhs)
393}
394
395#[cube]
396fn bits_not_equal<T: Int>(lhs: Vector<T, N>, rhs: Vector<T, N>) -> Vector<bool, N> {
397    lhs.not_equal(&rhs)
398}
399
400define_size!(W);
401
402const LANES_PER_WORD: usize = (u32::BITS / u8::BITS) as usize;
403
404/// Registers `W`, the word count of an `N`-lane fp8 vector, so that `Vector<u32, W>` names the
405/// words those lanes are packed into.
406fn register_words_size(scope: &Scope) {
407    let lanes = N::__expand_value(scope);
408    assert!(
409        lanes.is_multiple_of(LANES_PER_WORD),
410        "fp8 is packed four lanes to a u32 on this backend: vectors of {lanes} lanes are not \
411         supported, use a vector size that is a multiple of {LANES_PER_WORD}"
412    );
413    scope.register_size::<W>(lanes / LANES_PER_WORD);
414}
415
416/// [`register_words_size`], then the word type itself.
417fn words_type(scope: &Scope) -> TypeHandle {
418    register_words_size(scope);
419    Vector::<u32, W>::__expand_as_type(scope)
420}
421
422#[cube]
423fn unpack_words<N: Size, W: Size>(words: Vector<u32, W>) -> Vector<u32, N> {
424    let mut lanes = Vector::<u32, N>::empty();
425    #[unroll]
426    for lane in 0..N::value() {
427        let word = words.extract(lane / LANES_PER_WORD);
428        let shift = comptime![(lane % LANES_PER_WORD) as u32 * u8::BITS];
429        lanes.insert(lane, (word >> shift) & 0xFF);
430    }
431    lanes
432}
433
434#[cube]
435fn pack_words<N: Size, W: Size>(lanes: Vector<u32, N>) -> Vector<u32, W> {
436    let mut words = Vector::<u32, W>::empty();
437    #[unroll]
438    for index in 0..W::value() {
439        let mut word = 0u32;
440        #[unroll]
441        for offset in 0..LANES_PER_WORD {
442            let shift = comptime![offset as u32 * u8::BITS];
443            word |= (lanes.extract(index * LANES_PER_WORD + offset) & 0xFF) << shift;
444        }
445        words.insert(index, word);
446    }
447    words
448}