Skip to content

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

Open
gberasmus87 wants to merge 4 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 4 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.
@gdevenyi

gdevenyi commented Sep 5, 2026

Copy link
Copy Markdown

Thanks for taking both points. The dense_quant_mode consolidation matches what I carry on the deploy branch, and resolving it through one helper with try_get_tp_info is the right call — a rank has to build and load the same mode or the buffers do not match.

One thing to flag on point 1, not a problem with this PR as it stands but a hazard when it meets #385.

The new bf16 branch in models/qwen4_exp/attention.py hardcodes:

self.o_proj = LinearReplicated(self.qo_attn_dim, config.hidden_size, has_bias=False)

That is exactly what main does today, so this PR is correct on its own. But it is also the precise path a rank takes under TP>1 — the one your commit message calls out — and there LinearReplicated is wrong in three ways at once. qkv_proj is column-parallel, so each rank's attention output is its local head slice [T, local_qo_dim], not the full qo_attn_dim; o_proj therefore has to take the sharded input dim and all-reduce the partial sums afterwards. LinearReplicated keeps the full [hidden, qo_attn_dim] weight, expects the unsharded input, and performs no reduction. The loader already assumes the row-parallel layout: _shard puts o_proj on dim 1.

On my deploy branch the same else branch reads:

self.qkv_proj = LinearColParallelMerged(
    config.hidden_size, qkv_sizes, has_bias=False, local_output_sizes=self._qkv_split
)
self.o_proj = LinearOProj(self.qo_attn_dim, config.hidden_size, has_bias=False)

LinearOProj divides the input size by tp_info.size and all-reduces after the GEMM, and it degenerates to LinearReplicated's behaviour at TP=1 (div_even(x, 1) == x, the all-reduce is skipped when tp_size == 1), so it is safe for main as it stands. The one caveat is that it calls get_tp_info() in __init__, which raises when no engine has set TP info — the same reason you reached for try_get_tp_info in dense_quant_mode. So it is not a drop-in for tooling paths that build a model with no engine.

I am not asking you to change anything here; #385 is mine and the merge is my problem. Flagging it so whichever of the two lands second does not silently keep the LinearReplicated line, because the failure is quiet: the shapes only disagree once local_output_sizes shortens the qkv split, and a missing all-reduce gives each rank a partial sum that still decodes to fluent-looking text.

Also worth knowing, since your commit message mentions the two cannot be tested together on your box: the combination is running here. deploy/chatdnp carries #385 + #386 + #389 + this PR + #354, TP=2 on 2 x RTX 6000 Ada, and both attention branches are exercised — fp8_dynamic in production and the bf16 fallback whenever FREETOKEN_FP8_DENSE=0. If a TP=2 run of a specific case would help, say which and I will post it.

🤖 Generated with Claude Code

https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

Follow-up to @gdevenyi's note on the previous fix. The bf16 branch kept
o_proj as LinearReplicated, which is what main does today and is correct
at TP=1, but it is the path a rank falls back to under TP>1 (FlashML-org#385), and
there a replicated o_proj is wrong three ways at once: qkv_proj is
column-parallel so each rank's attention output is its local head slice,
o_proj therefore needs the sharded input dim, and the partial sums need an
all-reduce. It also fails quietly, since a missing reduction still decodes
to fluent-looking text.

LinearOProj does all of that and degenerates to LinearReplicated at TP=1:
div_even(x, 1) == x, and the all-reduce is skipped when tp_size == 1. So
this is a no-op for main and only changes what FlashML-org#385 finds when the two
meet, whichever lands second.

It does mean get_tp_info() runs in __init__, but the same branch already
does that two lines up through LinearColParallelMerged, so there is no new
constraint: this path was engine-only before and still is. Config parsing,
the one no-engine path that mattered, stays on try_get_tp_info.

The comment above the branch also claimed a row-parallel o_proj that the
code did not build; it now describes what is built.

Verified on ailab1 (single RTX PRO 4000 Blackwell, TP=1):

- tests/models/qwen4_exp/{test_config,test_weight,test_skeleton,
  test_qsa_backend}.py: 49 passed at 72773b0 and 49 passed with this
  change. That set includes test_qsa_layer_matches_hf_dense, which builds
  Qwen4ExpAttention on the card and checks the whole layer against the HF
  dense reference, so the new o_proj is exercised through a real forward.
- A direct check on CPU and CUDA: the built o_proj is a LinearOProj with
  weight [hidden, qo_attn_dim] and local_input_size == qo_attn_dim, and
  its forward is bit-identical (max |diff| = 0.0) to a LinearReplicated
  carrying the same weight.

Correction to the previous message: the 47 failures I attributed to VRAM
contention were, for these four files at least, a missing ninja on PATH
in my throwaway test venv. With the serving venv's bin on PATH the files
pass in full.
@gberasmus87

Copy link
Copy Markdown
Author

Thanks, that's a fair flag and cheaper to fix now than to remember at merge time. Swapped in 2b2a96e: the bf16 branch now builds o_proj as LinearOProj, same as your deploy branch. I read the class before doing it and agree it's a no-op at TP=1: div_even(x, 1) == x and the all-reduce is skipped, so the weight shape and the GEMM are identical to LinearReplicated. The comment above that branch also promised a row-parallel o_proj the code didn't build, so it now says what's there.

On the get_tp_info() caveat: it doesn't add a constraint here, because the same else branch already calls it two lines up through LinearColParallelMerged. That path was engine-only before and still is. Config parsing is the no-engine path that mattered, and that stays on try_get_tp_info. I've left local_output_sizes to #385, since main's LinearColParallelMerged doesn't take it, and likewise the forward's reshape(-1, self.qo_attn_dim) on the gate, which is the other full-width assumption in this file and belongs with whoever owns the local head count.

Verified at TP=1 on the Blackwell box: the four qwen4_exp test files that build this layer (test_config, test_weight, test_skeleton, test_qsa_backend) are 49/49 at 72773b0 and 49/49 with the change, including test_qsa_layer_matches_hf_dense, which runs the whole layer on the card against the HF dense reference. A direct check on CPU and CUDA confirms the built o_proj is a LinearOProj with weight [hidden, qo_attn_dim] and a forward bit-identical to LinearReplicated carrying the same weight. One correction to my last commit message while I'm here: the 47 failures I put down to VRAM contention were, for these four files at least, a missing ninja on PATH in my throwaway venv. With that fixed they pass in full.

I'll take you up on the TP=2 run. The case I can't exercise is exactly this one, the bf16 fallback under TP, so: deploy/chatdnp with this commit, FREETOKEN_FP8_DENSE=0, TP=2, your usual single-stream and the 8-question probe, with your fp8_dynamic production numbers as the reference. If the probe is still 7/8 and the rate lands where the fallback did before, that's the missing all-reduce ruled out rather than merely fluent.

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