Summary
--tensor-parallel-size 2 is currently a regression for offloaded MoE: measured 15.63 tok/s against 18.00 at TP=1 on DeepSeek-V4-Flash (2x RTX 6000 Ada, no NVLink). Two independent single-GPU servers give 2.05x instead, so the hardware scales fine — the engine does not.
I went looking for expert parallelism and concluded that EP is the wrong fix, and the right one is already half-built.
Why TP=2 loses today
The resident MoE path shards by TP:
intermediate_size_per_partition = div_even(intermediate_size, tp_size)
if allocate_experts:
self._alloc_resident_experts(intermediate_size_per_partition)
OffloadMoELayer passes allocate_experts=False and takes its weights from OffloadMoeCache, which has no TP awareness at all — no tp_size/tp_rank in offload_cache.py, expert_banks.py or models/deepseek_v4/moe.py. Every rank therefore loads and caches whole, unsharded experts.
Confirmed at runtime: moe_cache_size=2477 per rank at TP=2 versus 2559 at TP=1. The cache did not grow. Both ranks stream the same experts over their own PCIe link, so aggregate PCIe traffic doubles for identical work, and then the layer pays an all-reduce over PCIe. Only attention and dense shard usefully, and this model is MoE-dominated.
Why not expert parallelism proper
Sharding experts across ranks (rank r owns e % world == r) needs all-to-all dispatch and combine per MoE layer. Three problems, in order of severity:
- CUDA-graph capture. Decode graphs are captured at static shapes. All-to-all counts depend on routing, so they vary per step. Workable by padding to the worst case (
bs * top_k tokens per rank), but that is real added machinery in the hot path.
- Load imbalance. Real MoE routing is skewed; a rank owning hot experts stalls the others every layer.
- New collectives in a codebase that currently only needs
all_reduce.
The fix that fits: TP-shard the offload banks
Split the intermediate dimension, exactly as the resident path already does. Rank r holds I/tp of every expert.
- Cache doubles. A slot is half the bytes, so the same VRAM holds ~2x as many distinct experts. Residency here goes from 2559/11008 = 23% to roughly 46%.
- PCIe halves per rank. Each rank fetches only its half of a missed expert, over its own link — two links, half the bytes each.
- Host RAM is unchanged. 71.5 GiB per rank instead of 143 GiB replicated, so TP=2 costs no more host memory than TP=1 (today it costs double).
- No new collectives. Intermediate-dim sharding makes the down projection produce partial sums, and
_maybe_all_reduce already combines them. The communication is bs * H * 2 bytes per layer — 8 KiB at bs=1, negligible.
- CUDA graphs unaffected. Shapes stay static.
Extrapolating the measured residency curve (25% -> 50% residency was worth +59% on a comparable model), plus the halved per-rank gather, this looks like 1.4-1.8x on this box — versus 0.87x today.
What it touches
The work is almost entirely in loading, not in the kernels — they already accept whatever I they are handed.
- Per-format bank slicing at load (
moe/expert_banks.py). The awkward part: gate_up is [2I, H] with gate at rows [0, I) and up at [I, 2I), so a rank's slice is two disjoint row ranges, not one. down is [H, I], a column slice.
- Divisibility.
I/tp must stay a multiple of each format's block size — 128 for fp8_block, 32 for the fp4 scale blocks, 32 for q4_0. DSV4 at I=2048, tp=2 gives 1024, which is fine, but this needs a guard rather than an assumption.
- mxfp4 is transposed (
blocks_t [K//2, N]), so its slice is along N and the scale tensor slices differently again. This format has already broken two general assumptions in recent work; it should be last, or explicitly unsupported at first.
- Thread
I_local through OffloadMoeCache construction in engine.py and the _BANK_SCHEMAS shape asserts.
- The CPU MoE / hybrid path takes
I from the cache, so it follows for free — but --moe-backend hybrid under TP needs its own check.
Suggested staging
- bf16 only, TP=2, behind a guard that refuses unsupported formats. Prove the cache doubles and measure end to end.
- Add ds_fp4 and nvfp4 (row-major, straightforward slices).
- fp8_block and q4_0.
- mxfp4 last, or leave it TP=1-only.
Measurement notes
ft bench decode cannot test this: LLM.__init__ hardcodes tp_info=DistributedInfo(0, 1). Either that gets a parameter or the comparison has to go through ft serve.
- A server binds
PORT and PORT+1 (a multiprocessing.spawn child), so multi-instance comparisons need spaced ports.
- FreeToken JIT-links its own pynccl against a system NCCL; without
libnccl-dev installed, TP fails at ld: cannot find -lnccl even though torch bundles libnccl.so.2. Falling back to torch's copy would remove a sharp edge.
Summary
--tensor-parallel-size 2is currently a regression for offloaded MoE: measured 15.63 tok/s against 18.00 at TP=1 on DeepSeek-V4-Flash (2x RTX 6000 Ada, no NVLink). Two independent single-GPU servers give 2.05x instead, so the hardware scales fine — the engine does not.I went looking for expert parallelism and concluded that EP is the wrong fix, and the right one is already half-built.
Why TP=2 loses today
The resident MoE path shards by TP:
OffloadMoELayerpassesallocate_experts=Falseand takes its weights fromOffloadMoeCache, which has no TP awareness at all — notp_size/tp_rankinoffload_cache.py,expert_banks.pyormodels/deepseek_v4/moe.py. Every rank therefore loads and caches whole, unsharded experts.Confirmed at runtime:
moe_cache_size=2477per rank at TP=2 versus 2559 at TP=1. The cache did not grow. Both ranks stream the same experts over their own PCIe link, so aggregate PCIe traffic doubles for identical work, and then the layer pays an all-reduce over PCIe. Only attention and dense shard usefully, and this model is MoE-dominated.Why not expert parallelism proper
Sharding experts across ranks (rank r owns
e % world == r) needs all-to-all dispatch and combine per MoE layer. Three problems, in order of severity:bs * top_ktokens per rank), but that is real added machinery in the hot path.all_reduce.The fix that fits: TP-shard the offload banks
Split the intermediate dimension, exactly as the resident path already does. Rank r holds
I/tpof every expert._maybe_all_reducealready combines them. The communication isbs * H * 2bytes per layer — 8 KiB at bs=1, negligible.Extrapolating the measured residency curve (25% -> 50% residency was worth +59% on a comparable model), plus the halved per-rank gather, this looks like 1.4-1.8x on this box — versus 0.87x today.
What it touches
The work is almost entirely in loading, not in the kernels — they already accept whatever
Ithey are handed.moe/expert_banks.py). The awkward part:gate_upis[2I, H]with gate at rows[0, I)and up at[I, 2I), so a rank's slice is two disjoint row ranges, not one.downis[H, I], a column slice.I/tpmust stay a multiple of each format's block size — 128 forfp8_block, 32 for the fp4 scale blocks, 32 forq4_0. DSV4 at I=2048, tp=2 gives 1024, which is fine, but this needs a guard rather than an assumption.blocks_t [K//2, N]), so its slice is alongNand the scale tensor slices differently again. This format has already broken two general assumptions in recent work; it should be last, or explicitly unsupported at first.I_localthroughOffloadMoeCacheconstruction inengine.pyand the_BANK_SCHEMASshape asserts.Ifrom the cache, so it follows for free — but--moe-backend hybridunder TP needs its own check.Suggested staging
Measurement notes
ft bench decodecannot test this:LLM.__init__hardcodestp_info=DistributedInfo(0, 1). Either that gets a parameter or the comparison has to go throughft serve.PORTandPORT+1(amultiprocessing.spawnchild), so multi-instance comparisons need spaced ports.libnccl-devinstalled, TP fails atld: cannot find -lnccleven though torch bundleslibnccl.so.2. Falling back to torch's copy would remove a sharp edge.