[watermarking] Dual-key gumbel-max watermarking for speculative decoding support (#56122)

Signed-off-by: Raphael Rialland <raphael.rialland@mistral.ai>
Signed-off-by: Simon Veitner <sveitner@redhat.com>
Signed-off-by: Tomas Ruiz <tomas.ruiz.te@gmail.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Simon Veitner <sveitner@redhat.com>
Co-authored-by: Tomas Ruiz <tomas.ruiz.te@gmail.com>
This commit is contained in:
Raphaël Rialland
2026-09-12 18:42:01 +00:00
committed by GitHub
co-authored by OpenAI Codex Simon Veitner Tomas Ruiz
parent 1ee4be4dbc
commit 7ee8a6dd01
28 changed files with 3460 additions and 245 deletions
+22
View File
@@ -122,6 +122,28 @@ steps:
commands:
- pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-pcp.txt
- label: ":nvidia: (H100) LM Eval Watermark Feature Combination"
key: lm-eval-dspark-watermark-2xh100
device: h100
num_devices: 2
# Local TP2 runs took 84–379 seconds, including startup.
timeout_in_minutes: 15
source_file_dependencies:
- .buildkite/test_areas/lm_eval.yaml
- tests/evals/gsm8k/
- vllm/config/speculative.py
- vllm/config/vllm.py
- vllm/config/watermarking.py
- vllm/sampling_params.py
- vllm/model_executor/models/qwen3_dspark.py
- vllm/model_executor/models/qwen3_dflash.py
- vllm/model_executor/warmup/
- vllm/v1/watermarking/
- vllm/v1/worker/gpu/
autorun_on_main: true
commands:
- pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-watermark-dspark-tp2.txt
- label: ":nvidia: (B200) LM Eval Spec Decode"
key: lm-eval-spec-decode-4xb200
timeout_in_minutes: 120
+110 -10
View File
@@ -34,11 +34,17 @@ Deployments that require watermarking must restrict this field to trusted
callers, or strip and validate it at the ingress boundary, so untrusted clients
cannot opt out.
`context_width` controls how many prior output tokens seed each watermark
decision and defaults to 4. Larger values make the watermark less robust to
`context_width` controls how many prior tokens seed each watermark decision
and defaults to 4. Larger values make the watermark less robust to
edits because an insertion, deletion, or substitution changes more subsequent
contexts. Values above 16 are allowed but emit a warning.
`allow_target_only_watermarking` defaults to false and only has an effect when
speculative decoding is enabled. It permits speculative decoding with a
watermarking algorithm that does not support it natively, at the cost of
weaker detectability. See
[Speculative decoding](#speculative-decoding).
## Architecture
`WatermarkConfig` selects an algorithm and PRF. Model Runner V2 constructs the
@@ -52,21 +58,108 @@ the reference algorithms. `WatermarkDetector` consumes token IDs, so callers
remain responsible for using the tokenizer and watermark profile that match
generation.
## Speculative decoding
Watermarking requires speculative decoding to use probabilistic draft sampling,
standard rejection sampling, and an autoregressive model-based method (`dspark`,
`eagle`, `eagle3`, or `mtp`). Parallel drafting is supported only by `dspark`.
A watermarking algorithm without native speculative-decoding support is
rejected before model loading. Set
`"allow_target_only_watermarking": true` to allow it: accepted draft tokens are
not watermarked, while target-side rejection recovery and bonus sampling remain
watermarked. The watermark signal is diluted in proportion to the share of
output tokens supplied by accepted drafts; rejected drafts do not dilute it
because their recovery tokens are watermarked.
Speculative-decoding token paths do not currently support generation-side
context deduplication. The configured `deduplicate_contexts` policy is not
applied to accepted drafts, rejection-recovery tokens, or bonus tokens.
For `dual_key_gumbel`, `alpha` has no effect under speculative decoding. The
speculative protocol selects the key for each token instead.
## Algorithms
### Gumbel-max
Gumbel-max derives a deterministic pseudorandom value from the key, prior
generated-token context, and every candidate token, then uses the resulting
Gumbel noise for categorical sampling. See
Gumbel-max derives a deterministic pseudorandom value from the key, prior token
context, and every candidate token, then uses the resulting Gumbel noise for
categorical sampling. See
[Aaronson's original presentation](https://simons.berkeley.edu/sites/default/files/2024-10/LLM24-2%20Slides%20-%20Scott%20Aaronson.pdf).
Gumbel-max requires stochastic sampling. Greedy requests (`temperature=0`)
bypass watermarking and emit a warning once per worker.
Single-key Gumbel-max watermarking may increase degenerate generations. In
particular, reusing the same keyed random vector when a context repeats can
reinforce repetition loops that continue until the maximum token limit.
When a token context is repeated, generation can use ordinary sampling for that
occurrence. Reusing the repeated context leads to a bias over the sequence, as
certain token choices would be correlated. Ordinary sampling at these positions
allows for single-sequence non-distortion (see section G.3 of the
[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)).
The detector independently deduplicates contexts so repeated keyed random
vectors are not treated as independent evidence, meaning that context
deduplication at generation time does not reduce the watermarking signal, unless
a user tampers with the output and removes the first occurrence while leaving an
unwatermarked repetition.
`deduplicate_contexts` controls which history is searched for a repeated
context:
- `"none"` disables context deduplication.
- `"single_turn"`, the default, searches the tokens generated for the current
request. This gives single-turn non-distortion.
- `"all"` also searches the prompt, which extends non-distortion across the
turns of a conversation. The first `context_width` generated tokens use
ordinary sampling, and subsequent tokens whose context already occurs in the
prompt are not watermarked. Previous turns of a session, or a prompt that
already contains the structure of the answer such as a tool result the model
extends, may therefore leave little of the answer watermarked. Given the
impact of `"all"` on both performance (sampling steps must scan over a
potentially large amount of previous context) and watermarking detectability
(we may significantly reduce the amount of watermarked tokens), you should
only use `"all"` when non-distortion across multiple turns is required.
`deduplicate_contexts_max_history` limits the search to the most recent
positions and defaults to 8,192. Each position is compared over the
`context_width` tokens before it, so the window of tokens read is that much
longer. A smaller value reduces scanning cost but only provides the guarantee
within that window. Set it to `null` to search back to the start of the
generation for `"single_turn"`, or of the request for `"all"`; an unbounded
search costs more as the sequence grows. The setting has no effect when
`deduplicate_contexts` is `"none"`. Values below 1,024 emit a warning because a
short window can miss repetition loops whose contexts recur farther apart.
For example, this checks prompt and completion history within the default
8,192-position window:
```bash
vllm serve MODEL \
--watermark-config \
'{"algorithm":"gumbel","key":42,"deduplicate_contexts":"all"}'
```
### Dual-key Gumbel-max
Dual-key Gumbel-max derives independent keys A and B from one configured master
key. During ordinary generation, each token uses key A with probability
`1 - alpha` and key B with probability `alpha`. The algorithm-specific `alpha`
parameter defaults to 0.1. Detection scores every token against both keys.
The same two key streams support speculative decoding without changing its
acceptance rate. In this mode, the speculative protocol selects the key instead
of `alpha`: draft tokens use key A, while rejection recovery and bonus tokens
use key B. The ordinary target-to-draft probability-ratio test remains unchanged,
implementing [SynthID-Text Supplementary Algorithm
6](https://media.springernature.com/original/springer-static/esm/art%3A10.1038%2Fs41586-024-08025-4/MediaObjects/41586_2024_8025_MOESM1_ESM.pdf).
Select `dual_key_gumbel` together with probabilistic drafting:
```bash
vllm serve MODEL \
--speculative-config \
'{"method":"mtp","num_speculative_tokens":3,"draft_sample_method":"probabilistic"}' \
--watermark-config '{"algorithm":"dual_key_gumbel","key":42,"alpha":0.1}'
```
### SynthID-Text
@@ -106,7 +199,14 @@ result = GumbelWatermarkDetector(key=42, prf="philox").detect(token_ids)
print(result.p_value, result.is_watermarked)
```
The tokenizer, algorithm, PRF, key, and context width must match generation.
The detection configuration must match the generation configuration, including
the tokenizer, PRF, watermarking algorithm, algorithm-specific watermarking
configuration, and key. In practice, this information is often unavailable
when checking a piece of text. Deployments should therefore retain the set of
candidate configurations they have served, test the text against each
candidate, and correct for multiple testing, for example with a Bonferroni
correction to the resulting p-values.
Gumbel-max detection scores repeated contexts once by default so identical PRF
random vectors are not treated as independent evidence. Keep
`deduplicate_contexts=True` unless the detector's calibration has been adjusted
@@ -140,7 +240,7 @@ watermarked output or to modify watermarked text so it is no longer detected.
## Limitations
- Watermarking is currently available only with Model Runner V2.
- Gumbel-max cannot be configured with speculative decoding.
- Not all watermarking algorithms have native speculative-decoding support.
- Beam search expands candidates from model log probabilities and does not apply
Gumbel-max watermarking.
- Models that replace the vLLM sampler with a custom sampler cannot use
+22 -3
View File
@@ -53,18 +53,37 @@ def test_watermark_config_cli():
"--model",
"dummy",
"--watermark-config",
'{"algorithm":"gumbel","key":42,"prf":"philox"}',
'{"algorithm":"dual_key_gumbel","key":42,"prf":"philox","alpha":0.25,'
'"allow_target_only_watermarking":true}',
]
)
config = EngineArgs.from_cli_args(args).create_watermark_config()
assert config is not None
assert config.algorithm == "gumbel"
assert config.algorithm == "dual_key_gumbel"
assert config.key == 42
assert config.alpha == 0.25
assert config.context_width == 4
assert config.deduplicate_contexts == "single_turn"
assert config.deduplicate_contexts_max_history == 8192
assert config.prf == "philox"
assert not config.supports_speculative_decoding
assert config.allow_target_only_watermarking
args = parser.parse_args(
[
"--model",
"dummy",
"--watermark-config",
'{"key":42,"deduplicate_contexts":"none",'
'"deduplicate_contexts_max_history":32}',
]
)
config = EngineArgs.from_cli_args(args).create_watermark_config()
assert config is not None
assert config.deduplicate_contexts == "none"
assert config.deduplicate_contexts_max_history == 32
@pytest.mark.parametrize(
@@ -0,0 +1,29 @@
model_name: "Qwen/Qwen3-4B-FP8"
num_questions: 1319
num_fewshot: 5
max_tokens: 256
use_chat_completions: false
temperature: 1.0
seed: 42
# Reference accuracy from the non-watermarked DSpark regression test.
accuracy_threshold: 0.801
tolerance: 0.02
# Measured TP1 acceptance length: 4.115; allow a 0.2 regression.
min_acceptance_length: 3.915
env:
VLLM_USE_V2_MODEL_RUNNER: "1"
server_args: >-
--tensor-parallel-size 2
--max-model-len 4096
--gpu-memory-utilization 0.92
--no-enable-prefix-caching
--speculative-config.method dspark
--speculative-config.model deepseek-ai/dspark_qwen3_4b_block7
--speculative-config.num_speculative_tokens 7
--speculative-config.draft_sample_method probabilistic
--watermark-config.algorithm dual_key_gumbel
--watermark-config.key 42
--watermark-config.context_width 4
--watermark-config.deduplicate_contexts none
@@ -0,0 +1 @@
Qwen3-4B-FP8-DSpark-watermark-TP2.yaml
@@ -0,0 +1,121 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from vllm.config.watermarking import WatermarkConfig, derive_watermark_key
from vllm.model_executor.warmup.spec_decode_rejection_warmup import (
spec_decode_rejection_warmup,
)
MASTER_KEY = 20250910
def _worker(
watermark_config: WatermarkConfig | None,
draft_sample_method: str = "greedy",
) -> SimpleNamespace:
return SimpleNamespace(
device=torch.device("cpu"),
vllm_config=SimpleNamespace(
speculative_config=SimpleNamespace(
num_speculative_tokens=2,
rejection_sample_method="standard",
draft_sample_method=draft_sample_method,
),
model_config=SimpleNamespace(
get_vocab_size=lambda: 128, dtype=torch.bfloat16
),
watermark_config=watermark_config,
),
)
def _capture_calls(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
calls: list[dict] = []
def fake_rejection_sample(**kwargs):
calls.append(kwargs)
return None, None
monkeypatch.setattr(
"vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils.rejection_sample",
fake_rejection_sample,
)
return calls
def test_warmup_token_id_dtypes_match_the_runtime_buffers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _capture_calls(monkeypatch)
spec_decode_rejection_warmup(_worker(None))
assert calls
for kwargs in calls:
assert kwargs["draft_sampled"].dtype == torch.int32
assert kwargs["pos"].dtype == torch.int64
def test_warmup_index_mapping_dtypes_match_the_runtime_buffers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _capture_calls(monkeypatch)
spec_decode_rejection_warmup(_worker(None))
assert calls
for kwargs in calls:
assert kwargs["idx_mapping"].dtype == torch.int64
assert kwargs["expanded_idx_mapping"].dtype == torch.int64
assert kwargs["expanded_local_pos"].dtype == torch.int32
def test_warmup_watermark_args_match_the_runtime_kernel_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
watermark_config = WatermarkConfig(
key=MASTER_KEY, algorithm="dual_key_gumbel", context_width=3
)
calls = _capture_calls(monkeypatch)
spec_decode_rejection_warmup(
_worker(watermark_config, draft_sample_method="probabilistic")
)
assert calls
key_b = derive_watermark_key(MASTER_KEY, b"key_b")
for kwargs in calls:
contexts = kwargs["contexts"]
assert contexts.dtype == torch.int32
assert contexts.shape == (3, 3) and contexts.stride() == (3, 1)
assert kwargs["watermarking"].dtype == torch.bool
assert kwargs["watermark_key"] == key_b
assert key_b & 0xFFFFFFFF >= 2**31 and key_b >> 32 >= 2**31
def test_warmup_is_skipped_when_the_recovery_key_cannot_be_derived(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _capture_calls(monkeypatch)
monkeypatch.setattr(
"vllm.v1.watermarking.spec_decode.speculative_target_watermark_key",
lambda _: (_ for _ in ()).throw(NotImplementedError("no philox")),
)
spec_decode_rejection_warmup(_worker(WatermarkConfig(key=MASTER_KEY)))
assert calls == []
def test_warmup_draft_logits_match_the_runtime_draft_sample_method(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _capture_calls(monkeypatch)
spec_decode_rejection_warmup(_worker(None))
assert calls
assert all(kwargs["draft_logits"] is None for kwargs in calls)
calls = _capture_calls(monkeypatch)
spec_decode_rejection_warmup(_worker(None, draft_sample_method="probabilistic"))
assert calls
assert all(kwargs["draft_logits"] is not None for kwargs in calls)
@@ -0,0 +1,300 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""The warmup must cover every specialization the sampler will launch.
``_philox_gumbel_kernel`` is keyed on the Philox key halves, on the logits
dtype (model dtype when the sampler passes logits through, fp32 when logits
processing copies them) and on whether a skip mask is passed, so a missing
combination leaves a request paying the compile this module exists to remove.
"""
import logging
from types import SimpleNamespace
import pytest
import torch
from vllm.config.watermarking import WatermarkConfig, derive_watermark_key
from vllm.model_executor.warmup.watermark_sample_warmup import watermark_sample_warmup
from vllm.platforms import current_platform
from vllm.v1.watermarking.factory import create_watermarker
from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
from vllm.v1.watermarking.spec_decode import create_speculative_target_watermarker
MASTER_KEY = 20250910
KEY_A = derive_watermark_key(MASTER_KEY, b"key_a")
KEY_B = derive_watermark_key(MASTER_KEY, b"key_b")
@pytest.fixture(autouse=True)
def cuda_alike_platform(monkeypatch: pytest.MonkeyPatch) -> None:
"""The warmup returns early off CUDA, so pin the platform it reads.
Everything here runs on the fake worker's cpu device; without this the
tests below would pass only on a CUDA-alike host and silently warm nothing
anywhere else.
"""
monkeypatch.setattr(current_platform, "is_cuda_alike", lambda: True)
def _sampler(
watermark_config: WatermarkConfig,
speculative: bool,
use_fp64_gumbel: bool,
) -> GPUWatermarkSampler:
"""The sampler ``GPUModelRunner`` builds for this config.
Mirrors ``vllm/v1/worker/gpu/model_runner.py:473-486``. ``__new__`` skips
``Sampler.__init__``, which allocates UVA-backed device buffers; the warmup
only reads the three attributes set here.
"""
watermarker = create_watermarker(watermark_config)
if speculative:
watermarker = create_speculative_target_watermarker(watermarker)
sampler = GPUWatermarkSampler.__new__(GPUWatermarkSampler)
sampler.watermarker = watermarker
sampler.deduplicate_contexts = watermark_config.deduplicate_contexts
sampler.use_fp64_gumbel = use_fp64_gumbel
return sampler
def _worker(
watermark_config: WatermarkConfig | None,
speculative: bool = False,
use_fp64_gumbel: bool = False,
with_sampler: bool = True,
) -> SimpleNamespace:
sampler = (
_sampler(watermark_config, speculative, use_fp64_gumbel)
if watermark_config is not None and with_sampler
else None
)
return SimpleNamespace(
device=torch.device("cpu"),
model_runner=SimpleNamespace(sampler=sampler),
vllm_config=SimpleNamespace(
model_config=SimpleNamespace(
get_vocab_size=lambda: 128,
dtype=torch.bfloat16,
),
watermark_config=watermark_config,
),
)
def _capture_calls(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
calls: list[dict] = []
def fake_philox_gumbel_sample(logits, contexts, key, **kwargs):
calls.append({"logits": logits, "contexts": contexts, "key": key, **kwargs})
return torch.zeros(logits.shape[0], dtype=torch.int64)
monkeypatch.setattr(
"vllm.v1.worker.gpu.sample.watermark.philox_gumbel_sample",
fake_philox_gumbel_sample,
)
return calls
def _variants(calls: list[dict]) -> set[tuple[int, torch.dtype, bool]]:
return {
(call["key"], call["logits"].dtype, call.get("skip_mask") is not None)
for call in calls
}
def test_no_warmup_without_a_watermark_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(_worker(None))
assert calls == []
def test_no_warmup_without_a_watermarked_sampler(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A rank that does not sample launches nothing to warm.
Only the final pipeline stage of a generative model holds a
``GPUWatermarkSampler``; on any other rank the kernel is never launched.
"""
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(
_worker(WatermarkConfig(key=MASTER_KEY), with_sampler=False)
)
assert calls == []
def test_no_warmup_off_cuda(monkeypatch: pytest.MonkeyPatch) -> None:
"""``GumbelWatermarker`` launches the kernel only on a CUDA-alike device.
Elsewhere -- an XPU worker inherits ``kernel_warmup`` from the CUDA worker
-- the sampler takes the torch path, so warming would compile a kernel
nothing launches.
"""
calls = _capture_calls(monkeypatch)
monkeypatch.setattr(current_platform, "is_cuda_alike", lambda: False)
watermark_sample_warmup(_worker(WatermarkConfig(key=MASTER_KEY)))
assert calls == []
def test_single_key_warms_both_logits_dtypes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(_worker(WatermarkConfig(key=MASTER_KEY)))
assert _variants(calls) == {
(MASTER_KEY, torch.bfloat16, True),
(MASTER_KEY, torch.float32, True),
}
assert len(calls) == len(_variants(calls))
def test_dual_key_warms_both_derived_keys(monkeypatch: pytest.MonkeyPatch) -> None:
"""``DualKeyGumbelWatermarker.sample`` launches the kernel once per key.
The halves of a derived key exceed 2**31, so each key is typed i64/i32 by
magnitude and a wrong key compiles a specialization the engine never uses.
"""
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(
_worker(WatermarkConfig(key=MASTER_KEY, algorithm="dual_key_gumbel"))
)
assert _variants(calls) == {
(KEY_A, torch.bfloat16, True),
(KEY_A, torch.float32, True),
(KEY_B, torch.bfloat16, True),
(KEY_B, torch.float32, True),
}
@pytest.mark.parametrize("alpha,key", [(0.0, KEY_A), (1.0, KEY_B)])
def test_dual_key_alpha_bounds_warm_one_key(
monkeypatch: pytest.MonkeyPatch, alpha: float, key: int
) -> None:
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(
_worker(
WatermarkConfig(key=MASTER_KEY, algorithm="dual_key_gumbel", alpha=alpha)
)
)
assert _variants(calls) == {
(key, torch.bfloat16, True),
(key, torch.float32, True),
}
def test_speculative_config_warms_the_target_role_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Under spec decode the sampler holds ``create_target_watermarker()``.
Rows without draft tokens sample through it, so the sampler path launches
key B alone; the draft key runs in the speculator's own sampler.
"""
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(
_worker(
WatermarkConfig(key=MASTER_KEY, algorithm="dual_key_gumbel"),
speculative=True,
)
)
assert _variants(calls) == {
(KEY_B, torch.bfloat16, True),
(KEY_B, torch.float32, True),
}
def test_dedup_none_also_warms_the_mask_free_specialization(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Without deduplication an all-watermarked batch passes no skip mask.
A later batch mixing in an opted-out request passes one, so both
specializations are reachable and both must be warmed.
"""
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(
_worker(WatermarkConfig(key=MASTER_KEY, deduplicate_contexts="none"))
)
assert _variants(calls) == {
(MASTER_KEY, torch.bfloat16, True),
(MASTER_KEY, torch.float32, True),
(MASTER_KEY, torch.bfloat16, False),
(MASTER_KEY, torch.float32, False),
}
for call in calls:
if call.get("skip_mask") is None:
assert "temperatures" not in call
def test_warmup_argument_dtypes_match_the_runtime_buffers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Contexts are int32, the sampling state int64 indices and fp32 temperatures.
``GPUWatermarkSampler._get_contexts`` gathers from the int32 request-state
token ids, ``expanded_idx_mapping``/``positions``/``seeds`` are int64 and
``SamplingStates.temperature`` is fp32; warming other dtypes compiles
specializations no engine launches.
"""
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(_worker(WatermarkConfig(key=MASTER_KEY, context_width=3)))
assert calls
for call in calls:
num_tokens = call["logits"].shape[0]
assert call["contexts"].dtype == torch.int32
assert call["contexts"].shape == (num_tokens, 3)
assert call["skip_mask"].dtype == torch.bool
assert call["expanded_idx_mapping"].dtype == torch.int64
assert call["positions"].dtype == torch.int64
assert call["seeds"].dtype == torch.int64
assert call["temperatures"].dtype == torch.float32
def test_use_fp64_follows_the_path_that_forwards_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Only the mixed path forwards the sampler's ``use_fp64_gumbel``.
``GumbelWatermarker._sample_watermarked`` launches the mask-free variant
with the ``use_fp64=False`` default, so warming it with fp64 would compile
a specialization the engine never launches and miss the one it does.
"""
calls = _capture_calls(monkeypatch)
watermark_sample_warmup(
_worker(
WatermarkConfig(key=MASTER_KEY, deduplicate_contexts="none"),
use_fp64_gumbel=True,
)
)
assert calls
for call in calls:
assert call["use_fp64"] is (call.get("skip_mask") is not None)
def test_warmup_failure_does_not_abort_startup(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def failing_philox_gumbel_sample(*args, **kwargs):
raise RuntimeError("no triton")
monkeypatch.setattr(
"vllm.v1.worker.gpu.sample.watermark.philox_gumbel_sample",
failing_philox_gumbel_sample,
)
with caplog.at_level(logging.WARNING):
watermark_sample_warmup(_worker(WatermarkConfig(key=MASTER_KEY)))
assert "Skipping watermark sampler warmup." in caplog.text
+180 -5
View File
@@ -2856,14 +2856,183 @@ def _watermarked_vllm_config() -> VllmConfig:
return config
def test_gumbel_watermark_rejects_speculative_decoding():
def test_target_only_gumbel_allows_speculative_decoding(caplog_vllm, disable_log_dedup):
config = _watermarked_vllm_config()
config.speculative_config = SpeculativeConfig(
method="ngram",
num_speculative_tokens=1,
config.watermark_config = WatermarkConfig(
key=42, allow_target_only_watermarking=True
)
config.speculative_config = SimpleNamespace(
method="mtp",
draft_sample_method="probabilistic",
rejection_sample_method="standard",
parallel_drafting=False,
)
with pytest.raises(ValueError, match="does not support speculative decoding"):
with caplog_vllm.at_level(logging.WARNING):
config._check_watermarking_unsupported()
assert "Target-only watermarking leaves accepted draft tokens" in caplog_vllm.text
assert "Context deduplication is not supported" in caplog_vllm.text
def test_speculative_watermarking_without_context_dedup_does_not_warn(
caplog_vllm, disable_log_dedup
):
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(
algorithm="dual_key_gumbel", key=42, deduplicate_contexts="none"
)
config.speculative_config = SimpleNamespace(
method="mtp",
draft_sample_method="probabilistic",
rejection_sample_method="standard",
parallel_drafting=False,
)
with caplog_vllm.at_level(logging.WARNING):
config._check_watermarking_unsupported()
assert "Context deduplication is not supported" not in caplog_vllm.text
def test_gumbel_rejects_speculative_decoding_without_target_only():
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(
algorithm="gumbel", key=42, allow_target_only_watermarking=False
)
config.speculative_config = SimpleNamespace(
method="mtp",
draft_sample_method="probabilistic",
rejection_sample_method="standard",
parallel_drafting=False,
)
with pytest.raises(ValueError, match="'gumbel'.*allow_target_only_watermarking"):
config._check_watermarking_unsupported()
def test_dual_key_gumbel_warns_that_configured_alpha_is_unused(
caplog_vllm, disable_log_dedup
):
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(
algorithm="dual_key_gumbel", key=42, alpha=0.25
)
config.speculative_config = SimpleNamespace(
method="mtp",
draft_sample_method="probabilistic",
rejection_sample_method="standard",
parallel_drafting=False,
)
with caplog_vllm.at_level(logging.WARNING):
config._check_watermarking_unsupported()
assert "The configured alpha=0.25 is not used" in caplog_vllm.text
@pytest.mark.parametrize(
("alpha", "speculative"),
[(0.1, True), (0.25, False)],
ids=["default-alpha", "no-specdec"],
)
def test_dual_key_gumbel_alpha_warning_is_not_emitted(
caplog_vllm, disable_log_dedup, alpha, speculative
):
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(
algorithm="dual_key_gumbel", key=42, alpha=alpha
)
if speculative:
config.speculative_config = SimpleNamespace(
method="mtp",
draft_sample_method="probabilistic",
rejection_sample_method="standard",
parallel_drafting=False,
)
with caplog_vllm.at_level(logging.WARNING):
config._check_watermarking_unsupported()
assert "is not used" not in caplog_vllm.text
def test_dual_key_gumbel_requires_probabilistic_drafting():
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(algorithm="dual_key_gumbel", key=42)
config.speculative_config = SimpleNamespace(
method="mtp",
draft_sample_method="greedy",
rejection_sample_method="standard",
parallel_drafting=False,
)
with pytest.raises(ValueError, match="draft_sample_method='probabilistic'"):
config._check_watermarking_unsupported()
@pytest.mark.parametrize("method", ["eagle", "eagle3", "mtp"])
def test_dual_key_gumbel_supports_probabilistic_speculative_decoding(method):
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(algorithm="dual_key_gumbel", key=42)
config.speculative_config = SimpleNamespace(
method=method,
draft_sample_method="probabilistic",
rejection_sample_method="standard",
parallel_drafting=False,
)
config._check_watermarking_unsupported()
def test_dual_key_gumbel_supports_dspark():
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(algorithm="dual_key_gumbel", key=42)
config.speculative_config = SimpleNamespace(
method="dspark",
draft_sample_method="probabilistic",
rejection_sample_method="standard",
parallel_drafting=True,
)
config._check_watermarking_unsupported()
def test_dual_key_gumbel_rejects_non_autoregressive_speculation():
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(algorithm="dual_key_gumbel", key=42)
config.speculative_config = SimpleNamespace(
method="ngram",
draft_sample_method="probabilistic",
rejection_sample_method="standard",
parallel_drafting=False,
)
with pytest.raises(ValueError, match="autoregressive model-based"):
config._check_watermarking_unsupported()
@pytest.mark.parametrize(
("overrides", "match"),
[
({"rejection_sample_method": "synthetic"}, "rejection_sample_method"),
({"rejection_sample_method": "block"}, "rejection_sample_method"),
({"parallel_drafting": True}, "Parallel speculative drafting"),
],
)
def test_dual_key_gumbel_rejects_incompatible_speculative_modes(overrides, match):
config = _watermarked_vllm_config()
config.watermark_config = WatermarkConfig(algorithm="dual_key_gumbel", key=42)
values = {
"method": "mtp",
"draft_sample_method": "probabilistic",
"rejection_sample_method": "standard",
"parallel_drafting": False,
**overrides,
}
config.speculative_config = SimpleNamespace(**values)
with pytest.raises(ValueError, match=match):
config._check_watermarking_unsupported()
@@ -2882,6 +3051,12 @@ def test_watermark_key_must_fit_in_64_bits():
WatermarkConfig(key=2**64)
@pytest.mark.parametrize("alpha", [-0.1, 1.1])
def test_watermark_alpha_must_be_a_probability(alpha):
with pytest.raises(ValidationError):
WatermarkConfig(key=42, algorithm="dual_key_gumbel", alpha=alpha)
def test_unknown_watermark_prf_is_rejected():
with pytest.raises(ValidationError):
pydantic.TypeAdapter(WatermarkConfig).validate_python(
@@ -6,7 +6,10 @@ import math
import pytest
import torch
from vllm.v1.watermarking import GumbelWatermarker
from vllm.v1.watermarking.spec_decode import watermarked_rejection_sample
from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import (
rejection_sample,
)
@@ -139,6 +142,93 @@ def _assert_distribution_match(
)
def test_watermarked_bonus_uses_target_key():
target_logits = torch.randn(16, device="cuda")
inputs = _build_rejection_sample_inputs(
target_logits,
target_logits,
num_speculative_steps=1,
temperature=1.0,
num_trials=1,
)
draft_token = inputs["draft_sampled"][1]
contexts = torch.tensor(
[[10, 11], [11, draft_token.item()]], dtype=torch.int64, device="cuda"
)
sampled, num_sampled = watermarked_rejection_sample(
**inputs,
num_speculative_steps=1,
contexts=contexts,
watermarking=torch.tensor([True], device="cuda"),
watermarker=GumbelWatermarker(key=42, context_width=2),
)
expected = GumbelWatermarker(key=42, context_width=2).sample(
target_logits.unsqueeze(0), contexts[1:], lambda _: None
)
assert num_sampled.item() == 2
assert sampled[0, 1] == expected.token_ids[0]
def test_watermarked_recovery_uses_target_key():
target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0], device="cuda")
draft_logits = torch.tensor([100.0, -100.0, -100.0, -100.0], device="cuda")
inputs = _build_rejection_sample_inputs(
target_logits,
draft_logits,
num_speculative_steps=1,
temperature=1.0,
num_trials=1,
)
inputs["draft_sampled"][1] = 0
contexts = torch.tensor([[10, 11], [11, 0]], dtype=torch.int64, device="cuda")
sampled, num_sampled = watermarked_rejection_sample(
**inputs,
num_speculative_steps=1,
contexts=contexts,
watermarking=torch.tensor([True], device="cuda"),
watermarker=GumbelWatermarker(key=42, context_width=2),
)
expected = GumbelWatermarker(key=42, context_width=2).sample(
target_logits.unsqueeze(0), contexts[:1], lambda _: None
)
assert num_sampled.item() == 1
assert sampled[0, 0] == expected.token_ids[0]
def test_watermarked_recovery_supports_smaller_draft_vocabulary():
target_logits = torch.tensor([float("-inf"), 0.0, 1.0, 2.0, 100.0], device="cuda")
draft_logits = torch.tensor([100.0, -100.0, -100.0, -100.0], device="cuda")
inputs = _build_rejection_sample_inputs(
target_logits[:4],
draft_logits,
num_speculative_steps=1,
temperature=1.0,
num_trials=1,
)
inputs["target_logits"] = target_logits.unsqueeze(0).expand(2, -1).contiguous()
inputs["draft_sampled"][1] = 0
contexts = torch.tensor([[10, 11], [11, 0]], dtype=torch.int64, device="cuda")
watermarker = GumbelWatermarker(key=42, context_width=2)
sampled, num_sampled = watermarked_rejection_sample(
**inputs,
num_speculative_steps=1,
contexts=contexts,
watermarking=torch.tensor([True], device="cuda"),
watermarker=watermarker,
)
expected = watermarker.sample(
target_logits[:4].unsqueeze(0), contexts[:1], lambda _: None
)
assert num_sampled.item() == 1
assert sampled[0, 0] == expected.token_ids[0]
@pytest.mark.parametrize(
"num_speculative_steps,temperature",
[
@@ -747,3 +837,192 @@ def test_chunked_requests_match_full_batch(has_draft_logits: bool):
steps = torch.arange(num_speculative_steps + 1, device=device)
valid = steps.unsqueeze(0) < num_sampled.unsqueeze(1)
assert torch.equal(chunked_sampled[valid], sampled[valid])
def _residual_recovery_logits(
inputs: dict,
num_sampled: torch.Tensor,
vocab_size: int,
) -> tuple[torch.Tensor, torch.Tensor]:
target_logits = inputs["target_logits"]
draft_logits = inputs["draft_logits"]
cu_num_logits = inputs["cu_num_logits"]
resample_rows = cu_num_logits[:-1].to(torch.int64) + num_sampled.to(torch.int64) - 1
req_state_indices = inputs["expanded_idx_mapping"][resample_rows].to(torch.int64)
temperatures = inputs["temperature"][req_state_indices]
is_bonus = resample_rows == cu_num_logits[1:] - 1
next_rows = (resample_rows + 1).clamp_max(inputs["draft_sampled"].shape[0] - 1)
needs_residual = ~is_bonus & (inputs["draft_sampled"][next_rows] >= 0)
target_rows = target_logits[resample_rows][:, :vocab_size].float()
target_rows = torch.where(target_rows.isnan(), float("-inf"), target_rows)
if draft_logits is None:
rejected = inputs["draft_sampled"][next_rows].clamp_min(0)
one_hot = torch.arange(vocab_size, device=target_rows.device).unsqueeze(
0
) == rejected.unsqueeze(-1)
residual_logits = torch.where(one_hot, float("-inf"), target_rows)
else:
draft_steps = (num_sampled.to(torch.int64) - 1).clamp_max(
draft_logits.shape[1] - 1
)
draft_rows = draft_logits[req_state_indices, draft_steps][
:, :vocab_size
].float()
draft_rows = torch.where(draft_rows.isnan(), float("-inf"), draft_rows)
draft_rows = draft_rows / temperatures.unsqueeze(-1)
target_log_probs = torch.log_softmax(target_rows, dim=-1)
draft_log_probs = torch.log_softmax(draft_rows, dim=-1)
ratio = torch.exp(draft_log_probs - target_log_probs)
residual_logits = torch.where(
ratio < 1,
target_log_probs + torch.log1p(-ratio.clamp_max(1)),
float("-inf"),
)
recovery = torch.where(needs_residual.unsqueeze(-1), residual_logits, target_rows)
return recovery, resample_rows
def _watermark_inputs(
num_trials: int,
num_speculative_steps: int,
context_width: int,
vocab_size: int,
one_hot_draft: bool,
temperature: float = 1.0,
) -> tuple[dict, torch.Tensor]:
device = "cuda"
target_logits_1d = torch.randn(vocab_size, device=device) / temperature
draft_logits_1d = torch.randn(vocab_size, device=device)
inputs = _build_rejection_sample_inputs(
target_logits_1d,
draft_logits_1d,
num_speculative_steps,
temperature=temperature,
num_trials=num_trials,
)
if one_hot_draft:
inputs["draft_logits"] = None
inputs["draft_sampled"].view(num_trials, num_speculative_steps + 1)[::5, 1] = -1
num_logits = inputs["target_logits"].shape[0]
contexts = torch.randint(
0, vocab_size, (num_logits, context_width), dtype=torch.int64, device=device
)
contexts[::7, 0] = -1
return inputs, contexts
@pytest.mark.parametrize("one_hot_draft", [False, True])
# Cover one full block and a partial multi-block vocabulary.
@pytest.mark.parametrize("vocab_size", [1024, 3500])
def test_watermarked_recovery_matches_philox_gumbel_sample(
one_hot_draft: bool, vocab_size: int
):
torch.manual_seed(7)
key = 0xDA39A3EE5E6B4B0D
context_width = 4
inputs, contexts = _watermark_inputs(
num_trials=128,
num_speculative_steps=2,
context_width=context_width,
vocab_size=vocab_size,
one_hot_draft=one_hot_draft,
)
watermarker = GumbelWatermarker(key=key, context_width=context_width)
sampled, num_sampled = watermarked_rejection_sample(
**inputs,
num_speculative_steps=2,
contexts=contexts,
watermarking=torch.ones(128, dtype=torch.bool, device="cuda"),
watermarker=watermarker,
)
recovery, resample_rows = _residual_recovery_logits(inputs, num_sampled, vocab_size)
expected = philox_gumbel_sample(recovery, contexts[resample_rows], key)
recovered = sampled[torch.arange(128, device="cuda"), num_sampled.long() - 1]
torch.testing.assert_close(recovered, expected, rtol=0, atol=0)
is_bonus = resample_rows == inputs["cu_num_logits"][1:] - 1
next_rows = (resample_rows + 1).clamp_max(inputs["draft_sampled"].shape[0] - 1)
is_placeholder = ~is_bonus & (inputs["draft_sampled"][next_rows] < 0)
assert int((~is_bonus & ~is_placeholder).sum()) > 0
assert int(is_placeholder.sum()) > 0
if not one_hot_draft:
# One-hot drafts almost never reach the bonus row.
assert int(is_bonus.sum()) > 0
def test_watermarked_recovery_leaves_disabled_rows_on_the_stock_draw():
torch.manual_seed(11)
num_trials = 96
vocab_size = 512
inputs, contexts = _watermark_inputs(
num_trials=num_trials,
num_speculative_steps=3,
context_width=2,
vocab_size=vocab_size,
one_hot_draft=False,
)
watermarking = torch.zeros(num_trials, dtype=torch.bool, device="cuda")
watermarking[::2] = True
inputs["temperature"][:8] = 0.0
stock_sampled, stock_num_sampled = rejection_sample(
**inputs, num_speculative_steps=3
)
sampled, num_sampled = watermarked_rejection_sample(
**inputs,
num_speculative_steps=3,
contexts=contexts,
watermarking=watermarking,
watermarker=GumbelWatermarker(key=99, context_width=2),
)
torch.testing.assert_close(num_sampled, stock_num_sampled, rtol=0, atol=0)
rows = torch.arange(num_trials, device="cuda")
positions = num_sampled.long() - 1
disabled = ~watermarking | (inputs["temperature"] == 0)
torch.testing.assert_close(
sampled[rows[disabled], positions[disabled]],
stock_sampled[rows[disabled], positions[disabled]],
rtol=0,
atol=0,
)
for req in range(num_trials):
n = int(num_sampled[req]) - 1
torch.testing.assert_close(
sampled[req, :n], stock_sampled[req, :n], rtol=0, atol=0
)
assert int(disabled.sum()) > 0 and int((~disabled).sum()) > 0
def test_watermarked_recovery_is_unchanged_by_fp64_gumbel():
torch.manual_seed(13)
inputs, contexts = _watermark_inputs(
num_trials=64,
num_speculative_steps=2,
context_width=4,
vocab_size=2048,
one_hot_draft=False,
)
watermarker = GumbelWatermarker(key=2**63 + 12345, context_width=4)
kwargs = dict(
num_speculative_steps=2,
contexts=contexts,
watermarking=torch.ones(64, dtype=torch.bool, device="cuda"),
watermarker=watermarker,
)
sampled32, num_sampled32 = watermarked_rejection_sample(**inputs, **kwargs)
sampled64, num_sampled64 = watermarked_rejection_sample(
**inputs, **kwargs, use_fp64=True
)
rows = torch.arange(64, device="cuda")
torch.testing.assert_close(num_sampled64, num_sampled32, rtol=0, atol=0)
torch.testing.assert_close(
sampled64[rows, num_sampled64.long() - 1],
sampled32[rows, num_sampled32.long() - 1],
rtol=0,
atol=0,
)
+125 -10
View File
@@ -5,8 +5,15 @@ import pytest
import torch
from vllm.platforms import current_platform
from vllm.v1.watermarking import GumbelWatermarkDetector, GumbelWatermarker
from vllm.v1.watermarking import (
DualKeyGumbelWatermarkDetector,
GumbelWatermarkDetector,
GumbelWatermarker,
derive_watermark_key,
)
from vllm.v1.watermarking.gumbel import _gamma_survival_integer_shape
from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
def test_gamma_survival_integer_shape():
@@ -34,6 +41,67 @@ def test_detector_deduplicates_context_even_when_target_differs():
assert detection.num_scored_tokens == 3
def test_dual_key_detector_scores_each_token_against_both_keys():
token_ids = [1, 2, 3, 4, 5]
key_a_detector = GumbelWatermarkDetector(
key=derive_watermark_key(42, b"key_a"),
context_width=1,
deduplicate_contexts=False,
)
key_b_detector = GumbelWatermarkDetector(
key=derive_watermark_key(42, b"key_b"),
context_width=1,
deduplicate_contexts=False,
)
detector = DualKeyGumbelWatermarkDetector(
key=42,
context_width=1,
deduplicate_contexts=False,
alpha=0.5,
)
key_a = key_a_detector.detect(token_ids)
key_b = key_b_detector.detect(token_ids)
dual = detector.detect(token_ids)
assert dual.score == pytest.approx((key_a.score + key_b.score) / 2)
assert dual.p_value == pytest.approx(
_gamma_survival_integer_shape(dual.score * 2, dual.num_scored_tokens * 2)
)
@pytest.mark.parametrize("alpha", [0.2, 0.7])
def test_dual_key_detector_weights_and_recalibrates_scores(alpha: float):
token_ids = [1, 2, 3, 4, 5]
key_a = GumbelWatermarkDetector(
key=derive_watermark_key(42, b"key_a"), deduplicate_contexts=False
).detect(token_ids)
key_b = GumbelWatermarkDetector(
key=derive_watermark_key(42, b"key_b"), deduplicate_contexts=False
).detect(token_ids)
dual = DualKeyGumbelWatermarkDetector(
key=42, deduplicate_contexts=False, alpha=alpha
).detect(token_ids)
variance = (1 - alpha) ** 2 + alpha**2
expected_p_value = torch.special.gammaincc(
torch.tensor(dual.num_scored_tokens / variance, dtype=torch.float64),
torch.tensor(dual.score / variance, dtype=torch.float64),
).item()
assert dual.score == pytest.approx((1 - alpha) * key_a.score + alpha * key_b.score)
assert dual.p_value == pytest.approx(expected_p_value)
def test_dual_key_detector_rejects_invalid_alpha():
with pytest.raises(ValueError, match="alpha must be between 0 and 1"):
DualKeyGumbelWatermarkDetector(key=42, alpha=1.1)
def test_dual_key_detector_default_alpha():
assert DualKeyGumbelWatermarkDetector(key=42).alpha == 0.2
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@@ -45,10 +113,8 @@ def test_fused_watermarker_matches_cpu(key: int, context_width: int):
logits = torch.randn(32, 8193)
watermarker = GumbelWatermarker(key, context_width)
expected = watermarker.sample(logits, contexts, lambda values: None).token_ids
actual = watermarker.sample(
logits.cuda(), contexts.cuda(), lambda values: None
).token_ids.cpu()
expected = watermarker.sample(logits, contexts).token_ids
actual = watermarker.sample(logits.cuda(), contexts.cuda()).token_ids.cpu()
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
@@ -61,7 +127,7 @@ def test_fused_watermarker_handles_nan_logits():
logits = torch.full((2, 1025), float("nan"), device="cuda")
watermarker = GumbelWatermarker(key=42, context_width=4)
token_ids = watermarker.sample(logits, contexts, lambda values: None).token_ids
token_ids = watermarker.sample(logits, contexts).token_ids
assert torch.all((token_ids >= 0) & (token_ids < logits.shape[-1]))
@@ -76,9 +142,58 @@ def test_fused_watermarker_handles_noncontiguous_inputs():
logits = torch.randn(8, 2050, device="cuda")[:, ::2]
watermarker = GumbelWatermarker(key=42, context_width=4)
expected = watermarker.sample(
logits.contiguous(), contexts.contiguous(), lambda values: None
).token_ids
actual = watermarker.sample(logits, contexts, lambda values: None).token_ids
expected = watermarker.sample(logits.contiguous(), contexts.contiguous()).token_ids
actual = watermarker.sample(logits, contexts).token_ids
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
@pytest.mark.skipif(
not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
)
@pytest.mark.parametrize("use_fp64", [False, True])
def test_philox_gumbel_sample_skip_mask_matches_separate_samplers(use_fp64: bool):
torch.manual_seed(0)
logits = torch.randn(8, 8193, device="cuda")
context_storage = torch.randint(0, 248320, (8, 8), dtype=torch.int64, device="cuda")
contexts = context_storage[:, ::2]
repeated_mask = torch.tensor(
[False, False, False, True, False, False, True, False], device="cuda"
)
watermarking = torch.tensor(
[True, False, True, True, False, True, True, False], device="cuda"
)
req_indices = torch.tensor([3, 1, 7, 0, 4, 2, 6, 5], device="cuda")
temperatures = torch.ones(8, dtype=torch.float32, device="cuda")
temperatures[2] = 0
seeds = torch.arange(8, dtype=torch.int64, device="cuda") + 1000
positions = torch.arange(8, dtype=torch.int64, device="cuda") + 100
watermark_mask = (
watermarking[req_indices] & (temperatures[req_indices] != 0) & ~repeated_mask
)
watermarked = philox_gumbel_sample(logits, contexts, 42)
ordinary = gumbel_sample(
logits,
req_indices,
temperatures,
seeds,
positions,
apply_temperature=False,
is_drafting=False,
use_fp64=use_fp64,
)
expected = torch.where(watermark_mask, watermarked, ordinary)
actual = philox_gumbel_sample(
logits,
contexts,
42,
skip_mask=~watermark_mask,
expanded_idx_mapping=req_indices,
temperatures=temperatures,
seeds=seeds,
positions=positions,
use_fp64=use_fp64,
)
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
+889 -18
View File
@@ -9,44 +9,98 @@ import torch
from vllm import SamplingParams
from vllm.config.watermarking import WatermarkConfig
from vllm.v1.watermarking import create_watermarker
from vllm.platforms import current_platform
from vllm.v1.watermarking import (
DualKeyGumbelWatermarker,
SupportsSpeculativeDecoding,
create_watermarker,
derive_watermark_key,
)
from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
from vllm.v1.watermarking.watermarker import WatermarkSample
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.sample.sampler import Sampler
from vllm.v1.worker.gpu.sample.watermark import (
philox_gumbel_sample,
repeated_context_mask,
)
from vllm.v1.worker.gpu.spec_decode.dspark.speculator import DSparkSpeculator
from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
@pytest.mark.parametrize("algorithm", ["gumbel"])
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.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]])
random_sample = lambda sample_logits: sample_logits.argmax(dim=-1)
first = watermarker.sample(logits, contexts, random_sample)
second = watermarker.sample(logits, contexts, random_sample)
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)
def test_gumbel_config_warns_about_degenerate_generations(monkeypatch):
@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)
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 == [
"Single-key Gumbel-max watermarking may increase the frequency of "
"degenerate generations, including repetition loops."
(
"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)
@@ -56,6 +110,158 @@ def test_large_context_width_warns_but_is_allowed():
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,
)
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")
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
assert not SamplingParams.from_optional(watermarking=False).watermarking
@@ -83,14 +289,9 @@ def test_gpu_sampler_warns_when_watermarking_is_enabled_for_greedy(monkeypatch):
def test_gpu_sampler_respects_mixed_request_watermarking(monkeypatch):
class StubWatermarker:
context_width = 1
def sample(self, logits, contexts, random_sample):
return WatermarkSample(torch.tensor([7, 7]), logits + 10)
sampler = object.__new__(GPUWatermarkSampler)
sampler.watermarker = StubWatermarker()
sampler.deduplicate_contexts = "single_turn"
sampler.watermarking = SimpleNamespace(
np=np.array([True, False]), gpu=torch.tensor([True, False])
)
@@ -102,8 +303,11 @@ def test_gpu_sampler_respects_mixed_request_watermarking(monkeypatch):
sampler._get_contexts = lambda expanded_idx_mapping: torch.zeros(
2, 1, dtype=torch.int64
)
sampler._get_repeated_contexts = lambda expanded_idx_mapping, contexts: torch.zeros(
2, dtype=torch.bool
)
monkeypatch.setattr(
"vllm.v1.watermarking.gpu_sampler.gumbel_sample",
"vllm.v1.watermarking.watermarker.gumbel_sample",
lambda *args, **kwargs: torch.tensor([3, 4]),
)
logits = torch.zeros(2, 8)
@@ -123,11 +327,524 @@ def test_gpu_sampler_respects_mixed_request_watermarking(monkeypatch):
assert torch.equal(output_logits[1], logits[1])
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,
)
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,
)
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,
)
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_sample):
def sample(self, logits, contexts, random_sampler=None, skip_mask=None):
raise AssertionError("watermarker should not run for greedy requests")
sampler = object.__new__(GPUWatermarkSampler)
@@ -155,3 +872,157 @@ def test_gpu_sampler_skips_watermarking_for_greedy_batch(monkeypatch):
)
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_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]]),
)
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:
@staticmethod
def sample(logits, contexts, random_sampler=None):
assert random_sampler is None
return WatermarkSample(torch.tensor([7, 7]), logits)
speculator = object.__new__(StubSpeculator)
speculator.model = StubModel()
speculator.use_fp64_gumbel = False
draft_watermarker = object.__new__(DraftWatermarker)
draft_watermarker.watermarker = StubWatermarker()
draft_watermarker.contexts = torch.tensor([[1, 2], [3, 4]])
draft_watermarker.enabled = torch.tensor([True, False])
speculator.draft_watermarker = draft_watermarker
monkeypatch.setattr(
"vllm.v1.worker.gpu.spec_decode.speculator.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.contexts, torch.tensor([[2, 7], [4, 4]]))
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
watermark_logits: list[torch.Tensor] = []
def sample(logits, sampled, idx_map, temperature):
watermark_logits.append(logits.clone())
return sampled + 1
speculator.draft_watermarker = SimpleNamespace(sample=sample)
monkeypatch.setattr(
"vllm.v1.worker.gpu.spec_decode.dspark.speculator.gumbel_sample",
lambda *args, **kwargs: torch.tensor([3, 4]),
)
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.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]))
+62 -9
View File
@@ -50,7 +50,7 @@ from .reasoning import ReasoningConfig
from .scheduler import SchedulerConfig
from .speculative import EagleModelTypes, NgramGPUTypes, SpeculativeConfig
from .structured_outputs import StructuredOutputsConfig
from .utils import SupportsHash, config, replace
from .utils import SupportsHash, config, get_field, replace
from .watermarking import WatermarkConfig
from .weight_transfer import WeightTransferConfig
@@ -1142,14 +1142,67 @@ class VllmConfig:
watermark_config = getattr(self, "watermark_config", None)
if watermark_config is None:
return
if (
self.speculative_config is not None
and not watermark_config.supports_speculative_decoding
):
raise ValueError(
f"The {watermark_config.algorithm} watermarking algorithm "
"does not support speculative decoding."
)
if self.speculative_config is not None:
speculative_config = self.speculative_config
if speculative_config.draft_sample_method != "probabilistic":
raise ValueError(
"Speculative decoding with watermarking requires "
"draft_sample_method='probabilistic'."
)
if speculative_config.rejection_sample_method != "standard":
raise ValueError(
"Speculative decoding with watermarking requires "
"rejection_sample_method='standard'."
)
if (
speculative_config.parallel_drafting
and speculative_config.method != "dspark"
):
raise ValueError(
"Parallel speculative drafting is not supported with watermarking."
)
if speculative_config.method not in ("dspark", "eagle", "eagle3", "mtp"):
raise ValueError(
"Watermarking supports only autoregressive model-based "
"speculative decoding."
)
if (
not watermark_config.allow_target_only_watermarking
and not watermark_config.supports_speculative_decoding
):
raise ValueError(
f"The '{watermark_config.algorithm}' watermarking algorithm "
"does not support speculative decoding. Set "
"allow_target_only_watermarking=true to leave draft tokens "
"unwatermarked."
)
if (
watermark_config.allow_target_only_watermarking
and not watermark_config.supports_speculative_decoding
):
logger.warning_once(
"Target-only watermarking leaves accepted draft tokens "
"unwatermarked, weakening detectability in proportion to the "
"share of output tokens supplied by accepted drafts.",
scope="global",
)
if watermark_config.deduplicate_contexts != "none":
logger.warning_once(
"Context deduplication is not supported with speculative "
"decoding and will not be applied to accepted drafts, "
"rejection-recovery tokens, or bonus tokens.",
scope="global",
)
if watermark_config.algorithm == "dual_key_gumbel" and (
watermark_config.alpha != get_field(WatermarkConfig, "alpha").default
):
logger.warning_once(
"Speculative decoding selects the watermark key by token role: "
"draft tokens use key A, recovery and bonus tokens use key B. "
"The configured alpha=%s is not used.",
watermark_config.alpha,
scope="global",
)
if beam_search:
raise ValueError("Beam search is not supported with watermarking.")
if custom_sampler:
+49 -18
View File
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import hashlib
from typing import Literal
from pydantic import Field, model_validator
@@ -11,12 +12,16 @@ from vllm.logger import init_logger
logger = init_logger(__name__)
WatermarkingAlgorithm = Literal["gumbel"]
WatermarkingAlgorithm = Literal["gumbel", "dual_key_gumbel"]
WatermarkPRFName = Literal["philox"]
WatermarkContextScope = Literal["none", "single_turn", "all"]
_SPECULATIVE_DECODING_SUPPORT: dict[WatermarkingAlgorithm, bool] = {
"gumbel": False,
}
_MIN_RECOMMENDED_DEDUP_HISTORY = 1024
def derive_watermark_key(key: int, domain: bytes) -> int:
digest = hashlib.sha256(domain + key.to_bytes(8, "big")).digest()
return int.from_bytes(digest[:8], "big")
@config
@@ -27,23 +32,49 @@ class WatermarkConfig:
"""Secret key used to watermark generated text."""
algorithm: WatermarkingAlgorithm = "gumbel"
"""Algorithm used to watermark generated text."""
alpha: float = Field(default=0.1, ge=0, le=1)
"""Probability of selecting key B for dual-key watermarking."""
context_width: int = Field(default=4, ge=1)
"""Number of prior output tokens used by the watermark PRF."""
"""Number of prior tokens used by the watermark PRF."""
deduplicate_contexts: WatermarkContextScope = "single_turn"
"""Which history is searched for a repeated context before a token is
sampled; a repeated context is sampled without the watermark. `none`
disables the search. `single_turn` (default) searches this request's
generated tokens. `all` also searches the prompt and samples the first
`context_width` generated tokens without watermarking."""
deduplicate_contexts_max_history: int | None = Field(default=8192, ge=1)
"""Number of most recent history positions searched (default 8192), or
`None` for the whole scope. Each position is compared over the
`context_width` tokens before it. Ignored when `deduplicate_contexts` is
`none`."""
prf: WatermarkPRFName = "philox"
"""Pseudorandom function used by the watermarking algorithm."""
@model_validator(mode="after")
def validate_key(self) -> Self:
if self.key > 2**64 - 1:
raise ValueError("philox keys must fit in 64 bits")
if self.algorithm == "gumbel":
logger.warning_once(
"Single-key Gumbel-max watermarking may increase the frequency of "
"degenerate generations, including repetition loops.",
scope="global",
)
return self
allow_target_only_watermarking: bool = False
"""Allow speculative decoding without watermarking draft tokens."""
@property
def supports_speculative_decoding(self) -> bool:
return _SPECULATIVE_DECODING_SUPPORT[self.algorithm]
return self.algorithm == "dual_key_gumbel"
@model_validator(mode="after")
def validate_watermark_settings(self) -> Self:
if self.key > 2**64 - 1:
raise ValueError("philox keys must fit in 64 bits")
history_is_too_short = (
self.deduplicate_contexts_max_history is not None
and self.deduplicate_contexts_max_history < _MIN_RECOMMENDED_DEDUP_HISTORY
)
if self.algorithm in ("gumbel", "dual_key_gumbel") and (
self.deduplicate_contexts == "none" or history_is_too_short
):
logger.warning_once(
"Gumbel-max watermarking with context deduplication "
"disabled or limited to fewer than "
f"{_MIN_RECOMMENDED_DEDUP_HISTORY} 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 "
f"{_MIN_RECOMMENDED_DEDUP_HISTORY} or null to mitigate this.",
scope="global",
)
return self
@@ -44,6 +44,9 @@ from vllm.model_executor.warmup.replayssm_warmup import (
from vllm.model_executor.warmup.spec_decode_rejection_warmup import (
spec_decode_rejection_warmup,
)
from vllm.model_executor.warmup.watermark_sample_warmup import (
watermark_sample_warmup,
)
from vllm.platforms import current_platform
from vllm.utils.deep_gemm import is_deep_gemm_supported
from vllm.utils.flashinfer import has_flashinfer
@@ -207,6 +210,7 @@ def kernel_warmup(worker: "Worker", *, process_local_only: bool = False):
if enable_jit_warmup:
kimi_k3_triton_warmup(worker)
spec_decode_rejection_warmup(worker)
watermark_sample_warmup(worker)
qwen4_exp_qsa_triton_warmup(worker)
if enable_jit_warmup and current_platform.is_device_capability_family(100):
@@ -1,17 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Warm up spec-decode rejection-sampler Triton kernels.
The rejection sampler kernels (``_compute_local_logits_stats_kernel``,
``_rejection_kernel``, ``_resample_kernel``) are JIT-compiled by Triton on
first use. Without warmup, the first spec-decode request pays a multi-second
compilation cost. This pre-compiles them with dummy data matching the
server's vocab size and speculative config.
"""
"""Warm up speculative-decoding rejection kernels."""
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import torch
@@ -39,42 +32,76 @@ def spec_decode_rejection_warmup(worker: Worker) -> None:
if num_spec <= 0 or vocab_size <= 0:
return
# Mirror the constexpr-relevant flags the runtime uses.
rejection_method = getattr(spec_config, "rejection_sample_method", None)
use_block_verification = rejection_method == "block"
use_synthetic = rejection_method == "synthetic"
# HAS_DRAFT_LOGITS is a constexpr, so the warmup must match the runtime.
use_draft_logits = (
getattr(spec_config, "draft_sample_method", "greedy") == "probabilistic"
)
device = worker.device
num_reqs = 1
tokens_per_req = num_spec + 1
num_logits = num_reqs * tokens_per_req
# Triton JIT-specializes on tensor dtypes. The target logits may be fp32
# (apply_sampling_params copies to fp32 when processing is needed) or the
# model dtype (pass-through otherwise), while draft logits are always the
# model dtype. Warm every (target, draft) combination the runtime can hit.
watermark_config = getattr(worker.vllm_config, "watermark_config", None)
watermark_kwargs: dict[str, Any] = {}
if watermark_config is not None:
from vllm.v1.watermarking.spec_decode import speculative_target_watermark_key
try:
watermark_key = speculative_target_watermark_key(watermark_config)
except Exception:
logger.warning(
"Skipping spec-decode rejection sampler warmup: could not derive "
"the watermark recovery key.",
exc_info=True,
)
return
# Tensor dtypes and integer widths affect Triton specialization.
watermark_kwargs = {
"contexts": torch.zeros(
(num_logits, watermark_config.context_width),
dtype=torch.int32,
device=device,
),
"watermarking": torch.zeros(num_reqs, dtype=torch.bool, device=device),
"watermark_key": watermark_key,
}
# Sampling-parameter processing may promote either distribution to fp32.
model_dtype = model_config.dtype
warmup_dtype_pairs = {
(model_dtype, model_dtype),
(torch.float32, torch.float32),
(torch.float32, model_dtype),
(model_dtype, torch.float32),
}
warmup_dtype_pairs: set[tuple[torch.dtype, torch.dtype | None]]
if use_draft_logits:
warmup_dtype_pairs = {
(model_dtype, model_dtype),
(torch.float32, torch.float32),
(torch.float32, model_dtype),
(model_dtype, torch.float32),
}
else:
warmup_dtype_pairs = {(model_dtype, None), (torch.float32, None)}
logger.info(
"Warming up spec-decode rejection sampler kernels "
"(vocab=%d, num_spec=%d, dtype_pairs=%s, block_verify=%s).",
"(vocab=%d, num_spec=%d, dtype_pairs=%s, block_verify=%s, watermark=%s).",
vocab_size,
num_spec,
[(str(t), str(d)) for t, d in warmup_dtype_pairs],
use_block_verification,
watermark_config is not None,
)
for tgt_dtype, draft_dtype in warmup_dtype_pairs:
target_logits = torch.zeros(
(num_logits, vocab_size), dtype=tgt_dtype, device=device
)
draft_logits = torch.zeros(
(num_reqs, num_spec, vocab_size), dtype=draft_dtype, device=device
draft_logits = (
torch.zeros(
(num_reqs, num_spec, vocab_size), dtype=draft_dtype, device=device
)
if draft_dtype is not None
else None
)
synthetic_rates = (
torch.full((num_spec,), 0.5, dtype=torch.float32, device=device)
@@ -85,14 +112,14 @@ def spec_decode_rejection_warmup(worker: Worker) -> None:
rejection_sample(
target_logits=target_logits,
draft_logits=draft_logits,
draft_sampled=torch.zeros(num_logits, dtype=torch.int64, device=device),
draft_sampled=torch.zeros(num_logits, dtype=torch.int32, device=device),
cu_num_logits=torch.tensor(
[0, num_logits], dtype=torch.int32, device=device
),
pos=torch.zeros(num_logits, dtype=torch.int64, device=device),
idx_mapping=torch.zeros(num_reqs, dtype=torch.int32, device=device),
idx_mapping=torch.zeros(num_reqs, dtype=torch.int64, device=device),
expanded_idx_mapping=torch.zeros(
num_logits, dtype=torch.int32, device=device
num_logits, dtype=torch.int64, device=device
),
expanded_local_pos=torch.arange(
num_logits, dtype=torch.int32, device=device
@@ -103,6 +130,7 @@ def spec_decode_rejection_warmup(worker: Worker) -> None:
synthetic_conditional_rates=synthetic_rates,
use_fp64=False,
use_block_verification=use_block_verification,
**watermark_kwargs,
)
except Exception:
logger.warning(
@@ -0,0 +1,155 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Warm up the watermarked sampler's Triton kernel.
``_philox_gumbel_kernel`` is JIT-compiled once per (key, logits dtype,
skip-mask, ``USE_FP64``) specialization. The generic sampler warmup samples with
``SamplingParams.for_sampler_warmup()``, whose feature-heavy logits processing
forces the fp32 copy in ``apply_sampling_params``, so the first ordinary
temperature-1.0 request is the first launch with model-dtype logits and pays
the compilation inside inference. This pre-compiles every specialization the
sampler path can launch for the configured watermark.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
import torch
from vllm.logger import init_logger
from vllm.platforms import current_platform
if TYPE_CHECKING:
from vllm.v1.watermarking.watermarker import Watermarker
from vllm.v1.worker.gpu_worker import Worker
logger = init_logger(__name__)
_NUM_WARMUP_TOKENS = 1
def _philox_key(watermarker: Watermarker) -> int | None:
"""None when the watermarker takes the torch path and compiles no kernel."""
from vllm.v1.watermarking.gumbel import GumbelWatermarker
from vllm.v1.watermarking.prfs import PhiloxPRF
if not isinstance(watermarker, GumbelWatermarker):
return None
prf = watermarker.prf
return prf.key if type(prf) is PhiloxPRF else None
def _philox_sampler_keys(watermarker: Watermarker) -> list[int]:
"""Philox keys the sampler launches the kernel with."""
from vllm.v1.watermarking.gumbel import DualKeyGumbelWatermarker
watermarkers: list[Watermarker] = [watermarker]
if isinstance(watermarker, DualKeyGumbelWatermarker):
# ``DualKeyGumbelWatermarker.sample`` launches once per key, and
# collapses to a single key at the alpha bounds, where the other key's
# specialization is never launched.
if watermarker.alpha == 0:
watermarkers = [watermarker.draft_watermarker]
elif watermarker.alpha == 1:
watermarkers = [watermarker.target_watermarker]
else:
watermarkers = [
watermarker.draft_watermarker,
watermarker.target_watermarker,
]
return [key for key in map(_philox_key, watermarkers) if key is not None]
@torch.inference_mode()
def watermark_sample_warmup(worker: Worker) -> None:
if getattr(worker.vllm_config, "watermark_config", None) is None:
return
# ``GumbelWatermarker`` launches the kernel only on CUDA
# (vllm/v1/watermarking/gumbel.py:63,:82); elsewhere the sampler takes the
# torch path and warming here would compile a kernel nothing launches.
if not current_platform.is_cuda_alike():
return
from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
# Read the sampler the runner built instead of rebuilding it from config:
# its watermarker (the target role under a speculative config), its context
# deduplication setting and its fp64 flag are what the launches have to
# match, and cannot drift from the runner. A rank that does not sample -- a
# non-final pipeline stage, a pooling model -- holds no such sampler.
sampler = getattr(worker.model_runner, "sampler", None)
if not isinstance(sampler, GPUWatermarkSampler):
return
model_config = worker.vllm_config.model_config
device = worker.device
try:
keys = _philox_sampler_keys(sampler.watermarker)
if not keys:
return
# The sampler passes model-dtype logits through when no logits
# processing is needed and fp32 otherwise. ``ModelConfig.dtype`` is
# resolved to a torch dtype during validation.
dtypes = {cast("torch.dtype", model_config.dtype), torch.float32}
# ``GPUWatermarkSampler._sample_random`` builds a skip mask whenever
# context deduplication is on; with it off only a batch that mixes in an
# opted-out or greedy row does, and the mask-free specialization stays
# reachable.
with_skip_mask = (
(True,) if sampler.deduplicate_contexts != "none" else (True, False)
)
vocab_size = model_config.get_vocab_size()
contexts = torch.zeros(
(_NUM_WARMUP_TOKENS, sampler.watermarker.context_width),
dtype=torch.int32,
device=device,
)
# Dtypes mirror the runtime buffers the sampler passes and are part of
# the specialization key: int32 request-state token ids, int64 index and
# seed buffers, fp32 sampling temperatures.
sampling_state = {
"skip_mask": torch.zeros(
_NUM_WARMUP_TOKENS, dtype=torch.bool, device=device
),
"expanded_idx_mapping": torch.zeros(
_NUM_WARMUP_TOKENS, dtype=torch.int64, device=device
),
"temperatures": torch.ones(
_NUM_WARMUP_TOKENS, dtype=torch.float32, device=device
),
"seeds": torch.zeros(_NUM_WARMUP_TOKENS, dtype=torch.int64, device=device),
"positions": torch.zeros(
_NUM_WARMUP_TOKENS, dtype=torch.int64, device=device
),
}
logger.info(
"Warming up watermark sampler kernel (vocab=%d, keys=%d, dtypes=%s, "
"skip_mask=%s).",
vocab_size,
len(keys),
[str(dtype) for dtype in dtypes],
list(with_skip_mask),
)
for dtype in dtypes:
logits = torch.zeros(
(_NUM_WARMUP_TOKENS, vocab_size), dtype=dtype, device=device
)
for key in keys:
for use_skip_mask in with_skip_mask:
philox_gumbel_sample(
logits,
contexts,
key,
# Only the mixed path forwards the sampler's setting;
# ``GumbelWatermarker._sample_watermarked`` launches
# the mask-free variant with the fp32 default.
use_fp64=use_skip_mask and sampler.use_fp64_gumbel,
**(sampling_state if use_skip_mask else {}),
)
except Exception:
logger.warning("Skipping watermark sampler warmup.", exc_info=True)
+12 -1
View File
@@ -1,9 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.config.watermarking import derive_watermark_key
from vllm.v1.watermarking.detector import WatermarkDetection, WatermarkDetector
from vllm.v1.watermarking.factory import create_watermarker
from vllm.v1.watermarking.gumbel import (
DualKeyGumbelWatermarkDetector,
DualKeyGumbelWatermarker,
GumbelWatermarkDetector,
GumbelWatermarker,
)
@@ -12,12 +15,19 @@ from vllm.v1.watermarking.prfs import (
WatermarkPRF,
create_prf,
)
from vllm.v1.watermarking.watermarker import Watermarker, WatermarkSample
from vllm.v1.watermarking.watermarker import (
SupportsSpeculativeDecoding,
Watermarker,
WatermarkSample,
)
__all__ = [
"DualKeyGumbelWatermarkDetector",
"DualKeyGumbelWatermarker",
"GumbelWatermarkDetector",
"GumbelWatermarker",
"PhiloxPRF",
"SupportsSpeculativeDecoding",
"WatermarkDetection",
"WatermarkDetector",
"WatermarkPRF",
@@ -25,4 +35,5 @@ __all__ = [
"WatermarkSample",
"create_watermarker",
"create_prf",
"derive_watermark_key",
]
+14 -16
View File
@@ -1,24 +1,22 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Callable
from vllm.config.watermarking import WatermarkConfig
from vllm.v1.watermarking.gumbel import GumbelWatermarker
from vllm.v1.watermarking.gumbel import (
DualKeyGumbelWatermarker,
GumbelWatermarker,
)
from vllm.v1.watermarking.watermarker import Watermarker
def _create_gumbel(config: WatermarkConfig) -> Watermarker:
return GumbelWatermarker(config.key, config.context_width, config.prf)
_WATERMARKERS: dict[str, Callable[[WatermarkConfig], Watermarker]] = {
"gumbel": _create_gumbel,
}
def create_watermarker(config: WatermarkConfig) -> Watermarker:
watermarker_factory = _WATERMARKERS.get(config.algorithm)
if watermarker_factory is None:
raise ValueError(f"Unknown watermarking algorithm: {config.algorithm}")
return watermarker_factory(config)
if config.algorithm == "gumbel":
return GumbelWatermarker(config.key, config.context_width, config.prf)
if config.algorithm == "dual_key_gumbel":
return DualKeyGumbelWatermarker(
config.key,
config.context_width,
config.prf,
config.alpha,
)
raise ValueError(f"Unknown watermarking algorithm: {config.algorithm}")
+98 -35
View File
@@ -1,16 +1,18 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from typing import Literal
import numpy as np
import torch
from vllm.logger import init_logger
from vllm.sampling_params import SamplingParams
from vllm.v1.sample.ops.topk_topp_sampler import apply_top_k_top_p
from vllm.v1.watermarking.watermarker import Watermarker
from vllm.v1.watermarking.watermarker import RandomSampler, Watermarker
from vllm.v1.worker.gpu.buffer_utils import UvaBackedTensor
from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
from vllm.v1.worker.gpu.sample.sampler import Sampler
from vllm.v1.worker.gpu.sample.watermark import repeated_context_mask
logger = init_logger(__name__)
@@ -20,10 +22,14 @@ class GPUWatermarkSampler(Sampler):
self,
watermarker: Watermarker,
*args,
deduplicate_contexts: Literal["none", "single_turn", "all"] = "single_turn",
deduplicate_contexts_max_history: int | None = 8192,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.watermarker = watermarker
self.deduplicate_contexts = deduplicate_contexts
self.deduplicate_contexts_max_history = deduplicate_contexts_max_history
self.watermarking = UvaBackedTensor(
self.sampling_states.max_num_reqs, dtype=torch.bool
)
@@ -72,37 +78,38 @@ class GPUWatermarkSampler(Sampler):
processed_logits = apply_top_k_top_p(processed_logits, top_k, top_p)
contexts = self._get_contexts(expanded_idx_mapping)
def random_sample(sample_logits: torch.Tensor) -> torch.Tensor:
return gumbel_sample(
sample_logits,
expanded_idx_mapping,
self.sampling_states.temperature.gpu,
self.sampling_states.seeds.gpu,
pos,
apply_temperature=False,
is_drafting=False,
use_fp64=self.use_fp64_gumbel,
repeated_contexts = None
if self.deduplicate_contexts != "none":
repeated_contexts = self._get_repeated_contexts(
expanded_idx_mapping, contexts
)
temperatures = self.sampling_states.temperature.gpu[expanded_idx_mapping]
needs_mixed_sampling = repeated_contexts is not None or not np.all(enabled)
skip_mask = None
if needs_mixed_sampling:
watermarking = self.watermarking.gpu[expanded_idx_mapping] & (
temperatures != 0
)
if repeated_contexts is not None:
watermarking &= ~repeated_contexts
skip_mask = ~watermarking
random_sampler = RandomSampler(
expanded_idx_mapping=expanded_idx_mapping,
temperatures=self.sampling_states.temperature.gpu,
seeds=self.sampling_states.seeds.gpu,
positions=pos,
use_fp64=self.use_fp64_gumbel,
)
output = self.watermarker.sample(
processed_logits,
contexts,
random_sample,
random_sampler,
skip_mask=skip_mask,
)
temperatures = self.sampling_states.temperature.gpu[expanded_idx_mapping]
watermarking = self.watermarking.gpu[expanded_idx_mapping] & (temperatures != 0)
if not np.all(enabled):
unwatermarked = random_sample(processed_logits)
sampled = torch.where(watermarking, output.token_ids, unwatermarked)
output_logits = output.logits
if output.logits is not processed_logits:
output_logits = torch.where(
watermarking.unsqueeze(-1), output.logits, processed_logits
)
else:
sampled = output.token_ids
output_logits = output.logits
sampled = output.token_ids
output_logits = output.logits
sampled = torch.where(
temperatures == 0,
processed_logits.argmax(dim=-1),
@@ -110,22 +117,78 @@ class GPUWatermarkSampler(Sampler):
)
return sampled, output_logits
def _get_contexts(self, expanded_idx_mapping: torch.Tensor) -> torch.Tensor:
def _get_repeated_contexts(
self,
expanded_idx_mapping: torch.Tensor,
contexts: torch.Tensor,
) -> torch.Tensor:
return repeated_context_mask(
self.req_states.all_token_ids.gpu,
expanded_idx_mapping,
self.req_states.prompt_len.gpu,
self.req_states.total_len.gpu,
contexts,
self.deduplicate_contexts_max_history,
include_prompt=self.deduplicate_contexts == "all",
skip_partial_context=self.deduplicate_contexts == "all",
)
def _get_contexts(
self,
expanded_idx_mapping: torch.Tensor,
expanded_local_pos: torch.Tensor | None = None,
draft_sampled: torch.Tensor | None = None,
) -> torch.Tensor:
"""Build contexts for flattened verification rows.
With committed tokens ``[10, 11]``, drafts ``[20, 21]``, and width 2,
local positions ``[0, 1, 2]`` use contexts ``[10, 11]``, ``[11, 20]``,
and ``[20, 21]``. The first verification row precedes the first draft.
"""
context_width = self.watermarker.context_width
req_indices = expanded_idx_mapping.to(torch.int64)
valid_reqs = req_indices >= 0
safe_req_indices = req_indices.clamp_min(0)
total_lens = self.req_states.total_len.gpu[safe_req_indices].to(torch.int64)
prompt_lens = self.req_states.prompt_len.gpu[safe_req_indices].to(torch.int64)
if expanded_local_pos is None:
expanded_local_pos = torch.zeros_like(req_indices)
else:
expanded_local_pos = expanded_local_pos.to(torch.int64)
offsets = torch.arange(
-context_width, 0, dtype=torch.int64, device=req_indices.device
)
positions = total_lens.unsqueeze(-1) + offsets
valid_positions = valid_reqs.unsqueeze(-1) & (
positions >= prompt_lens.unsqueeze(-1)
positions = (
total_lens.unsqueeze(-1) + expanded_local_pos.unsqueeze(-1) + offsets
)
positions = positions.clamp_min(0)
contexts = self.req_states.all_token_ids.gpu[
safe_req_indices.unsqueeze(-1), positions
committed = positions < total_lens.unsqueeze(-1)
valid_committed = (
valid_reqs.unsqueeze(-1)
& committed
& (positions >= prompt_lens.unsqueeze(-1))
)
committed_contexts = self.req_states.all_token_ids.gpu[
safe_req_indices.unsqueeze(-1),
positions.clamp(0, self.req_states.all_token_ids.gpu.shape[1] - 1),
]
return torch.where(valid_positions, contexts, -1)
contexts = torch.where(valid_committed, committed_contexts, -1)
if draft_sampled is None:
return contexts
row_indices = torch.arange(
len(req_indices), dtype=torch.int64, device=req_indices.device
)
draft_offsets = positions - total_lens.unsqueeze(-1)
draft_indices = (
row_indices.unsqueeze(-1)
- expanded_local_pos.unsqueeze(-1)
+ draft_offsets
+ 1
)
valid_drafts = (
valid_reqs.unsqueeze(-1)
& ~committed
& (draft_offsets < expanded_local_pos.unsqueeze(-1))
)
draft_contexts = draft_sampled[draft_indices.clamp(0, len(draft_sampled) - 1)]
return torch.where(valid_drafts, draft_contexts, contexts)
+162 -3
View File
@@ -8,13 +8,14 @@ import warnings
import torch
from vllm.config.watermarking import WatermarkPRFName
from vllm.config.watermarking import WatermarkPRFName, derive_watermark_key
from vllm.v1.watermarking.detector import (
WatermarkDetector,
)
from vllm.v1.watermarking.prfs import PhiloxPRF, WatermarkPRF, create_prf
from vllm.v1.watermarking.watermarker import (
RandomSampler,
SupportsSpeculativeDecoding,
Watermarker,
WatermarkSample,
)
@@ -54,11 +55,10 @@ class GumbelWatermarker(Watermarker):
def context_width(self) -> int:
return self._context_width
def sample(
def _sample_watermarked(
self,
logits: torch.Tensor,
contexts: torch.Tensor,
_random_sample: RandomSampler,
) -> WatermarkSample:
if type(self.prf) is PhiloxPRF and logits.device.type == "cuda":
from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
@@ -72,6 +72,110 @@ class GumbelWatermarker(Watermarker):
noise = -torch.log(-torch.log(uniforms))
return WatermarkSample(torch.argmax(logits + noise, dim=-1), logits)
def _try_sample_mixed(
self,
logits: torch.Tensor,
contexts: torch.Tensor,
skip_mask: torch.Tensor,
random_sampler: RandomSampler,
) -> WatermarkSample | None:
if type(self.prf) is not PhiloxPRF or logits.device.type != "cuda":
return None
from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
token_ids = philox_gumbel_sample(
logits,
contexts,
self.prf.key,
skip_mask=skip_mask,
expanded_idx_mapping=random_sampler.expanded_idx_mapping,
temperatures=random_sampler.temperatures,
seeds=random_sampler.seeds,
positions=random_sampler.positions,
use_fp64=random_sampler.use_fp64,
)
return WatermarkSample(token_ids, logits)
class DualKeyGumbelWatermarker(Watermarker, SupportsSpeculativeDecoding):
def __init__(
self,
key: int,
context_width: int = 4,
prf: WatermarkPRFName = "philox",
alpha: float = 0.1,
) -> None:
if not 0 <= alpha <= 1:
raise ValueError("alpha must be between 0 and 1")
self.alpha = alpha
self.draft_watermarker = GumbelWatermarker(
derive_watermark_key(key, b"key_a"), context_width, prf
)
self.target_watermarker = GumbelWatermarker(
derive_watermark_key(key, b"key_b"), context_width, prf
)
self._routing_logits_cache: dict[torch.device, torch.Tensor] = {}
def _routing_logits(self, device: torch.device) -> torch.Tensor:
# Building this tensor on every CUDA call stages a pageable H2D copy and
# synchronizes the stream.
cached = self._routing_logits_cache.get(device)
if cached is None:
cached = torch.tensor(
[1 - self.alpha, self.alpha],
dtype=torch.float32,
device=device,
).log()
self._routing_logits_cache[device] = cached
return cached
@property
def context_width(self) -> int:
return self.draft_watermarker.context_width
def sample(
self,
logits: torch.Tensor,
contexts: torch.Tensor,
random_sampler: RandomSampler | None = None,
skip_mask: torch.Tensor | None = None,
) -> WatermarkSample:
if self.alpha == 0:
return self.draft_watermarker.sample(
logits, contexts, random_sampler, skip_mask
)
if self.alpha == 1:
return self.target_watermarker.sample(
logits, contexts, random_sampler, skip_mask
)
if random_sampler is None:
raise ValueError("dual-key Gumbel routing requires a random sampler")
key_a_sample = self.draft_watermarker.sample(
logits, contexts, random_sampler, skip_mask
)
key_b_sample = self.target_watermarker.sample(
logits, contexts, random_sampler, skip_mask
)
routing_logits = self._routing_logits(logits.device).expand(logits.shape[0], -1)
use_key_a = random_sampler(routing_logits) == 0
return WatermarkSample(
torch.where(
use_key_a,
key_a_sample.token_ids,
key_b_sample.token_ids,
),
logits,
)
def _sample_watermarked(
self,
logits: torch.Tensor,
contexts: torch.Tensor,
) -> WatermarkSample:
raise ValueError("dual-key Gumbel routing requires a random sampler")
class GumbelWatermarkDetector(WatermarkDetector):
def __init__(
@@ -100,6 +204,61 @@ class GumbelWatermarkDetector(WatermarkDetector):
return token_scores.sum().item()
class DualKeyGumbelWatermarkDetector(GumbelWatermarkDetector):
"""Detect a dual-key watermark with weighted early fusion.
``alpha`` is the weight assigned to key B; key A receives ``1 - alpha``.
"""
def __init__(
self,
key: int,
context_width: int = 4,
p_value_threshold: float = 0.01,
prf: WatermarkPRFName = "philox",
deduplicate_contexts: bool = True,
alpha: float = 0.2,
) -> None:
if not 0 <= alpha <= 1:
raise ValueError("alpha must be between 0 and 1")
super().__init__(
derive_watermark_key(key, b"key_a"),
context_width,
p_value_threshold,
prf,
deduplicate_contexts,
)
self.key_b_prf = create_prf(prf, derive_watermark_key(key, b"key_b"))
self.alpha = alpha
def _score_tokens(
self, contexts: torch.Tensor, targets: torch.Tensor
) -> torch.Tensor:
scores = torch.stack(
[
-torch.log1p(
-prf.uniform(contexts, targets.unsqueeze(-1))
.squeeze(-1)
.to(torch.float64)
)
for prf in (self.prf, self.key_b_prf)
],
dim=-1,
)
weights = scores.new_tensor([1 - self.alpha, self.alpha])
return scores @ weights
def _get_p_value(self, score: float, num_scored_tokens: int) -> float:
variance = (1 - self.alpha) ** 2 + self.alpha**2
if self.alpha in (0, 0.5, 1):
return _gamma_survival_integer_shape(
score / variance, round(num_scored_tokens / variance)
)
shape = torch.tensor(num_scored_tokens / variance, dtype=torch.float64)
scaled_score = torch.tensor(score / variance, dtype=torch.float64)
return torch.special.gammaincc(shape, scaled_score).item()
def _validate_context_width(context_width: int) -> None:
if context_width < 1:
raise ValueError("context_width must be positive")
+153
View File
@@ -0,0 +1,153 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import torch
from vllm.config.watermarking import WatermarkConfig
from vllm.v1.watermarking.factory import create_watermarker
from vllm.v1.watermarking.gumbel import GumbelWatermarker
from vllm.v1.watermarking.prfs import PhiloxPRF
from vllm.v1.watermarking.watermarker import (
SupportsSpeculativeDecoding,
Watermarker,
)
from vllm.v1.worker.gpu.spec_decode.rejection_sampler_utils import rejection_sample
class DraftWatermarker:
def __init__(
self,
watermarker: Watermarker,
max_num_reqs: int,
device: torch.device,
) -> None:
self.watermarker = watermarker
self.contexts = torch.zeros(
max_num_reqs,
watermarker.context_width,
dtype=torch.int64,
device=device,
)
self.enabled = torch.zeros(max_num_reqs, dtype=torch.bool, device=device)
def prepare(self, contexts: torch.Tensor, enabled: torch.Tensor) -> None:
num_reqs = contexts.shape[0]
self.contexts[:num_reqs].copy_(contexts)
self.enabled[:num_reqs].copy_(enabled)
def sample(
self,
logits: torch.Tensor,
ordinary_sampled: torch.Tensor,
idx_mapping: torch.Tensor,
temperature: torch.Tensor,
) -> torch.Tensor:
request_temperatures = temperature[idx_mapping]
processed_logits = logits / torch.where(
request_temperatures == 0, 1, request_temperatures
).unsqueeze(-1)
watermarked = self.watermarker.sample(
processed_logits,
self.contexts[: logits.shape[0]],
).token_ids
enabled = self.enabled[: logits.shape[0]] & (request_temperatures != 0)
sampled = torch.where(enabled, watermarked, ordinary_sampled)
contexts = self.contexts[: logits.shape[0]]
contexts.copy_(torch.cat((contexts[:, 1:], sampled.unsqueeze(-1)), dim=-1))
return sampled
def create_speculative_target_watermarker(watermarker: Watermarker) -> Watermarker:
if isinstance(watermarker, SupportsSpeculativeDecoding):
return watermarker.target_watermarker
return watermarker
def create_speculative_draft_watermarker(
watermarker: Watermarker,
max_num_reqs: int,
device: torch.device,
allow_target_only: bool,
) -> DraftWatermarker | None:
if isinstance(watermarker, SupportsSpeculativeDecoding):
return DraftWatermarker(watermarker.draft_watermarker, max_num_reqs, device)
if allow_target_only:
return None
raise ValueError(
f"The {type(watermarker).__name__} watermarking algorithm does not support "
"speculative decoding. Set allow_target_only_watermarking=true to "
"leave draft tokens unwatermarked."
)
def _philox_key(watermarker: Watermarker) -> int:
if not (
isinstance(watermarker, GumbelWatermarker)
and type(watermarker.prf) is PhiloxPRF
):
raise NotImplementedError(
"In-kernel watermarked recovery supports only the Philox PRF "
"GumbelWatermarker"
)
return watermarker.prf.key
def _resolve_watermark_key(watermarker: Watermarker) -> int:
"""Return the Philox recovery key."""
if isinstance(watermarker, SupportsSpeculativeDecoding):
raise ValueError(
f"{type(watermarker).__name__} keys the target role separately from "
"the draft. Pass create_speculative_target_watermarker(watermarker) "
"so the recovery draw carries the target's key."
)
return _philox_key(watermarker)
def speculative_target_watermark_key(
watermark_config: WatermarkConfig | None,
) -> int | None:
"""Resolve the resample kernel's Philox key from configuration."""
if watermark_config is None:
return None
return _resolve_watermark_key(
create_speculative_target_watermarker(create_watermarker(watermark_config))
)
def watermarked_rejection_sample(
target_logits: torch.Tensor,
draft_logits: torch.Tensor | None,
draft_sampled: torch.Tensor,
cu_num_logits: torch.Tensor,
pos: torch.Tensor,
idx_mapping: torch.Tensor,
expanded_idx_mapping: torch.Tensor,
expanded_local_pos: torch.Tensor,
temperature: torch.Tensor,
seed: torch.Tensor,
num_speculative_steps: int,
contexts: torch.Tensor,
watermarking: torch.Tensor,
watermarker: Watermarker,
use_fp64: bool = False,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Use the target key for rejection recovery and bonus tokens."""
assert contexts.shape == (target_logits.shape[0], watermarker.context_width)
return rejection_sample(
target_logits,
draft_logits,
draft_sampled,
cu_num_logits,
pos,
idx_mapping,
expanded_idx_mapping,
expanded_local_pos,
temperature,
seed,
num_speculative_steps,
use_fp64=use_fp64,
contexts=contexts,
watermarking=watermarking,
watermark_key=_resolve_watermark_key(watermarker),
)
+68 -5
View File
@@ -2,13 +2,12 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from typing import TypeAlias
from typing import Protocol, runtime_checkable
import torch
RandomSampler: TypeAlias = Callable[[torch.Tensor], torch.Tensor]
from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
@dataclass(frozen=True)
@@ -17,17 +16,81 @@ class WatermarkSample:
logits: torch.Tensor
@dataclass(frozen=True)
class RandomSampler:
expanded_idx_mapping: torch.Tensor
temperatures: torch.Tensor
seeds: torch.Tensor
positions: torch.Tensor
use_fp64: bool = False
def __call__(self, logits: torch.Tensor) -> torch.Tensor:
return gumbel_sample(
logits,
self.expanded_idx_mapping,
self.temperatures,
self.seeds,
self.positions,
apply_temperature=False,
is_drafting=False,
use_fp64=self.use_fp64,
)
class Watermarker(ABC):
@property
@abstractmethod
def context_width(self) -> int:
raise NotImplementedError
@abstractmethod
def sample(
self,
logits: torch.Tensor,
contexts: torch.Tensor,
random_sample: RandomSampler,
random_sampler: RandomSampler | None = None,
skip_mask: torch.Tensor | None = None,
) -> WatermarkSample:
if skip_mask is not None:
if random_sampler is None:
raise ValueError("skip_mask requires a random sampler")
mixed = self._try_sample_mixed(logits, contexts, skip_mask, random_sampler)
if mixed is not None:
return mixed
watermarked = self._sample_watermarked(logits, contexts)
if skip_mask is None:
return watermarked
assert random_sampler is not None
token_ids = torch.where(
skip_mask, random_sampler(logits), watermarked.token_ids
)
output_logits = watermarked.logits
if output_logits is not logits:
output_logits = torch.where(skip_mask.unsqueeze(-1), logits, output_logits)
return WatermarkSample(token_ids, output_logits)
@abstractmethod
def _sample_watermarked(
self,
logits: torch.Tensor,
contexts: torch.Tensor,
) -> WatermarkSample:
raise NotImplementedError
def _try_sample_mixed(
self,
logits: torch.Tensor,
contexts: torch.Tensor,
skip_mask: torch.Tensor,
random_sampler: RandomSampler,
) -> WatermarkSample | None:
return None
@runtime_checkable
class SupportsSpeculativeDecoding(Protocol):
"""A watermarker compatible with standard speculative rejection sampling."""
draft_watermarker: Watermarker
target_watermarker: Watermarker
+29 -2
View File
@@ -79,6 +79,10 @@ from vllm.v1.outputs import (
)
from vllm.v1.watermarking import create_watermarker
from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
from vllm.v1.watermarking.spec_decode import (
create_speculative_target_watermarker,
speculative_target_watermark_key,
)
from vllm.v1.worker.block_table import get_block_table_width
from vllm.v1.worker.cp_utils import check_attention_cp_compatibility
from vllm.v1.worker.gpu import pcp_manager as pcp
@@ -472,8 +476,18 @@ class GPUModelRunner(LoRAModelRunnerMixin):
if self.vllm_config.watermark_config is None:
self.sampler = Sampler(**sampler_kwargs)
else:
watermarker = create_watermarker(self.vllm_config.watermark_config)
self.sampler = GPUWatermarkSampler(watermarker, **sampler_kwargs)
wm_config = self.vllm_config.watermark_config
watermarker = create_watermarker(wm_config)
if self.speculative_config is not None:
watermarker = create_speculative_target_watermarker(watermarker)
self.sampler = GPUWatermarkSampler(
watermarker,
deduplicate_contexts=wm_config.deduplicate_contexts,
deduplicate_contexts_max_history=(
wm_config.deduplicate_contexts_max_history
),
**sampler_kwargs,
)
custom = self.model_state.custom_sampler(self.sampler)
if custom:
@@ -484,6 +498,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
self.sampler,
self.speculative_config,
self.device,
watermark_key=speculative_target_watermark_key(
self.vllm_config.watermark_config
),
)
self.prompt_logprobs_worker = PromptLogprobsWorker(
self.max_num_reqs,
@@ -860,6 +877,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
if hasattr(self.model, "get_mtp_target_hidden_states"):
pre_hc_hidden_states = self.model.get_mtp_target_hidden_states()
spec_hidden_states = pre_hc_hidden_states[: hidden_states.shape[0]] # type: ignore[union-attr]
if isinstance(self.sampler, GPUWatermarkSampler):
self.speculator.prepare_watermarking(
self.sampler._get_contexts(input_batch.idx_mapping),
self.sampler.watermarking.gpu[input_batch.idx_mapping],
)
with use_workspace_lane(self._draft_workspace_lane):
self.speculator.propose(
input_batch=input_batch,
@@ -2086,6 +2108,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
if hasattr(self.model, "get_mtp_target_hidden_states"):
pre_hc_hidden_states = self.model.get_mtp_target_hidden_states()
spec_hidden_states = pre_hc_hidden_states[: draft_hidden_states.size(0)]
if isinstance(self.sampler, GPUWatermarkSampler):
self.speculator.prepare_watermarking(
self.sampler._get_contexts(input_batch.idx_mapping),
self.sampler.watermarking.gpu[input_batch.idx_mapping],
)
with use_workspace_lane(self._draft_workspace_lane):
draft_tokens = self.speculator.propose(
input_batch,
+326 -47
View File
@@ -25,6 +25,7 @@ from vllm.v1.watermarking.prfs.philox import (
from vllm.v1.watermarking.prfs.philox import (
_UINT32_MASK as _UINT32_MASK_VALUE,
)
from vllm.v1.worker.gpu.sample.gumbel import gumbel_noised_argmax
if HAS_TRITON:
from triton.language import math as tl_math
@@ -42,6 +43,162 @@ _CONTEXT_DOMAIN = (
_TOKEN_DOMAIN = tl.constexpr(_TOKEN_DOMAIN_VALUE) if HAS_TRITON else _TOKEN_DOMAIN_VALUE
@triton.jit
def _repeated_context_mask_kernel(
output_ptr,
all_token_ids_ptr,
all_token_ids_stride,
req_indices_ptr,
prompt_lens_ptr,
total_lens_ptr,
contexts_ptr,
context_stride,
CONTEXT_WIDTH: tl.constexpr,
MAX_HISTORY: tl.constexpr,
INCLUDE_PROMPT: tl.constexpr,
SKIP_PARTIAL_CONTEXT: tl.constexpr,
BLOCK: tl.constexpr,
):
row = tl.program_id(0).to(tl.int64)
req_idx = tl.load(req_indices_ptr + row).to(tl.int64)
if req_idx < 0:
tl.store(output_ptr + row, 0)
return
prompt_len = tl.load(prompt_lens_ptr + req_idx)
total_len = tl.load(total_lens_ptr + req_idx)
if SKIP_PARTIAL_CONTEXT and total_len - prompt_len < CONTEXT_WIDTH:
tl.store(output_ptr + row, 1)
return
sequence_start = 0 if INCLUDE_PROMPT else prompt_len
history_len = total_len - sequence_start
offsets = tl.arange(0, BLOCK)
repeated = tl.full((), 0, tl.int32)
scan_start = 0
if MAX_HISTORY > 0:
scan_start = tl.maximum(history_len - MAX_HISTORY, 0)
# Aligned loop bounds let the per-lane offsets fold into immediates.
aligned_start = (scan_start // BLOCK) * BLOCK
for block_start in tl.range(aligned_start, history_len, BLOCK):
previous_pos = block_start + offsets
in_window = (previous_pos >= scan_start) & (previous_pos < history_len)
matches = in_window
for offset in range(CONTEXT_WIDTH):
context_token = tl.load(contexts_ptr + row * context_stride + offset)
historical_pos = previous_pos + offset - CONTEXT_WIDTH
historical_token = tl.load(
all_token_ids_ptr
+ req_idx * all_token_ids_stride
+ sequence_start
+ tl.maximum(historical_pos, 0),
mask=in_window & (historical_pos >= 0),
other=-1,
)
matches &= historical_token == context_token
repeated |= tl.max(matches.to(tl.int32), axis=0)
tl.store(output_ptr + row, repeated)
def _repeated_context_mask_cpu(
all_token_ids: torch.Tensor,
req_indices: torch.Tensor,
prompt_lens: torch.Tensor,
total_lens: torch.Tensor,
contexts: torch.Tensor,
max_history: int | None = None,
include_prompt: bool = False,
skip_partial_context: bool = False,
) -> torch.Tensor:
"""Reference implementation; the parity tests check the Triton kernel against it."""
repeated = torch.zeros(len(req_indices), dtype=torch.bool)
for row, req_idx_tensor in enumerate(req_indices):
req_idx = int(req_idx_tensor)
if req_idx < 0:
continue
prompt_len = int(prompt_lens[req_idx])
total_len = int(total_lens[req_idx])
if skip_partial_context and total_len - prompt_len < contexts.shape[-1]:
repeated[row] = True
continue
sequence_start = 0 if include_prompt else prompt_len
history_tokens = all_token_ids[req_idx, sequence_start:total_len].tolist()
prefix = [-1] * contexts.shape[-1]
current_context = tuple(contexts[row].tolist())
history_start = (
max(0, len(history_tokens) - max_history) if max_history is not None else 0
)
for history_pos, token_id in enumerate(history_tokens):
if history_pos >= history_start and (
tuple(prefix[-contexts.shape[-1] :]) == current_context
):
repeated[row] = True
break
prefix.append(token_id)
return repeated
def repeated_context_mask(
all_token_ids: torch.Tensor,
req_indices: torch.Tensor,
prompt_lens: torch.Tensor,
total_lens: torch.Tensor,
contexts: torch.Tensor,
max_history: int | None = None,
include_prompt: bool = False,
skip_partial_context: bool = False,
) -> torch.Tensor:
"""Return, per row, whether the row's context already occurred in its history.
Args:
all_token_ids: `[max_num_reqs, max_model_len]` token ids of every request.
req_indices: Request slot per sampled row; -1 marks a padding row, which
is reported as not repeated.
prompt_lens: Prompt length per request slot.
total_lens: Prompt plus generated length per request slot.
contexts: `[num_rows, context_width]` context per row, padded with -1
before the start of the scanned history.
max_history: Number of most recent history positions searched, or
``None`` for all of them. The window compared at each position
reaches `context_width` tokens further back.
include_prompt: Search the prompt as well as the generated tokens.
skip_partial_context: Mark contexts containing start padding so they use
ordinary sampling.
"""
if max_history is not None and max_history < 1:
raise ValueError("max_history must be positive or None")
if all_token_ids.device.type == "cpu":
return _repeated_context_mask_cpu(
all_token_ids,
req_indices,
prompt_lens,
total_lens,
contexts,
max_history,
include_prompt,
skip_partial_context,
)
if contexts.stride(-1) != 1:
contexts = contexts.contiguous()
repeated = torch.empty(len(req_indices), dtype=torch.bool, device=contexts.device)
_repeated_context_mask_kernel[(len(req_indices),)](
repeated,
all_token_ids,
all_token_ids.stride(0),
req_indices,
prompt_lens,
total_lens,
contexts,
contexts.stride(0),
CONTEXT_WIDTH=contexts.shape[-1],
MAX_HISTORY=0 if max_history is None else max_history,
INCLUDE_PROMPT=include_prompt,
SKIP_PARTIAL_CONTEXT=skip_partial_context,
BLOCK=512,
)
return repeated
@triton.jit
def _mulhilo32(multiplier: tl.constexpr, value):
high = tl_math.umulhi(multiplier, value)
@@ -73,12 +230,100 @@ def _uint32_to_uniform(value):
return (scaled.to(tl.uint32, bitcast=True) - 1).to(tl.float32, bitcast=True)
@triton.jit
def _philox_context_state(
contexts_row_ptr,
key_0,
key_1,
CONTEXT_WIDTH: tl.constexpr,
):
state_0 = tl.full((), _CONTEXT_DOMAIN, tl.uint32)
state_1 = tl.full((), CONTEXT_WIDTH, tl.uint32)
state_2 = tl.full((), 0, tl.uint32)
state_3 = tl.full((), 0, tl.uint32)
for offset in range(0, CONTEXT_WIDTH, 4):
context_0 = tl.load(contexts_row_ptr + offset).to(tl.uint32)
if offset + 1 < CONTEXT_WIDTH:
context_1 = tl.load(contexts_row_ptr + offset + 1).to(tl.uint32)
else:
context_1 = tl.full((), _UINT32_MASK - 1, tl.uint32)
if offset + 2 < CONTEXT_WIDTH:
context_2 = tl.load(contexts_row_ptr + offset + 2).to(tl.uint32)
else:
context_2 = tl.full((), _UINT32_MASK - 2, tl.uint32)
if offset + 3 < CONTEXT_WIDTH:
context_3 = tl.load(contexts_row_ptr + offset + 3).to(tl.uint32)
else:
context_3 = tl.full((), _UINT32_MASK - 3, tl.uint32)
state_0, state_1, state_2, state_3 = _philox4x32_10(
(state_0 ^ context_0) & _UINT32_MASK,
(state_1 ^ context_1) & _UINT32_MASK,
(state_2 ^ context_2) & _UINT32_MASK,
(state_3 ^ context_3) & _UINT32_MASK,
(key_0 ^ offset) & _UINT32_MASK,
key_1,
)
return state_0, state_1, state_2, state_3
@triton.jit
def _philox_candidate_words(groups, state_0, state_1, state_2, state_3, key_0, key_1):
candidate_words = groups.to(tl.uint32)
vector_zero = candidate_words * 0
return _philox4x32_10(
candidate_words & _UINT32_MASK,
state_0 + vector_zero,
state_1 + vector_zero,
state_2 + vector_zero,
((key_0 ^ state_3) & _UINT32_MASK) + vector_zero,
((key_1 ^ _TOKEN_DOMAIN) & _UINT32_MASK) + vector_zero,
)
@triton.jit
def _philox_gumbel_from_logits(logits, word):
logits = tl.where(logits != logits, float("-inf"), logits)
uniform = _uint32_to_uniform(word)
return logits - tl.log(-tl.log(uniform))
@triton.jit
def _gumbel_value(logits_ptr, output, mask):
logits = tl.load(logits_ptr, mask=mask, other=float("-inf")).to(tl.float32)
logits = tl.where(logits != logits, float("-inf"), logits)
uniform = _uint32_to_uniform(output)
return logits - tl.log(-tl.log(uniform))
return _philox_gumbel_from_logits(logits, output)
@triton.jit
def philox_gumbel_block_argmax(
logits,
mask,
block_idx,
contexts_row_ptr,
key_0,
key_1,
CONTEXT_WIDTH: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
"""Apply the detector-compatible PRF to one vocabulary block."""
tl.static_assert(BLOCK_SIZE % 4 == 0)
state_0, state_1, state_2, state_3 = _philox_context_state(
contexts_row_ptr, key_0, key_1, CONTEXT_WIDTH
)
groups = block_idx * (BLOCK_SIZE // 4) + tl.arange(0, BLOCK_SIZE // 4)
output_0, output_1, output_2, output_3 = _philox_candidate_words(
groups, state_0, state_1, state_2, state_3, key_0, key_1
)
# Lay the per-group words out in token order: 4g+0, 4g+1, 4g+2, 4g+3.
words = tl.interleave(
tl.interleave(output_0, output_2),
tl.interleave(output_1, output_3),
)
values = tl.where(
mask,
_philox_gumbel_from_logits(logits.to(tl.float32), words),
float("-inf"),
)
return tl.max(values, axis=0, return_indices=True)
@triton.jit
@@ -100,60 +345,63 @@ def _philox_gumbel_kernel(
logits_stride,
contexts_ptr,
context_stride,
skip_mask_ptr,
expanded_idx_mapping_ptr,
seeds_ptr,
pos_ptr,
temp_ptr,
key_0_value,
key_1_value,
vocab_size,
CONTEXT_WIDTH: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
USE_FP64: tl.constexpr,
):
row = tl.program_id(0).to(tl.int64)
block_index = tl.program_id(1)
groups = block_index * (BLOCK_SIZE // 4) + tl.arange(0, BLOCK_SIZE // 4)
if skip_mask_ptr is not None:
req_state_idx = tl.load(expanded_idx_mapping_ptr + row).to(tl.int64)
valid_req = req_state_idx >= 0
if tl.load(skip_mask_ptr + row):
temp = tl.load(temp_ptr + req_state_idx, mask=valid_req, other=0.0).to(
tl.float32
)
seed = tl.load(seeds_ptr + req_state_idx, mask=valid_req, other=0)
pos = tl.load(pos_ptr + row)
candidate = block_index * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
mask = candidate < vocab_size
logits_row = logits_ptr + row * logits_stride
logits = tl.load(logits_row + candidate, mask=mask, other=float("-inf")).to(
tl.float32
)
value, index = gumbel_noised_argmax(
logits,
candidate,
mask,
seed,
pos,
temp,
IS_DRAFTING=False,
USE_FP64=USE_FP64,
APPLY_TEMPERATURE=False,
)
token_id = block_index * BLOCK_SIZE + index
tl.store(
local_argmax_ptr + row * local_argmax_stride + block_index,
token_id,
)
tl.store(local_max_ptr + row * local_max_stride + block_index, value)
return
key_0 = key_0_value.to(tl.uint32)
key_1 = key_1_value.to(tl.uint32)
state_0 = tl.full((), _CONTEXT_DOMAIN, tl.uint32)
state_1 = tl.full((), CONTEXT_WIDTH, tl.uint32)
state_2 = tl.full((), 0, tl.uint32)
state_3 = tl.full((), 0, tl.uint32)
for offset in range(0, CONTEXT_WIDTH, 4):
context_0 = tl.load(contexts_ptr + row * context_stride + offset).to(tl.uint32)
if offset + 1 < CONTEXT_WIDTH:
context_1 = tl.load(contexts_ptr + row * context_stride + offset + 1).to(
tl.uint32
)
else:
context_1 = tl.full((), _UINT32_MASK - 1, tl.uint32)
if offset + 2 < CONTEXT_WIDTH:
context_2 = tl.load(contexts_ptr + row * context_stride + offset + 2).to(
tl.uint32
)
else:
context_2 = tl.full((), _UINT32_MASK - 2, tl.uint32)
if offset + 3 < CONTEXT_WIDTH:
context_3 = tl.load(contexts_ptr + row * context_stride + offset + 3).to(
tl.uint32
)
else:
context_3 = tl.full((), _UINT32_MASK - 3, tl.uint32)
state_0, state_1, state_2, state_3 = _philox4x32_10(
(state_0 ^ context_0) & _UINT32_MASK,
(state_1 ^ context_1) & _UINT32_MASK,
(state_2 ^ context_2) & _UINT32_MASK,
(state_3 ^ context_3) & _UINT32_MASK,
(key_0 ^ offset) & _UINT32_MASK,
key_1,
)
candidate_words = groups.to(tl.uint32)
vector_zero = candidate_words * 0
output_0, output_1, output_2, output_3 = _philox4x32_10(
candidate_words & _UINT32_MASK,
state_0 + vector_zero,
state_1 + vector_zero,
state_2 + vector_zero,
((key_0 ^ state_3) & _UINT32_MASK) + vector_zero,
((key_1 ^ _TOKEN_DOMAIN) & _UINT32_MASK) + vector_zero,
state_0, state_1, state_2, state_3 = _philox_context_state(
contexts_ptr + row * context_stride, key_0, key_1, CONTEXT_WIDTH
)
output_0, output_1, output_2, output_3 = _philox_candidate_words(
groups, state_0, state_1, state_2, state_3, key_0, key_1
)
candidate_0 = groups * 4
candidate_1 = candidate_0 + 1
@@ -184,17 +432,42 @@ def _philox_gumbel_kernel(
def philox_gumbel_sample(
logits: torch.Tensor, contexts: torch.Tensor, key: int
logits: torch.Tensor,
contexts: torch.Tensor,
key: int,
*,
skip_mask: torch.Tensor | None = None,
expanded_idx_mapping: torch.Tensor | None = None,
temperatures: torch.Tensor | None = None,
seeds: torch.Tensor | None = None,
positions: torch.Tensor | None = None,
use_fp64: bool = False,
) -> torch.Tensor:
sampling_state = (expanded_idx_mapping, temperatures, seeds, positions)
if skip_mask is None and any(value is not None for value in sampling_state):
raise ValueError("sampling state requires skip_mask")
if skip_mask is not None and any(value is None for value in sampling_state):
raise ValueError("skip_mask requires complete sampling state")
if logits.stride(-1) != 1:
logits = logits.contiguous()
if contexts.stride(-1) != 1:
contexts = contexts.contiguous()
if skip_mask is not None:
skip_mask = skip_mask.contiguous()
if expanded_idx_mapping is not None:
expanded_idx_mapping = expanded_idx_mapping.contiguous()
if positions is not None:
positions = positions.contiguous()
num_tokens, vocab_size = logits.shape
block_size = 1024
num_blocks = triton.cdiv(vocab_size, block_size)
local_argmax = logits.new_empty(num_tokens, num_blocks, dtype=torch.int64)
local_max = logits.new_empty(num_tokens, num_blocks, dtype=torch.float32)
local_max = logits.new_empty(
num_tokens,
num_blocks,
dtype=torch.float64 if use_fp64 else torch.float32,
)
_philox_gumbel_kernel[(num_tokens, num_blocks)](
local_argmax,
local_argmax.stride(0),
@@ -204,11 +477,17 @@ def philox_gumbel_sample(
logits.stride(0),
contexts,
contexts.stride(0),
skip_mask,
expanded_idx_mapping,
seeds,
positions,
temperatures,
key & _UINT32_MASK_VALUE,
key >> 32,
vocab_size,
CONTEXT_WIDTH=contexts.shape[-1],
BLOCK_SIZE=block_size,
USE_FP64=use_fp64,
)
max_block_index = local_max.argmax(dim=-1, keepdim=True)
return local_argmax.gather(dim=-1, index=max_block_index).view(-1)
@@ -137,7 +137,7 @@ class DSparkSpeculator(DFlashSpeculator):
# sample_pos is the predicted token's position P. Sampling keys a draw
# by the position before the sampled token, P-1.
return gumbel_sample(
sampled = gumbel_sample(
logits,
idx_map,
self.temperature,
@@ -149,6 +149,14 @@ class DSparkSpeculator(DFlashSpeculator):
logits_cache_col=self._step_cols[step],
use_fp64=self.use_fp64_gumbel,
)
if self.draft_watermarker is not None:
sampled = self.draft_watermarker.sample(
logits,
sampled,
idx_map,
self.temperature,
)
return sampled
def _sample_sequential(self, num_reqs: int, head_hidden: torch.Tensor) -> None:
if self._draft_topk is not None:
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Iterable, Iterator
from typing import Any
import numpy as np
import torch
@@ -10,6 +11,7 @@ from vllm.config.model import PROCESSED_LOGPROBS_MODES
from vllm.triton_utils import tl, triton
from vllm.v1.outputs import LogprobsTensors
from vllm.v1.spec_decode.utils import unconditional_to_conditional_rates
from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
from vllm.v1.worker.gpu.input_batch import (
InputBatch,
get_num_sampled_and_rejected,
@@ -78,8 +80,13 @@ class RejectionSampler:
sampler: Sampler,
spec_config: SpeculativeConfig,
device: torch.device,
*,
watermark_key: int | None = None,
):
self.sampler = sampler
self.watermark_key = watermark_key
if watermark_key is not None:
assert isinstance(sampler, GPUWatermarkSampler)
self.num_speculative_steps = spec_config.num_speculative_tokens
self.enable_adaptive_verification = spec_config.enable_adaptive_verification
rejection_sample_method = spec_config.rejection_sample_method
@@ -141,6 +148,23 @@ class RejectionSampler:
in ("raw_logits", "processed_logits"),
)
def _watermarking_kwargs(
self,
draft_sampled: torch.Tensor,
expanded_idx_mapping: torch.Tensor,
expanded_local_pos: torch.Tensor,
) -> dict[str, Any]:
if self.watermark_key is None:
return {}
assert isinstance(self.sampler, GPUWatermarkSampler)
return {
"contexts": self.sampler._get_contexts(
expanded_idx_mapping, expanded_local_pos, draft_sampled
),
"watermarking": self.sampler.watermarking.gpu,
"watermark_key": self.watermark_key,
}
def _verify(
self,
logits: torch.Tensor,
@@ -177,6 +201,9 @@ class RejectionSampler:
self.synthetic_conditional_rates,
use_fp64=self.sampler.use_fp64_gumbel,
use_block_verification=self.use_block_verification,
**self._watermarking_kwargs(
draft_sampled, expanded_idx_mapping, expanded_local_pos
),
)
return processed_logits, sampled, num_sampled
@@ -4,6 +4,7 @@ import torch
from vllm.triton_utils import tl, tldevice, triton
from vllm.v1.worker.gpu.sample.gumbel import gumbel_block_argmax, tl_rand32
from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_block_argmax
@triton.jit
@@ -693,6 +694,39 @@ def _rejection_kernel(
@triton.jit
def _seeded_resample_argmax(
residual_logits,
block,
mask,
resample_token_idx,
expanded_idx_mapping_ptr,
temp_ptr,
seed_ptr,
pos_ptr,
vocab_size,
USE_FP64: tl.constexpr,
):
return gumbel_block_argmax(
residual_logits,
block,
mask,
resample_token_idx,
expanded_idx_mapping_ptr,
temp_ptr,
seed_ptr,
pos_ptr,
None, # logits_cache_ptr
0, # logits_cache_stride_0
0, # logits_cache_stride_1
None, # logits_cache_col_ptr
vocab_size,
IS_DRAFTING=False,
APPLY_TEMPERATURE=False,
USE_FP64=USE_FP64,
)
@triton.jit(do_not_specialize=["watermark_key_0", "watermark_key_1"])
def _resample_kernel(
# [num_reqs, num_blocks]
resampled_local_argmax_ptr,
@@ -727,11 +761,20 @@ def _resample_kernel(
pos_ptr,
# [num_logits]
cumulative_log_p_ptr,
# [num_logits, CONTEXT_WIDTH]
contexts_ptr,
contexts_stride,
# [max_num_reqs], uint8 view of a bool tensor
watermarking_ptr,
watermark_key_0,
watermark_key_1,
vocab_size,
BLOCK_SIZE: tl.constexpr,
HAS_DRAFT_LOGITS: tl.constexpr,
USE_FP64: tl.constexpr,
USE_BLOCK_VERIFICATION: tl.constexpr,
CONTEXT_WIDTH: tl.constexpr,
WATERMARK: tl.constexpr,
):
req_idx = tl.program_id(0)
resample_idx = tl.load(rejected_step_ptr + req_idx)
@@ -763,10 +806,7 @@ def _resample_kernel(
other=float("-inf"),
).to(tl.float32)
# Compute the residual logits to resample the rejected token from.
if is_bonus or not is_valid_rejected_draft:
# Bonus token (no rejections) or -1 placeholder token. In either case,
# directly use the target logits.
residual_logits = target_logits
elif HAS_DRAFT_LOGITS:
# draft_logits is stored pre-temperature, so apply scale first.
@@ -796,12 +836,7 @@ def _resample_kernel(
)
target_log_probs += log_p_tau
draft_log_probs = draft_logits - draft_lse
# Compute the residual:
# r(x) = max(p(x) - q(x), 0)
# Gumbel sampling needs logits, so we compute it in log space:
# log(r(x)) = log(max(exp(log_p(x)) - exp(log_q(x)), 0))
# The more numerically stable form is:
# log(max(exp(a) - exp(b), 0)) = a + log(max(1 - exp(b - a), 0))
# Compute log(max(p - q, 0)) without subtracting probabilities.
ratio = tl.exp(draft_log_probs - target_log_probs)
residual_logits = tl.where(
ratio < 1.0,
@@ -809,38 +844,62 @@ def _resample_kernel(
float("-inf"),
).to(tl.float32)
else:
# One-hot draft. The residual is just the target distribution with
# the rejected draft token probability zeroed out.
# NOTE: During block verification, the residual becomes:
# 0 if x == rejected_draft_token
# p_tau * M_b(x) / Z otherwise
# Therefore p_tau is a constant that cancels under normalization,
# and does not need to be applied.
# The block-verification factor is constant and cancels on normalization.
residual_logits = tl.where(
block != rejected_draft_token,
target_logits,
float("-inf"),
).to(tl.float32)
# Resample the rejected/bonus token.
value, idx = gumbel_block_argmax(
residual_logits,
block,
mask,
resample_token_idx,
expanded_idx_mapping_ptr,
temp_ptr,
seed_ptr,
pos_ptr,
None, # logits_cache_ptr
0, # logits_cache_stride_0
0, # logits_cache_stride_1
None, # logits_cache_col_ptr
vocab_size,
IS_DRAFTING=False,
APPLY_TEMPERATURE=False,
USE_FP64=USE_FP64,
)
if WATERMARK:
# Padded and greedy rows retain the stock draw.
is_watermarked = (
tl.load(
watermarking_ptr + req_state_idx,
mask=req_state_idx >= 0,
other=0,
)
!= 0
)
if is_watermarked & (temp != 0.0):
watermark_value, idx = philox_gumbel_block_argmax(
residual_logits,
mask,
block_idx,
contexts_ptr + resample_token_idx * contexts_stride,
watermark_key_0.to(tl.uint32),
watermark_key_1.to(tl.uint32),
CONTEXT_WIDTH,
BLOCK_SIZE,
)
# Detector compatibility fixes the keyed draw at fp32.
value = watermark_value.to(tl.float64) if USE_FP64 else watermark_value
else:
value, idx = _seeded_resample_argmax(
residual_logits,
block,
mask,
resample_token_idx,
expanded_idx_mapping_ptr,
temp_ptr,
seed_ptr,
pos_ptr,
vocab_size,
USE_FP64=USE_FP64,
)
else:
value, idx = _seeded_resample_argmax(
residual_logits,
block,
mask,
resample_token_idx,
expanded_idx_mapping_ptr,
temp_ptr,
seed_ptr,
pos_ptr,
vocab_size,
USE_FP64=USE_FP64,
)
token_id = block_idx * BLOCK_SIZE + idx
tl.store(
resampled_local_argmax_ptr
@@ -947,6 +1006,9 @@ def rejection_sample(
synthetic_conditional_rates: torch.Tensor | None = None,
use_fp64: bool = False,
use_block_verification: bool = False,
contexts: torch.Tensor | None = None,
watermarking: torch.Tensor | None = None,
watermark_key: int | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
assert target_logits.ndim == 2 and target_logits.stride(-1) == 1
assert draft_logits is None or (
@@ -954,6 +1016,30 @@ def rejection_sample(
)
num_reqs = cu_num_logits.shape[0] - 1
num_logits, vocab_size = target_logits.shape
watermark = contexts is not None
assert watermark == (watermarking is not None) == (watermark_key is not None), (
"contexts, watermarking and watermark_key must be set together."
)
contexts_stride = 0
context_width = 1
watermarking_bytes: torch.Tensor | None = None
watermark_key_0 = 0
watermark_key_1 = 0
if contexts is not None:
assert watermarking is not None and watermark_key is not None
assert contexts.ndim == 2 and contexts.shape[0] == num_logits
# Context words are hashed as uint32, so int32 (the request-state token
# dtype) and int64 both land on the same PRF stream, -1 included.
assert contexts.dtype in (torch.int32, torch.int64)
if contexts.stride(-1) != 1:
contexts = contexts.contiguous()
assert watermarking.ndim == 1 and watermarking.dtype == torch.bool
contexts_stride = contexts.stride(0)
context_width = contexts.shape[-1]
watermarking_bytes = watermarking.view(torch.uint8)
watermark_key_0 = watermark_key & 0xFFFFFFFF
watermark_key_1 = watermark_key >> 32
draft_logits_stride_0 = 0
draft_logits_stride_1 = 0
if has_draft_logits := draft_logits is not None:
@@ -1165,11 +1251,18 @@ def rejection_sample(
seed,
pos,
cumulative_log_p,
contexts,
contexts_stride,
watermarking_bytes,
watermark_key_0,
watermark_key_1,
vocab_size,
BLOCK_SIZE=RESAMPLE_BLOCK_SIZE,
HAS_DRAFT_LOGITS=has_draft_logits,
USE_FP64=use_fp64,
USE_BLOCK_VERIFICATION=use_block_verification,
CONTEXT_WIDTH=context_width,
WATERMARK=watermark,
)
# Insert the resampled tokens into the output sampled.
+32 -1
View File
@@ -17,6 +17,11 @@ from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
from vllm.model_executor.models import supports_multimodal_embeddings
from vllm.multimodal import MULTIMODAL_REGISTRY
from vllm.v1.kv_cache_interface import KVCacheConfig
from vllm.v1.watermarking import create_watermarker
from vllm.v1.watermarking.spec_decode import (
DraftWatermarker,
create_speculative_draft_watermarker,
)
from vllm.v1.worker.gpu.attn_utils import (
build_attn_metadata,
init_attn_backend,
@@ -168,6 +173,17 @@ class DraftModelSpeculator(BaseSpeculator):
device=device,
)
self.draft_watermarker: DraftWatermarker | None = None
watermark_config = getattr(vllm_config, "watermark_config", None)
if watermark_config is not None:
watermarker = create_watermarker(watermark_config)
self.draft_watermarker = create_speculative_draft_watermarker(
watermarker,
self.max_num_reqs,
device,
watermark_config.allow_target_only_watermarking,
)
self.supports_mm_inputs = False
self.pcp_manager: PCPManager | None = None
@@ -373,7 +389,7 @@ class DraftModelSpeculator(BaseSpeculator):
) -> torch.Tensor:
if draft_logits is not None:
logits = self.model.compute_logits(hidden_states)
return gumbel_sample(
sampled = gumbel_sample(
logits,
idx_mapping,
temperature,
@@ -385,8 +401,23 @@ class DraftModelSpeculator(BaseSpeculator):
logits_cache_col=draft_step,
use_fp64=self.use_fp64_gumbel,
)
if self.draft_watermarker is not None:
sampled = self.draft_watermarker.sample(
logits,
sampled,
idx_mapping,
temperature,
)
return sampled
return self._greedy_sample_draft(hidden_states)
def prepare_watermarking(
self, contexts: torch.Tensor, watermarking: torch.Tensor
) -> None:
if self.draft_watermarker is None:
return
self.draft_watermarker.prepare(contexts, watermarking)
def _copy_request_inputs(
self,
num_reqs: int,