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 14, 22:35 UTC
[BugFix] Improve internal DP load balancing (#21617)
Signed-off-by: Nick Hill <nhill@redhat.com>
Nick Hill committed
Aug 2, 2025 at 03:45 UTC
8d524ce79ffd0571d6a576cb9a5c21feab187246
7 files changed
+122
-59
vllm/entrypoints/openai/api_server.py
+3
@@ -199,6 +199,8 @@ async def build_async_engine_client_from_engine_args(
199
200
from vllm.v1.engine.async_llm import AsyncLLM
201
async_llm: Optional[AsyncLLM] = None
202
+ client_count = client_config.pop(
203
+ "client_count") if client_config else 1
204
client_index = client_config.pop(
205
"client_index") if client_config else 0
206
try:
@@ -208,6 +210,7 @@ async def build_async_engine_client_from_engine_args(
210
enable_log_requests=engine_args.enable_log_requests,
211
disable_log_stats=engine_args.disable_log_stats,
212
client_addresses=client_config,
213
+ client_count=client_count,
214
client_index=client_index)
215
216
# Don't keep the dummy data in memory
vllm/v1/engine/async_llm.py
+4
@@ -57,6 +57,7 @@ class AsyncLLM(EngineClient):
57
start_engine_loop: bool = True,
58
stat_loggers: Optional[list[StatLoggerFactory]] = None,
59
client_addresses: Optional[dict[str, str]] = None,
60
+ client_count: int = 1,
61
client_index: int = 0,
62
) -> None:
63
"""
@@ -120,6 +121,7 @@ class AsyncLLM(EngineClient):
121
executor_class=executor_class,
122
log_stats=self.log_stats,
123
client_addresses=client_addresses,
124
+ client_count=client_count,
125
client_index=client_index,
126
)
127
@@ -156,6 +158,7 @@ class AsyncLLM(EngineClient):
158
enable_log_requests: bool = False,
159
disable_log_stats: bool = False,
160
client_addresses: Optional[dict[str, str]] = None,
161
+ client_count: int = 1,
162
client_index: int = 0,
163
disable_log_requests: bool = True, # Deprecated, will be removed
164
) -> "AsyncLLM":
@@ -176,6 +179,7 @@ class AsyncLLM(EngineClient):
179
log_stats=not disable_log_stats,
180
usage_context=usage_context,
181
client_addresses=client_addresses,
182
+ client_count=client_count,
183
client_index=client_index,
184
)
185
vllm/v1/engine/coordinator.py
+73
-37
@@ -1,5 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import copy
4
import multiprocessing
5
import time
6
import weakref
@@ -65,18 +66,14 @@ class DPCoordinator:
66
67
# Assume coordinator is colocated with front-end procs when not in
68
# either external or hybrid DP LB mode.
69
+ local_only = not (external_lb or hybrid_lb)
70
front_publish_address = get_engine_client_zmq_addr(
69
- local_only=not external_lb and not hybrid_lb, host=host)
71
+ local_only=local_only, host=host)
72
73
local_only_eng = dp_size == parallel_config.data_parallel_size_local
74
back_publish_address = get_engine_client_zmq_addr(local_only_eng, host)
75
back_output_address = get_engine_client_zmq_addr(local_only_eng, host)
76
75
- # When in external LB mode, load stats aren't published, only changes
76
- # to request wave / running state, so we don't need to rate-limit the
77
- # updates to the front-end proc(s).
78
- min_stats_update_interval_ms = 0 if external_lb else 100
79
-
77
context = get_mp_context()
78
self.proc: multiprocessing.Process = context.Process(
79
target=DPCoordinatorProc.run_coordinator,
@@ -86,7 +83,6 @@ class DPCoordinator:
83
"front_publish_address": front_publish_address,
84
"back_output_address": back_output_address,
85
"back_publish_address": back_publish_address,
89
- "min_stats_update_interval_ms": min_stats_update_interval_ms,
86
},
87
daemon=True)
88
self.proc.start()
@@ -125,10 +121,6 @@ class DPCoordinatorProc:
121
122
self.stats_update_interval_ms = min_stats_update_interval_ms
123
128
- self.current_wave = 0
129
- self.engines_running = False
130
- self.stats_changed = False
131
-
124
@staticmethod
125
def run_coordinator(
126
engine_count: int,
@@ -155,6 +147,16 @@ class DPCoordinatorProc:
147
148
decoder = MsgpackDecoder(EngineCoreOutputs)
149
150
+ # For tracking request wave progression.
151
+ current_wave = 0
152
+ engines_running = False
153
+
154
+ # For tracking request counts for internal load-balancing.
155
+ stats_changed = False
156
+ last_stats_step = -1
157
+ last_stats_wave = -1
158
+ last_step_counts: Optional[list[list[int]]] = None
159
+
160
with make_zmq_socket(
161
path=front_publish_address, # IPC
162
ctx=self.ctx,
@@ -191,21 +193,33 @@ class DPCoordinatorProc:
193
while True:
194
elapsed = int(time.time() * 1000) - last_publish_time
195
# Send at stats_update_interval_ms interval if the stats have
194
- # changed, or otherwise every 4 seconds.
196
+ # changed, or otherwise every 5 seconds.
197
wait_for = (self.stats_update_interval_ms
196
- if self.stats_changed else 4000)
197
- events = poller.poll(timeout=max(0, wait_for - elapsed))
198
+ if stats_changed else 5000)
199
+
200
+ # Wait at least 50ms to ensure we've received all stats for
201
+ # the current step.
202
+ min_timeout = 50 if last_step_counts is None else 0
203
+
204
+ events = poller.poll(timeout=max(min_timeout, wait_for -
205
+ elapsed))
206
if not events:
207
# Poller timeout - publish current stats to front-ends.
200
- engine_req_counts_list = self._get_engine_counts()
201
- to_publish = (engine_req_counts_list, self.current_wave,
202
- self.engines_running)
208
+ if last_step_counts is not None:
209
+ engine_req_counts_list = last_step_counts
210
+ last_step_counts = None
211
+ else:
212
+ engine_req_counts_list = self._get_engine_counts()
213
+ stats_changed = False
214
+
215
+ to_publish = (engine_req_counts_list, current_wave,
216
+ engines_running)
217
publish_front.send(msgspec.msgpack.encode(to_publish))
218
last_publish_time = int(time.time() * 1000)
205
- self.stats_changed = False
219
continue
220
221
events = dict(events)
222
+ wave_state_changed = False
223
224
if publish_front in events:
225
buffer = publish_front.recv()
@@ -232,7 +246,7 @@ class DPCoordinatorProc:
246
# current_wave
247
# we note that 0 is the wave number for the new
248
# engine
235
- self.engines_running = False
249
+ engines_running = False
250
logger.info(
251
"DPCoordinator scaled up from %s to %s "
252
"engines", current_count, new_engine_count)
@@ -248,15 +262,15 @@ class DPCoordinatorProc:
262
# engines are paused, so that we can wake the other
263
# engines.
264
engine_to_exclude, wave = decoded
251
- if not self.engines_running:
252
- if wave < self.current_wave:
265
+ if not engines_running:
266
+ if wave < current_wave:
267
# If the wave number is stale, ensure the message
268
# is handled by all the engines.
269
engine_to_exclude = None
270
257
- self.engines_running = True
258
- self.stats_changed = True
259
- self._send_start_wave(publish_back, self.current_wave,
271
+ engines_running = True
272
+ wave_state_changed = True
273
+ self._send_start_wave(publish_back, current_wave,
274
engine_to_exclude)
275
276
if output_back in events:
@@ -274,36 +288,56 @@ class DPCoordinatorProc:
288
# 1. Updated request load stats - update our local
289
# state with these.
290
stats = self.engines[eng_index].request_counts
291
+ stats_step = scheduler_stats.step_counter
292
+ stats_wave = scheduler_stats.current_wave
293
+ if (stats_wave > last_stats_wave
294
+ or stats_wave == last_stats_wave
295
+ and stats_step > last_stats_step):
296
+ if stats_changed:
297
+ last_step_counts = self._get_engine_counts(
298
+ do_copy=True)
299
+ last_stats_step = stats_step
300
+ last_stats_wave = stats_wave
301
+ elif stats_wave != last_stats_wave or (
302
+ stats_step != last_stats_step):
303
+ logger.warning(
304
+ "Received stats for out-of-order "
305
+ "step (%d, %d) from engine %d (expected "
306
+ "> (%d, %d))", stats_wave, stats_step,
307
+ eng_index, last_stats_wave, last_stats_step)
308
stats[0] = scheduler_stats.num_waiting_reqs
309
stats[1] = scheduler_stats.num_running_reqs
279
- self.stats_changed = True
310
+ stats_changed = True
311
312
if (wave := outputs.wave_complete) is not None:
313
# 2. Notification from rank 0 engine that we've
314
# moved into the global paused state
315
# (engines_running==False).
285
- if self.current_wave <= wave:
316
+ if current_wave <= wave:
317
new_wave = wave + 1
318
logger.debug("Moving DP wave from %d to %d.",
288
- self.current_wave, new_wave)
289
- self.current_wave = new_wave
290
- self.engines_running = False
291
- self.stats_changed = True
319
+ current_wave, new_wave)
320
+ current_wave = new_wave
321
+ engines_running = False
322
+ wave_state_changed = True
323
elif (wave := outputs.start_wave) is not None and (
293
- wave > self.current_wave or
294
- (wave == self.current_wave
295
- and not self.engines_running)):
324
+ wave > current_wave or
325
+ (wave == current_wave and not engines_running)):
326
# 3. The engine received request for a non-current wave
327
# so we must ensure that other engines progress to the
328
# next wave (race condition handling).
329
logger.debug(
330
"Starting wave %d after notification of "
331
"stale wave request from engine.", wave)
302
- self.current_wave = wave
303
- self.engines_running = True
304
- self.stats_changed = True
332
+ current_wave = wave
333
+ engines_running = True
334
+ wave_state_changed = True
335
self._send_start_wave(publish_back, wave, eng_index)
336
337
+ if wave_state_changed:
338
+ message = (None, current_wave, engines_running)
339
+ publish_front.send(msgspec.msgpack.encode(message))
340
+
341
@staticmethod
342
def _send_start_wave(socket: zmq.Socket, wave: int,
343
exclude_engine_index: Optional[int]):
@@ -316,6 +350,8 @@ class DPCoordinatorProc:
350
socket.send_multipart(
351
(EngineCoreRequestType.START_DP_WAVE.value, wave_encoded))
352
319
- def _get_engine_counts(self) -> list[list[int]]:
353
+ def _get_engine_counts(self, do_copy=False) -> list[list[int]]:
354
"""Return list of [waiting, running] count lists for each engine."""
355
+ if do_copy:
356
+ return [copy.copy(e.request_counts) for e in self.engines]
357
return [e.request_counts for e in self.engines]
vllm/v1/engine/core.py
+8
-5
@@ -928,7 +928,7 @@ class DPEngineCoreProc(EngineCoreProc):
928
):
929
# Counts forward-passes of the model so that we can synchronize
930
# finished with DP peers every N steps.
931
- self.counter = 0
931
+ self.step_counter = 0
932
self.current_wave = 0
933
self.last_counts = (0, 0)
934
@@ -999,7 +999,9 @@ class DPEngineCoreProc(EngineCoreProc):
999
counts = self.scheduler.get_request_counts()
1000
if counts != self.last_counts:
1001
self.last_counts = counts
1002
- stats = SchedulerStats(*counts)
1002
+ stats = SchedulerStats(*counts,
1003
+ step_counter=self.step_counter,
1004
+ current_wave=self.current_wave)
1005
self.output_queue.put_nowait(
1006
(-1, EngineCoreOutputs(scheduler_stats=stats)))
1007
@@ -1041,15 +1043,16 @@ class DPEngineCoreProc(EngineCoreProc):
1043
self.output_queue.put_nowait(
1044
(client_index,
1045
EngineCoreOutputs(wave_complete=self.current_wave)))
1046
+ # Increment wave count and reset step counter.
1047
self.current_wave += 1
1048
+ self.step_counter = 0
1049
1050
def _has_global_unfinished_reqs(self, local_unfinished: bool) -> bool:
1051
1052
# Optimization - only perform finish-sync all-reduce every 32 steps.
1049
- self.counter += 1
1050
- if self.counter != 32:
1053
+ self.step_counter += 1
1054
+ if self.step_counter % 32 != 0:
1055
return True
1052
- self.counter = 0
1056
1057
return ParallelConfig.has_unfinished_dp(self.dp_group,
1058
local_unfinished)
vllm/v1/engine/core_client.py
+29
-17
@@ -86,11 +86,12 @@ class EngineCoreClient(ABC):
86
executor_class: type[Executor],
87
log_stats: bool,
88
client_addresses: Optional[dict[str, str]] = None,
89
+ client_count: int = 1,
90
client_index: int = 0,
91
) -> "MPClient":
92
parallel_config = vllm_config.parallel_config
93
client_args = (vllm_config, executor_class, log_stats,
93
- client_addresses, client_index)
94
+ client_addresses, client_count, client_index)
95
if parallel_config.data_parallel_size > 1:
96
if parallel_config.data_parallel_external_lb:
97
# External load balancer - client per DP rank.
@@ -727,6 +728,7 @@ class AsyncMPClient(MPClient):
728
executor_class: type[Executor],
729
log_stats: bool,
730
client_addresses: Optional[dict[str, str]] = None,
731
+ client_count: int = 1,
732
client_index: int = 0):
733
super().__init__(
734
asyncio_mode=True,
@@ -929,11 +931,12 @@ class DPAsyncMPClient(AsyncMPClient):
931
executor_class: type[Executor],
932
log_stats: bool,
933
client_addresses: Optional[dict[str, str]] = None,
934
+ client_count: int = 1,
935
client_index: int = 0):
936
self.current_wave = 0
937
938
super().__init__(vllm_config, executor_class, log_stats,
936
- client_addresses, client_index)
939
+ client_addresses, client_count, client_index)
940
941
# List of [waiting, running] pair per engine.
942
# Used only by DPLBAsyncMPClient subclass.
@@ -1029,7 +1032,11 @@ class DPAsyncMPClient(AsyncMPClient):
1032
counts, wave, running = msgspec.msgpack.decode(buf)
1033
self.current_wave = wave
1034
self.engines_running = running
1032
- self.lb_engines = counts[count_slice]
1035
+ if counts is not None:
1036
+ sliced_counts = counts[count_slice]
1037
+ self.lb_engines = sliced_counts
1038
+ logger.debug("Received counts: %s (%s)", sliced_counts,
1039
+ count_slice)
1040
1041
resources.stats_update_task = asyncio.create_task(
1042
run_engine_stats_update_task())
@@ -1065,40 +1072,45 @@ class DPLBAsyncMPClient(DPAsyncMPClient):
1072
executor_class: type[Executor],
1073
log_stats: bool,
1074
client_addresses: Optional[dict[str, str]] = None,
1075
+ client_count: int = 1,
1076
client_index: int = 0):
1077
1078
+ self.client_count = client_count
1079
+
1080
# To route aborts to the correct engine.
1081
self.reqs_in_flight: dict[str, EngineIdentity] = {}
1082
1083
super().__init__(vllm_config, executor_class, log_stats,
1074
- client_addresses, client_index)
1084
+ client_addresses, client_count, client_index)
1085
1086
assert len(self.core_engines) > 1
1087
1088
+ self.eng_start_index = (len(self.core_engines) *
1089
+ self.client_index) // client_count
1090
+
1091
def get_core_engine_for_request(
1092
self, request: EngineCoreRequest) -> EngineIdentity:
1093
# Engines are in rank order.
1094
+ current_counts = self.lb_engines
1095
if (eng_index := request.data_parallel_rank) is None:
1082
- if not self.lb_engines:
1096
+ if not current_counts:
1097
return self.core_engine
1098
# TODO use P2C alg for larger DP sizes
1085
- num_engines = len(self.lb_engines)
1086
- min_counts = [sys.maxsize, sys.maxsize]
1099
+ num_engines = len(current_counts)
1100
+ min_score = sys.maxsize
1101
eng_index = 0
1102
for i in range(num_engines):
1103
# Start from client_index to help with balancing when engines
1104
# are empty.
1091
- idx = (self.client_index + i) % num_engines
1092
- counts = self.lb_engines[idx]
1093
- if counts < min_counts:
1094
- min_counts = counts
1105
+ idx = (self.eng_start_index + i) % num_engines
1106
+ waiting, running = current_counts[idx]
1107
+ score = waiting * 4 + running
1108
+ if score < min_score:
1109
+ min_score = score
1110
eng_index = idx
1096
- # Adjust local counts for better balancing between stats updates
1097
- # from the coordinator (which happen every 100ms).
1098
- if min_counts[0]:
1099
- min_counts[0] += 1
1100
- else:
1101
- min_counts[1] += 1
1111
+ # Increment local waiting count for better balancing between stats
1112
+ # updates from the coordinator (which happen every 100ms).
1113
+ current_counts[eng_index][0] += self.client_count
1114
1115
chosen_engine = self.core_engines[eng_index]
1116
# Record which engine is chosen for this request, to handle aborts.
vllm/v1/metrics/stats.py
+4
@@ -33,6 +33,10 @@ class SchedulerStats:
33
num_running_reqs: int = 0
34
num_waiting_reqs: int = 0
35
36
+ # These are used for internal DP load-balancing.
37
+ step_counter: int = 0
38
+ current_wave: int = 0
39
+
40
kv_cache_usage: float = 0.0
41
42
prefix_cache_stats: PrefixCacheStats = field(
vllm/v1/utils.py
+1
@@ -154,6 +154,7 @@ class APIServerProcessManager:
154
client_config = {
155
"input_address": in_addr,
156
"output_address": out_addr,
157
+ "client_count": num_servers,
158
"client_index": i
159
}
160
if stats_update_address is not None: