[Model Runner V2] Acceptance estimation for adaptive verification (#52228)

Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>

Giancarlo Delfin committed Sep 14, 2026 at 15:58 UTC 3bb0a03f35269185b6358753e64932bb946cc531
9 files changed +900 -28
tests/v1/spec_decode/test_acceptance_estimator.py new
+294
@@ -0,0 +1,294 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""``predict`` writes one probability into two index spaces at once.
4 +
5 +Adaptive verification reads the estimate in this step's batch order, while the
6 +next step's refit reads it in persistent request-state slots. A mix-up between
7 +the two is silent -- every value stays a plausible probability, it just belongs
8 +to another request -- so it is asserted here rather than left to acceptance
9 +metrics to hint at.
10 +"""
11 +
12 +import pytest
13 +import torch
14 +
15 +from vllm.v1.worker.gpu.spec_decode.acceptance_estimator import (
16 + OnlineAcceptanceEstimator,
17 +)
18 +
19 +pytest.importorskip("triton")
20 +if not torch.cuda.is_available():
21 + pytest.skip("CUDA required for acceptance estimator tests", allow_module_level=True)
22 +
23 +MAX_NUM_REQS = 16
24 +NUM_STEPS = 4
25 +VOCAB_SIZE = 2048
26 +
27 +
28 +def _make_estimator(device: torch.device) -> OnlineAcceptanceEstimator:
29 + estimator = OnlineAcceptanceEstimator(MAX_NUM_REQS, NUM_STEPS, device)
30 + # Distinct per-position intercepts, so reading the wrong one shows up.
31 + estimator.slope.fill_(0.7)
32 + estimator.intercepts[:] = torch.tensor([1.5, 1.0, 0.5, 0.0], device=device)
33 + return estimator
34 +
35 +
36 +def _expected_probs(
37 + estimator: OnlineAcceptanceEstimator,
38 + logits: torch.Tensor,
39 + steps: torch.Tensor,
40 + temperature: torch.Tensor,
41 +) -> torch.Tensor:
42 + """logit(q_max) by a different route than the kernel takes.
43 +
44 + The kernel shifts by the max and drops the unit term; here the argmax is
45 + masked out and the complement is reduced with logsumexp, so agreement is
46 + evidence about the value rather than about a shared formula.
47 + """
48 + temp = torch.where(temperature > 0, temperature, torch.ones_like(temperature))
49 + scaled = logits.double() / temp[:, None].double()
50 + top = scaled.max(dim=-1)
51 + rest = scaled.scatter(1, top.indices[:, None], float("-inf"))
52 + feature = (top.values - torch.logsumexp(rest, dim=-1)).clamp(-40.0, 40.0)
53 + weight, bias = estimator.slope, estimator.intercepts
54 + return torch.sigmoid(weight.double() * feature + bias[steps].double()).float()
55 +
56 +
57 +@pytest.mark.parametrize("tokens_per_req", [1, NUM_STEPS])
58 +def test_predict_agrees_across_slot_and_batch_order(tokens_per_req: int):
59 + """One row per request (per-step drafting) and a whole block at once.
60 +
61 + Which one it is is inferred from whether the draft step is per-row, so the
62 + two cases differ only in how ``draft_step`` is shaped.
63 + """
64 + device = torch.device("cuda")
65 + torch.manual_seed(0)
66 + estimator = _make_estimator(device)
67 + num_reqs = 5
68 +
69 + # Batch position i holds a shuffled persistent slot, as a served batch would.
70 + slots = torch.randperm(MAX_NUM_REQS, device=device)[:num_reqs].to(torch.int32)
71 + if tokens_per_req == 1:
72 + idx_mapping = slots
73 + draft_step = torch.tensor(2, device=device, dtype=torch.int32)
74 + steps = draft_step.expand(num_reqs)
75 + else:
76 + idx_mapping = slots.repeat_interleave(NUM_STEPS)
77 + draft_step = torch.arange(NUM_STEPS, device=device, dtype=torch.int32).repeat(
78 + num_reqs
79 + )
80 + steps = draft_step
81 + batch_rows = torch.arange(num_reqs, device=device).repeat_interleave(tokens_per_req)
82 + logits = torch.randn(idx_mapping.shape[0], VOCAB_SIZE, device=device) * 4.0
83 +
84 + confidence_probs = torch.zeros(MAX_NUM_REQS, NUM_STEPS, device=device)
85 + temperature = torch.rand(MAX_NUM_REQS, device=device) + 0.5
86 + estimator.predict(logits, idx_mapping, draft_step, confidence_probs, temperature)
87 +
88 + expected = _expected_probs(
89 + estimator, logits, steps.long(), temperature[idx_mapping.long()]
90 + )
91 + torch.testing.assert_close(
92 + estimator.predictions[idx_mapping.long(), steps.long()],
93 + expected,
94 + atol=2e-3,
95 + rtol=2e-3,
96 + )
97 + torch.testing.assert_close(
98 + confidence_probs[batch_rows, steps.long()], expected, atol=2e-3, rtol=2e-3
99 + )
100 +
101 +
102 +def test_predict_skips_cudagraph_padded_rows():
103 + """Padded rows carry slot -1 and must not land on a live request."""
104 + device = torch.device("cuda")
105 + torch.manual_seed(0)
106 + estimator = _make_estimator(device)
107 + num_reqs, num_padded = 3, 6
108 +
109 + slots = torch.tensor([7, 2, 11], device=device, dtype=torch.int32)
110 + idx_mapping = torch.cat(
111 + [slots, torch.full((num_padded,), -1, device=device, dtype=torch.int32)]
112 + )
113 + draft_step = torch.tensor(1, device=device, dtype=torch.int32)
114 + logits = torch.randn(num_reqs + num_padded, VOCAB_SIZE, device=device) * 4.0
115 +
116 + confidence_probs = torch.zeros(MAX_NUM_REQS, NUM_STEPS, device=device)
117 + temperature = torch.rand(MAX_NUM_REQS, device=device) + 0.5
118 + estimator.predict(logits, idx_mapping, draft_step, confidence_probs, temperature)
119 +
120 + live = (slots.long(), torch.full((num_reqs,), 1, device=device))
121 + expected = _expected_probs(
122 + estimator, logits[:num_reqs], live[1], temperature[slots.long()]
123 + )
124 + torch.testing.assert_close(
125 + estimator.predictions[live], expected, atol=2e-3, rtol=2e-3
126 + )
127 + # Nothing outside the live (request, position) cells was touched.
128 + untouched = torch.ones(MAX_NUM_REQS, NUM_STEPS, dtype=torch.bool, device=device)
129 + untouched[live] = False
130 + assert not estimator.predictions[untouched].any()
131 + assert not confidence_probs[num_reqs:].any()
132 +
133 +
134 +# A refit takes one undamped Newton step, so it needs a realistic number of
135 +# observations per round to be well conditioned -- a handful of requests can
136 +# send the step far past the optimum. This is the batch a served step sees.
137 +REFIT_NUM_REQS = 512
138 +
139 +
140 +def _drive_round(estimator, features, num_accepted, slots):
141 + """Feed one step's graded drafts through predict's buffers into `step`."""
142 + estimator.features[slots.long()] = features
143 + estimator.predictions[slots.long()] = torch.sigmoid(
144 + estimator.slope * features + estimator.intercepts
145 + )
146 + estimator.step(
147 + slots,
148 + (num_accepted + 1).to(torch.int32),
149 + (features.shape[1] - num_accepted).to(torch.int32),
150 + )
151 +
152 +
153 +def test_refit_recovers_a_shared_slope_and_per_position_intercepts():
154 + """The fit must find the logistic that generated the observations.
155 +
156 + One slope is shared across draft positions and each keeps its own intercept,
157 + so the arrowhead solve is only correct if it recovers both from data whose
158 + positions differ in level but not in slope.
159 + """
160 + device = torch.device("cuda")
161 + torch.manual_seed(0)
162 + true_slope = 0.6
163 + true_bias = torch.tensor([1.4, 0.9, 0.4, -0.1], device=device)
164 +
165 + estimator = OnlineAcceptanceEstimator(REFIT_NUM_REQS, NUM_STEPS, device)
166 + estimator.REFIT_INTERVAL = 5
167 + slots = torch.arange(REFIT_NUM_REQS, device=device, dtype=torch.int32)
168 + for _ in range(60):
169 + features = torch.rand(REFIT_NUM_REQS, NUM_STEPS, device=device) * 8.0
170 + accepted = torch.rand_like(features) < torch.sigmoid(
171 + true_slope * features + true_bias
172 + )
173 + # Verification stops at the first rejection, so a position's label is
174 + # conditional on every shallower one having been accepted.
175 + num_accepted = (~accepted).float().argmax(dim=1)
176 + num_accepted[accepted.all(dim=1)] = NUM_STEPS
177 + _drive_round(estimator, features, num_accepted, slots)
178 +
179 + slope, bias = estimator.slope, estimator.intercepts
180 + torch.testing.assert_close(
181 + slope[0], torch.tensor(true_slope, device=device), atol=0.1, rtol=0
182 + )
183 + torch.testing.assert_close(bias, true_bias, atol=0.3, rtol=0)
184 +
185 +
186 +def test_refit_survives_a_position_with_no_observations():
187 + """A draft position nobody reached must not stall the shared slope.
188 +
189 + Its statistics stay zero while the others accumulate, which is the degenerate
190 + case the arrowhead denominator has to tolerate.
191 + """
192 + device = torch.device("cuda")
193 + torch.manual_seed(0)
194 + estimator = OnlineAcceptanceEstimator(REFIT_NUM_REQS, NUM_STEPS, device)
195 + estimator.REFIT_INTERVAL = 5
196 + slots = torch.arange(REFIT_NUM_REQS, device=device, dtype=torch.int32)
197 + before_slope = estimator.slope.clone()
198 + before_intercepts = estimator.intercepts.clone()
199 +
200 + # Every request is rejected at position 1, so positions 2+ are never graded.
201 + num_accepted = torch.ones(REFIT_NUM_REQS, device=device)
202 + for _ in range(20):
203 + features = torch.rand(REFIT_NUM_REQS, NUM_STEPS, device=device) * 8.0
204 + _drive_round(estimator, features, num_accepted, slots)
205 +
206 + assert estimator.slope.isfinite().all()
207 + assert estimator.intercepts.isfinite().all()
208 + assert not torch.equal(estimator.slope, before_slope), "slope never moved"
209 + # Positions 2 and 3 were never observed, so their intercepts must be untouched.
210 + torch.testing.assert_close(
211 + estimator.intercepts[2:], before_intercepts[2:], rtol=0, atol=0
212 + )
213 +
214 +
215 +def test_step_damping_scales_with_the_evidence():
216 + """A round moves a coefficient in proportion to the samples behind it.
217 +
218 + The Newton step itself barely changes with how much data a round carries --
219 + curvature and score both scale with it -- so scaling a round's statistics
220 + isolates the damping factor n / (n + DAMPING_OBSERVATIONS). The slope/
221 + intercept coupling is zeroed so the two parameters can be checked apart.
222 + """
223 + device = torch.device("cuda")
224 + damping = OnlineAcceptanceEstimator.DAMPING_OBSERVATIONS
225 + per_position_n = 20.0
226 +
227 + def refit_with(scale):
228 + estimator = OnlineAcceptanceEstimator(MAX_NUM_REQS, NUM_STEPS, device)
229 + estimator.info[:, 1] = 0.0 # sum(w*margin): no slope/intercept coupling
230 + estimator.info[:, 2] = 1.0 * scale # sum(w)
231 + estimator.grad[:, 1] = 0.5 * scale # sum(y - p)
232 + # The slope's information and score are accumulated as per-position
233 + # partials that the refit sums, so spread each total over the positions.
234 + estimator.info[:, 0] = 30.0 * scale / NUM_STEPS # sum(w*margin^2)
235 + estimator.grad[:, 0] = 2.0 * scale / NUM_STEPS # sum((y - p)*margin)
236 + estimator.counts[:] = per_position_n * scale
237 + estimator._steps_since_refit = estimator.REFIT_INTERVAL - 1
238 + before_slope = estimator.slope.clone()
239 + before_intercepts = estimator.intercepts.clone()
240 + empty = torch.zeros(0, dtype=torch.int32, device=device)
241 + estimator.step(empty, empty, empty)
242 + return (
243 + (estimator.slope - before_slope)[0].item(),
244 + (estimator.intercepts - before_intercepts)[0].item(),
245 + )
246 +
247 + for scale in (1.0, 10.0, 100.0):
248 + total_n = per_position_n * scale * NUM_STEPS
249 + slope_delta, bias_delta = refit_with(scale)
250 + assert slope_delta == pytest.approx(
251 + (2.0 / 30.0) * total_n / (total_n + damping), rel=1e-3
252 + )
253 + assert bias_delta == pytest.approx(
254 + 0.5 * (per_position_n * scale) / (per_position_n * scale + damping),
255 + rel=1e-3,
256 + )
257 +
258 + # A round with no observations at all must leave the coefficients alone.
259 + assert refit_with(0.0) == (0.0, 0.0)
260 +
261 +
262 +def test_feature_ignores_which_token_was_drawn():
263 + """The score must depend on the draft distribution, not on the draw.
264 +
265 + This is what keeps rejection sampling lossless: if the score read the drawn
266 + token, trimming a draft would condition on the token being trimmed and tilt
267 + the emitted distribution toward the drafter's confident modes. ``predict``
268 + takes no token argument, and this pins that -- two rows with identical
269 + logits must score identically no matter what was sampled from them.
270 + """
271 + device = torch.device("cuda")
272 + torch.manual_seed(0)
273 + estimator = _make_estimator(device)
274 +
275 + row = torch.randn(VOCAB_SIZE, device=device) * 4.0
276 + logits = row.expand(2, VOCAB_SIZE).contiguous()
277 + idx_mapping = torch.tensor([0, 1], device=device, dtype=torch.int32)
278 + draft_step = torch.tensor(0, device=device, dtype=torch.int32)
279 + temperature = torch.ones(MAX_NUM_REQS, device=device)
280 + confidence_probs = torch.zeros(MAX_NUM_REQS, NUM_STEPS, device=device)
281 +
282 + estimator.predict(logits, idx_mapping, draft_step, confidence_probs, temperature)
283 + assert estimator.features[0, 0] == estimator.features[1, 0]
284 +
285 + # And the value is the top token's log-odds, not the top-2 margin.
286 + scaled = row.double()
287 + top = scaled.max(dim=-1)
288 + rest = scaled.scatter(0, top.indices[None], float("-inf"))
289 + torch.testing.assert_close(
290 + estimator.features[0, 0].double(),
291 + (top.values - torch.logsumexp(rest, dim=-1)).clamp(-40.0, 40.0),
292 + atol=2e-3,
293 + rtol=2e-3,
294 + )
tests/v1/worker/test_gpu_autoregressive_speculator.py
+5
@@ -123,6 +123,7 @@ def test_speculator_uses_draft_model_hidden_size(monkeypatch, hc_mult, expected)
123 draft_model_config=draft_model_config,
124 use_local_argmax_reduction=False,
125 draft_sample_method="greedy",
126 + enable_adaptive_verification=False,
127 )
128 vllm_config = SimpleNamespace(
129 speculative_config=speculative_config,
@@ -161,6 +162,7 @@ def test_mm_support_configured_after_model_load(monkeypatch):
162 speculator.dtype = torch.float32
163 speculator.draft_model_config = draft_model_config
164 speculator.supports_mm_inputs = False
165 + speculator.use_acceptance_estimator = False
166
167 checked_configs = []
168
@@ -195,6 +197,7 @@ def test_load_model_keeps_mm_support_for_capable_drafter(monkeypatch):
197 speculator = object.__new__(_TestSpeculator)
198 speculator.supports_mm_inputs = False
199 speculator.inputs_embeds = None
200 + speculator.use_acceptance_estimator = False
201 speculator.vllm_config = SimpleNamespace(model_config=object())
202 speculator.max_num_tokens = 4
203 speculator.hidden_size = 3
@@ -219,6 +222,7 @@ def test_load_model_disables_mm_support_for_text_only_drafter(monkeypatch):
222 speculator = object.__new__(_TestSpeculator)
223 speculator.supports_mm_inputs = False
224 speculator.inputs_embeds = None
225 + speculator.use_acceptance_estimator = False
226 speculator.vllm_config = SimpleNamespace(model_config=object())
227 draft_model = _TextOnlyDraftModel()
228 speculator.test_draft_model = draft_model
@@ -251,6 +255,7 @@ def test_multi_module_mm_support_configured_after_model_load(monkeypatch):
255 speculator = object.__new__(MultiModuleMTPSpeculator)
256 speculator.supports_mm_inputs = False
257 speculator.inputs_embeds = None
258 + speculator.use_acceptance_estimator = False
259 speculator.cached_draft_input_embeds = None
260 speculator.vllm_config = SimpleNamespace(model_config=object())
261 speculator.max_num_tokens = 4
tests/v1/worker/test_gpu_extract_hidden_states_speculator.py
+2
@@ -60,6 +60,7 @@ def test_propose_caches_hidden_states_and_returns_sampled_tokens(monkeypatch):
60 layer_name = "cache_only_layers.2"
61 speculator = object.__new__(ExtractHiddenStatesSpeculator)
62 speculator.vllm_config = cast(Any, SimpleNamespace())
63 + speculator.acceptance_estimator = None
64 speculator.num_hidden_states = 2
65 speculator.hidden_states = torch.zeros(4, 2, 3)
66 speculator.draft_attn_layer_names = {layer_name}
@@ -111,6 +112,7 @@ def test_propose_caches_hidden_states_and_returns_sampled_tokens(monkeypatch):
112
113 def test_propose_requires_aux_hidden_states():
114 speculator = object.__new__(ExtractHiddenStatesSpeculator)
115 + speculator.acceptance_estimator = None
116 speculator.num_hidden_states = 2
117 input_batch = cast(
118 Any, SimpleNamespace(idx_mapping=torch.tensor([0], dtype=torch.int32))
vllm/config/speculative.py
+10 -2
@@ -1561,8 +1561,16 @@ class SpeculativeConfig:
1561 self.index_share_for_mtp_iteration
1562 )
1563
1564 - if self.method != "dspark" and self.enable_adaptive_verification:
1565 - raise ValueError("Adaptive verification only supported with DSpark")
1564 + if (
1565 + self.method != "dspark"
1566 + and self.enable_adaptive_verification
1567 + and self.use_local_argmax_reduction
1568 + ):
1569 + raise ValueError(
1570 + "Adaptive verification estimates per-position acceptance from "
1571 + "the draft logits, which use_local_argmax_reduction never "
1572 + "materializes. Disable one of them."
1573 + )
1574
1575 return self
1576
vllm/v1/worker/gpu/model_runner.py
+4
@@ -2120,6 +2120,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2120
2121 if self.speculator is not None:
2122 assert self.sampler is not None
2123 + if isinstance(self.speculator, DraftModelSpeculator):
2124 + self.speculator.observe_verification(
2125 + input_batch.idx_mapping, num_sampled, num_rejected
2126 + )
2127 # Let the target override the hidden state fed to the drafter
2128 # (e.g. DeepSeek V4 MTP needs the pre-hc_head residual). The
2129 # target returns a persistent buffer sized at max_num_batched_tokens;
vllm/v1/worker/gpu/spec_decode/acceptance_estimator.py new
+501
@@ -0,0 +1,501 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import torch
5 +
6 +from vllm.triton_utils import tl, triton
7 +from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import (
8 + _compute_max_and_sumexp,
9 +)
10 +
11 +# The feature is a log-odds, log(q / (1 - q)), which a row carrying all of its
12 +# mass on one token sends to infinity. Clamp it symmetrically.
13 +_MAX_LOG_ODDS = 40.0
14 +
15 +# Cold-start coefficients: the median slope and intercept fitted online across
16 +# DeepSeek-V4-Flash, Muse-Glimmer-30B, MiMo-V2.5-Pro, Inkling and Kimi-K2.5.
17 +# Log-loss is flat near the optimum, so one starting point serves all of them
18 +# and a real fit replaces it within a refit or two.
19 +_INIT_SLOPE = 0.5
20 +_INIT_BIAS = -0.2
21 +
22 +
23 +@triton.jit
24 +def _accumulate_kernel(
25 + info_ptr,
26 + grad_ptr,
27 + idx_mapping_ptr,
28 + num_sampled_ptr,
29 + num_rejected_ptr,
30 + features_ptr,
31 + features_stride,
32 + pred_ptr,
33 + pred_stride,
34 + counts_ptr,
35 + num_reqs,
36 + BLOCK_R: tl.constexpr,
37 +):
38 + step = tl.program_id(0).to(tl.int64)
39 + req_block = tl.arange(0, BLOCK_R)
40 + req_mask = req_block < num_reqs
41 + req_state_block = tl.load(idx_mapping_ptr + req_block, mask=req_mask, other=0).to(
42 + tl.int64
43 + )
44 +
45 + num_sampled = tl.load(num_sampled_ptr + req_block, mask=req_mask, other=0).to(
46 + tl.int64
47 + )
48 + # Subtract bonus token.
49 + num_accepted = tl.maximum(num_sampled - 1, 0)
50 + num_rejected = tl.load(num_rejected_ptr + req_block, mask=req_mask, other=0).to(
51 + tl.int64
52 + )
53 + num_admitted = num_accepted + num_rejected
54 +
55 + feature = tl.load(
56 + features_ptr + req_state_block * features_stride + step,
57 + mask=req_mask,
58 + other=0.0,
59 + ).to(tl.float32)
60 + pred = tl.load(
61 + pred_ptr + req_state_block * pred_stride + step,
62 + mask=req_mask,
63 + other=0.0,
64 + ).to(tl.float32)
65 +
66 + # The accepted tokens and the first rejected draft token (if one exists) are
67 + # "observed" and given labels.
68 + observed = req_mask & (step <= num_accepted) & (step < num_admitted)
69 + label = tl.where(step < num_accepted, 1.0, 0.0)
70 + w = tl.where(observed, pred * (1.0 - pred), 0.0)
71 + resid = tl.where(observed, label - pred, 0.0)
72 +
73 + # This block's contribution to the normal equations for design row
74 + # x = [feature, eₖ]: the XᵀWX entries aₖ, bₖ, cₖ and the Xᵀr entries g₀ₖ, g₁ₖ.
75 + # aₖ and g₀ₖ are partials of scalars shared by every position, which
76 + # _refit_kernel sums.
77 + a_k = tl.sum(w * feature * feature, axis=0)
78 + b_k = tl.sum(w * feature, axis=0)
79 + c_k = tl.sum(w, axis=0)
80 + g0_k = tl.sum(resid * feature, axis=0)
81 + g1_k = tl.sum(resid, axis=0)
82 + count = tl.sum(tl.where(observed, 1.0, 0.0), axis=0)
83 + tl.store(info_ptr + step * 3 + 0, tl.load(info_ptr + step * 3 + 0) + a_k)
84 + tl.store(info_ptr + step * 3 + 1, tl.load(info_ptr + step * 3 + 1) + b_k)
85 + tl.store(info_ptr + step * 3 + 2, tl.load(info_ptr + step * 3 + 2) + c_k)
86 + tl.store(grad_ptr + step * 2 + 0, tl.load(grad_ptr + step * 2 + 0) + g0_k)
87 + tl.store(grad_ptr + step * 2 + 1, tl.load(grad_ptr + step * 2 + 1) + g1_k)
88 + tl.store(counts_ptr + step, tl.load(counts_ptr + step) + count)
89 +
90 +
91 +@triton.jit
92 +def _refit_kernel(
93 + slope_ptr,
94 + intercepts_ptr,
95 + info_ptr,
96 + info_row_stride,
97 + grad_ptr,
98 + grad_row_stride,
99 + counts_ptr,
100 + NUM_SPECULATIVE_STEPS: tl.constexpr,
101 + L2: tl.constexpr,
102 + DAMPING: tl.constexpr,
103 + BLOCK: tl.constexpr,
104 +):
105 + k = tl.arange(0, BLOCK)
106 + mask = k < NUM_SPECULATIVE_STEPS
107 +
108 + # Load the stats accumulated over this round to solve (XᵀWX + λI) Δθ = Xᵀr.
109 + a_k = tl.load(info_ptr + k * info_row_stride + 0, mask=mask, other=0.0)
110 + b_k = tl.load(info_ptr + k * info_row_stride + 1, mask=mask, other=0.0)
111 + c_k = tl.load(info_ptr + k * info_row_stride + 2, mask=mask, other=0.0) + L2
112 + a = tl.sum(a_k, axis=0) + L2
113 + g0_k = tl.load(grad_ptr + k * grad_row_stride + 0, mask=mask, other=0.0)
114 + g1_k = tl.load(grad_ptr + k * grad_row_stride + 1, mask=mask, other=0.0)
115 + g0 = tl.sum(g0_k, axis=0)
116 + n = tl.load(counts_ptr + k, mask=mask, other=0.0)
117 +
118 + # Load the current shared slope and per-position intercepts.
119 + w = tl.load(slope_ptr)
120 + bias = tl.load(intercepts_ptr + k, mask=mask, other=0.0)
121 +
122 + # Solve for the shared slope delta, Δω, in the following arrowhead system:
123 + # ⎡ a+λ b₀ b₁ … bₙ₋₁ ⎤ ⎡ Δω ⎤ ⎡ g₀ ⎤
124 + # ⎢ b₀ c₀+λ ⎥ ⎢ Δβ₀ ⎥ ⎢ g₁₀ ⎥
125 + # ⎢ b₁ c₁+λ ⎥ ⎢ Δβ₁ ⎥ = ⎢ g₁₁ ⎥
126 + # ⎢ ⋮ ⋱ ⎥ ⎢ ⋮ ⎥ ⎢ ⋮ ⎥
127 + # ⎣ bₙ₋₁ cₙ₋₁+λ ⎦ ⎣ Δβₙ₋₁ ⎦ ⎣ g₁ₙ₋₁ ⎦
128 + # XᵀWX + λI Δθ Xᵀ(Y-P)
129 + shrink = b_k * b_k / c_k
130 + coupling = b_k * g1_k / c_k
131 + step_w = (g0 - tl.sum(coupling, axis=0)) / (a - tl.sum(shrink, axis=0))
132 +
133 + # The slope learns from every position, so it is damped by the round's
134 + # total sample count.
135 + total_n = tl.sum(n, axis=0)
136 + step_w *= total_n / (total_n + DAMPING)
137 + # Mask out NaNs and steps that would drive the slope negative.
138 + step_w = tl.where((step_w == step_w) & (w + step_w >= 0.0), step_w, 0.0)
139 + # Update the shared slope.
140 + new_w = w + step_w
141 +
142 + # Solve for the per-position intercept deltas, Δβₖ, in the same system.
143 + step_bias = (g1_k - b_k * step_w) / c_k
144 + # Damp by the position's sample count.
145 + step_bias *= n / (n + DAMPING)
146 + # Mask out NaNs.
147 + step_bias = tl.where(step_bias == step_bias, step_bias, 0.0)
148 + # Update the per-position intercepts.
149 + new_bias = bias + step_bias
150 +
151 + tl.store(slope_ptr, new_w)
152 + tl.store(intercepts_ptr + k, new_bias, mask=mask)
153 +
154 + # Start the round clean by clearing the per-round statistics.
155 + tl.store(info_ptr + k * info_row_stride + 0, 0.0, mask=mask)
156 + tl.store(info_ptr + k * info_row_stride + 1, 0.0, mask=mask)
157 + tl.store(info_ptr + k * info_row_stride + 2, 0.0, mask=mask)
158 + tl.store(grad_ptr + k * grad_row_stride + 0, 0.0, mask=mask)
159 + tl.store(grad_ptr + k * grad_row_stride + 1, 0.0, mask=mask)
160 + tl.store(counts_ptr + k, 0.0, mask=mask)
161 +
162 +
163 +@triton.jit
164 +def _local_max_sumexp_kernel(
165 + local_max_ptr,
166 + local_max_stride,
167 + local_sumexp_ptr,
168 + local_sumexp_stride,
169 + logits_ptr,
170 + logits_stride,
171 + idx_mapping_ptr,
172 + idx_mapping_stride,
173 + temperature_ptr,
174 + num_tokens,
175 + vocab_size,
176 + BLOCK_SIZE: tl.constexpr,
177 +):
178 + token_idx = tl.program_id(0).to(tl.int64)
179 + if token_idx >= num_tokens:
180 + return
181 +
182 + block_idx = tl.program_id(1)
183 + req_state_idx = tl.load(idx_mapping_ptr + token_idx * idx_mapping_stride).to(
184 + tl.int64
185 + )
186 + if req_state_idx < 0:
187 + # Cudagraph-padded requests carry -1. Write an empty partial so the
188 + # buffer holds nothing uninitialized; _predict_kernel skips these rows.
189 + tl.store(
190 + local_max_ptr + token_idx * local_max_stride + block_idx, float("-inf")
191 + )
192 + tl.store(local_sumexp_ptr + token_idx * local_sumexp_stride + block_idx, 0.0)
193 + return
194 +
195 + # Draft logits are pre-temperature, and acceptance is decided on the scaled
196 + # distribution the drafter actually sampled from, so scale first. A request
197 + # with temp == 0 drafts greedily, so leave its logits alone rather than dividing
198 + # by zero, matching what the sampler does.
199 + temp = tl.load(temperature_ptr + req_state_idx).to(tl.float32)
200 + temp = tl.where(temp > 0.0, temp, 1.0)
201 +
202 + offsets = block_idx * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
203 + mask = offsets < vocab_size
204 + logits = (
205 + tl.load(
206 + logits_ptr + token_idx * logits_stride + offsets,
207 + mask=mask,
208 + other=float("-inf"),
209 + ).to(tl.float32)
210 + / temp
211 + )
212 + block_max, block_sumexp = _compute_max_and_sumexp(logits)
213 + tl.store(local_max_ptr + token_idx * local_max_stride + block_idx, block_max)
214 + tl.store(
215 + local_sumexp_ptr + token_idx * local_sumexp_stride + block_idx, block_sumexp
216 + )
217 +
218 +
219 +@triton.jit
220 +def _predict_kernel(
221 + features_ptr,
222 + features_stride,
223 + pred_ptr,
224 + pred_stride,
225 + conf_ptr,
226 + conf_stride,
227 + slope_ptr,
228 + intercepts_ptr,
229 + local_max_ptr,
230 + local_max_stride,
231 + local_sumexp_ptr,
232 + local_sumexp_stride,
233 + idx_mapping_ptr,
234 + idx_mapping_stride,
235 + step_ptr,
236 + num_tokens,
237 + vocab_num_blocks,
238 + per_token_step: tl.constexpr,
239 + NUM_SPECULATIVE_STEPS: tl.constexpr,
240 + MAX_LOG_ODDS: tl.constexpr,
241 + PADDED_VOCAB_NUM_BLOCKS: tl.constexpr,
242 +):
243 + token_idx = tl.program_id(0).to(tl.int64)
244 + if token_idx >= num_tokens:
245 + return
246 +
247 + req_state_idx = tl.load(idx_mapping_ptr + token_idx * idx_mapping_stride).to(
248 + tl.int64
249 + )
250 + if req_state_idx < 0:
251 + # Cudagraph-padded requests carry -1. Skip them so that they don't
252 + # scatter garbage over a live request's features.
253 + return
254 +
255 + # A per-row step means the speculator sampled the whole block in one pass, so
256 + # each request owns NUM_SPECULATIVE_STEPS consecutive rows; a step shared by
257 + # every row means one row per request. Batch position, unlike req_state_idx,
258 + # is only meaningful for the step that computed it, and is what adaptive
259 + # verification indexes by.
260 + if per_token_step:
261 + step = tl.load(step_ptr + token_idx).to(tl.int64)
262 + batch_idx = token_idx // NUM_SPECULATIVE_STEPS
263 + else:
264 + step = tl.load(step_ptr).to(tl.int64)
265 + batch_idx = token_idx
266 +
267 + blocks = tl.arange(0, PADDED_VOCAB_NUM_BLOCKS)
268 + blocks_mask = blocks < vocab_num_blocks
269 + maxes = tl.load(
270 + local_max_ptr + token_idx * local_max_stride + blocks,
271 + mask=blocks_mask,
272 + other=float("-inf"),
273 + )
274 + sumexps = tl.load(
275 + local_sumexp_ptr + token_idx * local_sumexp_stride + blocks,
276 + mask=blocks_mask,
277 + other=0.0,
278 + )
279 +
280 + # The feature, logit(q(m*)) for the row's max draft logit, m*, is:
281 + # q(x) = e^(x - m*) / Σ_z e^(z - m*)
282 + # logit(q(m*)) = log(q(m*) / (1 - q(m*)))
283 + # = m* - log(Σ_{z≠m*} e^(z - m*)).
284 + # It scores the draft distribution, not the token drawn from it, so it is
285 + # a function of the prefix alone. Trimming a draft therefore never
286 + # conditions on the token being trimmed, and rejection sampling stays
287 + # lossless. Scoring the drawn token instead would select on the proposal
288 + # and bias the emitted distribution toward the drafter's confident modes.
289 +
290 + # Rescale each block's partial to the global max and sum. The most likely
291 + # token contributes exactly e^(m - m) = 1, so removing it leaves the sum
292 + # over every other token.
293 + global_max = tl.max(maxes, axis=0)
294 + total_sumexp = tl.sum(
295 + tl.where(blocks_mask, sumexps * tl.exp(maxes - global_max), 0.0), axis=0
296 + )
297 + complement_sumexp = tl.maximum(total_sumexp - 1.0, 0.0)
298 +
299 + # Shifted by m*, the numerator is e^(m* - m*) = 1, so the feature is just the
300 + # negated complement log-sum-exponential. Clamp to the min/max log-odds.
301 + feature = -tl.log(complement_sumexp)
302 + feature = tl.minimum(tl.maximum(feature, -MAX_LOG_ODDS), MAX_LOG_ODDS)
303 + tl.store(features_ptr + req_state_idx * features_stride + step, feature)
304 +
305 + # Predict the acceptance probability from the feature and current coefficients.
306 + weight = tl.load(slope_ptr)
307 + bias = tl.load(intercepts_ptr + step)
308 + prob = tl.sigmoid(weight * feature + bias)
309 + tl.store(pred_ptr + req_state_idx * pred_stride + step, prob)
310 + tl.store(conf_ptr + batch_idx * conf_stride + step, prob)
311 +
312 +
313 +class OnlineAcceptanceEstimator:
314 + """Predicts per-position acceptance, and calibrates itself while serving.
315 +
316 + Lifecycle per step, driven by ``DraftModelSpeculator``:
317 +
318 + 1. ``step`` folds the previous step's drafts, now graded by the target,
319 + into the IRLS accumulators, and periodically solves for new coefficients,
320 + independently but identically on every rank.
321 + 2. ``predict`` runs inside the captured draft graph, turning this step's
322 + draft logits into acceptance probabilities for adaptive verification.
323 +
324 + Trimming starts immediately: survival is a running product of sigmoids, so it
325 + decreases with draft position whatever the coefficients are, and an unfitted
326 + estimator degrades to uniform-depth truncation rather than to anything harmful.
327 + """
328 +
329 + # Refit every this many steps, accumulating samples in between.
330 + REFIT_INTERVAL = 100
331 + # Newton steps are damped by n / (n + DAMPING_OBSERVATIONS), so a round
332 + # carrying this many observations moves a parameter half of a full step. It
333 + # replaces a hard minimum-sample gate: a data-poor position keeps learning,
334 + # just slowly, instead of freezing until it crosses a threshold. Lower values
335 + # track a drifting workload faster, higher ones are steadier on thin data.
336 + DAMPING_OBSERVATIONS = 50.0
337 + # Ridge on the Newton solve.
338 + L2 = 1e-3
339 +
340 + def __init__(
341 + self,
342 + max_num_reqs: int,
343 + num_speculative_steps: int,
344 + device: torch.device,
345 + ):
346 + self.num_speculative_steps = num_speculative_steps
347 + self.device = device
348 + self._steps_since_refit = 0
349 + self._refits = 0
350 +
351 + # The learned coefficients used for predicting acceptance.
352 + self.slope = torch.full((1,), _INIT_SLOPE, dtype=torch.float32, device=device)
353 + self.intercepts = torch.full(
354 + (num_speculative_steps,), _INIT_BIAS, dtype=torch.float32, device=device
355 + )
356 + # Holds logit(max q), which is used as the feature for the logistic.
357 + # Stored in stable slots keyed by persistent request-state index.
358 + self.features = torch.zeros(
359 + max_num_reqs, num_speculative_steps, dtype=torch.float32, device=device
360 + )
361 + # Holds the acceptance predictions made at draft time, computed as:
362 + # p = σ(ω * f + βₖ)
363 + # where f is feature (logit(max q)) at draft position k, ω is the learned
364 + # shared slope, and βₖ is the learned per-position intercept. Stored in
365 + # stable slots keyed by the persistent request-state index.
366 + self.predictions = torch.zeros(
367 + max_num_reqs, num_speculative_steps, dtype=torch.float32, device=device
368 + )
369 +
370 + # Per-round Newton-IRLS statistics, cleared after each refit. For observation
371 + # i the design row is xᵢ = [fᵢ, eₖ], the IRLS weight is wᵢ = pᵢ(1-pᵢ), and the
372 + # residual is yᵢ - pᵢ, where yᵢ is the label (1 for accepted, 0 for rejected).
373 + # XᵀWX is then the negative Hessian of the Bernoulli log-likelihood loss and
374 + # Xᵀ(Y-P) is its gradient, so the Newton step for θ = [ω, β₀ … βₙ₋₁] is the
375 + # the solution of (XᵀWX + λI) Δθ = Xᵀ(Y-P).
376 + #
377 + # Columns [aₖ, bₖ, cₖ] are each position's contribution to the XᵀWX arrowhead:
378 + # ⎡ a b₀ b₁ … bₙ₋₁ ⎤
379 + # ⎢ b₀ c₀ ⎥
380 + # ⎢ b₁ c₁ ⎥
381 + # ⎢ ⋮ ⋱ ⎥
382 + # ⎣ bₙ₋₁ cₙ₋₁ ⎦
383 + # bₖ and cₖ are per-position, but a = Σₖ aₖ is one scalar shared by all of
384 + # them.
385 + self.info = torch.zeros(
386 + num_speculative_steps, 3, dtype=torch.float32, device=device
387 + )
388 + # Columns [g₀ₖ, g₁ₖ] are each position's contribution to the Xᵀ(Y-P) vector:
389 + # ⎡ g₀ ⎤
390 + # ⎢ g₁₀ ⎥
391 + # ⎢ g₁₁ ⎥
392 + # ⎢ ⋮ ⎥
393 + # ⎣ g₁ₙ₋₁ ⎦
394 + # As above, g₁ₖ is per-position and g₀ = Σₖ g₀ₖ is shared.
395 + self.grad = torch.zeros(
396 + num_speculative_steps, 2, dtype=torch.float32, device=device
397 + )
398 + # Number of graded drafts per-position in the current round, zeroed after
399 + # each refit. _refit_kernel uses this to damp coefficient updates by:
400 + # n / (n + DAMPING_OBSERVATIONS)
401 + self.counts = torch.zeros(
402 + num_speculative_steps, dtype=torch.float32, device=device
403 + )
404 +
405 + def step(
406 + self,
407 + idx_mapping: torch.Tensor,
408 + num_sampled: torch.Tensor,
409 + num_rejected: torch.Tensor,
410 + ) -> None:
411 + # Accumulate the previous step's graded drafts into the IRLS statistics.
412 + num_reqs = idx_mapping.shape[0]
413 + _accumulate_kernel[(self.num_speculative_steps,)](
414 + self.info,
415 + self.grad,
416 + idx_mapping,
417 + num_sampled,
418 + num_rejected,
419 + self.features,
420 + self.features.stride(0),
421 + self.predictions,
422 + self.predictions.stride(0),
423 + self.counts,
424 + num_reqs,
425 + BLOCK_R=triton.next_power_of_2(max(num_reqs, 1)),
426 + )
427 +
428 + self._steps_since_refit += 1
429 + if self._steps_since_refit < self.REFIT_INTERVAL:
430 + return
431 + self._steps_since_refit = 0
432 +
433 + # Fit the coefficients to the accumulated statistics gathered over the
434 + # course of the last REFIT_INTERVAL steps.
435 + _refit_kernel[(1,)](
436 + self.slope,
437 + self.intercepts,
438 + self.info,
439 + self.info.stride(0),
440 + self.grad,
441 + self.grad.stride(0),
442 + self.counts,
443 + NUM_SPECULATIVE_STEPS=self.num_speculative_steps,
444 + L2=self.L2,
445 + DAMPING=self.DAMPING_OBSERVATIONS,
446 + BLOCK=triton.next_power_of_2(self.num_speculative_steps),
447 + )
448 + self._refits += 1
449 +
450 + def predict(
451 + self,
452 + logits: torch.Tensor,
453 + idx_mapping: torch.Tensor,
454 + draft_step: torch.Tensor,
455 + confidence_probs: torch.Tensor,
456 + temperature: torch.Tensor,
457 + ) -> None:
458 + num_tokens, vocab_size = logits.shape
459 + VOCAB_BLOCK_SIZE = 4096
460 + num_blocks = triton.cdiv(vocab_size, VOCAB_BLOCK_SIZE)
461 + local_max = torch.empty(
462 + num_tokens, num_blocks, dtype=torch.float32, device=self.device
463 + )
464 + local_sumexp = torch.empty_like(local_max)
465 + _local_max_sumexp_kernel[(num_tokens, num_blocks)](
466 + local_max,
467 + local_max.stride(0),
468 + local_sumexp,
469 + local_sumexp.stride(0),
470 + logits,
471 + logits.stride(0),
472 + idx_mapping,
473 + idx_mapping.stride(0),
474 + temperature,
475 + num_tokens,
476 + vocab_size,
477 + BLOCK_SIZE=VOCAB_BLOCK_SIZE,
478 + )
479 + _predict_kernel[(num_tokens,)](
480 + self.features,
481 + self.features.stride(0),
482 + self.predictions,
483 + self.predictions.stride(0),
484 + confidence_probs,
485 + confidence_probs.stride(0),
486 + self.slope,
487 + self.intercepts,
488 + local_max,
489 + local_max.stride(0),
490 + local_sumexp,
491 + local_sumexp.stride(0),
492 + idx_mapping,
493 + idx_mapping.stride(0),
494 + draft_step,
495 + num_tokens,
496 + num_blocks,
497 + per_token_step=draft_step.dim() > 0,
498 + NUM_SPECULATIVE_STEPS=self.num_speculative_steps,
499 + MAX_LOG_ODDS=_MAX_LOG_ODDS,
500 + PADDED_VOCAB_NUM_BLOCKS=triton.next_power_of_2(num_blocks),
501 + )
vllm/v1/worker/gpu/spec_decode/dflash2/speculator.py
+6
@@ -213,5 +213,11 @@ class DFlash2Speculator(DFlashSpeculator):
213 anchor_token_ids,
214 )
215 self._sample_path(candidate_ids, scores, num_reqs)
216 + if self.enable_adaptive_verification:
217 + self._maybe_predict_acceptance(
218 + self._selector_scores[:num_reqs].flatten(0, 1),
219 + self.sample_idx_mapping[:num_sample],
220 + self.sample_col[:num_sample],
221 + )
222 if self.draft_logits is not None:
223 self._cache_draft_logits(candidate_ids, num_sample)
vllm/v1/worker/gpu/spec_decode/dspark/speculator.py
+16 -18
@@ -79,12 +79,7 @@ class DSparkSpeculator(DFlashSpeculator):
79 self.draft_model_config.hf_config, "dspark_draft_topk", None
80 )
81
82 - self.draft_token_confidence_probs = torch.empty_like(
83 - self.draft_tokens, dtype=torch.float32
84 - )
85 - self.enable_adaptive_verification = (
86 - self.speculative_config.enable_adaptive_verification
87 - )
82 + self.use_confidence_head: bool = False
83
84 def load_draft_model(
85 self,
@@ -108,13 +103,14 @@ class DSparkSpeculator(DFlashSpeculator):
103 dtype=self.draft_logits.dtype,
104 device=self.device,
105 )
111 - if self.enable_adaptive_verification and model.model.confidence_head is None:
112 - raise ValueError(
113 - "Adaptive verification needs a DSpark checkpoint with a confidence "
114 - "head, and this one has none. Pass "
115 - "enable_adaptive_verification=false in the speculative config to verify"
116 - " a fixed number of drafts instead."
117 - )
106 + self.use_confidence_head = (
107 + self.enable_adaptive_verification
108 + and model.model.confidence_head is not None
109 + )
110 + if self.use_confidence_head:
111 + # The acceptance estimator is not needed when a trained confidence head
112 + # is available.
113 + self.use_acceptance_estimator = False
114 return model
115
116 def _sample_logits(
@@ -124,8 +120,10 @@ class DSparkSpeculator(DFlashSpeculator):
120 sample_pos: torch.Tensor,
121 step: int,
122 ) -> torch.Tensor:
123 + self._maybe_predict_acceptance(logits, idx_map, self._step_cols[step])
124 if self.draft_logits is None:
128 - return self.model.map_draft_to_target(logits.argmax(dim=-1))
125 + draft_ids = logits.argmax(dim=-1)
126 + return self.model.map_draft_to_target(draft_ids)
127
128 # Probabilistic sampling and rejection operate in target-vocabulary
129 # space. A reduced draft vocabulary is scattered into its target rows.
@@ -184,7 +182,7 @@ class DSparkSpeculator(DFlashSpeculator):
182 for i in range(n_spec):
183 # Sequential stage: Markov bias from the previously sampled token.
184 markov_embed = self.model.markov_embed(prev)
187 - if self.enable_adaptive_verification:
185 + if self.use_confidence_head:
186 confidence_markov_embeds.append(markov_embed)
187 bias = self.model.markov_bias(markov_embed)
188 logits_i = base_logits[:, i] + bias
@@ -194,7 +192,7 @@ class DSparkSpeculator(DFlashSpeculator):
192 self.draft_tokens[:num_reqs, i] = draft_sampled_i
193 prev = draft_sampled_i
194
197 - if self.enable_adaptive_verification:
195 + if self.use_confidence_head:
196 confidence = self.model.compute_confidence(
197 sample_hidden,
198 torch.stack(confidence_markov_embeds, dim=1).flatten(0, 1),
@@ -229,7 +227,7 @@ class DSparkSpeculator(DFlashSpeculator):
227
228 for i in range(n_spec):
229 markov_embed = self.model.markov_embed(prev)
232 - if self.enable_adaptive_verification:
230 + if self.use_confidence_head:
231 confidence_markov_embeds.append(markov_embed)
232 logits_i = self.model.apply_markov_bias_gathered(
233 markov_embed,
@@ -243,7 +241,7 @@ class DSparkSpeculator(DFlashSpeculator):
241 self.draft_tokens[:num_reqs, i] = draft_sampled_i
242 prev = draft_sampled_i
243
246 - if self.enable_adaptive_verification:
244 + if self.use_confidence_head:
245 confidence = self.model.compute_confidence(
246 sample_hidden,
247 torch.stack(confidence_markov_embeds, dim=1).flatten(0, 1),
vllm/v1/worker/gpu/spec_decode/speculator.py
+62 -8
@@ -33,6 +33,9 @@ from vllm.v1.worker.gpu.dp_utils import DPSyncState
33 from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
34 from vllm.v1.worker.gpu.model_states.interface import ModelState
35 from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
36 +from vllm.v1.worker.gpu.spec_decode.acceptance_estimator import (
37 + OnlineAcceptanceEstimator,
38 +)
39 from vllm.v1.worker.utils import AttentionGroup
40
41 if TYPE_CHECKING:
@@ -158,6 +161,15 @@ class DraftModelSpeculator(BaseSpeculator):
161 self.arange_np = np.arange(self.max_num_reqs + 1, dtype=np.int32)
162 self.draft_is_prefilling = torch.zeros(self.max_num_reqs, dtype=torch.bool)
163
164 + self.draft_token_confidence_probs = torch.empty_like(
165 + self.draft_tokens, dtype=torch.float32
166 + )
167 + self.enable_adaptive_verification = (
168 + self.speculative_config.enable_adaptive_verification
169 + )
170 + self.use_acceptance_estimator = self.enable_adaptive_verification
171 + self.acceptance_estimator: OnlineAcceptanceEstimator | None = None
172 +
173 self.draft_logits: torch.Tensor | None = None
174 if self.speculative_config.draft_sample_method == "probabilistic":
175 # Pre-temperature logits, cached from the previous decode step.
@@ -227,6 +239,20 @@ class DraftModelSpeculator(BaseSpeculator):
239 type(self.model).__name__,
240 )
241
242 + if self.use_acceptance_estimator:
243 + if self.use_local_argmax_reduction:
244 + raise ValueError(
245 + "Adaptive verification without a confidence head estimates "
246 + "per-position acceptance from the draft logits, which "
247 + "use_local_argmax_reduction never materializes. Disable one "
248 + "of them."
249 + )
250 + self.acceptance_estimator = OnlineAcceptanceEstimator(
251 + self.max_num_reqs,
252 + self.num_speculative_steps,
253 + self.device,
254 + )
255 +
256 def set_eplb_state(self, eplb_state: EplbState) -> None:
257 """Inject EPLB state after construction."""
258 self.eplb_state = eplb_state
@@ -371,12 +397,6 @@ class DraftModelSpeculator(BaseSpeculator):
397 "(communication: O(2*tp_size) vs O(vocab_size))."
398 )
399
374 - def _greedy_sample_draft(self, hidden_states: torch.Tensor) -> torch.Tensor:
375 - if self.use_local_argmax_reduction:
376 - return self.model.get_top_tokens(hidden_states)
377 - logits = self.model.compute_logits(hidden_states)
378 - return logits.argmax(dim=-1)
379 -
400 def sample_draft(
401 self,
402 hidden_states: torch.Tensor,
@@ -408,8 +428,42 @@ class DraftModelSpeculator(BaseSpeculator):
428 idx_mapping,
429 temperature,
430 )
411 - return sampled
412 - return self._greedy_sample_draft(hidden_states)
431 + elif self.use_local_argmax_reduction:
432 + return self.model.get_top_tokens(hidden_states)
433 + else:
434 + logits = self.model.compute_logits(hidden_states)
435 + sampled = logits.argmax(dim=-1)
436 + self._maybe_predict_acceptance(logits, idx_mapping, draft_step)
437 + return sampled
438 +
439 + def _maybe_predict_acceptance(
440 + self,
441 + logits: torch.Tensor,
442 + idx_mapping: torch.Tensor,
443 + draft_step: torch.Tensor,
444 + ) -> None:
445 + if self.acceptance_estimator is not None:
446 + self.acceptance_estimator.predict(
447 + logits,
448 + idx_mapping,
449 + draft_step,
450 + self.draft_token_confidence_probs,
451 + self.temperature,
452 + )
453 +
454 + def observe_verification(
455 + self,
456 + idx_mapping: torch.Tensor,
457 + num_sampled: torch.Tensor,
458 + num_rejected: torch.Tensor,
459 + ) -> None:
460 + """Fold the target's verdict on the last drafts into the estimator.
461 +
462 + Must run before the next `propose`, which overwrites the per-slot
463 + features the verdict grades.
464 + """
465 + if self.acceptance_estimator is not None:
466 + self.acceptance_estimator.step(idx_mapping, num_sampled, num_rejected)
467
468 def prepare_watermarking(
469 self, contexts: torch.Tensor, watermarking: torch.Tensor