Skip to content

qwen3_5_moe is TP=1-only because the model never matched the engine's per-rank GDN state contract — patch makes TP=2 work on 2x RX 9070 XT (ROCm) #478

Open

Description

@No-LetterHead4141

Summary

kvcache/linear_state_pool.py::_linear_local_dims already allocates the Gated-DeltaNet conv and recurrent state per rank (div_even(heads, tp_size)), so the engine expects the GDN layers to be head-sharded at TP>1. But no GDN family implements that sharding, and every one is gated with NotImplementedError("... supports TP=1 only"). Replicating GDN instead (the natural first attempt) writes full-head state into half-size per-rank buffers and produces garbage from the first token.

Two adjacent issues block TP once the gate is lifted:

  1. moe/host_banks.py::alloc_persistent_layer_banks has rank 0 fill the expert banks and every other rank map rank 0's files — which cannot represent an intermediate-sharded expert layout.
  2. PersistentPool.prepare_dir evicts every sibling pool under the root, so per-rank pools delete each other.

With the GDN layers head-sharded to match the state pool, the experts sharded per rank, and per-rank pools that don't evict each other, TP=2 produces answers identical to TP=1 on 2× RX 9070 XT (gfx1201, ROCm 7.2.1).

How it was found

Greedy decoding (temperature 0) as a correctness fingerprint: run the patched tree at TP=1 as a control (must be byte-identical to unpatched TP=1), then TP=2, and compare. Bisecting by replicating one component at a time is what pointed at the state pool — output stayed wrong with every weight replicated, which meant the engine was allocating per-rank state the model had to match, not that sharding was buggy.

Results (Qwen3.6-35B-A3B-FP8, offload, 62 GB host RAM)

TP=1 (one card) TP=2 (both cards)
greedy answers, 5 prompts baseline identical final answers
decode (soak, stochastic) 13.5 tok/s 17.7 tok/s
first-token latency 10.0 s 5.0 s
host RAM pinned 31.4 GB 2 × 16 GB per-rank = 31.4 GB (no over-commit)
20-round soak (120 requests, ~32 min) — 0 errors, throughput flat, healthy after

The fix

I have a working patch (9 files) against qwen3_5_moe:

  • GDN heads sharded in contiguous halves (exact — each head owns its recurrent state, and the fla kernels pair k-head i with the contiguous v-head group i_hv // (HV // H)); the merged input projection uses per-segment slots so the per-slot TP division keeps [q|k|v|z] layout.
  • a segment-aware loader slicer for the GDN tensors and their fp8 block scales; three TP=1-only gates removed.
  • attention head-sharded (LinearOProj); fp8-block experts sharded on the intermediate dim (no extra all-reduce — MoELayer._maybe_all_reduce already covers it).
  • rank-sharded expert banks: each rank fills and pins its own pool; rank 0 sweeps the root first (sparing same-run sibling pools via a manifest match), barrier, then the others create without evicting.
  • vision tower built replicated (as qwen3_vl already does under TP).

Happy to open a PR if that's useful — let me know the preferred shape (squashed onto main, or against a branch). Also glad to share the greedy-fingerprint validation script.

Two smaller things I can split out if you'd rather:

  • layers/base.py state-dict shape assertion currently gives no parameter name; adding the name + both shapes made this a one-run diagnosis.
  • fast_index_copy AOT variants are sized from the full moe_intermediate_size, so under TP the gather falls to the JIT path (correct, just not precompiled).

Activity

  1. No-LetterHead4141 commented on Sep 14, 2026

    @No-LetterHead4141
    Author

    Cross-referencing related work I found after opening this, so it's clear where this sits: #447, #385 (draft), #440.

    What #447 covers, and what it leaves open. #447 removes exactly one TP gate — qwen4_exp weight loading supports TP=1 only. For qwen3_5_moe it keeps the existing gate and adds a second restriction:

    if ownership is not None:
        raise NotImplementedError("block-FP8 expert banks do not support owner-local TP+EP")
    if get_tp_info().size > 1:
        raise NotImplementedError("qwen3_5_moe fp8 expert banks support TP=1 only")

    It also scopes its GDN TP to the unquantized path:

    if self._split_in_proj and tp_size > 1:
        raise NotImplementedError("qwen4_exp dense TP currently supports the BF16 GDN path only")

    So this issue is the complementary gap rather than a duplicate: qwen3_5_moe at TP>1, with block-FP8 experts and the quantized (split_in_proj) GDN path that an FP8 checkpoint like Qwen3.6-35B-A3B-FP8 takes.

    Independent convergence on the GDN design. I implemented this for qwen3_5_moe before reading #447, and the two arrived at the same shape, which is probably worth something as cross-validation — same approach, different model family, different vendor:

    • div_even(num_k_heads/num_v_heads, tp_size, allow_replicate=True) for rank-local head counts
    • LinearReplicated → LinearOProj on out_proj
    • the fused input GEMM built from global sizes, with the forward splitting by rank-local sizes
    • a segment-aware loader shard that splits in_proj_qkv / conv1d into q/k/v, shards each, and re-concatenates (a plain contiguous half of the fused block would hand rank 0 [q|k] and rank 1 [v])
    • A_log / dt_bias sized to local num_v_heads

    The framing in my original report still holds as the underlying reason all of this is needed: _linear_local_dims already allocates GDN conv and recurrent state per rank, so the engine has always expected head-sharded GDN, and replicating it writes full-head state into half-size buffers.

    What my patch adds on top of #447's direction: qwen3_5_moe GDN and attention; block-FP8 routed experts sharded on the intermediate dim; and per-rank expert bank pools, since alloc_persistent_layer_banks has rank 0 fill and every rank map rank 0's files, which cannot represent a rank-sharded expert layout (and PersistentPool.prepare_dir evicts sibling per-rank pools).

    Happy to rebase onto #447 once it lands rather than propose anything competing — it looks like the better base. Validated on 2× RX 9070 XT (gfx1201, ROCm 7.2.1): greedy output identical to TP=1, 120-request soak with 0 errors, 13.5 → 17.7 tok/s, first-token 10.0 → 5.0 s.

  2. CraigStone-Dev commented on Sep 24, 2026

    @CraigStone-Dev

    Data point from the NVIDIA side, in case it helps situate the patch: we ran the qwen3_5_moe TP=2 work that grew out of #104 (RuixiangMa's base TP + a 9-commit stack completing the quantized loaders and a resident NVFP4 fused path) on 2× RTX PRO 4000 Blackwell with nvidia/Qwen3.6-35B-A3B-NVFP4:

    • --moe-backend fused (GPU-resident experts), TP=2: 141 tok/s client wall (engine ~146), 22.4 GB VRAM/GPU, boots clean, coherent output. That's 5–9× the offload (~15) and hybrid (~27) TP=2 paths on the same box, since the experts are fully resident and CUDA graphs apply.
    • @alogotron reproduced it almost exactly on 2× RTX 3090 (Ampere, PCIe): 139.8 tok/s client wall, 17.3 GB/GPU, 568k-token KV capacity — see the feat(models): support TP for qwen3_5_moe #104 thread.

    For reference, that stack is parked at CraigStone-Dev/FreeToken:feat/qwen3-5-tp-support (we held off pushing it forward while #447 and the sharded-bank direction in the #385 thread settled), and it is now rebased onto current main as rebase/tp-20260923 (5 commits, CPU suite green) if anyone wants the quantized-loader side (per-head GDN sharding, block-FP8/mixed checkpoint handling, resident NVFP4 expert banks) as a reference.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions