poolside's agentic-coding Laguna-M.1 (226B MoE, ~22B active) in NVFP4 on two DGX Sparks (TP2) — 12.5 tok/s single-stream at 196,608-token context.
* segment sizes marked with an asterisk are estimates pending a measured run
Eval scores
(compare all)Bench card
No measured runs yet — be the first: install the spark-benchmark skill.
Overview
Laguna-M.1 is a 226B-total / ~22B-active MoE — 70 layers, 256 routed experts at top-16 — and poolside publishes its own NVFP4 export at 139.32 GB. That number is the whole shape of this recipe: it does not fit one 121 GiB Spark under any arrangement, so tensor parallelism across two nodes is a fitting decision rather than a speed one, and each rank ends up holding 64.98 GiB of weights. The second shaping fact is what the quant left alone. The NVFP4 config's `ignore` list is only seven entries, but they are `lm_head`, the router gates, and every attention projection — q, k, v, o and g_proj. A per-tensor byte census says routed experts are 87% of the file and, because one token routes to 16 of 256 experts, only 33% of what a decode step actually reads; BF16 attention is 11% of the file and 65% of the reads. Decode on a Spark is bandwidth-bound, so the 4-bit weights buy memory, not speed: at 23.3 GB read per token the two-node roofline is about 23 tok/s, and the measured 12.5 is a normal fraction of it under eager mode. Expect this configuration to feel like a very large model that thinks slowly, not like a fast 22B. The third is context. Laguna-S and Laguna-XS are hybrid — one full-attention layer in four, the rest sliding-window-512 — and context on them is nearly free. M.1 is not: `sliding_window` is 0 and there is no `layer_types`, so all 70 layers carry a real KV cache, at 140 KiB per token. Even with the FP8 cache the checkpoint asks for, and even split across two ranks, the model's native 262,144-token window would want ~18.8 GiB of pool per rank on top of 65 GiB of weights — and on this box that is the difference between a server that starts and a server the OOM guard kills. Pinning 16 GiB per rank buys a 239,664-token pool, and 196,608 is the longest request that keeps a safe margin above it.
- 2 × DGX Spark (GB10, sm_121), tensor-parallel (TP2) — the full 226B model in NVFP4, 64.98 GiB of weights per node (measured)
- 12.52 tok/s single-stream decode at a 2,048-token code prompt, warm; 12.63 at 512 and 12.25 at 8,192 — decode is nearly flat with prompt length
- Prefill 1,795 tok/s at 2,048 tokens; TTFT 465 ms at 512, 1.14 s at 2,048, 4.73 s at 8,192
- No speculative decoding: the checkpoint carries no MTP layer and poolside publishes no Laguna-M.1 DFlash draft, so the usual Spark decode lever is simply unavailable here
- The quant leaves ALL attention in BF16 — 11% of the file, 65% of the bytes read per decoded token. That is why 4-bit weights buy memory and not speed
- 196,608-token context actually serves, against a measured 239,664-token FP8 KV pool (1.22x)
- All 70 layers are full attention (sliding_window 0) — unlike Laguna-S/XS, context is expensive here at 140 KiB/token
- KV comes up FP8 with no flag passed: the checkpoint declares its own kv_cache_scheme
- --enforce-eager is required: cross-node TP2 cudagraph replay deadlocks on GB10
- MAX_JOBS=4 on the raylets is required, or the first-forward kernel JIT bursts enough host memory to get the server killed — see troubleshooting
Software requirements
- 2 × DGX Spark (GB10, sm_121), NVIDIA driver ≥ 580 (CUDA-13 capable)
- vLLM 0.26.0 + torch 2.11.0+cu130 in a venv (~/venvs/vllm-026). LagunaForCausalLM is natively registered — no --trust-remote-code, and no patches of any kind were needed. Last verified 2026-07-28 on vLLM 0.26.0
- ninja (~/.local/bin) and nvcc (/usr/local/cuda/bin) on the raylet PATH — the NVFP4 MoE kernel JIT-compiles on the first forward
- loginctl enable-linger on BOTH nodes — without it the TP2 worker raylet dies on every SSH disconnect (the systemd --user manager is torn down)
- Direct RoCE link with static point-to-point IPs; resolve each node's RoCEv2 GID index at start time (they differ per node and drift across reboots)
- ~150 GB free disk per node — both nodes need the full 139.32 GB checkpoint at the same path
- Passwordless SSH between the nodes. Base model: poolside/Laguna-M.1 (license OpenMDW-1.1)
Quick start
- 1
Enable user lingering + free the nodes (preflight, once per box)
TP2 places ranks with Ray, and the raylets run as systemd --user services. Without lingering, the user manager (and every raylet) is torn down the moment the last SSH session closes, and vLLM then hangs forever in 'Waiting for creating a placement group'. Enable lingering once, then start from a clean slate.
# once per box ssh 192.168.100.1 'loginctl enable-linger $USER' ssh 192.168.100.2 'loginctl enable-linger $USER' # clean slate before serving pkill -f '[v]llm serve' 2>/dev/null ssh 192.168.100.2 "pkill -f '[v]llm serve' 2>/dev/null; true"bashVerify with `loginctl show-user $USER | grep Linger` → Linger=yes on both nodes. Do NOT purge the page cache here: this recipe pins the KV pool with --kv-cache-memory-bytes, which skips vLLM's free-memory check, so a purge buys nothing and only makes the 28-shard load slower.
- 2
Stage the NVFP4 checkpoint on both nodes
Each TP rank reads all shards, so both nodes need the complete tree at an identical path. Spark-2's default route is wifi, so download once on node 1 and mirror over the direct RoCE link rather than pulling 139 GB twice.
# node 1 — 139.32 GB, 28 shards (~23 min at ~110 MB/s) hf download poolside/Laguna-M.1-NVFP4 --local-dir ~/models/hf/Laguna-M.1-NVFP4 # mirror to node 2 over the direct RoCE link (~820 MB/s) rsync -aL --partial --inplace \ -e 'ssh -c aes128-gcm@openssh.com -o Compression=no' \ ~/models/hf/Laguna-M.1-NVFP4/ 192.168.100.2:~/models/hf/Laguna-M.1-NVFP4/bashVerify both nodes report 39 files and zero *.incomplete before serving — a partial tree fails several minutes into the load. There is no draft model to stage: none exists for this target.
- 3
Bring up the 2-node Ray cluster (raylets as detached services)
Start each raylet as a systemd --user *service* (not --scope): pass the fabric env via --setenv, raise LimitMEMLOCK for RDMA, and put ninja + nvcc on PATH. MAX_JOBS=4 is load-bearing and must live here rather than on the serve command — the Ray workers do the kernel JIT, and they inherit the raylet's environment, not the driver's.
VENV=$HOME/venvs/vllm-026 ENV='--setenv=PATH='$HOME'/venvs/vllm-026/bin:'$HOME'/.local/bin:/usr/local/cuda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ --setenv=NCCL_IB_DISABLE=0 --setenv=NCCL_IB_HCA=rocep1s0f1 \ --setenv=NCCL_SOCKET_IFNAME=enp1s0f1np1 --setenv=GLOO_SOCKET_IFNAME=enp1s0f1np1 \ --setenv=RAY_memory_monitor_refresh_ms=0 --setenv=MAX_JOBS=4' # each node's RoCEv2 IPv4 GID index differs and drifts across reboots — resolve it gid() { ssh $1 "for g in /sys/class/infiniband/rocep1s0f1/ports/1/gids/*; do i=\${g##*/}; \ [ \"\$(cat /sys/class/infiniband/rocep1s0f1/ports/1/gid_attrs/types/\$i 2>/dev/null)\" = 'RoCE v2' ] || continue; \ case \$(cat \$g) in *ffff:$(printf '%02x%02x:%02x%02x' $(echo $2|tr . ' ')) ) echo \$i; break;; esac; done"; } GID1=$(gid 192.168.100.1 192.168.100.1); GID2=$(gid 192.168.100.2 192.168.100.2) ssh 192.168.100.1 "systemd-run --user --collect --unit=lagm1-ray-head $ENV \ --setenv=VLLM_HOST_IP=192.168.100.1 --setenv=NCCL_IB_GID_INDEX=$GID1 \ -p MemoryMax=102G -p MemorySwapMax=0 -p LimitMEMLOCK=infinity \ $VENV/bin/ray start --block --head --node-ip-address=192.168.100.1 --port=6379 --object-store-memory=2000000000" sleep 10 ssh 192.168.100.2 "systemd-run --user --collect --unit=lagm1-ray-worker $ENV \ --setenv=VLLM_HOST_IP=192.168.100.2 --setenv=NCCL_IB_GID_INDEX=$GID2 \ -p MemoryMax=110G -p MemorySwapMax=0 -p LimitMEMLOCK=infinity \ $VENV/bin/ray start --block --address=192.168.100.1:6379 --object-store-memory=2000000000 --node-ip-address=192.168.100.2" sleep 12 $VENV/bin/ray status # expect 2 nodes, 2.0 GPUbash--block keeps `ray start` as the service's main process so the unit stays active. If `ray status` shows 1.0 GPU the worker dropped — check lingering, and that the worker unit is active (`systemctl --user is-active lagm1-ray-worker` on .2).
- 4
Serve TP2 with a pinned KV pool
--enforce-eager is required: cross-node cudagraph replay deadlocks on GB10. --kv-cache-memory-bytes pins the pool deterministically instead of letting vLLM's utilization math guess on unified memory, and 16 GiB/rank is what the 196,608-token window costs with a margin. --max-num-batched-tokens 4096 bounds the chunked-prefill transient, which matters on a box this close to full.
VENV=$HOME/venvs/vllm-026 export VLLM_HOST_IP=192.168.100.1 export NCCL_SOCKET_IFNAME=enp1s0f1np1 GLOO_SOCKET_IFNAME=enp1s0f1np1 export NCCL_IB_DISABLE=0 NCCL_IB_HCA=rocep1s0f1 NCCL_IB_GID_INDEX=$GID1 export RAY_memory_monitor_refresh_ms=0 systemd-run --user --scope --collect -p MemoryMax=12G -p MemorySwapMax=0 \ $VENV/bin/vllm serve $HOME/models/hf/Laguna-M.1-NVFP4 \ --served-model-name laguna-m1-nvfp4 \ --distributed-executor-backend ray --tensor-parallel-size 2 \ --enforce-eager \ --max-model-len 196608 \ --gpu-memory-utilization 0.85 \ --kv-cache-memory-bytes 17179869184 \ --max-num-seqs 2 --max-num-batched-tokens 4096 \ --host 0.0.0.0 --port 8000bashBudget ~25 minutes for a first start on a fresh venv: 28 shards load in ~8 min per rank ('Model loading took 64.98 GiB memory'), then the NVFP4 MoE kernel JIT-compiles for another ~15. Later boots are much faster (cached under ~/.cache/flashinfer). Expect the log to confirm 'Using FLASHINFER attention backend', "Using 'FLASHINFER_CUTLASS' NvFp4 MoE backend" and 'GPU KV cache size: 239,664 tokens'.
- 5
Smoke test, warm up, then read the pool and memory
Confirm coherence, then warm the kernels before believing any throughput number — a cold pass faults expert planes in from NVMe. Read steady-state memory only once the API is up; mid-load figures flatter you by exactly the KV pool you are about to allocate.
curl -s localhost:8000/v1/chat/completions -H 'Content-Type: application/json' \ -d '{"model":"laguna-m1-nvfp4","messages":[{"role":"user","content":"capital of France? one word"}],"max_tokens":8,"temperature":0}' # -> "Paris" # a couple of 300-token throwaway generations to warm the kernels, then measure free -g | sed -n '2p' # head ~101 GiB used ssh 192.168.100.2 "free -g | sed -n '2p'" # worker ~96 GiB usedbashA 300-token warmup generation takes ~24 s, which is the honest feel of this config: ~12.5 tok/s. If your first benchmark pass shows the 512-token prompt slower than the 2,048-token one, the run paid a JIT cost and the whole pass is suspect — warm and re-run.
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --enforce-eager | required | Cross-node TP2 cudagraph replay deadlocks on GB10 — the first real generation hangs and the engine dies with an RPC timeout. Eager costs decode (there are no captured graphs), but a working server beats a wedged pair of boxes. This is a fleet property, not a Laguna one. |
| --kv-cache-memory-bytes | 17179869184 (16 GiB per rank) | Pins the KV pool deterministically. On unified memory vLLM's utilization fraction counts system-wide usage and is not a reliable lever, and on 0.26.0 the auto-sizing path can abort outright. 16 GiB/rank measures out at a 239,664-token FP8 pool. Raising it to 20 GiB (which is what the model's full 262,144 window wants) pushed the head node past its budget and got the server killed — see troubleshooting. |
| --max-model-len | 196608 | 1.22x concurrency against the 239,664-token pool. The pool size varies a couple of percent between boots, so leave that margin: if the pool comes up smaller than max-model-len the server refuses to start. The model's native window is 262,144 and this config cannot reach it — that is a memory limit, not a model one. |
| --max-num-batched-tokens | 4096 | Bounds the chunked-prefill chunk, and with it the largest activation transient during startup profiling and long prefills. On a node running at ~97 of 114 GiB that transient is the difference between starting and being OOM-killed. Prefill still measures 1,795 tok/s at a 2,048-token prompt. |
| --max-num-seqs | 2 | Left small deliberately. `max-num-seqs` is a decode knob when speculative decoding is on, because vLLM derives the cudagraph capture size from it — but this config has no draft AND runs eager, so nothing derives from it and the scheduler slots are pure cost. Do not copy the value into a spec-decode recipe. |
| MAX_JOBS | 4, on the raylets | Caps ninja's parallel nvcc jobs during the NVFP4 MoE kernel JIT, which runs inside the first forward. Unset, ninja uses nproc (20 here) at 3-4 GiB per compiler process, and the burst kills the server. It must be set on the raylet environment: Ray actors inherit the raylet's env, not the driver's. |
| kv_cache_dtype | not set — comes up FP8 anyway | The checkpoint's quantization_config carries its own `kv_cache_scheme` (8-bit float, tensor strategy) and vLLM honours it silently. Do the KV arithmetic in FP8 bytes, not BF16, or you will over-budget by 2x: 70 full-attention layers x 2 x 8 KV heads x 128 head_dim x 1 byte = 140 KiB/token, halved per rank under TP2. |
| --speculative-config | not used — no draft exists | Written down because it is the first thing a Spark owner will reach for. The checkpoint's index has zero mtp/nextn/eagle tensors, the config declares no `num_nextn_predict_layers`, and poolside publishes DFlash drafts only for Laguna-XS, XS.2 and S-2.1 — nothing for M.1. Speculative decoding on this model needs a draft that does not exist yet. |
| --trust-remote-code | not needed | The checkpoint carries an auto_map pointing at modeling_laguna.py, which makes it look like a custom-code model. It is not, for vLLM: LagunaForCausalLM is natively registered in 0.26.0 and the HF remote code is never loaded. |
API usage
Chat completion
curl -s http://<spark-1>:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "laguna-m1-nvfp4",
"messages": [{"role": "user", "content": "Write a Python LRU cache with tests."}],
"max_tokens": 512,
"temperature": 0
}'bashMeasure single-stream decode correctly
# Count tokens from usage.completion_tokens, never by counting SSE frames.
curl -s http://<spark-1>:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "laguna-m1-nvfp4",
"messages": [{"role": "user", "content": "Write a Python LRU cache with tests."}],
"max_tokens": 300, "temperature": 0,
"stream": true, "stream_options": {"include_usage": true}
}' | tail -3
# ~300 tokens in ~24 s = ~12.5 tok/s, warm, at a short prompt.bashTroubleshooting
- The server is killed a few seconds after the KV pool is allocated, with no traceback
- Serving at the model's native 262,144-token context does not work
- Decode is only ~12.5 tok/s on a model with 22B active parameters
- vLLM warns 'tensor_parallel_size=2 is bigger than a reserved number of GPUs (1 GPUs) in a node'
- Ray's raylet logs '/tmp/ray/session_... is over 95% full' every 10 seconds
- Ruled out: no vLLM patch is needed for this model
Revision history
What has changed on this page since it was published, and what it measured. Newest first.
- re-measured
First publication: measured bring-up of poolside/Laguna-M.1-NVFP4 on two Sparks over TP2, vLLM 0.26.0.
12.52 tok/s single-stream decode at a 2,048-token code prompt (warm), 196,608-token context against a measured 239,664-token FP8 KV pool.
Memory budget
Measured: 'Model loading took 64.98 GiB' on each rank, and steady-state `free -g` with the API up reads 101 GiB on the head and 96 GiB on the worker against idle baselines of ~4 and ~3 GiB — so the serving working set is ~97 and ~93 GiB of the 114 GiB usable. The weights split is apportioned from a per-tensor byte census (bench/ckpt_bytes.py): routed experts + shared expert + dense MLP = 113.95 GiB of the 129.70 GiB file, attention + embeddings + lm_head = 15.75 GiB, halved per rank. KV is the pinned figure. Unlike Laguna-S and Laguna-XS, this model is NOT hybrid: `sliding_window: 0` and no `layer_types`, so all 70 layers hold a full KV cache — 140 KiB/token, or 70 KiB/token/rank at FP8 under TP2, which is roughly 10x what its smaller siblings cost. That is why context, not weights, is what runs out here: the 16 GiB/rank pin buys a 239,664-token pool, and 196,608 is the longest request that leaves a safe 1.22x margin. Overhead is back-computed from the measured residency and is larger on the head, which also runs the API server and engine core.