Skip to main content

onnx_runtime_loader/
writer.rs

1//! `com.microsoft::EPContext` **dump / writer path** (§55.4) — the inverse of
2//! the load path in [`crate::epcontext`].
3//!
4//! After the session partitions a graph and an EP compiles a claimed subgraph,
5//! it asks that EP for its compiled context ([`save_context`]) and hands the
6//! result here. This module:
7//!
8//! 1. builds one `com.microsoft::EPContext` [`Node`] per compiled partition,
9//!    carrying the §55.2 attributes (`source`, `ep_sdk_version`,
10//!    `partition_name`, `main_context`, `embed_mode`, `ep_cache_context`);
11//! 2. handles `embed_mode`: inline the compiled blob into the `ep_cache_context`
12//!    STRING attribute (byte-exact, via the byte-preserving IR string attr), or
13//!    write it to an external sidecar `.bin` next to the output model and store
14//!    the **relative** path (mirroring the §19.2 external-data convention so the
15//!    produced model round-trips through the §55.3 load path);
16//! 3. **replaces** each partition's nodes with its single EPContext node, wiring
17//!    the partition's boundary inputs/outputs to the node's variadic i/o; and
18//! 4. serialises the resulting model to `<orig_stem>_ctx.onnx` (or an explicit
19//!    output path) via the [`crate::encoder`] seam.
20//!
21//! ## Model-agnostic (§55.6 HARD RULE)
22//!
23//! Nothing here hardcodes a vendor/op/model name. The EPContext op-type/domain
24//! come from the shared constants in [`crate::epcontext`]; every `source` key,
25//! SDK version, partition name, and blob comes from the caller (ultimately from
26//! the EP via its trait). Adding a new compiled EP needs **no** change here.
27//!
28//! [`save_context`]: https://docs.rs/onnx-runtime-ep-api
29//! [`Node`]: onnx_runtime_ir::Node
30
31use std::collections::HashSet;
32use std::path::{Path, PathBuf};
33
34use onnx_runtime_ir::{Attribute, Graph, Node, NodeId, ValueId};
35
36use crate::encoder::{encode_model, Model};
37use crate::epcontext::{attr, EP_CONTEXT_OP, MS_DOMAIN};
38use crate::LoaderError;
39
40/// Configuration for the EPContext dump path (§55.4).
41///
42/// A follow-up wires the `SessionBuilder` / C-API options
43/// `ep.context_enable` / `ep.context_file_path` / `ep.context_embed_mode` to
44/// populate this struct, so the field names/types match those options exactly.
45/// It is directly constructible (no option-string parsing) so it can be built
46/// and tested standalone.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct EpContextDumpConfig {
49    /// `ep.context_enable` — whether to dump a context-cache model at all. When
50    /// `false`, [`dump_ep_context`] is a no-op: it writes no sidecars and no ctx
51    /// model, so a disabled config is safe by construction.
52    pub enable: bool,
53    /// `ep.context_file_path` — the output model path. `None` defaults to
54    /// `<orig_stem>_ctx.onnx` beside the source model.
55    pub file_path: Option<PathBuf>,
56    /// `ep.context_embed_mode` — `1` embeds the blob inline (default), `0`
57    /// writes it to an external sidecar `.bin` and stores the relative path.
58    pub embed_mode: u8,
59}
60
61impl Default for EpContextDumpConfig {
62    fn default() -> Self {
63        Self {
64            enable: false,
65            file_path: None,
66            // §55.2 default: embed the payload inline.
67            embed_mode: 1,
68        }
69    }
70}
71
72impl EpContextDumpConfig {
73    /// Whether `embed_mode` selects the external-file form (`0`). Every other
74    /// value (including the §55.2 default `1`) embeds inline — the same
75    /// fail-closed decode the load path uses ([`crate::EmbedMode`]).
76    fn is_external(&self) -> bool {
77        self.embed_mode == 0
78    }
79}
80
81/// One EP-compiled partition to serialise into an `EPContext` node (§55.4).
82///
83/// This is the **model-agnostic** input to the writer: the loader does not
84/// depend on `onnx-runtime-ep-api`, so the session maps its runtime `EpContext`
85/// (+ the EP's `context_source_keys`) into this neutral view. Every field is
86/// supplied by the caller — no vendor name is baked in.
87#[derive(Clone, Copy, Debug)]
88pub struct EpContextPartition<'a> {
89    /// `source` attribute — the EP's own dispatch key (§55.6). Never hardcoded.
90    pub source: &'a str,
91    /// `ep_sdk_version` attribute — the SDK/toolchain version (from the runtime
92    /// context's `ep_version`). Empty ⇒ the attribute is omitted.
93    pub ep_sdk_version: &'a str,
94    /// `partition_name` attribute — the ORT-partitioned graph name. Empty ⇒ the
95    /// attribute is omitted.
96    pub partition_name: &'a str,
97    /// `main_context` attribute — `true` for a primary node owning the payload.
98    pub main_context: bool,
99    /// The compiled vendor blob (the runtime context's `data`) → the node's
100    /// `ep_cache_context` (inline or sidecar, per `embed_mode`).
101    pub blob: &'a [u8],
102    /// The partition's nodes (the runtime context's `covered_nodes`) that this
103    /// single EPContext node replaces. Their boundary tensors become the node's
104    /// variadic i/o.
105    pub covered_nodes: &'a [NodeId],
106}
107
108/// Dump `model` to a `*_ctx.onnx` context-cache model, replacing each compiled
109/// `partition`'s subgraph with a single `com.microsoft::EPContext` node (§55.4).
110///
111/// Returns the path the context model was written to.
112///
113/// * The output path is `config.file_path` if set, else `<orig_stem>_ctx.onnx`
114///   beside `orig_path`.
115/// * For `embed_mode = 0` each partition's blob is written to a sidecar
116///   `<ctx_stem>_p{index}_<source>_<partition>.bin` **next to the output model**
117///   and the node stores that filename as a **relative** path (resolved back
118///   relative to the model dir by the §55.3 load path). The partition **index**
119///   makes the filename injective: two partitions with different identities can
120///   never alias the same file even when their sanitised components collide.
121///
122/// # Errors
123///
124/// Beyond the loader I/O / encoding errors, this returns
125/// [`LoaderError::EpContext`] if a partition has no covered nodes.
126///
127/// Two partitions may legitimately share the same `source` and
128/// `partition_name` (a single EP can emit multiple compiled primary
129/// partitions, including multiple unnamed ones). The per-partition index in
130/// the sidecar filename keeps their blobs distinct, and the §55.3 consume path
131/// loads each `main_context=1` node independently, so such duplicates are not
132/// rejected here.
133///
134/// If [`EpContextDumpConfig::enable`] is `false` this writes **nothing** (no
135/// sidecars, no ctx model) and returns the path it *would* have written to, so a
136/// disabled config is a no-op by construction.
137pub fn dump_ep_context(
138    model: &Model,
139    orig_path: &Path,
140    partitions: &[EpContextPartition],
141    config: &EpContextDumpConfig,
142) -> Result<PathBuf, LoaderError> {
143    let out_path = resolve_output_path(orig_path, config);
144
145    // Honour `ep.context_enable`: a disabled config produces no files and no ctx
146    // model. Return early *before* any side effect so the dump is safe by
147    // construction regardless of how the caller gates.
148    if !config.enable {
149        return Ok(out_path);
150    }
151
152    let out_dir = out_path
153        .parent()
154        .filter(|p| !p.as_os_str().is_empty())
155        .map(Path::to_path_buf)
156        .unwrap_or_else(|| PathBuf::from("."));
157
158    // Mutate a clone so the caller's graph is untouched.
159    let mut graph = model.graph.clone();
160
161    // EPContext lives in the `com.microsoft` opset; ensure the produced model
162    // declares it so it is a valid ONNX model and round-trips through ORT.
163    graph
164        .opset_imports
165        .entry(MS_DOMAIN.to_string())
166        .or_insert(1);
167
168    // Splicing removes/inserts nodes and updates value edges, but never rewrites
169    // graph.outputs: boundary output values survive and the EPContext node
170    // re-produces them. Reuse this invariant set for every partition boundary
171    // and for the batched orphan-value checks.
172    let graph_outputs: HashSet<ValueId> = graph.outputs.iter().copied().collect();
173    #[cfg(debug_assertions)]
174    let original_graph_outputs = graph.outputs.clone();
175
176    let mut replacements = Vec::with_capacity(partitions.len());
177    for (index, part) in partitions.iter().enumerate() {
178        replacements.push(prepare_partition(
179            &graph,
180            &graph_outputs,
181            &out_dir,
182            &out_path,
183            config,
184            index,
185            part,
186        )?);
187    }
188    graph.replace_node_groups(replacements, &graph_outputs);
189    #[cfg(debug_assertions)]
190    debug_assert_eq!(graph.outputs, original_graph_outputs);
191
192    let out_model = Model {
193        graph: &graph,
194        metadata: model.metadata.clone(),
195        weights: model.weights,
196    };
197    let bytes = encode_model(&out_model)?;
198    std::fs::write(&out_path, bytes).map_err(|source| LoaderError::Io {
199        path: out_path.clone(),
200        source,
201    })?;
202    Ok(out_path)
203}
204
205/// Prepare the replacement EPContext node for one partition.
206fn prepare_partition(
207    graph: &Graph,
208    graph_outputs: &HashSet<ValueId>,
209    out_dir: &Path,
210    out_path: &Path,
211    config: &EpContextDumpConfig,
212    index: usize,
213    part: &EpContextPartition,
214) -> Result<(Vec<NodeId>, Node), LoaderError> {
215    let covered: HashSet<NodeId> = part.covered_nodes.iter().copied().collect();
216    if covered.is_empty() {
217        return Err(LoaderError::EpContext(
218            "EPContext dump: partition has no covered nodes".to_string(),
219        ));
220    }
221    let (inputs, outputs) = partition_boundary(graph, &covered, graph_outputs);
222
223    // Build the ep_cache_context payload (inline bytes or a relative sidecar
224    // path), writing the external `.bin` next to the output model when needed.
225    let ep_cache_context = if config.is_external() {
226        let rel = sidecar_filename(out_path, index, part.source, part.partition_name);
227        let sidecar = out_dir.join(&rel);
228        std::fs::write(&sidecar, part.blob).map_err(|source| LoaderError::Io {
229            path: sidecar,
230            source,
231        })?;
232        Attribute::String(rel.into_bytes())
233    } else {
234        Attribute::String(part.blob.to_vec())
235    };
236
237    let mut node = Node::new(NodeId(0), EP_CONTEXT_OP, inputs, outputs);
238    node.domain = MS_DOMAIN.to_string();
239    let attrs = &mut node.attributes;
240    attrs.insert(
241        attr::MAIN_CONTEXT.to_string(),
242        Attribute::Int(part.main_context as i64),
243    );
244    attrs.insert(
245        attr::EMBED_MODE.to_string(),
246        Attribute::Int(if config.is_external() { 0 } else { 1 }),
247    );
248    attrs.insert(
249        attr::SOURCE.to_string(),
250        Attribute::String(part.source.as_bytes().to_vec()),
251    );
252    if !part.ep_sdk_version.is_empty() {
253        attrs.insert(
254            attr::EP_SDK_VERSION.to_string(),
255            Attribute::String(part.ep_sdk_version.as_bytes().to_vec()),
256        );
257    }
258    if !part.partition_name.is_empty() {
259        attrs.insert(
260            attr::PARTITION_NAME.to_string(),
261            Attribute::String(part.partition_name.as_bytes().to_vec()),
262        );
263    }
264    attrs.insert(attr::EP_CACHE_CONTEXT.to_string(), ep_cache_context);
265
266    Ok((part.covered_nodes.to_vec(), node))
267}
268
269/// Compute a partition's boundary tensors (§55.4): the variadic inputs/outputs
270/// the replacement EPContext node must carry.
271///
272/// * **Inputs** — values a covered node consumes that are produced *outside* the
273///   partition (or are graph inputs / initializers / sources). Positional order
274///   and skipped-optional (`None`) slots are preserved.
275/// * **Outputs** — values a covered node produces that are graph outputs *or*
276///   consumed by a node outside the partition.
277///
278/// Both are deduped preserving first-seen order, iterating covered nodes in
279/// ascending [`NodeId`] for deterministic output.
280///
281/// # Boundary-ordering ABI (compiler-integration seam)
282///
283/// The variadic input/output order of the emitted EPContext node is reconstructed
284/// here purely from **ascending `NodeId`** over the covered set. This is
285/// deterministic and correct for the current caller, but node-id order is **not**
286/// an explicit, versioned ABI — a future compiler that emits its own canonical
287/// boundary ordering (or relies on a specific i/o slot layout) must not silently
288/// assume this ordering. If/when compiler integration lands, make the intended
289/// boundary order an explicit contract between the partitioner and this seam
290/// rather than depending on `NodeId` allocation order.
291fn partition_boundary(
292    graph: &Graph,
293    covered: &HashSet<NodeId>,
294    graph_outputs: &HashSet<ValueId>,
295) -> (Vec<Option<ValueId>>, Vec<ValueId>) {
296    let mut ordered: Vec<NodeId> = covered.iter().copied().collect();
297    ordered.sort_by_key(|n| n.0);
298
299    let mut inputs: Vec<Option<ValueId>> = Vec::new();
300    let mut seen_in: HashSet<ValueId> = HashSet::new();
301    let mut outputs: Vec<ValueId> = Vec::new();
302    let mut seen_out: HashSet<ValueId> = HashSet::new();
303
304    for nid in &ordered {
305        let node = graph.node(*nid);
306        for slot in &node.inputs {
307            let Some(vid) = *slot else { continue };
308            let external = match graph.value(vid).producer {
309                Some(prod) => !covered.contains(&prod),
310                None => true, // graph input / initializer / source
311            };
312            if external && seen_in.insert(vid) {
313                inputs.push(Some(vid));
314            }
315        }
316    }
317    for nid in &ordered {
318        let node = graph.node(*nid);
319        for &vid in &node.outputs {
320            let escapes = graph_outputs.contains(&vid)
321                || graph
322                    .consumers(vid)
323                    .into_iter()
324                    .any(|consumer| !covered.contains(&consumer));
325            if escapes && seen_out.insert(vid) {
326                outputs.push(vid);
327            }
328        }
329    }
330    (inputs, outputs)
331}
332
333/// The output context-model path: `config.file_path`, else `<orig_stem>_ctx.onnx`
334/// beside `orig_path`.
335fn resolve_output_path(orig_path: &Path, config: &EpContextDumpConfig) -> PathBuf {
336    if let Some(p) = &config.file_path {
337        return p.clone();
338    }
339    let dir = orig_path
340        .parent()
341        .filter(|p| !p.as_os_str().is_empty())
342        .map(Path::to_path_buf)
343        .unwrap_or_else(|| PathBuf::from("."));
344    let stem = orig_path
345        .file_stem()
346        .and_then(|s| s.to_str())
347        .unwrap_or("model");
348    dir.join(format!("{stem}_ctx.onnx"))
349}
350
351/// Sidecar `.bin` filename for an external blob (§55.4):
352/// `<ctx_stem>_p{index}_<source>_<partition>.bin`, with `source`/`partition`
353/// sanitised to filesystem-safe characters. Returned as a bare filename
354/// (relative to the model dir, per the §55.3 external-path policy).
355///
356/// The `index` (the partition's position within this dump call) is an
357/// **injective** disambiguator: [`sanitize_component`] is non-injective (it maps
358/// every disallowed char to `_`), so two partitions with different identities —
359/// e.g. sources `Vendor/EP` and `Vendor_EP` — sanitise to the same components
360/// and would otherwise collide onto one file, silently overwriting each other's
361/// blob. Prefixing the distinct partition index guarantees a distinct filename
362/// per partition, so every EPContext node stores the path of *its own* blob.
363fn sidecar_filename(out_path: &Path, index: usize, source: &str, partition: &str) -> String {
364    let stem = out_path
365        .file_stem()
366        .and_then(|s| s.to_str())
367        .unwrap_or("model");
368    let src = sanitize_component(source);
369    let part = sanitize_component(partition);
370    if part.is_empty() {
371        format!("{stem}_p{index}_{src}.bin")
372    } else {
373        format!("{stem}_p{index}_{src}_{part}.bin")
374    }
375}
376
377/// Replace any character that is not alphanumeric, `-`, `_`, or `.` with `_`,
378/// so an EP-supplied `source`/`partition` never yields a path separator or an
379/// otherwise unsafe filename component.
380fn sanitize_component(s: &str) -> String {
381    s.chars()
382        .map(|c| {
383            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
384                c
385            } else {
386                '_'
387            }
388        })
389        .collect()
390}