feat(build): one-command Qwen3 genai bundle via winml build (npu + qnn)#1081
Merged
Conversation
…u --ep qnn` Route registered decoder-LLM families to a full onnxruntime-genai bundle (ctx/iter/embeddings/lm_head + genai_config.json + tokenizer) as a single `winml build` command, gated on `--device npu --ep qnn` so every other target keeps today's stock composite output. A data-only genai-bundle recipe registry carries all model-specific detail; the routing itself stays architecture-agnostic. - add GenaiBundleRecipe registry + build_genai_bundle orchestrator (models/winml/genai_bundle.py) - move the GQA default-attr strip pass into the qwen3 package and reference it from the recipe - wire winml build routing (_maybe_build_genai_bundle), gated on npu+qnn - thin scripts/qwen3.py to delegate to the shared orchestrator - fix WinMLAutoModel.from_pretrained dropping an explicit model_type override: it delegated to the base composite from_pretrained, which re-derived the native model_type and built the stock graph (breaking quantization/calibration). It now resolves the concrete composite class for the overridden model_type. - docs: build.md genai-bundle section + qwen3-genai-bundle sample (build + run on NPU, the --compile requirement, and the native teardown-exit caveat) Verified e2e on a Snapdragon X Elite NPU: `winml build --device npu --ep qnn` produces the bundle; `winml perf --runtime winml-genai --device npu --compile` generates tokens on the QNN HTP.
xieofxie
reviewed
Jul 10, 2026
timenick
reviewed
Jul 10, 2026
…n teardown
QNN on the Hexagon NPU faults during interpreter/driver teardown, after the
EPContext .onnx and its .bin weights are already flushed to disk. Judging
compile success by the subprocess exit code alone discarded that valid work
and fell back to a JIT compile of the original graph, which repeats the same
crashing native path at model-load time (silent native crash before the first
token).
_compile_stage now calls _salvage_epcontext on a non-zero exit: it looks for a
structurally valid EPContext at the canonical output, in the compiled dir, or
under ORT's auto name {stem}_<device>_ctx.onnx next to the source model (where
ORT writes it before copying into the compiled dir), validates it via
is_compiled_onnx, and promotes the graph plus its relocated .bin weights
sidecar to the canonical stage output. A salvaged stage is then treated exactly
like a clean compile (marker written -> cached, config patched). The device
token is wildcard-matched, so no execution provider is hardcoded.
Tests: TestCompileStageSalvage covers source-dir salvage with sidecar
relocation, compiled-dir salvage, canonical-output reuse, and the garbage /
non-EPContext negative cases.
DingmaomaoBJTU
force-pushed
the
dingmaomaobjtu-qwen3-export-cmd
branch
from
July 10, 2026 04:30
4e517a1 to
cfc3e01
Compare
… Qwen3 genai NPU The same-process `winml perf --compile` path can native-crash at model load (before the first token): it compiles the EPContext stages and loads the model for generation in one process, and the crash-prone stage compilation leaves the process fragile. Document the reliable two-step flow -- run `--compile` once to produce `<bundle>/_compiled/`, then run `winml perf -m <bundle>/_compiled` (no `--compile`) in a fresh process, which loads the compiled EPContext graphs directly with no JIT. Root-cause fix (isolating model load into its own process) tracked in #1087.
…eedback Route a registered decoder-LLM family to the onnxruntime-genai bundle whenever --ep qnn is passed explicitly and the target is the NPU -- either --device npu or a --device auto that resolves to the NPU on this system -- rather than requiring an explicit --device npu. Every other target keeps the stock composite build. Address PR #1081 review feedback: - scripts/qwen3.py: map the --device token to an EP alias (cpu->cpu, gpu->dml, npu->qnn) instead of forwarding the bare token as the EP, which made --device gpu fail downstream as an unknown EP. - build.py: reject the single/composite pipeline controls the bundle recipe fixes (--quant/--no-quant, --optimize/--no-optimize, --analyze/--no-analyze, --compile/--no-compile, --max-optim-iterations, --allow-unsupported-nodes) instead of silently letting a supplied flag become a no-op. - genai_bundle.py: replace the hardcoded-operator node summary with a generic top-N op-type histogram, and drop the unused module logger. - tests: de-duplicate the onnx import in the orchestrator test. Add coverage: --device auto routing, --no-quant rejection, and the script device->EP mapping (including the GPU regression).
Narrow the WinMLAutoModel.from_pretrained union return via cast (composite for the transformer stages, single model for companions) and cast the ep token to the EP name literal alias before normalization. No behavior change; the orchestrator stays duck-typed.
The previous fix narrowed the WinMLAutoModel.from_pretrained union via cast() with TYPE_CHECKING-only class imports. CodeQL cannot resolve names referenced solely inside cast(), so it flagged both imports as unused. Drop the imports and narrow the union-attr accesses with a targeted type: ignore instead, satisfying mypy, ruff (TC006), and CodeQL together. No behavior change.
…sion STEP 4.5 in generate_hf_build_config applies the device/precision policy to the quant config. The QDQ branch mutates parent_config.quant in place, but the fp16 and weight-only (RTN) branches replaced it with a fresh WinMLQuantizationConfig, discarding the model_type/task/model_id that _assemble_config had stamped on it. quantize_onnx dispatches a registered model-type quant finalizer off config.model_type. With model_type dropped, the finalizer never ran, so weight-only builds silently fell back to the default RTN scheme (block_size=128, accuracy_level=0 -> fp32 GEMM accumulation) instead of the finalizer's pinned scheme. For the qwen3 lm_head (w4a32) this regressed the MatMulNBits vocab projection from block_size=32/accuracy_level=4 (int8 MLAS GEMM) to 128/0, slowing genai-bundle decode. Fix: mutate parent_config.quant in place in the fp16 and weight-only branches (mirroring the QDQ branch) so the calibration identity fields survive the policy. Architecture-agnostic: any model_type with a registered finalizer benefits. The rebuilt lm_head now matches the known-good artifact (block_size=32, accuracy_level=4, 92.7 MB). Adds regression tests asserting weight-only and fp16 precision preserve quant.model_type/task/model_id.
Open
4 tasks
timenick
reviewed
Jul 13, 2026
…salvage) - Reject -c/--config on the genai bundle fast path: the bundle is fully recipe-driven, so a config file's fields are silently discarded. Reject explicitly after the recipe matches (non-bundle -c builds unaffected). - Reject a conflicting --precision override when the transformer's model_type has a registered quant finalizer: the finalizer pins the reference-matched scheme and would revert the override to a silent no-op. Gate is keyed on the finalizer registry (has_quant_finalizer), not any model name, so transformers without a finalizer still honor overrides. Enforced in build_genai_bundle so it covers both the CLI fast path and scripts/qwen3.py. - Harden EPContext salvage after a compile-subprocess crash: snapshot candidate mtimes before spawning so only files this compile actually produced/rewrote are salvaged (stale artifacts from an earlier compile with different provider options are rejected); additionally validate that an embed_mode=0 context's external ep_cache_context .bin exists and is non-empty before promoting.
_epcontext_is_valid required ep_cache_context for every embed_mode=0 node, so a legitimate multi-partition QNN artifact (one main_context=1 node holding the external .bin for all partitions, secondary main_context=0 nodes that omit ep_cache_context per the EPContext schema) was rejected at the first secondary node — failing salvage and falling back to the crashing JIT path. Skip main_context=0 nodes and only require the main context's external .bin, mirroring the compiler's own main_context handling in compiler/stages/compile.py. Adds a multi-partition salvage test plus a missing-main-bin rejection test.
…flict check The finalizer-pinned precision guard compared the raw override string against the recipe precision, so valid equivalents were wrongly rejected: - --precision auto resolves to the device default (w8a16 on NPU) = the recipe precision, but 'auto' != 'w8a16' raised. - A case variant like W8A16 (accepted case-insensitively downstream) != 'w8a16' raised. Treat 'auto' as deferring to the recipe (never a conflict) and compare case-insensitively. A pinned transformer's precision now collapses to the canonical recipe value for the downstream builder. Adds regression tests for the auto and case-variant inputs.
timenick
approved these changes
Jul 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Exporting Qwen3 as an onnxruntime-genai bundle for the NPU previously required a bespoke
scripts/qwen3.pyrun that emitted raw ONNX graphs and left the genai assembly to manual steps. This makeswinml buildproduce the full, runnable genai bundle in one command, while keeping the existing per-model output unchanged for every other target.What
For registered decoder-LLM families,
winml build -m <model> -o <dir> --ep qnntargeted at the NPU now assembles a complete onnxruntime-genai bundle:ctx.onnx/iter.onnx- transformer prefill + decode on the NPU (QNN, w8a16)embeddings.onnx/lm_head.onnx- companions on CPUgenai_config.json+ tokenizerThe routing is opt-in and non-destructive: it triggers only when
--ep qnnis passed explicitly together with an NPU target — either--device npu, or a--device autothat resolves to the NPU on this machine. Any other target (CPU, GPU, or an auto-detected NPU without--ep qnn) keeps today's stock composite build.All model-specific detail lives in a data-only genai-bundle recipe registered by the model package (
GenaiBundleRecipe+build_genai_bundle), so thewinml buildrouting itself stays architecture-agnostic.scripts/qwen3.pyis thinned to delegate to the same orchestrator, and registering a recipe for another decoder family is all that is needed to give it the same one-command build.Reproduce (NPU + CPU hybrid)
The bundle runs as an NPU + CPU hybrid: the transformer stages (
ctx/iter) execute on the QNN HTP whileembeddingsandlm_headrun on CPU. The split is encoded per stage in the bundle'sgenai_config.json, so--device auto("mixed") honors it without any extra flags.Observed on a Snapdragon X Elite NPU (step 3, hybrid, HTP burst mode): TTFT ~136 ms, decode ~21.6 tok/s (~46 ms/token), stable across repeated runs.
Notable
WinMLAutoModel.from_pretrainedcomputed an explicitmodel_typeoverride but then delegated to the base compositefrom_pretrained, which re-derived the native model_type and built the stock graph, breaking quantization/calibration. It now resolves the concrete composite class for the overridden type. This is the fix that actually lets the surgical bundle build succeed.--compileonce to produce_compiled/, then benchmark-m <bundle>/_compiledin a fresh process. Documented in the sample.--compilestep may still exit with a native0xC0000374/0xC0000005during QNN-EP teardown, after each stage's EPContext is written. The salvage path recovers the valid_compiled/artifacts from that crash, so the subsequent direct run succeeds. The crash originates in the native runtime below winml-cli and does not affect the generated tokens or the saved perf metrics.Verification
--device auto), the scriptdevice→EP mapping, and the--no-quantrejection guard; ruff clean.winml build ... --ep qnnproduced the bundle,--compilewrote_compiled/, and the fresh-process run generated tokens on the QNN HTP as an NPU+CPU hybrid (TTFT ~136 ms, decode ~21.6 tok/s).