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}