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    for node in &nodes {
222        let Op::Concat { axis } = &node.op else {
223            continue;
224        };
225        let shapes: Vec<Shape> = node
226            .inputs
227            .iter()
228            .map(|&id| graph.node(id).shape.clone())
229            .collect();
230        let refs: Vec<&Shape> = shapes.iter().collect();
231        if let Ok(out) = crate::shape::concat_shape(&refs, *axis) {
232            graph.node_mut(node.id).shape = out;
233        }
234    }
235}
236
237/// Clamp `Op::Narrow` start indices after bind (template may bake in max_seq placeholders).
238pub fn sync_narrow_ops(graph: &mut Graph) {
239    use crate::Op;
240    let nodes = graph.nodes().to_vec();
241    for node in &nodes {
242        let Op::Narrow { axis, start, len } = &node.op else {
243            continue;
244        };
245        let in_shape = graph.node(node.inputs[0]).shape.clone();
246        if *axis >= in_shape.rank() || !in_shape.is_static() {
247            continue;
248        }
249        let ax_len = in_shape.dims()[*axis].unwrap_static();
250        if *start + *len > ax_len {
251            graph.node_mut(node.id).op = Op::Narrow {
252                axis: *axis,
253                start: ax_len.saturating_sub(*len),
254                len: *len,
255            };
256        }
257    }
258}
259
260/// After [`bind_graph`], sync `Op::Expand { target_shape }` with bound output shapes.
261pub fn sync_expand_ops(graph: &mut Graph) {
262    use crate::Op;
263    let nodes = graph.nodes().to_vec();
264    for node in &nodes {
265        let Op::Expand { .. } = &node.op else {
266            continue;
267        };
268        if !node.shape.is_static() {
269            continue;
270        }
271        let target: Vec<i64> = node
272            .shape
273            .dims()
274            .iter()
275            .map(|d| d.unwrap_static() as i64)
276            .collect();
277        graph.node_mut(node.id).op = Op::Expand {
278            target_shape: target,
279        };
280    }
281}
282
283/// Infer symbol sizes from runtime input element counts.
284///
285/// Each `Op::Input` may have at most one dynamic dimension; its size is
286/// `data_len / product(static_dims)`.
287pub fn infer_bindings_from_inputs(
288    graph: &Graph,
289    inputs: &[(&str, usize)],
290) -> Result<DimBinding, String> {
291    let by_name: HashMap<&str, usize> = inputs.iter().copied().collect();
292    let mut binding = DimBinding::new();
293    for node in graph.nodes() {
294        let Op::Input { name } = &node.op else {
295            continue;
296        };
297        let Some(&n_elems) = by_name.get(name.as_str()) else {
298            continue;
299        };
300        let mut static_prod: usize = 1;
301        let mut dynamic_sym: Option<u32> = None;
302        for d in node.shape.dims() {
303            match d {
304                Dim::Static(n) => static_prod *= *n,
305                Dim::Dynamic(sym) => {
306                    if dynamic_sym.is_some() {
307                        return Err(format!(
308                            "Input '{name}' has multiple dynamic dims; \
309                             pass an explicit DimBinding"
310                        ));
311                    }
312                    dynamic_sym = Some(*sym);
313                }
314            }
315        }
316        let Some(sym) = dynamic_sym else {
317            continue;
318        };
319        if static_prod == 0 {
320            return Err(format!("Input '{name}': static dim product is zero"));
321        }
322        if n_elems % static_prod != 0 {
323            return Err(format!(
324                "Input '{name}': len {n_elems} not divisible by static product {static_prod}"
325            ));
326        }
327        let size = n_elems / static_prod;
328        if let Some(prev) = binding.get(sym) {
329            if prev != size {
330                return Err(format!(
331                    "symbol {sym} bound to {prev} and {size} from different inputs"
332                ));
333            }
334        } else {
335            binding.set(sym, size);
336        }
337    }
338    complete_im2col_row_bindings(graph, &mut binding);
339    Ok(binding)
340}
341
342/// When `sym::BATCH` is bound on NCHW inputs feeding `Op::Im2Col`, derive
343/// `sym::ROWS = batch · H_out · W_out` for im2col/matmul reshape consumers.
344pub fn complete_im2col_row_bindings(graph: &Graph, binding: &mut DimBinding) {
345    let Some(batch) = binding.get(sym::BATCH) else {
346        return;
347    };
348    if binding.get(sym::ROWS).is_some() {
349        return;
350    }
351    for node in graph.nodes() {
352        let Op::Im2Col {
353            kernel_size,
354            stride,
355            padding,
356            dilation,
357        } = &node.op
358        else {
359            continue;
360        };
361        let x_shape = &graph.node(node.inputs[0]).shape;
362        if x_shape.rank() != 4 {
363            continue;
364        }
365        if !x_shape.dim(2).is_static() || !x_shape.dim(3).is_static() {
366            continue;
367        }
368        let h = x_shape.dim(2).unwrap_static();
369        let w = x_shape.dim(3).unwrap_static();
370        let kh = kernel_size.first().copied().unwrap_or(1);
371        let kw = kernel_size.get(1).copied().unwrap_or(1);
372        let sh = stride.first().copied().unwrap_or(1);
373        let sw = stride.get(1).copied().unwrap_or(1);
374        let ph = padding.first().copied().unwrap_or(0);
375        let pw = padding.get(1).copied().unwrap_or(0);
376        let dh = dilation.first().copied().unwrap_or(1);
377        let dw = dilation.get(1).copied().unwrap_or(1);
378        let h_out = crate::shape::conv2d_spatial_output(h, kh, sh, ph, dh);
379        let w_out = crate::shape::conv2d_spatial_output(w, kw, sw, pw, dw);
380        binding.set(sym::ROWS, batch * h_out * w_out);
381        return;
382    }
383}
384
385/// Infer bindings from f32 slice lengths (convenience for tests/runtime).
386pub fn infer_bindings_from_f32_inputs(
387    graph: &Graph,
388    inputs: &[(&str, &[f32])],
389) -> Result<DimBinding, String> {
390    infer_bindings_from_inputs(
391        graph,
392        &inputs
393            .iter()
394            .map(|(n, d)| (*n, d.len()))
395            .collect::<Vec<_>>(),
396    )
397}
398
399pub fn same_binding(a: &DimBinding, b: &DimBinding) -> bool {
400    if a.len() != b.len() {
401        return false;
402    }
403    a.iter().all(|(sym, size)| b.get(sym) == Some(size))
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::infer::GraphExt;
410
411    #[test]
412    fn bind_graph_specializes_matmul() {
413        let batch = sym::BATCH;
414        let seq = sym::SEQ;
415        let mut g = Graph::new("dyn");
416        let x = g.input("x", Shape::batch_seq(batch, seq, 4, DType::F32));
417        let w = g.param("w", Shape::new(&[4, 8], DType::F32));
418        let y = g.mm(x, w);
419        g.set_outputs(vec![y]);
420
421        assert!(has_dynamic_dims(&g));
422        let binding = DimBinding::batch_seq(2, 16);
423        let bound = bind_graph(&g, &binding);
424        assert!(!has_dynamic_dims(&bound));
425        assert_eq!(
426            bound.node(bound.outputs[0]).shape,
427            Shape::new(&[2, 16, 8], DType::F32)
428        );
429    }
430
431    #[test]
432    fn infer_bindings_from_input_data() {
433        let mut g = Graph::new("dyn");
434        let x = g.input(
435            "x",
436            Shape::from_dims(
437                &[Dim::Static(3), Dim::Dynamic(sym::SEQ), Dim::Static(64)],
438                DType::F32,
439            ),
440        );
441        g.set_outputs(vec![x]);
442
443        let b = infer_bindings_from_f32_inputs(&g, &[("x", &vec![0.0f32; 3 * 128 * 64])])
444            .expect("infer");
445        assert_eq!(b.get(sym::SEQ), Some(128));
446    }
447
448    #[test]
449    fn infer_bindings_sets_im2col_rows_from_batch() {
450        let mut g = Graph::new("im2col_rows");
451        let x = g.input(
452            "x",
453            Shape::from_dims(
454                &[
455                    Dim::Dynamic(sym::BATCH),
456                    Dim::Static(1),
457                    Dim::Static(4),
458                    Dim::Static(4),
459                ],
460                DType::F32,
461            ),
462        );
463        let _col = g.im2col(x, [3, 3], [1, 1], [1, 1], [1, 1]);
464        g.set_outputs(vec![x]);
465        let b = infer_bindings_from_f32_inputs(&g, &[("x", &[0.0f32; 2 * 16])]).expect("infer");
466        assert_eq!(b.get(sym::BATCH), Some(2));
467        assert_eq!(b.get(sym::ROWS), Some(2 * 4 * 4));
468    }
469}