The overnight spike settled whether frank-tts can run on the Strix Halo NPU. It cannot, and the wall is the loader, not the silicon: AMD's own Linux runtime opens /dev/accel/accel0, succeeds at the GEM ioctls, then declines the NuTTS-Air graph without emitting a single log line — and the one run that did load has never reproduced. The accelerator that does exist is the gfx1151 iGPU behind -DGGML_VULKAN=ON, a flag our own Containerfile.engine writes OFF, and Frank set the target shape to co-tenancy: the engine resident beside frank-dragoon, not a handover. - report/tts-npu-spike-verdict-2026-10-19.md owns the measured result: the exoneration chain, the two arms' real-time bars (12.5 codes/s deployed vs 50.00 for NuTTS-Air), the 65,536-entry speech-token census (ids 151,671-217,206), the host gates (rootless cannot read accel0; dpkg-deb the vendor XRT, never dpkg -i), the iGPU census (18,523 of 116,733 MiB GTT in use, 17 GiB host RAM available, GTT pages are host pages), the two gates that decide co-tenancy (RADV heap for an APU tenant; dragoon's TTFT with a synthesis in flight), and the five-file change surface for the trial. - report/npu-tts-runtime-landscape-2026-10-19.md and report/qwen3-tts-payload-portability-2026-10-19.md are the research slices; both now point at the verdict page instead of ending in loose ends. - The vault page gains "Where the engine computes", so an operator doing a TTS task learns the NPU is a closed door without re-walking it. No fleet change: frank-tts / frank-tts-engine still serve narrator1-3 on CPU. tests: ./tests/run.sh 450/450 green.
12 KiB
TTS on the Strix Halo NPU — spike verdict (2026-10-19)
Question: how can the fleet's TTS engine run on hardware rather than CPU? Answer in one line: not on the NPU — the loader, not the silicon, refuses it; the door that exists is the gfx1151 iGPU behind a build flag we already own.
Spike evidence lives in report/npu-tts-runtime-landscape-2026-10-19.md (which runtimes can
reach XDNA2 at all) and report/qwen3-tts-payload-portability-2026-10-19.md (what the
payload is and where it can be moved). This page owns only the measured spike result and
the model-side contract neither slice has. Throwaway work ran in containers under
/home/dank/spikes/npu-tts on Dankclaw; nothing in the fleet was changed — frank-tts and
frank-tts-engine still serve narrator1|2|3 on CPU.
Legend: [host] live census 2026-10-19 · [spike] measured in the spike containers · [vendor] primary vendor source, with the version it holds for.
1. Verdict
NO-GO on the NPU, and the wall is a loader. The XDNA2 device is healthy by AMD's own instruments; the runtime that owns this model family opens the device and then declines the graph, silently. One run loaded successfully; byte-identical runs never reproduced it, and a path that cannot be reproduced is not a foundation. [spike]
Go on the iGPU, and the lever is in this repo.
dankclaw/voiceservice/Containerfile.engine builds qwentts.cpp (commit 5916bf7) with
-DGGML_VULKAN=OFF written out deliberately beside CUDA/HIP/METAL/SYCL/RPC — the running
container carries libggml-base.so.0.27.0 + libggml-cpu.so.0.27.0 + tts-server and
nothing else. So: -DGGML_VULKAN=ON + --device Vulkan0, plus /dev/dri passthrough in
the engine quadlet (the ComfyUI pattern: AddDevice + GroupAdd=keep-groups + a
world-writable udev rule). The target shape is co-tenancy — the engine resident on the
gfx1151 beside frank-dragoon, not a handover — so §5 carries the census and the two
gates that decide it: the heap RADV offers an APU tenant, and dragoon's TTFT with a
synthesis in flight. Unproven, stated: whether qwentts.cpp's codec ops have Vulkan kernels
for every op. [host] [spike]
Why the iGPU is worth doing at all, given CPU already works: the deployed payload needs only 12.5 codes/s (§2), so this is latency headroom and freed cores, not reviving a dead service. The independent Strix Halo measurement in the portability slice ("use Vulkan exclusively"; NPU decode under 1.5 t/s for an 8B) points the same way.
2. The two arms do not share a real-time bar
| Arm | Talker | Codec | Codes per second of audio | Bar |
|---|---|---|---|---|
Deployed (frank-tts-engine) |
qwen-talker-1.7b-base-Q4_K_M.gguf |
qwen-tokenizer-12hz-Q8_0.gguf |
12.5 | ~13 tok/s [host] |
Spike (amd/neutts-air_rai_1.8.0_npu_4K) |
NuTTS-Air, 24-layer Qwen2 | NeuCodec 24000 / hop 480 |
50.00 | ~50 tok/s [spike] |
Measured from the staged NPU artifact — the contract any future arm must satisfy:
| Fact | Value |
|---|---|
| Vocab | genai_config.json vocab_size 217,652; base BPE 151,643; specials 151,644–151,670 [spike] |
| Speech tokens | 65,536 `< |
| Stop token | `< |
| Input ceiling | 4096 tokens total, input + output [vendor] |
| Frame rate | 50.00 codes/s confirmed across three references (49.96 / 49.99 / 50.00) [spike] |
| Reference encode | librosa.load(sr=16000, mono=True) → [1,1,T] float → codec.encode_code(audio_or_path=…) (vendor NeuTTS.encode_reference); CPU RTF 0.28–0.38 on 8 threads, so codes are a cacheable one-time cost, never a per-request charge. neucodec also needs torchtune; install both --no-deps --target <dir> so a second torch never shadows the venv's. [spike] |
| Pre-encoded voices | /spike/logs/codes-narrator{1,2,3}.npy — 994 / 1,249 / 1,250 codes for 19.90 / 24.98 / 25.00 s [spike] |
Gap that any arm inherits: the voice packs carry no transcript. manifest.json holds
provenance and crop geometry (crop.source_speech_run 98.739–118.636 s of a 22,569 s
audiobook) and no text, and Dankclaw has no ASR at all — whisper, whisper.cpp,
faster-whisper all absent. NuTTS conditioning needs ref_text; do not invent it. [host]
3. Exoneration chain — why the blame is not the hardware
Every row is an instrument, not an inference.
| Probe | Result |
|---|---|
xrt-smi examine |
[0000:c6:00.1] RyzenAI-npu5 aie2p 6x8, XRT 2.25.37 (vendor extract), NPU fw 1.1.2.65, amdxdna 7.0.0-38-generic [spike] |
xrt-smi validate |
PASSED, including throughput [spike] |
pyxrt.device(0) in-container |
constructs — XRT enumerates the device [spike] |
strace -f -e trace=openat on the failing load |
genai_config.json read → model.onnx opened → libxrt_driver_xdna.so.2 loaded → /dev/accel/accel0 opened O_RDWR → GEM ioctls succeed → EP declines, before touching dd_metastate_* or cache/txn_bins.zip [spike] |
dmesg, fuser /dev/accel/accel0 |
silent since boot, no holder — the decline never reaches the kernel [spike] |
Hardlinked mirror dirs of the model tree (rundir*) |
retired, no change — the "metastate shadowing" theory is dead [spike] |
Every logging lever in that build: GENAI_ORT_VERBOSE_LOGGING, GENAI_LOG_ORT_LIB, set_log_options, set_log_callback(callable), RYZENAI_EP_TRACING, RYZENAI_LLM_PROFILE_INIT |
zero lines — the decline path is silent [spike] |
Vendor quicktest |
Session successfully initialized — proves the XRT/driver/device path, not NPU residency: that build falls back to CPU silently. Prove residency with /sys/class/accel/accel0/device/mem_usage during generation. [spike] [vendor] |
No NPU token rate was ever measured, so none is quoted here. The single successful load
(13:17, [load] 0.84s, finite logits, XRT banner at generate time) has not been
reproduced by the byte-identical command since, with the model files pristine by inode and
mtime. [spike]
4. Host gates the spike proved (so they are not re-litigated)
- Rootless containers cannot read
/dev/accel/accel0(root:render0660): the host gid never enters the userns map.--group-add keep-groups,--userns=keep-id,--userns=keep-id:gid=990,--group-add render,--group-add gid:990— all fail. Doors: rootful podman (separate storage), or a world-writable udev rule, which the fleet already does forkfd/renderD*. [spike] - Never
dpkg -ithe vendor XRT deb — it installs a DKMSamdxdnaover the distro driver.dpkg-deb -xthe debs and bind-mount the tree at/opt/xilinx/xrt; the plugin deb is what carrieslibxrt_driver_xdna.so. [spike] - In-tree
amdxdna 0.7.0-38+iommu=ptis enough for context creation; a missing device node is not the modern failure mode. [host] xrt-smiinside a container needslibboost-filesystem1.83.0+libboost-program-options1.83.0(hyphenated Ubuntu names, afterapt update); boost is needed byxrt-smionly, never by the OGA/EP path. [spike]- Headroom is tight and it is host RAM, not a separate pool — see §5 for tonight's census.
A multi-GB NPU partition plus an AIE compile workspace does not fit beside
frank-dragoon. Benign-lookingFailed to alloc dev bo memory, ret -28is that pressure. [host]
5. Target shape: the TTS engine shares the gfx1151 with frank-dragoon
The deployment Frank wants is both resident at once, not a handover. Measured 2026-10-19 (read-only, nothing loaded or unloaded):
| Measure | Value | Reading |
|---|---|---|
| iGPU GTT in use | 18,523 MiB of 116,733 MiB | ≈96 GiB of aperture free; this is the real budget (fleet law) [host] |
| DRM "VRAM" carve-out | 2,048 MiB total, 174 MiB used | what KFD/DRM calls dedicated — not the budget [host] |
| Host RAM | 121 GiB total, 17 GiB available, 79 GiB buff/cache | GTT pages are system pages: an iGPU allocation is host RAM consumed, so the two tenants share one pool [host] |
| TTS engine today | 3.84 GiB RSS, CPU | its device-resident replacement lands in that same pool [host] |
Two gates, in this order, before any deploy:
- Heap gate (unmeasured — the number that decides everything else). What heap
RADV offers a tenant on this APU is not known here:
mesa-vulkan-drivers 26.0.3andradeon_icd.jsonare installed,vulkan-toolsis not. EnumerateVkPhysicalDeviceMemoryPropertiesinside the container that will host the engine — before loading a model. If the device-local heap is the 2 GiB carve-out rather than the aperture, weights + KV + compute buffers fall back allocation-by-allocation and the iGPU win is gone. [inferred] — the fallback behavior is the risk, and the missing number is the gate. - Contention gate.
frank-dragoonis the endpoint the operator session runs on, so the honest measurement with both loaded is dragoon's own TTFT and decode with a synthesis in flight — one request each, no ladder. Expect compute-unit sharing rather than memory pressure: the aperture has room, the shaders do not.
Change surface — the whole edit, so nobody re-derives it:
Containerfile.enginebuild stage: addlibvulkan-dev glslang-dev. It installs onlyca-certificates cmake g++ git maketoday, and ggml's Vulkan backend compiles its shaders at build time — the flag alone fails at configure.-DGGML_VULKAN=OFF→ONin the same cmake block; the flag list is written out explicitly, so this is a one-word diff.- Runtime stage: add
mesa-vulkan-drivers. Without a Vulkan ICD inside the image the device node buys nothing — there is noradeon_icd.jsonin the container today. frank-tts-engine.container:AddDevice=/dev/dri/renderD128+GroupAdd=keep-groups.- Engine argv:
--device Vulkan0— qwentts.cpp's tools take--device <name>at the pinned commit.
Gate 1 is one command: vulkan-tools in a throwaway container off the same base with
/dev/dri passed through, then vulkaninfo and read the VkPhysicalDeviceMemoryProperties
heap sizes — no model, no load, no compute. Every package involved has a candidate on the
host's apt (vulkan-tools 1.4.341, libvulkan-dev 1.4.341, glslang-dev/glslang-tools
16.2.0); the build stage runs on the digest-pinned Ubuntu 24.04 base, whose versions are
older but present.
Constraints that do not relax for a co-tenant: /dev/dri passthrough needs the ComfyUI
pattern (AddDevice + GroupAdd=keep-groups + the world-writable udev rule) because
systemd user-unit spawns carry no supplementary groups; the 64 GiB swap at /swap.img
exists to protect co-tenants, and paging a KV pool is worse than the OOM it prevents;
pp_dpm_fclk reads auto (unheld), which only becomes a hazard if the NPU ever joins.
6. Re-visit condition
Retry the NPU when a Linux loader claims the graph: an onnxruntime-genai-ryzenai
build whose RyzenAI adapter takes the NuTTS-Air Full-Fusion graph on RyzenAI-npu5, or that
model appearing in a runtime's own supported catalog with a Linux runner. Then the
prerequisites are already mapped and priced: the gated NeuCodec encoder for cloned voices,
the ref_text transcripts, the 4096-token prompt budget, and 50 codes/s as the bar.