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 15, 23:30 UTC
[V1][PP] Run engine busy loop with batch queue (#13064)
Cody Yu committed
Feb 15, 2025 at 03:59 UTC
9206b3d7ecf5ac05ba22d45740609e570d3d171e
6 files changed
+299
-15
tests/v1/core/test_scheduler.py
+51
@@ -213,3 +213,54 @@ def test_schedule_partial_requests():
213
assert output.num_scheduled_tokens[requests[0].request_id] == 1
214
assert output.num_scheduled_tokens[requests[1].request_id] == 700
215
assert requests[2].request_id not in output.num_scheduled_tokens
216
+
217
+
218
+def test_schedule_concurrent_batches():
219
+ scheduler = create_scheduler(
220
+ max_num_batched_tokens=1024,
221
+ max_num_seqs=2,
222
+ )
223
+ requests = create_requests(
224
+ num_requests=2,
225
+ num_tokens=512,
226
+ )
227
+
228
+ # Schedule the first request.
229
+ scheduler.add_request(requests[0])
230
+ scheduler_output0 = scheduler.schedule()
231
+ assert len(scheduler_output0.scheduled_new_reqs) == 1
232
+ assert scheduler_output0.num_scheduled_tokens[
233
+ requests[0].request_id] == 512
234
+
235
+ # The first request is still running, so only schedule the second request.
236
+ scheduler.add_request(requests[1])
237
+ scheduler_output1 = scheduler.schedule()
238
+ assert len(scheduler_output1.scheduled_new_reqs) == 1
239
+ assert scheduler_output1.num_scheduled_tokens[
240
+ requests[1].request_id] == 512
241
+
242
+ # Model output of the first request.
243
+ model_runner_output = ModelRunnerOutput(
244
+ req_ids=[requests[0].request_id],
245
+ req_id_to_index={requests[0].request_id: 0},
246
+ sampled_token_ids=[0],
247
+ logprobs=None,
248
+ prompt_logprobs_dict={},
249
+ )
250
+ scheduler.update_from_output(scheduler_output0, model_runner_output)
251
+
252
+ # Schedule the next step.
253
+ # The first request can be scheduled again while the second
254
+ # request is still running.
255
+ scheduler_output2 = scheduler.schedule()
256
+ assert scheduler_output2.num_scheduled_tokens[requests[0].request_id] == 1
257
+
258
+ # Model output of the second request.
259
+ model_runner_output = ModelRunnerOutput(
260
+ req_ids=[requests[1].request_id],
261
+ req_id_to_index={requests[1].request_id: 0},
262
+ sampled_token_ids=[0],
263
+ logprobs=None,
264
+ prompt_logprobs_dict={},
265
+ )
266
+ scheduler.update_from_output(scheduler_output1, model_runner_output)
tests/v1/engine/test_engine_core.py
+88
-1
@@ -1,7 +1,10 @@
1
# SPDX-License-Identifier: Apache-2.0
2
3
+import copy
4
+import threading
5
import time
6
import uuid
7
+from concurrent.futures import Future
8
9
import pytest
10
from transformers import AutoTokenizer
@@ -12,7 +15,9 @@ from vllm.engine.arg_utils import EngineArgs
15
from vllm.platforms import current_platform
16
from vllm.v1.engine import EngineCoreRequest
17
from vllm.v1.engine.core import EngineCore
15
-from vllm.v1.executor.abstract import Executor
18
+from vllm.v1.executor.abstract import Executor, UniProcExecutor
19
+from vllm.v1.kv_cache_interface import KVCacheConfig
20
+from vllm.v1.outputs import ModelRunnerOutput
21
22
if not current_platform.is_cuda():
23
pytest.skip(reason="V1 currently only supported on CUDA.",
@@ -191,3 +196,85 @@ def test_engine_core_advanced_sampling(monkeypatch):
196
)
197
engine_core.add_request(request2)
198
_check_engine_state()
199
+
200
+
201
+@fork_new_process_for_each_test
202
+def test_engine_core_concurrent_batches(monkeypatch):
203
+ """
204
+ Test that the engine can handle multiple concurrent batches.
205
+ """
206
+
207
+ def make_request_with_max_tokens(max_tokens: int) -> EngineCoreRequest:
208
+ request = make_request()
209
+ request.sampling_params.max_tokens = max_tokens
210
+ return request
211
+
212
+ class DummyExecutor(UniProcExecutor):
213
+
214
+ def initialize(self, kv_cache_config: KVCacheConfig) -> None:
215
+ super().initialize(kv_cache_config)
216
+
217
+ # This executor actually can only run 1 batch at a time
218
+ self.semaphore = threading.Semaphore(1)
219
+
220
+ def execute_model(
221
+ self,
222
+ scheduler_output,
223
+ ) -> Future[ModelRunnerOutput]:
224
+ """Make execute_model non-blocking."""
225
+ future: Future[ModelRunnerOutput] = Future()
226
+
227
+ def _thread_wrapper(scheduler_output, future):
228
+ with self.semaphore:
229
+ output = self.collective_rpc("execute_model",
230
+ args=(scheduler_output, ))
231
+ # Make a copy because output[0] may be reused
232
+ # by the next batch.
233
+ output = copy.deepcopy(output[0])
234
+ future.set_result(output)
235
+
236
+ threading.Thread(target=_thread_wrapper,
237
+ args=(scheduler_output, future)).start()
238
+ return future
239
+
240
+ @property
241
+ def max_concurrent_batches(self) -> int:
242
+ return 2
243
+
244
+ with monkeypatch.context() as m:
245
+ m.setenv("VLLM_USE_V1", "1")
246
+
247
+ engine_args = EngineArgs(
248
+ model=MODEL_NAME,
249
+ # To test concurrent batches.
250
+ max_num_seqs=2,
251
+ # Avoid all requests being scheduled once.
252
+ enable_prefix_caching=False,
253
+ max_num_batched_tokens=10,
254
+ )
255
+ vllm_config = engine_args.create_engine_config()
256
+ engine_core = EngineCore(vllm_config=vllm_config,
257
+ log_stats=False,
258
+ executor_class=DummyExecutor)
259
+ assert engine_core.batch_queue is not None
260
+
261
+ # Add two requests in a row.
262
+ req = make_request_with_max_tokens(5)
263
+ engine_core.add_request(req)
264
+ req = make_request_with_max_tokens(5)
265
+ engine_core.add_request(req)
266
+
267
+ # First saturate the batch queue.
268
+ assert engine_core.step_with_batch_queue() is None
269
+ assert engine_core.batch_queue.qsize() == 1
270
+ assert engine_core.step_with_batch_queue() is None
271
+ assert engine_core.batch_queue.qsize() == 2
272
+ assert engine_core.scheduler.get_num_unfinished_requests() == 2
273
+
274
+ # Loop through both requests.
275
+ while engine_core.scheduler.get_num_unfinished_requests() == 2:
276
+ engine_core.step_with_batch_queue()
277
+
278
+ # Reaching here when got the result of the first request.
279
+ while engine_core.scheduler.get_num_unfinished_requests() == 1:
280
+ engine_core.step_with_batch_queue()
vllm/v1/core/scheduler.py
+17
@@ -58,6 +58,9 @@ class Scheduler:
58
# Priority queues for requests.
59
self.waiting: Deque[Request] = deque()
60
self.running: List[Request] = []
61
+ # The requests that have been scheduled and are being executed
62
+ # by the executor.
63
+ self.scheduled_req_ids: Set[str] = set()
64
65
# The request IDs that are finished in between the previous and the
66
# current steps. This is used to notify the workers about the finished
@@ -118,6 +121,11 @@ class Scheduler:
121
req_index = 0
122
while req_index < len(self.running) and token_budget > 0:
123
request = self.running[req_index]
124
+ if request.request_id in self.scheduled_req_ids:
125
+ # This request has already been scheduled.
126
+ req_index += 1
127
+ continue
128
+
129
num_new_tokens = request.num_tokens - request.num_computed_tokens
130
num_new_tokens = min(num_new_tokens, token_budget)
131
assert num_new_tokens > 0
@@ -164,6 +172,7 @@ class Scheduler:
172
173
# Schedule the request.
174
scheduled_running_reqs.append(request)
175
+ self.scheduled_req_ids.add(request.request_id)
176
req_to_new_block_ids[request.request_id] = [
177
b.block_id for b in new_blocks
178
]
@@ -251,6 +260,7 @@ class Scheduler:
260
261
self.waiting.popleft()
262
self.running.append(request)
263
+ self.scheduled_req_ids.add(request.request_id)
264
if request.status == RequestStatus.WAITING:
265
scheduled_new_reqs.append(request)
266
self.request_scheduled(request, scheduled_timestamp)
@@ -519,6 +529,7 @@ class Scheduler:
529
stop_reason=request.stop_reason,
530
events=request.take_events()))
531
532
+ self.scheduled_req_ids.remove(request.request_id)
533
if not stopped:
534
new_running.append(request)
535
@@ -575,6 +586,8 @@ class Scheduler:
586
587
if request.status == RequestStatus.RUNNING:
588
self.running.remove(request)
589
+ if request.request_id in self.scheduled_req_ids:
590
+ self.scheduled_req_ids.remove(request.request_id)
591
else:
592
self.waiting.remove(request)
593
request.status = finished_status
@@ -595,6 +608,10 @@ class Scheduler:
608
def has_unfinished_requests(self) -> bool:
609
return self.get_num_unfinished_requests() > 0
610
611
+ def get_num_unscheduled_requests(self) -> int:
612
+ """Number of requests that are not being processed by the executor."""
613
+ return self.get_num_unfinished_requests() - len(self.scheduled_req_ids)
614
+
615
def reset_prefix_cache(self) -> bool:
616
return self.kv_cache_manager.reset_prefix_cache()
617
vllm/v1/engine/core.py
+73
-6
@@ -4,8 +4,9 @@ import queue
4
import signal
5
import threading
6
import time
7
+from concurrent.futures import Future
8
from multiprocessing.connection import Connection
8
-from typing import Any, List, Tuple, Type
9
+from typing import Any, List, Optional, Tuple, Type
10
11
import psutil
12
import zmq
@@ -18,11 +19,12 @@ from vllm.transformers_utils.config import (
19
maybe_register_config_serialize_by_value)
20
from vllm.utils import get_exception_traceback, zmq_socket_ctx
21
from vllm.v1.core.kv_cache_utils import get_kv_cache_configs
21
-from vllm.v1.core.scheduler import Scheduler
22
+from vllm.v1.core.scheduler import Scheduler, SchedulerOutput
23
from vllm.v1.engine import (EngineCoreOutputs, EngineCoreRequest,
24
EngineCoreRequestType)
25
from vllm.v1.engine.mm_input_cache import MMInputCacheServer
26
from vllm.v1.executor.abstract import Executor
27
+from vllm.v1.outputs import ModelRunnerOutput
28
from vllm.v1.request import Request, RequestStatus
29
from vllm.v1.serial_utils import MsgpackDecoder, MsgpackEncoder
30
from vllm.version import __version__ as VLLM_VERSION
@@ -66,9 +68,22 @@ class EngineCore:
68
log_stats=self.log_stats,
69
)
70
71
+ # Setup MM Input Mapper.
72
self.mm_input_cache_server = MMInputCacheServer(
73
vllm_config.model_config)
74
75
+ # Setup batch queue for pipeline parallelism.
76
+ # Batch queue for scheduled batches. This enables us to asynchronously
77
+ # schedule and execute batches, and is required by pipeline parallelism
78
+ # to eliminate pipeline bubbles.
79
+ self.batch_queue_size = self.model_executor.max_concurrent_batches
80
+ self.batch_queue: Optional[queue.Queue[Tuple[Future[ModelRunnerOutput],
81
+ SchedulerOutput]]] = None
82
+ if self.batch_queue_size > 1:
83
+ logger.info("Batch queue is enabled with size %d",
84
+ self.batch_queue_size)
85
+ self.batch_queue = queue.Queue(self.batch_queue_size)
86
+
87
def _initialize_kv_caches(self,
88
vllm_config: VllmConfig) -> Tuple[int, int]:
89
start = time.time()
@@ -135,7 +150,55 @@ class EngineCore:
150
scheduler_output = self.scheduler.schedule()
151
output = self.model_executor.execute_model(scheduler_output)
152
engine_core_outputs = self.scheduler.update_from_output(
138
- scheduler_output, output)
153
+ scheduler_output, output) # type: ignore
154
+ return engine_core_outputs
155
+
156
+ def step_with_batch_queue(self) -> Optional[EngineCoreOutputs]:
157
+ """Schedule and execute batches with the batch queue.
158
+ Note that if nothing to output in this step, None is returned.
159
+
160
+ The execution flow is as follows:
161
+ 1. Try to schedule a new batch if there are unscheduled requests
162
+ and the job queue is not full. If a new batch is scheduled, directly
163
+ return an empty engine core output. In other words, we won't check
164
+ and return model outputs before the batch queue is full.
165
+ 2. If there is no new scheduled batch, meaning that the batch queue
166
+ is full or no other requests can be scheduled, we block until the first
167
+ batch in the job queue is finished.
168
+ 3. Update the scheduler from the output.
169
+ """
170
+ assert self.batch_queue is not None
171
+
172
+ engine_core_outputs = None
173
+ scheduler_output = None
174
+ # If there are unscheduled requests and the job queue
175
+ # is not full, schedule a new batch. Note that this is not blocking.
176
+ if (self.scheduler.get_num_unscheduled_requests() > 0
177
+ and not self.batch_queue.full()):
178
+ scheduler_output = self.scheduler.schedule()
179
+ if scheduler_output.total_num_scheduled_tokens > 0:
180
+ future = self.model_executor.execute_model(scheduler_output)
181
+ self.batch_queue.put_nowait(
182
+ (future, scheduler_output)) # type: ignore
183
+
184
+ # If all requests are scheduled or the job queue is full,
185
+ # block until the first batch in the job queue is finished.
186
+ if (scheduler_output is None
187
+ or scheduler_output.total_num_scheduled_tokens == 0):
188
+ try:
189
+ future, scheduler_output = self.batch_queue.get(
190
+ timeout=POLLING_TIMEOUT_S)
191
+ # Blocking until the first result is available.
192
+ model_output = future.result()
193
+ self.batch_queue.task_done()
194
+ engine_core_outputs = self.scheduler.update_from_output(
195
+ scheduler_output, model_output)
196
+ except queue.Empty:
197
+ # If the queue is empty (timeout at .get), return
198
+ # an empty EngineCoreOutputs for logging.
199
+ engine_core_outputs = EngineCoreOutputs(
200
+ outputs=[], scheduler_stats=self.scheduler.make_stats())
201
+
202
return engine_core_outputs
203
204
def shutdown(self):
@@ -226,6 +289,9 @@ class EngineCoreProc(EngineCore):
289
def run_busy_loop(self):
290
"""Core busy loop of the EngineCore."""
291
292
+ step_fn = (self.step
293
+ if self.batch_queue is None else self.step_with_batch_queue)
294
+
295
# Loop until process is sent a SIGINT or SIGTERM
296
while True:
297
# 1) Poll the input queue until there is work to do.
@@ -249,10 +315,11 @@ class EngineCoreProc(EngineCore):
315
self._handle_client_request(*req)
316
317
# 3) Step the engine core.
252
- outputs = self.step()
318
+ outputs = step_fn()
319
254
- # 5) Put EngineCoreOutputs into the output queue.
255
- self.output_queue.put_nowait(outputs)
320
+ # 4) Put EngineCoreOutputs into the output queue.
321
+ if outputs is not None:
322
+ self.output_queue.put_nowait(outputs)
323
324
def _handle_client_request(self, request_type: EngineCoreRequestType,
325
request: Any) -> None:
vllm/v1/executor/abstract.py
+9
-8
@@ -1,11 +1,10 @@
1
# SPDX-License-Identifier: Apache-2.0
2
3
-from typing import List, Type
3
+from concurrent.futures import Future
4
+from typing import List, Type, Union
5
6
from vllm.config import VllmConfig
7
from vllm.executor.executor_base import ExecutorBase
7
-from vllm.executor.ray_distributed_executor import ( # noqa
8
- RayDistributedExecutor as RayDistributedExecutorV0)
8
from vllm.executor.uniproc_executor import ( # noqa
9
ExecutorWithExternalLauncher as ExecutorWithExternalLauncherV0)
10
from vllm.executor.uniproc_executor import ( # noqa
@@ -33,6 +32,8 @@ class Executor(ExecutorBase):
32
f"ExecutorBase. Got {distributed_executor_backend}.")
33
executor_class = distributed_executor_backend
34
elif distributed_executor_backend == "ray":
35
+ from vllm.v1.executor.ray_distributed_executor import ( # noqa
36
+ RayDistributedExecutor)
37
executor_class = RayDistributedExecutor
38
elif distributed_executor_backend == "mp":
39
from vllm.v1.executor.multiproc_executor import MultiprocExecutor
@@ -70,11 +71,15 @@ class Executor(ExecutorBase):
71
def execute_model(
72
self,
73
scheduler_output,
73
- ) -> ModelRunnerOutput:
74
+ ) -> Union[ModelRunnerOutput, Future[ModelRunnerOutput]]:
75
output = self.collective_rpc("execute_model",
76
args=(scheduler_output, ))
77
return output[0]
78
79
+ @property
80
+ def max_concurrent_batches(self) -> int:
81
+ return 1
82
+
83
def profile(self, is_start: bool = True):
84
self.collective_rpc("profile", args=(is_start, ))
85
@@ -85,7 +90,3 @@ class UniProcExecutor(UniProcExecutorV0, Executor):
90
91
class ExecutorWithExternalLauncher(ExecutorWithExternalLauncherV0, Executor):
92
pass
88
-
89
-
90
-class RayDistributedExecutor(RayDistributedExecutorV0, Executor):
91
- pass
vllm/v1/executor/ray_distributed_executor.py
new
+61
@@ -0,0 +1,61 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+
3
+from concurrent.futures import Future
4
+from typing import Union
5
+
6
+from vllm.executor.ray_distributed_executor import ( # noqa
7
+ RayDistributedExecutor as RayDistributedExecutorV0)
8
+from vllm.v1.executor.abstract import Executor
9
+from vllm.v1.outputs import ModelRunnerOutput
10
+
11
+
12
+class FutureWrapper(Future):
13
+ """A wrapper around a Ray output reference to meet the interface
14
+ of .execute_model().
15
+ """
16
+
17
+ def __init__(self, ref):
18
+ super().__init__()
19
+ self.ref = ref
20
+
21
+ def result(self, timeout=None):
22
+ if timeout is not None:
23
+ raise NotImplementedError("timeout is not supported")
24
+ return self.ref.get()
25
+
26
+
27
+class RayDistributedExecutor(RayDistributedExecutorV0, Executor):
28
+ """Ray distributed executor using Ray Compiled Graphs."""
29
+
30
+ @property
31
+ def max_concurrent_batches(self) -> int:
32
+ """Ray distributed executor supports pipeline parallelism,
33
+ meaning that it allows PP size batches to be executed concurrently.
34
+ """
35
+ return 1 #self.vllm_config.parallel_config.pipeline_parallel_size
36
+
37
+ def execute_model(
38
+ self,
39
+ scheduler_output,
40
+ ) -> Union[ModelRunnerOutput, Future[ModelRunnerOutput]]:
41
+ """Execute the model on the Ray workers.
42
+
43
+ Args:
44
+ scheduler_output: The scheduler output to execute.
45
+
46
+ Returns:
47
+ The model runner output.
48
+ """
49
+ # Build the compiled DAG for the first time.
50
+ if self.forward_dag is None: # type: ignore
51
+ self.forward_dag = self._compiled_ray_dag(enable_asyncio=False)
52
+
53
+ refs = self.forward_dag.execute(scheduler_output) # type: ignore
54
+
55
+ # When PP is not used, we block here until the result is available.
56
+ if self.max_concurrent_batches == 1:
57
+ return refs[0].get()
58
+
59
+ # When PP is used, we return a FutureWrapper immediately so that
60
+ # the scheduler can yield to the next batch.
61
+ return FutureWrapper(refs[0])