poolside's agentic-coding Laguna-S 2.1 (118B MoE, ~8B active) in NVFP4 on one DGX Spark, with the matching NVFP4 DFlash speculator — 42.9 tok/s single-stream (2.3x the no-draft baseline) at the full 262K context.
* segment sizes marked with an asterisk are estimates pending a measured run
Eval scores
(compare all)Bench card
| 2K | 8K | 32K | 128K | |
|---|---|---|---|---|
| decode tok/s | 31.2 | 36.9 | 31.0 | 32.0 |
| ttft | 776ms | 2.21s | 10.03s | 61.72s |
| prefill tok/s | 2.6k | 3.7k | 3.3k | 2.1k |
| power | 38W | 39W | 40W | 45W |
includes 1 community run · @Fogle
median per context · o256
Contributors
Overview
Laguna-S 2.1 is poolside's 118B agentic-coding MoE (256 routed experts + 1 shared, 10 active per token, ~8B active) with a 262K context and hybrid attention (1 full-attention layer in 4, the rest sliding-window-512). Its NVFP4 checkpoint is 71.9 GB — small enough for a single 121 GiB DGX Spark, which is the reason to pick this build over the two-Spark FP8 one: it frees the second Spark entirely, and a single node avoids the cross-node CUDA-graph deadlock that forces the TP2 recipe onto --enforce-eager, so this serves with graphs. Two things shape the config. First, the speed lever is the DFlash speculator, not the quant: the checkpoint leaves attention in BF16 (the dominant per-token read), so NVFP4 vs FP8 barely changes decode — but the draft turns 18.9 tok/s into 42.9 (2.3x, greedy so lossless). Second, and the trap worth knowing: DFlash drafts are TARGET-PRECISION-SPECIFIC. A draft ships no lm_head of its own — it shares the target's and is calibrated to the target's hidden states — so it only works with the target it was made for. You must pair this NVFP4 target with poolside's -DFlash-NVFP4 draft (61.9% acceptance here); the -DFlash-FP8 draft (or a W4A16 quant of it) gives 0% acceptance against an NVFP4 target and drops decode below the no-draft baseline. Memory is a non-fight: hybrid attention makes the full 262K context fit ~1.8x over, so we pin the KV pool down to 1.25x and leave the rest free.
- 1 × DGX Spark (GB10, sm_121) — the whole 118B model in NVFP4, ~72 GB of weights, second Spark left free
- DFlash speculative decode at k=6: 42.9 tok/s single-stream on code vs 18.9 no-draft — 2.3x
- --max-num-seqs 4 (not the reflexive 1-2): under spec-decode it sets the CUDA-graph capture size, worth a measured +7% single-stream (43 vs 40 tok/s at k=6)
- 61.9% draft acceptance at k=6, 3.71 tokens/step (per-position 0.88 / 0.69 / 0.64 / 0.59 / 0.51 / 0.39)
- Greedy (temp 0), so spec output is token-identical to the baseline — the speedup is free of any quality cost
- Serves with CUDA graphs (no --enforce-eager) — a single node has no cross-node cudagraph deadlock
- MUST pair the NVFP4 target with the -DFlash-NVFP4 draft: DFlash drafts are target-precision-specific (the -DFlash-FP8/W4A16 draft gives 0% acceptance here)
- MoE backend auto-selects FLASHINFER_CUTLASS for NVFP4 on sm_121 — no --moe-backend flag needed
- Full 262,144-token context serves; KV pinned to a 327K-token pool (1.25x), ~24 GiB free
- Slower than the two-Spark FP8 build (60.9 tok/s) — one Spark's ~273 GB/s vs two summed — but frees a whole node
- First boot JITs sm_121a kernels + compiles graphs for ~15-20 min (cached after; looks like a hang, it isn't)
Software requirements
- 1 × DGX Spark (GB10, sm_121), NVIDIA driver ≥ 580 (CUDA-13 capable)
- vLLM 0.25.1 + torch 2.11.0+cu130 (~/venvs/vllm-025). LagunaForCausalLM and DFlashLagunaForCausalLM are natively registered — no --trust-remote-code
- CUDA 13 is required for the FP4 MoE kernels (a cu129 build fails). flashinfer 0.6.13 ships no prebuilt sm_121a cubins — budget one ~15-20 min JIT + torch.compile on the first boot
- ~75 GB free disk for the checkpoint + draft
- Base model: poolside/Laguna-S-2.1 (license OpenMDW-1.1)
Quick start
- 1
Free the node, then stage the NVFP4 checkpoint and its matching draft
Single node, so no fabric mirroring. Purge the page cache first — vLLM's startup check wants free memory >= util*total, and a cold box with page cache trips it. Pull the NVFP4 weights AND the NVFP4-matched DFlash draft (not the FP8 draft — see the warning in troubleshooting).
pkill -f '[v]llm serve' 2>/dev/null python3 ~/Dev/vLLM-Moet/spark/purge-cache.py ~/models/hf hf download poolside/Laguna-S-2.1-NVFP4 --local-dir ~/models/hf/Laguna-S-2.1-NVFP4 hf download poolside/Laguna-S-2.1-DFlash-NVFP4 --local-dir ~/models/hf/Laguna-S-2.1-DFlash-NVFP4bashIf `hf download` stalls (the xet path hangs on these boxes), set HF_HUB_DISABLE_XET=1. Verify the target has its 14 safetensors shards and zero *.incomplete files.
- 2
Serve NVFP4 + the NVFP4 DFlash draft under a memory cap
No Ray, no TP — a single vLLM process. --speculative-config turns on DFlash at k=6 (the measured peak). --kv-cache-memory-bytes pins the KV pool to ~1.25x for a single user instead of the idle 1.82x at util 0.85. No --moe-backend flag — vLLM auto-selects FLASHINFER_CUTLASS for the NVFP4 MoE on sm_121. No --enforce-eager — a single node runs CUDA graphs without the cross-node deadlock the two-Spark recipe hits.
VENV=$HOME/venvs/vllm-025 systemd-run --user --scope --collect -p MemoryMax=118G -p MemorySwapMax=0 \ env PATH="$VENV/bin:$HOME/.local/bin:/usr/local/cuda/bin:$PATH" VLLM_USE_DEEP_GEMM=0 \ vllm serve ~/models/hf/Laguna-S-2.1-NVFP4 \ --served-model-name laguna-s-nvfp4 \ --max-model-len 262144 \ --gpu-memory-utilization 0.85 \ --kv-cache-memory-bytes 12884901888 \ --max-num-seqs 4 --max-num-batched-tokens 8192 \ --speculative-config '{"method":"dflash","model":"'$HOME'/models/hf/Laguna-S-2.1-DFlash-NVFP4","num_speculative_tokens":6}' \ --host 0.0.0.0 --port 8000bashFirst boot pays a ~15-20 min flashinfer sm_121a JIT + torch.compile (cached after; the log freezes and the GPU idles — it is compiling, not hung). Expect 'Using FLASHINFER_CUTLASS NvFp4 MoE backend' and 'GPU KV cache size: 327,717 tokens ... 1.25x'.
- 3
Smoke test, warm up, then read acceptance
Confirm coherence, warm the kernels (spec-decode JITs extra Triton kernels on first use), then read the acceptance rate — that number is why decode is 2.1x.
curl -s localhost:8000/v1/chat/completions -H 'Content-Type: application/json' \ -d '{"model":"laguna-s-nvfp4","messages":[{"role":"user","content":"capital of France? one word"}],"max_tokens":8,"temperature":0}' # -> "Paris" # a few hundred tokens of throwaway traffic, then: curl -s localhost:8000/metrics | grep -E 'spec_decode_num_(draft|accepted)_tokens_total' # accepted / drafted ~= 0.59 on code at k=6 free -g | sed -n '2p' # ~94 GiB used, ~26 freebashWarm before benchmarking. Acceptance is content-dependent: ~59% on code at k=6; reasoning/thinking prose drafts worse (thinking is off by default in the chat template, which is right for a coding endpoint). If acceptance is 0%, you paired the wrong draft — see troubleshooting.
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --speculative-config model | poolside/Laguna-S-2.1-DFlash-NVFP4 | The NVFP4-target-matched DFlash draft. DFlash drafts are target-precision-specific (they share the target's lm_head and are calibrated to its hidden states), so this NVFP4 target needs the NVFP4 draft — 57-59% acceptance. The -DFlash-FP8 draft (or a W4A16 quant of it) gives 0% acceptance here and drops decode below baseline |
| --max-num-seqs | 4 | A DECODE knob here, not just a scheduler one: under spec-decode vLLM sizes the CUDA-graph capture as max_num_seqs × (k+1), so the reflexive 'single user → set it to 2' actually costs decode. Measured: k=6 runs 40.5 tok/s at max-num-seqs 2 vs 42.9 at max-num-seqs 4 — a free +6-7% single-stream. It uses no KV you can fill on one user, so 4 is a better default; sweep it with k and keep the best concurrency-1 decode |
| --speculative-config num_speculative_tokens | 6 (swept 6/12/15/18 at max-num-seqs 4) | DFlash draft length; k=6 is the measured peak. Higher k CRATERS acceptance and does not help decode: k=6 43.5 tok/s @ 62% accept, k=12 40.9 @ 35%, k=15 42.1 @ 31%, k=18 34.7 @ 24% (2048 prompt, warm). The mean accepted LENGTH keeps rising with k (3.7→4.7), which is why a large k looks tempting and misleads — optimize decode tok/s, not acceptance length. Per-position acceptance at k=6: 0.88 / 0.69 / 0.64 / 0.59 / 0.51 / 0.39 |
| --kv-cache-memory-bytes | 12884901888 (12 GiB) | Pins the KV pool to 327,717 tokens = 1.25x at 262K. On a SINGLE node the KV is not sharded, so 262K needs ~9.6 GiB — the 5 GiB/rank value from the two-Spark recipe is too small here and the engine refuses to start (estimated max len 128K). Without the pin, util 0.85 gives 1.82x (no draft); shrinking it hands memory back without changing decode |
| --moe-backend | (unset — auto) | vLLM auto-selects FLASHINFER_CUTLASS for the NVFP4 MoE on sm_121 and serves correctly. The old marlin-only rule for NVFP4 MoE does not apply to this build |
| --enforce-eager | (unset — graphs on) | A single node has no cross-node cudagraph-replay deadlock, so leave graphs on (spec-decode downgrades FULL→PIECEWISE, which is fine). This is the opposite of the two-Spark FP8 recipe, which must force eager |
| --kv-cache-dtype | (unset — FP8 from checkpoint) | The checkpoint carries its own kv_cache_scheme (8-bit), so vLLM resolves KV to fp8_e4m3 automatically — part of why KV is cheap |
| VLLM_CACHE_ROOT | fresh dir if A/B-ing drafts | vLLM's torch.compile cache does not re-key on the draft's quantization, so switching between a BF16/NVFP4 draft and a W4A16 draft reloads a stale graph and dies with KeyError: 'weight_packed'. Point VLLM_CACHE_ROOT at a fresh dir per draft variant (or serve --enforce-eager) when comparing drafts |
API usage
Measure single-stream decode correctly (and read acceptance)
Derive tok/s from usage.completion_tokens and the first/last-token timestamps, never by counting SSE frames — DFlash bundles several tokens per chunk. Warm first. This is how the 42.9 tok/s headline was obtained (bench/harness.py, spec-decoding profile, 2048-token code prompt, temp 0, warm). The paired no-draft baseline was 18.9, hence 2.3x.
curl -s localhost:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"laguna-s-nvfp4",
"messages":[{"role":"user","content":"Complete this module: import csv\n\ndef parse_csv(path):"}],
"max_tokens":256, "temperature":0, "stream":true,
"stream_options":{"include_usage":true}}'
# decode_tps = (completion_tokens - 1) / (t_last_token - t_first_token)
# measured: 42.9 tok/s at a 2048-token prompt; 61.9% acceptance, 3.71 tokens/stepbashTroubleshooting
- The DFlash draft gives 0% acceptance and decode drops below the no-draft baseline
- You paired the wrong-precision draft. DFlash drafts are target-precision-specific — a draft shares the target's lm_head and is calibrated to its hidden states. This NVFP4 target needs poolside/Laguna-S-2.1-DFlash-NVFP4; the -DFlash-FP8 draft (or a W4A16 quant of it) yields 0% here (verification rejects 100%, so you pay pure draft overhead). Always check `spec_decode_num_accepted_tokens_total` > 0 after swapping a draft.
- Engine refuses to start: 'estimated maximum model length is 128192' / KV cache larger than available
- The KV pin is too small for a single node. On TP2 the KV is sharded so 5 GiB/rank suffices; on ONE node the whole pool is on that node, and 262K needs ~9.6 GiB. Use --kv-cache-memory-bytes 12884901888 (12 GiB) or larger.
- Startup fails: 'Free memory on device cuda:0 (NN GiB) is less than desired GPU memory utilization'
- Page cache or a zombie process is holding memory. Purge the page cache (`purge-cache.py ~/models/hf`) and kill any leftover vLLM/Ray from prior runs (kill by PID; `pkill -f '[v]llm'` can drop your SSH). A clean box shows ~117 GiB free.
- Drafter load dies with KeyError: 'weight_packed' after switching drafts
- vLLM's torch.compile cache doesn't re-key on the draft's quantization, so a graph compiled for one draft precision is reloaded for another and mismatches. Point VLLM_CACHE_ROOT at a fresh dir for the new draft, or serve --enforce-eager (no compiled graph). Only bites when A/B-ing drafts of different quant.
- Server log freezes for ~15-20 min on first boot; GPU idle
- flashinfer JITs sm_121a kernels (no prebuilt cubins for GB10) and torch.compile builds the graphs. Confirm with `pgrep -af 'ninja|nvcc'`. Cached afterwards — later boots are ~3-5 min. Do not kill it.
- Decode is slower than the two-Spark FP8 recipe
- Expected. Single-stream decode is bandwidth-bound and one Spark has ~273 GB/s; the two-Spark FP8 build sums two nodes' bandwidth (~26.5 tok/s no-draft vs 18.9 here). This recipe trades peak decode for using only one node — pick it when you want the second Spark free, not when you want maximum tok/s.
- A published single-node figure (e.g. '77 tok/s' at k=15) that you can't reproduce
- Reproduce before believing it. One such '77' was a typo for 7, at num_speculative_tokens=15 — and k=15 measures 42.1 tok/s / 31% acceptance here, below the k=6 peak (43-44 tok/s / 62%). Large k craters DFlash acceptance on this model; k=6 is the peak. Sweep k yourself and read spec_decode acceptance from /metrics.
Memory budget
The whole point of the NVFP4 build is that it fits one node: 71.9 GB of weights leaves ~40 GiB free on a 121 GiB Spark, so the second Spark is free for other work. Routed experts are NVFP4; attention (q/k/v/o/g_proj), router gate, shared expert and embeddings stay BF16 (ignore list) — the same reason a smaller quant doesn't speed up decode (attention is BF16 and dominates the per-token read), so the speed lever is the DFlash draft. Laguna-S is hybrid-attention (12 of 48 layers full, the rest sliding-window-512), so KV is cheap: at --gpu-memory-utilization 0.85 the no-draft pool came up at 476,715 tokens = 1.82x concurrency at 262K. We pin --kv-cache-memory-bytes to 12 GiB, which with the draft's KV subtracted gives a 327,717-token pool = 1.25x at the full 262,144 context. Steady-state resident measures ~97 GiB (of 121) with the draft at max-num-seqs 4, leaving ~24 GiB free. Unlike the two-Spark FP8 recipe, a single node has no cross-node cudagraph deadlock, so this serves with CUDA graphs (no --enforce-eager) — though single-stream decode is bounded by one Spark's ~273 GB/s, so it is slower than the two-Spark FP8 build (which sums two nodes' bandwidth). The planes/dense split is apportioned from the checkpoint's ignore list; KV is the pinned figure; overhead is back-computed from the measured residency.