Files
serverAppConfigs/serverAppConfigs-docs/LLM/halogen-flash-server.md
T
Frank 301914b1c0 feat(llm): swap frank-large to halogen-flash-server, retire frank-npu
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.
2026-09-11 14:48:28 -07:00

13 KiB


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

./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 enabled 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.

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:

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