Skip to main content

onnx_runtime_shape_inference/
dim_expr.rs

1//! Symbolic dimension arithmetic: [`DimExpr`].
2//!
3//! `onnx-runtime-ir`'s [`Dim`](onnx_runtime_ir::Dim) can only represent a
4//! concrete extent ([`Dim::Static`](onnx_runtime_ir::Dim::Static)) or an opaque
5//! symbol ([`Dim::Symbolic`](onnx_runtime_ir::Dim::Symbolic)). Shape inference
6//! for reshape / conv / pool / flatten needs *derived* dimensions such as
7//! `d0 * d1`, `d0 + k`, or `d0 / k`. We do **not** modify the frozen IR
8//! contract; instead this crate reasons over dimensions using a small canonical
9//! integer polynomial and only *lowers* back to an IR [`Dim`] when writing an
10//! inferred shape into the graph (see [`crate::context::SymbolInterner`]).
11//!
12//! # Representation
13//!
14//! A [`DimExpr`] is a multivariate polynomial with integer coefficients over
15//! the graph's [`SymbolId`]s: a sum of *monomials*, where each monomial is a
16//! sorted product of symbols paired with an `i64` coefficient. Canonicalisation
17//! (constant folding, term merging, sorted monomials) means two structurally
18//! equal expressions compare equal — so identical derived dimensions (e.g. two
19//! `batch * seq` products) intern to the *same* fresh symbol and stay unified.
20//!
21//! This is deliberately **not** a general CAS: it captures exactly the affine
22//! and product forms the Phase-1 op set produces. Operations it cannot
23//! represent exactly (floor division by a symbol, non-exact division) surface
24//! as `None`, and the caller falls back to a fresh opaque symbol — the same
25//! permissive degrade the reference implementation uses.
26//!
27//! # Overflow contract
28//!
29//! Coefficients are `i64`. A pathological (but not necessarily malicious) graph
30//! can drive a concrete total past `i64::MAX` — e.g. a `Size`/`Reshape` product
31//! over four `2^20` dims is `2^80`. Every coefficient combiner
32//! ([`add`](DimExpr::add), [`sub`](DimExpr::sub), [`mul`](DimExpr::mul)) is
33//! therefore **checked**: on overflow it does **not** panic (as unchecked debug
34//! arithmetic would) and does **not** wrap to a bogus — possibly zero or
35//! negative — static dim (as unchecked release arithmetic would). Instead the
36//! result **degrades to an opaque unknown** ([`DimExpr::overflow`]): an
37//! expression that reports as neither a constant nor a bare symbol, poisons any
38//! further arithmetic it participates in, and lowers to a *fresh* symbol (see
39//! [`crate::context::SymbolInterner::lower`]). This matches the crate's
40//! permissive philosophy: a single pathological dim degrades to "unknown"
41//! rather than aborting whole-graph inference. [`checked_div`](DimExpr::checked_div)
42//! likewise returns `None` on overflow (including the `i64::MIN / -1` edge) so
43//! the caller degrades to a fresh symbol.
44
45use std::collections::BTreeMap;
46
47use onnx_runtime_ir::SymbolId;
48
49/// A monomial: a sorted product of symbol ids. The empty vector is the constant
50/// monomial (`1`). Stored as raw `u32`s so it is `Ord` for canonical keying.
51type Monomial = Vec<u32>;
52
53/// A canonical integer polynomial over symbolic dimensions.
54///
55/// Invariant: `terms` never contains a zero coefficient, and every key
56/// ([`Monomial`]) is sorted ascending. The empty map is the integer `0`.
57///
58/// The `overflow` flag marks an expression whose exact value could not be
59/// represented because a coefficient combiner exceeded `i64` range. Such an
60/// expression is an opaque unknown (see the module-level overflow contract):
61/// it is never a constant or bare symbol, and it lowers to a fresh symbol.
62#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)]
63pub struct DimExpr {
64    terms: BTreeMap<Monomial, i64>,
65    overflow: bool,
66}
67
68impl DimExpr {
69    /// The constant `n`.
70    pub fn constant(n: i64) -> Self {
71        let mut terms = BTreeMap::new();
72        if n != 0 {
73            terms.insert(Vec::new(), n);
74        }
75        Self {
76            terms,
77            overflow: false,
78        }
79    }
80
81    /// A single symbolic dimension.
82    pub fn symbol(s: SymbolId) -> Self {
83        let mut terms = BTreeMap::new();
84        terms.insert(vec![s.0], 1);
85        Self {
86            terms,
87            overflow: false,
88        }
89    }
90
91    /// An opaque unknown produced by an arithmetic overflow. See the
92    /// module-level overflow contract. It reports as neither a constant nor a
93    /// bare symbol, poisons any arithmetic it participates in, and lowers to a
94    /// fresh symbol.
95    pub fn overflow() -> Self {
96        Self {
97            terms: BTreeMap::new(),
98            overflow: true,
99        }
100    }
101
102    /// Whether this expression is the overflow/unknown sentinel.
103    pub fn is_overflow(&self) -> bool {
104        self.overflow
105    }
106
107    /// The integer value, if this expression is a pure constant (includes `0`).
108    pub fn as_const(&self) -> Option<i64> {
109        if self.overflow {
110            return None;
111        }
112        match self.terms.len() {
113            0 => Some(0),
114            1 => self.terms.get(&Vec::new()).copied(),
115            _ => None,
116        }
117    }
118
119    /// The single symbol, if this expression is exactly one symbol with
120    /// coefficient `1` (e.g. a bare `Dim::Symbolic` round-trips through this).
121    pub fn as_symbol(&self) -> Option<SymbolId> {
122        if self.overflow || self.terms.len() != 1 {
123            return None;
124        }
125        let (mono, &coeff) = self.terms.iter().next()?;
126        if coeff == 1 && mono.len() == 1 {
127            Some(SymbolId(mono[0]))
128        } else {
129            None
130        }
131    }
132
133    /// Whether this is a pure constant.
134    pub fn is_const(&self) -> bool {
135        self.as_const().is_some()
136    }
137
138    /// Every symbol id appearing in this expression (across all monomials), with
139    /// possible repeats. Used to raise a child interner's floor above symbols
140    /// that reach a subgraph only through a container-type seed.
141    pub fn symbol_ids(&self) -> impl Iterator<Item = SymbolId> + '_ {
142        self.terms
143            .keys()
144            .flat_map(|mono| mono.iter().copied())
145            .map(SymbolId)
146    }
147
148    /// Drop any term whose coefficient collapsed to zero.
149    fn prune(mut self) -> Self {
150        self.terms.retain(|_, c| *c != 0);
151        self
152    }
153
154    /// `self + other`.
155    ///
156    /// Overflow-safe: an out-of-range coefficient sum degrades the result to
157    /// [`DimExpr::overflow`] (never panics, never wraps). See the module-level
158    /// overflow contract.
159    pub fn add(&self, other: &DimExpr) -> DimExpr {
160        if self.overflow || other.overflow {
161            return DimExpr::overflow();
162        }
163        let mut terms = self.terms.clone();
164        for (mono, &coeff) in &other.terms {
165            let slot = terms.entry(mono.clone()).or_insert(0);
166            match slot.checked_add(coeff) {
167                Some(v) => *slot = v,
168                None => return DimExpr::overflow(),
169            }
170        }
171        DimExpr {
172            terms,
173            overflow: false,
174        }
175        .prune()
176    }
177
178    /// `self - other`.
179    ///
180    /// Overflow-safe: see [`add`](DimExpr::add).
181    pub fn sub(&self, other: &DimExpr) -> DimExpr {
182        if self.overflow || other.overflow {
183            return DimExpr::overflow();
184        }
185        let mut terms = self.terms.clone();
186        for (mono, &coeff) in &other.terms {
187            let slot = terms.entry(mono.clone()).or_insert(0);
188            match slot.checked_sub(coeff) {
189                Some(v) => *slot = v,
190                None => return DimExpr::overflow(),
191            }
192        }
193        DimExpr {
194            terms,
195            overflow: false,
196        }
197        .prune()
198    }
199
200    /// `self * other`.
201    ///
202    /// Overflow-safe: an out-of-range coefficient product or accumulation
203    /// degrades the result to [`DimExpr::overflow`]. See [`add`](DimExpr::add).
204    pub fn mul(&self, other: &DimExpr) -> DimExpr {
205        if self.overflow || other.overflow {
206            return DimExpr::overflow();
207        }
208        let mut terms: BTreeMap<Monomial, i64> = BTreeMap::new();
209        for (a_mono, &a_c) in &self.terms {
210            for (b_mono, &b_c) in &other.terms {
211                let Some(prod) = a_c.checked_mul(b_c) else {
212                    return DimExpr::overflow();
213                };
214                let mut mono = a_mono.clone();
215                mono.extend_from_slice(b_mono);
216                mono.sort_unstable();
217                let slot = terms.entry(mono).or_insert(0);
218                match slot.checked_add(prod) {
219                    Some(v) => *slot = v,
220                    None => return DimExpr::overflow(),
221                }
222            }
223        }
224        DimExpr {
225            terms,
226            overflow: false,
227        }
228        .prune()
229    }
230
231    /// `self / other`, only when the division is *exact*.
232    ///
233    /// Handles the cases the op set actually produces: division by a non-zero
234    /// constant (every coefficient must divide evenly) and division by a single
235    /// monomial (symbol cancellation, as in `Reshape` `-1` inference where
236    /// `total = b*s*768` is divided by `b*s*12` to yield `64`). Anything else —
237    /// dividing by a multi-term polynomial, or a non-exact quotient — returns
238    /// `None` so the caller can degrade to a fresh symbol.
239    pub fn checked_div(&self, other: &DimExpr) -> Option<DimExpr> {
240        // A poisoned operand has no representable value: degrade (caller mints a
241        // fresh symbol on `None`).
242        if self.overflow || other.overflow {
243            return None;
244        }
245        if self.terms.is_empty() {
246            return Some(DimExpr::constant(0));
247        }
248        // Divisor must be a single monomial with a non-zero coefficient.
249        if other.terms.len() != 1 {
250            return None;
251        }
252        let (div_mono, &div_coeff) = other.terms.iter().next()?;
253        if div_coeff == 0 {
254            return None;
255        }
256        let mut out: BTreeMap<Monomial, i64> = BTreeMap::new();
257        for (mono, &coeff) in &self.terms {
258            // `checked_rem`/`checked_div` guard the `i64::MIN / -1` overflow
259            // (divisor coefficients can be negative via `sub`): `None` degrades
260            // to a fresh symbol rather than panicking.
261            if coeff.checked_rem(div_coeff)? != 0 {
262                return None;
263            }
264            // Subtract the divisor's symbol multiset from this monomial.
265            let mut remaining = mono.clone();
266            for sym in div_mono {
267                let pos = remaining.iter().position(|s| s == sym)?;
268                remaining.remove(pos);
269            }
270            out.insert(remaining, coeff.checked_div(div_coeff)?);
271        }
272        Some(
273            DimExpr {
274                terms: out,
275                overflow: false,
276            }
277            .prune(),
278        )
279    }
280
281    /// The product of a slice of expressions (`1` for an empty slice).
282    pub fn product(exprs: &[DimExpr]) -> DimExpr {
283        let mut acc = DimExpr::constant(1);
284        for e in exprs {
285            acc = acc.mul(e);
286        }
287        acc
288    }
289}
290
291impl From<onnx_runtime_ir::Dim> for DimExpr {
292    fn from(d: onnx_runtime_ir::Dim) -> Self {
293        match d {
294            onnx_runtime_ir::Dim::Static(n) => DimExpr::constant(n as i64),
295            onnx_runtime_ir::Dim::Symbolic(s) => DimExpr::symbol(s),
296        }
297    }
298}
299
300impl onnx_runtime_ir::EinsumDimensionValue for DimExpr {
301    fn einsum_static_size(&self) -> Option<usize> {
302        usize::try_from(self.as_const()?).ok()
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn sym(n: u32) -> DimExpr {
311        DimExpr::symbol(SymbolId(n))
312    }
313
314    #[test]
315    fn constant_folding() {
316        let e = DimExpr::constant(3).add(&DimExpr::constant(4));
317        assert_eq!(e.as_const(), Some(7));
318        assert!(e.is_const());
319    }
320
321    #[test]
322    fn zero_is_canonical() {
323        assert_eq!(DimExpr::constant(0).as_const(), Some(0));
324        assert_eq!(
325            DimExpr::constant(5).sub(&DimExpr::constant(5)).as_const(),
326            Some(0)
327        );
328    }
329
330    #[test]
331    fn symbol_roundtrip() {
332        let e = sym(2);
333        assert_eq!(e.as_symbol(), Some(SymbolId(2)));
334        // 2*d is not a bare symbol.
335        assert_eq!(e.add(&sym(2)).as_symbol(), None);
336    }
337
338    #[test]
339    fn affine_expression() {
340        // d0 + 5
341        let e = sym(0).add(&DimExpr::constant(5));
342        assert_eq!(e.as_const(), None);
343        assert_eq!(e.as_symbol(), None);
344        // (d0 + 5) - 5 == d0
345        assert_eq!(e.sub(&DimExpr::constant(5)).as_symbol(), Some(SymbolId(0)));
346    }
347
348    #[test]
349    fn product_of_symbols() {
350        // d0 * d1
351        let e = sym(0).mul(&sym(1));
352        // commutative canonical form: d1 * d0 equals d0 * d1
353        assert_eq!(e, sym(1).mul(&sym(0)));
354    }
355
356    #[test]
357    fn exact_constant_division() {
358        let e = DimExpr::constant(48);
359        assert_eq!(
360            e.checked_div(&DimExpr::constant(6)).unwrap().as_const(),
361            Some(8)
362        );
363        // non-exact
364        assert!(
365            DimExpr::constant(7)
366                .checked_div(&DimExpr::constant(2))
367                .is_none()
368        );
369    }
370
371    #[test]
372    fn reshape_minus_one_cancellation() {
373        // total = b * s * 768, known = b * s * 12  ->  64
374        let b = sym(0);
375        let s = sym(1);
376        let total = DimExpr::product(&[b.clone(), s.clone(), DimExpr::constant(768)]);
377        let known = DimExpr::product(&[b, s, DimExpr::constant(12)]);
378        let missing = total.checked_div(&known).unwrap();
379        assert_eq!(missing.as_const(), Some(64));
380    }
381
382    #[test]
383    fn division_by_multiterm_is_none() {
384        let total = sym(0).mul(&sym(1));
385        let divisor = sym(0).add(&DimExpr::constant(1));
386        assert!(total.checked_div(&divisor).is_none());
387    }
388
389    #[test]
390    fn symbolic_product_division_keeps_symbol() {
391        // (b * 768) / 768 == b
392        let e = sym(0).mul(&DimExpr::constant(768));
393        let q = e.checked_div(&DimExpr::constant(768)).unwrap();
394        assert_eq!(q.as_symbol(), Some(SymbolId(0)));
395    }
396
397    #[test]
398    fn from_ir_dim() {
399        use onnx_runtime_ir::Dim;
400        assert_eq!(DimExpr::from(Dim::Static(4)).as_const(), Some(4));
401        assert_eq!(
402            DimExpr::from(Dim::Symbolic(SymbolId(9))).as_symbol(),
403            Some(SymbolId(9))
404        );
405    }
406
407    #[test]
408    fn mul_overflow_degrades_to_unknown() {
409        // A 2^80-scale product (four 2^20 dims) exceeds i64: no panic (as debug
410        // unchecked arithmetic would) and no wrap-to-zero (as release would).
411        let big = DimExpr::constant(1 << 20);
412        let total = DimExpr::product(&[big.clone(), big.clone(), big.clone(), big]);
413        assert!(total.is_overflow());
414        assert_eq!(total.as_const(), None); // never a bogus concrete dim
415        assert_eq!(total.as_symbol(), None);
416    }
417
418    #[test]
419    fn add_and_sub_overflow_degrade() {
420        let max = DimExpr::constant(i64::MAX);
421        assert!(max.add(&DimExpr::constant(1)).is_overflow());
422        let min = DimExpr::constant(i64::MIN);
423        assert!(min.sub(&DimExpr::constant(1)).is_overflow());
424    }
425
426    #[test]
427    fn overflow_poisons_further_arithmetic() {
428        let poisoned = DimExpr::overflow();
429        assert!(poisoned.add(&DimExpr::constant(1)).is_overflow());
430        assert!(poisoned.mul(&DimExpr::constant(2)).is_overflow());
431        assert!(poisoned.sub(&DimExpr::constant(3)).is_overflow());
432        assert!(poisoned.checked_div(&DimExpr::constant(4)).is_none());
433        // An overflowed divisor also degrades.
434        assert!(DimExpr::constant(8).checked_div(&poisoned).is_none());
435    }
436
437    #[test]
438    fn checked_div_guards_i64_min_over_neg_one() {
439        // i64::MIN / -1 overflows; the guard degrades to None rather than panic.
440        let num = DimExpr::constant(i64::MIN);
441        let div = DimExpr::constant(-1);
442        assert!(num.checked_div(&div).is_none());
443    }
444}