Skip to content

[OpenVINO] Export DFlash v2 and DSpark drafts for PagedAttention and continuous batching - #2098

Open
imargulis wants to merge 14 commits into
huggingface:mainfrom
imargulis:dflash2-pa
Open

imargulis wants to merge 14 commits into
huggingface:mainfrom
imargulis:dflash2-pa

Conversation

@imargulis

@imargulis imargulis commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Extends the PagedAttention export of DFlash drafts to the rest of the DFlash family, so that every DFlash-family draft runs in OpenVINO GenAI continuous batching from one exported IR: DFlash, DFlash v2, and DeepSpec DSpark drafts with or without a Markov head, including the MiniCPM-V 4.7 DSpark drafts.

Changes to the export:

  • DFlash v2 (DFlash2DraftModel, e.g. incoai/Qwen3.8-27B-DFlash2):
    • The draft uses the per-row backbone of the DFlash drafts. Its grouped dynamic convolution shifts the taps along the packed block rows and masks them to the block of the row they produce, so a tap never reaches another sequence's block after the PagedAttention conversion.
    • The candidate selector is exported as the draft's Markov head, openvino_markov_head_model.xml with markov_head_type=dflash2. It is the packed Top-K lattice of the DSpark Markov head, with transitions gated by the draft hidden states and no dense step. Like the DSpark Markov head, it is never weight-compressed and keeps the checkpoint precision unless --weight-format fp16/fp32 is given.
    • The backbone gets dflash/version=2 and dflash/gpu_min_activations_scale_factor=32 instead of the decoder default ACTIVATIONS_SCALE_FACTOR=8. OpenVINO GenAI compiles the draft on GPU with at least that scale factor.
  • DSpark: to the exporter, DSpark drafts are DFlash v1 drafts, with the candidate position offset (0) and the Markov head taken from their config. The Markov head model drops the dflash/interface tag: markov_head_type names the head, and runtimes find its computations and read their results by port name.
  • Block rows: the Qwen3 and Muse-Glimmer drafts write them with index_put_ instead of item assignment. Item assignment strips the leading unit dimension of a one-row value, so when the dummy token_type_ids hold a single block row, the trace freezes that shape and the exported draft fails on any other block length.
  • Sliding layers: the PyTorch draft keeps the whole past in its cache, as the exported graph does, and applies the window through its attention mask. DynamicCache(config=...) kept only the last window of keys, so the second cached step built its mask over more keys than the cache held.
  • CLI: DFlash v2 drafts are loaded natively like the other DFlash drafts, and the task is now also inferred from a local draft checkpoint, so --task is no longer needed.
  • Docs: Qwen3.8-27B-DFlash2 in the supported architectures, and the files a DFlash v2 export produces.

Runtime support: openvinotoolkit/openvino.genai#4847. On GPU, the paged draft needs PagedAttention with token_type_ids in the mixed stage and the Markov head needs the radix TopK, both in the OpenVINO nightly from 2026.5.0.dev20261028.

How to test

Environment (Python 3.11+). Until openvinotoolkit/openvino.genai#4847 is merged, OpenVINO GenAI is built from its branch, which needs CMake 3.26+ and a C++ compiler:

python -m venv dflash-env
source dflash-env/bin/activate  # Windows: dflash-env\Scripts\activate
python -m pip install --upgrade pip

# optimum-intel. MiniCPM-V 4.7 targets also need transformers>=5.18.
pip install "optimum-intel[openvino] @ git+https://github.com/huggingface/optimum-intel.git"

# OpenVINO and OpenVINO Tokenizers nightly, and OpenVINO GenAI with DFlash continuous batching
pip install --pre -U openvino openvino-tokenizers --extra-index-url https://storage.openvinotoolkit.org/simple/wheels/nightly
pip install --pre "openvino-genai @ git+https://github.com/imargulis/openvino.genai.git@dflash-exp-markov" --extra-index-url https://storage.openvinotoolkit.org/simple/wheels/nightly

Export a DSpark draft and its target. Drafts need neither --task nor --trust-remote-code:

optimum-cli export openvino --model Qwen/Qwen3-4B --weight-format int8 Qwen3-4B-int8-ov
optimum-cli export openvino --model deepseek-ai/dspark_qwen3_4b_block7 dspark_qwen3_4b_block7-ov

Run with OpenVINO GenAI:

import openvino_genai as ov_genai

draft = ov_genai.draft_model("dspark_qwen3_4b_block7-ov", "GPU")
pipe = ov_genai.LLMPipeline("Qwen3-4B-int8-ov", "GPU", draft_model=draft)

config = ov_genai.GenerationConfig()
config.max_new_tokens = 256
config.num_assistant_tokens = 7

result = pipe.generate(["Write a Python function that checks whether a number is prime."], generation_config=config)
print(result.texts[0])
print("accepted tokens:", result.extended_perf_metrics.get_num_accepted_tokens())

The other drafts export the same way:

# DFlash
optimum-cli export openvino --model z-lab/Qwen3.6-27B-DFlash --weight-format int4 Qwen3.6-27B-DFlash-ov
# DFlash v2
optimum-cli export openvino --model incoai/Qwen3.8-27B-DFlash2 --weight-format int4 Qwen3.8-27B-DFlash2-ov
# DeepSpec DFlash draft, a DSpark draft without a Markov head
optimum-cli export openvino --model deepseek-ai/dflash_qwen3_4b_block7 dflash_qwen3_4b_block7-ov
# MiniCPM-V 4.7 DSpark
optimum-cli export openvino --model openbmb/MiniCPM-V-4.7-1B-DSpark MiniCPM-V-4.7-1B-DSpark-ov

The Qwen3.6, Qwen3.8 and MiniCPM-V 4.7 targets are multimodal and have linear attention: they run with VLMPipeline.

Validation

Intel Core Ultra X7 358H, Arc B390 GPU.
HumanEval, greedy; the 27B and 35B targets 5 prompts x 256 new tokens, the others 20 prompts x 2048.

Draft Target num_assistant_tokens Target alone, tok/s With draft, tok/s TPOT speedup Tokens per step
z-lab/Qwen3.6-27B-DFlash, int4 Qwen3.6-27B, int4 15 7.13 58.74 8.01x 12.12
incoai/Qwen3.8-27B-DFlash2, int4 Qwen3.8-27B, int4 7 7.53 41.49 5.50x 7.40
deepseek-ai/dspark_qwen3_4b_block7 Qwen3-4B, int8 7 25.32 94.07 3.69x 5.48
deepseek-ai/dflash_qwen3_4b_block7 (no Markov head) Qwen3-4B, int8 7 25.29 87.48 3.43x 5.08
openbmb/MiniCPM-V-4.7-1B-DSpark MiniCPM-V-4.7-1B, int8 15 94.03 178.32 1.88x 3.98
openbmb/MiniCPM-V-4.7-35B-A3B-DSpark MiniCPM-V-4.7-35B-A3B, int4 15 40.41 113.38 2.65x 9.90

Tests

The DFlash v2 tests use a new tiny draft, optimum-intel-internal-testing/tiny-random-qwen3-dflash2, a draft for tiny-random-qwen3 laid out like the published DFlash v2 checkpoints: block_size in a nested dflash_config, grouped-query attention with one KV head, a sliding and a full-attention layer, random base kernels that reach the previous rows, and non-default output_multiplier, final_logit_softcapping and input_embedding_scale. They cover the checkpoint keys, config normalization and validation, the grouped dynamic convolution against a reference implementation, and the exported package: the rt_info of both IRs and the Markov head's Top-K lattice on packed blocks against a reference formula, also from a bf16 checkpoint.

For DFlash and DFlash v2, the draft IR converted to PagedAttention matches the PyTorch draft over several steps of sequences packed into one call, and each input is read only in its own rows. A draft without a Markov head (DFlash, DeepSpec DFlash) still exports a single IR. The CLI test of the Markov head package runs on the DSpark and the DFlash v2 drafts with default, INT8, INT4 and FP16 weights, and the task is inferred for every draft architecture.

Before submitting

  • This PR fixes a typo or improves the docs (you can dismiss the other checks if that's the case).
  • Did you make sure to update the documentation with your changes?
  • Did you write any new necessary tests?

@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@imargulis
imargulis force-pushed the dflash2-pa branch 5 times, most recently from fda6460 to 94ab113 Compare November 4, 2026 17:47
imargulis added a commit to imargulis/openvino.genai that referenced this pull request Nov 4, 2026


The DFlash CI job installs optimum-intel from
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.

Co-authored-by: Cursor <cursoragent@cursor.com>
DynamicCache(config=...) gives sliding-window layers a sliding cache layer, which keeps only the last
window of keys. The draft applies the window through its attention mask instead, so the second cached
step built a mask over more keys than the cache held. A plain DynamicCache keeps every key, as the
exported graph does.

Co-authored-by: Cursor <cursoragent@cursor.com>
imargulis and others added 12 commits November 9, 2026 02:53
…v head

DFlash-2 drafts now use the per-row DFlash backbone that SDPAToPagedAttention converts: every row
comes out, and the runtime rolls the block rows of the KV cache back through past_lens. The grouped
dynamic convolution shifts its taps along the flattened rows and masks them to the block of the row
they produce, so the taps hold on the token axis after the PagedAttention conversion too.

The DFlash-2 candidate selector is exported as the Markov head of the draft,
openvino_markov_head_model.xml with dflash/interface=markov_head_v1 and markov_head_type=dflash2,
instead of a separate selector model. It is the packed Top-K lattice of the DSpark Markov head, with
transitions gated by the draft hidden states and no dense step. The backbone advertises it through
markov_rank and markov_head_type, as a DSpark draft does.

DSpark drafts are DFlash v1 drafts to the exporter: the same validation, backbone and RT info, with
the candidate offset and the Markov head taken from their config.

The tests use optimum-intel-internal-testing/tiny-random-qwen3-dflash2, a two-layer DFlash-2 draft
for tiny-random-qwen3 with the tensor layout of the published checkpoints.

Co-authored-by: Ofir Zafrir <ofir.zafrir@intel.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
main_export read architectures[0] three times, each with its own empty-list
guard: to validate DFlash configs and pick the Eagle3 loader, to load native
DFlash drafts, and to drop the tokenizer of DFlash drafts. It now derives the
architecture once, with config_architecture() from dflash_utils, which the
export config, the dummy input generator and the draft model also use instead
of their own copies of the same expression.

No behavior change: the architecture stays None outside the transformers
library and for configs without architectures, and the Eagle3 config update
does not touch architectures.

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

Item assignment (x[block_rows] = value) strips the leading unit dimension of a
one-row value with a view. When the random dummy token_type_ids hold a single
block row, the trace freezes that view's shape, and the exported draft fails
on any other block length. The query, attention input and output row writes
of the Qwen3 and MuseGlimmer drafts now call index_put_ directly.

The PagedAttention tests trace the Qwen3 variants with a single block row, so
they cover this case regardless of the dummy seed.

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

Since huggingface#1983, the exporter infers text-generation-with-past for the natively loaded draft
architectures and compresses DFlash draft IRs to INT4 on all layers, but
task inference on a local draft checkpoint still raised RuntimeError.
infer_task now falls back to the config architectures for it, as for
Eagle-3 drafts, so the CLI needs no --task there either.

The DFlash and DSpark CLI tests run without --trust-remote-code. The
Markov head package test also omits --task, and checks that no int8
weights remain in the INT4 drafts.

Co-authored-by: Cursor <cursoragent@cursor.com>
The PyTorch reference of the PagedAttention tests passed its own DynamicCache, so
no test reached the cache the draft builds when it gets none (db5a55c). The
reference now takes the draft's cache. With the sliding layers of these tests, a
cache built from the config drops past keys and fails both variants.

Co-authored-by: Cursor <cursoragent@cursor.com>
The draft export config computes the Markov head spec once and hands it to the
Markov head model config and to the RT-info writer, which used to rebuild it from
the HF config. The Markov head config takes its gating from the spec's head type.
The default Top-K helper, used once, is inlined.

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

MarkovHeadOpenVINOConfig.markov_head_top_k repeated the K of its Markov head spec
and served only to tell convert.py that the graph is a Markov head. A class flag,
markov_head_model, now does that, and the dflash/top_k RT info reads the spec, the
same one that sizes the exported TopK.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
MarkovHeadOpenVINOConfig.inputs spelled out the three inputs that both heads take
twice. It now adds draft_hidden_states for the gated head or prev_token_ids for
the DSpark head to the shared ones. The IR keeps its input order, which the export
takes from the head's forward signature; the DSpark head test now checks that
order, as the gated head test already did.

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

Since 5be7015 the convolution runs on the packed block rows, so a masked tap
reaches another sequence's block (or the zero padding), not a context row.

Co-authored-by: Cursor <cursoragent@cursor.com>
- The anchor-layout test is what is left of the one that checked the
  backbone dropping the anchor row: the offset is checked by the config
  test and the row count by the PagedAttention tests.
- The DFlash-2 target annotation test exported one more Qwen3 target;
  the decoder locator test now checks one locator per decoder layer.
- The two DSpark Markov head export tests export the draft once; the
  full-model parity loop is covered by the split computations and the
  bf16 test.
- The single-sequence PagedAttention test is folded into the
  multi-sequence one, which repeats its steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Markov head model carried dflash/interface=markov_head_v1 to version
its contract with runtimes. markov_head_type already names the head, and
runtimes find the computations and read their results by port name and
check every port, so the tag could only catch a port whose meaning
changes without a new name. Such a port gets a new name instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
main exports Muse-Glimmer assistants with a DFlash export config, but their config is not a DFlash, DSpark or DFlash-2 one, so the strict parser rejected them. They get the rt_info keys their config has, as on main.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants