howtospark
Recipes

2 × Spark192K ctx· memory-bound12.52 tok/s@ 2KvLLMthinking offvanilla

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.

spark .1 · rank 0 (head)head
17 GiB free
Activations + graphs16* GiB
KV cache16 GiBFP8
Expert planes57* GiBNVFP4
114 usable
97* / 114 GiB
85% of usable
spark .2 · rank 1 (worker)worker
21 GiB free
Activations + graphs12* GiB
KV cache16 GiBFP8
Expert planes57* GiBNVFP4
114 usable
93* / 114 GiB
82% of usable
Expert planesDense weightsKV cacheActivations + graphsOS reserve

* 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. 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"bash

    Verify 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. 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/bash

    Verify 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. 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. 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 8000bash

    Budget ~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. 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 usedbash

    A 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

ParameterValuePurpose
--enforce-eagerrequiredCross-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-bytes17179869184 (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-len1966081.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-tokens4096Bounds 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-seqs2Left 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_JOBS4, on the rayletsCaps 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_dtypenot set — comes up FP8 anywayThe 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-confignot used — no draft existsWritten 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-codenot neededThe 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
  }'bash

Measure 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.bash

Troubleshooting

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. Entries marked arena are promoted wins from the public engine arena — measured, not authored.

  1. 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.

Source repoOpenMDW-1.1
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.