Repository navigation
[Fix] eplb: Load all local replicas of each expert - #45363
Conversation
With an a2a backend, _compute_logical_to_all_physical_map narrows a logical expert's dispatch candidates on each rank to its nearest replica when it finds one. logical_to_all_physical() read that list, so on a rank that stores two replicas of an expert the weight loaders filled only the first, while a rank without a local replica could still dispatch tokens to the second. - ExpertLocationMetadata.logical_to_all_physical() returns every slot that physical_to_logical_map assigns to a routed expert. The draft-layer and require_global_experts branches and the dispatch maps are unchanged. - FusedMoE.weight_loader and moe_wna16._local_expert_ids keep only this rank's routed slots before the local mapping, so that after an elastic EP scale-up another rank's routed slot past _num_global_routed is not written into the fused shared slot. - Add test/registered/unit/eplb/test_expert_location_weight_loading.py. Signed-off-by: Zane <zanes@nvidia.com>
|
/tag-run-ci-label |
|
/tag-and-rerun-ci |
alexnails
left a comment
There was a problem hiding this comment.
small comment on CPU perf
| ) | ||
| # Every replica on every rank: logical_to_all_physical_map may narrow the | ||
| # dispatch candidates to one replica, but the loaders must fill every slot. | ||
| return (cpu_map[layer_id] == logical_expert_id).nonzero().flatten().tolist() |
There was a problem hiding this comment.
this looks to cost the same as before.
[Agent check]
A CPU microbench at DSV3 shape (58 layers × 256 logical × 288 physical, 89k calls ≈ one reload's worth of w1/w2/w3 + scales) gives 3.66 µs/call here vs 3.38 µs for the old l2p[l, e].tolist() + filter, ~0.33 s vs ~0.30 s per full reload, per rank.
My suggestion:
a follow-up could build a List[List[List[int]]] from physical_to_logical_map_cpu.tolist() once in _init_raw/update (~1 ms). That makes this lookup 0.08 µs/call, so ~0.3 s → ~8 ms. The old code had the same cost, so don't really feel u need to add pr scope.
There was a problem hiding this comment.
Thanks for measuring it. Agreed, caching the inverse map once per update can be a separate follow-up; I'll keep this PR to the loading fix.
| else: | ||
| physical = metadata.logical_to_all_physical(layer.layer_id, expert_id) | ||
| if expert_id < metadata.num_logical_experts: | ||
| # Same filter as FusedMoE.weight_loader; a fused shared id keeps its slot. |
There was a problem hiding this comment.
rm this comment? the others are ok.
Signed-off-by: Zane <zanes@nvidia.com>
|
/rerun-failed-ci |
|
/rerun-failed-ci |
Motivation
With an a2a backend (for example
--moe-a2a-backend deepep), a server started with--init-expert-locationset to aphysical_to_logical_mapin which one EP rank holds two replicas of the same logical expert never loads the second replica. A rank that holds no replica of that expert can still dispatch tokens to that slot, so those tokens go through an expert whose weights were never written. There is no error; the outputs just change. Reloading the weights through the same loader (for example withupdate_weights_from_disk) skips the same slots.Such layouts come out of EPLB: the packing step can put two replicas of a hot expert on one GPU (deepseek-ai/EPLB#22), and SGLang's copy of the algorithm does too. Passing a saved, rebalanced layout back through
--init-expert-locationtherefore hits this. In two layouts that we saved from Qwen3-30B-A3B servers (EP2, 2 redundant experts) after online rebalancing, 16 and 23 of the 48 layers had such a pair.Where it happens (main @ 78185f4):
_compute_logical_to_all_physical_map: with an a2a backend, each rank replaces every logical expert's candidate list with_find_nearest_expert(EPLB: prefer to use physical experts in the same gpu or node #10874). A rank that holds replicas keeps only its first local one. A rank that holds none keeps the full list (on one node).FusedMoE.weight_loaderwrites a checkpoint expert into the slots thatlogical_to_all_physical()returns, which is this narrowed list, and_weight_loader_physicalkeeps the local ones. The rank that holds both replicas writes only the first. For unquantized weights the second one staystorch.empty._fair_choicespick among all replicas, which can be the unloaded one.staticis the default with an a2a backend; with--ep-dispatch-algorithm dynamic, the same list is sampled per token.The narrowing has left rows unloaded before: #12866 removed it to fix NaN NVFP4 input scales, and #13162 / #13484 restored it with a separate path for those global scale parameters (
require_global_experts). That relies on the other unloaded rows never being dispatched to, and a second replica on the same rank breaks it. #32962 skipped the narrowing when there is no a2a backend; the a2a path is unchanged.Modifications
ExpertLocationMetadata.logical_to_all_physical()(eplb/expert_location.py): for a routed expert, it now returns every physical slot thatphysical_to_logical_mapassigns to the expert, on all ranks, instead of reading the per-rank dispatch listlogical_to_all_physical_map.require_global_expertsbranch are unchanged. They only read the map's shape, andlogical_to_all_physical_mapis padded to the number of physical slots, so both maps give the same values.require_global_experts, the lookup rejects out-of-range logical expert ids withIndexError, including negative ids, which used to index from the end. The draft-layer and global-scale branches keep their existing conventions.logical_to_all_physical_map,logical_to_rank_dispatch_physical_map) are not touched, so the nearest-replica dispatch from EPLB: prefer to use physical experts in the same gpu or node #10874 stays as it is.FusedMoE._local_routed_expert_ids()(layers/moe/fused_moe_triton/layer.py, new) keeps the slots in this rank's routed range.FusedMoE.weight_loaderapplies it to a routed expert's slots (not withrequire_global_experts) before_weight_loader_physicalmaps them to local ids._num_global_routedthat an existing rank's layer set at startup, and_map_global_expert_id_to_local_expert_idmaps such a slot onto that rank's fused shared expert.moe_wna16._local_expert_ids(layers/quantization/moe_wna16.py) writes qzeros outside the layer's loader, so it applies the same filter to routed experts. A fused shared expert's id, which the draft-layer fallback returns, is not filtered.elastic_ep/expert_backup_client.py, already keeps only its own range and is unchanged.test/registered/unit/eplb/test_expert_location_weight_loading.py(base-a-test-cpu, 8 tests). It is a new file because it drives the realFusedMoEandmoe_wna16loaders through a stand-in layer, unlike the existing dispatch-table tests. The tests check that:FusedMoE.weight_loaderand_weight_loader_physicalfill every local slot with its own expert (the same layouts, random EP2 and EP4); the stand-in layer records the writes instead of copying tensors;_sglang_require_global_expertsstill gets every slot, on every rank (the new filter does not apply to it);moe_wna16picks the right qzeros slots: a second replica, scale-up on ranks 0 and 1, a draft layer with a fused shared expert;Alternatives considered:
dynamicgets more candidates, and the static table draws its random picks in a different order.update_expert_weights_single_layertreats it as unchanged), so a never-written slot can still be picked.Accuracy Tests
CPU repro (script and full output below). EP2, 4 logical experts + 2 redundant, rank 1 holds both replicas of expert 3 (slots 3 and 4), rank 0 holds none.
dynamic, 4047 of rank 0's 8000 tokens for expert 3 go there.CPU tests
test/registered/unit/eplb/,test/registered/unit/model_loader/test_presharded_loader.py,test/registered/unit/model_loader/test_modelopt_loader.pyandtest/registered/unit/layers/quantization/test_moe_wna16_ep_shard.py: 123 passed, 2 skipped (both need CUDA), 88 subtests passed.moe_wna16code), 6 of the 8 new tests fail. Each of these partial changes makes at least one new test fail:moe_wna16change;moe_wna16too;require_global_expertsparams too;.pre-commit-config.yaml, pluscheck_registered_tests: clean.GPU. We ran this change once, on an earlier
main, with the two saved layouts; this was not a GPU run of 78185f4 itself. That tree also had the changes of #45341, which are inactive here because the weight cache is off.--tp 2 --ep-size 2 --enable-eplb --ep-num-redundant-experts 2 --random-seed 42 --moe-a2a-backend deepep --deepep-mode normal --moe-runner-backend deep_gemm --deepep-dispatcher-output-dtype bf16 --max-running-requests 128 --mem-fraction-static 0.6 --disable-radix-cache --eplb-rebalance-num-iterations 1000000000 --expert-distribution-recorder-buffer-size 1000(no rebalance during the test), plus--init-expert-locationset to one of the two saved layouts above.All non-expert tensors were byte-identical to the reference on both ranks, in all four servers.
GSM8K (5-shot, all 1319 questions, 128 at a time) was recorded for context and was not a criterion. Correct answers: 1204 and 1210 without this change, 1205 and 1193 with it. Two passes of the never-rebalanced server on the same setup differed by 7 and by 9.
Not tested on GPU:
moe_wna16path);dynamicdispatch;Speed Tests and Profiling
Not measured. Only weight loading changes: the lookup compares one row of
physical_to_logical_mapper call instead of indexing the per-rank list, and a rank that holds two replicas of an expert now writes that expert twice. Dispatch and forward passes are unchanged.Related
eplb/expert_location.py, in other functions.elifright before thelogical_to_all_physical()call inFusedMoE.weight_loader. Whichever lands second may need a small rebase.CPU repro script
CPU repro output, main @ 78185f4
CPU repro output, this PR
Checklist
Review and Merge Process
/tag-and-rerun-ci,/tag-run-ci-label,/rerun-failed-ciCI States
Latest PR Test (Base): ✅ Run #40305204419
Latest PR Test (Extra): ❌ Run #40305204539
Latest PR Test (AMD ROCm 10): ❌ Run #40305204466