Deep Reinforce's agentic-coding Ornith 1.0 35B (35B MoE, ~3B active) unquantized in BF16 on one DGX Spark, serving the full 262,144-token context at 30.3 tok/s single-stream.
* 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 | 29.3 | 29.2 | 27.4 | 22.1 |
| ttft | 491ms | 1.59s | 7.02s | 42.97s |
| prefill tok/s | 4.1k | 5.1k | 4.7k | 3.0k |
| power | 33W | 37W | 38W | 38W |
includes 1 community run · @kvncrw
median per context · o256
Contributors
Overview
Ornith 1.0 35B is Deep Reinforce's agentic-coding model, post-trained on the Qwen3.5 35B-A3B MoE stack: 256 routed experts at top-8, roughly 3B active parameters per token, 30 of its 40 layers gated-delta linear attention and 10 full attention, a 262,144-token native window, and MIT licensed. At 70.2 GB of BF16 it lands on a single Spark with room to spare, which is the reason to run it unquantized — you get the model exactly as it was published, with no quantization artifact to audit and no calibration set to defend. It is a genuinely multimodal release: the checkpoint carries all 333 tensors of a 27-block vision tower alongside the language model, so it loads on stock vLLM with no patching, which is not true of every checkpoint that declares this architecture. The price of BF16 is decode speed. Every generated token re-reads roughly 3B active parameters at two bytes each, and decode on the Spark is bandwidth-bound, so the measured 30.3 tok/s single-stream is what the memory bus allows; a 4-bit expert build is the lever if you want it faster, and this recipe is the honest BF16 reference such a build would be measured against. Two things are worth knowing before you copy the serve command. First, there is no free speculative draft: the config advertises mtp_num_hidden_layers: 1, but model.safetensors.index.json contains 31,666 tensors and not one of them matches 'mtp' — the language layers run 0 to 39 with nothing at index 40, where a native MTP layer would sit. Second, memory is not the binding constraint but it is not free either. The weights alone take 65.53 GiB of a 114 GiB budget, so unlike a 4-bit build of the same architecture you do not get to be careless with the KV pool: an 8 GiB pin gives 1.58x concurrency at the full context, and a 4 GiB pin — which is ample for a 4-bit sibling — cannot fit a single full-length request. Long prompts are served but they are not fast to start: the largest request the window admits, 261,224 prompt tokens, took 122 seconds before its first token and then decoded at 18.1 tok/s. The context claim here is measured rather than inherited — that request is in the committed benchmark rows — and it is the TTFT curve, not decode, that decides whether this model is pleasant to use on a large repository.
- 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 30.3 tok/s single-stream decode at a 2048-token prompt, warm, greedy
- Full 262,144-token context served, at 1.58x concurrency on an 8 GiB pinned KV pool
- Loads on stock vLLM 0.24.0 with no patches and no --language-model-only: the vision tower's weights are all present
- Hybrid attention: 30 of 40 layers are gated-delta linear, 10 are full attention — which is why a 262K window costs only 8 GiB of pinned KV
- 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 (30.4 / 30.3 / 29.8 / 28.0 / 22.5 / 18.3 tok/s at 0.5K / 2K / 8K / 32K / 131K / 250K) while TTFT climbs from 0.3 s to 114 s
- The claimed context is proven, not assumed: a single 261,224-token prompt served at 18.1 tok/s decode after 122 s of prefill
- Working set ~82 of 114 usable GiB; weights alone are 65.53 GiB
- Weight load ~440 s cold from NVMe, then ~78 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_5MoeForConditionalGeneration is also registered in vLLM 0.25.1 and 0.26.0
- The `hf` CLI to fetch the checkpoint (~70.3 GB)
- ~75 GB of free disk on the serving node
Quick start
- 1
Free the node first
Anything still holding unified memory — a previous vLLM, a leftover Ray cluster — comes out of the same pool, and this checkpoint alone needs 65.53 GiB of it.
pgrep -af 'vllm serve|ray::' || echo 'node is clear' ray stop --force 2>/dev/null || true free -gbash - 2
Download the checkpoint
16 safetensors shards, 70,250,411,008 bytes. Only this node needs it — there is no second rank to mirror to.
hf download deepreinforce-ai/Ornith-1.0-35B \ --local-dir ~/models/hf/Ornith-1.0-35B # verify before serving ls ~/models/hf/Ornith-1.0-35B/*.safetensors | wc -l # expect 16 find ~/models/hf/Ornith-1.0-35B -name '*.incomplete' | wc -l # expect 0 du -sb ~/models/hf/Ornith-1.0-35B # expect 70250411008bashA partial tree fails deep into load, several minutes in. Check the shard count and the incomplete count first.
- 3
Serve it
Note what is NOT here: no --tensor-parallel-size, no --moe-backend, no --enforce-eager, no --speculative-config, no quantization flag, and no vLLM patch. vLLM auto-selects the FlashInfer CUTLASS unquantized MoE backend and the FLASH_ATTN attention backend 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-35B \ --served-model-name ornith \ --max-model-len 262144 \ --kv-cache-memory-bytes 8589934592 \ --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-ornith.logbashBudget about 9 minutes for the first boot: 440 s of weight loading from cold NVMe plus 78 s of profiling, torch.compile and warmup. Subsequent boots with the tree in page cache are much faster.
- 4
Confirm what it actually chose
Five lines tell you the run is healthy. If the KV pool is far off 412,980 tokens, or the MoE backend line says something other than FlashInfer CUTLASS, read the troubleshooting section before benchmarking.
grep -E 'MoE backend|attention backend|Model loading took|GPU KV cache size|Maximum concurrency' /tmp/vllm-ornith.logbashExpect: FlashInfer CUTLASS Unquantized MoE, FLASH_ATTN, 65.53 GiB, 412,980 tokens, 1.58x.
- 5
Warm it up before you trust any number
Expert planes 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 30.4 and 30.3 tok/s respectively.
for i in 1 2 3; do curl -s http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"ornith", "messages":[{"role":"user","content":"Write an LRU cache in Python."}], "max_tokens":256,"temperature":0}' > /dev/null donebash - 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 answers with 'Here's a thinking process: 1. Understand User Request...' 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":"ornith", "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
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 ~86 GiB used of 121 — weights 65.53 + KV 8 + overhead ~8.5 + OS ~4bash
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --kv-cache-memory-bytes | 8589934592 (8 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. 8 GiB buys a measured 412,980 tokens = 1.58x 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 = 4 GiB | do not copy it from a 4-bit recipe | 4 GiB is the value that works for 4-bit builds of this architecture and it does not transfer to BF16 — the full 262,144-token window costs a little over 5 GiB of KV here, so the engine refuses to start and tells you the largest model length your pool can serve instead. 8 GiB is the smallest round number that clears it with margin for the few-percent pool variation between boots. |
| --max-model-len | 262144 | The model's full native window, with the pool divided to 1.58x rather than left idle at a higher multiple. Do not inherit a smaller value from another config — on this architecture only 10 of 40 layers hold KV, so context is cheap even at BF16. |
| --max-num-seqs | 4 | Left 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-tokens | 8192 | Sets the prefill chunk, which is what decides TTFT. Measured prefill climbs from 1,637 tok/s on a 511-token prompt to 4,975 tok/s on an 8,206-token one, peaks near 4,608 tok/s at 32K, and then falls back to 2,195 tok/s at 250K as attention over the growing prefix starts to dominate. |
| --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-only | not set (and must not be) | Some checkpoints on this architecture 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: model.safetensors.index.json carries all 333 visual.* tensors for the 27-block tower. Passing the flag here would zero the multimodal input limits and throw away a capability the weights paid for. |
| --speculative-config | not set (none available) | The config advertises mtp_num_hidden_layers: 1, but the weight index contains zero tensors matching 'mtp' and the language layers stop at index 39 — the draft layer was not shipped. Do not pass a {"method":"mtp"} config expecting it to work. An ngram/prompt-lookup draft would at least initialize on this architecture, since vLLM's gated-delta linear-attention path partitions the recurrent state for spec decode properly, but it only drafts when the output repeats the prompt, which is a net loss on open-ended generation. |
| --moe-backend | not set (deliberately) | Leave it alone. vLLM auto-selects 'FlashInfer CUTLASS Unquantized MoE' out of ['FlashInfer TRTLLM', 'FlashInfer CUTLASS', 'TRITON', 'BATCHED_TRITON'] on sm_121 and it serves correctly. The marlin-only advice in older DGX Spark write-ups is about NVFP4 checkpoints on a different vLLM build and does not apply to a BF16 MoE. |
| --enforce-eager | not set (deliberately) | Single node, so there is no cross-node CUDA-graph replay deadlock to avoid, and transients are not tight — profiling, KV-cache creation and warmup took 78 s total including 31 s of torch.compile. The captured graphs are worth keeping. |
| --tensor-parallel-size | not set (1 node) | The working set is ~82 GiB, which fits one Spark with ~32 GiB to spare. Splitting it across two would add RoCE/NCCL coordination plus the per-node GID-index and Gloo-interface gotchas, in exchange for memory that is not scarce. |
| chat_template_kwargs.enable_thinking | false for every measurement here | Not 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": "ornith",
"messages": [{"role": "user", "content": "Explain MoE routing in three sentences."}],
"max_tokens": 512,
"chat_template_kwargs": {"enable_thinking": false}
}' | jq -r '.choices[0].message.content'bashMeasure 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 of the same model, where frame counting under-reports by the acceptance length.
import json, time, urllib.request, uuid
body = {
"model": "ornith",
"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")pythonConfirm 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 8 GiB pin is sized to avoid.
grep -E 'GPU KV cache size|Maximum concurrency' /tmp/vllm-ornith.log
# GPU KV cache size: 412,980 tokens
# Maximum concurrency for 262,144 tokens per request: 1.58xbashTroubleshooting
- Every answer starts with 'Here's a thinking process: 1. Understand User Request...' before 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.
- 'To serve at least one request with the model's max seq len (262144), 5.xx GiB KV cache is needed, which is larger than the available KV cache memory (4.0 GiB).'
- Your --kv-cache-memory-bytes is too small for BF16. A 4 GiB pool is enough for a 4-bit build of this architecture but not for this one. Use 8589934592 (8 GiB), which yields 412,980 tokens and 1.58x concurrency. Do not solve it by lowering --max-model-len; the message helpfully suggests a smaller window, but memory is not scarce enough on this node to give up a quarter of the context for it.
- The log says 'Setting attention block size to 1056 tokens to ensure that attention page size is >= mamba page size'.
- Expected on this architecture, not a warning to act on. Thirty of the forty language layers are gated-delta linear attention carrying a per-sequence recurrent state, whose page has to fit alongside the full-attention pages — which forces the 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) and the checkpoint size (65.40 GiB) exceeds 90% of available RAM'.
- Informational. It means vLLM will not try to prefetch the whole tree into page cache before loading it, which is correct here — the checkpoint is over half the machine's memory. It is also why the first load takes 440 s: the shards are read from NVMe as they are needed. A second boot with the tree still in page cache is much faster. Do not attempt to fix this by purging the cache; --kv-cache-memory-bytes already skips the free-memory check that a purge exists to satisfy, so a purge only costs you a full re-read.
- Decode is only ~30 tok/s. Is a kernel misconfigured?
- No — that is what BF16 costs on this hardware. Decode on the Spark is bandwidth-bound and each generated token re-reads roughly 3B active parameters at two bytes each. The measured 30.3 tok/s at a 2048-token prompt is a normal fraction of the resulting roofline, and the backends chosen (FlashInfer CUTLASS unquantized MoE, FLASH_ATTN) are the right ones. If you want this model faster, the lever is a 4-bit expert quantization, not a flag — and be aware that on a 92%-expert MoE like this one a quant that leaves attention unquantized buys much less decode than its file-size reduction suggests, because unquantized attention can still dominate per-token bytes read.
- Can I turn on speculative decoding? The config says mtp_num_hidden_layers: 1.
- No draft ships with this checkpoint. The config advertises an MTP layer but model.safetensors.index.json holds 31,666 tensors and not one of them matches 'mtp' — the language layers run 0-39 with nothing at index 40, where a native MTP layer would sit. Passing '{"method":"mtp"}' will not find weights to load. Always grep the index, not just the config; the config is a declaration of intent and the index is the fact.
- A 200K-token prompt takes minutes before the first token appears.
- Expected, and it is the number that decides whether this model is usable on a large repository. Measured TTFT on one Spark: 0.31 s at 511 tokens, 0.54 s at 2,042, 1.65 s at 8,206, 7.1 s at 32,778, 42.9 s at 131,290, 114.0 s at 250,223 and 122.0 s at 261,224 — the largest prompt the window admits, which does serve, at 18.1 tok/s decode. Decode barely moves over the same sweep (30.4 down to 18.1 tok/s), so the long-context cost is almost entirely prefill. Raising --max-num-batched-tokens above 8192 trades startup transients for a larger prefill chunk if you want to push on it; the ceiling is the ~4,975 tok/s prefill rate this config reaches around an 8K prompt.
- Engine core initialization failed with a list of uninitialized visual.* parameters.
- You are not serving this checkpoint. Several models register the same Qwen3_5MoeForConditionalGeneration architecture as language-only releases that still carry a vision_config, and the class builds its 27-block vision tower unconditionally — so those fail on ~113 uninitialized visual.* weights and need both --language-model-only and a source patch to vllm/model_executor/models/qwen3_5.py. Ornith 1.0 35B is not affected: its index contains all 333 visual.* tensors. If you see this error, check `python3 -c "import json;print(sum('visual' in k for k in json.load(open('model.safetensors.index.json'))['weight_map']))"` on the tree you are actually pointing at.
- Should I serve this on two Sparks with TP2 instead?
- There is no reason to. The whole working set is ~82 of 114 usable GiB on one node, so tensor parallelism would buy memory that is not scarce while adding the RoCE fabric setup, the per-node NCCL_IB_GID_INDEX resolution, the Gloo interface binding and the cross-node CUDA-graph deadlock that forces --enforce-eager. Keep it single-node — and use the second Spark to sweep flags in parallel instead.
Memory budget
This recipe serves the model at its native precision — no quantization step, no calibration, no self-built artifact — so the memory shape is decided entirely by the checkpoint. It is 70,250,411,008 bytes of BF16 safetensors and vLLM logs 'Model loading took 65.53 GiB memory', which is 57% of a Spark's 114 usable GiB gone before any KV exists. What keeps that affordable is the architecture: full_attention_interval is 4, so only 10 of the 40 language layers hold a real KV cache, and those carry 2 KV heads at head_dim 256. Pinning the pool at 8 GiB with --kv-cache-memory-bytes yields a measured 412,980 tokens — 1.58x concurrency at the model's full native 262,144-token context, which is above 1.0x with margin for the few-percent pool variation between boots without paying for concurrency a single user cannot spend. Steady-state `free -g` with the API up and idle reads 86 GiB used of 121; subtracting the ~4 GiB idle OS baseline leaves ~82 GiB of serving working set against the 114 GiB ceiling, so about 32 GiB stays free. The 65.53 GiB weights figure and the 412,980-token KV figure are measured from the engine log. The split of those 65.53 GiB between segments is arithmetic from the config's own shapes rather than a per-tensor measurement, and the overhead segment is back-computed from the steady-state total.