feat(qwen4_exp): load-time per-tensor FP8 dense projections (W8A8 via _scaled_mm) - #389
Draft
gdevenyi wants to merge 6 commits into
Draft
feat(qwen4_exp): load-time per-tensor FP8 dense projections (W8A8 via _scaled_mm)#389gdevenyi wants to merge 6 commits into
gdevenyi wants to merge 6 commits into
Conversation
…ackend) Shard the dense weights per rank at load (attention qkv by head, GDN in_proj as its six parts with the matching conv1d channels and A_log/dt_bias, shared-expert gate_up per part; o_proj/out_proj/down_proj row-parallel; embed/lm_head by vocab rows) and the NVFP4 expert banks along the intermediate axis, so every rank holds half the experts and each MoE layer needs one all-reduce (routed + gate * shared are combined before the reduce). Router, QSA indexer, norms, hyper-connections and PLE stay replicated so all ranks select the same blocks and n-gram rows. Also: LinearColParallelMerged(local_output_sizes=) for the kv-replicated case and distributed_timeout 60 -> 1800 s (ranks reach their first collective minutes apart behind a 100+ GiB load). Limits: offload backend with bf16 dense projections; fp8_block / nvfp4 dense checkpoints raise under TP. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
tests/models/qwen4_exp/test_weight.py feeds iter_weights a synthetic checkpoint whose config.json has no model_type; at TP=1 nothing is sharded, so do not touch the config. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
… _scaled_mm) Opt-in with FREETOKEN_FP8_DENSE=1 on a bf16-dense checkpoint (e.g. the RadixArk NVFP4 build): the weight reader quantizes qkv_proj / o_proj, GDN in_proj (q|k|v|z; the b|a gate rows stay bf16 as in_proj_ba) and out_proj to per-tensor e4m3 after TP sharding, and layers/fp8_dynamic.py runs them as cuBLASLt W8A8 GEMMs with a dynamic per-tensor activation scale (one fused Triton launch at decode sizes; no host sync, CUDA-graph safe). Column-merged and row-parallel variants, so it works at TP>1. Why: on an RTX 6000 Ada (sm_89, torch 2.11.0+cu130) these projections are 2.67 GB of the ~4 GB a TP=2 rank reads per token; bf16 cuBLAS takes 3.2-3.4 ms per step per rank, raw _scaled_mm 1.9 ms, while the existing Triton FP8 kernels are slower than bf16 there (measured, weights rotated past the L2). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
…fore the cache planner runs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
t[qkvz:].contiguous() on a contiguous row slice returns a view, so every GDN layer's bf16 gate rows kept the whole sharded bf16 in_proj resident next to the fp8 copy: 36 x 42 MB = 1.5 GiB per TP=2 rank, which is why the expert cache planner saw no saving (22,594 -> 22,458 slots) after the FP8 switch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
Author
|
3f8f249: the expert-cache planner now sees the halved dense bytes. |
This was referenced Sep 5, 2026
Open
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.
What this adds
FREETOKEN_FP8_DENSE=1: load-time per-tensor FP8 for the bf16 attention / GDN projections ofqwen4_exp, run as cuBLASLt W8A8 GEMMs (torch._scaled_mm). Opt-in, default off, no checkpoint change: the weight reader quantizesqkv_proj,o_proj, GDNin_proj(q|k|v|z; the b|a gate rows stay bf16 asin_proj_ba, as in the block-fp8 checkpoints and in sglang / vLLM) andout_projto e4m3 with one fp32 scale each, after TP sharding.layers/fp8_dynamic.pyholds the op: a dynamic per-tensor activation scale (one fused Triton launch at decode sizes: amax pass, then the cast; a torch reduction plus a cast kernel above 64k elements), the_scaled_mmcall, and the all-reduce for the row-parallel case. No host sync anywhere, so the decode path is CUDA-graph safe; the branch between the two quant paths is on the tensor shape, never on its values. Requires sm_89+ (_scaled_mm's floor).Stacked on #385 (the TP commits): the column-merged / row-parallel classes shard the same way its bf16 ones do.
Why
On 2 x RTX 6000 Ada (sm_89, torch 2.11.0+cu130, flashinfer 0.6.18) these projections are 2.67 GB of the ~4 GB a TP=2 rank reads per decode token. Micro-benchmark at the per-rank shapes (12 x [6656x2560] + [2560x3072], 36 x [8192x2560] + [2560x3072]), weight rotations larger than the 96 MB L2, activation quantization outside the timed region for the raw rows:
fp8_block_linear.py)fp8_pertensor_linear.py)torch._scaled_mm, per-tensor scalesThe FP8 tensor cores are fine here; the existing Triton FP8 kernels reach 20-60% of bf16's bandwidth on this stack and the per-tensor wrapper spends ~24 us per call before cuBLASLt, so the FP8 checkpoints are slower than bf16 at decode on this card. This path takes the direct route.
Measurements
A/B on the machine above (
RadixArk/Qwen3.8-Flash-Next-NVFP4, TP=2,--moe-backend offload --ple-backend pinned --num-tokens 262144 --memory-ratio 0.94 --moe-prefill-hit-d2d --max-running-requests 16 --cuda-graph-max-bs 16, vision tower loaded), both runs from the same build in the same session:FREETOKEN_FP8_DENSE=1--moe-cache-auto)For the greedy rows the bf16 run-to-run floor at TP=2 is 84 words / identical / identical, so FP8 changes the sampling trajectory more than the noise does, as expected from different numerics; the outputs stay on topic (the code case continues the same memoised Fibonacci past where bf16 stopped). The +10% matches the micro-benchmark: ~1.3 ms per step per rank (4 GEMMs per layer, 48 layers) of an ~11 ms step.
Residency first fell (22,594 -> 22,458 slots) although the reader emits 1.25 GiB less per rank:
in_proj_ba.weightwas produced ast[qkvz:].contiguous(), and.contiguous()on an already-contiguous row slice returns the view, so every GDN layer's 48 bf16 gate rows kept the whole sharded bf16in_projalive next to its fp8 copy (36 x 42 MB = 1.5 GiB per rank). 3f8f249 clones the slice (regression assertion in the test); the planner then resolves 23,539 slots, 95.8% residency, with the same 2.6 GiB headroom.Limits
torch<2.12pin (sglang-kernel 0.4.5) blocks anyway; per-tensor stays.Testing
tests/models/qwen4_exp/test_fp8_dense.py: loader round trip within e4m3 tolerance, thein_projsplit per rank (fp8 q|k|v|z rows + bf16 b|a rows equal to the source), op state-dict contract (CPU); the op againstF.linearon the dequantized weight at M=1 / 16 / 300 and the zero-input scale floor (GPU).tests/models/qwen4_exp+tests/scheduleron the box: 153 passed, 104 skipped (CPU).🤖 Generated with Claude Code
https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt