Sharing a working recipe for running MiniMax-M3 across two DGX Spark (GB10) nodes with llama.cpp layer-split over RPC — including a native tool-calling fix (a hybrid chat template) that isn’t upstream yet.
The full BF16/MXFP8 checkpoint is far too big for a single 128 GB Spark (MiniMax-M3-MXFP8 is ~444 GB), so this splits a sub-4-bit GGUF across two boxes. End result: a stable OpenAI-compatible endpoint with reasoning and function-calling.
The hard part, in one line: llama.cpp PR #24523 adds preliminary M3 support, but its tool-call parser can’t read M3’s native format (HTTP 500). M2’s template parses but corrupts M3’s generation. The fix is a hybrid template — M3 native body + M2’s tool-call format.
Full scripts, the template and the generator are in the repo: GitHub - karolpalys/minimax3-on-2-nodes: Minimax3 on 2 nodes: run MiniMax-M3 426B across two DGX Spark nodes via llama.cpp RPC, with a working native tool-calling hybrid chat template · GitHub
What you get
| Metric | Value (UD-IQ4_XS, 2× DGX Spark) |
|---|---|
| Model | MiniMax-M3 426B MoE, UD-IQ4_XS GGUF (~194 GiB, ~97 GiB/node) |
| Decode (tg) | ~10.7 tok/s |
| Prefill (pp) | ~590 tok/s @ --ubatch-size 2048 (8k prompt) |
| Context | 65,536 (configurable; KV q8_0 ≈ 45 KB/token) |
| Tool-calling | ✅ native structured tool_calls via the hybrid template |
| Reasoning | ✅ <mm:think> separated into reasoning_content |
| First load | ~13–25 min (RPC streams the worker’s layers; cached after) |
Setup notes that actually matter
- Two GB10 nodes (or any two CUDA boxes) whose combined memory clears ~194 GiB, i.e. ~97 GiB free per node.
- CUDA + cmake + recent GCC (Sparks ship aarch64 / GCC 13), and ~250 GB free disk for the GGUF.
--split-mode layer puts a contiguous layer range on each device, so there’s exactly one cross-node transfer per micro-batch (at the split boundary). That’s why a bigger --ubatch-size helps prefill: fewer, larger transfers.
Build (both nodes)
git clone https://github.com/karolpalys/minimax3-on-2-nodes
cd minimax3-on-2-nodes
CUDA_ARCH=121 bash scripts/build-llama-cpp.sh # 121 = GB10 / sm_121
The helper fetches PR #24523 (preliminary M3 support) and merges origin/master to pull in two chat fixes that are required so tool-calling doesn’t hard-crash: #24329 (peg-native parsing) and #24653 (grammar left-recursion). The M3 commit only touches src/models/… and the chat fixes only touch common/chat*, so the merge is conflict-free. Builds with -DGGML_CUDA=ON -DGGML_RPC=ON -DCMAKE_CUDA_ARCHITECTURES=121.
⚠️ Worker RPATH gotcha.
rpc-serverhas an empty RPATH, so running it from its own dir fails withlibggml.so.0: cannot open shared object fileeven though the.soare right there. It must be launched withLD_LIBRARY_PATH=~/llama.cpp-bin(the run script does this).
Download the model (HEAD)
bash scripts/download-model.sh # UD-IQ4_XS by default
Custom downloader because the large (~49 GB) shards are Xet-backed and hf download can stall at ~0 B/min, while HF_HUB_DISABLE_XET=1 rejects shards > ~48 GB. The script resolves each file to its cas-bridge presigned URL (plain HTTP, byte-range) and pulls with 16 parallel range requests, per-part resume and curl -f. UD-IQ4_XS is the sweet spot that fits two Sparks with headroom; other quants via QUANT=UD-Q4_K_XL.
The hybrid chat template (the key fix)
This is the part that makes native tool-calling actually work.
Problem. M3’s native template emits tool calls with a namespace token before every XML tag plus a recursive argument encoder. llama.cpp’s auto-derived peg-native parser can’t read it:
HTTP 500 "Failed to parse input at pos N: …<tool_call>…"
HTTP 500 "The model produced output that does not match the expected peg-native format"
Trap. Swapping in the MiniMax-M2 template makes the parser happy (M2 uses a simple <minimax:tool_call><invoke><parameter> format), but M2 forces <think> in the generation prompt while M3 was trained on <mm:think>. M3 then degrades — truncated answers (“2+2” → “Simple math question.” then stop) and prose narration instead of tool calls.
Fix — hybrid. Keep M3’s native template unchanged (roles, <mm:think>, adaptive generation prompt, <response> tool-result format) and change only the tool-call format to M2’s parser-friendly style. M3 obeys the in-context format and the parser returns structured tool_calls, while its behaviour stays native. Verified across single call, multi-turn (call→result→answer), and reasoning (no <mm:think> leak into content).
templates/MiniMax-M3-hybrid.jinja is ready to use. To regenerate it for an updated GGUF:
python3 tools/make-hybrid-template.py \
--gguf .../MiniMax-M3-UD-IQ4_XS-00001-of-00006.gguf \
--out ~/MiniMax-M3-hybrid.jinja
It extracts the native template from the GGUF and applies three precise transplants (token defs, the tool-instruction block, the tool_calls renderer), asserting each block matched exactly once so it fails loudly if a future template changes shape.
Run (HEAD node)
WORKER_HOST=10.20.20.2 \
WORKER_USER=$USER \
MODEL=~/models/MiniMax-M3-GGUF/UD-IQ4_XS/MiniMax-M3-UD-IQ4_XS-00001-of-00006.gguf \
TEMPLATE=~/MiniMax-M3-hybrid.jinja \
bash scripts/run-minimax-m3.sh
It kills stale instances, optionally drops page cache on both nodes, starts rpc-server on the worker with the right LD_LIBRARY_PATH, waits for its port, then execs llama-server with the tuned flags. First load streams the worker’s ~97 GiB over RPC (~13–25 min); later loads are faster thanks to rpc-server -c tensor caching.
Key flags:
| Flag | Why |
|---|---|
--rpc <worker>:50052 --split-mode layer |
distribute layers across the 2 nodes |
-ngl 999 |
all layers on GPU |
-fa on |
flash attention |
--ubatch-size 2048 --batch-size 2048 |
+26% prefill vs default 512 (fewer cross-node transfers); negligible extra memory |
--parallel 1 |
one slot, full ctx to a single request (raise for concurrency) |
--cache-type-k/v q8_0 |
KV in q8_0 to fit memory |
--jinja --chat-template-file …hybrid.jinja |
the fix |
--temp 1.0 --top-p 0.95 --top-k 40 |
MiniMax-recommended sampling |
Pre-flight: no other big model on either node, stop earlyoom (it will SIGTERM llama-server mid-load), and confirm the fast link (ping -c2 10.20.20.2 should be < 1 ms).
Performance & tuning
- Prefill scales with
--ubatch-size: 512 → ~469 tok/s, 1024 → ~551 (+18%), 2048 → ~592 (+26%). Diminishing returns past 2048, so that’s the chosen sweet spot. Memory cost was negligible. - Decode (~10.7 tok/s) is bounded by dual-node RPC serialization and the dense-attention fallback — M3’s native sparse attention isn’t implemented in llama.cpp.
--ubatch-sizedoesn’t affect decode. - Prompt cache is on by default — repeated prefixes (agent loops) are nearly free on later turns.
- Context: KV q8_0 ≈ 45 KB/token → 64k ≈ 2.8 GiB, 128k ≈ 5.6 GiB per node. Raising ctx eats into the thin per-node headroom.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
libggml.so.0: cannot open shared object file on worker |
rpc-server empty RPATH → launch with LD_LIBRARY_PATH=~/llama.cpp-bin |
Tool calls return HTTP 500 (peg-native / Failed to parse) |
not using the hybrid template, or llama.cpp lacks the master chat fixes — rebuild and pass --chat-template-file …hybrid.jinja |
| Truncated / narrated answers, no tool calls | you used M2’s template instead of the hybrid — regenerate with make-hybrid-template.py |
| llama-server killed mid-load | an OOM killer (earlyoom) — stop it before launch |
rpc-server did not come up |
wrong worker IP (WiFi vs fast link), SSH not passwordless, or libs missing on worker |
hf download stalls at ~0 B/min on big shards |
Xet stall — use scripts/download-model.sh (parallel cas-bridge curl) |
| Throughput drops / RDMA collapse after a reboot | clean second reboot of both nodes (Spark mlx5 WC + nvidia-peermem quirk) |
Repo & credits
Full scripts, the hybrid template and the generator: GitHub - karolpalys/minimax3-on-2-nodes: Minimax3 on 2 nodes: run MiniMax-M3 426B across two DGX Spark nodes via llama.cpp RPC, with a working native tool-calling hybrid chat template · GitHub (MIT)
Built on llama.cpp (PR #24523 for M3; #16932, #24329, #24653 for the chat/tool-call path) and unsloth’s MiniMax-M3-GGUF dynamic quants. The contribution here is the hybrid-template approach plus the dual-node RPC recipe that make M3’s native tool-calling work end-to-end without degrading the model’s behaviour.
Generated with AI. Good luck and have fun!



