fuel_ir/dtype.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Types for elements that can be stored and manipulated using tensors.
3//!
4//! The [`DType`] enum represents the element type of a tensor, and the [`WithDType`] trait
5//! allows Rust native types to be used with tensors.
6#![allow(clippy::redundant_closure_call)]
7use crate::{Error, Result};
8
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12/// The different types of elements allowed in tensors.
13// EXHAUSTIVE-BY-DESIGN: `DType` is a closed set. The consumers that map a dtype
14// to a concrete per-variant fact — `size_in_bytes`, `as_str`, the host-buffer
15// allocation tables (fuel-cpu-backend), the safetensors/FDX/DLPack tag maps
16// (`--features dlpack`), and scalar `zero`/`one`/`from_f64` — match it
17// exhaustively, and several are CROSS-CRATE, so a wildcard there cannot produce a
18// correct answer: it would fabricate a byte size / tag and silently corrupt.
19// Adding a variant is a breaking change: gate it workspace-wide AND under every
20// feature that has a consumer (`--features cuda`/`vulkan`/`dlpack`), never just
21// `-p fuel-ir`. Do NOT add `#[non_exhaustive]` here — it would force wildcards
22// onto those cross-crate exhaustive tables and route a new dtype to the wrong
23// buffer. (The `DType::ALL` + `_all_variants_witness` pair below is the existing
24// compile-time tripwire this marker names. Consumers that *decline* an unknown
25// dtype loudly — `Err`/`bail!`/`None` — are fine and expected; only the
26// exhaustive concrete-fact maps are load-bearing.) See GAP-049.
27#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
28#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
29pub enum DType {
30 /// Unsigned 8-bit integer.
31 U8,
32 /// Signed 8-bit integer. Added 2026-05-19 for int8 GEMM (W8A8
33 /// inference) support — baracuda's `gemm_s8_*` family accepts
34 /// `i8` operands and `mma.sync` `.s8.s8.s32` instructions.
35 I8,
36 /// Unsigned 32-bit integer.
37 U32,
38 /// Signed 16-bit integer.
39 I16,
40 /// Signed 32-bit integer.
41 I32,
42 /// Signed 64-bit integer.
43 I64,
44 /// Brain floating-point using half precision (16 bits).
45 BF16,
46 /// Floating-point using half precision (16 bits).
47 F16,
48 /// Floating-point using single precision (32 bits).
49 F32,
50 /// Floating-point using double precision (64 bits).
51 F64,
52 /// 8-bit floating point, 4-bit exponent + 3-bit mantissa — specifically
53 /// **OCP finite E4M3FN**: bias 7, max-finite ±448, no infinities, single
54 /// NaN. This is the only E4M3 format Fuel represents, so the bare name is
55 /// unambiguous today (enforced by
56 /// `reserved_token_tests::exactly_one_e4m3_family_dtype_or_revisit_gap_169`).
57 ///
58 /// The identity `F8E4M3 == OCP E4M3FN` is committed and tested at three
59 /// independent sites: the structure-key path (`fuel-dispatch`
60 /// `telemetry::baracuda_provider::map_element_kind`), the JIT seam
61 /// (`fuel-dispatch` `jit_adopt::fp8_maps_to_baracuda_ocp_element_kinds`),
62 /// and the external-contract parser (`fuel-dispatch` `fkc::lower`'s
63 /// "F8E4M3FNUZ must not lower to F8E4M3" prefix rejection).
64 ///
65 /// **The name is deliberately NOT `F8E4M3FN`** (GAP-169). A rename would
66 /// touch ~110 sites across every backend, cascade through the
67 /// `cuda_dtype!` / `ctor!` macros into `CudaStorageSlice` / `PinnedBuffer`,
68 /// and break 3 `fuel-metal-backend` sites that cannot be compiled on the
69 /// primary dev box — all for reader clarity only. The interop token is
70 /// already `f8e4m3fn` on the sk4 path (`token_kind`), and this dtype's own
71 /// token stays `f8e4m3` for the checked-in `.fkc.md` contracts that author
72 /// it (see the `RESERVED_DTYPE_TOKENS` note below). Revisit only when a
73 /// second E4M3-family variant (e.g. `F8E4M3FNUZ`) actually lands — the
74 /// witness above fires then.
75 F8E4M3,
76 /// 8-bit floating point with 5-bit exponent and 2-bit mantissa — the
77 /// OCP-standard FP8 pair's other half.
78 ///
79 /// E4M3 (above) carries more mantissa and is the forward-pass format;
80 /// E5M2 trades mantissa for exponent range and is the gradient format.
81 /// Shipping one without the other leaves FP8 training unrepresentable,
82 /// and `fuel-cuda-backend`'s cast table already documents six baracuda
83 /// cast pairs it skips for want of this variant. Distinct from
84 /// [`F8E6M2`](Self::F8E6M2), which is a non-OCP block-scale type.
85 F8E5M2,
86 /// 6-bit float with 2 exponent bits and 3 mantissa bits (MX6 format).
87 F6E2M3,
88 /// 6-bit float with 3 exponent bits and 2 mantissa bits (MX6 format).
89 F6E3M2,
90 /// 4-bit float (MX4 format).
91 F4,
92 /// 8-bit float with 8 exponent bits and 0 mantissa bits.
93 F8E8M0,
94 /// Unsigned 8-bit block-scale: 6 exponent bits, 2 mantissa bits, no sign
95 /// (0+6+2=8). A finer-granularity sibling of [`F8E8M0`](Self::F8E8M0) —
96 /// trades 2 exponent bits for 2 mantissa — so a scale type, not a signed
97 /// value dtype. (The "8-bit float, 6 exp, 2 mant" framing it shipped with in
98 /// e275538c was a misread: 1+6+2=9 doesn't fit a signed byte.)
99 F8E6M2,
100 /// Boolean truth value, one byte per element (`0` = false, `1` = true).
101 ///
102 /// The output dtype of the element-wise comparisons
103 /// (`eq`/`ne`/`lt`/`le`/`gt`/`ge`) — GAP-168(c) / CireSnave's 2026-08-12
104 /// ruling that Fuel "uses `Bool` where possible and casts to `U8` when
105 /// necessary". Storage width equals `u8` (KISS-CLASSIFY `bool`: "1-byte
106 /// truth value; storage width equals `u8`"), so a backend may store it in
107 /// a `U8` buffer — but it is a DISTINCT logical dtype: neither an integer
108 /// nor a floating-point value (`is_int` and `is_float` are both `false`),
109 /// so using a mask AS A NUMBER (multiply/sum/accumulate) is rejected at
110 /// build time and extracting a number requires an explicit `.cast(U8)`.
111 Bool,
112}
113
114impl DType {
115 /// Every [`DType`] variant, in declaration order.
116 ///
117 /// Completeness is REMINDED, not compiler-derived (GAP-248). `_all_variants_witness`
118 /// below is a wildcard-free `match`, so adding a `DType` fails to compile THERE — a
119 /// variant cannot be added without being dragged to this file. But extending THIS
120 /// list in the same edit is then convention: nothing ties `ALL` to the witness, so a
121 /// variant added to the match yet forgotten here would still compile. A hand-written
122 /// `const` (not a derived iterator) because the anchor is a compile-time tripwire —
123 /// the residual gap is that it reminds rather than derives. Measured complete at head
124 /// (`ALL` = witness = enum = 18). The enforced version is a single-source macro
125 /// declaring the enum and `ALL` together; see GAP-248.
126 pub const ALL: &'static [DType] = &[
127 DType::U8,
128 DType::I8,
129 DType::U32,
130 DType::I16,
131 DType::I32,
132 DType::I64,
133 DType::BF16,
134 DType::F16,
135 DType::F32,
136 DType::F64,
137 DType::F8E4M3,
138 DType::F8E5M2,
139 DType::F6E2M3,
140 DType::F6E3M2,
141 DType::F4,
142 DType::F8E8M0,
143 DType::F8E6M2,
144 DType::Bool,
145 ];
146}
147
148#[cfg(test)]
149mod reserved_token_tests {
150 use super::*;
151 use std::str::FromStr;
152
153 /// A reserved spelling (KISS-CLASSIFY §6.1's reserved vocabulary) must be
154 /// recognized and declined **distinguishably from an unknown token**.
155 ///
156 /// The whole claim is in one assertion pair: both reject, and they reject
157 /// DIFFERENTLY — matchably, not merely with different prose. (Scope: this
158 /// is the §6.1 vocabulary applied to Fuel's own parser, not §6.1-0001
159 /// conformance — see [`RESERVED_DTYPE_TOKENS`].)
160 #[test]
161 fn reserved_fnuz_declines_distinctly_from_an_unknown_token() {
162 for tok in RESERVED_DTYPE_TOKENS {
163 let err = DType::from_str(tok).expect_err("a reserved token must not parse");
164 assert!(
165 err.is_reserved(),
166 "{tok} is reserved by §6.1 and must decline AS reserved, not as \
167 unknown — a caller cannot otherwise tell 'wait for a schema bump' \
168 from 'fix your input'",
169 );
170 }
171
172 // The other side of the distinction. Without this, a deriver that
173 // reported EVERYTHING as reserved would pass the loop above.
174 let unknown = DType::from_str("asdf").expect_err("garbage must not parse");
175 assert!(
176 !unknown.is_reserved(),
177 "an unknown token must NOT report as reserved"
178 );
179 assert_eq!(unknown, DTypeParseError::Unknown("asdf".into()));
180 }
181
182 /// The FKC contract spelling is uppercase; the reserved list is canonical
183 /// lowercase. One list serves both, so the comparison must be
184 /// case-insensitive — otherwise the FKC site silently loses the
185 /// distinction while this one keeps it.
186 #[test]
187 fn reserved_lookup_is_case_insensitive_so_one_list_serves_both_sites() {
188 assert!(is_reserved_dtype_token("F8E4M3FNUZ"));
189 assert!(is_reserved_dtype_token("f8e5m2fnuz"));
190 assert!(
191 !is_reserved_dtype_token("f8e4m3fn"),
192 "the OCP form is NOT reserved"
193 );
194 assert!(!is_reserved_dtype_token("asdf"));
195 }
196
197 /// **The delimiter trap, named so the next reader sees it.** Every
198 /// supported token here is a PREFIX of a reserved one: `f8e4m3` prefixes
199 /// `f8e4m3fnuz`, `f8e5m2` prefixes `f8e5m2fnuz`. Any `starts_with` /
200 /// `contains` / prefix-strip classifier therefore reads the RESERVED token
201 /// as the SUPPORTED one — and the caller then computes on `fnuz` bytes as
202 /// if they were OCP, which is silent numerical corruption rather than a
203 /// loud failure.
204 ///
205 /// This site is safe *by construction* (`FromStr` is an exact-literal
206 /// `match`, `is_reserved_dtype_token` is whole-token `eq_ignore_ascii_case`)
207 /// — but "safe by construction" is a property of today's implementation,
208 /// so it is asserted rather than assumed. Both directions are checked: a
209 /// reserved token must not resolve to its prefix's dtype, and a supported
210 /// token must not be swept up as reserved.
211 #[test]
212 fn a_reserved_token_is_never_confused_with_the_supported_token_it_extends() {
213 // Direction 1 — the dangerous one. A prefix matcher would return
214 // Ok(F8E4M3) / Ok(F8E5M2) here.
215 assert!(
216 !matches!(DType::from_str("f8e4m3fnuz"), Ok(DType::F8E4M3)),
217 "f8e4m3fnuz must NOT parse as its OCP prefix f8e4m3 — different \
218 exponent bias, so the bytes decode to different numbers",
219 );
220 assert!(
221 !matches!(DType::from_str("f8e5m2fnuz"), Ok(DType::F8E5M2)),
222 "f8e5m2fnuz must NOT parse as its OCP prefix f8e5m2",
223 );
224
225 // Direction 2 — the mirror. A `contains`-style reserved check would
226 // sweep the supported tokens in, declining dtypes Fuel really has.
227 assert!(!is_reserved_dtype_token("f8e4m3"));
228 assert!(!is_reserved_dtype_token("f8e5m2"));
229
230 // Non-vacuity: these ARE genuine prefix pairs, so the assertions above
231 // are exercising the trap and not passing on unrelated strings.
232 for tok in RESERVED_DTYPE_TOKENS {
233 assert!(
234 DType::ALL.iter().any(|d| tok.starts_with(d.as_str())),
235 "{tok} was expected to EXTEND a supported token; if that is no \
236 longer true this test has stopped testing the prefix trap",
237 );
238 }
239 }
240
241 /// A reserved token must never also be parseable as a real dtype — i.e. the
242 /// reserved list and the accepted set must not overlap. Guards the mirror
243 /// mistake: adding `fnuz` to `FromStr`'s accept arms later would make the
244 /// reserved arm unreachable and silently activate an unimplemented layout.
245 #[test]
246 fn reserved_tokens_are_disjoint_from_the_accepted_set() {
247 for tok in RESERVED_DTYPE_TOKENS {
248 assert!(
249 DType::from_str(tok).is_err(),
250 "{tok} is reserved and must not resolve to a DType",
251 );
252 }
253 // Non-vacuity: the accept path genuinely works, so the loop above is
254 // asserting rejection rather than a broken parser rejecting everything.
255 assert_eq!(DType::from_str("f8e4m3").unwrap(), DType::F8E4M3);
256 assert_eq!(DType::from_str("f8e5m2").unwrap(), DType::F8E5M2);
257 }
258
259 /// **GAP-169 deferral expiry witness.** The bare name `F8E4M3` is
260 /// unambiguous only while there is exactly ONE E4M3-family dtype — today,
261 /// the OCP-finite `F8E4M3`. If a second ever lands (e.g. an AMD
262 /// `F8E4M3FNUZ`: different bias, no −0), the bare name becomes ambiguous and
263 /// the rename deferred in GAP-169 (see `DType::F8E4M3`'s doc) must be
264 /// revisited. This asserts the **premise** (exactly one E4M3 today), not the
265 /// conclusion (the name is fine).
266 ///
267 /// **Completeness is inherited, not assumed:** the population is
268 /// [`DType::ALL`], which [`_all_variants_witness`] REMINDS the author to keep
269 /// exhaustive (a variant cannot be added without a compile error at the witness,
270 /// though nothing forces it into `ALL` — GAP-248; measured complete at head), and
271 /// the count is derived through
272 /// the same `is_e4m3_family` predicate that feeds the assertion — so the
273 /// perturbation control (widen the predicate, watch it fire at two) actually
274 /// exercises the detection path. Keep the filter on `DType::ALL`: this file
275 /// also holds the raw literals `"f8e4m3fn"` / `"f8e4m3fnuz"` (reserved-token
276 /// rejection), which are not any variant's [`as_str`](DType::as_str), so
277 /// pointing the predicate at raw tokens would inflate the count with no
278 /// variant added.
279 ///
280 /// **Bound:** this detects a sibling *spelled* `f8e4m3*`, not the E4M3
281 /// *format* in the abstract — a sibling spelled otherwise (e.g. a bare
282 /// `e4m3fnuz`, the pre-respell sk4 form) would slip past. The prefix match
283 /// is deliberate and is NOT the delimiter trap: that trap is a guard firing
284 /// on a longer string it did not mean to catch, whereas this witness WANTS
285 /// every longer `f8e4m3*` spelling — so `starts_with` is the intended
286 /// semantics; do not "fix" it into `==`, which silently destroys the
287 /// detection. The token it keys on cannot drift: GAP-169 ruled `as_str()`
288 /// must stay `f8e4m3` because external `.fkc.md` contracts parse it, so the
289 /// deferral protects its own expiry witness.
290 #[test]
291 fn exactly_one_e4m3_family_dtype_or_revisit_gap_169() {
292 fn is_e4m3_family(dt: DType) -> bool {
293 // Prefix, not equality: every `f8e4m3*` spelling (e.g. a future
294 // `f8e4m3fnuz`) is E4M3-family and must count. See the bound above.
295 dt.as_str().starts_with("f8e4m3")
296 }
297 let e4m3: Vec<DType> = DType::ALL
298 .iter()
299 .copied()
300 .filter(|&dt| is_e4m3_family(dt))
301 .collect();
302 assert_eq!(
303 e4m3.as_slice(),
304 [DType::F8E4M3].as_slice(),
305 "GAP-169 deferral premise broken: the E4M3 family now has {} members \
306 ({e4m3:?}), not one. A sibling E4M3 variant exists, so the bare \
307 `F8E4M3` name is now ambiguous — revisit GAP-169 (rename to \
308 F8E4M3FN vs the sibling; see DType::F8E4M3's doc).",
309 e4m3.len(),
310 );
311 }
312
313 /// Every variant in [`DType::ALL`] must return a consistent byte count
314 /// from [`size_in_bytes`](DType::size_in_bytes). An incorrect value
315 /// silently corrupts memory allocation, stride computation, and buffer
316 /// sizing — so this is a regression gate: a new variant that forgets its
317 /// size gets caught here rather than in a downstream OOM.
318 ///
319 /// Sub-byte types (F6E2M3, F6E3M2, F4) legitimately return 0 — they are
320 /// packed in hardware and have no natural byte-aligned storage width.
321 #[test]
322 fn size_in_bytes_is_consistent_for_every_variant() {
323 let expected: &[(DType, usize)] = &[
324 (DType::U8, 1),
325 (DType::I8, 1),
326 (DType::U32, 4),
327 (DType::I16, 2),
328 (DType::I32, 4),
329 (DType::I64, 8),
330 (DType::BF16, 2),
331 (DType::F16, 2),
332 (DType::F32, 4),
333 (DType::F64, 8),
334 (DType::F8E4M3, 1),
335 (DType::F8E5M2, 1),
336 (DType::F6E2M3, 0),
337 (DType::F6E3M2, 0),
338 (DType::F4, 0),
339 (DType::F8E8M0, 1),
340 (DType::F8E6M2, 1),
341 (DType::Bool, 1),
342 ];
343 assert_eq!(
344 DType::ALL.len(),
345 expected.len(),
346 "DType::ALL has {} variants but expected list has {} — \
347 add the new variant to the expected list above",
348 DType::ALL.len(),
349 expected.len(),
350 );
351 let map: std::collections::HashMap<DType, usize> = expected.iter().copied().collect();
352 for &dt in DType::ALL {
353 let size = map
354 .get(&dt)
355 .copied()
356 .unwrap_or_else(|| panic!("DType::{dt:?} missing from expected list"));
357 assert_eq!(
358 dt.size_in_bytes(),
359 size,
360 "{dt:?}.size_in_bytes() returned {} but expected {size}",
361 dt.size_in_bytes(),
362 );
363 }
364 }
365}
366
367/// Compile-time completeness tripwire for [`DType::ALL`]. This wildcard-free
368/// `match` names every variant, so adding a `DType` fails to compile HERE
369/// (non-exhaustive `match`) until the new variant is handled — and
370/// [`DType::ALL`] is to be extended in the same edit. Never called; it exists
371/// only so the compiler forces acknowledgement of every variant.
372fn _all_variants_witness(dt: DType) {
373 match dt {
374 DType::U8
375 | DType::I8
376 | DType::U32
377 | DType::I16
378 | DType::I32
379 | DType::I64
380 | DType::BF16
381 | DType::F16
382 | DType::F32
383 | DType::F64
384 | DType::F8E4M3
385 | DType::F8E5M2
386 | DType::F6E2M3
387 | DType::F6E3M2
388 | DType::F4
389 | DType::F8E8M0
390 | DType::F8E6M2
391 | DType::Bool => {}
392 }
393}
394
395/// The **single source of truth** for KISS-CLASSIFY §6.1 tokens that are
396/// RESERVED at this schema version.
397///
398/// Canonical lowercase; compare case-insensitively so the FKC contract
399/// spelling (`F8E4M3FNUZ`) resolves against the same list. **One list, two
400/// call sites** — `FromStr` here and the FKC token table in
401/// `fuel-dispatch/src/fkc/lower.rs` — because two hand-copied lists is the
402/// drift pattern this project keeps re-finding.
403///
404/// These are the AMD `fnuz` FP8 layouts. They are **byte-incompatible** with
405/// their OCP counterparts (different exponent bias, no −0, no infinities), so
406/// treating one as the other silently corrupts every element rather than
407/// failing — which is why the two rejections are worth telling apart.
408///
409/// # Provenance, and the spelling that changed
410///
411/// Verbatim from KISS-CLASSIFY at **sk4**: `conformance/src/structure_key.rs`
412/// `RESERVED_DTYPES: [&str; 2]`. sk3 spelled these `e4m3fnuz` / `e5m2fnuz`;
413/// sk4 respells them with the `f8` prefix. **A stale list here would be wrong
414/// in the most expensive way** — tokens that never occur decline nothing, and
415/// every test still passes. Re-check this list against KISS on any schema
416/// bump; read it with `git show origin/main:<path>`, since a KISS working tree
417/// may sit behind its own `origin/main`.
418///
419/// # Scope — what these do and do NOT discharge
420///
421/// KISS-CLASSIFY §6.1-0001 places its obligation on a **reader of a
422/// `structure_key`**. Fuel has no such reader: it DERIVES and emits keys, and
423/// treats foreign ones as opaque bytes by design (K1 opacity —
424/// `telemetry::structure_key::StructureKeyToken`). Measured, not assumed: no
425/// split/decode path over a structure_key exists outside test code, which
426/// parses only Fuel's own just-emitted token to assert its shape.
427///
428/// So this list does **not** make Fuel §6.1-0001-conformant; there is
429/// currently no site in Fuel where that clause applies. What it does is
430/// (a) harden the two dtype-token surfaces Fuel really does ingest — its own
431/// `FromStr` and FKC contract files, which are external, hand-authored input —
432/// so a correctly-spelled `f8e5m2fnuz` is told it is reserved rather than sent
433/// hunting for a typo, and (b) put the vocabulary and the decline shape in one
434/// place for the day Fuel gains a key reader.
435///
436/// Note the two vocabularies differ elsewhere even though they agree here:
437/// Fuel's internal token for the OCP type is `f8e4m3`, while the KISS seam
438/// spells it `f8e4m3fn` (see `telemetry::structure_key_derive`). The reserved
439/// spellings coincide only because Fuel has no `fnuz` type at all.
440///
441/// # This decline EXPIRES (GAP-161)
442///
443/// sk4 §6.1-0001 gives reserved spellings "no computation semantics at this
444/// schema version" and calls activation "a future additive schema event". So
445/// unlike a decline that is permanently correct, this one has a **scheduled
446/// trigger** — it becomes wrong when KISS activates the tokens. What
447/// distinguishes it from a missing-kernel decline is not that it never expires
448/// but that its trigger is **external and announced**: it cannot quietly
449/// become true through a Fuel-side change. The maintenance that follows is to
450/// watch KISS's `RESERVED_DTYPES` on a schema bump, and this const is the
451/// single grep target to flip when that happens.
452pub const RESERVED_DTYPE_TOKENS: &[&str] = &["f8e4m3fnuz", "f8e5m2fnuz"];
453
454/// Is `s` a §6.1 token that is RESERVED at this schema version?
455pub fn is_reserved_dtype_token(s: &str) -> bool {
456 RESERVED_DTYPE_TOKENS
457 .iter()
458 .any(|t| s.eq_ignore_ascii_case(t))
459}
460
461/// Error returned when a string cannot be parsed as a [`DType`].
462///
463/// # Why this distinguishes two rejections
464///
465/// `f8e5m2fnuz` is a real, specified layout this schema version declines to
466/// compute on; `asdf` is not a dtype at all. Those are different facts about
467/// the world, and they imply different actions — *wait for a schema bump* vs
468/// *fix your input*. Collapsing them tells a caller "no" without telling it
469/// *which* no.
470///
471/// **The distinction is a VARIANT, not a message.** A differing `Display`
472/// string is prose a caller cannot act on; the obligation is to a caller of
473/// this API, so it has to be matchable.
474///
475/// For what this does and does not discharge — in particular that it is NOT
476/// KISS-CLASSIFY §6.1-0001 conformance, because that clause binds a reader of
477/// a `structure_key` and Fuel has none — and for the scheduled external expiry
478/// this decline carries, see [`RESERVED_DTYPE_TOKENS`].
479#[derive(Debug, PartialEq, Eq)]
480pub enum DTypeParseError {
481 /// Not a §6.1 dtype token at all.
482 Unknown(String),
483 /// A §6.1 token that is **reserved** at this schema version: recognized,
484 /// deliberately not computed on.
485 Reserved(String),
486}
487
488impl DTypeParseError {
489 /// The offending token, whichever rejection this is.
490 pub fn token(&self) -> &str {
491 match self {
492 DTypeParseError::Unknown(t) | DTypeParseError::Reserved(t) => t,
493 }
494 }
495
496 /// Was this a recognized-but-reserved spelling?
497 pub fn is_reserved(&self) -> bool {
498 matches!(self, DTypeParseError::Reserved(_))
499 }
500}
501
502impl std::fmt::Display for DTypeParseError {
503 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504 match self {
505 DTypeParseError::Unknown(t) => write!(f, "cannot parse '{t}' as a dtype"),
506 DTypeParseError::Reserved(t) => write!(
507 f,
508 "dtype token '{t}' is RESERVED at this schema version \
509 (KISS-CLASSIFY §6.1-0001): it names a real layout that is \
510 byte-incompatible with its OCP counterpart, and is deliberately \
511 not computed on — this is not an unknown token"
512 ),
513 }
514 }
515}
516
517impl std::error::Error for DTypeParseError {}
518
519impl std::str::FromStr for DType {
520 type Err = DTypeParseError;
521 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
522 match s {
523 "u8" => Ok(Self::U8),
524 "i8" => Ok(Self::I8),
525 "u32" => Ok(Self::U32),
526 "i16" => Ok(Self::I16),
527 "i32" => Ok(Self::I32),
528 "i64" => Ok(Self::I64),
529 "bf16" => Ok(Self::BF16),
530 "f16" => Ok(Self::F16),
531 "f32" => Ok(Self::F32),
532 "f64" => Ok(Self::F64),
533 "f8e4m3" => Ok(Self::F8E4M3),
534 "f8e5m2" => Ok(Self::F8E5M2),
535 "f6e2m3" => Ok(Self::F6E2M3),
536 "f6e3m2" => Ok(Self::F6E3M2),
537 "f4" => Ok(Self::F4),
538 "f8e8m0" => Ok(Self::F8E8M0),
539 "f8e6m2" => Ok(Self::F8E6M2),
540 "bool" => Ok(Self::Bool),
541 // §6.1-0001: a RESERVED spelling is recognized and declined
542 // DISTINCTLY from an unknown one. Deleting this arm makes
543 // `reserved_fnuz_declines_distinctly_from_an_unknown_token` fail —
544 // that is the born-red evidence for this change, and it was
545 // observed (3 passed / 1 failed, `Compiling fuel-ir` in the run).
546 _ if is_reserved_dtype_token(s) => Err(DTypeParseError::Reserved(s.to_string())),
547 _ => Err(DTypeParseError::Unknown(s.to_string())),
548 }
549 }
550}
551
552impl DType {
553 /// Returns the string representation of this dtype.
554 pub fn as_str(&self) -> &'static str {
555 match self {
556 Self::U8 => "u8",
557 Self::I8 => "i8",
558 Self::U32 => "u32",
559 Self::I16 => "i16",
560 Self::I32 => "i32",
561 Self::I64 => "i64",
562 Self::BF16 => "bf16",
563 Self::F16 => "f16",
564 Self::F32 => "f32",
565 Self::F64 => "f64",
566 Self::F8E4M3 => "f8e4m3",
567 Self::F8E5M2 => "f8e5m2",
568 Self::F6E2M3 => "f6e2m3",
569 Self::F6E3M2 => "f6e3m2",
570 Self::F4 => "f4",
571 Self::F8E8M0 => "f8e8m0",
572 Self::F8E6M2 => "f8e6m2",
573 Self::Bool => "bool",
574 }
575 }
576
577 /// Returns the size used by each element in bytes.
578 ///
579 /// Returns 0 for sub-byte types (F6E2M3, F6E3M2, F4).
580 pub fn size_in_bytes(&self) -> usize {
581 match self {
582 Self::U8 => 1,
583 Self::I8 => 1,
584 Self::U32 => 4,
585 Self::I16 => 2,
586 Self::I32 => 4,
587 Self::I64 => 8,
588 Self::BF16 => 2,
589 Self::F16 => 2,
590 Self::F32 => 4,
591 Self::F64 => 8,
592 Self::F8E4M3 => 1,
593 Self::F8E5M2 => 1,
594 Self::F6E2M3 => 0,
595 Self::F6E3M2 => 0,
596 Self::F4 => 0,
597 Self::F8E8M0 => 1,
598 Self::F8E6M2 => 1,
599 // Boolean: one byte per element (storage width equals u8).
600 Self::Bool => 1,
601 }
602 }
603
604 /// Returns `true` if this is an integer type (U8, I8, U32, I16, I32, I64).
605 pub fn is_int(&self) -> bool {
606 match self {
607 Self::U8 | Self::I8 | Self::U32 | Self::I16 | Self::I32 | Self::I64 => true,
608 Self::BF16
609 | Self::F16
610 | Self::F32
611 | Self::F64
612 | Self::F8E4M3
613 | Self::F8E5M2
614 | Self::F6E2M3
615 | Self::F6E3M2
616 | Self::F4
617 | Self::F8E8M0
618 | Self::F8E6M2
619 // Bool is a truth value, not an integer.
620 | Self::Bool => false,
621 }
622 }
623
624 /// Returns `true` if this is a floating-point type.
625 pub fn is_float(&self) -> bool {
626 match self {
627 // Bool is a truth value, neither integer nor float.
628 Self::U8 | Self::I8 | Self::U32 | Self::I16 | Self::I32 | Self::I64 | Self::Bool => {
629 false
630 }
631 Self::BF16
632 | Self::F16
633 | Self::F32
634 | Self::F64
635 | Self::F8E4M3
636 | Self::F8E5M2
637 | Self::F6E2M3
638 | Self::F6E3M2
639 | Self::F4
640 | Self::F8E8M0
641 | Self::F8E6M2 => true,
642 }
643 }
644}
645
646/// Trait for Rust types that can be stored as tensor elements.
647///
648/// This maps Rust native types (e.g., `f32`, `u8`) to their corresponding [`DType`].
649/// It provides conversion routines between Rust values and CPU tensor storage.
650pub trait WithDType:
651 Sized
652 + Copy
653 + num_traits::NumAssign
654 + std::cmp::PartialOrd
655 + std::fmt::Display
656 + 'static
657 + Send
658 + Sync
659 + std::any::Any
660{
661 const DTYPE: DType;
662
663 fn from_f64(v: f64) -> Self;
664 fn to_f64(self) -> f64;
665 fn to_scalar(self) -> crate::scalar::Scalar;
666}
667
668// B0.4 (WithDType weld break, part 1): the host-buffer conversion methods
669// (`cpu_storage_ref`/`to_cpu_storage_owned`/`to_cpu_storage`/
670// `cpu_storage_as_slice`/`cpu_storage_data`) moved off `WithDType` onto the
671// `HostDType: WithDType` extension trait in `cpu_storage.rs` (where `HostBuffer`
672// lives) — this breaks the `dtype <-> cpu_storage` cycle.
673//
674// B0.5 (WithDType weld break, part 2): the `VecOps` supertrait (the `dtype -> cpu/`
675// edge) is GONE — `cpu/` moved to the dedicated `fuel-cpu-kernels` crate, and the
676// ~5 fuel-cpu-backend call sites that need `T::vec_dot`/`T::vec_reduce_*` now carry
677// an explicit `T: fuel_cpu_kernels::VecOps` bound. `WithDType` is now pure vocabulary
678// with no impl edges out of fuel-ir.
679
680macro_rules! with_dtype {
681 ($ty:ty, $dtype:ident, $from_f64:expr, $to_f64:expr) => {
682 impl WithDType for $ty {
683 const DTYPE: DType = DType::$dtype;
684
685 fn from_f64(v: f64) -> Self {
686 $from_f64(v)
687 }
688
689 fn to_f64(self) -> f64 {
690 $to_f64(self)
691 }
692
693 fn to_scalar(self) -> crate::scalar::Scalar {
694 crate::scalar::Scalar::$dtype(self)
695 }
696 }
697 };
698}
699use float8::F8E4M3 as f8e4m3;
700use half::{bf16, f16};
701
702with_dtype!(u8, U8, |v: f64| v as u8, |v: u8| v as f64);
703with_dtype!(i8, I8, |v: f64| v as i8, |v: i8| v as f64);
704with_dtype!(u32, U32, |v: f64| v as u32, |v: u32| v as f64);
705with_dtype!(i16, I16, |v: f64| v as i16, |v: i16| v as f64);
706with_dtype!(i32, I32, |v: f64| v as i32, |v: i32| v as f64);
707with_dtype!(i64, I64, |v: f64| v as i64, |v: i64| v as f64);
708with_dtype!(f16, F16, f16::from_f64, f16::to_f64);
709with_dtype!(bf16, BF16, bf16::from_f64, bf16::to_f64);
710with_dtype!(f32, F32, |v: f64| v as f32, |v: f32| v as f64);
711with_dtype!(f64, F64, |v: f64| v, |v: f64| v);
712with_dtype!(f8e4m3, F8E4M3, f8e4m3::from_f64, |v: f8e4m3| v.to_f64());
713
714/// Trait for integer element types that can be used with tensor indexing and masking.
715///
716/// Implemented for `u8`, `u32`, `i16`, `i32`, and `i64`.
717pub trait IntDType: WithDType + num_traits::Bounded {
718 fn is_true(&self) -> bool;
719 fn as_usize(&self) -> usize;
720}
721
722impl IntDType for i64 {
723 fn is_true(&self) -> bool {
724 *self != 0
725 }
726 fn as_usize(&self) -> usize {
727 *self as usize
728 }
729}
730
731impl IntDType for u32 {
732 fn is_true(&self) -> bool {
733 *self != 0
734 }
735 fn as_usize(&self) -> usize {
736 *self as usize
737 }
738}
739
740impl IntDType for u8 {
741 fn is_true(&self) -> bool {
742 *self != 0
743 }
744 fn as_usize(&self) -> usize {
745 *self as usize
746 }
747}
748
749impl IntDType for i8 {
750 fn is_true(&self) -> bool {
751 *self != 0
752 }
753 fn as_usize(&self) -> usize {
754 *self as usize
755 }
756}
757
758impl IntDType for i16 {
759 fn is_true(&self) -> bool {
760 *self != 0
761 }
762 fn as_usize(&self) -> usize {
763 *self as usize
764 }
765}
766
767impl IntDType for i32 {
768 fn is_true(&self) -> bool {
769 *self != 0
770 }
771 fn as_usize(&self) -> usize {
772 *self as usize
773 }
774}
775
776/// Trait for floating-point element types.
777pub trait FloatDType: WithDType {}
778
779impl FloatDType for f16 {}
780impl FloatDType for bf16 {}
781impl FloatDType for f32 {}
782impl FloatDType for f64 {}
783impl FloatDType for f8e4m3 {}
784
785// Safetensors interop: DType <-> safetensors::Dtype conversions
786use safetensors::tensor as st;
787
788impl From<DType> for st::Dtype {
789 fn from(value: DType) -> Self {
790 match value {
791 DType::U8 => st::Dtype::U8,
792 DType::I8 => st::Dtype::I8,
793 DType::U32 => st::Dtype::U32,
794 DType::I16 => st::Dtype::I16,
795 DType::I32 => st::Dtype::I32,
796 DType::I64 => st::Dtype::I64,
797 DType::BF16 => st::Dtype::BF16,
798 DType::F16 => st::Dtype::F16,
799 DType::F32 => st::Dtype::F32,
800 DType::F64 => st::Dtype::F64,
801 DType::F8E4M3 => st::Dtype::F8_E4M3,
802 DType::F8E5M2 => st::Dtype::F8_E5M2,
803 DType::F6E2M3 => st::Dtype::F6_E2M3,
804 DType::F6E3M2 => st::Dtype::F6_E3M2,
805 DType::F4 => st::Dtype::F4,
806 DType::F8E8M0 => st::Dtype::F8_E8M0,
807 DType::Bool => st::Dtype::BOOL,
808 // safetensors 0.7.0 tracks the OCP MX + standard FP8 set
809 // (E4M3/E5M2/E8M0/F6/F4). F8E6M2 is a non-OCP-standard token with no
810 // safetensors representation, so it cannot be serialized there.
811 DType::F8E6M2 => panic!(
812 "F8E6M2 has no safetensors representation (0.7.0 supports \
813 E4M3/E5M2/E8M0/F6/F4, not E6M2)"
814 ),
815 }
816 }
817}
818
819impl TryFrom<st::Dtype> for DType {
820 type Error = Error;
821 fn try_from(value: st::Dtype) -> Result<Self> {
822 match value {
823 st::Dtype::U8 => Ok(DType::U8),
824 st::Dtype::I8 => Ok(DType::I8),
825 st::Dtype::U32 => Ok(DType::U32),
826 st::Dtype::I16 => Ok(DType::I16),
827 st::Dtype::I32 => Ok(DType::I32),
828 st::Dtype::I64 => Ok(DType::I64),
829 st::Dtype::BF16 => Ok(DType::BF16),
830 st::Dtype::F16 => Ok(DType::F16),
831 st::Dtype::F32 => Ok(DType::F32),
832 st::Dtype::F64 => Ok(DType::F64),
833 st::Dtype::F8_E4M3 => Ok(DType::F8E4M3),
834 st::Dtype::F8_E5M2 => Ok(DType::F8E5M2),
835 st::Dtype::F6_E2M3 => Ok(DType::F6E2M3),
836 st::Dtype::F6_E3M2 => Ok(DType::F6E3M2),
837 st::Dtype::F4 => Ok(DType::F4),
838 st::Dtype::F8_E8M0 => Ok(DType::F8E8M0),
839 st::Dtype::BOOL => Ok(DType::Bool),
840 dtype => Err(Error::UnsupportedSafeTensorDtype(dtype)),
841 }
842 }
843}