Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f4df2af
Wip
ilmarkov Nov 24, 2025
a46c72a
Optimize weight rearrange with numpy
ilmarkov Nov 25, 2025
561b427
Add preserve expert on the same slot within gpu optimization
ilmarkov Nov 25, 2025
691f090
Merge branch 'main' into imarkov/eplb_optimizations
ilmarkov Nov 25, 2025
b853314
Merge branch 'main' into imarkov/eplb_optimizations
ilmarkov Nov 26, 2025
30bab97
Edit config and fix config post_init
ilmarkov Nov 26, 2025
c6f14d1
Optimize after codex review
ilmarkov Nov 26, 2025
0808374
Vectorize get_ep_ranks_with_experts
ilmarkov Nov 26, 2025
599648b
Merge branch 'main' into imarkov/eplb_optimizations
ilmarkov Dec 9, 2025
b462872
Fix pre-commit
ilmarkov Dec 9, 2025
60f744d
Merge branch 'main' into imarkov/eplb_optimizations
ilmarkov Dec 9, 2025
cfac6b3
Remove eplb config fix
ilmarkov Dec 9, 2025
6b2a1de
Updates after review
ilmarkov Dec 10, 2025
fc54d76
Correct eplb state logs
ilmarkov Dec 11, 2025
f28720d
Remove layer grouping
ilmarkov Dec 11, 2025
ab0ca86
Futher optimize rearrange
ilmarkov Dec 11, 2025
b57a045
Merge branch 'main' into imarkov/eplb_optimizations
ilmarkov Dec 11, 2025
208b51b
Upd
ilmarkov Dec 11, 2025
a5ecdc1
Add comments
ilmarkov Dec 11, 2025
040ae89
Address review comments
ilmarkov Dec 12, 2025
9a41b91
Fix precommit
ilmarkov Dec 12, 2025
7d0ab7d
Refactor tests and address nits
ilmarkov Dec 15, 2025
11c492a
Merge branch 'main' into imarkov/eplb_optimizations
ilmarkov Dec 15, 2025
1f90b1f
Update eplb config checks
ilmarkov Dec 16, 2025
def3415
Merge branch 'main' into imarkov/eplb_optimizations
tlrmchlsmth Jan 6, 2026
2134c9b
Fix test
ilmarkov Jan 7, 2026
4dc455b
Prettify the log
ilmarkov Jan 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions tests/distributed/test_eplb_algo.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,136 @@ def test_additional_cases():
print(phy2log)

test_basic_rebalance()


def _make_phyrank_from_phy2log(phy2log: torch.Tensor) -> torch.Tensor:
"""Create phyrank from phy2log"""
pr = torch.zeros_like(phy2log)
for layer in range(phy2log.shape[0]):
seen: dict[int, int] = {}
row = phy2log[layer].tolist()
for i, expert in enumerate(row):
r = seen.get(expert, 0)
pr[layer, i] = r
seen[expert] = r + 1
return pr


def _validate_intragpu_rearrangement(
old_global_expert_indices: torch.Tensor,
new_phy2log: torch.Tensor,
new_phyrank: torch.Tensor,
post_phy2log: torch.Tensor,
post_phyrank: torch.Tensor,
num_ranks: int,
slots_per_gpu: int,
):
# Per-GPU checks
for gpu_idx in range(num_ranks):
start = gpu_idx * slots_per_gpu
end = start + slots_per_gpu
old_seg = old_global_expert_indices[0, start:end]
new_seg = new_phy2log[0, start:end]
new_rnk = new_phyrank[0, start:end]
post_seg = post_phy2log[0, start:end]
post_rnk = post_phyrank[0, start:end]

# Pairwise equality for (expert, rank) pairs to ensure nothing is lost
def sorted_pairs(seg: torch.Tensor, rnk: torch.Tensor):
pairs = list(zip(seg.tolist(), rnk.tolist()))
pairs.sort()
return pairs

assert sorted_pairs(post_seg, post_rnk) == sorted_pairs(new_seg, new_rnk), (
f"Per-GPU pairs of (expert,rank) must match new mapping for GPU {gpu_idx}"
)

# For experts that remain on the same GPU, the old slot is preserved
# for at least one occurrence; rank at that slot must be valid for that expert
old_list = old_seg.tolist()
new_list = new_seg.tolist()
post_list = post_seg.tolist()
remained = set(old_list) & set(new_list)
new_ranks_for_expert: dict[int, list[int]] = {}
for v, r in zip(new_list, new_rnk.tolist()):
new_ranks_for_expert.setdefault(v, []).append(r)
for expert in remained:
old_pos = old_list.index(expert)
assert post_list[old_pos] == expert, (
f"Expert {expert} on GPU {gpu_idx} should stay at old slot {old_pos}"
)
# Rank at preserved slot must be one of the ranks
# the expert has in new mapping
assert post_rnk.tolist()[old_pos] in new_ranks_for_expert[expert], (
f"Rank for expert {expert} at preserved slot on GPU {gpu_idx} "
"must come from new mapping"
)


def test_preserve_intragpu_slots_simple():
"""Experts that stay on a GPU keep their old slots; incoming not lost."""
# Setup: 2 GPUs, 4 slots each, 1 layer
num_ranks = 2
slots_per_gpu = 4
# Old mapping: GPU0 -> [0,1,2,3], GPU1 -> [4,5,6,7]
old_global_expert_indices = torch.tensor([[0, 1, 2, 3, 4, 5, 6, 7]])
# New mapping shuffles within GPU0 and brings 4,5 into GPU0.
# GPU0 new -> [1,5,0,4] (0 and 1 remain on GPU0 but at different slots)
# GPU1 new -> [6,2,7,3] (6 and 7 remain on GPU1, 2 and 3 move in)
phy2log = torch.tensor([[1, 5, 0, 4, 6, 2, 7, 3]])
# Derive phyrank from replica occurrence order per expert
phyrank = _make_phyrank_from_phy2log(phy2log)

post_phy2log, post_phyrank = DefaultEplbPolicy.preserve_intragpu_slots(
phy2log, phyrank, num_ranks, old_global_expert_indices
)

# Shapes preserved
assert post_phy2log.shape == phy2log.shape
assert post_phyrank.shape == phyrank.shape

_validate_intragpu_rearrangement(
old_global_expert_indices,
phy2log,
phyrank,
post_phy2log,
post_phyrank,
num_ranks,
slots_per_gpu,
)


def test_preserve_intragpu_slots_with_duplicates():
Comment thread
ilmarkov marked this conversation as resolved.
Outdated
"""Test preserve intragpu slots with duplicates"""
# Setup: 2 GPUs, 5 slots each (total 10 physical experts), 1 layer
num_ranks = 2
slots_per_gpu = 5
# Old mapping:
# GPU0 -> [0, 1, 0, 2, 3] (expert 0 duplicated)
# GPU1 -> [4, 5, 6, 1, 2]
old_global_expert_indices = torch.tensor([[0, 1, 0, 2, 3, 4, 5, 6, 1, 2]])
# New mapping reorders within GPUs and moves some experts across GPUs,
# while still including duplicates:
# GPU0 new -> [0, 5, 4, 0, 1] (expert 0 duplicated, 4/5 incoming)
# GPU1 new -> [6, 2, 3, 1, 2] (expert 2 duplicated)
phy2log = torch.tensor([[0, 5, 4, 0, 1, 6, 2, 3, 1, 2]])
# Derive ranks so duplicates have ranks [0,1,...] by occurrence
phyrank = _make_phyrank_from_phy2log(phy2log)
Comment thread
ilmarkov marked this conversation as resolved.
Outdated

post_phy2log, post_phyrank = DefaultEplbPolicy.preserve_intragpu_slots(
phy2log, phyrank, num_ranks, old_global_expert_indices
)

# Shapes preserved
assert post_phy2log.shape == phy2log.shape
assert post_phyrank.shape == phyrank.shape

_validate_intragpu_rearrangement(
old_global_expert_indices,
phy2log,
phyrank,
post_phy2log,
post_phyrank,
num_ranks,
slots_per_gpu,
)
15 changes: 8 additions & 7 deletions tests/distributed/test_eplb_execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,31 +286,32 @@ def _test_async_transfer_layer_without_mtp_worker(
device,
old_indices,
)
old_indices_cpu = old_indices.cpu()
new_indices_cpu = new_indices.cpu()

expert_buffer = [torch.empty_like(w) for w in expert_weights[0]]
cuda_stream = torch.cuda.Stream(device=device)

for layer_idx in range(num_layers):
is_unchanged, is_received_locally, experts_recv_loc = asyncio.run(
is_unchanged, is_received_locally, recv_metadata = asyncio.run(
transfer_layer(
old_global_expert_indices=old_indices,
new_global_expert_indices=new_indices,
old_global_expert_indices=old_indices_cpu,
new_global_expert_indices=new_indices_cpu,
expert_weights=expert_weights,
expert_weights_buffer=expert_buffer,
ep_group=ep_group,
layer=layer_idx,
cuda_stream=cuda_stream,
)
)

cuda_stream.synchronize()
move_from_buffer(
expert_weights=expert_weights[layer_idx],
expert_weights_buffer=expert_buffer,
expert_weights_buffers=expert_buffer,
is_unchanged=is_unchanged,
is_received_locally=is_received_locally,
experts_recv_loc=experts_recv_loc,
new_indices=new_indices[layer_idx].tolist(),
recv_metadata=recv_metadata,
new_indices=new_indices_cpu[layer_idx],
ep_group=ep_group,
)

Expand Down
4 changes: 4 additions & 0 deletions vllm/config/parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ class EPLBConfig:
Log the balancedness each step of expert parallelism.
This is turned off by default since it will cause communication overhead.
"""
log_balancedness_interval: int = 1
"""
Interval for logging the balancedness.
"""
use_async: bool = False
"""
Whether to use non-blocking EPLB.
Expand Down
2 changes: 1 addition & 1 deletion vllm/distributed/eplb/async_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ async def transfer_run_periodically(
(
model_state.is_unchanged,
model_state.is_received_locally,
model_state.experts_recv_loc,
model_state.recv_metadata,
) = await transfer_layer(
old_global_expert_indices=model_state.physical_to_logical_map,
new_global_expert_indices=model_state.new_physical_to_logical_map,
Expand Down
90 changes: 58 additions & 32 deletions vllm/distributed/eplb/eplb_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
"""

import threading
import time
from collections.abc import Sequence
from dataclasses import dataclass

import numpy as np
import torch
from torch.distributed import ProcessGroup, all_reduce

Expand All @@ -46,7 +46,11 @@

from .async_worker import start_async_worker
from .policy import EPLB_POLICIES, AbstractEplbPolicy, DefaultEplbPolicy
from .rebalance_execute import move_from_buffer, rearrange_expert_weights_inplace
from .rebalance_execute import (
RecvMetadata,
move_from_buffer,
rearrange_expert_weights_inplace,
)

logger = init_logger(__name__)

Expand Down Expand Up @@ -164,20 +168,19 @@ class EplbModelState:
"""
Whether the async EPLB needs to poll peers for buffer readiness.
"""
is_unchanged: list[bool]
is_unchanged: np.ndarray
"""
intermediate variable between `move_to_buffer` and `move_to_workspace`.
The size is same as the num of physical experts in the current layer.
"""
is_received_locally: list[bool]
is_received_locally: np.ndarray
"""
intermediate variable between `move_to_buffer` and `move_to_workspace`.
The size is same as the num of physical experts in the current layer.
"""
experts_recv_loc: dict[int, int]
recv_metadata: RecvMetadata
"""
intermediate variable between `move_to_buffer` and `move_to_workspace`.
The size is same as the num of physical experts in the current layer.
"""
is_async_enabled: bool
"""
Expand Down Expand Up @@ -507,9 +510,14 @@ def add_model(
layer_to_transfer=0,
rebalanced=False,
pending_global_ready_check=False,
is_unchanged=[],
is_received_locally=[],
experts_recv_loc={},
is_unchanged=np.array([]),
is_received_locally=np.array([]),
recv_metadata=RecvMetadata(
recv_primary_mask=np.array([]),
recv_count=0,
recv_expert_ids=np.array([]),
recv_dst_rows=np.array([]),
),
is_async_enabled=self.is_async,
cuda_device_index=self.cuda_device_index,
new_physical_to_logical_map=new_physical_to_logical_map,
Expand Down Expand Up @@ -553,7 +561,12 @@ def step(
for eplb_model_state in self.model_states.values():
eplb_model_state.expert_load_pass.zero_()

if log_stats:
if (
log_stats
and self.expert_rearrangement_step
% self.parallel_config.eplb_config.log_balancedness_interval
== 0
):
# Sync the expert load pass for each model (main and drafter).
# expert_load_pass: (num_moe_layers, num_physical_experts)
expert_load_pass_list = self._sync_load_pass()
Expand Down Expand Up @@ -585,9 +598,10 @@ def step(

if ep_group.rank() == 0:
logger.info(
"EPLB step: %d for model %s: avg_tokens=%.2f, "
"EPLB step: %d/%d for model %s: avg_tokens=%.2f, "
"max_tokens=%d, balancedness=%.4f",
self.expert_rearrangement_step,
self.expert_rearrangement_step_interval,
Comment thread
ilmarkov marked this conversation as resolved.
Outdated
eplb_model_state.model_name,
avg_tokens,
max_tokens,
Expand Down Expand Up @@ -684,11 +698,14 @@ def rearrange(
ep_group = get_ep_group().device_group
ep_rank = ep_group.rank()

time_start = None
start_event = None
end_event = None
is_main_rank = ep_rank == 0
if is_main_rank:
torch.cuda.synchronize()
time_start = time.perf_counter()
if not self.is_async or is_profile:
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
start_event.record()
logger.info(
"Rearranging experts %s %s...",
"(async mode)" if self.is_async else "sync mode",
Expand Down Expand Up @@ -800,6 +817,7 @@ def rearrange(
num_groups,
num_nodes,
num_gpus,
eplb_model_state.physical_to_logical_map,
)

if not eplb_model_state.is_async_enabled or is_profile:
Expand Down Expand Up @@ -848,17 +866,17 @@ def rearrange(
new_logical_replica_count
)
if is_main_rank:
assert time_start is not None
torch.cuda.synchronize()
time_end = time.perf_counter()
assert start_event is not None
assert end_event is not None
end_event.record()
end_event.synchronize()
gpu_elapsed = start_event.elapsed_time(end_event) / 1000.0
logger.info(
"Rearranged experts%sin %.2f seconds.",
"Rearranged experts %s in %.2f s.",
" (profile) " if is_profile else " ",
time_end - time_start,
gpu_elapsed,
)
else:
device = eplb_model_state.physical_to_logical_map.device
new_physical = new_physical_to_logical_map.to(device)
max_slots = eplb_model_state.logical_to_physical_map.shape[-1]
padded_logical = torch.nn.functional.pad(
new_logical_to_physical_map,
Expand All @@ -869,7 +887,10 @@ def rearrange(
eplb_model_state.logical_replica_count.device
)

eplb_model_state.new_physical_to_logical_map = new_physical
# Move map to cpu in advance
eplb_model_state.new_physical_to_logical_map = (
new_physical_to_logical_map.cpu()
)
eplb_model_state.new_logical_to_physical_map = padded_logical
eplb_model_state.new_logical_replica_count = new_replica

Expand Down Expand Up @@ -968,25 +989,30 @@ def move_to_workspace(
stream = torch.cuda.current_stream(device=device_index)
stream.wait_event(model_state.buffer_ready_event)
model_state.buffer_ready_event = None
expert_weights = model_state.model.expert_weights[
model_state.layer_to_transfer
]
expert_weights_buffer = model_state.expert_buffer
new_indices = (
model_state.new_physical_to_logical_map[model_state.layer_to_transfer]
.cpu()
.numpy()
)
move_from_buffer(
expert_weights=model_state.model.expert_weights[
model_state.layer_to_transfer
],
expert_weights_buffer=model_state.expert_buffer,
expert_weights=expert_weights,
expert_weights_buffers=expert_weights_buffer,
is_unchanged=model_state.is_unchanged,
is_received_locally=model_state.is_received_locally,
experts_recv_loc=model_state.experts_recv_loc,
new_indices=model_state.new_physical_to_logical_map[
model_state.layer_to_transfer
].tolist(),
ep_group=ep_group,
recv_metadata=model_state.recv_metadata,
new_indices=new_indices,
ep_rank=ep_group.rank(),
)
transferred_layer = model_state.layer_to_transfer
self._update_layer_mapping_from_new(model_state, transferred_layer)
# After the main thread consumes, advance layer_to_transfer
model_state.layer_to_transfer += 1
model_state.ep_buffer_ready = 0
logger.info(
logger.debug(
"model %s successfully move_to_workspace layer %d",
model_state.model_name,
transferred_layer,
Expand Down
Loading