[Core] Fix internal LB load-balancing (#49204)

Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Nick Hill committed Jul 27, 2026 at 07:00 UTC 59a6b0411d1817c712728a17fa8c2e0cdb4cf1f9
7 files changed +188 -59
rust/src/engine-core-client/src/client/state.rs
+4 -8
@@ -74,17 +74,13 @@ impl EngineRoutingState {
74 ///
75 /// Scheduler stats can raise the load estimate above the frontend-local
76 /// view, but they should not lower it below requests this frontend has
77 - /// already admitted. Waiting requests still get the same extra penalty
78 - /// as the original `waiting * 4 + running` score.
77 + /// already admitted.
78 fn routing_score(&self) -> usize {
80 - const WAITING_WEIGHT: usize = 4;
81 -
79 let Some(stats) = self.last_scheduler_stats else {
80 return self.inflight;
81 };
82
86 - let scheduler_total = stats.running + stats.waiting;
87 - self.inflight.max(scheduler_total) + stats.waiting * (WAITING_WEIGHT - 1)
83 + self.inflight.max(stats.running + stats.waiting)
84 }
85
86 /// Replace the local routing view with a fresh real scheduler snapshot.
@@ -750,7 +746,7 @@ mod tests {
746 }
747
748 #[test]
753 - fn routing_score_keeps_extra_waiting_penalty() {
749 + fn routing_score_counts_waiting_without_extra_penalty() {
750 let state = EngineRoutingState {
751 inflight: 1,
752 last_scheduler_stats: Some(EngineLoadSnapshot {
@@ -759,7 +755,7 @@ mod tests {
755 }),
756 };
757
762 - assert_eq!(state.routing_score(), 14);
758 + assert_eq!(state.routing_score(), 5);
759 }
760
761 #[test]
tests/v1/engine/test_engine_core_client.py
+82 -11
@@ -8,6 +8,7 @@ import os
8 import signal
9 import time
10 import uuid
11 +from collections import Counter
12 from concurrent.futures import Future
13 from dataclasses import dataclass
14 from threading import Thread
@@ -27,7 +28,11 @@ from vllm.platforms import current_platform
28 from vllm.pooling_params import LateInteractionParams, PoolingParams
29 from vllm.usage.usage_lib import UsageContext
30 from vllm.utils.torch_utils import set_default_torch_num_threads
30 -from vllm.v1.engine import EngineCoreReadyResponse, EngineCoreRequest
31 +from vllm.v1.engine import (
32 + EngineCoreOutputs,
33 + EngineCoreReadyResponse,
34 + EngineCoreRequest,
35 +)
36 from vllm.v1.engine.core import EngineCore
37 from vllm.v1.engine.core_client import (
38 AsyncMPClient,
@@ -198,13 +203,19 @@ def _make_pooling_request(
203 )
204
205
201 -def test_dplb_late_interaction_sticky_routing():
206 +def _make_dplb_client(num_engines: int = 3, client_count: int = 1) -> DPLBAsyncMPClient:
207 client = object.__new__(DPLBAsyncMPClient)
203 - client.client_count = 1
208 + client.client_count = client_count
209 client.reqs_in_flight = {}
205 - client.core_engines = [b"\x00\x00", b"\x01\x00", b"\x02\x00"]
206 - client.lb_engines = [[0, 0], [0, 0], [0, 0]]
210 + client.engine_inflight = Counter()
211 + client.core_engines = [bytes([i, 0]) for i in range(num_engines)]
212 + client.lb_engines = [[0, 0, 0.0] for _ in range(num_engines)]
213 client.eng_start_index = 0
214 + return client
215 +
216 +
217 +def test_dplb_late_interaction_sticky_routing():
218 + client = _make_dplb_client()
219
220 query_key = "rerank-abc-query-0"
221 query_request = _make_pooling_request(
@@ -223,12 +234,8 @@ def test_dplb_late_interaction_sticky_routing():
234
235
236 def test_dplb_non_late_interaction_still_uses_lb():
226 - client = object.__new__(DPLBAsyncMPClient)
227 - client.client_count = 1
228 - client.reqs_in_flight = {}
229 - client.core_engines = [b"\x00\x00", b"\x01\x00", b"\x02\x00"]
230 - client.lb_engines = [[2, 1], [0, 0], [1, 0]]
231 - client.eng_start_index = 0
237 + client = _make_dplb_client()
238 + client.lb_engines = [[2, 1, 0.0], [0, 0, 0.0], [1, 0, 0.0]]
239
240 request = make_request(SamplingParams(max_tokens=1))
241 chosen_engine = client.get_core_engine_for_request(request)
@@ -237,6 +244,70 @@ def test_dplb_non_late_interaction_still_uses_lb():
244 assert client.lb_engines[1][0] == 1
245
246
247 +def test_dplb_burst_round_robins_despite_snapshot_rebinds():
248 + """A stats snapshot rebind wipes the optimistic lb_engines increments;
249 + the exact in-flight floor must keep a burst spreading round-robin."""
250 + client = _make_dplb_client(num_engines=4)
251 +
252 + for _ in range(4):
253 + client.get_core_engine_for_request(make_request(SamplingParams(max_tokens=1)))
254 + # Coordinator snapshot arrives, not yet reflecting the 4 routed requests.
255 + client.lb_engines = [[0, 0, 0.0] for _ in range(4)]
256 + for _ in range(4):
257 + client.get_core_engine_for_request(make_request(SamplingParams(max_tokens=1)))
258 +
259 + assert sorted(client.engine_inflight.values()) == [2, 2, 2, 2]
260 +
261 +
262 +def test_dplb_snapshot_backpressure_overrides_inflight():
263 + """An engine reported heavily loaded by the coordinator is avoided even
264 + when this client has routed nothing to it."""
265 + client = _make_dplb_client(num_engines=2)
266 + client.lb_engines = [[5, 10, 0.0], [0, 0, 0.0]]
267 +
268 + chosen = client.get_core_engine_for_request(
269 + make_request(SamplingParams(max_tokens=1))
270 + )
271 +
272 + assert chosen == client.core_engines[1]
273 +
274 +
275 +def test_dplb_kv_pressure_amplifies_waiting_penalty():
276 + """A waiting queue on a KV-bound engine (slow drain) is penalized, while
277 + the same queue with low KV usage is not (e.g. transient burst)."""
278 + client = _make_dplb_client(num_engines=2)
279 + # Engine 0 has a smaller total but is KV-bound with a queue.
280 + client.lb_engines = [[5, 10, 1.0], [0, 20, 0.2]]
281 +
282 + chosen = client.get_core_engine_for_request(
283 + make_request(SamplingParams(max_tokens=1))
284 + )
285 + assert chosen == client.core_engines[1]
286 +
287 + # Same counts without KV pressure: the smaller total wins.
288 + client = _make_dplb_client(num_engines=2)
289 + client.lb_engines = [[5, 10, 0.2], [0, 20, 0.2]]
290 +
291 + chosen = client.get_core_engine_for_request(
292 + make_request(SamplingParams(max_tokens=1))
293 + )
294 + assert chosen == client.core_engines[0]
295 +
296 +
297 +def test_dplb_finished_requests_release_inflight():
298 + client = _make_dplb_client(num_engines=2)
299 +
300 + req = make_request(SamplingParams(max_tokens=1))
301 + engine = client.get_core_engine_for_request(req)
302 + assert client.engine_inflight[engine] == 1
303 +
304 + outputs = EngineCoreOutputs(finished_requests={req.request_id})
305 + asyncio.run(DPLBAsyncMPClient.process_engine_outputs(client, outputs))
306 +
307 + assert client.engine_inflight[engine] == 0
308 + assert req.request_id not in client.reqs_in_flight
309 +
310 +
311 def test_apply_ready_response_syncs_block_size():
312 import msgspec
313
vllm/v1/core/sched/interface.py
+4
@@ -234,6 +234,10 @@ class SchedulerInterface(ABC):
234 """Returns (num_running_reqs, num_waiting_reqs)."""
235 raise NotImplementedError
236
237 + def get_kv_cache_usage(self) -> float:
238 + """Returns the fraction of the KV cache currently in use (0.0-1.0)."""
239 + return 0.0
240 +
241 @abstractmethod
242 def make_stats(self) -> "SchedulerStats | None":
243 """Make a SchedulerStats object for logging.
vllm/v1/core/sched/scheduler.py
+4
@@ -2154,6 +2154,10 @@ class Scheduler(SchedulerInterface):
2154 """Returns (num_running_reqs, num_waiting_reqs)."""
2155 return len(self.running), len(self.waiting) + len(self.skipped_waiting)
2156
2157 + def get_kv_cache_usage(self) -> float:
2158 + """Returns the fraction of the KV cache currently in use (0.0-1.0)."""
2159 + return self.kv_cache_manager.usage
2160 +
2161 def add_request(self, request: Request) -> None:
2162 existing = self.requests.get(request.request_id)
2163 if existing is not None:
vllm/v1/engine/coordinator.py
+40 -29
@@ -139,7 +139,8 @@ class DPCoordinator:
139
140 class EngineState:
141 def __init__(self):
142 - self.request_counts = [0, 0] # [waiting, running]
142 + # [waiting, running, kv_cache_usage]
143 + self.request_counts: list[int | float] = [0, 0, 0.0]
144
145
146 class DPCoordinatorProc:
@@ -202,7 +203,7 @@ class DPCoordinatorProc:
203 stats_changed = False
204 last_stats_step = -1
205 last_stats_wave = -1
205 - last_step_counts: list[list[int]] | None = None
206 + last_step_counts: list[list[int | float]] | None = None
207
208 with (
209 make_zmq_socket(
@@ -260,8 +261,12 @@ class DPCoordinatorProc:
261 wait_for = self.stats_update_interval_ms if stats_changed else 5000
262
263 # Wait at least 50ms to ensure we've received all stats for
263 - # the current step.
264 - min_timeout = 50 if last_step_counts is None else 0
264 + # the current step. Only applicable to lockstep (MoE) DP;
265 + # non-lockstep engines have no synchronized step boundaries.
266 + if self.enable_wave_coordination and last_step_counts is None:
267 + min_timeout = 50
268 + else:
269 + min_timeout = 0
270
271 events = poller.poll(timeout=max(min_timeout, wait_for - elapsed))
272 if not events:
@@ -374,32 +379,38 @@ class DPCoordinatorProc:
379 # 1. Updated request load stats - update our local
380 # state with these.
381 stats = self.engines[eng_index].request_counts
377 - stats_step = scheduler_stats.step_counter
378 - stats_wave = scheduler_stats.current_wave
379 - if (
380 - stats_wave > last_stats_wave
381 - or stats_wave == last_stats_wave
382 - and stats_step > last_stats_step
383 - ):
384 - if stats_changed:
385 - last_step_counts = self._get_engine_counts(do_copy=True)
386 - last_stats_step = stats_step
387 - last_stats_wave = stats_wave
388 - elif stats_wave != last_stats_wave or (
389 - stats_step != last_stats_step
390 - ):
391 - logger.warning(
392 - "Received stats for out-of-order "
393 - "step (%d, %d) from engine %d (expected "
394 - "> (%d, %d))",
395 - stats_wave,
396 - stats_step,
397 - eng_index,
398 - last_stats_wave,
399 - last_stats_step,
400 - )
382 + if self.enable_wave_coordination:
383 + # Steps are synchronized across lockstep (MoE) DP
384 + # ranks; snapshot counts at step boundaries.
385 + stats_step = scheduler_stats.step_counter
386 + stats_wave = scheduler_stats.current_wave
387 + if (
388 + stats_wave > last_stats_wave
389 + or stats_wave == last_stats_wave
390 + and stats_step > last_stats_step
391 + ):
392 + if stats_changed:
393 + last_step_counts = self._get_engine_counts(
394 + do_copy=True
395 + )
396 + last_stats_step = stats_step
397 + last_stats_wave = stats_wave
398 + elif stats_wave != last_stats_wave or (
399 + stats_step != last_stats_step
400 + ):
401 + logger.warning(
402 + "Received stats for out-of-order "
403 + "step (%d, %d) from engine %d (expected "
404 + "> (%d, %d))",
405 + stats_wave,
406 + stats_step,
407 + eng_index,
408 + last_stats_wave,
409 + last_stats_step,
410 + )
411 stats[0] = scheduler_stats.num_waiting_reqs
412 stats[1] = scheduler_stats.num_running_reqs
413 + stats[2] = scheduler_stats.kv_cache_usage
414 stats_changed = True
415
416 # Wave coordination: handle wave completion and start notifications
@@ -452,7 +463,7 @@ class DPCoordinatorProc:
463 wave_encoded = msgspec.msgpack.encode((wave, exclude_engine_index))
464 socket.send_multipart((EngineCoreRequestType.START_DP_WAVE.value, wave_encoded))
465
455 - def _get_engine_counts(self, do_copy=False) -> list[list[int]]:
466 + def _get_engine_counts(self, do_copy=False) -> list[list[int | float]]:
467 """Return list of [waiting, running] count lists for each engine."""
468 if do_copy:
469 return [copy.copy(e.request_counts) for e in self.engines]
vllm/v1/engine/core.py
+23 -3
@@ -1057,6 +1057,7 @@ class EngineCoreProc(EngineCore):
1057 # Only publish request queue stats to coordinator for "internal"
1058 # and "hybrid" LB modes.
1059 self.publish_dp_lb_stats = internal_dp_balancing
1060 + self.last_counts = (0, 0)
1061
1062 self.addresses = addresses
1063 self.process_input_queue_block = True
@@ -1376,11 +1377,27 @@ class EngineCoreProc(EngineCore):
1377 while self._handle_shutdown():
1378 # 1) Poll the input queue until there is work to do.
1379 self._process_input_queue()
1380 + # Publish request counts before and after GPU step to ensure freshness.
1381 + self._maybe_publish_request_counts()
1382 # 2) Step the engine core and return the outputs.
1383 self._process_engine_step()
1384 + self._maybe_publish_request_counts()
1385
1386 raise SystemExit
1387
1388 + def _maybe_publish_request_counts(self):
1389 + if not self.publish_dp_lb_stats:
1390 + return
1391 +
1392 + # Publish our request counts (if they've changed).
1393 + counts = self.scheduler.get_request_counts()
1394 + if counts != self.last_counts:
1395 + self.last_counts = counts
1396 + stats = SchedulerStats(
1397 + *counts, kv_cache_usage=self.scheduler.get_kv_cache_usage()
1398 + )
1399 + self.output_queue.put_nowait((-1, EngineCoreOutputs(scheduler_stats=stats)))
1400 +
1401 def _process_input_queue(self):
1402 """Exits when an engine step needs to be performed."""
1403
@@ -1890,7 +1907,6 @@ class DPEngineCoreProc(EngineCoreProc):
1907 # finished with DP peers every N steps.
1908 self.step_counter = 0
1909 self.current_wave = 0
1893 - self.last_counts = (0, 0)
1910
1911 # Two-phase pause protocol state. When pending_pause is True, the
1912 # engine keeps stepping (dummy batches) while waiting for all DP
@@ -2027,12 +2043,16 @@ class DPEngineCoreProc(EngineCoreProc):
2043 if not self.publish_dp_lb_stats:
2044 return
2045
2030 - # Publish our request counts (if they've changed).
2046 + # Publish our request counts (if they've changed), stamped with the
2047 + # lockstep-synchronized step counter and wave number.
2048 counts = self.scheduler.get_request_counts()
2049 if counts != self.last_counts:
2050 self.last_counts = counts
2051 stats = SchedulerStats(
2035 - *counts, step_counter=self.step_counter, current_wave=self.current_wave
2052 + *counts,
2053 + kv_cache_usage=self.scheduler.get_kv_cache_usage(),
2054 + step_counter=self.step_counter,
2055 + current_wave=self.current_wave,
2056 )
2057 self.output_queue.put_nowait((-1, EngineCoreOutputs(scheduler_stats=stats)))
2058
vllm/v1/engine/core_client.py
+31 -8
@@ -7,7 +7,7 @@ import sys
7 import uuid
8 import weakref
9 from abc import ABC, abstractmethod
10 -from collections import defaultdict, deque
10 +from collections import Counter, defaultdict, deque
11 from collections.abc import Awaitable, Callable, Sequence
12 from concurrent.futures import Future
13 from dataclasses import dataclass
@@ -1271,9 +1271,11 @@ class DPAsyncMPClient(AsyncMPClient):
1271 client_index,
1272 )
1273
1274 - # List of [waiting, running] pair per engine.
1274 + # List of [waiting, running, kv_cache_usage] per engine.
1275 # Used only by DPLBAsyncMPClient subclass.
1276 - self.lb_engines: list[list[int]] = [[0, 0] for _ in self.core_engines]
1276 + self.lb_engines: list[list[int | float]] = [
1277 + [0, 0, 0.0] for _ in self.core_engines
1278 + ]
1279
1280 self.eep_scaling_cache: ElasticScalingCache | None = None
1281
@@ -1351,7 +1353,7 @@ class DPAsyncMPClient(AsyncMPClient):
1353 )
1354 if len(self.lb_engines) < new_engine_count:
1355 self.lb_engines = self.lb_engines + [
1354 - [0, 0]
1356 + [0, 0, 0.0]
1357 for _ in range(
1358 new_engine_count - len(self.lb_engines)
1359 )
@@ -1445,6 +1447,9 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1447 # To route aborts to the correct engine.
1448 self.reqs_in_flight: dict[str, EngineIdentity] = {}
1449
1450 + # Exact per-engine count of this client's unfinished requests.
1451 + self.engine_inflight: Counter[EngineIdentity] = Counter()
1452 +
1453 super().__init__(
1454 vllm_config,
1455 executor_class,
@@ -1470,14 +1475,30 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1475 current_counts = self.lb_engines
1476 # TODO use P2C alg for larger DP sizes
1477 num_engines = len(current_counts)
1473 - min_score = sys.maxsize
1478 + min_score: float = sys.maxsize
1479 eng_index = 0
1480 for i in range(num_engines):
1481 # Start from client_index to help with balancing when engines
1482 # are empty.
1483 idx = (self.eng_start_index + i) % num_engines
1479 - waiting, running = current_counts[idx]
1480 - score = waiting * 4 + running
1484 + waiting, running, kv_cache_usage = current_counts[idx]
1485 + # Estimate engine load as the greater of the coordinator's
1486 + # latest (waiting + running) snapshot and this client's own
1487 + # in-flight count (scaled by the number of clients). The
1488 + # in-flight floor is exact and can't be erased by a snapshot
1489 + # rebind, so a burst spreads round-robin even when snapshots
1490 + # race with routing decisions; the snapshot raises the score
1491 + # when other clients or stale requests load the engine.
1492 + inflight = self.engine_inflight[self.core_engines[idx]]
1493 + score: float = max(self.client_count * inflight, waiting + running)
1494 + if waiting:
1495 + # Waiting requests are penalized in proportion to KV cache
1496 + # pressure: a queue on a KV-bound engine drains slowly, so
1497 + # new requests should strongly prefer other engines. With
1498 + # low KV usage the queue is transient (e.g. mid-burst) and
1499 + # the penalty stays off, preserving exact round-robin.
1500 + # Ramps from 0 at <=50% usage to 3x waiting at 100%.
1501 + score += waiting * 6.0 * max(0.0, kv_cache_usage - 0.5)
1502 if score < min_score:
1503 min_score = score
1504 eng_index = idx
@@ -1494,6 +1515,7 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1515 chosen_engine = self.core_engines[eng_index]
1516 # Record which engine is chosen for this request, to handle aborts.
1517 self.reqs_in_flight[request.request_id] = chosen_engine
1518 + self.engine_inflight[chosen_engine] += 1
1519 return chosen_engine
1520
1521 async def call_utility_async(self, method: str, *args) -> Any:
@@ -1513,7 +1535,8 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1535 ):
1536 if outputs.finished_requests and self.reqs_in_flight:
1537 for req_id in outputs.finished_requests:
1516 - self.reqs_in_flight.pop(req_id, None)
1538 + if (engine := self.reqs_in_flight.pop(req_id, None)) is not None:
1539 + self.engine_inflight[engine] -= 1
1540
1541 @staticmethod
1542 async def eep_process_engine_core_notification(