Deep Reinforce's agentic-coding Ornith 1.0 35B (35B MoE, ~3B active) in vendor FP8 on one DGX Spark, serving the full 262,144-token context at 37.7 tok/s single-stream.
* 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
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. This recipe serves the vendor's own FP8 export of it, which is the configuration to reach for first if you want the model fast rather than bit-exact. It is a W8A8 compressed-tensors cut with per-channel weight scales and dynamic per-token activation scales, and what matters about it is not the file size but the coverage: the ignore list is only six patterns long, so both halves of the per-token read path get compressed — the 256 routed experts that are 92% of the parameters, and the q/k/v/o projections on the ten full-attention layers. That is why the speedup is real rather than cosmetic. Decode on the Spark is bandwidth-bound, and a quantization that halves the bytes a token reads shows up directly in tokens per second: measured 37.7 tok/s single-stream at a 2048-token prompt, against 30.3 for the same model, same node and same flags in BF16. Prefill gains too, by a wider margin — 5,096 tok/s at 2048 against 3,746 — because prefill is compute-bound and the FP8 tensor cores are doing that arithmetic. Memory is the other half of the story and it is arguably the bigger one. The weights land at 34.87 GiB where BF16 needs 65.53, so with an 8 GiB KV pin the whole serving working set is about 51 of 114 usable GiB. More than half the machine is free while serving the full context, which is what makes this the version to run if you also want a draft model resident, a second service on the box, or simply headroom you do not have to think about. Two caveats before you copy the serve command. There is no speculative draft: the config advertises mtp_num_hidden_layers: 1, but the weight index holds 62,546 tensors and not one matches 'mtp'. And this is a genuinely multimodal release whose vision tower is left in BF16 by the quantization — all 333 visual.* tensors are present, so it loads on stock vLLM with no patching and must not be served with --language-model-only.
- 1 x DGX Spark (GB10, sm_121) — no tensor parallelism, no second node
- Measured 37.7 tok/s single-stream decode at a 2048-token prompt, warm, greedy
- Weights 34.87 GiB against 65.53 GiB for the same model in BF16 — the working set is ~51 of 114 usable GiB, so over half the Spark stays free
- Full 262,144-token context served, at 1.58x concurrency on an 8 GiB pinned KV pool
- The claimed context is proven, not assumed: a single 261,224-token prompt served at 20.9 tok/s decode after 113 s of prefill
- The vendor's own FP8 export: W8A8 compressed-tensors, per-channel weights, dynamic per-token activations, only six ignore patterns
- Both halves of the per-token read path are quantized — the 256 routed experts AND the full-attention q/k/v/o — which is why decode moves and not just the file size
- Prefill gains more than decode: 5,096 tok/s at a 2048-token prompt, 6,062 at 8K
- Decode falls gently with prompt length (37.9 / 37.7 / 36.9 / 34.3 / 26.5 / 21.0 tok/s at 0.5K / 2K / 8K / 32K / 131K / 250K) while TTFT climbs from 0.19 s to 106 s
- Loads on stock vLLM 0.24.0 with no patches and no --language-model-only: all 333 vision-tower tensors are present and left in BF16
- vLLM picks the TRITON FP8 MoE backend on sm_121 — the FlashInfer FP8 paths are offered but not selected
- No speculative draft available — the config advertises mtp_num_hidden_layers: 1 but the checkpoint ships zero MTP tensors
- Weight load ~233 s cold from NVMe, then ~41 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 (last verified 2026-07-27 on 0.24.0). The architecture Qwen3_5MoeForConditionalGeneration is also registered in vLLM 0.25.1 and 0.26.0, but the FP8 MoE backend menu differs between releases on sm_121 — re-measure rather than assuming
- The `hf` CLI to fetch the checkpoint (~37.7 GB)
- ~45 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.
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, 37,710,886,135 bytes. Only this node needs it — there is no second rank to mirror to.
hf download deepreinforce-ai/Ornith-1.0-35B-FP8 \ --local-dir ~/models/hf/Ornith-1.0-35B-FP8 # verify before serving ls ~/models/hf/Ornith-1.0-35B-FP8/*.safetensors | wc -l # expect 16 find ~/models/hf/Ornith-1.0-35B-FP8 -name '*.incomplete' | wc -l # expect 0 du -sb ~/models/hf/Ornith-1.0-35B-FP8 # expect 37710886135bashA 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. The checkpoint's own quantization_config tells vLLM it is compressed-tensors FP8; vLLM then auto-selects the TRITON FP8 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-FP8 \ --served-model-name ornith-fp8 \ --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-fp8.logbashBudget about 4.5 minutes for the first boot: 233 s of weight loading from cold NVMe plus 41 s of profiling, torch.compile and warmup.
- 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 weights figure is not near 34.87 GiB, read the troubleshooting section before benchmarking.
grep -E 'Fp8 MoE backend|attention backend|Model loading took|GPU KV cache size|Maximum concurrency' /tmp/vllm-ornith-fp8.logbashExpect: TRITON Fp8 MoE, FLASH_ATTN, 34.87 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 37.9 and 37.7 tok/s respectively.
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":"ornith-fp8", "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-fp8", "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 ~55 GiB used of 121 — weights 34.87 + KV 8 + overhead ~8 + OS ~4bash
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| --quantization | not set (and must not be) | The checkpoint carries its own quantization_config (quant_method: compressed-tensors, format: float-quantized), so vLLM detects FP8 from the weights and configures the loader itself. Forcing --quantization fp8 instead asks vLLM to quantize a checkpoint that is already compressed. |
| --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. Note that quantizing the weights does not shrink this number: the cache is unquantized (kv_cache_scheme: null) and per-token KV is set by the architecture, not the weight precision. |
| --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. |
| --gpu-memory-utilization | 0.92 | Mostly inert here because --kv-cache-memory-bytes takes over the sizing decision, and generous by design: the working set is ~51 of 114 usable GiB, so this config is nowhere near the ceiling it describes. Left at the same value as the BF16 sibling config so the two are directly comparable. |
| --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 2,618 tok/s on a 511-token prompt to 5,096 at 2,042 and 6,062 at 8,208 as the chunk fills, then falls back on very long prompts as attention over the growing prefix dominates. |
| --limit-mm-per-prompt | {"image":1,"video":0} | This is a real multimodal checkpoint — the quantization leaves the whole vision tower in BF16 (ignore pattern re:model\.visual\..*) — 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. |
| --language-model-only | not set (and must not be) | Some checkpoints on this architecture are language-only releases that still declare a vision_config, and they need that flag plus a vLLM source patch to stop the tower being constructed. This is not one of them: model.safetensors.index.json carries all 333 visual.* tensors. 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 62,546 tensors and zero matching 'mtp' — the draft layer was not shipped. There is real memory here to host an external draft if one ever appears (the working set leaves ~63 GiB free), but nothing to load today. |
| --moe-backend | not set (deliberately) | Leave it alone. vLLM auto-selects 'TRITON' for the FP8 MoE out of ['AITER', 'FLASHINFER_TRTLLM', 'FLASHINFER_CUTLASS', 'VLLM_CUTLASS', 'TRITON', 'MARLIN', ...] on sm_121, and it serves correctly at the throughput published here. Note that this is a different choice from what the same engine makes for the BF16 build of this model, where the unquantized path picks FlashInfer CUTLASS — so backend advice does not transfer between precisions of one checkpoint. |
| VLLM_USE_DEEP_GEMM | 0 | DeepGEMM's FP8 paths are the usual source of block-scale trouble on GB10, and this checkpoint uses per-channel rather than block scales, so there is nothing to gain by leaving it on. Set to 0 for the measurements here. |
| --enforce-eager | not set (deliberately) | Single node, so there is no cross-node CUDA-graph replay deadlock to avoid, and transients are nowhere near tight — profiling, KV-cache creation and warmup took 41 s total. The captured graphs are worth keeping. |
| --tensor-parallel-size | not set (1 node) | The working set is ~51 GiB, which fits one Spark with over 60 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 emphatically 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-fp8",
"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-fp8",
"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 checkpoint is really FP8, and what it left alone
The ignore list is the single most informative thing about a vendor quant — it tells you which parts of the per-token read path were NOT compressed, which is what bounds the decode gain.
python3 -c "import json; q=json.load(open('$HOME/models/hf/Ornith-1.0-35B-FP8/config.json'))['quantization_config']; print(q['quant_method'], q['config_groups']['config_group_0']['format'] if 'format' in q['config_groups']['config_group_0'] else q['format']); print('\n'.join(q['ignore']))"
# compressed-tensors float-quantized
# lm_head
# re:.*embed_tokens.*
# re:.*\.mlp\.gate$
# re:.*shared_expert_gate$
# re:.*linear_attn.*
# re:model\.visual\..*bashTroubleshooting
- 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.
- The MoE backend line says TRITON, not FlashInfer. Did something fall back?
- No — that is the correct auto-selection for this checkpoint on sm_121 and it is the configuration all the numbers here were measured on. vLLM 0.24.0 logs 'Using TRITON Fp8 MoE backend out of potential backends: [AITER, FLASHINFER_TRTLLM, FLASHINFER_CUTLASS, VLLM_CUTLASS, TRITON, MARLIN, BATCHED_VLLM_CUTLASS, BATCHED_TRITON, XPU, CPU]'. Do not override it with --moe-backend on the strength of advice written for a different precision: the unquantized build of this same model picks FlashInfer CUTLASS instead, and neither choice transfers.
- '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. The intuition that a smaller checkpoint should need a smaller KV pin is wrong: this quantization does not touch the cache (kv_cache_scheme is null) and per-token KV is a function of the architecture, so the full 262,144-token window costs the same little over 5 GiB here as it does in BF16. Use 8589934592 (8 GiB), which yields 412,980 tokens and 1.58x concurrency. Do not solve it by lowering --max-model-len; there is over 60 GiB free on this config, so giving up a quarter of the context would buy nothing.
- 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.
- 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. This FP8 export is not affected: its index contains all 333 visual.* tensors, left in BF16 by the quantization. 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.
- 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 62,546 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.
- How much of the speedup over the unquantized build is real, and where does it come from?
- Measured on the same node with identical flags, warm, greedy, thinking off, at a 2048-token prompt: decode 30.25 tok/s unquantized versus 37.70 here (+24.6%), prefill 3,746 versus 5,096 tok/s (+36%). The decode gain is a bandwidth effect — the routed experts are 92% of the parameters and this quantization compresses them, so a token reads roughly half as many bytes. It is not the full 2x because the 30 gated-delta linear-attention layers, the router gates, the embeddings and lm_head are all on the ignore list and stay BF16. The larger prefill gain is a compute effect: prefill is arithmetic-bound and FP8 tensor cores do that arithmetic faster. If a vendor quant's ignore list had covered only the experts and left full attention in BF16, the decode number would move far less — check the list before predicting the gain.
- 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.19 s at 511 tokens, 0.40 s at 2,042, 1.35 s at 8,208, 6.0 s at 32,780, 38.3 s at 131,292 and 105.7 s at 250,223. Decode over the same sweep falls from 37.9 to 21.0 tok/s, so the long-context cost is almost entirely prefill. Two things worth knowing: the quantization helps prefill at length less than it helps a short prompt (2,367 tok/s at 250K against 6,062 at 8K, because attention over the growing prefix comes to dominate), and the KV cache is not quantized at all, so the decode advantage over an unquantized serve also shrinks as the prompt grows. Raising --max-num-batched-tokens above 8192 trades startup transients for a larger prefill chunk if you want to push on it.
- I have 60 GiB free while serving. What should I spend it on?
- Not more KV — the pool is already at 1.58x concurrency for a single user, and multiples above 1.0x are context you paid for and cannot use. The honest answers are: a resident speculative draft if one is ever published for this model (none exists today), a second service on the same box, or nothing at all. Headroom on a unified-memory machine is not waste; an overcommit here does not OOM the process, it swap-wedges the whole node.
- Should I serve this on two Sparks with TP2 instead?
- There is no reason to. The whole working set is ~51 of 114 usable GiB on one node. 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 is the vendor's own FP8 export of the checkpoint, so the memory shape is decided by what their quantization config chose to compress. It is a W8A8 compressed-tensors cut — per-channel symmetric FP8 weights with dynamic per-token FP8 activations — applied to every Linear except six ignore patterns: lm_head, the embeddings, the MoE router gates, the shared-expert gates, every gated-delta linear-attention projection, and the whole vision tower. vLLM logs 'Model loading took 34.87 GiB memory', against 65.53 GiB for the same model in BF16, so the quantization gives back 30.7 GiB of a 114 GiB budget. The KV pool is unaffected by the weight precision and is pinned identically at 8 GiB with --kv-cache-memory-bytes, yielding a measured 412,980 tokens — 1.58x concurrency at the model's full native 262,144-token context. That figure is not a coincidence: only 10 of the 40 language layers hold a real KV cache (full_attention_interval is 4) and those carry 2 KV heads at head_dim 256, so per-token KV is a property of the architecture rather than of the weight quantization. Steady-state `free -g` with the API up and idle reads 55 GiB used of 121; subtracting the ~4 GiB idle OS baseline leaves ~51 GiB of serving working set against the 114 GiB ceiling, so roughly 63 GiB stays free — more than half the machine. The 34.87 GiB weights figure and the 412,980-token KV figure are measured from the engine log, and the overhead segment is back-computed from the steady-state total. The split of the weights between the expert planes and everything else is arithmetic from the config's own shapes rather than a per-tensor measurement.