Skip to content

Add DSpark and DFlash v2 drafts and sampled drafting to DFlash speculative decoding - #4847

Open
imargulis wants to merge 65 commits into
openvinotoolkit:masterfrom
imargulis:dflash-exp-markov
Open

imargulis wants to merge 65 commits into
openvinotoolkit:masterfrom
imargulis:dflash-exp-markov

Conversation

@imargulis

@imargulis imargulis commented Nov 5, 2026 •

Copy link
Copy Markdown

Description

Adds DSpark and DFlash v2 drafts, whose Markov heads make every drafted token depend on the one before it, to DFlash speculative decoding in the continuous batching pipeline. It also adds sampled (multinomial) DFlash drafting, verified with the rejection sampling of #4769.

Stacking

This PR builds on two open PRs, which should land first:

Until they merge, the branch also carries #4769's 19 commits and #4790's 19 commits, rebased onto #4769. Only the 12 commits after "Run the DFlash draft's hidden-state FC in the target" need review (their diff):

The follow-up PR for drafts with a pruned draft vocabulary (MiniCPM-OCR-1.0-DSpark) builds on this one.

Change

  • Markov heads. draft_model loads the Markov head (openvino_markov_head_model.xml) next to the draft backbone and compiles it with the draft properties. The block sampler takes an optional BlockRowSource, and the new DFlash candidate selector (dflash_candidate_selector.cpp) is that source for every DFlash draft. With a head, it runs in one of two modes, selected by the markov_head_top_k draft property:

    • the Top-K candidate scorer (the default) scores the K best backbone candidates of neighboring positions in one inference for all scheduled requests;
    • the dense Markov walk (markov_head_top_k=0, DSpark only) corrects every drafted position, one inference per position.

    The gated Markov head of DFlash v2 also reads the draft hidden states. A DFlash v2 draft on GPU compiles with at least the ACTIVATIONS_SCALE_FACTOR stored in its metadata.

  • Sampled drafting. A multinomial request draws its candidates with the draft's RNG after its logit processors, and records the distribution of every candidate for the target's rejection sampling (Make sampled speculative decoding exact for FastDraft, EAGLE3 and MTP #4769). Beam search and parallel sampling stay rejected. With prefix caching, a target with linear-attention state verifies greedy requests only, so a sampled request is rejected when it is added, as Make sampled speculative decoding exact for FastDraft, EAGLE3 and MTP #4769 does for MTP.

  • First block under dynamic split fuse. The draft step that runs the last prompt rows of a request also drafts its first block, so a request no longer spends one target step without candidates.

  • Draft step metrics. draft_step_metrics holds the wall-clock time of every draft step. draft_model_metrics, draft_markov_head_metrics and draft_sampling_metrics hold its backbone, Markov head and sampling parts, one entry per step.

  • NPU. LLMPipeline and VLMPipeline reject a DFlash draft when the target or the draft runs on NPU. NPU pipelines use the stateful backend, and DFlash runs on the continuous batching backend only.

  • Docs. The DFlash section of speculative-decoding.mdx describes DSpark and DFlash v2 drafts, the Markov head modes, greedy and sampled drafting, the draft KV cache, the draft step metrics and the runtime limits.

Fixes to #4790:

  • Draft proposals. Make sampled speculative decoding exact for FastDraft, EAGLE3 and MTP #4769 added the draft proposals of the inserted tokens to insert_tokens_to_sequence; the target's tokens carry none for the draft.
  • Split validation windows. On pure attention the scheduler runs only the first rows of a validation window that does not fit the token budget. The sampler treated the last scheduled row as the bonus row and appended its token after the last candidate, leaving the candidates past the split unvalidated. It also scheduled candidates that a rejection had already removed. Now every scheduled row validates the candidate after it, and the rest wait for the next step.
  • Omni outputs. A DFlash VLM request that asks for Omni intermediate outputs is rejected; speculative decoding results leave them empty.
  • Draft attention backend. ATTENTION_BACKEND=PA in the draft properties is accepted and dropped before compile_model, and any other backend is rejected.
  • Model cache key of the target. Since the draft's hidden-state FC moved into the target, two drafts of one architecture on one target got the same model cache key. The second draft then loaded the target compiled with the first draft's FC: on an Intel Arc B390 GPU, deepseek-ai/dflash_qwen3_4b_block7 run after the DSpark draft of the same Qwen3-4B target dropped from 5.08 to 1.02 tokens per step. The moved FC now carries a hash of its weights, which the key includes.

Dependencies

Validation

Greedy speculative decoding, one request at a time, on PagedAttention. HumanEval and MT-Bench (first turn), 20 prompts x 2048 new tokens. Speedup is target TPOT over speculative TPOT. B390: Intel Core Ultra X7 358H with Arc B390 iGPU, Windows. B70: Intel Arc Pro B70, Ubuntu 24.04.

Draft, target Dataset num_assistant_tokens Tokens per step B390 / B70 Speedup B390 / B70 tok/s B390 / B70
DFlash v1 (z-lab/Qwen3.6-27B-DFlash), Qwen3.6-27B int4 HumanEval 15 10.63 / 10.88 6.54x / 4.94x 51.4 / 161.3
MT-Bench 15 4.53 / 4.47 2.63x / 1.90x 22.4 / 68.8
DFlash v2 (incoai/Qwen3.8-27B-DFlash2), Qwen3.8-27B int4 HumanEval 7 7.10 / 7.12 5.26x / 4.77x 40.0 / 152.8
MT-Bench 7 4.57 / 4.58 3.06x / 2.86x 25.7 / 99.1
DSpark (deepseek-ai/dspark_qwen3_4b_block7), Qwen3-4B int8 HumanEval 7 5.48 / 5.46 3.69x / 3.10x 94.6 / 285.7
MT-Bench 7 4.32 / 4.14 2.58x / 2.21x 74.1 / 227.8
DSpark without a Markov head (deepseek-ai/dflash_qwen3_4b_block7), Qwen3-4B int8 HumanEval 7 5.09 / 5.14 3.41x / 2.64x 87.6 / 245.7
MT-Bench 7 3.91 / 3.94 2.39x / 1.71x 67.8 / 176.6
DSpark (openbmb/MiniCPM-V-4.7-1B-DSpark), MiniCPM-V-4.7-1B int8 HumanEval 15 3.98 / 4.04 1.90x / 1.74x 178.9 / 295.5
MT-Bench 15 3.29 / 3.30 1.38x / 1.33x 149.5 / 244.8
DSpark (openbmb/MiniCPM-V-4.7-35B-A3B-DSpark), MiniCPM-V-4.7-35B-A3B int4 HumanEval 15 8.76 / 8.55 2.39x / 2.72x 103.6 / 262.9
MT-Bench 15 4.44 / 4.55 1.07x / 1.26x 53.7 / 141.5

On the B70, DFlash is faster than MTP on the same targets: 1.83x (v1) and 1.78x (v2) by tok/s on HumanEval, and 1.11x (v1) and 1.49x (v2) on MT-Bench.

Tokens per step agree between the two devices within 2.5% in 11 of these 12 rows, and within 5% in all of them. Where a speculative output differs from the target's, it diverges at a near-tie of the FP16 GPU inference. The benchmarks ran on the 2026-11-03 revision of this branch. Since then, the stack was rebased onto newer master and #4769 revisions, and this PR got review fixes; none of them changes greedy drafting.

Known issues

  • Without a Markov head, the candidate selector takes the argmax of full-vocabulary draft rows on the host. That costs 0.83 ms per draft step on the B390, but 10.81 ms on the B70 host, 16% of DFlash v1's speculative step there.
  • Preempting the target while candidates wait for validation validates them against shifted logits, with any speculative draft (CVS-198275). test_target_and_draft_preemption_complete xfails when that changes the output, as Support DFlash and DSpark drafts through continuous batching on PagedAttention #4790's preemption test does. Locally it does so for the DSpark drafts on the random tiny models.

AI assistance used: yes
If yes: Cursor agents helped write the implementation and tests, and ran the builds, tests and benchmarks; I reviewed every change.
Human validation performed: C++ unit tests, the DFlash and DSpark Python CI jobs locally, and the benchmarks above.

CVS-###

Checklist:

  • This PR follows GenAI Contributing guidelines.
  • Tests have been updated or added to cover the new code.
    • tests/cpp/test_dflash_cb_strategy.cpp covers:
      • the Markov head metadata and model loading, both markov_head_top_k modes and their rejections, and the DFlash v2 activation scale;
      • the candidate selector with and without a head, greedy and sampled, with logit processors and ties;
      • sampled candidates carrying their proposals, and penalties over drafted and rejected tokens;
      • the first block under dynamic split fuse.
    • tests/cpp/sampler.cpp covers the block sampler with a row source; for the Support DFlash and DSpark drafts through continuous batching on PagedAttention #4790 fixes, it also covers split validation windows.
    • tests/cpp/scheduler.cpp covers the draft window of the prompt end; tests/cpp/speculative_decoding.cpp covers draft_step_metrics.
    • New tests/python_tests/test_dflash_markov_head_cb.py: DFlash v1, DSpark (both modes) and DFlash v2 drafts, built from a tiny Qwen3, in continuous batching with several requests. Greedy output must match the target alone: with and without dynamic split fuse, staggered requests, chunked prefill, small draft and target KV caches and prefix caching. It also covers reproducible sampled requests, penalties, the draft step metrics, LLMPipeline drafts without properties, and the NPU, missing-input and missing-Markov-head rejections.
    • tests/python_tests/test_vlm_pipeline.py: test_vlm_dspark_matches_main_only (tiny MiniCPM-V-4.7 with its tiny DSpark draft, both modes) and sampled VLM drafting with prefix caching on a linear-attention target.
    • tests/python_tests/test_continuous_batching.py: reproducible sampled DFlash requests, greedy requests with penalties, and the draft attention backend.
    • The DFlash CI job adds test_dflash_markov_head_cb.py, with transformers 5.15.1 for fix llm_bench and wwb parameters for new transformers #2098's export. A new DSpark job runs the MiniCPM-V-4.7 test with transformers 5.18, on Linux only: VLM jobs are commented out on Windows (ticket 198295).
  • This PR fully addresses the ticket.
  • I have made corresponding changes to the documentation. speculative-decoding.mdx

imargulis and others added 18 commits November 5, 2026 07:01
Without top-k, TopPFilter built a 16-byte Token for every vocabulary
entry (4 MB at a 248k vocabulary), partially sorted it with M = 16, 32,
... 1024 and fell back to a full sort when the nucleus was larger. With
the full-vocabulary softmax before it, a sampled row cost about 1.9 ms
on the host, about 20 ms when the nucleus exceeded 1024 tokens and about
10 ms when the tail expf results went denormal. Speculative decoding
samples several such rows per emitted token, so requests with top-p but
no top-k slowed down every method, target-only included.

TopPFilter now copies only the tokens whose probability is at least
p_max * e^-10 (then e^-20, e^-40, then every token with a probability),
sorts them by probability with ties ordered by index, and walks them
until the mass exceeds top_p. Each such set is a prefix of the sorted
order, so the nucleus is the one a full sort gives. The temperature
softmax passes p_max = 1 / sum(exp) through Logits::m_max_probability;
the value only changes the number of rounds, never the nucleus.

MinPFilter without top-k collects the surviving tokens straight from
the probabilities instead of building the full Token vector. The
temperature softmax and FullVocabLogSumExpTransform flush exponents
below -64 to zero: such terms cannot change a float sum that includes
the top token's 1.0, and expf is very slow when its result is denormal.
Beam search partially sorts the 2 * group_size tokens it takes instead
of the whole vocabulary.

Sampled tokens and scores match the previous build on 2,008 CPU
generations (top-p, min-p, temperature, top-k + top-p, logprobs, beam
search). Per row at a 248k vocabulary, top-p goes from 1.7-26 ms to
0.4-1.6 ms, min-p from 1.9-10.8 ms to 0.6-1.0 ms and a beam from
20-23 ms to 1.6-1.8 ms.

Co-authored-by: Cursor <cursoragent@cursor.com>
The logit processor's generated-token count was set once per step, so
every position of a speculative validation window saw the count from
the start of the window and the min_new_tokens stop-token mask stayed
on too long. After a rejected draft token the count was also reset to
the prompt length plus the generated length, which lifted the mask too
early for the rest of the request.

Set the count from the sequence's verified length right before the
logits of each position are processed, which is also correct for the
parallel samples of a request that share one processor, and reset it to
the generated length after a rejection.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sequence::update_generated_log_prob replaced a token's log-probability
but not the cumulative one, so after speculative validation the score
used to rank and report results still counted the draft model's
log-probabilities for the accepted tokens.

Co-authored-by: Guy Boudoukh <guy.boudoukh@intel.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
are_tokenizers_equal encoded a probe string with both tokenizers but
compared only the shapes of the results and the special token ids, so
tokenizers that split the string into the same number of different
tokens were accepted as equal.

Co-authored-by: Cursor <cursoragent@cursor.com>
The FastDraft and EAGLE3 strategies each copied the request config for
the draft pipeline and adjusted it by hand, and MTP did the same in its
make_draft_generation_config(). Use one function so draft-only
adjustments apply to every strategy; MTP builds on it and also caps an
unlimited max_new_tokens. Also reuse the draft's generated requests in
SpeculativeDecodingImpl::step() instead of collecting them twice and
discarding the first result.

Co-authored-by: Cursor <cursoragent@cursor.com>
LogitProcessor::decrease_generated_token_occurance() left a token whose
last occurrence was removed in the generated history with count 0. The
repetition and presence penalties apply to every token in the history
whatever its count, so a speculative draft kept penalizing the tokens
that update_request() removed after the main model rejected them.
Erase a token from the history once its count reaches 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sampled draft tokens were checked with a ratio of the draft's
log-probability of the candidate to the target's log-probability of an
independently drawn token, a 101-level uniform, and a replacement taken
from the next position's logits. The output followed neither model.

Record the post-filter distribution q each draft token was sampled
from, sparse over the logit processor's candidates or dense, in target
token ids. Carry it with the token from the draft pipeline to the main
pipeline and verify each window position against the target's
post-filter p with the standard test: accept with probability
min(1, p/q), otherwise emit a sample of norm(max(0, p - q)) and drop the
later candidates. Accepted tokens report the target's log-probability.

The draft sampler is seeded independently of the main one, so the
uniforms that accept candidates do not reuse the ones that proposed
them. Draft and main logits may differ in width, as padded vocabularies
do; ids beyond a model's row have zero probability under it.
Distributions are normalized and checked with sums in double, since
float sums over a full vocabulary, or over a candidate list about as
long (top-p or min-p without top-k), drift past the proposal tolerance.

Co-authored-by: Guy Boudoukh <guy.boudoukh@intel.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
MTP admission and MTP/EAGLE3 streaming asserted greedy decoding even
though verification now handles multinomial requests for every CB
drafting strategy. Accept multinomial configs there, matching the
FastDraft and prompt-lookup streaming checks. Beam search, tree search,
parallel sampling and the confidence threshold remain rejected for MTP.

Co-authored-by: Cursor <cursoragent@cursor.com>
After a speculative or prompt-lookup step, match_stop_string decodes the
last num_tokens_to_validate + longest-stop-string tokens, starting at
generated_tokens.size() - draft_generated_tokens. Two cases went wrong:

- After an early rejection the sequence can be shorter than the number
  of validated candidates. Prompt lookup pads its candidates to
  num_assistant_tokens, so this happens on most first steps. The
  size_t subtraction wrapped and the buffer started before the
  generated ids, so tokens read out of bounds were decoded; a stop
  string found in them made remove_last_tokens remove more tokens than
  had been generated.
- When the sequence was shorter than the window, the check returned
  without a match. A stop string in a first window that was accepted
  whole was skipped, and generation ran past it.

The window now starts at the first generated token when the sequence
is shorter than the window.

The out-of-bounds read is pre-existing: master reads out of range in
every prompt-lookup stop-string test. It was uncovered here because the
draft proposals added to Sequence changed the memory layout, after
which this test failed intermittently on Windows with "Cannot remove
more tokens than has been generated":

  test_stop_strings_facebook_opt[PROMPT_LOOKUP_DECODING-multiple_stop_strings_match]

The new
test_stop_strings_in_first_validation_window covers the skipped check
and fails on master with prompt lookup.

Co-authored-by: Cursor <cursoragent@cursor.com>
The draft records q normalized in double, but _multinomial_sample
summed its total and running weights in float. A weight below half a
float ulp of the running sum was never drawn, so a draw over most of a
large vocabulary did not follow the recorded q, and verification was no
longer exact. With weights 0.39 and 0.6 followed by 400,000 weights of
2.5e-8, the tail holds 1% of q but got no draws, and against a target of
{0.5, 0.5} the emitted token 1 had probability 0.505.

Sum in double on both paths, deferred expf included. CandidateWeights
keeps a double total, which replaces the separate double normalizer of
the proposal. The uniform stays a float, so the RNG stream is
unchanged. Seeded draws over most of a large vocabulary (temperature
only, top-p near 1) can still pick a different token than before; with
top-k, top-p 0.9 or min-p the tokens were identical in CPU runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Python MTP tests were greedy only. Generate 32 tokens with
do_sample, top-k and top-p over several verification steps, and check
that the streamed chunks concatenate to the final text and that the
draft proposed tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
Apply the ruff formatting that the lint job checks on changed lines.

Co-authored-by: Cursor <cursoragent@cursor.com>
verify_draft_tree applied the logit processor at every tree depth and
to the bonus token without updating its generated-token count, so all
of them kept the stop-token mask of the window's first position. Set
the count before each depth from the tokens generated before the
window and the depths accepted so far, and before the bonus token from
the sequence's generated length.

Co-authored-by: Cursor <cursoragent@cursor.com>
With prefix caching, verification of models with linear-attention
state supports greedy decoding only, because the checkpoint of a
verification window is committed from the greedy acceptance result. A
sampled MTP request on such a model was admitted and then failed at
its first verification window. Reject it in add_request, before either
pipeline receives it, and document the limitation.

Co-authored-by: Cursor <cursoragent@cursor.com>
When a stop string that is excluded from the output starts the decoded
window, nothing is left before it, and the whitespace trim called back()
on an empty string. The window clamp checks first windows that were
skipped before, so a stop string at the start of the generation reaches
this path. An empty result still removes the whole window.

Co-authored-by: Cursor <cursoragent@cursor.com>
The sampler indexed d2t with every draft logit without checking its size:
when mapping the sampled token, in the EAGLE3 tree search and when
recording proposals. Sampler::sample now checks once per call that d2t
has an entry for every draft logit.

validate_draft_proposal put every sparse proposal into a hash set to
reject repeated ids. An EAGLE3 draft without top-k or top-p records its
whole draft row, so each candidate paid one insert per draft token.
Repeated ids can only come from a d2t that sends two draft tokens to one
target id, so set_d2t_for_decoding checks that once and sampled drafting
requires it. Greedy drafting accepts any mapping, as before. Validation
is now one pass over the probabilities, without allocation.

Co-authored-by: Cursor <cursoragent@cursor.com>
The multinomial draw scaled a float uniform by the summed weight. A float
in [0.5, 1) moves in steps of 2^-24, so a token whose probability is below
that step could be drawn at the wrong rate or not at all, although the
weights are summed in double. The draw now takes a double uniform, as the
residual draw already does, and so does the acceptance test of rejection
sampling.

A double uniform takes two outputs of the random engine instead of one,
so a given rng_seed gives a different sequence than before. A seed still
reproduces its own output.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sampled drafting was streamed only in the MTP test. The new test streams
a sampled EAGLE3 request on Qwen3-1.7B, checks that the streamed text
matches the result and that the draft has tokens accepted.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI balanced review requested due to automatic review settings November 5, 2026 15:19
@github-actions github-actions Bot added category: visual language Visual language pipeline category: continuous batching Continuous batching category: LLM LLM pipeline (stateful, static) category: sampling Sampling / Decoding algorithms category: speculative decoding Speculative decoding category: GHA CI based on Github actions labels Nov 5, 2026
ofirzaf and others added 26 commits November 10, 2026 02:38
The draft runs with an unlimited max_num_batched_tokens, so a draft window is never split, and with cache
rotation the pipeline sizes its rotation table from that budget, which fails to allocate. The draft
windows were also not validated with evicted draft KV. Reject cache eviction on the target or the draft
until it is supported.

Co-authored-by: Cursor <cursoragent@cursor.com>
…he docs

Drafts exported for OpenVINO GenAI 2026.4 or earlier no longer load, and with a VLM target and prefix caching,
ChatHistory and image add_request() requests need num_assistant_tokens > 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
The draft projects the concatenated target hidden states with an FC before its layers. Running that FC on
the target's last_hidden_state makes the rows passed to the draft in_width / out_width times smaller (8x
for Qwen3.5-9B: 32768 to 4096 per row), which shrinks the target output, the copy into the draft, and the
draft hidden_states input. The FC weight subgraph moves to the target as is, so its decompression markers
stay and no weights are copied; the draft FC consumers read hidden_states, which is resized to the
projected width. A draft whose hidden_states does not feed only an FC MatMul is rejected, and the width
check between the target hidden states and the draft input moves into the transform.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
DFlash rejects return_omni_outputs again: the speculative result path does not fill the Omni outputs, so
the request returned empty payloads. add_request again requires one request per tensor, i64 [1, seq_len]
token IDs or f32 [1, rows, hidden] embeddings; a [2, N] tensor kept only its first row. A draft step that
schedules nothing returns before the forward and keeps the previous inference_duration, so the draft
metrics are recorded only when the step scheduled a request.

Co-authored-by: Cursor <cursoragent@cursor.com>
insert_tokens_to_sequence takes the draft proposals of the inserted
tokens, a parameter that rejection sampling (openvinotoolkit#4769) added; the target's
tokens carry none for the draft.

Co-authored-by: Cursor <cursoragent@cursor.com>
On pure attention the scheduler splits a validation window that does not
fit the token budget and runs only its first rows. The sampler treated the
last scheduled row as the bonus row: it validated one candidate fewer than
the rows it got, appended the token sampled from that row after the last
candidate, and left the candidates past the split in the sequence
unvalidated. It also left the unscheduled candidates to validate after a
rejection had removed them, so when no new candidates replaced them, as a
DFlash draft often proposes none, the target scheduled tokens that no
longer existed.

Every scheduled row now validates the candidate after it, the candidates
past the split wait for the next step, and a rejection leaves nothing to
validate.

Co-authored-by: Cursor <cursoragent@cursor.com>
Visual token pruning, echo and prompt logprobs stay allowed for a DFlash
VLM request: the target runs them as it does without a draft. Omni
intermediate outputs are rejected, since speculative decoding results
leave them empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
The DFlash draft always runs on PagedAttention, but an ATTENTION_BACKEND
in the draft properties reached compile_model, where the plugin rejects
the property. Drop "PA", and reject any other backend with a message that
names the property, before the models are transformed.

Co-authored-by: Cursor <cursoragent@cursor.com>
The target compiles with ov::cache_model_path, so the OpenVINO model cache
key covers the target graph and directory but not the weight values. Once
the draft's hidden-state FC moved into the target, two drafts of the same
architecture on one target got the same key, and the second loaded the
target compiled with the first draft's FC. On an Intel Arc B390 GPU, a
Qwen3-4B draft run after another Qwen3-4B draft with different FC
weights dropped from 5.08 to 1.02 tokens per step.

The moved FC now carries a hash of its weight constants in its rt_info,
which the cache key includes.

Co-authored-by: Cursor <cursoragent@cursor.com>
The DFlash draft took the target's hidden rows to end with one row per
candidate the target removed, and stopped drafting for the request when
there were fewer rows. That holds when the whole validation window runs.
A window that does not fit the token budget now validates only its
scheduled rows, so a rejection there also removes candidates that never
ran. The draft then found too few rows, and the request continued on the
target alone. With a budget of 3 tokens and 3 candidates, the draft
proposed 3 tokens for a 20-token request instead of 51.

The sequence now records the token position of its first hidden row,
the DFlash target publishes its rows up to its processed length, and the
draft reads its context from the end of them.

Co-authored-by: Cursor <cursoragent@cursor.com>
A sequence's hidden state is a view of the output of the forward that ran
it, and the next inference writes that output again. A request that the
target did not run kept publishing the view. After a draft window that did
not run whole, the DFlash draft took its context rows from it, which by
then held rows of the requests the target ran since; the bounds check
passed, and the draft KV of the request kept those rows for the rest of
its generation. Before each forward, the model runner now copies the rows
of the sequences that the forward does not schedule.

Co-authored-by: Cursor <cursoragent@cursor.com>
The acceptance metrics took the candidates that reached the target in a
step, less those rejected in that step, as accepted, and skipped a step
that brought none. A window that the token budget split, or that the target
did not schedule, is validated over several steps, so a candidate rejected
in a later step stayed counted as accepted: with the tiny DeepSpec draft, 4
requests on a budget of 9 tokens reported 31 accepted tokens instead of 29.
A request now gathers its candidates and their rejections until the target
has none left to validate.

Co-authored-by: Cursor <cursoragent@cursor.com>
A window of candidates counted in the acceptance metrics once the draft
reported which of them the main model removed. The draft cannot report
that after the main request finished or the draft request ended, so the
window was dropped: every request lost its last window, and a request
whose draft ran out of KV cache, or whose DFlash window could not run,
lost the window in progress, with the candidates an earlier step of a
split window had accepted. The window now keeps the main request and
the candidates as they entered it, and counts once the main model
validated all of them or the request finished: the candidates the main
sequence still starts with are accepted. A tree window still counts the
candidates the draft reports removed. Without dynamic split fuse and
with 4 new tokens, each of 4 requests drafts one candidate; only the
rejected ones were counted.

Co-authored-by: Cursor <cursoragent@cursor.com>
The docs said that OpenVINO GenAI does not limit num_assistant_tokens,
but GenerationConfig::validate() rejects more than 100, for DFlash
requests too.

Co-authored-by: Cursor <cursoragent@cursor.com>
A DFlash draft without properties of its own compiles with the target's,
in which GenAI sets ov::cache_model_path to the target directory. With a
model path, the OpenVINO model cache keys a compiled model on its graph
and that path in place of its weights, so with CACHE_DIR set, drafts that
differ only in their weights shared one cached blob: a pipeline could run
with the draft of another.

A draft that takes the target's properties drops the model path, so its
cache key takes its weights, as it does when the draft has properties of
its own.

Co-authored-by: Cursor <cursoragent@cursor.com>
A tree window counted the candidates the draft reported removed, which
it reports only while both requests run. The window that ended a request
was dropped uncounted, as on master, where every request lost its last
window, and so was a window whose draft request had ended.

After it validates a tree, the main sequence keeps the path it accepted:
the root, which is the token before the candidates, and the accepted
candidates. The main request keeps it after the main pipeline removed the
request, so a tree window counts from that path when the main model has
validated it, as a linear window counts from the main tokens. The draft
reported the same count: the tree nodes less that path.

Co-authored-by: Cursor <cursoragent@cursor.com>
A matched stop string removes tokens from the end of a sequence after the sampler validated them: the tokens after
the stop string, and the stop string's own tokens unless include_stop_str_in_output is set. The window compared its
candidates with the tokens the main sequence kept, so it counted the removed candidates as rejected, and the counts
depended on include_stop_str_in_output. Each sequence now counts the candidates the sampler accepts, and a window
takes the count from when it began to when the main model validated the last of its candidates, also over the steps
of a split window.

With the target drafting for itself, a stop string in the first window of a request left 0 of its 5 candidates
accepted before.

Co-authored-by: Cursor <cursoragent@cursor.com>
…split fuse

A DFlash draft window that starts inside the prompt carried only prompt
rows, so the first block waited for the next window and every request
spent one target step without candidates. Without dynamic split fuse
that wait stays: a step runs either prompt or generation windows, and
the vLLM prompt phase takes a window only when all of it fits the batch.
Dynamic split fuse schedules prompt and generation rows together, so it
runs the rest of the prompt and the extra rows of the block as one
window, and a window the token budget cuts keeps the candidates of the
rows it ran. There the window of the last prompt rows carries the first
block.

Co-authored-by: Cursor <cursoragent@cursor.com>
Speculative decoding validates a sampled request with rejection
sampling, which needs the distribution each candidate was drawn from,
and a DFlash request with sampling was rejected.

The block sampler draws the candidate rows of a multinomial request
with the request's draft RNG after its logit processors, and records
the proposal of every sampled candidate for the target to verify. The
draft config is the speculative decoding draft config, with its salted
seed; it keeps the stop tokens and min_new_tokens, so the draft
suppresses the stop tokens as the target does. The draft sync clears
the proposals the target has validated. Beam search and parallel
sampling are still rejected.

With prefix caching, a target with linear-attention state verifies
greedy requests only, so add_request rejects a sampled request that
drafts, as it does for MTP, instead of failing at its first
verification window.

Co-authored-by: Cursor <cursoragent@cursor.com>
A DFlash-2 draft can carry a Markov head that scores the candidates of a
block from the tokens drafted before them, and its rows differ from the
backbone logits by the output multiplier and the soft cap. The block
sampler drew every candidate from the gathered backbone logits.

The sampler now takes an optional BlockRowSource with the block sampling
start offset. It collects the blocks of a step, prepares the source
once, and draws every block from the rows the source fills for the token
before. A source that runs per position is stepped over the blocks that
still draw, and a source can draw a whole block, or per position a
token, itself when the request takes its tokens without the logit
processors. A position the source has no row for ends the block.

The DFlash candidate selector is that source for every DFlash draft.
Without a head it scales and soft caps the backbone rows, and a greedy
request whose logit processors keep the argmax takes the best token of
every row without running them. A Top-K scorer is run once over the
packed block rows, and the gated scorer also reads the draft hidden
states of those rows; a greedy request whose logit processors keep the
argmax walks the lattice greedily, taking the best candidate after the
token before, a sampled one walks the lattice with the request's draft
RNG, and any other request samples the lattice row after the token
before, ending the block at a token outside the candidates. A dense
Markov step is run per position and biases the scaled rows by the token
drafted before; a greedy request whose logit processors keep the argmax
takes the best biased token without filling the row. The selector
compiles the head with the draft properties.

Every block follows at least one context row, which keeps the blocks of
different requests apart in the packed rows that the DFlash-2
convolutions run over.

Co-authored-by: Cursor <cursoragent@cursor.com>
The draft metrics of a DFlash draft held the wall-clock time of every
draft step, with its backbone inference as inference time, so a report
could not tell how much of a step the backbone, the Markov head, the
candidate sampling and the rest of the step took. Their batch sizes
counted the candidate rows a step forwarded, also when a Markov head
ended a block before them, and none of the context rows: a forward of
prompt rows alone counted no processed tokens.

Report the wall-clock time of every draft step of continuous batching
speculative decoding in draft_step_metrics, from the start of the draft
step until its candidates are submitted to the target, with the
submitted candidates as its batch size. Only steps in which the draft
pipeline recorded an inference are counted, so a DFlash draft records
the parts of a step with one entry per step: draft_model_metrics holds
the backbone forward, draft_markov_head_metrics the Markov head
inference of a draft with a Markov head, and draft_sampling_metrics the
rest of the draft sampler call, which draws the candidates from the rows
the Markov head gives. The rest of a step is scheduling, input
preparation and candidate submission. The backbone counts the rows it
forwarded, context rows included, as the draft's processed tokens; the
Markov head and the sampling count the candidates the step drafted.

Co-authored-by: Cursor <cursoragent@cursor.com>
DFlash speculative decoding runs on the continuous batching pipeline
only, but with an NPU target, LLMPipeline and VLMPipeline chose the
stateful speculative decoding pipeline, which does not support DFlash
drafts, and so did LLMPipeline with an NPU draft.

Reject a DFlash draft in both pipelines when the target or the draft
runs on NPU, with an error that names the supported backends.

Co-authored-by: Cursor <cursoragent@cursor.com>
Describe the DFlash, DSpark and DFlash-2 drafts, their export and the
markov_head_top_k modes of the Markov head, greedy and sampled
drafting, the draft KV cache, the first block under dynamic split fuse,
the draft step metrics and the runtime limits of DFlash speculative
decoding.

Co-authored-by: Cursor <cursoragent@cursor.com>


The DFlash CI job installs optimum-intel at the head commit of
huggingface/optimum-intel#2098, which adds the DFlash v2 and DSpark
exports to openvinotoolkit#2096, and also runs test_dflash_markov_head_cb.py, which
skips its module without them. openvinotoolkit#2098 exports Qwen3.5 with transformers
5.15 or later, so the job moves from 5.2.0 to 5.15.1.

test_vlm_dspark_matches_main_only pairs
optimum-intel-internal-testing/tiny-random-minicpmv47 with its tiny
DSpark draft optimum-intel-internal-testing/tiny-random-minicpmv47-dspark:
greedy speculative decoding with the Markov head's candidate scorer and
with its dense step (markov_head_top_k=0) gives the target's text. It
runs in a new job with transformers 5.18, which the target needs. The
job is commented out on Windows, as the other VLM jobs are there
(ticket 198295).

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI balanced review requested due to automatic review settings November 10, 2026 10:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Outstanding repository requirements include a tracking issue for the prefix-caching xfail, a dedicated DFlash/DSpark test module with reusable fixtures and CI updates, and completion of the documentation preview and PR checklist prerequisites.

3 open issues

Review effort: Balanced

return Path(_get_ov_model(main_model_id)), draft_model_path


def test_vlm_dflash_prefix_caching(cat_tensor):

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category: continuous batching Continuous batching category: CPP API Changes in GenAI C++ public headers category: GGUF GGUF file reader category: GH Pages Docs Github Pages documentation category: GHA CI based on Github actions category: LLM LLM pipeline (stateful, static) category: Python API Python API for GenAI category: sampling Sampling / Decoding algorithms category: speculative decoding Speculative decoding category: visual language Visual language pipeline no-match-files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants