Skip to content

feat(qwen3_5): load vendor fine-grained FP8 checkpoints - #1742

Merged
inureyes merged 3 commits into
mainfrom
feature/issue-1361-fp8-block-requantize
Sep 10, 2026
Merged

inureyes merged 3 commits into
mainfrom
feature/issue-1361-fp8-block-requantize

Conversation

@inureyes

@inureyes inureyes commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Summary

The vendor 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 arrived intact and nothing errored: on main this 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_block reconstructs 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_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 fmt, and a missing weight_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_weights decodes 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.
  • Peak memory is bounded by construction: one tensor at a time, with each packed pair evaluated before the next tensor starts, so the f32 reconstruction of the largest projection is the only transient.
  • Wired into all three Qwen3.5 entry points (text loader, VLM loader before the weight map is split, adapter-side special loader). Each also merges the effective quantization block 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 cuda and MLX_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 packed uint32 plane and the uint8 scale plane, 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. 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.
  • Real checkpoint Qwen/Qwen3.8-27B-FP8 (29 GB, 1606 tensors, 407 sidecars): mlxcel generate -n 220 --show-reasoning produced 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.
  • Checkpoint structure verified directly rather than taken from the issue: all 407 F8_E4M3 tensors have a bf16 sidecar in the same shard, every sidecar shape is exactly ceil(R/128) x ceil(C/128), every width is a multiple of 32, no F8_E4M3 tensor 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).
  • Scale direction checked independently in numpy on three real tensors: decode * scale_inv gives max 0.32 to 0.44 and std 0.011 to 0.017, a normal projection distribution, while decode / scale_inv gives 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 -- --check and cargo clippy --workspace --all-targets --features cuda -- -D warnings pass.

Not verified

  • The issue's "logits agree with the bf16 original within the mxfp8 tolerance" is unverified and left unticked: the bf16 reference Qwen/Qwen3.8-27B (55.6 GB) was deliberately not fetched on this host, and no weaker oracle is offered in its place.
  • The issue's cargo test --workspace --profile test-fast --features metal,accelerate is not runnable on this host (Linux / CUDA, no Metal and no Accelerate) and was not run. The CUDA equivalents above were run instead.
  • A qwen3_5_moe FP8 release was not available to test. A fused 3-D experts.gate_up_proj carrying block scales would be refused by name (rank 2 is required) rather than mis-converted.
  • The MTP drafter keeps its own loader in mlxcel-core and does not take this pre-pass; MTP speculative decoding is already unsupported for this family.

Closes #1361

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
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:models Model architectures, weights, loading, metadata area:core mlxcel-core: MLX FFI, primitives, KV cache, layers modelsize:medium 4-bit checkpoint 10-50GB; comfortable on a 128GB dev box modeltype:text Text-only language model modeltype:vlm Vision-language model arch:hybrid Hybrid attention stack (linear/sliding/full or attention+SSM interleave) status:review Under review labels Sep 10, 2026
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
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 10, 2026
@inureyes
inureyes merged commit 6ad3feb into main Sep 10, 2026
13 checks passed
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
@inureyes
inureyes deleted the feature/issue-1361-fp8-block-requantize branch September 14, 2026 11:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch:hybrid Hybrid attention stack (linear/sliding/full or attention+SSM interleave) area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:models Model architectures, weights, loading, metadata modelsize:medium 4-bit checkpoint 10-50GB; comfortable on a 128GB dev box modeltype:text Text-only language model modeltype:vlm Vision-language model priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(qwen3_5): load fine-grained FP8 checkpoints by requantizing 128x128 block scales to mxfp8

1 participant