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`](fn@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`](fn@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, its sign, exponent and mantissa
160/// moved onto the `f16`'s own, is short of its value by: `2^14`, the gap between an `e2m1` exponent of zero and an `f16` one.
161pub const E2M1_F16_LIFT: f64 = 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/// One multiply for every lane lifts the placed values ([`e2m1_words_to_f16_placed`](fn@e2m1_words_to_f16_placed)) to their
180/// values, exactly, the factor being a power of two.
181#[cube]
182pub fn e2m1_words_to_f16<W: Size, V: Size>(words: Vector<u32, W>) -> Vector<f16, V> {
183 e2m1_words_to_f16_placed::<W, V>(words) * Vector::new(f16::new(E2M1_F16_LIFT))
184}
185
186/// The eight codes of each word of `words`, lowest nibble first, placed as `f16` each
187/// [`E2M1_F16_LIFT`] short of its value, exactly: what a caller that multiplies the values by a
188/// factor anyway, a block scale, takes, multiplying the lift into that factor once rather than
189/// paying a multiply a value.
190///
191/// A word's codes `j` and `j + 4` sit sixteen bits apart, so a word is four pairs, each placed
192/// by one mask and one shift; the pairs land on their lanes by compile-time inserts.
193#[cube]
194pub fn e2m1_words_to_f16_placed<W: Size, V: Size>(words: Vector<u32, W>) -> Vector<f16, V> {
195 let mut values = Vector::<f16, V>::empty();
196 #[unroll]
197 for w in 0..W::value() {
198 let word = words.extract(w);
199 #[unroll]
200 for j in 0..4usize {
201 let shift = comptime![4 * j as u32];
202 let pair = Vector::<f16, Const<2>>::reinterpret(f16_pair_bits(word >> shift));
203 values.insert(8 * w + j, pair.extract(0usize));
204 values.insert(8 * w + j + 4, pair.extract(1usize));
205 }
206 }
207 values
208}
209
210/// [`e2m1_words_to_f16`](fn@e2m1_words_to_f16) for bytes that do not fill a word, one `e2m1x2` per lane of `bytes`:
211/// the high nibble moves up to the second half of the word first, one more mask and shift a pair.
212#[cube]
213pub fn e2m1_bytes_to_f16<B: Size, V: Size>(bytes: Vector<u32, B>) -> Vector<f16, V> {
214 let mut values = Vector::<f16, V>::empty();
215 #[unroll]
216 for b in 0..B::value() {
217 let byte = bytes.extract(b);
218 let codes = (byte & 0xF) | ((byte & 0xF0) << 12);
219 let pair = Vector::<f16, Const<2>>::reinterpret(f16_pair_bits(codes));
220 values.insert(2 * b, pair.extract(0usize));
221 values.insert(2 * b + 1, pair.extract(1usize));
222 }
223 values * Vector::new(f16::new(E2M1_F16_LIFT))
224}
225
226/// The codes of the `e2m1x2` bytes in `bytes`, one a lane, low nibble first: what the `f32`
227/// decode reads.
228#[cube]
229fn bytes_to_codes<B: Size, V: Size>(bytes: Vector<u32, B>) -> Vector<u32, V> {
230 let mut codes = Vector::<u32, V>::empty();
231 #[unroll]
232 for b in 0..B::value() {
233 let byte = bytes.extract(b);
234 codes.insert(2 * b, byte & NIBBLE);
235 codes.insert(2 * b + 1, (byte >> 4) & NIBBLE);
236 }
237 codes
238}
239
240/// The `e2m1x2` bytes two codes a lane make, low nibble first: what an encode stores.
241#[cube]
242fn codes_to_bytes<V: Size, B: Size>(codes: Vector<u32, V>) -> Vector<u32, B> {
243 let mut bytes = Vector::<u32, B>::empty();
244 #[unroll]
245 for b in 0..B::value() {
246 bytes.insert(
247 b,
248 (codes.extract(2 * b) & NIBBLE) | ((codes.extract(2 * b + 1) & NIBBLE) << 4),
249 );
250 }
251 bytes
252}
253
254/// Bytes, one a lane, packed four to a word, lane 0 in the low byte.
255#[cube]
256fn bytes_to_words<B: Size, W: Size>(bytes: Vector<u32, B>) -> Vector<u32, W> {
257 let mut words = Vector::<u32, W>::empty();
258 #[unroll]
259 for w in 0..W::value() {
260 let mut word = 0u32;
261 #[unroll]
262 for offset in 0..4usize {
263 let shift = comptime![8 * offset as u32];
264 word |= (bytes.extract(4 * w + offset) & 0xFF) << shift;
265 }
266 words.insert(w, word);
267 }
268 words
269}
270
271/// Words, each four bytes, as one byte a lane, lane 0 in the low byte.
272#[cube]
273fn words_to_bytes<W: Size, B: Size>(words: Vector<u32, W>) -> Vector<u32, B> {
274 let mut bytes = Vector::<u32, B>::empty();
275 #[unroll]
276 for b in 0..B::value() {
277 let shift = comptime![8 * (b % 4) as u32];
278 bytes.insert(b, (words.extract(b / 4) >> shift) & 0xFF);
279 }
280 bytes
281}
282
283define_size!(B);
284define_size!(V);
285define_size!(W);
286
287pub type LowerFp4CastPass = MatchRewritePass<LowerFp4Cast>;
288
289/// Lowers every cast from or to `e2m1x2` onto the software codec, for a backend that has no
290/// native fp4 conversion.
291///
292/// The decode goes through `f16` pairs ([`e2m1_words_to_f16`](fn@e2m1_words_to_f16)) where the backend has `f16`, and
293/// through `f32` ([`e2m1_bits_to_float`](fn@e2m1_bits_to_float)) where it does not: the pairs decode two codes for what
294/// the `f32` path spends on one and need no select, and the backend is what knows whether `f16`
295/// exists. A cast whose bytes fill whole words decodes a word at a time, codes four apart
296/// sharing a pair. The encode is [`float_to_e2m1_bits`](fn@float_to_e2m1_bits) either way.
297#[derive(new, Clone, Copy, Debug, Default, NamedRewrite)]
298pub struct LowerFp4Cast {
299 /// Whether the backend computes in `f16`.
300 half: bool,
301 container: Fp8Container,
302}
303
304fn is_e2m1x2(ctx: &Context, ty: TypeHandle) -> bool {
305 ty.scalar_ty(ctx).deref(ctx).is::<Float4E2M1x2Type>()
306}
307
308impl MatchRewrite for LowerFp4Cast {
309 fn r#match(&mut self, ctx: &Context, op: Ptr<Operation>) -> bool {
310 if !op.is_op::<CastOp>(ctx) {
311 return false;
312 }
313 is_e2m1x2(ctx, op.operand(ctx, 0).get_type(ctx))
314 || is_e2m1x2(ctx, op.result(ctx).get_type(ctx))
315 }
316
317 fn rewrite(
318 &mut self,
319 ctx: &mut Context,
320 rewriter: &mut MatchRewriter,
321 op: Ptr<Operation>,
322 ) -> Result<()> {
323 let scope = Scope::from_context_and_inserter(ctx, rewriter);
324 let input = op.operand(ctx, 0);
325 let result_ty = op.result(ctx).get_type(ctx);
326 let value = if is_e2m1x2(ctx, input.get_type(ctx)) {
327 self.decode(&scope, input, result_ty)
328 } else {
329 self.encode(&scope, input, result_ty)
330 };
331 rewriter.replace_operation_with_values(ctx, op, vec![value]);
332 Ok(())
333 }
334}
335
336impl LowerFp4Cast {
337 /// `input`'s bytes as `u32` lanes, one byte a lane.
338 fn byte_lanes(&self, scope: &Scope, input: Value, bytes: usize) -> Value {
339 match self.container {
340 Fp8Container::Bytes => {
341 let lanes =
342 reinterpret_value(scope, input, Vector::<u8, B>::__expand_as_type(scope));
343 cast_value(scope, lanes, Vector::<u32, B>::__expand_as_type(scope))
344 }
345 Fp8Container::Words => {
346 let words = reinterpret_value(scope, input, self.words_type(scope, bytes));
347 words_to_bytes::expand::<W, B>(scope, words.into()).read_value(scope)
348 }
349 }
350 }
351
352 /// Registers `W`, the words `bytes` bytes fill, and names their type.
353 fn words_type(&self, scope: &Scope, bytes: usize) -> TypeHandle {
354 assert!(
355 bytes.is_multiple_of(4),
356 "fp4 is packed four bytes to a u32 on this backend: vectors of {bytes} e2m1x2 lanes \
357 are not supported, use a multiple of four"
358 );
359 scope.register_size::<W>(bytes / 4);
360 Vector::<u32, W>::__expand_as_type(scope)
361 }
362
363 fn decode(&self, scope: &Scope, input: Value, result_ty: TypeHandle) -> Value {
364 let ctx = scope.ctx();
365 let bytes = input.vector_size(ctx);
366 let values = result_ty.vector_size(ctx);
367 debug_assert_eq!(values, 2 * bytes, "an e2m1x2 lane casts to two values");
368 scope.register_size::<B>(bytes);
369 scope.register_size::<V>(values);
370 let decoded = match (self.half, bytes.is_multiple_of(4)) {
371 (true, true) => {
372 let words = reinterpret_value(scope, input, self.words_type(scope, bytes));
373 e2m1_words_to_f16::expand::<W, V>(scope, words.into()).read_value(scope)
374 }
375 (true, false) => {
376 let lanes = self.byte_lanes(scope, input, bytes);
377 e2m1_bytes_to_f16::expand::<B, V>(scope, lanes.into()).read_value(scope)
378 }
379 (false, _) => {
380 let lanes = self.byte_lanes(scope, input, bytes);
381 let codes = bytes_to_codes::expand::<B, V>(scope, lanes.into());
382 e2m1_bits_to_float::expand::<f32, V>(scope, codes).read_value(scope)
383 }
384 };
385 cast_value(scope, decoded, result_ty)
386 }
387
388 fn encode(&self, scope: &Scope, input: Value, result_ty: TypeHandle) -> Value {
389 let ctx = scope.ctx();
390 let values = input.vector_size(ctx);
391 let bytes = result_ty.vector_size(ctx);
392 debug_assert_eq!(values, 2 * bytes, "two values cast to an e2m1x2 lane");
393 scope.register_size::<B>(bytes);
394 scope.register_size::<V>(values);
395 let value = cast_value(scope, input, Vector::<f32, V>::__expand_as_type(scope));
396 let codes = float_to_e2m1_bits::expand::<f32, V>(scope, value.into());
397 let packed = codes_to_bytes::expand::<V, B>(scope, codes);
398 let container = match self.container {
399 Fp8Container::Bytes => cast_value(
400 scope,
401 packed.read_value(scope),
402 Vector::<u8, B>::__expand_as_type(scope),
403 ),
404 Fp8Container::Words => {
405 self.words_type(scope, bytes);
406 bytes_to_words::expand::<B, W>(scope, packed).read_value(scope)
407 }
408 };
409 reinterpret_value(scope, container, result_ty)
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use cubecl_common::e2m1;
416
417 /// The rounding this module implements is round-to-nearest with ties to even, which is what
418 /// `e2m1` itself does. The kernel reaches it by counting cleared midpoints and `e2m1` by a
419 /// different route, so the two agreeing is the specification holding rather than a tautology.
420 ///
421 /// Ties are not an edge case on a grid this coarse: `0.75` and `2.5` are both exact midpoints
422 /// of neighbouring code points, and rounding them all outward would bias every quantized block
423 /// upward by a visible amount rather than by a rounding error.
424 #[test]
425 fn the_midpoints_round_to_even() {
426 for (midpoint, expected) in [
427 (0.25f32, 0.0f32),
428 (0.75, 1.0),
429 (1.25, 1.0),
430 (1.75, 2.0),
431 (2.5, 2.0),
432 (3.5, 4.0),
433 (5.0, 4.0),
434 ] {
435 let landed = e2m1::from_f32(midpoint).to_f32();
436 assert_eq!(landed, expected, "{midpoint} rounded to {landed}");
437 }
438 }
439
440 /// Everything past the top magnitude saturates rather than wrapping or reaching a NaN code —
441 /// `e2m1` has neither an infinity nor a NaN to land on. The kernel's count of cleared
442 /// midpoints saturates by construction, so this pins the reference it is checked against.
443 #[test]
444 fn magnitudes_past_the_maximum_saturate() {
445 for value in [6.0f32, 6.1, 100.0, f32::MAX, f32::INFINITY] {
446 assert_eq!(e2m1::from_f32(value).to_f32(), 6.0);
447 assert_eq!(e2m1::from_f32(-value).to_f32(), -6.0);
448 }
449 }
450
451 /// The sign is carried onto the negative zero that code `0x8` names, which is the one code
452 /// telling `-0.0` from `0.0` depends on.
453 #[test]
454 fn the_sign_bit_survives_a_round_trip() {
455 for code in 8..16u8 {
456 let value = e2m1::from_bits(code).to_f32();
457 assert!(value.is_sign_negative(), "code {code} decoded as {value}");
458 assert_eq!(e2m1::from_f32(value).to_bits(), code);
459 }
460 }
461}