Skip to main content

onnx_runtime_loader/
encoder.rs

1//! ONNX protobuf **encoding** — the inverse of [`graph_builder`](crate::graph_builder)
2//! and [`weights`](crate::weights) (§19.1, §55.4 dump path).
3//!
4//! Serialises an [`onnx_runtime_ir::Graph`] (plus model-level metadata that the
5//! IR does not itself store) back into an ONNX `ModelProto`, then to protobuf
6//! bytes via `prost`. This is the foundational capability the EPContext writer
7//! (§55.4) builds on, but it is deliberately **model-agnostic**: it hardcodes no
8//! op type, vendor, or model name.
9//!
10//! ## Round-trip contract
11//!
12//! Everything the load path (`decode → build → weights`) preserves survives an
13//! `encode → decode` round-trip byte-for-byte:
14//!
15//! * nodes: `op_type`, `domain`, `input`/`output` order (incl. skipped optional
16//!   slots), attributes, `doc_string`;
17//! * graph inputs / outputs / interior `value_info` (dtype + static & symbolic
18//!   dims, symbols re-emitted by their interned name);
19//! * initializers: all supported dtypes, raw little-endian bytes byte-exact
20//!   (including `STRING` payloads);
21//! * opset imports, `ir_version`, producer fields, model `doc_string`,
22//!   `metadata_props`.
23//!
24//! ### STRING attributes are byte-preserving (§55.3/§55.4)
25//!
26//! ONNX `STRING` attributes are arbitrary byte strings — a compiled-vendor blob,
27//! a relative path, or text — that are not guaranteed to be valid UTF-8. The IR
28//! stores them as raw bytes ([`Attribute::String`](onnx_runtime_ir::Attribute)),
29//! so both decode and encode round-trip the bytes exactly with **zero** op or
30//! attribute-name knowledge. The `EPContext` `ep_cache_context` opaque blob is
31//! preserved purely by this generic mechanism — the encoder contains no
32//! op-specific branch.
33//!
34//! ## Fields deliberately not encoded
35//!
36//! The IR `Graph` does not model these, so they cannot be reproduced and are
37//! emitted empty / default:
38//!
39//! * `TrainingInfoProto`, `FunctionProto`, sparse initializers, quantization
40//!   annotations (not represented in the IR). Model-local functions ARE
41//!   supported on the load path — [`crate::function_inline`] expands every
42//!   function call into its primitive body before the IR is built — but the IR
43//!   `Graph` does not retain the original `FunctionProto` declarations, so they
44//!   are not re-emitted here;
45//! * control-flow **subgraph** formal `input`/`output` lists — the load path
46//!   does not register them as graph I/O for nested graphs. Rather than silently
47//!   emit a subgraph with a truncated signature, [`encode_attribute`] returns a
48//!   clean "unsupported" error for any `Graph`/`Graphs` attribute (no Phase-1 op
49//!   uses control-flow subgraphs).
50
51use std::collections::HashSet;
52use std::path::Path;
53
54use prost::Message;
55
56use onnx_runtime_ir::{
57    Attribute, DataType, Dim, Graph, Node, Shape, TensorData, TypeProto, ValueId, WeightRef,
58};
59
60use crate::proto::onnx::{
61    self, attribute_proto::AttributeType, tensor_shape_proto, type_proto, AttributeProto,
62    GraphProto, ModelProto, NodeProto, OperatorSetIdProto, StringStringEntryProto, TensorProto,
63    TensorShapeProto, ValueInfoProto,
64};
65use crate::weights::WeightStore;
66use crate::LoaderError;
67
68/// Default ONNX `ir_version` stamped when [`ModelMetadata`] does not override it
69/// (IR version 10, the version paired with opset 21).
70pub const DEFAULT_IR_VERSION: i64 = 10;
71
72/// Model-level metadata that the IR [`Graph`] does not itself carry.
73///
74/// The load path drops these (it only keeps `opset_imports` on the `Graph`), so
75/// a caller that wants a faithful `ModelProto` supplies them here. All fields
76/// default to empty/zero except [`ir_version`](ModelMetadata::ir_version).
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct ModelMetadata {
79    /// `ModelProto.ir_version`.
80    pub ir_version: i64,
81    /// `ModelProto.producer_name`.
82    pub producer_name: String,
83    /// `ModelProto.producer_version`.
84    pub producer_version: String,
85    /// `ModelProto.domain`.
86    pub domain: String,
87    /// `ModelProto.model_version`.
88    pub model_version: i64,
89    /// `ModelProto.doc_string`.
90    pub doc_string: Option<String>,
91    /// `GraphProto.name` for the top-level graph.
92    pub graph_name: String,
93    /// `ModelProto.metadata_props` (`key → value`), emitted in order.
94    pub metadata_props: Vec<(String, String)>,
95}
96
97impl Default for ModelMetadata {
98    fn default() -> Self {
99        Self {
100            ir_version: DEFAULT_IR_VERSION,
101            producer_name: String::new(),
102            producer_version: String::new(),
103            domain: String::new(),
104            model_version: 0,
105            doc_string: None,
106            graph_name: String::new(),
107            metadata_props: Vec::new(),
108        }
109    }
110}
111
112/// An IR [`Graph`] bundled with the model-level metadata and live weight bytes
113/// needed to encode a complete ONNX `ModelProto`.
114///
115/// Construct with [`Model::new`] and refine via [`Model::with_metadata`] /
116/// [`Model::with_weights`]. A [`WeightStore`] is required only when the graph
117/// has `External`-backed initializers (inline initializers carry their own
118/// bytes).
119pub struct Model<'a> {
120    /// The graph to encode.
121    pub graph: &'a Graph,
122    /// Model-level metadata (see [`ModelMetadata`]).
123    pub metadata: ModelMetadata,
124    /// Live weight store backing any [`WeightRef::External`] initializers.
125    pub weights: Option<&'a WeightStore>,
126}
127
128impl<'a> Model<'a> {
129    /// A model over `graph` with default metadata and no external weight store.
130    ///
131    /// **Note on defaulted metadata:** the load path does not preserve
132    /// model-level metadata (it keeps only `opset_imports` on the `Graph`), so a
133    /// model built this way stamps [`DEFAULT_IR_VERSION`] and empty producer /
134    /// version / `metadata_props` fields. A rewrite path that must reproduce the
135    /// original model faithfully should capture the source metadata and pass it
136    /// via [`Model::with_metadata`] rather than relying on these defaults.
137    pub fn new(graph: &'a Graph) -> Self {
138        Self {
139            graph,
140            metadata: ModelMetadata::default(),
141            weights: None,
142        }
143    }
144
145    /// Attach model-level metadata.
146    pub fn with_metadata(mut self, metadata: ModelMetadata) -> Self {
147        self.metadata = metadata;
148        self
149    }
150
151    /// Attach the live [`WeightStore`] backing external initializers.
152    pub fn with_weights(mut self, weights: &'a WeightStore) -> Self {
153        self.weights = Some(weights);
154        self
155    }
156}
157
158/// Encode `model` into serialized ONNX protobuf bytes.
159pub fn encode_model(model: &Model) -> Result<Vec<u8>, LoaderError> {
160    Ok(encode_model_proto(model)?.encode_to_vec())
161}
162
163/// Encode `model` and write the serialized bytes to `path`.
164pub fn write_model(model: &Model, path: impl AsRef<Path>) -> Result<(), LoaderError> {
165    let bytes = encode_model(model)?;
166    let path = path.as_ref();
167    std::fs::write(path, bytes).map_err(|source| LoaderError::Io {
168        path: path.to_path_buf(),
169        source,
170    })
171}
172
173/// Build the [`ModelProto`] for `model` without serialising it (useful when the
174/// caller wants to mutate the proto before encoding, e.g. the §55.4 writer
175/// splicing in `EPContext` nodes).
176pub fn encode_model_proto(model: &Model) -> Result<ModelProto, LoaderError> {
177    let meta = &model.metadata;
178    let graph = encode_graph_proto(model.graph, model.weights, true, &meta.graph_name)?;
179
180    // Opset imports sorted by domain for deterministic output.
181    let mut opset_import: Vec<OperatorSetIdProto> = model
182        .graph
183        .opset_imports
184        .iter()
185        .map(|(domain, &version)| OperatorSetIdProto {
186            domain: domain.clone(),
187            version: version as i64,
188        })
189        .collect();
190    if meta.ir_version >= 3
191        && !opset_import
192            .iter()
193            .any(|opset| opset.domain.is_empty() || opset.domain == "ai.onnx")
194    {
195        // IR >= 3 requires a default-domain import even for an empty graph.
196        opset_import.push(OperatorSetIdProto {
197            domain: String::new(),
198            version: 21,
199        });
200    }
201    opset_import.sort_by(|a, b| a.domain.cmp(&b.domain));
202
203    let metadata_props = meta
204        .metadata_props
205        .iter()
206        .map(|(key, value)| StringStringEntryProto {
207            key: key.clone(),
208            value: value.clone(),
209        })
210        .collect();
211
212    Ok(ModelProto {
213        ir_version: meta.ir_version,
214        opset_import,
215        producer_name: meta.producer_name.clone(),
216        producer_version: meta.producer_version.clone(),
217        domain: meta.domain.clone(),
218        model_version: meta.model_version,
219        doc_string: meta.doc_string.clone().unwrap_or_default(),
220        graph: Some(graph),
221        metadata_props,
222        ..Default::default()
223    })
224}
225
226/// Encode a [`Graph`] into a `GraphProto`. `is_top_level` reserved for future
227/// subgraph-specific behaviour; currently the same fields are emitted for both.
228fn encode_graph_proto(
229    graph: &Graph,
230    weights: Option<&WeightStore>,
231    _is_top_level: bool,
232    name: &str,
233) -> Result<GraphProto, LoaderError> {
234    // 1. Initializers, ordered by value id for determinism.
235    let mut init_ids: Vec<ValueId> = graph.initializers.keys().copied().collect();
236    init_ids.sort_by_key(|v| v.0);
237    let mut initializer = Vec::with_capacity(init_ids.len());
238    for vid in &init_ids {
239        let weight = &graph.initializers[vid];
240        let iname = value_name(graph, *vid).unwrap_or_default().to_string();
241        initializer.push(encode_weight(iname, weight, weights)?);
242    }
243
244    // 2. Graph inputs / outputs as ValueInfoProtos.
245    let input: Vec<ValueInfoProto> = graph
246        .inputs
247        .iter()
248        .map(|&vid| encode_value_info(graph, vid))
249        .collect();
250    let output: Vec<ValueInfoProto> = graph
251        .outputs
252        .iter()
253        .map(|&vid| encode_value_info(graph, vid))
254        .collect();
255
256    // 3. Interior value_info: every named value that is not a graph input,
257    //    output, or initializer. Anonymous values (skipped optional outputs,
258    //    unnamed SSA edges) carry no name and are omitted.
259    let mut excluded: HashSet<ValueId> = HashSet::new();
260    excluded.extend(graph.inputs.iter().copied());
261    excluded.extend(graph.outputs.iter().copied());
262    excluded.extend(init_ids.iter().copied());
263    let mut value_info = Vec::new();
264    for (vid, value) in graph.values.iter() {
265        if excluded.contains(&vid) {
266            continue;
267        }
268        if value.name.as_deref().is_some_and(|n| !n.is_empty()) {
269            value_info.push(encode_value_info(graph, vid));
270        }
271    }
272
273    // 4. Nodes, in ascending node-id (== load) order.
274    let mut node = Vec::with_capacity(graph.num_nodes());
275    for (_, n) in graph.nodes.iter() {
276        node.push(encode_node(graph, n)?);
277    }
278
279    Ok(GraphProto {
280        node,
281        name: name.to_string(),
282        initializer,
283        input,
284        output,
285        value_info,
286        ..Default::default()
287    })
288}
289
290/// Encode a single node into a `NodeProto`.
291fn encode_node(graph: &Graph, node: &Node) -> Result<NodeProto, LoaderError> {
292    let input: Vec<String> = node
293        .inputs
294        .iter()
295        .map(|slot| match slot {
296            Some(vid) => value_name(graph, *vid).unwrap_or_default().to_string(),
297            None => String::new(),
298        })
299        .collect();
300    let output: Vec<String> = node
301        .outputs
302        .iter()
303        .map(|&vid| value_name(graph, vid).unwrap_or_default().to_string())
304        .collect();
305
306    // Sort attributes by name so the encoding is deterministic (the IR stores
307    // them in a HashMap).
308    let mut keys: Vec<&String> = node.attributes.keys().collect();
309    keys.sort();
310    let mut attribute = Vec::with_capacity(keys.len());
311    for key in keys {
312        attribute.push(encode_attribute(graph, key, &node.attributes[key])?);
313    }
314
315    Ok(NodeProto {
316        input,
317        output,
318        name: node.name.clone(),
319        op_type: node.op_type.clone(),
320        domain: node.domain.clone(),
321        attribute,
322        doc_string: node.doc_string.clone().unwrap_or_default(),
323        ..Default::default()
324    })
325}
326
327/// Encode one IR [`Attribute`] into an `AttributeProto`, setting the `type`
328/// discriminator to match the populated field (required for IR ≥ 0.0.2).
329fn encode_attribute(
330    graph: &Graph,
331    name: &str,
332    attr: &Attribute,
333) -> Result<AttributeProto, LoaderError> {
334    let mut ap = AttributeProto {
335        name: name.to_string(),
336        ..Default::default()
337    };
338    match attr {
339        Attribute::Int(v) => {
340            ap.i = *v;
341            ap.r#type = AttributeType::Int as i32;
342        }
343        Attribute::Float(v) => {
344            ap.f = *v;
345            ap.r#type = AttributeType::Float as i32;
346        }
347        Attribute::String(s) => {
348            ap.s = s.clone();
349            ap.r#type = AttributeType::String as i32;
350        }
351        Attribute::Ints(v) => {
352            ap.ints = v.clone();
353            ap.r#type = AttributeType::Ints as i32;
354        }
355        Attribute::Floats(v) => {
356            ap.floats = v.clone();
357            ap.r#type = AttributeType::Floats as i32;
358        }
359        Attribute::Strings(v) => {
360            ap.strings = v.clone();
361            ap.r#type = AttributeType::Strings as i32;
362        }
363        Attribute::Tensor(t) => {
364            ap.t = Some(encode_tensor(t));
365            ap.r#type = AttributeType::Tensor as i32;
366        }
367        Attribute::Graph(_) | Attribute::Graphs(_) => {
368            // The load path does not register a nested subgraph's formal
369            // `input`/`output` lists (only its nodes / value_info survive), so a
370            // control-flow subgraph cannot be re-emitted with a faithful
371            // signature. Fail cleanly rather than silently emit a subgraph with a
372            // truncated I/O contract. (No Phase-1 op uses control-flow
373            // subgraphs; see the module docs.)
374            return Err(LoaderError::GraphBuild(format!(
375                "attribute {name:?}: encoding control-flow subgraph attributes is \
376                 unsupported (nested-graph formal I/O is not round-tripped by the \
377                 load path)"
378            )));
379        }
380        Attribute::TypeProto(tp) => {
381            ap.tp = Some(encode_type_proto(graph, tp));
382            ap.r#type = AttributeType::TypeProto as i32;
383        }
384        // The IR carries a SparseTensor attribute variant, but the load path
385        // never builds one (it errors on sparse attribute kinds). Surface a
386        // clean error rather than emit a malformed proto.
387        Attribute::SparseTensor(_) => {
388            return Err(LoaderError::GraphBuild(format!(
389                "attribute {name:?}: SparseTensor encoding is unsupported"
390            )));
391        }
392    }
393    Ok(ap)
394}
395
396/// Encode a [`TensorData`] into a `TensorProto`, preserving raw little-endian
397/// bytes (or `STRING` payloads) byte-exactly.
398fn encode_tensor(t: &TensorData) -> TensorProto {
399    let mut tp = TensorProto {
400        dims: t.dims.iter().map(|&d| d as i64).collect(),
401        data_type: t.dtype.to_onnx(),
402        name: t.name.clone().unwrap_or_default(),
403        ..Default::default()
404    };
405    if t.dtype == DataType::String {
406        tp.string_data = t.strings.iter().map(|s| s.clone().into_bytes()).collect();
407    } else {
408        tp.raw_data = t.data.clone();
409    }
410    tp
411}
412
413/// Encode an initializer [`WeightRef`] into a `TensorProto` named `name`.
414///
415/// Inline weights are emitted directly; external weights are materialised inline
416/// (as `raw_data`) from the provided [`WeightStore`]. Preserving the external
417/// `data_location` reference on write is a follow-up (see the decision note).
418fn encode_weight(
419    name: String,
420    weight: &WeightRef,
421    weights: Option<&WeightStore>,
422) -> Result<TensorProto, LoaderError> {
423    match weight {
424        WeightRef::Inline(t) => {
425            let mut tp = encode_tensor(t);
426            tp.name = name;
427            Ok(tp)
428        }
429        WeightRef::External { dtype, dims, .. } => {
430            if *dtype == DataType::String {
431                return Err(LoaderError::GraphBuild(format!(
432                    "external initializer {name:?}: STRING external data is unsupported"
433                )));
434            }
435            let bytes = weights.and_then(|s| s.bytes(weight)).ok_or_else(|| {
436                LoaderError::GraphBuild(format!(
437                    "external initializer {name:?}: weight bytes unavailable \
438                     (attach a WeightStore via Model::with_weights)"
439                ))
440            })?;
441            Ok(TensorProto {
442                name,
443                data_type: dtype.to_onnx(),
444                dims: dims.iter().map(|&d| d as i64).collect(),
445                raw_data: bytes.to_vec(),
446                ..Default::default()
447            })
448        }
449    }
450}
451
452/// Encode a value's `(dtype, shape)` into a `ValueInfoProto`.
453fn encode_value_info(graph: &Graph, vid: ValueId) -> ValueInfoProto {
454    let value = graph.value(vid);
455    ValueInfoProto {
456        name: value.name.clone().unwrap_or_default(),
457        r#type: Some(encode_tensor_type(graph, value.dtype, &value.shape)),
458        ..Default::default()
459    }
460}
461
462/// Build a tensor `TypeProto` from an element type and shape.
463fn encode_tensor_type(graph: &Graph, dtype: DataType, shape: &Shape) -> onnx::TypeProto {
464    onnx::TypeProto {
465        value: Some(type_proto::Value::TensorType(type_proto::Tensor {
466            elem_type: dtype.to_onnx(),
467            shape: Some(encode_shape(graph, shape)),
468        })),
469        ..Default::default()
470    }
471}
472
473/// Encode an IR [`Shape`] into a `TensorShapeProto`. Static dims become
474/// `dim_value`; symbolic dims are re-emitted by their interned name as
475/// `dim_param`, or as a valueless (unknown) dimension when unnamed.
476fn encode_shape(graph: &Graph, shape: &Shape) -> TensorShapeProto {
477    use tensor_shape_proto::{dimension::Value as DV, Dimension};
478    let dim = shape
479        .iter()
480        .map(|d| {
481            let value = match d {
482                Dim::Static(n) => Some(DV::DimValue(*n as i64)),
483                Dim::Symbolic(sym) => graph
484                    .symbol_constraints
485                    .get(sym)
486                    .and_then(|c| c.name.clone())
487                    .map(DV::DimParam),
488            };
489            Dimension {
490                value,
491                ..Default::default()
492            }
493        })
494        .collect();
495    TensorShapeProto { dim }
496}
497
498/// Encode an IR [`TypeProto`] (the inverse of `graph_builder::convert_type_proto`).
499fn encode_type_proto(graph: &Graph, tp: &TypeProto) -> onnx::TypeProto {
500    let value = match tp {
501        TypeProto::Tensor { dtype, shape } => type_proto::Value::TensorType(type_proto::Tensor {
502            elem_type: dtype.to_onnx(),
503            shape: Some(encode_shape(graph, shape)),
504        }),
505        TypeProto::SparseTensor { dtype, shape } => {
506            type_proto::Value::SparseTensorType(type_proto::SparseTensor {
507                elem_type: dtype.to_onnx(),
508                shape: Some(encode_shape(graph, shape)),
509            })
510        }
511        TypeProto::Sequence(inner) => type_proto::Value::SequenceType(Box::new(type_proto::Sequence {
512            elem_type: Some(Box::new(encode_type_proto(graph, inner))),
513        })),
514        TypeProto::Optional(inner) => type_proto::Value::OptionalType(Box::new(type_proto::Optional {
515            elem_type: Some(Box::new(encode_type_proto(graph, inner))),
516        })),
517        TypeProto::Map { key, value } => type_proto::Value::MapType(Box::new(type_proto::Map {
518            key_type: key.to_onnx(),
519            value_type: Some(Box::new(encode_type_proto(graph, value))),
520        })),
521    };
522    onnx::TypeProto {
523        value: Some(value),
524        ..Default::default()
525    }
526}
527
528/// The name of a graph value, if it has one.
529fn value_name(graph: &Graph, vid: ValueId) -> Option<&str> {
530    graph.try_value(vid).and_then(|v| v.name.as_deref())
531}