Skip to content

Commit a36535e

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; defaults VLLM_MOE_SKIP_PADDING=1 so MoE kernels skip the pruned 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 on the HOST, as in the paper: the draft step D2H-copies its confidence logits asynchronously, and at dispatch the host consumes the copy staged one step earlier (fixed offset — decided by step counting so every TP rank makes the identical choice, and the copy has had a full step to land, so allocation never blocks on the GPU). Algorithm 1 (greedy global admission by prefix-survival, theta = tau * SPS(R, B) argmax stopping rule, dspark_budget_frac cap) runs in numpy; capacities scatter into the host array that already shapes batch trimming/dispatch. Per-request verify budgets lag drafts by one step; new requests default to full capacity. An admit-everything ablation measures the enforcement tax at +0.1-0.3 ms/step vs no-capacity (the previous in-graph GPU allocator + capacity D2H flush cost ~2.2 ms/step of lost async-scheduling overlap). The step-rate table is 2D over (running requests R, verify batch tokens B): draft and host costs scale with R and are paid regardless of pruning, so a 1D curve over B alone overestimates pruning payoff ~2x at high concurrency and over-prunes. `dspark_sps_curve="auto"` measures the surface at engine init: REAL decode steps are timed over an (R pow2, k in {0,2,4,7}) grid with capacities forced through the mode's real enforcement path (median over chunks of pipelined steps; plain SamplingParams; rank-0 broadcast; online-STS reset afterwards). Explicit 1D breakpoint lists are still accepted (replicated across R); `dspark_sps_overhead_ms` adds scheduler/IPC time above the worker. benchmarks/profile_dspark_sps_curve.py remains for offline measurement. - Online Sequential Temperature Scaling (`dspark_online_sts`, on by default with capacity modes): per-position temperatures fitted online by an ECE grid search over binned rejection-sampler outcomes (order-preserving, per the paper Sec 3.2.1; identity until observations accumulate). Runs on the host: outcomes ride the same staged confidence copies, joined to the previous copy's logits by request id, so TP-rank agreement is plain deterministic numpy. - 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. - TP-rank determinism fixes for padded draft FULL-graph replays: padding rows of sample_idx_mapping now carry a -1 inert-row sentinel so replays never scatter draft logits through stale req-state slot ids (duplicate-index scatters have undefined write order and silently diverge per-rank state; with varlen capacity this became a collective-size-mismatch deadlock), and the online-STS proposal staging moved out of the captured graph. Worker slot recycling now iterates finished_req_ids in sorted order and fallback per-request sampling seeds come from a dedicated RNG stream, removing two more per-rank divergence hazards. Debug guard: VLLM_DSPARK_TP_CHECK={1,2} (capacity.py::check_dspark_tp_consistency) cross-checks request-keyed capacity/STS state across TP ranks each step and fails fast with per-rank state dumps. Co-authored-by: OpenAI Codex <codex@openai.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com>
1 parent 8347c6e commit a36535e

43 files changed

Lines changed: 2950 additions & 148 deletions

Some content is hidden

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
model_name: "deepseek-ai/DeepSeek-V4-Flash-DSpark"
2+
accuracy_threshold: 0.92
3+
num_questions: 1319
4+
num_fewshot: 5
5+
startup_max_wait_seconds: 1800
6+
server_args: >-
7+
--tokenizer-mode deepseek_v4
8+
--trust-remote-code
9+
--dtype bfloat16
10+
--max-model-len 8192
11+
--tensor-parallel-size 4
12+
--enable-expert-parallel
13+
--block-size 256
14+
--gpu-memory-utilization 0.5
15+
--kv-cache-dtype fp8
16+
--max-num-batched-tokens 16384
17+
--max-num-seqs 32
18+
--speculative-config '{"method":"dspark",
19+
"model":"deepseek-ai/DeepSeek-V4-Flash-DSpark",
20+
"attention_backend":"FLASH_ATTN","num_speculative_tokens":7,
21+
"draft_sample_method":"probabilistic","dspark_confidence_threshold":0.0,
22+
"dspark_budget_frac":0.5,"dspark_capacity_verification_mode":"varlen"}'

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/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)