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/architecture/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, WeightRef};
44use onnx_runtime_shape_inference::{InferenceRegistry, MergePolicy};
45use onnx_runtime_tracer::{Args, SpanGuard};
46
47use crate::graph_builder::BuiltGraph;
48
49pub mod encoder;
50pub mod epcontext;
51pub mod function_inline;
52pub(crate) mod graph_builder;
53pub mod proto;
54pub mod weights;
55pub mod writer;
56
57mod pathsafe;
58
59pub use encoder::{
60 DEFAULT_IR_VERSION, DEFAULT_OPSET_VERSION, Model, ModelMetadata, encode_model,
61 encode_model_proto, write_model,
62};
63pub use epcontext::{
64 EmbedMode, EpContextBlob, EpContextNode, ep_context_node_ids, ep_context_nodes,
65 is_ep_context_op, resolve_ep_context,
66};
67pub use error::LoaderError;
68pub use weights::{
69 ExpertQuantization, ExpertStorageOrder, ExpertTensorLayout, ExpertWeightRegion,
70 NonPageableReason, Pageability, WeightRegionCatalog, WeightStore, qmoe_expert_tensor_layout,
71};
72pub use writer::{EpContextDumpConfig, EpContextPartition, dump_ep_context};
73
74fn trace_span(name: &'static str, cat: &'static str) -> Option<SpanGuard> {
75 onnx_runtime_tracer::global_context()
76 .filter(|trace| trace.is_enabled())
77 .map(|trace| trace.span(name, cat))
78}
79
80mod error;
81
82/// Load a model from a filesystem path, producing a fully-built [`Graph`].
83///
84/// Runs the full pipeline: parse → build → load weights → shape inference.
85/// External initializer data is resolved relative to the model file's
86/// directory.
87///
88/// # Note on external weights
89///
90/// The returned `Graph` stores [`onnx_runtime_ir::WeightRef::External`]
91/// descriptors (path / offset / length) for weights held in external data
92/// files, but the memory maps that back those bytes are **dropped** when this
93/// function returns. Callers that need to read external weight bytes must
94/// either re-map the files themselves or use [`load_model_with_weights`] which
95/// keeps the maps alive via the returned [`Arc<WeightStore>`].
96pub fn load_model(path: impl AsRef<Path>) -> Result<Graph, LoaderError> {
97 Ok(load_model_with_weights(path)?.0)
98}
99
100/// Load a model from an in-memory protobuf buffer, producing a [`Graph`].
101///
102/// External initializer data (if any) is resolved relative to the current
103/// working directory.
104///
105/// # Note on external weights
106///
107/// Same caveat as [`load_model`]: external weight bytes are not accessible
108/// from the returned `Graph` alone. Use [`load_model_bytes_with_weights`] to
109/// keep them live.
110pub fn load_model_bytes(bytes: &[u8]) -> Result<Graph, LoaderError> {
111 Ok(load_model_bytes_with_weights(bytes, Path::new("."))?.0)
112}
113
114/// Load a model from a filesystem path, returning both the [`Graph`] and the
115/// live [`WeightStore`] that backs all initializer data.
116///
117/// The [`Arc<WeightStore>`] keeps every external-data memory map alive for as
118/// long as any clone of the `Arc` exists. At session time, given a
119/// [`onnx_runtime_ir::WeightRef`] from `graph.initializers`, call
120/// [`WeightStore::bytes`] to obtain the raw little-endian byte slice — this
121/// works for both [`WeightRef::Inline`] and [`WeightRef::External`] weights.
122///
123/// External initializer data is resolved relative to the model file's
124/// directory.
125pub fn load_model_with_weights(
126 path: impl AsRef<Path>,
127) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
128 let path = path.as_ref();
129 let bytes = read_model_binary(path)?;
130 let model_dir = path.parent().unwrap_or_else(|| Path::new("."));
131 build_from_bytes_with_weights(&bytes, model_dir, None)
132}
133
134/// Read a model file into the binary protobuf bytes of its `ModelProto`.
135///
136/// Detection is by filename suffix: a path ending in `.textproto` is parsed as
137/// ONNX protobuf **TextFormat** and converted to the binary wire encoding (see
138/// [`proto::textproto_to_binary`]); any other path is read verbatim as an
139/// already-binary `.onnx` model. This is the single seam that lets every
140/// path-based loader entry accept git-friendly textproto fixtures while keeping
141/// binary `.onnx` loading unchanged.
142///
143/// Path-based callers still pass this file's parent directory to the weight
144/// loader, so TextFormat graphs may reference external initializer data just
145/// like binary ONNX graphs.
146pub fn read_model_binary(path: impl AsRef<Path>) -> Result<Vec<u8>, LoaderError> {
147 let path = path.as_ref();
148 let mut span = trace_span("load.read_model_binary", "load");
149 let raw = std::fs::read(path).map_err(|source| LoaderError::Io {
150 path: path.to_path_buf(),
151 source,
152 })?;
153 let raw_len = raw.len();
154 if is_textproto_path(path) {
155 let text = String::from_utf8(raw)
156 .map_err(|e| LoaderError::TextProtoParse(format!("model is not valid UTF-8: {e}")))?;
157 let binary = proto::textproto_to_binary(&text)?;
158 if let Some(span) = span.as_mut() {
159 span.set_args(
160 Args::new()
161 .bytes(binary.len() as u64)
162 .with("raw_bytes", raw_len as u64)
163 .with("textproto", true)
164 .with("path", path.display().to_string()),
165 );
166 }
167 Ok(binary)
168 } else {
169 if let Some(span) = span.as_mut() {
170 span.set_args(
171 Args::new()
172 .bytes(raw_len as u64)
173 .with("textproto", false)
174 .with("path", path.display().to_string()),
175 );
176 }
177 Ok(raw)
178 }
179}
180
181/// Whether `path` names an ONNX protobuf TextFormat fixture (`*.textproto`).
182pub fn is_textproto_path(path: impl AsRef<Path>) -> bool {
183 path.as_ref()
184 .extension()
185 .is_some_and(|ext| ext.eq_ignore_ascii_case("textproto"))
186}
187
188/// Load a model from an in-memory protobuf buffer, returning both the
189/// [`Graph`] and the live [`WeightStore`] that backs all initializer data.
190///
191/// External initializer data (if any) is resolved relative to `base_dir`.
192/// The [`Arc<WeightStore>`] keeps every memory map alive for as long as any
193/// clone of the `Arc` exists.
194pub fn load_model_bytes_with_weights(
195 bytes: &[u8],
196 base_dir: impl AsRef<Path>,
197) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
198 build_from_bytes_with_weights(bytes, base_dir.as_ref(), None)
199}
200
201/// Like [`load_model_bytes_with_weights`], but a matched function-call node is
202/// **kept as an op** (not inlined) whenever `keep_as_op` returns `true` for it —
203/// the general "keep-as-op iff a kernel claims it, else inline" policy used to
204/// let a registered fused kernel dispatch on a call that would otherwise expand
205/// into its function body. Passing `None` (or using the non-filtered entry) is
206/// byte-identical to inlining every function.
207pub fn load_model_bytes_with_weights_filtered(
208 bytes: &[u8],
209 base_dir: impl AsRef<Path>,
210 keep_as_op: &function_inline::KeepAsOp<'_>,
211) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
212 build_from_bytes_with_weights(bytes, base_dir.as_ref(), Some(keep_as_op))
213}
214
215fn build_from_bytes_with_weights(
216 bytes: &[u8],
217 model_dir: &Path,
218 keep_as_op: Option<&function_inline::KeepAsOp<'_>>,
219) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
220 let model = parse_model(bytes)?;
221 validate_proto(&model)?;
222 let BuiltGraph {
223 mut graph,
224 name_map,
225 } = build_graph(&model, keep_as_op)?;
226
227 // Fail-fast legality check that needs no weights: reject illegal opset
228 // imports before we touch the (potentially large) weight files.
229 validate_opset_imports(&graph)?;
230
231 let store = load_weights(&model, model_dir, &name_map)?;
232 attach_weights(&mut graph, &store);
233
234 validate_ir(&graph)?;
235 validate_loaded_model(&graph)?;
236 infer_shapes(&mut graph)?;
237
238 Ok((graph, Arc::new(store)))
239}
240
241fn parse_model(bytes: &[u8]) -> Result<proto::onnx::ModelProto, LoaderError> {
242 let mut span = trace_span("load.parse_model", "load");
243 let model = proto::decode_model(bytes)?;
244 if let Some(span) = span.as_mut() {
245 span.set_args(Args::new().bytes(bytes.len() as u64));
246 }
247 Ok(model)
248}
249
250fn validate_proto(model: &proto::onnx::ModelProto) -> Result<(), LoaderError> {
251 let mut span = trace_span("load.validate_model_proto", "load");
252 validate_model_proto(model)?;
253 if let Some(span) = span.as_mut() {
254 span.set_args(
255 Args::new()
256 .with("graph_count", if model.graph.is_some() { 1_u64 } else { 0 })
257 .with("metadata_props", model.metadata_props.len() as u64),
258 );
259 }
260 Ok(())
261}
262
263fn build_graph(
264 model: &proto::onnx::ModelProto,
265 keep_as_op: Option<&function_inline::KeepAsOp<'_>>,
266) -> Result<BuiltGraph, LoaderError> {
267 let mut span = trace_span("load.build_graph", "load");
268 let built = graph_builder::build_graph(model, keep_as_op)?;
269 if let Some(span) = span.as_mut() {
270 span.set_args(
271 Args::new()
272 .with("nodes", built.graph.num_nodes() as u64)
273 .with("values", built.graph.values.len() as u64)
274 .with("inputs", built.graph.inputs.len() as u64)
275 .with("outputs", built.graph.outputs.len() as u64)
276 .with("initializers", built.graph.initializers.len() as u64),
277 );
278 }
279 Ok(built)
280}
281
282fn load_weights(
283 model: &proto::onnx::ModelProto,
284 model_dir: &Path,
285 name_map: &std::collections::HashMap<String, onnx_runtime_ir::ValueId>,
286) -> Result<WeightStore, LoaderError> {
287 let mut span = trace_span("load.external_weights", "load");
288 let store = weights::load_weights(model, model_dir, name_map)?;
289 if let Some(span) = span.as_mut() {
290 let mut inline_count = 0_u64;
291 let mut inline_bytes = 0_u64;
292 let mut external_count = 0_u64;
293 let mut external_bytes = 0_u64;
294 for weight in store.weights.values() {
295 match weight {
296 WeightRef::Inline(tensor) => {
297 inline_count += 1;
298 inline_bytes += tensor.data.len() as u64;
299 }
300 WeightRef::External { length, .. } => {
301 external_count += 1;
302 external_bytes += *length as u64;
303 }
304 }
305 }
306 span.set_args(
307 Args::new()
308 .with("initializers", store.weights.len() as u64)
309 .with("inline_initializers", inline_count)
310 .with("inline_bytes", inline_bytes)
311 .with("external_initializers", external_count)
312 .with("external_bytes", external_bytes)
313 .with("model_dir", model_dir.display().to_string()),
314 );
315 }
316 Ok(store)
317}
318
319fn attach_weights(graph: &mut Graph, store: &WeightStore) {
320 // Copy descriptors into the graph; the store's mmaps stay alive via Arc.
321 for (&value_id, weight) in &store.weights {
322 graph.set_initializer(value_id, weight.clone());
323 }
324}
325
326fn validate_ir(graph: &Graph) -> Result<(), LoaderError> {
327 // Structural IR validation runs here — *after* initializers are attached —
328 // rather than inside `graph_builder::build_graph`. A top-level initializer
329 // is only recorded in `graph.initializers` by the weight-loading path above,
330 // so validating earlier would mis-flag a legal initializer that is also a
331 // graph output (constant pass-through) or a pre-IR-4 graph input that is
332 // also an initializer as a producer-less `MissingProducer`. Validating the
333 // fully-assembled graph recognizes those values as initializer sources.
334 let mut span = trace_span("load.validate_graph", "load");
335 graph
336 .validate()
337 .map_err(|errors| LoaderError::GraphBuild(format!("{errors:?}")))?;
338 if let Some(span) = span.as_mut() {
339 span.set_args(
340 Args::new()
341 .with("nodes", graph.num_nodes() as u64)
342 .with("values", graph.values.len() as u64)
343 .with("initializers", graph.initializers.len() as u64),
344 );
345 }
346 Ok(())
347}
348
349fn validate_loaded_model(graph: &Graph) -> Result<(), LoaderError> {
350 // Full fail-fast validation once initializers are attached (so
351 // initializer-backed values are recognized as sourced). Rejects
352 // statically-knowable unsupported/illegal constructs before shape
353 // inference or execution — see [`validate_model`].
354 let mut span = trace_span("load.validate_model", "load");
355 validate_model(graph)?;
356 if let Some(span) = span.as_mut() {
357 span.set_args(Args::new().with("nodes", graph.num_nodes() as u64));
358 }
359 Ok(())
360}
361
362fn infer_shapes(graph: &mut Graph) -> Result<(), LoaderError> {
363 // Static/symbolic shape inference (the loader owns this seam). Run the
364 // extensible per-op registry over the fully-built graph — inputs,
365 // initializers, and node outputs — to populate every value's shape and
366 // dtype. `Permissive`: prefer the more specific dim on a benign
367 // disagreement and keep going, and reconcile graph outputs with their
368 // declared shapes rather than clobbering them. Values that stay symbolic
369 // (genuinely data-dependent extents) are left for the session's JIT
370 // fallback to resolve at run time.
371 let registry = InferenceRegistry::default_registry();
372 let opset_imports = graph.opset_imports.clone();
373 let mut span = trace_span("load.shape_inference", "load");
374 registry.infer_graph(graph, &opset_imports, MergePolicy::Permissive)?;
375 if let Some(span) = span.as_mut() {
376 span.set_args(
377 Args::new()
378 .with("nodes", graph.num_nodes() as u64)
379 .with("values", graph.values.len() as u64)
380 .with("opset_domains", graph.opset_imports.len() as u64),
381 );
382 }
383 Ok(())
384}
385
386/// Fail-fast, load-time validation of everything statically knowable to be
387/// illegal or unsupported (RULES #1: fail at *load*, never via a silent
388/// sentinel at run time).
389///
390/// This is the single cohesive entry point wired into **both** load paths — the
391/// disk/bytes loader ([`build_from_bytes_with_weights`]) and the session's
392/// programmatic entry ([`onnx_runtime_session`]'s `from_parts`/`from_graph`) —
393/// so the checks cannot drift between the two. It runs, in order:
394///
395/// Protobuf-only invariants (`ir_version`, raw SSA names, `ref_attr_name`,
396/// subgraph shadows, and output names) run earlier in
397/// [`validate_model_proto`], before graph construction coalesces names or drops
398/// protobuf-only fields. This IR-level phase then runs:
399///
400/// 1. [`validate_opset_imports`] — every node's domain must declare an opset.
401/// 2. [`validate_einsum_nodes`] — resolve `Einsum-12`/`Einsum-28` from the
402/// imported opset and validate every statically known equation/type/shape.
403/// 3. [`validate_no_control_flow`] — allow the implemented subgraph-bearing ops
404/// (`If`/`Loop`/`Scan`) and reject any other op carrying a `GraphProto`
405/// attribute the executor cannot run.
406/// 4. [`validate_no_dangling_refs`] — every consumed tensor must be sourced
407/// (graph input, initializer, or an upstream node output).
408/// 5. [`validate_no_initializer_producer`] — an initializer must be a constant
409/// source; reject any initializer value that is also a node output (shares a
410/// `ValueId` with a producer), which the IR structural check does not cover.
411///
412/// Each rejection names the offending node/op/tensor and explains what is
413/// expected. No sentinel defaults, no silent skips.
414///
415/// Structural invariants that the IR builder already enforces via
416/// [`onnx_runtime_ir::Graph::validate`] at build time — duplicate output
417/// names, dangling value ids, producer/consumer link consistency, and data
418/// dependency cycles — are intentionally *not* re-checked here to avoid drift;
419/// this function adds the checks that path does not cover.
420pub fn validate_model(graph: &Graph) -> Result<(), LoaderError> {
421 validate_opset_imports(graph)?;
422 validate_einsum_nodes(graph)?;
423 validate_no_control_flow(graph)?;
424 validate_no_dangling_refs(graph)?;
425 validate_no_initializer_producer(graph)?;
426 Ok(())
427}
428
429/// Resolve and validate every default-domain `Einsum` against the model's
430/// effective imported opset before shape inference or placement.
431pub fn validate_einsum_nodes(graph: &Graph) -> Result<(), LoaderError> {
432 use onnx_runtime_ir::{Attribute, EinsumInput, EinsumPlan, EinsumSchema, EinsumShapePlan};
433
434 fn check_graph(
435 graph: &Graph,
436 imports: &std::collections::HashMap<String, u64>,
437 ) -> Result<(), LoaderError> {
438 for (_, node) in graph.nodes.iter() {
439 if !node.is_default_domain() || node.op_type != "Einsum" {
440 continue;
441 }
442 let imported_opset = node
443 .local_opset()
444 .or_else(|| imports.get("").copied())
445 .unwrap_or(1);
446 let equation = match node.attr("equation") {
447 Some(Attribute::String(bytes)) => {
448 std::str::from_utf8(bytes).map_err(|error| LoaderError::InvalidEinsum {
449 node: node_label(node),
450 detail: format!(
451 "attribute `equation` is not valid UTF-8 at byte offset {}",
452 error.valid_up_to()
453 ),
454 })?
455 }
456 _ => {
457 return Err(LoaderError::InvalidEinsum {
458 node: node_label(node),
459 detail: "missing required STRING attribute `equation`".to_string(),
460 });
461 }
462 };
463 if node.outputs.len() != 1 {
464 return Err(LoaderError::InvalidEinsum {
465 node: node_label(node),
466 detail: format!(
467 "equation `{equation}` requires exactly 1 output, but the node declares {} outputs",
468 node.outputs.len()
469 ),
470 });
471 }
472 let output_id = node.outputs[0];
473 let output = graph.values.get(output_id).ok_or_else(|| LoaderError::InvalidEinsum {
474 node: node_label(node),
475 detail: format!(
476 "equation `{equation}` declares 1 output, but output #0 references missing value {output_id:?}"
477 ),
478 })?;
479 if output.name.as_deref().is_none_or(str::is_empty) {
480 return Err(LoaderError::InvalidEinsum {
481 node: node_label(node),
482 detail: format!(
483 "equation `{equation}` declares 1 output, but required output #0 has an empty or omitted name"
484 ),
485 });
486 }
487 let mut metadata = Vec::with_capacity(node.inputs.len());
488 for (input, slot) in node.inputs.iter().enumerate() {
489 let value_id = slot.ok_or_else(|| LoaderError::InvalidEinsum {
490 node: node_label(node),
491 detail: format!("input #{input} is omitted from a variadic required operand"),
492 })?;
493 let value =
494 graph
495 .values
496 .get(value_id)
497 .ok_or_else(|| LoaderError::InvalidEinsum {
498 node: node_label(node),
499 detail: format!("input #{input} references missing value {value_id:?}"),
500 })?;
501 metadata.push(EinsumInput::from_optional(
502 graph.value_type_is_known(value_id).then_some(value.dtype),
503 graph
504 .value_shape_is_known(value_id)
505 .then_some(value.shape.as_slice()),
506 ));
507 }
508 let plan = match EinsumPlan::build_for_opset(equation, &metadata, imported_opset) {
509 Ok(plan) => Some(plan),
510 Err(error) if error.is_incomplete_metadata() => {
511 if let Some(shapes) = metadata
512 .iter()
513 .map(|input| input.shape())
514 .collect::<Option<Vec<_>>>()
515 {
516 EinsumShapePlan::build_for_opset(equation, &shapes, imported_opset)
517 .map_err(|error| LoaderError::InvalidEinsum {
518 node: node_label(node),
519 detail: error.to_string(),
520 })?;
521 }
522 None
523 }
524 Err(error) => {
525 return Err(LoaderError::InvalidEinsum {
526 node: node_label(node),
527 detail: error.to_string(),
528 });
529 }
530 };
531 if graph.value_type_is_known(output_id) {
532 let schema = EinsumSchema::resolve(imported_opset).map_err(|error| {
533 LoaderError::InvalidEinsum {
534 node: node_label(node),
535 detail: error.to_string(),
536 }
537 })?;
538 if !schema.supports_dtype(output.dtype) {
539 return Err(LoaderError::InvalidEinsum {
540 node: node_label(node),
541 detail: format!(
542 "output dtype {:?} is not admitted by {schema}; cast the output and \
543 every operand to one homogeneous schema-supported dtype",
544 output.dtype
545 ),
546 });
547 }
548 let input_dtype = plan
549 .as_ref()
550 .map(EinsumPlan::dtype)
551 .or_else(|| metadata.iter().find_map(|input| input.dtype()));
552 if let Some(input_dtype) = input_dtype
553 && output.dtype != input_dtype
554 {
555 return Err(LoaderError::InvalidEinsum {
556 node: node_label(node),
557 detail: format!(
558 "output dtype {:?} does not match known homogeneous input dtype {:?}",
559 output.dtype, input_dtype
560 ),
561 });
562 }
563 }
564 }
565 for subgraph in graph.subgraphs.values() {
566 check_graph(subgraph, imports)?;
567 }
568 Ok(())
569 }
570
571 check_graph(graph, &graph.opset_imports)
572}
573
574/// Validate ONNX model metadata and protobuf-level graph invariants that are
575/// intentionally not preserved by the runtime IR (notably `ir_version` and
576/// `AttributeProto::ref_attr_name`).
577///
578/// This runs before graph construction, ensuring invalid names cannot be
579/// coalesced into a single IR value and attribute references cannot be dropped.
580pub fn validate_model_proto(model: &proto::onnx::ModelProto) -> Result<(), LoaderError> {
581 use std::collections::HashSet;
582
583 use proto::onnx::GraphProto;
584
585 // Lower sanity bound only: `ir_version` is a required ONNX field and IR
586 // versions start at 1, so reject an absent (0) or negative version.
587 if model.ir_version < 1 {
588 return Err(LoaderError::InvalidIrVersion {
589 ir_version: model.ir_version,
590 });
591 }
592 // No upper bound. Per the maintainer directive, new ONNX IR versions are
593 // effectively always backward-compatible (they add fields/metadata rather
594 // than breaking existing model semantics), so gating on a version ceiling
595 // only produces false-positive rejections of otherwise-valid newer models.
596 // If a genuinely unsupported construct ever ships, gate on that specific
597 // FEATURE at load time — never on the IR version number.
598 if model.ir_version >= 3 && model.opset_import.is_empty() {
599 return Err(LoaderError::MissingModelOpsetImport {
600 ir_version: model.ir_version,
601 });
602 }
603
604 fn node_description(node: &proto::onnx::NodeProto, index: usize) -> String {
605 if node.name.is_empty() {
606 format!("<unnamed node #{index}>")
607 } else {
608 format!("{:?}", node.name)
609 }
610 }
611
612 fn check_graph(graph: &GraphProto) -> Result<(), LoaderError> {
613 let mut producers = std::collections::HashMap::new();
614 for input in &graph.input {
615 if !input.name.is_empty() {
616 producers.insert(input.name.clone(), "graph input".to_string());
617 }
618 }
619 for (index, node) in graph.node.iter().enumerate() {
620 let node_description = node_description(node, index);
621 for output in &node.output {
622 if output.is_empty() {
623 continue;
624 }
625 let producer = format!("output of {node_description}");
626 if let Some(first) = producers.insert(output.clone(), producer.clone()) {
627 return Err(LoaderError::DuplicateValueProducer {
628 tensor: output.clone(),
629 first,
630 second: producer,
631 });
632 }
633 }
634 for attribute in &node.attribute {
635 if !attribute.ref_attr_name.is_empty() {
636 return Err(LoaderError::RefAttributeOutsideFunction {
637 op_type: node.op_type.clone(),
638 node: node_description.clone(),
639 domain: display_domain(&node.domain),
640 attr: attribute.name.clone(),
641 ref_attr_name: attribute.ref_attr_name.clone(),
642 });
643 }
644 }
645 }
646
647 let sources: HashSet<&str> = graph
648 .input
649 .iter()
650 .map(|input| input.name.as_str())
651 .chain(
652 graph
653 .initializer
654 .iter()
655 .map(|initializer| initializer.name.as_str()),
656 )
657 .chain(
658 graph
659 .node
660 .iter()
661 .flat_map(|node| node.output.iter().map(String::as_str)),
662 )
663 .collect();
664 for output in &graph.output {
665 if !output.name.is_empty() && !sources.contains(output.name.as_str()) {
666 return Err(LoaderError::GraphOutputMissingProducer {
667 tensor: output.name.clone(),
668 });
669 }
670 }
671
672 let outer_initializers: HashSet<&str> = graph
673 .initializer
674 .iter()
675 .map(|initializer| initializer.name.as_str())
676 .collect();
677 for node in &graph.node {
678 for attribute in &node.attribute {
679 let subgraphs = attribute.g.iter().chain(attribute.graphs.iter());
680 for subgraph in subgraphs {
681 if let Some(input) = subgraph
682 .input
683 .iter()
684 .find(|input| outer_initializers.contains(input.name.as_str()))
685 {
686 return Err(LoaderError::SubgraphInputShadowsInitializer {
687 tensor: input.name.clone(),
688 });
689 }
690 check_graph(subgraph)?;
691 }
692 }
693 }
694 Ok(())
695 }
696
697 if let Some(graph) = &model.graph {
698 check_graph(graph)?;
699 }
700 Ok(())
701}
702
703/// Human-readable node label for diagnostics: the quoted ONNX node name, or a
704/// synthetic `<unnamed node #id>` when the model left it blank.
705fn node_label(node: &onnx_runtime_ir::Node) -> String {
706 if node.name.is_empty() {
707 format!("<unnamed node #{}>", node.id.0)
708 } else {
709 format!("{:?}", node.name)
710 }
711}
712
713/// Canonical display domain for a node (`""` renders as `ai.onnx`).
714fn display_domain(domain: &str) -> String {
715 if domain.is_empty() {
716 "ai.onnx".to_string()
717 } else {
718 domain.to_string()
719 }
720}
721
722/// Reject subgraph-bearing (control-flow) ops the runtime cannot execute.
723///
724/// The CPU executor implements the three standard subgraph-bearing control-flow
725/// ops — `If`, `Loop`, and `Scan` (default `ai.onnx` domain) — by recursively
726/// executing their nested [`onnx_runtime_ir::Attribute::Graph`]/`Graphs` bodies.
727/// Any *other* op that smuggles a subgraph attribute (a control-flow construct
728/// this runtime does not implement, or a custom op hiding a nested graph) is
729/// still rejected fast: the executor has no path to run it, so a silent skip or
730/// a late panic would be worse than a clear load-time error.
731///
732/// The check descends into every nested subgraph as well, so an unimplemented
733/// control-flow op buried inside an `If`/`Loop`/`Scan` body is caught at load
734/// rather than surfacing only when that branch/iteration executes.
735pub fn validate_no_control_flow(graph: &Graph) -> Result<(), LoaderError> {
736 use onnx_runtime_ir::Attribute;
737
738 /// The standard subgraph-bearing ops the CPU executor can run recursively.
739 ///
740 /// Operates on loaded IR, where the default domain is canonically `""`.
741 fn is_implemented_control_flow(node: &onnx_runtime_ir::Node) -> bool {
742 node.is_default_domain() && matches!(node.op_type.as_str(), "If" | "Loop" | "Scan")
743 }
744
745 fn check_graph(graph: &Graph) -> Result<(), LoaderError> {
746 for (_, node) in graph.nodes.iter() {
747 // Report attributes in a deterministic order for stable diagnostics.
748 let mut subgraph_attrs: Vec<&String> = node
749 .attributes
750 .iter()
751 .filter(|(_, v)| matches!(v, Attribute::Graph(_) | Attribute::Graphs(_)))
752 .map(|(k, _)| k)
753 .collect();
754 subgraph_attrs.sort();
755 if let Some(attr) = subgraph_attrs.first() {
756 // A subgraph body is fine when its owner is an implemented
757 // control-flow op; otherwise fail fast.
758 if !is_implemented_control_flow(node) {
759 return Err(LoaderError::UnsupportedControlFlow {
760 op_type: node.op_type.clone(),
761 node: node_label(node),
762 domain: display_domain(&node.domain),
763 attr: (*attr).clone(),
764 });
765 }
766 }
767 }
768 // Descend into nested bodies so an unimplemented construct inside an
769 // implemented op's subgraph is still caught at load time.
770 for subgraph in graph.subgraphs.values() {
771 check_graph(subgraph)?;
772 }
773 Ok(())
774 }
775
776 check_graph(graph)
777}
778
779/// Reject graphs with a node input that has no source.
780///
781/// The graph builder materializes an unresolved input name as a fresh named
782/// value with no producer (see `graph_builder::get_or_create`); such a value is
783/// legal only if it is a graph input or an initializer. Any other producer-less
784/// consumed value is a dangling reference — a structurally malformed graph that
785/// [`onnx_runtime_ir::Graph::validate`] does not catch (it only requires graph
786/// *outputs* to be sourced, not node inputs). We reject it at load, naming the
787/// offending node and tensor.
788///
789/// Must run after initializers are attached to `graph.initializers` so
790/// initializer-backed inputs are recognized as sourced.
791pub fn validate_no_dangling_refs(graph: &Graph) -> Result<(), LoaderError> {
792 use std::collections::HashSet;
793
794 let graph_inputs: HashSet<_> = graph.inputs.iter().copied().collect();
795
796 for (_, node) in graph.nodes.iter() {
797 for vid in node.input_values() {
798 let Some(value) = graph.values.get(vid) else {
799 // A dangling value id is caught by IR-level structural
800 // validation; nothing to report here.
801 continue;
802 };
803 let is_sourced = value.producer.is_some()
804 || graph_inputs.contains(&vid)
805 || graph.initializers.contains_key(&vid);
806 if !is_sourced {
807 let tensor = value
808 .name
809 .clone()
810 .unwrap_or_else(|| format!("<anonymous value #{}>", vid.0));
811 return Err(LoaderError::DanglingTensorRef {
812 op_type: node.op_type.clone(),
813 node: node_label(node),
814 domain: display_domain(&node.domain),
815 tensor,
816 });
817 }
818 }
819 }
820 Ok(())
821}
822
823/// Reject graphs where an initializer value is also produced by a node.
824///
825/// The graph builder maps tensor *names* → [`onnx_runtime_ir::ValueId`] for both
826/// node inputs and node outputs (see `graph_builder::get_or_create`). If a node
827/// output name collides with an initializer name, the node output reuses the
828/// initializer's `ValueId` and `connect_edges` then sets `producer = Some(node)`
829/// on that shared value. [`onnx_runtime_ir::Graph::validate`] rejects a *graph
830/// input* with a producer but has no equivalent check for an *initializer*, so
831/// such a malformed graph passes structural validation.
832///
833/// This matters for memory-safety: the session's weight-streaming path borrows
834/// an initializer's read-only mmap bytes zero-copy. A producer-backed
835/// initializer would let a kernel write through that read-only storage
836/// (SIGSEGV on external data, aliasing UB inline). The executor already refuses
837/// to borrow producer-backed initializers, but rejecting the graph here fails
838/// fast and cleanly regardless of the execution path. We name the tensor and
839/// the offending producing node.
840pub fn validate_no_initializer_producer(graph: &Graph) -> Result<(), LoaderError> {
841 for &vid in graph.initializers.keys() {
842 let Some(value) = graph.values.get(vid) else {
843 continue;
844 };
845 if let Some(producer) = value.producer {
846 let tensor = value
847 .name
848 .clone()
849 .unwrap_or_else(|| format!("<anonymous value #{}>", vid.0));
850 let node = if graph.nodes.contains(producer) {
851 node_label(graph.node(producer))
852 } else {
853 format!("<node #{}>", producer.0)
854 };
855 return Err(LoaderError::InitializerHasProducer { tensor, node });
856 }
857 }
858 Ok(())
859}
860///
861/// ONNX treats `""` and `"ai.onnx"` as equivalent spellings of the default
862/// domain. Model-level imports also govern nodes nested in subgraphs.
863pub fn validate_opset_imports(graph: &Graph) -> Result<(), LoaderError> {
864 // Loaded IR is canonical: the default domain is `""` for both node domains
865 // and opset-import keys, so a direct lookup suffices (no dual-spelling
866 // fallback needed — see `onnx_runtime_ir::normalize_domain`).
867 fn has_import(imports: &std::collections::HashMap<String, u64>, domain: &str) -> bool {
868 imports.contains_key(domain)
869 }
870
871 fn validate_graph(
872 graph: &Graph,
873 imports: &std::collections::HashMap<String, u64>,
874 ) -> Result<(), LoaderError> {
875 for (_, node) in graph.nodes.iter() {
876 if !has_import(imports, &node.domain) {
877 let domain = if node.domain.is_empty() {
878 "ai.onnx".to_string()
879 } else {
880 node.domain.clone()
881 };
882 let node_name = if node.name.is_empty() {
883 format!("<unnamed node #{}>", node.id.0)
884 } else {
885 format!("{:?}", node.name)
886 };
887 return Err(LoaderError::MissingOpsetImport {
888 op_type: node.op_type.clone(),
889 node: node_name,
890 domain,
891 });
892 }
893 }
894 for subgraph in graph.subgraphs.values() {
895 validate_graph(subgraph, imports)?;
896 }
897 Ok(())
898 }
899
900 validate_graph(graph, &graph.opset_imports)
901}