Running a 320-billion-parameter model in a home lab

GLM-5.3-Flash on 2× NVIDIA DGX Spark — bring-up, tuning, a two-lane serving design, and thermal engineering.
Everything below was measured on this cluster, failures included.

320Bparams (18B active MoE)
2×128 GBDGX Spark (GB10)
61.7 tok/sstructured decode
900 Ktoken context
990 KKV-pool tokens

①The bring-up saga (5 failures → a live model)

Day-0 models on day-0 hardware means every layer of the stack can betray you. Click each attempt — the failures taught us exactly what the success needed.

Attempt 1 — Qwen3.8-Flash-Next on SGLang blocked
Got all the way to a live endpoint: both nodes loaded, caches allocated, Uvicorn serving. The first real inference timed out and killed the scheduler. Along the way we learned: CUTLASS & CuteDSL FP4 kernels abort on GB10 (sm_121) — only the marlin kernel runs, and it repacks weights at ~2× memory. Day-old engine, last-mile failure. Reverted cleanly.
Attempt 2 — GLM-5.3-Flash NVFP4, first recipe OOM
A 181 GB NVFP4 quant across 2×128 GB looks fine on paper — until marlin's repack overhead lands. NVRM: Out of memory with ~117 GB free per node. A knife-edge fit that fit the author's kit and not ours. Lesson: on unified memory, your free RAM is the spec that matters.
Attempt 3 — the patched-image recipe WORKED
A community kit (tonyd2wild) that had already found and fixed seven day-0 bugs across vLLM/FlashInfer for this exact hardware, plus an InstantTensor loader that streams 181 GB in ~85 s at 3 GB/s and bypasses the page-cache behavior behind the earlier OOM. First token minutes later. The lesson that pays forever: find the person who already fought your hardware.
Attempt 4 — DFlash2 speculative decoding 2.15×
A 2.3 GB block-diffusion drafter guesses 7 tokens per step; the big model verifies them in one pass. Structured output (lists, code, tool calls — what agents actually emit) went from ~22 to 55–62 tok/s. Prose gains less (~27 tok/s) — the drafter predicts natural text worse. Costs ~zero extra KV.
Attempt 5 — the thinking bug 25× fix
GLM-5.3 defaults reasoning_effort to max. A simple question burned 33 s and 2,600 characters of hidden reasoning — then returned an empty answer (budget exhausted). Setting effort to low for chat: same question in 1.3 s. One default, 25× latency. We now route: low effort for conversation, high + thinking for delegated hard work.

②Picking the weights: the quality ladder

We started on NVFP4 and migrated to EXL3 4-bit. KLD (divergence vs the full-precision model — lower is better) tells the story better than vibes:

Official FP8 · 328 GB
0.0206
EXL3 4bpw · 164 GB
0.0246
NVFP4 · 181 GB
0.0605
EXL3 4bpw lands within 0.004 nats of native FP8 at 54% of the bytes — and 2.5× more faithful than the NVFP4 we started on. Smaller and better. The migration also unlocked a 900 K-token context with a 990,659-token KV pool (fp8, MLA) on the same two boxes. Current serving config trades context (262 K) for scheduler throughput — see §⑥.

③Two lanes, one brain

One engine can't be cloned (2 × 164 GB doesn't fit), and tensor-parallel already balances the GPUs perfectly. What isn't balanced is workload priority: an interactive assistant shares the engine with long-running batch jobs. So: one engine, one shared KV pool, two QoS lanes.

🗣️ Agent lane · :8891 (head)

priority 0 · reasoning_effort low → snappy voice/chat replies. Measured 0.6–2.8 s while the batch lane was saturated.

⚙️ Batch lane · :8892 (worker)

priority 10 · reasoning_effort high + thinking → deep thinking for delegated heavy work, big token budgets.

▼ both forward into ▼
vLLM · TP=2 over 200 Gb ConnectX · shared KV pool
caller-supplied values always win — lanes only fill gaps
Why shared beats split: either lane can burst to the full pool when the other is idle. Split pools strand capacity. The lanes are ~80 lines of stdlib Python each; the intelligence stays in the engine's scheduler.

④Thermal engineering: chasing 90 °C

Running inference 24/7 made the head node average 89–90 °C. Diagnosis before levers: where is the heat?

FindingEvidence
Governor pinned all 20 Grace cores at max, 24/7performance governor; idle cores at 2.8 GHz doing nothing
Fast cores boost to 3.9 GHz under loadper-policy cpuinfo_max_freq — invisible if you only read cpu0
GPU at ~96% util is the floor heatGPU die 65 °C, 96%, ~33 W — soaks SoC zones
Busy-wait polling burned ~2 coresWorker_TP 198% CPU with a 1 s spin window — the single biggest lever
Before (perf gov, 1 s spin)
89–90 °C
schedutil
83–84 °C
+ 3.3 GHz boost cap
~84 °C under load
+ spinwait 1s → 16 ms
74 °C avg
idle cores now
338 MHz
The honest result: the CPU levers removed the waste heat (one zone −9 °C, idle cores 2.8 GHz → 338 MHz, spikes gone) and cost ~nothing — decode is GPU-bound. Then the real culprit surfaced: VLLM::Worker_TP sat at 198% CPU — nearly two cores busy-waiting. vLLM's SpinCondition reader polls with a 1-second window by default; the upstream TP=2 sweep selected 16 ms. One env var (GLM53_SPINWAIT_MS=16) took the head node from a 93.7 °C peak to 80.3 °C (74 °C average) under identical 96% GPU load, with worker CPU dropping to 123% — and no throughput cost. Lesson: before blaming the workload, check what your stack is doing while it waits. Settings re-assert after reboot via the watchdog.
# the whole fix, applied via a privileged container (no sudo on the box)
for g in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do echo schedutil > $g; done
# cap only the 3.9 GHz-capable policies at 3.3 GHz; efficiency cores untouched
for p in /sys/devices/system/cpu/cpufreq/policy*/scaling_max_freq; do
  hw=$(cat ${p%scaling_max_freq}cpuinfo_max_freq)
  [ "$hw" -gt 3300000 ] && echo 3300000 > "$p"
done

⑤Lessons we paid for (so you don't have to)

reasoning_effort defaults to MAX
GLM-5.3 thinks as hard as it can unless told otherwise. Interactive traffic must set reasoning_effort: low (25× latency difference) — keep max for genuinely hard delegated work.
Full-file rewrites are output-heavy — don't think hard at them
High effort on a rewrite task burned the whole 16 K-token budget on reasoning and returned zero code. Effort should match task shape: deep for hard reasoning, low for bulk generation.
Generation-over-editing bias (the model's own diagnosis)
Asked why it created build.gradle next to an existing build.gradle.kts, the model answered: training priors favor generating a fresh canonical file over editing what exists — "the same prior kept winning." The fix it prescribed for itself now leads every task prompt: edit in place; a new file needs proof no equivalent exists.
Hybrid-attention models cache in pages, not tokens
GLM-5.3's KDA linear-attention layers checkpoint state only on 3584-token page boundaries. If the scheduler chunk (max-num-batched-tokens) isn't page-aligned, prefix caching silently reports 0% — and one config knob isn't enough; the fix needs engine patches. Know your model's cache granularity.
pkill -f matches your own command line
A deploy script that greps for a process name will find — and kill — the SSH shell carrying that name as an argument. Write pkill -f "name[.]py": the bracket class can't match its own literal text.