main
py 295 lines 11 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import os
4
5 import numpy as np
6 import torch
7 from numba import get_num_threads, jit, njit, prange, set_num_threads
8
9 from vllm.config import VllmConfig
10
11
12 class NgramProposer:
13 def __init__(self, vllm_config: VllmConfig):
14 assert vllm_config.speculative_config is not None
15 assert vllm_config.speculative_config.prompt_lookup_min is not None
16 assert vllm_config.speculative_config.prompt_lookup_max is not None
17
18 # Minimum length of the n-gram to match.
19 self.min_n = vllm_config.speculative_config.prompt_lookup_min
20 # Maximum length of the n-gram to match.
21 self.max_n = vllm_config.speculative_config.prompt_lookup_max
22 # Number of tokens follow the match. If there are less than k
23 # tokens follow the match, we will return the maximum amount of
24 # tokens until the end.
25 self.k = vllm_config.speculative_config.num_speculative_tokens
26 # Maximum length of the model.
27 self.max_model_len = vllm_config.model_config.max_model_len
28
29 # Pre-allocate buffers for numba batch propose.
30 max_num_seqs = vllm_config.scheduler_config.max_num_seqs
31 self.valid_ngram_draft = np.zeros((max_num_seqs, self.k), dtype=np.int32)
32 self.valid_ngram_num_drafts = np.zeros((max_num_seqs), dtype=np.int32)
33
34 # Threshold of total number of tokens in the batch to enable
35 # multi-threading in numba batch propose.
36 self.num_tokens_threshold = 8192
37 tp_size = vllm_config.parallel_config.tensor_parallel_size
38 cpu_count = os.cpu_count()
39 # Max number of threads for numba parallel processing.
40 if cpu_count:
41 # Divide by 2 to use physical cores
42 # and not logical cores (hyper-threading).
43 # Cap the number of threads to 8 to avoid using too many threads
44 # since other components like frontend (incl tokenization)
45 # and Structured Outputs also use multiple threads.
46 # TODO(ekagra-ranjan): bump up the cap from 1 to 8
47 # when TP parallelization for ngram is implemented.
48 self.num_numba_thread_available = min(1, (cpu_count // 2))
49 # Divide by tp_size to ensure each tensor parallel rank
50 # has some threads since all ranks will run this.
51 self.num_numba_thread_available //= tp_size
52 else:
53 self.num_numba_thread_available = 1
54
55 # Trigger Numba JIT compilation for N-gram proposer.
56 # This usually takes less than 1 second.
57 self.propose(
58 self.k,
59 [[]] * 1024,
60 np.zeros(1024, dtype=np.int32),
61 np.zeros((1024, self.max_model_len), dtype=np.int32),
62 )
63
64 def batch_propose(
65 self,
66 num_requests: int,
67 valid_ngram_requests: list,
68 num_tokens_no_spec: np.ndarray,
69 token_ids_cpu: np.ndarray,
70 k: int,
71 ) -> list[list[int]]:
72 """Batch version of ngram proposer using numba for acceleration.
73
74 Args:
75 num_requests:
76 Number of requests in the batch.
77 valid_ngram_requests:
78 Set of indices of requests that need ngram proposals.
79 num_tokens_no_spec:
80 Numpy array of shape (batch_size,) representing the number
81 of tokens without speculative tokens for each request.
82 token_ids_cpu:
83 Numpy array of shape (batch_size, max_model_len)
84 representing the token IDs for each request.
85 k:
86 Number of speculative tokens to propose.
87
88 Returns:
89 list[list[int]]:
90 A list where each element is a list of proposed
91 token IDs for the corresponding request.
92
93 """
94 draft_token_ids: list[list[int]] = []
95
96 # Only run batch propose if there are requests needing ngram proposals.
97 # avoid calling numba function with empty list which causes error
98 # ValueError: cannot compute fingerprint of empty list
99 if num_ngram_requests := len(valid_ngram_requests):
100 original_num_numba_threads = get_num_threads()
101 # Ensure we use at least one thread.
102 # If total tokens is small, using multiple threads
103 # may slow down due to overhead.
104 total_tokens = np.sum(num_tokens_no_spec)
105 if total_tokens >= self.num_tokens_threshold:
106 final_num_threads = max(
107 1, min(self.num_numba_thread_available, num_ngram_requests)
108 )
109 set_num_threads(final_num_threads)
110 else:
111 set_num_threads(1)
112
113 batch_propose_numba(
114 valid_ngram_requests,
115 num_tokens_no_spec,
116 token_ids_cpu,
117 self.min_n,
118 self.max_n,
119 self.max_model_len,
120 k,
121 self.valid_ngram_draft,
122 self.valid_ngram_num_drafts,
123 )
124
125 # Restore original number of threads.
126 set_num_threads(original_num_numba_threads)
127
128 for i in range(num_requests):
129 if i in valid_ngram_requests and self.valid_ngram_num_drafts[i] > 0:
130 draft_token_ids.append(
131 self.valid_ngram_draft[i, : self.valid_ngram_num_drafts[i]].tolist()
132 )
133 else:
134 draft_token_ids.append([])
135
136 return draft_token_ids
137
138 def propose(
139 self,
140 num_speculative_tokens: int,
141 sampled_token_ids: list[list[int]],
142 num_tokens_no_spec: np.ndarray,
143 token_ids_cpu: np.ndarray,
144 slot_mappings: dict[str, torch.Tensor]
145 | list[dict[str, torch.Tensor]]
146 | None = None, # unused
147 ) -> list[list[int]]:
148 assert num_speculative_tokens <= self.k
149
150 # find which requests need ngram proposals
151 valid_ngram_requests = []
152 for i, sampled_ids in enumerate(sampled_token_ids):
153 num_sampled_ids = len(sampled_ids)
154 if not num_sampled_ids:
155 # Skip speculative decoding.
156 continue
157
158 num_tokens = num_tokens_no_spec[i]
159 if num_tokens >= self.max_model_len:
160 # Skip requests that have already reached the max model length.
161 continue
162
163 valid_ngram_requests.append(i)
164
165 draft_token_ids = self.batch_propose(
166 len(sampled_token_ids),
167 valid_ngram_requests,
168 num_tokens_no_spec,
169 token_ids_cpu,
170 num_speculative_tokens,
171 )
172
173 return draft_token_ids
174
175 def load_model(self, *args, **kwargs):
176 # No model to load.
177 pass
178
179
180 @njit(parallel=True)
181 def batch_propose_numba(
182 valid_ngram_requests: list,
183 num_tokens_no_spec: np.ndarray,
184 token_ids_cpu: np.ndarray,
185 min_n: int,
186 max_n: int,
187 max_model_len: int,
188 k: int,
189 valid_ngram_draft: np.ndarray,
190 valid_ngram_num_drafts: np.ndarray,
191 ):
192 for i in prange(len(valid_ngram_requests)):
193 idx = valid_ngram_requests[i]
194 num_tokens = num_tokens_no_spec[idx]
195 context_token_ids = token_ids_cpu[idx, :num_tokens]
196 drafter_output = _find_longest_matched_ngram_and_propose_tokens(
197 origin_tokens=context_token_ids,
198 min_ngram=min_n,
199 max_ngram=max_n,
200 max_model_len=max_model_len,
201 k=k,
202 )
203
204 valid_ngram_num_drafts[idx] = drafter_output.shape[0]
205 if len(drafter_output):
206 valid_ngram_draft[idx, : drafter_output.shape[0]] = drafter_output
207
208
209 @jit(nopython=True)
210 def _find_longest_matched_ngram_and_propose_tokens(
211 origin_tokens: np.ndarray,
212 min_ngram: int,
213 max_ngram: int,
214 max_model_len: int,
215 k: int,
216 ) -> np.ndarray:
217 """Find the longest n-gram which matches the suffix of the given tokens
218 whose length is within [min_ngram, max_ngram] (inclusive).
219
220 If found, we will extract k right after the matched ngram.
221 """
222 # Do not generate draft tokens is context is shorter than minimum n-gram
223 total_token = origin_tokens.shape[0]
224 if total_token < min_ngram:
225 return np.empty((0,), dtype=origin_tokens.dtype)
226
227 # Do not generate draft tokens beyond the max model length.
228 k = min(k, max_model_len - total_token)
229 if k <= 0:
230 return np.empty((0,), dtype=origin_tokens.dtype)
231
232 # Flip tokens, and the goal become to find longest ngram
233 # on the rightmost position which matches the prefix with
234 # length [min_n, max_n] (inclusive).
235 tokens = origin_tokens[::-1]
236
237 # Longest prefix (not including itself) which is a suffix of
238 # the current position.
239 # lps[i] = max{v, where tokens[0:v] == tokens[i+1-v:i+1]}
240 #
241 # As ngram is capped by max_ngram to save memory, we only need to
242 # store lps for the first max_ngram prefix.
243 lps = np.zeros(max_ngram, dtype=np.int32)
244
245 longest_ngram = 0
246 position = 0
247
248 # lps[0] always equal to 0, we start with index 1
249 prev_lps = 0
250 i = 1
251 while i < total_token:
252 # tokens[:prev_lps] is the longest prefix as a suffix of tokens[:i]
253 if tokens[prev_lps] == tokens[i]:
254 # Token match: tokens[:prev_lps+1] is the longest prefix as
255 # a suffix of tokens[:i+1]
256 prev_lps += 1
257 # Check if we found a longer valid ngram.
258 #
259 # Update position when longest_ngram matched prev_lps,
260 # as we want to get the target n-gram of the earliest position
261 # in the original tokens (i.e.
262 # latest position in the reversed tokens)
263 if prev_lps >= longest_ngram:
264 longest_ngram = prev_lps
265 position = i
266 if i < max_ngram:
267 # Store LPS for the first max_ngram prefix
268 lps[i] = prev_lps
269 if prev_lps == max_ngram:
270 # When prev_lps reached max_ngram, update prev_lps
271 # to lps[max_ngram-1] to avoid matching ngram
272 # longer than max_ngram
273 prev_lps = lps[max_ngram - 1]
274 i += 1
275 elif prev_lps != 0:
276 # Token mismatch: try the second-longest prefix
277 # among all suffix of tokens[:i],
278 # which is the longest prefix of tokens[:prev_lps]
279 prev_lps = lps[prev_lps - 1]
280 else:
281 # Token mismatch, and no more prefix (except empty string)
282 # as a suffix of tokens[:i]
283 i += 1
284
285 if longest_ngram < min_ngram:
286 # No valid ngram is found
287 return np.empty((0,), dtype=origin_tokens.dtype)
288
289 # Flip the position back, so in origin_tokens,
290 # origin_tokens[total_token-1-position:total_token-1-position+longest_ngram]
291 # is the matched ngram, so we should start drafting tokens from
292 # total_token-1-position+longest_ngram
293 start_position = total_token - 1 - position + longest_ngram
294 k = min(k, total_token - start_position)
295 return origin_tokens[start_position : start_position + k]