[Feature][DSpark]: Logprobs adaptive verification (#52242)
Signed-off-by: Naveenraj Kamalakannan <therealnaveenkamal@gmail.com> Co-authored-by: Benjamin Chislett <bchislett@nvidia.com>
Naveenraj Kamalakannan committed
Aug 25, 2026 at 10:22 UTC
d5cadcee8641a9fcec15facb5a9157d157daa207
10 files changed
+61
-25
docs/features/speculative_decoding/adaptive_verification.md
+1
-1
@@ -36,7 +36,7 @@ Set `enable_adaptive_verification: false` to verify the full block for every req
36
37
- The attention backend must tolerate device-decided query lengths, since the CPU lengths only bound them from above. Backends that plan off the CPU lengths are excluded by the attention selector, and rejected at startup for models that hard-wire their backend.
38
- Full cudagraphs are required: step costs are profiled from captured graphs, so `--enforce-eager` is rejected at startup.
39
-- Not supported with LoRA (the per-token LoRA mapping is built from CPU-side boundaries), pipeline parallelism (cost curves and confidences exist only on the last rank), or output logprobs (to be fixed).
39
+- Not supported with LoRA (the per-token LoRA mapping is built from CPU-side boundaries) or pipeline parallelism (cost curves and confidences exist only on the last rank).
40
41
## Tuning the cost profile
42
tests/v1/engine/test_output_processor.py
+1
-1
@@ -411,7 +411,7 @@ def _validate_logprobs(
411
ref_prompt_logprob_toks,
412
ref_prompt_logprob_vals,
413
ref_prompt_token_ranks,
414
- _,
414
+ *_,
415
) = ref_prompt_logprobs
416
for idx, (prompt_token, pos_logprob_dict) in enumerate(
417
zip(prompt_token_ids[1:], prompt_logprobs[1:])
tests/v1/test_outputs.py
+19
@@ -42,6 +42,25 @@ def test_logprobs_tensors_cat():
42
assert LogprobsTensors.cat([first]) is first
43
44
45
+def test_logprobs_tensors_tolists_with_tensor_boundaries():
46
+ """Adaptive verification hands over the request boundaries as a tensor
47
+ (they only exist on device); tolists() must materialize it as a plain
48
+ list so slice_request splits requests correctly."""
49
+ tensors = LogprobsTensors(
50
+ torch.tensor([[1, 2], [3, 4], [5, 6]]),
51
+ torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]),
52
+ torch.tensor([1, 2, 3]),
53
+ cu_num_generated_tokens_tensor=torch.tensor([0, 2, 3], dtype=torch.int32),
54
+ )
55
+
56
+ lists = tensors.to_cpu_nonblocking().tolists()
57
+
58
+ assert lists.cu_num_generated_tokens == [0, 2, 3]
59
+ sliced = lists.slice_request(1, 1)
60
+ assert sliced.logprob_token_ids.tolist() == [[5, 6]]
61
+ assert sliced.sampled_token_ranks.tolist() == [3]
62
+
63
+
64
def test_sampling_mask_tensors_tolist():
65
tensors = SamplingMaskTensors(
66
packed_mask=torch.tensor(
tests/v1/worker/test_gpu_rejection_sampler_chunking.py
+1
@@ -51,6 +51,7 @@ def test_chunked_scores_match_full_batch(logprobs_mode: str):
51
rejection_sampler = object.__new__(RejectionSampler)
52
rejection_sampler.sampler = SimpleNamespace(logprobs_mode=logprobs_mode)
53
rejection_sampler.num_speculative_steps = 3
54
+ rejection_sampler.enable_adaptive_verification = False
55
56
def fake_verify(
57
self,
vllm/sampling_params.py
-14
@@ -974,20 +974,6 @@ class SamplingParams(
974
if speculative_config is None:
975
return
976
977
- # Adaptive verification compacts logits after the forward pass, while
978
- # compute_topk_scores uses the scheduled layout in cu_num_logits_np.
979
- # TODO(lucas): lift this restriction. The true boundaries exist on device as
980
- # cu_num_logits; cu_num_generated_tokens is only read host-side after
981
- # the logprobs D2H, so it could ride along on that copy.
982
- if (
983
- speculative_config.enable_adaptive_verification
984
- and self.num_logprobs is not None
985
- ):
986
- raise ValueError(
987
- "Output logprobs are not supported with DSpark confidence-based "
988
- "verification."
989
- )
990
-
977
# Some sampling parameters are not yet compatible with spec decoding.
978
if self.min_p > _SAMPLING_EPS or self.logit_bias:
979
raise VLLMValidationError(
vllm/v1/engine/logprobs.py
+1
-1
@@ -134,7 +134,7 @@ class LogprobsProcessor:
134
assert self.num_prompt_logprobs is not None
135
assert self.prompt_logprobs is not None
136
137
- token_ids, logprobs, ranks, _ = prompt_logprobs_tensors
137
+ token_ids, logprobs, ranks, *_ = prompt_logprobs_tensors
138
139
# Recover shapes.
140
num_prompt_tokens, num_logprobs = logprobs.shape
vllm/v1/outputs.py
+21
-4
@@ -98,27 +98,38 @@ class LogprobsTensors(NamedTuple):
98
logprobs: torch.Tensor
99
# [num_reqs x num_generated_tokens]
100
selected_token_ranks: torch.Tensor
101
- # [num_reqs]
101
+ # [num_reqs + 1]
102
cu_num_generated_tokens: list[int] | None = None
103
+ # [num_reqs + 1]. Set instead of cu_num_generated_tokens when the
104
+ # boundaries only exist on device (adaptive verification); rides along on
105
+ # the async D2H copy.
106
+ cu_num_generated_tokens_tensor: torch.Tensor | None = None
107
108
def tolists(self, cu_num_generated_tokens: list[int] | None = None):
109
+ if cu_num_generated_tokens is None:
110
+ if self.cu_num_generated_tokens_tensor is not None:
111
+ cu_num_generated_tokens = self.cu_num_generated_tokens_tensor.tolist()
112
+ else:
113
+ cu_num_generated_tokens = self.cu_num_generated_tokens
114
return LogprobsLists(
115
self.logprob_token_ids.cpu().numpy(),
116
self.logprobs.cpu().numpy(),
117
self.selected_token_ranks.cpu().numpy(),
109
- cu_num_generated_tokens
110
- if cu_num_generated_tokens is not None
111
- else self.cu_num_generated_tokens,
118
+ cu_num_generated_tokens,
119
)
120
121
def to_cpu_nonblocking(self) -> "LogprobsTensors":
122
if self.logprob_token_ids.device.type == "cpu":
123
return self
124
+ cu_tensor = self.cu_num_generated_tokens_tensor
125
+ if cu_tensor is not None:
126
+ cu_tensor = cu_tensor.to("cpu", non_blocking=True)
127
return LogprobsTensors(
128
self.logprob_token_ids.to("cpu", non_blocking=True),
129
self.logprobs.to("cpu", non_blocking=True),
130
self.selected_token_ranks.to("cpu", non_blocking=True),
131
self.cu_num_generated_tokens,
132
+ cu_tensor,
133
)
134
135
def filter(self, mask: torch.Tensor) -> "LogprobsTensors":
@@ -126,6 +137,9 @@ class LogprobsTensors(NamedTuple):
137
assert self.cu_num_generated_tokens is None, (
138
"filter can't be used with cu_num_generated_tokens"
139
)
140
+ assert self.cu_num_generated_tokens_tensor is None, (
141
+ "filter can't be used with cu_num_generated_tokens_tensor"
142
+ )
143
return LogprobsTensors(
144
self.logprob_token_ids[mask],
145
self.logprobs[mask],
@@ -147,6 +161,9 @@ class LogprobsTensors(NamedTuple):
161
if cu_num_generated_tokens is None:
162
return tensor
163
return tensor._replace(cu_num_generated_tokens=cu_num_generated_tokens)
164
+ # The multi-chunk path rebuilds boundaries from the CPU layout, which
165
+ # the device-only boundaries of adaptive verification never use.
166
+ assert all(tensor.cu_num_generated_tokens_tensor is None for tensor in tensors)
167
return LogprobsTensors(
168
logprob_token_ids=torch.cat(
169
[tensor.logprob_token_ids for tensor in tensors]
vllm/v1/worker/gpu/sample/logprob.py
+4
-2
@@ -110,7 +110,7 @@ def compute_topk_scores(
110
logits: torch.Tensor,
111
num_logprobs: int,
112
sampled_token_ids: torch.Tensor,
113
- cu_num_logits: list[int] | None = None,
113
+ cu_num_logits: list[int] | torch.Tensor | None = None,
114
logprob_token_ids_state: "LogprobTokenIdsState | None" = None,
115
expanded_idx_mapping: torch.Tensor | None = None,
116
max_per_req_token_ids: int = 0,
@@ -177,11 +177,13 @@ def compute_topk_scores(
177
vocab_size,
178
BLOCK_SIZE=8192, # type: ignore
179
)
180
+ is_tensor = isinstance(cu_num_logits, torch.Tensor)
181
return LogprobsTensors(
182
logprob_token_ids=logprob_token_ids,
183
logprobs=scores,
184
selected_token_ranks=token_ranks,
184
- cu_num_generated_tokens=cu_num_logits,
185
+ cu_num_generated_tokens=None if is_tensor else cu_num_logits,
186
+ cu_num_generated_tokens_tensor=cu_num_logits if is_tensor else None,
187
)
188
189
vllm/v1/worker/gpu/spec_decode/rejection_sampler.py
+12
-1
@@ -81,6 +81,7 @@ class RejectionSampler:
81
):
82
self.sampler = sampler
83
self.num_speculative_steps = spec_config.num_speculative_tokens
84
+ self.enable_adaptive_verification = spec_config.enable_adaptive_verification
85
rejection_sample_method = spec_config.rejection_sample_method
86
self.use_block_verification: bool = False
87
self.synthetic_conditional_rates: torch.Tensor | None = None
@@ -122,11 +123,20 @@ class RejectionSampler:
123
num_warps=1,
124
)
125
expanded_logits = num_logits != num_reqs
126
+ cu_num_generated_tokens: list[int] | torch.Tensor | None = None
127
+ if expanded_logits:
128
+ if self.enable_adaptive_verification:
129
+ # Adaptive verification keeps the true per-request boundaries
130
+ # on device only; cu_num_logits_np holds the pre-compacted
131
+ # layout.
132
+ cu_num_generated_tokens = cu_num_logits.clone()
133
+ else:
134
+ cu_num_generated_tokens = cu_num_logits_np.tolist()
135
return compute_topk_scores(
136
logits,
137
max_num_logprobs,
138
flat_sampled,
129
- cu_num_logits_np.tolist() if expanded_logits else None,
139
+ cu_num_generated_tokens,
140
logits_mode=self.sampler.logprobs_mode
141
in ("raw_logits", "processed_logits"),
142
)
@@ -191,6 +201,7 @@ class RejectionSampler:
201
# guarantees the compacted batch always lands here.
202
request_chunks: Iterable[tuple[int, int]] = ((0, num_reqs),)
203
else:
204
+ assert not self.enable_adaptive_verification
205
request_chunks = _iter_request_chunks(cu_num_logits_np, max_chunk_logits)
206
207
sampled_chunks: list[torch.Tensor] = []
vllm/v1/worker/gpu_model_runner.py
+1
-1
@@ -5803,7 +5803,7 @@ class GPUModelRunner(
5803
scores = logits.to(torch.float32)
5804
else:
5805
scores = self.sampler.compute_logprobs(logits)
5806
- token_ids, logprobs, ranks, _ = self.sampler.gather_logprobs(
5806
+ token_ids, logprobs, ranks, *_ = self.sampler.gather_logprobs(
5807
scores, num_prompt_logprobs, tgt_token_ids
5808
)
5809