Skip to content

fix(qwen4_exp): read weights under the checkpoint's quant config - #759

Merged
jason-fxz merged 2 commits into
mainfrom
fix/qwen4-exp-scheme-loader
Oct 20, 2026
Merged

jason-fxz merged 2 commits into
mainfrom
fix/qwen4-exp-scheme-loader

Conversation

@jason-fxz

Copy link
Copy Markdown
Collaborator

Fixes #738.

orcarouter/Qwen3.8-Flash-Next-Uncensored-NVFP4 is an llm-compressor (compressed-tensors) export. The model built its layers correctly from the quant config (192 per-channel fp8 Linears), but the qwen4_exp safetensors reader could not feed them:

  • the dense reader dropped every .weight_scale and only knew .weight / .weight_scale_inv, so the first fp8 layer failed with KeyError: ...linear_attn.in_proj_qkvz.weight_scale;
  • the routed-expert spec was a fixed ModelOpt regex (weight / weight_scale / weight_scale_2), so the compressed-tensors names weight_packed / weight_global_scale would not match;
  • the PLE n-gram table is bf16 with no scale (128 x [2500012, 160], 95.4 GiB), and both PLE backends accepted only an fp8 table.

This PR makes the reader follow the checkpoint's quant config instead of a list of suffixes, the way the qwen3_5_moe reader already does.

Changes

  • models/qwen4_exp/weight.py: _DenseReader replaces _DenseFuser. Each quant-aware Linear tensor is routed by quant.scheme_for(module) and the dialect's stored names (quant.storage(scheme)): bf16, per-channel / per-tensor fp8, block-fp8, MXFP8 and NVFP4 dense layers in any mix. Fused parts (qkv_proj, in_proj_qkvz / in_proj_ba or in_proj, gate_up_proj, the HC input_mix_weight_down_block_inject with its 16-row pad) are concatenated per role; per-channel and per-tensor fp8 scales become fp32 [rows]. Each part is checked when it arrives (dtype, block shape), a module missing tensors is listed at the end, and a role stored twice is refused. Static KV-cache scales (k_scale / v_scale) are dropped, as in qwen3_5_moe.
  • models/qwen4_exp/weight.py: nvfp4_expert_spec builds the expert key pattern and kind map from quant.stored_tensors(NVFP4), so ModelOpt and compressed-tensors experts both load (the compressed-tensors global scale is inverted, as in qwen3_5_moe).
  • models/qwen4_exp/weight.py, ple_disk.py: load_ple_table and the disk row source accept an fp8 table (scalar weight_scale required) or a bf16 table (no scale). PleRowSource gains row_dtype.
  • layers/quantization/configs/base.py, modelopt.py: Stored gains aliases. The ModelOpt MXFP8 scale accepts both weight_scale_inv (nvidia/MiniMax-M3) and weight_scale (nvidia/Qwen3.8-Flash-Next-FP4-Local, local-inference-lab/Qwen3.8-Flash-Next-NVFP4). Both names hold the same e8m0 codes; vLLM likewise maps weight_scale_inv onto weight_scale at load. The qwen4_exp and qwen3_5_moe readers match any of the names.

Stricter than before: a Linear the quant config declares unquantized but that carries a .weight_scale now raises instead of being dropped silently. None of the Qwen3.8-Flash-Next checkpoints checked below has one.

Not covered: nvidia/Qwen3.8-Flash-Next-FP4-Local also needs its CSF expert scales, NVFP4 PLE table and W4A16 vision MLP (#708); only its MXFP8 dense part is handled here. A quantized vision tower is still not read (as on main).

Results

RTX PRO 6000, 249 GB host RAM.

Dense state dict (iter_weights with vision off vs the model the engine builds from the same config):

checkpoint layout tensors emitted / model missing / extra / shape mismatch
orcarouter/Qwen3.8-Flash-Next-Uncensored-NVFP4 compressed-tensors, per-channel fp8 dense, NVFP4 experts, bf16 PLE 1022 / 1022 0 / 0 / 0
RadixArk/Qwen3.8-Flash-Next-NVFP4 ModelOpt NVFP4, bf16 dense 794 / 794 0 / 0 / 0
nvidia/Qwen3.8-Flash-Next-NVFP4 ModelOpt MIXED_PRECISION, bf16 dense 794 / 794 0 / 0 / 0

On orcarouter the fused in_proj_qkvz and shared-expert gate_up_proj scales equal the concatenated checkpoint scales bitwise. On RadixArk and nvidia the expert spec is the same as main's fixed one.

MXFP8 scale name: from nvidia/Qwen3.8-Flash-Next-FP4-Local, 13 MXFP8 modules of layer 0 (GDN, shared expert) and layer 3 (attention, indexer), read by range requests, fill all 16 model buffers with the right shapes and dtypes. Its in_proj_qkvz dequantized as w * 2^(code - 127) is 2.65% from the Qwen/Qwen3.8-Flash-Next bf16 weight (relative Frobenius error), and local-inference-lab's in_proj_qkv is 2.66% (dividing by the scale instead gives 6.6e7): the plain e4m3 rounding error.

Serving orcarouter with ft serve --moe-backend auto --max-running-requests 4 --memory-ratio 0.95 (auto: offload, disk PLE): ready in under 3 minutes. Greedy answers (thinking off) are correct: "The capital of Australia is Canberra.", 37 * 43 -> 1591, a two-sentence Chinese explanation of photosynthesis, a Fibonacci function. Decode is about 55-60 tok/s. With --ple-backend pinned the 95.4 GiB table loads at about 2.2 GB/s and the four answers are identical.

Tests

  • New, each red on main: per-channel fp8 key set and fp32 row-scale fusion (llm-compressor layout), the compressed-tensors expert names, a bf16 PLE table in the pinned and disk backends, MXFP8 dense under either scale name.
  • test_weight_ckpt.py: the dense pass runs with vision off, so the state-dict comparison matches the text model it builds; on main two of its tests failed on the vision keys. 45 / 45 pass on the RadixArk checkpoint.
  • test_quant_config.py: the tensor oracle reads a U8 weight_scale on an fp8 weight as MXFP8.

pytest tests/models/qwen4_exp tests/models/test_qwen3_5_moe_weight.py tests/models/test_quant_config.py tests/models/test_minimax_m3.py tests/kernels/test_mxfp8_linear.py tests/kernels/test_row_store.py: 346 passed, 143 skipped, 5 failed. The 5 also fail on main: four test_probed_layers_get_the_method_their_config_says cases from the local HF cache and test_nvfp4_experts_restricted_to_triton_for_swigluoai.

- route each dense Linear tensor by its scheme's stored names, so per-channel fp8 scales (llm-compressor) load and fused parts concatenate per role
- name the routed NVFP4 experts by the checkpoint's dialect, so compressed-tensors weight_packed / weight_global_scale load
- accept a bf16 PLE table without a scale in the pinned and disk backends
ModelOpt exports store the same e8m0 codes as weight_scale_inv (MiniMax-M3) or weight_scale (Qwen3.8-Flash-Next-FP4-Local); Stored gains aliases and the scheme-driven readers match any of them.
@jason-fxz
jason-fxz merged commit 163d1de into main Oct 20, 2026
1 check passed
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 20, 2026
- hybrid fetch split: upstream per-layer-kind bench rows (FlashML-org#734) first, our startup split now measured per layer kind without a row (before the CPU executor), then cap 1; dropped the profile-driven auto->hybrid pick and its cache-share guard with upstream
- qwen3_5_moe: upstream fp32 runtime norm +1 (FlashML-org#755) under our DFlash hidden-layer return and LinearOProj
- qwen4_exp: upstream quant-config reader and MXFP8 scale aliases (FlashML-org#759) replace our .weight_scale rename; our validating scan_ple_table and NVFP4 PLE tables gain the bf16 table support
- nvfp4_banks: upstream W4A4 input scales (FlashML-org#736) combined with our TP sharding and disk-tier skip
- ftw: fp16 nvfp4 global refusal (FlashML-org#739) on top of our eager index validation
- kernel/nvfp4_moe: drop the e2m1 LUT argument our bit-placement decode removed
- benchbw: upstream layer-kind rows plus our THP bench banks, decode-shaped gather and custom profile hint
- tests: disk-tier spec test skips the quant-config-driven qwen4_exp hook

Assisted-by: Claude Opus 5.5
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.

Support for orcarouter/Qwen3.8-Flash-Next-Uncensored-NVFP4

1 participant