Skip to content

Commit f4ee8d9

Browse files
LucasWilkinsoncodexclaude
committed
[Spec Decode] DSpark capacity reallocation with varlen full-CG verification
Implements DSpark (arXiv 2607.05147) confidence-scheduled verification with two capacity enforcement modes and full CUDA graph support: - Capacity manager with `mask` (pad pruned verify rows) and `varlen` (compact the verifier batch) modes; varlen replays FULL CUDA graphs for both the target verify step and the DSpark draft query step. - Paper-faithful Algorithm 1 allocator: capacities are the greedy admission counts (sum(capacities) == spent budget, hard cap; zero-survival tokens are never candidates), fixing a threshold-recount tie escape that disabled the budget under saturated confidence logits. - Hardware-aware prefix scheduler: optional `dspark_sps_curve` config (profiled steps-per-second vs verification batch tokens) drives the theta = tau * SPS(B) argmax stopping rule; `dspark_budget_frac` remains as an admission upper bound. Includes benchmarks/profile_dspark_sps_curve.py to measure the curve from the captured cudagraph staircase. - Varlen full-CG correctness fixes: per-request token bound in cudagraph dispatch, capture/replay buffer-address consistency in the DSA indexer varlen decode path (forced flatten + persistent indices buffer), padded-row sizing in the indexer build, TP-deterministic capacity flushes, and correct handling of the scheduler's -1 draft placeholder ids in capacity accounting. Co-authored-by: OpenAI Codex <codex@openai.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHmbeNn4JRhg5zaZqPiGHm Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com>
1 parent 8347c6e commit f4ee8d9

40 files changed

Lines changed: 2667 additions & 142 deletions
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
"""Profile the engine step-rate curve for the DSpark prefix scheduler.
4+
5+
Times the captured FULL cudagraph replays of the target verification step at
6+
every captured batch token count and emits ``dspark_sps_curve`` breakpoints
7+
encoding the resulting staircase (step time is constant within a cudagraph
8+
padding bucket and jumps at capture-size boundaries).
9+
10+
Example:
11+
python benchmarks/profile_dspark_sps_curve.py <target-model> \\
12+
--speculative-config '{"method": "dspark", "model": "...", ...}' \\
13+
--engine-args '{"tensor_parallel_size": 4, "max_num_seqs": 32}'
14+
15+
Paste the printed ``dspark_sps_curve`` entry into --speculative-config.
16+
17+
Caveats: replays run on whatever (dummy) buffer state capture left behind, so
18+
data-dependent kernels (e.g. MoE routing) may be timed on unrepresentative
19+
inputs, and per-step CPU/draft overhead is modeled only through the constant
20+
``--overhead-ms``. Only the curve's shape matters to the scheduler.
21+
"""
22+
23+
import argparse
24+
import json
25+
26+
27+
def _time_fullgraph_replays(worker, iters: int, warmup: int) -> dict[int, float]:
28+
"""Worker-side: time FULL graph replay per batch token count (ms/step).
29+
30+
Runs on every TP rank via collective_rpc so the collectives captured in
31+
the graphs stay matched; every rank replays the same descs in the same
32+
sorted order. Before timing each descriptor the input buffers are
33+
refreshed into the same coherent dummy state capture used, so replays
34+
never read stale metadata.
35+
"""
36+
import torch
37+
38+
from vllm.v1.worker.gpu.cudagraph_utils import prepare_inputs_to_capture
39+
40+
runner = worker.model_runner
41+
mgr = runner.cudagraph_manager
42+
assert mgr is not None and mgr.graphs, (
43+
"No FULL cudagraphs captured; run with a cudagraph_mode that captures "
44+
"FULL decode graphs."
45+
)
46+
# Prefer varlen spec-decode descs; fall back to all captured graphs.
47+
descs = [d for d in mgr.graphs if d.max_req_tokens is not None]
48+
if not descs:
49+
descs = list(mgr.graphs.keys())
50+
# One desc per token count: the largest request count is the most
51+
# representative shape under load.
52+
by_tokens: dict[int, object] = {}
53+
for d in descs:
54+
cur = by_tokens.get(d.num_tokens)
55+
if cur is None or (d.num_reqs or 0) > (cur.num_reqs or 0):
56+
by_tokens[d.num_tokens] = d
57+
58+
results: dict[int, float] = {}
59+
for num_tokens in sorted(by_tokens):
60+
desc = by_tokens[num_tokens]
61+
num_reqs = desc.num_reqs or min(num_tokens, mgr.max_num_reqs)
62+
prepare_inputs_to_capture(
63+
num_reqs,
64+
num_tokens,
65+
runner.model_state,
66+
runner.input_buffers,
67+
runner.block_tables,
68+
runner.attn_groups,
69+
runner.kv_cache_config,
70+
max_req_tokens=desc.max_req_tokens,
71+
)
72+
graph = mgr.graphs[desc]
73+
for _ in range(warmup):
74+
graph.replay()
75+
torch.accelerator.synchronize()
76+
start = torch.Event(enable_timing=True)
77+
end = torch.Event(enable_timing=True)
78+
start.record()
79+
for _ in range(iters):
80+
graph.replay()
81+
end.record()
82+
torch.accelerator.synchronize()
83+
results[num_tokens] = start.elapsed_time(end) / iters
84+
return results
85+
86+
87+
def staircase_breakpoints(
88+
ms_per_step: dict[int, float], overhead_ms: float
89+
) -> list[list[float]]:
90+
"""Convert per-capture-size step times into ``dspark_sps_curve``
91+
breakpoints. Batches pad UP to the next capture size, so every B in
92+
``(prev_size, size]`` runs at ``size``'s rate; encode each bucket as a
93+
flat segment with one-token jumps at the boundaries."""
94+
curve: list[list[float]] = []
95+
prev_size = 0
96+
for size in sorted(ms_per_step):
97+
rate = round(1000.0 / (ms_per_step[size] + overhead_ms), 3)
98+
seg_start = prev_size + 1
99+
if seg_start < size:
100+
curve.append([seg_start, rate])
101+
curve.append([size, rate])
102+
prev_size = size
103+
return curve
104+
105+
106+
def main() -> None:
107+
parser = argparse.ArgumentParser(description=__doc__)
108+
parser.add_argument("model", help="Target model (path or HF id)")
109+
parser.add_argument(
110+
"--speculative-config",
111+
required=True,
112+
help="JSON speculative config (same value you pass to vllm serve)",
113+
)
114+
parser.add_argument(
115+
"--engine-args",
116+
default="{}",
117+
help="JSON dict of extra vllm.LLM kwargs "
118+
'(e.g. \'{"tensor_parallel_size": 4, "max_num_seqs": 32}\')',
119+
)
120+
parser.add_argument("--iters", type=int, default=50)
121+
parser.add_argument("--warmup", type=int, default=5)
122+
parser.add_argument(
123+
"--overhead-ms",
124+
type=float,
125+
default=0.0,
126+
help="Constant per-step overhead (draft forward, sampling, CPU gap) "
127+
"added to every measured step time before converting to a rate.",
128+
)
129+
parser.add_argument("--output", help="Write the curve JSON to this file")
130+
args = parser.parse_args()
131+
132+
# The timing callable is shipped to the workers via collective_rpc, which
133+
# requires the pickle fallback. Local profiling tool, trusted input.
134+
import os
135+
136+
os.environ.setdefault("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
137+
138+
from vllm import LLM
139+
140+
llm = LLM(
141+
model=args.model,
142+
speculative_config=json.loads(args.speculative_config),
143+
**json.loads(args.engine_args),
144+
)
145+
per_rank = llm.collective_rpc(
146+
_time_fullgraph_replays, kwargs={"iters": args.iters, "warmup": args.warmup}
147+
)
148+
ms_per_step = per_rank[0]
149+
150+
print("\nMeasured FULL-graph step times (rank 0):")
151+
for size in sorted(ms_per_step):
152+
print(f" B={size:5d} tokens: {ms_per_step[size]:8.3f} ms/step")
153+
154+
curve = staircase_breakpoints(ms_per_step, args.overhead_ms)
155+
entry = {"dspark_sps_curve": curve}
156+
print("\nAdd to --speculative-config:")
157+
print(json.dumps(entry))
158+
if args.output:
159+
with open(args.output, "w") as f:
160+
json.dump(entry, f, indent=2)
161+
print(f"\nWritten to {args.output}")
162+
163+
164+
if __name__ == "__main__":
165+
main()

tests/engine/test_arg_utils.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,43 @@ def test_jit_monitor_mode_arg(mode):
225225
assert engine_args.create_observability_config().jit_monitor_mode == mode
226226

227227

228+
def test_dspark_capacity_verification_mode_arg():
229+
parser = EngineArgs.add_cli_args(FlexibleArgumentParser())
230+
args = parser.parse_args(
231+
[
232+
"--spec-method",
233+
"ngram",
234+
"--spec-tokens",
235+
"1",
236+
"--dspark-capacity-verification-mode",
237+
"mask",
238+
]
239+
)
240+
241+
engine_args = EngineArgs.from_cli_args(args)
242+
assert engine_args.dspark_capacity_verification_mode == "mask"
243+
speculative_config = engine_args.create_speculative_config(None, None)
244+
assert speculative_config is not None
245+
assert speculative_config.dspark_capacity_verification_mode == "mask"
246+
247+
248+
def test_dspark_capacity_verification_mode_conflicts_with_speculative_config():
249+
parser = EngineArgs.add_cli_args(FlexibleArgumentParser())
250+
args = parser.parse_args(
251+
[
252+
"--speculative-config",
253+
'{"method":"ngram","num_speculative_tokens":1,'
254+
'"dspark_capacity_verification_mode":"mask"}',
255+
"--dspark-capacity-verification-mode",
256+
"varlen",
257+
]
258+
)
259+
260+
engine_args = EngineArgs.from_cli_args(args)
261+
with pytest.raises(ValueError, match="dspark_capacity_verification_mode"):
262+
engine_args.create_speculative_config(None, None)
263+
264+
228265
def test_hf_token_get_kwargs():
229266
kwargs = get_kwargs(ModelConfig)["hf_token"]
230267

tests/test_config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,55 @@ def test_draft_sample_method_gumbel_is_rejected():
15721572
)
15731573

15741574

1575+
def test_dspark_capacity_config_validation():
1576+
speculative_config = SpeculativeConfig(
1577+
method="ngram",
1578+
num_speculative_tokens=1,
1579+
dspark_confidence_threshold=0.25,
1580+
dspark_budget_frac=0.5,
1581+
dspark_capacity_verification_mode="mask",
1582+
)
1583+
assert speculative_config.dspark_confidence_threshold == 0.25
1584+
assert speculative_config.dspark_budget_frac == 0.5
1585+
assert speculative_config.dspark_capacity_verification_mode == "mask"
1586+
assert (
1587+
SpeculativeConfig(
1588+
method="ngram", num_speculative_tokens=1
1589+
).dspark_capacity_verification_mode
1590+
== "varlen"
1591+
)
1592+
assert (
1593+
SpeculativeConfig(
1594+
method="ngram",
1595+
num_speculative_tokens=1,
1596+
dspark_capacity_verification_mode="compact",
1597+
).dspark_capacity_verification_mode
1598+
== "varlen"
1599+
)
1600+
assert (
1601+
SpeculativeConfig(
1602+
method="ngram", num_speculative_tokens=1
1603+
).dspark_confidence_threshold
1604+
== 0.0
1605+
)
1606+
1607+
for threshold in (-0.1, 1.1):
1608+
with pytest.raises(ValueError, match="dspark_confidence_threshold"):
1609+
SpeculativeConfig(
1610+
method="ngram",
1611+
num_speculative_tokens=1,
1612+
dspark_confidence_threshold=threshold,
1613+
)
1614+
1615+
for budget_frac in (0.0, -0.1, 1.1):
1616+
with pytest.raises(ValueError, match="dspark_budget_frac"):
1617+
SpeculativeConfig(
1618+
method="ngram",
1619+
num_speculative_tokens=1,
1620+
dspark_budget_frac=budget_frac,
1621+
)
1622+
1623+
15751624
def test_ir_op_priority_default():
15761625
"""Test that IR op priority defaults are set correctly."""
15771626
from vllm.config.kernel import IrOpPriorityConfig

tests/v1/spec_decode/test_rejection_sampler_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def test_stochastic_rejection_sample(num_speculative_steps: int, temperature: fl
150150

151151
torch.manual_seed(42)
152152
device = "cuda"
153-
num_trials = 10 * VOCAB_SIZE
153+
num_trials = 64
154154

155155
target_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
156156
draft_logits_1d = torch.randn(VOCAB_SIZE, device=device, dtype=torch.float32)
@@ -288,7 +288,7 @@ def test_placeholder_draft_token_rejected():
288288
"""
289289
torch.manual_seed(0)
290290
device = "cuda"
291-
num_trials = 64
291+
num_trials = 10 * VOCAB_SIZE
292292
K = 1
293293
temperature = 0.6
294294

tests/v1/worker/test_gpu_block_table.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import torch
66

77
from vllm.platforms import current_platform
8+
from vllm.v1.attention.backends.utils import PAD_SLOT_ID
89
from vllm.v1.worker.gpu.block_table import BlockTables
910

1011
pytestmark = pytest.mark.skipif(
@@ -130,3 +131,49 @@ def test_block_tables_apply_staged_writes_single_group():
130131
block_tables.block_tables[0].gpu[0, :2],
131132
torch.tensor([1, 2], dtype=torch.int32, device=device),
132133
)
134+
135+
136+
def test_compute_slot_mappings_applies_padding_mask():
137+
device = torch.device("cuda")
138+
block_tables = BlockTables(
139+
block_sizes=[16],
140+
max_num_reqs=2,
141+
max_num_batched_tokens=8,
142+
max_num_blocks_per_group=[4],
143+
device=device,
144+
kernel_block_sizes=[16],
145+
)
146+
147+
block_tables.append_block_ids(
148+
req_index=0,
149+
new_block_ids=([2],),
150+
overwrite=True,
151+
)
152+
block_tables.append_block_ids(
153+
req_index=1,
154+
new_block_ids=([3],),
155+
overwrite=True,
156+
)
157+
block_tables.apply_staged_writes()
158+
159+
idx_mapping = torch.tensor([0, 1], dtype=torch.int32, device=device)
160+
query_start_loc = torch.tensor([0, 3, 5], dtype=torch.int32, device=device)
161+
positions = torch.tensor([0, 1, 2, 0, 1], dtype=torch.int64, device=device)
162+
is_padding = torch.tensor(
163+
[False, True, False, False, True, False, False, False],
164+
dtype=torch.bool,
165+
device=device,
166+
)
167+
168+
slot_mappings = block_tables.compute_slot_mappings(
169+
idx_mapping,
170+
query_start_loc,
171+
positions,
172+
num_tokens_padded=8,
173+
is_padding=is_padding,
174+
)
175+
torch.accelerator.synchronize()
176+
177+
assert slot_mappings.cpu().tolist() == [
178+
[32, PAD_SLOT_ID, 34, 48, PAD_SLOT_ID, PAD_SLOT_ID, PAD_SLOT_ID, PAD_SLOT_ID]
179+
]

0 commit comments

Comments
 (0)