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