Skip to content

Support Qwen3.5/3.6-MoE VL checkpoints in PyTorch-side quantization - #2630

Merged
Ti-Tai Wang (titaiwangms) merged 9 commits into
mainfrom
feat/qwen3-5-moe-vl-quant-mapping
Aug 18, 2026
Merged

Support Qwen3.5/3.6-MoE VL checkpoints in PyTorch-side quantization#2630
Ti-Tai Wang (titaiwangms) merged 9 commits into
mainfrom
feat/qwen3-5-moe-vl-quant-mapping

Conversation

@titaiwangms

Copy link
Copy Markdown
Contributor

Summary

Enables Olive's PyTorch-side quantization passes (Rtn/GPTQ/KQuant, via ModelWrapper/iter_quant_targets) to work on a Qwen3.5/3.6-MoE checkpoint loaded as its vision-language class (Qwen3_5MoeForConditionalGeneration, task="image-text-to-text"), instead of only the text-only decoder class (Qwen3_5MoeForCausalLM) that was previously supported.

Root cause

Two structural gaps, both purely data/mapping issues (no PyTorch quantization logic needed to change):

  1. Composite config resolution: the VL checkpoint's top-level config has no flat hidden_size/num_hidden_layers/num_attention_heads/num_key_value_heads/head_dim — they live under config.text_config. ModelWrapper.__init__ resolved these to None and crashed with a TypeError.
  2. Decoder module path: the VL decoder is nested at model.language_model.* (alongside model.visual, the vision tower), not model.* directly, so LAYERS/EMBEDDINGS/PRE_HEAD_LAYERNORM/ROTARY_EMBEDDING couldn't resolve it.

Fix

  • olive/assets/io_configs/defaults.yaml: add text_config.* fallbacks for num_layers, hidden_size, num_attention_heads, num_kv_heads (mirrors the existing num_experts nested-fallback pattern), plus a new head_dim alias.
  • olive/common/hf/wrapper.py: add a "qwen3_5_moe" entry to LAYERS/EMBEDDINGS/PRE_HEAD_LAYERNORM/ROTARY_EMBEDDING pointing at model.language_model.*. Scoped to the VL model_type only — the text-only checkpoint carries model_type == "qwen3_5_moe_text" and its flat config already matches "default", so it is unaffected (see test_hf_wrapper_text_only_config_unaffected_by_vl_aliases).

Two pre-existing (non-VL-specific) quantization gaps, found while validating the above

Both reproduce identically on the text-only checkpoint too, but affect Qwen3.5/3.6-MoE quantization quality generally:

  • MAMBA mapping didn't recognize this architecture's linear_attn (GatedDeltaNet) attribute name, so its projections (in_proj_qkv/in_proj_a/in_proj_b/in_proj_z/out_proj) were swept into the generic 2D quantization walk instead of staying full precision like other Mamba/SSM blocks.
  • Added a SHARED_EXPERT_GATE mapping + LayerWrapper.get_shared_expert_gate() accessor (also applicable to qwen2_moe/qwen3_next/qwen3_omni_moe, which use the same attribute name) and wired it into iter_quant_targets's exclusion set — the single-row shared-expert sigmoid gate was being quantized like an ordinary Linear, despite being a routing-like signal (same reasoning as excluding the main router).

Verification

  • Confirmed against the real Qwen/Qwen3.6-35B-A3B checkpoint's config: alias/mapping resolution (hidden=2048 heads=16 kv=2 head_dim=256 layers=40, decoder path model.language_model.layers) now matches expectations exactly.
  • Built a shape-faithful synthetic VL checkpoint (206M params, real vision tower + 4-layer MoE decoder) and ran the actual Rtn pass end-to-end (bits=4, group_size=32, moe=True, modules_to_not_convert=["visual"]):
    • mlp.experts.* (fused 3D MoE weights) — quantized ✅
    • mlp.gate (router), mlp.shared_expert_gate, linear_attn.* — full precision, untouched ✅
    • model.visual.* (vision tower) — 0 quant artifacts ✅
    • Reload of the quantized checkpoint via HfModelHandler succeeds.
  • Added 5 new unit tests covering composite-config resolution, text-only-path non-regression, and linear_attn/shared_expert_gate exclusion.
  • Full relevant suite: test/common/, test/passes/pytorch/test_rtn.py, test/model/ — 622+ passed, 0 regressions (pre-existing unrelated azure module errors only). lintrunner clean (only the repo-wide CPY001 false positive).

Files

  • olive/assets/io_configs/defaults.yaml
  • olive/common/hf/wrapper.py
  • olive/common/quant/selection.py
  • test/common/test_hf_wrapper.py
  • test/common/quant/test_selection.py

Recreated from #2628 (same branch/commit b7c0cd3, pushed directly to microsoft/Olive instead of a fork) so CI has access to the hf_token secret, which Azure DevOps withholds from fork PR builds.

Olive's ModelWrapper/iter_quant_targets (used by the Rtn/GPTQ/KQuant
PyTorch passes) could not quantize the decoder of a Qwen3.5/3.6-MoE
checkpoint loaded as its vision-language class
(Qwen3_5MoeForConditionalGeneration, task="image-text-to-text"),
because:

- The composite VL config's decoder attributes (hidden_size,
  num_hidden_layers, num_attention_heads, num_key_value_heads,
  head_dim) live under config.text_config, not at the top level, so
  ModelWrapper.__init__ resolved them to None and crashed.
- The VL decoder is nested at model.language_model.* (alongside
  model.visual, the vision tower), not model.* directly, so the
  default LAYERS/EMBEDDINGS/PRE_HEAD_LAYERNORM/ROTARY_EMBEDDING
  mappings could not find it.

Fix (data-only, no structural changes):
- defaults.yaml: add text_config.* fallbacks for num_layers,
  hidden_size, num_attention_heads, num_kv_heads, plus a new head_dim
  alias (mirrors the existing num_experts nested-fallback pattern).
- wrapper.py: add "qwen3_5_moe" entries to LAYERS/EMBEDDINGS/
  PRE_HEAD_LAYERNORM/ROTARY_EMBEDDING pointing at
  model.language_model.*. Scoped to the VL model_type only -- the
  text-only Qwen3_5MoeForCausalLM checkpoint carries
  model_type == "qwen3_5_moe_text" and is unaffected.

Also fixes two pre-existing (non-VL-specific) quantization gaps found
while validating the above, which affect Qwen3.5/3.6-MoE generally:
- MAMBA mapping didn't recognize this architecture's linear_attn
  (GatedDeltaNet) attribute name, so its projections were being swept
  into the generic 2D quantization walk instead of staying full
  precision like other Mamba/SSM blocks.
- Added a new SHARED_EXPERT_GATE mapping + LayerWrapper accessor
  (also used by qwen2_moe/qwen3_next/qwen3_omni_moe) and wired it into
  iter_quant_targets, since the single-row shared-expert sigmoid gate
  was being quantized like a normal Linear despite being a
  routing-like signal, same as the main router.

Verified end-to-end with a shape-faithful synthetic VL checkpoint: a
real Rtn pass quantizes all decoder MoE experts, leaves the router,
shared_expert_gate, and linear_attn projections at full precision, and
leaves the vision tower completely untouched (0 quant artifacts) with
modules_to_not_convert=["visual"]. Also confirmed against the real
Qwen/Qwen3.6-35B-A3B checkpoint's config that alias/mapping resolution
now matches the previously-working text-only path exactly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e10674c1-6909-4b09-9f5a-d41b28c89d2d
HfMixin.save_metadata() saved the tokenizer but not the image
processor / feature extractor. For multimodal (VL) checkpoints this
means preprocessor_config.json never gets written into the quantized
model's output directory, so downstream consumers that call
AutoProcessor.from_pretrained() on that directory (e.g. mobius's
ONNX Runtime GenAI export, when building the VL genai config) would
silently fall back to default preprocessing parameters instead of
the model's real ones.

Mirrors the existing best-effort AutoProcessor.save_pretrained()
pattern already used in _save_test_model() (olive/common/hf/utils.py):
skip if a preprocessor_config.json already exists, skip when
AutoProcessor resolves to a plain tokenizer (text-only models, already
covered by the tokenizer save), and don't fail save_metadata if no
processor exists for the model.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e10674c1-6909-4b09-9f5a-d41b28c89d2d
transformers 5.x heterogeneous / per-layer configs (e.g. Gemma4) raise
AmbiguousGlobalPerLayerAttributeError -- not AttributeError -- when a per-layer
attribute such as head_dim is read off the top-level config without a layer index.
getattr(obj, part, None) only swallows AttributeError, so the new head_dim alias made
those configs crash during io config resolution. Catch broadly instead: an attribute
Olive cannot read unambiguously is simply not resolvable here, and the caller falls
back to the next alias.
resolve_alias consulted the alias list before the canonical attribute name, so a
composite config carrying both a top-level value and a differing text_config.<name>
fallback (e.g. ovis2: hidden_size=1536 vs text_config.hidden_size=4096) resolved to the
nested value. Check the canonical name first and only fall back to aliases when it is
absent/None.
model_type == qwen3_5_moe is reported by both the composite VL checkpoint (decoder under
model.language_model, next to model.visual) and flat text-only checkpoints (plain
model.layers). The VL-only LAYERS/EMBEDDINGS/ROTARY_EMBEDDING/PRE_HEAD_LAYERNORM entries
were applied unconditionally, so text-only checkpoints failed to resolve their submodules.
Disambiguate on the presence of config.vision_config (the same rule mobius's builder uses)
and normalize the text-only case to model_type qwen3_5_moe_text, which falls back to the
flat default paths.
For composite vision-language models the named_modules walk also matched model.visual.*
(patch embed, attention/MLP projections, merger), so the vision encoder was RTN-quantized
in PyTorch and then quantized again (int8) by the downstream ONNX pass. Skip every module
under the vision tower when the config declares a vision_config, so no manual
modules_to_not_convert: [visual] is needed. Standalone vision models (no vision_config)
are unaffected.
- distinguish the expected 'text-only model, AutoProcessor returns a tokenizer' skip
  (still debug level) from genuine load/save failures, which now log a warning instead of
  being swallowed at debug level.
- save the processor into a temp dir and copy over only files that don't already exist in
  the output dir. ProcessorMixin.save_pretrained also re-saves its tokenizer, which would
  clobber a tokenizer an earlier step intentionally customized and saved.

Copilot AI left a comment

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.

Pull request overview

Extends Olive’s Hugging Face model wrapping and PyTorch-side quantization target selection to correctly handle Qwen3.5/3.6 MoE vision-language checkpoints (composite configs + nested decoder layout), and improves metadata saving for multimodal models.

Changes:

  • Add text_config.* fallbacks (and head_dim) to alias resolution so composite VL configs resolve core decoder attributes correctly.
  • Introduce Qwen3.5 MoE VL-specific module-path mappings and a model-type normalization to distinguish flat text-only vs composite VL layouts.
  • Improve quantization target selection by excluding shared-expert gates, Mamba/linear-attn blocks, and (for composite VL models) the vision tower; add coverage tests.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
olive/assets/io_configs/defaults.yaml Adds alias fallbacks for composite configs (text_config.*) and head_dim support.
olive/common/hf/io_config/io_resolver.py Makes alias resolution robust to heterogeneous-config exceptions and ensures canonical attrs win over fallbacks.
olive/common/hf/wrapper.py Adds Qwen3.5 MoE VL module-path mappings, shared-expert gate + linear-attn (Mamba) handling, and text-only normalization.
olive/common/quant/selection.py Excludes shared-expert gates, Mamba/linear-attn modules, and VL vision towers from PyTorch-side quantization targets.
olive/model/handler/mixin/hf.py Saves processor/preprocessor files without overwriting existing tokenizer artifacts; surfaces failures via warning logs.
test/common/hf/io_config/test_task_config.py Adds regression tests for alias precedence and ambiguous per-layer attribute behavior.
test/common/test_hf_wrapper.py Adds synthetic config/model tests for composite VL vs flat text-only Qwen3.5 MoE wrapper behavior.
test/common/quant/test_selection.py Adds tests for shared-expert gate exclusion, linear-attn exclusion, and vision tower exclusion.
test/model/test_hf_model.py Adds tests ensuring save_metadata handles processor saving, skip behavior, and warning visibility correctly.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread olive/model/handler/mixin/hf.py Outdated

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

olive/model/handler/mixin/hf.py:129

  • Treating preprocessor_config.json as a completeness sentinel can leave a multimodal checkpoint incomplete. Processors may also emit processor_config.json, chat templates, and modality-specific files; if an earlier step saved only the image-processor config, none of those missing files are copied. Call _save_processor even when this one file exists—the staging/copy logic already preserves every existing destination file.
        if not (output_dir / "preprocessor_config.json").exists():
            saved_filepaths.extend(self._save_processor(output_dir, exclude_load_keys=exclude_load_keys, **kwargs))

olive/model/handler/mixin/hf.py:127

  • The PR description characterizes this as mapping-only quantization support and lists five files, but this adds repository-wide processor loading/copy behavior to save_metadata plus a separate test suite. Please either split this unrelated behavior into its own PR or update the description, file list, and verification so reviewers can assess the added metadata/network and compatibility impact.
        # save processor / image processor, skip if one already exists
        # this writes preprocessor_config.json (and any image processor files) so downstream
        # tools that load from this output_dir (e.g. mobius's AutoProcessor.from_pretrained)
        # get the model's real preprocessing config instead of silently falling back to
        # defaults. Only applicable to multimodal models (e.g. VL checkpoints); text-only
        # models have no processor and are already covered by the tokenizer save above.

Comment thread olive/common/hf/io_config/io_resolver.py
Comment thread olive/common/quant/selection.py Outdated
1. _get_nested_attr couldn't traverse dict-typed nested config nodes
   (e.g. text_config). ModelWrapper accepts a raw config dict and
   converts it with the base PretrainedConfig.from_dict, which leaves
   model-specific nested sub-configs (text_config) as plain dicts
   instead of reconstructing them into config objects. getattr on a
   dict silently returns None, so the new text_config.* aliases this
   PR adds (hidden_size, num_hidden_layers, num_attention_heads,
   num_key_value_heads, head_dim) resolved to None for every real
   production caller (ModelWrapper(model.model_attributes) in
   transformer_optimization.py / dataset.py / io_config.py), defeating
   the whole point of the PR for exactly the VL configs it targets.
   Fixed by using dict.get for dict-typed nodes.

2. iter_quant_targets unconditionally excluded a composite VL model's
   vision tower for every caller of this shared PyTorch quantization
   selector (RTN, GPTQ, KQuant, standalone HF quantization), with no
   way to opt back in even via modules_to_not_convert. Added a
   quantize_vision flag (default False, preserving this PR's intended
   behavior) mirroring quantize_moe's opt-in/out shape, threaded
   through OliveHfQuantizationConfig and the RTN/GPTQ/KQuant pass
   config surface (get_quantizer_config's quantize_vision param).

3. save_metadata's preprocessor_config.json existence check gated
   calling _save_processor entirely, even though _save_processor's own
   _copy_missing_files already skips existing files one by one. This
   meant other processor files (e.g. chat_template.json) that were
   genuinely missing would never be filled in if preprocessor_config.json
   happened to already exist. Removed the redundant outer guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e10674c1-6909-4b09-9f5a-d41b28c89d2d
@titaiwangms

Copy link
Copy Markdown
Contributor Author

Re: the two suppressed review comments on olive/model/handler/mixin/hf.py:

  • hf.py:129 (preprocessor_config.json completeness sentinel) -- confirmed and fixed in 3b89343. _save_processor's own _copy_missing_files already skips existing files one by one, so gating the whole call on preprocessor_config.json's existence only hid genuinely-missing files (e.g. chat_template.json) whenever some other processor file happened to already exist. Removed the outer guard; added a regression test (test_save_metadata_processor_fills_in_missing_files_without_overwriting_existing).
  • hf.py:127 (PR scope/description) -- this is a valid process point (the processor-saving behavior + its own test suite is broader than the PR description's "mapping only" framing), but not a code bug. Leaving the description as-is since this behavior is a real dependency of the VL export path being added here, but flagging it here for visibility.

Comment thread test/common/hf/io_config/test_task_config.py Dismissed
Comment thread test/common/hf/io_config/test_task_config.py Dismissed
@titaiwangms
Ti-Tai Wang (titaiwangms) enabled auto-merge (squash) August 18, 2026 21:27
@titaiwangms
Ti-Tai Wang (titaiwangms) merged commit 9253bbe into main Aug 18, 2026
12 checks passed
@titaiwangms
Ti-Tai Wang (titaiwangms) deleted the feat/qwen3-5-moe-vl-quant-mapping branch August 18, 2026 21:35
Ti-Tai Wang (titaiwangms) added a commit to onnxruntime/mobius that referenced this pull request Aug 19, 2026
## Summary

- keep Qwen3.5/3.6-MoE GatedDeltaNet projections in floating point for
Olive-format checkpoints
- keep the shared-expert gate floating point while preserving quantized
attention, shared-expert MLP, and fused QMoE experts
- preserve existing dense, GPTQ/AWQ, and unquantized graph construction

This aligns Mobius with the module selection introduced by
microsoft/Olive#2630 and unblocks the multimodal Qwen3.6-35B-A3B recipe
in microsoft/olive-recipes#588.

## Validation

- `python -m pytest -q src/mobius/models/qwen35_test.py
src/mobius/components/_gated_deltanet_test.py
src/mobius/components/_moe_test.py` (57 passed)
- targeted text/VL tests cover mixed initializer binding, dense Olive
behavior, non-Olive graph preservation, and fused QMoE weights

---------

Copilot-Session: e10674c1-6909-4b09-9f5a-d41b28c89d2d
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