main
py 250 lines 10.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Batch-sharded sampling must be a drop-in for replicated sampling.
4
5 The two boots are compared token for token, which is only meaningful because
6 both schedule the same batches. With the engine in the caller's process every
7 request is queued before the first step, so the first prefill has the same
8 shape in both runs. Give the engine its own process and the comparison turns
9 into a coin toss: prefill starts before the last requests arrive over IPC, so
10 one boot batches two requests where the other batches one. The batch shape
11 sets the GEMM reduction order, logprobs shift by up to ~0.25, and near-ties
12 flip — measured at up to 5 of 16 completions, with sharding disabled on both
13 sides. A real sharding bug — rows routed to the wrong request, misaligned
14 gathers — produces many-nats divergence and scrambled top-k sets."""
15
16 import pytest
17
18 from tests.utils import multi_gpu_test
19 from vllm import LLM, SamplingParams
20 from vllm.distributed import cleanup_dist_env_and_memory
21 from vllm.logprobs import Logprob, LogprobsOnePosition
22
23 MODEL = "meta-llama/Llama-3.2-1B-Instruct"
24
25 # Misrouted rows move logprobs by nats; anything under this is float noise.
26 # With matched batches the two boots agreed bitwise over 320 positions, so the
27 # bound is not expected to be reached.
28 LOGPROB_TOL = 0.5
29
30 PROMPTS = [
31 "The capital of France is",
32 "The three primary colors are",
33 "Write a haiku about the ocean.",
34 "1, 1, 2, 3, 5, 8,",
35 "The chemical symbol for gold is",
36 "Once upon a time, in a distant kingdom,",
37 "Python's GIL stands for",
38 "The fastest land animal is",
39 ]
40
41 # Exercise greedy, seeded random, top-k/top-p, penalties, and logprobs.
42 SAMPLING_PARAMS = [
43 SamplingParams(temperature=0.0, max_tokens=32, logprobs=5, prompt_logprobs=1),
44 SamplingParams(temperature=0.8, seed=42, top_p=0.9, max_tokens=32, logprobs=3),
45 SamplingParams(temperature=0.7, seed=7, top_k=50, min_p=0.05, max_tokens=32),
46 SamplingParams(
47 temperature=0.9,
48 seed=123,
49 frequency_penalty=0.5,
50 repetition_penalty=1.2,
51 max_tokens=32,
52 logprobs=2,
53 ),
54 ]
55
56
57 def _is_sharded_sampling_active(worker) -> bool:
58 return worker.model_runner.batch_sharder is not None
59
60
61 def _sampling_params() -> list[SamplingParams]:
62 return [SAMPLING_PARAMS[i % len(SAMPLING_PARAMS)] for i in range(len(PROMPTS))]
63
64
65 def _generate(monkeypatch: pytest.MonkeyPatch, disable_sharding: bool):
66 llm = LLM(
67 model=MODEL,
68 tensor_parallel_size=2,
69 enable_batch_sharded_sampling=not disable_sharding,
70 max_model_len=1024,
71 # More requests than ranks, so round-robin slot ownership splits the
72 # batch across both ranks.
73 max_num_seqs=len(PROMPTS),
74 enforce_eager=True,
75 enable_prefix_caching=False,
76 gpu_memory_utilization=0.7,
77 # Timing-based kernel selection is the dominant source of cross-boot
78 # numeric noise; disable it so the comparison bounds stay tight.
79 kernel_config={"enable_flashinfer_autotune": False},
80 )
81 try:
82 # Guard against a vacuous comparison: verify every worker is in the
83 # intended sampling mode.
84 modes = llm.llm_engine.collective_rpc(_is_sharded_sampling_active)
85 assert all(mode == (not disable_sharding) for mode in modes), modes
86 params = _sampling_params()
87 # Two waves: the second reuses the request slots freed by the first
88 # (in whatever order requests finished), covering the cross-rank
89 # slot-recycling path that request ownership derives from.
90 wave1 = llm.generate(PROMPTS, params)
91 wave2 = llm.generate(PROMPTS[::-1], params[::-1])
92 return wave1 + wave2
93 finally:
94 del llm
95 cleanup_dist_env_and_memory()
96
97
98 def _requested_top_k(lps: LogprobsOnePosition, num_logprobs: int) -> set[int]:
99 """Exclude the extra sampled/prompt token, including at rank k+1."""
100 top = {
101 token_id
102 for token_id, logprob in lps.items()
103 if logprob.rank is not None and 1 <= logprob.rank <= num_logprobs
104 }
105 assert len(top) == num_logprobs, (
106 f"expected {num_logprobs} ranked top-k entries, got {len(top)}"
107 )
108 return top
109
110
111 def _assert_logprob_dicts_close(
112 ref_lps: LogprobsOnePosition,
113 out_lps: LogprobsOnePosition,
114 what: str,
115 num_logprobs: int,
116 ) -> None:
117 # Allow one boundary entry of the top-k set to swap at a near-tie. Compare
118 # the top-k alone: counting the sampled token would spend that allowance
119 # on the extra entry itself whenever only one side carries one.
120 ref_top = _requested_top_k(ref_lps, num_logprobs)
121 out_top = _requested_top_k(out_lps, num_logprobs)
122 shared_top = ref_top & out_top
123 assert len(shared_top) >= num_logprobs - 1, (
124 f"{what}: top-k token sets diverge: {sorted(ref_top)} vs {sorted(out_top)}"
125 )
126 # Values are comparable for every shared token, sampled one included.
127 for token_id in set(ref_lps) & set(out_lps):
128 diff = abs(ref_lps[token_id].logprob - out_lps[token_id].logprob)
129 assert diff <= LOGPROB_TOL, (
130 f"{what}[{token_id}]: logprob diff {diff} "
131 f"({ref_lps[token_id].logprob} vs {out_lps[token_id].logprob})"
132 )
133
134
135 @pytest.mark.cpu_test
136 @pytest.mark.parametrize(
137 ("num_logprobs", "ref_ranks", "out_ranks"),
138 [
139 (2, {0: 1, 1: 2, 2: 3}, {0: 1, 3: 2, 2: 4}),
140 (1, {0: 1, 1: 2}, {2: 1, 1: 3}),
141 (2, {0: 1, 1: 2}, {0: 1, 2: 2, 1: 3}),
142 (2, {0: 1, 1: 2, 2: 4}, {0: 1, 1: 2, 2: 5}),
143 (0, {0: 2}, {0: 3}),
144 ],
145 ids=["decode-boundary", "prompt-boundary", "sampled-in-top-k", "gap", "zero"],
146 )
147 def test_logprob_comparison_allows_sampled_token_rank_changes(
148 num_logprobs: int, ref_ranks: dict[int, int], out_ranks: dict[int, int]
149 ):
150 ref_lps, out_lps = (
151 {token: Logprob(-2.0 - rank / 100, rank=rank) for token, rank in ranks.items()}
152 for ranks in (ref_ranks, out_ranks)
153 )
154 _assert_logprob_dicts_close(ref_lps, out_lps, "boundary swap", num_logprobs)
155
156
157 @pytest.mark.cpu_test
158 def test_logprob_comparison_rejects_multiple_top_k_swaps():
159 ref = {0: Logprob(-2.0, rank=1), 1: Logprob(-2.01, rank=2)}
160 out = {2: Logprob(-2.0, rank=1), 3: Logprob(-2.01, rank=2)}
161 with pytest.raises(AssertionError, match="top-k token sets diverge"):
162 _assert_logprob_dicts_close(ref, out, "misrouted top-k", num_logprobs=2)
163
164
165 @pytest.mark.cpu_test
166 def test_logprob_comparison_checks_extra_sampled_token_value():
167 ref = {0: Logprob(-1.0, rank=1), 1: Logprob(-3.0, rank=3)}
168 out = {0: Logprob(-1.0, rank=1), 1: Logprob(-4.0, rank=3)}
169 with pytest.raises(AssertionError, match="logprob diff"):
170 _assert_logprob_dicts_close(ref, out, "sampled token", num_logprobs=1)
171
172
173 @pytest.mark.cpu_test
174 @pytest.mark.parametrize("rank", [None, 2])
175 def test_logprob_comparison_rejects_missing_top_k_rank(rank: int | None):
176 lps = {0: Logprob(-1.0, rank=rank)}
177 with pytest.raises(AssertionError, match="expected 1 ranked top-k entries"):
178 _assert_logprob_dicts_close(lps, lps, "missing rank", num_logprobs=1)
179
180
181 @multi_gpu_test(num_gpus=2)
182 def test_sharded_sampling_outputs_match(monkeypatch: pytest.MonkeyPatch):
183 """Generation, logprobs, and prompt logprobs match between batch-sharded
184 sampling and the replicated fallback."""
185 monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
186 # Required for the collective_rpc mode check below.
187 monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
188 # Queue every request before the first step, so both boots schedule the
189 # same batches -- see the module docstring.
190 monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
191
192 ref_outputs = _generate(monkeypatch, disable_sharding=True)
193 shard_outputs = _generate(monkeypatch, disable_sharding=False)
194
195 assert len(ref_outputs) == len(shard_outputs) == 2 * len(PROMPTS)
196 params = _sampling_params()
197 divergent = []
198 for i, (ref, out, sampling_params) in enumerate(
199 zip(ref_outputs, shard_outputs, params + params[::-1])
200 ):
201 ref_completion = ref.outputs[0]
202 out_completion = out.outputs[0]
203 ref_ids = list(ref_completion.token_ids)
204 out_ids = list(out_completion.token_ids)
205
206 # Logits are only comparable at positions whose context (prompt +
207 # previously sampled tokens) is identical: every position up to and
208 # including the first divergence.
209 prefix = 0
210 while prefix < min(len(ref_ids), len(out_ids)):
211 if ref_ids[prefix] != out_ids[prefix]:
212 break
213 prefix += 1
214 if ref_ids != out_ids:
215 divergent.append((i, prefix))
216
217 if ref_completion.logprobs is not None:
218 assert out_completion.logprobs is not None
219 assert sampling_params.logprobs is not None
220 comparable = min(prefix + 1, len(ref_ids), len(out_ids))
221 for pos in range(comparable):
222 _assert_logprob_dicts_close(
223 ref_completion.logprobs[pos],
224 out_completion.logprobs[pos],
225 f"prompt {i} logprobs[{pos}]",
226 sampling_params.logprobs,
227 )
228
229 # The prompt is fixed, so every prompt-logprob position is comparable.
230 if ref.prompt_logprobs is not None:
231 assert out.prompt_logprobs is not None
232 assert sampling_params.prompt_logprobs is not None
233 for pos, (ref_lps, out_lps) in enumerate(
234 zip(ref.prompt_logprobs, out.prompt_logprobs)
235 ):
236 if ref_lps is None or out_lps is None:
237 assert ref_lps is None and out_lps is None
238 continue
239 _assert_logprob_dicts_close(
240 ref_lps,
241 out_lps,
242 f"prompt {i} prompt_logprobs[{pos}]",
243 sampling_params.prompt_logprobs,
244 )
245
246 assert not divergent, (
247 f"{len(divergent)}/{2 * len(PROMPTS)} completions diverged at "
248 f"(prompt, position) {divergent}; the sampling modes did not agree "
249 "token for token with in-process scheduling"
250 )