Skip to main content

rlx_compile/
precision.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Precision policy + AutoMixedPrecision rewrite pass.
17//!
18//! The `PrecisionPolicy` is a high-level declarative spec that maps
19//! op kinds to numeric precisions. The `AutoMixedPrecision` pass
20//! consumes a policy and rewrites the graph: updates each node's
21//! shape dtype + inserts Cast nodes at precision boundaries.
22//!
23//! After this pass runs, the IR carries per-node precision info via
24//! `node.shape.dtype`, and the backend just reads it to pick the
25//! right kernel variant. Backends don't need any session-level
26//! precision flag.
27
28use rlx_fusion::pass::Pass;
29use rlx_ir::*;
30use std::collections::HashMap;
31
32/// Which numeric precision to use for an op.
33/// (Subset of DType — only the ones we currently dispatch on.)
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum Precision {
36    F32,
37    F16,
38    BF16,
39}
40
41impl Precision {
42    pub fn dtype(self) -> DType {
43        match self {
44            Precision::F32 => DType::F32,
45            Precision::F16 => DType::F16,
46            Precision::BF16 => DType::BF16,
47        }
48    }
49}
50
51/// Cast configuration carried by ops that emit a typed output.
52///
53/// Inspired by TileKernels' `CastInputConfig` / `CastOutputConfig`: a single
54/// dataclass that flows from the layer down to the kernel selector, so adding
55/// new quantized formats (FP8 e4m3, FP4 e2m1, blocked scaling) becomes a
56/// matter of populating fields rather than threading new flags through call
57/// sites.
58///
59/// Today only `out_dtype` is consulted by backends — the scaling-factor
60/// fields are reserved for future quantization passes (FP8 / blocked SF).
61/// Constructed once by the precision pass and embedded in fused ops.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct CastConfig {
64    /// Destination dtype for the cast (fragment of the output tensor).
65    pub out_dtype: DType,
66    /// Scaling factor block size `(rows, cols)` for blocked quantization.
67    /// `None` means no scaling factor (plain cast).
68    pub sf_block: Option<(usize, usize)>,
69    /// Round scaling factors to powers of two (UE8M0 style).
70    pub round_sf: bool,
71}
72
73impl CastConfig {
74    /// Plain dtype cast with no scaling factor.
75    pub const fn plain(out_dtype: DType) -> Self {
76        Self {
77            out_dtype,
78            sf_block: None,
79            round_sf: false,
80        }
81    }
82    /// True when the cast does no work (out matches input dtype).
83    pub fn is_noop(&self, in_dtype: DType) -> bool {
84        self.out_dtype == in_dtype && self.sf_block.is_none()
85    }
86}
87
88/// High-level op categorization for precision policies.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum OpKind {
91    /// Matmul, FusedMatMulBiasAct, conv — compute-heavy ops that
92    /// benefit most from low precision.
93    Compute,
94    /// LayerNorm, RmsNorm, Softmax — reductions that need accuracy.
95    Reduction,
96    /// Add, Mul, GELU, SiLU — element-wise ops.
97    Elementwise,
98    /// Gather, Narrow, Reshape — data movement, no math.
99    DataMovement,
100    /// Inputs, parameters, outputs — user-facing.
101    Boundary,
102}
103
104fn op_kind(op: &Op) -> OpKind {
105    match op {
106        Op::MatMul
107        | Op::FusedMatMulBiasAct { .. }
108        | Op::Conv { .. }
109        | Op::Im2Col { .. }
110        | Op::DotGeneral { .. }
111        | Op::DenseSolve
112        | Op::BatchedDenseSolve
113        | Op::Attention { .. }
114        | Op::FusedTransformerLayer { .. }
115        | Op::GroupedMatMul
116        | Op::DequantGroupedMatMul { .. }
117        | Op::DequantMoEWeights { .. }
118        | Op::LoraMatMul { .. }
119        | Op::DequantMatMul { .. }
120        | Op::ScaledMatMul { .. }
121        | Op::QMatMul { .. }
122        | Op::QConv2d { .. }
123        | Op::Conv2dBackwardInput { .. }
124        | Op::Conv2dBackwardWeight { .. }
125        | Op::AttentionBackward { .. } => OpKind::Compute,
126        Op::LayerNorm { .. }
127        | Op::RmsNorm { .. }
128        | Op::Softmax { .. }
129        | Op::FusedResidualLN { .. }
130        | Op::FusedResidualRmsNorm { .. }
131        | Op::Reduce { .. }
132        | Op::Cumsum { .. }
133        | Op::Sample { .. }
134        | Op::SelectiveScan { .. }
135        | Op::GatedDeltaNet { .. }
136        | Op::Lstm { .. }
137        | Op::Gru { .. }
138        | Op::Rnn { .. }
139        | Op::Mamba2 { .. }
140        | Op::SoftmaxCrossEntropy
141        | Op::SoftmaxCrossEntropyWithLogits
142        | Op::SoftmaxCrossEntropyBackward
143        | Op::LayerNormBackwardInput { .. }
144        | Op::LayerNormBackwardGamma { .. }
145        | Op::GroupNorm { .. } => OpKind::Reduction,
146        Op::Activation(_)
147        | Op::Binary(_)
148        | Op::FusedSwiGLU { .. }
149        | Op::Compare(_)
150        | Op::Where
151        | Op::ElementwiseRegion { .. }
152        | Op::Quantize { .. }
153        | Op::ScaledQuantize { .. }
154        | Op::ScaledQuantScale { .. }
155        | Op::ScaledDequantize { .. }
156        | Op::Dequantize { .. }
157        | Op::FakeQuantize { .. }
158        | Op::FakeQuantizeBackward { .. }
159        | Op::FakeQuantizeLSQ { .. }
160        | Op::FakeQuantizeLSQBackwardX { .. }
161        | Op::FakeQuantizeLSQBackwardScale { .. }
162        | Op::ReluBackward
163        | Op::ActivationBackward { .. }
164        | Op::ComplexNormSq
165        | Op::ComplexNormSqBackward
166        | Op::Conjugate => OpKind::Elementwise,
167        Op::Gather { .. }
168        | Op::Narrow { .. }
169        | Op::Reshape { .. }
170        | Op::Transpose { .. }
171        | Op::Concat { .. }
172        | Op::Expand { .. }
173        | Op::Cast { .. }
174        | Op::Rope { .. }
175        | Op::Pool { .. }
176        | Op::FusedAttentionBlock { .. }
177        | Op::TopK { .. }
178        | Op::ScatterAdd
179        | Op::MaxPool2dBackward { .. }
180        | Op::ResizeNearest2x
181        | Op::AxialRope2d { .. } => OpKind::DataMovement,
182        Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => OpKind::Boundary,
183        // Control flow: treated as data movement (the inner sub-graph
184        // gets its own precision policy applied separately).
185        Op::If { .. } | Op::While { .. } => OpKind::DataMovement,
186        // Custom user-registered ops are opaque to the precision pass
187        // — classify as Compute by default; the registered op's own
188        // implementation decides what dtype it operates at.
189        Op::Custom { .. } => OpKind::Compute,
190        Op::Scan { .. } => OpKind::Compute,
191        Op::ScanBackward { .. } => OpKind::Compute,
192        Op::ScanBackwardXs { .. } => OpKind::Compute,
193        Op::CustomFn { .. } => OpKind::Compute,
194        Op::Fft { .. } => OpKind::Compute,
195        Op::FftButterflyStage { .. } => OpKind::Compute,
196        Op::LogMel => OpKind::Compute,
197        Op::LogMelBackward => OpKind::Compute,
198        _ => OpKind::Compute,
199    }
200}
201
202/// Declarative precision policy for graph compilation.
203#[derive(Debug, Clone, Default)]
204pub enum PrecisionPolicy {
205    /// All ops at F32. Default; safe; baseline accuracy.
206    #[default]
207    AlwaysF32,
208    /// All ops at F16. Maximum speed; may lose accuracy on reductions.
209    AlwaysF16,
210    /// Mixed precision, conservative variant. Forces F32 at every reduction
211    /// boundary, matching PyTorch's pre-2024 autocast and HuggingFace's
212    /// historical default. Accuracy is the highest of the AMP variants;
213    /// performance suffers from a Cast node before and after every
214    /// LayerNorm / Softmax in the graph.
215    ///   Compute → F16
216    ///   Reduction → F32  (← the cast tax — see AutoMixed for the fix)
217    ///   Elementwise → F16
218    ///   DataMovement → F16
219    ///   Boundary (input/param/output) → F32
220    AutoMixedConservative,
221    /// Mixed precision (Phase G — current default). Reductions stay in
222    /// the input dtype; the kernels themselves promote-to-f32 internally
223    /// for the accumulation. This eliminates the dozens of Cast nodes
224    /// that AutoMixedConservative inserts at LN/Softmax boundaries
225    /// without sacrificing the f32 reduction accumulation that matters.
226    /// Matches what modern PyTorch autocast actually does on Metal.
227    ///   Compute → F16
228    ///   Reduction → F16  (kernel accumulates in f32 internally)
229    ///   Elementwise → F16
230    ///   DataMovement → F16
231    ///   Boundary (input/param/output) → F32
232    AutoMixed,
233    /// Mixed precision targeting BF16 on TPU/XLA. Same shape as
234    /// `AutoMixed` (compute + reduction + elementwise + data-movement
235    /// in the chosen low precision; boundaries stay F32) but the low
236    /// precision is BF16 instead of F16. BF16 is the native compute
237    /// dtype on TPU and recent GPUs; matches what JAX picks when
238    /// `jax.config.update("jax_default_dtype_bits", "bfloat16")`.
239    ///   Compute → BF16
240    ///   Reduction → BF16  (XLA's TPU codegen accumulates in f32)
241    ///   Elementwise → BF16
242    ///   DataMovement → BF16
243    ///   Boundary → F32
244    AutoMixedBf16,
245    /// Explicit per-op-kind override.
246    Custom(HashMap<OpKind, Precision>),
247}
248
249impl PrecisionPolicy {
250    /// Resolve the target precision for an op kind.
251    pub fn precision_for(&self, kind: OpKind) -> Precision {
252        match self {
253            PrecisionPolicy::AlwaysF32 => Precision::F32,
254            PrecisionPolicy::AlwaysF16 => match kind {
255                OpKind::Boundary => Precision::F32, // user-facing stays f32
256                _ => Precision::F16,
257            },
258            PrecisionPolicy::AutoMixedConservative => match kind {
259                OpKind::Compute => Precision::F16,
260                OpKind::Reduction => Precision::F32,
261                OpKind::Elementwise => Precision::F16,
262                OpKind::DataMovement => Precision::F16,
263                OpKind::Boundary => Precision::F32,
264            },
265            PrecisionPolicy::AutoMixed => match kind {
266                OpKind::Compute => Precision::F16,
267                OpKind::Reduction => Precision::F16,
268                OpKind::Elementwise => Precision::F16,
269                OpKind::DataMovement => Precision::F16,
270                OpKind::Boundary => Precision::F32,
271            },
272            PrecisionPolicy::AutoMixedBf16 => match kind {
273                OpKind::Compute => Precision::BF16,
274                OpKind::Reduction => Precision::BF16,
275                OpKind::Elementwise => Precision::BF16,
276                OpKind::DataMovement => Precision::BF16,
277                OpKind::Boundary => Precision::F32,
278            },
279            PrecisionPolicy::Custom(map) => map.get(&kind).copied().unwrap_or(Precision::F32),
280        }
281    }
282}
283
284/// Pass that rewrites a graph according to a `PrecisionPolicy`.
285///
286/// For each node:
287/// 1. Look up the target precision based on op kind.
288/// 2. Update `node.shape.dtype` to that precision.
289/// 3. If any input has a different dtype, insert a Cast node before it.
290///
291/// After this pass, every node knows its compute precision via its
292/// shape dtype. Backends dispatch kernels per-node.
293pub struct AutoMixedPrecision {
294    pub policy: PrecisionPolicy,
295}
296
297impl AutoMixedPrecision {
298    pub fn new(policy: PrecisionPolicy) -> Self {
299        Self { policy }
300    }
301}
302
303impl Pass for AutoMixedPrecision {
304    fn name(&self) -> &str {
305        "auto_mixed_precision"
306    }
307
308    fn run(&self, graph: Graph) -> Graph {
309        // Skip the pass entirely for AlwaysF32 — it's a no-op.
310        if matches!(self.policy, PrecisionPolicy::AlwaysF32) {
311            return graph;
312        }
313
314        let mut new_graph = Graph::new(&graph.name);
315        // Maps old NodeId → new NodeId at its post-rewrite precision.
316        let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
317        // Tracks the precision each rewritten node ended up at.
318        let mut node_precision: HashMap<NodeId, Precision> = HashMap::new();
319        // Cast cache: avoid re-inserting identical Cast nodes.
320        // Key: (source new id, target precision)
321        let mut cast_cache: HashMap<(NodeId, Precision), NodeId> = HashMap::new();
322
323        for node in graph.nodes() {
324            // Native fp8 GEMM subgraph (from `scaled_quant_insert`): leave it
325            // intact. Its operands are U8 codes and its output is already f32 —
326            // relabeling the op's dtype or casting U8 inputs to f16 would
327            // corrupt it. Emit unchanged; if an f32 operand of a quantize op
328            // was lowered to f16 upstream, cast it back to f32 first.
329            if matches!(
330                node.op,
331                Op::ScaledMatMul { .. }
332                    | Op::ScaledQuantize { .. }
333                    | Op::ScaledQuantScale { .. }
334                    | Op::ScaledDequantize { .. }
335            ) {
336                let new_inputs: Vec<NodeId> = node
337                    .inputs
338                    .iter()
339                    .map(|&in_id| {
340                        let src = id_map[&in_id];
341                        let sd = new_graph.node(src).shape.dtype();
342                        if matches!(sd, DType::F16 | DType::BF16) {
343                            let shape = new_graph.node(src).shape.clone().with_dtype(DType::F32);
344                            new_graph.add_node(Op::Cast { to: DType::F32 }, vec![src], shape)
345                        } else {
346                            src
347                        }
348                    })
349                    .collect();
350                let new_id = new_graph.add_node(node.op.clone(), new_inputs, node.shape.clone());
351                id_map.insert(node.id, new_id);
352                // The matmul's output is f32; consumers cast it down as needed.
353                node_precision.insert(node.id, Precision::F32);
354                continue;
355            }
356
357            let kind = op_kind(&node.op);
358            let target = self.policy.precision_for(kind);
359
360            // Inputs / params keep their original dtype (they're external);
361            // outputs stay user-visible at F32.
362            let target = match kind {
363                OpKind::Boundary => Precision::F32,
364                _ => target,
365            };
366
367            // Resolve each input: insert a Cast if precision differs.
368            let mut new_inputs = Vec::with_capacity(node.inputs.len());
369            for &in_id in &node.inputs {
370                let src_new_id = id_map[&in_id];
371                let src_prec = node_precision
372                    .get(&in_id)
373                    .copied()
374                    .unwrap_or(Precision::F32);
375                if src_prec == target {
376                    new_inputs.push(src_new_id);
377                } else {
378                    // Insert (or reuse cached) cast
379                    let cast_id = *cast_cache.entry((src_new_id, target)).or_insert_with(|| {
380                        let shape = new_graph
381                            .node(src_new_id)
382                            .shape
383                            .clone()
384                            .with_dtype(target.dtype());
385                        new_graph.add_node(Op::Cast { to: target.dtype() }, vec![src_new_id], shape)
386                    });
387                    new_inputs.push(cast_id);
388                }
389            }
390
391            // Build the rewritten node with the target dtype on its shape.
392            let new_shape = node.shape.clone().with_dtype(target.dtype());
393            let new_id = new_graph.add_node(node.op.clone(), new_inputs, new_shape);
394            id_map.insert(node.id, new_id);
395            node_precision.insert(node.id, target);
396        }
397
398        // Outputs always stay at F32 — cast back if needed.
399        let new_outputs: Vec<NodeId> = graph
400            .outputs
401            .iter()
402            .map(|&out_id| {
403                let src_new_id = id_map[&out_id];
404                let src_prec = node_precision
405                    .get(&out_id)
406                    .copied()
407                    .unwrap_or(Precision::F32);
408                if src_prec == Precision::F32 {
409                    src_new_id
410                } else {
411                    let shape = new_graph
412                        .node(src_new_id)
413                        .shape
414                        .clone()
415                        .with_dtype(DType::F32);
416                    new_graph.add_node(Op::Cast { to: DType::F32 }, vec![src_new_id], shape)
417                }
418            })
419            .collect();
420        new_graph.set_outputs(new_outputs);
421
422        new_graph
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn always_f32_is_noop() {
432        let mut g = Graph::new("test");
433        let x = g.input("x", Shape::new(&[2, 4], DType::F32));
434        let w = g.param("w", Shape::new(&[4, 3], DType::F32));
435        let mm = g.matmul(x, w, Shape::new(&[2, 3], DType::F32));
436        g.set_outputs(vec![mm]);
437
438        let pass = AutoMixedPrecision::new(PrecisionPolicy::AlwaysF32);
439        let out = pass.run(g);
440        assert_eq!(out.len(), 3); // input, param, matmul — no casts
441    }
442
443    #[test]
444    fn auto_mixed_inserts_casts_at_boundary() {
445        let mut g = Graph::new("test");
446        let x = g.input("x", Shape::new(&[2, 4], DType::F32));
447        let w = g.param("w", Shape::new(&[4, 3], DType::F32));
448        let mm = g.matmul(x, w, Shape::new(&[2, 3], DType::F32));
449        g.set_outputs(vec![mm]);
450
451        let pass = AutoMixedPrecision::new(PrecisionPolicy::AutoMixed);
452        let out = pass.run(g);
453
454        // Should have: input(f32), param(f32), cast(f32→f16) for x,
455        // cast(f32→f16) for w, matmul(f16), cast(f16→f32) for output.
456        // = 6 nodes total, with the final output being a Cast back to F32.
457        assert!(out.len() >= 6);
458        let final_node = out.node(out.outputs[0]);
459        assert!(matches!(final_node.op, Op::Cast { to: DType::F32 }));
460    }
461}