recipe for running Qwen3.8-Flash-Next on a single DGX Spark (fork)
Python
45
38 commits
updated Oct 5, 2026
Run Qwen3.8-Flash-Next — a ~176B-parameter model (125B main + 51B n-gram, 6B active) — on one NVIDIA DGX Spark / ASUS GX10 with vLLM: ~50–60 tok/s single-stream decode with MTP=3 (~2,100 tok/s prefill), working prefix caching, and a never-evict pin that keeps your system prompt's KV resident through arbitrary traffic.
Forked from blazux/qwen3.8-Flash-DGX,
which established the foundation this fork stands on: the ~49 GiB fp8 n-gram ("PLE")
table is a pure lookup that a token only touches 16 rows of, so it is served
from NVMe via mmap instead of living in the 128 GB unified pool
(full story: docs/HOW-IT-WORKS.md). Upstream serves the
official NVFP4 checkpoint at 25–28 tok/s; for that path and its tuning
guide, use upstream. This fork replaces the checkpoint with
Intel's W4A16 AutoRound int4
plus an int8 GPTQ lm_head and blockwise-fp8 side layers (all prepared by
CPU-only tools in tools/), an fp8 PLE table, and a set of GB10/vLLM
patches — roughly 1.8× faster decode than the NVFP4 recipe on the same box.
Upstream's NVFP4 recipe independently reproduced on a DGX Spark by @jschmied — see blazux#1 and their write-up, which also contributed the concurrency findings below.
| this fork (int4/int8/fp8) | |
|---|---|
| Prefill | ~2,100–2,200 tok/s |
| Decode, single stream | ~50–60 tok/s (MTP=3) |
| Prefix caching | on (+ never-evict pin) |
| Context | 262k native / 500k YaRN |
| Weights resident | ~68 GiB |
Measured on this stack with the default serve.sh config (DGX Spark, default GPU
clocks; int4 MTP draft experts, DRAFT_VOCAB=1, MTP=3, prefix caching on, SEQS=16,
8192-token prefill chunks). One caveat: on this box the PLE table is served from
another machine's RAM over RDMA (the magi branch), the fastest row source there is —
with the table on a regular local NVMe, as in the Quickstart, expect decode about
2–3% slower than the numbers below. Single-stream decode by workload, thinking enabled —
reproduce with
bench_qwen35.sh
(from albond's 122B recipe) pointed at your endpoint, on a freshly started container:
| Task | Prompt Tokens | Gen Tokens | Time (s) | Speed (tok/s) |
|---|---|---|---|---|
| [Q&A] | 65 | 57.5 ± 5.2 | 1.18 ± 0.17 | 49.1 ± 2.6 |
| [Code] | 72 | 275.8 ± 2.2 | 4.89 ± 0.24 | 56.5 ± 3.1 |
| [JSON] | 90 | 841.8 ± 20.8 | 13.54 ± 0.46 | 62.1 ± 0.7 |
| [Math] | 71 | 64.0 ± 0.0 | 1.15 ± 0.01 | 55.5 ± 0.2 |
| [LongCode] | 79 | 2048.0 ± 0.0 | 42.33 ± 2.53 | 48.5 ± 2.8 |
Note: arithmetic mean ± sample standard deviation over 4 runs (2 scripts × 2 runs, 2026-09-09). Draft acceptance with thinking on is ~70% (2.1 of 3); with enable_thinking: false it is ~87% and decode runs 5–10% faster than the table.
Under concurrency, TTFT grows with MTP's prefill cost (see the next
section), not with the paged table. On upstream's NVFP4 path
@jschmied measured aggregate throughput
scaling to ~267 tok/s at 48 streams
(load-and-waits.md);
two portable takeaways: per-token page-fault cost falls with concurrency
(batched tokens share n-gram rows), and a low --max-num-seqs silently
queues requests — check vllm:request_queue_time_seconds_sum before quoting
an aggregate number.
docker build also works on x86 Blackwell
(sm_120) for testing, though this is tuned for the Spark.git clone https://github.com/Saren-Arterius/qwen3.8-Flash-DGX-AutoRound.git
cd qwen3.8-Flash-DGX-AutoRound
docker build -t qwen38-flash-dgx . # official image + this fork's patches
# The prepared checkpoint + PLE table (one-time, ~116 GiB):
hf download Saren/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid-MTP_int4RTN --local-dir /models/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid-MTP_int4RTN
hf download Saren/Qwen3.8-Flash-Next-ple-table-fp8 --local-dir /models/ple-table-fp8
# option: the same checkpoint with the MTP draft experts left in bf16 (+3.5 GiB, ~3% slower decode):
# hf download Saren/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid --local-dir /models/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid
# (or build them yourself from Intel's release: ./prepare.sh — see below)
# Point serve.sh at your checkpoint + table dirs, then:
./serve.sh # boots on :8000 (~5 min with fastsafetensors)
docker logs -f qwen38-flash # wait for "Application startup complete"
Then hit the OpenAI-compatible API:
curl http://localhost:8000/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model": "qwen",
"messages": [{"role":"user","content":"Write a haiku about a desktop supercomputer."}],
"max_tokens": 512
}'
serve.sh is a thin example config over scripts/serve-intel-ar.sh — every
knob is an env var. The defaults below are serve.sh's (the recommended
entry point); where the bare scripts/serve-intel-ar.sh falls back to
something else, the note says so. Keep your machine's real settings as an
edited copy or a local-only commit on top.
The quickstart's two hf download repos are the finished artifacts — hashes
verified against the local originals. If you'd rather build (or audit) them
yourself from
Intel/Qwen3.8-Flash-Next-W4A16-AutoRound,
one script runs the whole pipeline (CPU-only — a NAS box is fine):
./prepare.sh /models/Qwen3.8-Flash-Next-W4A16-AutoRound /models/ple-table-fp8
Each step is explained in prepare.sh's header comments: int8 lm_head repack,
fp8 side-layer conversion, n-gram index strip, fp8 table fetch, the
quantization_config rewrite, and the int4 MTP-draft-experts variant. On that last one: this vLLM build has no
auto-round loader, but its GPTQ config (AutoGPTQConfig → Marlin kernels)
reads the same packed tensors — the GPTQModel-style dynamic rules exclude
the families that are not int4-packed and flip the head to 8-bit. The original
AutoRound config is kept as config.json.autoround.
docker build -t qwen38-flash-dgx .
MODEL_DIR=/models/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid-MTP_int4RTN \
TABLE_DIR=/models/ple-table-fp8 \
PREFIX_CACHE=1 PIN_PROMPT="You are HomeBot, the household assistant." \
scripts/serve-intel-ar.sh
or edit the paths in serve.sh (the example config used above) and run it.
| Var | serve.sh default | Notes |
|---|---|---|
MODEL_DIR / TABLE_DIR | /path/to/... — edit these | Prepared checkpoint / fp8 PLE table dirs |
PORT | 8000 | API port (bare script: 18300) |
CTX | 262144 | Max context |
YARN | 0 | 1 = Qwen's YaRN rope scaling (factor 4) past the native 262144 — set CTX too (500k was upstream's validated ceiling). Also forces the MTP draft's max_model_len, which --hf-overrides alone doesn't reach |
SEQS | 8 | Max concurrent sequences (don't benchmark with 1–2, see below) |
GPU_MEM | 0.01 | Near-zero pool fraction, paired with KV_BYTES: deterministic sizing, so the driver never oversubscribes the unified pool (NV_ERR_NO_MEMORY / Xid 31 freezes). Bare script: a 0.85 fraction — avoid on unified-memory boxes. |
KV_BYTES | 20g | Explicit KV pool size, passed as --kv-cache-memory-bytes (bare script: unset) |
MTP | 3 | Speculative tokens from the MTP head (0 = off; bare script: 2) |
PREFIX_CACHE | 1 | Prefix caching — fixed and recommended on this fork (bare script: 0) |
DET_TOPK | 1 | Deterministic QSA top-k kernel (patch 9; @jschmied, vllm#55122): identical output at T=0 at full prefill speed. 0 = stock kernel (non-deterministic, may drop attention candidates) |
EXACT_TOPK | 0 | 1 = exact torch.topk fallback (patch 9; deterministic, −20–40% on long prefill). Wins over DET_TOPK when set |
DRAFT_VOCAB | 1 | The MTP drafter scores only the 65,536 most frequent tokens (patch 10; from upstream blazux): +3–5% decode on English/code, draft acceptance unchanged there (thinking on or off). The shipped id set is English/code-weighted — CJK-heavy output loses acceptance and decode speed with it, so set 0 (full vocabulary) or build your own set with tools/build_draft_vocab.py over your traffic |
DRAFT_HEAD | int8 | int4 gives the drafter a private int4 g128 RTN GPTQ-Marlin copy of the full-vocabulary head (built at first use, ~313 MiB vs 616 MiB read per draft step). Measured a wash: tg unchanged within noise, draft acceptance 1–8 points lower (see the MTP options section), so the shared int8 head stays the default |
PIN_PROMPT / PIN_MAX_FRACTION | unset / 0.25 | Never-evict pin (patch 6); needs PREFIX_CACHE=1 |
FP8_HYBRID | 1 | int4+fp8 hybrid dispatch (patch 4) |
PLE_MADV_RANDOM | 1 | MADV_RANDOM on the table mmap (patch 1): no readahead around 160-byte row faults — upstream (blazux 0c6df7e) measured 4–8% faster cold prefill and a cleaner page cache, now the default |
PLE_PREFETCH | 0 | Batch-assembly prefetch — measured not worth enabling (see appendix) |
HIT_DEBUG | 0 | Prefix-cache tracing (patch 8) |
PREWARM | 1 | Stream the table once at boot to warm the page cache |
WORKERS | 32 | Threads for the mmap gather |
LOAD_FORMAT | fastsafetensors | Noticeably faster cold boots |
TOOL_PARSER | qwen3_xml | Tool-call parser (bare script: qwen3_coder) |
SERVED_NAME | qwen | Model id on the API (bare script: qwen3.8-flash-next) |
ITER_DETAILS | 0 | 1 = per-step prefill metrics: vllm:scheduled_ctx_tokens_total updates every engine step (stock prompt_tokens_total only moves when a prefill finishes). Live view: bench/ppwatch.sh |
EXTRA | Extra vLLM flags, passed verbatim |
GPU_MEM=0.01 + KV_BYTES sizing over a large
fraction (an OOM inside the unified pool can freeze the box).| Component | Precision | How |
|---|---|---|
| 512-expert MoE, 48 main layers | int4 GPTQ-Marlin g128 | Intel checkpoint as-is |
| MTP draft layer's own 512 experts | int4 GPTQ-Marlin g128 RTN (the default -MTP_int4RTN checkpoint) | Intel leaves layer 48 in bf16 (~4.7 GiB, -:.*layers\.48\..*); tools/quantize_mtp_experts_int4.py (patch 10) makes it int4 on the Marlin path: −3.5 GiB, +2–4% decode, acceptance unchanged. The plain -hybrid repo keeps them bf16 |
| lm_head (shared with MTP draft head) | int8 GPTQ-Marlin (uint8b128) | tools/quantize_lm_head_int8.py + "lm_head": true |
| GDN in/out projections, QSA q/k/v/o, shared expert | fp8 blockwise e4m3 (128×128) | tools/fp8_convert.py + src/vllm_fp8_hybrid.py |
| Embeddings, hyper-connections, norms, MoE gates, fc_hidden | bf16 | excluded via dynamic rules |
| PLE n-gram table (51B params, layer 1) | fp8 rows, mmapped from disk | tools/fetch-ple-table-fp8.sh + the mmap patch |
| KV cache | bf16 | QSA refuses fp8 KV |
Everything is applied at image build time (see the Dockerfile); each patch is
independent and gated by an env var where it changes behavior.
src/vllm_ple_mmap.py, extends upstream's patch)weight_scale) are all
accepted; row size is derived from the safetensors headers. The fp8 table
halves the bytes read per token vs bf16.VLLM_PLE_MMAP_DIR: the table no longer has to live inside the
checkpoint dir — point it at any directory of safetensors shards (NFS, local
NVMe, a RAM-backed device...). The backend matters: the ~49 GiB table
outgrows what the page cache can keep warm next to the model, so gathers
cost ~1.3 ms/op from a RAM-backed source vs 5–9 ms from local NVMe vs
30–50 ms over NFS — decode impact in the appendix below.np.unique), gather of
unique rows only, staging through a persistent pinned buffer with an async
H2D copy, and GPU-side expansion via the inverse index. A decode fast path
(VLLM_PLE_MMAP_FAST_ROWS, default 512) skips the thread pool entirely for
small gathers. Net effect: ~7.2 → ~2.5–3.8 ms per lookup op on a RAM-backed table.VLLM_PLE_MMAP_MADV_RANDOM=1: madvise(MADV_RANDOM) the mmap so faults
stay single-page — for tables on remote RAM or boxes with no page-cache
headroom.VLLM_PLE_MMAP_STATS_SEC (default 30) logs
PLE mmap stats (last Ns): calls, op ms, gather ms, rows, MB and resets the
counters each period.Dockerfile sed)sm_121 reports 99 KiB of shared memory per block — the same as ADA, where the
flash-linear-attention Triton kernels use their big GDN tiles — but the gate in
vllm/third_party/flash_linear_attention/ops/utils.py demands 100 KiB, so all
36 GDN layers silently fell back to small tiles. Lowering the gate to 99 KiB
(101376) lets GB10 take the big-tile path. (Found the hard way in the
Qwen3.5-122B Spark recipe — ported from
Entrpi/qwen3.5-122B-A10B-on-spark's
patch_fla_shmem.py.)
Dockerfile sed on model.py / mtp.py)Upstream constructs ParallelLMHead without quant_config, forcing a bf16
head (1.27 GiB, and a bf16 GEMV per token over a 248320 vocab). One added
kwarg in both the main model and the MTP draft lets the head pick up the
checkpoint's int8 GPTQ packing. Without the mtp.py half, MTP ≥ 3 crashes at
load ("no module or parameter named 'lm_head.qweight'").
src/vllm_fp8_hybrid.py, VLLM_FP8_HYBRID=1)vLLM's GPTQ config quantizes listed layers and leaves the rest to
UnquantizedLinearMethod — it has no notion of "this excluded layer is
actually fp8 in the checkpoint". This shim wraps AutoGPTQConfig: it scans the
checkpoint metadata for F8_E4M3 weights with a weight_scale_inv sibling and
routes exactly those layers to vLLM's blockwise-Fp8Config
(weight_block_size=[128,128], dynamic activation scheme) while everything
else keeps the GPTQ path. VLLM_USE_DEEP_GEMM=0 is required on sm_121
(DeepGEMM hits CUDA_ERROR_LAUNCH_FAILED); the triton fallback is fine.
src/patch_mamba_align_split.py)Upstream runs --no-enable-prefix-caching because of a CUBLAS error in the
GDN in_proj GEMM on the cached-block path. With the fp8 side layers that
GEMM runs a different kernel, and prefix caching is stable in our serving
(PREFIX_CACHE=1).
It also works properly now. On this hybrid model the reconciled cache hit is
the minimum across all KV cache groups (full attention + four mamba/GDN
state groups), and mamba "align" mode can only cache a state at a prefill
chunk end on a 1600-token boundary. The image's scheduler aligned those chunk
ends to cache_config.block_size — which the engine rewrites to the minimum
group block size (8, the MTP draft granularity) — so chunks ended where no
mamba state was cacheable, a cold request published zero reusable mamba
states, and a repeated prompt only got fast on the 3rd try (the miss
triggers junction machinery that rebuilds the boundary one request late).
Long prompts effectively never hit. The patch makes the split use
cache_config.mamba_block_size (1600). Verified: an 8k-token repeat goes
10.1 s → 0.90 s on the 2nd request.
The same rewritten block_size also poisoned the worker side: the
align-mode state-slot seed (mamba_hybrid.py) divided by it too, so a prefix
hit at 6400 tokens seeded state column 799 instead of 3, read past the
block-table row and restored a wrong (often all-zero / stale) mamba state —
greedy outputs visibly changed on cache hits. Root-caused upstream by
blazux
(his fix: 8347e7c); the same one-line seed fix is folded into this patch.
Verified: cold-vs-hit first-token logprobs now agree within the stack's
normal run-to-run jitter (Marlin atomic-add nondeterminism), where before the
fix greedy outputs diverged within the first few tokens.
Notes: the prefix-cache granularity is large (1600 tokens; shorter prefixes
get no reuse), and a repeat hit tops out at round_down(P,1600) − 1600 — MTP
(eagle-style) always recomputes the last matched block.
src/patch_never_evict.py)--never-evict-kv-cache-prompt-includes "<substring of your system prompt>"
pins the KV blocks of any prompt containing that marker: they are held in a
side queue on BlockPool, excluded from the free count, and thus never handed
out for eviction — your assistant's system prompt stays cached no matter what
other traffic does. --never-evict-kv-cache-max-fraction (default 0.25) caps
the pin. The pin set is replaced on each matching request, so an updated
system prompt releases the old blocks automatically.
Verified end-to-end: a pinned 8k prompt still answers in 0.94 s after 2M tokens of unique traffic (3.1× full KV-pool turnover).
Implementation notes: the marker is tokenized once and matched as a token-id
subsequence (first/last token dropped — BPE merges at the boundaries); the pin
is keyed on block hashes, not block ids, because this hybrid model frees
mamba/GDN state blocks mid-request — each freed block is re-claimed by the pin
the moment free_blocks() sees it. Only prefix-cacheable KV groups
participate (the MTP draft layer's group is not, and must be skipped). This is
a pin-only port of our arc_pin2 patch from the Qwen3.5-122B Spark stack,
which built the pin on top of the ARC GPU-eviction work in
vllm#40270; the ARC/2Q
policies themselves were deliberately dropped — the stock free queue is
C-speed on a path this model hits every step.
Self-check (no GPU): docker run --rm -v "$PWD/src:/t" --entrypoint python3 qwen38-flash-dgx /t/test_never_evict_pin.py.
src/mamba_utils_guarded.py)Hardens the align-mode state-copy kernels against the "CUDA illegal memory
access / Xid 31 under load" crash class (also
blazux#2): backports
vllm#50729 (overlapping
state-copy race) and bounds-checks every block id against its state pool
before dereferencing — an out-of-range id skips the copy and bumps a counter
(logged as mamba state-copy guard) instead of taking down the CUDA context.
With the block-size seed fix (patch 5) the out-of-range ids the guard was absorbing are gone at the root: the counter is expected to stay at 0, and a nonzero count is logged as an error — it now indicates a new bug worth reporting, not a known quirk.
src/patch_hit_debug.py, HIT_DEBUG=1)Set HIT_DEBUG=1 (→ VLLM_HIT_DEBUG=1 in the container) to log, per request:
the per-KV-group hit reconciliation (which group truncated the hit), mamba
boundary-state publication (which slots were real/null/hashed), cached-block
evictions, and prefill chunk-stop decisions. This is what found the bug in
patch 5; costs nothing when off.
src/patch_qsa_exact_topk.py + Dockerfile patch 10)Taken from upstream blazux/qwen3.8-Flash-DGX
(8347e7c, 4b723de, 0022e36). The sparse attention (QSA) picks its top-k key
blocks with vLLM's persistent_topk kernel, which is non-deterministic on GB10 and
can drop real candidates (vllm#51782,
diagnosed by @k3dani in
blazux#3). Two fixes, both opt-in
env vars, DET_TOPK=1 the default:
DET_TOPK=1 — deterministic kernel by @jschmied
(vllm#55122): index-ordered output
slots and bit-exact tie resolution (signed zero canonicalised), plus a deterministic
low-shared-memory fallback. The sources are fetched at a pinned commit (sha256-checked)
and compiled with the image's nvcc at build time as a standalone _C_det.so — no vLLM
rebuild; qsadet_patch.py (also @jschmied's) wires the QSA block selection to it.
Upstream measured the whole prefill penalty of the exact path gone (GX10, 32k:
1,794 → 2,996 tok/s) with decode unchanged.EXACT_TOPK=1 — exact torch.topk over the visible columns (blazux's first fix):
also deterministic, −20–40% on long prefill; kept as the fallback and wins over
DET_TOPK when set. Self-check (no GPU): docker run --rm -v "$PWD/src:/t" -w /t --entrypoint python3 qwen38-flash-dgx test_qsa_exact_topk_cpu.py.Both on by default (the Quickstart checkpoint + DRAFT_VOCAB=1); the plain
-hybrid checkpoint and DRAFT_VOCAB=0 remain options. Both leave outputs
unchanged — the target verifies every drafted token, only the draft's cost and
acceptance move. Measured on a DGX Spark (bench_qwen35.sh with
enable_thinking: false, T=0, second run):
bf16 draft (-hybrid repo) | int4 draft experts | int4 + DRAFT_VOCAB=1 (default) | |
|---|---|---|---|
| Code / JSON / LongCode tok/s | 57.3 / 61.3 / 57.9 | 59.1 / 62.6 / 60.3 | 62.0 / 66.0 / 62.4 |
| draft acceptance (of 3) | 88.0% (2.64) | 88.5% (2.65) | 87.3% (2.62) |
| weights resident | ~71.4 GiB | 67.9 GiB | 67.9 GiB |
With thinking on (albond's original script) draft acceptance is ~70% in all three configs — only 1.0% of reasoning tokens fall outside the 65k set — and the default config is still the fastest (Code 56.5, JSON 62.1, LongCode 48.5 tok/s, see the Throughput table).
int4 draft experts — Intel's checkpoint leaves the MTP layer's 512 routed
experts in bf16 (~4.7 GiB on the unquantized MoE path). tools/quantize_mtp_experts_int4.py
writes a variant checkpoint (hardlinks for the untouched shards) with those
experts as int4 g128 RTN in Intel's exact GPTQ layout and drops the layers.48
exclusion, so the draft runs on GPTQ-Marlin like the main layers. Prebuilt:
Saren/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid-MTP_int4RTN
— point MODEL_DIR at it. Idea from upstream's hybrid-mtp mode
(blazux#11 by @pfy, after
thavoc's graft write-up), done here as RTN int4 instead of an NVFP4 graft so no
new kernel path is needed.
DRAFT_VOCAB=1 (src/patch_mtp_draft_vocab.py, from upstream blazux 0c6df7e,
idea from MiaAI-Lab's recipe reimplemented there) — vLLM shares the target's
lm_head with the drafter, so every draft pass scores all 248,320 rows. With the
flag the draft scores a private 65,536-row slice (src/draft_vocab_65536.npy:
corpus frequency + BPE order + all special tokens; tools/build_draft_vocab.py
rebuilds it) and every other id is −inf. This fork's head is int8 GPTQ-Marlin,
so the slice is dequantized from the checkpoint's GPTQ tensors at first use
(616 → 320 MiB read per draft step).
DRAFT_HEAD=int4 (same patch) — the other way to halve the head read
without touching the vocabulary: a private int4 g128 RTN copy of the full head,
quantized with vLLM's own marlin_quantize at first use (~23 s) from the
checkpoint's int8 GPTQ tensors, run through the same GPTQ-Marlin kernel
(616 → 313 MiB per draft step; +313 MB resident). Outputs cannot change (the
target verifies every token), only acceptance can, and it does: int4 RTN puts
~11% relative error on the head (int8: ~0.5%). Measured 2026-09-10, thinking
off, 600-token answers, second run of each prompt:
| int8 shared head (default) | DRAFT_HEAD=int4 | |
|---|---|---|
| Traditional Chinese prose | 34.6 tok/s, 31% accept | 35.5 tok/s, 30% |
| Cantonese prose | 32.8 tok/s, 28% | 32.3 tok/s, 25% |
| English prose | 40.4 tok/s, 44% | 39.6 tok/s, 39% |
| Python + tests | 62.5 tok/s, 86% | 60.7 tok/s, 78% |
The bandwidth saved is handed back in rejected drafts, so it stays off. A calibrated int4 (AutoRound/GPTQ on real MTP hidden states) might keep more acceptance; not tried. Context: krisbailey.com's "shortlist MTP" write-up found the same ~4% ceiling for head-shrinking tricks once the head kernel is bandwidth-bound, which Marlin already is here.
MTP raises decode substantially but puts a floor (~0.8 s) under
time-to-first-token: vLLM's v1 engine only emits the first token after the
drafter has run, and MTP's draft layer is a stateful autoregressive
transformer — on every prefill chunk it must run a full-chunk-width forward
(always eager: above the cudagraph capture sizes) to sync its own KV/GDN
state, plus k−1 sequential single-token passes. Cross-attention drafters like
DFlash don't pay this, but Flash-Next has no such drafter — MTP is what ships
in the checkpoint. If your workload is TTFT-sensitive, weigh MTP depth
against MTP=0; only 0 removes the floor.
Dockerfile official vLLM Flash-Next image + the patches above
serve.sh example launcher config (edit paths, run)
prepare.sh build the checkpoint + table from Intel's release
src/vllm_ple_mmap.py mmap PLE table (any dtype, relocatable dir, fast gather)
src/vllm_fp8_hybrid.py int4+fp8 hybrid dispatch on the GPTQ config
src/patch_never_evict.py never-evict system-prompt KV pinning
src/patch_mamba_align_split.py prefix-cache chunk-alignment fix
src/patch_hit_debug.py prefix-cache tracing (VLLM_HIT_DEBUG)
src/patch_qsa_exact_topk.py exact, deterministic QSA top-k (VLLM_QSA_EXACT_TOPK=1; from blazux)
(Dockerfile patch 10) @jschmied's deterministic persistent_topk kernel, built at docker build
src/test_qsa_exact_topk_cpu.py CPU unit test for the exact top-k (no GPU needed)
src/patch_mtp_draft_vocab.py private MTP draft head: reduced vocabulary (from blazux) / int4 (VLLM_MTP_DRAFT_VOCAB, VLLM_MTP_DRAFT_HEAD)
src/draft_vocab_65536.npy the default 65,536-token draft id set (from blazux)
tools/build_draft_vocab.py rebuild the draft id set (from blazux)
tools/quantize_mtp_experts_int4.py int4 RTN the MTP draft experts -> -MTP_int4RTN checkpoint
src/mamba_utils_guarded.py hardened align-mode state copy (vllm#50729 + guard)
src/test_ple_mmap_cpu.py CPU unit test for the gather (no GPU needed)
src/test_never_evict_pin.py CPU unit test for the pin (no GPU needed)
scripts/serve-intel-ar.sh the docker run behind serve.sh
scripts/smoke-test.sh health + coherence + prefill/decode numbers
src/patch_prefill_metrics.py per-step prefill counters for Prometheus (ITER_DETAILS=1)
bench/ppwatch.sh live prefill tok/s from those counters
bench/decode_bench.py batch-1 decode / TTFT / spec-acceptance bench
tools/ CPU-only checkpoint preparation
docs/HOW-IT-WORKS.md upstream's mmap-PLE story
docs/OPTIMIZATIONS.md this fork's patches in depth
vllm/vllm-openai:qwen38-flash-next,
the release/qwen38next recipe / PR #53896).0c6df7e; idea from MiaAI-Lab's recipe, reimplemented there); the quantized draft
experts follow upstream's hybrid-mtp mode
(blazux#11 by @pfy).torch.topk fix and the kernel wiring/pins from
upstream blazux/qwen3.8-Flash-DGX;
the deterministic persistent_topk kernel itself by @jschmied
(vllm#55122); the bug diagnosed by
@k3dani (blazux#3).PLE_PREFETCH)Batch-1 decode bench (bench/decode_bench.py, medians of 3; W1 = fresh-prompt
1000-token decode, W2 = pinned ~8k prefix hit + 256 tokens; DGX Spark GB10,
MTP=3). Only the table location and the prefetch flag change between rows (all local NVMe is Gen 4):
| table source | prefetch | W1 tok/s | W2 tok/s | gather |
|---|---|---|---|---|
| RDMA row daemon | on | 42.4 | 47.8 | ~1.3 ms/op |
| RDMA row daemon | off | 41.6 | 46.7 | ~1.3 ms/op |
| local NVMe | off | 36.5 | 45.7 | 5–9 ms/op |
| local NVMe | on | 34.5 | 42.6 | 9–17 ms/op |
| local NVMe, warm cache | on | 33.4 | 41.5 | 9–16 ms/op |
| NFS (RDMA mount, btrfs) | on | 26.2 | 36.5 | 26–56 ms/op |
| NFS (RDMA mount, btrfs) | off | 24.2 | 32.9 | 30–42 ms/op |
Takeaways:
PLE_PREFETCH=1 (VLLM_PLE_MMAP_PREFETCH). The
batch-assembly prefetch only wins where the row source is very slow (NFS,
+2–3 tok/s, within run-to-run variance), is a wash on fast sources, and is
net-negative on local NVMe: the handoff/wait in consume() costs more
than the inline gather it replaces (confirmed on a warm page cache). It
stays experimental and default-off.About those "RDMA row daemon" rows: that is a custom one-sided RDMA READ
server that pins the whole table in another box's RAM — the fastest row
source measured, at the price of a second machine and an ibverbs science
project. If you have a NAS with ≥64 GB of RAM and a ≥100 Gbit RDMA
link to your Spark — and no second DGX Spark to put to better use — the
magi branch ships the tool (src/ple_rdma/) and setup
notes ("PLE table over RDMA"). Everyone else: local NVMe is the recipe.
184 followers · starred Aug 2026
recipe for running Qwen3.8-Flash-Next on a single DGX Spark (fork)
Python
45
38 commits
updated Oct 5, 2026
Run Qwen3.8-Flash-Next — a ~176B-parameter model (125B main + 51B n-gram, 6B active) — on one NVIDIA DGX Spark / ASUS GX10 with vLLM: ~50–60 tok/s single-stream decode with MTP=3 (~2,100 tok/s prefill), working prefix caching, and a never-evict pin that keeps your system prompt's KV resident through arbitrary traffic.
Forked from blazux/qwen3.8-Flash-DGX,
which established the foundation this fork stands on: the ~49 GiB fp8 n-gram ("PLE")
table is a pure lookup that a token only touches 16 rows of, so it is served
from NVMe via mmap instead of living in the 128 GB unified pool
(full story: docs/HOW-IT-WORKS.md). Upstream serves the
official NVFP4 checkpoint at 25–28 tok/s; for that path and its tuning
guide, use upstream. This fork replaces the checkpoint with
Intel's W4A16 AutoRound int4
plus an int8 GPTQ lm_head and blockwise-fp8 side layers (all prepared by
CPU-only tools in tools/), an fp8 PLE table, and a set of GB10/vLLM
patches — roughly 1.8× faster decode than the NVFP4 recipe on the same box.
Upstream's NVFP4 recipe independently reproduced on a DGX Spark by @jschmied — see blazux#1 and their write-up, which also contributed the concurrency findings below.
| this fork (int4/int8/fp8) | |
|---|---|
| Prefill | ~2,100–2,200 tok/s |
| Decode, single stream | ~50–60 tok/s (MTP=3) |
| Prefix caching | on (+ never-evict pin) |
| Context | 262k native / 500k YaRN |
| Weights resident | ~68 GiB |
Measured on this stack with the default serve.sh config (DGX Spark, default GPU
clocks; int4 MTP draft experts, DRAFT_VOCAB=1, MTP=3, prefix caching on, SEQS=16,
8192-token prefill chunks). One caveat: on this box the PLE table is served from
another machine's RAM over RDMA (the magi branch), the fastest row source there is —
with the table on a regular local NVMe, as in the Quickstart, expect decode about
2–3% slower than the numbers below. Single-stream decode by workload, thinking enabled —
reproduce with
bench_qwen35.sh
(from albond's 122B recipe) pointed at your endpoint, on a freshly started container:
| Task | Prompt Tokens | Gen Tokens | Time (s) | Speed (tok/s) |
|---|---|---|---|---|
| [Q&A] | 65 | 57.5 ± 5.2 | 1.18 ± 0.17 | 49.1 ± 2.6 |
| [Code] | 72 | 275.8 ± 2.2 | 4.89 ± 0.24 | 56.5 ± 3.1 |
| [JSON] | 90 | 841.8 ± 20.8 | 13.54 ± 0.46 | 62.1 ± 0.7 |
| [Math] | 71 | 64.0 ± 0.0 | 1.15 ± 0.01 | 55.5 ± 0.2 |
| [LongCode] | 79 | 2048.0 ± 0.0 | 42.33 ± 2.53 | 48.5 ± 2.8 |
Note: arithmetic mean ± sample standard deviation over 4 runs (2 scripts × 2 runs, 2026-09-09). Draft acceptance with thinking on is ~70% (2.1 of 3); with enable_thinking: false it is ~87% and decode runs 5–10% faster than the table.
Under concurrency, TTFT grows with MTP's prefill cost (see the next
section), not with the paged table. On upstream's NVFP4 path
@jschmied measured aggregate throughput
scaling to ~267 tok/s at 48 streams
(load-and-waits.md);
two portable takeaways: per-token page-fault cost falls with concurrency
(batched tokens share n-gram rows), and a low --max-num-seqs silently
queues requests — check vllm:request_queue_time_seconds_sum before quoting
an aggregate number.
docker build also works on x86 Blackwell
(sm_120) for testing, though this is tuned for the Spark.git clone https://github.com/Saren-Arterius/qwen3.8-Flash-DGX-AutoRound.git
cd qwen3.8-Flash-DGX-AutoRound
docker build -t qwen38-flash-dgx . # official image + this fork's patches
# The prepared checkpoint + PLE table (one-time, ~116 GiB):
hf download Saren/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid-MTP_int4RTN --local-dir /models/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid-MTP_int4RTN
hf download Saren/Qwen3.8-Flash-Next-ple-table-fp8 --local-dir /models/ple-table-fp8
# option: the same checkpoint with the MTP draft experts left in bf16 (+3.5 GiB, ~3% slower decode):
# hf download Saren/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid --local-dir /models/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid
# (or build them yourself from Intel's release: ./prepare.sh — see below)
# Point serve.sh at your checkpoint + table dirs, then:
./serve.sh # boots on :8000 (~5 min with fastsafetensors)
docker logs -f qwen38-flash # wait for "Application startup complete"
Then hit the OpenAI-compatible API:
curl http://localhost:8000/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model": "qwen",
"messages": [{"role":"user","content":"Write a haiku about a desktop supercomputer."}],
"max_tokens": 512
}'
serve.sh is a thin example config over scripts/serve-intel-ar.sh — every
knob is an env var. The defaults below are serve.sh's (the recommended
entry point); where the bare scripts/serve-intel-ar.sh falls back to
something else, the note says so. Keep your machine's real settings as an
edited copy or a local-only commit on top.
The quickstart's two hf download repos are the finished artifacts — hashes
verified against the local originals. If you'd rather build (or audit) them
yourself from
Intel/Qwen3.8-Flash-Next-W4A16-AutoRound,
one script runs the whole pipeline (CPU-only — a NAS box is fine):
./prepare.sh /models/Qwen3.8-Flash-Next-W4A16-AutoRound /models/ple-table-fp8
Each step is explained in prepare.sh's header comments: int8 lm_head repack,
fp8 side-layer conversion, n-gram index strip, fp8 table fetch, the
quantization_config rewrite, and the int4 MTP-draft-experts variant. On that last one: this vLLM build has no
auto-round loader, but its GPTQ config (AutoGPTQConfig → Marlin kernels)
reads the same packed tensors — the GPTQModel-style dynamic rules exclude
the families that are not int4-packed and flip the head to 8-bit. The original
AutoRound config is kept as config.json.autoround.
docker build -t qwen38-flash-dgx .
MODEL_DIR=/models/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid-MTP_int4RTN \
TABLE_DIR=/models/ple-table-fp8 \
PREFIX_CACHE=1 PIN_PROMPT="You are HomeBot, the household assistant." \
scripts/serve-intel-ar.sh
or edit the paths in serve.sh (the example config used above) and run it.
| Var | serve.sh default | Notes |
|---|---|---|
MODEL_DIR / TABLE_DIR | /path/to/... — edit these | Prepared checkpoint / fp8 PLE table dirs |
PORT | 8000 | API port (bare script: 18300) |
CTX | 262144 | Max context |
YARN | 0 | 1 = Qwen's YaRN rope scaling (factor 4) past the native 262144 — set CTX too (500k was upstream's validated ceiling). Also forces the MTP draft's max_model_len, which --hf-overrides alone doesn't reach |
SEQS | 8 | Max concurrent sequences (don't benchmark with 1–2, see below) |
GPU_MEM | 0.01 | Near-zero pool fraction, paired with KV_BYTES: deterministic sizing, so the driver never oversubscribes the unified pool (NV_ERR_NO_MEMORY / Xid 31 freezes). Bare script: a 0.85 fraction — avoid on unified-memory boxes. |
KV_BYTES | 20g | Explicit KV pool size, passed as --kv-cache-memory-bytes (bare script: unset) |
MTP | 3 | Speculative tokens from the MTP head (0 = off; bare script: 2) |
PREFIX_CACHE | 1 | Prefix caching — fixed and recommended on this fork (bare script: 0) |
DET_TOPK | 1 | Deterministic QSA top-k kernel (patch 9; @jschmied, vllm#55122): identical output at T=0 at full prefill speed. 0 = stock kernel (non-deterministic, may drop attention candidates) |
EXACT_TOPK | 0 | 1 = exact torch.topk fallback (patch 9; deterministic, −20–40% on long prefill). Wins over DET_TOPK when set |
DRAFT_VOCAB | 1 | The MTP drafter scores only the 65,536 most frequent tokens (patch 10; from upstream blazux): +3–5% decode on English/code, draft acceptance unchanged there (thinking on or off). The shipped id set is English/code-weighted — CJK-heavy output loses acceptance and decode speed with it, so set 0 (full vocabulary) or build your own set with tools/build_draft_vocab.py over your traffic |
DRAFT_HEAD | int8 | int4 gives the drafter a private int4 g128 RTN GPTQ-Marlin copy of the full-vocabulary head (built at first use, ~313 MiB vs 616 MiB read per draft step). Measured a wash: tg unchanged within noise, draft acceptance 1–8 points lower (see the MTP options section), so the shared int8 head stays the default |
PIN_PROMPT / PIN_MAX_FRACTION | unset / 0.25 | Never-evict pin (patch 6); needs PREFIX_CACHE=1 |
FP8_HYBRID | 1 | int4+fp8 hybrid dispatch (patch 4) |
PLE_MADV_RANDOM | 1 | MADV_RANDOM on the table mmap (patch 1): no readahead around 160-byte row faults — upstream (blazux 0c6df7e) measured 4–8% faster cold prefill and a cleaner page cache, now the default |
PLE_PREFETCH | 0 | Batch-assembly prefetch — measured not worth enabling (see appendix) |
HIT_DEBUG | 0 | Prefix-cache tracing (patch 8) |
PREWARM | 1 | Stream the table once at boot to warm the page cache |
WORKERS | 32 | Threads for the mmap gather |
LOAD_FORMAT | fastsafetensors | Noticeably faster cold boots |
TOOL_PARSER | qwen3_xml | Tool-call parser (bare script: qwen3_coder) |
SERVED_NAME | qwen | Model id on the API (bare script: qwen3.8-flash-next) |
ITER_DETAILS | 0 | 1 = per-step prefill metrics: vllm:scheduled_ctx_tokens_total updates every engine step (stock prompt_tokens_total only moves when a prefill finishes). Live view: bench/ppwatch.sh |
EXTRA | Extra vLLM flags, passed verbatim |
GPU_MEM=0.01 + KV_BYTES sizing over a large
fraction (an OOM inside the unified pool can freeze the box).| Component | Precision | How |
|---|---|---|
| 512-expert MoE, 48 main layers | int4 GPTQ-Marlin g128 | Intel checkpoint as-is |
| MTP draft layer's own 512 experts | int4 GPTQ-Marlin g128 RTN (the default -MTP_int4RTN checkpoint) | Intel leaves layer 48 in bf16 (~4.7 GiB, -:.*layers\.48\..*); tools/quantize_mtp_experts_int4.py (patch 10) makes it int4 on the Marlin path: −3.5 GiB, +2–4% decode, acceptance unchanged. The plain -hybrid repo keeps them bf16 |
| lm_head (shared with MTP draft head) | int8 GPTQ-Marlin (uint8b128) | tools/quantize_lm_head_int8.py + "lm_head": true |
| GDN in/out projections, QSA q/k/v/o, shared expert | fp8 blockwise e4m3 (128×128) | tools/fp8_convert.py + src/vllm_fp8_hybrid.py |
| Embeddings, hyper-connections, norms, MoE gates, fc_hidden | bf16 | excluded via dynamic rules |
| PLE n-gram table (51B params, layer 1) | fp8 rows, mmapped from disk | tools/fetch-ple-table-fp8.sh + the mmap patch |
| KV cache | bf16 | QSA refuses fp8 KV |
Everything is applied at image build time (see the Dockerfile); each patch is
independent and gated by an env var where it changes behavior.
src/vllm_ple_mmap.py, extends upstream's patch)weight_scale) are all
accepted; row size is derived from the safetensors headers. The fp8 table
halves the bytes read per token vs bf16.VLLM_PLE_MMAP_DIR: the table no longer has to live inside the
checkpoint dir — point it at any directory of safetensors shards (NFS, local
NVMe, a RAM-backed device...). The backend matters: the ~49 GiB table
outgrows what the page cache can keep warm next to the model, so gathers
cost ~1.3 ms/op from a RAM-backed source vs 5–9 ms from local NVMe vs
30–50 ms over NFS — decode impact in the appendix below.np.unique), gather of
unique rows only, staging through a persistent pinned buffer with an async
H2D copy, and GPU-side expansion via the inverse index. A decode fast path
(VLLM_PLE_MMAP_FAST_ROWS, default 512) skips the thread pool entirely for
small gathers. Net effect: ~7.2 → ~2.5–3.8 ms per lookup op on a RAM-backed table.VLLM_PLE_MMAP_MADV_RANDOM=1: madvise(MADV_RANDOM) the mmap so faults
stay single-page — for tables on remote RAM or boxes with no page-cache
headroom.VLLM_PLE_MMAP_STATS_SEC (default 30) logs
PLE mmap stats (last Ns): calls, op ms, gather ms, rows, MB and resets the
counters each period.Dockerfile sed)sm_121 reports 99 KiB of shared memory per block — the same as ADA, where the
flash-linear-attention Triton kernels use their big GDN tiles — but the gate in
vllm/third_party/flash_linear_attention/ops/utils.py demands 100 KiB, so all
36 GDN layers silently fell back to small tiles. Lowering the gate to 99 KiB
(101376) lets GB10 take the big-tile path. (Found the hard way in the
Qwen3.5-122B Spark recipe — ported from
Entrpi/qwen3.5-122B-A10B-on-spark's
patch_fla_shmem.py.)
Dockerfile sed on model.py / mtp.py)Upstream constructs ParallelLMHead without quant_config, forcing a bf16
head (1.27 GiB, and a bf16 GEMV per token over a 248320 vocab). One added
kwarg in both the main model and the MTP draft lets the head pick up the
checkpoint's int8 GPTQ packing. Without the mtp.py half, MTP ≥ 3 crashes at
load ("no module or parameter named 'lm_head.qweight'").
src/vllm_fp8_hybrid.py, VLLM_FP8_HYBRID=1)vLLM's GPTQ config quantizes listed layers and leaves the rest to
UnquantizedLinearMethod — it has no notion of "this excluded layer is
actually fp8 in the checkpoint". This shim wraps AutoGPTQConfig: it scans the
checkpoint metadata for F8_E4M3 weights with a weight_scale_inv sibling and
routes exactly those layers to vLLM's blockwise-Fp8Config
(weight_block_size=[128,128], dynamic activation scheme) while everything
else keeps the GPTQ path. VLLM_USE_DEEP_GEMM=0 is required on sm_121
(DeepGEMM hits CUDA_ERROR_LAUNCH_FAILED); the triton fallback is fine.
src/patch_mamba_align_split.py)Upstream runs --no-enable-prefix-caching because of a CUBLAS error in the
GDN in_proj GEMM on the cached-block path. With the fp8 side layers that
GEMM runs a different kernel, and prefix caching is stable in our serving
(PREFIX_CACHE=1).
It also works properly now. On this hybrid model the reconciled cache hit is
the minimum across all KV cache groups (full attention + four mamba/GDN
state groups), and mamba "align" mode can only cache a state at a prefill
chunk end on a 1600-token boundary. The image's scheduler aligned those chunk
ends to cache_config.block_size — which the engine rewrites to the minimum
group block size (8, the MTP draft granularity) — so chunks ended where no
mamba state was cacheable, a cold request published zero reusable mamba
states, and a repeated prompt only got fast on the 3rd try (the miss
triggers junction machinery that rebuilds the boundary one request late).
Long prompts effectively never hit. The patch makes the split use
cache_config.mamba_block_size (1600). Verified: an 8k-token repeat goes
10.1 s → 0.90 s on the 2nd request.
The same rewritten block_size also poisoned the worker side: the
align-mode state-slot seed (mamba_hybrid.py) divided by it too, so a prefix
hit at 6400 tokens seeded state column 799 instead of 3, read past the
block-table row and restored a wrong (often all-zero / stale) mamba state —
greedy outputs visibly changed on cache hits. Root-caused upstream by
blazux
(his fix: 8347e7c); the same one-line seed fix is folded into this patch.
Verified: cold-vs-hit first-token logprobs now agree within the stack's
normal run-to-run jitter (Marlin atomic-add nondeterminism), where before the
fix greedy outputs diverged within the first few tokens.
Notes: the prefix-cache granularity is large (1600 tokens; shorter prefixes
get no reuse), and a repeat hit tops out at round_down(P,1600) − 1600 — MTP
(eagle-style) always recomputes the last matched block.
src/patch_never_evict.py)--never-evict-kv-cache-prompt-includes "<substring of your system prompt>"
pins the KV blocks of any prompt containing that marker: they are held in a
side queue on BlockPool, excluded from the free count, and thus never handed
out for eviction — your assistant's system prompt stays cached no matter what
other traffic does. --never-evict-kv-cache-max-fraction (default 0.25) caps
the pin. The pin set is replaced on each matching request, so an updated
system prompt releases the old blocks automatically.
Verified end-to-end: a pinned 8k prompt still answers in 0.94 s after 2M tokens of unique traffic (3.1× full KV-pool turnover).
Implementation notes: the marker is tokenized once and matched as a token-id
subsequence (first/last token dropped — BPE merges at the boundaries); the pin
is keyed on block hashes, not block ids, because this hybrid model frees
mamba/GDN state blocks mid-request — each freed block is re-claimed by the pin
the moment free_blocks() sees it. Only prefix-cacheable KV groups
participate (the MTP draft layer's group is not, and must be skipped). This is
a pin-only port of our arc_pin2 patch from the Qwen3.5-122B Spark stack,
which built the pin on top of the ARC GPU-eviction work in
vllm#40270; the ARC/2Q
policies themselves were deliberately dropped — the stock free queue is
C-speed on a path this model hits every step.
Self-check (no GPU): docker run --rm -v "$PWD/src:/t" --entrypoint python3 qwen38-flash-dgx /t/test_never_evict_pin.py.
src/mamba_utils_guarded.py)Hardens the align-mode state-copy kernels against the "CUDA illegal memory
access / Xid 31 under load" crash class (also
blazux#2): backports
vllm#50729 (overlapping
state-copy race) and bounds-checks every block id against its state pool
before dereferencing — an out-of-range id skips the copy and bumps a counter
(logged as mamba state-copy guard) instead of taking down the CUDA context.
With the block-size seed fix (patch 5) the out-of-range ids the guard was absorbing are gone at the root: the counter is expected to stay at 0, and a nonzero count is logged as an error — it now indicates a new bug worth reporting, not a known quirk.
src/patch_hit_debug.py, HIT_DEBUG=1)Set HIT_DEBUG=1 (→ VLLM_HIT_DEBUG=1 in the container) to log, per request:
the per-KV-group hit reconciliation (which group truncated the hit), mamba
boundary-state publication (which slots were real/null/hashed), cached-block
evictions, and prefill chunk-stop decisions. This is what found the bug in
patch 5; costs nothing when off.
src/patch_qsa_exact_topk.py + Dockerfile patch 10)Taken from upstream blazux/qwen3.8-Flash-DGX
(8347e7c, 4b723de, 0022e36). The sparse attention (QSA) picks its top-k key
blocks with vLLM's persistent_topk kernel, which is non-deterministic on GB10 and
can drop real candidates (vllm#51782,
diagnosed by @k3dani in
blazux#3). Two fixes, both opt-in
env vars, DET_TOPK=1 the default:
DET_TOPK=1 — deterministic kernel by @jschmied
(vllm#55122): index-ordered output
slots and bit-exact tie resolution (signed zero canonicalised), plus a deterministic
low-shared-memory fallback. The sources are fetched at a pinned commit (sha256-checked)
and compiled with the image's nvcc at build time as a standalone _C_det.so — no vLLM
rebuild; qsadet_patch.py (also @jschmied's) wires the QSA block selection to it.
Upstream measured the whole prefill penalty of the exact path gone (GX10, 32k:
1,794 → 2,996 tok/s) with decode unchanged.EXACT_TOPK=1 — exact torch.topk over the visible columns (blazux's first fix):
also deterministic, −20–40% on long prefill; kept as the fallback and wins over
DET_TOPK when set. Self-check (no GPU): docker run --rm -v "$PWD/src:/t" -w /t --entrypoint python3 qwen38-flash-dgx test_qsa_exact_topk_cpu.py.Both on by default (the Quickstart checkpoint + DRAFT_VOCAB=1); the plain
-hybrid checkpoint and DRAFT_VOCAB=0 remain options. Both leave outputs
unchanged — the target verifies every drafted token, only the draft's cost and
acceptance move. Measured on a DGX Spark (bench_qwen35.sh with
enable_thinking: false, T=0, second run):
bf16 draft (-hybrid repo) | int4 draft experts | int4 + DRAFT_VOCAB=1 (default) | |
|---|---|---|---|
| Code / JSON / LongCode tok/s | 57.3 / 61.3 / 57.9 | 59.1 / 62.6 / 60.3 | 62.0 / 66.0 / 62.4 |
| draft acceptance (of 3) | 88.0% (2.64) | 88.5% (2.65) | 87.3% (2.62) |
| weights resident | ~71.4 GiB | 67.9 GiB | 67.9 GiB |
With thinking on (albond's original script) draft acceptance is ~70% in all three configs — only 1.0% of reasoning tokens fall outside the 65k set — and the default config is still the fastest (Code 56.5, JSON 62.1, LongCode 48.5 tok/s, see the Throughput table).
int4 draft experts — Intel's checkpoint leaves the MTP layer's 512 routed
experts in bf16 (~4.7 GiB on the unquantized MoE path). tools/quantize_mtp_experts_int4.py
writes a variant checkpoint (hardlinks for the untouched shards) with those
experts as int4 g128 RTN in Intel's exact GPTQ layout and drops the layers.48
exclusion, so the draft runs on GPTQ-Marlin like the main layers. Prebuilt:
Saren/Qwen3.8-Flash-Next-W4A16-AutoRound-hybrid-MTP_int4RTN
— point MODEL_DIR at it. Idea from upstream's hybrid-mtp mode
(blazux#11 by @pfy, after
thavoc's graft write-up), done here as RTN int4 instead of an NVFP4 graft so no
new kernel path is needed.
DRAFT_VOCAB=1 (src/patch_mtp_draft_vocab.py, from upstream blazux 0c6df7e,
idea from MiaAI-Lab's recipe reimplemented there) — vLLM shares the target's
lm_head with the drafter, so every draft pass scores all 248,320 rows. With the
flag the draft scores a private 65,536-row slice (src/draft_vocab_65536.npy:
corpus frequency + BPE order + all special tokens; tools/build_draft_vocab.py
rebuilds it) and every other id is −inf. This fork's head is int8 GPTQ-Marlin,
so the slice is dequantized from the checkpoint's GPTQ tensors at first use
(616 → 320 MiB read per draft step).
DRAFT_HEAD=int4 (same patch) — the other way to halve the head read
without touching the vocabulary: a private int4 g128 RTN copy of the full head,
quantized with vLLM's own marlin_quantize at first use (~23 s) from the
checkpoint's int8 GPTQ tensors, run through the same GPTQ-Marlin kernel
(616 → 313 MiB per draft step; +313 MB resident). Outputs cannot change (the
target verifies every token), only acceptance can, and it does: int4 RTN puts
~11% relative error on the head (int8: ~0.5%). Measured 2026-09-10, thinking
off, 600-token answers, second run of each prompt:
| int8 shared head (default) | DRAFT_HEAD=int4 | |
|---|---|---|
| Traditional Chinese prose | 34.6 tok/s, 31% accept | 35.5 tok/s, 30% |
| Cantonese prose | 32.8 tok/s, 28% | 32.3 tok/s, 25% |
| English prose | 40.4 tok/s, 44% | 39.6 tok/s, 39% |
| Python + tests | 62.5 tok/s, 86% | 60.7 tok/s, 78% |
The bandwidth saved is handed back in rejected drafts, so it stays off. A calibrated int4 (AutoRound/GPTQ on real MTP hidden states) might keep more acceptance; not tried. Context: krisbailey.com's "shortlist MTP" write-up found the same ~4% ceiling for head-shrinking tricks once the head kernel is bandwidth-bound, which Marlin already is here.
MTP raises decode substantially but puts a floor (~0.8 s) under
time-to-first-token: vLLM's v1 engine only emits the first token after the
drafter has run, and MTP's draft layer is a stateful autoregressive
transformer — on every prefill chunk it must run a full-chunk-width forward
(always eager: above the cudagraph capture sizes) to sync its own KV/GDN
state, plus k−1 sequential single-token passes. Cross-attention drafters like
DFlash don't pay this, but Flash-Next has no such drafter — MTP is what ships
in the checkpoint. If your workload is TTFT-sensitive, weigh MTP depth
against MTP=0; only 0 removes the floor.
Dockerfile official vLLM Flash-Next image + the patches above
serve.sh example launcher config (edit paths, run)
prepare.sh build the checkpoint + table from Intel's release
src/vllm_ple_mmap.py mmap PLE table (any dtype, relocatable dir, fast gather)
src/vllm_fp8_hybrid.py int4+fp8 hybrid dispatch on the GPTQ config
src/patch_never_evict.py never-evict system-prompt KV pinning
src/patch_mamba_align_split.py prefix-cache chunk-alignment fix
src/patch_hit_debug.py prefix-cache tracing (VLLM_HIT_DEBUG)
src/patch_qsa_exact_topk.py exact, deterministic QSA top-k (VLLM_QSA_EXACT_TOPK=1; from blazux)
(Dockerfile patch 10) @jschmied's deterministic persistent_topk kernel, built at docker build
src/test_qsa_exact_topk_cpu.py CPU unit test for the exact top-k (no GPU needed)
src/patch_mtp_draft_vocab.py private MTP draft head: reduced vocabulary (from blazux) / int4 (VLLM_MTP_DRAFT_VOCAB, VLLM_MTP_DRAFT_HEAD)
src/draft_vocab_65536.npy the default 65,536-token draft id set (from blazux)
tools/build_draft_vocab.py rebuild the draft id set (from blazux)
tools/quantize_mtp_experts_int4.py int4 RTN the MTP draft experts -> -MTP_int4RTN checkpoint
src/mamba_utils_guarded.py hardened align-mode state copy (vllm#50729 + guard)
src/test_ple_mmap_cpu.py CPU unit test for the gather (no GPU needed)
src/test_never_evict_pin.py CPU unit test for the pin (no GPU needed)
scripts/serve-intel-ar.sh the docker run behind serve.sh
scripts/smoke-test.sh health + coherence + prefill/decode numbers
src/patch_prefill_metrics.py per-step prefill counters for Prometheus (ITER_DETAILS=1)
bench/ppwatch.sh live prefill tok/s from those counters
bench/decode_bench.py batch-1 decode / TTFT / spec-acceptance bench
tools/ CPU-only checkpoint preparation
docs/HOW-IT-WORKS.md upstream's mmap-PLE story
docs/OPTIMIZATIONS.md this fork's patches in depth
vllm/vllm-openai:qwen38-flash-next,
the release/qwen38next recipe / PR #53896).0c6df7e; idea from MiaAI-Lab's recipe, reimplemented there); the quantized draft
experts follow upstream's hybrid-mtp mode
(blazux#11 by @pfy).torch.topk fix and the kernel wiring/pins from
upstream blazux/qwen3.8-Flash-DGX;
the deterministic persistent_topk kernel itself by @jschmied
(vllm#55122); the bug diagnosed by
@k3dani (blazux#3).PLE_PREFETCH)Batch-1 decode bench (bench/decode_bench.py, medians of 3; W1 = fresh-prompt
1000-token decode, W2 = pinned ~8k prefix hit + 256 tokens; DGX Spark GB10,
MTP=3). Only the table location and the prefetch flag change between rows (all local NVMe is Gen 4):
| table source | prefetch | W1 tok/s | W2 tok/s | gather |
|---|---|---|---|---|
| RDMA row daemon | on | 42.4 | 47.8 | ~1.3 ms/op |
| RDMA row daemon | off | 41.6 | 46.7 | ~1.3 ms/op |
| local NVMe | off | 36.5 | 45.7 | 5–9 ms/op |
| local NVMe | on | 34.5 | 42.6 | 9–17 ms/op |
| local NVMe, warm cache | on | 33.4 | 41.5 | 9–16 ms/op |
| NFS (RDMA mount, btrfs) | on | 26.2 | 36.5 | 26–56 ms/op |
| NFS (RDMA mount, btrfs) | off | 24.2 | 32.9 | 30–42 ms/op |
Takeaways:
PLE_PREFETCH=1 (VLLM_PLE_MMAP_PREFETCH). The
batch-assembly prefetch only wins where the row source is very slow (NFS,
+2–3 tok/s, within run-to-run variance), is a wash on fast sources, and is
net-negative on local NVMe: the handoff/wait in consume() costs more
than the inline gather it replaces (confirmed on a warm page cache). It
stays experimental and default-off.About those "RDMA row daemon" rows: that is a custom one-sided RDMA READ
server that pins the whole table in another box's RAM — the fastest row
source measured, at the price of a second machine and an ibverbs science
project. If you have a NAS with ≥64 GB of RAM and a ≥100 Gbit RDMA
link to your Spark — and no second DGX Spark to put to better use — the
magi branch ships the tool (src/ple_rdma/) and setup
notes ("PLE table over RDMA"). Everyone else: local NVMe is the recipe.
184 followers · starred Aug 2026