@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 6170d47d22
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 10, 14:00 UTC

[EPLB] Optimize EPLB with numpy (#29499)

Signed-off-by: ilmarkov <markovilya197@gmail.com> Co-authored-by: Tyler Michael Smith <tyler@neuralmagic.com>

Ilya Markov committed Jan 7, 2026 at 21:21 UTC 6170d47d2220bc464fb561b93caf799a7560e304
8 files changed +738 -272
tests/distributed/test_eplb_algo.py
+140
@@ -310,3 +310,143 @@ if __name__ == "__main__":
310 print(phy2log)
311
312 test_basic_rebalance()
313 +
314 +
315 +def _make_phy_replicas_idx_from_phy2log(phy2log: torch.Tensor) -> torch.Tensor:
316 + """Create replicas indices mapping from phy2log"""
317 + pr = torch.zeros_like(phy2log)
318 + for layer in range(phy2log.shape[0]):
319 + seen: dict[int, int] = {}
320 + row = phy2log[layer].tolist()
321 + for i, expert in enumerate(row):
322 + r = seen.get(expert, 0)
323 + pr[layer, i] = r
324 + seen[expert] = r + 1
325 + return pr
326 +
327 +
328 +def _validate_intragpu_rearrangement(
329 + old_global_expert_indices: torch.Tensor,
330 + new_phy2log: torch.Tensor,
331 + new_phy_replicas_idx: torch.Tensor,
332 + post_phy2log: torch.Tensor,
333 + post_phy_replicas_idx: torch.Tensor,
334 + num_ranks: int,
335 + slots_per_gpu: int,
336 +):
337 + # Per-GPU checks
338 + for gpu_idx in range(num_ranks):
339 + start = gpu_idx * slots_per_gpu
340 + end = start + slots_per_gpu
341 + old_seg = old_global_expert_indices[0, start:end]
342 + new_seg = new_phy2log[0, start:end]
343 + new_rnk = new_phy_replicas_idx[0, start:end]
344 + post_seg = post_phy2log[0, start:end]
345 + post_rnk = post_phy_replicas_idx[0, start:end]
346 +
347 + # Pairwise equality for (expert, rank) pairs to ensure nothing is lost
348 + def sorted_pairs(seg: torch.Tensor, rnk: torch.Tensor):
349 + pairs = list(zip(seg.tolist(), rnk.tolist()))
350 + pairs.sort()
351 + return pairs
352 +
353 + assert sorted_pairs(post_seg, post_rnk) == sorted_pairs(new_seg, new_rnk), (
354 + f"Per-GPU pairs of (expert,rank) must match new mapping for GPU {gpu_idx}"
355 + )
356 +
357 + # For experts that remain on the same GPU, the old slot is preserved
358 + # for at least one occurrence; rank at that slot must be valid for that expert
359 + old_list = old_seg.tolist()
360 + new_list = new_seg.tolist()
361 + post_list = post_seg.tolist()
362 + remained = set(old_list) & set(new_list)
363 + new_ranks_for_expert: dict[int, list[int]] = {}
364 + for v, r in zip(new_list, new_rnk.tolist()):
365 + new_ranks_for_expert.setdefault(v, []).append(r)
366 + for expert in remained:
367 + old_pos = old_list.index(expert)
368 + assert post_list[old_pos] == expert, (
369 + f"Expert {expert} on GPU {gpu_idx} should stay at old slot {old_pos}"
370 + )
371 + # Rank at preserved slot must be one of the ranks
372 + # the expert has in new mapping
373 + assert post_rnk.tolist()[old_pos] in new_ranks_for_expert[expert], (
374 + f"Rank for expert {expert} at preserved slot on GPU {gpu_idx} "
375 + "must come from new mapping"
376 + )
377 +
378 +
379 +@pytest.mark.parametrize(
380 + "num_ranks, slots_per_gpu, old_phy2log, new_phy2log",
381 + [
382 + pytest.param(
383 + # Setup: 2 GPUs, 4 slots each, 1 layer
384 + # Old mapping: GPU0 -> [0,1,2,3], GPU1 -> [4,5,6,7]
385 + # New mapping shuffles within GPU0 and brings 4,5 into GPU0.
386 + # GPU0 new -> [1,5,0,4]; GPU1 new -> [6,2,7,3]
387 + 2,
388 + 4,
389 + torch.tensor([[0, 1, 2, 3, 4, 5, 6, 7]]),
390 + torch.tensor([[1, 5, 0, 4, 6, 2, 7, 3]]),
391 + id="simple",
392 + ),
393 + pytest.param(
394 + # Setup: 2 GPUs, 5 slots each (total 10 physical experts), 1 layer
395 + # Old mapping:
396 + # GPU0 -> [0, 1, 0, 2, 3] (expert 0 duplicated)
397 + # GPU1 -> [4, 5, 6, 1, 2]
398 + # New mapping reorders within GPUs and moves some experts across GPUs,
399 + # while still including duplicates:
400 + # GPU0 new -> [0, 5, 4, 0, 1] (expert 0 duplicated, 4/5 incoming)
401 + # GPU1 new -> [6, 2, 3, 2, 1] (expert 2 duplicated)
402 + 2,
403 + 5,
404 + torch.tensor([[0, 1, 0, 2, 3, 4, 5, 6, 1, 2]]),
405 + torch.tensor([[0, 5, 4, 0, 1, 6, 2, 3, 2, 1]]),
406 + id="duplicates",
407 + ),
408 + pytest.param(
409 + # Setup: 3 GPUs, 4 slots each (total 12 physical experts), 1 layer
410 + # Old mapping:
411 + # GPU0 -> [0, 1, 2, 3]
412 + # GPU1 -> [0, 1, 2, 3]
413 + # GPU2 -> [0, 1, 2, 3]
414 + # New mapping decides to use one expert on 2 GPUs and shuffles
415 + # experts on the third GPU,
416 + # GPU0 new -> [0, 0, 0, 0]
417 + # GPU1 new -> [0, 0, 0, 0]
418 + # GPU2 new -> [1, 2, 3, 0]
419 + 3,
420 + 4,
421 + torch.tensor([[0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3]]),
422 + torch.tensor([[0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 0]]),
423 + id="skewed_expert",
424 + ),
425 + ],
426 +)
427 +def test_preserve_intragpu_slots(
428 + num_ranks: int,
429 + slots_per_gpu: int,
430 + old_phy2log: torch.Tensor,
431 + new_phy2log: torch.Tensor,
432 +):
433 + """Experts that stay on a GPU keep their old slots; incoming not lost."""
434 + phy_replicas_idx = _make_phy_replicas_idx_from_phy2log(new_phy2log)
435 +
436 + post_phy2log, post_phy_replicas_idx = DefaultEplbPolicy.preserve_intragpu_slots(
437 + new_phy2log, phy_replicas_idx, num_ranks, old_phy2log
438 + )
439 +
440 + # Shapes preserved
441 + assert post_phy2log.shape == new_phy2log.shape
442 + assert post_phy_replicas_idx.shape == phy_replicas_idx.shape
443 +
444 + _validate_intragpu_rearrangement(
445 + old_phy2log,
446 + new_phy2log,
447 + phy_replicas_idx,
448 + post_phy2log,
449 + post_phy_replicas_idx,
450 + num_ranks,
451 + slots_per_gpu,
452 + )
tests/distributed/test_eplb_execute.py
+9 -8
@@ -286,15 +286,17 @@ def _test_async_transfer_layer_without_mtp_worker(
286 device,
287 old_indices,
288 )
289 + old_indices_cpu = old_indices.cpu()
290 + new_indices_cpu = new_indices.cpu()
291
292 expert_buffer = [torch.empty_like(w) for w in expert_weights[0]]
293 cuda_stream = torch.cuda.Stream(device=device)
294
295 for layer_idx in range(num_layers):
294 - is_unchanged, is_received_locally, experts_recv_loc = asyncio.run(
296 + is_unchanged, is_received_locally, recv_metadata = asyncio.run(
297 transfer_layer(
296 - old_global_expert_indices=old_indices,
297 - new_global_expert_indices=new_indices,
298 + old_global_expert_indices=old_indices_cpu,
299 + new_global_expert_indices=new_indices_cpu,
300 expert_weights=expert_weights,
301 expert_weights_buffer=expert_buffer,
302 ep_group=ep_group,
@@ -302,16 +304,15 @@ def _test_async_transfer_layer_without_mtp_worker(
304 cuda_stream=cuda_stream,
305 )
306 )
305 -
307 cuda_stream.synchronize()
308 move_from_buffer(
309 expert_weights=expert_weights[layer_idx],
309 - expert_weights_buffer=expert_buffer,
310 + expert_weights_buffers=expert_buffer,
311 is_unchanged=is_unchanged,
312 is_received_locally=is_received_locally,
312 - experts_recv_loc=experts_recv_loc,
313 - new_indices=new_indices[layer_idx].tolist(),
314 - ep_group=ep_group,
313 + recv_metadata=recv_metadata,
314 + new_indices=new_indices_cpu[layer_idx],
315 + ep_rank=ep_rank,
316 )
317
318 verify_expert_weights_after_shuffle(
vllm/config/parallel.py
+12
@@ -69,6 +69,10 @@ class EPLBConfig:
69 Log the balancedness each step of expert parallelism.
70 This is turned off by default since it will cause communication overhead.
71 """
72 + log_balancedness_interval: int = 1
73 + """
74 + Interval for logging the balancedness.
75 + """
76 use_async: bool = False
77 """
78 Whether to use non-blocking EPLB.
@@ -77,6 +81,14 @@ class EPLBConfig:
81 policy: EPLBPolicyOption = "default"
82 """The policy type for expert parallel load balancing (EPLB)."""
83
84 + @model_validator(mode="after")
85 + def _validate_eplb_config(self) -> Self:
86 + if self.use_async and self.policy != "default":
87 + raise ValueError("Async EPLB is only supported with the default policy.")
88 + if self.log_balancedness and self.log_balancedness_interval <= 0:
89 + raise ValueError("log_balancedness_interval must be greater than 0.")
90 + return self
91 +
92
93 @config
94 @dataclass
vllm/distributed/eplb/async_worker.py
+1 -1
@@ -89,7 +89,7 @@ async def transfer_run_periodically(
89 (
90 model_state.is_unchanged,
91 model_state.is_received_locally,
92 - model_state.experts_recv_loc,
92 + model_state.recv_metadata,
93 ) = await transfer_layer(
94 old_global_expert_indices=model_state.physical_to_logical_map,
95 new_global_expert_indices=model_state.new_physical_to_logical_map,
vllm/distributed/eplb/eplb_state.py
+60 -32
@@ -27,10 +27,10 @@ physical experts.
27 """
28
29 import threading
30 -import time
30 from collections.abc import Sequence
31 from dataclasses import dataclass
32
33 +import numpy as np
34 import torch
35 from torch.distributed import ProcessGroup, all_reduce
36
@@ -46,7 +46,11 @@ from vllm.model_executor.models.interfaces import MixtureOfExperts
46
47 from .async_worker import start_async_worker
48 from .policy import EPLB_POLICIES, AbstractEplbPolicy, DefaultEplbPolicy
49 -from .rebalance_execute import move_from_buffer, rearrange_expert_weights_inplace
49 +from .rebalance_execute import (
50 + RecvMetadata,
51 + move_from_buffer,
52 + rearrange_expert_weights_inplace,
53 +)
54
55 logger = init_logger(__name__)
56
@@ -164,20 +168,19 @@ class EplbModelState:
168 """
169 Whether the async EPLB needs to poll peers for buffer readiness.
170 """
167 - is_unchanged: list[bool]
171 + is_unchanged: np.ndarray
172 """
173 intermediate variable between `move_to_buffer` and `move_to_workspace`.
174 The size is same as the num of physical experts in the current layer.
175 """
172 - is_received_locally: list[bool]
176 + is_received_locally: np.ndarray
177 """
178 intermediate variable between `move_to_buffer` and `move_to_workspace`.
179 The size is same as the num of physical experts in the current layer.
180 """
177 - experts_recv_loc: dict[int, int]
181 + recv_metadata: RecvMetadata
182 """
183 intermediate variable between `move_to_buffer` and `move_to_workspace`.
180 - The size is same as the num of physical experts in the current layer.
184 """
185 is_async_enabled: bool
186 """
@@ -507,9 +510,14 @@ class EplbState:
510 layer_to_transfer=0,
511 rebalanced=False,
512 pending_global_ready_check=False,
510 - is_unchanged=[],
511 - is_received_locally=[],
512 - experts_recv_loc={},
513 + is_unchanged=np.array([]),
514 + is_received_locally=np.array([]),
515 + recv_metadata=RecvMetadata(
516 + recv_primary_mask=np.array([]),
517 + recv_count=0,
518 + recv_expert_ids=np.array([]),
519 + recv_dst_rows=np.array([]),
520 + ),
521 is_async_enabled=self.is_async,
522 cuda_device_index=self.cuda_device_index,
523 new_physical_to_logical_map=new_physical_to_logical_map,
@@ -553,7 +561,12 @@ class EplbState:
561 for eplb_model_state in self.model_states.values():
562 eplb_model_state.expert_load_pass.zero_()
563
556 - if log_stats:
564 + if (
565 + log_stats
566 + and self.expert_rearrangement_step
567 + % self.parallel_config.eplb_config.log_balancedness_interval
568 + == 0
569 + ):
570 # Sync the expert load pass for each model (main and drafter).
571 # expert_load_pass: (num_moe_layers, num_physical_experts)
572 expert_load_pass_list = self._sync_load_pass()
@@ -586,12 +599,15 @@ class EplbState:
599 if ep_group.rank() == 0:
600 logger.info(
601 "EPLB step: %d for model %s: avg_tokens=%.2f, "
589 - "max_tokens=%d, balancedness=%.4f",
602 + "max_tokens=%d, balancedness=%.4f, "
603 + "steps until the next rearrangement: %d",
604 self.expert_rearrangement_step,
605 eplb_model_state.model_name,
606 avg_tokens,
607 max_tokens,
608 balancedness,
609 + self.expert_rearrangement_step_interval
610 + - self.expert_rearrangement_step,
611 )
612
613 # Update the expert load sliding window
@@ -684,11 +700,14 @@ class EplbState:
700 ep_group = get_ep_group().device_group
701 ep_rank = ep_group.rank()
702
687 - time_start = None
703 + start_event = None
704 + end_event = None
705 is_main_rank = ep_rank == 0
706 if is_main_rank:
690 - torch.cuda.synchronize()
691 - time_start = time.perf_counter()
707 + if not self.is_async or is_profile:
708 + start_event = torch.cuda.Event(enable_timing=True)
709 + end_event = torch.cuda.Event(enable_timing=True)
710 + start_event.record()
711 logger.info(
712 "Rearranging experts %s %s...",
713 "(async mode)" if self.is_async else "sync mode",
@@ -800,6 +819,7 @@ class EplbState:
819 num_groups,
820 num_nodes,
821 num_gpus,
822 + eplb_model_state.physical_to_logical_map,
823 )
824
825 if not eplb_model_state.is_async_enabled or is_profile:
@@ -848,17 +868,17 @@ class EplbState:
868 new_logical_replica_count
869 )
870 if is_main_rank:
851 - assert time_start is not None
852 - torch.cuda.synchronize()
853 - time_end = time.perf_counter()
871 + assert start_event is not None
872 + assert end_event is not None
873 + end_event.record()
874 + end_event.synchronize()
875 + gpu_elapsed = start_event.elapsed_time(end_event) / 1000.0
876 logger.info(
855 - "Rearranged experts%sin %.2f seconds.",
877 + "Rearranged experts %s in %.2f s.",
878 " (profile) " if is_profile else " ",
857 - time_end - time_start,
879 + gpu_elapsed,
880 )
881 else:
860 - device = eplb_model_state.physical_to_logical_map.device
861 - new_physical = new_physical_to_logical_map.to(device)
882 max_slots = eplb_model_state.logical_to_physical_map.shape[-1]
883 padded_logical = torch.nn.functional.pad(
884 new_logical_to_physical_map,
@@ -869,7 +889,10 @@ class EplbState:
889 eplb_model_state.logical_replica_count.device
890 )
891
872 - eplb_model_state.new_physical_to_logical_map = new_physical
892 + # Move map to cpu in advance
893 + eplb_model_state.new_physical_to_logical_map = (
894 + new_physical_to_logical_map.cpu()
895 + )
896 eplb_model_state.new_logical_to_physical_map = padded_logical
897 eplb_model_state.new_logical_replica_count = new_replica
898
@@ -968,25 +991,30 @@ class EplbState:
991 stream = torch.cuda.current_stream(device=device_index)
992 stream.wait_event(model_state.buffer_ready_event)
993 model_state.buffer_ready_event = None
994 + expert_weights = model_state.model.expert_weights[
995 + model_state.layer_to_transfer
996 + ]
997 + expert_weights_buffer = model_state.expert_buffer
998 + new_indices = (
999 + model_state.new_physical_to_logical_map[model_state.layer_to_transfer]
1000 + .cpu()
1001 + .numpy()
1002 + )
1003 move_from_buffer(
972 - expert_weights=model_state.model.expert_weights[
973 - model_state.layer_to_transfer
974 - ],
975 - expert_weights_buffer=model_state.expert_buffer,
1004 + expert_weights=expert_weights,
1005 + expert_weights_buffers=expert_weights_buffer,
1006 is_unchanged=model_state.is_unchanged,
1007 is_received_locally=model_state.is_received_locally,
978 - experts_recv_loc=model_state.experts_recv_loc,
979 - new_indices=model_state.new_physical_to_logical_map[
980 - model_state.layer_to_transfer
981 - ].tolist(),
982 - ep_group=ep_group,
1008 + recv_metadata=model_state.recv_metadata,
1009 + new_indices=new_indices,
1010 + ep_rank=ep_group.rank(),
1011 )
1012 transferred_layer = model_state.layer_to_transfer
1013 self._update_layer_mapping_from_new(model_state, transferred_layer)
1014 # After the main thread consumes, advance layer_to_transfer
1015 model_state.layer_to_transfer += 1
1016 model_state.ep_buffer_ready = 0
989 - logger.info(
1017 + logger.debug(
1018 "model %s successfully move_to_workspace layer %d",
1019 model_state.model_name,
1020 transferred_layer,
vllm/distributed/eplb/policy/abstract.py
+4 -1
@@ -16,6 +16,7 @@ class AbstractEplbPolicy(ABC):
16 num_groups: int,
17 num_nodes: int,
18 num_ranks: int,
19 + old_global_expert_indices: torch.Tensor | None = None,
20 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
21 """
22 Entry point for expert-parallelism load balancer.
@@ -28,7 +29,9 @@ class AbstractEplbPolicy(ABC):
29 num_groups: number of expert groups
30 num_nodes: number of server nodes
31 num_ranks: number of ranks, must be a multiple of `num_nodes`
31 -
32 + old_global_expert_indices: [layers, num_logical_experts], the old global
33 + expert indices. Used to avoid unnecessary weight copying
34 + for experts moving within one rank.
35 Returns:
36 physical_to_logical_map: [layers, num_replicas], the expert
37 index of each replica
vllm/distributed/eplb/policy/default.py
+124 -12
@@ -93,7 +93,7 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
93
94 Returns:
95 phy2log: [X, num_phy], logical expert id of each physical expert
96 - rank: [X, num_phy], the replica rank
96 + replica_idx: [X, num_phy], the index of the replica for each logical expert
97 logcnt: [X, num_log], number of replicas for each logical expert
98 """
99 n, num_log = weight.shape
@@ -101,15 +101,15 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
101 assert num_redundant >= 0
102 device = weight.device
103 phy2log = torch.arange(num_phy, dtype=torch.int64, device=device).repeat(n, 1)
104 - rank = torch.zeros(n, num_phy, dtype=torch.int64, device=device)
104 + replica_idx = torch.zeros(n, num_phy, dtype=torch.int64, device=device)
105 logcnt = torch.ones(n, num_log, dtype=torch.int64, device=device)
106 arangen = torch.arange(n, dtype=torch.int64, device=device)
107 for i in range(num_log, num_phy):
108 redundant_indices = (weight / logcnt).max(dim=-1).indices
109 phy2log[:, i] = redundant_indices
110 - rank[:, i] = logcnt[arangen, redundant_indices]
110 + replica_idx[:, i] = logcnt[arangen, redundant_indices]
111 logcnt[arangen, redundant_indices] += 1
112 - return phy2log, rank, logcnt
112 + return phy2log, replica_idx, logcnt
113
114 @classmethod
115 def rebalance_experts_hierarchical(
@@ -132,7 +132,7 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
132 Returns:
133 phy2log: [layers, num_replicas], the expert
134 index of each replica
135 - log2phy: [layers, num_logical_experts, X],
135 + pphy_replicas_idx: [layers, num_logical_experts, X],
136 the replica indices for each expert
137 logcnt: [layers, num_logical_experts], number of
138 physical replicas for each logical expert
@@ -177,7 +177,7 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
177 tokens_per_mlog = weight.gather(-1, mlog2log).view(
178 -1, num_logical_experts // num_nodes
179 )
180 - phy2mlog, phyrank, mlogcnt = cls.replicate_experts(
180 + phy2mlog, replicas_idx, mlogcnt = cls.replicate_experts(
181 tokens_per_mlog, num_physical_experts // num_nodes
182 )
183
@@ -203,9 +203,109 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
203 ).view(1, -1, 1)
204 ).flatten(-2)
205 pphy2log = mlog2log.gather(-1, pphy2mlog)
206 - pphyrank = phyrank.gather(-1, pphy2phy).view(num_layers, -1)
206 + pphy_replicas_idx = replicas_idx.gather(-1, pphy2phy).view(num_layers, -1)
207 logcnt = mlogcnt.view(num_layers, -1).gather(-1, log2mlog)
208 - return pphy2log, pphyrank, logcnt
208 + return pphy2log, pphy_replicas_idx, logcnt
209 +
210 + @classmethod
211 + def preserve_intragpu_slots(
212 + cls,
213 + phy2log: torch.Tensor,
214 + phy_replicas_idx: torch.Tensor,
215 + num_ranks: int,
216 + old_global_expert_indices: torch.Tensor,
217 + ) -> tuple[torch.Tensor, torch.Tensor]:
218 + """
219 + Reorder the new mapping per GPU so that experts that remain on the same GPU
220 + keep their previous slot positions when possible. Incoming experts to that GPU
221 + fill any remaining available slots. This is applied only when the number of GPUs
222 + is unchanged and the slots per GPU remain the same between
223 + the old and new mappings.
224 + """
225 + device = phy2log.device
226 + num_phy_experts = phy2log.shape[1]
227 + if num_ranks <= 0 or num_phy_experts % num_ranks != 0:
228 + return phy2log, phy_replicas_idx
229 +
230 + # Move to CPU and convert to NumPy for processing
231 + new_phy2log_np = phy2log.cpu().numpy()
232 + replicas_idx_np = phy_replicas_idx.cpu().numpy()
233 + old_phy2log_np = old_global_expert_indices.cpu().numpy()
234 +
235 + slots_per_gpu = num_phy_experts // num_ranks
236 + num_layers = new_phy2log_np.shape[0]
237 +
238 + post_phy2log_np = new_phy2log_np.copy()
239 + post_phy_replicas_idx_np = replicas_idx_np.copy()
240 +
241 + for gpu_idx in range(num_ranks):
242 + start = gpu_idx * slots_per_gpu
243 + end = start + slots_per_gpu
244 + # Experts across all layers for this GPU
245 + old_local = old_phy2log_np[:, start:end] # [layers, slots]
246 + new_local = new_phy2log_np[:, start:end] # [layers, slots]
247 + new_ridx = replicas_idx_np[:, start:end] # [layers, slots]
248 +
249 + used_new_indices = np.zeros((num_layers, slots_per_gpu), dtype=bool)
250 + preserved_positions = np.zeros((num_layers, slots_per_gpu), dtype=bool)
251 +
252 + # First pass: preserve same-logical experts in their previous slots
253 + for slot_idx in range(slots_per_gpu):
254 + # matches: [layers, slots], True where new local experts have
255 + # the same logical value as the old from 'slot_idx' and not checked yet
256 + matches = (new_local == old_local[:, slot_idx][:, None]) & (
257 + ~used_new_indices
258 + )
259 + has_any = matches.any(axis=1)
260 + if np.any(has_any):
261 + first_idx = np.argmax(matches, axis=1)
262 + layer_indices = np.nonzero(has_any)[0]
263 + matched_new_positions = first_idx[layer_indices]
264 + post_phy2log_np[layer_indices, start + slot_idx] = new_local[
265 + layer_indices, matched_new_positions
266 + ]
267 + post_phy_replicas_idx_np[layer_indices, start + slot_idx] = (
268 + new_ridx[layer_indices, matched_new_positions]
269 + )
270 + used_new_indices[layer_indices, matched_new_positions] = True
271 + preserved_positions[layer_indices, slot_idx] = True
272 +
273 + # Second pass: fill remaining slots with remaining new experts
274 + remaining_mask = ~used_new_indices # [layers, slots]
275 + fill_mask = ~preserved_positions # [layers, slots]
276 + if remaining_mask.any() and fill_mask.any():
277 + idx_base = np.tile(np.arange(slots_per_gpu), (num_layers, 1))
278 + # Sentinel value for unavailable positions.
279 + large = slots_per_gpu + 1
280 + # Priorities: keep original index for available spots, set sentinel
281 + # for unavailable; lower is earlier.
282 + remaining_priority = np.where(remaining_mask, idx_base, large)
283 + fill_priority = np.where(fill_mask, idx_base, large)
284 + # Sort to get ordered indices of available src/dst positions per layer.
285 + remaining_indices = np.argsort(remaining_priority, axis=1)
286 + fill_indices = np.argsort(fill_priority, axis=1)
287 + # Fill count per layer (cannot exceed either side).
288 + remaining_counts = remaining_mask.sum(axis=1)
289 + fill_counts = fill_mask.sum(axis=1)
290 + take_counts = np.minimum(remaining_counts, fill_counts)
291 + # Assign remaining new experts to remaining slots per layer.
292 + for layer_idx in range(num_layers):
293 + k = int(take_counts[layer_idx])
294 + if k <= 0:
295 + continue
296 + src_pos = remaining_indices[layer_idx, :k]
297 + dst_pos = fill_indices[layer_idx, :k]
298 + post_phy2log_np[layer_idx, start + dst_pos] = new_local[
299 + layer_idx, src_pos
300 + ]
301 + post_phy_replicas_idx_np[layer_idx, start + dst_pos] = new_ridx[
302 + layer_idx, src_pos
303 + ]
304 +
305 + # Convert back to torch and move to original device
306 + post_phy2log = torch.from_numpy(post_phy2log_np).to(device)
307 + post_phy_replicas_idx = torch.from_numpy(post_phy_replicas_idx_np).to(device)
308 + return post_phy2log, post_phy_replicas_idx
309
310 @classmethod
311 def rebalance_experts(
@@ -215,6 +315,7 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
315 num_groups: int,
316 num_nodes: int,
317 num_ranks: int,
318 + old_global_expert_indices: torch.Tensor | None = None,
319 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
320 """
321 Entry point for expert-parallelism load balancer.
@@ -228,7 +329,9 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
329 num_nodes: number of server nodes, where the intra-node network
330 (e.g, NVLink) is faster
331 num_ranks: number of ranks, must be a multiple of `num_nodes`
231 -
332 + old_global_expert_indices: [layers, num_logical_experts], the old global
333 + expert indices. Used to avoid unnecessary weight copying
334 + for experts moving within one rank.
335 Returns:
336 phy2log: [layers, num_replicas], the expert
337 index of each replica
@@ -241,14 +344,23 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
344 weight = weight.float()
345 if num_groups % num_nodes == 0:
346 # use hierarchical load-balance policy
244 - phy2log, phyrank, logcnt = cls.rebalance_experts_hierarchical(
347 + phy2log, phy_replicas_idx, logcnt = cls.rebalance_experts_hierarchical(
348 weight, num_replicas, num_groups, num_nodes, num_ranks
349 )
350 else:
351 # use global load-balance policy
249 - phy2log, phyrank, logcnt = cls.rebalance_experts_hierarchical(
352 + phy2log, phy_replicas_idx, logcnt = cls.rebalance_experts_hierarchical(
353 weight, num_replicas, 1, 1, num_ranks
354 )
355 + # Optional postprocessing to preserve slots for experts moving
356 + # within the same GPU
357 + # Only apply when the number of GPUs and slots per GPU remain unchanged.
358 + # Helps to avoid unnecessary weight copying when experts move
359 + # within the same GPU.
360 + if old_global_expert_indices is not None:
361 + phy2log, phy_replicas_idx = cls.preserve_intragpu_slots(
362 + phy2log, phy_replicas_idx, num_ranks, old_global_expert_indices
363 + )
364 num_redundant_experts = num_replicas - num_logical_experts
365 maxlogcnt = num_redundant_experts + 1
366 log2phy: torch.Tensor = torch.full(
@@ -259,7 +371,7 @@ class DefaultEplbPolicy(AbstractEplbPolicy):
371 )
372 log2phy.view(num_layers, -1).scatter_(
373 -1,
262 - phy2log * maxlogcnt + phyrank,
374 + phy2log * maxlogcnt + phy_replicas_idx,
375 torch.arange(num_replicas, dtype=torch.int64, device=log2phy.device).expand(
376 num_layers, -1
377 ),
vllm/distributed/eplb/rebalance_execute.py
+388 -218
@@ -6,9 +6,10 @@ The actual execution of the rearrangement.
6 This involves the exchange of expert weights between GPUs.
7 """
8
9 -from collections.abc import Iterable, MutableSequence, Sequence
10 -from functools import partial
9 +from collections.abc import Iterable, Sequence
10 +from dataclasses import dataclass
11
12 +import numpy as np
13 import torch
14 from torch.distributed import (
15 P2POp,
@@ -18,213 +19,317 @@ from torch.distributed import (
19 get_global_rank,
20 )
21
22 +from vllm.logger import init_logger
23
22 -def idx_local_to_global(
23 - local_idx: int,
24 - local_cnt: int,
25 - ep_rank: int,
26 -) -> int:
27 - """
28 - Convert a local expert index to a global expert index.
29 - """
30 - return ep_rank * local_cnt + local_idx
24 +logger = init_logger(__name__)
25
26
33 -def idx_global_to_local(
34 - global_idx: int,
35 - local_cnt: int,
36 - ep_rank: int,
37 -) -> int:
38 - """
39 - Convert a global expert index to a local expert index.
40 - """
41 - return global_idx - ep_rank * local_cnt
27 +@dataclass
28 +class RecvMetadata:
29 + """Metadata describing remote receives during EPLB rebalancing."""
30
31 + recv_primary_mask: np.ndarray
32 + """Mask of (num_local_experts,) indicating primary experts received."""
33 + recv_count: int
34 + """Number of received experts for the layer."""
35 + recv_expert_ids: np.ndarray
36 + """Expert ids (num_local_experts,) of remote primary experts."""
37 + recv_dst_rows: np.ndarray
38 + """Target expert indices (num_local_experts,) in local tensors to send."""
39
44 -def global_idx_to_rank(
45 - global_idx: int,
46 - local_cnt: int,
47 -) -> int:
48 - """
49 - Convert a global expert index to a rank index.
50 - """
51 - return global_idx // local_cnt
40
41 +# Type alias for the result of move_to_buffer or transfer_layer
42 +MoveToBufferResult = tuple[np.ndarray, np.ndarray, RecvMetadata]
43
54 -def get_ep_ranks_with_expert(
55 - idx: int,
44 +
45 +def get_ep_ranks_with_experts_batch(
46 + expert_ids: np.ndarray,
47 num_local_experts: int,
57 - old_indices: Sequence[int],
58 - new_indices: Sequence[int],
59 -) -> tuple[MutableSequence[int], MutableSequence[int]]:
48 + old_indices: np.ndarray,
49 + new_indices: np.ndarray,
50 +) -> tuple[dict[int, list[int]], dict[int, list[int]]]:
51 """
52 Get the ranks of the experts that need to be exchanged.
53
54 Args:
64 - idx: The index of the expert.
55 + expert_ids: 1D array of expert indices to query.
56 num_local_experts: The number of local experts.
57 old_indices: The old indices of the experts.
58 new_indices: The new indices of the experts.
59
60 Returns:
70 - A tuple of two lists:
71 - - The ranks of the experts that need to be sent.
72 - - The ranks of the experts that need to be received.
61 + A tuple of two dictionaries mapping expert_id to:
62 + - ranks_to_send: The ranks that have this expert and need to send.
63 + - ranks_to_recv: The ranks that need to receive this expert.
64 """
74 - global2rank = partial(
75 - global_idx_to_rank,
76 - local_cnt=num_local_experts,
77 - )
65 + ranks_to_send_map: dict[int, list[int]] = {}
66 + ranks_to_recv_map: dict[int, list[int]] = {}
67 +
68 + # Fast path: if no experts, return empty dicts
69 + if expert_ids.size == 0:
70 + return ranks_to_send_map, ranks_to_recv_map
71 +
72 + unique_experts = np.unique(expert_ids)
73 + num_positions = len(old_indices)
74 + position_indices = np.arange(num_positions, dtype=np.int32)
75 +
76 + # Vectorized approach: find all positions matching any query expert in one pass
77 + # Use np.isin to get boolean masks for all relevant positions at once
78 + old_relevant_mask = np.isin(old_indices, unique_experts)
79 + new_relevant_mask = np.isin(new_indices, unique_experts)
80 +
81 + # Process old_indices (send ranks)
82 + if np.any(old_relevant_mask):
83 + old_relevant_positions = position_indices[old_relevant_mask]
84 + old_relevant_experts = old_indices[old_relevant_mask]
85 + old_relevant_ranks = old_relevant_positions // num_local_experts
86 +
87 + # Sort by expert first, then by position (to maintain first-appearance order)
88 + sort_order = np.lexsort((old_relevant_positions, old_relevant_experts))
89 + sorted_experts = old_relevant_experts[sort_order]
90 + sorted_ranks = old_relevant_ranks[sort_order]
91 +
92 + # Find boundaries where expert changes
93 + expert_boundaries = np.concatenate(
94 + [[0], np.where(np.diff(sorted_experts) != 0)[0] + 1, [len(sorted_experts)]]
95 + )
96
79 - ranks_to_send: list[int] = []
80 - ranks_to_recv: list[int] = []
81 -
82 - for i, e in enumerate(old_indices):
83 - if e == idx:
84 - rank = global2rank(i)
85 - if not ranks_to_send or ranks_to_send[-1] != rank:
86 - ranks_to_send.append(rank)
87 -
88 - for i, e in enumerate(new_indices):
89 - if e == idx:
90 - rank = global2rank(i)
91 - if not ranks_to_recv or ranks_to_recv[-1] != rank:
92 - ranks_to_recv.append(rank)
93 -
94 - # Remove those ranks that can get this expert locally.
95 - ranks_to_send_set = set(ranks_to_send)
96 - ranks_to_recv_actual = [
97 - rank for rank in ranks_to_recv if rank not in ranks_to_send_set
98 - ]
97 + # For each expert, extract unique ranks in order of first appearance
98 + for i in range(len(expert_boundaries) - 1):
99 + start, end = expert_boundaries[i], expert_boundaries[i + 1]
100 + expert = int(sorted_experts[start])
101 + expert_ranks = sorted_ranks[start:end]
102 +
103 + # Get unique ranks preserving order
104 + _, unique_idx = np.unique(expert_ranks, return_index=True)
105 + unique_ranks = expert_ranks[np.sort(unique_idx)]
106 + ranks_to_send_map[expert] = unique_ranks.tolist()
107 +
108 + # Process new_indices (recv ranks)
109 + if np.any(new_relevant_mask):
110 + new_relevant_positions = position_indices[new_relevant_mask]
111 + new_relevant_experts = new_indices[new_relevant_mask]
112 + new_relevant_ranks = new_relevant_positions // num_local_experts
113 +
114 + # Sort by expert first, then by position
115 + sort_order = np.lexsort((new_relevant_positions, new_relevant_experts))
116 + sorted_experts = new_relevant_experts[sort_order]
117 + sorted_ranks = new_relevant_ranks[sort_order]
118 +
119 + # Find boundaries where expert changes
120 + expert_boundaries = np.concatenate(
121 + [[0], np.where(np.diff(sorted_experts) != 0)[0] + 1, [len(sorted_experts)]]
122 + )
123
100 - return ranks_to_send, ranks_to_recv_actual
124 + # For each expert, extract unique ranks and exclude local copies
125 + for i in range(len(expert_boundaries) - 1):
126 + start, end = expert_boundaries[i], expert_boundaries[i + 1]
127 + expert = int(sorted_experts[start])
128 + expert_ranks = sorted_ranks[start:end]
129 +
130 + # Get unique ranks preserving order
131 + _, unique_idx = np.unique(expert_ranks, return_index=True)
132 + unique_ranks = expert_ranks[np.sort(unique_idx)]
133 +
134 + # Remove ranks that have local copies (in send map)
135 + send_ranks_set = set(ranks_to_send_map.get(expert, []))
136 + recv_ranks_actual = [
137 + int(r) for r in unique_ranks if r not in send_ranks_set
138 + ]
139 + ranks_to_recv_map[expert] = recv_ranks_actual
140 +
141 + # Handle experts that only appear in old (send only) or new (recv only)
142 + for expert in unique_experts:
143 + expert = int(expert)
144 + if expert not in ranks_to_send_map:
145 + ranks_to_send_map[expert] = []
146 + if expert not in ranks_to_recv_map:
147 + ranks_to_recv_map[expert] = []
148 +
149 + return ranks_to_send_map, ranks_to_recv_map
150
151
152 def move_to_buffer(
153 num_local_experts: int,
105 - old_indices: Sequence[int],
106 - new_indices: Sequence[int],
154 + old_indices: np.ndarray,
155 + new_indices: np.ndarray,
156 expert_weights: Iterable[torch.Tensor],
108 - expert_weights_buffer: Sequence[torch.Tensor],
157 + expert_weights_buffers: Sequence[torch.Tensor],
158 cuda_stream: torch.cuda.Stream | None,
159 ep_group: ProcessGroup,
111 -) -> tuple[list[bool], list[bool], dict[int, int]]:
160 +) -> MoveToBufferResult:
161 """
113 - Perform expert weights rearrangement of one layer.
162 + Rearranges expert weights during EPLB rebalancing.
163 +
164 + Args:
165 + num_local_experts: Number of local experts.
166 + old_indices: (num_experts_total,) ndarray of current (old)
167 + global-to-local expert assignments.
168 + new_indices: (num_experts_total,) ndarray of desired (new)
169 + global-to-local assignments after rebalance.
170 + expert_weights: Original expert weights for the layer.
171 + expert_weights_buffers: Intermediate buffers (one per tensor).
172 + cuda_stream: CUDA stream for async copies (can be None for sync mode).
173 + ep_group: Distributed process group for expert parallel comms.
174 +
175 + Returns:
176 + is_unchanged (np.ndarray): (num_local_experts,), True where an expert row
177 + is unchanged after rebalance.
178 + is_received_locally (np.ndarray): (num_local_experts,), True where a row
179 + can be updated from local data.
180 + RecvMetadata: Metadata needed for completing remote weight transfers.
181 """
182 + assert old_indices.shape == new_indices.shape
183 ep_rank = ep_group.rank()
116 - local2global = partial(
117 - idx_local_to_global,
118 - local_cnt=num_local_experts,
119 - ep_rank=ep_rank,
120 - )
184
122 - # 0. Do nothing for experts that did not change.
123 - is_unchanged = [
124 - old_indices[local2global(i)] == new_indices[local2global(i)]
125 - for i in range(num_local_experts)
126 - ]
185 + recv_primary_mask = np.zeros((num_local_experts,), dtype=np.bool_)
186 + send_expert_ids = np.full((num_local_experts,), -1, dtype=np.int64)
187 + send_src_rows = np.full((num_local_experts,), -1, dtype=np.int32)
188 + recv_expert_ids = np.full((num_local_experts,), -1, dtype=np.int64)
189 + recv_dst_rows = np.full((num_local_experts,), -1, dtype=np.int32)
190
128 - # 1. Perform weight copy inside the local rank.
129 - is_received_locally = is_unchanged[:]
130 - for src in range(num_local_experts):
131 - src_global = local2global(src)
132 - for dst in range(num_local_experts):
133 - dst_global = local2global(dst)
134 - if is_received_locally[dst]:
135 - continue
136 - if old_indices[src_global] == -1 or new_indices[dst_global] == -1:
137 - continue
138 - if old_indices[src_global] == new_indices[dst_global]:
139 - is_received_locally[dst] = True
140 - for weight, buffer in zip(expert_weights, expert_weights_buffer):
141 - with torch.cuda.stream(cuda_stream):
142 - buffer[dst].copy_(weight[src], non_blocking=True)
191 + base = ep_rank * num_local_experts
192 + local_rows = np.arange(num_local_experts, dtype=np.int32)
193 + local_global = base + local_rows
194 +
195 + old_local_expert_ids = old_indices[local_global]
196 + new_local_expert_ids = new_indices[local_global]
197 +
198 + # Unchanged mask
199 + is_unchanged = old_local_expert_ids == new_local_expert_ids
200 +
201 + # Local receive eligibility
202 + new_valid = new_local_expert_ids != -1
203 + can_recv_local = np.isin(
204 + new_local_expert_ids, old_local_expert_ids, assume_unique=False
205 + )
206 + is_received_locally = np.logical_or(
207 + is_unchanged, np.logical_and(new_valid, can_recv_local)
208 + )
209 +
210 + # Send map: first src row per unique expert present locally in old mapping
211 + send_count = 0
212 + valid_old = old_local_expert_ids != -1
213 + if np.any(valid_old):
214 + uniq_experts, first_idx = np.unique(
215 + old_local_expert_ids[valid_old], return_index=True
216 + )
217 + filtered_rows = local_rows[valid_old]
218 + src_rows = filtered_rows[first_idx]
219 + send_count = int(uniq_experts.shape[0])
220 + send_expert_ids[:send_count] = uniq_experts
221 + send_src_rows[:send_count] = src_rows
222 +
223 + # Recv map: primary dst per unique expert needed remotely
224 + recv_count = 0
225 + need_recv_mask = np.logical_and(~is_received_locally, new_valid)
226 + if np.any(need_recv_mask):
227 + desired_experts = new_local_expert_ids[need_recv_mask]
228 + desired_dsts = local_rows[need_recv_mask]
229 + uniq_recv_experts, uniq_indices = np.unique(desired_experts, return_index=True)
230 + dst_rows = desired_dsts[uniq_indices]
231 + recv_count = int(uniq_recv_experts.shape[0])
232 + recv_expert_ids[:recv_count] = uniq_recv_experts
233 + recv_dst_rows[:recv_count] = dst_rows
234 + recv_primary_mask[dst_rows] = True
235 +
236 + eligible_local_buffer_mask = np.logical_and(~is_unchanged, is_received_locally)
237 +
238 + # 1. Local moves into tmp buffers
239 + if bool(eligible_local_buffer_mask.any()) and send_count > 0:
240 + dest_indices = np.nonzero(eligible_local_buffer_mask)[0].tolist()
241 + expert_to_src_map = dict(
242 + zip(send_expert_ids[:send_count], send_src_rows[:send_count])
243 + )
244 + for dst in dest_indices:
245 + expert = new_local_expert_ids[dst]
246 + src_local = expert_to_src_map.get(expert, -1)
247 + if src_local != -1:
248 + for w, b in zip(expert_weights, expert_weights_buffers):
249 + b[dst].copy_(w[src_local], non_blocking=True)
250
251 p2p_ops: list[P2POp] = []
252
146 - # 2. Initiate sending of weights.
147 - experts_send_loc: dict[int, int] = {}
148 - for src in range(num_local_experts):
149 - expert = old_indices[local2global(src)]
150 - if expert == -1:
151 - continue
152 - if expert in experts_send_loc:
153 - continue
154 - experts_send_loc[expert] = src
155 -
156 - # We need to sort here to match send/recv
157 - for expert, src in sorted(experts_send_loc.items()):
158 - ranks_to_send, ranks_to_recv = get_ep_ranks_with_expert(
159 - expert,
253 + # Pre-compute global ranks mapping
254 + ep_size = ep_group.size()
255 + rank_to_global = {rank: get_global_rank(ep_group, rank) for rank in range(ep_size)}
256 +
257 + # 2. Post sends
258 + if send_count > 0:
259 + experts = send_expert_ids[:send_count]
260 + srcs = send_src_rows[:send_count]
261 + order = np.argsort(experts, kind="stable")
262 + experts = experts[order]
263 + srcs = srcs[order]
264 +
265 + send_map, recv_map = get_ep_ranks_with_experts_batch(
266 + experts,
267 num_local_experts,
268 old_indices,
269 new_indices,
270 )
271
165 - # Calculate the ranks to send by this rank
166 - num_dst_per_sender = len(ranks_to_recv) // len(ranks_to_send)
167 - sender_pos = ranks_to_send.index(ep_rank)
168 - recv_begin = sender_pos * num_dst_per_sender
169 - recv_end = recv_begin + num_dst_per_sender
170 - recv_ranks = ranks_to_recv[recv_begin:recv_end]
171 -
172 - # Tackle remainders
173 - remainder_start = len(ranks_to_send) * num_dst_per_sender
174 - recver_pos = remainder_start + sender_pos
175 - if recver_pos < len(ranks_to_recv):
176 - recv_ranks.append(ranks_to_recv[recver_pos])
177 -
178 - for dst in recv_ranks:
179 - dst_global = get_global_rank(ep_group, dst)
180 - p2p_ops += [
181 - P2POp(
182 - torch.distributed.isend,
183 - weight[src],
184 - dst_global,
185 - )
186 - for weight in expert_weights
187 - ]
188 -
189 - # 3. Initiate receiving of weights.
190 - experts_recv_loc: dict[int, int] = {}
191 - for dst in range(num_local_experts):
192 - if is_received_locally[dst]:
193 - continue
194 - expert = new_indices[local2global(dst)]
195 - if expert == -1:
196 - continue
197 - if expert in experts_recv_loc:
198 - continue
199 - experts_recv_loc[expert] = dst
200 -
201 - # We need to sort here to match send/recv
202 - for expert, dst in sorted(experts_recv_loc.items()):
203 - ranks_to_send, ranks_to_recv = get_ep_ranks_with_expert(
204 - expert,
272 + for expert, src in zip(experts.tolist(), srcs.tolist()):
273 + ranks_to_send = send_map[expert]
274 + ranks_to_recv = recv_map[expert]
275 + if not ranks_to_send or not ranks_to_recv:
276 + continue
277 + num_dst_per_sender = len(ranks_to_recv) // len(ranks_to_send)
278 + sender_pos = ranks_to_send.index(ep_rank)
279 + recv_begin = sender_pos * num_dst_per_sender
280 + recv_end = recv_begin + num_dst_per_sender
281 + recv_ranks = ranks_to_recv[recv_begin:recv_end]
282 + remainder_start = len(ranks_to_send) * num_dst_per_sender
283 + recver_pos = remainder_start + sender_pos
284 + if recver_pos < len(ranks_to_recv):
285 + recv_ranks.append(ranks_to_recv[recver_pos])
286 + for dst in recv_ranks:
287 + dst_global = rank_to_global[dst]
288 + p2p_ops += [
289 + P2POp(
290 + torch.distributed.isend,
291 + w[src],
292 + dst_global,
293 + )
294 + for w in expert_weights
295 + ]
296 +
297 + # 3. Post recvs
298 + if recv_count > 0:
299 + experts = recv_expert_ids[:recv_count]
300 + dsts = recv_dst_rows[:recv_count]
301 + order = np.argsort(experts, kind="stable")
302 + experts = experts[order]
303 + dsts = dsts[order]
304 +
305 + send_map, recv_map = get_ep_ranks_with_experts_batch(
306 + experts,
307 num_local_experts,
308 old_indices,
309 new_indices,
310 )
311
210 - # Calculate the rank to recv by this rank
211 - num_dst_per_sender = len(ranks_to_recv) // len(ranks_to_send)
212 - recver_pos = ranks_to_recv.index(ep_rank)
213 - remainder_start = len(ranks_to_send) * num_dst_per_sender
214 - if recver_pos < remainder_start:
215 - src = ranks_to_send[recver_pos // num_dst_per_sender]
216 - else:
217 - src = ranks_to_send[recver_pos - remainder_start]
218 -
219 - src_global = get_global_rank(ep_group, src)
220 - p2p_ops += [
221 - P2POp(
222 - torch.distributed.irecv,
223 - weight[dst],
224 - src_global,
225 - )
226 - for weight in expert_weights_buffer
227 - ]
312 + for expert, dst in zip(experts.tolist(), dsts.tolist()):
313 + ranks_to_send = send_map[expert]
314 + ranks_to_recv = recv_map[expert]
315 + if not ranks_to_send or not ranks_to_recv:
316 + continue
317 + num_dst_per_sender = len(ranks_to_recv) // len(ranks_to_send)
318 + recver_pos = ranks_to_recv.index(ep_rank)
319 + remainder_start = len(ranks_to_send) * num_dst_per_sender
320 + if recver_pos < remainder_start:
321 + src = ranks_to_send[recver_pos // num_dst_per_sender]
322 + else:
323 + src = ranks_to_send[recver_pos - remainder_start]
324 + src_global = rank_to_global[src]
325 + p2p_ops += [
326 + P2POp(
327 + torch.distributed.irecv,
328 + b[dst],
329 + src_global,
330 + )
331 + for b in expert_weights_buffers
332 + ]
333
334 # 4. Execute the P2P operations. The real communication happens here.
335 if p2p_ops and cuda_stream is not None:
@@ -237,38 +342,95 @@ def move_to_buffer(
342 for req in reqs:
343 req.wait()
344 # wait for the communication to finish
240 - return is_unchanged, is_received_locally, experts_recv_loc
345 + return (
346 + is_unchanged,
347 + is_received_locally,
348 + RecvMetadata(
349 + recv_primary_mask=recv_primary_mask,
350 + recv_count=recv_count,
351 + recv_expert_ids=recv_expert_ids,
352 + recv_dst_rows=recv_dst_rows,
353 + ),
354 + )
355
356
357 def move_from_buffer(
358 expert_weights: Iterable[torch.Tensor],
245 - expert_weights_buffer: list[torch.Tensor],
246 - is_unchanged: list[bool],
247 - is_received_locally: list[bool],
248 - experts_recv_loc: dict[int, int],
249 - new_indices: Sequence[int],
250 - ep_group: ProcessGroup,
359 + expert_weights_buffers: list[torch.Tensor],
360 + is_unchanged: np.ndarray,
361 + is_received_locally: np.ndarray,
362 + recv_metadata: RecvMetadata,
363 + new_indices: np.ndarray,
364 + ep_rank: int,
365 ) -> None:
252 - ep_rank = ep_group.rank()
253 - num_local_experts = len(is_unchanged)
366 + """
367 + Copies expert weights from communication buffers back to the target weight tensors
368 + after EPLB rebalancing.
369 +
370 + Args:
371 + expert_weights: List of the actual MoE layer weights used in the execution.
372 + expert_weights_buffers: Intermediate buffers containing the experts weights
373 + after the transfer is completed.
374 + is_unchanged: (num_local_experts,), True where an expert row is unchanged.
375 + is_received_locally: (num_local_experts,), True where a row is updated locally.
376 + recv_metadata: RecvMetadata containing remote receive metadata.
377 + new_indices: (num_experts_total,) mapping from local rows to desired
378 + (possibly global) expert id, after rebalance.
379 + ep_rank: Rank of the process in the expert parallel group.
380 + """
381 + recv_primary_mask = recv_metadata.recv_primary_mask
382 + recv_count = recv_metadata.recv_count
383 + recv_expert_ids = recv_metadata.recv_expert_ids
384 + recv_dst_rows = recv_metadata.recv_dst_rows
385 + num_local_experts = is_unchanged.shape[0]
386 +
387 + # Mask for rows to copy back from buffers:
388 + # copy if locally received OR remote primary recv
389 + copy_mask = np.logical_or(is_received_locally, recv_primary_mask)
390 + dest_mask_np = np.logical_and(~is_unchanged, copy_mask)
391 + if bool(dest_mask_np.any()):
392 + dest_indices = np.nonzero(dest_mask_np)[0].tolist()
393 + for dst in dest_indices:
394 + for w, b in zip(expert_weights, expert_weights_buffers):
395 + w[dst].copy_(b[dst], non_blocking=True)
396 +
397 + if recv_count == 0:
398 + return
399
255 - local2global = partial(
256 - idx_local_to_global, local_cnt=num_local_experts, ep_rank=ep_rank
400 + # Duplicate remote received rows to non-primary duplicate dsts
401 + base = ep_rank * num_local_experts
402 + local_experts = new_indices[base + np.arange(num_local_experts, dtype=np.int32)]
403 + duplicate_mask = np.logical_and(
404 + np.logical_and(~is_unchanged, ~is_received_locally),
405 + np.logical_and(~recv_primary_mask, local_experts != -1),
406 )
407 + # All received experts are unique in the destination, so no need to copy duplicates
408 + if not bool(duplicate_mask.any()):
409 + return
410
259 - for dst in range(num_local_experts):
260 - if is_unchanged[dst]:
261 - continue
262 - if is_received_locally[dst]:
263 - for weight, buffer in zip(expert_weights, expert_weights_buffer):
264 - weight[dst].copy_(buffer[dst], non_blocking=True)
265 - else:
266 - expert = new_indices[local2global(dst)]
267 - if expert == -1:
268 - continue
269 - src = experts_recv_loc[expert]
270 - for weight, buffer in zip(expert_weights, expert_weights_buffer):
271 - weight[dst].copy_(buffer[src], non_blocking=True)
411 + dup_dst_rows = np.nonzero(duplicate_mask)[0]
412 + dup_experts = local_experts[dup_dst_rows]
413 +
414 + prim_experts = recv_expert_ids[:recv_count]
415 + prim_dsts = recv_dst_rows[:recv_count]
416 + order = np.argsort(prim_experts, kind="stable")
417 + prim_experts_sorted = prim_experts[order]
418 + prim_dsts_sorted = prim_dsts[order]
419 + pos = np.searchsorted(prim_experts_sorted, dup_experts)
420 + valid = np.logical_and(
421 + pos < prim_experts_sorted.shape[0],
422 + prim_experts_sorted[np.minimum(pos, prim_experts_sorted.shape[0] - 1)]
423 + == dup_experts,
424 + )
425 + if not bool(valid.any()):
426 + return
427 +
428 + matched_dst_rows = dup_dst_rows[valid]
429 + matched_src_rows = prim_dsts_sorted[pos[valid]]
430 +
431 + for dst, src in zip(matched_dst_rows.tolist(), matched_src_rows.tolist()):
432 + for w in expert_weights:
433 + w[dst].copy_(w[src], non_blocking=True)
434
435
436 async def transfer_layer(
@@ -281,7 +443,7 @@ async def transfer_layer(
443 layer: int = 0,
444 cuda_stream: torch.cuda.Stream | None = None,
445 rank_mapping: dict[int, int] | None = None,
284 -) -> tuple[list[bool], list[bool], dict[int, int]]:
446 +) -> MoveToBufferResult:
447 """
448 Rearranges the expert weights in place according to the new expert indices.
449
@@ -299,6 +461,13 @@ async def transfer_layer(
461 is_profile (bool): If `True`, do not perform any actual weight copy.
462 This is used during profile run, where we only perform dummy
463 communications to reserve enough memory for the buffers.
464 +
465 + Returns:
466 + is_unchanged (np.ndarray): (1, num_local_experts), True where expert
467 + is left unchanged.
468 + is_received_locally (np.ndarray): (1, num_local_experts), True where expert
469 + can be received locally.
470 + RecvMetadata: Metadata needed for completing remote weight transfers.
471 """
472 ep_size = ep_group.size()
473 if rank_mapping is not None:
@@ -323,16 +492,19 @@ async def transfer_layer(
492 assert new_global_expert_indices.shape == (num_moe_layers, num_physical_experts)
493 assert num_physical_experts == ep_size * num_local_physical_experts
494
326 - is_unchanged, is_received_locally, experts_recv_loc = move_to_buffer(
495 + old_global_expert_indices_np = old_global_expert_indices.cpu().numpy()
496 + new_global_expert_indices_np = new_global_expert_indices.cpu().numpy()
497 +
498 + is_unchanged, is_received_locally, recv_metadata = move_to_buffer(
499 num_local_experts=num_local_physical_experts,
328 - old_indices=old_global_expert_indices[layer].tolist(),
329 - new_indices=new_global_expert_indices[layer].tolist(),
500 + old_indices=old_global_expert_indices_np[layer],
501 + new_indices=new_global_expert_indices_np[layer],
502 expert_weights=expert_weights[layer],
331 - expert_weights_buffer=expert_weights_buffer,
503 + expert_weights_buffers=expert_weights_buffer,
504 cuda_stream=cuda_stream,
505 ep_group=ep_group,
506 )
335 - return is_unchanged, is_received_locally, experts_recv_loc
507 + return is_unchanged, is_received_locally, recv_metadata
508
509
510 def rearrange_expert_weights_inplace(
@@ -388,19 +560,17 @@ def rearrange_expert_weights_inplace(
560 ep_size = ep_group.size()
561 assert num_physical_experts == ep_size * num_local_physical_experts
562
391 - # A buffer to hold the expert weights in one layer during the exchange.
563 + first_layer_weights = list(expert_weights[0])
564 + # Buffers to hold the expert weights during the exchange.
565 # NOTE: Currently we assume the same weights across different layers
566 # have the same shape.
394 - expert_weights_buffer = [torch.empty_like(w) for w in expert_weights[0]]
395 -
567 + weights_buffer: list[torch.Tensor] = [
568 + torch.empty_like(w) for w in first_layer_weights
569 + ]
570 if is_profile:
397 - # Maximum send size is to send all local experts to all ranks,
398 - # So we use a dummy `all_gather` to reserve enough communication buffer
399 - for weight, buffer in zip(expert_weights[0], expert_weights_buffer):
400 - # A `/dev/null`-like buffer to avoid real memory allocation
571 + # Reserve communication buffers via a minimal dummy all_gather on first layer
572 + for weight, buffer in zip(expert_weights[0], weights_buffer):
573 dummy_recv_buffer = [buffer for _ in range(ep_size)]
402 - # NOTE(bowen): Needed this barrier to avoid OOM during actual
403 - # execution. I'm not very sure why this is needed
574 torch.distributed.barrier()
575 all_gather(
576 dummy_recv_buffer,
@@ -409,32 +579,32 @@ def rearrange_expert_weights_inplace(
579 )
580 return
581
412 - old_global_expert_indices_cpu = old_global_expert_indices.cpu()
413 - new_global_expert_indices_cpu = new_global_expert_indices.cpu()
414 -
582 # NOTE(bowen): We need this synchronize to run, but I don't know why.
583 # If you figure out the reason, please let me know -- thank you!
584 torch.cuda.synchronize()
585
419 - for layer in range(num_moe_layers):
420 - is_unchanged, is_received_locally, experts_recv_loc = move_to_buffer(
586 + old_global_expert_indices_cpu = old_global_expert_indices.cpu().numpy()
587 + new_global_expert_indices_cpu = new_global_expert_indices.cpu().numpy()
588 +
589 + for layer_idx in range(num_moe_layers):
590 + is_unchanged, is_received_locally, recv_metadata = move_to_buffer(
591 num_local_experts=num_local_physical_experts,
422 - old_indices=old_global_expert_indices_cpu[layer].tolist(),
423 - new_indices=new_global_expert_indices_cpu[layer].tolist(),
424 - expert_weights=expert_weights[layer],
425 - expert_weights_buffer=expert_weights_buffer,
592 + old_indices=old_global_expert_indices_cpu[layer_idx],
593 + new_indices=new_global_expert_indices_cpu[layer_idx],
594 + expert_weights=expert_weights[layer_idx],
595 + expert_weights_buffers=weights_buffer,
596 cuda_stream=None,
597 ep_group=ep_group,
598 )
599
600 move_from_buffer(
431 - expert_weights=expert_weights[layer],
432 - expert_weights_buffer=expert_weights_buffer,
601 + expert_weights=expert_weights[layer_idx],
602 + expert_weights_buffers=weights_buffer,
603 is_unchanged=is_unchanged,
604 is_received_locally=is_received_locally,
435 - experts_recv_loc=experts_recv_loc,
436 - new_indices=new_global_expert_indices[layer].tolist(),
437 - ep_group=ep_group,
605 + recv_metadata=recv_metadata,
606 + new_indices=new_global_expert_indices_cpu[layer_idx],
607 + ep_rank=ep_group.rank(),
608 )
609
610
@@ -526,4 +696,4 @@ def _map_new_expert_indices_with_rank_mapping(
696 return mapped_expert_indices
697
698
529 -__all__ = ["transfer_layer", "move_from_buffer"]
699 +__all__ = ["transfer_layer", "move_from_buffer", "RecvMetadata"]