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}