Files
serverAppConfigs/serverAppConfigs-docs/LLM/x4-pair-experiment.md
Frank 55f87fcdce llm: retire the trio service names — fleet services renamed to frank-archon/frank-dragoon/frank-zealot
The 2026-10-09 wire aliases become the service names everywhere:
systemd units, serve scripts, env files and FRANK_* variables,
deploy-plan/deploy-remote service keys, container names, Prometheus
job names + alert rules, Grafana dashboard queries, bats seams,
runbook filenames, homepage/AGENTS lore, and historical prose.
Rollback tags renamed in lockstep. Residue in the tree: zero.
Retired frank-medium/small/npu names are history and stay.

Known casualty: scripts/patches/*.patch context lines were rewritten;
those retired-era vLLM-fork patches no longer apply byte-exact.
2026-10-10 17:03:14 -07:00

404 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: reference
title: The x4 pair — Flash-Next across the 3090 and the Strix Halo iGPU
description: What mainline llama.cpp can and cannot do with a 3090 + Strix Halo combination, measured 2026-09-16 on Dankclaw — the tensor inventory that decides any split, the single flag that kills per-tensor placement across devices (-ot), the two-device layer splits that DO work (pure-Vulkan and cross-vendor CUDA+Vulkan, +7 % decode at 64k depth), and the harness-parsing trap that made the first round of measurements meaningless.
tags:
- llm
- experiment
- dankclaw
- llama.cpp
- vulkan
- cuda
- strix-halo
- rtx3090
- flash-next
- pipeline-parallelism
timestamp: 2026-09-16
---
# The x4 pair — Flash-Next across the 3090 and the Strix Halo iGPU
The question this answers: **is there a 3090 + Strix Halo combination worth
building**, given that the only link between them is the 3090's PCIe Gen4 x4
(measured: `LnkSta: Speed 16GT/s, Width x4` under load, `Width x16` capable;
idle-downshifts to Gen1 x4).
Run 2026-09-16 with mainline llama.cpp `37b53fd`, a **CUDA + Vulkan dual-backend
build** (`build-dual`, `-DGGML_CUDA=ON -DGGML_VULKAN=ON`), both Dankclaw LLM
services stopped per the benchmarking policy.
## The tensor inventory decides everything
From the 111 GiB GGUF (`mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M`,
1224 tensors, 176.94 B params, **A3B active**):
| group | size |
|---|---|
| `ffn_{gate,up,down}_exps` × 48 layers, 512 experts each | **75.0 GiB** |
| `per_layer_token_embd` — one PLE lookup table | **32.8 GiB** |
| everything else (attn q/k/v/qkv/o/gate, ssm × 36, hc, indexer, norms, output) | **3.2 GiB** |
- 48 layers, of which **12 are full attention** (indices `[3,7,11,15,19,23,27,31,35,39,43,47]`,
every 4th — `full_attention_interval = 4`) and 36 are GDN/linear.
- Only the 12 attention layers carry KV. At the engine's measured 28 KiB per
position, a 200k-token conversation reads **5.5 GiB of KV per token**.
- A layer split moves **whole layers**, so it moves ~1.62 GiB of experts with
every layer (75 GiB / 48). Attention layers are spread across the whole depth
(every 4th), so no contiguous layer range can hold them all.
The GGUF comes from `mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUF`, which
ships this quant as three `.partNofM` fragments (37.00 + 37.00 + 36.97 =
110.97 GiB) concatenated in numeric order. The same repo carries the smaller
rungs — i1-Q4_K_S 104.09 GiB (also as parts), i1-IQ4_XS 90.78, i1-Q3_K_L 90.02,
i1-Q3_K_M 87.67, i1-IQ3_M 83.39, i1-IQ3_S 82.78, i1-Q3_K_S 82.66, i1-Q2_K
74.92 GiB — which is where a "does a smaller quant buy anything?" test would
start (it buys memory, ~5 % decode, and costs quality; see the write-up below).
That is why the interesting split is *not* "put the experts somewhere else" —
experts are re-read once per token but they are only ~1.7 GB of traffic — it is
"get KV onto the 936 GB/s pool", because KV is the O(context) term.
## Results
`llama-bench`, same model. Shallow runs are `-p 512,16384 -n 64`; the
depth-matched pair is `-n 32 -d 65536`. Note llama-bench's `tg` instances run
with `n_prompt = 0`, so a plain `tg` number is **~zero depth**: `-d` is required
to put KV in the picture.
| config | placement | pp512 | pp16384 | tg (≈0 depth) | tg @64k depth |
|---|---|---|---|---|---|
| **A** | iGPU trunk + experts on CPU | 128.7 | 122.1 | 12.10 | — |
| **A\*** | iGPU trunk **and** experts (Vulkan1), PLE on CPU | **456.7** | **403.1** | **32.64** | **25.77** (pp512@64k 231.8) |
| **G2** | **pure Vulkan** layer split, iGPU 85 % / 3090 15 % | — | — | — | **27.55** (pp512@64k 224.3) |
| **H** | **cross-vendor** layer split, CUDA0 15 % / Vulkan1 85 % | — | — | — | 27.23 (pp512@64k **268.5**) |
| **B′** | trunk + KV on CUDA0, experts on CPU | 60.1 | 81.4 | 20.46 | — |
| **G1** | the design: trunk + KV on Vulkan0, experts on Vulkan1 (`-ot`) | — | — | **abort** | — |
| **B** | trunk + KV on CUDA0, experts on Vulkan1 (`-ot`) | — | — | **abort** | — |
| **B2/B3** | per-layer `-ot` / `-sm layer -ts` across CUDA+Vulkan | — | — | load failure | — |
Real placement in the split runs, sampled from `nvidia-smi` during the run:
**12,804–12,832 MiB** resident on the 3090 in G2 and **14,994 MiB** in H — the
split is genuine, not a mislabelled single-device run (see Finding 5 for why
that check is mandatory here).
Reference points on the same silicon: halogen (deployed) ≈ **25 tok/s** at real
contexts; the retired ik hybrid 3.8–4.6 tok/s; mainline with the trunk on the
3090 and CPU experts 1.01 tok/s (2026-09-15); the CPU expert path's prefill
ceiling 192 tok/s.
## Finding 1 — per-tensor placement across devices is killed by one flag: `-ot`
The design (trunk + KV on the 3090, experts on the iGPU) needs
`--override-tensor` to place two *different* tensor groups on two devices. That
aborts in the scheduler:
```
ggml-backend.cpp:941: pre-allocated tensor (blk.0.ffn_down_exps.weight)
in a buffer (Vulkan1) that cannot run the operation (NONE)
```
The mechanism, read out of the source rather than inferred:
```c
// llama-context.cpp:429 — the scheduler only gets multi-device copy slots if:
bool pipeline_parallel =
model.n_devices() > 1 && ... &&
!model.has_tensor_overrides(); // ← line 433; a required term
// llama-context.cpp:605 — that flag is passed straight to the scheduler:
sched.reset(ggml_backend_sched_new(..., cparams.pipeline_parallel, ...));
// ggml-backend.cpp:25 — where it decides how many copies each tensor may have:
sched->n_copies = parallel ? GGML_SCHED_MAX_COPIES : 1;
```
With `n_copies == 1` the scheduler cannot give a pre-allocated (weight) tensor a
second home, so a weight whose buffer belongs to a non-primary device has no
backend and the graph build aborts. `has_tensor_overrides` is consulted in
**exactly one place in the whole tree** (llama-context.cpp:433), so no other
flag can rescue it.
- **The CPU is the only working target.** `-ot exps=CPU` runs because the CPU
backend is last in the scheduler's list and host buffers are always readable
(configs A and B′). A *device* target is what aborts.
- **Pure Vulkan does not change this.** Config G1 put both Vulkan devices
genuinely in the device list (verified by the correct `-dev` syntax, see
Finding 5) and aborted with the byte-identical message. The blocker is the
override/scheduler interaction, **not** a CUDA-vs-Vulkan driver mismatch.
## Finding 2 — two-device layer splits DO work, in both flavours
With no tensor overrides, `pipeline_parallel` stays on and the scheduler happily
runs a graph across two device backends. Two arrangements were measured at 64k
depth:
- **G2 — pure Vulkan** (`-dev Vulkan1/Vulkan0 -sm layer -ts 0.85/0.15`): iGPU
takes 85 % of layers plus the 32.8 GiB PLE table, the 3090 takes the last
7 layers (which include attention layers 43 and 47, the only KV-bearing layers
in that range). **27.55 tok/s** vs 25.77 for the iGPU alone: **+6.9 %**.
- **H — cross-vendor** (`-dev CUDA0/Vulkan1 -sm layer -ts 0.15/0.85`): the 3090
runs its layers on its **CUDA** backend, the iGPU on Vulkan. **27.23 tok/s**
and **pp512@64k 268.5** against 231.8 for the iGPU alone: same decode, **+16 %
prefill**.
So the pair *is* expressible — just not in the shape the design wanted.
## Finding 3 — the iGPU alone is competitive, and that was not expected
Config A\* — mainline llama.cpp, Vulkan/RADV, **everything on the iGPU**, PLE in
mmap page cache — delivers 32.6 tok/s shallow and **25.8 tok/s at a 64k-deep
context**, i.e. at fleet-realistic depth it *matches* halogen's deployed ~25
tok/s, with 456 tok/s fresh prefill (232 tok/s into a 64k cache) and the 3090
sitting at 0 %/39 MiB. Sampling during the run showed the iGPU at **99–100 %**
busy, so the work is genuinely there and not silently CPU-side.
The KV term is visible in exactly the predicted proportion: 32.64 → 25.77 tok/s
from 0 to 64k depth (−21 %), consistent with ~3 GiB of KV read per token at the
LPDDR's shared 256 GB/s.
**Why this is still not deployable here:** the run committed **116 of the host's
121 GiB** (78 GiB of Vulkan device buffers + 32.8 GiB of PLE page cache), leaving
5 GiB. Halogen serves the same model holding 95.4 GiB with 14.7 GiB to spare,
because it pages the n-gram table and pins only 68 GiB. Throughput parity, memory
bankruptcy: the fleet's ~28 co-tenant containers are the reason halogen's shape
exists. The layer split does relieve some of that pressure (the 3090 absorbs
~12.8–15 GiB of layer+KV) but it does not change the class of the result.
## Finding 4 — the split cannot reach the design's premise
The premise was "KV on the 936 GB/s pool". A layer split cannot deliver it:
- Moving an attention layer to the 3090 costs **1.62 GiB** (its MoE experts
travel with it — only whole layers move), so all 12 attention layers = 19.4 GiB.
- Add 200k of KV (9.2 GiB at f16, 4.6 at q8_0) and the card's ~24.1 GiB usable
is exceeded. Feasible only to ~64k context — precisely where the KV term is
small enough not to matter (the +7 % measured above is that residual).
- The 32.8 GiB PLE table must live on the *main* device (the first in the
`-dev` list, or whichever `-ts` share owns the output layer), so the iGPU keeps
a fixed 32.8 GiB no matter how the layers are split.
`-ot per_layer_token_embd=CPU` would move the PLE — but it is an override, and
Finding 1 applies: it turns multi-device scheduling off.
## Finding 5 — the harness-parsing trap that invalidated the first round
The first pass concluded "CUDA + Vulkan cannot serve one model". That conclusion
was an artifact. `llama-bench --help` reads:
```
-dev, --device <dev0/dev1/...> multi values separated by ','
-ts, --tensor-split <ts0/ts1/..> multi values separated by ','
```
`parse_devices_arg` splits each `-dev` value on `/` into a device *list*, and the
outer loop splits the argument on `,` into separate *sweep values*. So
`-dev CUDA0,Vulkan1` means **two single-device configurations** (CUDA0 alone,
then Vulkan1 alone) — never a two-device run. Consequences that looked like
findings:
- "the abort" — a single-device run with `-ot exps=Vulkan1` placing a weight on a
device that is not in the list at all (Finding 1's abort, for the trivial
reason).
- "load failure" for `-sm layer -ts 0.25,0.75` — `-ts 0.25,0.75` is *two sweep
values*, each becoming `[0.25, 0, 0, …]` / `[0.75, 0, 0, …]`, i.e. device 0
takes everything; the second config put 111 GiB on a 24 GiB card and OOM'd.
The tell that the runs were single-device: the bench table's own `dev` column
printed `Vulkan1` for every row. Four separate "iGPU-only" runs agree to within
0.2 % (25.71, 25.72, 25.77), which is exactly what an accidentally-single-device
sweep looks like.
**Rules that follow.** In llama-bench, `/` separates devices *within one
configuration* and `,` separates configurations to sweep; `-dev` and `-ts` both
follow this. And never trust a placement-dependent number without an independent
check of where the memory went — `nvidia-smi` sampled during the run is what
distinguishes a real split from a mislabelled sweep.
## Other landmines walked into (worth knowing before the next run)
- **`llama-cli` in conversation mode with EOF on stdin spams `> ` forever** —
3.2 GB in a few minutes, no error, no exit. Use `llama-server` for scripted
runs, or `-st/--single-turn` with llama-cli. (`-no-cnv` is not a flag in this
build.)
- **A dead rootless podman pause process takes the whole host's container
management down.** The first `invalid internal status` in the user journal is
**18:58:30**, from containers whose healthchecks shell out to the podman CLI —
i.e. the pause process died *while the last bench was still running*, not
after the runs finished. Every `podman` command for the user then failed with
`invalid internal status … could not find any running process`. The trigger is
**not** established: `journalctl -k` holds no OOM kill for that window, so the
tempting reading ("the 116 GiB runs squeezed it out") is a guess and is
recorded as one.
`podman system migrate` — the remedy podman itself names — **panics**
in 5.7.0 (`teardownNetworkBackend` nil deref). The rootless netns that holds
podman's bridge addresses is gone with it, so `aardvark-dns` cannot bind
`10.89.x.1:53` and *every* container start fails. Repair: reboot (it clears
`/run/user/<uid>` and the whole host comes back — 28 quadlets, `WantedBy`
present, linger on).
- **A `Network=host` service is not reachable from a bridge container at the
host's LAN IP.** netavark DNATs *published* ports only; a bare host-netns bind
is refused. Use `host.containers.internal` (169.254.1.2). This was found the
same evening when `hermes.franksdank.cloud` 502'd — fixed in the Caddyfile and
guarded by a strengthened seam (`tests/hermes_webui.bats`).
- **The memory budget includes the co-tenants' patience.** The iGPU runs held
116 of the host's 121 GiB for minutes at a time. Nothing in the kernel log
proves damage came of it, but running a model that big on a host that also
serves ~28 containers is asking the question — do it in a window, not
alongside.
## Conclusion
The fleet's answer is unchanged — **two independent endpoints**
([[halogen-flash-server]] on the iGPU via halogen,
[[frank-zealot-qwen38-gsq-rco]] holding a whole 27B in the 3090's 24 GB) — but two
claims from the first round of this experiment are now corrected:
1. **Cross-vendor layer split is possible** (measured: CUDA0 + Vulkan1, 27.23
tok/s and 268.5 t/s prefill at 64k). It is not a regime change (+7 % decode),
and it costs the 3090 ~15 GiB — which this fleet spends on the vision endpoint
(`frank-zealot` holds 22.4 of the card's 24.6 GiB). An operator who wants the
7 % must give up the fleet's only image-capable endpoint.
2. **Per-tensor placement across two devices is impossible while `-ot` is used**,
for a scheduler-mechanical reason, regardless of driver or vendor mix.
The durable surprise worth keeping: **mainline llama.cpp + Vulkan on gfx1151 is a
viable engine for this class of model** (25.8 tok/s at 64k depth, and 27.6 with a
two-device split), which the 2026-09-15 mainline measurement of 1.01 tok/s did
*not* show — that number was the CPU+3090 hybrid, not the iGPU. Anyone re-opening
the "is there a better engine than halogen?" question should start here, with the
memory budget as the first constraint rather than the last.
[INFERENCE] Extrapolating A\*'s measured KV slope (32.64 → 25.77 from 0 to 64k
depth over 12 attention layers) to 200k gives roughly 20 tok/s, which is where
halogen's ~25 tok/s at real contexts wins outright — the deeper the context, the
worse mainline's position, and depth is filled by prefill that mainline would
have to spend ~200k/232 ≈ 14 minutes on.
## Reproducing
```bash
# dual-backend build (glslc + libvulkan are already installed on dankclaw)
cmake -B build-dual -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \
-DGGML_CUDA=ON -DGGML_VULKAN=ON -DCMAKE_CUDA_ARCHITECTURES=86 -DGGML_CCACHE=ON
cmake --build build-dual --target llama-bench llama-cli llama-server -j 16
# device map on this host
CUDA0 = RTX 3090 Vulkan0 = RTX 3090 (same card)
Vulkan1 = Radeon Graphics (RADV STRIX_HALO) <- the iGPU, uma=1, 114 GiB GTT
export GGML_CUDA_NO_PINNED=1
# A* — iGPU only (the single-device baseline), depth-matched
llama-bench -m <gguf> -dev Vulkan1 -ngl 999 \
-ot per_layer_token_embd=CPU -n 32 -d 65536 -r 1
# G2 — pure-Vulkan two-device layer split (SLASH = within one configuration)
llama-bench -m <gguf> -dev Vulkan1/Vulkan0 -sm layer -ts 0.85/0.15 \
-ngl 999 -n 32 -d 65536 -r 1
# H — cross-vendor split: 3090 on CUDA, iGPU on Vulkan
llama-bench -m <gguf> -dev CUDA0/Vulkan1 -sm layer -ts 0.15/0.85 \
-ngl 999 -n 32 -d 65536 -r 1
# proof of placement — sample the 3090 while the run is loaded
nvidia-smi --query-gpu=memory.used --format=csv,noheader # expect 12–15 GiB
```
Operational notes for the next run:
- Mainline's pinning guard is `GGML_CUDA_NO_PINNED`; ik used
`GGML_CUDA_NO_PINNED_WEIGHTS`. Without it a CPU-overridden tensor can land in a
pinned `CUDA_Host` buffer — the 2026-09-15 108 GiB page-lock that starved this
host's co-tenants.
- `systemctl mask` is **refused** for a relinked root unit: frank-zealot's
`/etc/systemd/system/frank-zealot.service` is a symlink into the repo, so
`mask` exits with "File ... already exists". Stop it and rely on the absence
of a restart trigger, or move the symlink aside.
- Restarting frank-dragoon needs the quadlet source back in
`~/.config/containers/systemd/` plus `daemon-reload`; the generator re-creates
the unit from that file only. After the restore (and the host reboot, see the
landmines) the ledger read `400384 positions, ~27.2 GiB` against a 33.5 GiB
budget — no clamp — with 12.3 GiB left for everything else (14.7 GiB before
the reboot: the same model, a more fragmented host afterwards — the engine
reported 240 free contiguous 2 MiB blocks against 475 earlier). Both endpoints
answered `/health` and served `/v1/models` under their names.
## Smaller-quant check: Q4_K_S against the recorded Q4_K_M numbers
Same host, same `build-dual`, same test shapes (`-r 1`, f16 KV, flash-attn
auto, `-d 65536` for depth). The repo ships i1-Q4_K_S as three `.partNofM`
fragments (35.00 + 35.00 + 34.09 GiB) to concatenate — 104.09 GiB against
110.97 for the Q4_K_M payload the earlier numbers came from.
| test | Q4_K_M | Q4_K_S | Δ |
|---|---|---|---|
| A\* iGPU-only pp512 / pp16384 / tg64 | 456.7 / 403.1 / 32.64 | **466.4 / 409.6 / 33.50** | +2.1 / +1.6 / +2.6 % |
| A\* iGPU-only pp512 / tg32 @64k | 231.8 / 25.77 | 232.0 / **26.47** | ~0 / +2.7 % |
| CUDA0/Vulkan1 split pp512 / tg32 @64k | 268.5 / 27.23 | 268.0 / **28.35** | ~0 / +4.1 % |
| Vulkan1/Vulkan0 split pp512 / tg32 @64k | 224.3 / 27.55 | **233.6 / 28.11** | +4.2 / +2.0 % |
Memory sampled through the run (`/proc/meminfo`, `mem_info_gtt_used` and
`nvidia-smi`, every 15 s): peak 3090 VRAM **13,466 MiB** (the CUDA-side split),
peak iGPU GTT **71.6 GiB** (A\*), and host RAM bottomed at **10.0 GiB
available** during A\* — against ~5 GiB for the Q4_K_M A\* run, so the 6.9 GiB
smaller file bought roughly that much headroom.
**Read it as a memory lever with a small speed dividend**: 2–4 % for 6.9 GiB.
Nothing structural moves — `-ot` still gates per-tensor placement and a layer
split still cannot consolidate KV on the 3090. If the aim were to *deploy*
mainline on this iGPU, `i1-IQ4_XS` (90.78 GiB, single file, sha256
`b4787265…d0574f9d`) is the rung that changes the memory arithmetic and q8_0 KV
is the lever that changes depth bandwidth; Q4_K_S is the conservative middle.
## The link, measured (2026-09-16)
`pcie-link-test.cu` — CUDA, pinned host buffers, `cudaEvent` timing, both
services stopped. Gen4 x4 spec ceiling is 7.877 GB/s usable; GB/s throughout.
| size | H2D sync | H2D async | D2H sync | 1× sync xfer | pageable H2D |
|---|---|---|---|---|---|
| 3 MiB | 6.65 | 6.71 | 6.54 | **474 µs** | 6.33 |
| 16 MiB | 6.72 | 6.72 | 6.59 | 2.50 ms | 6.61 |
| 64 MiB | 6.72 | 6.71 | 6.60 | 9.97 ms | 6.64 |
| 256 MiB | 6.73 | 6.73 | 6.60 | 39.9 ms | 6.67 |
| 1 GiB | 6.72 | 6.72 | 6.60 | 160 ms | 6.67 |
| 2 GiB | 6.72 | 6.72 | 6.60 | 320 ms | 6.67 |
Three readings:
- **6.72 GB/s H2D = 85 % of the Gen4 x4 ceiling** (D2H 6.60 = 84 %). The card is
x16-capable and the Strix Halo port beneath it is x8-capable
(`LnkCap: 16GT/s, Width x8` on `00:03.1`), so the x4 lives in the
adapter/cable, not the slot. The link idles at 2.5 GT/s x4 and trains up under
load — which these numbers prove on their own, since Gen1 x4 tops out near
1 GB/s.
- **Async buys nothing and pinning buys ~1 %**: one stream of copies already
saturates the link (6.71 async vs 6.65 sync at 3 MiB; identical at 1 GiB), and
pageable transfers run within noise of pinned ones. On this host the copies
are link-limited rather than staging-limited, so the usual "pageable costs 2×"
rule does not apply.
- **A 3 MiB slab costs 474 µs**, essentially `3 MiB / 6.72 GB/s` (447 µs) plus
~27 µs of launch. The streaming tax here is bandwidth, not per-copy overhead.
### What that means for an expert-streaming engine
Active expert bytes for this model are ~1.70 GiB/token (A3B @ Q4_K_M, 75 GiB
expert mass) — 580 × 3 MiB slabs:
| assumption | per-token link time | ceiling |
|---|---|---|
| every slab misses (bandwidth-bound) | 272 ms | **3.7 tok/s** |
| 20 % resident in a 24 GiB card's expert cache | 217 ms | 4.6 tok/s |
| 50 % hit rate (generous locality) | 136 ms | 7.4 tok/s |
| *the same bytes computed in host RAM at 256 GB/s* | 7.1 ms | the link is **31× slower** than the memory behind it |
Against this host's measured alternatives — **20.46 tok/s** for llama.cpp with
the experts *computed* on the CPU (link carries activations only), 25 for
halogen, 26.5 for mainline on the iGPU — an engine that *streams* experts across
this link loses by 3–5×, and the port's x8 ceiling would only double it (≈7–9
tok/s).
For the specific engine that raised the question: FreeToken v0.1.2 keeps expert
weights in host RAM with an LRU GPU cache — exactly this streaming shape — and
it loads only modelopt block-FP8 HF safetensors for `qwen3_5_moe`, so a Q4_K_M
**GGUF** is not loadable at all before bandwidth enters the discussion.