Live after the 1ac4d48 deploy: 'expert_precise_memory': True, runtime overhead
5.25 GiB, expert cache space 17.22 GiB / 7522 slots, swap region 13.7 GiB /
5986 slots, GPU KV cache 398,658 tokens / 1.99x, pool 3,150,000,000, MNBT 1024
-- the probe's projection is now production.
Compile cache proven on consecutive boots of that shape: cold 'Compiling a
graph for compile range (1, 1024) takes 179.24 s' / 'torch.compile took
188.26 s'; warm 'Directly load the compiled graph(s) ... from the cache, took
2.143 s' / 'torch.compile took 3.97 s in total' / 'init engine ... took 41.13 s
(compilation: 3.98 s)'. Host dir 1.4 GB / 4,707 files. Time to /health stays
payload-bound (490 s warm), so the claim is ~184 s of inductor/AOT build per
restart, not a proportional boot cut.
Tests: 367/367.
63 KiB
AGENTS.md — serverAppConfigs Agent Rules
Overview
This repository is the single source of truth for the two homelab servers and
the FrankBook-Pro Mac mini. It tracks the Podman container stacks and the LLM
inference fleet. One repo, one checkout per host, one deploy script. All
services run from /home/dank/serverAppConfigs on each host (the 2026-08-22
merge absorbed the former vllmScripts repo, history preserved).
- Dankserver (192.168.0.235) — media/library stack (RomM, Copyparty,
Calibre-Web, Jellyfin, Plex) plus the
frank-primaryLLM service. - Dankclaw (192.168.0.143) — AI/search/dev/observability stack (SearXNG
via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, Loki,
Alloy, Pi-hole) plus TWO LLM services:
frank-large(ROCmFPX llama.cpp fork on the gfx1151 iGPU) andfrank-small(llama.cpp CUDA on the RTX 3090). Caddy on Dankclaw reverse-proxies both hosts. - FrankBook-Pro (192.168.0.112) — macOS mini running the *arr stack
(Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) as quadlets inside
its podman machine; all egress rides the Gluetun WireGuard netns. Deployed
via the
frankbook-prohandler.
Fleet topology (ACTIVE)
| Target | Service | Runtime | Model | Port |
|---|---|---|---|---|
| Dankserver | frank-primary |
tcclaviger RDNA4 vLLM fork (docker.io/tcclaviger/vllm@sha256:da4029666…, digest-pinned, plain podman run, root systemd unit) |
orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8 (~172.8 GiB, 131 shards), TP=2, expert offload automatic (fork plans it), prefetch OFF, fp8 KV (--kv-cache-dtype fp8), no speculative decode, MNBT 1024, MoE gather 32×64, --expert-precise-memory (fitted 5.25 GiB overhead, not the 7.60 GiB default → expert arena 17.22 GiB / 7,522 slots, +16 %), GPU_MAX_HW_QUEUES=2, mounted cache dir at /root/.cache (the image ships no /root/.cache at all; unmounted, every boot re-pays torch.compile took 181.20 s), 3 slots × 200704 ctx at KV_BYTES 3,150,000,000/rank — the pool, not the slot count, caps resident conversations at ~2 full-length (measured live: GPU KV cache size: 398,658 tokens, Maximum concurrency for 200,704 tokens per request: 1.99×), and shorter requests use the spare blocks. --max-num-seqs must stay 3: SEQS=2 cannot boot this model — torch.fx ConstraintViolationError on query_start_loc, measured at BOTH MNBT 1024 and 2048. The pool cut buys runtime headroom, NOT arena: the arena is sized by max_model_len via the registry KV reserve (262144 → 6,248 slots; 200704 → 6,496) plus the overhead term (--expert-precise-memory → 7,522) — util 0.98, and the client contract wired: `--reasoning-parser … |
|
| Dankclaw | frank-large |
ROCmFPX llama.cpp fork (kingjones30/ROCmFPX @ 36e9acd, gfx1151, built by scripts/build-rocmfpx.sh; bare llama-server, root systemd unit) |
kingjones777/Qwen3.8-27B-ROCmFP4-STRIX-MTP-GGUF Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf (26.28 GiB, ftype 115 ≈ 8.39 bpw: 353 F32 + 187 Q8_0 + 311 GGML_TYPE_Q8_0_ROCMFPX tensors — only this fork can read them), MTP spec decode from the repo's own mtp-Qwen3.8-27B-Q4_0.gguf (n-max 4, pinned to ROCm0), 1 session × 262,144 (native window) with f16 KV, the bundled BF16 mmproj for vision, thinking on with the card's recipe (temp 1.0 / top_p 0.95 / top_k 20). Measured 2026-10-02: decode 17.0–17.1 t/s with MTP vs 7.6 without (+123 %), draft acceptance 0.462, prefill 325–351 t/s, prompt-cache replay 0.5 s, boot→healthy 16 s, GTT 64.5 GiB. Cut over 2026-10-02 from gufo; rollback tag frank-large-gufo-q4kxl-20261001 |
7770 |
| Dankclaw | frank-small |
llama.cpp master, CUDA sm_86 build (root systemd unit, relinked) | bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF Q5_K_M (9B dense qwen3_5 hybrid, 6.4 GiB GGUF, no MTP weights so no spec decode) + the repo's f16 mmproj for vision, on the RTX 3090, 3 × 180,224 (pool 540,672 — NOT the native window; three contexts cost 81,920 tokens per conversation) with asymmetric KV f16 K + q8_0 V under flash attention, 21,684 used / 2,441 free of 24,576 MiB, 106.8 tok/s decode, 3,814 t/s prefill. Resized twice 2026-10-02 (Q6_K → Q5_K_M → 3 slots + vision → this pool); rollback models/MiMo-Q6K/ or PARALLEL=2 CTX_SIZE=524288 |
7771 |
Retired endpoints (frank-npu, and every prior runtime) are in
the runbook index. The full swap history lives in
serverAppConfigs-docs/LLM/index.md — do not grow it here.
Repository Layout
dankserver/— compose.yaml + Caddyfile + .env.example for the media stackdankclaw/— compose.yaml + Caddyfile + .env.example for the AI stackshared/— homepage, Prometheus, Loki, Alloy, SearXNG configsscripts/—deploy.sh/deploy-plan.py/deploy-remote.sh, LLM serve scripts, systemd units, env tunables, bench harnessesold/— retired LLM scripts (reference only)serverAppConfigs-docs/— knowledge base (Obsidian vault, OKF format)tests/— bats suite + Playwright e2e
Git Workflow
All edits flow through git. Never edit files on a remote machine directly.
- Source the live config from the target machine
- Commit the fetched version here (redact secrets into
.env) - Edit locally
- Test —
./tests/run.shmust pass - Commit and push — origin is Gitea on Dankclaw
- Pull on the target (
deploy.shdoes this) - Deploy —
scripts/deploy.shplans and dispatches only changed services
Deployment
./scripts/deploy.sh dankclaw
./scripts/deploy.sh dankserver
./scripts/deploy.sh dankclaw --service searxng
./scripts/deploy.sh dankserver --service frank-primary
./scripts/deploy.sh dankclaw --dry-run
The planner maps changed paths to services; the remote runner restarts only
the selected service and records its revision outside the checkout.
Documentation-only changes are a no-op. LLM services are selected only by
their own files — every endpoint (frank-large/frank-small/frank-primary)
by its unit/env/serve trio in scripts/ — so
unrelated
container changes cannot interrupt inference. Set FRANK_SUDO_PW for headless
runs on a host without passwordless sudo; the value never enters the repo.
deploy.sh <target> with no --service is a fleet-wide DRAIN, not a sync.
It also walks every per-service revision marker under the deploy-state dir and
restarts each service whose marker is behind HEAD and whose paths changed
since that marker — so a backlog accumulated by service-scoped deploys fires
all at once, and one failing health gate aborts the run with every later
service left pending. Name the service unless a drain is what you want
(./scripts/deploy.sh dankserver --service frank-primary).
Deployment completeness (MANDATORY)
A deploy is not complete when deploy.sh exits green. It requires:
./tests/run.sh— all seams green, and- the Playwright e2e suite (
tests/e2e/run.sh) — runs automatically as the[3/3]gate at the end ofdeploy.shagainst the LIVE fleet; a behavior-affecting deploy that fails it exitsDEPLOY INCOMPLETE. Bypass only with--skip-e2ein an emergency.
graph LR
A[Edit locally] --> B[Tests pass] --> C[Commit + push]
C --> D[deploy.sh plan] --> E[Target pulls ff-only]
E --> F[Dispatch selected service] --> G[Per-service health]
G --> H[Playwright e2e gate] --> I{green?}
I -->|yes| J[Deploy complete]
I -->|no| K[DEPLOY INCOMPLETE]
Boot Persistence & Self-Healing (MANDATORY)
Every deployed, working Podman container MUST have a self-healing,
boot-persistent Quadlet unit. A container that runs only from compose.yaml
or a manual podman run dies silently at reboot — rootless Podman does not
auto-start bare containers, and restart: unless-stopped does not survive the
explicit stop that shutdown performs. Never report such a service as "up".
Unit requirements:
- Boot persistence —
[Install] WantedBy=default.target, installed to~/.config/containers/systemd/<name>.container, enabled in the user manager, with linger on for the deploy user (loginctl show-user dank -p Linger→Linger=yes). - Self-healing —
Restart=always(oron-failure) with a shortRestartSec; quadlet health check where the service has one. - Repo provenance — the
.containerfile lives in this repo (<host>/<svc>/quadlet/), is installed by the deployer, and is covered by the quadlet test seam. The host-side unit is generated state; the repo file is the source of truth.
Before declaring a service deployed: systemctl --user is-enabled <name> →
enabled, is-active → active, and the container answers its health check.
A running container with no unit is a gap: write the unit, test it, deploy it.
This rule exists because reboots found gaps twice (2026-08-26: Caddy as a
bare compose container never returned; 2026-09-11: five observability
services had no units). The observability services now have units under
dankclaw/observability/quadlet/ and their compose definitions were removed —
one owner per container name. The stack is configured and delivering as of
2026-09-17: a Grafana datasource and the llm-fleet dashboard are
provisioned from shared/grafana/provisioning/, shared/prometheus/rules/ llm-fleet.yml loads 5 alert rules + 1 recording rule, and firing rules are
delivered: Prometheus → Alertmanager (dankclaw/observability/quadlet/ alertmanager.container, loopback :9093) → host-side Signal relay
(scripts/signal-relay.py, user unit, 192.168.0.143:18093 — the host LAN
IP, the one bind a container can reach as host.containers.internal;
iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311.
Prometheus targets read down for structural reasons (HTML-scraped "probes",
Gitea metrics off); the remedy is a blackbox exporter. See
serverAppConfigs-docs/Infrastructure/Observability.md.
Testing
Tests are mandatory before commit (bats required on the dev machine):
./tests/run.sh
Seams: compose validity, env consistency (no leaked secrets), Caddyfile
validity, quadlet units, docs completeness including vault link integrity,
script syntax (shell + python), LLM unit integrity, LLM secret hygiene,
deployment flow, service-scoped planning, homepage config/pages/deploy/checker,
and e2e_scaffold — which fails if a Playwright spec file's number prefix is in
no project, because such a spec silently never runs.
See serverAppConfigs-docs/Architecture/Testing Policy.md.
Secret Rules
- Never commit real secrets. The live Gitea DB password and sudo password are test-enforced leak checks.
.envfiles are git-ignored; commit only.env.examplewith placeholders. Exception:scripts/*.envLLM tunables (paths and numbers, never credentials).- Do not document passwords anywhere in the repo. SSH keys are the auth
mechanism;
FRANK_SUDO_PWcovers headless sudo and never enters the repo. - A token passed as
curl -H "Authorization: Bearer …"is world-readable. It lands in/proc/<pid>/cmdline, so anyps/pgrep -aon the host prints it — that leaked an HF token into a session transcript on 2026-09-16, while merely checking whether a download was still running. Pass secrets out of band (curl -K <mode-600 file>carrying the header, orHF_TOKENin the environment forhf/hf_transfer) and neverpgrep -aa fetch command that carries an auth header.
External Exposure Policy (MANDATORY)
Every Caddy-proxied service is either behind the auth chain (Anubis :8923 → Authentik embedded outpost) or explicitly internal-only — no public DNS name, no vhost. New services default to internal-only until guarded.
When guarding a new vhost — provider first, then route:
- Create the outpost proxy provider FIRST (
external_host= exact vhost URL with trailing slash;internal_host = http://caddy:8017). If the vhost flips first, authenticated traffic hits the outpost error page. - Flip the vhost to
reverse_proxy anubis:8923withheader_up X-Real-Ip {remote_host}, and add the post-auth@<svc> host <vhost>handler on thehttp://:8017listener routing to the real upstream. - Anubis guard set, Caddy TLS edge, and outpost provider list change together in one deploy.
- Service-to-service traffic targets internal addresses (
loki:3100), never the public hostname — non-browser clients cannot solve challenges.
Exempt categories (document the reason in the vhost comment): the IdP itself
(auth.), git smart-HTTP endpoints, services with their own complete auth.
Deletion & Trash Policy (MANDATORY)
Never rm. Move deletions into trash so they stay recoverable:
- In-repo files →
.AGENTTRASH/beside the item's directory. - Elsewhere → dated staging dir
~/.AGENTTRASH/YYYYMMDD-topic. - List staged items and sizes; wait for explicit user approval to purge.
rm, rm -rf, and git clean -f are forbidden without explicit permission.
git * --no-verify is forbidden. The trash is a temporary hold, not an
archive.
Benchmarking Policy (MANDATORY)
STOP the production service BEFORE benchmarking — these servers serve
real users, and a bench beside a live service starves the GPU and corrupts
results. Use the runtime's own harness: llama-bench for the llama.cpp
endpoints; a live llama.cpp server also reports honest per-request numbers in the /completion timings object (prefill/decode rates, draft_n, draft_n_accepted), which is how frank-large and frank-small were measured. Rolling your own bench
requires asking first.
Scratch ports are not used on LLM hosts — a
test process cannot share the single GPU anyway; validate against the service
itself.
(A gufo-era law; frank-large is ROCmFPX llama.cpp since 2026-10-02, but the rollback tag keeps it live.) gufo bench cannot rank speculative-decode settings, and will invert the ranking.
Measured 2026-09-28 on frank-large at depth 32768: its corpus
reports acceptance=0.933 ± 0.000 with an identical trace sha256 across
draft widths, i.e. the same highly-predictable text every run, and 93 % of
every proposal accepted. It reported 29–37 t/s decode where real traffic
gives ~15 t/s, and it made --draft-tokens 16 look 6 % faster than 7 when
real text made it 14 % slower. Use it for shape, not for A/B. For an A/B,
replay real text through the live server and read the Server-Timing
header — but note the unit rides in the name half (ttft;dur=402.485),
so splitting on = alone keys the dict ttft;dur and every lookup silently
returns nothing. Generate at least 128 tokens for the measurement: at 8
tokens, inter_token is first-token noise and the run-to-run spread swamps
every real difference.
This covers any GPU experiment, not just benchmarks — including a bare
2-rank RCCL/torch.distributed probe. On 2026-09-16 one such probe was run
against the live service "because it only needs 200 MB of VRAM"; the host went
hard down ~20 minutes later (no ARP reply, 100% ICMP loss from two subnets, no
recovery — a power cycle was required). A hung AQL dispatch wedging the GPU
driver is the plausible mechanism, and this host has precedent for going
unreachable until a power cycle. If an experiment needs GPUs, stop and mask
first, or use a host that serves nobody. Telling an off machine from a wedged
one: ip neigh show <ip> from a peer — FAILED means off/NIC dead, while a
merely wedged kernel usually still answers ARP.
- Stop and mask (self-healing would restart it):
- Dankclaw
frank-large(root unit, vLLM podman on the iGPU):sudo systemctl stop frank-large && sudo systemctl mask frank-large - Dankclaw
frank-small(root unit, llama.cpp on the 3090):sudo systemctl stop frank-small && sudo systemctl mask frank-small - Dankserver
frank-primary(root unit, tcclaviger vLLM on the 2x R9700):sudo systemctl stop frank-primary && sudo systemctl mask frank-primary
- Dankclaw
- Bench.
NOTE:
systemctl maskFAILS on a relinked unit (the unit file in/etc/systemd/system/is itself a symlink into the checkout — mask refuses to replace it). Stop is enough for the bench window; the deploy restores the unit, so re-deploy after benching instead of unmasking. - Unmask and start; verify
curl http://192.168.0.<host>:7770/health.
CRITICAL: never systemctl disable a system unit on these hosts — it
deletes the unit file from /etc/systemd/system/, not just the symlink.
mask symlinks to /dev/null and is safe/reversible.
Model Config (harness)
The harness reaches the fleet through .omp/agent/models.yml in the
operator's home — /home/blank/.omp/agent/models.yml on the dev box, which is
where the harness runs; there is no copy on the servers. Each provider is one
HOST:PORT; each model id is the served alias: frank-primary →
192.168.0.235:7770, frank-large → 192.168.0.143:7770, frank-small →
modelRoles.
Resolution rules: the default model resolves as a defaultProvider +
defaultModel pair (the provider must serve the model); a subagent override
uses a bare model id, which resolves only when unique across providers. Two
ports on one host need two providers — the file cannot express "same host,
different port".
When renaming a service, retiring an endpoint, or moving a model: update the
provider block, model id, contextWindow, maxTokens, input in
models.yml, then fix every modelRoles entry that named the old endpoint —
a role pointing at a provider the file does not define fails at call time. As
of 2026-10-02 all three endpoints accept images (input: [text, image]):
frank-large through the BF16 mmproj that ships inside its GGUF payload,
frank-primary through a BF16 mmproj loaded explicitly (this build never
autoloads projectors), and frank-small through
mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf passed as --mmproj — the Q5_K_M
GGUF itself carries no vision tensors. frank-small's contextWindow is
180224, the per-slot window, not the model's native 262144: the harness
must compact before llama.cpp answers a longer prompt with a 400. Note also
that
the reasoning channel differs by runtime: llama.cpp AND SGLang (qwen3
parser, live-verified) emit reasoning_content, vLLM emits reasoning —
models.yml sets reasoningContentField per endpoint to match.
There is no audio
input anywhere in the fleet: that capability left with the FLM NPU
endpoint on 2026-09-11, returned 2026-09-17 with the Gemma4 projector's
audio encoder, and left again with the Gemma4 retirement on 2026-10-01
(the MiMo tower is vision-only).
frank-large — ROCmFPX llama.cpp, Qwen3.8-27B Q8_0_ROCMFPX_AGENT + MTP (Dankclaw, ACTIVE)
The kingjones30/ROCmFPX llama.cpp fork at commit
36e9acd40e10a87cd3c3ef8ec734668757dc8520, built for gfx1151 by
scripts/build-rocmfpx.sh into /home/dank/GitPackages/kingjones-ROCmFPX/build,
as a root systemd unit (scripts/frank-large.service, User=dank,
Restart=always) running a bare llama-server (no container) on port 7770
from the Strix Halo iGPU. Boot to healthy: 16 s.
The runtime is not interchangeable — it is chosen by the FILE.
kingjones777/Qwen3.8-27B-ROCmFP4-STRIX-MTP-GGUF's best rung,
Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf (28,214,425,952 B, sha256 f3ef2bda…,
ftype 115 ≈ 8.39 bpw), stores 311 tensors of type 103,
GGML_TYPE_Q8_0_ROCMFPX — a quantization that exists in no other loader:
stock llama.cpp and gufo both refuse it at load (unknown tensor type). The
file is 353 F32 + 187 plain-Q8_0 + 311 ROCmFPX; the AGENT routing variant
keeps the output-head tensors at plain Q8_0 (187 vs 2 in the non-AGENT file),
which is why the card reports MTP acceptance 0.953 vs 0.911 — highest quality
AND the better drafter base. Vision comes from the bundled
mmproj-Qwen3.8-27B-BF16.gguf (931,145,888 B).
The pin is load-bearing. The 2026-08-25 ROCmFPX builds segfaulted in
ggml_compute_forward_set_rows on exactly this file; build-rocmfpx.sh
checks the commit out by SHA, so a fork-side force-push cannot silently
re-break the endpoint.
Spec decode is the performance feature, and it is measured. The draft is
the repo's own mtp-Qwen3.8-27B-Q4_0.gguf (1,680,271,648 B, sha256 051a1764…,
nextn_predict_layers = 1), pinned to the same card:
--spec-type draft-mtp --spec-draft-n-max 4 --spec-draft-n-min 0 --spec-draft-p-min 0.0 --spec-draft-device ROCm0 --spec-draft-ngl 99.
Measured 2026-10-02 in one session, temp 0, 2,202-token prompt / 512 tokens,
cache_prompt: false, production service stopped for each arm:
MTP on 17.00–17.14 t/s decode (draft acceptance 0.462, mean acceptance
length ≈ 1.86) against 7.60–7.66 t/s with the draft removed — +123 %;
prefill pays 8–15 % (381 → 325–351 t/s). Acceptance is far from the card's
0.95 (that number is the card's own text at a shallow prompt); the gain is
real and n-max 4 is where it was measured, never swept. This replaces the
gufo Q4_K_XL arm at ~15 t/s, so the endpoint got higher quality AND more
speed at once.
Shape and budget: ONE session at the native 262,144 window, unquantized
f16 KV (~64 KiB/token → ~16 GiB, affordable inside the iGPU's ~114 GiB GTT
aperture), --fit off --flash-attn on --direct-io --ctx-checkpoints 16 --cache-ram 8192 --cache-idle-slots. Measured GTT 64.5 GiB with model +
draft + projector resident. Prompt cache verified: a repeat pass over the
same 6k prompt replays in 0.5 s.
Client contract (llama.cpp, live-verified 2026-10-02): --jinja is on
(without it chat_template_kwargs are silently ignored), tools are native
(get_weather → {"city":"Seattle"}), thinking lands in
reasoning_content, reasoning_effort accepts ONLY
xhigh|medium|low (anything else is HTTP 500 — models.yml folds high and
max to xhigh), and thinking is enabled server-side with the card's
recipe (temp 1.0 / top_p 0.95 / top_k 20 / min_p 0) plus
--reasoning-format deepseek --reasoning-budget -1. Two traps: a request
whose whole budget is consumed by reasoning returns content: null (bench
clients must fall back to reasoning_content or they crash after
measuring), and the fork logs
MTP boundary missing for seq_id=… ; resyncing under concurrent draft
resets — self-recovering, not a fault.
Fetch: scripts/download-frank-large-kingjones.sh (-f + byte count + LFS
sha256 verified, --verify-only, no token on the command line).
Runbook: serverAppConfigs-docs/LLM/frank-large-rocmfpx-q8agent-mtp.md.
Rollback: git tag frank-large-gufo-q4kxl-20261001 — gufo v0.1.1
(scripts/build-gufo.sh stays on disk for it), the Q4_K_XL Qwen3.8-27B
payload and the DFlash2 drafter all remain on the host; restore the tag's
unit/env/serve trio and redeploy.
frank-small — llama.cpp on the RTX 3090 (Dankclaw, ACTIVE)
llama.cpp master (CUDA sm_86 build
/home/dank/GitPackages/llama-master/build-cuda/bin/llama-server) as a
root systemd unit (scripts/frank-small.service, relink_united into
/etc/systemd/system/), serving
bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF at Q5_K_M (6,876,124,704 B,
sha256 04cf4f…) plus mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf
(918,166,048 B, sha256 ff348f…), both /home/dank/models/MiMo-Q5KM/, on
port 7771. The 2026-10-02 swap was a quant rung down from Q6_K, not a
runtime change — the llama.cpp path arrived 2026-10-01 night for one reason:
asymmetric KV. --cache-type-k f16 + --cache-type-v q8_0 is the fleet's
only per-tensor KV lever; SGLang's --kv-cache-dtype moves K and V together.
Vision came the same day via --mmproj: the GGUF carries no v.* tensors, so
the projector file is the entire vision path (and ~1.4 GiB of the card).
Architecture: qwen3_5 hybrid — 32 layers, 8 full-attention + 24 Gated DeltaNet, native 262,144 window. KV exists only in the 8 attention layers (4 KV heads × 256 dim × 2 B ≈ 2 KiB/token/layer, ~16 KiB/token for f16 K+V); the DeltaNet state is a fixed per-slot buffer, so slot count — not context — drives that term.
The shape: --ctx-size 540672 --parallel 3 = THREE conversations of
180,224 tokens each (llama.cpp divides the pool:
n_ctx_slot = CTX_SIZE / PARALLEL), --flash-attn on (the q8_0-V path is
only cheap under flash attention — the seam pins it), f16 K + q8_0 V, all
layers on the GPU, --mmproj for vision. Measured VRAM 21,684 used / 2,441
free of 24,576 MiB — the 2–3 GiB headroom Frank asked for, landed by
solving the pool against two measured boots: non-KV cost is 7,390 MiB without
a projector (2 × 262,144 boot) and 8,859 MiB with one (3 × 196,608 boot), so
vision costs ~1.4 GiB, not the 0.9 GiB the file size suggests. Three native
windows (786,432 tokens) need 18.4 GiB of KV alone and were never on the
table. Full f16 V would add ~4 GiB more and bust the card — that is the
justification for the asymmetric contract.
Verified at deploy (2026-10-02): /v1/models reports frank-small; boot logs
n_slots = 3, n_ctx_slot = 180224 and loaded multimodal model; decode
106.8 tok/s at temp 0 (prefill 3,814 t/s) — ~5 % faster than the
2 × 262,144 shape, because a shorter slot means a shorter attention scan;
three concurrent requests answered in parallel and a fourth queued behind a
released slot; native tool_calls; thinking in reasoning_content; a
solid-red PNG answers "Red." through an image_url content part (the legacy
top-level images field is ignored by /v1/chat/completions — the model then
invents a color). A prompt over the slot is a 400
exceed_context_size_error, which is why models.yml sets
contextWindow: 180224.
Runbook: serverAppConfigs-docs/LLM/frank-small-llama-mimo-q5km.md.
Rollback: frank-small-llama-mimo-q6k (Q6_K file on disk at
/home/dank/models/MiMo-Q6K/); before that frank-small-sglang-mimo-v26
(venv on disk at /home/dank/GitPackages/sglang-cu128/; its INT8 payload was
staged to ~dank/.AGENTTRASH/20261002-retired-llm-payloads/ on 2026-10-02).
frank-primary — tcclaviger vLLM, Qwen3.8-Flash-Next FP8 (Dankserver, ACTIVE)
The tcclaviger RDNA4 vLLM fork (docker.io/tcclaviger/vllm@sha256:da4029666…,
digest-pinned) serving orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8
(~172.8 GiB, 131 shards) on both Radeon AI PRO R9700s (gfx1201), TP=2,
fp8 KV, no speculative decode, expert prefetch OFF, and since
2026-10-02 the shape-E geometry: --max-model-len 200704 × 3 slots with
--kv-cache-memory-bytes 3,150,000,000/rank, so the pool admits ~2
full-length conversations (measured live: 398,658 tokens, Maximum concurrency for 200,704 tokens per request: 1.99×) while the argv keeps the
only slot count that compiles (below), plus --expert-precise-memory
(runtime overhead 5.25 GiB, expert cache space 17.22 GiB / 7,522 slots),
GPU_MAX_HW_QUEUES=2 and a mounted /root/.cache. models.yml carries
contextWindow: 200704 for this endpoint — the served window, not the model's
native 262144, so the harness compacts before vLLM answers a longer prompt
with a 400.
--max-num-seqs 2 is impossible on this model. Measured 2026-10-02 by
single-variable boot probe against the then-deployed shape: SEQS 2 fails at
BOTH MNBT 1024 and 2048 with torch.fx.experimental.symbolic_shapes.Constraint ViolationError: Constraints violated (L['query_start_loc'].size()[0]) … specialized it to be a constant (3); SEQS 3 passes at both. model_runner.py
sizes that input buffer max_num_reqs + 1 and _dummy_run sets
num_reqs = min(max_num_batched_tokens, max_num_reqs), so the width is
SEQS + 1 for any MNBT ≥ SEQS — MNBT only moves the compile RANGE
((1,1024) vs (1,2048)). --enforce-eager bypasses the path but drops CUDA
graphs. The same failure class killed the 2026-10-01 bf16 2×262144 shape.
The expert arena has TWO levers: max_model_len and the overhead term.
The fork's planner sizes expert cache as budget − non-expert − KV reserve − graphs − overhead, and the KV reserve is registry-priced at 9190 B/token ×
max_model_len: 262144 → 2.48 GiB reserve → arena 14.30 GiB / 6,248 slots;
200704 → 1.91 GiB → 14.87 GiB / 6,496 slots. Δreserve 0.57 GiB = Δarena
0.57 GiB. The 6.1e9 → 3.15e9 pool cut bought zero arena; it buys runtime
headroom (the shape it replaced died with 0 bytes free on a real 995-token
request). The second lever is --expert-precise-memory, ON since
2026-10-02: it swaps the registry's default overhead (7.60 GiB = fitted +
compile/graph-capture headroom) for the fitted figure, and the 2.35 GiB
difference lands in the arena — measured plan lines at 200704 × 3: overhead
5.25 GiB, expert cache 17.22 GiB / 7,522 slots (+1,026 / +16 %), swap region
13.7 GiB / 5,986 slots, boot healthy in 420 s. The pool stayed at 3.15e9
deliberately: because the pool is not an arena lever, cutting it would cost
concurrency (a 2.2e9 probe reports 277,686 tokens / 1.38×) and buy no slots.
The arena (2.344 MiB per slot) is the decode-critical region — cutting it for KV
headroom collapses the offload hit rate.
A container's compile cache does NOT survive podman rm -f, and on this
endpoint that is 181 s per boot. Measured 2026-10-02: the image ships no
/root/.cache at all (fresh container: No such file or directory); after a
boot it holds /root/.cache/vllm at 1,154,714,333 B in 4,665 files plus
/root/.cache/comgr at 229 MB, all in the ephemeral layer, and the engine
prints torch.compile took 181.20 s in total (173.19 s of it one graph,
Compiling a graph for compile range (1, 1024)). Every deploy, restart and
reboot re-pays it. The serve script now mounts
FRANK_PRIMARY_CACHE_DIR at /root/.cache — the parent, so the vLLM AOT /
torch_compile tree (VLLM_CACHE_ROOT, whose default is
/root/.cache/vllm) and the comgr HIP-kernel JIT cache both persist, and
nothing in the image is hidden because the image has no such directory. The
compile RANGE is part of the cache key, so an MNBT change costs one
recompilation, not a permanent tax. Same class of fix as the recipe's
/cache/{triton,vllm,inductor} volumes. Proven live 2026-10-02 on
consecutive boots: cold Compiling a graph for compile range (1, 1024) takes 179.24 s / torch.compile took 188.26 s; warm Directly load the compiled graph(s) for compile range (1, 1024) from the cache, took 2.143 s /
torch.compile took 3.97 s in total / init engine (profile, create kv cache, warmup model) took 41.13 s (compilation: 3.98 s). Time to /health is
dominated by the 172.8 GiB payload load, so the visible win is the ~184 s of
inductor/AOT build, not a proportional boot cut.
Measured 2026-09-29 tuning campaign (fp8 + MTP4 era, since retired): decode
37.4-40.6 tok/s, acceptance ~54 %; the 256k ctx arm measured 34.1-36.5;
2-way concurrency sums BELOW parity (25.49 vs 26.8 single) — slots are a
capacity feature, not a throughput one. V-only q8 KV is NOT possible via
config: stock --kv-cache-dtype is K+V together; a V-only quant needs a fork
patch.
Unit and self-healing: root systemd unit (scripts/frank-primary.service,
User=dank, LimitMEMLOCK=infinity), ExecStop=podman stop -t 120 frank-primary-qwen. The serve script drives podman run directly, gates
the digest-pinned image, the payload's config.json and its exact
131-shard count and the GPU device nodes, clears stale container
identities, then blocks on podman wait. deploy_llm_primary gates the
same, waits out :7770, relinks/enables/restarts the unit and health-gates
with the MainPID check. Boot cost ~7 min warm (the 172.8 GiB payload load; the
~184 s inductor/AOT build inside it is what the mounted cache now skips — see
the compile-cache lesson below); health returns 503 while loading.
Runbook: serverAppConfigs-docs/LLM/dankserver-qwen38-fp8-expert-offload-tuning.md.
Retired GLM experiment: serverAppConfigs-docs/LLM/frank-primary-glm53-flash-w4a16.md.
Key Lessons Learned
Durable operational lessons from the fleet's history. Full detail in the linked runbooks.
PCIe / multi-GPU
- RCCL collectives need PCIe atomics on every participating GPU. RCCL's
kernels carry a hidden hostcall-buffer parameter; ROCr refuses to dispatch
them when the device has no atomics, and the failure surfaces as
hipErrorIllegalState("the operation cannot be performed in the present state") fromhipLaunchKernel, not as anything that names atomics. The value is consumed at driver probe time, so writing the AtomicOp Requester bit withsetpciat runtime changes nothing. Check the chain:lspci -vv -s <gpu>and every upstream bridge must showAtomicOpsCap: Routing+(aRouting-bridge is a hardware wall), and the endpoint's Device Control 2 (setpci -s <gpu> CAP_EXP+28.w) must have bit 6 set. AMD desktop platforms put one card on CPU root ports (atomics work) and the rest behind the chipset (usually not) — that asymmetry is what blocked frank-medium's TP=2. A one-card collective proves nothing: NCCL short-circuits atworld_size=1. - That wall was physical, and it was fixed on 2026-09-20 by a board swap.
Both paths that put two cards on CPU root ports were: an M.2→PCIe x4 riser
into a CPU-attached M.2 slot, or a board whose CPU lanes bifurcate to
x8/x8. dankserver took the second: ASUS ProArt B850-Creator WiFi NEO,
PCIEX16_1+PCIEX16_2at x8/x8 Gen5 on root ports00:01.1/00:01.3, every bridge nowRouting+and both endpointsReqEn+— the exact precondition the old test plan named. Bandwidth was never the constraint (a 27B TP=2 allreduce at batch 4 is ≈10 MB per token-step, ≈1.4 ms at Gen4 x4 and under 0.4 ms at Gen5 x8). Proven on the same day. vLLM's R4D one-shot allreduce runs TP=2 here and frank-medium serves on it; llama.cpp's RCCL path was built too (GGML_HIP_RCCL=ON) and its tensor mode measured — real collective, slower shape. Note the collective choice matters: TheRock's RCCL builds lack gfx1201 device code (invalid device function), which is why R4D is the path vLLM uses at all. Evidence matrix and the measured three-arm verdict: Dual-R9700-Cooperation. - When a GPU collective dies, read the runtime, not the framework. The
message that identified the cause came from
AMD_LOG_LEVEL=3(Pcie atomics not enabled, hostcall not supported→AQL dispatch failed!), not from vLLM or RCCL.AMD_LOG_LEVELcosts one env var and prints the failing HIP call with its return code. - The software path that puts both cards on ONE model without a collective:
llama.cpp's layer split. On the old board vLLM could not do it in any of its
parallelisms — TP needs an allreduce, PP needs the same p2p primitive, and both
died on the card that could not dispatch an RCCL kernel.
--split-mode layerin llama.cpp needs no collective: layers are distributed in order and only the boundary activation crosses PCIe, once per forward pass. Measured here: 1991 t/s prefill / 18.8 tok/s decode layer-split, against 272 / 26.3 tensor (butterfly fallback). This was whatfrank-mediumran until 2026-09-20, when the board swap made collectives legal and radiance TP=2 beat it on every axis (95.8 vs 45.7 t/s decode, 0.21 vs 0.72 s TTFT, 259 vs 72 t/s burst-4) — see frank-medium-radiance-mxfp4; the llama shape is the rollback target frank-medium-llamacpp-dflash2. - Layer split is also the cross-vendor answer — and
-otis what cannot be combined with it. On Dankclaw (2026-09-16) a CUDA 3090 + Vulkan iGPU layer split ran a real two-device graph:-dev CUDA0/Vulkan1 -sm layer -ts 0.15/0.85held 14,994 MiB on the 3090 and gave 27.2 tok/s with 268.5 t/s prefill at 64k depth (iGPU alone: 25.8 / 231.8). What does NOT work is per-tensor placement across devices:--override-tensor <group>=<device>setshas_tensor_overrides(), a REQUIRED term of the scheduler'spipeline_parallelflag (llama-context.cpp:433 — the only place in the tree that reads it), and that flag is what setsn_copiesinggml_backend_sched_new; withn_copies == 1a pre-allocated weight on a non-primary device has no backend and the graph aborts (ggml-backend.cpp:941, reported with the leaf's own op name(NONE)).=CPUis the only override target that works, because the CPU backend is last in the list. Pure Vulkan changes nothing here — the gate is the override, not the driver mix. See x4-pair-experiment. - A host↔3090 link at x4 measures 6.72 GB/s, and that is a design
constraint, not a detail.
pcie-link-test.cuon Dankclaw (2026-09-16, services stopped): pinned H2D 6.72 GB/s, D2H 6.60 GB/s — 85 % of the Gen4 x4 ceiling — with async no better than sync and pageable within 1 % of pinned, i.e. the copies are link-limited rather than staging-limited (the usual "pageable costs 2x" rule does not apply here). The card is x16-capable and the Strix Halo port beneath it is x8-capable (LnkCap: 16GT/s, Width x8on00:03.1), so the x4 lives in the adapter or cable; the link idles at 2.5 GT/s x4 and trains up under load. Consequence for any engine that streams weights across it: a 3 MiB slab costs 474 us, and a 111 GiB A3B model needs ~1.70 GiB of active experts per token ⇒ 3.7 tok/s (4.6 with a 20 % resident cache), against 20.46 tok/s for the same model with the experts computed in host RAM by llama.cpp (-ot exps=CPU, whence the link carries activations only) and 25–26.5 on the iGPU. Streaming over PCIe loses; computing in RAM does not care. FreeToken — expert LRU in host RAM, exactly this streaming shape — also loads only modelopt block-FP8 safetensors forqwen3_5_moe, so a Q4_K_M GGUF is unloadable there before bandwidth is even the question. See x4-pair-experiment.
ROCm / GPU
- Layer split was the deployed shape on both multi-device llama.cpp services, for two different reasons. On Dankserver it was the only way both R9700s served one model (no collective could be dispatched on card #2) until the 2026-09-20 board swap; frank-medium now runs radiance TP=2 instead. On a RCCL-less fork build it is also the fast one: tensor parallel falls back to a butterfly allreduce and collapses prefill ~7x (measured 271.8 vs 1991 t/s). Pin the spec-decode drafter to one device — an unpinned draft under tensor mode aborts the KV build, and on a layer split the natural home is the card that ends up holding the target's last layers.
-
- **Bundling a ROCm runtime next to a llama.cpp binary:
cp -r SRC DST/is a - trap.** With a missing
DST, cp createsDSTas a copy ofSRCand drops - one directory level. rocBLAS and hipBLASLt look for their device code at a
- fixed depth (
../lib/rocblas/library,../lib/hipblaslt/library/<arch>), - and the flattened copy fails at request time, not load time: the engine
- reads the weights, answers
/healthand dies on the first completion with - `Cannot read .../TensileLibrary.dat: No such file or directory for GPU arch
- gfx1201`. Declare the destination, then copy the contents.
- **Bundling a ROCm runtime next to a llama.cpp binary:
- mmap must stay on for the big-PLE models:
--no-mmapforces the ~50 GiB PLE table into anonymous memory → silent cgroup kill, dmesg is the only evidence. mmap pages ARE page cache. Never pin the PLE tensor with-ot(23 → 13.4 t/s). - HIP reads bare env vars
TEMP,HOST,GPU_MAX_HEAP_SIZE. Serve scripts export env files withset -a, so a tunable namedTEMPleaks into HIP (TEMP=1.0sent the JIT cache to path1.0and segfaulted every load, fixed 2026-08-13 by renaming toTEMPERATURE). Never name a tunable these; check withstrings /opt/rocm-*/lib/libamdhip64.so.* | grep -xE "TEMP|HOST|GPU_MAX_HEAP_SIZE". -DGGML_CUDA_FA_ALL_QUANTS=ONis required for q8_0 KV + flash attention on CUDA builds; without it prefill collapses ~12x (95 vs 1300+ t/s, measured 2026-08-17).- Flash-Next MTP heads do not load on any current loader — they need full
per-layer
blk.<il>.hc_attn_norm.weightand ship onlyblk.48(verified 3x). Don't re-attempt without new loader support. - QSA flash-attn assert:
GGML_ASSERT(a-type == GGML_TYPE_F32)on qwen4exp needsscripts/patches/qwen4exp-qsa-f32-mask.patch— but only on the agentionai FAST layer set; the kingjones STRIX set works unpatched. - GPU budget on Dankclaw is the GTT aperture (~114 GiB), not the
rocm-smi "dedicated" figure. The Radeon 8060S (gfx1151) reports
116,732 MiB (
llama-server --list-devices;gtt_total=122402963456in/sys/class/drm/card1/device/). The older "64 GiB, SVM-backed" lesson was already stale — the CIRU Orca predecessor held 92.6 GiB of weights on the iGPU.free/dmidecodeshow the CPU-side carve-out — intentional, do not change.
Load / crash diagnosis
- Model load is slow (minutes cold); health returns 503 while loading; do
not kill the process.
deploy.shwaits up to 20 minutes (LLM_WAIT). On halogen, the FIRST request after a (re)start can go silent ~45–60 s (healthengine_unresponsive, 503s) and then self-recovers — do not restart over it. - Crash-loop procedure: (1)
journalctl -u <svc> -n 100+ kernel line viajournalctl -k(segfault at address 8 inlibamdhip64= NULL unref in HIP); (2) run serve script, then raw binary — different outcome isolates the env file; (3)gdb -batch -ex run -ex "bt 25" --args bash scripts/serve-<svc>.sh— llama-server buffers stdout and loses the log tail on SIGSEGV; trust gdb, not the log; (4)amdgpu: SVM mapping failedspam is a symptom, not a root cause. - Journal signatures: client abort logs
W srv stop: cancel task, id_task = N; natural stop logs onlyslot releasewithtruncated = 0. The server never preempts a running generation; excess tasks queue when slots are busy.
Host memory / swap
- Swap resize ordering: create and
swaponthe replacement BEFOREswapoffof the old one, or live pages must land in RAM thatMemAvailableoverstates. Dankclaw swap (64 GiB at/swap.img, fstab-driven) protects the co-tenants, not frank-large — pinned weights don't swap, and paging the KV pool is worse than the OOM it prevents. - Host swap state is invisible to
deploy.sh(no repo file) — checkswapon --showbefore reasoning about headroom.
Runtime-specific guards
- Kairic Edge (if ever re-deployed): the pinned CK commit needs
third_party/composable_kernel/include/ck/config.hgenerated manually (untracked;git cleanremoves it), and its greedy-argmax fastpath rejects non-greedy requests and reasoning budgets. Re-test chat before production. - FastMTP sidecar: build must be commit
4df29be4+ the HauhauCS patch; stock binaries fail the draft load with a tensor-shape mismatch. - A GGUF's custom tensor type picks the runtime — read the histogram before
promising a file to a loader.
Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.ggufis ftype 115 with 311 tensors of type 103 (GGML_TYPE_Q8_0_ROCMFPX); stock llama.cpp and gufo refuse it at load, so ROCmFPX was not a preference but the only door. Counting tensor types out of the GGUF KV section takes one python read and settles a runtime argument in minutes. The build identity matters as much as the fork: the 2026-08-25 ROCmFPX builds SIGSEGV inggml_compute_forward_set_rowson this same file, soscripts/build-rocmfpx.shpins commit36e9acdby SHA. - Speculative-decode flags move as a SET. Handing the ROCmFPX fork
--model-draftwith no--spec-type(the naive way to build a "no MTP" bench arm) segfaulted at load, core dumped, no diagnostic (2026-10-02). A baseline arm must strip--model-draftAND every--spec-draft-*AND--spec-type, or it measures a crash instead of the absence of drafting. - An orphaned scratch server taxes the whole host. A bench child left
alive by a client-side exception kept ~26 GiB of weights resident on a
121 GiB box, pushed
availableto 3 GiB, and every later rate was suspect. Record the child pid and kill it in afinally, then provefreecame back before believing a number. Kill by pid — neverpkill -f llama-server, which also hits frank-small.
llama.cpp serving
- MTP is a property of the FILE, not of the flags. The
-mtpGGUF build carries the head asblk.<n>.nextn.*(qwen35.nextn_predict_layers = 1);--spec-type draft-mtpthen setsload_mtp, and the draft context runs on the MAIN model's weights (shares_model), so there is no second copy of a 12 GiB model in VRAM and no-mdargument. The plain build has no MTP tensors at all, and pointing--model-draftat the file would load it twice. - What can be chained, and what cannot.
--spec-type a,bbuilds every enabled implementation and chains drafting through them, in a fixed internal order (ngram types first, then the drafters — CLI order is irrelevant). The gate isctx_dft: draftless types (the ngram family) are added unconditionally, whiledraft-mtp,draft-dflash,draft-dsparkanddraft-eagle3all require a draft CONTEXT. So a chain may hold one context-backed drafter plus any number of ngram types —draft-dflash,ngram-modis legal and measured (2.6x the drafter alone on prompt-echo traffic, byte- identical to it on prose at the fleet's 48/64 window) — butdraft-mtp,draft-dflashdies at load: "context type MTP requested but model doesn't contain MTP layers", because MTP builds its context against whatever model the draft context holds. Two context-backed types cannot share one process, and MTP's head only loads when MTP is the draft model. - A drafter's KV scales with the TARGET's context, and its window policy
decides how much that costs. The draft context is built with
cparams.n_ctx = llama_n_ctx(ctx_tgt)(common/speculative.cpp) — there is no flag to cap it — so a drafter that declares NO sliding window caches the whole window on whatever device--spec-draft-devicenames. Swapping DFlash2 (dflash.attention.sliding_window = 2048, ~40 MiB of draft KV) for DSpark (no window; 5 layers x 2 x 8 kv heads x 128 dim x 2 B = 20 KiB per position, so 5.0 GiB at 262144) put ROCm1 at 33.9 GB of 34.2 GB and killed the FIRST draft graph capture withROCm error: out of memory— after the model had loaded and/healthhad answered. Readsliding_windowout of the drafter's GGUF header before sizing any spec-decode deployment, and prefer a skewed-tsover a context cut when the full window matters. The pair was swapped DFlash2 -> DSpark -> DFlash2 in one day (2026-09-16) and the round trip moved the balance point FOUR layers (~4.3 GiB of headroom, 40:25 -> 36:29), which is the practical size of the effect. See frank-medium-llamacpp-dflash2. --ubatch-sizehas an OPTIMUM on this stack, not a plateau — and its compute buffers land about TWICE as heavy on the card that holds the output layers and the drafter. Measured on frank-medium 2026-09-16 at the deployed shape (262144 over two slots, a 64k prefill measured at 64k depth,scripts/bench-batch-ubatch.sh): prefill runs 1046 tok/s at ubatch 256, 1068 at 384, 1193 at 512 (the peak), 1135 at 768, 1152 at 1024, 1151 at 1280, 1165 at 1536, 1148 at 1792 and 1078 at 2048. Both ends lose — below 512 the SMs are not saturated, and 2048 is 10 % off the peak — and 512 -> 1536 costs ROCm0 1.30 GiB and ROCm1 2.41 GiB to go 2.4 % SLOWER. The logical--batch-sizedoes not matter: 2048 through 8192 land within 2 % of each other at a fixed ubatch and use identical VRAM. Decode never moves at all (15.88 tok/s at depth 65536 on every cell). Because the buffers are asymmetric, a split balanced at one batch is unbalanced at another: 512 -> 1536 shifted frank-medium's balance by a layer, so 36:29 became 35:30. Re-measure the balance after changing the batch. (8192/2048 failed to allocate its 2020 MiB pp buffer here under the DSpark drafter, when ROCm1 had 0.08 GiB free. That was a CONTIGUITY failure, not a total-bytes one, and no totals model — the sweep's or the seam's — can see it.)- One
-tsstep is one LAYER, and a hybrid stack's layers are not the same size. qwen35 is 16 x (3 Gated DeltaNet + 1 Gated Attention), so a step is worth ~0.43 GiB on a DeltaNet boundary and ~1.43 GiB on a full-attention one (0.45 GiB of weights plus ~1.0 GiB of its own f16 KV at 262144) — both measured on frank-medium. Balance by measurement, never by interpolating between two splits; an affine fit reproduces the deployed point and overstates the gaps in between.tests/frank_medium_unit.batsencodes that fit as a gate. See frank-medium-llamacpp-dflash2. - ngram-mod prints no startup banner. The load log shows the drafter's
adding speculative implementation 'draft-dflash'and nothing at all for the matcher, so a chain can look unengaged while it is working. Identify it behaviourally (draft_n> 0 only where text repeats) or by the per-taskdraft acceptance = ... mean len = ...line. It costs no VRAM (a 64 MiB host-RAM hash table) and its window decides which traffic it can see: too short and it proposes on ordinary prose fragments, which costs verification without acceptance. --paralleldivides the pool, so MORE conversations cost WINDOW, and the per-slot number is the contract — notctx-size.n_ctx_slot = CTX_SIZE / PARALLEL, and llama.cpp refuses a prompt longer than the SLOT (400 exceed_context_size_error: request (184001 tokens) exceeds the available context size (180224 tokens)) even when the pool has room. So frank-small's 2 → 3 reslice moved each conversation from the model's native 262,144 down to 180,224, andmodels.ymlcontextWindowhad to follow or the harness would discover the ceiling as a 400 on a long conversation. Side effect worth knowing: the shorter slot made decode ~5 % FASTER (106.8 vs 100.4 tok/s) — less attention to scan and one fewer DeltaNet state resident. Measured 2026-10-02. See frank-small-llama-mimo-q5km.- Price a VRAM budget from two boots, never from a formula — and an mmproj
costs ~1.5x its file size. The frank-small 3090 fit was solved from two
measurements: 2 × 262,144 with no projector = 20,190 MiB used (KV 12,800 ⇒
non-KV 7,390), 3 × 196,608 with the f16 projector = 22,972 used (KV
14,113 ⇒ non-KV 8,859). The difference is the vision tower: a 918 MB file
that bills ~1.4 GiB, because the encoder's activation and compute buffers
ride with it. Subtracting the two rows is exact; extrapolating weights+KV+
"a bit for buffers" is what leaves 1.1 GiB free when you meant 2.7. Then
solve
KV ≤ card − non-KV − headroomand round to a multiple ofPARALLEL × 4096. Recorded inscripts/frank-small.envso the next resize starts from measurement. imagesandimage_urlare NOT interchangeable: only the content part reaches the model.--mmprojloads,usage.prompt_tokensrises, and the model still invents an answer if the client used the legacy top-levelimagesfield on/v1/chat/completions— frank-small answered "Brown." for a solid-red PNG that way and "Red." through the OpenAIimage_urlpart. The failure is silent because a hallucinated color is a plausible answer. Probe vision with a solid color and expect an exact word.--jinjais whattoolsneeds. On 2026-09-16 a subagent died with500 tools param requires --jinja flag, from the ik_llama.cpp runtime that then served frank-large: every tool-using agent on that endpoint was broken and the failure read like a model fault. Mainline llama.cpp 0.4.1 defaults--jinjaon and the serve script pins it; halogen also acceptstoolsand returnstool_calls. Check tool support before routing a role at an endpoint.- Quantized KV on CUDA needs
GGML_CUDA_FA_ALL_QUANTS=ON— the same option and the same ~12x prefill collapse described in the ROCm note above. f16 KV needs nothing; frank-small ran f16 for that reason until 2026-09-17, whenbuild-cudawas rebuilt with the option ON so the endpoint could take q8_0 KV. - llama-bench separates a LIST with
/and SWEEP values with,. Both-devand-tsfollow this:-dev CUDA0,Vulkan1is two single-device configurations, never one two-device run, and-ts 0.85,0.15is two sweep values each becoming[0.85, 0, …]/[0.15, 0, …], i.e. device 0 takes everything. Write-dev A/B -ts x/y. This artifact produced a wrong fleet conclusion on 2026-09-16 — "CUDA + Vulkan cannot serve one model", with two bogus OOMs that were really "all 111 GiB on the 24 GiB card" — and the bench table's owndevcolumn was printing the truth the whole time. Any placement-dependent number needs an independent check: sample the other device's memory (nvidia-smi) while the run is loaded. - A model's own template can REJECT the harness's thinking level. Qwen3.8's
template accepts only
xhigh,mediumandlowforreasoning_effortand raises on anything else — HTTP 500, not a clamp — so the harness's levels must be folded onto that set inmodels.yml(compat.reasoningEffortMap). A role whose level is unmapped fails on its FIRST request, and the failure names Jinja, not the model: every subagent onfrank-smalldied withUnexpected reasoning effort highuntilhigh: xhighwas added on 2026-09-16. Halogen accepts the wider set, so the two entries legitimately differ.
Ops
- Never
pkill -fa pattern that appears in your own command line — it kills your ssh session. Usepkill -x <exact-name>. - Both hosts allow passwordless sudo for the deploy user. Use plain
sudoin committed scripts (noecho pw | sudo -S);FRANK_SUDO_PWcovers hosts without it. - ComfyUI on Dankclaw runs with GPU access (
/dev/kfd+/dev/driviaAddDevice,GroupAdd=keep-groups, plus the udev rule that makes those nodes world-writable — user-unit spawns carry no supplementary groups). It must NOT pass--cpu: the gfx1151 ROCm wheels exist and the unit's--cpuera ended 2026-09-16, when a stale device-less container was found restarted 9,146 times (RuntimeError: No HIP GPUs are available). - SearXNG outbound traffic must stay on the Gluetun tunnel; do not move its ports off the gluetun service.
amd_iommu=offon Dankclaw (bought 13–16 % prefill for frank-large, 2026-09-11) permanently retired the XDNA2 NPU: the driver's SVA/PASID bind needs an enabled IOMMU (/dev/accel/accel0failserr=-19). Revert path inserverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md.- Rootless Quadlet containers run with
--replace --rm:podman logscovers only the CURRENT instance; history across recreations lives injournalctl --user -u <name>.service. Read rootless storage WITHOUT sudo (per-user storage —sudo podman logsfinds nothing). - A dead rootless podman pause process breaks ALL container management on the
host, and
podman system migratepanics in 5.7.0. Symptoms: everypodmancommand fails withinvalid internal status … could not find any running process, every container start fails binding podman's bridge addresses (Error starting server failed to bind udp listener on 10.89.x.1:53: Cannot assign requested address), and everyRestart=alwaysquadlet loops. Container storage is intact; what is gone is/run/user/<uid>/libpod/tmp/alive(a UUID token, not a pid) and the rootless netns holding the bridges — deleting the stale token is not enough, andpodman system migratesegfaults inteardownNetworkBackend. Repair: reboot (/run/userclears and all 28 quadlets return on their own — checkLinger=yesandWantedBy=default.targetfirst). Seen 2026-09-16, hours into heavy GPU work on the same host — but the trigger is NOT established (journalctl -krecorded no OOM kill; the kernel had no other word on it). Treat the pause process as host state that can die for its own reasons, and reach for the reboot when it does. - A container cannot reach a
Network=hostservice at the host's LAN IP. netavark DNATs published ports only: from a bridge container192.168.0.143:<published>answers while192.168.0.143:<host bind>and the gateway10.89.1.1are both refused. Usehost.containers.internal(169.254.1.2). This is what 502'dhermes.franksdank.clouduntil the Caddyfile upstream was changed on 2026-09-16 (its vhost comment had claimed the LAN IP was open from that bridge — it never was). - A
*.gguffilter misses mradermacher's payloads: large quants ship as.partNofMfragments.mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUFcarries Q4_K_M (37.00 + 37.00 + 36.97 = 110.97 GiB) and Q4_K_S (35.00 + 35.00- 34.09 = 104.09 GiB) as parts, while the smaller rungs (Q2_K, Q3_K_, IQ3_,
IQ4_XS) are single files. Fetch the parts, verify each against the Hub's LFS
sha256, concatenate in numeric order — that is how the deployed 111 GiB file
was assembled. Listing the Hub tree and filtering
endswith(".gguf")makes Q4_K_S look like it does not exist.
- 34.09 = 104.09 GiB) as parts, while the smaller rungs (Q2_K, Q3_K_, IQ3_,
IQ4_XS) are single files. Fetch the parts, verify each against the Hub's LFS
sha256, concatenate in numeric order — that is how the deployed 111 GiB file
was assembled. Listing the Hub tree and filtering
- A fetch script must
-fand verify, or it reports success on an access error.curl -o FILE URLexits 0 on HTTP 401/403 and writes the error body as the "artifact": a gated repo yielded three 153-byte shards with identical sha256 values and a cheerfulDOWNLOAD_DONEin 20 seconds for 104 GiB. Use-f/--fail-with-body, compare each file's byte count to the Hub's declared size, and check the LFS sha256 —scripts/download-frank-medium-gguf.shalready does all three; ad-hoc fetches tend not to. Gated repos (e.g.orcarouter/Qwen3.8-Flash-Next-Uncensored-GGUF, same 104.09 GiB Q4_K_S) return 403 even with the host's HF token until that account accepts the gate.
Runbook Index
Active:
| Service | Runbook |
|---|---|
| frank-large (ROCmFPX llama.cpp fork, Qwen3.8-27B Q8_0_ROCMFPX_AGENT + MTP4 + vision, Dankclaw) | serverAppConfigs-docs/LLM/frank-large-rocmfpx-q8agent-mtp.md |
| frank-small (llama.cpp CUDA, MiMo-V2.6-Distill-Qwen-9B Q5_K_M, Dankclaw) | serverAppConfigs-docs/LLM/frank-small-llama-mimo-q5km.md |
| frank-primary (tcclaviger vLLM, Qwen3.8-Flash-Next FP8, Dankserver) | serverAppConfigs-docs/LLM/dankserver-qwen38-fp8-expert-offload-tuning.md |
Dankclaw kernel posture (amd_iommu=off) |
serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md |
| Deployment flow + incidents | serverAppConfigs-docs/LLM/deployment.md |
Retired (rollback targets and history):
| Runtime | Host | Window | Runbook |
|---|---|---|---|
gufo v0.1.1 Qwen3.8-27B UD-Q8_K_XL/UD-Q4_K_XL + DFlash2 (frank-large) |
Dankclaw | 2026-09-28 → 10-01, again 10-01 → 10-02 (rollback target) | frank-large-gufo-qwen38-27b.md |
vLLM TheRock MiMo-V2.6-Distill-Qwen-9B BF16 + cross-model MTP (frank-large) |
Dankclaw | 2026-10-01 (same-day rollback to gufo) | frank-large-vllm-mimo-v26-bf16.md |
llama.cpp master MiMo-V2.6-Distill-Qwen-9B Q6_K (frank-small) |
Dankclaw | 2026-10-01 → 10-02 (rollback target) | frank-small-llama-mimo-q6k.md |
SGLang MiMo-V2.6 INT8-W8A16-AutoRound (frank-small) |
Dankclaw | 2026-10-01 (same-day swap to the GGUF path) | frank-small-sglang-mimo-v26.md |
tcclaviger vLLM GLM-5.3-Flash W4A16 int4, +/- DFlash2 drafter (frank-primary) |
Dankserver | 2026-09-30 → 10-01 | frank-primary-glm53-flash-w4a16.md |
llama.cpp b11030 Qwen3.8-27B UD-Q8_K_XL + DFlash2 (frank-large) |
Dankclaw | 2026-09-21 → 09-28 | frank-large-unsloth-qwen38-27b-q8xl.md |
R9V radiance-vLLM Qwen3.8-Flash-Next-Uncensored IQ4_XS (frank-primary) |
Dankserver | 2026-09-27 (same-day swap to Q8_0) | frank-primary-r9v-radiance-qwen38-uncensored.md |
llama.cpp b11030 Qwen3.8-Flash-Next Q8_0 + shared MTP (frank-primary) |
Dankserver | 2026-09-26 → 09-27 | frank-primary-llamacpp-qwen38-flash-next-q8.md |
ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M (frank-large) |
Dankclaw | 2026-09-15 → 09-16 | frank-large-ik-uncensored.md |
vllm-radiance TP=2 MXFP4 + DFlash2 (frank-medium) |
Dankserver | 2026-09-20 → 09-26 | frank-medium-radiance-mxfp4.md |
Lucebox DS4.1 DeepSeek-V4.1-Flash ROCmFP23 (frank-primary) |
Dankserver | 2026-09-26 (same-day swap) | frank-primary-lucebox-ds4.md |
| kingjones STRIX Qwen3.8-Flash-Next ROCmFP4 | Dankclaw | 2026-08-30 → 09-11 | kingjones-strix-rocmfp4.md |
halogen-flash-server Qwen3.8-Flash-Next w4b (frank-large) |
Dankclaw | 2026-09-16 → 09-19 | halogen-flash-server.md |
CIRU v4.4.1 Orca Qwen3.8-Flash (frank-large) |
Dankclaw | 2026-09-19 → 09-21 | frank-large-ciru-orca.md |
unsloth llama.cpp b11030 Qwen3.8-Flash-Next UD-IQ4_XS (frank-large) |
Dankclaw | 2026-09-21 (same-day swap) | frank-large-unsloth-qwen38-flash-next.md |
Kairic Edge Qwen3.8-27B IU4 W4A4 (frank-large) |
Dankclaw | 2026-09-21 (same-day swap) | frank-large-kairic-edge-qwen38-27b.md |
ISTA-DASLab Qwen3.8-27B-GSQ-RCO IQ3_S (frank-small) |
Dankclaw | 2026-09-16 → 09-17 | frank-small-qwen38-gsq-rco.md |
vLLM Gemma4-12B w4a16 (frank-small) |
Dankclaw | 2026-09-19 (same-day revert) | frank-small-vllm-gemma4.md |
llama.cpp Gemma4-12B QAT Q4_K_M (frank-small) |
Dankclaw | 2026-09-17 → 10-01 | frank-small-gemma4-12b.md |
| agentionai ROCmFP4-FAST imatrix | Dankclaw | 2026-08-29 → 08-30 | agentionai-fast-imatrix.md |
| eemin Qwen3.8-27B ROCmI4 W4A4 MTP | Dankclaw | 2026-08-18 → 08-25 | eemin-rocmi4-mtp.md |
| Kairic Edge IU4 | Dankclaw | 2026-08-22 (same-day revert) | kairic-edge.md |
| CIRU ActiveFPX v3 / PromptForge | Dankclaw | through 2026-08-22 | ciru-activefpx-promptforge.md |
| DFlash2 Vulkan | Dankclaw | 2026-08-21 (same-day revert) | dflash2-large-vulkan.md |
FastFlowLM NPU (frank-npu) |
Dankclaw | twice, retired 2026-09-11 | frank-npu.md, FastFlowLM NPU Inference.md |
FreeToken (official + Huihui FP8, frank-small) |
Dankserver | 2026-09-02 → 09-15 | freetoken-frank-small.md |
| FastMTP patched llama.cpp | Dankserver | retired 2026-08-18 | qwen3.8-spec-matrix.md |
| ik_llama.cpp Ornith CRACK | Dankserver | retired 2026-09-02 | ikllama-ornith-dankserver.md |
llama.cpp layer split + DFlash2 (frank-medium) |
Dankserver | 2026-09-16 → 09-20 | frank-medium-llamacpp-dflash2.md |
Retired runbooks are under serverAppConfigs-docs/LLM/. Retired scripts live
in old/ and .AGENTTRASH/.
Why
Direct edits on the remote drift from version control, and future agents won't know what changed. Git is the single source of truth. The two workloads share two hosts, one Caddy, and one Gitea; one repo keeps them in one history.
Summary
| Context | Action |
|---|---|
| Editing a compose/Caddyfile/serve script | Through git — fetch, commit, edit, test, push, pull, deploy. Never edit on the remote. |
| Config exists only on remote | Pull it into the repo first — commit the fetched version (redacted) before editing. |
| Unstaged local changes | Commit or discard — check git status before starting work; ask if unsure. |
| Deploying a committed change | scripts/deploy.sh <target> — pushes, pulls ff-only, plans, dispatches, health-checks. |
| Container without a Quadlet unit | Give it one — boot-persistent, self-healing .container via git. |
| Benchmarking | Stop and mask the service first, bench, unmask, restart, verify health. |
| Deleting a file | Move it to trash — .AGENTTRASH/ (in-repo) or ~/.AGENTTRASH/YYYYMMDD-topic. Never rm. |
| Tests exist | Run them — verify tests pass after every change. Do not commit failing tests. |
| Real secrets | Never commit — host .env files; .env.example holds placeholders only. |