onnx_runtime_loader/lib.rs
1//! # `onnx-runtime-loader`
2//!
3//! Loads ONNX models from disk into the [`onnx_runtime_ir::Graph`] IR
4//! (see `docs/ORT2.md` §19).
5//!
6//! Pipeline ([`load_model`] / [`load_model_with_weights`]):
7//! 1. [`proto`] — decode the ONNX protobuf (`prost` types generated from the
8//! vendored `onnx.proto3`) into a `ModelProto`.
9//! 2. [`graph_builder`] — build an [`onnx_runtime_ir::Graph`] (nodes, values,
10//! symbolic dim interning, opset imports), upholding the §3.5 invariants.
11//! 3. [`weights`] — resolve inline and external initializer data (external
12//! files are memory-mapped into a [`WeightStore`]).
13//! 4. Static/symbolic shape inference via
14//! [`onnx-runtime-shape-inference`](onnx_runtime_shape_inference): the loader
15//! owns the "loader = shape-inference" seam, so after the [`Graph`] is built
16//! (with initializers applied) it runs the extensible per-op registry to
17//! populate every value's shape and dtype. Values that cannot be resolved
18//! statically (genuinely data-dependent extents) are left symbolic for the
19//! session to resolve just-in-time.
20//!
21//! ## Obtaining weight bytes at session time
22//!
23//! Use [`load_model_with_weights`] (or [`load_model_bytes_with_weights`]) to
24//! receive both the [`Graph`] and an [`Arc<WeightStore>`]. Then, given any
25//! [`onnx_runtime_ir::WeightRef`] stored in `graph.initializers`, call
26//! [`WeightStore::bytes`] to get the raw little-endian byte slice:
27//!
28//! ```ignore
29//! let (graph, store) = load_model_with_weights("model.onnx")?;
30//! for (vid, weight_ref) in &graph.initializers {
31//! let bytes: &[u8] = store.bytes(weight_ref).expect("weight bytes live");
32//! // ... hand bytes to a kernel
33//! }
34//! ```
35//!
36//! The `Arc` keeps all memory maps alive as long as any clone of it exists, so
37//! kernel dispatch can store `Arc<WeightStore>` alongside the `Graph` without
38//! lifetime coupling.
39
40use std::path::Path;
41use std::sync::Arc;
42
43use onnx_runtime_ir::Graph;
44use onnx_runtime_shape_inference::{InferenceRegistry, MergePolicy};
45
46use crate::graph_builder::BuiltGraph;
47
48pub mod encoder;
49pub mod epcontext;
50pub mod function_inline;
51pub(crate) mod graph_builder;
52pub mod proto;
53pub mod weights;
54pub mod writer;
55
56mod pathsafe;
57
58pub use encoder::{
59 DEFAULT_IR_VERSION, Model, ModelMetadata, encode_model, encode_model_proto, write_model,
60};
61pub use epcontext::{
62 EmbedMode, EpContextBlob, EpContextNode, ep_context_node_ids, ep_context_nodes,
63 is_ep_context_op, resolve_ep_context,
64};
65pub use error::LoaderError;
66pub use weights::{
67 ExpertQuantization, ExpertStorageOrder, ExpertTensorLayout, ExpertWeightRegion,
68 NonPageableReason, Pageability, WeightRegionCatalog, WeightStore,
69};
70pub use writer::{EpContextDumpConfig, EpContextPartition, dump_ep_context};
71
72mod error {
73 use std::path::PathBuf;
74
75 /// Errors produced while loading an ONNX model.
76 #[derive(Debug, thiserror::Error)]
77 pub enum LoaderError {
78 #[error("failed to read model file {path}: {source}")]
79 Io {
80 path: PathBuf,
81 #[source]
82 source: std::io::Error,
83 },
84
85 #[error("failed to parse ONNX protobuf: {0}")]
86 ProtobufParse(String),
87
88 #[error("failed to parse ONNX protobuf TextFormat: {0}")]
89 TextProtoParse(String),
90
91 #[error("unsupported opset: domain={domain}, version={version}")]
92 UnsupportedOpset { domain: String, version: u64 },
93
94 #[error(
95 "illegal ONNX model: operator {domain}::{op_type} at node {node} uses domain \
96 '{domain}' but no corresponding opset_import is declared. RULES #1: the model must \
97 declare an opset_import for domain '{domain}'; if you built this graph \
98 programmatically, add it before loading; if this is a file, the model is \
99 malformed/invalid per the ONNX spec"
100 )]
101 MissingOpsetImport {
102 op_type: String,
103 node: String,
104 domain: String,
105 },
106
107 #[error(
108 "unsupported ONNX model: operator {domain}::{op_type} at node {node} carries a \
109 subgraph attribute '{attr}' (control-flow / nested-graph op) that this runtime cannot \
110 execute. RULES #1: ep-cpu recursively executes the standard control-flow ops \
111 If/Loop/Scan (ai.onnx), but not {op_type}, so the model cannot be run as-is. \
112 Expected: express control flow with If/Loop/Scan, lower/unroll {op_type} into \
113 supported ops, or register a kernel able to execute its subgraph body"
114 )]
115 UnsupportedControlFlow {
116 op_type: String,
117 node: String,
118 domain: String,
119 attr: String,
120 },
121
122 #[error(
123 "illegal ONNX model: operator {domain}::{op_type} at node {node} consumes tensor \
124 '{tensor}', but no producer exists — it is not a graph input, not an initializer, and \
125 not produced by any upstream node. RULES #1: every consumed tensor must be sourced; \
126 the graph is structurally malformed. Expected: add '{tensor}' as a graph input or \
127 initializer, or add a node that produces it; if this is a file, the model is invalid \
128 per the ONNX spec"
129 )]
130 DanglingTensorRef {
131 op_type: String,
132 node: String,
133 domain: String,
134 tensor: String,
135 },
136
137 #[error(
138 "illegal ONNX model: tensor '{tensor}' is declared as an initializer but is also \
139 produced as an output of node {node} — an initializer must be a constant source with \
140 no producer. RULES #1: initializer names must be unique and must not collide with any \
141 node output name; a producer-backed initializer would let a kernel write through \
142 read-only weight storage. Expected: rename the node output or the initializer so they \
143 no longer share a name; if this is a file, the model is malformed per the ONNX spec"
144 )]
145 InitializerHasProducer { tensor: String, node: String },
146
147 #[error(
148 "illegal ONNX model: value '{tensor}' has multiple producers ({first} and {second}). \
149 RULES #1: ONNX graphs are in SSA form, so a value name may be assigned only once. \
150 Expected: give each graph input and node output a unique name"
151 )]
152 DuplicateValueProducer {
153 tensor: String,
154 first: String,
155 second: String,
156 },
157
158 #[error(
159 "illegal ONNX model: operator {domain}::{op_type} at node {node} has attribute \
160 '{attr}' referring to function attribute '{ref_attr_name}' outside a FunctionProto. \
161 RULES #1: ref_attr_name is only bound while inlining a FunctionProto; it has no \
162 executable value in a main graph or control-flow subgraph. Expected: replace it with \
163 a concrete attribute value or move the node into a FunctionProto"
164 )]
165 RefAttributeOutsideFunction {
166 op_type: String,
167 node: String,
168 domain: String,
169 attr: String,
170 ref_attr_name: String,
171 },
172
173 #[error(
174 "illegal ONNX model: ir_version {ir_version} is invalid. RULES #1: ir_version is \
175 required and ONNX IR versions start at 1. Expected: emit a model with ir_version >= 1"
176 )]
177 InvalidIrVersion { ir_version: i64 },
178
179 #[error(
180 "illegal ONNX model: ir_version {ir_version} requires at least one opset_import \
181 (ONNX IR>=3). Expected: add an opset_import for every operator domain used by the \
182 model"
183 )]
184 MissingModelOpsetImport { ir_version: i64 },
185
186 #[error(
187 "illegal ONNX model: initializer '{tensor}' in an outer graph is shadowed by a \
188 subgraph input of the same name. RULES #1: this runtime does not permit ambiguous \
189 initializer/subgraph binding. Expected: rename the subgraph formal input or the \
190 outer initializer"
191 )]
192 SubgraphInputShadowsInitializer { tensor: String },
193
194 #[error(
195 "illegal ONNX model: graph output '{tensor}' has no producer in its graph. RULES #1: \
196 every output must be a graph input, initializer, or node output in the same scope. \
197 Expected: produce '{tensor}' locally or declare it as an input/initializer"
198 )]
199 GraphOutputMissingProducer { tensor: String },
200
201 #[error("external data file not found: {path}")]
202 ExternalDataNotFound { path: PathBuf },
203
204 #[error("external data path rejected ({reason}): {path}")]
205 ExternalDataPath { path: String, reason: &'static str },
206
207 #[error("weight mmap failed: {0}")]
208 Mmap(String),
209
210 #[error("EPContext node error: {0}")]
211 EpContext(String),
212
213 #[error("EPContext external path rejected ({reason}): {path}")]
214 EpContextPath { path: String, reason: &'static str },
215
216 #[error("graph construction failed: {0}")]
217 GraphBuild(String),
218
219 #[error(
220 "illegal ONNX model: model-local function {function} is recursive (call chain: \
221 {chain}). RULES #1: ONNX function bodies may reference other model-local functions \
222 but MUST NOT be recursive — inlining cannot terminate. Expected: break the cycle so \
223 no function transitively calls itself"
224 )]
225 RecursiveFunction { function: String, chain: String },
226
227 #[error(
228 "illegal ONNX model: call to model-local function {function} at node {node} passes \
229 {actual} {kind}(s) but the function declares only {formal}. RULES #1: a function \
230 call may omit trailing optional {kind}s but must not supply more than are declared. \
231 Expected: remove the extra {kind}(s) or fix the function signature"
232 )]
233 FunctionArityMismatch {
234 function: String,
235 node: String,
236 kind: &'static str,
237 formal: usize,
238 actual: usize,
239 },
240
241 #[error(
242 "illegal ONNX model: call to model-local function {function} at node {node} is missing \
243 required attribute '{attribute}', and the function declares no default for it. \
244 RULES #1: an attribute listed in FunctionProto.attribute has no default and must be \
245 supplied at every call site. Expected: set '{attribute}' on the call node, or give \
246 the function a default via attribute_proto"
247 )]
248 MissingRequiredFunctionAttribute {
249 function: String,
250 node: String,
251 attribute: String,
252 },
253
254 #[error("unsupported ONNX data_type {raw} at {context}")]
255 UnsupportedDataType { raw: i32, context: String },
256
257 #[error("shape inference failed: {0}")]
258 ShapeInference(#[from] onnx_runtime_shape_inference::ShapeInferError),
259
260 #[error(transparent)]
261 Ir(#[from] onnx_runtime_ir::IrError),
262 }
263}
264
265/// Load a model from a filesystem path, producing a fully-built [`Graph`].
266///
267/// Runs the full pipeline: parse → build → load weights → shape inference.
268/// External initializer data is resolved relative to the model file's
269/// directory.
270///
271/// # Note on external weights
272///
273/// The returned `Graph` stores [`onnx_runtime_ir::WeightRef::External`]
274/// descriptors (path / offset / length) for weights held in external data
275/// files, but the memory maps that back those bytes are **dropped** when this
276/// function returns. Callers that need to read external weight bytes must
277/// either re-map the files themselves or use [`load_model_with_weights`] which
278/// keeps the maps alive via the returned [`Arc<WeightStore>`].
279pub fn load_model(path: impl AsRef<Path>) -> Result<Graph, LoaderError> {
280 Ok(load_model_with_weights(path)?.0)
281}
282
283/// Load a model from an in-memory protobuf buffer, producing a [`Graph`].
284///
285/// External initializer data (if any) is resolved relative to the current
286/// working directory.
287///
288/// # Note on external weights
289///
290/// Same caveat as [`load_model`]: external weight bytes are not accessible
291/// from the returned `Graph` alone. Use [`load_model_bytes_with_weights`] to
292/// keep them live.
293pub fn load_model_bytes(bytes: &[u8]) -> Result<Graph, LoaderError> {
294 Ok(load_model_bytes_with_weights(bytes, Path::new("."))?.0)
295}
296
297/// Load a model from a filesystem path, returning both the [`Graph`] and the
298/// live [`WeightStore`] that backs all initializer data.
299///
300/// The [`Arc<WeightStore>`] keeps every external-data memory map alive for as
301/// long as any clone of the `Arc` exists. At session time, given a
302/// [`onnx_runtime_ir::WeightRef`] from `graph.initializers`, call
303/// [`WeightStore::bytes`] to obtain the raw little-endian byte slice — this
304/// works for both [`WeightRef::Inline`] and [`WeightRef::External`] weights.
305///
306/// External initializer data is resolved relative to the model file's
307/// directory.
308pub fn load_model_with_weights(
309 path: impl AsRef<Path>,
310) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
311 let path = path.as_ref();
312 let bytes = read_model_binary(path)?;
313 let model_dir = path.parent().unwrap_or_else(|| Path::new("."));
314 build_from_bytes_with_weights(&bytes, model_dir)
315}
316
317/// Read a model file into the binary protobuf bytes of its `ModelProto`.
318///
319/// Detection is by filename suffix: a path ending in `.textproto` is parsed as
320/// ONNX protobuf **TextFormat** and converted to the binary wire encoding (see
321/// [`proto::textproto_to_binary`]); any other path is read verbatim as an
322/// already-binary `.onnx` model. This is the single seam that lets every
323/// path-based loader entry accept git-friendly textproto fixtures while keeping
324/// binary `.onnx` loading unchanged.
325///
326/// Note: textproto has no model-directory context for external weights, so
327/// textproto fixtures must inline all initializer data.
328pub fn read_model_binary(path: impl AsRef<Path>) -> Result<Vec<u8>, LoaderError> {
329 let path = path.as_ref();
330 let raw = std::fs::read(path).map_err(|source| LoaderError::Io {
331 path: path.to_path_buf(),
332 source,
333 })?;
334 if is_textproto_path(path) {
335 let text = String::from_utf8(raw)
336 .map_err(|e| LoaderError::TextProtoParse(format!("model is not valid UTF-8: {e}")))?;
337 proto::textproto_to_binary(&text)
338 } else {
339 Ok(raw)
340 }
341}
342
343/// Whether `path` names an ONNX protobuf TextFormat fixture (`*.textproto`).
344pub fn is_textproto_path(path: impl AsRef<Path>) -> bool {
345 path.as_ref()
346 .extension()
347 .is_some_and(|ext| ext.eq_ignore_ascii_case("textproto"))
348}
349
350/// Load a model from an in-memory protobuf buffer, returning both the
351/// [`Graph`] and the live [`WeightStore`] that backs all initializer data.
352///
353/// External initializer data (if any) is resolved relative to `base_dir`.
354/// The [`Arc<WeightStore>`] keeps every memory map alive for as long as any
355/// clone of the `Arc` exists.
356pub fn load_model_bytes_with_weights(
357 bytes: &[u8],
358 base_dir: impl AsRef<Path>,
359) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
360 build_from_bytes_with_weights(bytes, base_dir.as_ref())
361}
362
363fn build_from_bytes_with_weights(
364 bytes: &[u8],
365 model_dir: &Path,
366) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
367 let model = proto::decode_model(bytes)?;
368 validate_model_proto(&model)?;
369 let BuiltGraph {
370 mut graph,
371 name_map,
372 } = graph_builder::build_graph(&model)?;
373
374 // Fail-fast legality check that needs no weights: reject illegal opset
375 // imports before we touch the (potentially large) weight files.
376 validate_opset_imports(&graph)?;
377
378 let store = weights::load_weights(&model, model_dir, &name_map)?;
379 // Copy descriptors into the graph; the store's mmaps stay alive via Arc.
380 for (&vid, weight) in &store.weights {
381 graph.set_initializer(vid, weight.clone());
382 }
383
384 // Structural IR validation runs here — *after* initializers are attached —
385 // rather than inside `graph_builder::build_graph`. A top-level initializer
386 // is only recorded in `graph.initializers` by the weight-loading path above,
387 // so validating earlier would mis-flag a legal initializer that is also a
388 // graph output (constant pass-through) or a pre-IR-4 graph input that is
389 // also an initializer as a producer-less `MissingProducer`. Validating the
390 // fully-assembled graph recognizes those values as initializer sources.
391 graph
392 .validate()
393 .map_err(|errs| LoaderError::GraphBuild(format!("{errs:?}")))?;
394
395 // Full fail-fast validation once initializers are attached (so
396 // initializer-backed values are recognized as sourced). Rejects
397 // statically-knowable unsupported/illegal constructs before shape
398 // inference or execution — see [`validate_model`].
399 validate_model(&graph)?;
400
401 // Static/symbolic shape inference (the loader owns this seam). Run the
402 // extensible per-op registry over the fully-built graph — inputs,
403 // initializers, and node outputs — to populate every value's shape and
404 // dtype. `Permissive`: prefer the more specific dim on a benign
405 // disagreement and keep going, and reconcile graph outputs with their
406 // declared shapes rather than clobbering them. Values that stay symbolic
407 // (genuinely data-dependent extents) are left for the session's JIT
408 // fallback to resolve at run time.
409 let registry = InferenceRegistry::default_registry();
410 let opset_imports = graph.opset_imports.clone();
411 registry.infer_graph(&mut graph, &opset_imports, MergePolicy::Permissive)?;
412
413 Ok((graph, Arc::new(store)))
414}
415
416/// Fail-fast, load-time validation of everything statically knowable to be
417/// illegal or unsupported (RULES #1: fail at *load*, never via a silent
418/// sentinel at run time).
419///
420/// This is the single cohesive entry point wired into **both** load paths — the
421/// disk/bytes loader ([`build_from_bytes_with_weights`]) and the session's
422/// programmatic entry ([`onnx_runtime_session`]'s `from_parts`/`from_graph`) —
423/// so the checks cannot drift between the two. It runs, in order:
424///
425/// Protobuf-only invariants (`ir_version`, raw SSA names, `ref_attr_name`,
426/// subgraph shadows, and output names) run earlier in
427/// [`validate_model_proto`], before graph construction coalesces names or drops
428/// protobuf-only fields. This IR-level phase then runs:
429///
430/// 1. [`validate_opset_imports`] — every node's domain must declare an opset.
431/// 2. [`validate_no_control_flow`] — allow the implemented subgraph-bearing ops
432/// (`If`/`Loop`/`Scan`) and reject any other op carrying a `GraphProto`
433/// attribute the executor cannot run.
434/// 3. [`validate_no_dangling_refs`] — every consumed tensor must be sourced
435/// (graph input, initializer, or an upstream node output).
436/// 4. [`validate_no_initializer_producer`] — an initializer must be a constant
437/// source; reject any initializer value that is also a node output (shares a
438/// `ValueId` with a producer), which the IR structural check does not cover.
439///
440/// Each rejection names the offending node/op/tensor and explains what is
441/// expected. No sentinel defaults, no silent skips.
442///
443/// Structural invariants that the IR builder already enforces via
444/// [`onnx_runtime_ir::Graph::validate`] at build time — duplicate output
445/// names, dangling value ids, producer/consumer link consistency, and data
446/// dependency cycles — are intentionally *not* re-checked here to avoid drift;
447/// this function adds the checks that path does not cover.
448pub fn validate_model(graph: &Graph) -> Result<(), LoaderError> {
449 validate_opset_imports(graph)?;
450 validate_no_control_flow(graph)?;
451 validate_no_dangling_refs(graph)?;
452 validate_no_initializer_producer(graph)?;
453 Ok(())
454}
455
456/// Validate ONNX model metadata and protobuf-level graph invariants that are
457/// intentionally not preserved by the runtime IR (notably `ir_version` and
458/// `AttributeProto::ref_attr_name`).
459///
460/// This runs before graph construction, ensuring invalid names cannot be
461/// coalesced into a single IR value and attribute references cannot be dropped.
462pub fn validate_model_proto(model: &proto::onnx::ModelProto) -> Result<(), LoaderError> {
463 use std::collections::HashSet;
464
465 use proto::onnx::GraphProto;
466
467 // Lower sanity bound only: `ir_version` is a required ONNX field and IR
468 // versions start at 1, so reject an absent (0) or negative version.
469 if model.ir_version < 1 {
470 return Err(LoaderError::InvalidIrVersion {
471 ir_version: model.ir_version,
472 });
473 }
474 // No upper bound. Per the maintainer directive, new ONNX IR versions are
475 // effectively always backward-compatible (they add fields/metadata rather
476 // than breaking existing model semantics), so gating on a version ceiling
477 // only produces false-positive rejections of otherwise-valid newer models.
478 // If a genuinely unsupported construct ever ships, gate on that specific
479 // FEATURE at load time — never on the IR version number.
480 if model.ir_version >= 3 && model.opset_import.is_empty() {
481 return Err(LoaderError::MissingModelOpsetImport {
482 ir_version: model.ir_version,
483 });
484 }
485
486 fn node_description(node: &proto::onnx::NodeProto, index: usize) -> String {
487 if node.name.is_empty() {
488 format!("<unnamed node #{index}>")
489 } else {
490 format!("{:?}", node.name)
491 }
492 }
493
494 fn check_graph(graph: &GraphProto) -> Result<(), LoaderError> {
495 let mut producers = std::collections::HashMap::new();
496 for input in &graph.input {
497 if !input.name.is_empty() {
498 producers.insert(input.name.clone(), "graph input".to_string());
499 }
500 }
501 for (index, node) in graph.node.iter().enumerate() {
502 let node_description = node_description(node, index);
503 for output in &node.output {
504 if output.is_empty() {
505 continue;
506 }
507 let producer = format!("output of {node_description}");
508 if let Some(first) = producers.insert(output.clone(), producer.clone()) {
509 return Err(LoaderError::DuplicateValueProducer {
510 tensor: output.clone(),
511 first,
512 second: producer,
513 });
514 }
515 }
516 for attribute in &node.attribute {
517 if !attribute.ref_attr_name.is_empty() {
518 return Err(LoaderError::RefAttributeOutsideFunction {
519 op_type: node.op_type.clone(),
520 node: node_description.clone(),
521 domain: display_domain(&node.domain),
522 attr: attribute.name.clone(),
523 ref_attr_name: attribute.ref_attr_name.clone(),
524 });
525 }
526 }
527 }
528
529 let sources: HashSet<&str> = graph
530 .input
531 .iter()
532 .map(|input| input.name.as_str())
533 .chain(
534 graph
535 .initializer
536 .iter()
537 .map(|initializer| initializer.name.as_str()),
538 )
539 .chain(
540 graph
541 .node
542 .iter()
543 .flat_map(|node| node.output.iter().map(String::as_str)),
544 )
545 .collect();
546 for output in &graph.output {
547 if !output.name.is_empty() && !sources.contains(output.name.as_str()) {
548 return Err(LoaderError::GraphOutputMissingProducer {
549 tensor: output.name.clone(),
550 });
551 }
552 }
553
554 let outer_initializers: HashSet<&str> = graph
555 .initializer
556 .iter()
557 .map(|initializer| initializer.name.as_str())
558 .collect();
559 for node in &graph.node {
560 for attribute in &node.attribute {
561 let subgraphs = attribute.g.iter().chain(attribute.graphs.iter());
562 for subgraph in subgraphs {
563 if let Some(input) = subgraph
564 .input
565 .iter()
566 .find(|input| outer_initializers.contains(input.name.as_str()))
567 {
568 return Err(LoaderError::SubgraphInputShadowsInitializer {
569 tensor: input.name.clone(),
570 });
571 }
572 check_graph(subgraph)?;
573 }
574 }
575 }
576 Ok(())
577 }
578
579 if let Some(graph) = &model.graph {
580 check_graph(graph)?;
581 }
582 Ok(())
583}
584
585/// Human-readable node label for diagnostics: the quoted ONNX node name, or a
586/// synthetic `<unnamed node #id>` when the model left it blank.
587fn node_label(node: &onnx_runtime_ir::Node) -> String {
588 if node.name.is_empty() {
589 format!("<unnamed node #{}>", node.id.0)
590 } else {
591 format!("{:?}", node.name)
592 }
593}
594
595/// Canonical display domain for a node (`""` renders as `ai.onnx`).
596fn display_domain(domain: &str) -> String {
597 if domain.is_empty() {
598 "ai.onnx".to_string()
599 } else {
600 domain.to_string()
601 }
602}
603
604/// Reject subgraph-bearing (control-flow) ops the runtime cannot execute.
605///
606/// The CPU executor implements the three standard subgraph-bearing control-flow
607/// ops — `If`, `Loop`, and `Scan` (default `ai.onnx` domain) — by recursively
608/// executing their nested [`onnx_runtime_ir::Attribute::Graph`]/`Graphs` bodies.
609/// Any *other* op that smuggles a subgraph attribute (a control-flow construct
610/// this runtime does not implement, or a custom op hiding a nested graph) is
611/// still rejected fast: the executor has no path to run it, so a silent skip or
612/// a late panic would be worse than a clear load-time error.
613///
614/// The check descends into every nested subgraph as well, so an unimplemented
615/// control-flow op buried inside an `If`/`Loop`/`Scan` body is caught at load
616/// rather than surfacing only when that branch/iteration executes.
617pub fn validate_no_control_flow(graph: &Graph) -> Result<(), LoaderError> {
618 use onnx_runtime_ir::Attribute;
619
620 /// The standard subgraph-bearing ops the CPU executor can run recursively.
621 ///
622 /// Operates on loaded IR, where the default domain is canonically `""`.
623 fn is_implemented_control_flow(node: &onnx_runtime_ir::Node) -> bool {
624 node.is_default_domain() && matches!(node.op_type.as_str(), "If" | "Loop" | "Scan")
625 }
626
627 fn check_graph(graph: &Graph) -> Result<(), LoaderError> {
628 for (_, node) in graph.nodes.iter() {
629 // Report attributes in a deterministic order for stable diagnostics.
630 let mut subgraph_attrs: Vec<&String> = node
631 .attributes
632 .iter()
633 .filter(|(_, v)| matches!(v, Attribute::Graph(_) | Attribute::Graphs(_)))
634 .map(|(k, _)| k)
635 .collect();
636 subgraph_attrs.sort();
637 if let Some(attr) = subgraph_attrs.first() {
638 // A subgraph body is fine when its owner is an implemented
639 // control-flow op; otherwise fail fast.
640 if !is_implemented_control_flow(node) {
641 return Err(LoaderError::UnsupportedControlFlow {
642 op_type: node.op_type.clone(),
643 node: node_label(node),
644 domain: display_domain(&node.domain),
645 attr: (*attr).clone(),
646 });
647 }
648 }
649 }
650 // Descend into nested bodies so an unimplemented construct inside an
651 // implemented op's subgraph is still caught at load time.
652 for subgraph in graph.subgraphs.values() {
653 check_graph(subgraph)?;
654 }
655 Ok(())
656 }
657
658 check_graph(graph)
659}
660
661/// Reject graphs with a node input that has no source.
662///
663/// The graph builder materializes an unresolved input name as a fresh named
664/// value with no producer (see `graph_builder::get_or_create`); such a value is
665/// legal only if it is a graph input or an initializer. Any other producer-less
666/// consumed value is a dangling reference — a structurally malformed graph that
667/// [`onnx_runtime_ir::Graph::validate`] does not catch (it only requires graph
668/// *outputs* to be sourced, not node inputs). We reject it at load, naming the
669/// offending node and tensor.
670///
671/// Must run after initializers are attached to `graph.initializers` so
672/// initializer-backed inputs are recognized as sourced.
673pub fn validate_no_dangling_refs(graph: &Graph) -> Result<(), LoaderError> {
674 use std::collections::HashSet;
675
676 let graph_inputs: HashSet<_> = graph.inputs.iter().copied().collect();
677
678 for (_, node) in graph.nodes.iter() {
679 for vid in node.input_values() {
680 let Some(value) = graph.values.get(vid) else {
681 // A dangling value id is caught by IR-level structural
682 // validation; nothing to report here.
683 continue;
684 };
685 let is_sourced = value.producer.is_some()
686 || graph_inputs.contains(&vid)
687 || graph.initializers.contains_key(&vid);
688 if !is_sourced {
689 let tensor = value
690 .name
691 .clone()
692 .unwrap_or_else(|| format!("<anonymous value #{}>", vid.0));
693 return Err(LoaderError::DanglingTensorRef {
694 op_type: node.op_type.clone(),
695 node: node_label(node),
696 domain: display_domain(&node.domain),
697 tensor,
698 });
699 }
700 }
701 }
702 Ok(())
703}
704
705/// Reject graphs where an initializer value is also produced by a node.
706///
707/// The graph builder maps tensor *names* → [`onnx_runtime_ir::ValueId`] for both
708/// node inputs and node outputs (see `graph_builder::get_or_create`). If a node
709/// output name collides with an initializer name, the node output reuses the
710/// initializer's `ValueId` and `connect_edges` then sets `producer = Some(node)`
711/// on that shared value. [`onnx_runtime_ir::Graph::validate`] rejects a *graph
712/// input* with a producer but has no equivalent check for an *initializer*, so
713/// such a malformed graph passes structural validation.
714///
715/// This matters for memory-safety: the session's weight-streaming path borrows
716/// an initializer's read-only mmap bytes zero-copy. A producer-backed
717/// initializer would let a kernel write through that read-only storage
718/// (SIGSEGV on external data, aliasing UB inline). The executor already refuses
719/// to borrow producer-backed initializers, but rejecting the graph here fails
720/// fast and cleanly regardless of the execution path. We name the tensor and
721/// the offending producing node.
722pub fn validate_no_initializer_producer(graph: &Graph) -> Result<(), LoaderError> {
723 for &vid in graph.initializers.keys() {
724 let Some(value) = graph.values.get(vid) else {
725 continue;
726 };
727 if let Some(producer) = value.producer {
728 let tensor = value
729 .name
730 .clone()
731 .unwrap_or_else(|| format!("<anonymous value #{}>", vid.0));
732 let node = if graph.nodes.contains(producer) {
733 node_label(graph.node(producer))
734 } else {
735 format!("<node #{}>", producer.0)
736 };
737 return Err(LoaderError::InitializerHasProducer { tensor, node });
738 }
739 }
740 Ok(())
741}
742///
743/// ONNX treats `""` and `"ai.onnx"` as equivalent spellings of the default
744/// domain. Model-level imports also govern nodes nested in subgraphs.
745pub fn validate_opset_imports(graph: &Graph) -> Result<(), LoaderError> {
746 // Loaded IR is canonical: the default domain is `""` for both node domains
747 // and opset-import keys, so a direct lookup suffices (no dual-spelling
748 // fallback needed — see `onnx_runtime_ir::normalize_domain`).
749 fn has_import(imports: &std::collections::HashMap<String, u64>, domain: &str) -> bool {
750 imports.contains_key(domain)
751 }
752
753 fn validate_graph(
754 graph: &Graph,
755 imports: &std::collections::HashMap<String, u64>,
756 ) -> Result<(), LoaderError> {
757 for (_, node) in graph.nodes.iter() {
758 if !has_import(imports, &node.domain) {
759 let domain = if node.domain.is_empty() {
760 "ai.onnx".to_string()
761 } else {
762 node.domain.clone()
763 };
764 let node_name = if node.name.is_empty() {
765 format!("<unnamed node #{}>", node.id.0)
766 } else {
767 format!("{:?}", node.name)
768 };
769 return Err(LoaderError::MissingOpsetImport {
770 op_type: node.op_type.clone(),
771 node: node_name,
772 domain,
773 });
774 }
775 }
776 for subgraph in graph.subgraphs.values() {
777 validate_graph(subgraph, imports)?;
778 }
779 Ok(())
780 }
781
782 validate_graph(graph, &graph.opset_imports)
783}