The project's README from github.com/MiaAI-Lab/Qwen3.8-Flash-Next-Single-DGX-Spark. As one markdown file: README.md.
by Mia'a AI Lab
Self-contained recipe for serving the Mia-AiLab/Qwen3.8-Flash-Next-NVFP4
checkpoint (99 GiB) from a single DGX Spark's 121 GiB unified memory, via vLLM
with the PLE table offloaded and memory-mapped. This is a vision-language
model: text, images and video all work out of the box (see below). Nothing here depends on the
2-node files it was derived from.
cp .env.sample .env # edit IMAGE / HF_TOKEN if needed
./download.sh # fetch the ~99 GiB checkpoint (resumable)
./start.sh # ~10-12 min to /health; serves on :8888
./stop.sh # container + watchdog, graceful
start.sh never downloads anything — it resolves the checkpoint from the local
Hugging Face cache and fails fast if it is absent. Budget ~130 GiB of free disk:
99 GiB for the checkpoint plus the ~27 GiB packed PLE table built on first launch.
./start.sh --no-launch prints the derived memory budget and the docker
command without running anything. ./stop.sh sends SIGTERM and waits up to
STOP_TIMEOUT (default 30 s) so vLLM can unlink its POSIX shared memory —
the container runs with --ipc host, so segments it leaves behind leak onto
the host's /dev/shm until reboot. ./stop.sh --force skips the wait.
.env.sample ships 262,144 context (YaRN off), MTP 3, HOST_RESERVE_GIB=26,
KV_TARGET_GIB=20, KV_CACHE_DTYPE=fp8, MAMBA_SSM_CACHE_DTYPE=bfloat16,
MAX_NUM_SEQS=4, MAX_NUM_BATCHED_TOKENS=2048, with the V2 model runner
pinned through EXTRA_DOCKER_ARGS.
Everything below was measured on this host on 2026-09-04; each row names the
configuration it came from, because the numbers move a lot between them.
Decode numbers are not in this table: they predate the 2026-09-05 optimisation
pass and are superseded by the sparkDash sweep
below (48.7 tok/s single-stream prose, 162.9 aggregate at 8 streams).
| Configuration | KV pool | Prefill @400k | Needles 5/50/95% |
|---|---|---|---|
262k, KV_TARGET_GIB=20, BF16 |
21.28 GiB = 736,837 tok (2.81x a 262k req) | — | — |
512k YaRN, KV_TARGET_GIB=20, BF16 |
19.2 GiB = 704,558 tok (1.34x a 512k req) | 1,537 tok/s (TTFT 260.3 s) | 3/3 PASS |
512k YaRN, KV_TARGET_GIB=22, BF16 |
796,196 tok (1.52x a 512k req) | 1,883 tok/s @32k | 12/14 (see FP8 section) |
512k YaRN, KV_TARGET_GIB=22, FP8 |
22.2 GiB = 1,431,164 tok (2.73x a 512k req) | 1,495 tok/s (TTFT 267.7 s); 1,769 tok/s @32k | 15/20 (see FP8 section) |
The same profile at KV_TARGET_GIB=16 and MAX_NUM_SEQS=5 (262k,
HOST_RESERVE_GIB=26, FP8) was measured on 2026-09-05. The shipped wish is now
20, which this host clips to 16.67 GiB — see Configuration:
| GPU budget | GMU 0.780 = 94.87 GiB |
| Available KV cache | 16.46 GiB = 992,584 tokens (3.79x a 262k request) |
Time to /health |
10 min 51 s (checkpoint read from NVMe) |
Host MemAvailable, 2 min after /health |
15.7 GiB (MemFree 5.1 GiB) |
| Host MemAvailable, 40 idle minutes | 15.5–16.4 GiB (MemFree ≥ 4.4 GiB) |
| Host MemAvailable after two ~90k prompts | 16.2 → 15.05 GiB after the first, 15.2 GiB 60 s after the second (min 14.9 during prefill; MemFree ≥ 3.5 GiB) |
| Host MemAvailable, five concurrent ~60k prompts | 14.9 → 14.57 GiB at +60 s (min 14.26 during; MemFree ≥ 3.24 GiB); 5/5 completed, no watchdog event |
| 2.5 h under the qwen-code harness (~38 requests, 19 of them 50–100k tokens, up to 3 concurrent) | 14.2–14.9 GiB between turns, min 12.8 GiB at 3 concurrent; driver 96.6 → 97.5 GiB in one step |
NV_ERR_NO_MEMORY in journalctl -k |
0 across launch and all of the above |
The idle figure used to be quoted here as ~12.9 GiB; that came from a
2026-09-04 run — before the host-side cap existed, so KV_TARGET_GIB=20 then
meant 20 GiB of KV rather than today's clipped 16.67 — and before the day's
co-tenants were on the box. See the safety
rules for why the number matters.
One honest gap: KV_TARGET_GIB=20 is now measured end to end — ten launches,
the 1/2/4/8-stream decode sweep, needles 15/15 at 32k and a 45-minute soak on
2026-09-06 — but every one of those ran at 512k YaRN, not at the shipped
262k native rope. The 262k row above is at BF16 and predates the MADV_RANDOM
mmap change. Short-context (32k) prefill is measured for both dtypes; see the
FP8 section.
KV_TARGET_GIB shipped as 22, then 20, until 2026-09-05. Both lost servers:
three on 2026-09-04. The rows above at 22 are real measurements, but the host
they were taken on had 6.9–8.8 GiB of MemAvailable left, against a 6 GiB
watchdog floor and a GPU driver that refuses allocations before that. The
budget is now capped from the host side (HOST_RESERVE_GIB, see
Safety rules); the KV pool is whatever the cap leaves. The
shipped wish is 20 and this host clips it to 16.67 GiB, so 20 and 22 of actual
KV are no longer reachable through KV_TARGET_GIB alone; a pinned
GPU_MEMORY_UTILIZATION still gets you there, with a warning.
Both sweeps below were measured with
sparkDash against this server.
bench/sweep.py drives it one concurrency level at a time and reads vLLM's
own counters around each level, which is what makes the ms-per-step and
tokens-per-step columns below comparable across launches; bench/mixed.py
covers the decode-under-prefill case sparkDash has no mode for. The two
prefill columns are not a clean A/B — they differ in rope config and KV
target as well as chunk width — so each is labelled with what it was measured
at.
Measured on 512k YaRN, MTP 3, FP8 KV, 2,048 chunks, CUDAGRAPH_CAPTURE_SIZES=auto,
MAMBA_SSM_CACHE_DTYPE=bfloat16, the V2 model runner pinned, and
MAX_NUM_SEQS=8 — which is the one part of this row that .env.sample
does not ship, because every sweep here was short-context. Prose, 600 tokens,
three repeats per level, bench/sweep.py:
| streams | ms/engine step | tokens/step | aggregate | per stream |
|---|---|---|---|---|
| 1 | 61.5 | 3.00 | 48.7 tok/s | 48.7 tok/s |
| 2 | 74.3 | 2.83 | 74.6 tok/s | 37.3 tok/s |
| 4 | 96.2 | 2.84 | 113.7 tok/s | 28.4 tok/s |
| 8 | 131.0 | 2.81 | 162.9 tok/s | 20.4 tok/s |
Two changes separate this from the 2026-09-05 row. MAX_NUM_SEQS=8 with a FULL
decode graph at all eight verify widths (4 through 32) is what makes the
8-stream column reachable at all. BF16 recurrent state is worth +8.5% at 8
streams on its own, in a matched pair on the same launch config: 151.6 →
164.5 tok/s, step 141.4 → 130.4 ms, with per-position draft acceptance
unchanged within rounding (0.80/0.59/0.41 against 0.79/0.58/0.42) and needle
retrieval unchanged at 15/15.
Speculative depth was swept properly here for the first time (K=0/1/2/3 at
every stream count, FULL graphs throughout). K=3 wins at every concurrency,
K=2 ties it, K=1 loses 8–14% and K=0 loses 32–46%. There is no crossover, so
MTP_K_SCHEDULE has nothing to schedule. The full table is in the CHANGELOG.
Decode under a concurrent prefill is the one place the shipped chunk width
hurts. With two streams decoding and one 64k prompt arriving, the gap between
their streamed chunks for the 34.5 s of that prefill is p50 1,057 ms /
p95 1,111 ms / p99 1,400 ms, against 78 ms on the quiet server. Those are per
engine step, not per token: vLLM emits one streamed chunk per step carrying
that step's accepted tokens (2.7 here, measured), so per-token latency is
roughly a third of the figures above. The two streams together delivered 74
steps' worth of output inside the window — ~5.8 tok/s if acceptance holds at
its quiet-server value. That is one engine step per 2,048-token chunk, by
construction. Halving the chunk to 1,024 takes p95 to 666 ms and p99 to 674 ms
and raises in-window delivery to 139 steps (9.3 tok/s), for 5.5% of prefill
throughput at 64k. It is
not the shipped default (see the CHANGELOG for why the bar was not met), but if
your traffic is long prompts arriving against live streams, measure it.
Prefill on this configuration, three sparkDash ladders on 2026-09-06 (one at 256k), server warm:
| context | TTFT | prefill | 2026-09-05 | change |
|---|---|---|---|---|
| 8k | 3.74 s | 2,200 tok/s (2,155–2,238) | 1,764 | +24.7% |
| 16k | 7.13 s | 2,304 tok/s (2,293–2,311) | 2,265 | +1.7% |
| 32k | 14.18 s | 2,314 tok/s (2,305–2,324) | 2,265 | +2.2% |
| 64k | 29.05 s | 2,257 tok/s (2,257–2,258) | 2,222 | +1.6% |
| 128k | 61.09 s | 2,146 tok/s (2,144–2,148) | 2,110 | +1.7% |
| 256k | 134.89 s | 1,944 tok/s | 1,913 | +1.6% |
Prefill did not change; only the 8k row looks like it did. Fit
TTFT = tokens / rate + overhead across 16k–128k and the per-token rate is
2,125 tok/s here against 2,089 on 2026-09-05 — +1.7%, with the same
−0.60 s intercept in both. Every row above 8k agrees with that. The 8k point
sits above the fit in both ladders, by 1.34 s in the 2026-09-05 run and
0.47 s here, because 8k runs first and pays the PLE page-cache warm-up; a
server that has been up for hours has mostly already paid it. Read the 8k row
as a statement about cache state, not about kernels.
BF16 recurrent state is the only serving change since 2026-09-05, and prefill is within ~2% either way, so its effect here is not separable from run-to-run variation — unlike decode, where it is worth +8.5% at 8 streams. Note the 64k and 128k rows reproduce to ±0.05% across the three ladders, so that ±2% is a between-launch figure, not measurement noise.
Measured on the shipped profile (512k YaRN, 2,048 chunks, MAX_NUM_SEQS=4,
MTP 3, FP8 KV, KV_TARGET_GIB=20 → 16.18 GiB = 974,768 tokens) with
CUDAGRAPH_CAPTURE_SIZES=auto, the PLE gather prefetch, and reduced-vocabulary
drafting (MTP_DRAFT_VOCAB, 65,536 tokens) all active. The "before" columns are
the same rope config and chunk width, so decode is a matched pair; prefill
differs only in KV_TARGET_GIB (22 → 20), which does not affect prefill rate.
Decode on prose, by concurrent stream count:
| streams | TTFT | aggregate | per stream | before | change |
|---|---|---|---|---|---|
| 1 | 270 ms | 46.3 tok/s | 46.3 tok/s | 36.9 | +25.5% |
| 2 | 466 ms | 73.0 tok/s | 36.5 tok/s | 57.4 | +27.2% |
| 3 | 355 ms | 91.9 tok/s | 31.2 tok/s | — | — |
| 4 | 346 ms | 108.1 tok/s | 27.7 tok/s | 85.9 | +25.8% |
Almost all of that is the reduced draft vocabulary. The MTP drafter reads its
own 1.18 GiB BF16 lm_head once per draft step, three of the four lm_head
reads in an MTP-3 engine step; slicing it to 65,536 rows saves 2.61 GiB per
step, and decode here is close enough to the memory-bandwidth wall that bytes
removed convert almost one-for-one into time. Accuracy is unchanged — the
target model verifies every drafted token — measured at 250 MGSM problems per
language, English 94.8% vs 93.6% and Chinese 86.4% vs 86.4%. See the CHANGELOG
entry for the full method.
That win now ships as the default: .env.sample sets MTP_DRAFT_VOCAB to the
checked-in files/draft_vocab_en_code_47k.txt (47,149 ids, code-tuned, 99.58%
held-out coverage on host code+docs), and start.sh resolves relative paths
against the repo and warns when MTP runs with the full head. Empty the knob to
restore full-vocabulary drafting. One honest caveat: the shipped file was built
from host code and docs, not from the model's own output, so non-code traffic
(especially Chinese, where the measured 65k vocab covered only 50.6%) may draft
worse than the numbers above; correctness is unaffected, only speed.
Live A/B of the shipped file against the full head (sparkDash prose+code
sweep, S=1/2/4/8, three repeats) measured +13.1% mean across eight cells with
acceptance unchanged — full table in the CHANGELOG 2026-09-09 entry.
Prefill, same chunk width as the shipped column below:
| context | TTFT | prefill | before | change |
|---|---|---|---|---|
| 8k | 4.67 s | 1,764 tok/s | 1,646 | +7.2% |
| 16k | 7.25 s | 2,265 tok/s | 2,052 | +10.4% |
| 32k | 14.49 s | 2,265 tok/s | 2,073 | +9.3% |
| 64k | 29.52 s | 2,222 tok/s | 2,037 | +9.1% |
| 128k | 62.15 s | 2,110 tok/s | 1,945 | +8.5% |
| 256k | 137.03 s | 1,913 tok/s | 1,791 | +6.8% |
The prefill gain is most likely the PLE page-fault prefetch rather than the
draft vocabulary, which does not touch prefill: the PLE row gather runs for
every prefilled token, so a 2,048-token chunk gathers 16 rows per token —
~32,768 of them — against the ~256 a 4-stream MTP-3 decode step gathers. That
gather is single-threaded and was taking each
missing 4 KiB page fault on its own; batching the reads with
posix_fadvise(WILLNEED) measured 13x on a cold 280-row gather in isolation and
only ~3% on decode, where there are too few faults per step for it to matter.
This was not isolated with an A/B, so read the attribution as inference from
the mechanism, not as a measurement.
These numbers came from one run each. The decode figures are content-dependent
for the reason given at the end of this section, and the x3 TTFT below x2
is run-to-run noise.
Prefill. At the shipped 2,048-token chunk width throughput peaks around
32-64k and falls away with context. Raising MAX_NUM_BATCHED_TOKENS to 8,192
flattens it from 16k out to 128k, because per-chunk overhead is amortised over
4x fewer chunks:
| context | shipped: 2,048 chunks (512k YaRN, KV_TARGET_GIB=22) |
opt-in: 8,192 chunks (262k native, KV_TARGET_GIB=20) |
|---|---|---|
| 8k | 5.00 s · 1,646 tok/s | 3.69 s · 2,228 tok/s |
| 16k | 8.00 s · 2,052 tok/s | 7.16 s · 2,293 tok/s |
| 32k | 15.83 s · 2,073 tok/s | 13.87 s · 2,366 tok/s |
| 64k | 32.20 s · 2,037 tok/s | 28.31 s · 2,316 tok/s |
| 128k | 67.41 s · 1,945 tok/s | 58.88 s · 2,227 tok/s |
| 256k | 146.40 s · 1,791 tok/s | not re-measured |
The one matched pair — same server, same kernel, only the chunk width changed — is 32k: 2,133 → 2,366 tok/s (+10.9%), TTFT 15.38 → 13.87 s (−9.8%). The rest of the right-hand column is one run each and should be read as indicative.
The 8,192 column is not the default. If you want the faster prefill and TTFT above, set it yourself:
MAX_NUM_BATCHED_TOKENS=8192 ./start.sh # one launch
or edit the line in .env to make it stick.
It is paid for out of the KV pool, not the GPU budget. 8,192 chunks raise peak
activation to 1.27 GiB, and vLLM profiles that before it sizes the KV cache,
so the pool absorbs it: 1,145,289 tokens measured at KV_TARGET_GIB=20,
still 4.37x a full 262k request. The best matched evidence for the size of that
trade is PR #2's own pair at KV_TARGET_GIB=22, 1,282,724 → 1,249,637 tokens —
about −2.6% of pool for +11% prefill.
Host MemAvailable sat at 8.1-8.3 GiB idle and low-watered at 7.4 GiB across
the 8,192 sweep. It is offered as a knob rather than a default because the
supporting observation is minutes, not hours: if you serve long sessions near
the memory floor, measure it on your own workload before committing to it.
Decode on prose (2,048 chunks, 512k YaRN), by concurrent stream count. These are the pre-2026-09-05 figures, kept because the tables above are stated as deltas against them:
| streams | TTFT | aggregate | per stream |
|---|---|---|---|
| 1 | 418 ms | 36.9 tok/s | 36.9 tok/s |
| 2 | 445 ms | 57.4 tok/s | 29.7 tok/s |
| 4 | 550 ms | 85.9 tok/s | 23.4 tok/s |
Decode speed on this model is strongly content-dependent, because MTP speculative decoding accepts more drafts on predictable text. Measured 2026-09-06 on sparkDash prose: 2.80 tokens of a possible 4 per engine step, per-position acceptance 0.80 / 0.59 / 0.41 — 60% of drafted tokens accepted. Quoting text back out of the context goes higher still; dense technical prose sits lower. Treat single-stream decode as a range rather than one number.
The figures quoted here until 2026-09-06 — acceptance length 2.1 of 4, per position 0.65 / 0.33 / 0.14, and "~41 tok/s" as the best case — were taken under PIECEWISE CUDA graphs with the full 248,320-token draft vocabulary. They are superseded in both directions: acceptance is much higher, and ordinary prose now measures 48.7 tok/s single-stream.
The checkpoint is multimodal (is_multimodal: true, language_model_only: false, a 27-layer vision tower) and the launcher enables it by default —
nothing extra to configure. The vision tower is already counted in the
"weights on GPU" figure, so images and video cost no additional GPU budget.
Verified on this host 2026-09-04 against the running server:
| Modality | Test | Result |
|---|---|---|
| Image | 336x336 PNG, three colour bands | named all three in order; 179 prompt tokens |
| Video | 4 s clip, 16 frames, one colour per second | named all four in temporal order; 376 prompt tokens |
Use the standard OpenAI content-part shapes — image_url and video_url,
either an http(s):// URL or a data: URI:
curl -s localhost:8888/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model":"qwen3.8-flash-next","max_tokens":600,"temperature":0,
"messages":[{"role":"user","content":[
{"type":"image_url","image_url":{"url":"https://example.com/photo.jpg"}},
{"type":"text","text":"Describe this image."}]}]}'
Three things to know before leaning on it:
using text-only draft inputs instead and falls back for those requests. The answer is still correct — the
target model sees the image — but decode runs closer to the non-speculative
speed. Text-only requests are unaffected.YARN=1 with the shipped FP8 KV you have 2.16x a full-length
request in KV (measured 2026-09-06 on the running server), so two concurrent
long video requests already contend; the 262k profile has far more headroom.
The 1.34x quoted here before was a BF16-KV measurement.Precedence is environment > .env > built-in default in start.sh, so any
knob can be overridden per launch:
MAX_MODEL_LEN=65536 MTP_NUM_SPECULATIVE_TOKENS=0 ./start.sh
The safety-relevant knob is HOST_RESERVE_GIB (default 26): the GPU budget
is capped at MemTotal − HOST_RESERVE_GIB no matter what KV_TARGET_GIB
asks for, and start.sh prints "KV target X reduced to Y" when the cap binds.
KV_TARGET_GIB is a wish under that cap: the shipped 20 is clipped to
16.67 GiB here, ~1.13M