Skip to content

Offloaded MoE ignores tensor parallelism: TP=2 is a regression, and the fix is not expert parallelism #62

Description

@gdevenyi

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:

  1. 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.
  2. Load imbalance. Real MoE routing is skewed; a rank owning hot experts stalls the others every layer.
  3. 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.

  1. 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.
  2. 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.
  3. 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.
  4. Thread I_local through OffloadMoeCache construction in engine.py and the _BANK_SCHEMAS shape asserts.
  5. 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

  1. bf16 only, TP=2, behind a guard that refuses unsupported formats. Prove the cache doubles and measure end to end.
  2. Add ds_fp4 and nvfp4 (row-major, straightforward slices).
  3. fp8_block and q4_0.
  4. 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.

Activity

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

    featureNew feature or requestmulti-gpuTensor parallel / multiple GPUs

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions