[Feat] Support thinking_token_budget in Model Runner V2 (#46727)
Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Chauncey committed
Aug 7, 2026 at 11:36 UTC
72c0d6765793e4c7242c3586274af3e1a8aca170
10 files changed
+1122
-43
benchmarks/kernels/benchmark_thinking_budget.py
new
+365
@@ -0,0 +1,365 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import dataclasses
5
+from collections.abc import Callable
6
+from typing import Literal
7
+
8
+import numpy as np
9
+import torch
10
+
11
+from vllm.sampling_params import SamplingParams
12
+from vllm.triton_utils import triton
13
+from vllm.utils.argparse_utils import FlexibleArgumentParser
14
+from vllm.v1.worker.gpu.sample.thinking_budget import ThinkingBudgetState
15
+from vllm.v1.worker.gpu.states import RequestState
16
+
17
+START_TOKEN_ID = 90
18
+END_TOKEN_ID = 91
19
+TOKEN_ID = 10
20
+VOCAB_SIZE = 128
21
+QUANTILES = [0.5, 0.2, 0.8]
22
+
23
+
24
+class ReasoningConfig:
25
+ reasoning_start_token_ids = [START_TOKEN_ID]
26
+ reasoning_end_token_ids = [END_TOKEN_ID]
27
+ natural_reasoning_end_token_ids = [END_TOKEN_ID]
28
+
29
+
30
+@dataclasses.dataclass
31
+class BenchmarkCase:
32
+ req_states: RequestState
33
+ state: ThinkingBudgetState
34
+ run: Callable[[], None]
35
+
36
+
37
+def create_case(
38
+ history_len: int,
39
+ device: torch.device,
40
+ extra_tokens: int = 0,
41
+ history_type: Literal["reasoning", "prefill"] = "reasoning",
42
+ batch_size: int = 1,
43
+ budget_type: Literal["active", "forced", "mixed"] = "active",
44
+) -> BenchmarkCase:
45
+ assert history_len >= 2
46
+ if history_type == "prefill":
47
+ tokens = [*([TOKEN_ID] * (history_len - 1)), START_TOKEN_ID]
48
+ else:
49
+ tokens = [TOKEN_ID, START_TOKEN_ID, *([TOKEN_ID] * (history_len - 2))]
50
+ req_states = RequestState(
51
+ max_num_reqs=batch_size,
52
+ max_model_len=history_len + extra_tokens + 1,
53
+ max_num_batched_tokens=batch_size,
54
+ num_speculative_steps=1,
55
+ vocab_size=VOCAB_SIZE,
56
+ device=device,
57
+ )
58
+ req_indices = []
59
+ for req_num in range(batch_size):
60
+ req_id = f"benchmark-{req_num}"
61
+ req_states.add_request(
62
+ req_id=req_id,
63
+ prompt_len=1,
64
+ all_token_ids=tokens,
65
+ num_computed_tokens=history_len,
66
+ max_tokens=extra_tokens + 1,
67
+ )
68
+ req_indices.append(req_states.req_id_to_index[req_id])
69
+ req_states.apply_staged_writes()
70
+
71
+ state = ThinkingBudgetState(req_states, ReasoningConfig())
72
+ active_budget = history_len + extra_tokens + 1
73
+ forced_budget = max(1, history_len - 2)
74
+ for req_num, req_idx in enumerate(req_indices):
75
+ if budget_type == "forced":
76
+ budget = forced_budget
77
+ elif budget_type == "mixed" and req_num % 2 == 1:
78
+ budget = None
79
+ else:
80
+ budget = active_budget
81
+ state.add_request(
82
+ req_idx,
83
+ SamplingParams(thinking_token_budget=budget),
84
+ )
85
+ state.apply_staged_writes()
86
+
87
+ idx_mapping = torch.tensor(req_indices, dtype=torch.int32, device=device)
88
+ logits = torch.zeros((batch_size, VOCAB_SIZE), device=device)
89
+ idx_mapping_np = idx_mapping.cpu().numpy()
90
+ input_ids = torch.full((batch_size,), TOKEN_ID, dtype=torch.int32, device=device)
91
+ local_pos = torch.zeros(batch_size, dtype=torch.int32, device=device)
92
+
93
+ def run() -> None:
94
+ state.apply(
95
+ logits,
96
+ idx_mapping,
97
+ idx_mapping,
98
+ idx_mapping_np,
99
+ input_ids,
100
+ local_pos,
101
+ )
102
+
103
+ run()
104
+ torch.accelerator.synchronize(device)
105
+ active_req_indices = [
106
+ req_idx
107
+ for req_num, req_idx in enumerate(req_indices)
108
+ if budget_type != "mixed" or req_num % 2 == 0
109
+ ]
110
+ assert torch.all(state.cached_scan_pos[active_req_indices] == history_len).item()
111
+ if budget_type == "forced":
112
+ assert torch.all(logits[:, END_TOKEN_ID] == 1.0e9).item()
113
+ return BenchmarkCase(req_states, state, run)
114
+
115
+
116
+def benchmark_cached(
117
+ case: BenchmarkCase, warmup_ms: int, rep_ms: int
118
+) -> tuple[float, float, float]:
119
+ median_ms, min_ms, max_ms = triton.testing.do_bench(
120
+ case.run,
121
+ warmup=warmup_ms,
122
+ rep=rep_ms,
123
+ quantiles=QUANTILES,
124
+ )
125
+ return median_ms, min_ms, max_ms
126
+
127
+
128
+def summarize(timings_ms: list[float]) -> tuple[float, float, float]:
129
+ median_ms, min_ms, max_ms = np.quantile(timings_ms, QUANTILES)
130
+ return float(median_ms), float(min_ms), float(max_ms)
131
+
132
+
133
+def time_cuda_call(run: Callable[[], None]) -> float:
134
+ start = torch.Event(enable_timing=True)
135
+ end = torch.Event(enable_timing=True)
136
+ start.record()
137
+ run()
138
+ end.record()
139
+ end.synchronize()
140
+ return start.elapsed_time(end)
141
+
142
+
143
+def benchmark_incremental_decode(
144
+ history_len: int,
145
+ device: torch.device,
146
+ warmup_steps: int,
147
+ iterations: int,
148
+) -> tuple[float, float, float]:
149
+ case = create_case(history_len, device, warmup_steps + iterations)
150
+ timings_ms: list[float] = []
151
+ total_len = history_len
152
+ for step in range(warmup_steps + iterations):
153
+ case.req_states.all_token_ids.stage_write(0, total_len, [TOKEN_ID])
154
+ total_len += 1
155
+ case.req_states.total_len.stage_write_elem(0, total_len)
156
+ case.req_states.apply_staged_writes()
157
+ torch.accelerator.synchronize(device)
158
+
159
+ elapsed_ms = time_cuda_call(case.run)
160
+ if step >= warmup_steps:
161
+ timings_ms.append(elapsed_ms)
162
+
163
+ assert case.state.cached_scan_pos[0].item() == total_len
164
+ return summarize(timings_ms)
165
+
166
+
167
+def benchmark_cold_scan(
168
+ history_len: int,
169
+ device: torch.device,
170
+ warmup_steps: int,
171
+ iterations: int,
172
+ history_type: Literal["reasoning", "prefill"],
173
+) -> tuple[float, float, float]:
174
+ case = create_case(history_len, device, history_type=history_type)
175
+ timings_ms: list[float] = []
176
+ for step in range(warmup_steps + iterations):
177
+ case.state.cached_last_start.fill_(-1)
178
+ case.state.cached_last_end.fill_(-1)
179
+ case.state.cached_scan_pos.zero_()
180
+ torch.accelerator.synchronize(device)
181
+
182
+ elapsed_ms = time_cuda_call(case.run)
183
+ if step >= warmup_steps:
184
+ timings_ms.append(elapsed_ms)
185
+
186
+ return summarize(timings_ms)
187
+
188
+
189
+def main() -> None:
190
+ parser = FlexibleArgumentParser(
191
+ description=(
192
+ "Benchmark thinking-budget scan, forced-end, and batched request "
193
+ "overhead across token history lengths, including worst-case cold "
194
+ "resume reconstruction."
195
+ )
196
+ )
197
+ parser.add_argument(
198
+ "--history-lengths",
199
+ type=int,
200
+ nargs="+",
201
+ default=[128, 16384, 32768],
202
+ )
203
+ parser.add_argument(
204
+ "--modes",
205
+ nargs="+",
206
+ choices=[
207
+ "cached",
208
+ "incremental-decode",
209
+ "cold-prefill",
210
+ "cold-resume-worst-case",
211
+ "forced-end",
212
+ "batched-budgeted",
213
+ "batched-mixed",
214
+ ],
215
+ default=[
216
+ "cached",
217
+ "incremental-decode",
218
+ "cold-prefill",
219
+ "cold-resume-worst-case",
220
+ "forced-end",
221
+ "batched-budgeted",
222
+ "batched-mixed",
223
+ ],
224
+ )
225
+ parser.add_argument(
226
+ "--batch-sizes",
227
+ type=int,
228
+ nargs="+",
229
+ default=[1, 8, 32],
230
+ help="Batch sizes used by batched-budgeted and batched-mixed modes.",
231
+ )
232
+ parser.add_argument("--device", type=str, default="cuda:0")
233
+ parser.add_argument(
234
+ "--warmup-ms",
235
+ type=int,
236
+ default=100,
237
+ help="Warmup duration passed to triton.testing.do_bench.",
238
+ )
239
+ parser.add_argument(
240
+ "--rep-ms",
241
+ type=int,
242
+ default=500,
243
+ help="Measurement duration passed to triton.testing.do_bench.",
244
+ )
245
+ parser.add_argument(
246
+ "--warmup-steps",
247
+ type=int,
248
+ default=10,
249
+ help="Warmup steps for incremental-decode and cold modes.",
250
+ )
251
+ parser.add_argument(
252
+ "--iterations",
253
+ type=int,
254
+ default=100,
255
+ help="Measured steps for incremental-decode and cold modes.",
256
+ )
257
+ parser.add_argument(
258
+ "--max-slowdown",
259
+ type=float,
260
+ default=None,
261
+ help=(
262
+ "Fail if cached or incremental-decode median latency exceeds this "
263
+ "multiple of its shortest-history latency. Cold modes are excluded "
264
+ "because worst-case cold-resume cost scales with scanned history."
265
+ ),
266
+ )
267
+ args = parser.parse_args()
268
+
269
+ history_lengths = sorted(set(args.history_lengths))
270
+ if not history_lengths or history_lengths[0] < 2:
271
+ parser.error("history lengths must be at least 2")
272
+ if args.warmup_steps < 0 or args.iterations <= 0:
273
+ parser.error("warmup steps must be non-negative and iterations positive")
274
+ batch_sizes = sorted(set(args.batch_sizes))
275
+ if not batch_sizes or batch_sizes[0] <= 0:
276
+ parser.error("batch sizes must be positive")
277
+
278
+ device = torch.device(args.device)
279
+ results: dict[str, dict[int, tuple[float, float, float]]] = {}
280
+ for mode in args.modes:
281
+ mode_batch_sizes = batch_sizes if mode.startswith("batched-") else [1]
282
+ if mode == "batched-mixed":
283
+ mode_batch_sizes = [size for size in mode_batch_sizes if size >= 2]
284
+ if not mode_batch_sizes:
285
+ parser.error("batched-mixed requires a batch size of at least 2")
286
+ for batch_size in mode_batch_sizes:
287
+ label = f"{mode}-b{batch_size}" if mode.startswith("batched-") else mode
288
+ mode_results: dict[int, tuple[float, float, float]] = {}
289
+ for history_len in history_lengths:
290
+ if mode == "cached":
291
+ case = create_case(history_len, device)
292
+ result = benchmark_cached(case, args.warmup_ms, args.rep_ms)
293
+ elif mode == "incremental-decode":
294
+ result = benchmark_incremental_decode(
295
+ history_len,
296
+ device,
297
+ args.warmup_steps,
298
+ args.iterations,
299
+ )
300
+ elif mode == "cold-prefill":
301
+ result = benchmark_cold_scan(
302
+ history_len,
303
+ device,
304
+ args.warmup_steps,
305
+ args.iterations,
306
+ "prefill",
307
+ )
308
+ elif mode == "cold-resume-worst-case":
309
+ result = benchmark_cold_scan(
310
+ history_len,
311
+ device,
312
+ args.warmup_steps,
313
+ args.iterations,
314
+ "reasoning",
315
+ )
316
+ else:
317
+ budget_type = (
318
+ "forced"
319
+ if mode == "forced-end"
320
+ else "mixed"
321
+ if mode == "batched-mixed"
322
+ else "active"
323
+ )
324
+ case = create_case(
325
+ history_len,
326
+ device,
327
+ batch_size=batch_size,
328
+ budget_type=budget_type,
329
+ )
330
+ result = benchmark_cached(case, args.warmup_ms, args.rep_ms)
331
+ mode_results[history_len] = result
332
+ results[label] = mode_results
333
+
334
+ print("mode history_len median_us p20_us p80_us slowdown")
335
+ for label, mode_results in results.items():
336
+ baseline_ms = mode_results[history_lengths[0]][0]
337
+ for history_len in history_lengths:
338
+ median_ms, min_ms, max_ms = mode_results[history_len]
339
+ slowdown = median_ms / baseline_ms
340
+ print(
341
+ f"{label:<26} {history_len:>11} {median_ms * 1000:>9.3f} "
342
+ f"{min_ms * 1000:>6.3f} {max_ms * 1000:>6.3f} "
343
+ f"{slowdown:>7.3f}x"
344
+ )
345
+
346
+ if args.max_slowdown is not None:
347
+ for mode in ("cached", "incremental-decode"):
348
+ if mode not in results:
349
+ continue
350
+ mode_results = results[mode]
351
+ baseline_ms = mode_results[history_lengths[0]][0]
352
+ worst_history_len = max(
353
+ mode_results, key=lambda length: mode_results[length][0]
354
+ )
355
+ worst_slowdown = mode_results[worst_history_len][0] / baseline_ms
356
+ if worst_slowdown > args.max_slowdown:
357
+ raise SystemExit(
358
+ f"thinking-budget {mode} slowdown {worst_slowdown:.3f}x "
359
+ f"at history length {worst_history_len} exceeds "
360
+ f"{args.max_slowdown:.3f}x"
361
+ )
362
+
363
+
364
+if __name__ == "__main__":
365
+ main()
tests/entrypoints/openai/chat_completion/test_thinking_token_budget.py
+3
-8
@@ -72,9 +72,7 @@ def server():
72
"0.4",
73
"--no-async-scheduling",
74
]
75
- # thinking_token_budget is not yet supported by the V2 model runner.
76
- env_dict = {"VLLM_USE_V2_MODEL_RUNNER": "0"}
77
- with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server:
75
+ with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
76
yield remote_server
77
78
@@ -90,9 +88,7 @@ def server_with_auto_reasoning_config():
88
"0.4",
89
"--no-async-scheduling",
90
]
93
- # thinking_token_budget is not yet supported by the V2 model runner.
94
- env_dict = {"VLLM_USE_V2_MODEL_RUNNER": "0"}
95
- with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server:
91
+ with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
92
yield remote_server
93
94
@@ -126,8 +122,7 @@ def server_qwen35_fp8_mtp_tp2():
122
}
123
),
124
]
129
- # thinking_token_budget is not yet supported by the V2 model runner.
130
- env_dict: dict[str, str] = {"VLLM_USE_V2_MODEL_RUNNER": "0"}
125
+ env_dict: dict[str, str] = {}
126
# With 4+ GPUs, run TP=2 on physical devices 2,3 so module-scoped 0.6B servers
127
# on 0,1 do not exhaust memory on the same devices as this worker.
128
if current_platform.device_count() >= 4:
tests/v1/worker/test_gpu_thinking_budget.py
new
+262
@@ -0,0 +1,262 @@
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
+pytest.importorskip("triton")
8
+if not torch.cuda.is_available():
9
+ pytest.skip(
10
+ "CUDA required for Model Runner V2 thinking budget tests",
11
+ allow_module_level=True,
12
+ )
13
+
14
+from vllm.sampling_params import SamplingParams
15
+from vllm.v1.worker.gpu.sample.thinking_budget import ThinkingBudgetState
16
+from vllm.v1.worker.gpu.states import RequestState
17
+
18
+DEVICE = torch.device("cuda")
19
+START = 90
20
+END = 91
21
+END_A = 92
22
+END_B = 93
23
+VOCAB_SIZE = 128
24
+
25
+
26
+class MockReasoningConfig:
27
+ reasoning_start_token_ids = [START]
28
+ reasoning_end_token_ids = [END]
29
+ natural_reasoning_end_token_ids = [END]
30
+
31
+
32
+class MockMultiTokenEndReasoningConfig:
33
+ reasoning_start_token_ids = [START]
34
+ reasoning_end_token_ids = [END_A, END_B]
35
+ natural_reasoning_end_token_ids = [END_A, END_B]
36
+
37
+
38
+class MockDistinctEndReasoningConfig:
39
+ reasoning_start_token_ids = [START]
40
+ reasoning_end_token_ids = [END_A, END_B]
41
+ natural_reasoning_end_token_ids = [END]
42
+
43
+
44
+def _make_req_states(tokens: list[int], prompt_len: int = 1) -> RequestState:
45
+ req_states = RequestState(
46
+ max_num_reqs=4,
47
+ max_model_len=max(64, len(tokens) + 1),
48
+ max_num_batched_tokens=16,
49
+ num_speculative_steps=4,
50
+ vocab_size=VOCAB_SIZE,
51
+ device=DEVICE,
52
+ )
53
+ req_states.add_request(
54
+ req_id="req",
55
+ prompt_len=prompt_len,
56
+ all_token_ids=tokens,
57
+ num_computed_tokens=len(tokens),
58
+ max_tokens=32,
59
+ )
60
+ req_states.apply_staged_writes()
61
+ return req_states
62
+
63
+
64
+def _apply(
65
+ state: ThinkingBudgetState,
66
+ logits: torch.Tensor,
67
+ input_ids: list[int],
68
+ local_pos: list[int],
69
+) -> torch.Tensor:
70
+ idx_mapping = torch.tensor([3], dtype=torch.int32, device=DEVICE)
71
+ expanded_idx_mapping = torch.tensor(
72
+ [3] * len(input_ids), dtype=torch.int32, device=DEVICE
73
+ )
74
+ idx_mapping_np = idx_mapping.cpu().numpy()
75
+ state.apply(
76
+ logits,
77
+ expanded_idx_mapping,
78
+ idx_mapping,
79
+ idx_mapping_np,
80
+ torch.tensor(input_ids, dtype=torch.int32, device=DEVICE),
81
+ torch.tensor(local_pos, dtype=torch.int32, device=DEVICE),
82
+ )
83
+ return logits.cpu()
84
+
85
+
86
+def test_v2_thinking_budget_forces_end_after_budget_reached():
87
+ req_states = _make_req_states([1, START, 10, 11, 12], prompt_len=1)
88
+ state = ThinkingBudgetState(req_states, MockReasoningConfig())
89
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
90
+ state.apply_staged_writes()
91
+
92
+ logits = torch.arange(VOCAB_SIZE, dtype=torch.float32, device=DEVICE).view(1, -1)
93
+ expected = logits.cpu()
94
+ out = _apply(state, logits, input_ids=[12], local_pos=[0])
95
+
96
+ expected[0, END] = 1.0e9
97
+ torch.testing.assert_close(out, expected)
98
+
99
+
100
+def test_v2_thinking_budget_restores_masked_end_token():
101
+ req_states = _make_req_states([1, START, 10, 11, 12], prompt_len=1)
102
+ state = ThinkingBudgetState(req_states, MockReasoningConfig())
103
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
104
+ state.apply_staged_writes()
105
+
106
+ logits = torch.zeros((1, VOCAB_SIZE), device=DEVICE)
107
+ logits[0, END] = -float("inf")
108
+ out = _apply(state, logits, input_ids=[12], local_pos=[0])
109
+
110
+ assert out[0, END] == pytest.approx(1.0e9)
111
+
112
+
113
+def test_v2_thinking_budget_allows_tokens_before_budget():
114
+ req_states = _make_req_states([1, START, 10, 11], prompt_len=1)
115
+ state = ThinkingBudgetState(req_states, MockReasoningConfig())
116
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
117
+ state.apply_staged_writes()
118
+
119
+ logits = torch.zeros((1, VOCAB_SIZE), device=DEVICE)
120
+ out = _apply(state, logits, input_ids=[11], local_pos=[0])
121
+
122
+ assert torch.all(out == 0)
123
+
124
+
125
+def test_v2_thinking_budget_continues_multi_token_end_marker():
126
+ req_states = _make_req_states([1, START, 10, 11, 12], prompt_len=1)
127
+ state = ThinkingBudgetState(req_states, MockMultiTokenEndReasoningConfig())
128
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
129
+ state.apply_staged_writes()
130
+
131
+ logits = torch.zeros((2, VOCAB_SIZE), device=DEVICE)
132
+ out = _apply(
133
+ state,
134
+ logits,
135
+ input_ids=[12, END_A],
136
+ local_pos=[0, 1],
137
+ )
138
+
139
+ assert out[0, END_A] == pytest.approx(1.0e9)
140
+ assert out[1, END_B] == pytest.approx(1.0e9)
141
+
142
+
143
+def test_v2_thinking_budget_uses_distinct_forced_end_marker():
144
+ req_states = _make_req_states([1, START, 10, 11, 12], prompt_len=1)
145
+ state = ThinkingBudgetState(req_states, MockDistinctEndReasoningConfig())
146
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
147
+ state.apply_staged_writes()
148
+
149
+ logits = torch.zeros((2, VOCAB_SIZE), device=DEVICE)
150
+ out = _apply(
151
+ state,
152
+ logits,
153
+ input_ids=[12, END_A],
154
+ local_pos=[0, 1],
155
+ )
156
+
157
+ assert out[0, END_A] == pytest.approx(1.0e9)
158
+ assert out[1, END_B] == pytest.approx(1.0e9)
159
+
160
+
161
+def test_v2_thinking_budget_stops_after_natural_end_marker():
162
+ req_states = _make_req_states(
163
+ [1, START, 10, END, 20, 21, 22],
164
+ prompt_len=1,
165
+ )
166
+ state = ThinkingBudgetState(req_states, MockDistinctEndReasoningConfig())
167
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
168
+ state.apply_staged_writes()
169
+
170
+ logits = torch.zeros((1, VOCAB_SIZE), device=DEVICE)
171
+ out = _apply(state, logits, input_ids=[22], local_pos=[0])
172
+
173
+ assert torch.all(out == 0)
174
+
175
+
176
+def test_v2_thinking_budget_ignores_plain_request():
177
+ req_states = _make_req_states([1, START, 10, 11, 12], prompt_len=1)
178
+ state = ThinkingBudgetState(req_states, MockReasoningConfig())
179
+ state.add_request(3, SamplingParams())
180
+ state.apply_staged_writes()
181
+
182
+ logits = torch.zeros((1, VOCAB_SIZE), device=DEVICE)
183
+ out = _apply(state, logits, input_ids=[12], local_pos=[0])
184
+
185
+ assert torch.all(out == 0)
186
+
187
+
188
+def test_v2_thinking_budget_latest_prefill_end_disables_forcing():
189
+ req_states = _make_req_states(
190
+ [1, START, 10, 11, 12, END, 13],
191
+ prompt_len=1,
192
+ )
193
+ state = ThinkingBudgetState(req_states, MockReasoningConfig())
194
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
195
+ state.apply_staged_writes()
196
+
197
+ logits = torch.zeros((1, VOCAB_SIZE), device=DEVICE)
198
+ out = _apply(state, logits, input_ids=[13], local_pos=[0])
199
+
200
+ assert torch.all(out == 0)
201
+
202
+
203
+def test_v2_thinking_budget_uses_latest_prefill_start_boundary():
204
+ req_states = _make_req_states(
205
+ [1, START, 10, 11, 12, END, 13, START, 14, 15, 16],
206
+ prompt_len=1,
207
+ )
208
+ state = ThinkingBudgetState(req_states, MockReasoningConfig())
209
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
210
+ state.apply_staged_writes()
211
+
212
+ logits = torch.zeros((1, VOCAB_SIZE), device=DEVICE)
213
+ out = _apply(state, logits, input_ids=[16], local_pos=[0])
214
+
215
+ assert out[0, END] == pytest.approx(1.0e9)
216
+
217
+
218
+def test_v2_thinking_budget_incrementally_scans_long_generation():
219
+ """Guard against rescanning the full token history on every decode step."""
220
+ tokens = [1, START, *([10] * 16382)]
221
+ req_states = _make_req_states(tokens)
222
+ state = ThinkingBudgetState(req_states, MockReasoningConfig())
223
+ state.add_request(3, SamplingParams(thinking_token_budget=32768))
224
+ state.apply_staged_writes()
225
+
226
+ _apply(state, torch.zeros((1, VOCAB_SIZE), device=DEVICE), [10], [0])
227
+ assert state.cached_scan_pos[3].item() == len(tokens)
228
+
229
+ req_states.all_token_ids.stage_write(3, len(tokens), [10])
230
+ req_states.total_len.stage_write_elem(3, len(tokens) + 1)
231
+ req_states.apply_staged_writes()
232
+ _apply(state, torch.zeros((1, VOCAB_SIZE), device=DEVICE), [10], [0])
233
+
234
+ assert state.cached_scan_pos[3].item() == len(tokens) + 1
235
+
236
+
237
+def test_v2_thinking_budget_clamps_oversized_budget():
238
+ """Budgets beyond int32 must not crash and behave as unlimited."""
239
+ req_states = _make_req_states([1, START, 10, 11, 12], prompt_len=1)
240
+ state = ThinkingBudgetState(req_states, MockReasoningConfig())
241
+ state.add_request(3, SamplingParams(thinking_token_budget=2**40))
242
+ state.apply_staged_writes()
243
+
244
+ logits = torch.zeros((1, VOCAB_SIZE), device=DEVICE)
245
+ out = _apply(state, logits, input_ids=[12], local_pos=[0])
246
+
247
+ assert torch.all(out == 0)
248
+
249
+
250
+def test_v2_thinking_budget_continues_end_prefix_from_prompt():
251
+ """A resumed prompt ending with a partial forced-end marker must not
252
+ restart the marker sequence and duplicate its first token."""
253
+ req_states = _make_req_states([1, START, 10, 11, END_A], prompt_len=5)
254
+ state = ThinkingBudgetState(req_states, MockMultiTokenEndReasoningConfig())
255
+ state.add_request(3, SamplingParams(thinking_token_budget=3))
256
+ state.apply_staged_writes()
257
+
258
+ logits = torch.zeros((1, VOCAB_SIZE), device=DEVICE)
259
+ out = _apply(state, logits, input_ids=[END_A], local_pos=[0])
260
+
261
+ assert out[0, END_B] == pytest.approx(1.0e9)
262
+ assert out[0, END_A] == 0
vllm/config/reasoning.py
+31
-13
@@ -13,10 +13,10 @@ from vllm.tokenizers import cached_tokenizer_from_config
13
class ReasoningConfig:
14
"""Configuration for reasoning models.
15
16
- Set `reasoning_start_str` and `reasoning_end_str` to the strings that delimit
17
- the reasoning block (e.g. `"<think>"` and `"</think>"`). The
18
- corresponding token IDs are derived automatically via
19
- `initialize_token_ids` and are not intended to be set directly.
16
+ Set `reasoning_start_str` and `reasoning_end_str` to the strings used to
17
+ enter and forcibly terminate reasoning. The end string may include a
18
+ transition phrase before the parser's natural reasoning end marker. Token
19
+ IDs are derived automatically by `initialize_token_ids`.
20
"""
21
22
reasoning_parser: str = ""
@@ -24,7 +24,7 @@ class ReasoningConfig:
24
reasoning_start_str: str = ""
25
"""String that indicates the start of reasoning."""
26
reasoning_end_str: str = ""
27
- """String that indicates the end of reasoning content."""
27
+ """String forced when the thinking budget is exhausted."""
28
29
_reasoning_start_token_ids: list[int] | None = field(
30
default=None, init=False, repr=False
@@ -34,8 +34,11 @@ class ReasoningConfig:
34
_reasoning_end_token_ids: list[int] | None = field(
35
default=None, init=False, repr=False
36
)
37
- """Private backing field for `reasoning_end_token_ids`. Set by
38
- `initialize_token_ids`. Not intended to be configured directly."""
37
+ """Private backing field for forced reasoning end token IDs."""
38
+ _natural_reasoning_end_token_ids: list[int] | None = field(
39
+ default=None, init=False, repr=False
40
+ )
41
+ """Token IDs that naturally terminate reasoning, as defined by the parser."""
42
43
_enabled: bool = field(default=False, init=False, repr=False)
44
"""Private field indicating whether reasoning token IDs have been initialized.
@@ -55,15 +58,20 @@ class ReasoningConfig:
58
59
@property
60
def reasoning_end_token_ids(self) -> list[int] | None:
58
- """Token IDs derived from `reasoning_end_str`. Set automatically by
59
- `initialize_token_ids`. Not intended to be configured directly."""
61
+ """Token IDs forced when the thinking budget is exhausted."""
62
return self._reasoning_end_token_ids
63
64
+ @property
65
+ def natural_reasoning_end_token_ids(self) -> list[int] | None:
66
+ """Token IDs that indicate the model naturally ended reasoning."""
67
+ return self._natural_reasoning_end_token_ids
68
+
69
def initialize_token_ids(self, model_config: ModelConfig) -> None:
70
"""Initialize reasoning token IDs from strings using the tokenizer."""
71
if (
72
self._reasoning_start_token_ids is not None
73
and self._reasoning_end_token_ids is not None
74
+ and self._natural_reasoning_end_token_ids is not None
75
):
76
self._enabled = True
77
return # Already initialized
@@ -71,9 +79,8 @@ class ReasoningConfig:
79
tokenizer = cached_tokenizer_from_config(model_config=model_config)
80
reasoning_start_str = self.reasoning_start_str
81
reasoning_end_str = self.reasoning_end_str
74
- if self.reasoning_parser is not None and (
75
- not reasoning_start_str or not reasoning_end_str
76
- ):
82
+ natural_reasoning_end_str = ""
83
+ if self.reasoning_parser:
84
parser_cls = ReasoningParserManager.get_reasoning_parser(
85
self.reasoning_parser
86
)
@@ -85,6 +92,10 @@ class ReasoningConfig:
92
end_token = reasoning_parser.reasoning_end_str
93
if end_token and not reasoning_end_str:
94
reasoning_end_str = end_token
95
+ natural_reasoning_end_str = end_token or ""
96
+
97
+ if not natural_reasoning_end_str:
98
+ natural_reasoning_end_str = reasoning_end_str
99
100
if not reasoning_start_str or not reasoning_end_str:
101
# If we don't have valid strings to tokenize,
@@ -96,8 +107,15 @@ class ReasoningConfig:
107
self._reasoning_end_token_ids = tokenizer.encode(
108
reasoning_end_str, add_special_tokens=False
109
)
110
+ self._natural_reasoning_end_token_ids = tokenizer.encode(
111
+ natural_reasoning_end_str, add_special_tokens=False
112
+ )
113
100
- if not self._reasoning_start_token_ids or not self._reasoning_end_token_ids:
114
+ if (
115
+ not self._reasoning_start_token_ids
116
+ or not self._reasoning_end_token_ids
117
+ or not self._natural_reasoning_end_token_ids
118
+ ):
119
raise ValueError(
120
f"ReasoningConfig: failed to tokenize reasoning strings: "
121
f"reasoning_start_str='{self.reasoning_start_str}', "
vllm/config/vllm.py
-6
@@ -2377,12 +2377,6 @@ class VllmConfig:
2377
f"Model Runner V2 does not yet support: {', '.join(unsupported)}"
2378
)
2379
2380
- if self.reasoning_config is not None:
2381
- logger.warning_once(
2382
- "Model Runner V2 does not yet support the thinking_token_budget "
2383
- "request parameter. Set VLLM_USE_V2_MODEL_RUNNER=0 if this is required."
2384
- )
2385
-
2380
def validate_block_size(self) -> None:
2381
"""Validate block_size against DCP and mamba constraints.
2382
vllm/v1/engine/input_processor.py
+9
-16
@@ -108,22 +108,15 @@ class InputProcessor:
108
self.tokenizer,
109
)
110
111
- if params.thinking_token_budget is not None:
112
- if (
113
- self.vllm_config.reasoning_config is None
114
- or not self.vllm_config.reasoning_config.enabled
115
- ):
116
- raise VLLMValidationError(
117
- "thinking_token_budget is set but reasoning_config is "
118
- "not configured. Please set --reasoning-parser "
119
- "and/or --reasoning-config to use thinking_token_budget."
120
- )
121
- if self.use_v2_model_runner:
122
- raise VLLMValidationError(
123
- "thinking_token_budget is not yet supported by the V2 "
124
- "model runner. Run vLLM with VLLM_USE_V2_MODEL_RUNNER=0 "
125
- "to use thinking_token_budget."
126
- )
111
+ if params.thinking_token_budget is not None and (
112
+ self.vllm_config.reasoning_config is None
113
+ or not self.vllm_config.reasoning_config.enabled
114
+ ):
115
+ raise VLLMValidationError(
116
+ "thinking_token_budget is set but reasoning_config is "
117
+ "not configured. Please set --reasoning-parser "
118
+ "and/or --reasoning-config to use thinking_token_budget."
119
+ )
120
elif isinstance(params, PoolingParams):
121
supported_pooling_tasks = [
122
task for task in supported_tasks if task in POOLING_TASKS
vllm/v1/worker/gpu/model_runner.py
+1
@@ -371,6 +371,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
371
logprobs_mode=self.model_config.logprobs_mode,
372
num_speculative_tokens=self.decode_query_len,
373
use_fp64_gumbel=self.model_config.use_fp64_gumbel,
374
+ reasoning_config=self.vllm_config.reasoning_config,
375
)
376
custom = self.model_state.custom_sampler(self.sampler)
377
vllm/v1/worker/gpu/sample/sampler.py
+22
@@ -6,6 +6,7 @@ import torch
6
7
import vllm.envs as envs
8
from vllm.config.model import PROCESSED_LOGPROBS_MODES, LogprobsMode
9
+from vllm.config.reasoning import ReasoningConfig
10
from vllm.sampling_params import SamplingParams
11
from vllm.v1.sample.ops.topk_topp_sampler import (
12
apply_top_k_top_p,
@@ -24,6 +25,7 @@ from vllm.v1.worker.gpu.sample.logprob import (
25
from vllm.v1.worker.gpu.sample.output import SamplerOutput
26
from vllm.v1.worker.gpu.sample.penalties import PenaltiesState
27
from vllm.v1.worker.gpu.sample.states import NO_LOGPROBS, SamplingStates
28
+from vllm.v1.worker.gpu.sample.thinking_budget import ThinkingBudgetState
29
from vllm.v1.worker.gpu.states import RequestState
30
31
@@ -37,6 +39,7 @@ class Sampler:
39
logprobs_mode: LogprobsMode = "raw_logprobs",
40
num_speculative_tokens: int = 1,
41
use_fp64_gumbel: bool = False,
42
+ reasoning_config: ReasoningConfig | None = None,
43
):
44
self.logprobs_mode = logprobs_mode
45
self.compute_nans = envs.VLLM_COMPUTE_NANS_IN_LOGITS # False by default.
@@ -48,6 +51,7 @@ class Sampler:
51
self.logit_bias_state = LogitBiasState(max_num_reqs, device)
52
self.bad_words_state = BadWordsState(req_states)
53
self.logprob_token_ids_state = LogprobTokenIdsState(max_num_reqs, device)
54
+ self.thinking_budget_state = ThinkingBudgetState(req_states, reasoning_config)
55
self.num_speculative_tokens = num_speculative_tokens
56
self.use_flashinfer = flashinfer_sampler_supported()
57
@@ -59,6 +63,7 @@ class Sampler:
63
self.logit_bias_state.add_request(req_idx, prompt_len, sampling_params)
64
self.bad_words_state.add_request(req_idx, sampling_params)
65
self.logprob_token_ids_state.add_request(req_idx, sampling_params)
66
+ self.thinking_budget_state.add_request(req_idx, sampling_params)
67
68
def apply_staged_writes(self) -> None:
69
self.sampling_states.apply_staged_writes()
@@ -66,6 +71,7 @@ class Sampler:
71
self.logit_bias_state.apply_staged_writes()
72
self.bad_words_state.apply_staged_writes()
73
self.logprob_token_ids_state.apply_staged_writes()
74
+ self.thinking_budget_state.apply_staged_writes()
75
76
def __call__(
77
self,
@@ -73,6 +79,7 @@ class Sampler:
79
input_batch: InputBatch,
80
) -> SamplerOutput:
81
expanded_idx_mapping = input_batch.expanded_idx_mapping
82
+ idx_mapping = input_batch.idx_mapping
83
idx_mapping_np = input_batch.idx_mapping_np
84
cu_num_logits_np = input_batch.cu_num_logits_np
85
expanded_local_pos = input_batch.expanded_local_pos
@@ -92,6 +99,7 @@ class Sampler:
99
sampled, processed_logits = self.sample(
100
logits,
101
expanded_idx_mapping,
102
+ idx_mapping,
103
idx_mapping_np,
104
pos,
105
input_ids,
@@ -146,6 +154,7 @@ class Sampler:
154
self,
155
logits: torch.Tensor,
156
expanded_idx_mapping: torch.Tensor,
157
+ idx_mapping: torch.Tensor,
158
idx_mapping_np: np.ndarray,
159
pos: torch.Tensor,
160
input_ids: torch.Tensor,
@@ -181,6 +190,17 @@ class Sampler:
190
expanded_local_pos,
191
)
192
193
+ # Force the reasoning end marker once a request's thinking budget is
194
+ # reached; applied before temperature so the forced token is always kept.
195
+ self.thinking_budget_state.apply(
196
+ logits,
197
+ expanded_idx_mapping,
198
+ idx_mapping,
199
+ idx_mapping_np,
200
+ input_ids,
201
+ expanded_local_pos,
202
+ )
203
+
204
# Apply temperature in place.
205
self.sampling_states.apply_temperature(
206
logits, expanded_idx_mapping, idx_mapping_np
@@ -219,6 +239,7 @@ class Sampler:
239
self,
240
logits: torch.Tensor,
241
expanded_idx_mapping: torch.Tensor,
242
+ idx_mapping: torch.Tensor,
243
idx_mapping_np: np.ndarray,
244
pos: torch.Tensor,
245
input_ids: torch.Tensor,
@@ -228,6 +249,7 @@ class Sampler:
249
processed_logits = self.apply_sampling_params(
250
logits,
251
expanded_idx_mapping,
252
+ idx_mapping,
253
idx_mapping_np,
254
pos,
255
input_ids,
vllm/v1/worker/gpu/sample/thinking_budget.py
new
+428
@@ -0,0 +1,428 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from typing import TYPE_CHECKING
4
+
5
+import numpy as np
6
+import torch
7
+
8
+from vllm.sampling_params import SamplingParams
9
+from vllm.triton_utils import tl, triton
10
+from vllm.utils.torch_utils import async_tensor_h2d
11
+from vllm.v1.worker.gpu.buffer_utils import UvaBackedTensor
12
+from vllm.v1.worker.gpu.states import RequestState
13
+
14
+if TYPE_CHECKING:
15
+ from vllm.config.reasoning import ReasoningConfig
16
+
17
+_INT32_MAX = np.iinfo(np.int32).max
18
+_COLD_SCAN_BLOCK = 1024
19
+
20
+
21
+class ThinkingBudgetState:
22
+ """Model Runner V2 state for per-request thinking token budgets."""
23
+
24
+ def __init__(
25
+ self,
26
+ req_states: RequestState,
27
+ reasoning_config: "ReasoningConfig | None",
28
+ ):
29
+ self.req_states = req_states
30
+ self.max_num_reqs = req_states.max_num_reqs
31
+ self.device = req_states.device
32
+
33
+ start_ids = (
34
+ []
35
+ if reasoning_config is None
36
+ else reasoning_config.reasoning_start_token_ids or []
37
+ )
38
+ end_ids = (
39
+ []
40
+ if reasoning_config is None
41
+ else reasoning_config.reasoning_end_token_ids or []
42
+ )
43
+ natural_end_ids = (
44
+ []
45
+ if reasoning_config is None
46
+ else reasoning_config.natural_reasoning_end_token_ids or []
47
+ )
48
+ self.enabled = bool(start_ids and end_ids and natural_end_ids)
49
+ if not self.enabled:
50
+ return
51
+
52
+ self.thinking_token_budget = UvaBackedTensor(
53
+ self.max_num_reqs, dtype=torch.int32
54
+ )
55
+ self.thinking_token_budget.np.fill(-1)
56
+ self.thinking_token_budget.copy_to_uva()
57
+ self.use_thinking_budget = np.zeros(self.max_num_reqs, dtype=bool)
58
+
59
+ self.cached_last_start = torch.full(
60
+ (self.max_num_reqs,), -1, dtype=torch.int32, device=self.device
61
+ )
62
+ self.cached_last_end = torch.full(
63
+ (self.max_num_reqs,), -1, dtype=torch.int32, device=self.device
64
+ )
65
+ self.cached_scan_pos = torch.zeros(
66
+ self.max_num_reqs, dtype=torch.int32, device=self.device
67
+ )
68
+ self._reset_reqs: list[int] = []
69
+ self._budget_dirty = False
70
+
71
+ self.reasoning_start_token_ids = torch.tensor(
72
+ start_ids, dtype=torch.int32, device=self.device
73
+ )
74
+ self.natural_reasoning_end_token_ids = torch.tensor(
75
+ natural_end_ids, dtype=torch.int32, device=self.device
76
+ )
77
+ self.reasoning_end_token_ids = torch.tensor(
78
+ end_ids, dtype=torch.int32, device=self.device
79
+ )
80
+
81
+ def add_request(self, req_idx: int, sampling_params: SamplingParams) -> None:
82
+ if not self.enabled:
83
+ return
84
+ budget = sampling_params.thinking_token_budget
85
+ self.use_thinking_budget[req_idx] = budget is not None
86
+ if budget is None:
87
+ budget = -1
88
+ else:
89
+ budget = min(budget, _INT32_MAX)
90
+ self._reset_reqs.append(req_idx)
91
+ if self.thinking_token_budget.np[req_idx] != budget:
92
+ self.thinking_token_budget.np[req_idx] = budget
93
+ self._budget_dirty = True
94
+
95
+ def apply_staged_writes(self) -> None:
96
+ if not self.enabled:
97
+ return
98
+ if self._reset_reqs:
99
+ idx = async_tensor_h2d(
100
+ self._reset_reqs, dtype=torch.int64, device=self.device
101
+ )
102
+ self.cached_last_start.index_fill_(0, idx, -1)
103
+ self.cached_last_end.index_fill_(0, idx, -1)
104
+ self.cached_scan_pos.index_fill_(0, idx, 0)
105
+ self._reset_reqs.clear()
106
+ if self._budget_dirty:
107
+ self.thinking_token_budget.copy_to_uva()
108
+ self._budget_dirty = False
109
+
110
+ def apply(
111
+ self,
112
+ logits: torch.Tensor,
113
+ expanded_idx_mapping: torch.Tensor,
114
+ idx_mapping: torch.Tensor,
115
+ idx_mapping_np: np.ndarray,
116
+ input_ids: torch.Tensor,
117
+ expanded_local_pos: torch.Tensor,
118
+ ) -> None:
119
+ if not self.enabled or not np.any(self.use_thinking_budget[idx_mapping_np]):
120
+ return
121
+
122
+ apply_thinking_budget(
123
+ logits,
124
+ idx_mapping,
125
+ expanded_idx_mapping,
126
+ self.thinking_token_budget.gpu,
127
+ self.req_states.all_token_ids.gpu,
128
+ self.req_states.total_len.gpu,
129
+ input_ids,
130
+ expanded_local_pos,
131
+ self.cached_last_start,
132
+ self.cached_last_end,
133
+ self.cached_scan_pos,
134
+ self.reasoning_start_token_ids,
135
+ self.natural_reasoning_end_token_ids,
136
+ self.reasoning_end_token_ids,
137
+ )
138
+
139
+
140
+@triton.jit
141
+def _load_effective_token(
142
+ all_token_ids_ptr,
143
+ all_token_ids_stride,
144
+ input_ids_ptr,
145
+ cur_req_first_pos,
146
+ req_state_idx,
147
+ total_len,
148
+ pos,
149
+):
150
+ if pos < total_len:
151
+ return tl.load(all_token_ids_ptr + req_state_idx * all_token_ids_stride + pos)
152
+ # In decode/spec-decode, input_ids at local position 0 is the already
153
+ # committed last sampled token. Effective draft-prefix positions start at
154
+ # local position 1.
155
+ input_pos = cur_req_first_pos + pos - total_len + 1
156
+ return tl.load(input_ids_ptr + input_pos)
157
+
158
+
159
+@triton.jit
160
+def _update_committed_marker_cache_kernel(
161
+ req_ids_ptr,
162
+ thinking_token_budget_ptr,
163
+ all_token_ids_ptr,
164
+ all_token_ids_stride,
165
+ total_len_ptr,
166
+ cached_last_start_ptr,
167
+ cached_last_end_ptr,
168
+ cached_scan_pos_ptr,
169
+ reasoning_start_token_ids_ptr,
170
+ natural_reasoning_end_token_ids_ptr,
171
+ START_LEN: tl.constexpr,
172
+ NATURAL_END_LEN: tl.constexpr,
173
+ MAX_LEN: tl.constexpr,
174
+ BLOCK: tl.constexpr,
175
+):
176
+ req_state_idx = tl.load(req_ids_ptr + tl.program_id(0))
177
+ budget = tl.load(thinking_token_budget_ptr + req_state_idx)
178
+ if budget < 0:
179
+ return
180
+
181
+ total_len = tl.load(total_len_ptr + req_state_idx)
182
+ scan_pos = tl.load(cached_scan_pos_ptr + req_state_idx)
183
+ last_start = tl.load(cached_last_start_ptr + req_state_idx)
184
+ last_end = tl.load(cached_last_end_ptr + req_state_idx)
185
+
186
+ if scan_pos > total_len:
187
+ scan_pos = 0
188
+ last_start = -1
189
+ last_end = -1
190
+
191
+ if scan_pos == 0 and last_start < 0 and last_end < 0:
192
+ # Cold scan: walk backward in vectorized blocks, stopping at the first
193
+ # block with a marker; only the relative order of the two positions
194
+ # found matters below.
195
+ block_hi = total_len
196
+ while block_hi > 0 and last_start < 0 and last_end < 0:
197
+ block_lo = block_hi - BLOCK
198
+ if block_lo < 0:
199
+ block_lo = 0
200
+ offs = block_lo + tl.arange(0, BLOCK)
201
+
202
+ start_match = (offs < block_hi) & (offs + START_LEN <= total_len)
203
+ for j in tl.static_range(0, START_LEN):
204
+ expected = tl.load(reasoning_start_token_ids_ptr + j)
205
+ actual = tl.load(
206
+ all_token_ids_ptr + req_state_idx * all_token_ids_stride + offs + j,
207
+ mask=offs + j < total_len,
208
+ other=-1,
209
+ )
210
+ start_match = start_match & (actual == expected)
211
+
212
+ end_match = (offs < block_hi) & (offs + NATURAL_END_LEN <= total_len)
213
+ for j in tl.static_range(0, NATURAL_END_LEN):
214
+ expected = tl.load(natural_reasoning_end_token_ids_ptr + j)
215
+ actual = tl.load(
216
+ all_token_ids_ptr + req_state_idx * all_token_ids_stride + offs + j,
217
+ mask=offs + j < total_len,
218
+ other=-1,
219
+ )
220
+ end_match = end_match & (actual == expected)
221
+
222
+ last_start = tl.max(tl.where(start_match, offs, -1), axis=0)
223
+ last_end = tl.max(tl.where(end_match, offs, -1), axis=0)
224
+ block_hi = block_lo
225
+ else:
226
+ for i in tl.range(scan_pos, total_len):
227
+ if i + START_LEN <= total_len:
228
+ start_match = True
229
+ for j in tl.static_range(0, START_LEN):
230
+ expected = tl.load(reasoning_start_token_ids_ptr + j)
231
+ actual = tl.load(
232
+ all_token_ids_ptr + req_state_idx * all_token_ids_stride + i + j
233
+ )
234
+ start_match = start_match & (actual == expected)
235
+ if start_match:
236
+ last_start = i
237
+
238
+ if i + NATURAL_END_LEN <= total_len:
239
+ end_match = True
240
+ for j in tl.static_range(0, NATURAL_END_LEN):
241
+ expected = tl.load(natural_reasoning_end_token_ids_ptr + j)
242
+ actual = tl.load(
243
+ all_token_ids_ptr + req_state_idx * all_token_ids_stride + i + j
244
+ )
245
+ end_match = end_match & (actual == expected)
246
+ if end_match:
247
+ last_end = i
248
+
249
+ tl.store(cached_last_start_ptr + req_state_idx, last_start)
250
+ tl.store(cached_last_end_ptr + req_state_idx, last_end)
251
+ new_scan_pos = total_len - (MAX_LEN - 1)
252
+ if new_scan_pos < 0:
253
+ new_scan_pos = 0
254
+ tl.store(cached_scan_pos_ptr + req_state_idx, new_scan_pos)
255
+
256
+
257
+@triton.jit
258
+def _thinking_budget_kernel(
259
+ logits_ptr,
260
+ logits_stride,
261
+ expanded_idx_mapping_ptr,
262
+ thinking_token_budget_ptr,
263
+ all_token_ids_ptr,
264
+ all_token_ids_stride,
265
+ total_len_ptr,
266
+ input_ids_ptr,
267
+ expanded_local_pos_ptr,
268
+ cached_last_start_ptr,
269
+ cached_last_end_ptr,
270
+ reasoning_start_token_ids_ptr,
271
+ natural_reasoning_end_token_ids_ptr,
272
+ reasoning_end_token_ids_ptr,
273
+ START_LEN: tl.constexpr,
274
+ NATURAL_END_LEN: tl.constexpr,
275
+ END_LEN: tl.constexpr,
276
+):
277
+ token_idx = tl.program_id(0).to(tl.int64)
278
+ req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx)
279
+ budget = tl.load(thinking_token_budget_ptr + req_state_idx)
280
+ if budget < 0:
281
+ return
282
+
283
+ local_pos = tl.load(expanded_local_pos_ptr + token_idx)
284
+ cur_req_first_pos = token_idx - local_pos
285
+ total_len = tl.load(total_len_ptr + req_state_idx)
286
+ effective_len = total_len + local_pos
287
+
288
+ last_start = tl.load(cached_last_start_ptr + req_state_idx)
289
+ last_end = tl.load(cached_last_end_ptr + req_state_idx)
290
+
291
+ start_lo = total_len - START_LEN + 1
292
+ if start_lo < 0:
293
+ start_lo = 0
294
+ for i in tl.range(start_lo, effective_len - START_LEN + 1):
295
+ start_match = True
296
+ for j in tl.static_range(0, START_LEN):
297
+ expected = tl.load(reasoning_start_token_ids_ptr + j)
298
+ actual = _load_effective_token(
299
+ all_token_ids_ptr,
300
+ all_token_ids_stride,
301
+ input_ids_ptr,
302
+ cur_req_first_pos,
303
+ req_state_idx,
304
+ total_len,
305
+ i + j,
306
+ )
307
+ start_match = start_match & (actual == expected)
308
+ if start_match:
309
+ last_start = i
310
+
311
+ end_lo = total_len - NATURAL_END_LEN + 1
312
+ if end_lo < 0:
313
+ end_lo = 0
314
+ for i in tl.range(end_lo, effective_len - NATURAL_END_LEN + 1):
315
+ end_match = True
316
+ for j in tl.static_range(0, NATURAL_END_LEN):
317
+ expected = tl.load(natural_reasoning_end_token_ids_ptr + j)
318
+ actual = _load_effective_token(
319
+ all_token_ids_ptr,
320
+ all_token_ids_stride,
321
+ input_ids_ptr,
322
+ cur_req_first_pos,
323
+ req_state_idx,
324
+ total_len,
325
+ i + j,
326
+ )
327
+ end_match = end_match & (actual == expected)
328
+ if end_match:
329
+ last_end = i
330
+
331
+ if last_start < 0 or last_start <= last_end:
332
+ return
333
+
334
+ reasoning_start = last_start + START_LEN
335
+ # If the request resumes from a prompt that already contains generated
336
+ # reasoning content, count it against the remaining budget.
337
+ num_reasoning_tokens = effective_len - reasoning_start
338
+ if num_reasoning_tokens < budget:
339
+ return
340
+
341
+ # If the tail already ends with a prefix of the forced end sequence
342
+ # (even from a resumed prompt), continue from the next marker token.
343
+ end_prefix_len = 0
344
+ max_prefix_len = END_LEN - 1
345
+ if effective_len < max_prefix_len:
346
+ max_prefix_len = effective_len
347
+
348
+ for prefix_len in tl.static_range(1, END_LEN):
349
+ if prefix_len <= max_prefix_len:
350
+ prefix_match = True
351
+ suffix_start = effective_len - prefix_len
352
+ for j in tl.static_range(0, END_LEN):
353
+ if j < prefix_len:
354
+ expected = tl.load(reasoning_end_token_ids_ptr + j)
355
+ actual = _load_effective_token(
356
+ all_token_ids_ptr,
357
+ all_token_ids_stride,
358
+ input_ids_ptr,
359
+ cur_req_first_pos,
360
+ req_state_idx,
361
+ total_len,
362
+ suffix_start + j,
363
+ )
364
+ prefix_match = prefix_match & (actual == expected)
365
+ if prefix_match:
366
+ end_prefix_len = prefix_len
367
+
368
+ force_token_id = tl.load(reasoning_end_token_ids_ptr + end_prefix_len)
369
+ tl.store(logits_ptr + token_idx * logits_stride + force_token_id, 1.0e9)
370
+
371
+
372
+def apply_thinking_budget(
373
+ logits: torch.Tensor,
374
+ req_ids: torch.Tensor,
375
+ expanded_idx_mapping: torch.Tensor,
376
+ thinking_token_budget: torch.Tensor,
377
+ all_token_ids: torch.Tensor,
378
+ total_len: torch.Tensor,
379
+ input_ids: torch.Tensor,
380
+ expanded_local_pos: torch.Tensor,
381
+ cached_last_start: torch.Tensor,
382
+ cached_last_end: torch.Tensor,
383
+ cached_scan_pos: torch.Tensor,
384
+ reasoning_start_token_ids: torch.Tensor,
385
+ natural_reasoning_end_token_ids: torch.Tensor,
386
+ reasoning_end_token_ids: torch.Tensor,
387
+) -> None:
388
+ num_tokens = logits.shape[0]
389
+ start_len = reasoning_start_token_ids.shape[0]
390
+ natural_end_len = natural_reasoning_end_token_ids.shape[0]
391
+ end_len = reasoning_end_token_ids.shape[0]
392
+
393
+ _update_committed_marker_cache_kernel[(req_ids.shape[0],)](
394
+ req_ids,
395
+ thinking_token_budget,
396
+ all_token_ids,
397
+ all_token_ids.stride(0),
398
+ total_len,
399
+ cached_last_start,
400
+ cached_last_end,
401
+ cached_scan_pos,
402
+ reasoning_start_token_ids,
403
+ natural_reasoning_end_token_ids,
404
+ START_LEN=start_len,
405
+ NATURAL_END_LEN=natural_end_len,
406
+ MAX_LEN=max(start_len, natural_end_len),
407
+ BLOCK=_COLD_SCAN_BLOCK,
408
+ )
409
+
410
+ _thinking_budget_kernel[(num_tokens,)](
411
+ logits,
412
+ logits.stride(0),
413
+ expanded_idx_mapping,
414
+ thinking_token_budget,
415
+ all_token_ids,
416
+ all_token_ids.stride(0),
417
+ total_len,
418
+ input_ids,
419
+ expanded_local_pos,
420
+ cached_last_start,
421
+ cached_last_end,
422
+ reasoning_start_token_ids,
423
+ natural_reasoning_end_token_ids,
424
+ reasoning_end_token_ids,
425
+ START_LEN=start_len,
426
+ NATURAL_END_LEN=natural_end_len,
427
+ END_LEN=end_len,
428
+ )
vllm/v1/worker/gpu/spec_decode/rejection_sampler.py
+1
@@ -141,6 +141,7 @@ class RejectionSampler:
141
processed_logits = self.sampler.apply_sampling_params(
142
logits,
143
expanded_idx_mapping,
144
+ idx_mapping,
145
idx_mapping_np,
146
pos,
147
draft_sampled,