[MRV2] Add DRY as a custom logits processor example (#50584)

Signed-off-by: Oleksandr Zakharchuk <oleksandr.zakharchuk@gmail.com>

sashko-zakharchuk committed Sep 29, 2026 at 21:46 UTC a96ee5935d769ab72ca5c4dea5ee9b9964c9a45e
3 files changed +1866
examples/features/logits_processor/README.md
+8
@@ -28,6 +28,14 @@ A special case of wrapping a request-level logits processor where the processor
28 python examples/features/logits_processor/custom_req_init.py
29 ```
30
31 +### `dry.py` — DRY repetition penalty (Model Runner V2)
32 +
33 +Implements the DRY (Don't Repeat Yourself) repetition penalty, ported from llama.cpp, as `DryState`, a custom logits processor for the Model Runner V2 interface. Requests turn it on through `SamplingParams.extra_args`, for example `{"dry_multiplier": 0.8}`. The example sends one prompt twice in one batch, once without DRY and once with it, and prints both outputs. `tests/v1/sample/test_dry.py` imports the processor from this file.
34 +
35 +```bash
36 +python examples/features/logits_processor/dry.py
37 +```
38 +
39 ## Key Concepts
40
41 - **Batch-level vs. request-level**: vLLM processes logits at the batch level for efficiency. If you have a per-request processor, you need to wrap it using the patterns shown in `custom_req.py` and `custom_req_init.py`.
examples/features/logits_processor/dry.py new
+827
@@ -0,0 +1,827 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""This example implements the DRY (Don't Repeat Yourself) repetition penalty as
4 +a Model Runner V2 custom logits processor, ``DryState``.
5 +
6 +A token that would extend a repeat of at least ``allowed_length`` tokens loses
7 +``multiplier * base ** (repeat_length - allowed_length)`` from its logit, with the
8 +exponent clamped as in llama.cpp.
9 +Parameter names and matching semantics follow llama.cpp's
10 +``llama_sampler_dry_apply``. llama.cpp's version is itself ported from pi6am's
11 +koboldcpp implementation of p-e-w's scheme for text-generation-webui.
12 +
13 +Pass the class to ``LLM`` and configure it per request through ``extra_args``::
14 +
15 + LLM(..., logits_processors=[DryState])
16 + SamplingParams(extra_args={"dry_multiplier": 0.8})
17 +
18 +The recognized keys are ``dry_multiplier`` (0.0, off), ``dry_base`` (1.75),
19 +``dry_allowed_length`` (2), ``dry_penalty_last_n`` (-1, the whole context)
20 +and ``dry_sequence_breakers``. This processor claims the ``dry_*`` namespace,
21 +so any other ``dry_*`` key fails the request. Speculative decoding is not
22 +supported and is refused at construction.
23 +
24 +Run the example with::
25 +
26 + python examples/features/logits_processor/dry.py
27 +
28 +It sends one prompt twice in one batch, once without DRY and once with it, and
29 +prints both outputs, yielding an output similar to that shown below:
30 +
31 +Generated Outputs:
32 +------------------------------------------------------------
33 +Prompt: 'The future of AI is', without DRY
34 +Output: ' in the hands of the people.\n\nThe future of AI is in the hands of
35 + the people.\n\nThe future of AI is in the hands of the
36 + people.\n\nThe future of AI is in the hands of the people.\n\nThe
37 + future of AI is in the hands of the people.\n'
38 +------------------------------------------------------------
39 +Prompt: 'The future of AI is', with DRY
40 +Output: ' in the hands of the people.\n\nThe future of AI is the future of
41 + the human race.\n\nThe future of AI is a future of the human race,
42 + and the future of humanity.\n\nThe future of AI is an AI that is
43 + capable of making decisions that are not based on human
44 + intelligence.'
45 +------------------------------------------------------------
46 +"""
47 +
48 +import math
49 +import weakref
50 +from typing import TYPE_CHECKING, Any, NamedTuple
51 +
52 +import numpy as np
53 +import torch
54 +
55 +from vllm.logger import init_logger
56 +from vllm.sampling_params import SamplingParams
57 +from vllm.utils.gpu_sync_debug import gpu_sync_allowed
58 +from vllm.utils.torch_utils import async_tensor_h2d
59 +from vllm.v1.worker.gpu.sample.logits_processor import (
60 + LogitsContext,
61 + LogitsProcessor,
62 + LogitsProcRequestState,
63 +)
64 +
65 +if TYPE_CHECKING:
66 + from vllm.config import VllmConfig
67 +
68 +logger = init_logger(__name__)
69 +
70 +_J_BUDGET = 2048
71 +# Run lengths feed an int16 sum, so they must stay below 2**15.
72 +assert _J_BUDGET < 2**15, "_J_BUDGET must fit the int16 run-length sum"
73 +
74 +# Byte budget for the per-chunk transients of the match scan. The gather
75 +# W32[:, idx1] materializes [R, chunk, J] int32 (4 B/elem) before the
76 +# comparison reduces it to bool; with the masks, the int8 cumprod and
77 +# the int16 row sums, the marginal transient cost is ~8 B/elem. 24 keeps
78 +# headroom for the fixed R*vocab penalty accumulator floor and allocator slack.
79 +_CHUNK_BYTE_BUDGET = 256 * 1024 * 1024
80 +_CHUNK_PEAK_BYTES_PER_ELEM = 24
81 +
82 +# llama.cpp's FLOAT_MAX_LOG (src/llama-sampler.cpp): ln(float32 max).
83 +_FLOAT_MAX_LOG = 88.7228391
84 +
85 +# Breakers are truncated to this many code points. llama.cpp cuts at this many
86 +# bytes (llama_sampler_init_dry), which keeps fewer characters of a non-ASCII
87 +# breaker.
88 +_MAX_BREAKER_CHAR_LEN = 40
89 +
90 +# Upper bound on distinct breaker sets cached per tokenizer.
91 +_MAX_CACHED_BREAKER_SETS = 64
92 +
93 +STR_SPEC_DEC_REJECTS_DRY = (
94 + "The DRY logits processor is not supported when speculative decoding is enabled."
95 +)
96 +
97 +DEFAULT_DRY_SEQUENCE_BREAKERS = ("\n", ":", '"', "*")
98 +"""llama.cpp's default DRY sequence breakers."""
99 +
100 +MAX_DRY_SEQUENCE_BREAKERS = 64
101 +"""Upper bound on the dry_sequence_breakers list length. Each multi-character
102 +breaker in an uncached set scans the vocabulary."""
103 +
104 +_DRY_INT_MAX = 2**31 - 1
105 +"""Upper bound on the integral DRY parameters, matching llama-server's
106 +INT32_MAX cap. The state below stores them in an int64 numpy array, where
107 +anything at or above 2**63 raises OverflowError inside execute_model and
108 +takes the engine down."""
109 +
110 +_MAX_CACHED_BREAKER_MASKS = 64
111 +"""Upper bound on distinct breaker sets holding a device mask, mirroring
112 +_MAX_CACHED_BREAKER_SETS on the host side."""
113 +
114 +_WARMUP_WINDOW = 8
115 +"""Window length of the dry_core call in DryState.__init__, long enough to
116 +hold a match at the default dry_allowed_length."""
117 +
118 +_DRY_DEFAULTS: dict[str, Any] = {
119 + "dry_multiplier": 0.0,
120 + "dry_base": 1.75,
121 + "dry_allowed_length": 2,
122 + "dry_penalty_last_n": -1, # whole context; llama.cpp's default is 64
123 + "dry_sequence_breakers": DEFAULT_DRY_SEQUENCE_BREAKERS,
124 +}
125 +"""Recognized extra_args keys and their defaults."""
126 +
127 +
128 +# ---------------------------------------------------------------------------
129 +# The match computation
130 +# ---------------------------------------------------------------------------
131 +# ``_dry_penalties`` is a sequential port of llama.cpp's Z-algorithm, used as the
132 +# reference. ``dry_core`` is the vectorized form: the comparisons that determine
133 +# the match length ending at position ``i`` lie along one diagonal, so a
134 +# cumprod-sum along ``j`` of an ``[R, K, J]`` tensor gives every match length
135 +# without a sequential scan. Capping ``J`` at ``allowed_length + max_exponent``
136 +# is exact, because the exponent clamp maps every longer match to the same
137 +# penalty. Requests whose cap is unusable (``max_exponent == 0``, i.e.
138 +# ``base <= 1.000001``, or a cap beyond ``_J_BUDGET``) use the sequential form.
139 +
140 +
141 +def max_exponent(base: float) -> int:
142 + """Exponent clamp, mirroring llama.cpp bit-for-bit.
143 +
144 + llama.cpp computes ``FLOAT_MAX_LOG / std::log(dry_base)`` entirely in
145 + float32. Computing the quotient in float64 lands on the wrong side of
146 + the integer truncation for some bases: at ``base=2.0`` float64 gives
147 + 127.99999998 -> 127 while float32 gives exactly 128.0 -> 128.
148 + """
149 + if base <= 1.000001:
150 + return 0
151 + return int(np.float32(_FLOAT_MAX_LOG) / np.log(np.float32(base)))
152 +
153 +
154 +def _dry_penalties(
155 + window: list[int],
156 + breakers: frozenset[int],
157 + multiplier: float,
158 + base: float,
159 + allowed_length: int,
160 + max_exp: int,
161 +) -> dict[int, float]:
162 + """Compute DRY penalties for one request.
163 +
164 + Port of the scan in ``llama_sampler_dry_apply``: a reverse-direction
165 + Z-algorithm finds, for each position, the length of the match between
166 + the window suffix and the sequence ending at that position; each
167 + match's follower token is charged the penalty for the longest repeat
168 + it would extend.
169 +
170 + Returns a dict mapping token id -> penalty to subtract from its logit.
171 + """
172 + m = len(window)
173 +
174 + def rat(i: int) -> int: # reverse access: rat(0) == last token
175 + return window[m - 1 - i]
176 +
177 + # Step 1: the nearest breaker from the end caps the match length.
178 + rep_limit = m
179 + for i in range(m):
180 + if rat(i) in breakers:
181 + rep_limit = i
182 + break
183 + if rep_limit < allowed_length:
184 + return {}
185 +
186 + # Step 2: reverse-direction Z-algorithm -> per-position repeat counts
187 + # (forward-indexed into ``window`` via cnt[last - k]).
188 + cnt = [0] * m
189 + last = m - 1
190 + lt = rt = 0
191 + for k in range(1, m):
192 + if k > rt:
193 + # Outside the current Z-box: extend naively.
194 + z = 0
195 + while z + k < m and rat(z) == rat(z + k):
196 + z += 1
197 + cnt[last - k] = min(z, rep_limit)
198 + if z > 0:
199 + lt, rt = k, k + z - 1
200 + else:
201 + p = k - lt
202 + right_part_len = rt - k + 1
203 + if cnt[last - p] < right_part_len:
204 + # Fully inside the Z-box: copy.
205 + cnt[last - k] = min(cnt[last - p], rep_limit)
206 + else:
207 + # Touches the right edge: extend past it.
208 + j = rt + 1
209 + while j < m and rat(j) == rat(j - k):
210 + j += 1
211 + cnt[last - k] = min(j - k, rep_limit)
212 + lt, rt = k, j - 1
213 +
214 + # Step 3: map each repeat's follower token to the longest repeat that
215 + # it would extend.
216 + max_token_repeat: dict[int, int] = {}
217 + for i in range(m - 1):
218 + repeat_len = cnt[i]
219 + if repeat_len >= allowed_length:
220 + tok = window[i + 1]
221 + if max_token_repeat.get(tok, -1) < repeat_len:
222 + max_token_repeat[tok] = repeat_len
223 +
224 + # Step 4: exponential penalty, exponent clamped for float32 safety.
225 + # Breaker tokens are never penalized. A value overflowing float32
226 + # saturates the logit to -inf downstream, as in llama.cpp.
227 + penalties: dict[int, float] = {}
228 + for tok, repeat_len in max_token_repeat.items():
229 + if tok in breakers:
230 + continue
231 + exponent = repeat_len - allowed_length
232 + if max_exp and exponent > max_exp:
233 + exponent = max_exp
234 + penalties[tok] = multiplier * (base**exponent)
235 + return penalties
236 +
237 +
238 +def dry_core(
239 + logits: torch.Tensor,
240 + row_idx: torch.Tensor,
241 + W: torch.Tensor,
242 + n_r: torch.Tensor,
243 + allowed: torch.Tensor,
244 + max_exp: torch.Tensor,
245 + mult: torch.Tensor,
246 + base: torch.Tensor,
247 + breaker_masks: list[torch.Tensor | None],
248 + j_budget: int,
249 +) -> torch.Tensor:
250 + """Apply DRY penalties in place given batched window tensors.
251 +
252 + The tensor bounds below are not checked, to avoid a sync.
253 +
254 + Args:
255 + logits: [B, vocab] float tensor, modified in place.
256 + row_idx: [R] int64, row of ``logits`` for each DRY request.
257 + W: [R, N] int64 windows, right-aligned (window tokens occupy the
258 + trailing ``n_r`` columns; leading columns are ignored via ``n_r``
259 + masks, whatever they hold).
260 + n_r: [R] int64 window lengths.
261 + allowed: [R] int64 per-request allowed length.
262 + max_exp: [R] int64 per-request exponent ceiling, > 0.
263 + mult: [R] float32 per-request multiplier, >= 0, already rounded
264 + through float32, as llama.cpp stores it.
265 + base: [R] float32 per-request base, >= 1, rounded the same way.
266 + breaker_masks: per-request [vocab] bool masks (or None for no
267 + breakers). ``vocab`` must equal ``logits.shape[-1]``.
268 + j_budget: max(allowed + max_exp), <= _J_BUDGET, computed by the caller on
269 + the host.
270 +
271 + """
272 + device = logits.device
273 + vocab = logits.shape[-1]
274 + R, N = W.shape
275 +
276 + # j_budget arrives as an unchecked host int; this check fails loudly rather
277 + # than wrapping an int16 run length. The routing predicate in DryState.apply
278 + # is what actually bounds it.
279 + if j_budget > _J_BUDGET:
280 + raise ValueError(f"j_budget {j_budget} over _J_BUDGET {_J_BUDGET}")
281 + # base >= 1 and mult >= 0 are preconditions too (see the amax comment below).
282 + # They live in device tensors and reading them back would sync; use_dry()
283 + # and validate_params enforce them instead.
284 +
285 + # rep_limit: distance from the end of the nearest breaker (llama.cpp step 1).
286 + # A breaker at column c is j = N-1-c tokens from the end.
287 + rep_limit = n_r.clone()
288 + # bool(bm.any()) would be a per-request host sync; the None check says the same.
289 + any_breakers = any(bm is not None for bm in breaker_masks)
290 + bmask = None
291 + if any_breakers:
292 + # Stacked once, used twice: nearest-breaker search here, penalty zeroing
293 + # after the scan. The masks are cached and resident (DryState._breaker_masks),
294 + # so the stack costs one byte per (row, vocab) entry.
295 + bmask = torch.stack(
296 + [
297 + bm
298 + if bm is not None
299 + else torch.zeros(vocab, dtype=torch.bool, device=device)
300 + for bm in breaker_masks
301 + ]
302 + )
303 + Bwin = bmask.gather(1, W.clamp(min=0))
304 + valid_cols = torch.arange(N, device=device)[None, :] >= (N - n_r)[:, None]
305 + Bwin &= valid_cols
306 + has_breaker = Bwin.any(dim=1)
307 + # max breaker column -> nearest to the end.
308 + max_col = torch.where(
309 + has_breaker,
310 + (Bwin * torch.arange(1, N + 1, device=device)[None, :]).max(dim=1).values
311 + - 1,
312 + torch.zeros_like(n_r),
313 + )
314 + rep_limit = torch.where(has_breaker, (N - 1) - max_col, n_r)
315 +
316 + # llama.cpp: if rep_limit < allowed_length, the request produces nothing.
317 + active = rep_limit >= allowed
318 +
319 + # Token ids fit int32, and gathering int32 halves the dominant per-chunk transient.
320 + W32 = W.to(torch.int32)
321 + # J is a tensor shape, so it must be known host-side; the caller computes it
322 + # from numpy (see DryState.apply) to avoid a per-step device readback.
323 + J = max(1, min(j_budget, N))
324 + idx2 = torch.arange(N - 1, N - 1 - J, -1, device=device) # [J]
325 + suffix = W32.gather(1, idx2.expand(R, J)) # [R, J]
326 + K = N - 1 # offsets 1..N-1
327 + chunk = max(1, _CHUNK_BYTE_BUDGET // (_CHUNK_PEAK_BYTES_PER_ELEM * max(1, R * J)))
328 +
329 + # amax keeps one penalty per token, for its longest match, however the offsets
330 + # are chunked, as long as the penalty does not fall as the match grows
331 + # (base >= 1, mult >= 0).
332 + pen = torch.zeros(R * vocab + 1, dtype=torch.float32, device=device)
333 +
334 + for k0 in range(1, K + 1, chunk):
335 + k1 = min(k0 + chunk, K + 1)
336 + ks = torch.arange(k0, k1, device=device) # [C]
337 + C = ks.shape[0]
338 + # idx1[c, j] = N-1-j-k ; invalid (out of window) entries masked.
339 + idx1 = idx2[None, :] - ks[:, None] # [C, J]
340 + invalid = idx1 < (N - n_r)[:, None, None] # [R, C, J]
341 + eq = W32[:, idx1.clamp(min=0)] == suffix[:, None, :] # [R, C, J]
342 + eq &= ~invalid
343 + del invalid
344 + # Run length of leading True along j = the match length. Explicit dtypes avoid
345 + # int64 intermediate copies; values are 0/1 and runs are <= J <= _J_BUDGET,
346 + # so int8/int16 are exact.
347 + L = eq.cumprod(dim=2, dtype=torch.int8).sum(dim=2, dtype=torch.int16)
348 + del eq
349 + # Do not narrow rep_limit to L's int16; window lengths can exceed it.
350 + L = torch.minimum(L, rep_limit[:, None])
351 +
352 + # Follower token of offset k lives at column N-k; the offset counts only
353 + # while position i = N-1-k is inside the window (k <= n_r - 1).
354 + valid_k = ks[None, :] <= (n_r - 1)[:, None] # [R, C]
355 + charge = (allowed[:, None] <= L) & valid_k & active[:, None]
356 + # No `if charge.any()` and no boolean indexing: both sync the host. Uncharged
357 + # entries scatter to a trash slot one past the accumulator end, never read.
358 + followers = W.gather(1, (N - ks).clamp(max=N - 1).expand(R, C))
359 + rows = torch.arange(R, device=device)[:, None].expand(R, C) * vocab
360 + flat = rows + followers.clamp(min=0)
361 + # A Python int, not a device tensor: torch.tensor(x, device=...) here would be a
362 + # pageable host-to-device copy, which is itself a synchronization.
363 + flat = torch.where(charge, flat, R * vocab)
364 + # L takes rep_limit's dtype from the minimum above. The cast keeps the
365 + # exponent int64 whatever dtype n_r, allowed and max_exp arrive in.
366 + exponent = torch.minimum(L.to(torch.int64) - allowed[:, None], max_exp[:, None])
367 + # float64 pow, as llama.cpp's std::pow(float, int); a float32 pow overflows
368 + # early (0.8 * 2**128 is finite in float32).
369 + p_chunk = mult[:, None].double() * torch.pow(
370 + base[:, None].double(), exponent.to(torch.float64)
371 + )
372 + # No where() on p_chunk: uncharged entries went to the trash slot and are
373 + # never read.
374 + pen.scatter_reduce_(
375 + 0,
376 + flat.reshape(-1),
377 + p_chunk.to(torch.float32).reshape(-1),
378 + reduce="amax",
379 + include_self=True,
380 + )
381 +
382 + # Of the penalty terms, only the penalty itself is held at [R, vocab] (4 B/entry);
383 + # the exponent, its float64 cast, the pow result and the product stay at [R, C]
384 + # inside the loop.
385 + # Breakers zero the penalty rather than the logit, so a breaker keeps its value.
386 + # The narrowing to logits.dtype below is a no-op under vLLM's sampler, which
387 + # always passes float32 logits.
388 + pen2 = pen[:-1].view(R, vocab)
389 + if bmask is not None:
390 + pen2.masked_fill_(bmask, 0.0)
391 + # index_add_ avoids the extra [R, vocab] gather+copy of `logits[row_idx] -= pen2`.
392 + pen2.neg_()
393 + logits.index_add_(
394 + 0, row_idx, pen2 if logits.dtype == pen2.dtype else pen2.to(logits.dtype)
395 + )
396 + return logits
397 +
398 +
399 +# ---------------------------------------------------------------------------
400 +# Breaker resolution
401 +# ---------------------------------------------------------------------------
402 +
403 +
404 +class _VocabIndex(NamedTuple):
405 + """A tokenizer's decoded vocabulary, plus a character index into it.
406 +
407 + ``char_ids`` maps every character in ``texts`` to the ids of the tokens
408 + whose text contains it.
409 + """
410 +
411 + texts: list[str]
412 + char_ids: dict[str, list[int]]
413 +
414 +
415 +# Per-tokenizer caches, weakly keyed so tokenizers can be collected:
416 +# tokenizer -> its decoded vocabulary and character index, and
417 +# tokenizer -> {breaker string tuple -> resolved breaker ids}.
418 +_BreakerIdsPerTokenizer = dict[tuple[str, ...], list[int]]
419 +_vocab_index_cache: "weakref.WeakKeyDictionary[Any, _VocabIndex]" = (
420 + weakref.WeakKeyDictionary()
421 +)
422 +_breaker_ids_cache: "weakref.WeakKeyDictionary[Any, _BreakerIdsPerTokenizer]" = (
423 + weakref.WeakKeyDictionary()
424 +)
425 +
426 +
427 +def _vocab_index(tokenizer: Any) -> _VocabIndex:
428 + """Decode every token of ``tokenizer`` once and index its characters."""
429 + index = _vocab_index_cache.get(tokenizer)
430 + if index is None:
431 + # Include added tokens above vocab_size, which llama.cpp's scan also covers.
432 + n_ids = getattr(tokenizer, "max_token_id", tokenizer.vocab_size - 1) + 1
433 + texts = tokenizer.batch_decode([[i] for i in range(n_ids)])
434 + char_ids: dict[str, list[int]] = {}
435 + for i, text in enumerate(texts):
436 + for char in set(text):
437 + char_ids.setdefault(char, []).append(i)
438 + index = _VocabIndex(texts, char_ids)
439 + _vocab_index_cache[tokenizer] = index
440 + return index
441 +
442 +
443 +def resolve_dry_breakers(tokenizer: Any, breaker_strs: tuple[str, ...]) -> list[int]:
444 + """Resolve breaker strings to the ids of every token whose text contains one.
445 +
446 + This is llama.cpp's ``get_overlapping_token_sequences`` rule, without its
447 + multi-token restart sequences. Resolution runs in ``add_request``, inside
448 + ``execute_model``, so a single-character breaker (llama.cpp's defaults and
449 + most custom sets) resolves by lookup in the character index instead of a
450 + vocabulary scan. Results are cached per (tokenizer, breaker set).
451 + """
452 + breaker_strs = tuple(s[:_MAX_BREAKER_CHAR_LEN] for s in breaker_strs if s)
453 + if not breaker_strs:
454 + return []
455 + per_tok = _breaker_ids_cache.setdefault(tokenizer, {})
456 + cached = per_tok.get(breaker_strs)
457 + if cached is not None:
458 + return list(cached)
459 +
460 + index = _vocab_index(tokenizer)
461 + ids: set[int] = set()
462 + for s in breaker_strs:
463 + if len(s) == 1:
464 + ids.update(index.char_ids.get(s, ()))
465 + else:
466 + ids.update(i for i, text in enumerate(index.texts) if s in text)
467 + result = sorted(ids)
468 + if len(per_tok) >= _MAX_CACHED_BREAKER_SETS:
469 + # Evict the oldest entry (dict preserves insertion order).
470 + per_tok.pop(next(iter(per_tok)))
471 + per_tok[breaker_strs] = result
472 + return list(result)
473 +
474 +
475 +# ---------------------------------------------------------------------------
476 +# The logits processor
477 +# ---------------------------------------------------------------------------
478 +
479 +
480 +def _dry_args(sampling_params: SamplingParams) -> dict[str, Any]:
481 + """Read the request's DRY arguments, filling in the defaults.
482 +
483 + A key present with value None counts as unset, matching how
484 + ``SamplingParams`` coerces its own None-valued arguments.
485 + """
486 + extra = sampling_params.extra_args or {}
487 + return {
488 + name: default if extra.get(name) is None else extra[name]
489 + for name, default in _DRY_DEFAULTS.items()
490 + }
491 +
492 +
493 +def use_dry(multiplier: float, base: float, penalty_last_n: int) -> bool:
494 + """Whether DRY applies, by llama.cpp's gate in llama_sampler_dry_apply."""
495 + return bool(multiplier) and base >= 1.0 and penalty_last_n != 0
496 +
497 +
498 +class DryState(LogitsProcessor):
499 + def __init__(self, vllm_config: "VllmConfig", req_states: LogitsProcRequestState):
500 + # Refuse speculative decoding here, since admission cannot see its config.
501 + if vllm_config.speculative_config is not None:
502 + raise ValueError(STR_SPEC_DEC_REJECTS_DRY)
503 + self.req_states = req_states
504 + max_num_reqs = req_states.max_num_reqs
505 + self.vocab_size = req_states.vocab_size
506 + self.device = req_states.device
507 +
508 + # float32, to round multiplier and base as llama.cpp's float members do.
509 + self.multiplier = np.zeros(max_num_reqs, dtype=np.float32)
510 + self.base = np.zeros(max_num_reqs, dtype=np.float32)
511 + self.allowed_length = np.zeros(max_num_reqs, dtype=np.int64)
512 + self.penalty_last_n = np.zeros(max_num_reqs, dtype=np.int64)
513 + self.max_exponent = np.zeros(max_num_reqs, dtype=np.int64)
514 + self.use_dry = np.zeros(max_num_reqs, dtype=bool)
515 +
516 + # req_idx -> its breaker set, and breaker set -> [vocab] bool device
517 + # mask shared by every request that asked for the same breakers.
518 + self.breaker_ids: dict[int, frozenset[int]] = {}
519 + self._breaker_masks: dict[frozenset[int], torch.Tensor] = {}
520 +
521 + self._warned_unresolved = False
522 +
523 + # Deferred import: the tokenizer registry pulls in transformers, and
524 + # the frontend imports this module only to validate params.
525 + from vllm.tokenizers import cached_tokenizer_from_config
526 +
527 + # None under skip_tokenizer_init.
528 + self._tokenizer = cached_tokenizer_from_config(vllm_config.model_config)
529 + # Resolve the default set here to keep the vocabulary decode out of
530 + # execute_model, where add_request runs.
531 + self._default_breaker_ids = self._resolve_breakers(
532 + DEFAULT_DRY_SEQUENCE_BREAKERS
533 + )
534 + self._warm_up_dry_core()
535 +
536 + def _warm_up_dry_core(self) -> None:
537 + """Load dry_core's CUDA kernels before a request needs them.
538 +
539 + The operands match the dtypes and ranks apply passes, and include a breaker
540 + mask so that the breaker path's kernels load too.
541 + """
542 + vocab = self.vocab_size
543 + # The breaker takes the last id, which must not be the window's token 0.
544 + if vocab < 2:
545 + return
546 + device = self.device
547 + allowed = _DRY_DEFAULTS["dry_allowed_length"]
548 + base = _DRY_DEFAULTS["dry_base"]
549 + max_exp = max_exponent(base)
550 +
551 + def col(value: float, dtype: torch.dtype) -> torch.Tensor:
552 + return torch.full((1,), value, dtype=dtype, device=device)
553 +
554 + breakers = torch.zeros(vocab, dtype=torch.bool, device=device)
555 + breakers.narrow(0, vocab - 1, 1).fill_(True)
556 + dry_core(
557 + torch.zeros(1, vocab, dtype=torch.float32, device=device),
558 + row_idx=col(0, torch.int64),
559 + W=torch.zeros(1, _WARMUP_WINDOW, dtype=torch.int64, device=device),
560 + n_r=col(_WARMUP_WINDOW, torch.int64),
561 + allowed=col(allowed, torch.int64),
562 + max_exp=col(max_exp, torch.int64),
563 + mult=col(1.0, torch.float32),
564 + base=col(base, torch.float32),
565 + breaker_masks=[breakers],
566 + j_budget=allowed + max_exp,
567 + )
568 +
569 + @classmethod
570 + def validate_params(cls, sampling_params: SamplingParams) -> None:
571 + """Check the ``dry_*`` keys of ``extra_args`` at request admission.
572 +
573 + Raises:
574 + ValueError: on an unknown or out-of-range DRY argument.
575 +
576 + """
577 + extra = sampling_params.extra_args or {}
578 + unknown = sorted(
579 + key for key in extra if key.startswith("dry_") and key not in _DRY_DEFAULTS
580 + )
581 + if unknown:
582 + # A misspelled key would otherwise be ignored silently.
583 + raise ValueError(
584 + f"Unknown dry_* extra_args: {', '.join(unknown)}. "
585 + f"Supported keys: {', '.join(_DRY_DEFAULTS)}."
586 + )
587 +
588 + args = _dry_args(sampling_params)
589 + for name in (
590 + "dry_multiplier",
591 + "dry_base",
592 + "dry_allowed_length",
593 + "dry_penalty_last_n",
594 + ):
595 + value = args[name]
596 + # JSON true/false are bools, which pass isinstance(value, int).
597 + if isinstance(value, bool) or not isinstance(value, (int, float)):
598 + raise ValueError(
599 + f"{name} must be a number, got {type(value).__name__}."
600 + )
601 +
602 + multiplier = args["dry_multiplier"]
603 + base = args["dry_base"]
604 + allowed_length = args["dry_allowed_length"]
605 + penalty_last_n = args["dry_penalty_last_n"]
606 + breakers = args["dry_sequence_breakers"]
607 +
608 + if not math.isfinite(multiplier) or multiplier < 0.0:
609 + raise ValueError(
610 + f"dry_multiplier must be non-negative and finite, got {multiplier}."
611 + )
612 + if not math.isfinite(base) or base < 0.0:
613 + raise ValueError(f"dry_base must be non-negative and finite, got {base}.")
614 + if (
615 + not isinstance(allowed_length, int)
616 + or allowed_length < 0
617 + or allowed_length > _DRY_INT_MAX
618 + ):
619 + raise ValueError(
620 + f"dry_allowed_length must be an integer in [0, {_DRY_INT_MAX}], "
621 + f"got {allowed_length}."
622 + )
623 + if (
624 + not isinstance(penalty_last_n, int)
625 + or penalty_last_n < -1
626 + or penalty_last_n > _DRY_INT_MAX
627 + ):
628 + raise ValueError(
629 + "dry_penalty_last_n must be an integer: -1 (whole context), "
630 + f"0 (disable), or in [1, {_DRY_INT_MAX}], got {penalty_last_n}."
631 + )
632 + if not isinstance(breakers, (list, tuple)) or any(
633 + not isinstance(s, str) for s in breakers
634 + ):
635 + raise ValueError(
636 + f"dry_sequence_breakers must be a list of strings, got {breakers!r}."
637 + )
638 + if len(breakers) > MAX_DRY_SEQUENCE_BREAKERS:
639 + raise ValueError(
640 + f"dry_sequence_breakers supports at most "
641 + f"{MAX_DRY_SEQUENCE_BREAKERS} entries, got {len(breakers)}."
642 + )
643 + if multiplier and 0.0 <= base < 1.0:
644 + # libllama has the same gate. llama-server resets such a base to its
645 + # default instead.
646 + logger.warning(
647 + "dry_base=%s is below 1.0, which disables DRY entirely "
648 + "(llama.cpp semantics), even though dry_multiplier=%s was "
649 + "set. No repetition penalty will be applied.",
650 + base,
651 + multiplier,
652 + )
653 +
654 + def _resolve_breakers(self, breakers: tuple[str, ...]) -> frozenset[int]:
655 + if self._tokenizer is None or not breakers:
656 + return frozenset()
657 + return frozenset(resolve_dry_breakers(self._tokenizer, breakers))
658 +
659 + def add_request(self, req_idx: int, sampling_params: SamplingParams) -> bool:
660 + args = _dry_args(sampling_params)
661 + multiplier = args["dry_multiplier"]
662 + base = args["dry_base"]
663 + penalty_last_n = args["dry_penalty_last_n"]
664 + enabled = use_dry(multiplier, base, penalty_last_n)
665 + self.use_dry[req_idx] = enabled
666 + self.breaker_ids.pop(req_idx, None)
667 + if not enabled:
668 + return False
669 + self.multiplier[req_idx] = multiplier
670 + self.base[req_idx] = base
671 + self.allowed_length[req_idx] = args["dry_allowed_length"]
672 + self.penalty_last_n[req_idx] = penalty_last_n
673 + self.max_exponent[req_idx] = max_exponent(float(self.base[req_idx]))
674 +
675 + breakers = tuple(args["dry_sequence_breakers"])
676 + ids = (
677 + self._default_breaker_ids
678 + if breakers == DEFAULT_DRY_SEQUENCE_BREAKERS
679 + else self._resolve_breakers(breakers)
680 + )
681 + if ids:
682 + self.breaker_ids[req_idx] = ids
683 + elif breakers and self._tokenizer is None and not self._warned_unresolved:
684 + logger.warning(
685 + "DRY sequence breakers were not resolved to token ids: this "
686 + "engine has no tokenizer. Proceeding without breakers."
687 + )
688 + self._warned_unresolved = True
689 + return True
690 +
691 + def _breaker_mask(self, req_idx: int) -> torch.Tensor | None:
692 + ids = self.breaker_ids.get(req_idx)
693 + if not ids:
694 + return None
695 + mask = self._breaker_masks.get(ids)
696 + if mask is None:
697 + # Built on the host to avoid a sync; once per breaker set.
698 + ids_np = np.fromiter(ids, dtype=np.int64, count=len(ids))
699 + ids_np = ids_np[ids_np < self.vocab_size]
700 + m_np = np.zeros(self.vocab_size, dtype=bool)
701 + m_np[ids_np] = True
702 + mask = async_tensor_h2d(m_np, self.device)
703 + if len(self._breaker_masks) >= _MAX_CACHED_BREAKER_MASKS:
704 + # Evict the oldest set. A client can send a new set with every request.
705 + self._breaker_masks.pop(next(iter(self._breaker_masks)))
706 + self._breaker_masks[ids] = mask
707 + return mask
708 +
709 + def apply(self, logits: torch.Tensor, ctx: LogitsContext) -> torch.Tensor:
710 + req_indices = ctx.idx_mapping_np
711 + active_rows = np.flatnonzero(self.use_dry[req_indices])
712 + if active_rows.size == 0:
713 + return logits
714 + if logits.shape[0] != req_indices.shape[0]:
715 + raise RuntimeError("DRY received draft-expanded logits")
716 +
717 + # host-side bound; reading positions off GPU would sync per step
718 + cur_len = ctx.seq_lens_upper_bound_np[active_rows].astype(np.int64)
719 +
720 + reqs = req_indices[active_rows]
721 + last_n = self.penalty_last_n[reqs]
722 + window_len = np.where(last_n == -1, cur_len, np.minimum(cur_len, last_n))
723 + allowed = self.allowed_length[reqs]
724 + keep = window_len > allowed
725 + if not np.any(keep):
726 + return logits
727 + active_rows = active_rows[keep]
728 + reqs = reqs[keep]
729 + cur_len = cur_len[keep]
730 + window_len = window_len[keep]
731 + allowed = allowed[keep]
732 + max_exp = self.max_exponent[reqs]
733 +
734 + # Route degenerate-clamp requests (base <= 1.000001 or oversized
735 + # cap) through the sequential reference implementation.
736 + fast = (max_exp > 0) & (allowed + max_exp <= _J_BUDGET)
737 + all_tokens = self.req_states.all_token_ids.gpu
738 +
739 + if np.any(fast):
740 + f_rows = active_rows[fast]
741 + f_reqs = reqs[fast]
742 + f_len = window_len[fast]
743 + N = int(f_len.max())
744 + reqs_t = async_tensor_h2d(f_reqs, self.device)
745 + cur_t = async_tensor_h2d(cur_len[fast], self.device)
746 + j = torch.arange(N, device=self.device)
747 + # Right-aligned gather: column j holds token (cur_len - N + j);
748 + # out-of-window columns are masked inside dry_core via n_r.
749 + gather_idx = (cur_t[:, None] - N + j[None, :]).clamp(min=0)
750 + W = all_tokens[reqs_t[:, None], gather_idx].long()
751 + dry_core(
752 + logits,
753 + row_idx=async_tensor_h2d(f_rows, self.device),
754 + W=W,
755 + n_r=async_tensor_h2d(f_len, self.device),
756 + allowed=async_tensor_h2d(allowed[fast], self.device),
757 + max_exp=async_tensor_h2d(max_exp[fast], self.device),
758 + mult=async_tensor_h2d(self.multiplier[f_reqs], self.device),
759 + base=async_tensor_h2d(self.base[f_reqs], self.device),
760 + breaker_masks=[self._breaker_mask(r) for r in f_reqs],
761 + j_budget=int((allowed[fast] + max_exp[fast]).max()),
762 + )
763 +
764 + # The sequential fallback copies each window to the host, an expected sync.
765 + slow = ~fast
766 + if np.any(slow):
767 + with gpu_sync_allowed():
768 + rows_list = []
769 + cols_list = []
770 + vals_list = []
771 + for row, req, w_len, cur in zip(
772 + active_rows[slow], reqs[slow], window_len[slow], cur_len[slow]
773 + ):
774 + window = (
775 + all_tokens[int(req), int(cur) - int(w_len) : int(cur)]
776 + .cpu()
777 + .tolist()
778 + )
779 + penalties = _dry_penalties(
780 + window,
781 + self.breaker_ids.get(int(req), frozenset()),
782 + float(self.multiplier[req]),
783 + float(self.base[req]),
784 + int(self.allowed_length[req]),
785 + int(self.max_exponent[req]),
786 + )
787 + for tok, val in penalties.items():
788 + rows_list.append(int(row))
789 + cols_list.append(tok)
790 + vals_list.append(val)
791 + if rows_list:
792 + logits[
793 + torch.tensor(rows_list, dtype=torch.int64, device=self.device),
794 + torch.tensor(cols_list, dtype=torch.int64, device=self.device),
795 + ] -= torch.tensor(
796 + vals_list, dtype=torch.float32, device=self.device
797 + )
798 + return logits
799 +
800 +
801 +# ---------------------------------------------------------------------------
802 +# The demo
803 +# ---------------------------------------------------------------------------
804 +
805 +
806 +def main():
807 + # Imported here so that loading DryState from this module does not import LLM.
808 + from vllm import LLM
809 +
810 + prompts = ["The future of AI is"] * 2
811 + sampling_params_list = [
812 + SamplingParams(temperature=0.0, max_tokens=64),
813 + SamplingParams(
814 + temperature=0.0, max_tokens=64, extra_args={"dry_multiplier": 0.8}
815 + ),
816 + ]
817 + llm = LLM(model="facebook/opt-125m", logits_processors=[DryState])
818 + outputs = llm.generate(prompts, sampling_params_list)
819 + print("\nGenerated Outputs:\n" + "-" * 60)
820 + for label, output in zip(("without DRY", "with DRY"), outputs):
821 + print(f"Prompt: {output.prompt!r}, {label}")
822 + print(f"Output: {output.outputs[0].text!r}")
823 + print("-" * 60)
824 +
825 +
826 +if __name__ == "__main__":
827 + main()
tests/v1/sample/test_dry.py new
+1031
@@ -0,0 +1,1031 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for the DRY (Don't Repeat Yourself) repetition penalty."""
4 +
5 +import math
6 +import random
7 +import subprocess
8 +import sys
9 +from pathlib import Path
10 +from types import SimpleNamespace
11 +from typing import Any
12 +
13 +import numpy as np
14 +import pytest
15 +import torch
16 +
17 +import vllm.v1.worker.gpu.sample.logits_processor.loader as loader
18 +from examples.features.logits_processor import dry
19 +from examples.features.logits_processor.dry import (
20 + _MAX_CACHED_BREAKER_MASKS,
21 + DEFAULT_DRY_SEQUENCE_BREAKERS,
22 + DryState,
23 + _dry_penalties,
24 + use_dry,
25 +)
26 +from examples.features.logits_processor.dry import max_exponent as _max_exponent
27 +from vllm.exceptions import VLLMValidationError
28 +from vllm.sampling_params import SamplingParams
29 +
30 +DEVICE = torch.device("cpu")
31 +DRY_FQCN = "examples.features.logits_processor.dry:DryState"
32 +
33 +
34 +def _params(**dry_args) -> SamplingParams:
35 + """SamplingParams carrying DRY arguments the way a client sends them."""
36 + return SamplingParams(extra_args=dry_args or None)
37 +
38 +
39 +def _penalties(window, multiplier=1.0, base=2.0, allowed_length=1, breakers=()):
40 + return _dry_penalties(
41 + window,
42 + frozenset(breakers),
43 + multiplier,
44 + base,
45 + allowed_length,
46 + _max_exponent(base),
47 + )
48 +
49 +
50 +def test_worked_example():
51 + # llama.cpp's commented example (src/llama-sampler.cpp): tokens
52 + # "a b c c b c y a b c" -> per-position repeat counts
53 + # [0,0,3,1,0,2,0,0,0,0]; extenders: c (len 3), b (len 1), y (len 2).
54 + # With multiplier=1, base=2, allowed_length=1: c -> 4, b -> 1, y -> 2.
55 + got = _penalties([1, 2, 3, 3, 2, 3, 9, 1, 2, 3])
56 + assert got == {3: 4.0, 2: 1.0, 9: 2.0}
57 +
58 +
59 +def test_z_box_equality_boundary():
60 + # The Z-algorithm's copy-vs-extend equality boundary: when
61 + # cnt[last - p] == right_part_len the match may extend past the Z-box
62 + # and must be re-scanned, not copied. Copying here drops
63 + # token 0's penalty on this input.
64 + got = _penalties([0, 0, 0, 1, 0, 0], allowed_length=2)
65 + assert got == {0: 1.0, 1: 1.0}
66 +
67 +
68 +def test_exponent_clamp_float32_semantics():
69 + # llama.cpp computes FLOAT_MAX_LOG / log(base) in float32. At base=2.0
70 + # that is exactly 128.0; float64 gives 127.99999998 -> 127. The clamp
71 + # must land on 128 or long-repeat penalties come out half of
72 + # llama.cpp's and never saturate to -inf.
73 + assert _max_exponent(2.0) == 128
74 + assert _max_exponent(1.75) == 158
75 + assert _max_exponent(1.0) == 0 # disabled guard
76 + assert _max_exponent(1.0000005) == 0 # llama.cpp's 1.000001 guard
77 +
78 +
79 +def test_long_repeat_saturates_to_neg_inf():
80 + # 200 identical tokens at base=2.0: clamped exponent 128 ->
81 + # 2**128 overflows float32 -> logit must land at exactly -inf,
82 + # matching llama.cpp. Only the tensor path saturates, on the cast into
83 + # dry_core's float32 accumulator.
84 + state, all_tokens = _make_v2_state(max_num_reqs=1, vocab=16)
85 + state.add_request(
86 + 0,
87 + _params(
88 + dry_multiplier=1.0,
89 + dry_base=2.0,
90 + dry_allowed_length=0,
91 + dry_sequence_breakers=[],
92 + ),
93 + )
94 + all_tokens[0, :200] = 7
95 + logits = torch.zeros(1, 16, device=DEVICE)
96 + _apply(state, logits, np.array([0]), np.array([200]))
97 + assert logits[0, 7].item() == -float("inf")
98 +
99 +
100 +def test_allowed_length_threshold():
101 + # Match of length 2 is penalized at allowed_length=2, not at 3.
102 + window = [1, 2, 3, 1, 2]
103 + assert _penalties(window, allowed_length=3) == {}
104 + assert _penalties(window, allowed_length=2) == {3: 1.0}
105 +
106 +
107 +def test_breakers_cap_and_exclude():
108 + # An unbroken run is penalized; making that token a breaker
109 + # collapses rep_limit to 0 and produces nothing.
110 + assert _penalties([7] * 6, allowed_length=2) == {7: 8.0}
111 + assert _penalties([7] * 6, allowed_length=2, breakers=[7]) == {}
112 +
113 +
114 +def test_short_windows_are_safe():
115 + assert _penalties([]) == {}
116 + assert _penalties([5]) == {}
117 +
118 +
119 +class _StubTokenizer:
120 + """Minimal tokenizer for breaker-resolution tests."""
121 +
122 + _TEXTS = ["a", "\n", "b:\n", "hello", ":", "x:y", "*", "**"]
123 +
124 + vocab_size = len(_TEXTS)
125 +
126 + def batch_decode(self, ids_lists):
127 + return [self._TEXTS[ids[0]] for ids in ids_lists]
128 +
129 +
130 +def test_breaker_containment_resolution():
131 + # Resolution must match every token whose text contains the breaker
132 + # string (llama.cpp get_overlapping_token_sequences), not only exact
133 + # encodings.
134 + from examples.features.logits_processor.dry import resolve_dry_breakers
135 +
136 + tok = _StubTokenizer()
137 + assert resolve_dry_breakers(tok, ("\n",)) == [1, 2]
138 + assert resolve_dry_breakers(tok, (":",)) == [2, 4, 5]
139 + assert resolve_dry_breakers(tok, ("*",)) == [6, 7]
140 + assert resolve_dry_breakers(tok, ()) == []
141 + # Repeat resolution is consistent, and callers get their own copy
142 + # (mutating a result must not poison the cache).
143 + first = resolve_dry_breakers(tok, ("\n",))
144 + first.append(999)
145 + assert resolve_dry_breakers(tok, ("\n",)) == [1, 2]
146 +
147 +
148 +def test_validate_params_rejects_json_booleans():
149 + for name in (
150 + "dry_multiplier",
151 + "dry_base",
152 + "dry_allowed_length",
153 + "dry_penalty_last_n",
154 + ):
155 + with pytest.raises(ValueError, match=name):
156 + DryState.validate_params(_params(**{name: True}))
157 +
158 +
159 +def test_validate_params():
160 + for bad in (
161 + {"dry_multiplier": -1.0},
162 + {"dry_multiplier": float("nan")},
163 + {"dry_penalty_last_n": -7},
164 + {"dry_penalty_last_n": 2.5},
165 + {"dry_allowed_length": -1},
166 + {"dry_allowed_length": 1.5},
167 + {"dry_sequence_breakers": [1, 2]},
168 + {"dry_sequence_breakers": [f"b{i}" for i in range(65)]},
169 + ):
170 + with pytest.raises(ValueError):
171 + DryState.validate_params(_params(**bad))
172 + # Valid corner values pass, as does a request that says nothing at all.
173 + DryState.validate_params(SamplingParams())
174 + DryState.validate_params(_params())
175 + DryState.validate_params(_params(dry_multiplier=0.0))
176 + DryState.validate_params(_params(dry_multiplier=1.0, dry_penalty_last_n=-1))
177 + DryState.validate_params(_params(dry_multiplier=1.0, dry_sequence_breakers=[]))
178 + # An explicit null means "unset", as it does for every SamplingParams
179 + # field; it must not be read as a non-numeric value and rejected.
180 + DryState.validate_params(_params(dry_multiplier=None, dry_base=None))
181 +
182 +
183 +def test_validate_params_rejects_unknown_dry_keys():
184 + """An unknown dry_* key fails the request instead of being ignored."""
185 + with pytest.raises(ValueError, match="dry_multipliers"):
186 + DryState.validate_params(_params(dry_multiplier=0.8, dry_multipliers=0.8))
187 + # Keys outside the namespace belong to somebody else; leave them alone.
188 + DryState.validate_params(_params(dry_multiplier=0.8, target_token=3))
189 +
190 +
191 +def test_validate_params_raises_plain_valueerror():
192 + """validate_params raises ``ValueError``, as the V2 interface specifies."""
193 + with pytest.raises(ValueError) as excinfo:
194 + DryState.validate_params(_params(dry_multiplier=-1.0))
195 + assert type(excinfo.value) is ValueError
196 +
197 +
198 +def _oracle(window, breakers, multiplier, base, allowed_length, max_exponent):
199 + """llama_sampler_dry_apply's steps, with a brute-force match scan in place of
200 + the Z-algorithm that ``_dry_penalties`` ports."""
201 + m = len(window)
202 + rep_limit = m
203 + for i in range(m):
204 + if window[m - 1 - i] in breakers:
205 + rep_limit = i
206 + break
207 + if rep_limit < allowed_length:
208 + return {}
209 + cnt = [0] * m
210 + for i in range(m - 1):
211 + length = 0
212 + while (
213 + length < m
214 + and i - length >= 0
215 + and window[i - length] == window[m - 1 - length]
216 + ):
217 + length += 1
218 + cnt[i] = min(length, rep_limit)
219 + max_tok: dict[int, int] = {}
220 + for i in range(m - 1):
221 + if cnt[i] >= allowed_length:
222 + tok = window[i + 1]
223 + if max_tok.get(tok, -1) < cnt[i]:
224 + max_tok[tok] = cnt[i]
225 + penalties = {}
226 + for tok, repeat_len in max_tok.items():
227 + if tok in breakers:
228 + continue
229 + exponent = repeat_len - allowed_length
230 + if max_exponent and exponent > max_exponent:
231 + exponent = max_exponent
232 + penalties[tok] = multiplier * (base**exponent)
233 + return penalties
234 +
235 +
236 +def test_differential_against_oracle():
237 + rng = random.Random(0)
238 + for case in range(400):
239 + if case % 10 == 0:
240 + # Long, highly repetitive: the only way to reach the clamp.
241 + alphabet, n = rng.randint(1, 2), rng.randint(200, 320)
242 + else:
243 + alphabet, n = rng.randint(2, 6), rng.randint(0, 48)
244 + window = [rng.randrange(alphabet) for _ in range(n)]
245 + multiplier = rng.choice([0.5, 0.8, 1.0, 2.0])
246 + base = rng.choice([1.1, 1.75, 2.0, 3.0])
247 + allowed = rng.randint(0, 4)
248 + breakers = frozenset(
249 + rng.sample(range(alphabet), rng.randint(0, min(2, alphabet)))
250 + )
251 + max_exp = _max_exponent(base)
252 + want = _oracle(window, breakers, multiplier, base, allowed, max_exp)
253 + got = _dry_penalties(window, breakers, multiplier, base, allowed, max_exp)
254 + assert set(want) == set(got), f"case {case}: keys {want} vs {got}"
255 + for tok in want:
256 + a, b = want[tok], got[tok]
257 + assert math.isclose(a, b, rel_tol=1e-9), (
258 + f"case {case} token {tok}: {a} vs {b}"
259 + )
260 +
261 +
262 +def test_double_pow_saturation():
263 + # llama.cpp's std::pow(float, int) computes in double: 0.8 * 2**128
264 + # is finite in float32 (-2.72e38) while 1.0 * 2**128 saturates to
265 + # -inf. A float32 pow gets the first case wrong.
266 + for mult, expect_inf in ((0.8, False), (1.0, True)):
267 + state, all_tokens = _make_v2_state(max_num_reqs=1, vocab=16)
268 + state.add_request(
269 + 0,
270 + _params(
271 + dry_multiplier=mult,
272 + dry_base=2.0,
273 + dry_allowed_length=0,
274 + dry_sequence_breakers=[],
275 + ),
276 + )
277 + all_tokens[0, :200] = 7
278 + logits = torch.zeros(1, 16, device=DEVICE)
279 + _apply(state, logits, np.array([0]), np.array([200]))
280 + val = logits[0, 7].item()
281 + if expect_inf:
282 + assert val == -float("inf")
283 + else:
284 + assert math.isfinite(val) and math.isclose(
285 + val, -0.8 * 2.0**128, rel_tol=1e-6
286 + )
287 +
288 +
289 +def test_dry_core_rejects_j_budget_over_the_budget():
290 + """dry_core raises on a j_budget over _J_BUDGET and accepts _J_BUDGET itself."""
291 +
292 + def run(j_budget):
293 + def col(value, dtype=torch.int64):
294 + return torch.full((1,), value, dtype=dtype, device=DEVICE)
295 +
296 + dry.dry_core(
297 + torch.zeros(1, 8, device=DEVICE),
298 + row_idx=col(0),
299 + W=torch.zeros(1, 4, dtype=torch.int64, device=DEVICE),
300 + n_r=col(4),
301 + allowed=col(2),
302 + max_exp=col(1),
303 + mult=col(1.0, torch.float32),
304 + base=col(2.0, torch.float32),
305 + breaker_masks=[None],
306 + j_budget=j_budget,
307 + )
308 +
309 + run(dry._J_BUDGET)
310 + with pytest.raises(ValueError, match="j_budget"):
311 + run(dry._J_BUDGET + 1)
312 +
313 +
314 +# ---------------------------------------------------------------------------
315 +# The DryState custom logits processor
316 +# ---------------------------------------------------------------------------
317 +
318 +
319 +def _make_vllm_config(speculative_config=None, skip_tokenizer_init=True):
320 + """Stub VllmConfig with only the fields DryState reads."""
321 + return SimpleNamespace(
322 + speculative_config=speculative_config,
323 + model_config=SimpleNamespace(skip_tokenizer_init=skip_tokenizer_init),
324 + )
325 +
326 +
327 +def _make_v2_state(
328 + max_num_reqs=8,
329 + vocab=32,
330 + max_model_len=256,
331 + device=DEVICE,
332 + tokenizer=None,
333 +):
334 + all_tokens = torch.zeros(
335 + max_num_reqs, max_model_len, dtype=torch.int32, device=device
336 + )
337 + req_states = SimpleNamespace(
338 + max_num_reqs=max_num_reqs,
339 + vocab_size=vocab,
340 + device=device,
341 + all_token_ids=SimpleNamespace(gpu=all_tokens),
342 + )
343 + state = DryState(_make_vllm_config(), req_states)
344 + if tokenizer is not None:
345 + # Re-resolve the default set now that a tokenizer is installed.
346 + state._tokenizer = tokenizer
347 + state._default_breaker_ids = state._resolve_breakers(
348 + DEFAULT_DRY_SEQUENCE_BREAKERS
349 + )
350 + return state, all_tokens
351 +
352 +
353 +def _apply(state, logits, idx_mapping_np, seq_lens_np):
354 + """Call ``DryState.apply`` with a LogitsContext built as the sampler builds it.
355 +
356 + Args:
357 + state: the ``DryState`` under test.
358 + logits: [num_reqs, vocab] tensor, one row per request, modified in place.
359 + idx_mapping_np: [num_reqs] batch position -> request slot.
360 + seq_lens_np: [num_reqs] host-side context lengths.
361 +
362 + """
363 + from vllm.v1.worker.gpu.sample.logits_processor import LogitsContext
364 +
365 + n_reqs = idx_mapping_np.shape[0]
366 + assert logits.shape[0] == n_reqs
367 + idx_mapping = torch.from_numpy(idx_mapping_np).to(logits.device)
368 + ctx = LogitsContext(
369 + expanded_idx_mapping=idx_mapping,
370 + idx_mapping=idx_mapping,
371 + idx_mapping_np=idx_mapping_np,
372 + expanded_local_pos=torch.zeros(n_reqs, dtype=torch.int64, device=logits.device),
373 + input_ids=torch.zeros(n_reqs, dtype=torch.int32, device=logits.device),
374 + pos=torch.from_numpy(seq_lens_np.astype("int64") - 1).to(logits.device),
375 + seq_lens_upper_bound_np=seq_lens_np,
376 + )
377 + return state.apply(logits, ctx)
378 +
379 +
380 +def test_v2_worked_example_and_window_bounds():
381 + # The window is [0, seq_len), including the last input token. Without it every
382 + # penalty lands on the continuation of the previous suffix instead of the one
383 + # being sampled.
384 + state, all_tokens = _make_v2_state()
385 + params = _params(
386 + dry_multiplier=1.0,
387 + dry_base=2.0,
388 + dry_allowed_length=1,
389 + dry_sequence_breakers=[],
390 + )
391 + state.add_request(3, params)
392 + seq = [1, 2, 3, 3, 2, 3, 9, 1, 2, 3]
393 + all_tokens[3, : len(seq)] = torch.tensor(seq, dtype=torch.int32)
394 +
395 + logits = torch.zeros(1, 32, device=DEVICE)
396 + idx_mapping = np.array([3])
397 + # last input token sits at position len(seq)-1
398 + seq_lens = np.array([len(seq)])
399 + _apply(state, logits, idx_mapping, seq_lens)
400 + assert logits[0, 3].item() == -4.0
401 + assert logits[0, 2].item() == -1.0
402 + assert logits[0, 9].item() == -2.0
403 +
404 +
405 +def test_v2_disabled_and_gating():
406 + state, _ = _make_v2_state()
407 + assert not state.add_request(0, SamplingParams()) # dry off by default
408 + assert not state.add_request(1, _params(dry_multiplier=1.0, dry_base=0.5))
409 + assert not state.add_request(2, _params(dry_multiplier=1.0, dry_penalty_last_n=0))
410 + assert not state.use_dry[:3].any()
411 + # Re-adding a slot with DRY off after one with DRY on must clear it.
412 + on = _params(dry_multiplier=0.8, dry_sequence_breakers=[])
413 + assert state.add_request(4, on)
414 + assert state.use_dry[4]
415 + assert not state.add_request(4, SamplingParams())
416 + assert not state.use_dry[4]
417 +
418 +
419 +def test_v2_penalty_last_n_window():
420 + # A cap smaller than the history must limit the visible window.
421 + state, all_tokens = _make_v2_state()
422 + params = _params(
423 + dry_multiplier=1.0,
424 + dry_base=2.0,
425 + dry_allowed_length=1,
426 + dry_penalty_last_n=4,
427 + dry_sequence_breakers=[],
428 + )
429 + state.add_request(0, params)
430 + seq = [7] * 20
431 + all_tokens[0, : len(seq)] = torch.tensor(seq, dtype=torch.int32)
432 + logits = torch.zeros(1, 32, device=DEVICE)
433 + _apply(state, logits, np.array([0]), np.array([20]))
434 + # window = last 4 tokens of an identical run: longest match ending
435 + # before the suffix is 3 (rep-limited by the window), penalty 2**(3-1).
436 + assert logits[0, 7].item() == -4.0
437 +
438 +
439 +def test_spec_decode_is_refused_at_construction():
440 + """A speculative config makes DRY refuse to load."""
441 + req_states = SimpleNamespace(
442 + max_num_reqs=4,
443 + vocab_size=32,
444 + device=DEVICE,
445 + all_token_ids=SimpleNamespace(gpu=torch.zeros(4, 8, dtype=torch.int32)),
446 + )
447 + config = _make_vllm_config(speculative_config=SimpleNamespace())
448 + with pytest.raises(ValueError, match="speculative decoding"):
449 + DryState(config, req_states)
450 +
451 +
452 +def test_v2_draft_expanded_logits_raise():
453 + """``DryState.apply`` raises on more logits rows than requests.
454 +
455 + Only speculative decoding lays logits out that way, and construction refuses it.
456 + """
457 + from vllm.v1.worker.gpu.sample.logits_processor import LogitsContext
458 +
459 + state, _ = _make_v2_state()
460 + state.add_request(0, _params(dry_multiplier=0.8, dry_sequence_breakers=[]))
461 + # One request with one draft token has two logits rows.
462 + rows = torch.zeros(2, dtype=torch.int64, device=DEVICE)
463 + ctx = LogitsContext(
464 + expanded_idx_mapping=rows,
465 + idx_mapping=rows[:1],
466 + idx_mapping_np=np.array([0]),
467 + expanded_local_pos=torch.arange(2, device=DEVICE),
468 + input_ids=torch.zeros(2, dtype=torch.int32, device=DEVICE),
469 + pos=torch.tensor([7, 8], device=DEVICE),
470 + seq_lens_upper_bound_np=np.array([9]),
471 + )
472 + logits = torch.zeros(2, 32, device=DEVICE)
473 + with pytest.raises(RuntimeError, match="draft-expanded"):
474 + state.apply(logits, ctx)
475 + assert not logits.any()
476 +
477 +
478 +def test_constructs_without_speculative_config():
479 + state, _ = _make_v2_state()
480 + assert isinstance(state, DryState)
481 + assert state._tokenizer is None # skip_tokenizer_init in the stub config
482 +
483 +
484 +@pytest.mark.parametrize("chunk_budget", [4096, dry._CHUNK_BYTE_BUDGET])
485 +def test_v2_matches_reference_fuzz(chunk_budget, monkeypatch):
486 + # The V2 window-gather and routing path must agree with the sequential
487 + # reference on randomized histories, including degenerate bases that
488 + # route through the slow path. At these sizes the default budget is always one
489 + # chunk. The 4096 B case splits most calls into several, to cover how per-chunk
490 + # results combine.
491 + monkeypatch.setattr(dry, "_CHUNK_BYTE_BUDGET", chunk_budget)
492 + state, all_tokens = _make_v2_state(max_num_reqs=8, vocab=16, max_model_len=200)
493 + rng = random.Random(7)
494 + for trial in range(60):
495 + n_reqs = rng.randint(1, 5)
496 + histories = {}
497 + seq_lens = []
498 + idx_list = []
499 + for r in range(n_reqs):
500 + args: dict[str, Any] = dict(
501 + dry_multiplier=rng.choice([0.8, 1.0, 2.0]),
502 + dry_base=rng.choice([1.0000005, 1.1, 1.75, 2.0]),
503 + dry_allowed_length=rng.randint(0, 3),
504 + dry_penalty_last_n=rng.choice([-1, 5, 50]),
505 + dry_sequence_breakers=[],
506 + )
507 + state.add_request(r, _params(**args))
508 + # Breaker ids go straight into the state and must follow add_request,
509 + # which pops the slot's ids. String resolution is tested above.
510 + breakers = frozenset(rng.sample(range(4), rng.randint(0, 2)))
511 + if breakers:
512 + state.breaker_ids[r] = breakers
513 + n = rng.randint(2, 180)
514 + hist = [rng.randrange(rng.randint(1, 4) + 1) for _ in range(n)]
515 + all_tokens[r, :n] = torch.tensor(hist, dtype=torch.int32)
516 + histories[r] = (args, hist, breakers)
517 + idx_list.append(r)
518 + seq_lens.append(n)
519 +
520 + logits = torch.zeros(n_reqs, 16, device=DEVICE)
521 + _apply(state, logits, np.array(idx_list), np.array(seq_lens))
522 + for r in range(n_reqs):
523 + args, hist, breakers = histories[r]
524 + base32 = float(np.float32(args["dry_base"]))
525 + if base32 < 1.0 or not args["dry_multiplier"]:
526 + want: dict[int, float] = {}
527 + else:
528 + last_n = args["dry_penalty_last_n"]
529 + w = len(hist) if last_n == -1 else min(len(hist), last_n)
530 + allowed = args["dry_allowed_length"]
531 + window = hist[-w:] if w > allowed else None
532 + want = (
533 + _dry_penalties(
534 + window,
535 + breakers,
536 + float(np.float32(args["dry_multiplier"])),
537 + base32,
538 + allowed,
539 + _max_exponent(base32),
540 + )
541 + if window is not None
542 + else {}
543 + )
544 + for tok in range(16):
545 + expected = -want.get(tok, 0.0)
546 + got = logits[r, tok].item()
547 + assert math.isclose(expected, got, rel_tol=1e-5, abs_tol=1e-6), (
548 + f"trial {trial} req {r} tok {tok}: {expected} vs {got}"
549 + )
550 +
551 +
552 +def test_v2_batch_sharded_sampling_matches_unsharded(monkeypatch):
553 + """Each rank's DRY rows under batch-sharded sampling match the unsharded rows.
554 +
555 + With ``--enable-batch-sharded-sampling``, ``BatchSharder`` gives every TP rank
556 + a local batch of the requests it owns and localizes the two fields DRY reads
557 + from ``LogitsContext``: ``idx_mapping_np``, and ``seq_lens_cpu_upper_bound``,
558 + which the sampler passes as ``seq_lens_upper_bound_np``. Its Triton plan kernel
559 + writes only device fields DRY does not read, so it is replaced here and the
560 + sharding runs on the host.
561 + """
562 + from dataclasses import replace
563 + from unittest import mock
564 +
565 + from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
566 + from vllm.v1.worker.gpu.sample import batch_shard
567 +
568 + vocab = 16
569 + state, all_tokens = _make_v2_state(
570 + max_num_reqs=8, vocab=vocab, tokenizer=_StubTokenizer()
571 + )
572 + # Slots out of batch order, so a rank's rows are not its slots, and only rank 2 at
573 + # tp_size 3 owns a prefix of the batch. Slot 3 has DRY off, slot 6 takes the
574 + # sequential path and slot 0 caps its window. Slot 5 breaks on "\n" (tokens 1
575 + # and 2), slot 6 on "a" (token 0), and slots 7 and 1 on "b" (token 2).
576 + slots = np.array([5, 2, 7, 0, 3, 6, 1])
577 + seq_lens = np.array([40, 23, 57, 31, 12, 48, 36])
578 + breakers = {5: ["\n"], 6: ["a"], 7: ["b"], 1: ["b"]}
579 + rng = random.Random(5)
580 + for slot, n in zip(slots.tolist(), seq_lens.tolist()):
581 + args: dict[str, Any] = dict(
582 + dry_multiplier=0.8, dry_sequence_breakers=breakers.get(slot, [])
583 + )
584 + if slot == 6:
585 + args["dry_base"] = 1.0000005
586 + if slot == 0:
587 + args["dry_penalty_last_n"] = 16
588 + state.add_request(slot, SamplingParams() if slot == 3 else _params(**args))
589 + hist = [rng.randrange(3) for _ in range(n)]
590 + all_tokens[slot, :n] = torch.tensor(hist, dtype=torch.int32)
591 + assert state.breaker_ids == {5: {1, 2}, 6: {0}, 7: {2}, 1: {2}}
592 + full = torch.zeros(len(slots), vocab, device=DEVICE)
593 + _apply(state, full, slots, seq_lens)
594 + assert (full < 0).any(dim=1).tolist() == [s != 3 for s in slots.tolist()]
595 +
596 + buffers = InputBuffers(max_num_reqs=8, max_num_tokens=len(slots), device=DEVICE)
597 + batch = replace(
598 + InputBatch.make_dummy(len(slots), len(slots), buffers),
599 + req_ids=[f"req-{i}" for i in range(len(slots))],
600 + idx_mapping=torch.from_numpy(slots).to(DEVICE),
601 + idx_mapping_np=slots,
602 + seq_lens_cpu_upper_bound=torch.from_numpy(seq_lens),
603 + )
604 + monkeypatch.setattr(batch_shard, "_build_shard_plan_kernel", mock.MagicMock())
605 + for tp_size in (2, 3):
606 + covered: list[int] = []
607 + for rank in range(tp_size):
608 + group = SimpleNamespace(rank_in_group=rank, world_size=tp_size)
609 + with mock.patch.object(batch_shard, "get_tp_group", return_value=group):
610 + sharder = batch_shard.BatchSharder(
611 + max_num_reqs=8, max_num_logits_per_req=1, device=DEVICE
612 + )
613 + local, _, _, _ = sharder.shard_sampler_inputs(batch, None)
614 + owned = [int(req_id.split("-")[1]) for req_id in local.req_ids]
615 + covered += owned
616 + logits = torch.zeros(len(owned), vocab, device=DEVICE)
617 + seq_lens_np = local.seq_lens_cpu_upper_bound.numpy()
618 + _apply(state, logits, local.idx_mapping_np, seq_lens_np)
619 + assert torch.equal(logits, full[owned]), f"tp_size {tp_size} rank {rank}"
620 + assert sorted(covered) == list(range(len(slots)))
621 +
622 +
623 +def test_v2_breaker_strings_resolve_and_reach_dry_core():
624 + # Breaker strings arrive in extra_args and are resolved by the processor's
625 + # own tokenizer; the ids must reach the penalty computation, and the slot
626 + # must not inherit them when it is reused.
627 + state, all_tokens = _make_v2_state(tokenizer=_StubTokenizer())
628 + breaking = _params(
629 + dry_multiplier=1.0,
630 + dry_base=2.0,
631 + dry_allowed_length=1,
632 + dry_sequence_breakers=["*"],
633 + )
634 + state.add_request(0, breaking)
635 + # "*" is token 6 and is contained in token 7 ("**"), so both break.
636 + assert state.breaker_ids[0] == frozenset({6, 7})
637 + all_tokens[0, :8] = 7
638 + logits = torch.zeros(1, 32, device=DEVICE)
639 + _apply(state, logits, np.array([0]), np.array([8]))
640 + assert logits[0, 7].item() == 0.0
641 +
642 + fresh = _params(
643 + dry_multiplier=1.0,
644 + dry_base=2.0,
645 + dry_allowed_length=1,
646 + dry_sequence_breakers=[],
647 + )
648 + state.add_request(0, fresh)
649 + assert 0 not in state.breaker_ids
650 + logits2 = torch.zeros(1, 32, device=DEVICE)
651 + _apply(state, logits2, np.array([0]), np.array([8]))
652 + assert logits2[0, 7].item() < 0.0
653 +
654 +
655 +def test_breaker_mask_cache_is_bounded():
656 + """The device-mask cache must not grow with the number of distinct sets."""
657 + state, _ = _make_v2_state(vocab=128)
658 + sets = [frozenset({i}) for i in range(_MAX_CACHED_BREAKER_MASKS + 8)]
659 + for breakers in sets:
660 + state.breaker_ids[0] = breakers
661 + assert state._breaker_mask(0) is not None
662 + assert len(state._breaker_masks) <= _MAX_CACHED_BREAKER_MASKS
663 + # The first set was evicted and the newest kept.
664 + assert sets[-1] in state._breaker_masks
665 + assert sets[0] not in state._breaker_masks
666 +
667 +
668 +def test_default_breaker_set_is_resolved_eagerly():
669 + """A request that names no breakers must not pay to resolve them."""
670 + state, _ = _make_v2_state(tokenizer=_StubTokenizer())
671 + # "\n" -> {1, 2}, ":" -> {2, 4, 5}, '"' -> {}, "*" -> {6, 7}.
672 + assert state._default_breaker_ids == frozenset({1, 2, 4, 5, 6, 7})
673 + state.add_request(0, _params(dry_multiplier=0.8))
674 + assert state.breaker_ids[0] is state._default_breaker_ids
675 + # Naming the default set explicitly must land on the same object.
676 + state.add_request(
677 + 1,
678 + _params(
679 + dry_multiplier=0.8,
680 + dry_sequence_breakers=list(DEFAULT_DRY_SEQUENCE_BREAKERS),
681 + ),
682 + )
683 + assert state.breaker_ids[1] is state._default_breaker_ids
684 +
685 +
686 +def test_init_warms_up_dry_core_once(monkeypatch):
687 + """__init__ calls dry_core once, the way apply calls it."""
688 + calls = []
689 + # Taken before the patch below replaces it in the same module.
690 + original = dry.dry_core
691 +
692 + def spy(logits, **kwargs):
693 + calls.append((logits, kwargs))
694 + return original(logits, **kwargs)
695 +
696 + def kernel_args(logits, kwargs):
697 + def sig(t):
698 + return None if t is None else (t.dtype, t.dim(), t.device)
699 +
700 + masks = {sig(m) for m in kwargs["breaker_masks"]}
701 + other = {
702 + name: sig(arg) if isinstance(arg, torch.Tensor) else type(arg)
703 + for name, arg in kwargs.items()
704 + if name != "breaker_masks"
705 + }
706 + return sig(logits), masks, other
707 +
708 + monkeypatch.setattr("examples.features.logits_processor.dry.dry_core", spy)
709 + _make_v2_state(vocab=1)
710 + assert not calls, "a vocabulary of one leaves no id for the breaker"
711 + state, all_tokens = _make_v2_state(vocab=64, tokenizer=_StubTokenizer())
712 + assert len(calls) == 1
713 + assert (calls[0][0] < 0).any(), "the warmup matched nothing"
714 + assert not state._breaker_masks
715 + assert not state.breaker_ids
716 + assert not state.use_dry.any()
717 +
718 + state.add_request(0, _params(dry_multiplier=0.8))
719 + all_tokens[0, :8] = 3
720 + _apply(state, torch.zeros(1, 64, device=DEVICE), np.array([0]), np.array([8]))
721 + assert len(calls) == 2
722 + assert kernel_args(*calls[0]) == kernel_args(*calls[1])
723 +
724 +
725 +def test_no_tokenizer_warns_once_and_still_penalizes():
726 + # Without a tokenizer, breaker strings are dropped with one warning.
727 + state, all_tokens = _make_v2_state()
728 + assert state._tokenizer is None
729 + assert state._default_breaker_ids == frozenset()
730 + state.add_request(0, _params(dry_multiplier=0.8))
731 + assert state._warned_unresolved
732 + assert 0 not in state.breaker_ids
733 + all_tokens[0, :8] = 7
734 + logits = torch.zeros(1, 32, device=DEVICE)
735 + _apply(state, logits, np.array([0]), np.array([8]))
736 + assert logits[0, 7].item() < 0.0
737 +
738 +
739 +def test_v2_degenerate_base_routes_to_unclamped_slow_path():
740 + # base <= 1.000001 gives max_exponent == 0, which in the sequential
741 + # reference means "no clamp"; the vectorized path would clamp the
742 + # exponent to 0, so such requests must route to the reference scan.
743 + state, all_tokens = _make_v2_state()
744 + params = _params(
745 + dry_multiplier=1.0,
746 + dry_base=1.0000005,
747 + dry_allowed_length=0,
748 + dry_sequence_breakers=[],
749 + )
750 + state.add_request(0, params)
751 + all_tokens[0, :150] = 7
752 + logits = torch.zeros(1, 32, device=DEVICE)
753 + _apply(state, logits, np.array([0]), np.array([150]))
754 + base32 = float(np.float32(1.0000005))
755 + want = -1.0 * base32**149
756 + got = logits[0, 7].item()
757 + assert math.isclose(got, want, rel_tol=1e-6)
758 + assert got != -1.0
759 +
760 +
761 +def test_breaker_resolution_covers_added_tokens():
762 + class AddedTokenTokenizer:
763 + _TEXTS = ["a", "\n", "b", "<|im_start|>"]
764 + vocab_size = 3 # the added token sits above vocab_size
765 + max_token_id = 3
766 +
767 + def batch_decode(self, ids_lists):
768 + return [self._TEXTS[ids[0]] for ids in ids_lists]
769 +
770 + from examples.features.logits_processor.dry import resolve_dry_breakers
771 +
772 + tok = AddedTokenTokenizer()
773 + assert resolve_dry_breakers(tok, ("<|im_start|>",)) == [3]
774 + assert resolve_dry_breakers(tok, ("\n",)) == [1]
775 +
776 +
777 +class _TextsTokenizer:
778 + """Tokenizer stub over an explicit list of decoded token texts."""
779 +
780 + def __init__(self, texts: list[str], vocab_size: int):
781 + self._texts = texts
782 + self.vocab_size = vocab_size
783 + self.max_token_id = len(texts) - 1
784 +
785 + def batch_decode(self, ids_lists):
786 + return [self._texts[ids[0]] for ids in ids_lists]
787 +
788 +
789 +def _containment_scan(texts: list[str], breaker_strs: tuple[str, ...]) -> list[int]:
790 + """Resolution as a plain scan: the semantics the character index must keep."""
791 + ids: set[int] = set()
792 + for s in breaker_strs:
793 + ids.update(i for i, text in enumerate(texts) if s in text)
794 + return sorted(ids)
795 +
796 +
797 +def test_breaker_index_matches_containment_scan():
798 + """Breaker resolution must match a plain containment scan.
799 +
800 + Every character of a synthetic vocabulary is checked, including multi-byte
801 + and combining ones, added tokens and an empty token, then multi-character
802 + and mixed breaker sets.
803 + """
804 + from examples.features.logits_processor.dry import (
805 + _MAX_BREAKER_CHAR_LEN,
806 + _vocab_index,
807 + resolve_dry_breakers,
808 + )
809 +
810 + rng = random.Random(11)
811 + # U+4E2D and U+1F600 are multi-byte in UTF-8. "e\u0301" is two code points, so
812 + # U+0301 alone resolves by lookup and the pair by scan.
813 + alphabet = list('abcde \n:"*.,') + ["\u4e2d", "\u00e9", "e\u0301", "\U0001f600"]
814 + texts = [""] # a token that decodes to nothing
815 + for _ in range(8000):
816 + texts.append("".join(rng.choices(alphabet, k=rng.randint(1, 12))))
817 + vocab_size = len(texts)
818 + # Added tokens, above vocab_size; one holds the vocabulary's only U+2028.
819 + texts += ["<|im_start|>", "<|im_end|>", "\u2028sep"]
820 + tok = _TextsTokenizer(texts, vocab_size)
821 +
822 + def check(breakers: tuple[str, ...]) -> list[int]:
823 + kept = tuple(s[:_MAX_BREAKER_CHAR_LEN] for s in breakers if s)
824 + want = _containment_scan(texts, kept)
825 + assert resolve_dry_breakers(tok, breakers) == want, breakers
826 + return want
827 +
828 + # Every character present is indexed, so a lookup miss means "in no token".
829 + present = sorted(set("".join(texts)))
830 + assert set(_vocab_index(tok).char_ids) == set(present)
831 + for char in present:
832 + check((char,))
833 + assert len(check(("a",))) > 2000 # a character in thousands of tokens
834 + assert check(("\u2028",)) == [len(texts) - 1] # only above vocab_size
835 + for absent in ("q", "Z", "\u2603", "\U0001f601", "\u0300"):
836 + assert check((absent,)) == []
837 +
838 + substrings = []
839 + for _ in range(300):
840 + text = rng.choice(texts[1:vocab_size])
841 + start = rng.randrange(len(text))
842 + substrings.append(text[start : start + rng.randint(2, 5)])
843 + for s in substrings + ["<|im_start|>", "e\u0301", "\u2028sep", "zz", "a" * 41]:
844 + check((s,))
845 + for _ in range(200):
846 + check(tuple(rng.sample(present, rng.randint(1, 4)) + rng.sample(substrings, 2)))
847 + assert check(()) == []
848 + assert check(("",)) == []
849 + assert check(("", "\n")) == check(("\n",))
850 +
851 +
852 +# The accumulator scales with R and the chunk transients do not, hence two batch
853 +# sizes. The breaker path's masks get their own case.
854 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
855 +@pytest.mark.parametrize(
856 + ("n_reqs", "limit_mib", "n_breakers"),
857 + [(32, 128, 0), (256, 256, 0), (256, 288, 3900)],
858 +)
859 +def test_peak_memory_bounded(n_reqs, limit_mib, n_breakers):
860 + # base=1.1 gives max_exponent=930, a large J for the vectorized path
861 + # (the cap is allowed_length + max_exponent <= _J_BUDGET = 2048).
862 + device = torch.device("cuda")
863 + rng = random.Random(3)
864 + window = 2048
865 + state, all_tokens = _make_v2_state(
866 + max_num_reqs=n_reqs, vocab=128256, max_model_len=window, device=device
867 + )
868 + params = _params(
869 + dry_multiplier=0.8,
870 + dry_base=1.1,
871 + dry_allowed_length=2,
872 + dry_sequence_breakers=[],
873 + )
874 + breaker_ids: frozenset[int] = frozenset()
875 + if n_breakers:
876 + # About the size of llama.cpp's default set on a Llama-3 tokenizer. Id 0 is
877 + # in the history, so breakers cut some matches but not all.
878 + breaker_ids = frozenset({0} | set(rng.sample(range(8, 128256), n_breakers - 1)))
879 + # Eight symbols, so 2-token repeats are common.
880 + hist = torch.tensor(
881 + [[rng.randrange(8) for _ in range(window)] for _ in range(n_reqs)],
882 + dtype=torch.int32,
883 + )
884 + all_tokens.copy_(hist.to(device))
885 + for r in range(n_reqs):
886 + state.add_request(r, params)
887 + if breaker_ids:
888 + state.breaker_ids[r] = breaker_ids
889 + logits = torch.zeros(n_reqs, 128256, device=device)
890 + torch.accelerator.synchronize()
891 + torch.accelerator.reset_peak_memory_stats()
892 + base_alloc = torch.accelerator.memory_allocated()
893 + _apply(state, logits, np.arange(n_reqs), np.full(n_reqs, window))
894 + torch.accelerator.synchronize()
895 + peak = torch.accelerator.max_memory_allocated() - base_alloc
896 + charged = int((logits < 0).sum().item())
897 + assert charged > 0, "no token was penalized"
898 + # The limits catch the exponent and pow being held at full [R, vocab] width.
899 + limit = limit_mib * 1024 * 1024
900 + assert peak < limit, f"peak {peak / 2**20:.1f} MiB over {limit / 2**20:.0f} MiB"
901 +
902 +
903 +# ---------------------------------------------------------------------------
904 +# Plugin wiring: loading by FQCN, and validation at request admission.
905 +# ---------------------------------------------------------------------------
906 +
907 +
908 +def test_loads_through_the_loader_by_fqcn():
909 + """The example's FQCN must resolve and construct."""
910 + req_states = SimpleNamespace(
911 + device=DEVICE,
912 + max_num_reqs=4,
913 + vocab_size=32,
914 + all_token_ids=SimpleNamespace(gpu=torch.zeros(4, 8, dtype=torch.int32)),
915 + prompt_len=None,
916 + prefill_len=None,
917 + total_len=None,
918 + )
919 + procs = loader.build_custom_logits_processors(
920 + _make_vllm_config(), req_states, False, [DRY_FQCN]
921 + )
922 + assert [type(p) for p in procs] == [DryState]
923 +
924 +
925 +def test_loader_import_does_not_initialize_cuda():
926 + """Importing dry.py must not initialize CUDA.
927 +
928 + The frontend imports it under ``guard_cuda_initialization()`` to validate
929 + params, and a device touched at import time fails the engine at startup.
930 + """
931 + code = (
932 + "import torch\n"
933 + "from vllm.utils.torch_utils import guard_cuda_initialization\n"
934 + "with guard_cuda_initialization():\n"
935 + " import examples.features.logits_processor.dry # noqa: F401\n"
936 + "assert not torch.cuda.is_initialized()\n"
937 + )
938 + # From the repository root, where `examples` is importable.
939 + subprocess.run(
940 + [sys.executable, "-c", code], check=True, cwd=Path(__file__).parents[3]
941 + )
942 +
943 +
944 +def test_admission_validator_rejects_bad_extra_args():
945 + """The real frontend validator must turn a bad dry_* value into a 400.
946 +
947 + ``build_custom_logits_processors_params_validator`` is what
948 + ``InputProcessor`` calls per request; it wraps a processor's ``ValueError``
949 + as ``VLLMValidationError``, which the entrypoints map to a client error.
950 + """
951 + loader._cached_load_v2_logitsprocs.cache_clear()
952 + validate = loader.build_custom_logits_processors_params_validator([DRY_FQCN])
953 + validate(SamplingParams())
954 + validate(_params(dry_multiplier=0.8))
955 + with pytest.raises(VLLMValidationError, match="dry_multiplier"):
956 + validate(_params(dry_multiplier=-1.0))
957 + with pytest.raises(VLLMValidationError, match="dry_multipliers"):
958 + validate(_params(dry_multiplier=0.8, dry_multipliers=0.8))
959 +
960 +
961 +@pytest.mark.parametrize("field", ["dry_penalty_last_n", "dry_allowed_length"])
962 +def test_dry_int_params_upper_bound(field):
963 + """Both integer fields are capped at INT32_MAX; see ``_DRY_INT_MAX``."""
964 + for bad in (2**63, 2**31):
965 + with pytest.raises(ValueError, match=field):
966 + DryState.validate_params(_params(dry_multiplier=0.8, **{field: bad}))
967 + # The boundary itself is legal, and so are the ordinary values.
968 + DryState.validate_params(_params(dry_multiplier=0.8, **{field: 2**31 - 1}))
969 + DryState.validate_params(_params(dry_multiplier=0.8, **{field: 2}))
970 +
971 +
972 +def test_dry_base_below_one_warns_that_it_disabled_dry():
973 + """`dry_base < 1.0` disables DRY, as in llama.cpp; validate_params warns.
974 +
975 + Patches the module logger because vLLM's logger does not propagate to caplog.
976 + """
977 + from unittest.mock import patch
978 +
979 + with patch("examples.features.logits_processor.dry.logger") as log:
980 + DryState.validate_params(_params(dry_multiplier=0.8, dry_base=0.8))
981 + assert not use_dry(0.8, 0.8, -1), "base < 1.0 must disable DRY"
982 + assert log.warning.called, "expected a warning"
983 + assert "disables DRY" in log.warning.call_args[0][0]
984 +
985 + # A normal configuration says nothing.
986 + with patch("examples.features.logits_processor.dry.logger") as log:
987 + DryState.validate_params(_params(dry_multiplier=0.8, dry_base=1.75))
988 + assert use_dry(0.8, 1.75, -1)
989 + assert not log.warning.called
990 +
991 +
992 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
993 +def test_result_does_not_depend_on_the_chunk_budget():
994 + """Logits must not depend on how the offsets are chunked."""
995 + from examples.features.logits_processor import dry as dc
996 +
997 + device = torch.device("cuda")
998 + vocab, window, n_reqs = 64, 600, 1
999 + # A short alphabet repeated, so one follower token recurs at offsets far
1000 + # enough apart to land in different chunks.
1001 + W = torch.tensor([([5, 6, 7] * 200)[:window]], dtype=torch.int64, device=device)
1002 + col = lambda v, dt=torch.int64: torch.full((n_reqs,), v, dtype=dt, device=device) # noqa: E731
1003 +
1004 + def run(budget):
1005 + saved = dc._CHUNK_BYTE_BUDGET
1006 + dc._CHUNK_BYTE_BUDGET = budget
1007 + try:
1008 + logits = torch.zeros(n_reqs, vocab, device=device)
1009 + dc.dry_core(
1010 + logits,
1011 + torch.arange(n_reqs, device=device),
1012 + W,
1013 + col(window),
1014 + col(2),
1015 + col(60),
1016 + col(0.8, torch.float32),
1017 + col(1.75, torch.float32),
1018 + [None] * n_reqs,
1019 + j_budget=62,
1020 + )
1021 + return logits
1022 + finally:
1023 + dc._CHUNK_BYTE_BUDGET = saved
1024 +
1025 + many = run(4096)
1026 + one = run(1 << 30)
1027 + assert torch.equal(many, one), (
1028 + f"chunking changed the result: {many[0][many[0] < 0][:3].tolist()} vs "
1029 + f"{one[0][one[0] < 0][:3].tolist()}"
1030 + )
1031 + assert (many < 0).any(), "nothing was penalized"