Skip to content

Commit ef2db0b

Browse files
fix: thread ep parameter through to WinMLSession (#404)
## Summary - The `--ep` flag was silently dropped in the model construction path: `WinMLAutoModel` received it but never forwarded it to `WinMLPreTrainedModel` → `WinMLSession` - Added `ep` parameter to `WinMLPreTrainedModel.__init__()` and forwarded it to `WinMLSession` - Passed `ep` at all three `winml_class(...)` construction sites in `WinMLAutoModel` (`from_pretrained`, `from_onnx` skip-build path, `from_onnx` build path) ### Default-path behavior change Previously, when no `--ep` was set, `_build_session_options` always fell through to `set_provider_selection_policy(...)` (PREFER_NPU/GPU/CPU) and let ORT pick the EP. This PR also changes that path: for non-CPU devices, it now first calls `_find_ep_for_device` and, if a match is found, uses `add_provider_for_devices` directly — bypassing the device policy. This is intentional: it gives us explicit control over which physical device is used (matching what we already do for the `--ep` path) and avoids WinML EP registration issues that the `providers=` string-based path cannot handle. Fixes #402 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 52d312b commit ef2db0b

6 files changed

Lines changed: 80 additions & 25 deletions

File tree

src/winml/modelkit/commands/perf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -920,7 +920,7 @@ def _run_onnx_benchmark(
920920
"""
921921
from ..session import WinMLSession
922922

923-
session = WinMLSession(onnx_path=onnx_path, device=device)
923+
session = WinMLSession(onnx_path=onnx_path, device=device, ep=config.ep)
924924

925925
# Generate random inputs from session's I/O config
926926
io_cfg = session.io_config

src/winml/modelkit/models/auto.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def from_onnx(
192192
config=None,
193193
device=device,
194194
session_options=session_options,
195+
ep=ep,
195196
)
196197

197198
# Resolve output directory
@@ -228,6 +229,7 @@ def from_onnx(
228229
config=None, # No HF PretrainedConfig for bare ONNX builds
229230
device=device,
230231
session_options=session_options,
232+
ep=ep,
231233
)
232234

233235
@classmethod
@@ -425,6 +427,7 @@ def from_pretrained(
425427
onnx_path=onnx_path,
426428
config=hf_config, # HF PretrainedConfig for pipeline compatibility
427429
device=device, # pass user's original device string; WinMLSession handles "auto"
430+
ep=resolved_ep,
428431
)
429432
model._build_config = config # resolved build config (task, quant, compile)
430433
return model

src/winml/modelkit/models/winml/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __init__(
6565
config: PretrainedConfig | None = None,
6666
device: str = "auto",
6767
session_options: Any | None = None,
68+
ep: str | None = None,
6869
) -> None:
6970
"""Initialize inference model.
7071
@@ -73,6 +74,7 @@ def __init__(
7374
config: HuggingFace PretrainedConfig (num_labels, id2label, etc.)
7475
device: Target device ("auto", "npu", "gpu", "cpu")
7576
session_options: ORT SessionOptions (e.g., for graph_optimization_level)
77+
ep: Explicit EP short name (e.g., "dml", "qnn"). Forwarded to WinMLSession.
7678
"""
7779
self._onnx_path = Path(onnx_path)
7880
self.config = config
@@ -86,6 +88,7 @@ def __init__(
8688
onnx_path=self._onnx_path,
8789
device=device,
8890
session_options=session_options,
91+
ep=ep,
8992
)
9093

9194
@property

src/winml/modelkit/session/session.py

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -287,10 +287,16 @@ def compile(self) -> None:
287287
logger.warning("ModelCompiler failed, using original: %s", e)
288288

289289
try:
290-
# Create InferenceSession
290+
# Create InferenceSession.
291+
# EP is either configured via add_provider_for_devices (WinML EP
292+
# registry, e.g. QNN) or left to ORT's device policy (fallback).
293+
# Never pass providers= — WinML-registered EPs don't support it.
291294
sess_options = self._build_session_options(target_device)
292295
with _suppress_native_output(compile_log):
293-
session = ort.InferenceSession(str(model_path), sess_options=sess_options)
296+
session = ort.InferenceSession(
297+
str(model_path),
298+
sess_options=sess_options,
299+
)
294300

295301
# Log which providers were selected by ORT (based on policy)
296302
actual_providers = session.get_providers()
@@ -415,34 +421,45 @@ def _is_verbose(self) -> bool:
415421
def _build_session_options(self, device: str) -> ort.SessionOptions:
416422
"""Build ORT SessionOptions from instance session_options and device.
417423
418-
When ``self._ep`` is set, uses ``add_provider_for_devices`` to
419-
explicitly bind a specific EP (e.g., MIGraphX, NvTensorRTRTX). Otherwise
420-
falls back to policy-based selection via DEVICE_POLICY_MAP.
424+
When ``self._ep`` is set (and not ``"cpu"``), uses
425+
``add_provider_for_devices`` to explicitly bind that EP.
426+
``"cpu"`` falls through to policy-based selection so ORT handles
427+
CPU-only inference without any EP registration.
428+
When ``self._ep`` is not set, queries ``get_ep_devices()`` to
429+
discover an available EP for the target device type. Falls back to
430+
policy-based selection only as a last resort.
421431
422432
Note: Returns a **fresh** SessionOptions when using explicit EP to
423433
avoid "already registered" errors from repeated calls.
424434
"""
425435
# Explicit EP targeting: create fresh opts to avoid double-registration
436+
# Don't filter by device type — trust the user's --ep choice
437+
# (e.g., QNN reports as NPU in get_ep_devices but can target GPU)
426438
if self._ep and self._ep != "cpu":
427439
target_name = self._EP_NAME_MAP.get(self._ep)
428440
if target_name:
429441
matched = self._find_ep_device(target_name)
430442
if matched:
431443
opts = ort.SessionOptions()
432444
opts.add_provider_for_devices([matched], self._provider_options)
433-
logger.info(
434-
"Explicit EP: %s (%s)",
435-
self._ep,
436-
target_name,
437-
)
445+
logger.info("Explicit EP: %s (%s)", self._ep, target_name)
438446
return opts
439447
logger.warning(
440-
"EP '%s' (%s) not found in available devices; falling back to policy",
448+
"EP '%s' (%s) not found in available devices",
441449
self._ep,
442450
target_name,
443451
)
444452

445-
# Policy-based selection (default path)
453+
# No explicit EP — discover available EP for this device type
454+
if not self._ep and device.lower() != "cpu":
455+
matched = self._find_ep_for_device(device)
456+
if matched:
457+
opts = ort.SessionOptions()
458+
opts.add_provider_for_devices([matched], self._provider_options)
459+
logger.info("Discovered EP for %s: %s", device, matched.ep_name)
460+
return opts
461+
462+
# Policy-based selection (last resort)
446463
opts = self._session_options
447464
policy = DEVICE_POLICY_MAP.get(
448465
device.lower(), ort.OrtExecutionProviderDevicePolicy.PREFER_NPU
@@ -453,16 +470,45 @@ def _build_session_options(self, device: str) -> ort.SessionOptions:
453470

454471
@staticmethod
455472
def _find_ep_device(ep_name: str) -> Any:
456-
"""Find an OrtEpDevice matching the given EP name.
473+
"""Find the first OrtEpDevice matching the given EP name.
474+
475+
Args:
476+
ep_name: Full EP name (e.g., "DmlExecutionProvider").
457477
458478
Returns:
459-
The first matching OrtEpDevice, or None if not found.
479+
The matching OrtEpDevice, or None if not found.
460480
"""
461481
for ep_dev in ort.get_ep_devices():
462482
if ep_dev.ep_name == ep_name:
463483
return ep_dev
464484
return None
465485

486+
@staticmethod
487+
def _find_ep_for_device(device: str) -> Any:
488+
"""Find the first available OrtEpDevice for the given device type.
489+
490+
Queries ``ort.get_ep_devices()`` and returns the first EP whose
491+
hardware device type matches (e.g., device="gpu" matches GPU EPs).
492+
493+
Note: Selection order is determined by the ORT EP registry, which is
494+
not part of any documented contract. On systems where multiple EPs
495+
match the same device type (e.g., QNN and DML both appear as GPU),
496+
the result is registry-order dependent. When a specific EP is
497+
required, use ``self._ep`` to bypass this discovery path entirely.
498+
499+
Returns:
500+
The matching OrtEpDevice, or None if not found.
501+
"""
502+
from ..utils.constants import DEVICE_TO_DEVICE_TYPE
503+
504+
device_type = DEVICE_TO_DEVICE_TYPE.get(device.upper())
505+
if device_type is None:
506+
return None
507+
for ep_dev in ort.get_ep_devices():
508+
if ep_dev.device.type == device_type:
509+
return ep_dev
510+
return None
511+
466512
def _validate_inputs(self, inputs: dict[str, Any]) -> None:
467513
"""Validate inputs against model expectations.
468514

src/winml/modelkit/sysinfo/device.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@
4141
# AMD
4242
"MIGraphXExecutionProvider": "gpu",
4343
"VitisAIExecutionProvider": "npu",
44-
# Qualcomm
45-
"QNNExecutionProvider": "npu",
44+
# Qualcomm (QNN supports both NPU and GPU via Adreno backend)
45+
"QNNExecutionProvider": "npu/gpu",
4646
# Microsoft
4747
"DmlExecutionProvider": "gpu",
4848
# Intel
@@ -51,11 +51,11 @@
5151
"CPUExecutionProvider": "cpu",
5252
}
5353

54-
# Derived inverse mapping (excludes multi-device EPs like OpenVINO)
54+
# Derived inverse mapping (multi-device EPs are included in each device)
5555
_DEVICE_EP_MAP: dict[str, list[str]] = {}
5656
for _ep, _device in _EP_DEVICE_MAP.items():
57-
if "/" not in _device:
58-
_DEVICE_EP_MAP.setdefault(_device, []).append(_ep)
57+
for _d in _device.split("/"):
58+
_DEVICE_EP_MAP.setdefault(_d, []).append(_ep)
5959

6060
# Valid explicit device values
6161
_VALID_DEVICES = frozenset({"npu", "gpu", "cpu"})

tests/unit/sysinfo/test_device.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,13 @@ def test_ep_device_map_values_are_lowercase(self) -> None:
136136
for ep, device in _EP_DEVICE_MAP.items():
137137
assert device == device.lower(), f"{ep} maps to non-lowercase '{device}'"
138138

139-
def test_device_ep_map_excludes_openvino(self) -> None:
140-
"""_DEVICE_EP_MAP should not contain OpenVINO entries."""
141-
all_eps = [ep for eps in _DEVICE_EP_MAP.values() for ep in eps]
142-
assert "OpenVINOExecutionProvider" not in all_eps
139+
def test_device_ep_map_includes_multi_device_eps(self) -> None:
140+
"""Multi-device EPs (QNN, OpenVINO) should appear in each device."""
141+
assert "QNNExecutionProvider" in _DEVICE_EP_MAP["npu"]
142+
assert "QNNExecutionProvider" in _DEVICE_EP_MAP["gpu"]
143+
assert "OpenVINOExecutionProvider" in _DEVICE_EP_MAP["npu"]
144+
assert "OpenVINOExecutionProvider" in _DEVICE_EP_MAP["gpu"]
145+
assert "OpenVINOExecutionProvider" in _DEVICE_EP_MAP["cpu"]
143146

144147
def test_device_ep_map_derived_from_ep_device_map(self) -> None:
145148
"""_DEVICE_EP_MAP should be consistent with _EP_DEVICE_MAP."""
@@ -148,7 +151,7 @@ def test_device_ep_map_derived_from_ep_device_map(self) -> None:
148151
assert ep in _EP_DEVICE_MAP, (
149152
f"EP '{ep}' in _DEVICE_EP_MAP but not in _EP_DEVICE_MAP"
150153
)
151-
assert _EP_DEVICE_MAP[ep] == device
154+
assert device in _EP_DEVICE_MAP[ep].split("/")
152155

153156
def test_nv_tensorrt_rtx_is_gpu_ep(self) -> None:
154157
"""NvTensorRTRTXExecutionProvider should map to gpu."""

0 commit comments

Comments
 (0)