Repository navigation
feat(qwen3_5): load vendor fine-grained FP8 checkpoints - #1742
Merged
Merged
Conversation
The vLLM-style FP8 releases of the Qwen3.5 family store every converted projection as raw E4M3 bytes in `<name>.weight` plus a bf16 inverse scale per 128x128 block in `<name>.weight_scale_inv`. MLX's safetensors reader maps `F8_E4M3` to `uint8`, so those bytes already arrive intact and nothing errors: the checkpoint loads, generates, and is wrong by a per-block factor on every projection. That silence is the reason this is a correctness fix rather than a capability gap. `models::fp8_block` adds the detection and the load-time conversion. `qwen_fp8_block_quantization` reads `quantization_config` from the top level or `text_config` and accepts `quant_method: "fp8"` with `fmt` absent or `"e4m3"` and `weight_block_size: [128, 128]`; any other geometry, a non-E4M3 format, and a missing `weight_block_size` (per-tensor FP8, a different sidecar layout) are named errors rather than an assumed 128. `requantize_block_fp8_weights` decodes the bytes through a 256-entry device LUT, pads to whole blocks, applies the block scale, slices back, and requantizes to MLX-native mxfp8 (group size 32, 8 bits), which the existing quantized-matmul path executes with no per-token dequantization. Conversion is one tensor at a time and each packed pair is evaluated before the next tensor starts, so the f32 reconstruction of the largest projection is the only transient. Tensors with no sidecar (norms, embeddings, `conv1d`, `A_log`, `dt_bias`, and the whole vision tower on the released checkpoints) stay dense, and `UnifiedLinear` already picks per tensor from the presence of `.scales`. The pre-pass is wired into all three Qwen3.5 entry points: the text loader, the VLM loader (before the weight map is split, so sidecars are still paired), and the adapter-side special loader. Each also merges the effective `quantization` block into the text config, which the FP8 releases do not carry, so the model constructor builds quantized layers. Validated with `cargo test --profile test-fast --features cuda --lib models::fp8_block` (10/10) under `MLX_ENABLE_TF32=0`. The central test compares the device path bitwise, on both planes, against `quantize_weights_with_mode(decode(bytes) * expanded_scales, 32, 8, "mxfp8")` built independently on the host, over a 130x160 tensor that is block-aligned on neither axis. Refs #1361
The Qwen3.5 entry said the FP8 and ModelOpt NVFP4 builds were both out of scope because mlxcel could not read vLLM-style `weight_scale_inv` block scales. The FP8 half is no longer true. Replace it with the layout that is now read, what the served model costs (8 bits per weight plus one E8M0 exponent per 32 values, close to the checkpoint's own footprint), which tensors stay dense, and which declarations are refused by name. ModelOpt NVFP4 stays out of scope on its own sidecar layout. Refs #1361
Records what the released FP8 checkpoint actually holds (407 sidecars, all bf16, all exactly ceil(R/128) x ceil(C/128), all block-aligned, so the real checkpoint never exercises the padding branch), the numpy check that `weight_scale_inv` multiplies rather than divides despite its name, the measured 1.04x load-time peak against the 1.5x bound, and the two acceptance criteria that stay open: bf16 logit parity, because that 55.6 GB reference was deliberately not fetched, and the metal+accelerate command, which is not runnable on a Linux/CUDA host. Also replaces two em dashes in the module doc comment with colons. Refs #1361
6 tasks
inureyes
added a commit
that referenced
this pull request
Sep 10, 2026
Bilingual pre-merge report for the settings-schema roster and drafter-refusal drift repair, committed into the branch because the repository carries TECHNICAL_REPORTS/.keep-reports. The analytical content is why a per-PR test scope cannot see cross-file contract drift. A scope derived from a diff excludes exactly the files that assert about what the diff changed, and neither defect existed on its author's branch: #1759 was correct against the roster it was written against and #1751's assertion was correct against the message on its base, so the drift is a property of the merged state alone. The two misses have different shapes, one a module-filter miss inside the same --lib target and one a target miss on an integration binary --lib never builds, and the workspace gate run on merged main is the only layer where the merged state and the full target set meet. The report also records why the third gate failure (#1769, the deterministic mxfp8 tolerance from #1742) is a host toolchain result rather than drift, and therefore keeps its own PR and its own standard of evidence. Refs #1322, #1339, #1343, #1769.
inureyes
added a commit
that referenced
this pull request
Sep 10, 2026
) ## Why The fp8 round-trip bound failed on every Metal host, M1 Ultra byte-identically to M5 Max, because the pinned MLX `9a795735` predates ml-explore/mlx#4353: Metal and CPU encoded the mxfp8 E8M0 block scale as `round(log2(amax / 448))`, so about half the blocks saturated their maxima, losing up to `1 - 2^-1/2`. CUDA rounds up, which is why #1742 passed on GB10. The test was right, and `requantize_block_fp8_weights`, the only E8M0 quantize caller, was clipping vendor FP8 checkpoints on Metal. Widening the bound, as the issue proposed, was rejected. ## What changed - MLX pin `9a795735` to upstream main `81ba1c6a` (99 commits). The seven overlays whose targets upstream touched are three-way merged and keep their deltas; the other 21 are unchanged upstream. `metal/compiled.cpp` also drops a leftover `elem_to_loc_1<uint>` that undid part of ml-explore/mlx#3720, so its only delta is the mixed-dtype cast. - Adaptations to what the new pin changes under the bridge: - `gather_qmm` gained `global_scale` ahead of `sorted_indices` (ml-explore/mlx#4458), so all 13 calls pass `std::nullopt`. - CUDA now needs cuSOLVER (ml-explore/mlx#4208). `link_cuda()` names it, `docs/installation.md` lists it, and CI's link job now runs on pin and CUDA link-list changes, which is how this slipped past CI. - `array::is_available()` now throws and clears a failed launch's error (ml-explore/mlx#3742). The rejection sampler's drain now reads status, signal and error pointer instead, so a GPU fault no longer terminates the process from inside `fused_sample` or hides the error from its owner. A regression test aborts the process with the old drain and passes with the new one. - The round-trip check moves into its own test, `fp8_block_requantize_round_trip_stays_within_half_an_e4m3_step`, with the same seed and shape. It states the derivation and asserts `group_max <= 448 * scale` per block. ## Validation (M1 Ultra, macOS 27.0) - fp8: the old pin fails the new test at block 0 (the maximum 4.8046875 scales to 615 and saturates). The new pin saturates 0 of 650 blocks, with a worst error of 0.0489 of the group max against 0.2928 before. - Turbo launchers pass (max RMS 1.7263e-4 and 1.5259e-4). - Teacher-forced logit traces, old pin vs new, on five checkpoints at widths 1, 8 and 256: 0 disagreements on decided positions. Over 4,096 positions on qwen3-30b-a3b, 1 of 1,720 decided positions differs, at the reference's rank 2, with perplexity -0.20%. - Branches the short runs missed: - head-dim-512 decode past 1,024 keys: identical text. - GQA-8 decode past 8,192 keys: identical text, decode 55.6 to 60.8 tok/s. - head-dim-72 vision towers: image prefill 239 to 279 tok/s. Descriptions diverge into equally faithful text; decided answers are unchanged. - Short-context throughput is within 0.6% on three checkpoints. - Workspace gate: 10986 passed, 0 failed, 359 ignored. Clippy, fmt, both pin parsers and the cross-repo reference check are clean. - CUDA was compiled and linked in CI but not run: `OpenXLA feature link` linked a `--features cuda,xla-iree` release binary on GB10 at the new pin, which covers the overlays and the cuSOLVER link. The green `CUDA sm_70 compile` check is a skip, because CUDA 13.0 cannot target sm_70. The per-checkpoint numbers, method and derivations are in `TECHNICAL_REPORTS/1772-mlx-pin-mxfp8-round-up-20260911.en.md`. ## Not validated, or reported and not fixed - No CUDA execution. No M5 Max run: the generation-17 NAX paths changed upstream in this range are unmeasured. - `array_evaluated_bytes`, the server's lookahead read, is another non-`Result` bridge function that now throws on a failed launch. It needs routing through the scheduler's step-failure path. - The mixed-dtype cast in `metal/compiled.cpp` would cast a comparison's inputs to `bool`. This is latent, since no compiled function contains a comparison. Closes #1769
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.
Summary
The vendor FP8 releases of the Qwen3.5 family store every converted projection as raw E4M3 bytes in
<name>.weightplus a bf16 inverse scale per 128x128 block in<name>.weight_scale_inv. MLX's safetensors reader mapsF8_E4M3touint8, so those bytes already arrived intact and nothing errored: onmainthis checkpoint loads, generates, and is wrong by a per-block factor on every projection. That is why this is a correctness fix and not a missing-capability one.models::fp8_blockreconstructs each pair at load and requantizes it to MLX-native mxfp8 (group size 32, 8 bits), which the existing quantized-matmul path executes with no per-token dequantization.What changed
src/models/fp8_block.rs(new):qwen_fp8_block_quantizationreadsquantization_configfrom the top level ortext_configand acceptsquant_method: "fp8"withfmtabsent or"e4m3"andweight_block_size: [128, 128]. Any other geometry, a non-E4M3fmt, and a missingweight_block_size(per-tensor FP8, a different sidecar layout) are named errors rather than an assumed 128, because the wrong pairing mis-scales a tensor without failing.requantize_block_fp8_weightsdecodes the E4M3 bytes through a 256-entry device LUT (take), pads to whole blocks, applies the block scale by broadcast over[rb, 128, cb, 128], slices back, and quantizes. Rank other than 2, a sidecar whose shape does not tile its weight, an orphan sidecar, a width that is not a multiple of 32, and a weight already decoded to a float dtype are each refused by name.quantizationblock into the text config, which these releases do not carry.docs/supported-models.md: the Qwen3.5 entry no longer calls the FP8 builds out of scope; it records the layout, the served footprint, which tensors stay dense, and which declarations are refused.TECHNICAL_REPORTS/1742-qwen35-fp8-block-requantize-20260910.{en,ko}.md.Validation
Every command below ran on Linux / aarch64 / GB10 (sm_121) with
--features cudaandMLX_ENABLE_TF32=0.cargo test --profile test-fast --features cuda --lib models::fp8_block: 10/10. The central test compares the device path bitwise, on both the packeduint32plane and theuint8scale plane, againstquantize_weights_with_mode(decode(bytes) * expanded_scales, 32, 8, "mxfp8")built independently on the host, over a 130x160 tensor that is block-aligned on neither axis. A second case covers the block-aligned no-padding branch, and a third checks all 256 E4M3 encodings through the device LUT.cargo test --profile test-fast --features cuda --lib models::qwen3_5(28),--lib models::sanitize(71),--lib loading::special(6): all pass.Qwen/Qwen3.8-27B-FP8(29 GB, 1606 tensors, 407 sidecars):mlxcel generate -n 220 --show-reasoningproduced a coherent reasoning trace and a well-formed limerick, stopping naturally at 207 tokens. Load took 32.1 s at 23.36 GB resident and 24.37 GB peak (MLX allocator), a ratio of 1.04, against the 1.5x bound the issue asks for. Process RSS peaked at 6.9 GB, because MLX's CUDA allocations on this host do not appear in RSS, so the allocator figures are the meaningful ones.F8_E4M3tensors have a bf16 sidecar in the same shard, every sidecar shape is exactlyceil(R/128) x ceil(C/128), every width is a multiple of 32, noF8_E4M3tensor lacks a sidecar, and every tensor is block-aligned (so the real checkpoint never exercises the padding branch, which is why the unit test uses 130x160).decode * scale_invgives max 0.32 to 0.44 and std 0.011 to 0.017, a normal projection distribution, whiledecode / scale_invgives about 4e6. The E4M3 decode alone tops out at exactly 448 in every tensor, confirming the vendor scaled each block to fill the E4M3 range.cargo fmt --all -- --checkandcargo clippy --workspace --all-targets --features cuda -- -D warningspass.Not verified
Qwen/Qwen3.8-27B(55.6 GB) was deliberately not fetched on this host, and no weaker oracle is offered in its place.cargo test --workspace --profile test-fast --features metal,accelerateis not runnable on this host (Linux / CUDA, no Metal and no Accelerate) and was not run. The CUDA equivalents above were run instead.qwen3_5_moeFP8 release was not available to test. A fused 3-Dexperts.gate_up_projcarrying block scales would be refused by name (rank 2 is required) rather than mis-converted.mlxcel-coreand does not take this pre-pass; MTP speculative decoding is already unsupported for this family.Closes #1361