Skip to content

feat(models): complete TP for qwen3_5_moe quantized checkpoints (FP8/NVFP4) - #1

Open
CraigStone-Dev wants to merge 9 commits into
RuixiangMa:feat/qwen3-5-tp-supportfrom
CraigStone-Dev:feat/qwen3-5-tp-support
Open

feat(models): complete TP for qwen3_5_moe quantized checkpoints (FP8/NVFP4)#1
CraigStone-Dev wants to merge 9 commits into
RuixiangMa:feat/qwen3-5-tp-supportfrom
CraigStone-Dev:feat/qwen3-5-tp-support

Conversation

@CraigStone-Dev

@CraigStone-Dev CraigStone-Dev commented Aug 26, 2026

Copy link
Copy Markdown

This branch completes the TP port for the quantized qwen3_5_moe checkpoints — the piece missing from this PR, which covers BF16. Community testing (alogotron, 2x RTX 3090) showed FP8/NVFP4 checkpoints die at make_row_parallel_quant under TP>1.

Changes (on top of 3c8b281)

2eae9a8 — feat(models): complete TP for qwen3_5_moe quantized checkpoints (FP8/NVFP4)

  • Row-parallel quantized linears (the missing piece): Fp8PerTensorRowParallel, Fp8BlockRowParallel, Nvfp4DenseRowParallel — sharded-K GEMM + all-reduce, bias after the reduce. The existing Triton GEMMs are K-generic, so no new kernels were needed.
  • Column-merged quantized linears accept local_output_sizes (GQA KV-head replication); the make_col_merged_quant guard is gone.
  • gdn.py: in_proj_qkvz now declares TP-local sizes (the PR left it full-size while forward splits by local sizes — a latent TP>1 bug).
  • Loaders: the mixed-FP8 (modelopt/NVFP4) and block-FP8 checkpoints are ungated and TP-sharded: fused fp8 qkv/in_proj_qkvz per sub-part (qkv expands to key|key|value with KV-head replication), standalone o_proj/out_proj row-shard, in_proj_ba per-head, conv1d/A_log/dt_bias/embed_tokens sharded; NVFP4 dense (shared_expert, lm_head) stays replicated (small, correct).
  • CPU unit tests: tests/models/test_qwen3_5_tp_quant.py (12 tests, all pass).

ab82a3f — test: add TP1/TP2 A/B verification scripts

  • tests/ab_suite.py / tests/ab_long.py: deterministic TP1-vs-TP2 comparison clients for a served endpoint (used for the E2E check below).

6197d63 — fix(models): shard lm_head vocab-parallel in the block-fp8 loader at TP>1

  • External validation (alogotron, 2x RTX 3090) found the block-FP8 checkpoint (Qwen/Qwen3.6-35B-A3B-FP8) dies at TP=2: the loader yielded lm_head full-width (248320x2048) against the vocab-sharded ParallelLMHead (124160 rows). Per-tensor sharding is now in _fp8_shard_standalone, with lm_head.weight added to the vocab-parallel branch.

a77453f — test: tiny block-FP8 checkpoint generator + TP=2 loader regression test

  • tests/models/tiny_fp8_ckpt.py: shrinks the real Qwen3.6-35B-A3B-FP8 geometry (4 layers: 3 GDN + 1 full-attn, few experts) to a few hundred MB while keeping hidden 2048, all per-layer dims, and by default the full 248320 vocab (random weights -- for load/boot validation, not coherent text).
  • test_fp8_iter_weights_tp2_sharding: runs the full _iter_weights_fp8 at TP=2 over the synthetic checkpoint (lm_head/embed vocab-sharded, o_proj/out_proj input-sharded, qkv/in_proj_qkvz per-part local rows, conv1d sub-parts, replicated router/shared expert); fails on the pre-fix loader, passes with the fix.
  • E2E: with the full-vocab checkpoint, ft serve --tensor-parallel-size 2 --disable-pynccl boots and serves on 2x RTX PRO 4000 (the pre-fix loader dies in load_state_dict with the reported mismatch).

f1ff8e2 — fix(models): shard bf16 lm_head in the mixed loader; never shard NVFP4-native emits

  • A code review of the port found two more latent loader bugs: (1) the mixed modelopt path (_iter_weights_attn_fp8) yielded a bf16 lm_head (lm_head_quant=="none") full-width against the vocab-sharded ParallelLMHead -- same class as 6197d63 (an NVFP4 lm_head is emitted native above the sharding block and correctly stays replicated); (2) the main iter_weights path ran the NVFP4-native emit (shared_expert / dense MLP / lm_head) through _maybe_shard, which sharded down_proj/lm_head while leaving their scales full-width -- but the model holds every NVFP4-native layer replicated.
  • make_tiny_mixed_ckpt (per-tensor FP8 attn/GDN + NVFP4 shared_expert + bf16 lm_head) + test_mixed_iter_weights_tp2_sharding guard (1); fails pre-fix, passes post-fix. Full CPU suite: 12/12 in test_qwen3_5_tp_quant.py + 3/3 in test_qwen3_5_tp.py.

Validation (2x RTX PRO 4000 Blackwell, PCIe, no NVLink)

Model: nvidia/Qwen3.6-35B-A3B-NVFP4 (mixed: FP8 per-tensor attention + NVFP4 experts/shared_expert/lm_head) — the target checkpoint for this port.

  • ft serve --tensor-parallel-size 2 --disable-pynccl --moe-cache-size 4096 --expert-load serial boots clean; CUDA-graph capture (bs 1/2/4) with NCCL all-reduce works.

  • KV capacity: 1,223,892 tokens/rank at TP=2 vs 517,045 at TP=1 -> ~2.4x (about 4-5 concurrent 256K contexts vs about 2).

  • Correctness A/B (temperature 0): 10/10 identical on the short suite; on the 512-token long suite 1/5 byte-identical and the rest semantically identical with only last-ulp drift (all-reduce changes FP reduction order, flipping a few near-tie greedy tokens). Final answers matched.

  • 3-way concurrent requests batch correctly (warm wall 11s, all correct).

  • Note: --expert-load serial is required on a 54 GB-RAM box (expert banks are 21.8 GB per rank, replicated per Offloaded MoE ignores tensor parallelism: TP=2 is a regression, and the fix is not expert parallelism FlashML-org/FreeToken#62 -> ~44 GB pinned; the parallel O_DIRECT build OOM-kills rank 0).

  • Re-validated after all fixes (2026-08-26): freetoken:local image rebuilt with the full stack; both --tensor-parallel-size 2 and 1 boot clean and serve correct completions on the same hardware (KV pools unchanged: 1,223,892 tokens/rank at TP=2, 517,045 at TP=1).

Disclosure

This contribution was developed with an AI coding agent (implementation and tests are AI-generated); all validation above was run on real hardware (2x RTX PRO 4000 Blackwell) and the numbers are from those runs. Happy to adjust scope or split the commits however is most useful for review.

…NVFP4)

Finishes the work started in this branch (which only covered BF16):

- Row-parallel quantized linears: Fp8PerTensorRowParallel, Fp8BlockRowParallel,
  Nvfp4DenseRowParallel (sharded-K GEMM + all-reduce, bias after reduce).
- Column-merged quantized linears accept local_output_sizes (GQA KV-head
  replication), removing the make_col_merged_quant guard.
- gdn.py: in_proj_qkvz now declares TP-local sizes (was full-size while
  forward split by local sizes).
- weight.py: _iter_weights_attn_fp8 (mixed modelopt checkpoints) ungated and
  TP-sharded: fused fp8 qkv/in_proj_qkvz per sub-part with KV replication,
  standalone fp8 o_proj/out_proj row-shard, in_proj_ba per-head, conv1d /
  A_log / dt_bias / embed_tokens sharded; NVFP4 dense (shared_expert,
  lm_head) stays replicated. _iter_weights_fp8 (block-fp8) and the expert
  bank paths ungated with the same sharding.
- tests: CPU unit tests for the shard math and row-parallel numerics
  (pure-torch reference path).
Two small dependency-free clients for the E2E check used to validate the
quantized TP port: ab_suite.py (10 short deterministic prompts, JSON out,
--diff mode) and ab_long.py (5 long-form prompts, full reasoning+answer
capture, --diff with first-divergence reporting). Used to compare TP=1 vs
TP=2 outputs of nvidia/Qwen3.6-35B-A3B-NVFP4 on 2x RTX PRO 4000.
…TP>1

The block-fp8 pass (_iter_weights_fp8) sharded embed_tokens but not
lm_head, so at TP>1 the full-width (vocab, hidden) tensor was yielded
against the vocab-sharded ParallelLMHead buffer and load_state_dict
failed with a shape assert (reported on PR FlashML-org#104: 124160 vs 248320 rows
at TP=2). Extract the per-tensor sharding into _fp8_shard_standalone
(behavior unchanged for o_proj/out_proj, embed_tokens, A_log/dt_bias,
conv1d) and add lm_head.weight to the vocab-parallel dim-0 branch.
Nvfp4LMHead (mixed checkpoint) stays replicated, as before.

CPU test: test_fp8_shard_standalone_vocab_row_conv_tp2.
make_tiny_fp8_ckpt() shrinks the Qwen3.6-35B-A3B-FP8 geometry (4 layers:
3 GDN + 1 full-attn, few experts) to a few hundred MB while keeping hidden
2048, all per-layer dims, and by default the full 248320 vocab -- so
lm_head / embed_tokens exercise the exact vocab-parallel shapes the TP=2
loader bug hit. Random weights: for load/boot/serve validation, not
coherent generation.

test_fp8_iter_weights_tp2_sharding runs the full _iter_weights_fp8 at
TP=2 over the synthetic checkpoint and asserts the per-rank shapes
(lm_head / embed_tokens vocab-sharded, o_proj / out_proj input-sharded,
qkv / in_proj_qkvz per-part local rows, conv1d sub-parts, replicated
router / shared expert). Fails on the pre-fix loader (lm_head full-width),
passes with the lm_head vocab-parallel shard fix.

Also validated E2E: with the full-vocab checkpoint, ft serve
--tensor-parallel-size 2 --disable-pynccl boots and serves on 2x RTX PRO
4000 (the pre-fix loader dies in load_state_dict with the same
LOAD_MISMATCH reported on PR FlashML-org#104).
…4-native emits

Found by a post-merge correctness review of the TP port (two latent
loader bugs, same class as the block-fp8 lm_head fix 6197d63):

1. _iter_weights_attn_fp8 (mixed modelopt path): a bf16 lm_head
   (lm_head_quant=='none') was yielded full-width against the
   vocab-sharded ParallelLMHead at TP>1. An NVFP4 lm_head never reaches
   the sharding block (emitted native above), so adding lm_head.weight
   to the vocab-parallel branch is safe in both cases.
2. Main iter_weights path: the NVFP4-native emit (shared_expert / dense
   MLP / lm_head) ran through _maybe_shard, which sharded down_proj
   (dim 1) / lm_head (dim 0) while leaving weight_scale / weight_global
   full-width -- but the model holds every NVFP4-native layer replicated
   (Nvfp4Dense* / Nvfp4LMHead). Yield it unsharded.

Test: make_tiny_mixed_ckpt (per-tensor FP8 attn/GDN + NVFP4
shared_expert + bf16 lm_head) + test_mixed_iter_weights_tp2_sharding --
fails on the pre-fix loader (full-width lm_head), passes with the fix.
Full CPU suite 11/11.
Under TP>1 every rank process auto-pinned its CPU MoE worker pool to the
whole machine (one thread per physical core), so the rank pools
time-sliced on the same cores with constant cache thrashing. Measured on
2x RTX 3090 (alogotron) and 2x RTX PRO 4000 (Rudi): hybrid TP=2 decode
collapsed to ~2 tok/s; disjoint per-rank slices recover ~28-29 tok/s on
Rudi (13.5x), and the --moe-cpu-threads 8 cap only ~2x because both ranks
still take the first N cores.

- resolve_threads_and_affinity(rank, world_size): each rank gets a
  disjoint round-robin slice of the physical cores (reps[rank::world_size]);
  an explicit --moe-cpu-threads count stays inside the rank's slice.
  Round-robin keeps a fair P/E-core mix on heterogeneous CPUs.
- CpuMoeExecutor reads try_get_tp_info() and passes rank/world through;
  the torch intra-op thread clamp now counts the rank's slice, not the
  whole machine.
- The 'CPU MoE executor ready' line now logs on ALL ranks with
  tp_rank=N/M (it was info_rank0, which made a pinning collision
  invisible in the logs).
- tests/moe/test_cpu_executor_affinity.py: partition invariants
  (disjoint slices, explicit count stays in slice, TP=1 unchanged).
…patch)

Adds the 'nvfp4' weight_format to the resident (fused) MoELayer:
- _alloc_resident_experts: 6 native ModelOpt NVFP4 banks (gate_up_packed/scale/
  global, down_packed/scale/global) on GPU, TP-sharded on the intermediate dim
  (gate_up column-parallel, down row-parallel; down global keeps full H rows).
- _resident_gemm: dispatch to the existing plain *_nvfp4 Triton kernels
  (fused_experts_nvfp4 prefill / fused_experts_decode_nvfp4_marlin decode),
  mirroring OffloadMoELayer._expert_gemm's 'nvfp4' branch.
- Qwen3_5MoE selects weight_format='nvfp4' for expert_quant=='nvfp4'.

Unwired: iter_weights still skips per-expert NVFP4 tensors (next commit builds +
yields the resident sharded banks).
Wires the resident (fused) NVFP4 expert path end to end:
- nvfp4_banks.load_nvfp4_expert_resident_banks: the resident mirror of
  load_nvfp4_expert_source_banks -- allocates the 6 native NVFP4 banks on host,
  TP-sharded on the intermediate dim (gate_up column-parallel on the 2*I output
  rows, down row-parallel on the I input cols: //2 for the FP4 bytes, //16 for the
  per-16 block scale; down global keeps full H rows). Never pinned.
- qwen3_5_moe/weight._iter_resident_nvfp4_experts: yields the per-layer sharded
  banks as state-dict views (keys match the MoELayer's resident NVFP4 attrs).
  Called from both dense passes (_iter_weights_attn_fp8 mixed + the pure-NVFP4
  path) when include_moe_experts=True (the fused backend).
- tests: tiny_fp8_ckpt.make_tiny_mixed_ckpt gains routed_experts=True to emit the
  per-expert NVFP4 tensors; two CPU tests verify the TP=2 sharding (each rank's
  slice == the checkpoint slice) and the TP=1 full banks.

CPU-verified on freetoken:local (TP=1 + TP=2 sharding). GPU E2E next.
_adjust_config rejected expert_quant=='nvfp4' for any non-offload backend. Now that
MoELayer has a resident NVFP4 allocation + kernel dispatch (TP-sharded on the
intermediate dim), 'nvfp4' joins 'none'/'fp8_block' as a valid resident format. The
other quant formats (mxfp4, q4_0, ds_fp4) still require offload/cpu.
@RuixiangMa

Copy link
Copy Markdown
Owner

@CraigStone-Dev Great job, find one critical issue:
It removes the NotImplementedError gate in _iter_weights_fp8. Non-expert weights are correctly sharded, but fp8_block resident expert banks are full-size, not TP-sharded:

  • _alloc_resident_experts uses self.intermediate_size (full), comment says "TP=1-only"
  • _resident_gemm : computes full output with unsharded banks
  • MoELayer._maybe_all_reduce : all-reduces → sums N identical outputs

TP=2 → output = 2× correct value, no error. Offload path is unaffected (OffloadMoELayer._maybe_all_reduce is a no-op).

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