frank-large moves from the kingjones30/ROCmFPX llama.cpp build to peonist-ai/halogen-flash-server 0.5.8 serving Qwen3.8-Flash-Next (w4b .hgn 115.55 GiB + quality overlay). Upstream measures this engine at roughly 4x the prefill throughput of the llama.cpp family on the same gfx1151 silicon, and prefill dominates any prompt with real context. Shape: a rootless Quadlet container (dankclaw/frank-large/quadlet/), engine on container loopback :8730 (never published), OpenAI front-end :8731 published as host 7770, HALOGEN_MODEL_ID=frank-large so no client renamed anything. Context 262144, KV pool 262144 positions (~7.2 GiB, conservative for a shared 121 GiB host), 4 slots, host reserve 24, vision tower on, weights pinned, all other flags at vendor defaults. Weights are fetched by HALOGEN_DOWNLOAD on first start into a writable /home/dank/models/halogen. The llama.cpp era (unit, env, serve script) is staged in .AGENTTRASH/ rather than deleted; the STRIX payload and the ROCmFPX build stay on disk as the rollback target. deploy_llm_large becomes a user-quadlet handler: it retires the old ROOT unit (moving its checkout symlink into the home trash, never rm) and gates the first start on the download rather than reporting a failure for a fetch that is working exactly as designed. frank-npu is retired in the same change. The kernel cmdline moves from iommu=pt to amd_iommu=off for the 13-16% of prefill upstream measured, and that takes the XDNA2 NPU with it: without PASID/SVA the driver cannot open /dev/accel/accel0 (err=-19). Its five dependent routes are repointed (Hermes vision -> frank-large, Hermes title generation -> frank-small, harness vision -> frank-large, tiny -> frank-small, task -> frank-large:medium) and its provider blocks are removed from both the harness config and Hermes's live config. Audio input leaves the fleet with no replacement; image input survives on halogen's vision tower. Docs: new halogen-flash-server runbook, new IOMMU decision record, and frank-npu rewritten as a retirement record. AGENTS.md topology, runtime sections, benchmarking policy (frank-large is a user unit now) and the harness model-config section are corrected.
241 lines
13 KiB
Markdown
241 lines
13 KiB
Markdown
---
|
|
type: reference
|
|
title: halogen-flash-server on Dankclaw (frank-large)
|
|
description: The active frank-large runbook — peonist-ai/halogen-flash-server 0.5.8 serving Qwen3.8-Flash-Next (w4b) on the Dankclaw gfx1151 box: container shape, deployed flags, the memory model, the client contract, and the failure modes worth knowing.
|
|
tags:
|
|
- llm
|
|
- halogen
|
|
- dankclaw
|
|
- frank-large
|
|
- strix-halo
|
|
- gfx1151
|
|
- qwen4exp
|
|
- rocm
|
|
timestamp: 2026-09-11
|
|
---
|
|
|
|
# halogen-flash-server on Dankclaw (frank-large)
|
|
|
|
> **ACTIVE since 2026-09-11.** Replaced the llama.cpp / kingjones30-ROCmFPX
|
|
> STRIX deployment (see [[kingjones-strix-rocmfp4]], now the rollback target).
|
|
> Deployed as a rootless Quadlet container,
|
|
> `dankclaw/frank-large/quadlet/frank-large.container`, port 7770.
|
|
|
|
The engine is a from-scratch serving runtime for the Qwen3.8-Flash-Next
|
|
architecture on AMD's gfx1151 (Strix Halo) — the same silicon this host runs.
|
|
Upstream's own comparison table measures it at **roughly 4x the prefill
|
|
throughput** of the llama.cpp-family runtimes it ships against (EngramHalo.cpp,
|
|
ROCmFP4, CIRU-IU4) on a 32K prompt, and prefill is most of the wall clock on any
|
|
prompt with real context. That, not decode, is the reason for the swap.
|
|
|
|
## Shape
|
|
|
|
One container runs both halves of the stack:
|
|
|
|
| half | port | reachable |
|
|
|---|---|---|
|
|
| engine | 8730 | container loopback only — **unauthenticated, never publish it** |
|
|
| OpenAI front-end | 8731 | published as host **7770** |
|
|
|
|
The `all` mode is upstream's default and the shape a single-container host
|
|
wants: the front-end talks to the engine over loopback inside the container, so
|
|
there is one process tree, one log, and one published port.
|
|
|
|
`HALOGEN_MODEL_ID=frank-large` sets the id reported at `/v1/models`, in
|
|
`/health`, and in every response body. Requests are **never rejected for naming
|
|
a different model** — the server serves whatever asks. Keeping the fleet alias
|
|
is therefore free, and it means Hermes, the harness config, the OpenWebUI
|
|
discovery, the `services.json` probe and the docs needed no rename.
|
|
|
|
## Weights
|
|
|
|
`peonist-ai/halogen-qwen3.8-flash-next` (HuggingFace, public and ungated):
|
|
|
|
| file | size | role |
|
|
|---|---|---|
|
|
| `qwen38-flash-next-w4b.hgn` | 115.55 GiB | the checkpoint |
|
|
| `qwen38-flash-next-w4b.overlay.hgn` | 2.31 GiB | **quality sidecar** — picked up automatically; the engine warns at startup if it is missing |
|
|
| `qwen38-flash-next-vision.hgn` | 0.84 GiB | vision tower (see below) |
|
|
| `tokenizer/` | ~21 MB | flat tokenizer dir; found beside the checkpoint, so no extra mount |
|
|
|
|
`HALOGEN_DOWNLOAD` fetches the whole repo into the bind mount on **first start
|
|
only** — the entrypoint acts only when the checkpoint is absent, so a restart
|
|
never re-downloads, and `huggingface_hub` resumes an interrupted pull. The
|
|
mount must be read-write for this. Roughly 127 GB; on Dankclaw it lands in
|
|
`/home/dank/models/halogen`.
|
|
|
|
`overlay-speed.hgn` (2.22 GiB) comes along with the repo but is unused: it
|
|
trades the `o_proj` calibration for about 2 % of decode, and the quality
|
|
sidecar is the default for a reason.
|
|
|
|
## Deployed flags
|
|
|
|
Everything else is configured by environment variable — there is no config
|
|
file, and the flags are read once at startup. These are the departures from
|
|
upstream's defaults:
|
|
|
|
| flag | value | why |
|
|
|---|---|---|
|
|
| `HALOGEN_MODEL_ID` | `frank-large` | fleet identity; no client rename |
|
|
| `HALOGEN_CTX` | `262144` | the model's native context, and what both clients declare |
|
|
| `HALOGEN_KV_POOL_POSITIONS` | `262144` | conservative for a **shared** 121 GiB host: one full-length conversation, or four at 65k, at ~7.2 GiB |
|
|
| `HALOGEN_KV_SLOTS` | `4` | four concurrent generations; slots share the pool and cost ~115 MiB each |
|
|
| `HALOGEN_HOST_RESERVE_GIB` | `24` | RAM kept for the n-gram table the engine reads through the page cache — above upstream's 20 because a dozen containers share this host |
|
|
| `HALOGEN_VISION_TOWER` | `1` | image input; the fleet's only image path since [[frank-npu]] was retired |
|
|
| `HALOGEN_DOWNLOAD` | `peonist-ai/halogen-qwen3.8-flash-next` | first-start fetch |
|
|
|
|
Everything else is upstream's default, deliberately: prompt cache mode `2`
|
|
(two entries per conversation — end-of-system-prompt and end-of-history — which
|
|
already covers the shared-prefix case), `HALOGEN_CACHE_INPLACE=1` (the snapshot
|
|
stays ~115 MiB at any depth), the speculative drafter `1` (byte-identical at
|
|
temperature 0, and it stands down whenever a second conversation is
|
|
generating), the quality sidecar, `HALOGEN_ADMIT_CHUNK=0` (a non-zero value
|
|
gives up the byte-identity property), the baked `HALOGEN_MAX_TOK=32768`
|
|
(16.7 GiB prefill arena), and the default `HALOGEN_MAX_TOKENS_CAP=65536`.
|
|
|
|
## Memory model
|
|
|
|
Three numbers explain the whole box:
|
|
|
|
- **~68 GiB pinned weights.** The checkpoint is mapped and registered with the
|
|
GPU in place, never copied. The kernel counts the locked pages as reclaimable
|
|
file cache, so `free`, `MemAvailable` and every dashboard built on them
|
|
**overstate free memory by the size of the model**. The server prints the
|
|
corrected figure at startup; believe that line, not Grafana.
|
|
- **~7.2 GiB per 262,144 KV positions**, plus the prefill arena (16.7 GiB at
|
|
`HALOGEN_MAX_TOK=32768`, 8.4 GiB at 16384). The engine's device-side budget
|
|
stops at roughly 47 GiB on a 128 GB machine *with the weights pinned* — and
|
|
lower on a machine carrying more besides this server, which Dankclaw is.
|
|
- **`HALOGEN_HOST_RESERVE_GIB`** is what is left for the page cache the lookup
|
|
table is read through. Too little and long prompts crawl with the disk busy
|
|
and the process in uninterruptible sleep — that is a cache problem, not a
|
|
memory problem, and the fix is a *smaller* pool, not a bigger one.
|
|
|
|
`HALOGEN_KV_POOL_FIT=1` (default) measures that budget at startup and lowers
|
|
the pool itself if the configured one will not fit, printing what it chose.
|
|
Read those lines on every restart; they are the ground truth for sizing.
|
|
|
|
**Co-tenancy.** `HALOGEN_FLASH_PIN_TRUNK=1` (default) is what the published
|
|
decode numbers were measured with, and it means this container holds most of
|
|
the host. If a co-tenant then takes the remainder, both it and the engine can
|
|
stall for minutes at a time at full CPU with no disk activity and no output.
|
|
That is documented behaviour, not a crash, and it needs no restart.
|
|
`HALOGEN_FLASH_PIN_TRUNK=0` gives the memory back and costs several times the
|
|
decode speed — a last resort, not a tuning option.
|
|
|
|
## Client contract
|
|
|
|
The differences from llama.cpp that bite are all in the request, not the
|
|
deployment:
|
|
|
|
- **No server-side sampler defaults.** `temperature` absent or `0` is
|
|
**greedy** decode; above 0 the request samples on the same drafter. Supported
|
|
fields: `temperature`, `top_p`, `top_k`, `min_p`, `seed`,
|
|
`presence_penalty`, `frequency_penalty`, `logit_bias`, `logprobs`. Refused
|
|
with a 400: `top_logprobs`, `logprobs` with `stream: true`, `n > 1`, and any
|
|
value outside its defined range (values are refused, never clamped).
|
|
The fleet runs greedy for clients that stay silent — deterministic, and
|
|
byte-identical with the drafter.
|
|
- **The token budget covers thinking.** The default is **8192**, and a budget
|
|
that runs out mid-thought returns `finish_reason: "length"` with an **empty
|
|
`content`** and the partial reasoning in `reasoning_content` — which most
|
|
clients do not display. Above the cap (`HALOGEN_MAX_TOKENS_CAP`, 65536) a
|
|
request is a 400 rather than a silent truncation. `max_tokens`,
|
|
`max_completion_tokens` and `max_output_tokens` all work and must agree if
|
|
more than one is sent.
|
|
- **`reasoning_effort`** is accepted at the top level and inside
|
|
`chat_template_kwargs`: `minimal`, `low`, `medium`, `high`, `xhigh`. The
|
|
model's default is `xhigh`. `minimal` aliases the template's `low` and still
|
|
reasons; `enable_thinking: false` turns thinking off; `preserve_thinking` is
|
|
honoured. `preserve_reasoning` is a **400** — it is the llama.cpp-era key.
|
|
- `/v1/models`, `/health` and completions all report the served id, and
|
|
`reasoning_content` carries the reasoning trace.
|
|
|
|
**Pool admission is the one arithmetic trap.** A request reserves its prompt
|
|
**plus** `max_tokens` positions when it is admitted; if the pool cannot hold it
|
|
the request waits in arrival order, and after the queue timeout (3600 s) it
|
|
gets a `503 engine_busy`. A prompt large enough that prompt + budget exceeds
|
|
the pool can therefore never be admitted at all. The 262,144-position pool is
|
|
the binding constraint on how large a conversation can get, not `HALOGEN_CTX`
|
|
and not the client's declared `context_length`.
|
|
|
|
## Vision
|
|
|
|
With the tower loaded, `/v1/chat/completions` accepts the usual OpenAI image
|
|
content part carrying a `data:` URL or bare base64 — **`http(s)` URLs are
|
|
refused deliberately**, because fetching one would make the server issue
|
|
outbound requests to wherever a client pointed it. One image costs about 2,040
|
|
tokens of context and 5.5 / 11.8 / 25.3 s at 1280x800 / 1920x1080 / 2560x1440;
|
|
text at 12 pt and above is read exactly, and a bigger frame past that point
|
|
adds empty area rather than detail. Audio and video have no path here at all —
|
|
that capability left with [[frank-npu]].
|
|
|
|
## Deploy and operate
|
|
|
|
```bash
|
|
./scripts/deploy.sh dankclaw --service frank-large
|
|
```
|
|
|
|
The handler retires the llama.cpp-era **root** unit first (stops it, drops its
|
|
enable symlinks, moves `/etc/systemd/system/frank-large.service` into the home
|
|
trash — never `rm`), then pulls `ghcr.io/peonist-ai/halogen-flash-server:0.5.8`,
|
|
re-tags it to the `localhost/` alias the unit references, installs the Quadlet,
|
|
and restarts the generated unit. Boot persistence comes from
|
|
`[Install] WantedBy=default.target` in the unit file plus linger on the deploy
|
|
user — generated units **cannot** be `systemctl --user enable`d on this host.
|
|
|
|
The health gate is bootstrap-aware: while the checkpoint is absent the
|
|
container is downloading and `/health` cannot answer, so the handler waits for
|
|
the file instead of burning its budget and reporting a failure that is not one.
|
|
|
|
```bash
|
|
systemctl --user status frank-large.service # the generated unit
|
|
podman logs frank-large | head -40 # version banner + memory ledger
|
|
curl -s http://127.0.0.1:7770/health # 200 only after the engine PONGs
|
|
curl -s http://127.0.0.1:7770/v1/models
|
|
```
|
|
|
|
`/health` asks the **engine** for a PONG before answering, so a 200 means
|
|
"ready to serve", not merely "process alive". During the first-start download
|
|
it stays unhealthy by design.
|
|
|
|
## Troubleshooting
|
|
|
|
| symptom | cause |
|
|
|---|---|
|
|
| `dmalloc: FAILED requesting … (out of memory)` then `HIP … out of memory` | the KV pool did not fit. `HALOGEN_KV_SLOTS` will not fix it (slots share the pool); lower `HALOGEN_KV_POOL_POSITIONS` to 262144, then `HALOGEN_MAX_TOK` to 16384 (~9 % prefill) |
|
|
| starts, answers short prompts, collapses to a few tok/s on a long one, disk busy, process in uninterruptible sleep | short of **file cache**, not memory — lower the pool and/or raise `HALOGEN_HOST_RESERVE_GIB` |
|
|
| long replies that look cut off | read `finish_reason`: `stop` means you have the whole answer, `length` means you ran out of budget (thinking counts) |
|
|
| a request that never returns, then `503 engine_busy` | the pool could not reserve prompt + `max_tokens`; see the admission trap above |
|
|
| both the engine and a co-tenant stall for minutes at full CPU | the pinned-trunk trade; see Memory model |
|
|
| `free`, `MemAvailable`, Grafana all wrong by ~68 GiB | expected — locked weights are counted as reclaimable |
|
|
|
|
`HALOGEN_VERBOSE=1` and `HALOGEN_DMALLOC_LOG=1` print the fullest startup
|
|
account and every allocation over 64 MB; both are the vendor's suggested
|
|
evidence to attach to a report. The two lines worth grepping first:
|
|
|
|
```bash
|
|
podman logs frank-large 2>&1 | grep -E '^(dmalloc|kv pool):'
|
|
```
|
|
|
|
## Rollback
|
|
|
|
The llama.cpp era is intact: the STRIX payload stays at
|
|
`/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX`, the
|
|
`kingjones30/ROCmFPX` build stays at
|
|
`/home/dank/GitPackages/kingjones-ROCmFPX/build`, and the retired unit, env and
|
|
serve script are in `serverAppConfigs/.AGENTTRASH/` (suffixed `.llama.cpp`).
|
|
Rolling back is a `git revert` of this change set plus a deploy — **but the
|
|
Quadlet unit must be removed from `~/.config/containers/systemd/` first**
|
|
(otherwise the restarted root unit and the still-installed container both
|
|
claim port 7770). See [[kingjones-strix-rocmfp4]].
|
|
|
|
The `amd_iommu=off` kernel argument that came with this change is separate and
|
|
reverts on its own — see [[dankclaw-iommu-translation-off]].
|
|
|
|
## Citations
|
|
|
|
- [peonist-ai/halogen-flash-server](https://github.com/peonist-ai/halogen-flash-server) — README, `docs/FLAGS.md`, `deploy/entrypoint.sh`, `docker-compose.yml`, CHANGELOG (v0.5.8)
|
|
- [peonist-ai/halogen-qwen3.8-flash-next](https://huggingface.co/peonist-ai/halogen-qwen3.8-flash-next) — weights and file inventory
|
|
- [Qwen/Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) — base model
|