main
py 366 lines 13.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import itertools
5 from collections.abc import Iterable
6 from dataclasses import dataclass
7
8 import numpy as np
9
10 from vllm.logger import init_logger
11 from vllm.logprobs import (
12 FlatLogprobs,
13 PromptLogprobs,
14 SampleLogprobs,
15 append_logprobs_for_next_position,
16 create_prompt_logprobs,
17 create_sample_logprobs,
18 )
19 from vllm.tokenizers.detokenizer_utils import (
20 TokenizerLike,
21 convert_ids_list_to_tokens,
22 )
23 from vllm.v1.engine import EngineCoreOutput, EngineCoreRequest
24 from vllm.v1.outputs import LogprobsLists, LogprobsTensors
25
26 logger = init_logger(__name__)
27
28 NONES = itertools.repeat(None)
29
30
31 @dataclass
32 class LogprobsProcessor:
33 # Tokenizer for this request,
34 # None if detokenization is disabled.
35 tokenizer: TokenizerLike | None
36
37 # Logprobs for this request
38 logprobs: SampleLogprobs | None
39 prompt_logprobs: PromptLogprobs | None
40 cumulative_logprob: float | None
41 num_logprobs: int | None
42 num_prompt_logprobs: int | None
43 # [num_scored_rows, num_token_ids], set once on the final prefill chunk.
44 prompt_token_id_logprobs: np.ndarray | None = None
45
46 @classmethod
47 def from_new_request(
48 cls,
49 tokenizer: TokenizerLike | None,
50 request: EngineCoreRequest,
51 ) -> "LogprobsProcessor":
52 sampling_params = request.sampling_params
53 assert sampling_params is not None
54 num_logprobs = sampling_params.num_logprobs
55 num_prompt_logprobs = sampling_params.prompt_logprobs
56 return cls(
57 tokenizer=tokenizer,
58 cumulative_logprob=(None if num_logprobs is None else 0.0),
59 logprobs=(
60 None
61 if num_logprobs is None
62 else create_sample_logprobs(sampling_params.flat_logprobs)
63 ),
64 prompt_logprobs=(
65 None
66 if num_prompt_logprobs is None
67 else create_prompt_logprobs(sampling_params.flat_logprobs)
68 ),
69 num_prompt_logprobs=num_prompt_logprobs,
70 num_logprobs=num_logprobs,
71 )
72
73 def _update_sample_logprobs(self, logprobs_lists: LogprobsLists) -> None:
74 """Update with sample logprobs from EngineCore.
75
76 Outer lists are only of len > 1 if EngineCore made
77 >1 tokens in prior step (e.g. in spec decoding).
78
79 Args:
80 logprobs_lists: the lists of logprob tokens, logprobs, and ranks.
81
82 """
83 assert self.num_logprobs is not None
84 assert self.logprobs is not None
85 assert self.cumulative_logprob is not None
86
87 token_ids_lst, logprobs_lst, ranks_lst, _ = logprobs_lists
88
89 for rank_np, logprobs_np, token_ids_np in zip(
90 ranks_lst, logprobs_lst, token_ids_lst
91 ):
92 rank = rank_np.tolist()
93 logprobs = logprobs_np.tolist()
94 token_ids = token_ids_np.tolist()
95 # Detokenize (non-incrementally).
96 decoded_tokens: list[str] | Iterable[None]
97 if self.tokenizer is None:
98 decoded_tokens = NONES
99 else:
100 decoded_tokens_list = convert_ids_list_to_tokens(
101 self.tokenizer, token_ids
102 )
103 context_token_ids = self._get_sampled_context_ids(self.logprobs)
104 decoded_tokens = self._verify_tokens(
105 decoded_tokens_list=decoded_tokens_list,
106 tokens=token_ids,
107 context_token_ids=context_token_ids,
108 )
109
110 # Sampler puts the sampled logprob in first.
111 sampled_token_logprob = logprobs[0]
112 self.cumulative_logprob += sampled_token_logprob
113
114 # Update with the Logprob container for this pos.
115 append_logprobs_for_next_position(
116 self.logprobs,
117 token_ids,
118 logprobs,
119 decoded_tokens,
120 rank,
121 self.num_logprobs,
122 )
123
124 def _update_prompt_logprobs(
125 self,
126 prompt_logprobs_tensors: LogprobsTensors,
127 ) -> None:
128 """Update with prompt logprobs from EngineCore.
129
130 Args:
131 prompt_logprobs_tensors: tuple containing the prompt logprobs
132 tensors.
133
134 """
135 # Prompt logprobs are enabled.
136 assert self.num_prompt_logprobs is not None
137 assert self.prompt_logprobs is not None
138
139 token_ids, logprobs, ranks, *_ = prompt_logprobs_tensors
140
141 # Recover shapes.
142 num_prompt_tokens, num_logprobs = logprobs.shape
143
144 # Detokenize non-incrementally.
145 # Output is flat: [num_tok, num_lps] -> [num_tok * num_lps]
146 all_decoded_tokens: list[str] | None = (
147 None
148 if self.tokenizer is None
149 else convert_ids_list_to_tokens(
150 self.tokenizer, token_ids.flatten().tolist()
151 )
152 )
153
154 # Pythonize the torch tensors.
155 prompt_token_ranks = ranks.tolist()
156 prompt_logprobs = logprobs.tolist()
157 token_ids_list = token_ids.tolist()
158
159 # Make Logprob for each position.
160 for pos in range(num_prompt_tokens):
161 # Handle flattening and UTF-8 correction per position
162 offset = pos * num_logprobs
163 offset_end = offset + num_logprobs
164
165 decoded_tokens_for_pos: list[str] | Iterable[None]
166 if all_decoded_tokens is None:
167 decoded_tokens_for_pos = NONES
168 else:
169 # Extract decoded tokens for this position
170 decoded_tokens_slice = all_decoded_tokens[offset:offset_end]
171 # Context: preceding prompt tokens accumulated in
172 # self.prompt_logprobs from previous loop iterations.
173 context_token_ids = self._get_sampled_context_ids(self.prompt_logprobs)
174 # Apply UTF-8 correction within this position's token boundaries
175 decoded_tokens_for_pos = self._verify_tokens(
176 decoded_tokens_list=decoded_tokens_slice,
177 tokens=token_ids_list[pos],
178 context_token_ids=context_token_ids,
179 )
180
181 # Update with the Logprob container for this pos.
182 append_logprobs_for_next_position(
183 self.prompt_logprobs,
184 token_ids_list[pos],
185 prompt_logprobs[pos],
186 decoded_tokens_for_pos,
187 prompt_token_ranks[pos],
188 self.num_prompt_logprobs,
189 )
190
191 def pop_prompt_logprobs(self) -> PromptLogprobs | None:
192 """Pop and return all request prompt logprobs.
193
194 The logprobs processor aggregates prompt chunk logprobs
195 over one or more prefill chunks. This method returns
196 all prompt logprobs at once and then forgets them.
197 Ensures correct RequestOutputKind.DELTA semantics
198 wherein all prompt logprobs are returned at once at
199 the end of prefill.
200
201 Returns:
202 None if prompt logprobs are disabled for this request.
203 List of all prompt logprobs, otherwise.
204
205 """
206 plp = self.prompt_logprobs
207 if plp:
208 self.prompt_logprobs = []
209 return plp
210
211 def pop_prompt_token_id_logprobs(self) -> np.ndarray | None:
212 """Pop and return the fixed-ID prompt scores."""
213 scores = self.prompt_token_id_logprobs
214 self.prompt_token_id_logprobs = None
215 return scores
216
217 @staticmethod
218 def _get_sampled_context_ids(
219 logprobs_source: SampleLogprobs | PromptLogprobs | None,
220 max_context: int = 4,
221 ) -> list[int]:
222 """Extract recent sampled token IDs from a logprobs source.
223
224 The sampled (or prompt) token at each position is the first
225 entry, since it is always inserted first by
226 append_logprobs_for_next_position.
227
228 Args:
229 logprobs_source: The logprobs container to extract from.
230 max_context: Maximum number of preceding tokens to return.
231 4 is sufficient for any UTF-8 multi-byte sequence.
232
233 Returns:
234 List of sampled token IDs, oldest first, most recent last.
235
236 """
237 if not logprobs_source:
238 return []
239
240 n = len(logprobs_source)
241 start = max(0, n - max_context)
242
243 # Efficient path for FlatLogprobs: access token_ids directly.
244 if isinstance(logprobs_source, FlatLogprobs):
245 return [
246 logprobs_source.token_ids[logprobs_source.start_indices[i]]
247 for i in range(start, n)
248 if logprobs_source.start_indices[i] < logprobs_source.end_indices[i]
249 ]
250
251 # list[dict] path
252 result: list[int] = []
253 for i in range(start, n):
254 entry = logprobs_source[i]
255 if entry is not None:
256 result.append(next(iter(entry)))
257 return result
258
259 def _correct_decoded_token(
260 self, token_id: int, context_token_ids: list[int]
261 ) -> str:
262 """Correct a decoded token that contains the replacement character.
263
264 When byte-fallback tokenization splits multi-byte UTF-8
265 characters across tokens, individual token decoding produces
266 the replacement character U+FFFD. This method uses preceding
267 sampled tokens as context to reconstruct the correct text.
268
269 Args:
270 token_id: The single token ID to correct.
271 context_token_ids: Preceding sampled token IDs in sequential
272 order (oldest first). These are the actual tokens in
273 the generated sequence, NOT top-k alternatives.
274
275 Returns:
276 The corrected decoded string, or empty string if the byte
277 sequence is genuinely incomplete at this point.
278
279 """
280 assert self.tokenizer is not None
281
282 max_ctx = min(len(context_token_ids), 4)
283
284 for num_ctx in range(1, max_ctx + 1):
285 context = context_token_ids[-num_ctx:]
286 full_decoded = self.tokenizer.decode(context + [token_id])
287
288 if full_decoded.endswith("�"):
289 continue
290
291 # Find the boundary between "clean" context tokens and
292 # byte-fallback tokens that are part of the same incomplete
293 # sequence. Byte-fallback context tokens returned "" when
294 # they were processed, so their text must be attributed to
295 # this completing token.
296 clean_end = len(context)
297 for j in range(len(context) - 1, -1, -1):
298 if self.tokenizer.decode([context[j]]).endswith("�"):
299 clean_end = j
300 else:
301 break
302
303 # Decode only the clean (non-byte-fallback) prefix.
304 if clean_end > 0:
305 clean_prefix = self.tokenizer.decode(context[:clean_end])
306 else:
307 clean_prefix = ""
308
309 if full_decoded.startswith(clean_prefix):
310 return full_decoded[len(clean_prefix) :]
311
312 # Tokenizer normalization may cause prefix mismatch.
313 # Find the longest common prefix between them.
314 common_len = 0
315 for a, b in zip(clean_prefix, full_decoded):
316 if a != b:
317 break
318 common_len += 1
319 return full_decoded[common_len:]
320
321 return ""
322
323 def _verify_tokens(
324 self,
325 decoded_tokens_list: list[str],
326 tokens: list[int],
327 context_token_ids: list[int] | None = None,
328 ) -> list[str]:
329 """Verify and correct decoded tokens with replacement characters.
330
331 Args:
332 decoded_tokens_list: Decoded token strings to verify.
333 tokens: Token IDs corresponding to decoded_tokens_list.
334 These are alternatives at the SAME position (e.g.
335 [sampled, top1, top2]), NOT sequential tokens.
336 context_token_ids: Preceding sampled token IDs providing
337 sequential context. If None, extracted from
338 self.logprobs.
339
340 """
341 if context_token_ids is None:
342 context_token_ids = self._get_sampled_context_ids(self.logprobs)
343
344 corrected_decoded_token_map = dict()
345 for idx, text in enumerate(decoded_tokens_list):
346 if text.endswith("�"):
347 # Replacement char at the end means a potential
348 # unfinished byte sequence from byte-fallback
349 # tokenization. Correct each token independently
350 # using only the sequential context.
351 corrected_decoded_token_map[idx] = self._correct_decoded_token(
352 tokens[idx], context_token_ids
353 )
354
355 for idx, text in corrected_decoded_token_map.items():
356 decoded_tokens_list[idx] = text
357
358 return decoded_tokens_list
359
360 def update_from_output(self, output: EngineCoreOutput) -> None:
361 if output.new_logprobs is not None:
362 self._update_sample_logprobs(output.new_logprobs)
363 if output.new_prompt_logprobs_tensors is not None:
364 self._update_prompt_logprobs(output.new_prompt_logprobs_tensors)
365 if output.prompt_token_id_logprobs is not None:
366 self.prompt_token_id_logprobs = output.prompt_token_id_logprobs.numpy()