DGX Spark · documentation

Benchmarks — how to reproduce them

docs/benchmarks.md Last pushed 30 August 2026

Current numbers live in measurements.md. This page is the method: how each figure was taken, for both recipes, so you can check them on your own box.

All numbers in this repository are real measurements from a single machine. Nothing is extrapolated. Where something was not measured, it says so.

Provenance

Item Value
Hardware NVIDIA DGX Spark (GB10, SM121), 128 GB unified memory, ~121 GiB usable
Memory bandwidth ~273 GB/s
OS Ubuntu 24.04.4, kernel 6.17.0-1029-nvidia aarch64, driver 580.173.02, CUDA 13.0, swap off
Editing recipe llama.cpp, PR #27742 @ 035e227 + patches/canreuse-qwen4exp.patch; unsloth/Qwen3.8-Flash-Next-GGUF UD-Q4_K_XL, 103.7 GiB
Long-context recipe vLLM 0.1.dev20073+g8e685d198 (image built from upstream 82ed48d), RadixArk/Qwen3.8-Flash-Next-NVFP4, MTP=3, prefix caching off (PREFIX_CACHE=0; it now ships on, so set this to reproduce), PIECEWISE CUDA graphs

Nothing else contended for the GPU during any run: the host's usual API endpoint was shut off for the duration.


The harness

Both recipes are measured by one script, ../bench/portable_bench.py, so their numbers are comparable:

./bench/portable_bench.py --api http://127.0.0.1:8000 --label mine

It streams responses and times time-to-first-token separately from decode. That matters: the two engines report their own counters differently and are not comparable directly. llama.cpp exposes a timings block; vLLM does not, and vLLM's own "generation throughput" counts accepted draft tokens, which reads ~6 tok/s higher than what a client actually receives. Measuring end-to-end from the client side avoids picking whichever number flatters.

Two methodology details, both learned the hard way and inherited from bench/run_bench.py:

  • One warmup per task is discarded. The first request after any state change is markedly slower.
  • Every repetition substitutes a different token into the prompt. Repeating an identical prompt lets the context-copying drafter draft from its own memory of the previous generation. Measured inflation when we did not do this: 60 → 169 tok/s. Any benchmark that sends the same prompt three times and averages is measuring its own cache.

A prefill trap worth knowing

An earlier run of ours reported ~1,500–1,660 tok/s prefill for llama.cpp. That was wrong. The prompts shared a common prefix, so most requests hit the server's slot cache. The giveaway was the spread: TTFT p50 of 477 ms against a max of 21 s.

The honest cold prefill rate is the slowest request, not the aggregate. Our current prefill figures use max_tokens=1 and, on the vLLM side, benefit from prefix caching being disabled on this platform — nothing is served from cache.


Decode by task shape

The four tasks are defined in bench/portable_bench.py: reproduce a file with one change, a targeted bug fix, add a function, and free-form prose as a control. They are chosen to span how much of the answer already exists in the question, because on this model that is the variable that dominates throughput.

# editing recipe
./run.sh edit serve
./bench/portable_bench.py --api http://127.0.0.1:8000 --label llamacpp

# long-context recipe
./run.sh longctx serve
./bench/portable_bench.py --api http://127.0.0.1:8000 --label vllm

Results: measurements.md.

Cold vs warm page cache

Only meaningful for the editing recipe. Under vLLM the n-gram table stays mapped by the live process, so drop_caches cannot evict it and there is no cold state to measure while it serves.

MODEL=~/.qwen38fn/model/Qwen3.8-Flash-Next-UD-Q4_K_XL-00001-of-00004.gguf \
  bench/cold_vs_warm.sh

The script drops the page cache (needs root, or a privileged container), reports table residency via mincore(2), measures, then warms with tools/warm_table.py and measures again.

Result, and it reverses this repo's earlier advice: warming buys nothing on the current build. Prose measures 27.8 tok/s at both 17.7% and 58.1% residency, and on the other tasks the medians move in both directions by less than the run-to-run spread. Read that as no measurable effect, not as "cold is faster". See ruled-out.md.

Prefill and decode across the context window

# prompt of a known size, one output token: TTFT is prompt ingestion alone
curl -s localhost:8000/v1/chat/completions -H 'Content-Type: application/json' \
  -d '{"model":"qwen3.8-flash-next","messages":[{"role":"user","content":"<long text>"}],"max_tokens":1}'

Divide the reported prompt_tokens by the measured TTFT. For decode at depth, send the same long prompt with a real generation request and time tokens after the first.

Results: measurements.md.

Memory and residency

free -g                                    # system view
tools/page_cache.py <shard-1.gguf>         # n-gram table residency, mincore(2), no root needed
docker logs qwen38-flash | grep -i "KV cache\|Actual usage"   # vLLM's own accounting

tools/page_cache.py locates per_layer_token_embd.weight across a split GGUF and reports what fraction of its byte range is in the page cache.

A caution. Unified memory makes free confusing here — "GPU" allocations and page cache come from the same pool, and mapped file pages do not appear where you would expect. Our own file-level residency measurement totals more than physical RAM across the vLLM checkpoint. It validates on single files (a control reads 0.0%, goes to 100.0% when read, files are not sparse) but we do not trust the aggregate and do not publish it. See measurements.md.

Concurrency

for i in $(seq 1 8); do
  curl -s localhost:8000/v1/chat/completions -H 'Content-Type: application/json' \
    -d '{"model":"qwen3.8-flash-next","messages":[{"role":"user","content":"count to 40"}],"max_tokens":200}' \
    -o /tmp/c-$i.json -w "req$i %{http_code} %{time_total}s\n" &
done; wait

Correction to an earlier claim in this repo. This page previously said concurrent requests crash the editing recipe. They do not. Eight simultaneous requests all returned 200, the server survived, and completions arrived in near-perfect 3-second succession — that is --parallel 1 queueing, not batching. Aggregate throughput therefore stays at the single-stream rate and each of N clients gets roughly 1/N of it. The long-context recipe genuinely serves 2 sequences.


Editing-recipe specifics

These were measured on the editing recipe only and have not been re-run against vLLM.

Major page faults per token

Prompt tokens Prefill tok/s Decode tok/s Major faults/token
226 355 22.34 2.7
1,659 632 22.14 2.0
6,443 662 21.28 1.4
19,197 499 19.50 1.3

Faults per generated token fall with longer prompts — plausibly because a longer prefill has already faulted in table rows the decode reuses. Not separately verified.

Reproduce: sample the process's major-fault counter from /proc/<pid>/stat before and after a generation, divide the delta by tokens generated.

Memory footprint and load time

Metric Value
Model file 103.7 GiB
Resident weights ~76.9 GiB
Table served from NVMe ~26.8 GiB
Steady-state memory used ~95 GiB
Process RSS ~1.4 GiB
Load time from NVMe ~3m35s

The RSS figure is the point of the whole design: the table is served by the OS page cache from NVMe, so it never becomes process-resident.

The graph-reuse patch

With stock PR #27742, graphs reused = 0 — the qsa/ple graph inputs never override can_reuse(), so the graph is rebuilt and re-split every token and CUDA graph capture never engages. With patches/canreuse-qwen4exp.patch:

Metric Before After
Graphs reused 0 1543
Decode baseline +2.8%

The measured effect is larger than +2.8% in practice: our prose figure (27.8 tok/s) sits above the 19–25 tok/s others report for the same quant on the same hardware, and warming the table stopped mattering once graphs engaged.

Reproduce: run the same generation with and without the patch; compare the graphs reused counter in the server log.

Draft-model speculation

An external draft model was tested before MTP was available anywhere:

Draft Vocab Mean accepted length Decode
Qwen3.5-0.8B 248320 (compatible) 2.88 ~23 tok/s — no speedup

Acceptance was healthy; throughput did not move. On a top-10-of-512 MoE, k draft tokens activate the union of experts across those positions, so weight traffic grows with k.

This is no longer the last word on speculation. The model's own trained MTP head does help — about 1.16× on prose across engines — but only through vLLM, because the GGUF converter drops the head (supports_mtp_export = False; we confirmed zero MTP tensors across all four shards, and all 31 present in the NVFP4 checkpoint). See ruled-out.md.


Not measured

  • Editing recipe above ~19k context, and its decode-at-depth curve.
  • IQ-series quants on either recipe. Our Q3_K result says lower-bit K-quants are slower here, but independent reports show the IQ series behaving the opposite way. Different kernels.
  • Quality. No eval suite has been run against either recipe. Every number here is speed.
  • Batch/offline throughput, and concurrency beyond 8 simultaneous requests.
  • Vision throughput. Both recipes now have a measured image-eval score (../docs/vision.md), but no dedicated vision throughput benchmark: the numbers there are eval wall-clock at concurrency 4, not a tok/s figure.

Every number here was measured. Open an issue if one looks wrong.

All documentation