[feature] Watermarked generation and detection (Gumbel-max algorithm) (#54053)
Signed-off-by: Raphael Rialland <raphael.rialland@mistral.ai> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Raphaël Rialland committed
Sep 10, 2026 at 06:00 UTC
ea40bb9e905f8d552281dd1ec074f91865a0a242
32 files changed
+1693
-15
docs/features/watermarking.md
new
+147
@@ -0,0 +1,147 @@
1
+# Text watermarking
2
+
3
+Text watermarking embeds a statistical signal in generated token choices. A
4
+detector that knows the generation parameters can test for this signal without
5
+access to the model weights.
6
+
7
+The design and scope are discussed in
8
+[RFC #53916](https://github.com/vllm-project/vllm/issues/53916).
9
+
10
+## Configuration
11
+
12
+Enable watermarking by configuring an algorithm and secret key at engine
13
+startup:
14
+
15
+```bash
16
+vllm serve MODEL \
17
+ --watermark-config '{"algorithm":"gumbel","key":42}'
18
+```
19
+
20
+Watermarking is disabled when `--watermark-config` is omitted. Gumbel-max (see
21
+`gumbel`) is the default algorithm within an enabled `WatermarkConfig`. When
22
+watermarking is configured, it is enabled for requests by default.
23
+
24
+Requests can opt out without changing the engine-level algorithm or key:
25
+
26
+```python
27
+from vllm import SamplingParams
28
+
29
+sampling_params = SamplingParams(watermarking=False)
30
+```
31
+
32
+The OpenAI-compatible APIs accept the same `watermarking: false` request field.
33
+Deployments that require watermarking must restrict this field to trusted
34
+callers, or strip and validate it at the ingress boundary, so untrusted clients
35
+cannot opt out.
36
+
37
+`context_width` controls how many prior output tokens seed each watermark
38
+decision and defaults to 4. Larger values make the watermark less robust to
39
+edits because an insertion, deletion, or substitution changes more subsequent
40
+contexts. Values above 16 are allowed but emit a warning.
41
+
42
+## Architecture
43
+
44
+`WatermarkConfig` selects an algorithm and PRF. Model Runner V2 constructs the
45
+corresponding `Watermarker`, and `GPUWatermarkSampler` invokes it for the final
46
+stochastic token selection after temperature, min-p, top-k, and top-p are
47
+applied. A watermarker can either select a token directly or transform logits
48
+and delegate to vLLM's random sampler.
49
+
50
+Detection is separate from generation. vLLM provides detector primitives for
51
+the reference algorithms. `WatermarkDetector` consumes token IDs, so callers
52
+remain responsible for using the tokenizer and watermark profile that match
53
+generation.
54
+
55
+## Algorithms
56
+
57
+### Gumbel-max
58
+
59
+Gumbel-max derives a deterministic pseudorandom value from the key, prior
60
+generated-token context, and every candidate token, then uses the resulting
61
+Gumbel noise for categorical sampling. See
62
+[Aaronson's original presentation](https://simons.berkeley.edu/sites/default/files/2024-10/LLM24-2%20Slides%20-%20Scott%20Aaronson.pdf).
63
+
64
+Gumbel-max requires stochastic sampling. Greedy requests (`temperature=0`)
65
+bypass watermarking and emit a warning once per worker.
66
+
67
+Single-key Gumbel-max watermarking may increase degenerate generations. In
68
+particular, reusing the same keyed random vector when a context repeats can
69
+reinforce repetition loops that continue until the maximum token limit.
70
+
71
+### SynthID-Text
72
+
73
+[SynthID-Text](https://www.nature.com/articles/s41586-024-08025-4) is planned but
74
+not currently implemented.
75
+
76
+## Pseudorandom functions
77
+
78
+A watermark PRF turns the secret key, token context, and candidate token into
79
+reproducible random values. Generation and detection must produce identical
80
+values across devices and releases. The values should also be uniform and
81
+independent enough for the sampling algorithm and detector statistics. PRF
82
+selection is an advanced compatibility and performance setting; most users
83
+should keep the default.
84
+
85
+Watermarked generation currently supports the `philox` PRF:
86
+
87
+- `philox` is based on the counter-based Philox4x32-10
88
+ generator from the [Random123 paper](https://doi.org/10.1145/2063384.2063405).
89
+ It is parallel, vectorizes on accelerators, and avoids CPU transfers, but is
90
+ not a cryptographic PRF and does not provide key-recovery or forgery
91
+ resistance. vLLM versions its input mapping and provides compatibility
92
+ vectors so generation and detection remain interoperable.
93
+
94
+## Detection
95
+
96
+The detector primitives operate on token IDs and do not require model weights:
97
+
98
+```python
99
+from transformers import AutoTokenizer
100
+
101
+from vllm.v1.watermarking import GumbelWatermarkDetector
102
+
103
+tokenizer = AutoTokenizer.from_pretrained(MODEL)
104
+token_ids = tokenizer.encode(text, add_special_tokens=False)
105
+result = GumbelWatermarkDetector(key=42, prf="philox").detect(token_ids)
106
+print(result.p_value, result.is_watermarked)
107
+```
108
+
109
+The tokenizer, algorithm, PRF, key, and context width must match generation.
110
+Gumbel-max detection scores repeated contexts once by default so identical PRF
111
+random vectors are not treated as independent evidence. Keep
112
+`deduplicate_contexts=True` unless the detector's calibration has been adjusted
113
+for correlated scores.
114
+
115
+The reported p-value is calibrated under the assumption that scored PRF inputs
116
+are independent. A deployment uses one fixed key, so repeated structures across
117
+documents reuse the same PRF values and can make the realized false-positive
118
+rate key-dependent even when contexts are deduplicated within each document.
119
+Measure the false-positive rate on representative unwatermarked traffic with the
120
+deployed key before relying on `is_watermarked` for decisions.
121
+
122
+A minimal HTTP detector is available in
123
+`examples/basic/online_serving/watermark_detection_server.py`:
124
+
125
+```bash
126
+python examples/basic/online_serving/watermark_detection_server.py \
127
+ --tokenizer MODEL --key 42 --prf philox
128
+```
129
+
130
+```bash
131
+curl http://localhost:8000/detect \
132
+ -H 'Content-Type: application/json' \
133
+ -d '{"text":"Text to inspect"}'
134
+```
135
+
136
+Scores and p-values expose information about the per-token watermark signal.
137
+Repeated queries can use this information to construct text that imitates
138
+watermarked output or to modify watermarked text so it is no longer detected.
139
+
140
+## Limitations
141
+
142
+- Watermarking is currently available only with Model Runner V2.
143
+- Gumbel-max cannot be configured with speculative decoding.
144
+- Beam search expands candidates from model log probabilities and does not apply
145
+ Gumbel-max watermarking.
146
+- Models that replace the vLLM sampler with a custom sampler cannot use
147
+ configured watermarking.
examples/basic/online_serving/watermark_detection_server.py
new
+70
@@ -0,0 +1,70 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""Minimal reference server for watermark detection."""
5
+
6
+import argparse
7
+
8
+import uvicorn
9
+from fastapi import FastAPI
10
+from pydantic import BaseModel
11
+
12
+from vllm.tokenizers import TokenizerLike, cached_get_tokenizer
13
+from vllm.v1.watermarking import GumbelWatermarkDetector
14
+
15
+app = FastAPI()
16
+tokenizer: TokenizerLike | None = None
17
+detector: GumbelWatermarkDetector | None = None
18
+
19
+
20
+class DetectionRequest(BaseModel):
21
+ text: str
22
+
23
+
24
+class DetectionResponse(BaseModel):
25
+ score: float
26
+ p_value: float
27
+ num_scored_tokens: int
28
+ is_watermarked: bool
29
+
30
+
31
+@app.post("/detect")
32
+def detect(request: DetectionRequest) -> DetectionResponse:
33
+ assert tokenizer is not None
34
+ assert detector is not None
35
+ token_ids = tokenizer.encode(request.text, add_special_tokens=False)
36
+ result = detector.detect(token_ids)
37
+ return DetectionResponse(
38
+ score=result.score,
39
+ p_value=result.p_value,
40
+ num_scored_tokens=result.num_scored_tokens,
41
+ is_watermarked=result.is_watermarked,
42
+ )
43
+
44
+
45
+def parse_args() -> argparse.Namespace:
46
+ parser = argparse.ArgumentParser(description=__doc__)
47
+ parser.add_argument("--tokenizer", required=True)
48
+ parser.add_argument("--key", required=True, type=int)
49
+ parser.add_argument("--prf", choices=("philox",), default="philox")
50
+ parser.add_argument("--context-width", type=int, default=4)
51
+ parser.add_argument("--p-value-threshold", type=float, default=0.01)
52
+ parser.add_argument("--host", default="127.0.0.1")
53
+ parser.add_argument("--port", type=int, default=8000)
54
+ return parser.parse_args()
55
+
56
+
57
+def main(args: argparse.Namespace) -> None:
58
+ global tokenizer, detector
59
+ tokenizer = cached_get_tokenizer(args.tokenizer)
60
+ detector = GumbelWatermarkDetector(
61
+ key=args.key,
62
+ context_width=args.context_width,
63
+ p_value_threshold=args.p_value_threshold,
64
+ prf=args.prf,
65
+ )
66
+ uvicorn.run(app, host=args.host, port=args.port)
67
+
68
+
69
+if __name__ == "__main__":
70
+ main(parse_args())
tests/engine/test_arg_utils.py
+21
@@ -46,6 +46,27 @@ def test_optional_type():
46
assert optional_type_func("42") == 42
47
48
49
+def test_watermark_config_cli():
50
+ parser = EngineArgs.add_cli_args(FlexibleArgumentParser())
51
+ args = parser.parse_args(
52
+ [
53
+ "--model",
54
+ "dummy",
55
+ "--watermark-config",
56
+ '{"algorithm":"gumbel","key":42,"prf":"philox"}',
57
+ ]
58
+ )
59
+
60
+ config = EngineArgs.from_cli_args(args).create_watermark_config()
61
+
62
+ assert config is not None
63
+ assert config.algorithm == "gumbel"
64
+ assert config.key == 42
65
+ assert config.context_width == 4
66
+ assert config.prf == "philox"
67
+ assert not config.supports_speculative_decoding
68
+
69
+
70
@pytest.mark.parametrize(
71
"options",
72
[
tests/entrypoints/openai/test_watermarking.py
new
+32
@@ -0,0 +1,32 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
5
+from vllm.entrypoints.openai.completion.protocol import CompletionRequest
6
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
7
+
8
+
9
+def test_chat_request_disables_watermarking():
10
+ request = ChatCompletionRequest(
11
+ messages=[{"role": "user", "content": "hello"}], watermarking=False
12
+ )
13
+
14
+ params = request.to_sampling_params(max_tokens=1, default_sampling_params={})
15
+
16
+ assert not params.watermarking
17
+
18
+
19
+def test_completion_request_disables_watermarking():
20
+ request = CompletionRequest(prompt="hello", watermarking=False)
21
+
22
+ params = request.to_sampling_params(max_tokens=1)
23
+
24
+ assert not params.watermarking
25
+
26
+
27
+def test_responses_request_disables_watermarking():
28
+ request = ResponsesRequest(input="hello", watermarking=False)
29
+
30
+ params = request.to_sampling_params(default_max_tokens=1)
31
+
32
+ assert not params.watermarking
tests/entrypoints/serve/utils/test_api_utils.py
+8
-1
@@ -120,17 +120,24 @@ class TestRedactSensitiveArgs:
120
API_KEY = "sk-test-secret-12345"
121
122
def test_redact_replaces_sensitive_values_only(self):
123
- args = {"api_key": self.API_KEY, "hf_token": "hf_secret", "other": "visible"}
123
+ args = {
124
+ "api_key": self.API_KEY,
125
+ "hf_token": "hf_secret",
126
+ "watermark_config": {"algorithm": "gumbel", "key": 42},
127
+ "other": "visible",
128
+ }
129
redacted = redact_sensitive_args(args)
130
assert redacted == {
131
"api_key": "***",
132
"hf_token": "***",
133
+ "watermark_config": "***",
134
"other": "visible",
135
}
136
# original dict must not be mutated
137
assert args == {
138
"api_key": self.API_KEY,
139
"hf_token": "hf_secret",
140
+ "watermark_config": {"algorithm": "gumbel", "key": 42},
141
"other": "visible",
142
}
143
tests/test_config.py
+61
@@ -32,6 +32,7 @@ from vllm.config import (
32
SchedulerConfig,
33
SpeculativeConfig,
34
VllmConfig,
35
+ WatermarkConfig,
36
update_config,
37
)
38
from vllm.config.compilation import CompilationMode, CUDAGraphMode
@@ -2574,6 +2575,66 @@ def test_draft_sample_method_gumbel_is_rejected():
2575
)
2576
2577
2578
+def _watermarked_vllm_config() -> VllmConfig:
2579
+ config = object.__new__(VllmConfig)
2580
+ config.watermark_config = WatermarkConfig(key=42)
2581
+ config.speculative_config = None
2582
+ return config
2583
+
2584
+
2585
+def test_gumbel_watermark_rejects_speculative_decoding():
2586
+ config = _watermarked_vllm_config()
2587
+ config.speculative_config = SpeculativeConfig(
2588
+ method="ngram",
2589
+ num_speculative_tokens=1,
2590
+ )
2591
+
2592
+ with pytest.raises(ValueError, match="does not support speculative decoding"):
2593
+ config._check_watermarking_unsupported()
2594
+
2595
+
2596
+def test_gumbel_watermark_rejects_beam_search():
2597
+ with pytest.raises(ValueError, match="Beam search is not supported"):
2598
+ _watermarked_vllm_config()._check_watermarking_unsupported(beam_search=True)
2599
+
2600
+
2601
+def test_gumbel_watermark_rejects_custom_sampler():
2602
+ with pytest.raises(ValueError, match="custom samplers are not supported"):
2603
+ _watermarked_vllm_config()._check_watermarking_unsupported(custom_sampler=True)
2604
+
2605
+
2606
+def test_watermark_key_must_fit_in_64_bits():
2607
+ with pytest.raises(ValueError, match="64 bits"):
2608
+ WatermarkConfig(key=2**64)
2609
+
2610
+
2611
+def test_unknown_watermark_prf_is_rejected():
2612
+ with pytest.raises(ValidationError):
2613
+ pydantic.TypeAdapter(WatermarkConfig).validate_python(
2614
+ {"key": 42, "prf": "unsupported"}
2615
+ )
2616
+
2617
+
2618
+def test_watermark_key_is_excluded_from_serialization():
2619
+ serialized = pydantic.TypeAdapter(WatermarkConfig).dump_python(
2620
+ WatermarkConfig(key=42), mode="json"
2621
+ )
2622
+
2623
+ assert "key" not in serialized
2624
+
2625
+
2626
+def test_watermarking_forces_model_runner_v2(monkeypatch):
2627
+ monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "0")
2628
+
2629
+ with patch("vllm.config.vllm.logger.info_once") as info_once:
2630
+ assert _watermarked_vllm_config().use_v2_model_runner
2631
+
2632
+ info_once.assert_called_once_with(
2633
+ "Watermarking requires Model Runner V2 and overrides "
2634
+ "VLLM_USE_V2_MODEL_RUNNER=0."
2635
+ )
2636
+
2637
+
2638
@patch("vllm.config.speculative.ModelConfig")
2639
def test_mtp_draft_uses_model_weights_not_local_cache(mock_model_config_cls):
2640
"""Regression test: MTP + runai_streamer should use model_weights (original
tests/watermarking/test_detection.py
new
+91
@@ -0,0 +1,91 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.v1.watermarking import GumbelWatermarkDetector
5
+
6
+_NON_WATERMARKED_TOKEN_IDS = list(range(64))
7
+_WATERMARKED_TOKEN_IDS = [
8
+ 85,
9
+ 56,
10
+ 123,
11
+ 77,
12
+ 105,
13
+ 62,
14
+ 9,
15
+ 104,
16
+ 16,
17
+ 98,
18
+ 123,
19
+ 22,
20
+ 16,
21
+ 35,
22
+ 47,
23
+ 127,
24
+ 77,
25
+ 67,
26
+ 19,
27
+ 2,
28
+ 10,
29
+ 8,
30
+ 75,
31
+ 22,
32
+ 127,
33
+ 30,
34
+ 95,
35
+ 9,
36
+ 101,
37
+ 14,
38
+ 113,
39
+ 122,
40
+ 85,
41
+ 66,
42
+ 122,
43
+ 50,
44
+ 38,
45
+ 119,
46
+ 15,
47
+ 58,
48
+ 68,
49
+ 62,
50
+ 24,
51
+ 126,
52
+ 80,
53
+ 126,
54
+ 68,
55
+ 83,
56
+ 81,
57
+ 126,
58
+ 62,
59
+ 31,
60
+ 18,
61
+ 81,
62
+ 64,
63
+ 47,
64
+ 22,
65
+ 70,
66
+ 47,
67
+ 109,
68
+ 54,
69
+ 21,
70
+ 65,
71
+ 38,
72
+]
73
+
74
+
75
+def test_detection_on_known_sequence():
76
+ detector = GumbelWatermarkDetector(key=42, context_width=4, prf="philox")
77
+
78
+ assert detector.detect(_WATERMARKED_TOKEN_IDS).is_watermarked
79
+ assert not detector.detect(_NON_WATERMARKED_TOKEN_IDS).is_watermarked
80
+
81
+
82
+def test_detector_handles_empty_input():
83
+ detector = GumbelWatermarkDetector(key=42, context_width=4, prf="philox")
84
+ contexts, targets = detector._prepare_inputs([])
85
+ detection = detector.detect([])
86
+
87
+ assert contexts.shape == (0, 4)
88
+ assert targets.shape == (0,)
89
+ assert detection.score == 0
90
+ assert detection.p_value == 1
91
+ assert not detection.is_watermarked
tests/watermarking/test_gumbel.py
new
+84
@@ -0,0 +1,84 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+import torch
6
+
7
+from vllm.platforms import current_platform
8
+from vllm.v1.watermarking import GumbelWatermarkDetector, GumbelWatermarker
9
+from vllm.v1.watermarking.gumbel import _gamma_survival_integer_shape
10
+
11
+
12
+def test_gamma_survival_integer_shape():
13
+ assert _gamma_survival_integer_shape(0.0, 4) == 1.0
14
+ assert _gamma_survival_integer_shape(2.0, 1) == pytest.approx(0.1353352832)
15
+
16
+
17
+def test_detector_deduplicates_repeated_prf_inputs_by_default():
18
+ detection = GumbelWatermarkDetector(key=42, context_width=1).detect([1, 1, 1, 1, 1])
19
+
20
+ assert detection.num_scored_tokens == 2
21
+
22
+
23
+def test_detector_can_score_repeated_prf_inputs():
24
+ detection = GumbelWatermarkDetector(
25
+ key=42, context_width=1, deduplicate_contexts=False
26
+ ).detect([1, 1, 1, 1, 1])
27
+
28
+ assert detection.num_scored_tokens == 5
29
+
30
+
31
+def test_detector_deduplicates_context_even_when_target_differs():
32
+ detection = GumbelWatermarkDetector(key=42, context_width=1).detect([1, 2, 1, 3])
33
+
34
+ assert detection.num_scored_tokens == 3
35
+
36
+
37
+@pytest.mark.skipif(
38
+ not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
39
+)
40
+@pytest.mark.parametrize("key", [1, 2**32, 42, 15726070495360670683])
41
+@pytest.mark.parametrize("context_width", [1, 4, 16])
42
+def test_fused_watermarker_matches_cpu(key: int, context_width: int):
43
+ torch.manual_seed(0)
44
+ contexts = torch.randint(0, 248320, (32, context_width), dtype=torch.int64)
45
+ logits = torch.randn(32, 8193)
46
+ watermarker = GumbelWatermarker(key, context_width)
47
+
48
+ expected = watermarker.sample(logits, contexts, lambda values: None).token_ids
49
+ actual = watermarker.sample(
50
+ logits.cuda(), contexts.cuda(), lambda values: None
51
+ ).token_ids.cpu()
52
+
53
+ torch.testing.assert_close(actual, expected, rtol=0, atol=0)
54
+
55
+
56
+@pytest.mark.skipif(
57
+ not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
58
+)
59
+def test_fused_watermarker_handles_nan_logits():
60
+ contexts = torch.zeros((2, 4), dtype=torch.int64, device="cuda")
61
+ logits = torch.full((2, 1025), float("nan"), device="cuda")
62
+ watermarker = GumbelWatermarker(key=42, context_width=4)
63
+
64
+ token_ids = watermarker.sample(logits, contexts, lambda values: None).token_ids
65
+
66
+ assert torch.all((token_ids >= 0) & (token_ids < logits.shape[-1]))
67
+
68
+
69
+@pytest.mark.skipif(
70
+ not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
71
+)
72
+def test_fused_watermarker_handles_noncontiguous_inputs():
73
+ contexts = torch.randint(0, 248320, (8, 8), dtype=torch.int64, device="cuda")[
74
+ :, ::2
75
+ ]
76
+ logits = torch.randn(8, 2050, device="cuda")[:, ::2]
77
+ watermarker = GumbelWatermarker(key=42, context_width=4)
78
+
79
+ expected = watermarker.sample(
80
+ logits.contiguous(), contexts.contiguous(), lambda values: None
81
+ ).token_ids
82
+ actual = watermarker.sample(logits, contexts, lambda values: None).token_ids
83
+
84
+ torch.testing.assert_close(actual, expected, rtol=0, atol=0)
tests/watermarking/test_prf.py
new
+44
@@ -0,0 +1,44 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import pytest
5
+import torch
6
+
7
+from vllm.platforms import current_platform
8
+from vllm.v1.watermarking.prfs import PhiloxPRF
9
+
10
+
11
+def test_philox_compatibility_vector():
12
+ contexts = torch.tensor([[1, 2, 3, 4], [4, 5, 6, 7]])
13
+ token_ids = torch.tensor([7, 8])
14
+ expected_mantissas = [[1661833, 10571167], [9742331, 13375724]]
15
+ expected = (
16
+ (torch.tensor(expected_mantissas, dtype=torch.float64) + 1) / (2**24 + 1)
17
+ ).to(torch.float32)
18
+
19
+ assert torch.equal(PhiloxPRF(42).uniform(contexts, token_ids), expected)
20
+
21
+
22
+def test_prf_pairs_contexts_with_target_tokens():
23
+ contexts = torch.tensor([[1, 2, 3, 4], [4, 5, 6, 7]])
24
+ token_ids = torch.tensor([[7], [8]])
25
+
26
+ assert PhiloxPRF(42).uniform(contexts, token_ids).shape == (2, 1)
27
+
28
+
29
+@pytest.mark.skipif(
30
+ not current_platform.is_cuda_alike(), reason="requires a CUDA-like accelerator"
31
+)
32
+@pytest.mark.parametrize("key", [42, 15726070495360670683])
33
+@pytest.mark.parametrize("context_width", [1, 4, 16])
34
+def test_philox_accelerator_matches_cpu(key: int, context_width: int):
35
+ contexts = torch.arange(2 * context_width, dtype=torch.int64).reshape(
36
+ 2, context_width
37
+ )
38
+ token_ids = torch.arange(1024, dtype=torch.int64)
39
+ prf = PhiloxPRF(key)
40
+
41
+ expected = prf.uniform(contexts, token_ids)
42
+ actual = prf.uniform(contexts.cuda(), token_ids.cuda()).cpu()
43
+
44
+ torch.testing.assert_close(actual, expected, rtol=0, atol=0)
tests/watermarking/test_watermarking.py
new
+157
@@ -0,0 +1,157 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from types import SimpleNamespace
5
+
6
+import numpy as np
7
+import pytest
8
+import torch
9
+
10
+from vllm import SamplingParams
11
+from vllm.config.watermarking import WatermarkConfig
12
+from vllm.v1.watermarking import create_watermarker
13
+from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
14
+from vllm.v1.watermarking.watermarker import WatermarkSample
15
+from vllm.v1.worker.gpu.sample.sampler import Sampler
16
+
17
+
18
+@pytest.mark.parametrize("algorithm", ["gumbel"])
19
+def test_watermarker_contract(algorithm: str):
20
+ watermarker = create_watermarker(
21
+ WatermarkConfig(algorithm=algorithm, key=42, context_width=4)
22
+ )
23
+ logits = torch.zeros(2, 128)
24
+ contexts = torch.tensor([[1, 2, 3, 4], [4, 5, 6, 7]])
25
+ random_sample = lambda sample_logits: sample_logits.argmax(dim=-1)
26
+
27
+ first = watermarker.sample(logits, contexts, random_sample)
28
+ second = watermarker.sample(logits, contexts, random_sample)
29
+
30
+ assert first.token_ids.shape == (2,)
31
+ assert first.logits.shape == logits.shape
32
+ assert torch.equal(first.token_ids, second.token_ids)
33
+
34
+
35
+def test_gumbel_config_warns_about_degenerate_generations(monkeypatch):
36
+ messages: list[str] = []
37
+ monkeypatch.setattr(
38
+ "vllm.config.watermarking.logger.warning_once",
39
+ lambda message, *, scope: messages.append(message),
40
+ )
41
+
42
+ WatermarkConfig(key=42)
43
+
44
+ assert messages == [
45
+ "Single-key Gumbel-max watermarking may increase the frequency of "
46
+ "degenerate generations, including repetition loops."
47
+ ]
48
+
49
+
50
+def test_large_context_width_warns_but_is_allowed():
51
+ config = WatermarkConfig(key=42, context_width=17)
52
+
53
+ with pytest.warns(UserWarning, match="reduce robustness to edits"):
54
+ watermarker = create_watermarker(config)
55
+
56
+ assert watermarker.context_width == 17
57
+
58
+
59
+def test_sampling_params_can_disable_watermarking():
60
+ assert SamplingParams().watermarking
61
+ assert not SamplingParams.from_optional(watermarking=False).watermarking
62
+
63
+
64
+def test_gpu_sampler_warns_when_watermarking_is_enabled_for_greedy(monkeypatch):
65
+ sampler = object.__new__(GPUWatermarkSampler)
66
+ sampler.watermarking = SimpleNamespace(np=np.ones(1, dtype=bool))
67
+ messages: list[str] = []
68
+ monkeypatch.setattr(Sampler, "add_request", lambda *args, **kwargs: None)
69
+ monkeypatch.setattr(
70
+ "vllm.v1.watermarking.gpu_sampler.logger.warning_once", messages.append
71
+ )
72
+
73
+ sampler.add_request(0, 1, SamplingParams(temperature=0))
74
+ sampler.add_request(0, 1, SamplingParams(temperature=1))
75
+ sampler.add_request(0, 1, SamplingParams(temperature=0, watermarking=False))
76
+
77
+ assert messages == [
78
+ (
79
+ "Watermarking is enabled, but greedy decoding (temperature=0) cannot be "
80
+ "watermarked. This request will use ordinary greedy sampling."
81
+ )
82
+ ]
83
+
84
+
85
+def test_gpu_sampler_respects_mixed_request_watermarking(monkeypatch):
86
+ class StubWatermarker:
87
+ context_width = 1
88
+
89
+ def sample(self, logits, contexts, random_sample):
90
+ return WatermarkSample(torch.tensor([7, 7]), logits + 10)
91
+
92
+ sampler = object.__new__(GPUWatermarkSampler)
93
+ sampler.watermarker = StubWatermarker()
94
+ sampler.watermarking = SimpleNamespace(
95
+ np=np.array([True, False]), gpu=torch.tensor([True, False])
96
+ )
97
+ sampler.sampling_states = SimpleNamespace(
98
+ temperature=SimpleNamespace(np=np.ones(2), gpu=torch.ones(2)),
99
+ seeds=SimpleNamespace(gpu=torch.zeros(2, dtype=torch.int64)),
100
+ )
101
+ sampler.use_fp64_gumbel = False
102
+ sampler._get_contexts = lambda expanded_idx_mapping: torch.zeros(
103
+ 2, 1, dtype=torch.int64
104
+ )
105
+ monkeypatch.setattr(
106
+ "vllm.v1.watermarking.gpu_sampler.gumbel_sample",
107
+ lambda *args, **kwargs: torch.tensor([3, 4]),
108
+ )
109
+ logits = torch.zeros(2, 8)
110
+
111
+ sampled, output_logits = sampler._sample_random(
112
+ logits,
113
+ torch.tensor([0, 1]),
114
+ np.array([0, 1]),
115
+ torch.zeros(2, dtype=torch.int64),
116
+ None,
117
+ None,
118
+ False,
119
+ )
120
+
121
+ assert torch.equal(sampled, torch.tensor([7, 4]))
122
+ assert torch.equal(output_logits[0], torch.full((8,), 10.0))
123
+ assert torch.equal(output_logits[1], logits[1])
124
+
125
+
126
+def test_gpu_sampler_skips_watermarking_for_greedy_batch(monkeypatch):
127
+ class StubWatermarker:
128
+ context_width = 1
129
+
130
+ def sample(self, logits, contexts, random_sample):
131
+ raise AssertionError("watermarker should not run for greedy requests")
132
+
133
+ sampler = object.__new__(GPUWatermarkSampler)
134
+ sampler.watermarker = StubWatermarker()
135
+ sampler.watermarking = SimpleNamespace(
136
+ np=np.array([True, True]), gpu=torch.tensor([True, True])
137
+ )
138
+ sampler.sampling_states = SimpleNamespace(
139
+ temperature=SimpleNamespace(np=np.zeros(2), gpu=torch.zeros(2)),
140
+ )
141
+ expected = (torch.tensor([3, 4]), torch.zeros(2, 8))
142
+ monkeypatch.setattr(
143
+ "vllm.v1.watermarking.gpu_sampler.Sampler._sample_random",
144
+ lambda *args, **kwargs: expected,
145
+ )
146
+
147
+ actual = sampler._sample_random(
148
+ torch.zeros(2, 8),
149
+ torch.tensor([0, 1]),
150
+ np.array([0, 1]),
151
+ torch.zeros(2, dtype=torch.int64),
152
+ None,
153
+ None,
154
+ False,
155
+ )
156
+
157
+ assert actual is expected
vllm/config/__init__.py
+2
@@ -60,6 +60,7 @@ from vllm.config.vllm import (
60
get_layers_from_vllm_config,
61
set_current_vllm_config,
62
)
63
+from vllm.config.watermarking import WatermarkConfig
64
from vllm.config.weight_transfer import WeightTransferConfig
65
66
# __all__ should only contain classes and functions.
@@ -146,4 +147,5 @@ __all__ = [
147
"set_current_vllm_config",
148
"get_layers_from_vllm_config",
149
"WeightTransferConfig",
150
+ "WatermarkConfig",
151
]
vllm/config/vllm.py
+36
@@ -51,6 +51,7 @@ from .scheduler import SchedulerConfig
51
from .speculative import EagleModelTypes, NgramGPUTypes, SpeculativeConfig
52
from .structured_outputs import StructuredOutputsConfig
53
from .utils import SupportsHash, config, replace
54
+from .watermarking import WatermarkConfig
55
from .weight_transfer import WeightTransferConfig
56
57
if TYPE_CHECKING:
@@ -381,6 +382,8 @@ class VllmConfig:
382
"""LoRA configuration."""
383
speculative_config: SpeculativeConfig | None = None
384
"""Speculative decoding configuration."""
385
+ watermark_config: WatermarkConfig | None = None
386
+ """Text watermarking configuration."""
387
diffusion_config: DiffusionConfig | None = None
388
"""Diffusion LLM (dLLM) configuration."""
389
@@ -662,6 +665,14 @@ class VllmConfig:
665
666
@property
667
def use_v2_model_runner(self) -> bool:
668
+ if getattr(self, "watermark_config", None) is not None:
669
+ if envs.VLLM_USE_V2_MODEL_RUNNER is False:
670
+ logger.info_once(
671
+ "Watermarking requires Model Runner V2 and overrides "
672
+ "VLLM_USE_V2_MODEL_RUNNER=0."
673
+ )
674
+ return True
675
+
676
use_v2_model_runner = envs.VLLM_USE_V2_MODEL_RUNNER
677
if use_v2_model_runner is not None:
678
return use_v2_model_runner
@@ -1091,6 +1102,30 @@ class VllmConfig:
1102
if not self.use_v2_model_runner:
1103
raise ValueError("trace replay requires Model Runner V2")
1104
1105
+ def _check_watermarking_unsupported(
1106
+ self,
1107
+ *,
1108
+ beam_search: bool = False,
1109
+ custom_sampler: bool = False,
1110
+ ) -> None:
1111
+ watermark_config = getattr(self, "watermark_config", None)
1112
+ if watermark_config is None:
1113
+ return
1114
+ if (
1115
+ self.speculative_config is not None
1116
+ and not watermark_config.supports_speculative_decoding
1117
+ ):
1118
+ raise ValueError(
1119
+ f"The {watermark_config.algorithm} watermarking algorithm "
1120
+ "does not support speculative decoding."
1121
+ )
1122
+ if beam_search:
1123
+ raise ValueError("Beam search is not supported with watermarking.")
1124
+ if custom_sampler:
1125
+ raise ValueError(
1126
+ "Model-specific custom samplers are not supported with watermarking."
1127
+ )
1128
+
1129
def _resolve_and_verify_engram_config(self) -> None:
1130
"""Resolve legacy offload settings and validate model and parallel configs."""
1131
if self.engram_config is None:
@@ -1124,6 +1159,7 @@ class VllmConfig:
1159
self.try_verify_and_update_config()
1160
self._resolve_and_verify_engram_config()
1161
1162
+ self._check_watermarking_unsupported()
1163
# Models may have supplied their own DCP defaults above; anything still
1164
# unset falls back to the stock ones.
1165
self.parallel_config.set_dcp_defaults()
vllm/config/watermarking.py
new
+49
@@ -0,0 +1,49 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from typing import Literal
5
+
6
+from pydantic import Field, model_validator
7
+from typing_extensions import Self
8
+
9
+from vllm.config.utils import config
10
+from vllm.logger import init_logger
11
+
12
+logger = init_logger(__name__)
13
+
14
+WatermarkingAlgorithm = Literal["gumbel"]
15
+WatermarkPRFName = Literal["philox"]
16
+
17
+_SPECULATIVE_DECODING_SUPPORT: dict[WatermarkingAlgorithm, bool] = {
18
+ "gumbel": False,
19
+}
20
+
21
+
22
+@config
23
+class WatermarkConfig:
24
+ """Configuration for text watermark generation."""
25
+
26
+ key: int = Field(ge=0, repr=False, exclude=True)
27
+ """Secret key used to watermark generated text."""
28
+ algorithm: WatermarkingAlgorithm = "gumbel"
29
+ """Algorithm used to watermark generated text."""
30
+ context_width: int = Field(default=4, ge=1)
31
+ """Number of prior output tokens used by the watermark PRF."""
32
+ prf: WatermarkPRFName = "philox"
33
+ """Pseudorandom function used by the watermarking algorithm."""
34
+
35
+ @model_validator(mode="after")
36
+ def validate_key(self) -> Self:
37
+ if self.key > 2**64 - 1:
38
+ raise ValueError("philox keys must fit in 64 bits")
39
+ if self.algorithm == "gumbel":
40
+ logger.warning_once(
41
+ "Single-key Gumbel-max watermarking may increase the frequency of "
42
+ "degenerate generations, including repetition loops.",
43
+ scope="global",
44
+ )
45
+ return self
46
+
47
+ @property
48
+ def supports_speculative_decoding(self) -> bool:
49
+ return _SPECULATIVE_DECODING_SUPPORT[self.algorithm]
vllm/engine/arg_utils.py
+14
@@ -106,6 +106,7 @@ from vllm.config.parallel import (
106
from vllm.config.scheduler import SchedulerPolicy
107
from vllm.config.utils import get_field
108
from vllm.config.vllm import OptimizationLevel, PerformanceMode
109
+from vllm.config.watermarking import WatermarkConfig
110
from vllm.logger import init_logger, suppress_logging
111
from vllm.platforms import CpuArchEnum, current_platform
112
from vllm.plugins import load_general_plugins
@@ -656,6 +657,7 @@ class EngineArgs:
657
spec_method: str | None = None
658
spec_model: str | None = None
659
spec_tokens: int | None = None
660
+ watermark_config: dict[str, Any] | None = None
661
diffusion_config: dict[str, Any] | None = None
662
663
show_hidden_metrics_for_version: str | None = (
@@ -1680,6 +1682,8 @@ class EngineArgs:
1682
vllm_group.add_argument(
1683
"--spec-tokens", **speculative_kwargs["num_speculative_tokens"]
1684
)
1685
+ vllm_kwargs["watermark_config"]["type"] = optional_type(json.loads)
1686
+ vllm_group.add_argument("--watermark-config", **vllm_kwargs["watermark_config"])
1687
vllm_kwargs["diffusion_config"]["type"] = optional_type(json.loads)
1688
vllm_group.add_argument(
1689
"--diffusion-config", "-dc", **vllm_kwargs["diffusion_config"]
@@ -1986,6 +1990,14 @@ class EngineArgs:
1990
cfg = json.loads(cfg)
1991
return DiffusionConfig(**cfg)
1992
1993
+ def create_watermark_config(self) -> WatermarkConfig | None:
1994
+ if self.watermark_config is None:
1995
+ return None
1996
+ cfg = self.watermark_config
1997
+ if isinstance(cfg, str):
1998
+ cfg = json.loads(cfg)
1999
+ return WatermarkConfig(**cfg)
2000
+
2001
def create_observability_config(self) -> ObservabilityConfig:
2002
return ObservabilityConfig(
2003
show_hidden_metrics_for_version=self.show_hidden_metrics_for_version,
@@ -2366,6 +2378,7 @@ class EngineArgs:
2378
target_parallel_config=parallel_config,
2379
)
2380
diffusion_config = self.create_diffusion_config()
2381
+ watermark_config = self.create_watermark_config()
2382
2383
self._set_default_max_num_seqs_and_batched_tokens_args(
2384
usage_context,
@@ -2602,6 +2615,7 @@ class EngineArgs:
2615
kernel_config=kernel_config,
2616
lora_config=lora_config,
2617
speculative_config=speculative_config,
2618
+ watermark_config=watermark_config,
2619
diffusion_config=diffusion_config,
2620
structured_outputs_config=self.structured_outputs_config,
2621
observability_config=observability_config,
vllm/entrypoints/generate/beam_search/offline.py
+1
@@ -82,6 +82,7 @@ class BeamSearchOfflineMixin(OfflineInferenceMixin):
82
temperature = params.temperature
83
ignore_eos = params.ignore_eos
84
length_penalty = params.length_penalty
85
+ self.llm_engine.vllm_config._check_watermarking_unsupported(beam_search=True)
86
87
tokenizer = self.renderer.get_tokenizer()
88
eos_token_id = tokenizer.eos_token_id
vllm/entrypoints/openai/chat_completion/protocol.py
+2
@@ -267,6 +267,7 @@ class ChatCompletionRequest(OpenAIBaseModel):
267
top_k: int | None = None
268
min_p: float | None = None
269
repetition_penalty: float | None = None
270
+ watermarking: bool = True
271
length_penalty: float = 1.0
272
stop_token_ids: list[int] | None = []
273
include_stop_str_in_output: bool = False
@@ -724,6 +725,7 @@ class ChatCompletionRequest(OpenAIBaseModel):
725
frequency_penalty=self.frequency_penalty,
726
repetition_penalty=repetition_penalty,
727
temperature=temperature,
728
+ watermarking=self.watermarking,
729
top_p=top_p,
730
top_k=top_k,
731
min_p=min_p,
vllm/entrypoints/openai/completion/protocol.py
+2
@@ -75,6 +75,7 @@ class CompletionRequest(OpenAIBaseModel):
75
top_k: int | None = None
76
min_p: float | None = None
77
repetition_penalty: float | None = None
78
+ watermarking: bool = True
79
length_penalty: float = 1.0
80
stop_token_ids: list[int] | None = []
81
include_stop_str_in_output: bool = False
@@ -373,6 +374,7 @@ class CompletionRequest(OpenAIBaseModel):
374
frequency_penalty=self.frequency_penalty,
375
repetition_penalty=repetition_penalty,
376
temperature=temperature,
377
+ watermarking=self.watermarking,
378
top_p=top_p,
379
top_k=top_k,
380
min_p=min_p,
vllm/entrypoints/openai/responses/protocol.py
+2
@@ -213,6 +213,7 @@ class ResponsesRequest(OpenAIBaseModel):
213
)
214
215
# --8<-- [start:responses-extra-params]
216
+ watermarking: bool = True
217
request_id: str = Field(
218
default_factory=lambda: f"resp_{random_uuid()}",
219
description=(
@@ -436,6 +437,7 @@ class ResponsesRequest(OpenAIBaseModel):
437
438
return SamplingParams.from_optional(
439
temperature=temperature,
440
+ watermarking=self.watermarking,
441
top_p=top_p,
442
top_k=top_k,
443
max_tokens=max_tokens,
vllm/entrypoints/serve/utils/api_utils.py
+1
-1
@@ -268,7 +268,7 @@ def jsonify_non_default_args(
268
269
270
# Fields whose values must never be logged verbatim.
271
-_SENSITIVE_ARG_FIELDS = frozenset({"api_key", "hf_token"})
271
+_SENSITIVE_ARG_FIELDS = frozenset({"api_key", "hf_token", "watermark_config"})
272
273
274
def redact_sensitive_args(args: dict[str, Any]) -> dict[str, Any]:
vllm/sampling_params.py
+5
@@ -253,6 +253,8 @@ class SamplingParams(
253
"""Controls the randomness of the sampling. Lower values make the model
254
more deterministic, while higher values make the model more random. Zero
255
means greedy sampling."""
256
+ watermarking: bool = True
257
+ """Whether to apply the engine's configured watermark to this request."""
258
top_p: float = 1.0
259
"""Controls the cumulative probability of the top tokens to consider. Must
260
be in (0, 1]. Set to 1 to consider all tokens."""
@@ -385,6 +387,7 @@ class SamplingParams(
387
frequency_penalty: float | None = 0.0,
388
repetition_penalty: float | None = 1.0,
389
temperature: float | None = 1.0,
390
+ watermarking: bool = True,
391
top_p: float | None = 1.0,
392
top_k: int = 0,
393
min_p: float = 0.0,
@@ -450,6 +453,7 @@ class SamplingParams(
453
if repetition_penalty is None
454
else repetition_penalty,
455
temperature=1.0 if temperature is None else temperature,
456
+ watermarking=watermarking,
457
top_p=1.0 if top_p is None else top_p,
458
top_k=top_k,
459
min_p=min_p,
@@ -1253,6 +1257,7 @@ class SamplingParams(
1257
f"frequency_penalty={self.frequency_penalty}, "
1258
f"repetition_penalty={self.repetition_penalty}, "
1259
f"temperature={self.temperature}, "
1260
+ f"watermarking={self.watermarking}, "
1261
f"top_p={self.top_p}, "
1262
f"top_k={self.top_k}, "
1263
f"min_p={self.min_p}, "
vllm/v1/watermarking/__init__.py
new
+28
@@ -0,0 +1,28 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.v1.watermarking.detector import WatermarkDetection, WatermarkDetector
5
+from vllm.v1.watermarking.factory import create_watermarker
6
+from vllm.v1.watermarking.gumbel import (
7
+ GumbelWatermarkDetector,
8
+ GumbelWatermarker,
9
+)
10
+from vllm.v1.watermarking.prfs import (
11
+ PhiloxPRF,
12
+ WatermarkPRF,
13
+ create_prf,
14
+)
15
+from vllm.v1.watermarking.watermarker import Watermarker, WatermarkSample
16
+
17
+__all__ = [
18
+ "GumbelWatermarkDetector",
19
+ "GumbelWatermarker",
20
+ "PhiloxPRF",
21
+ "WatermarkDetection",
22
+ "WatermarkDetector",
23
+ "WatermarkPRF",
24
+ "Watermarker",
25
+ "WatermarkSample",
26
+ "create_watermarker",
27
+ "create_prf",
28
+]
vllm/v1/watermarking/detector.py
new
+100
@@ -0,0 +1,100 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from abc import ABC, abstractmethod
5
+from dataclasses import dataclass
6
+from typing import TypeAlias
7
+
8
+import torch
9
+
10
+Context: TypeAlias = tuple[int, ...]
11
+
12
+
13
+@dataclass(frozen=True)
14
+class WatermarkDetection:
15
+ score: float
16
+ p_value: float
17
+ num_scored_tokens: int
18
+ is_watermarked: bool
19
+
20
+
21
+class WatermarkDetector(ABC):
22
+ def __init__(
23
+ self,
24
+ context_width: int,
25
+ p_value_threshold: float,
26
+ deduplicate_contexts: bool = False,
27
+ ) -> None:
28
+ if context_width < 1:
29
+ raise ValueError("context_width must be positive")
30
+ if not 0 < p_value_threshold < 1:
31
+ raise ValueError("p_value_threshold must be between 0 and 1")
32
+ self.context_width = context_width
33
+ self.p_value_threshold = p_value_threshold
34
+ self.deduplicate_contexts = deduplicate_contexts
35
+
36
+ def detect(self, token_ids: list[int]) -> WatermarkDetection:
37
+ contexts, targets = self._prepare_inputs(token_ids)
38
+ num_scored_tokens = len(targets)
39
+ if num_scored_tokens == 0:
40
+ return WatermarkDetection(0.0, 1.0, 0, False)
41
+
42
+ token_scores = self._score_tokens(contexts, targets)
43
+ score = self._aggregate_scores(token_scores)
44
+ p_value = self._get_p_value(score, num_scored_tokens)
45
+ return WatermarkDetection(
46
+ score=score,
47
+ p_value=p_value,
48
+ num_scored_tokens=num_scored_tokens,
49
+ is_watermarked=p_value <= self.p_value_threshold,
50
+ )
51
+
52
+ def _prepare_inputs(
53
+ self, token_ids: list[int]
54
+ ) -> tuple[torch.Tensor, torch.Tensor]:
55
+ contexts: list[Context] = []
56
+ targets = []
57
+ seen_contexts: set[Context] = set()
58
+ prefix = [-1] * self.context_width
59
+ for token_id in token_ids:
60
+ context = tuple(prefix[-self.context_width :])
61
+ # Reusing a context reuses one keyed random vector, so the resulting
62
+ # token scores are not independent evidence.
63
+ if not self.deduplicate_contexts or not _is_repeated_context(
64
+ context, seen_contexts
65
+ ):
66
+ contexts.append(context)
67
+ targets.append(token_id)
68
+ prefix.append(token_id)
69
+
70
+ if not contexts:
71
+ return (
72
+ torch.empty((0, self.context_width), dtype=torch.int64),
73
+ torch.empty(0, dtype=torch.int64),
74
+ )
75
+
76
+ return (
77
+ torch.tensor(contexts, dtype=torch.int64).reshape(-1, self.context_width),
78
+ torch.tensor(targets, dtype=torch.int64),
79
+ )
80
+
81
+ @abstractmethod
82
+ def _score_tokens(
83
+ self, contexts: torch.Tensor, targets: torch.Tensor
84
+ ) -> torch.Tensor:
85
+ raise NotImplementedError
86
+
87
+ @abstractmethod
88
+ def _aggregate_scores(self, token_scores: torch.Tensor) -> float:
89
+ raise NotImplementedError
90
+
91
+ @abstractmethod
92
+ def _get_p_value(self, score: float, num_scored_tokens: int) -> float:
93
+ raise NotImplementedError
94
+
95
+
96
+def _is_repeated_context(context: Context, seen_contexts: set[Context]) -> bool:
97
+ if context in seen_contexts:
98
+ return True
99
+ seen_contexts.add(context)
100
+ return False
vllm/v1/watermarking/factory.py
new
+24
@@ -0,0 +1,24 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from collections.abc import Callable
5
+
6
+from vllm.config.watermarking import WatermarkConfig
7
+from vllm.v1.watermarking.gumbel import GumbelWatermarker
8
+from vllm.v1.watermarking.watermarker import Watermarker
9
+
10
+
11
+def _create_gumbel(config: WatermarkConfig) -> Watermarker:
12
+ return GumbelWatermarker(config.key, config.context_width, config.prf)
13
+
14
+
15
+_WATERMARKERS: dict[str, Callable[[WatermarkConfig], Watermarker]] = {
16
+ "gumbel": _create_gumbel,
17
+}
18
+
19
+
20
+def create_watermarker(config: WatermarkConfig) -> Watermarker:
21
+ watermarker_factory = _WATERMARKERS.get(config.algorithm)
22
+ if watermarker_factory is None:
23
+ raise ValueError(f"Unknown watermarking algorithm: {config.algorithm}")
24
+ return watermarker_factory(config)
vllm/v1/watermarking/gpu_sampler.py
new
+131
@@ -0,0 +1,131 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import numpy as np
5
+import torch
6
+
7
+from vllm.logger import init_logger
8
+from vllm.sampling_params import SamplingParams
9
+from vllm.v1.sample.ops.topk_topp_sampler import apply_top_k_top_p
10
+from vllm.v1.watermarking.watermarker import Watermarker
11
+from vllm.v1.worker.gpu.buffer_utils import UvaBackedTensor
12
+from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample
13
+from vllm.v1.worker.gpu.sample.sampler import Sampler
14
+
15
+logger = init_logger(__name__)
16
+
17
+
18
+class GPUWatermarkSampler(Sampler):
19
+ def __init__(
20
+ self,
21
+ watermarker: Watermarker,
22
+ *args,
23
+ **kwargs,
24
+ ) -> None:
25
+ super().__init__(*args, **kwargs)
26
+ self.watermarker = watermarker
27
+ self.watermarking = UvaBackedTensor(
28
+ self.sampling_states.max_num_reqs, dtype=torch.bool
29
+ )
30
+ self.watermarking.np.fill(True)
31
+ self.watermarking.copy_to_uva()
32
+
33
+ def add_request(
34
+ self, req_idx: int, prompt_len: int, sampling_params: SamplingParams
35
+ ) -> None:
36
+ super().add_request(req_idx, prompt_len, sampling_params)
37
+ self.watermarking.np[req_idx] = sampling_params.watermarking
38
+ if sampling_params.watermarking and sampling_params.temperature == 0:
39
+ logger.warning_once(
40
+ "Watermarking is enabled, but greedy decoding "
41
+ "(temperature=0) cannot be watermarked. This request will use "
42
+ "ordinary greedy sampling."
43
+ )
44
+
45
+ def apply_staged_writes(self) -> None:
46
+ super().apply_staged_writes()
47
+ self.watermarking.copy_to_uva()
48
+
49
+ def _sample_random(
50
+ self,
51
+ processed_logits: torch.Tensor,
52
+ expanded_idx_mapping: torch.Tensor,
53
+ idx_mapping_np: np.ndarray,
54
+ pos: torch.Tensor,
55
+ top_k: torch.Tensor | None,
56
+ top_p: torch.Tensor | None,
57
+ use_flashinfer: bool,
58
+ ) -> tuple[torch.Tensor, torch.Tensor]:
59
+ enabled = self.watermarking.np[idx_mapping_np] & (
60
+ self.sampling_states.temperature.np[idx_mapping_np] != 0
61
+ )
62
+ if not np.any(enabled):
63
+ return super()._sample_random(
64
+ processed_logits,
65
+ expanded_idx_mapping,
66
+ idx_mapping_np,
67
+ pos,
68
+ top_k,
69
+ top_p,
70
+ use_flashinfer,
71
+ )
72
+
73
+ processed_logits = apply_top_k_top_p(processed_logits, top_k, top_p)
74
+ contexts = self._get_contexts(expanded_idx_mapping)
75
+
76
+ def random_sample(sample_logits: torch.Tensor) -> torch.Tensor:
77
+ return gumbel_sample(
78
+ sample_logits,
79
+ expanded_idx_mapping,
80
+ self.sampling_states.temperature.gpu,
81
+ self.sampling_states.seeds.gpu,
82
+ pos,
83
+ apply_temperature=False,
84
+ is_drafting=False,
85
+ use_fp64=self.use_fp64_gumbel,
86
+ )
87
+
88
+ output = self.watermarker.sample(
89
+ processed_logits,
90
+ contexts,
91
+ random_sample,
92
+ )
93
+ temperatures = self.sampling_states.temperature.gpu[expanded_idx_mapping]
94
+ watermarking = self.watermarking.gpu[expanded_idx_mapping] & (temperatures != 0)
95
+ if not np.all(enabled):
96
+ unwatermarked = random_sample(processed_logits)
97
+ sampled = torch.where(watermarking, output.token_ids, unwatermarked)
98
+ output_logits = output.logits
99
+ if output.logits is not processed_logits:
100
+ output_logits = torch.where(
101
+ watermarking.unsqueeze(-1), output.logits, processed_logits
102
+ )
103
+ else:
104
+ sampled = output.token_ids
105
+ output_logits = output.logits
106
+ sampled = torch.where(
107
+ temperatures == 0,
108
+ processed_logits.argmax(dim=-1),
109
+ sampled,
110
+ )
111
+ return sampled, output_logits
112
+
113
+ def _get_contexts(self, expanded_idx_mapping: torch.Tensor) -> torch.Tensor:
114
+ context_width = self.watermarker.context_width
115
+ req_indices = expanded_idx_mapping.to(torch.int64)
116
+ valid_reqs = req_indices >= 0
117
+ safe_req_indices = req_indices.clamp_min(0)
118
+ total_lens = self.req_states.total_len.gpu[safe_req_indices].to(torch.int64)
119
+ prompt_lens = self.req_states.prompt_len.gpu[safe_req_indices].to(torch.int64)
120
+ offsets = torch.arange(
121
+ -context_width, 0, dtype=torch.int64, device=req_indices.device
122
+ )
123
+ positions = total_lens.unsqueeze(-1) + offsets
124
+ valid_positions = valid_reqs.unsqueeze(-1) & (
125
+ positions >= prompt_lens.unsqueeze(-1)
126
+ )
127
+ positions = positions.clamp_min(0)
128
+ contexts = self.req_states.all_token_ids.gpu[
129
+ safe_req_indices.unsqueeze(-1), positions
130
+ ]
131
+ return torch.where(valid_positions, contexts, -1)
vllm/v1/watermarking/gumbel.py
new
+111
@@ -0,0 +1,111 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""Gumbel-max watermark generation and detection primitives."""
5
+
6
+import math
7
+import warnings
8
+
9
+import torch
10
+
11
+from vllm.config.watermarking import WatermarkPRFName
12
+from vllm.v1.watermarking.detector import (
13
+ WatermarkDetector,
14
+)
15
+from vllm.v1.watermarking.prfs import PhiloxPRF, WatermarkPRF, create_prf
16
+from vllm.v1.watermarking.watermarker import (
17
+ RandomSampler,
18
+ Watermarker,
19
+ WatermarkSample,
20
+)
21
+
22
+
23
+def _gamma_survival_integer_shape(score: float, shape: int) -> float:
24
+ """Return the survival probability of a Gamma(shape, 1) variable."""
25
+ if shape <= 0 or score <= 0:
26
+ return 1.0
27
+ log_score = math.log(score)
28
+ log_term = 0.0
29
+ max_log_term = 0.0
30
+ log_terms = [log_term]
31
+ for index in range(1, shape):
32
+ log_term += log_score - math.log(index)
33
+ log_terms.append(log_term)
34
+ max_log_term = max(max_log_term, log_term)
35
+ log_sum = max_log_term + math.log(
36
+ sum(math.exp(term - max_log_term) for term in log_terms)
37
+ )
38
+ return min(1.0, math.exp(min(0.0, -score + log_sum)))
39
+
40
+
41
+class GumbelWatermarker(Watermarker):
42
+ def __init__(
43
+ self,
44
+ key: int,
45
+ context_width: int = 4,
46
+ prf: WatermarkPRF | WatermarkPRFName = "philox",
47
+ ) -> None:
48
+ prf = create_prf(prf, key) if isinstance(prf, str) else prf
49
+ _validate_context_width(context_width)
50
+ self.prf = prf
51
+ self._context_width = context_width
52
+
53
+ @property
54
+ def context_width(self) -> int:
55
+ return self._context_width
56
+
57
+ def sample(
58
+ self,
59
+ logits: torch.Tensor,
60
+ contexts: torch.Tensor,
61
+ _random_sample: RandomSampler,
62
+ ) -> WatermarkSample:
63
+ if type(self.prf) is PhiloxPRF and logits.device.type == "cuda":
64
+ from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
65
+
66
+ return WatermarkSample(
67
+ philox_gumbel_sample(logits, contexts, self.prf.key), logits
68
+ )
69
+ vocabulary = torch.arange(logits.shape[-1], device=logits.device)
70
+ uniforms = self.prf.uniform(contexts, vocabulary)
71
+ uniforms = uniforms.clamp_min(torch.finfo(torch.float32).tiny)
72
+ noise = -torch.log(-torch.log(uniforms))
73
+ return WatermarkSample(torch.argmax(logits + noise, dim=-1), logits)
74
+
75
+
76
+class GumbelWatermarkDetector(WatermarkDetector):
77
+ def __init__(
78
+ self,
79
+ key: int,
80
+ context_width: int = 4,
81
+ p_value_threshold: float = 0.01,
82
+ prf: WatermarkPRF | WatermarkPRFName = "philox",
83
+ deduplicate_contexts: bool = True,
84
+ ) -> None:
85
+ prf = create_prf(prf, key) if isinstance(prf, str) else prf
86
+ _validate_context_width(context_width)
87
+ super().__init__(context_width, p_value_threshold, deduplicate_contexts)
88
+ self.prf = prf
89
+
90
+ def _score_tokens(
91
+ self, contexts: torch.Tensor, targets: torch.Tensor
92
+ ) -> torch.Tensor:
93
+ uniforms = self.prf.uniform(contexts, targets.unsqueeze(-1)).squeeze(-1)
94
+ return -torch.log1p(-uniforms.to(torch.float64))
95
+
96
+ def _get_p_value(self, score: float, num_scored_tokens: int) -> float:
97
+ return _gamma_survival_integer_shape(score, num_scored_tokens)
98
+
99
+ def _aggregate_scores(self, token_scores: torch.Tensor) -> float:
100
+ return token_scores.sum().item()
101
+
102
+
103
+def _validate_context_width(context_width: int) -> None:
104
+ if context_width < 1:
105
+ raise ValueError("context_width must be positive")
106
+ if context_width > 16:
107
+ warnings.warn(
108
+ "context_width values greater than 16 reduce robustness to edits because "
109
+ "each changed token affects more subsequent watermark contexts",
110
+ stacklevel=3,
111
+ )
vllm/v1/watermarking/prfs/__init__.py
new
+19
@@ -0,0 +1,19 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.config.watermarking import WatermarkPRFName
5
+from vllm.v1.watermarking.prfs.base import WatermarkPRF
6
+from vllm.v1.watermarking.prfs.philox import PhiloxPRF
7
+
8
+
9
+def create_prf(name: WatermarkPRFName, key: int) -> WatermarkPRF:
10
+ if name == "philox":
11
+ return PhiloxPRF(key)
12
+ raise ValueError(f"Unknown watermark PRF: {name}")
13
+
14
+
15
+__all__ = [
16
+ "PhiloxPRF",
17
+ "WatermarkPRF",
18
+ "create_prf",
19
+]
vllm/v1/watermarking/prfs/base.py
new
+19
@@ -0,0 +1,19 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from abc import ABC, abstractmethod
5
+
6
+import torch
7
+
8
+_UINT32_MASK = 2**32 - 1
9
+
10
+
11
+class WatermarkPRF(ABC):
12
+ @abstractmethod
13
+ def uniform(self, contexts: torch.Tensor, token_ids: torch.Tensor) -> torch.Tensor:
14
+ raise NotImplementedError
15
+
16
+
17
+def uint32_to_uniform(values: torch.Tensor) -> torch.Tensor:
18
+ mantissas = (values & _UINT32_MASK) >> 8
19
+ return ((mantissas.to(torch.float64) + 1.0) / (2**24 + 1)).to(torch.float32)
vllm/v1/watermarking/prfs/philox.py
new
+145
@@ -0,0 +1,145 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""Philox4x32-10 as defined by https://doi.org/10.1145/2063384.2063405."""
5
+
6
+import torch
7
+
8
+from vllm.v1.watermarking.prfs.base import WatermarkPRF, uint32_to_uniform
9
+
10
+_UINT32_MASK = 2**32 - 1
11
+_PHILOX_M0 = 0xD2511F53
12
+_PHILOX_M1 = 0xCD9E8D57
13
+_PHILOX_W0 = 0x9E3779B9
14
+_PHILOX_W1 = 0xBB67AE85
15
+_CONTEXT_DOMAIN = 0x574D4358
16
+_TOKEN_DOMAIN = 0x574D544B
17
+
18
+
19
+def _mulhilo32(
20
+ multiplier: int, values: torch.Tensor
21
+) -> tuple[torch.Tensor, torch.Tensor]:
22
+ value_low = values & 0xFFFF
23
+ value_high = values >> 16
24
+ multiplier_low = multiplier & 0xFFFF
25
+ multiplier_high = multiplier >> 16
26
+
27
+ low_product = value_low * multiplier_low
28
+ cross_product = value_high * multiplier_low + value_low * multiplier_high
29
+ low_with_carry = low_product + ((cross_product & 0xFFFF) << 16)
30
+ low = low_with_carry & _UINT32_MASK
31
+ high = (
32
+ value_high * multiplier_high + (cross_product >> 16) + (low_with_carry >> 32)
33
+ ) & _UINT32_MASK
34
+ return high, low
35
+
36
+
37
+def _philox4x32_10(
38
+ counter: tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor],
39
+ key: tuple[torch.Tensor, torch.Tensor],
40
+) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
41
+ counter_0, counter_1, counter_2, counter_3 = counter
42
+ key_0, key_1 = key
43
+ for round_index in range(10):
44
+ high_0, low_0 = _mulhilo32(_PHILOX_M0, counter_0)
45
+ high_1, low_1 = _mulhilo32(_PHILOX_M1, counter_2)
46
+ counter_0, counter_1, counter_2, counter_3 = (
47
+ (high_1 ^ counter_1 ^ key_0) & _UINT32_MASK,
48
+ low_1,
49
+ (high_0 ^ counter_3 ^ key_1) & _UINT32_MASK,
50
+ low_0,
51
+ )
52
+ if round_index != 9:
53
+ key_0 = (key_0 + _PHILOX_W0) & _UINT32_MASK
54
+ key_1 = (key_1 + _PHILOX_W1) & _UINT32_MASK
55
+ return counter_0, counter_1, counter_2, counter_3
56
+
57
+
58
+def _philox_uniform(
59
+ key_0_value: int,
60
+ key_1_value: int,
61
+ contexts: torch.Tensor,
62
+ token_ids: torch.Tensor,
63
+) -> torch.Tensor:
64
+ contexts = contexts.to(torch.int64) & _UINT32_MASK
65
+ prefix_shape = contexts.shape[:-1]
66
+ device = contexts.device
67
+ key_0 = torch.full(prefix_shape, key_0_value, dtype=torch.int64, device=device)
68
+ key_1 = torch.full(prefix_shape, key_1_value, dtype=torch.int64, device=device)
69
+ state = (
70
+ torch.full(prefix_shape, _CONTEXT_DOMAIN, dtype=torch.int64, device=device),
71
+ torch.full(
72
+ prefix_shape,
73
+ contexts.shape[-1],
74
+ dtype=torch.int64,
75
+ device=device,
76
+ ),
77
+ torch.zeros(prefix_shape, dtype=torch.int64, device=device),
78
+ torch.zeros(prefix_shape, dtype=torch.int64, device=device),
79
+ )
80
+
81
+ for offset in range(0, contexts.shape[-1], 4):
82
+ block = []
83
+ for index in range(4):
84
+ position = offset + index
85
+ if position < contexts.shape[-1]:
86
+ block.append(contexts[..., position])
87
+ else:
88
+ block.append(
89
+ torch.full(
90
+ prefix_shape,
91
+ _UINT32_MASK - index,
92
+ dtype=torch.int64,
93
+ device=device,
94
+ )
95
+ )
96
+ counter = tuple(
97
+ (state[index] ^ block[index]) & _UINT32_MASK for index in range(4)
98
+ )
99
+ state = _philox4x32_10(counter, ((key_0 ^ offset) & _UINT32_MASK, key_1))
100
+
101
+ output_shape = torch.broadcast_shapes(prefix_shape + (1,), token_ids.shape)
102
+ tokens = torch.broadcast_to(token_ids.to(device=device), output_shape)
103
+ state = tuple(
104
+ torch.broadcast_to(word.unsqueeze(-1), output_shape) for word in state
105
+ )
106
+ candidate_key_0 = torch.broadcast_to(key_0.unsqueeze(-1), output_shape)
107
+ candidate_key_1 = torch.broadcast_to(key_1.unsqueeze(-1), output_shape)
108
+ token_words = tokens.to(torch.int64) & _UINT32_MASK
109
+ outputs = _philox4x32_10(
110
+ (
111
+ token_words >> 2,
112
+ state[0],
113
+ state[1],
114
+ state[2],
115
+ ),
116
+ (
117
+ (candidate_key_0 ^ state[3]) & _UINT32_MASK,
118
+ (candidate_key_1 ^ _TOKEN_DOMAIN) & _UINT32_MASK,
119
+ ),
120
+ )
121
+ word_index = token_words & 3
122
+ output = outputs[0]
123
+ for index in range(1, 4):
124
+ output = torch.where(word_index == index, outputs[index], output)
125
+ return uint32_to_uniform(output)
126
+
127
+
128
+_compiled_philox_uniform = torch.compile(_philox_uniform, fullgraph=True, dynamic=True)
129
+
130
+
131
+class PhiloxPRF(WatermarkPRF):
132
+ """Accelerator-friendly Philox4x32-10 watermark PRF."""
133
+
134
+ version = "philox4x32-10-v1"
135
+
136
+ def __init__(self, key: int) -> None:
137
+ if not 0 <= key <= 2**64 - 1:
138
+ raise ValueError("Philox keys must fit in 64 bits")
139
+ self.key = key
140
+
141
+ def uniform(self, contexts: torch.Tensor, token_ids: torch.Tensor) -> torch.Tensor:
142
+ key_words = self.key & _UINT32_MASK, self.key >> 32
143
+ if contexts.device.type == "cuda":
144
+ return _compiled_philox_uniform(*key_words, contexts, token_ids)
145
+ return _philox_uniform(*key_words, contexts, token_ids)
vllm/v1/watermarking/watermarker.py
new
+33
@@ -0,0 +1,33 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from abc import ABC, abstractmethod
5
+from collections.abc import Callable
6
+from dataclasses import dataclass
7
+from typing import TypeAlias
8
+
9
+import torch
10
+
11
+RandomSampler: TypeAlias = Callable[[torch.Tensor], torch.Tensor]
12
+
13
+
14
+@dataclass(frozen=True)
15
+class WatermarkSample:
16
+ token_ids: torch.Tensor
17
+ logits: torch.Tensor
18
+
19
+
20
+class Watermarker(ABC):
21
+ @property
22
+ @abstractmethod
23
+ def context_width(self) -> int:
24
+ raise NotImplementedError
25
+
26
+ @abstractmethod
27
+ def sample(
28
+ self,
29
+ logits: torch.Tensor,
30
+ contexts: torch.Tensor,
31
+ random_sample: RandomSampler,
32
+ ) -> WatermarkSample:
33
+ raise NotImplementedError
vllm/v1/worker/gpu/model_runner.py
+20
-12
@@ -78,6 +78,8 @@ from vllm.v1.outputs import (
78
ModelRunnerOutput,
79
RoutedExpertsTensors,
80
)
81
+from vllm.v1.watermarking import create_watermarker
82
+from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
83
from vllm.v1.worker.block_table import get_block_table_width
84
from vllm.v1.worker.cp_utils import check_attention_cp_compatibility
85
from vllm.v1.worker.gpu import pcp_manager as pcp
@@ -452,21 +454,27 @@ class GPUModelRunner(LoRAModelRunnerMixin):
454
455
# Initialize samplers. Model states may override via custom_sampler().
456
if self.is_last_pp_rank and not self.is_pooling_model:
455
- self.sampler = Sampler(
456
- max_num_reqs=self.max_num_reqs,
457
- vocab_size=self.vocab_size,
458
- device=self.device,
459
- req_states=self.req_states,
460
- logprobs_mode=self.model_config.logprobs_mode,
461
- num_speculative_tokens=self.decode_query_len,
462
- use_fp64_gumbel=self.model_config.use_fp64_gumbel,
463
- enable_trace_replay=self.model_config.enable_trace_replay,
464
- reasoning_config=self.vllm_config.reasoning_config,
465
- return_sampling_mask=self.model_config.return_sampling_mask,
466
- )
457
+ sampler_kwargs: dict[str, Any] = {
458
+ "max_num_reqs": self.max_num_reqs,
459
+ "vocab_size": self.vocab_size,
460
+ "device": self.device,
461
+ "req_states": self.req_states,
462
+ "logprobs_mode": self.model_config.logprobs_mode,
463
+ "num_speculative_tokens": self.decode_query_len,
464
+ "use_fp64_gumbel": self.model_config.use_fp64_gumbel,
465
+ "enable_trace_replay": self.model_config.enable_trace_replay,
466
+ "reasoning_config": self.vllm_config.reasoning_config,
467
+ "return_sampling_mask": self.model_config.return_sampling_mask,
468
+ }
469
+ if self.vllm_config.watermark_config is None:
470
+ self.sampler = Sampler(**sampler_kwargs)
471
+ else:
472
+ watermarker = create_watermarker(self.vllm_config.watermark_config)
473
+ self.sampler = GPUWatermarkSampler(watermarker, **sampler_kwargs)
474
custom = self.model_state.custom_sampler(self.sampler)
475
476
if custom:
477
+ self.vllm_config._check_watermarking_unsupported(custom_sampler=True)
478
self.sampler, self.rejection_sampler = custom
479
elif self.speculative_config is not None:
480
self.rejection_sampler = RejectionSampler(
vllm/v1/worker/gpu/sample/sampler.py
+20
-1
@@ -307,7 +307,26 @@ class Sampler:
307
or self.sampling_states.any_explicit_seed(idx_mapping_np)
308
)
309
310
- # Sample the next token.
310
+ return self._sample_random(
311
+ processed_logits,
312
+ expanded_idx_mapping,
313
+ idx_mapping_np,
314
+ pos,
315
+ top_k,
316
+ top_p,
317
+ use_flashinfer,
318
+ )
319
+
320
+ def _sample_random(
321
+ self,
322
+ processed_logits: torch.Tensor,
323
+ expanded_idx_mapping: torch.Tensor,
324
+ idx_mapping_np: np.ndarray,
325
+ pos: torch.Tensor,
326
+ top_k: torch.Tensor | None,
327
+ top_p: torch.Tensor | None,
328
+ use_flashinfer: bool,
329
+ ) -> tuple[torch.Tensor, torch.Tensor]:
330
if use_flashinfer:
331
sampled = flashinfer_sample(processed_logits, top_k, top_p).to(torch.int64)
332
else:
vllm/v1/worker/gpu/sample/watermark.py
new
+214
@@ -0,0 +1,214 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import torch
5
+
6
+from vllm.triton_utils import HAS_TRITON, tl, triton
7
+from vllm.v1.watermarking.prfs.philox import (
8
+ _CONTEXT_DOMAIN as _CONTEXT_DOMAIN_VALUE,
9
+)
10
+from vllm.v1.watermarking.prfs.philox import (
11
+ _PHILOX_M0 as _PHILOX_M0_VALUE,
12
+)
13
+from vllm.v1.watermarking.prfs.philox import (
14
+ _PHILOX_M1 as _PHILOX_M1_VALUE,
15
+)
16
+from vllm.v1.watermarking.prfs.philox import (
17
+ _PHILOX_W0 as _PHILOX_W0_VALUE,
18
+)
19
+from vllm.v1.watermarking.prfs.philox import (
20
+ _PHILOX_W1 as _PHILOX_W1_VALUE,
21
+)
22
+from vllm.v1.watermarking.prfs.philox import (
23
+ _TOKEN_DOMAIN as _TOKEN_DOMAIN_VALUE,
24
+)
25
+from vllm.v1.watermarking.prfs.philox import (
26
+ _UINT32_MASK as _UINT32_MASK_VALUE,
27
+)
28
+
29
+if HAS_TRITON:
30
+ from triton.language import math as tl_math
31
+else:
32
+ tl_math = tl
33
+
34
+_UINT32_MASK = tl.constexpr(_UINT32_MASK_VALUE) if HAS_TRITON else _UINT32_MASK_VALUE
35
+_PHILOX_M0 = tl.constexpr(_PHILOX_M0_VALUE) if HAS_TRITON else _PHILOX_M0_VALUE
36
+_PHILOX_M1 = tl.constexpr(_PHILOX_M1_VALUE) if HAS_TRITON else _PHILOX_M1_VALUE
37
+_PHILOX_W0 = tl.constexpr(_PHILOX_W0_VALUE) if HAS_TRITON else _PHILOX_W0_VALUE
38
+_PHILOX_W1 = tl.constexpr(_PHILOX_W1_VALUE) if HAS_TRITON else _PHILOX_W1_VALUE
39
+_CONTEXT_DOMAIN = (
40
+ tl.constexpr(_CONTEXT_DOMAIN_VALUE) if HAS_TRITON else _CONTEXT_DOMAIN_VALUE
41
+)
42
+_TOKEN_DOMAIN = tl.constexpr(_TOKEN_DOMAIN_VALUE) if HAS_TRITON else _TOKEN_DOMAIN_VALUE
43
+
44
+
45
+@triton.jit
46
+def _mulhilo32(multiplier: tl.constexpr, value):
47
+ high = tl_math.umulhi(multiplier, value)
48
+ low = tl.mul(multiplier, value, sanitize_overflow=False)
49
+ return high, low
50
+
51
+
52
+@triton.jit
53
+def _philox4x32_10(counter_0, counter_1, counter_2, counter_3, key_0, key_1):
54
+ for round_index in range(10):
55
+ high_0, low_0 = _mulhilo32(_PHILOX_M0, counter_0)
56
+ high_1, low_1 = _mulhilo32(_PHILOX_M1, counter_2)
57
+ counter_0, counter_1, counter_2, counter_3 = (
58
+ (high_1 ^ counter_1 ^ key_0) & _UINT32_MASK,
59
+ low_1,
60
+ (high_0 ^ counter_3 ^ key_1) & _UINT32_MASK,
61
+ low_0,
62
+ )
63
+ if round_index != 9:
64
+ key_0 = (key_0 + _PHILOX_W0) & _UINT32_MASK
65
+ key_1 = (key_1 + _PHILOX_W1) & _UINT32_MASK
66
+ return counter_0, counter_1, counter_2, counter_3
67
+
68
+
69
+@triton.jit
70
+def _uint32_to_uniform(value):
71
+ mantissa = (value & _UINT32_MASK) >> 8
72
+ scaled = (mantissa + 1).to(tl.float32) * 2**-24
73
+ return (scaled.to(tl.uint32, bitcast=True) - 1).to(tl.float32, bitcast=True)
74
+
75
+
76
+@triton.jit
77
+def _gumbel_value(logits_ptr, output, mask):
78
+ logits = tl.load(logits_ptr, mask=mask, other=float("-inf")).to(tl.float32)
79
+ logits = tl.where(logits != logits, float("-inf"), logits)
80
+ uniform = _uint32_to_uniform(output)
81
+ return logits - tl.log(-tl.log(uniform))
82
+
83
+
84
+@triton.jit
85
+def _select_max(best_value, best_token, candidate_value, candidate):
86
+ take_candidate = candidate_value > best_value
87
+ return (
88
+ tl.where(take_candidate, candidate_value, best_value),
89
+ tl.where(take_candidate, candidate, best_token),
90
+ )
91
+
92
+
93
+@triton.jit(do_not_specialize=["key_0_value", "key_1_value"])
94
+def _philox_gumbel_kernel(
95
+ local_argmax_ptr,
96
+ local_argmax_stride,
97
+ local_max_ptr,
98
+ local_max_stride,
99
+ logits_ptr,
100
+ logits_stride,
101
+ contexts_ptr,
102
+ context_stride,
103
+ key_0_value,
104
+ key_1_value,
105
+ vocab_size,
106
+ CONTEXT_WIDTH: tl.constexpr,
107
+ BLOCK_SIZE: tl.constexpr,
108
+):
109
+ row = tl.program_id(0).to(tl.int64)
110
+ block_index = tl.program_id(1)
111
+ groups = block_index * (BLOCK_SIZE // 4) + tl.arange(0, BLOCK_SIZE // 4)
112
+
113
+ key_0 = key_0_value.to(tl.uint32)
114
+ key_1 = key_1_value.to(tl.uint32)
115
+ state_0 = tl.full((), _CONTEXT_DOMAIN, tl.uint32)
116
+ state_1 = tl.full((), CONTEXT_WIDTH, tl.uint32)
117
+ state_2 = tl.full((), 0, tl.uint32)
118
+ state_3 = tl.full((), 0, tl.uint32)
119
+ for offset in range(0, CONTEXT_WIDTH, 4):
120
+ context_0 = tl.load(contexts_ptr + row * context_stride + offset).to(tl.uint32)
121
+ if offset + 1 < CONTEXT_WIDTH:
122
+ context_1 = tl.load(contexts_ptr + row * context_stride + offset + 1).to(
123
+ tl.uint32
124
+ )
125
+ else:
126
+ context_1 = tl.full((), _UINT32_MASK - 1, tl.uint32)
127
+ if offset + 2 < CONTEXT_WIDTH:
128
+ context_2 = tl.load(contexts_ptr + row * context_stride + offset + 2).to(
129
+ tl.uint32
130
+ )
131
+ else:
132
+ context_2 = tl.full((), _UINT32_MASK - 2, tl.uint32)
133
+ if offset + 3 < CONTEXT_WIDTH:
134
+ context_3 = tl.load(contexts_ptr + row * context_stride + offset + 3).to(
135
+ tl.uint32
136
+ )
137
+ else:
138
+ context_3 = tl.full((), _UINT32_MASK - 3, tl.uint32)
139
+ state_0, state_1, state_2, state_3 = _philox4x32_10(
140
+ (state_0 ^ context_0) & _UINT32_MASK,
141
+ (state_1 ^ context_1) & _UINT32_MASK,
142
+ (state_2 ^ context_2) & _UINT32_MASK,
143
+ (state_3 ^ context_3) & _UINT32_MASK,
144
+ (key_0 ^ offset) & _UINT32_MASK,
145
+ key_1,
146
+ )
147
+
148
+ candidate_words = groups.to(tl.uint32)
149
+ vector_zero = candidate_words * 0
150
+ output_0, output_1, output_2, output_3 = _philox4x32_10(
151
+ candidate_words & _UINT32_MASK,
152
+ state_0 + vector_zero,
153
+ state_1 + vector_zero,
154
+ state_2 + vector_zero,
155
+ ((key_0 ^ state_3) & _UINT32_MASK) + vector_zero,
156
+ ((key_1 ^ _TOKEN_DOMAIN) & _UINT32_MASK) + vector_zero,
157
+ )
158
+ candidate_0 = groups * 4
159
+ candidate_1 = candidate_0 + 1
160
+ candidate_2 = candidate_0 + 2
161
+ candidate_3 = candidate_0 + 3
162
+ logits_row = logits_ptr + row * logits_stride
163
+ value_0 = _gumbel_value(
164
+ logits_row + candidate_0, output_0, candidate_0 < vocab_size
165
+ )
166
+ value_1 = _gumbel_value(
167
+ logits_row + candidate_1, output_1, candidate_1 < vocab_size
168
+ )
169
+ value_2 = _gumbel_value(
170
+ logits_row + candidate_2, output_2, candidate_2 < vocab_size
171
+ )
172
+ value_3 = _gumbel_value(
173
+ logits_row + candidate_3, output_3, candidate_3 < vocab_size
174
+ )
175
+ best_value = value_0
176
+ best_token = candidate_0
177
+ best_value, best_token = _select_max(best_value, best_token, value_1, candidate_1)
178
+ best_value, best_token = _select_max(best_value, best_token, value_2, candidate_2)
179
+ best_value, best_token = _select_max(best_value, best_token, value_3, candidate_3)
180
+ value = tl.max(best_value, axis=0)
181
+ token_id = tl.min(tl.where(best_value == value, best_token, vocab_size), axis=0)
182
+ tl.store(local_argmax_ptr + row * local_argmax_stride + block_index, token_id)
183
+ tl.store(local_max_ptr + row * local_max_stride + block_index, value)
184
+
185
+
186
+def philox_gumbel_sample(
187
+ logits: torch.Tensor, contexts: torch.Tensor, key: int
188
+) -> torch.Tensor:
189
+ if logits.stride(-1) != 1:
190
+ logits = logits.contiguous()
191
+ if contexts.stride(-1) != 1:
192
+ contexts = contexts.contiguous()
193
+ num_tokens, vocab_size = logits.shape
194
+ block_size = 1024
195
+ num_blocks = triton.cdiv(vocab_size, block_size)
196
+ local_argmax = logits.new_empty(num_tokens, num_blocks, dtype=torch.int64)
197
+ local_max = logits.new_empty(num_tokens, num_blocks, dtype=torch.float32)
198
+ _philox_gumbel_kernel[(num_tokens, num_blocks)](
199
+ local_argmax,
200
+ local_argmax.stride(0),
201
+ local_max,
202
+ local_max.stride(0),
203
+ logits,
204
+ logits.stride(0),
205
+ contexts,
206
+ contexts.stride(0),
207
+ key & _UINT32_MASK_VALUE,
208
+ key >> 32,
209
+ vocab_size,
210
+ CONTEXT_WIDTH=contexts.shape[-1],
211
+ BLOCK_SIZE=block_size,
212
+ )
213
+ max_block_index = local_max.argmax(dim=-1, keepdim=True)
214
+ return local_argmax.gather(dim=-1, index=max_block_index).view(-1)