Skip to content

Commit 170f703

Browse files
Key EPContext cache by compile identity (#1295)
## Summary - replace direct `WinMLSession` mtime-only EPContext reuse with a strict compile identity - compile cache misses into immutable UUID generations selected by an atomic marker - serialize same-identity compilation across threads/processes while isolating different identities - hash source ONNX/external-data bytes, EP DLL, hardware, effective options, embed mode, ORT version, and generated artifacts - publish stable CLI outputs atomically with content-addressed binary references while retaining the legacy stable binary alias ## Problem `WinMLSession.compile()` previously reused `<model>_<device>_ctx.onnx` whenever it was newer than the source ONNX. The cache key omitted the EP/source/version, hardware, provider/session options, embed mode, ORT version, external-data sidecars, and context binary integrity. Different configurations could therefore reuse or overwrite one shared EPContext artifact. ## Behavior - legacy or malformed caches without an identity marker rebuild once - exact identities reuse an immutable generation - source/external data, EP DLL, hardware, provider/session options, embed mode, or ORT changes produce a different cache namespace - source changes observed while waiting or compiling are retried and never published under a stale identity - custom opaque `SessionOptions` and uncertain source identity use unique non-cacheable generations - marker/artifact failures fail closed without discarding a successfully compiled generation - missing, modified, or timestamp-preserving substituted context binaries invalidate reuse - compiler finalization consumes the exact session generation and never scans stale siblings after raw fallback - public ONNX output remains stable, references an immutable content-addressed binary, and still publishes the documented stable binary alias ## Validation - affected unit suites: 125 passed, 6 existing skips - focused cache/concurrency suite: 16 passed - Ruff check and format check on all changed Python files - mypy on all changed production modules - real QNN/NPU keen_hominy compile: - first compile produced one identity marker and immutable generation - second compile logged `Using cached EPContext` - second compile reduced total time from about 13.0s to 1.75s - public `model_qnn_ctx.onnx` references `model_qnn_ctx_qnn.<sha256>.bin` - legacy `model_qnn_ctx_qnn.bin` alias remains present with matching size `uv lock --offline --check` could not resolve uncached Linux `onnx` metadata on this ARM64 host. The lockfile TOML parses and the added unit test verifies that every direct project dependency is represented in both the editable package dependencies and `requires-dist` metadata; CI runs on the supported AMD64 environment. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent e564a63 commit 170f703

17 files changed

Lines changed: 2379 additions & 107 deletions

File tree

docs/commands/compile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ eliminating graph partitioning at load time. An optional post-compilation
4343
validation pass runs a forward pass through the
4444
target EP; skip it with `--no-validate` when the target hardware is absent.
4545

46+
If a provider option names a compiler input file, list its key in
47+
`compile.provider_option_file_keys` in the JSON config. The CLI canonicalizes
48+
that option path and fingerprints its contents for EPContext cache identity;
49+
other provider-option strings are always passed through unchanged.
50+
4651
## Examples
4752

4853
```bash

docs/reference/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ Set to `null` to skip compilation.
122122
| `ep_config.embed_context` | `bool` | `false` | Embed binary in ONNX (true) or external .bin (false). |
123123
| `ep_config.compiler` | `str` | `"ort"` | Compiler backend: `ort` or `qairt`. |
124124
| `ep_config.provider_options` | `dict` | `{}` | EP-specific options. |
125+
| `ep_config.provider_option_file_keys` | `list[str]` | `[]` | Keys in `provider_options` whose values are input files. Declared paths are canonicalized and content-fingerprinted for EPContext cache identity. |
125126
| `ep_config.qnn_sdk_root` | `str \| null` | `null` | QNN SDK path for QAIRT compiler backend. |
126127
| `validate` | `bool` | `true` | Validate compiled model. |
127128
| `verbose` | `bool` | `false` | Verbose compilation logging. |

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
"diffusers>=0.36",
3838
"evaluate>=0.4.6",
3939
"fastapi>=0.135.3",
40+
"filelock>=3.20",
4041
"hf_xet>=1.1.10",
4142
"httpx>=0.24.0",
4243
"jsonschema>=4.23",

src/winml/modelkit/commands/compile.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ def compile(
183183
# Apply build config defaults (CLI explicit options take precedence).
184184
# Read raw JSON so missing keys are distinguishable from dataclass defaults.
185185
config_provider_options: dict[str, str] = {}
186+
config_provider_option_file_keys: set[str] = set()
186187
if config_file is not None:
187188
try:
188189
build_cfg, raw_cfg = cli_utils.load_build_config(config_file)
@@ -197,6 +198,8 @@ def compile(
197198
# EP provider options (e.g. QNN htp_arch/soc_model/vtcm_mb) for the compile session.
198199
if "provider_options" in cc:
199200
config_provider_options = dict(cc["provider_options"])
201+
if "provider_option_file_keys" in cc:
202+
config_provider_option_file_keys = set(cc["provider_option_file_keys"])
200203
if not cli_utils.is_cli_provided(ctx, "device"):
201204
if configured_target is not None:
202205
device = configured_target.device
@@ -309,6 +312,8 @@ def compile(
309312
# for duplicate keys.
310313
if config_provider_options:
311314
config.ep_config.provider_options.update(config_provider_options)
315+
if config_provider_option_file_keys:
316+
config.ep_config.provider_option_file_keys.update(config_provider_option_file_keys)
312317
if cli_provider_options:
313318
config.ep_config.provider_options.update(cli_provider_options)
314319

src/winml/modelkit/compiler/configs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class EPConfig:
3636
Attributes:
3737
provider: Target execution provider (qnn, cpu, cuda, dml)
3838
provider_options: EP-specific options as key=value dict
39+
provider_option_file_keys: Provider option keys whose values are file paths
3940
enable_ep_context: Generate EPContext model with pre-compiled graph
4041
embed_context: Embed context in ONNX (True) or external .bin file (False)
4142
compiler: Compiler backend ("ort", "ort_session", or "qairt").
@@ -51,6 +52,7 @@ class EPConfig:
5152
compiler: CompilerName = "ort"
5253
qnn_sdk_root: Path | None = None
5354
device: str = "auto"
55+
provider_option_file_keys: set[str] = field(default_factory=set)
5456

5557

5658
@dataclass
@@ -274,18 +276,21 @@ def for_vitisai(cls, device: str | None = None) -> WinMLCompileConfig:
274276
from pathlib import Path as _Path
275277

276278
provider_options: dict[str, str] = {}
279+
provider_option_file_keys: set[str] = set()
277280
ryzen_ai = os.environ.get("RYZEN_AI_INSTALLATION_PATH")
278281
if ryzen_ai:
279282
xclbin = _Path(ryzen_ai) / "voe-4.0-win_amd64" / "xclbins" / "phoenix" / "4x4.xclbin"
280283
if xclbin.exists():
281284
provider_options["target"] = "X1"
282285
provider_options["xclbin"] = str(xclbin)
286+
provider_option_file_keys.add("xclbin")
283287
provider_options["xlnx_enable_py3_round"] = "0"
284288
ep_cfg = EPConfig(
285289
provider="vitisai",
286290
enable_ep_context=True,
287291
provider_options=provider_options,
288292
device=device or "auto",
293+
provider_option_file_keys=provider_option_file_keys,
289294
)
290295
return cls(ep_config=ep_cfg)
291296

@@ -305,6 +310,7 @@ def to_dict(self) -> dict[str, Any]:
305310
return {
306311
"execution_provider": self.ep_config.provider,
307312
"provider_options": self.ep_config.provider_options,
313+
"provider_option_file_keys": sorted(self.ep_config.provider_option_file_keys),
308314
"enable_ep_context": self.ep_config.enable_ep_context,
309315
"embed_context": self.ep_config.embed_context,
310316
"compiler": self.ep_config.compiler,
@@ -324,6 +330,7 @@ def from_dict(cls, data: dict[str, Any]) -> WinMLCompileConfig:
324330
ep_config = EPConfig(
325331
provider=data.get("execution_provider"),
326332
provider_options=data.get("provider_options", {}),
333+
provider_option_file_keys=set(data.get("provider_option_file_keys", [])),
327334
enable_ep_context=data.get("enable_ep_context", True),
328335
embed_context=data.get("embed_context", False),
329336
compiler=data.get("compiler", "ort"),

src/winml/modelkit/compiler/stages/compile.py

Lines changed: 125 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66

77
from __future__ import annotations
88

9+
import hashlib
10+
import os
911
import shutil
1012
import tempfile
13+
import threading
1114
import time
1215
from pathlib import Path
1316
from typing import TYPE_CHECKING, ClassVar, cast
1417

1518
import numpy as np
19+
from filelock import FileLock
1620
from onnx import AttributeProto
1721

1822
from ...onnx import load_onnx, save_onnx
@@ -31,6 +35,7 @@
3135

3236
if TYPE_CHECKING:
3337
import onnxruntime as ort
38+
from onnx import ModelProto
3439

3540
from ...utils.constants import EPAlias
3641
from ..context import CompileContext
@@ -42,6 +47,16 @@
4247
"qairt": WinMLQairtSession,
4348
}
4449

50+
_FINALIZE_THREAD_LOCKS_GUARD = threading.Lock()
51+
_FINALIZE_THREAD_LOCKS: dict[Path, threading.Lock] = {}
52+
53+
54+
def _finalize_thread_lock(lock_path: Path) -> threading.Lock:
55+
"""Return the process-local lock paired with one public output path."""
56+
resolved = lock_path.resolve(strict=False)
57+
with _FINALIZE_THREAD_LOCKS_GUARD:
58+
return _FINALIZE_THREAD_LOCKS.setdefault(resolved, threading.Lock())
59+
4560

4661
class CompileStage(BaseStage):
4762
"""Compile model."""
@@ -109,6 +124,7 @@ def _compile_single_model(self, context: CompileContext) -> None:
109124
)
110125
try:
111126
winml_session.compile()
127+
running_model_path = winml_session.running_model_path
112128

113129
session = winml_session._session
114130
context.session = session
@@ -117,18 +133,22 @@ def _compile_single_model(self, context: CompileContext) -> None:
117133
if context.validate:
118134
self._validate_model(session, context)
119135
self._collect_model_info(session, context)
136+
137+
if ep_config.enable_ep_context:
138+
if running_model_path == model_path:
139+
context.add_warning(f"No EPContext produced for {model_path.name}")
140+
return
141+
self._finalize_output(
142+
context,
143+
model_path,
144+
output_dir,
145+
device=ep_device.device.device_type.lower(),
146+
src_ctx_path=running_model_path,
147+
)
120148
finally:
121149
context.session = None
122150
winml_session.reset()
123151

124-
if ep_config.enable_ep_context:
125-
self._finalize_output(
126-
context,
127-
model_path,
128-
output_dir,
129-
device=ep_device.device.device_type.lower(),
130-
)
131-
132152
def _compile_shared_context(self, context: CompileContext) -> None:
133153
"""Compile through shared SessionOptions for multi-model and ORT-session flows."""
134154
import onnxruntime as ort
@@ -306,6 +326,7 @@ def _finalize_output(
306326
output_dir: Path,
307327
*,
308328
device: str | None = None,
329+
src_ctx_path: Path | None = None,
309330
) -> None:
310331
"""Find EPContext files and copy to output directory.
311332
@@ -343,11 +364,11 @@ def _finalize_output(
343364
]
344365
)
345366

346-
src_ctx_path = None
347-
for pattern in ctx_patterns:
348-
if pattern.exists():
349-
src_ctx_path = pattern
350-
break
367+
if src_ctx_path is None:
368+
for pattern in ctx_patterns:
369+
if pattern.exists():
370+
src_ctx_path = pattern
371+
break
351372

352373
if src_ctx_path is None:
353374
context.add_warning("EPContext model not found in work directory")
@@ -362,7 +383,23 @@ def _finalize_output(
362383
else:
363384
final_ctx_path = output_dir / f"{original_stem}_{output_suffix}_ctx.onnx"
364385

365-
# Ensure output directory exists
386+
publish_lock = final_ctx_path.with_name(f"{final_ctx_path.name}.publish.lock")
387+
with _finalize_thread_lock(publish_lock), FileLock(publish_lock):
388+
self._publish_finalized_output(
389+
context,
390+
src_ctx_path,
391+
final_ctx_path,
392+
output_dir,
393+
)
394+
395+
def _publish_finalized_output(
396+
self,
397+
context: CompileContext,
398+
src_ctx_path: Path,
399+
final_ctx_path: Path,
400+
output_dir: Path,
401+
) -> None:
402+
"""Publish a self-consistent EPContext bundle while holding its output lock."""
366403
output_dir.mkdir(parents=True, exist_ok=True)
367404

368405
# Validate every external context reference before publishing the final
@@ -396,7 +433,7 @@ def _finalize_output(
396433

397434
source_root = src_ctx_path.parent.resolve()
398435
output_root = output_dir.resolve()
399-
binary_exports: list[tuple[Path, Path, bytes, list[AttributeProto]]] = []
436+
binary_exports: list[tuple[Path, Path, Path, bytes, list[AttributeProto]]] = []
400437
sources_by_final_binary: dict[Path, Path] = {}
401438
for raw_ref, cache_attrs in external_refs.items():
402439
try:
@@ -428,51 +465,57 @@ def _finalize_output(
428465
suffix = relative_ref.name[len(src_ctx_path.stem) :]
429466
final_relative_ref = relative_ref.with_name(f"{final_ctx_path.stem}{suffix}")
430467

431-
final_binary = (output_root / final_relative_ref).resolve()
468+
stable_binary = (output_root / final_relative_ref).resolve()
432469
try:
433-
final_binary.relative_to(output_root)
470+
stable_binary.relative_to(output_root)
434471
except ValueError as exc:
435472
raise ValueError(f"unsafe EPContext binary reference: {cache_ref!r}") from exc
436473

437-
existing_source = sources_by_final_binary.get(final_binary)
474+
existing_source = sources_by_final_binary.get(stable_binary)
438475
if existing_source is not None and existing_source != source_binary:
439476
raise ValueError(
440477
"Distinct EPContext binaries map to the same output path: "
441-
f"{existing_source}, {source_binary} -> {final_binary}"
478+
f"{existing_source}, {source_binary} -> {stable_binary}"
442479
)
443-
sources_by_final_binary[final_binary] = source_binary
480+
sources_by_final_binary[stable_binary] = source_binary
481+
content_token = self._file_sha256(source_binary)[:16]
482+
unique_relative_ref = final_relative_ref.with_name(
483+
f"{final_relative_ref.stem}.{content_token}{final_relative_ref.suffix}"
484+
)
485+
unique_binary = (output_root / unique_relative_ref).resolve()
486+
try:
487+
unique_binary.relative_to(output_root)
488+
except ValueError as exc:
489+
raise ValueError(f"unsafe EPContext binary reference: {cache_ref!r}") from exc
444490
binary_exports.append(
445491
(
446492
source_binary,
447-
final_binary,
448-
final_relative_ref.as_posix().encode("utf-8"),
493+
unique_binary,
494+
stable_binary,
495+
unique_relative_ref.as_posix().encode("utf-8"),
449496
cache_attrs,
450497
)
451498
)
452499

453-
cache_refs_updated = False
454500
first_final_binary: Path | None = None
455-
for source_binary, final_binary, final_ref_bytes, cache_attrs in binary_exports:
456-
final_binary.parent.mkdir(parents=True, exist_ok=True)
457-
if source_binary != final_binary:
458-
shutil.copy2(source_binary, final_binary)
459-
context.log(f"Copied binary to: {final_binary}")
501+
for (
502+
source_binary,
503+
unique_binary,
504+
stable_binary,
505+
final_ref_bytes,
506+
cache_attrs,
507+
) in binary_exports:
508+
self._atomic_copy(source_binary, unique_binary)
509+
self._atomic_copy(source_binary, stable_binary)
510+
context.log(f"Published binary generation: {unique_binary}")
460511
if first_final_binary is None:
461-
first_final_binary = final_binary
512+
first_final_binary = unique_binary
462513

463514
for cache_attr in cache_attrs:
464-
if cache_attr.s != final_ref_bytes:
465-
cache_attr.s = final_ref_bytes
466-
cache_refs_updated = True
467-
468-
if cache_refs_updated:
469-
save_onnx(model, final_ctx_path)
470-
context.log("Updated external EPContext binary references")
471-
elif src_ctx_path != final_ctx_path:
472-
shutil.copy2(src_ctx_path, final_ctx_path)
473-
context.log(f"Copied EPContext to: {final_ctx_path}")
474-
else:
475-
context.log(f"EPContext already at: {final_ctx_path}")
515+
cache_attr.s = final_ref_bytes
516+
517+
self._atomic_save_onnx(model, final_ctx_path)
518+
context.log(f"Published EPContext: {final_ctx_path}")
476519

477520
context.output_path = final_ctx_path
478521
context.context_binary_path = first_final_binary
@@ -483,9 +526,49 @@ def _finalize_output(
483526
src_schematic = src_ctx_path.parent / schematic_name
484527
final_schematic = output_dir / schematic_name
485528
if src_schematic.is_file() and src_schematic != final_schematic:
486-
shutil.copy2(src_schematic, final_schematic)
529+
self._atomic_copy(src_schematic, final_schematic)
487530
context.log(f"Copied schematic to: {final_schematic}")
488531

532+
@staticmethod
533+
def _file_sha256(path: Path) -> str:
534+
digest = hashlib.sha256()
535+
with path.open("rb") as source_file:
536+
for chunk in iter(lambda: source_file.read(1024 * 1024), b""):
537+
digest.update(chunk)
538+
return digest.hexdigest()
539+
540+
@staticmethod
541+
def _atomic_copy(source: Path, destination: Path) -> None:
542+
"""Copy one file through a same-directory temporary and atomic replace."""
543+
if source.resolve() == destination.resolve(strict=False):
544+
return
545+
destination.parent.mkdir(parents=True, exist_ok=True)
546+
fd, temporary_name = tempfile.mkstemp(
547+
prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent
548+
)
549+
os.close(fd)
550+
temporary_path = Path(temporary_name)
551+
try:
552+
shutil.copy2(source, temporary_path)
553+
temporary_path.replace(destination)
554+
finally:
555+
temporary_path.unlink(missing_ok=True)
556+
557+
@staticmethod
558+
def _atomic_save_onnx(model: ModelProto, destination: Path) -> None:
559+
"""Save an ONNX model beside its destination and atomically replace it."""
560+
fd, temporary_name = tempfile.mkstemp(
561+
prefix=f".{destination.stem}.", suffix=destination.suffix, dir=destination.parent
562+
)
563+
os.close(fd)
564+
temporary_path = Path(temporary_name)
565+
temporary_path.unlink(missing_ok=True)
566+
try:
567+
save_onnx(model, temporary_path)
568+
temporary_path.replace(destination)
569+
finally:
570+
temporary_path.unlink(missing_ok=True)
571+
489572
def _collect_model_info(self, session: ort.InferenceSession, context: CompileContext) -> None:
490573
"""Collect model input/output information."""
491574
input_shapes = {}

0 commit comments

Comments
 (0)