feat(qwen4_exp): load-time per-tensor FP8 dense projections (W8A8 via _scaled_mm) - #389
feat(qwen4_exp): load-time per-tensor FP8 dense projections (W8A8 via _scaled_mm)#389gdevenyi wants to merge 16 commits into
Conversation
|
3f8f249: the expert-cache planner now sees the halved dense bytes. |
|
Two follow-up commits pushed onto this branch, both measured end to end on 2 x RTX 6000 Ada (sm_89, TP=2,
The partial count reaches the kernels as a runtime argument, not a constexpr, and pass 1 is grid-strided and capped, so both kernels have exactly one compiled variant. Passing it as a constexpr instead costs a fresh compilation per distinct input length, which in a server means unbounded variant growth and a compile stall mid-generation — that cost ~7 tok/s on the first benchmark run before it was fixed.
The vocab-parallel head is the last large bf16 read on the decode path: 0.64 GB per step per rank after the TP=2 vocab split, 0.80 ms of a 9.9 ms step in an nsys trace. Its own env flag rather than riding on Measured against this branch as the baseline, production flags, warm single-stream (the benchmark's first run is always cold):
The aggregate barely moves, and that is expected: at 8 concurrent the step is ~24.7 ms rather than ~10 ms because the routed-expert read scales with the batch, so a fixed dense-side saving is a much smaller fraction of it. This is a single-stream lever. Tests: One thing I tried on top and am not proposing: the same treatment for the hyper-connection mixers, which look like the obvious next target ( 🤖 Generated with Claude Code |
…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
quant_per_tensor ran a single Triton program over the whole tensor, so its cost grew linearly with the input: 9.8 us for an [8, 10240] activation and 68.6 us at [64, 10240], against ~1.2 us of useful work. Above 16384 elements, split it into a block-parallel partial amax and a reduce+cast, which flattens the cost to ~2.4 us. The arithmetic is the one the single-program kernel already used, so a given tensor quantizes bit for bit as before; the old three-launch torch reduction path above 65536 elements goes away with it. The partial count is a runtime argument and the pass-1 grid is strided and capped at _MAX_PARTS, so both kernels have exactly ONE compiled Triton variant. Letting the partial count reach the kernel as a constexpr instead costs a fresh compilation for every distinct input length -- unbounded variant growth in a server that sees arbitrary prompt lengths, and a compile stall mid-generation. Measured on an RTX 6000 Ada (sm_89, torch 2.11) under CUDA-graph capture, one-program -> split: 20480 elts 2.59 -> 2.22 us, 81920 elts 9.83 -> 2.44 us, 655360 elts 68.60 -> 3.06 us. Below the threshold the single program still wins (2560 elts: 1.25 vs 2.07 us) and is kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt (cherry picked from commit d254729)
The vocab-parallel head is the last large bf16 read on the decode path: 0.64 GB per step per rank at the Qwen3.8-Flash-Next geometry ([124160, 2560] after the TP=2 vocab split), 0.80 ms of the 9.9 ms step in an nsys trace of production. Its own flag, not FREETOKEN_FP8_DENSE, because this one moves the logits: every other quantized module feeds a norm or a sigmoid downstream, while a per-tensor e4m3 vocab matrix changes each sampled token's score directly, so it carries its own quality gate rather than riding along with the pure-throughput changes. ParallelLMHead.forward grows a _logits() seam (the local vocab-shard GEMM); Fp8ParallelLMHead overrides only that, leaving the all_gather of the logits above it untouched. Untied embeddings only -- a tied head shares the bf16 embedding table, which the lookup side still reads as bf16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt (cherry picked from commit 35e80b3) (cherry picked from commit 0693735) (cherry picked from commit 1797ab5)
902f506 to
85c4b60
Compare
|
Rebased onto What this PR is, restated against the new architecture. #418 gives every module its scheme from the checkpoint's This PR is the other case: a checkpoint that ships its dense side in bf16, quantized per-tensor at load with a dynamic activation scale. Qwen3.8-Flash-Next NVFP4 is exactly that — a 292-entry modelopt Selection. #426 deleted the attn_quant = "fp8_dynamic" if fp8_dense_enabled() else "none"and every consumer additionally requires the checkpoint to declare nothing for that module: fp8_dyn = config.attn_quant == "fp8_dynamic" and (
config.quant is None or config.quant.scheme_for(f"{prefix}.qkv_proj") is None
)so a quantized checkpoint always wins and goes through I think the cleaner long-term shape is to express this as a real scheme + Other resolutions: Testing. Full 🤖 Generated with Claude Code |
|
Re-tested against current main ( Method. This PR's head merged onto main, then the full Result: 1219 passed, 359 skipped, no new failures. 9 more tests skip here than on main: this PR adds that many CUDA-only tests the hidden-GPU run does not exercise. So the result above says it merges, imports and leaves every CPU-reachable path intact — it says nothing about the kernels themselves. I am running those on free cards in a maintenance window and will post the numbers here. 🤖 Generated with Claude Code |
…build
Qwen4ExpDecoderLayer builds its MoE as `Qwen4ExpMoE(config, layer_id, prefix=...)`,
but this PR's override of __init__ (added to hold the TP communicator) took only
(config, layer_id), so a server boot died with
TypeError: Qwen4ExpMoE.__init__() got an unexpected keyword argument 'prefix'
The whole CPU test suite was green with that bug in place, because every test that
builds a decoder layer is behind requires_cuda -- nothing without a GPU ever
constructed the model. tests/models/qwen4_exp/test_build_cpu.py closes that: it
builds the full model on the meta device (no GPU, no memory) and asserts the state
dict has both layer families, an lm_head, and MoE weights on more than one layer,
so a dropped or shared prefix fails too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
…el shard
With the expert piece stream sliced per rank and the banks sized from
MoEConfig.local_intermediate, a rank holds exactly its half of every expert, so this
kernel can serve TP>1 -- the routed output is a partial sum and the MoE layer already
reduces it (_maybe_all_reduce, or the single combined all-reduce in qwen4_exp's block).
Without this the whole selection table is empty under TP=2 on sm_89 and the server
refuses to start:
KernelSelectionError: no usable kernel in table;
triton: TP > 1 is not supported for this expert format;
marlin: vLLM is not installed;
b12x: b12x requires sm_120+, got sm_89
marlin and b12x keep tp_ok=False deliberately: their pack() repacks the native rows and
neither has been verified against a per-rank bank.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
…d loader alike
The model builder and the weight reader tested this separately and drifted, so on the
shipping checkpoint the reader emitted lm_head.weight_scale while the builder made a
plain ParallelLMHead, and startup died with
RuntimeError: Unexpected keys in state_dict: ['lm_head.weight_scale']
The builder's extra condition was `config.quant is None`, which is wrong twice over:
what matters is whether the checkpoint declares a scheme for **lm_head**, not whether it
has a QuantConfig at all. This model ships NVFP4 routed experts (so quant is not None)
with lm_head in the modelopt ignore list (so it has no scheme and the synthetic FP8 head
is exactly what is wanted).
Both sides now call config.use_fp8_lmhead(), which owns the flag, the tied-embedding
exclusion and the scheme test in one place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
The predicate moved into config.py but its import did not come with it, so building the model raised NameError on the very first line of use_fp8_lmhead -- under every flag setting, including the default one that wants no FP8 head at all. Found by the CPU key check (build the model on meta, diff its slots against the names the loader emits) before it could cost a GPU window. No behaviour change beyond the module now importing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
|
Two correctness fixes pushed onto this branch ( 1. The FP8 lm_head predicate was evaluated twice, and the copies drifted.
The builder's extra condition was Generalising: any load-time quantization has this shape. The builder allocates slots, the loader emits keys, and 2. How #2 was caught, and the check I would suggest upstream adopt. Build the model on
The reason this class of bug survives CI is that every model-construction test in Known limitation, not fixed here. The model side gates the dense FP8 path per layer — 🤖 Generated with Claude Code |
The rebase onto the quantization refactor dropped
nk, nv = self._local_num_k_heads, self._local_num_v_heads
from the top of GatedDeltaNet.forward while keeping all eleven uses of nk and nv below
it. Every decode died at the first CUDA-graph capture:
File "python/freetoken/models/qwen4_exp/gdn.py", line 189, in forward
b, a = torch.split(ba, [nv, nv], dim=-1)
NameError: name 'nv' is not defined
Also drops a duplicate `from freetoken.distributed import get_tp_info` two lines under
the first, left by the same merge.
`ruff check --select F821` reports both, and would have reported the missing
fp8_lmhead_enabled import a commit earlier. Lint the tree before asking for the GPU, not
after: this file's eleven undefined names cost a window that a sub-second check would
have saved. The eight F821 hits outside qwen4_exp are pre-existing on main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
|
Third fix on this branch ( The rebase onto the quantization refactor dropped a single line from nk, nv = self._local_num_k_heads, self._local_num_v_headsand kept all eleven uses of
That suggests something worth considering for the repo rather than just for me: there is no ruff configuration and no lint job in I have not looked at whether those are live paths or dead branches, and some may be deliberate forward references. But each one is a The same commit removes a duplicated Where this branch now stands on hardware. 2 x RTX 6000 Ada, TP=2, 🤖 Generated with Claude Code |
|
Accuracy result for the load-time FP8 path, measured properly this time. Earlier I reported GSM8K on this stack as "97.00% unchanged" from an older note. That figure was stale and I should not have quoted it. Here is a controlled A/B run in one session, same harness, both servers settled, same 300 questions in the same order, on 2 × RTX 6000 Ada at TP=2 with
So per-tensor FP8 on the dense projections and the lm_head costs no measurable accuracy on this checkpoint — one question better, two fewer genuine misses, which is noise at n=300 but is certainly not a regression. One caution for anyone reproducing this. At the default 768-token cap the comparison inverts: 95.67% with FP8 against 96.33% without, which reads as a 2-question regression. It is not. The FP8 run truncated 10 answers at the cap and the baseline truncated 6, and a per-question flip analysis shows 9 of the 10 truncated answers become correct at 1536. Quoting GSM8K on this model without stating the cap is close to meaningless; the untruncated number is the one that means something. The branch is also now free of the two startup faults reported above — the 🤖 Generated with Claude Code |
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