BAAI's AREX-Base deep-research agent (122B MoE, ~10B active) in Q4_K_M on one DGX Spark — 23.98 tok/s single-stream, at a measured 250,223-token context.
* segment sizes marked with an asterisk are estimates pending a measured run
Eval scores
(compare all)Bench card
| 2K | |
|---|---|
| decode tok/s | 45.0 |
| ttft | 120ms |
| prefill tok/s | 800 |
| power | — |
includes 1 community run · @Marinara_Waif
median per context · o256
Contributors
Overview
AREX-Base is BAAI's deep-research agent built on the Qwen3.5-122B-A10B stack — a 256-expert MoE routing top-8, with 36 of its 48 layers running gated-delta linear attention and only every fourth layer keeping full attention. At BF16 it is 245.1 GB and needs more memory than two Sparks have; at Q4_K_M it is 74.9 GB and needs one, which is the whole reason this recipe is a llama.cpp recipe rather than a vLLM one. No FP8 or NVFP4 build of this checkpoint exists on the Hub, and quantizing 245 GB ourselves is a day's work, so the fastest honest route to a running 122B model on a single Spark is the community GGUF. What you get is a model that decodes at 23.98 tok/s single-stream at a 2,048-token prompt and holds that rate over any prompt length you would call ordinary — 23.99 at 512 tokens, 23.98 at 2,048, 23.66 at 8,192 — because 36 of 48 layers have no growing cache to re-read. It is not flat forever: decode falls to 17.40 tok/s at a 131,292-token prompt and 13.91 at 250,223, i.e. 42% off the short-prompt rate at the top of the window, which is the twelve full-attention layers finally making themselves felt. The short-prompt number is a healthy fraction of what the hardware allows: roughly 10.2B parameters are active per token (6.6B of always-read layers plus 8/256 of the 115.9B expert plane), which at the file's average 4.9 bits per parameter is about 6.2 GB read per token, so GB10's 273 GB/s caps this configuration near 44 tok/s and the measurement sits at 54% of that. The real cost of a long prompt is not decode, it is prefill: at 483–704 tok/s it barely improves with length, so time-to-first-token runs 1.3 s at 512 tokens, 3.4 s at 2,048, 12.3 s at 8,192, **229 s at 131,292 and 521 s at 250,223**. A 128K-token research prompt means nearly four minutes of silence before the first token; a quarter-million-token one means nearly nine. That — not throughput — is what to design around on this model. The server starts at the model's full 262,144-token window and the largest request actually sent in this session was 250,223 tokens, which is the number published as the context; it completed normally with the node at 93.5 GB. Two things are deliberately not claimed here. There is no speculative decoding: `config.json` advertises `mtp_num_hidden_layers: 1` but the released weights contain zero `mtp.*` tensors, so there is no in-checkpoint draft to enable — and the obvious external candidate has now been measured and rejected: the vendor's dense 4.5B sibling AREX-Turbo, quantized to Q4_K_M and served as a draft, decodes 15–20% *slower* than no draft at every k from 1 to 3, even at k=1 where every single drafted token was accepted. The reason is in the architecture rather than the draft: a recurrent linear-attention state cannot be partially rolled back, so the engine checkpoints and restores it around every speculative step, and that costs about as much as the extra token buys. The flag list below records the numbers. And this is a throughput recipe only — no eval was run against this quantization, so treat Q4_K_M's effect on the model's research and tool-use quality as unmeasured.
- 23.98 tok/s single-stream at a 2,048-token prompt, warm — a 122B model on one Spark
- Decode holds up: 23.99 / 23.98 / 23.66 tok/s at 512 / 2,048 / 8,192-token prompts, 17.40 at 131,292, 13.91 at 250,223
- A 250,223-token prompt served and measured — the server comes up at the model's full 262,144 window with no tuning
- 74.9 GB of GGUF against 245.1 GB of BF16, which is what makes one node enough
- ~78 GiB serving working set of 114 usable — about 36 GiB of headroom left over
- KV costs only 24 KiB/token: 36 of 48 layers are linear attention and hold no growing cache
- TTFT is the real long-context cost: 1.3 s at 512 tokens, 12.3 s at 8,192, 229 s at 131,292, 521 s at 250,223
- No speculative decoding: the config advertises an MTP head the weights do not contain
- A reasoning model — replies arrive in `reasoning_content`, and `content` is empty until it stops thinking
- Runs on one Spark; the second node is free for something else
Software requirements
- 1 × DGX Spark (GB10, sm_121), NVIDIA driver ≥ 580 (CUDA-13 capable)
- llama.cpp built with `GGML_CUDA=ON` against CUDA 13 — verified on build `ee445f9` (server reports `version: 15`), which already registers the `qwen35moe` architecture
- No patches, no out-of-tree code, no custom kernels
- ~75 GB of free disk for the two GGUF shards, plus the `hf` CLI to fetch them
- Last verified 2026-07-28 on llama.cpp ee445f9 (decode re-measured on a second node; a sibling draft model was measured and rejected)
Quick start
- 1
Free the node first
On GB10 the weights, KV and compute buffers all come out of one 121 GiB unified pool, so a leftover server is not untidiness — it changes what will fit.
pkill -f "[v]llm serve" # the [v] matters: pkill -f "vllm serve" kills your own SSH command pkill -f "[l]lama-server" ray stop --force 2>/dev/null free -g | sed -n 2p # want ~115 GiB available before you startbashDo not purge page cache here. This model is loaded once and read from mmap; a warm page cache is what makes a second boot fast.
- 2
Build llama.cpp with CUDA
The architecture tag you need is `qwen35moe`. It is present in llama.cpp from well before this recipe was written, but an old binary predating it will refuse the file outright, so build from a recent checkout rather than reusing whatever is on the box.
git clone https://github.com/ggml-org/llama.cpp ~/Dev/llama.cpp cmake -S ~/Dev/llama.cpp -B ~/Dev/llama.cpp/build \ -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release cmake --build ~/Dev/llama.cpp/build --config Release -j $(nproc) ~/Dev/llama.cpp/build/bin/llama-server --version ldd ~/Dev/llama.cpp/build/bin/llama-server | grep -c libcublas # want 1bashIf `libcublas` does not appear, CMake silently fell back to a CPU-only build and the model will run at a few tokens per second. Check before you spend 20 minutes loading 75 GB.
- 3
Download the GGUF
Two shards, 74.9 GB total. llama.cpp resolves the second shard automatically from the first, so only shard 1 is named on the command line.
hf download bartowski/BAAI_AREX-Base-GGUF \ --include "BAAI_AREX-Base-Q4_K_M/*" \ --local-dir ~/models/gguf/AREX-Base-Q4_K_M # verify before serving: two shards at full size, nothing half-written ls -l ~/models/gguf/AREX-Base-Q4_K_M/BAAI_AREX-Base-Q4_K_M/*.gguf find ~/models/gguf/AREX-Base-Q4_K_M -name '*.incomplete' | wc -l # 0bashExpect 39,925,205,408 and 35,022,084,864 bytes. Sustained ~61 MB/s on our link, about 20 minutes; the repo also carries an `mmproj-*` file for the vision tower, which this text-only recipe does not download or use.
- 4
Serve at the full context
There is nothing clever in this command, which is the point — the model's native 262,144-token window is requested outright and it starts. One slot, because the target is a single user.
M=~/models/gguf/AREX-Base-Q4_K_M/BAAI_AREX-Base-Q4_K_M/BAAI_AREX-Base-Q4_K_M-00001-of-00002.gguf setsid nohup ~/Dev/llama.cpp/build/bin/llama-server \ -m "$M" \ -c 262144 \ --parallel 1 \ -ngl 99 \ -fa on \ --host 0.0.0.0 --port 8080 \ --alias arex-base \ > ~/arex-serve.log 2>&1 < /dev/null &bash`setsid nohup` matters if you are driving this over SSH — a plain background job dies with the connection. First load took 106 s cold; expect faster once the file is in page cache.
- 5
Confirm it came up as you asked
grep -E "n_ctx_slot|model loaded|listening" ~/arex-serve.log curl -s http://127.0.0.1:8080/v1/models | head -c 200bashExpect `initializing, n_slots = 1, n_ctx_slot = 262144` then `model loaded` and `listening on http://0.0.0.0:8080`. If `n_ctx_slot` is smaller than you asked for, llama.cpp clamped the context and everything below is measuring a different configuration.
- 6
Warm it before you trust any number
The first requests after load pay for faulting the expert planes in from NVMe. Send real traffic, then benchmark twice and keep the second pass.
for i in 1 2 3; do curl -s http://127.0.0.1:8080/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"arex-base","messages":[{"role":"user","content":"Write a Python quicksort and a binary search tree with type hints."}],"max_tokens":400,"temperature":0}' > /dev/null donebashThe tell that a pass was still cold: decode at a 512-token prompt comes out clearly *higher* than at 2,048. Warm, the two agree to within 0.05 tok/s on this model.
- 7
Check the memory you actually used
free -g | sed -n 2p # steady state, AFTER the API is answeringbashExpect ~81 GiB used of 121, against a ~3 GiB idle baseline. Read it only once the API is up — during load the KV cache does not exist yet, so the figure flatters you by exactly what you are about to allocate.
- 8
Confirm the context you intend to use — with a real request
The server starting at `n_ctx_slot = 262144` is not evidence that a quarter-million-token prompt works; a long prefill can fail where the budget says it should not. Send one before you rely on it.
python3 - <<'EOF' import json,urllib.request prompt = "the quick brown fox jumps over the lazy dog. " * 28000 # ~250k tokens req = urllib.request.Request( "http://127.0.0.1:8080/v1/chat/completions", data=json.dumps({"model":"arex-base", "messages":[{"role":"user","content":prompt+"\nHow many times did the fox jump?"}], "max_tokens":64,"temperature":0}).encode(), headers={"Content-Type":"application/json"}) print(json.load(urllib.request.urlopen(req, timeout=1800))["usage"]) EOFbashBudget the wait: prefill runs at ~483 tok/s at this length, so a 250,000-token prompt is about 8.7 minutes before the first token appears. Set a generous client timeout — the default in most HTTP clients will give up long before the server does.
Key vLLM parameters
| Parameter | Value | Purpose |
|---|---|---|
| -c | 262144 | The model's native window, requested in full. On most models this is the flag you climb toward carefully; here it is free, because only 12 of 48 layers keep a KV cache and each token costs 24 KiB, so the whole window is ~6.0 GiB. There is no reason to serve this model at 32K. |
| --parallel | 1 | One slot. In llama.cpp the context is divided among slots, so `--parallel 4` at `-c 262144` would give each request 65,536 tokens rather than serving four full-length ones. Since the target is a single user, one slot keeps the whole window available to the one request that matters. Raise it only if you actually fan out, and divide the context accordingly. |
| -ngl | 99 | Offload every layer to the GPU. On unified memory this is not the usual VRAM-vs-RAM tradeoff — there is one pool either way — but leaving layers on the CPU makes them run on CPU kernels, which is dramatically slower. Always all layers on a Spark. |
| -fa | on | Flash attention. Left on as the build's default for the full-attention layers; not swept here, so it is documented as shipped rather than as a tuned choice. |
| no speculative decoding | measured and rejected | The usual biggest lever on the Spark does not pay on this configuration, and that is a measurement rather than an omission. `config.json` advertises `mtp_num_hidden_layers: 1` but the released weights contain zero `mtp.*` tensors, so there is no in-checkpoint draft. The obvious external candidate is the vendor's dense 4.5B sibling AREX-Turbo, which shares the 248,320-token vocabulary exactly; quantized to Q4_K_M (2.71 GB) and served with `--spec-type draft-simple -md <draft> -ngld 99 --spec-draft-n-max K` it was measured on 2026-07-28 at 20.16 / 20.48 / 19.38 tok/s for k=1 / 2 / 3 against a same-session, same-node no-draft baseline of 24.24 — i.e. 15-20% SLOWER at every k, despite draft acceptance of 1.000 / 0.842 / 0.717. Draft quality is not the problem: at k=1 every drafted token was accepted and the arm was still 17% slower. Two costs explain it. The draft reads 2.698 GB per token against the target's 6.489 GB (its 248,320 x 2,560 embedding is tied, so it is read every step), which alone caps k=1 at 1.41x before acceptance; and 36 of 48 layers are gated-delta linear attention, whose recurrent state cannot be partially rolled back, so llama.cpp saves and restores a full state checkpoint around each speculative step. Measured, that machinery costs ~42.6 ms per step on top of the 41.3 ms verify and 15.3 ms draft - about one extra target decode. Serve this model without a draft. |
| context, measured vs configured | -c 262144 configured, 250,223 tokens verified | The server comes up at the model's full native window on the first attempt, and nothing needed tuning to get there — the KV cost is only 24 KiB/token. What is published as this recipe's context is the largest prompt actually sent and answered in the measurement session, 250,223 tokens, at 13.91 tok/s decode and 520.7 s to first token with the node at 93.5 GB. The remaining ~12,000 tokens up to 262,144 are configured but untested here; a prompt that long is a nine-minute prefill, so verify it yourself before designing around it. |
API usage
Chat — and where the answer actually is
AREX reasons before it answers, and llama.cpp splits that out: the thinking goes to `choices[0].message.reasoning_content` and `content` stays an empty string until the model finishes. A client that reads only `content` sees a blank reply — give it enough `max_tokens` to think and then answer, and read both fields.
curl -s http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "arex-base",
"messages": [{"role": "user", "content": "What is 17*23? Answer briefly."}],
"max_tokens": 512,
"temperature": 0
}' | python3 -m json.toolbashMeasure decode correctly
Take token counts from the server's `usage` block, never by counting streamed chunks. And subtract time-to-first-token before dividing: at an 8,192-token prompt TTFT is 12.3 s, so a naive total-time division would report roughly half the real decode rate.
curl -s -o /tmp/r.json -w '%{time_total}s total\n' \
http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"arex-base","messages":[{"role":"user","content":"Explain gradient checkpointing."}],"max_tokens":256,"temperature":0}'
python3 -c "import json;u=json.load(open('/tmp/r.json'))['usage'];print(u)"bashTroubleshooting
- The reply is an empty string. `choices[0].message.content` is `""` and the model appears to have generated nothing.
- It generated plenty — into `reasoning_content`. AREX-Base is a reasoning model and llama.cpp routes its thinking into a separate field, leaving `content` empty until the thinking block closes. If you also hit `"finish_reason": "length"`, the budget ran out mid-thought: raise `max_tokens` (512 is a reasonable floor for a short question) and read `reasoning_content` alongside `content`.
- The server exits at load with an unknown-architecture error naming `qwen35moe`.
- The llama.cpp binary predates support for this architecture. `grep -o QWEN35MOE ~/Dev/llama.cpp/src/llama-arch.h` in your checkout — if it prints nothing, pull and rebuild. Verified working on build `ee445f9`, which reports `version: 15`.
- Decode is a few tokens per second instead of ~24.
- Two usual causes, in order of likelihood. Either the build is CPU-only — check `ldd ~/Dev/llama.cpp/build/bin/llama-server | grep libcublas` returns a line, and rebuild with `-DGGML_CUDA=ON` if it does not — or the first requests after load are still faulting the 75 GB of experts in from NVMe. Send a few hundred tokens of traffic and measure again.
- Passing `--speculative-config` / a draft model and seeing no speedup, or a load error about missing MTP tensors.
- There is no MTP head in this checkpoint despite what `config.json` says. `mtp_num_hidden_layers: 1` is advertised, but the BF16 release's `model.safetensors.index.json` contains zero `mtp.*` entries, so the GGUF has nothing to convert. Any speculative setup here needs an external draft — BAAI's dense 4.5B AREX-Turbo shares the 248,320-token vocabulary and is the natural candidate — and its acceptance rate must be measured before you believe any throughput gain from it.
- Dead end ruled out: serving this model on vLLM.
- Not attempted here, and not cheap to attempt. No FP8 or NVFP4 build of AREX-Base exists on the Hub, so a vLLM recipe means quantizing 245.1 GB of BF16 yourself; the BF16 weights also do not fit two Sparks with room for KV (245.1 GB against 228 GiB usable across the pair). The GGUF route is what makes this model single-node at all. Note also that the base checkpoint is a `Qwen3_5MoeForConditionalGeneration` carrying a vision tower, which is its own vLLM complication — this recipe serves the language model only.
- A very long request appears to hang, or the client times out with nothing returned.
- Prefill on this model runs at roughly 450–700 tok/s and does not speed up with length, so a 131K-token prompt takes 229 s and a 250K one takes 521 s before the first token appears. That is the server working, not wedged — `tail -f ~/arex-serve.log` shows `prompt processing … progress = 0.xx` climbing. The usual cause of a failure here is a client-side timeout: raise it past 900 s for quarter-million-token prompts.
- You attach a draft model with `-md` and speculative decoding never happens - decode is unchanged and nothing in the log complains.
- `--spec-type` defaults to `none` on current llama.cpp builds, so `-md`, `-ngld` and `--spec-draft-n-max` are accepted and ignored. Pass `--spec-type draft-simple` (the type list is `none,draft-simple,draft-eagle3,draft-mtp,draft-dflash,ngram-simple,ngram-map-k,ngram-map-k4v,ngram-mod,ngram-cache`). The silence is total: the draft loads and is validated, the boot log carries no `spec`/`draft` line at `-lv 3`, and `common_speculative_init()` logs `no implementations specified for speculative decoding` only at TRC. Verify it is really on by reading `timings.draft_n` from a `/completion` response - llama.cpp emits `draft_n` / `draft_n_accepted` only when the speculator actually drafted, so an absent (`null`) field means it never ran, not that acceptance was zero.
- Converting a Qwen3.5-family draft to GGUF succeeds, then llama.cpp refuses to load it: `error loading model: missing tensor 'blk.32.attn_norm.weight'` on a 32-layer model.
- The checkpoint's `config.json` declares `mtp_num_hidden_layers: 1` while its weight index contains no `mtp.*` tensors, and `convert_hf_to_gguf.py` faithfully writes `nextn_predict_layers = 1` - so the loader then demands an extra block that was never in the file. Re-convert with `--no-mtp`. Check first with `sum('mtp' in k for k in index['weight_map'])` on `model.safetensors.index.json`; several models in this family advertise an MTP head they do not ship.
Revision history
What has changed on this page since it was published, and what it measured. Newest first.
- re-measured
Measured AREX-Turbo as a speculative draft and rejected it — 15–20% slower at k=1/2/3 despite 100%/84%/72% acceptance. The serve command is unchanged; `keyParams` and `troubleshooting` now carry the numbers and the two llama.cpp traps found on the way.
At a matched 2,042-token prompt on one node in one session: no draft 24.24 tok/s → k=1 20.16 (−16.8%, acceptance 1.000), k=2 20.48 (−15.5%, 0.842), k=3 19.38 (−20.1%, 0.717). Draft alone decodes 65.32 tok/s (15.31 ms/token) against the target's 41.3 ms, so the loss is the ~42.6 ms/step recurrent-state checkpoint the engine needs on a hybrid target, not a slow draft.
- re-measured
Re-verified the published serve command on the other Spark against the same llama.cpp build. Nothing changed; `decodeTps` still quotes the 2026-07-27 run, which carries the full prompt-length curve.
Decode at a 2,042-token prompt: 23.98 tok/s (Spark-2, 2026-07-27) → 24.24 tok/s (Spark-1, 2026-07-28), +1.1% — inside node-to-node and boot-to-boot variation, so the two nodes are interchangeable on this config.
Memory budget
The interesting fact about this configuration is that a 122B model and its entire 262,144-token window fit one Spark with about 36 GiB to spare. Two things pay for that. The weights are the obvious half: Q4_K_M turns 245.1 GB of BF16 into 74.9 GB (69.8 GiB), which is what makes a 122B-parameter model a single-node proposition at all. The KV cache is the half nobody budgets for. The architecture keeps full attention on only 12 of 48 layers (`full_attention_interval: 4`; the other 36 are gated-delta linear attention with no growing cache), and those 12 layers carry just 2 KV heads at head_dim 256 — so a token costs 24 KiB and the full 262,144-token context needs about 6.0 GiB. There was no context/memory tradeoff to tune here: the model's native maximum was requested outright at `-c 262144` on the first boot and it started. Measured total at steady state while serving: 81 GiB of the 121 on the box, against a 3 GiB idle baseline, i.e. a ~78 GiB serving working set against 114 usable. The weights figure is the file size on disk; the KV figure is computed from the layer geometry and the overhead figure is what is left over, so both are marked estimated.