All ~173 managed skills read in full and merged, one polished reference page per skill-cluster: - LLM cross-cutting (17 pages): GGUF Compatibility, Llama Cpp Serving Sizing, Llama Cpp Multi-Device Splits, PCIe Link and Atomics, RDNA4 Container Serving, RDNA4 MoE Expert Offload VRAM Budget, vLLM Boot Forensics, vLLM Porting and Compile Triage, LLM Bench Windows, LLM Performance Diagnosis, Model Swap and Rollback, Tcclaviger Fork Ops, Heterogeneous Split Triage, FreeToken Builds + deltas folded into the retired runbooks (GLM DFlash2 port laws, Lucebox dual-R9700, R9V qualification pipeline, FLM pack registration, ik hybrid envelope, frank-medium split rebalance, llama.cpp GGUF preflight, batch-ubatch split section, bench-window D-state/stale-read notes, second-endpoint bring-up). - Architecture (3): Stale Signal Audit, Quadlet Lifecycle, Deploy State Triage. - Operations (6 + index): Remote Job Runner, Payload and Disk Reclaim, Container Host Reachability, Fleet Ops Playbook, Fleet Audit and Posture, SSH and Remote Access. - Infrastructure: Observability Provisioning + Homepage Status Probes; updates to Authentik, Anubis, LAN-DNS (per-client DoH bypass, Porkbun zone ops), Swap Configuration, OpenWebUI, Hermes (pre-existing page preserved and extended). - FrankBook-Pro: ARR-Stack filled (API split, schema-first probing, auth-posture, auth-config trap); qBittorrent E2E verification. - Desktop/Agent/Research: Noctalia and Niri, OMP Harness, Agent Docs and Edits, Command Center Dotfiles, PICO-8 Asset Standards. - Indexes: LLM Cross-Cutting References section, Operations/ and Agent/ index pages, root-index Skill Distillation section, AGENTS.md pointer. - log.md carries the wave plan. check-vault-links.py 0 unresolved (185 pages); ./tests/run.sh 366/366.
160 lines
7.6 KiB
Markdown
160 lines
7.6 KiB
Markdown
---
|
||
type: reference
|
||
title: Heterogeneous Split Triage
|
||
description: Triage before building when someone asks to split a model across unequal accelerators on one host (24 GB GDDR6X + LPDDR APU, PCIe x4 link) — link characterisation, the real bottleneck, what placement never buys, UMA pools, drafter splits, and the verdict ranking.
|
||
tags: [heterogeneous, multi-device, llama-cpp, triage, pcie]
|
||
timestamp: 2026-10-05T00:00:00-07:00
|
||
---
|
||
|
||
# Heterogeneous Split Triage
|
||
|
||
Produced the 2026-09-16 no-go verdict on splitting Qwen3.8-Flash-Next across
|
||
the RTX 3090 + Strix Halo pair. Use it to answer "could we split model X
|
||
across device A and device B" with arithmetic instead of enthusiasm.
|
||
|
||
## 1. Characterise the link (bytes vs latency)
|
||
|
||
```bash
|
||
lspci -vv -s <bdf> | grep -E "LnkCap:|LnkSta:" # capability vs actual
|
||
nvidia-smi --query-gpu=pcie.link.gen.max,pcie.link.gen.current,pcie.link.width.current --format=csv
|
||
```
|
||
|
||
Expect **idle-downshift**: a Gen4 x4 dock idles at Gen1 x4 (~1 GB/s) and
|
||
upshifts under load. Measure while the device is busy, or you size the design
|
||
against the wrong number. Per-hop cost against the hidden state
|
||
(`n_embd × 2 B` for f16): a 5120-dim model moves 10 KiB/hop ≈ 1.3 µs at
|
||
Gen4 x4.
|
||
|
||
- **Bytes per token ≈ hops/token × hidden state** — typically ~1 MiB/token
|
||
for 2 hops/layer × 64 layers ≈ 0.3 % of a 40 ms token. **Bytes are almost
|
||
never the constraint.**
|
||
- The real budget line is **hop COUNT**: every device boundary is a
|
||
host-side sync. A runtime that splits per *op* hits ~100 splits/token and
|
||
pins the GPU at ~5 % SM. Measure `graph splits` in the loader log; never
|
||
assume.
|
||
- **Design rule: partition by where the weights live, never by where the
|
||
activations flow.** Resident weights, activations crossing.
|
||
|
||
## 2. Get the workload's real bottleneck (KV is usually it)
|
||
|
||
```
|
||
KV/token = kv_bytes_per_position × context
|
||
```
|
||
|
||
Attention reads the whole cached context every token, so KV/token scales
|
||
with context — compare it against expert/weight traffic per token and total
|
||
token time. At long context on a hybrid arch (few attention layers, many
|
||
positions), **KV dominates**. Consequences:
|
||
|
||
- The highest-leverage placement is **KV cache on the fastest-memory device,
|
||
together with its attention compute** — never KV alone: separating them
|
||
puts KV/token over the link, which is fatal (GBs across 7.88 GB/s =
|
||
hundreds of ms/token).
|
||
- Check the fit: `kv_positions_per_slot × slots × bytes_per_position +
|
||
attention_trunk ≤ VRAM`.
|
||
|
||
## 3. What placement never buys
|
||
|
||
Prefill is compute-bound and expert weights are read once per batch, so
|
||
moving them to faster memory buys nothing while adding a serial stage.
|
||
Expect a regression, and size it from calibration: on Strix Halo + RTX 3090
|
||
(Qwen3.8-Flash-Next, ik_llama.cpp) moving 12.5 % of the expert mass to the
|
||
GPU gave **decode +12 %, prefill 192 → 32 tok/s**. A
|
||
`1/(f_big/BW_slow + f_fast/BW_fast)` harmonic calculation reproduces that
|
||
decode gain to within ~2 points — usable for decode projections, and it
|
||
assumes sequential stages (true at batch 1; no pipelining).
|
||
|
||
## 4. Is the second device even a separate pool?
|
||
|
||
On a UMA APU, check before assuming additivity:
|
||
|
||
```bash
|
||
cat /sys/class/drm/card*/device/mem_info_vram_total # BIOS carve-out (small)
|
||
cat /sys/class/drm/card*/device/mem_info_gtt_total # aperture onto SYSTEM RAM
|
||
rocm-smi --showmeminfo all | grep -i gtt # used == what the CPU also sees
|
||
```
|
||
|
||
A large GTT window means the "GPU memory" is the *same* LPDDR under one
|
||
memory controller: adding a stage there adds a compute engine, not capacity
|
||
or bandwidth. Only the discrete card's VRAM is additive. Fencing more
|
||
(`amdgpu.gttsize`, BIOS UMA size) just removes bytes from CPU-visible RAM.
|
||
|
||
## 5. Speculative decoding as the "cheap" split
|
||
|
||
A drafter on the *other* device is bandwidth-free (token ids per round, tens
|
||
of bytes), so it is tempting. Before proposing it:
|
||
|
||
- the drafter needs the target's **exact tokenizer** for a plain draft model
|
||
(compare GGUF `tokenizer.ggml.tokens`, never trust the family name), or it
|
||
must be a head **trained for that target** (EAGLE/DFlash/MTP are
|
||
conditioned on the target's hidden states and are not portable between
|
||
models);
|
||
- a plain drafter costs **K sequential forwards per round**, so it must be
|
||
10–50× faster than the target. Break-even with `r = t_draft/t_target`:
|
||
`rate_ratio = A/(K·r + 1)`, `A = (1−p^(K+1))/(1−p)`; at `r ≈ 0.4–0.5`
|
||
(a drafter only ~2× faster) break-even needs `p ≈ 0.7+` at K=3;
|
||
- compare against the **incumbent**, not the no-draft baseline: a target
|
||
already running its own MTP head is a high bar;
|
||
- check the runtime accepts an external drafter at all — closed engines may
|
||
enumerate a fixed drafter set and 400 anything else (capability gates
|
||
keyed on the loaded checkpoint). That is a design boundary, not a config
|
||
gap. In llama.cpp the portable mechanism is `--spec-draft-device/-devd`
|
||
(draft gets its own device list) plus `-ngld`/`-otd`/`-ctkd -ctvd`.
|
||
|
||
## 6. Expressing a split (llama.cpp, the usual vehicle)
|
||
|
||
```bash
|
||
-dev CUDA0,Vulkan0 -sm layer -ts F0,F1 # contiguous layer ranges (simplest)
|
||
-ot "\.attn_.*=CUDA0" -ot "\.exps\.=Vulkan0" # per-tensor, by ROLE (KV co-location)
|
||
```
|
||
|
||
- buffer types match by name at load (`ggml_backend_buft_name`), so device
|
||
names work as `-ot` values;
|
||
- `-cmoe`/`-ncmoe` are **CPU-only**; device-side MoE placement is `-ot`;
|
||
- several backends in one binary is fine (CUDA + Vulkan is the practical
|
||
pair; HIP + CUDA do not coexist) — `GPU_BACKEND_A=OFF` in the existing
|
||
build's CMakeCache tells you what needs adding;
|
||
- contiguous ranges are the WRONG split when KV bandwidth dominates: they
|
||
drag half the attention layers (and their KV) back to the slow pool.
|
||
|
||
## 7. Hazards checklist
|
||
|
||
- Paged/sidecar tables (huge n-gram/embedding tables) must stay where they
|
||
are read from; moving them is pure loss.
|
||
- Keep `mmap` on for models with huge embedding tables (anonymous memory
|
||
gets the process cgroup-killed).
|
||
- CUDA graph capture can abort on unusual graph shapes (hyper-connections)
|
||
— check before assuming optimisations apply.
|
||
- A brand-new backend on the second device may not implement the arch's ops
|
||
at all: make the first measurement a **baseline of the runtime on that
|
||
device alone** — a split's arithmetic is worthless if the baseline is
|
||
worse than the incumbent engine.
|
||
- A split consumes the fast device entirely — check what else it was
|
||
serving before trading it away.
|
||
- Benchmark discipline on production hosts: stop and mask first
|
||
([[Fleet Ops Playbook]]).
|
||
|
||
## 8. Verdict shape
|
||
|
||
Rank and report; prefer the boring answer when it wins:
|
||
|
||
1. **Two independent endpoints** (zero shared traffic) — often the real
|
||
answer to "what's a useful combination", especially over a narrow link.
|
||
2. **One process, weights-resident, KV co-located with attention on the
|
||
fast pool** — promising and quantifiable (+% decode at long context);
|
||
needs a dual-backend build and a baseline measurement.
|
||
3. **Hot-expert cache / per-token weight streaming** — affordable only if
|
||
the miss set per token is small (a few experts; check
|
||
`misses × bytes × tok/s` against link bandwidth) and overlapped; injects
|
||
PCIe latency into the critical path; no mainstream runtime implements a
|
||
dynamic expert cache.
|
||
4. List explicitly what does NOT help: TP/collectives across unequal
|
||
devices, KV on the far device, model loading over the link,
|
||
prefill-motivated placements.
|
||
|
||
## Related
|
||
|
||
- [[PCIe Link and Atomics]] — the link and collective walls underneath
|
||
- [[Llama Cpp Multi-Device Splits]] — the deployed split shapes
|
||
- [[Llama Cpp Serving Sizing]] — KV and pool arithmetic
|