Support Qwen3.5/3.6-MoE VL checkpoints in PyTorch-side quantization - #2630
Conversation
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.
There was a problem hiding this comment.
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 (andhead_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.
There was a problem hiding this comment.
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.jsonas a completeness sentinel can leave a multimodal checkpoint incomplete. Processors may also emitprocessor_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_processoreven 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_metadataplus 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.
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
|
Re: the two suppressed review comments on
|
## 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
Summary
Enables Olive's PyTorch-side quantization passes (
Rtn/GPTQ/KQuant, viaModelWrapper/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):
confighas no flathidden_size/num_hidden_layers/num_attention_heads/num_key_value_heads/head_dim— they live underconfig.text_config.ModelWrapper.__init__resolved these toNoneand crashed with aTypeError.model.language_model.*(alongsidemodel.visual, the vision tower), notmodel.*directly, soLAYERS/EMBEDDINGS/PRE_HEAD_LAYERNORM/ROTARY_EMBEDDINGcouldn't resolve it.Fix
olive/assets/io_configs/defaults.yaml: addtext_config.*fallbacks fornum_layers,hidden_size,num_attention_heads,num_kv_heads(mirrors the existingnum_expertsnested-fallback pattern), plus a newhead_dimalias.olive/common/hf/wrapper.py: add a"qwen3_5_moe"entry toLAYERS/EMBEDDINGS/PRE_HEAD_LAYERNORM/ROTARY_EMBEDDINGpointing atmodel.language_model.*. Scoped to the VLmodel_typeonly — the text-only checkpoint carriesmodel_type == "qwen3_5_moe_text"and its flat config already matches"default", so it is unaffected (seetest_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:
MAMBAmapping didn't recognize this architecture'slinear_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.SHARED_EXPERT_GATEmapping +LayerWrapper.get_shared_expert_gate()accessor (also applicable toqwen2_moe/qwen3_next/qwen3_omni_moe, which use the same attribute name) and wired it intoiter_quant_targets's exclusion set — the single-row shared-expert sigmoid gate was being quantized like an ordinaryLinear, despite being a routing-like signal (same reasoning as excluding the main router).Verification
Qwen/Qwen3.6-35B-A3Bcheckpoint's config: alias/mapping resolution (hidden=2048 heads=16 kv=2 head_dim=256 layers=40, decoder pathmodel.language_model.layers) now matches expectations exactly.Rtnpass 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 ✅HfModelHandlersucceeds.linear_attn/shared_expert_gateexclusion.test/common/,test/passes/pytorch/test_rtn.py,test/model/— 622+ passed, 0 regressions (pre-existing unrelatedazuremodule errors only).lintrunnerclean (only the repo-wideCPY001false positive).Files
olive/assets/io_configs/defaults.yamlolive/common/hf/wrapper.pyolive/common/quant/selection.pytest/common/test_hf_wrapper.pytest/common/quant/test_selection.pyRecreated from #2628 (same branch/commit
b7c0cd3, pushed directly to microsoft/Olive instead of a fork) so CI has access to thehf_tokensecret, which Azure DevOps withholds from fork PR builds.