Skip to main content

rlx_ir/
dtype.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Element data types for tensors.
6
7/// Scalar element type. Matches hardware-supported types.
8#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum DType {
11    F32,
12    F16,
13    BF16,
14    F64,
15    I8,
16    I16,
17    I32,
18    I64,
19    U8,
20    U32,
21    Bool,
22    /// Complex with f32 real and f32 imaginary components, stored
23    /// interleaved as `[re, im, re, im, ...]`. 8 bytes per complex
24    /// element. Element-wise ops (Add/Sub/Mul/Conj) follow the
25    /// standard complex algebra. Reverse-mode AD on this dtype is
26    /// **not yet wired** — Wirtinger conventions (∂/∂z vs ∂/∂z̄)
27    /// belong to a separate pass that knows to emit conjugate-aware
28    /// VJPs. The forward path is sufficient for AC analysis and
29    /// FFT-based workflows that don't need to differentiate through
30    /// complex math (and in fact, FFT today already encodes complex
31    /// as 2N-real-block; this dtype is the natural successor).
32    C64,
33    /// Complex with f64 real and f64 imaginary components, stored
34    /// interleaved as `[re, im, re, im, ...]`. 16 bytes per complex
35    /// element — the double-precision sibling of [`Self::C64`]. This
36    /// dtype covers the dtype plumbing (size/flags/promotion/Display)
37    /// and the CAST matrix only: complex *arithmetic* (FFT, cgemm,
38    /// complex binary/activation, Wirtinger AD) remains C64-only, and
39    /// those sites reject C128 rather than compute on it.
40    C128,
41}
42
43impl DType {
44    /// Size in bytes of one element.
45    pub const fn size_bytes(self) -> usize {
46        match self {
47            Self::Bool | Self::I8 | Self::U8 => 1,
48            Self::F16 | Self::BF16 | Self::I16 => 2,
49            Self::F32 | Self::I32 | Self::U32 => 4,
50            Self::F64 | Self::I64 | Self::C64 => 8,
51            Self::C128 => 16,
52        }
53    }
54
55    pub const fn is_float(self) -> bool {
56        matches!(self, Self::F32 | Self::F16 | Self::BF16 | Self::F64)
57    }
58
59    /// True for complex-valued dtypes. Complex elementwise ops follow
60    /// standard complex algebra, distinct from the float real/imag
61    /// components (e.g. complex multiply ≠ paired-real multiply).
62    pub const fn is_complex(self) -> bool {
63        matches!(self, Self::C64 | Self::C128)
64    }
65
66    pub const fn is_int(self) -> bool {
67        matches!(
68            self,
69            Self::I8 | Self::I16 | Self::I32 | Self::I64 | Self::U8 | Self::U32
70        )
71    }
72
73    /// Promotion rank — higher means "wider, more expressive". The
74    /// promoted dtype of a binary op is `max(rank(lhs), rank(rhs))`.
75    /// Borrowed from MAX's `dtype_promotion.py` pattern: one module
76    /// owns the table; ops query it instead of re-implementing ad-hoc
77    /// rules.
78    ///
79    /// Ranks (low → high):
80    ///   0 = Bool, 1 = U8/I8, 2 = I16/BF16, 3 = F16, 4 = U32/I32,
81    ///   5 = I64, 6 = F32, 7 = F64, 8 = C64, 9 = C128.
82    /// Floats outrank ints of the same width (matches PyTorch /
83    /// NumPy). BF16 promotes to F32 against F16 since BF16 has
84    /// wider range but F16 has more mantissa.
85    pub const fn promotion_rank(self) -> u8 {
86        match self {
87            Self::Bool => 0,
88            Self::U8 | Self::I8 => 1,
89            Self::I16 | Self::BF16 => 2,
90            Self::F16 => 3,
91            Self::U32 | Self::I32 => 4,
92            Self::I64 => 5,
93            Self::F32 => 6,
94            Self::F64 => 7,
95            Self::C64 => 8,
96            // C128 (complex f64) outranks C64 (complex f32): a binary
97            // op mixing the two promotes to C128 (wider components).
98            Self::C128 => 9,
99        }
100    }
101
102    /// Result dtype for a binary op between `self` and `other`.
103    /// Mixed int+float → float at least as wide as either input.
104    /// `f16 + bf16 → f32` (no clean lossless target).
105    pub fn promote(self, other: Self) -> Self {
106        if self == other {
107            return self;
108        }
109        // Special case: f16 + bf16 → f32 (their domains are too
110        // different to lose precision in either direction).
111        if matches!(
112            (self, other),
113            (Self::F16, Self::BF16) | (Self::BF16, Self::F16)
114        ) {
115            return Self::F32;
116        }
117        // Mixed int+float: bump to the smallest float that covers both.
118        let promote_int_to_float = |int: Self, float: Self| -> Self {
119            match (int, float) {
120                (_, Self::F64) => Self::F64,
121                (Self::I64, _) => Self::F64, // 64-bit int needs F64
122                (_, Self::F32) => Self::F32,
123                (_, Self::F16) | (_, Self::BF16) => Self::F32, // safe upcast
124                _ => float,
125            }
126        };
127        match (
128            self.is_int(),
129            other.is_int(),
130            self.is_float(),
131            other.is_float(),
132        ) {
133            (true, false, false, true) => promote_int_to_float(self, other),
134            (false, true, true, false) => promote_int_to_float(other, self),
135            _ => {
136                if self.promotion_rank() >= other.promotion_rank() {
137                    self
138                } else {
139                    other
140                }
141            }
142        }
143    }
144}
145
146fn integral_scalar(value: f64, name: &str) -> Result<i64, String> {
147    if !value.is_finite() {
148        return Err(format!(
149            "constant value {value} is not finite for dtype {name}"
150        ));
151    }
152    if value.fract() != 0.0 {
153        return Err(format!(
154            "constant value {value} must be integral for dtype {name}"
155        ));
156    }
157    Ok(value as i64)
158}
159
160/// Encode a scalar as little-endian bytes for [`crate::op::Op::Constant`].
161pub fn scalar_constant_bytes(value: f64, dtype: DType) -> Result<Vec<u8>, String> {
162    let out_of_range =
163        |name: &str| format!("constant value {value} is out of range for dtype {name}");
164    match dtype {
165        DType::F32 => Ok((value as f32).to_le_bytes().to_vec()),
166        DType::F64 => Ok(value.to_le_bytes().to_vec()),
167        DType::I8 => {
168            let v = integral_scalar(value, "i8")?;
169            if !(i8::MIN as i64..=i8::MAX as i64).contains(&v) {
170                return Err(out_of_range("i8"));
171            }
172            Ok((v as i8).to_le_bytes().to_vec())
173        }
174        DType::I16 => {
175            let v = integral_scalar(value, "i16")?;
176            if !(i16::MIN as i64..=i16::MAX as i64).contains(&v) {
177                return Err(out_of_range("i16"));
178            }
179            Ok((v as i16).to_le_bytes().to_vec())
180        }
181        DType::I32 => {
182            let v = integral_scalar(value, "i32")?;
183            if !(i32::MIN as i64..=i32::MAX as i64).contains(&v) {
184                return Err(out_of_range("i32"));
185            }
186            Ok((v as i32).to_le_bytes().to_vec())
187        }
188        DType::I64 => {
189            if !value.is_finite() {
190                return Err(format!(
191                    "constant value {value} is not finite for dtype i64"
192                ));
193            }
194            if value.fract() != 0.0 {
195                return Err(format!(
196                    "constant value {value} must be integral for dtype i64"
197                ));
198            }
199            // `i64::MAX as f64` rounds up to 2^63; use open bounds at ±2^63.
200            if value >= 9.223372036854776e18 || value < -9.223372036854776e18 {
201                return Err(out_of_range("i64"));
202            }
203            Ok((value as i64).to_le_bytes().to_vec())
204        }
205        DType::U8 => {
206            let v = integral_scalar(value, "u8")?;
207            if !(0..=u8::MAX as i64).contains(&v) {
208                return Err(out_of_range("u8"));
209            }
210            Ok((v as u8).to_le_bytes().to_vec())
211        }
212        DType::U32 => {
213            let v = integral_scalar(value, "u32")?;
214            if v < 0 || v > u32::MAX as i64 {
215                return Err(out_of_range("u32"));
216            }
217            Ok((v as u32).to_le_bytes().to_vec())
218        }
219        DType::Bool => Ok(vec![u8::from(value != 0.0)]),
220        DType::F16 | DType::BF16 | DType::C64 | DType::C128 => Err(format!(
221            "scalar literal dtype '{dtype:?}' is built via f32 constant + cast"
222        )),
223    }
224}
225
226/// Per-element semantics that don't fit into a flat `DType` enum
227/// (plan #40). Mirrors MAX's `layout/element.mojo` `Element` type:
228/// `DType` says "f8", but two FP8 variants exist (e4m3 and e5m2)
229/// with different range/precision tradeoffs. Saturation policy
230/// (clamp on overflow vs. wrap) is similarly orthogonal.
231///
232/// Today most ops only care about `dtype`; downstream quantization
233/// kernels read `subtype` and `saturating` to pick the right
234/// dequant. Building this in early prevents the "every op grew its
235/// own ad-hoc fp8 flag" mess MAX hit in v1.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
237pub struct Element {
238    pub dtype: DType,
239    /// Subtype within `dtype` for FP8 variants etc. `Standard`
240    /// for everything else.
241    pub subtype: ElementSubtype,
242    /// Whether arithmetic saturates on overflow (true for the
243    /// quantized accumulator paths) or wraps (default).
244    pub saturating: bool,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
248pub enum ElementSubtype {
249    Standard,
250    /// FP8 e4m3 (4 exp bits, 3 mantissa) — lower range, more
251    /// precision; matches NVIDIA's "FNUZ" Hopper format.
252    Fp8E4m3,
253    /// FP8 e5m2 (5 exp bits, 2 mantissa) — wider range, less
254    /// precision; closer to bf16 in dynamic range.
255    Fp8E5m2,
256}
257
258impl Element {
259    pub const fn new(dtype: DType) -> Self {
260        Self {
261            dtype,
262            subtype: ElementSubtype::Standard,
263            saturating: false,
264        }
265    }
266    pub const fn fp8_e4m3() -> Self {
267        Self {
268            dtype: DType::U8,
269            subtype: ElementSubtype::Fp8E4m3,
270            saturating: true,
271        }
272    }
273    pub const fn fp8_e5m2() -> Self {
274        Self {
275            dtype: DType::U8,
276            subtype: ElementSubtype::Fp8E5m2,
277            saturating: true,
278        }
279    }
280    pub const fn saturating(self) -> Self {
281        Self {
282            saturating: true,
283            ..self
284        }
285    }
286}
287
288impl std::fmt::Display for DType {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        match self {
291            Self::F32 => write!(f, "f32"),
292            Self::F16 => write!(f, "f16"),
293            Self::BF16 => write!(f, "bf16"),
294            Self::F64 => write!(f, "f64"),
295            Self::I8 => write!(f, "i8"),
296            Self::I16 => write!(f, "i16"),
297            Self::I32 => write!(f, "i32"),
298            Self::I64 => write!(f, "i64"),
299            Self::U8 => write!(f, "u8"),
300            Self::U32 => write!(f, "u32"),
301            Self::Bool => write!(f, "bool"),
302            Self::C64 => write!(f, "c64"),
303            Self::C128 => write!(f, "c128"),
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn element_constructors() {
314        let f = Element::new(DType::F32);
315        assert_eq!(f.dtype, DType::F32);
316        assert_eq!(f.subtype, ElementSubtype::Standard);
317        assert!(!f.saturating);
318
319        let e4 = Element::fp8_e4m3();
320        assert_eq!(e4.subtype, ElementSubtype::Fp8E4m3);
321        assert!(e4.saturating);
322        assert_eq!(e4.dtype, DType::U8);
323
324        let s = Element::new(DType::I32).saturating();
325        assert!(s.saturating);
326        assert_eq!(s.dtype, DType::I32);
327    }
328
329    #[test]
330    fn promote_same() {
331        assert_eq!(DType::F32.promote(DType::F32), DType::F32);
332        assert_eq!(DType::I8.promote(DType::I8), DType::I8);
333    }
334
335    #[test]
336    fn promote_int_widening() {
337        assert_eq!(DType::I8.promote(DType::I16), DType::I16);
338        assert_eq!(DType::I32.promote(DType::I64), DType::I64);
339    }
340
341    #[test]
342    fn promote_int_to_float() {
343        assert_eq!(DType::I32.promote(DType::F32), DType::F32);
344        assert_eq!(DType::I64.promote(DType::F32), DType::F64);
345        assert_eq!(DType::I8.promote(DType::F16), DType::F32);
346    }
347
348    #[test]
349    fn promote_f16_bf16_goes_to_f32() {
350        assert_eq!(DType::F16.promote(DType::BF16), DType::F32);
351        assert_eq!(DType::BF16.promote(DType::F16), DType::F32);
352    }
353
354    #[test]
355    fn promote_is_commutative_for_well_defined_pairs() {
356        let pairs = [
357            (DType::F32, DType::F16),
358            (DType::I32, DType::F64),
359            (DType::Bool, DType::I8),
360        ];
361        for (a, b) in pairs {
362            assert_eq!(
363                a.promote(b),
364                b.promote(a),
365                "promote({a},{b}) should equal promote({b},{a})"
366            );
367        }
368    }
369
370    #[test]
371    fn scalar_constant_bytes_round_trips() {
372        assert_eq!(
373            scalar_constant_bytes(2.5, DType::F32).unwrap(),
374            2.5f32.to_le_bytes().to_vec()
375        );
376        assert_eq!(
377            scalar_constant_bytes(-1.0, DType::F64).unwrap(),
378            (-1.0f64).to_le_bytes().to_vec()
379        );
380        assert_eq!(
381            scalar_constant_bytes(7.0, DType::I32).unwrap(),
382            7i32.to_le_bytes()
383        );
384        assert_eq!(scalar_constant_bytes(0.0, DType::Bool).unwrap(), vec![0]);
385        assert_eq!(scalar_constant_bytes(1.0, DType::Bool).unwrap(), vec![1]);
386    }
387
388    #[test]
389    fn scalar_constant_bytes_rejects_out_of_range() {
390        assert!(scalar_constant_bytes(128.0, DType::I8).is_err());
391        assert!(scalar_constant_bytes(-1.0, DType::U32).is_err());
392        assert!(scalar_constant_bytes(9.223372036854776e18, DType::I64).is_err());
393        assert!(scalar_constant_bytes(2.5, DType::I32).is_err());
394    }
395
396    #[test]
397    fn scalar_constant_bytes_rejects_low_precision_direct() {
398        assert!(scalar_constant_bytes(1.0, DType::F16).is_err());
399        assert!(scalar_constant_bytes(1.0, DType::BF16).is_err());
400        assert!(scalar_constant_bytes(1.0, DType::C64).is_err());
401        assert!(scalar_constant_bytes(1.0, DType::C128).is_err());
402    }
403
404    #[test]
405    fn c128_size_and_flags() {
406        // 16 bytes: interleaved (re: f64, im: f64), double C64's 8.
407        assert_eq!(DType::C128.size_bytes(), 16);
408        assert_eq!(DType::C64.size_bytes(), 8);
409        // Complex flag: both C64 and C128 are complex, nothing else is.
410        assert!(DType::C128.is_complex());
411        assert!(DType::C64.is_complex());
412        assert!(!DType::F64.is_complex());
413        // C128 is neither float nor int.
414        assert!(!DType::C128.is_float());
415        assert!(!DType::C128.is_int());
416        assert_eq!(DType::C128.to_string(), "c128");
417    }
418
419    #[test]
420    fn c128_promotion() {
421        // C128 outranks C64; mixing the two promotes to C128.
422        assert_eq!(DType::C64.promote(DType::C128), DType::C128);
423        assert_eq!(DType::C128.promote(DType::C64), DType::C128);
424        assert_eq!(DType::C128.promote(DType::C128), DType::C128);
425        assert!(DType::C128.promotion_rank() > DType::C64.promotion_rank());
426    }
427}