howtospark
Recipes
DR

1 × Spark256K ctx· model max12.6 tok/s@ 2KvLLMthinking off

Deep Reinforce's agentic-coding Ornith 1.0 9B (9B dense) unquantized in BF16 on one DGX Spark, serving the full 262,144-token context at 12.6 tok/s single-stream.

spark .1 · single nodepeer
75 GiB free
KV cache12 GiBBF16
Dense weights17.66 GiBBF16
114 usable
39* / 114 GiB
34% of usable
Dense weightsKV cacheActivations + graphsOS reserve

* segment sizes marked with an asterisk are estimates pending a measured run

Eval scores

(compare all)

Bench card

Ornith 1.0 9B — BF16
2K8K32K128K
decode tok/s12.212.111.610.0
ttft495ms1.89s8.30s45.48s
prefill tok/s4.1k4.3k3.9k2.9k
power37W37W38W38W

includes 1 community run · @kvncrw

median per context · o256

Contributors

Overview

Ornith 1.0 9B is the small member of Deep Reinforce's agentic-coding family, and unlike its 35B and 397B siblings it is dense: 32 layers at hidden_size 4096, an MLP intermediate of 12288, a 262,144-token native window, and a 27-block vision tower whose weights are all present in the checkpoint. At 17.5 GiB of BF16 it is the easiest thing on this hardware to place — the weights are 15% of a Spark, the whole serving working set is about 39 of 114 usable GiB, and there is no quantization artifact to audit, no calibration set to defend, and no second node to coordinate. The reason to read this page carefully is the number it publishes: 12.6 tok/s single-stream at a 2048-token prompt. That is slow, and it is slow for a structural reason worth understanding before you pick this model. Decode on a Spark is bandwidth-bound, so felt speed tracks the bytes one token reads, and a dense model reads all of them. Summed from the safetensors headers, a decode step here pulls 15.87 GB — everything except the vision tower and the embedding gather — which against the Spark's published 273 GB/s of LPDDR5X puts the arithmetic ceiling at 17.2 tok/s. The measured 12.6 is 73% of that, which is a healthy fraction and means nothing is misconfigured; it means the model is asking for a lot of bandwidth. Total parameter count is a poor guide here, and the direction of the error surprises people: a sparse mixture-of-experts several times larger can be several times faster, because it reads only the experts it routes to. There is no speculative draft to recover the difference. The config advertises mtp_num_hidden_layers: 1, but of the 760 tensors in the weight index not one matches 'mtp' and the layers stop at index 31, where a native MTP layer would sit at 32. So take this recipe for what it is good at. It loads in under two minutes, leaves three quarters of the machine free, serves the full 262,144-token window with a proven request at the top of it, and gives you the model exactly as it was published. If you want speed on this hardware, the lever is fewer bytes per token — a 4-bit build of these weights, or a sparse model — not fewer parameters.

  • 1 x DGX Spark (GB10, sm_121) — no tensor parallelism, no second node
  • Native BF16, exactly as Deep Reinforce published it — no quantization, no calibration, nothing to audit
  • Measured 12.6 tok/s single-stream decode at a 2048-token prompt, warm, greedy
  • Dense, so every token reads 15.87 GB of weights — 73% of the 17.2 tok/s arithmetic ceiling at the Spark's published 273 GB/s
  • Full 262,144-token context served, at 1.49x concurrency on a 12 GiB pinned KV pool
  • Working set ~39 of 114 usable GiB — about three quarters of the machine stays free while serving the full window
  • Loads on stock vLLM 0.24.0 with no patches and no --language-model-only: all 333 vision-tower tensors are present
  • Hybrid attention: 24 of 32 layers are gated-delta linear, 8 are full attention — but those 8 keep 4 KV heads, so KV costs 32 KiB/token
  • No speculative draft available — the config advertises mtp_num_hidden_layers: 1 but the checkpoint ships zero MTP tensors
  • Decode falls gently with prompt length (12.65 / 12.61 / 12.50 / 12.00 / 10.34 / 8.86 tok/s at 0.5K / 2K / 8K / 32K / 131K / 250K) while TTFT climbs from 0.14 s to 109 s
  • Fast to bring up: 108 s of weight loading from cold NVMe, then 37 s of profiling, compile and warmup

Software requirements

  • 1 x DGX Spark (GB10, sm_121), NVIDIA driver >= 580 (CUDA-13 capable)
  • vLLM 0.24.0 with torch 2.11.0+cu130 (verified 2026-07-27). The architecture Qwen3_5ForConditionalGeneration is registered in vLLM 0.24.0
  • The `hf` CLI to fetch the checkpoint (~18.8 GB)
  • ~25 GB of free disk on the serving node

Quick start

  1. 1

    Free the node first

    Anything still holding unified memory — a previous vLLM, a leftover Ray cluster — comes out of the same pool. This model needs far less of it than most, but a leftover server will still stop the KV pin from being satisfiable.

    pgrep -af 'vllm serve|ray::' || echo 'node is clear'
    ray stop --force 2>/dev/null || true
    free -gbash
  2. 2

    Download the checkpoint

    4 safetensors shards, 18,819,627,488 bytes of weights. Only this node needs it — there is no second rank to mirror to.

    hf download deepreinforce-ai/Ornith-1.0-9B \
      --local-dir ~/models/hf/Ornith-1.0-9B
    
    # verify before serving
    ls ~/models/hf/Ornith-1.0-9B/*.safetensors | wc -l              # expect 4
    find ~/models/hf/Ornith-1.0-9B -name '*.incomplete' | wc -l      # expect 0bash

    A partial tree fails deep into load. Check the shard count and the incomplete count first — the `hf` CLI leaves *.incomplete files behind while it is still fetching, and a directory that already has the right total size can still be missing its last shard.

  3. 3

    Serve it

    Note what is NOT here: no --tensor-parallel-size, no --quantization, no --enforce-eager, no --speculative-config, no --language-model-only, and no vLLM patch. vLLM auto-selects the FLASH_ATTN attention backend and the Triton/FLA gated-delta prefill kernel on sm_121 and both work.

    PATH="$HOME/venvs/vllm/bin:$PATH" VLLM_USE_DEEP_GEMM=0 \
    TORCHINDUCTOR_COMPILE_THREADS=2 MAX_JOBS=4 \
    vllm serve ~/models/hf/Ornith-1.0-9B \
      --served-model-name ornith9b \
      --max-model-len 262144 \
      --kv-cache-memory-bytes 12884901888 \
      --gpu-memory-utilization 0.92 \
      --max-num-seqs 4 \
      --max-num-batched-tokens 8192 \
      --limit-mm-per-prompt '{"image":1,"video":0}' \
      --host 0.0.0.0 --port 8000 2>&1 | tee /tmp/vllm-ornith9b.logbash

    Budget about three minutes for the first boot: 108 s of weight loading from cold NVMe, 28 s of torch.compile, then 37 s of profiling and warmup. Subsequent boots with the tree in page cache and the compile cache warm are faster.

  4. 4

    Confirm what it actually chose

    Four lines tell you the run is healthy. If the KV pool is far off 390,070 tokens, re-read the memory section before benchmarking — the pool is the one number in this config that is genuinely tight relative to the window it has to serve.

    grep -E 'attention backend|GDN prefill|Model loading took|GPU KV cache size|Maximum concurrency' /tmp/vllm-ornith9b.logbash

    Expect: FLASH_ATTN, 'Using Triton/FLA GDN prefill kernel (requested=auto, head_k_dim=128)', 17.66 GiB, 390,070 tokens, 1.49x.

  5. 5

    Warm it up before you trust any number

    Weights fault in from NVMe on first touch. Send a few hundred tokens of real traffic after every restart, then measure. The tell that you are still cold is a 512-token prompt benchmarking slower than a 2048-token one; warm, ours run 12.65 and 12.61 tok/s respectively, and two consecutive harness passes agreed to within 0.04 tok/s.

    for i in 1 2 3 4; do
      curl -s http://127.0.0.1:8000/v1/chat/completions \
        -H 'Content-Type: application/json' \
        -d '{"model":"ornith9b",
             "messages":[{"role":"user","content":"Write an LRU cache in Python."}],
             "max_tokens":256,"temperature":0,
             "chat_template_kwargs":{"enable_thinking":false}}' > /dev/null
    donebash
  6. 6

    Decide whether you want the thinking preamble

    Ornith reasons by default. Its chat template opens every assistant turn with a <think> block, so a plain request explains its plan before it writes any code. Pass enable_thinking: false and the template emits an empty <think></think> pair instead, and the reply starts with the answer. All the numbers in this recipe were measured with thinking off.

    curl -s http://127.0.0.1:8000/v1/chat/completions \
      -H 'Content-Type: application/json' \
      -d '{"model":"ornith9b",
           "messages":[{"role":"user","content":"Write an LRU cache in Python."}],
           "max_tokens":256,"temperature":0,
           "chat_template_kwargs":{"enable_thinking":false}}' \
      | jq -r '.choices[0].message.content'bash
  7. 7

    Check the memory you actually used

    Read this with the API up and idle, never during weight loading — mid-load the KV pool does not exist yet and the figure flatters you by exactly the amount you are about to allocate.

    free -g
    # expect ~43 GiB used of 121 — weights 17.66 + KV 12 + overhead ~9.3 + OS ~4bash

Key vLLM parameters

ParameterValuePurpose
--kv-cache-memory-bytes12884901888 (12 GiB)Pins the KV pool deterministically instead of nudging the utilization fraction, which is unreliable on GB10 because vLLM's utilization math counts system-wide usage on a unified-memory box. 12 GiB buys a measured 390,070 tokens = 1.49x concurrency at the full 262,144 context. It also skips vLLM's startup free-memory check, so no page-cache purge is needed after the download.
--kv-cache-memory-bytes = 8 GiBnot enough here, despite the model being smallThis is the counter-intuitive part of the config and the number most likely to be copied wrongly from a bigger model on the same architecture family. KV cost has nothing to do with parameter count: 8 of the 32 layers are full attention and each keeps 4 KV heads at head_dim 256, so a token costs 8 x 2 x 4 x 256 x 2 = 32 KiB. The full 262,144-token window is therefore just over 8 GiB of cache on its own, and an 8 GiB pin leaves no margin — it lands at roughly 1.0x concurrency, where a few percent of boot-to-boot pool variation is the difference between starting and refusing to start. 12 GiB is the smallest round number that clears it with room.
--max-model-len262144The model's full native window, with the pool divided to 1.49x rather than left idle at a higher multiple. The window is genuinely usable: a single 261,224-token prompt served, and the decode and TTFT curve out to 250K is published above.
--max-num-seqs4Left at 4 rather than dropped to 1-2. There is no speculative draft here, so nothing derives a CUDA-graph capture size from it and it is a pure scheduler knob — but the KV it can claim is headroom we are not using anyway, so lowering it would buy nothing while risking the decode regression this flag causes on drafted configs.
--max-num-batched-tokens8192Sets the prefill chunk, which is what decides TTFT. Measured prefill climbs from 3,601 tok/s on a 511-token prompt to a peak of 4,430 tok/s at 8,208, then falls back through 4,169 at 32K and 3,061 at 131K to 2,303 tok/s at 250K as attention over the growing prefix starts to dominate. A 250,223-token prompt costs 109 s before the first token.
--limit-mm-per-prompt{"image":1,"video":0}This is a real multimodal checkpoint, and vLLM sizes its multimodal profiling and encoder cache from the per-modality limits. Capping video at 0 and images at 1 keeps that budget small for a text-serving deployment while leaving single-image prompts working. Raise it if you actually intend to send video.
--language-model-onlynot set (and must not be)Some checkpoints in this architecture family are language-only and need that flag plus a vLLM source patch to stop the vision tower being constructed. This is not one of them: the weight index carries all 333 visual.* tensors for the 27-block tower, 0.85 GiB of them. Passing the flag here would zero the multimodal input limits and, on a venv carrying that patch, skip building a module the checkpoint has weights for — which fails at load with 'There is no module or parameter named visual'.
--speculative-confignot set (none available)The config advertises mtp_num_hidden_layers: 1, but the weight index contains zero tensors matching 'mtp' and the layers stop at index 31 — the draft layer was not shipped. Do not pass a {"method":"mtp"} config expecting it to work. This is the config that would most benefit from a draft, since decode here is a pure bandwidth wall, so it is worth re-checking if Deep Reinforce ever publishes the head.
--quantizationnot set (this is the BF16 reference)Deliberately absent — the point of this recipe is the model exactly as published. It is also the honest baseline any 4-bit build of these weights should be measured against, and the one config where the bandwidth arithmetic is easy to check: a decode step reads 15.87 GB, so quartering the bytes is the whole lever.
--enforce-eagernot set (deliberately)Single node, so there is no cross-node CUDA-graph replay deadlock to avoid, and transients are nowhere near tight — three quarters of the machine is free. torch.compile took 28 s and the captured graphs are worth keeping.
--tensor-parallel-sizenot set (1 node)The working set is ~39 GiB on a 114 GiB node. Splitting it across two Sparks would add RoCE/NCCL coordination plus the per-node GID-index and Gloo-interface gotchas, in exchange for memory that is not remotely scarce. Tensor parallelism would raise aggregate bandwidth, which is what actually binds here — but a cross-node TP2 serve on this hardware needs --enforce-eager to avoid a cudagraph-replay deadlock, and that costs decode back. It was not measured for this recipe.
chat_template_kwargs.enable_thinkingfalse for every measurement hereNot a serve flag but a per-request one, and it changes what you measure. The chat template opens each assistant turn with '<think>' unless enable_thinking is false, in which case it emits an empty '<think>\n\n</think>' pair. With thinking on, a large share of the generated tokens are reasoning rather than answer, so a tok/s figure measured with it on is not comparable to one measured with it off.

API usage

Chat completion, answer first

curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "ornith9b",
    "messages": [{"role": "user", "content": "Explain gated-delta linear attention in three sentences."}],
    "max_tokens": 512,
    "chat_template_kwargs": {"enable_thinking": false}
  }' | jq -r '.choices[0].message.content'bash

Measure decode correctly

Count tokens from usage.completion_tokens, not by counting SSE frames. There is no speculative draft in this config so the two happen to agree here — but the habit is what keeps the measurement comparable against a drafted build, where frame counting under-reports by the acceptance length. The uuid prefix is what stops the prefix cache from serving the second run for free.

import json, time, urllib.request, uuid

body = {
    "model": "ornith9b",
    "messages": [{"role": "user",
                  "content": f"[{uuid.uuid4().hex[:8]}] Write an LRU cache in Python."}],
    "max_tokens": 256, "temperature": 0, "stream": True,
    "stream_options": {"include_usage": True}, "ignore_eos": True,
    "chat_template_kwargs": {"enable_thinking": False},
}
req = urllib.request.Request("http://127.0.0.1:8000/v1/chat/completions",
                             data=json.dumps(body).encode(),
                             headers={"Content-Type": "application/json"})
t0 = time.perf_counter(); t_first = None; usage = None
with urllib.request.urlopen(req) as res:
    for raw in res:
        line = raw.decode().strip()
        if not line.startswith("data: "):
            continue
        if line[6:] == "[DONE]":
            break
        ch = json.loads(line[6:])
        usage = ch.get("usage") or usage
        if ch.get("choices") and ch["choices"][0]["delta"].get("content") and not t_first:
            t_first = time.perf_counter()
t_end = time.perf_counter()
print(f"ttft {1000*(t_first-t0):.0f} ms")
print(f"decode {(usage['completion_tokens']-1)/(t_end-t_first):.1f} tok/s")
# expect ~12.6 tok/s warm at a short promptpython

Check the bandwidth arithmetic for yourself

This is the whole explanation for the decode number, and it takes two seconds because safetensors headers are JSON at the front of each shard — you never read a weight. Everything except the vision tower and the embedding gather is pulled on every decode step. The 273 GB/s is the DGX Spark's published LPDDR5X figure, not something measured here; the resulting ceiling is arithmetic.

import json, glob, struct, os

tot = {}
for f in sorted(glob.glob(os.path.expanduser(
        "~/models/hf/Ornith-1.0-9B/*.safetensors"))):
    with open(f, "rb") as fh:
        n = struct.unpack("<Q", fh.read(8))[0]
        header = json.loads(fh.read(n))
    for k, v in header.items():
        if k == "__metadata__":
            continue
        lo, hi = v["data_offsets"]
        if ".visual." in k or k.startswith("model.visual"):
            c = "vision_tower"          # only runs on image inputs
        elif "embed_tokens" in k:
            c = "embed_tokens"          # a gather, not a full read
        else:
            c = "read_every_token"
        tot[c] = tot.get(c, 0) + hi - lo

read = tot["read_every_token"]
print(f"bytes read per decode token: {read / 1e9:.2f} GB")
print(f"ceiling at 273 GB/s:         {273 / (read / 1e9):.1f} tok/s")
# bytes read per decode token: 15.87 GB
# ceiling at 273 GB/s:         17.2 tok/spython

Confirm the KV pool you actually got

The pool size varies a few percent between boots, so re-read it rather than assuming the number below. If it comes up smaller than --max-model-len the server refuses to start, which is the failure mode the 12 GiB pin is sized to avoid.

grep -E 'GPU KV cache size|Maximum concurrency' /tmp/vllm-ornith9b.log
# GPU KV cache size: 390,070 tokens
# Maximum concurrency for 262,144 tokens per request: 1.49xbash

Troubleshooting

Decode is only ~12.6 tok/s. A 9B model should be fast — something must be misconfigured.
Nothing is misconfigured; this is the model asking for bandwidth. Decode on a Spark is bandwidth-bound, and a dense model reads every weight on every token. Summed from the safetensors headers, a decode step here pulls 15.87 GB — the dense MLP is 9.00 GiB of it, the gated-delta linear-attention projections 3.01, the lm_head 1.90, the full-attention projections 0.88. Against the Spark's published 273 GB/s that is a 17.2 tok/s arithmetic ceiling, and the measured 12.6 is 73% of it, which is a normal fraction. Before blaming a kernel, run the byte-accounting snippet above: if your measurement is a healthy fraction of the ceiling, the only lever left is fewer bytes per token — a 4-bit build of these weights, or a sparse model that routes to a fraction of its parameters. Parameter count is the wrong number to reason from.
'To serve at least one request with the model's max seq len (262144), 8.xx GiB KV cache is needed, which is larger than the available KV cache memory'.
Your --kv-cache-memory-bytes is too small. This trips people because the model is small and the intuition is that its cache must be too — it is not. Eight of the thirty-two layers are full attention with 4 KV heads at head_dim 256, so a token costs 32 KiB and the full window costs just over 8 GiB. Use 12884901888 (12 GiB), which yields 390,070 tokens and 1.49x concurrency. Do not solve it by lowering --max-model-len: the message helpfully suggests a smaller window, but memory is nowhere near scarce on this node — three quarters of it is free — so giving up context to save cache is a straight loss.
Every answer starts by explaining its plan before writing any code.
That is the model reasoning, not a template bug. Ornith's chat_template.jinja opens the assistant turn with '<think>\n' unless the request passes enable_thinking: false, in which case it emits an empty '<think>\n\n</think>\n\n' pair and the answer starts immediately. Send it as "chat_template_kwargs": {"enable_thinking": false}. Decide this before you benchmark: with thinking on, most of the generated tokens are reasoning, so the throughput numbers are not comparable with the ones here.
'ValueError: There is no module or parameter named visual in Qwen3_5ForConditionalGeneration' at weight load.
You passed --language-model-only, or you are on a vLLM venv that carries a vision-tower guard patch for a different checkpoint on this architecture family. This checkpoint ships the tower — 333 visual.* tensors, 0.85 GiB — so the tower must be built. Drop the flag. The mirror-image failure, a pile of uninitialized visual.* parameters, is what you get when a language-only checkpoint on this family is served without the flag; the weight index is the only thing that settles which case you are in. Check it with: python3 -c "import json;w=json.load(open('model.safetensors.index.json'))['weight_map'];print(sum('visual' in k for k in w))" — greater than zero means no flag.
The log says 'Setting attention block size to N tokens to ensure that attention page size is >= mamba page size'.
Expected on this architecture, not a warning to act on. Twenty-four of the thirty-two layers are gated-delta linear attention carrying a per-sequence recurrent state, whose page has to fit alongside the full-attention pages — which forces an unusually large block size. It is part of why per-token KV lands where it does.
'Auto-prefetch is disabled because the filesystem (EXT4) is not a recognized network FS (NFS/Lustre)'.
Informational. vLLM only prefetches whole trees on network filesystems; on local NVMe it reads shards as it needs them. Loading took 108 s cold and there is nothing to tune here. Note also that you should NOT purge page cache before this serve: --kv-cache-memory-bytes skips vLLM's startup free-memory check entirely, so the purge buys nothing and only costs you a cold read.
A 250K-token prompt seems to hang.
It is prefilling. Measured TTFT at a 250,223-token prompt is 109 seconds, and prefill throughput has fallen to 2,303 tok/s by then from a peak of 4,430 at 8K. The context is real — a single 261,224-token request served and then decoded — but the first token on a very long prompt costs minutes, and that, not decode, is what decides whether this model is pleasant to use on a large repository. Raise your client timeout rather than shortening the window.
Ruled out: enabling speculative decoding to recover the decode speed.
There is no draft to enable. The config advertises mtp_num_hidden_layers: 1, but the weight index contains zero tensors matching 'mtp' and the language layers stop at index 31, so a {"method":"mtp"} speculative config has nothing to load. An ngram/prompt-lookup draft would at least initialize on this architecture — vLLM's gated-delta linear-attention path partitions the recurrent state for spec decode properly, so it does not hit the assertion that a bare linear-attention port would — but prompt-lookup only drafts when the output repeats the prompt, which is a net loss on the open-ended generation this benchmark measures. It was not measured here.
mit
Memory budget

This is the cheapest recipe on the site to fit and the most expensive per token, and both facts come from the same place: it is a dense model. vLLM logs 'Model loading took 17.66 GiB memory', so the weights take 15% of a Spark's 114 usable GiB and nothing about placement is difficult. What is not cheap is the KV. Only 8 of the 32 layers are full attention, but those 8 carry 4 KV heads at head_dim 256, which works out to 32 KiB per token — and a 262,144-token window therefore costs a little over 8 GiB of cache, more than the whole weight budget of some 4-bit builds. Pinning the pool at 12 GiB with --kv-cache-memory-bytes yields a measured 390,070 tokens, which is 1.49x concurrency at the full native window: above 1.0x with margin for the few-percent variation between boots, without paying for concurrency a single user cannot spend. Steady-state `free -g` with the API up and idle reads 43 GiB used of 121; subtracting the ~4 GiB idle OS baseline leaves ~39 GiB of serving working set against the 114 GiB ceiling, so roughly 75 GiB of the machine stays free while the full context is served. The 17.66 GiB weights figure and the 390,070-token KV figure are read from the engine log. The overhead segment is back-computed from the steady-state total and is the only estimated number here.