[CI][Spec Decode] Add async scheduling accuracy tests (#55840)

Signed-off-by: Andreas Karatzas <Andreas.Karatzas@amd.com> Signed-off-by: Andreas Karatzas <andreas.karatzas@protonmail.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Andreas Karatzas committed Sep 30, 2026 at 20:06 UTC df8fd42116f172b7a53bc10c8a680b05232edbed
4 files changed +1166 -3
.buildkite/test-amd.yaml
+29 -1
@@ -454,7 +454,7 @@ steps:
454 - tests/v1/e2e/general/
455 - vllm/platforms/rocm.py
456 commands:
457 - - pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py --ignore v1/e2e/general/test_kv_sharing_fast_prefill.py -k "not test_mamba_prefix_cache_mrv2"
457 + - pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py --ignore v1/e2e/general/test_async_scheduling_accuracy.py --ignore v1/e2e/general/test_kv_sharing_fast_prefill.py -k "not test_mamba_prefix_cache_mrv2"
458
459 - label: ":amd: (MI250) E2E Scheduling" # TBD
460 timeout_in_minutes: 55
@@ -470,6 +470,20 @@ steps:
470 commands:
471 - pytest -v -s v1/e2e/general/test_async_scheduling.py
472
473 +- label: ":amd: (MI250) E2E Scheduling Accuracy" # TBD
474 + timeout_in_minutes: 55
475 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250]
476 + dind: false
477 + agent_pool: mi250_1
478 + optional: true
479 + working_dir: "/vllm-workspace/tests"
480 + source_file_dependencies:
481 + - vllm/v1/
482 + - tests/v1/e2e/general/
483 + - vllm/platforms/rocm.py
484 + commands:
485 + - pytest -v -s v1/e2e/general/test_async_scheduling_accuracy.py
486 +
487 - label: ":amd: (MI250) V1 Engine" # TBD
488 timeout_in_minutes: 45
489 mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250]
@@ -2654,6 +2668,20 @@ steps:
2668 commands:
2669 - pytest -v -s v1/e2e/general/test_async_scheduling.py
2670
2671 +- label: ":amd: (MI300) E2E Scheduling Accuracy" # TBD
2672 + timeout_in_minutes: 55
2673 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300]
2674 + dind: false
2675 + agent_pool: mi300_1
2676 + optional: true
2677 + working_dir: "/vllm-workspace/tests"
2678 + source_file_dependencies:
2679 + - vllm/v1/
2680 + - tests/v1/e2e/general/
2681 + - vllm/platforms/rocm.py
2682 + commands:
2683 + - pytest -v -s v1/e2e/general/test_async_scheduling_accuracy.py
2684 +
2685 - label: ":amd: (MI300) V1 Engine" # TBD
2686 timeout_in_minutes: 45
2687 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300]
.buildkite/test_areas/engine.yaml
+22 -2
@@ -85,6 +85,26 @@ steps:
85 source_file_dependencies:
86 - vllm/platforms/rocm.py
87
88 +- label: ":nvidia: (H200 MIG 18GB) E2E Scheduling Accuracy"
89 + key: e2e-scheduling-accuracy-1-gpu
90 + timeout_in_minutes: 53
91 + device: h200_18gb
92 + source_file_dependencies:
93 + - vllm/v1/
94 + - tests/v1/e2e/general/
95 + commands:
96 + - pytest -v -s v1/e2e/general/test_async_scheduling_accuracy.py
97 + mirror:
98 + amd:
99 + label: ":amd: (MI250) E2E Scheduling Accuracy"
100 + dind: false
101 + device: mi250_1
102 + timeout_in_minutes: 55
103 + depends_on:
104 + - image-build-amd
105 + source_file_dependencies:
106 + - vllm/platforms/rocm.py
107 +
108 - label: ":nvidia: (H200 MIG 18GB) E2E Core"
109 device: h200_18gb
110 key: e2e-core-1-gpu
@@ -93,13 +113,13 @@ steps:
113 - vllm/v1/
114 - tests/v1/e2e/general/
115 commands:
96 - - pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py --ignore v1/e2e/general/test_kv_sharing_fast_prefill.py -k "not test_mamba_prefix_cache_mrv2"
116 + - pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py --ignore v1/e2e/general/test_async_scheduling_accuracy.py --ignore v1/e2e/general/test_kv_sharing_fast_prefill.py -k "not test_mamba_prefix_cache_mrv2"
117 mirror:
118 amd:
119 label: ":amd: (MI250) E2E Core"
120 dind: false
121 commands:
102 - - pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py --ignore v1/e2e/general/test_kv_sharing_fast_prefill.py -k "not test_mamba_prefix_cache_mrv2"
122 + - pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py --ignore v1/e2e/general/test_async_scheduling_accuracy.py --ignore v1/e2e/general/test_kv_sharing_fast_prefill.py -k "not test_mamba_prefix_cache_mrv2"
123 device: mi250_1
124 timeout_in_minutes: 50
125 depends_on:
tests/v1/e2e/general/accuracy_utils.py new
+491
@@ -0,0 +1,491 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Numerical checks against an independent, teacher-forced target model."""
4 +
5 +import json
6 +import math
7 +from collections import Counter
8 +from dataclasses import dataclass
9 +from functools import lru_cache
10 +
11 +import torch
12 +import xgrammar as xgr
13 +from tokenizers.decoders import DecodeStream
14 +
15 +from tests.conftest import HfRunner
16 +from vllm import SamplingParams
17 +from vllm.logprobs import Logprob
18 +from vllm.outputs import RequestOutput
19 +from vllm.tokenizers.detokenizer_utils import convert_ids_list_to_tokens
20 +from vllm.v1.engine.logprobs import LogprobsProcessor
21 +
22 +
23 +@dataclass(frozen=True)
24 +class AccuracyTolerance:
25 + logprob_atol: float
26 + greedy_atol: float
27 + logprob_mean_atol: float = 0.1
28 + greedy_total_atol: float = 0.5
29 +
30 + def logprob_error(self, reference: torch.Tensor) -> torch.Tensor:
31 + return torch.full_like(reference, self.logprob_atol)
32 +
33 +
34 +def check_first_divergence(
35 + baseline: RequestOutput,
36 + actual: RequestOutput,
37 + params: SamplingParams,
38 + tolerance: AccuracyTolerance,
39 + context: str,
40 +) -> None:
41 + """Require the two engines' scores to justify their first different choice."""
42 + assert baseline.finished and actual.finished, context
43 + assert len(baseline.outputs) == len(actual.outputs) == 1, context
44 + assert baseline.prompt_token_ids is not None, context
45 + assert baseline.prompt_token_ids == actual.prompt_token_ids, context
46 + base, other = baseline.outputs[0], actual.outputs[0]
47 + position = next(
48 + (i for i, (a, b) in enumerate(zip(base.token_ids, other.token_ids)) if a != b),
49 + None,
50 + )
51 + if position is None:
52 + assert len(base.token_ids) == len(other.token_ids), (
53 + f"{context}: one completion is a strict prefix of the other"
54 + )
55 + assert (base.finish_reason, base.stop_reason) == (
56 + other.finish_reason,
57 + other.stop_reason,
58 + ), f"{context}: identical tokens have different stopping reasons"
59 + return
60 +
61 + assert params.logprobs is not None and params.logprobs > 0, context
62 + assert params.temperature == 0.0, context
63 + assert params.presence_penalty == 0.0 and params.repetition_penalty == 1.0
64 + assert not params.logit_bias, context
65 + a, b = base.token_ids[position], other.token_ids[position]
66 + counts = Counter(base.token_ids[:position])
67 + context = (
68 + f"{context}, first divergence position={position}, "
69 + f"baseline token={a}, actual token={b}"
70 + )
71 + for side, completion, selected, alternative in (
72 + ("baseline", base, a, b),
73 + ("actual", other, b, a),
74 + ):
75 + assert completion.logprobs is not None, context
76 + assert len(completion.logprobs) == len(completion.token_ids), context
77 + scores = completion.logprobs[position]
78 + assert selected in scores, f"{context}, {side}: missing selected token score"
79 + chosen = scores[selected].logprob
80 + assert math.isfinite(chosen), f"{context}, {side}: non-finite selected score"
81 + known = alternative in scores
82 + if known:
83 + competing = scores[alternative].logprob
84 + assert math.isfinite(competing), (
85 + f"{context}, {side}: divergent token has a non-finite score"
86 + )
87 + else:
88 + # The sampled token may be returned in addition to the requested
89 + # top-k. An omitted token's raw score is bounded by the kth score.
90 + top_scores = [
91 + score.logprob
92 + for token, score in scores.items()
93 + if len(scores) == params.logprobs or token != selected
94 + ]
95 + assert len(top_scores) == params.logprobs, context
96 + assert not any(math.isnan(score) for score in top_scores), context
97 + competing = min(top_scores)
98 +
99 + # Both candidates were emitted with the same history and constraints;
100 + # frequency penalties are the only supported transform changing their
101 + # finite score difference. Prompt tokens do not contribute to counts.
102 + chosen -= params.frequency_penalty * counts[selected]
103 + competing -= params.frequency_penalty * counts[alternative]
104 + gap = chosen - competing
105 + detail = (
106 + f"{context}, {side}: selected={selected}, alternative={alternative}, "
107 + f"{'gap' if known else 'gap lower bound'}={gap}"
108 + )
109 + if known:
110 + # Log-softmax and reconstructing penalties may round in FP32, but
111 + # a near tie cannot excuse selecting the engine's own worse token.
112 + assert gap >= -1e-5, f"{detail}: selected token is below its alternative"
113 + assert gap <= tolerance.greedy_atol, (
114 + f"{detail}: divergence exceeds near-tie bound={tolerance.greedy_atol}"
115 + )
116 +
117 +
118 +def check_logprobs(
119 + scores: dict[int, Logprob],
120 + reference: torch.Tensor,
121 + token: int,
122 + requested: int,
123 + tolerance: AccuracyTolerance,
124 + context: str,
125 +) -> torch.Tensor:
126 + """Check every score, reported rank, and the complete requested top-k."""
127 + assert all(0 <= token_id < reference.numel() for token_id in scores), context
128 + assert token in scores, f"{context}: missing score for token {token}"
129 + assert max(1, requested) <= len(scores) <= requested + 1, context
130 + assert not torch.isnan(reference).any(), context
131 + assert all(s.rank is not None and s.rank >= 1 for s in scores.values()), context
132 + ids = torch.tensor(list(scores), device=reference.device)
133 + actual = torch.tensor(
134 + [score.logprob for score in scores.values()], device=reference.device
135 + )
136 + expected = reference[ids]
137 + finite = torch.isfinite(expected)
138 + assert torch.equal(torch.isfinite(actual), finite), (
139 + f"{context}: finite/masked logprobs differ: {scores}"
140 + )
141 + assert torch.equal(actual[~finite], expected[~finite]), context
142 + assert (actual[finite] <= 0).all(), f"{context}: positive log probability"
143 + errors = (actual[finite] - expected[finite]).abs()
144 + bounds = tolerance.logprob_error(expected[finite])
145 + assert (errors <= bounds).all(), (
146 + f"{context}: token IDs={ids[finite].tolist()}, "
147 + f"actual={actual[finite].tolist()}, reference={expected[finite].tolist()}, "
148 + f"error={errors.tolist()}, bound={bounds.tolist()}"
149 + )
150 +
151 + for token_id, score in scores.items():
152 + value = reference[token_id]
153 + if not torch.isfinite(value):
154 + continue
155 + delta = tolerance.logprob_error(value)
156 + lower = 1 + int((reference > value + 2 * delta).sum())
157 + upper = max(1, int((reference >= value - 2 * delta).sum()))
158 + assert score.rank is not None and lower <= score.rank <= upper, (
159 + f"{context}: token={token_id}, rank={score.rank}, "
160 + f"reference rank interval=[{lower}, {upper}]"
161 + )
162 +
163 + if requested:
164 + # The sampled token can be outside top-k even when it ties the kth
165 + # score. Its strict-greater rank can therefore duplicate a top-k rank.
166 + top_ids = (
167 + list(scores)
168 + if len(scores) == requested
169 + else [t for t in scores if t != token]
170 + )
171 + assert len(top_ids) == requested, f"{context}: incomplete top-k"
172 + assert {scores[t].rank for t in top_ids} == set(range(1, requested + 1)), (
173 + context
174 + )
175 + ordered = sorted(top_ids, key=lambda t: scores[t].rank)
176 + assert all(
177 + scores[a].logprob >= scores[b].logprob for a, b in zip(ordered, ordered[1:])
178 + ), f"{context}: top-k scores disagree with their reported ranks"
179 + boundary = reference.topk(requested).values[-1]
180 + if torch.isfinite(boundary):
181 + delta = tolerance.logprob_error(boundary)
182 + required = (reference > boundary + 2 * delta).nonzero().flatten()
183 + assert set(required.tolist()) <= scores.keys(), (
184 + f"{context}: missing an unambiguously top-{requested} token"
185 + )
186 + assert (reference[top_ids] >= boundary - 2 * delta).all(), (
187 + f"{context}: returned token below the top-{requested} boundary"
188 + )
189 + return errors
190 +
191 +
192 +def check_greedy_token(
193 + logits: torch.Tensor,
194 + token: int,
195 + tolerance: AccuracyTolerance,
196 + context: str,
197 +) -> float:
198 + assert not torch.isnan(logits).any(), context
199 + assert torch.isfinite(logits[token]), (
200 + f"{context}: selected token {token} is forbidden by the sampling parameters"
201 + )
202 + best = int(logits.argmax())
203 + gap = float(logits[best] - logits[token])
204 + assert gap <= tolerance.greedy_atol, (
205 + f"{context}: selected token={token}, reference best={best}, "
206 + f"logit gap={gap}, bound={tolerance.greedy_atol}"
207 + )
208 + return gap
209 +
210 +
211 +def check_accuracy_budget(errors, gaps, tolerance, context):
212 + """Reject systematic sub-bound errors within each request and score stream."""
213 + if errors:
214 + finite_errors = torch.cat(errors)
215 + if finite_errors.numel():
216 + mean_error = float(finite_errors.mean())
217 + assert mean_error <= tolerance.logprob_mean_atol, (
218 + f"{context}: mean logprob error={mean_error}, "
219 + f"bound={tolerance.logprob_mean_atol}"
220 + )
221 + assert sum(gaps) <= tolerance.greedy_total_atol, (
222 + f"{context}: total greedy logit gap={sum(gaps)}, "
223 + f"bound={tolerance.greedy_total_atol}"
224 + )
225 +
226 +
227 +def _check_decoded_tokens(processor, scores, history, context):
228 + ids = list(scores)
229 + decoded = convert_ids_list_to_tokens(processor.tokenizer, ids)
230 + decoded = processor._verify_tokens(decoded, ids, context_token_ids=history[-4:])
231 + for token, text in zip(ids, decoded, strict=True):
232 + assert scores[token].decoded_token == text, (
233 + f"{context}: incorrect decoded text for token {token}"
234 + )
235 +
236 +
237 +@torch.inference_mode()
238 +def check_request_accuracy(
239 + hf: HfRunner,
240 + request: RequestOutput,
241 + params: SamplingParams,
242 + tolerance: AccuracyTolerance,
243 + context: str,
244 +) -> None:
245 + """Score the actual continuation, including positions after any divergence."""
246 + assert request.finished and len(request.outputs) == 1, context
247 + (completion,) = request.outputs
248 + prompt = request.prompt_token_ids
249 + assert prompt is not None and len(prompt) > 0, context
250 + tokens = list(completion.token_ids)
251 + assert params.max_tokens is not None and 0 < len(tokens) <= params.max_tokens, (
252 + context
253 + )
254 + # Incremental decoding buffers incomplete UTF-8; decoding the final token
255 + # list in one call can incorrectly append a replacement character.
256 + stream = DecodeStream(skip_special_tokens=params.skip_special_tokens)
257 + tokenizer = hf.tokenizer.backend_tokenizer
258 + for token in prompt:
259 + stream.step(tokenizer, token)
260 + visible = tokens
261 + if completion.finish_reason == "stop" and not params.include_stop_str_in_output:
262 + visible = tokens[:-1]
263 + expected_text = "".join(stream.step(tokenizer, token) or "" for token in visible)
264 + assert completion.text == expected_text, (
265 + f"{context}: decoded text does not match generated token IDs"
266 + )
267 + inputs = torch.tensor([prompt + tokens], device=hf.device)
268 + logits = hf.model(input_ids=inputs, use_cache=False).logits[0].float()
269 + # Use the existing byte-fallback handling with an independent request
270 + # history; numerical scores always come from the HF model above.
271 + decoder = LogprobsProcessor(
272 + tokenizer=hf.tokenizer,
273 + logprobs=None,
274 + prompt_logprobs=None,
275 + cumulative_logprob=None,
276 + num_logprobs=None,
277 + num_prompt_logprobs=None,
278 + )
279 + # Prompt token i is predicted by the causal logits at position i - 1.
280 + if params.prompt_logprobs is None:
281 + assert request.prompt_logprobs is None, context
282 + else:
283 + assert request.prompt_logprobs is not None, context
284 + assert len(request.prompt_logprobs) == len(prompt), context
285 + assert request.prompt_logprobs[0] is None, context
286 + errors = []
287 + for i, scores in enumerate(request.prompt_logprobs[1:], 1):
288 + assert scores is not None, context
289 + _check_decoded_tokens(decoder, scores, prompt[1:i], context)
290 + errors.append(
291 + check_logprobs(
292 + scores,
293 + logits[i - 1].log_softmax(-1),
294 + prompt[i],
295 + params.prompt_logprobs,
296 + tolerance,
297 + f"{context}, prompt position={i}",
298 + )
299 + )
300 + check_accuracy_budget(errors, [], tolerance, f"{context}, prompt logprobs")
301 +
302 + if params.logprobs is None:
303 + assert completion.logprobs is None, context
304 + else:
305 + assert completion.logprobs is not None, context
306 + assert len(completion.logprobs) == len(tokens), context
307 +
308 + reference = SamplingReference(
309 + hf.tokenizer,
310 + logits.shape[-1],
311 + hf.model.generation_config.to_dict(),
312 + params,
313 + )
314 + errors = []
315 + gaps = []
316 + for i, token in enumerate(tokens):
317 + raw, processed = reference.distributions(
318 + logits[len(prompt) + i - 1], tokens[:i]
319 + )
320 + position = f"{context}, generated position={i}"
321 + gaps.append(check_greedy_token(processed, token, tolerance, position))
322 + if completion.logprobs is not None:
323 + _check_decoded_tokens(decoder, completion.logprobs[i], tokens[:i], position)
324 + errors.append(
325 + check_logprobs(
326 + completion.logprobs[i],
327 + raw.log_softmax(-1),
328 + token,
329 + params.logprobs,
330 + tolerance,
331 + position,
332 + )
333 + )
334 + check_accuracy_budget(errors, gaps, tolerance, f"{context}, completion")
335 + # Advance the grammar through the last token too; an incomplete JSON prefix
336 + # is allowed at max_tokens, but every emitted token must remain valid.
337 + reference.accept_history(tokens)
338 + check_stopping(
339 + tokens,
340 + completion.finish_reason,
341 + reference.stop_ids,
342 + params,
343 + context,
344 + eos_token_id=hf.tokenizer.eos_token_id,
345 + stop_reason=completion.stop_reason,
346 + )
347 +
348 +
349 +def check_stopping(
350 + tokens,
351 + finish_reason,
352 + stop_ids,
353 + params,
354 + context,
355 + *,
356 + eos_token_id: int | None,
357 + stop_reason: int | str | None,
358 +):
359 + assert not params.ignore_eos and not params.stop, context
360 + assert not stop_ids.intersection(tokens[:-1]), (
361 + f"{context}: generation continued after a stop token"
362 + )
363 + if tokens[-1] in stop_ids:
364 + assert finish_reason == "stop", (
365 + f"{context}: stop token did not terminate output"
366 + )
367 + expected_stop_reason = None if tokens[-1] == eos_token_id else tokens[-1]
368 + assert stop_reason == expected_stop_reason, (
369 + f"{context}: stop reason={stop_reason}, expected={expected_stop_reason}"
370 + )
371 + else:
372 + assert finish_reason == "length" and len(tokens) == params.max_tokens, context
373 + assert stop_reason is None, f"{context}: length cap has a stop reason"
374 +
375 +
376 +@lru_cache(maxsize=4)
377 +def _compiler(tokenizer, vocab_size: int):
378 + info = xgr.TokenizerInfo.from_huggingface(tokenizer, vocab_size=vocab_size)
379 + return xgr.GrammarCompiler(info, max_threads=1, cache_enabled=True)
380 +
381 +
382 +class SamplingReference:
383 + """One request's reference; histories must grow without changing their prefix."""
384 +
385 + def __init__(
386 + self,
387 + tokenizer,
388 + vocab_size: int,
389 + generation_config: dict,
390 + params: "SamplingParams",
391 + ):
392 + assert params.temperature == 0.0, "Only the original greedy matrix is supported"
393 + assert params.presence_penalty == 0.0 and params.repetition_penalty == 1.0
394 + assert params.allowed_token_ids is None and not params.logit_bias
395 + self.vocab_size = vocab_size
396 + self.min_tokens = params.min_tokens
397 + self.frequency_penalty = params.frequency_penalty
398 + self.stop_ids = set(params.stop_token_ids or [])
399 + for eos in (tokenizer.eos_token_id, generation_config.get("eos_token_id")):
400 + if eos is not None:
401 + self.stop_ids.update([eos] if isinstance(eos, int) else eos)
402 + assert all(0 <= token < vocab_size for token in self.stop_ids)
403 +
404 + # Match public bad-word tokenization semantics without using sampler code.
405 + self.bad_words: list[list[int]] = []
406 + for word in params.bad_words or []:
407 + plain = tokenizer.encode(word.lstrip(), add_special_tokens=False)
408 + spaced = tokenizer.encode(" " + word.lstrip(), add_special_tokens=False)
409 + assert plain, f"Empty bad-word tokenization: {word!r}"
410 + self.bad_words.append(plain)
411 + if spaced and len(spaced) == len(plain) and spaced[0] != plain[0]:
412 + self.bad_words.append(spaced)
413 +
414 + self.history: list[int] = []
415 + self.matcher = None
416 + self.bitmask = None
417 + if params.structured_outputs is not None:
418 + schema = params.structured_outputs.json
419 + assert schema is not None, "Only the original JSON schema is supported"
420 + # Preserve dictionary order: xgrammar's default grammar orders fields.
421 + schema = schema if isinstance(schema, str) else json.dumps(schema)
422 + compiled = _compiler(tokenizer, vocab_size).compile_json_schema(
423 + schema, any_whitespace=True
424 + )
425 + self.matcher = xgr.GrammarMatcher(
426 + compiled, override_stop_tokens=sorted(self.stop_ids) or None
427 + )
428 + self.bitmask = xgr.allocate_token_bitmask(1, vocab_size)
429 +
430 + def accept_history(self, generated_ids: list[int]) -> bool:
431 + """Validate newly generated grammar tokens, including the final EOS.
432 +
433 + Return whether the grammar has terminated. Use this after the final
434 + output token; no next-token distribution exists after termination.
435 + """
436 + assert generated_ids[: len(self.history)] == self.history, (
437 + "Each reference belongs to one monotonically growing request history"
438 + )
439 + if self.matcher is not None:
440 + for token in generated_ids[len(self.history) :]:
441 + assert self.matcher.accept_token(token), (
442 + f"Grammar rejected token {token} at position {len(self.history)}"
443 + )
444 + self.history.append(token)
445 + return self.matcher.is_terminated()
446 + else:
447 + self.history = generated_ids.copy()
448 + return False
449 +
450 + def distributions(
451 + self, raw_logits: torch.Tensor, generated_ids: list[int]
452 + ) -> tuple[torch.Tensor, torch.Tensor]:
453 + """Return FP32 logits before and after non-grammar sampling transforms.
454 +
455 + ``raw_logits`` scores the next token after the prompt and
456 + ``generated_ids``. Returned tensors do not alias the model's tensor.
457 + Normalize the first result to check reported completion logprobs;
458 + the second supplies the greedy decision reference.
459 + """
460 + assert raw_logits.shape == (self.vocab_size,)
461 + assert not self.accept_history(generated_ids), (
462 + "Cannot generate a distribution after grammar EOS"
463 + )
464 +
465 + grammar_logits = raw_logits.float().clone()
466 + if self.matcher is not None:
467 + assert self.bitmask is not None
468 + self.matcher.fill_next_token_bitmask(self.bitmask)
469 + words = self.bitmask[0, :, None]
470 + allowed = ((words >> torch.arange(32)) & 1).flatten()
471 + allowed = allowed[: self.vocab_size].bool().to(raw_logits.device)
472 + grammar_logits.masked_fill_(~allowed, -torch.inf)
473 +
474 + processed = grammar_logits.clone()
475 + for bad_word in self.bad_words:
476 + prefix = bad_word[:-1]
477 + if not prefix or generated_ids[-len(prefix) :] == prefix:
478 + processed[bad_word[-1]] = -torch.inf
479 +
480 + if len(generated_ids) < self.min_tokens and self.stop_ids:
481 + stop_ids = sorted(self.stop_ids)
482 + stop_scores = processed[stop_ids].clone()
483 + processed[stop_ids] = -torch.inf
484 + # A completed grammar may have no continuation except a stop token.
485 + if self.matcher is not None and torch.isneginf(processed).all():
486 + processed[stop_ids] = stop_scores
487 +
488 + if self.frequency_penalty:
489 + for token, count in Counter(generated_ids).items():
490 + processed[token] -= self.frequency_penalty * count
491 + return grammar_logits, processed
tests/v1/e2e/general/test_async_scheduling_accuracy.py new
+624
@@ -0,0 +1,624 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Accuracy with automatic attention selection and the model's normal dtype.
4 +
5 +Complements the strict Flex/FP32 invariance checks in test_async_scheduling.py.
6 +"""
7 +
8 +import os
9 +from dataclasses import replace
10 +from typing import Any
11 +
12 +import pytest
13 +import torch
14 +
15 +from tests.conftest import HfRunner, VllmRunner
16 +from tests.utils import (
17 + large_gpu_mark,
18 + single_gpu_only,
19 +)
20 +from tests.v1.e2e.general.accuracy_utils import (
21 + AccuracyTolerance,
22 + check_accuracy_budget,
23 + check_first_divergence,
24 + check_greedy_token,
25 + check_logprobs,
26 + check_request_accuracy,
27 + check_stopping,
28 +)
29 +from vllm import SamplingParams
30 +from vllm.logprobs import Logprob
31 +from vllm.outputs import CompletionOutput, RequestOutput
32 +from vllm.platforms import current_platform
33 +from vllm.sampling_params import StructuredOutputsParams
34 +from vllm.utils.torch_utils import set_default_torch_num_threads
35 +from vllm.v1.metrics.reader import Metric
36 +
37 +MODEL = "Qwen/Qwen3-0.6B"
38 +EAGLE3_TARGET_MODEL = "meta-llama/Llama-3.2-1B-Instruct"
39 +
40 +# Need to enforce eager for MRV2 while we sort out cudagraph issues.
41 +ENFORCE_EAGER = os.getenv("ENFORCE_EAGER", "0") == "1"
42 +
43 +first_prompt = (
44 + "The following numbers of the sequence "
45 + + ", ".join(str(i) for i in range(10))
46 + + " are:"
47 +)
48 +example_prompts = [first_prompt, "In one word, the capital of France is "] + [
49 + f"Tell me about the number {i}: " for i in range(32)
50 +]
51 +
52 +default_params = dict(
53 + temperature=0.0, # greedy
54 + max_tokens=30,
55 + min_tokens=28,
56 +)
57 +
58 +
59 +@single_gpu_only
60 +def test_without_spec_decoding(
61 + vllm_runner: type[VllmRunner],
62 + hf_runner: type[HfRunner],
63 + sample_json_schema,
64 +):
65 + """Check target-model accuracy across scheduling and executor configurations."""
66 + struct_outputs = StructuredOutputsParams(json=sample_json_schema)
67 + test_sampling_params: list[dict[str, Any]] = [
68 + dict(),
69 + dict(frequency_penalty=-1.0),
70 + dict(bad_words=["the", " the"]),
71 + dict(bad_words=["the", " the"], logprobs=2),
72 + dict(logprobs=2),
73 + dict(logprobs=2, frequency_penalty=-1.0),
74 + dict(prompt_logprobs=2),
75 + dict(prompt_logprobs=2, logprobs=2),
76 + dict(structured_outputs=struct_outputs),
77 + dict(
78 + structured_outputs=struct_outputs,
79 + logprobs=2,
80 + ),
81 + dict(
82 + structured_outputs=struct_outputs,
83 + frequency_penalty=-1.0,
84 + ),
85 + dict(
86 + structured_outputs=struct_outputs,
87 + logprobs=2,
88 + frequency_penalty=-1.0,
89 + ),
90 + ]
91 +
92 + # test_preemption, executor, async_scheduling,
93 + # spec_config, test_prefill_chunking
94 + test_configs = [
95 + (False, "mp", False, None, False),
96 + (True, "mp", False, None, True),
97 + (False, "mp", True, None, False),
98 + (False, "uni", True, None, False),
99 + (True, "mp", True, None, False),
100 + (True, "uni", True, None, False),
101 + (False, "mp", True, None, True),
102 + (True, "mp", True, None, True),
103 + (True, "uni", True, None, True),
104 + ]
105 +
106 + run_tests(vllm_runner, hf_runner, MODEL, test_configs, test_sampling_params)
107 +
108 +
109 +@single_gpu_only
110 +@large_gpu_mark(min_gb=16)
111 +def test_with_eagle3_spec_decoding(
112 + vllm_runner: type[VllmRunner],
113 + hf_runner: type[HfRunner],
114 + sample_json_schema,
115 +):
116 + """Check accuracy and acceptance, including drafts that exceed model length."""
117 + spec_config = {
118 + "method": "eagle3",
119 + "num_speculative_tokens": 2,
120 + "model": "nm-testing/Llama3_2_1B_speculator.eagle3",
121 + }
122 + # Set small draft model len to force doesn't-fit-in-drafter case.
123 + spec_config_short = spec_config | {"max_model_len": 50}
124 +
125 + struct_outputs = StructuredOutputsParams(json=sample_json_schema)
126 +
127 + test_sampling_params: list[dict[str, Any]] = [
128 + dict(),
129 + dict(frequency_penalty=-1.0),
130 + dict(bad_words=["the", " the"]),
131 + dict(bad_words=["the", " the"], logprobs=2),
132 + dict(logprobs=2),
133 + dict(logprobs=2, frequency_penalty=-1.0),
134 + dict(prompt_logprobs=2),
135 + dict(prompt_logprobs=2, logprobs=2),
136 + dict(structured_outputs=struct_outputs),
137 + dict(
138 + structured_outputs=struct_outputs,
139 + logprobs=2,
140 + frequency_penalty=-1.0,
141 + ),
142 + ]
143 +
144 + # test_preemption, executor, async_scheduling,
145 + # spec_config, test_prefill_chunking
146 + test_configs = [
147 + (False, "mp", False, None, False),
148 + (False, "mp", False, spec_config, False),
149 + (True, "mp", False, spec_config, True),
150 + (True, "uni", False, spec_config_short, True),
151 + (False, "mp", True, spec_config, False),
152 + (True, "mp", True, spec_config, False),
153 + (False, "mp", True, spec_config_short, True),
154 + (True, "uni", True, spec_config, False),
155 + (True, "uni", True, spec_config_short, False),
156 + (True, "mp", True, spec_config, True),
157 + (True, "uni", True, spec_config_short, True),
158 + ]
159 +
160 + run_tests(
161 + vllm_runner, hf_runner, EAGLE3_TARGET_MODEL, test_configs, test_sampling_params
162 + )
163 +
164 +
165 +@single_gpu_only
166 +@pytest.mark.skipif(
167 + current_platform.is_xpu(),
168 + reason=("XPU matmul/attention kernels are not batch-invariant"),
169 +)
170 +def test_with_ngram_gpu_spec_decoding(
171 + vllm_runner: type[VllmRunner], hf_runner: type[HfRunner]
172 +):
173 + """Check ngram accuracy and acceptance across scheduling configurations."""
174 + ngram_gpu_config = {
175 + "method": "ngram_gpu",
176 + "num_speculative_tokens": 3,
177 + "prompt_lookup_max": 3,
178 + "prompt_lookup_min": 2,
179 + }
180 +
181 + # test_preemption, executor, async_scheduling,
182 + # spec_config, test_prefill_chunking
183 + test_configs = [
184 + (False, "mp", False, None, False),
185 + (False, "mp", False, ngram_gpu_config, False),
186 + (True, "mp", False, ngram_gpu_config, True),
187 + (False, "mp", True, ngram_gpu_config, False),
188 + (True, "mp", True, ngram_gpu_config, False),
189 + (True, "uni", True, ngram_gpu_config, False),
190 + (True, "mp", True, ngram_gpu_config, True),
191 + ]
192 +
193 + run_tests(vllm_runner, hf_runner, MODEL, test_configs, [{}, dict(logprobs=2)])
194 +
195 +
196 +def run_tests(
197 + vllm_runner: type[VllmRunner],
198 + hf_runner: type[HfRunner],
199 + model: str,
200 + test_configs: list[tuple],
201 + test_sampling_params: list[dict[str, Any]],
202 +):
203 + """Compare the shared prefix and independently validate every continuation."""
204 + assert test_configs[0] == (False, "mp", False, None, False), (
205 + "The baseline must use synchronous target-only decoding"
206 + )
207 + outputs = []
208 + for n, config in enumerate(test_configs, 1):
209 + outputs.append(
210 + run_test(
211 + vllm_runner,
212 + model,
213 + f"{n}/{len(test_configs)}",
214 + test_sampling_params,
215 + *config,
216 + )
217 + )
218 +
219 + baseline_config, baseline_batches, _ = outputs[0]
220 + # Compare vLLM's own decisions at the first divergence, where histories
221 + # still match. HF then scores each continuation, including after divergence.
222 + # Neither engine forces an attention backend, dtype, or matmul precision.
223 + # Qwen/Llama BF16 controls and Qwen holdouts measured max score error <0.38,
224 + # per-stream request mean <0.073, max greedy gap 0.125, and request sum 0.25.
225 + # Per-request budgets also reject systematic errors below the scalar bounds.
226 + tolerance = AccuracyTolerance(logprob_atol=0.5, greedy_atol=0.25)
227 + # These short CPU reductions cost more to distribute across a thread pool.
228 + with set_default_torch_num_threads(1), hf_runner(model) as hf:
229 + hf.model.eval()
230 + for config, batches, _ in outputs:
231 + assert len(batches) == len(test_sampling_params)
232 + for baseline_batch, batch, overrides in zip(
233 + baseline_batches, batches, test_sampling_params, strict=True
234 + ):
235 + assert len(batch) == len(example_prompts), config
236 + params = SamplingParams(**default_params, **overrides)
237 + for i, (baseline, request, prompt) in enumerate(
238 + zip(baseline_batch, batch, example_prompts, strict=True)
239 + ):
240 + assert request.prompt_token_ids == hf.tokenizer.encode(prompt)
241 + context = f"config=[{config}], params={overrides}, request={i}"
242 + if params.logprobs is not None:
243 + check_first_divergence(
244 + baseline,
245 + request,
246 + params,
247 + tolerance,
248 + f"baseline=[{baseline_config}], {context}",
249 + )
250 + check_request_accuracy(
251 + hf,
252 + request,
253 + params,
254 + tolerance,
255 + context,
256 + )
257 + print(f"ACCURACY PASSED: config=[{config}], params={overrides}")
258 +
259 + baseline_acceptances = next((o[2] for o in outputs if o[2] is not None), None)
260 + if baseline_acceptances is not None:
261 + for settings, (config, _, acceptances) in zip(
262 + test_configs, outputs, strict=True
263 + ):
264 + if acceptances is None:
265 + continue
266 + test_preemption, _, _, spec_config, _ = settings
267 + assert spec_config is not None
268 + for baseline, actual, params in zip(
269 + baseline_acceptances, acceptances, test_sampling_params, strict=True
270 + ):
271 + context = f"config=[{config}], params={params}"
272 + if spec_config.get("max_model_len") is None:
273 + # Keep the original acceptance-quality floor per batch.
274 + relative_drop = (
275 + 0.10 if current_platform.is_rocm() and test_preemption else 0.05
276 + )
277 + assert actual >= baseline * (1 - relative_drop), (
278 + f"{context}: acceptance={actual}, baseline={baseline}"
279 + )
280 + else:
281 + assert actual > 0.1, f"{context}: acceptance={actual}"
282 +
283 +
284 +def run_test(
285 + vllm_runner: type[VllmRunner],
286 + model: str,
287 + test_str: str,
288 + sampling_param_tests: list[dict[str, Any]],
289 + test_preemption: bool,
290 + executor: str,
291 + async_scheduling: bool,
292 + spec_config: dict[str, Any] | None,
293 + test_prefill_chunking: bool,
294 +):
295 + spec_decoding = spec_config is not None
296 + spec_method = (spec_config or {}).get("method", "none")
297 + # Chunked ngram decoding admits fewer concurrent requests under the
298 + # 48-token budget. Its original 33-block cache did not preempt on ROCm.
299 + # A 17-block cache forces contention while every original request fits
300 + # comfortably within 256 tokens (the longest prompt plus output is 67).
301 + cache_blocks = 17 if test_prefill_chunking and spec_method == "ngram_gpu" else 33
302 + cache_arg: dict[str, Any] = (
303 + dict(
304 + num_gpu_blocks_override=cache_blocks,
305 + max_model_len=(cache_blocks - 1) * 16,
306 + )
307 + if test_preemption
308 + else dict(gpu_memory_utilization=0.9, max_model_len=4096)
309 + )
310 + spec_mml = (spec_config or {}).get("max_model_len")
311 + test_config = (
312 + f"executor={executor}, preemption={test_preemption}, "
313 + f"async_sched={async_scheduling}, "
314 + f"chunk_prefill={test_prefill_chunking}, "
315 + f"spec_decoding={spec_decoding}, spec_method={spec_method}, spec_mml={spec_mml}"
316 + )
317 + print("-" * 80)
318 + print(f"---- TESTING {test_str}: {test_config}")
319 + print("-" * 80)
320 +
321 + with vllm_runner(
322 + model,
323 + enable_chunked_prefill=test_prefill_chunking,
324 + # Force prefill chunking
325 + max_num_batched_tokens=48 if test_prefill_chunking else None,
326 + enforce_eager=ENFORCE_EAGER,
327 + async_scheduling=async_scheduling,
328 + distributed_executor_backend=executor,
329 + speculative_config=spec_config,
330 + disable_log_stats=False,
331 + enable_prefix_caching=False if current_platform.is_rocm() else None,
332 + **cache_arg,
333 + ) as vllm_model:
334 + results = []
335 + acceptance_rates: list[float] | None = [] if spec_decoding else None
336 + for override_params in sampling_param_tests:
337 + metrics_before = vllm_model.llm.get_metrics()
338 + print(f"----------- RUNNING PARAMS: {override_params}")
339 + results.append(
340 + vllm_model.llm.generate(
341 + vllm_model.get_inputs(example_prompts),
342 + sampling_params=SamplingParams(**default_params, **override_params),
343 + )
344 + )
345 + metrics_after = vllm_model.llm.get_metrics()
346 + if acceptance_rates is not None:
347 + acceptance_rate = _get_acceptance_rate(metrics_before, metrics_after)
348 + acceptance_rates.append(acceptance_rate)
349 + print(f"ACCEPTANCE RATE {acceptance_rate}")
350 +
351 + if test_preemption:
352 + preemptions = _get_count(
353 + metrics_before, metrics_after, "vllm:num_preemptions"
354 + )
355 + assert preemptions > 0, "preemption test had no preemptions"
356 +
357 + # Preserve the original parameter-effect checks across repeated batches.
358 + if len(results) > 1:
359 + baseline = _result_signature(results[0])
360 + for outputs, params in zip(results[1:], sampling_param_tests[1:], strict=True):
361 + assert _result_signature(outputs) != baseline, (
362 + f"{test_config}: sampling parameters had no observable effect: {params}"
363 + )
364 + # The old ROCm filter used schema-only as its first parameter set.
365 + # Retain those comparisons after restoring the ordinary cases too.
366 + schema_batches = [
367 + batch
368 + for batch, params in zip(results, sampling_param_tests, strict=True)
369 + if params.get("structured_outputs") is not None
370 + ]
371 + for batch in schema_batches[1:]:
372 + assert _result_signature(batch) != _result_signature(schema_batches[0]), (
373 + f"{test_config}: structured sampling parameters had no effect"
374 + )
375 +
376 + return test_config, results, acceptance_rates
377 +
378 +
379 +def _result_signature(requests):
380 + return [
381 + (
382 + list(request.outputs[0].token_ids),
383 + request.outputs[0].text,
384 + request.prompt_logprobs,
385 + request.outputs[0].logprobs,
386 + )
387 + for request in requests
388 + ]
389 +
390 +
391 +def _get_acceptance_rate(before: list[Metric], after: list[Metric]) -> float:
392 + draft = _get_count(before, after, "vllm:spec_decode_num_draft_tokens")
393 + accept = _get_count(before, after, "vllm:spec_decode_num_accepted_tokens")
394 + assert draft > 0, "speculative test did not draft any tokens"
395 + assert 0 <= accept <= draft, f"invalid acceptance counters: {accept=}, {draft=}"
396 + return accept / draft
397 +
398 +
399 +def _get_count(before: list[Metric], after: list[Metric], name: str) -> int:
400 + before_val = next(m.value for m in before if m.name == name)
401 + after_val = next(m.value for m in after if m.name == name)
402 + return after_val - before_val
403 +
404 +
405 +@pytest.mark.parametrize(
406 + "corruption", ["score", "rank", "top_k", "missing", "nan", "positive", "order"]
407 +)
408 +def test_accuracy_checks_reject_incorrect_logprobs(corruption):
409 + second = 2.875 if corruption == "order" else 1.0
410 + reference = torch.tensor([3.0, second, -2.0, -5.0]).log_softmax(-1)
411 + tolerance = AccuracyTolerance(logprob_atol=0.5, greedy_atol=0.25)
412 + scores = {
413 + 0: Logprob(logprob=float(reference[0]), rank=1, decoded_token="a"),
414 + 1: Logprob(logprob=float(reference[1]), rank=2, decoded_token="b"),
415 + }
416 + check_logprobs(scores, reference, 0, 2, tolerance, "control")
417 + if corruption == "score":
418 + scores[0] = replace(scores[0], logprob=scores[0].logprob - 1.0)
419 + elif corruption == "rank":
420 + scores[1] = replace(scores[1], rank=1)
421 + elif corruption == "top_k":
422 + del scores[1]
423 + scores[3] = Logprob(logprob=float(reference[3]), rank=2, decoded_token="d")
424 + elif corruption == "missing":
425 + del scores[0]
426 + elif corruption == "positive":
427 + scores[0] = replace(scores[0], logprob=0.1)
428 + elif corruption == "order":
429 + scores[0] = replace(scores[0], rank=2)
430 + scores[1] = replace(scores[1], rank=1)
431 + else:
432 + scores[0] = replace(scores[0], logprob=float("nan"))
433 + with pytest.raises(AssertionError):
434 + check_logprobs(scores, reference, 0, 2, tolerance, corruption)
435 +
436 +
437 +def test_accuracy_checks_distinguish_ties_from_wrong_tokens():
438 + tolerance = AccuracyTolerance(logprob_atol=0.5, greedy_atol=0.25)
439 + logits = torch.tensor([3.0, 2.875, -2.0, -torch.inf])
440 + check_greedy_token(logits, 0, tolerance, "best")
441 + check_greedy_token(logits, 1, tolerance, "near tie")
442 + for token in (2, 3):
443 + with pytest.raises(AssertionError):
444 + check_greedy_token(logits, token, tolerance, "incorrect token")
445 +
446 + # top-k may omit a sampled token tied at its boundary. The sampled rank
447 + # can duplicate a sequential top-k rank without corrupting the result.
448 + reference = torch.zeros(4).log_softmax(-1)
449 + scores = {
450 + token: Logprob(logprob=float(reference[token]), rank=rank)
451 + for token, rank in [(3, 1), (0, 1), (1, 2)]
452 + }
453 + check_logprobs(scores, reference, 3, 2, tolerance, "tied top-k")
454 +
455 + # A grammar with one allowed token still returns the requested top-k,
456 + # including masked entries whose score is exactly negative infinity.
457 + reference = torch.tensor([0.0, -torch.inf, -torch.inf])
458 + scores = {0: Logprob(0.0, 1), 1: Logprob(-float("inf"), 2)}
459 + check_logprobs(scores, reference, 0, 2, tolerance, "masked top-k")
460 +
461 +
462 +def test_accuracy_checks_reject_incorrect_stopping():
463 + params = SamplingParams(max_tokens=3)
464 + stop_ids = {2, 3}
465 + for tokens, reason, stop_reason in [
466 + ([0, 1, 2], "stop", None),
467 + ([0, 1, 3], "stop", 3),
468 + ([0, 1, 0], "length", None),
469 + ]:
470 + check_stopping(
471 + tokens,
472 + reason,
473 + stop_ids,
474 + params,
475 + "valid stopping at length cap",
476 + eos_token_id=2,
477 + stop_reason=stop_reason,
478 + )
479 + for tokens, reason, stop_reason in [
480 + ([0, 2, 1], "length", None),
481 + ([0, 1, 2], "length", None),
482 + ([0, 1, 2], "stop", 2),
483 + ([0, 1, 3], "stop", None),
484 + ([0, 1, 3], "stop", 2),
485 + ([0, 1, 0], "length", 3),
486 + ]:
487 + with pytest.raises(AssertionError):
488 + check_stopping(
489 + tokens,
490 + reason,
491 + stop_ids,
492 + params,
493 + "incorrect stopping",
494 + eos_token_id=2,
495 + stop_reason=stop_reason,
496 + )
497 +
498 +
499 +def test_accuracy_checks_reject_systematic_sub_bound_errors():
500 + tolerance = AccuracyTolerance(logprob_atol=0.5, greedy_atol=0.25)
501 + reference = torch.tensor([3.0, 2.75, -2.0]).log_softmax(-1)
502 + scores = {i: Logprob(float(reference[i]) - 0.4, i + 1) for i in (0, 1)}
503 + # Each corrupted score fits the scalar bound, but the bias must not pass.
504 + errors = check_logprobs(scores, reference, 0, 2, tolerance, "biased scores")
505 + with pytest.raises(AssertionError, match="mean logprob error"):
506 + check_accuracy_budget([errors], [], tolerance, "biased scores")
507 +
508 + gap = check_greedy_token(reference, 1, tolerance, "one ambiguous choice")
509 + with pytest.raises(AssertionError, match="total greedy logit gap"):
510 + check_accuracy_budget([], [gap] * 30, tolerance, "systematically worse choices")
511 + check_accuracy_budget(
512 + [torch.tensor([0.03, 0.07])], [0.125, 0.125], tolerance, "control variation"
513 + )
514 +
515 +
516 +def _divergence_request(
517 + tokens: list[int], scores: dict[int, float], position: int = 0
518 +) -> RequestOutput:
519 + logprobs: list[dict[int, Logprob]] = [{} for _ in tokens]
520 + logprobs[position] = {
521 + token: Logprob(score, rank)
522 + for rank, (token, score) in enumerate(
523 + sorted(scores.items(), key=lambda item: item[1], reverse=True), 1
524 + )
525 + }
526 + return RequestOutput(
527 + request_id="test",
528 + prompt=None,
529 + prompt_token_ids=[0, 0, 0],
530 + prompt_logprobs=None,
531 + outputs=[
532 + CompletionOutput(
533 + index=0,
534 + text="",
535 + token_ids=tokens,
536 + cumulative_logprob=None,
537 + logprobs=logprobs,
538 + finish_reason="length",
539 + )
540 + ],
541 + finished=True,
542 + )
543 +
544 +
545 +@pytest.mark.parametrize(
546 + "base_scores,actual_scores,error",
547 + [
548 + ({0: -0.8, 1: -0.9}, {0: -0.9, 1: -0.8}, None),
549 + ({0: -0.8, 1: -0.8}, {0: -0.8, 1: -0.8}, None),
550 + ({0: -0.8, 1: -0.9}, {0: -0.8, 1: -0.9}, "actual.*below"),
551 + ({0: -0.9, 1: -0.8}, {0: -0.9, 1: -0.8}, "baseline.*below"),
552 + ({0: -0.8, 1: -1.1}, {0: -0.9, 1: -0.8}, "baseline.*near-tie"),
553 + ({0: -0.8, 1: -0.9}, {0: -1.1, 1: -0.8}, "actual.*near-tie"),
554 + ({0: -0.8, 2: -0.9}, {0: -0.9, 1: -0.8}, None),
555 + ({0: -0.8, 2: -1.1}, {0: -0.9, 1: -0.8}, "baseline.*lower bound"),
556 + ({0: -0.8, 1: -0.9}, {1: -0.8, 2: -1.1}, "actual.*lower bound"),
557 + ],
558 + ids=[
559 + "near-tie-rank-reversal",
560 + "exact-tie",
561 + "wrong-near-tie-actual-choice",
562 + "wrong-near-tie-baseline-choice",
563 + "decisive-baseline",
564 + "decisive-actual",
565 + "missing-candidate-inconclusive",
566 + "missing-candidate-decisive-baseline",
567 + "missing-candidate-decisive-actual",
568 + ],
569 +)
570 +def test_first_divergence_checks_engine_preferences(base_scores, actual_scores, error):
571 + baseline = _divergence_request([0], base_scores)
572 + actual = _divergence_request([1], actual_scores)
573 + args = (
574 + baseline,
575 + actual,
576 + SamplingParams(temperature=0, logprobs=2),
577 + AccuracyTolerance(logprob_atol=0.5, greedy_atol=0.25),
578 + "paired engines",
579 + )
580 + if error is None:
581 + check_first_divergence(*args)
582 + else:
583 + with pytest.raises(AssertionError, match=error):
584 + check_first_divergence(*args)
585 +
586 +
587 +@pytest.mark.parametrize("missing_alternative", [False, True])
588 +def test_first_divergence_uses_generated_penalty_history(missing_alternative):
589 + # Raw ordering favors token 1; one generated 0 reverses the penalized order.
590 + # The prompt contains three more 0s which must not contribute to penalties.
591 + base_scores = {0: -1.5, 1: -0.6}
592 + if missing_alternative:
593 + # The selected 0 is outside top-2. Use the top-k boundary (-0.6),
594 + # excluding the additional sampled token (-1.5), for the missing 1.
595 + base_scores = {0: -1.5, 2: -0.5, 3: -0.6}
596 + baseline = _divergence_request([0, 0, 2], base_scores, position=1)
597 + actual = _divergence_request([0, 1, 3, 4], {0: -1.6, 1: -0.5}, position=1)
598 + check_first_divergence(
599 + baseline,
600 + actual,
601 + SamplingParams(temperature=0, logprobs=2, frequency_penalty=-1),
602 + AccuracyTolerance(logprob_atol=0.5, greedy_atol=0.25),
603 + "different later histories are validated separately",
604 + )
605 +
606 +
607 +@pytest.mark.parametrize("mismatch", ["length", "finish_reason", "prompt"])
608 +def test_first_divergence_rejects_mismatches_without_a_token_change(mismatch):
609 + baseline = _divergence_request([0, 1], {})
610 + actual = _divergence_request([0, 1], {})
611 + if mismatch == "length":
612 + actual.outputs[0].token_ids = [0]
613 + elif mismatch == "finish_reason":
614 + actual.outputs[0].finish_reason = "stop"
615 + else:
616 + actual.prompt_token_ids = [1]
617 + with pytest.raises(AssertionError):
618 + check_first_divergence(
619 + baseline,
620 + actual,
621 + SamplingParams(temperature=0, logprobs=2),
622 + AccuracyTolerance(logprob_atol=0.5, greedy_atol=0.25),
623 + mismatch,
624 + )