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    for (index, part) in partitions.iter().enumerate() {
169        splice_partition(&mut graph, &out_dir, &out_path, config, index, part)?;
170    }
171
172    let out_model = Model {
173        graph: &graph,
174        metadata: model.metadata.clone(),
175        weights: model.weights,
176    };
177    let bytes = encode_model(&out_model)?;
178    std::fs::write(&out_path, bytes).map_err(|source| LoaderError::Io {
179        path: out_path.clone(),
180        source,
181    })?;
182    Ok(out_path)
183}
184
185/// Replace one partition's nodes with a single EPContext node wired to the
186/// partition's boundary i/o.
187fn splice_partition(
188    graph: &mut Graph,
189    out_dir: &Path,
190    out_path: &Path,
191    config: &EpContextDumpConfig,
192    index: usize,
193    part: &EpContextPartition,
194) -> Result<(), LoaderError> {
195    let covered: HashSet<NodeId> = part.covered_nodes.iter().copied().collect();
196    if covered.is_empty() {
197        return Err(LoaderError::EpContext(
198            "EPContext dump: partition has no covered nodes".to_string(),
199        ));
200    }
201    let (inputs, outputs) = partition_boundary(graph, &covered);
202
203    // Build the ep_cache_context payload (inline bytes or a relative sidecar
204    // path), writing the external `.bin` next to the output model when needed.
205    let ep_cache_context = if config.is_external() {
206        let rel = sidecar_filename(out_path, index, part.source, part.partition_name);
207        let sidecar = out_dir.join(&rel);
208        std::fs::write(&sidecar, part.blob).map_err(|source| LoaderError::Io {
209            path: sidecar,
210            source,
211        })?;
212        Attribute::String(rel.into_bytes())
213    } else {
214        Attribute::String(part.blob.to_vec())
215    };
216
217    // Remove the compiled subgraph. Interior values (consumed only within the
218    // partition) are GC'd; boundary values survive (graph I/O or consumed
219    // outside), their producer cleared — the new node re-produces them.
220    for &nid in part.covered_nodes {
221        graph.remove_node(nid);
222    }
223
224    let mut node = Node::new(NodeId(0), EP_CONTEXT_OP, inputs, outputs);
225    node.domain = MS_DOMAIN.to_string();
226    let attrs = &mut node.attributes;
227    attrs.insert(
228        attr::MAIN_CONTEXT.to_string(),
229        Attribute::Int(part.main_context as i64),
230    );
231    attrs.insert(
232        attr::EMBED_MODE.to_string(),
233        Attribute::Int(if config.is_external() { 0 } else { 1 }),
234    );
235    attrs.insert(
236        attr::SOURCE.to_string(),
237        Attribute::String(part.source.as_bytes().to_vec()),
238    );
239    if !part.ep_sdk_version.is_empty() {
240        attrs.insert(
241            attr::EP_SDK_VERSION.to_string(),
242            Attribute::String(part.ep_sdk_version.as_bytes().to_vec()),
243        );
244    }
245    if !part.partition_name.is_empty() {
246        attrs.insert(
247            attr::PARTITION_NAME.to_string(),
248            Attribute::String(part.partition_name.as_bytes().to_vec()),
249        );
250    }
251    attrs.insert(attr::EP_CACHE_CONTEXT.to_string(), ep_cache_context);
252
253    graph.insert_node(node);
254    Ok(())
255}
256
257/// Compute a partition's boundary tensors (§55.4): the variadic inputs/outputs
258/// the replacement EPContext node must carry.
259///
260/// * **Inputs** — values a covered node consumes that are produced *outside* the
261///   partition (or are graph inputs / initializers / sources). Positional order
262///   and skipped-optional (`None`) slots are preserved.
263/// * **Outputs** — values a covered node produces that are graph outputs *or*
264///   consumed by a node outside the partition.
265///
266/// Both are deduped preserving first-seen order, iterating covered nodes in
267/// ascending [`NodeId`] for deterministic output.
268///
269/// # Boundary-ordering ABI (compiler-integration seam)
270///
271/// The variadic input/output order of the emitted EPContext node is reconstructed
272/// here purely from **ascending `NodeId`** over the covered set. This is
273/// deterministic and correct for the current caller, but node-id order is **not**
274/// an explicit, versioned ABI — a future compiler that emits its own canonical
275/// boundary ordering (or relies on a specific i/o slot layout) must not silently
276/// assume this ordering. If/when compiler integration lands, make the intended
277/// boundary order an explicit contract between the partitioner and this seam
278/// rather than depending on `NodeId` allocation order.
279fn partition_boundary(
280    graph: &Graph,
281    covered: &HashSet<NodeId>,
282) -> (Vec<Option<ValueId>>, Vec<ValueId>) {
283    let mut ordered: Vec<NodeId> = covered.iter().copied().collect();
284    ordered.sort_by_key(|n| n.0);
285
286    let mut inputs: Vec<Option<ValueId>> = Vec::new();
287    let mut seen_in: HashSet<ValueId> = HashSet::new();
288    let mut outputs: Vec<ValueId> = Vec::new();
289    let mut seen_out: HashSet<ValueId> = HashSet::new();
290    let graph_outputs: HashSet<ValueId> = graph.outputs.iter().copied().collect();
291
292    for nid in &ordered {
293        let node = graph.node(*nid);
294        for slot in &node.inputs {
295            let Some(vid) = *slot else { continue };
296            let external = match graph.value(vid).producer {
297                Some(prod) => !covered.contains(&prod),
298                None => true, // graph input / initializer / source
299            };
300            if external && seen_in.insert(vid) {
301                inputs.push(Some(vid));
302            }
303        }
304    }
305    for nid in &ordered {
306        let node = graph.node(*nid);
307        for &vid in &node.outputs {
308            let escapes = graph_outputs.contains(&vid)
309                || graph
310                    .value(vid)
311                    .consumers
312                    .iter()
313                    .any(|c| !covered.contains(c));
314            if escapes && seen_out.insert(vid) {
315                outputs.push(vid);
316            }
317        }
318    }
319    (inputs, outputs)
320}
321
322/// The output context-model path: `config.file_path`, else `<orig_stem>_ctx.onnx`
323/// beside `orig_path`.
324fn resolve_output_path(orig_path: &Path, config: &EpContextDumpConfig) -> PathBuf {
325    if let Some(p) = &config.file_path {
326        return p.clone();
327    }
328    let dir = orig_path
329        .parent()
330        .filter(|p| !p.as_os_str().is_empty())
331        .map(Path::to_path_buf)
332        .unwrap_or_else(|| PathBuf::from("."));
333    let stem = orig_path
334        .file_stem()
335        .and_then(|s| s.to_str())
336        .unwrap_or("model");
337    dir.join(format!("{stem}_ctx.onnx"))
338}
339
340/// Sidecar `.bin` filename for an external blob (§55.4):
341/// `<ctx_stem>_p{index}_<source>_<partition>.bin`, with `source`/`partition`
342/// sanitised to filesystem-safe characters. Returned as a bare filename
343/// (relative to the model dir, per the §55.3 external-path policy).
344///
345/// The `index` (the partition's position within this dump call) is an
346/// **injective** disambiguator: [`sanitize_component`] is non-injective (it maps
347/// every disallowed char to `_`), so two partitions with different identities —
348/// e.g. sources `Vendor/EP` and `Vendor_EP` — sanitise to the same components
349/// and would otherwise collide onto one file, silently overwriting each other's
350/// blob. Prefixing the distinct partition index guarantees a distinct filename
351/// per partition, so every EPContext node stores the path of *its own* blob.
352fn sidecar_filename(out_path: &Path, index: usize, source: &str, partition: &str) -> String {
353    let stem = out_path
354        .file_stem()
355        .and_then(|s| s.to_str())
356        .unwrap_or("model");
357    let src = sanitize_component(source);
358    let part = sanitize_component(partition);
359    if part.is_empty() {
360        format!("{stem}_p{index}_{src}.bin")
361    } else {
362        format!("{stem}_p{index}_{src}_{part}.bin")
363    }
364}
365
366/// Replace any character that is not alphanumeric, `-`, `_`, or `.` with `_`,
367/// so an EP-supplied `source`/`partition` never yields a path separator or an
368/// otherwise unsafe filename component.
369fn sanitize_component(s: &str) -> String {
370    s.chars()
371        .map(|c| {
372            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
373                c
374            } else {
375                '_'
376            }
377        })
378        .collect()
379}