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}