main
py 274 lines 9.37 KB
Raw
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, derive_watermark_key
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 SupportsSpeculativeDecoding,
19 Watermarker,
20 WatermarkSample,
21 )
22
23
24 def _gamma_survival_integer_shape(score: float, shape: int) -> float:
25 """Return the survival probability of a Gamma(shape, 1) variable."""
26 if shape <= 0 or score <= 0:
27 return 1.0
28 log_score = math.log(score)
29 log_term = 0.0
30 max_log_term = 0.0
31 log_terms = [log_term]
32 for index in range(1, shape):
33 log_term += log_score - math.log(index)
34 log_terms.append(log_term)
35 max_log_term = max(max_log_term, log_term)
36 log_sum = max_log_term + math.log(
37 sum(math.exp(term - max_log_term) for term in log_terms)
38 )
39 return min(1.0, math.exp(min(0.0, -score + log_sum)))
40
41
42 class GumbelWatermarker(Watermarker):
43 def __init__(
44 self,
45 key: int,
46 context_width: int = 4,
47 prf: WatermarkPRF | WatermarkPRFName = "philox",
48 ) -> None:
49 prf = create_prf(prf, key) if isinstance(prf, str) else prf
50 _validate_context_width(context_width)
51 self.prf = prf
52 self._context_width = context_width
53
54 @property
55 def context_width(self) -> int:
56 return self._context_width
57
58 def _sample_watermarked(
59 self,
60 logits: torch.Tensor,
61 contexts: torch.Tensor,
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 def _try_sample_mixed(
76 self,
77 logits: torch.Tensor,
78 contexts: torch.Tensor,
79 skip_mask: torch.Tensor,
80 random_sampler: RandomSampler,
81 ) -> WatermarkSample | None:
82 if type(self.prf) is not PhiloxPRF or logits.device.type != "cuda":
83 return None
84
85 from vllm.v1.worker.gpu.sample.watermark import philox_gumbel_sample
86
87 token_ids = philox_gumbel_sample(
88 logits,
89 contexts,
90 self.prf.key,
91 skip_mask=skip_mask,
92 expanded_idx_mapping=random_sampler.expanded_idx_mapping,
93 temperatures=random_sampler.temperatures,
94 seeds=random_sampler.seeds,
95 positions=random_sampler.positions,
96 use_fp64=random_sampler.use_fp64,
97 is_drafting=random_sampler.is_drafting,
98 logits_cache=random_sampler.logits_cache,
99 logits_cache_col=random_sampler.logits_cache_col,
100 logits_cache_source=random_sampler.logits_cache_source,
101 )
102 return WatermarkSample(token_ids, logits)
103
104
105 class DualKeyGumbelWatermarker(Watermarker, SupportsSpeculativeDecoding):
106 def __init__(
107 self,
108 key: int,
109 context_width: int = 4,
110 prf: WatermarkPRFName = "philox",
111 alpha: float = 0.1,
112 ) -> None:
113 if not 0 <= alpha <= 1:
114 raise ValueError("alpha must be between 0 and 1")
115 self.alpha = alpha
116 self.draft_watermarker = GumbelWatermarker(
117 derive_watermark_key(key, b"key_a"), context_width, prf
118 )
119 self.target_watermarker = GumbelWatermarker(
120 derive_watermark_key(key, b"key_b"), context_width, prf
121 )
122 self._routing_logits_cache: dict[torch.device, torch.Tensor] = {}
123
124 def _routing_logits(self, device: torch.device) -> torch.Tensor:
125 # Building this tensor on every CUDA call stages a pageable H2D copy and
126 # synchronizes the stream.
127 cached = self._routing_logits_cache.get(device)
128 if cached is None:
129 cached = torch.tensor(
130 [1 - self.alpha, self.alpha],
131 dtype=torch.float32,
132 device=device,
133 ).log()
134 self._routing_logits_cache[device] = cached
135 return cached
136
137 @property
138 def context_width(self) -> int:
139 return self.draft_watermarker.context_width
140
141 def sample(
142 self,
143 logits: torch.Tensor,
144 contexts: torch.Tensor,
145 random_sampler: RandomSampler | None = None,
146 skip_mask: torch.Tensor | None = None,
147 ) -> WatermarkSample:
148 if self.alpha == 0:
149 return self.draft_watermarker.sample(
150 logits, contexts, random_sampler, skip_mask
151 )
152 if self.alpha == 1:
153 return self.target_watermarker.sample(
154 logits, contexts, random_sampler, skip_mask
155 )
156 if random_sampler is None:
157 raise ValueError("dual-key Gumbel routing requires a random sampler")
158
159 key_a_sample = self.draft_watermarker.sample(
160 logits, contexts, random_sampler, skip_mask
161 )
162 key_b_sample = self.target_watermarker.sample(
163 logits, contexts, random_sampler, skip_mask
164 )
165 routing_logits = self._routing_logits(logits.device).expand(logits.shape[0], -1)
166 use_key_a = random_sampler(routing_logits) == 0
167 return WatermarkSample(
168 torch.where(
169 use_key_a,
170 key_a_sample.token_ids,
171 key_b_sample.token_ids,
172 ),
173 logits,
174 )
175
176 def _sample_watermarked(
177 self,
178 logits: torch.Tensor,
179 contexts: torch.Tensor,
180 ) -> WatermarkSample:
181 raise ValueError("dual-key Gumbel routing requires a random sampler")
182
183
184 class GumbelWatermarkDetector(WatermarkDetector):
185 def __init__(
186 self,
187 key: int,
188 context_width: int = 4,
189 p_value_threshold: float = 0.01,
190 prf: WatermarkPRF | WatermarkPRFName = "philox",
191 deduplicate_contexts: bool = True,
192 ) -> None:
193 prf = create_prf(prf, key) if isinstance(prf, str) else prf
194 _validate_context_width(context_width)
195 super().__init__(context_width, p_value_threshold, deduplicate_contexts)
196 self.prf = prf
197
198 def _score_tokens(
199 self, contexts: torch.Tensor, targets: torch.Tensor
200 ) -> torch.Tensor:
201 uniforms = self.prf.uniform(contexts, targets.unsqueeze(-1)).squeeze(-1)
202 return -torch.log1p(-uniforms.to(torch.float64))
203
204 def _get_p_value(self, score: float, num_scored_tokens: int) -> float:
205 return _gamma_survival_integer_shape(score, num_scored_tokens)
206
207 def _aggregate_scores(self, token_scores: torch.Tensor) -> float:
208 return token_scores.sum().item()
209
210
211 class DualKeyGumbelWatermarkDetector(GumbelWatermarkDetector):
212 """Detect a dual-key watermark with weighted early fusion.
213
214 ``alpha`` is the weight assigned to key B; key A receives ``1 - alpha``.
215 """
216
217 def __init__(
218 self,
219 key: int,
220 context_width: int = 4,
221 p_value_threshold: float = 0.01,
222 prf: WatermarkPRFName = "philox",
223 deduplicate_contexts: bool = True,
224 alpha: float = 0.2,
225 ) -> None:
226 if not 0 <= alpha <= 1:
227 raise ValueError("alpha must be between 0 and 1")
228 super().__init__(
229 derive_watermark_key(key, b"key_a"),
230 context_width,
231 p_value_threshold,
232 prf,
233 deduplicate_contexts,
234 )
235 self.key_b_prf = create_prf(prf, derive_watermark_key(key, b"key_b"))
236 self.alpha = alpha
237
238 def _score_tokens(
239 self, contexts: torch.Tensor, targets: torch.Tensor
240 ) -> torch.Tensor:
241 scores = torch.stack(
242 [
243 -torch.log1p(
244 -prf.uniform(contexts, targets.unsqueeze(-1))
245 .squeeze(-1)
246 .to(torch.float64)
247 )
248 for prf in (self.prf, self.key_b_prf)
249 ],
250 dim=-1,
251 )
252 weights = scores.new_tensor([1 - self.alpha, self.alpha])
253 return scores @ weights
254
255 def _get_p_value(self, score: float, num_scored_tokens: int) -> float:
256 variance = (1 - self.alpha) ** 2 + self.alpha**2
257 if self.alpha in (0, 0.5, 1):
258 return _gamma_survival_integer_shape(
259 score / variance, round(num_scored_tokens / variance)
260 )
261 shape = torch.tensor(num_scored_tokens / variance, dtype=torch.float64)
262 scaled_score = torch.tensor(score / variance, dtype=torch.float64)
263 return torch.special.gammaincc(shape, scaled_score).item()
264
265
266 def _validate_context_width(context_width: int) -> None:
267 if context_width < 1:
268 raise ValueError("context_width must be positive")
269 if context_width > 16:
270 warnings.warn(
271 "context_width values greater than 16 reduce robustness to edits because "
272 "each changed token affects more subsequent watermark contexts",
273 stacklevel=3,
274 )