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.

  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.