Skip to content

feat(build): one-command Qwen3 genai bundle via winml build (npu + qnn)#1081

Merged
DingmaomaoBJTU merged 10 commits into
mainfrom
dingmaomaobjtu-qwen3-export-cmd
Jul 13, 2026
Merged

feat(build): one-command Qwen3 genai bundle via winml build (npu + qnn)#1081
DingmaomaoBJTU merged 10 commits into
mainfrom
dingmaomaobjtu-qwen3-export-cmd

Conversation

@DingmaomaoBJTU

@DingmaomaoBJTU DingmaomaoBJTU commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Why

Exporting Qwen3 as an onnxruntime-genai bundle for the NPU previously required a bespoke scripts/qwen3.py run that emitted raw ONNX graphs and left the genai assembly to manual steps. This makes winml build produce 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 qnn targeted 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 CPU
  • genai_config.json + tokenizer

The routing is opt-in and non-destructive: it triggers only when --ep qnn is passed explicitly together with an NPU target — either --device npu, or a --device auto that 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 the winml build routing itself stays architecture-agnostic. scripts/qwen3.py is 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 while embeddings and lm_head run on CPU. The split is encoded per stage in the bundle's genai_config.json, so --device auto ("mixed") honors it without any extra flags.

# 1. Build the bundle in one command. --device auto resolves to the NPU here;
#    --device npu is equivalent.
winml build -m Qwen/Qwen3-0.6B -o out/qwen3-bundle --device auto --ep qnn

# 2. Pre-compile each QNN stage to an EPContext binary under _compiled/.
#    This may exit with a native teardown crash *after* the artifacts are
#    written to disk; the salvage path recovers them (see Notable).
winml perf -m out/qwen3-bundle --runtime winml-genai --device auto --compile

# 3. Benchmark the pre-compiled bundle in a fresh process (no --compile).
#    Loading _compiled/ directly is the stable path — it skips the fragile
#    in-process compile-then-load.
winml perf -m out/qwen3-bundle/_compiled --runtime winml-genai --device auto \
    --iterations 10 --warmup 2 --max-new-tokens 64

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

  • Routing bug fix (found during e2e). WinMLAutoModel.from_pretrained computed an explicit model_type override but then 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 type. This is the fix that actually lets the surgical bundle build succeed.
  • Compile once, then run directly. onnxruntime-genai compiles the QNN context in-memory at model creation, which faults before the first token when done in the same process that also just prepared the bundle. The reliable pattern is the two-step workflow above: --compile once to produce _compiled/, then benchmark -m <bundle>/_compiled in a fresh process. Documented in the sample.
  • Teardown crash is now salvaged. The --compile step may still exit with a native 0xC0000374 / 0xC0000005 during 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

  • Scoped unit tests green across the genai-bundle registry/orchestrator, build routing (including --device auto), the script device→EP mapping, and the --no-quant rejection guard; ruff clean.
  • End to end on a Snapdragon X Elite NPU via the two-step workflow above: winml build ... --ep qnn produced the bundle, --compile wrote _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).

…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.
@DingmaomaoBJTU
DingmaomaoBJTU requested a review from a team as a code owner July 9, 2026 10:47
Comment thread src/winml/modelkit/models/winml/genai_bundle.py Fixed
Comment thread tests/unit/models/winml/test_genai_bundle_orchestrator.py Fixed
Comment thread src/winml/modelkit/commands/build.py
Comment thread scripts/qwen3.py Outdated
Comment thread src/winml/modelkit/commands/build.py
Comment thread src/winml/modelkit/models/winml/genai_bundle.py Outdated
…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.
github-actions Bot added 2 commits July 10, 2026 13:37
… 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.
Comment thread src/winml/modelkit/models/winml/genai_bundle.py Fixed
Comment thread src/winml/modelkit/models/winml/genai_bundle.py Fixed
github-actions Bot added 2 commits July 10, 2026 17:45
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.
Comment thread src/winml/modelkit/config/build.py
Comment thread src/winml/modelkit/session/genai_session.py Outdated
github-actions Bot added 3 commits July 13, 2026 10:06
…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.
@DingmaomaoBJTU
DingmaomaoBJTU merged commit 8ffb40d into main Jul 13, 2026
9 checks passed
@DingmaomaoBJTU
DingmaomaoBJTU deleted the dingmaomaobjtu-qwen3-export-cmd branch July 13, 2026 02:46
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.

4 participants