@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / d90d8eb674
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 14, 05:30 UTC

[BugFix] Async scheduling and PP compatibility with DP (#23770)

Signed-off-by: Nick Hill <nhill@redhat.com>

Nick Hill committed Aug 29, 2025 at 08:17 UTC d90d8eb674f3870b8c85515a468108d5f1bd609a
7 files changed +107 -100
tests/v1/engine/test_engine_core.py
+22 -37
@@ -306,17 +306,17 @@ def test_engine_core_concurrent_batches(monkeypatch: pytest.MonkeyPatch):
306
307 # Schedule Batch 1: (10, req0)
308 assert engine_core.step_with_batch_queue()[0] is None
309 - assert engine_core.batch_queue.qsize() == 1
310 - scheduler_output = engine_core.batch_queue.queue[-1][1]
309 + assert len(engine_core.batch_queue) == 1
310 + scheduler_output = engine_core.batch_queue[-1][1]
311 assert scheduler_output.num_scheduled_tokens["0"] == 10
312 # num_computed_tokens should have been updated immediately.
313 assert engine_core.scheduler.requests[
314 req0.request_id].num_computed_tokens == 10
315
316 # Schedule Batch 2: (2, req0), (8, req1)
317 - assert engine_core.step_with_batch_queue()[0] is None
318 - assert engine_core.batch_queue.qsize() == 2
319 - scheduler_output = engine_core.batch_queue.queue[-1][1]
317 + assert engine_core.step_with_batch_queue()[0] == {}
318 + assert len(engine_core.batch_queue) == 1
319 + scheduler_output = engine_core.batch_queue[-1][1]
320 assert scheduler_output.num_scheduled_tokens["0"] == 2
321 assert scheduler_output.num_scheduled_tokens["1"] == 8
322 # num_computed_tokens should have been updated immediately.
@@ -325,42 +325,32 @@ def test_engine_core_concurrent_batches(monkeypatch: pytest.MonkeyPatch):
325
326 assert engine_core.scheduler.get_num_unfinished_requests() == 2
327
328 - # Batch queue is full. Finish Batch 1.
329 - engine_core.step_with_batch_queue()
330 -
331 - # Schedule Batch 3: (4, req1). Note that req0 cannot be scheduled
328 + # Finish Batch 1 and schedule Batch 3: (4, req1).
329 + # Note that req0 cannot be scheduled
330 # because it is in the decoding stage now.
331 engine_core.step_with_batch_queue()
334 - assert engine_core.batch_queue.qsize() == 2
335 - scheduler_output = engine_core.batch_queue.queue[-1][1]
332 + assert len(engine_core.batch_queue) == 1
333 + scheduler_output = engine_core.batch_queue[-1][1]
334 assert scheduler_output.num_scheduled_tokens["1"] == 4
335
338 - # Batch queue is full. Finish Batch 2. Get first token of req0.
336 + # Finish Batch 2. Get first token of req0.
337 + # Schedule Batch 4: (1, req0).
338 output = engine_core.step_with_batch_queue()[0].get(0)
339 assert output is not None
340 assert len(output.outputs) == 1
341 assert engine_core.scheduler.requests[req0.request_id].num_tokens == 13
343 -
344 - # Schedule Batch 4: (1, req0).
345 - engine_core.step_with_batch_queue()
346 - assert engine_core.batch_queue.qsize() == 2
347 - scheduler_output = engine_core.batch_queue.queue[-1][1]
342 + scheduler_output = engine_core.batch_queue[-1][1]
343 assert scheduler_output.num_scheduled_tokens["0"] == 1
344
350 - # Batch queue is full. Finish Batch 3. Get first token of req1.
345 + # Finish Batch 3. Get first token of req1. Schedule Batch 5: (1, req1).
346 output = engine_core.step_with_batch_queue()[0].get(0)
347 assert output is not None
348 assert len(output.outputs) == 1
349 assert engine_core.scheduler.requests[req1.request_id].num_tokens == 13
355 -
356 - # Schedule Batch 5: (1, req1).
357 - engine_core.step_with_batch_queue()
358 - assert engine_core.batch_queue.qsize() == 2
359 - scheduler_output = engine_core.batch_queue.queue[-1][1]
350 + scheduler_output = engine_core.batch_queue[-1][1]
351 assert scheduler_output.num_scheduled_tokens["1"] == 1
352
353 # Loop until req0 is finished.
363 - step = 0
354 req_id = 0
355 expected_num_tokens = [
356 engine_core.scheduler.requests["0"].num_tokens + 1,
@@ -368,19 +358,14 @@ def test_engine_core_concurrent_batches(monkeypatch: pytest.MonkeyPatch):
358 ]
359 while engine_core.scheduler.get_num_unfinished_requests() == 2:
360 output = engine_core.step_with_batch_queue()[0]
371 - if step % 2 == 0:
372 - # Even steps consumes an output.
373 - assert output is not None
374 - assert len(output[0].outputs) == 1
375 - if req_id in engine_core.scheduler.requests:
376 - assert engine_core.scheduler.requests[
377 - req_id].num_tokens == expected_num_tokens[req_id]
378 - expected_num_tokens[req_id] += 1
379 - req_id = (req_id + 1) % 2
380 - else:
381 - # Odd steps schedules a new batch.
382 - assert output is None
383 - step += 1
361 + # Every step consumes an output.
362 + assert output is not None
363 + assert len(output[0].outputs) == 1
364 + if req_id in engine_core.scheduler.requests:
365 + assert engine_core.scheduler.requests[
366 + req_id].num_tokens == expected_num_tokens[req_id]
367 + expected_num_tokens[req_id] += 1
368 + req_id = (req_id + 1) % 2
369
370
371 @multi_gpu_test(num_gpus=2)
tests/v1/test_async_llm_dp.py
+4 -2
@@ -75,9 +75,10 @@ async def generate(
75 ],
76 )
77 @pytest.mark.parametrize("data_parallel_backend", ["mp", "ray"])
78 +@pytest.mark.parametrize("async_scheduling", [True, False])
79 @pytest.mark.asyncio
79 -async def test_load(output_kind: RequestOutputKind,
80 - data_parallel_backend: str):
80 +async def test_load(output_kind: RequestOutputKind, data_parallel_backend: str,
81 + async_scheduling: bool):
82
83 stats_loggers = {}
84
@@ -105,6 +106,7 @@ async def test_load(output_kind: RequestOutputKind,
106 prompt = "This is a test of data parallel"
107
108 engine_args.data_parallel_backend = data_parallel_backend
109 + engine_args.async_scheduling = async_scheduling
110 engine = AsyncLLM.from_engine_args(engine_args,
111 stat_loggers=[SimpleStatsLogger])
112 after.callback(engine.shutdown)
vllm/executor/ray_utils.py
+6
@@ -10,6 +10,7 @@ import msgspec
10
11 import vllm.platforms
12 from vllm.config import ParallelConfig
13 +from vllm.distributed import get_pp_group
14 from vllm.executor.msgspec_utils import decode_hook, encode_hook
15 from vllm.logger import init_logger
16 from vllm.platforms import current_platform
@@ -136,6 +137,11 @@ try:
137 scheduler_output, intermediate_tensors)
138 if isinstance(output, IntermediateTensors):
139 output = scheduler_output, output
140 + elif not get_pp_group().is_last_rank:
141 + # Case where there are no scheduled requests
142 + # but may still be finished requests.
143 + assert not output or not output.req_ids
144 + output = scheduler_output, None
145 return output
146
147 def override_env_vars(self, vars: Dict[str, str]):
vllm/v1/engine/core.py
+36 -33
@@ -138,12 +138,12 @@ class EngineCore:
138 # schedule and execute batches, and is required by pipeline parallelism
139 # to eliminate pipeline bubbles.
140 self.batch_queue_size = self.model_executor.max_concurrent_batches
141 - self.batch_queue: Optional[queue.Queue[tuple[Future[ModelRunnerOutput],
142 - SchedulerOutput]]] = None
141 + self.batch_queue: Optional[deque[tuple[Future[ModelRunnerOutput],
142 + SchedulerOutput]]] = None
143 if self.batch_queue_size > 1:
144 logger.info("Batch queue is enabled with size %d",
145 self.batch_queue_size)
146 - self.batch_queue = queue.Queue(self.batch_queue_size)
146 + self.batch_queue = deque(maxlen=self.batch_queue_size)
147
148 self.request_block_hasher: Optional[Callable[[Request],
149 list[BlockHash]]] = None
@@ -319,41 +319,43 @@ class EngineCore:
319 batch in the job queue is finished.
320 3. Update the scheduler from the output.
321 """
322 - assert self.batch_queue is not None
322 + batch_queue = self.batch_queue
323 + assert batch_queue is not None
324
324 - engine_core_outputs = None
325 - scheduler_output = None
325 # Try to schedule a new batch if the batch queue is not full, but
326 # the scheduler may return an empty batch if all requests are scheduled.
327 # Note that this is not blocking.
329 - if not self.batch_queue.full():
330 - scheduler_output = self.scheduler.schedule()
331 - if scheduler_output.total_num_scheduled_tokens > 0:
332 - future = self.model_executor.execute_model(scheduler_output)
333 - self.batch_queue.put_nowait(
334 - (future, scheduler_output)) # type: ignore
335 -
336 - scheduled_batch = (scheduler_output is not None
337 - and scheduler_output.total_num_scheduled_tokens > 0)
338 -
339 - # If no more requests can be scheduled and the job queue is not empty,
340 - # block until the first batch in the job queue is finished.
341 - # TODO(comaniac): Ideally we should peek the first batch in the
342 - # job queue to check if it's finished before scheduling a new batch,
343 - # but peeking the first element in a queue is not thread-safe,
344 - # so we need more work.
345 - if not scheduled_batch and not self.batch_queue.empty():
346 - future, scheduler_output = self.batch_queue.get_nowait()
328 + assert len(batch_queue) < self.batch_queue_size
329
348 - # Blocking until the first result is available.
349 - model_output = self.execute_model_with_error_logging(
350 - lambda _: future.result(), scheduler_output)
330 + model_executed = False
331 + if self.scheduler.has_requests():
332 + scheduler_output = self.scheduler.schedule()
333 + future = self.model_executor.execute_model(scheduler_output)
334 + batch_queue.appendleft(
335 + (future, scheduler_output)) # type: ignore[arg-type]
336 +
337 + model_executed = scheduler_output.total_num_scheduled_tokens > 0
338 + if model_executed and len(batch_queue) < self.batch_queue_size \
339 + and not batch_queue[-1][0].done():
340 + # Don't block on next worker response unless the queue is full
341 + # or there are no more requests to schedule.
342 + return None, True
343 +
344 + elif not batch_queue:
345 + # Queue is empty. We should not reach here since this method should
346 + # only be called when the scheduler contains requests or the queue
347 + # is non-empty.
348 + return None, False
349 +
350 + # Block until the next result is available.
351 + future, scheduler_output = batch_queue.pop()
352 + model_output = self.execute_model_with_error_logging(
353 + lambda _: future.result(), scheduler_output)
354
352 - self.batch_queue.task_done()
353 - engine_core_outputs = (self.scheduler.update_from_output(
354 - scheduler_output, model_output))
355 + engine_core_outputs = self.scheduler.update_from_output(
356 + scheduler_output, model_output)
357
356 - return engine_core_outputs, scheduled_batch
358 + return engine_core_outputs, model_executed
359
360 def shutdown(self):
361 self.structured_output_manager.clear_backend()
@@ -388,7 +390,7 @@ class EngineCore:
390 return self.model_executor.is_sleeping
391
392 def execute_dummy_batch(self):
391 - self.model_executor.collective_rpc("execute_dummy_batch")
393 + self.model_executor.execute_dummy_batch()
394
395 def add_lora(self, lora_request: LoRARequest) -> bool:
396 return self.model_executor.add_lora(lora_request)
@@ -733,7 +735,8 @@ class EngineCoreProc(EngineCore):
735 """Exits when an engine step needs to be performed."""
736
737 waited = False
736 - while not self.engines_running and not self.scheduler.has_requests():
738 + while not self.engines_running and not self.scheduler.has_requests() \
739 + and not self.batch_queue:
740 if logger.isEnabledFor(DEBUG) and self.input_queue.empty():
741 logger.debug("EngineCore waiting for work.")
742 waited = True
vllm/v1/executor/abstract.py
+5 -4
@@ -81,12 +81,10 @@ class Executor(ExecutorBase):
81 pass
82
83 def determine_available_memory(self) -> list[int]: # in bytes
84 - output = self.collective_rpc("determine_available_memory")
85 - return output
84 + return self.collective_rpc("determine_available_memory")
85
86 def get_kv_cache_specs(self) -> list[dict[str, KVCacheSpec]]:
88 - output = self.collective_rpc("get_kv_cache_spec")
89 - return output
87 + return self.collective_rpc("get_kv_cache_spec")
88
89 def execute_model(
90 self,
@@ -96,6 +94,9 @@ class Executor(ExecutorBase):
94 args=(scheduler_output, ))
95 return output[0]
96
97 + def execute_dummy_batch(self) -> None:
98 + self.collective_rpc("execute_dummy_batch")
99 +
100 def take_draft_token_ids(self) -> Optional[DraftTokenIds]:
101 output = self.collective_rpc("take_draft_token_ids")
102 return output[0]
vllm/v1/executor/multiproc_executor.py
+12 -3
@@ -191,6 +191,10 @@ class MultiprocExecutor(Executor):
191 outputs, self.output_rank)
192 return self.kv_output_aggregator.aggregate(outputs, self.output_rank)
193
194 + def execute_dummy_batch(self) -> None:
195 + self.collective_rpc("execute_dummy_batch",
196 + unique_reply_rank=self.output_rank)
197 +
198 def take_draft_token_ids(self) -> Optional[DraftTokenIds]:
199 # OPTIMIZATION: Get output only from a single worker (output_rank)
200 outputs = self.collective_rpc("take_draft_token_ids",
@@ -242,12 +246,17 @@ class MultiprocExecutor(Executor):
246 dequeue_timeout = None if deadline is None else (
247 deadline - time.monotonic())
248
245 - if non_block:
249 + if self.io_thread_pool is not None:
250 + # We must consume worker_response_mq from a single thread.
251 result = self.io_thread_pool.submit( # type: ignore
252 get_response, w, dequeue_timeout, self.shutdown_event)
248 - else:
253 + if not non_block:
254 + result = result.result()
255 + elif not non_block:
256 result = get_response(w, dequeue_timeout)
250 -
257 + else:
258 + raise RuntimeError("non_block can only be used when"
259 + " max_concurrent_batches > 1")
260 responses.append(result)
261
262 return responses
vllm/v1/worker/gpu_worker.py
+22 -21
@@ -354,36 +354,37 @@ class Worker(WorkerBase):
354 scheduler_output: "SchedulerOutput",
355 ) -> Optional[ModelRunnerOutput]:
356 intermediate_tensors = None
357 - if not get_pp_group().is_first_rank:
357 + forward_pass = scheduler_output.total_num_scheduled_tokens > 0
358 + if forward_pass and not get_pp_group().is_first_rank:
359 intermediate_tensors = IntermediateTensors(
360 get_pp_group().recv_tensor_dict(
361 all_gather_group=get_tp_group()))
362
363 output = self.model_runner.execute_model(scheduler_output,
364 intermediate_tensors)
365 + if isinstance(output, ModelRunnerOutput):
366 + return output
367
368 + assert isinstance(output, IntermediateTensors)
369 parallel_config = self.vllm_config.parallel_config
366 - if parallel_config.distributed_executor_backend != "external_launcher" \
367 - and not get_pp_group().is_last_rank:
368 - assert isinstance(output, IntermediateTensors)
369 - get_pp_group().send_tensor_dict(output.tensors,
370 - all_gather_group=get_tp_group())
371 -
372 - kv_connector_output = output.kv_connector_output
373 - if not kv_connector_output:
374 - return None
375 -
376 - # In case of PP with kv transfer, we need to pass through the
377 - # kv_connector_output
378 - if (not kv_connector_output.finished_sending
379 - and not kv_connector_output.finished_recving):
380 - return EMPTY_MODEL_RUNNER_OUTPUT
381 -
382 - output = copy.copy(EMPTY_MODEL_RUNNER_OUTPUT)
383 - output.kv_connector_output = kv_connector_output
384 - return output
370 + assert parallel_config.distributed_executor_backend != (
371 + "external_launcher") and not get_pp_group().is_last_rank
372 +
373 + get_pp_group().send_tensor_dict(output.tensors,
374 + all_gather_group=get_tp_group())
375 +
376 + kv_connector_output = output.kv_connector_output
377 + if not kv_connector_output:
378 + return None
379 +
380 + # In case of PP with kv transfer, we need to pass through the
381 + # kv_connector_output
382 + if (not kv_connector_output.finished_sending
383 + and not kv_connector_output.finished_recving):
384 + return EMPTY_MODEL_RUNNER_OUTPUT
385
386 - assert isinstance(output, ModelRunnerOutput)
386 + output = copy.copy(EMPTY_MODEL_RUNNER_OUTPUT)
387 + output.kv_connector_output = kv_connector_output
388 return output
389
390 def take_draft_token_ids(self) -> Optional[DraftTokenIds]: