Skip to content

feat(genai): honor --ep/--device as an EP override for GenaiSession (#1025)#1046

Open
DingmaomaoBJTU wants to merge 6 commits into
mainfrom
dingmaomaobjtu-genai-session-ep-device-override
Open

feat(genai): honor --ep/--device as an EP override for GenaiSession (#1025)#1046
DingmaomaoBJTU wants to merge 6 commits into
mainfrom
dingmaomaobjtu-genai-session-ep-device-override

Conversation

@DingmaomaoBJTU

Copy link
Copy Markdown
Collaborator

Summary

Closes #1025.

Previously GenaiSession's ep argument (and winml perf --runtime winml-genai's --ep/--device) was informational only: EP registration and pre-compilation were decided purely from the bundle's genai_config.json per-stage session_options. This PR adds an explicit override path so callers can force EP routing that differs from the bundle config, with precedence explicit arg > bundle config.

What changed

GenaiSession

  • ep default is now None (was "cpu"), and the "mixed" sentinel is retired.
    • ep=Nonerespect the bundle config unchanged (registration + compile derive from genai_config.json exactly as before).
    • ep=<concrete EP>force the whole decoder pipeline onto that EP by rewriting every stage's provider_options:
      • cpu strips hardware providers (CPU fallback; no WinML EP registration/compile).
      • a hardware EP routes every stage to it (registers the WinML EPs it needs; pre-compiles when compile=True). A stage's existing options are carried over only when it already targeted the same canonical EP.
  • Registration and compile gates now derive from the effective (post-override) config via the existing generic _bundle_uses_hardware_ep — no hardcoded EP short-name → behavior map.
  • Because og.Config.clear_providers/append_provider can only touch the top-level provider (not per-stage session_options), an override loads og.Model from a derived _compiled/ bundle. _prepare_compiled_bundle now writes that derived bundle for override-only passes too (not just compilation).
  • Invalid ep values raise ValueError (validated against the shared EP union), so an override can never silently become a no-op.

perf --runtime winml-genai

  • Precedence: explicit --ep > concrete --device > respect config. Removed ep from _GENAI_IGNORED_FLAGS (it is now honored).
  • device_to_genai_ep returns EPNameOrAlias | Noneauto (and any unrecognized device) maps to None (respect config), replacing the old auto → "mixed".
  • Reporting (benchmark_info.ep / device line) shows the resolved override alias, or "config" when respecting the bundle.

Design notes / compatibility

  • The rewrite and validation are generic — EPs are compared/aliased through the shared EP union (normalize_ep_name / EP_NAME_TO_ALIAS / EP_NAMES); the only literal provider name is the universal cpu fallback.
  • The always-safe override direction is cpu. Forcing a hardware EP onto stages the bundle did not build for it (e.g. embeddings/lm_head) may fall back to JIT/CPU inside onnxruntime-genai, which is documented in the ep docstring.

…1025)

Previously GenaiSession's `ep` arg was informational only: EP registration and pre-compilation were decided purely from the bundle's genai_config.json per-stage session_options. This adds an explicit override path with precedence explicit arg > bundle config.

GenaiSession(ep=None) (new default, replacing `cpu` and retiring the `mixed` sentinel) respects the config unchanged. A concrete ep forces the whole decoder pipeline onto that EP by rewriting every stage's provider_options: `cpu` strips hardware providers (no WinML EP registration/compile), a hardware EP routes every stage to it (registering EPs, compiling when requested). Registration and compile gates now derive from the effective post-override config. Because og.Config cannot override per-stage session_options, an override loads og.Model from a derived _compiled/ bundle.

perf --runtime winml-genai now applies precedence explicit --ep > concrete --device > respect config; device_to_genai_ep maps auto/unknown to None; reporting shows the resolved override alias or `config`.

The rewrite/validation is generic (shared EP union, no hardcoded EP short-name behavior map). Adds 40+ tests (137 pass across the two affected modules).
@DingmaomaoBJTU DingmaomaoBJTU requested a review from a team as a code owner July 3, 2026 09:31
# the whole decoder pipeline onto one EP override: "cpu" strips the hardware
# providers (CPU fallback, no WinML EP registration), "qnn"/"dml" route every
# stage to that accelerator. An unknown device also falls back to ``None``.
_DEVICE_TO_GENAI_EP: dict[str, EPNameOrAlias | None] = {

@xieofxie xieofxie Jul 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we could update config, we don't need this mapping. We should always update to what user inputs and follow the device ep resolution.

But before this, I think we need a way to identify if it should be default.

Currently device=auto + ep=None means resolving the best capable EP + device for onnx files.

Need a discussion?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like device=follow + ep=None means follow

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — agree the hardcoded device → ep map (npu→qnn, gpu→dml) shouldn't live here. Since we now rewrite genai_config.json, --device cpu/npu/gpu should go through the same resolve_device/resolve_eps path the ONNX runtime uses, so it picks the best available EP for the device on this machine instead of a static guess (e.g. an NPU that's VitisAI/OpenVINO rather than QNN). I'll switch to that.

The genai-specific wrinkle behind the "what's default" question: a genai bundle is mixed by design — embeddings/lm_head on CPU, ctx/iter on NPU — and genai_config.json already encodes that per-stage routing. Any concrete --device forces the whole pipeline onto one EP, which is a deliberate override, not the common case (forcing everything onto one EP measured ~12x slower than respecting the config in my local run). So I think "respect the bundle config" has to stay the default.

Proposal, to make that explicit like you suggested:

  • --device config (= default, ep=None) → respect genai_config.json as-is.
  • --device cpu|npu|gpu → resolve the best available EP for that device via resolve_eps, rewrite every stage onto it.
  • --ep <name> → explicit EP override, wins over --device.

Two calls I'd like from you:

  1. Name for the "respect config" sentinel — config or follow?
  2. What should --device auto mean for genai? I lean toward it aliasing config (respect the bundle), since auto-resolving one EP and forcing the whole pipeline is usually wrong for a mixed genai bundle. Or do you want auto = "resolve best single EP" to match the ONNX path, with config/follow as the default instead?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. look like default config is best. but it will diverge from onnx. Should we use different default for different runtime? look like acceptable

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — went with config as the default for winml-genai, and yes it intentionally diverges from ONNX (which stays auto). A genai bundle is mixed per-stage by design, and forcing one EP across the whole pipeline measured ~12× slower than respecting the config in my local run, so "respect the bundle" has to be the genai default. Different default per runtime, as you said — acceptable.

Implemented in 07ef0ec:

  • Dropped the hardcoded _DEVICE_TO_GENAI_EP map. --device auto|npu|gpu|cpu now go through the same resolve_device/resolve_eps the ONNX path uses, so they pick the best EP actually available for that device (e.g. an NPU that's VitisAI/OpenVINO, not a static npu→qnn).
  • --device config = respect genai_config.json as-is. It's the genai default (omitting --device maps to it), can be passed explicitly, and the winml runtime rejects it with a clear error.
  • --device auto now matches ONNX: best device → best available EP, forced across the whole pipeline.
  • Precedence unchanged: --ep > --device > config.

On your earlier point ("if we could update config, we don't need this mapping — always update the config"): that's exactly how the override already works — it rewrites genai_config.json in a derived _compiled/ bundle rather than passing EP flags at load, so there's no separate mapping layer. config just means "don't rewrite". I kept the name config over follow since it reads as "respect the config file".

github-actions Bot added 5 commits July 6, 2026 11:33
The derived-bundle EPContext cache was keyed only on the pipeline stage
name ({stage}_ctx.onnx) and freshness compared provider_options alone.
The new ep override can route the same stage onto different
EPContext-capable providers (QNN/OpenVINO/VitisAI) across runs; when a
non-native EP is forced both runs present empty provider_options, so a
binary compiled for EP-A could be reused for EP-B (wrong-accelerator
load or silent misroute).

Encode the EP in the artifact name ({stage}_{ep}_ctx.onnx) so each EP
keeps a distinct cache, and additionally require the recorded EP to
match in the freshness check. Add regression tests for per-EP artifact
naming and cross-EP cache non-reuse.
…session-ep-device-override

# Conflicts:
#	src/winml/modelkit/session/genai_session.py
Replace the hardcoded _DEVICE_TO_GENAI_EP map (npu->qnn, gpu->dml) with
resolve_genai_ep(), which reuses the same resolve_device/resolve_eps path
the WinML ONNX runtime uses so a concrete --device picks the best EP
actually available for that device on this machine (e.g. an NPU that is
VitisAI/OpenVINO rather than QNN) instead of a static short-name guess.

Add a 'config' sentinel (winml-genai default) meaning 'respect the
bundle's genai_config.json per-stage routing': omitting --device maps to
it, --device config selects it explicitly, and the winml runtime rejects
it. --device auto now matches ONNX (best device -> best EP, whole
pipeline). Precedence unchanged: --ep > --device > config.
…session-ep-device-override

# Conflicts:
#	tests/unit/session/test_genai_session.py
Add two e2e tiers to test_perf_e2e.py for the genai EP-override path:

- TestPerfGenaiContract (no bundle): asserts perf --help advertises the
  'config' device sentinel and that --device config is rejected on the
  ONNX runtime with a genai-only usage error (exit 2).
- TestPerfGenai (slow, network): builds a tiny Qwen3-0.6B int4 CPU genai
  bundle once per class via the onnxruntime-genai model builder, then
  benchmarks the four device/ep resolutions -- default and --device config
  respect the bundle (device=ep=config), --ep cpu overrides the EP only,
  and --device cpu overrides both device and EP. Skips gracefully when the
  LLM stack is absent or the model build fails.
except subprocess.TimeoutExpired:
pytest.skip("onnxruntime-genai model build timed out")

if proc.returncode != 0 or not (out / "genai_config.json").exists():
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GenaiSession: support --ep/--device to override genai_config.json EP routing

3 participants