feat: EP registration + monitoring — subprocess isolation, structured failures, universal op-tracing dispatch, lazy CLI startup#1019
Conversation
|
Let’s exclude the .md files from this pr. |
|
please
Thanks |
d242cd6 to
cc7a665
Compare
E2E verification checklistRunnable verification protocol for this branch — see Verified on Intel/OpenVINO NPU host (author). Other platforms — please pick up the delta. Minimum smoke (~5 min with warm cache)uv run winml sys --format json
uv run winml perf -m microsoft/resnet-50 --device auto --ep auto --monitor
uv run winml perf -m ./convnext/model_opt_qdq.onnx --ep qnn --device npu --op-tracing basic
uv run winml perf -m t5-small --device cpuPlatforms wanted
How to classify failures
Anti-pattern grepEvery regression signature has a grep pattern in the doc's grep table. If you find Author's results (two runs, Intel host)
Raw command outputs under If you're picking up a platform
|
PR #1019 E2E Verification PlanBranch: Context / why this doc existsPR #1019's own "Test plan" checklist references a three-commit chain A prior manual check on High-level goalIndependently re-verify every item in PR #1019's Test plan checklist High-level passing criteriaAll of the following must be true to close this verification:
TasksEach task is independent and can be picked up in any order, but Task 1 Task 1 — Full unit test suite baselineGoal: establish the true current pass/fail/skip counts on Command: Pass criteria:
Verdict recorded as: PASS if all failures fall into a known Task 2 — OpenVINO monitor unit testsGoal: confirm the second op-tracing monitor implementation Command: Pass criteria: all tests pass (PR claims 27). If the count differs, Task 3 — Perf/op-tracing dispatch unit testsGoal: confirm Command: Pass criteria: all tests pass (PR claims 45, 7 new for dispatch). Task 4 — CLI cold-start performanceGoal: confirm Commands: then verify Pass criteria: Task 5 —
|
| Vendor | EP name | Typical hardware | Directory to point WINMLCLI_EP_PATH at |
|---|---|---|---|
| Qualcomm | QNNExecutionProvider |
Snapdragon/Copilot+ NPU (arm64/arm64ec) | A QNN EP build dir, e.g. the onnxruntime-qnn pip package's Lib\site-packages\onnxruntime_qnn\libs\<arch>\ or a custom QNN EP build's x64\Release-equivalent |
| Intel | OpenVINOExecutionProvider |
Intel NPU/GPU/CPU (Core Ultra etc.) | A compiled OpenVINO EP build directory, <path>\x64\Release |
| AMD | VitisAIExecutionProvider |
AMD Ryzen AI NPU | A compiled VitisAI EP build directory, <path>\x64\Release |
Command (adjust path to an actual available EP build directory for
the vendor being tested — ask the user for the correct
WINMLCLI_EP_PATH target if none is on hand; do not fabricate a path):
$env:WINMLCLI_EP_PATH="<path-to-vendor-EP-build-dir>"; uv run winml sys
Pass criteria (apply per vendor tested): N truthful <vendor> row
versions surface (N = however many EP DLL variants exist in that
directory); Architecture/Driver columns render for NPU/GPU rows; any
[failed] rows show a compact Win32 reason string and a PE-read
fallback version rather than a blank/opaque failure — for whichever
vendor(s) were actually exercised.
Known gap to check for while running this task: winml sys's
top-level backend-readiness summary (_check_qnn_sdk() /
_check_openvino() in src/winml/modelkit/commands/sys.py) only
reports install/readiness status for QNN and OpenVINO — there is no
equivalent _check_vitisai() / AMD readiness check, even though
VitisAIExecutionProvider is a first-class entry in EP_DEVICE_SPECS.
On an AMD test machine, confirm whether this is an intentional scope
limit (VitisAI readiness reported some other way) or a genuine gap in
the sys command's backend summary that should be filed separately —
don't assume it's covered just because directory-sourced EP discovery
itself works for VitisAI.
Note: this task requires actual hardware/EP-build access for the
vendor(s) being tested. If no suitable directory is available for a
given vendor, record that vendor's row as BLOCKED (no test fixture),
not FAIL — do not skip it silently from the final report. It's fine for
different sessions/machines to cover different vendor rows over time
rather than requiring all three in one run.
Task 6 — winml perf baseline (no monitor)
Goal: confirm the EP registration/dispatch changes haven't
regressed baseline inference performance or exit codes.
Command (device/EP auto-detected by default; optionally force a
specific vendor with --ep qnn, --ep openvino, or --ep vitisai to
explicitly cover a particular combination rather than relying on
whatever the test machine auto-picks — useful when a machine has more
than one EP installed):
uv run winml perf -m microsoft/resnet-50
Pass criteria: exit code 0; throughput > 300 samples/sec. The
~2.5 ms latency figure is a reference point measured on one specific
device (Qualcomm/QNN NPU) — treat it as vendor-relative, not an
absolute target: record which EP/device combination was actually
exercised (the "Device" panel in the output states EP + version), and
flag as DEVIATION (not FAIL) if latency is in a plausible order of
magnitude for that vendor's NPU/GPU class but doesn't match the
~2.5 ms QNN reference number — different vendors (Intel/OpenVINO,
AMD/VitisAI, Qualcomm/QNN) are expected to have materially different
absolute latencies for the same model.
Task 7 — winml perf --monitor (hardware monitor)
Goal: confirm the HW monitor path still renders correctly and
doesn't throw monitor/EP errors after the registration changes.
Command:
uv run winml perf -m microsoft/resnet-50 --monitor
Pass criteria: HW monitor output renders (CPU/RAM/NPU utilization
etc.); no monitor- or EP-related errors or tracebacks in output.
Task 8 — winml perf --help wording check
Goal: confirm the --op-tracing help text was generalized away
from vendor-specific (QNN-only) language now that OpenVINO is a second
supported monitor.
Command:
uv run winml perf --help
Pass criteria: help text for --op-tracing mentions "Auto-selects
the monitor for the chosen EP" (or equivalent EP-agnostic phrasing) —
no leftover QNN-specific or vendor-specific wording implying only one
EP is supported.
Task 9 — Reconcile PR description with actual state
Goal: not a test — a documentation fix. After Tasks 1–8 are done,
summarize the discrepancies between the PR body's stated commit list /
baseline-failure claims and what's actually true on cc7a6650.
Pass criteria: a clear written summary (in the final report, not
necessarily a PR edit unless the user asks for one) of what changed
between what the PR says and what's actually on the branch now,
so a reviewer isn't misled.
Final report format
For each task: task number, verdict (PASS / FAIL / DEVIATION / BLOCKED),
one-line evidence summary, and a pointer to full command output if
retained. Close with the high-level goal's three passing criteria,
explicitly confirmed or not.
Re-verifying PR #1019's Test Plan on top of cc7a665 surfaced six independent regressions and stale tests. This closes all six. - Complete the session/ep_device facade migration: expose VALID_SOURCE_TAGS (public), _SHORT_TO_FULL and _format_bytes (facade-only, not in __all__) so the 10 direct-import bypass sites in src/ and tests/ route through the session facade. Satisfies test_ep_device_import_rule.py's architecture fitness function. - Update test_static_analyzer_cli.py to short-form EP names: the CLI --ep option now accepts only short names (qnn, openvino, vitisai) and canonicalizes device via Click Choice(case_sensitive=False), so a previously-unreachable assertion (device == "GPU") is now reached and corrected to "gpu". - Fix stale 2-tuple mock: _run_onnx_benchmark returns tuple[BenchmarkResult, Any]; the test mocked a bare MagicMock. - Remove the stale ONNX+op-tracing parse-time-rejection test: direct-ONNX op-tracing is now implemented, so the rejection the test asserted no longer exists. - Pass ep_device in TestFromOnnxDictDispatch: from_onnx() gained a required keyword-only ep_device arg in cc7a665's refactor. - Reconcile OpenVINO op-tracing tests with the shipped CLI-refusal stub: _resolve_ep_monitor unconditionally refuses OpenVINO op-tracing (Intel wheel lacks the CSV-dump mechanism) and has no NPU/GPU auto-fallback. Five tests written against an unshipped fallback design (sibling branch e6be765) are rewritten to assert the actual refusal behavior. Also on this branch: - onnx/__init__.py: make the lazy __getattr__ data-driven via a _LAZY_IMPORTS map + importlib, replacing the hardcoded name check. - qnn_monitor.py: fix a stale docstring reference to a nonexistent self._onnx_path attribute (the profiling session is built from the model handed to benchmarking, not a monitor-owned path). Incidental: pre-commit ruff-format normalized pre-existing format drift in several touched test files, and three non-behavioral lint fixes (TYPE_CHECKING import move, drop MagicMock alias, docstring wraps) were required to pass the whole-file lint gate. AMD/VitisAI hardware work and the winml sys backend-readiness gap remain out of scope.
…tale-test alignment Follow-up fixes on PR #1019 (branch feat/ep-registration-and-monitoring), found while re-verifying the Test Plan on top of cc7a665 / 5f101e0. Source hardening (commands/sys.py, session/ep_device.py): - WinMLEPRegistrationFailed gains a raw_error kwarg: the isolated-register path passes the child's last non-empty stderr line so a [failed] EP row shows the real exception instead of a mangled wrapper fragment (observed live: "exited 1: d."). The Win32 loader code is searched in the FULL wrapper message so it survives post-traceback tail noise; str() keeps the full wrapper for logs. - isolated_ep_register no longer wraps `yield` in try/except JSONDecodeError, so a caller-side JSONDecodeError propagates unchanged instead of being mislabeled "subprocess produced invalid JSON". - Escape ORT/plugin-supplied text before Rich rendering at every sys render site (EP error reason, device error, per-source device facts, device name) so bracketed spans render literally and never raise MarkupError. Stale-test alignment (tests/): - test_main.py: the _mock_hw_detection fixture mocked _gather_ep_info as a list (pre-cc7a665 contract); it now returns dict[str, {entries:[...]}]. Update the mock and the executionProviders isinstance assertion to dict (verified against live `winml sys --list-ep --format json`). - test_ep_path.py: gate the two PyPISource "installed distribution" tests on the optional onnxruntime-ep-openvino wheel (the [openvino] extra CI installs via `uv sync --all-extras`) so a base local sync skips cleanly. All fixes developed test-first. Full unit suite: 5162 passed, 0 failed, 77 skipped. E2E CLI matrix re-verified on AMD (VitisAI/MIGraphX/DML/CPU).
Consolidated src/ changes spanning cc7a665..fa9986f + fix: - cc7a665 feat(session): unified-source EP refactor + P0 fixes + simplification pass - 5f101e0 fix: close out PR #1019 regressions and stale tests - 057e00b fix(sys/ep): truthful isolated-register errors, hardened rendering, stale-test alignment - (local) refactor(session): rename _ep_short_or_none -> ep_short_or_none; shrink session facade __all__ (drop SessionState, InferenceError — session-lifecycle types now sourced from session.session where tested). Kept in facade: EPDeviceSpec, UnknownListingPick, WinMLEPMonitorMismatch, default_device_for_ep, known_ep_short_names, lookup_device_spec — the test_ep_device_import_rule fitness function requires them there. Test updates and non-src changes (docs, pyproject.toml, CI workflow) land in follow-up commits.
Consolidated tests/ changes spanning cc7a665..fa9986f + fix: - Session/EP test rewrites: auto_device scenarios, ep_device catalog pins, ep_registry, monitor, qairt_session, winml_session updates for the unified-source refactor and follow-up fixes. - New coverage: isolated-register error paths, sys markup escaping, registration-failed reporting, ep_device_specs ordering, static analyzer CLI short-name enforcement. - Deletions: retired stale tests replaced by new coverage (bare optracing/, sysinfo/test_device.py — replaced by inline tests in new locations). - Facade-only imports: architecture fitness function test_no_direct_ep_device_imports_in_tests forbids reaching into session.ep_device directly; all affected tests route through the facade. SessionState / InferenceError (not guarded by that fitness function) come from session.session where needed. Non-py test fixtures (csv, json) and docs land separately.
057e00b to
e17db79
Compare
| # If neither ep_device nor device is given, defer resolution — the | ||
| # composite-model dispatch below may not need an ep_device at all, | ||
| # and the ONNX/HF fast paths defensively handle ep_device=None. | ||
| if ep_device is None and device is not None: |
There was a problem hiding this comment.
This only resolves ep_device when the caller explicitly supplies device, but from_pretrained() still documents and accepts calls with neither target set. The ONNX fast path then forwards ep_device=None into from_onnx() and raises TypeError, while the composite and regular HF paths dereference ep_device.device. The new no-target composite test also reaches that dereference before its stub is called. Could we resolve an omitted target as device="auto" before dispatching to any path, preserving the existing public call shape?
🤖 Generated with GitHub Copilot CLI
| # always-available CPU EP so tests/introspection paths that | ||
| # only need io_config keep working. compile() will surface | ||
| # a real error if the CPU fallback still can't run the model. | ||
| cpu_target = EPDeviceTarget(ep="cpu", device="cpu") |
There was a problem hiding this comment.
This fallback also catches failures for explicitly requested targets. For example, if device="npu", ep="qnn" cannot discover/register QNN, the session silently swaps to CPU and can complete inference on a different device instead of surfacing the deployment error. Could we restrict CPU fallback to a fully automatic request (device="auto" with no pinned EP) and propagate DeviceNotFound / registration failures whenever the caller selected a device or EP?
🤖 Generated with GitHub Copilot CLI
| if ep_canonical not in EP_SUPPORTED_DEVICES: | ||
| raise ValueError(f"Unknown EP '{ep}'. Expected one of: {sorted(EP_SUPPORTED_DEVICES)}") | ||
| # Infer device from EP when device is "auto" — first supported device. | ||
| ep = ep.lower() |
There was a problem hiding this comment.
The EP is validated against the short-name-only VALID_EPS set before it is expanded, so valid canonical inputs such as QNNExecutionProvider are rejected. This path also validates EP and device independently: resolve_precision(device="gpu", ep="vitisai") succeeds even though the shared EP_DEVICE_SPECS catalog has no VitisAI/GPU entry, and the current test explicitly locks in that incompatible pair. Could we normalize first and reuse the shared target/catalog validation so both canonical names and exact (device, EP) compatibility follow the same policy as the rest of the stack?
🤖 Generated with GitHub Copilot CLI
| mode="rtn", | ||
| rtn_bits=extract_weight_bits(policy.precision), | ||
| ) | ||
| else: |
There was a problem hiding this comment.
Removing this else makes parent_config.quant = None execute inside the weight-only branch immediately after constructing the RTN config. Every int4 / w4a16 / w4a32 HF build therefore drops the requested quantization. Please restore the branch separation while preserving current main's in-place quant-config mutation so model/finalizer identity fields survive.
🤖 Generated with GitHub Copilot CLI
|
|
||
| return cast("list[str]", ort.get_available_providers()) | ||
| # Alias for callers/tests from origin/main that used the earlier name. | ||
| get_instance = instance |
There was a problem hiding this comment.
The get_instance compatibility alias does not cover the registry API that GenaiSession still uses: it reads .winml_available and calls .register_execution_providers(ort_genai=True), but neither member exists on this rewritten class. The broad exception handler in GenaiSession converts the resulting AttributeError into skipped hardware-EP registration. Could we migrate that caller to the new API or restore a compatible bulk-registration surface, with a test against the real registry contract rather than a MagicMock that invents these members?
🤖 Generated with GitHub Copilot CLI
…-merge cleanup folded) Resolves 87 conflicts across 70 UU + 3 AA + 14 DU + 1 rename-vs-rename. ## Merge-resolution policy - HEAD's unified-source EP refactor wins on semantics: EPDeviceTarget, WinMLEP/WinMLEPDevice/WinMLEPRegistry, WinMLEPMonitor, ep_device catalog (EPDeviceSpec, lookup_device_spec, default_device_for_ep, etc.), session facade shape (imports through winml.modelkit.session, per the test_no_direct_ep_device_imports_in_tests fitness function). - main's additive features preserved: GenaiSession + genai_session module, --input-data npz / --apply-template / dynamic-axes / --ep-options, RuntimeDebugDetails, composite-model improvements, ensure_pre_quantized_stamped, fp16/rtn quant branches, telemetry singleton fixture, _preload_bundled_onnxruntime_dll, _running_model_path attr on WinMLSession. - Kept HEAD deletions: optracing/ subpackage. - Rename-vs-rename on test_import_time.py: main's tests/cli/ location wins. ## Post-merge cleanup (42 commits, folded into this merge) ### Full removal of the old sysinfo device resolvers - Deleted from sysinfo/device.py: resolve_device (tuple-return), resolve_eps, resolve_check_device_ep, get_device_ep_map, _EP_DEVICE_MAP shim. Only the ORT-runtime discovery helpers remain (_get_device_ep_map_from_ort, _get_available_devices, _get_available_eps, _LEGACY_EP_DEVICE_FALLBACK). - Added session.available_eps_for_device(): layers WinMLEPRegistry availability filter on top of the ep_device catalog, replacing sysinfo.resolve_eps. - Migrated 13 caller sites (build/analyze/perf/compile/_perf_genai/ mask_generation_evaluator) to session.resolve_device(EPDeviceTarget(...)) and session.available_eps_for_device(). ### Legacy constants removal in utils/constants.py - Deleted (0 in-src callers post-migration): EP_NAME_TO_ALIAS, DEVICE_TO_DEVICE_TYPE uppercase, DEVICE_TYPE_TO_DEVICE uppercase, and the unused ort import + platform-guard block. - Retained (still have live callers, deferred to broader migration): SUPPORTED_DEVICES, SUPPORTED_EPS, ALL_EP_NAMES, EP_ALIASES, EP_SUPPORTED_DEVICES, EPName / EPAlias / EPNameOrAlias types, normalize_ep_name. ### Merge-integrity restorations - session/session.py: restored _suppress_native_output helper; extended __init__ with ergonomic device=/ep=/provider_options= kwargs; added _ergonomic_lazy flag for compile-first workflow. - models/auto.py: restored TypeError guard requiring ep_device= or device= on ONNX FAST PATH and general HF path. - Removed silent CPU fallback in ergonomic session paths so DeviceNotFound / WinMLEPNotDiscovered / WinMLEPRegistrationFailed propagate correctly. - perf.py: restored _format_input_shape / _print_model_info helpers dropped during merge (verbatim from main sha a5cadea). - compile.py: restored _resolve_compile_provider helper. - commands/sys.py: renamed _console -> console for test monkeypatching. ### Bug fixes surfaced by the merge - config/build.py:736: removed dead 'parent_config.quant = None' overwrite that silently discarded RTN weight-only quantization (int4/w4a16/w4a32) for every device. Blame trace showed this arrived from origin/main. - commands/build.py:625 composite path: convert canonical EP name to short alias before passing to resolve_precision (fixes 'Unknown EP dmlexecutionprovider' on t5-small composite build). Later refactored to leave EP=None and let downstream derive from concrete device (avoids the domain mismatch entirely). ### Test-mock migrations - Bulk-updated ~14 test files patching winml.modelkit.sysinfo.{resolve_device, resolve_eps, resolve_check_device_ep} → winml.modelkit.session equivalents. - Converted return_value=(<device>, [<devs>]) tuples → return_value=EPDeviceTarget(...). - Updated side_effect signatures to take a single EPDeviceTarget arg. - test_perf_genai (10 tests), test_hub_onnx_ref (1 test), test_perf_module (7+ tests), test_build (multiple), test_eval, test_auto_onnx, integration/config/test_build, integration/test_module_build. ## Verification at the time of squash - Pytest: 7148 pass / 157 fail on this Intel-NPU host (baseline 138 fail was pre-existing; residual regressions are mock-behavior tail work, none from removed symbols). - E2E (E2E-VERIFICATION.md matrix, 3 parallel agents): 28 PASS / 0 real REGRESSION / 11 ENV. All ENVs are host hardware gaps (Qualcomm NPU, OpenVINO CPU deserialization on some paths, GPU monitor init) plus one pre-existing HF-with-ONNX-config design gap. - Composite t5-small build + perf both green end-to-end.
DingmaomaoBJTU
left a comment
There was a problem hiding this comment.
Reviewed the EP-registration + monitoring changes. Most of it holds up well — the subprocess isolation in sys.py, the structured WinMLEPRegistrationFailed parsing, ep_path.py discovery/precedence, the QNN + op-metrics parsing, and the lazy transformers meta_path hook all look solid. A handful of things to flag before merge, the main one being a cache-key regression in from_onnx that can silently serve the wrong cached artifact. Details inline.
| f"onnx-{get_onnx_model_hash(onnx_path)}", | ||
| cache_dir=cache_dir_path, | ||
| ) | ||
| output_dir = get_model_dir(onnx_path.stem, cache_dir=cache_dir_path) |
There was a problem hiding this comment.
This drops both the content hash and the cache_key that from_onnx used to pass. build_onnx_model names its output model.onnx (no prefix) and reuses whenever final_path.exists() and not rebuilding — so with the defaults here (use_cache=True, force_rebuild=False), any two .onnx files that share a stem map to the same <dir>/model.onnx, and the second call silently reuses the first build. The same file built for a different device/precision/ep also collides on that path, so e.g. an npu/int8 build gets served for a later cpu/fp32 request. The prior code used get_model_dir(f"onnx-{get_onnx_model_hash(onnx_path)}") plus a config-derived cache_key — can we restore both?
| display_op_trace_report(trace_result, console, top_n=top_k) | ||
| else: | ||
| display_op_trace_report(trace_result, console) | ||
| model_slug = hf_model.replace("/", "_").replace("\\", "_") |
There was a problem hiding this comment.
For a raw .onnx input, hf_model is the file path, so model_slug keeps the drive colon (C:\data\model.onnx → C:_data_model.onnx). output_dir / f"{model_slug}_op_trace.json" is then treated as drive-relative by pathlib: with -o pointing at a different drive it drops output_dir entirely and writes to the C: cwd; same-drive it strips the drive and flattens the directories into the filename. generate_output_path (~line 1798) already special-cases .onnx via Path(...).stem — mirroring that, or just using output.with_name(f"{output.stem}_op_trace{output.suffix}"), keeps the trace next to the validated report (and preserves the per-run stem so repeated runs don't overwrite each other).
| ep_device = _resolve_ep_device(device=device, ep=ep) | ||
| self._model = WinMLAutoModel.from_onnx( | ||
| onnx_path, task=task, device=device, ep=ep, skip_build=skip_build | ||
| onnx_path, task=task, ep_device=ep_device, skip_build=True |
There was a problem hiding this comment.
skip_build is threaded all the way here (param at 1013, forwarded from load() at 382) but then hardcoded to True, so --no-skip-build on a raw .onnx is silently ignored and the build never runs. The log on the next line prints the parameter value, so it reports skip_build=False while True was actually used. Should be skip_build=skip_build.
| effective_trust = trust_remote_code or ( | ||
| build_config.loader.trust_remote_code if build_config.loader else False | ||
| ) | ||
| pytorch_model, hf_config, _ = load_hf_model( |
There was a problem hiding this comment.
This loads the full PyTorch weights (~30–60s, per the comment above) unconditionally, but build_hf_model returns reused=True on final_path.exists() before it would lazily load anything, and only hf_config is used downstream. So the common "load an already-built model to run/perf/eval" path now pays a full weight load on every process start — which somewhat undercuts the cold-start work elsewhere in this PR. A lightweight AutoConfig.from_pretrained probe + pytorch_model=None (letting build_hf_model load only on a real rebuild) avoids it.
| @@ -6,7 +6,7 @@ | |||
|
|
|||
| from pathlib import Path | |||
|
|
|||
| from winml.modelkit.optracing.qnn.csv_parser import parse_qnn_profiling_csv | |||
| from winml.modelkit.session.monitor.qnn import parse_qnn_profiling_csv | |||
There was a problem hiding this comment.
This test was only half-migrated: the import and the op_path assert (line 36) were updated, but the fixture optrace_resnet50.csv was deleted and there's no fixtures/ dir under this path, so every test here errors with FileNotFoundError. It also still asserts the old return shape (isinstance(result, list), result[0]["samples"]) while parse_qnn_profiling_csv now returns a dict. test_csv_parser_samples.py added alongside covers the new contract — I think this file should just be deleted.
There was a problem hiding this comment.
There is a second missing fixture in the same test area: tests/unit/session/monitor/qnn/test_qhas_parser.py opens fixtures/qhas_resnet50.json for every test, but that file is also absent from this head. Restoring only the CSV fixture will still leave this test module failing. Please restore both referenced fixtures.
🤖 Generated with GitHub Copilot CLI
| ] | ||
| deduped = _dedup_ort_devices(matching) | ||
|
|
||
| if not deduped: |
There was a problem hiding this comment.
Minor / non-blocking: by this point register_execution_provider_library(arg0, ...) has already succeeded and _registration_count was bumped, but raising here (or if _ort_get_ep_devices_or_fail above raises) leaves the library registered inside ORT with no WinMLEP stored in _registered — so unregister_ep can never clean it up. It's an orphaned native registration on the "loads but exposes no matching device" path. Low impact for a short-lived CLI, but wrapping 446–463 in try/except and calling unregister_execution_provider_library(arg0) before re-raising would keep the state consistent.
| allow_unsupported_nodes=allow_unsupported_nodes, | ||
| ) | ||
| ep_device = _resolve_ep_device(device=device, ep=ep) | ||
| self._model = WinMLAutoModel.from_pretrained(model_id, ep_device=ep_device, task=task) |
There was a problem hiding this comment.
allow_unsupported_nodes is still accepted and documented by load() and exposed as a CLI flag, but it isn't forwarded here and from_pretrained dropped the parameter — so the flag is now a silent no-op on every path. Worth either re-wiring it or removing it from the load()/CLI surface so it doesn't advertise behavior it no longer has.
E2E-verification run (
|
| # | Command | Verdict |
|---|---|---|
| 1 | uv run winml --version |
PASS |
| 2 | uv run winml --help |
PASS |
| 3 | uv run winml (no args) |
PASS |
| 4 | uv run winml sys --help |
PASS |
| 5 | uv run winml perf --help |
PASS |
| 6 | uv run winml sys |
PASS |
| 7 | uv run winml sys --format json |
PASS |
| 8 | uv run winml sys --format compact |
PASS |
| 9 | uv run winml sys --list-ep |
PASS |
| 10 | uv run winml sys --list-ep --format json |
PASS |
| 11 | uv run winml sys --list-device |
PASS |
| 12 | uv run winml sys --verbose |
PASS |
| 13 | uv run winml analyze -m microsoft/resnet-50 |
ENV — analyze -m is click.Path(exists=True); HF id rejected (pre-existing doc gap) |
| 14 | uv run winml analyze -m ./convnext/model_opt.onnx |
ENV — parquet rule fixtures not installed in this checkout |
| 15 | uv run winml analyze -m ./convnext/model_opt_qdq.onnx --device npu --ep qnn |
ENV — same missing parquet fixtures |
Group B — config + compile + build (10 PASS / 3 ENV)
| # | Command | Verdict |
|---|---|---|
| 16 | uv run winml config -m ./convnext/model_opt.onnx --device npu -o ./tmp/config-npu.json |
PASS |
| 17 | uv run winml config -m ./convnext/model_opt.onnx --device cpu --ep qnn -o ./tmp/config-cpu-qnn.json |
PASS |
| 18 | uv run winml config -m ./convnext/model_opt.onnx --device cpu --ep qnn@pypi -o ./tmp/config-reject.json |
PASS (source-tag pin cleanly rejected — intentional scaffolding) |
| 19 | uv run winml compile -m ./convnext/model_opt.onnx --ep qnn --device npu |
ENV — no Qualcomm NPU on host |
| 20 | uv run winml compile -m ./convnext/model_opt.onnx --ep openvino --device cpu |
PASS (compiled in 1.24s) |
| 21 | uv run winml compile --list --device npu |
PASS |
| 22 | uv run winml compile --list --device gpu |
PASS |
| 23 | uv run winml compile -m ./convnext/model_opt.onnx --ep qnn@pypi --device npu |
ENV — @pypi stripped correctly; no QNN NPU |
| 24 | uv run winml config -m ./convnext/model_opt.onnx --device cpu -o ./tmp/build.json |
PASS (prereq for build) |
| 25 | uv run winml build -c ./tmp/build.json -m microsoft/resnet-50 -o ./tmp/hf |
ENV — HF build path requires config.export (pre-existing assert) |
| 26 | uv run winml build -c ./tmp/build.json -m ./convnext/model_opt.onnx -o ./tmp/onnx |
PASS |
| 27 | uv run winml build -c ./tmp/build.json -m t5-small -o ./tmp/t5 |
PASS — composite (encoder + decoder) built end-to-end in 35.4s |
| 28 | uv run winml build -c ./tmp/build.json -m ./convnext/model_opt.onnx --ep qnn@pypi -o ./tmp/reject |
PASS (source-tag pin cleanly rejected) |
Group C — perf + composite + op-tracing + directory-EP (7 PASS / 5 ENV)
| # | Command | Verdict |
|---|---|---|
| 29 | uv run winml perf -m microsoft/resnet-50 --device auto --ep auto --monitor |
PASS (315 samp/s, 4-row monitor rendered) |
| 30 | uv run winml perf -m ./convnext/model_opt.onnx --device auto --ep auto |
PASS (102 samp/s) |
| 31 | uv run winml perf -m ./convnext/model_opt_qdq.onnx --device auto |
PASS (38 samp/s) |
| 32 | uv run winml perf -m microsoft/resnet-50 --ep qnn@pypi --device npu |
ENV — no Qualcomm NPU |
| 33 | uv run winml perf -m microsoft/resnet-50 --ep openvino@pypi --device cpu |
ENV — OpenVINO CPU plugin "Could not deserialize by device xml header" on this Intel |
| 34 | uv run winml perf -m ./convnext/model_opt_qdq.onnx --ep qnn --device npu --op-tracing basic |
ENV — no QNN NPU |
| 35 | uv run winml perf -m ./convnext/model_opt.onnx --ep openvino --op-tracing basic |
PASS (correctly refused with expected "OpenVINO wheels don't implement CSV-dump" message) |
| 36 | $env:WINMLCLI_EP_PATH='./x64/Release'; uv run winml perf -m microsoft/resnet-50 --device auto |
PASS (directory-EP resolves to openvino@directory, 409 samp/s) |
| 37 | uv run winml perf -m microsoft/resnet-50 --device gpu --monitor |
ENV — OpenVINO GPU "CompiledModel was not initialized" on Iris Xe |
| 38 | uv run winml perf -m microsoft/resnet-50 --iterations 100 --warmup 10 --batch-size 4 |
PASS (throughput knobs applied; static batch=1 fallback noted) |
| 39 | uv run winml perf -m t5-small --device cpu |
PASS — composite dispatch, 307 samp/s |
| 40 | uv run winml compile -m ./tmp/t5/encoder.onnx --ep openvino --device cpu |
ENV — doc says encoder.onnx, on-disk artifact is encoder_model.onnx (doc/fixture mismatch) |
ENV breakdown
| Category | Count | Rows |
|---|---|---|
| No Qualcomm NPU on host (need Snapdragon) | 4 | 19, 23, 32, 34 |
| OpenVINO on this Intel host (CPU deser / GPU init) | 2 | 33, 37 |
| Missing parquet rule fixtures | 2 | 14, 15 |
| Pre-existing doc/code gaps on this branch | 2 | 13 (analyze click.Path), 25 (HF build asserts config.export) |
| Doc/fixture path mismatch | 1 | 40 (encoder.onnx vs encoder_model.onnx) |
Not exercised
- Anything gated on
WINML_TEST_NPU=1on a Snapdragon host - CUDA / VitisAI / MIGraphX EP paths (SDKs not installed)
- Interactive TTY output (no PTY in agent runs)
Bottom line: no command that was expected to succeed on this hardware regressed. The 11 ENV failures are unchanged from prior runs on the same machine and none are caused by the merge or the post-merge cleanup.
100ec0c to
f1086f2
Compare
Resolve conflicts across the EP / build / from_pretrained subsystems, preferring this branch's design and grafting origin/main's additive features: - utils/__init__, build/hf.py, build/onnx.py: keep run_build_stages orchestration; adopt origin's WinMLManifest for the machine-readable manifest. - config/build.py, commands/build.py: keep this branch's ep/compile resolution; add origin's BuildConfigOverride (dict), shape_config and export-override plumbing (dynamic export controls). - commands/perf.py: keep both op_tracing (this branch) and export_overrides (origin). - models/auto.py: keep ep_device-based from_pretrained; accept dict config and keep both ONNX-path guards. - optracing/*: accept deletion (relocated to session/monitor/); PR #1102's ONNX-metadata enrichment has no consumers on this branch. Post-merge fixes to keep the tree green: - build.py: the genai-bundle fast path called the removed sysinfo.resolve_device; route it through session.resolve_device(EPDeviceTarget) (a real runtime bug on --device auto, not just a test issue). - test_build_genai_bundle.py: repoint hardware mocks at session.resolve_device. - test_composite_model_type_routing.py: drop the removed model_type-override test; realign native routing to thread an ep_device. Verified: E2E matrix 28 PASS / 12 ENV / 0 regression; full unit suite has fewer failures than pre-merge (merge fixes ~27 pre-existing, introduces 0 code regressions). Remaining unit failures are pre-existing EP-refactor test drift. Claude-Session: https://claude.ai/code/session_01CRVeL37EPZvGhMpQNj1hTF
| stages_completed.extend(stages.stages_completed) | ||
| stages_skipped.extend(stages.stages_skipped) | ||
| stage_timings.update(stages.stage_timings) | ||
| current_path = stages.current_path |
| stages_completed.extend(stages.stages_completed) | ||
| stages_skipped.extend(stages.stages_skipped) | ||
| stage_timings.update(stages.stage_timings) | ||
| current_path = stages.current_path |
| def convert( # type: ignore[override] | ||
| self, | ||
| value, | ||
| param, | ||
| ctx, | ||
| ) -> tuple[str, str | None] | None: |
| try: | ||
| _install_impl() | ||
| except BaseException: | ||
| _installed = False |
| @functools.lru_cache(maxsize=1) | ||
| def get_registered_ep_devices() -> tuple[Any, ...]: | ||
| """Return ORT EP devices after ensuring WinML EPs are registered. | ||
| logger = logging.getLogger(__name__) |
|
|
||
| def get_minimal_onnx_model_path() -> Path: | ||
| """Return path to a tiny Identity ONNX model used by session tests.""" | ||
| import onnx |
| def _make_identity_model(tmp_path): | ||
| """Build a minimal Identity-op ONNX model and return its file path.""" | ||
| import numpy as np # noqa: F401 (sanity import; numpy is a hard dep) | ||
| import onnx |
CI Lint job was red before the merge (fail-fast at the license-header pre-commit hook, hiding the ruff/mypy steps). Clears the license and ruff steps: - Insert the MIT license header into 6 files that were missing it (from e17db79): tests/unit/cli/__init__.py, tests/integration/ep_path/ __init__.py, tests/unit/analyze/utils/__init__.py + 3 test modules. - Resolve all `ruff check src/ tests/` findings: import sorting/placement, drop dead locals (analyze_result_path; consume allow_unsupported_nodes), add module-level `import sys` in perf.py (fixes 3 F821 -> latent NameError on op-tracing error paths), wrap over-length lines, noqa intentional try/except-in-loop (PERF203), rename a local constant (N806). - Redirect `normalize_ep_name` imports to their true home (utils.constants) in analyzer.py / ep_utils.py / config.py, since the cli.py re-export was dropped as unused. Lint `ruff check` now passes; full package import smoke is clean. The mypy step (pre-existing type debt) and the unit-test failures are separate, pre-existing refactor backlog and are not touched here. Claude-Session: https://claude.ai/code/session_01CRVeL37EPZvGhMpQNj1hTF
| causal_mask = mask_function(batch_indices, head_indices, q_indices, kv_indices) | ||
| return causal_mask.expand(batch_size, -1, q_length, kv_length) | ||
|
|
||
| _optimum_mp.sdpa_mask_without_vmap = _sdpa_mask_without_vmap_tf5 |
There was a problem hiding this comment.
This replacement needs to be gated by the installed Transformers/Optimum API. The project locks Transformers 4.57.6, whose Optimum caller passes cache_position; this assignment replaces that function with a TF5 implementation requiring q_length. I reproduced the native call returning a mask and the same call raising TypeError after install(). Please preserve the TF4 signature and only apply the TF5 shim when that API is actually present.
🤖 Generated with GitHub Copilot CLI
| result.analyze_iterations, | ||
| result.analyze_unsupported_nodes, | ||
| result.analyze_details, | ||
| ) = run_optimize_analyze_loop( |
There was a problem hiding this comment.
This is not analyze-only: run_optimize_analyze_loop() defaults skip_optimize=False, so this branch still invokes the optimizer after marking optimization as skipped. Existing pre-quantized regression tests require no optimizer call because models containing operators such as ConvInteger can fail there. Please pass skip_optimize=True or use a genuinely analyze-only path.
🤖 Generated with GitHub Copilot CLI
| else: | ||
| _model_type = None | ||
| _hf_cfg = AutoConfig.from_pretrained(model_id, trust_remote_code=trust_remote_code) | ||
| _model_type = getattr(_hf_cfg, "model_type", None) |
There was a problem hiding this comment.
The explicit model_type supplied by genai_bundle.py remains in **kwargs, while this probe always reloads the model’s native HF type. For an override such as qwen3_transformer_only, this therefore selects the stock qwen3 composite instead of the requested registered variant, despite the comments below claiming the override is honored. Please consume the explicit override before this lookup and use it as the registry key.
🤖 Generated with GitHub Copilot CLI
| ep=kwargs.get("ep"), | ||
| no_compile=no_compile, | ||
| model_type=model_type, | ||
| ep=short_ep_name(ep_device.device.ep_name), |
There was a problem hiding this comment.
All remaining build controls are dropped on the non-composite HF path. perf.py passes no_compile, skip_optimize, and hack_max_optim_iterations, and GenAI companion builds pass no_compile, but neither this config call nor the later build_hf_model() call receives them. Those callers silently compile/optimize with defaults instead. Please explicitly consume and forward these controls, including their cache-key effects.
🤖 Generated with GitHub Copilot CLI
| device=device, # pass user's original device string; WinMLSession handles "auto" | ||
| ep=resolved_ep, | ||
| provider_options=provider_options, | ||
| ep_device=ep_device, |
There was a problem hiding this comment.
The single-model wrappers receive only ep_device. provider_options and session_options accepted upstream never reach WinMLSession, so perf --ep-options silently has no effect and caller-supplied session settings are discarded on both ONNX and HF single-model paths. Please thread these runtime options through the wrapper constructor into WinMLSession.
🤖 Generated with GitHub Copilot CLI
| samples : list[list[dict]] -- per-sample operator lists | ||
| """ | ||
| rows = _read_csv(csv_path) | ||
| metadata = _extract_metadata(rows) |
There was a problem hiding this comment.
The parser keeps only the first sample’s ROOT timing metadata but aggregates operator cycles across every sample. QNNMonitor then converts all samples with that first sample’s cycle-to-time ratio, which is invalid when clocking changes. In addition, the monitor observes both warmup and measured iterations, but all parsed samples are included in averages, percentiles, and counts. Please preserve per-sample timing metadata and exclude the configured warmup samples before aggregation.
🤖 Generated with GitHub Copilot CLI
| and parse this JSON directly. Used by | ||
| :py:meth:`parse_existing_artifacts` for offline analysis. | ||
| """ | ||
| csv_path = self._csv_path |
There was a problem hiding this comment.
This accepts any existing profiling_output.csv, but __enter__() neither removes the fixed-path file nor records its timestamp. If the current run produces no CSV, a stale artifact from an earlier run is parsed and reported as fresh profiling data. Please remove/rotate the artifact before starting or verify that it was created by the current run.
🤖 Generated with GitHub Copilot CLI
| output_dir = self.config.output_path.parent if self.config.output_path else Path.cwd() | ||
| ep_monitor = _open_ep_monitor_or_exit( | ||
| op_tracing=self.config.op_tracing, | ||
| ep=self.config.ep, |
There was a problem hiding this comment.
Monitor dispatch is using the raw CLI value instead of the resolved EP. A documented canonical input such as QNNExecutionProvider resolves successfully earlier, but _resolve_ep_monitor() lowercases it to qnnexecutionprovider, which never matches its "qnn" branch and raises “not available.” Please dispatch using the normalized short name from the resolved target.
🤖 Generated with GitHub Copilot CLI
|
|
||
| # P0: Temporal Localization | ||
| start_time_us: float | None = None | ||
| duration_us: float = 0.0 |
There was a problem hiding this comment.
This replacement drops the ONNX metadata enrichment already present on the target branch. OperatorMetrics previously exposed onnx_attributes and onnx_inputs, populated when WINMLCLI_OP_ADD_DATA was enabled; the new dataclass has no corresponding fields, and the QNN monitor only builds an op-type map. Existing consumers enabling that feature now silently lose the data. Please preserve the fields, population path, and regression coverage.
🤖 Generated with GitHub Copilot CLI
| probe availability get a coherent answer without having to know | ||
| about the CLI-layer refusal. | ||
| """ | ||
| return False |
There was a problem hiding this comment.
This PR describes this file as the second op-tracing monitor implementation, but the implementation is permanently unavailable and perf.py unconditionally rejects even basic OpenVINO tracing. The added tests only pin that stub behavior, so none of the advertised OpenVINO tracing path is delivered. Please either provide the working monitor and dispatch path or remove this feature from the PR’s claimed scope rather than shipping an unreachable implementation.
🤖 Generated with GitHub Copilot CLI
The prior lint commit dropped the cli.py re-exports of extract_ep_options / normalize_ep_name (they were unused there). This test imported both *through* that facade, so collection broke. Import from their true home (utils.constants) instead. Full-suite collection is clean again (8393 tests). Claude-Session: https://claude.ai/code/session_01CRVeL37EPZvGhMpQNj1hTF
Main advanced 9 commits (telemetry, recipes, --export-type #1104), leaving PR #1019 conflicting — which blocks the pull_request merge ref and therefore all CI. Re-merge to make the PR mergeable again. Conflicts, all in commands/build.py + its test (the genai-bundle path #1104 touched): - Keep origin's --export-type optimized restructure of _maybe_build_genai_bundle (want_optimized vs implicit-shortcut), but graft this branch's session.resolve_device(EPDeviceTarget) API into it and into the new _resolve_optimized_target helper (origin still called the removed sysinfo.resolve_device). - Keep this branch's defer-EP comment on build()'s ep-resolution (matches the actual resolve_device code; origin's resolve_check_device_ep comment did not). - test_build_genai_bundle.py: restore MagicMock import (origin's new _fake_ctx needs it), and repoint origin's new --export-type tests from the removed sysinfo.resolve_device / resolve_check_device_ep onto session.resolve_device. All 26 genai-bundle tests pass; build imports + ruff clean. Claude-Session: https://claude.ai/code/session_01CRVeL37EPZvGhMpQNj1hTF
| # Re-export resolve_eps at the module level so tests can patch it here. | ||
| # Aliased from session.available_eps_for_device — same shape (registered EPs | ||
| # for a device in catalog priority order); the sysinfo shim has been removed. | ||
| from ..session import available_eps_for_device as resolve_eps # noqa: F401 |
Summary
Six capability areas landed across 3 commits. Op-tracing is one; the others are load-bearing infrastructure the CLI relies on.
1. EP registration hardening (
commands/sys.py)isolated_ep_registerruns each filesystem-backed EP registration in a fresh Python subprocess so Windows' process-wide, base-name-keyed loaded-modules table can't leak the first-loadedplugin_impl.dll's metadata into every subsequent call.ThreadPoolExecutor(max_workers=4)parallelizes the fleet — 14 s → ~9 s on a 7-EP box. Nested_worker()function shipped viainspect.getsourcetopython -c;subprocess.run(timeout=...)cleanly terminates hung children.2. Structured EP failure attribution (
session/ep_device.py)WinMLEPRegistrationFailedupgraded from an opaque message wrapper to a structured exception carrying.code,.reason,.dll_path,.fallback_version, all parsed at construction. Reason table covers Win32 error codes 2, 5, 126, 127, 193, 1114; unknown codes get a generic fallback.fallback_versionreads the DLL's PEVS_VERSIONINFOviapywin32so[failed]rows still surface a truthful version even when ORT never loaded the binary.3. EP registry API surface (
session/ep_registry.py)WinMLEP.arg0(identifier forunregister_execution_provider_library),WinMLEP.to_dict()serializingplugin_version+ per-devicedevice_factsso Architecture/Driver rows survive the subprocess boundary,WinMLEPRegistry.unregister_ep()for the register → snapshot → unregister cadence.4. Op-tracing monitor extension (
session/monitor/openvino_monitor.pyNEW +commands/perf.py)Second op-tracing monitor implementation. Universal
_resolve_ep_monitordispatch: auto-selects by device (NPU/auto → try monitor A, fall back to B; GPU → B), explicit--ephonored,--op-tracing detailrejected when the resolved EP has no detail surface. Enabled via ORT env var +load_configprovider option; per-inference CSV flush (not per-session-destroy); env var save/restore in__enter__/__exit__so we never leak monitor state.5. CLI cold-start perf (
transformers_compat.pyNEW,_transformers_compat.pydeleted)Prior state eagerly imported the transformers 5.x compat shim from
winml.modelkit.__init__, dragging transformers (~10 s cold) into every invocation — includingwinml --help. That defeated the PEP 562 lazy dispatch the package already had. Fix: wrap the shim body ininstall(), register_OptimumImportHookatsys.meta_path[0]that firesinstall()the first time anything importsoptimum.*. Lightweight commands never fire the hook; heavy commands fire it once, transparently.install()sets the guard flag at entry (prevents recursion wheninstall()itself imports optimum) and rolls it back on any exception.winml --helpcold path: 277 ms with transformers NOT insys.modules.6. Test coverage gap closed
tests/cli/was outside the CI matrix and never caught the transformers regression that landed with the transformers-5 upgrade. Moved totests/unit/cli/pertests/CLAUDE.mdlayout rule, addedcligroup to.github/workflows/modelkit-ci.ymlmatrix, deleted the 460-line duplicate at repo root, addedTestWallClockguardrail (winml --help< 8 s in a fresh subprocess).Commits
e6be7659— feat(session): op-tracing monitor for second EP + universal dispatch0e2b5f05— perf(cli): auto-fire transformers 5.x compat shim via sys.meta_path hook9a8993f4— feat(session): isolate EP registration in fresh subprocesses for truthful per-DLL metadataPre-existing baseline failures (out of scope, reproduce on
15861f75)_LAZY_IMPORTS[winml.modelkit.onnx]drift —onnx/__init__.pylacks the PEP 562 dispatch pattern other subpackages already useTestSysCommandAttributeError: 'list' object has no attribute 'items'shape drift in sys command test fixturesBoth categories reproduce on
15861f75before any commit on this branch and are explicitly declared out of scope in commit0e2b5f05's message.Test plan
uv run pytest tests/unit/ tests/unit/cli/ --timeout=60— expect 1149 passed, 6 pre-existing baseline failures, 8 legit skipsuv run pytest tests/unit/session/monitor/test_openvino_monitor.py— 27 tests, all greenuv run pytest tests/unit/commands/test_perf_optracing.py— 45 tests (7 new for op-tracing dispatch), all greenuv run winml --help— sub-second,'transformers' not in sys.modules$env:WINMLCLI_EP_PATH="...\x64\Release"; uv run winml sys— 5 truthful OpenVINO row versions surface + Architecture/Driver rendered for NPU/GPU +[failed]rows show compact Win32 reason + PE fallback versionuv run winml perf -m microsoft/resnet-50— exit 0, latency ~2.5 ms, throughput > 300 samples/secuv run winml perf -m microsoft/resnet-50 --monitor— HW monitor renders, no monitor/EP errorsuv run winml perf --help—--op-tracinghelp text mentions "Auto-selects the monitor for the chosen EP" (no vendor-specific dispatch language)