Field note · llama.cpp
RSS is the wrong instrument for memory on a DGX Spark
The llama-server process shows about 29 GiB of RSS while about 87 GiB of weights for Qwen3.8-Flash-Next sit in CUDA buffers that RSS never counts, and nvidia-smi reports [N/A] for memory on GB10 because there is no separate GPU pool to report. MemAvailable before and after start is the honest measurement, and that holds for every model on a DGX Spark, not just this one. The idle headroom measured at launch was not production headroom either: the same configuration was later killed by the kernel under real load, with nothing in its own log, and working defaults moved to a smaller context size and ubatch afterward.
- Hardware
- NVIDIA DGX Spark · GB10 (SM121) · aarch64 · 128 GB unified
- Software
- llama.cpp master @ 5e6a37cb1 + PR #27742 (branch qwen4exp-27742) · unsloth/Qwen3.8-Flash-Next-GGUF UD-IQ4_XS
This isn't specific to this model
Every DGX Spark model shares one trait: there's no separate GPU memory pool. CPU and GPU draw from the same 128 GB of unified LPDDR5x, so the usual instruments for "how much memory does this process need" stop meaning what they normally mean. Qwen3.8-Flash-Next just makes the gap unusually large, because so much of it — 93.6 GB — is weights.
The numbers
At CTX_SIZE=262144, PARALLEL_SLOTS=4, CACHE_TYPE=f16, server up and idle:
| metric | value |
|---|---|
MemTotal | 121 GiB |
MemAvailable before start | 107–111 GiB |
MemAvailable with server up | 7–11 GiB |
| llama-server RSS | ~29 GiB |
| weights on disk | 93.6 GB (87 GiB) |
RSS reads about 29 GiB. Weights alone are about 87 GiB. The gap isn't a measurement error — it's CUDA buffers that never appear in the process's resident set at all. nvidia-smi can't fill that gap either: on GB10 it reports [N/A] for memory, because there's no separate GPU pool for it to report on. MemAvailable, read before and after the server starts, is the one number here that tells the truth. llama-server doesn't print a KV-split line at default verbosity, so there's no log line to read any of this from — it has to be measured externally.
It was OOM-killed in production, and the log said nothing
The measurement above is real, but it isn't a safe operating point. This exact configuration — CTX_SIZE=262144, UBATCH_SIZE=2048 — was later killed by the kernel in production. Serving an agentic pipeline issuing ~25.6K-token prompts across all four slots ran the box out of memory:
13:08:59 NVRM: Check failed: Out of memory [NV_ERR_NO_MEMORY] ... _memdescAllocInternal
13:09:05 NVRM: (repeats)
13:12:12 dashboard-servi invoked oom-killer
13:12:12 Out of memory: Killed process 1966928 (llama-server)
total-vm:164222812kB anon-rss:31651260kB oom_score_adj:200
An OOM kill leaves nothing in the server's own log — no assert, no stack trace, no final line; the process simply stops appearing. This one was noticed only because /metrics began returning zero bytes. If a llama.cpp log just ends mid-run with no error, journalctl -k | grep -i oom-kill is the first thing to check, before looking for a crash. The GPU allocation failures above preceded the kill by three minutes — the earliest warning available, and it exists only in the kernel log, never the server's.
The reason is that synthetic idle measurement is not production headroom. Peak allocation is the prefill compute buffer, which scales with ubatch, and for a 512-expert MoE that reservation is the single largest one in the run — exactly the one an a-priori estimate is likeliest to underestimate, and exactly what an idle-headroom check will not catch. Working defaults moved after this incident:
| before | after | |
|---|---|---|
CTX_SIZE | 262144 | 131072 |
UBATCH_SIZE | 2048 | 1024 |
| idle headroom | 11 GiB | 17 GiB |
131072 is still roughly 5x the largest prompt this pipeline actually sends. Treat the table above as an honest snapshot of idle measurement, not as a validated production setting — 7–11 GiB of idle headroom was not enough.
The killed process also carried oom_score_adj:200, which turned out to have nothing to do with memory size and everything to do with how the process was launched — see DefaultOOMScoreAdjust=200 in the systemd user manager kills terminal-launched servers first.
Why 256K context is affordable at all
The KV arithmetic, worked out beforehand, was the one part of the estimate that held up:
12 attn layers x 2 kv-heads x 256 x 2 (K+V) x 2 B = 24 KiB/token -> ~6.1 GiB @ 262144
indexer: 12 x 1 shared K head x 128 x 2 B = 3 KiB/token -> ~0.8 GiB
GDN recurrent state is PER SLOT, not per token: ~108 MiB/slot -> ~0.4 GiB
Only 12 of the model's 48 layers are attention (full_attention_interval = 4); the other 36 carry GatedDeltaNet recurrent state instead, which is a fixed cost per slot rather than a cost that scales with context length. That split is why a 262144-token context is affordable at all on a 128 GB box. It's worth flagging that the four slots in that arithmetic are a scheduling ceiling, not a guarantee — unified mode allocates one shared pool sized for that ceiling, and four simultaneous conversations all sitting at full context would oversubscribe it.
Where the a-priori estimate missed
The KV arithmetic was right; the total wasn't. My arithmetic beforehand predicted about 97.5 GiB in use; the real figure measured about 9 GiB higher. What it missed was the compute-graph reservation: for a 512-expert MoE at ubatch 2048, that reservation is considerably larger than the same estimate would produce for a dense model.
Offloading flags do not help here
The n-gram PLE table is about 46% of the 4-bit file (roughly 43 GB), and the model card sells it as more amenable to offloading than the MoE experts are — true on a discrete-GPU box, where offloading moves a tensor to memory the GPU couldn't otherwise reach. (Reasoned from the unified-memory architecture, not benchmarked — but it's why I didn't reach for these flags when headroom got thin.) On a DGX Spark, CPU and GPU share the same 128 GB pool, so --n-cpu-moe and -ot move tensors between buffers that are the same physical memory. The one real lever is page cache, which can double-count mmap'd weights while they load — drop it manually, before starting, if MemAvailable is already low.
The f16 cache type in the table above isn't a free choice, either: q8_0 would buy back 4 GiB (MemAvailable 14 GiB idle versus 10 GiB on f16), but both KV types hit a separate multi-slot crash under concurrent load, which is why the working configuration also dropped to --parallel 1 regardless of cache type. See A quantized KV cache crashes qwen4exp on llama.cpp, in two different ways.