[Model Runner V2] Cache draft logits in model's LM head dtype (#50910)

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

Giancarlo Delfin committed Aug 5, 2026 at 19:12 UTC 81bc196913653c9e06b957e39a606002a09db171
6 files changed +170 -76
tests/v1/spec_decode/test_rejection_sampler_utils.py
+21 -7
@@ -25,6 +25,12 @@ def _build_rejection_sample_inputs(
25 temperature: float,
26 num_trials: int,
27 ) -> dict:
28 + """Build rejection_sample kwargs from a fixed target and draft distribution.
29 +
30 + target_logits_1d must already have temperature applied (the sampler applies
31 + sampling params before verification), whereas draft_logits_1d must not:
32 + rejection_sample divides the draft logits by the temperature on load.
33 + """
34 device = target_logits_1d.device
35 vocab_size = target_logits_1d.shape[0]
36 K = num_speculative_steps
@@ -35,7 +41,10 @@ def _build_rejection_sample_inputs(
41 draft_logits_1d.view(1, 1, vocab_size).expand(num_trials, K, -1).contiguous()
42 )
43
38 - draft_probs = torch.softmax(draft_logits_1d, dim=0)
44 + scaled_draft_logits_1d = draft_logits_1d.float()
45 + if temperature > 0:
46 + scaled_draft_logits_1d = scaled_draft_logits_1d / temperature
47 + draft_probs = torch.softmax(scaled_draft_logits_1d, dim=0)
48 draft_tokens = torch.multinomial(
49 draft_probs.expand(num_trials, -1), K, replacement=True
50 )
@@ -138,7 +147,10 @@ def _assert_distribution_match(
147 (3, 1.0),
148 ],
149 )
141 -def test_stochastic_rejection_sample(num_speculative_steps: int, temperature: float):
150 +@pytest.mark.parametrize("draft_logits_dtype", [torch.float32, torch.bfloat16])
151 +def test_stochastic_rejection_sample(
152 + num_speculative_steps: int, temperature: float, draft_logits_dtype: torch.dtype
153 +):
154 """
155 Verify that rejection sampling produces the target distribution.
156 This is done by simulating many independent trials of speculative
@@ -146,6 +158,9 @@ def test_stochastic_rejection_sample(num_speculative_steps: int, temperature: fl
158 run rejection sample on all of the trials (requests), and verify
159 that the sampled tokens at every position follow the target
160 distribution p(x).
161 +
162 + Parametrized over the draft-logits dtype: storing them in the draft head's
163 + dtype must not bias the output distribution.
164 """
165
166 torch.manual_seed(42)
@@ -153,11 +168,12 @@ def test_stochastic_rejection_sample(num_speculative_steps: int, temperature: fl
168 num_trials = 10 * VOCAB_SIZE
169
170 target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
156 - draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
171 + draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32).to(
172 + draft_logits_dtype
173 + )
174
175 if temperature > 0:
176 target_logits_1d /= temperature
160 - draft_logits_1d /= temperature
177
178 inputs = _build_rejection_sample_inputs(
179 target_logits_1d,
@@ -251,7 +267,6 @@ def test_synthetic_rejection_sample(
267
268 if temperature > 0:
269 target_logits_1d /= temperature
254 - draft_logits_1d /= temperature
270
271 inputs = _build_rejection_sample_inputs(
272 target_logits_1d,
@@ -334,7 +349,7 @@ def test_placeholder_draft_token_rejected():
349 temperature = 0.6
350
351 target_logits_1d = torch.randn(VOCAB_SIZE, device=device) / temperature
337 - draft_logits_1d = torch.randn(VOCAB_SIZE, device=device) / temperature
352 + draft_logits_1d = torch.randn(VOCAB_SIZE, device=device)
353
354 inputs = _build_rejection_sample_inputs(
355 target_logits_1d,
@@ -383,7 +398,6 @@ def test_block_verification_rejection_sample(
398
399 if temperature > 0:
400 target_logits_1d /= temperature
386 - draft_logits_1d /= temperature
401
402 inputs = _build_rejection_sample_inputs(
403 target_logits_1d,
tests/v1/worker/test_gpu_gumbel_sample.py
+66
@@ -225,3 +225,69 @@ def test_vocab_size_not_multiple_of_block(vocab_size: int):
225 df = vocab_size - 1
226 if df >= 1:
227 assert chi2 < df + 10 * math.sqrt(2 * df), f"chi2={chi2:.1f}, df={df}"
228 +
229 +
230 +# ----------------------------- Logits cache --------------------------------
231 +
232 +
233 +def _float_bits(t: torch.Tensor) -> torch.Tensor:
234 + """Reinterpret floats as same-width ints, so equality is truly bitwise."""
235 + int_dtype = torch.int32 if t.dtype == torch.float32 else torch.int16
236 + return t.view(int_dtype)
237 +
238 +
239 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16, torch.float32])
240 +@pytest.mark.parametrize("per_token_col", [False, True])
241 +def test_logits_cache_stores_input_logits_bitwise(
242 + dtype: torch.dtype, per_token_col: bool
243 +):
244 + """`logits_cache` must receive the input logits, pre-temperature and bit-exact.
245 +
246 + The cache is kept in the head's dtype, which is only lossless because the
247 + stored value is the input logit itself. Storing `logit / temp` instead would
248 + generally not be representable there, and the rejection sampler -- which
249 + divides by the same temperature on load -- would then verify against a `q`
250 + the draft never sampled from. Temperatures 0.0 and 1.0 are included because
251 + both make the divide a no-op and would mask such a bug.
252 +
253 + Both column-addressing modes are covered: a 0-d column (one draft step per
254 + call, as MTP/EAGLE do) and a [num_tokens] column (as DFlash does, sampling
255 + every step in one call).
256 + """
257 + torch.manual_seed(0)
258 + num_reqs, vocab_size, num_steps = 8, 4099, 3
259 + logits = torch.randn(num_reqs, vocab_size, device=DEVICE, dtype=dtype)
260 + idx_mapping = torch.arange(num_reqs, dtype=torch.int32, device=DEVICE)
261 + temp = torch.tensor(
262 + [0.0, 0.1, 0.5, 1.0, 1.5, 2.0, 0.7, 1.0], dtype=torch.float32, device=DEVICE
263 + )
264 + seed = torch.arange(num_reqs, dtype=torch.int64, device=DEVICE)
265 + pos = torch.arange(num_reqs, dtype=torch.int64, device=DEVICE)
266 +
267 + cache = torch.zeros(num_reqs, num_steps, vocab_size, device=DEVICE, dtype=dtype)
268 + if per_token_col:
269 + # Each token lands in its own column, cycling through the steps.
270 + cols = torch.arange(num_reqs, dtype=torch.int32, device=DEVICE) % num_steps
271 + else:
272 + cols = torch.tensor(1, dtype=torch.int32, device=DEVICE)
273 +
274 + gumbel_sample(
275 + logits,
276 + idx_mapping,
277 + temp,
278 + seed,
279 + pos,
280 + apply_temperature=True,
281 + logits_cache=cache,
282 + logits_cache_col=cols,
283 + )
284 +
285 + if per_token_col:
286 + stored = cache[torch.arange(num_reqs, device=DEVICE), cols.long()]
287 + else:
288 + stored = cache[:, 1]
289 + # Untouched columns must stay untouched.
290 + assert not cache[:, 0].any() and not cache[:, 2].any()
291 + assert torch.equal(_float_bits(stored), _float_bits(logits)), (
292 + "cached logits differ from the input logits"
293 + )
vllm/v1/worker/gpu/sample/gumbel.py
+33 -36
@@ -91,9 +91,9 @@ def gumbel_block_argmax(
91 temp_ptr,
92 seeds_ptr,
93 pos_ptr,
94 - processed_logits_ptr,
95 - processed_logits_stride,
96 - processed_logits_col_ptr,
94 + logits_cache_ptr,
95 + logits_cache_stride,
96 + logits_cache_col_ptr,
97 vocab_size,
98 APPLY_TEMPERATURE: tl.constexpr,
99 USE_FP64: tl.constexpr,
@@ -104,30 +104,30 @@ def gumbel_block_argmax(
104 temp = tl.load(temp_ptr + req_state_idx, mask=is_valid_req, other=0.0).to(
105 tl.float32
106 )
107 - if temp != 0.0 and APPLY_TEMPERATURE:
108 - # Apply temperature.
109 - # NOTE(woosuk): Match the behavior of _temperature_kernel.
110 - # E.g., if the kernel uses tl.div_rn, we should use tl.div_rn here too.
111 - logits = logits / temp
112 -
113 - if processed_logits_ptr is not None:
114 - # Store the temperature-applied logits.
115 - if processed_logits_col_ptr is not None:
116 - if PER_TOKEN_COL:
117 - col = tl.load(processed_logits_col_ptr + token_idx)
118 - else:
119 - col = tl.load(processed_logits_col_ptr)
107 + if logits_cache_ptr is not None:
108 + # Store the logits *before* temperature. Dividing first would produce a
109 + # value that is generally not representable in the cache's dtype, forcing
110 + # it to be fp32. Consumers (the rejection sampler) divide by the same
111 + # temperature on load, which reproduces the value used below bitwise.
112 + if PER_TOKEN_COL:
113 + col = tl.load(logits_cache_col_ptr + token_idx)
114 else:
121 - col = 0
115 + col = tl.load(logits_cache_col_ptr)
116 tl.store(
123 - processed_logits_ptr
124 - + req_state_idx * processed_logits_stride
117 + logits_cache_ptr
118 + + req_state_idx * logits_cache_stride
119 + col * vocab_size
120 + block,
121 logits,
122 mask=mask & is_valid_req,
123 )
124
125 + if temp != 0.0 and APPLY_TEMPERATURE:
126 + # Apply temperature.
127 + # NOTE(woosuk): Match the behavior of _temperature_kernel.
128 + # E.g., if the kernel uses tl.div_rn, we should use tl.div_rn here too.
129 + logits = logits / temp
130 +
131 # fp32 is the default reduction dtype; fp64 is ~1/32–1/64x the throughput
132 # on H100/Ada/Blackwell and empirically indistinguishable for Gumbel-max.
133 if USE_FP64:
@@ -164,9 +164,9 @@ def _gumbel_sample_kernel(
164 local_argmax_stride,
165 local_max_ptr,
166 local_max_stride,
167 - processed_logits_ptr,
168 - processed_logits_stride,
169 - processed_logits_col_ptr,
167 + logits_cache_ptr,
168 + logits_cache_stride,
169 + logits_cache_col_ptr,
170 logits_ptr,
171 logits_stride,
172 expanded_idx_mapping_ptr,
@@ -199,9 +199,9 @@ def _gumbel_sample_kernel(
199 temp_ptr,
200 seeds_ptr,
201 pos_ptr,
202 - processed_logits_ptr,
203 - processed_logits_stride,
204 - processed_logits_col_ptr,
202 + logits_cache_ptr,
203 + logits_cache_stride,
204 + logits_cache_col_ptr,
205 vocab_size,
206 APPLY_TEMPERATURE=APPLY_TEMPERATURE,
207 USE_FP64=USE_FP64,
@@ -219,33 +219,30 @@ def gumbel_sample(
219 seed: torch.Tensor, # [max_num_reqs]
220 pos: torch.Tensor, # [num_tokens]
221 apply_temperature: bool,
222 - output_processed_logits: torch.Tensor | None = None,
223 - output_processed_logits_col: torch.Tensor | None = None,
222 + logits_cache: torch.Tensor | None = None, # [max_num_reqs, num_cols, vocab_size]
223 + logits_cache_col: torch.Tensor | None = None, # scalar or [num_tokens]
224 use_fp64: bool = False,
225 ) -> torch.Tensor:
226 # Enforce contiguity on non-strided input tensors
227 expanded_idx_mapping = expanded_idx_mapping.contiguous()
228 pos = pos.contiguous()
229 - if output_processed_logits_col is not None:
230 - output_processed_logits_col = output_processed_logits_col.contiguous()
229 + if logits_cache_col is not None:
230 + logits_cache_col = logits_cache_col.contiguous()
231 num_tokens, vocab_size = logits.shape
232 BLOCK_SIZE = 1024
233 num_blocks = triton.cdiv(vocab_size, BLOCK_SIZE)
234 local_argmax = logits.new_empty(num_tokens, num_blocks, dtype=torch.int64)
235 local_max_dtype = torch.float64 if use_fp64 else torch.float32
236 local_max = logits.new_empty(num_tokens, num_blocks, dtype=local_max_dtype)
237 - per_token_col = (
238 - output_processed_logits_col is not None
239 - and output_processed_logits_col.dim() > 0
240 - )
237 + per_token_col = logits_cache_col is not None and logits_cache_col.dim() > 0
238 _gumbel_sample_kernel[(num_tokens, num_blocks)](
239 local_argmax,
240 local_argmax.stride(0),
241 local_max,
242 local_max.stride(0),
246 - output_processed_logits,
247 - output_processed_logits.stride(0) if output_processed_logits is not None else 0,
248 - output_processed_logits_col,
243 + logits_cache,
244 + logits_cache.stride(0) if logits_cache is not None else 0,
245 + logits_cache_col,
246 logits,
247 logits.stride(0),
248 expanded_idx_mapping,
vllm/v1/worker/gpu/spec_decode/dspark/speculator.py
+2 -2
@@ -127,8 +127,8 @@ class DSparkSpeculator(DFlashSpeculator):
127 self.seeds,
128 sample_pos - 1,
129 apply_temperature=True,
130 - output_processed_logits=self.draft_logits,
131 - output_processed_logits_col=self._step_cols[step],
130 + logits_cache=self.draft_logits,
131 + logits_cache_col=self._step_cols[step],
132 use_fp64=self.use_fp64_gumbel,
133 )
134
vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py
+44 -28
@@ -123,6 +123,7 @@ def _compute_global_logprobs_and_logsumexp(
123 logit_idx,
124 req_state_idx,
125 draft_step,
126 + temp,
127 # [num_logits, V]
128 target_logits_ptr,
129 target_logits_stride,
@@ -160,14 +161,18 @@ def _compute_global_logprobs_and_logsumexp(
161 )
162 target_log_prob = target_logit - target_lse
163 if HAS_DRAFT_LOGITS:
163 - draft_logit = tl.load(
164 - draft_logits_ptr
165 - + req_state_idx * draft_logits_stride_0
166 - + draft_step * draft_logits_stride_1
167 - + token,
168 - mask=mask,
169 - other=float("-inf"),
170 - ).to(tl.float32)
164 + # draft_logits is stored pre-temperature, so apply scale first.
165 + draft_logit = (
166 + tl.load(
167 + draft_logits_ptr
168 + + req_state_idx * draft_logits_stride_0
169 + + draft_step * draft_logits_stride_1
170 + + token,
171 + mask=mask,
172 + other=float("-inf"),
173 + ).to(tl.float32)
174 + / temp
175 + )
176 draft_lse = _compute_global_logsumexp(
177 draft_local_max_ptr,
178 draft_local_max_stride,
@@ -272,15 +277,19 @@ def _compute_local_logits_stats_kernel(
277 target_sumexp,
278 )
279 if HAS_DRAFT_LOGITS:
275 - # Get local draft max and summed exponentials.
276 - draft_logits = tl.load(
277 - draft_logits_ptr
278 - + req_state_idx * draft_logits_stride_0
279 - + draft_step_idx * draft_logits_stride_1
280 - + block_offsets,
281 - mask=mask,
282 - other=float("-inf"),
283 - ).to(tl.float32)
280 + # Get local draft max and summed exponentials. draft_logits is
281 + # stored pre-temperature, so apply scale first.
282 + draft_logits = (
283 + tl.load(
284 + draft_logits_ptr
285 + + req_state_idx * draft_logits_stride_0
286 + + draft_step_idx * draft_logits_stride_1
287 + + block_offsets,
288 + mask=mask,
289 + other=float("-inf"),
290 + ).to(tl.float32)
291 + / temp
292 + )
293 draft_max, draft_sumexp = _compute_max_and_sumexp(draft_logits)
294 tl.store(
295 draft_local_max_ptr + logit_idx * draft_local_max_stride + block_idx,
@@ -348,6 +357,7 @@ def _compute_cumulative_log_p_kernel(
357 logit_idx,
358 req_state_idx,
359 step,
360 + temp,
361 target_logits_ptr,
362 target_logits_stride,
363 target_local_max_ptr,
@@ -429,6 +439,7 @@ def _compute_local_residual_mass_kernel(
439 logit_idx,
440 req_state_idx,
441 draft_step_idx,
442 + temp,
443 target_logits_ptr,
444 target_logits_stride,
445 target_local_max_ptr,
@@ -599,6 +610,7 @@ def _rejection_kernel(
610 logit_idx,
611 req_state_idx,
612 i,
613 + temp,
614 target_logits_ptr,
615 target_logits_stride,
616 target_local_max_ptr,
@@ -724,14 +736,18 @@ def _resample_kernel(
736 # Bonus token (no rejections). Directly use the target logits.
737 residual_logits = target_logits
738 elif HAS_DRAFT_LOGITS:
727 - draft_logits = tl.load(
728 - draft_logits_ptr
729 - + req_state_idx * draft_logits_stride_0
730 - + resample_idx * draft_logits_stride_1
731 - + block,
732 - mask=mask,
733 - other=float("-inf"),
734 - ).to(tl.float32)
739 + # draft_logits is stored pre-temperature, so apply scale first.
740 + draft_logits = (
741 + tl.load(
742 + draft_logits_ptr
743 + + req_state_idx * draft_logits_stride_0
744 + + resample_idx * draft_logits_stride_1
745 + + block,
746 + mask=mask,
747 + other=float("-inf"),
748 + ).to(tl.float32)
749 + / temp
750 + )
751 target_lse = tl.load(target_rejected_logsumexp_ptr + req_idx)
752 draft_lse = tl.load(draft_rejected_logsumexp_ptr + req_idx)
753 target_log_probs = target_logits - target_lse
@@ -784,9 +800,9 @@ def _resample_kernel(
800 temp_ptr,
801 seed_ptr,
802 pos_ptr,
787 - None, # processed_logits_ptr
788 - 0, # processed_logits_stride
789 - None, # processed_logits_col_ptr
803 + None, # logits_cache_ptr
804 + 0, # logits_cache_stride
805 + None, # logits_cache_col_ptr
806 vocab_size,
807 APPLY_TEMPERATURE=False,
808 USE_FP64=USE_FP64,
vllm/v1/worker/gpu/spec_decode/speculator.py
+4 -3
@@ -131,11 +131,12 @@ class DraftModelSpeculator(BaseSpeculator):
131
132 self.draft_logits: torch.Tensor | None = None
133 if self.speculative_config.draft_sample_method == "probabilistic":
134 + # Pre-temperature logits, cached from the previous decode step.
135 self.draft_logits = torch.zeros(
136 self.max_num_reqs,
137 self.num_speculative_steps,
138 self.vocab_size,
138 - dtype=torch.float32,
139 + dtype=vllm_config.model_config.head_dtype,
140 device=device,
141 )
142
@@ -332,8 +333,8 @@ class DraftModelSpeculator(BaseSpeculator):
333 seeds,
334 positions + 1,
335 apply_temperature=True,
335 - output_processed_logits=draft_logits,
336 - output_processed_logits_col=draft_step,
336 + logits_cache=draft_logits,
337 + logits_cache_col=draft_step,
338 use_fp64=self.use_fp64_gumbel,
339 )
340 return self._greedy_sample_draft(hidden_states)