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
[Perf][V1] Fully overlap model execution (#23569)
Signed-off-by: Benjamin Chislett <benjamin.chislett@centml.ai>
Benjamin Chislett committed
Sep 5, 2025 at 21:20 UTC
cee182b2970b56d3f9d5aa0017ce165cbdb788f6
5 files changed
+252
-31
vllm/v1/executor/multiproc_executor.py
+45
-5
@@ -3,6 +3,7 @@
3
import multiprocessing
4
import os
5
import pickle
6
+import queue
7
import signal
8
import threading
9
import time
@@ -33,7 +34,8 @@ from vllm.utils import (decorate_logs, get_distributed_init_method,
34
get_loopback_ip, get_mp_context, get_open_port,
35
set_process_title)
36
from vllm.v1.executor.abstract import Executor, FailureCallback
36
-from vllm.v1.outputs import DraftTokenIds, ModelRunnerOutput
37
+from vllm.v1.outputs import (AsyncModelRunnerOutput, DraftTokenIds,
38
+ ModelRunnerOutput)
39
from vllm.worker.worker_base import WorkerWrapperBase
40
41
logger = init_logger(__name__)
@@ -414,6 +416,16 @@ class WorkerProc:
416
# Initializes a message queue for sending the model output
417
self.worker_response_mq = MessageQueue(1, 1)
418
419
+ scheduler_config = vllm_config.scheduler_config
420
+ self.use_async_scheduling = scheduler_config.async_scheduling
421
+ if self.use_async_scheduling:
422
+ self.async_output_queue: queue.Queue = queue.Queue()
423
+ self.async_output_copy_thread = Thread(
424
+ target=self.async_output_busy_loop,
425
+ daemon=True,
426
+ name="WorkerAsyncOutputCopy")
427
+ self.async_output_copy_thread.start()
428
+
429
# Initialize device and loads weights
430
self.worker.init_device()
431
self.worker.load_model()
@@ -595,6 +607,36 @@ class WorkerProc:
607
SUCCESS = auto()
608
FAILURE = auto()
609
610
+ def enqueue_output(self, output: Any):
611
+ """Prepares output from the worker and enqueues it to the
612
+ worker_response_mq. If the output is an Exception, it is
613
+ converted to a FAILURE response.
614
+ """
615
+ if isinstance(output, AsyncModelRunnerOutput):
616
+ output = output.get_output()
617
+
618
+ if isinstance(output, Exception):
619
+ result = (WorkerProc.ResponseStatus.FAILURE, str(output))
620
+ else:
621
+ result = (WorkerProc.ResponseStatus.SUCCESS, output)
622
+ self.worker_response_mq.enqueue(result)
623
+
624
+ def handle_output(self, output: Any):
625
+ """Handles output from the worker. If async scheduling is enabled,
626
+ it is passed to the async_output_busy_loop thread. Otherwise, it is
627
+ enqueued directly to the worker_response_mq.
628
+ """
629
+ if self.use_async_scheduling:
630
+ self.async_output_queue.put(output)
631
+ else:
632
+ self.enqueue_output(output)
633
+
634
+ def async_output_busy_loop(self):
635
+ """Entrypoint for the thread which handles outputs asynchronously."""
636
+ while True:
637
+ output = self.async_output_queue.get()
638
+ self.enqueue_output(output)
639
+
640
def worker_busy_loop(self):
641
"""Main busy loop for Multiprocessing Workers"""
642
while True:
@@ -614,10 +656,8 @@ class WorkerProc:
656
# exception might not be serializable, so we convert it to
657
# string, only for logging purpose.
658
if output_rank is None or self.rank == output_rank:
617
- self.worker_response_mq.enqueue(
618
- (WorkerProc.ResponseStatus.FAILURE, str(e)))
659
+ self.handle_output(e)
660
continue
661
662
if output_rank is None or self.rank == output_rank:
622
- self.worker_response_mq.enqueue(
623
- (WorkerProc.ResponseStatus.SUCCESS, output))
663
+ self.handle_output(output)
vllm/v1/outputs.py
+15
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from abc import ABC, abstractmethod
5
from dataclasses import dataclass
6
from typing import NamedTuple, Optional
7
@@ -114,6 +115,20 @@ class ModelRunnerOutput:
115
num_nans_in_logits: Optional[dict[str, int]] = None
116
117
118
+# ModelRunnerOutput wrapper for async scheduling.
119
+class AsyncModelRunnerOutput(ABC):
120
+
121
+ @abstractmethod
122
+ def get_output(self) -> ModelRunnerOutput:
123
+ """Get the ModelRunnerOutput for this async output.
124
+
125
+ This is a blocking call that waits until the results are ready, which
126
+ might involve copying device tensors to the host.
127
+ This method should only be called once per AsyncModelRunnerOutput.
128
+ """
129
+ pass
130
+
131
+
132
@dataclass
133
class DraftTokenIds:
134
vllm/v1/worker/gpu_input_batch.py
+5
@@ -250,6 +250,11 @@ class InputBatch:
250
251
self.pooling_params: dict[str, PoolingParams] = {}
252
253
+ # Cached reference to the GPU tensor of previously sampled tokens
254
+ self.prev_sampled_token_ids: Optional[torch.Tensor] = None
255
+ self.prev_sampled_token_ids_invalid_indices: Optional[set[int]] = None
256
+ self.prev_req_id_to_index: Optional[dict[str, int]] = None
257
+
258
@property
259
def req_ids(self) -> list[str]:
260
# None elements should only be present transiently
vllm/v1/worker/gpu_model_runner.py
+182
-21
@@ -67,8 +67,8 @@ from vllm.v1.kv_cache_interface import (AttentionSpec,
67
FullAttentionSpec, KVCacheConfig,
68
KVCacheGroupSpec, KVCacheSpec,
69
MambaSpec, SlidingWindowSpec)
70
-from vllm.v1.outputs import (EMPTY_MODEL_RUNNER_OUTPUT, DraftTokenIds,
71
- LogprobsTensors, ModelRunnerOutput)
70
+from vllm.v1.outputs import (EMPTY_MODEL_RUNNER_OUTPUT, AsyncModelRunnerOutput,
71
+ DraftTokenIds, LogprobsTensors, ModelRunnerOutput)
72
from vllm.v1.pool.metadata import PoolingMetadata
73
from vllm.v1.sample.logits_processor import LogitsProcessors, build_logitsprocs
74
from vllm.v1.sample.metadata import SamplingMetadata
@@ -100,6 +100,53 @@ else:
100
logger = init_logger(__name__)
101
102
103
+# Wrapper for ModelRunnerOutput to support overlapped execution.
104
+class AsyncGPUModelRunnerOutput(AsyncModelRunnerOutput):
105
+
106
+ def __init__(
107
+ self,
108
+ model_runner_output: ModelRunnerOutput,
109
+ sampled_token_ids: torch.Tensor,
110
+ invalid_req_indices: list[int],
111
+ async_output_copy_stream: torch.cuda.Stream,
112
+ ):
113
+ self._model_runner_output = model_runner_output
114
+ self._invalid_req_indices = invalid_req_indices
115
+
116
+ # Event on the copy stream so we can synchronize the non-blocking copy.
117
+ self._async_copy_ready_event = torch.cuda.Event()
118
+
119
+ # Keep a reference to the device tensor to avoid it being
120
+ # deallocated until we finish copying it to the host.
121
+ self._sampled_token_ids = sampled_token_ids
122
+
123
+ # Initiate the copy on a separate stream, but do not synchronize it.
124
+ default_stream = torch.cuda.current_stream()
125
+ with torch.cuda.stream(async_output_copy_stream):
126
+ async_output_copy_stream.wait_stream(default_stream)
127
+ self._sampled_token_ids_cpu = self._sampled_token_ids.to(
128
+ 'cpu', non_blocking=True)
129
+ self._async_copy_ready_event.record()
130
+
131
+ def get_output(self) -> ModelRunnerOutput:
132
+ """Copy the device tensors to the host and return a ModelRunnerOutput.
133
+
134
+ This function blocks until the copy is finished.
135
+ """
136
+ self._async_copy_ready_event.synchronize()
137
+
138
+ # Release the device tensor once the copy has completed
139
+ del self._sampled_token_ids
140
+
141
+ valid_sampled_token_ids = self._sampled_token_ids_cpu.tolist()
142
+ for i in self._invalid_req_indices:
143
+ valid_sampled_token_ids[i].clear()
144
+
145
+ output = self._model_runner_output
146
+ output.sampled_token_ids = valid_sampled_token_ids
147
+ return output
148
+
149
+
150
class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
151
152
def __init__(
@@ -230,6 +277,10 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
277
is_pooling_model=self.is_pooling_model,
278
)
279
280
+ self.use_async_scheduling = self.scheduler_config.async_scheduling
281
+ self.async_output_copy_stream = torch.cuda.Stream() if \
282
+ self.use_async_scheduling else None
283
+
284
# TODO(woosuk): Provide an option to tune the max cudagraph batch size.
285
# The convention is different.
286
# self.cudagraph_batch_sizes sorts in ascending order.
@@ -654,6 +705,73 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
705
706
return cu_num_tokens, arange
707
708
+ def _prepare_input_ids(self, total_num_scheduled_tokens: int,
709
+ cu_num_tokens: np.ndarray) -> None:
710
+ """Prepare the input IDs for the current batch.
711
+
712
+ Carefully handles the `prev_sampled_token_ids` which can be cached
713
+ from the previous engine iteration, in which case those tokens on the
714
+ GPU need to be copied into the corresponding slots into input_ids."""
715
+
716
+ if self.input_batch.prev_sampled_token_ids is None:
717
+ # Normal scheduling case
718
+ self.input_ids.copy_to_gpu(total_num_scheduled_tokens)
719
+ return
720
+
721
+ # Async scheduling case, where some decode requests from the previous
722
+ # iteration won't have entries in input_ids_cpu and need to be copied
723
+ # on the GPU from prev_sampled_token_ids.
724
+ prev_req_id_to_index = self.input_batch.prev_req_id_to_index
725
+ assert prev_req_id_to_index is not None
726
+ flattened_indices = []
727
+ prev_common_req_indices = []
728
+ indices_match = True
729
+ max_flattened_index = -1
730
+ for req_id, cur_index in self.input_batch.req_id_to_index.items():
731
+ if (prev_index := prev_req_id_to_index.get(req_id)) is not None:
732
+ prev_common_req_indices.append(prev_index)
733
+ # We need to compute the flattened input_ids index of the
734
+ # last token in each common request.
735
+ flattened_index = cu_num_tokens[cur_index].item() - 1
736
+ flattened_indices.append(flattened_index)
737
+ indices_match &= (prev_index == flattened_index)
738
+ max_flattened_index = max(max_flattened_index, flattened_index)
739
+ num_commmon_tokens = len(flattened_indices)
740
+ if num_commmon_tokens < total_num_scheduled_tokens:
741
+ # If not all requests are decodes from the last iteration,
742
+ # We need to copy the input_ids_cpu to the GPU first.
743
+ self.input_ids.copy_to_gpu(total_num_scheduled_tokens)
744
+ if num_commmon_tokens == 0:
745
+ # No requests in common with the previous iteration
746
+ # So input_ids_cpu will have all the input ids.
747
+ return
748
+ if indices_match and max_flattened_index == (num_commmon_tokens - 1):
749
+ # Common-case optimization: the batch is unchanged
750
+ # and no reordering happened.
751
+ # The indices are both the same permutation of 0..N-1 so
752
+ # we can copy directly using a single slice.
753
+ self.input_ids.gpu[:num_commmon_tokens].copy_(
754
+ self.input_batch.prev_sampled_token_ids[:num_commmon_tokens,
755
+ 0],
756
+ non_blocking=True)
757
+ return
758
+ # Upload the index tensors asynchronously
759
+ # so the scatter can be non-blocking.
760
+ input_ids_index_tensor = torch.tensor(flattened_indices,
761
+ dtype=torch.int64,
762
+ pin_memory=self.pin_memory).to(
763
+ self.device,
764
+ non_blocking=True)
765
+ prev_common_req_indices_tensor = torch.tensor(
766
+ prev_common_req_indices,
767
+ dtype=torch.int64,
768
+ pin_memory=self.pin_memory).to(self.device, non_blocking=True)
769
+ self.input_ids.gpu.scatter_(
770
+ dim=0,
771
+ index=input_ids_index_tensor,
772
+ src=self.input_batch.prev_sampled_token_ids[
773
+ prev_common_req_indices_tensor, 0])
774
+
775
def _prepare_inputs(
776
self,
777
scheduler_output: "SchedulerOutput",
@@ -740,7 +858,8 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
858
max_seq_len = self.seq_lens.np[:num_reqs].max().item()
859
860
# Copy the tensors to the GPU.
743
- self.input_ids.copy_to_gpu(total_num_scheduled_tokens)
861
+ self._prepare_input_ids(total_num_scheduled_tokens, cu_num_tokens)
862
+
863
if self.uses_mrope:
864
# Only relevant for models using M-RoPE (e.g, Qwen2-VL)
865
self.mrope_positions.gpu[:, :total_num_scheduled_tokens].copy_(
@@ -1458,7 +1577,7 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
1577
self,
1578
scheduler_output: "SchedulerOutput",
1579
intermediate_tensors: Optional[IntermediateTensors] = None,
1461
- ) -> Union[ModelRunnerOutput, IntermediateTensors]:
1580
+ ) -> Union[ModelRunnerOutput, AsyncModelRunnerOutput, IntermediateTensors]:
1581
self._update_states(scheduler_output)
1582
if not scheduler_output.total_num_scheduled_tokens:
1583
if not has_kv_transfer_group():
@@ -1673,6 +1792,12 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
1792
# so that we could clear the sampled tokens before returning.
1793
discard_sampled_tokens_req_indices.append(i)
1794
1795
+ # Copy some objects so they don't get modified after returning.
1796
+ # This is important when using async scheduling.
1797
+ req_ids_output_copy = self.input_batch.req_ids.copy()
1798
+ req_id_to_index_output_copy = \
1799
+ self.input_batch.req_id_to_index.copy()
1800
+
1801
# NOTE: GPU -> CPU Sync happens here.
1802
# Move as many CPU operations as possible before this sync point.
1803
logprobs_tensors = sampler_output.logprobs_tensors
@@ -1685,21 +1810,41 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
1810
scheduler_output.num_scheduled_tokens,
1811
)
1812
1688
- # Get the valid generated tokens.
1813
+ num_sampled_tokens = sampler_output.sampled_token_ids.shape[0]
1814
sampled_token_ids = sampler_output.sampled_token_ids
1690
- max_gen_len = sampled_token_ids.shape[-1]
1691
- if max_gen_len == 1:
1692
- # No spec decode tokens.
1693
- valid_sampled_token_ids = self._to_list(sampled_token_ids)
1815
+ if not self.use_async_scheduling:
1816
+ # Get the valid generated tokens.
1817
+ max_gen_len = sampled_token_ids.shape[-1]
1818
+ if max_gen_len == 1:
1819
+ # No spec decode tokens.
1820
+ valid_sampled_token_ids = self._to_list(sampled_token_ids)
1821
+ else:
1822
+ # Includes spec decode tokens.
1823
+ valid_sampled_token_ids = self.rejection_sampler.parse_output(
1824
+ sampled_token_ids,
1825
+ self.input_batch.vocab_size,
1826
+ )
1827
+ # Mask out the sampled tokens that should not be sampled.
1828
+ for i in discard_sampled_tokens_req_indices:
1829
+ valid_sampled_token_ids[i].clear()
1830
else:
1695
- # Includes spec decode tokens.
1696
- valid_sampled_token_ids = self.rejection_sampler.parse_output(
1697
- sampled_token_ids,
1698
- self.input_batch.vocab_size,
1699
- )
1700
- # Mask out the sampled tokens that should not be sampled.
1701
- for i in discard_sampled_tokens_req_indices:
1702
- valid_sampled_token_ids[i].clear()
1831
+ valid_sampled_token_ids = []
1832
+ invalid_req_indices = list(discard_sampled_tokens_req_indices)
1833
+ invalid_req_indices_set = set(invalid_req_indices)
1834
+ assert sampled_token_ids.shape[-1] == 1
1835
+
1836
+ # Cache the sampled tokens on the GPU and avoid CPU sync.
1837
+ # These will be copied into input_ids in the next step
1838
+ # when preparing inputs.
1839
+ self.input_batch.prev_sampled_token_ids = \
1840
+ sampled_token_ids
1841
+ self.input_batch.prev_sampled_token_ids_invalid_indices = \
1842
+ invalid_req_indices_set
1843
+ self.input_batch.prev_req_id_to_index = {
1844
+ req_id: i
1845
+ for i, req_id in enumerate(self.input_batch.req_ids)
1846
+ if i not in invalid_req_indices_set
1847
+ }
1848
1849
# Cache the sampled tokens in the model runner, so that the scheduler
1850
# doesn't need to send them back.
@@ -1707,7 +1852,12 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
1852
# the sampled tokens back, because there's no direct communication
1853
# between the first-stage worker and the last-stage worker.
1854
req_ids = self.input_batch.req_ids
1710
- for req_idx, sampled_ids in enumerate(valid_sampled_token_ids):
1855
+ for req_idx in range(num_sampled_tokens):
1856
+ if self.use_async_scheduling:
1857
+ sampled_ids = [-1] if \
1858
+ req_idx not in invalid_req_indices_set else None
1859
+ else:
1860
+ sampled_ids = valid_sampled_token_ids[req_idx]
1861
if not sampled_ids:
1862
continue
1863
@@ -1722,6 +1872,7 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
1872
start_idx:end_idx] = sampled_ids
1873
self.input_batch.num_tokens_no_spec[req_idx] = end_idx
1874
self.input_batch.num_tokens[req_idx] = end_idx
1875
+
1876
req_id = req_ids[req_idx]
1877
req_state = self.requests[req_id]
1878
req_state.output_token_ids.extend(sampled_ids)
@@ -1741,9 +1892,9 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
1892
1893
self.eplb_step()
1894
1744
- return ModelRunnerOutput(
1745
- req_ids=self.input_batch.req_ids,
1746
- req_id_to_index=self.input_batch.req_id_to_index,
1895
+ output = ModelRunnerOutput(
1896
+ req_ids=req_ids_output_copy,
1897
+ req_id_to_index=req_id_to_index_output_copy,
1898
sampled_token_ids=valid_sampled_token_ids,
1899
logprobs=logprobs_lists,
1900
prompt_logprobs_dict=prompt_logprobs_dict,
@@ -1752,6 +1903,16 @@ class GPUModelRunner(LoRAModelRunnerMixin, KVConnectorModelRunnerMixin):
1903
num_nans_in_logits=num_nans_in_logits,
1904
)
1905
1906
+ if not self.use_async_scheduling:
1907
+ return output
1908
+
1909
+ return AsyncGPUModelRunnerOutput(
1910
+ model_runner_output=output,
1911
+ sampled_token_ids=sampled_token_ids,
1912
+ invalid_req_indices=invalid_req_indices,
1913
+ async_output_copy_stream=self.async_output_copy_stream,
1914
+ )
1915
+
1916
def take_draft_token_ids(self) -> Optional[DraftTokenIds]:
1917
if self._draft_token_ids is None:
1918
return None
vllm/v1/worker/gpu_worker.py
+5
-5
@@ -5,7 +5,7 @@ import copy
5
import gc
6
import os
7
from contextlib import AbstractContextManager, nullcontext
8
-from typing import TYPE_CHECKING, Any, Optional
8
+from typing import TYPE_CHECKING, Any, Optional, Union
9
10
import torch
11
import torch.distributed
@@ -28,8 +28,8 @@ from vllm.tasks import SupportedTask
28
from vllm.utils import GiB_bytes, MemorySnapshot, memory_profiling
29
from vllm.v1.engine import ReconfigureDistributedRequest, ReconfigureRankType
30
from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheSpec
31
-from vllm.v1.outputs import (EMPTY_MODEL_RUNNER_OUTPUT, DraftTokenIds,
32
- ModelRunnerOutput)
31
+from vllm.v1.outputs import (EMPTY_MODEL_RUNNER_OUTPUT, AsyncModelRunnerOutput,
32
+ DraftTokenIds, ModelRunnerOutput)
33
from vllm.v1.utils import report_usage_stats
34
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
35
from vllm.v1.worker.worker_base import WorkerBase
@@ -355,7 +355,7 @@ class Worker(WorkerBase):
355
def execute_model(
356
self,
357
scheduler_output: "SchedulerOutput",
358
- ) -> Optional[ModelRunnerOutput]:
358
+ ) -> Optional[Union[ModelRunnerOutput, AsyncModelRunnerOutput]]:
359
intermediate_tensors = None
360
forward_pass = scheduler_output.total_num_scheduled_tokens > 0
361
if forward_pass and not get_pp_group().is_first_rank:
@@ -365,7 +365,7 @@ class Worker(WorkerBase):
365
366
output = self.model_runner.execute_model(scheduler_output,
367
intermediate_tensors)
368
- if isinstance(output, ModelRunnerOutput):
368
+ if isinstance(output, (ModelRunnerOutput, AsyncModelRunnerOutput)):
369
return output
370
371
assert isinstance(output, IntermediateTensors)