Skip to content

Commit a3d7b9d

Browse files
authored
[core] feat: support async scheduling with structured outputs (#1582)
* [core] feat: support async scheduling with structured outputs Signed-off-by: AlpinDale <alpindale@gmail.com> * [cleanup] remove unused variable `num_scheduled_tokens` from GPUModelRunner Signed-off-by: AlpinDale <alpindale@gmail.com> --------- Signed-off-by: AlpinDale <alpindale@gmail.com>
1 parent 2b230ea commit a3d7b9d

25 files changed

Lines changed: 364 additions & 187 deletions

aphrodite/distributed/kv_transfer/kv_connector/utils.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from collections.abc import Sequence
66
from concurrent.futures import CancelledError, Future
7-
from typing import TYPE_CHECKING, Literal, cast
7+
from typing import TYPE_CHECKING, Literal
88

99
import torch
1010

@@ -138,8 +138,10 @@ def from_connector(cls, connector: "KVConnectorBase", world_size: int):
138138
return cls(connector.get_finished_count() or world_size)
139139

140140
def aggregate(
141-
self, outputs: list[ModelRunnerOutput], output_rank: int = 0
142-
) -> ModelRunnerOutput:
141+
self, outputs: list[ModelRunnerOutput | None], output_rank: int = 0
142+
) -> ModelRunnerOutput | None:
143+
if not outputs[output_rank]:
144+
return None
143145
# Aggregate kv_connector_output from all workers
144146

145147
def update_finished_set(
@@ -161,6 +163,7 @@ def update_finished_set(
161163
aggregated_kv_connector_stats = None
162164
invalid_block_ids = set[int]()
163165
for model_runner_output in outputs:
166+
assert model_runner_output is not None
164167
kv_output = model_runner_output.kv_connector_output
165168
if not kv_output:
166169
continue
@@ -204,6 +207,7 @@ def update_finished_set(
204207
# select output of the worker specified by output_rank
205208
output = outputs[output_rank]
206209

210+
assert output is not None
207211
output.kv_connector_output = KVConnectorOutput(
208212
finished_sending=finished_sending or None,
209213
finished_recving=finished_recving or None,
@@ -215,13 +219,16 @@ def update_finished_set(
215219
return output
216220

217221
def async_aggregate(
218-
self, output_futures: Sequence[Future[ModelRunnerOutput]], output_rank: int = 0
219-
) -> Future[ModelRunnerOutput]:
222+
self,
223+
output_futures: Sequence[Future[ModelRunnerOutput | None]],
224+
output_rank: int = 0,
225+
) -> Future[ModelRunnerOutput | None]:
220226
"""Takes a list of futures and returns a single future which resolves
221227
to the respective list of outputs."""
222-
result_future: Future[ModelRunnerOutput] = Future()
228+
result_future: Future[ModelRunnerOutput | None] = Future()
223229

224230
outputs: list[ModelRunnerOutput | None] = [None] * len(output_futures)
231+
remaining = len(output_futures)
225232

226233
def make_callback(idx):
227234
def callback(fut):
@@ -236,12 +243,10 @@ def callback(fut):
236243
result_future.set_exception(e)
237244

238245
# this check assumes io_thread_pool uses a single thread
239-
if all(outputs):
240-
result_future.set_result(
241-
self.aggregate(
242-
cast(list[ModelRunnerOutput], outputs), output_rank
243-
)
244-
)
246+
nonlocal remaining
247+
remaining -= 1
248+
if not remaining:
249+
result_future.set_result(self.aggregate(outputs, output_rank))
245250

246251
return callback
247252

aphrodite/v1/core/sched/async_scheduler.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ def _update_after_schedule(
1212
scheduler_output: SchedulerOutput,
1313
) -> None:
1414
super()._update_after_schedule(scheduler_output)
15+
pending_structured_output_tokens = False
1516
for req_id in scheduler_output.num_scheduled_tokens:
1617
request = self.requests[req_id]
18+
pending_structured_output_tokens |= request.use_structured_output and request.num_output_placeholders > 0
1719
if request.num_computed_tokens == request.num_tokens + request.num_output_placeholders:
1820
# The request will generate a new token in this scheduling step.
1921
# TODO(woosuk): Support speculative decoding.
2022
request.num_output_placeholders += 1
2123

24+
scheduler_output.pending_structured_output_tokens = pending_structured_output_tokens
25+
2226
def _update_request_with_output(
2327
self,
2428
request: Request,

aphrodite/v1/core/sched/interface.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
if TYPE_CHECKING:
66
from aphrodite.distributed.kv_transfer.kv_connector.v1 import KVConnectorBase_V1
7-
from aphrodite.v1.core.sched.output import SchedulerOutput
7+
from aphrodite.v1.core.sched.output import GrammarOutput, SchedulerOutput
88
from aphrodite.v1.engine import EngineCoreOutputs
99
from aphrodite.v1.metrics.stats import SchedulerStats
1010
from aphrodite.v1.outputs import DraftTokenIds, ModelRunnerOutput
@@ -38,6 +38,10 @@ def schedule(self) -> "SchedulerOutput":
3838
"""
3939
raise NotImplementedError
4040

41+
@abstractmethod
42+
def get_grammar_bitmask(self, scheduler_output: "SchedulerOutput") -> "GrammarOutput | None":
43+
raise NotImplementedError
44+
4145
@abstractmethod
4246
def update_from_output(
4347
self,

aphrodite/v1/core/sched/output.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,17 @@ class SchedulerOutput:
173173
# freed from the encoder cache.
174174
free_encoder_mm_hashes: list[str]
175175

176-
# ids of structured outputs requests included in the bitmask, in the
177-
# same order as the corresponding stacked rows of the bitmask.
178-
# There may be more than one row per request in the case of speculative decoding.
179-
structured_output_request_ids: list[str]
180-
# the bitmask for the whole batch
181-
grammar_bitmask: "npt.NDArray[np.int32] | None"
176+
# Whether the scheduled requests have all the output tokens they
177+
# need to perform grammar bitmask computation.
178+
pending_structured_output_tokens: bool = False
182179

183180
# KV Cache Connector metadata.
184181
kv_connector_metadata: KVConnectorMetadata | None = None
182+
183+
184+
@dataclass
185+
class GrammarOutput:
186+
# ids of structured output requests.
187+
structured_output_request_ids: list[str]
188+
# Bitmask ordered as structured_output_request_ids.
189+
grammar_bitmask: "npt.NDArray[np.int32]"

aphrodite/v1/core/sched/scheduler.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import time
77
from collections import defaultdict
88
from collections.abc import Iterable
9-
from typing import TYPE_CHECKING, Any
9+
from typing import Any
1010

1111
from aphrodite.config import AphroditeConfig
1212
from aphrodite.distributed.kv_events import EventPublisherFactory, KVEventBatch
@@ -18,7 +18,12 @@
1818
from aphrodite.v1.core.encoder_cache_manager import EncoderCacheManager, compute_encoder_budget
1919
from aphrodite.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager
2020
from aphrodite.v1.core.sched.interface import SchedulerInterface
21-
from aphrodite.v1.core.sched.output import CachedRequestData, NewRequestData, SchedulerOutput
21+
from aphrodite.v1.core.sched.output import (
22+
CachedRequestData,
23+
GrammarOutput,
24+
NewRequestData,
25+
SchedulerOutput,
26+
)
2227
from aphrodite.v1.core.sched.request_queue import SchedulingPolicy, create_request_queue
2328
from aphrodite.v1.core.sched.utils import check_stop, remove_all
2429
from aphrodite.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs
@@ -29,10 +34,6 @@
2934
from aphrodite.v1.spec_decode.metrics import SpecDecodingStats
3035
from aphrodite.v1.structured_output import StructuredOutputManager
3136

32-
if TYPE_CHECKING:
33-
import numpy as np
34-
import numpy.typing as npt
35-
3637
logger = init_logger(__name__)
3738

3839

@@ -669,9 +670,6 @@ def schedule(self) -> SchedulerOutput:
669670
scheduled_spec_decode_tokens,
670671
req_to_new_blocks,
671672
)
672-
structured_output_request_ids, grammar_bitmask = self.get_grammar_bitmask(
673-
num_scheduled_tokens.keys(), scheduled_spec_decode_tokens
674-
)
675673

676674
# Record the request ids that were scheduled in this step.
677675
self.prev_step_scheduled_req_ids.clear()
@@ -691,8 +689,6 @@ def schedule(self) -> SchedulerOutput:
691689
# the previous and the current steps.
692690
finished_req_ids=self.finished_req_ids,
693691
free_encoder_mm_hashes=self.encoder_cache_manager.get_freed_mm_hashes(),
694-
structured_output_request_ids=structured_output_request_ids,
695-
grammar_bitmask=grammar_bitmask,
696692
)
697693

698694
# NOTE(Kuntai): this function is designed for multiple purposes:
@@ -907,11 +903,7 @@ def _try_schedule_encoder_inputs(
907903
encoder_compute_budget,
908904
)
909905

910-
def get_grammar_bitmask(
911-
self,
912-
scheduled_request_ids: Iterable[str],
913-
scheduled_spec_decode_tokens: dict[str, list[int]],
914-
) -> tuple[list[str], npt.NDArray[np.int32] | None]:
906+
def get_grammar_bitmask(self, scheduler_output: SchedulerOutput) -> GrammarOutput | None:
915907
# Collect list of scheduled request ids that use structured output.
916908
# The corresponding rows of the bitmask will be in this order.
917909
# PERF: in case of chunked prefill,
@@ -920,18 +912,18 @@ def get_grammar_bitmask(
920912
# cycle to fill in the bitmask, which could be a big no-op.
921913
structured_output_request_ids = [
922914
req_id
923-
for req_id in scheduled_request_ids
915+
for req_id in scheduler_output.num_scheduled_tokens
924916
if (req := self.requests.get(req_id)) and req.use_structured_output
925917
]
926918
if not structured_output_request_ids:
927-
return structured_output_request_ids, None
919+
return None
928920

929921
bitmask = self.structured_output_manager.grammar_bitmask(
930922
self.requests,
931923
structured_output_request_ids,
932-
scheduled_spec_decode_tokens,
924+
scheduler_output.scheduled_spec_decode_tokens,
933925
)
934-
return structured_output_request_ids, bitmask
926+
return GrammarOutput(structured_output_request_ids, bitmask)
935927

936928
def update_from_output(
937929
self,

aphrodite/v1/engine/core.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from contextlib import ExitStack, contextmanager
1111
from inspect import isclass, signature
1212
from logging import DEBUG
13-
from typing import Any, TypeVar
13+
from typing import Any, TypeVar, cast
1414

1515
import msgspec
1616
import zmq
@@ -168,7 +168,7 @@ def __init__(
168168
if kv_connector is not None:
169169
# Collect and store KV connector xfer metadata from workers
170170
# (after KV cache registration)
171-
xfer_handshake_metadata = self.model_executor.get_kv_connector_handshake_metadata()
171+
xfer_handshake_metadata = self.modeling.get_kv_connector_handshake_metadata()
172172

173173
if xfer_handshake_metadata:
174174
# xfer_handshake_metadata is list of dicts from workers
@@ -305,9 +305,13 @@ def step(self) -> tuple[dict[int, EngineCoreOutputs], bool]:
305305
if not self.scheduler.has_requests():
306306
return {}, False
307307
scheduler_output = self.scheduler.schedule()
308+
future = self.modeling.execute_model(scheduler_output, non_block=True)
309+
grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output)
308310

309311
with self.log_error_detail(scheduler_output):
310-
model_output = self.modeling.execute_model(scheduler_output)
312+
model_output = future.result()
313+
if model_output is None:
314+
model_output = self.modeling.sample_tokens(grammar_output)
311315

312316
engine_core_outputs = self.scheduler.update_from_output(scheduler_output, model_output)
313317

@@ -345,16 +349,39 @@ def step_with_batch_queue(
345349
assert len(batch_queue) < self.batch_queue_size
346350

347351
model_executed = False
352+
deferred_scheduler_output = None
348353
if self.scheduler.has_requests():
349354
scheduler_output = self.scheduler.schedule()
350-
future = self.modeling.execute_model(scheduler_output, non_block=True)
351-
batch_queue.appendleft((future, scheduler_output))
355+
exec_future = self.modeling.execute_model(scheduler_output, non_block=True)
352356

353357
model_executed = scheduler_output.total_num_scheduled_tokens > 0
354-
if model_executed and len(batch_queue) < self.batch_queue_size and not batch_queue[-1][0].done():
355-
# Don't block on next worker response unless the queue is full
356-
# or there are no more requests to schedule.
357-
return None, True
358+
if scheduler_output.pending_structured_output_tokens:
359+
# We need to defer sampling until we have processed the model output
360+
# from the prior step.
361+
deferred_scheduler_output = scheduler_output
362+
# Block-wait for execute to return (continues running async on the GPU).
363+
with self.log_error_detail(scheduler_output):
364+
exec_result = exec_future.result()
365+
assert exec_result is None
366+
else:
367+
# We aren't waiting for any tokens, get any grammar output immediately.
368+
grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output)
369+
# Block-wait for execute to return (continues running async on the GPU).
370+
with self.log_error_detail(scheduler_output):
371+
exec_result = exec_future.result()
372+
373+
if exec_result is None:
374+
# Call sample tokens.
375+
future = self.modeling.sample_tokens(grammar_output, non_block=True)
376+
else:
377+
# No sampling required (e.g. all requests finished).
378+
future = cast(Future[ModelRunnerOutput], exec_future)
379+
# Add this step's future to the queue.
380+
batch_queue.appendleft((future, scheduler_output))
381+
if model_executed and len(batch_queue) < self.batch_queue_size and not batch_queue[-1][0].done():
382+
# Don't block on next worker response unless the queue is full
383+
# or there are no more requests to schedule.
384+
return None, True
358385

359386
elif not batch_queue:
360387
# Queue is empty. We should not reach here since this method should
@@ -368,6 +395,17 @@ def step_with_batch_queue(
368395
model_output = future.result()
369396

370397
engine_core_outputs = self.scheduler.update_from_output(scheduler_output, model_output)
398+
399+
# NOTE: We can either handle the deferred tasks here or save
400+
# in a field and do it immediately once step_with_batch_queue is
401+
# re-called. The latter slightly favors TTFT over TPOT/throughput.
402+
if deferred_scheduler_output:
403+
# We now have the tokens needed to compute the bitmask for the
404+
# deferred request. Get the bitmask and call sample tokens.
405+
grammar_output = self.scheduler.get_grammar_bitmask(deferred_scheduler_output)
406+
future = self.modeling.sample_tokens(grammar_output, non_block=True)
407+
batch_queue.appendleft((future, deferred_scheduler_output))
408+
371409
return engine_core_outputs, model_executed
372410

373411
def shutdown(self):

aphrodite/v1/executor/abstract.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from aphrodite.lora.request import LoRARequest
1313
from aphrodite.tasks import SupportedTask
1414
from aphrodite.utils.import_utils import resolve_obj_by_qualname
15-
from aphrodite.v1.core.sched.output import SchedulerOutput
15+
from aphrodite.v1.core.sched.output import GrammarOutput, SchedulerOutput
1616
from aphrodite.v1.engine import ReconfigureDistributedRequest
1717
from aphrodite.v1.kv_cache_interface import KVCacheConfig, KVCacheSpec
1818
from aphrodite.v1.outputs import DraftTokenIds, ModelRunnerOutput
@@ -175,28 +175,44 @@ def get_kv_connector_handshake_metadata(
175175

176176
@overload
177177
def execute_model(
178-
self,
179-
scheduler_output: SchedulerOutput,
180-
non_block: Literal[False] = False,
181-
) -> ModelRunnerOutput:
178+
self, scheduler_output: SchedulerOutput, non_block: Literal[False] = False
179+
) -> ModelRunnerOutput | None:
182180
pass
183181

184182
@overload
185183
def execute_model(
186-
self,
187-
scheduler_output: SchedulerOutput,
188-
non_block: Literal[True] = True,
189-
) -> Future[ModelRunnerOutput]:
184+
self, scheduler_output: SchedulerOutput, non_block: Literal[True] = True
185+
) -> Future[ModelRunnerOutput | None]:
190186
pass
191187

192188
def execute_model(
193189
self, scheduler_output: SchedulerOutput, non_block: bool = False
194-
) -> ModelRunnerOutput | Future[ModelRunnerOutput]:
190+
) -> ModelRunnerOutput | None | Future[ModelRunnerOutput | None]:
195191
output = self.collective_rpc( # type: ignore[call-overload]
196192
"execute_model", args=(scheduler_output,), non_block=non_block
197193
)
198194
return output[0]
199195

196+
@overload
197+
def sample_tokens(
198+
self, grammar_output: GrammarOutput | None, non_block: Literal[False] = False
199+
) -> ModelRunnerOutput:
200+
pass
201+
202+
@overload
203+
def sample_tokens(
204+
self, grammar_output: GrammarOutput | None, non_block: Literal[True] = True
205+
) -> Future[ModelRunnerOutput]:
206+
pass
207+
208+
def sample_tokens(
209+
self, grammar_output: GrammarOutput | None, non_block: bool = False
210+
) -> ModelRunnerOutput | Future[ModelRunnerOutput]:
211+
output = self.collective_rpc( # type: ignore[call-overload]
212+
"sample_tokens", args=(grammar_output,), non_block=non_block
213+
)
214+
return output[0]
215+
200216
def execute_dummy_batch(self) -> None:
201217
self.collective_rpc("execute_dummy_batch")
202218

0 commit comments

Comments
 (0)