Skip to content

feat(qwen4_exp): tensor parallelism for Qwen3.8-Flash-Next (offload backend) - #385

Draft
gdevenyi wants to merge 5 commits into
FlashML-org:mainfrom
gdevenyi:feat/qwen4-exp-tp
Draft

feat(qwen4_exp): tensor parallelism for Qwen3.8-Flash-Next (offload backend)#385
gdevenyi wants to merge 5 commits into
FlashML-org:mainfrom
gdevenyi:feat/qwen4-exp-tp

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Sep 4, 2026

Copy link
Copy Markdown

What this adds

ft serve --tp-size 2 for qwen4_exp (Qwen3.8-Flash-Next) on the offload MoE backend. Upstream refuses TP>1 for this architecture; with two 48 GiB cards the model runs as two independent TP=1 instances, each keeping 37% of the NVFP4 experts resident and streaming the rest over PCIe every step.

Per rank, the patch:

  • Shards the dense weights at load (weight.py::_shard): attention qkv_proj by head ([q|gate] per head; kv heads split, or replicated when there are fewer than ranks), GDN in_proj as its six parts ([q | k | v | z | b | a]) plus the matching conv1d channels and A_log / dt_bias, shared-expert gate_up_proj per part; o_proj, out_proj and the shared down_proj row-parallel with the all-reduce inside; embed_tokens / lm_head by vocab rows. Router, QSA indexer, norms, hyper-connections and PLE stay replicated, so every rank selects the same sparse blocks and n-gram rows.
  • Shards the NVFP4 expert banks along the intermediate axis (nvfp4_banks.py, I=640 -> 320 per rank): packed codes, the 16-wide scale blocks and the per-row globals for the gate/up rows and the down columns. The offload cache then holds half the experts per rank.
  • One all-reduce per MoE layer (moe.py): the routed and shared partial sums are combined as routed + sigmoid(gate) * shared before a single reduce, instead of one reduce each.
  • LinearColParallelMerged(local_output_sizes=) for the kv-replicated case (the same shape as feat(models): support TP for qwen3_5_moe #104's hunk), and distributed_timeout 60 s -> 1800 s: behind a 100+ GiB load the ranks reach their first collective minutes apart, and 60 s kills the launch.

Everything else in the engine (scheduler, KV pool, PLE table, CUDA graphs) is untouched.

Measurements

2 x RTX 6000 Ada (48 GiB, sm_89, PCIe Gen4 x16, no NVLink), 2 x Xeon Gold 6526Y, 503 GiB RAM. RadixArk/Qwen3.8-Flash-Next-NVFP4, --moe-backend offload --ple-backend pinned --num-tokens 262144 --memory-ratio 0.94 --moe-prefill-hit-d2d.

run (both GPUs, KV 262,144 tokens) single-stream tok/s 8 concurrent tok/s expert residency TTFT, 1k prompt
two TP=1 instances (previous layout) 55.9 / 56.7 266.1 aggregate 36.6% each 2.05 s
TP=2, 8 running requests 88.6 298.8 100% 1.55 s
TP=2, 16 running / graph bs 16 89.8 324.5 94.5% 0.84 s

A 262,144-token prompt reaches its first token in 74 s at TP=2 (116 s at TP=1). The decode step is dominated by the bf16 dense read; TP=2 halves it per GPU and removes the PCIe expert gather because everything fits.

Correctness. An 8-question probe gives identical answers at TP=1 and TP=2. Three raw prompts decoded greedily for 256 tokens: the ~1k-token prompt (QSA over many blocks, GDN state, PLE context) is word-for-word identical across every run; the two short prompts diverge after 13 and 29 words between TP=1 and TP=2, but the TP=2 server diverges from itself at the same points on a second pass (bf16 atomics in the expert kernels), so that is run-to-run noise rather than a sharding error. Greedy output at TP=2 is not bit-exact between passes.

Limits

Offload backend with bf16 dense projections only: fp8_block / nvfp4 dense checkpoints raise under TP (row-parallel FP8 / NVFP4 linears do not exist yet, the same gap #104 has). The hybrid / CPU MoE backends are not sharded.

Related: #62, #29 (TP for offloaded MoE), #104 (TP for qwen3_5_moe, which this reuses the merged-linear hunk from).

Testing

  • tests/models/qwen4_exp/test_tp_shard.py: the per-head / per-part row sharding of every fused projection reassembles to the original (CPU).
  • tests/models/test_nvfp4_banks_tp.py: the bank placer's per-rank slices of codes, scales and globals cover the intermediate axis exactly once (CPU).
  • Served for a day on the machine above at TP=2 with the numbers in the table. tests/models/qwen4_exp on one of its GPUs: 97 passed, 3 failed; the same 3 (test_chunked_prefill_matches_one_shot[*], a bit-exact assertion off by bf16 noise on this torch 2.11 / flashinfer 0.6.18 / triton 3.6 stack) fail on plain main there too.

🤖 Generated with Claude Code

https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

@gdevenyi

gdevenyi commented Sep 4, 2026

Copy link
Copy Markdown
Author

tests/models/qwen4_exp on a RTX 6000 Ada with this branch: 97 passed, 3 failed; plain main (af71ba4) on the same GPU: 94 passed, the same 3 failed (test_qsa_backend.py::test_chunked_prefill_matches_one_shot[*], a bit-exact chunked-vs-one-shot assertion that is off by bf16 noise with torch 2.11.0+cu130 / flashinfer 0.6.18 / triton 3.6.0). So nothing here regresses the package; the second commit fixes the one regression the first had (test_weight.py feeds iter_weights a synthetic checkpoint without model_type, and the loader now reads the config only when TP > 1).

gberasmus87 added a commit to gberasmus87/FreeToken that referenced this pull request Sep 5, 2026
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.
gberasmus87 added a commit to gberasmus87/FreeToken that referenced this pull request Sep 5, 2026
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 added a commit to gberasmus87/FreeToken that referenced this pull request Sep 10, 2026
qwen4_exp is the only family that builds its attention o_proj as
LinearReplicated; llama, gpt_oss and minimax_m2 all use LinearOProj. That is
correct at TP=1 and wrong under TP>1 three ways at once: qkv_proj is
column-parallel, so a rank's attention output is its local head slice rather
than the full qo_attn_dim, o_proj therefore has to take the sharded input dim,
and the partial sums need an all-reduce. LinearReplicated keeps the full
[hidden, qo_attn_dim] weight, expects the unsharded input and reduces nothing.

It also fails quietly: a missing all-reduce leaves each rank holding a partial
sum that still decodes to fluent-looking text.

LinearOProj degenerates to exactly 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 as it stands and only changes what FlashML-org#385 finds when the two meet. It adds
no constraint from calling get_tp_info() in __init__ either, since the same
constructor already reaches it two lines up through LinearColParallelMerged.

The comment above the branch now describes what is built.

Raised by @gdevenyi against the earlier form of this work in FlashML-org#392.
gdevenyi and others added 3 commits September 10, 2026 14:54
…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
@gdevenyi

Copy link
Copy Markdown
Author

Rebased onto fb7f732 (main after #418 / #427 / #426). The model-side half rebased; the expert-bank half was a rewrite, and the new version is smaller and more general, so it is worth describing rather than just re-pushing.

What changed and why. The original sharded inside load_nvfp4_expert_source_banks with a local _tp_slice + _Placer that wrote each checkpoint tensor into its bank slot. That function no longer exists: #418 turned bank building into model -> expert piece stream -> moe/expert_banks.build_expert_banks -> the kernel's pack, and nvfp4_banks.py now only yields pieces.

So the sharding moved to the two places the new architecture actually owns:

  1. nvfp4_banks._tp_shard slices each checkpoint tensor to this rank's slice of the expert intermediate axis as it streams (gate/up on the row axis, down on the column axis — halved for the packed FP4 codes, sixteenthed for the fp8 block scales; the per-tensor _global has no I axis and is kept whole).
  2. layers/quantization/moe/nvfp4.py sizes its banks from cfg.local_intermediate instead of cfg.intermediate.

MoEConfig already had tp_rank, tp_size and local_intermediate — the NVFP4 method simply was not using them (unquantized and mxfp4 already do). So this is less new machinery than the original PR, and because it sits in the shared method rather than in qwen4_exp, it now applies to any NVFP4 model on the offload path rather than just this one. At TP=1 local_intermediate == intermediate, so nothing changes.

tests/models/test_nvfp4_banks_tp.py was rewritten against that contract: for each role and tp_size in {1,2,4}, the ranks' slices must concatenate back to the original tensor exactly, on the correct axis — which is the property the all-reduce depends on. It also asserts a shard that would split a 16-wide scale block is rejected, and that MoEConfig.local_intermediate agrees with the slice.

Two other resolutions worth flagging:

Testing. Full pytest tests with CUDA hidden (deterministic — with the GPUs visible this box is serving a model and 65-95 GPU tests fail on main itself, so only the set difference means anything): main 1205 passed / 0 failed, this branch 1214 passed / 0 failed, and the set of failures new relative to main is empty. The +9 are the rewritten TP tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

@gdevenyi

Copy link
Copy Markdown
Author

Re-tested against current main (fb7f732, i.e. after the #418 / #427 / #426 quantization refactor) on 2 x RTX 6000 Ada, TP=2 box.

Method. This PR's head merged onto main, then the full pytest tests suite. The run is CUDA-hidden (CUDA_VISIBLE_DEVICES="") on purpose: this box is serving a model on both GPUs, and with them visible 65-95 GPU tests fail on main itself with AcceleratorError: out of memory, with the count swinging ~10 between identical runs. Hiding CUDA makes the result deterministic, so a failure-set difference against main means something. Baseline: main = 1205 passed, 350 skipped, 0 failed.

Result: 1214 passed, 350 skipped, no new failures.

The +9 over main are this PR's own tests, and they ran (not skipped).

🤖 Generated with Claude Code

https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

gdevenyi and others added 2 commits September 10, 2026 18:02
…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
@gdevenyi

Copy link
Copy Markdown
Author

Pushed d2ad39b: let TritonNvfp4MoEKernel serve a tensor-parallel shard.

Integrating this branch with the NVFP4 expert path after the #418/#427 quantization refactor, the engine refused to start:

KernelSelectionError: no usable kernel in table; triton: TP > 1 is not supported

Every NVFP4 MoE kernel carried tp_ok=False. For marlin and b12x that is right — they index the full intermediate. The triton kernel does not: once MoEConfig gained tp_rank/tp_size/local_intermediate it already has everything it needs, and its seven remaining uses of cfg.intermediate were simply the un-migrated spelling of cfg.local_intermediate. Fixed those and flipped tp_ok=True for the triton kernel only.

Worth a second pair of eyes on one thing: this made the bank-sizing bug below silent rather than fatal, so I want the sizing assertion reviewed as much as the flag.

Related, and the more interesting failure. A merge of upstream main into the deployment line silently dropped this same local_intermediate change. layers/quantization/moe/nvfp4.py was new in main, so the merge took upstream's copy wholesale with no conflict to resolve — the file did not exist on the other side to conflict with. At TP=2 the expert bank came out (1280, 1280) instead of (640, 1280): twice the memory, wrong shard, no error. I found it by checking all 42 files the rebase touched for the "new in main, so no conflict" pattern; it was the only one. If you take this PR, that class of loss is worth knowing about for any future rebase across the refactor.

Also on this branch since the last comment: tests/models/qwen4_exp/test_build_cpu.py, which builds the whole model on torch.device("meta") and asserts both layer families, lm_head, and MoE weights on more than one layer. It exists because every model-construction test in tests/models/qwen4_exp is behind requires_cuda, so a constructor signature change (Qwen4ExpMoE.__init__() got an unexpected keyword argument 'prefix') passed CI on a green CPU suite and only surfaced on hardware. Verified it fails without the fix it guards.

🤖 Generated with Claude Code

https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

@gdevenyi

Copy link
Copy Markdown
Author

End-to-end validation of this PR at TP=2, as a controlled A/B against the branch it replaces.

Our deployment branch (upstream fb7f732 + this PR + #386 + #389 + a few local fixes) was benchmarked against the previous production branch in one session, same harness, both servers settled before measuring, same 300 GSM8K questions in the same order. 2 × RTX 6000 Ada, nvidia/Qwen3.8-Flash-Next-NVFP4, offload MoE, fp8 KV, 2,097,152-token pool.

previous branch with this PR
single-stream decode 105.80 / 105.69 tok/s 105.06 / 105.68
aggregate @ 8 concurrent 353.36 / 357.37 354.23 / 357.31
TTFT, 1k prompt (3 runs) 0.79 / 0.73 / 0.70 s 0.680 / 0.683 / 0.680
expert residency 0.717 0.717
GSM8K-300 greedy, 1536 cap 97.67% (293/300) 98.00% (294/300)
genuine cap-independent misses 7 5

Throughput and residency are identical. TTFT is ~7% better on the mean, and more interestingly it is flat at 0.68 from the first measurement where the old branch drifts 0.79 → 0.70 as its expert cache settles. Accuracy is one question higher with two fewer genuine misses — both inside noise at n=300, but nothing regressed.

Two methodology notes, because they cost me real time and may save yours:

Do not benchmark this box inside ~10 minutes of start. The aggregate reads 2–3% low while the expert cache fills. My first run on the new branch showed 343.7 against a 352.3 baseline and I nearly reported a 2.4% regression that did not exist; settled, it is 354–357.

GSM8K at the default 768-token cap measures truncation as much as reasoning. At 768 the two branches score 95.67% and 96.33%, which looks like a regression; the difference is entirely that one truncated 10 answers and the other 6. At 1536 the ordering reverses. Per-question flip analysis: 9 of 10 truncated answers become correct when the cap is lifted.

Since the last comment this branch also carries 1c09ceeTritonNvfp4MoEKernel now serves a tensor-parallel shard, without which TP=2 raises KernelSelectionError: no usable kernel in table on an NVFP4 checkpoint.

🤖 Generated with Claude Code

https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt

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.

1 participant