Skip to main content

onnx_runtime_session/
executor.rs

1//! The sequential CPU executor (Track D, `docs/ORT2.md` §20, §11.3).
2//!
3//! Turns a loaded [`Graph`] plus its live [`WeightStore`] into a runnable plan:
4//! resolve every value's concrete shape from the actual bound inputs at
5//! `run`, size a device buffer per value from those *resolved* shapes, resolve
6//! a kernel per node through the execution provider (keyed by the resolved
7//! input shapes), then walk the topological order binding
8//! [`TensorView`]/[`TensorMut`] windows over those buffers and invoking each
9//! kernel. It is generic over any [`Graph`] and any [`ExecutionProvider`]; the
10//! Phase-1 build wires in the CPU EP only, but nothing here is op- or
11//! model-specific.
12//!
13//! ## Symbolic → concrete shape resolution (§3.2, §11)
14//!
15//! Real models carry *symbolic* input dims (e.g. `batch`, `max_seq_len`): the
16//! loader produces a [`Shape`] whose dims are a mix of [`Dim::Static`] and
17//! [`Dim::Symbolic`]. This executor is model-agnostic about them — a symbol is
18//! whatever [`SymbolId`] the graph interned. At `run` it reads the actual shape
19//! of each bound input, **binds** the graph's symbols to concrete sizes from
20//! those inputs (conflicts across inputs are an error), and **substitutes**
21//! those bindings into every value's loader shape to obtain a fully-concrete
22//! shape. Buffers are sized from the resolved shapes and become run-scoped when
23//! shapes are dynamic (reused when the resolved shape is unchanged, re-allocated
24//! when it changes). A fully-static graph is simply the special case where
25//! there are no symbols: resolution is a no-op and every buffer/kernel is
26//! materialized once at build.
27//!
28//! The session does **not** infer op output shapes — that is the loader's job
29//! (the loader runs `onnx-runtime-shape-inference` at load time). If a value's
30//! loader shape still contains an unbound symbol after substitution, the
31//! session resolves genuinely data-dependent extents just-in-time during
32//! execution (see [`dynamic_output_shapes`]); anything it still cannot size is
33//! reported as [`SessionError::UnresolvedShape`] naming the value and its
34//! producing op, rather than guessing.
35//!
36//! ## Holden's precondition (ep-api safety review #1) — enforced here
37//!
38//! A [`TensorView`] carries no backing length, so it cannot self-check storage
39//! bounds. This executor owns every buffer, so it is the layer that *can*: for
40//! **every** input and output view of **every** node it calls
41//! [`strided::view_in_bounds`] (or, for sub-byte dtypes, the `storage_bytes`
42//! equivalent in [`view_bounds`]) against the **run-scoped resolved** buffer and
43//! refuses to dispatch on failure. That check is the sole thing that makes
44//! ep-cpu's unchecked pointer derefs sound.
45
46use std::collections::HashMap;
47use std::sync::Arc;
48
49use onnx_runtime_ep_api::{
50    DeviceBuffer, DevicePtr, DevicePtrMut, ExecutionProvider, KernelMatch, TensorMut, TensorView,
51};
52use onnx_runtime_ep_cpu::strided::view_in_bounds;
53use onnx_runtime_ep_cpu::CpuExecutionProvider;
54use onnx_runtime_ir::{
55    as_static_shape, compute_contiguous_strides, DataType, Dim, Graph, Node, NodeId, Shape,
56    SymbolId, TensorLayout, ValueId,
57};
58use onnx_runtime_loader::WeightStore;
59
60use crate::error::{Result, SessionError};
61use crate::tensor::{host_bytes, write_host, Tensor};
62
63/// A per-node compiled entry: the structural facts the run loop needs without
64/// re-deriving them from the graph. Shapes are **not** baked here — they are
65/// resolved per run from the bound inputs (see module docs).
66#[derive(Debug)]
67pub(crate) struct NodePlan {
68    pub node_id: NodeId,
69    /// Present (non-skipped) input value ids, in positional order.
70    pub inputs: Vec<ValueId>,
71    /// Output value ids, in positional order.
72    pub outputs: Vec<ValueId>,
73    /// Element types of the present inputs.
74    pub input_dtypes: Vec<DataType>,
75    /// Element types of the outputs.
76    pub output_dtypes: Vec<DataType>,
77}
78
79/// Cache key for a compiled kernel (§11.1). Keyed by the concrete node and its
80/// **resolved** (concrete) input shapes: attributes are fixed per node, so this
81/// is correct, and the shape component makes it *shape-keyed* — a re-run with
82/// the same resolved shapes hits, a different shape (e.g. a new batch/seq)
83/// misses and re-compiles. This preserves Chew's guarantee: a kernel is never
84/// reused for a shape it was not compiled for.
85#[derive(Clone, PartialEq, Eq, Hash, Debug)]
86struct KernelKey {
87    node: u32,
88    shapes: Vec<Vec<usize>>,
89}
90
91/// Observable kernel-cache statistics (§11.1) — enough to prove reuse in tests.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub struct CacheStats {
94    /// Distinct compiled entries currently held.
95    pub entries: usize,
96    /// Lookups served from an existing entry.
97    pub hits: u64,
98    /// Lookups that compiled a new kernel.
99    pub misses: u64,
100}
101
102/// Shape-keyed kernel cache (§11.1). Owns the compiled kernels for the session.
103#[derive(Default)]
104pub(crate) struct KernelCache {
105    entries: HashMap<KernelKey, Box<dyn onnx_runtime_ep_api::Kernel>>,
106    hits: u64,
107    misses: u64,
108}
109
110impl KernelCache {
111    fn stats(&self) -> CacheStats {
112        CacheStats {
113            entries: self.entries.len(),
114            hits: self.hits,
115            misses: self.misses,
116        }
117    }
118
119    /// Return the cached kernel for `(node, resolved_input_shapes)`, verifying
120    /// EP support and compiling+inserting it on a miss. The EP support check
121    /// lives on the miss path so a re-planned shape is re-validated exactly
122    /// once per distinct shape.
123    fn get_or_create(
124        &mut self,
125        node_id: NodeId,
126        node: &Node,
127        input_shapes: &[Vec<usize>],
128        opset: u64,
129        ep: &CpuExecutionProvider,
130    ) -> Result<&dyn onnx_runtime_ep_api::Kernel> {
131        let key = KernelKey {
132            node: node_id.0,
133            shapes: input_shapes.to_vec(),
134        };
135        if self.entries.contains_key(&key) {
136            self.hits += 1;
137        } else {
138            // Verify the EP claims this op at these concrete shapes/layouts
139            // before compiling — same gate the static path used at build.
140            let shape_dims: Vec<Shape> = input_shapes
141                .iter()
142                .map(|s| s.iter().map(|&d| Dim::Static(d)).collect())
143                .collect();
144            let layouts = vec![TensorLayout::contiguous(); input_shapes.len()];
145            if !matches!(
146                ep.supports_op(node, &shape_dims, &layouts),
147                KernelMatch::Supported { .. }
148            ) {
149                return Err(SessionError::UnsupportedOp {
150                    op_type: node.op_type.clone(),
151                });
152            }
153            let kernel = ep.get_kernel(node, input_shapes, opset)?;
154            self.entries.insert(key.clone(), kernel);
155            self.misses += 1;
156        }
157        Ok(self.entries.get(&key).expect("just inserted").as_ref())
158    }
159}
160
161/// The compiled, runnable graph: buffers + plan + kernel cache. Owned by the
162/// public [`InferenceSession`](crate::InferenceSession).
163pub(crate) struct Executor {
164    graph: Graph,
165    /// Kept alive so external-weight memory maps outlive buffer population.
166    _weights: Arc<WeightStore>,
167    ep: Arc<CpuExecutionProvider>,
168    /// One device buffer per backed value. Static values are allocated once at
169    /// build; dynamic (symbol-shaped) values are allocated per run and cached
170    /// here so a run whose resolved shape is unchanged reuses the allocation.
171    buffers: HashMap<ValueId, DeviceBuffer>,
172    /// The concrete shape each live buffer in [`Self::buffers`] is currently
173    /// sized for — the reuse key for run-scoped buffers.
174    buffer_shapes: HashMap<ValueId, Vec<usize>>,
175    /// Loader-produced (possibly symbolic) shape of every value.
176    value_shapes: HashMap<ValueId, Shape>,
177    /// Element type of every value.
178    value_dtypes: HashMap<ValueId, DataType>,
179    /// Topologically ordered execution plan (structure only; shapes per run).
180    plan: Vec<NodePlan>,
181    /// name → value id for the graph inputs the caller must supply.
182    input_index: HashMap<String, ValueId>,
183    /// Value ids the caller must supply at `run` (graph inputs minus initializers).
184    required_inputs: Vec<ValueId>,
185    /// Whether any value in the graph carries a symbolic dim. A fully-static
186    /// graph is materialized eagerly at build; a symbolic graph defers buffer
187    /// allocation and kernel compilation to the first `run` that fixes shapes.
188    has_symbols: bool,
189    cache: KernelCache,
190}
191
192/// The `[shape, strides, byte_offset]` storage-bounds gate (Holden's
193/// precondition). Uses [`view_in_bounds`] for fixed-width dtypes and a
194/// `storage_bytes` check for sub-byte packed dtypes (which have no integral
195/// per-element byte size).
196fn view_bounds(
197    shape: &[usize],
198    strides: &[i64],
199    byte_offset: usize,
200    dtype: DataType,
201    buffer_len: usize,
202) -> Result<()> {
203    let esize = dtype.byte_size();
204    if esize == 0 {
205        // Sub-byte (int4/uint4) or variable-width: size via `storage_bytes`.
206        let numel: usize = shape.iter().product();
207        let need = byte_offset + dtype.storage_bytes(numel);
208        if need > buffer_len {
209            return Err(SessionError::from(
210                onnx_runtime_ep_api::EpError::InvalidTensorView {
211                    reason: format!(
212                        "sub-byte view needs {need} bytes but backing allocation is {buffer_len}"
213                    ),
214                },
215            ));
216        }
217        return Ok(());
218    }
219    view_in_bounds(shape, strides, byte_offset, esize, buffer_len)?;
220    Ok(())
221}
222
223/// Element count of a shape with overflow checking. A malicious or corrupt
224/// shape whose dims multiply past `usize::MAX` would silently wrap under a plain
225/// `iter().product()`, under-sizing the backing buffer. Returns
226/// [`SessionError::ShapeOverflow`] instead so the caller allocates nothing.
227fn checked_numel(dims: &[usize], value: impl FnOnce() -> String) -> Result<usize> {
228    let mut acc = 1usize;
229    for &d in dims {
230        acc = match acc.checked_mul(d) {
231            Some(n) => n,
232            None => {
233                return Err(SessionError::ShapeOverflow {
234                    value: value(),
235                    dims: dims.to_vec(),
236                })
237            }
238        };
239    }
240    Ok(acc)
241}
242
243/// Byte size of `numel` elements of `dtype` with overflow checking. Even when
244/// the element *count* fits in `usize` (guarded by [`checked_numel`]), the
245/// element-count → bytes multiply can still wrap for a fixed-width dtype and
246/// under-size the backing buffer. Returns [`SessionError::ShapeOverflow`] so the
247/// caller allocates nothing rather than a wrapped, undersized buffer.
248fn checked_storage_bytes(
249    dtype: DataType,
250    numel: usize,
251    value: impl FnOnce() -> String,
252    dims: &[usize],
253) -> Result<usize> {
254    dtype
255        .checked_storage_bytes(numel)
256        .ok_or_else(|| SessionError::ShapeOverflow {
257            value: value(),
258            dims: dims.to_vec(),
259        })
260}
261
262/// The effective operator-set version governing `node` — the graph's imported
263/// opset for the node's domain. The default ONNX domain is spelled both `""`
264/// and `"ai.onnx"`; both map to the same import. When a graph omits the import
265/// (should not happen for a loaded model), fall back to `u64::MAX` so ops with a
266/// single registration still resolve and opset-specialized ops pick their
267/// newest kernel.
268fn effective_opset(graph: &Graph, node: &Node) -> u64 {
269    let domain = node.domain.as_str();
270    graph
271        .opset_imports
272        .get(domain)
273        .or_else(|| {
274            if domain.is_empty() {
275                graph.opset_imports.get("ai.onnx")
276            } else if domain == "ai.onnx" {
277                graph.opset_imports.get("")
278            } else {
279                None
280            }
281        })
282        .copied()
283        .unwrap_or(u64::MAX)
284}
285
286/// Substitute concrete symbol bindings into a (possibly symbolic) shape.
287/// Returns `None` if any dim is a symbol with no binding.
288fn substitute(shape: &Shape, bindings: &HashMap<SymbolId, usize>) -> Option<Vec<usize>> {
289    shape
290        .iter()
291        .map(|d| match d {
292            Dim::Static(n) => Some(*n),
293            Dim::Symbolic(s) => bindings.get(s).copied(),
294        })
295        .collect()
296}
297
298/// Decode a host buffer's integer elements as `i64` for `dtype`, or `None` if
299/// the dtype is not an integer the shape math understands. Used to read the
300/// *values* of shape-defining inputs (e.g. `Slice` starts/ends) at run time.
301fn buffer_as_i64(buffer: &DeviceBuffer, dtype: DataType) -> Option<Vec<i64>> {
302    let bytes = crate::tensor::host_bytes(buffer);
303    match dtype {
304        DataType::Int64 => Some(
305            bytes
306                .chunks_exact(8)
307                .map(|c| i64::from_le_bytes(c.try_into().unwrap()))
308                .collect(),
309        ),
310        DataType::Int32 => Some(
311            bytes
312                .chunks_exact(4)
313                .map(|c| i32::from_le_bytes(c.try_into().unwrap()) as i64)
314                .collect(),
315        ),
316        _ => None,
317    }
318}
319
320/// Compute the concrete output shapes of a *data-dependent* shape op from its
321/// already-resolved input shapes and the runtime *values* of its integer
322/// inputs. This is the executor's fallback for the rare value whose shape the
323/// loader's static (symbolic) inference could not pin down — e.g. a `Slice`
324/// whose `ends` is produced by a runtime `Shape → Min → Cast` chain, so its
325/// extent is only known once those upstream nodes have executed.
326///
327/// Model-agnostic: it dispatches on the op type alone. Returns `None` for ops
328/// this executor cannot resolve dynamically, which surfaces as
329/// [`SessionError::UnresolvedShape`] exactly as before.
330fn dynamic_output_shapes(
331    node: &Node,
332    input_shapes: &[Vec<usize>],
333    input_values: &[Option<Vec<i64>>],
334) -> Option<Vec<Vec<usize>>> {
335    match node.op_type.as_str() {
336        // Opset-10+ `Slice`: data, starts, ends, [axes], [steps] as inputs. The
337        // per-axis element count mirrors the `Slice` kernel's clamp semantics
338        // exactly (ONNX reference), so the buffer we size here matches what the
339        // kernel writes.
340        "Slice" => {
341            let data_shape = input_shapes.first()?;
342            let starts = input_values.get(1)?.as_ref()?;
343            let ends = input_values.get(2)?.as_ref()?;
344            let (axes, steps) = onnx_runtime_ep_cpu::slice_axes_steps(
345                starts.len(),
346                input_values.get(3).and_then(|v| v.as_deref()),
347                input_values.get(4).and_then(|v| v.as_deref()),
348            );
349            // Reuse the exact kernel geometry helper so the buffer we size here
350            // always matches what the Slice kernel writes. Any error (length
351            // mismatch, out-of-range axis, zero step) means "cannot resolve".
352            let plan =
353                onnx_runtime_ep_cpu::slice_plan(data_shape, starts, ends, &axes, &steps).ok()?;
354            let count: Vec<usize> = plan.iter().map(|p| p.count).collect();
355            Some(vec![count])
356        }
357        _ => None,
358    }
359}
360
361impl Executor {
362    /// Compile a graph + weights into a runnable executor on the CPU EP.
363    pub(crate) fn build(
364        graph: Graph,
365        weights: Arc<WeightStore>,
366        ep: Arc<CpuExecutionProvider>,
367    ) -> Result<Self> {
368        // Topological order up front: also validates the graph is a DAG.
369        let order = graph.topological_order()?;
370
371        let mut value_shapes: HashMap<ValueId, Shape> = HashMap::new();
372        let mut value_dtypes: HashMap<ValueId, DataType> = HashMap::new();
373        let mut buffers: HashMap<ValueId, DeviceBuffer> = HashMap::new();
374        let mut buffer_shapes: HashMap<ValueId, Vec<usize>> = HashMap::new();
375
376        // 1) Initializers: always concrete. Record dims, allocate, copy weights.
377        for (&vid, weight) in &graph.initializers {
378            let dtype = weight.dtype();
379            let dims = weight.dims().to_vec();
380            let bytes = weights.bytes(weight).ok_or_else(|| {
381                SessionError::Internal(format!("weight bytes unavailable for value#{}", vid.0))
382            })?;
383            let mut buf = ep.allocate(bytes.len().max(1), TensorLayout::contiguous().alignment)?;
384            write_host(&mut buf, bytes)?;
385            value_dtypes.insert(vid, dtype);
386            value_shapes.insert(vid, dims.iter().map(|&d| Dim::Static(d)).collect());
387            buffer_shapes.insert(vid, dims);
388            buffers.insert(vid, buf);
389        }
390
391        // 2) Record the loader shape + dtype of every remaining value (graph
392        //    inputs and node outputs). No allocation yet — shapes may be
393        //    symbolic and are only sized once resolved.
394        for &vid in &graph.inputs {
395            value_shapes
396                .entry(vid)
397                .or_insert_with(|| graph.value(vid).shape.clone());
398            value_dtypes.entry(vid).or_insert(graph.value(vid).dtype);
399        }
400        for &nid in &order {
401            for &out in &graph.node(nid).outputs {
402                value_shapes
403                    .entry(out)
404                    .or_insert_with(|| graph.value(out).shape.clone());
405                value_dtypes.entry(out).or_insert(graph.value(out).dtype);
406            }
407        }
408
409        let has_symbols = value_shapes.values().any(|s| as_static_shape(s).is_none());
410
411        // 3) Build the structural per-node plan.
412        let mut plan = Vec::with_capacity(order.len());
413        for &nid in &order {
414            let node = graph.node(nid);
415            // EPContext nodes are pre-compiled: they bypass placement and were
416            // already restored through their owning EP by the session's
417            // consume path (§55.3). They must never be resolved as ordinary
418            // kernels — the CPU EP has no `EPContext` kernel — so skip them
419            // here.
420            if onnx_runtime_loader::is_ep_context_op(&node.op_type, &node.domain) {
421                continue;
422            }
423            let inputs: Vec<ValueId> = node.input_values().collect();
424            let outputs: Vec<ValueId> = node.outputs.clone();
425            let input_dtypes: Vec<DataType> = inputs.iter().map(|v| value_dtypes[v]).collect();
426            let output_dtypes: Vec<DataType> = outputs.iter().map(|v| value_dtypes[v]).collect();
427            plan.push(NodePlan {
428                node_id: nid,
429                inputs,
430                outputs,
431                input_dtypes,
432                output_dtypes,
433            });
434        }
435
436        // 4) name → value id and the set of caller-required inputs.
437        let mut input_index = HashMap::new();
438        let mut required_inputs = Vec::new();
439        for &vid in &graph.inputs {
440            if graph.initializers.contains_key(&vid) {
441                continue; // pre-filled; not a caller input
442            }
443            required_inputs.push(vid);
444            if let Some(name) = &graph.value(vid).name {
445                input_index.insert(name.clone(), vid);
446            }
447        }
448
449        let mut exec = Self {
450            graph,
451            _weights: weights,
452            ep,
453            buffers,
454            buffer_shapes,
455            value_shapes,
456            value_dtypes,
457            plan,
458            input_index,
459            required_inputs,
460            has_symbols,
461            cache: KernelCache::default(),
462        };
463
464        // 5) Fully-static graphs are materialized eagerly (buffers + the whole
465        //    "compiled plan" of kernels), so the first `run` sees only cache
466        //    hits. Symbolic graphs cannot be sized until a `run` fixes their
467        //    shapes, so their buffers/kernels are created on first use.
468        if !exec.has_symbols {
469            let empty = HashMap::new();
470            let resolved = exec.resolve_all(&empty)?;
471            exec.size_buffers(&resolved)?;
472            exec.compile_all(&resolved)?;
473        }
474        Ok(exec)
475    }
476
477    /// Allocate `vid`'s buffer for `dims`, or reuse the existing allocation when
478    /// it is already sized for `dims` (the run-scoped reuse path).
479    fn ensure_buffer(&mut self, vid: ValueId, dtype: DataType, dims: &[usize]) -> Result<()> {
480        if self.buffer_shapes.get(&vid).map(|s| s.as_slice()) == Some(dims) {
481            return Ok(()); // identical shape → reuse allocation
482        }
483        if let Some(old) = self.buffers.remove(&vid) {
484            self.ep.deallocate(old)?;
485        }
486        let numel = checked_numel(dims, || format!("value#{}", vid.0))?;
487        let size = checked_storage_bytes(dtype, numel, || format!("value#{}", vid.0), dims)?;
488        let buf = self
489            .ep
490            .allocate(size.max(1), TensorLayout::contiguous().alignment)?;
491        self.buffers.insert(vid, buf);
492        self.buffer_shapes.insert(vid, dims.to_vec());
493        Ok(())
494    }
495
496    /// Resolve every value's concrete shape by substituting `bindings` into its
497    /// loader shape. A value whose shape stays symbolic (unbound) cannot be
498    /// sized: report it as an uninferred shape, naming its producing op.
499    fn resolve_all(
500        &self,
501        bindings: &HashMap<SymbolId, usize>,
502    ) -> Result<HashMap<ValueId, Vec<usize>>> {
503        let mut resolved = HashMap::with_capacity(self.value_shapes.len());
504        for (&vid, shape) in &self.value_shapes {
505            match substitute(shape, bindings) {
506                Some(dims) => {
507                    resolved.insert(vid, dims);
508                }
509                None => {
510                    let value = self.graph.value(vid);
511                    let name = value
512                        .name
513                        .clone()
514                        .unwrap_or_else(|| format!("value#{}", vid.0));
515                    let op = value
516                        .producer
517                        .map(|nid| self.graph.node(nid).op_type.clone())
518                        .unwrap_or_else(|| "<graph input>".to_string());
519                    return Err(SessionError::UnresolvedShape { value: name, op });
520                }
521            }
522        }
523        Ok(resolved)
524    }
525
526    /// Like [`Self::resolve_all`] but never errors: values whose shape stays
527    /// symbolic (a data-dependent extent the loader could not pin down) are
528    /// simply omitted, to be resolved just-in-time during execution once their
529    /// producing node's inputs are concrete.
530    fn resolve_soft(&self, bindings: &HashMap<SymbolId, usize>) -> HashMap<ValueId, Vec<usize>> {
531        let mut resolved = HashMap::with_capacity(self.value_shapes.len());
532        for (&vid, shape) in &self.value_shapes {
533            if let Some(dims) = substitute(shape, bindings) {
534                resolved.insert(vid, dims);
535            }
536        }
537        resolved
538    }
539
540    /// Size (allocate or reuse) a backing buffer for every value from its
541    /// resolved concrete shape. Initializers already hold their weights and are
542    /// left untouched. Values whose shape is not (yet) in `resolved` — the
543    /// data-dependent ones filled in during execution — are skipped here and
544    /// sized just-in-time in the run loop.
545    fn size_buffers(&mut self, resolved: &HashMap<ValueId, Vec<usize>>) -> Result<()> {
546        let vids: Vec<ValueId> = self.value_shapes.keys().copied().collect();
547        for vid in vids {
548            if self.graph.initializers.contains_key(&vid) {
549                continue;
550            }
551            let dtype = self.value_dtypes[&vid];
552            let Some(dims) = resolved.get(&vid).cloned() else {
553                continue;
554            };
555            self.ensure_buffer(vid, dtype, &dims)?;
556        }
557        Ok(())
558    }
559
560    /// Resolved input shapes of a plan node, in positional order.
561    fn node_input_shapes(
562        plan: &NodePlan,
563        resolved: &HashMap<ValueId, Vec<usize>>,
564    ) -> Vec<Vec<usize>> {
565        plan.inputs.iter().map(|v| resolved[v].clone()).collect()
566    }
567
568    /// Resolved output shapes of a plan node, in positional order.
569    fn node_output_shapes(
570        plan: &NodePlan,
571        resolved: &HashMap<ValueId, Vec<usize>>,
572    ) -> Vec<Vec<usize>> {
573        plan.outputs.iter().map(|v| resolved[v].clone()).collect()
574    }
575
576    /// Populate the kernel cache for the compiled plan against `resolved` shapes.
577    fn compile_all(&mut self, resolved: &HashMap<ValueId, Vec<usize>>) -> Result<()> {
578        for i in 0..self.plan.len() {
579            let node_id = self.plan[i].node_id;
580            let input_shapes = Self::node_input_shapes(&self.plan[i], resolved);
581            let node = self.graph.node(node_id);
582            let opset = effective_opset(&self.graph, node);
583            self.cache
584                .get_or_create(node_id, node, &input_shapes, opset, &self.ep)?;
585        }
586        Ok(())
587    }
588
589    pub(crate) fn cache_stats(&self) -> CacheStats {
590        self.cache.stats()
591    }
592
593    /// The compiled graph, retained for the §55.4 EPContext dump path: the
594    /// exporter needs the (post-optimize) graph to serialise a `*_ctx.onnx`
595    /// context-cache model with compiled partitions spliced out.
596    pub(crate) fn graph(&self) -> &Graph {
597        &self.graph
598    }
599
600    /// Live weight bytes backing the graph, needed alongside [`Self::graph`] so
601    /// the EPContext dump can encode initializers into the context model.
602    pub(crate) fn weights(&self) -> &Arc<WeightStore> {
603        &self._weights
604    }
605
606    /// Warmup: re-touch the shape-keyed cache for the compiled plan so the first
607    /// real `run` sees only cache hits (§11.3). Only meaningful for fully-static
608    /// graphs, whose plan shapes are known at build; symbolic graphs cannot be
609    /// pre-compiled without a concrete shape and warm up on their first `run`.
610    pub(crate) fn warmup(&mut self) -> Result<()> {
611        if self.has_symbols {
612            return Ok(());
613        }
614        let empty = HashMap::new();
615        let resolved = self.resolve_all(&empty)?;
616        self.compile_all(&resolved)
617    }
618
619    /// Bind the graph's symbols to concrete sizes from the actual bound-input
620    /// shapes, validating rank and static dims and detecting symbol conflicts.
621    fn bind_symbols(
622        &self,
623        inputs: &[(&str, &Tensor)],
624    ) -> Result<HashMap<SymbolId, usize>> {
625        let mut bindings: HashMap<SymbolId, usize> = HashMap::new();
626        for (name, tensor) in inputs {
627            let vid = *self
628                .input_index
629                .get(*name)
630                .ok_or_else(|| SessionError::InputNotFound {
631                    name: (*name).to_string(),
632                })?;
633            let want_dtype = self.value_dtypes[&vid];
634            if tensor.dtype != want_dtype {
635                return Err(SessionError::DtypeMismatch {
636                    name: (*name).to_string(),
637                    expected: format!("{want_dtype:?}"),
638                    got: format!("{:?}", tensor.dtype),
639                });
640            }
641            let decl = &self.value_shapes[&vid];
642            if decl.len() != tensor.shape.len() {
643                return Err(SessionError::RankMismatch {
644                    name: (*name).to_string(),
645                    expected: decl.len(),
646                    got: tensor.shape.len(),
647                });
648            }
649            for (dim, &actual) in decl.iter().zip(&tensor.shape) {
650                match dim {
651                    Dim::Static(n) => {
652                        if *n != actual {
653                            return Err(SessionError::ShapeMismatch {
654                                name: (*name).to_string(),
655                                expected: as_static_shape(decl).unwrap_or_default(),
656                                got: tensor.shape.clone(),
657                            });
658                        }
659                    }
660                    Dim::Symbolic(s) => {
661                        if let Some(&prev) = bindings.get(s) {
662                            if prev != actual {
663                                let sym = self
664                                    .symbol_name(*s)
665                                    .unwrap_or_else(|| format!("symbol#{}", s.0));
666                                return Err(SessionError::SymbolConflict {
667                                    symbol: sym,
668                                    first: prev,
669                                    second: actual,
670                                });
671                            }
672                        } else {
673                            bindings.insert(*s, actual);
674                        }
675                    }
676                }
677            }
678        }
679        Ok(bindings)
680    }
681
682    /// Human-readable name of a symbol, if the graph recorded one.
683    fn symbol_name(&self, s: SymbolId) -> Option<String> {
684        self.graph
685            .symbol_constraints
686            .get(&s)
687            .and_then(|c| c.name.clone())
688    }
689
690    /// Sequential topological executor.
691    pub(crate) fn run(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<Tensor>> {
692        // --- Resolve shapes from the actual bound inputs --------------------
693        let bindings = self.bind_symbols(inputs)?;
694
695        // Every required input must be supplied.
696        let provided: Vec<ValueId> = inputs
697            .iter()
698            .filter_map(|(name, _)| self.input_index.get(*name).copied())
699            .collect();
700        for &vid in &self.required_inputs {
701            if !provided.contains(&vid) {
702                let name = self
703                    .graph
704                    .value(vid)
705                    .name
706                    .clone()
707                    .unwrap_or_else(|| format!("value#{}", vid.0));
708                return Err(SessionError::InputNotFound { name });
709            }
710        }
711
712        // Substitute the bindings into every value → concrete shapes, then size
713        // the run-scoped buffers from them (reused when unchanged). Values with a
714        // data-dependent shape stay unresolved here and are filled in during the
715        // execution loop, once their producing node's inputs are concrete.
716        let mut resolved = self.resolve_soft(&bindings);
717        self.size_buffers(&resolved)?;
718
719        // --- Bind input bytes into their (now correctly sized) buffers ------
720        for (name, tensor) in inputs {
721            let vid = self.input_index[*name];
722            let buf = self
723                .buffers
724                .get_mut(&vid)
725                .expect("input value has a buffer");
726            write_host(buf, tensor.as_bytes())?;
727        }
728
729        // --- Execute nodes ---------------------------------------------------
730        // Split borrows by field so the kernel (borrowed from `cache`) and the
731        // buffers can be touched in the same iteration.
732        let graph = &self.graph;
733        let ep = self.ep.clone();
734        let cache = &mut self.cache;
735        let buffers = &mut self.buffers;
736
737        for np in &self.plan {
738            let input_shapes = Self::node_input_shapes(np, &resolved);
739
740            // Data-dependent shapes: if any output's shape is still unresolved,
741            // compute it now from the concrete input shapes + the runtime values
742            // of this node's integer inputs (which upstream nodes have already
743            // produced), then size those buffers just-in-time.
744            if np.outputs.iter().any(|v| !resolved.contains_key(v)) {
745                let input_values: Vec<Option<Vec<i64>>> = np
746                    .inputs
747                    .iter()
748                    .enumerate()
749                    .map(|(i, v)| {
750                        buffers
751                            .get(v)
752                            .and_then(|b| buffer_as_i64(b, np.input_dtypes[i]))
753                    })
754                    .collect();
755                let node = graph.node(np.node_id);
756                let out_shapes = dynamic_output_shapes(node, &input_shapes, &input_values)
757                    .ok_or_else(|| {
758                        let vid = np
759                            .outputs
760                            .iter()
761                            .find(|v| !resolved.contains_key(v))
762                            .copied()
763                            .unwrap_or(np.outputs[0]);
764                        let value = graph.value(vid);
765                        SessionError::UnresolvedShape {
766                            value: value
767                                .name
768                                .clone()
769                                .unwrap_or_else(|| format!("value#{}", vid.0)),
770                            op: node.op_type.clone(),
771                        }
772                    })?;
773                // A future multi-output data-dependent op would misindex
774                // `out_shapes[oi]`; verify the sizer returned exactly one shape
775                // per output rather than panicking on a length mismatch.
776                if out_shapes.len() != np.outputs.len() {
777                    return Err(SessionError::OutputShapeCountMismatch {
778                        op: node.op_type.clone(),
779                        expected: np.outputs.len(),
780                        got: out_shapes.len(),
781                    });
782                }
783                for (oi, &ovid) in np.outputs.iter().enumerate() {
784                    let dims = out_shapes[oi].clone();
785                    let numel = checked_numel(&dims, || format!("value#{}", ovid.0))?;
786                    let need = checked_storage_bytes(
787                        np.output_dtypes[oi],
788                        numel,
789                        || format!("value#{}", ovid.0),
790                        &dims,
791                    )?
792                    .max(1);
793                    let fits = buffers.get(&ovid).map(|b| b.len() == need).unwrap_or(false);
794                    if !fits {
795                        if let Some(old) = buffers.remove(&ovid) {
796                            ep.deallocate(old)?;
797                        }
798                        let buf = ep.allocate(need, TensorLayout::contiguous().alignment)?;
799                        buffers.insert(ovid, buf);
800                    }
801                    resolved.insert(ovid, dims);
802                }
803            }
804
805            let output_shapes = Self::node_output_shapes(np, &resolved);
806
807            // Precompute contiguous strides for every input/output view; these
808            // holders must outlive the views that borrow them.
809            let in_strides: Vec<Vec<i64>> = input_shapes
810                .iter()
811                .map(|s| compute_contiguous_strides(s))
812                .collect();
813            let out_strides: Vec<Vec<i64>> = output_shapes
814                .iter()
815                .map(|s| compute_contiguous_strides(s))
816                .collect();
817
818            // Input base pointers (raw, no lingering borrow) + bounds gate
819            // against the run-scoped resolved buffers.
820            let mut in_ptrs: Vec<*const std::ffi::c_void> = Vec::with_capacity(np.inputs.len());
821            for (i, &vid) in np.inputs.iter().enumerate() {
822                let buf = buffers.get(&vid).ok_or_else(|| {
823                    SessionError::Internal(format!("missing buffer for input value#{}", vid.0))
824                })?;
825                view_bounds(
826                    &input_shapes[i],
827                    &in_strides[i],
828                    0,
829                    np.input_dtypes[i],
830                    buf.len(),
831                )?;
832                in_ptrs.push(buf.as_ptr());
833            }
834
835            // Take output buffers out of the map so they can be borrowed `&mut`
836            // without conflicting with the input reads still in the map (SSA
837            // guarantees outputs are disjoint from inputs).
838            let mut out_bufs: Vec<(ValueId, DeviceBuffer)> = Vec::with_capacity(np.outputs.len());
839            for &vid in &np.outputs {
840                let buf = buffers.remove(&vid).ok_or_else(|| {
841                    SessionError::Internal(format!("missing buffer for output value#{}", vid.0))
842                })?;
843                out_bufs.push((vid, buf));
844            }
845
846            // Build input views over the raw pointers.
847            let mut views: Vec<TensorView> = Vec::with_capacity(np.inputs.len());
848            for i in 0..np.inputs.len() {
849                views.push(TensorView::new(
850                    DevicePtr(in_ptrs[i]),
851                    np.input_dtypes[i],
852                    &input_shapes[i],
853                    &in_strides[i],
854                    onnx_runtime_ir::DeviceId::cpu(),
855                ));
856            }
857
858            // Build output views + bounds gate.
859            let mut outs: Vec<TensorMut> = Vec::with_capacity(out_bufs.len());
860            for (i, (_, buf)) in out_bufs.iter_mut().enumerate() {
861                view_bounds(
862                    &output_shapes[i],
863                    &out_strides[i],
864                    0,
865                    np.output_dtypes[i],
866                    buf.len(),
867                )?;
868                let ptr = buf.as_mut_ptr();
869                outs.push(TensorMut::new(
870                    DevicePtrMut(ptr),
871                    np.output_dtypes[i],
872                    &output_shapes[i],
873                    &out_strides[i],
874                    onnx_runtime_ir::DeviceId::cpu(),
875                ));
876            }
877
878            // Resolve the kernel (shape-keyed by the resolved input shapes) and
879            // dispatch.
880            let node = graph.node(np.node_id);
881            let opset = effective_opset(graph, node);
882            let kernel = cache.get_or_create(np.node_id, node, &input_shapes, opset, &ep)?;
883            kernel.execute(&views, &mut outs)?;
884
885            // Drop the views (they borrow the holders/pointers) before moving
886            // the output buffers back into the map.
887            drop(views);
888            drop(outs);
889            for (vid, buf) in out_bufs {
890                buffers.insert(vid, buf);
891            }
892        }
893
894        // --- Collect graph outputs into owned tensors -----------------------
895        let mut results = Vec::with_capacity(self.graph.outputs.len());
896        for &vid in &self.graph.outputs {
897            let dtype = self.value_dtypes[&vid];
898            let shape = resolved[&vid].clone();
899            let buf = self.buffers.get(&vid).ok_or_else(|| {
900                SessionError::Internal(format!("output value#{} not produced", vid.0))
901            })?;
902            let n = dtype.storage_bytes(shape.iter().product());
903            let bytes = &host_bytes(buf)[..n];
904            results.push(Tensor::from_raw_in(self.ep.clone(), dtype, shape, bytes)?);
905        }
906        Ok(results)
907    }
908}
909
910impl Drop for Executor {
911    fn drop(&mut self) {
912        // Free every buffer via the owning EP (DeviceBuffer has no Drop).
913        for (_, buf) in self.buffers.drain() {
914            let _ = self.ep.deallocate(buf);
915        }
916    }
917}
918
919/// Instantiate and initialize the Phase-1 CPU execution provider (§20.7,
920/// CPU-only auto-detection). A GPU/accelerator EP would be prepended here in a
921/// later phase; for Phase 1 the CPU EP is the sole, always-available backend.
922pub(crate) fn auto_detect_cpu_ep() -> Result<Arc<CpuExecutionProvider>> {
923    let mut ep = CpuExecutionProvider::new();
924    ep.initialize(&Default::default())?;
925    Ok(Arc::new(ep))
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    /// Holden's precondition: the dispatch-boundary gate must reject a view that
933    /// addresses bytes past its backing allocation, rather than letting a kernel
934    /// dereference out of bounds (UB).
935    #[test]
936    fn view_bounds_rejects_out_of_bounds_view() {
937        // A [2, 3] f32 view needs 24 bytes; give it a 16-byte backing length.
938        let shape = [2usize, 3];
939        let strides = compute_contiguous_strides(&shape);
940        let err = view_bounds(&shape, &strides, 0, DataType::Float32, 16);
941        assert!(err.is_err(), "gate must reject an oversized view");
942
943        // Exactly-fitting length is accepted.
944        assert!(view_bounds(&shape, &strides, 0, DataType::Float32, 24).is_ok());
945    }
946
947    /// A negative byte offset region (via a byte_offset that pushes the origin
948    /// past the buffer) is also rejected.
949    #[test]
950    fn view_bounds_rejects_offset_overrun() {
951        let shape = [4usize];
952        let strides = compute_contiguous_strides(&shape);
953        // 4 f32 = 16 bytes; origin at byte 8 leaves only 8 bytes → overrun.
954        assert!(view_bounds(&shape, &strides, 8, DataType::Float32, 16).is_err());
955        assert!(view_bounds(&shape, &strides, 0, DataType::Float32, 16).is_ok());
956    }
957
958    /// Symbol substitution: static dims pass through, bound symbols resolve, an
959    /// unbound symbol yields `None` (the uninferred-shape signal).
960    #[test]
961    fn substitute_resolves_bound_symbols_only() {
962        let mut bindings = HashMap::new();
963        bindings.insert(SymbolId(0), 7usize);
964        let shape = vec![Dim::Symbolic(SymbolId(0)), Dim::Static(4)];
965        assert_eq!(substitute(&shape, &bindings), Some(vec![7, 4]));
966
967        let unbound = vec![Dim::Symbolic(SymbolId(1)), Dim::Static(4)];
968        assert_eq!(substitute(&unbound, &bindings), None);
969    }
970
971    /// H-D1: element-count multiplication must be overflow-checked so a huge or
972    /// malicious shape reports `ShapeOverflow` instead of wrapping `usize` and
973    /// under-sizing the buffer.
974    #[test]
975    fn checked_numel_detects_overflow() {
976        // Well-formed shapes multiply normally.
977        assert_eq!(checked_numel(&[2, 3, 4], || "v".into()).unwrap(), 24);
978        assert_eq!(checked_numel(&[], || "v".into()).unwrap(), 1);
979
980        // A product past usize::MAX overflows.
981        let huge = [usize::MAX, 2];
982        let err = checked_numel(&huge, || "value#9".into());
983        assert!(matches!(
984            err,
985            Err(SessionError::ShapeOverflow { .. })
986        ));
987    }
988
989    /// H-D1 (byte layer): even when the element *count* fits in `usize`, the
990    /// count → bytes multiply can wrap for a fixed-width dtype. The allocation
991    /// path must report `ShapeOverflow` rather than under-allocating.
992    #[test]
993    fn checked_storage_bytes_detects_byte_overflow() {
994        // `usize::MAX / 4` elements fit in usize (pass checked_numel) but
995        // `* 8` bytes for Float64 wraps — this is the exploited under-alloc.
996        let numel = usize::MAX / 4;
997        let err = checked_storage_bytes(DataType::Float64, numel, || "value#9".into(), &[numel]);
998        assert!(matches!(err, Err(SessionError::ShapeOverflow { .. })));
999
1000        // A well-formed size passes through unchanged.
1001        assert_eq!(
1002            checked_storage_bytes(DataType::Float32, 4, || "v".into(), &[4]).unwrap(),
1003            16
1004        );
1005    }
1006
1007    /// The data-dependent shape sizer must return exactly one shape per output
1008    /// so the run loop's `out_shapes[oi]` indexing can never misindex. Slice is
1009    /// single-output, so it returns a 1-element Vec; the run loop additionally
1010    /// guards the count (see `OutputShapeCountMismatch`).
1011    #[test]
1012    fn dynamic_output_shapes_slice_is_single_output() {
1013        let node = Node::new(NodeId(0), "Slice", vec![], vec![]);
1014        let input_shapes = vec![vec![4usize, 2]];
1015        let input_values = vec![
1016            None,           // data (unused by sizer)
1017            Some(vec![1]),  // starts
1018            Some(vec![3]),  // ends
1019            Some(vec![0]),  // axes
1020            Some(vec![1]),  // steps
1021        ];
1022        let out = dynamic_output_shapes(&node, &input_shapes, &input_values).unwrap();
1023        assert_eq!(out.len(), 1, "Slice must resolve exactly one output shape");
1024        assert_eq!(out[0], vec![2, 2]);
1025
1026        // An op the sizer cannot resolve returns None (surfaces as UnresolvedShape).
1027        let other = Node::new(NodeId(1), "Conv", vec![], vec![]);
1028        assert!(dynamic_output_shapes(&other, &input_shapes, &input_values).is_none());
1029    }
1030
1031    /// The effective opset is read from the graph's import for the op's domain,
1032    /// with the default and `ai.onnx` spellings treated as one.
1033    #[test]
1034    fn effective_opset_reads_graph_import() {
1035        let mut graph = Graph::default();
1036        graph.opset_imports.insert(String::new(), 12);
1037        let node = Node::new(NodeId(0), "Softmax", vec![], vec![]);
1038        assert_eq!(effective_opset(&graph, &node), 12);
1039
1040        // Missing import falls back to u64::MAX (assume latest).
1041        let empty = Graph::default();
1042        assert_eq!(effective_opset(&empty, &node), u64::MAX);
1043    }
1044}