Skip to content

qwen4_exp: serve the block-FP8 dense projections natively (+25% decode) - #392

Open
gberasmus87 wants to merge 3 commits into
FlashML-org:mainfrom
gberasmus87:qwen4-exp-serve-block-fp8-dense
Open

qwen4_exp: serve the block-FP8 dense projections natively (+25% decode)#392
gberasmus87 wants to merge 3 commits into
FlashML-org:mainfrom
gberasmus87:qwen4-exp-serve-block-fp8-dense

Conversation

@gberasmus87

Copy link
Copy Markdown

Stacks on #320, so this branch contains that commit too. The diff to review here is the second commit (da06515).

What

#320 loads modelopt MIXED_PRECISION checkpoints by dequantizing the block-FP8 dense projections to bf16 at load, which doubles the dense bytes read on every decode step. This keeps them quantized and lets the existing Fp8Block linears consume them directly.

On the modelopt build of Qwen3.8-Flash-Next that is 156 FP8_PB_WO tensors, 2.67G elements: 4.98 GiB as bf16 becomes 2.49 GiB.

Why this needed more than a config flag

The four-way linear_attn.in_proj fusion concatenates in_proj_qkv and in_proj_z (both FP8_PB_WO) with in_proj_b and in_proj_a (bf16). You cannot torch.cat fp8 with bf16, which is exactly why #320 dequantizes.

gdn.py already implements the right answer for block-fp8 checkpoints: an fp8 in_proj_qkvz GEMM plus a small bf16 in_proj_ba GEMM, matching sglang/vLLM. It was simply unreachable for this checkpoint, because it keyed off expert_quant == "fp8_block" and here the experts are NVFP4 while only the dense side is block-FP8. So:

  • config.py reports attn_quant="fp8_block" from the checkpoint's own per-module FP8_PB_WO declaration, independently of expert_quant
  • gdn.py takes the block-fp8 path when either says so
  • quant_linear.py's factories widen the same way
  • attention.py builds qkv_proj/o_proj through those factories instead of hardcoded bf16 classes
  • weight.py keeps the weight_scale_inv tensors and fuses on a table matching the modules the model actually builds

Every fp8 fusion group is a whole number of 128-row blocks (10240/6144, and 12288/512/512), so the per-block scales concatenate exactly alongside the rows they describe.

Measured

Single RTX PRO 4000 Blackwell 24 GB, TP=1, --moe-backend hybrid, PCIe gen 3 x16, modelopt Qwen3.8-Flash-Next. Greedy, warmed, and counting every streamed delta: this model emits most tokens as reasoning_content, so counting only content understates the rate badly.

build decode
#320 (dequantize to bf16) 27.43 tok/s
this PR 34.20 tok/s (34.16 / 34.24)

+24.7%, run-to-run spread 0.2%, identical output hashes across runs. TTFT unchanged at ~4.87s, as expected since this does not touch prefill. VRAM usage is also unchanged, because memory_ratio hands the freed 2.49 GiB straight to the expert cache, which is most likely where the gain actually comes from on a PCIe-3 box.

That also explains the flat result @gdevenyi measured on 2x RTX 6000 Ada at TP=2 for the loading PR: with 96 GB and the dense read split across two GPUs, neither the bandwidth nor the residency effect has room to matter. The win is regime-dependent, and largest where VRAM is tight and the interconnect is slow.

Compatibility

A checkpoint carrying weight_scale_inv without declaring FP8_PB_WO still takes #320's dequant path unchanged, so builds that quantize the dense side but describe it differently keep working.

FlashML-org#320 loads modelopt MIXED_PRECISION checkpoints by dequantizing the block-FP8
dense projections to bf16 at load, which doubles the bytes read on every decode
step. Keep them quantized instead and let the existing Fp8Block linears consume
them: 4.98 GiB -> 2.49 GiB of dense weights on the modelopt build of
Qwen3.8-Flash-Next (156 FP8_PB_WO tensors, 2.67G elements).

The checkpoint declares this per module - 48 .mlp.experts as NVFP4 and 156
attn/GDN projections as FP8_PB_WO - so config.py now reports
attn_quant="fp8_block" independently of expert_quant, and gdn.py takes the
block-fp8 path when either says so. It previously keyed off expert_quant alone,
so a checkpoint with NVFP4 experts and block-FP8 dense never reached it.
quant_linear.py's factories widen the same way, and qwen4_exp attention builds
qkv_proj/o_proj through them instead of hardcoding the bf16 classes.

The loader stops dequantizing when the declaration is present, keeps the
weight_scale_inv tensors, and swaps in a fusion table matching the modules the
model actually builds: the four-way in_proj fusion splits into an fp8 qkv|z GEMM
plus a small bf16 b|a GEMM - the split gdn.py already implements for block-fp8,
matching sglang/vLLM - and each fp8 group fuses its scale on the same axis as its
weight. That split is what unblocks the bulk of this: b|a are bf16, so the old
four-way cat mixed dtypes and forced the dequant. Every fp8 part is a whole
number of 128-row blocks (10240/6144 and 12288/512/512), so the per-block scales
concatenate exactly alongside the rows they describe.

A checkpoint carrying weight_scale_inv WITHOUT declaring FP8_PB_WO still takes
the FlashML-org#320 dequant path, so builds that quantize the dense side but describe it
differently keep working unchanged.

Verified on the modelopt checkpoint without a GPU: parse_config yields
nvfp4/fp8_block, and "none" when the declaration is removed; iter_weights emits
in_proj_qkvz [16384,2560] fp8 + scale [128,20], in_proj_ba [96,2560] bf16,
qkv_proj [13312,2560] fp8 + scale [104,20], and fp8 out_proj/o_proj - the exact
buffers Fp8BlockLinear declares. Serving numbers to follow.
@gdevenyi

gdevenyi commented Sep 5, 2026

Copy link
Copy Markdown

Ran this on 2 x RTX 6000 Ada (sm_89, 48 GB each, PCIe 4 x16, no NVLink) with the lovedheart NVFP4-FP8 (modelopt MIXED_PRECISION) checkpoint, offload backend, KV pool 262,144 tokens, pinned PLE, 8 running requests, one card (TP=1) unless stated. Tree: main af71ba4 + TP (#385) + load-time per-tensor FP8 (#389) + this PR cherry-picked. Same harness as my other PR feedback: single-stream = median of three 256-token generations, aggregate = eight concurrent 256-token requests, TTFT on a ~1k-token prompt, residency = expert slots / 24,576, probe = the same 8-question greedy smoke test (thinking off).

build, TP=1 on one card single-stream 8 concurrent residency TTFT 1k probe
#320 (dequantize to bf16) 58.4 tok/s 135.9 tok/s 36.6% 2.04 s 7/8
this PR (native block-FP8) 69.9 (+19.7%) 150.2 (+10.5%) 40.5% 1.92 s 7/8
#389 load-time per-tensor FP8 on the same checkpoint (dequantize, re-quantize per tensor, _scaled_mm W8A8) 71.7 (+22.7%) 153.4 (+12.9%) 40.4% 1.94 s 7/8

So the win reproduces on Ada at TP=1, and it is the residency effect you describe: +3.9 points of experts on the card. In isolation the Triton block-FP8 GEMV is slower than bf16 cuBLAS on this card (3.78 vs 3.20 ms per 48-layer decode step at M=1, the micro-benchmark in #389), but the freed 2.5 GiB buys more than that costs. Per-tensor _scaled_mm edges it out by 2-3% at the same residency because that GEMM is about 2x faster than the block kernel on sm_89.

At TP=2 (both cards, 16 running, vision tower loaded) the block-FP8 linears have no parallel variant, so I carry this PR on my deploy branch with a precedence rule: FREETOKEN_FP8_DENSE=1 dequantizes and re-quantizes per tensor (TP-capable); otherwise native block-FP8 at TP=1 (this PR); otherwise bf16 under TP>1. Lovedheart at TP=2 that way: 99.8 tok/s single-stream, 336.7 at 8 concurrent, 95.8% residency, TTFT 0.81 s, the same as the RadixArk NVFP4 build with the same flag (99.2-99.6 / 331.9). Probe 6/8 at TP=2. The 6/8 at TP=2 is the prime-count question (primes in 10..30, answered 5): the RadixArk build also misses it at TP=2 in bf16 and in FP8, the bf16-dequant lovedheart run at TP=2 scored 7/8, and every TP=1 run scored 7/8. It is a borderline question that flips with the numeric path (TP=2 reduction order, FP8), and this is a one-question smoke test, not a perplexity measurement. Greedy outputs against the bf16-dequant run: the 1k-token prompt is identical for both FP8 routes at TP=1; the two short prompts diverge after 13-79 words, against a run-to-run floor of ~84 words / identical / identical on this box.

Two notes on the diff from that merge: (1) routing the bf16 fallback of attention.py through make_col_merged / make_replicated drops the TP-aware classes (local_output_sizes, row-parallel o_proj) that #385 needs; in my merge the factories are used only for the fp8_block branch. (2) _dense_is_block_fp8(model_path) in weight.py and parse_config read the declaration independently; when a rank has to downgrade (TP>1) the two must agree, so I resolve both from one helper (dense_quant_mode) after the TP info is set. Branch: gdevenyi/FreeToken deploy/chatdnp (5d97d3a).

Both from @gdevenyi's review on 2 x RTX 6000 Ada, where this is carried on
a deploy branch alongside FlashML-org#385 (TP).

1. Attention routed its bf16 fallback through the quantized factories too,
   which swaps in their generic fallback and drops the tensor-parallel
   classes FlashML-org#385 needs (per-rank local_output_sizes, row-parallel o_proj).
   That is exactly the path a rank takes under TP>1, since the block-FP8
   linears have no parallel variant. The factories are now used only on the
   fp8_block branch; every other case keeps LinearColParallelMerged /
   LinearReplicated as before.

2. config.parse_config and weight._dense_is_block_fp8 read the same
   declaration through two independent code paths, each with its own copy of
   _FP8_BLOCK_ALGOS. That is safe only while they cannot disagree, and they
   can: a rank downgrading under TP>1 must have the modules it BUILDS and the
   buffers it LOADS downgrade together, or the buffers will not match. Both
   now resolve through one helper, config.dense_quant_mode, which owns the
   declaration test and the TP downgrade. The duplicate constant is gone.

   It reads TP through try_get_tp_info, not get_tp_info: Engine.__init__ sets
   TP info as its first statement so a rank always knows its size by the time
   this matters, but config parsing also happens with no engine at all
   (checkpoint conversion, tooling, tests) where get_tp_info raises.

Verified on the modelopt checkpoint: parse_config still yields
nvfp4/fp8_block; the two sides agree at TP=1 (both fp8_block) and at TP=2
(both downgraded); attention builds LinearColParallelMerged/LinearReplicated
under bf16 and Fp8BlockColMerged/Fp8BlockLinear under fp8_block.

tests/models/qwen4_exp/test_config.py + test_weight.py: 30 passed. The whole
qwen4_exp suite reports 47 failed / 46 passed / 50 skipped both at the
merge-base and with these fixes - identical sets, no regressions. Those
failures are pre-existing and are an artefact of this box rather than the
code: its single 24 GB card is 23.6 GB occupied serving a model, so the
GPU-dependent tests cannot allocate. I have not been able to run them on a
free card.
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.

2 participants