Repository navigation
[Perf][Spec Decode] Vocab-parallel DSpark Markov sampling and vocab-sharded verification over TP - #65007
[Perf][Spec Decode] Vocab-parallel DSpark Markov sampling and vocab-sharded verification over TP#65007MrGeva wants to merge 9 commits into
Conversation
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
| """Runs a DSpark speculator's Markov loop vocab-parallel over TP.""" | ||
|
|
||
| def __init__(self, speculator: DSparkSpeculator): | ||
| import torch.distributed._symmetric_memory as symm_mem |
There was a problem hiding this comment.
No fallback when symmetric memory is unavailable. TPMarkovSampler(self) is constructed unguarded from DSparkSpeculator._try_enable_tp_markov_sampling, but the private import here and symm_mem.empty / symm_mem.rendezvous (L348-360) can raise: RuntimeError on PCIe-only, no-P2P or multi-node TP, ImportError on a torch build without it. The test itself catches RuntimeError ("symmetric memory is unavailable"), and SymmMemCommunicator wraps the same calls in try/except RuntimeError and disables itself. Here the exception escapes load_model, so setting dspark_tp_markov_sampling on such a node kills engine start-up. That contradicts the config docstring ("otherwise the replicated loop runs") and the "ignored because ..." warning path. rendezvous is collective, so if only some ranks fail the others stay blocked in it. unsupported_reason also has no check that the TP group is single-node or peer-reachable.
Suggest wrapping the import and construction in try/except, logging the warning and leaving _tp_markov_sampler = None, with the decision agreed across ranks (same cpu_group all_reduce as the projection check).
There was a problem hiding this comment.
Agreed, thanks. Fixed in eddf09a (test in db489e9).
An exception from the import, symm_mem.empty or rendezvous escaped load_model, and since rendezvous is collective, a failure on one rank could leave the others waiting.
TPMarkovSampler.create() now sets up the exchange in two steps, and every TP rank agrees on each one over the CPU group, as the projection check already did:
- Import and
symm_mem.emptyrun in atry. Anall_reduce(MAX)of the failure flag follows, so no rank entersrendezvousunless every rank has allocated. rendezvousruns in atry, followed by a second agreement.
On any failure every rank returns (None, reason). The speculator then logs "dspark_tp_markov_sampling is ignored because symmetric memory is unavailable (...)" and keeps the replicated loop. The test makes symm_mem.empty raise on rank 1 only, then checks that both ranks fall back without hanging. A multi-node or non-P2P group falls back through the same path (failure in empty/rendezvous), so I did not add a separate node check.
One case is still not covered: a rank that fails inside rendezvous before it publishes its handle to the store. The other ranks would then wait in torch's store exchange, which we can't interrupt from here. A peer's failure while importing handles happens after the exchange, so that case is caught.
| (mask & (((w >> 18) & ((1 << 14) - 1)) != tag)).to(tl.int32), axis=0 | ||
| ) | ||
| it += 1 | ||
| if bad > 0: |
There was a problem hiding this comment.
Exchange timeouts are silently swallowed. After SPIN_LIMIT iterations the kernel only does atomic_add(err_ptr, 1) and carries on with whatever is in w, i.e. stale or zero words. The sampled token is then wrong, and in push mode (L275) stale or zero bf16 values are written into the draft-logits cache. errors is never read on the host outside the test. In production a late or missing peer therefore produces drafts whose cached logits no longer match the drawn tokens, which biases the rejection-sampling probability ratio, with no log line.
Also, _SPIN_LIMIT = 1 << 24 is described as "about a second" (L72). Each iteration is a volatile global load plus a block reduction (roughly 0.5-1 us), so it is closer to 10 s per kernel, times 7 combine kernels per draft step if a peer is gone. That is effectively a hang with a delayed, silent corruption instead of an error.
Suggest checking errors on the host (e.g. once per propose or every N steps) and raising or disabling the feature, and fixing the comment.
There was a problem hiding this comment.
Agreed, the timeout was silent: it was only counted in errors and never read. Fixed in eddf09a and f2ffea6.
On the spin limit: it was neither about 1 s nor about 10 s. I measured it on GB300: 0.24 µs per iteration (one 196-block row), so 1 << 24 is about 4.0 s per kernel.
_SPIN_LIMIT = 1 << 23, about 2 s per kernel, and the comment now gives the measured cost.- On a timeout the kernels set a flag directly in pinned host memory (a
UvaBuffer), so the normal path has no extra work. DSparkSpeculator.propose()callssampler.check_exchange(). It reads that flag without synchronizing and raisesRuntimeError("DSpark vocab-parallel Markov sampling timed out waiting for a TP peer; ..."). The error therefore surfaces at most one step later, instead of producing silent bad drafts.- An intermediate version copied a device counter to the host after every draft step instead. That memcpy node cost about 5 µs per step in the draft graph, so I dropped it.
- The test checks this: only rank 0 samples (spin limit patched down), and
check_exchange()raises.
I chose raising over disabling the feature at runtime. The sampler's kernels are captured in the draft CUDA graphs, so disabling it would mean recapturing them. Also, a peer that misses an exchange by seconds in a lockstep TP group means the group itself is unhealthy: the target's NCCL collectives would stall on it too.
| APPLY_TEMPERATURE=True, | ||
| ) | ||
| token = (block_idx * BLOCK_SIZE + idx).to(tl.int64) | ||
| tag = e & ((1 << 14) - 1) |
There was a problem hiding this comment.
14-bit tag aliasing. Freshness is tag == e & 0x3FFF. A (row, block) word is refreshed only while that row is active, so a slot not written for a multiple of 16384 epochs (about 2340 draft steps at K=7, i.e. seconds at serving rates) and then read again has the expected tag and is accepted without waiting.
- The zero-initialised buffer: at epoch 16384 the tag is 0, so any never-written word passes immediately ("Tags never match a zeroed word: the first epoch is 1" holds only for the first 16383 epochs).
- Concurrency drops below row r, stays there for exactly 16384*k epochs (same parity slot), then rises: row r's old words (tag from epoch e-16384k) are read as fresh before the peers' writes land, so a token and max from a past draw are used.
The combine kernel (L192) has the same check. The push path avoids this with the full 32-bit epoch.
The word is full (32 value + 14 tag + 18 token), but the block index is implied by the word's position, so only the 10-bit in-block index needs storing. That frees 8 bits for a 22-bit tag, and removes the 2^18 vocabulary limit.
There was a problem hiding this comment.
Confirmed, and reproduced on GB300 against the previous head. Fixed in eddf09a (regression test in db489e9).
The reproducer used a fresh, zeroed words buffer with the epoch set so the first tag is 0 (epoch 16383), and delayed the peer by about 1 s with torch.cuda._sleep before its sample. The rank that is on time then accepts the zero words, and the drafted tokens differ from the replicated loop's, with errors == 0:
| case | TP2 rank 0 token mismatches | TP4 mismatches per rank |
|---|---|---|
| epoch 100, B=4 (control) | 0 | 0 |
| epoch 16383 (tag 0 on zeroed words), B=4 push | 2 | 6–10 |
| epoch 16383, B=8 recompute | 10 | 11–17 |
| epoch 32767 (stale words written 16384 epochs earlier, same tag), B=4 | 2 | 6–10 |
Fixed as you suggested, plus one more step:
- Word layout. The word is now
[fp32 block max | 22-bit tag | 10-bit index in the block]. The token is rebuilt from the word's block position, which also removes the 2^18 vocabulary limit. - Tags are never 0. The tag is
epoch % (2^22 - 1) + 1, so a zeroed or cleared word never matches. The epoch is int64. - Readers clear consumed words. The combine kernel (and the push-unpack kernel) clear each word right after reading it. The next write to a slot comes two epochs later, after the peer has seen this rank's next word, so a slot only ever holds 0 or the word of the epoch being read. A stale word can no longer alias, whatever the tag width.
- Regression test. A late peer at epochs 0, 2^14 − 1 and 2^22 − 2 is waited for, and tokens and cache match the replicated loop.
| x = torch.randn( | ||
| n_spec * num_reqs, w2.shape[1], device=w2.device, generator=gen | ||
| ).to(w2.dtype) | ||
| steps = [F.linear(x[i::n_spec].contiguous(), w2) for i in range(n_spec)] |
There was a problem hiding this comment.
The exactness guard does not test the stock path. inexact_projection_reason compares F.linear(x, w2[c0:c1]) and the batched F.linear with the full F.linear. The replicated loop's bias is model.markov_bias -> LogitsProcessor._apply_head -> markov_w2.quant_method.apply -> dispatch_unquantized_gemm(). On CUDA that can route to torch.ops.vllm.cuda_skinny_unquantized_gemm (layer.use_skinny_gemm, set for ParallelLMHead by maybe_use_skinny_gemm in UnquantizedEmbeddingMethod.process_weights_after_loading) or, with VLLM_BATCH_INVARIANT, to linear_batch_invariant. The latter is excluded in unsupported_reason; the former is not.
sample() calls F.linear directly on cached weight views (here for the recompute path and per step). If the stock projection ever uses a different kernel (a skinny plan for a (vocab, markov_rank) shape, a dispatch change), the guard still reports "exact" while tokens and cache silently diverge from the replicated loop.
Suggest adding getattr(head.markov_w2, "use_skinny_gemm", False) to unsupported_reason, and using the real model.markov_bias(...) output as the guard's reference.
There was a problem hiding this comment.
Agreed, as a latent risk. There is no divergence today: skinny-GEMM plans exist only for SM121, and none for a (vocab, markov_rank) shape; VLLM_BATCH_INVARIANT was already excluded. Fixed both ways in eddf09a:
unsupported_reasonnow rejectsmarkov_w2.use_skinny_gemm("markov_w2 uses the skinny GEMM").- The guard's reference is now the real
model.markov_bias(...)(DSparkMarkovHead → LogitsProcessor →quant_method.apply→dispatch_unquantized_gemm), at every batch size. A different kernel or dispatch on the stock path now fails the guard, and the replicated loop runs. The guard checks this rank's row slice at M = num_reqs, and the peers' row slices that the recompute path uses at M = 7·num_reqs, row by row against the per-step stock projection.
| assert sampler.errors.item() == 0 | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not current_platform.is_cuda(), reason="needs CUDA") |
There was a problem hiding this comment.
This test never runs in CI. The only step that selects tests/v1/spec_decode as a directory is the single-device "H200 MIG 35GB V1 Spec Decode" step in .buildkite/test_areas/spec_decode.yaml. There device_count() < world_size, so every case hits pytest.skip("Not enough GPUs to run the test."). Multi-GPU spec-decode tests (e.g. v1/e2e/spec_decode/test_sharded_sampling.py) are listed explicitly in distributed.yaml. This PR adds no step and the test has no multi_gpu_test / distributed marker.
tests/AGENTS.md: "A test no step in .buildkite/test_areas/*.yaml selects never runs, which looks like passing." The PR description's "CI runs the test on TP >= 2 CUDA runners only" is not backed by any CI config.
Suggest adding it to a >=2-GPU step and using multi_gpu_test(num_gpus=...) from tests/utils.py instead of the hand-rolled mp.spawn plus skip.
There was a problem hiding this comment.
Right, the H200 MIG step only skipped it. Fixed in db489e9.
I added a :nvidia: (B200) V1 Spec Decode DSpark TP Markov step (b200-k8s, 2 GPUs) to .buildkite/test_areas/spec_decode.yaml. It has source_file_dependencies on the DSpark speculator directory, gumbel.py, the logits processor, the vocab-parallel embedding, qwen3_dspark.py and the test. The tests use multi_gpu_test(num_gpus=2) / multi_gpu_test(num_gpus=4) instead of the hand-rolled skip.
TP4 does not run in CI (the step has 2 GPUs). I ran it on GB300 (TP2 and TP4, both vocabularies: 4 passed). The PR description is corrected.
| g = torch.Generator(device=device).manual_seed(0) | ||
| w1 = torch.randn(vocab, RANK, device=device, generator=g).bfloat16() | ||
| w2 = (torch.randn(vocab, RANK, device=device, generator=g) / 4).bfloat16() | ||
| model = SimpleNamespace( |
There was a problem hiding this comment.
Stand-ins instead of real objects, and the reference is not the stock loop. tests/AGENTS.md: "Construct vLLM objects with their real constructors; do not stand them in with SimpleNamespace or MagicMock". _fake_speculator builds the speculator, draft model, markov_head, logits_processor and draft_logits from SimpleNamespace. _replicated_loop re-implements the loop as F.linear(markov_embed(prev), w2) plus gumbel_sample.
So the test proves equality with the author's model of the stock loop, not with DSparkSpeculator._sample_markov_chain / model.markov_bias (LogitsProcessor + quant_method.apply). Any difference on the real path (GEMM dispatch, dtype handling, row_mapping) is invisible.
Suggest building a real DSparkMarkovHead / DSparkSpeculator (test_dspark_topk.py already builds a real DSparkMarkovHead) and calling the real _sample_markov_chain as the reference.
There was a problem hiding this comment.
Agreed. Fixed where feasible in db489e9. The test now uses real objects:
- a real
DSparkMarkovHeadbuilt with its constructor (ParallelLMHead+UnquantizedEmbeddingMethod.process_weights_after_loading); - a real
LogitsProcessor,DraftLogitsCacheandInputBuffers.
Both arms run the real DSparkSpeculator._sample_sequential. With the sampler off, that is the stock _sample_markov_chain → model.markov_bias → _sample_logits → gumbel_sample. With it on, it takes the new early return. The test model wires markov_embed / markov_bias exactly as Qwen3 DSpark does.
What is still not built with its constructor is the DSparkSpeculator itself. Its constructor and load_model need a DSpark checkpoint's config and draft weights, so the test sets the state its sampling reads via __new__, as test_dspark_topk.py does. An LLM-level TP2 test would need a DSpark checkpoint, and the smallest are M3.1 Flash / DSV4, which CI cannot load.
The real-model end-to-end check is out of tree. It used M3.1 Flash's draft model at TP4 on GB300, running the replicated _sample_sequential and the vocab-parallel one from identical draft metadata. Tokens and the whole draft-logits cache were bitwise equal at B = 1…128 on all ranks, in both modes.
| reason, | ||
| ) | ||
| return | ||
| self._tp_markov_sampler = TPMarkovSampler(self) |
There was a problem hiding this comment.
The enable/fallback glue has no test. TPMarkovSampler.unsupported_reason, _try_enable_tp_markov_sampling, the load_model hook and the _sample_sequential early return are not exercised anywhere. The new test calls only inexact_projection_reason and the sampler on a SimpleNamespace that has none of _draft_topk, draft_watermarker, acceptance_estimator, use_fp64_gumbel, use_confidence_head, _d2t_scatter_index. An attribute typo in unsupported_reason would only surface as an AttributeError at engine load. The PR description itself says the new glue "has not run end to end on main".
At minimum, a unit test over unsupported_reason for each fallback reason, plus one LLM-level run with dspark_tp_markov_sampling on TP=2 comparing against the replicated loop.
There was a problem hiding this comment.
Agreed. Added in db489e9:
unsupported_reason. One case per reason: greedy, batch-sharded cache, non-bf16 cache, top-k, reduced vocab, confidence head, fp64 Gumbel, scale, soft cap, head dtype, skinny GEMM. Each sets one attribute and checks the reason, so a typo there fails the test instead of the engine load._try_enable_tp_markov_sampling. Exercised with symmetric memory failing on one rank.- The
_sample_sequentialearly return. Exercised because both arms go through_sample_sequential, eagerly and under CUDA-graph capture/replay. propose→check_exchange. Covered by the timeout case.
An LLM-level TP2 run is not possible in CI without a DSpark checkpoint (see the previous thread). On GB300 the M3.1 Flash serving smoke (TP4, concurrency 1/8/32, two runs each) logs "DSpark Markov sampling is vocab-parallel over TP." on all 4 ranks in every run, with no fallback warning, no timeout and no error, and mean acceptance length in the same range as the base arm.
| spc = _fake_speculator(vocab, device) | ||
| # The projections of these vocabularies are exact on the tested GPUs; | ||
| # where they are not, the speculator keeps the replicated loop. | ||
| assert TPMarkovSampler.inexact_projection_reason(spc) is None |
There was a problem hiding this comment.
Hard assert on a hardware-dependent property. inexact_projection_reason depends on which cuBLAS kernels get picked, which the PR says varies by shape and GPU (validated on SM103 only). Asserting is None turns the by-design outcome "feature falls back to the replicated loop" on another GPU or cuBLAS version into a test failure, once the test actually runs in CI on H100/B200. The comment directly above says the fallback is expected where results are inexact.
Either skip with the returned reason (equality is only meaningful when the sampler is eligible), or assert on the reason string per known GPU.
There was a problem hiding this comment.
Agreed. Fixed in db489e9: the worker now calls TPMarkovSampler.create(). If it falls back (inexact projection on this GPU or cuBLAS, or no symmetric memory), the test skips with the returned reason instead of failing. Equality is only asserted when the sampler is active.
|
|
||
| N_SPEC = 7 | ||
| RANK = 64 | ||
| MAX_REQS = 32 |
There was a problem hiding this comment.
Coverage stops at 32 requests, but the feature is enabled up to 128. MAX_REQS = 32 and the batch list is (1, 2, 4, 5, 16, 32). The recompute path at 33-128 requests (where _MAX_REQS, the load-time sweep and the n_spec * num_reqs batched GEMM matter most, with a ~360 MB bias tensor) is covered only by the out-of-tree harness mentioned in the PR body (B=64).
Suggest parametrising MAX_REQS to 128 (or at least 64) with at least one case above 32.
There was a problem hiding this comment.
Fixed in db489e9. MAX_REQS = 128 now, with cases 1, 2, 4, 5, 33 and 128 requests in both completion modes, plus CUDA-graph replays at 3 and 8. The load-time guard runs over 1…128 in every test. On the real M3.1 Flash draft model (TP4, GB300) I checked B = 1, 2, 4, 8, 16, 32, 64, 96 and 128: all bitwise equal.
| 2 * self.max_reqs * self.num_blocks, dtype=torch.int64, device=device | ||
| ) | ||
| half = _BLOCK_SIZE // 2 | ||
| self.stage = symm_mem.empty( |
There was a problem hiding this comment.
Memory claim is off by roughly 10x. The staging buffer is 2 * world * push_max_reqs * n_spec * per * (BLOCK_SIZE // 2) int64, i.e. 2447495128 B ~ 43 MiB at TP4, V=200064, K=7 (similar at TP8). The PR says "a few MB (words, plus push staging for 4 requests)". The words buffer adds only ~0.4 MB. It is allocated and zeroed on every rank up front, even for a workload that never drops below push_max_reqs.
Worth correcting the description, and considering whether push staging should be sized for fewer slots.
There was a problem hiding this comment.
Correct. The push staging buffer is 2 · world · 4 · 7 · per · 512 · 8 B, which is 42.9 MiB at TP2 and TP4 and 43.8 MiB at TP8 for V = 200064, K = 7. The word buffer adds 0.38 MiB at 128 requests (measured: stage 42.9 MiB, words 0.38 MiB per rank). I fixed the description: about 43 MiB of symmetric memory per rank, allocated and zeroed once at load, and only when the option is on.
I left the sizing as is (about 0.015% of a GB300's memory). It could be halved by dropping the parity double buffer, which the consume-and-clear protocol no longer strictly needs, but I'd rather not change the layout in this PR.
| prevs = torch.cat( | ||
| [anchor.unsqueeze(1), draft_tokens[:num_reqs, : n_spec - 1]], dim=1 | ||
| ) | ||
| bias = F.linear(model.markov_embed(prevs.reshape(-1)), self.w2) |
There was a problem hiding this comment.
Recompute mode projects columns it then throws away. Each rank computes the full [num_reqs * n_spec, vocab] bias (reads all of w2, up to ~360 MB of bf16 output at 128 requests), and _tp_markov_fill_kernel then skips this rank's own [own_start, own_end) blocks. Projecting only the peers' row ranges (w2[:col_start], w2[col_end:]) would cut GEMM traffic and the temporary by 1/TP. If this is deliberately avoided because the bitwise guard only covers the full-weight shape, that is worth a comment, since it is the dominant cost of the path used for 5-128 requests.
There was a problem hiding this comment.
Agreed. Fixed in eddf09a: the recompute path now projects only the peers' rows, w2[:col_start] and w2[col_end:], as two GEMMs (one on the first and last rank). The fill kernel reads the left or right part by block, and the guard checks exactly those two shapes at M = 7·num_reqs, so the cache stays bitwise equal; this was verified at B = 8…128 on the real model.
Measured on GB300 at TP4: the recompute GEMM alone gets 4–7 µs faster on the slowest rank (B8: 23.1 → 16.5 µs, B64: 51.2 → 44.1, B128: 88.2 → 83.1). The middle ranks need two GEMMs, which eats part of the 1/TP saving. At the level of the whole loop, the difference against projecting the full weight is within run-to-run noise (±5 µs at B ≥ 8, ABAB on one node).
| n_spec = speculator.num_speculative_steps | ||
| gen = torch.Generator(device=w2.device).manual_seed(0) | ||
| bad = None | ||
| for num_reqs in range(1, min(speculator.max_num_reqs, _MAX_REQS) + 1): |
There was a problem hiding this comment.
Load-time sweep is heavy. On every TP rank, for each of up to 128 batch sizes, this runs n_spec + 2 GEMMs against the whole ~100 MB w2, materialises two ~360 MB intermediates at the top end, and blocks on torch.equal (host sync) several times per size. cuBLAS heuristics are piecewise in M, so checking the CUDA-graph capture sizes (what actually runs) plus a sparse set of other sizes would give the same practical coverage at a fraction of the startup cost and peak memory.
There was a problem hiding this comment.
Thanks. Partly valid: I measured it before changing anything. On GB300 the old guard took 0.26 s per rank at TP4 and 1.24 s at TP2 (first call, including cuBLAS warm-up), with about 1 GiB of transient allocations from the stacked per-step outputs. That is high for what it is, but not a startup problem.
I kept checking every batch size from 1 to max_reqs rather than only the capture sizes plus a sparse set. The draft also runs eagerly or piecewise, for example above the largest capture size and on non-FULL paths, at any batch size, and the guard is about bitwise equality, where interpolating between sizes isn't safe. Instead I made it cheaper (eddf09a):
- no host sync per size: mismatches accumulate in a device flag, read once;
- comparisons per step against
model.markov_bias, without materializing all steps; empty_cache()afterwards.
On the real M3.1 Flash draft model at TP4, the whole create() (guard plus symmetric-memory setup) now takes 0.42 s, with a 367 MiB peak. The guard is now also stricter: real reference, peers' slices.
| ) | ||
|
|
||
|
|
||
| class TPMarkovSampler: |
There was a problem hiding this comment.
Design fit / complexity vs. measured gain. This adds ~590 lines: a custom spin-wait and tagging protocol over private torch symmetric-memory APIs, a second cache-write path (own columns, push/unpack, recompute/fill), and a load-time numerical-equality sweep. The measured saving is 33-110 us per draft step, i.e. 0.25-0.9% of the step in the PR's own serving table (loop-only: 59-159 us). The failure modes it adds sit in the shared decode hot path: a GPU spin on a missing peer, tag aliasing, and silent drift if GEMM dispatch changes (see the other comments). The PR notes that an NCCL all-gather and a flag variant were slower, so this is a judgment call, but it seems worth the maintainers explicitly accepting that trade-off, or marking the option experimental in the config docstring.
There was a problem hiding this comment.
Fair question. Here are the numbers, so maintainers can weigh it.
Scope. The option is opt-in, default off, and now marked Experimental in its docstring. With it off, nothing changes on the shared decode path: _sample_sequential gains one is not None check, and propose one more. With it on, unsupported configurations and inexact GEMMs fall back to the replicated loop on every rank alike, a failed symmetric-memory setup falls back, and a peer timeout raises instead of drifting. The other comments' failure modes (aliasing, silent timeouts, dispatch drift) are fixed and tested in this revision (eddf09a, db489e9, f2ffea6).
What it saves (MiniMax-M3.1 Flash, TP4, K=7, GB300).
- Markov loop, graph-timed on the real draft model (replicated → vocab-parallel, µs): B1 291 → 233, B4 371 → 315, B8 440 → 389, B16 563 → 509, B32 705 → 600, B64 1156 → 995, B128 2010 → 1759. That saves 51–251 µs, or 10–20% of the loop. The absolute saving grows with batch size, because the replicated loop's cost scales with B at full vocabulary on every rank.
- Serving (seeded, same node, nsys; draft CUDA graph p50 per step, µs): concurrency 1: 646 → 586, concurrency 8: 917 → 875–882, concurrency 32: 1368 → 1235–1240. That saves 61, 35–42 and 128–133 µs per step. The whole step is 6.8, 13.4 and 19.7 ms, so this is 0.3–0.9% of the step, as in the original table.
Why this design. A plain NCCL all-gather of the per-block maxima, and a sys-scope flag variant, were both slower than the replicated loop at these sizes (the exchange is about 1.5 KB per row per step, so latency is everything). That is why the exchange uses tagged LL words over symmetric memory. The exchange and sampler are also meant as the base of a planned follow-up (vocab-sharded verification), which would reuse TPMarkovSampler to draft on each rank's owned columns.
If the maintainers prefer not to carry an LL protocol in spec_decode/dspark/ for a sub-1% step gain on its own, I'm fine keeping it out of tree until the follow-up shows the larger combined effect. Let me know which you prefer.
| # at this granularity. | ||
| _BLOCK_SIZE = 1024 | ||
| _TOKEN_BITS = 18 | ||
| _TAG_BITS = 14 |
There was a problem hiding this comment.
_TAG_BITS is defined but never used; only _TOKEN_BITS is (in unsupported_reason). The kernels hard-code (1 << 14) - 1, << 18, >> 18 and (1 << 18) - 1 in several places (sample and combine kernels). Changing either width means finding every literal. Define them as tl.constexpr module constants (as gumbel.py does with _DRAFT_NOISE_SALT = tl.constexpr(1 << 30)) and derive masks and shifts from them.
There was a problem hiding this comment.
Fixed in eddf09a. _INDEX_BITS = tl.constexpr(10) and _TAG_BITS = tl.constexpr(22) are module constants (as in gumbel.py), and every shift and mask is derived from them. A _tag(e, BITS) helper gives both exchanges their tag (22 bits for the sampling words, 32 bits for push staging), and tl.static_assert(BLOCK_SIZE == 1 << _INDEX_BITS) ties the block size to the layout. _TOKEN_BITS and the vocabulary limit are gone.
|
|
||
| @staticmethod | ||
| @torch.inference_mode() | ||
| def inexact_projection_reason(speculator: DSparkSpeculator) -> str | None: |
There was a problem hiding this comment.
The shard geometry (vocab = min(w2.shape[0], org_vocab_size), num_blocks, per, col_start / col_end) is computed twice: in __init__ (L334-341) and again here. If one drifts, the guard validates a different slice than the sampler uses. Factor one helper returning (vocab, num_blocks, per, col_start, col_end). Also vllm.utils.math_utils.cdiv (already imported by speculator.py) could replace triton.cdiv.
There was a problem hiding this comment.
Fixed in eddf09a. The geometry (vocab, blocks, per, col_start/col_end, the row slices) is computed once in TPMarkovSampler.__init__, which no longer allocates or communicates. create() builds the sampler first and then runs the guard (_inexact_projection_reason, now an instance method) on that same object, so the guard checks exactly the slices the sampler uses. triton.cdiv was replaced with vllm.utils.math_utils.cdiv.
|
|
||
| def sample( | ||
| self, | ||
| speculator: DSparkSpeculator, |
There was a problem hiding this comment.
TPMarkovSampler captures part of the speculator in __init__ (w2 views, n_spec, max_reqs) but sample() takes the speculator again on every call to reach model, draft_logits.values, draft_tokens, input_buffers, _anchor_idx (a private attribute), seeds and temperature. That leaves two sources of truth: self.n_spec vs speculator.num_speculative_steps, and vocab_size = base_logits.shape[-1] for the kernels vs self.vocab_size used to derive num_blocks / per. A mismatch would silently mis-index the words buffer. Keep the reference from construction and assert base_logits.shape[-1] == self.vocab_size.
There was a problem hiding this comment.
Fixed in eddf09a. The sampler keeps the speculator reference from construction, and sample() no longer takes it. The loop uses self.n_spec throughout. The kernels use self.vocab_size, with assert base_logits.shape[-1] == self.vocab_size, so the words-buffer geometry and the logits cannot disagree.
|
I folded vocab-sharded speculative verification (the opt-in |
|
This pull request has merge conflicts that must be resolved before it can be |
Add `speculative_config.dspark_tp_markov_sampling`. With tensor parallelism, each TP rank computes the Markov bias and the Gumbel sample of its share of the vocabulary (a row slice of markov_w2) instead of the whole vocabulary, and the ranks exchange one tagged 8-byte word per 1024-column block (block max, token) over symmetric memory, without fences or flags. Every rank picks the winner as the replicated loop does, so the drafted tokens are the same. The draft-logits cache is completed by pushing the biased logits to the peers (small batches) or by recomputing the peers' columns for all steps at once after the loop. A load-time check verifies on the real weight that the sliced and batched projections match the full projection bitwise for every batch size (all TP ranks agree); otherwise, and for unsupported configurations, the replicated loop runs. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…afe tags Review fixes for the vocab-parallel DSpark Markov sampler: - Fallback agreed across ranks: TPMarkovSampler.create() runs the checks, the projection guard and the symmetric-memory setup, and every TP rank agrees on each step over the CPU group, so a rank without symmetric memory makes every rank keep the replicated loop instead of failing start-up or leaving its peers in the collective rendezvous. - Exchange timeouts are surfaced: the timeout count is copied to pinned host memory after every sample and DSparkSpeculator.propose raises if it is non-zero. The spin limit is now about 2 s per kernel on GB300 (measured 0.24 us per iteration). - No stale words: the exchange word is [fp32 block max | 22-bit tag | 10-bit index in the block] (the token follows from the block), tags are never 0, the epoch is int64, and readers clear the words they consume. A late peer at a 14-bit tag wrap made the old protocol accept zeroed or stale words and draw other tokens. - The projection guard compares against the real model.markov_bias (logits processor and GEMM dispatch) at every batch size, without a host sync per size; a skinny-GEMM markov_w2 is unsupported. - The recompute path projects only the peers' rows of markov_w2. - One source of truth: the shard geometry is computed once, the sampler keeps its speculator, and the tag/index widths are named constants. - The option is marked experimental. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g path, in CI The test now builds a real DSparkMarkovHead, LogitsProcessor, DraftLogitsCache and InputBuffers, and runs both arms through DSparkSpeculator._sample_sequential: the replicated loop (_sample_markov_chain) as reference and the vocab-parallel early return. It covers 1 to 128 requests in both completion modes, CUDA-graph replays, every unsupported_reason, a symmetric-memory failure on one rank (every rank falls back), a late peer at tag-wrap epochs, and a timed-out exchange (raised on the host). It skips when the projections are inexact on the GPU, as the feature then falls back by design. Run it on 2 B200s in CI (multi_gpu_test); TP4 runs where 4 GPUs exist. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exchange kernels now set a timeout flag directly in pinned host memory (a UvaBuffer) instead of counting on the device and copying the count to the host after every sample. That copy was a memcpy node in the draft CUDA graph and, together with clearing the push staging after unpacking, cost 4-10 us per draft step on GB300. The push staging keeps its 32-bit tags, which repeat only after 2^32 - 1 epochs, so it needs no clearing; the per-block words are still cleared by their reader. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the opt-in `speculative_config.vocab_sharded_verification`. Each TP rank owns whole 8192-column blocks of the vocabulary and verifies drafts on those only: the target logits are its lm_head shard plus the right neighbour's boundary columns (no all-gather), temperature and an exact top-p run on the owned columns (64-bit fixed-point masses, so all ranks agree), and the rejection sampler's per-block kernels run on the owned blocks with their partials exchanged over symmetric memory (tagged 8-byte words, no fences). The sampled tokens equal the full-vocabulary rejection sampler's for the same processed logits. With DSpark and `dspark_tp_markov_sampling`, drafting uses the same owned ranges: the draft logits are assembled the same way and only the owned columns of the draft-logits cache are written. Steps that need the full vocabulary (logprobs, top-k, min_p, logits processors, structured output, ...) take the stock path after completing those requests' cached draft logits. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imeouts, safe tags Review fixes for vocab-sharded verification, on top of the DSpark TP Markov review fixes. - VocabShard.create() allocates and rendezvouses the symmetric buffer with every TP rank agreeing on each step; on failure all ranks fall back. - Exchange timeouts are copied to a pinned host flag after every step; the runner (verification) and propose() (drafting) raise on them. The spin is ~2 s. - Tags are never 0 and readers clear the words they consume, so a stale or zeroed word can no longer match. The owned-logits kernel tiles are aligned to the pushed bf16 word pairs so each word has one reader. - The exchange kernels do not specialize on per-step layout scalars, and the verification kernels (owned logits, top-p, rejection) are compiled at load with one dummy request on every rank. - Load-time eligibility: greedy drafting, watermarking, FLy, adaptive verification, NaN checks, sampling masks, trace replay, a custom rejection sampler and PCP disable the option with a warning. - Greedy rows do not trigger the sharded top-p. - complete_cache all-gathers a bounded number of rows at a time and writes the peers' slices in place. - Buffer sizing counts two bf16 columns per boundary word; the draft-side shard is sized for the Markov sampler's batch limit; capacity checks raise instead of asserting. - One TPMarkovSampler is built: the speculator defers it to enable_vocab_sharded_drafting when vocab-sharded verification is on. - Constants: block sizes come from rejection_sampler_utils, the tag stride and the top-p fixed-point scale from their definitions. - Tests: real DraftLogitsCache, multi_gpu_test markers and a 2-GPU CI step, late peers at tag-wrap epochs, an odd shard size, a timeout, p near 1, rows without finite logits, and Sampler.needs_full_vocab per request feature. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…speculator The test builds the speculator with __new__, so the attribute that _sample_sequential reads for vocab-sharded drafting must be set there. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ost memory As in the DSpark TP Markov exchange: the polling kernels set a timeout flag directly in pinned host memory (a UvaBuffer) instead of counting on the device and copying the count to the host at the end of every step, which was a memcpy node in the draft CUDA graph and a copy per verification step. check_exchange() reads the flag without synchronizing. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t at load The load-time warmup ran one step of K + 1 logit rows, so serving still compiled kernel variants on first use, while the peers could be waiting on an exchange: the top-p tie kernels (their column-split count is a constexpr that depends on the row count) and a `_pull_kernel` variant (Triton specializes the exchange strides, multiples of the row count, on divisibility by 16). Warm up one step per tie-split count with a row count that is a multiple of 16 and one that is not (`warmup_num_logits`), and test that these cover every step size. Signed-off-by: Eran Geva <19514940+MrGeva@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
946ffe5 to
01ad205
Compare
Overview
With tensor parallelism, DSpark speculative decoding repeats full-vocabulary work on every TP rank, twice per decode step: the drafter's sequential Markov sampling loop, and the target's verification (an all-gather of the target logits, then temperature, top-p and the rejection sampler over the whole vocabulary). This PR splits both by vocabulary across the TP ranks. It has two parts, each behind its own opt-in flag (both off by default):
speculative_config.dspark_tp_markov_sampling, experimental): each rank runs the Markov loop on its share of the vocabulary and the ranks exchange per-block maxima.speculative_config.vocab_sharded_verification): each rank verifies on its own vocabulary blocks, without gathering the target logits, and with part 1 on, the drafter also works only on those blocks.They ship together because they share the exchange machinery: symmetric-memory tagged words with never-zero tags that the reader clears, a bounded spin with a timeout flag in pinned host memory, and a setup fallback that all ranks agree on. Also, the draft side of part 2 is part 1's Markov loop restricted to the ranks' owned columns.
Part 1: vocab-parallel DSpark Markov sampling
DSpark drafts a block of
num_speculative_tokenstokens with a parallel backbone pass, then runs a sequential Markov loop. Each of the loop's steps adds a Markov bias, from the previously sampled token, to the base draft logits and Gumbel-samples the next token. With tensor parallelism the loop runs replicated on every TP rank. Each step:markov_w2projection ([draft_vocab, markov_rank]bf16, 102 MB for a 200k vocabulary) to compute a few rows of bias;Both are memory bound, and every rank does the full work. For MiniMax-M3.1 Flash (K=7) the loop is 150–570 µs per decode step at concurrency 1–32 on GB300, on the draft's critical path.
With
dspark_tp_markov_sampling, each TP rank owns a contiguous range of the sampler's 1024-column blocks and, per step:markov_w2(1/TP of the weight traffic);gumbel_noised_argmax,spec_step_seed);[block max (fp32) | 22-bit tag | 10-bit index in the block], to every rank through torch symmetric memory. Each word carries its own tag, so no fence or flag is needed (NCCL's LL protocol). Tags are never 0, and the reader clears each word it consumes, so a slot holds either nothing or the current step's word and a stale word can never be taken for a fresh one. A sys-scope release/acquire flag variant and an NCCL all-gather were both slower than the replicated loop.The block maxima are the values the replicated loop computes, so the drafted tokens are the same.
The rejection sampler reads the draft-logits cache at full vocabulary on every rank (unless part 2 is on, see below). Each rank writes its own columns while sampling, and the rest is completed in one of two ways:
markov_w2(left and right of its own). This reads them once per draft step instead of once per Markov step. Pushing a whole cache would cost more NVLink time than it saves.Exactness guard. The sliced and batched projections must equal the replicated loop's projection bitwise. cuBLAS gives that for 64-aligned vocabularies, but not for odd ones (e.g. 50017), where it picks different kernels. So at load time the speculator checks it on the real weight against the real
model.markov_bias(logits processor and GEMM dispatch included), for every batch size up to 128, and all TP ranks agree on the result (0.42 s and a 367 MiB transient peak per rank for M3.1 Flash at TP4). If any size differs, or the configuration is unsupported, the replicated loop runs. Unsupported configurations are:sample_sequential;markov_w2, TP = 1, non-CUDA.Batches above 128 requests also run the replicated loop.
Part 2: vocab-sharded verification
With a vocab-parallel LM head, verification all-gathers the target logits, so every TP rank holds
[num_logits, vocab]and applies temperature, top-p and the rejection sampler over the whole vocabulary: the same work on every rank. With probabilistic drafting the drafter does the same with its own logits: an all-gather per draft step, and a full-vocabulary draft-logits cache on every rank.With
vocab_sharded_verification, each TP rank owns whole 8192-column blocks of the vocabulary (the rejection sampler's statistics blocks, split as evenly as possible) and works on those only:p, plus the row max. Masses are summed in 64-bit fixed point, so every rank, and every run, gets the same kept set. Greedy rows do not trigger it.dspark_tp_markov_sampling): the draft logits are computed the same way (own shard + right neighbour's columns), part 1's Markov loop runs on the same owned ranges, and only the owned columns of the draft-logits cache are written, since verification reads only those.All exchanges are small (per-block partials, per-row histograms, the boundary columns) and use part 1's tagged-word scheme. Each verification step uses its own tag epoch and alternates between two buffer halves. The sharded verification kernels are compiled at load, collectively on every rank, so that no rank compiles while its peers wait on an exchange. The warmup runs one step per kernel variant that serving can launch: for each top-p tie-split count, a row count that is a multiple of 16 and one that is not (
warmup_num_logits, commit 946ffe5). Verified: no vocab-shard Triton compiles after load in a c32 serving smoke, and the unit tests pass.Steps with requests that need the full vocabulary (logprobs, top-k, min_p, logits processors or a thinking budget, structured output) take the stock path. Before such a step, the cached draft logits of its requests are completed with an all-gather, bounded to 64 MiB per chunk.
Opt-in, flags and fallbacks
Both options are off by default and change nothing unless set.
dspark_tp_markov_samplingvocab_sharded_verificationVLLM_COMPUTE_NANS_IN_LOGITS, sampling masks or trace replay; a target model withoutcompute_logits_local; a draft-logits cache narrower than the target vocabulary; symmetric memory unavailablevocab_sharded_verification, per stepFailure handling (both parts):
Claims
Part 1 (vocab-parallel Markov sampling)
Same tokens and the same draft-logits cache, bitwise. On MiniMax-M3.1 Flash's real draft model (TP4, GB300), the replicated loop and this loop were run from identical draft metadata. The metadata includes a padding request, a greedy request and a temperature-0.7 request. The drafted tokens and the entire draft-logits cache match on all 4 ranks at B = 1, 2, 4, 8, 16, 32, 64, 96 and 128, in both completion modes, with 0 exchange timeouts.
Loop time (CUDA-graph timed on the real draft model, TP4, µs, best of two runs on one node; the shared draft lm_head + logits all-gather is included in both):
The review changes do not cost loop time. Against the first revision on the same node, run ABAB: B1 232 → 233, B32 601 → 600, B64 998 → 995, B128 1759 → 1759.
Draft CUDA graph per decode step in serving: −61 µs at concurrency 1, −35 to −42 µs at 8, −128 to −133 µs at 32 (Validation).
Memory: about 43 MiB of symmetric memory per rank, allocated and zeroed once at load: push staging for 4 requests,
2 · TP · 4 · K · ⌈blocks/TP⌉ · 512 · 8 B(42.9 MiB at TP2/TP4, 43.8 MiB at TP8 for V = 200064, K = 7), plus 0.4 MiB of block words for 128 requests.Part 2 (vocab-sharded verification)
Same tokens: for the same processed logits, the sampled tokens and counts equal the full-vocabulary rejection sampler's (block, standard and synthetic modes). Drafting on owned columns draws the same tokens as part 1's loop.
Exact, deterministic top-p: the kept set is the float64-exact one except within fp32 rounding of
pat the boundary (on real logits: 4 of 3611 rows, one tied token each); never a superset.Faster from 8 requests up (MiniMax-M3.1 Flash, TP4, DSpark K=7, GB300; base = the same build with part 1 on and gathered verification). On the final revision, step period −6.3% at concurrency 16 (15.62 → 14.63 ms) and −6.7% at concurrency 32 (18.82 → 17.56 ms). Time per decode step outside the target model's CUDA graph (step period − target graph span; the target graph itself is unchanged):
c16 / c32: final revision, two runs per arm. c1 / c8: first revision (the review changes kept the c16 / c32 gain within noise of the first revision's −882 / −1210 µs).
At concurrency 1 the exchanges' latency outweighs the work saved (verification 325 → 377 µs, drafting 591 → 565 µs).
Memory: one symmetric buffer per rank, allocated at load (before the KV cache is sized), proportional to
max_num_seqs: 253 MiB at TP4, K = 7, V = 200064,max_num_seqs256, plus 50 MiB on the draft side (sized for the Markov sampler's 128-request limit).Accuracy: GSM8K unchanged within noise with both options on (Validation).
Validation
GB300 (SM103), driver 580.178.04, image
vllm/vllm-openai:minimax-m31-flash-arm64-cu134@sha256:5d763476…(vLLM36ae880) with this PR's files, adapted to that image's API wheremaindiffers (the image predatesDraftLogitsCache). Serving uses the InferenceX GB300 recipe: TP4, DSpark K=7 probabilistic drafting, block verification, NVFP4 KV, FULL_AND_PIECEWISE.Both options, final head
Stock image against the image with only this PR's files at the head commit, both options on, on one node, arms back to back. GSM8K 8-shot through the chat API, 320 questions at concurrency 32, block verification:
Both differences are 2 questions out of 320, in opposite directions, within one standard error (about 0.008).
Serving smoke (same node, both options on, 64 greedy + 64 sampled GSM8K requests at concurrency 32, with temporary per-step counters): all 4 ranks logged
Vocab-sharded verification: this rank owns vocabulary columns [...]andDSpark drafting is vocab-sharded.. Every serving step with drafts was verified vocab-sharded, and every draft step ran the owned-columns Markov loop, on all 4 ranks. There were no fallback warnings, full-vocabulary verification steps or exchange timeouts. The only full-vocabulary steps were three warm-up dummy runs at load, before CUDA graph capture.Tests
tests/v1/spec_decode/test_dspark_tp_markov.py, run in CI by the new 2×B200 step:nvidia: (B200) V1 Spec Decode DSpark TP Markov:DSparkMarkovHead(ParallelLMHead),LogitsProcessor,DraftLogitsCacheandInputBuffers. Both arms runDSparkSpeculator._sample_sequential: the replicated_sample_markov_chainas reference, and the vocab-parallel early return.complete_cache, the whole cache is equal, at 1, 5 and 128 requests (several chunks).unsupported_reason; symmetric memory failing on one rank (every rank falls back); a late peer at epochs where a 14-bit tag would wrap; an exchange timeout, whichcheck_exchangeraises.tests/v1/spec_decode/test_vocab_sharded_verify.py, run in CI by the new 2×B200 step:nvidia: (B200) V1 Spec Decode Vocab-Sharded Verification:p. Two runs give identical masks.tests/v1/worker/test_gpu_sampler_flags.py: one case per request feature forSampler.needs_full_vocab(min_p, top-k, logprobs, thinking budget, logit bias, penalties and bad words set it; greedy-with-top-p, temperature and top-p alone do not).On GB300 (image port of the tests, TP2 and TP4 × both vocabularies): 8 passed for the two multi-GPU files, 26 passed for the sampler flags. The TP4 cases need 4 GPUs and do not run in CI.
Not covered by CI: an
LLM-level run needs a DSpark checkpoint; the smallest are MiniMax-M3.1 Flash and DSV4. The real-model checks and serving runs here are on M3.1 Flash on GB300.Part 1 serving
Agentic-like code prompts of about 22k tokens; nsys, same node, arms back to back; draft CUDA graph span p50 per step, µs:
Arms on one node per concurrency (replicated, vocab-parallel, first revision, vocab-parallel). The first revision measured 582, 869 and 1245 µs, so the review changes keep the gain within run-to-run noise. An earlier c16 run of the first revision measured 1079 → 1003 µs. Every vocab-parallel run logged
DSpark Markov sampling is vocab-parallel over TP.on all 4 ranks, with no fallback, timeout or error. The target graph does not change; whole-step periods move by up to ±4% between server restarts on this setup, so the draft graph span is the reliable comparison.Part 2 serving
Agentic-like code prompts of about 22k tokens, temperature 1, top-p 0.95, part 1 on in both arms; nsys on one node, arms alternated base / PR / base / PR.
Final revision: time outside the target graph c16 2300 / 2294 → 1403 / 1392 µs (draft graph 972 → 838 µs; period 15.62 → 14.63 ms, −6.3%); c32 2964 / 2945 → 1704 / 1685 µs (draft graph 1180 → 956 µs; period 18.82 → 17.56 ms, −6.7%). Engagement lines on all 4 ranks.
First revision, per part (averages of two runs):
Against batch-sharded sampling (
enable_batch_sharded_sampling, same build without part 1, which it excludes), time outside the target graph: 1422 → 1314 µs at concurrency 8, 1631 → 1416 µs at 16, 2370 → 1695 µs at 32.Top-p on real logits: 3611 verify rows dumped from MiniMax-M3.1 Flash DSpark serving (64-, 105-, 186- and 256-row batches, p = 0.95). 4 rows differ from the float64 reference, each by one tied token at the boundary (reference cumulative mass within 1.5e-8 of
p); no row keeps extra tokens. CUDA-graph timed, per call on 4 ranks: 104 µs at 64 rows, 211 µs at 256 rows.Earlier accuracy runs (GSM8K chat, 320 questions, concurrency 32, greedy / temperature 1 top-p 0.95): part 1 with MiniMax-M3 model changes in both arms: 0.9719 → 0.9812 / 0.9719 → 0.9750 and 0.9781 → 0.9750 / 0.9750 → 0.9719. Part 2 against part 1 alone: 0.9750 → 0.9781 / 0.9688 → 0.9750 (first revision) and 0.9781 → 0.9812 / 0.9750 → 0.9688 (review revision). Mean acceptance length unchanged within run-to-run noise.
Details
main(8530a77):dspark_tp_markov_samplingis a method-specific option: it is listed in_METHOD_SPECIFIC_OPTIONSand inDSparkSpeculatorConfig.supported_options. Its type isbool | None(unset means off), so the generic option check rejects it for other methods only when it is set.vocab_sharded_verificationis a plainSpeculativeConfigfield; it is a property of verification, not of a drafting method.DSparkSpeculator.load_modelcalls_try_enable_tp_markov_samplingwhen only part 1 is on. With part 2 also on, the runner decides verification first and then callsenable_vocab_sharded_drafting(shard_or_None), which builds exactly one Markov sampler (owned-columns or plain).model.markov_bias, the skinny-GEMM exclusion, peers-only recompute; tests on the real sampling path and the CI step; the timeout flag in pinned host memory);VocabShard.create, load-time eligibility and collective warmup,do_not_specializeon per-step scalars, never-zero tags with consume-and-clear, bounded completion chunks, tighter buffer sizing, CI step and tests; the timeout flag in pinned host memory).vllm/v1/worker/gpu/spec_decode/vocab_shard.py(new): ownership, the exchanges, the owned-logits assembly and the sharded top-p.rejection_sampler_utils.py:rejection_sample(..., vocab_shard=...); the stats, residual-mass and resample kernels takeblock_start/target_col_start, and the logprob helper optionally reads the drafted token's logits from a small array. Stock calls pass 0 / None.RejectionSampler.supports_vocab_sharding/vocab_sharding_unsupported_reason/warmup_vocab_sharded/verify_vocab_sharded;Sampler.needs_full_vocab(per request);GPUModelRunner.sampletakes the sharded path when eligible.TPMarkovSampler.create(speculator, owned_ranges),DSparkSpeculator.enable_vocab_sharded_drafting/complete_draft_logits.maindiffers. The A/B serving runs of each part had the same unrelated MiniMax-M3 model changes in both arms; the final GSM8K run above compares the stock image with only this PR's files. The option wiring formain's per-method speculator config has not run end to end onmain, which has no MiniMax-M3.1 NVIDIA model path.enable_batch_sharded_sampling) splits the batch across ranks instead of the vocabulary. It is exclusive with both options here: part 1 is ignored when batch-sharded draft logits are in use, part 2 when batch-sharded sampling is on. Part 2's verification side works with any probabilistic drafter whose draft-logits cache is replicated; only its draft side is DSpark-specific.Pull Request Checklist
I used vLLM's
/pr-checklistskill. (Mandatory for agents, optional for humans).AI assistance was used during the creation of this PR.
Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.
Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.
Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.
Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.
AI assistance: the kernels, tests and benchmarks were developed with Claude Code (Claude Opus 5.5). Every line was reviewed by the human submitter, who also confirmed the validation above.
🤖 Generated with Claude Code