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}