[Model Runner V2] batch-sharded sample (#50465)
Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>
Giancarlo Delfin committed
Aug 24, 2026 at 15:49 UTC
d154d90d6c4bcf26a0c78ac4f3e43621c14333ba
19 files changed
+1781
-22
.buildkite/test_areas/model_runner_v2.yaml
+6
-1
@@ -63,7 +63,7 @@ steps:
63
64
- label: ":nvidia: (L4) Model Runner V2 Distributed"
65
key: model-runner-v2-distributed-2-gpus
66
- timeout_in_minutes: 30
66
+ timeout_in_minutes: 40
67
working_dir: "/vllm-workspace/tests"
68
num_devices: 2
69
source_file_dependencies:
@@ -72,6 +72,8 @@ steps:
72
- tests/basic_correctness/test_basic_correctness.py
73
- tests/v1/distributed/test_async_llm_dp.py
74
- tests/v1/distributed/test_eagle_dp.py
75
+ - tests/v1/e2e/general/test_sharded_sampling.py
76
+ - tests/v1/e2e/spec_decode/test_sharded_sampling.py
77
commands:
78
- set -x
79
- export VLLM_USE_V2_MODEL_RUNNER=1
@@ -81,6 +83,9 @@ steps:
83
- export NCCL_CUMEM_HOST_ENABLE=0
84
- TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_async_llm_dp.py -k "not ray"
85
- TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_eagle_dp.py
86
+ - pytest -v -s v1/e2e/general/test_sharded_sampling.py
87
+ - pytest -v -s v1/e2e/spec_decode/test_sharded_sampling.py
88
+
89
mirror:
90
amd:
91
label: ":amd: (MI300) Model Runner V2 Distributed"
tests/v1/e2e/general/test_sharded_sampling.py
new
+162
@@ -0,0 +1,162 @@
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 comparison is tolerance-based, not bitwise: engine boots land in
6
+slightly different kernel/collective states (measured: same-state boots are
7
+bitwise identical across the two modes; cross-state boots differ by up to
8
+~0.25 logprob and can flip a near-tie token). A real sharding bug — rows
9
+routed to the wrong request, misaligned gathers — produces many-nats
10
+divergence and scrambled top-k sets, far outside these bounds."""
11
+
12
+import pytest
13
+
14
+from tests.utils import multi_gpu_test
15
+from vllm import LLM, SamplingParams
16
+from vllm.distributed import cleanup_dist_env_and_memory
17
+
18
+MODEL = "meta-llama/Llama-3.2-1B-Instruct"
19
+
20
+# Boot-state numeric noise bound (measured <= 0.25 on identical contexts).
21
+LOGPROB_TOL = 0.5
22
+# Near-tie greedy/seeded flips from boot-state noise; measured <= 1 per
23
+# engine boot across ~16 completions, plus one of headroom.
24
+MAX_DIVERGENT_PROMPTS = 3
25
+
26
+PROMPTS = [
27
+ "The capital of France is",
28
+ "The three primary colors are",
29
+ "Write a haiku about the ocean.",
30
+ "1, 1, 2, 3, 5, 8,",
31
+ "The chemical symbol for gold is",
32
+ "Once upon a time, in a distant kingdom,",
33
+ "Python's GIL stands for",
34
+ "The fastest land animal is",
35
+]
36
+
37
+# Exercise greedy, seeded random, top-k/top-p, penalties, and logprobs.
38
+SAMPLING_PARAMS = [
39
+ SamplingParams(temperature=0.0, max_tokens=32, logprobs=5, prompt_logprobs=1),
40
+ SamplingParams(temperature=0.8, seed=42, top_p=0.9, max_tokens=32, logprobs=3),
41
+ SamplingParams(temperature=0.7, seed=7, top_k=50, min_p=0.05, max_tokens=32),
42
+ SamplingParams(
43
+ temperature=0.9,
44
+ seed=123,
45
+ frequency_penalty=0.5,
46
+ repetition_penalty=1.2,
47
+ max_tokens=32,
48
+ logprobs=2,
49
+ ),
50
+]
51
+
52
+
53
+def _is_sharded_sampling_active(worker) -> bool:
54
+ return worker.model_runner.batch_sharder is not None
55
+
56
+
57
+def _generate(monkeypatch: pytest.MonkeyPatch, disable_sharding: bool):
58
+ llm = LLM(
59
+ model=MODEL,
60
+ tensor_parallel_size=2,
61
+ enable_batch_sharded_sampling=not disable_sharding,
62
+ max_model_len=1024,
63
+ # More requests than ranks, so round-robin slot ownership splits the
64
+ # batch across both ranks.
65
+ max_num_seqs=len(PROMPTS),
66
+ enforce_eager=True,
67
+ enable_prefix_caching=False,
68
+ gpu_memory_utilization=0.7,
69
+ # Timing-based kernel selection is the dominant source of cross-boot
70
+ # numeric noise; disable it so the comparison bounds stay tight.
71
+ kernel_config={"enable_flashinfer_autotune": False},
72
+ )
73
+ try:
74
+ # Guard against a vacuous comparison: verify every worker is in the
75
+ # intended sampling mode.
76
+ modes = llm.llm_engine.collective_rpc(_is_sharded_sampling_active)
77
+ assert all(mode == (not disable_sharding) for mode in modes), modes
78
+ params = [
79
+ SAMPLING_PARAMS[i % len(SAMPLING_PARAMS)] for i in range(len(PROMPTS))
80
+ ]
81
+ # Two waves: the second reuses the request slots freed by the first
82
+ # (in whatever order requests finished), covering the cross-rank
83
+ # slot-recycling path that request ownership derives from.
84
+ wave1 = llm.generate(PROMPTS, params)
85
+ wave2 = llm.generate(PROMPTS[::-1], params[::-1])
86
+ return wave1 + wave2
87
+ finally:
88
+ del llm
89
+ cleanup_dist_env_and_memory()
90
+
91
+
92
+def _assert_logprob_dicts_close(ref_lps, out_lps, what: str) -> None:
93
+ # Allow one boundary entry of the top-k set to swap at a near-tie.
94
+ common = set(ref_lps) & set(out_lps)
95
+ assert len(common) >= max(len(ref_lps), len(out_lps)) - 1, (
96
+ f"{what}: top-k token sets diverge: {sorted(ref_lps)} vs {sorted(out_lps)}"
97
+ )
98
+ for token_id in common:
99
+ diff = abs(ref_lps[token_id].logprob - out_lps[token_id].logprob)
100
+ assert diff <= LOGPROB_TOL, (
101
+ f"{what}[{token_id}]: logprob diff {diff} "
102
+ f"({ref_lps[token_id].logprob} vs {out_lps[token_id].logprob})"
103
+ )
104
+
105
+
106
+@multi_gpu_test(num_gpus=2)
107
+def test_sharded_sampling_outputs_match(monkeypatch: pytest.MonkeyPatch):
108
+ """Generation, logprobs, and prompt logprobs match between batch-sharded
109
+ sampling and the replicated fallback, up to measured boot-state noise."""
110
+ monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
111
+ # Required for the collective_rpc mode check below.
112
+ monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
113
+
114
+ ref_outputs = _generate(monkeypatch, disable_sharding=True)
115
+ shard_outputs = _generate(monkeypatch, disable_sharding=False)
116
+
117
+ assert len(ref_outputs) == len(shard_outputs) == 2 * len(PROMPTS)
118
+ num_divergent = 0
119
+ for i, (ref, out) in enumerate(zip(ref_outputs, shard_outputs)):
120
+ ref_completion = ref.outputs[0]
121
+ out_completion = out.outputs[0]
122
+ ref_ids = list(ref_completion.token_ids)
123
+ out_ids = list(out_completion.token_ids)
124
+
125
+ # Logits are only comparable at positions whose context (prompt +
126
+ # previously sampled tokens) is identical: every position up to and
127
+ # including the first divergence.
128
+ prefix = 0
129
+ while prefix < min(len(ref_ids), len(out_ids)):
130
+ if ref_ids[prefix] != out_ids[prefix]:
131
+ break
132
+ prefix += 1
133
+ if ref_ids != out_ids:
134
+ num_divergent += 1
135
+
136
+ if ref_completion.logprobs is not None:
137
+ assert out_completion.logprobs is not None
138
+ comparable = min(prefix + 1, len(ref_ids), len(out_ids))
139
+ for pos in range(comparable):
140
+ _assert_logprob_dicts_close(
141
+ ref_completion.logprobs[pos],
142
+ out_completion.logprobs[pos],
143
+ f"prompt {i} logprobs[{pos}]",
144
+ )
145
+
146
+ # The prompt is fixed, so every prompt-logprob position is comparable.
147
+ if ref.prompt_logprobs is not None:
148
+ assert out.prompt_logprobs is not None
149
+ for pos, (ref_lps, out_lps) in enumerate(
150
+ zip(ref.prompt_logprobs, out.prompt_logprobs)
151
+ ):
152
+ if ref_lps is None or out_lps is None:
153
+ assert ref_lps is None and out_lps is None
154
+ continue
155
+ _assert_logprob_dicts_close(
156
+ ref_lps, out_lps, f"prompt {i} prompt_logprobs[{pos}]"
157
+ )
158
+
159
+ assert num_divergent <= MAX_DIVERGENT_PROMPTS, (
160
+ f"{num_divergent}/{2 * len(PROMPTS)} prompts diverged: beyond near-tie "
161
+ "boot-state noise; sharded sampling is likely misrouting requests"
162
+ )
tests/v1/e2e/spec_decode/test_sharded_sampling.py
new
+102
@@ -0,0 +1,102 @@
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 under
4
+speculative decoding.
5
+
6
+The non-spec arm of this claim lives in tests/v1/e2e/general/
7
+test_sharded_sampling.py. Spec decode is the harder case: draft tokens give
8
+each request a different number of logits rows, so the shard plan's
9
+per-request arithmetic and the rejection sampler are both exercised.
10
+"""
11
+
12
+import pytest
13
+import torch
14
+
15
+from tests.utils import wait_for_memory_to_settle
16
+from vllm import LLM, SamplingParams
17
+from vllm.distributed import cleanup_dist_env_and_memory
18
+
19
+from .utils import _skip_if_insufficient_gpus_for_tp, get_test_prompts, greedy_sampling
20
+
21
+
22
+def _is_sharded_sampling_active(worker) -> bool:
23
+ return worker.model_runner.batch_sharder is not None
24
+
25
+
26
+def test_mtp_sharded_sampling_equivalence(monkeypatch: pytest.MonkeyPatch):
27
+ """Batch-sharded sampling must be a bit-exact drop-in for replicated
28
+ sampling under MTP spec decoding: the collectives move the same logits
29
+ bytes, Gumbel keys derive from (request slot, position, seed), and slot
30
+ assignment is rank-deterministic. Both runs here are spec decode with
31
+ identical math, so outputs must match exactly."""
32
+ tp_size = 2
33
+ _skip_if_insufficient_gpus_for_tp(tp_size)
34
+ model_name = "Qwen/Qwen3.5-0.8B-Base"
35
+ test_prompts = get_test_prompts(mm_enabled=False, num_prompts=20)
36
+
37
+ with monkeypatch.context() as m:
38
+ # Batch-sharded sampling only exists in the V2 model runner.
39
+ m.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
40
+ # Required for the collective_rpc mode check below.
41
+ m.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
42
+
43
+ def run(disable_sharding: bool):
44
+ llm = LLM(
45
+ model=model_name,
46
+ tensor_parallel_size=tp_size,
47
+ enable_batch_sharded_sampling=not disable_sharding,
48
+ speculative_config={
49
+ "method": "mtp",
50
+ "num_speculative_tokens": 1,
51
+ "max_model_len": 2048,
52
+ },
53
+ max_model_len=2048,
54
+ limit_mm_per_prompt={"image": 0, "video": 0},
55
+ # More requests than ranks, so round-robin slot ownership
56
+ # splits the batch across both ranks.
57
+ max_num_seqs=len(test_prompts),
58
+ # Timing-based kernel selection is the dominant source of
59
+ # cross-boot numeric noise; disable for a tight comparison.
60
+ kernel_config={"enable_flashinfer_autotune": False},
61
+ )
62
+ try:
63
+ # Guard against a vacuous comparison: verify every worker is
64
+ # in the intended sampling mode.
65
+ modes = llm.llm_engine.collective_rpc(_is_sharded_sampling_active)
66
+ assert all(mode == (not disable_sharding) for mode in modes)
67
+ greedy = llm.chat(test_prompts, greedy_sampling())
68
+ seeded = llm.chat(
69
+ test_prompts,
70
+ SamplingParams(temperature=1.0, seed=33, max_tokens=10),
71
+ )
72
+ return greedy, seeded
73
+ finally:
74
+ del llm
75
+ torch.accelerator.empty_cache()
76
+ cleanup_dist_env_and_memory()
77
+ wait_for_memory_to_settle()
78
+
79
+ ref_greedy, ref_seeded = run(disable_sharding=True)
80
+ shard_greedy, shard_seeded = run(disable_sharding=False)
81
+
82
+ # Engine boots are not bitwise deterministic (kernel/collective
83
+ # selection shifts logits by ~1 ulp, flipping near-tie tokens), so
84
+ # allow a small number of divergent prompts. A sharding bug produces
85
+ # wholesale divergence, not isolated near-tie flips.
86
+ for name, ref_outputs, shard_outputs in (
87
+ ("greedy", ref_greedy, shard_greedy),
88
+ ("seeded", ref_seeded, shard_seeded),
89
+ ):
90
+ num_divergent = 0
91
+ for i, (ref, out) in enumerate(zip(ref_outputs, shard_outputs)):
92
+ if list(ref.outputs[0].token_ids) != list(out.outputs[0].token_ids):
93
+ num_divergent += 1
94
+ print(
95
+ f"{name} prompt {i} diverged:\n"
96
+ f" replicated: {ref.outputs[0].text!r}\n"
97
+ f" sharded: {out.outputs[0].text!r}"
98
+ )
99
+ assert num_divergent <= 2, (
100
+ f"{name}: {num_divergent}/{len(ref_outputs)} prompts diverged, "
101
+ "beyond near-tie boot noise"
102
+ )
tests/v1/worker/test_gpu_batch_shard.py
new
+613
@@ -0,0 +1,613 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Routing-math tests for batch-sharded sampling.
4
+
5
+These simulate every TP rank in-process (no torch.distributed): the local
6
+sub-batch, the all-to-all send/recv layout, and the padded result gather must
7
+compose to a bit-exact round trip of the replicated computation.
8
+"""
9
+
10
+from types import SimpleNamespace
11
+from unittest import mock
12
+
13
+import numpy as np
14
+import pytest
15
+import torch
16
+
17
+from vllm.v1.core.sched.output import GrammarOutput
18
+from vllm.v1.worker.gpu.input_batch import InputBatch
19
+from vllm.v1.worker.gpu.sample import batch_shard
20
+from vllm.v1.worker.gpu.sample.batch_shard import (
21
+ BatchSharder,
22
+ _shard_grammar_output,
23
+)
24
+
25
+DEVICE = "cuda"
26
+
27
+# The owner-sorted layout is expanded by a Triton kernel, so the routing math
28
+# can only be exercised on device.
29
+requires_cuda = pytest.mark.skipif(
30
+ not torch.cuda.is_available(), reason="requires CUDA"
31
+)
32
+
33
+
34
+def _np(t: torch.Tensor) -> np.ndarray:
35
+ return t.cpu().numpy()
36
+
37
+
38
+def _make_batch(
39
+ rng: np.random.Generator,
40
+ num_reqs: int,
41
+ max_num_reqs: int,
42
+ max_spec: int,
43
+ slot_pool: np.ndarray | None = None,
44
+) -> InputBatch:
45
+ """Consistent batch with random unique request slots, 1 + k_i logits
46
+ rows per request at the tail of each query segment."""
47
+ if slot_pool is None:
48
+ slot_pool = np.arange(max_num_reqs)
49
+ slots = rng.choice(slot_pool, size=num_reqs, replace=False)
50
+ slots = slots.astype(np.int32)
51
+ k = rng.integers(0, max_spec + 1, size=num_reqs)
52
+ num_logits_per_req = (1 + k).astype(np.int32)
53
+ cu = np.zeros(num_reqs + 1, dtype=np.int32)
54
+ np.cumsum(num_logits_per_req, out=cu[1:])
55
+
56
+ query_lens = num_logits_per_req + rng.integers(0, 4, size=num_reqs).astype(np.int32)
57
+ qsl = np.zeros(num_reqs + 1, dtype=np.int32)
58
+ np.cumsum(query_lens, out=qsl[1:])
59
+ num_tokens = int(qsl[-1])
60
+
61
+ logits_indices_np = np.concatenate(
62
+ [
63
+ np.arange(qsl[i + 1] - num_logits_per_req[i], qsl[i + 1])
64
+ for i in range(num_reqs)
65
+ ]
66
+ ).astype(np.int64)
67
+ expanded_idx_np = np.repeat(slots, num_logits_per_req)
68
+ expanded_pos_np = np.concatenate(
69
+ [np.arange(n, dtype=np.int32) for n in num_logits_per_req]
70
+ )
71
+ seq_lens_np = (query_lens + rng.integers(0, 100, size=num_reqs)).astype(np.int32)
72
+
73
+ def dev(a: np.ndarray) -> torch.Tensor:
74
+ return torch.from_numpy(a).to(DEVICE)
75
+
76
+ return InputBatch(
77
+ req_ids=[f"req-{i}" for i in range(num_reqs)],
78
+ num_reqs=num_reqs,
79
+ num_reqs_after_padding=num_reqs,
80
+ idx_mapping=dev(slots),
81
+ idx_mapping_np=slots,
82
+ expanded_idx_mapping=dev(expanded_idx_np),
83
+ expanded_local_pos=dev(expanded_pos_np),
84
+ num_scheduled_tokens=query_lens,
85
+ num_tokens=num_tokens,
86
+ num_tokens_after_padding=num_tokens,
87
+ num_draft_tokens=int(k.sum()),
88
+ num_draft_tokens_per_req=k.astype(np.int32),
89
+ query_start_loc=dev(qsl),
90
+ query_start_loc_np=qsl,
91
+ seq_lens=dev(seq_lens_np),
92
+ seq_lens_cpu_upper_bound=torch.from_numpy(seq_lens_np.copy()),
93
+ dcp_local_seq_lens=None,
94
+ num_computed_tokens_np=np.zeros(num_reqs, dtype=np.int32),
95
+ prefill_len_np=np.zeros(num_reqs, dtype=np.int32),
96
+ num_computed_prefill_tokens_np=np.zeros(num_reqs, dtype=np.int32),
97
+ is_prefilling_np=np.zeros(num_reqs, dtype=np.bool_),
98
+ has_prefill=False,
99
+ max_seq_len_np=None,
100
+ input_ids=torch.zeros(num_tokens, dtype=torch.int32, device=DEVICE),
101
+ positions=torch.arange(num_tokens, dtype=torch.int64, device=DEVICE),
102
+ is_padding=torch.zeros(num_tokens, dtype=torch.bool, device=DEVICE),
103
+ logits_indices=dev(logits_indices_np),
104
+ cu_num_logits=dev(cu),
105
+ cu_num_logits_np=cu,
106
+ has_structured_output_reqs=False,
107
+ prompt_lens=None,
108
+ )
109
+
110
+
111
+def _shard_all_ranks(batch: InputBatch, max_num_reqs: int, tp_size: int):
112
+ """Simulate every TP rank by faking get_tp_group() per BatchSharder."""
113
+ results = []
114
+ for rank in range(tp_size):
115
+ fake_group = SimpleNamespace(rank_in_group=rank, world_size=tp_size)
116
+ with mock.patch.object(batch_shard, "get_tp_group", return_value=fake_group):
117
+ sharder = BatchSharder(
118
+ max_num_reqs=max_num_reqs,
119
+ max_num_logits_per_req=8,
120
+ device=torch.device(DEVICE),
121
+ )
122
+ local_batch, sorted_logits_indices, _, metadata = sharder.shard_sampler_inputs(
123
+ batch, None
124
+ )
125
+ results.append((local_batch, sorted_logits_indices, metadata))
126
+ return results
127
+
128
+
129
+def _owned_batch_indices(local_batch: InputBatch) -> np.ndarray:
130
+ return np.array([int(r.split("-")[1]) for r in local_batch.req_ids], dtype=np.int64)
131
+
132
+
133
+def _owned_rows(cu: np.ndarray, owned: np.ndarray) -> np.ndarray:
134
+ if len(owned) == 0:
135
+ return np.array([], dtype=np.int64)
136
+ return np.concatenate([np.arange(cu[r], cu[r + 1]) for r in owned])
137
+
138
+
139
+@requires_cuda
140
+@pytest.mark.parametrize("tp_size", [2, 4, 8])
141
+@pytest.mark.parametrize("seed", [0, 1])
142
+def test_local_batch_partition(tp_size: int, seed: int):
143
+ """Local sub-batches partition the batch by owner, preserve batch order
144
+ within an owner, and carry exactly the owned requests' entries of every
145
+ per-request and per-row array."""
146
+ rng = np.random.default_rng(seed)
147
+ max_num_reqs = 64
148
+ batch = _make_batch(rng, num_reqs=23, max_num_reqs=max_num_reqs, max_spec=3)
149
+ num_logits = int(batch.cu_num_logits_np[-1])
150
+ results = _shard_all_ranks(batch, max_num_reqs, tp_size)
151
+ cu = batch.cu_num_logits_np
152
+
153
+ metadata0 = results[0][2]
154
+ assert sum(metadata0.num_logits_per_rank) == num_logits
155
+ # Gather width = max logits rows of any request (bounds num_sampled).
156
+ assert metadata0.max_num_logits_per_req == int(np.diff(cu).max())
157
+
158
+ all_owned: list[int] = []
159
+ for rank, (local, _, metadata) in enumerate(results):
160
+ owned = _owned_batch_indices(local)
161
+ assert local.num_reqs == len(owned) == metadata.num_local_reqs
162
+ assert (batch.idx_mapping_np[owned] % tp_size == rank).all()
163
+ # Stable sort: batch order is preserved within an owner.
164
+ assert (np.diff(owned) > 0).all()
165
+ all_owned.extend(owned.tolist())
166
+
167
+ rows = _owned_rows(cu, owned)
168
+ assert (
169
+ metadata.num_local_logits
170
+ == len(rows)
171
+ == metadata0.num_logits_per_rank[rank]
172
+ )
173
+
174
+ expected_idx = batch.idx_mapping_np[owned]
175
+ assert (local.idx_mapping_np == expected_idx).all()
176
+ assert _np(local.idx_mapping).tolist() == expected_idx.tolist()
177
+ assert (np.diff(local.cu_num_logits_np) == np.diff(cu)[owned]).all()
178
+ assert _np(local.cu_num_logits).tolist() == local.cu_num_logits_np.tolist()
179
+ assert (
180
+ _np(local.expanded_idx_mapping) == _np(batch.expanded_idx_mapping)[rows]
181
+ ).all()
182
+ assert (
183
+ _np(local.expanded_local_pos) == _np(batch.expanded_local_pos)[rows]
184
+ ).all()
185
+ assert (_np(local.logits_indices) == _np(batch.logits_indices)[rows]).all()
186
+ assert (_np(local.seq_lens) == _np(batch.seq_lens)[owned]).all()
187
+ assert batch.num_draft_tokens_per_req is not None
188
+ assert local.num_draft_tokens_per_req is not None
189
+ assert (
190
+ local.num_draft_tokens_per_req == batch.num_draft_tokens_per_req[owned]
191
+ ).all()
192
+ assert local.num_draft_tokens == int(
193
+ batch.num_draft_tokens_per_req[owned].sum()
194
+ )
195
+ # Triton specializes kernels on pointer alignment: every packed-buffer
196
+ # view fed to kernels must stay 16-byte aligned across batch shapes,
197
+ # or serving JITs new kernel variants mid-collective.
198
+ for t in (
199
+ local.idx_mapping,
200
+ local.expanded_idx_mapping,
201
+ local.expanded_local_pos,
202
+ local.logits_indices,
203
+ local.cu_num_logits,
204
+ metadata.gathered_src_indices,
205
+ ):
206
+ assert t.data_ptr() % 16 == 0
207
+ assert sorted(all_owned) == list(range(batch.num_reqs))
208
+
209
+ # sorted_logits_indices is identical on all ranks: the owner-sorted logits row
210
+ # positions, i.e. logits_indices permuted rank-major.
211
+ sorted_logits_indices = _np(results[0][1])
212
+ for _, gi, _ in results:
213
+ assert (_np(gi) == sorted_logits_indices).all()
214
+ rows_all = np.concatenate(
215
+ [_owned_rows(cu, _owned_batch_indices(local)) for local, _, _ in results]
216
+ )
217
+ assert (
218
+ sorted_logits_indices.tolist() == _np(batch.logits_indices)[rows_all].tolist()
219
+ )
220
+
221
+
222
+@requires_cuda
223
+@pytest.mark.parametrize("tp_size", [2, 4])
224
+def test_all_to_all_reassembly_simulated(tp_size: int):
225
+ """Vocab shards computed from owner-sorted hidden states and routed
226
+ through the simulated all-to-all reassemble into exactly the full-vocab
227
+ logits of each rank's owned rows."""
228
+ rng = np.random.default_rng(7)
229
+ max_num_reqs = 32
230
+ batch = _make_batch(rng, num_reqs=11, max_num_reqs=max_num_reqs, max_spec=4)
231
+ vocab = 64 * tp_size
232
+ shard_w = vocab // tp_size
233
+ token_logits = torch.randn(batch.num_tokens, vocab, device=DEVICE)
234
+
235
+ results = _shard_all_ranks(batch, max_num_reqs, tp_size)
236
+ sorted_logits_indices = results[0][1]
237
+ num_logits_per_rank = results[0][2].num_logits_per_rank
238
+ bounds = np.zeros(tp_size + 1, dtype=np.int64)
239
+ np.cumsum(num_logits_per_rank, out=bounds[1:])
240
+
241
+ # send_r = rank r's vocab shard of the owner-sorted rows, with no further
242
+ # permute; chunk d of send_r goes to rank d.
243
+ sends = [
244
+ token_logits[sorted_logits_indices][:, r * shard_w : (r + 1) * shard_w]
245
+ for r in range(tp_size)
246
+ ]
247
+ for rank, (local, _, metadata) in enumerate(results):
248
+ recv = torch.cat(
249
+ [sends[src][bounds[rank] : bounds[rank + 1]] for src in range(tp_size)]
250
+ )
251
+ # Reassemble source-rank-major chunks into full-vocab rows, mirroring
252
+ # all_to_all_logits (source-rank order is vocab-shard order).
253
+ reassembled = (
254
+ recv.view(tp_size, metadata.num_local_logits, shard_w)
255
+ .permute(1, 0, 2)
256
+ .reshape(metadata.num_local_logits, vocab)
257
+ )
258
+ expected = token_logits[local.logits_indices]
259
+ assert torch.equal(reassembled, expected)
260
+
261
+
262
+@requires_cuda
263
+def test_shard_no_spec():
264
+ """Batches without draft tokens (one logits row per request) degenerate
265
+ cleanly: rows mirror requests."""
266
+ rng = np.random.default_rng(11)
267
+ tp_size = 4
268
+ max_num_reqs = 32
269
+ batch = _make_batch(rng, num_reqs=10, max_num_reqs=max_num_reqs, max_spec=0)
270
+ results = _shard_all_ranks(batch, max_num_reqs, tp_size)
271
+
272
+ assert results[0][2].max_num_logits_per_req == 1
273
+ for local, _, metadata in results:
274
+ assert metadata.num_local_logits == metadata.num_local_reqs == local.num_reqs
275
+ owned = _owned_batch_indices(local)
276
+ assert _np(local.logits_indices).tolist() == (
277
+ _np(batch.logits_indices)[owned].tolist()
278
+ )
279
+
280
+
281
+@pytest.mark.parametrize("tp_size", [2, 4])
282
+def test_shard_grammar_output(tp_size: int):
283
+ """The grammar filter keeps exactly the owned requests' bitmask rows, in
284
+ grammar order, and the per-rank pieces partition the global bitmask."""
285
+ rng = np.random.default_rng(5)
286
+ max_num_reqs = 32
287
+ num_reqs = 12
288
+ slots = rng.choice(np.arange(max_num_reqs), size=num_reqs, replace=False)
289
+ k = rng.integers(0, 4, size=num_reqs)
290
+ cu = np.zeros(num_reqs + 1, dtype=np.int32)
291
+ np.cumsum(1 + k, out=cu[1:])
292
+ owner = slots % tp_size
293
+
294
+ # Grammar requests: every other request, in a scrambled order.
295
+ req_ids = [f"req-{i}" for i in range(num_reqs)]
296
+ grammar_idx = list(rng.permutation(np.arange(0, num_reqs, 2)))
297
+ grammar_req_ids = [req_ids[i] for i in grammar_idx]
298
+ num_grammar_rows = sum(int(cu[i + 1] - cu[i]) for i in grammar_idx)
299
+ bitmask = rng.integers(0, 2**31, size=(num_grammar_rows, 4), dtype=np.int32)
300
+ input_batch = SimpleNamespace(req_ids=req_ids, cu_num_logits_np=cu)
301
+
302
+ # Global row offset of each grammar request within the bitmask.
303
+ row_offsets = {}
304
+ cursor = 0
305
+ for i in grammar_idx:
306
+ row_offsets[i] = cursor
307
+ cursor += int(cu[i + 1] - cu[i])
308
+
309
+ grammar_output = GrammarOutput(
310
+ structured_output_request_ids=grammar_req_ids,
311
+ grammar_bitmask=bitmask,
312
+ )
313
+
314
+ kept_total = 0
315
+ for rank in range(tp_size):
316
+ local_req_ids = [req_ids[i] for i in range(num_reqs) if owner[i] == rank]
317
+ local = _shard_grammar_output(grammar_output, input_batch, local_req_ids)
318
+ expected_idx = [i for i in grammar_idx if owner[i] == rank]
319
+ if not expected_idx:
320
+ assert local is None
321
+ continue
322
+ assert local is not None
323
+ assert local.structured_output_request_ids == [req_ids[i] for i in expected_idx]
324
+ expected_rows = np.concatenate(
325
+ [
326
+ np.arange(row_offsets[i], row_offsets[i] + cu[i + 1] - cu[i])
327
+ for i in expected_idx
328
+ ]
329
+ )
330
+ assert (local.grammar_bitmask == bitmask[expected_rows.astype(int)]).all()
331
+ kept_total += local.grammar_bitmask.shape[0]
332
+ assert kept_total == num_grammar_rows
333
+
334
+
335
+@requires_cuda
336
+@pytest.mark.parametrize("tp_size", [2, 4])
337
+def test_gather_src_indices_round_trip(tp_size: int):
338
+ """Padded per-rank result blocks indexed by gathered_src_indices restore
339
+ batch order, including when some rank owns zero requests."""
340
+ rng = np.random.default_rng(3)
341
+ max_num_reqs = 40
342
+ all_slots = np.arange(max_num_reqs)
343
+ batch = _make_batch(
344
+ rng,
345
+ num_reqs=17,
346
+ max_num_reqs=max_num_reqs,
347
+ max_spec=2,
348
+ slot_pool=all_slots[all_slots % tp_size != 0], # rank 0 owns nothing
349
+ )
350
+ results = _shard_all_ranks(batch, max_num_reqs, tp_size)
351
+ metadata0 = results[0][2]
352
+ assert metadata0.num_local_reqs == 0
353
+
354
+ # Value of request j is j; each rank packs its owned requests' values into
355
+ # a zero-padded (max_num_reqs_per_rank, 1) block, gathered rank-major.
356
+ pad = metadata0.max_num_reqs_per_rank
357
+ blocks = []
358
+ for local, _, _ in results:
359
+ owned = _owned_batch_indices(local)
360
+ block = torch.zeros(pad, 1, dtype=torch.int64, device=DEVICE)
361
+ block[: len(owned), 0] = torch.from_numpy(owned).to(DEVICE)
362
+ blocks.append(block)
363
+ gathered = torch.cat(blocks)
364
+
365
+ restored = gathered[metadata0.gathered_src_indices][:, 0]
366
+ assert restored.tolist() == list(range(batch.num_reqs))
367
+
368
+
369
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
370
+@pytest.mark.parametrize("tp_size", [2, 4])
371
+def test_gather_sampler_output_kernels(tp_size: int):
372
+ """The fused pack/unpack kernels round-trip per-rank sampler results
373
+ into batch order: token ids zero-filled past the local width, counts
374
+ cast back to int32, identical on every rank."""
375
+ from dataclasses import replace as dc_replace
376
+
377
+ from vllm.v1.worker.gpu.sample.output import SamplerOutput
378
+
379
+ rng = np.random.default_rng(9)
380
+ max_num_reqs = 32
381
+ batch = _make_batch(rng, num_reqs=13, max_num_reqs=max_num_reqs, max_spec=3)
382
+ results = _shard_all_ranks(batch, max_num_reqs, tp_size)
383
+ device = torch.device("cuda")
384
+ num_reqs = batch.num_reqs
385
+ width = results[0][2].max_num_logits_per_req
386
+
387
+ # Distinctive per-rank local outputs. Even ranks mimic the regular
388
+ # sampler (one column, exercising zero-fill); odd ranks mimic the
389
+ # rejection sampler's over-allocation (exercising truncation).
390
+ local_outputs: list[SamplerOutput | None] = []
391
+ expected_ids = torch.zeros(num_reqs, width, dtype=torch.int64)
392
+ expected_sampled = torch.zeros(num_reqs, dtype=torch.int32)
393
+ expected_rejected = torch.zeros(num_reqs, dtype=torch.int32)
394
+ for rank, (local, _, metadata) in enumerate(results):
395
+ if metadata.num_local_reqs == 0:
396
+ local_outputs.append(None)
397
+ continue
398
+ owned = torch.from_numpy(_owned_batch_indices(local))
399
+ src_width = 1 if rank % 2 == 0 else width + 3
400
+ ids = owned[:, None] * 1000 + torch.arange(src_width)[None, :]
401
+ num_sampled = (owned % width + 1).to(torch.int32)
402
+ num_rejected = (owned % 3).to(torch.int32)
403
+ local_outputs.append(
404
+ SamplerOutput(
405
+ sampled_token_ids=ids.to(device),
406
+ logprobs_tensors=None,
407
+ num_nans=None,
408
+ num_sampled=num_sampled.to(device),
409
+ num_rejected=num_rejected.to(device),
410
+ )
411
+ )
412
+ copy_width = min(src_width, width)
413
+ expected_ids[owned, :copy_width] = ids[:, :copy_width]
414
+ expected_sampled[owned] = num_sampled
415
+ expected_rejected[owned] = num_rejected
416
+
417
+ # Two passes over a mocked all-gather: capture each rank's packed send
418
+ # block, then replay with the concatenated blocks as the gathered result.
419
+ packed_blocks: dict[int, torch.Tensor] = {}
420
+ gathered_full: list[torch.Tensor] = []
421
+
422
+ def run(rank: int) -> SamplerOutput:
423
+ metadata = dc_replace(
424
+ results[rank][2],
425
+ gathered_src_indices=results[rank][2].gathered_src_indices.to(device),
426
+ )
427
+
428
+ def fake_all_gather(x: torch.Tensor, dim: int = 0) -> torch.Tensor:
429
+ packed_blocks[rank] = x.clone()
430
+ if gathered_full:
431
+ return gathered_full[0]
432
+ return torch.zeros(
433
+ tp_size * x.shape[0], x.shape[1], dtype=x.dtype, device=x.device
434
+ )
435
+
436
+ with mock.patch.object(
437
+ batch_shard, "tensor_model_parallel_all_gather", fake_all_gather
438
+ ):
439
+ return batch_shard.gather_sampler_output(
440
+ local_outputs[rank],
441
+ metadata,
442
+ device,
443
+ global_batch=batch,
444
+ local_batch=results[rank][0],
445
+ )
446
+
447
+ for rank in range(tp_size):
448
+ run(rank)
449
+ # Zero the uninitialized padding rows so the comparison is deterministic;
450
+ # the real path never reads them (gathered_src_indices skips padding).
451
+ for rank, (local, _, metadata) in enumerate(results):
452
+ packed_blocks[rank][metadata.num_local_reqs :] = 0
453
+ gathered_full.append(torch.cat([packed_blocks[r] for r in range(tp_size)]))
454
+
455
+ for rank in range(tp_size):
456
+ out = run(rank)
457
+ assert out.sampled_token_ids.dtype == torch.int64
458
+ assert out.num_sampled is not None and out.num_sampled.dtype == torch.int32
459
+ assert out.num_rejected is not None
460
+ assert torch.equal(out.sampled_token_ids.cpu(), expected_ids)
461
+ assert torch.equal(out.num_sampled.cpu(), expected_sampled)
462
+ assert torch.equal(out.num_rejected.cpu(), expected_rejected.to(torch.int32))
463
+
464
+
465
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
466
+def test_gather_sampler_output_logprobs_and_nans():
467
+ """num_nans is reduced per request and gathered; LogprobsTensors are
468
+ packed into fixed per-request blocks, width-aligned across ranks (pad
469
+ and truncate paths), and restored to global row order."""
470
+ from dataclasses import replace as dc_replace
471
+
472
+ from vllm.v1.outputs import LogprobsTensors
473
+ from vllm.v1.worker.gpu.sample.output import SamplerOutput
474
+
475
+ tp_size = 2
476
+ rng = np.random.default_rng(21)
477
+ max_num_reqs = 32
478
+ batch = _make_batch(rng, num_reqs=6, max_num_reqs=max_num_reqs, max_spec=3)
479
+ results = _shard_all_ranks(batch, max_num_reqs, tp_size)
480
+ device = torch.device("cuda")
481
+ num_reqs = batch.num_reqs
482
+ cu = batch.cu_num_logits_np
483
+ num_logits = int(cu[-1])
484
+ width = results[0][2].max_num_logits_per_req
485
+ num_cols = 3
486
+
487
+ def cuda_fields(b: InputBatch) -> InputBatch:
488
+ return dc_replace(
489
+ b,
490
+ cu_num_logits=b.cu_num_logits.to(device),
491
+ expanded_local_pos=b.expanded_local_pos.to(device),
492
+ )
493
+
494
+ global_cuda = cuda_fields(batch)
495
+
496
+ local_outputs: list[SamplerOutput | None] = []
497
+ local_batches: list[InputBatch] = []
498
+ expected_ids = torch.zeros(num_logits, num_cols, dtype=torch.int64)
499
+ expected_logprobs = torch.full(
500
+ (num_logits, num_cols), float("-inf"), dtype=torch.float32
501
+ )
502
+ expected_ranks = torch.zeros(num_logits, dtype=torch.int64)
503
+ expected_nans = torch.zeros(num_reqs, dtype=torch.int32)
504
+ for rank, (local, _, metadata) in enumerate(results):
505
+ local_batches.append(cuda_fields(local))
506
+ if metadata.num_local_reqs == 0:
507
+ local_outputs.append(None)
508
+ continue
509
+ owned = _owned_batch_indices(local)
510
+ rows = torch.from_numpy(_owned_rows(cu, owned))
511
+ # Rank 0 sends fewer columns (pad path), rank 1 more (truncate path).
512
+ local_cols = num_cols - 1 if rank == 0 else num_cols + 2
513
+ ids = rows[:, None] * 10 + torch.arange(local_cols)[None, :]
514
+ logprobs = ids.float() * 0.5
515
+ ranks_t = rows + 7
516
+ nans = (rows % 4).to(torch.int32)
517
+ local_outputs.append(
518
+ SamplerOutput(
519
+ sampled_token_ids=torch.zeros(
520
+ metadata.num_local_reqs, width, dtype=torch.int64, device=device
521
+ ),
522
+ logprobs_tensors=LogprobsTensors(
523
+ logprob_token_ids=ids.to(device),
524
+ logprobs=logprobs.to(device),
525
+ selected_token_ranks=ranks_t.to(device),
526
+ cu_num_generated_tokens=None,
527
+ ),
528
+ num_nans=nans.to(device),
529
+ num_sampled=torch.ones(
530
+ metadata.num_local_reqs, dtype=torch.int32, device=device
531
+ ),
532
+ num_rejected=torch.zeros(
533
+ metadata.num_local_reqs, dtype=torch.int32, device=device
534
+ ),
535
+ )
536
+ )
537
+ copy_cols = min(local_cols, num_cols)
538
+ expected_ids[rows, :copy_cols] = ids[:, :copy_cols]
539
+ expected_logprobs[rows, :copy_cols] = logprobs[:, :copy_cols]
540
+ expected_ranks[rows] = ranks_t
541
+ for j in owned:
542
+ in_req = (rows >= int(cu[j])) & (rows < int(cu[j + 1]))
543
+ expected_nans[j] = int(nans[in_req].sum())
544
+
545
+ recorded: dict[int, list[torch.Tensor]] = {}
546
+ gathered_full: list[torch.Tensor] = []
547
+
548
+ def run(rank: int) -> SamplerOutput:
549
+ metadata = dc_replace(
550
+ results[rank][2],
551
+ gathered_src_indices=results[rank][2].gathered_src_indices.to(device),
552
+ )
553
+ calls = {"i": 0}
554
+
555
+ def fake_all_gather(x: torch.Tensor, dim: int = 0) -> torch.Tensor:
556
+ i = calls["i"]
557
+ calls["i"] += 1
558
+ if gathered_full:
559
+ return gathered_full[i]
560
+ recorded.setdefault(rank, []).append(x.clone())
561
+ return torch.zeros(
562
+ tp_size * x.shape[0], *x.shape[1:], dtype=x.dtype, device=x.device
563
+ )
564
+
565
+ with mock.patch.object(
566
+ batch_shard, "tensor_model_parallel_all_gather", fake_all_gather
567
+ ):
568
+ return batch_shard.gather_sampler_output(
569
+ local_outputs[rank],
570
+ metadata,
571
+ device,
572
+ global_batch=global_cuda,
573
+ local_batch=local_batches[rank],
574
+ gather_num_nans=True,
575
+ logprobs_dims=(num_cols - 1, 0),
576
+ )
577
+
578
+ for rank in range(tp_size):
579
+ run(rank)
580
+ gathered_full.extend(
581
+ torch.cat([recorded[r][i] for r in range(tp_size)]) for i in range(3)
582
+ )
583
+
584
+ for rank in range(tp_size):
585
+ out = run(rank)
586
+ assert out.num_nans is not None
587
+ assert torch.equal(out.num_nans.cpu(), expected_nans)
588
+ lp = out.logprobs_tensors
589
+ assert lp is not None
590
+ assert torch.equal(lp.logprob_token_ids.cpu(), expected_ids)
591
+ assert torch.equal(lp.logprobs.cpu(), expected_logprobs)
592
+ assert torch.equal(lp.selected_token_ranks.cpu(), expected_ranks)
593
+ if num_logits != num_reqs:
594
+ assert lp.cu_num_generated_tokens == cu.tolist()
595
+ else:
596
+ assert lp.cu_num_generated_tokens is None
597
+
598
+
599
+def test_finish_requests_frees_slots_in_sorted_order():
600
+ """Request slots must be freed in the same order on every TP rank:
601
+ ownership derives from slot indices, and `finished_req_ids` is a set
602
+ whose iteration order is per-process hash-randomized."""
603
+ from vllm.v1.worker.gpu.model_runner import GPUModelRunner
604
+
605
+ removed: list[str] = []
606
+ fake_runner = SimpleNamespace(_remove_request=removed.append, pooling_runner=None)
607
+ scheduler_output = SimpleNamespace(
608
+ finished_req_ids={"req-b", "req-c", "req-a"},
609
+ preempted_req_ids={"req-e", "req-d"},
610
+ )
611
+ GPUModelRunner.finish_requests(fake_runner, scheduler_output)
612
+ assert removed == sorted(removed)
613
+ assert set(removed) == {"req-a", "req-b", "req-c", "req-d", "req-e"}
tests/v1/worker/test_gpu_model_runner_v2_eplb.py
+1
@@ -60,6 +60,7 @@ def _make_runner(**overrides: Any) -> Any:
60
runner.parallel_config = SimpleNamespace(
61
enable_eplb=True,
62
enable_elastic_ep=False,
63
+ enable_batch_sharded_sampling=False,
64
eplb_config=SimpleNamespace(log_balancedness=True),
65
)
66
runner.vllm_config = SimpleNamespace(
vllm/config/parallel.py
+7
@@ -164,6 +164,13 @@ class ParallelConfig:
164
"""Whether the deployed model is MoE (if known)."""
165
enable_expert_parallel: bool = False
166
"""Use expert parallelism instead of tensor parallelism for MoE layers."""
167
+ enable_batch_sharded_sampling: bool | None = None
168
+ """Use sharded sampling across tensor parallel ranks. Each rank samples
169
+ a slice of the batch instead of every rank sampling all of it. Currently
170
+ defaults to False if not set. Enabling it explicitly raises when the config
171
+ cannot support it (`tensor_parallel_size` must be > 1, `max_num_seqs` at
172
+ least `tensor_parallel_size`, and `max_logprobs` non-negative). Models opt in
173
+ by implementing `compute_logits_local`."""
174
enable_ep_weight_filter: bool = False
175
"""Skip non-local expert weights during model loading when expert
176
parallelism is active. Each rank only reads its own expert shard from
vllm/config/vllm.py
+53
@@ -1675,6 +1675,8 @@ class VllmConfig:
1675
"Remove VLLM_USE_V2_MODEL_RUNNER=0."
1676
)
1677
1678
+ self._validate_batch_sharded_sampling()
1679
+
1680
# Re-compute compile ranges after platform-specific config updates
1681
# (e.g., XPU may lower max_num_batched_tokens when MLA is enabled)
1682
self._set_compile_ranges()
@@ -2552,6 +2554,57 @@ class VllmConfig:
2554
2555
return unsupported
2556
2557
+ def _validate_batch_sharded_sampling(self) -> None:
2558
+ """Validate `enable_batch_sharded_sampling` against the rest of the config."""
2559
+ if not self.parallel_config.enable_batch_sharded_sampling:
2560
+ # Default to False if not set.
2561
+ self.parallel_config.enable_batch_sharded_sampling = False
2562
+ return
2563
+
2564
+ blockers: list[str] = []
2565
+ tp_size = self.parallel_config.tensor_parallel_size
2566
+
2567
+ if not self.use_v2_model_runner:
2568
+ blockers.append("it is only implemented by Model Runner V2")
2569
+
2570
+ if tp_size <= 1:
2571
+ blockers.append("tensor_parallel_size is 1, so there is nothing to shard")
2572
+ elif self.scheduler_config.max_num_seqs < tp_size:
2573
+ # Requests are assigned to ranks whole, so fewer slots than ranks
2574
+ # leaves some ranks without work in every step.
2575
+ blockers.append(
2576
+ f"max_num_seqs ({self.scheduler_config.max_num_seqs}) is below "
2577
+ f"tensor_parallel_size ({tp_size})"
2578
+ )
2579
+
2580
+ if self.model_config is not None and self.model_config.max_logprobs < 0:
2581
+ # max_logprobs == -1 allows vocab-size logprob requests, which the
2582
+ # fixed-width logprobs gather cannot reasonably size for.
2583
+ blockers.append("max_logprobs is -1, allowing vocab-size logprob requests")
2584
+
2585
+ if (
2586
+ self.speculative_config is not None
2587
+ and self.speculative_config.enable_adaptive_verification
2588
+ ):
2589
+ # Adaptive verification picks the per-request draft split on the GPU,
2590
+ # so cu_num_logits_np is only an upper bound, while the shard plan is
2591
+ # built from that CPU array. The two disagree once the budget binds.
2592
+ # TODO(TheEpicDolphin): Support adaptive verification with batch-sharded
2593
+ # sampling.
2594
+ blockers.append(
2595
+ "it does not yet work with adaptive verification, which decides "
2596
+ "the per-request logits counts on the GPU, where the CPU-side "
2597
+ "shard plan cannot see them"
2598
+ )
2599
+
2600
+ if blockers:
2601
+ raise ValueError(
2602
+ "Batch-sharded sampling was explicitly enabled via "
2603
+ "the --enable-batch-sharded-sampling flag, but is not supported "
2604
+ "in this configuration for the following reason(s): "
2605
+ f"{'; '.join(blockers)}."
2606
+ )
2607
+
2608
def _validate_v2_model_runner(self) -> None:
2609
"""Check for features not yet supported by the V2 model runner."""
2610
if not HAS_TRITON:
vllm/engine/arg_utils.py
+8
@@ -496,6 +496,9 @@ class EngineArgs:
496
data_parallel_multi_port_external_lb: bool = False
497
data_parallel_backend: DataParallelBackend = ParallelConfig.data_parallel_backend
498
enable_expert_parallel: bool = ParallelConfig.enable_expert_parallel
499
+ enable_batch_sharded_sampling: bool | None = (
500
+ ParallelConfig.enable_batch_sharded_sampling
501
+ )
502
enable_ep_weight_filter: bool = ParallelConfig.enable_ep_weight_filter
503
moe_backend: MoEBackend = KernelConfig.moe_backend
504
linear_backend: LinearBackend = KernelConfig.linear_backend
@@ -1156,6 +1159,10 @@ class EngineArgs:
1159
"-ep",
1160
**parallel_kwargs["enable_expert_parallel"],
1161
)
1162
+ parallel_group.add_argument(
1163
+ "--enable-batch-sharded-sampling",
1164
+ **parallel_kwargs["enable_batch_sharded_sampling"],
1165
+ )
1166
parallel_group.add_argument(
1167
"--enable-ep-weight-filter",
1168
**parallel_kwargs["enable_ep_weight_filter"],
@@ -2279,6 +2286,7 @@ class EngineArgs:
2286
data_parallel_hybrid_lb=self.data_parallel_hybrid_lb,
2287
is_moe_model=model_config.is_moe,
2288
enable_expert_parallel=self.enable_expert_parallel,
2289
+ enable_batch_sharded_sampling=self.enable_batch_sharded_sampling,
2290
enable_ep_weight_filter=self.enable_ep_weight_filter,
2291
all2all_backend=self.all2all_backend,
2292
enable_elastic_ep=self.enable_elastic_ep,
vllm/lora/layers/logits_processor.py
+7
@@ -155,7 +155,14 @@ class LogitsProcessorWithLoRA(BaseLayerWithLoRA):
155
hidden_states: torch.Tensor,
156
lm_head: VocabParallelEmbedding,
157
embedding_bias: torch.Tensor | None = None,
158
+ skip_gather: bool = False,
159
) -> torch.Tensor | None:
160
+ # The LoRA delta is accumulated into the full gathered logits, so the
161
+ # TP gather cannot be skipped here.
162
+ if skip_gather:
163
+ raise NotImplementedError(
164
+ "Skipping the logits TP gather is not supported with an lm_head LoRA."
165
+ )
166
# Get the logits for the next tokens.
167
if hasattr(lm_head, "base_layer"):
168
actual_lm_head = lm_head.base_layer
vllm/model_executor/layers/logits_processor.py
+7
-1
@@ -99,12 +99,15 @@ class LogitsProcessor(PluggableLayer):
99
lm_head: VocabParallelEmbedding,
100
hidden_states: torch.Tensor,
101
embedding_bias: torch.Tensor | None = None,
102
+ skip_gather: bool = False,
103
) -> torch.Tensor | None:
104
if self.logits_as_input:
105
logits = hidden_states
106
else:
107
# Get the logits for the next tokens.
107
- logits = self._get_logits(hidden_states, lm_head, embedding_bias)
108
+ logits = self._get_logits(
109
+ hidden_states, lm_head, embedding_bias, skip_gather
110
+ )
111
if logits is not None:
112
if self.soft_cap is not None:
113
logits = logits / self.soft_cap
@@ -173,9 +176,12 @@ class LogitsProcessor(PluggableLayer):
176
hidden_states: torch.Tensor,
177
lm_head: VocabParallelEmbedding,
178
embedding_bias: torch.Tensor | None,
179
+ skip_gather: bool = False,
180
) -> torch.Tensor | None:
181
# Get the logits for the next tokens.
182
logits = self._apply_head(lm_head, hidden_states, embedding_bias)
183
+ if skip_gather:
184
+ return logits
185
186
# Gather logits for TP
187
if lm_head.tp_size > 1:
vllm/model_executor/models/llama.py
+6
@@ -532,6 +532,12 @@ class LlamaForCausalLM(
532
logits = self.logits_processor(self.lm_head, hidden_states)
533
return logits
534
535
+ def compute_logits_local(
536
+ self,
537
+ hidden_states: torch.Tensor,
538
+ ) -> torch.Tensor:
539
+ return self.logits_processor(self.lm_head, hidden_states, skip_gather=True)
540
+
541
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
542
loader = AutoWeightsLoader(self)
543
return loader.load_weights(weights)
vllm/model_executor/models/qwen3_5.py
+9
@@ -429,6 +429,12 @@ class Qwen3_5ForCausalLMBase(
429
) -> torch.Tensor | None:
430
return self.logits_processor(self.lm_head, hidden_states)
431
432
+ def compute_logits_local(
433
+ self,
434
+ hidden_states: torch.Tensor,
435
+ ) -> torch.Tensor:
436
+ return self.logits_processor(self.lm_head, hidden_states, skip_gather=True)
437
+
438
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
439
loader = AutoWeightsLoader(self)
440
return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
@@ -591,6 +597,9 @@ class Qwen3_5ForConditionalGeneration(Qwen3VLForConditionalGeneration, IsHybrid)
597
598
return hidden_states
599
600
+ def compute_logits_local(self, hidden_states: torch.Tensor) -> torch.Tensor:
601
+ return self.language_model.compute_logits_local(hidden_states)
602
+
603
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
604
loader = AutoWeightsLoader(self)
605
return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
vllm/models/deepseek_v4/nvidia/model.py
+6
@@ -1765,6 +1765,12 @@ class DeepseekV4ForCausalLM(
1765
logits = self.logits_processor(self.lm_head, hidden_states)
1766
return logits
1767
1768
+ def compute_logits_local(
1769
+ self,
1770
+ hidden_states: torch.Tensor,
1771
+ ) -> torch.Tensor:
1772
+ return self.logits_processor(self.lm_head, hidden_states, skip_gather=True)
1773
+
1774
def forward(
1775
self,
1776
input_ids: torch.Tensor,
vllm/models/inkling/amd/logits_processor.py
+11
-4
@@ -63,6 +63,7 @@ class InklingLogitsProcessor(LogitsProcessor):
63
lm_head: VocabParallelEmbedding,
64
hidden_states: torch.Tensor,
65
embedding_bias: torch.Tensor | None = None,
66
+ skip_gather: bool = False,
67
) -> torch.Tensor | None:
68
# ``base_layer`` exists only on the LogitsProcessorWithLoRA wrapper,
69
# which calls this forward with ``self`` bound to the wrapper. The
@@ -72,15 +73,16 @@ class InklingLogitsProcessor(LogitsProcessor):
73
# lives on this class).
74
if hasattr(self, "base_layer"):
75
return type(self.base_layer)._lora_forward(
75
- self, lm_head, hidden_states, embedding_bias
76
+ self, lm_head, hidden_states, embedding_bias, skip_gather
77
)
77
- return self._base_forward(lm_head, hidden_states, embedding_bias)
78
+ return self._base_forward(lm_head, hidden_states, embedding_bias, skip_gather)
79
80
def _lora_forward(
81
self,
82
lm_head: VocabParallelEmbedding,
83
hidden_states: torch.Tensor,
84
embedding_bias: torch.Tensor | None = None,
85
+ skip_gather: bool = False,
86
) -> torch.Tensor | None:
87
# ``self`` is the LogitsProcessorWithLoRA wrapper here: ``_get_logits``
88
# returns the base logits plus the lm_head LoRA delta. Apply the muP
@@ -90,7 +92,9 @@ class InklingLogitsProcessor(LogitsProcessor):
92
if self.logits_as_input:
93
logits = hidden_states
94
else:
93
- logits = self._get_logits(hidden_states, lm_head, embedding_bias)
95
+ logits = self._get_logits(
96
+ hidden_states, lm_head, embedding_bias, skip_gather
97
+ )
98
# TODO: fuse this multiplication
99
if logits is not None and mup:
100
assert self.base_layer.soft_cap is None
@@ -103,10 +107,11 @@ class InklingLogitsProcessor(LogitsProcessor):
107
lm_head: VocabParallelEmbedding,
108
hidden_states: torch.Tensor,
109
embedding_bias: torch.Tensor | None = None,
110
+ skip_gather: bool = False,
111
) -> torch.Tensor | None:
112
mup = self.logits_mup_width_multiplier
113
if not mup:
109
- return super().forward(lm_head, hidden_states, embedding_bias)
114
+ return super().forward(lm_head, hidden_states, embedding_bias, skip_gather)
115
# Fold the muP width divisor into the lm_head GEMM alpha (fp32 epilogue):
116
# no separate elementwise kernel, no bf16 rounding of scaled logits, and
117
# no weight mutation. Overfit to the served checkpoint: bf16 lm_head, no
@@ -123,6 +128,8 @@ class InklingLogitsProcessor(LogitsProcessor):
128
beta=0.0,
129
alpha=1.0 / mup,
130
)
131
+ if skip_gather:
132
+ return logits
133
logits = self._gather_logits(logits)
134
if logits is not None:
135
logits = logits[..., : self.org_vocab_size]
vllm/models/inkling/nvidia/logits_processor.py
+11
-4
@@ -63,6 +63,7 @@ class InklingLogitsProcessor(LogitsProcessor):
63
lm_head: VocabParallelEmbedding,
64
hidden_states: torch.Tensor,
65
embedding_bias: torch.Tensor | None = None,
66
+ skip_gather: bool = False,
67
) -> torch.Tensor | None:
68
# ``base_layer`` exists only on the LogitsProcessorWithLoRA wrapper,
69
# which calls this forward with ``self`` bound to the wrapper. The
@@ -72,15 +73,16 @@ class InklingLogitsProcessor(LogitsProcessor):
73
# lives on this class).
74
if hasattr(self, "base_layer"):
75
return type(self.base_layer)._lora_forward(
75
- self, lm_head, hidden_states, embedding_bias
76
+ self, lm_head, hidden_states, embedding_bias, skip_gather
77
)
77
- return self._base_forward(lm_head, hidden_states, embedding_bias)
78
+ return self._base_forward(lm_head, hidden_states, embedding_bias, skip_gather)
79
80
def _lora_forward(
81
self,
82
lm_head: VocabParallelEmbedding,
83
hidden_states: torch.Tensor,
84
embedding_bias: torch.Tensor | None = None,
85
+ skip_gather: bool = False,
86
) -> torch.Tensor | None:
87
# ``self`` is the LogitsProcessorWithLoRA wrapper here: ``_get_logits``
88
# returns the base logits plus the lm_head LoRA delta. Apply the muP
@@ -90,7 +92,9 @@ class InklingLogitsProcessor(LogitsProcessor):
92
if self.logits_as_input:
93
logits = hidden_states
94
else:
93
- logits = self._get_logits(hidden_states, lm_head, embedding_bias)
95
+ logits = self._get_logits(
96
+ hidden_states, lm_head, embedding_bias, skip_gather
97
+ )
98
# TODO: fuse this multiplication
99
if logits is not None and mup:
100
assert self.base_layer.soft_cap is None
@@ -103,10 +107,11 @@ class InklingLogitsProcessor(LogitsProcessor):
107
lm_head: VocabParallelEmbedding,
108
hidden_states: torch.Tensor,
109
embedding_bias: torch.Tensor | None = None,
110
+ skip_gather: bool = False,
111
) -> torch.Tensor | None:
112
mup = self.logits_mup_width_multiplier
113
if not mup:
109
- return super().forward(lm_head, hidden_states, embedding_bias)
114
+ return super().forward(lm_head, hidden_states, embedding_bias, skip_gather)
115
# Fold the muP width divisor into the lm_head GEMM alpha (fp32 epilogue):
116
# no separate elementwise kernel, no bf16 rounding of scaled logits, and
117
# no weight mutation. Overfit to the served checkpoint: bf16 lm_head, no
@@ -123,6 +128,8 @@ class InklingLogitsProcessor(LogitsProcessor):
128
beta=0.0,
129
alpha=1.0 / mup,
130
)
131
+ if skip_gather:
132
+ return logits
133
logits = self._gather_logits(logits)
134
if logits is not None:
135
logits = logits[..., : self.org_vocab_size]
vllm/models/minimax_m3/nvidia/model.py
+6
@@ -1059,6 +1059,9 @@ class MiniMaxM3SparseForCausalLM(nn.Module, SupportsPP, SupportsEagle3):
1059
def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
1060
return self.logits_processor(self.lm_head, hidden_states)
1061
1062
+ def compute_logits_local(self, hidden_states: torch.Tensor) -> torch.Tensor:
1063
+ return self.logits_processor(self.lm_head, hidden_states, skip_gather=True)
1064
+
1065
def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
1066
return self.model.get_expert_mapping()
1067
@@ -1272,6 +1275,9 @@ class MiniMaxM3SparseForConditionalGeneration(
1275
def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
1276
return self.language_model.compute_logits(hidden_states)
1277
1278
+ def compute_logits_local(self, hidden_states: torch.Tensor) -> torch.Tensor:
1279
+ return self.language_model.compute_logits_local(hidden_states)
1280
+
1281
def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
1282
return self.language_model.get_expert_mapping()
1283
vllm/v1/worker/gpu/model_runner.py
+72
-4
@@ -127,6 +127,11 @@ from vllm.v1.worker.gpu.mm.lora import set_active_mm_loras
127
from vllm.v1.worker.gpu.model_states import init_model_state
128
from vllm.v1.worker.gpu.pool.pooling_runner import PoolingRunner
129
from vllm.v1.worker.gpu.pp_utils import PPHandler
130
+from vllm.v1.worker.gpu.sample.batch_shard import (
131
+ BatchSharder,
132
+ all_to_all_logits,
133
+ gather_sampler_output,
134
+)
135
from vllm.v1.worker.gpu.sample.output import SamplerOutput
136
from vllm.v1.worker.gpu.sample.prompt_logprob import PromptLogprobsWorker
137
from vllm.v1.worker.gpu.sample.sampler import Sampler
@@ -310,6 +315,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
315
# model_state exists (num_new_sampled_tokens_per_step from ModelState).
316
self.sampler: Sampler | None = None
317
self.rejection_sampler: RejectionSampler | None = None
318
+ self.batch_sharder: BatchSharder | None = None
319
self.prompt_logprobs_worker: PromptLogprobsWorker | None = None
320
self.structured_outputs_worker: StructuredOutputsWorker | None = None
321
self.cudagraph_manager: ModelCudaGraphManager | None = None
@@ -404,6 +410,21 @@ class GPUModelRunner(LoRAModelRunnerMixin):
410
+ self.model_state.num_new_sampled_tokens_per_step
411
)
412
413
+ if self.parallel_config.enable_batch_sharded_sampling:
414
+ if hasattr(self.model, "compute_logits_local"):
415
+ self.batch_sharder = BatchSharder(
416
+ max_num_reqs=self.max_num_reqs,
417
+ max_num_logits_per_req=self.decode_query_len,
418
+ device=self.device,
419
+ )
420
+ logger.info("Batch-sharded sampling enabled.")
421
+ else:
422
+ logger.warning_once(
423
+ "Disabling batch-sharded sampling: %s does not implement "
424
+ "compute_logits_local",
425
+ type(self.model).__name__,
426
+ )
427
+
428
# Initialize samplers. Model states may override via custom_sampler().
429
if self.is_last_pp_rank and not self.is_pooling_model:
430
self.sampler = Sampler(
@@ -935,7 +956,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
956
preempted_req_ids = scheduler_output.preempted_req_ids
957
if preempted_req_ids:
958
finished_req_ids = finished_req_ids.union(preempted_req_ids)
938
- for req_id in finished_req_ids:
959
+ # Sorted so every TP rank frees request slots in the same order.
960
+ # Features like batch-sharded sampling derive rank request ownership
961
+ # from the slot index.
962
+ for req_id in sorted(finished_req_ids):
963
self._remove_request(req_id)
964
965
def free_states(self, scheduler_output: SchedulerOutput) -> None:
@@ -1335,8 +1359,25 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1359
input_batch: InputBatch,
1360
grammar_output: GrammarOutput | None,
1361
) -> tuple[SamplerOutput, torch.Tensor, torch.Tensor]:
1338
- sample_hidden_states = hidden_states[input_batch.logits_indices]
1339
- logits = self.model.compute_logits(sample_hidden_states)
1362
+ shard_metadata = None
1363
+ global_input_batch = input_batch
1364
+ if self.batch_sharder is not None:
1365
+ # Shard the inputs along the batch dimension to sample in parallel
1366
+ # across TP ranks.
1367
+ input_batch, sorted_logits_indices, grammar_output, shard_metadata = (
1368
+ self.batch_sharder.shard_sampler_inputs(input_batch, grammar_output)
1369
+ )
1370
+ # The hidden states must be gathered in rank-owner-sorted order
1371
+ # before computing the partial-vocab logits, so that the all-to-all
1372
+ # produces full-vocab logits for just the locally-owned requests.
1373
+ sample_hidden_states = hidden_states[sorted_logits_indices]
1374
+ local_logits = self.model.compute_logits_local(sample_hidden_states)
1375
+ logits = all_to_all_logits(local_logits, shard_metadata)
1376
+ logits = logits[:, : self.vocab_size]
1377
+ else:
1378
+ sample_hidden_states = hidden_states[input_batch.logits_indices]
1379
+ logits = self.model.compute_logits(sample_hidden_states)
1380
+
1381
if grammar_output is not None:
1382
# Apply grammar bitmask to the logits in-place.
1383
assert self.structured_outputs_worker is not None
@@ -1347,7 +1388,12 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1388
grammar_output.grammar_bitmask,
1389
)
1390
1350
- if input_batch.num_draft_tokens == 0 or self.rejection_sampler is None:
1391
+ sampler_output: SamplerOutput | None
1392
+ if input_batch.num_reqs == 0:
1393
+ # This rank owns no requests this step. It contributes an
1394
+ # all-padding block to the gather below.
1395
+ sampler_output = None
1396
+ elif input_batch.num_draft_tokens == 0 or self.rejection_sampler is None:
1397
assert self.sampler is not None
1398
sampler_output = self.sampler(logits, input_batch)
1399
else:
@@ -1361,6 +1407,28 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1407
self.speculator.draft_logits,
1408
)
1409
1410
+ if shard_metadata is not None:
1411
+ # Gather the sharded sampler outputs from the TP ranks into a single
1412
+ # sampler output.
1413
+ assert self.sampler is not None
1414
+ sampler_output = gather_sampler_output(
1415
+ sampler_output,
1416
+ shard_metadata,
1417
+ device=self.device,
1418
+ global_batch=global_input_batch,
1419
+ local_batch=input_batch,
1420
+ gather_num_nans=self.sampler.compute_nans,
1421
+ logprobs_dims=self.sampler.get_logprobs_dims(
1422
+ global_input_batch.idx_mapping_np,
1423
+ # Rejection sampler does not return logprob token ids.
1424
+ include_token_ids=(
1425
+ global_input_batch.num_draft_tokens == 0
1426
+ or self.rejection_sampler is None
1427
+ ),
1428
+ ),
1429
+ )
1430
+
1431
+ assert sampler_output is not None
1432
return sampler_output, sampler_output.num_sampled, sampler_output.num_rejected
1433
1434
def postprocess_sampled(
vllm/v1/worker/gpu/sample/batch_shard.py
new
+674
@@ -0,0 +1,674 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from dataclasses import dataclass, replace
5
+
6
+import numpy as np
7
+import torch
8
+import torch.distributed
9
+
10
+from vllm.distributed.communication_op import tensor_model_parallel_all_gather
11
+from vllm.distributed.parallel_state import get_tp_group
12
+from vllm.triton_utils import tl, triton
13
+from vllm.v1.core.sched.output import GrammarOutput
14
+from vllm.v1.outputs import LogprobsTensors
15
+from vllm.v1.worker.gpu.input_batch import InputBatch
16
+from vllm.v1.worker.gpu.sample.output import SamplerOutput
17
+
18
+
19
+@dataclass
20
+class BatchShardMetadata:
21
+ """Collective layout for one sampling step.
22
+
23
+ Requests (and their logits) are owner-sorted: all requests owned by
24
+ rank 0 first, then rank 1, etc. Within an owner, batch order is
25
+ preserved (stable sort). Every field is a pure function of the
26
+ replicated idx_mapping and cu_num_logits, so all ranks build identical
27
+ plans without communication — which relies on request slots
28
+ (idx_mapping) being assigned identically on every rank (see
29
+ finish_requests in the model runner).
30
+ """
31
+
32
+ tp_size: int
33
+ # Per-owner-rank logits counts (all-to-all send splits).
34
+ num_logits_per_rank: list[int]
35
+ num_local_logits: int
36
+ num_local_reqs: int
37
+ # The number of request entries contributed by each rank to the
38
+ # gathered result (shorter shards are padded up to this value).
39
+ max_num_reqs_per_rank: int
40
+ # [num_reqs] For batch request i, its source index in the rank-major
41
+ # gathered results. Indexing the gathered tensors with this restores
42
+ # the original batch order.
43
+ gathered_src_indices: torch.Tensor
44
+ # The number of entries occupied by each request in the gathered
45
+ # tensor. Derived from the global cu_num_logits, so each rank agrees
46
+ # ont he gather shapes, including empty shards.
47
+ max_num_logits_per_req: int
48
+
49
+
50
+@triton.jit(
51
+ do_not_specialize=["num_reqs", "local_logits_start", "max_num_reqs_per_rank"]
52
+)
53
+def _build_shard_plan_kernel(
54
+ idx_mapping_ptr,
55
+ cu_num_logits_ptr,
56
+ query_start_loc_ptr,
57
+ seq_lens_ptr,
58
+ sorted_logits_indices_ptr,
59
+ gathered_src_indices_ptr,
60
+ local_idx_mapping_ptr,
61
+ local_cu_num_logits_ptr,
62
+ local_logits_indices_ptr,
63
+ local_expanded_idx_mapping_ptr,
64
+ local_expanded_local_pos_ptr,
65
+ local_seq_lens_ptr,
66
+ num_reqs,
67
+ local_logits_start,
68
+ max_num_reqs_per_rank,
69
+ TP_SIZE: tl.constexpr,
70
+ TP_RANK: tl.constexpr,
71
+ PADDED_NUM_REQS: tl.constexpr,
72
+ PADDED_NUM_LOGITS_PER_REQ: tl.constexpr,
73
+):
74
+ req_idx = tl.program_id(0)
75
+ req_state_idx = tl.load(idx_mapping_ptr + req_idx)
76
+ owner = req_state_idx % TP_SIZE
77
+ num_logits = tl.load(cu_num_logits_ptr + req_idx + 1) - tl.load(
78
+ cu_num_logits_ptr + req_idx
79
+ )
80
+
81
+ req_block = tl.arange(0, PADDED_NUM_REQS)
82
+ req_mask = req_block < num_reqs
83
+ owners = tl.load(idx_mapping_ptr + req_block, mask=req_mask, other=0) % TP_SIZE
84
+ num_logits_per_req = tl.load(
85
+ cu_num_logits_ptr + req_block + 1, mask=req_mask, other=0
86
+ ) - tl.load(cu_num_logits_ptr + req_block, mask=req_mask, other=0)
87
+
88
+ # Owner-sorted layout is rank 0's requests in batch order, then rank 1's,
89
+ # etc, so a request's starting offset can be derived from counting.
90
+ lower = (owners < owner) & req_mask
91
+ earlier = (owners == owner) & (req_block < req_idx) & req_mask
92
+
93
+ # Compute the logits row offset for the current request in the sorted
94
+ # batch.
95
+ req_logits_start = tl.sum(tl.where(lower, num_logits_per_req, 0)) + tl.sum(
96
+ tl.where(earlier, num_logits_per_req, 0)
97
+ )
98
+
99
+ # Copy the current request logit indices into the sorted batch.
100
+ query_start = tl.load(query_start_loc_ptr + req_idx + 1) - num_logits
101
+ logit_block = tl.arange(0, PADDED_NUM_LOGITS_PER_REQ)
102
+ logit_mask = logit_block < num_logits
103
+ logits_indices = (query_start + logit_block).to(tl.int64)
104
+ tl.store(
105
+ sorted_logits_indices_ptr + req_logits_start + logit_block,
106
+ logits_indices,
107
+ mask=logit_mask,
108
+ )
109
+
110
+ # Write the mapping of output tensor request indices => gathered tensor
111
+ # request indices.
112
+ # Batch request i's result lives in the padded, gathered results at
113
+ # rank_of(i) * max_num_reqs_per_rank + offset_within_rank(i).
114
+ local_req_idx = tl.sum(earlier.to(tl.int32))
115
+ tl.store(
116
+ gathered_src_indices_ptr + req_idx,
117
+ (owner * max_num_reqs_per_rank + local_req_idx).to(tl.int64),
118
+ )
119
+
120
+ if req_idx == 0:
121
+ tl.store(local_cu_num_logits_ptr, 0)
122
+
123
+ if owner == TP_RANK:
124
+ local_start = req_logits_start - local_logits_start
125
+ tl.store(local_idx_mapping_ptr + local_req_idx, req_state_idx)
126
+ tl.store(local_cu_num_logits_ptr + local_req_idx + 1, local_start + num_logits)
127
+ tl.store(
128
+ local_logits_indices_ptr + local_start + logit_block,
129
+ logits_indices,
130
+ mask=logit_mask,
131
+ )
132
+ tl.store(
133
+ local_expanded_idx_mapping_ptr + local_start + logit_block,
134
+ req_state_idx,
135
+ mask=logit_mask,
136
+ )
137
+ tl.store(
138
+ local_expanded_local_pos_ptr + local_start + logit_block,
139
+ logit_block,
140
+ mask=logit_mask,
141
+ )
142
+ tl.store(local_seq_lens_ptr + local_req_idx, tl.load(seq_lens_ptr + req_idx))
143
+
144
+
145
+class BatchSharder:
146
+ """Shards the sampler inputs across TP ranks along the batch dimension."""
147
+
148
+ def __init__(
149
+ self,
150
+ max_num_reqs: int,
151
+ max_num_logits_per_req: int,
152
+ device: torch.device,
153
+ ):
154
+ tp_group = get_tp_group()
155
+ self.tp_rank = tp_group.rank_in_group
156
+ self.tp_size = tp_group.world_size
157
+ self.device = device
158
+ self._padded_num_reqs = triton.next_power_of_2(max_num_reqs)
159
+ self._padded_num_logits_per_req = triton.next_power_of_2(max_num_logits_per_req)
160
+ self._num_warps = max(1, min(8, self._padded_num_reqs // 128))
161
+
162
+ def shard_sampler_inputs(
163
+ self,
164
+ input_batch: InputBatch,
165
+ grammar_output: GrammarOutput | None,
166
+ ) -> tuple[InputBatch, torch.Tensor, GrammarOutput | None, BatchShardMetadata]:
167
+ """Owner-sort the batch and build this rank's local sampler inputs.
168
+
169
+ Returns the local sub-batch, the owner-sorted logits_indices (gather
170
+ the sampling hidden states with these so `compute_logits_local` emits
171
+ logits in all-to-all send order), the local grammar output (None if
172
+ this rank owns none of the structured-output requests), and the shard
173
+ metadata (used for all-gathering the sampler outputs).
174
+ """
175
+ tp_rank = self.tp_rank
176
+ tp_size = self.tp_size
177
+ num_reqs = input_batch.idx_mapping_np.shape[0]
178
+ num_logits = int(input_batch.cu_num_logits_np[-1])
179
+
180
+ # Deterministically assign requests to ranks, round-robin over slot
181
+ # indices so ownership stays balanced when the slot allocator fills
182
+ # low slots first (partial occupancy).
183
+ # NOTE: This mirrors the assignment used in _build_shard_plan_kernel.
184
+ req_owner_np = input_batch.idx_mapping_np % tp_size
185
+ local_req_indices_np = np.flatnonzero(req_owner_np == tp_rank)
186
+ local_idx_mapping_np = input_batch.idx_mapping_np[local_req_indices_np]
187
+ num_local_reqs = local_req_indices_np.shape[0]
188
+ num_reqs_per_rank_np = np.bincount(req_owner_np, minlength=tp_size)
189
+ max_num_reqs_per_rank = int(num_reqs_per_rank_np.max()) if num_reqs else 1
190
+ # Derive the number of logits owned by each rank, as well as the
191
+ # local rank.
192
+ num_logits_per_req_np = np.diff(input_batch.cu_num_logits_np)
193
+ num_logits_per_rank_np = np.bincount(
194
+ req_owner_np, weights=num_logits_per_req_np, minlength=tp_size
195
+ ).astype(np.int64)
196
+ num_local_logits = int(num_logits_per_rank_np[tp_rank])
197
+ local_logits_start = int(num_logits_per_rank_np[:tp_rank].sum())
198
+ local_cu_num_logits_np = np.zeros(num_local_reqs + 1, dtype=np.int32)
199
+ np.cumsum(
200
+ num_logits_per_req_np[local_req_indices_np], out=local_cu_num_logits_np[1:]
201
+ )
202
+ max_num_logits_per_req = int(num_logits_per_req_np.max()) if num_reqs else 1
203
+
204
+ # Shard the input batch GPU tensors.
205
+ sorted_logits_indices = torch.empty(
206
+ num_logits, dtype=torch.int64, device=self.device
207
+ )
208
+ gathered_src_indices = torch.empty(
209
+ num_reqs, dtype=torch.int64, device=self.device
210
+ )
211
+ local_logits_indices = torch.empty(
212
+ num_local_logits, dtype=torch.int64, device=self.device
213
+ )
214
+ local_idx_mapping = torch.empty(
215
+ num_local_reqs, dtype=torch.int32, device=self.device
216
+ )
217
+ local_cu_num_logits = torch.empty(
218
+ num_local_reqs + 1, dtype=torch.int32, device=self.device
219
+ )
220
+ local_expanded_idx_mapping = torch.empty(
221
+ num_local_logits, dtype=torch.int32, device=self.device
222
+ )
223
+ local_expanded_local_pos = torch.empty(
224
+ num_local_logits, dtype=torch.int32, device=self.device
225
+ )
226
+ local_seq_lens = torch.empty(
227
+ num_local_reqs, dtype=torch.int32, device=self.device
228
+ )
229
+ if num_reqs > 0:
230
+ _build_shard_plan_kernel[(num_reqs,)](
231
+ input_batch.idx_mapping,
232
+ input_batch.cu_num_logits,
233
+ input_batch.query_start_loc,
234
+ input_batch.seq_lens,
235
+ sorted_logits_indices,
236
+ gathered_src_indices,
237
+ local_idx_mapping,
238
+ local_cu_num_logits,
239
+ local_logits_indices,
240
+ local_expanded_idx_mapping,
241
+ local_expanded_local_pos,
242
+ local_seq_lens,
243
+ num_reqs,
244
+ local_logits_start,
245
+ max_num_reqs_per_rank,
246
+ TP_SIZE=tp_size,
247
+ TP_RANK=tp_rank,
248
+ PADDED_NUM_REQS=self._padded_num_reqs,
249
+ PADDED_NUM_LOGITS_PER_REQ=self._padded_num_logits_per_req,
250
+ num_warps=self._num_warps,
251
+ )
252
+
253
+ # Compute the local number of draft tokens.
254
+ num_draft_tokens_per_req = None
255
+ num_draft_tokens = 0
256
+ if input_batch.num_draft_tokens_per_req is not None:
257
+ num_draft_tokens_per_req = input_batch.num_draft_tokens_per_req[
258
+ local_req_indices_np
259
+ ]
260
+ num_draft_tokens = int(num_draft_tokens_per_req.sum())
261
+
262
+ local_req_ids = [input_batch.req_ids[i] for i in local_req_indices_np.tolist()]
263
+ local_batch = replace(
264
+ input_batch,
265
+ req_ids=local_req_ids,
266
+ num_reqs=num_local_reqs,
267
+ idx_mapping=local_idx_mapping,
268
+ idx_mapping_np=local_idx_mapping_np,
269
+ expanded_idx_mapping=local_expanded_idx_mapping,
270
+ expanded_local_pos=local_expanded_local_pos,
271
+ seq_lens=local_seq_lens,
272
+ logits_indices=local_logits_indices,
273
+ cu_num_logits=local_cu_num_logits,
274
+ cu_num_logits_np=local_cu_num_logits_np,
275
+ num_draft_tokens=num_draft_tokens,
276
+ num_draft_tokens_per_req=num_draft_tokens_per_req,
277
+ )
278
+ local_grammar_output = None
279
+ if grammar_output is not None:
280
+ local_grammar_output = _shard_grammar_output(
281
+ grammar_output, input_batch, local_req_ids
282
+ )
283
+ metadata = BatchShardMetadata(
284
+ tp_size=tp_size,
285
+ num_logits_per_rank=num_logits_per_rank_np.tolist(),
286
+ num_local_logits=num_local_logits,
287
+ num_local_reqs=num_local_reqs,
288
+ max_num_reqs_per_rank=max_num_reqs_per_rank,
289
+ gathered_src_indices=gathered_src_indices,
290
+ max_num_logits_per_req=max_num_logits_per_req,
291
+ )
292
+ return local_batch, sorted_logits_indices, local_grammar_output, metadata
293
+
294
+
295
+def all_to_all_logits(
296
+ logits_shard: torch.Tensor,
297
+ metadata: BatchShardMetadata,
298
+) -> torch.Tensor:
299
+ recv = logits_shard.new_empty(
300
+ metadata.tp_size * metadata.num_local_logits, logits_shard.shape[-1]
301
+ )
302
+ torch.distributed.all_to_all_single(
303
+ recv,
304
+ logits_shard.contiguous(),
305
+ output_split_sizes=[metadata.num_local_logits] * metadata.tp_size,
306
+ input_split_sizes=metadata.num_logits_per_rank,
307
+ group=get_tp_group().device_group,
308
+ )
309
+ shard_width = recv.shape[-1]
310
+ return (
311
+ recv.view(metadata.tp_size, metadata.num_local_logits, shard_width)
312
+ .permute(1, 0, 2)
313
+ .reshape(metadata.num_local_logits, metadata.tp_size * shard_width)
314
+ )
315
+
316
+
317
+def _shard_grammar_output(
318
+ grammar_output: GrammarOutput,
319
+ input_batch: InputBatch,
320
+ local_req_ids: list[str],
321
+) -> GrammarOutput | None:
322
+ owned = set(local_req_ids)
323
+ req_id_to_idx = {req_id: i for i, req_id in enumerate(input_batch.req_ids)}
324
+ cu_num_logits_np = input_batch.cu_num_logits_np
325
+
326
+ local_ids: list[str] = []
327
+ keep_indices: list[int] = []
328
+ cursor = 0
329
+ for req_id in grammar_output.structured_output_request_ids:
330
+ req_idx = req_id_to_idx[req_id]
331
+ num_req_logits = int(cu_num_logits_np[req_idx + 1] - cu_num_logits_np[req_idx])
332
+ if req_id in owned:
333
+ local_ids.append(req_id)
334
+ keep_indices.extend(range(cursor, cursor + num_req_logits))
335
+ cursor += num_req_logits
336
+ if not local_ids:
337
+ return None
338
+ return GrammarOutput(
339
+ structured_output_request_ids=local_ids,
340
+ grammar_bitmask=grammar_output.grammar_bitmask[keep_indices],
341
+ )
342
+
343
+
344
+@triton.jit(
345
+ do_not_specialize=["max_num_logits_per_req", "num_src_cols", "packed_stride"]
346
+)
347
+def _pack_sampler_output_kernel(
348
+ packed_ptr,
349
+ packed_stride,
350
+ sampled_token_ids_ptr,
351
+ sampled_token_ids_stride,
352
+ num_sampled_ptr,
353
+ num_rejected_ptr,
354
+ num_nans_ptr,
355
+ local_cu_num_logits_ptr,
356
+ max_num_logits_per_req,
357
+ num_src_cols,
358
+ BLOCK_SIZE: tl.constexpr,
359
+):
360
+ req_idx = tl.program_id(0)
361
+ cols = tl.arange(0, BLOCK_SIZE)
362
+ row_ptr = packed_ptr + req_idx * packed_stride
363
+
364
+ token_ids = tl.load(
365
+ sampled_token_ids_ptr + req_idx * sampled_token_ids_stride + cols,
366
+ mask=cols < num_src_cols,
367
+ other=0,
368
+ )
369
+ tl.store(row_ptr + cols, token_ids, mask=cols < max_num_logits_per_req)
370
+ num_sampled = tl.load(num_sampled_ptr + req_idx)
371
+ tl.store(row_ptr + max_num_logits_per_req, num_sampled.to(tl.int64))
372
+ num_rejected = tl.load(num_rejected_ptr + req_idx)
373
+ tl.store(row_ptr + max_num_logits_per_req + 1, num_rejected.to(tl.int64))
374
+ if num_nans_ptr is not None:
375
+ # num_nans is per logits row; reduce it per request. The replicated
376
+ # samplers report per-row counts that downstream zips per request,
377
+ # so a per-request sum is the well-defined equivalent.
378
+ start = tl.load(local_cu_num_logits_ptr + req_idx)
379
+ num_req_logits = tl.load(local_cu_num_logits_ptr + req_idx + 1) - start
380
+ nans = tl.load(num_nans_ptr + start + cols, mask=cols < num_req_logits, other=0)
381
+ tl.store(
382
+ row_ptr + max_num_logits_per_req + 2, tl.sum(nans.to(tl.int64), axis=0)
383
+ )
384
+
385
+
386
+@triton.jit(do_not_specialize=["max_num_logits_per_req", "gathered_stride"])
387
+def _unpack_gathered_output_kernel(
388
+ gathered_ptr,
389
+ gathered_stride,
390
+ gathered_src_indices_ptr,
391
+ sampled_token_ids_ptr,
392
+ num_sampled_ptr,
393
+ num_rejected_ptr,
394
+ num_nans_ptr,
395
+ max_num_logits_per_req,
396
+ BLOCK_SIZE: tl.constexpr,
397
+):
398
+ req_idx = tl.program_id(0)
399
+ src = tl.load(gathered_src_indices_ptr + req_idx)
400
+ row_ptr = gathered_ptr + src * gathered_stride
401
+ cols = tl.arange(0, BLOCK_SIZE)
402
+ mask = cols < max_num_logits_per_req
403
+
404
+ token_ids = tl.load(row_ptr + cols, mask=mask)
405
+ tl.store(
406
+ sampled_token_ids_ptr + req_idx * max_num_logits_per_req + cols,
407
+ token_ids,
408
+ mask=mask,
409
+ )
410
+ num_sampled = tl.load(row_ptr + max_num_logits_per_req)
411
+ tl.store(num_sampled_ptr + req_idx, num_sampled.to(tl.int32))
412
+ num_rejected = tl.load(row_ptr + max_num_logits_per_req + 1)
413
+ tl.store(num_rejected_ptr + req_idx, num_rejected.to(tl.int32))
414
+ if num_nans_ptr is not None:
415
+ num_nans = tl.load(row_ptr + max_num_logits_per_req + 2)
416
+ tl.store(num_nans_ptr + req_idx, num_nans.to(tl.int32))
417
+
418
+
419
+@triton.jit(
420
+ do_not_specialize=[
421
+ "max_num_logits_per_req",
422
+ "num_logprob_cols",
423
+ "num_src_cols",
424
+ "send_ids_stride",
425
+ "send_logprobs_stride",
426
+ "src_ids_stride",
427
+ "src_logprobs_stride",
428
+ ]
429
+)
430
+def _pack_logprobs_kernel(
431
+ send_ids_ptr,
432
+ send_ids_stride,
433
+ send_logprobs_ptr,
434
+ send_logprobs_stride,
435
+ src_ids_ptr,
436
+ src_ids_stride,
437
+ src_logprobs_ptr,
438
+ src_logprobs_stride,
439
+ selected_token_ranks_ptr,
440
+ local_cu_num_logits_ptr,
441
+ max_num_logits_per_req,
442
+ num_logprob_cols,
443
+ num_src_cols,
444
+ BLOCK_SIZE: tl.constexpr,
445
+):
446
+ req_idx = tl.program_id(0)
447
+ pos = tl.program_id(1)
448
+ start = tl.load(local_cu_num_logits_ptr + req_idx)
449
+ if pos < tl.load(local_cu_num_logits_ptr + req_idx + 1) - start:
450
+ src = start + pos
451
+ dst = req_idx * max_num_logits_per_req + pos
452
+ cols = tl.arange(0, BLOCK_SIZE)
453
+ # A rank whose sub-batch has no draft tokens runs the regular sampler
454
+ # while others run the rejection sampler, and their column counts can
455
+ # differ (e.g. logprob_token_ids widening); the two masks pad or
456
+ # truncate to the agreed width.
457
+ src_mask = cols < num_src_cols
458
+ dst_mask = cols < num_logprob_cols
459
+ ids = tl.load(src_ids_ptr + src * src_ids_stride + cols, mask=src_mask, other=0)
460
+ logprobs = tl.load(
461
+ src_logprobs_ptr + src * src_logprobs_stride + cols,
462
+ mask=src_mask,
463
+ other=float("-inf"),
464
+ )
465
+ ids_row = send_ids_ptr + dst * send_ids_stride
466
+ tl.store(ids_row + cols, ids.to(tl.int64), mask=dst_mask)
467
+ rank = tl.load(selected_token_ranks_ptr + src)
468
+ tl.store(ids_row + num_logprob_cols, rank.to(tl.int64))
469
+ tl.store(
470
+ send_logprobs_ptr + dst * send_logprobs_stride + cols,
471
+ logprobs.to(tl.float32),
472
+ mask=dst_mask,
473
+ )
474
+
475
+
476
+@triton.jit(
477
+ do_not_specialize=[
478
+ "max_num_logits_per_req",
479
+ "num_logprob_cols",
480
+ "gathered_ids_stride",
481
+ "gathered_logprobs_stride",
482
+ ]
483
+)
484
+def _unpack_logprobs_kernel(
485
+ gathered_ids_ptr,
486
+ gathered_ids_stride,
487
+ gathered_logprobs_ptr,
488
+ gathered_logprobs_stride,
489
+ gathered_src_indices_ptr,
490
+ cu_num_logits_ptr,
491
+ logprob_token_ids_ptr,
492
+ logprobs_ptr,
493
+ selected_token_ranks_ptr,
494
+ max_num_logits_per_req,
495
+ num_logprob_cols,
496
+ BLOCK_SIZE: tl.constexpr,
497
+):
498
+ req_idx = tl.program_id(0)
499
+ pos = tl.program_id(1)
500
+ start = tl.load(cu_num_logits_ptr + req_idx)
501
+ if pos < tl.load(cu_num_logits_ptr + req_idx + 1) - start:
502
+ src = tl.load(gathered_src_indices_ptr + req_idx) * max_num_logits_per_req + pos
503
+ dst = start + pos
504
+ cols = tl.arange(0, BLOCK_SIZE)
505
+ mask = cols < num_logprob_cols
506
+ src_ids_row = gathered_ids_ptr + src * gathered_ids_stride
507
+ ids = tl.load(src_ids_row + cols, mask=mask)
508
+ tl.store(logprob_token_ids_ptr + dst * num_logprob_cols + cols, ids, mask=mask)
509
+ rank = tl.load(src_ids_row + num_logprob_cols)
510
+ tl.store(selected_token_ranks_ptr + dst, rank)
511
+ logprobs = tl.load(
512
+ gathered_logprobs_ptr + src * gathered_logprobs_stride + cols, mask=mask
513
+ )
514
+ tl.store(logprobs_ptr + dst * num_logprob_cols + cols, logprobs, mask=mask)
515
+
516
+
517
+def _gather_logprobs_tensors(
518
+ local_output: SamplerOutput | None,
519
+ metadata: BatchShardMetadata,
520
+ global_batch: InputBatch,
521
+ local_batch: InputBatch,
522
+ logprobs_dims: tuple[int, int],
523
+ device: torch.device,
524
+) -> LogprobsTensors:
525
+ num_logprobs, max_token_ids = logprobs_dims
526
+ num_logprob_cols = 1 + max(num_logprobs, max_token_ids)
527
+ max_num_logits_per_req = metadata.max_num_logits_per_req
528
+ num_send_rows = metadata.max_num_reqs_per_rank * max_num_logits_per_req
529
+ block_size = triton.next_power_of_2(num_logprob_cols)
530
+
531
+ # Padded slots are never read back: gathered_src_indices only addresses
532
+ # rows that an owner rank wrote.
533
+ send_ids_ranks = torch.empty(
534
+ num_send_rows, num_logprob_cols + 1, dtype=torch.int64, device=device
535
+ )
536
+ send_logprobs = torch.empty(
537
+ num_send_rows, num_logprob_cols, dtype=torch.float32, device=device
538
+ )
539
+ if local_output is not None and local_output.logprobs_tensors is not None:
540
+ lp = local_output.logprobs_tensors
541
+ _pack_logprobs_kernel[(metadata.num_local_reqs, max_num_logits_per_req)](
542
+ send_ids_ranks,
543
+ send_ids_ranks.stride(0),
544
+ send_logprobs,
545
+ send_logprobs.stride(0),
546
+ lp.logprob_token_ids,
547
+ lp.logprob_token_ids.stride(0),
548
+ lp.logprobs,
549
+ lp.logprobs.stride(0),
550
+ lp.selected_token_ranks,
551
+ local_batch.cu_num_logits,
552
+ max_num_logits_per_req,
553
+ num_logprob_cols,
554
+ lp.logprob_token_ids.shape[1],
555
+ BLOCK_SIZE=block_size,
556
+ )
557
+
558
+ gathered_ids_ranks = tensor_model_parallel_all_gather(send_ids_ranks, dim=0)
559
+ gathered_logprobs = tensor_model_parallel_all_gather(send_logprobs, dim=0)
560
+
561
+ num_reqs = global_batch.num_reqs
562
+ num_logits = int(global_batch.cu_num_logits_np[-1])
563
+ logprob_token_ids = torch.empty(
564
+ num_logits, num_logprob_cols, dtype=torch.int64, device=device
565
+ )
566
+ logprobs = torch.empty(
567
+ num_logits, num_logprob_cols, dtype=torch.float32, device=device
568
+ )
569
+ selected_token_ranks = torch.empty(num_logits, dtype=torch.int64, device=device)
570
+ _unpack_logprobs_kernel[(num_reqs, max_num_logits_per_req)](
571
+ gathered_ids_ranks,
572
+ gathered_ids_ranks.stride(0),
573
+ gathered_logprobs,
574
+ gathered_logprobs.stride(0),
575
+ metadata.gathered_src_indices,
576
+ global_batch.cu_num_logits,
577
+ logprob_token_ids,
578
+ logprobs,
579
+ selected_token_ranks,
580
+ max_num_logits_per_req,
581
+ num_logprob_cols,
582
+ BLOCK_SIZE=block_size,
583
+ )
584
+ return LogprobsTensors(
585
+ logprob_token_ids=logprob_token_ids,
586
+ logprobs=logprobs,
587
+ selected_token_ranks=selected_token_ranks,
588
+ cu_num_generated_tokens=(
589
+ global_batch.cu_num_logits_np.tolist() if num_logits != num_reqs else None
590
+ ),
591
+ )
592
+
593
+
594
+def gather_sampler_output(
595
+ local_output: SamplerOutput | None,
596
+ metadata: BatchShardMetadata,
597
+ device: torch.device,
598
+ global_batch: InputBatch,
599
+ local_batch: InputBatch,
600
+ gather_num_nans: bool = False,
601
+ logprobs_dims: tuple[int, int] | None = None,
602
+) -> SamplerOutput:
603
+ max_num_logits_per_req = metadata.max_num_logits_per_req
604
+ num_packed_cols = max_num_logits_per_req + 2 + (1 if gather_num_nans else 0)
605
+ block_size = triton.next_power_of_2(max_num_logits_per_req)
606
+
607
+ # Pack the sampler output tensors (excluding logprobs) into a single
608
+ # tensor for the all-gather.
609
+ packed = torch.empty(
610
+ metadata.max_num_reqs_per_rank,
611
+ num_packed_cols,
612
+ dtype=torch.int64,
613
+ device=device,
614
+ )
615
+ if local_output is not None:
616
+ assert local_output.num_sampled is not None
617
+ assert local_output.num_rejected is not None
618
+ assert not gather_num_nans or local_output.num_nans is not None
619
+ num_src_cols = min(
620
+ local_output.sampled_token_ids.shape[1], max_num_logits_per_req
621
+ )
622
+ _pack_sampler_output_kernel[(metadata.num_local_reqs,)](
623
+ packed,
624
+ packed.stride(0),
625
+ local_output.sampled_token_ids,
626
+ local_output.sampled_token_ids.stride(0),
627
+ local_output.num_sampled,
628
+ local_output.num_rejected,
629
+ local_output.num_nans if gather_num_nans else None,
630
+ local_batch.cu_num_logits if gather_num_nans else None,
631
+ max_num_logits_per_req,
632
+ num_src_cols,
633
+ BLOCK_SIZE=block_size,
634
+ )
635
+
636
+ # All-gather the sampler output tensors (excluding logprobs).
637
+ gathered = tensor_model_parallel_all_gather(packed, dim=0)
638
+ # All-gather the logprobs tensors.
639
+ logprobs_tensors = None
640
+ if logprobs_dims is not None:
641
+ logprobs_tensors = _gather_logprobs_tensors(
642
+ local_output, metadata, global_batch, local_batch, logprobs_dims, device
643
+ )
644
+
645
+ # Unpack the gathered tensor into a sampler output object.
646
+ num_reqs = metadata.gathered_src_indices.shape[0]
647
+ sampled_token_ids = torch.empty(
648
+ num_reqs, max_num_logits_per_req, dtype=torch.int64, device=device
649
+ )
650
+ num_sampled = torch.empty(num_reqs, dtype=torch.int32, device=device)
651
+ num_rejected = torch.empty(num_reqs, dtype=torch.int32, device=device)
652
+ num_nans = (
653
+ torch.empty(num_reqs, dtype=torch.int32, device=device)
654
+ if gather_num_nans
655
+ else None
656
+ )
657
+ _unpack_gathered_output_kernel[(num_reqs,)](
658
+ gathered,
659
+ gathered.stride(0),
660
+ metadata.gathered_src_indices,
661
+ sampled_token_ids,
662
+ num_sampled,
663
+ num_rejected,
664
+ num_nans,
665
+ max_num_logits_per_req,
666
+ BLOCK_SIZE=block_size,
667
+ )
668
+ return SamplerOutput(
669
+ sampled_token_ids=sampled_token_ids,
670
+ logprobs_tensors=logprobs_tensors,
671
+ num_nans=num_nans,
672
+ num_sampled=num_sampled,
673
+ num_rejected=num_rejected,
674
+ )
vllm/v1/worker/gpu/sample/sampler.py
+20
-8
@@ -103,6 +103,22 @@ class Sampler:
103
if self.trace_replay_state is not None:
104
self.trace_replay_state.apply_staged_writes()
105
106
+ def get_logprobs_dims(
107
+ self, idx_mapping_np: np.ndarray, include_token_ids: bool = True
108
+ ) -> tuple[int, int] | None:
109
+ """(num_logprobs, max_per_req_token_ids) for the given requests, or
110
+ None when none of them want logprobs."""
111
+ max_num_logprobs = self.sampling_states.max_num_logprobs(idx_mapping_np)
112
+ max_token_ids = (
113
+ self.logprob_token_ids_state.max_num_token_ids(idx_mapping_np)
114
+ if include_token_ids
115
+ else 0
116
+ )
117
+ if max_num_logprobs == NO_LOGPROBS and max_token_ids == 0:
118
+ return None
119
+ num_logprobs = max_num_logprobs if max_num_logprobs != NO_LOGPROBS else 0
120
+ return num_logprobs, max_token_ids
121
+
122
def __call__(
123
self,
124
logits: torch.Tensor,
@@ -120,11 +136,7 @@ class Sampler:
136
# that num_nans is computed before applying penalties and temperature.
137
num_nans = get_num_nans(logits) if self.compute_nans else None
138
123
- max_num_logprobs = self.sampling_states.max_num_logprobs(idx_mapping_np)
124
- max_per_req_token_ids = self.logprob_token_ids_state.max_num_token_ids(
125
- idx_mapping_np
126
- )
127
- return_logprobs = max_num_logprobs != NO_LOGPROBS or max_per_req_token_ids > 0
139
+ logprobs_dims = self.get_logprobs_dims(idx_mapping_np)
140
141
sampled, processed_logits = self.sample(
142
logits,
@@ -134,7 +146,7 @@ class Sampler:
146
pos,
147
input_ids,
148
expanded_local_pos,
137
- return_logprobs=return_logprobs,
149
+ return_logprobs=logprobs_dims is not None,
150
)
151
152
if self.trace_replay_state is not None:
@@ -142,12 +154,12 @@ class Sampler:
154
# computed logprobs reflect the real distribution of the forced token.
155
self.trace_replay_state.apply_trace(sampled, idx_mapping)
156
145
- if return_logprobs:
157
+ if logprobs_dims is not None:
158
+ num_logprobs, max_per_req_token_ids = logprobs_dims
159
if self.logprobs_mode in PROCESSED_LOGPROBS_MODES:
160
logits = processed_logits
161
expanded_logits = logits.shape[0] != idx_mapping_np.shape[0]
162
cu_num_logits = cu_num_logits_np.tolist() if expanded_logits else None
150
- num_logprobs = max_num_logprobs if max_num_logprobs != NO_LOGPROBS else 0
163
logprobs_tensors = compute_topk_scores(
164
logits,
165
num_logprobs,