--- type: reference title: Llama Cpp Multi-Device Splits description: What llama.cpp can and cannot express across devices, the no-collective layer-split path, split balancing by measurement, drafter KV sizing from GGUF headers, and heterogeneous placement triage. tags: [llama.cpp, multi-gpu, layer-split, pcie, sizing, speculative] timestamp: 2026-10-05T00:00:00-07:00 --- # Llama Cpp Multi-Device Splits ## 1. Expressibility law — classify the arrangement before running anything llama.cpp expresses exactly two things across devices, and they are not interchangeable: | arrangement | how | expressible? | |---|---|---| | **Layer split** — layers 0..n on dev A, rest on dev B | `-sm layer -ts /` | **yes**, no collectives needed; works cross-vendor (CUDA + Vulkan measured 2026-09-16) | | **Per-tensor placement** — attention+KV on A, experts on B | `-ot =` | **NO.** `--override-tensor` sets `has_tensor_overrides()`, a REQUIRED term of the scheduler's `pipeline_parallel` flag (only read site: `llama-context.cpp`, in the `pipeline_parallel = …` expression). That flag sets `n_copies = parallel ? MAX : 1` in `ggml_backend_sched_new`; with `n_copies == 1` a pre-allocated weight on a non-primary device has no backend and the graph aborts at `ggml-backend.cpp:941` (`pre-allocated tensor (blk.0.ffn_down_exps.weight) in a buffer (Vulkan1) that cannot run the operation (NONE)` — `(NONE)` is the leaf's own op name). | Upstream anchors, re-read 2026-10-18 against `master`: `src/llama-context.cpp:517-526` computes `pipeline_parallel` with `!model.has_tensor_overrides()` as a required term; `ggml/src/ggml-backend.cpp:1089` carries the verbatim abort `pre-allocated tensor (%s) in a buffer (%s) that cannot run the operation (%s)` (the `:941` anchor above is where it sat in the `37b53fd` build this was measured on); and `common/arg.cpp` defines `-sm/--split-mode` (2821), `-fit/--fit` (2882), `--spec-type` (4295), `--spec-draft-device` (4259), `--spec-draft-n-max` (4161), `-fa/--flash-attn` (1758) — every flag in §4 is real upstream, not fork-only ([source](https://raw.githubusercontent.com/ggml-org/llama.cpp/master/common/arg.cpp)). Failure modes observed (mainline `37b53fd`): per-tensor `-ot exps=Vulkan1` with `-dev CUDA0,Vulkan1` → scheduler abort; layer-scoped `-ot` with a cross-backend pair → model load fails with only `failed to load model`; `-ot x=CPU` **works** because the CPU backend is last in the list — **the CPU is the only legitimate partner in a heterogeneous split.** On a CUDA+AMD host a card can be the trunk OR the offload host, never the complement of an AMD-resident peer. Pure Vulkan changes nothing here — the gate is the override, not the driver mix. If the plan needs tensors on a specific device: stop, the answer is no for any vendor mix. Expressible alternatives, in order: (1) two independent endpoints, one model per device; (2) single-vendor layer split; (3) device + CPU per-tensor offload (mainline pinning guard: `GGML_CUDA_NO_PINNED`; ik fork was `GGML_CUDA_NO_PINNED_WEIGHTS` — without it a CPU-overridden tensor can land in a pinned `CUDA_Host` buffer and page-lock ~108 GiB). Layer-split arithmetic to check before promising: a whole layer carries its experts (MoE) — compute GiB/layer, not per param-group; only some layers carry KV (every 4th on the hybrid), so a share of layers is only a share of KV; big non-repeating tensors (PLE tables) land on the main device and cannot move — they cap how small the primary can get. ## 2. No collectives? The atomics wall and the layer-split answer Decide with one line before designing: ```bash sudo lspci -vv -s | grep AtomicOpsCtl # ReqEn- ⇒ no collectives, ever lspci -tv # a Routing- bridge is a hardware wall ``` `ReqEn-` kills vLLM entirely — TP needs a per-layer allreduce AND PP needs a device-backend `send`/`recv` of the hidden state (`vllm/distributed/parallel_state.py`) — i.e. the same RCCL primitive that fails on an atomics-less card. llama.cpp's `--split-mode layer` needs no collective: layers distribute in order, only the boundary activation crosses PCIe once per forward pass. Full probe/repair detail: [[PCIe Link and Atomics]]. ## 3. Build: pin by feature, bundle the runtime at the exact depth - Pin the ref by FEATURE list (e.g. DFlash2 + qwen35 MTP on 2026-09-16 → `ggml-org/llama.cpp#27342`); never float. Verify a fork build the same way (`ls /…/spec_decode/ # dflash/ vs dflash2/`). - `-DCMAKE_HIP_ARCHITECTURES=` — a sibling arch links, loads, then segfaults. `-DGGML_HIP_RCCL=OFF` when there is no atomics path (only tensor mode degrades to the butterfly fallback; layer split unaffected). - **TRAP — `cp -r SRC DST/` flattens when `DST` does not exist** and drops one directory level. rocBLAS/hipBLASLt look for device code at a FIXED depth (`../lib/rocblas/library`, `../lib/hipblaslt/library/`); flattened, the engine loads every GiB, answers `/health`, and dies on the FIRST request with `Cannot read .../TensileLibrary.dat: No such file or directory for GPU arch`. `mkdir -p DST && cp -r SRC/. DST/`, then `patchelf --set-rpath '$ORIGIN/../lib' build/bin/llama-*`. - Dual-backend build (CUDA + Vulkan is the practical pair; HIP+CUDA do not coexist): `-DGGML_CUDA=ON -DGGML_VULKAN=ON` in one tree; Vulkan needs `glslc` + libvulkan headers. **Device names are not what you expect:** `--list-devices` lists the discrete card under BOTH backends (`CUDA0` AND `Vulkan0`) and the APU LAST (`Vulkan1`) — a wrong `-ot` name makes llama.cpp print the valid set and exit (self-diagnosing). - What the existing build can do: `GPU_BACKEND_*=OFF` lines in its CMakeCache. ## 4. Serve flags that matter on a split ```bash HIP_VISIBLE_DEVICES=0,1 llama-server \ --device ROCm0,ROCm1 --split-mode layer --tensor-split 1,1 \ --gpu-layers 999 --fit off --flash-attn on \ --ctx-size --parallel 1 --cache-type-k f16 --cache-type-v f16 \ --jinja --metrics --cache-prompt \ --spec-type draft-dflash --spec-draft-model \ --spec-draft-device ROCm1 --spec-draft-n-max 7 ``` - `--fit off` + explicit `--ctx-size`: auto-fit must not resize a context set on purpose. **`--parallel N` DIVIDES `--ctx-size`** — one slot at large ctx is the concurrency policy, concurrent agent loops queue. - Pin the drafter to the card holding the target's LAST layers (`--spec-draft-device`); under tensor mode an unpinned draft joins the meta-device KV allocation and aborts the KV build. `--spec-draft-n-max` must be ≤ the drafter's `block_size - 1` (DFlash2: 8 ⇒ 7). - Qwen3.8-family templates RAISE on `reasoning_effort=high` (500) — probe low/medium/high/xhigh and map harness levels before declaring ready. ## 5. Split balancing: measure, never interpolate **A `-ts` step is ONE LAYER, and hybrid stacks do not have uniform layers.** qwen35 is 16 × (3 Gated DeltaNet + 1 Gated Attention): one step ≈ 0.43 GiB on a DeltaNet boundary, ≈ 1.43 GiB on a full-attention one (weights + that layer's own f16 KV) — measured on frank-medium. The gap can jump 2.9 GiB in a single step; the best integer split can sit 0.2 GiB from balance while its neighbours are 1+ GiB off. An affine fit reproduces the deployed point and overstates gaps in between (`tests/frank_medium_unit.bats` encodes the fit as a gate; treat a NEGATIVE fitted constant as a unit bug). Knobs that move weight and REQUIRE a re-measure: `--ubatch-size` (compute buffers are **~2x heavier on the output/drafter card** — 512→1536 added ~3.3 GiB there vs ~1.7 GiB elsewhere), a drafter swap (weights + draft KV), `--ctx-size`/`--parallel` (KV). Total free VRAM is invariant across splits — only the distribution moves; use that to sanity-check a suspicious row. **Procedure (one split per load):** ```bash WORK=/tmp/split.env; cp scripts/serve-.env $WORK export FRANK_MEDIUM_ENV_FILE=$WORK for ts in 39,26 38,27 37,28 36,29 35,30; do sed -i "s|^TENSOR_SPLIT=.*|TENSOR_SPLIT=$ts|" $WORK nohup bash scripts/serve-.sh >/tmp/split.log 2>&1 & # poll /health (or process exit), then: rocm-smi --showmeminfo vram | grep 'VRAM Total Used' # sample ~6 s after health pkill -x llama-server; sleep 6 done ``` Load → sample → kill, NO requests sent (allocation only). Run under `nohup` and poll from a separate call (a load is 30–90 s). Pick the smallest inter-card gap that clears the per-card floor with room to spare; one `-ts` step ≈ 1.5 GiB at ~6k batch / 262k ctx on 30 GiB cards — enough to bracket, not to solve. Prefer balance: the busy card's headroom bounds the context (`ctx_extra ≈ (floor − measured_free) / per-position cost on the busier card`). A faulted split (`SDMA0 UTCL2 page fault` → `illegal memory access` → `ggml_abort`) is OFF-LIMITS — re-measure, never re-tune; more headroom does not prevent a boundary-copy fault. ## 6. Drafter KV: sized from the TARGET, bounded by its window policy `common/speculative.cpp` builds the draft context with `cparams.n_ctx = llama_n_ctx(ctx_tgt)` — `common/speculative.cpp:2663` on `master`, re-read 2026-10-18 — **no flag caps it.** So: ``` draft KV = ctx_tgt × block_count × 2 (K,V) × kv_heads × key_length × dtype ``` …unless the drafter declares a sliding window. Measured at 262,144 target ctx: DFlash2 (`sliding_window = 2048`) ≈ 40 MiB; a same-shape 5-layer drafter with NO window = 20 KiB/position = **5.0 GiB** — a four-layer swing in the balance point, and swapping them invalidated an already-balanced split. **Failure signature:** the engine loads the whole model, answers `/health`, and dies on the FIRST draft decode with `ROCm error: out of memory` / `cudaMalloc failed` inside `common_speculative_impl_draft_dflash::process` — a sizing failure, not a broken drafter; under a self-healing unit it presents as a crash-loop. Read `sliding_window`, `block_count`, `head_count_kv`, `key_length` from the drafter's GGUF header first. A compact reader (the three format traps: no KV alignment — v3 alignment applies only to the tensor section; arrays are `type(9) → element_type(u32) → count(u64)`, element type FIRST; the enum is `7=BOOL 9=ARRAY`, 64-bit = 10/11/12, and 8=STRING/9=ARRAY is the v2/v3 file order, NOT gguf.h's 2/3): ```python import struct, sys def rd(path): f = open(path, 'rb'); assert f.read(4) == b'GGUF' struct.unpack('