Merge pull request 'fix(frank-dragoon): stage the NPU lane inside the payload mount' (#44) from feat/frank-dragoon-npu-lane into main

This commit was merged in pull request #44.
This commit is contained in:
2026-10-19 21:46:25 -07:00
7 changed files with 95 additions and 24 deletions
+12 -2
View File
@@ -1345,7 +1345,7 @@ LLM_DRAGOON_TOK_FILES="${LLM_DRAGOON_TOK_FILES:-tokenizer.json chat_template.jin
# hang the machine while the fabric clock moves, so halogen refuses to boot with # hang the machine while the fabric clock moves, so halogen refuses to boot with
# the lane armed unless that clock is held. # the lane armed unless that clock is held.
LLM_DRAGOON_NPU_MODELS="${LLM_DRAGOON_NPU_MODELS:-qwen3-embedding-0.6b}" LLM_DRAGOON_NPU_MODELS="${LLM_DRAGOON_NPU_MODELS:-qwen3-embedding-0.6b}"
LLM_DRAGOON_NPU_DIR="${LLM_DRAGOON_NPU_DIR:-/home/dank/models/halogen-npu}" LLM_DRAGOON_NPU_DIR="${LLM_DRAGOON_NPU_DIR:-/home/dank/models/halogen-v2-abliterated/npu}"
LLM_DRAGOON_DEV_ACCEL="${LLM_DRAGOON_DEV_ACCEL:-/dev/accel/accel0}" LLM_DRAGOON_DEV_ACCEL="${LLM_DRAGOON_DEV_ACCEL:-/dev/accel/accel0}"
LLM_DRAGOON_XRT_DIR="${LLM_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}" LLM_DRAGOON_XRT_DIR="${LLM_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}"
LLM_DRAGOON_NETWORK="${LLM_DRAGOON_NETWORK:-dankclaw_default}" LLM_DRAGOON_NETWORK="${LLM_DRAGOON_NETWORK:-dankclaw_default}"
@@ -1435,8 +1435,18 @@ deploy_llm_dragoon() {
return 1 return 1
fi fi
# --- NPU lane: the same three predicates the serve script checks --------- # --- NPU lane: the same predicates the serve script checks --------------
if [[ -n "$LLM_DRAGOON_NPU_MODELS" ]]; then if [[ -n "$LLM_DRAGOON_NPU_MODELS" ]]; then
# One door into the container: the read-only /models mount of the
# payload dir. A staging dir outside it needs a nested bind whose
# mountpoint runc cannot create through a read-only parent, so the
# parity twin of the serve gate refuses the shape before the restart.
npu_model_dir="$(sed -nE 's/^FRANK_DRAGOON_MODEL_DIR=(.*)$/\1/p' \
"$CHECKOUT/scripts/frank-dragoon.env" | tail -1)"
if [[ -n "$npu_model_dir" && "$LLM_DRAGOON_NPU_DIR/" != "$npu_model_dir"/* ]]; then
echo "ERROR: NPU staging dir $LLM_DRAGOON_NPU_DIR is outside the payload dir $npu_model_dir — the lane's files reach the container only through the read-only /models mount" >&2
return 1
fi
if [[ ! -e "$LLM_DRAGOON_DEV_ACCEL" ]]; then if [[ ! -e "$LLM_DRAGOON_DEV_ACCEL" ]]; then
echo "ERROR: NPU node $LLM_DRAGOON_DEV_ACCEL absent — amdxdna is not bound (is iommu=pt still on the boot line?)" >&2 echo "ERROR: NPU node $LLM_DRAGOON_DEV_ACCEL absent — amdxdna is not bound (is iommu=pt still on the boot line?)" >&2
return 1 return 1
+8 -1
View File
@@ -29,7 +29,14 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}" ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
DEST="${FRANK_DRAGOON_NPU_DIR:-$HOME/models/halogen-npu}" # The env file owns the staging path (it must sit inside the payload dir, which
# is the container's only /models mount); an exported FRANK_DRAGOON_NPU_DIR wins
# so the bats seam can stage somewhere disposable.
DEST="${FRANK_DRAGOON_NPU_DIR:-}"
if [ -z "$DEST" ] && [ -f "$ENV_FILE" ]; then
DEST="$(sed -nE 's/^FRANK_DRAGOON_NPU_DIR=(.*)$/\1/p' "$ENV_FILE" | tail -1)"
fi
: "${DEST:?FRANK_DRAGOON_NPU_DIR is not set and $ENV_FILE names no FRANK_DRAGOON_NPU_DIR}"
HF="https://huggingface.co" HF="https://huggingface.co"
MODELS="${HALOGEN_NPU_MODELS:-}" MODELS="${HALOGEN_NPU_MODELS:-}"
+11 -5
View File
@@ -87,18 +87,24 @@ HALOGEN_WEIGHTS_LOCK=1
# fabric-clock dependency, no /v1/embeddings). Rolling the lane back is blanking # fabric-clock dependency, no /v1/embeddings). Rolling the lane back is blanking
# this one line and re-deploying — nothing else moves. # this one line and re-deploying — nothing else moves.
# Names are the vendor catalog's folder names (`/opt/halogen/npu/models.txt` # Names are the vendor catalog's folder names (`/opt/halogen/npu/models.txt`
# inside the image). The engine cannot fetch them here: /models is read-only and # inside the image) and the engine reads a model ONLY at /models/npu/<name>/ —
# HALOGEN_NPU_DIR is gone from the image and setting it is a boot refusal.
# The engine cannot fetch anything here: /models is read-only and
# HALOGEN_DOWNLOAD stays unset so the container opens no outbound connection at # HALOGEN_DOWNLOAD stays unset so the container opens no outbound connection at
# boot, so scripts/download-frank-dragoon-npu.sh must stage them into # boot, so scripts/download-frank-dragoon-npu.sh must stage the files first
# $FRANK_DRAGOON_NPU_DIR first (the serve gate and the deployer both refuse by # (the serve gate and the deployer both refuse by name when a named model is
# name when a named model is not staged). # missing). FRANK_DRAGOON_NPU_DIR therefore lives INSIDE the payload dir: the
# payload mount is the container's only handle on /models, and runc cannot
# mkdir a nested /models/npu mountpoint through a read-only bind — the lane's
# first boot died on precisely that (2026-10-19). Swap the payload dir and this
# path moves with it.
# Cost: ~1.5 GiB extra pinned host RAM per named model, and a hard dependency on # Cost: ~1.5 GiB extra pinned host RAM per named model, and a hard dependency on
# the host holding the GPU fabric clock — on Strix Halo the iGPU and the NPU # the host holding the GPU fabric clock — on Strix Halo the iGPU and the NPU
# together hang the machine (and corrupt NPU results) while FCLK changes speed, # together hang the machine (and corrupt NPU results) while FCLK changes speed,
# so halogen refuses to start with this variable set unless the clock is held # so halogen refuses to start with this variable set unless the clock is held
# (dankclaw/host/halogen-fabric-clock; the deployer installs and gates it). # (dankclaw/host/halogen-fabric-clock; the deployer installs and gates it).
HALOGEN_NPU_MODELS=qwen3-embedding-0.6b HALOGEN_NPU_MODELS=qwen3-embedding-0.6b
FRANK_DRAGOON_NPU_DIR=/home/dank/models/halogen-npu FRANK_DRAGOON_NPU_DIR=/home/dank/models/halogen-v2-abliterated/npu
# Node paths and the XRT directory are variables so the bats seam can drive # Node paths and the XRT directory are variables so the bats seam can drive
# these gates on a box with no NPU. Ubuntu's distro XRT (libxrt2 + libxrt-npu2) # these gates on a box with no NPU. Ubuntu's distro XRT (libxrt2 + libxrt-npu2)
# is mounted FILE BY FILE and TWICE EACH — once where the image looks for XRT, # is mounted FILE BY FILE and TWICE EACH — once where the image looks for XRT,
+20 -4
View File
@@ -125,9 +125,17 @@ fi
# Empty = the endpoint exactly as it ran before the lane existed: no device, no # Empty = the endpoint exactly as it ran before the lane existed: no device, no
# extra mounts, no gate below. Set = three host preconditions, each refused BY # extra mounts, no gate below. Set = three host preconditions, each refused BY
# NAME (the 2026-10-01 law: the serve gate and the deployer gate must agree): # NAME (the 2026-10-01 law: the serve gate and the deployer gate must agree):
# 1. the staged model files (the engine checks size+sha256 against its own # 1. the staged model files under $FRANK_DRAGOON_NPU_DIR — a path that MUST
# catalog at boot; it cannot fetch them here because /models is read-only # live INSIDE $FRANK_DRAGOON_MODEL_DIR, because the container's only
# and HALOGEN_DOWNLOAD stays unset — no outbound connection at boot), # handle on them is the read-only /models mount. A nested bind at
# /models/npu cannot work: runc has to mkdir its mountpoint and the parent
# bind is ro (`mkdirat .../merged/models/npu: read-only file system`,
# measured 2026-10-19), and the image ships no /models at all for it to
# land on. `$MODEL_DIR/npu/<id>` is visible at /models/npu/<id> through
# the existing mount, which is also where the vendor's catalog expects it.
# The engine re-checks size+sha256 against that catalog at boot; it cannot
# fetch anything here because /models is read-only and HALOGEN_DOWNLOAD
# stays unset — no outbound connection at boot),
# 2. the distro XRT's three libraries, each mounted twice (where the image # 2. the distro XRT's three libraries, each mounted twice (where the image
# looks for XRT, and at its own path — the libraries find each other there; # looks for XRT, and at its own path — the libraries find each other there;
# an /opt/xilinx/xrt of symlinks into the system dir dangles inside the # an /opt/xilinx/xrt of symlinks into the system dir dangles inside the
@@ -144,6 +152,15 @@ if [ -n "$NPU_MODELS" ]; then
NPU_DIR="${FRANK_DRAGOON_NPU_DIR:?HALOGEN_NPU_MODELS is set, so FRANK_DRAGOON_NPU_DIR must name the staged model dir}" NPU_DIR="${FRANK_DRAGOON_NPU_DIR:?HALOGEN_NPU_MODELS is set, so FRANK_DRAGOON_NPU_DIR must name the staged model dir}"
XRT_DIR="${FRANK_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}" XRT_DIR="${FRANK_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}"
SYSFS="${FRANK_DRAGOON_SYSFS:-/sys}" SYSFS="${FRANK_DRAGOON_SYSFS:-/sys}"
# The lane's files have exactly one door into the container: the read-only
# payload mount. A staging dir OUTSIDE it would need a nested bind whose
# mountpoint runc cannot create through that read-only parent, so refuse the
# shape by name here rather than let runc die at container init (2026-10-19).
case "$NPU_DIR/" in
"$FRANK_DRAGOON_MODEL_DIR"/*) ;;
*) echo "frank-dragoon: FRANK_DRAGOON_NPU_DIR=$NPU_DIR is outside the payload dir $FRANK_DRAGOON_MODEL_DIR — the lane's files reach the container only through the read-only /models mount; move the staging dir inside it" >&2
exit 1 ;;
esac
if [ ! -e "$DEV_ACCEL" ]; then if [ ! -e "$DEV_ACCEL" ]; then
echo "frank-dragoon: $DEV_ACCEL missing — the NPU lane needs the amdxdna node (is iommu=pt still on the boot line?)" >&2 echo "frank-dragoon: $DEV_ACCEL missing — the NPU lane needs the amdxdna node (is iommu=pt still on the boot line?)" >&2
exit 1 exit 1
@@ -183,7 +200,6 @@ if [ -n "$NPU_MODELS" ]; then
exit 1 exit 1
fi fi
NPU_ARGS+=(--device "$DEV_ACCEL" \ NPU_ARGS+=(--device "$DEV_ACCEL" \
-v "$NPU_DIR:/models/npu:ro" \
-e HALOGEN_NPU_MODELS="$NPU_MODELS") -e HALOGEN_NPU_MODELS="$NPU_MODELS")
echo "frank-dragoon: NPU lane armed: $NPU_MODELS on $DEV_ACCEL (fabric clock held)" >&2 echo "frank-dragoon: NPU lane armed: $NPU_MODELS on $DEV_ACCEL (fabric clock held)" >&2
fi fi
@@ -215,9 +215,10 @@ is in `render`, so a quadlet needs `AddDevice=/dev/accel/accel0` +
`GroupAdd=keep-groups`. **No NPU endpoint is deployed** — stays the `GroupAdd=keep-groups`. **No NPU endpoint is deployed** — stays the
history of the retired endpoint; what changed is only its precondition. history of the retired endpoint; what changed is only its precondition.
**Deliberately deferred:** the `pp_dpm_fclk` pin (level 7, 2000 MHz) from the issue's **The `pp_dpm_fclk` pin (level 7, 2000 MHz) went in on 2026-10-19**, with the first thing that needed it: the
co-run step. Pinning the fabric clock changes iGPU timing for every workload, and vendor's root host unit, installed and enabled by `frank-dragoon`'s deployer while its NPU lane is named. Pinning
nothing co-runs on the NPU yet; it goes in when an NPU endpoint actually exists. the fabric clock changes iGPU timing for every workload, which is exactly why it waited for a co-run to exist; the
law, the hold and the serve-side re-check live in [[frank-dragoon]].
PerfOpt still needs Linux 7.4 — `7.0.0-38-generic` has zero `perfopt` symbols and the PerfOpt still needs Linux 7.4 — `7.0.0-38-generic` has zero `perfopt` symbols and the
26.04 archive caps at `7.0.0-38.38`. 26.04 archive caps at `7.0.0-38.38`.
+17 -8
View File
@@ -219,7 +219,7 @@ created it — earlier topologies in .
- Deploy waits for :7770 to be free: a rootless container binds it at CREATE and exits 126, and a - Deploy waits for :7770 to be free: a rootless container binds it at CREATE and exits 126, and a
leftover Quadlet beside the root unit guarantees the race. leftover Quadlet beside the root unit guarantees the race.
## The NPU lane (present in the image, not enabled) ## The NPU lane (XDNA2, armed by one env var)
XDNA2 executes only vendor-precompiled AIE partition programs — that is the wall the TTS attempt died at XDNA2 executes only vendor-precompiled AIE partition programs — that is the wall the TTS attempt died at
([[Frank TTS]]) — and flash-server ships those programs. `HALOGEN_NPU_MODELS=<names>` starts a second engine **in the ([[Frank TTS]]) — and flash-server ships those programs. `HALOGEN_NPU_MODELS=<names>` starts a second engine **in the
@@ -235,8 +235,10 @@ Embedding contract on that lane: `POST /v1/embeddings`, `model: "qwen3-embedding
(`Instruct: …\nQuery:…`), `base64` accepted, and LangChain's `OpenAIEmbeddings` needs (`Instruct: …\nQuery:…`), `base64` accepted, and LangChain's `OpenAIEmbeddings` needs
`check_embedding_ctx_length=False`. `check_embedding_ctx_length=False`.
Four prerequisites the trio does not satisfy today, from the vendor's own host list — and this box is one of the two Four host prerequisites, each refused **by name** in the serve script and mirrored in `deploy_llm_dragoon` — a
configurations they test (Ubuntu 26.04's XRT 2.21 `libxrt2`+`libxrt-npu2`, the kernel's `amdxdna`, `iommu=pt`): precondition that surfaces as an engine crash-loop is a gate that was never written. All four are satisfied on this
box as of 2026-10-19; the endpoint still runs with the lane **off** until that day's mount fix merges, and the first
armed boot's evidence lands here with it:
1. `--device /dev/accel/accel0` plus `--group-add keep-groups` (the pattern ComfyUI uses for `/dev/kfd`; `dank` is 1. `--device /dev/accel/accel0` plus `--group-add keep-groups` (the pattern ComfyUI uses for `/dev/kfd`; `dank` is
already in `render`). already in `render`).
@@ -248,11 +250,18 @@ configurations they test (Ubuntu 26.04's XRT 2.21 `libxrt2`+`libxrt-npu2`, the k
engine holds the clock itself only when it runs as root with `/sys` mounted; `frank-dragoon` is a **rootless** engine holds the clock itself only when it runs as root with `/sys` mounted; `frank-dragoon` is a **rootless**
`podman run` under `User=dank`, so the path is the vendor's root host unit (`deploy/host/halogen-fabric-clock`) — `podman run` under `User=dank`, so the path is the vendor's root host unit (`deploy/host/halogen-fabric-clock`) —
which is exactly the `pp_dpm_fclk` level-7 pin deferred in [[dankclaw-iommu-translation-off]] with the words "it which is exactly the `pp_dpm_fclk` level-7 pin deferred in [[dankclaw-iommu-translation-off]] with the words "it
goes in when an NPU endpoint actually exists". Held: about a watt. Unheld: the hang. Measured 2026-10-19 nothing goes in when an NPU endpoint actually exists". Held: about a watt. Unheld: the hang. The deployer installs and
is held (fabric DPM lists 400–2000 MHz with no forced level, graphics clock sitting at 857 of 2900 MHz). enables that unit whenever the lane is named, and `halogen-fabric-clock status` reports `held (manual)` here as of
4. `HALOGEN_DOWNLOAD=peonist-ai/halogen-qwen3.8-flash-next` on a boot with a **writable** `/models/npu` — the payload 2026-10-19; the serve script re-reads the same two sysfs files at every start and refuses by name if the top level
mount is `:ro` by law, so the programs land via a throwaway writable run or a dedicated read-write bind for that is not the starred one, because a hold done by hand is a countdown to the next reboot.
one subdirectory; the vendor notes a read-only volume works but re-runs the conversion at every start. 4. The programs are staged under `$FRANK_DRAGOON_NPU_DIR`, which **must sit inside the payload dir**
(`/home/dank/models/halogen-v2-abliterated/npu`): the payload's read-only mount is the container's only handle on
`/models`. A nested bind at `/models/npu` cannot work — the image ships no `/models`, so runc has to `mkdir` the
mountpoint through a read-only parent and dies at container init (measured 2026-10-19: 52 crash-loop restarts with
chat down before the mount shape was fixed). `HALOGEN_NPU_DIR` is no escape; the shipped entrypoint rejects it by
name ("our NPU models live under `/models/npu/<id>/`"). Nothing downloads at boot — `HALOGEN_DOWNLOAD` stays
unset, so the container opens no outbound connection; `scripts/download-frank-dragoon-npu.sh` pins every file by
size and sha256 against the catalog and the payload mount carries the tree in.
Price: the programs and their weights sit in host RAM beside the 62.9 GiB of locked weights ("a few GB in all" for Price: the programs and their weights sit in host RAM beside the 62.9 GiB of locked weights ("a few GB in all" for
the default set; ~1.1 GiB for `embeddinggemma-2`), and **the Flash model decodes slower while the NPU works** — same the default set; ~1.1 GiB for `embeddinggemma-2`), and **the Flash model decodes slower while the NPU works** — same
+23 -1
View File
@@ -469,7 +469,12 @@ $(cat "$TEST_TMP/serve.log")"
run_with_log run_with_log
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"--device $TEST_TMP/accel0"* ]] [[ "$output" == *"--device $TEST_TMP/accel0"* ]]
[[ "$output" == *"-v $TEST_TMP/npu:/models/npu:ro"* ]] # The lane's files ride the payload mount: /models is a read-only bind and
# runc cannot mkdir a nested /models/npu mountpoint through it (measured
# 2026-10-19), so the staging dir lives INSIDE the payload dir and gets no
# -v of its own.
[[ "$output" == *"-v $TEST_TMP:/models:ro"* ]]
[[ "$output" != *":/models/npu"* ]]
[[ "$output" == *"-e HALOGEN_NPU_MODELS=qwen3-embedding-0.6b"* ]] [[ "$output" == *"-e HALOGEN_NPU_MODELS=qwen3-embedding-0.6b"* ]]
for l in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do for l in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
[[ "$output" == *"-v $TEST_TMP/xrt/$l:/opt/xilinx/xrt/lib/$l:ro"* ]] [[ "$output" == *"-v $TEST_TMP/xrt/$l:/opt/xilinx/xrt/lib/$l:ro"* ]]
@@ -479,6 +484,20 @@ $(cat "$TEST_TMP/serve.log")"
[[ "$output" == *"-e HALOGEN_CHECKPOINT=/models/qwen.hgn"* ]] [[ "$output" == *"-e HALOGEN_CHECKPOINT=/models/qwen.hgn"* ]]
} }
@test "the lane refuses a staging dir outside the payload dir and says why" {
# A staging dir outside the payload dir needs a nested bind the read-only
# /models mount cannot host: runc dies at container init, which is AFTER the
# deployer has stopped the old unit — so the shape is refused here, before
# anything is rendered.
podman_stub
good_env
npu_fixture
run_with_log FRANK_DRAGOON_NPU_DIR=/elsewhere/npu
[ "$status" -ne 0 ]
[[ "$output" == *"/elsewhere/npu"*"outside the payload dir"* ]]
[[ "$output" != *"--device"* ]]
}
@test "the lane refuses a model that is not staged and names the fetch script" { @test "the lane refuses a model that is not staged and names the fetch script" {
# The engine cannot fetch these at boot here (/models is read-only and # The engine cannot fetch these at boot here (/models is read-only and
# HALOGEN_DOWNLOAD stays unset), so an un-staged name must stop the unit # HALOGEN_DOWNLOAD stays unset), so an un-staged name must stop the unit
@@ -560,6 +579,9 @@ $(cat "$TEST_TMP/serve.log")"
grep -qE 'download-frank-dragoon-npu\.sh' "$DEPLOYER" grep -qE 'download-frank-dragoon-npu\.sh' "$DEPLOYER"
# The lane's own health gate is a real embedding, not a 200 on /health. # The lane's own health gate is a real embedding, not a 200 on /health.
grep -qE '/v1/embeddings' "$DEPLOYER" grep -qE '/v1/embeddings' "$DEPLOYER"
# Parity with the serve gate: a staging dir outside the payload dir is
# refused before the restart, not left to runc.
grep -qE 'outside the payload dir' "$DEPLOYER"
} }
@test "the fabric-clock host unit is the vendor shape and boots on its own" { @test "the fabric-clock host unit is the vendor shape and boots on its own" {