main
py 991 lines 36.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import contextlib
4 import os
5 import random
6
7 import pytest
8 import torch
9 from transformers import AutoTokenizer
10 from utils import (
11 BACKENDS,
12 TEST_MODEL,
13 _extract_step_logprobs,
14 _random_prompt,
15 skip_if_not_cuda,
16 skip_unsupported,
17 )
18
19 import vllm.envs as envs
20 from vllm import LLM, SamplingParams
21
22
23 @skip_unsupported
24 @pytest.mark.flaky(reruns=3)
25 @pytest.mark.timeout(1000)
26 @pytest.mark.parametrize(
27 "backend",
28 BACKENDS,
29 )
30 @pytest.mark.parametrize("rms_norm_impl", ["default", "vllm_c"])
31 def test_v1_generation_is_deterministic_across_batch_sizes_with_needle(
32 backend,
33 rms_norm_impl,
34 ):
35 """Ensures that the same request (the 'needle' prompt) yields identical output
36 whether run alone (bs=1) or mixed into a larger batch (e.g., bs=64),
37 using the high-level v1 LLM() API only (no manual batching).
38
39 Strategy:
40 - Create a single LLM engine configured for the larger batch limit (N).
41 - Compute a baseline output for the needle prompt when it is run alone.
42 - For many trials, generate a mixed batch (size N) where the needle appears
43 at a random position among random filler prompts using the same engine.
44 - Track how many trials match vs mismatch, and report totals at the end.
45 The test fails if any mismatches occur, but we still dump pass/fail
46 counts.
47
48 Notes:
49 - Use seeded stochastic sampling with a fixed seed to test determinism.
50 - Outputs are intentionally longer and sampled at higher temperature/top_p
51 to produce a more random-sounding phrase, yet remain deterministic by
52 seed.
53 - Keep max_tokens and max_model_len bounded for speed and memory use.
54
55 """
56 seed = int(os.getenv("VLLM_TEST_SEED", "12345"))
57 random.seed(seed)
58
59 attention_config = {"backend": backend}
60 # Force the C++ RMSNorm implementation so we actually exercise the
61 # num_tokens-dependent block-size branches.
62 kernel_config = None
63 if rms_norm_impl == "vllm_c":
64 kernel_config = {
65 "ir_op_priority": {
66 "rms_norm": ["vllm_c"],
67 "fused_add_rms_norm": ["vllm_c"],
68 }
69 }
70 # Allow overrides from environment (useful for CI tuning)
71 # "facebook/opt-125m" is too small, doesn't reliably test determinism
72 model = TEST_MODEL
73 num_trials = int(os.getenv("VLLM_NEEDLE_TRIALS", "5"))
74 max_batch_size = int(os.getenv("VLLM_NEEDLE_BATCH_SIZE", "128"))
75 min_random_prompt = int(os.getenv("VLLM_MIN_PROMPT", "1024"))
76 max_random_prompt = int(os.getenv("VLLM_MAX_PROMPT", "2048"))
77 assert max_batch_size >= 2, "Batch size should be >= 2 to mix needle."
78
79 # Keep GPU memory usage low to avoid startup allocation failures.
80 gpu_mem_util = float(os.getenv("VLLM_GPU_MEMORY_UTILIZATION", "0.5"))
81 max_model_len = int(os.getenv("VLLM_MAX_MODEL_LEN", "5120"))
82
83 # Sampling parameters: longer outputs with a more random-sounding
84 # continuation,but still deterministic due to fixed seed.
85 temperature = float(os.getenv("VLLM_NEEDLE_TEMPERATURE", "0.0"))
86 top_p = float(os.getenv("VLLM_NEEDLE_TOP_P", "0.95"))
87 max_tokens = int(os.getenv("VLLM_NEEDLE_MAX_TOKENS", "128"))
88
89 sampling = SamplingParams(
90 temperature=temperature,
91 top_p=top_p,
92 max_tokens=max_tokens,
93 seed=20240919,
94 )
95
96 needle_prompt = "There once was a "
97
98 llm = None
99 try:
100 llm = LLM_with_max_seqs(
101 model=model,
102 max_num_seqs=max_batch_size,
103 gpu_memory_utilization=gpu_mem_util,
104 max_model_len=max_model_len,
105 attention_config=attention_config,
106 kernel_config=kernel_config,
107 )
108
109 # Baseline generation for the needle prompt alone.
110 baseline_out = llm.generate([needle_prompt], sampling)
111 assert len(baseline_out) == 1
112 assert len(baseline_out[0].outputs) >= 1
113 baseline_text = baseline_out[0].outputs[0].text
114
115 mismatches = 0
116
117 for trial in range(num_trials):
118 # Create a batch of size `max_batch_size` and insert the needle at
119 # a random index
120 prompts: list[str] = []
121 batch_size = random.randint(max_batch_size // 2, max_batch_size)
122 needle_pos = random.randint(0, batch_size - 1)
123 for i in range(batch_size):
124 if i == needle_pos:
125 prompts.append(needle_prompt)
126 else:
127 prompts.append(_random_prompt(min_random_prompt, max_random_prompt))
128
129 # Generate with the same engine but in a larger batch.
130 outputs = llm.generate(prompts, sampling)
131 # Find the needle output by position
132 needle_output = outputs[needle_pos]
133 assert needle_output.prompt == needle_prompt
134 assert len(needle_output.outputs) >= 1
135 text = needle_output.outputs[0].text
136
137 if text != baseline_text:
138 print(f"{text}\n\n== Not the same as ==\n\n{baseline_text}\n\n")
139 mismatches += 1
140
141 passes = num_trials - mismatches
142 # Dump how many passed vs failed
143 print(
144 f"[determinism] total={num_trials}, passed={passes}, "
145 f"failed={mismatches}, max_batch_size={max_batch_size}"
146 )
147
148 if mismatches > 0:
149 pytest.fail(
150 f"Nondeterministic outputs detected: {mismatches} failed out "
151 f"of {num_trials} trials (max_batch_size={max_batch_size})."
152 )
153
154 finally:
155 # Ensure engines are shutdown to free GPU/VRAM across test sessions
156 if llm is not None:
157 with contextlib.suppress(Exception):
158 llm.llm_engine.engine_core.shutdown()
159
160
161 @skip_unsupported
162 @pytest.mark.parametrize(
163 "backend",
164 BACKENDS,
165 )
166 @pytest.mark.parametrize(
167 "block_m,block_n",
168 [(16, 16), (8, 16)],
169 )
170 def test_logprobs_bitwise_batch_invariance_bs1_vs_bsN(
171 backend,
172 block_m,
173 block_n,
174 ):
175 seed = int(os.getenv("VLLM_TEST_SEED", "12345"))
176 random.seed(seed)
177 tp_size = int(os.getenv("VLLM_TEST_TP_SIZE", "1"))
178
179 llm = LLM(
180 model=TEST_MODEL,
181 tensor_parallel_size=tp_size,
182 max_num_seqs=128,
183 max_model_len=8192,
184 dtype="auto", # not everything is supported
185 gpu_memory_utilization=0.9,
186 attention_config={
187 "backend": backend,
188 "flex_attn_block_m": block_m,
189 "flex_attn_block_n": block_n,
190 },
191 )
192
193 # Use more realistic prompts for better token generation
194 prompts = [_random_prompt(10, 50) for _ in range(32)]
195
196 # Chunked prefill is covered by
197 # test_logprobs_bitwise_batch_invariance_ragged_chunked_prefill below.
198
199 sp = SamplingParams(
200 temperature=0.6,
201 top_p=1.0,
202 max_tokens=16,
203 seed=1234,
204 logprobs=5,
205 )
206
207 # BS=1: run prompts individually and collect logprobs per step.
208 print("\n" + "=" * 80)
209 print("STARTING BS=1 RUNS (each prompt individually)")
210 print("=" * 80 + "\n")
211
212 bs1_logprobs_per_prompt = []
213 bs1_tokens_per_prompt = []
214 for idx, p in enumerate(prompts):
215 print(f"\n[BS=1] Running prompt {idx}/{len(prompts)} - Preview: {p[:80]}...")
216 outs = llm.generate([p], sp, use_tqdm=False)
217 assert len(outs) == 1
218 step_logprobs, token_ids = _extract_step_logprobs(outs[0])
219 if step_logprobs is None:
220 pytest.skip(
221 "Logits are not available on RequestOutput; "
222 "enable logprobs return to run this test."
223 )
224 bs1_logprobs_per_prompt.append(step_logprobs)
225 bs1_tokens_per_prompt.append(token_ids)
226 print(f"[BS=1] Prompt {idx} generated tokens: {token_ids}")
227
228 # BS=N: run prompts in a batch and collect logprobs per step for each
229 # prompt.
230 print("\n" + "=" * 80)
231 print(f"STARTING BS={len(prompts)} RUN (all prompts batched)")
232 print("=" * 80 + "\n")
233
234 outs_batched = llm.generate(prompts, sp, use_tqdm=False)
235 assert len(outs_batched) == len(prompts)
236 bsN_logprobs_per_prompt = []
237 bsN_tokens_per_prompt = []
238
239 print(f"\n[BS={len(prompts)}] Processing batched outputs...")
240 for idx, o in enumerate(outs_batched):
241 tokens = o.outputs[0].token_ids if o.outputs else "N/A"
242 print(f"[BS={len(prompts)}] Prompt {idx} generated tokens: {tokens}")
243 step_logprobs, token_ids = _extract_step_logprobs(o)
244 if step_logprobs is None:
245 pytest.skip(
246 "Logits are not available on RequestOutput; "
247 "enable logprobs return to run this test."
248 )
249 bsN_logprobs_per_prompt.append(step_logprobs)
250 bsN_tokens_per_prompt.append(token_ids)
251
252 # Compare step-by-step logprobs for each prompt between BS=1 and BS=N runs.
253 failed_prompts = []
254 for i, (logprobs_bs1, logprobs_bsN, tokens_bs1, tokens_bsN) in enumerate(
255 zip(
256 bs1_logprobs_per_prompt,
257 bsN_logprobs_per_prompt,
258 bs1_tokens_per_prompt,
259 bsN_tokens_per_prompt,
260 )
261 ):
262 if len(logprobs_bs1) != len(logprobs_bsN):
263 reason = (
264 f"Different number of steps: {len(logprobs_bs1)} (BS=1) "
265 f"vs {len(logprobs_bsN)} (BS=N)"
266 )
267 failed_prompts.append(
268 {
269 "prompt_idx": i,
270 "step": "all",
271 "reason": reason,
272 "prompt_preview": prompts[i][:100],
273 "bs1_tokens": tokens_bs1,
274 "bsN_tokens": tokens_bsN,
275 }
276 )
277 continue
278
279 # Check if tokens match first
280 if tokens_bs1 != tokens_bsN:
281 failed_prompts.append(
282 {
283 "prompt_idx": i,
284 "step": "sampling",
285 "reason": "Different tokens sampled",
286 "prompt_preview": prompts[i][:100],
287 "bs1_tokens": tokens_bs1,
288 "bsN_tokens": tokens_bsN,
289 "bs1_all_logprobs": [
290 logprobs_bs1[s].tolist() for s in range(len(logprobs_bs1))
291 ],
292 "bsN_all_logprobs": [
293 logprobs_bsN[s].tolist() for s in range(len(logprobs_bsN))
294 ],
295 }
296 )
297 continue
298
299 for t, (a, b) in enumerate(zip(logprobs_bs1, logprobs_bsN)):
300 if a.shape != b.shape:
301 failed_prompts.append(
302 {
303 "prompt_idx": i,
304 "step": t,
305 "reason": f"Shape mismatch: {a.shape} vs {b.shape}",
306 "prompt_preview": prompts[i][:100],
307 "bs1_tokens": tokens_bs1,
308 "bsN_tokens": tokens_bsN,
309 }
310 )
311 break
312
313 if not torch.equal(a, b):
314 max_diff = torch.abs(a - b).max().item()
315 # Print which token failed
316 print(f"\n[DIVERGENCE] Prompt {i}, Token {t}: max_diff={max_diff:.6e}")
317 bs1_tok = tokens_bs1[t] if t < len(tokens_bs1) else "N/A"
318 bsN_tok = tokens_bsN[t] if t < len(tokens_bsN) else "N/A"
319 print(f" Token IDs: bs1={bs1_tok}, bsN={bsN_tok}")
320 print(f" BS=1 logprob: {a.tolist()}")
321 print(f" BS=N logprob: {b.tolist()}")
322 failed_prompts.append(
323 {
324 "prompt_idx": i,
325 "step": t,
326 "reason": f"Bitwise mismatch (max_diff={max_diff:.6e})",
327 "prompt_preview": prompts[i][:100],
328 "bs1_tokens": tokens_bs1,
329 "bsN_tokens": tokens_bsN,
330 "bs1_all_logprobs": [
331 logprobs_bs1[s].tolist() for s in range(len(logprobs_bs1))
332 ],
333 "bsN_all_logprobs": [
334 logprobs_bsN[s].tolist() for s in range(len(logprobs_bsN))
335 ],
336 }
337 )
338 break
339
340 # Print summary of all failures
341 if failed_prompts:
342 print(f"\n{'=' * 80}")
343 fail_msg = (
344 f"BATCH INVARIANCE FAILURES: {len(failed_prompts)}/"
345 f"{len(prompts)} prompts failed"
346 )
347 print(fail_msg)
348 print(f"{'=' * 80}")
349 for fail in failed_prompts:
350 print(f"\nPrompt {fail['prompt_idx']} (step {fail['step']}):")
351 print(f" Reason: {fail['reason']}")
352 print(f" Preview: {fail['prompt_preview']}...")
353
354 # Always show the tokens
355 if "bs1_tokens" in fail:
356 print(f" BS=1 tokens: {fail['bs1_tokens']}")
357 if "bsN_tokens" in fail:
358 print(f" BS=N tokens: {fail['bsN_tokens']}")
359
360 if "bs1_all_logprobs" in fail:
361 print(f" BS=1 logprobs for all {len(fail['bs1_all_logprobs'])} steps:")
362 for step_idx, logprobs in enumerate(fail["bs1_all_logprobs"]):
363 print(f" Step {step_idx}: {logprobs}")
364 print(f" BS=N logprobs for all {len(fail['bsN_all_logprobs'])} steps:")
365 for step_idx, logprobs in enumerate(fail["bsN_all_logprobs"]):
366 print(f" Step {step_idx}: {logprobs}")
367 print(f"{'=' * 80}\n")
368
369 # Fail the test with summary
370 msg = (
371 f"Batch invariance violated in {len(failed_prompts)}/"
372 f"{len(prompts)} prompts. See output above for details."
373 )
374 pytest.fail(msg)
375
376
377 @skip_unsupported
378 @pytest.mark.parametrize(
379 "backend",
380 BACKENDS,
381 )
382 def test_logprobs_bitwise_batch_invariance_ragged_chunked_prefill(backend):
383 """Batch invariance must hold when a prefill is split across chunks."""
384 random.seed(int(os.getenv("VLLM_TEST_SEED", "12345")))
385
386 prompts = (
387 [_random_prompt(10, 50) for _ in range(8)]
388 + [_random_prompt(300, 450) for _ in range(4)]
389 + [_random_prompt(800, 1200) for _ in range(2)]
390 )
391 random.shuffle(prompts)
392
393 # _random_prompt takes a word target, not a token count, and the ratio
394 # depends on the tokenizer, so derive the budget rather than hardcode it:
395 # half the longest prompt splits that prefill whatever TEST_MODEL is, while
396 # the shorter prompts are still co-scheduled whole. Floor is max_num_seqs.
397 tokenizer = AutoTokenizer.from_pretrained(TEST_MODEL)
398 prompt_lens = [len(tokenizer(p).input_ids) for p in prompts]
399 max_num_batched_tokens = max(len(prompts), max(prompt_lens) // 2)
400 assert max_num_batched_tokens < max(prompt_lens), (
401 f"a {max_num_batched_tokens}-token budget does not split the longest "
402 f"prompt ({max(prompt_lens)} tokens), so nothing would be chunked"
403 )
404
405 sp = SamplingParams(
406 temperature=0.6, top_p=1.0, max_tokens=16, seed=1234, logprobs=5
407 )
408 llm = LLM_with_max_seqs(
409 model=TEST_MODEL,
410 max_num_seqs=len(prompts),
411 gpu_memory_utilization=float(os.getenv("VLLM_GPU_MEMORY_UTILIZATION", "0.5")),
412 max_model_len=4096,
413 attention_config={"backend": backend},
414 max_num_batched_tokens=max_num_batched_tokens,
415 )
416
417 try:
418 bs1 = []
419 for p in prompts:
420 out = llm.generate([p], sp, use_tqdm=False)[0]
421 bs1.append(_extract_step_logprobs(out))
422 if any(logprobs is None for logprobs, _ in bs1):
423 pytest.skip("Logprobs are not available on RequestOutput.")
424
425 outs_batched = llm.generate(prompts, sp, use_tqdm=False)
426 assert len(outs_batched) == len(prompts)
427
428 mismatches = []
429 for i, out in enumerate(outs_batched):
430 logprobs, tokens = _extract_step_logprobs(out)
431 bs1_logprobs, bs1_tokens = bs1[i]
432 n = prompt_lens[i]
433 if tokens != bs1_tokens:
434 mismatches.append(f"prompt {i} ({n} tokens): tokens differ")
435 elif not torch.equal(bs1_logprobs, logprobs):
436 delta = torch.max(torch.abs(bs1_logprobs - logprobs)).item()
437 mismatches.append(
438 f"prompt {i} ({n} tokens): logprobs differ, max|delta|={delta:.3e}"
439 )
440
441 assert not mismatches, (
442 "Batch invariance violated under chunked prefill "
443 f"(max_num_batched_tokens={max_num_batched_tokens}): "
444 + "; ".join(mismatches)
445 )
446 finally:
447 with contextlib.suppress(Exception):
448 llm.llm_engine.engine_core.shutdown()
449
450
451 @skip_unsupported
452 @pytest.mark.parametrize(
453 "backend",
454 BACKENDS,
455 )
456 def test_simple_generation(backend):
457 """Simple test that runs the model with a basic prompt and prints the output.
458 Useful for quick smoke testing and debugging.
459 """
460 model = TEST_MODEL
461
462 llm = LLM(
463 model=model,
464 max_num_seqs=1,
465 tensor_parallel_size=int(os.getenv("VLLM_TP_SIZE", "1")),
466 gpu_memory_utilization=0.9,
467 max_model_len=2048,
468 dtype="auto",
469 enable_prefix_caching=False,
470 attention_config={"backend": backend},
471 )
472
473 prompt = "the capital of france is"
474 sampling_params = SamplingParams(
475 temperature=0.0,
476 max_tokens=20,
477 )
478
479 print(f"\n{'=' * 80}")
480 print("Running simple generation test")
481 print(f"Prompt: '{prompt}'")
482 print(f"{'=' * 80}\n")
483
484 try:
485 outputs = llm.generate([prompt], sampling_params)
486
487 assert len(outputs) == 1
488 output_text = outputs[0].outputs[0].text
489
490 print(f"Output: '{output_text}'")
491 print(f"\n{'=' * 80}")
492 print(f"Full completion: '{prompt}{output_text}'")
493 print(f"{'=' * 80}\n")
494
495 finally:
496 with contextlib.suppress(Exception):
497 llm.llm_engine.engine_core.shutdown()
498
499
500 @skip_unsupported
501 @pytest.mark.parametrize(
502 "backend",
503 BACKENDS,
504 )
505 def test_logprobs_without_batch_invariance_should_fail(
506 backend, monkeypatch: pytest.MonkeyPatch
507 ):
508 """This test is the inverse of test_logprobs_bitwise_batch_invariance_bs1_vs_bsN.
509 It DISABLES batch invariance mode and expects to see non-deterministic behavior
510 between BS=1 and BS=N runs. This demonstrates that batch invariance is actually
511 doing something useful.
512
513 The test will PASS if we detect differences (proving batch invariance matters).
514 The test will FAIL if everything matches (suggesting batch invariance isn't needed).
515 """
516 # CRITICAL: Disable batch invariance for this test
517 monkeypatch.setenv("VLLM_BATCH_INVARIANT", "0")
518 monkeypatch.setattr(envs, "VLLM_BATCH_INVARIANT", False)
519 seed = int(os.getenv("VLLM_TEST_SEED", "12345"))
520 random.seed(seed)
521 tp_size = int(os.getenv("VLLM_TEST_TP_SIZE", "1"))
522
523 print(f"\n{'=' * 80}")
524 print("BATCH INVARIANCE DISABLED: Expecting non-deterministic behavior")
525 print(f"{'=' * 80}\n")
526
527 llm = LLM(
528 model=TEST_MODEL,
529 tensor_parallel_size=tp_size,
530 max_num_seqs=32,
531 max_model_len=8192,
532 dtype="auto",
533 attention_config={"backend": backend},
534 )
535
536 # build ragged prompts to change shapes significantly across BS=1 vs BS=N
537 long_min = int(os.getenv("VLLM_MIN_PROMPT", "768"))
538 long_max = int(os.getenv("VLLM_MAX_PROMPT", "2048"))
539 prompts: list[str] = []
540 options = [
541 (max(long_min, 1536), max(long_max, 3072)), # very long
542 (max(1024, long_min), max(2048, long_max)), # long
543 (256, 512), # mid
544 (10, 20), # short
545 ]
546
547 for _ in range(32):
548 lo, hi = random.choice(options)
549 prompts.append(_random_prompt(lo, hi))
550
551 sp = SamplingParams(
552 temperature=0.6,
553 top_p=1.0,
554 max_tokens=8,
555 seed=1234,
556 logprobs=5,
557 )
558
559 # BS=1: run prompts individually and collect logprobs per step.
560 print("\n" + "=" * 80)
561 print("STARTING BS=1 RUNS (each prompt individually)")
562 print("=" * 80 + "\n")
563
564 bs1_logprobs_per_prompt = []
565 bs1_tokens_per_prompt = []
566 for idx, p in enumerate(prompts):
567 print(f"\n[BS=1] Running prompt {idx}/{len(prompts)} - Preview: {p[:80]}...")
568 outs = llm.generate([p], sp, use_tqdm=False)
569 assert len(outs) == 1
570 step_logprobs, token_ids = _extract_step_logprobs(outs[0])
571 if step_logprobs is None:
572 pytest.skip(
573 "Logits are not available on RequestOutput; "
574 "enable logprobs return to run this test."
575 )
576 bs1_logprobs_per_prompt.append(step_logprobs)
577 bs1_tokens_per_prompt.append(token_ids)
578 print(f"[BS=1] Prompt {idx} generated tokens: {token_ids}")
579
580 # BS=N: run prompts in a batch and collect logprobs per step for each prompt.
581 print("\n" + "=" * 80)
582 print(f"STARTING BS={len(prompts)} RUN (all prompts batched)")
583 print("=" * 80 + "\n")
584
585 outs_batched = llm.generate(prompts, sp, use_tqdm=False)
586 assert len(outs_batched) == len(prompts)
587 bsN_logprobs_per_prompt = []
588 bsN_tokens_per_prompt = []
589
590 print(f"\n[BS={len(prompts)}] Processing batched outputs...")
591 for idx, o in enumerate(outs_batched):
592 tokens = o.outputs[0].token_ids if o.outputs else "N/A"
593 print(f"[BS={len(prompts)}] Prompt {idx} generated tokens: {tokens}")
594 step_logprobs, token_ids = _extract_step_logprobs(o)
595 if step_logprobs is None:
596 pytest.skip(
597 "Logits are not available on RequestOutput; "
598 "enable logprobs return to run this test."
599 )
600 bsN_logprobs_per_prompt.append(step_logprobs)
601 bsN_tokens_per_prompt.append(token_ids)
602
603 # Compare step-by-step logprobs for each prompt between BS=1 and BS=N runs.
604 differences_found = []
605 for i, (logprobs_bs1, logprobs_bsN, tokens_bs1, tokens_bsN) in enumerate(
606 zip(
607 bs1_logprobs_per_prompt,
608 bsN_logprobs_per_prompt,
609 bs1_tokens_per_prompt,
610 bsN_tokens_per_prompt,
611 )
612 ):
613 if len(logprobs_bs1) != len(logprobs_bsN):
614 reason = (
615 f"Different number of steps: {len(logprobs_bs1)} (BS=1) "
616 f"vs {len(logprobs_bsN)} (BS=N)"
617 )
618 differences_found.append(
619 {
620 "prompt_idx": i,
621 "step": "all",
622 "reason": reason,
623 "prompt_preview": prompts[i][:100],
624 "bs1_tokens": tokens_bs1,
625 "bsN_tokens": tokens_bsN,
626 }
627 )
628 continue
629
630 # Check if tokens match first
631 if tokens_bs1 != tokens_bsN:
632 differences_found.append(
633 {
634 "prompt_idx": i,
635 "step": "sampling",
636 "reason": "Different tokens sampled",
637 "prompt_preview": prompts[i][:100],
638 "bs1_tokens": tokens_bs1,
639 "bsN_tokens": tokens_bsN,
640 }
641 )
642 continue
643
644 for t, (a, b) in enumerate(zip(logprobs_bs1, logprobs_bsN)):
645 if a.shape != b.shape:
646 differences_found.append(
647 {
648 "prompt_idx": i,
649 "step": t,
650 "reason": f"Shape mismatch: {a.shape} vs {b.shape}",
651 "prompt_preview": prompts[i][:100],
652 "bs1_tokens": tokens_bs1,
653 "bsN_tokens": tokens_bsN,
654 }
655 )
656 break
657
658 if not torch.equal(a, b):
659 max_diff = torch.abs(a - b).max().item()
660 print(
661 f"\n[EXPECTED DIVERGENCE FOUND] Prompt {i}, "
662 f"Token {t}: max_diff={max_diff:.6e}"
663 )
664 bs1_tok = tokens_bs1[t] if t < len(tokens_bs1) else "N/A"
665 bsN_tok = tokens_bsN[t] if t < len(tokens_bsN) else "N/A"
666 print(f" Token IDs: bs1={bs1_tok}, bsN={bsN_tok}")
667 print(f" BS=1 logprob: {a.tolist()}")
668 print(f" BS=N logprob: {b.tolist()}")
669 differences_found.append(
670 {
671 "prompt_idx": i,
672 "step": t,
673 "reason": f"Bitwise mismatch (max_diff={max_diff:.6e})",
674 "prompt_preview": prompts[i][:100],
675 "bs1_tokens": tokens_bs1,
676 "bsN_tokens": tokens_bsN,
677 }
678 )
679 break
680
681 # Print summary
682 print(f"\n{'=' * 80}")
683 if differences_found:
684 success_msg = (
685 f"✓ SUCCESS: Batch invariance is doing something! "
686 f"Found {len(differences_found)}/{len(prompts)} prompts "
687 f"with differences when batch invariance was DISABLED."
688 )
689 print(success_msg)
690 print(f"{'=' * 80}")
691 for diff in differences_found:
692 print(f"\nPrompt {diff['prompt_idx']} (step {diff['step']}):")
693 print(f" Reason: {diff['reason']}")
694 print(f" Preview: {diff['prompt_preview']}...")
695 if "bs1_tokens" in diff:
696 print(f" BS=1 tokens: {diff['bs1_tokens']}")
697 if "bsN_tokens" in diff:
698 print(f" BS=N tokens: {diff['bsN_tokens']}")
699 print(f"{'=' * 80}\n")
700 # Test PASSES because we found differences (batch invariance matters!)
701 return
702 else:
703 # Test FAILS because everything matched even without batch invariance
704 fail_msg = (
705 f"✗ UNEXPECTED: All {len(prompts)} prompts matched "
706 f"between BS=1 and BS=N even with batch invariance DISABLED. "
707 f"This suggests batch invariance might not be necessary, "
708 f"or the test needs more sensitive prompts."
709 )
710 print(fail_msg)
711 print(f"{'=' * 80}\n")
712 pytest.fail(fail_msg)
713
714
715 @skip_if_not_cuda
716 @pytest.mark.parametrize("backend", ["FLASH_ATTN"])
717 def test_decode_logprobs_match_prefill_logprobs(
718 backend,
719 ):
720 """Test that verifies decode logprobs match prefill logprobs.
721
722 For each decoded token at position i:
723 1. Run decode to generate N tokens and collect their logprobs
724 2. For each position i in [0, N):
725 - Take prefix = prompt + tokens[0:i]
726 - Run prefill(prefix + tokens[i]) to get logprob of tokens[i]
727 - Verify prefill logprob matches decode logprob bitwise
728
729 This ensures that the logprobs from decode are consistent with what
730 we would get if we ran prefill on each prefix.
731 """
732 seed = int(os.getenv("VLLM_TEST_SEED", "12345"))
733 random.seed(seed)
734 tp_size = int(os.getenv("VLLM_TEST_TP_SIZE", "1"))
735
736 llm = LLM(
737 model=TEST_MODEL,
738 tensor_parallel_size=tp_size,
739 max_num_seqs=32,
740 max_model_len=8192,
741 dtype="auto",
742 attention_config={"backend": backend},
743 )
744
745 # Use a few test prompts
746 num_test_prompts = int(os.getenv("VLLM_DECODE_PREFILL_NUM_PROMPTS", "4"))
747 prompts = [_random_prompt(10, 50) for _ in range(num_test_prompts)]
748
749 # Generate longer sequences to test multiple decode steps
750 max_tokens = int(os.getenv("VLLM_DECODE_PREFILL_MAX_TOKENS", "16"))
751
752 sp = SamplingParams(
753 temperature=0.0, # Greedy for determinism
754 max_tokens=max_tokens,
755 logprobs=5,
756 )
757
758 print("\n" + "=" * 80)
759 print("STEP 1: Running decode to generate tokens and collect logprobs")
760 print("=" * 80 + "\n")
761
762 # Step 1: Run decode and collect logprobs
763 decode_outputs = llm.generate(prompts, sp, use_tqdm=False)
764
765 failed_comparisons = []
766
767 for prompt_idx, (prompt, decode_output) in enumerate(zip(prompts, decode_outputs)):
768 print(f"\n[Prompt {prompt_idx}] Testing: {prompt[:80]}...")
769
770 # Extract decode logprobs and tokens
771 decode_logprobs, token_ids = _extract_step_logprobs(decode_output)
772 if decode_logprobs is None:
773 pytest.skip(
774 "Logprobs are not available on RequestOutput; "
775 "enable logprobs return to run this test."
776 )
777
778 print(f"[Prompt {prompt_idx}] Generated {len(token_ids)} tokens: {token_ids}")
779 print(f"[Prompt {prompt_idx}] Decode logprobs: {decode_logprobs.tolist()}")
780
781 # Step 2: For each token position, run prefill and compare
782 print(f"\n[Prompt {prompt_idx}] Verifying each token via prefill...")
783
784 for token_idx in range(len(token_ids)):
785 # Construct the prefix up to (but not including) this token
786 current_token = token_ids[token_idx]
787
788 # We need to detokenize to get the text prefix
789 # For this, we'll use the tokenizer from the LLM
790 # However, the LLM API doesn't expose tokenizer easily, so we'll
791 # construct the prefix by decoding from the original prompt
792
793 # Get text up to this point by using the output text
794 # This is approximate but should work for verification
795 if token_idx == 0:
796 prefix_prompt = prompt
797 else:
798 # No per-token text, so regenerate with max_tokens = token_idx
799 # to get the prefix text
800 prefix_sp = SamplingParams(
801 temperature=0.0,
802 max_tokens=token_idx,
803 logprobs=1,
804 )
805 prefix_output = llm.generate([prompt], prefix_sp, use_tqdm=False)[0]
806 prefix_prompt = prompt + prefix_output.outputs[0].text
807
808 # Now run prefill with max_tokens=1 to get the logprob of the next token
809 prefill_sp = SamplingParams(
810 temperature=0.0,
811 max_tokens=1,
812 logprobs=5,
813 )
814
815 print(
816 f" [Token {token_idx}] Running prefill for prefix "
817 f"(len={len(prefix_prompt)})..."
818 )
819 prefill_output = llm.generate([prefix_prompt], prefill_sp, use_tqdm=False)[
820 0
821 ]
822 prefill_logprobs, prefill_token_ids = _extract_step_logprobs(prefill_output)
823
824 if prefill_logprobs is None:
825 print(f" [Token {token_idx}] Warning: No prefill logprobs available")
826 continue
827
828 # The first token from prefill should match the current token
829 prefill_token = prefill_token_ids[0]
830 prefill_logprob = prefill_logprobs[0].item()
831 decode_logprob = decode_logprobs[token_idx].item()
832
833 print(
834 f" [Token {token_idx}] Decode token: {current_token}, "
835 f"logprob: {decode_logprob:.8f}"
836 )
837 print(
838 f" [Token {token_idx}] Prefill token: {prefill_token}, "
839 f"logprob: {prefill_logprob:.8f}"
840 )
841
842 # Check if tokens match
843 if current_token != prefill_token:
844 failed_comparisons.append(
845 {
846 "prompt_idx": prompt_idx,
847 "token_idx": token_idx,
848 "reason": "Token mismatch",
849 "decode_token": current_token,
850 "prefill_token": prefill_token,
851 "decode_logprob": decode_logprob,
852 "prefill_logprob": prefill_logprob,
853 "prompt_text": prompt[:100],
854 "prefix_text": prefix_prompt[:100],
855 }
856 )
857 print(f" [Token {token_idx}] ✗ TOKEN MISMATCH!")
858 continue
859
860 # Check if logprobs match bitwise
861 if decode_logprob != prefill_logprob:
862 diff = abs(decode_logprob - prefill_logprob)
863 failed_comparisons.append(
864 {
865 "prompt_idx": prompt_idx,
866 "token_idx": token_idx,
867 "reason": "Logprob mismatch",
868 "decode_token": current_token,
869 "prefill_token": prefill_token,
870 "decode_logprob": decode_logprob,
871 "prefill_logprob": prefill_logprob,
872 "diff": diff,
873 "prompt_text": prompt[:100],
874 "prefix_text": prefix_prompt[:100],
875 "decode_all_tokens": token_ids,
876 "decode_all_logprobs": decode_logprobs.tolist(),
877 }
878 )
879 print(f" [Token {token_idx}] ✗ LOGPROB MISMATCH! diff={diff:.8e}")
880 else:
881 print(f" [Token {token_idx}] ✓ Match (bitwise equal)")
882
883 # Print summary
884 print(f"\n{'=' * 80}")
885 if failed_comparisons:
886 print(f"DECODE-PREFILL MISMATCH: {len(failed_comparisons)} failures detected")
887 print(f"{'=' * 80}")
888
889 # Group failures by prompt for better readability
890 failures_by_prompt: dict[int, list[dict]] = {}
891 for fail in failed_comparisons:
892 pid = fail["prompt_idx"]
893 if pid not in failures_by_prompt:
894 failures_by_prompt[pid] = []
895 failures_by_prompt[pid].append(fail)
896
897 for prompt_idx, failures in failures_by_prompt.items():
898 print(f"\n{'=' * 80}")
899 print(f"PROMPT {prompt_idx}: {failures[0]['prompt_text']}...")
900 print(f"{'=' * 80}")
901 print(f"Total failures for this prompt: {len(failures)}")
902
903 # Show where mismatches occur (which token positions)
904 mismatch_positions = [f["token_idx"] for f in failures]
905 print(f"Mismatch at token positions: {mismatch_positions}")
906
907 # Show first few failures in detail
908 for i, fail in enumerate(failures[:5]): # Show first 5 failures per prompt
909 print(f"\n [Failure {i + 1}] Token position {fail['token_idx']}:")
910 print(f" Reason: {fail['reason']}")
911 print(f" Prefix text: '{fail['prefix_text']}...'")
912 print(
913 f" Decode: token={fail['decode_token']}, "
914 f"logprob={fail['decode_logprob']:.10f}"
915 )
916 print(
917 f" Prefill: token={fail['prefill_token']}, "
918 f"logprob={fail['prefill_logprob']:.10f}"
919 )
920 if "diff" in fail:
921 print(f" Difference: {fail['diff']:.10e}")
922 # Show in hex to see bitwise difference
923 import struct
924
925 decode_hex = struct.pack("f", fail["decode_logprob"]).hex()
926 prefill_hex = struct.pack("f", fail["prefill_logprob"]).hex()
927 print(f" Decode logprob (hex): 0x{decode_hex}")
928 print(f" Prefill logprob (hex): 0x{prefill_hex}")
929
930 # If we have all tokens/logprobs, show the context
931 if "decode_all_tokens" in fail and "decode_all_logprobs" in fail:
932 token_idx = fail["token_idx"]
933 all_tokens = fail["decode_all_tokens"]
934 all_logprobs = fail["decode_all_logprobs"]
935
936 # Show context: 2 tokens before and after
937 start = max(0, token_idx - 2)
938 end = min(len(all_tokens), token_idx + 3)
939
940 print(f" Context (tokens {start} to {end - 1}):")
941 for j in range(start, end):
942 marker = " <-- MISMATCH" if j == token_idx else ""
943 print(
944 f" [{j}] token={all_tokens[j]}, "
945 f"logprob={all_logprobs[j]:.8f}{marker}"
946 )
947
948 if len(failures) > 5:
949 print(f"\n ... and {len(failures) - 5} more failures for this prompt")
950
951 print(f"\n{'=' * 80}\n")
952
953 pytest.fail(
954 f"Decode logprobs do not match prefill logprobs: "
955 f"{len(failed_comparisons)} mismatches found."
956 )
957 else:
958 print("✓ SUCCESS: All decode logprobs match prefill logprobs bitwise!")
959 print(f"{'=' * 80}\n")
960
961
962 def LLM_with_max_seqs(
963 model: str,
964 max_num_seqs: int,
965 gpu_memory_utilization: float,
966 max_model_len: int,
967 attention_config: dict | None = None,
968 kernel_config: dict | None = None,
969 max_num_batched_tokens: int | None = None,
970 ) -> LLM:
971 """Helper to construct an LLM with a specific max_num_seqs (batch-size limit)
972 using the high-level v1 LLM API, while constraining memory usage.
973 """
974 extra_kwargs: dict = {}
975 if kernel_config is not None:
976 extra_kwargs["kernel_config"] = kernel_config
977 if max_num_batched_tokens is not None:
978 extra_kwargs["max_num_batched_tokens"] = max_num_batched_tokens
979 return LLM(
980 model=model,
981 max_num_seqs=max_num_seqs,
982 gpu_memory_utilization=gpu_memory_utilization,
983 max_model_len=max_model_len,
984 dtype="auto",
985 tensor_parallel_size=int(os.getenv("VLLM_TP_SIZE", "1")),
986 enable_prefix_caching=False,
987 attention_config=attention_config,
988 # Enable for MOE models
989 # enable_expert_parallel=True,
990 **extra_kwargs,
991 )