convert: support compressed-tensors mixed-precision NVFP4 checkpoints - #195
Draft
danielhanchen wants to merge 1 commit into
Draft
convert: support compressed-tensors mixed-precision NVFP4 checkpoints#195danielhanchen wants to merge 1 commit into
danielhanchen wants to merge 1 commit into
Conversation
Every NVFP4 checkpoint published by Unsloth (unsloth/Qwen3.6-35B-A3B-NVFP4, unsloth/Qwen3.6-35B-A3B-NVFP4-Fast, unsloth/Qwen3.6-27B-NVFP4) uses the compressed-tensors "mixed-precision" format with two config groups: one float-quantized FP8 group covering the attention projections and lm_head, and one nvfp4-pack-quantized group covering the MoE experts. The converter rejected all of them with NotImplementedError: Can't handle multiple config groups for compressed-tensors yet because the nvfp4_compressed_tensors gate required every group to be nvfp4-pack-quantized. Relax both copies of that gate to accept a checkpoint in which any group is NVFP4, and handle the rest of the checkpoint: - _generate_nvfp4_tensors now identifies NVFP4 tensors by dtype and block geometry rather than by scale rank alone. The FP8 group also carries a 2D weight_scale of shape [out, 1], so the existing "scale.ndim < 2" test let FP8 tensors fall into the NVFP4 repacking path. - The nvfp4 branch of dequant_model now dequantizes the leftover FP8 weights the same way the float-quantized branch does, and drops the unused input_scale, k_scale and v_scale sidecars. Previously it did nothing, so those tensors reached the writer still quantized. With this, converting unsloth/Qwen3.6-35B-A3B-NVFP4-Fast with --fp8-as-q8 produces a MOSTLY_NVFP4 GGUF whose 240 expert tensors are GGML_TYPE_NVFP4 and whose attention and lm_head tensors are Q8_0.
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.
Problem
Every NVFP4 checkpoint Unsloth publishes is refused by
convert_hf_to_gguf.py:This reproduces on
unsloth/Qwen3.6-35B-A3B-NVFP4,unsloth/Qwen3.6-35B-A3B-NVFP4-Fastand
unsloth/Qwen3.6-27B-NVFP4.ggml-org/llama.cppmaster has the same twoall(...)gates (
conversion/base.py:492and:861) and the sameraiseat:496, so it isrefused there too.
Why
These are compressed-tensors
"format": "mixed-precision"checkpoints with two configgroups, not one:
group_0float-quantizedstrategy: channelself_attn.(q|k|v|o)_proj,linear_attn.(in_proj_qkv|in_proj_z|out_proj),lm_head(and on the non-Fast 35B also the layer 32 to 39 experts)group_1nvfp4-pack-quantizedgroup_size: 16,scale_dtype: torch.float8_e4m3fnmlp.experts.*.(gate|up|down)_proj,shared_expert.(gate|up|down)_projThe
nvfp4_compressed_tensorsgate demands that all groups benvfp4-pack-quantized, so a checkpoint that is NVFP4 for the experts and FP8 for theattention is classified as neither, and falls into the multi-group
raise.Fix
Four hunks in
conversion/base.py:all(...)becomesany(...)in both copies of the gate (dequant_modelandprepare_tensors), so a mixed-precision checkpoint with an NVFP4 group is recognisedas NVFP4.
_generate_nvfp4_tensorsidentifies NVFP4 tensors by dtype and block geometry(
weightuint8,scalefloat8_e4m3fn, one scale per 16 values) rather than byscale.ndim >= 2alone. This matters because the FP8 group's per-channelweight_scaleis shape[out, 1], which is also rank 2, so without the extra testthe FP8 tensors were fed to
_nvfp4_pack.nvfp4_compressed_tensorsbranch ofdequant_modelwas a barepass. It nowdequantizes whatever
weight_scaleentries survive_generate_nvfp4_tensors(whichin a mixed-precision checkpoint are exactly the non-NVFP4 group) using the same
dequant_simplecall thefloat-quantizedbranch uses, and drops the unusedinput_scale,k_scaleandv_scalesidecars.Verification
Converts in about six minutes on a DGX Spark and writes
general.file_type = 39(
MOSTLY_NVFP4), 1233 tensors, 21.32 GiB:blk.0.ffn_down_exps.weight [512, 2048, 256]token_embd.weight [2048, 248320]output.weight [2048, 248320]blk.0.ffn_down_exps.scale [256]480 of the F32 tensors are the
weight_scale_2andinput_scalesidecars written as.scaleand.input_scale.Measured on a DGX Spark (GB10, sm_121a),
nvidia-smi -lgc 300,2100, observedclocks.sm2093 MHz on every cell, against the same model asMXFP4_MOEand asunsloth/Qwen3.6-35B-A3B-GGUFUD-Q4_K_XL:nsyson the NVFP4 prefill cell confirms the Blackwell FP4 path is what runs:42.3 % of GPU time in
mul_mat_q<(ggml_type)40, 128, false>plus 2.5 % inquantize_mmq_nvfp4<...>, the FP4 activation quantiser, so it is genuine W4A4.KL divergence over 4 chunks with the NVFP4 GGUF as the base (no BF16 checkpoint was
available for an absolute reference):
-ctk q8_0 -ctv q8_0Two things this does not do
quantization_config.kv_cache_scheme= 8 bit float, per-tensor,static_minmax, andship
self_attn.k_scale/self_attn.v_scalefor the full-attention layers. There isno GGUF representation for them, so they are discarded and llama.cpp's KV cache stays
F16 unless the user passes
-ctk/-ctv. The FP8 KV the checkpoint was calibratedfor is not reproduced.
input_scalesidecars are written but unused.llama-model.cpploads them asTENSOR_NOT_REQUIRED, but the MMQ activation quantiserquantize_mmq_nvfp4computesits own dynamic per-row global scale (
row_amax / (6 * 448)) at runtime and then doesa five candidate local search over neighbouring UE4M3 codes per 16 element sub-block.
The checkpoint's statically calibrated activation scale is therefore ignored, which is
a deliberate difference from the vLLM CUTLASS path but worth being explicit about.
Draft because the size result deserves a follow-up: the convert-only path cannot touch
what the HF checkpoint left unquantized, so
token_embdand the ignore-listedlinear_attn.in_proj_a/in_proj_bstay BF16 and the NVFP4 GGUF ends up larger thanQ4_K_XL. An
--outtypestyle override for the unquantized remainder would fix that.Follow-up measured after this PR was opened
Use
--outtype q8_0, not--outtype auto. A convert-only path cannot touch what the HFcheckpoint never quantized, so with
--outtype autothe ignore-listedtoken_embdandlinear_attn.in_proj_a/in_proj_bstay BF16 (2.53 GiB) and the file lands at 21.32 GiB, largerthan Q4_K_XL. With
--outtype q8_0 --fp8-as-q8it is 20.14 GiB:The 240 NVFP4 tensors and all 480
.scale/.input_scalesidecars are byte-identical between thetwo builds (sha256 over the concatenated NVFP4 tensor bytes matches).
llama-quantizecannot dothis shrink instead:
--tensor-typeis only consulted insidellama_tensor_get_type, which isonly reached when
tensor_allows_quantizationreturns true, and that function begins withif (params->only_copy) return false;(src/llama-quant.cpp:289), while withoutCOPYthe NVFP4expert tensors are eligible for requantization and there is no way to exempt them.
The q8_0 build also measures slightly better: PPL 4.9027 +/- 0.1260 against 4.9348 +/- 0.1269, and
prefill 2405.0 t/s against 2344.9 t/s, decode at npl 32 338.1 t/s against 339.7 t/s.
The GGUF reproduces the source checkpoint. Feeding vLLM the exact token ids that
llama-perplexity --kl-divergence-basescored (read out of the.datheader, so there is notokenizer mismatch) and scoring the same second-half positions with
prompt_logprobs=20, over 4092positions:
FlashInferCutlassNvFp4LinearKernel+FLASHINFER_CUTLASSMoETop-20 KL(vLLM || GGUF), renormalised over vLLM's top-20 support: 0.042921 nats, top-1 agreement
90.69 %. The weights are bit-identical between the two, so that residual is the KV cache and the
activation quantiser described above.