Skip to main content

rlx_ir/
dynamic.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//! Dynamic / symbolic dimensions — compile once, specialize at runtime.
6//!
7//! Plan #54: graphs built with [`Dim::Dynamic`] symbols specialize via
8//! [`DimBinding`] before buffer planning and backend lowering.
9
10use std::collections::{BTreeSet, HashMap};
11
12use crate::shape::{Dim, DimBinding, Shape};
13use crate::{DType, Graph, Op};
14
15/// Well-known dynamic dimension symbols. Reuse the same id across shapes
16/// so `[?0, ?1, H]` and `[?0, ?1, 4]` share batch/seq bindings.
17pub mod sym {
18    pub const BATCH: u32 = 0;
19    pub const SEQ: u32 = 1;
20    /// Cached prefix length for decode KV (past_k / past_v axis 1).
21    pub const PAST_SEQ: u32 = 3;
22    /// Product of leading axes (e.g. `batch * seq` flatten).
23    pub const ROWS: u32 = 2;
24}
25
26/// Allocate named dynamic symbols for model builders.
27#[derive(Debug, Clone, Default)]
28pub struct DimEnv {
29    next: u32,
30    names: HashMap<String, u32>,
31}
32
33impl DimEnv {
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Return the symbol id for `name`, allocating on first use.
39    pub fn sym(&mut self, name: &str) -> u32 {
40        if let Some(&id) = self.names.get(name) {
41            return id;
42        }
43        let id = self.next;
44        self.next += 1;
45        self.names.insert(name.into(), id);
46        id
47    }
48
49    pub fn name(&self, symbol: u32) -> Option<&str> {
50        self.names
51            .iter()
52            .find_map(|(n, &s)| (s == symbol).then_some(n.as_str()))
53    }
54}
55
56impl Shape {
57    /// `[batch, seq, hidden]` with symbolic leading axes.
58    pub fn batch_seq(batch: u32, seq: u32, hidden: usize, dtype: DType) -> Self {
59        Self::from_dims(
60            &[Dim::Dynamic(batch), Dim::Dynamic(seq), Dim::Static(hidden)],
61            dtype,
62        )
63    }
64
65    /// `[batch, seq]` matrix.
66    pub fn batch_seq_2d(batch: u32, seq: u32, dtype: DType) -> Self {
67        Self::from_dims(&[Dim::Dynamic(batch), Dim::Dynamic(seq)], dtype)
68    }
69
70    /// `[batch, seq, heads, head_dim]` attention layout.
71    pub fn batch_seq_heads(
72        batch: u32,
73        seq: u32,
74        heads: usize,
75        head_dim: usize,
76        dtype: DType,
77    ) -> Self {
78        Self::from_dims(
79            &[
80                Dim::Dynamic(batch),
81                Dim::Dynamic(seq),
82                Dim::Static(heads),
83                Dim::Static(head_dim),
84            ],
85            dtype,
86        )
87    }
88}
89
90impl DimBinding {
91    pub fn from_pairs(pairs: &[(u32, usize)]) -> Self {
92        let mut b = Self::new();
93        for &(sym, size) in pairs {
94            b.set(sym, size);
95        }
96        b
97    }
98
99    pub fn batch_seq(batch: usize, seq: usize) -> Self {
100        let mut b = Self::from_pairs(&[(sym::BATCH, batch), (sym::SEQ, seq)]);
101        if batch > 1 {
102            b.set(sym::ROWS, batch * seq);
103        }
104        b
105    }
106
107    pub fn batch_past_seq(batch: usize, past_seq: usize) -> Self {
108        Self::from_pairs(&[(sym::BATCH, batch), (sym::PAST_SEQ, past_seq)])
109    }
110}
111
112/// True if any node shape references a dynamic dimension.
113pub fn has_dynamic_dims(graph: &Graph) -> bool {
114    graph
115        .nodes()
116        .iter()
117        .any(|n| n.shape.dims().iter().any(|d| matches!(d, Dim::Dynamic(_))))
118}
119
120/// Collect all dynamic symbols referenced anywhere in the graph.
121pub fn collect_dynamic_symbols(graph: &Graph) -> Vec<u32> {
122    let mut syms = BTreeSet::new();
123    for node in graph.nodes() {
124        for s in node.shape.dynamic_symbols() {
125            syms.insert(s);
126        }
127    }
128    syms.into_iter().collect()
129}
130
131/// Specialize every node's shape against `bindings`.
132///
133/// Node ids are preserved (nodes are cloned in insertion order), so
134/// edges and outputs remain valid without remapping.
135pub fn bind_graph(graph: &Graph, bindings: &DimBinding) -> Graph {
136    let mut out = Graph::new(&graph.name);
137    for node in graph.nodes() {
138        let bound = node.shape.bind(bindings);
139        out.push_ext(
140            node.op.clone(),
141            node.inputs.clone(),
142            bound,
143            node.name.clone(),
144            node.origin.clone(),
145        );
146    }
147    out.set_outputs(graph.outputs.clone());
148    out
149}
150
151/// After [`bind_graph`], sync `Op::Reshape { new_shape }` with bound node shapes.
152pub fn sync_reshape_ops(graph: &mut Graph) {
153    use crate::Op;
154    for node in graph.nodes_mut() {
155        if let Op::Reshape { new_shape } = &mut node.op {
156            if node.shape.is_static() {
157                *new_shape = node
158                    .shape
159                    .dims()
160                    .iter()
161                    .map(|d| d.unwrap_static() as i64)
162                    .collect();
163            }
164        }
165    }
166}
167
168/// Recompute all inferrable output shapes after binding (propagates concat fixes).
169///
170/// Refining a shape is the point of this pass, but *replacing* a fully-static
171/// declaration with a different fully-static one is not a refinement — it is the
172/// graph telling us two incompatible things, and the planner has already sized
173/// arena slots from the declaration. Silently taking the inferred side is how a
174/// mis-shaped `GroupedMatMul` bank (`[E, N, K]` for `[E, K, N]`) used to sail
175/// through: the check in each backend's lowering compares the declared shape
176/// against the operands, so rewriting the declaration first makes that check
177/// compare a value with itself. In debug the IR verifier caught it; release
178/// builds — the ones that ship — had no guard at all.
179///
180/// So: refine freely where the declaration was dynamic or absent, and reject a
181/// static-vs-static contradiction — comparing only the non-unit extents, because
182/// `[]` and `[1]` are the same scalar and the graph uses both spellings
183/// interchangeably (a rank-0 `Reduce` result feeding a rank-1 consumer). Across
184/// the workspace suite that distinction is the whole story: comparing raw dims
185/// flags 20 of these harmless scalar-rank pairs for every real conflict.
186pub fn sync_graph_shapes(graph: &mut Graph) {
187    // Extents that actually address memory — unit axes carry no elements, so
188    // they cannot be the difference between a correct and an overrunning kernel.
189    fn extents(s: &Shape) -> Vec<usize> {
190        s.dims()
191            .iter()
192            .map(|d| d.unwrap_static())
193            .filter(|&v| v != 1)
194            .collect()
195    }
196    let nodes = graph.nodes().to_vec();
197    for node in &nodes {
198        if let Some(shape) = crate::infer_shape::infer_output_shape(graph, node) {
199            if node.shape.is_static()
200                && shape.is_static()
201                && extents(&node.shape) != extents(&shape)
202            {
203                panic!(
204                    "rlx-ir: node {:?} ({:?}) output shape mismatch: declares {} but its \
205                     operands give {}. These disagree and both are static, so this is a \
206                     malformed graph, not a shape that needs refining — the arena is sized \
207                     from the declaration while kernels read their extents from the operands, \
208                     so letting it through under- or over-writes the output slot",
209                    node.id, node.op, node.shape, shape
210                );
211            }
212            graph.node_mut(node.id).shape = shape;
213        }
214    }
215}
216
217/// Recompute `Op::Concat` output shapes from bound inputs (fixes mixed static+dynamic axes).
218pub fn sync_concat_shapes(graph: &mut Graph) {
219    use crate::Op;
220    let nodes = graph.nodes().to_vec();
221    // Concats whose shape this pass actually corrected. Their *consumers* were
222    // built from the pre-bind shape and are now stale too, so the correction has
223    // to be carried forward — see the propagation loop below.
224    let mut corrected: BTreeSet<crate::NodeId> = BTreeSet::new();
225    for node in &nodes {
226        let Op::Concat { axis } = &node.op else {
227            continue;
228        };
229        let shapes: Vec<Shape> = node
230            .inputs
231            .iter()
232            .map(|&id| graph.node(id).shape.clone())
233            .collect();
234        let refs: Vec<&Shape> = shapes.iter().collect();
235        if let Ok(out) = crate::shape::concat_shape(&refs, *axis) {
236            if graph.node(node.id).shape != out {
237                corrected.insert(node.id);
238            }
239            graph.node_mut(node.id).shape = out;
240        }
241    }
242    if corrected.is_empty() {
243        return;
244    }
245    // Carry the correction downstream.
246    //
247    // `concat_shape` deliberately under-reports a mixed static+dynamic axis as
248    // just `Dim::Dynamic(sym)` (it cannot add `1` to a symbol), so every node
249    // derived from that concat inherits the symbol *without* the static part.
250    // Binding then turns both into statics that disagree by exactly the dropped
251    // amount: a decode KV concat `[1, PAST_SEQ, d] ++ [1, 1, d]` bound at
252    // PAST_SEQ=4 gives a concat of 5 rows feeding a `Narrow` that still declares
253    // 4. `sync_graph_shapes` sees two static shapes that disagree and panics as
254    // if the graph were malformed, which is how `rlx-gemma`/`rlx-llama32`
255    // dynamic decode failed.
256    //
257    // Only nodes actually downstream of a corrected concat are re-inferred, so
258    // a genuinely malformed graph elsewhere still trips the check.
259    for node in &nodes {
260        if !node.inputs.iter().any(|id| corrected.contains(id)) {
261            continue;
262        }
263        if let Some(shape) = crate::infer_shape::infer_output_shape(graph, node) {
264            if graph.node(node.id).shape != shape {
265                corrected.insert(node.id);
266            }
267            graph.node_mut(node.id).shape = shape;
268        } else {
269            // Shape not inferable here; treat it as still-suspect so consumers
270            // downstream of it are re-inferred too rather than silently kept.
271            corrected.insert(node.id);
272        }
273    }
274}
275
276/// Clamp `Op::Narrow` start indices after bind (template may bake in max_seq placeholders).
277pub fn sync_narrow_ops(graph: &mut Graph) {
278    use crate::Op;
279    let nodes = graph.nodes().to_vec();
280    for node in &nodes {
281        let Op::Narrow { axis, start, len } = &node.op else {
282            continue;
283        };
284        let in_shape = graph.node(node.inputs[0]).shape.clone();
285        if *axis >= in_shape.rank() || !in_shape.is_static() {
286            continue;
287        }
288        let ax_len = in_shape.dims()[*axis].unwrap_static();
289        if *start + *len > ax_len {
290            graph.node_mut(node.id).op = Op::Narrow {
291                axis: *axis,
292                start: ax_len.saturating_sub(*len),
293                len: *len,
294            };
295        }
296    }
297}
298
299/// After [`bind_graph`], sync `Op::Expand { target_shape }` with bound output shapes.
300pub fn sync_expand_ops(graph: &mut Graph) {
301    use crate::Op;
302    let nodes = graph.nodes().to_vec();
303    for node in &nodes {
304        let Op::Expand { .. } = &node.op else {
305            continue;
306        };
307        if !node.shape.is_static() {
308            continue;
309        }
310        let target: Vec<i64> = node
311            .shape
312            .dims()
313            .iter()
314            .map(|d| d.unwrap_static() as i64)
315            .collect();
316        graph.node_mut(node.id).op = Op::Expand {
317            target_shape: target,
318        };
319    }
320}
321
322/// Infer symbol sizes from runtime input element counts.
323///
324/// Each `Op::Input` may have at most one dynamic dimension; its size is
325/// `data_len / product(static_dims)`.
326pub fn infer_bindings_from_inputs(
327    graph: &Graph,
328    inputs: &[(&str, usize)],
329) -> Result<DimBinding, String> {
330    let by_name: HashMap<&str, usize> = inputs.iter().copied().collect();
331    let mut binding = DimBinding::new();
332    for node in graph.nodes() {
333        let Op::Input { name } = &node.op else {
334            continue;
335        };
336        let Some(&n_elems) = by_name.get(name.as_str()) else {
337            continue;
338        };
339        let mut static_prod: usize = 1;
340        let mut dynamic_sym: Option<u32> = None;
341        for d in node.shape.dims() {
342            match d {
343                Dim::Static(n) => static_prod *= *n,
344                Dim::Dynamic(sym) => {
345                    if dynamic_sym.is_some() {
346                        return Err(format!(
347                            "Input '{name}' has multiple dynamic dims; \
348                             pass an explicit DimBinding"
349                        ));
350                    }
351                    dynamic_sym = Some(*sym);
352                }
353            }
354        }
355        let Some(sym) = dynamic_sym else {
356            continue;
357        };
358        if static_prod == 0 {
359            return Err(format!("Input '{name}': static dim product is zero"));
360        }
361        if n_elems % static_prod != 0 {
362            return Err(format!(
363                "Input '{name}': len {n_elems} not divisible by static product {static_prod}"
364            ));
365        }
366        let size = n_elems / static_prod;
367        if let Some(prev) = binding.get(sym) {
368            if prev != size {
369                return Err(format!(
370                    "symbol {sym} bound to {prev} and {size} from different inputs"
371                ));
372            }
373        } else {
374            binding.set(sym, size);
375        }
376    }
377    complete_im2col_row_bindings(graph, &mut binding);
378    Ok(binding)
379}
380
381/// When `sym::BATCH` is bound on NCHW inputs feeding `Op::Im2Col`, derive
382/// `sym::ROWS = batch · H_out · W_out` for im2col/matmul reshape consumers.
383pub fn complete_im2col_row_bindings(graph: &Graph, binding: &mut DimBinding) {
384    let Some(batch) = binding.get(sym::BATCH) else {
385        return;
386    };
387    if binding.get(sym::ROWS).is_some() {
388        return;
389    }
390    for node in graph.nodes() {
391        let Op::Im2Col {
392            kernel_size,
393            stride,
394            padding,
395            dilation,
396        } = &node.op
397        else {
398            continue;
399        };
400        let x_shape = &graph.node(node.inputs[0]).shape;
401        if x_shape.rank() != 4 {
402            continue;
403        }
404        if !x_shape.dim(2).is_static() || !x_shape.dim(3).is_static() {
405            continue;
406        }
407        let h = x_shape.dim(2).unwrap_static();
408        let w = x_shape.dim(3).unwrap_static();
409        let kh = kernel_size.first().copied().unwrap_or(1);
410        let kw = kernel_size.get(1).copied().unwrap_or(1);
411        let sh = stride.first().copied().unwrap_or(1);
412        let sw = stride.get(1).copied().unwrap_or(1);
413        let ph = padding.first().copied().unwrap_or(0);
414        let pw = padding.get(1).copied().unwrap_or(0);
415        let dh = dilation.first().copied().unwrap_or(1);
416        let dw = dilation.get(1).copied().unwrap_or(1);
417        let h_out = crate::shape::conv2d_spatial_output(h, kh, sh, ph, dh);
418        let w_out = crate::shape::conv2d_spatial_output(w, kw, sw, pw, dw);
419        binding.set(sym::ROWS, batch * h_out * w_out);
420        return;
421    }
422}
423
424/// Infer bindings from f32 slice lengths (convenience for tests/runtime).
425pub fn infer_bindings_from_f32_inputs(
426    graph: &Graph,
427    inputs: &[(&str, &[f32])],
428) -> Result<DimBinding, String> {
429    infer_bindings_from_inputs(
430        graph,
431        &inputs
432            .iter()
433            .map(|(n, d)| (*n, d.len()))
434            .collect::<Vec<_>>(),
435    )
436}
437
438pub fn same_binding(a: &DimBinding, b: &DimBinding) -> bool {
439    if a.len() != b.len() {
440        return false;
441    }
442    a.iter().all(|(sym, size)| b.get(sym) == Some(size))
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::infer::GraphExt;
449
450    #[test]
451    fn bind_graph_specializes_matmul() {
452        let batch = sym::BATCH;
453        let seq = sym::SEQ;
454        let mut g = Graph::new("dyn");
455        let x = g.input("x", Shape::batch_seq(batch, seq, 4, DType::F32));
456        let w = g.param("w", Shape::new(&[4, 8], DType::F32));
457        let y = g.mm(x, w);
458        g.set_outputs(vec![y]);
459
460        assert!(has_dynamic_dims(&g));
461        let binding = DimBinding::batch_seq(2, 16);
462        let bound = bind_graph(&g, &binding);
463        assert!(!has_dynamic_dims(&bound));
464        assert_eq!(
465            bound.node(bound.outputs[0]).shape,
466            Shape::new(&[2, 16, 8], DType::F32)
467        );
468    }
469
470    #[test]
471    fn infer_bindings_from_input_data() {
472        let mut g = Graph::new("dyn");
473        let x = g.input(
474            "x",
475            Shape::from_dims(
476                &[Dim::Static(3), Dim::Dynamic(sym::SEQ), Dim::Static(64)],
477                DType::F32,
478            ),
479        );
480        g.set_outputs(vec![x]);
481
482        let b = infer_bindings_from_f32_inputs(&g, &[("x", &vec![0.0f32; 3 * 128 * 64])])
483            .expect("infer");
484        assert_eq!(b.get(sym::SEQ), Some(128));
485    }
486
487    #[test]
488    fn infer_bindings_sets_im2col_rows_from_batch() {
489        let mut g = Graph::new("im2col_rows");
490        let x = g.input(
491            "x",
492            Shape::from_dims(
493                &[
494                    Dim::Dynamic(sym::BATCH),
495                    Dim::Static(1),
496                    Dim::Static(4),
497                    Dim::Static(4),
498                ],
499                DType::F32,
500            ),
501        );
502        let _col = g.im2col(x, [3, 3], [1, 1], [1, 1], [1, 1]);
503        g.set_outputs(vec![x]);
504        let b = infer_bindings_from_f32_inputs(&g, &[("x", &[0.0f32; 2 * 16])]).expect("infer");
505        assert_eq!(b.get(sym::BATCH), Some(2));
506        assert_eq!(b.get(sym::ROWS), Some(2 * 4 * 4));
507    }
508}