[watermarking] add context deduplication support to speculative decoding (#56807)

Signed-off-by: Raphael Rialland <raphael.rialland@mistral.ai> Signed-off-by: Simon Veitner <sveitner@redhat.com> Co-authored-by: Simon Veitner <sveitner@redhat.com> Co-authored-by: Lucas Wilkinson <LucasWilkinson@users.noreply.github.com>

Raphaël Rialland committed Sep 30, 2026 at 18:22 UTC 0f8b398158f01fb3ea0e81b5adc93338fdf3db9c
17 files changed +1379 -88
docs/features/watermarking.md
+14 -15
@@ -77,30 +77,17 @@ watermarked. The watermark signal is diluted in proportion to the share of
77 output tokens supplied by accepted drafts; rejected drafts do not dilute it
78 because their recovery tokens are watermarked.
79
80 -Speculative-decoding token paths do not currently support generation-side
81 -context deduplication. The configured `deduplicate_contexts` policy is not
82 -applied to accepted drafts, rejection-recovery tokens, or bonus tokens.
83 -
80 For `dual_key_gumbel`, `alpha` has no effect under speculative decoding. The
81 speculative protocol selects the key for each token instead.
82
87 -## Algorithms
88 -
89 -### Gumbel-max
90 -
91 -Gumbel-max derives a deterministic pseudorandom value from the key, prior token
92 -context, and every candidate token, then uses the resulting Gumbel noise for
93 -categorical sampling. See
94 -[Aaronson's original presentation](https://simons.berkeley.edu/sites/default/files/2024-10/LLM24-2%20Slides%20-%20Scott%20Aaronson.pdf).
95 -
96 -Gumbel-max requires stochastic sampling. Greedy requests (`temperature=0`)
97 -bypass watermarking and emit a warning once per worker.
83 +## Context deduplication
84
85 When a token context is repeated, generation can use ordinary sampling for that
86 occurrence. Reusing the repeated context leads to a bias over the sequence, as
87 certain token choices would be correlated. Ordinary sampling at these positions
88 allows for single-sequence non-distortion (see section G.3 of the
89 [SynthID-Text supplementary materials](https://media.springernature.com/original/springer-static/esm/art%3A10.1038%2Fs41586-024-08025-4/MediaObjects/41586_2024_8025_MOESM1_ESM.pdf)).
90 +This also applies to drafter watermarking during speculative decoding.
91 The detector independently deduplicates contexts so repeated keyed random
92 vectors are not treated as independent evidence, meaning that context
93 deduplication at generation time does not reduce the watermarking signal, unless
@@ -143,6 +130,18 @@ vllm serve MODEL \
130 '{"algorithm":"gumbel","key":42,"deduplicate_contexts":"all"}'
131 ```
132
133 +## Algorithms
134 +
135 +### Gumbel-max
136 +
137 +Gumbel-max derives a deterministic pseudorandom value from the key, prior token
138 +context, and every candidate token, then uses the resulting Gumbel noise for
139 +categorical sampling. See
140 +[Aaronson's original presentation](https://simons.berkeley.edu/sites/default/files/2024-10/LLM24-2%20Slides%20-%20Scott%20Aaronson.pdf).
141 +
142 +Gumbel-max requires stochastic sampling. Greedy requests (`temperature=0`)
143 +bypass watermarking and emit a warning once per worker.
144 +
145 ### Dual-key Gumbel-max
146
147 Dual-key Gumbel-max derives independent keys A and B from one configured master
tests/test_config.py
+23 -2
@@ -3526,7 +3526,6 @@ def test_target_only_gumbel_allows_speculative_decoding(caplog_vllm, disable_log
3526 config._check_watermarking_unsupported()
3527
3528 assert "Target-only watermarking leaves accepted draft tokens" in caplog_vllm.text
3529 - assert "Context deduplication is not supported" in caplog_vllm.text
3529
3530
3531 def test_speculative_watermarking_without_context_dedup_does_not_warn(
@@ -3543,10 +3542,32 @@ def test_speculative_watermarking_without_context_dedup_does_not_warn(
3542 parallel_drafting=False,
3543 )
3544
3545 + caplog_vllm.clear()
3546 with caplog_vllm.at_level(logging.WARNING):
3547 config._check_watermarking_unsupported()
3548
3549 - assert "Context deduplication is not supported" not in caplog_vllm.text
3549 + assert "dedup" not in caplog_vllm.text.lower()
3550 +
3551 +
3552 +def test_speculative_context_dedup_logs_no_unsupported_warning(
3553 + caplog_vllm, disable_log_dedup
3554 +):
3555 + config = _watermarked_vllm_config()
3556 + config.watermark_config = WatermarkConfig(
3557 + algorithm="dual_key_gumbel", key=42, deduplicate_contexts="single_turn"
3558 + )
3559 + config.speculative_config = SimpleNamespace(
3560 + method="mtp",
3561 + draft_sample_method="probabilistic",
3562 + rejection_sample_method="standard",
3563 + parallel_drafting=False,
3564 + )
3565 +
3566 + caplog_vllm.clear()
3567 + with caplog_vllm.at_level(logging.WARNING):
3568 + config._check_watermarking_unsupported()
3569 +
3570 + assert "dedup" not in caplog_vllm.text.lower()
3571
3572
3573 def test_gumbel_rejects_speculative_decoding_without_target_only():
tests/v1/spec_decode/test_rejection_sampler_utils.py
+176
@@ -203,6 +203,182 @@ def test_watermarked_recovery_uses_target_key():
203 assert sampled[0, 0] == expected.token_ids[0]
204
205
206 +def test_repeated_draft_context_does_not_change_acceptance():
207 + torch.manual_seed(0)
208 + num_trials = 8
209 + # A draft distribution of its own, so the drafts are not accepted outright.
210 + target_logits = torch.randn(64, device="cuda")
211 + draft_logits = torch.randn(64, device="cuda")
212 + inputs = _build_rejection_sample_inputs(
213 + target_logits,
214 + draft_logits,
215 + num_speculative_steps=1,
216 + temperature=1.0,
217 + num_trials=num_trials,
218 + )
219 + expected, expected_num_sampled = rejection_sample(**inputs, num_speculative_steps=1)
220 +
221 + sampled, num_sampled = watermarked_rejection_sample(
222 + **inputs,
223 + num_speculative_steps=1,
224 + contexts=torch.tensor([[10, 11], [11, 12]], device="cuda").repeat(
225 + num_trials, 1
226 + ),
227 + watermarking=torch.ones(num_trials, dtype=torch.bool, device="cuda"),
228 + watermarking_skip_mask=torch.tensor([True, False], device="cuda").repeat(
229 + num_trials
230 + ),
231 + watermarker=GumbelWatermarker(key=42, context_width=2),
232 + )
233 +
234 + # Some drafts must actually be rejected, or the ordinary-residual branch
235 + # the skip mask selects is never reached.
236 + assert (expected_num_sampled == 1).any()
237 + torch.testing.assert_close(num_sampled, expected_num_sampled, rtol=0, atol=0)
238 + torch.testing.assert_close(sampled[:, :1], expected[:, :1], rtol=0, atol=0)
239 +
240 +
241 +def test_repeated_context_skip_mask_is_read_per_row_in_a_block():
242 + """A block of three drafts, accepted at row 0 and rejected at row 1.
243 +
244 + The skip mask selects the sampler per row, so the row the target recovers
245 + at decides which key draws the recovery token.
246 + """
247 + num_speculative_steps = 3
248 + target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0], device="cuda")
249 + draft_logits = torch.tensor([30.0, -30.0, -30.0, -30.0], device="cuda")
250 + inputs = _build_rejection_sample_inputs(
251 + target_logits,
252 + draft_logits,
253 + num_speculative_steps=num_speculative_steps,
254 + temperature=1.0,
255 + num_trials=1,
256 + )
257 + # Row 0 drafts a token the target gives 0.67 and the draft e**-60, so it is
258 + # accepted; row 1 drafts a token the target gives no mass, so it is not.
259 + inputs["draft_sampled"][1] = 3
260 + inputs["draft_sampled"][2] = 0
261 + contexts = torch.tensor(
262 + [[10, 11], [11, 3], [3, 0], [0, 1]], dtype=torch.int64, device="cuda"
263 + )
264 + expected, expected_num_sampled = rejection_sample(
265 + **inputs, num_speculative_steps=num_speculative_steps
266 + )
267 + assert expected_num_sampled.item() == 2
268 + assert expected[0, 0].item() == 3
269 +
270 + watermarker = GumbelWatermarker(key=42, context_width=2)
271 + skipped, skipped_num_sampled = watermarked_rejection_sample(
272 + **inputs,
273 + num_speculative_steps=num_speculative_steps,
274 + contexts=contexts,
275 + watermarking=torch.tensor([True], device="cuda"),
276 + watermarking_skip_mask=torch.tensor([False, True, False, False], device="cuda"),
277 + watermarker=watermarker,
278 + )
279 +
280 + assert skipped_num_sampled.item() == 2
281 + torch.testing.assert_close(skipped[:, :2], expected[:, :2], rtol=0, atol=0)
282 +
283 + watermarked, watermarked_num_sampled = watermarked_rejection_sample(
284 + **inputs,
285 + num_speculative_steps=num_speculative_steps,
286 + contexts=contexts,
287 + watermarking=torch.tensor([True], device="cuda"),
288 + watermarking_skip_mask=torch.zeros(
289 + num_speculative_steps + 1, dtype=torch.bool, device="cuda"
290 + ),
291 + watermarker=watermarker,
292 + )
293 + # The residual equals the target to within e**-60, so the recovery token is
294 + # the target key's draw on the rejected row's context.
295 + recovery = GumbelWatermarker(key=42, context_width=2).sample(
296 + target_logits.unsqueeze(0), contexts[1:2], lambda _: None
297 + )
298 +
299 + assert watermarked_num_sampled.item() == 2
300 + assert watermarked[0, 0].item() == 3
301 + assert watermarked[0, 1] == recovery.token_ids[0]
302 +
303 +
304 +def test_repeated_recovery_context_uses_ordinary_residual_sample():
305 + target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0], device="cuda")
306 + draft_logits = torch.tensor([100.0, -100.0, -100.0, -100.0], device="cuda")
307 + inputs = _build_rejection_sample_inputs(
308 + target_logits,
309 + draft_logits,
310 + num_speculative_steps=1,
311 + temperature=1.0,
312 + num_trials=1,
313 + )
314 + inputs["draft_sampled"][1] = 0
315 + expected, expected_num_sampled = rejection_sample(**inputs, num_speculative_steps=1)
316 +
317 + sampled, num_sampled = watermarked_rejection_sample(
318 + **inputs,
319 + num_speculative_steps=1,
320 + contexts=torch.tensor([[10, 11], [11, 0]], device="cuda"),
321 + watermarking=torch.tensor([True], device="cuda"),
322 + watermarking_skip_mask=torch.tensor([True, False], device="cuda"),
323 + watermarker=GumbelWatermarker(key=42, context_width=2),
324 + )
325 +
326 + torch.testing.assert_close(num_sampled, expected_num_sampled, rtol=0, atol=0)
327 + torch.testing.assert_close(sampled[:, :1], expected[:, :1], rtol=0, atol=0)
328 +
329 +
330 +def test_repeated_bonus_context_uses_ordinary_target_sample():
331 + target_logits = torch.randn(64, device="cuda")
332 + inputs = _build_rejection_sample_inputs(
333 + target_logits,
334 + target_logits,
335 + num_speculative_steps=1,
336 + temperature=1.0,
337 + num_trials=1,
338 + )
339 + expected, expected_num_sampled = rejection_sample(**inputs, num_speculative_steps=1)
340 +
341 + sampled, num_sampled = watermarked_rejection_sample(
342 + **inputs,
343 + num_speculative_steps=1,
344 + contexts=torch.tensor([[10, 11], [11, 12]], device="cuda"),
345 + watermarking=torch.tensor([True], device="cuda"),
346 + watermarking_skip_mask=torch.tensor([False, True], device="cuda"),
347 + watermarker=GumbelWatermarker(key=42, context_width=2),
348 + )
349 +
350 + torch.testing.assert_close(num_sampled, expected_num_sampled, rtol=0, atol=0)
351 + torch.testing.assert_close(sampled[:, :2], expected[:, :2], rtol=0, atol=0)
352 +
353 +
354 +def test_repeated_context_does_not_change_unwatermarked_or_greedy_rows():
355 + target_logits = torch.randn(64, device="cuda")
356 + inputs = _build_rejection_sample_inputs(
357 + target_logits,
358 + target_logits,
359 + num_speculative_steps=1,
360 + temperature=1.0,
361 + num_trials=2,
362 + )
363 + inputs["temperature"][1] = 0
364 + expected, expected_num_sampled = rejection_sample(**inputs, num_speculative_steps=1)
365 +
366 + sampled, num_sampled = watermarked_rejection_sample(
367 + **inputs,
368 + num_speculative_steps=1,
369 + contexts=torch.tensor([[10, 11], [11, 12], [20, 21], [21, 22]], device="cuda"),
370 + watermarking=torch.tensor([False, True], device="cuda"),
371 + watermarking_skip_mask=torch.ones(4, dtype=torch.bool, device="cuda"),
372 + watermarker=GumbelWatermarker(key=42, context_width=2),
373 + )
374 +
375 + torch.testing.assert_close(num_sampled, expected_num_sampled, rtol=0, atol=0)
376 + for row, length in enumerate(num_sampled):
377 + torch.testing.assert_close(
378 + sampled[row, : int(length)], expected[row, : int(length)], rtol=0, atol=0
379 + )
380 +
381 +
382 def test_watermarked_recovery_supports_smaller_draft_vocabulary():
383 target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0, 100.0], device="cuda")
384 draft_logits = torch.tensor([100.0, -100.0, -100.0, -100.0], device="cuda")
tests/watermarking/test_gumbel.py
+62
@@ -12,6 +12,7 @@ from vllm.v1.watermarking import (
12 derive_watermark_key,
13 )
14 from vllm.v1.watermarking.gumbel import _gamma_survival_integer_shape
15 +from vllm.v1.watermarking.watermarker import RandomSampler
16 from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
17 from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
18
@@ -197,3 +198,64 @@ def test_philox_gumbel_sample_skip_mask_matches_separate_samplers(use_fp64: bool
198 )
199
200 torch.testing.assert_close(actual, expected, rtol=0, atol=0)
201 +
202 +
203 +@pytest.mark.skipif(
204 + not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
205 +)
206 +def test_philox_mixed_draft_sampling_caches_unprocessed_logits():
207 + torch.manual_seed(0)
208 + num_rows, vocab_size, num_steps = 5, 4099, 3
209 + logits = torch.randn(num_rows, vocab_size, device="cuda", dtype=torch.float16)
210 + temperatures = torch.tensor([0.0, 0.3, 0.5, 1.0, 2.0], device="cuda")
211 + processed_logits = logits / torch.where(
212 + temperatures == 0, 1, temperatures
213 + ).unsqueeze(-1)
214 + contexts = torch.randint(0, vocab_size, (num_rows, 4), device="cuda")
215 + idx_mapping = torch.arange(num_rows, dtype=torch.int32, device="cuda")
216 + seeds = torch.arange(num_rows, dtype=torch.int64, device="cuda") + 10
217 + positions = torch.arange(num_rows, dtype=torch.int64, device="cuda") + 20
218 + # The skipped rows must divide by a temperature other than 1, or the
219 + # unprocessed logits the cache stores are the logits the sampler sees.
220 + # Row 0 keeps a greedy row in the skipped set.
221 + skip_mask = torch.tensor([True, True, False, False, True], device="cuda")
222 + cols = torch.arange(num_rows, dtype=torch.int32, device="cuda") % num_steps
223 + cache = torch.zeros(
224 + num_rows, num_steps, vocab_size + 1, dtype=logits.dtype, device="cuda"
225 + )
226 +
227 + ordinary = gumbel_sample(
228 + logits,
229 + idx_mapping,
230 + temperatures,
231 + seeds,
232 + positions,
233 + apply_temperature=True,
234 + is_drafting=True,
235 + )
236 + watermarked = philox_gumbel_sample(processed_logits, contexts, 42)
237 + expected = torch.where(skip_mask, ordinary, watermarked)
238 + actual = (
239 + GumbelWatermarker(key=42, context_width=4)
240 + .sample(
241 + processed_logits,
242 + contexts,
243 + random_sampler=RandomSampler(
244 + expanded_idx_mapping=idx_mapping,
245 + temperatures=temperatures,
246 + seeds=seeds,
247 + positions=positions,
248 + is_drafting=True,
249 + logits_cache=cache,
250 + logits_cache_col=cols,
251 + logits_cache_source=logits,
252 + ),
253 + skip_mask=skip_mask,
254 + )
255 + .token_ids
256 + )
257 +
258 + torch.testing.assert_close(actual, expected, rtol=0, atol=0)
259 + stored = cache[torch.arange(num_rows, device="cuda"), cols.long(), :vocab_size]
260 + assert torch.equal(stored.view(torch.int16), logits.view(torch.int16))
261 + assert not cache[:, :, -1].any()
tests/watermarking/test_watermarking.py
+550 -13
@@ -28,6 +28,7 @@ from vllm.v1.watermarking.spec_decode import (
28 from vllm.v1.watermarking.watermarker import Watermarker, WatermarkSample
29 from vllm.v1.worker.gpu.sample.sampler import Sampler
30 from vllm.v1.worker.gpu.sample.watermark import (
31 + draft_watermarking_mask,
32 philox_gumbel_sample,
33 repeated_context_mask,
34 )
@@ -202,11 +203,17 @@ def test_speculative_decoding_uses_fixed_dual_key_roles():
203 max_num_reqs=1,
204 device=torch.device("cpu"),
205 allow_target_only=False,
206 + num_speculative_steps=3,
207 + deduplicate_contexts="all",
208 + deduplicate_contexts_max_history=None,
209 )
210
211 assert target.prf.key == derive_watermark_key(42, b"key_b")
212 assert draft is not None
213 assert draft.watermarker.prf.key == derive_watermark_key(42, b"key_a")
214 + assert draft.num_speculative_steps == 3
215 + assert draft.deduplicate_contexts == "all"
216 + assert draft.deduplicate_contexts_max_history is None
217
218
219 def test_recovery_key_rejects_the_unsplit_dual_key_watermarker():
@@ -892,6 +899,187 @@ def test_gpu_sampler_builds_speculative_contexts_from_drafts():
899 assert torch.equal(contexts, torch.tensor([[10, 11], [11, 20], [20, 21]]))
900
901
902 +def test_gpu_sampler_deduplicates_contexts_within_speculative_block():
903 + sampler = object.__new__(GPUWatermarkSampler)
904 + sampler.watermarker = SimpleNamespace(context_width=2)
905 + sampler.deduplicate_contexts = "single_turn"
906 + sampler.deduplicate_contexts_max_history = None
907 + sampler.num_speculative_tokens = 4
908 + sampler.req_states = SimpleNamespace(
909 + all_token_ids=SimpleNamespace(gpu=torch.tensor([[100, 1, 2, 0, 0, 0]])),
910 + prompt_len=SimpleNamespace(gpu=torch.tensor([1])),
911 + total_len=SimpleNamespace(gpu=torch.tensor([3])),
912 + )
913 + request_indices = torch.tensor([0, 0, 0, 0, 0])
914 + local_positions = torch.tensor([0, 1, 2, 3, 4])
915 + contexts = sampler._get_contexts(
916 + request_indices,
917 + local_positions,
918 + torch.tensor([0, 3, 4, 1, 2]),
919 + )
920 +
921 + repeated = sampler._get_repeated_contexts(
922 + request_indices, contexts, local_positions
923 + )
924 +
925 + assert torch.equal(
926 + contexts,
927 + torch.tensor([[1, 2], [2, 3], [3, 4], [4, 1], [1, 2]]),
928 + )
929 + assert torch.equal(repeated, torch.tensor([False, False, False, False, True]))
930 +
931 + sampler.deduplicate_contexts_max_history = 3
932 + repeated = sampler._get_repeated_contexts(
933 + request_indices, contexts, local_positions
934 + )
935 + assert not repeated.any()
936 +
937 +
938 +def test_gpu_sampler_all_scope_skips_only_partial_speculative_contexts():
939 + sampler = object.__new__(GPUWatermarkSampler)
940 + sampler.watermarker = SimpleNamespace(context_width=2)
941 + sampler.deduplicate_contexts = "all"
942 + sampler.deduplicate_contexts_max_history = None
943 + sampler.num_speculative_tokens = 3
944 + sampler.req_states = SimpleNamespace(
945 + all_token_ids=SimpleNamespace(gpu=torch.tensor([[100, 101, 10, 0, 0]])),
946 + prompt_len=SimpleNamespace(gpu=torch.tensor([2])),
947 + total_len=SimpleNamespace(gpu=torch.tensor([3])),
948 + )
949 + request_indices = torch.tensor([0, 0, 0])
950 + local_positions = torch.tensor([0, 1, 2])
951 + contexts = sampler._get_contexts(
952 + request_indices,
953 + local_positions,
954 + torch.tensor([10, 20, 30]),
955 + )
956 +
957 + repeated = sampler._get_repeated_contexts(
958 + request_indices, contexts, local_positions
959 + )
960 +
961 + assert torch.equal(contexts, torch.tensor([[-1, 10], [10, 20], [20, 30]]))
962 + assert torch.equal(repeated, torch.tensor([True, False, False]))
963 +
964 +
965 +@pytest.mark.skipif(
966 + not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
967 +)
968 +@pytest.mark.parametrize("max_history,expected_last", [(None, True), (3, False)])
969 +def test_speculative_context_repetition_accelerator_parity(
970 + max_history: int | None, expected_last: bool
971 +):
972 + all_token_ids = torch.tensor([[10, 11]])
973 + req_indices = torch.tensor([0, 0, 0, 0, 0])
974 + prompt_lens = torch.tensor([0])
975 + total_lens = torch.tensor([2])
976 + local_positions = torch.tensor([0, 1, 2, 3, 4])
977 + contexts = torch.tensor([[1, 2], [2, 3], [3, 4], [4, 1], [1, 2]])
978 +
979 + expected = repeated_context_mask(
980 + all_token_ids,
981 + req_indices,
982 + prompt_lens,
983 + total_lens,
984 + contexts,
985 + history_offsets=local_positions,
986 + local_positions=local_positions,
987 + num_speculative_steps=4,
988 + max_history=max_history,
989 + )
990 + actual = repeated_context_mask(
991 + all_token_ids.cuda(),
992 + req_indices.cuda(),
993 + prompt_lens.cuda(),
994 + total_lens.cuda(),
995 + contexts.cuda(),
996 + history_offsets=local_positions.cuda(),
997 + local_positions=local_positions.cuda(),
998 + num_speculative_steps=4,
999 + max_history=max_history,
1000 + ).cpu()
1001 +
1002 + assert torch.equal(
1003 + expected, torch.tensor([False, False, False, False, expected_last])
1004 + )
1005 + assert torch.equal(actual, expected)
1006 +
1007 +
1008 +@pytest.mark.skipif(
1009 + not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
1010 +)
1011 +@pytest.mark.parametrize("steps_layout", ["per_row", "broadcast"])
1012 +@pytest.mark.parametrize("req_indices_layout", ["contiguous", "column_view"])
1013 +def test_draft_context_deduplication_accelerator_parity(
1014 + steps_layout, req_indices_layout
1015 +):
1016 + """Parity with the CPU reference for the layouts the speculators pass.
1017 +
1018 + A draft step is one 0-d tensor broadcast over the batch, and DSpark samples
1019 + a step at a time, so its request indices are an `idx_map[:, i]` column. Both
1020 + are read per row, so a row that ignores the layout consults another row's
1021 + step or another request's history.
1022 + """
1023 + num_rows, num_steps, step = 4, 3, 1
1024 + all_token_ids = torch.tensor([[10, 11, 20, 11], [30, 31, 11, 20]])
1025 + prompt_lens = torch.tensor([0, 0])
1026 + total_lens = torch.tensor([4, 4])
1027 + contexts = torch.tensor([[40, 41], [31, 11], [10, 11], [50, 51]])
1028 + prior_contexts = torch.zeros(num_rows, num_steps, 2, dtype=torch.int64)
1029 + prior_contexts[0, 0] = torch.tensor([40, 41])
1030 + enabled = torch.ones(num_rows, dtype=torch.bool)
1031 + req_indices = torch.tensor([0, 1, 0, 1])
1032 +
1033 + def run(all_token_ids, req_indices, steps, prior):
1034 + return draft_watermarking_mask(
1035 + all_token_ids,
1036 + req_indices,
1037 + prompt_lens.to(all_token_ids.device),
1038 + total_lens.to(all_token_ids.device),
1039 + prior,
1040 + contexts.to(all_token_ids.device),
1041 + steps,
1042 + enabled.to(all_token_ids.device),
1043 + max_history=3,
1044 + include_prompt=False,
1045 + )
1046 +
1047 + reference_prior = prior_contexts.clone()
1048 + reference = run(
1049 + all_token_ids,
1050 + req_indices,
1051 + torch.full((num_rows,), step),
1052 + reference_prior,
1053 + )
1054 +
1055 + # Row 0 repeats its own step-0 context, rows 1 and 2 repeat a committed
1056 + # context of their own request, row 3 repeats nothing.
1057 + assert torch.equal(reference, torch.tensor([False, False, False, True]))
1058 + expected_prior = prior_contexts.clone()
1059 + expected_prior[:, step] = contexts
1060 + assert torch.equal(reference_prior, expected_prior)
1061 +
1062 + if steps_layout == "per_row":
1063 + steps = torch.full((num_rows,), step).cuda()
1064 + else:
1065 + steps = torch.tensor(step).cuda().expand(num_rows)
1066 + assert steps.stride(0) == 0
1067 +
1068 + if req_indices_layout == "contiguous":
1069 + gpu_req_indices = req_indices.cuda()
1070 + else:
1071 + gpu_req_indices = (
1072 + req_indices.repeat_interleave(num_steps).view(num_rows, num_steps).cuda()
1073 + )[:, 1]
1074 + assert gpu_req_indices.stride(0) == num_steps
1075 +
1076 + actual_prior = prior_contexts.cuda()
1077 + actual = run(all_token_ids.cuda(), gpu_req_indices, steps, actual_prior).cpu()
1078 +
1079 + assert torch.equal(actual, reference)
1080 + assert torch.equal(actual_prior.cpu(), expected_prior)
1081 +
1082 +
1083 def test_gpu_sampler_builds_chunked_multi_request_speculative_contexts():
1084 sampler = object.__new__(GPUWatermarkSampler)
1085 sampler.watermarker = SimpleNamespace(context_width=2)
@@ -921,6 +1109,69 @@ def test_gpu_sampler_builds_chunked_multi_request_speculative_contexts():
1109 )
1110
1111
1112 +@pytest.mark.skipif(
1113 + not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
1114 +)
1115 +def test_draft_context_deduplication_broadcasts_the_draft_step():
1116 + """The draft step is one 0-d tensor broadcast over the batch.
1117 +
1118 + The context occurs four positions back, which `max_history - step` puts
1119 + just outside the scanned window, so a row that mistakes another row's step
1120 + for its own reports the opposite verdict. The neighbours leg is the
1121 + deterministic control: a row that reads a step of 0 instead of 2 widens its
1122 + window by two positions and finds the context. The production leg asserts
1123 + the layout the speculators pass, where the stride-less read lands outside
1124 + the allocation and can return the right value by chance.
1125 + """
1126 + num_rows = 4
1127 + all_token_ids = torch.tensor([[3, 4, 9, 9, 9, 9], [3, 4, 9, 9, 9, 9]])
1128 + req_indices = torch.tensor([0, 0, 1, 1])
1129 + prompt_lens = torch.tensor([0, 0])
1130 + total_lens = torch.tensor([6, 6])
1131 + prior_contexts = torch.zeros(num_rows, 3, 2, dtype=torch.int64)
1132 + contexts = torch.tensor([[3, 4]]).expand(num_rows, 2).contiguous()
1133 + enabled = torch.ones(num_rows, dtype=torch.bool)
1134 +
1135 + def run(steps, device):
1136 + prior = prior_contexts.clone().to(device)
1137 + active = draft_watermarking_mask(
1138 + all_token_ids.to(device),
1139 + req_indices.to(device),
1140 + prompt_lens.to(device),
1141 + total_lens.to(device),
1142 + prior,
1143 + contexts.to(device),
1144 + steps,
1145 + enabled.to(device),
1146 + max_history=4,
1147 + include_prompt=False,
1148 + )
1149 + return active.cpu(), prior.cpu()
1150 +
1151 + expected, expected_prior = run(torch.full((num_rows,), 2), torch.device("cpu"))
1152 + assert torch.equal(expected, torch.ones(num_rows, dtype=torch.bool))
1153 +
1154 + materialized, materialized_prior = run(
1155 + torch.full((num_rows,), 2).cuda(), torch.device("cuda")
1156 + )
1157 + assert torch.equal(materialized, expected)
1158 + assert torch.equal(materialized_prior, expected_prior)
1159 +
1160 + # Neighbouring steps are the value a row reads when the broadcast stride is
1161 + # ignored; the production tensor reads uninitialized memory instead.
1162 + neighbours = torch.tensor([2, 0, 0, 0]).cuda()
1163 + broadcast, broadcast_prior = run(
1164 + neighbours[0].expand(num_rows), torch.device("cuda")
1165 + )
1166 + assert torch.equal(broadcast, expected)
1167 + assert torch.equal(broadcast_prior, expected_prior)
1168 +
1169 + step = torch.tensor(2).cuda()
1170 + production, production_prior = run(step.expand(num_rows), torch.device("cuda"))
1171 + assert torch.equal(production, expected)
1172 + assert torch.equal(production_prior, expected_prior)
1173 +
1174 +
1175 def test_draft_sampler_uses_draft_key_and_advances_context(monkeypatch):
1176 class StubSpeculator(DraftModelSpeculator):
1177 def capture(self): ...
@@ -937,22 +1188,32 @@ def test_draft_sampler_uses_draft_key_and_advances_context(monkeypatch):
1188 return torch.zeros(hidden_states.shape[0], 8)
1189
1190 class StubWatermarker:
1191 + context_width = 2
1192 +
1193 @staticmethod
941 - def sample(logits, contexts, random_sampler=None):
942 - assert random_sampler is None
943 - return WatermarkSample(torch.tensor([7, 7]), logits)
1194 + def sample(logits, contexts, random_sampler=None, skip_mask=None):
1195 + assert random_sampler is not None
1196 + return WatermarkSample(
1197 + torch.where(skip_mask, random_sampler(logits), 7), logits
1198 + )
1199
1200 speculator = object.__new__(StubSpeculator)
1201 speculator.model = StubModel()
1202 speculator.use_fp64_gumbel = False
1203 speculator.acceptance_estimator = None
949 - draft_watermarker = object.__new__(DraftWatermarker)
950 - draft_watermarker.watermarker = StubWatermarker()
951 - draft_watermarker.contexts = torch.tensor([[1, 2], [3, 4]])
1204 + draft_watermarker = DraftWatermarker(
1205 + StubWatermarker(),
1206 + max_num_reqs=2,
1207 + device=torch.device("cpu"),
1208 + num_speculative_steps=1,
1209 + deduplicate_contexts="none",
1210 + deduplicate_contexts_max_history=None,
1211 + )
1212 + draft_watermarker.contexts.copy_(torch.tensor([[1, 2], [3, 4]]))
1213 draft_watermarker.enabled = torch.tensor([True, False])
1214 speculator.draft_watermarker = draft_watermarker
1215 monkeypatch.setattr(
955 - "vllm.v1.worker.gpu.spec_decode.speculator.gumbel_sample",
1216 + "vllm.v1.watermarking.watermarker.gumbel_sample",
1217 lambda *args, **kwargs: torch.tensor([3, 4]),
1218 )
1219
@@ -970,6 +1231,274 @@ def test_draft_sampler_uses_draft_key_and_advances_context(monkeypatch):
1231 assert torch.equal(draft_watermarker.contexts, torch.tensor([[2, 7], [4, 4]]))
1232
1233
1234 +def test_draft_sampler_uses_ordinary_samples_for_repeated_contexts(monkeypatch):
1235 + class StubWatermarker:
1236 + context_width = 1
1237 +
1238 + def __init__(self):
1239 + self.samples = iter((2, 1, 8))
1240 +
1241 + def sample(self, logits, contexts, random_sampler=None, skip_mask=None):
1242 + watermarked = torch.tensor([next(self.samples)])
1243 + return WatermarkSample(
1244 + torch.where(skip_mask, random_sampler(logits), watermarked), logits
1245 + )
1246 +
1247 + draft_watermarker = DraftWatermarker(
1248 + StubWatermarker(),
1249 + max_num_reqs=1,
1250 + device=torch.device("cpu"),
1251 + num_speculative_steps=3,
1252 + deduplicate_contexts="single_turn",
1253 + deduplicate_contexts_max_history=None,
1254 + )
1255 + draft_watermarker.prepare(
1256 + contexts=torch.tensor([[1]]),
1257 + enabled=torch.tensor([True]),
1258 + all_token_ids=torch.tensor([[100, 1, 0, 0]]),
1259 + prompt_lens=torch.tensor([1]),
1260 + total_lens=torch.tensor([2]),
1261 + )
1262 + idx_mapping = torch.tensor([0])
1263 + temperature = torch.tensor([1.0])
1264 + ordinary_samples = iter((torch.tensor([5]), torch.tensor([6]), torch.tensor([4])))
1265 + monkeypatch.setattr(
1266 + "vllm.v1.watermarking.watermarker.gumbel_sample",
1267 + lambda *args, **kwargs: next(ordinary_samples),
1268 + )
1269 + seeds = torch.tensor([0])
1270 + draft_logits = torch.zeros(1, 3, 8)
1271 +
1272 + first = draft_watermarker.sample(
1273 + torch.zeros(1, 8),
1274 + idx_mapping,
1275 + temperature=temperature,
1276 + seed=seeds,
1277 + pos=torch.tensor([0]),
1278 + apply_temperature=True,
1279 + is_drafting=True,
1280 + logits_cache_col=torch.tensor(0),
1281 + logits_cache=draft_logits,
1282 + use_fp64=False,
1283 + )
1284 + second = draft_watermarker.sample(
1285 + torch.zeros(1, 8),
1286 + idx_mapping,
1287 + temperature=temperature,
1288 + seed=seeds,
1289 + pos=torch.tensor([1]),
1290 + apply_temperature=True,
1291 + is_drafting=True,
1292 + logits_cache_col=torch.tensor(1),
1293 + logits_cache=draft_logits,
1294 + use_fp64=False,
1295 + )
1296 + repeated = draft_watermarker.sample(
1297 + torch.zeros(1, 8),
1298 + idx_mapping,
1299 + temperature=temperature,
1300 + seed=seeds,
1301 + pos=torch.tensor([2]),
1302 + apply_temperature=True,
1303 + is_drafting=True,
1304 + logits_cache_col=torch.tensor(2),
1305 + logits_cache=draft_logits,
1306 + use_fp64=False,
1307 + )
1308 +
1309 + assert first.item() == 2
1310 + assert second.item() == 1
1311 + assert repeated.item() == 4
1312 +
1313 +
1314 +@pytest.mark.skipif(
1315 + not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
1316 +)
1317 +def test_draft_context_deduplication_reads_strided_request_indices():
1318 + """DSpark samples a step at a time, so req_indices is an `idx_map[:, i]` column."""
1319 + num_reqs = 4
1320 + num_speculative_steps = 3
1321 + all_token_ids = torch.tensor(
1322 + [[3, 4, 3, 4], [9, 9, 9, 9], [3, 4, 3, 4], [9, 9, 9, 9]]
1323 + ).cuda()
1324 + prompt_lens = torch.zeros(num_reqs, dtype=torch.int64).cuda()
1325 + total_lens = torch.full((num_reqs,), 4).cuda()
1326 + contexts = torch.tensor([[3, 4]]).expand(num_reqs, 2).contiguous().cuda()
1327 + steps = torch.zeros(num_reqs, dtype=torch.int64).cuda()
1328 + enabled = torch.ones(num_reqs, dtype=torch.bool).cuda()
1329 +
1330 + idx_map = (
1331 + torch.arange(num_reqs)
1332 + .repeat_interleave(num_speculative_steps)
1333 + .view(num_reqs, num_speculative_steps)
1334 + .cuda()
1335 + )
1336 + column = idx_map[:, 1]
1337 + assert column.stride(0) == num_speculative_steps
1338 +
1339 + def run(req_indices):
1340 + prior = torch.zeros(
1341 + num_reqs, num_speculative_steps, 2, dtype=torch.int64, device="cuda"
1342 + )
1343 + return draft_watermarking_mask(
1344 + all_token_ids,
1345 + req_indices,
1346 + prompt_lens,
1347 + total_lens,
1348 + prior,
1349 + contexts,
1350 + steps,
1351 + enabled,
1352 + max_history=None,
1353 + include_prompt=False,
1354 + ).cpu()
1355 +
1356 + expected = torch.tensor([False, True, False, True])
1357 + assert torch.equal(run(column.contiguous()), expected)
1358 + assert torch.equal(run(column), expected)
1359 +
1360 +
1361 +def test_draft_sampler_deduplicates_against_committed_history(monkeypatch):
1362 + class StubWatermarker:
1363 + context_width = 1
1364 +
1365 + @staticmethod
1366 + def sample(logits, contexts, random_sampler=None, skip_mask=None):
1367 + return WatermarkSample(
1368 + torch.where(skip_mask, random_sampler(logits), 7), logits
1369 + )
1370 +
1371 + draft_watermarker = DraftWatermarker(
1372 + StubWatermarker(),
1373 + max_num_reqs=1,
1374 + device=torch.device("cpu"),
1375 + num_speculative_steps=1,
1376 + deduplicate_contexts="single_turn",
1377 + deduplicate_contexts_max_history=None,
1378 + )
1379 + draft_watermarker.prepare(
1380 + contexts=torch.tensor([[1]]),
1381 + enabled=torch.tensor([True]),
1382 + all_token_ids=torch.tensor([[100, 1, 2, 1]]),
1383 + prompt_lens=torch.tensor([1]),
1384 + total_lens=torch.tensor([4]),
1385 + )
1386 +
1387 + monkeypatch.setattr(
1388 + "vllm.v1.watermarking.watermarker.gumbel_sample",
1389 + lambda *args, **kwargs: torch.tensor([4]),
1390 + )
1391 + sampled = draft_watermarker.sample(
1392 + torch.zeros(1, 8),
1393 + torch.tensor([0]),
1394 + temperature=torch.tensor([1.0]),
1395 + seed=torch.tensor([0]),
1396 + pos=torch.tensor([0]),
1397 + apply_temperature=True,
1398 + is_drafting=True,
1399 + logits_cache_col=torch.tensor(0),
1400 + logits_cache=torch.zeros(1, 1, 8),
1401 + use_fp64=False,
1402 + )
1403 +
1404 + assert sampled.item() == 4
1405 +
1406 +
1407 +@pytest.mark.skipif(
1408 + not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
1409 +)
1410 +@pytest.mark.parametrize("idx_mapping_layout", ["contiguous", "column_view"])
1411 +def test_draft_watermarker_deduplicates_across_a_block_on_cuda(
1412 + monkeypatch, idx_mapping_layout
1413 +):
1414 + """Drive the whole draft sampler over a block with the production shapes.
1415 +
1416 + `draft_step` is a 0-d tensor, and a speculator that samples one step at a
1417 + time hands over a column of its index map. Each step is checked against the
1418 + committed history the window still reaches and against the earlier steps of
1419 + the block.
1420 + """
1421 + num_reqs, num_steps, vocab_size = 2, 3, 16
1422 + watermarker = GumbelWatermarker(key=42, context_width=2)
1423 + draft_watermarker = DraftWatermarker(
1424 + watermarker,
1425 + max_num_reqs=num_reqs,
1426 + device=torch.device("cuda"),
1427 + num_speculative_steps=num_steps,
1428 + deduplicate_contexts="single_turn",
1429 + deduplicate_contexts_max_history=4,
1430 + )
1431 + draft_watermarker.prepare(
1432 + contexts=torch.tensor([[5, 6], [6, 3]]).cuda(),
1433 + enabled=torch.ones(num_reqs, dtype=torch.bool).cuda(),
1434 + all_token_ids=torch.tensor([[5, 6, 7, 5, 6], [8, 9, 6, 6, 3]]).cuda(),
1435 + prompt_lens=torch.zeros(num_reqs, dtype=torch.int64).cuda(),
1436 + total_lens=torch.full((num_reqs,), 5).cuda(),
1437 + )
1438 +
1439 + if idx_mapping_layout == "contiguous":
1440 + idx_mapping = torch.arange(num_reqs, dtype=torch.int32).cuda()
1441 + else:
1442 + idx_mapping = (
1443 + torch.arange(num_reqs, dtype=torch.int32)
1444 + .repeat_interleave(num_steps)
1445 + .view(num_reqs, num_steps)
1446 + .cuda()
1447 + )[:, 1]
1448 + assert idx_mapping.stride(0) == num_steps
1449 +
1450 + skip_masks: list[torch.Tensor] = []
1451 + original = philox_gumbel_sample
1452 +
1453 + def spy(*args, **kwargs):
1454 + skip_masks.append(kwargs["skip_mask"].cpu())
1455 + return original(*args, **kwargs)
1456 +
1457 + monkeypatch.setattr("vllm.v1.worker.gpu.sample.watermark.philox_gumbel_sample", spy)
1458 +
1459 + draft_logits = torch.zeros(num_reqs, num_steps, vocab_size + 1).cuda()
1460 + step_logits = []
1461 + for step in range(num_steps):
1462 + # Token 6 wins by a margin no Gumbel draw can close, on either path.
1463 + logits = torch.zeros(num_reqs, vocab_size).cuda()
1464 + logits[:, 6] = 100.0
1465 + logits[:, 0] = float(step) + 1.0
1466 + step_logits.append(logits)
1467 + sampled = draft_watermarker.sample(
1468 + logits,
1469 + idx_mapping,
1470 + temperature=torch.ones(num_reqs).cuda(),
1471 + seed=torch.zeros(num_reqs, dtype=torch.int64).cuda(),
1472 + pos=torch.full((num_reqs,), 5 + step, dtype=torch.int64).cuda(),
1473 + apply_temperature=True,
1474 + is_drafting=True,
1475 + logits_cache_col=torch.tensor(step).cuda(),
1476 + logits_cache=draft_logits,
1477 + use_fp64=False,
1478 + )
1479 + assert torch.equal(sampled.cpu(), torch.tensor([6, 6]))
1480 +
1481 + # Step 0: request 0's [5, 6] is committed at position 2, request 1's [6, 3]
1482 + # is new. Step 1: [6, 6] and [3, 6] are both new. Step 2: request 0's
1483 + # [6, 6] repeats its own step-1 context and request 1's repeats position 4.
1484 + assert [mask.tolist() for mask in skip_masks] == [
1485 + [True, False],
1486 + [False, False],
1487 + [True, True],
1488 + ]
1489 + assert torch.equal(
1490 + draft_watermarker.prior_contexts[:num_reqs].cpu(),
1491 + torch.tensor([[[5, 6], [6, 6], [6, 6]], [[6, 3], [3, 6], [6, 6]]]),
1492 + )
1493 + assert torch.equal(
1494 + draft_watermarker.contexts[:num_reqs].cpu(), torch.tensor([[6, 6], [6, 6]])
1495 + )
1496 + for step, logits in enumerate(step_logits):
1497 + # The 0-d draft step selects the logits cache column.
1498 + assert torch.equal(draft_logits[:, step, :vocab_size], logits)
1499 + assert not draft_logits[:, :, -1].any()
1500 +
1501 +
1502 def test_dspark_reduced_vocab_draft_sampler_applies_watermarking(monkeypatch):
1503 speculator = object.__new__(DSparkSpeculator)
1504 speculator.draft_logits = torch.zeros(2, 1, 8)
@@ -982,15 +1511,23 @@ def test_dspark_reduced_vocab_draft_sampler_applies_watermarking(monkeypatch):
1511 speculator.acceptance_estimator = None
1512 watermark_logits: list[torch.Tensor] = []
1513
985 - def sample(logits, sampled, idx_map, temperature):
1514 + def sample(
1515 + logits,
1516 + idx_mapping,
1517 + temperature,
1518 + seed,
1519 + pos,
1520 + apply_temperature,
1521 + is_drafting,
1522 + logits_cache=None,
1523 + logits_cache_col=None,
1524 + use_fp64=False,
1525 + logits_cache_source=None,
1526 + ):
1527 watermark_logits.append(logits.clone())
987 - return sampled + 1
1528 + return torch.tensor([4, 5])
1529
1530 speculator.draft_watermarker = SimpleNamespace(sample=sample)
990 - monkeypatch.setattr(
991 - "vllm.v1.worker.gpu.spec_decode.dspark.speculator.gumbel_sample",
992 - lambda *args, **kwargs: torch.tensor([3, 4]),
993 - )
1531
1532 sampled = speculator._sample_logits(
1533 torch.tensor([[10.0, 20.0], [30.0, 40.0]]),
vllm/config/vllm.py
-7
@@ -1344,13 +1344,6 @@ class VllmConfig:
1344 "share of output tokens supplied by accepted drafts.",
1345 scope="global",
1346 )
1347 - if watermark_config.deduplicate_contexts != "none":
1348 - logger.warning_once(
1349 - "Context deduplication is not supported with speculative "
1350 - "decoding and will not be applied to accepted drafts, "
1351 - "rejection-recovery tokens, or bonus tokens.",
1352 - scope="global",
1353 - )
1347 if watermark_config.algorithm == "dual_key_gumbel" and (
1348 watermark_config.alpha != get_field(WatermarkConfig, "alpha").default
1349 ):
vllm/v1/watermarking/gpu_sampler.py
+11 -2
@@ -119,8 +119,9 @@ class GPUWatermarkSampler(Sampler):
119 self,
120 expanded_idx_mapping: torch.Tensor,
121 contexts: torch.Tensor,
122 + expanded_local_pos: torch.Tensor | None = None,
123 ) -> torch.Tensor:
123 - return repeated_context_mask(
124 + repeated = repeated_context_mask(
125 self.req_states.all_token_ids.gpu,
126 expanded_idx_mapping,
127 self.req_states.prompt_len.gpu,
@@ -128,8 +129,16 @@ class GPUWatermarkSampler(Sampler):
129 contexts,
130 self.deduplicate_contexts_max_history,
131 include_prompt=self.deduplicate_contexts == "all",
131 - skip_partial_context=self.deduplicate_contexts == "all",
132 + skip_partial_context=(
133 + self.deduplicate_contexts == "all" and expanded_local_pos is None
134 + ),
135 + history_offsets=expanded_local_pos,
136 + local_positions=expanded_local_pos,
137 + num_speculative_steps=(
138 + self.num_speculative_tokens if expanded_local_pos is not None else 0
139 + ),
140 )
141 + return repeated
142
143 def _get_contexts(
144 self,
vllm/v1/watermarking/gumbel.py
+4
@@ -94,6 +94,10 @@ class GumbelWatermarker(Watermarker):
94 seeds=random_sampler.seeds,
95 positions=random_sampler.positions,
96 use_fp64=random_sampler.use_fp64,
97 + is_drafting=random_sampler.is_drafting,
98 + logits_cache=random_sampler.logits_cache,
99 + logits_cache_col=random_sampler.logits_cache_col,
100 + logits_cache_source=random_sampler.logits_cache_source,
101 )
102 return WatermarkSample(token_ids, logits)
103
vllm/v1/watermarking/spec_decode.py
+112 -10
@@ -3,14 +3,16 @@
3
4 import torch
5
6 -from vllm.config.watermarking import WatermarkConfig
6 +from vllm.config.watermarking import WatermarkConfig, WatermarkContextScope
7 from vllm.v1.watermarking.factory import create_watermarker
8 from vllm.v1.watermarking.gumbel import GumbelWatermarker
9 from vllm.v1.watermarking.prfs import PhiloxPRF
10 from vllm.v1.watermarking.watermarker import (
11 + RandomSampler,
12 SupportsSpeculativeDecoding,
13 Watermarker,
14 )
15 +from vllm.v1.worker.gpu.sample.watermark import draft_watermarking_mask
16 from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import rejection_sample
17
18
@@ -20,8 +22,14 @@ class DraftWatermarker:
22 watermarker: Watermarker,
23 max_num_reqs: int,
24 device: torch.device,
25 + num_speculative_steps: int,
26 + deduplicate_contexts: WatermarkContextScope,
27 + deduplicate_contexts_max_history: int | None,
28 ) -> None:
29 self.watermarker = watermarker
30 + self.num_speculative_steps = num_speculative_steps
31 + self.deduplicate_contexts = deduplicate_contexts
32 + self.deduplicate_contexts_max_history = deduplicate_contexts_max_history
33 self.contexts = torch.zeros(
34 max_num_reqs,
35 watermarker.context_width,
@@ -29,30 +37,112 @@ class DraftWatermarker:
37 device=device,
38 )
39 self.enabled = torch.zeros(max_num_reqs, dtype=torch.bool, device=device)
40 + self.prior_contexts = torch.zeros(
41 + max_num_reqs,
42 + num_speculative_steps,
43 + watermarker.context_width,
44 + dtype=torch.int64,
45 + device=device,
46 + )
47 + self.all_token_ids: torch.Tensor | None = None
48 + self.prompt_lens: torch.Tensor | None = None
49 + self.total_lens: torch.Tensor | None = None
50
33 - def prepare(self, contexts: torch.Tensor, enabled: torch.Tensor) -> None:
51 + def prepare(
52 + self,
53 + contexts: torch.Tensor,
54 + enabled: torch.Tensor,
55 + all_token_ids: torch.Tensor,
56 + prompt_lens: torch.Tensor,
57 + total_lens: torch.Tensor,
58 + ) -> None:
59 num_reqs = contexts.shape[0]
60 self.contexts[:num_reqs].copy_(contexts)
61 self.enabled[:num_reqs].copy_(enabled)
62 + self.all_token_ids = all_token_ids
63 + self.prompt_lens = prompt_lens
64 + self.total_lens = total_lens
65 +
66 + def _sampling_state(
67 + self,
68 + logits: torch.Tensor,
69 + idx_mapping: torch.Tensor,
70 + request_temperatures: torch.Tensor,
71 + draft_step: int | torch.Tensor,
72 + contexts: torch.Tensor,
73 + ) -> tuple[torch.Tensor, torch.Tensor]:
74 + num_rows = logits.shape[0]
75 + steps = torch.as_tensor(draft_step, device=logits.device, dtype=torch.int64)
76 + if steps.ndim == 0:
77 + steps = steps.expand(num_rows)
78 + enabled = self.enabled[:num_rows] & (request_temperatures != 0)
79 + if self.deduplicate_contexts == "none":
80 + return steps, enabled
81 + assert self.all_token_ids is not None
82 + assert self.prompt_lens is not None
83 + assert self.total_lens is not None
84 + enabled = draft_watermarking_mask(
85 + self.all_token_ids,
86 + idx_mapping,
87 + self.prompt_lens,
88 + self.total_lens,
89 + self.prior_contexts,
90 + contexts,
91 + steps,
92 + enabled,
93 + self.deduplicate_contexts_max_history,
94 + include_prompt=self.deduplicate_contexts == "all",
95 + )
96 + return steps, enabled
97
98 def sample(
99 self,
100 logits: torch.Tensor,
41 - ordinary_sampled: torch.Tensor,
101 idx_mapping: torch.Tensor,
102 temperature: torch.Tensor,
103 + seed: torch.Tensor,
104 + pos: torch.Tensor,
105 + apply_temperature: bool,
106 + is_drafting: bool,
107 + logits_cache: torch.Tensor | None = None,
108 + logits_cache_col: torch.Tensor | None = None,
109 + use_fp64: bool = False,
110 + logits_cache_source: torch.Tensor | None = None,
111 ) -> torch.Tensor:
112 + assert apply_temperature
113 + assert is_drafting
114 + assert logits_cache is not None
115 + assert logits_cache_col is not None
116 + num_rows = logits.shape[0]
117 request_temperatures = temperature[idx_mapping]
118 + contexts = self.contexts[:num_rows]
119 + steps, enabled = self._sampling_state(
120 + logits, idx_mapping, request_temperatures, logits_cache_col, contexts
121 + )
122 +
123 processed_logits = logits / torch.where(
124 request_temperatures == 0, 1, request_temperatures
125 ).unsqueeze(-1)
49 - watermarked = self.watermarker.sample(
126 + random_sampler = RandomSampler(
127 + expanded_idx_mapping=idx_mapping,
128 + temperatures=temperature,
129 + seeds=seed,
130 + positions=pos,
131 + use_fp64=use_fp64,
132 + is_drafting=True,
133 + logits_cache=logits_cache,
134 + logits_cache_col=steps,
135 + logits_cache_source=(
136 + logits if logits_cache_source is None else logits_cache_source
137 + ),
138 + )
139 + sampled = self.watermarker.sample(
140 processed_logits,
51 - self.contexts[: logits.shape[0]],
141 + contexts,
142 + random_sampler=random_sampler,
143 + skip_mask=~enabled,
144 ).token_ids
53 - enabled = self.enabled[: logits.shape[0]] & (request_temperatures != 0)
54 - sampled = torch.where(enabled, watermarked, ordinary_sampled)
55 - contexts = self.contexts[: logits.shape[0]]
145 +
146 contexts.copy_(torch.cat((contexts[:, 1:], sampled.unsqueeze(-1)), dim=-1))
147 return sampled
148
@@ -68,9 +158,19 @@ def create_speculative_draft_watermarker(
158 max_num_reqs: int,
159 device: torch.device,
160 allow_target_only: bool,
161 + num_speculative_steps: int = 1,
162 + deduplicate_contexts: WatermarkContextScope = "none",
163 + deduplicate_contexts_max_history: int | None = 8192,
164 ) -> DraftWatermarker | None:
165 if isinstance(watermarker, SupportsSpeculativeDecoding):
73 - return DraftWatermarker(watermarker.draft_watermarker, max_num_reqs, device)
166 + return DraftWatermarker(
167 + watermarker.draft_watermarker,
168 + max_num_reqs,
169 + device,
170 + num_speculative_steps,
171 + deduplicate_contexts,
172 + deduplicate_contexts_max_history,
173 + )
174 if allow_target_only:
175 return None
176 raise ValueError(
@@ -130,9 +230,10 @@ def watermarked_rejection_sample(
230 contexts: torch.Tensor,
231 watermarking: torch.Tensor,
232 watermarker: Watermarker,
233 + watermarking_skip_mask: torch.Tensor | None = None,
234 use_fp64: bool = False,
235 ) -> tuple[torch.Tensor, torch.Tensor]:
135 - """Use the target key for rejection recovery and bonus tokens."""
236 + """Use the target key except at contexts selected for ordinary sampling."""
237 assert contexts.shape == (target_logits.shape[0], watermarker.context_width)
238 return rejection_sample(
239 target_logits,
@@ -149,5 +250,6 @@ def watermarked_rejection_sample(
250 use_fp64=use_fp64,
251 contexts=contexts,
252 watermarking=watermarking,
253 + watermarking_skip_mask=watermarking_skip_mask,
254 watermark_key=_resolve_watermark_key(watermarker),
255 )
vllm/v1/watermarking/watermarker.py
+8 -1
@@ -23,6 +23,10 @@ class RandomSampler:
23 seeds: torch.Tensor
24 positions: torch.Tensor
25 use_fp64: bool = False
26 + is_drafting: bool = False
27 + logits_cache: torch.Tensor | None = None
28 + logits_cache_col: torch.Tensor | None = None
29 + logits_cache_source: torch.Tensor | None = None
30
31 def __call__(self, logits: torch.Tensor) -> torch.Tensor:
32 return gumbel_sample(
@@ -32,7 +36,10 @@ class RandomSampler:
36 self.seeds,
37 self.positions,
38 apply_temperature=False,
35 - is_drafting=False,
39 + is_drafting=self.is_drafting,
40 + logits_cache=self.logits_cache,
41 + logits_cache_col=self.logits_cache_col,
42 + logits_cache_source=self.logits_cache_source,
43 use_fp64=self.use_fp64,
44 )
45
vllm/v1/worker/gpu/model_runner.py
+2 -4
@@ -914,8 +914,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
914 spec_hidden_states = pre_hc_hidden_states[: hidden_states.shape[0]] # type: ignore[union-attr]
915 if isinstance(self.sampler, GPUWatermarkSampler):
916 self.speculator.prepare_watermarking(
917 - self.sampler._get_contexts(input_batch.idx_mapping),
918 - self.sampler.watermarking.gpu[input_batch.idx_mapping],
917 + self.sampler, input_batch.idx_mapping
918 )
919 with use_workspace_lane(self._draft_workspace_lane):
920 self.speculator.propose(
@@ -2194,8 +2193,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2193 spec_hidden_states = pre_hc_hidden_states[: draft_hidden_states.size(0)]
2194 if isinstance(self.sampler, GPUWatermarkSampler):
2195 self.speculator.prepare_watermarking(
2197 - self.sampler._get_contexts(input_batch.idx_mapping),
2198 - self.sampler.watermarking.gpu[input_batch.idx_mapping],
2196 + self.sampler, input_batch.idx_mapping
2197 )
2198 with use_workspace_lane(self._draft_workspace_lane):
2199 draft_tokens = self.speculator.propose(
vllm/v1/worker/gpu/sample/gumbel.py
+29 -1
@@ -220,6 +220,8 @@ def gumbel_block_argmax(
220 logits_cache_stride_0,
221 logits_cache_stride_1,
222 logits_cache_col_ptr,
223 + logits_cache_source_ptr,
224 + logits_cache_source_stride,
225 vocab_size,
226 IS_DRAFTING: tl.constexpr,
227 APPLY_TEMPERATURE: tl.constexpr,
@@ -240,12 +242,16 @@ def gumbel_block_argmax(
242 col = tl.load(logits_cache_col_ptr + token_idx)
243 else:
244 col = tl.load(logits_cache_col_ptr)
245 + cached_logits = tl.load(
246 + logits_cache_source_ptr + token_idx * logits_cache_source_stride + block,
247 + mask=mask,
248 + )
249 tl.store(
250 logits_cache_ptr
251 + req_state_idx * logits_cache_stride_0
252 + col * logits_cache_stride_1
253 + block,
248 - logits,
254 + cached_logits,
255 mask=mask & is_valid_req,
256 )
257
@@ -275,6 +281,8 @@ def _gumbel_sample_kernel(
281 logits_cache_stride_0,
282 logits_cache_stride_1,
283 logits_cache_col_ptr,
284 + logits_cache_source_ptr,
285 + logits_cache_source_stride,
286 logits_ptr,
287 logits_stride,
288 expanded_idx_mapping_ptr,
@@ -312,6 +320,8 @@ def _gumbel_sample_kernel(
320 logits_cache_stride_0,
321 logits_cache_stride_1,
322 logits_cache_col_ptr,
323 + logits_cache_source_ptr,
324 + logits_cache_source_stride,
325 vocab_size,
326 IS_DRAFTING=IS_DRAFTING,
327 APPLY_TEMPERATURE=APPLY_TEMPERATURE,
@@ -334,6 +344,7 @@ def gumbel_sample(
344 logits_cache: torch.Tensor | None = None, # [max_num_reqs, num_cols, vocab_size]
345 logits_cache_col: torch.Tensor | None = None, # scalar or [num_tokens]
346 use_fp64: bool = False,
347 + logits_cache_source: torch.Tensor | None = None,
348 ) -> torch.Tensor:
349 # Enforce contiguity on non-strided input tensors
350 expanded_idx_mapping = expanded_idx_mapping.contiguous()
@@ -342,11 +353,26 @@ def gumbel_sample(
353 logits_cache_col = logits_cache_col.contiguous()
354 num_tokens, vocab_size = logits.shape
355 if logits_cache is not None:
356 + if logits_cache_source is None:
357 + logits_cache_source = logits
358 + assert logits_cache_source.shape == logits.shape, (
359 + "logits cache source must match sampled logits shape"
360 + )
361 + assert logits_cache_source.device == logits.device, (
362 + "logits cache source must be on the sampled logits device"
363 + )
364 + assert logits_cache_source.dtype == logits_cache.dtype, (
365 + "logits cache source and destination must have the same dtype"
366 + )
367 assert logits_cache.size(-1) >= vocab_size, (
368 f"draft logits cache vocab dim ({logits_cache.size(-1)}) is narrower "
369 f"than the sampled logits ({vocab_size}). Cached logits would be "
370 "truncated."
371 )
372 + elif logits_cache_source is not None:
373 + raise ValueError("logits_cache_source requires logits_cache")
374 + if logits_cache_source is not None and logits_cache_source.stride(-1) != 1:
375 + logits_cache_source = logits_cache_source.contiguous()
376 BLOCK_SIZE = 1024
377 num_blocks = triton.cdiv(vocab_size, BLOCK_SIZE)
378 local_argmax = logits.new_empty(num_tokens, num_blocks, dtype=torch.int64)
@@ -362,6 +388,8 @@ def gumbel_sample(
388 logits_cache.stride(0) if logits_cache is not None else 0,
389 logits_cache.stride(1) if logits_cache is not None else 0,
390 logits_cache_col,
391 + logits_cache_source,
392 + logits_cache_source.stride(0) if logits_cache_source is not None else 0,
393 logits,
394 logits.stride(0),
395 expanded_idx_mapping,
vllm/v1/worker/gpu/sample/watermark.py
+315 -7
@@ -51,12 +51,25 @@ def _repeated_context_mask_kernel(
51 req_indices_ptr,
52 prompt_lens_ptr,
53 total_lens_ptr,
54 + history_offsets_ptr,
55 + history_offsets_stride,
56 contexts_ptr,
57 context_stride,
58 + local_positions_ptr,
59 + prior_contexts_ptr,
60 + prior_contexts_stride_0,
61 + prior_contexts_stride_1,
62 + steps_ptr,
63 + steps_stride,
64 + enabled_ptr,
65 CONTEXT_WIDTH: tl.constexpr,
66 + NUM_SPECULATIVE_STEPS: tl.constexpr,
67 MAX_HISTORY: tl.constexpr,
68 INCLUDE_PROMPT: tl.constexpr,
69 SKIP_PARTIAL_CONTEXT: tl.constexpr,
70 + HAS_HISTORY_OFFSETS: tl.constexpr,
71 + SPECULATIVE_CONTEXTS: tl.constexpr,
72 + DRAFT_CONTEXTS: tl.constexpr,
73 BLOCK: tl.constexpr,
74 ):
75 row = tl.program_id(0).to(tl.int64)
@@ -71,12 +84,16 @@ def _repeated_context_mask_kernel(
84 return
85 sequence_start = 0 if INCLUDE_PROMPT else prompt_len
86 history_len = total_len - sequence_start
87 + history_offset = 0
88 + if HAS_HISTORY_OFFSETS:
89 + history_offset = tl.load(history_offsets_ptr + row * history_offsets_stride)
90
91 offsets = tl.arange(0, BLOCK)
92 repeated = tl.full((), 0, tl.int32)
93 scan_start = 0
94 if MAX_HISTORY > 0:
79 - scan_start = tl.maximum(history_len - MAX_HISTORY, 0)
95 + remaining_history = tl.maximum(MAX_HISTORY - history_offset, 0)
96 + scan_start = tl.maximum(history_len - remaining_history, 0)
97 # Aligned loop bounds let the per-lane offsets fold into immediates.
98 aligned_start = (scan_start // BLOCK) * BLOCK
99 for block_start in tl.range(aligned_start, history_len, BLOCK):
@@ -96,6 +113,93 @@ def _repeated_context_mask_kernel(
113 )
114 matches &= historical_token == context_token
115 repeated |= tl.max(matches.to(tl.int32), axis=0)
116 +
117 + if SPECULATIVE_CONTEXTS:
118 + local_pos = tl.load(local_positions_ptr + row)
119 + if INCLUDE_PROMPT:
120 + partial_context = tl.full((), 0, tl.int32)
121 + for context_offset in range(CONTEXT_WIDTH):
122 + context_token = tl.load(
123 + contexts_ptr + row * context_stride + context_offset
124 + )
125 + partial_context |= context_token < 0
126 + repeated |= partial_context
127 + for offset in range(1, NUM_SPECULATIVE_STEPS + 1):
128 + prior_row = row - offset
129 + in_history = (prior_row >= 0) & (local_pos >= offset)
130 + if MAX_HISTORY > 0:
131 + in_history &= offset <= MAX_HISTORY
132 + prior_req_idx = tl.load(
133 + req_indices_ptr + tl.maximum(prior_row, 0),
134 + mask=in_history,
135 + other=-1,
136 + )
137 + prior_local_pos = tl.load(
138 + local_positions_ptr + tl.maximum(prior_row, 0),
139 + mask=in_history,
140 + other=-1,
141 + )
142 + matches = (
143 + in_history
144 + & (req_idx == prior_req_idx)
145 + & (local_pos == prior_local_pos + offset)
146 + )
147 + for context_offset in range(CONTEXT_WIDTH):
148 + context_token = tl.load(
149 + contexts_ptr + row * context_stride + context_offset
150 + )
151 + prior_token = tl.load(
152 + contexts_ptr
153 + + tl.maximum(prior_row, 0) * context_stride
154 + + context_offset,
155 + mask=in_history,
156 + other=-2,
157 + )
158 + matches &= context_token == prior_token
159 + repeated |= matches
160 +
161 + if DRAFT_CONTEXTS:
162 + step = tl.load(steps_ptr + row * steps_stride).to(tl.int64)
163 + partial_context = tl.full((), 0, tl.int32)
164 + for context_offset in range(CONTEXT_WIDTH):
165 + context_token = tl.load(
166 + contexts_ptr + row * context_stride + context_offset
167 + )
168 + partial_context |= context_token < 0
169 + if INCLUDE_PROMPT:
170 + repeated |= partial_context
171 +
172 + for prior_step in range(NUM_SPECULATIVE_STEPS):
173 + in_history = prior_step < step
174 + if MAX_HISTORY > 0:
175 + in_history &= prior_step >= step - MAX_HISTORY
176 + matches = in_history
177 + for context_offset in range(CONTEXT_WIDTH):
178 + context_token = tl.load(
179 + contexts_ptr + row * context_stride + context_offset
180 + )
181 + prior_token = tl.load(
182 + prior_contexts_ptr
183 + + row * prior_contexts_stride_0
184 + + prior_step * prior_contexts_stride_1
185 + + context_offset
186 + )
187 + matches &= context_token == prior_token
188 + repeated |= matches
189 +
190 + for context_offset in range(CONTEXT_WIDTH):
191 + context_token = tl.load(
192 + contexts_ptr + row * context_stride + context_offset
193 + )
194 + tl.store(
195 + prior_contexts_ptr
196 + + row * prior_contexts_stride_0
197 + + step * prior_contexts_stride_1
198 + + context_offset,
199 + context_token,
200 + )
201 + enabled = tl.load(enabled_ptr + row)
202 + repeated = enabled & (repeated == 0)
203 tl.store(output_ptr + row, repeated)
204
205
@@ -108,6 +212,7 @@ def _repeated_context_mask_cpu(
212 max_history: int | None = None,
213 include_prompt: bool = False,
214 skip_partial_context: bool = False,
215 + history_offsets: torch.Tensor | None = None,
216 ) -> torch.Tensor:
217 """Reference implementation; the parity tests check the Triton kernel against it."""
218 repeated = torch.zeros(len(req_indices), dtype=torch.bool)
@@ -124,8 +229,11 @@ def _repeated_context_mask_cpu(
229 history_tokens = all_token_ids[req_idx, sequence_start:total_len].tolist()
230 prefix = [-1] * contexts.shape[-1]
231 current_context = tuple(contexts[row].tolist())
232 + history_offset = 0 if history_offsets is None else int(history_offsets[row])
233 history_start = (
128 - max(0, len(history_tokens) - max_history) if max_history is not None else 0
234 + max(0, len(history_tokens) - max(0, max_history - history_offset))
235 + if max_history is not None
236 + else 0
237 )
238 for history_pos, token_id in enumerate(history_tokens):
239 if history_pos >= history_start and (
@@ -146,6 +254,9 @@ def repeated_context_mask(
254 max_history: int | None = None,
255 include_prompt: bool = False,
256 skip_partial_context: bool = False,
257 + history_offsets: torch.Tensor | None = None,
258 + local_positions: torch.Tensor | None = None,
259 + num_speculative_steps: int = 0,
260 ) -> torch.Tensor:
261 """Return, per row, whether the row's context already occurred in its history.
262
@@ -163,12 +274,23 @@ def repeated_context_mask(
274 include_prompt: Search the prompt as well as the generated tokens.
275 skip_partial_context: Mark contexts containing start padding so they use
276 ordinary sampling.
277 + history_offsets: Number of newer, non-committed positions preceding each
278 + context. These positions count toward `max_history`. Must be 1-D
279 + over rows; a stride-0 broadcast row is fine.
280 + local_positions: Position within each request's speculative block. When
281 + provided, compare each context with earlier contexts in that block.
282 + num_speculative_steps: Maximum number of draft tokens in the block.
283
284 """
285 if max_history is not None and max_history < 1:
286 raise ValueError("max_history must be positive or None")
287 + if (local_positions is None) != (num_speculative_steps == 0):
288 + raise ValueError(
289 + "local_positions and positive num_speculative_steps must be "
290 + "provided together"
291 + )
292 if all_token_ids.device.type == "cpu":
171 - return _repeated_context_mask_cpu(
293 + repeated = _repeated_context_mask_cpu(
294 all_token_ids,
295 req_indices,
296 prompt_lens,
@@ -177,10 +299,42 @@ def repeated_context_mask(
299 max_history,
300 include_prompt,
301 skip_partial_context,
302 + history_offsets,
303 )
304 + if local_positions is not None:
305 + max_offset = num_speculative_steps + 1
306 + if max_history is not None:
307 + max_offset = min(max_offset, max_history + 1)
308 + for offset in range(1, max_offset):
309 + prior_contexts = torch.cat(
310 + (contexts[:offset], contexts[:-offset]), dim=0
311 + )
312 + prior_requests = torch.cat(
313 + (req_indices[:offset], req_indices[:-offset]), dim=0
314 + )
315 + prior_local_pos = torch.cat(
316 + (local_positions[:offset], local_positions[:-offset]), dim=0
317 + )
318 + repeated |= (
319 + (req_indices >= 0)
320 + & (local_positions >= offset)
321 + & (req_indices == prior_requests)
322 + & (local_positions == prior_local_pos + offset)
323 + & (contexts == prior_contexts).all(dim=-1)
324 + )
325 + if include_prompt:
326 + repeated |= (contexts < 0).any(dim=-1)
327 + return repeated
328
329 if contexts.stride(-1) != 1:
330 contexts = contexts.contiguous()
331 + # Only history_offsets passes its row stride to the kernel.
332 + req_indices = req_indices.contiguous()
333 + if local_positions is not None:
334 + local_positions = local_positions.contiguous()
335 + # `prompt_lens` and `total_lens` are indexed flat by request slot, and
336 + # `all_token_ids` takes a row stride but is read flat within the row. Those
337 + # three are contiguous by contract; every caller owns them outright.
338 repeated = torch.empty(len(req_indices), dtype=torch.bool, device=contexts.device)
339 _repeated_context_mask_kernel[(len(req_indices),)](
340 repeated,
@@ -189,17 +343,104 @@ def repeated_context_mask(
343 req_indices,
344 prompt_lens,
345 total_lens,
346 + history_offsets,
347 + 0 if history_offsets is None else history_offsets.stride(0),
348 contexts,
349 contexts.stride(0),
350 + local_positions,
351 + None,
352 + 0,
353 + 0,
354 + None,
355 + 0,
356 + None,
357 CONTEXT_WIDTH=contexts.shape[-1],
358 + NUM_SPECULATIVE_STEPS=num_speculative_steps,
359 MAX_HISTORY=0 if max_history is None else max_history,
360 INCLUDE_PROMPT=include_prompt,
361 SKIP_PARTIAL_CONTEXT=skip_partial_context,
362 + HAS_HISTORY_OFFSETS=history_offsets is not None,
363 + SPECULATIVE_CONTEXTS=local_positions is not None,
364 + DRAFT_CONTEXTS=False,
365 BLOCK=512,
366 )
367 return repeated
368
369
370 +def draft_watermarking_mask(
371 + all_token_ids: torch.Tensor,
372 + req_indices: torch.Tensor,
373 + prompt_lens: torch.Tensor,
374 + total_lens: torch.Tensor,
375 + prior_contexts: torch.Tensor,
376 + contexts: torch.Tensor,
377 + steps: torch.Tensor,
378 + enabled: torch.Tensor,
379 + max_history: int | None,
380 + include_prompt: bool,
381 +) -> torch.Tensor:
382 + if contexts.device.type == "cpu":
383 + repeated = repeated_context_mask(
384 + all_token_ids,
385 + req_indices,
386 + prompt_lens,
387 + total_lens,
388 + contexts,
389 + max_history,
390 + include_prompt=include_prompt,
391 + history_offsets=steps,
392 + )
393 + prior_steps = torch.arange(prior_contexts.shape[1]).unsqueeze(0)
394 + in_history = prior_steps < steps.unsqueeze(1)
395 + if max_history is not None:
396 + in_history &= prior_steps >= steps.unsqueeze(1) - max_history
397 + repeated |= (
398 + (prior_contexts[: len(contexts)] == contexts.unsqueeze(1)).all(dim=-1)
399 + & in_history
400 + ).any(dim=-1)
401 + if include_prompt:
402 + repeated |= (contexts < 0).any(dim=-1)
403 + row_indices = torch.arange(len(contexts))
404 + prior_contexts.index_put_((row_indices, steps), contexts)
405 + return enabled & ~repeated
406 +
407 + if contexts.stride(-1) != 1:
408 + contexts = contexts.contiguous()
409 + # Only `steps` passes its row stride to the kernel.
410 + req_indices = req_indices.contiguous()
411 + enabled = enabled.contiguous()
412 + active = torch.empty(len(contexts), dtype=torch.bool, device=contexts.device)
413 + _repeated_context_mask_kernel[(len(contexts),)](
414 + active,
415 + all_token_ids,
416 + all_token_ids.stride(0),
417 + req_indices,
418 + prompt_lens,
419 + total_lens,
420 + steps,
421 + steps.stride(0),
422 + contexts,
423 + contexts.stride(0),
424 + None,
425 + prior_contexts,
426 + prior_contexts.stride(0),
427 + prior_contexts.stride(1),
428 + steps,
429 + steps.stride(0),
430 + enabled,
431 + NUM_SPECULATIVE_STEPS=prior_contexts.shape[1],
432 + CONTEXT_WIDTH=contexts.shape[1],
433 + MAX_HISTORY=0 if max_history is None else max_history,
434 + INCLUDE_PROMPT=include_prompt,
435 + SKIP_PARTIAL_CONTEXT=False,
436 + HAS_HISTORY_OFFSETS=True,
437 + SPECULATIVE_CONTEXTS=False,
438 + DRAFT_CONTEXTS=True,
439 + BLOCK=512,
440 + )
441 + return active
442 +
443 +
444 @triton.jit
445 def _mulhilo32(multiplier: tl.constexpr, value):
446 high = tl_math.umulhi(multiplier, value)
@@ -351,28 +592,58 @@ def _philox_gumbel_kernel(
592 seeds_ptr,
593 pos_ptr,
594 temp_ptr,
595 + logits_cache_ptr,
596 + logits_cache_stride_0,
597 + logits_cache_stride_1,
598 + logits_cache_col_ptr,
599 + logits_cache_source_ptr,
600 + logits_cache_source_stride,
601 key_0_value,
602 key_1_value,
603 vocab_size,
604 CONTEXT_WIDTH: tl.constexpr,
605 BLOCK_SIZE: tl.constexpr,
606 USE_FP64: tl.constexpr,
607 + IS_DRAFTING: tl.constexpr,
608 + PER_TOKEN_COL: tl.constexpr,
609 ):
610 row = tl.program_id(0).to(tl.int64)
611 block_index = tl.program_id(1)
612 groups = block_index * (BLOCK_SIZE // 4) + tl.arange(0, BLOCK_SIZE // 4)
613 + candidate = block_index * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
614 + mask = candidate < vocab_size
615
365 - if skip_mask_ptr is not None:
616 + req_state_idx = row
617 + valid_req = True
618 + if expanded_idx_mapping_ptr is not None:
619 req_state_idx = tl.load(expanded_idx_mapping_ptr + row).to(tl.int64)
620 valid_req = req_state_idx >= 0
621 +
622 + if logits_cache_ptr is not None:
623 + if PER_TOKEN_COL:
624 + col = tl.load(logits_cache_col_ptr + row)
625 + else:
626 + col = tl.load(logits_cache_col_ptr)
627 + cached_logits = tl.load(
628 + logits_cache_source_ptr + row * logits_cache_source_stride + candidate,
629 + mask=mask,
630 + )
631 + tl.store(
632 + logits_cache_ptr
633 + + req_state_idx * logits_cache_stride_0
634 + + col * logits_cache_stride_1
635 + + candidate,
636 + cached_logits,
637 + mask=mask & valid_req,
638 + )
639 +
640 + if skip_mask_ptr is not None: # noqa: SIM102
641 if tl.load(skip_mask_ptr + row):
642 temp = tl.load(temp_ptr + req_state_idx, mask=valid_req, other=0.0).to(
643 tl.float32
644 )
645 seed = tl.load(seeds_ptr + req_state_idx, mask=valid_req, other=0)
646 pos = tl.load(pos_ptr + row)
374 - candidate = block_index * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
375 - mask = candidate < vocab_size
647 logits_row = logits_ptr + row * logits_stride
648 logits = tl.load(logits_row + candidate, mask=mask, other=float("-inf")).to(
649 tl.float32
@@ -384,7 +655,7 @@ def _philox_gumbel_kernel(
655 seed,
656 pos,
657 temp,
387 - IS_DRAFTING=False,
658 + IS_DRAFTING=IS_DRAFTING,
659 USE_FP64=USE_FP64,
660 APPLY_TEMPERATURE=False,
661 )
@@ -443,12 +714,37 @@ def philox_gumbel_sample(
714 seeds: torch.Tensor | None = None,
715 positions: torch.Tensor | None = None,
716 use_fp64: bool = False,
717 + is_drafting: bool = False,
718 + logits_cache: torch.Tensor | None = None,
719 + logits_cache_col: torch.Tensor | None = None,
720 + logits_cache_source: torch.Tensor | None = None,
721 ) -> torch.Tensor:
722 sampling_state = (expanded_idx_mapping, temperatures, seeds, positions)
723 if skip_mask is None and any(value is not None for value in sampling_state):
724 raise ValueError("sampling state requires skip_mask")
725 if skip_mask is not None and any(value is None for value in sampling_state):
726 raise ValueError("skip_mask requires complete sampling state")
727 + if logits_cache is not None:
728 + if logits_cache_col is None or logits_cache_source is None:
729 + raise ValueError("logits cache requires its column and source logits")
730 + if expanded_idx_mapping is None:
731 + raise ValueError("logits cache requires expanded_idx_mapping")
732 + assert logits_cache_source.shape == logits.shape, (
733 + "logits cache source must match sampled logits shape"
734 + )
735 + assert logits_cache_source.device == logits.device, (
736 + "logits cache source must be on the sampled logits device"
737 + )
738 + assert logits_cache_source.dtype == logits_cache.dtype, (
739 + "logits cache source and destination must have the same dtype"
740 + )
741 + assert logits_cache.size(-1) >= logits.shape[-1], (
742 + f"draft logits cache vocab dim ({logits_cache.size(-1)}) is narrower "
743 + f"than the sampled logits ({logits.shape[-1]}). Cached logits would be "
744 + "truncated."
745 + )
746 + elif logits_cache_col is not None or logits_cache_source is not None:
747 + raise ValueError("logits cache column and source require logits_cache")
748
749 if logits.stride(-1) != 1:
750 logits = logits.contiguous()
@@ -460,6 +756,10 @@ def philox_gumbel_sample(
756 expanded_idx_mapping = expanded_idx_mapping.contiguous()
757 if positions is not None:
758 positions = positions.contiguous()
759 + if logits_cache_col is not None:
760 + logits_cache_col = logits_cache_col.contiguous()
761 + if logits_cache_source is not None and logits_cache_source.stride(-1) != 1:
762 + logits_cache_source = logits_cache_source.contiguous()
763 num_tokens, vocab_size = logits.shape
764 block_size = 1024
765 num_blocks = triton.cdiv(vocab_size, block_size)
@@ -483,12 +783,20 @@ def philox_gumbel_sample(
783 seeds,
784 positions,
785 temperatures,
786 + logits_cache,
787 + logits_cache.stride(0) if logits_cache is not None else 0,
788 + logits_cache.stride(1) if logits_cache is not None else 0,
789 + logits_cache_col,
790 + logits_cache_source,
791 + logits_cache_source.stride(0) if logits_cache_source is not None else 0,
792 key & _UINT32_MASK_VALUE,
793 key >> 32,
794 vocab_size,
795 CONTEXT_WIDTH=contexts.shape[-1],
796 BLOCK_SIZE=block_size,
797 USE_FP64=use_fp64,
798 + IS_DRAFTING=is_drafting,
799 + PER_TOKEN_COL=logits_cache_col is not None and logits_cache_col.dim() > 0,
800 )
801 max_block_index = local_max.argmax(dim=-1, keepdim=True)
802 return local_argmax.gather(dim=-1, index=max_block_index).view(-1)
vllm/v1/worker/gpu/spec_decode/dspark/speculator.py
+6 -6
@@ -135,7 +135,12 @@ class DSparkSpeculator(DFlashSpeculator):
135
136 # sample_pos is the predicted token's position P. Sampling keys a draw
137 # by the position before the sampled token, P-1.
138 - sampled = gumbel_sample(
138 + sampler = (
139 + gumbel_sample
140 + if self.draft_watermarker is None
141 + else self.draft_watermarker.sample
142 + )
143 + return sampler(
144 logits,
145 idx_map,
146 self.temperature,
@@ -147,11 +152,6 @@ class DSparkSpeculator(DFlashSpeculator):
152 logits_cache_col=self._step_cols[step],
153 use_fp64=self.use_fp64_gumbel,
154 )
150 - if self.draft_watermarker is not None:
151 - sampled = self.draft_watermarker.sample(
152 - logits, sampled, idx_map, self.temperature
153 - )
154 - return sampled
155
156 def _sample_sequential(self, num_reqs: int, head_hidden: torch.Tensor) -> None:
157 if self._draft_topk is not None:
vllm/v1/worker/gpu/spec_decode/rejection_sampler.py
+10 -3
@@ -226,11 +226,18 @@ class RejectionSampler:
226 if self.watermark_key is None:
227 return {}
228 assert isinstance(self.sampler, GPUWatermarkSampler)
229 + contexts = self.sampler._get_contexts(
230 + expanded_idx_mapping, expanded_local_pos, draft_sampled
231 + )
232 + watermarking_skip_mask = None
233 + if self.sampler.deduplicate_contexts != "none":
234 + watermarking_skip_mask = self.sampler._get_repeated_contexts(
235 + expanded_idx_mapping, contexts, expanded_local_pos
236 + )
237 return {
230 - "contexts": self.sampler._get_contexts(
231 - expanded_idx_mapping, expanded_local_pos, draft_sampled
232 - ),
238 + "contexts": contexts,
239 "watermarking": self.sampler.watermarking.gpu,
240 + "watermarking_skip_mask": watermarking_skip_mask,
241 "watermark_key": self.watermark_key,
242 }
243
vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py
+37 -10
@@ -564,7 +564,6 @@ def _rejection_kernel(
564 # stores the target argmax upon first rejection, so it rejects the
565 # placeholder via `accepted` instead.
566 verifying &= is_valid_draft
567 -
567 if verifying:
568 pos = tl.load(pos_ptr + logit_idx)
569 u = tl_rand32(seed, pos, includes_zero=False)
@@ -719,6 +718,8 @@ def _seeded_resample_argmax(
718 0, # logits_cache_stride_0
719 0, # logits_cache_stride_1
720 None, # logits_cache_col_ptr
721 + None, # logits_cache_source_ptr
722 + 0, # logits_cache_source_stride
723 vocab_size,
724 IS_DRAFTING=False,
725 APPLY_TEMPERATURE=False,
@@ -766,6 +767,7 @@ def _resample_kernel(
767 contexts_stride,
768 # [max_num_reqs], uint8 view of a bool tensor
769 watermarking_ptr,
770 + watermarking_skip_mask_ptr,
771 watermark_key_0,
772 watermark_key_1,
773 vocab_size,
@@ -775,6 +777,7 @@ def _resample_kernel(
777 USE_BLOCK_VERIFICATION: tl.constexpr,
778 CONTEXT_WIDTH: tl.constexpr,
779 WATERMARK: tl.constexpr,
780 + DEDUPLICATE_CONTEXTS: tl.constexpr,
781 ):
782 req_idx = tl.program_id(0)
783 resample_idx = tl.load(rejected_step_ptr + req_idx)
@@ -807,6 +810,25 @@ def _resample_kernel(
810 ).to(tl.float32)
811
812 # Compute the residual logits to resample the rejected token from.
813 + is_watermarked = False
814 + if WATERMARK:
815 + is_watermarked = (
816 + tl.load(
817 + watermarking_ptr + req_state_idx,
818 + mask=req_state_idx >= 0,
819 + other=0,
820 + )
821 + != 0
822 + )
823 +
824 + skip_watermarking = False
825 + if DEDUPLICATE_CONTEXTS:
826 + skip_watermarking = (
827 + (tl.load(watermarking_skip_mask_ptr + resample_token_idx) != 0)
828 + & is_watermarked
829 + & (temp != 0.0)
830 + )
831 +
832 if is_bonus or not is_valid_rejected_draft:
833 # Bonus token (no rejections) or -1 placeholder token. In either case,
834 # directly use the target logits.
@@ -868,15 +890,7 @@ def _resample_kernel(
890 # Resample the rejected/bonus token.
891 if WATERMARK:
892 # Padded and greedy rows retain the stock draw.
871 - is_watermarked = (
872 - tl.load(
873 - watermarking_ptr + req_state_idx,
874 - mask=req_state_idx >= 0,
875 - other=0,
876 - )
877 - != 0
878 - )
879 - if is_watermarked & (temp != 0.0):
893 + if is_watermarked & (temp != 0.0) & ~skip_watermarking:
894 watermark_value, idx = philox_gumbel_block_argmax(
895 residual_logits,
896 mask,
@@ -1023,6 +1037,7 @@ def rejection_sample(
1037 use_block_verification: bool = False,
1038 contexts: torch.Tensor | None = None,
1039 watermarking: torch.Tensor | None = None,
1040 + watermarking_skip_mask: torch.Tensor | None = None,
1041 watermark_key: int | None = None,
1042 ) -> tuple[torch.Tensor, torch.Tensor]:
1043 assert target_logits.ndim == 2 and target_logits.stride(-1) == 1
@@ -1036,9 +1051,13 @@ def rejection_sample(
1051 assert watermark == (watermarking is not None) == (watermark_key is not None), (
1052 "contexts, watermarking and watermark_key must be set together."
1053 )
1054 + assert watermarking_skip_mask is None or watermark, (
1055 + "watermarking_skip_mask requires watermarking."
1056 + )
1057 contexts_stride = 0
1058 context_width = 1
1059 watermarking_bytes: torch.Tensor | None = None
1060 + watermarking_skip_mask_bytes: torch.Tensor | None = None
1061 watermark_key_0 = 0
1062 watermark_key_1 = 0
1063 if contexts is not None:
@@ -1053,6 +1072,12 @@ def rejection_sample(
1072 contexts_stride = contexts.stride(0)
1073 context_width = contexts.shape[-1]
1074 watermarking_bytes = watermarking.view(torch.uint8)
1075 + if watermarking_skip_mask is not None:
1076 + assert watermarking_skip_mask.shape == (num_logits,)
1077 + assert watermarking_skip_mask.dtype == torch.bool
1078 + if not watermarking_skip_mask.is_contiguous():
1079 + watermarking_skip_mask = watermarking_skip_mask.contiguous()
1080 + watermarking_skip_mask_bytes = watermarking_skip_mask.view(torch.uint8)
1081 watermark_key_0 = watermark_key & 0xFFFFFFFF
1082 watermark_key_1 = watermark_key >> 32
1083 draft_logits_stride_0 = 0
@@ -1269,6 +1294,7 @@ def rejection_sample(
1294 contexts,
1295 contexts_stride,
1296 watermarking_bytes,
1297 + watermarking_skip_mask_bytes,
1298 watermark_key_0,
1299 watermark_key_1,
1300 vocab_size,
@@ -1278,6 +1304,7 @@ def rejection_sample(
1304 USE_BLOCK_VERIFICATION=use_block_verification,
1305 CONTEXT_WIDTH=context_width,
1306 WATERMARK=watermark,
1307 + DEDUPLICATE_CONTEXTS=watermarking_skip_mask is not None,
1308 )
1309
1310 # Insert the resampled tokens into the output sampled.
vllm/v1/worker/gpu/spec_decode/speculator.py
+20 -7
@@ -38,6 +38,7 @@ from vllm.v1.worker.gpu.spec_decode.acceptance_estimator import (
38 from vllm.v1.worker.utils import AttentionGroup
39
40 if TYPE_CHECKING:
41 + from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
42 from vllm.v1.worker.gpu.pcp_manager import PCPManager
43
44 logger = init_logger(__name__)
@@ -198,6 +199,9 @@ class DraftModelSpeculator(BaseSpeculator):
199 self.max_num_reqs,
200 device,
201 watermark_config.allow_target_only_watermarking,
202 + self.num_speculative_steps,
203 + watermark_config.deduplicate_contexts,
204 + watermark_config.deduplicate_contexts_max_history,
205 )
206
207 self.supports_mm_inputs = False
@@ -411,7 +415,12 @@ class DraftModelSpeculator(BaseSpeculator):
415 ) -> torch.Tensor:
416 if draft_logits is not None:
417 logits = self.model.compute_logits(hidden_states)
414 - sampled = gumbel_sample(
418 + sampler = (
419 + gumbel_sample
420 + if self.draft_watermarker is None
421 + else self.draft_watermarker.sample
422 + )
423 + sampled = sampler(
424 logits,
425 idx_mapping,
426 temperature,
@@ -423,10 +432,6 @@ class DraftModelSpeculator(BaseSpeculator):
432 logits_cache_col=draft_step,
433 use_fp64=self.use_fp64_gumbel,
434 )
426 - if self.draft_watermarker is not None:
427 - sampled = self.draft_watermarker.sample(
428 - logits, sampled, idx_mapping, temperature
429 - )
435 elif self.use_local_argmax_reduction:
436 return self.model.get_top_tokens(hidden_states)
437 else:
@@ -462,11 +467,19 @@ class DraftModelSpeculator(BaseSpeculator):
467 self.acceptance_estimator.step(idx_mapping, num_sampled, num_rejected)
468
469 def prepare_watermarking(
465 - self, contexts: torch.Tensor, watermarking: torch.Tensor
470 + self,
471 + sampler: "GPUWatermarkSampler",
472 + idx_mapping: torch.Tensor,
473 ) -> None:
474 if self.draft_watermarker is None:
475 return
469 - self.draft_watermarker.prepare(contexts, watermarking)
476 + self.draft_watermarker.prepare(
477 + sampler._get_contexts(idx_mapping),
478 + sampler.watermarking.gpu[idx_mapping],
479 + sampler.req_states.all_token_ids.gpu,
480 + sampler.req_states.prompt_len.gpu,
481 + sampler.req_states.total_len.gpu,
482 + )
483
484 def _copy_request_inputs(
485 self,