[EPLB] Mask padding in EPLB load recording (#38128)

Signed-off-by: ilmarkov <markovilya197@gmail.com> Signed-off-by: Markov Ilya <markovilya19@gmail.com> Co-authored-by: Markov Ilya <markovilya19@gmail.com>

Ilya Markov committed Jun 29, 2026 at 04:43 UTC a2abce646f7db07f2169dfc59433d4128bc404de
17 files changed +289 -14
tests/distributed/test_eplb_fused_moe_layer_dep_nvfp4.py
+6
@@ -225,6 +225,12 @@ def _test_eplb_fml(env, world_size: int, test_config: TestConfig):
225 logical_to_physical_map,
226 logical_replica_count,
227 )
228 + fml.router.eplb_state.should_record_tensor = torch.ones(
229 + (), dtype=torch.bool, device=device
230 + )
231 + fml.router.eplb_state.num_unpadded_tokens_tensors = [
232 + torch.tensor(0, dtype=torch.int32, device=device)
233 + ]
234
235 out_after_shuffle = []
236 with set_forward_context(
tests/kernels/moe/test_moe_layer.py
+3
@@ -1332,6 +1332,9 @@ def _test_body_eplb(
1332 eplb_moe_layer.router.eplb_state.should_record_tensor = torch.ones(
1333 (), dtype=torch.bool, device=device
1334 )
1335 + eplb_moe_layer.router.eplb_state.num_unpadded_tokens_tensors = [
1336 + torch.tensor(0, dtype=torch.int32, device=device)
1337 + ]
1338
1339 # Get "after" output with rearranged weights and EPLB routing
1340 with set_forward_context(
tests/kernels/moe/test_routing.py
+66
@@ -61,12 +61,14 @@ def setup_eplb_state(
61 global_num_experts, dtype=torch.int64, device="cuda"
62 )
63 should_record_tensor = torch.ones((), dtype=torch.bool, device="cuda")
64 + num_unpadded_tokens_tensors = [torch.tensor(0, dtype=torch.int32, device="cuda")]
65
66 return EplbLayerState(
67 expert_load_view=expert_load_view,
68 logical_to_physical_map=logical_to_physical_map,
69 logical_replica_count=logical_replica_count,
70 should_record_tensor=should_record_tensor,
71 + num_unpadded_tokens_tensors=num_unpadded_tokens_tensors,
72 )
73
74
@@ -782,3 +784,67 @@ def test_eplb_map_with_redundancy(
784 torch.testing.assert_close(load, exp_load)
785 else:
786 assert load.sum().item() == 0
787 +
788 +
789 +@pytest.mark.parametrize(
790 + "l2p_map, replica_count, num_physical, topk_ids, "
791 + "num_unpadded, expected_out, expected_load",
792 + [
793 + pytest.param(
794 + [[0], [1], [2], [3]],
795 + [1, 1, 1, 1],
796 + 4,
797 + [[0, 1], [2, 3], [0, 2], [1, 3]],
798 + 2,
799 + [[0, 1], [2, 3], [0, 2], [1, 3]],
800 + # only rows 0,1 counted: expert 0→1, 1→1, 2→1, 3→1
801 + [1, 1, 1, 1],
802 + id="half_padded",
803 + ),
804 + pytest.param(
805 + # record everything (None = no padding info)
806 + [[0], [1], [2], [3]],
807 + [1, 1, 1, 1],
808 + 4,
809 + [[0, 1], [2, 3], [0, 2], [1, 3]],
810 + None,
811 + [[0, 1], [2, 3], [0, 2], [1, 3]],
812 + [2, 2, 2, 2],
813 + id="no_padding_info",
814 + ),
815 + ],
816 +)
817 +def test_eplb_map_num_unpadded_tokens(
818 + l2p_map,
819 + replica_count,
820 + num_physical,
821 + topk_ids,
822 + num_unpadded,
823 + expected_out,
824 + expected_load,
825 +):
826 + l2p = torch.tensor(l2p_map, dtype=torch.int64, device="cuda")
827 + rc = torch.tensor(replica_count, dtype=torch.int64, device="cuda")
828 + load = torch.zeros(num_physical, dtype=torch.int32, device="cuda")
829 + rec = torch.tensor(True, dtype=torch.bool, device="cuda")
830 + ids = torch.tensor(topk_ids, dtype=torch.int32, device="cuda")
831 + num_unpadded_t = (
832 + torch.tensor(num_unpadded, dtype=torch.int32, device="cuda")
833 + if num_unpadded is not None
834 + else None
835 + )
836 +
837 + out = eplb_map_to_physical_and_record(
838 + topk_ids=ids,
839 + expert_load_view=load,
840 + logical_to_physical_map=l2p,
841 + logical_replica_count=rc,
842 + record_enabled=rec,
843 + num_unpadded_tokens=num_unpadded_t,
844 + )
845 +
846 + exp_out = torch.tensor(expected_out, dtype=out.dtype, device="cuda")
847 + torch.testing.assert_close(out, exp_out)
848 +
849 + exp_load = torch.tensor(expected_load, dtype=torch.int32, device="cuda")
850 + torch.testing.assert_close(load, exp_load)
tests/model_executor/test_routed_experts_capture.py
+1
@@ -91,6 +91,7 @@ def test_base_router_capture_with_eplb_enabled():
91 eplb_state.logical_to_physical_map = torch.arange(32).view(32, 1)
92 eplb_state.logical_replica_count = torch.ones(32, dtype=torch.int64)
93 eplb_state.should_record_tensor = torch.ones((), dtype=torch.bool)
94 + eplb_state.num_unpadded_tokens_tensors = [torch.tensor(0, dtype=torch.int32)]
95 router = _make_router(eplb_state=eplb_state)
96
97 captured = []
vllm/config/utils.py
+20
@@ -203,6 +203,26 @@ class SupportsHash(Protocol):
203 def compute_hash(self) -> str: ...
204
205
206 +_config_hash_cache: dict[int, str] = {}
207 +
208 +
209 +def compute_hash_cached(config: SupportsHash) -> str:
210 + """Cache config.compute_hash() by object identity.
211 +
212 + Config objects (ModelConfig, etc.) are long-lived singletons that never
213 + mutate after construction, but compute_hash() is expensive (JSON
214 + serialization + SHA-256). This utility avoids recomputing the hash on
215 + every forward pass while keeping a single consistent key type for all
216 + lookup paths.
217 + """
218 + key = id(config)
219 + result = _config_hash_cache.get(key)
220 + if result is None:
221 + result = config.compute_hash()
222 + _config_hash_cache[key] = result
223 + return result
224 +
225 +
226 class SupportsMetricsInfo(Protocol):
227 def metrics_info(self) -> dict[str, str]: ...
228
vllm/distributed/elastic_ep/elastic_execute.py
+3 -1
@@ -458,7 +458,9 @@ class ElasticEPScalingExecutor:
458 eplb_model_state.logical_to_physical_map,
459 eplb_model_state.logical_replica_count,
460 )
461 - eplb_state._init_should_record_tensor(model)
461 + eplb_state._propagate_shared_tensors(
462 + model, eplb_model_state.num_unpadded_tokens_tensors
463 + )
464 model.update_physical_experts_metadata(
465 num_physical_experts=num_physical_experts,
466 num_local_physical_experts=num_local_experts,
vllm/distributed/eplb/eplb_state.py
+68 -5
@@ -35,6 +35,7 @@ import torch
35 from torch.distributed import ProcessGroup, all_reduce
36
37 from vllm.config import ModelConfig, ParallelConfig
38 +from vllm.config.utils import compute_hash_cached
39 from vllm.distributed.parallel_state import (
40 get_ep_group,
41 get_eplb_group,
@@ -206,6 +207,13 @@ class EplbModelState:
207 pending_result relies on the GIL to synchronize access between the main thread and
208 the async worker.
209 """
210 + num_unpadded_tokens_tensors: list[torch.Tensor] | None = None
211 + """
212 + Per-ubatch scalar int32 tensors holding the number of real (non-padding)
213 + tokens. Allocated once in :meth:`EplbState.add_model` so that device
214 + pointers remain stable across CUDA-graph replays. The router kernel
215 + indexes this list with ``dbo_current_ubatch_id()``.
216 + """
217
218
219 class EplbState:
@@ -253,7 +261,7 @@ class EplbState:
261 Shared scalar bool tensor for all layers. Every
262 :class:`EplbLayerState` holds a reference to the **same** object so
263 a single ``.fill_()`` updates all layers at once. Allocated on the
256 - first call to :meth:`_init_should_record_tensor`.
264 + first call to :meth:`_propagate_shared_tensors`.
265 """
266 self.is_async: bool = False
267 """
@@ -440,12 +448,19 @@ class EplbState:
448 self.policy = EPLB_POLICIES[policy_type]
449 logger.debug("Selected EPLB policy: %s", policy_type)
450
451 + # num_ubatches is 0 when DBO is disabled.
452 + num_ubatches = max(1, self.parallel_config.num_ubatches)
453 + num_unpadded_tokens_tensors = [
454 + torch.tensor(0, dtype=torch.int32, device=self.device)
455 + for _ in range(num_ubatches)
456 + ]
457 +
458 model.set_eplb_state(
459 expert_load_pass,
460 logical_to_physical_map,
461 logical_replica_count,
462 )
448 - self._init_should_record_tensor(model)
463 + self._propagate_shared_tensors(model, num_unpadded_tokens_tensors)
464 expert_buffer = [torch.empty_like(w) for w in model.expert_weights[0]]
465
466 assert self.parallel_config.eplb_config.communicator is not None, (
@@ -471,10 +486,43 @@ class EplbState:
486 eplb_stats=None,
487 cuda_device_index=self.cuda_device_index,
488 communicator=communicator,
489 + num_unpadded_tokens_tensors=num_unpadded_tokens_tensors,
490 )
491 self.model_states[model_config.compute_hash()] = model_state
492 self.num_valid_physical_experts = model.num_physical_experts
493
494 + def prepare_forward(
495 + self,
496 + model_config: ModelConfig,
497 + num_unpadded_tokens: int,
498 + ubatch_slices: list | None = None,
499 + ) -> None:
500 + """Fill the per-[u]batch ``num_unpadded_tokens`` tensors before a
501 + forward pass.
502 +
503 + Args:
504 + model_config: Identifies which ``EplbModelState`` to update.
505 + num_unpadded_tokens: Total number of real (non-padding) tokens
506 + in the batch.
507 + ubatch_slices: When DBO is active, a list of
508 + ``UBatchSlice`` objects describing each micro-batch's
509 + token range. When ``None``, only ``tensors[0]`` is filled.
510 + """
511 + model_state = self.model_states.get(compute_hash_cached(model_config))
512 + if model_state is None or model_state.num_unpadded_tokens_tensors is None:
513 + return
514 + tensors = model_state.num_unpadded_tokens_tensors
515 + if ubatch_slices is None:
516 + tensors[0].fill_(num_unpadded_tokens)
517 + else:
518 + for i, ubatch_slice in enumerate(ubatch_slices):
519 + ts = ubatch_slice.token_slice
520 + # Real tokens in this ubatch: clamp the global count into
521 + # the slice range so partially-filled ubatches get the
522 + # correct count.
523 + val = max(0, min(num_unpadded_tokens, ts.stop) - ts.start)
524 + tensors[i].fill_(val)
525 +
526 def step(
527 self,
528 is_dummy: bool = False,
@@ -638,11 +686,20 @@ class EplbState:
686 self._should_record_current_step(log_stats=log_stats)
687 )
688
641 - def _init_should_record_tensor(self, model: "MixtureOfExperts") -> None: # type: ignore[name-defined]
642 - """Allocate (once) and propagate the shared ``should_record_tensor``.
689 + def _propagate_shared_tensors(
690 + self,
691 + model: "MixtureOfExperts", # type: ignore[name-defined]
692 + num_unpadded_tokens_tensors: list[torch.Tensor],
693 + ) -> None:
694 + """Propagate shared tensors to every :class:`EplbLayerState`.
695 +
696 + Allocates ``should_record_tensor`` on the first call and then
697 + assigns both it and ``num_unpadded_tokens_tensors`` to every
698 + MoE layer's :class:`EplbLayerState`. All layers reference the
699 + **same** objects so a single update is visible everywhere.
700
701 Must be called after :meth:`model.set_eplb_state` so that each
645 - layer's ``eplb_state`` is already populated with the tensor views.
702 + layer's ``eplb_state`` is already populated.
703 """
704 layer_states = [
705 layer.eplb_state
@@ -659,6 +716,7 @@ class EplbState:
716 for ls in layer_states:
717 if ls is not None:
718 ls.should_record_tensor = self.should_record_tensor
719 + ls.num_unpadded_tokens_tensors = num_unpadded_tokens_tensors
720
721 def rearrange(
722 self,
@@ -985,6 +1043,11 @@ class EplbLayerState:
1043 sliding window before the next rearrangement, so recording them wastes
1044 GPU work.
1045 """
1046 + num_unpadded_tokens_tensors: list[torch.Tensor] | None = None
1047 + """
1048 + Reference to the parent :class:`EplbModelState`'s tensor list so the
1049 + router can read the correct per-[u]batch unpadded token count.
1050 + """
1051
1052 def set_layer_state(
1053 self,
vllm/model_executor/layers/fused_moe/router/base_router.py
+35 -8
@@ -11,6 +11,7 @@ from vllm.model_executor.layers.fused_moe.router.fused_moe_router import (
11 )
12 from vllm.platforms import current_platform
13 from vllm.triton_utils import tl, triton
14 +from vllm.v1.worker.ubatching import dbo_current_ubatch_id
15
16 if current_platform.is_cuda_alike():
17
@@ -22,11 +23,13 @@ if current_platform.is_cuda_alike():
23 out_ids_ptr,
24 out_ptr,
25 record_enabled_ptr,
26 + num_unpadded_tokens_ptr,
27 num_logical_experts,
28 map_slots,
29 out_size,
30 numel,
31 num_active_experts,
32 + HAS_NUM_UNPADDED: tl.constexpr,
33 BLOCK_SIZE: tl.constexpr,
34 ):
35 pid = tl.program_id(0)
@@ -50,6 +53,13 @@ if current_platform.is_cuda_alike():
53 token_idx = (offs // num_active_experts).to(tl.int64)
54 hashed = (token_idx * KNUTH_MULTIPLIER) & 0xFFFFFFFF
55 replica_idx = hashed % replica_count
56 + map_index = safe_expert_id * map_slots + replica_idx
57 + physical_id = tl.load(
58 + logical_to_physical_ptr + map_index,
59 + mask=mask & valid_expert,
60 + other=-1,
61 + )
62 + tl.store(out_ids_ptr + offs, physical_id, mask=mask)
63
64 # 2. Record expert load metrics.
65
@@ -64,16 +74,21 @@ if current_platform.is_cuda_alike():
74 # If later refactor moved all the MoE kernel calls
75 # to the modular kernel, we can move this logic there
76 # to achieve better efficiency.
67 - map_index = safe_expert_id * map_slots + replica_idx
68 - physical_id = tl.load(
69 - logical_to_physical_ptr + map_index,
70 - mask=mask & valid_expert,
71 - other=-1,
72 - )
73 - tl.store(out_ids_ptr + offs, physical_id, mask=mask)
77
78 record_enabled = tl.load(record_enabled_ptr) != 0
76 - valid = mask & record_enabled & (physical_id >= 0) & (physical_id < out_size)
79 + # Skip padded tokens when recording.
80 + if HAS_NUM_UNPADDED:
81 + num_unpadded_tokens = tl.load(num_unpadded_tokens_ptr)
82 + is_unpadded = offs < num_unpadded_tokens * num_active_experts
83 + else:
84 + is_unpadded = True
85 + valid = (
86 + mask
87 + & record_enabled
88 + & is_unpadded
89 + & (physical_id >= 0)
90 + & (physical_id < out_size)
91 + )
92 safe_physical_id = tl.where(physical_id >= 0, physical_id, 0)
93 tl.atomic_add(out_ptr + safe_physical_id, 1, mask=valid)
94
@@ -83,6 +98,7 @@ if current_platform.is_cuda_alike():
98 logical_replica_count: torch.Tensor,
99 expert_load_view: torch.Tensor,
100 record_enabled: torch.Tensor,
101 + num_unpadded_tokens: torch.Tensor | None,
102 ) -> torch.Tensor:
103 topk_ids_in = topk_ids.contiguous().to(dtype=torch.int32)
104 numel = topk_ids_in.numel()
@@ -99,11 +115,13 @@ if current_platform.is_cuda_alike():
115 out_flat,
116 expert_load_view,
117 record_enabled,
118 + num_unpadded_tokens,
119 logical_replica_count.shape[0],
120 logical_to_physical_map.shape[1],
121 expert_load_view.shape[0],
122 numel,
123 num_active_experts,
124 + HAS_NUM_UNPADDED=num_unpadded_tokens is not None,
125 BLOCK_SIZE=256,
126 )
127 return out_flat.reshape(topk_ids.shape)
@@ -114,6 +132,7 @@ if current_platform.is_cuda_alike():
132 logical_to_physical_map: torch.Tensor,
133 logical_replica_count: torch.Tensor,
134 record_enabled: torch.Tensor,
135 + num_unpadded_tokens: torch.Tensor | None = None,
136 ) -> torch.Tensor:
137 # Fused triton implementation: mapping + optional recording in one kernel.
138 return _eplb_map_and_record_triton(
@@ -122,6 +141,7 @@ if current_platform.is_cuda_alike():
141 logical_replica_count=logical_replica_count,
142 expert_load_view=expert_load_view,
143 record_enabled=record_enabled,
144 + num_unpadded_tokens=num_unpadded_tokens,
145 )
146 else:
147
@@ -131,6 +151,7 @@ else:
151 logical_to_physical_map: torch.Tensor,
152 logical_replica_count: torch.Tensor,
153 record_enabled: torch.Tensor,
154 + num_unpadded_tokens: torch.Tensor | None = None,
155 ) -> torch.Tensor:
156 return topk_ids
157
@@ -177,6 +198,8 @@ class BaseRouter(FusedMoERouter):
198 raise ValueError("EPLB requires logical_replica_count != None")
199 if eplb_state.should_record_tensor is None:
200 raise ValueError("EPLB requires should_record_tensor != None")
201 + if eplb_state.num_unpadded_tokens_tensors is None:
202 + raise ValueError("EPLB requires num_unpadded_tokens_tensors != None")
203
204 def _apply_eplb_mapping(self, topk_ids: torch.Tensor) -> torch.Tensor:
205 """Apply EPLB mapping to convert logical expert IDs to physical expert IDs."""
@@ -186,12 +209,16 @@ class BaseRouter(FusedMoERouter):
209 assert eplb_state.logical_to_physical_map is not None
210 assert eplb_state.logical_replica_count is not None
211 assert eplb_state.should_record_tensor is not None
212 + assert eplb_state.num_unpadded_tokens_tensors is not None
213 return eplb_map_to_physical_and_record(
214 topk_ids=topk_ids,
215 logical_to_physical_map=eplb_state.logical_to_physical_map,
216 logical_replica_count=eplb_state.logical_replica_count,
217 expert_load_view=eplb_state.expert_load_view,
218 record_enabled=eplb_state.should_record_tensor,
219 + num_unpadded_tokens=eplb_state.num_unpadded_tokens_tensors[
220 + dbo_current_ubatch_id()
221 + ],
222 )
223 return topk_ids
224
vllm/models/deepseek_v4/nvidia/model.py
+6
@@ -70,6 +70,7 @@ from vllm.platforms import current_platform
70 from vllm.sequence import IntermediateTensors
71 from vllm.utils.math_utils import cdiv
72 from vllm.v1.attention.backends.registry import AttentionBackendEnum
73 +from vllm.v1.worker.ubatching import dbo_current_ubatch_id
74
75
76 class DeepseekV4MLP(nn.Module):
@@ -464,6 +465,11 @@ class DeepseekV4MegaMoEExperts(nn.Module):
465 logical_to_physical_map=eplb_state.logical_to_physical_map,
466 logical_replica_count=eplb_state.logical_replica_count,
467 record_enabled=eplb_state.should_record_tensor,
468 + num_unpadded_tokens=eplb_state.num_unpadded_tokens_tensors[
469 + dbo_current_ubatch_id()
470 + ]
471 + if eplb_state.num_unpadded_tokens_tensors is not None
472 + else None,
473 )
474
475 prepare_megamoe_inputs(
vllm/v1/spec_decode/extract_hidden_states.py
+13
@@ -9,6 +9,7 @@ import torch
9 import torch.nn as nn
10
11 from vllm.config import CUDAGraphMode, VllmConfig, get_layers_from_vllm_config
12 +from vllm.distributed.eplb.eplb_state import EplbState
13 from vllm.forward_context import set_forward_context
14 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
15 from vllm.model_executor.model_loader import get_model
@@ -43,6 +44,8 @@ class ExtractHiddenStatesProposer:
44 self.dtype = vllm_config.model_config.dtype
45 self.dp_rank = vllm_config.parallel_config.data_parallel_rank
46
47 + self.eplb_state: EplbState | None = None
48 +
49 # Model and attention layer tracking (initialized in load_model)
50 self.model: nn.Module | None = None
51 self.attn_layer_names: list[str] = []
@@ -83,6 +86,10 @@ class ExtractHiddenStatesProposer:
86 self.max_num_tokens, dtype=torch.int64, device=device
87 )
88
89 + def set_eplb_state(self, eplb_state: EplbState) -> None:
90 + """Inject EPLB state after construction."""
91 + self.eplb_state = eplb_state
92 +
93 def propose(
94 self,
95 num_speculative_tokens: int,
@@ -145,6 +152,12 @@ class ExtractHiddenStatesProposer:
152 if num_tokens_across_dp is not None:
153 num_tokens_across_dp[self.dp_rank] = num_input_tokens
154
155 + if self.eplb_state is not None:
156 + assert self.vllm_config.speculative_config is not None
157 + self.eplb_state.prepare_forward(
158 + self.vllm_config.speculative_config.draft_model_config,
159 + num_tokens,
160 + )
161 with set_forward_context(
162 per_layer_attn_metadata,
163 self.vllm_config,
vllm/v1/spec_decode/llm_base_proposer.py
+18
@@ -15,6 +15,7 @@ from vllm.config import (
15 get_layers_from_vllm_config,
16 replace,
17 )
18 +from vllm.distributed.eplb.eplb_state import EplbState
19 from vllm.distributed.parallel_state import get_pp_group
20 from vllm.forward_context import set_forward_context
21 from vllm.logger import init_logger
@@ -79,6 +80,7 @@ class SpecDecodeBaseProposer:
80 self.dtype = vllm_config.model_config.dtype
81 self.max_model_len = vllm_config.model_config.max_model_len
82 self.dp_rank = vllm_config.parallel_config.data_parallel_rank
83 + self.eplb_state: EplbState | None = None
84 self.num_speculative_tokens = self.speculative_config.num_speculative_tokens
85
86 # We need to get the hidden size from the draft model config because
@@ -328,6 +330,10 @@ class SpecDecodeBaseProposer:
330 "does not support M-RoPE yet"
331 )
332
333 + def set_eplb_state(self, eplb_state: EplbState) -> None:
334 + """Inject EPLB state after construction."""
335 + self.eplb_state = eplb_state
336 +
337 def _init_parallel_drafting_params(self):
338 # For parallel drafting, we need the token ID to use for masked slots
339 # And for EAGLE + parallel drafting, we need the hidden state tensor to use
@@ -527,6 +533,12 @@ class SpecDecodeBaseProposer:
533 if self._share_mtp_indices and hasattr(self.model.model, "set_skip_topk"):
534 self.model.model.set_skip_topk(False)
535
536 + if self.eplb_state is not None:
537 + self.eplb_state.prepare_forward(
538 + self.draft_model_config,
539 + num_tokens,
540 + )
541 +
542 with set_forward_context(
543 per_layer_attn_metadata,
544 self.vllm_config,
@@ -672,6 +684,12 @@ class SpecDecodeBaseProposer:
684 if self.pass_hidden_states_to_model:
685 model_kwargs["hidden_states"] = self.hidden_states[:input_batch_size]
686
687 + if self.eplb_state is not None:
688 + self.eplb_state.prepare_forward(
689 + self.draft_model_config,
690 + batch_size,
691 + )
692 +
693 with set_forward_context(
694 per_layer_attn_metadata,
695 self.vllm_config,
vllm/v1/worker/gpu/eplb_utils.py
+12
@@ -8,6 +8,7 @@ from typing import Any
8 import torch
9 import torch.nn as nn
10
11 +from vllm.config import ModelConfig
12 from vllm.distributed.eplb.eplb_state import EplbState
13 from vllm.logger import init_logger
14 from vllm.model_executor.models.interfaces import (
@@ -90,6 +91,7 @@ class EPLBController:
91 draft_model,
92 speculative_config.draft_model_config,
93 )
94 + speculator.set_eplb_state(self.state)
95 self._has_registered_models = True
96 return True
97
@@ -135,6 +137,16 @@ class EPLBController:
137 log_stats=self.parallel_config.eplb_config.log_balancedness,
138 )
139
140 + def prepare_forward(
141 + self,
142 + model_config: ModelConfig,
143 + num_unpadded_tokens: int,
144 + ubatch_slices: list | None = None,
145 + ) -> None:
146 + if self.state is None or not self.parallel_config.enable_eplb:
147 + return
148 + self.state.prepare_forward(model_config, num_unpadded_tokens, ubatch_slices)
149 +
150 def setup_from_mapping(
151 self,
152 model: nn.Module,
vllm/v1/worker/gpu/model_runner.py
+3
@@ -1284,6 +1284,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1284 model_inputs["intermediate_tensors"] = IntermediateTensors(new_tensors)
1285 del intermediate_tensors
1286
1287 + # Update the EPLB meta.
1288 + self.eplb.prepare_forward(self.model_config, input_batch.num_tokens)
1289 +
1290 # Run model.
1291 if batch_desc.cg_mode == CUDAGraphMode.FULL:
1292 # Use explicit cudagraph replay for FULL mode.
vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py
+4
@@ -213,6 +213,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
213 need_eager=is_profile,
214 )
215
216 + self._prepare_eplb_forward(input_batch.num_tokens)
217 +
218 if prefill_batch_desc.cg_mode == CUDAGraphMode.FULL:
219 # Replay the full graph for draft prefill.
220 assert self.prefill_cudagraph_manager is not None
@@ -424,6 +426,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
426 num_tokens_across_dp: torch.Tensor | None,
427 cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE,
428 ) -> None:
429 + self._prepare_eplb_forward(num_reqs)
430 +
431 idx_mapping = self.idx_mapping[:num_reqs]
432 positions = self.input_buffers.positions[:num_reqs]
433 # Run the draft model forward pass.
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+7
@@ -278,6 +278,9 @@ class DFlashSpeculator(DraftModelSpeculator):
278 self.hidden_states[:num_target_tokens],
279 self.context_positions[:num_target_tokens],
280 )
281 + # DFlash processes all speculative tokens in one forward pass,
282 + # so the real token count is num_query_tokens.
283 + self._prepare_eplb_forward(num_query_tokens)
284 self._generate_draft(
285 num_reqs,
286 num_query_tokens,
@@ -354,6 +357,10 @@ class DFlashSpeculator(DraftModelSpeculator):
357 self.kv_cache_config,
358 )
359
360 + # DFlash processes all speculative tokens in one forward pass,
361 + # so the real token count is num_query_tokens.
362 + self._prepare_eplb_forward(num_query_tokens)
363 +
364 if batch_desc.cg_mode == CUDAGraphMode.FULL:
365 assert self.query_cudagraph_manager is not None
366 self.query_cudagraph_manager.run_fullgraph(batch_desc)
vllm/v1/worker/gpu/spec_decode/speculator.py
+15
@@ -8,6 +8,7 @@ import torch.nn as nn
8
9 from vllm.config import VllmConfig, get_layers_from_vllm_config
10 from vllm.config.compilation import CUDAGraphMode
11 +from vllm.distributed.eplb.eplb_state import EplbState
12 from vllm.logger import init_logger
13 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
14 from vllm.v1.kv_cache_interface import KVCacheConfig
@@ -106,6 +107,8 @@ class DraftModelSpeculator(BaseSpeculator):
107 self.dp_size = vllm_config.parallel_config.data_parallel_size
108 self.dp_rank = vllm_config.parallel_config.data_parallel_rank
109
110 + self.eplb_state: EplbState | None = None
111 +
112 self.input_buffers = InputBuffers(
113 max_num_reqs=self.max_num_reqs,
114 max_num_tokens=self.max_num_tokens,
@@ -165,6 +168,18 @@ class DraftModelSpeculator(BaseSpeculator):
168 )
169 self.draft_attn_layer_names = all_attn_layers - target_attn_layer_names
170
171 + def set_eplb_state(self, eplb_state: EplbState) -> None:
172 + """Inject EPLB state after construction."""
173 + self.eplb_state = eplb_state
174 +
175 + def _prepare_eplb_forward(self, num_unpadded_tokens: int) -> None:
176 + """Call EPLB prepare_forward if EPLB is active for the draft model."""
177 + if self.eplb_state is not None:
178 + self.eplb_state.prepare_forward(
179 + self.speculative_config.draft_model_config,
180 + num_unpadded_tokens,
181 + )
182 +
183 def set_attn(
184 self,
185 model_state: ModelState,
vllm/v1/worker/gpu_model_runner.py
+9
@@ -4319,6 +4319,13 @@ class GPUModelRunner(
4319 # When spec decode is enabled, defer connector finalization
4320 # (wait_for_save + clear metadata) until after draft model runs.
4321 defer_kv_connector_finalize = self.speculative_config is not None
4322 + # Update the EPLB meta.
4323 + if self.eplb_state is not None:
4324 + self.eplb_state.prepare_forward(
4325 + self.model_config,
4326 + num_tokens_unpadded,
4327 + ubatch_slices_padded,
4328 + )
4329 with (
4330 set_forward_context(
4331 attn_metadata,
@@ -5215,6 +5222,8 @@ class GPUModelRunner(
5222 self.drafter.model,
5223 spec_config.draft_model_config,
5224 )
5225 + assert hasattr(self.drafter, "set_eplb_state")
5226 + self.drafter.set_eplb_state(self.eplb_state)
5227 eplb_models += 1
5228
5229 self._setup_eagle3_aux_hidden_state_outputs()