[Model Runner V2][Spec Decode] Fix draft logits cache column stride in gumbel_sample (#53017)
Giancarlo Delfin committed
Aug 19, 2026 at 17:39 UTC
d4f4d3f40fc5350a71777fcb0e5eb8a57bda631f
3 files changed
+88
-6
tests/v1/worker/test_gpu_gumbel_sample.py
+69
@@ -291,3 +291,72 @@ def test_logits_cache_stores_input_logits_bitwise(
291
assert torch.equal(_float_bits(stored), _float_bits(logits)), (
292
"cached logits differ from the input logits"
293
)
294
+
295
+
296
+@pytest.mark.parametrize("extra_cache_cols", [0, 1])
297
+def test_logits_cache_columns_stay_separate_across_steps(extra_cache_cols: int):
298
+ """Each drafting step must land in its own cache column.
299
+
300
+ `extra_cache_cols=1` is the shape a draft produces when it adds an
301
+ input-only mask/noise row to its embedding table but keeps a full-width
302
+ output head: N-wide logits cached into N+1-wide rows. Striding cache
303
+ columns by the logits width instead of the cache's own row width leaves
304
+ step 0 correct and silently misaligns every step after it.
305
+ """
306
+ torch.manual_seed(0)
307
+ num_reqs, vocab_size, num_steps = 4, 1031, 3
308
+ idx_mapping = torch.arange(num_reqs, dtype=torch.int32, device=DEVICE)
309
+ temp = torch.ones(num_reqs, dtype=torch.float32, device=DEVICE)
310
+ seed = torch.arange(num_reqs, dtype=torch.int64, device=DEVICE)
311
+ pos = torch.arange(num_reqs, dtype=torch.int64, device=DEVICE)
312
+
313
+ cache = torch.zeros(
314
+ num_reqs, num_steps, vocab_size + extra_cache_cols, device=DEVICE
315
+ )
316
+ cols = torch.arange(num_steps, dtype=torch.int32, device=DEVICE)
317
+ per_step = [
318
+ torch.randn(num_reqs, vocab_size, device=DEVICE) for _ in range(num_steps)
319
+ ]
320
+
321
+ for step, logits in enumerate(per_step):
322
+ gumbel_sample(
323
+ logits,
324
+ idx_mapping,
325
+ temp,
326
+ seed,
327
+ pos,
328
+ apply_temperature=True,
329
+ logits_cache=cache,
330
+ logits_cache_col=cols[step],
331
+ )
332
+
333
+ for step, logits in enumerate(per_step):
334
+ stored = cache[:, step, :vocab_size]
335
+ assert torch.equal(_float_bits(stored), _float_bits(logits)), (
336
+ f"step {step} was overwritten by a later step"
337
+ )
338
+ # Columns past the sampled width belong to no step and stay untouched.
339
+ assert not cache[:, :, vocab_size:].any()
340
+
341
+
342
+def test_logits_cache_narrower_than_logits_is_rejected():
343
+ """A cache too narrow to hold a step would silently drop its tail."""
344
+ num_reqs, vocab_size, num_steps = 2, 64, 3
345
+ logits = torch.randn(num_reqs, vocab_size, device=DEVICE)
346
+ idx_mapping = torch.arange(num_reqs, dtype=torch.int32, device=DEVICE)
347
+ temp = torch.ones(num_reqs, dtype=torch.float32, device=DEVICE)
348
+ seed = torch.zeros(num_reqs, dtype=torch.int64, device=DEVICE)
349
+ pos = torch.arange(num_reqs, dtype=torch.int64, device=DEVICE)
350
+ cache = torch.zeros(num_reqs, num_steps, vocab_size - 1, device=DEVICE)
351
+
352
+ with pytest.raises(AssertionError, match="narrower"):
353
+ gumbel_sample(
354
+ logits,
355
+ idx_mapping,
356
+ temp,
357
+ seed,
358
+ pos,
359
+ apply_temperature=True,
360
+ logits_cache=cache,
361
+ logits_cache_col=torch.tensor(0, dtype=torch.int32, device=DEVICE),
362
+ )
vllm/v1/worker/gpu/sample/gumbel.py
+17
-5
@@ -91,8 +91,10 @@ def gumbel_block_argmax(
91
temp_ptr,
92
seeds_ptr,
93
pos_ptr,
94
+ # [max_num_reqs, num_cols, vocab_size]
95
logits_cache_ptr,
95
- logits_cache_stride,
96
+ logits_cache_stride_0,
97
+ logits_cache_stride_1,
98
logits_cache_col_ptr,
99
vocab_size,
100
APPLY_TEMPERATURE: tl.constexpr,
@@ -115,8 +117,8 @@ def gumbel_block_argmax(
117
col = tl.load(logits_cache_col_ptr)
118
tl.store(
119
logits_cache_ptr
118
- + req_state_idx * logits_cache_stride
119
- + col * vocab_size
120
+ + req_state_idx * logits_cache_stride_0
121
+ + col * logits_cache_stride_1
122
+ block,
123
logits,
124
mask=mask & is_valid_req,
@@ -164,8 +166,10 @@ def _gumbel_sample_kernel(
166
local_argmax_stride,
167
local_max_ptr,
168
local_max_stride,
169
+ # [max_num_reqs, num_cols, vocab_size]
170
logits_cache_ptr,
168
- logits_cache_stride,
171
+ logits_cache_stride_0,
172
+ logits_cache_stride_1,
173
logits_cache_col_ptr,
174
logits_ptr,
175
logits_stride,
@@ -200,7 +204,8 @@ def _gumbel_sample_kernel(
204
seeds_ptr,
205
pos_ptr,
206
logits_cache_ptr,
203
- logits_cache_stride,
207
+ logits_cache_stride_0,
208
+ logits_cache_stride_1,
209
logits_cache_col_ptr,
210
vocab_size,
211
APPLY_TEMPERATURE=APPLY_TEMPERATURE,
@@ -229,6 +234,12 @@ def gumbel_sample(
234
if logits_cache_col is not None:
235
logits_cache_col = logits_cache_col.contiguous()
236
num_tokens, vocab_size = logits.shape
237
+ if logits_cache is not None:
238
+ assert logits_cache.size(-1) >= vocab_size, (
239
+ f"draft logits cache vocab dim ({logits_cache.size(-1)}) is narrower "
240
+ f"than the sampled logits ({vocab_size}). Cached logits would be "
241
+ "truncated."
242
+ )
243
BLOCK_SIZE = 1024
244
num_blocks = triton.cdiv(vocab_size, BLOCK_SIZE)
245
local_argmax = logits.new_empty(num_tokens, num_blocks, dtype=torch.int64)
@@ -242,6 +253,7 @@ def gumbel_sample(
253
local_max.stride(0),
254
logits_cache,
255
logits_cache.stride(0) if logits_cache is not None else 0,
256
+ logits_cache.stride(1) if logits_cache is not None else 0,
257
logits_cache_col,
258
logits,
259
logits.stride(0),
vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py
+2
-1
@@ -833,7 +833,8 @@ def _resample_kernel(
833
seed_ptr,
834
pos_ptr,
835
None, # logits_cache_ptr
836
- 0, # logits_cache_stride
836
+ 0, # logits_cache_stride_0
837
+ 0, # logits_cache_stride_1
838
None, # logits_cache_col_ptr
839
vocab_size,
840
APPLY_TEMPERATURE=False,