Compare commits

..
Author SHA1 Message Date
Frank 4a02dc0980 frank-primary: digest-pin FreeToken base image, symlink libhiprtc dev name
- FROM pinned to sha256:a223aee1... (the pulled rocm/pytorch rocm7.14
  pytorch 2.11.0 manifest); a re-pushed tag cannot change the stack.
- The pip-ROCm torch ships libhiprtc.so.7 only; the PR's _cpu_moe links
  -lhiprtc and ld needs the unversioned symlink in the already-linked
  _rocm_sdk_core/lib (first build died: cannot find -lhiprtc).
2026-10-05 19:11:15 -07:00
Frank 1f6bdac759 frank-primary: add FreeToken PR587 ROCm serving image recipe
Dockerfile builds the FreeToken engine (FlashML-org/FreeToken PR #587,
pinned head 77a7201) inside the PR's own known-good ROCm 7.14 base image
(torch 2.11/triton 3.7, gfx1201) on dankserver, rootless podman. Follows
docs/install_amd.md exactly: --no-build-isolation --no-deps engine
install, curated portable runtime deps, flashlib --no-deps, ninja for the
QSA JIT; no flashinfer/sgl_kernel on ROCm (Triton fallback).
2026-10-05 19:00:30 -07:00
Frank a6ad9af708 docs: vault link integrity restored after rogue-session doc WIP landed
The parallel session's stash pop left GGUF Compatibility, Multi-Device
Splits, Serving Sizing, PCIe Link and Atomics, RDNA4 Container Serving,
vLLM Porting triage and RDNA4 MoE Budget unindexed and carrying 9
unresolved wikilinks (LLM Bench Windows / Model Swap / AGENTS targets
that never existed, a [[nodiscard]] C++ attribute parsed as a wiki link,
and two multi-line-wrapped link tokens). All repointed to real pages;
six rows added to the vault index; seam green.
2026-10-05 18:40:31 -07:00
Frank 380aafa1cc frank-small: Frank overrules q8_0 V — f16 K+V, ubatch 1024 first, ONE native 262144 slot (the only f16/f16 shape that fits Q8 weights on 24 GiB) 2026-10-05 18:31:50 -07:00
Frank 5573ca171f frank-small: seam follows the f16 K + q8_0 V contract from 1862dc3
1862dc3 repriced the Ornith rung (nine full-attn layers: f16/f16 = dead
boot) and flipped CACHE_TYPE_V to q8_0, but its note that the seam was
'retargeted in 8f53df7' covered only the payload paths — the KV test
still asserted the 2026-10-03 unquantized-both-halves contract and held
the suite red. Test now pins the shipped contract: K=f16, V=q8_0,
flash-attn on.
2026-10-05 18:23:44 -07:00
Frank 1c725ea024 e2e: run.sh keeps playwright arg passthrough with the default project set
The default-project-set rewrite dropped non---project args (e.g. --list),
so 'run.sh --list' silently RAN the whole suite instead of listing.
Args now ride alongside the default set; an explicit --project still
takes over. Verified: default --list = 33 tests listed; opt-in --list =
34 with the frankbook walk.
2026-10-05 18:18:21 -07:00
Frank 1862dc3c0f frank-small: env arithmetic for the Ornith Q8_0 rung (f16 K + q8_0 V)
The rogue session's swap left this env reprice uncommitted in the tree
(2026-10-05): Ornith is a 33-block qwen35 hybrid with NINE full-attn
layers (18 KiB/token/half), +1,900 MiB of weights vs MiMo Q6_K — f16/f16
lands ~379 MiB free, a dead boot; pool-stepping cannot rescue it because
the compaction invariant needs slot >= 191,148. The only lever that
keeps 2 x 200,704 AND the 2-3 GiB floor is q8_0 V (~2.87 GiB free). K
stays f16. Deployer gate + seam already retargeted in 8f53df7; fetch
script manifest already flipped in 3ee88d7. MEASURE after boot; under
1.5 GiB free, UBATCH_SIZE down to 1024, never the window.
2026-10-05 18:18:06 -07:00
Frank 2930431052 frank-small: repoint the deployer payload gate and unit seam at the Ornith-1.5-9B payload
The 17:47 Ornith-1.5-9B swap (77decb5) updated frank-small.env and
serve-frank-small.sh but left two consumers on the MiMo-Q6K paths:
deploy-remote.sh's LLM_SMALL_MODEL_FILE/LLM_SMALL_MMPROJ defaults (the
payload-existence gate checked files the unit no longer loads) and
frank_small_unit.bats (stubs + env assertion), which held the whole
bats suite red. Both now point at the Ornith-Q8 payload; docs updated
with the conditional frankbook e2e project.
2026-10-05 18:13:01 -07:00
Frank 8f53df7b4f frank-small: complete the Ornith swap in seam + deployer twins
The rogue session's Ornith commits (3ee88d7/77decb5) swapped the env and
fetch script but left the deploy handler gates and the seam asserting
the MiMo-Q6K paths — the seam failures 195-203 and a deploy that gates
a file the unit no longer reads. Flip both to Ornith-Q8 (Q8_0 GGUF +
BF16 mmproj), keeping the anti-drift twin structure intact.
2026-10-05 18:10:59 -07:00
Frank 9c508218e9 e2e: scope the FrankBook-Pro leg to its own trigger-gated Playwright project
The 11-admin spec walked links that terminate on FrankBook-Pro
(192.168.0.112), which sleeps when idle — every deploy gate timed out
there (measured 2026-10-05, two deploy windows). Split: the frankbook
links move to tests/e2e/specs/19-frankbook.spec.ts under a frankbook
project that run.sh excludes by default and deploy.sh triggers ONLY
when a frankbook blast-radius path changed (the spec, frankbook-pro/,
the homepage registry, the Caddyfile arr vhosts). deploy.sh computes
the trigger from the same BASE..HEAD revision range the plan uses.
2026-10-05 18:04:02 -07:00
Frank 77decb545e frank-small: swap the payload to Ornith-1.5-9B Q8_0 + BF16 mmproj 2026-10-05 17:55:36 -07:00
Frank 69f10a17bb frank-small: swap the payload to Ornith-1.5-9B Q8_0 2026-10-05 17:55:36 -07:00
Frank be840b4881 frank-large: halogen container cutover, rootless rlimit law, and the DFlash2 drafter shape 2026-10-05 17:35:05 -07:00
Frank b0b840b149 frank-primary: document FRANK_PRIMARY_MNBT=2048 and the chunked-prefill flag surface 2026-10-05 17:32:22 -07:00
Frank bdbe814fac frank-large: correct the halogen payload contract to the Hub's truth
The first constants were wrong in three ways, found while resuming the
checkpoint (the file was COMPLETE; the manifest was not):
- checkpoint is 35,865,565,184 B / LFS sha256 274c3fc7... (not
  35,880,329,102 / 40874e7a...), 1352 tensors per the card;
- the tokenizer is a FLAT tokenizer/ DIRECTORY of 5 files mounted whole
  at /tokenizer (model-card docker-run parity; the engine's
  HALOGEN_TOKENIZER default is the directory) — there is no
  frank-large.tokenizer.gguf;
- plain git blobs (vocab/merges/jinja/config) carry no LFS oid, so the
  fetch script size-checks them and sha-verifies only the two LFS
  objects (checkpoint, tokenizer.json).

Gates flipped together (serve + deployer twins): tokenizer dir must be a
real directory and every named file a regular file, no symlinks (HF
snapshot links dangle in the mount). Fetch made idempotent: a file at
the published size skips the transfer (resuming it is a CDN 416 under
set -e). Seam rewritten to the same contract, 17/17.
2026-10-05 16:45:12 -07:00
Frank 87695c5126 download-frank-large-halogen: resume against the resolved CDN URL
curl drops the Range header across the HF /resolve/ 302, so '-C -'
against the resolve URL pulls the 1 KB redirect body and exits 0 with
the file still 14 MiB short (measured 2026-10-05). Resolve the
effective URL first; the Xet CDN honors Range.
2026-10-05 16:31:35 -07:00
Frank 0900551317 docs: frank-large halogen runbook, AGENTS.md + indices for the swap
New runbook frank-large-halogen27b-dflash2.md; LLM + vault indices
re-pointed, ROCmFPX q6agent runbook marked RETIRED (rollback target);
AGENTS.md topology row, detail section, image-capability paragraph
(two endpoints accept images now), bench-law prose and runbook index
updated.
2026-10-05 16:29:11 -07:00
Frank fece3ffdd0 frank-large: cut over to halogen container (qwen3.8-27b .hgn + DFlash2)
Runtime swap ROCmFPX llama.cpp -> peonist-ai/halogen (image digest
dc0a39a0, tag 0.1.4), serving qwen3.8-27b-p1w4d-d2.hgn (33.4 GiB, the
.hgn format loads in no other engine) with HALOGEN_DRAFTER=2 (DFlash2)
on ONE slot of 200700 ctx: slots>1 disables the drafter (engine law).
Accepted: TEXT-ONLY (vision encoder ignored; frank-small owns images)
and the censored base (rollback = revert this commit; payload, fork
build and build-rocmfpx.sh pin stay on disk).

- serve script: podman run under the root unit, exec podman wait so
  Restart=always is real; gates image digest, checkpoint byte-count,
  flat regular-file tokenizer, GPU nodes; engine stdout to log.
- deploy handler: gates the artifacts, sweeps the legacy bare
  llama-server + old container name by NAME (never pkill llama-server:
  frank-small), retires the 2026-09 quadlet, port cutover 7770.
- fetch script: -C - resume, -f, byte count, sha256, --verify-only.
- seam rewritten (17 tests): halogen is now the deployed runtime.
2026-10-05 16:29:11 -07:00
Frank aa9908257d docs(index): frank-small summaries carried the superseded 2,849 MiB free figure
Both indexes stated the pre-doubling VRAM as current. Now 2,279 MiB free with
the 4096/2048 pair named, so no index contradicts the runbook it links to.
2026-10-05 16:03:18 -07:00
Frank 67141c8c02 docs(frank-small): record the batch doubling and scope the ubatch law to its backend
The AGENTS.md lesson "`--ubatch-size` has an OPTIMUM on this stack" was load-
bearing and over-general: it was measured on the 2x R9700 layer split, and the
same knob on the CUDA 3090 behaves the opposite way (curve climbs monotonically
to 2048). That difference is now the reason frank-small ships ubatch 2048, so
the lesson names the backend it was measured on and points at the counterpoint.

Adds the 2026-10-05 boot-pair A/B to the runbook (+4.6 % prefill at 8,928
tokens for 616 MiB, decode flat, the sub-ubatch prompt as the control), marks
the superseded 21,276/2,849 VRAM row, and logs the day. The old "ubatch 2048
failed the scratch boot" line stays visible as what it was: a predecessor-shape
observation, not a law.
2026-10-05 15:53:06 -07:00
Frank e5893dc30d frank-small: double the compute-buffer pair to 4096/2048
BATCH_SIZE 2048 -> 4096, UBATCH_SIZE 1024 -> 2048.

Why the ubatch move is right on THIS card: the prefill curve is
card-specific. On the 2x R9700 layer-split sibling it peaks at ubatch 512
and every step up costs GiB for no speed; on this CUDA 3090 it climbs
monotonically to 2048 (710 -> 1188 t/s on the 35B MoE sweep, 1944 -> 2423
t/s on the Ornith 8k sweep) with decode flat to under 1 t/s on every rung.
The logical batch is free either way: two sweeps agree that at a fixed
ubatch, 2048/4096/8192 give identical prefill speed and identical peak
VRAM, because the compute buffers are sized by the ubatch alone.

The risk is all in the ubatch, and it is documented rather than assumed:
the step cost ~1,244 MiB of peak graph VRAM on the 35B MoE, the shape this
replaces is 2,815 MiB free, and the Q5_K_M predecessor recorded "ubatch
2048 failed the scratch boot" with MORE free than that. Compute buffers
are allocated at graph capture during boot, so a failure is loud and fast;
the step-down rungs (4096/1536, then 4096/1024) are named in the env.

The seam stops hard-coding a rung: serve_argv now carries BATCH_SIZE and
UBATCH_SIZE the way it already carried CTX_SIZE et al., and the pair test
asserts the argv follows the shipped env plus the llama.cpp invariant
ubatch <= batch. Verified both ways: retuning the env to 8192/4096 keeps
the seam green, inverting the pair to 2048/4096 turns it red.
2026-10-05 15:10:15 -07:00
Frank b06b47c7b4 docs(fleet): radiance standalone engine probe - qwen4exp refuses fp8 input, quant happens at convert
rad-convert --plan-only with the deployed orcarouter FP8 payload:
'no architecture plugin claims qwen4exp at quantisation fp8; served at
(unquantised)'. Our quantization is reproducible from a bf16/abliterated
source by recipe (libr4d reads fp8 experts), not from our file. Image
pulled on dankserver, no GPU touched.
2026-10-05 14:11:47 -07:00
Frank 2e64f66b82 docs(frank-primary): RESOLVED - the ~40k corruption was marginal DDR5 at 4800, fixed at 4600
The prefix-caching OFF/ON experiment was confounded (both arms ran on
the marginal speed); the align-mode re-seed theory is unproven, not
disproven. Caching ships ON with zero gdn_hip faults under heavy
re-send traffic on stable RAM; knob stays commented. Corrects the
env's 'RAM RULED OUT' claim and adds the AGENTS.md lesson.
2026-10-05 13:54:06 -07:00
Frank 35a45b8a66 frank-primary: prefix caching back ON — the OFF experiment did not fix the corruption
Frank: still corrupted at ~40k with --no-enable-prefix-caching deployed
and mechanically verified (zero gdn_hip faults in the container serving
that window, coherent 40,771-token boundary probe). The recurrence is
FAULT-FREE, so the align-mode page re-seed is not the (only) mechanism
and the mitigation bought whole-history re-prefill per turn for nothing.

Revert: knob FRANK_PRIMARY_NO_PREFIX_CACHING commented out -> the serve
branch renders --enable-prefix-caching (the shipped contract). The knob,
the branch and the seam stay documented for a future fixed fork build.

Open hypotheses recorded in the runbook: re-fed poisoned transcript (the
same conversation cannot distinguish fixed from unfixed), or a second
fault-free long-context path — fp8 KV at depth is the next suspect. Next
diagnostic: fresh-conversation reproduction, then a KV-dtype A/B.
2026-10-05 13:13:26 -07:00
Frank 1fee99c8ce docs(frank-primary): record the verified 2026-10-05 prefix-cache-off deploy
Deploy b820d34, measured: --no-enable-prefix-caching in the engine argv,
health 200, warm boot 76.57 s (compile cache intact), expert plan
unchanged (17.77 GiB / 7,762 slots), GPU KV cache 424,678 tokens (up
from 401,408 at the same byte ask - pool now prices ~7,456 B/token with
the per-block mamba checkpoint pages gone; still clears the 400,700
floor), zero gdn_hip lines, and the 40,771-token boundary probe
(13 x 3136 = 40,768 crossed) decoded the 1-400 count coherently
(0.997 monotone, tail ...398 399 400). e2e 32/33 - the failure is the
admin spec's FrankBook-Pro leg (mini asleep), environmental.
2026-10-05 05:55:39 -07:00
Frank b820d341f5 frank-primary: fix the prefix-cache flag render (127 crash loop)
Two bash defects in the 9d8b185 prefix-cache wiring, both measured live
on the 2026-10-05 deploy attempt (unit crash-looped 190x, exit 127,
endpoint down for the whole 3600 s health window):

1. A # comment block inside the backslash-continued podman run
   terminates the continuation: the comment swallows the joined line
   and the flag line then runs as its own command
   ('--no-enable-prefix-caching: command not found'). The comment moves
   above the run block, with a no-comments-in-chain note.

2. The ${VAR:+A}${VAR:--B} double-expansion is ambiguous: when the
   variable is set, ${VAR:--B} parses as a SUBSTRING OFFSET, not the
   :- default — measured render was '--no-enable-prefix-caching1'.
   The flag is now branched explicitly into
   FRANK_PRIMARY_PREFIX_CACHE_FLAG (exactly one of the two flags
   reaches the engine; unset keeps the historical flag).

Seam: the render test sources the shipped env through the branch and
asserts the exact flag word the engine receives (render-assert, the
same pattern the spec knobs use). bash -n clean; all 22 frank-primary
seams pass.
2026-10-05 05:18:53 -07:00
Frank 9d8b18553d frank-primary: prefix caching OFF — the fork's QFN align-mode GDN state fault
Measured 2026-10-04 18:46:07 PDT (live podman logs): 48 gdn_hip state
faults in one 0.6 s burst — 'state out of ssm-cache range: max|S|=0 with
non-finite values (slot=42 head=3|8)', one per TP rank, 23 GDN layers
each — then the process-lifetime report cap. The guard's own text in
this build names it: 'known Qwen3.8-Flash-Next (QFN) flaw under prefix
caching … Mitigation: --no-enable-prefix-caching'.

Mechanism: prefix caching puts QFN on mamba_cache_mode 'align', which
checkpoints each sequence's GDN recurrent state into a mamba page per
3136-token block and re-seeds it from that page when a request resumes
from a cached prefix. The NaN state landed at the 13th boundary
(13 x 3136 = 40,768 — the reported ~40k mark) and persisted in the page:
every later turn of that conversation re-seeded the same non-finite
state for two heads, silently (fault-report cap already spent).

RAM ruled out the same day: zero MCE/EDAC records since the boot under
test, 8 GiB in-band pattern stable under live load, 4800 MT/s is the
JEDEC floor on 5600-rated DIMMs, and the two-head single-slot NaN
signature is a state-path fault, not scattered bit corruption.

Fix: FRANK_PRIMARY_NO_PREFIX_CACHING=1 -> --no-enable-prefix-caching
(flag renders in both directions from the knob). Cost: every turn
re-prefills the whole history instead of at most 3135 tail tokens.

Seam updated: the prefix-cache pin now asserts the conditional render
and the shipped OFF value. Runbook carries the evidence + acceptance
criteria; all 368 tests pass.
2026-10-05 03:45:26 -07:00
Frank a1c4701d7e docs(frank-small): Q8_0 rung evaluated and closed — floor break plus -24% decode 2026-10-04 18:17:43 -07:00
Frank c4603b130c frank-primary: pin the expert host region to 120 GiB (--expert-offload-mem)
The host region was the ONE arena-plan term still autofitting per boot: with
no cap the planner binds it to MemAvailable AT BOOT minus PLE/process/api/
margin terms (boot 2026-10-05: 237.97 GiB -> 159.39 GiB usable; the same host
reads 75.4 GiB available while running). Demand is 105.48 GiB
(52.74/rank x 2 ranks, 23,040 swap-target experts); a boot where co-tenant
RAM squeezed the bound near that silently pins next-hottest experts VRAM-only
(pinned_extra warning, smaller swap region, no flag change). 120 GiB =
measured demand + 14% headroom on 246.1 GiB total. Semantics verified in the
shipped image: cap binds as the bound, exceedance only warns.

The GPU-side arena is unchanged (deterministic arithmetic over the same
constants): verify after boot that host reads '--expert-offload-mem' and
slots stay 7,762. Seam: red first (wiring + value), then green; full suite
368+1 green.
2026-10-04 18:16:38 -07:00
Frank 9a144f779e docs(frank-primary): record VLLM_CUDAGRAPH_MEM_ESTIMATE_MB, the graph-term outright pin
Verified in the shipped image (v1/worker/gpu/cudagraph_mem.py):
estimate_cudagraph_memory() checks VLLM_CUDAGRAPH_MEM_ESTIMATE_MB FIRST and
returns that MiB total outright, ignoring the BASE/PER_GRAPH pair — the only
way to state the CUDA-graph plan term directly instead of deriving it. We
keep shipping the pair (0/12) so a future spec-decode shape re-prices
automatically; documented the switch condition.

Also corrected two stale shape-G claims in the runbook that predated the
1.00 revert: the deployed shape block and the lever table both still said
util 1.0 / 'le=1 ceiling', while shape G ships 0.982.
2026-10-04 17:51:27 -07:00
Frank 50459e4e31 docs: frank-small Q6_K cutover runbook + measured post-boot numbers
- new active runbook frank-small-llama-mimo-q6k-2x200704.md: 21,276 used /
  2,849 true free (inside the 2-3 GiB floor), decode 94.44 t/s vs 104.84 on
  Q5_K_M - the rung costs a measured -10 % (weight-bandwidth-bound card),
  512px red-PNG vision verified, and the new probe-size law (>=256 px: a
  10x10 image undersupplies image tokens and the model answers 'video'),
  nvidia-smi free-field law (driver holds ~450 MiB the subtraction hides)
- frank-small-llama-mimo-q5km.md retired; it keeps the boot-pair table and
  becomes the Q5 rollback reference (3,571 free, 104.84 t/s measured)
- AGENTS.md fleet row, endpoint section, --parallel lesson, runbook index
  (active row + two retired rows); LLM + root index entries
- Q6_K GGUF-vs-GGUF diff proven: template and all 46 KV keys byte-identical
  to Q5_K_M - the vision probe scare was probe size, not payload
2026-10-03 11:20:33 -07:00
Frank ff8c0ee264 frank-small: unit Description names the Q6_K rung
The journal still advertised Q5_K_M after the payload swap; the unit file
is the repo-side truth, fix it and let the next deploy daemon-reload it.
2026-10-03 11:20:10 -07:00
Frank 6ec0b1a85d frank-small: Q5_K_M -> Q6_K rung at the held 2x200704 f16-KV shape
The freed third slot funded exact V; the remaining ~3.5 GiB of honest
nvidia-smi free (3,571, not the 4,046 total-minus-used I quoted - the
driver holds ~450 MiB of reserve) funds the +875 MiB of Q6_K weights.
Projected landing ~2.7 GiB free, inside the 2-3 GiB floor; measure on
boot, step the pool one 12,288-token increment if it lands under 2 GiB.

- payload dir MiMo-Q6K/ made self-contained (mmproj hardlinked from
  MiMo-Q5KM/, link-count-2 verified)
- env/serve/deployer/bats repointed; download script now fetches and
  verifies Q6_K (7,793,710,624 B, ef96d05a...) with Q5_K_M documented as
  the rollback; payload sha verified live on the host before deploy
- seam green 14/14 against the stub engine
2026-10-03 11:00:19 -07:00
Frank b80886e318 docs: frank-large Q6 uncensored cutover + frank-small 2x200704 f16-KV reslice
- new active runbook frank-large-rocmfpx-q6agent-uncensored-mtp.md: the
  Q6_0_ROCMFPX_AGENT file (ftype 114, 339 type-102 + 159 type-103 tensors),
  27.6-28.1 t/s at 86.8% draft acceptance, the measured ngram-mod chain
  rejection (-15% prose: recurrent-state rollback disabled), the
  bench-window hardening laws
- frank-large-rocmfpx-q8agent-mtp.md retired; rollback section corrected
  (gufo payloads removed from disk 2026-10-03 - that rollback is now a
  re-download; the aligned Q8 file is the direct one, still on disk)
- frank-small-llama-mimo-q5km.md reshaped to 2 x 200704 with fully
  unquantized f16 KV: boot-pair table gains the measured row (20,530 used /
  4,046 free), 104.84 tok/s, slot-window contract at 200704, rollback to
  the 3x180224 + q8_0-V shape documented
- AGENTS.md: fleet table rows, both endpoint sections, the chain law
  extended with the recurrent-model measurement, the --parallel law with
  the reslice, runbook index (active + retired)
- LLM/index.md + root index entries updated
2026-10-03 08:01:44 -07:00
Frank 47a2c1287b frank-primary: shape G measured — arena 17.77 GiB / 7,762 slots, KV 401,408 tokens / 2.00x
Record the live numbers and the UTIL 1.0 verdict (unbootable: worker init
demands free > budget; ValueError on both ranks before a single weight loads).
Driver used 30.97 of 31.86 GiB; the 0.89 GiB/card margin held through a live
4,096-token generation at ~38-39 tok/s.
2026-10-03 03:21:55 -07:00
Frank c67a2aeb80 frank-primary: shape G v2 — UTIL back to the 0.982 law; the 1.00 force was unbootable
Worker init refuses budget >= device total on BOTH ranks:
  ValueError: Free memory on device cuda:0 (31.29/31.86 GiB ...)
NRestarts=2 crash loop, zero weights loaded. Experts stay at the v1 shape
(graph term 0.07 GiB, KV 3.1665e9 = the 400,700-token ask); only the budget
returns under the law. Arena 17.78 GiB / ~7,912 slots predicted.
2026-10-03 03:01:55 -07:00
Frank 0a4ff30db2 frank-primary: shape G — force the arena to the flag ceiling, KV to 400,700 tokens
Two plan terms were still unspent: the CUDA-graph charge (shape F's own boot
reported a NEGATIVE capture delta, -0.06 GiB, so the 256 MiB base pool was
fiction -> charge 0 + 12 MiB/graph = 0.07 GiB) and the budget (util 0.982 -> 1.0,
the field's le=1 maximum). Together +0.82 GiB of expert cache: plan target
18.35 GiB / ~8,016 slots. KV_BYTES 3.15e9 -> 3.1665e9 = the 400,700-token ask at
the engine's measured 7,891.8 B/token. Cost of the force, recorded: driver
high-water ~30.4 GiB of 31.86, so ~1.5 GiB of real margin per card; rollback is
UTIL=0.982.
2026-10-03 02:49:38 -07:00
Frank c682c6329c frank-primary: shape F — MNBT 2048, util 0.982, CUDA-graph plan term pinned to the measured capture
The arena is budget - non-expert - KV reserve - graphs - overhead. With util
capped at 0.982 (never 1.00) the graph estimate is the only plan term this repo
can shrink: the fork charges an analytic 256 MiB + 52 MiB/graph = 0.55 GiB where
the engine's own post-capture line reports 0.31 GiB, so pricing it at
256 + 12/graph returns ~0.24 GiB to the expert arena. MNBT 1024 -> 2048 is
arena-free: the registry overhead is base + 0.22 GiB per 1024 ABOVE its nbt 4096
anchor, and the fitted 5.25 GiB was fitted AT 4096.
2026-10-03 02:22:49 -07:00
Frank 6584c059d6 frank-small: two wide unquantized contexts (2 x 200,704) on the 3090
Frank's order, two messages: "extend the max context of frank-small to
200700 per individual stream without further quantization", then
"reduce it to 2 streams for a total of 401400-ish tokens, set ubatch to
1k", then "unquantize the KV if we have extra space".

CTX_SIZE 540672 -> 401408 with PARALLEL 3 -> 2 gives n_ctx_slot 200,704:
above the 200,700 the harness compacts at, so a full-length request
lands in one stream instead of a 400 exceed_context_size_error. The pool
must divide evenly across slots and stay 256-aligned for the FA path,
which is why it is 401,408 and not 401,400.

CACHE_TYPE_V q8_0 -> f16: the freed third slot is exactly what pays for
unquantized values (24.5 -> 32 KiB/token), and the budget lands back in
the 2-3 GiB band Frank set on 2026-10-02 instead of leaving ~6 GiB of
the card idle. The asymmetric-KV contract that created this endpoint is
spent with it; both --cache-type flags stay explicit.

--batch-size/--ubatch-size are now passed (2048/1024). The endpoint had
never passed either, so it ran on engine defaults nobody wrote down.

Franksdank-Runbook: serverAppConfigs-docs/LLM/frank-small-llama-mimo-q5km.md
2026-10-03 02:04:54 -07:00
Frank 7d3dd33de0 frank-large: swap to the uncensored Q6_0_ROCMFPX_AGENT payload
Frank's order: replace frank-large with
kingjones777/Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP-GGUF's
Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT.gguf, following that card.

The file is ftype 114 — 339 GGML_TYPE_Q6_0_ROCMFPX (102) + 159
GGML_TYPE_Q8_0_ROCMFPX (103) + 353 F32 over 851 tensors,
qwen35/64 layers/full_attention_interval 4/4 KV heads/262,144 window —
read out of its own header. The pinned ROCmFPX build already knows both
types (ggml.h 102/103 plus convert/cpy/getrows/mmq/fattn device paths
for 102), so no rebuild: the fork stays at 36e9acd and the payload
directory is the only new host state.

Card compliance, all already our flags: Q4_0 draft head (not Q8_0),
--spec-draft-n-max 4 (the knee on both cards), --spec-draft-ngl 99,
--jinja, --fit off, -fa on, -dio, one slot at full window, thinking with
the card's own sampling triple (which the file also embeds as its
general.sampling defaults). Kept from the deployed shape: the BF16
projector (this repo ships only a Q8_0 one; BF16 is the file verified
4/4 on spatial ground truth here) and the byte-identical MTP head, which
the new script hardlinks instead of re-fetching 1.6 GiB.

Franksdank-Runbook: serverAppConfigs-docs/LLM/frank-large-rocmfpx-q6agent-uncensored-mtp.md
2026-10-03 02:04:22 -07:00
Frank 0ae923b879 autoresearch arms: add D/E context arms (KV 4.05e9 = 2.00x under MTP3; E adds MNBT 4096 + util 0.99) 2026-10-02 23:52:04 -07:00
Frank b7c3bdb2be autoresearch: temp sweep, engine evidence, env overlays, arms driver
Four harness gaps that block an honest MTP re-measurement:

- measure: AR_TEMPS sweeps sampling temperatures (default "0,0.7"). Draft
  ACCEPTANCE is sampling-dependent — a greedy draft matches a greedy target
  exactly and is rejected more under a non-greedy target — so judging a spec
  arm only at temperature 0 judges half the contract. The unsuffixed keys stay
  temperature 0, so every historical number remains comparable.
- window: capture an evidence.log sidecar (expert-plan lines, compile path,
  graph sizes, /metrics spec-decode and cache counters, OOM signatures). The
  arena/overhead/reserve numbers exist ONLY in the boot log; an arm that loses
  decode speed is otherwise unexplainable afterwards.
- orchestrator: FRANK_PRIMARY_AR_ENV_FILE stages an env other than the repo's,
  which is how arms A/B without mutating the checkout (the fingerprint covers
  whatever is staged, so the memo can never answer one arm with another's
  numbers), plus METRIC lines for the swept temperatures and the evidence dump.
- scripts/autoresearch/frank-primary-arms.sh: runs a sequence of full windows
  (baseline, MTP3+probabilistic with a step-4 capture ladder at precise memory
  ON, the same at the default overhead, and the vendor SEQS-16 shape), gating
  each staged env on its JSON knobs still parsing AFTER sourcing — an unquoted
  {"temperature":0.7} loses its quotes at source time and reaches the engine as
  {temperature:0.7}, which is a 20-minute boot wasted.
2026-10-02 16:38:17 -07:00
Frank d57174c8d3 frank-primary: env-driven spec/compilation/sampling knobs
Three engine knobs that were impossible to set without editing the run line:
FRANK_PRIMARY_SPEC (--speculative-config), FRANK_PRIMARY_COMPCONFIG
(--compilation-config) and FRANK_PRIMARY_GEN_CONFIG
(--override-generation-config). Each holds the whole JSON value; empty or
unset means the flag is absent, so the shipped shape E argv is unchanged.

The seam re-pins the law that justified the omission: "speculative decode is
ABSENT BY MEASUREMENT" compared the wrong pair — c1248d4 retired MTP by
weighing fp8+MTP4 (37.4-40.6 t/s, 54% acceptance) against the bf16 no-spec
boot (36.4-39.4), bf16 then proved unbootable, and fp8 came back WITHOUT MTP.
fp8+spec was never measured against fp8+no-spec on one shape, the no-spec
incumbent runs ~30 t/s under the fleet's 35 t/s target, and every vendor
flash-next recipe ships MTP3.

Also in the seam: a dead assertion is deleted (a failing `! grep` mid-test is
exempt from bats' errexit, so the pre-precise-memory `! grep
--expert-precise-memory` never ran), and the knob test asserts by RENDERING the
flag from the sourced env instead of grepping for an assignment.
2026-10-02 16:37:52 -07:00
Frank ea1132aa0a docs: record the unmeasured CPU-side recipe-diff candidates (HSA knobs, OMP_NUM_THREADS)
All six HSA names verified real by string presence in libhsa-runtime64.so, so
they are knobs and not no-ops — but none is shipped without its own A/B.
2026-10-02 15:15:13 -07:00
Frank 57f33e5289 frank-primary: record the measured shape-E and compile-cache numbers
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.
2026-10-02 15:05:34 -07:00
Frank 1ac4d48371 frank-primary: shape E — --expert-precise-memory (arena 6,496->7,522 slots), GPU_MAX_HW_QUEUES=2, and a mounted /root/.cache
Measured by boot probe on the shape-D base (production stopped, candidate
booted AS the service), plan numbers read off the engine's own log:

  runtime overhead   7.60 -> 5.25 GiB (precise gfx1201 TP2 registry)
  expert cache      14.87 -> 17.22 GiB
  slots              6,496 -> 7,522 (+1,026, +16%)
  swap region                13.7 GiB / 5,986 slots
  boot                       healthy in 420 s, no OOM

'--expert-precise-memory': True also appears in non-default args, so a silent
argparse drop is visible. The pool is NOT cut (option A) because the pool is
not an arena lever: 3.15e9/rank keeps shape D's measured 1.99x concurrency,
and a 2.2e9 arm probed in the same window (277,686 tokens, 1.38x) is recorded
as the conservative fallback.

Boot-cost bug found while diffing Frank's 3xR9700 recipe: the image ships no
/root/.cache at all, our boot writes 1.1 GB of vLLM AOT/torch_compile
artifacts (4,665 files) plus 229 MB of comgr JIT kernels into the ephemeral
layer, and podman rm -f discards them — so every deploy/restart/reboot re-pays
'torch.compile took 181.20 s in total' (173.19 s of it one graph). Mounting
FRANK_PRIMARY_CACHE_DIR at /root/.cache persists both and hides nothing.

GPU_MAX_HW_QUEUES=2 added at Frank's direction (real HSA knob); not A/B
measured. Retracted my own recipe-diff claim that cudagraph_capture_sizes
needed pinning: capture is 3 PIECEWISE + 2 FULL graphs, 3 s, 0.31 GiB.

Tests: 367/367.
2026-10-02 14:37:57 -07:00
Frank 812193aa63 docs: the bare-deploy drain law + the plex gate fix in the work log
`deploy.sh <target>` with no `--service` also walks the per-service revision
markers and restarts everything behind HEAD whose paths changed — a drain, not
a sync. That is how the latent plex gate bug surfaced: the backlog fired all
at once and plex's http:// readiness probe, which can never succeed against a
PMS that drops plain HTTP off-loopback, burned its full 600 s and aborted the
run with every later service left pending.

Both facts recorded: AGENTS.md gains the drain law under Deployment, log.md
gains the plex entry with the evidence chain.
2026-10-02 12:26:55 -07:00
Frank 39f0e0d8d4 plex: probe the readiness gate over HTTPS — PMS drops plain HTTP off-loopback
deploy_plex gated on `curl http://127.0.0.1:32400/identity`, which can never
succeed: Plex accepts the TCP connection (it arrives on the container's eth0)
and then answers with nothing, so curl reports "Empty reply from server" (52).
Measured 2026-10-02 on a plex that was healthy the whole time —
`https://127.0.0.1:32400/identity` returned 200 in 44 ms while the gate burned
its full 600 s and failed the deploy.

The repo already knew this law in another place: tests/caddyfile.bats pins the
plex vhosts to a TLS upstream with the comment "PMS drops plain HTTP
off-loopback". The deployer's gate never got the same treatment, and plex's
per-service revision had been behind long enough that the gate had not run
against this posture until now.

-k because PMS serves its own self-signed cert on 32400.
2026-10-02 12:23:56 -07:00
Frank 656012a0fa docs: LLM index + work log for frank-primary shape D, harness window
index.md intro and mermaid nodes still described 4 x 262144 fp8/bf16 and
frank-small at 2 x 262144; the active-deployment list had no frank-primary
entry at all. Fixed both, plus a malformed GLM list item.

log.md gains the shape-D entry (the SEQS=2 constraint, the arena-lever
measurement, the 2x2 probe table, the measured 1.99x), and the frank-small
entry's stale consequence bullet now points at the resolution.

frank-small runbook: the compaction-fit note is corrected - the 200704 window
moved frank-primary's trigger to 140,492, which fits a 180,224 slot again.
2026-10-02 12:05:39 -07:00
Frank 295d5784d3 frank-primary: record the measured shape-D numbers (398,658 tokens, 1.99x, 6,496 slots)
Comments/docs only - no functional change. The live boot of c769ff2 reported:
  GPU KV cache size: 398,658 tokens,
  Maximum concurrency for 200,704 tokens per request: 1.99x
  expert cache space 14.87 GiB / 6,496 slots of 2.344 MiB

The arena prediction (6,496) landed exactly; the concurrency prediction (2.04x)
did not, because the per-token KV price is SHAPE-DEPENDENT: 7,701 B/token at
MAXLEN 262144 (6.1e9 -> 792,069 tokens) but 7,892 at 200704 (3.146e9 ->
398,658). Never extrapolate a concurrency figure across geometries.

Also smoke-verified: three concurrent 67-token requests answered in parallel,
~4.1 s each - the 3 slots are real, and the pool admits 2 full-length.
2026-10-02 11:48:09 -07:00
Frank c769ff2f63 frank-primary: 3 slots x 200704, fp8, pool sized for 2 contexts
Re-applies the desired shape incrementally on the proven-good fp8 3x262144
base, after bisecting why the first attempt (30904ba) crash-looped.

Root cause: `--max-num-seqs 2` cannot compile on this model. AOT
build_guards(strict_error=True) inside profile_run -> _dummy_run specializes
`query_start_loc`'s width to SEQS + 1 (3 at SEQS 2, 4 at SEQS 3) and the
ConstraintViolationError kills engine init ~7 min in. MNBT is orthogonal:
SEQS 2 failed at MNBT 1024 AND 2048; SEQS 3 passed at both. KV dtype was a
red herring. Measured by four single-variable boot probes.

Arena lever measured: the expert arena is sized from `max_model_len` via the
registry-priced KV reserve (9190 B/token x max_model_len), NOT from
--kv-cache-memory-bytes. 262144 -> 14.30 GiB / 6,248 slots; 200704 ->
14.87 GiB / 6,496 slots. The 6.1e9 -> 3.15e9 pool cut bought zero arena and
is a runtime-headroom lever.

Shape: MAXLEN 200704, SEQS 3 (unchanged - the only slot count that compiles),
KV_BYTES 3.15e9/rank = 2.04x full-length concurrency at 7,701 B/token fp8,
MNBT 1024, gather 32x64, no spec decode, no prefetch, no precise-memory.
2026-10-02 11:33:27 -07:00
Frank e460bbbab5 frank-primary: revert to known-good fp8 3x262144
The 2 x 200704 fp8 / MNBT 1024 shape (30904ba) crash-loops at boot: the
engine dies in determine_available_memory -> profile_run -> _dummy_run with
the SAME torch.compile dynamo constraint that killed the bf16 2 x 262144
shape on 2026-10-01:

  RuntimeError: Worker failed with error 'Constraints violated
  (L['query_start_loc'].size()[0])! You marked it as dynamic but your code
  specialized it to be a constant (3)'

Not an OOM. Restoring the last shape measured serving (fp8 3 x 262144 /
KV 6.1e9 / MNBT 2048 / arena 6,248) so the endpoint serves again; the
desired edits are re-applied one at a time from this base.
2026-10-02 10:46:01 -07:00
53 changed files with 5910 additions and 1199 deletions

No files matched your search

+311 -152
View File
@@ -12,8 +12,8 @@ merge absorbed the former `vllmScripts` repo, history preserved).
Calibre-Web, Jellyfin, Plex) plus the `frank-primary` LLM 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) and `frank-small` (llama.cpp CUDA on the RTX 3090). Caddy on
Alloy, Pi-hole) plus TWO LLM services: `frank-large` (halogen container on
the gfx1151 iGPU) and `frank-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
@@ -24,9 +24,9 @@ merge absorbed the former `vllmScripts` repo, history preserved).
| 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, **2 seqs × 200704 ctx** at KV_BYTES 3,150,000,000/rank (fp8 measures 7,701 B/token), pool deliberately small so the expert arena keeps what it reclaims (~7,500 slots vs 6,248 at the 6.1e9 shape) — 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 |
| 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 2048 (arena-free: the registry prices overhead flat up to its nbt 4096 anchor), MoE gather 32×64, **`--expert-precise-memory`** (fitted 5.25 GiB overhead, not the 7.60 GiB default), **`VLLM_CUDAGRAPH_MEM_BASE_MB=0` / `_PER_GRAPH_MB=12`** (the plan's CUDA-graph term priced at the measured capture — 0.31 GiB then a NEGATIVE −0.06 GiB delta, so the 256 MiB analytic base was fiction → graph term 0.07 GiB), **`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,166,500,000/rank (~400,700 KV tokens at the engine's 7,891.8 B/token here) — the **pool, not the slot count, caps resident conversations** (measured live: `GPU KV cache size: 401,408 tokens, Maximum concurrency for 200,704 tokens per request: 2.00×`; the 3.15e9 pool below it measured 398,658/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. **util stays 0.982 BY LAW**: at `le=1` the worker init refuses `budget == total` with `ValueError: Free memory on device cuda:N (31.29/31.86 GiB)` on both ranks before a single weight loads — measured crash loop 2026-10-03. Arena at shape G: **17.77 GiB / 7,762 slots measured**, KV 401,408 tokens / 2.00× concurrency, 0.89 GiB of real free VRAM per card held through a live 4,096-token generation (the ~3.5 GiB of driver-reported free VRAM at shape E was estimator slack — the registry's 9190 B/token KV reserve vs the real 7,892, and the 5.25 GiB overhead vs far less — NOT allocatable), and the client contract wired: `--reasoning-parser …
| Dankclaw | `frank-large` | **halogen container** (`ghcr.io/peonist-ai/halogen@sha256:dc0a39a0…`, digest-pinned, plain `podman run` via `scripts/serve-frank-large.sh`, root systemd unit) | `peonist-ai/halogen-qwen3.8-27b` **`qwen3.8-27b-p1w4d-d2.hgn`** (35,865,565,184 B; the `.hgn` format loads in NO other engine — llama.cpp, vLLM and transformers all refuse it), **DFlash2 spec decode (`HALOGEN_DRAFTER=2`)**, **1 slot × 200,700 ctx** — `HALOGEN_KV_SLOTS=1` is load-bearing: batching XOR speculation, slots>1 silently disables the drafter (measured: 8 slots at serial ~6 t/s/stream), d2 KV (2× fp8, 64 KiB/token → 12.2 GiB pool, engine sizes it and prints a boot ledger naming any clamp), **TEXT-ONLY** (the engine ignores the vision encoder — vision is frank-small's job), **censored base** (the ROCmFPX AGENT rollback is the refusal escape hatch), **no server-side sampler: absent temperature is GREEDY**, so the card's thinking recipe (temp 1.0 / top_p 0.95) rides the wire in `models.yml`. Publisher card 31.7 t/s mean across eight text workloads vs the retired ROCmFPX endpoint's measured 27.6–28.1. Prompt over the slot AND `max_tokens` over `HALOGEN_MAX_TOKENS_CAP` are both 400s naming the limit, never truncations. Serve passes NO `--ulimit nofile` (rootless runc cannot raise Dankclaw's 524288 hard limit — see Key Lessons). Cutover 2026-10-05|7770|
| Dankclaw | `frank-small` | llama.cpp master, CUDA sm_86 build (root systemd unit, relinked) | bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF **Q6_K** (9B dense qwen3_5 hybrid, 7.26 GiB GGUF, no MTP weights so no spec decode) + the repo's f16 mmproj for **vision**, on the RTX 3090, **2 × 200,704** (pool 401,408 — NOT the native window) with **fully unquantized KV (f16 K + f16 V)** under flash attention, **batch 4096 / ubatch 2048** (doubled 2026-10-05: measured +4.6 % prefill at 8,928 tokens for 616 MiB), 21,846 used / **2,279 free** of 24,576 MiB, 94.4 tok/s decode. Resized 2026-10-03 (3 × 180,224 + q8_0 V → 2 × 200,704 + f16 V → + Q6_K rung: the freed slots paid for exact values AND the higher quant; the rung costs a measured −10 % decode — weight-bandwidth-bound); rollback `MODEL_FILE=…/MiMo-Q5KM/…Q5_K_M.gguf` (buys the 10 % back) or `CTX_SIZE=540672 PARALLEL=3 CACHE_TYPE_V=q8_0` (Q5 only) | 7771 |
Retired endpoints (`frank-npu`, and every prior runtime) are in
the [runbook index](#runbook-index). The full swap history lives in
@@ -74,6 +74,14 @@ 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:
@@ -82,7 +90,14 @@ A deploy is **not complete** when `deploy.sh` exits green. It requires:
2. the Playwright e2e suite (`tests/e2e/run.sh`) — runs **automatically** as
the `[3/3]` gate at the end of `deploy.sh` against the LIVE fleet; a
behavior-affecting deploy that fails it exits `DEPLOY INCOMPLETE`. Bypass
only with `--skip-e2e` in an emergency.
only with `--skip-e2e` in an emergency. The gate runs the conditional
`frankbook` Playwright project (the `*arr`/qBittorrent walk,
`tests/e2e/specs/19-frankbook.spec.ts`) ONLY when a frankbook blast-radius
path changed since origin/main — that spec, `frankbook-pro/**`,
`shared/homepage/services.json`, or the `dankclaw/Caddyfile` serving the
arr vhosts — so a sleeping FrankBook-Pro never fails an unrelated deploy.
The default project set excludes it everywhere else (`E2E_INCLUDE_FRANKBOOK=1`
opts in; see `serverAppConfigs-docs/Architecture/E2E Suite.md`).
```mermaid
graph LR
@@ -215,7 +230,7 @@ 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.**
(A gufo-era law; frank-large ran ROCmFPX llama.cpp 2026-10-02 → 10-05 and is the halogen container since, but the rollback tags keep 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
@@ -241,7 +256,7 @@ one: `ip neigh show <ip>` from a peer — FAILED means off/NIC dead, while a
merely wedged kernel usually still answers ARP.
1. Stop and mask (self-healing would restart it):
- Dankclaw `frank-large` (root unit, vLLM podman on the iGPU):
- Dankclaw `frank-large` (root unit, halogen 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`
@@ -277,14 +292,16 @@ 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
of 2026-10-05 **two of the three endpoints accept images** (`input: [text,
image]`) — `frank-large` went TEXT-ONLY with the halogen swap (the engine
ignores the vision encoder; the BF16-mmproj era ended 2026-10-05) — while
`frank-primary` takes them 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
`mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf` passed as `--mmproj` — the MiMo
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
**200704** (resliced from 180224 on 2026-10-03), 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` —
@@ -295,76 +312,64 @@ 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)
### frank-large — halogen container, Qwen3.8-27B + DFlash2 (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 **halogen engine** (`ghcr.io/peonist-ai/halogen@sha256:dc0a39a0…`,
digest-pinned, published tag 0.1.4) as a podman container driven by
`scripts/serve-frank-large.sh` under a **root systemd unit**
(`scripts/frank-large.service`, `User=dank`, `Restart=always`, `ExecStop=podman
stop -t 120 frank-large-halogen`, the script `exec podman wait` so the restart
policy is real) on port 7770 from the Strix Halo iGPU. Engine stdout goes to
`/home/dank/logs/frank_large.log` — **the boot ledger (KV pool, drafter state,
any clamp) lives there, not in the journal**. Serving
`peonist-ai/halogen-qwen3.8-27b`: `qwen3.8-27b-p1w4d-d2.hgn` (35,865,565,184 B
≈ 33.4 GiB, sha256 `274c3fc7…`, 1352 tensors; p1 weights / w4 activations /
d2 KV) + the flat `tokenizer/` directory (5 files). Cutover 2026-10-05 from the ROCmFPX llama.cpp fork
([[frank-large-rocmfpx-q6agent-uncensored-mtp]], measured 27.6–28.1 t/s prose),
chosen for the publisher's 31.7 t/s DFlash2 mean.
**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 format is the lock.** `.hgn` loads in nothing else — the runtime is
chosen by the FILE, as ROCmFPX's tensor types were before it. This is NOT the
2026-09 `halogen-flash-server` image (different repo, Flash-Next w4b era); the
halogen-server repo restarted versioning at 0.1.x.
**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.
**Two product regressions, both accepted by Frank before deploy:** the
endpoint is **text-only** (README honest limits: the vision encoder is
ignored — frank-small owns images now) and the checkpoint is a finetune of the
**censored** `Qwen/Qwen3.8-27B` base (refusals can return; the rollback is one
commit-revert away with the AGENT payload still on disk).
**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.
**Engine laws that shape the config** (all in `scripts/frank-large.env` with
evidence): `HALOGEN_KV_SLOTS=1` — batch XOR speculation, slots>1 turns the
drafter off silently. `HALOGEN_SLOT_CTX=200700` — the per-conversation HARD
ceiling (400 naming the limit, never truncation); `models.yml`
`contextWindow: 200700` must match or the harness discovers the wall as a 400.
`HALOGEN_MAX_TOKENS_CAP=65536` + `HALOGEN_QUEUE_TIMEOUT=7200` are a coupled
pair; the harness `maxTokens: 57344` stays under the cap (over-cap is a 400
naming the limit — README, never a 413). Thinking tokens COUNT against the
budget: exhausted mid-thought is `finish_reason: "length"` with empty
`content` and the reasoning in `reasoning_content`.
`HALOGEN_DRAFTER=2` = DFlash2. **No server-side sampler**: an absent
temperature is greedy, so the thinking recipe exists only in `models.yml`
`extraBody`. `response_format` is unsupported (no constrained decoding).
Thinking lands in **`reasoning_content`** (harness field unchanged); the same
Qwen3.8 template rejects `reasoning_effort` outside `xhigh|medium|low`, so the
`models.yml` fold stays mandatory.
**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.
Fetch: `scripts/download-frank-large-halogen.sh` into
`/home/dank/models/halogen-qwen3.8-27b/` (`-f` + byte count + LFS sha256 +
`--verify-only`, no token on the command line; resume goes against the
RESOLVED CDN URL because curl drops `Range` across HF's 302). The deployer
and serve script both refuse a missing tokenizer file or **any symlink** —
an HF cache snapshot symlinks each entry into `blobs/` and those links
dangle inside the container mount.
**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.
Runbook: `serverAppConfigs-docs/LLM/frank-large-halogen27b-dflash2.md`.
Rollback: revert the swap commit and redeploy — the ROCmFPX payload
(`/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/`), the fork build
and `scripts/build-rocmfpx.sh` (pin `36e9acd`) all remain on disk, seam-tested.
The older gufo tag `frank-large-gufo-q4kxl-20261001` stays a **re-download**
rollback (payloads removed 2026-10-03; `scripts/build-gufo.sh` stays).
### frank-small — llama.cpp on the RTX 3090 (Dankclaw, ACTIVE)
@@ -372,15 +377,20 @@ unit/env/serve trio and redeploy.
`/home/dank/GitPackages/llama-master/build-cuda/bin/llama-server`) as a
**root systemd unit** (`scripts/frank-small.service`, `relink_unit`ed 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).
`bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF` at **Q6_K** (7,793,710,624 B,
sha256 `ef96d05a…`) **plus `mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf`**
(918,166,048 B, sha256 `ff348f…`), both `/home/dank/models/MiMo-Q6K/` (the
projector is a hardlink of the Q5KM copy — the dir is self-contained), on
port 7771. The rung went Q5_K_M → Q6_K on 2026-10-03 the same morning as the
reslice, funded by the freed slots. The runtime 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, and SGLang's `--kv-cache-dtype` moves K and V
together. On 2026-10-03 Frank spent the headroom that lever bought: the reslice
from three slots to two made room for **fully unquantized V**
(`CACHE_TYPE_V=f16`); both flags stay explicit in the serve script. Vision came
2026-10-02 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
@@ -388,67 +398,165 @@ DeltaNet, native 262,144 window. KV exists only in the 8 attention layers
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.
**The shape (2026-10-03):** `--ctx-size 401408 --parallel 2` = **TWO
conversations of 200,704 tokens each** (llama.cpp divides the pool:
`n_ctx_slot = CTX_SIZE / PARALLEL`), `--flash-attn on` (the seam pins it),
**f16 K + f16 V**, all layers on the GPU, `--mmproj` for vision, and since
2026-10-05 **batch 4096 / ubatch 2048** (doubled; the boot-pair A/B is in the
runbook). Measured trade at 8,928 prompt tokens: prefill 4,024 → **4,209 t/s,
+4.6 %**, decode flat (91.9–93.0 vs 93.8–94.3 — noise), and a 279-token prompt,
which sits below either ubatch, did not move at all. The old "ubatch 2048
failed the scratch boot" note belongs to the Q5_K_M predecessor and does NOT
generalize: this shape boots 2048 clean. Measured VRAM **21,846 used / 2,279
free** of 24,576 MiB against **21,276 / 2,849** for the 1024 arm, reproduced
byte-identically the same hour — the doubling costs **616 MiB** and lands at
the BOTTOM of the 2–3 GiB floor Frank asked for (`4096/1536` is the middle rung
if that margin is ever wanted; non-KV 9,302 MiB, up from 8,732 by exactly the
compute buffers; f16 K+V is 32 KiB/token across the 8 attention layers;
quote `nvidia-smi`'s `free` field, never `total − used` — the driver holds
~450 MiB of reserve the subtraction hides). The 2026-10-02 3 × 180,224 +
q8_0-V shape stays one env edit away (`CTX_SIZE=540672 PARALLEL=3
CACHE_TYPE_V=q8_0`, and it must pair with Q5_K_M — Q6_K breaks the floor
there). Three native windows need 18.4 GiB of KV alone and were never on the
table.
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`.
Verified at deploy (2026-10-03): boot logs `n_slots = 2, n_ctx_slot = 200704`
and `loaded multimodal model`; decode **94.44 tok/s** at temp 0 on the probe
that measured 104.84 on Q5_K_M — **the Q6_K rung costs a measured −10 %**:
decode on this card is weight-bandwidth-bound and Q6_K streams ~10 % more
bytes/token; health 200, e2e 33/33; native `tool_calls`; thinking in
`reasoning_content`; a **512×512** solid-red PNG answers `Red` through an
`image_url` content part (two traps: the legacy top-level `images` field is
ignored, and a TINY probe image undersupplies image tokens — a 10×10 PNG made
the model claim "it's a video"; probe with ≥256 px). A prompt over the slot
is a **400 `exceed_context_size_error`**, which is why `models.yml` sets
`contextWindow: 200704`.
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).
Runbook: `serverAppConfigs-docs/LLM/frank-small-llama-mimo-q6k-2x200704.md`
(the boot-pair table that prices pool changes lives in the retired
[[frank-small-llama-mimo-q5km]]).
Rollback: `MODEL_FILE=/home/dank/models/MiMo-Q5KM/MiMo-V2.6-Distill-Qwen-9B-Q5_K_M.gguf`
buys back 875 MiB and the −10 % decode rate (3,571 free, 104.84 tok/s, both
measured); the older shape `CTX_SIZE=540672 PARALLEL=3 CACHE_TYPE_V=q8_0`
pairs with Q5 only; SGLang rollback in
[[frank-small-sglang-mimo-v26]] (venv on disk at
`/home/dank/GitPackages/sglang-cu128/`, INT8 payload staged to
`~dank/.AGENTTRASH/20261002-retired-llm-payloads/`).
### frank-primary — tcclaviger vLLM, Qwen3.8-Flash-Next FP8 + MTP4 (Dankserver, ACTIVE)
### 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**,
**MTP4** speculative decoding, **expert prefetch OFF**, 200,000 ctx x 3 seqs,
**fp8 KV**. Deployed 2026-10-01 by rollback from commit `2339fae` after the
GLM/DFlash2 experiment was declared failed (same day).
**fp8 KV**, **no speculative decode**, **expert prefetch OFF**, and since
2026-10-03 the **shape-G** geometry (shape E + MNBT 2048 + the CUDA-graph plan
term at the measured floor): `--max-model-len 200704` × 3 slots with
`--kv-cache-memory-bytes 3,166,500,000`/rank (~400,700 KV tokens — the engine
prices this pool at 7,891.8 B/token here), util **0.982** (the law — see the
unbootable-1.0 lesson below), `--expert-precise-memory`,
`VLLM_CUDAGRAPH_MEM_BASE_MB=0` / `_PER_GRAPH_MB=12`,
`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.
Measured 2026-09-29 tuning campaign: **decode 37.4-40.6 tok/s, acceptance
~54 %** (MTP4 + prefetch off — the fleet's only endpoint that meets the
35 tok/s target). 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.
**`--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 engine auto-sets the attention block size (mamba-page floor); the
expert plan's registry prices KV per token; the expert arena (6,574 slots
x 2.344 MiB = 15.05 GiB/rank) is the decode-critical region — cutting it
for KV headroom collapses the offload hit rate.
**The expert arena has THREE reachable levers: `max_model_len`, the overhead
term, and the CUDA-graph estimate.**
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.
**Free VRAM on this endpoint is NOT arena headroom (2026-10-03 audit).**
`rocm-smi` reads ~3.5 GiB free per card after hours of real traffic, but the
planner charges `budget = total × util` against terms that overstate reality:
the KV reserve is registry-priced at 9190 B/token (`1.91 GiB` at 200704) while
the real pool bills 2.93 GiB at 7,892 B/token, and the precise overhead (5.25
GiB) is far above the idle-measured figure. So the visible free space is the
estimator gap, not allocatable arena. `gpu_memory_utilization` is a real arena
lever (each +0.01 = +0.32 GiB) but **0.982 is the law, and 1.00 is UNBOOTABLE
on this fork — measured 2026-10-03**: the worker init demands
`free > budget`, and at `le=1` budget == device total, so both ranks raise
`ValueError: Free memory on device cuda:N (31.29/31.86 GiB ...)` before a
single weight loads, twice (2-restart crash loop, zero payload progress). The
CLI field accepting `le=1` is the trap; Frank's 0.982 is where it always
ships. What IS reachable from flags: `VLLM_CUDAGRAPH_MEM_BASE_MB` /
`_PER_GRAPH_MB`, read at call time by `estimate_cudagraph_memory()` — the
analytic default (256 MiB + 52 MiB/graph) charged 0.55 GiB where the engine's
own post-capture line reported 0.31 GiB, and a second boot (with the plan
already charging 0.32 GiB) reported **-0.06 GiB**, so the 256 MiB base pool is
pure over-charge: shape G charges `0 base + 12 MiB/graph` = 0.07 GiB, which
still prices per graph for a future 18-graph spec-decode shape (0.21 GiB).
Verify every deploy by comparing the `Estimated CUDA graph memory`
line with `Graph capturing finished … took X GiB`; if capture exceeds the
estimate, raise `_PER_GRAPH_MB` back toward 52. MNBT 1024 → 2048 costs ZERO
arena: the registry overhead is `base + 0.22 GiB × max(0,(nbt−4096)/1024)`
anchored at nbt 4096, so every rung ≤4096 charges the same 5.25 GiB — and the
fitted figure was fitted AT 4096.
**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-glm`. The serve script drives `podman run` directly, gates
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; health returns 503 while
loading.
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`.
@@ -617,9 +725,30 @@ linked runbooks.
the KV pool is worse than the OOM it prevents.
- Host swap state is invisible to `deploy.sh` (no repo file) — check
`swapon --show` before reasoning about headroom.
- **Silent RAM corruption outranks every software theory.** frank-primary's
~40k-token output corruption (2026-10-04/05, repetition/garbage in long
agent conversations) was marginal DDR5 at 4800 MT/s on four DIMMs, fixed
by 4600. Consumer non-ECC memory raises **no MCE** for a silent flip, and
a short in-band pattern check (60 s, 8 GiB of 246 GiB) is not a stability
proof — combined, those two instruments said "ruled out" while the RAM
was the cause. "4800 is the JEDEC floor on 5600-rated DIMMs" is a rating,
not a margin guarantee. When the fault is data-shaped (NaN states,
garbage tokens) and a software revert doesn't move it, price in the RAM
before the runtime — and remember a revert tested under the same marginal
hardware is itself confounded. See the runbook's 2026-10-05 section.
**Runtime-specific guards**
- **Rootless podman cannot RAISE rlimits, and `--ulimit nofile=1048576`
dies at container create, not at runtime.** runc reports
`error setting rlimit type 7: operation not permitted` (type 7 =
RLIMIT_NOFILE) when the requested hard limit exceeds the host process's
hard limit — on Dankclaw that ceiling is 524288. Inherited-equal limits
are fine, so `memlock=-1:-1` works iff the unit grants
`LimitMEMLOCK=infinity` (systemd is privileged; the value flows through
podman to runc without any raise). Upstream halogen's own run lines pass
NO `--ulimit` at all — copy vendor run lines before adding flags
(measured 2026-10-05 on the frank-large cutover).
- **Kairic Edge** (if ever re-deployed): the pinned CK commit needs
`third_party/composable_kernel/include/ck/config.h` generated manually
(untracked; `git clean` removes it), and its greedy-argmax fastpath rejects
@@ -655,19 +784,26 @@ linked runbooks.
the MAIN model's weights (`shares_model`), so there is no second copy of a
12 GiB model in VRAM and no `-md` argument. The plain build has no MTP
tensors at all, and pointing `--model-draft` at the file would load it twice.
- **What can be chained, and what cannot.** `--spec-type a,b` builds 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 is `ctx_dft`: **draftless** types (the ngram family) are added
unconditionally, while `draft-mtp`, `draft-dflash`, `draft-dspark` and
`draft-eagle3` all require a draft CONTEXT. So a chain may hold one
context-backed drafter plus any number of ngram types — `draft-dflash,ngram-mod`
is 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) — but
`draft-mtp,draft-dflash` dies 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.
- **What can be chained, what cannot, and what a chain costs on a hybrid.**
`--spec-type a,b` builds 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 is `ctx_dft`: **draftless**
types (the ngram family) are added unconditionally, while `draft-mtp`,
`draft-dflash`, `draft-dspark` and `draft-eagle3` all require a draft
CONTEXT. So a chain may hold one context-backed drafter plus any number of
ngram types — `draft-dflash,ngram-mod` is 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) — but `draft-mtp,draft-dflash` dies 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 LEGAL chain can still lose on a
recurrent model:** `draft-mtp,ngram-mod` on frank-large's qwen35 hybrid
measured −15 % prose / −7 % block-copy (2026-10-03) — adding the ngram layer
logs `recurrent state rollback is not compatible with 'ngram-mod' -
disabling rollback support`, and MTP verification depends on that rollback,
so acceptance drops on ALL traffic even where the matcher fires. Measure
chains per architecture, never by analogy.
- **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
@@ -684,7 +820,8 @@ linked runbooks.
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-size` has an OPTIMUM on this stack, not a plateau — and its
- **`--ubatch-size` has an OPTIMUM on the 2x R9700 layer split, not a plateau —
and the curve is BACKEND-SPECIFIC, so never carry it across (next bullet). 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,
@@ -702,6 +839,19 @@ linked runbooks.
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.)
- **On the CUDA 3090 the same knob has NO interior optimum up to 2048, so
frank-small ships 4096/2048.** Measured 2026-10-05 by boot-pair A/B on the
live service: at 8,928 prompt tokens ubatch 1024 gives 3,972 / 4,077 t/s and
ubatch 2048 gives 4,207 / 4,212 — **+4.6 %** — for **616 MiB** (free 2,849 →
2,279). Decode did not move (91.9–93.0 vs 93.8–94.3, i.e. noise), and a
279-token prompt, which is below either ubatch, came back identical — that is
the mechanical proof the knob was the only variable, since a prompt shorter
than the ubatch cannot see it. The logical `--batch-size` was again free. And
the predecessor's "ubatch 2048 failed the scratch boot" was a shape
observation, not a law: the same file boots 2048 clean at 2 × 200,704. Bench
it by restarting the SERVICE through a transient `FRANK_SMALL_ENV_FILE`
drop-in, never a scratch port — one card holds one server. See
[[frank-small-llama-mimo-q6k-2x200704]].
- **One `-ts` step 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
@@ -728,7 +878,13 @@ linked runbooks.
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]].
The 2026-10-03 reslice went the other way (3 → 2 slots, 180,224 → 200,704,
pool 401,408) to fund unquantized f16 V, and `contextWindow` followed again;
decode landed 104.8 tok/s — the ~2 % off 106.8 is the exact-V tax. The same
day the freed space also bought the Q6_K rung (now 94.4 tok/s — the rung
itself is a further measured −10 %, weight-bandwidth, not slot geometry).
All moves measured; both slot directions cost or buy window, never pool.
See [[frank-small-llama-mimo-q6k-2x200704]].
- **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 ⇒
@@ -847,9 +1003,9 @@ 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 + MTP4, Dankserver) | `serverAppConfigs-docs/LLM/dankserver-qwen38-fp8-expert-offload-tuning.md` |
| frank-large (halogen container, Qwen3.8-27B .hgn + DFlash2, 1×200,700, text-only, Dankclaw) | `serverAppConfigs-docs/LLM/frank-large-halogen27b-dflash2.md` |
| frank-small (llama.cpp CUDA, MiMo-V2.6-Distill-Qwen-9B Q6_K, 2×200,704 f16 KV, Dankclaw) | `serverAppConfigs-docs/LLM/frank-small-llama-mimo-q6k-2x200704.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` |
@@ -857,9 +1013,12 @@ 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` |
| ROCmFPX llama.cpp fork Qwen3.8-27B Uncensored Q6_0_ROCMFPX_AGENT + MTP (`frank-large`) | Dankclaw | 2026-10-03 → 10-05 (rollback target, payload + fork build on disk) | `frank-large-rocmfpx-q6agent-uncensored-mtp.md` |
| ROCmFPX llama.cpp fork Qwen3.8-27B Q8_0_ROCMFPX_AGENT + MTP (`frank-large`) | Dankclaw | 2026-10-02 → 10-03 (rollback target, payload on disk) | `frank-large-rocmfpx-q8agent-mtp.md` |
| 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 (payloads removed from disk 2026-10-03; rollback = re-download) | `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` |
| llama.cpp master MiMo-V2.6-Distill-Qwen-9B Q6_K (`frank-small`) | Dankclaw | 2026-10-01 → 10-02 (the rung returned ACTIVE 2026-10-03 at the 2×200,704 shape — `frank-small-llama-mimo-q6k-2x200704.md`) | `frank-small-llama-mimo-q6k.md` |
| llama.cpp master MiMo-V2.6-Distill-Qwen-9B Q5_K_M 2×200,704 f16 KV (`frank-small`) | Dankclaw | 2026-10-02 → 10-03 (rollback target — one `MODEL_FILE` line, buys back 875 MiB + the −10 % rung tax; owns the boot-pair table) | `frank-small-llama-mimo-q5km.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` |
+14 -1
View File
@@ -29,7 +29,10 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
HOST="${FRANK_PRIMARY_AR_HOST:-dankserver}"
STAGE="/home/dank/.autoresearch-frank-primary"
ENV_FILE="$SCRIPT_DIR/scripts/frank-primary.env"
# FRANK_PRIMARY_AR_ENV_FILE stages a DIFFERENT env than the repo's — that is how
# the arms driver A/Bs engine knobs without mutating the checkout. The
# fingerprint covers whatever file is staged, so the memo cannot cross arms.
ENV_FILE="${FRANK_PRIMARY_AR_ENV_FILE:-$SCRIPT_DIR/scripts/frank-primary.env}"
SERVE="$SCRIPT_DIR/scripts/serve-frank-primary.sh"
UNIT="$SCRIPT_DIR/scripts/frank-primary.service"
MEASURE="$SCRIPT_DIR/scripts/autoresearch/frank-primary-measure.py"
@@ -58,6 +61,13 @@ print("METRIC ttft_ms_4096=%s" % m["ttft_ms_4096"])
print("METRIC kv_tokens=%s" % (run.get("kv_tokens") or 0))
print("METRIC boot_seconds=%s" % (run.get("boot_seconds") or -1))
print("METRIC completion_tokens_4096=%s" % m.get("completion_tokens_4096", 0))
# Any extra sampling temperature the measure swept (AR_TEMPS), e.g. the 0.7
# arm — that is where speculative-decode acceptance shows up, since a greedy
# draft matches a greedy target exactly and is rejected more under real
# sampling. Suffixed keys only (_t0.7), so the historical lines are untouched.
for k in sorted(m):
if "_t" in k and (k.startswith("decode_tps") or k.startswith("ttft_ms")):
print("METRIC %s=%s" % (k, m[k]))
' "$1"
}
@@ -101,4 +111,7 @@ run_json="$(ssh_run "cat '$STAGE/run.json'")" || {
echo "[autoresearch] window result:" >&2
printf '%s\n' "$run_json" >&2
emit_metrics "$run_json"
echo "[autoresearch] engine evidence (plan / compile path / spec counters):" >&2
ssh_run "cat '$STAGE/evidence.log' 2>/dev/null" >&2 || true
exit 0
+10
View File
@@ -0,0 +1,10 @@
Below is a telemetry API schema, labeled SCHEMA A. Later in this message the same schema is repeated word for word, labeled SCHEMA B.
Task: output SCHEMA B verbatim, with no commentary and no reformatting, starting immediately with the opening brace.
SCHEMA A:
{"version":"2.3.1","endpoints":[{"id":"ep-000","path":"/v2/telemetry/gw5978x49","method":"GET","latency_ms":219,"owner":"team-0","limit":{"burst":221,"sustained":149}},{"id":"ep-001","path":"/v2/telemetry/gw6279x77","method":"POST","latency_ms":456,"owner":"team-1","limit":{"burst":227,"sustained":173}},{"id":"ep-002","path":"/v2/telemetry/gw4501x82","method":"PUT","latency_ms":488,"owner":"team-2","limit":{"burst":407,"sustained":189}},{"id":"ep-003","path":"/v2/telemetry/gw3780xA8","method":"DELETE","latency_ms":90,"owner":"team-3","limit":{"burst":146,"sustained":132}},{"id":"ep-004","path":"/v2/telemetry/gw6491x17","method":"GET","latency_ms":842,"owner":"team-4","limit":{"burst":488,"sustained":193}},{"id":"ep-005","path":"/v2/telemetry/gw4848xAC","method":"POST","latency_ms":321,"owner":"team-5","limit":{"burst":116,"sustained":51}},{"id":"ep-006","path":"/v2/telemetry/gw3414x06","method":"PUT","latency_ms":51,"owner":"team-6","limit":{"burst":126,"sustained":122}},{"id":"ep-007","path":"/v2/telemetry/gw2193x74","method":"DELETE","latency_ms":428,"owner":"team-0","limit":{"burst":454,"sustained":162}},{"id":"ep-008","path":"/v2/telemetry/gw4185xB7","method":"GET","latency_ms":717,"owner":"team-1","limit":{"burst":197,"sustained":127}},{"id":"ep-009","path":"/v2/telemetry/gw7547x3E","method":"POST","latency_ms":155,"owner":"team-2","limit":{"burst":336,"sustained":177}},{"id":"ep-010","path":"/v2/telemetry/gw1090xE4","method":"PUT","latency_ms":772,"owner":"team-3","limit":{"burst":441,"sustained":198}},{"id":"ep-011","path":"/v2/telemetry/gw2746xC7","method":"DELETE","latency_ms":439,"owner":"team-4","limit":{"burst":113,"sustained":46}},{"id":"ep-012","path":"/v2/telemetry/gw9486x76","method":"GET","latency_ms":55,"owner":"team-5","limit":{"burst":286,"sustained":64}},{"id":"ep-013","path":"/v2/telemetry/gw2988x74","method":"POST","latency_ms":140,"owner":"team-6","limit":{"burst":411,"sustained":119}},{"id":"ep-014","path":"/v2/telemetry/gw9702xFC","method":"PUT","latency_ms":576,"owner":"team-0","limit":{"burst":305,"sustained":82}},{"id":"ep-015","path":"/v2/telemetry/gw8251x9C","method":"DELETE","latency_ms":838,"owner":"team-1","limit":{"burst":369,"sustained":130}},{"id":"ep-016","path":"/v2/telemetry/gw7991xD4","method":"GET","latency_ms":565,"owner":"team-2","limit":{"burst":229,"sustained":41}},{"id":"ep-017","path":"/v2/telemetry/gw8777x73","method":"POST","latency_ms":269,"owner":"team-3","limit":{"burst":385,"sustained":64}},{"id":"ep-018","path":"/v2/telemetry/gw5543xC4","method":"PUT","latency_ms":800,"owner":"team-4","limit":{"burst":267,"sustained":125}},{"id":"ep-019","path":"/v2/telemetry/gw4919x46","method":"DELETE","latency_ms":454,"owner":"team-5","limit":{"burst":40,"sustained":183}},{"id":"ep-020","path":"/v2/telemetry/gw5681x3C","method":"GET","latency_ms":282,"owner":"team-6","limit":{"burst":172,"sustained":82}},{"id":"ep-021","path":"/v2/telemetry/gw9849x14","method":"POST","latency_ms":145,"owner":"team-0","limit":{"burst":78,"sustained":60}},{"id":"ep-022","path":"/v2/telemetry/gw7275xB1","method":"PUT","latency_ms":160,"owner":"team-1","limit":{"burst":362,"sustained":55}},{"id":"ep-023","path":"/v2/telemetry/gw2052x6A","method":"DELETE","latency_ms":421,"owner":"team-2","limit":{"burst":170,"sustained":139}},{"id":"ep-024","path":"/v2/telemetry/gw8633x6A","method":"GET","latency_ms":67,"owner":"team-3","limit":{"burst":106,"sustained":108}},{"id":"ep-025","path":"/v2/telemetry/gw7381xE7","method":"POST","latency_ms":792,"owner":"team-4","limit":{"burst":300,"sustained":179}},{"id":"ep-026","path":"/v2/telemetry/gw1320xDB","method":"PUT","latency_ms":787,"owner":"team-5","limit":{"burst":295,"sustained":98}},{"id":"ep-027","path":"/v2/telemetry/gw8814x01","method":"DELETE","latency_ms":364,"owner":"team-6","limit":{"burst":153,"sustained":193}},{"id":"ep-028","path":"/v2/telemetry/gw7389xDA","method":"GET","latency_ms":859,"owner":"team-0","limit":{"burst":215,"sustained":138}},{"id":"ep-029","path":"/v2/telemetry/gw9947xCC","method":"POST","latency_ms":621,"owner":"team-1","limit":{"burst":460,"sustained":57}},{"id":"ep-030","path":"/v2/telemetry/gw8999x38","method":"PUT","latency_ms":283,"owner":"team-2","limit":{"burst":224,"sustained":125}},{"id":"ep-031","path":"/v2/telemetry/gw1475x63","method":"DELETE","latency_ms":348,"owner":"team-3","limit":{"burst":343,"sustained":174}},{"id":"ep-032","path":"/v2/telemetry/gw7624xB9","method":"GET","latency_ms":173,"owner":"team-4","limit":{"burst":431,"sustained":120}},{"id":"ep-033","path":"/v2/telemetry/gw3091xFB","method":"POST","latency_ms":641,"owner":"team-5","limit":{"burst":274,"sustained":7}},{"id":"ep-034","path":"/v2/telemetry/gw7455x97","method":"PUT","latency_ms":581,"owner":"team-6","limit":{"burst":340,"sustained":7}},{"id":"ep-035","path":"/v2/telemetry/gw2375xA4","method":"DELETE","latency_ms":442,"owner":"team-0","limit":{"burst":70,"sustained":119}},{"id":"ep-036","path":"/v2/telemetry/gw3977x0C","method":Line truncated
SCHEMA B (restated, identical to A):
{"version":"2.3.1","endpoints":[{"id":"ep-000","path":"/v2/telemetry/gw5978x49","method":"GET","latency_ms":219,"owner":"team-0","limit":{"burst":221,"sustained":149}},{"id":"ep-001","path":"/v2/telemetry/gw6279x77","method":"POST","latency_ms":456,"owner":"team-1","limit":{"burst":227,"sustained":173}},{"id":"ep-002","path":"/v2/telemetry/gw4501x82","method":"PUT","latency_ms":488,"owner":"team-2","limit":{"burst":407,"sustained":189}},{"id":"ep-003","path":"/v2/telemetry/gw3780xA8","method":"DELETE","latency_ms":90,"owner":"team-3","limit":{"burst":146,"sustained":132}},{"id":"ep-004","path":"/v2/telemetry/gw6491x17","method":"GET","latency_ms":842,"owner":"team-4","limit":{"burst":488,"sustained":193}},{"id":"ep-005","path":"/v2/telemetry/gw4848xAC","method":"POST","latency_ms":321,"owner":"team-5","limit":{"burst":116,"sustained":51}},{"id":"ep-006","path":"/v2/telemetry/gw3414x06","method":"PUT","latency_ms":51,"owner":"team-6","limit":{"burst":126,"sustained":122}},{"id":"ep-007","path":"/v2/telemetry/gw2193x74","method":"DELETE","latency_ms":428,"owner":"team-0","limit":{"burst":454,"sustained":162}},{"id":"ep-008","path":"/v2/telemetry/gw4185xB7","method":"GET","latency_ms":717,"owner":"team-1","limit":{"burst":197,"sustained":127}},{"id":"ep-009","path":"/v2/telemetry/gw7547x3E","method":"POST","latency_ms":155,"owner":"team-2","limit":{"burst":336,"sustained":177}},{"id":"ep-010","path":"/v2/telemetry/gw1090xE4","method":"PUT","latency_ms":772,"owner":"team-3","limit":{"burst":441,"sustained":198}},{"id":"ep-011","path":"/v2/telemetry/gw2746xC7","method":"DELETE","latency_ms":439,"owner":"team-4","limit":{"burst":113,"sustained":46}},{"id":"ep-012","path":"/v2/telemetry/gw9486x76","method":"GET","latency_ms":55,"owner":"team-5","limit":{"burst":286,"sustained":64}},{"id":"ep-013","path":"/v2/telemetry/gw2988x74","method":"POST","latency_ms":140,"owner":"team-6","limit":{"burst":411,"sustained":119}},{"id":"ep-014","path":"/v2/telemetry/gw9702xFC","method":"PUT","latency_ms":576,"owner":"team-0","limit":{"burst":305,"sustained":82}},{"id":"ep-015","path":"/v2/telemetry/gw8251x9C","method":"DELETE","latency_ms":838,"owner":"team-1","limit":{"burst":369,"sustained":130}},{"id":"ep-016","path":"/v2/telemetry/gw7991xD4","method":"GET","latency_ms":565,"owner":"team-2","limit":{"burst":229,"sustained":41}},{"id":"ep-017","path":"/v2/telemetry/gw8777x73","method":"POST","latency_ms":269,"owner":"team-3","limit":{"burst":385,"sustained":64}},{"id":"ep-018","path":"/v2/telemetry/gw5543xC4","method":"PUT","latency_ms":800,"owner":"team-4","limit":{"burst":267,"sustained":125}},{"id":"ep-019","path":"/v2/telemetry/gw4919x46","method":"DELETE","latency_ms":454,"owner":"team-5","limit":{"burst":40,"sustained":183}},{"id":"ep-020","path":"/v2/telemetry/gw5681x3C","method":"GET","latency_ms":282,"owner":"team-6","limit":{"burst":172,"sustained":82}},{"id":"ep-021","path":"/v2/telemetry/gw9849x14","method":"POST","latency_ms":145,"owner":"team-0","limit":{"burst":78,"sustained":60}},{"id":"ep-022","path":"/v2/telemetry/gw7275xB1","method":"PUT","latency_ms":160,"owner":"team-1","limit":{"burst":362,"sustained":55}},{"id":"ep-023","path":"/v2/telemetry/gw2052x6A","method":"DELETE","latency_ms":421,"owner":"team-2","limit":{"burst":170,"sustained":139}},{"id":"ep-024","path":"/v2/telemetry/gw8633x6A","method":"GET","latency_ms":67,"owner":"team-3","limit":{"burst":106,"sustained":108}},{"id":"ep-025","path":"/v2/telemetry/gw7381xE7","method":"POST","latency_ms":792,"owner":"team-4","limit":{"burst":300,"sustained":179}},{"id":"ep-026","path":"/v2/telemetry/gw1320xDB","method":"PUT","latency_ms":787,"owner":"team-5","limit":{"burst":295,"sustained":98}},{"id":"ep-027","path":"/v2/telemetry/gw8814x01","method":"DELETE","latency_ms":364,"owner":"team-6","limit":{"burst":153,"sustained":193}},{"id":"ep-028","path":"/v2/telemetry/gw7389xDA","method":"GET","latency_ms":859,"owner":"team-0","limit":{"burst":215,"sustained":138}},{"id":"ep-029","path":"/v2/telemetry/gw9947xCC","method":"POST","latency_ms":621,"owner":"team-1","limit":{"burst":460,"sustained":57}},{"id":"ep-030","path":"/v2/telemetry/gw8999x38","method":"PUT","latency_ms":283,"owner":"team-2","limit":{"burst":224,"sustained":125}},{"id":"ep-031","path":"/v2/telemetry/gw1475x63","method":"DELETE","latency_ms":348,"owner":"team-3","limit":{"burst":343,"sustained":174}},{"id":"ep-032","path":"/v2/telemetry/gw7624xB9","method":"GET","latency_ms":173,"owner":"team-4","limit":{"burst":431,"sustained":120}},{"id":"ep-033","path":"/v2/telemetry/gw3091xFB","method":"POST","latency_ms":641,"owner":"team-5","limit":{"burst":274,"sustained":7}},{"id":"ep-034","path":"/v2/telemetry/gw7455x97","method":"PUT","latency_ms":581,"owner":"team-6","limit":{"burst":340,"sustained":7}},{"id":"ep-035","path":"/v2/telemetry/gw2375xA4","method":"DELETE","latency_ms":442,"owner":"team-0","limit":{"burst":70,"sustained":119}},{"id":"ep-036","path":"/v2/telemetry/gw3977x0C","method":Line truncated
Output SCHEMA B verbatim now:
+1
View File
@@ -0,0 +1 @@
{"model": "frank-large", "prompt": "Below is a telemetry API schema, labeled SCHEMA A. Later in this message the same schema is repeated word for word, labeled SCHEMA B.\nTask: output SCHEMA B verbatim, with no commentary and no reformatting, starting immediately with the opening brace.\n\nSCHEMA A:\n{\"version\":\"2.3.1\",\"endpoints\":[{\"id\":\"ep-000\",\"path\":\"/v2/telemetry/gw5978x49\",\"method\":\"GET\",\"latency_ms\":219,\"owner\":\"team-0\",\"limit\":{\"burst\":221,\"sustained\":149}},{\"id\":\"ep-001\",\"path\":\"/v2/telemetry/gw6279x77\",\"method\":\"POST\",\"latency_ms\":456,\"owner\":\"team-1\",\"limit\":{\"burst\":227,\"sustained\":173}},{\"id\":\"ep-002\",\"path\":\"/v2/telemetry/gw4501x82\",\"method\":\"PUT\",\"latency_ms\":488,\"owner\":\"team-2\",\"limit\":{\"burst\":407,\"sustained\":189}},{\"id\":\"ep-003\",\"path\":\"/v2/telemetry/gw3780xA8\",\"method\":\"DELETE\",\"latency_ms\":90,\"owner\":\"team-3\",\"limit\":{\"burst\":146,\"sustained\":132}},{\"id\":\"ep-004\",\"path\":\"/v2/telemetry/gw6491x17\",\"method\":\"GET\",\"latency_ms\":842,\"owner\":\"team-4\",\"limit\":{\"burst\":488,\"sustained\":193}},{\"id\":\"ep-005\",\"path\":\"/v2/telemetry/gw4848xAC\",\"method\":\"POST\",\"latency_ms\":321,\"owner\":\"team-5\",\"limit\":{\"burst\":116,\"sustained\":51}},{\"id\":\"ep-006\",\"path\":\"/v2/telemetry/gw3414x06\",\"method\":\"PUT\",\"latency_ms\":51,\"owner\":\"team-6\",\"limit\":{\"burst\":126,\"sustained\":122}},{\"id\":\"ep-007\",\"path\":\"/v2/telemetry/gw2193x74\",\"method\":\"DELETE\",\"latency_ms\":428,\"owner\":\"team-0\",\"limit\":{\"burst\":454,\"sustained\":162}},{\"id\":\"ep-008\",\"path\":\"/v2/telemetry/gw4185xB7\",\"method\":\"GET\",\"latency_ms\":717,\"owner\":\"team-1\",\"limit\":{\"burst\":197,\"sustained\":127}},{\"id\":\"ep-009\",\"path\":\"/v2/telemetry/gw7547x3E\",\"method\":\"POST\",\"latency_ms\":155,\"owner\":\"team-2\",\"limit\":{\"burst\":336,\"sustained\":177}},{\"id\":\"ep-010\",\"path\":\"/v2/telemetry/gw1090xE4\",\"method\":\"PUT\",\"latency_ms\":772,\"owner\":\"team-3\",\"limit\":{\"burst\":441,\"sustained\":198}},{\"id\":\"ep-011\",\"path\":\"/v2/telemetry/gw2746xC7\",\"method\":\"DELETE\",\"latency_ms\":439,\"owner\":\"team-4\",\"limit\":{\"burst\":113,\"sustained\":46}},{\"id\":\"ep-012\",\"path\":\"/v2/telemetry/gw9486x76\",\"method\":\"GET\",\"latency_ms\":55,\"owner\":\"team-5\",\"limit\":{\"burst\":286,\"sustained\":64}},{\"id\":\"ep-013\",\"path\":\"/v2/telemetry/gw2988x74\",\"method\":\"POST\",\"latency_ms\":140,\"owner\":\"team-6\",\"limit\":{\"burst\":411,\"sustained\":119}},{\"id\":\"ep-014\",\"path\":\"/v2/telemetry/gw9702xFC\",\"method\":\"PUT\",\"latency_ms\":576,\"owner\":\"team-0\",\"limit\":{\"burst\":305,\"sustained\":82}},{\"id\":\"ep-015\",\"path\":\"/v2/telemetry/gw8251x9C\",\"method\":\"DELETE\",\"latency_ms\":838,\"owner\":\"team-1\",\"limit\":{\"burst\":369,\"sustained\":130}},{\"id\":\"ep-016\",\"path\":\"/v2/telemetry/gw7991xD4\",\"method\":\"GET\",\"latency_ms\":565,\"owner\":\"team-2\",\"limit\":{\"burst\":229,\"sustained\":41}},{\"id\":\"ep-017\",\"path\":\"/v2/telemetry/gw8777x73\",\"method\":\"POST\",\"latency_ms\":269,\"owner\":\"team-3\",\"limit\":{\"burst\":385,\"sustained\":64}},{\"id\":\"ep-018\",\"path\":\"/v2/telemetry/gw5543xC4\",\"method\":\"PUT\",\"latency_ms\":800,\"owner\":\"team-4\",\"limit\":{\"burst\":267,\"sustained\":125}},{\"id\":\"ep-019\",\"path\":\"/v2/telemetry/gw4919x46\",\"method\":\"DELETE\",\"latency_ms\":454,\"owner\":\"team-5\",\"limit\":{\"burst\":40,\"sustained\":183}},{\"id\":\"ep-020\",\"path\":\"/v2/telemetry/gw5681x3C\",\"method\":\"GET\",\"latency_ms\":282,\"owner\":\"team-6\",\"limit\":{\"burst\":172,\"sustained\":82}},{\"id\":\"ep-021\",\"path\":\"/v2/telemetry/gw9849x14\",\"method\":\"POST\",\"latency_ms\":145,\"owner\":\"team-0\",\"limit\":{\"burst\":78,\"sustained\":60}},{\"id\":\"ep-022\",\"path\":\"/v2/telemetry/gw7275xB1\",\"method\":\"PUT\",\"latency_ms\":160,\"owner\":\"team-1\",\"limit\":{\"burst\":362,\"sustained\":55}},{\"id\":\"ep-023\",\"path\":\"/v2/telemetry/gw2052x6A\",\"method\":\"DELETE\",\"latency_ms\":421,\"owner\":\"team-2\",\"limit\":{\"burst\":170,\"sustained\":139}},{\"id\":\"ep-024\",\"path\":\"/v2/telemetry/gw8633x6A\",\"method\":\"GET\",\"latency_ms\":67,\"owner\":\"team-3\",\"limit\":{\"burst\":106,\"sustained\":108}},{\"id\":\"ep-025\",\"path\":\"/v2/telemetry/gw7381xE7\",\"method\":\"POST\",\"latency_ms\":792,\"owner\":\"team-4\",\"limit\":{\"burst\":300,\"sustained\":179}},{\"id\":\"ep-026\",\"path\":\"/v2/telemetry/gw1320xDB\",\"method\":\"PUT\",\"latency_ms\":787,\"owner\":\"team-5\",\"limit\":{\"burst\":295,\"sustained\":98}},{\"id\":\"ep-027\",\"path\":\"/v2/telemetry/gw8814x01\",\"method\":\"DELETE\",\"latency_ms\":364,\"owner\":\"team-6\",\"limit\":{\"burst\":153,\"sustained\":193}},{\"id\":\"ep-028\",\"path\":\"/v2/telemetry/gw7389xDA\",\"method\":\"GET\",\"latency_ms\":859,\"owner\":\"team-0\",\"limit\":{\"burst\":215,\"sustained\":138}},{\"id\"Line truncated
+86
View File
@@ -0,0 +1,86 @@
# Subsurface Aquifer Monitoring Network - System Design Document (Revision 7)
This document describes the deployment of a subsurface aquifer monitoring network for the Cascadia sedimentary basin. It is written for the operations crew and the regional water authority. Every sensor node, relay, aggregation service, calibration procedure, and failure mode below carries a stable alphanumeric identifier that appears in every table and in the field notes.
## Sensor nodes
Node SN-000-08B9 sits in a discharge lens at 359.0 m below datum and reports a salinity of 2.38 ppt with the fractured interval reading 5.40 degC at dawn and the wellhead porous 1.72 m3/d while the electrode pair returns 3157 uS/cm.
Node SN-001-0A65 sits in a percolate lens at 25.9 m below datum and steps down to a salinity of 2.13 ppt with the diapir interval reading 8.55 degC at dawn and the wellhead silty 0.67 m3/d while the electrode pair returns 979 uS/cm.
Node SN-002-0571 sits in a diapir lens at 287.8 m below datum and peaks at a salinity of 7.63 ppt with the karst interval reading 4.26 degC at dawn and the wellhead spooned 11.50 m3/d while the electrode pair returns 2987 uS/cm.
Node SN-003-0CE9 sits in a confining lens at 169.5 m below datum and logs a salinity of 0.96 ppt with the alluvial interval reading 5.03 degC at dawn and the wellhead salinized 12.08 m3/d while the electrode pair returns 2607 uS/cm.
Node SN-004-0E2A sits in a freshened lens at 70.4 m below datum and settles near a salinity of 8.68 ppt with the spooned interval reading 4.88 degC at dawn and the wellhead siphon 4.38 m3/d while the electrode pair returns 2707 uS/cm.
Node SN-005-0DDD sits in a percolate lens at 44.6 m below datum and reports a salinity of 6.25 ppt with the alluvial interval reading 10.85 degC at dawn and the wellhead brackish 13.99 m3/d while the electrode pair returns 3618 uS/cm.
Node SN-006-0EFC sits in a till lens at 309.5 m below datum and reports a salinity of 3.49 ppt with the lacustrine interval reading 7.38 degC at dawn and the wellhead spooned 3.21 m3/d while the electrode pair returns 1253 uS/cm.
Node SN-007-0B02 sits in a recharge lens at 262.0 m below datum and steps down to a salinity of 2.38 ppt with the discharge interval reading 8.18 degC at dawn and the wellhead perched 4.05 m3/d while the electrode pair returns 3900 uS/cm.
Node SN-008-0448 sits in a karst lens at 406.5 m below datum and reports a salinity of 7.32 ppt with the upconing interval reading 6.17 degC at dawn and the wellhead fluvial 0.75 m3/d while the electrode pair returns 1448 uS/cm.
Node SN-009-043C sits in a alluvial lens at 159.3 m below datum and flags a salinity of 6.20 ppt with the percolate interval reading 7.60 degC at dawn and the wellhead brackish 13.01 m3/d while the electrode pair returns 2025 uS/cm.
Node SN-010-0FF5 sits in a karst lens at 264.2 m below datum and flags a salinity of 7.05 ppt with the freshened interval reading 7.88 degC at dawn and the wellhead siphon 8.41 m3/d while the electrode pair returns 1635 uS/cm.
Node SN-011-098A sits in a drain lens at 365.7 m below datum and reports a salinity of 8.11 ppt with the wedge interval reading 5.51 degC at dawn and the wellhead glacial 2.52 m3/d while the electrode pair returns 2916 uS/cm.
Node SN-012-0AE8 sits in a till lens at 259.6 m below datum and steps down to a salinity of 9.13 ppt with the recharge interval reading 11.60 degC at dawn and the wellhead spooned 0.46 m3/d while the electrode pair returns 3077 uS/cm.
Node SN-013-0B83 sits in a salinized lens at 171.2 m below datum and peaks at a salinity of 2.83 ppt with the lacustrine interval reading 5.56 degC at dawn and the wellhead discharge 0.35 m3/d while the electrode pair returns 3083 uS/cm.
Node SN-014-032E sits in a drain lens at 249.6 m below datum and flags a salinity of 1.09 ppt with the salinized interval reading 9.58 degC at dawn and the wellhead perched 12.00 m3/d while the electrode pair returns 2221 uS/cm.
Node SN-015-004F sits in a aeolian lens at 264.4 m below datum and reports a salinity of 7.34 ppt with the salinized interval reading 8.76 degC at dawn and the wellhead perched 0.31 m3/d while the electrode pair returns 1483 uS/cm.
Node SN-016-07C6 sits in a aeolian lens at 155.9 m below datum and reports a salinity of 0.64 ppt with the mineralized interval reading 11.75 degC at dawn and the wellhead diluted 13.46 m3/d while the electrode pair returns 524 uS/cm.
Node SN-017-06C5 sits in a salinized lens at 72.1 m below datum and averages a salinity of 4.52 ppt with the siphon interval reading 8.93 degC at dawn and the wellhead confining 3.98 m3/d while the electrode pair returns 3687 uS/cm.
Node SN-018-0E64 sits in a siphon lens at 334.9 m below datum and steps down to a salinity of 6.73 ppt with the salinized interval reading 7.63 degC at dawn and the wellhead freshened 9.64 m3/d while the electrode pair returns 1681 uS/cm.
Node SN-019-0122 sits in a spooned lens at 117.2 m below datum and logs a salinity of 3.24 ppt with the fractured interval reading 9.26 degC at dawn and the wellhead karst 3.50 m3/d while the electrode pair returns 1065 uS/cm.
Node SN-020-021D sits in a karst lens at 414.3 m below datum and recovers to a salinity of 0.76 ppt with the glacial interval reading 6.25 degC at dawn and the wellhead silty 9.60 m3/d while the electrode pair returns 1041 uS/cm.
Node SN-021-06C6 sits in a percolate lens at 379.2 m below datum and drifts to a salinity of 7.61 ppt with the wedge interval reading 5.84 degC at dawn and the wellhead karst 1.65 m3/d while the electrode pair returns 1913 uS/cm.
Node SN-022-0DC9 sits in a spooned lens at 327.1 m below datum and reports a salinity of 9.25 ppt with the freshened interval reading 5.05 degC at dawn and the wellhead silty 5.90 m3/d while the electrode pair returns 1544 uS/cm.
Node SN-023-0717 sits in a karst lens at 222.0 m below datum and steps down to a salinity of 4.02 ppt with the alluvial interval reading 6.60 degC at dawn and the wellhead siphon 3.77 m3/d while the electrode pair returns 3892 uS/cm.
Node SN-024-02A9 sits in a fractured lens at 479.7 m below datum and drifts to a salinity of 7.88 ppt with the silty interval reading 12.53 degC at dawn and the wellhead discharge 13.18 m3/d while the electrode pair returns 3592 uS/cm.
Node SN-025-0C8F sits in a wedge lens at 434.3 m below datum and holds a salinity of 1.63 ppt with the alluvial interval reading 4.22 degC at dawn and the wellhead till 5.73 m3/d while the electrode pair returns 3905 uS/cm.
Node SN-026-04BF sits in a colluvial lens at 460.0 m below datum and averages a salinity of 9.36 ppt with the diapir interval reading 8.98 degC at dawn and the wellhead brackish 10.29 m3/d while the electrode pair returns 802 uS/cm.
Node SN-027-0DA4 sits in a silty lens at 265.7 m below datum and steps down to a salinity of 7.06 ppt with the percolate interval reading 4.69 degC at dawn and the wellhead freshened 8.42 m3/d while the electrode pair returns 2201 uS/cm.
Node SN-028-01EB sits in a perched lens at 49.5 m below datum and recovers to a salinity of 1.83 ppt with the silty interval reading 9.32 degC at dawn and the wellhead drain 9.69 m3/d while the electrode pair returns 1126 uS/cm.
Node SN-029-042C sits in a karst lens at 30.6 m below datum and averages a salinity of 0.86 ppt with the percolate interval reading 9.85 degC at dawn and the wellhead recharge 8.16 m3/d while the electrode pair returns 1452 uS/cm.
## Relays
Relay RL-00-02181 reburses the upconing trunk from the brackish margin and peaks at a round-trip time of 131.8 ms with a frame loss of 1.04 percent across the night window.
Relay RL-01-00262 reburses the spooned trunk from the fluvial margin and holds a round-trip time of 372.2 ms with a frame loss of 3.65 percent across the night window.
Relay RL-02-03691 reburses the recharge trunk from the percolate margin and steps down to a round-trip time of 398.5 ms with a frame loss of 0.29 percent across the night window.
Relay RL-03-0E117 reburses the lacustrine trunk from the confining margin and averages a round-trip time of 373.9 ms with a frame loss of 3.44 percent across the night window.
Relay RL-04-0B41B reburses the aeolian trunk from the colluvial margin and peaks at a round-trip time of 70.7 ms with a frame loss of 3.25 percent across the night window.
Relay RL-05-0D12D reburses the recharge trunk from the diluted margin and steps down to a round-trip time of 264.7 ms with a frame loss of 0.03 percent across the night window.
Relay RL-06-043B4 reburses the colluvial trunk from the upconing margin and reports a round-trip time of 49.5 ms with a frame loss of 3.42 percent across the night window.
Relay RL-07-09AD6 reburses the fractured trunk from the freshened margin and averages a round-trip time of 225.3 ms with a frame loss of 1.06 percent across the night window.
Relay RL-08-04394 reburses the brackish trunk from the fluvial margin and steps down to a round-trip time of 88.6 ms with a frame loss of 2.47 percent across the night window.
Relay RL-09-017A0 reburses the wedge trunk from the lacustrine margin and drifts to a round-trip time of 363.0 ms with a frame loss of 3.30 percent across the night window.
Relay RL-10-0217D reburses the mineralized trunk from the lacustrine margin and peaks at a round-trip time of 26.2 ms with a frame loss of 1.30 percent across the night window.
Relay RL-11-08F97 reburses the perched trunk from the freshened margin and logs a round-trip time of 181.8 ms with a frame loss of 2.75 percent across the night window.
Relay RL-12-02628 reburses the fractured trunk from the glacial margin and steps down to a round-trip time of 378.5 ms with a frame loss of 2.69 percent across the night window.
Relay RL-13-06E06 reburses the silty trunk from the mineralized margin and flags a round-trip time of 153.4 ms with a frame loss of 2.15 percent across the night window.
## Aggregation services
Aggregator AG-00-3BB logs a queue depth of 5.2 messages and a histogram bucket of 6.82 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-01-0FF reports a queue depth of 86.4 messages and a histogram bucket of 4.10 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-02-37F drifts to a queue depth of 34.7 messages and a histogram bucket of 10.25 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-03-3B3 averages a queue depth of 93.5 messages and a histogram bucket of 13.59 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-04-33E flags a queue depth of 83.2 messages and a histogram bucket of 17.58 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-05-2F2 settles near a queue depth of 84.8 messages and a histogram bucket of 0.84 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-06-2AD averages a queue depth of 75.4 messages and a histogram bucket of 7.65 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-07-06E drifts to a queue depth of 26.5 messages and a histogram bucket of 14.26 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-08-0E3 averages a queue depth of 83.9 messages and a histogram bucket of 15.51 seconds before the dedupe pass settles the stream for the next rotation.
Aggregator AG-09-138 averages a queue depth of 78.6 messages and a histogram bucket of 8.50 seconds before the dedupe pass settles the stream for the next rotation.
## Calibration procedures
Procedure CP-00-047 steps the probe through a two-point rinse, then applies a trim of 0.2084 at the 66 milli-bar mark and records a residual of 0.02052 before the next field seal is crimped.
Procedure CP-01-0A4 steps the probe through a two-point rinse, then applies a trim of 0.7793 at the 96 milli-bar mark and records a residual of 0.01468 before the next field seal is crimped.
Procedure CP-02-054 steps the probe through a two-point rinse, then applies a trim of 0.4634 at the 68 milli-bar mark and records a residual of 0.04234 before the next field seal is crimped.
Procedure CP-03-02D steps the probe through a two-point rinse, then applies a trim of 0.8459 at the 12 milli-bar mark and records a residual of 0.04855 before the next field seal is crimped.
Procedure CP-04-098 steps the probe through a two-point rinse, then applies a trim of 0.5267 at the 97 milli-bar mark and records a residual of 0.00287 before the next field seal is crimped.
Procedure CP-05-09B steps the probe through a two-point rinse, then applies a trim of 0.3968 at the 72 milli-bar mark and records a residual of 0.01637 before the next field seal is crimped.
Procedure CP-06-030 steps the probe through a two-point rinse, then applies a trim of 0.8860 at the 12 milli-bar mark and records a residual of 0.04508 before the next field seal is crimped.
Procedure CP-07-0EC steps the probe through a two-point rinse, then applies a trim of 0.2367 at the 70 milli-bar mark and records a residual of 0.00108 before the next field seal is crimped.
Procedure CP-08-0BC steps the probe through a two-point rinse, then applies a trim of 0.7278 at the 68 milli-bar mark and records a residual of 0.04707 before the next field seal is crimped.
Procedure CP-09-0AA steps the probe through a two-point rinse, then applies a trim of 0.6069 at the 37 milli-bar mark and records a residual of 0.00443 before the next field seal is crimped.
## Failure modes
Mode FM-00-27 declares a fluvial excursion when two independent probes disagree beyond the band for three rotations; the fleet reports a mean time between failures of 36902 hours for this class of excursion.
Mode FM-01-13 declares a drain excursion when two independent probes disagree beyond the band for three rotations; the fleet peaks at a mean time between failures of 29042 hours for this class of excursion.
Mode FM-02-14 declares a till excursion when two independent probes disagree beyond the band for three rotations; the fleet peaks at a mean time between failures of 26976 hours for this class of excursion.
Mode FM-03-0C declares a diapir excursion when two independent probes disagree beyond the band for three rotations; the fleet drifts to a mean time between failures of 22578 hours for this class of excursion.
Mode FM-04-39 declares a perched excursion when two independent probes disagree beyond the band for three rotations; the fleet recovers to a mean time between failures of 27383 hours for this class of excursion.
Mode FM-05-19 declares a siphon excursion when two independent probes disagree beyond the band for three rotations; the fleet logs a mean time between failures of 23152 hours for this class of excursion.
## End of document
Summarize the network's dominant risk in one paragraph, then list the three node identifiers whose readings most limit the model this month.
+1
View File
@@ -0,0 +1 @@
{"model": "frank-large", "prompt": "# Subsurface Aquifer Monitoring Network - System Design Document (Revision 7)\n\nThis document describes the deployment of a subsurface aquifer monitoring network for the Cascadia sedimentary basin. It is written for the operations crew and the regional water authority. Every sensor node, relay, aggregation service, calibration procedure, and failure mode below carries a stable alphanumeric identifier that appears in every table and in the field notes.\n\n## Sensor nodes\nNode SN-000-08B9 sits in a discharge lens at 359.0 m below datum and reports a salinity of 2.38 ppt with the fractured interval reading 5.40 degC at dawn and the wellhead porous 1.72 m3/d while the electrode pair returns 3157 uS/cm.\nNode SN-001-0A65 sits in a percolate lens at 25.9 m below datum and steps down to a salinity of 2.13 ppt with the diapir interval reading 8.55 degC at dawn and the wellhead silty 0.67 m3/d while the electrode pair returns 979 uS/cm.\nNode SN-002-0571 sits in a diapir lens at 287.8 m below datum and peaks at a salinity of 7.63 ppt with the karst interval reading 4.26 degC at dawn and the wellhead spooned 11.50 m3/d while the electrode pair returns 2987 uS/cm.\nNode SN-003-0CE9 sits in a confining lens at 169.5 m below datum and logs a salinity of 0.96 ppt with the alluvial interval reading 5.03 degC at dawn and the wellhead salinized 12.08 m3/d while the electrode pair returns 2607 uS/cm.\nNode SN-004-0E2A sits in a freshened lens at 70.4 m below datum and settles near a salinity of 8.68 ppt with the spooned interval reading 4.88 degC at dawn and the wellhead siphon 4.38 m3/d while the electrode pair returns 2707 uS/cm.\nNode SN-005-0DDD sits in a percolate lens at 44.6 m below datum and reports a salinity of 6.25 ppt with the alluvial interval reading 10.85 degC at dawn and the wellhead brackish 13.99 m3/d while the electrode pair returns 3618 uS/cm.\nNode SN-006-0EFC sits in a till lens at 309.5 m below datum and reports a salinity of 3.49 ppt with the lacustrine interval reading 7.38 degC at dawn and the wellhead spooned 3.21 m3/d while the electrode pair returns 1253 uS/cm.\nNode SN-007-0B02 sits in a recharge lens at 262.0 m below datum and steps down to a salinity of 2.38 ppt with the discharge interval reading 8.18 degC at dawn and the wellhead perched 4.05 m3/d while the electrode pair returns 3900 uS/cm.\nNode SN-008-0448 sits in a karst lens at 406.5 m below datum and reports a salinity of 7.32 ppt with the upconing interval reading 6.17 degC at dawn and the wellhead fluvial 0.75 m3/d while the electrode pair returns 1448 uS/cm.\nNode SN-009-043C sits in a alluvial lens at 159.3 m below datum and flags a salinity of 6.20 ppt with the percolate interval reading 7.60 degC at dawn and the wellhead brackish 13.01 m3/d while the electrode pair returns 2025 uS/cm.\nNode SN-010-0FF5 sits in a karst lens at 264.2 m below datum and flags a salinity of 7.05 ppt with the freshened interval reading 7.88 degC at dawn and the wellhead siphon 8.41 m3/d while the electrode pair returns 1635 uS/cm.\nNode SN-011-098A sits in a drain lens at 365.7 m below datum and reports a salinity of 8.11 ppt with the wedge interval reading 5.51 degC at dawn and the wellhead glacial 2.52 m3/d while the electrode pair returns 2916 uS/cm.\nNode SN-012-0AE8 sits in a till lens at 259.6 m below datum and steps down to a salinity of 9.13 ppt with the recharge interval reading 11.60 degC at dawn and the wellhead spooned 0.46 m3/d while the electrode pair returns 3077 uS/cm.\nNode SN-013-0B83 sits in a salinized lens at 171.2 m below datum and peaks at a salinity of 2.83 ppt with the lacustrine interval reading 5.56 degC at dawn and the wellhead discharge 0.35 m3/d while the electrode pair returns 3083 uS/cm.\nNode SN-014-032E sits in a drain lens at 249.6 m below datum and flags a salinity of 1.09 ppt with the salinized interval reading 9.58 degC at dawn and the wellhead perched 12.00 m3/d while the electrode pair returns 2221 uS/cm.\nNode SN-015-004F sits in a aeolian lens at 264.4 m below datum and reports a salinity of 7.34 ppt with the salinized interval reading 8.76 degC at dawn and the wellhead perched 0.31 m3/d while the electrode pair returns 1483 uS/cm.\nNode SN-016-07C6 sits in a aeolian lens at 155.9 m below datum and reports a salinity of 0.64 ppt with the mineralized interval reading 11.75 degC at dawn and the wellhead diluted 13.46 m3/d while the electrode pair returns 524 uS/cm.\nNode SN-017-06C5 sits in a salinized lens at 72.1 m below datum and averages a salinity of 4.52 ppt with the siphon interval reading 8.93 degC at dawn and the wellhead confining 3.98 m3/d while the electrode pair returns 3687 uS/cm.\nNode SN-018-0E64 sits in a siphon lens at 334.9 m below datum and steps down to a salinity of 6.73 ppt with the salinized interval reading 7.63 degC at dawn and the wellhead freshened 9.64 m3/d while the electrode pair returns 1681 uS/cm.\nNode SN-019-0122 sits in a spooned lens at 1Line truncated
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# frank-primary-arms.sh — run a SEQUENCE of frank-primary bench arms and keep
# every arm's evidence. Runs on the operator box; each arm is one full window
# of ./autoresearch.sh (stop+mask -> boot the arm as the service -> measure ->
# restore production and verify), so the fleet is only ever down for one arm at
# a time and the production unit is back up between arms.
#
# An arm is the repo's own frank-primary.env plus an OVERLAY of engine knobs
# appended after it (later assignment wins when the serve script sources it),
# staged through FRANK_PRIMARY_AR_ENV_FILE so the checkout is never mutated and
# each arm gets its own fingerprint (the memo can never answer one arm with
# another arm's numbers).
#
# Usage: scripts/autoresearch/frank-primary-arms.sh [arm ...]
# scripts/autoresearch/frank-primary-arms.sh --list
# Output, under /tmp/frank-primary-arms-<UTC stamp>/ (override with ARMS_DIR):
#
# <arm>.out full autoresearch output (metrics + engine evidence)
# <arm>.env the exact staged env
# <arm>.rc window exit code
# summary.tsv arm, rc, decode_tps, decode_tps_t0.7, kv_tokens, boot_s
#
# Exit 0 when every arm produced metrics; 1 if any arm failed (a failed boot is
# a RESULT — the window still restored production, and the rc says which arm).
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
BASE_ENV="$REPO/scripts/frank-primary.env"
ARMS_DIR="${ARMS_DIR:-/tmp/frank-primary-arms-$(date -u +%Y%m%dT%H%M%SZ)}"
# --- arms ------------------------------------------------------------------
# baseline: the shipped shape E, measured with THIS harness so the comparison is
# matched (the ~29.6 t/s figure in the docs is an older shape, and no shape-E
# decode number exists yet).
OVER_baseline=()
# B: MTP3 with the vendor's probabilistic draft sampling, a capture ladder whose
# step is num_speculative_tokens + 1 = 4, and the sampling override. Precise
# memory stays ON — this arm is simultaneously the MTP test and the test of
# whether MTP's drafter workspace and the fitted 5.25 GiB overhead can coexist.
SPEC3='{"method":"mtp","num_speculative_tokens":3,"draft_sample_method":"probabilistic"}'
LADDER3='{"cudagraph_capture_sizes":[4,8,12],"max_cudagraph_capture_size":12,"inductor_compile_config":{"combo_kernels":false,"benchmark_combo_kernel":false}}'
# The value must arrive in the staged env file SINGLE-QUOTED: sourcing an env
# file performs quote removal, so an unquoted {"temperature":0.7} would reach
# the engine as {temperature:0.7}.
GEN07='{"temperature":0.7}'
OVER_B=(
"FRANK_PRIMARY_SPEC='$SPEC3'"
"FRANK_PRIMARY_COMPCONFIG='$LADDER3'"
'FRANK_PRIMARY_GEN_CONFIG='"'$GEN07'"
)
# B2: identical to B with the default (7.60 GiB) overhead, i.e. precise memory
# OFF. It is the fallback if B dies at graph capture, and it prices the arena
# that precise memory buys (7,522 slots -> ~6,496).
OVER_B2=(
"FRANK_PRIMARY_SPEC='$SPEC3'"
"FRANK_PRIMARY_COMPCONFIG='$LADDER3'"
'FRANK_PRIMARY_GEN_CONFIG='"'$GEN07'"
'FRANK_PRIMARY_PRECISE_MEMORY='
)
# C: the vendor's concurrency shape — SEQS 16 with the [4..32] ladder and util
# 0.95. This is also the direct test of the theory that pinning the capture
# ladder unlocks SEQS > 3 (the SEQS=2 failure was query_start_loc specialised to
# the constant 3, which is NOT in the default ladder; 4 is).
LADDER16='{"cudagraph_capture_sizes":[4,8,12,16,20,24,28,32],"max_cudagraph_capture_size":32,"inductor_compile_config":{"combo_kernels":false,"benchmark_combo_kernel":false}}'
OVER_C=(
"FRANK_PRIMARY_SPEC='$SPEC3'"
"FRANK_PRIMARY_COMPCONFIG='$LADDER16'"
'FRANK_PRIMARY_GEN_CONFIG='"'$GEN07'"
'FRANK_PRIMARY_SEQS=16'
'FRANK_PRIMARY_MNBT=4096'
'FRANK_PRIMARY_UTIL=0.95'
)
# D/E: the CONTEXT arms. Measured 2026-10-02 (arms run 20261002T233855Z): the
# KV pool is a hard byte request divided by a PER-TOKEN PRICE, and MTP raises
# that price because the drafter keeps its own KV per position:
# no spec: 3.15e9 / 398,658 tokens = 7,901 B/token (1.99x of 200,704)
# MTP3: 3.15e9 / 313,002 tokens = 10,064 B/token (1.56x)
# So arm C lost a fifth of the context to the drafter, not to util. Frank's
# contract is >=401,400 tokens over >=2 seqs = 2.00x of 200,704, which at
# 10,064 B/token costs 4.04e9 -> ask 4.05e9 (+0.83 GiB/rank).
# D is arm B with EXACTLY ONE variable changed (the KV bytes), so its result
# says whether the 4k-depth collapse (B 23.09 vs baseline 33.99) was KV or the
# batch shape.
KV401K='FRANK_PRIMARY_KV_BYTES=4050000000'
OVER_D=(
"FRANK_PRIMARY_SPEC='$SPEC3'"
"FRANK_PRIMARY_COMPCONFIG='$LADDER3'"
'FRANK_PRIMARY_GEN_CONFIG='"'$GEN07'"
"$KV401K"
)
# E: D with C's max_num_batched_tokens (4096 — C is the only arm that did NOT
# collapse at 4k depth: 33.15 where B gave 23.09) and util 0.99, the value
# Frank asked about. 0.99 is affordable on paper — production at util 0.98
# measures 30.37/34.21 GiB per card, 3.58 GiB physically unused — but it leaves
# only 0.32 GiB above the allowance while the CUDA-graph cost is an ANALYTIC
# estimate (C's was 1.47 GiB over 24 graphs), so it is measured, not assumed.
OVER_E=(
"FRANK_PRIMARY_SPEC='$SPEC3'"
"FRANK_PRIMARY_COMPCONFIG='$LADDER3'"
'FRANK_PRIMARY_GEN_CONFIG='"'$GEN07'"
"$KV401K"
'FRANK_PRIMARY_MNBT=4096'
'FRANK_PRIMARY_UTIL=0.99'
)
arm_overlay() {
case "$1" in
baseline) ;; # the shipped env, no overlay
B) printf '%s\n' "${OVER_B[@]}" ;;
B2) printf '%s\n' "${OVER_B2[@]}" ;;
C) printf '%s\n' "${OVER_C[@]}" ;;
D) printf '%s\n' "${OVER_D[@]}" ;;
E) printf '%s\n' "${OVER_E[@]}" ;;
*) return 1 ;;
esac
}
if [ "${1:-}" = "--list" ]; then
printf 'baseline B B2 C D E\n'
exit 0
fi
[ -f "$BASE_ENV" ] || { echo "[arms] missing $BASE_ENV" >&2; exit 1; }
if [ "$#" -gt 0 ]; then ARMS=("$@"); else ARMS=(baseline B B2 C); fi
mkdir -p "$ARMS_DIR"
echo "[arms] dir $ARMS_DIR arms: ${ARMS[*]}" >&2
: >"$ARMS_DIR/summary.tsv"
printf 'arm\trc\tdecode_tps\tdecode_tps_t0.7\tkv_tokens\tboot_s\n' >>"$ARMS_DIR/summary.tsv"
overall=0
for arm in "${ARMS[@]}"; do
if ! arm_overlay "$arm" >"$ARMS_DIR/$arm.overlay"; then
echo "[arms] unknown arm: $arm" >&2
overall=1
continue
fi
{ cat "$BASE_ENV"; echo; echo "# --- arm $arm overlay ($(date -u +%FT%TZ)) ---"
arm_overlay "$arm"; } >"$ARMS_DIR/$arm.env"
# Gate the staged env BEFORE burning a ~20-minute window: every knob that
# holds JSON must still be one valid JSON object after the file is SOURCED
# (that is the step that silently eats unquoted braces).
if ! bash -c 'set -a; . "$1" >/dev/null; set +a
for k in FRANK_PRIMARY_SPEC FRANK_PRIMARY_COMPCONFIG FRANK_PRIMARY_GEN_CONFIG; do
v="${!k:-}"
[ -n "$v" ] || continue
case "$v" in
"{"*) python3 -c "import json,sys; json.loads(sys.argv[1])" "$v" \
|| { echo "[arms] $k is not valid JSON: $v" >&2; exit 1; } ;;
*) echo "[arms] $k is not a JSON object: $v" >&2; exit 1 ;;
esac
done' _ "$ARMS_DIR/$arm.env"; then
echo "[arms] $arm: staged env rejected, window not opened" >&2
echo 99 >"$ARMS_DIR/$arm.rc"
overall=1
continue
fi
echo "[arms] === $arm: opening window $(date -u +%H:%M:%S)Z ===" >&2
FRANK_PRIMARY_AR_ENV_FILE="$ARMS_DIR/$arm.env" \
"$REPO/autoresearch.sh" >"$ARMS_DIR/$arm.out" 2>&1
rc=$?
echo "$rc" >"$ARMS_DIR/$arm.rc"
echo "[arms] === $arm: rc=$rc $(date -u +%H:%M:%S)Z ===" >&2
grep -E '^METRIC ' "$ARMS_DIR/$arm.out" | sed 's/^METRIC //' >"$ARMS_DIR/$arm.metrics" || true
metric() { local v; v="$(sed -n "s/^$1=//p" "$ARMS_DIR/$arm.metrics" | tail -1)"; printf '%s' "${v:-$2}"; }
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$arm" "$rc" \
"$(metric decode_tps FAIL)" "$(metric decode_tps_t0.7 FAIL)" \
"$(metric kv_tokens ?)" "$(metric boot_seconds ?)" >>"$ARMS_DIR/summary.tsv"
[ "$rc" -ne 0 ] && overall=1
done
echo "[arms] summary:" >&2
cat "$ARMS_DIR/summary.tsv" >&2
exit "$overall"
+52 -28
View File
@@ -19,14 +19,18 @@ Workload (fixed, deterministic, offline):
the server's own /v1/messages/count_tokens counter
* depths 115 / 1056 / 4096 target prompt tokens
* one 64-token warm pass per depth (warms the expert arena), then
max_tokens=256 at temperature 0, 3 runs, median
Everything is greedy with a fixed seed — the same workload every run, no
network, no time-of-day dependence.
max_tokens=256, 3 runs, median — repeated for every temperature in AR_TEMPS
(default 0 and 0.7). The prompt workload is deterministic (fixed seed, no
network, no time-of-day dependence); at temperature > 0 the generated text
is not, but the measured window is bounded by max_tokens, so the rate stays
comparable and the non-determinism is exactly the sampling that a
speculative-decode arm must survive.
Env:
AR_BASE endpoint (default http://127.0.0.1:7770)
AR_MODEL served name (default frank-primary)
AR_PROMPT_CEILING hard prompt ceiling in tokens (default 100000)
AR_TEMPS comma-separated sampling temperatures (default "0,0.7")
Output: progress on stderr, ONE json object on stdout (the last line).
Exit codes: 0 ok, 2 endpoint unhealthy / no usable completion, 3 bad usage.
@@ -47,6 +51,14 @@ DEPTHS = (115, 1056, 4096)
WARM_TOKENS = 64
MAX_TOKENS = 256
RUNS = 3
# Temperature sweep. The unsuffixed keys are temperature 0 — byte-comparable to
# every historical measurement — and AR_TEMPS adds more. The second one matters
# for speculative decode: draft ACCEPTANCE is sampling-dependent (a greedy draft
# matches a greedy target exactly and is rejected more under a non-greedy
# target), so an MTP arm judged only at temp 0 would be judged on half the
# contract. Production traffic is non-zero temperature (the payload's
# generation_config says 1.0; an override to 0.7 is under test).
TEMPS = tuple(float(x) for x in os.environ.get("AR_TEMPS", "0,0.7").split(","))
# 43-word x 12 = 516-word vocabulary, seed 1234 — the campaign's exact list
# and seed, so prompts are byte-identical to the historical measurements.
@@ -105,11 +117,11 @@ def prompt_for(target_tokens, wpt):
return text, pt
def measured(prompt, max_tokens):
def measured(prompt, max_tokens, temperature=0):
"""Return (ttft_s, decode_tok_s, completion_tokens) via usage counters."""
body = {"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "temperature": 0,
"max_tokens": max_tokens, "temperature": temperature,
"stream": True, "stream_options": {"include_usage": True}}
req = urllib.request.Request(
BASE + "/v1/chat/completions", data=json.dumps(body).encode(),
@@ -145,8 +157,13 @@ def measured(prompt, max_tokens):
return (ttft or wall), (ct / dec_window), ct
def fmt_temp(t):
return ("%.1f" % t).rstrip("0").rstrip(".") if t else "0"
def main():
log("base=%s model=%s ceiling=%d" % (BASE, MODEL, PROMPT_CEILING))
log("base=%s model=%s ceiling=%d temps=%s"
% (BASE, MODEL, PROMPT_CEILING, ",".join(fmt_temp(t) for t in TEMPS)))
try:
wpt = calibrate()
except Exception as e:
@@ -158,32 +175,39 @@ def main():
for tgt in DEPTHS:
try:
p, pt = prompt_for(tgt, wpt)
measured(p, WARM_TOKENS) # warm the arena
measured(p, WARM_TOKENS) # warm the arena and the prefix cache
except Exception as e:
log("warm pass failed at depth %d: %s" % (tgt, e))
return 2
rates, tts, cts = [], [], []
for i in range(RUNS):
try:
tt, r, ct = measured(p, MAX_TOKENS)
except Exception as e:
log("run %d/%d failed at depth %d: %s" % (i + 1, RUNS, tgt, e))
continue
rates.append(r)
tts.append(tt)
cts.append(ct)
if len(rates) < 2:
log("only %d usable run(s) at depth %d — refusing to median" % (len(rates), tgt))
return 2
result["decode_tps_%d" % tgt] = round(statistics.median(rates), 2)
result["ttft_ms_%d" % tgt] = round(1000.0 * statistics.median(tts), 1)
result["completion_tokens_%d" % tgt] = int(statistics.median(cts))
result["prompt_tokens_%d" % tgt] = pt
log("depth %5d tok decode %.2f t/s ttft %.3f s (runs=%d)"
% (pt, result["decode_tps_%d" % tgt], result["ttft_ms_%d" % tgt] / 1000.0, len(rates)))
for temp in TEMPS:
suffix = "" if temp == 0 else "_t%s" % fmt_temp(temp)
rates, tts, cts = [], [], []
for i in range(RUNS):
try:
tt, r, ct = measured(p, MAX_TOKENS, temp)
except Exception as e:
log("run %d/%d failed at depth %d temp %s: %s"
% (i + 1, RUNS, tgt, fmt_temp(temp), e))
continue
rates.append(r)
tts.append(tt)
cts.append(ct)
if len(rates) < 2:
log("only %d usable run(s) at depth %d temp %s — refusing to median"
% (len(rates), tgt, fmt_temp(temp)))
return 2
result["decode_tps_%d%s" % (tgt, suffix)] = round(statistics.median(rates), 2)
result["ttft_ms_%d%s" % (tgt, suffix)] = round(1000.0 * statistics.median(tts), 1)
result["completion_tokens_%d%s" % (tgt, suffix)] = int(statistics.median(cts))
result["prompt_tokens_%d%s" % (tgt, suffix)] = pt
log("depth %5d tok temp %-4s decode %7.2f t/s ttft %.3f s (runs=%d)"
% (pt, fmt_temp(temp), result["decode_tps_%d%s" % (tgt, suffix)],
result["ttft_ms_%d%s" % (tgt, suffix)] / 1000.0, len(rates)))
meds = [result["decode_tps_%d" % t] for t in DEPTHS]
result["decode_tps"] = round(statistics.median(meds), 2)
for temp in TEMPS:
suffix = "" if temp == 0 else "_t%s" % fmt_temp(temp)
meds = [result["decode_tps_%d%s" % (t, suffix)] for t in DEPTHS]
result["decode_tps%s" % suffix] = round(statistics.median(meds), 2)
print(json.dumps(result))
return 0
+19 -1
View File
@@ -118,7 +118,7 @@ main() {
[ -f "$STAGE/$f" ] || { log "staged file missing: $STAGE/$f"; exit 1; }
done
mkdir -p "$STAGE/last"
rm -f "$STAGE/run.json" "$STAGE/fail.log" "$STAGE/measure.out"
rm -f "$STAGE/run.json" "$STAGE/fail.log" "$STAGE/measure.out" "$STAGE/evidence.log"
local t_start t0 i kvline kv_tokens kv_conc measure_json boot_seconds
t_start="$(date +%s)"
@@ -177,6 +177,24 @@ main() {
kv_tokens="$(printf '%s' "$kvline" | sed -E 's/.*GPU KV cache size: ([0-9,]+) tokens.*/\1/' | tr -d ',' || true)"
kv_conc="$(printf '%s' "$kvline" | sed -E 's/.*per request: ([0-9.]+x).*/\1/' || true)"
# Human-facing evidence, in a sidecar file because it is multi-line prose,
# not metrics: the expert-plan lines (the ONLY place the arena / runtime
# overhead / KV-reserve numbers exist — they are never in /metrics), the
# compile path (cold build vs cache hit), the graph-capture sizes, the
# speculative-decode counters, and any OOM signature. Without this an arm
# that loses decode speed is unexplainable afterwards.
{
echo "=== engine plan / capacity / spec evidence ==="
podman logs "$CTR" 2>&1 | grep -E \
'KV cache size|Maximum concurrency|layers split|expert cache|runtime overhead|non-default args|r4d all-reduce|Using \[|cudagraph|CUDA graph|Capturing|graph for compile range|torch.compile took|Directly load the compiled|speculative|Speculative|acceptance|Acceptance|offload|precise|OutOfMemory|Tried to allocate|Engine core initialization failed' \
| tail -150 || true
echo "=== /metrics counters (spec decode, caches, success) ==="
curl -s --max-time 15 http://127.0.0.1:7770/metrics 2>/dev/null \
| grep -E '^vllm:(spec_decode|gpu_cache|prefix_cache|request_success|generation_tokens|iteration)' \
| head -80 || true
} >"$STAGE/evidence.log" 2>&1 || true
log "engine evidence -> $STAGE/evidence.log ($(wc -l <"$STAGE/evidence.log") lines)"
measure_json="$(grep -E '^\{' "$STAGE/measure.out" | tail -1 || true)"
if [ -z "$measure_json" ]; then
log "measure produced no JSON result line"
+81 -60
View File
@@ -935,15 +935,24 @@ deploy_plex() {
fi
systemctl --user restart plex.service
# Readiness gate: /identity answers MediaContainer XML once PMS is up.
#
# HTTPS, NOT HTTP. PMS drops plain HTTP off-loopback: the probe's
# connection arrives on the container's eth0, so an http:// request is
# accepted and then answered with nothing (curl 52, Empty reply) against a
# perfectly healthy server. Measured 2026-10-02 — the gate burned its full
# 600s and failed a plex whose https://127.0.0.1:32400/identity answered
# 200 in 44 ms the whole time. The same law is why the Caddyfile dials the
# plex upstream over TLS (tests/caddyfile.bats pins it).
# -k: PMS presents its own self-signed cert on 32400.
#
# Reality check from the 2026-09 cutover: the FIRST container boot of a
# years-old library ran ~10 min (PMS pre-binds 32400 during boot and
# empty-replies until the HTTP layer is ready), so the gate allows 600s.
# years-old library ran ~10 min, so the gate still allows 600s.
# Fail the deploy (pending state preserved) rather than mark it deployed blind.
for ((i = 1; i <= 120; i++)); do
curl -fsS --max-time 3 http://127.0.0.1:32400/identity >/dev/null && return 0
curl -fsSk --max-time 3 https://127.0.0.1:32400/identity >/dev/null && return 0
sleep 5
done
echo "FAIL: plex did not answer /identity within 600s" >&2
echo "FAIL: plex did not answer /identity over HTTPS within 600s" >&2
return 1
}
@@ -1077,29 +1086,33 @@ deploy_anubis() {
wait_container_healthy anubis 60 || return 1
}
# frank-large is the ROCmFPX fork of llama.cpp (kingjones30/ROCmFPX, pinned by
# scripts/build-rocmfpx.sh), serving
# kingjones777/Qwen3.8-27B-ROCmFP4-STRIX-MTP-GGUF:Q8_0_ROCMFPX_AGENT with the
# repo's own Q4_0 MTP draft head and its BF16 vision projector, on the Strix
# Halo iGPU. It is a ROOT unit: the runtime is a native binary linked against
# the host ROCm, so there is no container image to install and no generated
# unit to dodge. relink_unit points /etc/systemd/system/frank-large.service at
# the repo file and boot persistence is that unit's [Install] section.
# frank-large is the halogen CONTAINER (ghcr.io/peonist-ai/halogen,
# digest-pinned through scripts/frank-large.env) serving
# peonist-ai/halogen-qwen3.8-27b — qwen3.8-27b-p1w4d-d2.hgn, 33.4 GiB,
# DFlash2 spec decode, 1 slot x 200700, text-only — on the Strix Halo
# iGPU. It stays a ROOT unit because the serve script drives `podman run`
# directly (the frank-primary pattern): relink_unit points
# /etc/systemd/system/frank-large.service at the repo file, boot
# persistence is that unit's [Install], and LimitMEMLOCK=infinity is
# fleet parity. 2026-10-05 cutover from the ROCmFPX llama.cpp shape —
# rollback = revert the swap commit's trio; that payload, its draft head
# and the fork build all stay on disk
# (~/models/kingjones-Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP,
# ~/GitPackages/kingjones-ROCmFPX).
#
# The runtime gate matters as much as the payload gate: the fork is built from
# source on this host, and the payload is ftype 115 (tensor type 103,
# GGML_TYPE_Q8_0_ROCMFPX) which only this fork can read — so a machine whose
# /home/dank/GitPackages tree was never built has a unit that can never
# become healthy, and a green deploy would leave the endpoint dark.
#
# It replaces gufo v0.1.1 + unsloth UD-Q4_K_XL (the rollback target, still
# built by scripts/build-gufo.sh with its payload on disk), which had replaced
# the stock llama.cpp prebuilt. The port gate is the same as every cutover
# since 2026-09-16: nothing starts until 7770 is released.
LLM_LARGE_RUNTIME="${LLM_LARGE_RUNTIME:-/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server}"
LLM_LARGE_MODEL="${LLM_LARGE_MODEL:-/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf}"
LLM_LARGE_DRAFT="${LLM_LARGE_DRAFT:-/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/mtp-Qwen3.8-27B-Q4_0.gguf}"
LLM_LARGE_MMPROJ="${LLM_LARGE_MMPROJ:-/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/mmproj-Qwen3.8-27B-BF16.gguf}"
# The gates mirror serve-frank-large.sh's predicates EXACTLY (the
# 2026-10-01 crash-loop lesson): image digest in dank's podman storage,
# the .hgn checkpoint at the exact byte count, the tokenizer directory
# holding every named file as a regular (not symlinked) file — a green
# deploy against a missing artifact would
# leave a restart-looping unit and a dark endpoint. The port gate is the
# same as every cutover since 2026-09-16: nothing starts until 7770 is
# released.
LLM_LARGE_IMAGE="${LLM_LARGE_IMAGE:-ghcr.io/peonist-ai/halogen@sha256:dc0a39a0016d6cfc58a197978febaafdf8d28403f724ded6111d98b5fb7ac0ea}"
LLM_LARGE_CKPT="${LLM_LARGE_CKPT:-/home/dank/models/halogen-qwen3.8-27b/qwen3.8-27b-p1w4d-d2.hgn}"
LLM_LARGE_CKPT_BYTES="${LLM_LARGE_CKPT_BYTES:-35865565184}"
LLM_LARGE_TOK_DIR="${LLM_LARGE_TOK_DIR:-/home/dank/models/halogen-qwen3.8-27b/tokenizer}"
LLM_LARGE_TOK_FILES="${LLM_LARGE_TOK_FILES:-tokenizer.json chat_template.jinja tokenizer_config.json merges.txt vocab.json}"
retire_halogen_large_quadlet() {
local trash="$HOME/.AGENTTRASH/20260919-orca-cutover/quadlet"
@@ -1132,43 +1145,51 @@ wait_port_free() {
}
deploy_llm_large() {
# Refuse rather than restart-loop. A unit whose runtime or payload is
# missing never becomes healthy, and a green deploy would leave the
# endpoint dark with nothing in the deploy log to say why. The gates
# mirror serve-frank-large.sh's own predicates exactly: runtime
# executable, target GGUF, MTP head, mmproj.
if [[ ! -x "$LLM_LARGE_RUNTIME" ]]; then
echo "ERROR: ROCmFPX runtime absent at $LLM_LARGE_RUNTIME" >&2
echo "frank-large: build it: bash scripts/build-rocmfpx.sh" >&2
# Refuse rather than restart-loop; every gate below has a twin in
# serve-frank-large.sh and they must agree (2026-10-01 lesson).
# Capture podman output before grepping: `podman images | grep -q`
# races under pipefail (SIGPIPE 141 becomes "no match", 2026-09-30).
if ! grep -qF "${LLM_LARGE_IMAGE#*@}" <<<"$(podman images --format '{{.RepoDigests}}' 2>/dev/null)"; then
echo "ERROR: halogen image absent: $LLM_LARGE_IMAGE" >&2
echo "frank-large: pull it: podman pull ${LLM_LARGE_IMAGE%@*}" >&2
return 1
fi
local missing=0 f
for f in "$LLM_LARGE_MODEL" "$LLM_LARGE_DRAFT" "$LLM_LARGE_MMPROJ"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: payload absent at $f" >&2
echo "frank-large: fetch it: bash scripts/download-frank-large-kingjones.sh" >&2
missing=1
if [[ ! -f "$LLM_LARGE_CKPT" ]]; then
echo "ERROR: halogen checkpoint absent at $LLM_LARGE_CKPT" >&2
echo "frank-large: fetch it: bash scripts/download-frank-large-halogen.sh" >&2
return 1
fi
if [[ "$(stat -c %s "$LLM_LARGE_CKPT")" != "$LLM_LARGE_CKPT_BYTES" ]]; then
echo "ERROR: halogen checkpoint is not $LLM_LARGE_CKPT_BYTES bytes (partial download?)" >&2
echo "frank-large: re-check: bash scripts/download-frank-large-halogen.sh --verify-only" >&2
return 1
fi
if [[ -L "$LLM_LARGE_TOK_DIR" || ! -d "$LLM_LARGE_TOK_DIR" ]]; then
echo "ERROR: tokenizer dir missing or a symlink at $LLM_LARGE_TOK_DIR" >&2
echo "frank-large: fetch it: bash scripts/download-frank-large-halogen.sh" >&2
return 1
fi
for tf in $LLM_LARGE_TOK_FILES; do
if [[ ! -f "$LLM_LARGE_TOK_DIR/$tf" || -L "$LLM_LARGE_TOK_DIR/$tf" ]]; then
echo "ERROR: tokenizer file missing or a symlink: $LLM_LARGE_TOK_DIR/$tf" >&2
echo "frank-large: fetch it: bash scripts/download-frank-large-halogen.sh" >&2
return 1
fi
done
[[ "$missing" -eq 0 ]] || return 1
retire_halogen_large_quadlet
# On a re-deploy the predecessor is THIS unit: stop it before waiting on
# the port, or the wait deadlocks on the server it just stopped serving.
# Then sweep a stray manual run -- that is how a bench harness leaves the
# port busy. Match the TARGET GGUF name, which every frank-large command
# line carries via --model and which no other process on this host uses:
# frank-small also runs llama-server here (with the MiMo GGUF) and must
# not be killed, so keying on the binary name would take the 3090 endpoint
# down with it. The pattern never appears in the deploying shell's own
# command line, so -f is safe here (it is not in `bash deploy-remote.sh`).
# The predecessor on a re-deploy is THIS unit: stop it (ExecStop stops
# the container) before waiting on the port. The stray sweep is keyed
# on the FULL container name, which appears in the argv of a manual
# `podman run --name frank-large-halogen` / `podman wait` but NOT in
# this deployer's own command line (`bash deploy-remote.sh
# frank-large`) — so it cannot shoot itself in the foot (the fleet
# pkill law), and it can never match frank-small's llama-server.
run_privileged systemctl stop frank-large.service 2>/dev/null || true
pkill -f 'Qwen3.8-27B-Q8_0_ROCMFPX_AGENT' 2>/dev/null || true
# Nothing may start until the predecessor has released the port. On
# 2026-09-16 the cutover failed exactly here: the old server logged
# "Deactivated successfully", the next engine tried to bind 7770 a second
# later and reported "Address already in use". Gate on the port, not on
# hope.
pkill -f 'frank-large-halogen' 2>/dev/null || true
podman rm -f frank-large frank-large-halogen >/dev/null 2>&1 || true
# Nothing may start until the predecessor has released the port
# (2026-09-16: "Deactivated successfully" was not "port free").
wait_port_free 7770 || return 1
relink_unit frank-large
run_privileged systemctl daemon-reload
@@ -1186,12 +1207,12 @@ deploy_llm_large() {
#
# It is a SEPARATE endpoint on port 7771; frank-large keeps 7770. The 3090 is
# not shared: frank-large's engine runs on the gfx1151 iGPU.
LLM_SMALL_MODEL_FILE="${LLM_SMALL_MODEL_FILE:-/home/dank/models/MiMo-Q5KM/MiMo-V2.6-Distill-Qwen-9B-Q5_K_M.gguf}"
LLM_SMALL_MODEL_FILE="${LLM_SMALL_MODEL_FILE:-/home/dank/models/Ornith-Q8/Ornith-1.5-9B-Q8_0.gguf}"
LLM_SMALL_RUNTIME="${LLM_SMALL_RUNTIME:-/home/dank/GitPackages/llama-master/build-cuda/bin/llama-server}"
# The projector IS the vision capability here: the Q5_K_M GGUF ships no `v.*`
# tensors, so a deploy that starts without this file silently ships a
# The projector IS the vision capability here: the Ornith GGUF ships no
# `v.*` tensors, so a deploy that starts without this file silently ships a
# text-only endpoint. Checked unconditionally, like frank-large's.
LLM_SMALL_MMPROJ="${LLM_SMALL_MMPROJ:-/home/dank/models/MiMo-Q5KM/mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf}"
LLM_SMALL_MMPROJ="${LLM_SMALL_MMPROJ:-/home/dank/models/Ornith-Q8/mmproj-Ornith-1.5-9B-BF16.gguf}"
deploy_llm_small() {
# Refuse rather than restart-loop: a unit whose runtime or payload is
+26 -1
View File
@@ -196,6 +196,21 @@ else
printf ' - %s\n' "${PLAN[@]}"
fi
# --- frankbook e2e trigger --------------------------------------------------
# The *arr/qBittorrent e2e units walk links that terminate on FrankBook-Pro
# (192.168.0.112), which sleeps when idle. The [3/3] e2e gate therefore runs
# the frankbook Playwright project ONLY when something in its blast radius
# changed since origin/main: the frankbook spec itself, the frankbook-pro
# stack, the homepage registry, or the Caddyfile serving the arr vhosts.
# Every other deploy skips it (tests/e2e/run.sh excludes the project by
# default). See tests/e2e/specs/19-frankbook.spec.ts.
FB_E2E_TRIGGER=0
while IFS= read -r fb_path; do
case "$fb_path" in
tests/e2e/specs/19-frankbook.spec.ts|frankbook-pro/*|shared/homepage/services.json|dankclaw/Caddyfile) FB_E2E_TRIGGER=1 ;;
esac
done < <(git diff --name-only "$BASE_REVISION" "$HEAD_REVISION")
REMOTE="${REMOTE_USER}@${REMOTE_HOST}"
REMOTE_ARGS=("$TARGET" "$HEAD_REVISION" "$REMOTE_DIR" "$REMOTE_SHELL")
if [[ "${#EXPLICIT_SERVICES[@]}" -eq 0 ]]; then
@@ -209,6 +224,11 @@ if [[ "$DRY_RUN" -eq 1 ]]; then
printf '[dry-run] Runner arguments:'
printf ' %q' "${REMOTE_ARGS[@]}"
printf '\n'
if [[ "$FB_E2E_TRIGGER" -eq 1 ]]; then
echo "[dry-run] e2e gate: frankbook project INCLUDED (frankbook trigger paths changed)."
else
echo "[dry-run] e2e gate: frankbook project EXCLUDED (no frankbook trigger paths)."
fi
exit 0
fi
@@ -250,7 +270,12 @@ if [[ "$SKIP_E2E" -eq 1 ]]; then
echo "[3/3] e2e gate SKIPPED (--skip-e2e): live fleet NOT verified." >&2
else
echo "[3/3] Running Playwright e2e gate against the live fleet..."
if bash "$REPO_ROOT/tests/e2e/run.sh"; then
E2E_ENV=()
if [[ "$FB_E2E_TRIGGER" -eq 1 ]]; then
echo " frankbook trigger paths changed: including the frankbook e2e project."
E2E_ENV=(E2E_INCLUDE_FRANKBOOK=1)
fi
if env "${E2E_ENV[@]}" bash "$REPO_ROOT/tests/e2e/run.sh"; then
echo "e2e gate PASSED."
else
echo "=== DEPLOY INCOMPLETE: e2e gate FAILED on $TARGET ===" >&2
@@ -0,0 +1,62 @@
# frank-primary-freetoken.Dockerfile — FreeToken (PR #587 ROCm) serving image
# for the frank-primary endpoint (dankserver :7770, 1x R9700 gfx1201).
#
# Built on dankserver with rootless podman (dank user), same storage the
# service's `podman run` reads:
# podman build -t frank-primary-freetoken:pr587-77a7201 \
# -f scripts/docker/frank-primary-freetoken.Dockerfile scripts/docker/
#
# Base: the PR's own docs/install_amd.md names this exact image as the
# known-good ROCm 7.14 stack for gfx1201 (torch 2.11.0+rocm7.14, triton-rocm
# 3.7 — the original pin; FreeToken main+PR587 accepts torch>=2.11,<2.16 and
# triton>=3.6,<3.9). There is no rocm7.14+torch2.14 image on Docker Hub (the
# 2.14 images are rocm10.1.0 — an untested pair for this PR), so the PR doc's
# verbatim recommendation is the deployed stack.
#
# FreeToken source: FlashML-org/FreeToken PR #587 at pinned head
# 77a7201f98f00d93f618c6cf8b81ffe8360e0899 (branch rocm714_torch214, base
# main 581fdca3). The PR is ROCm-gated end to end; the NVIDIA/CUDA path is
# byte-identical to main.
#
# Build law from docs/install_amd.md:
# - `pip install --no-build-isolation --no-deps -e .` — the image's ROCm
# torch is preserved and used to compile the C++/HIP extensions.
# - NEVER a plain `pip install -e .` on ROCm: the dependency set is
# authored for NVIDIA (flashlib pulls numba + nvidia-cutlass-dsl; torch
# metadata pulls cuda-bindings) and the resolve can REPLACE the ROCm
# torch with a CUDA build.
# - Runtime deps installed explicitly, curated by the same doc. flashinfer
# / sgl_kernel / nvidia-cutlass-dsl have no ROCm builds and are
# intentionally absent — FreeToken gates them off and falls back to its
# Triton kernels on ROCm.
#
# Base is digest-pinned (2026-10-05): sha256:a223aee1… is the manifest this
# build was qualified against; a re-pushed tag cannot change the stack.
FROM rocm/pytorch@sha256:a223aee17aef5d21c3b9f63436dd19d27d1c665ec8b2f40011c9546cabae2a80
# Pin the PR head, not the branch: a future push to rocm714_torch214 would
# otherwise silently change the deployed engine.
# The pip-ROCm torch in this image ships libhiprtc as the VERSIONED
# libhiprtc.so.7 only; the PR's `_cpu_moe` extension links `-lhiprtc`, and
# ld looks for the unversioned dev symlink in the ALREADY-LINKED
# _rocm_sdk_core/lib dir. Create it there (first build failure 2026-10-05:
# `ld: cannot find -lhiprtc`).
RUN ln -s libhiprtc.so.7 /opt/venv/lib/python3.12/site-packages/_rocm_sdk_core/lib/libhiprtc.so
RUN git clone https://github.com/FlashML-org/FreeToken.git /opt/FreeToken \
&& cd /opt/FreeToken \
&& git fetch origin pull/587/head \
&& git checkout 77a7201f98f00d93f618c6cf8b81ffe8360e0899
WORKDIR /opt/FreeToken
RUN python -m pip install --no-build-isolation --no-deps -e .
# Curated runtime dependencies (docs/install_amd.md). transformers is pinned
# to the pyproject window (>=5.16,<5.17); the rest are the doc's portable set.
# torch / torchvision / triton-rocm come from the base image, 1:1 aligned.
RUN python -m pip install \
"transformers>=5.16,<5.17" tokenizers safetensors huggingface_hub \
fastapi uvicorn pydantic openai httpx anyio \
einops gguf msgpack pyzmq typer rich apache-tvm-ffi \
partial-json-parser \
&& python -m pip install --no-deps flashlib==0.3.0 \
&& python -m pip install ninja
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# download-frank-large-halogen.sh — fetch frank-large's halogen payload:
# the qwen3.8-27b .hgn checkpoint and the export tokenizer, from the
# PUBLIC peonist-ai/halogen-qwen3.8-27b repo (no token — and per the fleet
# secret law, none would ever touch argv).
#
# The fetch law, same as every sibling script: curl -f (an access error
# must not become a "successful" 153-byte artifact), exact byte count from
# the model card, LFS sha256 verification, --verify-only to re-check an
# existing payload without downloading.
set -euo pipefail
DEST="${FRANK_LARGE_MODEL_DIR:-$HOME/models/halogen-qwen3.8-27b}"
BASE="https://huggingface.co/peonist-ai/halogen-qwen3.8-27b/resolve/main"
FILES=(qwen3.8-27b-p1w4d-d2.hgn
tokenizer/tokenizer.json
tokenizer/vocab.json
tokenizer/merges.txt
tokenizer/chat_template.jinja
tokenizer/tokenizer_config.json)
# Byte sizes as published by the Hub on 2026-10-05, plus sha256 (the LFS
# oid) where the Hub stores an LFS object — plain git blobs carry no oid
# and are size-checked only. Re-check with:
# curl -s "https://huggingface.co/api/models/peonist-ai/halogen-qwen3.8-27b/tree/main?recursive=true"
SIZES=(35865565184 12809320 6722759 3353259 8952 17928)
SHAS=(274c3fc767fb57faf025dd03c76376b1fc1b32448aef45aaf02d1898fa962089
0997f410c57a1f4e53b09e4be8f4a172d90edd9564368fb0847030937229b9f3 - - - -)
VERIFY_ONLY=0
[ "${1:-}" = "--verify-only" ] && VERIFY_ONLY=1
mkdir -p "$DEST"
for i in "${!FILES[@]}"; do
f="${FILES[$i]}"; want="${SIZES[$i]}"; sha="${SHAS[$i]}"
path="$DEST/$f"
mkdir -p "$(dirname "$path")"
if [ "$VERIFY_ONLY" -eq 0 ]; then
# A file already at the published size skips the fetch entirely:
# resuming it would ask the CDN for a range past EOF (416 -> curl
# error under set -e), and re-pulling 33.4 GiB to prove a point is
# not what --verify-only is for. sha256 below still decides.
if [ -f "$path" ] && [ "$(stat -c %s "$path")" = "$want" ]; then
echo "HAVE $f ($want bytes) — skipping fetch"
else
echo "FETCH $f -> $path"
# HF answers /resolve/ with a 302 to the Xet CDN and curl DROPS
# the Range header across redirects — a naive -C - then
# "resumes" by pulling the ~1 KB redirect body (measured
# 2026-10-05: file frozen 14 MiB short, curl exit 0). Resolve
# the effective URL first, then resume against the CDN, which
# honors Range.
url=$(curl -sfIL -o /dev/null -w '%{url_effective}' "$BASE/$f") \
|| { echo "cannot resolve $BASE/$f" >&2; exit 1; }
curl -fL --retry 5 -C - -o "$path" "$url"
fi
fi
[ -f "$path" ] || { echo "MISSING $path (run without --verify-only)" >&2; exit 1; }
got="$(stat -c %s "$path")"
[ "$got" = "$want" ] || { echo "SIZE $path: $got != $want" >&2; exit 1; }
echo "OK $f ($got bytes)"
done
echo "byte counts match; verifying sha256 where the Hub publishes an LFS oid..."
for i in "${!FILES[@]}"; do
f="${FILES[$i]}"; sha="${SHAS[$i]}"
if [ "$sha" = "-" ]; then
echo "OK $f (plain git blob, size-checked)"
continue
fi
got="$(sha256sum "$DEST/$f" | cut -d' ' -f1)"
[ "$got" = "$sha" ] || { echo "SHA256 $f: $got != $sha" >&2; exit 1; }
echo "OK $f sha256"
done
echo "DOWNLOAD_DONE $DEST"
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Download kingjones777/Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP-GGUF for
# frank-large (Dankclaw iGPU). Public repo — no auth header, and never a token
# on the command line (it is world-readable in /proc/<pid>/cmdline).
#
# The payload is the repo's Q6 AGENT rung:
# * Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT.gguf — ftype 114, 23.15 GiB.
# Q6_0_ROCMFPX tensor type (102) + the AGENT routing, which keeps the
# output-head and attention tensors at plain Q8_0 — the variant the card
# names for MTP serving ("Take Q6 AGENT (114) if you want protected
# heads"). Like every ROCmFPX file it loads on nothing but the fork.
#
# The other two files frank-large needs are NOT re-downloaded:
# * mtp-Qwen3.8-27B-Q4_0.gguf — the uncensored repo redistributes the
# ALIGNED repo's draft head byte for byte (identical Hub LFS oid
# 051a1764…), so it is hardlinked out of the aligned payload dir when that
# dir holds the verified bytes, and fetched from this repo otherwise.
# * mmproj-Qwen3.8-27B-BF16.gguf — this repo ships a Q8_0 projector; the
# fleet keeps the BF16 one, which is the projector that was verified 4/4
# on spatial ground truth on this host. Hardlinked the same way, fetched
# from the ALIGNED repo if it is not on disk.
#
# Fresh downloads, no resume (`curl -C -` once produced an oversized file on a
# 31 GiB payload); -f/--fail-with-body so an HTTP 401 is not a success, then
# byte count and the Hub LFS sha256. Idempotent: complete files are skipped.
#
# Usage:
# scripts/download-frank-large-kingjones-uncensored.sh [DEST_DIR] [--verify-only]
set -euo pipefail
REPO="kingjones777/Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP-GGUF"
ALIGNED_REPO="kingjones777/Qwen3.8-27B-ROCmFP4-STRIX-MTP-GGUF"
BASE="https://huggingface.co/${REPO}/resolve/main"
ALIGNED_BASE="https://huggingface.co/${ALIGNED_REPO}/resolve/main"
SOURCE_DIR="${SOURCE_DIR:-/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP}"
DEST=""
VERIFY_ONLY=0
for arg in "$@"; do
case "$arg" in
--verify-only) VERIFY_ONLY=1 ;;
*) DEST="$arg" ;;
esac
done
DEST="${DEST:-/home/dank/models/kingjones-Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP}"
# name bytes sha256 base_url may_link — may_link=1 means the same bytes may
# already exist in SOURCE_DIR (hardlink them instead of re-fetching).
FILES=(
"Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT.gguf 24856607008 e966deb3d1a5a1b9ca7765126292f2d822f3dccdedb3b4872e673dec1cddc199 $BASE 0"
# The draft head is redistributed byte-identical by both repos; the BF16
# projector only exists in the aligned repo.
"mtp-Qwen3.8-27B-Q4_0.gguf 1680271648 051a1764cff8c4f3ee6ae8b00593a0364c7539c67fa50ffc58f3f96509fca38e $BASE 1"
"mmproj-Qwen3.8-27B-BF16.gguf 931145888 de2a49866988ea272c43dc2a43ee2662c25725a717ca5b9059ea420a97f53fe8 $ALIGNED_BASE 1"
)
matches() { # matches FILE SIZE SHA — byte count AND LFS sha256
[[ -f "$1" ]] && [[ "$(stat -c%s "$1")" == "$2" ]] \
&& sha256sum -c --quiet <(printf '%s %s\n' "$3" "$1")
}
mkdir -p "$DEST"
status=0
fetch() { # fetch NAME SIZE SHA BASE_URL
local name="$1" size="$2" sha="$3" url="$4" f="$DEST/$1"
echo "FETCHING $name"
curl -fsSL --fail-with-body -o "$f.part" "$url/$name"
mv "$f.part" "$f"
if [[ "$(stat -c%s "$f")" != "$size" ]]; then
echo "SIZE FAIL $name ($(stat -c%s "$f") != $size)"
return 1
fi
if ! sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
echo "SHA FAIL $name"
return 1
fi
echo "VERIFIED $name"
}
for entry in "${FILES[@]}"; do
read -r name size sha url may_link <<<"$entry"
f="$DEST/$name"
if matches "$f" "$size" "$sha"; then
echo "OK $name"
continue
fi
if (( VERIFY_ONLY )); then
echo "MISSING $name"
status=1
continue
fi
# Shared bytes: prefer the local copy (same filesystem, so a hardlink costs
# nothing) over re-downloading gigabytes that hash identically.
if [[ "$may_link" == 1 && -n "${SOURCE_DIR:-}" ]] && matches "$SOURCE_DIR/$name" "$size" "$sha"; then
echo "LINKING $name from $SOURCE_DIR"
ln -f "$SOURCE_DIR/$name" "$f" 2>/dev/null || cp -f "$SOURCE_DIR/$name" "$f"
if matches "$f" "$size" "$sha"; then
echo "VERIFIED $name"
continue
fi
echo "SHA FAIL $name (linked copy)"
status=1
continue
fi
if ! fetch "$name" "$size" "$sha" "$url"; then
status=1
fi
done
if (( status == 0 )); then
echo "DOWNLOAD_DONE $DEST"
else
echo "DOWNLOAD_FAILED $DEST" >&2
fi
exit "$status"
+14 -10
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Download the frank-small payload for llama.cpp on the RTX 3090:
# bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF:Q5_K_M plus its vision projector
# ornith-ai/Ornith-1.5-9B-GGUF:Q8_0 plus its vision projector
# (the GGUF carries no `v.*` tensors — the tower lives only in the mmproj, so
# without this file the endpoint is text-only by construction). Public repo —
# no auth header, and never a token on the command line (it is world-readable
@@ -11,16 +11,20 @@
# then byte count and the Hub LFS sha256. Idempotent: complete files are
# skipped.
#
# The Q6_K file this endpoint served until 2026-10-01 night stays on disk as
# the rollback target (7793710624 B, LFS oid
# ef96d05a2ddf2cbb450d1af1ac3860ec769d3575bafa70692ee5609bda3fad7d) — re-fetch
# it from the same repo with the same recipe if it is ever needed again.
# 2026-10-05 (Frank): the rung is Ornith-1.5-9B Q8_0. The payload moved off
# MiMo-V2.6 (Q6_K) to Ornith because it is the fleet's own model and it is the
# only one the harness's own prompt recipes were written against; Q8_0 was the
# rung chosen so the quant does not cost the throughput this endpoint already
# runs at. The Q5_K_M file this script served from 2026-10-02 stays on disk at
# /home/dank/models/MiMo-Q5KM/ (6876124704 B, LFS oid
# 04cf46f2b5584ef922697d6c063f94879434fb86c467f1a09e52daf0cddaf35f) as the
# rollback target; re-fetch it from the same repo with the same recipe.
#
# Usage:
# scripts/download-frank-small.sh [DEST_DIR] [--verify-only]
set -euo pipefail
REPO="bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF"
REPO="ornith-ai/Ornith-1.5-9B-GGUF"
BASE="https://huggingface.co/${REPO}/resolve/main"
DEST=""
@@ -31,12 +35,12 @@ for arg in "$@"; do
*) DEST="$arg" ;;
esac
done
DEST="${DEST:-/home/dank/models/MiMo-Q5KM}"
DEST="${DEST:-/home/dank/models/Ornith-Q8}"
# name bytes sha256
# name bytes sha256 (the sha256 is the LFS oid in the repo's pointer file)
FILES=(
"MiMo-V2.6-Distill-Qwen-9B-Q5_K_M.gguf 6876124704 04cf46f2b5584ef922697d6c063f94879434fb86c467f1a09e52daf0cddaf35f"
"mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf 918166048 ff348f3180a63188aa7285db85f550fe38acb61dd013c599eb8bad08d2cc2576"
"Ornith-1.5-9B-Q8_0.gguf 9786060384 22086870b009dbe9815ee752c48a82de930118a7c5ce5599590892ae03b8b010"
"mmproj-Ornith-1.5-9B-BF16.gguf 921704672 626f9f90627402a6bf4a999111d0fbd69b5fcca7aa8ba089d69e5f10e8858e1d"
)
mkdir -p "$DEST"
+107 -79
View File
@@ -1,85 +1,113 @@
# frank-large — ROCmFPX llama.cpp serving
# kingjones777/Qwen3.8-27B-ROCmFP4-STRIX-MTP-GGUF (Q8_0_ROCMFPX_AGENT, ftype
# 115) + its Q4_0 MTP draft head, on the Strix Halo iGPU (gfx1151), :7770.
# frank-large.env — tunables for the frank-large endpoint on Dankclaw's
# Strix Halo iGPU (gfx1151), host port 7770.
#
# 2026-10-02 (Frank): "the highest quality of that repo, one full context,
# whatever runtime it requires". The highest-quality rung is Q8_0_ROCMFPX_AGENT
# (26.28 GiB, 8.39 bpw: 187 tensors at plain Q8_0 + 311 at
# GGML_TYPE_Q8_0_ROCMFPX); the AGENT routing is also the variant the card
# tells you to take with MTP, because it keeps the output-head tensors at full
# Q8_0 (187 vs 2), which is what the draft scores against. The runtime the
# format REQUIRES is the ROCmFPX fork — type 103 exists nowhere else.
# RUNTIME (2026-10-05, Frank): halogen — the closed-source C++/HIP engine
# (github.com/peonist-ai/halogen-server, image ghcr.io/peonist-ai/halogen,
# digest-pinned below) serving peonist-ai/halogen-qwen3.8-27b, a ~6.32 bpw
# NVFP4-derivative quant of the BASE Qwen/Qwen3.8-27B (35,865,565,184 B,
# 1352 tensors), as a podman container under a root unit, driven by
# scripts/serve-frank-large.sh. This CUTS OVER the
# 2026-10-02..10-03 ROCmFPX llama.cpp shape (uncensored
# Q6_0_ROCMFPX_AGENT + MTP, 27.6-28.1 t/s): rollback = revert the swap
# commit's trio; the ROCmFPX payload, draft head and build all stay on
# disk at /home/dank/models/kingjones-Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP/.
#
# The shape: one session at the model's full native window (262,144), which is
# the whole point — the Q8_0 head costs quality the fleet wanted back, and
# the draft head pays for the bandwidth the bigger file spends.
# WHY THE RUNTIME MOVED WITH THE MODEL: the payload is .hgn — per its own
# card it "will not load in transformers, vLLM, or llama.cpp". Accepted
# consequences (Frank, 2026-10-05): TEXT-ONLY (halogen ignores the model's
# vision encoder; the fleet's vision path is frank-small) and the CENSORED
# base (this is a finetune of Qwen/Qwen3.8-27B, not the abliterated file —
# refusals may return). What it buys: vendor-measured decode DFlash2 31.7
# t/s mean (vs this endpoint's MTP 27.6-28.1 and the old halogen-era MTP
# 26.9), prefill ~566 t/s at 32k (was ~400 on the sibling engine), 1.4-2.3x
# wall-clock on prefill-heavy agent traffic, at HIGHER effective precision
# (~6.32 bpw vs ~6.6 with the old W4A4 promotion — see FLAGS.md).
#
# Budget (from the GGUF header, not from a boot log): attention KV lives only
# in the 16 full-attention layers (4 KV heads x 256 head_dim), 64 KiB/token
# with f16 K and V => 16.0 GiB at 262,144. The 48 Gated DeltaNet layers hold
# ~144 MiB of fixed state per sequence. Weights 26.28 + draft head 1.57 +
# mmproj 0.87 + KV 16.0 + state 0.14 + compute buffers ≈ 45-47 GiB against the
# iGPU's ~114 GiB GTT aperture, so f16 KV (the unquantized choice) is affordable
# here in a way it is not on frank-small's 24 GiB card.
# SHAPE — the two knobs that are NOT free:
# HALOGEN_KV_SLOTS=1 KEEPS SPECULATION ON. halogen makes batching and
# speculation mutually exclusive: with >1 slot the drafter is disabled
# and per-stream decode falls to serial speed (the sibling engine
# measured 41 t/s -> 14 t/s when slots were raised, and halogen's own
# FLAGS.md prints the same law). frank-large was already a one-session
# endpoint under ROCmFPX (PARALLEL=1), so slots=1 is the same contract,
# not a regression.
# HALOGEN_SLOT_CTX=200700 (Frank's window) is the per-conversation hard
# ceiling — a prompt over it is a 400 naming the limit, never a silent
# truncation. KV pool = slots x slot_ctx x 64 KiB = 12.2 GiB. Budget
# against the 121 GiB of unified memory: weights 33.4 GiB (mmap'd — page
# cache, not anon), KV 12.2, engine working set + auto prompt cache
# ~20-30, host baseline ~10 -> ~75-85 GiB in use. The 2026-09-16 halogen
# era ran the same engine class on this box with LESS headroom.
# HALOGEN_DRAFTER=2 (DFlash2) is the shipped default and the fastest
# exact drafter on this checkpoint; the .hgn BAKES BOTH drafters (MTP +
# DFlash2), so unlike the Flash-Next era there is no sidecar to mount
# and no --dflash flag — the drafter is an int knob, byte-identical
# output to greedy verification either way.
#
# CLIENT CONTRACT deltas vs the ROCmFPX shape (the harness side lives in
# ~/.omp/agent/models.yml):
# * NO server-side sampler defaults: a request without temperature
# decodes GREEDY. The harness sends temp 1.0 / top_p 0.95 via
# extraBody, so that wire recipe is load-bearing now, not cosmetic.
# * max_tokens defaults to 8192 PER REQUEST and INCLUDES thinking; a
# reasoning reply over a small budget returns empty content with
# finish_reason "length". The harness maxTokens is 57344: compaction
# fires at 70% of 200700 = 140,490 and 140,490 + 57,344 = 197,834 <=
# 200,700 keeps prompt + budget inside the pool (the 262144-era 65536
# would overflow). Requests over HALOGEN_MAX_TOKENS_CAP are a 400
# naming the cap; cap and queue timeout are COUPLED (see FLAGS.md
# table) — do not move one without the other.
# * response_format json_object/json_schema is a 400 (no constrained
# decoding in this build).
# * Thinking lands in reasoning_content — same field as the llama.cpp
# era, so the harness compat block does NOT change. reasoning_effort
# accepts none|low|xhigh on this build (template default xhigh); the
# models.yml fold (high->xhigh) still applies.
# * The engine's token protocol has no auth and binds loopback INSIDE
# the container; only the frontend port is published. Keep it that way.
# The qualified ROCmFPX fork build (scripts/build-rocmfpx.sh pins the commit).
LLAMA_BIN=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server
# One directory per HF repo, created by scripts/download-frank-large-kingjones.sh.
MODEL_FILE=/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf
# The draft head: Q4_0, NOT the Q8_0 head. The head's per-step cost is its
# projection over a 248K vocabulary, so the cheaper head is faster end to end
# at equal-or-better acceptance (card: +6.0% at n-max 1, +10.2% at n-max 2).
DRAFT_FILE=/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/mtp-Qwen3.8-27B-Q4_0.gguf
# Vision tower, separate from the LLM file. Empty string = text-only.
MMPROJ_FILE=/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/mmproj-Qwen3.8-27B-BF16.gguf
# Image: digest-pinned (fetch law: tag pulls silently re-point; the digest
# is what was qualified). 0.1.4 = the version the model card names.
FRANK_LARGE_IMAGE=ghcr.io/peonist-ai/halogen@sha256:dc0a39a0016d6cfc58a197978febaafdf8d28403f724ded6111d98b5fb7ac0ea
# Container identity: the halogen container name.
FRANK_LARGE_CONTAINER=frank-large-halogen
# Old quadlet-era container identity; the serve script clears stale names.
FRANK_LARGE_LEGACY_CONTAINERS=frank-large
# host:container — the frontend listens on 8731 inside (HALOGEN_API_PORT
# default); the engine port (8730) is never published.
FRANK_LARGE_PUBLISH=7770:8731
# Fleet endpoint: same port and alias as every frank-large before it, so the
# harness models.yml id and the homepage probe do not move.
ALIAS=frank-large
# HIP reads a bare HOST, so this is BIND_ADDR — the same reason the file
# avoids a bare TEMP.
BIND_ADDR=0.0.0.0
LISTEN_PORT=7770
FRANK_LARGE_MODEL_DIR=/home/dank/models/halogen-qwen3.8-27b
FRANK_LARGE_CHECKPOINT=qwen3.8-27b-p1w4d-d2.hgn
FRANK_LARGE_CHECKPOINT_BYTES=35865565184
FRANK_LARGE_CHECKPOINT_SHA256=274c3fc767fb57faf025dd03c76376b1fc1b32448aef45aaf02d1898fa962089
# The tokenizer is a FLAT DIRECTORY, $FRANK_LARGE_MODEL_DIR/tokenizer, mounted
# whole at /tokenizer — the model card ships `tokenizer/` and the engine's
# HALOGEN_TOKENIZER default is that directory (the card's own docker run sets
# no tokenizer flag). The Hub stores it as 5 files (sizes from the Hub API,
# 2026-10-05): tokenizer.json 12809320 (LFS sha256
# 0997f410c57a1f4e53b09e4be8f4a172d90edd9564368fb0847030937229b9f3),
# vocab.json 6722759, merges.txt 3353259, chat_template.jinja 8952,
# tokenizer_config.json 17928 (plain git blobs — size-checked, no LFS oid).
# The serve gate and the deployer refuse a missing file or ANY symlink: an
# HF cache snapshot symlinks each file into blobs/, and those links dangle
# inside the container mount.
FRANK_LARGE_TOKENIZER_FILES="tokenizer.json chat_template.jinja tokenizer_config.json merges.txt vocab.json"
# One full native window. llama.cpp divides --ctx-size across --parallel
# slots, so 262144/1 IS the model's trained window; the card validated 65,536
# and the fleet has been running this family at 262,144 since the gufo era.
CTX_SIZE=262144
PARALLEL=1
# Deck knobs. --ubatch-size has a measured optimum on this stack (512 peak on
# the sibling qwen35 model; larger ubatch costs GiBs of compute buffer for no
# speed), and --fit must stay off: --fit's auto placement does not know that
# 311 tensors of type 103 belong on the iGPU.
BATCH_SIZE=2048
UBATCH_SIZE=512
NGL=999
FIT=off
FLASH_ATTN=on
# Unquantized KV — the quality-first choice, affordable at one slot.
CACHE_TYPE_K=f16
CACHE_TYPE_V=f16
# Speculative decode: the model ships a standalone MTP head (mtp-Q4_0.gguf,
# arch qwen35, nextn_predict_layers=1, a 65-block draft). n-max 4 with n-min 0
# and p-min 0.0 is the card's measured optimum (+31% end to end, acceptance
# 0.926; its own n-max 8 measurement only reached 0.757 acceptance and LOST).
# The draft runs entirely on the iGPU, so cross-device traffic stays off the
# bandwidth-limited link.
SPEC_TYPE=draft-mtp
SPEC_DRAFT_NGL=99
SPEC_DRAFT_DEVICE=ROCm0
SPEC_DRAFT_N_MAX=4
SPEC_DRAFT_N_MIN=0
SPEC_DRAFT_P_MIN=0.0
# The card's thinking sampling profile, which is also what the harness sends
# in extraBody, so the endpoint behaves the same to a client that omits it.
TEMPERATURE=1.0
TOP_P=0.95
TOP_K=20
MIN_P=0.0
PRESENCE_PENALTY=0.0
NUM_THREADS=16
THREAD_BATCH=16
# --- engine flags (docs/FLAGS.md of the engine repo; anything unset stays
# at the image default, which is what was qualified) ------------------------
HALOGEN_MODEL_ID=frank-large
HALOGEN_SLOT_CTX=200700
HALOGEN_KV_SLOTS=1
HALOGEN_DRAFTER=2
# Coupled pair (FLAGS.md table): the cap bounds how long ONE request holds
# the GPU, the timeout bounds how long the NEXT client waits. 65536 at
# ~10 t/s serial worst case needs > 6550 s queue timeout; the defaults
# honor that. Do not lower the timeout alone.
HALOGEN_MAX_TOKENS_CAP=65536
HALOGEN_QUEUE_TIMEOUT=7200
# Engine prompt cache: EMPTY = auto (sized from available memory at
# startup). Pin an explicit MB only to argue with the auto figure — a
# full-context entry is ~18 GB at 262K, so a too-small pin yields a cache
# that reports enabled and never hits (the engine warns at startup).
#HALOGEN_CACHE_MB=
+12 -3
View File
@@ -1,5 +1,5 @@
[Unit]
Description=ROCmFPX llama.cpp (gfx1151) kingjones777 Qwen3.8-27B Q8_0_ROCMFPX_AGENT, MTP spec decode, 262144 ctx / 1 session, root systemd unit (dankclaw, frank-large, Strix Halo iGPU)
Description=halogen container (peonist-ai/halogen-qwen3.8-27b, DFlash2 spec decode, 1 slot x 200700, text-only), root systemd unit (dankclaw, frank-large, Strix Halo iGPU)
After=network-online.target
Wants=network-online.target
@@ -13,10 +13,19 @@ Group=dank
LimitMEMLOCK=infinity
Environment="FRANK_LARGE_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-large.env"
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-large.sh
# always, not on-failure: an engine that drains and exits 0 after a worker
# fault would otherwise leave the endpoint down until someone notices.
# systemd stop alone cannot stop the container child; stop it explicitly
# (frank-primary pattern). The engine drains on SIGTERM.
ExecStop=/usr/bin/podman stop -t 60 frank-large-halogen
TimeoutStartSec=1200
TimeoutStopSec=120
# always, not on-failure: an engine that exits 0 after an internal fault
# would otherwise leave the endpoint down until someone notices.
Restart=always
RestartSec=10
# The engine mmaps 33.4 GiB of weights and keeps a multi-GiB working set
# on a box that also hosts frank-small, ComfyUI and the Caddy/observability
# stack; make it a late OOM candidate, not the first.
OOMScoreAdjust=-500
[Install]
WantedBy=multi-user.target
+316 -83
View File
@@ -1,45 +1,35 @@
# frank-primary.env — tcclaviger RDNA4 vLLM production config (dankserver :7770).
#
# 2026-10-01 cutover to the BF16-KV no-spec incumbent (the overnight redo
# campaign's verdict, Frank's call): same digest-pinned engine and payload as
# the 2026-09-29 cutover, but the KV cache is UNQUANTIZED (auto = bf16) and
# speculative decode is GONE. Git tag frank-primary-fp8-mtp-20261001 is the
# last pre-cutover commit; the old shape (fp8 KV + MTP4) is in history and
# restorable from the tag.
#
# Every number here was QUALIFIED by boot probe on 2026-10-01 (not inherited):
# * MAXLEN 241664 x SEQS 3 (724,992 tokens) at bf16 KV is the DEPLOYED
# shape (2026-10-01 night ladder): KV_BYTES 10,184,000,000 PER RANK,
# engine measured "KV cache size: 732,359 tokens; Maximum concurrency
# for 241,664 tokens per request: 3.03x". 262144 and 245760 both OOMed
# at graph capture (76 MiB short / 0 bytes free); 237568 passed at
# 718,606 tokens and the +4096 bonus step passed and won.
# * KV_BYTES is PER RANK. The old "7.2 GiB ceiling, 9/16 GiB OOM" law was
# the MTP4 WORKSPACE: the spec-era pool measured 729,545 tokens because
# the drafter reserved ~2.6 GiB/rank beside the pool. No-spec bf16 takes
# 11.05 GiB/rank and boots clean — the ceiling was spec's, not VRAM's.
# * bf16 KV doubles the attention-KV bytes/token (measured 14,047 vs
# 7,926) and costs ~14% decode vs fp8 at 4064 ctx (49.25 -> 36.42 with
# the gather win) — traded deliberately for clean cache + 262k windows.
# * GATHER 32x64 is the night's biggest lever: R4D_LRU_GATHER_CHUNKS=32 /
# R4D_LRU_GATHER_LANES=64 measured +18.7% decode vs default geometry,
# and the win grows with depth (39.5@166 -> 54.1@1087 -> 49.3@4064 fp8
# era). Do not drop it.
# * MNBT 1024 is Frank's lock: MNBT 512 doubles TTFT at 4064 ctx (2.46 s
# vs 1.36 s); 1024-4096 are interchangeable on decode; 12288 costs 2.5%
# and arena. 1024 keeps the workspace floor so the arena stays max.
# * PREFETCH IS ABSENT BY LAW: expert-cache telemetry measured the
# prefetcher at 36% precision (36,720 slabs prefetched, 13,369 served) —
# roughly half the gather traffic was waste. Never re-add
# --expert-prefetch without a fresh A/B.
# * TunableOp stays OFF: CLAV_TUNABLEOP_SWEEP silently skips under expert
# offload unless CLAV_TUNABLEOP_WITH_OFFLOAD=1, and forcing it corrupts
# numerics.
# 2026-10-03 shape G (as shipped, after the 1.00 revert): the CUDA-graph plan
# term dropped from the analytic 0.55 GiB to the measured 0.07 GiB (0 base +
# 12 MiB/graph), and UTIL at the 0.982 law. MEASURED on the live deploy: arena
# 17.77 GiB / 7,762 slots, KV 401,408 tokens / 2.00x concurrency (the first
# shape holding two full-length conversations with real spare), driver used
# 30.97 GiB of 31.86 — 0.89 GiB of real free VRAM per card, held through a
# live 4,096-token generation. UTIL stays 0.982 ALWAYS: 1.00 is unbootable on
# this fork (worker init refuses budget >= device total) and 0.13-GiB-margin
# boots die under load.
# `budget - non-expert - KV reserve - graphs - overhead` and the KV reserve is a
# registry constant, so those two terms are the whole flag surface.
# 2026-10-03 shape F (absorbed): MNBT 2048 + the graph term pinned. Measured on
# the live deploy: arena 17.52 GiB / 7,653 slots, budget 31.29, graphs 0.32.
# 2026-10-02 shape E: fp8 KV, MAXLEN 200704 x SEQS 3, pool 3.15e9/rank, and
# --expert-precise-memory ON. The window shrink from the native 262144 and the
# --max-num-seqs 3 lock both come from shape D (SEQS=2 CANNOT boot this model —
# the measured 2x2 is below); shape E adds the precise-overhead plan, which
# trades the default 7.60 GiB compile headroom for the fitted 5.25 GiB and puts
# the 2.35 GiB difference into the expert arena: 6,496 -> 7,522 slots (+16%).
# The pool is deliberately NOT cut (option A): the pool is not an arena lever
# (measured), so 3.15e9 keeps shape D's 1.99x concurrency while the arena grows.
# Same digest-pinned tcclaviger RDNA4 fork. Rollback for the fp8+MTP4 era is git
# tag frank-primary-fp8-mtp-20261001.
#
# HIP reads bare env vars TEMP, HOST, GPU_MAX_HEAP_SIZE — every key here is
# prefixed FRANK_PRIMARY_ so none can leak.
# prefixed FRANK_PRIMARY_ so none can leak. The one bare name we WANT inside the
# container (GPU_MAX_HW_QUEUES) is held here under the prefix and passed on the
# run line as `-e GPU_MAX_HW_QUEUES=…`.
#
#
# --- runtime ----------------------------------------------------------------
# Digest-pinned: the floating :latest tag moved once already. Local alias
# qwen-fp8-runtime:tuned-20260929 (image ID 3322dbbbc96d) points at the same
@@ -59,63 +49,306 @@ FRANK_PRIMARY_PUBLISH=7770:7770
# hoc experiment identity (both hold the GPUs / name registrations).
FRANK_PRIMARY_LEGACY_CONTAINERS="frank-primary-r9v qwen-fp8"
# --- qualified engine shape (2026-10-02, Frank's own correction) ---
# 2026-10-01 left a shape that did not stay up: bf16 KV at 2 x 262144 with
# --expert-precise-memory crash-looped at graph capture (the arena is
# DEVICE-resident and the shape had 0.13 GiB to spare), and the plain bf16
# 2 x 262144 boot booted but left 0 bytes free at runtime. This is the
# correction: FP8 KV, TWO windows, each 200704 tokens, and the pool cut so
# the expert arena gets the room back.
# --- qualified engine shape (2026-10-02, shape E) ---------------------------
# WHAT THE SLOT COUNT DOES AND DOES NOT DO. The endpoint advertises 3 slots
# (SEQS 3), but the POOL, not the slot count, decides how many full-length
# conversations are resident. MEASURED LIVE 2026-10-02 at this shape:
# GPU KV cache size: 398,658 tokens,
# Maximum concurrency for 200,704 tokens per request: 1.99x
# (3,146,129,408 B reserved — 7,892 B/token). The per-token price is
# SHAPE-DEPENDENT, not a constant: the 262144 shape priced at 7,701 B/token
# (6,100,000,000 B -> 792,069 tokens), ~2.5% cheaper, so never extrapolate a
# concurrency figure across geometries. Shorter requests use the spare blocks,
# and a third LONG request queues rather than being refused. Verified: three
# concurrent 67-token requests answered in parallel, ~4.1 s each.
#
# LADDER (2026-10-01 night, subtract-16k method): 262144 and 245760 OOM at
# graph capture; 237568 passed at 718,606 tokens; 241664 x 3 passed at
# 732,359 tokens / 3.03x but is the shape this cutover retired. 200704 was
# chosen to sit below the 241664 rung while leaving a full 2.95 GiB/rank of
# pool to cut loose for the arena — the two contexts are a capacity
# contract, not a throughput one (2-way concurrency summed BELOW parity at
# 4064 ctx), so the slot buys the VRAM headroom long generations need.
# WHY NOT --max-num-seqs 2 (measured 2026-10-02, single-variable boot probes
# against the then-deployed known-good shape, production stopped for each):
# SEQS 2 + MNBT 1024 -> Engine core initialization failed
# SEQS 2 + MNBT 2048 -> Engine core initialization failed
# SEQS 3 + MNBT 1024 -> healthy (792,069 tokens, 3.02x, arena 6,248)
# SEQS 3 + MNBT 2048 -> healthy (the deployed incumbent)
# The failure is torch.fx ConstraintViolationError: query_start_loc.size()[0]
# "marked dynamic but your code specialized it to be a constant (3)", raised
# from AOT build_guards(strict_error=True) during profile_run -> _dummy_run.
# 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: 3 at SEQS 2, 4 at SEQS 3. MNBT only moves the
# compile RANGE ((1,1024) vs (1,2048)) and cannot dodge it — the 2x2 above is
# why MNBT is not a lever here. --enforce-eager bypasses the path but loses
# CUDA graphs. Same failure class killed the 2026-10-01 bf16 2 x 262144 shape.
#
# KV_BYTES is PER RANK. fp8 costs 7,701 B/token (measured 6,138 attention
# + 1,563 mamba/side, dtype-independent). 2 x 200704 = 401,408 tokens
# -> 3.091e9 B floor, pinned at 3.15e9 (~405,000 tokens, ~44 MB margin —
# the same margin convention the 6.1e9 shape used).
# THE ARENA LEVER IS max_model_len, NOT KV_BYTES (measured 2026-10-02). The
# planner sizes expert cache as budget - non-expert - KV reserve - graphs -
# overhead, and the KV reserve is registry-priced at 9190 B/token x max_model_len:
# MAXLEN 262144 -> reserve 2.48 GiB -> arena 14.30 GiB / 6,248 slots
# MAXLEN 200704 -> reserve 1.91 GiB -> arena 14.87 GiB / 6,496 slots
# d(reserve) 0.57 GiB = d(arena) 0.57 GiB = +248 slots. The 6.1e9 -> 3.15e9
# pool cut buys ZERO arena; it buys runtime HEADROOM (the shape it replaced
# died with 0 bytes free on a real 995-token request). To grow the arena,
# lower MAXLEN — not KV_BYTES. The OTHER arena lever is the overhead term:
# --expert-precise-memory (shape E) takes it from 7.60 to 5.25 GiB and lands
# those 2.35 GiB in the arena — 14.87 -> 17.22 GiB / 6,496 -> 7,522 slots.
#
# THE ARENA IS WHAT REMAINS: the fork's expert planner sizes it AFTER the
# KV override, so every byte cut here is arena. The 6.1e9 shape ran an
# arena of 6,248 slots; this one reclaims ~2.95 GiB/rank, roughly 1,260
# more slots at 2.344 MiB each. That is the point of the change — the
# gather read path is the measured decode bound, and a bigger arena means
# more experts resident instead of streamed.
#
# MNBT 1024 (down from 2048): 1024-4096 are interchangeable on decode AND
# identical on arena (the sweep measured the same slot count at every
# rung <= 4096), so this is a pure workspace-floor choice, not a decode
# trade. The floor matters because the arena budget is what is left.
#
# PRECISE MEMORY stays DROPPED: it crashed the 2 x 262144 bf16 boot
# (weights 6.28 + pool 6.80 + graphs 0.55 + arena 16.65 + non-torch ~1.45
# = 31.73 of 31.86 GiB, ~0.13 GiB free, NRestarts=3). The flag survives in
# the serve script as an opt-in; re-enable it only with a pool/context
# reduction that actually pays for the extra overhead.
#
# TUNABLEOP stays OFF (silently skipped under offload unless
# CLAV_TUNABLEOP_WITH_OFFLOAD=1, and then corrupts numerics).
#
# SEQS 2 x MAXLEN 200704 at FP8 KV, pool cut for the arena. No
# speculative decode, no prefetch.
# GATHER 32x64 stays: R4D_LRU_GATHER_CHUNKS=32 / R4D_LRU_GATHER_LANES=64
# measured +18.7% decode vs default geometry and the win grows with depth.
# Do not drop it.
# CHUNKED PREFILL: the fork exposes the standard scheduler flags. --max-num-batched-tokens
# (MNBT) is the lever; --enable-chunked-prefill is an auto-flag (True in SchedulerConfig)
# and --prefill-schedule-interval is the gap between iterations. The running shape carries
# --max-num-batched-tokens 2048 with --max-model-len 200704, so chunked prefill is ON and
# already active: a 200704-token prompt is prefilled in ~98 iterations of 2048 tokens.
# MEASURED 2026-09-29 on the ad hoc qwen-fp8 container (same fork, same payload, TP=2):
# MNBT 8192 vs the default 2048 is NEUTRAL on decode at every depth, and small values
# re-chunk prefill only — they hurt TTFT and never touch decode, because a decode step is
# max_num_seqs x (spec+1) tokens, independent of MNBT. There is NO --ubatch-size
# equivalent in vLLM: that is a llama.cpp compute-buffer concept (the frank-small pair
# doubled on 2026-10-05 has no counterpart here). MNBT is therefore NOT a lever on this
# shape — the deployed 2048 is the fork's default — and every change re-enters the
# compile RANGE path, so FRANK_PRIMARY_CACHE_DIR pays one recompilation.
# MNBT 2048 (Frank's call, 2026-10-03; the lock was 1024). The measured law
# still holds: MNBT 512 doubles TTFT at 4064 ctx (2.46 s vs 1.36 s), 1024-4096
# are interchangeable on decode, 12288 costs 2.5% plus arena. 2048 is ARENA-FREE
# by construction: the registry's overhead model is
# base + 0.22 GiB x max(0, (nbt - 4096)/1024) with the anchor at nbt 4096, so
# every rung up to and including 4096 prices the SAME 5.25 GiB overhead — and
# the fitted 5.25 was fitted at nbt 4096, i.e. our 2048 sits UNDER the anchor,
# so the activation term is covered by a figure measured at a bigger batch.
# SEQS 3 boots at both 1024 and 2048 (measured 2026-10-02; the SEQS=2
# ConstraintViolationError is a SEQS property, not an MNBT one). Costs exactly
# one recompilation: the compile RANGE is part of the cache key, so
# FRANK_PRIMARY_CACHE_DIR pays for whichever rung is chosen once.
# PREFETCH IS ABSENT BY LAW: expert-cache telemetry measured the prefetcher at
# 36% precision (36,720 slabs prefetched, 13,369 served) — roughly half the
# gather traffic was waste. Never re-add --expert-prefetch without a fresh A/B.
# TunableOp stays OFF: CLAV_TUNABLEOP_SWEEP silently skips under expert offload
# unless CLAV_TUNABLEOP_WITH_OFFLOAD=1, and forcing it corrupts numerics.
# --expert-precise-memory is ON (shape E). It selects the fitted overhead
# (5.25 GiB) over the default (7.60 GiB = fitted + compile/graph-capture
# headroom); the 2.35 GiB difference lands in the expert arena. MEASURED
# 2026-10-02 by a boot probe on the shape-D base (production stopped, candidate
# booted AS the service, plan read off the engine's own log):
# runtime overhead 7.60 -> 5.25 GiB (registry Qwen4Exp…, precise
# gfx1201 TP2 nbt4096/seqs16/len262144)
# expert cache 14.87 -> 17.22 GiB
# slots 6,496 -> 7,522 (+1,026 = +16%, 2.344 MiB each)
# swap region 13.7 GiB / 5,986 slots, 133-134 per layer
# boot healthy in 420 s, no OOM, no knife-edge
# The flag was called a non-option after it crash-looped the 2 x 262144 bf16
# boot (0.13 GiB free of 31.86 GiB, NRestarts=3 at ~6.7 min each) — that shape
# was already at the ceiling, so precise-memory was the last 2.35 GiB over, not
# the cause. At 200704 x 3 it fits with room; the run line also reports
# 'expert_precise_memory': True in non-default args, so a silent argparse drop
# would be visible in the boot log.
FRANK_PRIMARY_PRECISE_MEMORY=1
# GPU_MAX_HW_QUEUES caps the HSA hardware queues per GPU (AMD's own tuning
# guide recommends 2 for this class of workload; the runtime default on Linux
# is 4). Verified a REAL knob, not a no-op: the string is present in
# /opt/rocm/core-10.0/lib/libhsa-runtime64.so. NOT A/B MEASURED — Frank's call,
# shipped as a deliberate CPU-side tune alongside the 32x64 gather geometry.
FRANK_PRIMARY_GPU_MAX_HW_QUEUES=2
# Persistent compile cache. MEASURED 2026-10-02: the image ships NO
# /root/.cache at all (`ls: cannot access '/root/.cache'` in a fresh
# container), and after our boot it holds /root/.cache/vllm at 1,154,714,333 B
# in 4,665 files plus /root/.cache/comgr at 229 MB — both inside the
# container's ephemeral layer, so every `podman rm -f` (every deploy, restart
# and reboot) throws them away and re-pays the build. The engine's own log
# names the loss: `torch.compile took 181.20 s in total`, of which 'Compiling a
# graph for compile range (1, 1024) takes 173.19 s', followed by 'saved AOT
# compiled function to /root/.cache/vllm/torch_compile_cache/…'. Mounting a
# host dir at /root/.cache persists the vLLM AOT/torch_compile tree AND the
# comgr HIP-kernel JIT cache, and hides nothing (the image has no such dir).
# PROVEN LIVE 2026-10-02, two boots of this exact shape:
# cold `Compiling a graph for compile range (1, 1024) takes 179.24 s`
# `torch.compile took 188.26 s in total`
# 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)`
# (Total time to /health is dominated by the 172.8 GiB payload load, not the
# compile — the saving is the ~184 s of inductor/AOT build.)
# Safe to grow/prune: entries are keyed by version + config hash, so a stale
# entry is a miss, never a wrong answer.
FRANK_PRIMARY_CACHE_DIR=/home/dank/.cache/frank-primary
FRANK_PRIMARY_MAXLEN=200704
FRANK_PRIMARY_SEQS=2
FRANK_PRIMARY_KV_BYTES=3150000000
FRANK_PRIMARY_SEQS=3
# The POOL is deliberately kept at shape D's 3.15e9 (option A): because the pool
# is not an arena lever, raising it back from a cut buys concurrency only, and
# 3.15e9 preserves the measured 1.99x while precise-memory grows the arena. A
# 2.2e9 pool was probed in the same window (277,686 tokens / 1.38x) and rejected
# as the shipped value; it remains the conservative arm if free VRAM ever binds.
# KV forced to 400,700 tokens (Frank, 2026-10-03). The engine prices this pool
# at 7,891.8 B/token at this shape and loses ~3.87 MB to block alignment, so
# 3,166,500,000 B is the ask that lands at/above 400,700 (verify the reported
# "GPU KV cache size" line on every deploy; it is not a constant ratio across
# geometries — 7,701 B/token at 262144).
FRANK_PRIMARY_KV_BYTES=3166500000
FRANK_PRIMARY_KV_DTYPE=fp8
FRANK_PRIMARY_MNBT=1024
FRANK_PRIMARY_MNBT=2048
FRANK_PRIMARY_GATHER_CHUNKS=32
FRANK_PRIMARY_GATHER_LANES=64
FRANK_PRIMARY_UTIL=0.98
# PREFIX CACHING: ON. An OFF experiment ran 2026-10-05 (below) and FAILED
# to stop the corruption, so the historical flag is back.
# MEASURED 2026-10-04, 18:46:07 PDT (5.6 h into the live boot, one running
# request, prefix hit rate 48.1%): 48 gdn_hip state faults in one 0.6 s
# burst, then the process-lifetime silence cap:
# [gdn_hip] ERROR gdn_decode_r: state out of ssm-cache range: max|S|=0
# with non-finite values (slot=42 head=3 limit=3.40282e+38) fault #1-23
# [gdn_hip] ERROR gdn_decode_r: ... (slot=42 head=8 ...) fault #24-46
# [gdn_hip] ERROR: state fault report limit (32) reached; further faults
# are counted silently
# One physical SSM slot, two heads, BOTH ranks (heads 3 and 8), 23 layers
# each. The guard's own message in this build: "known Qwen3.8-Flash-Next
# (QFN) flaw under prefix caching and is being actively worked; output
# quality for this request may degrade. Mitigation:
# --no-enable-prefix-caching".
# MECHANISM: with prefix caching on, this fork puts QFN on
# mamba_cache_mode 'align' (the default-for-this-arch branch in
# config/vllm.py; 'all' is deprecated and 'none' is the no-prefix-cache
# mode, so align is the only sound mode WITH the flag). Align mode
# checkpoints each sequence's GDN (Gated DeltaNet) recurrent state into a
# mamba page every block, where block = 3136 tokens (the attention block
# size is raised to 3136 so the page sizes match exactly — the boot log's
# "Padding mamba page size by 0.13% ..."), and RE-SEEDS the state from that
# page whenever a request resumes from a cached prefix. The corrupt state
# landed at the 13th boundary: 13 x 3136 = 40,768 tokens — the "~40k" mark
# where the request's output turned repetitive/corrupted. The NaN stayed
# in the mamba page, so every later turn of that conversation re-seeded the
# same non-finite state for those two heads (silent: the fault-report cap
# was already spent), while fresh short sessions looked fine.
# RAM WAS "RULED OUT" THE SAME DAY — AND THAT WAS WRONG (RESOLVED below).
# What the evidence actually supported: zero MCE/EDAC records is the
# EXPECTED silence of consumer non-ECC memory (a silent flip raises no
# machine check); an 8 GiB pattern check over 60 s covers ~0.5 % of
# 246 GiB — no verdict on a speed setting; "4800 is the JEDEC floor on
# 5600-rated DIMMs" is a rating, not a stability proof at four-DIMM
# loading. Only the two-head NaN signature argument was load-bearing,
# and the revert test outvoted it.
# COST ACCEPTED: no prefix reuse, so each turn re-prefills the whole
# history instead of at most 3135 tail tokens (a full 40k context costs
# tens of seconds of TTFT per turn at this shape's prefill rate). A silently
# corrupted conversation is worse than a slower first token.
# RESULT (2026-10-05, later that day): THE MITIGATION DID NOT WORK. With
# --no-enable-prefix-caching deployed and mechanically verified (zero
# gdn_hip faults, a 40,771-token single-request boundary probe coherent),
# live long conversations STILL degraded at ~40k on the running endpoint
# (Frank, direct). The container serving that window logged ZERO faults
# while the corruption reproduced, so the align-mode page re-seed is not
# the (only) mechanism — see the runbook's RESULT note for the open
# hypotheses (re-fed poisoned transcript vs a fault-free long-context
# path such as fp8 KV at depth). Cost bought nothing: caching is back ON.
# The knob stays commented for a future fork build with a fixed GDN state
# path: set 1, redeploy, re-verify past 40,768 tokens (one conversation
# coherent AND zero gdn_hip lines).
# RESOLVED 2026-10-05 EVENING (Frank): IT WAS THE RAM. 4800 MT/s (in
# effect since the 2026-10-04 17:17 boot) was marginal; 4600 MT/s (since
# the 2026-10-05 12:21 boot) and the endpoint is clean — zero gdn_hip
# ERROR lines, zero MCE, and the live agent session on it past 140k
# context with no degradation. Both failure eras sit under the RAM: the
# 2026-10-04 NaN burst (corrupted weight/activation traffic poisoning a
# GDN state write — the guard-visible path) and the fault-free ~40k
# degradation (silent flips in KV/expert data, which no guard covers on
# non-ECC). CONSEQUENCE FOR THE KNOB: the OFF experiment was CONFOUNDED
# — both arms ran on the marginal speed — so it neither proved nor
# disproved the fork's align-mode flaw. Prefix caching stays ON (zero
# gdn_hip lines under heavy re-send traffic since the speed drop); flip
# the knob only if a fault recurs on stable RAM or the fork ships a fix.
# FRANK_PRIMARY_NO_PREFIX_CACHING=1
# UTIL is an ARENA lever: the fork plans `budget = device_total x util`, and at
# 31.86 GiB/card every +0.01 is +0.32 GiB of expert cache (~+139 slots), and
# 0.982 is Frank's standing law: NEVER 1.00. The 1.00 attempt (2026-10-03,
# shape G v1) died in worker init BEFORE loading a byte of weights — the fork
# refuses budget >= device total with
# ValueError: Free memory on device cuda:N (31.29/31.86 GiB ...)
# on BOTH ranks at 09:53 and 09:54, NRestarts=2 — so 1.0 is not even a runtime
# knife-edge here, it is unbootable. The two historical ceiling deaths (the
# 2x262144 bf16 boot at 0.13 GiB free; the 5x262144 boot at "0 bytes free" on a
# real 995-token request) remain the reason the margin exists. Rollback note
# for the other direction: if a request ever dies with
# `Tried to allocate ... 0 bytes free`, the first knob back is THIS number
# down a notch, not the window.
FRANK_PRIMARY_UTIL=0.982
# CUDA-graph plan term, priced at the measured capture. expert_plan.py charges
# estimate_cudagraph_memory() = BASE_MB + PER_GRAPH_MB x n_graphs (defaults
# 256/52 = 0.55 GiB for our 6-graph plan), and the estimator's own docstring
# says to tune these against the post-capture line. MEASURED twice on this
# shape: shape E reported "Graph capturing finished ... took 0.31 GiB" (TP0) and
# 0.17 GiB (TP1); shape F, with the 0.32 GiB we then charged, reported
# "took -0.06 GiB" — a NEGATIVE delta, i.e. capture fits inside memory the
# profiler had already counted. So the base pool (256 MiB) is the over-charge:
# charge 0 base + 12 MiB/graph = 0.07 GiB for the shipped no-spec 6-graph plan,
# which returns 0.25 GiB more to the arena while still paying per graph, so a
# future spec-decode shape (18 graphs) is charged 0.21 GiB rather than zero.
# Verify every deploy against the "Estimated CUDA graph memory" vs "Graph
# capturing finished ... took X GiB" lines; if capture ever goes positive and
# exceeds the charge, raise FRANK_PRIMARY_CUDAGRAPH_PER_GRAPH_MB back toward 52.
# A THIRD env var exists and is the only way to state the graph term outright:
# VLLM_CUDAGRAPH_MEM_ESTIMATE_MB pins the WHOLE term to an exact MiB total in
# one knob — estimate_cudagraph_memory() checks it FIRST and returns base/
# per-graph untouched when set (verified in the shipped image,
# v1/worker/gpu/cudagraph_mem.py). We ship the BASE/PER_GRAPH pair instead so
# a future spec-decode shape re-prices automatically; switch to ESTIMATE_MB
# only if the drift check below ever shows the pair cannot track capture.
FRANK_PRIMARY_CUDAGRAPH_BASE_MB=0
FRANK_PRIMARY_CUDAGRAPH_PER_GRAPH_MB=12
# HOST-region cap — the last arena-plan term that still autofit per boot. With
# no cap the planner's host bound is MemAvailable AT BOOT minus PLE 47.68 -
# 2 x 7.0 process - 5.0 api/engine - 5% margin (measured boot 2026-10-05:
# MemAvailable 237.97 -> 159.39 GiB usable across 2 ranks; the demand is 105.48
# = 52.74/rank x 2 ranks, 23,040 swap-target experts). The same host reads 75.4
# GiB available while running — the bound tracks whatever the co-tenants hold at
# boot, and a boot where that drops near the demand silently pins next-hottest
# experts VRAM-only instead (smaller swap region; the pinned_extra warning is
# the only trace). Pin 120 GiB: measured demand + 14% headroom, on a host with
# 246.1 GiB total. If a boot ever logs "expert-offload-mem … exceeds
# MemAvailable at plan time", THAT is the contract being broken — look at the
# co-tenant RAM before touching this number. Plan semantics verified in the
# shipped image (offloader/expert_plan.py): the cap binds as the bound, an
# exceedance only warns.
FRANK_PRIMARY_EXPERT_OFFLOAD_MEM=120
# --- optional engine knobs (unset == the flag is absent) ---------------------
# FRANK_PRIMARY_SPEC holds the WHOLE --speculative-config JSON; setting it adds
# the flag. THE RETIREMENT VERDICT WAS INVALID: c1248d4 (2026-10-02) retired MTP
# by comparing fp8+MTP4 (37.4-40.6 tok/s, 54% acceptance, 09-29) against the
# bf16 no-spec boot (36.4-39.4) — and bf16 then proved unbootable, so fp8 came
# back without MTP and fp8+spec was NEVER compared against fp8+no-spec on the
# same shape. The no-spec incumbent measures ~30 tok/s single-stream (below the
# fleet's 35 tok/s target); every vendor flash-next recipe ships MTP3; and our
# MTP4 run used the fork's default GREEDY draft sampling, while the recipes pass
# draft_sample_method=probabilistic (greedy drafts are rejected more often under
# temp>0 — the plausible cause of the 54%). MTP also re-adds drafter workspace:
# the old note says the 7.2 GiB/rank KV ceiling WAS the MTP4 workspace, and
# --expert-precise-memory spends that same budget, so an MTP arm must be tested
# with precise memory BOTH on and off.
# MTP3: '{"method":"mtp","num_speculative_tokens":3,"draft_sample_method":"probabilistic"}'
FRANK_PRIMARY_SPEC=
# FRANK_PRIMARY_COMPCONFIG holds the whole --compilation-config JSON. The
# capture-ladder law: the piecewise cudagraph ladder must contain
# num_speculative_tokens + 1 (MTP3 -> 4 -> ladder 4,8,12; MTP4 -> 5,10,15;
# MTP5 -> 6,12,18,24), because every draft step proposes n+1 tokens. The default
# ladder is multiples of 1+4 up to 512 then powers of two, so an MTP3 shape can
# miss its own step size. Pinning the ladder is ALSO the candidate fix for the
# SEQS>3 wall: query_start_loc is specialised to SEQS+1 and that constant must
# land on a captured graph size (3 is not in the default ladder, 4 is —
# measured 2026-10-02, and the vendor runs SEQS 16 on this model/TP2).
# combo_kernels false/true are BOTH in the shipped recipes, so it is not a law.
# MTP3 at SEQS 3: '{"cudagraph_mode":"PIECEWISE","cudagraph_capture_sizes":[1,4,8,12],"max_cudagraph_capture_size":12}'
FRANK_PRIMARY_COMPCONFIG=
# FRANK_PRIMARY_GEN_CONFIG holds the whole --override-generation-config JSON.
# The payload's HF generation_config.json sets temperature 1.0 / top_p 0.95 /
# top_k 20 and the engine WARNS about it on every boot, so a client that sends
# no sampling fields gets creativity it never asked for. This is the one knob
# that changes ANSWERS, not speed.
# '{"max_tokens":65536,"temperature":0.7,"top_p":0.95,"top_k":20}'
FRANK_PRIMARY_GEN_CONFIG=
# --- client contract: reasoning split, tools, preserve_thinking ----------------
# Without --reasoning-parser the engine leaves the thinking text INLINE in
# `content` with a dangling `</think>` and a null `reasoning` field (measured
# 2026-10-01: content = 'We need answer...Brief.\n</think>\n\n391') — the
# 2026-10-01: content = 'We need answer...Brief.\n\n391') — the
# malformed thinking block clients choke on. The qwen3 parser splits it
# properly. The GLM rollback (d91379d) had dropped all three of these; the
# working values are the ones ecaad36 shipped for this model.
+5 -4
View File
@@ -1,8 +1,9 @@
# frank-primary.service — tcclaviger RDNA4 vLLM endpoint (orcarouter
# Qwen3.8-Flash-Next-Uncensored-FP8, MTP4 spec decode, KV q8/q8, 600k total
# context over 3 sessions, TP=2, tiered experts, 131-shard payload) on the
# 2x R9700 pair, dankserver :7770. 2026-09-29 cutover from the R9V radiance
# fork (git tag frank-primary-r9v-q8-20260929 is the pre-cutover state; its
# Qwen3.8-Flash-Next-Uncensored-FP8, fp8 KV, NO spec decode, 3 slots x 200704
# context with the pool sized to ~2 full-length conversations, TP=2, tiered
# experts, 131-shard payload) on the 2x R9700 pair, dankserver :7770.
# 2026-09-29 cutover from the R9V radiance fork (git tag
# frank-primary-r9v-q8-20260929 is the pre-cutover state; its
# launcher/shim/setup-state machinery is retired and the state dirs stay on
# disk as rollback).
#
+112 -36
View File
@@ -1,39 +1,79 @@
# frank-small — llama.cpp (master, CUDA sm_86) serving
# bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF:Q5_K_M on the RTX 3090, :7771.
# ornith-ai/Ornith-1.5-9B-GGUF:Q8_0 on the RTX 3090, :7771.
#
# 2026-10-02 (Frank): THREE contexts, vision on, and a hard instruction to
# leave 2-3 GiB of the card free. Both new demands cost memory, so the per-slot
# window shrinks — see the budget below.
# 2026-10-05 (Frank): the payload moved off bartowski/MiMo-V2.6 (Q6_K) to
# ornith-ai/Ornith-1.5-9B Q8_0 (9,786,060,384 B, LFS oid 22086870...).
# Header-verified facts that re-price the shape: qwen35 hybrid like MiMo, but
# 33 blocks (not 32) with full_attention_interval 4 => NINE full-attention
# layers (MiMo had 8), 16 Q heads / 4 KV heads, k/v length 256, native window
# 262144, dense (no expert keys), nextn_predict_layers=1 (an MTP head is IN
# the file — capability only; no spec decode ships, same law as before).
#
# Two measurements on this card, this runtime, price the terms:
# THE KV CONTRACT STAYS f16 K + f16 V — NO KV QUANTIZATION (Frank, 2026-10-05,
# overruling a written q8_0-V proposal). The arithmetic that makes this hard:
# KV rate 9 attn layers x 4 heads x 256 x 2 B = 18 KiB/token K, same again
# for V at f16 => 36 KiB/token (MiMo was ~24.5 measured: one fewer
# full-attn layer AND one fewer KV head).
# Weights +1,900 MiB (9,334 vs Q6_K's 7,432). Measured free at the Q6_K
# 4096/2048 shape was 2,279 MiB; Q8_0 at f16/f16 does not fit that shape —
# the deficit is made up the way Frank ordered: UBATCH FIRST (2048 -> 1024,
# the measured 616 MiB rung), then POOL STEPS, then measure. Guess-and-check
# on the live card is the accepted method for llama.cpp sizing here: boot,
# read nvidia-smi free, step the pool (12,288-token increments, ~432 MiB
# each at the 36 KiB/token rate) until the 2-3 GiB floor holds. Compute
# buffers allocate at graph capture during BOOT, so a failure is loud and
# fast, not a surprise at the first long prompt.
#
# Previous shape history (MiMo era, for the rollback trail): 2026-10-03 the
# rung went Q5_K_M -> Q6_K with the pool HELD at 401,408; the freed third
# slot funded exact f16 V first; the ~3.5 GiB still free funded the +875 MiB
# of weights.
#
# 2026-10-03 (Frank): TWO streams instead of three, each one WIDE — 200,704
# tokens per conversation, above the 200,700 the harness compacts at — and the
# KV cache is now FULLY UNQUANTIZED (f16 K, f16 V). The two moves are the same
# move: llama.cpp divides --ctx-size across --parallel slots, so the slot given
# up is exactly the VRAM that pays for unquantized V. No further quantization
# anywhere on this endpoint.
#
# Three measurements on this card, this runtime, price the terms:
# 2 x 262,144, no projector: 20,190 MiB used / 4,386 free
# KV = 524,288 tok x 24.5 KiB = 12,800 MiB => non-KV (weights 6,557 +
# DeltaNet state ~25/slot + compute buffers + CUDA graphs) = 7,390 MiB
# 3 x 196,608, projector on: 22,972 MiB used / 1,153 free
# KV = 14,113 MiB => non-KV WITH the vision tower = 8,859 MiB
# The tower costs ~1.4 GiB, not the ~1.0 its file size suggested, so 196,608
# per slot left only 1.1 GiB free — outside the band. The pool therefore is
# 3 x 180,224 = 540,672 tokens: KV ~12,936 + non-KV 8,859 = ~21,800 MiB predicted.
# MEASURED at this shape: 21,684 MiB used / 2,441 MiB free — inside the band, and
# the number the bats budget gate reproduces (it prices non-KV at 9 GiB).
# 3 x 180,224, projector on: 21,684 MiB used / 2,441 free (the 2026-10-02
# shape) => non-KV = 8,748 MiB
# Two slots give back one DeltaNet state (~25 MiB). Priced at f16 K+V
# (32 KiB/token) this pool is 12,544 MiB, so the prediction is
# 12,544 + ~8,800 = ~20.9 GiB used, ~3.1 GiB free — the band the 2026-10-02
# shape hit, bought with precision instead of a slot. The 1024 ubatch
# re-prices the compute buffers upward, so MEASURE after any change: if the
# boot lands under 2 GiB free, step the pool down in 12,288-token
# (384 MiB at this rate) increments.
#
# KV arithmetic: attention KV exists only in the 8 full-attention layers
# (4 KV heads x 256 head_dim). K f16 = 16 KiB/token, V q8_0 = 8.5 KiB/token
# (34 B per 32-element block) => 24.5 KiB/token, so 12,288 tokens (=4096/slot)
# is one 293 MiB step — that is the ladder to walk if this moves again. The
# native 262,144 per slot is NOT reachable three times: 3 x 262,144 x
# 24.5 KiB = 18.4 GiB of KV before anything else is loaded.
# (4 KV heads x 256 head_dim x 2 B) = 16 KiB/token for K and the same again for
# V => 32 KiB/token, so 12,288 tokens (= 6144/slot) is one 384 MiB step — that
# is the ladder to walk if this moves again. Two native windows (2 x 262,144)
# are NOT reachable with full-precision V: 524,288 x 32 KiB = 16.1 GiB of KV
# against a card with ~15.7 GiB of KV room once weights, the vision tower, the
# DeltaNet states and the graphs are loaded. 200,704 per stream is what full
# precision buys; q8_0 on V would buy 262,144 instead.
#
# Why V stays q8_0 and K stays f16: full-precision V would add 8 KiB/token —
# +4.5 GiB on this pool — which is what pushes the card past its 24 GiB. K is
# the tensor the asymmetric-KV contract exists to protect.
# The asymmetric-KV contract this endpoint was created for (Frank, 2026-10-01
# night: f16 K + q8_0 V) is now spent — it existed to make a third slot fit,
# and the third slot is gone. The flags stay explicit so the choice is visible
# in the argv either way.
LLAMA_BIN=/home/dank/GitPackages/llama-master/build-cuda/bin/llama-server
MODEL_FILE=/home/dank/models/MiMo-Q5KM/MiMo-V2.6-Distill-Qwen-9B-Q5_K_M.gguf
MODEL_FILE=/home/dank/models/Ornith-Q8/Ornith-1.5-9B-Q8_0.gguf
# Vision: the Q5_K_M GGUF ships no `v.*` tensors, so the projector file IS the
# vision capability (sha256 ff348f31..., 918,166,048 B, fetched by
# Vision: the Ornith Q8_0 GGUF ships no `v.*` tensors either (dense-9B byte
# arithmetic: 9.79 GB ≈ 9B params x ~1.06 B/param leaves no room for a tower),
# so the projector file IS the vision capability (mmproj-Ornith-1.5-9B-BF16
# .gguf, 921,704,672 B, LFS oid 626f9f90..., fetched by
# scripts/download-frank-small.sh). Empty string = text-only on purpose.
MMPROJ_FILE=/home/dank/models/MiMo-Q5KM/mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf
MMPROJ_FILE=/home/dank/models/Ornith-Q8/mmproj-Ornith-1.5-9B-BF16.gguf
# Fleet endpoint: same port and alias as every frank-small before it, so the
# harness models.yml id and the homepage probe do not move.
@@ -41,20 +81,56 @@ ALIAS=frank-small
BIND_ADDR=0.0.0.0
LISTEN_PORT=7771
# Three independent conversations. llama.cpp divides --ctx-size across
# --parallel slots (n_ctx_slot = CTX_SIZE/PARALLEL), and a mamba-hybrid model
# cannot use unified KV, so each slot gets its own cache and its own window.
# 180,224 per slot is BELOW the native 262,144, and that is the cost of the
# third slot: a longer single prompt needs compaction instead of one shot.
# Consequence worth knowing: frank-primary's compaction trigger is 183,501
# tokens, which no longer fits a frank-small slot — see the runbook's
# "three slots vs compaction" note.
CTX_SIZE=540672
PARALLEL=3
# ONE conversation at the NATIVE 262,144 window (Frank, 2026-10-05). The
# two-slot shape died under Ornith and the NO-QUANTIZE law, by arithmetic:
# the KV rate is the ARCHITECTURE's, not the quant's — 9 full-attn layers x
# 4 KV heads x 256 dim x 2 B = 36 KiB/token at f16 K+V (MiMo measured 24.5),
# so 2 x 200,704 wants ~14.1 GiB of KV on top of +1,900 MiB of Q8 weights
# against 2,279 MiB measured free; ubatch 1024 claws back 616, and no
# pool step fits BOTH the 2-3 GiB floor and the harness compaction
# invariant (slot >= 191,148). One native slot fits with room; the window
# per conversation RISES 200,704 -> 262,144. The cost: a second concurrent
# request queues behind the released slot (subagent concurrency 2 -> 1).
CTX_SIZE=262144
PARALLEL=1
# The KV contract (Frank, 2026-10-01 night): unquantized K, q8_0 V.
# q8_0 on V is only served by the flash-attention path; the serve script
# pins --flash-attn on and must never lose it.
# Compute-buffer geometry. This endpoint passed neither flag until 2026-10-03,
# so it ran on llama.cpp's own defaults (2048/512); the pair is now 4096/2048.
# Both are tunables because the pair is a measurement, not a law — and the
# measurement is CARD-SPECIFIC. On the 2x R9700 layer-split sibling the ubatch
# curve peaks at 512 and every step up costs GiB of compute buffer for no
# speed (see frank-medium-batch-ubatch). On THIS CUDA 3090 the curve climbs
# monotonically to 2048: 710 -> 1188 t/s prefill from ubatch 1024 to 2048 on
# the 35B MoE sweep, 1944 -> 2423 t/s on the Ornith 8k sweep, with decode flat
# to under 1 t/s on every rung. Ubatch is a prefill knob; decode never moves.
#
# The logical batch is free — two independent sweeps agree that at a fixed
# ubatch, 2048/4096/8192 give identical prefill speed AND identical peak VRAM,
# because the compute buffers are sized by the ubatch alone. Doubling it costs
# nothing and buys nothing; it is here because it is the value the pair was
# measured at.
#
# THE RISK IS ALL IN THE UBATCH. The 1024 -> 2048 step cost ~1,244 MiB of peak
# graph VRAM on the 35B MoE (7,330 -> 8,574 MiB); this 9B hybrid has far
# smaller compute tensors, so the delta should be well under that, but the
# predecessor Q5_K_M shape recorded "ubatch 2048 failed the scratch boot" at
# ~3,571 MiB free. The Q8_0 rung ships UBATCH 1024 (Frank, 2026-10-05: lower
# ubatch FIRST — the measured 616 MiB step is the first VRAM the Q8 weights
# claw back, before any pool move). Compute buffers are allocated at graph
# capture during BOOT, so a failure is loud and fast, not a surprise at the
# first long prompt. MEASURE nvidia-smi free immediately after the deploy:
# under ~1.5 GiB, or a boot failure, step the POOL down next (12,288-token
# increments, ~432 MiB each at the Ornith 36 KiB/token f16 KV rate).
BATCH_SIZE=4096
UBATCH_SIZE=1024
# The KV contract (2026-10-05, Frank): f16 K + f16 V — NO KV quantization.
# The q8_0-V arithmetic was written and OVERRULED. The Q8 weights eat the
# VRAM that funded f16 V on 2026-10-03, so the remedy ladder is UBATCH FIRST
# (2048 -> 1024, the measured 616 MiB rung), then POOL STEPS — and
# guess-and-check on the live card is the accepted method: boot, read
# nvidia-smi, step down until the floor holds. --flash-attn stays pinned on
# in the serve script: it is the fast path for f16 KV.
CACHE_TYPE_K=f16
CACHE_TYPE_V=q8_0
CACHE_TYPE_V=f16
NGL=99
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=llama.cpp MiMo-V2.6-Distill-Qwen-9B Q5_K_M GGUF (qwen35 hybrid, 3x180224, f16 K + q8_0 V, mmproj vision) on the RTX 3090 (dankclaw, frank-small)
Description=llama.cpp MiMo-V2.6-Distill-Qwen-9B Q6_K GGUF (qwen35 hybrid, 2x200704, unquantized f16 KV, mmproj vision) on the RTX 3090 (dankclaw, frank-small)
After=network-online.target
Wants=network-online.target
+130 -163
View File
@@ -1,179 +1,146 @@
#!/usr/bin/env bash
# Serve kingjones777/Qwen3.8-27B-ROCmFP4-STRIX-MTP-GGUF as frank-large on
# Dankclaw's Strix Halo iGPU (gfx1151) with the ROCmFPX fork of llama.cpp.
# serve-frank-large.sh — frank-large endpoint: halogen (closed-source
# C++/HIP engine, image ghcr.io/peonist-ai/halogen, digest-pinned in
# frank-large.env) serving peonist-ai/halogen-qwen3.8-27b on Dankclaw's
# Strix Halo iGPU, host :7770. 2026-10-05 cutover from the ROCmFPX
# llama.cpp shape — revert the swap commit's trio to roll back (payload
# and fork build stay on disk).
#
# 2026-10-02 (Frank): swapped from gufo + Q4_K_XL to the highest-quality file
# in the repo, one session at the native 262,144 window.
# The engine is driven by `podman run` DIRECTLY (the fleet's frank-primary
# pattern): the image entrypoint `halogen-docker` in default "all" mode
# starts engine + OpenAI frontend in one container. The frontend listens on
# 8731 inside (HALOGEN_API_PORT default) and is published as host 7770; the
# engine's own token port (8730, no auth) stays unpublished on the
# container's loopback. The image ships NO default checkpoint:
# HALOGEN_CHECKPOINT selects the file inside the /models mount.
#
# Why this runtime and not the previous one: the payload is ftype 115 — 311
# tensors of GGML_TYPE_Q8_0_ROCMFPX (103), a tensor type that exists only in
# the ROCmFPX fork. Stock llama.cpp, SGLang and gufo all refuse the file, and
# gufo additionally gates every "Qwen3.8" artifact on a template-hash
# allowlist. The qualified build is scripts/build-rocmfpx.sh's pin: it loads
# ftype 115, survives warmup (the 2026-08-25 builds of the older fork line
# segfaulted in ggml_compute_forward_set_rows on this exact file), and drafts
# with the model's own MTP head.
# Gates mirror the deployer EXACTLY (the 2026-10-01 crash-loop lesson —
# serve gate and deploy gate disagreeing = green deploy, red unit):
# tokenizer directory present with every named file a FLAT REGULAR FILE —
# the model card ships `tokenizer/` as a flat directory and the engine
# defaults HALOGEN_TOKENIZER to /tokenizer, so a HF cache snapshot's
# symlinks into blobs/ dangle inside the mount; GPU device nodes present.
#
# Why this file: Q8_0_ROCMFPX_AGENT is the top of the repo's quality ladder
# (26.28 GiB, 8.39 bpw) and the AGENT routing keeps 187 tensors — the
# output-head set the draft scores against — at plain Q8_0 instead of the 2
# the non-AGENT build keeps, which is the variant the card names for MTP
# serving. Drafting uses the repo's Q4_0 MTP head: the head's cost is its
# projection over the 248K vocabulary, so the 4-bit head is faster end to end
# at equal-or-better acceptance than its Q8_0 sibling.
#
# The client contract lives here, not in frank-large.env:
# * --jinja — without it llama.cpp silently IGNORES the harness's
# chat_template_kwargs (enable_thinking / preserve_thinking), so the
# endpoint would look like it had no thinking control at all. The GGUF
# carries the official Qwen template, so no --chat-template-file.
# * --reasoning-format deepseek — thinking is emitted as reasoning_content,
# the field the harness reads for this endpoint.
# * the template accepts reasoning_effort xhigh|medium|low only, which is
# why models.yml folds the harness's levels onto that set.
# * --alias frank-large — that is the model id the harness resolves.
# * --ctx-checkpoints / --cache-ram — prompt-cache geometry for the 16
# sliding-window-style layers; harmless where the hybrid state forces a
# full re-process, and required for the checkpoint feature to work at all.
# Why containers instead of the bare binary (the old frank-large shape):
# the payload is .hgn and only halogen reads it; halogen ships as a
# container; the root unit keeps LimitMEMLOCK=infinity for parity and to
# outlive the login session, exactly like frank-primary's.
set -euo pipefail
# Fleet values live in frank-large.env. Anything already in the environment
# wins over the file, so an operator can override one value on a single run:
# the caller's values are captured, the file is sourced, then they are put
# back. (A plain `source` would let the file clobber the environment.)
TUNABLES=(LLAMA_BIN MODEL_FILE DRAFT_FILE MMPROJ_FILE ALIAS BIND_ADDR
LISTEN_PORT CTX_SIZE PARALLEL BATCH_SIZE UBATCH_SIZE NGL FIT
FLASH_ATTN CACHE_TYPE_K CACHE_TYPE_V SPEC_TYPE SPEC_DRAFT_NGL
SPEC_DRAFT_DEVICE SPEC_DRAFT_N_MAX SPEC_DRAFT_N_MIN SPEC_DRAFT_P_MIN
TEMPERATURE TOP_P TOP_K MIN_P PRESENCE_PENALTY NUM_THREADS
THREAD_BATCH ROCM_PATH HIP_VISIBLE HSA_GFX_VERSION)
_OVERRIDES=()
for _var in "${TUNABLES[@]}"; do
[[ -n "${!_var+set}" ]] && _OVERRIDES+=("$_var=${!_var}")
done
ENV_FILE="${FRANK_LARGE_ENV_FILE:-$(dirname "$0")/frank-large.env}"
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_LARGE_ENV_FILE:-$SCRIPT_DIR/frank-large.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "frank-large: env file not found: $ENV_FILE" >&2
exit 1
fi
for _kv in "${_OVERRIDES[@]+${_OVERRIDES[@]}}"; do
export "${_kv?}"
done
unset _var _kv
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${LLAMA_BIN=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server}"
: "${MODEL_FILE=/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf}"
: "${DRAFT_FILE=/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/mtp-Qwen3.8-27B-Q4_0.gguf}"
: "${MMPROJ_FILE=/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/mmproj-Qwen3.8-27B-BF16.gguf}"
: "${ALIAS=frank-large}"
: "${BIND_ADDR=0.0.0.0}"
: "${LISTEN_PORT=7770}"
: "${CTX_SIZE=262144}"
: "${PARALLEL=1}"
: "${BATCH_SIZE=2048}"
: "${UBATCH_SIZE=512}"
: "${NGL=999}"
: "${FIT=off}"
: "${FLASH_ATTN=on}"
: "${CACHE_TYPE_K=f16}"
: "${CACHE_TYPE_V=f16}"
: "${SPEC_TYPE=draft-mtp}"
: "${SPEC_DRAFT_NGL=99}"
: "${SPEC_DRAFT_DEVICE=ROCm0}"
: "${SPEC_DRAFT_N_MAX=4}"
: "${SPEC_DRAFT_N_MIN=0}"
: "${SPEC_DRAFT_P_MIN=0.0}"
: "${TEMPERATURE=1.0}"
: "${TOP_P=0.95}"
: "${TOP_K=20}"
: "${MIN_P=0.0}"
: "${PRESENCE_PENALTY=0.0}"
: "${NUM_THREADS=16}"
: "${THREAD_BATCH=16}"
: "${ROCM_PATH=/opt/rocm}"
: "${HIP_VISIBLE=0}"
: "${HSA_GFX_VERSION=11.5.1}"
: "${FRANK_LARGE_IMAGE:?FRANK_LARGE_IMAGE must name the digest-pinned image}"
: "${FRANK_LARGE_CONTAINER:?FRANK_LARGE_CONTAINER must name the container}"
: "${FRANK_LARGE_PUBLISH:?FRANK_LARGE_PUBLISH must be host:container}"
: "${FRANK_LARGE_MODEL_DIR:?FRANK_LARGE_MODEL_DIR must name the payload dir}"
: "${FRANK_LARGE_CHECKPOINT:?FRANK_LARGE_CHECKPOINT must name the .hgn inside it}"
: "${FRANK_LARGE_CHECKPOINT_BYTES:?FRANK_LARGE_CHECKPOINT_BYTES must pin the size}"
: "${FRANK_LARGE_TOKENIZER_FILES:?FRANK_LARGE_TOKENIZER_FILES must list the tokenizer dir contents}"
: "${HALOGEN_MODEL_ID:?HALOGEN_MODEL_ID must be the served alias (frank-large)}"
: "${HALOGEN_SLOT_CTX:?HALOGEN_SLOT_CTX must be set}"
: "${HALOGEN_KV_SLOTS:?HALOGEN_KV_SLOTS must be set (1 keeps the drafter on)}"
: "${HALOGEN_DRAFTER:?HALOGEN_DRAFTER must be set (2 = DFlash2)}"
[[ -n "$LLAMA_BIN" ]] || { echo "frank-large: LLAMA_BIN is empty" >&2; exit 1; }
[[ -x "$LLAMA_BIN" ]] || {
echo "frank-large: llama-server '$LLAMA_BIN' is not executable;" >&2
echo " build it with scripts/build-rocmfpx.sh" >&2
# Capture podman's output BEFORE grepping: under `set -o pipefail` the
# `podman images | grep -q` pattern races (grep exits on first match,
# podman dies SIGPIPE 141, pipefail turns the 141 into "no match" — the
# 2026-09-30 deploy refusal).
DIGESTS="$(podman images --format '{{.RepoDigests}}')"
if ! grep -qF "${FRANK_LARGE_IMAGE#*@}" <<<"$DIGESTS"; then
echo "frank-large: image $FRANK_LARGE_IMAGE not in podman storage;" >&2
echo " pull it: podman pull ${FRANK_LARGE_IMAGE%@*}" >&2
exit 1
}
# Refuse anything but the real payload. A path that is not a readable file
# makes llama-server hang rather than fail clean.
[[ -r "$MODEL_FILE" ]] || {
echo "frank-large: model file '$MODEL_FILE' is not readable;" >&2
echo " fetch it with scripts/download-frank-large-kingjones.sh" >&2
fi
# The checkpoint gate is size-exact: a truncated curl exits 0 on some
# failures and a partial .hgn loads far enough to look alive, so the byte
# count from the model card is the contract (the fleet fetch law).
CKPT="$FRANK_LARGE_MODEL_DIR/$FRANK_LARGE_CHECKPOINT"
if [ ! -f "$CKPT" ]; then
echo "frank-large: checkpoint $CKPT missing;" >&2
echo " fetch it: bash scripts/download-frank-large-halogen.sh" >&2
exit 1
}
# The MTP head is the performance feature: without it the Q8_0 weights decode
# at memory-bandwidth speed and the endpoint loses the ~1.4x the head buys.
[[ -r "$DRAFT_FILE" ]] || {
echo "frank-large: MTP draft head '$DRAFT_FILE' is not readable;" >&2
echo " fetch it with scripts/download-frank-large-kingjones.sh" >&2
fi
CKPT_SIZE="$(stat -c %s "$CKPT")"
if [ "$CKPT_SIZE" != "$FRANK_LARGE_CHECKPOINT_BYTES" ]; then
echo "frank-large: checkpoint is $CKPT_SIZE bytes, expected $FRANK_LARGE_CHECKPOINT_BYTES (partial download?)" >&2
exit 1
}
# The GGUF carries no vision tower; the projector file is the whole tower, so
# a missing file means a vision request would 500 rather than degrade.
# MMPROJ_FILE="" is the documented opt-out for a text-only endpoint.
MM_ARGS=()
if [[ -n "${MMPROJ_FILE:-}" ]]; then
[[ -r "$MMPROJ_FILE" ]] || {
echo "frank-large: mmproj '$MMPROJ_FILE' is not readable; set MMPROJ_FILE='' for text-only" >&2
fi
# The payload ships the tokenizer as a FLAT DIRECTORY (model card:
# "tokenizer/ flat tokenizer directory"). A HuggingFace cache snapshot
# symlinks each file into a sibling blobs/, and those symlinks dangle
# inside the container mount — so the directory must be real and every
# named file must be a regular file, not a link.
TOK_DIR="$FRANK_LARGE_MODEL_DIR/tokenizer"
if [ -L "$TOK_DIR" ] || [ ! -d "$TOK_DIR" ]; then
echo "frank-large: tokenizer dir $TOK_DIR missing or a symlink (HF cache snapshot?);" >&2
echo " fetch it: bash scripts/download-frank-large-halogen.sh" >&2
exit 1
fi
for TF in $FRANK_LARGE_TOKENIZER_FILES; do
if [ ! -f "$TOK_DIR/$TF" ] || [ -L "$TOK_DIR/$TF" ]; then
echo "frank-large: tokenizer file $TOK_DIR/$TF missing or a symlink;" >&2
echo " fetch it: bash scripts/download-frank-large-halogen.sh" >&2
exit 1
}
MM_ARGS=(--mmproj "$MMPROJ_FILE")
fi
done
# GPU access is mandatory but the node paths are variables so the seam can
# drive the gate on a box without an AMD GPU.
DEV_KFD="${FRANK_LARGE_DEV_KFD:-/dev/kfd}"
DEV_DRI="${FRANK_LARGE_DEV_DRI:-/dev/dri}"
if [ ! -e "$DEV_KFD" ] || [ ! -d "$DEV_DRI" ]; then
echo "frank-large: $DEV_KFD or $DEV_DRI missing — no gfx1151 to serve on" >&2
exit 1
fi
for CTR in ${FRANK_LARGE_LEGACY_CONTAINERS:-} "$FRANK_LARGE_CONTAINER"; do
podman rm -f "$CTR" >/dev/null 2>&1 || true
done
# HIP/ROCm host environment. HIP reads bare env vars (TEMP, HOST,
# GPU_MAX_HEAP_SIZE), which is why the tunables above are named TEMPERATURE
# and BIND_ADDR. HSA_OVERRIDE_GFX_VERSION is what lets a runtime built for a
# neighbouring gfx target run on this iGPU; the qualified pin targets gfx1151
# natively and the override is a no-op for it.
export ROCM_PATH
export HIP_VISIBLE_DEVICES="$HIP_VISIBLE"
export HSA_OVERRIDE_GFX_VERSION="$HSA_GFX_VERSION"
export LD_LIBRARY_PATH="$(dirname "$LLAMA_BIN"):${ROCM_PATH}/lib:${ROCM_PATH}/lib/rocm_sysdeps/lib:${ROCM_PATH}/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
LOG="${FRANK_LARGE_LOG:-/home/dank/logs/frank_large.log}"
mkdir -p "$(dirname "$LOG")"
# for the udev-granted supplementary group, seccomp unconfined for the ROCm
# ioctl surface, and unbounded memlock (the unit's LimitMEMLOCK=infinity is
# inherited, so runc never has to RAISE it). NO --ulimit nofile: Dankclaw's
# hard limit is 524288, rootless runc cannot raise a hard limit and dies
# with "error setting rlimit type 7: operation not permitted" (measured
# 2026-10-05 cutover), and 524288 fds is already ~100x what the engine
# wants; the model card and both sibling serve scripts pass no nofile
# either. No comment lines inside the backslash
# chain — a mid-chain # terminates the continuation (the 2026-10-05
# exit-127 crash loop on the sibling serve script).
podman run -d --name "$FRANK_LARGE_CONTAINER" \
--publish "$FRANK_LARGE_PUBLISH" \
--ipc=host \
--ulimit memlock=-1:-1 \
--device "$DEV_KFD" --device "$DEV_DRI" \
--group-add keep-groups \
--security-opt seccomp=unconfined \
-e HALOGEN_MODEL_ID="$HALOGEN_MODEL_ID" \
-e HALOGEN_CHECKPOINT="/models/$FRANK_LARGE_CHECKPOINT" \
-e HALOGEN_SLOT_CTX="$HALOGEN_SLOT_CTX" \
-e HALOGEN_KV_SLOTS="${HALOGEN_KV_SLOTS:-1}" \
-e HALOGEN_DRAFTER="$HALOGEN_DRAFTER" \
-e HALOGEN_MAX_TOKENS_CAP="${HALOGEN_MAX_TOKENS_CAP:-65536}" \
-e HALOGEN_QUEUE_TIMEOUT="${HALOGEN_QUEUE_TIMEOUT:-7200}" \
${HALOGEN_CACHE_MB:+-e HALOGEN_CACHE_MB="$HALOGEN_CACHE_MB"} \
-v "$FRANK_LARGE_MODEL_DIR:/models:ro" \
-v "$FRANK_LARGE_MODEL_DIR/tokenizer:/tokenizer:ro" \
"$FRANK_LARGE_IMAGE" \
> "$LOG" 2>&1
exec /usr/bin/env "$LLAMA_BIN" \
--model "$MODEL_FILE" \
"${MM_ARGS[@]}" \
--alias "$ALIAS" \
--host "$BIND_ADDR" \
--port "$LISTEN_PORT" \
--jinja \
--device ROCm0 \
--n-gpu-layers "$NGL" \
--parallel "$PARALLEL" \
--ctx-size "$CTX_SIZE" \
--batch-size "$BATCH_SIZE" \
--ubatch-size "$UBATCH_SIZE" \
--threads "$NUM_THREADS" \
--threads-batch "$THREAD_BATCH" \
--fit "$FIT" \
--flash-attn "$FLASH_ATTN" \
--direct-io \
--cache-type-k "$CACHE_TYPE_K" \
--cache-type-v "$CACHE_TYPE_V" \
--ctx-checkpoints 16 \
--cache-ram 8192 \
--cache-idle-slots \
--temp "$TEMPERATURE" \
--top-p "$TOP_P" \
--top-k "$TOP_K" \
--min-p "$MIN_P" \
--presence-penalty "$PRESENCE_PENALTY" \
--reasoning on \
--reasoning-format deepseek \
--reasoning-budget -1 \
--spec-type "$SPEC_TYPE" \
--model-draft "$DRAFT_FILE" \
--spec-draft-ngl "$SPEC_DRAFT_NGL" \
--spec-draft-device "$SPEC_DRAFT_DEVICE" \
--spec-draft-n-max "$SPEC_DRAFT_N_MAX" \
--spec-draft-n-min "$SPEC_DRAFT_N_MIN" \
--spec-draft-p-min "$SPEC_DRAFT_P_MIN" \
--metrics
# This script's lifetime IS the container's: podman wait returns the
# container's exit code, so a crash is a nonzero ExecStart and the unit's
# Restart=always is real. systemd stop goes through the unit's ExecStop
# (podman wait alone cannot stop the child).
exec podman wait "$FRANK_LARGE_CONTAINER"
+73 -12
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# serve-frank-primary.sh — frank-primary endpoint (tcclaviger RDNA4 vLLM fork,
# orcarouter Qwen3.8-Flash-Next-Uncensored-FP8 + MTP4 spec decode, 2x R9700,
# orcarouter Qwen3.8-Flash-Next-Uncensored-FP8, 2x R9700,
# dankserver :7770; 2026-09-29 cutover from the R9V radiance fork — git tag
# frank-primary-r9v-q8-20260929 is the pre-cutover state).
#
@@ -11,16 +11,43 @@
# every flag here was qualified by boot probe on 2026-09-29 — see
# frank-primary.env for the qualification record).
#
# Shape (2026-10-02, Frank's correction of the 10-01 bf16 shape): MAXLEN
# 200704 x SEQS 2 (401,408 tokens of contract), KV fp8 (--kv-cache-dtype
# fp8; the bf16 unquantized shape is retired to git tag
# frank-primary-fp8-mtp-20261001), KV_BYTES 3,150,000,000 PER RANK (the
# engine measured 405,000 tokens of capacity / ~3.0x concurrency), NO
# speculative decode, --max-num-batched-tokens 1024 (Frank's lock), MoE
# gather geometry R4D_LRU_GATHER_CHUNKS=32 /
# R4D_LRU_GATHER_LANES=64 (+18.7% decode, grows with depth), prefix
# caching, expert prefetch OFF (36% precision measured), TunableOp off, and
# the pool deliberately small so the expert arena keeps what it reclaims.
# Shape (2026-10-03 shape F, on the 2026-10-02 shape E base): MAXLEN 200704 x 3
# slots, fp8 KV, KV_BYTES
# 3,150,000,000 PER RANK — the POOL, not the slot count, caps resident
# conversations at ~2 full-length (measured: 398,658 tokens = 1.99x);
# shorter requests use the spare blocks. --max-num-seqs stays 3 because SEQS=2
# CANNOT boot this model
# (torch.fx ConstraintViolationError on query_start_loc, measured at both
# MNBT 1024 and 2048 — the 2x2 lives in frank-primary.env). Speculative decode
# is OFF today by ENV KNOB, not by measurement: FRANK_PRIMARY_SPEC / _COMPCONFIG
# / _GEN_CONFIG hold the whole JSON value and unset means no flag. The 2026-10-02
# retirement of MTP compared the wrong pair — see frank-primary.env.
# --max-num-batched-tokens 2048 (Frank's call 2026-10-03; arena-free because the
# registry prices overhead flat up to its nbt 4096 anchor), MoE gather geometry
# R4D_LRU_GATHER_CHUNKS=32 / R4D_LRU_GATHER_LANES=64 (+18.7% decode, grows
# with depth), prefix caching OFF 2026-10-05 (the fork's QFN align-mode GDN
# state fault — the evidence block is in frank-primary.env), expert prefetch
# OFF (36% precision measured),
# TunableOp off. Shape E adds --expert-precise-memory (fitted 5.25 GiB overhead
# instead of the 7.60 GiB default -> expert arena 17.22 GiB / 7,522 slots, +16%)
# and GPU_MAX_HW_QUEUES=2; shape F pins the plan's CUDA-graph term to the
# measured capture cost (VLLM_CUDAGRAPH_MEM_BASE_MB / _PER_GRAPH_MB, container
# env — the analytic default charges 0.55 GiB where capture took 0.31 GiB, so
# the 0.24 GiB difference goes to the arena) and raises util 0.98 -> 0.982,
# Frank's cap. The native-262144 and fp8+MTP4 shapes are retired to
# git tag frank-primary-fp8-mtp-20261001.
#
# THE COMPILE CACHE IS MOUNTED, and that is a boot-cost fix, not a tuning knob:
# the image ships no /root/.cache at all, our first boot writes 1.1 GB of vLLM
# AOT/torch_compile artifacts plus 229 MB of comgr JIT kernels into the
# container's ephemeral layer, and `podman rm -f` deletes them — so every
# deploy/restart/reboot re-pays `torch.compile took 181.20 s in total` (173.19 s
# of it one graph). Mounting FRANK_PRIMARY_CACHE_DIR at /root/.cache makes all
# later boots load the compiled artifact instead, and hides nothing. PROVEN
# LIVE 2026-10-02 on consecutive boots of this shape: cold `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 … took 41.13 s (compilation: 3.98 s)`.
#
# The root unit (scripts/frank-primary.service, User=dank,
# LimitMEMLOCK=infinity) grants the pin budget for the tiered-expert host
@@ -96,19 +123,49 @@ done
LOG=/home/dank/logs/frank_primary.log
mkdir -p "$(dirname "$LOG")"
# Persistent compile cache (see the header). Create it before the mount so even
# the first, cold boot has a target; unset -> no mount, exactly the old
# ephemeral behaviour. It carries /root/.cache/vllm (AOT + torch_compile) and
# /root/.cache/comgr (HIP kernel JIT).
CACHE_DIR="${FRANK_PRIMARY_CACHE_DIR:-}"
if [ -n "$CACHE_DIR" ]; then
mkdir -p "$CACHE_DIR"
fi
# --device /dev/dri is the WHOLE DIRECTORY (individual renderD nodes enumerate
# without the card nodes and HIP refuses contexts — "No CUDA GPUs are
# available" while device_count() says 2). --ipc=host for the engine's shared
# memory; memlock unbounded for the pinned host slabs.
# The entrypoint already execs `vllm serve "$@"`, so no `serve` here.
# Prefix caching: ON — the shipped contract. FRANK_PRIMARY_NO_PREFIX_-
# CACHING=1 flips to the fork-named mitigation (--no-enable-prefix-caching)
# for the QFN align-mode GDN state fault; it was tried 2026-10-05 and
# REVERTED the same day (live conversations still degraded at ~40k with
# caching off and zero faults — see the evidence block in
# frank-primary.env). NO comment lines inside the backslash chain below —
# a comment there terminates the continuation and the next line runs as
# its own command (the 2026-10-05 exit-127 crash loop).
# The ${VAR:-word} / ${VAR:+word} double-expansion for the two directions is
# AMBIGUOUS bash (${VAR:--flag} parses as a substring offset when set —
# measured 2026-10-05: rendered "--no-enable-prefix-caching1"), so the flag
# is branched explicitly: exactly one of the two reaches the engine.
if [ -n "${FRANK_PRIMARY_NO_PREFIX_CACHING:-}" ]; then
FRANK_PRIMARY_PREFIX_CACHE_FLAG=--no-enable-prefix-caching
else
FRANK_PRIMARY_PREFIX_CACHE_FLAG=--enable-prefix-caching
fi
podman run -d --name "$FRANK_PRIMARY_CONTAINER" \
--publish "$FRANK_PRIMARY_PUBLISH" \
--ipc=host --ulimit memlock=-1:-1 \
--cap-add SYS_PTRACE \
--device /dev/kfd --device /dev/dri --device /dev/mem \
-e HIP_VISIBLE_DEVICES=0,1 -e VLLM_ROCM_USE_AITER=0 \
${FRANK_PRIMARY_GPU_MAX_HW_QUEUES:+-e GPU_MAX_HW_QUEUES="$FRANK_PRIMARY_GPU_MAX_HW_QUEUES"} \
${FRANK_PRIMARY_GATHER_CHUNKS:+-e R4D_LRU_GATHER_CHUNKS="$FRANK_PRIMARY_GATHER_CHUNKS"} \
${FRANK_PRIMARY_GATHER_LANES:+-e R4D_LRU_GATHER_LANES="$FRANK_PRIMARY_GATHER_LANES"} \
${FRANK_PRIMARY_CUDAGRAPH_BASE_MB:+-e VLLM_CUDAGRAPH_MEM_BASE_MB="$FRANK_PRIMARY_CUDAGRAPH_BASE_MB"} \
${FRANK_PRIMARY_CUDAGRAPH_PER_GRAPH_MB:+-e VLLM_CUDAGRAPH_MEM_PER_GRAPH_MB="$FRANK_PRIMARY_CUDAGRAPH_PER_GRAPH_MB"} \
${CACHE_DIR:+-v "$CACHE_DIR":/root/.cache} \
-v "$FRANK_PRIMARY_MODEL_DIR":/models:ro \
"$FRANK_PRIMARY_IMAGE" /models \
--served-model-name "$FRANK_PRIMARY_SERVED_NAME" --port 7770 \
@@ -118,13 +175,17 @@ podman run -d --name "$FRANK_PRIMARY_CONTAINER" \
--max-num-seqs "$FRANK_PRIMARY_SEQS" \
${FRANK_PRIMARY_MNBT:+--max-num-batched-tokens "$FRANK_PRIMARY_MNBT"} \
${FRANK_PRIMARY_PRECISE_MEMORY:+--expert-precise-memory} \
${FRANK_PRIMARY_EXPERT_OFFLOAD_MEM:+--expert-offload-mem "$FRANK_PRIMARY_EXPERT_OFFLOAD_MEM"} \
--gpu-memory-utilization "$FRANK_PRIMARY_UTIL" \
--kv-cache-memory-bytes "$FRANK_PRIMARY_KV_BYTES" \
--kv-cache-dtype "$FRANK_PRIMARY_KV_DTYPE" \
--enable-prefix-caching \
"$FRANK_PRIMARY_PREFIX_CACHE_FLAG" \
--enable-auto-tool-choice --tool-call-parser "$FRANK_PRIMARY_TOOL_PARSER" \
--reasoning-parser "$FRANK_PRIMARY_REASONING_PARSER" \
--default-chat-template-kwargs "$FRANK_PRIMARY_CHAT_TEMPLATE_KWARGS" \
${FRANK_PRIMARY_SPEC:+--speculative-config "$FRANK_PRIMARY_SPEC"} \
${FRANK_PRIMARY_COMPCONFIG:+--compilation-config "$FRANK_PRIMARY_COMPCONFIG"} \
${FRANK_PRIMARY_GEN_CONFIG:+--override-generation-config "$FRANK_PRIMARY_GEN_CONFIG"} \
--trust-remote-code \
> "$LOG" 2>&1
+30 -18
View File
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
# Serve bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF (Q5_K_M) as frank-small on
# Serve ornith-ai/Ornith-1.5-9B-GGUF (Q8_0) as frank-small on
# Dankclaw's RTX 3090 via llama.cpp (master build, CUDA sm_86).
#
# 2026-10-01 night (Frank): swapped from SGLang + INT8-W8A16-AutoRound
# safetensors to the llama.cpp GGUF path, because llama.cpp is the only
# runtime on this host that can serve the MiMo qwen35 hybrid with
# ASYMMETRIC KV: --cache-type-k f16 (unquantized keys) + --cache-type-v
# q8_0 (quantized values), which is Frank's explicit KV contract for this
# endpoint. SGLang's --kv-cache-dtype only moves K and V together.
# runtime on this host that moves K and V independently — the asymmetric
# f16-K + q8_0-V contract that endpoint was built for. 2026-10-03 (Frank):
# the pair is now f16/f16 — two wide slots freed the VRAM, so the values are
# unquantized too. The per-tensor flags stay explicit either way.
#
# Architecture: qwen3_5 hybrid — 32 layers, 8 full-attention + 24 Gated
# DeltaNet, native 262144 window. KV lives only in the 8 attention layers;
# the DeltaNet state is per-slot fixed size. Vision comes from the repo's
# separate projector (the Q5_K_M GGUF carries no `v.*` tensors at all), so
# separate projector (the Q6_K GGUF carries no `v.*` tensors at all), so
# --mmproj is what gives this endpoint eyes; MMPROJ_FILE="" is the documented
# text-only opt-out.
#
@@ -33,22 +33,31 @@ fi
# --- defaults (used when the env file does not set them) -------------------
: "${LLAMA_BIN:=/home/dank/GitPackages/llama-master/build-cuda/bin/llama-server}"
: "${MODEL_FILE:=/home/dank/models/MiMo-Q5KM/MiMo-V2.6-Distill-Qwen-9B-Q5_K_M.gguf}"
: "${MODEL_FILE:=/home/dank/models/Ornith-Q8/Ornith-1.5-9B-Q8_0.gguf}"
: "${ALIAS:=frank-small}"
: "${BIND_ADDR:=0.0.0.0}"
: "${LISTEN_PORT:=7771}"
# ctx-size is the POOL llama.cpp divides across slots: 540672 / 3 gives
# 180224 per slot — three independent conversations (the native 262144 no
# longer fits three times on a 24 GiB card once the vision tower is loaded;
# frank-small.env carries the two measurements that set this number).
: "${CTX_SIZE:=540672}"
: "${PARALLEL:=3}"
# ctx-size is the POOL llama.cpp divides across slots: 401408 / 2 gives
# 200,704 per slot — two independent conversations, each one wide enough to
# hold a whole frank-primary-sized prompt in a single shot (the native 262,144
# no longer fits twice on a 24 GiB card once the vision tower is loaded;
# frank-small.env carries the three measurements that set this number).
: "${CTX_SIZE:=401408}"
: "${PARALLEL:=2}"
# Compute-buffer geometry. Before 2026-10-03 this endpoint never passed either
# flag, so it ran on llama.cpp's own defaults (2048/512). The pair is 4096/2048
# since 2026-10-05: on this CUDA card the prefill curve climbs all the way to
# ubatch 2048 (unlike the R9700 layer-split sibling, where 512 is the peak),
# and the logical batch is VRAM- and throughput-neutral. frank-small.env
# carries the sweep tables and the VRAM risk note.
: "${BATCH_SIZE:=4096}"
: "${UBATCH_SIZE:=2048}"
: "${CACHE_TYPE_K:=f16}"
: "${CACHE_TYPE_V:=q8_0}"
: "${CACHE_TYPE_V:=f16}"
: "${NGL:=99}"
# Vision projector. No colon on purpose: an explicitly empty value means
# "text-only, on purpose" and must NOT be replaced by the default path.
: "${MMPROJ_FILE=/home/dank/models/MiMo-Q5KM/mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf}"
: "${MMPROJ_FILE=/home/dank/models/Ornith-Q8/mmproj-Ornith-1.5-9B-BF16.gguf}"
# Fail loudly rather than half-loading: a missing file is a download that
# has not finished, and Restart=always would turn it into a crash loop that
@@ -66,9 +75,10 @@ fi
# open after boot; make sure it exists before the server needs it.
nvidia-modprobe -u -c=0 2>/dev/null || true
# q8_0 on V REQUIRES flash attention (the quantized-value kernels only exist
# on the FA path); an FA-less build silently falls back and the KV contract
# in frank-small.env becomes a lie, so pin the flag pair here.
# Flash attention stays pinned on: it is the fast path for f16 KV, and it is
# the only path that could serve quantized V at all — so if CACHE_TYPE_V is
# ever set back to q8_0 this flag must not be lost, or the build falls back
# silently and the KV contract in frank-small.env becomes a lie.
ARGS=(
--model "$MODEL_FILE"
--alias "$ALIAS"
@@ -76,6 +86,8 @@ ARGS=(
--port "$LISTEN_PORT"
--ctx-size "$CTX_SIZE"
--parallel "$PARALLEL"
--batch-size "$BATCH_SIZE"
--ubatch-size "$UBATCH_SIZE"
--n-gpu-layers "$NGL"
--flash-attn on
--cache-type-k "$CACHE_TYPE_K"
@@ -20,11 +20,12 @@ user sees it; the `bats` seams assert config shape, not behavior.
| Path | Role |
|------|------|
| `playwright.config.ts` | four projects (below), real-browser UA, trace on failure |
| `playwright.config.ts` | five projects (below), real-browser UA, trace on failure |
| `specs/00-auth.setup.ts` | setup project: two real authentik logins, saves storage states |
| `specs/10-homepage.spec.ts` | user panel set + open links derived from `services.json` (registry-derived — no hardcoded service lists) |
| `specs/11-admin.spec.ts` | `admin` project: admin grid + `/admin/` RBAC (403 for standard user, members-only 200) |
| `specs/12-crawl.spec.ts` | every user open link reaches its real app through the guard chain |
| `specs/19-frankbook.spec.ts` | `frankbook` project: walks the six admin links that terminate on FrankBook-Pro (192.168.0.112) — conditional, see below |
| `specs/13-sso.spec.ts` | direct guarded-vhost visits resolve silently with ONLY the homepage session (single-login enforcement) |
| `specs/14-guard.spec.ts` | `guest` project, no storage state: unauthenticated traffic lands on the IdP login |
| `lib/login.ts` | `akLogin` helper (two-stage flow, shadow-DOM locators) |
@@ -38,7 +39,16 @@ user sees it; the `bats` seams assert config shape, not behavior.
`.auth/qa-e2e{,-admin}.json` storage states.
- **standard** — 10/12/13 with the user state.
- **admin** — 11 with the admin state.
- **guest** — 14 with an empty state.
- **guest** — 14/15 with an empty state.
- **frankbook** — 19 with the admin state. **Excluded from every default run**: the
*arr/qBittorrent links terminate on FrankBook-Pro, which sleeps when idle, and a
sleeping Mac mini must never fail an unrelated deploy. The project joins only
when `E2E_INCLUDE_FRANKBOOK=1` (set by `scripts/deploy.sh` when a frankbook
trigger path changed: `tests/e2e/specs/19-frankbook.spec.ts`, `frankbook-pro/**`,
`shared/homepage/services.json`, or the `dankclaw/Caddyfile` serving the arr
vhosts) or when an explicit `--project` argument names it. An `11-admin` edit
that touches the arr links must therefore be paired with a `19-frankbook` run
against an awake host before it is trusted live.
Credentials live in `tests/e2e/.env.local` (git-ignored; placeholders in
`.env.local.example`). The scaffold bats seam fails the commit if creds or
@@ -47,8 +57,9 @@ auth states are ever tracked.
## Run
```bash
cd tests/e2e && npx playwright test # all projects
npx playwright test --project=standard 12-crawl.spec.ts # one slice
tests/e2e/run.sh # default set — frankbook project excluded
E2E_INCLUDE_FRANKBOOK=1 tests/e2e/run.sh # default set + the frankbook walk
cd tests/e2e && npx playwright test # ALL projects, frankbook included
```
Requires LAN reachability of `www.franksdank.cloud` (Pi-hole DNS or public).
@@ -95,6 +95,51 @@ llama-server --hf-repo <target-GGUF> \
The DFlash drafter proposes, then ngram-mod runs as an additional implementation in the chain. Verified against upstream `common/speculative.cpp` (b10133+).
### Chaining laws (measured on this fleet, 2026-09 → 10)
`--spec-type` takes a comma list, and the gate is `ctx_dft` in
`common/speculative.cpp` (`common_speculative_init_result`): **draftless**
types (the ngram family — simple, map-k, map-k4v, mod, cache) are added
unconditionally, while **context-backed** types (`draft-mtp`, `draft-dflash`,
`draft-dspark`, `draft-eagle3`) all require a draft CONTEXT. Consequences:
- A chain may hold ONE context-backed drafter plus any number of ngram types.
`draft-dflash,ngram-mod` is 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.
- Two context-backed types cannot share one process: `draft-mtp,draft-dflash`
dies at load with `context type MTP requested but model doesn't contain MTP
layers` — MTP builds its context against whatever the draft context holds,
so it met the DRAFTER's file, not a missing head. Pin that in a seam so
nobody "fixes the flag" later.
- Internal order is fixed (ngram types first, then drafters); CLI order is
irrelevant.
- **A legal chain can still lose on a recurrent model.** `draft-mtp,ngram-mod`
on the qwen35 hybrid (frank-large era) measured −15 % prose / −7 %
block-copy (2026-10-03): adding the ngram layer logs `recurrent state
rollback is not compatible with 'ngram-mod' - disabling rollback support`,
and MTP verification depends on that rollback, so acceptance drops on ALL
traffic even where the matcher fires. Measure chains per architecture,
never by analogy.
- ngram-mod prints NO startup banner — the load log shows only the drafter's
`adding speculative implementation` line. Never conclude "chain not
engaged" from the load log; identify behaviourally (`draft_n` > 0 only
where text repeats) or by the per-task `draft acceptance = ... mean len`
line. It costs ~64 MiB of host-RAM hash table.
### Drafter KV: read the window policy from the header
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 target window on whatever device
`--spec-draft-device` names. DFlash declares `sliding_window = 2048` (~40 MiB
of draft KV); DSpark declares none: 5 layers x 2 x 8 kv heads x 128 dim x 2 B
= 20 KiB per position, i.e. **5.0 GiB at 262144** — enough to kill the first
draft graph capture with `ROCm error: out of memory` AFTER the model loaded
and `/health` answered (measured 2026-09-16). Read `sliding_window` out of
the drafter's GGUF header before sizing any spec-decode deployment, and
prefer a skewed `-ts` over a context cut when the full window matters.
DFlash on llama.cpp:
```
@@ -209,6 +254,151 @@ docker run --rm --name vllm-qwen36-dflash \
History: before `--spec-default`, the box ran DSpark (`draft-dspark`, retired 2026-08-15 after a ROCm illegal-memory-access crash under two concurrent 16k prompts) and before that bundled MTP (`draft-mtp`, retired same day). DFlash was retired on 2026-08-14 (premature-EOS under parallel load). The old DFlash draft GGUF (`Qwen3.6-27B-DFlash-Q8_0.gguf`), the MTP-only drafter (`Qwen3.6-27B-MTP-ONLY-Q8_0.gguf`), and the DSpark draft (`Qwen3.8-27B-DSpark-Q8_0.gguf`) remain in `/home/dank/models/` as reference only.
## A/B protocol on a live llama.cpp endpoint
Verdicts are **traffic-shaped**: a proposer can only draft text it has seen,
so a single prompt shape answers the wrong question. Use fixed shapes:
| Shape | Prompt | Who it can favour |
|---|---|---|
| prose | 1k filler + "reply with one sentence" | model-backed drafters only |
| echo | ~1.5k passage of UNIQUE lines + "repeat verbatim" | ngram (output IS the prompt) |
| structured | repetitive output, no prompt overlap | ngram, if spans really repeat |
| identity | short deterministic question | controls for lossless verification |
Design the echo passage so no long span repeats INSIDE it — otherwise you
measure the matcher against itself. For `structured`, give each line a unique
id: repeated lines make it a negative control, not a repetition test.
Protocol:
1. Stop the unit (`sudo systemctl stop <svc>`), `pkill -x llama-server`; wait
for the port (a retired predecessor's published-port proxy leaves the
endpoint port in TIME_WAIT — the bind fails with address-in-use long after
the unit stopped).
2. Per config: write an env file with the overrides, start the serve script
with `FRANK_*_ENV_FILE`, poll `/health`, measure. Ready-made harness:
`scripts/bench-spec-ab.py <env-file> <serve-script>`.
3. **Restore the unit in a `finally`** and shout if it does not come back
healthy. A crashed harness that leaves production down is worse than no
harness.
4. **Prove losslessness**: one fixed prompt at temp 0 to every config, diff
the greedy text. Speculation only buys speed; differing texts are a
correctness bug. Empty answers (`content: null` when reasoning ate the
budget) make the comparison vacuous — bench clients must fall back to
`reasoning_content`, and echo/structured prompts should disable thinking
per request: `chat_template_kwargs: {"enable_thinking": false}`.
5. **Sweep the depth, not just the mode** — draft depth is model-specific and
single-valued; deeper often loses (measured: at n=7 a third of proposals
survived, at n=3 more than half).
6. **Sweep the ngram window in the same run as the configs**: too short a
`--spec-ngram-mod-n-min/-n-max` proposes on ordinary prose fragments —
rejected, verification still paid (measured 43.9 → 36.0 tok/s).
Read the right counters — per request `timings.draft_n` / `draft_n_accepted` /
`predicted_per_second`; cumulative `/metrics`
`llamacpp:spec_decode_num_drafts_total` / `..._accepted_tokens_total`;
per-task `draft acceptance = ... (N accepted / M generated)`. `draft_n == 0`
while tokens flow = the proposer is NOT engaged, whatever the flags say. A
zero-draft config is a useful negative control: it proves the other config's
drafts came from the proposer under test.
Harness hygiene: absolute script paths (a relative path plus `cd` to its own
dir double-counts the prefix); `pkill -x`, never `-f` (the pattern matches
your own command line); nested f-strings with backslash escapes are a
SyntaxError on the fleet's Python (<3.12 semantics) — build log lines with
concatenation. Report `decode tok/s` per shape plus acceptance and mean
accepted length; a config that wins on echo and loses on prose is a traffic
decision, not a winner. Wire the winner into the deployed env file with the
measured table as its justification comment; record losing numbers with "do
not switch without re-measuring".
The A/B vehicle for ANY single-GPU llama.cpp knob (batch/ubatch, KV type,
slot split, quant rung — not just spec flags) is the transient systemd
drop-in procedure in [[frank-large-benchmark]] and [[frank-small-benchmark]].
## Is it worth it? (arithmetic before deployment)
Answer against the INCUMBENT, not against zero.
**Step 0 — check whether the target already speculates.** Many engines ship a
drafter of their own; then your baseline is `A_incumbent / t_s`, not `1 / t_s`.
halogen-flash-server: MTP head ON by default — read its own accounting, e.g.
`serve_api: mtp 63 tok in 1.31s = 47.97 t/s | 33 rounds, commit 1.88/round`.
llama.cpp: `/metrics` counters and per-request `draft acceptance` lines.
**Step 1 — measure the two step times, never guess.** Target step `t_s` = 1 /
(client-visible tok/s ÷ commit-per-round); ask for the rate the operator
actually SEES (engine-internal decode-only numbers run ~1.7–2x optimistic).
Drafter step `d_s` from llama.cpp's per-task `eval time` lines when it is a
plain model on the same box.
**Step 2 — arithmetic:**
```
round_time = K * d_s + 1.1 * t_s # K drafts proposed, K+1 verified in ONE target pass
A = (1 - p^(K+1)) / (1 - p) # expected accepted tokens, p = per-position acceptance
rate = A / round_time # compare to the incumbent
break-even : A > rate_incumbent * (K*d_s + 1.1*t_s)
```
Worked example that decided a real case: `t_s = 67 ms` (25 tok/s, 1.67
commit/round), `d_s = 12 ms` (27B IQ3_S drafter on the 3090) → break-even
`p ≈ 0.70` at K=3, `≈0.68` at K=5; a realistic cross-finetune `p = 0.4–0.6`
gives 17–20 tok/s, i.e. a LOSS. A drafter only ~3x faster per token cannot
pay for K sequential forwards.
**Step 3 — classify the drafter by per-round cost:**
| drafter | cost per round | needs |
|---|---|---|
| plain model (`-md`/`draft-simple`) | **K sequential forwards** | 10–50x faster than the target |
| n-gram / prompt-lookup (`ngram-*`) | ~0, no model | repeated context (agent edit loops) |
| trained head (`draft-mtp`, EAGLE/DFlash) | **~1 pass** | the head must exist FOR THAT CHECKPOINT |
Trained heads are target-specific: EAGLE/DFlash consume the TARGET's hidden
states, so a dense-27B DFlash2 is useless for a 512-expert MoE trunk.
**Step 4 — acceptance reality checks.** Fleet datum: a trained z-lab DFlash2
at depth 7 accepted ~41 % on its matched target (`draft-dflash 7 → 43.5
tok/s`); treat 40–60 % as the honest band for a matched pair. Cheapest proxy
for `p` at temp 0 when both models are greedy and share a tokenizer: greedy
continuation agreement on identical prompts, two API calls, no weights moved.
llama.cpp can also synthesize the projection without any draft artifact:
`--spec-synth-rates P0,P1,...` / `--spec-synth-len L`. Speculation pays IN
PROPORTION TO HOW SLOW THE TARGET IS — the same 41 % gave 2.3x on an
18.8 tok/s layer-split target and a loss on a 25 tok/s one; fast targets are
usually sparse MoE, the worst case for drafting.
**Step 5 — check the attachment surface BEFORE the arithmetic.** Closed
engines refuse external drafters by design: halogen-flash-server's
`DRAFTER_CAP` gates `dflash2` on the loaded checkpoint and an unavailable
name is a deliberate 400 (a silent downgrade would make comparisons lie);
there is no device routing in its loopback token protocol, so a drafter can
never sit on another card. Pure llama.cpp DOES route the draft:
`--spec-draft-device/-devd` (default follows `--device`), `-ngld`,
`-otd`, `-ctkd/-ctvd`, `--spec-draft-n-max` (default 3), `--spec-draft-p-min`.
One process sees only the devices its backends were built with — CUDA+Vulkan
is the combination that shows a CUDA card AND an AMD iGPU (HIP+CUDA cannot
coexist); a CUDA-only build has no `Vulkan0` to draft with.
**Step 6 — cross-model pairs need a byte-identical tokenizer.** Speculation
compares token IDs, so draft and target must map id → same string. Verify
cheaply from the GGUF headers (first ~8 MB over HTTP, no full download):
parse `tokenizer.ggml.tokens`, print vocab size, merges, `pre`, and sha256
over the first 2000 tokens —
```
vocab=248320 merges=247587 model=gpt2 pre=qwen35
sha256(first 2000 tokens)=8619c68fe4466d3a # identical => ids are interchangeable
```
Identity is necessary, NOT sufficient — the drafter still has to be cheap and
accepted. Report in this order: does the target already draft; break-even
`p` at K=1,3,7 versus the incumbent; the realistic band and resulting rate;
whether the drafter fits (VRAM) and the attachment surface exists; the one
measurement that would settle it.
## Notes
- Speculative decoding helps low-QPS, memory-bound, latency-focused workloads. It helps less at high QPS.
@@ -225,4 +415,8 @@ History: before `--spec-default`, the box ran DSpark (`draft-dspark`, retired 20
- [[Dankclaw System Specs]]
- [[Dankserver System Specs]]
- [[VLLM Overview]]
- [[deployment]]
- [[deployment]]
- [[GGUF Compatibility]] — header parsing and custom tensor-type lock-in
- [[Llama Cpp Serving Sizing]] — slot-pool and drafter KV sizing arithmetic
- [[Llama Cpp Multi-Device Splits]] — where the drafter's KV actually lands
- [[frank-large-benchmark]] and [[frank-small-benchmark]] — the single-GPU env drop-in A/B vehicle
@@ -0,0 +1,123 @@
---
type: reference
title: GGUF Compatibility and Runtime Lock-in
description: How to answer "will engine X load this GGUF?" from the file's header alone, why a custom tensor type forces the runtime, and the discipline for pinning a fork build that can actually serve the file.
tags: [gguf, llama.cpp, forks, quantization, speculative]
timestamp: 2026-10-05T00:00:00-07:00
---
# GGUF Compatibility and Runtime Lock-in
**The law: a GGUF's tensor-type histogram and ftype pick the runtime — read the
header before promising a file to any loader.** Stock llama.cpp, Ollama and LM
Studio refuse custom types at load (`unknown tensor type`); a fork-private type
means exactly one engine can serve the file. Measured on this fleet repeatedly:
`Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf` is ftype 115 with 311 tensors of type 103
(`GGML_TYPE_Q8_0_ROCMFPX`) — only charlie12345/ROCmFPX reads it. The full type
tables live in [[Llama Cpp Runtimes]] and [[Llama Cpp Code Diff]]
(ggml_type 100–108, ftype families 100–106 / 110–113 / 118).
## 1. Read the header, do not guess
Parse the GGUF KV section and histogram the tensor types plus
`general.architecture.file_type` — one python read over the first few MiB, no
model load. Counting types out of the KV section settles a runtime argument in
minutes; repo names and model cards do not.
## 2. Compare routing variants by tensor mix, not by name
Two files of the same quant can differ in how many tensors stay at plain Q8_0
(measured: 187 vs 2 output-head tensors in an `_AGENT` variant of the same
size). That difference is what moves spec-decode acceptance (0.953 vs 0.911)
— the "same size" file is not the same file.
## 3. Pin the build by commit SHA
A fork that can read the file today can still crash mid-load: the 2026-08-25
ROCmFPX builds read the ftype-115 file and then SIGSEGV in
`ggml_compute_forward_set_rows`. Record the SHA in the build script
(`scripts/build-rocmfpx.sh` pins `36e9acd`) and check it out explicitly — never
a branch.
## 4. Header diff method (candidate vs known-good)
Answer "will engine X load this GGUF?" by diffing the candidate's header
against a file the engine **already loads** — never by filenames or model cards;
filenames lie about quantization and about MTP/vision heads.
1. **Get the bytes**: both files, from byte 0, at least 64 MiB. Tokenizer KVs
(151k token strings + merges) routinely eat 5–8 MiB, so a small range
desynchronises the parser. True size from `curl -sIL` → `x-linked-size`
(a bare HEAD `Content-Length` can lie):
```sh
curl -sL -r 0-67108863 -o cand.bin \
"https://huggingface.co/<owner>/<repo>/resolve/main/<file>.gguf"
ssh <host> 'head -c 67108864 /path/to/known-good.gguf' > good.bin
```
2. **Parse — and lock the parser against the known-good file first.** Layout
(v2/v3): `magic[4]` `version u32` `n_tensors u64` `n_kv u64`, then KVs, then
tensor infos. Strings are u64 length + bytes. **The value-type enum is NOT
llama.cpp's `gguf.h`:** in the files seen (2026-09-28, unsloth + ajgazin UD
GGUFs) type **8 = STRING, 9 = ARRAY**; llama.cpp's header has STRING=2,
ARRAY=3 and desynchronises immediately. Working table:
```
0 u8 1 i8 2 u16 3 i16 4 u32 5 i32 6 f32
7 bool 8 STR 9 ARRAY(u32 et, u64 n) 10 u64 11 i64 12 f64
```
Validate before trusting: the parse is right iff the tensor-info block ends
somewhere sane and names look real (`blk.0.attn_q.weight`,
`token_embd.weight`, `output_norm.weight`). EOF throw = wrong enum or
offset — do not work around it.
3. **Diff what engines actually bind on**: `general.architecture`; tensor
count; tensor name list in order; per-tensor `ggml_type` and dims; geometry
KVs (`<arch>.block_count`, `.embedding_length`, `.feed_forward_length`,
`.attention.head_count[_kv]`, `.rope.*`, `.ssm.*`, `.context_length`,
`.nextn_predict_layers`); MTP tensors (`nextn` in the name); vision tensors
(`visual`, `v_` prefixes). A same-family fine-tune quantized by the same
tool usually comes out byte-identical in every one of these — that is the
answer.
4. **Then read the engine's source before calling a delta a blocker.** A
metadata difference only matters if the engine reads that key
(`grep -rn "file_type\|recurrent_layers\|full_attention_interval" <src>/`).
Worked example (gufo v0.1.1): `general.file_type` is deliberately not
consulted (UD-*_K_XL ships a stale ftype 15; the tensor table rules), and
`qwen35.attention.recurrent_layers` is never read (layer kinds come from
`full_attention_interval`) — both deltas were non-issues the header diff
alone would have flagged wrongly.
5. **Say what you did not do.** Header analysis is strong evidence, not a
load. State byte sizes, check free disk before proposing a fetch, and do
not download tens of GB unasked — pointing the engine at the file is the
definitive test.
**Report shape:** verdict first (loads / will not / loads-with-caveats), then
the side-by-side diff table, then only the load-bearing caveats: text-only vs
vision tensors, chat-template differences (matters for llama.cpp, not for
engines with compiled-in Jinja), license, disk headroom, and whether the base
model is abliterated/uncensored.
## 5. A/B a fork's spec decode by stripping the whole flag set
`--model-draft` without `--spec-type` segfaults at load (measured 2026-10-02,
core dump, no diagnostic). A baseline "no-draft" arm must remove `--model-draft`
AND every `--spec-draft-*` AND `--spec-type` together — the flags move as a set.
## 6. Before deleting a retired payload, read the rollback tag's env file
A rollback may need a small companion dir: a drafter living in a directory
named after an old quant looks like trash but is load-bearing. Same class as
the gufo payload removal of 2026-10-03, which turned that rollback into a
re-download.
## Related
- [[Llama Cpp Runtimes]] — which fork ships which custom types
- [[Llama Cpp Code Diff]] — the enum tables and code-level differences
- [[frank-large-rocmfpx-q8agent-mtp]] — the ftype-115 lock-in in production
- [[Llama Cpp Serving Sizing]] — pricing what a payload costs once it loads
@@ -0,0 +1,274 @@
---
type: reference
title: Llama Cpp Multi-Device Splits
description: What llama.cpp can and cannot express across devices, the no-collective layer-split path, split balancing by measurement, drafter KV sizing from GGUF headers, and heterogeneous placement triage.
tags: [llama.cpp, multi-gpu, layer-split, pcie, sizing, speculative]
timestamp: 2026-10-05T00:00:00-07:00
---
# Llama Cpp Multi-Device Splits
## 1. Expressibility law — classify the arrangement before running anything
llama.cpp expresses exactly two things across devices, and they are not
interchangeable:
| arrangement | how | expressible? |
|---|---|---|
| **Layer split** — layers 0..n on dev A, rest on dev B | `-sm layer -ts <a>/<b>` | **yes**, no collectives needed; works cross-vendor (CUDA + Vulkan measured 2026-09-16) |
| **Per-tensor placement** — attention+KV on A, experts on B | `-ot <regex>=<dev>` | **NO.** `--override-tensor` sets `has_tensor_overrides()`, a REQUIRED term of the scheduler's `pipeline_parallel` flag (only read site: `llama-context.cpp`, in the `pipeline_parallel = …` expression). That flag sets `n_copies = parallel ? MAX : 1` in `ggml_backend_sched_new`; with `n_copies == 1` a pre-allocated weight on a non-primary device has no backend and the graph aborts at `ggml-backend.cpp:941` (`pre-allocated tensor (blk.0.ffn_down_exps.weight) in a buffer (Vulkan1) that cannot run the operation (NONE)` — `(NONE)` is the leaf's own op name). |
Failure modes observed (mainline `37b53fd`): per-tensor `-ot exps=Vulkan1`
with `-dev CUDA0,Vulkan1` → scheduler abort; layer-scoped `-ot` with a
cross-backend pair → model load fails with only `failed to load model`;
`-ot x=CPU` **works** because the CPU backend is last in the list — **the CPU
is the only legitimate partner in a heterogeneous split.** On a CUDA+AMD host
a card can be the trunk OR the offload host, never the complement of an
AMD-resident peer. Pure Vulkan changes nothing here — the gate is the
override, not the driver mix.
If the plan needs tensors on a specific device: stop, the answer is no for
any vendor mix. Expressible alternatives, in order: (1) two independent
endpoints, one model per device; (2) single-vendor layer split; (3) device +
CPU per-tensor offload (mainline pinning guard: `GGML_CUDA_NO_PINNED`; ik fork
was `GGML_CUDA_NO_PINNED_WEIGHTS` — without it a CPU-overridden tensor can
land in a pinned `CUDA_Host` buffer and page-lock ~108 GiB).
Layer-split arithmetic to check before promising: a whole layer carries its
experts (MoE) — compute GiB/layer, not per param-group; only some layers carry
KV (every 4th on the hybrid), so a share of layers is only a share of KV; big
non-repeating tensors (PLE tables) land on the main device and cannot move —
they cap how small the primary can get.
## 2. No collectives? The atomics wall and the layer-split answer
Decide with one line before designing:
```bash
sudo lspci -vv -s <card2> | grep AtomicOpsCtl # ReqEn- ⇒ no collectives, ever
lspci -tv # a Routing- bridge is a hardware wall
```
`ReqEn-` kills vLLM entirely — TP needs a per-layer allreduce AND PP needs a
device-backend `send`/`recv` of the hidden state
(`vllm/distributed/parallel_state.py`) — i.e. the same RCCL primitive that
fails on an atomics-less card. llama.cpp's `--split-mode layer` needs no
collective: layers distribute in order, only the boundary activation crosses
PCIe once per forward pass. Full probe/repair detail:
[[PCIe Link and Atomics]].
## 3. Build: pin by feature, bundle the runtime at the exact depth
- Pin the ref by FEATURE list (e.g. DFlash2 + qwen35 MTP on 2026-09-16 →
`ggml-org/llama.cpp#27342`); never float. Verify a fork build the same way
(`ls <venv>/…/spec_decode/ # dflash/ vs dflash2/`).
- `-DCMAKE_HIP_ARCHITECTURES=<gfx only>` — a sibling arch links, loads, then
segfaults. `-DGGML_HIP_RCCL=OFF` when there is no atomics path (only tensor
mode degrades to the butterfly fallback; layer split unaffected).
- **TRAP — `cp -r SRC DST/` flattens when `DST` does not exist** and drops one
directory level. rocBLAS/hipBLASLt look for device code at a FIXED depth
(`../lib/rocblas/library`, `../lib/hipblaslt/library/<arch>`); flattened,
the engine loads every GiB, answers `/health`, and dies on the FIRST request
with `Cannot read .../TensileLibrary.dat: No such file or directory for GPU
arch`. `mkdir -p DST && cp -r SRC/. DST/`, then
`patchelf --set-rpath '$ORIGIN/../lib' build/bin/llama-*`.
- Dual-backend build (CUDA + Vulkan is the practical pair; HIP+CUDA do not
coexist): `-DGGML_CUDA=ON -DGGML_VULKAN=ON` in one tree; Vulkan needs
`glslc` + libvulkan headers. **Device names are not what you expect:**
`--list-devices` lists the discrete card under BOTH backends (`CUDA0` AND
`Vulkan0`) and the APU LAST (`Vulkan1`) — a wrong `-ot` name makes
llama.cpp print the valid set and exit (self-diagnosing).
- What the existing build can do: `GPU_BACKEND_*=OFF` lines in its
CMakeCache.
## 4. Serve flags that matter on a split
```bash
HIP_VISIBLE_DEVICES=0,1 llama-server \
--device ROCm0,ROCm1 --split-mode layer --tensor-split 1,1 \
--gpu-layers 999 --fit off --flash-attn on \
--ctx-size <n> --parallel 1 --cache-type-k f16 --cache-type-v f16 \
--jinja --metrics --cache-prompt \
--spec-type draft-dflash --spec-draft-model <dflash2.gguf> \
--spec-draft-device ROCm1 --spec-draft-n-max 7
```
- `--fit off` + explicit `--ctx-size`: auto-fit must not resize a context set
on purpose. **`--parallel N` DIVIDES `--ctx-size`** — one slot at large ctx
is the concurrency policy, concurrent agent loops queue.
- Pin the drafter to the card holding the target's LAST layers
(`--spec-draft-device`); under tensor mode an unpinned draft joins the
meta-device KV allocation and aborts the KV build. `--spec-draft-n-max` must
be ≤ the drafter's `block_size - 1` (DFlash2: 8 ⇒ 7).
- Qwen3.8-family templates RAISE on `reasoning_effort=high` (500) — probe
low/medium/high/xhigh and map harness levels before declaring ready.
## 5. Split balancing: measure, never interpolate
**A `-ts` step is ONE LAYER, and hybrid stacks do not have uniform layers.**
qwen35 is 16 × (3 Gated DeltaNet + 1 Gated Attention): one step ≈ 0.43 GiB on
a DeltaNet boundary, ≈ 1.43 GiB on a full-attention one (weights + that
layer's own f16 KV) — measured on frank-medium. The gap can jump 2.9 GiB in a
single step; the best integer split can sit 0.2 GiB from balance while its
neighbours are 1+ GiB off. An affine fit reproduces the deployed point and
overstates gaps in between (`tests/frank_medium_unit.bats` encodes the fit as
a gate; treat a NEGATIVE fitted constant as a unit bug).
Knobs that move weight and REQUIRE a re-measure: `--ubatch-size` (compute
buffers are **~2x heavier on the output/drafter card** — 512→1536 added
~3.3 GiB there vs ~1.7 GiB elsewhere), a drafter swap (weights + draft KV),
`--ctx-size`/`--parallel` (KV). Total free VRAM is invariant across splits —
only the distribution moves; use that to sanity-check a suspicious row.
**Procedure (one split per load):**
```bash
WORK=/tmp/split.env; cp scripts/serve-<svc>.env $WORK
export FRANK_MEDIUM_ENV_FILE=$WORK
for ts in 39,26 38,27 37,28 36,29 35,30; do
sed -i "s|^TENSOR_SPLIT=.*|TENSOR_SPLIT=$ts|" $WORK
nohup bash scripts/serve-<svc>.sh >/tmp/split.log 2>&1 &
# poll /health (or process exit), then:
rocm-smi --showmeminfo vram | grep 'VRAM Total Used' # sample ~6 s after health
pkill -x llama-server; sleep 6
done
```
Load → sample → kill, NO requests sent (allocation only). Run under `nohup`
and poll from a separate call (a load is 30–90 s). Pick the smallest
inter-card gap that clears the per-card floor with room to spare; one `-ts`
step ≈ 1.5 GiB at ~6k batch / 262k ctx on 30 GiB cards — enough to bracket,
not to solve. Prefer balance: the busy card's headroom bounds the context
(`ctx_extra ≈ (floor − measured_free) / per-position cost on the busier
card`). A faulted split (`SDMA0 UTCL2 page fault` → `illegal memory access` →
`ggml_abort`) is OFF-LIMITS — re-measure, never re-tune; more headroom does
not prevent a boundary-copy fault.
## 6. Drafter KV: sized from the TARGET, bounded by its window policy
`common/speculative.cpp` builds the draft context with
`cparams.n_ctx = llama_n_ctx(ctx_tgt)` — **no flag caps it.** So:
```
draft KV = ctx_tgt × block_count × 2 (K,V) × kv_heads × key_length × dtype
```
…unless the drafter declares a sliding window. Measured at 262,144 target
ctx: DFlash2 (`sliding_window = 2048`) ≈ 40 MiB; a same-shape 5-layer drafter
with NO window = 20 KiB/position = **5.0 GiB** — a four-layer swing in the
balance point, and swapping them invalidated an already-balanced split.
**Failure signature:** the engine loads the whole model, answers `/health`,
and dies on the FIRST draft decode with `ROCm error: out of memory` /
`cudaMalloc failed` inside `common_speculative_impl_draft_dflash::process` —
a sizing failure, not a broken drafter; under a self-healing unit it presents
as a crash-loop. Read `sliding_window`, `block_count`, `head_count_kv`,
`key_length` from the drafter's GGUF header first. A compact reader (the
three format traps: no KV alignment — v3 alignment applies only to the tensor
section; arrays are `type(9) → element_type(u32) → count(u64)`, element type
FIRST; the enum is `7=BOOL 9=ARRAY`, 64-bit = 10/11/12, and 8=STRING/9=ARRAY
is the v2/v3 file order, NOT gguf.h's 2/3):
```python
import struct, sys
def rd(path):
f = open(path, 'rb'); assert f.read(4) == b'GGUF'
struct.unpack('<I', f.read(4)); struct.unpack('<Q', f.read(8))
nk = struct.unpack('<Q', f.read(8))[0]
def s():
n = struct.unpack('<Q', f.read(8))[0]; return f.read(n).decode('utf-8', 'replace')
SCALAR = {0:1,1:1,2:2,3:2,4:4,5:4,6:4,7:1,10:8,11:8,12:8}
FMT = {0:'<B',1:'<b',2:'<H',3:'<h',4:'<I',5:'<i',6:'<f',7:'<?',10:'<Q',11:'<q',12:'<d'}
keys = {}
for _ in range(nk):
k = s(); t = struct.unpack('<I', f.read(4))[0]
if t == 8: keys[k] = s()
elif t in SCALAR: keys[k] = struct.unpack(FMT[t], f.read(SCALAR[t]))[0]
else:
et = struct.unpack('<I', f.read(4))[0]; n = struct.unpack('<Q', f.read(8))[0]
if et == 8: keys[k] = [s() for _ in range(n)]
elif et in SCALAR: keys[k] = ('arr', et, n); f.read(n * SCALAR[et])
else: raise Exception('unsupported arr elem type %d' % et)
return keys
```
## 7. Heterogeneous placement triage (go/no-go with arithmetic)
- **Link:** `LnkCap` vs `LnkSta` at every hop; expect **idle-downshift** (a
Gen4 x4 dock idles at Gen1 x4, ~1 GB/s) — measure under load. Per-hop cost
vs hidden state (5120-dim f16 = 10 KiB/hop ≈ 1.3 µs at Gen4 x4): bytes are
almost never the constraint; **hop count is** — every boundary is a host
sync, and a per-op-split runtime can hit 100 splits/token at ~5 % SM. Read
`graph splits` from the loader log.
- **KV is usually the bottleneck:** `KV/token = kv_bytes_per_position ×
context` (attention reads the whole cache every token). Highest-leverage
placement: **KV on the fastest-memory device together with its attention
compute** — never KV alone over the link (GBs over 7.88 GB/s = hundreds of
ms/token). Check fit: `kv_positions × slots × bytes + attention_trunk ≤
VRAM`.
- **What placement never buys:** prefill is compute-bound and expert weights
are read once per batch — moving them buys nothing and adds a serial stage.
Calibration: Strix Halo + 3090, moving 12.5 % of expert mass to the GPU
gave decode +12 % but prefill 192 → 32 tok/s. The harmonic
`1/(f_big/BW_slow + f_fast/BW_fast)` reproduces the decode gain within ~2
points (assumes sequential stages — true at batch 1).
- **UMA check before assuming additivity:** `mem_info_vram_total` (BIOS
carve-out) vs `mem_info_gtt_total` (aperture onto SYSTEM RAM). A big GTT
window means the "GPU memory" IS the same LPDDR: adding a stage there adds
a compute engine, not capacity/bandwidth. Only discrete VRAM is additive.
- **Speculation as the "cheap" split:** bandwidth-free (token ids per round)
but needs the exact tokenizer for a plain drafter or a head trained for
that target; a plain drafter costs K sequential forwards (break-even needs
p ≈ 0.7+ at r≈0.4–0.5, K=3); compare against the INCUMBENT (a target
already committing ~1.7 tok/round on its own MTP head is a high bar); and
check the runtime accepts external drafters at all (closed engines 400
unknown drafter names by design). Details: [[DFlash DSpark Speculative Decoding]].
- **Hazards:** keep paged/sidecar tables where they are read from; keep mmap
ON for huge-embedding models (anonymous memory → cgroup kill); CUDA graph
capture can abort on unusual graph shapes; make the FIRST measurement a
single-device baseline of the runtime on the new device — a split's
arithmetic is worthless if the baseline loses to the incumbent; a split
consumes the fast device entirely — check what it was serving.
**Verdict ranking:** (1) two independent endpoints — often the real answer;
(2) one process, weights-resident, KV co-located with attention on the fast
pool; (3) hot-expert cache only if `misses × bytes × tok/s` fits the link and
overlaps — no mainstream runtime implements it; (4) name what does NOT help:
TP across unequal devices, KV on the far device, loading over the link,
prefill-motivated placements.
## 8. Harness syntax and verification
`llama-bench` separates a LIST with `/` and SWEEP values with `,`:
`-dev CUDA0,Vulkan1` is TWO single-device runs, never one two-device run;
`-ts 0.85,0.15` is two sweep values each becoming `[0.85, 0, …]` — device 0
takes everything. Write `-dev A/B -ts x/y`. Read the table's own `dev` column;
any placement-dependent number needs an independent memory check
(`nvidia-smi` sampling while loaded). (This artifact produced a wrong fleet
conclusion 2026-09-16 — "CUDA + Vulkan cannot serve one model" — with two
bogus OOMs that were really all-111-GiB-on-the-24-GiB-card.)
Verification battery: `/health`; both cards' memory ≈ half the weights each
(busier card also carries the drafter; compare `model_bytes/2 + drafter +
ctx×KV/2 + buffers`); raise ctx by N and watch BOTH cards rise by
N·bytes/2 — neither card's total may equal the model file (that arithmetic
rules out replication); `spec_decode_num_drafts_total` > 0 and a real
completion; a deep prompt (140k) proves a raised window is REAL, not
declared; repeat prompt → `timings.prompt_n` collapses (prefix cache live);
`/slots` shows expected `(id, n_ctx)` pairs; two concurrent requests with
`journalctl -o cat | grep 'slot print_timing'` proves drafting on BOTH slots
(default journal output truncates these lines).
Ops traps: a server started over ssh outlives stopping the runner (keeps the
port — `pkill -x <binary>` first); `systemctl start` of a root unit needs
sudo (polkit "Access denied" otherwise); old crash `ROCm error` lines live in
the journal forever — check `ActiveEnterTimestamp`/`NRestarts` before
treating a grep count as a live fault.
## Related
- [[PCIe Link and Atomics]] — the atomics wall, link probing, repair
- [[Llama Cpp Serving Sizing]] — slot-pool and KV pricing
- [[DFlash DSpark Speculative Decoding]] — spec types, chaining, A/B
- [[frank-medium-llamacpp-dflash2]] — the layer-split era runbook
- [[Dual-R9700-Cooperation]] / [[x4-pair-experiment]] — the measured history
@@ -0,0 +1,163 @@
---
type: reference
title: Llama Cpp Serving Sizing
description: Slot-pool sizing against a headroom band, per-device VRAM accounting, prompt-cache/state sizing and the QSA flash-attn fix — the measurement-first procedures for llama.cpp serving shapes.
tags: [llama.cpp, vram, kv-cache, slots, flash-attn, sizing]
timestamp: 2026-10-05T00:00:00-07:00
---
# Llama Cpp Serving Sizing
## 1. Slot-pool sizing against a headroom band
Goal: choose `CTX_SIZE`/`PARALLEL`/KV types so N conversations fit a GPU while
keeping a requested amount of VRAM free — from measurement, never arithmetic
guesswork.
1. **Fix the invariants.** Read the payload's KV geometry (attention layers
only, KV heads, head_dim, KV dtype bytes) and the per-slot rule
`n_ctx_slot = CTX_SIZE / PARALLEL`. A hybrid model with
`kv_unified = 'false'` gives each slot its own cache AND a fixed per-slot
state buffer — slot count is a memory term, not free.
2. **Two boots on the real card** (production stopped, per the bench law),
each near the target; record `nvidia-smi` used/total plus the boot log's
`n_slots`/`n_ctx_slot`: shape A = current/cheap, shape B = new demand.
`non-KV = used − (tokens × KiB/token)`; the two rows separate
weights/buffers/graphs from the new demand (a 918 MB projector bills
~1.4 GiB).
3. **Solve the band:** `KV_tokens ≤ (card − non-KV − headroom_low) /
KiB/token`, round the pool DOWN to a multiple of `PARALLEL × 4096` so every
slot gets an equal integer window. Boot and measure; if outside the band,
walk one step — never re-derive from a formula.
4. **Propagate the per-slot number — it is the client contract:**
`models.yml` `contextWindow` = `n_ctx_slot` (NOT the model's native window),
or long conversations die with `400 exceed_context_size_error` instead of
compacting; compaction/summary budgets sized to the old window get
re-checked; endpoint docs and the rollback table move too.
5. **Prove behaviour:** boot-log slot count, `nvidia-smi` free in band, N
concurrent requests answered in parallel with N+1 queued, and — if a
projector ships — a solid-color image probe through an **`image_url`
content part** expecting the exact color. The legacy top-level `images`
field is silently ignored, so a probe that uses it measures a
hallucination, not vision.
Gotchas: shorter slots can make decode FASTER (~5 % measured); the KV pool is
allocated whole at boot so post-boot `memory.used` IS the ceiling; record both
measured rows in the service `.env` comment block and mirror the non-KV price
in the bats budget gate so a shape that busts the band fails the suite instead
of the boot.
## 2. Per-device VRAM accounting ("where did the memory go?")
First decide which kind of memory is in question:
| device | kind | read it with |
|---|---|---|
| RTX 3090 (Dankclaw) | discrete GDDR6X | `nvidia-smi --query-gpu=memory.total,memory.used,memory.free` |
| gfx1151 iGPU (Dankclaw) | **UMA** — "VRAM" is host RAM | `/sys/class/drm/card1/device/mem_info_gtt_{total,used}` (114 GiB) vs `mem_info_vram_*` (2 GiB carve-out) |
| R9700s (Dankserver) | discrete | `rocm-smi` figure (64 GiB, SVM-backed) |
Per-process truth on NVIDIA:
`nvidia-smi --query-compute-apps=pid,process_name,used_memory`. Never add the
iGPU's GTT to host RAM as a separate pool; `free -g` is the arbiter for
co-tenants.
- **Engine ledger first.** Halogen prints weights pinned (+ remaining 2 MiB
contiguous blocks — the honest fragmentation signal: 240 blocks/12.3 GiB
after reboot vs 475/14.7 GiB before means the model did not come back into
the same hole), device budget, KV pool/working split, "left for everything
else". llama.cpp prints at `llama-model.cpp:1827` (per-backend model buffer)
and `llama-context.cpp:692` (compute buffer) — but **`llama-bench`
suppresses loader INFO and a systemd unit did not put them in the journal**
(verified `grep -c` = 0). Recipe: one launch through `llama-cli`/`llama-server`
keeping the first ~200 lines.
- **Otherwise compose from tensor sizes.** Group the GGUF tensor table by
role (Flash-Next example: 75.0 GiB experts at 1.5625 GiB/layer, 32.8 GiB PLE
table, 3.2 GiB everything else), KV ≈ 2 KiB/position per f16 attention
layer, × derived context. Vendor delta is real: same layers+KV cost 12.80
GiB under Vulkan vs 14.99 GiB under CUDA (~3.3 vs ~1.1 GiB of
graph/compute pool).
- **Traps:** mmap'd PLE is reclaimable page cache; `-ot` makes it pinned —
"move layers to the card" converts reclaimable cache into device memory and
frees nothing for co-tenants. A served model's footprint ≠ weight size
(frank-small: 22.6 of 24.1 GiB for an 11.29 GiB model). Do the capacity
subtraction before designing: 12 attn layers × 1.62 GiB + 200k f16 KV =
28.6 GiB > 24.1 GiB usable → KV-in-VRAM on that pair only reaches ~64k.
- **Context lengths: derive, never remember.** `llama-bench` computes
`n_ctx = n_prompt + n_gen + n_depth` (defaults 512/128/0, type_k/v F16,
flash_attn AUTO, split_mode LAYER). `-p 512,16384 -n 64` = contexts
576/16,448; a plain `tg` row has ~zero depth — `-d` is the only thing that
puts KV in the picture.
- **Label every number** measured / derived / inference, and state the context
each figure was taken at — 12.8 GiB at 65,568 tokens is not the same claim
as at 200k.
## 3. Prompt cache and state sizing
`--cache-ram` (MiB, default 8192) is HOST RAM for saved prompt states —
unpinned, on demand, moves no VRAM. `--ctx-checkpoints` (default 32) is a
different mechanism: per-slot KV snapshots; `0` disables them.
**State law** (compute before trusting any cap): a state is the serialized KV
of the whole sequence + constant recurrent state + the draft context's KV:
`per_token = 2 × n_full_attn_layers × n_kv_heads × head_dim × bytes (K and V) +
draft_per_token`. Measured on frank-medium (qwen3.5 hybrid, f16, MTP draft):
16×4×256×2×2 = 64 KiB/token + 4 KiB draft = **68 KiB/token**; a full
196,608-token slot state ≈ 12.8 GiB. Cross-check against the journal's
`prompt state size` lines — the two must agree.
**The journal is the instrument:** `exceeds cache size limit … skipping` =
cap binding in production; `making room … removing oldest` / `cache size
limit reached` = eviction, and the printed size proves the active cap. Do NOT
claim a raise is proven by `prompt_tokens_cached_total` rising — on exact
repeats that counter is 0 by construction.
**Hybrid + zero-checkpoint trap:** for a hybrid (DeltaNet/SSM + attention)
model, `seq_pos_min` sits at the END of the slot's own prompt (a recurrent
cell stores only the last position), so with `--ctx-checkpoints 0` there is no
checkpoint to fall back on and the server logs `forcing full prompt
re-processing … (likely due to SWA or hybrid/recurrent memory)`. Consequences:
intra-slot reuse only survives when the request echoes the assistant's
previous reply verbatim (a pure user resend is always a full re-prefill even
at f_sim 1.000); the RAM prompt cache becomes the ONLY carrier of a
conversation state ACROSS slots, so a cap that refuses deep states costs
minutes of re-prefill whenever slots are reassigned; state restoration only
runs via the LRU path (or LCP with f_keep < 0.5) — a plain prefix hit never
calls `prompt_load`.
**Verify a change:** live argv carries the flag (not the deploy log); `/health`
200 + real completion; VRAM unchanged and host RAM mostly free; no new
`exceeds cache size limit` lines, with a `making room` entry larger than the
old cap as direct proof. If the ceiling itself was not exercised live, say so —
crossing it needs a >120k-token conversation and must not run beside a live
service (bench law).
## 4. QSA flash-attn assert fix (qwen4exp)
`--flash-attn on` aborts graph build for qwen4exp models:
`GGML_ASSERT(a->type == GGML_TYPE_F32)` at `ggml.c:5371` through
`build_attn_qsa`. Root cause: `build_attn_qsa` calls `ggml_fill` on the
F16-cast kq mask returned by `get_kq_mask()` when flash attn is on, but
`ggml_fill` requires F32.
Fix (mirrors the MLA sparse path in `llm_graph_context::build_attn`): run the
top-k mask surgery on the F32 base mask (`inp->self_kq_mask`), cast to F16
only at the attention call when `flash_attn` is enabled
(`kq_mask_attn = cparams.flash_attn ? ggml_cast(ctx0, kq_mask_top_k,
GGML_TYPE_F16) : kq_mask_top_k`). The patch lives at
`scripts/patches/qwen4exp-qsa-f32-mask.patch`.
Only the charlie12345/ROCmFPX build-qwen38 (PR #27742 qwen4exp merged) takes
it; CIRU, upstream and stock builds do not. Validate live: load with
`--flash-attn on`, health 200, a real completion finishing `"stop"`, journal
clean of `GGML_ASSERT`/`cudaMalloc`/`missing tensor`, and flash-attn engaged
in the timings. Context cap for full-model residency on that shape stayed
32,768.
## Related
- [[GGUF Compatibility]] — what a payload is before it is priced
- [[frank-small-llama-mimo-q6k-2x200704]] — the current two-boot priced shape
- [[frank-medium-llamacpp-dflash2]] — where the 68 KiB/token datum was measured
- [[DFlash DSpark Speculative Decoding]] — the spec A/B protocol on llama.cpp
- [[Dual-R9700-Cooperation]] / [[x4-pair-experiment]] — the multi-device side
@@ -0,0 +1,244 @@
---
type: reference
title: PCIe Link and Atomics
description: Link probing (idle downshift, path ceilings, CUDA/HIP bandwidth probes), the streaming-vs-compute ceiling arithmetic, and the PCIe-atomics wall that decides whether AMD multi-GPU collectives can exist at all.
tags: [pcie, rccl, atomics, multi-gpu, bandwidth, amd, nvidia]
timestamp: 2026-10-05T00:00:00-07:00
---
# PCIe Link and Atomics
## 1. Degraded or just idle?
The trap: an idle GPU reports `LnkSta: Speed 2.5GT/s (downgraded)` — the
driver downshifts to Gen1 for power. That is not a fault and not the slot's
fault. Never conclude from an idle reading.
1. **Path ceiling from the kernel (cheapest, most decisive):**
`sudo journalctl -k -b --no-pager | grep "available PCIe bandwidth"` →
`pci 0000:c4:00.0: 63.012 Gb/s available PCIe bandwidth, limited by 16.0
GT/s PCIe x4 link at 0000:00:03.1 (capable of 252.048 Gb/s with ... x16)`.
The "limited by" device names the **bottleneck** (usually a root port) and
its width — that is the number a swap could move; "capable of x16" is the
endpoint's OWN capability, and the gap between them is the story.
`dmesg` rotates; `journalctl -k -b` keeps this boot.
2. **Both sides separately:** `LnkCap`/`LnkSta`/`LnkCtl2` on endpoint AND root
port. `LnkCtl2: Target Link Speed: 16GT/s` on both ends = the platform
intends Gen4, so a Gen1 current speed is power management, not
misconfiguration. A root port whose `LnkCap` width exceeds the trained
width means the socket/adapter wires fewer lanes — **M.2 connectors are 4
lanes by pinout; an M.2 riser can never deliver more.**
3. **Prove the link under load (the step people skip):** on NVIDIA sample NVML
while the GPU works
(`nvidia-smi --query-gpu=pcie.link.gen.current,pcie.link.gen.hostmax,pcie.link.width.current,pcie.link.width.max`):
`gen.current` climbing 1 → `hostmax` = idle downshift, healthy; stuck below
`hostmax` under load = real degradation; `width.current < width.max` under
load = **physical lane limit** — no software touches it. On a busy
production GPU this is a usage smoke test, not a benchmark — never stop a
live service to read its link.
4. **Do not plan hardware from `dmidecode -t slot`** — consumer firmware
labels Ethernet/SD controllers as "M.2 slots" and advertises slots in PCI
domains that do not exist. Trust `lspci -nn | grep "PCI bridge"` and
`lspci -tv` (empty root ports still enumerate; an absent port is absent).
5. **"Would moving the card help?"** Compare the bottleneck device from step 1
with the target port's `LnkCap` width; two M.2 sockets = both x4 = a
no-op. Clean `DevSta`/`CESta` with a low trained speed means "wired
narrow", not "failing". Report ceiling, card capability, what the idle
reading meant, and whether any physical change moves the number.
Gen4 usable payload after 128b/130b: **x4 7.877 GB/s, x8 15.75, x16 31.5**.
## 2. The engine shape decides whether the link matters at all
| engine shape | what crosses PCIe per token | link matters? |
|---|---|---|
| streams weights (expert LRU, paging) | the missed weights (~GB/token) | **yes — decisive** |
| computes offloaded weights in RAM (llama.cpp `-ot exps=CPU`) | activations only (~KB) | no; host RAM bandwidth decides |
| everything resident | nothing | no |
**Ceiling arithmetic:** `link time/token = active expert bytes / measured
H2D rate`; `ceiling = 1 / link_time × resident-fraction saved (LRU hits)`.
Compare against the compute-in-RAM path at host RAM bandwidth (256 GB/s
LPDDR5X on the Strix Halo). **If the link is >10x slower than the memory
behind it (x4 is 31x here), streaming loses by construction and no tuning
rescues it; doubling the link only doubles a losing number.**
Worked example (Dankclaw 2026-09-16): Gen4 x4 measured H2D **6.72 / D2H 6.60
GB/s** (85 % of spec) — pinned H2D ≈ async, pageable within 1 %, i.e.
link-limited not staging-limited. A 3 MiB slab (the expert-streaming
granularity) = 474 µs. A3B MoE at Q4_K_M (~1.70 GiB active/token) → 272
ms/token ⇒ **3.7 tok/s** (4.6 with 20 % resident) vs measured **20.46 tok/s**
with experts computed on the CPU, 25–26.5 on the iGPU. The streaming design
loses 3–5x.
## 3. Measure the link: two probes, same mechanics
Stop **all** production services first (fleet bench law) — the 3090's VRAM
may be nearly full, and the probe contends for the very link it measures.
**CUDA probe** (`pcie-link-test.cu`): sweep {3, 16, 64, 256 MiB, 1, 2 GiB}
with `cudaMalloc` + `cudaHostAlloc`, event-timed; per size measure sync H2D,
async H2D, D2H, one single transfer, and pageable H2D. `3 MiB` is the row
that matters for expert streaming; big sizes give the ceiling.
**HIP probe for RDNA4** (`pcie_bw.hip`) — the ROCm landmines each cost real
time, so encode them:
- `hipGetDeviceProperties` SEGFAULTS on some builds; use `hipGetDeviceCount`
+ `hipSetDevice` only. First line: `setvbuf(stdout, NULL, _IONBF, 0);` — a
segfaulting process loses block-buffered stdout and hides the crash point.
- Build with an explicit arch: `HIP_TARGETS=gfx1201 hipcc -O2
--offload-arch=gfx1201 -o p p.hip`; compile with `| grep -E "error:"`,
never `| tail -N` (truncation hid a missing `#include <cstring>`).
- `hipMemset`/`hipDeviceSynchronize` are `[nodiscard]` — use or cast.
- Mount the whole `/dev/dri` into ROCm containers; individual `renderD*`
nodes enumerate but fail context creation.
- `rocm-smi --showmetrics`: `pcie_link_width` is trustworthy, but
`pcie_link_speed` reads the ASPM idle value and `pcie_bandwidth_acc/inst`
are usually `N/A` on consumer cards — live traffic generally cannot be
sampled.
```cuda
// pcie_bw.hip — sweep sizes, pinned vs pageable
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <hip/hip_runtime.h>
static double one(size_t bytes, bool pinned, int iters) {
void *dev = NULL, *host = NULL;
if (hipMalloc(&dev, bytes) != hipSuccess) return -1;
if (pinned) { if (hipHostMalloc(&host, bytes, hipHostMallocDefault) != hipSuccess) return -1; }
else { host = malloc(bytes); }
memset(host, 0x5a, bytes);
hipEvent_t a, b; hipEventCreate(&a); hipEventCreate(&b);
for (int i = 0; i < 3; i++) hipMemcpy(dev, host, bytes, hipMemcpyHostToDevice);
hipDeviceSynchronize();
hipEventRecord(a, 0);
for (int i = 0; i < iters; i++) hipMemcpy(dev, host, bytes, hipMemcpyHostToDevice);
hipEventRecord(b, 0); hipEventSynchronize(b);
float ms = 0; hipEventElapsedTime(&ms, a, b);
double gb = (double)bytes * iters / (ms / 1000.0) / 1e9;
if (!pinned) free(host); else hipHostFree(host);
hipFree(dev); hipEventDestroy(a); hipEventDestroy(b);
return gb;
}
int main(int argc, char **argv) {
setvbuf(stdout, NULL, _IONBF, 0);
if (hipSetDevice((argc > 1) ? atoi(argv[1]) : 0) != hipSuccess) return 1;
size_t sz[] = {2459136UL, 3UL<<20, 16UL<<20, 64UL<<20, 256UL<<20, 1UL<<30};
for (int i = 0; i < 6; i++) {
size_t b = sz[i];
int iters = (b >= (1UL<<30)) ? 10 : (b >= (256UL<<20)) ? 20 : 100;
printf(" %10.3f MiB | pinned %6.2f | pageable %6.2f\n",
b/1048576.0, one(b,true,iters), one(b,false,iters));
}
return 0;
}
```
**Reading it:**
- **Engine-vs-link is the diagnosis, not the raw number:** compare
`engine_effective_GB_s` to `link_GB_s` at the SAME transfer size. Engine at
~40 % of the link at the same granularity = the wire is not the problem and
no hardware change will help.
- Achieved GB/s is itself the proof of the trained link state (6.7 GB/s only
exists at Gen4 x4) — no separate under-load `LnkSta` sample needed.
- **pinned ≈ pageable ⇒ pinning is not a lever.** async ≈ sync ⇒ link-limited
(the usual "pageable costs 2x" rule does not apply at this link).
- Small-transfer penalty (2.345 MiB: ~106 µs vs ~78 µs ideal, ~21 %) is
per-transfer overhead — recoverable only by BATCHING transfers, not by a
faster link. (Dankserver 2026-09-29: bulk 22.7 GB/s at the 2.344 MiB slab =
91 % of Gen5 x8, while the engine's effective read path ran ~9.5 GB/s — the
gap is the fork's gather software path, not the wire.)
- Both GPUs symmetric ⇒ no cross-device rebalance is available; measure both.
- Don't quote a derived line from inside a probe without hand-checking it —
a printf format/argument mismatch in one of these printed a nonsense
ceiling. GiB→GB before dividing.
## 4. The PCIe-atomics wall (AMD multi-GPU collectives)
**Symptom:** multi-GPU AMD host fails TP/PP; the failure surfaces as
`hipLaunchKernel: hipErrorIllegalState` for `ncclDevKernel_*` — nothing in
vLLM/RCCL/PyTorch names atomics. Get the real message:
```bash
AMD_LOG_LEVEL=3 <the failing process>
# → "Pcie atomics not enabled, hostcall not supported" → "AQL dispatch failed!"
```
**Cause chain:** RCCL kernels carry a hidden hostcall-buffer parameter; ROCr
refuses to fill it without PCIe atomics, which it reads **once at driver
probe** (`rocclr/device/rocm/rocdevice.cpp` `checkAtomicSupport()` — no else,
no override). **No env var, module param, or sysfs toggle exists** —
`setpci -s <gpu> CAP_EXP+28.w=0440` (DCTL2 bit 6) changes nothing at runtime.
**Check the CHAIN, not the card:**
```bash
sudo lspci -vv -s <gpu> | grep -E "AtomicOpsCtl|LnkSta|LnkCap" # want ReqEn+
lspci -tv # every hop
sudo lspci -vv -s <bridge> | grep AtomicOpsCap # want Routing+
```
`Routing-` is a read-only hardware capability. Typical AM5 layout: one x16
slot on CPU root ports (atomics fine), the rest behind the chipset (usually
not). Bandwidth for TP is small anyway: ~10 MB per token-step for a 27B at
batch 4 ≈ 1.4 ms at Gen4 x4 — compute-bound, not interconnect-bound.
**Probe matrix (bare 2-rank `torch.distributed`, one process per GPU, 25 s PG
timeout) — one probe answers several design questions:**
| Op | CPU-lane card | chipset card | Decides |
|---|---|---|---|
| `broadcast`/`all_reduce`/`barrier` | OK | FAIL | TP is dead |
| `send`/`recv` (P2P), in ISOLATION | hang | FAIL | **PP is dead too** — vLLM's PP handoff IS this primitive |
Test `send`/`recv` alone (a prior failed op leaves the group broken). Run the
probe from a REAL file (`torch.multiprocessing.spawn` re-executes it; piping
into `python3 -` fails with `FileNotFoundError: '/<stdin>'`). `world_size=1`
always succeeds — **one-card success proves nothing.**
**SAFETY — non-negotiable:** never run a GPU experiment beside a serving
unit: `sudo systemctl stop <svc> && sudo systemctl mask <svc>` first (mask,
never disable — disable deletes a relinked unit file), or use a host that
serves nobody. A hung AQL dispatch can wedge the GPU driver; a probe beside a
live service on 2026-09-16 preceded a hard host down (no ARP, no ICMP from
two subnets, power cycle required). Telling off from wedged afterwards: `ip
neigh show <ip>` from a peer — `FAILED`/`INCOMPLETE` = off/NIC dead; a wedged
kernel usually still answers ARP. Validate your own probe's naming before
reporting an outage (`gitea` vs `gitea-server`).
**Fixes, ranked:**
1. CPU-attached M.2 → PCIe x4 riser (~$30) — puts the card on the CPU root
complex; x4 suffices for TP.
2. Board whose CPU x16 bifurcates x8/x8 (done 2026-09-20 on dankserver — both
R9700s on root ports `00:01.1`/`00:01.3`, every bridge `Routing+`, both
endpoints `ReqEn+`).
3. llama.cpp `--split-mode layer` — no collectives at all ([[Llama Cpp Multi-Device Splits]]); costs the GGUF conversion plus paged attention /
continuous batching / spec decode.
4. Independent TP=1 replicas + routing proxy — cards never share one model.
**Closed software routes (do not re-litigate):** vLLM's IPC custom allreduce
is gated by a CUDA-sm-keyed table (`{9.0, 10.0, 10.3, 10.7}`; ROCm reports
12.0 → off on RDNA4 by code limit) — and patching it is insufficient because
TP init's `broadcast`/`barrier`/logits `all_gather` have no non-RCCL path. A
fork wrapping `.all_reduce` (e.g. radiance `RADIANCE_USE_R4D_AR` one-shot
P2P, gloo for IPC-handle exchange) does not intercept
`PyNcclCommunicator.__init__`'s warmup allreduce. NCCL symmetric memory needs
`ncclDevComm*` (many builds ship the version string without the device API).
P/D disaggregation needs a KV connector AND coordination collectives. gloo is
CPU/TCP — unreachable for per-layer TP.
**Open, unverified:** rebuilding RCCL so kernels stop declaring a hostcall
buffer (`-mprintf-kind=buffered` is the usual claim) — counter-evidence: no
live `printf`/`assert` in RCCL device source. Test path: rebuild, dump a
kernel's declared args (`roc-obj-extract` + `llvm-readelf --notes`), look for
`hidden_hostcall_buffer`; if gone, retry the 2-rank probe (service stopped).
## Related
- [[Llama Cpp Multi-Device Splits]] — the layer-split path around the wall
- [[Dual-R9700-Cooperation]] / [[x4-pair-experiment]] — measured history
- [[dankserver-qwen38-fp8-expert-offload-tuning]] — the live gather economics
@@ -0,0 +1,250 @@
---
type: reference
title: RDNA4 Container Serving
description: Bringing a model larger than VRAM onto Dankserver's 2x R9700 (gfx1201) with the tcclaviger vLLM stack — quant-class gating, fetch verification, container GPU-init triage, memory budgeting and the expert-offload plan.
tags: [rdna4, gfx1201, r9700, vllm, expert-offload, podman, rocm]
timestamp: 2026-10-05T00:00:00-07:00
---
# RDNA4 Container Serving
Bring-up path for Dankserver (2x Radeon AI PRO R9700, gfx1201, 31.86 GiB each =
**63.7 GiB real VRAM**, 246 GiB RAM, 7700X) with
`docker.io/tcclaviger/vllm` (vLLM 0.29.0.dev, GLM/Qwen-specialised RDNA4 build
with native MoE quant kernels and a first-class expert offload planner). The
tuning history and current deployed shape live in
[[dankserver-qwen38-fp8-expert-offload-tuning]]; this page is the bring-up law.
## 0. Know the box — and its phantom third GPU
`torch.cuda.device_count()` returns **3**; summing `total_memory` overstates
the box as 186.7 GiB. The third device is the 7700X's integrated Vega
(gfx1036): torch reports ~123 GiB for it, `rocm-smi` reports 0.50 GiB,
allocating on it moves system `MemAvailable` 1:1 (it is GTT over DDR5, not
dedicated VRAM), and it dies with `hipErrorInvalidKernelFile` when the image
ships no code for that arch. **Never sum device `total_memory`; never count
the iGPU as headroom or a workload.**
## 1. Preflight: is the quant class EXECUTABLE, not merely present?
Expert offload is mandatory when weights exceed VRAM, and it requires the
checkpoint's experts class to have BOTH a compiled kernel AND an
`offload_recipe` ("a pinned expert has no host copy, so there is no
read-through fallback" — startup aborts otherwise). Check BEFORE downloading
anything:
```bash
podman run --rm --device /dev/kfd --device /dev/dri --device /dev/mem \
-e HIP_VISIBLE_DEVICES=0,1 --entrypoint bash $IMAGE -c '
nm -D /app/r4dhip/r4d.so | grep -oE "gemm_moe_[a-z0-9_]+" | sort -u
grep -rl offload_recipe /opt/vllm/lib/python*/site-packages/vllm/model_executor/layers/fused_moe/'
```
| quant | kernel in `r4d.so` | experts class | verdict |
|---|---|---|---|
| FP8 | yes | `Fp8HipMoEExperts` | **serves** |
| MXFP4 / DSFP4 | yes (`gemm_moe_mxfp4a8_*` / `dsfp4a8_*`) | `R4dMxfp4MoEExperts` / `R4dDsfp4MoEExperts` | **serves** |
| W4A16 | **no** `gemm_moe_w4a16_*` | `R4dW4a16MoEExperts` exists | silently falls back to generic `TritonWNA16Experts`, no recipe → startup halts |
| RFA | none | no class at all | out |
Also confirm the architecture routes natively with NO overlay
(`ModelRegistry.get_supported_archs()` for `Qwen4Exp`/`Glm5`). The stock image
natively carries Qwen3.8/Qwen4Exp and GLM-5.x including MTP heads. **Do not
apply vendor overlays** against a fast-moving pre-release image — the breakage
misleads (a missing symbol in the overlay's OWN copy of a module masks the
real cause, then moves to another symbol each patch;
`cannot import name 'checkpoint_has_lm_head'` = overlay clobbered
`transformers_utils/config.py`). The image ships quantizers for
`rfa|rfi|mxfp4|mxfp4ocp|paro5` under `/app/tools/quantize.py`
(`--fp8-attn --quant-only --dry-run` first) if a re-quant is needed — note it
can run MXFP4/DSFP4/FP8 but only PRODUCE rfa/rfi/mxfp4/paro.
## 2. Fetch with verification, always
Gated repos: token never in argv (`pgrep -a curl` prints command lines).
Build a mode-600 curl config once:
```bash
{ printf 'header = "Authorization: Bearer '; cat ~/.cache/huggingface/token; printf '"\n'; } \
> ~/.cache/huggingface/curl.conf && chmod 600 ~/.cache/huggingface/curl.conf
curl -K ~/.cache/huggingface/curl.conf -f -sS -L -C - -o "$out" "$url"
```
`--fail` is MANDATORY — `curl -o FILE URL` exits 0 on HTTP 401/403 and writes
the error body as a "successful artifact" (a gated repo once yielded three
153-byte shards with identical sha256s and a cheerful DONE banner). `-C -`
resumes torn shards; HTTP/2 cancels leave silently truncated files, so
re-verify EVERY shard's byte count against the Hub manifest (and the LFS
sha256) before serving. The AGENTS.md Secret Rules carry the token-hygiene
law; [[frank-medium-model-swap]] has the same detail for GGUFs and
`.partNofM` fragments.
## 3. Serve — the known-good invocation and its traps
```bash
sudo sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches'
podman run -d --name <ctr> --ipc=host --ulimit memlock=-1:-1 \
--cap-add SYS_PTRACE \
--device /dev/kfd --device /dev/dri --device /dev/mem \
-e HIP_VISIBLE_DEVICES=0,1 -e VLLM_ROCM_USE_AITER=0 \
-v /home/dank/models/<model>:/models:ro \
$IMAGE /models --served-model-name <name> --port 7770 \
--tensor-parallel-size 2 --max-model-len 65536 \
--kv-cache-memory-bytes 2147483648 \
--max-num-seqs 4 --gpu-memory-utilization 0.95 \
--enable-prefix-caching --trust-remote-code
```
- **Entrypoint already execs `vllm serve "$@"`** — pass the model path as the
first argument and do NOT pass `serve` (podman then tries to pull `serve`
as an image name). `-e VAR=1` must come BEFORE the image name.
- **Mount the whole `/dev/dri`, never individual render nodes** (§4).
- `--ipc=host` and `--shm-size` are mutually exclusive in podman.
- `podman run -d` without `-a` sends container stdout nowhere — read
`podman logs <ctr>`; and vLLM writes `Application startup complete` to
**stderr**, so a `podman logs X 2>/dev/null | grep -q` readiness gate never
fires.
- Start at 65536, not the native ceiling; prove the plan, then scale.
- Load takes ~5 min with `/health` refusing the whole time — do not kill the
process (deploy.sh waits 20 min).
## 4. Container GPU-init triage (ordered)
1. **Enumerate-vs-context trap (most common):** individual
`renderD*` nodes give HIP the render nodes WITHOUT the primary `card*`
nodes — it enumerates agents (`device_count()` returns N) and then refuses
every context: `RuntimeError: No CUDA GPUs are available` on the first
real op. A/B-confirmed 2026-09-19. Rule: `--device /dev/kfd --device
/dev/dri --device /dev/mem`; restrict GPUs with `HIP_VISIBLE_DEVICES`,
never with device nodes.
2. **`device_count()` is not evidence.** Prove usability with a real op
(`(a@a).sum().item()` on a fresh `torch.randn`), then repeat via
`mp.spawn`, `mp.fork`, and `subprocess.run([sys.executable, ...])`, and
cross-check `rocm-smi --showmeminfo vram`.
3. **memlock rlimit → a MISLEADING CUDA OOM.** Host default 8192 KB; a pin
larger than the limit fails as
`torch.AcceleratorError: CUDA error: out of memory` raised from
`pin_memory()` — it names the accelerator but the fault is host pinned
memory. It cannot be raised from inside a container or even from root's
own shell (`Operation not permitted`); only PAM applies it at login, so
fixes only affect NEW sessions:
`/etc/security/limits.d/99-memlock.conf` with
`* - memlock unlimited` + `root - memlock unlimited`; the container needs
`--ulimit memlock=-1:-1` to inherit. Discriminating test after the fix:
the container reports `unlimited` AND small pins succeed; if pins still
fail, memlock was never the cause.
4. **`/proc/meminfo` TAB trap:** the key/value separator is a TAB, so
`grep MemAvailable /proc/meminfo | tr -s " " | cut -d" " -f2` returns
EMPTY and a following awk prints `0.0` — a false "host out of memory" on a
host with hundreds of GiB free, which invites aborting something healthy.
Tab-safe: `awk -v k='MemAvailable:' '$1==k {printf "%.1f", $2/1048576;
exit}' /proc/meminfo`, cross-check `<= MemTotal`, refuse to print a zero.
Use `scripts/dankstatus.sh` instead of hand-rolling.
5. **Silent-parse traps:** `grep -c PATTERN file || echo 0` yields TWO lines
(grep prints 0 AND exits 1) — `[: 0\n0: integer expected`; use
`| head -1` or `|| true`. `systemctl is-enabled` can emit multiple lines.
`rocm-smi --showmeminfo vram --csv` column order is `device, VRAM Total
Memory (B), VRAM Total Used Memory (B)` — reading $3 as total/$4 as used
yields 0.00; filter to `^card[0-9]+$`.
6. **Guard the box:** long loads with co-tenants can drive RAM to 0 — abort
before the OOM killer (`MemAvailable < 4 GiB → podman rm -f`, exit 1).
7. **Drop page cache before a large load** (`echo 3 > /proc/sys/vm/drop_caches`)
and confirm the drop was actually in effect for the run before treating a
failure as a capacity wall — a stale "it doesn't fit" from a contaminated
run is expensive to unwind.
## 5. Memory budgeting: the levers are not where they look
The planner builds `budget = total × util`, subtracts a KV reserve, and gives
the REMAINDER to the expert arena. Consequences, all measured:
- **`--gpu-memory-utilization` grows the ARENA, not the KV pool** (KV stayed
0.64 GiB at util 0.95 and 0.98). The arena is the only elastic term.
- **Lowering `--max-model-len` is the WRONG lever and backfires:** a smaller
context shrinks the reserve, handing the space to the arena — measured 65536
→ 0.64 GiB KV, 32768 → 0.34 GiB KV.
- **Hybrid-attention undercount:** the planner's per-token KV figure (9190
B/token for Qwen4Exp) misses the large fixed per-sequence Mamba state
(~2x), so the reserve is always short and the arena silently covers it.
Fix by pinning the pool: `--kv-cache-memory-bytes <BYTES>` (2 GiB →
113,595 tokens / 3.47x concurrency at 32k). For long context,
`--kv-offloading-size` (GiB) spills KV to host.
- **`--expert-offload-mem` caps the HOST side, not the VRAM arena**
(`cap = int(x * GIB)` against `host_memory_available()`). Do not reach for
it to fix a VRAM problem; to grow the arena, shrink the KV pin or raise
util (see [[dankserver-qwen38-fp8-expert-offload-tuning]] for the
util=1.0 unbootability law).
**Read the plan — it is the real acceptance test:**
```
expert offload: AUTO-ENABLED, weights 62.54 GiB per rank exceed the 30.27 GiB budget (TP2)
budget (total x util) 30.27 GiB
non-expert weights 6.28 GiB
KV reserve 0.36 GiB <- sanity-check against the model's own per-token figure
CUDA graphs 0.66 GiB
runtime overhead 7.60 GiB
expert cache space 15.36 GiB 6712 slots of 2.344 MiB
host-backed experts/rank 52.74 GiB 23040 experts
host: 105.48 GiB across 2 ranks of 160.89 GiB usable
```
Host fits when `host_total < usable`. The PLE n-gram table rides memfd and is
SHARED across TP ranks.
**Loading mechanism** (for reasoning about peak RAM): weights never materialise
in VRAM — expert Parameters start as CPU staging tensors; each layer's
`weight_loader` counts shard events and, on layer completion,
`ExpertTopology.split_layer` moves that layer's experts into the VRAM arena
and compacts the rest into exact-size pinned host tensors behind UVA. Peak
host RAM = `host-backed set + layers still loading`, not the whole model. At
runtime `apply_hot_cold` sends arena-resident experts down a hot GEMM and the
rest straight over PCIe via UVA views — no staging copy in the critical path.
**Emulation check:** a dequantize-to-compute path doubles real host RAM (118
GiB offload request → ~238 GiB) and is invisible in the weight count. Read the
log for `Using 'EMULATION' Mxfp4 MoE backend` before concluding "doesn't fit".
## 6. Performance profile and honest measurement
Decode on this pair is **PCIe-gather-bound, context-independent**: 12.11
tok/s FLAT from 304 to 16,095 context tokens — KV attention is not the cost.
With ~29 % of experts arena-resident, ~0.84 GB crosses PCIe per token at the
engine's effective read rate; cold-vs-warm differing ~10 % confirms the LRU
works; growth is linear in hit rate, and only speculative decode cuts
sequential STEPS rather than making each cheaper.
**Benchmark traps that produced fake numbers here:**
- `max_tokens=1` is not TTFT (the server can satisfy it without doing the
work — prefill looked flat at 1.26 s for 16k AND 30k prompts). A 64-token
non-streamed reply is time-to-64-tokens (decode-bound, flat ~9.6 s at every
length). Correct TTFT: stream and time the FIRST content-bearing chunk.
- A small repeating word loop is prefix-cacheable AND n-gram-predictable — a
43-word cycle reported 22,897 tok/s prefill, almost entirely cache hit. Use
a large vocabulary in shuffled order and print `cached_tokens`.
- ~1.2 s fixed per-request overhead swamps prefill below ~16k tokens; compare
prefill at 30k+ only.
- Size prompts with `/v1/messages/count_tokens` (a chat call for counting
pollutes the metric); guessing words-per-token silently overran the window
(HTTP 400).
- Always run a canary: plant a unique token at the END of a long prompt and
ask the model to quote it — proves prompts reach the model at all.
- Measure warm AND long (`podman exec` + urllib at 127.0.0.1:7770): a 33-token
reply read 14.6 cold / 20.3 warm; a 768-token generation hit 25.7; two
concurrent streams scaled to 47 aggregate.
**Levers (all opt-in):** `--expert-prefetch N` takes an INT depth, not a
boolean (default 0; at depth 1 only ~10 % cold-vs-warm difference — the cache
already works); `CLAV_TUNABLEOP_SWEEP=1` autotunes GEMM shapes (silently
skipped under expert offload unless `CLAV_TUNABLEOP_WITH_OFFLOAD=1` too —
see [[dankserver-qwen38-fp8-expert-offload-tuning]] for why it was rejected);
`--expert-precise-memory` swaps the fitted overhead figure in.
## Related
- [[dankserver-qwen38-fp8-expert-offload-tuning]] — deployed shape, plan law,
measured campaigns
- [[PCIe Link and Atomics]] — why decode is gather-bound and what cannot fix it
- [[RDNA4 MoE Expert Offload VRAM Budget]] — arena/plan arithmetic deep-dive
- [[frank-large-benchmark]] and [[frank-small-benchmark]] — window discipline on live hosts
@@ -0,0 +1,219 @@
---
type: reference
title: RDNA4 MoE Expert Offload VRAM Budget
description: The expert-offload plan arithmetic on the tcclaviger fork — which terms a deploy can move, the util law, the CUDA-graph charge, what free VRAM really is, knife-edge survivability, parallelism ceilings and the context ladder.
tags: [vllm, expert-offload, vram, arena, kv-cache, ladder, tcclaviger]
timestamp: 2026-10-05T00:00:00-07:00
---
# RDNA4 MoE Expert Offload VRAM Budget
For VRAM-bound vLLM on the tcclaviger RDNA4 fork (frank-primary: Qwen3.8-
Flash-Next FP8, TP=2, 2x R9700 at 31.86 GiB each, expert offload to host RAM).
Tuning history and the deployed shape live in
[[dankserver-qwen38-fp8-expert-offload-tuning]]; boot-failure capture lives in
[[vLLM Boot Forensics and Config Interrogation]].
## 1. The plan law
```
arena = budget − non_expert − kv_reserve − cuda_graphs − runtime_overhead
budget = device_total × gpu_memory_utilization # expert_plan.py build_plan
slots = arena // expert_bytes # 2.344 MiB/slot ⇒ 437 slots/GiB
```
**The arena is DEVICE-resident, so the plan predicts reality:** the fp8
3-window shape's plan totalled 28.27 GiB and `rocm-smi` measured exactly
28.27 GiB. `weights + pinned_pool + graphs + arena + non-torch (≈1.45 + 0.57
driver reserve)` vs 31.86 GiB predicts a boot failure before it happens.
Which terms a deploy can move:
| term | reachable? | how |
|---|---|---|
| budget (util) | **yes, law-capped** | `--gpu-memory-utilization`; +0.01 = +0.32 GiB on 31.86 GiB cards |
| CUDA graphs | **yes** | container env `VLLM_CUDAGRAPH_MEM_BASE_MB` / `_PER_GRAPH_MB` (or `_ESTIMATE_MB` for an explicit total) |
| non-expert weights | no | checkpoint census |
| KV reserve | no | registry constant: 9190 B/token × max_model_len × 1.08 + per-seq mamba pages |
| runtime overhead | partly | `--expert-precise-memory` swaps default 7.60 GiB for the registry-fitted 5.25 GiB |
Anything else is an IMAGE EDIT, not a deploy — say so; do not invent a flag
(verify with `podman exec <ctr> grep -n getenv …/offloader/expert_activation_registry.py`).
**UTIL LAW: 0.982, NEVER 1.00 (measured 2026-10-03).** The CLI field is
`Field(gt=0, le=1)`, but the worker init demands `free > budget`: at 1.0
budget == device total and BOTH ranks raise
`ValueError: Free memory on device cuda:N (31.29/31.86 GiB …)` BEFORE a
single weight loads — a crash loop (NRestarts ~2 min apart, zero payload
progress). Not a knife-edge: **unbootable**. Even bootable near-ceiling
shapes die under real load (0.13 GiB free bf16 boot; `0 bytes free` on a real
995-token request).
## 2. The CUDA-graph term: charge the measured capture
Defaults (256 MiB base + 52 MiB/graph) over-charge badly. Measure with the
engine's own post-capture line `Graph capturing finished … took X GiB` (per
rank; heavier rank binds). Measured on the no-spec piecewise plan: 0.31/0.17
GiB, and one boot reported a NEGATIVE −0.06 GiB delta (capture fit inside
pages the profiler had already counted) — the analytic base pool is pure
over-charge. Shipped: `BASE_MB=0`, `PER_GRAPH_MB=12` = 0.07 GiB — per-graph,
so a spec-decode shape (18 graphs) still pays (0.21 GiB). An explicit
`_ESTIMATE_MB` total does NOT scale with ladder changes; base+per-graph does.
**Verify every deploy:** `Estimated CUDA graph memory` line vs the `Graph
capturing finished` line; if capture exceeds the charge, raise `_PER_GRAPH_MB`
back toward 52. Check `VLLM_CUDAGRAPH_*` actually present in
`podman exec … env`.
## 3. The pool is invisible to the plan; free VRAM is the estimator gap
`--kv-cache-memory-bytes` does NOT appear in the arena arithmetic — the plan
reserves the registry price, not the actual pool. Raising the pool consumes
real free VRAM without growing the arena; the registry over-prices the pool
(9190 B/token planned vs 7,891.8 measured at 200704 fp8 — 7,701 at 262144;
**never reuse a B/token ratio across geometries**, and subtract ~3.87 MB
block-alignment loss when converting a token target to bytes). The ask lands
block-aligned: 3,166,500,000 B → 401,408 tokens; verify with
`GPU KV cache size: N tokens, Maximum concurrency … X.Xx`.
**Driver-reported free VRAM after hours of traffic is NOT allocatable
arena.** It is the estimator gap: the registry over-prices KV and charges
5.25 GiB overhead against ~2.5 measured; torch's allocator does not return
slabs, so it reads as the high-water mark. The only reachable levers are util
(within the law) and the graph charge; everything else needs a registry edit
inside the image — the fork's own log line says as much.
**MNBT is arena-free up to the anchor:** registry overhead =
`base + 0.22 GiB × max(0, (nbt−4096)/1024)` anchored at nbt 4096, so rungs
1024/2048/4096 are plan-identical (the fitted 5.25 GiB was fitted AT 4096).
MNBT changes re-key the compile range (one recompile, ~24 s at (1,2048) with
the mounted cache). 512 doubles TTFT at 4064 ctx; 12288 costs ~2.5 % plus
arena. MNBT does not move the KV pool.
## 4. Registry "compile headroom" is not reclaimable slack
`--expert-precise-memory` selects the fitted overhead (5.25 GiB, this arch at
TP=2) over the default (7.60 GiB = fitted + **2.35 GiB of compile and
graph-capture headroom** the registry documents as "transient VRAM the
profiler never sees"). It moves exactly one term — the plan's `room` — so the
arena grows by that 2.35 GiB (~1,025 slots). **That headroom is the budget
for the phase that allocates LAST**: reclaiming it requires paying elsewhere
(smaller pinned pool, fewer sessions, shorter context). Measured 2026-10-01:
bf16 2×262,144 + precise put the total at 31.73 of 31.86 GiB and the boot
crash-looped at graph capture; dropping the flag restored ~2.4 GiB and booted.
## 5. Boot ≠ survival (knife-edge shapes)
A shape can pass EVERY boot gate — weights, profile, KV allocation, graph
capture, `/health` 200 — and die on the first real request. Measured
2026-10-01: `5 × 262144 @ fp8` booted (KV 1,350,182 tokens / 5.15x) and was
killed seconds later by a 995-token prompt with `max_tokens=261149`:
`torch.OutOfMemoryError: Tried to allocate 50.00 MiB … 0 bytes free`. After
any knife-edge change: send a LARGE-`max_tokens` request before calling it
done, and read post-boot free VRAM per rank (`rocm-smi --showmeminfo vram`)
— the survivor had ~3.6 GiB free; the corpse had 0.
**KV accounting — pin the pool BYTES, floor-sized:** set
`--kv-cache-memory-bytes` PER RANK to the contract floor
(`seqs × MAXLEN × measured B/token`), not the native window — extra pool bytes
come out of the arena/headroom and buy nothing. Measured B/token on this
hybrid: **7,701 fp8 / 13,839 bf16** (decomposed: attention 6,138/12,276 +
dtype-independent mamba/side 1,563). Measured rates are shape-dependent —
7,891.8 at the 200704×3 shape. Capacity does NOT scale with `SEQS`: the
planner's KV reserve is for ONE max-model-len request.
**Parallelism ceilings you cannot flag past:**
- `--decode-context-parallel-size` refused for GQA when `TP ≤ num_kv_heads`:
*"requires TP (2) to be greater than the model's total number of KV heads
(2)"* — DCP shards KV along the sequence dimension AFTER KV heads spread
across TP ranks (it reuses TP ranks, not TP×DCP), so a 2-KV-head model
needs TP ≥ 4 = four GPUs.
- EP (`--enable-expert-parallel` + `--all2all-backend`) exists but REPLACES
the fork's local tiered-offload gather with a per-layer PCIe all-to-all.
- DP requires a full model copy per replica — on a model larger than the
card it is impossible, not slow.
## 6. The context ladder (find the max bootable window by measurement)
"Max possible" = the largest value that BOOTS, measured — never the model's
spec maximum, never hand arithmetic.
1. **Scale the pool with the window.** If KV_BYTES is pinned, it must shrink
in lockstep with MAXLEN or the rung tests nothing:
`KV_BYTES = round(KV_BYTES_ref × (seqs×MAXLEN_new) / (seqs×MAXLEN_ref))`
off a booted reference rung. Verify the rung's capacity still covers
`seqs × MAXLEN` from the engine's own line.
2. **Boot under the PRODUCTION environment, not a shell probe** — a rootless
shell lacks `LimitMEMLOCK=infinity` and can pass where production OOMs.
Transient unit:
`sudo systemd-run --uid=<svcuser> --unit=probe-$ML -p LimitMEMLOCK=infinity -p Restart=no env <ENVFILE_OVERRIDE> <serve-script>`
pointing the serve script at a scratch env file via its `*_ENV_FILE` hook.
3. **Judge on health + unit liveness:** poll `/health` (200 up / 503 loading
/ 000 not bound); break early when the unit goes inactive — that is the
failure signal, not a timeout.
4. **Capture the engine log per rung BEFORE teardown** (`podman logs` dies
with `podman rm`; a `podman logs` issued too soon after `podman run -d`
fails with *no container with that name* — record UNKNOWN, never FAIL).
5. **Ladder down on FAIL, one bonus step UP on PASS.** Measured steps on this
box: OOM −16,384 tokens, bonus +4,096. Tiny margins (76 MiB class) mean
the next rung down almost certainly passes: `Tried to allocate 76.00 MiB …
0 bytes free … 30.43 GiB allocated by PyTorch` died in graph CAPTION;
`Tried to allocate 10.28 GiB … 9.46 GiB free` died in KV ALLOCATION.
6. **Clean between rungs:** stop the transient unit, `podman rm -f`, kill
orphans by explicit PID
(`ps aux --sort=-rss | awk '/vllm\/bin\/python3/ && $6 > 50000000 {print $2}'`),
wait for `MemAvailable` to recover.
**The hazard that cost production:** a leftover ladder script whose teardown
`podman rm -f`s the production container fires against a freshly DEPLOYED
engine. Teardown runs ONLY on failure, never after a pass; kill the whole
scratch process tree and verify none remain BEFORE deploying. On PASS: print
the measured `GPU KV cache size / Maximum concurrency` line, LEAVE IT
SERVING, and pin the number into the repo (env + seam + harness
contextWindow) before deploying.
Cutover order: ladder → winner passes and serves → repo env + seam + harness
→ tests → commit+push → verify no scratch scripts remain → stash foreign work
(`git stash -u`) if the tree is dirty → deploy (one restart) → verify health
+ engine flags in the log.
**Measured ladder on this box (200704×3, fp8 KV, precise memory):**
| shape | util | graphs | arena | result |
|---|---|---|---|---|
| E | 0.98 | 0.55 | 17.22 GiB / 7,522 slots | boot ok |
| F | 0.982 | 0.32 | 17.52 GiB / 7,653 slots | boot ok |
| 1.0 | 1.00 | 0.07 | — | UNBOOTABLE (worker-init ValueError, both ranks) |
| G (deployed) | 0.982 | 0.07 | 17.77 GiB / 7,762 slots | KV 401,408 tokens / 2.00x; 0.89 GiB real free/card held through a live 4,096-token generation at ~38–39 tok/s |
bf16 ceilings at util 0.98: 3 seqs → 241,664 passes / 245,760 and 262,144 OOM
at graph capture; 6 seqs fp8 → 10.70 GiB ask with 9.18 GiB free (fails) —
~207k per context max.
## 7. Arena audit procedure (when asked for "more experts")
1. Read the live plan before proposing anything — `grep -oE` with a
digit-anchored tail (plain grep/journalctl truncate these columns to
`17.`):
```
ssh dankserver 'sudo journalctl CONTAINER_NAME=frank-primary-qwen -b -o cat --no-pager \
| grep -aoE "(budget \(total x util\) +[0-9.]+|expert cache space +[0-9.]+ GiB +[0-9,]+ slots|Estimated CUDA graph memory.{0,80}|Graph capturing finished.{0,40}|GPU KV cache size.{0,90})"'
```
2. Classify each term (§1 table); take only the two reachable levers.
3. Verify after deploy: plan lines show new budget/graphs/arena;
`GPU KV cache size` matches the ask; graph-estimate vs post-capture;
`rocm-smi` high-water per card = the real margin;
`systemctl show frank-primary -p NRestarts --value` = 0; a real chat
completion from a PEER (health-probe via `192.168.0.235:7770/health`, not
`localhost` inside an ssh session on the host — the latter returns `000`
and looks like an outage).
## Related
- [[RDNA4 Container Serving]] — the bring-up path and container traps
- [[vLLM Boot Forensics and Config Interrogation]] — capture and contracts
- [[dankserver-qwen38-fp8-expert-offload-tuning]] — measured history
- [[Llama Cpp Serving Sizing]] — the llama.cpp equivalent
@@ -237,6 +237,46 @@ vLLM supports multiple parallelism strategies:
- Expert Parallelism (EP) distributes expert networks in MoE models
- Context Parallelism (CP) splits context across GPUs
## Chunked Prefill — the flag surface, and what it actually does
**YES, the runtime exposes chunked-prefill options, and the running shape
already uses them.** The tcclaviger fork is a vLLM fork, so the standard
scheduler flags are present in the shipped image (verified by reading the
`vllm.engine.arg_utils` and `vllm.config.scheduler` source in the live
container `frank-primary-qwen`, not from a stub):
| Flag | Default | What it controls |
|---|---|---|
| `--enable-chunked-prefill` | `True` in `SchedulerConfig`; the CLI arg is an auto-flag (`"default": None`) | whether prefill may be split into chunks |
| `--max-num-batched-tokens` (MNBT) | 2048 (`DEFAULT_MAX_NUM_BATCHED_TOKENS`) | tokens processed per prefill iteration |
| `--prefill-schedule-interval` | 0 | gap between prefill iterations |
The running shape's argv carries `--max-num-batched-tokens 2048` alongside
`--max-model-len 200704`, so chunked prefill is **ON and already active**: a
200704-token prompt is prefilled in ~98 iterations of 2048 tokens, not in one
shot.
**It is a TTFT lever, not a throughput lever.** MEASURED 2026-09-29 on the
ad hoc qwen-fp8 container (the same fork, same payload, same TP=2):
- MNBT 8192 vs the default 2048: **neutral** on decode at every depth.
- Small values re-chunk prefill only. They hurt TTFT (each chunk is its own
iteration, so the first token waits on more iterations) and they never touch
decode: a decode step is `max_num_seqs x (spec+1)` tokens, independent of
MNBT.
**There is no `--ubatch-size` equivalent in vLLM.** `--ubatch-size` is a
llama.cpp compute-buffer concept (`--batch-size`/`--ubatch-size`); the tcclaviger
fork has no ubatch knob at all. The llama.cpp compute-buffer pair that frank-small
was doubled on (2026-10-05, +4.6 % prefill at 8,928 tokens for 616 MiB) has no
counterpart here, and the two backends should not be reasoned about by analogy.
**MNBT is not a lever on this shape either.** The deployed 2048 is the fork's
default, so a change is a deliberate departure from the shipped value, not a
tune. The one thing MNBT does cost: every change re-enters the compile RANGE
path, so `FRANK_PRIMARY_CACHE_DIR` pays one recompilation.
## See Also
- [[VLLM Index]]
@@ -168,11 +168,46 @@ Not advisable.
| **Affinity** | yes, built here | **V4-Flash only** — "the geometry is compiled in; there is no architecture abstraction" | 110 tok/s for 284B. Best engine for this box by an order of magnitude, wrong model |
| **tcclaviger vLLM fork** | **yes — proven 2026-09-29** | qwen4exp measured 37.4-40.6 tok/s decode (see [[dankserver-qwen38-fp8-expert-offload-tuning]]); **`glm5next` registered** (`Glm5NextForCausalLM`/`MTP`, backend-split `amd/`/`common/`/`nvidia/`, `sparse_indexer.py` for DSA); r4d.so has **native mxfp4 + dsfp4 MoE entries** (`has_dsfp4() == True`); W4A16 MoE class (`R4dW4a16MoEExperts`) wired into the same tiered offload; TunableOp silently skipped under offload (needs `CLAV_TUNABLEOP_WITH_OFFLOAD=1`, and then corrupts numerics — do not use) | the ad hoc qwen-fp8 container's runtime; **the old "MXFP4 = emulation/missing kernels" verdict predates this fork and is dead** — the GLM-5.3-Flash gate is now the payload format + a boot probe, not kernels |
| **Lucebox** | yes, image on host | Qwen 3.8, DS4; **V4.1 on the unmerged `feat/ds41-engine` branch** | The only V4.1 path. **Refuses layer split and layer-major prefill for V4.1** |
| **radiance-vLLM** (drwolfen) | yes — the active frank-primary | welded to `qwen4_exp`; PLE / QSA / packed-HC live inside that model class | 12–20 person-days per architecture |
| **radiance-vLLM** (drwolfen) | yes — served frank-primary 2026-09-27 → 09-30 | welded to `qwen4_exp`; PLE / QSA / packed-HC live inside that model class | 12–20 person-days per architecture |
| **FreeToken-ROCm** | partial | block-FP8 safetensors only (modelopt); known `CpuMoeExecutor` issue under ROCm | cannot load a GGUF |
| **llama.cpp** | yes | GLM-5.3 confirmed by a community R9700 guide; DS-V4.1 CED unverified | no expert LRU; **8 CPU cores** make CPU expert placement the wrong trade |
| **TokenSpeed** | claimed | GLM-5.x incl. a 5.3-Flash recipe | NVIDIA-first, unverified on gfx1201 |
| **KTransformers** | no | GLM-5.3 tutorial exists | built for Intel AMX + NVIDIA |
| **radiance** (StillDeadcode — the standalone C++/HIP engine, NOT the vLLM fork of the same name) | image `stilldeadcode/radiance` prebuilt for gfx1201 | qwen35 dense/MoE at fp8, qwen4exp quantised-at-convert, llama-shaped | see the probe below: our FP8 payload is REFUSED; tiers, MTP and vision native |
### radiance (standalone) — 2026-10-05 probe
`codeberg.org/StillDeadcode/radiance` — Deadcode's from-scratch engine
(v1.0.13): same author as the `vllm-radiance` fork we ran in September
([[frank-medium-radiance-mxfp4]]), an unrelated codebase — C++/HIP, no
Python, kernels/architectures/quantisers as `.so` plugins, flags written
for two 32 GB R9700s, native Qwen3.8-Flash-Next with `--placement
expert_tiered`, MTP and vision.
Probe with the DEPLOYED payload (`rad-convert --plan-only`, which stops
at declare — no GPU touched, no bytes written):
```
E no architecture plugin claims 'qwen4exp' at quantisation 'fp8'.
'qwen4exp' is served at: (unquantised).
```
So the 173 G orcarouter FP8 file cannot enter this engine at all: the
qwen4exp plugin takes bf16/f16/f32/GGUF in and every quantisation happens
at `rad-convert` time by recipe. Not a lock-in to their published quant —
recipes are per-weight-class rules (`rtn`/`gptq`/`awq`/`autoround`/
`paroquant`/`ggml`, grids from fp8 128×128 to i4/w4nl/w5nl codebooks,
custom `--quant` rules go ahead of the file's), and libr4d lists routed
experts-as-fp8 among the encodings it reads — so today's quantization is
reproducible **from a bf16 source**, not from our file. Uncensored-family
bf16 source candidates: `windowsxp811203/Qwen3.8-Flash-Next-Abliterated-GGUF`
(ships the bf16 GGUF) and `mradermacher/Qwen3.8-Flash-Next-Uncensored-GGUF`
(i1 quants only). 622 G free fits source + container. A trial is:
download → `rad-convert` (CPU-side) → stop+mask frank-primary window to
serve. Caveats on record: `--tp-wire wht6` is a lossy int8 all-reduce
wire; the recurrent GDN state is f32 by default (144 MiB/sequence) with
an opt-in f16 that changes generated text; every pool is budgeted
explicitly — no profiling pass, no utilisation fraction.
The two-R9700 three-owner split (primary + secondary + stream) is **not
available on this host**: the retired quadlet recorded that
@@ -275,35 +275,357 @@ Depth caveat: every number above is at a near-empty KV. Deep-context
behavior (the gather win growing with depth, bf16's decode cost at depth)
is only sampled at 3 depths; a 64k-192k-depth probe is the flagged gap.
### The deployed shape (2026-10-02: 2 x 200704, fp8, pool cut for the arena)
### The deployed shape (2026-10-03 shape G: 3 slots x 200704, fp8, precise memory, MNBT 2048, arena at the flag ceiling)
```
FRANK_PRIMARY_MAXLEN=200704 x SEQS 2 (401,408 tokens of contract)
FRANK_PRIMARY_KV_BYTES=3150000000 PER RANK (fp8, 7,701 B/token)
FRANK_PRIMARY_KV_DTYPE=fp8 --kv-cache-dtype fp8 (quantized)
FRANK_PRIMARY_MNBT=1024 --max-num-batched-tokens 1024
FRANK_PRIMARY_MAXLEN=200704 200704-token window
FRANK_PRIMARY_SEQS=3 --max-num-seqs 3 (SEQS=2 CANNOT boot - below)
FRANK_PRIMARY_KV_BYTES=3166500000 PER RANK (fp8, 7,892 B/token -> 400,700 tokens)
FRANK_PRIMARY_KV_DTYPE=fp8 --kv-cache-dtype fp8 (quantized)
FRANK_PRIMARY_MNBT=2048 --max-num-batched-tokens 2048 (arena-free, see shape F)
FRANK_PRIMARY_UTIL=0.982 budget = total x util, the law (1.0 unbootable, see below)
FRANK_PRIMARY_CUDAGRAPH_BASE_MB=0 / _PER_GRAPH_MB=12 -e VLLM_CUDAGRAPH_MEM_*
FRANK_PRIMARY_PRECISE_MEMORY=1 --expert-precise-memory (overhead 7.60 -> 5.25 GiB)
FRANK_PRIMARY_EXPERT_OFFLOAD_MEM=120 --expert-offload-mem (host-region cap, explicit)
FRANK_PRIMARY_GPU_MAX_HW_QUEUES=2 -e GPU_MAX_HW_QUEUES=2 (HSA queues/GPU)
FRANK_PRIMARY_CACHE_DIR=/home/dank/.cache/frank-primary mounted at /root/.cache
R4D_LRU_GATHER_CHUNKS=32 / R4D_LRU_GATHER_LANES=64 (container env)
NO --speculative-config, NO --expert-prefetch
FRANK_PRIMARY_NO_PREFIX_CACHING=1 --no-enable-prefix-caching (2026-10-05, the QFN align-mode GDN state fault — below)
```
This replaced the 2026-10-01 shape — 2 x 262144 at bf16 KV with
`--expert-precise-memory` — which crash-looped at graph capture: the arena is
DEVICE-resident, the precise-memory flag drops the plan's overhead 7.60 ->
5.25 GiB, and that shape had 0.13 GiB to spare (weights 6.28 + pool 6.80 +
graphs 0.55 + arena 16.65 + non-torch ~1.45 = 31.73 of 31.86 GiB,
NRestarts=3 at ~6.7 min each). The plain bf16 2 x 262144 boot also did not
stay up: it left 0 bytes free at runtime and a real client request (995-token
prompt, max_tokens=261149) killed the worker with `Tried to allocate 50.00 MiB
### 2026-10-05 — prefix caching OFF: the fork's QFN align-mode GDN state fault
**Symptom (Frank, 2026-10-04):** output turned repetitive/corrupted at
~40k tokens into a long conversation; short sessions stayed clean.
**Evidence (live `podman logs frank-primary-qwen`, the 2026-10-04 18:19
boot):** one 0.6 s burst at 18:46:07 PDT — 5.6 h into service, one
running request, and the engine's own 18:46:08 log line reading
"Prefix cache hit rate: 48.1%". 48 `gdn_hip` faults, then the
process-lifetime silence cap:
```
[gdn_hip] ERROR gdn_decode_r: state out of ssm-cache range: max|S|=0
with non-finite values (slot=42 head=3 limit=3.40282e+38) fault #1-23
[gdn_hip] ERROR gdn_decode_r: ... (slot=42 head=8 ...) fault #24-46
[gdn_hip] ERROR: state fault report limit (32) reached; further faults
are counted silently
```
One physical SSM slot (42), two heads (3 and 8 — one per TP rank), 23
GDN layers each, the state non-finite (NaN). The guard's own text in
this build: *"known Qwen3.8-Flash-Next (QFN) flaw under prefix caching
and is being actively worked; output quality for this request may
degrade. Mitigation: --no-enable-prefix-caching."*
**Mechanism.** With prefix caching on, the fork defaults QFN to
`mamba_cache_mode=align` (boot log: "Mamba cache mode is set to 'align'
for Qwen4ExpForConditionalGeneration … when prefix caching is enabled";
`config/cache.py`: "none" is the no-prefix-cache mode, "all" is
deprecated — align is the only sound mode WITH the flag). Align mode
checkpoints each sequence's GDN (Gated DeltaNet) recurrent state into a
mamba page per block and RE-SEEDS the state from that page when a
request resumes from a cached prefix. The block size is raised to
**3136 tokens** for the page-size equality (boot log: "Setting
attention block size to 3136 tokens to ensure that attention page size
is >= mamba page size" + "Padding mamba page size by 0.13% … exactly
equal"). The corrupt state landed at the **13th boundary: 13 × 3136 =
40,768 tokens** — the reported "~40k" mark. The NaN persisted in the
mamba page, so every later turn of that conversation re-seeded the same
non-finite state for those two heads (silently — the fault-report cap
was already spent) while fresh short sessions looked fine.
**RAM ruled out the same day** (the 2026-10-04 17:17 host reboot is when
the current memory speed took effect): zero MCE/EDAC kernel records
since boot; an 8 GiB in-band pattern check stable over 60 s under live
load; 4800 MT/s is the JEDEC DDR5 floor on 5600 MT/s-rated DIMMs (not an
overclock); and a two-head, single-slot NaN state is a state-path fault,
not scattered finite bit corruption.
**Fix.** `FRANK_PRIMARY_NO_PREFIX_CACHING=1` → `--no-enable-prefix-caching`
(the fork-named mitigation; the flag renders in both directions from the
knob so a fork default change cannot silently flip the contract). Cost:
no prefix reuse — every turn re-prefills the whole history instead of at
most 3135 tail tokens. A silently corrupted conversation is worse than a
slower first token.
**Verified 2026-10-05 (deploy b820d34):**
- Engine argv carries `--no-enable-prefix-caching`; health 200; warm boot
`init engine … took 76.57 s` (the mounted compile cache is intact).
- Boot plan unchanged, as predicted: expert cache **17.77 GiB / 7,762
slots** (the flag touches no plan term), CUDA-graph estimate 0.07 GiB
vs 0.08 GiB captured (inside the 12 MiB/graph charge).
- `GPU KV cache size: 424,678 tokens` — **up** from 401,408 at the same
3,166,500,000 B ask: the pool now prices ~7,456 B/token (was 7,891.8),
the mamba/side per-token term dropping once the per-block checkpoint
pages are gone; the 3136 attention block-size raise nonetheless stays
(the page-size invariant is enforced in mode "none" too). Frank's
400,700-token floor is cleared with ~24k of headroom.
- **Zero `gdn_hip` lines** in the boot-and-traffic window (only the two
INFO module banners mention the name).
- Boundary probe (single request, no prefix reuse): prompt 39,280 +
completion 1,491 = **40,771 total tokens — across the 40,768 line**;
the task (write 1–400, one per line) completed coherently: 399 integers
parsed, 0.997 monotone, tail `… 398 399 400`. Pre-fix, this boundary is
exactly where the conversation degraded (the 13th 3136-page re-seed).
- e2e: 32/33 pass; the one failure is the admin spec's
192.168.0.112:8989 leg with FrankBook-Pro asleep (environmental,
unrelated to this change; re-run `tests/e2e/run.sh` once the mini is up).
**RESULT — REVERTED THE SAME DAY.** With every mechanical check green
(zero faults, coherent 40,771-token probe), live long conversations
STILL degraded at ~40k on the running endpoint (Frank, direct
observation). The container serving that window (`--no-enable-prefix-
caching`, booted 2026-10-05 12:47 PDT) logged ZERO `gdn_hip` faults
while the corruption reproduced — so this recurrence is **fault-free**:
the NaN-state guard did not fire, which the re-seed theory requires.
Open hypotheses, in order: (1) the poisoned transcript is being re-fed —
corrupted assistant turns already in the history keep producing
corrupted continuations even with perfect state, so the same
conversation cannot distinguish fixed from unfixed; (2) a second
long-context mechanism with no guard coverage — fp8 KV degradation at
depth is the next suspect (a bf16-KV boot costs ~2× the pool per token
and would settle it). Prefix caching is **back ON**
(`FRANK_PRIMARY_NO_PREFIX_CACHING` commented out — the cost bought no
fix). The knob and the serve branch stay for a future fork build with a
fixed GDN path. Next diagnostic: reproduce on a FRESH conversation; if
it still breaks at ~40k with zero faults, A/B the KV dtype.
**RESOLVED (2026-10-05 evening): it was the RAM.** Frank dropped the
memory from 4800 to **4600 MT/s** (in effect since the 2026-10-05 12:21
PDT host boot) and the corruption stopped: zero `gdn_hip` ERROR lines in
the container since, zero MCE records, and the live agent session on
this endpoint past 140k context, "comfy cozy" (Frank). Marginal DDR5 on
four DIMMs is a common cause for BOTH eras: the 2026-10-04 NaN burst
(corrupted weight/activation traffic poisoning a GDN state write — the
guard-visible path) and the fault-free ~40k degradation (silent flips in
KV/expert data, which no guard covers on non-ECC memory). The re-seed
theory is therefore UNPROVEN, not disproven — the entire caching
experiment ran on marginal RAM and was confounded on both arms.
Post-mortem on the rule-out: consumer non-ECC RAM raises no MCE for a
silent flip; a 60 s / 8 GiB pattern sample covers ~0.5 % of 246 GiB;
"4800 is the JEDEC floor on 5600-rated DIMMs" is a rating, not a
stability proof at four-DIMM loading. The revert test was the decisive
instrument and it pointed at the RAM all along. Prefix caching stays ON
(zero faults under heavy re-send traffic since the speed drop); the knob
stays for a future fork fix. The fp8-KV A/B is NOT needed.
### Shape F/G (2026-10-03): where the free VRAM actually is, and what forcing it cost
`rocm-smi` showed **3.57 GiB free per R9700** at the deployed shape E (28.28 GiB
used of 31.86 GiB, after hours of real traffic — so it is the high-water mark,
not an idle artifact). Growing the expert arena into it is NOT a matter of
raising `--gpu-memory-utilization`, because the arena is
```
arena = budget - non-expert - KV reserve - CUDA graphs - runtime overhead
budget = device_total x gpu_memory_utilization # expert_plan.py:567
```
and every term on the right is either fixed or a lie:
| term | plan charges | measured reality | reachable from our side? |
|---|---|---|---|
| `budget` | 31.22 GiB (0.98) | field is `le=1` so 31.86 GiB is the field max, but 1.0 is UNBOOTABLE (below) | **yes** — shipped 0.982 |
| `non-expert weights` | 6.28 GiB | checkpoint census | no |
| `KV reserve` | 1.91 GiB (registry 9,190 B/token x 200704 +8%) | real pool bills 2.93 GiB at 7,892 B/token | no (registry constant) |
| `CUDA graphs` | 0.55 GiB = 256 MiB + 52 MiB x 6 | `Graph capturing finished in 3 secs, took 0.31 GiB` | **yes** — `VLLM_CUDAGRAPH_MEM_BASE_MB` / `_PER_GRAPH_MB`; `VLLM_CUDAGRAPH_MEM_ESTIMATE_MB` pins the whole term outright (checked first, pair ignored when set) |
| `runtime overhead` | 5.25 GiB (`--expert-precise-memory`) | far lower at idle | no (registry constant) |
Shape F took the graph term (0.55 -> 0.32 GiB) and nudged util to 0.982:
**arena 17.52 GiB / 7,653 slots measured**, +131 slots. Shape G takes the
graph term further (0.32 -> 0.07 GiB) at the same util.
**Shape G takes the graph term to the measured floor, util to the law.** The
graph base pool is the actual over-charge: shape F's own boot reported
`Graph capturing finished in 4 secs, took -0.06 GiB` — a **negative** capture
delta, so the 256 MiB base was fiction. Charging `0 base + 12 MiB/graph`
(0.07 GiB for the shipped 6-graph plan, and still per-graph so an 18-graph
spec-decode shape pays 0.21 GiB) lands the plan at budget 31.29 / graphs 0.07 /
**arena 17.77 GiB / 7,762 slots measured**. The KV ask went to
`3,166,500,000` B/rank, which the engine's own 7,891.8 B/token price lands at
**401,408 tokens measured / 2.00x concurrency** — the first shape holding two
full-length conversations with real spare. (The ask/landed delta is block
alignment: 196 x 2048. The price is shape-dependent, 7,701 B/token at 262144.)
**UTIL 1.00 is not a knife-edge on this fork, it is UNBOOTABLE — measured
2026-10-03.** One deploy took the field maximum to buy the last 0.57 GiB of
arena; both workers refused init BEFORE loading a single weight, twice:
`ValueError: Free memory on device cuda:N (31.29/31.86 GiB ...)` — the worker
demands free > budget and budget == total, so `le=1` on the CLI field is a
trap: the engine dies in a 2-restart crash loop with zero payload progress.
So `0.982` is a LAW, not a preference, and the "why 0.982" answer now has two
proofs: this ValueError, and the historical ceiling deaths (the 2x262144 bf16
boot at 0.13 GiB free; the 5x262144 boot at "0 bytes free" on a real
995-token request). Everything beyond ~7,912 slots needs the registry edited
inside the image (the fork's log line says exactly that: *"Update the
registry entry if this drifts"*), not a deploy.
**The host region was the last arena-plan term still autofitting per boot —
now it is a literal (2026-10-04).** With no `--expert-offload-mem` the planner
binds the host region to `MemAvailable` AT BOOT minus `PLE 47.68 - 2 x 7.0
process - 5.0 api/engine - 5% margin`: the 2026-10-05 boot read 237.97 GiB and
offered 159.39 GiB usable, while the same host reads **75.4 GiB available while
running** — the bound tracks whatever the co-tenants hold at that moment. The
demand is 105.48 GiB (52.74 GiB/rank x 2, 23,040 swap-target experts), and a
boot where the bound drops near it silently converts next-hottest experts to
VRAM-only (the `pinned_extra` warning is the only trace).
`FRANK_PRIMARY_EXPERT_OFFLOAD_MEM=120` pins it: measured demand + 14% headroom,
on a host with 246.1 GiB total. Semantics verified in the shipped image
(`offloader/expert_plan.py`): the cap binds as the bound, an exceedance only
warns, and the plan rewrites the field to the cap it met (+1 MiB/rank).
**MNBT 1024 -> 2048 is free on every axis the plan can see.** The registry
overhead model is `base + 0.22 GiB x max(0, (nbt - 4096)/1024)` anchored at
nbt 4096, so any rung up to 4096 charges the identical 5.25 GiB; the fitted 5.25
was itself fitted at nbt 4096, which means 2048 is covered by an activation
figure measured at a **larger** batch. SEQS 3 boots at both 1024 and 2048
(measured 2026-10-02 — the `query_start_loc` `ConstraintViolationError` is a
SEQS property). The costs are a one-time recompile (the compile **range** is
part of the cache key, so `FRANK_PRIMARY_CACHE_DIR` pays once) and a wider
prefill chunk, which the 09-29 sweep found decode-interchangeable across
1024-4096 while 512 doubled TTFT at 4064 ctx (2.46 s vs 1.36 s).
**`--expert-precise-memory` is the arena lever that does not touch the window.**
It attacks the planner's overhead term (shape F above attacks the graph term —
the third and last term this repo can reach), and the flag swaps the default
registry figure for the fitted one. Measured
2026-10-02 by a boot probe on the shape-D base (production stopped, candidate
booted AS the service; numbers are the engine's own plan lines):
| plan line | shape D | **shape E** |
|---|---|---|
| `runtime overhead (non-torch + buffers + activation)` | 7.60 GiB | **5.25 GiB** (`precise (gfx1201 TP2 nbt4096/seqs16/len262144)`) |
| `expert cache space` | 14.87 GiB | **17.22 GiB** |
| slots | 6,496 | **7,522** (+1,026, +16%) |
| `swap region` | - | 13.7 GiB / 5,986 slots, 133-134 per layer |
| boot | - | healthy in 420 s, no OOM |
`'expert_precise_memory': True` also appears in `non-default args`, so a silent
argparse drop is visible in the boot log. The flag lands **in the arena, not the
pool** - `--kv-cache-memory-bytes` is untouched at 3.15e9/rank (option A), because
the pool is not an arena lever and cutting it further would only cost
concurrency: a 2.2e9 pool was probed in the same window and reports 277,686
tokens / **1.38x**, so it is the conservative fallback if free VRAM ever binds,
not the shipped value.
**The compile cache is a boot-cost fix, and it was worth 173 s per boot.**
Measured 2026-10-02: the image ships **no `/root/.cache` at all** (a fresh
container: `No such file or directory`), and after a boot that directory holds
`vllm` at **1,154,714,333 B in 4,665 files** plus `comgr` at 229 MB, all inside
the container's ephemeral layer. Every
`podman rm -f` - i.e. every deploy, restart and reboot - discards it, and the
engine re-pays:
```
Using cache directory: /root/.cache/vllm/torch_compile_cache/b265bd396a/rank_0_0/backbone
Dynamo bytecode transform time: 4.99 s
Compiling a graph for compile range (1, 1024) takes 173.19 s
saved AOT compiled function to /root/.cache/vllm/torch_compile_cache/torch_aot_compile/…
torch.compile took 181.20 s in total
init engine (profile, create kv cache, warmup model) took 217.26 s (compilation: 181.20 s)
```
Mounting a host directory at `/root/.cache` makes every later boot load the AOT
artifact instead. The mount is deliberately the **parent**, not
`/root/.cache/vllm`: `VLLM_CACHE_ROOT` defaults to the `vllm` child, and
`comgr` is the HIP-kernel JIT cache, so one mount persists both — and it hides
nothing, because the image ships no `/root/.cache`. The cache key
includes the compile range, so the MNBT choice costs exactly one compilation.
Entries are version- and config-hashed: a stale entry is a miss, never a wrong
answer.
**Proven live on the deployed endpoint, 2026-10-02, consecutive boots of this
exact shape:**
| boot | engine log |
|---|---|
| cold (deploy) | `Compiling a graph for compile range (1, 1024) takes 179.24 s` → `torch.compile took 188.26 s in total` |
| warm (restart) | `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)` |
The host directory held **1.4 GB in 4,707 files** after the cold boot. Time to
`/health` is dominated by the 172.8 GiB payload load (the warm boot reached
health in 490 s), so the win is the ~184 s of inductor/AOT build rather than a
proportional cut in boot time.
`GPU_MAX_HW_QUEUES=2` caps the HSA hardware queues per GPU. Verified a real knob
rather than a no-op - the literal is read out of
`/opt/rocm/core-10.0/lib/libhsa-runtime64.so` - and shipped deliberately
alongside the 32x64 gather geometry. It is **not** A/B measured.
**The pool, not the slot count, sets how many conversations live at once.**
Measured live at this shape: `GPU KV cache size: 398,658 tokens, Maximum
concurrency for 200,704 tokens per request: 1.99x` (3,146,129,408 B reserved).
The per-token price is **shape-dependent**: the 262144 shape priced at 7,701
B/token (6,100,000,000 B -> 792,069 tokens), this pool at 7,892 - ~2.5% dearer,
so a concurrency figure never extrapolates across geometries. The endpoint
advertises 3 slots: a third LONG request queues rather than being refused, and
shorter requests use the spare blocks. Verified: three concurrent 67-token
requests answered in parallel, ~4.1 s each. Two contexts at 200704 are a
capacity contract, not a throughput one (2-way concurrency summed BELOW parity
at 4064 ctx).
#### `--max-num-seqs 2` is impossible on this model (measured 2026-10-02)
Four single-variable boot probes against the then-deployed shape, production
stopped for each:
| MAXLEN | SEQS | MNBT | result |
|---|---|---|---|
| 262144 | 2 | 1024 | **Engine core initialization failed** |
| 262144 | 2 | 2048 | **Engine core initialization failed** |
| 262144 | 3 | 1024 | healthy - 792,069 tokens, 3.02x, arena 6,248 |
| 262144 | 3 | 2048 | healthy - the then-deployed incumbent |
The failure is `torch.fx.experimental.symbolic_shapes.ConstraintViolationError:
Constraints violated (L['query_start_loc'].size()[0]) ... specialized it to be
a constant (3)`, raised from AOT `build_guards(strict_error=True)` during
`determine_available_memory -> profile_run -> _dummy_run`. `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`: 3 at SEQS 2, 4 at SEQS 3. **MNBT is not a
lever here** - it only moves the compile RANGE ((1,1024) vs (1,2048)), which
is why the identical error appears at both rungs. `--enforce-eager` bypasses
the path at the cost of CUDA graphs. The same failure class killed the
2026-10-01 bf16 2 x 262144 shape.
#### The arena lever is `max_model_len`, not `--kv-cache-memory-bytes`
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 x
max_model_len** - not from the `--kv-cache-memory-bytes` value:
| MAXLEN | KV reserve | arena |
|---|---|---|
| 262144 | 2.48 GiB | 14.30 GiB / **6,248 slots** |
| 200704 | 1.91 GiB | 14.87 GiB / **6,496 slots** |
D(reserve) 0.57 GiB = D(arena) 0.57 GiB = +248 slots. The 6.1e9 -> 3.15e9
pool cut (2.75 GiB) bought **zero** arena slots. It buys runtime HEADROOM,
which is still worth having - the shape it replaced died with `0 bytes free`
on a real 995-token request. Grow the arena by lowering `max_model_len`, or by
taking the overhead term with `--expert-precise-memory` (below); the pool is the
headroom lever only.
#### The other arena lever: `--expert-precise-memory` (overhead 7.60 -> 5.25 GiB)
The flag is now ON. It was refused on 2026-10-01 for a reason that does not
generalize: the 2 x 262144 bf16 shape was already at the ceiling (weights 6.28 +
pool 6.80 + graphs 0.55 + arena 16.65 + non-torch ~1.45 = 31.73 of 31.86 GiB,
NRestarts=3 at ~6.7 min each), so the 2.35 GiB the flag moves into the arena was
the last stack over the line, not the cause. The plain bf16 2 x 262144 boot also
did not stay up: 0 bytes free at runtime, and a real client request (995-token
prompt, `max_tokens=261149`) killed the worker with `Tried to allocate 50.00 MiB
... 0 bytes free`.
The correction is a pool cut, not a downgrade. fp8 costs 7,701 B/token
(measured 6,138 attention + 1,563 mamba/side, dtype-independent), so 2 x
200704 = 401,408 tokens needs 3.091e9 B; pinned at 3.15e9, ~44 MB of margin
above the floor — the same convention the 6.1e9 shape used. The arena is what
remains: the planner sizes it AFTER the KV override, so the 6,248 slots of the
6.1e9 shape become roughly 7,500 here. Two contexts at 200704 are a capacity
contract, not a throughput one (2-way concurrency summed BELOW parity at 4064
ctx), so the slot buys the VRAM headroom long generations need.
At 200704 x 3 the flag fits with room and is worth **+1,026 slots (+16%)**. What
made it affordable is the window trim, not a pool cut: the arena is
DEVICE-resident, the pool is not part of the budget's arithmetic, and the 200704
window's KV reserve (1.91 GiB) plus a smaller attention footprint is what the
plan needed. The pool stays at 3.15e9/rank deliberately (option A) - cutting it
further would cost concurrency (2.2e9 -> 1.38x) and buy no slots.
Boot-probed 2026-10-02 (transient systemd units at the production uid +
memlock, full-log capture; the rootless bench probe had masked the production
@@ -314,13 +636,17 @@ memory environment). The night searched two concurrency contracts:
| 3 x 262144, bf16, KV 11,047,010,304 | **OOM at graph capture** — `Tried to allocate 76.00 MiB ... 0 bytes free`, 30.43 GiB of 31.86 GiB already allocated by PyTorch |
| 3 x 245760, bf16 | **OOM identically** |
| 3 x 237568, bf16 | PASS — KV 718,606 tokens |
| 2 x 262144, bf16 + `--expert-precise-memory` | **DEPLOYED then crashed** — 0.13 GiB free at boot, NRestarts=3 |
| 2 x 262144, bf16 + `--expert-precise-memory` | **DEPLOYED then crashed** — 0.13 GiB free at boot, NRestarts=3 (the ceiling, not the flag) |
| 2 x 262144, bf16 (no precise flag) | did not stay up either: 0 bytes free at runtime, worker killed by a real request |
| **2 x 200704, fp8, pool cut for the arena** | **DEPLOYED (2026-10-02, Frank's correction)**: fp8 KV, two 200704 windows, ~7,500 arena slots, ~3.6 GiB free VRAM measured |
7,926 B/token against bf16's ~13,900-14,047 — at the cost of bf16's ~4 %
decode edge. Six full-length contexts cannot fit at any KV dtype: the 6-seq
graph/activation footprint leaves only 9.18 GiB for the pool, and 6 × 262144
needs 12.47 GB even at fp8.
| 3 x 200704, fp8, pool sized for 2 contexts | **DEPLOYED 2026-10-02 (shape D)**: 398,658 tokens = 1.99x concurrency, arena 14.87 GiB / 6,496 slots, the 2.75 GiB/rank pool cut returned as runtime headroom |
| **3 x 200704, fp8, + `--expert-precise-memory`** | shape E (2026-10-02): same 1.99x pool, overhead 5.25 GiB, arena 17.22 GiB / 7,522 slots, boot healthy in 420 s |
| 3 x 200704, fp8, MNBT 2048, graph term 0.32 GiB, util 0.982 | shape F (2026-10-03): arena 17.52 GiB / **7,653 slots** measured, budget 31.29, `Estimated CUDA graph memory: 0.32 GiB`, KV unchanged 398,658 |
| 3 x 200704, fp8, graph term 0.07 GiB, util **1.00**, KV 3.1665e9 | **DID NOT BOOT (2026-10-03)**: `ValueError: Free memory on device cuda:N (31.29/31.86 GiB)` on both ranks, twice, zero weights loaded — the worker demands free > budget and budget == total. UTIL 1.0 is unbootable, full stop |
| **3 x 200704, fp8, graph term 0.07 GiB, util 0.982, KV forced to 400,700** | **DEPLOYED 2026-10-03 (shape G)**: arena **17.77 GiB / 7,762 slots** measured (+109 over F, +240 over E), KV **401,408 tokens / 2.00x** concurrency measured, capture 0.31/0.10 GiB vs the 0.07 charge (under-charged, boots and holds), driver used 30.97 GiB -> **0.89 GiB real free per card** held through a live 4,096-token generation (~38-39 tok/s single-request, +13% vs the 33.99 shape-E baseline); rollback is UTIL or KV_BYTES down a notch |
Six full-length contexts cannot fit at any KV dtype: the 6-seq graph/activation
footprint leaves only 9.18 GiB for the pool, and 6 × 262144 needs 12.47 GB even
at fp8.
The old "KV_BYTES is PER RANK, 9/16 GiB OOM" law was the MTP4 WORKSPACE
reservation, not a VRAM wall. Failures are clean and diagnostic: the engine
@@ -379,8 +705,8 @@ Without the copy the exec dies with `can't open file '/mtp_measure.py': [Errno 2
No such file or directory` — measured 2026-10-01, which silently cost a whole
arm's numbers. A window script must do the copy, not assume the path exists.
### The deployed shape, measured again (2026-10-01 A/B window)
A clean production-environment boot of the deployed config (4 x 262144 fp8, MNBT
### The then-deployed shape, measured again (2026-10-01 A/B window)
A clean production-environment boot of the config deployed at that time (4 x 262144 fp8, MNBT
2048 in that window) reported **`GPU KV cache size: 1,079,582 tokens, Maximum
concurrency for 262,144 tokens per request: 4.12x`** — the same figures as the
deployed MNBT-1024 shape, i.e. MNBT does not move the pool, and the capacity is
@@ -0,0 +1,130 @@
# frank-large — halogen container, Qwen3.8-27B + DFlash2 (Dankclaw, ACTIVE 2026-10-05)
frank-large is the Strix Halo iGPU endpoint on Dankclaw (`:7770`). Since
2026-10-05 it runs the **halogen** engine — `ghcr.io/peonist-ai/halogen` at a
pinned digest, launched by `scripts/serve-frank-large.sh` under a root
systemd unit as a podman container — serving
**`peonist-ai/halogen-qwen3.8-27b`** (`qwen3.8-27b-p1w4d-d2.hgn`,
35,865,565,184 B ≈ 33.4 GiB, sha256 `274c3fc7…`, 1352 tensors) with the
**DFlash2** drafter at **one slot x 200,700 ctx**.
It replaced the ROCmFPX llama.cpp fork endpoint ([[frank-large-rocmfpx-q6agent-uncensored-mtp]]),
which remains the rollback target with its payload on disk.
## Why swap
|Metric|ROCmFPX Q6_0_ROCMFPX_AGENT + MTP (retired)|halogen qwen3.8-27b + DFlash2 (active)|
|---|---|---|
|Prose decode|27.6–28.1 t/s measured (4.8k depth, acceptance 0.868)|31.7 t/s mean across eight text workloads (publisher card, bf16 reference hardware)|
|Copy-echo decode|31.4 t/s|publisher reports echo inside the same 31.7 mean|
|Context|1 x 262,144|1 x 200,700|
|Format|GGUF (fork-private tensor types 102/103)|`.hgn` — loads in NO other engine|
The swap was accepted with eyes open on two product regressions, both
confirmed by Frank before deploy:
1. **Text-only.** halogen loads the language tower only; README "honest
limits": *"The vision encoder is ignored; image inputs are not supported."*
The mmproj-era vision role moves wholly to frank-small.
2. **Censored base.** The checkpoint is a finetune of the censored
`Qwen/Qwen3.8-27B` base, built to make its DFlash2 head strong. The
ROCmFPX AGENT file was an *Uncensored* build; refusals can return.
## Shape and why
`scripts/frank-large.env` carries every tunable with its evidence; the load-
bearing numbers:
- **`HALOGEN_KV_SLOTS=1` + `HALOGEN_DRAFTER=2`** — the engine offers batch OR
speculation, never both (FLAGS.md: "Batching and speculation are exclusive:
a step batches when more than one conversation is running, so the drafter
stays off with more than one slot"). Slots>1 silently loses DFlash2; the
sibling halogen engine measured 41 -> 14 t/s when slots went up. frank-large
was a one-conversation endpoint before (PARALLEL=1), so the contract holds.
- **`HALOGEN_SLOT_CTX=200700`** — the per-conversation HARD ceiling: a longer
prompt is a 400 naming the limit, never a truncation. The harness side is
wired to it: `models.yml` `contextWindow: 200700`, and OMP compacts at 70%
= 140,490, leaving 60,210 of the slot for the reply.
- **`HALOGEN_MAX_TOKENS_CAP=65536` + `HALOGEN_QUEUE_TIMEOUT=7200`** — coupled
pair (FLAGS.md table): a request at the cap must not outlive the timeout.
65536 > the harness's 57,344 `maxTokens` so an eager request never meets
the engine's 400.
- **KV math:** 1 slot x 200,700 x 64 KiB/token (d2 KV = 2x fp8) = 12.2 GiB
pool, inside the ~65 GiB left after 33.4 GiB mmap'd weights + engine working
set + host baseline on the 121.2 GiB box. The engine sizes the pool itself
and **prints a boot ledger naming any clamp** — the post-deploy check reads
that ledger, not a formula.
- **No server-side sampler:** an absent temperature is GREEDY. The card's
thinking recipe (temp 1.0 / top_p 0.95) lives in `models.yml` `extraBody`
— on the wire only. The template defaults `reasoning_effort` to **xhigh**
when a request sends none, and **thinking tokens consume the
`max_tokens` budget**: a budget exhausted mid-thought returns
`finish_reason: "length"` with EMPTY `content` and the partial reasoning
in `reasoning_content` — clients must read both fields.
## Client contract deltas vs llama.cpp
|Behavior|halogen|
|---|---|
|Prompt over slot|400 naming the limit (no truncation)|
|`max_tokens` over cap|400 naming the limit (never silent truncation)|
|full-length request outliving `HALOGEN_QUEUE_TIMEOUT`|503s for everyone queued|
|`timings`|every response carries llama-server-shape `timings` (`draft_n`, `draft_n_accepted`) — the drafter check reads the RESPONSE, and it proves speculation fired|
|`response_format`|NOT supported (no constrained decoding)|
|thinking field|`reasoning_content` (same as before)|
|`reasoning_effort`|same Qwen3.8 template; outside {xhigh,medium,low} rejected — models.yml fold stays; unset defaults to xhigh, `none` disables thinking|
|tools|server-side tool calls advertised — round-trip MUST be re-probed after deploy|
|image input|ignored/error — route to frank-small|
## Deploy mechanics
- **Image:** `ghcr.io/peonist-ai/halogen@sha256:dc0a39a0016d6cfc58a197978febaafdf8d28403f724ded6111d98b5fb7ac0ea`
(published tag 0.1.4). The deployer gates the exact digest exists in podman
storage before dispatch. NOT the 2026-09 `halogen-flash-server` repo — that
was the Flash-Next w4b era, different image, retired.
- **Payload:** `scripts/download-frank-large-halogen.sh` (fetch law: `curl -f`,
exact byte count, LFS sha256 where the Hub publishes one, `--verify-only`,
token never in argv) into `/home/dank/models/halogen-qwen3.8-27b/`. The
tokenizer is a FLAT DIRECTORY (`tokenizer/`, 5 files — tokenizer.json,
vocab.json, merges.txt, chat_template.jinja, tokenizer_config.json),
mounted whole at `/tokenizer`; the engine's `HALOGEN_TOKENIZER` default is
that directory, so the container gets no tokenizer flag (model-card
docker-run parity). The deployer and serve script refuse a missing file or
ANY symlink: an HF cache snapshot symlinks each entry into `blobs/` and
those links dangle inside the container mount.
- **Unit:** `scripts/frank-large.service` (root, `User=dank`, relinked into
`/etc/systemd/system/`), `ExecStart` = serve script, `ExecStop` = `podman
stop -t 120 frank-large-halogen`, script `exec podman wait` so
`Restart=always` is real. Engine stdout goes to
`/home/dank/logs/frank_large.log` (the boot ledger lives there, not in the
journal).
- **Cutover:** `deploy_llm_large` in `scripts/deploy-remote.sh` sweeps the
legacy bare `llama-server --model /home/dank/models/kingjones-*` process and
the legacy `frank-large` container name by NAME (never `pkill -f
llama-server` — that hits frank-small), retires the old quadlet if the 2026-09
halogen era's unit lingers, waits port 7770 free, relinks, health-gates.
- **Bench law:** stop+mask the unit before any GPU experiment on this box
(AGENTS.md); `/health` answers 503 while the 33.4 GiB payload loads — that
is loading, not failure.
## Verification (post-deploy checklist)
1. `grep -iE 'clamp|kv|pool|drafter|dflash' /home/dank/logs/frank_large.log`
— ledger shows the pool at the asked shape, DFlash2 active, NO clamp line.
2. `curl -s http://192.168.0.143:7770/health` -> 200.
3. Real completion with `reasoning_effort` + temp/top_p from the wire recipe;
`reasoning_content` populated; decode rate from the timings.
4. Spec decode fires: multi-token step behavior visible in rate; compare
against DRAFTER=0 arm if in doubt (never against gufo — bench law).
5. Harness round-trip: an OMP call through `llamacpp (dankclaw)/frank-large`
incl. a tool call (V1TOOLS).
6. Prompt > 200,700 tokens returns the 400 naming the limit (contract test).
## Rollback
Revert the frank-large swap commit and redeploy `dankclaw --service frank-large`:
the ROCmFPX payload (`/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/`),
the fork build (`/home/dank/GitPackages/kingjones-ROCmFPX/build`), and its
`scripts/build-rocmfpx.sh` pin (commit `36e9acd`, seam-tested) all remain on
disk. The halogen payload can stay: it costs nothing while the unit points
elsewhere.
@@ -0,0 +1,177 @@
---
type: reference
title: "frank-large on Dankclaw — ROCmFPX llama.cpp, Qwen3.8-27B Uncensored Q6_0_ROCMFPX_AGENT + MTP"
description: "The active frank-large runbook — the kingjones30/ROCmFPX llama.cpp fork (pinned commit, gfx1151) serving kingjones777's Uncensored Q6_0_ROCMFPX_AGENT GGUF (ftype 114, protected output heads), with the repo's own MTP-Q4_0 head drafting four tokens at one native 262,144 window, f16 KV, the BF16 projector for vision, and the measured rejection of the ngram-mod chain."
tags:
- llm
- frank-large
- dankclaw
- llama-cpp
- rocmfpx
- gfx1151
- gguf
- spec-decode
- mtp
timestamp: 2026-10-03
---
# frank-large — ROCmFPX Uncensored Q6_0_ROCMFPX_AGENT + MTP (gfx1151)
> **RETIRED 2026-10-03 → 2026-10-05 (rollback target; payload, fork build and
> build script all on disk).** Replaced by [[frank-large-halogen27b-dflash2]]
> (DFlash2, publisher 31.7 t/s mean vs the 27.6–28.1 measured here). This page
> remains the contract record for the rollback. Original banner follows.
> ACTIVE since 2026-10-03. Cut over from
> [[frank-large-rocmfpx-q8agent-mtp]] (ALIGNED Q8_0_ROCMFPX_AGENT, 17.0 t/s)
> on Frank's request: the *uncensored* variant of the same protected-heads
> family, one quant rung down. The swap turned out to be a strict upgrade on
> the axis that matters here: **decode 27.6–28.1 t/s at a 4,813-token prompt
> against 17.0 at a 2,202-token prompt** — faster with a deeper context, not a
> same-depth comparison, but the sign is not in question.
## Identity
| | |
|---|---|
| Host / device | Dankclaw (192.168.0.143), Radeon 8060S iGPU `gfx1151`, ~114 GiB GTT aperture |
| Endpoint | `http://192.168.0.143:7770` — alias `frank-large`, `/v1` OpenAI surface |
| Runtime | **`kingjones30/ROCmFPX`** llama.cpp fork @ `36e9acd40e10a87cd3c3ef8ec734668757dc8520`, built by [`scripts/build-rocmfpx.sh`](../../scripts/build-rocmfpx.sh) — unchanged from the Q8 era; the fork already knew type 102 |
| Unit | root systemd `frank-large.service` ([`scripts/frank-large.service`](../../scripts/frank-large.service)), `User=dank`, `Restart=always` |
| Serve script | [`scripts/serve-frank-large.sh`](../../scripts/serve-frank-large.sh) (owns the argv), tunables [`scripts/frank-large.env`](../../scripts/frank-large.env) |
| Weights | `/home/dank/models/kingjones-Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP/Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT.gguf` — 24,856,607,008 B, sha256 `e966deb3…` |
| Draft | `…/mtp-Qwen3.8-27B-Q4_0.gguf` — 1,680,271,648 B, sha256 `051a1764…` (byte-identical to the aligned repo's head; hardlinked, not re-downloaded) |
| Projector | `…/mmproj-Qwen3.8-27B-BF16.gguf` — 931,145,888 B (hardlinked from the aligned payload dir; the same file the Q8 era served) |
| Fetch | [`scripts/download-frank-large-kingjones-uncensored.sh`](../../scripts/download-frank-large-kingjones-uncensored.sh) — hardlinks the draft/projector from the aligned dir when present, otherwise fetches; `-f`, byte-count + LFS sha256 verified, `--verify-only`, token never on the command line |
| Seam | `tests/frank_large_unit.bats` (payload gates, deployer/serve parity, spec block, KV dtype) |
## Why this file
`kingjones777/Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP-GGUF` ships the same
"protected heads" construction as the aligned repo, in the uncensored tuning.
Parsed header of the deployed file:
| Field | Value |
|---|---|
| ftype | **114** — the fork's 64-bit-block family, ≈7.44 bpw |
| Tensor mix | 353 F32 + **339 type-102 (`GGML_TYPE_Q6_0_ROCMFPX`)** + **159 type-103 (`Q8_0_ROCMFPX`)** |
| Protected heads | 64 output-head tensors kept at plain precision — the AGENT construction from the aligned repo, at the 6-bit rung |
| Arch | qwen35, 64 layers, `full_attention_interval 4`, `context_length 262144` |
| Embedded sampling recipe | temp 1.0 / top_p 0.95 / top_k 20 |
The same lock-in law as the Q8 era applies: types 102 and 103 exist in no
loader but this fork; stock llama.cpp and gufo refuse the file at load
(`unknown tensor type`). The loader was not re-litigated — the file picks the
runtime, and the pin in `scripts/build-rocmfpx.sh` (commit by SHA) stays.
Rejected sibling: `Q6_0_ROCMFPX_LEAN` (6.45 bpw, no protected heads) — the
protection is what keeps the output-head logits at full precision and the
drafter accurate, so it stays deployed.
## Shape and budget
Unchanged from the Q8 era — the swap was payload-only:
| Dial | Value | Why |
|---|---|---|
| `--ctx-size` | 262,144 | the native window, no rope scaling |
| `--parallel` | 1 | one conversation, all memory to it |
| KV dtype | **f16** (both K and V) | ~64 KiB/token; free inside a 114 GiB aperture |
| `--fit off` | on | the auto-fitter would silently resize the window |
| `--flash-attn on` | on | required for the long-window attention path |
| `--direct-io`, `--ctx-checkpoints 16`, `--cache-ram 8192`, `--cache-idle-slots`, `--jinja`, `--reasoning on --reasoning-format deepseek --reasoning-budget -1` | | same as [[frank-large-rocmfpx-q8agent-mtp]] |
Spec decode: `--spec-type draft-mtp`, the repo's `mtp-Qwen3.8-27B-Q4_0.gguf`,
`--spec-draft-n-max 4`, pinned `--spec-draft-device ROCm0`.
Measured steady state: GTT **~61 GiB** under live load (host baseline 16.7),
boot to `/health` **~15 s**.
## Measured on this payload (2026-10-03, production service, `/completion` timings, temp 0, `cache_prompt: false`)
| Traffic | Decode t/s | Prefill t/s | Draft acceptance |
|---|---|---|---|
| Prose, 4,813-token prompt, 512 tokens | **27.57 / 28.10** (two runs) | ~205–217 | **396/456 = 0.868** → mean acceptance length ≈ 4.4, i.e. drafting at nearly the full n-max 4 |
| Schema echo (structured repetition) | **31.44** | — | 639/639 = 1.000 |
| Block copy (text repeated verbatim) | ~28.0 | — | 0.848 |
vs the Q8 era's 0.462 acceptance at a 2,202-token prompt: the MTP head drafts
materially better on this file. The card's claimed 0.953-class acceptance is
no longer science fiction on real prose — but keep reading it as a file
property, not a throughput promise.
## The ngram-mod chain — measured and rejected
Frank's ask: layer `--spec-type draft-mtp,ngram-mod` on top of MTP for an
extra gain. Measured in three bench windows (production service stopped per
the benchmarking law; artifacts `/home/dank/fl-ngram-ab/` on Dankclaw),
comparing `draft-mtp` alone vs the chain on the same traffic:
| Traffic | MTP alone | MTP + ngram-mod | Delta |
|---|---|---|---|
| Prose | 27.57 t/s | 23.40 t/s | **−15 %** |
| Block copy | 28.0 t/s | 26.1 t/s | **−7 %** (with ngram-mod demonstrably firing: 138 proposals/run) |
Mechanism, straight from arm B's boot log:
```
recurrent state rollback is not compatible with 'ngram-mod' - disabling rollback support
```
On the qwen35 hybrid (Gated DeltaNet recurrent layers) adding the ngram layer
disables the rollback path MTP's own verification relies on, so acceptance —
and throughput — drop on *all* traffic, even where the ngram matcher fires.
**Deployed decision: `draft-mtp` alone.** Chaining is not free on recurrent
models; the fleet law in `AGENTS.md → llama.cpp serving` records it.
## Bench-window hardening laws (learned building the A/B)
1. **Health 200 is not proof of ownership.** After boot, verify the pid you
started owns the port (`ss -ltnp`); a zombie server from a prior window
answers `/health` too and silently serves your arm.
2. **Kill scratch servers with SIGKILL after a grace period.** llama-server's
graceful SIGTERM waits on open keep-alive connections — a harness holding
the socket leaves the process (and its ~24 GiB of GTT) alive after the
script exits.
3. **Prove the port is free and GTT dropped between arms**, or the second
arm measures a collision.
## Client contract
Unchanged from the Q8 era and re-verified on this payload 2026-10-03: model id
`frank-large`, native tools, thinking in `reasoning_content`,
`reasoning_effort` ∈ {xhigh, medium, low} only (anything else HTTP 500),
card-recipe sampling defaults server-side, vision through the BF16 projector
(red PNG → **`Red`**, 74 prompt / 165 completion tokens). The traps list —
`content: null` when reasoning eats the budget, the self-recovering MTP
boundary log line, `enable_thinking:false` not `reasoning_effort:"none"` — is
in [[frank-large-rocmfpx-q8agent-mtp]] and still applies.
Sweep key for scratch servers is now the payload name:
`pkill -f 'Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT'` — never `pkill -f
llama-server` (frank-small is also one).
## Operations
```bash
./scripts/deploy.sh dankclaw --service frank-large
systemctl status frank-large
journalctl -u frank-large -n 200 --no-pager
scripts/download-frank-large-kingjones-uncensored.sh --verify-only
```
## Rollback
| Target | How |
|---|---|
| **ALIGNED Q8_0_ROCMFPX_AGENT** (previous shape, 17 t/s) | the payload dir `/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/` is untouched on disk; revert the swap commit (unit/env/serve trio) and redeploy, or point `FRANK_LARGE_MODEL_FILE` + `LLM_LARGE_MODEL_FILE` at the old paths. Verify with `scripts/download-frank-large-kingjones.sh --verify-only` |
| gufo Q4_K_XL era | git tag `frank-large-gufo-q4kxl-20261001`, **but the gufo payloads (UD-Q4_K_XL 16.35 GiB, UD-Q8_K_XL + DFlash2 drafter) were removed from disk 2026-10-03 with Frank's approval** — this rollback now requires re-download per [[frank-large-gufo-qwen38-27b]] |
## When it misbehaves
Same decision tree as [[frank-large-rocmfpx-q8agent-mtp]]:
`unknown tensor type` ⇒ off the pinned fork; SIGSEGV in
`ggml_compute_forward_set_rows` ⇒ pre-`36e9acd` build; slow decode with no
error ⇒ draft not engaged (`draft_n` in `/completion` timings) or an orphaned
second copy eating GTT (`/sys/class/drm/card1/device/mem_info_gtt_used`);
500 naming Jinja ⇒ `--jinja` lost; 500 naming the template ⇒ unmapped
`reasoning_effort` in `models.yml`.
@@ -1,7 +1,7 @@
---
type: reference
title: "frank-large on Dankclaw — ROCmFPX llama.cpp, Qwen3.8-27B Q8_0_ROCMFPX_AGENT + MTP"
description: "The active frank-large runbook — the kingjones30/ROCmFPX llama.cpp fork (pinned commit, gfx1151) serving kingjones777's Q8_0_ROCMFPX_AGENT GGUF, a ftype-115 file no other loader can read, with the repo's own MTP-Q4_0 head drafting four tokens at one native 262,144 window, f16 KV, and the bundled BF16 projector for vision. Measured MTP-vs-none A/B, the client contract, and rollback to gufo."
title: "frank-large on Dankclaw — ROCmFPX llama.cpp, Qwen3.8-27B Q8_0_ROCMFPX_AGENT + MTP (retired)"
description: "The retired frank-large runbook — the kingjones30/ROCmFPX llama.cpp fork (pinned commit, gfx1151) serving kingjones777's Q8_0_ROCMFPX_AGENT GGUF, a ftype-115 file no other loader can read, with the repo's own MTP-Q4_0 head, f16 KV, and the BF16 projector. Measured MTP-vs-none A/B, the client contract, and the rollback state as of 2026-10-03."
tags:
- llm
- frank-large
@@ -15,14 +15,20 @@ tags:
timestamp: 2026-10-02
---
# frank-large — ROCmFPX Q8_0_ROCMFPX_AGENT + MTP (gfx1151)
# frank-large — ROCmFPX Q8_0_ROCMFPX_AGENT + MTP (gfx1151, retired)
> **ACTIVE since 2026-10-02.** Cut over from
> [[frank-large-gufo-qwen38-27b]] (Q4_K_XL, ~15 t/s). Both requests came from
> Frank: *highest quality that runs*, and *one full context*. This endpoint
> delivers 8.39 bpw weights — two quant rungs above what gufo could load — and
> measures **17.0 t/s** decode instead of 15, so quality was not bought with
> speed.
> **RETIRED 2026-10-03** — superseded by
> [[frank-large-rocmfpx-q6agent-uncensored-mtp]]: Frank swapped to the
> uncensored Q6_0_ROCMFPX_AGENT file (protected heads, one rung down), which
> measures **27.6–28.1 t/s** at a deeper prompt than this file's 17.0. This
> runbook stays live as the rollback reference and the trap catalogue; the
> payload it documents remains on disk at
> `/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/`.
>
> Cut over 2026-10-02 from [[frank-large-gufo-qwen38-27b]] (Q4_K_XL, ~15 t/s)
> on two requests from Frank: *highest quality that runs*, and *one full
> context*. It delivered 8.39 bpw weights — two quant rungs above what gufo
> could load — at 17.0 t/s, so quality was not bought with speed.
>
> The capability that forced this whole shape is a GGUF tensor type:
> **`GGML_TYPE_Q8_0_ROCMFPX` (103)**. It exists in exactly one loader on this
@@ -184,17 +190,24 @@ broken payload never stops the running unit. Caller environment **overrides**
`frank-large.env`, and defaults use `:=` so an explicitly empty value surfaces
as the error it reads as.
## Rollback
## Rollback (as of retirement, 2026-10-03)
git tag **`frank-large-gufo-q4kxl-20261001`**: gufo v0.1.1
(`scripts/build-gufo.sh` still on disk), the Q4_K_XL Qwen3.8-27B payload and the
DFlash2 drafter all remain on the host. Restore that tag's unit/env/serve trio
and redeploy — nothing here deletes anything.
**Primary rollback: the ALIGNED Q8 file itself is the previous shape's
payload and remains on disk** at
`/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/` — revert the
2026-10-03 swap commit (unit/env/serve trio) or repoint
`FRANK_LARGE_MODEL_FILE`/`LLM_LARGE_MODEL_FILE`, verify with
`scripts/download-frank-large-kingjones.sh --verify-only`, redeploy.
The rollback payloads stay exactly where the tag's env file expects them:
`/home/dank/models/Qwen3.8-27B-UD-Q4_K_XL/` (16.35 GiB) and the DFlash2
drafter `/home/dank/models/Qwen3.8-27B-UD-Q8_K_XL/Qwen3.8-27B-DFlash2-Q4_K_M.gguf`
(1.07 GiB). The one-day vLLM MiMo era's payloads (MiMo-V2.6 BF16 18 GiB,
git tag **`frank-large-gufo-q4kxl-20261001`** (gufo v0.1.1, Q4_K_XL +
DFlash2) is now a **re-download** rollback: the gufo payloads
`/home/dank/models/Qwen3.8-27B-UD-Q4_K_XL/` (16.35 GiB) and
`/home/dank/models/Qwen3.8-27B-UD-Q8_K_XL/` (incl. the DFlash2 drafter) were
removed from disk on 2026-10-03 with Frank's explicit approval to free space.
`scripts/build-gufo.sh` remains on disk; re-fetch per
[[frank-large-gufo-qwen38-27b]].
The one-day vLLM MiMo era's payloads (MiMo-V2.6 BF16 18 GiB,
Qwen3.5-9B MTP source 19 GiB, both DFlash draft dirs) were staged to
`/home/dank/.AGENTTRASH/20261002-retired-llm-payloads/` on 2026-10-02 —
recoverable until the trash is purged, and never part of a rollback plan.
@@ -1,7 +1,7 @@
---
type: reference
title: "frank-small on Dankclaw — llama.cpp, MiMo-V2.6-Distill-Qwen-9B Q5_K_M GGUF"
description: "The active frank-small runbook — a CUDA (sm_86) build of llama.cpp master serving bartowski's MiMo-V2.6-Distill-Qwen-9B Q5_K_M GGUF on the RTX 3090: three conversations of 180,224 tokens, mmproj vision, the asymmetric KV contract (f16 K + q8_0 V), the two VRAM measurements that set the pool size, and what to check when it misbehaves."
title: "frank-small on Dankclaw — llama.cpp, MiMo-V2.6-Distill-Qwen-9B Q5_K_M GGUF (retired)"
description: "The retired 2026-10-02/03 frank-small runbook — the Q5_K_M rung at two wide unquantized-KV slots on the RTX 3090, superseded 2026-10-03 by the Q6_K file. Kept as the rollback reference: it carries the measured boot-pair table that prices every pool shape on this card, the slot-window contract, and the misbehaves tree."
tags:
- llm
- frank-small
@@ -11,23 +11,26 @@ tags:
- mimo
- gguf
- kv-cache
timestamp: 2026-10-02
timestamp: 2026-10-03
---
# frank-small — llama.cpp MiMo-V2.6 Q5_K_M (RTX 3090)
# frank-small — llama.cpp MiMo-V2.6 Q5_K_M (RTX 3090, retired)
> **ACTIVE since 2026-10-02.** One quant step down from
> [[frank-small-llama-mimo-q6k]] on the same runtime and the same KV
> contract, then **resized twice the same day** on Frank's orders: first to
> three contexts with vision, then down to the pool that keeps 2–3 GiB of the
> card free. Frank's arithmetic request — "as much as can fit while keeping
> overhead open space to 2-3gb on the 3090" — is answered by the two
> measurements in *Shape and the KV contract* below; the answer is
> **3 × 180,224**, not 3 × 262,144.
> **RETIRED 2026-10-03** — superseded by
> [[frank-small-llama-mimo-q6k-2x200704]]: the ~3.5 GiB this shape left free
> bought the **Q6_K quant rung** (same runtime, same 2 × 200,704 pool, same
> f16 KV), which measures 2,849 MiB free and 94.44 tok/s — 10 % slower
> decode is the measured price of the higher rung on this bandwidth-bound
> card. This file remains the rollback reference and the owner of the
> measured boot-pair table.
> **The tradeoff is the per-slot window.** Three sessions cost 81,920 tokens
> of per-conversation memory versus the two-slot shape: 180,224 tokens each
> instead of the model's native 262,144. See *What three slots cost*.
> Its own history: one quant step down from [[frank-small-llama-mimo-q6k]]
> (2026-10-01), resized twice on 2026-10-02 (three contexts with vision, then
> the 2–3 GiB-band pool), and on 2026-10-03 morning reshaped to **two
> conversations of 200,704 tokens with the KV VALUES unquantized** —
> *"2 streams with 200700 tokens context or more… if we have extra space on
> the 3090, unquantize the kv."* That shape measured 20,530 used / 4,046
> apparent free (the true `nvidia-smi` free was 3,571 — see *Shape*).
## Identity
@@ -41,77 +44,72 @@ timestamp: 2026-10-02
## Shape and the KV contract
`--ctx-size 540672 --parallel 3` — llama.cpp divides the pool across slots, so
each conversation gets `n_ctx_slot = 180224` (boot log: `n_slots = 3,
n_ctx_slot = 180224, kv_unified = 'false'`). `CTX_SIZE` MUST stay divisible by
`PARALLEL`; the bats seam enforces it.
`--ctx-size 401408 --parallel 2` — llama.cpp divides the pool across slots,
so each conversation gets `n_ctx_slot = 200704` (boot log 2026-10-03:
`n_slots = 2, n_ctx_slot = 200704, kv_unified = 'false'`). `CTX_SIZE` MUST
stay divisible by `PARALLEL`; the bats seam enforces it.
The KV contract is Frank's: **K unquantized (f16), V `q8_0`** —
`--cache-type-k f16 --cache-type-v q8_0`. Two hard dependencies ride on it:
The KV contract is Frank's, as of 2026-10-03: **both K and V unquantized
(f16)** — `--cache-type-k f16 --cache-type-v f16`. The asymmetric contract it
replaced (f16 K + `q8_0` V) had existed to make three slots fit; with two
slots the exact-value budget opens, and 4 KiB/token × 8 layers × 2 of extra
V traffic is the measured ~2 % decode tax. The serve script still passes both
flags explicitly — the asymmetric-era law that a quantized V requires
`--flash-attn on` stays pinned by the seam.
- **`--flash-attn on` is mandatory**: quantized V exists only on the FA path.
Without the flag the engine falls back silently and the contract becomes a
lie. The serve script pins the trio; the bats seam pins the script.
- The VRAM budget: attention KV lives in the 8 full-attention layers only
(4 KV heads × 256 dim). K f16 ≈ 16 KiB/token, V q8_0 ≈ 8.5 KiB/token
(34 B per 32-element block) ⇒ ~24.5 KiB/token over the pool.
**Pool sizes on this stack come from boots, not formulas.** The compute
buffers, CUDA graphs and vision tower all bill separately, so every shape
change is priced from measured rows:
**The pool size came from two boots, not from a formula.** `free`-style
arithmetic on this stack is unreliable because the compute buffers, CUDA
graphs and now the vision tower all bill separately, so the fit was measured
at two shapes and solved between them:
|Shape|Projector|`nvidia-smi` used|KV at 24.5 KiB/token|Non-KV (weights + buffers + projector)|
|Shape|KV dtype (K+V)|Projector|`nvidia-smi` used|Non-KV (weights + buffers + projector)|
|---|---|---|---|---|
|2 × 262,144 (pool 524,288)|none|20,190 MiB|12,800 MiB|**7,390 MiB**|
|3 × 196,608 (pool 589,824)|f16|22,972 MiB|14,113 MiB|**8,859 MiB**|
|2 × 262,144 (pool 524,288)|f16 + q8_0|none|20,190 MiB|**7,390 MiB**|
|3 × 196,608 (pool 589,824)|f16 + q8_0|f16|22,972 MiB|**8,859 MiB**|
|3 × 180,224 (pool 540,672)|f16 + q8_0|f16|21,684 MiB|~8,419 MiB|
|**2 × 200,704 (pool 401,408)**|**f16 + f16**|f16|**20,530 MiB**|**~7,986 MiB**|
The two rows price the projector and its image buffers at **~1.4 GiB** — not
the ~0.9 GiB the 918 MB file suggests, because the vision encoder's own
activation and compute buffers ride along. Solving the second row's non-KV cost
against Frank's 2–3 GiB band: the card is 24,576 MiB, so
`KV ≤ 24,576 − 9,216 − 2,048 = 13,312 MiB` ⇒ pool ≤ 556,630 tokens, rounded
down to a multiple of 3 × 4,096 ⇒ **540,672**, i.e. 13,265 MiB of KV and
**2,441 MiB free** (measured, 21,684 MiB used).
The 32 KiB/token full-attention KV price (8 layers × 4 KV heads × 256 dim ×
2 B × 2 halves) prices the deployed pool at 401,408 × 32 KiB = **12,544 MiB
of KV**; the card is 24,576 MiB, so the shape leaves **4,046 MiB free**
(measured steady-state — the KV pool is allocated whole at boot, so this is
the ceiling). The headroom above the 2–3 GiB floor is deliberate slack, not
an invitation: another 32,768 tokens of pool (1 GiB) would trade the last
safety margin for window the harness compacts before reaching anyway.
**Why 3 × 262,144 was never possible.** Three native windows are 786,432
tokens = 18.4 GiB of KV *alone*; plus the 8.86 GiB of non-KV cost that is
27.3 GiB on a 24 GiB card. The quant step bought 0.86 GiB; the geometry needed
6 GiB.
**Why 2 × 262,144 with f16 V does not fit either.** The native window pair is
524,288 tokens = 16,384 MiB of KV plus ~7,986 non-KV = 24.4 GiB: under the
floor by a hair. 200,704 is the largest 4,096-aligned slice that keeps the
measured band.
## Measured on this shape (2026-10-02, 3 × 180,224 + vision)
## Measured on this shape (2026-10-03, 2 × 200,704 + vision, f16 KV)
|Check|Result|
|---|---|
|Boot to healthy|~20 s, `n_slots = 3`, `n_ctx_slot = 180224`, `loaded multimodal model` logged|
|VRAM|**21,684 MiB used / 2,441 MiB free** of 24,576 (`nvidia-smi`, steady state; the KV pool is allocated whole at boot, so this is the ceiling)|
|Decode, temp 0, no thinking|**106.8 tok/s** (256-token completion at ~1,300-token depth)|
|Prefill|**3,814 tok/s** (same request)|
|Three concurrent requests|all three answered in 1.7 s wall — real parallel slots|
|Fourth concurrent request|answered in 3.0 s while three ran — queued behind a released slot, as the slot ceiling requires|
|Vision|`image_url` part with a 64×64 solid-red PNG → **"Red."**|
|Tools|native `tool_calls` with correct arguments (`get_weather {"city":"Seattle","unit":"f"}`)|
|Thinking|`reasoning_content` populated (145 chars on a "think first" prompt); `reasoning_effort: "low"` accepted|
|Over-slot prompt|`400 exceed_context_size_error: request (184001 tokens) exceeds the available context size (180224 tokens)` — refused, never silently truncated|
|Boot to healthy|~5 s, `n_slots = 2`, `n_ctx_slot = 200704`, `loaded multimodal model` logged|
|VRAM|**20,530 MiB used / 4,046 MiB free** of 24,576 (`nvidia-smi`, steady state)|
|Decode, temp 0, no thinking|**104.84 tok/s** (128-token completion at ~500-token depth; the 3 × 180,224 q8_0-V shape measured 106.8 — ~2 % is the honest price of exact V)|
|Prefill|1,650 t/s on the same shallow 506-token probe (rates are depth- and prompt-dependent; not comparable across shapes)|
|Batch geometry|`--batch-size 2048 --ubatch-size 1024` — ubatch 2048 failed the scratch boot; 1024 boots clean|
|Vision|projector loads at boot (`loaded multimodal model`); red-PNG probe passed on 2026-10-02. Caveat discovered on 2026-10-03: probes must use a >= 256 px image — a 10x10 px PNG undersupplies image tokens and the model hallucinates ("it's a video")|
|Tools / thinking|unchanged by the reslice: native `tool_calls`, thinking in `reasoning_content`|
Decode is ~5 % **faster** than the 2 × 262,144 shape measured earlier the same
day (100.4–101.8 tok/s): a shorter per-slot window means a shorter attention
scan at equal prompt depth, and one less slot's DeltaNet state resident.
## What the slot geometry costs
## What three slots cost
The per-slot window is the whole price, in both directions:
The per-slot window is the whole price. Two consequences the fleet depends on:
- **A prompt over 180,224 tokens is a hard 400**, so `~/.omp/agent/models.yml`
sets `contextWindow: 180224` for this endpoint — the harness compacts before
- **A prompt over 200,704 tokens is a hard 400**, so `~/.omp/agent/models.yml`
sets `contextWindow: 200704` for this endpoint — the harness compacts before
it reaches the ceiling instead of discovering it.
- **frank-primary's compaction prompt no longer fits with room to answer.**
Its trigger is 183,501 tokens — already above this slot — and its summary
budget is 32,768 output tokens. On the two-slot shape (262,144) that left
32.8k of output room, which is why that number was chosen; on this shape a
single compaction request must be truncated or split. Nothing in the fleet
routes compaction here automatically, but any runbook that claims frank-small
"fits a compaction request in ONE request" is describing the 262,144 shape.
- **frank-large's tallest pre-compaction prompt now fits here.** Compaction
for frank-large sessions runs on this endpoint (`compactionModel` in
`models.yml`); frank-large compacts at 70 % of 262,144 = 183,501 tokens,
which did **not** fit the old 180,224 slot and fits 200,704 with 17,163
tokens to spare. The 2026-10-02 window trim had restored the fit for
frank-primary's smaller 140,492-token trigger; the 2026-10-03 reslice widens
the fit to frank-large itself. Nothing routes compaction automatically —
the point is the ceiling no longer forbids it.
- Two simultaneous generations are the slot ceiling; a third request queues
behind a released slot (the mechanism measured on the 3-slot shape).
## Client contract
@@ -147,8 +145,9 @@ traffic first. The bats seam fails if a draft flag ships unannounced.
|Target|How|
|---|---|
|Q6_K, same runtime|`scripts/frank-small.env` `MODEL_FILE=/home/dank/models/MiMo-Q6K/MiMo-V2.6-Distill-Qwen-9B-Q6_K.gguf` (7,793,710,624 B, still on disk) and the deployer's `LLM_SMALL_MODEL_FILE` default. Q6_K needs ~1.4 GiB more than Q5_K_M, so pair the rollback with `CTX_SIZE` 49,152 tokens lower or `MMPROJ_FILE=`|
|Two 262,144 windows|`CTX_SIZE=524288 PARALLEL=2` (measured 20,190 MiB used, 4,386 free) and `contextWindow: 262144` in `models.yml` — choose it when a single conversation needs the native window or when frank-primary's compaction request has to land here|
|Previous shape: 3 × 180,224 + q8_0 V (2026-10-02)|`scripts/frank-small.env` `CTX_SIZE=540672 PARALLEL=3 CACHE_TYPE_V=q8_0` (measured 21,684 MiB used, 2,441 free) and `contextWindow: 180224` in `models.yml` — choose it when three simultaneous conversations matter more than per-slot window or exact V|
|Q6_K, same runtime|`scripts/frank-small.env` `MODEL_FILE=/home/dank/models/MiMo-Q6K/MiMo-V2.6-Distill-Qwen-9B-Q6_K.gguf` + `MMPROJ_FILE` in that dir — **this is the deployed rung since 2026-10-03** ([[frank-small-llama-mimo-q6k-2x200704]]): at 2 × 200,704 it fits with 2,849 MiB free, no pool cut needed (the older "+1.4 GiB, drop 49,152 tokens" estimate held only for the 3-slot shapes)|
|Two 262,144 windows|`CTX_SIZE=524288 PARALLEL=2 CACHE_TYPE_V=q8_0` (measured 20,190 MiB used, 4,386 free **with q8_0 V** — with f16 V the same shape is 16,384 MiB of KV + ~7,986 non-KV and does not fit) and `contextWindow: 262144` in `models.yml` — choose it when a single conversation needs the native window|
|Vision off|`MMPROJ_FILE=` in the env file; the deployer gate then has to be satisfied too, so point `LLM_SMALL_MMPROJ` at any real projector file or drop the gate in the same commit|
|SGLang + INT8|[[frank-small-sglang-mimo-v26]] — venv and old unit/env/serve trio still in place; the INT8 payload was staged to `/home/dank/.AGENTTRASH/20261002-retired-llm-payloads/` on 2026-10-02, so restore it there or re-fetch with `old/download-frank-small-sglang-int8.sh`|
@@ -158,14 +157,17 @@ traffic first. The bats seam fails if a draft flag ships unannounced.
naming the missing file if the binary or GGUF moved.
- `No CUDA GPUs are available`: the 3090 vanished from the bus or the UVM node
is missing — `nvidia-modprobe -u -c=0` (the serve script runs it).
- Requests queue behind each other: three concurrent long generations are the
slot ceiling by contract; a fourth waits for a slot to free (measured: 1.7 s
for the three, 3.0 s for the fourth).
- `400 … exceeds the available context size (180224 tokens)`: the conversation
outgrew ONE slot. This is not pool exhaustion — the pool is 540,672 tokens
and the per-slot window is the binding limit. Shorten the prompt, compact, or
roll back to the 262,144 shape above.
- Vision answers a color it never saw: the client sent `images` instead of an
`image_url` content part (see *Client contract*).
- Requests queue behind each other: two concurrent long generations are the
slot ceiling by contract (2026-10-03 shape); a third waits for a slot to
free (the mechanism measured on the 3-slot shape: 1.7 s for three, 3.0 s for
the fourth).
- `400 … exceeds the available context size (200704 tokens)`: the conversation
outgrew ONE slot. This is not pool exhaustion — the pool is 401,408 tokens
and the per-slot window is the binding limit. Shorten the prompt, compact,
or roll back to the 262,144 shape above.
- Vision answers a color it never saw, or claims the image "is a video":
either the client sent `images` instead of an `image_url` content part, or
the probe image is too small to supply real image tokens (use >= 256 px;
see *Client contract*).
- Slow decode after a CTX or quant change: check `nvidia-smi` — pushing KV out
of VRAM into unified memory halves the rate silently.
@@ -0,0 +1,158 @@
---
type: reference
title: "frank-small on Dankclaw — llama.cpp, MiMo-V2.6 Q6_K, 2 x 200704, f16 KV"
description: "The active frank-small runbook — llama.cpp master (CUDA sm_86) serving bartowski's MiMo-V2.6-Distill-Qwen-9B Q6_K GGUF on the RTX 3090: two conversations of 200,704 tokens with fully unquantized f16 KV, vision through the bundled projector, and the boot-pair arithmetic that prices every pool change."
tags:
- llm
- frank-small
- dankclaw
- llama-cpp
- gguf
- rtx3090
timestamp: 2026-10-03
---
# frank-small — llama.cpp MiMo Q6_K, 2 × 200,704, f16 KV
> **ACTIVE since 2026-10-03.** The third shape Frank bought in two days on
> this card: the freed third slot paid for **exact KV values**
> ([[frank-small-llama-mimo-q5km]] documents that step and remains the
> rollback), and the ~3.5 GiB still free then paid for the **Q6_K quant rung**
> — the same file this endpoint served on 2026-10-01, come back as an
> upgrade rather than a compromise. The pool held at 401,408 (2 × 200,704);
> only the weights moved up.
## Identity
| | |
|---|---|
| Host / device | Dankclaw (192.168.0.143), RTX 3090, 24,576 MiB |
| Endpoint | `http://192.168.0.143:7771` — alias `frank-small`, `/v1` OpenAI surface |
| Runtime | llama.cpp master, CUDA sm_86 build `…/llama-master/build-cuda/bin/llama-server` (`GGML_CUDA_FA_ALL_QUANTS=ON`) |
| Unit | root systemd `frank-small.service` ([`scripts/frank-small.service`](../../scripts/frank-small.service)), `User=dank`, `Restart=on-failure`, `LimitMEMLOCK=infinity` |
| Serve script | [`scripts/serve-frank-small.sh`](../../scripts/serve-frank-small.sh), tunables [`scripts/frank-small.env`](../../scripts/frank-small.env) |
| Weights | `/home/dank/models/MiMo-Q6K/MiMo-V2.6-Distill-Qwen-9B-Q6_K.gguf` — bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF **Q6_K**, 7,793,710,624 B, sha256 `ef96d05a…` (qwen3_5 hybrid: 32 layers, 8 full-attention + 24 Gated DeltaNet, native 262,144, no MTP tensors) |
| Projector | `…/MiMo-Q6K/mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf` — 918,166,048 B, sha256 `ff348f…` (hardlink of the Q5KM dir's copy, link-count-2 verified; the dir is self-contained) |
| Fetch | [`scripts/download-frank-small.sh`](../../scripts/download-frank-small.sh) — `-f`, byte count + LFS sha256 verified, `--verify-only`; both files verified against the pinned sha on the host before this deploy |
| Seam | `tests/frank_small_unit.bats` (argv assertions against a stub engine: payload path, slot geometry, KV pair, batch pair, projector opt-out) |
## Shape and KV contract
`--ctx-size 401408 --parallel 2` — llama.cpp divides the pool across slots,
so each conversation gets `n_ctx_slot = 200704`. `CTX_SIZE` MUST stay
divisible by `PARALLEL`; the seam enforces it. Both K and V are unquantized
(f16) — the flags stay explicit because `--flash-attn` must not be lost if V
ever goes back to `q8_0` (the serve script pins it, the seam pins the pin).
**The Q6_K rung is a weights-side spend only.** File delta +917,585,920 B
(+875 MiB) against Q5_K_M, same KV, same slot geometry, same batch pair.
Priced from the measured Q5 boot at this shape (non-KV 7,986 MiB): the boot
landed **21,276 MiB used / 2,849 MiB true free** — non-KV measured 8,732 MiB,
inside the 2–3 GiB floor, so the pool stayed at 401,408 and the
12,288-token step-down ladder was never climbed. Quote `nvidia-smi`'s `free`
field, never `total − used`: the driver holds ~450 MiB of reserve the
subtraction hides (that error once overstated this card's slack by a
half-GiB).
**Quant ceiling, evaluated and closed 2026-10-03 (Frank: stay here).**
`Q8_0` is the only rung above this one on the Hub (9,545,979,424 B = 9,104
MiB, +1,671 over Q6_K): at the live pool it would leave 1,178 MiB free —
under the floor — and the calibrated bandwidth law (it predicted this file's
94.4 t/s from Q5_K_M's 104.84 within 1 %) puts decode at ~72 t/s, a further
−24 % for the near-lossless rung. Fitting it was priced two ways (trim the
pool to 2 × 182,272 — which also breaks frank-large's 183,501-token
compaction-prompt fit by 1,236 tokens — or revert V to `q8_0`), and both
were declined. Re-open only on evidence the Q6_K residual matters.
**Pool prices on this card come from boots, never formulas** — the measured
boot-pair table lives in [[frank-small-llama-mimo-q5km]] (*Shape and KV
contract*) and still prices every rollback shape.
## Compute-buffer geometry (2026-10-05)
`BATCH_SIZE=4096 UBATCH_SIZE=2048` — both doubled from 2048/1024, and the trade
measured rather than assumed, because the predecessor shape recorded **"ubatch
2048 failed the scratch boot"** and this card has less free space than it did.
**That failure was a different shape, and it did not reproduce here.** The
service itself is the bench (one card holds one server, so no scratch port):
the old rung was re-booted through a transient `FRANK_SMALL_ENV_FILE` drop-in
and reproduced the 2026-10-03 boot **byte-identically** — 21,276 MiB used /
2,849 MiB free — which makes the ubatch the only variable in the comparison.
|Probe|ubatch 1024|ubatch 2048|Δ|
|---|---|---|---|
|Prefill, 8,928 tokens|3,972 / 4,077 t/s|**4,207 / 4,212 t/s**|**+4.6 %**|
|Decode, 128 tokens at that depth|91.87 / 93.01 t/s|93.81 / 94.27 t/s|noise|
|Prefill, 279 tokens|2,675.7 t/s|2,658–2,811 t/s|noise|
|VRAM used / free|21,276 / 2,849 MiB|**21,846 / 2,279 MiB**|**−616 MiB**|
Three readings:
- **The gain only exists above the old ubatch.** A 279-token prompt is shorter
than either micro-batch, so it cannot see the knob — and it did not move.
That is the control proving the 8.9k delta is geometry, not drift.
- **Decode is flat**, as every prior sweep on this card found. Ubatch is a
prefill knob.
- **The price is 616 MiB out of a 2.8 GiB margin.** 2,279 MiB free is still
inside the 2–3 GiB band, at its bottom edge. `4096/1536` is the middle rung,
and the pool's 12,288-token (384 MiB) ladder in
[[frank-small-llama-mimo-q5km]] is the other way to buy room back.
**Why 2048 is right HERE while 512 was right on the R9700 sibling: the curve is
backend-specific.** The 2× R9700 layer split peaks at ubatch 512 and is 10 %
off that peak by 2048 ([[frank-medium-batch-ubatch]]); on this CUDA card the
curve climbs monotonically to 2048 (710 → 1,188 t/s on the 35B MoE sweep,
1,944 → 2,423 t/s on the Ornith 8k sweep, both with decode flat). Never carry a
ubatch verdict across backends.
The logical `--batch-size` is VRAM- and throughput-neutral — two sweeps and this
A/B agree — so 4096 costs nothing and buys nothing by itself; it is here because
that is the pair the curve was measured at.
## Measured on this shape (2026-10-03, Q6_K, 2 × 200,704, f16 KV)
|Check|Result|
|---|---|
|Boot to healthy|model + projector resident in ~2.3 s (load log `0.02.27`), `n_slots = 2`, `n_ctx_slot = 200704`, `loaded multimodal model`, health 200; deploy e2e gate 33/33|
|VRAM|**21,276 MiB used / 2,849 MiB free** of 24,576 (`nvidia-smi` `free`, steady state) — inside the 2–3 GiB floor with exact V AND the higher rung. Superseded 2026-10-05 by the batch doubling: **21,846 / 2,279** — see *Compute-buffer geometry*|
|Decode, temp 0, no thinking|**94.44 tok/s** on the identical 506-token probe that measured 104.84 on Q5_K_M — **−10 %**. Decode on this card is weight-bandwidth-bound, and Q6_K streams ~10 % more bytes per token; the quant rung buys quality with decode rate, measurably|
|Prefill|2,229.9 t/s (same shallow probe; rates are depth-dependent, not comparable across probes)|
|Vision|512×512 solid-red PNG → **`Red`** (277 prompt tokens — the image really reached the tower). See the probe-size trap in *Client contract*|
|Tools / thinking|native `tool_calls`, thinking in `reasoning_content`, thinking OFF unless `enable_thinking: true` (MiMo template default; `models.yml` ships it in `extraBody`)|
## Client contract
llama.cpp surface, live-verified on this model family: model id `frank-small`,
native tools via `--jinja` (`tool_calls` in the response), thinking in
**`reasoning_content`**, vision via `image_url` content parts. Two vision
traps, both measured here:
1. A top-level `images` field on `/v1/chat/completions` is silently ignored
and the model invents a color.
2. **The probe image must be big enough to supply real image tokens.** A
10×10 px solid-red PNG came back "The image is a video, so I can't see its
content" — the engine itself warns at boot that Qwen-VL wants ≥ 1024 image
tokens, and a postage-stamp image lands one patch below usable signal.
512×512 answers `Red` in 19 tokens. Always probe vision with a ≥ 256 px
image; a wrong-color answer from a tiny image is the probe's fault, not
the endpoint's.
`~/.omp/agent/models.yml` sets `contextWindow: 200704` — the PER-SLOT window,
so the harness compacts before llama.cpp answers a longer prompt with a 400.
## Rollback
|Target|How|
|---|---|
|Q5_K_M at the same shape|`scripts/frank-small.env` `MODEL_FILE=/home/dank/models/MiMo-Q5KM/MiMo-V2.6-Distill-Qwen-9B-Q5_K_M.gguf` (+ `MMPROJ_FILE` in that dir) and the deployer default — buys back 875 MiB (3,571 free measured) **and the 10 % decode rate** (104.84 tok/s measured). Payload sha-verified on disk|
|3 × 180,224 + q8_0 V|`CTX_SIZE=540672 PARALLEL=3 CACHE_TYPE_V=q8_0` (measured 21,684 used / 2,441 free **with Q5_K_M** — Q6_K does NOT fit that shape inside the floor; pair them explicitly)|
|SGLang + INT8|[[frank-small-sglang-mimo-v26]] — payload staged to `/home/dank/.AGENTTRASH/20261002-retired-llm-payloads/`|
## When it misbehaves
Same decision tree as [[frank-small-llama-mimo-q5km]] (*When it misbehaves*):
`No CUDA GPUs are available` ⇒ `nvidia-modprobe -u -c=0`; a 400 naming
200,704 ⇒ the conversation outgrew one slot, not the pool; slow decode after
a shape change ⇒ check `nvidia-smi` for KV pushed into unified memory; queue
behind two long generations ⇒ the slot ceiling is two, by contract.
+81 -41
View File
@@ -1,21 +1,26 @@
# LLM Fleet — Knowledge Base
Documentation for the LLM inference fleet on the two hosts (2026-10-02):
Documentation for the LLM inference fleet on the two hosts (2026-10-03):
**Dankserver runs the tcclaviger RDNA4 vLLM fork on frank-primary** (the
digest-pinned `tcclaviger/vllm` image driving `podman run` directly, serving
orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8 across both Radeon AI PRO R9700s
on :7770, TP=2 with automatic tiered-expert offload, **4 seqs x 262144 ctx
(the model's full native window)**, fp8 KV (5 boot but leave no runtime
headroom; 6 do not fit), no speculative decode, MNBT 1024, MoE gather 32x64, util 0.98,
reasoning/tool parsers wired (qwen3 / qwen3_xml) with preserve_thinking on,
measured 36.4-39.4 tok/s decode — the 2026-10-01 bf16 no-spec cutover plus
the night ladder; rollback frank-primary-fp8-mtp-20261001), and Dankclaw
runs two endpoints — **ROCmFPX llama.cpp (frank-large, port 7770:
kingjones777/Qwen3.8-27B-ROCmFP4-STRIX-MTP-GGUF Q8_0_ROCMFPX_AGENT, a ftype-115
file only that fork can read, with the repo's MTP head drafting 4 tokens, ONE
session at 262,144, f16 KV, vision via the bundled projector)** and
**llama.cpp CUDA (frank-small, port 7771: MiMo-V2.6-Distill-Qwen-9B Q5_K_M
GGUF, 2 x 262,144, asymmetric KV f16 K + q8_0 V, on the RTX 3090)**.
on :7770, TP=2 with automatic tiered-expert offload, **shape E since
2026-10-02: 3 slots x 200704 ctx** with the KV pool sized for **~2 full-length
conversations** — measured 398,658 tokens = 1.99x — fp8 KV, no speculative
decode, MNBT 1024, MoE gather 32x64, util 0.98, reasoning/tool parsers wired
(qwen3 / qwen3_xml) with preserve_thinking on, plus **`--expert-precise-memory`
(the arena grows to 17.22 GiB / 7,522 slots, +16%), `GPU_MAX_HW_QUEUES=2`, and a
mounted cache dir** (the image ships no `/root/.cache` at all; unmounted, every boot re-pays
`torch.compile took 181.20 s`). `--max-num-seqs` must stay 3:
SEQS 2 cannot compile this model. Rollback frank-primary-fp8-mtp-20261001),
and Dankclaw
runs two endpoints — **halogen container (frank-large, port 7770:
ghcr.io/peonist-ai/halogen @ pinned digest serving peonist-ai
/halogen-qwen3.8-27b, a 33.4 GiB .hgn checkpoint that loads in no other
engine, DFlash2 spec decode, ONE slot at 200,700 ctx, text-only — vision
lives on frank-small)** and
**llama.cpp CUDA (frank-small, port 7771: MiMo-V2.6-Distill-Qwen-9B Q6_K
GGUF, 2 x 200,704, fully unquantized f16 KV, on the RTX 3090)**.
> 2026-08-22: the former `vllmScripts` repository is merged into the
> serverAppConfigs repository. This knowledge base moved from the old repo's
@@ -31,22 +36,23 @@ graph LR
gfx1201, 32 GiB each]
B2[tcclaviger RDNA4 vLLM fork, digest-pinned
podman run, frank-primary, port 7770]
C[Qwen3.8-Flash-Next FP8 bf16 no-spec
TP=2, expert offload automatic, 4 x 262144 ctx,
fp8 KV, MNBT 1024, gather 32x64, qwen3 parsers]
C[Qwen3.8-Flash-Next FP8, no spec decode
TP=2, expert offload automatic, 3 x 200704 ctx,
fp8 KV pool ~2 full-length, MNBT 1024, gather 32x64, qwen3 parsers,
expert-precise-memory arena 7,522 slots, /root/.cache mounted]
end
subgraph Dankclaw[AMD Strix Halo - Ubuntu 26.04]
E2[RTX 3090 (PCIe x4 dock) +
gfx1151 iGPU 114 GiB GTT]
E[ROCmFPX llama.cpp gfx1151, pinned fork build
E[halogen container gfx1151, digest-pinned image
frank-large, port 7770]
F[Qwen3.8-27B Q8_0_ROCMFPX_AGENT + MTP-Q4_0 head
1 x 262144 ctx, f16 KV, draft n-max 4, mmproj vision]
F[halogen-qwen3.8-27b .hgn p1w4d-d2 + DFlash2 drafter
1 x 200700 ctx, d2 KV, text-only]
G[llama.cpp master CUDA sm_86
frank-small, port 7771]
H[MiMo-V2.6 Q5_K_M GGUF
2 x 262144 ctx, f16 K + q8_0 V, flash-attn]
2 x 200704 ctx, f16 K + f16 V, flash-attn, mmproj vision]
E2 --> E --> F
E2 --> G --> H
end
@@ -65,24 +71,43 @@ frank-small, port 7771]
## Active Deployment
- [[frank-large-rocmfpx-q8agent-mtp]] — **ACTIVE since 2026-10-02**
(root systemd unit, port 7770): the pinned kingjones30/ROCmFPX fork serving
the repo's highest-quality rung `Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf`
(26.28 GiB, ftype 115 = 187 plain-Q8_0 + 311 GGML_TYPE_Q8_0_ROCMFPX
tensors) with the bundled `mtp-Qwen3.8-27B-Q4_0.gguf` head drafting 4
tokens, one 262,144 window, f16 KV, the BF16 projector for vision. Why the
runtime is not interchangeable, the pinned-build set_rows history, the
measured envelope, rollback to gufo
- [[frank-small-llama-mimo-q5km]] — **ACTIVE since 2026-10-02** (root
systemd unit, port 7771): llama.cpp master (CUDA sm_86) serving
bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF Q5_K_M on the RTX 3090,
THREE slots of 180,224 tokens (pool 540,672) with K f16 + V q8_0 and the
mmproj for vision, ~100 tok/s decode, ~2.7 GiB of the card left free.
The KV arithmetic, the flash-attn dependency, rollback to Q6_K
- [[frank-large-halogen27b-dflash2]] — **ACTIVE since 2026-10-05**
(root systemd unit + podman, port 7770): the digest-pinned
`ghcr.io/peonist-ai/halogen` image serving `peonist-ai/halogen-qwen3.8-27b`
(`qwen3.8-27b-p1w4d-d2.hgn`, 33.4 GiB) with the DFlash2 drafter on ONE slot
of 200,700 ctx (slots>1 disables speculation — engine law). Publisher card
31.7 t/s mean across eight text workloads. Accepted tradeoffs: TEXT-ONLY
(the engine ignores the vision encoder; frank-small owns images) and the
CENSORED base model (the ROCmFPX AGENT file was Uncensored by construction).
No server-side sampler — the thinking recipe rides the wire in models.yml.
Rollback: revert the swap commit; the ROCmFPX payload + fork build +
`build-rocmfpx.sh` pin all remain on disk
- [[frank-small-llama-mimo-q6k-2x200704]] — **ACTIVE since 2026-10-03**
(root systemd unit, port 7771): llama.cpp master (CUDA sm_86) serving
bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF **Q6_K** on the RTX 3090, TWO
slots of 200,704 tokens (pool 401,408) with FULLY UNQUANTIZED KV (f16 K +
f16 V) and the mmproj for vision — 94.4 tok/s decode (the rung costs a
measured -10 % vs Q5_K_M: weight-bandwidth-bound), batch 4096 / ubatch 2048
since 2026-10-05 (+4.6 % prefill, measured), 2,279 MiB of the card
left free. Probe-size trap (>=256 px images or the model says "video")
documented. Rollback: one MODEL_FILE line back to Q5_K_M
- [[frank-small-llama-mimo-q5km]] — **RETIRED 2026-10-02 → 10-03 (rollback
target; payload on disk; owns the measured boot-pair table)**: the same
runtime and the same 2 x 200,704 + f16 KV shape one quant rung lower —
104.8 tok/s, 3,571 MiB true free
- [[dankserver-qwen38-fp8-expert-offload-tuning]] — **ACTIVE since
2026-10-02 (shape E)** (root systemd unit, port 7770): the tcclaviger RDNA4
vLLM fork serving orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8 across both
R9700s, TP=2 with automatic expert offload — `--max-model-len 200704` x 3
slots, fp8 KV, pool 3,150,000,000 B/rank (measured 398,658 tokens = 1.99x),
MNBT 1024, gather 32x64, no spec decode, prefetch off, plus
`--expert-precise-memory` (arena 17.22 GiB / 7,522 slots), GPU_MAX_HW_QUEUES=2
and a mounted compile cache. Why `--max-num-seqs`
must stay 3 (SEQS 2 cannot compile this model), why the arena's levers are
`max_model_len` and the overhead term rather than the KV pool, and the measured envelope
- [[frank-primary-glm53-flash-w4a16]] — RETIRED 2026-09-30 → 10-01 (the
- failed GLM experiment; frank-primary runs Qwen FP8 again, see the intro):
(2026-09-30, root systemd unit, port 7770): the tcclaviger RDNA4 vLLM fork
(2026-09-30, root systemd unit, port 7770): the tcclaviger RDNA4 vLLM fork
failed GLM experiment; frank-primary runs Qwen FP8 again, see the intro):
the tcclaviger RDNA4 vLLM fork
(digest-pinned `tcclaviger/vllm@sha256:da4029666…`, plain `podman run`,
no shim) serving Intel/GLM-5.3-Flash-W4A16-AutoRound — AutoRound symmetric
int4 group 128, ~170 GiB, 34 shards + 2 extras, 113,074 tensors — on BOTH
@@ -178,8 +203,21 @@ frank-small, port 7771]
(bs1/bs3/bs4/bs6, 8k–700k KV) on Dankserver RTX 3090
- [[deployment]] — deploy and update process for both servers
- [[frank-redo-2026-08-17]] — the 2026-08-17 topology change
- [[frank-large-gufo-qwen38-27b]] — **RETIRED 2026-10-02 (the rollback
target)** (root systemd unit, port 7770): gufo
- [[frank-large-rocmfpx-q6agent-uncensored-mtp]] — **RETIRED 2026-10-03 →
10-05 (rollback target; payload, fork build and build script on disk)**:
the pinned ROCmFPX fork serving the uncensored protected-heads
Q6_0_ROCMFPX_AGENT rung with the repo's MTP-Q4_0 head drafting 4 tokens,
one 262,144 window, f16 KV; measured 27.6-28.1 t/s prose decode at 86.8 %
acceptance. Replaced by the halogen DFlash2 endpoint; the ngram-mod-onto-MTP
rejection (-15 %, recurrent-state rollback) stays a live lesson for this
architecture.
- [[frank-large-rocmfpx-q8agent-mtp]] — **RETIRED 2026-10-02 → 10-03 (the
rollback target; payload still on disk)**: the same pinned ROCmFPX fork
serving the ALIGNED `Q8_0_ROCMFPX_AGENT` (ftype 115, 17.0 t/s, MTP
acceptance 0.462) — the MTP-vs-none +123 % A/B, the trap catalogue
- [[frank-large-gufo-qwen38-27b]] — **RETIRED 2026-10-02 (history; the
payload dirs were removed from disk 2026-10-03, so this rollback is a
re-download)** (root systemd unit, port 7770): gufo
(gufo-org/gufo) v0.1.1, the Strix Halo inference engine, built from
source on Dankclaw, serving unsloth/Qwen3.8-27B-GGUF **UD-Q4_K_XL**
with the z-lab DFlash2 Q4_K_M drafter — ONE session at the full native
@@ -229,8 +267,10 @@ frank-small, port 7771]
.hgn + quality overlay) on the gfx1151 iGPU — two 200k conversations over one
400,384-position pool, deployed flags, memory model, client contract,
troubleshooting (2026-09-16 → 09-19)
- [[frank-small-llama-mimo-q6k]] — **RETIRED 2026-10-01 → 10-02 (rollback
target)**: llama.cpp (master, CUDA sm_86 build) serving
- [[frank-small-llama-mimo-q6k]] — **RETIRED 2026-10-01 → 10-02** (the same
rung returned ACTIVE 2026-10-03 at the 2 x 200,704 f16-KV shape —
[[frank-small-llama-mimo-q6k-2x200704]]): llama.cpp (master, CUDA sm_86
build) serving
bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF **Q6_K** on the RTX 3090 — TWO
native 262,144-token sessions with ASYMMETRIC KV (K f16 unquantized + V
q8_0, which forces flash-attn), ~20.9/24.6 GiB measured, text-only (the
@@ -0,0 +1,207 @@
---
type: reference
title: vLLM Boot Forensics and Config Interrogation
description: Capturing a vLLM container boot failure's real root cause from journald (the container log is already gone), classifying the failure, and interrogating a live endpoint's true config and client contracts.
tags: [vllm, forensics, journalctl, podman, config, contracts]
timestamp: 2026-10-05T00:00:00-07:00
---
# vLLM Boot Forensics and Config Interrogation
For serve-script vLLM endpoints (frank-primary/qwen-fp8 on dankserver, any
`podman rm -f` + restart service).
## 1. Capture the true cause — the journal, not `podman logs`
The serve script runs `podman rm -f <container>` at every start, so a dead
container's log is GONE by the time you look; `no container with name or ID`
means the journal is the only source. The engine's stdout survives in
journald under the container tag:
```bash
journalctl --no-pager --since "20 min ago" | grep -a "<container>\[" | tail -40
journalctl --no-pager --since "8 min ago" | grep -a "<container>\[" \
| grep -aiE "error|exception|assert|raise|invalid|failed" | grep -av APIServer | tail -12
```
Why naive greps fail:
- Default format truncates lines at the display cut, and the fork's giant
single-line ERROR dumps are journald-SPLIT — the decisive sentence never
appears in short format.
- `-o cat` strips the `svc[pid]:` prefix, so grepping the tag against `-o
cat` returns nothing — use the default format for the tag, `-o json` for
the content.
- Time filters: relative (`--since "NN min ago"`) only — absolute timestamps
are local PDT while the engine logs UTC; never mix.
**The JSON decode procedure** (proven 2026-10-01, caught
`torch.OutOfMemoryError` exactly):
1. Bound the window: last traceback lines + the `container died` podman
event.
2. `journalctl --no-pager --since … --until … -o json | python3 -c …` on the
remote: handle `MESSAGE` possibly arriving as a **byte list**
(`bytes(int(x) for x in m).decode("utf-8","replace")`), strip ANSI
(`re.sub(r"\x1b\[[0-9;]*m", "", m)`), dedupe by first ~12 chars (one
logical traceback can arrive as 20+ entries).
3. Reassemble the worker traceback: filter skeleton lines
(`File |…| raise |^+|~+|…<N> lines|…|re-`), keep content lines. The
decisive line is usually the last content line.
4. **The diagnosis is the gap math: `ask − free = shortfall`.**
**The wrapper trap:** `RuntimeError: Engine core initialization failed. See
root cause above` is the WRAPPER — the real cause is the EngineCore traceback
above it, ending in `RuntimeError: Worker failed with error '<cause>'`. Grep
for `Worker failed with error`.
**For probe boots / runner scripts:** capture live BEFORE teardown —
`podman logs -f <ctr> > /home/dank/logs/<arm>.engine.log 2>&1 &` right after
launch. A runner's `podman rm` destroys the only copy; two 2026-10-01
failures had their root cause eaten this way, and an 8-line bootfail capture
is insufficient.
## 2. Failure classes
| signature | class |
|---|---|
| `CUDA out of memory. Tried to allocate X GiB … Y GiB free` | capacity; the gap math names the next rung ([[RDNA4 MoE Expert Offload VRAM Budget]]) |
| `Worker failed with error 'Constraints violated (L['query_start_loc'].size()[0])! … specialized it to a constant (N)'` | torch.compile/dynamo shape specialization (N = num_seqs+1-ish) — NOT memory |
| `Deactivated successfully` with no traceback | container exited CLEAN (vLLM API server exits 0 after an EngineCore fatal); container already removed — re-read the journal, check `NRestarts` |
| `no container with name or ID` | container rm'd; journal only |
Boot-order reality: the plan computes fine at impossible budgets; the
failure lands later in worker init, not at plan time. A plan line printing a
huge arena does NOT mean it will boot.
Bypasses this fork exposes for compile faults: `--enforce-eager` (skips
torch.compile AND CUDA graphs — drops graphs, a real cost),
`VLLM_DISABLE_COMPILE_CACHE=1`, `VLLM_USE_AOT_COMPILE=1`. A stale cache is
usually NOT the cause — verify before "fixing" it (`~/.cache/vllm` may hold
only `modelinfos`; each `podman run` gets a fresh container FS unless the
cache dir is mounted).
**Crash-loop signature (shape does not fit):** `Started … ~6.7 min later …
Deactivated successfully … restart`. Diagnose with `systemctl show -p
NRestarts` + per-attempt duration. `Restart=always` loops visibly;
`Restart=on-failure` stays SILENTLY DEAD (`podman wait` returned 0, systemd
saw a clean exit).
## 3. Observation hygiene during recovery
- A container being STOPPED still answers `podman logs`, so a watcher
grepping its log reports PRE-change values and looks like the fix failed.
Discriminate by container identity:
`podman ps --sort created -a --format '{{.ID}} {{.Status}} {{.CreatedAt}}'`
— require the container to post-date the arm start, then read only that ID.
- A leftover probe script whose tail does `podman rm -f <prod-container>`
fires against a freshly DEPLOYED engine. Signature: a previously-healthy
unit exits 0 (`Started → Deactivated`, code 0) — an external rm, not a
crash. Suspect an external actor before debugging the engine; `pgrep -af`
every script you launched (and kill strays of `deploy-remote.sh` by
explicit PID — killing the dev-side deploy job does NOT kill its host-side
runner).
## 4. Ops laws that bite during recovery
- `Restart=always` does NOT fire after an explicit `systemctl stop` — a
deploy's stop leaves the unit down until something starts it. Check
`systemctl is-active` yourself.
- `ExecStop=podman stop -t 120` requires `TimeoutStopSec` > 120; the 90 s
default kills the stop and logs `Failed with result 'timeout'`.
- A `D`-state worker cannot be reaped by ANY signal (SIGTERM/KILL/`podman rm
-f` all fail). Check `ps -o pid,stat,etime,comm` before declaring a GPU
wedge — measured 2026-10-01: D → R on its own within ~3 min, then the
container stopped normally.
- Never `pgrep -f`/`pkill -f` a pattern that appears in your own command
line — kill by explicit PID list.
- `git stash -u` (not bare `git stash`) for deploy windows — untracked files
still trip the deployer's dirty-tree gate.
- A production engine started by a transient/ladder unit is NOT
boot-persistent; pin the winning numbers in the repo and deploy the real
unit.
## 5. Logging audit (what more can this container say?)
Answer with evidence from the live image, not upstream docs:
1. Enumerate the fork's own env registry:
`podman exec <ctr> python -c "import vllm.envs as e; print(chr(10).join(k for k in dir(e) if k.isupper() and any(s in k for s in ('LOG','DEBUG','TRACE','STATS','PROFILER','VERBOSE'))))"` — then read defaults/clamps in `vllm/envs.py` (e.g. `VLLM_LOG_STATS_INTERVAL` defaults 10.0 s, clamped ≥ 1.0; telemetry emits only while requests are in flight).
2. **Find the REAL HIP/ROCm library paths before grepping for AMD env vars** —
containers often install ROCm into system dirs, not `/opt/rocm`
(`podman exec <ctr> sh -c 'find / -name "libamdhip64.so*"'`). A grep of
`/opt/rocm/lib` that finds nothing is a FALSE NEGATIVE, not an absence.
Then `strings <lib> | grep -E '^AMD_LOG_LEVEL$|^HSA_ENABLE_DEBUG$'`.
3. Scan vendor kernel `.so`s for switches — and separate tuning vars from
logging vars (both share a prefix).
4. **Measure the DEBUG payoff**: count `logger.debug` sites in the module of
interest. 0–10 sites ⇒ a per-module DEBUG bump buys almost nothing; global
`VLLM_LOGGING_LEVEL=DEBUG` additionally makes api_server log full
request/response bodies (incident window only).
5. Check the deployed argv for `--disable-log-stats` / `--log-configs` to
know what is already suppressed.
Report: lever / effect / cost table, split into permanent-safe (stats
interval), incident-window (`AMD_LOG_LEVEL`, DEBUG), and does-not-exist
(absent strings). Wire via env-gated `-e` pass-throughs so default-off
preserves behavior.
## 6. Interrogate a live endpoint's REAL config
Flags and env diverge from intent constantly — rollbacks drop them silently.
Read what the container is actually serving, then prove the client contract
behaviorally:
```bash
# live argv (the truth)
ssh host 'podman exec <ctr> bash -c "cat /proc/1/cmdline | tr \"\\0\" \" \"" | tr " " "\n" \
| grep -A1 -E "max-model-len|max-num-seqs|kv-cache|reasoning-parser|tool-call-parser|template-kwargs|utilization|speculative"'
# container env knobs (fork tuning that never appears in argv)
ssh host 'podman exec <ctr> bash -c "env | grep -E \"R4D_|CLAV_|VLLM_\" | sort"'
# capacity contract as measured at boot
ssh host 'podman logs --tail 3000 <ctr> 2>&1 | grep -aE "GPU KV cache size|Maximum concurrency|non-default args"'
```
`non-default args` lines print what the server overrode — the fast way to
catch a flag that never landed. `GPU KV cache size: N tokens, Maximum
concurrency for M tokens per request: X.XXx` is the capacity contract:
N ≥ requests × M or the last client gets truncated.
**Thinking contract:** a reasoning model on vLLM returns `reasoning` ONLY if
the server runs a reasoning parser; without it the thinking lands INLINE in
`content` with a dangling close-tag and a null `reasoning`. Verify with a
temp-0 completion: pass = `reasoning` non-empty AND no `<\/think>` in
`content`. Diagnose with `ReasoningParserManager.list_registered()`
(import `vllm.reasoning`).
**Tool contract:** `tool_choice: "auto"` on a server WITHOUT
`--enable-auto-tool-choice` returns HTTP 400 — reads like a model fault, is a
config gap. Probe with a real tool schema and check `tool_calls` +
`finish_reason`.
**Restoring a dropped contract from history:**
```bash
git log --all --oneline -S"reasoning-parser"
git show <commit>:scripts/serve-<svc>.sh | grep -B3 -A3 "reasoning-parser"
```
Qwen3-family values that work on the tcclaviger fork: `--reasoning-parser
qwen3`, `--enable-auto-tool-choice --tool-call-parser qwen3_xml`,
`--default-chat-template-kwargs '{"enable_thinking": true,
"preserve_thinking": true}'`. `preserve_thinking`/`enable_thinking` are
chat-template variables, NOT engine flags: they belong in
`--default-chat-template-kwargs` server-side or `chat_template_kwargs` in the
request body; a bare top-level body field of that name is ignored. Check the
payload's `tokenizer_config.json` carries the key before trusting it either
way.
`/health` 200 says the engine is up, not that the contract is on the flags
you think — any client report of odd content (inline thinking, 400 on tools,
clipping at depth) starts with sections 1–3 here before touching anything.
## Related
- [[RDNA4 MoE Expert Offload VRAM Budget]] — the plan arithmetic behind OOMs
- [[dankserver-qwen38-fp8-expert-offload-tuning]] — deployed shape and history
- [[frank-large-benchmark]] and [[frank-small-benchmark]] — probe/ladder hygiene on live hosts
@@ -0,0 +1,330 @@
---
type: reference
title: vLLM Porting and Compile Triage
description: Forward-porting a model architecture to a new vLLM major (additive-only discipline, gap enumeration, shims that survive worker spawn) and the torch.compile/dynamo break triage, including the EAGLE3/DFlash2 aux-hidden-state protocol port rules.
tags: [vllm, porting, torch-compile, dynamo, eagle3, dflash, glm]
timestamp: 2026-10-05T00:00:00-07:00
---
# vLLM Porting and Compile Triage
## Part I — Forward-porting a model architecture
### 1. Check whether a port already exists; size the work honestly
Read what the target image already ships before assuming a port is needed: a
backport's upstream PR may be merged in the target version, and the target may
carry a native kernel path the older line lacks (e.g. `rfi_hip` for RDNA4
native quantized MoE). Porting around a native path that was already there is
the expensive mistake.
An upstream backport ships two kinds of file: **additive** (the new model
package, its config, its kernel modules — port these) and **whole-file
replacements of vLLM core** (`model_runner.py`, `scheduler.py`,
`kv_cache_utils.py`, platform files — these are version X's copies of files
version Y restructured; copying them is a DOWNGRADE, not a port). Measure both
(`find … | xargs wc -l`), and say the additive-only ratio out loud before
starting — one real case: a 71,724-line overlay reduced to 10,385 additive,
and the "missing" drafter was already registered in the target.
### 2. Enumerate the gap in ONE pass, not one ImportError at a time
Inventory the target tree first (`model_executor/layers/`,
`v1/attention/backends/`, quantization backends, spec-decode drafters in
`registry.py`, `third_party/flash_linear_attention/ops/`). Then walk the
staged package with `ast`, resolving every `from vllm… import X` against the
target, split into MODULE-missing vs NAME-missing.
- **The double-prefix bug:** the dotted module already starts with `vllm.`,
so joining onto the package dir needs
`rel = mod.split(".", 1)[1].replace(".", os.sep)`.
- **Static scans over-report:** bare annotations, TypeAlias and star
re-exports resolve at runtime. Do a runtime check
(`importlib.import_module` + `getattr`) before quoting numbers — one scan
said 151/163 resolved; runtime said 10/13.
- **Classify:** redirects dominate (version moved a symbol to a submodule) —
a ~60-line rewriter splitting `from X import (a, b, c)` blocks handles
them; **delete a block that empties** (a bare `from X import (` with an
empty body is a SyntaxError) and gate the rewriter on `ast.parse` of every
touched file.
- **Port absent symbols with AST extraction**, not copy-paste: locate the
`ClassDef`, check every base/decorator resolves in the target, append the
exact segment, re-`ast.parse` the target, roll back on failure, skip if
already defined.
### 3. Silent-default traps and transformers gates
- Check every `cfg_int(cfg, key, DEFAULT)` against the new checkpoint: if the
key was renamed, the DEFAULT silently survives and the model computes fluent
nonsense with no error. Diff every config key the old parser reads against
the new `config.json`. When the old port used a fallback, the CHECKPOINT's
value must win (measured: `num_experts_per_token` fallback 7 vs
checkpoint `num_experts_per_tok=8`).
- `model type X but Transformers does not recognize this architecture` =
pinned transformers predates the model. Upgrading transformers inside a
pinned vLLM image breaks things hard to attribute — prefer registering a
genuine `PretrainedConfig` subclass:
`CONFIG_MAPPING.register("glm5_next", Glm5NextConfig)`, gated by a real
round-trip (`AutoConfig.from_pretrained(<ckpt>)` returns the right class).
If the target image's transformers is below vLLM's declared minimum,
upgrading inside the image beats a shim.
- **Hardware-gate flags from the port's own docs**: search the README for the
arch name and "do not" — e.g. a native quant MoE backend that is
CUDA/CDNA-gated falls back to per-call quantize-dequantize emulation at
~5x slower; pin those flags in the serve script with a comment citing the
line.
- **Backend selection:** default to `auto`, then READ the chosen-backend
line. `TRITON_UNFUSED` rejects some activation keys; `TRITON_ATTN` rejects
MLA + sparse attention — both surface as an init rejection. **"Using
'EMULATION' backend" is a FAILED configuration, not a success** (weights
dequantized per call: memory + throughput cost).
- `Loading model from scratch…` for many minutes usually means the port
SUCCEEDED and you are in weight placement — verify all layers constructed,
both TP workers alive, zero errors, before chasing memory. And if it does
not fit, **prove the band is empty**: sweep the knob and show no setting
works; a single OOM is not a conclusion. Keep `MemAvailable` guarded
(abort on low RAM; do not let the OOM killer take co-tenants).
### 4. Shims that must survive worker spawn
vLLM v1 workers are spawned processes — a parent-process monkeypatch is gone
by weight-load time. In order of preference:
1. **Patch the installed library's source file on disk** (reliable). Locate
the real install path at runtime:
`python -c "import transformers,os;print(os.path.dirname(transformers.__file__))"`.
2. `.pth` in site-packages — works but runs at interpreter start and can
import a package mid-import (circular import through `accelerate`);
expect failure.
3. `sitecustomize.py` — often silently DEAD: Debian ships its own at
`/usr/lib/python3.12/sitecustomize.py`. Verify it is actually imported
before debugging why it has no effect. (A `.pth` on `PYTHONPATH` is never
executed by `site.py`; `sitecustomize.py` is.)
Always confirm with a FRESH process — never with the process that set the
patch up (`is_moe`, `num_experts_per_token`, `mhc_num_residual_streams`).
**Misleading OOM names on ROCm:** `torch.AcceleratorError: CUDA error: out of
memory` from `pin_memory()` is usually the HOST `memlock` rlimit, not VRAM.
Cannot be raised from inside a container or root's own shell; only PAM at
login: `/etc/security/limits.d/*.conf` + a NEW session. Confirm by pinning
2/8/16 GiB — if those succeed, the rlimit was a real bug but is not the
failure you are chasing.
### 5. Ship as an idempotent patch-at-start applier
One `apply.sh` at container start, each step guarded by a grep-for-marker
(re-entry safe), ending in a verification block that imports every installed
symbol and prints PASS/FAIL per symbol — ~10 s instead of a multi-minute
load, and it turns "I think it works" into evidence.
---
## Part II — torch.compile / dynamo bring-up triage
### The five laws (measured on torch 2.11+rocm, vLLM 0.29-dev)
1. **`suppress_errors` does NOT cover graph breaks** — fullgraph=True +
suppress_errors=True still raises `Unsupported`. Only `fullgraph=False`
tolerates a break.
2. **A warm `lru_cache` is not trace-safe** — dynamo re-traces the wrapped
body to build its guard even on a hit ("Dynamo ignores the cache wrapper
and directly traces the wrapped function"). Resolve the value ONCE into a
plain module global and read that; a global read folds, a cached call does
not. Verified in-image: warm lru_cache-calling function still fails
fullgraph=True; a plain global read compiles.
3. **AOT compile refuses breaks outright** ("Graph breaks are not supported
with aot compile") — set `VLLM_USE_AOT_COMPILE=0` for a break-tolerant
run.
4. **Fatal vs tolerated breaks:** tolerated breaks get a resume function
(untraceable part runs eagerly, numerics unchanged). Fatal ones cannot:
`Unsupported: Read uninitialized cell` (a traced callee closes over a name
bound after its definition) and `Skip calling torch.compiler.disable()'d
function` under fullgraph=True. `torch.compiler.disable` is the documented
escape hatch for host-side/stateful work (ContextVar reads, lazy
allocation, workspace grab) and is a NO-OP when nothing compiles — safe to
ship in the stock path.
5. **Trust the frame chain, not the line number** — dynamo INLINES user
callees; an inlined failure reports the enclosing function's `def` line.
"KeyError in forward at the def line" is really "a callee raised".
`TORCHDYNAMO_VERBOSE=1` adds nothing for a plain Python exception (only
for dynamo-internal errors).
**The tell** that the trunk lacks compile support at all:
```
Cudagraph mode FULL_AND_PIECEWISE requires piecewise capture, but the loaded model
provides neither a compiled submodule nor breakable CUDA graphs. Overriding to
FULL_DECODE_ONLY.
```
Diff against the family that DOES compile and copy its
`@support_torch_compile` decorator including `dynamic_arg_dims` and any
`enable_if` hook; env-gate every change so the file can stay mounted for an A/B.
### Procedure
0. **Establish what configuration is ACTUALLY in effect**:
`podman exec <ctr> printenv VLLM_COMPILE_FULLGRAPH VLLM_USE_AOT_COMPILE …`.
A launcher that "sets" an env var in a branch may silently drop it — four
consecutive boots ran `fullgraph=True` while the operator believed
otherwise, which makes the reported failure MOVE as breaks are removed and
invites entirely wrong theories.
1. **Boot with the break list, not the first error:** `TORCH_LOGS=graph_breaks`
+ `VLLM_USE_AOT_COMPILE=0` + `VLLM_COMPILE_FULLGRAPH=0`. Re-verify each
candidate break under the flag-free config you actually intend — a break
that appears only under the logging flag is a diagnostic artifact.
2. **Fix in priority order:** (a) host syncs in the traced region
(`.item()`/`.cpu()`/`.tolist()` — check reachability first, a dead fallback
branch is not your blocker); (b) capability probes doing I/O in `forward`
(`os.path.exists`, `.so`/`ctypes` lookups — resolve once into globals,
warm at import); (c) lazy stateful work (hoist, or `torch.compiler.disable`
a thin wrapper); (d) new kernel specializations — check the vendor ships
SOURCES (`nm -D`/`strings`): a binary-only templated `kernel<128|256|512>`
makes the 288 you need unreachable. Fix at the PATTERN level (one
launcher/decorator family breaking all 39 call sites), not site level.
3. **KeyError on a bare module name** = dynamo's name→object map miss
(`OutputGraph.nn_modules`: registered `output_graph.py:1259`/`:1384`,
read back `:3000`, `_buffers/_parameters/_modules` stripped at `:1225`).
Ship a probe via `sitecustomize.py` that patches
`OutputGraph.example_value_from_input_node` and keeps weakrefs of live
graphs, printing `node.target`, key count, near-miss keys — separates a
cross-graph reference from a name-derivation mismatch.
4. **Instrumenting a startup hook is a FIREARM — arm it once:**
```python
if os.environ.get("PROBE_ARMED") == "1":
pass # an ancestor already armed it -> stay inert
else:
os.environ["PROBE_ARMED"] = "1"
...install probes...
```
A `sitecustomize.py` on `PYTHONPATH` imports in EVERY interpreter and
children inherit `PYTHONPATH`: an unguarded hook that imports the engine
self-replicates (observed: 19+ levels, 33 processes alive 18 min later).
Kill the chain by removing the container whose cgroup owns every
generation, then verify with a precise `/proc/<pid>/environ` count —
NEVER pattern-kill an interpreter name on a host running python in
production containers. Also chain the image's own sitecustomize so its
hook survives.
5. **Cheap in-image classification (no boot):**
`g = torch.compile(fn, backend="eager", fullgraph=True)` inside the
container (with `--device /dev/kfd --device /dev/dri` — torch.compile
queries a device even on the eager backend). ~5 s per question instead of
~7 min boots.
6. **Package as real patch artifacts:** hand-written `@@` headers are not
diffs and `patch(1)` rejects them. Generate
`diff -u --label a/<path> --label b/<path> orig new > name.patch` with
`--- a/` / `+++ b/` paths so `-p1` works; verify by applying to a fresh
extraction of the pinned image and comparing sha256 (`sha256sum` must
equal the `b/` hash recorded in the header). Header carries: STATUS, the
measured evidence, base+patched sha256, how to apply, the NEXT blocker.
### Cost model before investing
**Profile first** — parse the rank0 chrome trace (`POST /start_profile`,
device-activity rows) for the kernel table. Measured case: the long-suspected
expert-gather was **1.05 %** of device time while **44 %** was unfused
pointwise glue (708,500 `vectorized_elementwise_kernel` launches =
1,428/token) — a months-old "structural" conclusion died in one afternoon.
Speculative decoding does NOT help a shape whose per-token cost scales with
the token count, and it hurts prefill. Decide the ceiling up front: if the
fused target is ~13–15 tok/s, do not spend a night of boots chasing 35.
---
## Part III — EAGLE3 / DFlash2 / DSpark aux-hidden-state protocol port
Goal: make a trunk satisfy `SupportsEagle3` so a DFlash2 drafter
(`DFlash2DraftModel`, method `dflash`) boots. Built on the GLM-5.3-Flash port
(tcclaviger fork, 2026-09-30; four failed boots produced the laws below).
### The four laws
1. **Patch the instance the runner calls.**
`set_eagle3_aux_hidden_state_layers(self.model)` (model_runner.py, right
after `model_loader.load_model`) runs on the LOADED instance — for a
multimodal model that is the `…ForConditionalGeneration` WRAPPER (the
APIServer log line `Resolved architecture: …` names it), NOT the text
model it holds as `self.language_model`. Patching only the inner
`…ForCausalLM` reproduces the identical `RuntimeError: Model does not
support EAGLE3 interface` forever. Patch BOTH wrappers.
2. **The check is Protocol isinstance, NOT duck typing.**
`supports_eagle3(model)` = `isinstance(model, SupportsEagle3)`
(interfaces.py ~1728; caller `v1/worker/gpu/spec_decode/eagle/eagle3_utils.py:20`).
`SupportsEagle3` is `@runtime_checkable` over base `SupportsEagleBase`
(`:1486`, `:1659`); `typing._ProtocolMeta.__instancecheck__` does
`getattr_static` over ALL `__protocol_attrs__` — including BASE-protocol
members — and breaks on the FIRST miss. Full member set:
`supports_eagle3`, `has_own_lm_head`, `has_own_embed_tokens`,
`set_aux_hidden_state_layers`,
`get_eagle3_default_aux_hidden_state_layers`. **Class attributes count as
presence; a methods-only implementation FAILS** because the three attrs
are missing. Verified in-image with stock typing, no boot: flag-only →
False; flag + two base members → True.
3. **MRO forbids inheriting the Protocol.** Appending `SupportsEagle3` to a
wrapper already carrying Protocol-derived bases dies at class creation:
`Cannot create a consistent method resolution order (MRO) for bases …` —
MRO simulation (stub Protocols + `type("C", bases, {})`) confirmed no
position works. This failure also PROVES the isinstance-semantics question
is moot: class creation precedes any check.
4. **Write the two methods by hand** — without inheritance the Protocol
defaults do not exist. Text model: unwrap to the trunk
(`self.model.aux_hidden_state_layers = layers`); multimodal wrapper:
delegate to `self.language_model` (the pixtral.py:498 pattern).
`get_eagle3_default_aux_hidden_state_layers` returns `(2, L//2, L-3)`; the
drafter config's `target_layer_ids` wins when present (GLM DFlash2 ships
`[5, 14, 24, 33, 42]` of 45).
The fix shape (on EACH wrapper class):
```python
supports_eagle3: ClassVar[Literal[True]] = True
has_own_lm_head: bool = False # False when embed/lm_head are delegated
has_own_embed_tokens: bool = False
def set_aux_hidden_state_layers(self, layers): ...
def get_eagle3_default_aux_hidden_state_layers(self): ...
```
### Trunk mechanics
The trunk (`Glm5NextModel`) must be `EagleModelMixin`; add
`aux_hidden_state_layers: set[int]` (default empty) + `start_layer` handling.
Capture aux states in the layer loop: the state ENTERING each requested
layer, BEFORE its input norm (GLM's loop carries the single-stream state;
deepseek_v2's equivalent is `hidden_states + residual`).
`use_aux_hidden_state_outputs=True` is set in model_runner.py (~:287) for
methods `dflash`, `dspark`, `extract_hidden_states`; the forward returns
`(hidden_states, aux_hidden_states)` conditionally after `self.norm` (tuple
consumed at `v1/worker/gpu/cudagraph_utils.py:621`).
### Window discipline and verification
- Scratch boots on dankserver MUST stop + runtime-mask production
`frank-primary` first — the expert-offload planner reads `MemAvailable` at
plan time, and beside-prod boots die at plan with
`ExpertPlanError: host holds N of M swappable experts`. After stop+mask,
loop until `MemAvailable ≥ 230 GiB` before launching (a stop against a
mid-load engine SIGKILLs at ~90 s and the freed ~170 GiB takes >1 min to
return — a new window started during restore inherits the false failure).
- Never launch a window while the previous window's RESTORE is in flight
(`systemctl start` returns immediately; the engine loads ~6 min).
- Save full probe logs BEFORE teardown (`podman logs <probe> > full.log`);
decision greps truncate lines at `…` and tracebacks die with the container.
- Cheap verification, no boot: AST-parse the patched file for class bases +
member presence; MRO simulation with stub Protocols; in-image `typing`
replication of the isinstance walk (pure stdlib — importing vllm itself
crashes on GPU-less inspection). Boot check: the worker line `Using Eagle3
auxiliary layers from config: (…)` = gate cleared.
- Honest expectations: a target-side DFlash2/EAGLE3 port on a
pointwise-glue-bound trunk (~44 % of kernel time) buys ~ZERO decode gain —
the verification pass costs ∝ its token count. Port it for capability, not
speed.
## Related
- [[vLLM Boot Forensics and Config Interrogation]] — capture and contracts
- [[RDNA4 Container Serving]] — the serving stack being ported onto
- [[dankserver-qwen38-fp8-expert-offload-tuning]] — the live deployment
- [[halogen-flash-server]] — the retired w4b-era endpoint and its profiling notes
+46 -19
View File
@@ -213,15 +213,29 @@ The LLM inference fleet on the two hosts. Full index:
agentionai/Qwen3.8-Flash-Next-ROCmFP4-FAST-imatrix (87.06 GiB,
Q4_0_ROCMFP4_FAST) + own MTP draft, build-qwen38 (retired 2026-08-30;
rollback record)
- [[frank-large-rocmfpx-q8agent-mtp]] — the ACTIVE frank-large runbook
since 2026-10-02 (root systemd unit, port 7770): the pinned kingjones30/
ROCmFPX fork (scripts/build-rocmfpx.sh) serving kingjones777/
Qwen3.8-27B-ROCmFP4-STRIX-MTP-GGUF **Q8_0_ROCMFPX_AGENT** — ftype 115,
311 tensors of GGML_TYPE_Q8_0_ROCMFPX, a format no other loader on the
host reads — with the repo's Q4_0 MTP head drafting 4 tokens and its BF16
projector, one session at the native 262,144 window
- [[frank-large-gufo-qwen38-27b]] — RETIRED 2026-10-01 → 10-02 (rollback
target, payload and `scripts/build-gufo.sh` both on disk): gufo v0.1.1
- [[frank-large-halogen27b-dflash2]] — the ACTIVE frank-large runbook since
2026-10-05 (root systemd unit + podman, port 7770): the digest-pinned
ghcr.io/peonist-ai/halogen image serving peonist-ai/halogen-qwen3.8-27b
(qwen3.8-27b-p1w4d-d2.hgn, 33.4 GiB — a .hgn that loads in no other
engine) with the DFlash2 drafter on ONE slot of 200,700 ctx (slots>1
disables speculation — engine law); TEXT-ONLY and censored base, both
accepted tradeoffs; no server-side sampler, so the thinking recipe rides
the wire
- [[frank-large-rocmfpx-q6agent-uncensored-mtp]] — RETIRED 2026-10-03 → 10-05
(rollback target; payload, fork build and build script on disk): the pinned
kingjones30/ROCmFPX fork (scripts/build-rocmfpx.sh) serving kingjones777/
Qwen3.8-27B-Uncensored-ROCmFP4-STRIX-MTP-GGUF
**Qwen3.8-27B-Uncensored-Q6_0_ROCMFPX_AGENT** — ftype 114, 339 tensors of
GGML_TYPE_Q6_0_ROCMFPX + 159 type-103, a format no other loader on the
host reads — with the repo's Q4_0 MTP head drafting 4 tokens (27.6-28.1
t/s measured, 86.8 % acceptance; the ngram-mod chain measured and
rejected) and its BF16 projector, one session at the native 262,144 window
- [[frank-large-rocmfpx-q8agent-mtp]] — RETIRED 2026-10-02 → 10-03 (rollback
target, aligned Q8 payload still on disk): the same fork serving the
ALIGNED Q8_0_ROCMFPX_AGENT at 17.0 t/s — the +123 % MTP A/B, the traps
- [[frank-large-gufo-qwen38-27b]] — RETIRED 2026-10-01 → 10-02 (history;
the payload dirs were removed from disk 2026-10-03, `scripts/build-gufo.sh`
remains): gufo v0.1.1
built from source serving Qwen3.8-27B UD-Q4_K_XL + the z-lab DFlash2
drafter on the gfx1151 iGPU — the two from-source build workarounds, the
template-hash allowlist, the measured prefill/decode trade
@@ -282,16 +296,21 @@ The LLM inference fleet on the two hosts. Full index:
- [[ciru-activefpx-promptforge]] — the RETIRED v3 CIRU PromptForge runtime on Dankclaw (superseded 2026-08-24)
- [[kingjones-strix-mtp]] — the kingjones Qwen3.8-27B ROCmFP4 STRIX MTP model on Dankclaw (active)
- [[dealign-ornith-crack]] — the retired Ornith-1.5-35B-A3B CRACK model on Dankserver (retired 2026-09-02)
- [[frank-small-llama-mimo-q5km]] — the ACTIVE frank-small runbook since
2026-10-02: a CUDA (sm_86) build of llama.cpp master serving
bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF **Q5_K_M** on Dankclaw's RTX
3090, port 7771 — THREE conversations at 180,224 tokens each, the
asymmetric KV contract (f16 K + q8_0 V), vision through the repo's mmproj,
and the two VRAM measurements that set the pool size; ~100 tok/s decode
- [[frank-small-llama-mimo-q6k]] — RETIRED 2026-10-01 (same day; replaced
by [[frank-small-llama-mimo-q5km]], payload still on disk): the first
llama.cpp + GGUF shape for this endpoint, Q6_K at the same
2 x 262,144 / f16-K + q8_0-V contract, 20.9 GiB measured
- [[frank-small-llama-mimo-q6k-2x200704]] — the ACTIVE frank-small runbook
since 2026-10-03: llama.cpp master (CUDA sm_86) serving
bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF **Q6_K** on Dankclaw's RTX 3090,
port 7771 — TWO conversations at 200,704 tokens each with fully
unquantized f16 KV and mmproj vision; 94.4 tok/s decode (the Q6_K rung
costs a measured −10 % vs Q5_K_M — weight-bandwidth), batch 4096 / ubatch
2048 (+4.6 % prefill, measured 2026-10-05), 2,279 MiB free, and
the ≥256 px image-probe trap (tiny images answer "video")
- [[frank-small-llama-mimo-q5km]] — RETIRED 2026-10-02 → 10-03 (rollback
target, payload on disk; owns the measured boot-pair table): same runtime
and shape one rung lower — 104.8 tok/s, 3,571 MiB true free
- [[frank-small-llama-mimo-q6k]] — RETIRED 2026-10-01 (same day; that same
Q6_K rung returned ACTIVE 2026-10-03 at the new shape, see
[[frank-small-llama-mimo-q6k-2x200704]]): the first llama.cpp + GGUF shape
for this endpoint, Q6_K at 2 x 262,144 / f16-K + q8_0-V, 20.9 GiB measured
- [[frank-small-sglang-mimo-v26]] — RETIRED 2026-10-01 (rollback target):
SGLang in a self-contained python3.12 venv serving
letechlead/MiMo-V2.6-Distill-Qwen-9B-INT8-W8A16-AutoRound (qwen3_5
@@ -360,6 +379,14 @@ The LLM inference fleet on the two hosts. Full index:
- [[qwen3.8-spec-matrix]] — Qwen3.8-27B spec history (superseded by FastMTP)
- [[FastFlowLM NPU Inference]] — the retired FastFlowLM NPU endpoint
- [[DFlash DSpark Speculative Decoding]] — retired DFlash/DSpark drafting
- [[GGUF Compatibility]] — which loaders accept which quant formats; the tensor-type histogram check
- [[Llama Cpp Multi-Device Splits]] — layer vs tensor split across unlike devices; the -ot pipeline gate
- [[Llama Cpp Serving Sizing]] — VRAM arithmetic from two measured boots, never a formula
- [[PCIe Link and Atomics]] — link caps vs loaded rate; RCCL atomics routing on the old board
- [[RDNA4 Container Serving]] — the serving stack recipe: mounts, ulimits, udev, verify gates
- [[RDNA4 MoE Expert Offload VRAM Budget]] — arena sizing law for expert offload: free VRAM is estimator slack, not allocatable headroom
- [[vLLM Boot Forensics and Config Interrogation]] — journal/endpoint forensics for boot failures; argv recovery from a live engine
- [[vLLM Porting and Compile Triage]] — forward-porting vLLM majors; compile failure forensics
- [[ling-3.0-flash-trial]] — the Ling-3.0-Flash trial runbook
- [[ling-3.0-flash-vllm-plan]] — the Ling-3.0-Flash vLLM plan (retired)
+315 -3
View File
@@ -1,5 +1,98 @@
# Bundle Change Log
## 2026-10-05
- **e2e: the FrankBook-Pro leg scoped to its own trigger-gated Playwright
project.** The `11-admin` walk included the six `*arr`/qBittorrent admin
links that terminate on 192.168.0.112, so every deploy gate died there when
the Mac mini slept (Caddy 502 on the five vhosts, connection timeout on the
raw Sonarr link). Split: those links moved to
`tests/e2e/specs/19-frankbook.spec.ts` under a `frankbook` project;
`tests/e2e/run.sh` excludes it from the default project set, and
`scripts/deploy.sh` computes a trigger from the same BASE..HEAD range the
plan uses (the spec, `frankbook-pro/**`, `shared/homepage/services.json`,
the `dankclaw/Caddyfile` arr vhosts) and sets `E2E_INCLUDE_FRANKBOOK=1`.
Verified live with the host asleep: full suite 33/33 in 1.1 min, the admin
walk back to 5.4 s; `--project=frankbook --list` proves the selection.
Live pass of the frankbook walk itself is deferred to the next deploy with
an awake host.
- **Catch while verifying: the 17:47 Ornith-1.5-9B swap (`77decb5`) had
left `frank_small_unit.bats` and the deployer's payload gate stale** —
the seam still expected MiMo-Q6K names and `deploy-remote.sh` gated
`LLM_SMALL_MODEL_FILE`/`LLM_SMALL_MMPROJ` on files the unit no longer
loads (the anti-drift seam doing its job). Both repointed to the
Ornith-Q8 paths; full `./tests/run.sh` back to green (366 tests).
- **frank-small: compute-buffer pair doubled to 4096/2048, and the prefill win
measured rather than assumed.** Frank asked for the batch and ubatch doubled.
The blocker was on record: the Q5_K_M predecessor documented **"ubatch 2048
failed the scratch boot"**, and this shape carries 616 MiB less free space
than that predecessor did.
- The A/B used the service itself as the bench (one card, one server — no
scratch port): the old rung was re-booted through a transient
`FRANK_SMALL_ENV_FILE` systemd drop-in, and it reproduced the 2026-10-03
boot **byte-identically** (21,276 used / 2,849 free), which is what makes
ubatch the only variable. Repo never saw a fake revert commit; scaffolding
staged to `~/.AGENTTRASH/20261005-frank-small-ubatch-ab/`.
- **At 8,928 prompt tokens: prefill 3,972/4,077 -> 4,207/4,212 t/s, +4.6 %,
for 616 MiB (free 2,849 -> 2,279).** Decode flat (91.9–93.0 vs 93.8–94.3 —
noise). The control that proves it is geometry and not drift: a 279-token
prompt sits below *both* micro-batches, so it cannot see the knob, and it
did not move (2,676 vs 2,658–2,811).
- **The predecessor's boot failure did not reproduce** — that was a shape
observation, not a law; this shape boots 2048 clean. And the fleet lesson
"**`--ubatch-size` has an optimum**" is now scoped to what it actually is:
true on the 2x R9700 layer split (peak 512, −10 % by 2048), false on the
CUDA 3090 where the curve climbs monotonically to 2048. AGENTS.md corrected
— the curve is backend-specific and must never be carried across backends.
- The seam stopped hard-coding a rung: `serve_argv` now carries `BATCH_SIZE`
and `UBATCH_SIZE` the way it already carried `CTX_SIZE`, and the pair test
asserts the argv follows the shipped env plus the `ubatch <= batch`
invariant. Proven live both ways — retuning the env to 8192/4096 keeps it
green, inverting to 2048/4096 turns it red. (The old assertions only ever
exercised the serve-script *defaults*, because the test env never carried
these two keys.)
- `4096/1536` is the documented middle rung; 2,279 MiB free sits at the bottom
edge of the 2–3 GiB floor. Deployed `e5893dc`, e2e gate PASSED (2 mini specs
flaky-then-passed once the sleeping FrankBook-Pro woke mid-run — WoL took a
second burst and ~90 s). Docs:
[[frank-small-llama-mimo-q6k-2x200704]] (*Compute-buffer geometry*).
## 2026-10-03
- **frank-primary: shape F then shape G — MNBT 2048, the CUDA-graph plan term
pinned to the measured capture, KV forced to 400,700 tokens, and UTIL 1.0
proven UNBOOTABLE.** Frank asked what the batch was with GPUs showing open
space; the audit found the arena is `budget - non-expert - KV reserve -
graphs - overhead` where the only deploy-reachable terms are the budget and
the graph estimate (registry KV price 9,190 B/token and the 5.25 GiB
overhead are image constants; the ~3.5 GiB of driver-free VRAM was
estimator slack, not allocatable).
- Shape F (c682c63): MNBT 1024 -> 2048 (arena-free — the registry prices
overhead flat up to its nbt 4096 anchor and the fitted 5.25 was fitted AT
4096), util 0.98 -> 0.982, graph term 0.55 -> 0.32 GiB (256 base +
12/graph vs the fork's 256 + 52/graph). Measured: arena 17.52 GiB /
7,653 slots, budget 31.29.
- Shape G v1 (0a4ff30) tried util 1.0: **both workers refused init before a
single weight loaded** — `ValueError: Free memory on device cuda:N
(31.29/31.86 GiB ...)`, NRestarts=2 crash loop, zero payload progress.
The CLI field is `Field(gt=0, le=1)`, but the worker demands free >
budget, so budget == total is a trap, not a knife-edge. Frank: ".982 is
the law, never 1.00" — now with a second proof.
- Shape G v2 (c67a2ae, deployed green incl. e2e 33/33): util back to
0.982, graph base to 0 (the shape F boot reported a NEGATIVE -0.06 GiB
capture delta, so the 256 MiB base was fiction; 0 + 12 MiB/graph still
prices a future 18-graph spec-decode shape at 0.21 GiB), KV_BYTES
3.15e9 -> 3.1665e9 = Frank's 400,700-token ask.
- MEASURED live: arena **17.77 GiB / 7,762 slots** (+240 over shape E),
KV **401,408 tokens / 2.00x concurrency** — the first shape holding two
full-length conversations with real spare; capture measured 0.31/0.10
GiB against the 0.07 charge (under-charged, boots and holds); driver
used 30.97 of 31.86 GiB -> **0.89 GiB real free per card**, held through
a live 4,096-token generation (~38-39 tok/s single-request wall-incl.,
+13% vs the 33.99 shape-E baseline; single sample, not a bench).
- Verification seam rewritten to assert the shape by its OUTPUTS (util at
the 0.982 law, graph charge below the analytic default and per-graph
positive, KV pool >= 400,700 tokens at the measured 7,892 B/token).
Runbook: `serverAppConfigs-docs/LLM/dankserver-qwen38-fp8-expert-offload-tuning.md`.
## 2026-10-01
- **frank-primary rolled BACK to Qwen3.8-Flash-Next-Uncensored-FP8 + MTP4;
GLM/DFlash2 declared failed and purged.** The DFlash2 drafter booted and
@@ -971,9 +1064,10 @@ the 3090", then **"frank-small should have vision"**.
- **Three contexts cost window, and that is the tradeoff Frank was told:**
180,224 tokens per conversation, not the native 262,144. A longer prompt is
a hard `400 exceed_context_size_error`, so `models.yml` sets
`contextWindow: 180224`. Consequence: frank-primary's 183,501-token
compaction request no longer fits here with summary room; the two-slot
rollback is the way back.
`contextWindow: 180224`. Consequence at the time: frank-primary's then
183,501-token compaction request did not fit here with summary room —
resolved later the same day when frank-primary's window moved to 200,704
and its trigger fell to 140,492 (see the shape-D entry below).
- Measured (2026-10-02, this shape): decode **106.8 t/s**, prefill **3,814
t/s** (~5 % faster decode than 2 x 262,144 — shorter slot, shorter attention
scan), three concurrent requests answered in parallel in 1.7 s with a fourth
@@ -993,3 +1087,221 @@ the 3090", then **"frank-small should have vision"**.
payloads). Kept in place for rollback: `MiMo-Q6K/`, `MiMo-Q5KM/`,
`Qwen3.8-27B-UD-Q4_K_XL/` and `Qwen3.8-27B-UD-Q8_K_XL/` (the latter holds the
gufo DFlash2 drafter). Runbook [[frank-small-llama-mimo-q5km]].
## 2026-10-02 — frank-primary shape D: 3 x 200704, fp8, pool sized for 2 contexts
Frank's order: shrink frank-primary to a 200704-token window with about two
resident full-length contexts, cut the KV pool, and drop MNBT to 1024. The
first attempt (`30904ba`, 2 x 200704 + SEQS=2) **crash-looped**; the revert
went live as `e460bbb`, and the shape was then re-applied one law at a time on
top of the proven-good base, root cause first.
- **ROOT CAUSE: `--max-num-seqs 2` cannot compile this model. It was never OOM,
never MNBT, never the KV dtype.** Four single-variable boot probes against
the then-deployed shape (production stopped for each, candidate booted AS the
service):
| MAXLEN | SEQS | MNBT | result |
|---|---|---|---|
| 262144 | 2 | 1024 | **Engine core initialization failed** |
| 262144 | 2 | 2048 | **Engine core initialization failed** |
| 262144 | 3 | 1024 | healthy - 792,069 tokens, 3.02x, arena 6,248 |
| 262144 | 3 | 2048 | healthy - the then-deployed incumbent |
`torch.fx.experimental.symbolic_shapes.ConstraintViolationError: Constraints
violated (L['query_start_loc'].size()[0]) ... specialized it to be a constant
(3)`, raised from AOT `build_guards(strict_error=True)` during
`determine_available_memory -> profile_run -> _dummy_run`, ~7 min in.
`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 its width is
**SEQS + 1** for ANY `MNBT >= SEQS` - 3 at SEQS 2, 4 at SEQS 3, exactly the
constant in the message. MNBT only moves the compile RANGE ((1,1024) vs
(1,2048)), which is why the identical error appears at both rungs.
`--enforce-eager` bypasses the path but drops CUDA graphs. The 2026-10-01
bf16 2 x 262144 failure was this same error class; its message named "KV
dtype and SEQS" together and nobody bisected, so the dtype was blamed - it
was a red herring.
- **THE ARENA LEVER IS `max_model_len`, NOT `--kv-cache-memory-bytes`.** The
planner sizes expert cache as `budget - non-expert - KV reserve - graphs -
overhead`, and the KV reserve is registry-priced at **9190 B/token x
max_model_len**, independent of the pool override:
| MAXLEN | KV reserve | arena |
|---|---|---|
| 262144 | 2.48 GiB | 14.30 GiB / **6,248 slots** |
| 200704 | 1.91 GiB | 14.87 GiB / **6,496 slots** |
d(reserve) 0.57 GiB = d(arena) 0.57 GiB = +248 slots. The 6.1e9 -> 3.15e9
pool cut (2.75 GiB/rank) bought **zero** arena slots. The previous session's
premise - "the pool cut becomes arena, ~7,500 slots" - was wrong; the pool
cut is a runtime-HEADROOM lever (the shape it replaced died with `0 bytes
free` on a real 995-token request).
- **Final shape (option D):** `--max-model-len 200704` x 3 slots, fp8 KV,
`--kv-cache-memory-bytes 3,150,000,000`/rank, MNBT 1024, gather 32x64, no
speculative decode, prefetch off, no `--expert-precise-memory`. The argv
keeps the only slot count that compiles; the POOL caps resident
conversations. Deployed `c769ff2`, healthy in 558 s, e2e 33/33.
- **Measured live, and one prediction corrected.** `expert cache space 14.87
GiB / 6,496 slots` - the arena prediction landed exactly. But `GPU KV cache
size: 398,658 tokens, Maximum concurrency for 200,704 tokens per request:
1.99x` (3,146,129,408 B reserved), not the 2.04x the arithmetic predicted:
**the per-token KV price is shape-dependent** - 7,701 B/token at MAXLEN
262144 (6,100,000,000 B -> 792,069 tokens) vs **7,892** at 200704, ~2.5 %
dearer. Never extrapolate a concurrency figure across geometries.
- **Smoke-verified on the live endpoint:** three concurrent 67-token requests
answered in parallel (~4.1 s each) - the 3 slots are real; native `tool_calls`
(`get_weather {"city":"Seattle"}`); thinking in `reasoning` with clean
`content`; `reasoning_effort` accepts only {xhigh, medium, low} and answers
400 for anything else (re-measured; the fork's message names the set).
- **Harness contract followed the window:** `models.yml` `contextWindow`
262144 -> **200704** (compaction trigger 140,492 + 36,864 = 177,356 <=
200,704). Side effect: frank-primary's compaction request now fits inside a
frank-small slot again, which it had stopped doing when frank-small went to
3 x 180,224.
- **Comment-only follow-up** `295d578` recorded the measured numbers and was
deployed so the per-service revision marker sits at HEAD; e2e 33/33 again.
- Rollback for the pre-shape-D fp8+MTP4 era stays git tag
`frank-primary-fp8-mtp-20261001`. Runbook
[[dankserver-qwen38-fp8-expert-offload-tuning]].
## 2026-10-02 - plex: the readiness gate probed HTTP against a TLS-only service
Found by accident, and it had been latent since the secure-connections posture
was set: `deploy_plex` gated on `curl -fsS http://127.0.0.1:32400/identity`,
which **cannot** succeed. PMS drops plain HTTP off-loopback - the probe's
connection arrives on the container's eth0 - so Plex accepts the TCP connection
and then answers with nothing, and curl reports "Empty reply from server" (52).
- Trigger: a bare `./scripts/deploy.sh dankserver` (no `--service`) drains the
whole pending backlog, not just the service you were thinking about. plex's
per-service revision had been behind for long enough that this gate had never
run against the current posture. It burned its full 600 s and failed the run;
every service after plex in the order was left undeployed.
- **Plex was healthy the entire time.** `https://127.0.0.1:32400/identity`
returned 200 with the MediaContainer XML in 44 ms while the gate was still
timing out. The in-container `http://127.0.0.1:32400/identity` also answers
200 - loopback is the one path PMS serves in the clear, which is exactly why
the host-side probe could not see the difference.
- Evidence chain that settled it: `podman exec plex curl http://127.0.0.1:32400`
= 200; `curl http://127.0.0.1:32400` from the host = empty reply;
`/proc/net/tcp` inside the container showed `00000000:7E90` LISTEN plus stuck
ESTABLISHED pairs on `1703590A:7E90` (10.89.3.23, the container's own eth0
address) that never produced a response.
- **The repo already knew this law, in a different file:**
`tests/caddyfile.bats` pins the plex vhosts to a TLS upstream with the comment
"PMS drops plain HTTP off-loopback". The deployer's gate never got the same
treatment. Fixed to `curl -fsSk --max-time 3 https://127.0.0.1:32400/identity`
(`-k`: PMS serves its own self-signed cert on 32400).
- Re-deployed `39f0e0d`: plex recorded deployed, the *arr services recorded
(compose-recorded on this host, no restart attempted), tautulli deployed,
e2e 33/33. Verified after: `plex-https=200`, plex container up, tautulli
healthy, frank-primary untouched (`health=200`, NRestarts=0).
- **Operating lesson: `deploy.sh <target>` with no `--service` is a fleet-wide
drain.** It restarts every service whose recorded revision is behind HEAD and
whose paths changed since. To touch one service, name it.
## 2026-10-02 — frank-primary shape E: `--expert-precise-memory` (+1,026 arena slots), a mounted compile cache, and the P2P answer
Shape D left one question open — the plan's overhead term was reserving 2.35 GiB
of compile headroom the boot never used — and the answer to Frank's free-VRAM
question arrived together with a boot-cost bug that had been costing 181 s on
every restart since the cutover.
- **`--expert-precise-memory` is real and it fits (option A).** Boot-probed on
the shape-D base with production stopped, the candidate booted AS the service
(`FRANK_PRIMARY_ENV_FILE=/tmp/probe-b.env bash scripts/serve-frank-primary.sh`,
trap-guaranteed restore), the numbers read off the engine's own plan lines:
| plan line | shape D | shape E |
|---|---|---|
| `runtime overhead (non-torch + buffers + activation)` | 7.60 GiB | **5.25 GiB** (`precise (gfx1201 TP2 nbt4096/seqs16/len262144)`) |
| `expert cache space` | 14.87 GiB | **17.22 GiB** |
| slots | 6,496 | **7,522** (+1,026, +16 %) |
| `swap region` | — | 13.7 GiB / 5,986 slots, 133-134 per layer |
| boot | — | **healthy in 420 s**, no OOM |
The flag also prints `'expert_precise_memory': True` in `non-default args`, so
a silent argparse drop cannot hide. It was refused on 2026-10-01 only because
the 2 x 262144 bf16 shape was already 0.13 GiB under the ceiling — there the
flag was the last stack over the line, not the cause.
- **The pool was NOT cut (option A) because the pool is not an arena lever.**
A 2.2e9 arm was probed in the same window and reports `277,686 tokens,
1.38x` — measured, rejected as the shipped value, kept as the conservative
fallback. With 3.15e9/rank the endpoint keeps shape D's 1.99x concurrency and
gains the arena, which is the shape D follow-up had actually been looking for.
- **THE COMPILE CACHE WAS BEING THROWN AWAY ON EVERY RESTART — 181 s each.**
Measured: a fresh container has **no `/root/.cache` at all** (`No such file or
directory`); after our boot it holds `/root/.cache/vllm` at
**1,154,714,333 B in 4,665 files** plus `/root/.cache/comgr` at 229 MB, inside
the ephemeral layer, and `podman rm -f` (every deploy, restart, reboot)
discards them. The engine names the loss itself: `Compiling a graph for compile
range (1, 1024) takes 173.19 s` ... `torch.compile took 181.20 s in total` ...
`init engine (profile, create kv cache, warmup model) took 217.26 s
(compilation: 181.20 s)`. The serve script now mounts
`FRANK_PRIMARY_CACHE_DIR` at `/root/.cache` — the **parent**, so the vLLM
AOT/torch_compile tree (`VLLM_CACHE_ROOT`, default `/root/.cache/vllm`) and
the comgr JIT cache both persist, and nothing is hidden because the image has
no such directory. Same class of fix as the recipe's
`/cache/{triton,vllm,inductor}` volumes; the compile RANGE is part of the
cache key, so an MNBT change costs one recompilation, not a permanent tax.
- **`GPU_MAX_HW_QUEUES=2`** (HSA hardware queues per GPU) added at Frank's
direction. Verified a real knob rather than a no-op — the literal is read from
`/opt/rocm/core-10.0/lib/libhsa-runtime64.so` — and shipped deliberately
alongside the 32x64 gather geometry. **Not A/B measured.**
- **P2P: the hardware supports it, and the engine is already using it.** The
live log shows `r4d all-reduce INSTALLED (rank=0 max=49152KB finegrained=T…)`
on BOTH ranks and `Using ['R4D_AR', 'PYNCCL'] all-reduce backends` — R4D's
P2P path, with RCCL as fallback, measured at **45.274 GB/s bidirectional**.
Root ports `00:01.1`/`00:01.3` both report ACS `ReqRedir+`, so P2P hairpins at
the root complex; there is no XGMI. The old FAIL predates the 2026-09-20 board
swap. Kernel support confirmed: `CONFIG_HSA_AMD_P2P=y`, `CONFIG_PCI_P2PDMA=y`,
`CONFIG_HSA_AMD=y`, `CONFIG_ZONE_DEVICE=y`, `iommu=pt` on the cmdline.
- **Recipe diff (Frank's 3 x R9700 compose vs this podman run), ranked:**
- *Acted on:* persistent compile caches (above — the one real operational
gap), and `GPU_MAX_HW_QUEUES`.
- *Retracted:* pinned `--compilation-config cudagraph_capture_sizes` was on my
list; measured, it is **not** a gap here — capture is 3 PIECEWISE + 2 FULL
graphs, `Graph capturing finished in 3 secs, took 0.31 GiB` (0.11 GiB on the
lighter rank). No 15-52-size ladder to cut.
- *Not acted on — measured candidates, none shipped (no bench window; these
are CPU-side and our decode is gather-bound, so a spin-wait stealing cores
from the gather threads is plausible but unproven):* `OMP_NUM_THREADS=8`
(defaults to all 16 logical CPUs on this 7700X), `HSA_ENABLE_INTERRUPT=1`,
`HSA_ENABLE_MWAITX=1`, `HSA_ENABLE_SDMA`, `HSA_NO_SCRATCH_RECLAIM`,
`HSA_ENABLE_IPC_MODE_LEGACY`. All six HSA names verified real — the literals
are read out of `/opt/rocm/core-10.0/lib/libhsa-runtime64.so` — so they are
knobs, not no-ops; any of them needs its own A/B before shipping.
- *Deliberate divergence, keep ours:* no MTP (the recipe has it; measured
fastest here), `qwen3_xml` vs `qwen3_coder` (ours verified live), no
`--override-generation-config` (server-side sampling defaults), published
port + Caddy rather than `network_mode: host`.
- *Non-gaps:* `shm_size 32gb` (ours is `--ipc=host`), `memlock -1` (same),
`SYS_PTRACE` (same), `/dev/kfd` + `/dev/dri` (same), `--enable-chunked-prefill`
(V1 default), `plecache` volume (the flag is not even passed), and
`ROCR_VISIBLE_DEVICES=1,2,3` vs our `HIP_VISIBLE_DEVICES=0,1` — verified
equivalent here: `rocm-smi --showid` puts the two R9700s at GPU[0]/GPU[1] and
the 7700X iGPU at GPU[2], so our indices are the right two cards.
- **Harness note, because it cost two probe attempts:** a probe that reported an
instantaneous candidate death was a logging artifact of the inline SSH script,
not a crash — the container and its serve script must be polled by recorded id
and their logs dumped on the exit path, or a failure looks like a bad flag.
The flag was fine both times.
- Live: `health=200`, `NRestarts=0`, arena 17.22 GiB / 7,522 slots, KV 398,658
tokens / 1.99x. Docs: [[dankserver-qwen38-fp8-expert-offload-tuning]].
- **Deployed `1ac4d48`, e2e 33/33, and the live plan matched the probe exactly:**
`'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 size: 398,658 tokens … 1.99x`, pool 3,150,000,000, MNBT 1024. The
precise overhead and the arena growth are now production numbers, not a
probe's projection.
- **The compile-cache fix was then proven on the same endpoint, two consecutive
boots of the deployed 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) 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)`. The host
dir held 1.4 GB in 4,707 files after the cold boot. **Time to `/health` is
dominated by the 172.8 GiB payload load** (the warm boot still needed 490 s),
so the honest claim is ~184 s of inductor/AOT build recovered per restart —
not a proportional boot cut.
+11
View File
@@ -45,5 +45,16 @@ export default defineConfig({
testMatch: /(14|15)-.*\.spec\.ts/,
use: { storageState: { cookies: [], origins: [] } },
},
{
name: "frankbook",
// The *arr/qBittorrent walk (19-). Excluded from default runs and from
// the deploy e2e gate unless a frankbook trigger path changed — the
// links terminate on FrankBook-Pro, which sleeps (see tests/e2e/run.sh
// and scripts/deploy.sh). Matched by number prefix like every project
// here; joined 2026-10-05.
testMatch: /19-.*\.spec\.ts/,
use: { storageState: ".auth/qa-e2e-admin.json" },
dependencies: ["setup"],
},
],
});
+28 -2
View File
@@ -4,8 +4,14 @@
# part of ./tests/run.sh. Credentials live in tests/e2e/.env.local
# (git-ignored; see .env.local.example). Usage:
#
# tests/e2e/run.sh # full suite
# tests/e2e/run.sh # full suite (frankbook project excluded)
# tests/e2e/run.sh --project guest # any playwright args pass through
#
# The frankbook project (19-frankbook.spec.ts — the *arr/qBittorrent walk)
# is NOT in the default project set: its links terminate on FrankBook-Pro,
# which sleeps when idle, so it must not gate unrelated deploys. deploy.sh
# sets E2E_INCLUDE_FRANKBOOK=1 when a frankbook trigger path changed. An
# explicit --project argument in "$@" bypasses the default set entirely.
set -euo pipefail
cd "$(dirname "$0")"
@@ -24,4 +30,24 @@ fi
npx playwright install chromium
exec npx playwright test "$@"
# Default project set; the frankbook project joins only when deploy.sh saw a
# frankbook trigger path (E2E_INCLUDE_FRANKBOOK=1). Other playwright args
# (--list, --grep, file filters) pass through alongside the default set; an
# explicit --project in "$@" takes over untouched.
DEFAULT_PROJECTS=(setup standard admin guest)
if [[ "${E2E_INCLUDE_FRANKBOOK:-0}" == "1" ]]; then
DEFAULT_PROJECTS+=(frankbook)
fi
has_project=0
for arg in "$@"; do
case "$arg" in
--project|--project=*) has_project=1 ;;
esac
done
PROJECT_ARGS=()
if [[ "$has_project" -eq 0 ]]; then
for project in "${DEFAULT_PROJECTS[@]}"; do
PROJECT_ARGS+=(--project "$project")
done
fi
exec npx playwright test "$@" "${PROJECT_ARGS[@]}"
+22 -3
View File
@@ -24,10 +24,28 @@ test("admin panel renders admin-category services", async ({ page }) => {
test("admin open links reach their apps through SSO", async ({ page }) => {
test.setTimeout(240_000);
// Expected link count comes from the live registry (admin showLink=true
// entries, now including the *arr vhosts that use their own auth).
const registry: { name: string; category: string; showLink: boolean }[] =
await (await page.request.get("/services.json")).json();
// entries, now including the *arr vhosts that use their own auth). The
// frankbook-hosted links (probe on 192.168.0.112) are NOT walked here:
// they terminate on FrankBook-Pro, which sleeps when idle, so their walk
// lives in 19-frankbook.spec.ts (frankbook project — excluded from the
// default e2e run and from the deploy gate unless a frankbook trigger
// path changed; see tests/e2e/run.sh and scripts/deploy.sh).
const registry: {
name: string;
category: string;
showLink: boolean;
url: string;
probe: string;
}[] = await (await page.request.get("/services.json")).json();
const expectedAdminLinks = registry.filter((s) => s.category === "admin" && s.showLink).length;
const frankbookUrls = registry
.filter(
(s) =>
s.category === "admin" &&
s.showLink &&
((s.probe ?? "").startsWith("192.168.0.112") || s.url.includes("192.168.0.112")),
)
.map((s) => new URL(s.url).origin);
await page.goto("/admin/");
await expect(page.locator("#grid a.open")).toHaveCount(expectedAdminLinks);
const hrefs = await page.locator("#grid a.open").evaluateAll((els) =>
@@ -35,6 +53,7 @@ test("admin open links reach their apps through SSO", async ({ page }) => {
);
for (const href of hrefs) {
if (frankbookUrls.includes(new URL(href).origin)) continue; // 19-frankbook walks these
const target = new URL(href);
await page.goto(href);
// Every guarded vhost runs its own Anubis proof-of-work on first visit.
+69
View File
@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test";
// The *arr / qBittorrent admin links terminate on FrankBook-Pro
// (192.168.0.112), a Mac mini that SLEEPS when idle: Caddy answers 502 for
// the vhosts and the raw Sonarr link times out. This walk therefore lives in
// its own project, excluded from the default e2e run (tests/e2e/run.sh) and
// from the deploy gate's invocation (scripts/deploy.sh) unless a frankbook
// trigger path changed: this spec, frankbook-pro/**,
// shared/homepage/services.json, or the dankclaw Caddyfile that serves the
// arr vhosts. A sleeping Mac mini must never fail an unrelated deploy.
//
// Assertions mirror 11-admin's "admin open links reach their apps through
// SSO": the guard chain settles (Anubis PoW where present — Sonarr's raw-IP
// link is internal-only and has none), the hostname holds, no authentik form
// appears, and the app answers below 500.
const FRANKBOOK_IP = "192.168.0.112";
const CHALLENGE = "Making sure you're not a bot";
interface RegistryEntry {
name: string;
category: string;
showLink: boolean;
url: string;
probe: string;
}
test("frankbook admin open links reach their apps", async ({ page }) => {
test.setTimeout(240_000);
const registry: RegistryEntry[] = await (await page.request.get("/services.json")).json();
const frankbook = registry.filter(
(s) =>
s.category === "admin" &&
s.showLink &&
((s.probe ?? "").startsWith(FRANKBOOK_IP) || s.url.includes(FRANKBOOK_IP)),
);
const frankbookUrls = frankbook.map((s) => new URL(s.url).origin);
await page.goto("/admin/");
const hrefs = await page
.locator("#grid a.open")
.evaluateAll((els) => els.map((el) => (el as HTMLAnchorElement).href));
const frankbookHrefs = hrefs.filter((href) => frankbookUrls.includes(new URL(href).origin));
// The rendered frankbook link set must mirror the registry: a renamed arr
// vhost or a dropped probe fails here instead of silently dropping the
// walk's coverage.
expect(frankbookHrefs, "rendered frankbook links must match the registry").toHaveLength(
frankbook.length,
);
for (const href of frankbookHrefs) {
const target = new URL(href);
await page.goto(href);
// Guarded vhosts run their own Anubis proof-of-work on first visit; the
// raw-IP Sonarr link has none and settles immediately.
await expect
.poll(
() => page.locator("body").innerText().then((t) => !t.includes(CHALLENGE)),
{ timeout: 90_000 },
)
.toBe(true);
await expect
.poll(() => new URL(page.url()).hostname, { timeout: 60_000 })
.toBe(target.hostname);
expect(await page.locator('input[name="uidField"]').count()).toBe(0);
const resp = await page.request.get(page.url());
expect(resp.status(), `${target.hostname} must answer, not error`).toBeLessThan(500);
}
});
+2 -1
View File
@@ -10,7 +10,8 @@ load helpers
for f in run.sh package.json playwright.config.ts .env.local.example \
lib/env.ts lib/login.ts \
specs/00-auth.setup.ts specs/10-homepage.spec.ts specs/11-admin.spec.ts \
specs/12-crawl.spec.ts specs/13-sso.spec.ts specs/14-guard.spec.ts; do
specs/12-crawl.spec.ts specs/13-sso.spec.ts specs/14-guard.spec.ts \
specs/19-frankbook.spec.ts; do
[ -f "$REPO_ROOT/tests/e2e/$f" ] || {
echo "missing tests/e2e/$f"
return 1
+223 -230
View File
@@ -1,264 +1,247 @@
#!/usr/bin/env bats
# Seam: frank-large serves kingjones777's Qwen3.8-27B Q8_0_ROCMFPX_AGENT (a
# ftype-115 ROCmFPX file) with the repo's MTP draft head on the Strix Halo
# iGPU, via the ROCmFPX fork of llama.cpp — one session at the native
# 262,144 window.
# Seam: frank-large serves peonist-ai/halogen-qwen3.8-27b (a .hgn halogen
# checkpoint) on the Strix Halo iGPU through the halogen CONTAINER
# (ghcr.io/peonist-ai/halogen, digest-pinned), driven by the root unit's
# serve script — one conversation of 200,700 tokens with the DFlash2
# drafter ON. 2026-10-05 cutover from the ROCmFPX llama.cpp shape.
#
# This seam exists because the format is the constraint: GGML_TYPE_Q8_0_ROCMFPX
# (tensor type 103) is understood by the ROCmFPX fork and by nothing else on
# this host, so the runtime, the payload and the deployer's gate have to agree
# on paths that no other endpoint uses. On 2026-10-01 a serve-script gate and
# its deployer twin disagreed during a cutover and the endpoint crash-looped;
# both sides are pinned here.
# This seam exists because the artifacts are a triangle that must agree on
# exact strings the engine reads: the image DIGEST, the checkpoint file
# name AND byte count inside the /models mount, and the tokenizer's
# flat-file shape (a HF cache snapshot's symlinks dangle inside the
# container). A serve gate and a deployer gate that disagree is the
# 2026-10-01 crash-loop; both sides are pinned here, plus the one engine
# law the fleet must never "optimize" away: HALOGEN_KV_SLOTS=1 is what
# keeps speculative decoding on.
#
# Everything is tested from what an operator or a supervisor can observe — the
# command line, the exit code, the deployer's error text — never from anything
# inside llama.cpp.
# Everything is tested from what an operator or supervisor can observe —
# the command line podman would run, the exit code, the deployer's error
# text — never from anything inside the engine.
setup() {
load helpers
SERVE="$REPO_ROOT/scripts/serve-frank-large.sh"
UNIT="$REPO_ROOT/scripts/frank-large.service"
ENV_FILE="$REPO_ROOT/scripts/frank-large.env"
DEPLOYER="$REPO_ROOT/scripts/deploy-remote.sh"
BUILD="$REPO_ROOT/scripts/build-rocmfpx.sh"
RUNTIME_STUB="/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server"
MODEL_STUB="/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf"
DRAFT_STUB="/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/mtp-Qwen3.8-27B-Q4_0.gguf"
MMPROJ_STUB="/home/dank/models/kingjones-Qwen3.8-27B-ROCmFP4-STRIX-MTP/mmproj-Qwen3.8-27B-BF16.gguf"
ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
SERVE="$ROOT/scripts/serve-frank-large.sh"
ENV_FILE="$ROOT/scripts/frank-large.env"
UNIT="$ROOT/scripts/frank-large.service"
DEPLOYER="$ROOT/scripts/deploy-remote.sh"
BUILD="$ROOT/scripts/build-rocmfpx.sh"
TEST_TMP="$(mktemp -d)"
SRC="$TEST_TMP/src"
mkdir -p "$SRC"
git -C "$SRC" init -q
git -C "$SRC" -c user.email=t -c user.name=t commit -q --allow-empty -m x
touch "$TEST_TMP/empty.env"
}
teardown() {
rm -rf "$TEST_TMP"
}
# make_stub — an executable that prints its whole argv on one line, so
# "what command line would the supervisor run" is observable output.
make_stub() {
cat > "$TEST_TMP/bin" <<'STUB'
#!/bin/sh
printf '%s\n' "$*"
# podman_stub — a fake podman on PATH that prints the argv of `run` and
# `wait` and answers `images` with whatever digests the test names, so
# "what would the supervisor run" is observable output and the digest
# gate is testable without a 3.5 GB image.
podman_stub() {
mkdir -p "$TEST_TMP/bin"
cat > "$TEST_TMP/bin/podman" <<'STUB'
#!/usr/bin/env bash
case "$1" in
images) printf '%s\n' "${STUB_DIGESTS:-}" ;;
rm) exit 0 ;;
run|wait) echo "PODMAN $*"; exit "${STUB_WAIT_RC:-0}" ;;
*) exit 0 ;;
esac
STUB
chmod +x "$TEST_TMP/bin"
chmod +x "$TEST_TMP/bin/podman"
}
@test "the env file defines the deployment values" {
grep -qE '^LLAMA_BIN=/' "$ENV_FILE"
grep -qE '^MODEL_FILE=/' "$ENV_FILE"
grep -qE '^DRAFT_FILE=/' "$ENV_FILE"
grep -qE '^ALIAS=[a-z0-9-]+$' "$ENV_FILE"
grep -qE '^LISTEN_PORT=[0-9]+$' "$ENV_FILE"
grep -qE '^CTX_SIZE=[0-9]+$' "$ENV_FILE"
# good_env — the minimum env the serve script demands, with the digest
# stub agrees on and zero-byte artifacts passing the size gate.
good_env() {
: > "$TEST_TMP/empty.env"
touch "$TEST_TMP/qwen.hgn"
mkdir -p "$TEST_TMP/tokenizer" && touch "$TEST_TMP/tokenizer/tok.json"
GOOD_ENV=(
"FRANK_LARGE_ENV_FILE=$TEST_TMP/empty.env"
"FRANK_LARGE_IMAGE=ghcr.io/peonist-ai/halogen@sha256:cafe"
"FRANK_LARGE_CONTAINER=frank-large-halogen"
"FRANK_LARGE_LEGACY_CONTAINERS=frank-large"
"FRANK_LARGE_DEV_KFD=$TEST_TMP/qwen.hgn"
"FRANK_LARGE_DEV_DRI=$TEST_TMP"
"FRANK_LARGE_LOG=$TEST_TMP/serve.log"
"FRANK_LARGE_PUBLISH=7770:8731"
"FRANK_LARGE_MODEL_DIR=$TEST_TMP"
"FRANK_LARGE_CHECKPOINT=qwen.hgn"
"FRANK_LARGE_CHECKPOINT_BYTES=0"
"FRANK_LARGE_TOKENIZER_FILES=tok.json"
"HALOGEN_MODEL_ID=frank-large"
"HALOGEN_SLOT_CTX=200700"
"HALOGEN_KV_SLOTS=1"
"HALOGEN_DRAFTER=2"
"STUB_DIGESTS=localhost/x ghcr.io/peonist-ai/halogen@sha256:cafe"
)
}
@test "the serve script fails when the runtime is missing" {
# Only the model, the head and the projector exist; the fork was never
# built. llama.cpp is not reached, so this fails fast and says what to
# run instead of failing at bind time.
touch "$TEST_TMP/model.gguf" "$TEST_TMP/draft.gguf" "$TEST_TMP/mmproj.gguf"
run env "LLAMA_BIN=$TEST_TMP/nope/llama-server" \
"MODEL_FILE=$TEST_TMP/model.gguf" "DRAFT_FILE=$TEST_TMP/draft.gguf" \
"MMPROJ_FILE=$TEST_TMP/mmproj.gguf" "$SERVE"
@test "the env file defines the halogen deployment values" {
grep -qE '^FRANK_LARGE_IMAGE=ghcr\.io/peonist-ai/halogen@sha256:[0-9a-f]{64}$' "$ENV_FILE"
grep -qE '^FRANK_LARGE_CONTAINER=frank-large-halogen$' "$ENV_FILE"
grep -qE '^FRANK_LARGE_PUBLISH=7770:8731$' "$ENV_FILE"
grep -qE '^FRANK_LARGE_CHECKPOINT=.*\.hgn$' "$ENV_FILE"
grep -qE '^FRANK_LARGE_CHECKPOINT_BYTES=[0-9]+$' "$ENV_FILE"
grep -qE '^FRANK_LARGE_CHECKPOINT_SHA256=[0-9a-f]{64}$' "$ENV_FILE"
grep -qE '^FRANK_LARGE_TOKENIZER_FILES="tokenizer\.json( [a-z_.]+)+"$' "$ENV_FILE"
grep -qE '^HALOGEN_MODEL_ID=frank-large$' "$ENV_FILE"
grep -qE '^HALOGEN_SLOT_CTX=[0-9]+$' "$ENV_FILE"
}
@test "the drafter stays on: slots must stay 1" {
# halogen makes batching and speculation mutually exclusive — with
# more than one slot the drafter is DISABLED and per-stream decode
# falls to serial speed (the sibling engine measured 41 -> 14 t/s
# when slots went up). frank-large was a one-session endpoint before
# this swap too (PARALLEL=1), so slots=1 preserves the contract;
# raising it is a product decision, never a default change.
grep -qE '^HALOGEN_KV_SLOTS=1$' "$ENV_FILE"
grep -qE '^HALOGEN_DRAFTER=2$' "$ENV_FILE"
# The cap/timeout pair is coupled (FLAGS.md table): a request at the
# cap must not outlast the queue timeout, or everyone queued behind
# it eats a 503.
grep -qE '^HALOGEN_MAX_TOKENS_CAP=[0-9]+$' "$ENV_FILE"
grep -qE '^HALOGEN_QUEUE_TIMEOUT=[0-9]+$' "$ENV_FILE"
}
@test "the serve script refuses when there is no GPU to serve on" {
podman_stub
good_env
run env PATH="$TEST_TMP/bin:$PATH" "${GOOD_ENV[@]}" \
FRANK_LARGE_DEV_KFD="$TEST_TMP/absent-node" "$SERVE"
[ "$status" -ne 0 ]
[[ "$output" == *"build it with scripts/build-rocmfpx.sh"* ]]
[[ "$output" == *"no gfx1151 to serve on"* ]]
}
@test "the serve script refuses to start without the model file" {
make_stub
touch "$TEST_TMP/draft.gguf" "$TEST_TMP/mmproj.gguf"
run env "LLAMA_BIN=$TEST_TMP/bin" "MODEL_FILE=$TEST_TMP/absent.gguf" \
"DRAFT_FILE=$TEST_TMP/draft.gguf" "MMPROJ_FILE=$TEST_TMP/mmproj.gguf" "$SERVE"
[ "$status" -ne 0 ]
[[ "$output" == *"is not readable"* ]]
[[ "$output" == *"download-frank-large-kingjones.sh"* ]]
@test "the KV budget fits unified memory with margin" {
# Pool arithmetic is the engine's documented product: slots x
# slot_ctx x 64 KiB. Against it: 33.4 GiB of mapped weights, the
# engine working set + auto prompt cache (~25 GiB of headroom
# allowed), host baseline and frank-small co-tenancy. The box has
# 121 GiB unified; the 2026-09-16 halogen era ran the same engine
# class here on a LARGER pool with less co-tenancy slack.
ctx="$(sed -nE 's/^HALOGEN_SLOT_CTX=([0-9]+)$/\1/p' "$ENV_FILE")"
slots="$(sed -nE 's/^HALOGEN_KV_SLOTS=([0-9]+)$/\1/p' "$ENV_FILE")"
pool_gib=$(( slots * ctx * 65536 / 1073741824 ))
total_gib=$(( 34 + pool_gib + 25 + 15 ))
[ "$total_gib" -le 121 ]
}
@test "the serve script refuses to start without the MTP draft head" {
make_stub
touch "$TEST_TMP/model.gguf" "$TEST_TMP/mmproj.gguf"
run env "LLAMA_BIN=$TEST_TMP/bin" "MODEL_FILE=$TEST_TMP/model.gguf" \
"DRAFT_FILE=$TEST_TMP/absent.gguf" "MMPROJ_FILE=$TEST_TMP/mmproj.gguf" "$SERVE"
@test "the serve script refuses when the image is not in storage" {
podman_stub
good_env
run env PATH="$TEST_TMP/bin:$PATH" "${GOOD_ENV[@]}" STUB_DIGESTS="" "$SERVE"
[ "$status" -ne 0 ]
[[ "$output" == *"MTP draft head"* ]]
[[ "$output" == *"not in podman storage"* ]]
[[ "$output" == *"podman pull"* ]]
}
@test "the serve script refuses a missing mmproj unless vision is opted out" {
make_stub
touch "$TEST_TMP/model.gguf" "$TEST_TMP/draft.gguf"
run env "LLAMA_BIN=$TEST_TMP/bin" "MODEL_FILE=$TEST_TMP/model.gguf" \
"DRAFT_FILE=$TEST_TMP/draft.gguf" "MMPROJ_FILE=$TEST_TMP/absent.gguf" "$SERVE"
@test "the serve script refuses a missing checkpoint and names the fetch script" {
podman_stub
good_env
run env PATH="$TEST_TMP/bin:$PATH" "${GOOD_ENV[@]}" \
FRANK_LARGE_CHECKPOINT=absent.hgn "$SERVE"
[ "$status" -ne 0 ]
[[ "$output" == *"set MMPROJ_FILE='' for text-only"* ]]
# The documented opt-out must actually start, without a --mmproj argument.
run env "LLAMA_BIN=$TEST_TMP/bin" "MODEL_FILE=$TEST_TMP/model.gguf" \
"DRAFT_FILE=$TEST_TMP/draft.gguf" "MMPROJ_FILE=" "$SERVE"
[[ "$output" == *"checkpoint"*"missing"* ]]
[[ "$output" == *"download-frank-large-halogen.sh"* ]]
}
@test "the serve script refuses a checkpoint of the wrong byte count" {
# A truncated curl exit-0 artifact must not boot; the model card's
# byte count is the contract (fleet fetch law).
podman_stub
good_env
run env PATH="$TEST_TMP/bin:$PATH" "${GOOD_ENV[@]}" \
FRANK_LARGE_CHECKPOINT_BYTES=42 "$SERVE"
[ "$status" -ne 0 ]
[[ "$output" == *"expected 42"* ]]
}
@test "the serve script refuses a symlinked tokenizer file" {
# A HuggingFace cache snapshot's entries are symlinks into blobs/ and
# dangle inside the container mount — even when the directory itself
# is real.
podman_stub
good_env
rm "$TEST_TMP/tokenizer/tok.json"
ln -sf /nowhere/x "$TEST_TMP/tokenizer/tok.json"
run env PATH="$TEST_TMP/bin:$PATH" "${GOOD_ENV[@]}" "$SERVE"
[ "$status" -ne 0 ]
[[ "$output" == *"missing or a symlink"* ]]
}
@test "the render names the container, the port map and the engine shape" {
podman_stub
good_env
run env PATH="$TEST_TMP/bin:$PATH" "${GOOD_ENV[@]}" "$SERVE"
[ "$status" -eq 0 ]
[[ "$output" != *"--mmproj"* ]]
# `podman run` output is redirected to the log by design (the unit
# journal must not absorb engine chatter); the stub's printed argv
# lands there, so the render assertions read the same log.
output="$output
$(cat "$TEST_TMP/serve.log")"
[[ "$output" == *"--name frank-large-halogen"* ]]
[[ "$output" == *"--publish 7770:8731"* ]]
[[ "$output" == *"-e HALOGEN_MODEL_ID=frank-large"* ]]
[[ "$output" == *"-e HALOGEN_SLOT_CTX=200700"* ]]
[[ "$output" == *"-e HALOGEN_KV_SLOTS=1"* ]]
[[ "$output" == *"-e HALOGEN_DRAFTER=2"* ]]
[[ "$output" == *"-e HALOGEN_CHECKPOINT=/models/qwen.hgn"* ]]
[[ "$output" == *"--device $TEST_TMP/qwen.hgn"* ]]
[[ "$output" == *"$TEST_TMP/tokenizer:/tokenizer:ro"* ]]
[[ "$output" == *"ghcr.io/peonist-ai/halogen@sha256:cafe"* ]]
# The unit's restart policy is real because the script becomes
# `podman wait`: the container's exit code is the service's exit.
[[ "$output" == *"PODMAN wait frank-large-halogen"* ]]
}
@test "the serve script fails when LLAMA_BIN is empty" {
touch "$TEST_TMP/model.gguf" "$TEST_TMP/draft.gguf" "$TEST_TMP/mmproj.gguf"
run env LLAMA_BIN= "MODEL_FILE=$TEST_TMP/model.gguf" \
"DRAFT_FILE=$TEST_TMP/draft.gguf" "MMPROJ_FILE=$TEST_TMP/mmproj.gguf" "$SERVE"
[ "$status" -ne 0 ]
[[ "$output" == *"LLAMA_BIN is empty"* ]]
@test "the shipped env pins the fleet's window and shape" {
# 200700 is the per-conversation hard ceiling (a longer prompt is a
# 400 naming the limit, never a silent truncation), and the harness
# compaction math must fit prompt + budget under it: OMP compacts at
# 70% = 140490, and 140490 + 57344 = 197834 <= 200700.
grep -qE '^HALOGEN_SLOT_CTX=200700$' "$ENV_FILE"
ctx=200700
[ $(( ctx * 70 / 100 + 57344 )) -le "$ctx" ]
}
@test "the shipped env points at the ROCmFPX runtime and the AGENT payload" {
grep -qE "^LLAMA_BIN=$RUNTIME_STUB$" "$ENV_FILE"
grep -qE "^MODEL_FILE=$MODEL_STUB$" "$ENV_FILE"
grep -qE "^DRAFT_FILE=$DRAFT_STUB$" "$ENV_FILE"
grep -qE "^MMPROJ_FILE=$MMPROJ_STUB$" "$ENV_FILE"
# HIP reads bare HOST/TEMP/GPU_MAX_HEAP_SIZE out of the environment that
# `set -a` creates; a tunable named HOST leaks into the runtime.
@test "no HIP-sensitive bare names leak through the env file" {
# HIP reads bare env vars out of a `set -a` source; a tunable named
# HOST or TEMP used to send the JIT cache to path 1.0 and segfault
# every load (kept from the llama.cpp era — the container gets only
# the explicit -e list, but the host env still reaches podman itself).
! grep -qE '^(HOST|TEMP|GPU_MAX_HEAP_SIZE)=' "$ENV_FILE"
}
@test "the argv contract keeps the fleet's port and alias" {
make_stub
touch "$TEST_TMP/model.gguf" "$TEST_TMP/draft.gguf" "$TEST_TMP/mmproj.gguf"
run env "LLAMA_BIN=$TEST_TMP/bin" "MODEL_FILE=$TEST_TMP/model.gguf" \
"DRAFT_FILE=$TEST_TMP/draft.gguf" "MMPROJ_FILE=$TEST_TMP/mmproj.gguf" \
ALIAS=frank-large BIND_ADDR=127.0.0.1 LISTEN_PORT=7770 "$SERVE"
[ "$status" -eq 0 ]
# The caller's values must survive the env file, or a single-run operator
# override silently does nothing.
[[ "$output" == *"--host 127.0.0.1"* ]]
[[ "$output" == *"--port 7770"* ]]
[[ "$output" == *"--alias frank-large"* ]]
# Vision is wired: the projector is the whole vision tower.
[[ "$output" == *"--mmproj $TEST_TMP/mmproj.gguf"* ]]
@test "the deployer gates exactly the artifacts the serve script opens" {
# Serve and deploy must name the SAME image digest, checkpoint and
# tokenizer: the 2026-10-01 crash-loop was exactly these twins
# disagreeing mid-cutover.
img="$(sed -nE 's/^FRANK_LARGE_IMAGE=(.*)$/\1/p' "$ENV_FILE")"
grep -qF "LLM_LARGE_IMAGE=\"\${LLM_LARGE_IMAGE:-$img}\"" "$DEPLOYER"
dir="$(sed -nE 's/^FRANK_LARGE_MODEL_DIR=(.*)$/\1/p' "$ENV_FILE")"
ckpt="$(sed -nE 's/^FRANK_LARGE_CHECKPOINT=(.*)$/\1/p' "$ENV_FILE")"
tokf="$(sed -nE 's/^FRANK_LARGE_TOKENIZER_FILES="(.*)"$/\1/p' "$ENV_FILE")"
grep -qF "LLM_LARGE_TOK_DIR=\"\${LLM_LARGE_TOK_DIR:-$dir/tokenizer}\"" "$DEPLOYER"
grep -qF "LLM_LARGE_TOK_FILES=\"\${LLM_LARGE_TOK_FILES:-$tokf}\"" "$DEPLOYER"
bytes="$(sed -nE 's/^FRANK_LARGE_CHECKPOINT_BYTES=([0-9]+)$/\1/p' "$ENV_FILE")"
grep -qF "LLM_LARGE_CKPT_BYTES=\"\${LLM_LARGE_CKPT_BYTES:-$bytes}\"" "$DEPLOYER"
}
@test "the shipped argv is one full native window on one session" {
grep -qE '^CTX_SIZE=262144$' "$ENV_FILE"
grep -qE '^PARALLEL=1$' "$ENV_FILE"
# 262144 IS the model's trained window (general.context_length in the
# GGUF), so 1 slot gets the whole thing — no truncation, no yarn scaling.
#
# Budget, from the GGUF header: 26.28 GiB of weights + 1.57 GiB draft head
# + 0.87 GiB projector + 16 full-attention layers x 4 kv heads x 256 dim x
# 2 (K+V) x 2 B = 64 KiB/token of f16 KV (~16 GiB at 262,144) + ~0.14 GiB
# of DeltaNet state + a couple GiB of compute buffers, against the iGPU's
# ~114 GiB GTT aperture. Change CTX_SIZE and this arithmetic, not a boot
# log, is what tells you it no longer fits.
ctx="$(sed -nE 's/^CTX_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
kv_gib=$((ctx * 65536 / 1073741824))
total_gib=$((27 + 2 + 1 + kv_gib + 3))
[ "$total_gib" -le 114 ]
@test "the deployer points a missing artifact at the halogen fetch script" {
grep -qE 'download-frank-large-halogen\.sh' "$DEPLOYER"
grep -qE 'podman pull' "$DEPLOYER"
}
@test "the MTP draft contract matches the model card's measured optimum" {
make_stub
touch "$TEST_TMP/model.gguf" "$TEST_TMP/draft.gguf" "$TEST_TMP/mmproj.gguf"
run env "LLAMA_BIN=$TEST_TMP/bin" "MODEL_FILE=$TEST_TMP/model.gguf" \
"DRAFT_FILE=$TEST_TMP/draft.gguf" "MMPROJ_FILE=$TEST_TMP/mmproj.gguf" "$SERVE"
[ "$status" -eq 0 ]
# The head is the model's own nextn layer, loaded as a separate draft
# model because this checkpoint ships it standalone (no blk.<n>.nextn.*
# tensors in the target file).
[[ "$output" == *"--spec-type draft-mtp"* ]]
[[ "$output" == *"--model-draft $TEST_TMP/draft.gguf"* ]]
# n-max 4 is the measured optimum; n-max 8 measured WORSE (acceptance
# fell to 0.757 and the extra verification cost outran the savings), so
# the flag is a measurement, not a wish.
[[ "$output" == *"--spec-draft-n-max 4"* ]]
[[ "$output" == *"--spec-draft-n-min 0"* ]]
[[ "$output" == *"--spec-draft-p-min 0.0"* ]]
# The draft runs on the iGPU beside the target: no device-to-device hop
# on a link that measures single-digit GB/s.
[[ "$output" == *"--spec-draft-device ROCm0"* ]]
[[ "$output" == *"--spec-draft-ngl 99"* ]]
}
@test "KV and placement stay unquantized with flash attention on" {
make_stub
touch "$TEST_TMP/model.gguf" "$TEST_TMP/draft.gguf" "$TEST_TMP/mmproj.gguf"
run env "LLAMA_BIN=$TEST_TMP/bin" "MODEL_FILE=$TEST_TMP/model.gguf" \
"DRAFT_FILE=$TEST_TMP/draft.gguf" "MMPROJ_FILE=$TEST_TMP/mmproj.gguf" "$SERVE"
[ "$status" -eq 0 ]
[[ "$output" == *"--cache-type-k f16"* ]]
[[ "$output" == *"--cache-type-v f16"* ]]
# ROCmFPx quantized KV has no kernel on this branch, and the endpoint has
# the aperture to keep KV unquantized anyway.
[[ "$output" == *"--flash-attn on"* ]]
# --fit's placement heuristics do not know that 311 tensors of type 103
# belong on the iGPU, so the whole stack is placed explicitly instead.
[[ "$output" == *"--fit off"* ]]
[[ "$output" == *"--n-gpu-layers 999"* ]]
}
@test "the client contract wires thinking and template kwargs" {
make_stub
touch "$TEST_TMP/model.gguf" "$TEST_TMP/draft.gguf" "$TEST_TMP/mmproj.gguf"
run env "LLAMA_BIN=$TEST_TMP/bin" "MODEL_FILE=$TEST_TMP/model.gguf" \
"DRAFT_FILE=$TEST_TMP/draft.gguf" "MMPROJ_FILE=$TEST_TMP/mmproj.gguf" "$SERVE"
[ "$status" -eq 0 ]
# Without --jinja, llama.cpp silently ignores the harness's
# chat_template_kwargs, so thinking control disappears from the API.
[[ "$output" == *"--jinja"* ]]
# The harness reads `reasoning_content` for this endpoint, which is what
# the deepseek emission format produces (vLLM's `reasoning` is not it).
[[ "$output" == *"--reasoning-format deepseek"* ]]
# The template needs an unbounded thinking budget.
[[ "$output" == *"--reasoning-budget -1"* ]]
}
@test "the deployer gates exactly the paths the serve script opens" {
# A gate that checks a different file than the engine opens is the
# 2026-10-01 crash-loop: deploy passed, the unit restart-looped. Read the
# engine's own paths out of the env file and require the deployer's
# defaults to name the same ones.
for pair in "LLAMA_BIN:LLM_LARGE_RUNTIME" \
"MODEL_FILE:LLM_LARGE_MODEL" \
"DRAFT_FILE:LLM_LARGE_DRAFT" \
"MMPROJ_FILE:LLM_LARGE_MMPROJ"; do
env_key="${pair%%:*}"
dep_key="${pair##*:}"
want="$(sed -nE "s|^${env_key}=(.*)\$|\1|p" "$ENV_FILE")"
[ -n "$want" ]
got="$(grep -oE "${dep_key}:-[^}]+" "$DEPLOYER" | head -1 | sed "s|^${dep_key}:-||")"
[ "$got" = "$want" ]
done
}
@test "the deployer points a missing runtime at the fork build script" {
grep -qE 'build it: bash scripts/build-rocmfpx\.sh' "$DEPLOYER"
grep -qE 'fetch it: bash scripts/download-frank-large-kingjones\.sh' "$DEPLOYER"
}
@test "the stray-process sweep cannot take frank-small down with frank-large" {
# frank-small serves a different GGUF over llama-server on the SAME host.
# A sweep keyed on the binary name (`pkill -f llama-server`) would kill
# the 3090 endpoint too, so it is keyed on frank-large's own payload file.
grep -q "pkill -f 'Qwen3.8-27B-Q8_0_ROCMFPX_AGENT'" "$DEPLOYER"
@test "the stray sweep cannot take frank-small or the deployer down" {
# frank-small also listens on this host (7771, llama-server) and the
# deployer's own command line contains "frank-large"; only the FULL
# container name matches a stray manual halogen run and nothing else.
grep -q "pkill -f 'frank-large-halogen'" "$DEPLOYER"
! grep -qE "pkill -f 'llama-server'" "$DEPLOYER"
}
@test "the build script refuses a source tree that is not the pinned commit" {
# The pin is load-bearing: the older builds of this fork line segfault in
# ggml_compute_forward_set_rows on this exact payload. A runtime that
# differs from what was qualified must be heard about at deploy time.
wrong="$(git -C "$SRC" rev-parse HEAD)"
run env ROCMFPX_SRC_DIR="$SRC" ROCMFPX_COMMIT="ffff$wrong" \
"$BUILD" --verify
[ "$status" -ne 0 ]
[[ "$output" == *"ROCMFPX_COMMIT"*mismatch* ]]
}
@test "the build script pins a real commit and the gfx1151 target" {
grep -qE 'ROCMFPX_COMMIT=.*[0-9a-f]{40}' "$BUILD"
grep -qE 'GPU_TARGET=.*gfx1151' "$BUILD"
grep -qE 'DGPU_TARGETS=' "$BUILD"
# The webui asset fetch half-failed on this box and aborted the link at
# 98%; the build must not depend on it.
grep -qE 'DLLAMA_BUILD_WEBUI=OFF' "$BUILD"
! grep -qE "pkill -f 'frank-large'" "$DEPLOYER"
}
@test "the deployed unit keeps the fleet's restart and boot-persistence shape" {
@@ -266,15 +249,25 @@ STUB
grep -qE '^WantedBy=' "$UNIT"
grep -qE '^ExecStart=.*/serve-frank-large\.sh$' "$UNIT"
grep -qE '^User=dank$' "$UNIT"
# The payload is 26 GiB of mmap'd weights plus pinned staging buffers; an
# RLIMIT smaller than the pinned set fails the load in a way that looks
# like an engine crash.
grep -qE '^LimitMEMLOCK=infinity$' "$UNIT"
# The service's lifetime is the container's; stopping the service
# must stop the container by its real name.
grep -qE '^ExecStop=.*podman stop .* frank-large-halogen$' "$UNIT"
}
@test "the unit points at no retired frank-large runtime path" {
# The host keeps every retired payload and build on disk, so a stale path
# in a unit still resolves — to a runtime nobody has measured in weeks.
@test "the unit runs the halogen runtime and no retired path" {
grep -q 'halogen' "$UNIT"
# Every retired payload/build stays on the host, so a stale path in
# a unit still resolves — to a runtime nobody has measured in weeks.
! grep -qE 'GitPackages/(ROCmFPX-kairic|llama.cpp-ik_llama|llama-builds|ciru-orca)' "$UNIT"
! grep -q 'halogen' "$UNIT"
! grep -qE 'kingjones-ROCmFPX' "$UNIT"
}
@test "the rollback door stays qualified: the ROCmFPX build pin survives" {
# Rolling back this swap means rebuilding the fork the old payload
# needs; the pin (real commit, gfx1151, no webui fetch) is what makes
# that door trustworthy months later.
grep -qE 'ROCMFPX_COMMIT=.*[0-9a-f]{40}' "$BUILD"
grep -qE 'GPU_TARGET=.*gfx1151' "$BUILD"
grep -qE 'DLLAMA_BUILD_WEBUI=OFF' "$BUILD"
}
+172 -74
View File
@@ -2,11 +2,10 @@
# Seam: frank-primary — the Dankserver LLM endpoint on :7770.
#
# This file asserts the tcclaviger RDNA4 vLLM ROOT-UNIT shape (the
# 2026-10-02 correction of the 2026-10-01 BF16 cutover): the serve
# 2026-10-01 BF16 cutover, the overnight redo campaign's verdict): the serve
# script drives `podman run` DIRECTLY (the image entrypoint execs
# `vllm serve`), with every engine flag qualified by boot probe and carried
# in the repo env file.
# The seam pins what the flags alone do not say: the
# in the repo env file. The seam pins what the flags alone do not say: the
# KV qualification law (the night ladder: 262144/245760 OOM at graph
# capture; 241664 boots at 732,359 tokens / 3.03x, util 0.98;
# the bf16-KV trade (double the attention-KV bytes/token, ~14% decode cost,
@@ -19,7 +18,8 @@
# laws: expert prefetch ABSENT (36% precision), NO speculative decode (the
# no-spec family measured fastest once the prefetch tax was removed; MTP
# re-measures pending), gather geometry 32x64 PRESENT (+18.7% decode),
# MNBT 1024 (Frank's lock), and the client contract (reasoning parser qwen3,
# MNBT 2048, the CUDA-graph plan term pinned to the measured capture cost, and
# the client contract (reasoning parser qwen3,
# tool parser qwen3_xml, preserve_thinking on).
load helpers
@@ -91,55 +91,70 @@ DOWNLOAD="$REPO_ROOT/scripts/download-frank-primary.sh"
}
@test "the qualified engine shape is in the env file" {
# 2026-10-01 night ladder (Frank's method), then a runtime-survivability
# correction, then Frank's 2026-10-02 decision to give the pool back to
# the expert arena. SEQS=3 bf16 topped out at 241664 (KV 732,359 / 3.03x);
# SEQS=6 fp8 does NOT fit (6 x 241664 asked 10.70 GiB against 9.18 GiB
# free); SEQS=5 fp8 x 262144 BOOTED (KV 1,350,182 / 5.15x) but left ~0
# bytes free at runtime and a real client request (995-token prompt with
# max_tokens=261149) killed the worker with `Tried to allocate 50.00 MiB
# ... 0 bytes free`. 200704 sits below the 241664 rung while leaving a
# full 2.95 GiB/rank to cut loose for the arena. UTIL stays 0.98
# (lowering it shrinks the engine's own budget instead of buying slack).
# FINAL (2026-10-02, Frank): TWO windows at 200704 with FP8 KV and a pool
# cut for the arena. fp8 costs 7,701 B/token (measured 6,138 attention +
# 1,563 mamba/side), so 2 x 200704 = 401,408 tokens needs 3.091e9 B;
# pinned at 3.15e9 (~405k tokens, ~44 MB margin). The arena is what
# remains — the planner sizes it AFTER the KV override — so the 6,248
# slots of the 6.1e9 shape become roughly 7,500 here.
# 2026-10-02 shape D. History: the 2026-10-01 night ladder put SEQS=3
# bf16 at 241664 (KV 732,359 / 3.03x); SEQS=6 fp8 does NOT fit (6 x
# 241664 asked 10.70 GiB against 9.18 GiB free); SEQS=5 fp8 x 262144
# BOOTED (1,350,182 / 5.15x) but left ~0 bytes free and a real 995-token
# request killed the worker with `Tried to allocate 50.00 MiB ... 0 bytes
# free`. UTIL is a REAL arena lever (the fork plans budget = total x util)
# but a weak one, and Frank caps it at 0.982 — never 1.00 (see
# FRANK_PRIMARY_UTIL for the two ceiling deaths that justify the margin).
# 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 ConstraintViolationError
# (query_start_loc.size()[0] marked dynamic but specialized to a constant
# (3)); SEQS 3 passes at both. model_runner sizes that buffer
# max_num_reqs + 1, so the width is SEQS + 1 and MNBT only moves the
# compile RANGE.
# So the shape is 3 slots with a pool that admits only ~2 full-length
# conversations: measured live, KV_BYTES 3.15e9/rank reports 398,658
# tokens = 1.99x for a 200704-token request (7,892 B/token at THIS shape;
# the per-token price is shape-dependent, 7,701 at 262144). Shorter
# requests use the spare blocks; a third LONG request queues.
# The arena is sized by max_model_len, NOT by KV_BYTES: the planner
# reserves registry 9190 B/token x max_model_len (2.48 GiB at 262144 ->
# arena 6,248 slots; 1.91 GiB at 200704 -> 6,496). The pool cut buys
# runtime headroom, not arena.
grep -q '^FRANK_PRIMARY_MAXLEN=200704$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_SEQS=2$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_UTIL=0.98$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_KV_BYTES=3150000000$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_MNBT=1024$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_SEQS=3$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_UTIL=0.982$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_KV_BYTES=3166500000$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_MNBT=2048$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_KV_DTYPE=fp8$' "$ENV_FILE"
# --expert-precise-memory is NOT set: with bf16 2 x 262144 it put the
# boot 0.13 GiB from the 31.86 GiB ceiling (arena 16.65 device-resident)
# and crash-looped at graph capture. The serve script keeps the opt-in
# flag; the smaller pool here is the arena it would have been paying for.
! grep -q '^FRANK_PRIMARY_PRECISE_MEMORY=' "$ENV_FILE"
# --expert-precise-memory IS set (shape E, 2026-10-02). It was refused
# while the 2 x 262144 bf16 boot sat 0.13 GiB under the 31.86 GiB ceiling
# (arena 16.65 device-resident, NRestarts=3 at ~6.7 min each) — there it was
# the last 2.35 GiB over the line, not the cause. Probed on the shape-D base
# with production stopped: boots healthy in 420 s, plan reports runtime
# overhead 5.25 GiB (not 7.60), expert cache 17.22 GiB / 7,522 slots
# (+1,026 / +16% over 6,496). The pool is NOT cut with it (option A): the
# pool is not an arena lever, so 3.15e9 keeps the measured 1.99x.
grep -q '^FRANK_PRIMARY_PRECISE_MEMORY=1$' "$ENV_FILE"
# GPU_MAX_HW_QUEUES caps HSA hw queues per GPU; real knob (the literal is in
# libhsa-runtime64.so), passed inside the container as a bare name — the env
# file keeps only the prefixed form.
grep -q '^FRANK_PRIMARY_GPU_MAX_HW_QUEUES=2$' "$ENV_FILE"
# The compile cache is a BOOT-COST fix: the image ships no /root/.cache at
# all, our boot writes 1.1 GB of vLLM AOT artifacts (4,665 files) plus
# 229 MB of comgr JIT kernels into the ephemeral layer, and every
# `podman rm -f` throws them away — re-paying `torch.compile took 181.20 s`
# (173.19 s of it one graph) on every deploy, restart and reboot.
grep -q '^FRANK_PRIMARY_CACHE_DIR=/home/dank/.cache/frank-primary$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_GATHER_CHUNKS=32$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_GATHER_LANES=64$' "$ENV_FILE"
! grep -q '^FRANK_PRIMARY_SPEC=' "$ENV_FILE"
# Prefix caching is ON (reverted 2026-10-05 the same day it was
# disabled): the fork's QFN align-mode GDN state fault (48 gdn_hip
# state faults, slot 42 heads 3/8, at the 13th 3136-token boundary =
# 40,768 — the "~40k corruption" report) was mitigated with
# --no-enable-prefix-caching, but live long conversations STILL
# degraded at ~40k with caching off and zero faults logged — cost
# with no fix, so the historical flag returned. The knob stays
# commented in the env file for a future fixed fork build; flip it to
# 1 only together with a re-verification past 40,768 tokens.
grep -qE '^# FRANK_PRIMARY_NO_PREFIX_CACHING=1$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_MODEL_SHARDS=131$' "$ENV_FILE"
}
@test "the pool is sized as the arena floor, not the context floor" {
# THE LAW this seam pins: the fork's expert planner sizes the arena
# AFTER the KV override, so every byte cut out of KV_BYTES becomes
# arena. A context-size change must move the pool with it, or the
# reduction buys nothing. 2 x 200704 = 401,408 tokens at fp8's 7,701
# B/token is 3.091e9 B; the pin is 3.15e9, ~44 MB of margin above the
# floor — the same convention the 6.1e9 shape used.
floor=$(awk -v n="401408" -v b="7701" 'BEGIN{printf "%.0f", n*b}')
[ "$floor" = "3091243008" ]
kv=$(grep '^FRANK_PRIMARY_KV_BYTES=' "$ENV_FILE" | cut -d= -f2)
[ "$kv" = "3150000000" ]
margin=$(( kv - floor ))
[ "$margin" -gt 40000000 ]
[ "$margin" -lt 100000000 ]
}
@test "the serve script wires the engine flags from the env" {
grep -q -- '--max-num-seqs "$FRANK_PRIMARY_SEQS"' "$SERVE"
grep -q -- '--max-model-len "$FRANK_PRIMARY_MAXLEN"' "$SERVE"
@@ -148,9 +163,37 @@ DOWNLOAD="$REPO_ROOT/scripts/download-frank-primary.sh"
grep -q -- '--max-num-batched-tokens "$FRANK_PRIMARY_MNBT"' "$SERVE"
grep -q -- '-e R4D_LRU_GATHER_CHUNKS="$FRANK_PRIMARY_GATHER_CHUNKS"' "$SERVE"
grep -q -- '-e R4D_LRU_GATHER_LANES="$FRANK_PRIMARY_GATHER_LANES"' "$SERVE"
! grep -q -- '--speculative-config' "$SERVE"
# Speculative decode, the cudagraph capture ladder and the sampling
# defaults are all ENV-DRIVEN knobs (unset -> the flag is absent, which is
# the shipped shape today). JSON values carry no shell-special characters
# that this quoting cannot carry.
grep -q -- '${FRANK_PRIMARY_SPEC:+--speculative-config "$FRANK_PRIMARY_SPEC"}' "$SERVE"
grep -q -- '${FRANK_PRIMARY_COMPCONFIG:+--compilation-config "$FRANK_PRIMARY_COMPCONFIG"}' "$SERVE"
grep -q -- '${FRANK_PRIMARY_GEN_CONFIG:+--override-generation-config "$FRANK_PRIMARY_GEN_CONFIG"}' "$SERVE"
grep -q -- '--tensor-parallel-size 2' "$SERVE"
grep -q -- '--enable-prefix-caching' "$SERVE"
# Prefix caching renders exactly ONE flag from the knob, branched
# explicitly in the serve script (the ${VAR:--flag} double-expansion is
# ambiguous bash — when set it parsed as a substring offset, measured
# 2026-10-05): set -> --no-enable-prefix-caching (the fork-named
# mitigation, tried and REVERTED the same day — live corruption
# persisted at ~40k with zero faults), unset -> the shipped
# --enable-prefix-caching. Both directions are spelled out so a fork
# default change cannot silently flip the contract.
grep -qF 'FRANK_PRIMARY_PREFIX_CACHE_FLAG=--no-enable-prefix-caching' "$SERVE"
grep -qF 'FRANK_PRIMARY_PREFIX_CACHE_FLAG=--enable-prefix-caching' "$SERVE"
grep -qF '"$FRANK_PRIMARY_PREFIX_CACHE_FLAG"' "$SERVE"
# The observable contract: the exact flag word the engine receives,
# rendered from the shipped env through the serve script's branch (the
# same render-assert pattern the spec knobs use above).
run bash -c '
set -a; . "$1" >/dev/null; set +a
if [ -n "${FRANK_PRIMARY_NO_PREFIX_CACHING:-}" ]; then
FRANK_PRIMARY_PREFIX_CACHE_FLAG=--no-enable-prefix-caching
else
FRANK_PRIMARY_PREFIX_CACHE_FLAG=--enable-prefix-caching
fi
printf %s "$FRANK_PRIMARY_PREFIX_CACHE_FLAG"' _ "$ENV_FILE"
[ "$output" = "--enable-prefix-caching" ]
grep -q -- '--trust-remote-code' "$SERVE"
# DCP is opt-in: the flag is passed only when FRANK_PRIMARY_DCP is set
# (DCP reuses the TP ranks when PCP is off, so TP=2 + DCP=2 fits two GPUs).
@@ -161,6 +204,30 @@ DOWNLOAD="$REPO_ROOT/scripts/download-frank-primary.sh"
# moves the plan's `room` term and therefore the expert arena: ~1,025
# more slots at 2.344 MiB each.
grep -q -- '${FRANK_PRIMARY_PRECISE_MEMORY:+--expert-precise-memory}' "$SERVE"
# Shape E host/CPU wiring: the cache dir is created before it is mounted
# (a cold boot must have a target) and the mount lands on /root/.cache,
# whose vllm/ child is the path VLLM_CACHE_ROOT defaults to — the engine log
# names it as `Using cache directory: /root/.cache/vllm/torch_compile_cache/…`.
grep -q -- 'mkdir -p "$CACHE_DIR"' "$SERVE"
grep -q -- '${CACHE_DIR:+-v "$CACHE_DIR":/root/.cache}' "$SERVE"
grep -q -- '-e GPU_MAX_HW_QUEUES="$FRANK_PRIMARY_GPU_MAX_HW_QUEUES"' "$SERVE"
# Shape F/G: the CUDA-graph plan term is carried as CONTAINER env (the fork
# reads it at call time inside estimate_cudagraph_memory), and both must be
# present — the graph term and UTIL are the ONLY plan terms this repo can
# move, and shrinking them is what grows the arena. The base must be shipped
# explicitly: unset, the estimator would fall back to its 256 MiB default.
grep -q -- '-e VLLM_CUDAGRAPH_MEM_BASE_MB="$FRANK_PRIMARY_CUDAGRAPH_BASE_MB"' "$SERVE"
grep -q -- '-e VLLM_CUDAGRAPH_MEM_PER_GRAPH_MB="$FRANK_PRIMARY_CUDAGRAPH_PER_GRAPH_MB"' "$SERVE"
grep -q '^FRANK_PRIMARY_CUDAGRAPH_BASE_MB=0$' "$ENV_FILE"
grep -q '^FRANK_PRIMARY_CUDAGRAPH_PER_GRAPH_MB=12$' "$ENV_FILE"
# The host region is the ONE plan term that autofits per-boot: with no cap
# the bound is MemAvailable at that moment (boot 2026-10-05: 237.97 GiB ->
# 159.39 GiB usable, while the running host later reads 75.4 GiB available).
# An explicit --expert-offload-mem cap makes the plan deterministic whatever
# the co-tenant RAM state is at boot; 120 GiB covers the measured 105.48
# GiB of swappable experts (52.74/rank x 2) with 14% headroom.
grep -q -- '${FRANK_PRIMARY_EXPERT_OFFLOAD_MEM:+--expert-offload-mem "$FRANK_PRIMARY_EXPERT_OFFLOAD_MEM"}' "$SERVE"
grep -q '^FRANK_PRIMARY_EXPERT_OFFLOAD_MEM=120$' "$ENV_FILE"
}
@test "tools and reasoning are wired — the client contract is served" {
@@ -188,30 +255,41 @@ DOWNLOAD="$REPO_ROOT/scripts/download-frank-primary.sh"
# --enable-expert-offload flag in this arm; the legality of the pin is
# the unit's unbounded LimitMEMLOCK.
! grep -q -- '--enable-expert-offload' "$SERVE"
! grep -q -- '--expert-precise-memory' "$SERVE"
grep -q '^LimitMEMLOCK=infinity$' "$UNIT"
grep -q -- '--ipc=host --ulimit memlock=-1:-1' "$SERVE"
}
@test "speculative decode is ABSENT BY MEASUREMENT" {
# The 2026-10-01 overnight redo (prefetch OFF) re-measured the whole
# spec family against no-spec at matched geometry: no-spec measured
# 41.46/41.29/41.47 tok/s while the spec-era incumbent's 37-40 carried
# the prefetch tax, and MTP's 2.6 GiB/rank drafter reservation halves
# the pool (729,545 vs 975,342 tokens). The MTP3/4/6 untaxed re-measures
# never completed; until one shows a real win, no spec is the incumbent.
! grep -q '^FRANK_PRIMARY_SPEC=' "$ENV_FILE"
! grep -q -- '--speculative-config' "$SERVE"
@test "speculative decode is an ENV KNOB — the old retirement verdict was invalid" {
# The 2026-10-02 c1248d4 retirement of MTP compared the WRONG PAIR:
# fp8+MTP4 (37.4-40.6 tok/s, 54% acceptance) against the bf16 no-spec boot
# (36.4-39.4), then bf16 proved unbootable and fp8 came back WITHOUT MTP —
# so fp8+spec was never compared against fp8+no-spec on the same shape. The
# no-spec incumbent runs ~30 tok/s, under the fleet's 35 tok/s target, while
# EVERY vendor flash-next recipe ships MTP3, and our MTP4 run used the
# default GREEDY draft sampling (probabilistic is the other fork option and
# is what the recipes use under temp>0). So spec is a knob, not a law: the
# shipped value is whatever a matched-pair re-measurement wins.
grep -q 'THE RETIREMENT VERDICT WAS INVALID' "$ENV_FILE"
# The knobs are empty in the shipped env, so the `${VAR:+…}` wiring renders
# NO spec/compilation flag — asserted by rendering, not by grepping the
# assignment (shape E today has neither flag in its argv).
run bash -c 'set -a; . "$1" >/dev/null; set +a
printf %s "${FRANK_PRIMARY_SPEC:+--speculative-config $FRANK_PRIMARY_SPEC}${FRANK_PRIMARY_COMPCONFIG:+--compilation-config $FRANK_PRIMARY_COMPCONFIG}"' _ "$ENV_FILE"
[ -z "$output" ]
# The capture-ladder law: the piecewise graph ladder must contain
# num_speculative_tokens + 1, so an MTP3 shape captures [4, 8, 12] and the
# 2026-10-02 SEQS=2 failure (query_start_loc specialized to the constant 3)
# is why SEQS stays a multiple-compatible value.
grep -q 'num_speculative_tokens + 1' "$ENV_FILE"
}
@test "KV dtype is bf16 (auto) — exactness, paid for in pool bytes" {
# --kv-cache-dtype auto = bf16 (unquantized). Frank's call 2026-10-01.
# bf16 costs 13,839 B/token against fp8's 7,701 — decomposed as 12,276
# attention + 1,563 mamba/side (dtype-independent) versus fp8's 6,138 +
# 1,563. That halves the pool capacity per byte, which is why the contract
# is two full-size windows instead of three; in exchange the KV is exact
# (no quantization error) and measured +4% decode at shallow depth, where
# fp8's dequant cost dominates over the extra bytes read.
@test "KV dtype is fp8 — the incumbent, and the only one that boots" {
# fp8 costs ~7,701 B/token at 262144 (7,892 at 200704 — shape-dependent),
# decomposed as 6,138 attention + 1,563 mamba/side (dtype-independent);
# bf16 costs 13,839 (12,276 + 1,563).
# bf16 was abandoned 2026-10-01: its 2 x 262144 boot died with 0 bytes
# free on a real request, and the SEQS=2 slot count it was designed
# around cannot compile this model at all (see the shape test above).
grep -q '^FRANK_PRIMARY_KV_DTYPE=fp8$' "$ENV_FILE"
grep -q -- '--kv-cache-dtype "$FRANK_PRIMARY_KV_DTYPE"' "$SERVE"
}
@@ -227,15 +305,35 @@ DOWNLOAD="$REPO_ROOT/scripts/download-frank-primary.sh"
grep -q -- '-e R4D_LRU_GATHER_CHUNKS="$FRANK_PRIMARY_GATHER_CHUNKS"' "$SERVE"
}
@test "MNBT is at Frank's 1024 with the arena intact" {
# MNBT 512 doubles TTFT at 4064 ctx (2.46 s vs 1.36 s); 1024-4096 are
# interchangeable on decode AND identical on arena (the sweep measured
# 6,499 slots at every rung <=4096); 12288 costs 2.5% plus arena.
# 1024 is the 2026-10-02 setting — the pool was cut to feed the expert
# arena, so the workspace floor matters more than it did at 2048: the
# arena budget is what is left, and 1024-4096 pay nothing for it.
grep -q '^FRANK_PRIMARY_MNBT=1024$' "$ENV_FILE"
@test "the arena sits just under the ceiling and the KV ask is honoured" {
# The planner sizes expert cache as budget - non-expert - KV reserve -
# graphs - overhead, and two of those five terms are reachable from a
# deploy: budget (= device_total x util) and graphs (VLLM_CUDAGRAPH_MEM_*).
# UTIL has a HARD LAW, not a field cap: 0.982, never 1.00. The 1.00 attempt
# is UNBOOTABLE on this fork — worker init raises
# `ValueError: Free memory on device cuda:N (31.29/31.86 GiB ...)`
# on both ranks because the worker demands free > budget and budget ==
# total — and even if it booted, 0.13-GiB-margin shapes have died under
# real load. So the arena tops out at 0.982 budget (31.29 GiB).
# Graphs: charge strictly below the fork's analytic 563 MiB default while
# still paying per graph (the measured post-capture delta is -0.06 GiB, i.e.
# the base pool is the over-charge, but a future spec-decode shape captures
# 18 graphs and must still be charged).
# MNBT stays 2048: 512 doubles TTFT at 4064 ctx, 1024-4096 are decode-
# interchangeable AND arena-identical (the registry prices overhead flat up
# to its nbt 4096 anchor), 12288 costs 2.5% plus arena.
# The KV ask: the pool is priced at the engine's measured 7,892 B/token at
# this shape, and a served conversation needs 200,704 tokens, so the pool
# must clear 400,700 tokens to keep two full-length resident.
grep -q '^FRANK_PRIMARY_MNBT=2048$' "$ENV_FILE"
grep -q -- '--max-num-batched-tokens "$FRANK_PRIMARY_MNBT"' "$SERVE"
grep -q '^FRANK_PRIMARY_UTIL=0.982$' "$ENV_FILE"
base=$(awk -F= '/^FRANK_PRIMARY_CUDAGRAPH_BASE_MB=/{print $2}' "$ENV_FILE")
per_graph=$(awk -F= '/^FRANK_PRIMARY_CUDAGRAPH_PER_GRAPH_MB=/{print $2}' "$ENV_FILE")
[ "$base" -eq 0 ] && [ "$per_graph" -gt 0 ]
[ $((base + 6 * per_graph)) -lt 563 ]
kv=$(awk -F= '/^FRANK_PRIMARY_KV_BYTES=/{print $2}' "$ENV_FILE")
[ $((kv / 7892)) -ge 400700 ]
}
@test "expert prefetch is absent BY LAW, not by omission" {
+78 -38
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bats
# Seam: the frank-small llama.cpp root unit on dankclaw.
# frank-small is the RTX 3090 endpoint; since 2026-10-01 night it serves
# bartowski/MiMo-V2.6-Distill-Qwen-9B-GGUF:Q5_K_M via a CUDA (sm_86) build of
# llama.cpp master, chosen for the ASYMMETRIC KV Frank asked for: K f16
# (unquantized) + V q8_0, which only llama.cpp exposes per-tensor. Since
# 2026-10-02 it runs THREE contexts and carries a vision projector, and the
# card must keep 2-3 GiB free. This seam runs the serve script against a stub
# engine and asserts on the argv it actually execs, so what is pinned is the
# frank-small is the RTX 3090 endpoint; since 2026-10-05 it serves
# ornith-ai/Ornith-1.5-9B-GGUF:Q8_0 via a CUDA (sm_86) build of
# llama.cpp master, chosen because it is the only runtime here that moves K and
# V independently. Since 2026-10-03 it runs TWO unquantized contexts of
# 200,704 tokens each and carries a vision projector, and the card must keep
# 2-3 GiB free. This seam runs the serve script against a stub engine and
# asserts on the argv it actually execs, so what is pinned is the
# command the operator gets.
load helpers
@@ -16,9 +16,9 @@ SERVE="$REPO_ROOT/scripts/serve-frank-small.sh"
ENV_FILE="$REPO_ROOT/scripts/frank-small.env"
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
ENV_LARGE="$REPO_ROOT/scripts/frank-large.env"
TARGET_STUB="MiMo-V2.6-Distill-Qwen-9B-Q5_K_M.gguf"
TARGET_STUB="Ornith-1.5-9B-Q8_0.gguf"
# Same basename as the shipped projector: the test env repoints it into $STAGE.
MM_STUB="mmproj-MiMo-V2.6-Distill-Qwen-9B-f16.gguf"
MM_STUB="mmproj-Ornith-1.5-9B-BF16.gguf"
setup() {
STAGE="$(mktemp -d)"
@@ -52,6 +52,8 @@ BIND_ADDR=0.0.0.0
LISTEN_PORT=7771
CTX_SIZE=$(sed -nE 's/^CTX_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")
PARALLEL=$(sed -nE 's/^PARALLEL=([0-9]+)$/\1/p' "$ENV_FILE")
BATCH_SIZE=$(sed -nE 's/^BATCH_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")
UBATCH_SIZE=$(sed -nE 's/^UBATCH_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")
CACHE_TYPE_K=$(sed -nE 's/^CACHE_TYPE_K=(.+)$/\1/p' "$ENV_FILE")
CACHE_TYPE_V=$(sed -nE 's/^CACHE_TYPE_V=(.+)$/\1/p' "$ENV_FILE")
NGL=$(sed -nE 's/^NGL=([0-9]+)$/\1/p' "$ENV_FILE")
@@ -79,9 +81,9 @@ argv_value() {
grep -q 'FRANK_SMALL_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-small.env' "$UNIT"
}
@test "the shipped env points at the llama.cpp runtime and the Q5_K_M GGUF" {
@test "the shipped env points at the llama.cpp runtime and the Ornith-1.5-9B Q8_0 GGUF" {
grep -qE '^LLAMA_BIN=/home/dank/GitPackages/llama-master/build-cuda/bin/llama-server$' "$ENV_FILE"
grep -qE "^MODEL_FILE=/home/dank/models/MiMo-Q5KM/$TARGET_STUB$" "$ENV_FILE"
grep -qE "^MODEL_FILE=/home/dank/models/Ornith-Q8/$TARGET_STUB$" "$ENV_FILE"
}
@test "the deployer gates on exactly the payload paths the env file ships" {
@@ -98,7 +100,7 @@ argv_value() {
done
}
@test "the shipped env serves the fleet shape: three 180224-token contexts" {
@test "the shipped env serves the fleet shape: one native 262144-token context" {
serve_argv
[ "$status" -eq 0 ]
[ "$(argv_value --alias)" = "frank-small" ]
@@ -108,43 +110,81 @@ argv_value() {
[ "$(argv_value --n-gpu-layers)" -gt 0 ]
# llama.cpp divides --ctx-size across slots and a mamba hybrid cannot
# share one cache, so the observable contract is the per-slot window.
[ "$(argv_value --parallel)" -eq 3 ]
# 2026-10-05 (Frank): NO KV quantization forces ONE slot at the NATIVE
# window — Ornith's 9 full-attn layers price f16 KV at 36 KiB/token, and
# two 200,704 slots cannot fit the 2-3 GiB floor on 24 GiB with Q8
# weights. The window per conversation ROSE 200,704 -> 262,144.
[ "$(argv_value --parallel)" -eq 1 ]
ctx="$(argv_value --ctx-size)"
par="$(argv_value --parallel)"
[ $((ctx % par)) -eq 0 ]
[ $((ctx / par)) -eq 180224 ]
[ $((ctx / par)) -le 262144 ]
[ $((ctx / par)) -eq 262144 ]
}
@test "the KV contract is asymmetric: unquantized K, q8_0 V, flash-attn on" {
@test "the KV contract is unquantized both halves, flash-attn on" {
serve_argv
[ "$status" -eq 0 ]
[ "$(argv_value --cache-type-k)" = "f16" ]
[ "$(argv_value --cache-type-v)" = "q8_0" ]
# q8_0 on V only exists on the flash-attention path; without this flag
# pair the KV contract in the env file is a lie the engine never sees.
# Frank, 2026-10-05: NO KV quantization on this endpoint. The Q8_0 rung
# funds itself by UBATCH first (2048 -> 1024), then pool steps — a q8_0-V
# proposal was written and overruled. The flag pair is what makes this
# observable — a build without the FA path silently falls back, so both
# must appear in the argv the supervisor runs.
[ "$(argv_value --cache-type-v)" = "f16" ]
[ "$(argv_value --flash-attn)" = "on" ]
}
@test "the KV budget leaves 2-3 GiB of the 3090 free" {
# Rate: K f16 (16 KiB/token) + V q8_0 (~8.5, budgeted 25 with slack).
# The non-KV half is MEASURED on this card on 2026-10-02: with the
# projector loaded the engine reported 22,972 MiB used against 14,113
# MiB of KV, so weights + vision tower + DeltaNet state + compute
# buffers + CUDA graphs = 8,859 MiB => 9 GiB. Two asserts, both about
# the output the operator sees: the pool must still fit with 2 GiB
# spare, and it must be large enough that 5 GiB spare is NOT reachable
# (an undersized pool is as wrong as an oversized one). Arithmetic, not
# a boot log: change CTX_SIZE and this bites before the deploy does —
# it rejects the 3 x 196,608 pool that measured only 1.1 GiB free. The
# same pool with f16 V (34 KiB/token) lands past the card, which is why
# the contract is asymmetric.
# Rate, from the Ornith GGUF header (read from the Hub file, 2026-10-05):
# NINE full-attention layers (block_count 33, full_attention_interval 4)
# x 4 kv heads x 256 dim x 2 B = 18 KiB/token for K and the same for V
# => 36 KiB/token (budgeted 37 with slack; MiMo measured 24.5 — one
# fewer layer AND one fewer KV head). The non-KV half: Q8_0 weights
# 9,334 MiB (9,786,060,384 B) + vision tower ~1.3 GiB (mmproj 879 MiB at
# the measured ~1.5x cost law) + DeltaNet state + compute buffers +
# CUDA graphs => 11 GiB budgeted. Two asserts, both about the output
# the operator sees: the pool must still fit with 2 GiB spare, and it
# must be large enough that 5 GiB spare is NOT reachable (an undersized
# pool is as wrong as an oversized one).
# Arithmetic, not a boot log: change CTX_SIZE and this bites before the
# deploy does. At Ornith's rate the two-200704-slot pool (14.1 GiB KV)
# FAILS the first assert — which is exactly why the shipped shape is
# ONE native slot.
ctx="$(sed -nE 's/^CTX_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
kv_gib=$((ctx * 25 / 1048576))
[ $((kv_gib + 9 + 2)) -le 24 ]
[ $((kv_gib + 9 + 5)) -gt 24 ]
f16v_gib=$((ctx * 34 / 1048576))
[ $((f16v_gib + 9)) -gt 24 ]
kv_gib=$((ctx * 37 / 1048576))
[ $((kv_gib + 11 + 2)) -le 24 ]
[ $((kv_gib + 11 + 5)) -gt 24 ]
# And the ceiling that sets the per-slot window: two NATIVE windows cannot
# be paid for in full precision on this card.
native=$((2 * 262144))
[ $((native * 37 / 1048576 + 11)) -gt 24 ]
}
@test "the compute-buffer pair follows the env file and stays self-consistent" {
# This endpoint ran on llama.cpp's own defaults until 2026-10-03, so the
# contract is: whatever rung the repo carries is the rung the engine is
# exec'd with — the number a benchmark attributes a prefill rate to must be
# a repo value, never an engine default nobody wrote down. Asserted against
# the env rather than a literal so a retune moves one file, not two.
local env_batch env_ubatch
env_batch="$(sed -nE 's/^BATCH_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
env_ubatch="$(sed -nE 's/^UBATCH_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
serve_argv
[ "$status" -eq 0 ]
[ "$(argv_value --batch-size)" = "$env_batch" ]
[ "$(argv_value --ubatch-size)" = "$env_ubatch" ]
# llama.cpp refuses ubatch > batch, so a retune that inverts the pair has
# to fail here instead of at boot on a live endpoint.
[ "$env_batch" -ge "$env_ubatch" ]
# And an env that names neither flag still ships an explicit pair: the
# serve script's fallbacks are written-down values, not blanks the engine
# fills in silently.
sed -i '/^BATCH_SIZE=/d;/^UBATCH_SIZE=/d' "$STAGE/test.env"
serve_argv
[ "$status" -eq 0 ]
argv_value --batch-size | grep -qE '^[0-9]+$'
argv_value --ubatch-size | grep -qE '^[0-9]+$'
}
@test "no speculative draft ships by default" {
@@ -156,7 +196,7 @@ argv_value() {
}
@test "vision ships by default and MMPROJ_FILE empty opts out of the image path" {
# The Q5_K_M GGUF carries no `v.*` tensors, so the projector argument IS
# The Q6_K GGUF carries no `v.*` tensors, so the projector argument IS
# the vision capability: no flag, no eyes.
serve_argv
[ "$status" -eq 0 ]
@@ -170,7 +210,7 @@ argv_value() {
! grep -qxF -- '--mmproj' <<<"$output"
}
@test "the fleet reasoning contract survives the reslice to three contexts" {
@test "the fleet reasoning contract survives the reslice to two wide contexts" {
serve_argv
[ "$status" -eq 0 ]
# deepseek format = thinking lands in reasoning_content, the field the