@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / f5d3acd474
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 15, 19:45 UTC

[BugFix][V1] Fix parallel sampling finishing/aborts (#14512)

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

Nick Hill committed Mar 12, 2025 at 13:29 UTC f5d3acd47466f094beb36f7a5d05520466713f93
7 files changed +137 -113
tests/v1/engine/test_async_llm.py
+50 -6
@@ -46,6 +46,7 @@ async def generate(engine: AsyncLLM,
46 prompt: PromptType,
47 output_kind: RequestOutputKind,
48 max_tokens: int,
49 + n: int = 1,
50 prompt_logprobs: Optional[int] = None) -> tuple[int, str]:
51 # Ensure generate doesn't complete too fast for cancellation test.
52 await asyncio.sleep(0.2)
@@ -54,13 +55,15 @@ async def generate(engine: AsyncLLM,
55 sampling_params = SamplingParams(max_tokens=max_tokens,
56 ignore_eos=True,
57 output_kind=output_kind,
57 - temperature=0,
58 + temperature=0.5,
59 + seed=33,
60 + n=n,
61 prompt_logprobs=prompt_logprobs)
62 async for out in engine.generate(request_id=request_id,
63 prompt=prompt,
64 sampling_params=sampling_params):
65
63 - num_tokens = len(out.outputs[0].token_ids)
66 + num_tokens = sum(len(output.token_ids) for output in out.outputs)
67 if output_kind == RequestOutputKind.DELTA:
68 count += num_tokens
69 else:
@@ -136,17 +139,22 @@ async def test_abort(monkeypatch, output_kind: RequestOutputKind,
139
140 NUM_REQUESTS = 100
141 NUM_EXPECTED_TOKENS = 100
142 + NUM_EXPECTED_TOKENS_LONG = 50000
143 REQUEST_IDS_TO_ABORT = range(1, 100, 10)
144 + PARALLEL_SAMPLE_REQ_IDS = range(1, 100, 15)
145
146 request_ids = [f"request-{i}" for i in range(NUM_REQUESTS)]
147
148 # Create concurrent requests.
149 tasks: list[asyncio.Task] = []
145 - for request_id in request_ids:
150 + for idx, request_id in enumerate(request_ids):
151 + max_tokens = NUM_EXPECTED_TOKENS_LONG if (
152 + idx in REQUEST_IDS_TO_ABORT) else NUM_EXPECTED_TOKENS
153 + n = 3 if idx in PARALLEL_SAMPLE_REQ_IDS else 1
154 tasks.append(
155 asyncio.create_task(
156 generate(engine, request_id, prompt, output_kind,
149 - NUM_EXPECTED_TOKENS)))
157 + max_tokens, n)))
158
159 # API server cancels requests when they disconnect.
160 for idx in REQUEST_IDS_TO_ABORT:
@@ -162,10 +170,13 @@ async def test_abort(monkeypatch, output_kind: RequestOutputKind,
170 else:
171 # Otherwise, make sure the request was not impacted.
172 num_generated_tokens, request_id = await task
165 - assert num_generated_tokens == NUM_EXPECTED_TOKENS, (
173 + n = 3 if idx in PARALLEL_SAMPLE_REQ_IDS else 1
174 + expected_tokens = NUM_EXPECTED_TOKENS * n
175 + assert num_generated_tokens == expected_tokens, (
176 f"{request_id} generated {num_generated_tokens} but "
167 - f"expected {NUM_EXPECTED_TOKENS}")
177 + f"expected {expected_tokens}")
178
179 + # Make sure all aborted requests were really aborted.
180 assert not engine.output_processor.has_unfinished_requests()
181
182 # Confirm we can do another generation.
@@ -176,3 +187,36 @@ async def test_abort(monkeypatch, output_kind: RequestOutputKind,
187 num_generated_tokens, request_id = await task
188 assert num_generated_tokens == NUM_EXPECTED_TOKENS
189 assert not engine.output_processor.has_unfinished_requests()
190 +
191 +
192 +@pytest.mark.parametrize("n", [1, 3])
193 +@pytest.mark.parametrize("engine_args_and_prompt",
194 + [(TEXT_ENGINE_ARGS, TEXT_PROMPT),
195 + (VISION_ENGINE_ARGS, VISION_PROMPT)])
196 +@pytest.mark.asyncio
197 +async def test_finished_flag(monkeypatch, n: int,
198 + engine_args_and_prompt: tuple[AsyncEngineArgs,
199 + PromptType]):
200 +
201 + with monkeypatch.context() as m, ExitStack() as after:
202 + m.setenv("VLLM_USE_V1", "1")
203 + engine_args, prompt = engine_args_and_prompt
204 +
205 + engine = AsyncLLM.from_engine_args(engine_args)
206 + after.callback(engine.shutdown)
207 +
208 + sampling_params = SamplingParams(max_tokens=100,
209 + output_kind=RequestOutputKind.DELTA,
210 + temperature=1.0,
211 + seed=33,
212 + n=n)
213 + outputs = [
214 + out
215 + async for out in engine.generate(request_id="request-33",
216 + prompt=prompt,
217 + sampling_params=sampling_params)
218 + ]
219 +
220 + # Assert only the last output has the finished flag set
221 + assert all(not out.finished for out in outputs[:-1])
222 + assert outputs[-1].finished
tests/v1/entrypoints/openai/test_completion.py
+15 -6
@@ -263,15 +263,16 @@ async def test_parallel_no_streaming(client: openai.AsyncOpenAI,
263
264 prompt = "What is an LLM?"
265 n = 3
266 - max_tokens = 5
266 + max_tokens = 50 # we want some to finish earlier than others
267
268 # High temperature to maximize chance of unique completions.
269 completion = await client.completions.create(model=model_name,
270 prompt=prompt,
271 max_tokens=max_tokens,
272 n=n,
273 - temperature=0.95,
273 + temperature=1.0,
274 stream=False,
275 + logprobs=0,
276 seed=42)
277
278 # Assert `n` completions
@@ -279,6 +280,7 @@ async def test_parallel_no_streaming(client: openai.AsyncOpenAI,
280 assert num_completions == n, (
281 f"Num completions {num_completions} but expected {n}.")
282 completion_repeats: dict[str, int] = {}
283 + output_token_lengths = set()
284 for idx, choice in enumerate(completion.choices):
285 # Assert correct completion index & some finish reason.
286 assert choice.index == idx, (
@@ -287,6 +289,9 @@ async def test_parallel_no_streaming(client: openai.AsyncOpenAI,
289 "None finish_reason is invalid.")
290 text = choice.text
291 completion_repeats[text] = completion_repeats.get(text, 0) + 1
292 + output_token_lengths.add(len(choice.logprobs.tokens))
293 + # Assert subrequests finished at different times
294 + assert len(output_token_lengths) > 1
295 # Assert `n` unique completions
296 num_unique = len(completion_repeats)
297 if num_unique != n:
@@ -312,16 +317,16 @@ async def test_parallel_streaming(client: openai.AsyncOpenAI, model_name: str):
317
318 prompt = "What is an LLM?"
319 n = 3
315 - max_tokens = 5
320 + max_tokens = 50 # we want some to finish earlier than others
321
322 stream = await client.completions.create(model=model_name,
323 prompt=prompt,
324 max_tokens=max_tokens,
325 n=n,
321 - temperature=0.95,
326 + temperature=1.0,
327 stream=True,
328 seed=42)
324 - chunks: list[list[str]] = [[] for i in range(n)]
329 + chunks: list[list[str]] = [[] for _ in range(n)]
330 finish_reason_count = 0
331 async for chunk in stream:
332 index = chunk.choices[0].index
@@ -333,14 +338,18 @@ async def test_parallel_streaming(client: openai.AsyncOpenAI, model_name: str):
338 assert finish_reason_count == n, (
339 f"Expected {n} completions with valid indices and finish_reason.")
340 completion_repeats: dict[str, int] = {}
341 + chunk_lengths = set()
342 for chunk in chunks:
343 chunk_len = len(chunk)
344 # Assert correct number of completion tokens
339 - assert chunk_len == max_tokens, (
345 + chunk_lengths.add(chunk_len)
346 + assert chunk_len <= max_tokens, (
347 f"max_tokens={max_tokens} but chunk len is {chunk_len}.")
348 text = "".join(chunk)
349 completion_repeats[text] = completion_repeats.get(text, 0) + 1
350 print(text)
351 + # Assert subrequests finished at different times
352 + assert len(chunk_lengths) > 1
353 # Assert `n` unique completions
354 num_unique = len(completion_repeats)
355 if num_unique != n:
vllm/outputs.py
+18 -46
@@ -134,57 +134,29 @@ class RequestOutput:
134 self.encoder_prompt_token_ids = encoder_prompt_token_ids
135 self.num_cached_tokens = num_cached_tokens
136
137 - @classmethod
138 - def new(
139 - cls,
140 - request_id: str,
141 - prompt: Optional[str],
142 - prompt_token_ids: Optional[list[int]],
143 - text: str,
144 - token_ids: list[int],
145 - logprobs: Optional[SampleLogprobs],
146 - prompt_logprobs: Optional[PromptLogprobs],
147 - cumulative_logprob: Optional[float],
148 - finished: bool = False,
149 - ) -> "RequestOutput":
150 - """Initialize a new RequestOutput object."""
151 -
152 - # TODO: Support `n` > 1.
153 - completion_output = CompletionOutput(
154 - index=0,
155 - text=text,
156 - token_ids=token_ids,
157 - cumulative_logprob=cumulative_logprob,
158 - logprobs=logprobs)
159 -
160 - return RequestOutput(
161 - request_id=request_id,
162 - prompt=prompt,
163 - prompt_token_ids=prompt_token_ids,
164 - prompt_logprobs=prompt_logprobs,
165 - outputs=[completion_output],
166 - finished=finished,
167 - )
168 -
137 def add(self, next_output: "RequestOutput") -> None:
138 """Merge subsequent RequestOutput into this one"""
139
172 - self.prompt = next_output.prompt
173 - self.prompt_token_ids = next_output.prompt_token_ids
174 - self.prompt_logprobs = next_output.prompt_logprobs
140 self.finished |= next_output.finished
141
177 - #TODO assuming n == 1 for now
178 - completion = self.outputs[0]
179 - next_completion = next_output.outputs[0]
180 - completion.text += next_completion.text
181 - if not isinstance(completion.token_ids, MutableSequence):
182 - completion.token_ids = list(completion.token_ids)
183 - completion.token_ids.extend(next_completion.token_ids)
184 - if next_completion.logprobs:
185 - assert completion.logprobs is not None
186 - completion.logprobs.extend(next_completion.logprobs)
187 - completion.cumulative_logprob = next_completion.cumulative_logprob
142 + for next_completion in next_output.outputs:
143 + for completion in self.outputs:
144 + if completion.index == next_completion.index:
145 + # Merge outputs with same index
146 + completion.text += next_completion.text
147 + if not isinstance(completion.token_ids, MutableSequence):
148 + completion.token_ids = list(completion.token_ids)
149 + completion.token_ids.extend(next_completion.token_ids)
150 + if next_completion.logprobs:
151 + assert completion.logprobs is not None
152 + completion.logprobs.extend(next_completion.logprobs)
153 + completion.cumulative_logprob = (
154 + next_completion.cumulative_logprob)
155 + completion.finish_reason = next_completion.finish_reason
156 + completion.stop_reason = next_completion.stop_reason
157 + break
158 + else:
159 + self.outputs.append(next_completion)
160
161 @classmethod
162 def from_seq_group(
vllm/v1/engine/async_llm.py
+1 -2
@@ -298,9 +298,8 @@ class AsyncLLM(EngineClient):
298 async def abort(self, request_id: str) -> None:
299 """Abort RequestId in OutputProcessor and EngineCore."""
300
301 - request_ids = [request_id]
301 + request_ids = self.output_processor.abort_requests((request_id, ))
302 await self.engine_core.abort_requests_async(request_ids)
303 - self.output_processor.abort_requests(request_ids)
303
304 if self.log_requests:
305 logger.info("Aborted request %s.", request_id)
vllm/v1/engine/llm_engine.py
+1 -1
@@ -137,8 +137,8 @@ class LLMEngine:
137 def abort_request(self, request_ids: list[str]) -> None:
138 """Remove request_ids from EngineCore and Detokenizer."""
139
140 + request_ids = self.output_processor.abort_requests(request_ids)
141 self.engine_core.abort_requests(request_ids)
141 - self.output_processor.abort_requests(request_ids)
142
143 def add_request(
144 self,
vllm/v1/engine/output_processor.py
+30 -19
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2
3 import asyncio
4 +from collections.abc import Iterable
5 from dataclasses import dataclass
6 from typing import Optional, Union
7
@@ -102,8 +103,7 @@ class RequestState:
103 ) -> Optional[RequestOutput]:
104
105 finished = finish_reason is not None
105 - output_kind = self.output_kind
106 - final_only = output_kind == RequestOutputKind.FINAL_ONLY
106 + final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
107
108 # In follow up, we will switch to invariant where EngineCore
109 # does not stream partial prefills.
@@ -111,24 +111,24 @@ class RequestState:
111 # Only the final output is required in FINAL_ONLY mode.
112 return None
113
114 - def new_request_output(request_id: str) -> RequestOutput:
115 - return self._new_request_output(request_id, finished)
116 -
114 completion_output = self._new_completion_output(
115 new_token_ids, finish_reason, stop_reason)
116
120 - if self.parent_req is not None:
121 - return self.parent_req.make_request_output(final_only,
122 - completion_output,
123 - new_request_output)
117 + request_id = self.request_id
118 + if self.parent_req is None:
119 + outputs = [completion_output]
120 + else:
121 + request_id, outputs, finished = self.parent_req.get_outputs(
122 + request_id, completion_output)
123 + if not outputs:
124 + return None
125
125 - request_output = new_request_output(self.request_id)
126 - request_output.outputs.append(completion_output)
127 - return request_output
126 + return self._new_request_output(request_id, outputs, finished)
127
128 def _new_request_output(
129 self,
130 request_id: str,
131 + outputs: list[CompletionOutput],
132 finished: bool,
133 ) -> RequestOutput:
134
@@ -143,7 +143,7 @@ class RequestState:
143 prompt=self.prompt,
144 prompt_token_ids=self.prompt_token_ids,
145 prompt_logprobs=prompt_logprobs,
146 - outputs=[],
146 + outputs=outputs,
147 finished=finished,
148 )
149
@@ -188,6 +188,7 @@ class OutputProcessor:
188 self.log_stats = log_stats
189 self.tokenizer = tokenizer
190 self.request_states: dict[str, RequestState] = {}
191 + self.parent_requests: dict[str, ParentRequest] = {}
192 self.lora_states = LoRARequestStates()
193
194 def get_num_unfinished_requests(self):
@@ -198,14 +199,20 @@ class OutputProcessor:
199
200 def abort_requests(
201 self,
201 - request_ids: list[str],
202 - ) -> None:
202 + request_ids: Iterable[str],
203 + ) -> list[str]:
204 + request_ids_to_abort = []
205 for request_id in request_ids:
206 req_state = self.request_states.pop(request_id, None)
207 if req_state is not None:
208 self.lora_states.abort_request(req_state)
207 - if req_state.parent_req is not None:
208 - req_state.parent_req.finish_child_request(request_id)
209 + request_ids_to_abort.append(request_id)
210 + else:
211 + parent = self.parent_requests.pop(request_id, None)
212 + if parent and parent.child_requests:
213 + self.abort_requests(parent.child_requests)
214 + request_ids_to_abort.extend(parent.child_requests)
215 + return request_ids_to_abort
216
217 def add_request(
218 self,
@@ -227,6 +234,8 @@ class OutputProcessor:
234 log_stats=self.log_stats)
235 self.request_states[request_id] = req_state
236 self.lora_states.add_request(req_state)
237 + if parent_req:
238 + self.parent_requests[parent_req.request_id] = parent_req
239
240 def process_outputs(
241 self,
@@ -314,12 +323,14 @@ class OutputProcessor:
323 # Free completed requests.
324 if finish_reason is not None:
325 self.request_states.pop(req_id)
326 + # Remove parent request if applicable.
327 + parent_req = req_state.parent_req
328 + if parent_req and not parent_req.child_requests:
329 + self.parent_requests.pop(parent_req.request_id, None)
330 if not engine_core_output.finished:
331 # If req not finished in EngineCore, but Detokenizer
332 # detected stop string, abort needed in EngineCore.
333 reqs_to_abort.append(req_id)
321 - if req_state.parent_req is not None:
322 - req_state.parent_req.finish_child_request(req_id)
334
335 # Track per-request stats
336 self._update_stats_from_finished(req_state, finish_reason,
vllm/v1/engine/parallel_sampling.py
+22 -33
@@ -1,11 +1,11 @@
1 # SPDX-License-Identifier: Apache-2.0
2
3 from copy import copy
4 -from typing import Callable, Optional, Union
4 +from typing import Optional, Union
5
6 -from vllm.outputs import CompletionOutput, RequestOutput
6 +from vllm.outputs import CompletionOutput
7 from vllm.pooling_params import PoolingParams
8 -from vllm.sampling_params import SamplingParams
8 +from vllm.sampling_params import RequestOutputKind, SamplingParams
9 from vllm.v1.metrics.stats import IterationStats
10
11
@@ -23,7 +23,7 @@ class ParentRequest:
23 child_requests: set[str]
24
25 # To aggregate child completions when not streaming
26 - output_aggregator: Optional[RequestOutput]
26 + output_aggregator: list[CompletionOutput]
27
28 # To find the max number of generated tokens across all children
29 max_num_generation_tokens: int
@@ -37,7 +37,9 @@ class ParentRequest:
37 self.sampling_params = sampling_params
38
39 self.child_requests = set()
40 - self.output_aggregator = None
40 + self.output_aggregator = [None] * sampling_params.n if (
41 + sampling_params.output_kind
42 + == RequestOutputKind.FINAL_ONLY) else []
43 self.max_num_generation_tokens = 0
44 self.cached_child_sampling_params = None
45
@@ -93,43 +95,30 @@ class ParentRequest:
95 """
96 child_req_id = f"{index}_{self.request_id}"
97 self.child_requests.add(child_req_id)
96 - return (child_req_id, self._get_child_sampling_params(index))
97 -
98 - def finish_child_request(self, req_id: str):
99 - self.child_requests.remove(req_id)
98 + return child_req_id, self._get_child_sampling_params(index)
99
100 @property
101 def n(self) -> int:
102 return self.sampling_params.n
103
105 - def make_request_output(
104 + def get_outputs(
105 self,
107 - final_only: bool,
106 + child_request_id: str,
107 completion_output: CompletionOutput,
109 - new_request_output: Callable[[str], RequestOutput],
110 - ) -> Optional[RequestOutput]:
111 - # Use an existing RequestOutput if we're aggregating
112 - request_output = self.output_aggregator
113 -
114 - # Make new RequestOutput otherwise
115 - if request_output is None:
116 - request_output = new_request_output(self.request_id)
117 -
118 - # Add a new completion
119 - request_output.outputs.append(completion_output)
108 + ) -> tuple[str, list[CompletionOutput], bool]:
109 + if completion_output.finished():
110 + self.child_requests.remove(child_request_id)
111
121 - # If not streaming, aggregate until all child requests complete
122 - if final_only and len(request_output.outputs) != self.n:
123 - self.output_aggregator = request_output
124 - return None
125 -
126 - # We're done aggregating
127 - self.output_aggregator = None
112 + if self.sampling_params.output_kind != RequestOutputKind.FINAL_ONLY:
113 + # If streaming, just return the current output.
114 + outputs = [completion_output]
115 + else:
116 + # If not streaming, aggregate the n final outputs.
117 + self.output_aggregator[completion_output.index] = completion_output
118 + outputs = [] if self.child_requests else self.output_aggregator
119
129 - # Parent completion output list must be sorted by index
130 - request_output.outputs = sorted(request_output.outputs,
131 - key=lambda x: x.index)
132 - return request_output
120 + finished = not self.child_requests
121 + return self.request_id, outputs, finished
122
123 def observe_num_generation_tokens(self, num_generation_tokens: int):
124 self.max_num_generation_tokens = max(num_generation_tokens,