- config.yml: default/commit -> frank-medium (dankserver); task/tiny/smol/vision -> frank-small (dankclaw-3090); vision xhigh - models.yml: add llamacpp (dankclaw-3090) provider; frank-large preserve_thinking + context notes - niri: focus-ring/border/shadow layout, hot-corners off, re-add noctalia.kdl include; blankpad eDP-1 scale 1.0 - memories: flush rollouts; retire frank_large + marreta_authentik skills; add auth-chain/kv-pool/quadlet-log skills - managed-skills: 40+ new fleet runbook skills - agent/skills: import built-in skill set
5.5 KiB
name, description
| name | description |
|---|---|
| llamacpp-cross-backend-device-split | Decide whether two unlike devices (NVIDIA CUDA + AMD Vulkan, or any two backends) can serve one llama.cpp model, and measure the attempt honestly on a serving host — the two scheduler failure modes, what is actually expressible, the dual-backend build recipe, and the measurement traps that make results meaningless. |
Cross-backend device splits in llama.cpp
Use when someone proposes serving ONE model across two devices of different backend families (NVIDIA CUDA + AMD Vulkan/HIP, or a dGPU + an APU), and you need to know whether it is expressible before spending a build and a service outage on it.
Answer first: it is not expressible
A weight tensor in one device buffer cannot be consumed by an op the scheduler
placed on another device. Two observed failure modes (mainline llama.cpp
37b53fd, 2026-09-16):
| attempt | result |
|---|---|
per-tensor: -ot "exps=Vulkan1" with -sm none -mg 0 -dev CUDA0,Vulkan1 |
ggml-backend.cpp:941: pre-allocated tensor (blk.0.ffn_down_exps.weight) in a buffer (Vulkan1) that cannot run the operation (NONE) — scheduler abort |
per-layer: -sm layer -ts a,b -dev CUDA0,Vulkan1, or layer-scoped -ot |
model load fails, message is only failed to load model |
device + CPU: -ot "exps=CPU" |
works — the CPU backend is the universal fallback and host buffers can be copied |
The rule: the CPU is the only legitimate partner in a heterogeneous split. Everything else must be single-vendor. On a CUDA+AMD host that means a card can be the trunk or the offload host, never the complement of an AMD-resident peer — the same wall the R9700 pair hits from the RCCL/atomics direction.
If the answer is "not expressible", stop and consider the expressible
alternatives in this order: (1) two independent endpoints, one model per device;
(2) single-vendor layer split (-sm layer, no collective needed); (3)
device + CPU per-tensor offload.
Build the dual-backend binary
Needs both toolchains in one tree; Vulkan needs glslc + libvulkan headers
(already present on this fleet's hosts).
cmake -B build-dual -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \
-DGGML_CUDA=ON -DGGML_VULKAN=ON -DCMAKE_CUDA_ARCHITECTURES=<sm> \
-DGGML_CCACHE=ON -DLLAMA_CURL=OFF
cmake --build build-dual --target llama-bench llama-cli llama-server -j $(nproc)
Device names are not what you expect. --list-devices on an
NVIDIA-plus-APU host lists the discrete card under BOTH backends (e.g. CUDA0
and Vulkan0) and the APU last (Vulkan1). The iGPU is Vulkan1, not
Vulkan0. Valid -ot buffer types are whatever that list prints; a wrong name
makes llama.cpp print the valid set and exit, so the error is self-diagnosing.
Measure it without fooling yourself
tgin llama-bench runs withn_prompt = 0. A plain-n 64row is a ~zero-depth decode: no KV traffic at all. Anything about context cost or KV placement must use-d/--n-depth, which fills the cache before timing.-p/-nlists build separate instances (prefill instances withn_gen = 0, generation instances withn_prompt = 0), andn_ctx = n_prompt + n_gen.- Compare at depth-matched settings, and remember prefill into a deep cache is the slow case (e.g. 456 tok/s fresh vs 232 tok/s into 64k).
- Sample the actual engines during the run (
/sys/class/drm/card*/device/gpu_busy_percent,nvidia-smi) — an idle device or a 100 % APU is the difference between a real measurement and a silent CPU fallback. - Watch the host memory figure, not just tok/s. A Vulkan-resident model lives in device buffers (committed, not reclaimable), whereas an mmap'd CPU-placed tensor is page cache — the same model can commit 116 GiB one way and 95 GiB the other. On a host with co-tenants, that decides deployability.
Serving-host discipline
- Stop and mask every LLM service first (repo benchmarking policy). For a
rootless Quadlet, move the
.containerout of~/.config/containers/systemd/anddaemon-reload— the generator re-creates the unit from that file on every reload, somaskis not the lever. systemctl maskis refused for a relinked root unit whose/etc/systemd/system/<svc>.serviceis a symlink into the repo: it exits withFile ... already exists and is a symlink to .... Stop it and ensure nothing restarts it, or move the symlink aside.- Root units need
sudo systemctl start; without it you getAccess denied as the requested operation requires interactive authentication(polkit), which reads like a permissions bug. - Export the pinning guard: mainline is
GGML_CUDA_NO_PINNED(ik_llama.cpp wasGGML_CUDA_NO_PINNED_WEIGHTS). Without it a CPU-overridden tensor can land in a pinnedCUDA_Hostbuffer — the failure mode that page-locked 108 GiB and starved a host's co-tenants until a power cycle. - Restore: move the quadlet back,
daemon-reload,reset-failed, start, then verify/health,/v1/modelsand the engine's own boot ledger (e.g. halogen'skv pool:lines, which say whether the configured pool was clamped).
Calibration point
On a 121 GiB Strix Halo host with a 3090: a 177B MoE (112 GiB GGUF, 12 of 48 layers carrying KV) ran 32.6 tok/s shallow / 25.8 tok/s at 64k depth / 456 tok/s prefill with everything on the iGPU via Vulkan, and 12.1 tok/s with the same trunk but experts on the CPU. If a proposed split cannot beat the iGPU-only number at matched depth, it is not worth the failure modes above.