Files
vLLM/tests/watermarking/test_watermarking.py
7b6310c109 [Perf][Sampler] Speed up the small-batch split top-p pipeline (#61996)
Signed-off-by: Bowen Wang <bowen@inferact.ai>
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Nick Hill <nickhill123@gmail.com>
Co-authored-by: vllm-agent <claw@inferact.ai>
2026-10-16 21:22:11 -07:00

1887 lines
66 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from vllm import SamplingParams
from vllm import logger as vllm_logger
from vllm.config import VllmConfig
from vllm.config.watermarking import WatermarkConfig
from vllm.platforms import current_platform
from vllm.v1.sample.ops.topk_topp_sampler import apply_top_k_top_p_pytorch
from vllm.v1.watermarking import (
DualKeyGumbelWatermarker,
SupportsSpeculativeDecoding,
create_watermarker,
derive_watermark_key,
)
from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
from vllm.v1.watermarking.gumbel import GumbelWatermarker
from vllm.v1.watermarking.spec_decode import (
DraftWatermarker,
_resolve_watermark_key,
create_speculative_draft_watermarker,
create_speculative_target_watermarker,
speculative_target_watermark_key,
)
from vllm.v1.watermarking.watermarker import Watermarker, WatermarkSample
from vllm.v1.worker.gpu import buffer_utils
from vllm.v1.worker.gpu.buffer_utils import UvaBackedTensor
from vllm.v1.worker.gpu.sample.watermark import (
draft_watermarking_mask,
philox_gumbel_sample,
repeated_context_mask,
)
from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator
from vllm.v1.worker.gpu.spec_decode.dspark.speculator import DSparkSpeculator
from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
class StubWatermarker(Watermarker):
context_width = 1
def _sample_watermarked(self, logits, contexts):
return WatermarkSample(torch.tensor([7, 7]), logits + 10)
def _argmax_sampler(logits: torch.Tensor) -> torch.Tensor:
return logits.argmax(dim=-1)
@pytest.fixture
def make_gpu_watermark_sampler(monkeypatch):
class CPUUvaBuffer:
def __init__(self, size, dtype):
self.cpu = torch.zeros(size, dtype=dtype)
self.np = self.cpu.numpy()
def uva(self, n=None):
return self.cpu[:n] if n is not None else self.cpu
monkeypatch.setattr(buffer_utils, "UvaBuffer", CPUUvaBuffer)
def make(watermarker, *, max_num_reqs=2, vocab_size=8, **kwargs):
req_states = SimpleNamespace(
max_num_reqs=max_num_reqs,
vocab_size=vocab_size,
device=torch.device("cpu"),
all_token_ids=SimpleNamespace(gpu=torch.zeros(max_num_reqs, 1)),
prompt_len=SimpleNamespace(
np=np.zeros(max_num_reqs, dtype=np.int32),
gpu=torch.zeros(max_num_reqs, dtype=torch.int32),
),
prefill_len=SimpleNamespace(np=np.zeros(max_num_reqs, dtype=np.int32)),
total_len=SimpleNamespace(gpu=torch.zeros(max_num_reqs, dtype=torch.int32)),
)
return GPUWatermarkSampler(
watermarker,
vllm_config=VllmConfig(),
max_num_reqs=max_num_reqs,
vocab_size=vocab_size,
device=torch.device("cpu"),
req_states=req_states,
**kwargs,
)
return make
@pytest.mark.parametrize("algorithm", ["gumbel", "dual_key_gumbel"])
def test_watermarker_contract(algorithm: str):
watermarker = create_watermarker(
WatermarkConfig(algorithm=algorithm, key=42, context_width=4)
)
logits = torch.zeros(2, 128)
contexts = torch.tensor([[1, 2, 3, 4], [4, 5, 6, 7]])
first = watermarker.sample(logits, contexts, _argmax_sampler)
second = watermarker.sample(logits, contexts, _argmax_sampler)
assert first.token_ids.shape == (2,)
assert first.logits.shape == logits.shape
assert torch.equal(first.token_ids, second.token_ids)
@pytest.mark.parametrize("algorithm", ["gumbel", "dual_key_gumbel"])
@pytest.mark.parametrize(
"config_overrides",
[
{"deduplicate_contexts": "none"},
{"deduplicate_contexts_max_history": 1023},
],
)
def test_gumbel_config_warns_when_context_deduplication_is_weak(
monkeypatch, algorithm, config_overrides
):
messages: list[str] = []
monkeypatch.setattr(
"vllm.config.watermarking.logger.warning_once",
lambda message, *, scope: messages.append(message),
)
WatermarkConfig(key=42, algorithm=algorithm)
WatermarkConfig(key=42, algorithm=algorithm, deduplicate_contexts_max_history=1024)
WatermarkConfig(key=42, algorithm=algorithm, deduplicate_contexts_max_history=None)
WatermarkConfig(key=42, algorithm=algorithm, **config_overrides)
assert messages == [
(
"Gumbel-max watermarking with context deduplication disabled "
"or limited to fewer than 1024 positions may increase the frequency of "
"degenerate generations, including repetition loops. Use "
"deduplicate_contexts='single_turn' or 'all' with "
"deduplicate_contexts_max_history at least 1024 or null to mitigate this."
)
]
def test_context_deduplication_history_can_be_unbounded():
config = WatermarkConfig(key=42, deduplicate_contexts_max_history=None)
assert config.deduplicate_contexts_max_history is None
def test_large_context_width_warns_but_is_allowed():
config = WatermarkConfig(key=42, context_width=17)
with pytest.warns(UserWarning, match="reduce robustness to edits"):
watermarker = create_watermarker(config)
assert watermarker.context_width == 17
def test_dual_key_watermarker_uses_domain_separated_keys():
config = WatermarkConfig(algorithm="dual_key_gumbel", key=42)
watermarker = create_watermarker(config)
assert isinstance(watermarker, SupportsSpeculativeDecoding)
draft = watermarker.draft_watermarker
target = watermarker.target_watermarker
assert isinstance(watermarker, DualKeyGumbelWatermarker)
assert watermarker.alpha == 0.1
assert draft.prf.key == derive_watermark_key(42, b"key_a")
assert target.prf.key == derive_watermark_key(42, b"key_b")
assert target.prf.key != draft.prf.key
def test_dual_key_watermarker_routes_tokens_with_alpha():
watermarker = DualKeyGumbelWatermarker(key=42, context_width=2, alpha=0.25)
logits = torch.zeros(2, 16)
contexts = torch.tensor([[1, 2], [3, 4]])
key_a = watermarker.draft_watermarker.sample(logits, contexts, lambda _: None)
key_b = watermarker.target_watermarker.sample(logits, contexts, lambda _: None)
def route(routing_logits):
torch.testing.assert_close(
routing_logits.softmax(dim=-1),
torch.tensor([[0.75, 0.25], [0.75, 0.25]]),
)
return torch.tensor([0, 1])
sampled = watermarker.sample(logits, contexts, route)
assert torch.equal(
sampled.token_ids, torch.stack([key_a.token_ids[0], key_b.token_ids[1]])
)
def test_dual_key_routing_logits_are_cached():
alpha = 0.25
watermarker = DualKeyGumbelWatermarker(key=42, context_width=2, alpha=alpha)
logits = torch.zeros(2, 16)
contexts = torch.tensor([[1, 2], [3, 4]])
recorded: list[torch.Tensor] = []
def route(routing_logits):
recorded.append(routing_logits)
return torch.tensor([0, 1])
watermarker.sample(logits, contexts, route)
watermarker.sample(logits, contexts, route)
expected = torch.tensor([1 - alpha, alpha], dtype=torch.float32).log()
expected = expected.expand(2, -1)
assert len(recorded) == 2
assert all(torch.equal(seen, expected) for seen in recorded)
assert len(watermarker._routing_logits_cache) == 1
@pytest.mark.parametrize("alpha", [0.0, 0.25, 1.0])
def test_dual_key_watermarker_skips_masked_rows(alpha: float):
watermarker = DualKeyGumbelWatermarker(key=42, context_width=2, alpha=alpha)
torch.manual_seed(0)
logits = torch.randn(4, 32)
contexts = torch.tensor([[1, 2], [3, 4], [5, 6], [7, 8]])
skip_mask = torch.tensor([True, False, True, False])
unmasked = watermarker.sample(logits, contexts, _argmax_sampler)
masked = watermarker.sample(logits, contexts, _argmax_sampler, skip_mask)
ordinary = _argmax_sampler(logits)
assert torch.equal(masked.token_ids[skip_mask], ordinary[skip_mask])
assert torch.equal(masked.token_ids[~skip_mask], unmasked.token_ids[~skip_mask])
assert not torch.equal(unmasked.token_ids[skip_mask], ordinary[skip_mask])
def test_dual_key_watermarker_routing_requires_a_random_sampler():
watermarker = DualKeyGumbelWatermarker(key=42, context_width=2, alpha=0.25)
with pytest.raises(ValueError, match="random sampler"):
watermarker.sample(torch.zeros(2, 16), torch.tensor([[1, 2], [3, 4]]))
def test_speculative_decoding_uses_fixed_dual_key_roles():
watermarker = create_watermarker(
WatermarkConfig(algorithm="dual_key_gumbel", key=42, alpha=0.25)
)
target = create_speculative_target_watermarker(watermarker)
draft = create_speculative_draft_watermarker(
watermarker,
max_num_reqs=1,
device=torch.device("cpu"),
allow_target_only=False,
num_speculative_steps=3,
deduplicate_contexts="all",
deduplicate_contexts_max_history=None,
)
assert target.prf.key == derive_watermark_key(42, b"key_b")
assert draft is not None
assert draft.watermarker.prf.key == derive_watermark_key(42, b"key_a")
assert draft.num_speculative_steps == 3
assert draft.state.deduplicate_contexts == "all"
assert draft.state.deduplicate_contexts_max_history is None
def test_recovery_key_rejects_the_unsplit_dual_key_watermarker():
watermarker = create_watermarker(
WatermarkConfig(algorithm="dual_key_gumbel", key=42, alpha=0.25)
)
with pytest.raises(ValueError, match="keys the target role separately"):
_resolve_watermark_key(watermarker)
target = create_speculative_target_watermarker(watermarker)
assert _resolve_watermark_key(target) == derive_watermark_key(42, b"key_b")
@pytest.mark.parametrize("algorithm", ["gumbel", "dual_key_gumbel"])
def test_config_key_resolution_matches_the_model_runner(algorithm: str):
config = WatermarkConfig(algorithm=algorithm, key=42, alpha=0.25)
runtime_key = _resolve_watermark_key(
create_speculative_target_watermarker(create_watermarker(config))
)
assert speculative_target_watermark_key(config) == runtime_key
def test_config_key_resolution_returns_none_when_watermarking_is_disabled():
assert speculative_target_watermark_key(None) is None
def test_target_only_speculative_watermarking_skips_draft_watermarker():
watermarker = create_watermarker(WatermarkConfig(algorithm="gumbel", key=42))
assert not isinstance(watermarker, SupportsSpeculativeDecoding)
with pytest.raises(ValueError, match="does not support speculative decoding"):
create_speculative_draft_watermarker(
watermarker,
max_num_reqs=1,
device=torch.device("cpu"),
allow_target_only=False,
)
assert (
create_speculative_draft_watermarker(
watermarker,
max_num_reqs=1,
device=torch.device("cpu"),
allow_target_only=True,
)
is None
)
def test_dual_key_derivation_is_stable():
assert derive_watermark_key(32, b"key_a") == 16368605726115524094
assert derive_watermark_key(32, b"key_b") == 4799302812959726346
def test_sampling_params_can_disable_watermarking():
assert SamplingParams().watermarking is None
assert SamplingParams.from_optional(watermarking=False).watermarking is False
def test_gpu_sampler_warns_about_unexpected_greedy_watermarking(
monkeypatch, make_gpu_watermark_sampler
):
vllm_logger._print_warning_once.cache_clear()
sampler = make_gpu_watermark_sampler(StubWatermarker(), max_num_reqs=1)
messages: list[str] = []
monkeypatch.setattr(
"vllm.v1.watermarking.gpu_sampler.logger.warning",
lambda message, *_args, **_kwargs: messages.append(message),
)
sampler.add_request(0, SamplingParams(temperature=0, watermarking=True))
assert sampler.watermarking.np[0]
sampler.add_request(0, SamplingParams(temperature=0, watermarking=True))
assert sampler.watermarking.np[0]
sampler.add_request(0, SamplingParams(temperature=0, watermarking=False))
assert not sampler.watermarking.np[0]
assert messages == [
(
"Watermarking is enabled, but greedy decoding "
"(temperature=0) cannot be watermarked. This request will use "
"ordinary greedy sampling."
)
]
vllm_logger._print_warning_once.cache_clear()
def test_gpu_sampler_respects_mixed_request_watermarking(
monkeypatch, make_gpu_watermark_sampler
):
sampler = make_gpu_watermark_sampler(StubWatermarker(), deduplicate_contexts="none")
sampler.add_request(0, SamplingParams(watermarking=True))
sampler.add_request(1, SamplingParams(watermarking=False))
sampler.apply_staged_writes()
sampler._get_contexts = lambda expanded_idx_mapping: torch.zeros(
2, 1, dtype=torch.int64
)
monkeypatch.setattr(
"vllm.v1.watermarking.watermarker.gumbel_sample",
lambda *args, **kwargs: torch.tensor([3, 4]),
)
logits = torch.zeros(2, 8)
sampled, output_logits = sampler._sample_random(
logits,
torch.tensor([1, 0]),
np.array([1, 0]),
torch.zeros(2, dtype=torch.int64),
None,
None,
False,
False,
)
assert torch.equal(sampled, torch.tensor([3, 7]))
assert torch.equal(output_logits[0], logits[0])
assert torch.equal(output_logits[1], torch.full((8,), 10.0))
def test_gpu_sampler_filters_top_k_top_p_before_watermarking(
monkeypatch, make_gpu_watermark_sampler
):
# ``apply_top_k_top_p`` dispatches to a Triton kernel whenever Triton is
# installed, which cannot read this test's CPU tensors. Force the torch
# reference path; the assertion is about the call order, not the kernel.
monkeypatch.setattr(
"vllm.v1.watermarking.gpu_sampler.apply_top_k_top_p",
lambda logits, k, p, **kwargs: apply_top_k_top_p_pytorch(
logits, k, p, allow_cpu_sync=True
),
)
class CapturingWatermarker:
context_width = 1
captured_logits = None
def sample(self, logits, contexts, random_sampler=None, skip_mask=None):
self.captured_logits = logits
return WatermarkSample(logits.argmax(dim=-1), logits)
watermarker = CapturingWatermarker()
sampler = make_gpu_watermark_sampler(
watermarker, max_num_reqs=1, vocab_size=4, deduplicate_contexts="none"
)
sampler.add_request(0, SamplingParams(top_k=2, top_p=0.8, watermarking=True))
sampler.apply_staged_writes()
sampler._get_contexts = lambda expanded_idx_mapping: torch.zeros(
1, 1, dtype=torch.int64
)
logits = torch.tensor([[5.0, 4.0, 3.0, 2.0]])
sampled, _ = sampler._sample_random(
logits,
torch.tensor([0]),
np.array([0]),
torch.zeros(1, dtype=torch.int64),
torch.tensor([2]),
torch.tensor([0.8]),
False,
False,
)
assert watermarker.captured_logits is not None
assert torch.isneginf(watermarker.captured_logits[0, 2:]).all()
assert sampled.item() in (0, 1)
def test_watermark_context_ignores_prefix_cache_bookkeeping():
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = SimpleNamespace(context_width=3)
sampler.req_states = SimpleNamespace(
total_len=SimpleNamespace(gpu=torch.tensor([4, 0, 5])),
prompt_len=SimpleNamespace(gpu=torch.tensor([3, 0, 2])),
all_token_ids=SimpleNamespace(
gpu=torch.tensor(
[
[10, 11, 12, 40, 0, 0],
[0, 0, 0, 0, 0, 0],
[20, 21, 50, 51, 52, 0],
]
)
),
num_computed_tokens=SimpleNamespace(gpu=torch.tensor([0, 0, 0])),
)
request_slots = torch.tensor([2, 0])
cold_contexts = sampler._get_contexts(request_slots)
sampler.req_states.num_computed_tokens.gpu[:] = torch.tensor([3, 0, 2])
cached_contexts = sampler._get_contexts(request_slots)
expected = torch.tensor([[50, 51, 52], [-1, -1, 40]])
assert torch.equal(cold_contexts, expected)
assert torch.equal(cached_contexts, expected)
def test_gpu_sampler_skips_watermarking_for_repeated_contexts(monkeypatch):
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = StubWatermarker()
sampler.deduplicate_contexts = "single_turn"
sampler.watermarking = SimpleNamespace(
np=np.array([True, True]), gpu=torch.tensor([True, True])
)
sampler.sampling_states = SimpleNamespace(
temperature=SimpleNamespace(np=np.ones(2), gpu=torch.ones(2)),
seeds=SimpleNamespace(gpu=torch.zeros(2, dtype=torch.int64)),
)
sampler.use_fp64_gumbel = False
sampler._get_contexts = lambda expanded_idx_mapping: torch.zeros(
2, 1, dtype=torch.int64
)
sampler._get_repeated_contexts = lambda expanded_idx_mapping, contexts: (
torch.tensor([True, False])
)
monkeypatch.setattr(
"vllm.v1.watermarking.watermarker.gumbel_sample",
lambda *args, **kwargs: torch.tensor([3, 4]),
)
logits = torch.zeros(2, 8)
sampled, output_logits = sampler._sample_random(
logits,
torch.tensor([0, 1]),
np.array([0, 1]),
torch.zeros(2, dtype=torch.int64),
None,
None,
False,
False,
)
assert torch.equal(sampled, torch.tensor([3, 7]))
assert torch.equal(output_logits[0], logits[0])
assert torch.equal(output_logits[1], torch.full((8,), 10.0))
def test_gpu_sampler_can_disable_context_deduplication(monkeypatch):
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = StubWatermarker()
sampler.deduplicate_contexts = "none"
sampler.watermarking = SimpleNamespace(
np=np.array([True, True]), gpu=torch.tensor([True, True])
)
sampler.sampling_states = SimpleNamespace(
temperature=SimpleNamespace(np=np.ones(2), gpu=torch.ones(2)),
seeds=SimpleNamespace(gpu=torch.zeros(2, dtype=torch.int64)),
)
sampler.use_fp64_gumbel = False
sampler._get_contexts = lambda expanded_idx_mapping: torch.zeros(
2, 1, dtype=torch.int64
)
sampler._get_repeated_contexts = lambda *args: pytest.fail(
"context deduplication should not run"
)
logits = torch.zeros(2, 8)
sampled, output_logits = sampler._sample_random(
logits,
torch.tensor([0, 1]),
np.array([0, 1]),
torch.zeros(2, dtype=torch.int64),
None,
None,
False,
False,
)
assert torch.equal(sampled, torch.tensor([7, 7]))
assert torch.equal(output_logits, torch.full((2, 8), 10.0))
def test_repeated_context_mask_ignores_prompt_tokens():
all_token_ids = torch.tensor(
[
[8, 9, 1, 2, 1, 2],
[3, 4, 1, 2, 3, 4],
[0, 0, 0, 0, 0, 0],
],
dtype=torch.int32,
)
req_indices = torch.tensor([0, 1, -1])
prompt_lens = torch.tensor([2, 2, 0])
total_lens = torch.tensor([6, 6, 0])
contexts = torch.tensor([[1, 2], [3, 4], [-1, -1]])
repeated = repeated_context_mask(
all_token_ids,
req_indices,
prompt_lens,
total_lens,
contexts,
)
assert torch.equal(repeated, torch.tensor([True, False, False]))
def test_repeated_context_mask_can_include_prompt_tokens():
all_token_ids = torch.tensor([[1, 2, 3, 4, 5, 6, 1, 2, 3, 4]])
req_indices = torch.tensor([0])
prompt_lens = torch.tensor([4])
total_lens = torch.tensor([10])
contexts = torch.tensor([[1, 2, 3, 4]])
single_turn = repeated_context_mask(
all_token_ids, req_indices, prompt_lens, total_lens, contexts
)
all_history = repeated_context_mask(
all_token_ids,
req_indices,
prompt_lens,
total_lens,
contexts,
include_prompt=True,
)
assert not single_turn.item()
assert all_history.item()
def test_repeated_context_mask_can_skip_partial_contexts():
all_token_ids = torch.tensor([[10, 11, 12, 13, 1, 2, 3, 4]], dtype=torch.int32)
req_indices = torch.tensor([0])
prompt_lens = torch.tensor([4])
contexts = torch.tensor([[-1, -1, -1, 1]])
partial = repeated_context_mask(
all_token_ids,
req_indices,
prompt_lens,
torch.tensor([5]),
contexts,
include_prompt=True,
skip_partial_context=True,
)
complete = repeated_context_mask(
all_token_ids,
req_indices,
prompt_lens,
torch.tensor([8]),
torch.tensor([[1, 2, 3, 4]]),
include_prompt=True,
skip_partial_context=True,
)
assert partial.item()
assert not complete.item()
def test_gpu_sampler_contexts_are_completion_local_for_all_scopes():
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = SimpleNamespace(context_width=4)
sampler.req_states = SimpleNamespace(
all_token_ids=SimpleNamespace(gpu=torch.tensor([[10, 11, 12, 13, 1, 2]])),
prompt_len=SimpleNamespace(gpu=torch.tensor([4])),
total_len=SimpleNamespace(gpu=torch.tensor([6])),
)
request_indices = torch.tensor([0])
sampler.deduplicate_contexts = "single_turn"
single_turn = sampler._get_contexts(request_indices)
sampler.deduplicate_contexts = "all"
all_history = sampler._get_contexts(request_indices)
assert torch.equal(single_turn, torch.tensor([[-1, -1, 1, 2]]))
assert torch.equal(all_history, single_turn)
def test_gpu_sampler_all_history_skips_partial_context():
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = SimpleNamespace(context_width=4)
sampler.req_states = SimpleNamespace(
all_token_ids=SimpleNamespace(gpu=torch.tensor([[10, 11, 12, 13, 1, 2]])),
prompt_len=SimpleNamespace(gpu=torch.tensor([4])),
total_len=SimpleNamespace(gpu=torch.tensor([6])),
)
sampler.deduplicate_contexts_max_history = None
request_indices = torch.tensor([0])
contexts = sampler._get_contexts(request_indices)
sampler.deduplicate_contexts = "single_turn"
single_turn = sampler._get_repeated_contexts(request_indices, contexts)
sampler.deduplicate_contexts = "all"
all_history = sampler._get_repeated_contexts(request_indices, contexts)
assert not single_turn.item()
assert all_history.item()
def test_repeated_context_mask_respects_max_history():
all_token_ids = torch.tensor([[1, 2, 3, 4, 5, 6, 1, 2, 3, 4]])
req_indices = torch.tensor([0])
prompt_lens = torch.tensor([0])
total_lens = torch.tensor([10])
contexts = torch.tensor([[1, 2, 3, 4]])
full_history = repeated_context_mask(
all_token_ids, req_indices, prompt_lens, total_lens, contexts
)
last_six = repeated_context_mask(
all_token_ids, req_indices, prompt_lens, total_lens, contexts, max_history=6
)
last_five = repeated_context_mask(
all_token_ids, req_indices, prompt_lens, total_lens, contexts, max_history=5
)
assert full_history.item()
assert last_six.item()
assert not last_five.item()
@pytest.mark.parametrize("max_history", [0, -1])
def test_repeated_context_mask_rejects_non_positive_max_history(max_history: int):
with pytest.raises(ValueError, match="max_history must be positive or None"):
repeated_context_mask(
torch.tensor([[1, 2, 3, 4]], dtype=torch.int32),
torch.tensor([0], dtype=torch.int32),
torch.tensor([0], dtype=torch.int32),
torch.tensor([4], dtype=torch.int32),
torch.tensor([[1, 2, 3, 4]], dtype=torch.int32),
max_history=max_history,
)
def test_gpu_sampler_wires_request_history_scope():
sampler = object.__new__(GPUWatermarkSampler)
sampler.req_states = SimpleNamespace(
all_token_ids=SimpleNamespace(
gpu=torch.tensor([[1, 2, 3, 4, 5, 6, 1, 2, 3, 4]])
),
prompt_len=SimpleNamespace(gpu=torch.tensor([4])),
total_len=SimpleNamespace(gpu=torch.tensor([10])),
)
sampler.deduplicate_contexts_max_history = None
request_indices = torch.tensor([0])
contexts = torch.tensor([[1, 2, 3, 4]])
sampler.deduplicate_contexts = "single_turn"
single_turn = sampler._get_repeated_contexts(request_indices, contexts)
sampler.deduplicate_contexts = "all"
all_history = sampler._get_repeated_contexts(request_indices, contexts)
assert not single_turn.item()
assert all_history.item()
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@pytest.mark.parametrize("max_history", [5, 6])
@pytest.mark.parametrize("include_prompt", [False, True])
def test_repeated_context_mask_max_history_accelerator_parity(
max_history: int, include_prompt: bool
):
inputs = (
torch.tensor([[1, 2, 3, 4, 5, 6, 1, 2, 3, 4]]),
torch.tensor([0]),
torch.tensor([0]),
torch.tensor([10]),
torch.tensor([[1, 2, 3, 4]]),
)
expected = repeated_context_mask(
*inputs, max_history=max_history, include_prompt=include_prompt
)
actual = repeated_context_mask(
*(value.cuda() for value in inputs),
max_history=max_history,
include_prompt=include_prompt,
).cpu()
assert torch.equal(actual, expected)
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
def test_repeated_context_mask_partial_context_accelerator_parity():
inputs = (
torch.tensor([[10, 11, 12, 13, 1]], dtype=torch.int32),
torch.tensor([0], dtype=torch.int32),
torch.tensor([4], dtype=torch.int32),
torch.tensor([5], dtype=torch.int32),
torch.tensor([[-1, -1, -1, 1]], dtype=torch.int64),
)
expected = repeated_context_mask(
*inputs, include_prompt=True, skip_partial_context=True
)
actual = repeated_context_mask(
*(value.cuda() for value in inputs),
include_prompt=True,
skip_partial_context=True,
).cpu()
assert torch.equal(actual, expected)
def _unaligned_max_history_inputs() -> tuple[torch.Tensor, ...]:
"""One row whose only repeated context ends at position 680.
680 is not a multiple of the kernel's 512-position block, so the scan window
selected by ``max_history`` starts in the middle of a block.
"""
history = list(range(1_000, 2_200))
history[676:680] = [1, 2, 3, 4]
return (
torch.tensor([history], dtype=torch.int32),
torch.tensor([0], dtype=torch.int32),
torch.tensor([0], dtype=torch.int32),
torch.tensor([len(history)], dtype=torch.int32),
torch.tensor([[1, 2, 3, 4]], dtype=torch.int32),
)
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@pytest.mark.parametrize(
"max_history,expected", [(1200 - 680, True), (1200 - 680 - 1, False)]
)
def test_repeated_context_mask_unaligned_max_history_accelerator_parity(
max_history: int, expected: bool
):
inputs = _unaligned_max_history_inputs()
reference = repeated_context_mask(*inputs, max_history=max_history)
actual = repeated_context_mask(
*(value.cuda() for value in inputs), max_history=max_history
).cpu()
assert torch.equal(reference, torch.tensor([expected]))
assert torch.equal(actual, reference)
def _repeated_context_inputs(
context_width: int, device: str = "cpu"
) -> tuple[torch.Tensor, ...]:
"""Build repeated, unique, and padded request rows for mask tests."""
prompt = [101, 102, 103, 104]
repeated_output = [*range(1, context_width + 1)] * 2
unique_output = [*range(1, context_width + 2)]
max_len = len(prompt) + len(repeated_output)
rows = [
prompt + repeated_output,
prompt + unique_output,
[],
]
all_token_ids = torch.zeros((3, max_len), dtype=torch.int32, device=device)
for row, token_ids in enumerate(rows):
all_token_ids[row, : len(token_ids)] = torch.tensor(
token_ids, dtype=torch.int32, device=device
)
return (
all_token_ids,
torch.tensor([0, 1, -1], dtype=torch.int32, device=device),
torch.tensor([len(prompt), len(prompt), 0], dtype=torch.int32, device=device),
torch.tensor([len(rows[0]), len(rows[1]), 0], dtype=torch.int32, device=device),
torch.tensor(
[
repeated_output[-context_width:],
unique_output[-context_width:],
[-1] * context_width,
],
dtype=torch.int64,
device=device,
),
)
@pytest.mark.parametrize("context_width", [1, 3, 4, 16, 17])
def test_repeated_context_mask(context_width: int):
repeated = repeated_context_mask(*_repeated_context_inputs(context_width))
assert torch.equal(repeated, torch.tensor([True, False, False]))
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@pytest.mark.parametrize("context_width", [1, 3, 4, 16, 17])
def test_repeated_context_mask_accelerator_parity(context_width: int):
cpu_inputs = _repeated_context_inputs(context_width)
accelerator_inputs = list(_repeated_context_inputs(context_width, "cuda"))
contexts = accelerator_inputs[-1]
storage = torch.empty(
contexts.shape[0], contexts.shape[1] * 2, dtype=contexts.dtype, device="cuda"
)
storage[:, ::2] = contexts
accelerator_inputs[-1] = storage[:, ::2]
expected = repeated_context_mask(*cpu_inputs)
actual = repeated_context_mask(*accelerator_inputs).cpu()
assert torch.equal(actual, expected)
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
def test_repeated_context_mask_scans_multiple_blocks():
context = [1, 2, 3, 4]
repeated_output = [*range(10_000, 11_030), *context, 99, *context]
unique_output = list(range(20_000, 21_039))
prompt = [101, 102, 103, 104]
rows = [prompt + repeated_output, prompt + unique_output]
all_token_ids = torch.zeros((2, len(rows[0])), dtype=torch.int32)
for row, token_ids in enumerate(rows):
all_token_ids[row, : len(token_ids)] = torch.tensor(token_ids)
inputs = (
all_token_ids,
torch.tensor([0, 1], dtype=torch.int32),
torch.tensor([len(prompt), len(prompt)], dtype=torch.int32),
torch.tensor([len(rows[0]), len(rows[1])], dtype=torch.int32),
torch.tensor([context, unique_output[-4:]], dtype=torch.int64),
)
expected = repeated_context_mask(*inputs)
actual = repeated_context_mask(*(value.cuda() for value in inputs)).cpu()
assert torch.equal(expected, torch.tensor([True, False]))
assert torch.equal(actual, expected)
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
def test_repeated_context_mask_last_request_at_capacity():
max_model_len = 1024
all_token_ids = torch.arange(
2 * max_model_len, dtype=torch.int32, device="cuda"
).reshape(2, max_model_len)
inputs = (
all_token_ids,
torch.tensor([1], dtype=torch.int32, device="cuda"),
torch.tensor([0, 0], dtype=torch.int32, device="cuda"),
torch.tensor([max_model_len, max_model_len], dtype=torch.int32, device="cuda"),
all_token_ids[1, -4:].to(torch.int64).unsqueeze(0),
)
repeated = repeated_context_mask(*inputs)
assert not repeated.item()
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
def test_gpu_sampler_uses_fused_gumbel_for_repeated_contexts():
device = torch.device("cuda")
logits = torch.tensor([[0.0, 1.0, 2.0, 3.0], [3.0, 2.0, 1.0, 0.0]], device=device)
all_token_ids = torch.tensor(
[[101, 102, 1, 2, 1, 2], [101, 102, 3, 4, 5, 6]],
dtype=torch.int32,
device=device,
)
request_indices = torch.tensor([0, 1], dtype=torch.int64, device=device)
request_indices_np = np.array([0, 1])
temperatures = torch.ones(2, device=device)
seeds = torch.tensor([11, 22], dtype=torch.int64, device=device)
positions = torch.tensor([6, 6], dtype=torch.int64, device=device)
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = GumbelWatermarker(key=42, context_width=1)
sampler.deduplicate_contexts = "single_turn"
sampler.deduplicate_contexts_max_history = None
sampler.watermarking = SimpleNamespace(
np=np.ones(2, dtype=bool), gpu=torch.ones(2, dtype=torch.bool, device=device)
)
sampler.sampling_states = SimpleNamespace(
temperature=SimpleNamespace(np=np.ones(2), gpu=temperatures),
seeds=SimpleNamespace(gpu=seeds),
)
sampler.req_states = SimpleNamespace(
all_token_ids=SimpleNamespace(gpu=all_token_ids),
prompt_len=SimpleNamespace(
gpu=torch.tensor([2, 2], dtype=torch.int32, device=device)
),
total_len=SimpleNamespace(
gpu=torch.tensor([6, 6], dtype=torch.int32, device=device)
),
)
sampler.use_fp64_gumbel = False
contexts = sampler._get_contexts(request_indices)
repeated = sampler._get_repeated_contexts(request_indices, contexts)
expected = philox_gumbel_sample(
logits,
contexts,
42,
skip_mask=repeated,
expanded_idx_mapping=request_indices,
temperatures=temperatures,
seeds=seeds,
positions=positions,
)
actual, output_logits = sampler._sample_random(
logits,
request_indices,
request_indices_np,
positions,
None,
None,
False,
False,
)
assert torch.equal(repeated, torch.tensor([True, False], device=device))
assert torch.equal(actual, expected)
assert output_logits is logits
def test_gpu_sampler_skips_watermarking_for_greedy_batch(monkeypatch):
class StubWatermarker:
context_width = 1
def sample(self, logits, contexts, random_sampler=None, skip_mask=None):
raise AssertionError("watermarker should not run for greedy requests")
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = StubWatermarker()
sampler.watermarking = SimpleNamespace(
np=np.array([True, True]), gpu=torch.tensor([True, True])
)
sampler.sampling_states = SimpleNamespace(
temperature=SimpleNamespace(np=np.zeros(2), gpu=torch.zeros(2)),
)
expected = (torch.tensor([3, 4]), torch.zeros(2, 8))
monkeypatch.setattr(
"vllm.v1.watermarking.gpu_sampler.Sampler._sample_random",
lambda *args, **kwargs: expected,
)
actual = sampler._sample_random(
torch.zeros(2, 8),
torch.tensor([0, 1]),
np.array([0, 1]),
torch.zeros(2, dtype=torch.int64),
None,
None,
False,
False,
)
assert actual is expected
def test_gpu_sampler_builds_speculative_contexts_from_drafts():
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = SimpleNamespace(context_width=2)
sampler.req_states = SimpleNamespace(
all_token_ids=SimpleNamespace(gpu=torch.tensor([[100, 101, 10, 11, 0, 0]])),
prompt_len=SimpleNamespace(gpu=torch.tensor([2])),
total_len=SimpleNamespace(gpu=torch.tensor([4])),
)
contexts = sampler._get_contexts(
torch.tensor([0, 0, 0]),
torch.tensor([0, 1, 2]),
torch.tensor([11, 20, 21]),
)
assert torch.equal(contexts, torch.tensor([[10, 11], [11, 20], [20, 21]]))
def test_gpu_sampler_deduplicates_contexts_within_speculative_block():
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = SimpleNamespace(context_width=2)
sampler.deduplicate_contexts = "single_turn"
sampler.deduplicate_contexts_max_history = None
sampler.num_speculative_tokens = 4
sampler.req_states = SimpleNamespace(
all_token_ids=SimpleNamespace(gpu=torch.tensor([[100, 1, 2, 0, 0, 0]])),
prompt_len=SimpleNamespace(gpu=torch.tensor([1])),
total_len=SimpleNamespace(gpu=torch.tensor([3])),
)
request_indices = torch.tensor([0, 0, 0, 0, 0])
local_positions = torch.tensor([0, 1, 2, 3, 4])
contexts = sampler._get_contexts(
request_indices,
local_positions,
torch.tensor([0, 3, 4, 1, 2]),
)
repeated = sampler._get_repeated_contexts(
request_indices, contexts, local_positions
)
assert torch.equal(
contexts,
torch.tensor([[1, 2], [2, 3], [3, 4], [4, 1], [1, 2]]),
)
assert torch.equal(repeated, torch.tensor([False, False, False, False, True]))
sampler.deduplicate_contexts_max_history = 3
repeated = sampler._get_repeated_contexts(
request_indices, contexts, local_positions
)
assert not repeated.any()
def test_gpu_sampler_all_scope_skips_only_partial_speculative_contexts():
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = SimpleNamespace(context_width=2)
sampler.deduplicate_contexts = "all"
sampler.deduplicate_contexts_max_history = None
sampler.num_speculative_tokens = 3
sampler.req_states = SimpleNamespace(
all_token_ids=SimpleNamespace(gpu=torch.tensor([[100, 101, 10, 0, 0]])),
prompt_len=SimpleNamespace(gpu=torch.tensor([2])),
total_len=SimpleNamespace(gpu=torch.tensor([3])),
)
request_indices = torch.tensor([0, 0, 0])
local_positions = torch.tensor([0, 1, 2])
contexts = sampler._get_contexts(
request_indices,
local_positions,
torch.tensor([10, 20, 30]),
)
repeated = sampler._get_repeated_contexts(
request_indices, contexts, local_positions
)
assert torch.equal(contexts, torch.tensor([[-1, 10], [10, 20], [20, 30]]))
assert torch.equal(repeated, torch.tensor([True, False, False]))
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@pytest.mark.parametrize("max_history,expected_last", [(None, True), (3, False)])
def test_speculative_context_repetition_accelerator_parity(
max_history: int | None, expected_last: bool
):
all_token_ids = torch.tensor([[10, 11]])
req_indices = torch.tensor([0, 0, 0, 0, 0])
prompt_lens = torch.tensor([0])
total_lens = torch.tensor([2])
local_positions = torch.tensor([0, 1, 2, 3, 4])
contexts = torch.tensor([[1, 2], [2, 3], [3, 4], [4, 1], [1, 2]])
expected = repeated_context_mask(
all_token_ids,
req_indices,
prompt_lens,
total_lens,
contexts,
history_offsets=local_positions,
local_positions=local_positions,
num_speculative_steps=4,
max_history=max_history,
)
actual = repeated_context_mask(
all_token_ids.cuda(),
req_indices.cuda(),
prompt_lens.cuda(),
total_lens.cuda(),
contexts.cuda(),
history_offsets=local_positions.cuda(),
local_positions=local_positions.cuda(),
num_speculative_steps=4,
max_history=max_history,
).cpu()
assert torch.equal(
expected, torch.tensor([False, False, False, False, expected_last])
)
assert torch.equal(actual, expected)
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@pytest.mark.parametrize("steps_layout", ["per_row", "broadcast"])
@pytest.mark.parametrize("req_indices_layout", ["contiguous", "column_view"])
def test_draft_context_deduplication_accelerator_parity(
steps_layout, req_indices_layout
):
"""Parity with the CPU reference for the layouts the speculators pass.
A draft step is one 0-d tensor broadcast over the batch, and DSpark samples
a step at a time, so its request indices are an `idx_map[:, i]` column. Both
are read per row, so a row that ignores the layout consults another row's
step or another request's history.
"""
num_rows, num_steps, step = 4, 3, 1
all_token_ids = torch.tensor([[10, 11, 20, 11], [30, 31, 11, 20]])
prompt_lens = torch.tensor([0, 0])
total_lens = torch.tensor([4, 4])
contexts = torch.tensor([[40, 41], [31, 11], [10, 11], [50, 51]])
prior_contexts = torch.zeros(num_rows, num_steps, 2, dtype=torch.int64)
prior_contexts[0, 0] = torch.tensor([40, 41])
enabled = torch.ones(num_rows, dtype=torch.bool)
req_indices = torch.tensor([0, 1, 0, 1])
def run(all_token_ids, req_indices, steps, prior):
return draft_watermarking_mask(
all_token_ids,
req_indices,
prompt_lens.to(all_token_ids.device),
total_lens.to(all_token_ids.device),
prior,
contexts.to(all_token_ids.device),
steps,
enabled.to(all_token_ids.device),
max_history=3,
include_prompt=False,
)
reference_prior = prior_contexts.clone()
reference = run(
all_token_ids,
req_indices,
torch.full((num_rows,), step),
reference_prior,
)
# Row 0 repeats its own step-0 context, rows 1 and 2 repeat a committed
# context of their own request, row 3 repeats nothing.
assert torch.equal(reference, torch.tensor([False, False, False, True]))
expected_prior = prior_contexts.clone()
expected_prior[:, step] = contexts
assert torch.equal(reference_prior, expected_prior)
if steps_layout == "per_row":
steps = torch.full((num_rows,), step).cuda()
else:
steps = torch.tensor(step).cuda().expand(num_rows)
assert steps.stride(0) == 0
if req_indices_layout == "contiguous":
gpu_req_indices = req_indices.cuda()
else:
gpu_req_indices = (
req_indices.repeat_interleave(num_steps).view(num_rows, num_steps).cuda()
)[:, 1]
assert gpu_req_indices.stride(0) == num_steps
actual_prior = prior_contexts.cuda()
actual = run(all_token_ids.cuda(), gpu_req_indices, steps, actual_prior).cpu()
assert torch.equal(actual, reference)
assert torch.equal(actual_prior.cpu(), expected_prior)
def test_gpu_sampler_builds_chunked_multi_request_speculative_contexts():
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = SimpleNamespace(context_width=2)
sampler.req_states = SimpleNamespace(
all_token_ids=SimpleNamespace(
gpu=torch.tensor(
[
[100, 101, 10, 11, 0, 0],
[200, 201, 30, 31, 0, 0],
[300, 301, 50, 51, 0, 0],
]
)
),
prompt_len=SimpleNamespace(gpu=torch.tensor([2, 2, 2])),
total_len=SimpleNamespace(gpu=torch.tensor([4, 4, 4])),
)
contexts = sampler._get_contexts(
torch.tensor([2, 2, 1, 1]),
torch.tensor([0, 1, 0, 1]),
torch.tensor([51, 60, 31, 40]),
)
assert torch.equal(
contexts,
torch.tensor([[50, 51], [51, 60], [30, 31], [31, 40]]),
)
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
def test_draft_context_deduplication_broadcasts_the_draft_step():
"""The draft step is one 0-d tensor broadcast over the batch.
The context occurs four positions back, which `max_history - step` puts
just outside the scanned window, so a row that mistakes another row's step
for its own reports the opposite verdict. The neighbours leg is the
deterministic control: a row that reads a step of 0 instead of 2 widens its
window by two positions and finds the context. The production leg asserts
the layout the speculators pass, where the stride-less read lands outside
the allocation and can return the right value by chance.
"""
num_rows = 4
all_token_ids = torch.tensor([[3, 4, 9, 9, 9, 9], [3, 4, 9, 9, 9, 9]])
req_indices = torch.tensor([0, 0, 1, 1])
prompt_lens = torch.tensor([0, 0])
total_lens = torch.tensor([6, 6])
prior_contexts = torch.zeros(num_rows, 3, 2, dtype=torch.int64)
contexts = torch.tensor([[3, 4]]).expand(num_rows, 2).contiguous()
enabled = torch.ones(num_rows, dtype=torch.bool)
def run(steps, device):
prior = prior_contexts.clone().to(device)
active = draft_watermarking_mask(
all_token_ids.to(device),
req_indices.to(device),
prompt_lens.to(device),
total_lens.to(device),
prior,
contexts.to(device),
steps,
enabled.to(device),
max_history=4,
include_prompt=False,
)
return active.cpu(), prior.cpu()
expected, expected_prior = run(torch.full((num_rows,), 2), torch.device("cpu"))
assert torch.equal(expected, torch.ones(num_rows, dtype=torch.bool))
materialized, materialized_prior = run(
torch.full((num_rows,), 2).cuda(), torch.device("cuda")
)
assert torch.equal(materialized, expected)
assert torch.equal(materialized_prior, expected_prior)
# Neighbouring steps are the value a row reads when the broadcast stride is
# ignored; the production tensor reads uninitialized memory instead.
neighbours = torch.tensor([2, 0, 0, 0]).cuda()
broadcast, broadcast_prior = run(
neighbours[0].expand(num_rows), torch.device("cuda")
)
assert torch.equal(broadcast, expected)
assert torch.equal(broadcast_prior, expected_prior)
step = torch.tensor(2).cuda()
production, production_prior = run(step.expand(num_rows), torch.device("cuda"))
assert torch.equal(production, expected)
assert torch.equal(production_prior, expected_prior)
def test_draft_sampler_uses_draft_key_and_advances_context(monkeypatch):
class StubSpeculator(DraftModelSpeculator):
def capture(self): ...
def init_cudagraph_manager(self, cudagraph_mode): ...
def load_draft_model(self, target_model, target_attn_layer_names): ...
def propose(self, *args, **kwargs): ...
class StubModel:
@staticmethod
def compute_logits(hidden_states):
return torch.zeros(hidden_states.shape[0], 8)
class StubWatermarker:
context_width = 2
@staticmethod
def sample(logits, contexts, random_sampler=None, skip_mask=None):
assert random_sampler is not None
return WatermarkSample(
torch.where(skip_mask, random_sampler(logits), 7), logits
)
speculator = object.__new__(StubSpeculator)
speculator.model = StubModel()
speculator.use_fp64_gumbel = False
speculator.acceptance_estimator = None
draft_watermarker = DraftWatermarker(
StubWatermarker(),
max_num_reqs=2,
device=torch.device("cpu"),
num_speculative_steps=1,
deduplicate_contexts="none",
deduplicate_contexts_max_history=None,
)
draft_watermarker.state.contexts.copy_(torch.tensor([[1, 2], [3, 4]]))
draft_watermarker.state.enabled = torch.tensor([True, False])
speculator.draft_watermarker = draft_watermarker
monkeypatch.setattr(
"vllm.v1.watermarking.watermarker.gumbel_sample",
lambda *args, **kwargs: torch.tensor([3, 4]),
)
sampled = speculator.sample_draft(
hidden_states=torch.zeros(2, 4),
sample_src_positions=torch.zeros(2, dtype=torch.int64),
idx_mapping=torch.tensor([0, 1]),
temperature=torch.ones(2),
seeds=torch.zeros(2, dtype=torch.int64),
draft_step=torch.tensor(0),
draft_logits=torch.zeros(2, 1, 8),
)
assert torch.equal(sampled, torch.tensor([7, 4]))
assert torch.equal(draft_watermarker.state.contexts, torch.tensor([[2, 7], [4, 4]]))
def test_draft_sampler_uses_ordinary_samples_for_repeated_contexts(monkeypatch):
class StubWatermarker:
context_width = 1
def __init__(self):
self.samples = iter((2, 1, 8))
def sample(self, logits, contexts, random_sampler=None, skip_mask=None):
watermarked = torch.tensor([next(self.samples)])
return WatermarkSample(
torch.where(skip_mask, random_sampler(logits), watermarked), logits
)
draft_watermarker = DraftWatermarker(
StubWatermarker(),
max_num_reqs=1,
device=torch.device("cpu"),
num_speculative_steps=3,
deduplicate_contexts="single_turn",
deduplicate_contexts_max_history=None,
)
draft_watermarker.prepare(
contexts=torch.tensor([[1]]),
enabled=torch.tensor([True]),
all_token_ids=torch.tensor([[100, 1, 0, 0]]),
prompt_lens=torch.tensor([1]),
total_lens=torch.tensor([2]),
)
idx_mapping = torch.tensor([0])
temperature = torch.tensor([1.0])
ordinary_samples = iter((torch.tensor([5]), torch.tensor([6]), torch.tensor([4])))
monkeypatch.setattr(
"vllm.v1.watermarking.watermarker.gumbel_sample",
lambda *args, **kwargs: next(ordinary_samples),
)
seeds = torch.tensor([0])
draft_logits = torch.zeros(1, 3, 8)
first = draft_watermarker.sample(
torch.zeros(1, 8),
idx_mapping,
temperature=temperature,
seed=seeds,
pos=torch.tensor([0]),
apply_temperature=True,
is_drafting=True,
logits_cache_col=torch.tensor(0),
logits_cache=draft_logits,
use_fp64=False,
)
second = draft_watermarker.sample(
torch.zeros(1, 8),
idx_mapping,
temperature=temperature,
seed=seeds,
pos=torch.tensor([1]),
apply_temperature=True,
is_drafting=True,
logits_cache_col=torch.tensor(1),
logits_cache=draft_logits,
use_fp64=False,
)
repeated = draft_watermarker.sample(
torch.zeros(1, 8),
idx_mapping,
temperature=temperature,
seed=seeds,
pos=torch.tensor([2]),
apply_temperature=True,
is_drafting=True,
logits_cache_col=torch.tensor(2),
logits_cache=draft_logits,
use_fp64=False,
)
assert first.item() == 2
assert second.item() == 1
assert repeated.item() == 4
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
def test_draft_context_deduplication_reads_strided_request_indices():
"""DSpark samples a step at a time, so req_indices is an `idx_map[:, i]` column."""
num_reqs = 4
num_speculative_steps = 3
all_token_ids = torch.tensor(
[[3, 4, 3, 4], [9, 9, 9, 9], [3, 4, 3, 4], [9, 9, 9, 9]]
).cuda()
prompt_lens = torch.zeros(num_reqs, dtype=torch.int64).cuda()
total_lens = torch.full((num_reqs,), 4).cuda()
contexts = torch.tensor([[3, 4]]).expand(num_reqs, 2).contiguous().cuda()
steps = torch.zeros(num_reqs, dtype=torch.int64).cuda()
enabled = torch.ones(num_reqs, dtype=torch.bool).cuda()
idx_map = (
torch.arange(num_reqs)
.repeat_interleave(num_speculative_steps)
.view(num_reqs, num_speculative_steps)
.cuda()
)
column = idx_map[:, 1]
assert column.stride(0) == num_speculative_steps
def run(req_indices):
prior = torch.zeros(
num_reqs, num_speculative_steps, 2, dtype=torch.int64, device="cuda"
)
return draft_watermarking_mask(
all_token_ids,
req_indices,
prompt_lens,
total_lens,
prior,
contexts,
steps,
enabled,
max_history=None,
include_prompt=False,
).cpu()
expected = torch.tensor([False, True, False, True])
assert torch.equal(run(column.contiguous()), expected)
assert torch.equal(run(column), expected)
def test_draft_sampler_deduplicates_against_committed_history(monkeypatch):
class StubWatermarker:
context_width = 1
@staticmethod
def sample(logits, contexts, random_sampler=None, skip_mask=None):
return WatermarkSample(
torch.where(skip_mask, random_sampler(logits), 7), logits
)
draft_watermarker = DraftWatermarker(
StubWatermarker(),
max_num_reqs=1,
device=torch.device("cpu"),
num_speculative_steps=1,
deduplicate_contexts="single_turn",
deduplicate_contexts_max_history=None,
)
draft_watermarker.prepare(
contexts=torch.tensor([[1]]),
enabled=torch.tensor([True]),
all_token_ids=torch.tensor([[100, 1, 2, 1]]),
prompt_lens=torch.tensor([1]),
total_lens=torch.tensor([4]),
)
monkeypatch.setattr(
"vllm.v1.watermarking.watermarker.gumbel_sample",
lambda *args, **kwargs: torch.tensor([4]),
)
sampled = draft_watermarker.sample(
torch.zeros(1, 8),
torch.tensor([0]),
temperature=torch.tensor([1.0]),
seed=torch.tensor([0]),
pos=torch.tensor([0]),
apply_temperature=True,
is_drafting=True,
logits_cache_col=torch.tensor(0),
logits_cache=torch.zeros(1, 1, 8),
use_fp64=False,
)
assert sampled.item() == 4
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@pytest.mark.parametrize("idx_mapping_layout", ["contiguous", "column_view"])
def test_draft_watermarker_deduplicates_across_a_block_on_cuda(
monkeypatch, idx_mapping_layout
):
"""Drive the whole draft sampler over a block with the production shapes.
`draft_step` is a 0-d tensor, and a speculator that samples one step at a
time hands over a column of its index map. Each step is checked against the
committed history the window still reaches and against the earlier steps of
the block.
"""
num_reqs, num_steps, vocab_size = 2, 3, 16
watermarker = GumbelWatermarker(key=42, context_width=2)
draft_watermarker = DraftWatermarker(
watermarker,
max_num_reqs=num_reqs,
device=torch.device("cuda"),
num_speculative_steps=num_steps,
deduplicate_contexts="single_turn",
deduplicate_contexts_max_history=4,
)
draft_watermarker.prepare(
contexts=torch.tensor([[5, 6], [6, 3]]).cuda(),
enabled=torch.ones(num_reqs, dtype=torch.bool).cuda(),
all_token_ids=torch.tensor([[5, 6, 7, 5, 6], [8, 9, 6, 6, 3]]).cuda(),
prompt_lens=torch.zeros(num_reqs, dtype=torch.int64).cuda(),
total_lens=torch.full((num_reqs,), 5).cuda(),
)
if idx_mapping_layout == "contiguous":
idx_mapping = torch.arange(num_reqs, dtype=torch.int32).cuda()
else:
idx_mapping = (
torch.arange(num_reqs, dtype=torch.int32)
.repeat_interleave(num_steps)
.view(num_reqs, num_steps)
.cuda()
)[:, 1]
assert idx_mapping.stride(0) == num_steps
skip_masks: list[torch.Tensor] = []
original = philox_gumbel_sample
def spy(*args, **kwargs):
skip_masks.append(kwargs["skip_mask"].cpu())
return original(*args, **kwargs)
monkeypatch.setattr("vllm.v1.worker.gpu.sample.watermark.philox_gumbel_sample", spy)
draft_logits = torch.zeros(num_reqs, num_steps, vocab_size + 1).cuda()
step_logits = []
for step in range(num_steps):
# Token 6 wins by a margin no Gumbel draw can close, on either path.
logits = torch.zeros(num_reqs, vocab_size).cuda()
logits[:, 6] = 100.0
logits[:, 0] = float(step) + 1.0
step_logits.append(logits)
sampled = draft_watermarker.sample(
logits,
idx_mapping,
temperature=torch.ones(num_reqs).cuda(),
seed=torch.zeros(num_reqs, dtype=torch.int64).cuda(),
pos=torch.full((num_reqs,), 5 + step, dtype=torch.int64).cuda(),
apply_temperature=True,
is_drafting=True,
logits_cache_col=torch.tensor(step).cuda(),
logits_cache=draft_logits,
use_fp64=False,
)
assert torch.equal(sampled.cpu(), torch.tensor([6, 6]))
# Step 0: request 0's [5, 6] is committed at position 2, request 1's [6, 3]
# is new. Step 1: [6, 6] and [3, 6] are both new. Step 2: request 0's
# [6, 6] repeats its own step-1 context and request 1's repeats position 4.
assert [mask.tolist() for mask in skip_masks] == [
[True, False],
[False, False],
[True, True],
]
assert torch.equal(
draft_watermarker.state.prior_contexts[:num_reqs].cpu(),
torch.tensor([[[5, 6], [6, 6], [6, 6]], [[6, 3], [3, 6], [6, 6]]]),
)
assert torch.equal(
draft_watermarker.state.contexts[:num_reqs].cpu(),
torch.tensor([[6, 6], [6, 6]]),
)
for step, logits in enumerate(step_logits):
# The 0-d draft step selects the logits cache column.
assert torch.equal(draft_logits[:, step, :vocab_size], logits)
assert not draft_logits[:, :, -1].any()
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
def test_draft_watermarker_cuda_graph_reads_current_prompt_lens():
draft_watermarker = DraftWatermarker(
GumbelWatermarker(key=42, context_width=2),
max_num_reqs=1,
device=torch.device("cuda"),
num_speculative_steps=1,
deduplicate_contexts="single_turn",
deduplicate_contexts_max_history=None,
)
prompt_lens = UvaBackedTensor(1, dtype=torch.int32)
contexts = torch.tensor([[5, 6]]).cuda()
enabled = torch.ones(1, dtype=torch.bool).cuda()
# [5, 6] repeats only while the prompt counts as history.
all_token_ids = torch.tensor([[5, 6, 9, 5, 6]]).cuda()
total_lens = torch.tensor([5]).cuda()
logits = torch.zeros(1, 8).cuda()
idx_mapping = torch.zeros(1, dtype=torch.int32).cuda()
temperature = torch.ones(1).cuda()
draft_step = torch.tensor(0).cuda()
def prepare():
draft_watermarker.prepare(
contexts, enabled, all_token_ids, prompt_lens.gpu, total_lens
)
def watermark_mask():
_, mask = draft_watermarker._sampling_state(
logits, idx_mapping, temperature, draft_step, contexts
)
return mask
prepare()
assert not watermark_mask().item()
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
mask = watermark_mask()
# A step that adds requests moves prompt_lens.gpu to the next pool buffer.
prompt_lens.np[:] = 3
prompt_lens.copy_to_uva()
prepare()
graph.replay()
assert mask.item()
def test_dflash_draft_sampler_watermarks_drafts_in_step_order(monkeypatch):
class NextTokenWatermarker:
context_width = 2
@staticmethod
def _try_sample_block(*args, **kwargs):
return None
@staticmethod
def sample(logits, contexts, random_sampler=None, skip_mask=None):
watermarked = contexts[:, -1] + 1
return WatermarkSample(
torch.where(skip_mask, random_sampler(logits), watermarked), logits
)
def ordinary_sample(logits, idx_mapping, temperature, seed, pos, **kwargs):
assert kwargs["use_fp64"]
cache, col = kwargs["logits_cache"], kwargs["logits_cache_col"]
cache[idx_mapping, col] = kwargs["logits_cache_source"]
return seed[idx_mapping] + pos
monkeypatch.setattr(
"vllm.v1.watermarking.watermarker.gumbel_sample", ordinary_sample
)
num_reqs, num_steps, vocab_size = 3, 2, 4
draft_watermarker = DraftWatermarker(
NextTokenWatermarker(),
max_num_reqs=8,
device=torch.device("cpu"),
num_speculative_steps=num_steps,
deduplicate_contexts="none",
deduplicate_contexts_max_history=None,
)
# Batch rows hold request slots 2, 0, 1. Slot 0 is greedy and row 2 opted out.
draft_watermarker.state.contexts[:num_reqs] = torch.tensor([[1, 2], [3, 4], [5, 6]])
draft_watermarker.state.enabled[:num_reqs] = torch.tensor([True, True, False])
speculator = object.__new__(DFlashSpeculator)
speculator.model = SimpleNamespace(
compute_logits=lambda hidden_states: hidden_states
)
speculator.draft_watermarker = draft_watermarker
speculator.use_fp64_gumbel = True
speculator.acceptance_estimator = None
batch_slots = torch.tensor([2, 0, 1])
logits = torch.arange(num_reqs * num_steps * vocab_size, dtype=torch.float32)
draft_logits = torch.zeros(num_reqs, num_steps, vocab_size)
sampled = speculator.sample_draft(
hidden_states=logits.view(-1, vocab_size),
sample_src_positions=torch.arange(num_reqs * num_steps),
idx_mapping=batch_slots.repeat_interleave(num_steps),
temperature=torch.tensor([0.0, 1.0, 0.5]),
seeds=torch.tensor([100, 200, 300]),
draft_step=torch.arange(num_steps).repeat(num_reqs),
draft_logits=draft_logits,
num_steps=num_steps,
)
assert torch.equal(sampled, torch.tensor([3, 4, 102, 103, 204, 205]))
assert torch.equal(draft_logits[batch_slots], logits.view(num_reqs, num_steps, -1))
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
@pytest.mark.parametrize("deduplicate_contexts", ["none", "single_turn", "all"])
def test_dflash_draft_sampler_matches_step_loop_on_cuda(deduplicate_contexts, dtype):
"""The fused CUDA `sample_block` is bit-exact with sampling step by step.
Covers repeated contexts, a NaN logit, and greedy, opted-out and padding
rows; padding rows are discarded, so only valid rows are compared.
"""
num_reqs, num_steps, vocab_size, max_num_reqs = 4, 5, 1500, 6
generator = torch.Generator().manual_seed(0)
logits = torch.randn(num_reqs, num_steps, vocab_size, generator=generator) * 3
logits[..., :3] += 6
logits[0, 1, 5] = float("nan")
logits = logits.to(dtype).cuda()
idx_mapping = torch.tensor([4, 2, 0, -1], dtype=torch.int32)
valid = (idx_mapping >= 0).cuda()
idx_mapping = idx_mapping.repeat_interleave(num_steps).cuda()
temperature = torch.tensor([0.7, 1.0, 0.0, 1.3, 0.6, 0.9]).cuda()
seeds = torch.arange(max_num_reqs).cuda() * 1000
pos = (torch.arange(num_steps).repeat(num_reqs) + 20).cuda()
draft_step = torch.arange(num_steps).repeat(num_reqs).cuda()
contexts = torch.randint(0, 3, (num_reqs, 2), generator=generator)
contexts[0, 0] = -1
all_token_ids = torch.randint(0, 3, (max_num_reqs, 32), generator=generator)
def run(fused: bool):
draft_watermarker = DraftWatermarker(
GumbelWatermarker(key=42, context_width=2),
max_num_reqs=max_num_reqs,
device=torch.device("cuda"),
num_speculative_steps=num_steps,
deduplicate_contexts=deduplicate_contexts,
deduplicate_contexts_max_history=None,
)
draft_watermarker.prepare(
contexts=contexts.cuda(),
enabled=torch.tensor([True, False, True, True]).cuda(),
all_token_ids=all_token_ids.cuda(),
prompt_lens=torch.full((max_num_reqs,), 4).cuda(),
total_lens=torch.full((max_num_reqs,), 24).cuda(),
)
draft_logits = torch.zeros(max_num_reqs, num_steps, vocab_size, dtype=dtype)
draft_logits = draft_logits.cuda()
args = (temperature, seeds)
if fused:
sampled = draft_watermarker.sample_block(
logits.view(-1, vocab_size),
idx_mapping,
*args,
pos,
logits_cache=draft_logits,
logits_cache_col=draft_step,
)
else:
by_step = [t.view(num_reqs, num_steps) for t in (idx_mapping, pos)]
steps = draft_step.view(num_reqs, num_steps)
sampled = torch.stack(
[
draft_watermarker.sample(
logits[:, step],
by_step[0][:, step],
*args,
by_step[1][:, step],
logits_cache=draft_logits,
logits_cache_col=steps[:, step],
apply_temperature=True,
is_drafting=True,
)
for step in range(num_steps)
],
dim=1,
).view(-1)
return (
sampled.view(num_reqs, num_steps)[valid],
draft_watermarker.state.contexts[:num_reqs][valid],
draft_watermarker.state.prior_contexts,
draft_logits,
)
for fused, reference in zip(run(fused=True), run(fused=False)):
torch.testing.assert_close(fused, reference, rtol=0, atol=0, equal_nan=True)
def test_dspark_reduced_vocab_draft_sampler_applies_watermarking(monkeypatch):
speculator = object.__new__(DSparkSpeculator)
speculator.draft_logits = torch.zeros(2, 1, 8)
speculator._d2t_scatter_index = torch.tensor([1, 5])
speculator._draft_scatter_buf = torch.full((2, 8), float("-inf"))
speculator.temperature = torch.ones(2)
speculator.seeds = torch.zeros(2, dtype=torch.int64)
speculator._step_cols = torch.tensor([0])
speculator.use_fp64_gumbel = False
speculator.acceptance_estimator = None
watermark_logits: list[torch.Tensor] = []
def sample(
logits,
idx_mapping,
temperature,
seed,
pos,
apply_temperature,
is_drafting,
logits_cache=None,
logits_cache_col=None,
use_fp64=False,
logits_cache_source=None,
):
watermark_logits.append(logits.clone())
return torch.tensor([4, 5])
speculator.draft_watermarker = SimpleNamespace(sample=sample)
sampled = speculator._sample_logits(
torch.tensor([[10.0, 20.0], [30.0, 40.0]]),
torch.tensor([0, 1]),
torch.tensor([1, 1]),
0,
)
assert torch.equal(sampled, torch.tensor([4, 5]))
assert torch.equal(
watermark_logits[0][:, [1, 5]], torch.tensor([[10, 20], [30, 40]])
)
assert torch.isneginf(watermark_logits[0][:, [0, 2, 3, 4, 6, 7]]).all()
def test_dspark_target_only_watermarking_leaves_drafts_unwatermarked(monkeypatch):
speculator = object.__new__(DSparkSpeculator)
speculator.draft_logits = torch.zeros(2, 1, 8)
speculator._d2t_scatter_index = None
speculator.temperature = torch.ones(2)
speculator.seeds = torch.zeros(2, dtype=torch.int64)
speculator._step_cols = torch.tensor([0])
speculator.use_fp64_gumbel = False
speculator.acceptance_estimator = None
speculator.draft_watermarker = None
monkeypatch.setattr(
"vllm.v1.worker.gpu.spec_decode.dspark.speculator.gumbel_sample",
lambda *args, **kwargs: torch.tensor([3, 4]),
)
sampled = speculator._sample_logits(
torch.zeros(2, 8),
torch.tensor([0, 1]),
torch.tensor([1, 1]),
0,
)
assert torch.equal(sampled, torch.tensor([3, 4]))