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}