Skip to main content

rlx_compile/
precision.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//! Precision policy + AutoMixedPrecision rewrite pass.
6//!
7//! The `PrecisionPolicy` is a high-level declarative spec that maps
8//! op kinds to numeric precisions. The `AutoMixedPrecision` pass
9//! consumes a policy and rewrites the graph: updates each node's
10//! shape dtype + inserts Cast nodes at precision boundaries.
11//!
12//! After this pass runs, the IR carries per-node precision info via
13//! `node.shape.dtype`, and the backend just reads it to pick the
14//! right kernel variant. Backends don't need any session-level
15//! precision flag.
16
17use rlx_fusion::pass::Pass;
18use rlx_ir::*;
19use std::collections::HashMap;
20
21/// Which numeric precision to use for an op.
22/// (Subset of DType — only the ones we currently dispatch on.)
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum Precision {
25    F32,
26    F16,
27    BF16,
28}
29
30impl Precision {
31    pub fn dtype(self) -> DType {
32        match self {
33            Precision::F32 => DType::F32,
34            Precision::F16 => DType::F16,
35            Precision::BF16 => DType::BF16,
36        }
37    }
38}
39
40/// Cast configuration carried by ops that emit a typed output.
41///
42/// Inspired by TileKernels' `CastInputConfig` / `CastOutputConfig`: a single
43/// dataclass that flows from the layer down to the kernel selector, so adding
44/// new quantized formats (FP8 e4m3, FP4 e2m1, blocked scaling) becomes a
45/// matter of populating fields rather than threading new flags through call
46/// sites.
47///
48/// Today only `out_dtype` is consulted by backends — the scaling-factor
49/// fields are reserved for future quantization passes (FP8 / blocked SF).
50/// Constructed once by the precision pass and embedded in fused ops.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct CastConfig {
53    /// Destination dtype for the cast (fragment of the output tensor).
54    pub out_dtype: DType,
55    /// Scaling factor block size `(rows, cols)` for blocked quantization.
56    /// `None` means no scaling factor (plain cast).
57    pub sf_block: Option<(usize, usize)>,
58    /// Round scaling factors to powers of two (UE8M0 style).
59    pub round_sf: bool,
60}
61
62impl CastConfig {
63    /// Plain dtype cast with no scaling factor.
64    pub const fn plain(out_dtype: DType) -> Self {
65        Self {
66            out_dtype,
67            sf_block: None,
68            round_sf: false,
69        }
70    }
71    /// True when the cast does no work (out matches input dtype).
72    pub fn is_noop(&self, in_dtype: DType) -> bool {
73        self.out_dtype == in_dtype && self.sf_block.is_none()
74    }
75}
76
77/// High-level op categorization for precision policies.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum OpKind {
80    /// Matmul, FusedMatMulBiasAct, conv — compute-heavy ops that
81    /// benefit most from low precision.
82    Compute,
83    /// LayerNorm, RmsNorm, Softmax — reductions that need accuracy.
84    Reduction,
85    /// Add, Mul, GELU, SiLU — element-wise ops.
86    Elementwise,
87    /// Gather, Narrow, Reshape — data movement, no math.
88    DataMovement,
89    /// Inputs, parameters, outputs — user-facing.
90    Boundary,
91}
92
93fn op_kind(op: &Op) -> OpKind {
94    match op {
95        Op::MatMul
96        | Op::FusedMatMulBiasAct { .. }
97        | Op::FusedConvBiasAct { .. }
98        | Op::Conv { .. }
99        | Op::Im2Col { .. }
100        | Op::DotGeneral { .. }
101        | Op::DenseSolve
102        | Op::BatchedDenseSolve
103        | Op::Attention { .. }
104        | Op::FusedTransformerLayer { .. }
105        | Op::GroupedMatMul
106        | Op::DequantGroupedMatMul { .. }
107        | Op::DequantMoEWeights { .. }
108        | Op::LoraMatMul { .. }
109        | Op::DequantMatMul { .. }
110        | Op::SynthMatMul { .. }
111        | Op::ScaledMatMul { .. }
112        | Op::QMatMul { .. }
113        | Op::QConv2d { .. }
114        | Op::Conv2dBackwardInput { .. }
115        | Op::Conv2dBackwardWeight { .. }
116        | Op::AttentionBackward { .. } => OpKind::Compute,
117        Op::LayerNorm { .. }
118        | Op::RmsNorm { .. }
119        | Op::Softmax { .. }
120        | Op::FusedResidualLN { .. }
121        | Op::FusedResidualRmsNorm { .. }
122        | Op::Reduce { .. }
123        | Op::Cumsum { .. }
124        | Op::Sample { .. }
125        | Op::SelectiveScan { .. }
126        | Op::GatedDeltaNet { .. }
127        | Op::Lstm { .. }
128        | Op::Gru { .. }
129        | Op::Rnn { .. }
130        | Op::Mamba2 { .. }
131        | Op::SoftmaxCrossEntropy
132        | Op::SoftmaxCrossEntropyWithLogits
133        | Op::SoftmaxCrossEntropyBackward
134        | Op::LayerNormBackwardInput { .. }
135        | Op::LayerNormBackwardGamma { .. }
136        | Op::GroupNorm { .. }
137        // DiT adaLN — same reduction-accumulate story as LayerNorm.
138        | Op::AdaLayerNorm { .. }
139        | Op::AdaLayerNormBackward { .. } => OpKind::Reduction,
140        Op::Activation(_)
141        | Op::Binary(_)
142        | Op::FusedSwiGLU { .. }
143        | Op::Compare(_)
144        | Op::Where
145        | Op::ElementwiseRegion { .. }
146        | Op::Quantize { .. }
147        | Op::ScaledQuantize { .. }
148        | Op::ScaledQuantScale { .. }
149        | Op::ScaledDequantize { .. }
150        | Op::Dequantize { .. }
151        | Op::FakeQuantize { .. }
152        | Op::FakeQuantizeBackward { .. }
153        | Op::FakeQuantizeLSQ { .. }
154        | Op::FakeQuantizeLSQBackwardX { .. }
155        | Op::FakeQuantizeLSQBackwardScale { .. }
156        | Op::ReluBackward
157        | Op::ActivationBackward { .. }
158        | Op::ComplexNormSq
159        | Op::ComplexNormSqBackward
160        | Op::Conjugate
161        // DiT gate: x + y * sigmoid(gate) — elementwise fuse.
162        | Op::GatedResidual
163        | Op::GatedResidualBackward => OpKind::Elementwise,
164        Op::Gather { .. }
165        | Op::Narrow { .. }
166        | Op::Reshape { .. }
167        | Op::Transpose { .. }
168        | Op::Concat { .. }
169        | Op::Expand { .. }
170        | Op::Cast { .. }
171        | Op::Rope { .. }
172        | Op::Pool { .. }
173        | Op::FusedAttentionBlock { .. }
174        | Op::TopK { .. }
175        | Op::ScatterAdd { .. }
176        | Op::ScatterNd { .. }
177        | Op::ScatterElements { .. }
178        | Op::GatherNd { .. }
179        | Op::GatherElements { .. }
180        | Op::MaxPool2dBackward { .. }
181        | Op::ResizeNearest2x
182        | Op::AxialRope2d { .. } => OpKind::DataMovement,
183        Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => OpKind::Boundary,
184        // Control flow: treated as data movement (the inner sub-graph
185        // gets its own precision policy applied separately).
186        Op::If { .. } | Op::While { .. } => OpKind::DataMovement,
187        // Custom user-registered ops are opaque to the precision pass
188        // — classify as Compute by default; the registered op's own
189        // implementation decides what dtype it operates at.
190        Op::Custom { .. } => OpKind::Compute,
191        Op::Scan { .. } => OpKind::Compute,
192        Op::ScanBackward { .. } => OpKind::Compute,
193        Op::ScanBackwardXs { .. } => OpKind::Compute,
194        Op::CustomFn { .. } => OpKind::Compute,
195        Op::Fft { .. } => OpKind::Compute,
196        Op::FftButterflyStage { .. } => OpKind::Compute,
197        Op::LogMel => OpKind::Compute,
198        Op::LogMelBackward => OpKind::Compute,
199        // SPD / eigendecomposition — heavy Compute (host LAPACK or native
200        // solvers); never AutoMixed F16.
201        Op::BiMap
202        | Op::ReEig { .. }
203        | Op::LogEig { .. }
204        | Op::SpdBatchNorm { .. }
205        | Op::SpdKarcherMean { .. }
206        | Op::SpdKarcherMeanWeighted { .. }
207        | Op::SpdLogMap
208        | Op::SpdExpMap
209        | Op::SpdParallelTransport
210        | Op::SpdMatrixFnBatch { .. }
211        | Op::ReEigBackward { .. }
212        | Op::LogEigBackward { .. }
213        | Op::SpdBatchNormBackwardX { .. }
214        | Op::SpdBatchNormBackwardG { .. }
215        | Op::SpdLogMapBackward
216        | Op::SpdExpMapBackward
217        | Op::SpdParallelTransportBackward
218        | Op::SpdMatrixFnBatchBackward { .. }
219        | Op::Eigh
220        | Op::EighBackward
221        | Op::EighBatch
222        | Op::EighBatchBackward => OpKind::Compute,
223        _ => OpKind::Compute,
224    }
225}
226
227/// Declarative precision policy for graph compilation.
228#[derive(Debug, Clone, Default)]
229pub enum PrecisionPolicy {
230    /// All ops at F32. Default; safe; baseline accuracy.
231    #[default]
232    AlwaysF32,
233    /// All ops at F16. Maximum speed; may lose accuracy on reductions.
234    AlwaysF16,
235    /// Mixed precision, conservative variant. Forces F32 at every reduction
236    /// boundary, matching PyTorch's pre-2024 autocast and HuggingFace's
237    /// historical default. Accuracy is the highest of the AMP variants;
238    /// performance suffers from a Cast node before and after every
239    /// LayerNorm / Softmax in the graph.
240    ///   Compute → F16
241    ///   Reduction → F32  (← the cast tax — see AutoMixed for the fix)
242    ///   Elementwise → F16
243    ///   DataMovement → F16
244    ///   Boundary (input/param/output) → F32
245    AutoMixedConservative,
246    /// Mixed precision (Phase G — current default), tuned against Bonsai-27B
247    /// greedy tokens on Metal. Compute (matmul) and DataMovement stay F32:
248    /// F16 there diverged (residual-stream / Rope/Gather/Narrow paths) even
249    /// with `Cast`-`to` preserved. Elementwise and Reduction run F16 (reduction
250    /// kernels accumulate in f32 internally), which is where the win is without
251    /// the divergence. Boundaries stay user-visible F32.
252    ///   Compute → F32
253    ///   Reduction → F16  (kernel accumulates in f32 internally)
254    ///   Elementwise → F16
255    ///   DataMovement → F32
256    ///   Boundary (input/param/output) → F32
257    AutoMixed,
258    /// Mixed precision targeting BF16 on TPU/XLA. Same shape as
259    /// `AutoMixed` (compute + reduction + elementwise + data-movement
260    /// in the chosen low precision; boundaries stay F32) but the low
261    /// precision is BF16 instead of F16. BF16 is the native compute
262    /// dtype on TPU and recent GPUs; matches what JAX picks when
263    /// `jax.config.update("jax_default_dtype_bits", "bfloat16")`.
264    ///   Compute → BF16
265    ///   Reduction → BF16  (XLA's TPU codegen accumulates in f32)
266    ///   Elementwise → BF16
267    ///   DataMovement → BF16
268    ///   Boundary → F32
269    AutoMixedBf16,
270    /// BF16 with the **`AutoMixed` shape**: low precision only where it is
271    /// safe on CPU/Metal-class backends.
272    ///
273    /// `AutoMixedBf16` is the TPU/XLA policy — everything BF16, because
274    /// XLA's codegen accumulates in f32. On CPU that diverges. Measured on a
275    /// Qwen3.5 trunk forward, enabling BF16 one op kind at a time:
276    ///
277    /// | op kind | result |
278    /// |---|---|
279    /// | Elementwise | finite |
280    /// | Reduction | finite |
281    /// | Compute (matmul) | **NaN** |
282    /// | DataMovement | **NaN** |
283    ///
284    /// Exactly the split [`PrecisionPolicy::AutoMixed`] already records for
285    /// F16 (Rope/Gather/Narrow and the residual stream are the sensitive
286    /// paths). So this is that policy with BF16 as the low precision — the
287    /// one to use for mixed-precision **training** on CPU/Metal, where the
288    /// win is activation memory rather than matmul throughput.
289    ///   Compute → F32
290    ///   Reduction → BF16
291    ///   Elementwise → BF16
292    ///   DataMovement → F32
293    ///   Boundary → F32
294    AutoMixedBf16Safe,
295    /// Explicit per-op-kind override.
296    Custom(HashMap<OpKind, Precision>),
297}
298
299impl PrecisionPolicy {
300    /// Resolve the target precision for an op kind.
301    pub fn precision_for(&self, kind: OpKind) -> Precision {
302        match self {
303            PrecisionPolicy::AlwaysF32 => Precision::F32,
304            PrecisionPolicy::AlwaysF16 => match kind {
305                OpKind::Boundary => Precision::F32, // user-facing stays f32
306                _ => Precision::F16,
307            },
308            PrecisionPolicy::AutoMixed => match kind {
309                // Bonsai-27B Metal: Elementwise + Reduction F16 match F32
310                // greedy tokens. DataMovement→F16 still diverges (Rope/Gather/
311                // Narrow/Concat paths) even with Cast-`to` preserved — keep F32.
312                OpKind::Compute => Precision::F32,
313                OpKind::Reduction => Precision::F16,
314                OpKind::Elementwise => Precision::F16,
315                OpKind::DataMovement => Precision::F32,
316                OpKind::Boundary => Precision::F32,
317            },
318            PrecisionPolicy::AutoMixedConservative => match kind {
319                OpKind::Compute => Precision::F32,
320                OpKind::Reduction => Precision::F32,
321                OpKind::Elementwise => Precision::F16,
322                OpKind::DataMovement => Precision::F32,
323                OpKind::Boundary => Precision::F32,
324            },
325            PrecisionPolicy::AutoMixedBf16Safe => match kind {
326                OpKind::Compute => Precision::F32,
327                OpKind::Reduction => Precision::BF16,
328                OpKind::Elementwise => Precision::BF16,
329                OpKind::DataMovement => Precision::F32,
330                OpKind::Boundary => Precision::F32,
331            },
332            PrecisionPolicy::AutoMixedBf16 => match kind {
333                OpKind::Compute => Precision::BF16,
334                OpKind::Reduction => Precision::BF16,
335                OpKind::Elementwise => Precision::BF16,
336                OpKind::DataMovement => Precision::BF16,
337                OpKind::Boundary => Precision::F32,
338            },
339            PrecisionPolicy::Custom(map) => map.get(&kind).copied().unwrap_or(Precision::F32),
340        }
341    }
342}
343
344/// Pass that rewrites a graph according to a `PrecisionPolicy`.
345///
346/// For each node:
347/// 1. Look up the target precision based on op kind.
348/// 2. Update `node.shape.dtype` to that precision.
349/// 3. If any input has a different dtype, insert a Cast node before it.
350///
351/// After this pass, every node knows its compute precision via its
352/// shape dtype. Backends dispatch kernels per-node.
353pub struct AutoMixedPrecision {
354    pub policy: PrecisionPolicy,
355}
356
357impl AutoMixedPrecision {
358    pub fn new(policy: PrecisionPolicy) -> Self {
359        Self { policy }
360    }
361}
362
363impl Pass for AutoMixedPrecision {
364    fn name(&self) -> &str {
365        "auto_mixed_precision"
366    }
367
368    fn run(&self, graph: Graph) -> Graph {
369        // Skip the pass entirely for AlwaysF32 — it's a no-op.
370        if matches!(self.policy, PrecisionPolicy::AlwaysF32) {
371            return graph;
372        }
373
374        let mut new_graph = Graph::new(&graph.name);
375        // Maps old NodeId → new NodeId at its post-rewrite precision.
376        let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
377        // Tracks the precision each rewritten node ended up at.
378        let mut node_precision: HashMap<NodeId, Precision> = HashMap::new();
379        // Cast cache: avoid re-inserting identical Cast nodes.
380        // Key: (source new id, target precision)
381        let mut cast_cache: HashMap<(NodeId, Precision), NodeId> = HashMap::new();
382
383        for node in graph.nodes() {
384            // Native fp8 GEMM subgraph (from `scaled_quant_insert`): leave it
385            // intact. Its operands are U8 codes and its output is already f32 —
386            // relabeling the op's dtype or casting U8 inputs to f16 would
387            // corrupt it. Emit unchanged; if an f32 operand of a quantize op
388            // was lowered to f16 upstream, cast it back to f32 first.
389            if matches!(
390                node.op,
391                Op::ScaledMatMul { .. }
392                    | Op::ScaledQuantize { .. }
393                    | Op::ScaledQuantScale { .. }
394                    | Op::ScaledDequantize { .. }
395            ) {
396                let new_inputs: Vec<NodeId> = node
397                    .inputs
398                    .iter()
399                    .map(|&in_id| {
400                        let src = id_map[&in_id];
401                        let sd = new_graph.node(src).shape.dtype();
402                        if matches!(sd, DType::F16 | DType::BF16) {
403                            let shape = new_graph.node(src).shape.clone().with_dtype(DType::F32);
404                            new_graph.add_node(Op::Cast { to: DType::F32 }, vec![src], shape)
405                        } else {
406                            src
407                        }
408                    })
409                    .collect();
410                let new_id = new_graph.add_node(node.op.clone(), new_inputs, node.shape.clone());
411                id_map.insert(node.id, new_id);
412                // The matmul's output is f32; consumers cast it down as needed.
413                node_precision.insert(node.id, Precision::F32);
414                continue;
415            }
416
417            // Ops whose Metal kernels are still f32-only. Casting the node to
418            // F16 while the kernel reads/writes floats zeros the residual
419            // stream (Bonsai-27B AMP → all token-0 / "!!!!!!!!"). Keep these at
420            // F32; Cast boundaries reconnect the f16 residual around them.
421            let force_f32 = match &node.op {
422                Op::GatedDeltaNet { .. }
423                | Op::SelectiveScan { .. }
424                | Op::Conv { .. }
425                | Op::Conv3d { .. }
426                | Op::ConvTranspose2d { .. }
427                | Op::ConvTranspose3d { .. }
428                | Op::Im2Col { .. }
429                | Op::Pool { .. }
430                | Op::QConv2d { .. }
431                | Op::Conv2dBackwardInput { .. }
432                | Op::Conv2dBackwardWeight { .. } => true,
433                // Reduction / data-movement ops without Metal `_h` yet.
434                Op::Cumsum { .. }
435                | Op::Sample { .. }
436                | Op::Lstm { .. }
437                | Op::Gru { .. }
438                | Op::Rnn { .. }
439                | Op::Mamba2 { .. }
440                | Op::SoftmaxCrossEntropy
441                | Op::SoftmaxCrossEntropyWithLogits
442                | Op::SoftmaxCrossEntropyBackward
443                // The LayerNorm BACKWARDs were listed; the FORWARD was not.
444                // Measured: `LayerNorm2d` under `AlwaysF16` on Metal produced a
445                // non-finite output at rel 3.6e4. Found by
446                // `rlx-metal/tests/precision_policy_f16_attention.rs`'s sweep.
447                | Op::LayerNorm2d { .. }
448                | Op::LayerNormBackwardInput { .. }
449                | Op::LayerNormBackwardGamma { .. }
450                | Op::GroupNorm { .. }
451                // `Op::Attention` and its backward. Metal dispatches the SAME
452                // f32 sdpa kernel regardless of the node's dtype — the `dt`
453                // flag does not select an f16 variant because none exists — so
454                // tagging the node F16 leaves an f32 kernel reading f16 bytes
455                // and writing f32 into an f16-sized slot. Measured: a single
456                // attention op under `AlwaysF16` returned 256/256 EXACT ZEROS,
457                // which is this comment's "zeros the residual stream" at the
458                // scale of one op. `FusedAttentionBlock` was already listed; the
459                // unfused op was not. Guarded by
460                // `rlx-metal/tests/precision_policy_f16_attention.rs`.
461                | Op::Attention { .. }
462                | Op::AttentionBackward { .. }
463                | Op::FusedAttentionBlock { .. }
464                | Op::TopK { .. }
465                | Op::ScatterAdd { .. }
466                | Op::ScatterNd { .. }
467                | Op::ScatterElements { .. }
468                | Op::GatherNd { .. }
469                | Op::GatherElements { .. }
470                | Op::Expand { .. }
471                | Op::MaxPool2dBackward { .. }
472                | Op::ResizeNearest2x
473                | Op::AxialRope2d { .. } => true,
474                // SPD / eigh — host LAPACK or f32 native solvers.
475                Op::BiMap
476                | Op::ReEig { .. }
477                | Op::LogEig { .. }
478                | Op::SpdBatchNorm { .. }
479                | Op::SpdKarcherMean { .. }
480                | Op::SpdKarcherMeanWeighted { .. }
481                | Op::SpdLogMap
482                | Op::SpdExpMap
483                | Op::SpdParallelTransport
484                | Op::SpdMatrixFnBatch { .. }
485                | Op::ReEigBackward { .. }
486                | Op::LogEigBackward { .. }
487                | Op::SpdBatchNormBackwardX { .. }
488                | Op::SpdBatchNormBackwardG { .. }
489                | Op::SpdLogMapBackward
490                | Op::SpdExpMapBackward
491                | Op::SpdParallelTransportBackward
492                | Op::SpdMatrixFnBatchBackward { .. }
493                | Op::Eigh
494                | Op::EighBackward
495                | Op::EighBatch
496                | Op::EighBatchBackward => true,
497                // Keep *all* packed DequantMatMul at F32 until the full-graph
498                // AMP path (dual-Q1 / fused MLP + f16 residual) is proven.
499                Op::DequantMatMul { .. } => true,
500                // Same policy for the codebook-synthesis matmul.
501                Op::SynthMatMul { .. } => true,
502                _ => false,
503            };
504            if force_f32 {
505                let new_inputs: Vec<NodeId> = node
506                    .inputs
507                    .iter()
508                    .map(|&in_id| {
509                        let src = id_map[&in_id];
510                        let sd = new_graph.node(src).shape.dtype();
511                        if matches!(sd, DType::F16 | DType::BF16) {
512                            let shape = new_graph.node(src).shape.clone().with_dtype(DType::F32);
513                            new_graph.add_node(Op::Cast { to: DType::F32 }, vec![src], shape)
514                        } else {
515                            src
516                        }
517                    })
518                    .collect();
519                let new_id = new_graph.add_node(
520                    node.op.clone(),
521                    new_inputs,
522                    node.shape.clone().with_dtype(DType::F32),
523                );
524                id_map.insert(node.id, new_id);
525                node_precision.insert(node.id, Precision::F32);
526                continue;
527            }
528
529            let kind = op_kind(&node.op);
530            let mut target = self.policy.precision_for(kind);
531
532            // Inputs / params keep their original dtype (they're external);
533            // outputs stay user-visible at F32.
534            if kind == OpKind::Boundary {
535                target = Precision::F32;
536            }
537
538            // `Op::Cast { to }` must keep `to` — DataMovement→F16 was
539            // relabeling Cast-to-F32 nodes as F16 and zeroing AMP graphs.
540            if let Op::Cast { to } = &node.op {
541                target = match to {
542                    DType::F16 => Precision::F16,
543                    DType::BF16 => Precision::BF16,
544                    DType::F32 => Precision::F32,
545                    _ => {
546                        // Bool / int casts: emit unchanged.
547                        let new_id = new_graph.add_node(
548                            node.op.clone(),
549                            vec![id_map[&node.inputs[0]]],
550                            node.shape.clone(),
551                        );
552                        id_map.insert(node.id, new_id);
553                        node_precision.insert(node.id, Precision::F32);
554                        continue;
555                    }
556                };
557            }
558
559            // Resolve each input: insert a Cast if precision differs.
560            // Never cast integer / bool / packed-quant (U8/I8) tensors — those
561            // are bit patterns (GGUF weights, indices, masks), not AMP floats.
562            // Casting U8 Q1 packs to F16 silently zeros Metal decode.
563            let mut new_inputs = Vec::with_capacity(node.inputs.len());
564            for &in_id in &node.inputs {
565                let src_new_id = id_map[&in_id];
566                let src_dtype = new_graph.node(src_new_id).shape.dtype();
567                if !matches!(src_dtype, DType::F32 | DType::F16 | DType::BF16) {
568                    new_inputs.push(src_new_id);
569                    continue;
570                }
571                let src_prec = node_precision
572                    .get(&in_id)
573                    .copied()
574                    .unwrap_or(Precision::F32);
575                if src_prec == target {
576                    new_inputs.push(src_new_id);
577                } else {
578                    // Insert (or reuse cached) cast
579                    let cast_id = *cast_cache.entry((src_new_id, target)).or_insert_with(|| {
580                        let shape = new_graph
581                            .node(src_new_id)
582                            .shape
583                            .clone()
584                            .with_dtype(target.dtype());
585                        new_graph.add_node(Op::Cast { to: target.dtype() }, vec![src_new_id], shape)
586                    });
587                    new_inputs.push(cast_id);
588                }
589            }
590
591            // Build the rewritten node with the target dtype on its shape.
592            // Boundaries (Input/Param/Constant) keep their original dtype —
593            // packed U8/I8 GGUF weights and integer indices must not be
594            // relabeled as F32 (arena would oversize and AMP would try to
595            // cast them into the float stream).
596            // Cast keeps `to` (already reflected in `target` above).
597            let new_shape = if kind == OpKind::Boundary {
598                node.shape.clone()
599            } else if let Op::Cast { to } = &node.op {
600                node.shape.clone().with_dtype(*to)
601            } else if !matches!(node.shape.dtype(), DType::F32 | DType::F16 | DType::BF16) {
602                // An op that *produces* a non-float produces it under every
603                // precision policy — integer indices, masks, and fixed-point
604                // transforms (`Op::FftQ`) are bit patterns, not AMP floats.
605                // This is the same rule the input loop above applies; it was
606                // only being enforced for boundaries, so an integer-valued
607                // compute op was relabeled F32 and its backend then rejected
608                // it on dtype.
609                node.shape.clone()
610            } else {
611                node.shape.clone().with_dtype(target.dtype())
612            };
613            let new_id = new_graph.add_node(node.op.clone(), new_inputs, new_shape);
614            id_map.insert(node.id, new_id);
615            node_precision.insert(node.id, target);
616        }
617
618        // Outputs always stay at F32 — cast back if needed.
619        let new_outputs: Vec<NodeId> = graph
620            .outputs
621            .iter()
622            .map(|&out_id| {
623                let src_new_id = id_map[&out_id];
624                let src_prec = node_precision
625                    .get(&out_id)
626                    .copied()
627                    .unwrap_or(Precision::F32);
628                if src_prec == Precision::F32 {
629                    src_new_id
630                } else {
631                    let shape = new_graph
632                        .node(src_new_id)
633                        .shape
634                        .clone()
635                        .with_dtype(DType::F32);
636                    new_graph.add_node(Op::Cast { to: DType::F32 }, vec![src_new_id], shape)
637                }
638            })
639            .collect();
640        new_graph.set_outputs(new_outputs);
641
642        new_graph
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn always_f32_is_noop() {
652        let mut g = Graph::new("test");
653        let x = g.input("x", Shape::new(&[2, 4], DType::F32));
654        let w = g.param("w", Shape::new(&[4, 3], DType::F32));
655        let mm = g.matmul(x, w, Shape::new(&[2, 3], DType::F32));
656        g.set_outputs(vec![mm]);
657
658        let pass = AutoMixedPrecision::new(PrecisionPolicy::AlwaysF32);
659        let out = pass.run(g);
660        assert_eq!(out.len(), 3); // input, param, matmul — no casts
661    }
662
663    #[test]
664    fn auto_mixed_inserts_casts_at_boundary() {
665        let mut g = Graph::new("test");
666        let x = g.input("x", Shape::new(&[2, 4], DType::F32));
667        let w = g.param("w", Shape::new(&[4, 3], DType::F32));
668        let mm = g.matmul(x, w, Shape::new(&[2, 3], DType::F32));
669        // Under `AutoMixed`, MatMul (Compute) stays F32; the F16 region begins at
670        // an elementwise op, so the f32↔f16 boundary casts appear around the relu.
671        let act = g.relu(mm);
672        g.set_outputs(vec![act]);
673
674        let pass = AutoMixedPrecision::new(PrecisionPolicy::AutoMixed);
675        let out = pass.run(g);
676
677        // input(f32), param(f32), matmul(f32), cast(f32→f16) into relu,
678        // relu(f16), cast(f16→f32) at the output boundary = 6 nodes, and the
679        // final output node is a Cast back to F32.
680        assert!(out.len() >= 6);
681        let final_node = out.node(out.outputs[0]);
682        assert!(matches!(final_node.op, Op::Cast { to: DType::F32 }));
683    }
684
685    #[test]
686    fn auto_mixed_preserves_u8_quant_weights() {
687        use rlx_ir::quant::QuantScheme;
688        let mut g = Graph::new("q1_amp");
689        let x = g.input("x", Shape::new(&[1, 128], DType::F32));
690        let w = g.param("w", Shape::new(&[2304], DType::U8));
691        let y = g.add_node(
692            Op::DequantMatMul {
693                scheme: QuantScheme::GgufQ1_0,
694            },
695            vec![x, w],
696            Shape::new(&[1, 64], DType::F32),
697        );
698        g.set_outputs(vec![y]);
699
700        let out = AutoMixedPrecision::new(PrecisionPolicy::AutoMixed).run(g);
701        let params: Vec<_> = out
702            .nodes()
703            .iter()
704            .filter(|n| matches!(n.op, Op::Param { .. }))
705            .collect();
706        assert_eq!(params.len(), 1);
707        assert_eq!(params[0].shape.dtype(), DType::U8);
708        for n in out.nodes() {
709            if let Op::Cast { to } = &n.op {
710                let src = out.node(n.inputs[0]);
711                assert!(
712                    !matches!(src.op, Op::Param { .. }),
713                    "AMP must not cast U8 Param to {to:?}"
714                );
715            }
716        }
717    }
718}