Compare commits
259
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae4d1c710 | ||
|
|
6a4291f224 | ||
|
|
183a6d5442 | ||
|
|
6d274acc47 | ||
|
|
3344b51fbd | ||
|
|
e82c8b2d06 | ||
|
|
f7083f175d | ||
|
|
75b04dc313 | ||
|
|
2625ad1083 | ||
|
|
9c25b1cccb | ||
|
|
7aa32d1b64 | ||
|
|
576c3421b8 | ||
|
|
e4656cfd7a | ||
|
|
acab4cef22 | ||
|
|
04802b3e48 | ||
|
|
44e55ad172 | ||
|
|
132805a25a | ||
|
|
2406641bc6 | ||
|
|
dbcf8a2c35 | ||
|
|
a5a9c04da2 | ||
|
|
c95ae20bfb | ||
|
|
a0dfa61623 | ||
|
|
4b67c3c4d8 | ||
|
|
7ee831adee | ||
|
|
afad21dda5 | ||
|
|
f430e2264d | ||
|
|
82a2183d40 | ||
|
|
c70f9b3f6d | ||
|
|
32b8901bff | ||
|
|
6d03bbc8e7 | ||
|
|
32b58dd787 | ||
|
|
0a365274b6 | ||
|
|
e531e90278 | ||
|
|
c3ab565d9b | ||
|
|
5dbbfcb8a0 | ||
|
|
02a568fb97 | ||
|
|
1e1527d101 | ||
|
|
9eb85ea1f9 | ||
|
|
96944f0b17 | ||
|
|
33f97cd3b3 | ||
|
|
254fb41fd0 | ||
|
|
3613f69593 | ||
|
|
6bf70c6a64 | ||
|
|
f1b8b60817 | ||
|
|
4ae2bde9ab | ||
|
|
ceb5617a42 | ||
|
|
a7ceda95fd | ||
|
|
a32361ee5e | ||
|
|
ce6b3b1d3b | ||
|
|
382e3eb0b8 | ||
|
|
54a393edab | ||
|
|
a5459b0b1e | ||
|
|
b03931f7ce | ||
|
|
82f6ec55c2 | ||
|
|
536949c18d | ||
|
|
337afac91e | ||
|
|
c0c77590b1 | ||
|
|
441b908937 | ||
|
|
1a1dbac268 | ||
|
|
949d3cb6f4 | ||
|
|
3ce8d62e70 | ||
|
|
38737ab84e | ||
|
|
4437519430 | ||
|
|
3b29e173c2 | ||
|
|
9706f5d2de | ||
|
|
206dc3baf1 | ||
|
|
f5a3badb56 | ||
|
|
3e4b4f24f7 | ||
|
|
86f5903e74 | ||
|
|
bd4ee78811 | ||
|
|
b7b39cdc92 | ||
|
|
09532e5216 | ||
|
|
e1786e7cf2 | ||
|
|
409a8d8b0c | ||
|
|
e83162a2ac | ||
|
|
5cd9a0c4c2 | ||
|
|
a803aa046a | ||
|
|
73f77a13c3 | ||
|
|
dd76fcf5d4 | ||
|
|
1cb28095a7 | ||
|
|
e1760e3316 | ||
|
|
272e06daf6 | ||
|
|
4db985b90d | ||
|
|
44d72b9e78 | ||
|
|
8073d0aa9d | ||
|
|
5c678b804c | ||
|
|
81e0995f7d | ||
|
|
167e6d6de3 | ||
|
|
608b0058e3 | ||
|
|
4043798053 | ||
|
|
521537a34c | ||
|
|
19fdffe8bd | ||
|
|
54a4a4180a | ||
|
|
f7aec7f154 | ||
|
|
299078eaba | ||
|
|
7b2b5b66fe | ||
|
|
e1d4d59773 | ||
|
|
5787c86954 | ||
|
|
acbb539b97 | ||
|
|
49a83ef1d5 | ||
|
|
d012654fc9 | ||
|
|
b45771e87e | ||
|
|
011ceeb344 | ||
|
|
88a9bc02be | ||
|
|
6ac544a7b6 | ||
|
|
33396d2daa | ||
|
|
bdc0613739 | ||
|
|
3e32cb9cd1 | ||
|
|
6e80cd44f0 | ||
|
|
3a04e34414 | ||
|
|
9085a80bd1 | ||
|
|
58184008dd | ||
|
|
b4a1e6069e | ||
|
|
9f997f0841 | ||
|
|
f6298e3501 | ||
|
|
f14efdce1a | ||
|
|
a6fb0209e8 | ||
|
|
92a412e99d | ||
|
|
c238e32d6f | ||
|
|
da5eb968f9 | ||
|
|
b3052e9243 | ||
|
|
3e5a37a21f | ||
|
|
1e4fa14772 | ||
|
|
e6fba5d97e | ||
|
|
ca1475543d | ||
|
|
f83c241668 | ||
|
|
255796b3dc | ||
|
|
b4d7f6e7fe | ||
|
|
a3ff55efd8 | ||
|
|
fad87aa522 | ||
|
|
a3c2465638 | ||
|
|
58dc7a64c1 | ||
|
|
9c386d4f89 | ||
|
|
fca47d25d8 | ||
|
|
1c56dea67a | ||
|
|
96c5a0625c | ||
|
|
8270524edd | ||
|
|
a7329d190f | ||
|
|
5fe04e569f | ||
|
|
bb027265db | ||
|
|
4e551fafd7 | ||
|
|
3d8d18a45c | ||
|
|
c13a2568d4 | ||
|
|
e7a835e41f | ||
|
|
0ecfe830f7 | ||
|
|
ce370a5b28 | ||
|
|
bfe4d185bd | ||
|
|
94a9f3aa31 | ||
|
|
d2810ab610 | ||
|
|
7a8cc83385 | ||
|
|
c4623f1629 | ||
|
|
065a6911f9 | ||
|
|
745d66d60d | ||
|
|
4009f118c1 | ||
|
|
7de343c171 | ||
|
|
139a3cd4fa | ||
|
|
5ffbf19fb2 | ||
|
|
1fd98cab3f | ||
|
|
d61c19d41f | ||
|
|
e6fcf217b4 | ||
|
|
cb86ad8fd7 | ||
|
|
078b550bd8 | ||
|
|
765a8cb7de | ||
|
|
999c4fe440 | ||
|
|
2af2e6ac56 | ||
|
|
fbe54ca1ad | ||
|
|
f073b91033 | ||
|
|
425994515a | ||
|
|
c41384e03d | ||
|
|
127621bdd7 | ||
|
|
c96d0228a4 | ||
|
|
a61308d72e | ||
|
|
ccb805cf0b | ||
|
|
c6e2887f94 | ||
|
|
9eb53ef937 | ||
|
|
688928d489 | ||
|
|
56503dbad1 | ||
|
|
fbeb9701e4 | ||
|
|
650b6e0281 | ||
|
|
b62a05837f | ||
|
|
0367244d2a | ||
|
|
656d9567d5 | ||
|
|
0b4bc60a38 | ||
|
|
a2011f4eb6 | ||
|
|
b02647b992 | ||
|
|
b35269bfa5 | ||
|
|
1aeb7af6df | ||
|
|
cd0be97b39 | ||
|
|
7993a336bf | ||
|
|
1bb6399524 | ||
|
|
31b71a16d6 | ||
|
|
a4edaa7d9d | ||
|
|
aa75b45543 | ||
|
|
31908c623f | ||
|
|
b1fc1113c6 | ||
|
|
9f7457e39e | ||
|
|
687dbfbabe | ||
|
|
961713e92d | ||
|
|
01db29e754 | ||
|
|
ab06e4c221 | ||
|
|
1006e96312 | ||
|
|
7d85fed1c9 | ||
|
|
d8196ce499 | ||
|
|
6f442a14a3 | ||
|
|
afe72781bc | ||
|
|
200039d589 | ||
|
|
91d9864bb0 | ||
|
|
980491668c | ||
|
|
e494617599 | ||
|
|
9809ed9c74 | ||
|
|
634fef242b | ||
|
|
2fe68e69af | ||
|
|
70dd7fbe6d | ||
|
|
2626332828 | ||
|
|
eb4d9910cb | ||
|
|
5cc2b3c25a | ||
|
|
18a29c40d2 | ||
|
|
73a7ec3fc1 | ||
|
|
130e9d739f | ||
|
|
854890a155 | ||
|
|
d9f3d1be1e | ||
|
|
afa3a4521c | ||
|
|
9cc8ca1c88 | ||
|
|
9c05c40aa0 | ||
|
|
63219787af | ||
|
|
e914ecbca6 | ||
|
|
1eebf3b129 | ||
|
|
af5a1f69a2 | ||
|
|
0930510f9b | ||
|
|
af327bcc5e | ||
|
|
fcf94562d1 | ||
|
|
861920797d | ||
|
|
3ef711f146 | ||
|
|
823fd52d0e | ||
|
|
bda011d26f | ||
|
|
b19f1c49d6 | ||
|
|
b1ca4e02fa | ||
|
|
7f65cf0252 | ||
|
|
346e1d22f4 | ||
|
|
53ef121180 | ||
|
|
fa093e617d | ||
|
|
6215f1a6c8 | ||
|
|
8bac97ec87 | ||
|
|
69739237c8 | ||
|
|
e5580abb6f | ||
|
|
ee6d8610b3 | ||
|
|
933420fd29 | ||
|
|
9ffa99902d | ||
|
|
b8f564f3bd | ||
|
|
d42d053abe | ||
|
|
7eed636bd1 | ||
|
|
d9b82b372e | ||
|
|
3d10a4ca4f | ||
|
|
e5b731216c | ||
|
|
ce8d82d4a7 | ||
|
|
02d04bc1f6 | ||
|
|
428ac22736 | ||
|
|
5d5cac4da3 | ||
|
|
3eaba57925 |
No files matched your search
@@ -1,71 +0,0 @@
|
||||
# Tunables for frank-default-small (Tiel-Coder-35B-A3B UD-Q6_K_XL,
|
||||
# qwen35moe hybrid MoE, Dankserver, RTX 3090).
|
||||
#
|
||||
# Runtime: upstream llama.cpp (latest master, CUDA build with
|
||||
# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86) on RTX 3090
|
||||
# 24 GB; Ryzen 7 7700X; 62 GB RAM. Replaced the ik_llama.cpp Ornith
|
||||
# CRACK Q8_0 deployment on 2026-09-01.
|
||||
#
|
||||
# Model: peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF, UD-Q6_K_XL quant
|
||||
# (30 GB). Architecture: qwen35moe — 40 layers, full_attention_interval=4
|
||||
# (10 attention + 30 SSM/Mamba), 256 experts / 8 used, 2 KV heads × 256
|
||||
# dim, embedding 2048, rope dimension_count 64, rope freq_base 10 000 000.
|
||||
# Text-only (no mmproj). Built-in chat template in the GGUF.
|
||||
#
|
||||
# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM.
|
||||
# Non-expert weights (~1.6 GB) fit entirely on the 24 GB card.
|
||||
#
|
||||
# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per slot
|
||||
# × 3 parallel) and 10 attention layers, the KV cache is ~2 GB — the
|
||||
# requested 2 GB VRAM buffer.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The
|
||||
# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
|
||||
# incident). This box is NVIDIA/CUDA, but the rule keeps the env portable.
|
||||
|
||||
# Model file (UD-Q6_K_XL, 30 GB, text-only MoE hybrid).
|
||||
MODEL_DIR=/home/dank/models/tiel-coder-35b
|
||||
MODEL_FILE=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf
|
||||
|
||||
# Upstream llama.cpp llama-server (CUDA build, latest master).
|
||||
# Built with -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86
|
||||
# using the staged CUDA 13.2 toolkit at /home/dank/cuda13-stage.
|
||||
LLAMA_SERVER=/home/dank/llama.cpp/build/bin/llama-server
|
||||
|
||||
ALIAS=frank-default-small
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
|
||||
# Total context budget across all parallel slots. 200700 / 3 = 66 900
|
||||
# per slot. The model's context_length is 262 144, so 66 900 is well
|
||||
# within range.
|
||||
CTX_SIZE=200700
|
||||
PARALLEL=3
|
||||
|
||||
# Expert offload: all MoE expert weights pinned to CPU RAM.
|
||||
CPU_MOE=1
|
||||
|
||||
# All non-expert layers on GPU (auto-fit will determine the exact count;
|
||||
# with --cpu-moe the non-expert footprint is ~1.6 GB, leaving ample room).
|
||||
N_GPU_LAYERS=999
|
||||
|
||||
# KV cache: Q8_0 for both K and V. ~2 GB total for 200700 ctx × 3 slots
|
||||
# × 10 attention layers × (2 KV heads × 256 dim × 1 byte) × 2 (K+V).
|
||||
CACHE_TYPE_K=q8_0
|
||||
CACHE_TYPE_V=q8_0
|
||||
|
||||
VERBOSITY=2
|
||||
|
||||
# Prefill batch sizing.
|
||||
BATCH_SIZE=4096
|
||||
UBATCH_SIZE=1024
|
||||
|
||||
# Sampling.
|
||||
TEMPERATURE=1.0
|
||||
TOP_K=20
|
||||
TOP_P=0.95
|
||||
MIN_P=0.0
|
||||
|
||||
# Compute threads lowered so other processes keep a core.
|
||||
THREADS=7
|
||||
THREADS_HTTP=8
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=llama.cpp Tiel-Coder-35B-A3B Q6_K_XL qwen35moe hybrid MoE (dankserver, frank-default-small)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FRANK_SMALL_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-small.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-small.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,126 +0,0 @@
|
||||
# Tunables for frank-dragoon (kingjones777/Qwen3.8-Flash-Next-ROCmFP4-
|
||||
# STRIX-GGUF on the kingjones30/ROCmFPX llama.cpp runtime, Dankclaw,
|
||||
# Strix Halo gfx1151, unified memory).
|
||||
#
|
||||
# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the
|
||||
# qwen4exp-on-rocmfpx-d3ca537 patch applied — the model card's pinned
|
||||
# runtime: ROCmFP4 tensor types AND the qwen4exp architecture in one
|
||||
# tree; stock llama.cpp and charlie's fork alone cannot load this GGUF).
|
||||
# Built per the card recipe:
|
||||
# cmake -B build -DGGML_HIP=ON -DGPU_TARGETS=gfx1151 -DGGML_NATIVE=ON -DCMAKE_BUILD_TYPE=Release
|
||||
# at /home/dank/GitPackages/kingjones-ROCmFPX/build.
|
||||
# Replaced the agentionai ROCmFP4-FAST imatrix deployment (charlie's
|
||||
# build-qwen38 runtime) on 2026-08-30.
|
||||
#
|
||||
# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used,
|
||||
# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE
|
||||
# at layer 1), Q4_0_ROCMFP4_STRIX (file_type 105), 113.47 GiB single
|
||||
# file. STRIX is the top quality tier: PLE table at Q8_0 (50.66 GiB),
|
||||
# token embeddings + lm head at Q6_K. The PLE table NEVER enters GPU
|
||||
# memory — it streams from the file through the OS page cache. Two hard
|
||||
# card constraints:
|
||||
# 1. mmap MUST stay enabled (default) — --no-mmap forces the 50.66 GiB
|
||||
# table into anonymous memory and the cgroup killer takes the
|
||||
# process with nothing in the server log (only dmesg).
|
||||
# 2. Do NOT pin the PLE tensor to CPU with -ot — measured gen fell
|
||||
# ~23 -> 13.4 tok/s; the kernel streams it better.
|
||||
# NOTE: the card's own build_attn_qsa still calls ggml_fill on the
|
||||
# F16-cast kq mask (the path that aborted our charlie build with
|
||||
# --flash-attn on before scripts/patches/qwen4exp-qsa-f32-mask.patch).
|
||||
# If a clean kingjones-tree build aborts graph build with
|
||||
# GGML_ASSERT(a-type == GGML_TYPE_F32), port that fix (F32 base mask +
|
||||
# cast at the attention call) into this tree and rebuild.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
|
||||
# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as
|
||||
# env vars.
|
||||
|
||||
# kingjones30/ROCmFPX runtime binary (card recipe build).
|
||||
LLAMA_BIN=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server
|
||||
|
||||
# Model package from
|
||||
# https://huggingface.co/kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF
|
||||
# Layout: single unsharded GGUF, text-only (the repo ships no mmproj;
|
||||
# the vision tower below is borrowed from the agentionai FAST package —
|
||||
# same base model, f16 — kept only if it loads clean, drop
|
||||
# MMPROJ_PATH if it does not).
|
||||
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX
|
||||
MODEL_FILE=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf
|
||||
MMPROJ_PATH=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf
|
||||
|
||||
# Qwen-Sharp chat template (tracked in the repo, staged on the host). The
|
||||
# Sharp template drives thinking on by default (medium effort); the server
|
||||
# emits reasoning in DeepSeek format so clients receive reasoning_content
|
||||
# separately instead of literal thinking tags in content.
|
||||
CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja
|
||||
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true,"preserve_reasoning":true}'
|
||||
|
||||
# Alias + network.
|
||||
ALIAS=frank-dragoon
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
|
||||
# Serving: 401408-token context, TWO slots (parallel 2), batch 4096 /
|
||||
# ubatch 512, 16+16 threads, f16 KV, 16 context checkpoints, 8 GiB
|
||||
# prompt cache. --fit off: auto-fit segfaults on this model's
|
||||
# hyper-connection/indexer config; -ngl 999 puts every layer on the
|
||||
# SVM-backed GPU pool. ubatch 512 matches the card's re-measurement
|
||||
# (-ub 1024/2048/4096 showed no effect; -t 32 vs 16 is noise).
|
||||
#
|
||||
# CTX_SIZE=401408 / PARALLEL=2: carried over from the FAST deployment
|
||||
# where it loaded clean (~74 s, health 200). The card measures the full
|
||||
# 262144 window at ~71.7 GiB GTT at load on the same 128 GB board; QSA
|
||||
# caps KV growth so the extra window is cheap. KV footprint scales
|
||||
# with the 12 attention layers only. Verify load on first STRIX boot.
|
||||
CTX_SIZE=401408
|
||||
PARALLEL=2
|
||||
|
||||
BATCH_SIZE=4096
|
||||
UBATCH_SIZE=512
|
||||
NUM_THREADS=16
|
||||
THREAD_BATCH=16
|
||||
CACHE_TYPE_K=f16
|
||||
CACHE_TYPE_V=f16
|
||||
CTX_CHECKPOINTS=16
|
||||
CACHE_RAM=8192
|
||||
SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state
|
||||
SPLIT_MODE=none
|
||||
FIT=off
|
||||
NGL=999
|
||||
FLASH_ATTN=on
|
||||
|
||||
# Sampling (card instruct table, non-thinking).
|
||||
TEMPERATURE=0.7
|
||||
TOP_K=20
|
||||
TOP_P=0.8
|
||||
MIN_P=0.0
|
||||
PRESENCE_PENALTY=1.5
|
||||
|
||||
# Reasoning on (Sharp template drives it; preserve_thinking + preserve_reasoning
|
||||
# keep the reasoning trail in the conversation). DeepSeek format projects the
|
||||
# reasoning into the API's reasoning_content field instead of literal thinking
|
||||
# tags.
|
||||
REASONING=on
|
||||
REASONING_FORMAT=deepseek
|
||||
|
||||
# Strix Halo unified memory + native gfx1151.
|
||||
HSA_OVERRIDE_GFX_VERSION=11.5.1
|
||||
|
||||
VERBOSITY=4
|
||||
|
||||
|
||||
# Speculative decoding: disabled. The card's Usage block ships no spec
|
||||
# flags. The Flash-Next MTP heads do not load on this loader (requires
|
||||
# per-layer blk.N.hc_attn_norm; every head carries only blk.48 —
|
||||
# verified 3x on build-qwen38). DRAFT_MODEL_* point at the retired
|
||||
# agentionai FAST head and are unused while SPEC_TYPE=none.
|
||||
DRAFT_MODEL_DIR=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST
|
||||
DRAFT_MODEL_FILE=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf
|
||||
SPEC_TYPE=none
|
||||
SPEC_DRAFT_N_MAX=3
|
||||
SPEC_DRAFT_N_MIN=0
|
||||
SPEC_DRAFT_P_MIN=0.75
|
||||
SPEC_DRAFT_NGL=999
|
||||
SPEC_DRAFT_DEVICE=ROCm0
|
||||
SPEC_DRAFT_TYPE_K=f16
|
||||
SPEC_DRAFT_TYPE_V=f16
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=ROCmFPX (kingjones30 fork) llama.cpp Qwen3.8-Flash-Next ROCmFP4 STRIX 177B MoE (dankclaw, frank-dragoon)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FRANK_DRAGOON_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-dragoon.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,23 +0,0 @@
|
||||
# Tunables for frank-npu (FastFlowLM NPU endpoint, Dankclaw)
|
||||
# Runtime: FLM 1.0.1 (/usr/bin/flm) on the XDNA2 NPU (50 TOPS). Boot tag is
|
||||
# nuextract3:4b (NuExtract3-4B-NPU2, Qwen3.5-4B skeleton, structured-extraction
|
||||
# model); serve routes every request by the exact FLM registry tag in the
|
||||
# request's model field, so any registered tag (gemma4-it:e4b, qwythos:9b, ...)
|
||||
# is reachable on this port regardless of the boot tag. The NPU is a
|
||||
# single-lane device: generations serialize FIFO, and the first request after a
|
||||
# model swap pays the ~6-11 s load cost. The NPU draws from the unified RAM
|
||||
# pool shared with frank-dragoon (iGPU).
|
||||
FLM_MODEL=nuextract3:4b
|
||||
FLM_HOST=0.0.0.0
|
||||
FLM_PORT=7771
|
||||
# turbo = max NPU power mode (xrt-smi configure --pmode turbo at serve start).
|
||||
# No other tenant uses the NPU on this host, so the top mode costs nothing to
|
||||
# neighbours; drop to `performance` if iGPU power-budget contention ever shows.
|
||||
FLM_PMODE=turbo
|
||||
# FLM_ASR=1 additionally loads Whisper-V3-Turbo-NPU2 (+0.62 GB) and exposes
|
||||
# /v1/audio/transcriptions. NuExtract3 answers text-only; audio stays off.
|
||||
FLM_ASR=0
|
||||
# -1 = no --ctx-len flag: every pack loads with its own trained context
|
||||
# (nuextract3:4b -> 32768, gemma4-it:e4b -> 131072). A global override would
|
||||
# over-allocate KV for the small packs.
|
||||
FLM_CTX_LEN=-1
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=FastFlowLM NPU sight+sound inference (frank-npu) on Dankclaw
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FRANK_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-npu.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-npu.sh
|
||||
LimitMEMLOCK=infinity
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,180 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF as
|
||||
# frank-dragoon via the kingjones30/ROCmFPX llama.cpp runtime on
|
||||
# Dankclaw (Strix Halo gfx1151, unified memory).
|
||||
#
|
||||
# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the
|
||||
# model card's qwen4exp-on-rocmfpx patch) built at
|
||||
# /home/dank/GitPackages/kingjones-ROCmFPX/build (HIP, gfx1151,
|
||||
# ROCm 7.2.4, card recipe). The ROCmFP4 quant types plus the qwen4exp
|
||||
# architecture must be in one tree; stock llama.cpp cannot load this
|
||||
# GGUF.
|
||||
#
|
||||
# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used,
|
||||
# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE
|
||||
# at layer 1) quantized Q4_0_ROCMFP4_STRIX, 113.47 GiB single file. The
|
||||
# 50.66 GiB PLE n-gram table stays OUT of GPU memory and streams via
|
||||
# mmap from the OS page cache — never add --no-mmap or -ot for it.
|
||||
#
|
||||
# Profile: card sampling table (temp 0.7, top_p 0.80, top_k 20, min_p 0.0,
|
||||
# presence 1.5 — non-thinking instruct) mixed with the validated serving
|
||||
# profile: ctx 401408 x 2 slots, b 4096 / ub 512, t 16 / tb 16, f16 KV,
|
||||
# fa on (if the graph build aborts on the QSA F16 mask, see the note in
|
||||
# the env file: port scripts/patches/qwen4exp-qsa-f32-mask.patch into
|
||||
# this tree and rebuild), cont-batching, prompt cache + 8 GiB cache-ram
|
||||
# + idle-slot cache, 16 ctx checkpoints, --fit off (auto-fit segfaults
|
||||
# on qwen4exp hyper-connection config). Speculative decoding off: the
|
||||
# Flash-Next MTP heads do not load on this loader; the card ships no
|
||||
# spec flags.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}"
|
||||
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}"
|
||||
: "${MMPROJ_PATH:=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf}"
|
||||
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
|
||||
: "${CHAT_TEMPLATE_KWARGS:={}}"
|
||||
: "${ALIAS:=frank-dragoon}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
: "${CTX_SIZE:=401408}"
|
||||
: "${PARALLEL:=2}"
|
||||
: "${BATCH_SIZE:=4096}"
|
||||
: "${UBATCH_SIZE:=512}"
|
||||
: "${NUM_THREADS:=16}"
|
||||
: "${THREAD_BATCH:=16}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${CTX_CHECKPOINTS:=16}"
|
||||
: "${CACHE_RAM:=8192}"
|
||||
: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}"
|
||||
: "${SPLIT_MODE:=none}"
|
||||
: "${FIT:=off}"
|
||||
: "${NGL:=999}"
|
||||
: "${FLASH_ATTN:=on}"
|
||||
: "${TEMPERATURE:=0.7}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${TOP_P:=0.8}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${PRESENCE_PENALTY:=1.5}"
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
: "${HSA_OVERRIDE_GFX_VERSION:=11.5.1}"
|
||||
: "${SPEC_TYPE:=none}"
|
||||
: "${DRAFT_MODEL_DIR:=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST}"
|
||||
: "${DRAFT_MODEL_FILE:=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf}"
|
||||
: "${SPEC_DRAFT_N_MAX:=3}"
|
||||
: "${SPEC_DRAFT_N_MIN:=0}"
|
||||
: "${SPEC_DRAFT_P_MIN:=0.75}"
|
||||
: "${SPEC_DRAFT_NGL:=999}"
|
||||
: "${SPEC_DRAFT_DEVICE:=ROCm0}"
|
||||
: "${SPEC_DRAFT_TYPE_K:=f16}"
|
||||
: "${SPEC_DRAFT_TYPE_V:=f16}"
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
for f in "$LLAMA_BIN" "$MODEL" "$CHAT_TEMPLATE_FILE"; do
|
||||
if [[ ! -r "$f" ]]; then
|
||||
echo "missing or unreadable required artifact: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [[ ! -x "$LLAMA_BIN" ]]; then
|
||||
echo "missing executable runtime: $LLAMA_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- speculative decoding (MTP draft head) ---------------------------------
|
||||
# The MTP draft head is a separate GGUF sidecar loaded via --spec-draft-model
|
||||
# (alias --model-draft). ngram-only specs do not need a draft file.
|
||||
# Set SPEC_TYPE=none in the env to disable speculative decoding entirely.
|
||||
SPEC_ARGS=()
|
||||
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
|
||||
SPEC_ARGS=(
|
||||
--spec-type "$SPEC_TYPE"
|
||||
--spec-draft-n-max "$SPEC_DRAFT_N_MAX"
|
||||
--spec-draft-n-min "$SPEC_DRAFT_N_MIN"
|
||||
--spec-draft-p-min "$SPEC_DRAFT_P_MIN"
|
||||
)
|
||||
if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then
|
||||
DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE"
|
||||
if [[ ! -r "$DRAFT_MODEL" ]]; then
|
||||
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
SPEC_ARGS+=(
|
||||
--spec-draft-model "$DRAFT_MODEL"
|
||||
--spec-draft-ngl "$SPEC_DRAFT_NGL"
|
||||
--spec-draft-device "$SPEC_DRAFT_DEVICE"
|
||||
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
|
||||
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
|
||||
)
|
||||
fi
|
||||
if [[ -n "${SPEC_DRAFT_P_SPLIT:-}" ]]; then
|
||||
SPEC_ARGS+=(--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT")
|
||||
fi
|
||||
fi
|
||||
|
||||
# The STRIX repo ships no mmproj; this is the agentionai FAST package's
|
||||
# f16 vision tower for the same base model. Passed only when readable.
|
||||
MMPROJ_ARGS=()
|
||||
if [[ -n "${MMPROJ_PATH:-}" && -r "${MMPROJ_PATH:-}" ]]; then
|
||||
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
|
||||
fi
|
||||
|
||||
SERVER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
mkdir -p "$SLOT_SAVE_PATH"
|
||||
ROCM_PATH="${ROCM_PATH:-/opt/rocm}"
|
||||
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
exec /usr/bin/env \
|
||||
ROCM_PATH="$ROCM_PATH" \
|
||||
HIP_VISIBLE_DEVICES=0 \
|
||||
LD_LIBRARY_PATH="$LIBRARY_PATH" \
|
||||
HSA_OVERRIDE_GFX_VERSION="$HSA_OVERRIDE_GFX_VERSION" \
|
||||
"$LLAMA_BIN" \
|
||||
--model "$MODEL" \
|
||||
--alias "$ALIAS" \
|
||||
--chat-template-file "$CHAT_TEMPLATE_FILE" \
|
||||
--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT" \
|
||||
--jinja \
|
||||
--device ROCm0 \
|
||||
--gpu-layers "$NGL" \
|
||||
--split-mode "$SPLIT_MODE" \
|
||||
--parallel "$PARALLEL" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--fit "$FIT" \
|
||||
--flash-attn "$FLASH_ATTN" \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--threads "$NUM_THREADS" \
|
||||
--threads-batch "$THREAD_BATCH" \
|
||||
--ctx-checkpoints "$CTX_CHECKPOINTS" \
|
||||
--cache-ram "$CACHE_RAM" \
|
||||
--slot-save-path "$SLOT_SAVE_PATH" \
|
||||
--cache-prompt \
|
||||
--cache-idle-slots \
|
||||
--metrics \
|
||||
--temp "$TEMPERATURE" \
|
||||
--top-p "$TOP_P" \
|
||||
--top-k "$TOP_K" \
|
||||
--min-p "$MIN_P" \
|
||||
--presence-penalty "$PRESENCE_PENALTY" \
|
||||
--reasoning "$REASONING" \
|
||||
--reasoning-format "$REASONING_FORMAT" \
|
||||
--reasoning-budget -1 \
|
||||
"${MMPROJ_ARGS[@]}" \
|
||||
"${SPEC_ARGS[@]}"
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve the FastFlowLM (FLM) sight+sound endpoint — frank-npu.
|
||||
#
|
||||
# Runs `flm serve <tag>` on the XDNA2 NPU (50 TOPS, Dankclaw). The NPU is a
|
||||
# single-lane device: generations serialize in FIFO order, so this is a
|
||||
# background/triage endpoint, not an interactive multi-user chat. The NPU
|
||||
# draws from the unified RAM pool shared with frank-dragoon (the iGPU
|
||||
# llama.cpp service); see serverAppConfigs-docs/LLM/frank-npu.md
|
||||
# for the memory budget and the model-selection tradeoff.
|
||||
#
|
||||
# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity.
|
||||
# NOTE: an unknown model tag does NOT 404 — /v1/chat/completions answers HTTP 200 with a
|
||||
# flat {"error":"Failed to load <tag> model!"} body and no choices; /v1/completions returns a
|
||||
# nested OpenAI-shaped server_error (code 500) also under HTTP 200. Client model IDs must be the exact FLM registry
|
||||
# tag (e.g. gemma4-it:e4b), never a service alias. Custom tags only resolve
|
||||
# when FLM_CONFIG_PATH points at the user registry that carries them and their
|
||||
# kernels are symlinked into /home/dank/.config/flm/xclbins/<PackName>.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_NPU_ENV_FILE:-$SCRIPT_DIR/frank-npu.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${FLM_BIN:=/usr/bin/flm}"
|
||||
: "${FLM_MODEL:=gemma4-it:e4b}"
|
||||
: "${FLM_HOST:=0.0.0.0}"
|
||||
: "${FLM_PORT:=7771}"
|
||||
: "${FLM_PMODE:=turbo}"
|
||||
: "${FLM_ASR:=0}"
|
||||
: "${FLM_CTX_LEN:=131072}"
|
||||
|
||||
# The user registry is a superset of the system registry (it carries the
|
||||
# custom tags too), and its model_path resolves relative to the registry file,
|
||||
# so exports keep a tag swap in the env file from needing script edits.
|
||||
export FLM_CONFIG_PATH="${FLM_CONFIG_PATH:-/home/dank/.config/flm/model_list.json}"
|
||||
export FLM_XCLBIN_PATH="${FLM_XCLBIN_PATH:-/home/dank/.config/flm/xclbins}"
|
||||
|
||||
if [[ ! -x "$FLM_BIN" ]]; then
|
||||
echo "missing flm binary: $FLM_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- optional flags ----------------------------------------------------------
|
||||
OPT_ARGS=()
|
||||
if [[ "${FLM_ASR}" != "0" ]]; then
|
||||
OPT_ARGS+=(--asr "${FLM_ASR}")
|
||||
fi
|
||||
if [[ "${FLM_CTX_LEN}" != "-1" ]]; then
|
||||
OPT_ARGS+=(--ctx-len "${FLM_CTX_LEN}")
|
||||
fi
|
||||
|
||||
exec "$FLM_BIN" serve "$FLM_MODEL" \
|
||||
--host "$FLM_HOST" \
|
||||
-p "$FLM_PORT" \
|
||||
--pmode "$FLM_PMODE" \
|
||||
"${OPT_ARGS[@]}"
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve peculiar-ragdoll/Tiel-Coder-35B-A3B (UD-Q6_K_XL) as
|
||||
# frank-default-small via stock llama.cpp on Dankserver (RTX 3090).
|
||||
#
|
||||
# Runtime: upstream llama.cpp (latest master, CUDA build with
|
||||
# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86).
|
||||
# The model is a 35B-total / 8-active MoE hybrid (qwen35moe arch) with
|
||||
# SSM/Mamba layers (full_attention_interval=4: 10 attention + 30 SSM
|
||||
# layers out of 40). 256 experts, 8 used per token.
|
||||
#
|
||||
# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM.
|
||||
# Non-expert weights (attention, SSM, shared expert, embeddings, output)
|
||||
# are ~1.6 GB and fit entirely on the 24 GB RTX 3090.
|
||||
#
|
||||
# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per
|
||||
# slot × 3 parallel) and 10 attention layers (2 KV heads × 256 dim),
|
||||
# the KV cache is ~2 GB — the requested 2 GB VRAM buffer.
|
||||
#
|
||||
# Model is loaded with mmap (default). The 30 GB Q6_K_XL file is
|
||||
# memory-mapped; only accessed pages enter RAM.
|
||||
#
|
||||
# Tunables come from frank-default-small.env unless FRANK_SMALL_ENV_FILE
|
||||
# overrides that path.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_SMALL_ENV_FILE:-$SCRIPT_DIR/frank-default-small.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -----------------
|
||||
: "${MODEL_DIR:=/home/dank/models/tiel-coder-35b}"
|
||||
: "${MODEL_FILE:=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf}"
|
||||
: "${LLAMA_SERVER:=/home/dank/llama.cpp/build/bin/llama-server}"
|
||||
: "${ALIAS:=frank-default-small}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
: "${CTX_SIZE:=200700}"
|
||||
: "${PARALLEL:=3}"
|
||||
: "${CACHE_TYPE_K:=q8_0}"
|
||||
: "${CACHE_TYPE_V:=q8_0}"
|
||||
: "${N_GPU_LAYERS:=999}"
|
||||
: "${BATCH_SIZE:=4096}"
|
||||
: "${UBATCH_SIZE:=1024}"
|
||||
: "${VERBOSITY:=2}"
|
||||
: "${CPU_MOE:=1}"
|
||||
: "${TEMPERATURE:=1.0}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${TOP_P:=0.95}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${THREADS:=7}"
|
||||
: "${THREADS_HTTP:=8}"
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
|
||||
if [[ ! -f "$MODEL" ]]; then
|
||||
echo "missing required file: $MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$LLAMA_SERVER" ]]; then
|
||||
echo "missing llama-server binary: $LLAMA_SERVER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$LLAMA_SERVER" \
|
||||
--model "$MODEL" \
|
||||
$([ "$CPU_MOE" = "1" ] && printf -- "--cpu-moe") \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--parallel "$PARALLEL" \
|
||||
--gpu-layers "$N_GPU_LAYERS" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--temp "$TEMPERATURE" \
|
||||
--top-k "$TOP_K" \
|
||||
--top-p "$TOP_P" \
|
||||
--min-p "$MIN_P" \
|
||||
--threads "$THREADS" \
|
||||
--threads-http "$THREADS_HTTP" \
|
||||
--alias "$ALIAS" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT"
|
||||
@@ -26,8 +26,8 @@ merge absorbed the former `vllmScripts` repo, history preserved).
|
||||
| Target | Service | Runtime | Payload | Port |
|
||||
|--------|---------|---------|---------|------|
|
||||
| Dankserver | `frank-archon` | radiance, digest-pinned, root unit, TP=2 | publisher `qwen3.8-next-flash-fp8-iq4r-moe.rad` | 7770 |
|
||||
| Dankclaw | `frank-dragoon` | halogen-flash-server 0.16.2, digest-pinned, root unit | `qwen38-flash-next-v2-abliterated.hgn` + n-gram + vision tower | 7770 |
|
||||
| Dankclaw | `frank-zealot` | llama.cpp PrismML fork build 10777, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj | 7771 |
|
||||
| Dankclaw | `frank-dragoon` | halogen-flash-server 0.18.20, digest-pinned, root unit | `qwen38-flash-next-v2-abliterated.hgn` + n-gram + vision tower | 7770 |
|
||||
| Dankclaw | `frank-zealot` | llama.cpp PrismML fork build 12118, root unit — the only loader for this payload | Ternary-Bonsai-2-9B PQ2_0 + Q8_0 mmproj + rank-1 refusal adapter | 7771 |
|
||||
|
||||
Payload identity, shape, eras, laws and rollback live in ONE page each:
|
||||
[[frank-archon]], [[frank-dragoon]], [[frank-zealot]]. `scripts/<service>.env`
|
||||
@@ -156,6 +156,15 @@ Unit requirements:
|
||||
(`<host>/<svc>/quadlet/`), is installed by the deployer, and is covered by
|
||||
the quadlet test seam. The host-side unit is generated state; the repo file
|
||||
is the source of truth.
|
||||
- **Bind-source gate** — a unit whose `Volume=` source is a mount this host owns
|
||||
(the entropy array, the metadata view) carries
|
||||
`ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh <source>`.
|
||||
Measured 2026-10-31: in a **rootless** unit `RequiresMountsFor=` is inert (the
|
||||
user manager cannot order against the system's mount unit), and
|
||||
`ConditionPathIsMountPoint=` is fail-*dead* — an unmet condition logs `skipped,
|
||||
unmet condition check` with `NRestarts=0` and no start attempt ever follows.
|
||||
Bind the array before it is mounted and the container sees the empty pre-mount
|
||||
directory as truth; a media scan then deletes the library it cannot see.
|
||||
|
||||
Before declaring a service deployed: `systemctl --user is-enabled <name>` →
|
||||
`enabled`, `is-active` → `active`, and the container answers its health check.
|
||||
@@ -166,19 +175,22 @@ bare compose container never returned; 2026-09-11: five observability
|
||||
services had no units). The observability services now have units under
|
||||
`dankclaw/observability/quadlet/` and their compose definitions were removed —
|
||||
one owner per container name. The stack is **configured and delivering as of
|
||||
2026-09-17**: a Grafana datasource and the `llm-fleet` dashboard are
|
||||
provisioned from `shared/grafana/provisioning/`, `shared/prometheus/rules/
|
||||
llm-fleet.yml` loads 5 alert rules + 1 recording rule, and firing rules are
|
||||
2026-09-17**: Grafana datasources (Prometheus and Loki) and the `llm-fleet`
|
||||
dashboard are provisioned from `shared/grafana/provisioning/`, and
|
||||
`shared/prometheus/rules/llm-fleet.yml` loads two rule groups — `llm-fleet`
|
||||
(llama.cpp/halogen, `llamacpp:*`) and `vllm-fleet` (radiance, `vllm:*`) — alert
|
||||
+ recording rules, and firing rules are
|
||||
delivered: Prometheus → Alertmanager (`dankclaw/observability/quadlet/
|
||||
alertmanager.container`, loopback :9093) → host-side Signal relay
|
||||
(`scripts/signal-relay.py`, user unit, 192.168.0.143:18093 — the host LAN
|
||||
IP, the one bind a container can reach as `host.containers.internal`;
|
||||
iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311.
|
||||
Prometheus targets read down for structural reasons (HTML-scraped "probes",
|
||||
Gitea metrics off); the remedy is a blackbox exporter. `frank-zealot` was down for
|
||||
a different reason until 2026-10-18: llama.cpp will not serve `/metrics` at all
|
||||
without `--metrics`. See
|
||||
`serverAppConfigs-docs/Infrastructure/Observability.md`.
|
||||
The former structural target downs (HTML-scraped "probes", Gitea metrics off)
|
||||
are retired as of 2026-10-21: HTTP liveness rides a blackbox exporter
|
||||
(`shared/blackbox/blackbox.yml`), Gitea serves `/metrics` from its quadlet, and
|
||||
every Prometheus job reports up. `frank-zealot` was down for a different reason
|
||||
until 2026-10-18: llama.cpp will not serve `/metrics` at all without
|
||||
`--metrics`. See `serverAppConfigs-docs/Infrastructure/Observability.md`.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -290,8 +302,8 @@ means off or NIC dead; a merely wedged kernel usually still answers ARP.
|
||||
2. Bench against the service itself. No scratch ports on an LLM host — one
|
||||
process owns the card. Use the runtime's own harness (`llama-bench`; a
|
||||
live llama.cpp server also reports honest `timings` per request, including
|
||||
`draft_n`/`draft_n_accepted` — but on frank-zealot's PrismML fork build
|
||||
10777 that object only reaches you on **`/v1/chat/completions`**: a greedy
|
||||
`draft_n`/`draft_n_accepted` — but on frank-zealot's PrismML fork that object
|
||||
only reaches you on **`/v1/chat/completions`** (measured on build 10777): a greedy
|
||||
`ignore_eos` run on the raw `/completion` path there answers **500 "The
|
||||
model produced output that does not match the expected Content-only
|
||||
format"** (measured 2026-10-18). halogen has no `/completion` path at all,
|
||||
@@ -417,16 +429,17 @@ disagrees with it is the bug.
|
||||
digest-pinned `ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`.
|
||||
Engine stdout (the boot ledger: weights, KV pool, clamp) goes to
|
||||
`/home/dank/logs/frank_dragoon.log`, NOT the journal.
|
||||
- Shape: `HALOGEN_CTX=200704` is the **per-request ceiling** (a longer prompt
|
||||
is a 400, never a truncation); `HALOGEN_KV_SLOTS=2` over ONE
|
||||
`HALOGEN_KV_POOL_POSITIONS=401408` pool — pool positions buy concurrency,
|
||||
the per-request window never moves; `HALOGEN_SPEC_ADAPT=0` keeps the MTP
|
||||
drafter always armed (the adaptive default disarms it and halves decode);
|
||||
server sampler 1.0/0.95/20.
|
||||
- Harness contract: `models.yml` `contextWindow: 155648` = ceiling − 45,056.
|
||||
The clamp is `min(45056, window − est − 4096)` and the chars/4 estimate
|
||||
under-counts, so a harness window equal to the ceiling makes every long
|
||||
request a guaranteed 400. Reasoning arrives in `reasoning_content`; the
|
||||
- Shape: `HALOGEN_CTX=262144` (the checkpoint's native window) is the
|
||||
**per-request ceiling** (a longer prompt is a 400, never a truncation);
|
||||
`HALOGEN_KV_SLOTS=2` over ONE `HALOGEN_KV_POOL_POSITIONS=401408` pool —
|
||||
pool positions buy concurrency, the per-request window never moves;
|
||||
`HALOGEN_SPEC_ADAPT=0` keeps the MTP drafter always armed (the adaptive
|
||||
default disarms it and halves decode); server sampler 1.0/0.95/20.
|
||||
- Harness contract: `models.yml` `contextWindow: 200000` — ~45k+ under the
|
||||
ceiling. The clamp is `min(45056, window − est − 4096)` and the chars/4
|
||||
estimate under-counts (trigger is 95 %), so a harness window at the ceiling
|
||||
makes every long request a guaranteed 400.
|
||||
Reasoning arrives in `reasoning_content`; the
|
||||
template rejects `reasoning_effort` outside `xhigh|medium|low`; images are
|
||||
OpenAI `image_url` parts (probe ≥256 px); `response_format` unsupported;
|
||||
`/completion`, `/ping`, `/props`, `/tokenize` are 404.
|
||||
@@ -435,12 +448,16 @@ disagrees with it is the bug.
|
||||
### frank-zealot — llama.cpp (PrismML fork), RTX 3090, :7771
|
||||
|
||||
- Owns: `scripts/frank-zealot.env`, `scripts/serve-frank-zealot.sh`,
|
||||
`scripts/frank-zealot.service`; fork build `llama-prism-b10777` is the ONLY
|
||||
loader (the payload carries the fork's custom PQ2_0 tensor type plus
|
||||
`scripts/frank-zealot.service`; fork build `llama-prism-0244744` (build 12118)
|
||||
is the ONLY loader — `llama-prism-b10777` stays on disk as its rollback (the payload carries the fork's custom PQ2_0 tensor type plus
|
||||
`prism.hadamard.*` dequant metadata — stock llama.cpp refuses it, and a
|
||||
loader ignoring that metadata dequantizes wrong); payload
|
||||
`Bonsai2-9B-MTP/…PQ2_0.gguf` + the publisher's **Q8_0** mmproj (the BF16
|
||||
tower can crash the server with concurrent image requests).
|
||||
tower can crash the server with concurrent image requests) + the rank-1
|
||||
refusal-direction adapter applied at load by `--lora-scaled …:1`. The
|
||||
adapter's rollback is `LORA_FILE=` (empty) in the env — one line, no rebuild
|
||||
— and the fork BUILD is also the gate that lets it load: anything older than
|
||||
b10777 refuses it with `unsupported prism.hadamard.version: 2`.
|
||||
- Shape: `--ctx-size 602112 --parallel 3` = **three conversations at
|
||||
200,704 each**, `CACHE_TYPE_K=f16` + `CACHE_TYPE_V=q8_0` (asymmetric: 25,088
|
||||
B/token, 14,406 MiB of pool), batch 6144 / ubatch 1024,
|
||||
@@ -458,12 +475,15 @@ disagrees with it is the bug.
|
||||
- Owns: `scripts/frank-archon.env`, `scripts/serve-frank-archon.sh`,
|
||||
`scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template
|
||||
`scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned
|
||||
`stilldeadcode/radiance@sha256:4c3f4845…`; container name = unit name.
|
||||
- Shape: TP=2 over `wht6`, `--max-num-seqs 3`, `--max-model-len 200000`,
|
||||
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, VRAM pair
|
||||
`--vram-weights-mib 25600 --vram-kv-mib 5120` (ONE budget: weights + kv ≤
|
||||
30,969 MiB/card), `fp8` KV, MTP depth 2, MNBT 2048, prefix cache 8 GiB host
|
||||
+ 64 GiB disk, n-gram table pinned to RAM.
|
||||
`stilldeadcode/radiance@sha256:d590e300…` (radiance 1.8.1); container name = unit name.
|
||||
- Shape: TP=2 over `wht6`, `--max-num-seqs 2`, `--max-model-len 200000`,
|
||||
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, `fp8` KV,
|
||||
MTP depth 2, MNBT 2048, `--busy-prefill-chunk auto`, prefix cache 8 GiB host
|
||||
tier only, n-gram table pinned to RAM. The per-card weights/KV split is derived
|
||||
by the engine's budget resolver from `--gpu-headroom-mib`, the cache bound and
|
||||
the host pool; the `--vram-weights-mib`/`--vram-kv-mib` pair and the prefix disk
|
||||
tier are gone (removed at 1.3.0 / 1.8.1) and naming either is a boot refusal,
|
||||
not a no-op.
|
||||
- Harness/gate contract: `models.yml` `contextWindow` MUST quote 200,000
|
||||
(over-length is refused, never truncated); the ~147 GiB pinned-RAM ledger
|
||||
is why this is a root unit, and `ttm-preflight.sh` raises
|
||||
@@ -730,6 +750,13 @@ linked runbooks.
|
||||
`/etc/default/grub`. It replaced `amd_iommu=off`, and the local A/B on byte-identical traffic
|
||||
says identity mode costs nothing, so the XDNA2 NPU returned for free.
|
||||
[[dankclaw-iommu-translation-off]]
|
||||
- **A deploy handler that only `start`s a quadlet no-ops an image bump.**
|
||||
`systemctl --user start` on an already-active unit does nothing, so installing
|
||||
a new digest under a running service keeps the OLD container serving while the
|
||||
deploy, its health gate and the Playwright suite all pass — the authentik
|
||||
handler did exactly that on 2026-10-30 (2026.8.3 → 2026.8.5 moved only the
|
||||
outpost, the one handler that restarted). A pin change is applied by
|
||||
`restart`, never `start`.
|
||||
- **Rootless Quadlet containers run with `--replace --rm`**: `podman logs` covers only the CURRENT
|
||||
instance; history across recreations lives in `journalctl --user -u <name>.service`. Read
|
||||
rootless storage WITHOUT sudo — it is per-user storage, so `sudo podman logs` finds nothing.
|
||||
@@ -789,6 +816,8 @@ per-attempt page and no per-swap ledger anywhere else.
|
||||
| `frank-tts` — cloned-voice speech, Dankclaw | `serverAppConfigs-docs/Dankclaw/Frank TTS.md` |
|
||||
| Dankclaw kernel posture (`iommu=pt` since 2026-10-18) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
|
||||
| Deployment flow | `serverAppConfigs-docs/Architecture/Deployment Flow.md` |
|
||||
| Movie-library relayout (Jellyfin format, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Relayout.md` |
|
||||
| SSD-backed metadata view (Jellyfin writes, Plex reads, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Metadata View.md` |
|
||||
|
||||
There is no page for a retired endpoint and no change diary. A payload swap
|
||||
appends one row to the live endpoint's **Eras** table; when the endpoint itself
|
||||
@@ -796,6 +825,20 @@ dies, its page is destroyed. Rollback is a commit, not a document.
|
||||
`serverAppConfigs-docs/index.md` is the locator for every page that survives;
|
||||
retired scripts live in `old/` and `.AGENTTRASH/`.
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues live in Gitea (`dank/serverAppConfigs` on Dankclaw), via its REST API with the stored credential. See `serverAppConfigs-docs/Agent/Issue Tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
The five canonical roles keep their default strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `serverAppConfigs-docs/Agent/Triage Labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: root `GLOSSARY.md` + `docs/adr/`, created lazily; this vault stays the owner of operational facts. See `serverAppConfigs-docs/Agent/Domain Docs.md`.
|
||||
|
||||
## Why
|
||||
|
||||
Direct edits on the remote drift from version control, and future agents
|
||||
@@ -816,5 +859,7 @@ history.
|
||||
| Writing a doc | **One owner per fact** — edit the endpoint/service page; everywhere else links. Never restate a number. |
|
||||
| Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. |
|
||||
| Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. |
|
||||
| Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. |
|
||||
| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Not yet deployed on Dankserver: the live quadlets still bind the array read-only — see [[Media Metadata View]].** |
|
||||
| Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. |
|
||||
| Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. |
|
||||
@@ -162,6 +162,25 @@ marreta.franksdank.cloud {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Open Notebook (guarded: Anubis → Authentik; notebook-proxy provider, internal_host caddy:8017) ---
|
||||
# The podcast generator. ONE upstream carries the whole app: the image's own
|
||||
# Next.js layer rewrites /api/* to the FastAPI beside it (INTERNAL_API_URL,
|
||||
# default http://localhost:5055 inside the container), so a browser on this
|
||||
# vhost never needs the API port. That is why scripts/open-notebook.env sets
|
||||
# API_URL to exactly this origin — left unset, the app auto-detects
|
||||
# `<host-you-opened>:5055`, and 5055 on this host belongs to seerr — and why
|
||||
# the published API port sits on loopback. App-level login
|
||||
# (OPEN_NOTEBOOK_PASSWORD) stays on behind the chain: two owners, edge identity
|
||||
# and the app's own session.
|
||||
# Dialled by published LAN port, not container name: open-notebook is a
|
||||
# rootless quadlet and this Caddy lives in root's storage, so root's DNS does
|
||||
# not see the container (the same reason marreta and SearXNG are dialled by IP).
|
||||
notebook.franksdank.cloud {
|
||||
reverse_proxy anubis:8923 {
|
||||
header_up X-Real-Ip {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Pi-hole (quadlet on slirp4netns; published 8083) ---
|
||||
pihole.franksdank.cloud {
|
||||
basicauth {
|
||||
@@ -420,6 +439,16 @@ http://:8017 {
|
||||
handle @marreta {
|
||||
reverse_proxy 192.168.0.143:8082
|
||||
}
|
||||
|
||||
@notebook host notebook.franksdank.cloud
|
||||
handle @notebook {
|
||||
# Bare proxy, like ai.franksdank.cloud: the app's SSE answers
|
||||
# (/api/search/ask, chat sessions) stream for minutes through this
|
||||
# same listener with no flush or timeout tuning, so nothing here
|
||||
# needs it — a read/write timeout on the route would be the thing
|
||||
# that cuts them.
|
||||
reverse_proxy 192.168.0.143:8502
|
||||
}
|
||||
}
|
||||
|
||||
# --- Optional: Jackett ---
|
||||
|
||||
@@ -23,7 +23,7 @@ Network=dankclaw_default
|
||||
# Image=ghcr.io/goauthentik/ldap:2026.8.3 (amd64 digest; podman 5.7.0 refuses
|
||||
# the tag's nested manifest list, so pin the single-platform digest).
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
Image=ghcr.io/goauthentik/ldap@sha256:2e7b259eb8c37faac916946ddf48ec681ee9d3a9f724fe8521bf161973f13cf9
|
||||
Image=ghcr.io/goauthentik/ldap:2026.8.5@sha256:4c75bd27918418d2d6fbbfe4581bbd5a199fe38942d24559c4483edd64a1ea87
|
||||
Environment=AUTHENTIK_HOST=https://auth.franksdank.cloud
|
||||
Environment=AUTHENTIK_INSECURE=false
|
||||
Environment=AUTHENTIK_TOKEN=${AUTHENTIK_LDAP_OUTPOST_TOKEN}
|
||||
|
||||
@@ -20,7 +20,7 @@ Wants=authentik-server.service
|
||||
|
||||
[Container]
|
||||
ContainerName=authentik-outpost
|
||||
Image=ghcr.io/goauthentik/server:2026.8.3
|
||||
Image=ghcr.io/goauthentik/server:2026.8.5@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
|
||||
Exec=worker
|
||||
Network=dankclaw_default
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
|
||||
@@ -14,7 +14,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=authentik-postgres
|
||||
Image=docker.io/library/postgres:16-alpine
|
||||
Image=docker.io/library/postgres:16-alpine@sha256:0cefaabd882e42bcb2a2f3dc56d3ce4770560ba62fd7f197f1a2e008fd5731ab
|
||||
Network=dankclaw_default
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
Environment=POSTGRES_DB=authentik
|
||||
|
||||
@@ -19,7 +19,7 @@ Wants=authentik-postgres.service
|
||||
|
||||
[Container]
|
||||
ContainerName=authentik-server
|
||||
Image=ghcr.io/goauthentik/server:2026.8.3
|
||||
Image=ghcr.io/goauthentik/server:2026.8.5@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
|
||||
Exec=server
|
||||
Network=dankclaw_default
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
|
||||
@@ -16,7 +16,7 @@ Wants=authentik-server.service
|
||||
|
||||
[Container]
|
||||
ContainerName=authentik-worker
|
||||
Image=ghcr.io/goauthentik/server:2026.8.3
|
||||
Image=ghcr.io/goauthentik/server:2026.8.5@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
|
||||
Exec=worker
|
||||
Network=dankclaw_default
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
|
||||
|
||||
@@ -15,7 +15,7 @@ After=network.target
|
||||
[Container]
|
||||
ContainerName=gitea-postgres
|
||||
Network=gitea.network
|
||||
Image=docker.io/library/postgres:16-alpine
|
||||
Image=docker.io/library/postgres:16-alpine@sha256:0cefaabd882e42bcb2a2f3dc56d3ce4770560ba62fd7f197f1a2e008fd5731ab
|
||||
EnvironmentFile=/home/dank/Containers/gitea/env
|
||||
Environment=POSTGRES_USER=gitea
|
||||
Environment=POSTGRES_PASSWORD=${GITEA_DB_PASSWORD}
|
||||
|
||||
@@ -19,7 +19,7 @@ After=gitea-postgres.service network.target
|
||||
[Container]
|
||||
ContainerName=gitea-server
|
||||
Network=gitea.network
|
||||
Image=docker.io/gitea/gitea:latest
|
||||
Image=docker.io/gitea/gitea:latest@sha256:30777c91280d973fa02be48b1ade735bd6859e8f6f7af3b4444d519437852504
|
||||
EnvironmentFile=/home/dank/Containers/gitea/env
|
||||
Environment=DATABASE_TYPE=postgres
|
||||
Environment=DATABASE_HOST=gitea-postgres
|
||||
@@ -29,6 +29,13 @@ Environment=DATABASE_PASSWD=${GITEA_DB_PASSWORD}
|
||||
Environment=DATABASE_PORT=5432
|
||||
Environment=USER_UID=1000
|
||||
Environment=USER_GID=1000
|
||||
# Prometheus scrapes /metrics over the published LAN port (job "gitea" in
|
||||
# shared/prometheus/prometheus.yml). Off, /metrics is a 404 and the target is
|
||||
# permanently red (the audit's "disabled metrics" class). 0.0.0.0 binds inside
|
||||
# the container; the only route in is this host's published 3000, and the
|
||||
# exposed surface is counters only — same posture as the LLM /metrics.
|
||||
Environment=GITEA__metrics__ENABLED=true
|
||||
Environment=GITEA__metrics__ENABLED_HOST=0.0.0.0
|
||||
PublishPort=0.0.0.0:3000:3000
|
||||
PublishPort=2222:22
|
||||
Volume=/home/dank/Containers/gitea/data:/data
|
||||
|
||||
@@ -25,7 +25,7 @@ Description=Chrome DevTools Protocol daemon (headless-shell) for hermes
|
||||
[Container]
|
||||
ContainerName=chrome-devtools-protocol
|
||||
Network=host
|
||||
Image=docker.io/chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04
|
||||
Image=docker.io/chromedp/headless-shell@sha256:7bee201478b2f6266aae01243740ee85c16281e32f42d3aed9fdf04f4eace7a1
|
||||
Entrypoint=/headless-shell/headless-shell
|
||||
Exec=--no-sandbox --disable-gpu --use-gl=angle --use-angle=swiftshader --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222
|
||||
ShmSize=1073741824
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/sh
|
||||
# halogen-fabric-clock hold | release | status: the AMD GPU's fabric clock (FCLK), held at its top level.
|
||||
#
|
||||
# On Strix Halo the GPU and the NPU working at the same time can hang the machine, and corrupt the NPU's results,
|
||||
# while the GPU's fabric clock changes speed; held at its top level, the two run together cleanly. halogen-flash-server
|
||||
# with HALOGEN_NPU_MODELS refuses to start until the clock is held. This holds it: the performance level `manual` and
|
||||
# only the top pp_dpm_fclk level, on every amdgpu device that has the control (the GPU and SoC clocks stay automatic).
|
||||
# Cost: about 1 W at idle. Run as root; halogen-fabric-clock.service runs `hold` at every boot and after a resume.
|
||||
#
|
||||
# hold hold it (waits up to 60 s for the GPU driver to come up)
|
||||
# release give it back to the driver (the performance level `auto`)
|
||||
# status print each device's state; exit 0 when every device is held
|
||||
set -u
|
||||
SYS="${HALOGEN_SYSFS:-/sys}"
|
||||
|
||||
devices() {
|
||||
for d in "$SYS"/class/drm/card*/device; do
|
||||
[ -w "$d/pp_dpm_fclk" ] || [ -r "$d/pp_dpm_fclk" ] || continue
|
||||
[ -e "$d/power_dpm_force_performance_level" ] || continue
|
||||
echo "$d"
|
||||
done | sort -u
|
||||
}
|
||||
|
||||
held() { # held DEVICE_DIR: the performance level high, or manual with only the top fclk level selected
|
||||
perf=$(cat "$1/power_dpm_force_performance_level" 2>/dev/null)
|
||||
[ "$perf" = high ] && return 0
|
||||
[ "$perf" = manual ] || return 1
|
||||
top=$(grep . "$1/pp_dpm_fclk" | tail -n 1)
|
||||
[ "$(grep -c '\*' "$1/pp_dpm_fclk")" = 1 ] && [ "${top%\*}" != "$top" ]
|
||||
}
|
||||
|
||||
cmd="${1:-status}"
|
||||
case "$cmd" in
|
||||
hold)
|
||||
n=0
|
||||
while [ -z "$(devices)" ] && [ "$n" -lt 60 ]; do sleep 1; n=$((n + 1)); done
|
||||
[ -n "$(devices)" ] || { echo "halogen-fabric-clock: no amdgpu device with a fabric clock control (pp_dpm_fclk) under $SYS/class/drm" >&2; exit 1; }
|
||||
rc=0
|
||||
for d in $(devices); do
|
||||
lvl=$(grep . "$d/pp_dpm_fclk" | tail -n 1 | cut -d: -f1)
|
||||
# the driver applies the level several seconds after the write, so the read-back waits up to 30 s
|
||||
ok=0
|
||||
if echo manual > "$d/power_dpm_force_performance_level" && echo "$lvl" > "$d/pp_dpm_fclk"; then
|
||||
t=0; until held "$d" || [ "$t" -ge 300 ]; do sleep 0.1; t=$((t + 1)); done
|
||||
held "$d" && ok=1
|
||||
fi
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo "halogen-fabric-clock: $d: held at level $lvl ($(grep . "$d/pp_dpm_fclk" | tail -n 1 | awk '{print $2}'))"
|
||||
else
|
||||
echo "halogen-fabric-clock: $d: could not hold it (level $lvl)" >&2; rc=1
|
||||
fi
|
||||
done
|
||||
exit "$rc" ;;
|
||||
release)
|
||||
for d in $(devices); do echo auto > "$d/power_dpm_force_performance_level" && echo "halogen-fabric-clock: $d: released (auto)"; done ;;
|
||||
status)
|
||||
[ -n "$(devices)" ] || { echo "halogen-fabric-clock: no amdgpu device with a fabric clock control"; exit 1; }
|
||||
rc=0
|
||||
for d in $(devices); do
|
||||
if held "$d"; then echo "$d: held ($(cat "$d/power_dpm_force_performance_level"))"
|
||||
else echo "$d: NOT held ($(cat "$d/power_dpm_force_performance_level"))"; rc=1; fi
|
||||
done
|
||||
exit "$rc" ;;
|
||||
*) echo "usage: halogen-fabric-clock hold | release | status" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,19 @@
|
||||
# Holds the AMD GPU's fabric clock at its top level at boot and after every resume, for halogen-flash-server with
|
||||
# the NPU (HALOGEN_NPU_MODELS): on Strix Halo the GPU and the NPU at once are safe only with that clock held.
|
||||
# Install (as root), from this directory:
|
||||
# install -m 755 halogen-fabric-clock /usr/local/sbin/
|
||||
# install -m 644 halogen-fabric-clock.service /etc/systemd/system/
|
||||
# systemctl daemon-reload && systemctl enable --now halogen-fabric-clock.service
|
||||
# Check: halogen-fabric-clock status. Undo: systemctl disable halogen-fabric-clock.service, then
|
||||
# halogen-fabric-clock release (or reboot). The unit runs once at boot and once after each resume (a resume can hand
|
||||
# the clock back to the driver); it holds nothing open, so it shows inactive between runs.
|
||||
[Unit]
|
||||
Description=Hold the AMD GPU fabric clock at its top level (GPU and NPU together)
|
||||
After=systemd-modules-load.service suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/halogen-fabric-clock hold
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target
|
||||
@@ -23,7 +23,7 @@ After=gluetun.service network.target
|
||||
[Container]
|
||||
ContainerName=marreta_browser
|
||||
Network=gluetun.container
|
||||
Image=docker.io/lightpanda/browser:nightly
|
||||
Image=docker.io/lightpanda/browser:nightly@sha256:e72730e364d1fbf352c1149f29ffa4b278f3b1a55ff0dec3262298ebe624ec7a
|
||||
|
||||
[Service]
|
||||
TimeoutStartSec=300
|
||||
|
||||
@@ -32,7 +32,7 @@ After=gluetun.service marreta-browser.service network.target
|
||||
[Container]
|
||||
ContainerName=marreta
|
||||
Network=gluetun.container
|
||||
Image=ghcr.io/manualdousuario/marreta:latest
|
||||
Image=ghcr.io/manualdousuario/marreta:latest@sha256:30f2a34567b79b66da794b4b9423751bbbcd07ceff600d9f6a2d219e7e439362
|
||||
# Relocate off SearXNG's 8080 inside the shared netns.
|
||||
Environment=NGINX_HTTP_PORT=8085
|
||||
Environment=APP_NAME=Marreta
|
||||
|
||||
@@ -33,7 +33,7 @@ Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=alertmanager
|
||||
Image=docker.io/prom/alertmanager:latest
|
||||
Image=docker.io/prom/alertmanager:latest@sha256:39d8bb3966c694a124428a3a88fbcffd40bd069129ecd47c059f8a3f17c55f3b
|
||||
Network=dankclaw_default
|
||||
User=0:0
|
||||
Exec=--config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager
|
||||
|
||||
@@ -1,55 +1,51 @@
|
||||
# Quadlet unit: alloy (rootless, boot-persistent).
|
||||
# Quadlet unit: alloy (ROOT system service, boot-persistent).
|
||||
#
|
||||
# Install path on dankclaw: ~/.config/containers/systemd/alloy.container
|
||||
# Unit name: alloy.service (container name: alloy)
|
||||
# Install path on dankclaw: /etc/containers/systemd/alloy.container
|
||||
# Unit name: alloy.service (container name: alloy) — SYSTEM manager, root.
|
||||
#
|
||||
# Log shipper. This replaces the compose service of the same name in
|
||||
# dankclaw/compose.yaml ("Observability" block).
|
||||
# WHY ROOT (not rootless, unlike its observability siblings). Alloy reads the
|
||||
# host journal. The journal files are root:systemd-journal 0640 (other::---);
|
||||
# a rootless container carries no supplementary groups and cannot open them —
|
||||
# a rootless read of system.journal returns DENIED (see the Alloy page in the
|
||||
# vault). Running as root, one loki.source.journal reads system.journal AND
|
||||
# user-1000.journal, so it carries both host logs and the rootless Quadlet
|
||||
# containers' logs. Validated 2026-10-20: a root probe read 31,825 journal
|
||||
# lines and pushed 26,966 into Loki in 20 s, zero drops.
|
||||
#
|
||||
# IMAGE. franksdank/alloy is built locally by deploy-remote.sh
|
||||
# (build_compose_image) from dankclaw/alloy/Containerfile: upstream
|
||||
# grafana/alloy plus a static busybox for the healthcheck. Pull=never keeps a
|
||||
# boot from reaching for that local tag at a registry.
|
||||
# NETWORK=host. Loki publishes 127.0.0.1:3100 (loopback only) on the host, and
|
||||
# no bridge container — rootless or root — can reach the host's loopback, so
|
||||
# this unit runs on the host network and pushes to 127.0.0.1:3100. The side
|
||||
# effect: the UI must bind loopback too, so Exec sets the listen-addr to
|
||||
# 127.0.0.1 (the old rootless unit used 0.0.0.0 only because container loopback
|
||||
# was not host loopback; under Network=host they are the same address, so the
|
||||
# unauthenticated UI stays loopback-only — nothing off-host reaches it).
|
||||
#
|
||||
# NETWORK. It joins dankclaw_default so its config's loki.write endpoint
|
||||
# resolves the container name loki:3100.
|
||||
# THE /etc/machine-id MOUNT IS LOAD-BEARING. sd_journal locates the journal
|
||||
# directory by the local machine id; without it the container opens nothing and
|
||||
# the journal source reads exactly 0 lines — silently, no error, health green.
|
||||
# This mount is the entire reason the pipeline carries data. Never remove it.
|
||||
#
|
||||
# THE UI PORT EXISTS ON PURPOSE. Alloy's UI listens on the container's own
|
||||
# 127.0.0.1 by default and cannot be moved from the config file (alloy 1.x
|
||||
# refuses the ui block), so the compose service had no reachable health
|
||||
# endpoint at all: the homepage status card for alloy reported "down"
|
||||
# continuously and there was nothing for a deploy to wait on. The command
|
||||
# below therefore moves the UI to the container's wildcard address and the
|
||||
# port is published on host loopback only, which puts alloy in the same class
|
||||
# as the other three: reachable from the host, by Caddy over the private
|
||||
# network, and by nothing off-host. Alloy has no authentication, which is
|
||||
# exactly why it is not published wider.
|
||||
#
|
||||
# SOURCES. The host journald socket and the host log/container-storage trees
|
||||
# are bind-mounted read-only; Alloy tails them and pushes to Loki. The config
|
||||
# lives at /opt/alloy/config/config.alloy, installed from
|
||||
# shared/alloy/config.alloy on every deploy.
|
||||
#
|
||||
# ORDERING. Wants/After loki.service: without a reachable Loki, Alloy's write
|
||||
# client fails and its log pipeline backs up.
|
||||
# IMAGE. Built rootless by build_compose_image alloy, then mirrored into the
|
||||
# root image store by the deployer (a root build runs in the build container's
|
||||
# own resolver and fails on DNS here). Pull=never: boot must not reach for the
|
||||
# local tag at a registry.
|
||||
|
||||
[Unit]
|
||||
Description=Alloy log shipper (Dankclaw)
|
||||
After=network-online.target loki.service
|
||||
Wants=network-online.target loki.service
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=alloy
|
||||
Image=localhost/franksdank/alloy:latest
|
||||
Pull=never
|
||||
Network=dankclaw_default
|
||||
Exec=run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/config.alloy
|
||||
PublishPort=127.0.0.1:12345:12345
|
||||
Volume=/opt/alloy/config:/etc/alloy
|
||||
Network=host
|
||||
Exec=run --server.http.listen-addr=127.0.0.1:12345 /etc/alloy/config.alloy
|
||||
Volume=/opt/alloy/config:/etc/alloy:ro
|
||||
Volume=/etc/machine-id:/etc/machine-id:ro
|
||||
Volume=/run/systemd/journal/socket:/run/systemd/journal/socket:ro
|
||||
Volume=/var/log:/var/log:ro
|
||||
Volume=/var/lib/containers:/var/lib/containers:ro
|
||||
HealthCmd=/usr/local/bin/busybox wget --spider -q http://localhost:12345/-/healthy
|
||||
Volume=/var/log/journal:/var/log/journal:ro
|
||||
Volume=/run/log/journal:/run/log/journal:ro
|
||||
HealthCmd=/usr/local/bin/busybox wget --spider -q http://127.0.0.1:12345/-/healthy
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
@@ -60,4 +56,4 @@ Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,56 @@
|
||||
# Quadlet unit: blackbox exporter (rootless, host network, boot-persistent).
|
||||
#
|
||||
# Install path on dankclaw:
|
||||
# ~/.config/containers/systemd/blackbox-exporter.container
|
||||
#
|
||||
# WHY Network=host. The probes it runs are (a) the host's own LAN IP
|
||||
# (192.168.0.143:8081 SearXNG, :3005 OpenWebUI — host-process/portmap binds a
|
||||
# bridge container cannot reach; netavark DNATs published ports only, see
|
||||
# Container Host Reachability in the vault) and (b) ComfyUI's published port
|
||||
# via 127.0.0.1 (rootless --network=host shares the real host netns, so the
|
||||
# 0.0.0.0:8188 publish answers on loopback). A bridge-networked prober could
|
||||
# reach neither class reliably.
|
||||
#
|
||||
# WHY the listener binds 127.0.0.1 + LAN. A bridge container CANNOT dial
|
||||
# its own host's LAN IP (netavark hairpin gap, measured 2026-10-21), but a
|
||||
# bridge container dialing the podman gateway 169.254.1.2 lands on the host
|
||||
# LOOPBACK — that is the scrape path, and it needs this exporter to be
|
||||
# LISTENING ON LOOPBACK (proved: 200 from the prometheus container to
|
||||
# http://169.254.1.2:9115/-/healthy with a loopback bind). The gateway
|
||||
# address itself is NOT bindable from the host netns — it exists only
|
||||
# inside each rootless netns; binding it dies "bind: cannot assign
|
||||
# requested address" (live 2026-10-21). The LAN bind stays for diagnostics.
|
||||
# No public DNS name, no guard chain: internal net only, per the exposure
|
||||
# policy. The listen flag is --web.listen-address and it is REPEATABLE, one
|
||||
# flag per bind; plain --listen-address does not exist and the exporter
|
||||
# exits in 100 ms with "unknown long flag" (live 2026-10-21).
|
||||
|
||||
[Unit]
|
||||
Description=Blackbox exporter (HTTP probe prober)
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=docker.io/prom/blackbox-exporter@sha256:ea1c7aa3ef3f307cd03e85f34abb604478ccb90b567fc690da0cd9776da76b13
|
||||
ContainerName=blackbox-exporter
|
||||
Network=host
|
||||
# The image's ENTRYPOINT is /bin/blackbox_exporter itself (proved live
|
||||
# 2026-10-21: `podman run IMAGE sh` dies "unexpected sh"), so Exec= carries
|
||||
# FLAGS ONLY — repeating the binary makes it a positional arg and kingpin
|
||||
# exits "unexpected /bin/blackbox_exporter".
|
||||
Exec=--config.file=/etc/blackbox_exporter/config.yml --web.listen-address=127.0.0.1:9115 --web.listen-address=192.168.0.143:9115
|
||||
Volume=/opt/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||
# Plain exec form like every sibling unit. Shell form (`/bin/sh -c "…"`)
|
||||
# is passed as one quoted token and quadlet then folds the following
|
||||
# --health-interval/--health-retries (and the image name) into the command
|
||||
# string — unit died twice that way, live 2026-10-21. wget only; this
|
||||
# busybox variant has no curl, and host-network wget answers on loopback.
|
||||
HealthCmd=wget -q -O /dev/null http://127.0.0.1:9115/-/healthy
|
||||
HealthInterval=30s
|
||||
HealthRetries=3
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,56 @@
|
||||
# Quadlet unit: cadvisor (rootless, boot-persistent) — DANKCLAW.
|
||||
#
|
||||
# Install path on dankclaw: ~/.config/containers/systemd/cadvisor.container
|
||||
# Unit name: cadvisor.service (container name: cadvisor)
|
||||
#
|
||||
# WHY THIS EXISTS. The app stack is ~34 rootless Podman containers and until
|
||||
# now nothing reported per-container CPU/memory — Grafana could only see the
|
||||
# host (node_exporter) and the LLM engines. cadvisor reads the host cgroup
|
||||
# tree and exports container_cpu_* / container_memory_* per container.
|
||||
#
|
||||
# THE LOAD-BEARING FLAG IS --cgroupns=host. Podman starts every container in a
|
||||
# PRIVATE cgroup namespace, so an exporter container sees only its OWN cgroup
|
||||
# subtree: cadvisor exported a single root series until this flag was added,
|
||||
# then it walked the whole tree (996 per-container series measured 2026-10-20).
|
||||
# The quadlet unit name is carried in the cgroup path — app.slice/<name>.service/
|
||||
# libpod-payload-<hex> — so Prometheus relabels that path into a `container`
|
||||
# label (shared/prometheus/prometheus.yml, cadvisor-dankclaw job) and no
|
||||
# libpod/podman socket is needed to name containers.
|
||||
#
|
||||
# BINDING. cadvisor has no per-interface listen flag (unlike node_exporter's
|
||||
# --web.listen-address), so it binds 0.0.0.0 INSIDE its own network namespace.
|
||||
# The bridge PublishPort is what keeps the unauthenticated endpoint off the
|
||||
# host's other interfaces: it publishes exactly loopback (host-local probes and
|
||||
# the deployer's health gate) and this host's LAN address (Prometheus, a bridge
|
||||
# container, reaches a host-published port at its LAN address — the same netavark
|
||||
# rule node-exporter relies on, only here the port really is published).
|
||||
#
|
||||
# It is rootless on purpose: dank owns the app containers' cgroups, so a
|
||||
# rootless cadvisor reads every one of them; root would buy nothing here.
|
||||
|
||||
[Unit]
|
||||
Description=cAdvisor container metrics (Dankclaw)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=cadvisor
|
||||
Image=gcr.io/cadvisor/cadvisor:v0.49.1@sha256:3cde6faf0791ebf7b41d6f8ae7145466fed712ea6f252c935294d2608b1af388
|
||||
PodmanArgs=--pid=host --cgroupns=host
|
||||
PublishPort=127.0.0.1:8080:8080
|
||||
PublishPort=192.168.0.143:8080:8080
|
||||
Volume=/sys:/sys:ro
|
||||
Volume=/:/rootfs:ro
|
||||
Exec=--housekeeping_interval=30s
|
||||
HealthCmd=wget --spider -q http://localhost:8080/healthz
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=40s
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -34,7 +34,7 @@ Wants=network-online.target prometheus.service
|
||||
|
||||
[Container]
|
||||
ContainerName=grafana
|
||||
Image=docker.io/grafana/grafana:latest
|
||||
Image=docker.io/grafana/grafana:latest@sha256:488984b716c0862639f91a698adbe2862677a5a07bad42da5f03f3030883b205
|
||||
Network=dankclaw_default
|
||||
User=0:0
|
||||
PublishPort=127.0.0.1:3002:3000
|
||||
@@ -47,9 +47,24 @@ Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GRAFANA_OAUTH_CLIENT_SECRET}
|
||||
# client_id query parameter — /application/o/<slug>/authorize/ does not exist.
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.franksdank.cloud/application/o/authorize/
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.franksdank.cloud/application/o/token/
|
||||
# ?api=1 = introspection-style userinfo: returns custom claims (groups) too.
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/?api=1
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=false
|
||||
# Standard OIDC userinfo. Grafana v13 fetches <api_url>/emails for the address
|
||||
# ONLY when the token carries no `email` claim (authentik has no /emails path and
|
||||
# 404s it, failing the login) — so the claim must actually be there: the fix is
|
||||
# the grafana-email-scope mapping (see scripts/authentik-grafana-sso-fix.sh),
|
||||
# not a Grafana flag. Do NOT append ?api=1 to this URL: Grafana then GETs
|
||||
# <...>/userinfo/?api=1/emails, a 403.
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/
|
||||
# authentik's scopes are openid/email/profile. `user:email` (a GitLab-ism) is
|
||||
# not a known scope here; requesting it makes the authorize view crash.
|
||||
# Quoted: quadlet word-splits Environment=, so an unquoted spaced list
|
||||
# truncates SCOPES to its first token.
|
||||
Environment="GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile"
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_PATH=email
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH=sub
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH=preferred_username
|
||||
# First SSO login auto-provisions the user. Safe: unauthenticated traffic never
|
||||
# reaches this callback — the Anubis -> Authentik guard fronts every request.
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_AUTO_UPSERT=true
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_ORG=true
|
||||
Environment=GF_AUTH_GENERIC_OAUTH_ORG_ID=1
|
||||
|
||||
@@ -33,7 +33,7 @@ Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=node-exporter
|
||||
Image=docker.io/prom/node-exporter:latest
|
||||
Image=docker.io/prom/node-exporter:latest@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0
|
||||
Network=host
|
||||
PodmanArgs=--pid=host
|
||||
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.143:9100
|
||||
|
||||
@@ -43,8 +43,16 @@ Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=prometheus
|
||||
Image=docker.io/prom/prometheus:latest
|
||||
Image=docker.io/prom/prometheus:latest@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
|
||||
Network=dankclaw_default
|
||||
# Same-host scrape targets are dialed at host.containers.internal (the podman
|
||||
# gateway): a bridge container cannot dial its own host's LAN IP (netavark
|
||||
# hairpin gap, measured 2026-10-21), and traffic to the gateway lands on host
|
||||
# loopback / published ports (cadvisor/gpu jobs and the blackbox scrape all
|
||||
# ride it). The name resolves natively — NO HostAddr key: quadlet 5.7.0 calls
|
||||
# it unsupported and the WHOLE user quadlet batch then fails to generate,
|
||||
# silently orphaning every other unit (live 2026-10-21: prometheus.service
|
||||
# vanished while its container kept running as an orphan).
|
||||
User=0:0
|
||||
Exec=--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --storage.tsdb.retention.time=30d --web.enable-lifecycle
|
||||
PublishPort=127.0.0.1:9090:9090
|
||||
|
||||
@@ -48,13 +48,22 @@
|
||||
# without `podman unshare` — accepted for a datastore nobody edits by hand;
|
||||
# episode AUDIO is a host directory for the opposite reason (see the app unit).
|
||||
#
|
||||
# HEALTH. The base image is distroless: no shell, no curl, no wget, so no
|
||||
# CMD-SHELL probe and no wget/curl probe is possible here (same class of unit
|
||||
# as loki, whose probe had to move into the image). SurrealDB ships its own
|
||||
# probe instead — `surreal is-ready` connects to ws://localhost:8000 by default
|
||||
# and needs no credentials — verified against this digest: it prints `OK` and
|
||||
# exits 0 while the server is up. Argument-only HealthCmd, like every unit on
|
||||
# this host, because the quadlet generator splits it on spaces.
|
||||
# HEALTH. The base image is distroless — measured against this digest, not even
|
||||
# `ls` exists (`exec: "ls": executable file not found in $PATH`), so there is no
|
||||
# `/bin/sh`, no curl and no wget. SurrealDB ships its own probe instead:
|
||||
# `surreal is-ready` connects to ws://localhost:8000 by default, needs no
|
||||
# credentials, prints `OK` and exits 0 while the server is up.
|
||||
#
|
||||
# It must be handed over as a QUADLET-SIDE JSON ARRAY, not a bare command.
|
||||
# podman wraps a bare `HealthCmd` string as `CMD-SHELL` (measured on this host's
|
||||
# podman 5.7: `--health-cmd "/surreal is-ready"` stores
|
||||
# `{"Test":["CMD-SHELL","/surreal is-ready"]}`), and with no `/bin/sh` in the
|
||||
# image that probe fails in ~30 ms with empty output on every interval while the
|
||||
# database answers perfectly — which is exactly how this service's first deploy
|
||||
# died on 2026-10-19 (`did not become healthy in 90s`). The quoted array survives
|
||||
# the generator (`--health-cmd "[\"/surreal\",\"is-ready\"]"`) and stores
|
||||
# `["CMD","/surreal","is-ready"]`, which execs directly and goes healthy. Do not
|
||||
# "simplify" it back to a bare command.
|
||||
|
||||
[Unit]
|
||||
Description=SurrealDB (open-notebook datastore)
|
||||
@@ -70,7 +79,7 @@ EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
|
||||
EnvironmentFile=/home/dank/Containers/open-notebook/env
|
||||
Exec=start --log info rocksdb:/mydata/mydatabase.db
|
||||
Volume=open-notebook-db:/mydata
|
||||
HealthCmd=/surreal is-ready
|
||||
HealthCmd=["/surreal","is-ready"]
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
|
||||
@@ -33,17 +33,21 @@
|
||||
# and 5055 is exactly the port seerr already holds on this host
|
||||
# (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows
|
||||
# 127.0.0.1:5055 taken). So the API leaves the container on host port 5056.
|
||||
# That forces one setting: the frontend auto-detects the API as
|
||||
# `<host-you-opened>:5055`, so API_URL in scripts/open-notebook.env must name
|
||||
# the remapped port explicitly, or the UI loads and every call fails. Both
|
||||
# ports are published on all interfaces because the consumer is a browser on
|
||||
# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea)
|
||||
# bind the same way, while loopback-only is reserved for services whose user
|
||||
# path is the guard chain. This service has NO vhost and no Caddy route: it is
|
||||
# internal-only until it is guarded, and its own password
|
||||
# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not
|
||||
# decoration: with it unset the API answers everyone on the LAN, including the
|
||||
# unauthenticated episode-audio download.
|
||||
# Only 8502 is published on all interfaces, because it is the single upstream
|
||||
# the guard chain dials: notebook.franksdank.cloud → anubis → authentik →
|
||||
# http://:8017 → 192.168.0.143:8502 (dankclaw/Caddyfile), and this Caddy lives
|
||||
# in root's storage, so a published port is the only way it can reach a rootless
|
||||
# quadlet. The API needs no outward face at all — the image's own Next.js layer
|
||||
# rewrites /api/* to the FastAPI beside it, so one origin serves UI and API —
|
||||
# and the fleet's rule is that loopback-only is for services whose user path is
|
||||
# the guard chain, which this became on 2026-10-19; 5056 stays published on
|
||||
# loopback because the deployer's health gate dials it there. That is also why
|
||||
# API_URL in scripts/open-notebook.env names the vhost rather than a port: left
|
||||
# to itself the frontend auto-detects `<host-you-opened>:5055`, and the UI loads
|
||||
# while every call fails. OPEN_NOTEBOOK_PASSWORD is not made redundant by the
|
||||
# chain: it is what keeps the API honest for everything else on this host, and
|
||||
# with it unset the API answers anyone, including the unauthenticated
|
||||
# episode-audio download.
|
||||
#
|
||||
# VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite
|
||||
# checkpoints and the extraction caches the image keeps under it
|
||||
@@ -81,7 +85,7 @@ Pull=never
|
||||
Network=dankclaw_default
|
||||
Network=open-notebook.network
|
||||
PublishPort=0.0.0.0:8502:8502
|
||||
PublishPort=0.0.0.0:5056:5055
|
||||
PublishPort=127.0.0.1:5056:5055
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
|
||||
EnvironmentFile=/home/dank/Containers/open-notebook/env
|
||||
Volume=open-notebook-data:/app/data
|
||||
|
||||
@@ -27,7 +27,7 @@ After=network.target
|
||||
[Container]
|
||||
ContainerName=open-webui
|
||||
Network=open-webui.network
|
||||
Image=ghcr.io/open-webui/open-webui:main
|
||||
Image=ghcr.io/open-webui/open-webui:main@sha256:0b399a8ba34558e181d316750455f2a95376a38cd60a61b9c942e138898944bd
|
||||
PublishPort=3005:8080
|
||||
Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770
|
||||
Environment=ANONYMIZED_TELEMETRY=False
|
||||
|
||||
@@ -45,3 +45,4 @@
|
||||
192.168.0.143 pihole.franksdank.cloud
|
||||
192.168.0.143 seerr.franksdank.cloud
|
||||
192.168.0.143 marreta.franksdank.cloud
|
||||
192.168.0.143 notebook.franksdank.cloud
|
||||
@@ -24,7 +24,7 @@ Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=pihole
|
||||
Image=docker.io/pihole/pihole:latest
|
||||
Image=docker.io/pihole/pihole:latest@sha256:d34134716fc32038b052a8674c7c6879e0ff21f0e6d8af345166fffa2c27c113
|
||||
Network=slirp4netns
|
||||
PublishPort=0.0.0.0:53:53/udp
|
||||
PublishPort=0.0.0.0:53:53/tcp
|
||||
|
||||
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
|
||||
[Container]
|
||||
ContainerName=gluetun
|
||||
Network=searxng.network
|
||||
Image=docker.io/qmcgaw/gluetun:latest
|
||||
Image=docker.io/qmcgaw/gluetun:latest@sha256:4af0c804003481e100180e1f8d4d5e9c48163fedc0f88d061881b615aef9d183
|
||||
AddCapability=NET_ADMIN
|
||||
AddDevice=/dev/net/tun:/dev/net/tun
|
||||
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
|
||||
|
||||
@@ -16,7 +16,7 @@ After=searxng-network.service network.target
|
||||
ContainerName=searxng-valkey
|
||||
Network=searxng.network
|
||||
NetworkAlias=valkey
|
||||
Image=docker.io/valkey/valkey:9-alpine
|
||||
Image=docker.io/valkey/valkey:9-alpine@sha256:bf09d58e96d6b4524a15396b65b242bbe18beebbcc15a04d8ee75ddac62fae0b
|
||||
Volume=/opt/searxng/valkey:/data
|
||||
|
||||
[Service]
|
||||
|
||||
@@ -16,7 +16,7 @@ After=gluetun.service searxng-valkey.service network.target
|
||||
[Container]
|
||||
ContainerName=searxng
|
||||
Network=gluetun.container
|
||||
Image=ghcr.io/searxng/searxng:latest
|
||||
Image=ghcr.io/searxng/searxng:latest@sha256:f4aa660c54d062dedb268be84e821ea24e39f48c7a496af8dd02cc9eedb93847
|
||||
Environment=SEARXNG_SECRET=${SEARXNG_SECRET}
|
||||
Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL}
|
||||
Volume=/opt/searxng/config:/etc/searxng
|
||||
|
||||
@@ -29,7 +29,7 @@ Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=seerr
|
||||
Image=ghcr.io/seerr-team/seerr:v3.4.1
|
||||
Image=ghcr.io/seerr-team/seerr:v3.5.1@sha256:d8fab675b55ad293ded98fd71efcfd46054cbad9746534ae1cc4794a88c0b3be
|
||||
Network=dankclaw_default
|
||||
PublishPort=127.0.0.1:5055:5055
|
||||
Volume=/home/dank/Containers/seerr/data:/app/config
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# /etc/udev/rules.d/98-halogen-npu-access.rules
|
||||
#
|
||||
# Why the NPU node is world-writable: `frank-dragoon` runs rootless podman under a
|
||||
# unit with `User=dank`, and the container's user namespace maps exactly one uid and
|
||||
# one gid (dank). `/dev/accel/accel0` is `root:render 0660`, so inside the container
|
||||
# the node reads `nobody:nogroup` and the engine dies with its own diagnosis:
|
||||
#
|
||||
# halogen npu: /dev/accel/accel0 is here, but this process cannot open it
|
||||
# (crw-rw---- nobody:nogroup).
|
||||
#
|
||||
# `--group-add keep-groups` is the vendor's advice and it does NOT work in this
|
||||
# shape: measured 2026-10-19 from the host as a `dank` shell that was already in
|
||||
# `render`, with and without the flag, the node still read `nobody:nogroup` and the
|
||||
# open was refused — an unmapped gid cannot be granted to a process inside a
|
||||
# namespace that does not map it. Same conclusion `99-comfyui-gpu-access.rules`
|
||||
# reached for `/dev/kfd` from the systemd-user-spawn direction: the mode is the only
|
||||
# spawn-context-independent door short of running the stack rootful.
|
||||
#
|
||||
# Blast radius: any process in this host's single uid domain (every rootless
|
||||
# container here shares it) can submit to the XDNA2 NPU. The NPU holds no user data
|
||||
# and no privileged context — the same trade already accepted for the GPU compute
|
||||
# nodes beside it. Installed by `deploy-remote.sh` when the NPU lane is armed and left
|
||||
# in place afterwards — the mode costs nothing while the lane is idle. `serve-frank-dragoon.sh`
|
||||
# refuses to arm the lane on a node that is not other-accessible, so a host that lost this
|
||||
# file fails loudly at boot instead of crash-looping.
|
||||
KERNEL=="accel[0-9]*", SUBSYSTEM=="accel", MODE="0666"
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTTP front door for the frank-medium single-GPU replicas.
|
||||
|
||||
Why two replicas instead of TP=2: one vLLM process spanning both R9700s needs
|
||||
RCCL collectives, and RCCL's kernels require PCIe atomics on every
|
||||
participating GPU. The second card sits behind chipset bridges advertising
|
||||
`AtomicOpsCap: Routing-`, which no software can enable (see the runbook), so
|
||||
no collective can launch on it. Two single-GPU replicas use both cards; this
|
||||
service keeps the fleet contract of one endpoint on :7770, streaming included.
|
||||
|
||||
Routing decisions live in routing.py — spread single-turn requests, pin
|
||||
continuations to the replica whose prefix cache holds their context. This file
|
||||
only moves bytes: parse enough of the request to route it, forward it verbatim,
|
||||
stream the answer back, and report replica health on /health.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
|
||||
import routing
|
||||
|
||||
PORT = int(os.environ.get("PORT", "7770"))
|
||||
HEALTH_TTL = float(os.environ.get("HEALTH_TTL", "10"))
|
||||
# Hop-by-hop headers must not be forwarded (RFC 9110 §7.6.1) — forwarding
|
||||
# transfer-encoding or content-length of a rewritten body breaks clients.
|
||||
HOP_BY_HOP = {
|
||||
"host", "content-length", "transfer-encoding", "connection",
|
||||
"keep-alive", "proxy-authenticate", "proxy-authorization", "te",
|
||||
"trailers", "upgrade",
|
||||
}
|
||||
|
||||
UPSTREAMS = routing.UPSTREAMS
|
||||
# Replica health, refreshed by the watcher. Module state rather than aiohttp app
|
||||
# state: the application is frozen once the site is up.
|
||||
_UP: list[bool] = [True] * len(UPSTREAMS)
|
||||
|
||||
|
||||
async def health(session: ClientSession) -> web.Response:
|
||||
results = await asyncio.gather(
|
||||
*(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS),
|
||||
return_exceptions=True,
|
||||
)
|
||||
lines, up = [], 0
|
||||
for url, res in zip(UPSTREAMS, results):
|
||||
if isinstance(res, Exception) or res.status != 200:
|
||||
lines.append(f"{url} DOWN")
|
||||
else:
|
||||
up += 1
|
||||
lines.append(f"{url} UP")
|
||||
return web.Response(status=200 if up else 503, text="\n".join(lines) + "\n")
|
||||
|
||||
|
||||
async def watch_health() -> None:
|
||||
"""Refresh the replica up/down table so routing never targets a dead one.
|
||||
|
||||
One HTTP request per replica per interval, in exchange for never handing a
|
||||
client a 502 after a restart.
|
||||
"""
|
||||
global _UP
|
||||
session = ClientSession()
|
||||
try:
|
||||
while True:
|
||||
res = await asyncio.gather(
|
||||
*(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS),
|
||||
return_exceptions=True,
|
||||
)
|
||||
_UP = [not isinstance(r, Exception) and r.status == 200 for r in res]
|
||||
await asyncio.sleep(HEALTH_TTL)
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def proxy(request: web.Request) -> web.StreamResponse:
|
||||
session: ClientSession = request.app["session"]
|
||||
if request.path == "/health":
|
||||
return await health(session)
|
||||
|
||||
body = await request.read()
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP}
|
||||
|
||||
# Fail over before anything is written to the client. A replica that
|
||||
# restarts is invisible to the health watcher for up to HEALTH_TTL, and a
|
||||
# deploy restarts both replicas — without this retry a 10 s window of
|
||||
# requests would 502 on a service that has a live card the whole time.
|
||||
# vLLM's OpenAI endpoints are stateless per request, so re-sending a
|
||||
# request that never reached an engine is safe; a request that failed
|
||||
# mid-stream is not retried (that path is below).
|
||||
tried: set[int] = set()
|
||||
upstream = None
|
||||
last_error = "no healthy frank-medium replica"
|
||||
while len(tried) < len(UPSTREAMS):
|
||||
idx = routing.route(body, request.remote, _UP)
|
||||
if idx < 0 or idx in tried:
|
||||
break
|
||||
tried.add(idx)
|
||||
target = UPSTREAMS[idx] + request.path_qs
|
||||
try:
|
||||
upstream = await session.request(
|
||||
request.method, target, headers=headers, data=body or None,
|
||||
timeout=ClientTimeout(total=None, sock_connect=10, sock_read=3600),
|
||||
)
|
||||
break
|
||||
except Exception as exc:
|
||||
# Demote at once: the watcher will confirm, but every request until
|
||||
# then should skip this replica rather than pay the same timeout.
|
||||
# Logged because a silent failover hides a card that is down.
|
||||
_UP[idx] = False
|
||||
last_error = f"{target} unreachable: {exc}"
|
||||
print(f"proxy: failover — {target} unreachable ({exc}); retrying "
|
||||
f"another replica", flush=True)
|
||||
if upstream is None:
|
||||
# 503 when no replica was even reachable through the routing table,
|
||||
# 502 when replicas were tried and all of them failed.
|
||||
status = 502 if tried else 503
|
||||
return web.Response(status=status, text=f"proxy: {last_error}\n")
|
||||
|
||||
out = web.StreamResponse(status=upstream.status, reason=upstream.reason)
|
||||
for k, v in upstream.headers.items():
|
||||
if k.lower() not in HOP_BY_HOP:
|
||||
out.headers[k] = v
|
||||
await out.prepare(request)
|
||||
try:
|
||||
async for chunk in upstream.content.iter_any():
|
||||
await out.write(chunk)
|
||||
except Exception:
|
||||
# The client went away mid-stream (an aborted generation). Nothing to
|
||||
# report: the engine sees its own cancellation.
|
||||
pass
|
||||
finally:
|
||||
upstream.release()
|
||||
with suppress(Exception):
|
||||
await out.write_eof()
|
||||
return out
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
app = web.Application(client_max_size=1024 ** 3)
|
||||
app["session"] = ClientSession()
|
||||
app.router.add_route("*", "/{tail:.*}", proxy)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, "0.0.0.0", PORT).start()
|
||||
print(f"proxy: :{PORT} -> {', '.join(UPSTREAMS)} routing={routing.ROUTING} "
|
||||
f"key={routing.AFFINITY_BYTES}B known_max={routing.KNOWN_MAX}", flush=True)
|
||||
watcher = asyncio.create_task(watch_health())
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.gather(watcher)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(main())
|
||||
@@ -1,98 +0,0 @@
|
||||
# Quadlet unit: frank-medium replica A (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-a.container
|
||||
# Unit name: frank-medium-a.service (container name: frank-medium-a)
|
||||
#
|
||||
# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one
|
||||
# tensor-parallel process across both R9700s (see frank-medium.container, kept
|
||||
# as the target). That shape cannot run on this host: RCCL's collective kernels
|
||||
# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to
|
||||
# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled,
|
||||
# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The
|
||||
# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise
|
||||
# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
|
||||
# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root()
|
||||
# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS.
|
||||
# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have
|
||||
# atomics. Result: every collective that touches GPU 2 dies at launch.
|
||||
# Two independent single-GPU replicas use both cards instead; the proxy unit
|
||||
# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook.
|
||||
#
|
||||
# SHAPE. vLLM binds 0.0.0.0:7771 on the host network directly; no PublishPort.
|
||||
# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the
|
||||
# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and
|
||||
# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile
|
||||
# this shape instead of using the 2x7551-32624 pin, which is only valid at
|
||||
# TP=2/262144.
|
||||
#
|
||||
# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=0 (the first R9700). The
|
||||
# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin.
|
||||
# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters
|
||||
# first and HIP indexes into the filtered list, so a single-valued pair
|
||||
# (ROCR=1,HIP=1) leaves an empty device set.
|
||||
#
|
||||
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
|
||||
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
|
||||
[Unit]
|
||||
Description=frank-medium replica A — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #1
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=frank-medium-a
|
||||
Image=localhost/radiance-vllm:0.9.3
|
||||
AddDevice=/dev/kfd
|
||||
AddDevice=/dev/dri
|
||||
GroupAdd=keep-groups
|
||||
# The upstream launcher's exact podman posture (rootless).
|
||||
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
|
||||
Environment=HIP_VISIBLE_DEVICES=0
|
||||
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
|
||||
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
|
||||
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
|
||||
Environment=SERVED_NAMES=frank-medium
|
||||
Environment=PORT=7771
|
||||
Environment=TP=1
|
||||
Environment=MAXSEQS=4
|
||||
Environment=CHUNK=8192
|
||||
Environment=MAXLEN=114688
|
||||
Environment=GPU_UTIL=0.98
|
||||
Environment=KV_MEM=0
|
||||
Environment=SPEC_METHOD=dflash
|
||||
Environment=SPEC=7
|
||||
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards).
|
||||
Volume=/home/dank/models:/models
|
||||
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint.
|
||||
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
|
||||
# Compile cache. Shared with the other replica and with the TP=2 target: the
|
||||
# entries are content-addressed, so shapes simply add subdirectories.
|
||||
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
|
||||
# The pinned radiance clone: patches applied at every container start.
|
||||
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
|
||||
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's
|
||||
# shipped r4d.so predates the GDN overflow fix and NaNs this model.
|
||||
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
|
||||
# Entrypoint + healthcheck from this checkout, mounted read-only.
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
|
||||
# The entrypoint must be the container command; the image's own entrypoint
|
||||
# (which the patched script execs at the end) must not run unpatched.
|
||||
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
|
||||
# 200 only once the engine serves; during first-start compilation the
|
||||
# container is legitimately unhealthy. HealthCmd is split on spaces into an
|
||||
# argv array by the podman 5.x v1 generator, hence the file-based probe.
|
||||
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
|
||||
HealthInterval=60s
|
||||
HealthTimeout=30s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=1800s
|
||||
Pull=never
|
||||
|
||||
[Service]
|
||||
# First start compiles Triton/inductor kernels (several minutes) before the
|
||||
# engine loads ~20 GiB of weights.
|
||||
TimeoutStartSec=1800
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,98 +0,0 @@
|
||||
# Quadlet unit: frank-medium replica B (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-b.container
|
||||
# Unit name: frank-medium-b.service (container name: frank-medium-a)
|
||||
#
|
||||
# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one
|
||||
# tensor-parallel process across both R9700s (see frank-medium.container, kept
|
||||
# as the target). That shape cannot run on this host: RCCL's collective kernels
|
||||
# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to
|
||||
# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled,
|
||||
# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The
|
||||
# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise
|
||||
# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable
|
||||
# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root()
|
||||
# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS.
|
||||
# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have
|
||||
# atomics. Result: every collective that touches GPU 2 dies at launch.
|
||||
# Two independent single-GPU replicas use both cards instead; the proxy unit
|
||||
# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook.
|
||||
#
|
||||
# SHAPE. vLLM binds 0.0.0.0:7772 on the host network directly; no PublishPort.
|
||||
# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the
|
||||
# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and
|
||||
# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile
|
||||
# this shape instead of using the 2x7551-32624 pin, which is only valid at
|
||||
# TP=2/262144.
|
||||
#
|
||||
# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=1 (the second R9700). The
|
||||
# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin.
|
||||
# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters
|
||||
# first and HIP indexes into the filtered list, so a single-valued pair
|
||||
# (ROCR=1,HIP=1) leaves an empty device set.
|
||||
#
|
||||
# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the
|
||||
# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident).
|
||||
[Unit]
|
||||
Description=frank-medium replica B — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #2
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=frank-medium-b
|
||||
Image=localhost/radiance-vllm:0.9.3
|
||||
AddDevice=/dev/kfd
|
||||
AddDevice=/dev/dri
|
||||
GroupAdd=keep-groups
|
||||
# The upstream launcher's exact podman posture (rootless).
|
||||
PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host
|
||||
Environment=HIP_VISIBLE_DEVICES=1
|
||||
Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq
|
||||
Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8
|
||||
Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja
|
||||
Environment=SERVED_NAMES=frank-medium
|
||||
Environment=PORT=7772
|
||||
Environment=TP=1
|
||||
Environment=MAXSEQS=4
|
||||
Environment=CHUNK=8192
|
||||
Environment=MAXLEN=114688
|
||||
Environment=GPU_UTIL=0.98
|
||||
Environment=KV_MEM=0
|
||||
Environment=SPEC_METHOD=dflash
|
||||
Environment=SPEC=7
|
||||
# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards).
|
||||
Volume=/home/dank/models:/models
|
||||
# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint.
|
||||
Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface
|
||||
# Compile cache. Shared with the other replica and with the TP=2 target: the
|
||||
# entries are content-addressed, so shapes simply add subdirectories.
|
||||
Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache
|
||||
# The pinned radiance clone: patches applied at every container start.
|
||||
Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z
|
||||
# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's
|
||||
# shipped r4d.so predates the GDN overflow fix and NaNs this model.
|
||||
Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z
|
||||
# Entrypoint + healthcheck from this checkout, mounted read-only.
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
|
||||
# The entrypoint must be the container command; the image's own entrypoint
|
||||
# (which the patched script execs at the end) must not run unpatched.
|
||||
PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh
|
||||
# 200 only once the engine serves; during first-start compilation the
|
||||
# container is legitimately unhealthy. HealthCmd is split on spaces into an
|
||||
# argv array by the podman 5.x v1 generator, hence the file-based probe.
|
||||
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
|
||||
HealthInterval=60s
|
||||
HealthTimeout=30s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=1800s
|
||||
Pull=never
|
||||
|
||||
[Service]
|
||||
# First start compiles Triton/inductor kernels (several minutes) before the
|
||||
# engine loads ~20 GiB of weights.
|
||||
TimeoutStartSec=1800
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,61 +0,0 @@
|
||||
# Quadlet unit: frank-medium proxy (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankserver: ~/.config/containers/systemd/frank-medium-proxy.container
|
||||
# Unit name: frank-medium-proxy.service (container name: frank-medium-proxy)
|
||||
#
|
||||
# WHAT IT IS. A prefix-affinity reverse proxy in front of the two single-GPU
|
||||
# frank-medium replicas (:7771 on R9700 #1, :7772 on R9700 #2). It preserves
|
||||
# the fleet contract: one endpoint on host port 7770 answering as the served
|
||||
# model name frank-medium, OpenAI /v1, streaming included.
|
||||
#
|
||||
# WHY IT EXISTS. TP=2 — one vLLM process spanning both cards — is impossible
|
||||
# on this host (see frank-medium-a.container and the runbook: RCCL's kernels
|
||||
# need PCIe atomics, and the second card's chipset path advertises
|
||||
# AtomicOpsCap: Routing-). Two replicas are how both cards get used.
|
||||
#
|
||||
# ROUTING. Requests are keyed on a hash of their leading 4 KiB of prompt text,
|
||||
# so a conversation family — an agent loop re-sending the same system prompt,
|
||||
# tool list and file context every turn — keeps landing on the same replica,
|
||||
# where --enable-prefix-caching has that prefix warm. Plain round-robin threw
|
||||
# half of those cache hits away. A replica that is down is skipped, and
|
||||
# ROUTING=roundrobin restores the old behaviour for A/B measurement. /health
|
||||
# answers 200 while at least one replica is up and names the ones that are
|
||||
# down. The proxy runs on host networking so it can bind 7770 and reach the
|
||||
# replicas on loopback.
|
||||
#
|
||||
# The script is a few dozen lines of aiohttp, mounted read-only out of
|
||||
# /home/dank/serverAppConfigs (deploy.sh pulls that checkout before dispatch).
|
||||
# It reuses the same image as the replicas: aiohttp is already there and no
|
||||
# extra image has to be pulled on this host.
|
||||
[Unit]
|
||||
Description=frank-medium proxy — prefix-affinity in front of the two single-GPU replicas
|
||||
After=network-online.target
|
||||
Wants=frank-medium-a.service frank-medium-b.service
|
||||
After=frank-medium-a.service frank-medium-b.service
|
||||
|
||||
[Container]
|
||||
ContainerName=frank-medium-proxy
|
||||
Image=localhost/radiance-vllm:0.9.3
|
||||
PodmanArgs=--network=host
|
||||
Environment=PORT=7770
|
||||
Environment=UPSTREAMS=http://127.0.0.1:7771,http://127.0.0.1:7772
|
||||
# The proxy is two files: routing.py holds the replica-selection policy, the
|
||||
# server holds the HTTP plumbing. Both are mounted because the server imports
|
||||
# the policy by name from its own directory (/usr/local/bin is sys.path[0]).
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/routing.py:/usr/local/bin/routing.py:ro
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/affinity-proxy.py:/usr/local/bin/affinity-proxy.py:ro
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro
|
||||
PodmanArgs=--entrypoint /usr/local/bin/affinity-proxy.py
|
||||
HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py
|
||||
HealthInterval=60s
|
||||
HealthTimeout=30s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=60s
|
||||
Pull=never
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Routing policy for the frank-medium proxy: which replica serves a request.
|
||||
|
||||
Kept apart from the HTTP plumbing in affinity-proxy.py so the decisions can be
|
||||
tested without aiohttp, a network or a GPU — the first version of these rules
|
||||
shipped with a bug that only appeared on a continuation request, and a test
|
||||
that loads this module catches that class of mistake directly.
|
||||
|
||||
Two cards, two behaviours, and the workload decides which is right:
|
||||
|
||||
* A **single-turn** request is one member of a batch (a benchmark run, a burst
|
||||
of parallel subagents). Spreading those over both replicas lets both cards
|
||||
prefill at once; measured on this host, a 16-request burst of 15k-token
|
||||
prompts finishes in 18.6 s spread versus 30.1 s pinned.
|
||||
* A **continuation** re-sends a prefix the replica has already prefilled. Pinned
|
||||
to that replica the turn costs a cache lookup; spread, half the turns pay a
|
||||
cold prefill again (measured: 1096 ms versus 2676 ms mean warm-turn TTFT on
|
||||
90k-token contexts).
|
||||
|
||||
ROUTING picks the strategy; the default `adaptive` applies both rules above.
|
||||
`affinity` and `roundrobin` exist so the choice can be re-measured, and are not
|
||||
production settings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
|
||||
UPSTREAMS = [u.strip() for u in os.environ.get(
|
||||
"UPSTREAMS", "http://127.0.0.1:7771,http://127.0.0.1:7772").split(",") if u.strip()]
|
||||
ROUTING = os.environ.get("ROUTING", "adaptive").strip().lower()
|
||||
# Leading bytes of prompt text that identify a conversation's shared prefix.
|
||||
# 4 KiB covers a system prompt plus the start of the first user turn, which is
|
||||
# where agent loops keep the text they resend every turn.
|
||||
AFFINITY_BYTES = int(os.environ.get("AFFINITY_BYTES", "4096"))
|
||||
# Prefixes remembered for pinning. Bounded so a long-lived proxy cannot grow
|
||||
# without limit; 4096 conversations is far beyond what this fleet runs at once.
|
||||
KNOWN_MAX = int(os.environ.get("KNOWN_MAX", "4096"))
|
||||
|
||||
_counter = itertools.count()
|
||||
# digest -> replica index, insertion-ordered for O(1) LRU eviction. A prefix is
|
||||
# remembered as soon as any request carries it; only a continuation is pinned
|
||||
# there, because only a continuation can profit from the cache entry.
|
||||
_known: "collections.OrderedDict[bytes, int]" = collections.OrderedDict()
|
||||
|
||||
|
||||
def affinity_key(body: bytes, remote: str | None) -> tuple[bytes, bool]:
|
||||
"""Key a request by its shared prefix, and report whether it continues one.
|
||||
|
||||
Chat and completions bodies both carry their prompt text in JSON, so the
|
||||
leading bytes of that text are the prefix; a body we cannot parse (or an
|
||||
endpoint with no prompt, e.g. /v1/models) falls back to the client address,
|
||||
which still beats nothing for a single-client loop.
|
||||
"""
|
||||
fallback = (remote or "anonymous").encode()
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except Exception:
|
||||
return fallback, False
|
||||
|
||||
text = ""
|
||||
continuation = False
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("prompt"), str):
|
||||
text = payload["prompt"]
|
||||
elif isinstance(payload.get("messages"), list):
|
||||
continuation = len(payload["messages"]) > 1
|
||||
parts = []
|
||||
for msg in payload["messages"]:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
parts.append(content)
|
||||
elif isinstance(content, list): # multimodal content blocks
|
||||
parts.extend(
|
||||
blk.get("text", "") for blk in content
|
||||
if isinstance(blk, dict) and isinstance(blk.get("text"), str)
|
||||
)
|
||||
text = "\n".join(parts)
|
||||
if not text:
|
||||
return fallback, continuation
|
||||
return text[:AFFINITY_BYTES].encode("utf-8", "replace"), continuation
|
||||
|
||||
|
||||
def pick(key: bytes, continuation: bool, up: list[bool]) -> int:
|
||||
"""Replica for a request, or -1 when none is available.
|
||||
|
||||
Down replicas are never selected — a rollout must not send traffic to an
|
||||
engine that is still compiling its graphs — and a pin whose replica is down
|
||||
is re-established on a live one rather than kept pointing at a corpse.
|
||||
"""
|
||||
digest = hashlib.sha1(key).digest()
|
||||
|
||||
if ROUTING == "roundrobin":
|
||||
start = next(_counter) % len(UPSTREAMS)
|
||||
elif ROUTING == "adaptive":
|
||||
entry = _known.get(digest)
|
||||
if continuation and entry is not None and up[entry]:
|
||||
_known.move_to_end(digest)
|
||||
return entry
|
||||
# Anything that is not a continuation spreads by counter — including a
|
||||
# batch of requests that happen to share one prefix. Hashing the prefix
|
||||
# would send every member of such a batch to whichever card the hash
|
||||
# names (measured: 29.8 s for a 16-request burst, against 18.6 s when
|
||||
# spread). The counter is only advanced by these requests, so batch
|
||||
# members alternate across the cards.
|
||||
start = next(_counter) % len(UPSTREAMS)
|
||||
else: # affinity
|
||||
start = int.from_bytes(digest[:8], "big") % len(UPSTREAMS)
|
||||
|
||||
for offset in range(len(UPSTREAMS)):
|
||||
idx = (start + offset) % len(UPSTREAMS)
|
||||
if up[idx]:
|
||||
if ROUTING == "adaptive":
|
||||
_known[digest] = idx
|
||||
_known.move_to_end(digest)
|
||||
if len(_known) > KNOWN_MAX:
|
||||
_known.popitem(last=False)
|
||||
return idx
|
||||
return -1
|
||||
|
||||
|
||||
def route(body: bytes, remote: str | None, up: list[bool]) -> int:
|
||||
"""Convenience wrapper: decode the request body and choose a replica."""
|
||||
return pick(*affinity_key(body, remote), up)
|
||||
@@ -43,7 +43,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=calibre-server
|
||||
Image=lscr.io/linuxserver/calibre:9.15.0
|
||||
Image=lscr.io/linuxserver/calibre:9.17.0@sha256:b198fc03a6abc6be7e251a9958e50e74634b00e447c7b25e92c191c8077ba378
|
||||
Network=pasta
|
||||
EnvironmentFile=/home/dank/Containers/calibre-server/env
|
||||
Environment=PUID=1000
|
||||
|
||||
@@ -16,7 +16,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=calibre-web
|
||||
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls402
|
||||
Image=lscr.io/linuxserver/calibre-web:0.6.27-ls407@sha256:470b4ed9a7d6ea1475fef17dbcf957cb60880910296d226bd0dca652c05f68ca
|
||||
Network=pasta
|
||||
Environment=DOCKER_MODS=linuxserver/mods:universal-calibre
|
||||
Environment=LANG=en_US.UTF-8
|
||||
|
||||
+6
-28
@@ -94,34 +94,12 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Jellyfin — media server (movies, TV, music)
|
||||
# ═══════════════════════════════════════════════
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
container_name: jellyfin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8096:8096/tcp"
|
||||
- "8920:8920/tcp"
|
||||
- "7359:7359/udp"
|
||||
- "1900:1900/udp"
|
||||
environment:
|
||||
- JELLYFIN_PublishedServerUrl=http://192.168.0.235:8096
|
||||
- DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
|
||||
volumes:
|
||||
- /opt/jellyfin/config:/config
|
||||
- /opt/jellyfin/cache:/cache
|
||||
- /mnt/entropy:/data:ro
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
group_add:
|
||||
- "992"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8096/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# Jellyfin left this file when it became a Quadlet: the container is owned by
|
||||
# dankserver/jellyfin/quadlet/jellyfin.container, its data roots are under
|
||||
# /home/dank/Containers/jellyfin, and its library arrives through the SSD
|
||||
# overlay at /var/lib/media-meta/view (scripts/media-view.sh). The block that
|
||||
# was here named /opt/jellyfin/* and a read-only array bind — a second owner for
|
||||
# the container name `jellyfin`, wrong on every path. One owner per container.
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Calibre-Web — e-book web frontend
|
||||
|
||||
@@ -21,7 +21,13 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=copyparty
|
||||
Image=ghcr.io/9001/copyparty-ac:latest
|
||||
# Digest-pinned: upstream stopped publishing version tags after 1.19.15, so
|
||||
# the pin rides :latest@digest (the tag in the reference is cosmetic; the
|
||||
# digest is the pull). Pinned 2026-10-21 at the build whose label is
|
||||
# 1.20.26 - closes GHSA-3c88-p299-7hvr (1.20.25) and GHSA-hg99-jqfp-rf76
|
||||
# (1.20.26); the previously running build was the rolling :latest at
|
||||
# 1.20.24 (issue #73 on command-center).
|
||||
Image=ghcr.io/9001/copyparty-ac:latest@sha256:7f77459160a4bf34fdca79f2965dff4f0c1d7069a3d9cdfb3632acb041dd2f6a
|
||||
Network=dankserver_appnet
|
||||
EnvironmentFile=/home/dank/Containers/copyparty/env
|
||||
Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD} -v /w::r:ra,dank
|
||||
@@ -30,6 +36,9 @@ Volume=/home/dank/Containers/copyparty/cfg:/cfg
|
||||
PublishPort=3923:3923
|
||||
|
||||
[Service]
|
||||
# /w is the array: serving the pre-mount directory instead of the volume shows an
|
||||
# empty share and nothing complains, so the mount has to answer first.
|
||||
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /mnt/entropy
|
||||
EnvironmentFile=/home/dank/Containers/copyparty/env
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
entropy UUID=cd86c728-459e-4f3a-bcc2-41ecf84685ec /etc/entropy nofail,luks
|
||||
@@ -0,0 +1,18 @@
|
||||
# /etc/fstab: static file system information.
|
||||
#
|
||||
# Use 'blkid' to print the universally unique identifier for a
|
||||
# device; this may be used with UUID= as a more robust way to name devices
|
||||
# that works even if disks are added and removed. See fstab(5).
|
||||
#
|
||||
# <file system> <mount point> <type> <options> <dump> <pass>
|
||||
# / was on /dev/nvme0n1p2 during curtin installation
|
||||
/dev/disk/by-uuid/4f3731d2-fb7b-48e7-a79f-0e33cb8a4162 / ext4 defaults 0 1
|
||||
# /boot/efi was on /dev/nvme0n1p1 during curtin installation
|
||||
/dev/disk/by-uuid/183C-442D /boot/efi vfat defaults 0 1
|
||||
#/dev/disk/by-label/archive-one /mnt/archive-one auto nosuid,nodev,nofail,x-gvfs-show 0 0
|
||||
#/dev/disk/by-label/archive-two /mnt/archive-two auto nosuid,nodev,nofail,x-gvfs-show 0 0
|
||||
# /dev/md0 is a LUKS container, not a filesystem; its ext4 is mounted via
|
||||
# /dev/mapper/entropy on the next line. Mounting /dev/md0 directly fails.
|
||||
#/dev/md0 /mnt/md0 ext4 auto,nofail,nosuid,nodev 0 0
|
||||
/dev/mapper/entropy /mnt/entropy ext4 auto,nosuid,nofail,nodev,x-gvfs-show 0 0
|
||||
/swapfile none swap sw 0 0
|
||||
@@ -0,0 +1,30 @@
|
||||
# /etc/systemd/system/var-lib-media\x2dmeta-view.mount
|
||||
# (repo copy: dankserver/host/systemd/media-meta-view.mount — systemd names a
|
||||
# mount unit after its ESCAPED path, so the host file carries the \x2d spelling)
|
||||
#
|
||||
# Installed by `scripts/media-view.sh install` — the repo copy is the source of
|
||||
# truth (fleet law: never edit host state by hand).
|
||||
#
|
||||
# An OverlayFS merge that shows the media library as one tree while splitting
|
||||
# where its bytes live: lowerdir is the RAID6 array (the source of truth, never
|
||||
# written), upperdir is a directory on the NVMe that receives every write. The
|
||||
# two media servers bind the result at /var/lib/media-meta/view, so Jellyfin's
|
||||
# sidecars and artwork are SSD bytes — which is what makes them fast to browse
|
||||
# and what keeps Plex's NFO agent off the spinners — while the media itself stays
|
||||
# on the array untouched.
|
||||
#
|
||||
# RequiresMountsFor is load-bearing: without the array mounted, lowerdir is an
|
||||
# empty directory and the view would present an empty library as truth.
|
||||
|
||||
[Unit]
|
||||
Description=SSD-backed overlay view of the media library (array + NVMe metadata layer)
|
||||
RequiresMountsFor=/mnt/entropy
|
||||
|
||||
[Mount]
|
||||
What=overlay
|
||||
Where=/var/lib/media-meta/view
|
||||
Type=overlay
|
||||
Options=lowerdir=/mnt/entropy,upperdir=/var/lib/media-meta/upper,workdir=/var/lib/media-meta/work
|
||||
|
||||
[Install]
|
||||
WantedBy=local-fs.target
|
||||
@@ -0,0 +1,14 @@
|
||||
# The io controller is what makes IOWeight= mean anything, and systemd does not
|
||||
# enable it below `user.slice` by default: with io accounting off, `io` never
|
||||
# appears in `user.slice/cgroup.subtree_control`, so no rootless container can
|
||||
# have an `io.weight` file and `IOWeight=` in a quadlet is silently inert.
|
||||
# Measured on Dankserver 2026-10-31: setting this property flipped
|
||||
# `user.slice/cgroup.subtree_control` from `cpu memory pids` to `cpu io memory
|
||||
# pids` immediately, with nothing restarted.
|
||||
#
|
||||
# This is half the fix; the other half is delegating io to user@1000.service
|
||||
# (user@1000.service.d/io-delegate.conf). Both must be in place before a reboot,
|
||||
# because the delegation is read when the user manager starts. Law and the
|
||||
# verification command: [[Quadlet Lifecycle]].
|
||||
[Slice]
|
||||
IOAccounting=yes
|
||||
@@ -0,0 +1,12 @@
|
||||
# The system manager gives the user manager cpu/memory/pids by default (systemd
|
||||
# 259: `systemctl show user@1000.service -p DelegateControllers` → `cpu memory
|
||||
# pids`). Without `io` in that set no `io.weight` file can exist anywhere below
|
||||
# `user.slice`, so `IOWeight=` in a rootless quadlet is silently inert — the same
|
||||
# class of trap as `RequiresMountsFor=`. Granting the controller here is what
|
||||
# makes the media servers' I/O share real.
|
||||
#
|
||||
# Inert until `user@1000.service` starts again, i.e. the next reboot (restarting
|
||||
# it would kill every rootless container on the host). Verify after the reboot
|
||||
# with the check in [[Quadlet Lifecycle]].
|
||||
[Service]
|
||||
DelegateControllers=cpu memory pids io
|
||||
@@ -4,8 +4,9 @@
|
||||
# Unit name: jellyfin.service (podman 5.x drops the container- prefix)
|
||||
#
|
||||
# Data roots migrated from /opt/jellyfin to /home/dank/Containers/jellyfin at
|
||||
# cutover. All of /mnt/entropy is mounted read-only at /data: nothing automated
|
||||
# anywhere writes to the entropy volume. Every
|
||||
# cutover. The library is seen through /var/lib/media-meta/view, the SSD-backed
|
||||
# overlay view (scripts/media-view.sh): the array stays read-only and untouched,
|
||||
# while the sidecars this server writes land on the NVMe. Every
|
||||
# creation-time environment variable of the compose-era container is kept as
|
||||
# an Environment= line (zero drift); none of them are secrets, so this unit
|
||||
# needs no EnvironmentFile.
|
||||
@@ -16,7 +17,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=jellyfin
|
||||
Image=ghcr.io/jellyfin/jellyfin:latest
|
||||
Image=ghcr.io/jellyfin/jellyfin:latest@sha256:fea863d0cd9ec445985d9c75fc6ed64e38a1d2766cf0c250a33d553f4fc1b771
|
||||
Network=dankserver_appnet
|
||||
Environment=DEBIAN_FRONTEND=noninteractive
|
||||
Environment=DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true
|
||||
@@ -41,7 +42,11 @@ PublishPort=7359:7359/udp
|
||||
PublishPort=1900:1900/udp
|
||||
Volume=/home/dank/Containers/jellyfin/config:/config
|
||||
Volume=/home/dank/Containers/jellyfin/cache:/cache
|
||||
Volume=/mnt/entropy:/data:ro
|
||||
# The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo /
|
||||
# episode.nfo + artwork) and those bytes belong on the SSD. The gate in [Service]
|
||||
# is the guard: started against a plain, unmounted view directory a scan decides
|
||||
# the library is empty and deletes what it cannot see.
|
||||
Volume=/var/lib/media-meta/view:/data
|
||||
AddDevice=/dev/dri
|
||||
GroupAdd=keep-groups
|
||||
HealthCmd=curl -f http://localhost:8096/health
|
||||
@@ -50,8 +55,19 @@ HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
|
||||
[Service]
|
||||
# Refuse to start until the bind source is a real mount — see the header of
|
||||
# scripts/wait-for-mount.sh for the blind-boot incident this prevents.
|
||||
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/media-meta/view /mnt/entropy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# A library scan reads the array for minutes on end. Weighted LOW (default is
|
||||
# 100) so that when the array is contended a scan loses to playback and to every
|
||||
# other container this user runs. Proportional share, not a cap: it changes
|
||||
# nothing while the disk is idle. 10 is a convention — the requirement is only
|
||||
# that it sit far below 100 — and it is inert until user@1000.service is
|
||||
# delegated the io controller ([[Quadlet Lifecycle]]).
|
||||
IOWeight=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,39 @@
|
||||
# Quadlet unit: cadvisor (rootless, boot-persistent) — DANKSERVER.
|
||||
#
|
||||
# Install path on dankserver: ~/.config/containers/systemd/cadvisor.container
|
||||
# Unit name: cadvisor.service (container name: cadvisor)
|
||||
#
|
||||
# The Dankserver twin of dankclaw/observability/quadlet/cadvisor.container.
|
||||
# Same rootless shape, same --cgroupns=host requirement (see that file for the
|
||||
# full story), only the LAN address differs: this host publishes on
|
||||
# 192.168.0.235:8080 so Prometheus on Dankclaw can scrape it over the real
|
||||
# network (cross-host, so the host.containers.internal trick does not apply).
|
||||
# It reports the media/library stack (romm, plex, jellyfin, copyparty, calibre,
|
||||
# tautulli) by their quadlet unit names via the cgroup path.
|
||||
|
||||
[Unit]
|
||||
Description=cAdvisor container metrics (Dankserver)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=cadvisor
|
||||
Image=gcr.io/cadvisor/cadvisor:v0.49.1@sha256:3cde6faf0791ebf7b41d6f8ae7145466fed712ea6f252c935294d2608b1af388
|
||||
PodmanArgs=--pid=host --cgroupns=host
|
||||
PublishPort=127.0.0.1:8080:8080
|
||||
PublishPort=192.168.0.235:8080:8080
|
||||
Volume=/sys:/sys:ro
|
||||
Volume=/:/rootfs:ro
|
||||
Exec=--housekeeping_interval=30s
|
||||
HealthCmd=wget --spider -q http://localhost:8080/healthz
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=40s
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -37,7 +37,7 @@ Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=node-exporter
|
||||
Image=docker.io/prom/node-exporter:latest
|
||||
Image=docker.io/prom/node-exporter:latest@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0
|
||||
Network=host
|
||||
PodmanArgs=--pid=host
|
||||
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:9100 --web.listen-address=192.168.0.235:9100
|
||||
|
||||
@@ -4,19 +4,33 @@ After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=lscr.io/linuxserver/plex:1.43.4.10903-e5521bd8c-ls325
|
||||
Image=lscr.io/linuxserver/plex:1.43.5.11079-b3b13e506-ls330@sha256:01a53d9c7adcbb3701fbb0a4230c0a37c85453759b6b05a67356c3c7a09de08d
|
||||
ContainerName=plex
|
||||
Network=dankserver_appnet:alias=plex
|
||||
Environment=PUID=0
|
||||
Environment=PGID=0
|
||||
Environment=TZ=America/Los_Angeles
|
||||
Volume=/var/lib/plexmediaserver:/config
|
||||
Volume=/mnt/entropy:/mnt/entropy:ro
|
||||
# The container path stays /mnt/entropy on purpose: every path Plex recorded in
|
||||
# its own database is written against it, so swapping the HOST source of this bind
|
||||
# to the overlay view re-points Plex at the sidecars without touching its DB.
|
||||
# Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata.
|
||||
Volume=/var/lib/media-meta/view:/mnt/entropy:ro
|
||||
PublishPort=32400:32400
|
||||
|
||||
[Service]
|
||||
# Refuse to start until the bind source is a real mount: Plex blind is how paths
|
||||
# get marked missing in its own database. Both the view and the array under it
|
||||
# must answer — scripts/wait-for-mount.sh names the 2026-10-31 incident.
|
||||
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/media-meta/view /mnt/entropy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# See the jellyfin unit for the reasoning: a section scan gets a LOW weight
|
||||
# (default 100) so a walk of 50k files loses the array to playback and to every
|
||||
# other container under this user. Inert until user@1000.service holds the io
|
||||
# controller ([[Quadlet Lifecycle]]).
|
||||
IOWeight=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -14,7 +14,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=romm-mariadb
|
||||
Image=docker.io/library/mariadb:11
|
||||
Image=docker.io/library/mariadb:11@sha256:c4124d1afa875e47f2892edf26b482fa54f796e2f342d3717a04ca091a22a9bd
|
||||
Network=dankserver_appnet:alias=mariadb
|
||||
EnvironmentFile=/home/dank/Containers/romm/env
|
||||
Environment=MARIADB_DATABASE=romm
|
||||
|
||||
@@ -12,7 +12,7 @@ After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=romm-redis
|
||||
Image=docker.io/library/redis:7-alpine
|
||||
Image=docker.io/library/redis:7-alpine@sha256:8da19c7399dca900c6822a1cf30f9d2285585e2c4b457ff4095b0f4500054d3f
|
||||
Network=dankserver_appnet:alias=redis
|
||||
Volume=/home/dank/Containers/romm/redis:/data
|
||||
HealthCmd=redis-cli ping
|
||||
|
||||
@@ -18,7 +18,7 @@ After=romm-mariadb.service romm-redis.service network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=romm
|
||||
Image=ghcr.io/rommapp/romm:latest
|
||||
Image=ghcr.io/rommapp/romm:latest@sha256:8677fb7b81b1d3db20b849e989a1dadc2a11159e51eb34a2acdcb01102274edf
|
||||
Network=dankserver_appnet
|
||||
EnvironmentFile=/home/dank/Containers/romm/env
|
||||
Environment=DB_HOST=mariadb
|
||||
|
||||
@@ -4,7 +4,7 @@ After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls245
|
||||
Image=lscr.io/linuxserver/tautulli:v2.18.2-ls250@sha256:529c7eabcc40554c1dc48f33f1f3cc3b1438462d73e0ec2dac8ac0b66aeeb8d4
|
||||
ContainerName=tautulli
|
||||
Network=dankserver_appnet:alias=tautulli
|
||||
Environment=PUID=0
|
||||
|
||||
@@ -19,7 +19,7 @@ Description=Gluetun VPN tunnel (FrankBook-Pro podman machine)
|
||||
|
||||
[Container]
|
||||
ContainerName=gluetun
|
||||
Image=docker.io/qmcgaw/gluetun:latest
|
||||
Image=docker.io/qmcgaw/gluetun:latest@sha256:c2ee4191762fca6b6a3c3359a731198a95d9c9af4db32d466e223bf3a84e779b
|
||||
AddCapability=NET_ADMIN
|
||||
AddDevice=/dev/net/tun:/dev/net/tun
|
||||
Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}
|
||||
|
||||
@@ -15,7 +15,7 @@ After=gluetun.service
|
||||
|
||||
[Container]
|
||||
ContainerName=lidarr
|
||||
Image=lscr.io/linuxserver/lidarr:latest
|
||||
Image=lscr.io/linuxserver/lidarr:latest@sha256:d8549ff9ea29b4015c32257360652c1000c31ea471a2b3f0835ae77292faf35b
|
||||
Environment=PUID=0
|
||||
Environment=PGID=0
|
||||
Environment=TZ=America/Los_Angeles
|
||||
|
||||
@@ -24,7 +24,7 @@ After=gluetun.service
|
||||
|
||||
[Container]
|
||||
ContainerName=prowlarr
|
||||
Image=lscr.io/linuxserver/prowlarr:latest
|
||||
Image=lscr.io/linuxserver/prowlarr:latest@sha256:b3b00047ed20f6ce2b16780145cb6a0acb51c9e28ea6aa6f5dcd33c7034f1797
|
||||
Environment=PUID=0
|
||||
Environment=PGID=0
|
||||
Environment=TZ=America/Los_Angeles
|
||||
|
||||
@@ -29,7 +29,7 @@ Network=gluetun.container
|
||||
# Pinned to the last 4.x line: Readarr's retired qB client requires the
|
||||
# qB 4.x login response (HTTP 200, body "Ok."); qB 5.x answers 204 with an
|
||||
# empty body, which fails Readarr's auth check. All other clients accept 4.x.
|
||||
Image=lscr.io/linuxserver/qbittorrent:4.6.7
|
||||
Image=lscr.io/linuxserver/qbittorrent:4.6.7@sha256:55f15d44396315551f87294a176efae733b16e283f38980308e46073950257c6
|
||||
Environment=PUID=0
|
||||
Environment=PGID=0
|
||||
Environment=TZ=America/Los_Angeles
|
||||
|
||||
@@ -15,7 +15,7 @@ After=gluetun.service
|
||||
|
||||
[Container]
|
||||
ContainerName=radarr
|
||||
Image=lscr.io/linuxserver/radarr:latest
|
||||
Image=lscr.io/linuxserver/radarr:latest@sha256:fc0ae3c50f8128b97e84381903ec8d6845c3a569641cb7117c124376cccb57c1
|
||||
Environment=PUID=0
|
||||
Environment=PGID=0
|
||||
Environment=TZ=America/Los_Angeles
|
||||
|
||||
@@ -19,7 +19,7 @@ After=gluetun.service
|
||||
|
||||
[Container]
|
||||
ContainerName=readarr
|
||||
Image=lscr.io/linuxserver/readarr:amd64-1.0.980
|
||||
Image=lscr.io/linuxserver/readarr:amd64-1.0.980@sha256:a95e82dbb0eb2bb43de4c150f1dab9dd821306c67ea0b418983a957e731f2b61
|
||||
Environment=PUID=0
|
||||
Environment=PGID=0
|
||||
Environment=TZ=America/Los_Angeles
|
||||
|
||||
@@ -16,7 +16,7 @@ After=gluetun.service
|
||||
|
||||
[Container]
|
||||
ContainerName=sonarr
|
||||
Image=lscr.io/linuxserver/sonarr:latest
|
||||
Image=lscr.io/linuxserver/sonarr:latest@sha256:889dcc58c6aea859033985894b519b2e6b1e55d92527ed123f9f52e42ae7c97b
|
||||
Environment=PUID=0
|
||||
Environment=PGID=0
|
||||
Environment=TZ=America/Los_Angeles
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bench-spec-ab.py — settle "which proposer should this endpoint use?" with data.
|
||||
|
||||
Runs one payload through several speculative-decoding configurations of the same
|
||||
binary, one at a time, with the unit stopped between runs: on a single-GPU host
|
||||
there is no room for two copies of a 29 GiB model, and the benchmarking policy
|
||||
applies. Every configuration gets identical prompts at temperature 0, and the
|
||||
greedy text is compared across them — speculation may buy speed, never change
|
||||
the answer.
|
||||
|
||||
bench-spec-ab.py <env-file> <serve-script> [options]
|
||||
bench-spec-ab.py /home/dank/serverAppConfigs/scripts/frank-medium.env \
|
||||
/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh \
|
||||
--configs "dflash=draft-dflash@7;ngram=ngram-mod@64;chain=draft-dflash,ngram-mod@7"
|
||||
|
||||
Config syntax: `name=SPEC_TYPE[@n_max][|ENV=VAL|ENV=VAL]`, entries joined by `;`.
|
||||
The systemd unit is restored in a finally block, so it comes back on whatever the
|
||||
env file says even if a configuration crashes the harness.
|
||||
|
||||
THREE SHAPES, because a single one answers the wrong question. A proposer can
|
||||
only draft text it has seen, so the shape decides the verdict:
|
||||
|
||||
prose a 1k prompt asking for one sentence. Nothing to copy and nothing
|
||||
repetitive, so only a model-backed proposer can help: this is the
|
||||
shape that compares drafters.
|
||||
echo a ~1.5k passage of unique lines, "repeat it back verbatim". The
|
||||
output IS the prompt, which is ngram-mod's home turf.
|
||||
structured "25 JSON records, one per line" — repetitive OUTPUT with no prompt
|
||||
overlap, which is what an ngram matcher is really for in an
|
||||
agentic fleet (config edits, tables, record lists).
|
||||
|
||||
echo and structured disable thinking (`chat_template_kwargs`), otherwise the
|
||||
model thinks the answer into `reasoning_content`, `content` comes back empty and
|
||||
the cross-configuration identity check passes vacuously — which is exactly how
|
||||
this harness first "verified" an echo it never looked at.
|
||||
|
||||
Results on Dankserver (frank-medium payload, 2026-09-16, temperature 0). Decode:
|
||||
|
||||
shape dflash=7 ngram-mod(48/64) chain (tok/s)
|
||||
prose 1k/512 43.8 17.7 (0 drafts) 43.7
|
||||
echo 1.5k/400 76.8 102.5 200.6
|
||||
structured/400 91.1 17.8 (0 drafts) 91.2
|
||||
identity 70.4 17.8 (0 drafts) 70.4
|
||||
|
||||
`draft-mtp,draft-dflash` fails at load (a chain has ONE draft context and MTP
|
||||
builds against whatever model that context holds); ngram-mod has no such
|
||||
constraint — it is draftless and joins the chain unconditionally.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
FILLER = ("The quick brown fox jumps over the lazy dog near the river bank. "
|
||||
"A cache is a component that stores data so future requests are faster. ")
|
||||
TOKENS_PER_REP = 28
|
||||
UNIT = "frank-medium.service"
|
||||
MODEL = "frank-medium"
|
||||
SPEC_GREP = ("adding speculative implementation|- n_max=|- block_size=|creating MTP|"
|
||||
"draft acceptance")
|
||||
|
||||
DEFAULT_CONFIGS = [
|
||||
("none", {"SPEC_TYPE": "none"}),
|
||||
("mtp-n3", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "3"}),
|
||||
("mtp-n7", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "7"}),
|
||||
("dflash-n7", {"SPEC_TYPE": "draft-dflash", "SPEC_DRAFT_N_MAX": "7"}),
|
||||
]
|
||||
|
||||
WORDS = ["latency", "throughput", "cache", "adapter", "scheduler", "allocator",
|
||||
"queue", "kernel", "buffer", "shard", "replica", "window"]
|
||||
|
||||
|
||||
def echo_passage(lines=90):
|
||||
"""Unique lines: no long span repeats inside the prompt, so an n-gram can
|
||||
only fire when the OUTPUT copies the INPUT."""
|
||||
return "\n".join(
|
||||
"Record %03d: the %s of subsystem %d reports state %d with checksum %05d."
|
||||
% (i, WORDS[i % len(WORDS)], i % 7, i % 3, (i * 7919) % 100000)
|
||||
for i in range(lines))
|
||||
|
||||
|
||||
ECHO = (echo_passage() + "\n\nRepeat the passage above verbatim, every line, exactly as "
|
||||
"written, with no commentary before or after it.")
|
||||
STRUCTURED = ("Output exactly 25 JSON records, one per line, no prose and no code fence. "
|
||||
'Each line must look like {"id": N, "state": "active", "region": "us-west-2"} '
|
||||
"with N running from 1 to 25.")
|
||||
PROSE = FILLER * (1000 // TOKENS_PER_REP) + "\n\nReply with a single short sentence."
|
||||
IDENTITY = "List the first five prime numbers, comma separated, then stop."
|
||||
NO_THINK = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
|
||||
def log(msg, logfile):
|
||||
line = "[" + time.strftime("%H:%M:%S") + "] " + str(msg)
|
||||
print(line, flush=True)
|
||||
with open(logfile, "a") as fh:
|
||||
fh.write(line + "\n")
|
||||
|
||||
|
||||
def sh(cmd):
|
||||
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def parse_configs(spec):
|
||||
"""`name=SPEC_TYPE[@n_max][|KEY=VALUE]...`, entries joined by ';'.
|
||||
|
||||
KEY=VALUE parts override any env file key, which is how an impl's own window
|
||||
(SPEC_NGRAM_N_MIN/MAX, for instance) gets swept without hand-editing env
|
||||
files."""
|
||||
out = []
|
||||
for chunk in spec.split(";"):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
name, _, rest = chunk.partition("=")
|
||||
over = {}
|
||||
if "|" in rest:
|
||||
rest, _, extras = rest.partition("|")
|
||||
for item in extras.split("|"):
|
||||
if "=" in item:
|
||||
k, _, v = item.partition("=")
|
||||
over[k.strip()] = v.strip()
|
||||
n_max = None
|
||||
if "@" in rest:
|
||||
rest, _, n_max = rest.partition("@")
|
||||
over["SPEC_TYPE"] = rest.strip()
|
||||
if n_max:
|
||||
over["SPEC_DRAFT_N_MAX"] = n_max.strip()
|
||||
out.append((name.strip(), over))
|
||||
return out
|
||||
|
||||
|
||||
def write_env(src, name, overrides):
|
||||
"""The env file with this configuration's overrides applied."""
|
||||
path = "/tmp/spec-ab-" + name.replace("/", "_") + ".env"
|
||||
with open(src) as fh:
|
||||
lines = fh.read().splitlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
for k, v in overrides.items():
|
||||
if line.startswith(k + "="):
|
||||
line = k + "=" + v
|
||||
break
|
||||
out.append(line)
|
||||
with open(path, "w") as fh:
|
||||
fh.write("\n".join(out) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def post(base, body, timeout=1800):
|
||||
req = urllib.request.Request(
|
||||
base + "/v1/chat/completions", data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def vram():
|
||||
out = sh("rocm-smi --showmeminfo vram 2>/dev/null | grep 'Used Memory'")
|
||||
return [round(int(x) / 1e9, 2) for x in re.findall(r":\s+(\d+)\s*$", out, re.M)][:2]
|
||||
|
||||
|
||||
def wait_health(base, timeout=420):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
if not sh("pgrep -x llama-server >/dev/null && echo up"):
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/health", timeout=5) as r:
|
||||
if r.status == 200 and json.load(r).get("status") == "ok":
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(3)
|
||||
return False
|
||||
|
||||
|
||||
def stop_unit():
|
||||
sh("sudo systemctl stop " + UNIT)
|
||||
sh("pkill -x llama-server")
|
||||
for _ in range(40):
|
||||
if not sh("pgrep -x llama-server || true"):
|
||||
return
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def restore_unit(base, logfile):
|
||||
"""Bring the endpoint back on the deployed configuration. Runs in a
|
||||
finally, because a harness that can leave production down is a hazard."""
|
||||
log("restoring " + UNIT, logfile)
|
||||
sh("sudo systemctl start " + UNIT)
|
||||
for _ in range(120):
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/health", timeout=5) as r:
|
||||
if json.load(r).get("status") == "ok":
|
||||
log(UNIT + " healthy again", logfile)
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(5)
|
||||
log("WARNING: " + UNIT + " did not come back healthy -- check it by hand", logfile)
|
||||
return False
|
||||
|
||||
|
||||
def run_shape(base, prompt, max_tokens, label, extra=None):
|
||||
body = {"model": MODEL, "messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens, "temperature": 0.0}
|
||||
if extra:
|
||||
body.update(extra)
|
||||
d = post(base, body)
|
||||
tm = d["timings"]
|
||||
msg = d["choices"][0]["message"]
|
||||
content = (msg.get("content") or "").strip()
|
||||
row = {"shape": label,
|
||||
"prompt_n": tm["prompt_n"],
|
||||
"prefill_tps": round(1000 * tm["prompt_n"] / tm["prompt_ms"], 1),
|
||||
"predicted_n": tm["predicted_n"],
|
||||
"decode_tps": round(tm["predicted_per_second"], 2),
|
||||
"draft_n": tm.get("draft_n", 0),
|
||||
"accepted": tm.get("draft_n_accepted", 0),
|
||||
"answer": content,
|
||||
"reasoning_len": len(msg.get("reasoning_content") or "")}
|
||||
if label.startswith("echo"):
|
||||
# How much of the answer really is the passage: a configuration that
|
||||
# "wins" by answering something else is not a win.
|
||||
want = {l.strip() for l in echo_passage().splitlines()}
|
||||
got = [l.strip() for l in content.splitlines() if l.strip()]
|
||||
row["copy_ratio"] = round(sum(1 for l in got if l in want) / len(got), 3) if got else 0.0
|
||||
return row
|
||||
|
||||
|
||||
def measure(base):
|
||||
run_shape(base, FILLER * (4000 // TOKENS_PER_REP) +
|
||||
"\n\nReply with a single short sentence.", 32, "warmup")
|
||||
return [
|
||||
run_shape(base, PROSE, 512, "prose 1k/512"),
|
||||
run_shape(base, ECHO, 400, "echo 1.5k/400", NO_THINK),
|
||||
run_shape(base, STRUCTURED, 400, "structured/400", NO_THINK),
|
||||
run_shape(base, IDENTITY, 192, "identity"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("env_file")
|
||||
ap.add_argument("serve_script")
|
||||
ap.add_argument("--base-url", default="http://127.0.0.1:7770")
|
||||
ap.add_argument("--log", default="/tmp/bench-spec-ab.log")
|
||||
ap.add_argument("--configs", default=None,
|
||||
help="name=SPEC_TYPE[@n_max][|ENV=VAL] joined by ';' "
|
||||
"(default: the four configs measured on 2026-09-16)")
|
||||
ap.add_argument("--json", default="/tmp/bench-spec-ab.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
configs = parse_configs(args.configs) if args.configs else DEFAULT_CONFIGS
|
||||
# Absolute paths: the launcher must not depend on the caller's cwd, and a
|
||||
# relative path plus a `cd` to its own directory double-counts the prefix.
|
||||
env_file = os.path.abspath(args.env_file)
|
||||
serve_script = os.path.abspath(args.serve_script)
|
||||
base, logfile = args.base_url, args.log
|
||||
results = {}
|
||||
log("spec A/B start: " + ", ".join(n for n, _ in configs) +
|
||||
" (unit " + UNIT + " stopped for the duration)", logfile)
|
||||
try:
|
||||
stop_unit()
|
||||
for name, over in configs:
|
||||
env_path = write_env(env_file, name, over)
|
||||
logf = "/tmp/spec-ab-" + name.replace("/", "_") + ".log"
|
||||
sh("FRANK_MEDIUM_ENV_FILE=" + env_path + " setsid bash " + serve_script +
|
||||
" > " + logf + " 2>&1 < /dev/null &")
|
||||
if not wait_health(base):
|
||||
log(name + ": FAILED to become healthy -- " + sh("tail -3 " + logf), logfile)
|
||||
results[name] = {"error": "not healthy", "tail": sh("tail -6 " + logf)}
|
||||
stop_unit()
|
||||
continue
|
||||
rows = measure(base)
|
||||
results[name] = {"vram_gb": vram(), "rows": rows,
|
||||
"spec_log": sh("grep -E '" + SPEC_GREP + "' " + logf + " | tail -4")}
|
||||
for r in rows:
|
||||
log(name + " | " + r["shape"] + " prefill " + str(r["prefill_tps"]) +
|
||||
" tok/s | decode " + str(r["decode_tps"]) + " tok/s | draft " +
|
||||
str(r["draft_n"]) + "/" + str(r["accepted"]) +
|
||||
(" (" + str(round(100.0 * r["accepted"] / r["draft_n"])) + " %)"
|
||||
if r["draft_n"] else "") +
|
||||
(" | copy " + str(r["copy_ratio"]) if "copy_ratio" in r else "") +
|
||||
(" | EMPTY ANSWER" if not r["answer"] else ""), logfile)
|
||||
log(name + " | vram " + str(results[name]["vram_gb"]), logfile)
|
||||
stop_unit()
|
||||
finally:
|
||||
restore_unit(base, logfile)
|
||||
|
||||
with open(args.json, "w") as fh:
|
||||
json.dump(results, fh, indent=2)
|
||||
for shape in ("prose 1k/512", "echo 1.5k/400", "structured/400", "identity"):
|
||||
texts = [r["answer"] for v in results.values() if "rows" in v
|
||||
for r in v["rows"] if r["shape"] == shape]
|
||||
if any(not t for t in texts):
|
||||
log("WARNING: '" + shape + "' produced an empty answer in some configuration -- "
|
||||
"its identity check proves nothing", logfile)
|
||||
elif len(set(texts)) > 1:
|
||||
log("WARNING: configurations disagree on greedy text for '" + shape +
|
||||
"' -- speculation is changing answers", logfile)
|
||||
log("done; results in " + args.json, logfile)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,318 +0,0 @@
|
||||
# Tunables for frank-medium (orcarouter/Qwen3.8-27B-Uncensored-GGUF Q8_0 +
|
||||
# z-lab/Qwen3.8-27B-DFlash2-GGUF Q4_K_M drafter), mainline llama.cpp on
|
||||
# Dankserver, 2x AMD Radeon AI PRO R9700 (gfx1201). Since 2026-09-16 this is
|
||||
# the SOLE LLM runtime on the box: the vllm-radiance replica pair and its
|
||||
# routing proxy are retired.
|
||||
#
|
||||
# Hardware: two R9700 (Navi 48, RDNA4, 32,624 MiB each), no XGMI, different
|
||||
# IOMMU groups -- every cross-card byte goes over PCIe. The Raphael iGPU
|
||||
# (gfx1036/Radeon 610M) enumerates as HIP device 2 and is hidden below so
|
||||
# nothing can drift onto shared system RAM.
|
||||
#
|
||||
# WHY LAYER SPLIT AND NOT TENSOR (operator call, 2026-09-16): tensor parallel
|
||||
# needs a cross-card allreduce per layer, and this host cannot dispatch one.
|
||||
# RCCL's kernels carry a hidden hostcall-buffer parameter, and ROCr refuses it
|
||||
# on a device without PCIe atomics -- the second card sits behind the B650
|
||||
# chipset bridges (AtomicOpsCap: Routing-, read-only), so every collective that
|
||||
# touches it dies with hipErrorIllegalState. Measured on this box 2026-09-15
|
||||
# (llama-bench, pp4096 / tg128, f16 KV, fa on):
|
||||
#
|
||||
# single R9700 1160.9 / 19.42
|
||||
# layer 2x 1991.2 / 18.76 (859.1 / 17.64 at depth 32768)
|
||||
# tensor 2x 271.8 / 26.26 (no RCCL: butterfly fallback, -7x prefill)
|
||||
#
|
||||
# Layer split distributes layers in order; only the activation at the boundary
|
||||
# crosses the wire, once per forward pass. That is the "pipeline parallelism"
|
||||
# this service wanted, and it needs no collective at all.
|
||||
#
|
||||
# Runtime: mainline llama.cpp master fb27a525 (2026-09-16), gfx1201, built by
|
||||
# scripts/build-llama-rdna4.sh against a private ROCm 7.14.0a20260624 toolchain
|
||||
# that is bundled next to the binary (RPATH $ORIGIN/../lib, no LD_LIBRARY_PATH).
|
||||
# The ref is pinned by TWO features: DFlash2 (ggml-org/llama.cpp#27342 -- the
|
||||
# drafter is detected from its GGUF) and qwen35 MTP. Do not float it.
|
||||
#
|
||||
# Model: orcarouter/Qwen3.8-27B-Uncensored-GGUF @ Q8_0, 27.0 GiB, arch qwen35
|
||||
# (verified from the GGUF header 2026-09-19: 262144 trained window, 24 heads /
|
||||
# 4 KV heads, nextn_predict_layers=1). The file ALSO carries the model's MTP
|
||||
# head (blk.64.nextn.*), so --spec-type draft-mtp is available without a second
|
||||
# copy in VRAM -- one SPEC_TYPE change, the drafter below stays on disk.
|
||||
#
|
||||
# Drafter: z-lab/Qwen3.8-27B-DFlash2-GGUF @ Q4_K_M, 1.06 GiB (operator call
|
||||
# 2026-09-19: the Q4 rung -- the drafter's job is throughput, not output
|
||||
# quality, and the target verifies every drafted token). 5 layers, hidden
|
||||
# 5120, a block-diffusion proposal (block_size 8) under a 2048-token sliding
|
||||
# window (dflash.attention.sliding_window=2048, verified from the header).
|
||||
# The window is what makes it cheap HERE: its KV is bounded at 2048 positions
|
||||
# (~40 MiB) instead of spanning the context (see SIZING).
|
||||
#
|
||||
# Architecture (GGUF arch qwen35): 64 layers as 16 x (3 x (Gated DeltaNet ->
|
||||
# FFN) + 1 x (Gated Attention -> FFN)) -- 48 linear-attention layers whose
|
||||
# per-sequence state is CONSTANT-SIZE, 16 full-attention layers at 4 KV heads x
|
||||
# 256 head_dim. A token therefore costs 2 x 4 x 256 x 2 B = 64 KiB of KV at f16
|
||||
# in the attention layers only, plus ~0.16 GiB per sequence of f32 DeltaNet
|
||||
# state at any depth.
|
||||
#
|
||||
# SIZING, the deployed shape (32:33 / ctx 327680 / 6144x512 / two slots /
|
||||
# DFlash2 Q4_K_M), measured 2026-09-19 after the model swap (rocm-smi):
|
||||
#
|
||||
# card 0 (x16) 26.9 GiB used, 5.7 GiB free
|
||||
# card 1 (x1) 27.8 GiB used, 4.8 GiB free
|
||||
#
|
||||
# The predecessor's measured table (rocm-smi, 2026-09-16, UD-Q8_K_XL target)
|
||||
# stays as the calibration history:
|
||||
#
|
||||
# CTX split batch slots drafter GPU 0 free GPU 1 free note (ubatch 1536)
|
||||
# 262144 1,1 2048 1 DFlash2 8.64 GiB 4.43 GiB day's start
|
||||
# 262144 39,26 6144 2 DFlash2 2.42 GiB 7.20 GiB
|
||||
# 262144 38,27 6144 2 DFlash2 3.85 GiB 5.77 GiB
|
||||
# 262144 37,28 6144 2 DFlash2 4.28 GiB 5.34 GiB
|
||||
# 262144 36,29 6144 2 DFlash2 4.71 GiB 4.91 GiB
|
||||
# 262144 35,30 6144 2 DFlash2 5.14 GiB 4.48 GiB
|
||||
# 262144 40,25 6144 2 DSpark 1.87 GiB 1.86 GiB (retired)
|
||||
#
|
||||
# ...and the same deployed shape re-measured at ubatch 512, after llama-bench
|
||||
# showed 512 is the throughput optimum and costs 3.71 GiB less in total (see the
|
||||
# batch block below):
|
||||
#
|
||||
# 262144 36,29 6144 512 2 DFlash2 6.01 GiB 7.32 GiB ubatch only
|
||||
# 262144 35,30 6144 512 2 DFlash2 6.44 GiB 6.90 GiB
|
||||
#
|
||||
# ONE -ts STEP IS ONE LAYER, AND LAYERS ARE NOT THE SAME SIZE. This is a hybrid
|
||||
# stack -- 16 x (3 Gated DeltaNet + 1 Gated Attention) blocks -- so moving a
|
||||
# DeltaNet layer shifts ~0.43 GiB while an attention layer shifts ~1.43 GiB
|
||||
# (0.45 of weights plus ~1.0 GiB of its own f16 KV at 262144). Both step sizes
|
||||
# were measured, which is why the balance is found by measurement rather than by
|
||||
# interpolating between splits.
|
||||
#
|
||||
# WHY THE SPLIT IS 32:33 (operator call, 2026-09-19). The operator asked for
|
||||
# the tensors BALANCED across the two cards, which for 65 layers is 32:33 --
|
||||
# the most even split that exists. The predecessor's 35:30 skew existed to lean
|
||||
# work onto the x16 card (ROCm0), because ROCm1's Gen4 x1 chipset path made it
|
||||
# the 98 % utilization bottleneck; the measured trade of the balanced point is
|
||||
# that the x1 card runs hotter while the x16 card idles a little more, and the
|
||||
# operator accepted that for symmetry. Do not re-skew without asking.
|
||||
#
|
||||
# THE TWO CARDS' PATHS ARE NOT SYMMETRIC (measured, 2026-09-17): ROCm0
|
||||
# (03:00.0) hangs off CPU root port 00:01.1 at Gen4 x16 end to end (~31.5 GB/s);
|
||||
# ROCm1 (0b:00.0) rides the B650 chipset through bridges that each negotiate
|
||||
# Gen4 x1 (~1.97 GB/s). The layer-split boundary itself is not the cost
|
||||
# (~5.2 MB of activation per 512-token ubatch rides the wire); the x1 card is
|
||||
# COMPUTE-bound, not PCIe-bound.
|
||||
#
|
||||
# A FAULT TO WATCH. A 39:26 split, at 1x batch / 1 slot, died on its eighth
|
||||
# request: the amdgpu driver logged SDMA0 UTCL2 page faults and the engine
|
||||
# surfaced `ROCm error: an illegal memory access was encountered` -> ggml_abort,
|
||||
# i.e. a fault in the cross-card boundary copy, this layer split's hot path. It
|
||||
# has not reproduced since, but the signature is worth recognising -- it is NOT
|
||||
# an OOM, and no amount of headroom prevents it.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (HIP reads
|
||||
# those bare vars; 2026-08-13 incident). llama.cpp takes --host/--port/--temp
|
||||
# as flags only.
|
||||
|
||||
# llama-server: mainline fb27a525 built for gfx1201 by scripts/build-llama-rdna4.sh.
|
||||
LLAMA_BIN=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server
|
||||
|
||||
# Payload, fetched with scripts/download-frank-medium-gguf.sh.
|
||||
MODEL_DIR=/home/dank/models/Qwen3.8-27B-Uncensored-GGUF
|
||||
MODEL_FILE=Qwen3.8-27B-Uncensored-Q8_0.gguf
|
||||
# The f16 vision projector, same repo as the target. llama-server loads it via
|
||||
# the explicit --mmproj flag (no autoload in this build); the serve script
|
||||
# refuses to start without it when vision is enabled, so a missing file is a
|
||||
# deploy error, not a text-only server that pretends to be multimodal.
|
||||
MM_PROJ_FILE=mmproj-Qwen3.8-27B-Uncensored-f16.gguf
|
||||
DRAFT_MODEL_DIR=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF
|
||||
DRAFT_MODEL_FILE=Qwen3.8-27B-DFlash2-Q4_K_M.gguf
|
||||
|
||||
# Fleet identity. 7770 is the fleet's dankserver endpoint; the alias is the id
|
||||
# at /v1/models and what every client asks for.
|
||||
ALIAS=frank-medium
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
# Device placement. HIP_VISIBLE_DEVICES hides the Raphael iGPU (HIP device 2)
|
||||
# so nothing can drift onto shared system RAM; DEVICES is what llama.cpp splits
|
||||
# across, and neither of the two cards has an XGMI link to the other.
|
||||
HIP_VISIBLE_DEVICES=0,1
|
||||
DEVICES=ROCm0,ROCm1
|
||||
SPLIT_MODE=layer
|
||||
|
||||
# 32:33 -- balanced (operator call 2026-09-19): 65 layers split as evenly as
|
||||
# they can be, ROCm1 (the last-layers card, which also carries the drafter and
|
||||
# the output layers) holding the extra one. See the WHY THE SPLIT IS 32:33
|
||||
# block above for the trade against the predecessor's 35:30 x16-lean.
|
||||
TENSOR_SPLIT=32,33
|
||||
|
||||
NGL=999
|
||||
# Auto-fit has no business resizing a context we set explicitly.
|
||||
FIT=off
|
||||
FLASH_ATTN=on
|
||||
|
||||
# 327680 positions over TWO slots, so each conversation gets 163840 --
|
||||
# --parallel DIVIDES --ctx-size, so more slots would shrink every
|
||||
# conversation rather than grow the cache. 163840 is inside the checkpoint's
|
||||
# trained 262144 window, so the whole pool is trained territory (no rope
|
||||
# extrapolation, unlike the predecessor's 393216).
|
||||
CTX_SIZE=327680
|
||||
PARALLEL=2
|
||||
|
||||
# MEASURED 2026-09-16 with scripts/bench-batch-ubatch.sh on this box: llama-bench,
|
||||
# a 64k prefill plus a 128-token decode with 64k of KV already resident, 3 reps,
|
||||
# one invocation per cell, the deployed split and f16 KV. --ubatch-size is the
|
||||
# physical batch the graph is built at -- and the only one of the two knobs that
|
||||
# costs VRAM:
|
||||
#
|
||||
# batch ubatch prefill tok/s GPU 0 / GPU 1 free
|
||||
# 6144 1536 1164.96 +- 2.30 10.28 / 11.71 GiB
|
||||
# 6144 768 1135.03 +- 1.09 11.29 / 13.28 GiB
|
||||
# 6144 512 1192.93 +- 0.91 11.63 / 13.81 GiB <- deployed
|
||||
# 6144 384 1068.04 +- 0.79 11.80 / 14.07 GiB
|
||||
# 6144 256 1045.98 +- 1.69 11.97 / 14.33 GiB
|
||||
# 4096 1536 1161.90 +- 0.96 10.28 / 11.71 GiB
|
||||
# 2048 1536 1170.23 +- 0.86 10.28 / 11.71 GiB
|
||||
# 2048 512 1193.88 +- 1.01 11.63 / 13.81 GiB
|
||||
#
|
||||
# THE PLATEAU STARTS AT 512. 512 -> 1536 buys ~2 % of prefill -- inside the
|
||||
# run-to-run spread, and the 512 cells were the faster pair in BOTH batch
|
||||
# columns -- while costing 1.35 GiB on ROCm0 and 2.10 GiB on ROCm1, so the
|
||||
# deployment takes the cheap end. BELOW 512 the SMs are no longer saturated:
|
||||
# 384 and 256 fall ~11 % and ~12 %. The logical batch is free either way --
|
||||
# 2048, 4096 and 6144 land within 2 % of each other at a fixed ubatch and use
|
||||
# the SAME VRAM, because the compute buffers are sized by ubatch alone.
|
||||
#
|
||||
# The prefill figure is a property of the SHAPE, not of this knob: the same
|
||||
# sweep with no depth in front of it (depth 0) runs 1543 tok/s at ubatch 256 and
|
||||
# 1723 at 1536 -- the plateau is in the same place, and the ~32 % between the two
|
||||
# tables is what attending over 64k of resident KV costs. Decode is 15.88 tok/s
|
||||
# at depth 65536 on EVERY ubatch (17.80 at depth 0), so this knob cannot touch
|
||||
# generation at all.
|
||||
#
|
||||
# 8192/2048 stays untested under this drafter (the "2020 MiB pp buffer fails on
|
||||
# ROCm1" note was measured under DSpark, when it had 0.08 GiB free), and there is
|
||||
# no longer a reason to want it: batch buys nothing measurable here.
|
||||
BATCH_SIZE=6144
|
||||
UBATCH_SIZE=512
|
||||
|
||||
# Threads cover tokenization, sampling and HTTP only: every layer is on a GPU.
|
||||
NUM_THREADS=8
|
||||
THREAD_BATCH=8
|
||||
|
||||
# f16 K/V: an agentic checkpoint is the wrong place to spend KV precision.
|
||||
CACHE_TYPE_K=f16
|
||||
CACHE_TYPE_V=f16
|
||||
# Context checkpoints: 0 -- no per-slot KV snapshots at all. The build's
|
||||
# default is 32 (this binary's --help), so the 0 below is a deliberate
|
||||
# deviation, not an inherited default: a checkpoint is a VRAM->host-RAM copy
|
||||
# of part of a slot's KV, taken so a partially-matching prompt can be restored
|
||||
# instead of re-prefilled. This endpoint trades that away -- its reuse comes
|
||||
# from the slot prompt and from the RAM-side prompt cache below. Stated
|
||||
# explicitly so the tunable stays visible rather than merely absent.
|
||||
CTX_CHECKPOINTS=0
|
||||
# PROMPT CACHE, HOST RAM -- this is the one cache tunable that is NOT VRAM.
|
||||
# --cache-ram caps the total bytes of saved prompt states
|
||||
# (server_prompt_cache, tools/server/server-task.cpp): update() evicts
|
||||
# oldest-first past the cap, and alloc() REFUSES outright any single state
|
||||
# bigger than the whole cap. A state is the serialized KV of a whole sequence
|
||||
# plus its DeltaNet state plus the draft context's own KV, so at this shape it
|
||||
# costs ~68 KiB per token --
|
||||
# 16 full-attention layers x 4 kv heads x 256 dim x 2 B x 2 (K,V) = 64 KiB
|
||||
# plus the draft context's window-bounded KV (a ~40 MiB constant for DFlash2)
|
||||
# -- which makes the cap a TOKEN budget in disguise: 16384 MiB holds ~240k
|
||||
# tokens, i.e. a FULL 163840-token slot (~10.6 GiB) with ~5 GiB of the budget
|
||||
# spare for a second entry.
|
||||
#
|
||||
# The cost is host RAM, allocated on demand and never pinned (plain heap
|
||||
# vectors), so no VRAM number above moves. dankserver has 62 GiB beside the
|
||||
# media-stack co-tenants, so the cache is bounded rather than unlimited (-1):
|
||||
# the seam test refuses a budget over half the host's RAM.
|
||||
CACHE_RAM=16384
|
||||
|
||||
# Sampling defaults for requests that send none -- the values the vLLM
|
||||
# predecessor served on this endpoint (`--override-generation-config`), so a
|
||||
# client sees no change across the runtime swap. The model card's own
|
||||
# recommendation is temp 1.0 / top_p 0.95; the harness sets its own per model in
|
||||
# models.yml, so this is the floor, not the contract.
|
||||
TEMPERATURE=0.7
|
||||
TOP_P=0.95
|
||||
TOP_K=20
|
||||
MIN_P=0.0
|
||||
PRESENCE_PENALTY=0.0
|
||||
|
||||
# Reasoning on, projected into reasoning_content (deepseek format) with no
|
||||
# budget: the model decides how long to think.
|
||||
REASONING=on
|
||||
REASONING_FORMAT=deepseek
|
||||
REASONING_BUDGET=-1
|
||||
|
||||
# Chat template: empty = the one embedded in the GGUF (Qwen3.8's own).
|
||||
CHAT_TEMPLATE_FILE=
|
||||
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}'
|
||||
|
||||
# Speculative decoding: the DFlash2 sidecar (operator call 2026-09-19). The
|
||||
# target file also carries its own MTP head (nextn_predict_layers=1), so
|
||||
# draft-mtp is one SPEC_TYPE change away -- the drafter file stays on disk.
|
||||
# n_max 7 is the measured DFlash2 winner (see the table below); the drafter
|
||||
# rides ROCm1, the card that holds the last layers.
|
||||
SPEC_TYPE=draft-dflash
|
||||
SPEC_DRAFT_N_MAX=7
|
||||
SPEC_DRAFT_NGL=999
|
||||
SPEC_DRAFT_DEVICE=ROCm1
|
||||
SPEC_DRAFT_TYPE_K=f16
|
||||
SPEC_DRAFT_TYPE_V=f16
|
||||
|
||||
# DFlash2 vs the model's own MTP head -- MEASURED 2026-09-16 on the predecessor
|
||||
# payload (temperature 0, 1k prompt / 512 out; the autoregressive baseline and
|
||||
# both MTP depths were run in one session, same binary, same prompts):
|
||||
#
|
||||
# none 17.7 tok/s card1 26.07 GB
|
||||
# draft-mtp n=3 32.4 tok/s 58 % accepted card1 29.35 GB
|
||||
# draft-mtp n=7 26.7 tok/s 33 % accepted card1 29.65 GB
|
||||
# draft-dflash 7 43.5 tok/s 41 % accepted card1 29.42 GB
|
||||
#
|
||||
# All four produced IDENTICAL greedy text, so verification is lossless
|
||||
# whichever proposer runs. DFlash2 is the faster proposer (2.46x over
|
||||
# autoregressive, 34 % over the best MTP). The MTP head adds ~3.3 GB to the
|
||||
# card that holds it, against 1.06 GB for the Q4_K_M drafter's file -- at the
|
||||
# 327680 pool the drafter's window-bounded KV (~40 MiB) leaves headroom on
|
||||
# both cards, so the speed wins.
|
||||
#
|
||||
# THE TWO CANNOT BE CHAINED. `--spec-type draft-mtp,draft-dflash` dies at load:
|
||||
# context type MTP requested but model doesn't contain MTP layers
|
||||
# failed to create MTP context
|
||||
# A chain has ONE draft context, and MTP builds against whatever model that
|
||||
# context holds -- here the DFlash2 file, which has no MTP layers. MTP's head is
|
||||
# only loaded when MTP IS the draft model ("creating MTP draft context against
|
||||
# the target model ..."), so a chain of the two is a structural dead end, not a
|
||||
# tuning question. See serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md.
|
||||
|
||||
# ngram-mod: the draftless n-gram matcher (CPU hash table in host RAM, no VRAM,
|
||||
# no model file, no draft context). It joins the chain unconditionally --
|
||||
# unlike MTP, which needs a draft context and therefore cannot share one with
|
||||
# DFlash2. NOT CURRENTLY ACTIVE: SPEC_TYPE is draft-dflash alone, and the serve
|
||||
# script only passes --spec-ngram-mod-* when the type contains "ngram". The
|
||||
# window below is retained because the DFlash2 + ngram chain was measured free
|
||||
# on prose and 2.6x on prompt-echo -- one SPEC_TYPE change brings it back.
|
||||
#
|
||||
# WINDOW MEASURED 2026-09-16 (chain at each window, decode tok/s):
|
||||
#
|
||||
# n_min/n_max prose 1k/512 prompt-echo 1.5k/400
|
||||
# 48/64 43.9 200.6
|
||||
# 16/32 36.0 161.0
|
||||
# 8/16 36.2 113.8
|
||||
#
|
||||
# The long window is the right one, and not only because it wins on echo: a
|
||||
# SHORT window makes the matcher propose on short fragments of ordinary prose,
|
||||
# where the proposals are rejected and the verification still costs -- which is
|
||||
# the "ngram hurts" result the fleet measured on other payloads twice before.
|
||||
# With 48/64 the chain is byte-identical to DFlash2 alone on prose (same 119
|
||||
# drafts / 49 accepted) and on the identity prompt (70/53).
|
||||
#
|
||||
# Do not shorten these without re-running scripts/bench-spec-ab.py: the
|
||||
# no-penalty property is what makes the chain safe as a default.
|
||||
SPEC_NGRAM_N_MATCH=24
|
||||
SPEC_NGRAM_N_MIN=48
|
||||
SPEC_NGRAM_N_MAX=64
|
||||
|
||||
# Vision enabled: llama-server loads mmproj-Qwen3.8-27B-Uncensored-f16.gguf
|
||||
# (931 MiB, lands on ROCm0 -- the fit charges it there) via the explicit
|
||||
# --mmproj flag. This keeps frank-medium multimodal across the model swap.
|
||||
NO_MMPROJ=0
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=llama.cpp Qwen3.8-27B Uncensored UD-Q8_K_XL + DFlash2 drafter (dankserver, frank-medium, 2x Radeon AI PRO R9700)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
# The engine pins staging buffers on the first card; keep the memlock budget
|
||||
# unbounded so an RLIMIT that is smaller than the split buffers cannot fail the
|
||||
# load. Parity with the other LLM units on this fleet.
|
||||
LimitMEMLOCK=infinity
|
||||
Environment="FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF @ UD-Q8_K_XL as
|
||||
# frank-medium on Dankserver's two Radeon AI PRO R9700 (gfx1201), with the
|
||||
# z-lab DFlash2 block-diffusion drafter, using mainline llama.cpp.
|
||||
#
|
||||
# Runtime: llama.cpp master fb27a525 (2026-09-16), a gfx1201 HIP build that
|
||||
# bundles its own ROCm 7.14 runtime (RPATH $ORIGIN/../lib), produced by
|
||||
# scripts/build-llama-rdna4.sh. Two features pin that ref: DFlash2
|
||||
# (ggml-org/llama.cpp#27342) and qwen35 MTP. The charlie12345/ROCmFPX fork
|
||||
# this deployment replaced has neither (its tip is c49ebdb, 2026-08-22).
|
||||
#
|
||||
# Parallelism: --split-mode layer across both cards. Tensor split would need a
|
||||
# cross-card allreduce and this host cannot dispatch one -- RCCL's kernels
|
||||
# require a hostcall buffer that ROCr refuses on a card without PCIe atomics,
|
||||
# and the second R9700 sits behind the chipset (AtomicOpsCap: Routing-). Layer
|
||||
# split crosses the wire once per forward pass instead of once per layer.
|
||||
#
|
||||
# The vLLM replica pair and its routing proxy were retired with this cutover:
|
||||
# one process, one model, both cards.
|
||||
#
|
||||
# Tunables come from frank-medium.env unless FRANK_MEDIUM_ENV_FILE overrides it.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_MEDIUM_ENV_FILE:-$SCRIPT_DIR/frank-medium.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) ------------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF}"
|
||||
: "${MODEL_FILE:=Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf}"
|
||||
: "${DRAFT_MODEL_DIR:=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF}"
|
||||
: "${DRAFT_MODEL_FILE:=Qwen3.8-27B-DFlash2-Q8_0.gguf}"
|
||||
: "${ALIAS:=frank-medium}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
|
||||
# Devices. HIP_VISIBLE_DEVICES hides the Raphael iGPU (HIP device 2) so nothing
|
||||
# can drift onto shared system RAM; DEVICES is what llama.cpp splits across.
|
||||
: "${HIP_VISIBLE_DEVICES:=0,1}"
|
||||
: "${DEVICES:=ROCm0,ROCm1}"
|
||||
: "${SPLIT_MODE:=layer}"
|
||||
: "${TENSOR_SPLIT:=1,1}"
|
||||
: "${NGL:=999}"
|
||||
: "${FIT:=off}"
|
||||
: "${FLASH_ATTN:=on}"
|
||||
|
||||
: "${CTX_SIZE:=131072}"
|
||||
: "${PARALLEL:=1}"
|
||||
: "${BATCH_SIZE:=2048}"
|
||||
: "${UBATCH_SIZE:=512}"
|
||||
: "${NUM_THREADS:=8}"
|
||||
: "${THREAD_BATCH:=8}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${CTX_CHECKPOINTS:=8}"
|
||||
: "${CACHE_RAM:=8192}"
|
||||
|
||||
# Sampling defaults for silent clients: the values the vLLM predecessor served.
|
||||
: "${TEMPERATURE:=0.7}"
|
||||
: "${TOP_P:=0.95}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${PRESENCE_PENALTY:=0.0}"
|
||||
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
: "${REASONING_BUDGET:=-1}"
|
||||
: "${CHAT_TEMPLATE_FILE:=}"
|
||||
: "${CHAT_TEMPLATE_KWARGS:={\"enable_thinking\":true,\"preserve_thinking\":true}}"
|
||||
|
||||
# Speculative decoding: draft-dflash or draft-dspark (both are MODEL drafters --
|
||||
# a file, not a flag), or draft-mtp (the target GGUF's own MTP head, no draft
|
||||
# file). The drafter's own tuning knobs (n-min, p-min, p-split, poll) are left
|
||||
# at the build's defaults: they were only ever passed for dspark, and dspark is
|
||||
# not the deployed drafter.
|
||||
: "${SPEC_TYPE:=draft-dflash}"
|
||||
: "${SPEC_DRAFT_N_MAX:=7}"
|
||||
: "${SPEC_DRAFT_NGL:=999}"
|
||||
: "${SPEC_DRAFT_DEVICE:=}"
|
||||
: "${SPEC_DRAFT_TYPE_K:=f16}"
|
||||
: "${SPEC_DRAFT_TYPE_V:=f16}"
|
||||
: "${SPEC_NGRAM_N_MATCH:=24}"
|
||||
: "${SPEC_NGRAM_N_MIN:=48}"
|
||||
: "${SPEC_NGRAM_N_MAX:=64}"
|
||||
|
||||
NO_MMPROJ="${NO_MMPROJ:-0}"
|
||||
: "${MM_PROJ_FILE:=mmproj-BF16.gguf}"
|
||||
|
||||
export HIP_VISIBLE_DEVICES
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE"
|
||||
|
||||
if [[ ! -x "$LLAMA_BIN" ]]; then
|
||||
echo "missing executable runtime: $LLAMA_BIN" >&2
|
||||
echo "build it with: bash $SCRIPT_DIR/build-llama-rdna4.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -r "$MODEL" ]]; then
|
||||
echo "missing or unreadable target model: $MODEL" >&2
|
||||
echo "fetch it with: bash $SCRIPT_DIR/download-frank-medium-gguf.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$NO_MMPROJ" != "1" ]]; then
|
||||
MM_PROJ="$MODEL_DIR/$MM_PROJ_FILE"
|
||||
if [[ ! -r "$MM_PROJ" ]]; then
|
||||
echo "vision is enabled but the projector is missing: $MM_PROJ" >&2
|
||||
echo "fetch it with: bash $SCRIPT_DIR/download-frank-medium-gguf.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$CHAT_TEMPLATE_FILE" && ! -r "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
echo "missing or unreadable chat template: $CHAT_TEMPLATE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- speculative decoding -------------------------------------------------
|
||||
SPEC_ARGS=()
|
||||
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
|
||||
SPEC_ARGS=(--spec-type "$SPEC_TYPE" --spec-draft-n-max "$SPEC_DRAFT_N_MAX")
|
||||
if [[ "$SPEC_TYPE" == *dflash* || "$SPEC_TYPE" == *dspark* ]]; then
|
||||
if [[ ! -r "$DRAFT_MODEL" ]]; then
|
||||
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
SPEC_ARGS+=(
|
||||
--spec-draft-model "$DRAFT_MODEL"
|
||||
--spec-draft-ngl "$SPEC_DRAFT_NGL"
|
||||
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
|
||||
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
|
||||
)
|
||||
fi
|
||||
# ngram-mod is draftless: it needs no draft context and its window is its
|
||||
# own (--spec-ngram-mod-*), so it can ride in the chain beside a model
|
||||
# drafter. It also costs no VRAM -- a CPU hash table.
|
||||
if [[ "$SPEC_TYPE" == *ngram* ]]; then
|
||||
SPEC_ARGS+=(
|
||||
--spec-ngram-mod-n-match "$SPEC_NGRAM_N_MATCH"
|
||||
--spec-ngram-mod-n-min "$SPEC_NGRAM_N_MIN"
|
||||
--spec-ngram-mod-n-max "$SPEC_NGRAM_N_MAX"
|
||||
)
|
||||
fi
|
||||
if [[ -n "$SPEC_DRAFT_DEVICE" ]]; then
|
||||
SPEC_ARGS+=(--spec-draft-device "$SPEC_DRAFT_DEVICE")
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- template + placement -------------------------------------------------
|
||||
TEMPLATE_ARGS=()
|
||||
if [[ -n "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
TEMPLATE_ARGS+=(--chat-template-file "$CHAT_TEMPLATE_FILE")
|
||||
fi
|
||||
TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS")
|
||||
|
||||
PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE" --tensor-split "$TENSOR_SPLIT")
|
||||
|
||||
MMPROJ_ARGS=()
|
||||
if [[ "$NO_MMPROJ" == "1" ]]; then
|
||||
MMPROJ_ARGS=(--no-mmproj)
|
||||
else
|
||||
# No autoload in this build: a server without the flag starts text-only
|
||||
# and answers image requests with a 500, so name the projector.
|
||||
MMPROJ_ARGS=(--mmproj "$MM_PROJ")
|
||||
fi
|
||||
exec "$LLAMA_BIN" \
|
||||
--model "$MODEL" \
|
||||
--alias "$ALIAS" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT" \
|
||||
--jinja \
|
||||
--gpu-layers "$NGL" \
|
||||
--fit "$FIT" \
|
||||
--parallel "$PARALLEL" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--flash-attn "$FLASH_ATTN" \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--threads "$NUM_THREADS" \
|
||||
--threads-batch "$THREAD_BATCH" \
|
||||
--ctx-checkpoints "$CTX_CHECKPOINTS" \
|
||||
--cache-ram "$CACHE_RAM" \
|
||||
--cache-prompt \
|
||||
--metrics \
|
||||
--temp "$TEMPERATURE" \
|
||||
--top-p "$TOP_P" \
|
||||
--top-k "$TOP_K" \
|
||||
--min-p "$MIN_P" \
|
||||
--presence-penalty "$PRESENCE_PENALTY" \
|
||||
--reasoning "$REASONING" \
|
||||
--reasoning-format "$REASONING_FORMAT" \
|
||||
--reasoning-budget "$REASONING_BUDGET" \
|
||||
"${TEMPLATE_ARGS[@]}" \
|
||||
"${PLACEMENT_ARGS[@]}" \
|
||||
"${MMPROJ_ARGS[@]}" \
|
||||
"${SPEC_ARGS[@]}"
|
||||
@@ -1,192 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Decode throughput benchmark for frank-dragoon.
|
||||
Starts the server with a given --threads-batch value, sends an 8K+ token prompt,
|
||||
generates 256 tokens, measures generation tok/s.
|
||||
|
||||
Each --threads-batch value requires a cold server load (~3 min).
|
||||
"""
|
||||
import json, time, urllib.request, urllib.error, subprocess, os, sys
|
||||
|
||||
MODEL = "/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX/Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf"
|
||||
SERVE = "/home/dank/GitPackages/ROCmFPX/build-qwen38/bin/llama-server"
|
||||
PORT = 7770
|
||||
N_GEN = 256
|
||||
PROMPT_FILE = "/tmp/bench-prompts/prompt_code.txt"
|
||||
ENV_FILE = os.path.join(os.path.dirname(__file__), "frank-dragoon.env")
|
||||
SERVER_DIR = os.path.dirname(SERVE)
|
||||
ROCM_PATH = "/opt/rocm"
|
||||
|
||||
def make_env():
|
||||
"""Build the same env as serve-frank-dragoon.sh."""
|
||||
env = {**os.environ, "HSA_OVERRIDE_GFX_VERSION": "11.5.1", "HIP_VISIBLE_DEVICES": "0"}
|
||||
|
||||
# Source env file for tunables
|
||||
if os.path.exists(ENV_FILE):
|
||||
with open(ENV_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
env[k] = v
|
||||
|
||||
# Build LD_LIBRARY_PATH
|
||||
lib_path = f"{SERVER_DIR}:{ROCM_PATH}/lib:{ROCM_PATH}/lib/rocm_sysdeps/lib:{ROCM_PATH}/lib/llvm/lib"
|
||||
if os.environ.get("LD_LIBRARY_PATH"):
|
||||
lib_path += f":{os.environ['LD_LIBRARY_PATH']}"
|
||||
env["LD_LIBRARY_PATH"] = lib_path
|
||||
env["ROCM_PATH"] = ROCM_PATH
|
||||
|
||||
return env
|
||||
|
||||
ENV = make_env()
|
||||
|
||||
def kill_all():
|
||||
subprocess.run(["pkill", "-9", "llama-server"], capture_output=True)
|
||||
time.sleep(3)
|
||||
|
||||
def wait_ready():
|
||||
for i in range(600):
|
||||
try:
|
||||
r = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2)
|
||||
if json.loads(r.read()).get("status") == "ok":
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
def send_request(prompt, max_tokens=N_GEN):
|
||||
"""Send chat completion, measure generation throughput."""
|
||||
body = json.dumps({
|
||||
"model": "frank-dragoon",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.8
|
||||
}).encode()
|
||||
|
||||
t0 = time.time()
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{PORT}/v1/chat/completions",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
r = urllib.request.urlopen(req, timeout=1200)
|
||||
resp = json.loads(r.read())
|
||||
wall = time.time() - t0
|
||||
|
||||
usage = resp.get("usage", {})
|
||||
pt = usage.get("prompt_tokens", 0)
|
||||
gt = usage.get("completion_tokens", 0)
|
||||
gtps = gt / wall if wall > 0 else 0
|
||||
return pt, gt, wall, gtps
|
||||
|
||||
def load_and_extend_prompt():
|
||||
"""Load prompt and extend to ~8K tokens if needed."""
|
||||
with open(PROMPT_FILE) as f:
|
||||
text = f.read()
|
||||
|
||||
# Count approximate tokens (rough: ~4 chars per token for code)
|
||||
approx_tokens = len(text) / 4.0
|
||||
needed = 8200
|
||||
|
||||
if approx_tokens < needed:
|
||||
repeats = needed // max(int(approx_tokens), 1) + 1
|
||||
text = (text + "\n") * repeats
|
||||
words = text.split()[:needed]
|
||||
text = " ".join(words)
|
||||
|
||||
return text
|
||||
|
||||
def main():
|
||||
tb_values = [int(x) for x in sys.argv[1:]] if len(sys.argv) > 1 else [16, 32, 64, 128]
|
||||
results = []
|
||||
|
||||
prompt = load_and_extend_prompt()
|
||||
n_words = len(prompt.split())
|
||||
print(f"Prompt: {n_words} words (~{n_words} tokens), {len(prompt)} chars\n", flush=True)
|
||||
|
||||
for tb in tb_values:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"threads-batch = {tb}")
|
||||
print(f"{'='*60}", flush=True)
|
||||
|
||||
kill_all()
|
||||
print(" Starting server...", flush=True)
|
||||
|
||||
lib_path = ENV.get("LD_LIBRARY_PATH", "")
|
||||
cmd = [
|
||||
"/usr/bin/env",
|
||||
"HSA_OVERRIDE_GFX_VERSION=11.5.1",
|
||||
"HIP_VISIBLE_DEVICES=0",
|
||||
f"LD_LIBRARY_PATH={lib_path}",
|
||||
f"ROCM_PATH={ROCM_PATH}",
|
||||
SERVE, "--model", MODEL, "--port", str(PORT), "--jinja",
|
||||
"--device", "ROCm0", "--gpu-layers", "999", "--fit", "off",
|
||||
"--threads", "16", "--threads-batch", str(tb),
|
||||
"--parallel", "2", "--ctx-size", "131072",
|
||||
"--batch-size", "4096", "--ubatch-size", "4096",
|
||||
"--flash-attn", "on",
|
||||
"--cache-type-k", "f16", "--cache-type-v", "f16",
|
||||
"--cache-prompt", "--cache-idle-slots", "--metrics"
|
||||
]
|
||||
|
||||
srv = subprocess.Popen(cmd, env=ENV, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
ready = wait_ready()
|
||||
if not ready:
|
||||
print(" FAILED: server did not become ready", flush=True)
|
||||
srv.terminate()
|
||||
srv.wait()
|
||||
for line in (srv.stdout.readlines() or [])[-20:]:
|
||||
print(f" LOG: {line.rstrip()}", flush=True)
|
||||
continue
|
||||
|
||||
print(" Server ready.", flush=True)
|
||||
|
||||
# Warmup (small request)
|
||||
try:
|
||||
send_request("Hi", 16)
|
||||
except Exception as e:
|
||||
print(f" Warmup failed: {e}", flush=True)
|
||||
srv.terminate()
|
||||
srv.wait()
|
||||
continue
|
||||
|
||||
# Benchmark
|
||||
print(f" Generating {N_GEN} tokens...", flush=True)
|
||||
try:
|
||||
pt, gt, wall, gtps = send_request(prompt, N_GEN)
|
||||
print(f" {gt}/{N_GEN} gen tokens in {wall:.1f}s = {gtps:.1f} tok/s")
|
||||
print(f" Prompt tokens: {pt}")
|
||||
results.append({"tb": tb, "wall": wall, "gtps": gtps, "gt": gt, "pt": pt})
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}", flush=True)
|
||||
|
||||
srv.terminate()
|
||||
srv.wait()
|
||||
time.sleep(2)
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print("SUMMARY")
|
||||
print(f"{'='*60}")
|
||||
for r in results:
|
||||
print(f" tb={r['tb']:>3}: {r['gt']} gen tokens, {r['wall']:.1f}s, {r['gtps']:.1f} tok/s")
|
||||
|
||||
if results:
|
||||
best = max(results, key=lambda r: r['gtps'])
|
||||
print(f"\nBest: tb={best['tb']} at {best['gtps']:.1f} tok/s")
|
||||
|
||||
with open("/tmp/bench_decode_results.txt", "w") as f:
|
||||
f.write("tb,gen_tokens,wall_s,gen_tps,prompt_tokens\n")
|
||||
for r in results:
|
||||
f.write(f"{r['tb']},{r['gt']},{r['wall']:.1f},{r['gtps']:.1f},{r['pt']}\n")
|
||||
|
||||
print(f"\nResults: /tmp/bench_decode_results.txt")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,268 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark n-gram speculative decoding vs no-spec for frank-dragoon.
|
||||
|
||||
Tests prompt-processing throughput with 16K token prompts (manageable request size).
|
||||
Code-heavy prompt (high n-gram redundancy) vs prose prompt (low redundancy).
|
||||
|
||||
Usage:
|
||||
# Generate prompts: python3 scripts/bench-ngram-mod.py generate /tmp/prompts/
|
||||
# Run: python3 scripts/bench-ngram-mod.py /tmp/prompts/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
MODEL = "/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX/Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf"
|
||||
CHAT_TEMPLATE = "/home/dank/serverAppConfigs/scripts/chat-template.jinja"
|
||||
BUILD_DIR = "/home/dank/GitPackages/ROCmFPX/build-qwen38/bin"
|
||||
LLAMA_BIN = os.path.join(BUILD_DIR, "llama-server")
|
||||
|
||||
# 16K tokens ~= 64KB text
|
||||
TARGET_PROMPT_TOKENS = 16000
|
||||
TOKEN_CHAR_RATIO = 4
|
||||
TARGET_PROMPT_BYTES = TARGET_PROMPT_TOKENS * TOKEN_CHAR_RATIO
|
||||
|
||||
CONTEXT = 65536
|
||||
BATCH = 4096
|
||||
UBATCH = 4096
|
||||
THREADS = 16
|
||||
THREAD_BATCH = 32
|
||||
NGRAM_N_MAX = 24
|
||||
NGRAM_N_MIN = 4
|
||||
TEST_TOKENS = 128
|
||||
SERVER_WAIT = 300
|
||||
|
||||
|
||||
def start_server(port, spec_type):
|
||||
cmd = [
|
||||
LLAMA_BIN,
|
||||
"--model", MODEL,
|
||||
"--chat-template-file", CHAT_TEMPLATE,
|
||||
"--jinja",
|
||||
"--device", "ROCm0",
|
||||
"--gpu-layers", "999",
|
||||
"--parallel", "1",
|
||||
"--ctx-size", str(CONTEXT),
|
||||
"--batch-size", str(BATCH),
|
||||
"--ubatch-size", str(UBATCH),
|
||||
"--fit", "off",
|
||||
"--flash-attn", "on",
|
||||
"--cache-type-k", "f16",
|
||||
"--cache-type-v", "f16",
|
||||
"--threads", str(THREADS),
|
||||
"--threads-batch", str(THREAD_BATCH),
|
||||
"--cache-prompt",
|
||||
"--cache-idle-slots",
|
||||
"--metrics",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(port),
|
||||
"--temp", "0",
|
||||
]
|
||||
if spec_type == "ngram-mod":
|
||||
cmd.extend([
|
||||
"--spec-type", "ngram-mod",
|
||||
"--spec-ngram-mod-n-max", str(NGRAM_N_MAX),
|
||||
"--spec-ngram-mod-n-min", str(NGRAM_N_MIN),
|
||||
])
|
||||
else:
|
||||
cmd.extend(["--spec-type", "none"])
|
||||
|
||||
env = os.environ.copy()
|
||||
env["ROCM_PATH"] = "/opt/rocm"
|
||||
env["LD_LIBRARY_PATH"] = f"{BUILD_DIR}:/opt/rocm/lib:/opt/rocm/lib/rocm_sysdeps/lib:/opt/rocm/lib/llvm/lib"
|
||||
env["HSA_OVERRIDE_GFX_VERSION"] = "11.5.1"
|
||||
env["HIP_VISIBLE_DEVICES"] = "0"
|
||||
|
||||
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
print(f" Waiting for server on port {port} (spec={spec_type})...")
|
||||
for i in range(SERVER_WAIT):
|
||||
try:
|
||||
req = urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
|
||||
resp = json.loads(req.read().decode())
|
||||
if resp.get("status") == "ok":
|
||||
print(f" Server ready after {i+1}s")
|
||||
return proc
|
||||
except:
|
||||
time.sleep(1)
|
||||
|
||||
proc.terminate()
|
||||
out, err = proc.communicate()
|
||||
print(f"ERROR: Server failed to start in {SERVER_WAIT}s")
|
||||
print(err.decode()[-2000:])
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_test(prompt_text, spec_type, port):
|
||||
"""Send a request and extract prompt processing metrics."""
|
||||
data = json.dumps({
|
||||
"model": "frank-dragoon",
|
||||
"messages": [{"role": "user", "content": prompt_text}],
|
||||
"max_tokens": TEST_TOKENS,
|
||||
"stream": False,
|
||||
"temperature": 0,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/v1/chat/completions",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
resp = urllib.request.urlopen(req, timeout=600)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
result = json.loads(resp.read().decode())
|
||||
timings = result.get("timings", {})
|
||||
usage = result.get("usage", {})
|
||||
|
||||
return {
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"prompt_ms": timings.get("prompt_ms", 0),
|
||||
"prompt_per_second": timings.get("prompt_per_second", 0),
|
||||
"prompt_per_token_ms": timings.get("prompt_per_token_ms", 0),
|
||||
"predicted_per_second": timings.get("predicted_per_second", 0),
|
||||
"total_time_s": elapsed,
|
||||
"cache_n": timings.get("cache_n", 0),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <prompt_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
prompt_dir = Path(sys.argv[1])
|
||||
prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
code_prompt_file = prompt_dir / "prompt_code.txt"
|
||||
prose_prompt_file = prompt_dir / "prompt_prose.txt"
|
||||
|
||||
# Generate prompts if they don't exist
|
||||
if not code_prompt_file.exists() or not prose_prompt_file.exists():
|
||||
print("Generating prompts...")
|
||||
# Generate deterministic prompts
|
||||
# Code prompt: structured algorithms + data generation + config schemas
|
||||
code_lines = []
|
||||
for i in range(50):
|
||||
code_lines.append(f"# Algorithm {i}\n")
|
||||
code_lines.append(f"def algo_{i}(x, y):\n")
|
||||
code_lines.append(f" \"\"\"Algorithm {i} processing.\"\"\"\n")
|
||||
code_lines.append(f" result = 0\n")
|
||||
code_lines.append(f" for j in range({i * 10}):\n")
|
||||
code_lines.append(f" result += x * y + j\n")
|
||||
code_lines.append(f" return result\n\n")
|
||||
code_text = "".join(code_lines)
|
||||
# Pad/truncate to target
|
||||
while len(code_text) < TARGET_PROMPT_BYTES:
|
||||
code_text += "# Additional algorithm block\n"
|
||||
code_text += f"def extra_{len(code_text)//100}():\n"
|
||||
code_text += " x = 1\n"
|
||||
code_text += " y = 2\n"
|
||||
code_text += " return x + y\n\n"
|
||||
code_text = code_text[:TARGET_PROMPT_BYTES]
|
||||
|
||||
# Prose prompt: historical/scientific/philosophical text
|
||||
prose_text = ""
|
||||
topics = [
|
||||
"The Renaissance brought profound transformation to European civilization. Artists and scholars pushed the boundaries of human expression.",
|
||||
"Quantum mechanics describes the behavior of matter and energy at atomic scales. The uncertainty principle limits measurement precision.",
|
||||
"Ethical philosophy grapples with questions of right and wrong. Utilitarianism judges actions by their consequences and the happiness they produce.",
|
||||
"Mountains rise like ancient sentinels, their peaks lost in clouds that swirl and dance across the sky above the valleys below.",
|
||||
"The ocean whispers secrets to the shore, each wave carrying fragments of distant lands across the water before retreating back to sea.",
|
||||
]
|
||||
for _ in range(500):
|
||||
prose_text += f"{topics[len(prose_text) % len(topics)]} "
|
||||
prose_text = prose_text[:TARGET_PROMPT_BYTES]
|
||||
|
||||
code_prompt_file.write_text(code_text)
|
||||
prose_prompt_file.write_text(prose_text)
|
||||
print(f" Code: {code_prompt_file.stat().st_size:,} bytes")
|
||||
print(f" Prose: {prose_prompt_file.stat().st_size:,} bytes")
|
||||
|
||||
print("=" * 60)
|
||||
print(" N-gram Mod Benchmark — frank-dragoon")
|
||||
print(f" STRIX ROCmFP4 · 64K context · 16K prompts")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
port_base = 7772
|
||||
port_ngram = 7773
|
||||
|
||||
tests = [
|
||||
("code", "none", port_base, code_prompt_file),
|
||||
("prose", "none", port_ngram, prose_prompt_file),
|
||||
("code", "ngram-mod", port_base, code_prompt_file),
|
||||
("prose", "ngram-mod", port_ngram, prose_prompt_file),
|
||||
]
|
||||
|
||||
for prompt_type, spec_type, port, pfile in tests:
|
||||
print(f"\n=== {spec_type} ({prompt_type}) ===")
|
||||
print(f" File: {pfile} ({pfile.stat().st_size:,} bytes)")
|
||||
print(f" Spec: {spec_type}")
|
||||
if spec_type == "ngram-mod":
|
||||
print(f" n_max={NGRAM_N_MAX}, n_min={NGRAM_N_MIN}")
|
||||
|
||||
proc = start_server(port, spec_type)
|
||||
|
||||
try:
|
||||
# Warm-up
|
||||
print(" Warm-up...")
|
||||
run_test("Hello test", spec_type, port)
|
||||
|
||||
# Full prompt
|
||||
print(f" Sending {TARGET_PROMPT_TOKENS} token prompt...")
|
||||
with open(pfile) as f:
|
||||
prompt_text = f.read()
|
||||
result = run_test(prompt_text, spec_type, port)
|
||||
|
||||
tps = result["prompt_per_second"]
|
||||
print(f" Tokens: {result['prompt_tokens']}")
|
||||
print(f" Prompt: {result['prompt_ms']:.0f}ms")
|
||||
print(f" TPS: {tps:.1f} tok/s")
|
||||
print(f" Total: {result['total_time_s']:.1f}s")
|
||||
|
||||
results.append({
|
||||
"prompt_type": prompt_type,
|
||||
"spec_type": spec_type,
|
||||
"prompt_tokens": result["prompt_tokens"],
|
||||
"prompt_ms": result["prompt_ms"],
|
||||
"tps": tps,
|
||||
"total_time_s": result["total_time_s"],
|
||||
})
|
||||
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except:
|
||||
proc.kill()
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print(" RESULTS SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
for ptype in ["code", "prose"]:
|
||||
baseline = next((r for r in results if r["prompt_type"] == ptype and r["spec_type"] == "none"), None)
|
||||
ngram = next((r for r in results if r["prompt_type"] == ptype and r["spec_type"] == "ngram-mod"), None)
|
||||
if baseline and ngram:
|
||||
gain = ((ngram["tps"] - baseline["tps"]) / baseline["tps"]) * 100 if baseline["tps"] > 0 else 0
|
||||
print(f"\n{ptype.upper()} PROMPT:")
|
||||
print(f" Baseline TPS: {baseline['tps']:.1f} tok/s ({baseline['prompt_ms']:.0f}ms)")
|
||||
print(f" N-gram TPS: {ngram['tps']:.1f} tok/s ({ngram['prompt_ms']:.0f}ms)")
|
||||
print(f" Speedup: {gain:+.1f}%")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,304 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Benchmark n-gram speculative decoding vs no-spec for frank-dragoon.
|
||||
#
|
||||
# This tests prompt-processing throughput at 64k context with two distinct
|
||||
# prompts: one code-heavy and one prose-heavy. Uses llama-server on scratch
|
||||
# ports with the OpenAI-compatible API to measure prompt_eval_duration_ms.
|
||||
#
|
||||
# Run on Dankclaw after stopping the production service:
|
||||
# sudo systemctl stop frank-dragoon
|
||||
# ./scripts/bench-ngram-mod.sh
|
||||
# sudo systemctl start frank-dragoon
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
fi
|
||||
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/ROCmFPX/build-qwen38/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}"
|
||||
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}"
|
||||
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
|
||||
: "${CHAT_TEMPLATE_KWARGS:={}}"
|
||||
: "${BATCH_SIZE:=4096}"
|
||||
: "${UBATCH_SIZE:=4096}"
|
||||
: "${CTX_SIZE:=65536}"
|
||||
: "${NUM_THREADS:=16}"
|
||||
: "${THREAD_BATCH:=32}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${FIT:=off}"
|
||||
: "${TEMPERATURE:=0.7}"
|
||||
: "${TOP_P:=0.8}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
HOSTNAME=$(hostname 2>/dev/null || echo "dankclaw")
|
||||
|
||||
# Prompt files (generated by generate-prompts.py or passed via args)
|
||||
PROMPT_CODE="${1:-}"
|
||||
PROMPT_PROSE="${2:-}"
|
||||
|
||||
if [[ -z "$PROMPT_CODE" || -z "$PROMPT_PROSE" ]]; then
|
||||
echo "ERROR: Need two prompt file paths."
|
||||
echo "Usage: $0 <prompt_code.txt> <prompt_prose.txt>"
|
||||
echo "Generate with: python3 scripts/generate-prompts.py"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -r "$MODEL" ]]; then
|
||||
echo "missing or unreadable model: $MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_DIR="$(cd -- "$(dirname -- "$LLAMA_BIN")" && pwd)"
|
||||
ROCM_PATH="${ROCM_PATH:-/opt/rocm}"
|
||||
LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
# Scratch ports
|
||||
PORT_NGRAM=7771
|
||||
PORT_BASELINE=7772
|
||||
|
||||
cleanup() {
|
||||
echo "Cleaning up servers..."
|
||||
# Kill any leftover llama-server on our ports
|
||||
for port in $PORT_NGRAM $PORT_BASELINE; do
|
||||
fuser -k "$port"/tcp 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_server() {
|
||||
local port=$1
|
||||
local max_wait=120
|
||||
local elapsed=0
|
||||
while (( elapsed < max_wait )); do
|
||||
if curl -s "http://127.0.0.1:$port/health" | grep -q '"running"'; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
(( elapsed++ ))
|
||||
done
|
||||
echo "ERROR: Server on port $port did not start within ${max_wait}s" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
get_prompt_tokens() {
|
||||
local file=$1
|
||||
# Count tokens approximately (1 token ~ 4 chars for English/code)
|
||||
wc -c < "$file" | awk '{printf "%d", $1/4}'
|
||||
}
|
||||
|
||||
run_single_test() {
|
||||
local port=$1
|
||||
local spec_type="$2"
|
||||
local prompt_file=$3
|
||||
local label=$4
|
||||
local ngram_n_max="${5:-24}"
|
||||
local ngram_n_min="${6:-4}"
|
||||
|
||||
echo "=== $label ==="
|
||||
echo " spec-type: $spec_type"
|
||||
echo " ngram-mod n-max: $ngram_n_max (if enabled)"
|
||||
echo " ngram-mod n-min: $ngram_n_min (if enabled)"
|
||||
echo " prompt: $prompt_file ($(wc -c < "$prompt_file") bytes)"
|
||||
|
||||
# Build command
|
||||
local cmd=(
|
||||
/usr/bin/env
|
||||
ROCM_PATH="$ROCM_PATH"
|
||||
HIP_VISIBLE_DEVICES=0
|
||||
LD_LIBRARY_PATH="$LIBRARY_PATH"
|
||||
HSA_OVERRIDE_GFX_VERSION="${HSA_OVERRIDE_GFX_VERSION:-11.5.1}"
|
||||
"$LLAMA_BIN"
|
||||
--model "$MODEL"
|
||||
--chat-template-file "$CHAT_TEMPLATE_FILE"
|
||||
--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS"
|
||||
--host 127.0.0.1
|
||||
--port "$port"
|
||||
--jinja
|
||||
--device ROCm0
|
||||
--gpu-layers 999
|
||||
--parallel 1
|
||||
--ctx-size "$CTX_SIZE"
|
||||
--batch-size "$BATCH_SIZE"
|
||||
--ubatch-size "$UBATCH_SIZE"
|
||||
--fit "$FIT"
|
||||
--flash-attn on
|
||||
--cache-type-k "$CACHE_TYPE_K"
|
||||
--cache-type-v "$CACHE_TYPE_V"
|
||||
--threads "$NUM_THREADS"
|
||||
--threads-batch "$THREAD_BATCH"
|
||||
--cache-prompt
|
||||
--cache-idle-slots
|
||||
--metrics
|
||||
--temp "$TEMPERATURE"
|
||||
--top-p "$TOP_P"
|
||||
--top-k "$TOP_K"
|
||||
--min-p "$MIN_P"
|
||||
--reasoning "$REASONING"
|
||||
--reasoning-format "$REASONING_FORMAT"
|
||||
--reasoning-budget -1
|
||||
)
|
||||
|
||||
# Add spec flags
|
||||
if [[ "$spec_type" == "ngram-mod" ]]; then
|
||||
cmd+=(
|
||||
--spec-type "ngram-mod"
|
||||
--spec-ngram-mod-n-max "$ngram_n_max"
|
||||
--spec-ngram-mod-n-min "$ngram_n_min"
|
||||
)
|
||||
else
|
||||
cmd+=(
|
||||
--spec-type "none"
|
||||
)
|
||||
fi
|
||||
|
||||
# Start server with stderr to log
|
||||
local log_file="/tmp/ngram-bench-${label//[^a-zA-Z0-9]/-}.log"
|
||||
echo " Starting server (log: $log_file)..."
|
||||
|
||||
"${cmd[@]}" 2> "$log_file" &
|
||||
local server_pid=$!
|
||||
|
||||
if ! wait_for_server "$port"; then
|
||||
echo "ERROR: Server failed to start" >&2
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Server ready."
|
||||
|
||||
# Send prompt via API
|
||||
echo " Sending prompt..."
|
||||
local prompt_text
|
||||
prompt_text=$(cat "$prompt_file")
|
||||
|
||||
local response
|
||||
response=$(curl -s -X POST "http://127.0.0.1:$port/v1/completions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"model\": \"frank-dragoon\",
|
||||
\"prompt\": \"$prompt_text\",
|
||||
\"max_tokens\": 128,
|
||||
\"temperature\": 0,
|
||||
\"stream\": false
|
||||
}")
|
||||
|
||||
# Extract metrics
|
||||
local prompt_tokens prompt_eval_duration_ms total_duration_ms
|
||||
prompt_tokens=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['prompt_eval_count'])" 2>/dev/null || echo "0")
|
||||
prompt_eval_duration_ms=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['prompt_eval_duration_ms'])" 2>/dev/null || echo "0")
|
||||
total_duration_ms=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['total_duration_ms'])" 2>/dev/null || echo "0")
|
||||
|
||||
# Calculate throughput
|
||||
local prompt_tps=0
|
||||
if [[ "$prompt_eval_duration_ms" -gt 0 ]]; then
|
||||
prompt_tps=$(python3 -c "print(f'{($prompt_tokens / $prompt_eval_duration_ms) * 1000:.1f}')")
|
||||
fi
|
||||
|
||||
echo " Prompt tokens: $prompt_tokens"
|
||||
echo " Prompt eval: ${prompt_eval_duration_ms}ms"
|
||||
echo " Prompt TPS: $prompt_tps tok/s"
|
||||
echo " Total duration: ${total_duration_ms}ms"
|
||||
|
||||
# Kill server
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
echo " Done."
|
||||
echo "---"
|
||||
|
||||
# Output structured results for parsing
|
||||
echo "RESULT|$label|$prompt_file|$prompt_tokens|$prompt_eval_duration_ms|$total_duration_ms|$prompt_tps"
|
||||
}
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ N-gram Mod Benchmark — frank-dragoon ║"
|
||||
echo "║ STRIX ROCmFP4 · 64k context · two prompts ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Host: $HOSTNAME"
|
||||
echo "Model: $MODEL"
|
||||
echo "Build: $(basename "$LLAMA_BIN")"
|
||||
echo "Context: $CTX_SIZE"
|
||||
echo ""
|
||||
|
||||
# Run baseline (no spec)
|
||||
echo "=== BASELINE (no speculative decoding) ==="
|
||||
echo ""
|
||||
|
||||
BASELINE_CODE_RESULT=$(run_single_test $PORT_BASELINE "none" "$PROMPT_CODE" "Baseline-Code" 24 4)
|
||||
BASELINE_PROSE_RESULT=$(run_single_test $PORT_NGRAM "none" "$PROMPT_PROSE" "Baseline-Prose" 24 4)
|
||||
|
||||
echo ""
|
||||
echo "=== NGRAM-MOD (speculative decoding enabled) ==="
|
||||
echo ""
|
||||
|
||||
# Run n-gram mod
|
||||
NGRAM_CODE_RESULT=$(run_single_test $PORT_NGRAM "ngram-mod" "$PROMPT_CODE" "Ngram-Code" 24 4)
|
||||
NGRAM_PROSE_RESULT=$(run_single_test $PORT_BASELINE "ngram-mod" "$PROMPT_PROSE" "Ngram-Prose" 24 4)
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ RESULTS SUMMARY ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Parse and display results
|
||||
python3 << 'PYEOF'
|
||||
import sys
|
||||
|
||||
results = []
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line.startswith("RESULT|"):
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) != 8:
|
||||
continue
|
||||
label, prompt_file, prompt_tokens, prompt_eval_ms, total_ms, tps = parts[1], parts[2], int(parts[3]), int(parts[4]), int(parts[5]), float(parts[6])
|
||||
results.append({
|
||||
'label': label,
|
||||
'prompt_file': prompt_file,
|
||||
'prompt_tokens': prompt_tokens,
|
||||
'prompt_eval_ms': prompt_eval_ms,
|
||||
'total_ms': total_ms,
|
||||
'tps': tps
|
||||
})
|
||||
|
||||
# Compare baseline vs ngram for each prompt type
|
||||
for code_result in results:
|
||||
if not code_result['label'].startswith('Ngram-'):
|
||||
continue
|
||||
prompt_type = code_result['label'].replace('Ngram-', '').replace('Ngram', 'Baseline').replace('-', '-')
|
||||
baseline = next((r for r in results if r['label'] == 'Baseline-' + prompt_type), None)
|
||||
if not baseline:
|
||||
continue
|
||||
|
||||
spec = code_result['label'] == 'Ngram-' + prompt_type
|
||||
|
||||
print(f"Prompt: {prompt_type}")
|
||||
print(f" Tokens: {code_result['prompt_tokens']}")
|
||||
if spec:
|
||||
print(f" Spec: ngram-mod (n_max=24, n_min=4)")
|
||||
print(f" Prompt: {code_result['prompt_eval_ms']}ms | {code_result['tps']:>10.1f} tok/s")
|
||||
baseline_tps = baseline['tps']
|
||||
if baseline_tps > 0:
|
||||
improvement = ((code_result['tps'] - baseline_tps) / baseline_tps) * 100
|
||||
print(f" Speedup: {improvement:+.1f}% vs baseline ({baseline_tps:.1f} tok/s)")
|
||||
else:
|
||||
print(f" Spec: none (baseline)")
|
||||
print(f" Prompt: {baseline['prompt_eval_ms']}ms | {baseline['tps']:>10.1f} tok/s")
|
||||
print()
|
||||
PYEOF
|
||||
|
||||
echo ""
|
||||
echo "Raw data lines above (RESULT|... format) can be parsed for automation."
|
||||
echo ""
|
||||
@@ -1,229 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark decode throughput across speculative decoding configs.
|
||||
|
||||
Sweeps 4 configs: none, ngram-mod, draft-mtp, draft-mtp+ngram-mod.
|
||||
Each config generates 16k tokens from a minimal prompt at temp 0.
|
||||
|
||||
Runs ON the target host (Dankclaw). Service management uses local sudo.
|
||||
Usage:
|
||||
python3 bench-spec-decode.py [--max-tokens N] [--host H] [--port P]
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
SERVICE = "frank-dragoon"
|
||||
ENV_FILE = "/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
|
||||
ALIAS = "frank-dragoon"
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 7770
|
||||
DEFAULT_MAX_TOKENS = 16384
|
||||
HEALTH_TIMEOUT = 1200 # 20 min for cold load
|
||||
HEALTH_INTERVAL = 5
|
||||
|
||||
PROMPT = (
|
||||
"Write a comprehensive, detailed essay about the complete history of "
|
||||
"computing, from the earliest mechanical calculators through modern AI. "
|
||||
"Cover every major era in detail, including key figures, machines, and "
|
||||
"breakthroughs. Be as thorough as possible."
|
||||
)
|
||||
|
||||
CONFIGS = [
|
||||
("none", "none"),
|
||||
("ngram-mod", "ngram-mod"),
|
||||
("draft-mtp", "draft-mtp"),
|
||||
("draft-mtp+ngram-mod", "draft-mtp,ngram-mod"),
|
||||
]
|
||||
|
||||
|
||||
def local(cmd):
|
||||
"""Run a command locally (the script runs on the target host)."""
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120)
|
||||
if r.returncode != 0:
|
||||
print(f" WARNING: {cmd} -> exit {r.returncode}: {r.stderr.strip()}",
|
||||
file=sys.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def sed_spec_type(value):
|
||||
local(f"sudo sed -i 's/^SPEC_TYPE=.*/SPEC_TYPE={value}/' {ENV_FILE}")
|
||||
|
||||
|
||||
def wait_health(host, port, timeout=HEALTH_TIMEOUT):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
resp = urllib.request.urlopen(
|
||||
f"http://{host}:{port}/health", timeout=10
|
||||
)
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, ConnectionError,
|
||||
OSError):
|
||||
pass
|
||||
time.sleep(HEALTH_INTERVAL)
|
||||
return False
|
||||
|
||||
|
||||
def benchmark(host, port, alias, max_tokens):
|
||||
body = json.dumps({
|
||||
"model": alias,
|
||||
"messages": [{"role": "user", "content": PROMPT}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0,
|
||||
"stream": False,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"http://{host}:{port}/v1/chat/completions",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
t0 = time.time()
|
||||
resp = urllib.request.urlopen(req, timeout=1200)
|
||||
wall = time.time() - t0
|
||||
data = json.loads(resp.read())
|
||||
return data, wall
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--host", default=DEFAULT_HOST)
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
ap.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS)
|
||||
args = ap.parse_args()
|
||||
|
||||
# Read original SPEC_TYPE from the env file
|
||||
r = local(f"grep '^SPEC_TYPE=' {ENV_FILE}")
|
||||
original_spec = r.stdout.strip()
|
||||
orig_val = (original_spec.split("=", 1)[1]
|
||||
if "=" in original_spec else "draft-mtp,ngram-mod")
|
||||
print(f"Original: {original_spec}")
|
||||
print(f"Max tokens: {args.max_tokens}")
|
||||
print(f"Configs: {[c[0] for c in CONFIGS]}")
|
||||
print()
|
||||
|
||||
# Stop + mask the production service
|
||||
print("Stopping + masking production service...")
|
||||
local(f"sudo systemctl stop {SERVICE}")
|
||||
local(f"sudo systemctl mask {SERVICE}")
|
||||
time.sleep(2)
|
||||
|
||||
results = []
|
||||
for name, spec_type in CONFIGS:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Config: {name} (SPEC_TYPE={spec_type})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Set SPEC_TYPE
|
||||
sed_spec_type(spec_type)
|
||||
print(f"Set SPEC_TYPE={spec_type}")
|
||||
|
||||
# Start the service
|
||||
local(f"sudo systemctl unmask {SERVICE}")
|
||||
local(f"sudo systemctl start {SERVICE}")
|
||||
print("Starting service...")
|
||||
|
||||
# Wait for health
|
||||
print(f"Waiting for health (up to {HEALTH_TIMEOUT}s)...")
|
||||
if not wait_health(args.host, args.port):
|
||||
print(f"FAILED: service did not become healthy for {name}")
|
||||
results.append({
|
||||
"config": name, "spec_type": spec_type,
|
||||
"error": "health timeout",
|
||||
})
|
||||
local(f"sudo systemctl stop {SERVICE}")
|
||||
local(f"sudo systemctl mask {SERVICE}")
|
||||
continue
|
||||
|
||||
print("Healthy. Running benchmark...")
|
||||
|
||||
# Run benchmark
|
||||
try:
|
||||
data, wall = benchmark(
|
||||
args.host, args.port, ALIAS, args.max_tokens
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
results.append({
|
||||
"config": name, "spec_type": spec_type,
|
||||
"error": str(e),
|
||||
})
|
||||
local(f"sudo systemctl stop {SERVICE}")
|
||||
local(f"sudo systemctl mask {SERVICE}")
|
||||
continue
|
||||
|
||||
timings = data.get("timings", {})
|
||||
usage = data.get("usage", {})
|
||||
pred_n = timings.get("predicted_n", usage.get("completion_tokens", 0))
|
||||
pred_ms = timings.get("predicted_ms", 0)
|
||||
pred_ps = timings.get("predicted_per_second", 0)
|
||||
prompt_n = timings.get("prompt_n", usage.get("prompt_tokens", 0))
|
||||
prompt_ms = timings.get("prompt_ms", 0)
|
||||
|
||||
print(f" Generated: {pred_n} tokens")
|
||||
print(f" Wall time: {wall:.1f}s")
|
||||
print(f" Decode: {pred_ps:.2f} tok/s")
|
||||
print(f" Prefill: {prompt_n} tokens in {prompt_ms:.0f}ms"
|
||||
f" ({prompt_n / max(prompt_ms, 1) * 1000:.1f} tok/s)")
|
||||
|
||||
results.append({
|
||||
"config": name,
|
||||
"spec_type": spec_type,
|
||||
"predicted_tokens": pred_n,
|
||||
"predicted_ms": pred_ms,
|
||||
"predicted_per_second": pred_ps,
|
||||
"prompt_tokens": prompt_n,
|
||||
"prompt_ms": prompt_ms,
|
||||
"wall_time": wall,
|
||||
})
|
||||
|
||||
# Stop + mask between configs
|
||||
local(f"sudo systemctl stop {SERVICE}")
|
||||
local(f"sudo systemctl mask {SERVICE}")
|
||||
time.sleep(2)
|
||||
|
||||
# Restore original SPEC_TYPE
|
||||
sed_spec_type(orig_val)
|
||||
print(f"\nRestoring SPEC_TYPE={orig_val}")
|
||||
print("Unmasking + starting production service...")
|
||||
local(f"sudo systemctl unmask {SERVICE}")
|
||||
local(f"sudo systemctl start {SERVICE}")
|
||||
|
||||
# Health check
|
||||
if wait_health(args.host, args.port):
|
||||
print("Production service healthy.")
|
||||
else:
|
||||
print("WARNING: production service not healthy after restore!")
|
||||
|
||||
# Print results table
|
||||
print(f"\n{'='*70}")
|
||||
print("RESULTS")
|
||||
print(f"{'='*70}")
|
||||
print(f"{'Config':<30} {'Tokens':>8} {'Wall(s)':>10} {'Decode tok/s':>14} "
|
||||
f"{'Error':>20}")
|
||||
print(f"{'-'*30} {'-'*8} {'-'*10} {'-'*14} {'-'*20}")
|
||||
for r in results:
|
||||
if "error" in r:
|
||||
print(f"{r['config']:<30} {'':>8} {'':>10} {'':>14} "
|
||||
f"{r['error']:>20}")
|
||||
else:
|
||||
print(f"{r['config']:<30} {r['predicted_tokens']:>8} "
|
||||
f"{r['wall_time']:>10.1f} {r['predicted_per_second']:>14.2f} "
|
||||
f"{'':>20}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# Save JSON results
|
||||
ts = time.strftime("%Y%m%d-%H%M%S")
|
||||
out = f"/tmp/bench-spec-decode-{ts}.json"
|
||||
with open(out, "w") as f:
|
||||
json.dump({"results": results, "max_tokens": args.max_tokens,
|
||||
"timestamp": ts}, f, indent=2)
|
||||
print(f"Results saved: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bench-vllm-corpus.py — build a code-shaped benchmark corpus from the repo.
|
||||
|
||||
Why this exists: `vllm bench serve --dataset-name random` produces synthetic
|
||||
token soup, and speculative decoding accepts it nothing like real code — measured
|
||||
on frank-medium, random prompts accepted 2.9-3.6 tokens/update against the ~5.8
|
||||
the upstream reference reports on a code mix, which makes the random corpus
|
||||
under-report throughput by a wide margin and would pick the wrong SPEC depth.
|
||||
|
||||
The prompts are the shape agents actually send: a repository artifact plus an
|
||||
instruction that requires reading it. Output length is fixed at bench time
|
||||
(`--custom-output-len`), so the corpus carries prompts only.
|
||||
|
||||
Usage:
|
||||
bench-vllm-corpus.py [repo-root] [out.jsonl]
|
||||
|
||||
Then feed it to the endpoint (see scripts/bench-vllm-serve.sh, which passes the
|
||||
tokenizer the client needs and salts prompts so no run inherits a warm prefix
|
||||
cache). Methodology and the numbers these corpora produced are in
|
||||
serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
REPO = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "/home/dank/serverAppConfigs")
|
||||
OUT = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else "/home/dank/bench/corpus-code.jsonl")
|
||||
|
||||
TARGETS = [
|
||||
"scripts/deploy-remote.sh",
|
||||
"scripts/deploy-plan.py",
|
||||
"scripts/deploy.sh",
|
||||
"dankserver/frank-medium/radiance-entrypoint.sh",
|
||||
"dankserver/frank-medium/quadlet/frank-medium-a.container",
|
||||
"dankserver/frank-medium/proxy/affinity-proxy.py",
|
||||
"dankserver/frank-medium/proxy/routing.py",
|
||||
"tests/frank_medium_quadlet.bats",
|
||||
"tests/deployment_scoping.bats",
|
||||
"dankclaw/hermes/quadlet/hermes.container",
|
||||
"dankclaw/anubis/quadlet/anubis.container",
|
||||
"shared/prometheus/prometheus.yml",
|
||||
"shared/prometheus/rules/llm-fleet.yml",
|
||||
"shared/loki/loki-config.yaml",
|
||||
"dankclaw/compose.yaml",
|
||||
"dankserver/copyparty/quadlet/copyparty.container",
|
||||
]
|
||||
|
||||
PROMPTS = [
|
||||
"Read this repository artifact and summarise what it does, then list every "
|
||||
"assumption it makes about the host it runs on.\n\n```\n{body}\n```",
|
||||
"Here is a file from our homelab repository. Identify any bug, race or "
|
||||
"missing error path, and show the minimal patch that fixes it.\n\n```\n{body}\n```",
|
||||
"Explain this configuration to a new operator, then rewrite it with "
|
||||
"comments that state the constraint each line protects.\n\n```\n{body}\n```",
|
||||
"This file changed in a recent deploy. Write the changelog entry and a test "
|
||||
"that would fail if the change regressed.\n\n```\n{body}\n```",
|
||||
]
|
||||
|
||||
MAX_LINES = 220
|
||||
|
||||
|
||||
def main() -> int:
|
||||
samples = []
|
||||
for i, rel in enumerate(TARGETS):
|
||||
path = REPO / rel
|
||||
if not path.is_file():
|
||||
continue
|
||||
body = "\n".join(path.read_text(errors="replace").splitlines()[:MAX_LINES])
|
||||
samples.append({"prompt": PROMPTS[i % len(PROMPTS)].format(body=body)})
|
||||
if not samples:
|
||||
print(f"no corpus sources found under {REPO}", file=sys.stderr)
|
||||
return 1
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
with OUT.open("w") as fh:
|
||||
for sample in samples:
|
||||
fh.write(json.dumps(sample) + "\n")
|
||||
print(f"wrote {len(samples)} prompts to {OUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
# bench-vllm-serve.sh — concurrency sweep against a vLLM endpoint.
|
||||
#
|
||||
# Usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]
|
||||
# e.g. bench-vllm-serve.sh baseline 7770 1 4 8
|
||||
#
|
||||
# Runs `vllm bench serve` from inside the serving image (so the client matches
|
||||
# the server build) through an OpenAI-compatible endpoint, on the code corpus
|
||||
# scripts/bench-vllm-corpus.py writes.
|
||||
#
|
||||
# Two traps this script exists to encode, both of which produced silent garbage
|
||||
# during the 2026-09-16 frank-medium work:
|
||||
#
|
||||
# 1. The tokenizer must be passed explicitly and /models mounted. With only
|
||||
# `--model frank-medium` the client tries to resolve that alias on
|
||||
# HuggingFace and dies before sending a request — a run that looks like a
|
||||
# benchmark and reports nothing.
|
||||
# 2. `--ignore-eos` is deliberate: it measures decode throughput at a fixed
|
||||
# output length. Drop it when you care about real stop behaviour, but then
|
||||
# do not compare the numbers with anything measured under it.
|
||||
#
|
||||
# Also note the corpora are code-shaped on purpose (see the generator's header):
|
||||
# synthetic random prompts misreport speculative acceptance badly.
|
||||
#
|
||||
# Methodology, and the numbers these runs produced on 2x R9700, are in
|
||||
# serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md.
|
||||
set -u
|
||||
|
||||
TAG=${1:?usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]}
|
||||
PORT=${2:?usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]}
|
||||
shift 2
|
||||
CONCS=("$@")
|
||||
[ "${#CONCS[@]}" -eq 0 ] && CONCS=(1 4 8)
|
||||
|
||||
IMG=${IMG:-localhost/radiance-vllm:0.9.3}
|
||||
MODELS_DIR=${MODELS_DIR:-/home/dank/models}
|
||||
TOK=${TOK:-/models/Qwen3.8-27B-Uncensored-MXFP4-awq}
|
||||
BENCH_DIR=${BENCH_DIR:-/home/dank/bench}
|
||||
CORPUS=${CORPUS:-$BENCH_DIR/corpus-code.jsonl}
|
||||
OUT=${OUT:-$BENCH_DIR/$TAG}
|
||||
mkdir -p "$OUT"
|
||||
|
||||
if [ ! -f "$CORPUS" ]; then
|
||||
echo "corpus $CORPUS is missing; run scripts/bench-vllm-corpus.py first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for c in "${CONCS[@]}"; do
|
||||
echo "== $TAG conc=$c (port $PORT)"
|
||||
podman run --rm --network=host -v "$MODELS_DIR":/models:ro -v "$BENCH_DIR":/bench:ro \
|
||||
-e HF_HUB_OFFLINE=1 --entrypoint bash "$IMG" -lc "
|
||||
vllm bench serve --backend openai-chat \
|
||||
--base-url http://127.0.0.1:$PORT --endpoint /v1/chat/completions \
|
||||
--model frank-medium --tokenizer $TOK \
|
||||
--dataset-name custom --dataset-path /bench/$(basename "$CORPUS") \
|
||||
--custom-output-len 512 --num-prompts 16 --max-concurrency $c \
|
||||
--disable-shuffle --seed 0 --ignore-eos \
|
||||
--percentile-metrics ttft,tpot --metric-percentiles 50,99 \
|
||||
--disable-tqdm 2>&1 | grep -E 'Successful requests|Benchmark duration|Output token throughput|Total Token throughput|Mean TTFT|P50 TTFT|P50 TPOT|Acceptance rate|Acceptance length'
|
||||
" 2>&1 | sed 's/^/ /'
|
||||
echo
|
||||
done
|
||||
echo "results for $TAG written under $OUT"
|
||||
@@ -1,453 +0,0 @@
|
||||
{%- set template_version = "qwen3.8-froggeric-v22.4.0" %}
|
||||
{%- set _tool_format = tool_call_format if tool_call_format is defined else 'xml' %}
|
||||
{%- set image_count = namespace(value=0) %}
|
||||
{%- set video_count = namespace(value=0) %}
|
||||
{%- set add_vision_id = add_vision_id if add_vision_id is defined else false %}
|
||||
{%- set enable_thinking = enable_thinking if enable_thinking is defined else true %}
|
||||
{%- set auto_disable_thinking_with_tools = auto_disable_thinking_with_tools if auto_disable_thinking_with_tools is defined else false %}
|
||||
{%- if preserve_reasoning is defined and preserve_reasoning is not none %}
|
||||
{%- set _preserve_thinking = preserve_reasoning %}
|
||||
{%- elif preserve_thinking is defined and preserve_thinking is not none %}
|
||||
{%- set _preserve_thinking = preserve_thinking %}
|
||||
{%- else %}
|
||||
{%- set _preserve_thinking = true %}
|
||||
{%- endif %}
|
||||
{%- set max_tool_arg_chars = max_tool_arg_chars if max_tool_arg_chars is defined else 0 %}
|
||||
{%- set max_tool_response_chars = max_tool_response_chars if max_tool_response_chars is defined else 0 %}
|
||||
{%- set _default_reasoning_effort = 'medium' %}
|
||||
{%- set _has_tools = (tools is defined and tools and tools is iterable and tools is not mapping) %}
|
||||
{%- set _effort_raw = (reasoning_effort | string | lower) if reasoning_effort is defined and reasoning_effort is not none else _default_reasoning_effort %}
|
||||
{%- set _initial_thinking = enable_thinking %}
|
||||
{%- if _effort_raw in ('none', 'off') %}
|
||||
{%- set _initial_thinking = false %}
|
||||
{%- set _initial_effort = 'medium' %}
|
||||
{%- elif _effort_raw in ('minimal', 'low') %}
|
||||
{%- set _initial_effort = 'low' %}
|
||||
{%- elif _effort_raw in ('high', 'xhigh', 'max', 'ultracode', 'extreme') %}
|
||||
{%- set _initial_effort = 'xhigh' %}
|
||||
{%- else %}
|
||||
{%- set _initial_effort = 'medium' %}
|
||||
{%- endif %}
|
||||
{%- set ns_state = namespace(thinking=_initial_thinking, effort=_initial_effort) %}
|
||||
{%- if auto_disable_thinking_with_tools and _has_tools %}
|
||||
{%- set ns_state.thinking = false %}
|
||||
{%- endif %}
|
||||
{%- for msg in messages %}
|
||||
{%- if msg.role == 'system' or msg.role == 'developer' or msg.role == 'user' %}
|
||||
{%- if msg.content is string %}
|
||||
{%- if '<|think_off|>' in msg.content %}
|
||||
{%- set ns_state.thinking = false %}
|
||||
{%- elif '<|think_on|>' in msg.content %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- elif '<|think_xhigh|>' in msg.content or '<|think_high|>' in msg.content or '<|think_ultracode|>' in msg.content or '<|think_extreme|>' in msg.content or '<|think_max|>' in msg.content %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'xhigh' %}
|
||||
{%- elif '<|think_low|>' in msg.content or '<|think_minimal|>' in msg.content %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'low' %}
|
||||
{%- elif '<|think_medium|>' in msg.content %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'medium' %}
|
||||
{%- endif %}
|
||||
{%- elif msg.content is iterable and msg.content is not mapping %}
|
||||
{%- for item in msg.content %}
|
||||
{%- if item is string %}
|
||||
{%- set _item_text = item %}
|
||||
{%- elif item is mapping and 'text' in item and item.text is string %}
|
||||
{%- set _item_text = item.text %}
|
||||
{%- else %}
|
||||
{%- set _item_text = '' %}
|
||||
{%- endif %}
|
||||
{%- if _item_text %}
|
||||
{%- if '<|think_off|>' in _item_text %}
|
||||
{%- set ns_state.thinking = false %}
|
||||
{%- elif '<|think_on|>' in _item_text %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- elif '<|think_xhigh|>' in _item_text or '<|think_high|>' in _item_text or '<|think_ultracode|>' in _item_text or '<|think_extreme|>' in _item_text or '<|think_max|>' in _item_text %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'xhigh' %}
|
||||
{%- elif '<|think_low|>' in _item_text or '<|think_minimal|>' in _item_text %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'low' %}
|
||||
{%- elif '<|think_medium|>' in _item_text %}
|
||||
{%- set ns_state.thinking = true %}
|
||||
{%- set ns_state.effort = 'medium' %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- set reasoning_instructions = '' %}
|
||||
{%- if ns_state.thinking %}
|
||||
{%- if ns_state.effort == 'xhigh' %}
|
||||
{%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %}
|
||||
{%- elif ns_state.effort == 'low' %}
|
||||
{%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
|
||||
{%- if content is string %}
|
||||
{{- content }}
|
||||
{%- elif content is iterable and content is not mapping %}
|
||||
{%- for item in content %}
|
||||
{%- if item is mapping %}
|
||||
{%- if item.type == 'image' or 'image' in item or 'image_url' in item %}
|
||||
{%- if is_system_content %}
|
||||
{{- raise_exception('System message cannot contain images.') }}
|
||||
{%- endif %}
|
||||
{%- if do_vision_count %}
|
||||
{%- set image_count.value = image_count.value + 1 %}
|
||||
{%- endif %}
|
||||
{%- if add_vision_id %}
|
||||
{{- 'Picture ' ~ image_count.value ~ ': ' }}
|
||||
{%- endif %}
|
||||
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
|
||||
{%- elif item.type == 'video' or 'video' in item %}
|
||||
{%- if is_system_content %}
|
||||
{{- raise_exception('System message cannot contain videos.') }}
|
||||
{%- endif %}
|
||||
{%- if do_vision_count %}
|
||||
{%- set video_count.value = video_count.value + 1 %}
|
||||
{%- endif %}
|
||||
{%- if add_vision_id %}
|
||||
{{- 'Video ' ~ video_count.value ~ ': ' }}
|
||||
{%- endif %}
|
||||
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
|
||||
{%- elif 'text' in item %}
|
||||
{{- item.text }}
|
||||
{%- else %}
|
||||
{{- raise_exception('Unexpected item type in content.') }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- item | string }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- elif content is none or content is undefined %}
|
||||
{{- '' }}
|
||||
{%- else %}
|
||||
{{- raise_exception('Unexpected content type.') }}
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
{%- if not messages %}
|
||||
{{- raise_exception('No messages provided.') }}
|
||||
{%- endif %}
|
||||
{%- set head = namespace(count=0, seen_non_system=false) %}
|
||||
{%- for message in messages %}
|
||||
{%- set _is_sys = (message.role == 'system' or message.role == 'developer') %}
|
||||
{%- if _is_sys and not head.seen_non_system %}
|
||||
{%- set head.count = head.count + 1 %}
|
||||
{%- else %}
|
||||
{%- set head.seen_non_system = true %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- set sys_state = namespace(content='') %}
|
||||
{%- for message in messages[:head.count] %}
|
||||
{%- set _part = render_content(message.content, false, true) | trim %}
|
||||
{%- if '<|think_off|>' in _part %}{%- set _part = _part.split('<|think_off|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_on|>' in _part %}{%- set _part = _part.split('<|think_on|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_xhigh|>' in _part %}{%- set _part = _part.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_high|>' in _part %}{%- set _part = _part.split('<|think_high|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_ultracode|>' in _part %}{%- set _part = _part.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_extreme|>' in _part %}{%- set _part = _part.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_max|>' in _part %}{%- set _part = _part.split('<|think_max|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_medium|>' in _part %}{%- set _part = _part.split('<|think_medium|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_low|>' in _part %}{%- set _part = _part.split('<|think_low|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_minimal|>' in _part %}{%- set _part = _part.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
|
||||
{%- if _part %}
|
||||
{%- if sys_state.content %}
|
||||
{%- set sys_state.content = sys_state.content ~ '\n\n' ~ _part %}
|
||||
{%- else %}
|
||||
{%- set sys_state.content = _part %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- set _sc = sys_state.content %}
|
||||
{%- set _msgs = messages[head.count:] %}
|
||||
{%- if ns_state.thinking %}
|
||||
{%- set _terse_lead = 'Answer directly, after thinking. Lead with the answer, then only what it needs to be correct and usable.' %}
|
||||
{%- else %}
|
||||
{%- set _terse_lead = 'Answer directly and concisely. Give the answer with only what it needs to be correct and usable.' %}
|
||||
{%- endif %}
|
||||
{%- set _terse_core %}
|
||||
Never: open with preamble or pleasantries; restate the question; add filler transitions; hedge with niceties; or repeat a point you've already made.
|
||||
Always: keep essential steps, caveats, uncertainties, and specifics — never drop correctness or a needed warning for brevity. Keep the final answer lean. Use the least structure that conveys it (plain prose when short; lists or code only when they earn their place). If genuinely uncertain, say so and explain why — never omit uncertainty for the sake of brevity.
|
||||
If a user request is genuinely ambiguous, ask a sharp question, don't guess.
|
||||
{%- endset %}
|
||||
{%- set _terse = _terse_lead ~ '\n' ~ (_terse_core | trim) %}
|
||||
{#- `terse` chat-template kwarg: default true, so behaviour is unchanged unless a caller opts out.
|
||||
Pass {"terse": false} via chat_template_kwargs to serve the model with only its own system
|
||||
prompt -- useful for A/B-ing the terseness effect, or when a downstream prompt conflicts. #}
|
||||
{%- set _terse_on = terse if terse is defined else true %}
|
||||
{%- if _terse_on %}
|
||||
{%- if not _sc %}
|
||||
{%- set _sc = _terse | trim %}
|
||||
{%- else %}
|
||||
{%- set _sc = (_sc | trim) ~ '\n\n' ~ (_terse | trim) %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if _has_tools %}
|
||||
{{- '<|im_start|>system\n' }}
|
||||
{%- if reasoning_instructions %}
|
||||
{{- reasoning_instructions + '\n\n' }}
|
||||
{%- endif %}
|
||||
{{- '# Tools\n\nYou have access to the following functions:\n\n<tools>' }}
|
||||
{%- for tool in tools %}
|
||||
{{- '\n' }}
|
||||
{{- tool | tojson }}
|
||||
{%- endfor %}
|
||||
{{- '\n</tools>' }}
|
||||
{%- if _tool_format == 'json' %}
|
||||
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n{"name": "example_function_name", "arguments": {"example_parameter_1": "value_1", "example_parameter_2": "This is the value for the second parameter"}}\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: a single JSON object with "name" and "arguments" keys inside <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> tag MUST be at the very beginning of a new line, with NO spaces or indentation before it.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }}
|
||||
{%- else %}
|
||||
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> and <function> tags MUST be at the very beginning of a new line, with NO spaces or indentation before them.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }}
|
||||
{%- endif %}
|
||||
{%- if _sc %}
|
||||
{{- '\n\n' + _sc }}
|
||||
{%- endif %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- else %}
|
||||
{%- if _sc %}
|
||||
{{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + _sc + '<|im_end|>\n' }}
|
||||
{%- elif reasoning_instructions %}
|
||||
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- set _last_idx = _msgs | length - 1 %}
|
||||
{%- set ns = namespace(multi_step_tool=true, last_query_index=_last_idx) %}
|
||||
{%- for message in _msgs[::-1] %}
|
||||
{%- set index = (_msgs | length - 1) - loop.index0 %}
|
||||
{%- if ns.multi_step_tool and message.role == 'user' %}
|
||||
{%- set _rc = render_content(message.content, false) | trim %}
|
||||
{%- if not (_rc.startswith('<tool_response>') and _rc.endswith('</tool_response>')) %}
|
||||
{%- set ns.multi_step_tool = false %}
|
||||
{%- set ns.last_query_index = index %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- if ns.multi_step_tool %}
|
||||
{%- if _last_idx > 50 %}
|
||||
{%- set ns.last_query_index = _last_idx %}
|
||||
{%- else %}
|
||||
{%- set ns.last_query_index = 0 %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- set ns2 = namespace(prev_role='', consecutive_failures=0) %}
|
||||
{%- for message in _msgs %}
|
||||
{%- set is_system = (message.role == "system" or message.role == "developer") %}
|
||||
{%- set content = render_content(message.content, true, is_system) | trim %}
|
||||
{%- if is_system or message.role == 'user' %}
|
||||
{%- if '<|think_off|>' in content %}{%- set content = content.split('<|think_off|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_on|>' in content %}{%- set content = content.split('<|think_on|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_xhigh|>' in content %}{%- set content = content.split('<|think_xhigh|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_high|>' in content %}{%- set content = content.split('<|think_high|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_ultracode|>' in content %}{%- set content = content.split('<|think_ultracode|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_extreme|>' in content %}{%- set content = content.split('<|think_extreme|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_max|>' in content %}{%- set content = content.split('<|think_max|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_medium|>' in content %}{%- set content = content.split('<|think_medium|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_low|>' in content %}{%- set content = content.split('<|think_low|>') | join('') | trim %}{%- endif %}
|
||||
{%- if '<|think_minimal|>' in content %}{%- set content = content.split('<|think_minimal|>') | join('') | trim %}{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if is_system %}
|
||||
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
|
||||
{%- elif message.role == 'user' %}
|
||||
{%- set ns2.consecutive_failures = 0 %}
|
||||
{{- '<|im_start|>user\n' + content + '<|im_end|>\n' }}
|
||||
{%- elif message.role == 'assistant' %}
|
||||
{%- set reasoning_content = '' %}
|
||||
{%- set _explicit_reasoning = '' %}
|
||||
{%- if message.reasoning_content is defined and message.reasoning_content is not none %}
|
||||
{%- if message.reasoning_content is string %}
|
||||
{%- set _explicit_reasoning = message.reasoning_content %}
|
||||
{%- else %}
|
||||
{%- set _explicit_reasoning = message.reasoning_content | string %}
|
||||
{%- endif %}
|
||||
{%- elif message.thinking is defined and message.thinking is not none %}
|
||||
{%- if message.thinking is string %}
|
||||
{%- set _explicit_reasoning = message.thinking %}
|
||||
{%- else %}
|
||||
{%- set _explicit_reasoning = message.thinking | string %}
|
||||
{%- endif %}
|
||||
{%- elif message.reasoning is defined and message.reasoning is not none %}
|
||||
{%- if message.reasoning is string %}
|
||||
{%- set _explicit_reasoning = message.reasoning %}
|
||||
{%- else %}
|
||||
{%- set _explicit_reasoning = message.reasoning | string %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if _explicit_reasoning %}
|
||||
{%- set _lead_end = '' %}
|
||||
{%- if content.startswith('<think>') and '</think>' in content %}
|
||||
{%- set _lead_end = '</think>' %}
|
||||
{%- elif content.startswith('<thinking>') and '</thinking>' in content %}
|
||||
{%- set _lead_end = '</thinking>' %}
|
||||
{%- elif content.startswith('</think>') %}
|
||||
{%- set _lead_end = '</think>' %}
|
||||
{%- elif content.startswith('</thinking>') %}
|
||||
{%- set _lead_end = '</thinking>' %}
|
||||
{%- endif %}
|
||||
{%- if _lead_end %}
|
||||
{%- set content = content.split(_lead_end)[-1].lstrip('\n') %}
|
||||
{%- endif %}
|
||||
{%- set reasoning_content = _explicit_reasoning %}
|
||||
{%- else %}
|
||||
{%- set _think_end = '' %}
|
||||
{%- if content.startswith('</think>') %}
|
||||
{%- set _think_end = '</think>' %}
|
||||
{%- elif content.startswith('</thinking>') %}
|
||||
{%- set _think_end = '</thinking>' %}
|
||||
{%- elif '\n</think>' in content %}
|
||||
{%- set _think_end = '\n</think>' %}
|
||||
{%- elif '\n</thinking>' in content %}
|
||||
{%- set _think_end = '\n</thinking>' %}
|
||||
{%- elif '\n</ think>' in content %}
|
||||
{%- set _think_end = '\n</ think>' %}
|
||||
{%- elif '\n</think >' in content %}
|
||||
{%- set _think_end = '\n</think >' %}
|
||||
{%- elif content.startswith('<think>') and '</think>' in content %}
|
||||
{%- set _think_end = '</think>' %}
|
||||
{%- elif content.startswith('<thinking>') and '</thinking>' in content %}
|
||||
{%- set _think_end = '</thinking>' %}
|
||||
{%- endif %}
|
||||
{%- if _think_end %}
|
||||
{%- if 'thinking' in _think_end %}
|
||||
{%- set _think_start = '<thinking>' %}
|
||||
{%- else %}
|
||||
{%- set _think_start = '<think>' %}
|
||||
{%- endif %}
|
||||
{%- set reasoning_content = content.split(_think_end)[0].rstrip('\n') %}
|
||||
{%- if _think_start in reasoning_content %}
|
||||
{%- set reasoning_content = reasoning_content.split(_think_start)[-1].lstrip('\n') %}
|
||||
{%- endif %}
|
||||
{%- set content = content.split(_think_end)[-1].lstrip('\n') %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- set reasoning_content = reasoning_content | trim %}
|
||||
{%- if (_preserve_thinking or loop.index0 > ns.last_query_index) %}
|
||||
{{- '<|im_start|>assistant\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
|
||||
{%- else %}
|
||||
{{- '<|im_start|>assistant\n' + content }}
|
||||
{%- endif %}
|
||||
{%- if message.tool_calls is defined and message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
|
||||
{%- for tool_call in message.tool_calls %}
|
||||
{%- if tool_call.function is defined and tool_call.function is not none %}
|
||||
{%- set tc = tool_call.function %}
|
||||
{%- else %}
|
||||
{%- set tc = tool_call %}
|
||||
{%- endif %}
|
||||
{%- set tc_name = tc.name if (tc.name is defined and tc.name is not none) else '' %}
|
||||
{%- if _tool_format == 'json' %}
|
||||
{%- if loop.first %}
|
||||
{%- if content | trim %}
|
||||
{{- '\n\n' }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '\n' }}
|
||||
{%- endif %}
|
||||
{%- set _args = '{}' %}
|
||||
{%- if tc.arguments is defined and tc.arguments is not none %}
|
||||
{%- if tc.arguments is mapping %}
|
||||
{%- set _args = tc.arguments | tojson %}
|
||||
{%- elif tc.arguments is string %}
|
||||
{%- if tc.arguments %}
|
||||
{%- set _args = tc.arguments %}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{%- set _args = tc.arguments | tojson %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{{- '<tool_call>\n{"name": ' }}{{- tc_name | tojson }}{{- ', "arguments": ' }}{{- _args }}{{- '}\n</tool_call>' }}
|
||||
{%- else %}
|
||||
{%- if loop.first %}
|
||||
{%- if content | trim %}
|
||||
{{- '\n\n<tool_call>\n<function=' + tc_name + '>\n' }}
|
||||
{%- else %}
|
||||
{{- '<tool_call>\n<function=' + tc_name + '>\n' }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '\n<tool_call>\n<function=' + tc_name + '>\n' }}
|
||||
{%- endif %}
|
||||
{%- if tc.arguments is defined and tc.arguments is not none %}
|
||||
{%- if tc.arguments is mapping %}
|
||||
{%- for args_name, args_value in tc.arguments.items() %}
|
||||
{{- '<parameter=' + args_name + '>\n' }}
|
||||
{%- if args_value is string %}
|
||||
{%- set _av = args_value %}
|
||||
{%- else %}
|
||||
{%- set _av = args_value | tojson %}
|
||||
{%- endif %}
|
||||
{%- if max_tool_arg_chars > 0 and _av | length > max_tool_arg_chars %}
|
||||
{{- _av[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_av | length | string) ~ ' chars]' }}
|
||||
{%- else %}
|
||||
{{- _av }}
|
||||
{%- endif %}
|
||||
{{- '\n</parameter>\n' }}
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
{%- if tc.arguments is string %}
|
||||
{%- set _raw_args = tc.arguments %}
|
||||
{%- else %}
|
||||
{%- set _raw_args = tc.arguments | tojson %}
|
||||
{%- endif %}
|
||||
{%- if _raw_args %}
|
||||
{%- if max_tool_arg_chars > 0 and _raw_args | length > max_tool_arg_chars %}
|
||||
{{- _raw_args[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_raw_args | length | string) ~ ' chars]' }}
|
||||
{%- else %}
|
||||
{{- _raw_args }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{{- '</function>\n</tool_call>' }}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- elif message.role == 'tool' %}
|
||||
{%- set _content_lower = content | lower %}
|
||||
{%- set _content_head = _content_lower[:120] %}
|
||||
{%- set _is_code_or_grep = ('throw new ' in _content_lower or 'throw error' in _content_lower or 'console.error' in _content_lower or 'logger.error' in _content_lower or 'logging.error' in _content_lower or 'import ' in _content_head or 'def ' in _content_head or 'function ' in _content_head) %}
|
||||
{%- set _exit_code_zero = ('exit code: 0' in _content_head or 'process exited with code 0' in _content_head) %}
|
||||
{%- set _error_field_ok = ('"error": null' in _content_head or '"error":null' in _content_head or '"error": false' in _content_head or '"error":false' in _content_head or '"error": ""' in _content_head or '"error":""' in _content_head) %}
|
||||
{%- set _strong_error = (('"error":' in _content_head and not _error_field_ok) or '"status": "error"' in _content_head or '"status":"error"' in _content_head or 'traceback (most recent call last):' in _content_head or 'command not found' in _content_head or 'invalid syntax' in _content_head or 'fatal:' in _content_head or (('exit code: ' in _content_head or 'process exited with code' in _content_head) and not _exit_code_zero) or _content_head.startswith('exception:') or _content_head.startswith('failed to ')) %}
|
||||
{%- set _weak_error = ('error:' in _content_head or 'err!' in _content_head) %}
|
||||
{%- set _weak_suppressed = ('$ ' in _content_head or 'took ' in _content_head or content | length >= 600) %}
|
||||
{%- if not _is_code_or_grep and (_strong_error or (_weak_error and not _weak_suppressed)) %}
|
||||
{%- set ns2.consecutive_failures = ns2.consecutive_failures + 1 %}
|
||||
{%- else %}
|
||||
{%- set ns2.consecutive_failures = 0 %}
|
||||
{%- endif %}
|
||||
{%- if ns2.prev_role != 'tool' %}
|
||||
{{- '<|im_start|>user' }}
|
||||
{%- endif %}
|
||||
{%- if _tool_format != 'json' and max_tool_response_chars > 0 and content | length > max_tool_response_chars %}
|
||||
{%- set content = content[:max_tool_response_chars] + '\n[TRUNCATED - original length ' ~ (content | length | string) ~ ' chars]' %}
|
||||
{%- endif %}
|
||||
{{- '\n<tool_response>\n' + content }}
|
||||
{%- if ns2.consecutive_failures >= 2 %}
|
||||
{{- '\n\n⚠️ SYSTEM WARNING: ' ~ ns2.consecutive_failures ~ ' consecutive tool errors detected. Your previous approach is incorrect. You MUST use a fundamentally different approach or corrected arguments.' }}
|
||||
{%- elif ns2.consecutive_failures == 1 %}
|
||||
{{- '\n\n⚠️ SYSTEM WARNING: The previous tool call returned an error. Diagnose the failure and retry with completely corrected arguments.' }}
|
||||
{%- endif %}
|
||||
{{- '\n</tool_response>' }}
|
||||
{%- if loop.last %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- else %}
|
||||
{%- set _next_role = _msgs[loop.index0 + 1].role %}
|
||||
{%- if _next_role != 'tool' %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '<|im_start|>user\n[' + message.role + ']: ' + content + '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- set ns2.prev_role = message.role %}
|
||||
{%- endfor %}
|
||||
{%- if add_generation_prompt %}
|
||||
{{- '<|im_start|>assistant\n' }}
|
||||
{%- if not ns_state.thinking %}
|
||||
{{- '<think>\n\n</think>\n\n' }}
|
||||
{%- else %}
|
||||
{{- '<think>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download the kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT payload for
|
||||
# frank-medium from HuggingFace into
|
||||
# /home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT.
|
||||
#
|
||||
# Run on Dankserver (the box that serves it). The repo is public, so the
|
||||
# download works anonymously; a token in ~/.cache/huggingface/token is used
|
||||
# when present, which keeps the ~28 GB main file clear of the anonymous
|
||||
# rate limit at full speed.
|
||||
#
|
||||
# bash scripts/download-q8agent.sh
|
||||
# bash scripts/download-q8agent.sh --verify-only
|
||||
#
|
||||
# The script is resumable (curl -C -) and verifies every artifact's size
|
||||
# afterwards, so a truncated file fails loudly instead of load-crashing.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF"
|
||||
DIR="/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT"
|
||||
EXPECTED=(
|
||||
"Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf:28214425952"
|
||||
"mmproj-Qwen3.8-27B-BF16.gguf:931145888"
|
||||
"mtp-Qwen3.8-27B-Q4_0.gguf:1680271648"
|
||||
)
|
||||
# The model card's MTP prompt-cache fix ships in the same repo as a patch;
|
||||
# fetched for reference only. This fork's main already carries the
|
||||
# speculative boundary in the context checkpoint, so nothing applies it.
|
||||
EXTRA=(
|
||||
"patches/mtp-prompt-cache-fix.patch"
|
||||
)
|
||||
|
||||
cd -- "$(dirname -- "$0")/.."
|
||||
|
||||
main() {
|
||||
local mode="download"
|
||||
[[ "${1:-}" == "--verify-only" ]] && mode="verify"
|
||||
mkdir -p "$DIR"
|
||||
cd "$DIR"
|
||||
|
||||
local token="" auth=()
|
||||
if [[ -r "$HOME/.cache/huggingface/token" ]]; then
|
||||
token="$(cat "$HOME/.cache/huggingface/token")"
|
||||
auth=(-H "Authorization: Bearer $token")
|
||||
else
|
||||
echo "no HF token at $HOME/.cache/huggingface/token; downloading anonymously"
|
||||
fi
|
||||
|
||||
dl() {
|
||||
local f="$1"
|
||||
mkdir -p "$(dirname "$f")"
|
||||
curl -fsSL -C - -o "$f" --retry 10 --retry-delay 5 --retry-max-time 10800 \
|
||||
"${auth[@]}" \
|
||||
"https://huggingface.co/$REPO/resolve/main/$f"
|
||||
echo " done: $f ($(du -h "$f" | cut -f1))"
|
||||
}
|
||||
|
||||
if [[ "$mode" == "download" ]]; then
|
||||
echo "downloading $REPO into $DIR"
|
||||
for entry in "${EXPECTED[@]}"; do
|
||||
dl "${entry%%:*}"
|
||||
done
|
||||
for f in "${EXTRA[@]}"; do
|
||||
dl "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "verifying sizes"
|
||||
local ok=1 f want act
|
||||
for entry in "${EXPECTED[@]}"; do
|
||||
f="${entry%%:*}"; want="${entry##*:}"
|
||||
act="$(stat -c%s "$f" 2>/dev/null || echo 0)"
|
||||
if [[ "$act" -eq "$want" ]]; then
|
||||
echo " OK $f ($act bytes)"
|
||||
else
|
||||
echo " BAD $f ($act bytes, want $want)" >&2
|
||||
ok=0
|
||||
fi
|
||||
done
|
||||
if [[ -z "$token" ]]; then
|
||||
echo "note: no token was used; sizes above are the integrity check"
|
||||
fi
|
||||
[[ "$ok" -eq 1 ]] || exit 1
|
||||
echo "all files present and sized correctly"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,17 +0,0 @@
|
||||
# Tunables for frank-default-npu (FastFlowLM NPU endpoint, Dankclaw)
|
||||
# Runtime: FLM 1.0.1 on the XDNA2 NPU (50 TOPS). Model: Gemma4-E4B-IT
|
||||
# (8B, Q4_1, vision + audio + reasoning). The NPU allocates from the
|
||||
# unified pool shared with frank-default-large (the CIRU llama.cpp
|
||||
# service on the iGPU). See serverAppConfigs-docs/FastFlowLM NPU Inference.md.
|
||||
#
|
||||
# 2026-08-20: gemma4-it:e4b deployed. Footprint ~9.1 GB. 64k context.
|
||||
|
||||
FLM_MODEL=gemma4-it:e4b
|
||||
FLM_HOST=0.0.0.0
|
||||
FLM_PORT=52626
|
||||
FLM_PMODE=performance
|
||||
FLM_ASR=0
|
||||
# 64K context (native max for gemma4-it is 32768, but NPU supports up to 256k).
|
||||
# The model's max_prefill_len is 4096, so prompts longer than that get
|
||||
# truncated at prefill time.
|
||||
FLM_CTX_LEN=65536
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=FastFlowLM NPU inference (frank-default-npu) on Dankclaw
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FLM_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-npu.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-default-npu.sh
|
||||
LimitMEMLOCK=infinity
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,105 +0,0 @@
|
||||
# Tunables for frank-dragoon (mradermacher i1-Q4_K_M of
|
||||
# orcarouter/Qwen3.8-Flash-Next-Uncensored on ik_llama.cpp, Dankclaw,
|
||||
# RTX 3090 CUDA + Strix Halo CPU path, since 2026-09-15).
|
||||
#
|
||||
# Runtime: ik_llama.cpp master d85a1d7 (2026-09-15) with full qwen4exp
|
||||
# support (upstream PRs #2365/#2369/#2373 merged). Build recipe (CUDA for
|
||||
# the 3090 at sm_86, Zen5 AVX-512 IQK kernels for the CPU expert path):
|
||||
# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \
|
||||
# -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 \
|
||||
# -DGGML_AVX512=ON -DGGML_AVX512_VBMI=ON -DGGML_AVX512_VNNI=ON \
|
||||
# -DGGML_AVX512_BF16=ON -DLLAMA_CURL=OFF
|
||||
# The startup banner must print HAVE_FANCY_SIMD is defined — without those
|
||||
# macros the quantized GEMM silently falls back to AVX2 (no warning).
|
||||
#
|
||||
# Model: mradermacher imatrix quant of the uncensored Flash-Next (qwen4exp
|
||||
# MoE: 512 experts / 10 used, 48 layers = 12 attention + 36 GDN/ssm, QSA
|
||||
# indexer, hyper-connections, PLE n-gram table ~320M rows). 111 GiB shipped
|
||||
# as 3 parts, concatenated on disk; each part's sha256 was verified against
|
||||
# its HF LFS oid at download time.
|
||||
#
|
||||
# Placement (ikawrakow's hybrid recipe; upstream issue #2402 for the PLE):
|
||||
# - -ot "exps=CPU": ALL 512-expert tensors (121B elems, ~84 GB) live in
|
||||
# the Strix Halo's LPDDR5X via the plain CPU backend buffer — mmap-backed
|
||||
# page cache, ik's fastest known configuration for this shape (the
|
||||
# author's 2x3090 + Threadripper reference box measures ~50 t/s on the
|
||||
# same model family). The shared experts (shexp) keep their GPU-side
|
||||
# default: they run on every token.
|
||||
# - -ot "per_layer_token_embd=CPU": the ~32 GB PLE n-gram table follows —
|
||||
# only a tiny slice is touched per token, so it streams from page cache.
|
||||
# - NEVER use --cpu-moe on this host: it routes the CPU-overridden
|
||||
# weights through the CUDA_Host PINNED buffer (measured 2026-09-15:
|
||||
# 108.12 GiB page-locked, evicted every co-tenant into swap and starved
|
||||
# sshd into unreachability until a power cycle). -ot ...=CPU is the
|
||||
# mmap-backed path with identical DDR5 bandwidth for resident pages,
|
||||
# and the kernel degrades gracefully under pressure (page cache evict
|
||||
# + re-read) instead of stranding anyone.
|
||||
# - GGML_CUDA_NO_PINNED_WEIGHTS=1 (src/llama-load-tensors.cpp) keeps the
|
||||
# CPU-overridden weights mmapped and leaves only the small staging
|
||||
# buffers pinned. (GGML_CUDA_NO_PINNED would also unpin staging —
|
||||
# unnecessary here and slower for prefill transfers.)
|
||||
#
|
||||
# Sampling: greedy-by-default to preserve the halogen-era client contract
|
||||
# (a request without temperature decodes greedy; temp 0.0 = argmax).
|
||||
# Clients that send their own temperature get their own values.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (see
|
||||
# the 2026-08-13 HIP incident in AGENTS.md). llama.cpp reads --host and
|
||||
# --temp as CLI flags, never as environment variables.
|
||||
|
||||
# ik_llama.cpp runtime binary (master d85a1d7 build).
|
||||
LLAMA_BIN=/home/dank/GitPackages/ik_llama.cpp/build/bin/llama-server
|
||||
|
||||
# Model package from
|
||||
# https://huggingface.co/mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUF
|
||||
# (parts concatenated). The mmproj is the matching vision tower from the
|
||||
# static repo mradermacher/Qwen3.8-Flash-Next-Uncensored-GGUF — ik_llama.cpp
|
||||
# links examples/mtmd, so image input survives the runtime swap.
|
||||
MODEL_DIR=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M
|
||||
MODEL_FILE=Qwen3.8-Flash-Next-Uncensored.i1-Q4_K_M.gguf
|
||||
MMPROJ_PATH=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M/Qwen3.8-Flash-Next-Uncensored.mmproj-f16.gguf
|
||||
|
||||
# Qwen-Sharp chat template (tracked in the repo, staged on the host). The
|
||||
# server emits reasoning in DeepSeek format so clients receive
|
||||
# reasoning_content separately instead of literal think tags in content.
|
||||
CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja
|
||||
|
||||
# Alias + network.
|
||||
ALIAS=frank-dragoon
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
|
||||
# Serving: single 262144-token slot (the model's full context window),
|
||||
# batch 4096 / ubatch 512, 16+16 threads (the Halo CPU has 16 cores, no
|
||||
# SMT), f16 KV. The KV cache scales with the 12 full-attention layers only
|
||||
# (~6 GB f16 at 262k); the GDN/ssm layers keep constant-size state. If
|
||||
# VRAM headroom is needed, --cache-type-k/v q8_0 halves the KV footprint.
|
||||
CTX_SIZE=262144
|
||||
PARALLEL=1
|
||||
BATCH_SIZE=4096
|
||||
UBATCH_SIZE=512
|
||||
NUM_THREADS=16
|
||||
THREAD_BATCH=16
|
||||
CACHE_TYPE_K=f16
|
||||
CACHE_TYPE_V=f16
|
||||
|
||||
# GPU/MoE placement: everything on the 3090 except the experts and the PLE
|
||||
# table, which both live in host RAM via the CPU backend buffer (see the
|
||||
# placement notes above).
|
||||
NGL=999
|
||||
# ik_llama.cpp CUDA host-memory policy: CPU-overridden weights stay mmapped;
|
||||
# only the small staging buffers are pinned (see the placement notes above).
|
||||
GGML_CUDA_NO_PINNED_WEIGHTS=1
|
||||
|
||||
# Prompt cache (RAM budget in MiB) and slot state dir.
|
||||
CACHE_RAM=8192
|
||||
SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state
|
||||
|
||||
# Greedy-by-default (halogen contract). See sampling note above.
|
||||
TEMPERATURE=0.0
|
||||
|
||||
# Reasoning on: DeepSeek format projects thinking into the API's
|
||||
# reasoning_content field instead of literal think tags in content;
|
||||
# unlimited reasoning budget (the model decides).
|
||||
REASONING_FORMAT=deepseek
|
||||
REASONING_BUDGET=-1
|
||||
@@ -1,21 +0,0 @@
|
||||
[Unit]
|
||||
Description=ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M 177B MoE (dankclaw, frank-dragoon)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
# Staging buffers for the CUDA transfers are page-locked; the weights
|
||||
# themselves stay mmapped (GGML_CUDA_NO_PINNED_WEIGHTS=1), but the staging
|
||||
# budget still needs room. Parity with the halogen quadlet's
|
||||
# Ulimit=memlock=-1:-1.
|
||||
LimitMEMLOCK=infinity
|
||||
Environment="FRANK_DRAGOON_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-dragoon.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-dragoon.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,138 +0,0 @@
|
||||
# Tunables for frank-medium (kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF,
|
||||
# charlie12345/ROCmFPX llama.cpp, Dankserver, 2x AMD Radeon AI PRO R9700).
|
||||
#
|
||||
# Hardware (2026-09-15): the RTX 3090 left Dankserver and two Radeon AI PRO
|
||||
# R9700 cards (Navi 48, gfx1201, RDNA4, 32,624 MiB each, PCIe 5.0 x16) took
|
||||
# its place. The Raphael iGPU (gfx1036) is still enumerated by HIP as the
|
||||
# third device. There is no XGMI link between the two cards and they sit in
|
||||
# different IOMMU groups, so any cross-GPU traffic goes over PCIe.
|
||||
#
|
||||
# Runtime: charlie12345/ROCmFPX (llama.cpp fork, ROCmFPX quant family; the
|
||||
# Q8_0_ROCMFPX_AGENT tensor type exists nowhere else - stock llama.cpp cannot
|
||||
# load this GGUF). Built for gfx1201 with the fork's self-contained ROCm
|
||||
# 7.14 toolchain so the host's stale /opt/rocm-6.3.3 install is never used:
|
||||
#
|
||||
# cd /home/dank/GitPackages/charlie-ROCmFPX
|
||||
# env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh
|
||||
#
|
||||
# gfx1201 must be the build target: a gfx1200 binary links, loads the model
|
||||
# and then segfaults on this card (fork's docs/BUILD-AMD-ARCHITECTURES.md).
|
||||
#
|
||||
# Model: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT - Q8_0_ROCMFPX_AGENT
|
||||
# (ftype 115), 26.28 GiB, 8.39 bpw, arch qwen35 (MoE), quantized from a
|
||||
# 51.3 GiB BF16 source. The AGENT routing keeps 12 more attention-output
|
||||
# projections at full Q8_0 than the plain 8-bit build, which buys a higher
|
||||
# MTP draft acceptance (0.953 vs 0.911) and that is the whole point of this
|
||||
# checkpoint. Model card documents 131,072 context.
|
||||
#
|
||||
# Parallelism (--split-mode layer, operator call 2026-09-15 "pipeline
|
||||
# parallelism"): layers are distributed across the two cards in order and
|
||||
# activations hop PCIe at the boundary. Measured with llama-bench
|
||||
# (pp4096 / tg128 / same at depth 32768, f16 KV, fa on, 3 reps):
|
||||
#
|
||||
# single R9700 1160.9 / 19.42 (fits: 26.27 GiB weights, ~5 GiB left)
|
||||
# layer 2x 1991.2 / 18.76 859.1 / 17.64
|
||||
# tensor 2x 271.8 / 26.26 228.6 / 25.09
|
||||
#
|
||||
# Tensor parallel wins decode (+40 %) but collapses prefill 7x: this build
|
||||
# has no RCCL (llama.cpp logs "AllReduce init failed; falling back to
|
||||
# meta-backend butterfly"), so every layer pays a slow cross-card reduction.
|
||||
# Agentic traffic is prefill-heavy, so layer split wins the workload. Revisit
|
||||
# if the fork ships a working RCCL tensor path.
|
||||
#
|
||||
# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The
|
||||
# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP
|
||||
# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as
|
||||
# env vars.
|
||||
|
||||
# charlie12345/ROCmFPX llama-server (self-contained ROCm 7.14 build, RPATH
|
||||
# points at the bundled runtime libs; no LD_LIBRARY_PATH needed).
|
||||
LLAMA_BIN=/home/dank/GitPackages/charlie-ROCmFPX/build-rdna4-rocm714/bin/llama-server
|
||||
|
||||
# Model package, fetched with scripts/download-q8agent.sh:
|
||||
# Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf 26.28 GiB main weights
|
||||
# mtp-Qwen3.8-27B-Q4_0.gguf 1.68 GiB MTP draft head
|
||||
# mmproj-Qwen3.8-27B-BF16.gguf 0.93 GiB vision tower (unused)
|
||||
MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT
|
||||
MODEL_FILE=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf
|
||||
DRAFT_MODEL_FILE=mtp-Qwen3.8-27B-Q4_0.gguf
|
||||
|
||||
# Alias + network. The port is the fleet's dankserver endpoint; the alias is
|
||||
# what clients ask for, and it replaces the retired `frank-small`.
|
||||
ALIAS=frank-medium
|
||||
BIND_ADDR=0.0.0.0
|
||||
LISTEN_PORT=7770
|
||||
|
||||
# Device placement. HIP_VISIBLE_DEVICES hides the iGPU (device 2) so nothing
|
||||
# can drift onto shared system RAM; DEVICES is what llama.cpp splits across.
|
||||
HIP_VISIBLE_DEVICES=0,1
|
||||
DEVICES=ROCm0,ROCm1
|
||||
SPLIT_MODE=layer
|
||||
|
||||
# TENSOR_SPLIT empty = layers distributed by free VRAM (equal here).
|
||||
TENSOR_SPLIT=
|
||||
|
||||
NGL=999
|
||||
# Auto-fit has no business resizing a context we set explicitly.
|
||||
FIT=off
|
||||
FLASH_ATTN=on
|
||||
|
||||
# 131,072 tokens, one slot (the model card's documented window; the GGUF
|
||||
# reports n_ctx_train 262144, untested by the model author). Layer split
|
||||
# keeps each layer's KV on the card that owns the layer, so the window is
|
||||
# bounded by per-card free memory: ~5.6 GiB of KV per card at 131072 f16.
|
||||
CTX_SIZE=131072
|
||||
PARALLEL=1
|
||||
|
||||
# 26.28 GiB of weights + MTP draft + KV cache leaves ~15 GiB per card at this
|
||||
# context, so the batch shape is chosen for prefill throughput, not memory.
|
||||
BATCH_SIZE=2048
|
||||
UBATCH_SIZE=512
|
||||
NUM_THREADS=8
|
||||
THREAD_BATCH=8
|
||||
|
||||
# f16 K/V: an agent checkpoint is the wrong place to spend KV precision.
|
||||
CACHE_TYPE_K=f16
|
||||
CACHE_TYPE_V=f16
|
||||
CTX_CHECKPOINTS=8
|
||||
CACHE_RAM=8192
|
||||
|
||||
# Sampling: the checkpoint's instruct table.
|
||||
TEMPERATURE=0.7
|
||||
TOP_P=0.8
|
||||
TOP_K=20
|
||||
MIN_P=0.0
|
||||
PRESENCE_PENALTY=1.5
|
||||
|
||||
# Reasoning on; DeepSeek format projects the trace into reasoning_content
|
||||
# instead of literal thinking tags in content.
|
||||
REASONING=on
|
||||
REASONING_FORMAT=deepseek
|
||||
REASONING_BUDGET=-1
|
||||
|
||||
# Chat template: empty = the template embedded in the GGUF. Point this at
|
||||
# /home/dank/serverAppConfigs/scripts/chat-template.jinja to force the repo's
|
||||
# Sharp agent template instead.
|
||||
CHAT_TEMPLATE_FILE=
|
||||
CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}'
|
||||
|
||||
# MTP speculative decoding. n_max 6 / p_min 0.6 is the fork's measured
|
||||
# sweet spot for a MTP head with high acceptance; p_min 0.0 drafts
|
||||
# low-probability tokens that get rejected and makes MTP a net loss.
|
||||
SPEC_TYPE=draft-mtp
|
||||
SPEC_DRAFT_N_MAX=6
|
||||
SPEC_DRAFT_N_MIN=0
|
||||
SPEC_DRAFT_P_MIN=0.6
|
||||
SPEC_DRAFT_P_SPLIT=0.10
|
||||
SPEC_DRAFT_NGL=999
|
||||
SPEC_DRAFT_TYPE_K=f16
|
||||
SPEC_DRAFT_TYPE_V=f16
|
||||
SPEC_DRAFT_POLL=1
|
||||
SPEC_DRAFT_POLL_BATCH=1
|
||||
# The draft head rides the card that holds the target model's LAST layers:
|
||||
# the MTP head consumes the final hidden state, so pinning it to ROCm1 keeps
|
||||
# the per-token hop off the wire. (Under the abandoned tensor split the
|
||||
# draft must also be pinned to exactly one device or the meta-device KV
|
||||
# allocation aborts: GGML_ASSERT(buffer) in ggml-backend.cpp, measured
|
||||
# 2026-09-15.)
|
||||
SPEC_DRAFT_DEVICE=ROCm1
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=charlie12345/ROCmFPX llama.cpp serving kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT qwen35 MoE (dankserver, frank-medium, 2x Radeon AI PRO R9700)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dank
|
||||
Group=dank
|
||||
Environment="FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env"
|
||||
ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve the FastFlowLM (FLM) NPU endpoint — frank-default-npu.
|
||||
#
|
||||
# Runs flm serve with the model tag in frank-default-npu.env. The NPU
|
||||
# (XDNA2, 50 TOPS) is a single-lane device: generations serialize in FIFO
|
||||
# order, so this is a background/triage endpoint. The NPU shares the ~30 GiB
|
||||
# unified pool with frank-default-large (the CIRU llama.cpp service on the
|
||||
# iGPU); see serverAppConfigs-docs/FastFlowLM NPU Inference.md for the memory budget.
|
||||
#
|
||||
# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FLM_NPU_ENV_FILE:-$SCRIPT_DIR/frank-default-npu.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${FLM_BIN:=/usr/bin/flm}"
|
||||
: "${FLM_MODEL:=gemma4-it:e4b}"
|
||||
: "${FLM_HOST:=0.0.0.0}"
|
||||
: "${FLM_PORT:=52626}"
|
||||
: "${FLM_PMODE:=performance}"
|
||||
: "${FLM_ASR:=0}"
|
||||
: "${FLM_CTX_LEN:=-1}"
|
||||
|
||||
if [[ ! -x "$FLM_BIN" ]]; then
|
||||
echo "missing flm binary: $FLM_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- optional flags ----------------------------------------------------------
|
||||
OPT_ARGS=()
|
||||
if [[ "${FLM_ASR}" != "0" ]]; then
|
||||
OPT_ARGS+=(-a "${FLM_ASR}")
|
||||
fi
|
||||
if [[ "${FLM_CTX_LEN}" != "-1" ]]; then
|
||||
OPT_ARGS+=(-c "${FLM_CTX_LEN}")
|
||||
fi
|
||||
|
||||
exec "$FLM_BIN" serve "$FLM_MODEL" \
|
||||
--host "$FLM_HOST" \
|
||||
-p "$FLM_PORT" \
|
||||
--pmode "$FLM_PMODE" \
|
||||
"${OPT_ARGS[@]}"
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve mradermacher/Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M as frank-dragoon
|
||||
# via ik_llama.cpp on Dankclaw.
|
||||
#
|
||||
# Topology (ikawrakow's hybrid recipe; upstream issue #2402 for the PLE):
|
||||
# the RTX 3090 (CUDA, sm_86) takes the dense/attention/output weights and
|
||||
# the KV cache (-ngl 999); ALL MoE expert tensors and the ~32 GB PLE
|
||||
# n-gram table stay in the Strix Halo's LPDDR5X via the plain CPU backend
|
||||
# buffer (-ot ...=CPU — mmap-backed page cache; only a tiny slice of PLE is
|
||||
# touched per token). NEVER use --cpu-moe on this host: it routes the
|
||||
# CPU-overridden weights through the CUDA_Host PINNED buffer (measured
|
||||
# 108.12 GiB page-locked on 2026-09-15, which evicted every co-tenant into
|
||||
# swap and starved sshd into unreachability until a power cycle). NEVER add
|
||||
# --no-mmap — the STRIX-era lesson transfers directly (the cgroup killer
|
||||
# takes the process with nothing in the server log).
|
||||
#
|
||||
# Runtime: ik_llama.cpp master d85a1d7 built with CUDA (sm_86) + Zen5
|
||||
# AVX-512 IQK kernels; the startup banner must show HAVE_FANCY_SIMD
|
||||
# is defined. Flag set verified against this binary's --help before this
|
||||
# script was written. ik has no --cache-prompt/--cache-idle-slots (prompt
|
||||
# caching is its default behavior) and no --alias: it is a single-model
|
||||
# server and reports the GGUF's general.name at /v1/models.
|
||||
#
|
||||
# Sampling: greedy-by-default (halogen client contract) — clients that
|
||||
# send their own temperature get their own values.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -------------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/ik_llama.cpp/build/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M}"
|
||||
: "${MODEL_FILE:=Qwen3.8-Flash-Next-Uncensored.i1-Q4_K_M.gguf}"
|
||||
: "${MMPROJ_PATH:=$MODEL_DIR/Qwen3.8-Flash-Next-Uncensored.mmproj-f16.gguf}"
|
||||
: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
: "${CTX_SIZE:=262144}"
|
||||
: "${PARALLEL:=1}"
|
||||
: "${BATCH_SIZE:=4096}"
|
||||
: "${UBATCH_SIZE:=512}"
|
||||
: "${NUM_THREADS:=16}"
|
||||
: "${THREAD_BATCH:=16}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${NGL:=999}"
|
||||
: "${CPU_MOE:=on}"
|
||||
: "${FLASH_ATTN:=on}"
|
||||
: "${TEMPERATURE:=0.0}"
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
: "${REASONING_BUDGET:=-1}"
|
||||
: "${CACHE_RAM:=8192}"
|
||||
: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}"
|
||||
# Hard guard: on this host CUDA pinned host memory strands the co-tenants
|
||||
# (measured 2026-09-15: a default-policy start tried to page-lock 108.12 GiB,
|
||||
# evicted every co-tenant into swap and starved sshd into unreachability
|
||||
# until a power cycle). GGML_CUDA_NO_PINNED_WEIGHTS=1 keeps the
|
||||
# CPU-overridden weights mmapped (src/llama-load-tensors.cpp); the env file
|
||||
# sets it. Refuse to run without it.
|
||||
if [[ "${GGML_CUDA_NO_PINNED_WEIGHTS:-}" != "1" ]]; then
|
||||
echo "frank-dragoon: refusing to start — GGML_CUDA_NO_PINNED_WEIGHTS=1 is mandatory on this host" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$SLOT_SAVE_PATH"
|
||||
|
||||
# The UVM device node is created lazily by nvidia-modprobe on the first
|
||||
# CUDA open after boot; make sure it exists before the server needs it.
|
||||
nvidia-modprobe -u -c=0 2>/dev/null || true
|
||||
|
||||
CPU_MOE_ARGS=()
|
||||
if [[ "$CPU_MOE" == "on" ]]; then
|
||||
# -ot ...=CPU (plain CPU backend buffer) is the mmap-backed path. NEVER
|
||||
# swap this for --cpu-moe: that routes the weights through the CUDA_Host
|
||||
# PINNED buffer (108.12 GiB measured) and strands the co-tenants.
|
||||
CPU_MOE_ARGS=(-ot "exps=CPU" -ot "per_layer_token_embd=CPU")
|
||||
fi
|
||||
|
||||
MMPROJ_ARGS=()
|
||||
if [[ -n "${MMPROJ_PATH:-}" && -f "$MMPROJ_PATH" ]]; then
|
||||
MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH")
|
||||
fi
|
||||
|
||||
TEMPLATE_ARGS=()
|
||||
if [[ -n "${CHAT_TEMPLATE_FILE:-}" && -f "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
TEMPLATE_ARGS=(--chat-template-file "$CHAT_TEMPLATE_FILE")
|
||||
else
|
||||
TEMPLATE_ARGS=(--jinja)
|
||||
fi
|
||||
|
||||
exec "$LLAMA_BIN" \
|
||||
--model "$MODEL_DIR/$MODEL_FILE" \
|
||||
"${TEMPLATE_ARGS[@]}" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT" \
|
||||
--gpu-layers "$NGL" \
|
||||
"${CPU_MOE_ARGS[@]}" \
|
||||
--flash-attn "$FLASH_ATTN" \
|
||||
--parallel "$PARALLEL" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--threads "$NUM_THREADS" \
|
||||
--threads-batch "$THREAD_BATCH" \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--cache-ram "$CACHE_RAM" \
|
||||
--slot-save-path "$SLOT_SAVE_PATH" \
|
||||
--ctx-checkpoints 16 \
|
||||
--metrics \
|
||||
--temp "$TEMPERATURE" \
|
||||
--reasoning "$REASONING" \
|
||||
--reasoning-format "$REASONING_FORMAT" \
|
||||
--reasoning-budget "$REASONING_BUDGET" \
|
||||
"${MMPROJ_ARGS[@]}"
|
||||
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF as frank-medium via
|
||||
# the charlie12345/ROCmFPX llama.cpp runtime on Dankserver
|
||||
# (2x AMD Radeon AI PRO R9700, Navi 48, gfx1201, 32 GB each).
|
||||
#
|
||||
# Runtime: charlie12345/ROCmFPX main (c49ebdb, 2026-08-22), built for
|
||||
# gfx1201 with the fork's self-contained ROCm toolchain:
|
||||
#
|
||||
# cd /home/dank/GitPackages/charlie-ROCmFPX
|
||||
# env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh
|
||||
# -> build-rdna4-rocm714/bin/llama-server
|
||||
#
|
||||
# That script downloads the gfx120X-all TheRock ROCm 7.14 toolchain into
|
||||
# ~/.local/share/rocm-7.14.0a20260624, builds with
|
||||
# -DCMAKE_HIP_ARCHITECTURES="gfx1200;gfx1201", copies the ROCm runtime
|
||||
# (hip/hsa/rocblas/hipblaslt + their kernel libraries) next to the binary and
|
||||
# sets RPATH=$ORIGIN/../lib on it. The resulting service is self-contained:
|
||||
# no LD_LIBRARY_PATH, and the host's stale /opt/rocm-6.3.3 cannot leak in.
|
||||
# gfx1201 is NOT interchangeable with gfx1200 on this card (the fork's
|
||||
# docs/BUILD-AMD-ARCHITECTURES.md: a gfx1200 build links, loads and then
|
||||
# segfaults), and the build verifies the emitted code objects afterwards.
|
||||
#
|
||||
# Model: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT (Q8_0_ROCMFPX_AGENT,
|
||||
# ftype 115, arch qwen35 MoE), 26.28 GiB main file, plus a separate
|
||||
# MTP draft head and an mmproj vision tower. 8.39 bpw; the AGENT routing
|
||||
# keeps 13 of the 17 attention-output projections at full Q8_0, which is
|
||||
# what makes the MTP draft land ~0.95 of its guesses. A stock llama.cpp
|
||||
# cannot load this GGUF at all - Q8_0_ROCMFPX exists only in this fork.
|
||||
#
|
||||
# Parallelism: --split-mode layer across both cards (operator call
|
||||
# 2026-09-15). Layers are distributed in order and activations hop PCIe at
|
||||
# the boundary; measured llama-bench: layer 1991/18.8 tok/s (pp4096/tg128)
|
||||
# vs tensor 272/26.3 and single-card 1161/19.4. Tensor wins decode +40 %
|
||||
# but its prefill collapses 7x on this build (no RCCL: "AllReduce init
|
||||
# failed; falling back to meta-backend butterfly"), so layer split wins the
|
||||
# prefill-heavy agentic workload.
|
||||
#
|
||||
# Tunables come from frank-medium.env unless FRANK_MEDIUM_ENV_FILE
|
||||
# overrides that path.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_MEDIUM_ENV_FILE:-$SCRIPT_DIR/frank-medium.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- defaults (used when the env file does not set them) -----------------
|
||||
: "${LLAMA_BIN:=/home/dank/GitPackages/charlie-ROCmFPX/build-rdna4-rocm714/bin/llama-server}"
|
||||
: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT}"
|
||||
: "${MODEL_FILE:=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf}"
|
||||
: "${DRAFT_MODEL_FILE:=mtp-Qwen3.8-27B-Q4_0.gguf}"
|
||||
: "${ALIAS:=frank-medium}"
|
||||
: "${BIND_ADDR:=0.0.0.0}"
|
||||
: "${LISTEN_PORT:=7770}"
|
||||
|
||||
# Devices: ROCm0/ROCm1 are the two R9700s; the Raphael iGPU enumerates as
|
||||
# ROCm2 (gfx1036) and is hidden from HIP so nothing can drift onto it.
|
||||
: "${HIP_VISIBLE_DEVICES:=0,1}"
|
||||
: "${DEVICES:=ROCm0,ROCm1}"
|
||||
: "${SPLIT_MODE:=tensor}"
|
||||
: "${TENSOR_SPLIT:=}"
|
||||
: "${NGL:=999}"
|
||||
: "${FIT:=off}"
|
||||
: "${FLASH_ATTN:=on}"
|
||||
|
||||
: "${CTX_SIZE:=131072}"
|
||||
: "${PARALLEL:=1}"
|
||||
: "${BATCH_SIZE:=2048}"
|
||||
: "${UBATCH_SIZE:=512}"
|
||||
: "${NUM_THREADS:=8}"
|
||||
: "${THREAD_BATCH:=8}"
|
||||
: "${CACHE_TYPE_K:=f16}"
|
||||
: "${CACHE_TYPE_V:=f16}"
|
||||
: "${CTX_CHECKPOINTS:=8}"
|
||||
: "${CACHE_RAM:=8192}"
|
||||
|
||||
# Sampling: the checkpoint's instruct table.
|
||||
: "${TEMPERATURE:=0.7}"
|
||||
: "${TOP_P:=0.8}"
|
||||
: "${TOP_K:=20}"
|
||||
: "${MIN_P:=0.0}"
|
||||
: "${PRESENCE_PENALTY:=1.5}"
|
||||
|
||||
# Reasoning: the model is an agent checkpoint; keep the trace and project it
|
||||
# into the API's reasoning_content field rather than literal thinking tags.
|
||||
: "${REASONING:=on}"
|
||||
: "${REASONING_FORMAT:=deepseek}"
|
||||
: "${REASONING_BUDGET:=-1}"
|
||||
|
||||
# Chat template: empty means "use the template embedded in the GGUF".
|
||||
: "${CHAT_TEMPLATE_FILE:=}"
|
||||
: "${CHAT_TEMPLATE_KWARGS:={\"enable_thinking\":true,\"preserve_thinking\":true}}"
|
||||
|
||||
# MTP draft head (separate GGUF, --spec-type draft-mtp). SPEC_DRAFT_DEVICE
|
||||
# must name exactly one device: under tensor mode an unsplit draft joins the
|
||||
# meta-device KV allocation and aborts while building the KV cache
|
||||
# (GGML_ASSERT(buffer) in ggml-backend.cpp, 2026-09-15).
|
||||
: "${SPEC_TYPE:=draft-mtp}"
|
||||
: "${SPEC_DRAFT_N_MAX:=6}"
|
||||
: "${SPEC_DRAFT_N_MIN:=0}"
|
||||
: "${SPEC_DRAFT_P_MIN:=0.6}"
|
||||
: "${SPEC_DRAFT_P_SPLIT:=0.10}"
|
||||
: "${SPEC_DRAFT_NGL:=999}"
|
||||
: "${SPEC_DRAFT_DEVICE:=}"
|
||||
: "${SPEC_DRAFT_TYPE_K:=f16}"
|
||||
: "${SPEC_DRAFT_TYPE_V:=f16}"
|
||||
: "${SPEC_DRAFT_POLL:=1}"
|
||||
: "${SPEC_DRAFT_POLL_BATCH:=1}"
|
||||
|
||||
# Text only: the server must not try to autoload the vision tower. Vision
|
||||
# stays on frank-dragoon (Dankclaw), which is where the fleet routes images.
|
||||
NO_MMPROJ="${NO_MMPROJ:-1}"
|
||||
|
||||
export HIP_VISIBLE_DEVICES
|
||||
|
||||
MODEL="$MODEL_DIR/$MODEL_FILE"
|
||||
if [[ ! -x "$LLAMA_BIN" ]]; then
|
||||
echo "missing executable runtime: $LLAMA_BIN" >&2
|
||||
echo "build it with: cd $(dirname "$(dirname "$LLAMA_BIN")")/.. && env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -r "$MODEL" ]]; then
|
||||
echo "missing or unreadable required artifact: $MODEL" >&2
|
||||
echo "fetch it with: bash $SCRIPT_DIR/download-q8agent.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$CHAT_TEMPLATE_FILE" && ! -r "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
echo "missing or unreadable chat template: $CHAT_TEMPLATE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- speculative decoding (MTP draft head) --------------------------------
|
||||
SPEC_ARGS=()
|
||||
if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then
|
||||
SPEC_ARGS=(
|
||||
--spec-type "$SPEC_TYPE"
|
||||
--spec-draft-n-max "$SPEC_DRAFT_N_MAX"
|
||||
--spec-draft-n-min "$SPEC_DRAFT_N_MIN"
|
||||
--spec-draft-p-min "$SPEC_DRAFT_P_MIN"
|
||||
--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT"
|
||||
)
|
||||
if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then
|
||||
DRAFT_MODEL="$MODEL_DIR/$DRAFT_MODEL_FILE"
|
||||
if [[ ! -r "$DRAFT_MODEL" ]]; then
|
||||
echo "missing or unreadable draft model: $DRAFT_MODEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
SPEC_ARGS+=(
|
||||
--spec-draft-model "$DRAFT_MODEL"
|
||||
--spec-draft-ngl "$SPEC_DRAFT_NGL"
|
||||
--spec-draft-type-k "$SPEC_DRAFT_TYPE_K"
|
||||
--spec-draft-type-v "$SPEC_DRAFT_TYPE_V"
|
||||
--spec-draft-poll "$SPEC_DRAFT_POLL"
|
||||
--spec-draft-poll-batch "$SPEC_DRAFT_POLL_BATCH"
|
||||
)
|
||||
if [[ -n "$SPEC_DRAFT_DEVICE" ]]; then
|
||||
SPEC_ARGS+=(--spec-draft-device "$SPEC_DRAFT_DEVICE")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- template + placement -------------------------------------------------
|
||||
TEMPLATE_ARGS=()
|
||||
if [[ -n "$CHAT_TEMPLATE_FILE" ]]; then
|
||||
TEMPLATE_ARGS+=(--chat-template-file "$CHAT_TEMPLATE_FILE")
|
||||
TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS")
|
||||
fi
|
||||
|
||||
PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE")
|
||||
if [[ -n "$TENSOR_SPLIT" ]]; then
|
||||
PLACEMENT_ARGS+=(--tensor-split "$TENSOR_SPLIT")
|
||||
fi
|
||||
|
||||
MMPROJ_ARGS=()
|
||||
if [[ "$NO_MMPROJ" == "1" ]]; then
|
||||
MMPROJ_ARGS=(--no-mmproj)
|
||||
fi
|
||||
|
||||
exec "$LLAMA_BIN" \
|
||||
--model "$MODEL" \
|
||||
--alias "$ALIAS" \
|
||||
--host "$BIND_ADDR" \
|
||||
--port "$LISTEN_PORT" \
|
||||
--jinja \
|
||||
--gpu-layers "$NGL" \
|
||||
--fit "$FIT" \
|
||||
--parallel "$PARALLEL" \
|
||||
--ctx-size "$CTX_SIZE" \
|
||||
--batch-size "$BATCH_SIZE" \
|
||||
--ubatch-size "$UBATCH_SIZE" \
|
||||
--flash-attn "$FLASH_ATTN" \
|
||||
--cache-type-k "$CACHE_TYPE_K" \
|
||||
--cache-type-v "$CACHE_TYPE_V" \
|
||||
--threads "$NUM_THREADS" \
|
||||
--threads-batch "$THREAD_BATCH" \
|
||||
--ctx-checkpoints "$CTX_CHECKPOINTS" \
|
||||
--cache-ram "$CACHE_RAM" \
|
||||
--cache-prompt \
|
||||
--metrics \
|
||||
--temp "$TEMPERATURE" \
|
||||
--top-p "$TOP_P" \
|
||||
--top-k "$TOP_K" \
|
||||
--min-p "$MIN_P" \
|
||||
--presence-penalty "$PRESENCE_PENALTY" \
|
||||
--reasoning "$REASONING" \
|
||||
--reasoning-format "$REASONING_FORMAT" \
|
||||
--reasoning-budget "$REASONING_BUDGET" \
|
||||
"${TEMPLATE_ARGS[@]}" \
|
||||
"${PLACEMENT_ARGS[@]}" \
|
||||
"${MMPROJ_ARGS[@]}" \
|
||||
"${SPEC_ARGS[@]}"
|
||||
@@ -1,27 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# apply-protected-cast.sh — RUNS ON DANKSERVER. Rewrite the recipe's
|
||||
# PROTECTED-BEGIN/END section from Swift's OWN calibration grams: run
|
||||
# apply-protected-cast.sh — RUNS ON DANKSERVER. Rewrite a recipe's
|
||||
# PROTECTED-BEGIN/END section from the campaign's OWN calibration grams: run
|
||||
# derive-protected-experts.py over the calib dir, splice its cast lines into
|
||||
# the marked section of the recipe in place, keep the pre-edit recipe in the
|
||||
# campaign trash. Refuses to run twice (marker text says derived).
|
||||
# campaign trash. Refuses to run twice (marker text says derived), and refuses
|
||||
# when a requested target count was not reached. Run it on a COPY of the
|
||||
# template so the committed recipe stays the clean, reviewable baseline.
|
||||
set -euo pipefail
|
||||
CALIB=${1:-/home/dank/corpora/calib}
|
||||
RECIPE=${2:-/home/dank/serverAppConfigs-swift15/scripts/recipes/qwen4exp-swift15-w4dn8-i8-hc8m.recipe}
|
||||
PY=/home/dank/venvs/radcorpus/bin/python
|
||||
DERIVE=/home/dank/serverAppConfigs-swift15/scripts/derive-protected-experts.py
|
||||
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CALIB=${1:-/home/dank/campaign/qwen38-w5nl64/calib}
|
||||
RECIPE=${2:-$SELF_DIR/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe}
|
||||
TARGET_COUNT=${3:-0}
|
||||
PY=${PY:-/home/dank/venvs/radcorpus/bin/python}
|
||||
DERIVE=${DERIVE:-$SELF_DIR/derive-protected-experts.py}
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
# OUT and TRASH_DIR are overridable so the seam can drive the whole script in a
|
||||
# scratch directory; on the box they are the campaign's usual places.
|
||||
OUT=${OUT:-/tmp/protected-cast-$TS.txt}
|
||||
TRASH_DIR=${TRASH_DIR:-/home/dank/.AGENTTRASH}
|
||||
|
||||
grep -q "derive-protected-experts.py rewrote" "$RECIPE" && {
|
||||
echo "recipe already carries derived cast lines; refusing to re-derive" >&2; exit 1; }
|
||||
grep -q "PROTECTED-BEGIN" "$RECIPE" || { echo "no PROTECTED markers in $RECIPE" >&2; exit 1; }
|
||||
|
||||
"$PY" "$DERIVE" "$CALIB" > "/tmp/protected-cast-$TS.txt"
|
||||
N=$(grep -c 'cast dtype=bf16' "/tmp/protected-cast-$TS.txt")
|
||||
[ "$N" -ge 2 ] || { echo "derive produced $N cast lines; refusing" >&2; exit 1; }
|
||||
echo "derived $N protected experts -> /tmp/protected-cast-$TS.txt"
|
||||
ARGS=("$DERIVE" "$CALIB")
|
||||
if [ "$TARGET_COUNT" -gt 0 ]; then
|
||||
ARGS+=(--target-count "$TARGET_COUNT")
|
||||
fi
|
||||
"$PY" "${ARGS[@]}" > "$OUT"
|
||||
N=$(grep -c 'cast dtype=bf16' "$OUT")
|
||||
[ "$N" -ge 2 ] || { echo "derive produced $N cast lines; refusing (see $OUT)" >&2; exit 1; }
|
||||
if [ "$TARGET_COUNT" -gt 0 ] && [ "$N" -lt "$TARGET_COUNT" ]; then
|
||||
echo "derive reached $N protected experts, target was $TARGET_COUNT; refusing to convert" >&2
|
||||
echo " (the bar sweep bottomed out: the recording is too thin or the target too high)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "derived $N protected experts -> $OUT"
|
||||
|
||||
cp -f "$RECIPE" "/home/dank/.AGENTTRASH/$TS-qwen4exp-swift15-w4dn8-i8-hc8m.recipe"
|
||||
"$PY" - "$RECIPE" "/tmp/protected-cast-$TS.txt" <<'PYEOF'
|
||||
mkdir -p "$TRASH_DIR"
|
||||
cp -f "$RECIPE" "$TRASH_DIR/$TS-$(basename "$RECIPE")"
|
||||
"$PY" - "$RECIPE" "$OUT" <<'PYEOF'
|
||||
import sys
|
||||
recipe, castfile = sys.argv[1], sys.argv[2]
|
||||
lines = open(recipe).read().splitlines()
|
||||
@@ -35,7 +53,7 @@ for l in lines:
|
||||
elif 'PROTECTED-END' in l:
|
||||
insec = False
|
||||
out.append(l)
|
||||
out.append('# (derive-protected-experts.py rewrote this section from Swift\'s own grams)')
|
||||
out.append('# (derive-protected-experts.py rewrote this section from the campaign grams)')
|
||||
elif not insec:
|
||||
out.append(l)
|
||||
open(recipe, 'w').write('\n'.join(out) + '\n')
|
||||
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repair the Grafana OAuth2 relying-party SSO on Dankclaw's authentik.
|
||||
#
|
||||
# grafana.franksdank.cloud logs in through authentik as an OAuth2 relying party.
|
||||
# Two DB-side facts (the provider was created by hand via `ak shell`, never
|
||||
# repo-managed) silently broke that login, and BOTH had previously been
|
||||
# mis-recorded here as a single "the property-mapping evaluator is broken, so
|
||||
# userinfo returns only `sub`" myth. They are neither, and the evaluator is fine:
|
||||
#
|
||||
# 1. provider.authorization_flow was NULL. The authorize view builds a flow
|
||||
# planner from it and crashes with
|
||||
# `AttributeError: 'NoneType' object has no attribute 'slug'` — an HTTP 500
|
||||
# "Server Error — Powered by authentik", before any claim is touched.
|
||||
# Fix: point it at default-provider-authorization-implicit-consent, the
|
||||
# flow every other provider in this instance already uses.
|
||||
# 2. The `grafana-email-scope` ScopeMapping's expression was the bare string
|
||||
# `user.email`. authentik merges a scope mapping's result into the token as
|
||||
# a MAPPING: a returned dict adds its keys as claims; a returned string is
|
||||
# keyed under the mapping's NAME ("grafana-email-scope"), so no `email`
|
||||
# claim is ever emitted. Grafana, finding no email, falls back to
|
||||
# GET <api_url>/emails, authentik 404s that path, and the login dies with
|
||||
# "Login failed: InternalError". Fix: return
|
||||
# {"email": user.email, "email_verified": True} — the shape the stock
|
||||
# OpenID 'email' mapping uses. The dict-valued `grafana-profile-scope`
|
||||
# mapping has always worked, which is the proof the evaluator is fine.
|
||||
#
|
||||
# This is authentik DB state. It is NOT in any deployable file: a blueprint
|
||||
# re-import, or an ak-shell re-creation of the grafana provider, reverts BOTH
|
||||
# facts and the symptom returns verbatim. Re-run this after any such rebuild.
|
||||
# Every step is idempotent.
|
||||
#
|
||||
# Grafana's own half — SCOPES="openid email profile", a standard userinfo API_URL
|
||||
# with no `?api=1`, and ALLOW_SIGN_UP=true — lives in
|
||||
# dankclaw/observability/quadlet/grafana.container and deploys normally.
|
||||
#
|
||||
# Usage: authentik-grafana-sso-fix.sh (override host: AUTHENTIK_HOST=…)
|
||||
set -euo pipefail
|
||||
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||
|
||||
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<'PYEOF'
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
|
||||
p = OAuth2Provider.objects.filter(name="grafana").first()
|
||||
if not p:
|
||||
raise SystemExit("no OAuth2Provider named 'grafana'")
|
||||
|
||||
# 1. The authorization flow every other provider uses.
|
||||
want = Flow.objects.filter(slug="default-provider-authorization-implicit-consent").first()
|
||||
if not want:
|
||||
raise SystemExit("default-provider-authorization-implicit-consent flow is missing")
|
||||
if p.authorization_flow_id != want.pk:
|
||||
p.authorization_flow = want
|
||||
p.save()
|
||||
print(f"set grafana.authorization_flow -> {want.slug}")
|
||||
else:
|
||||
print(f"grafana.authorization_flow already {want.slug}")
|
||||
|
||||
# 2. The email scope mapping must RETURN A DICT, not a bare string.
|
||||
m = p.property_mappings.filter(name="grafana-email-scope").first()
|
||||
if not m:
|
||||
raise SystemExit("grafana-email-scope mapping is missing")
|
||||
fixed = 'return {"email": user.email, "email_verified": True}'
|
||||
if m.expression.strip() != fixed:
|
||||
m.expression = fixed
|
||||
m.save()
|
||||
print("repaired grafana-email-scope -> returns {email, email_verified}")
|
||||
else:
|
||||
print("grafana-email-scope expression already correct")
|
||||
|
||||
# 3. The id_token must carry the scope claims; Grafana reads identity from it.
|
||||
if not p.include_claims_in_id_token:
|
||||
p.include_claims_in_id_token = True
|
||||
p.save()
|
||||
print("enabled include_claims_in_id_token")
|
||||
else:
|
||||
print("include_claims_in_id_token already on")
|
||||
PYEOF
|
||||
|
||||
echo "Done. Verify: sign in at https://grafana.franksdank.cloud via Authentik;"
|
||||
echo "the round trip must land on / and /api/user must answer 200 with an email."
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Set the authentik login-session TTL on Dankclaw.
|
||||
#
|
||||
# The login-session lifetime is governed by the `default-authentication-login`
|
||||
# stage's `session_duration`, NOT a global setting and NOT SESSION_COOKIE_AGE
|
||||
# (that is the pre-login session, sessions.unauthenticated_age). Authentik does
|
||||
# not expose SESSION_SAVE_EVERY_REQUEST at all, so the session is an absolute
|
||||
# window from login, not a sliding one.
|
||||
#
|
||||
# The field is a STRING in authentik's own idiom ("days=7", "seconds=30",
|
||||
# "hours=12") — not a Python timedelta. Writing str(timedelta(days=7)) stores
|
||||
# "7 days, 0:00:00", which authentik's timedelta_from_string parser REJECTS
|
||||
# (ValueError), and login then crashes computing the session expiry. Always pass
|
||||
# the "key=value" form; this script validates it before writing.
|
||||
#
|
||||
# Existing sessions keep their old expiry; the new TTL applies from the next
|
||||
# login. This is DB state — it is NOT repo-managed and a blueprint re-import or
|
||||
# rebuild-from-scratch reverts it. Run this again after such a rebuild.
|
||||
#
|
||||
# Usage: authentik-set-login-session-duration.sh ["days=7"] (default days=7)
|
||||
set -euo pipefail
|
||||
|
||||
DUR="${1:-days=7}"
|
||||
STAGE="default-authentication-login"
|
||||
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||
|
||||
# Refuse anything that is not the safe key=value duration idiom.
|
||||
if ! printf '%s' "$DUR" | grep -Eq '^[A-Za-z]+=[0-9]+([ ]+[A-Za-z]+=[0-9]+)*$'; then
|
||||
echo "invalid duration '$DUR' — use authentik form, e.g. 'days=7' or 'seconds=30'" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
||||
from django.apps import apps
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
dur = "$DUR"
|
||||
timedelta_from_string(dur) # validate the form before touching the row
|
||||
ULS = apps.get_model("authentik_stages_user_login", "UserLoginStage")
|
||||
s = ULS.objects.get(name="$STAGE")
|
||||
s.session_duration = dur
|
||||
s.save(update_fields=["session_duration"])
|
||||
back = ULS.objects.get(name="$STAGE").session_duration
|
||||
assert back == dur, "persisted value mismatch: %r" % back
|
||||
print("session_duration set to", repr(back), "->", timedelta_from_string(back))
|
||||
PYEOF
|
||||
|
||||
echo "Login-session TTL for '$STAGE' is now $DUR (applies on next login)."
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-qwen38-bf16-rad.sh — RUNS ON DANKSERVER. rad-convert the vendor BF16
|
||||
# checkpoint /home/dank/models/Qwen3.8-Flash-Next into
|
||||
# /home/dank/models/rad/qwen38-bf16.rad with NO recipe and NO --reuse: a weight
|
||||
# no rule matches keeps the checkpoint's encoding, so every tensor stays the
|
||||
# bf16 it is in the safetensors and the container is the checkpoint's own
|
||||
# numbers behind a .rad header.
|
||||
#
|
||||
# This file is the campaign's measuring stick, twice over: scripts/
|
||||
# run-qwen38-bf16.sh kldref records the KLD reference logits from it (every
|
||||
# arm's --kld-ref is scored against that record) and the same container serves
|
||||
# with the calibration tap to write the Grams that derive the protected-expert
|
||||
# list and feed GPTQ. A reference that is not the vendor checkpoint, byte for
|
||||
# byte in bf16, silently moves every gate in the campaign — hence no recipe, no
|
||||
# --reuse, and the four refusal gates below.
|
||||
#
|
||||
# Why a container at all: a checkpoint dir has no mapped/file tiers, and a
|
||||
# weight only a host kernel reads (the 47.7 GiB PLE n-gram table) cannot be
|
||||
# served from one; rad-convert also bakes the tokenizer. The convert runs on
|
||||
# the HOST backend — no --device /dev/kfd, no /dev/dri — so declare resolves
|
||||
# kernels on CPU and production serving is untouched for the whole run (the
|
||||
# precedent that proved it: scripts/convert-swift15-w4dn8.sh).
|
||||
#
|
||||
# Gates, every one a refusal with exit 2 and a named reason, BEFORE anything is
|
||||
# written, cheapest first — the verifier runs last of them because its Hub gate
|
||||
# hashes 343 GiB and must not be paid for by a run that was going to refuse on
|
||||
# the output file anyway.
|
||||
# checkpoint the tree must be on disk; an absent dir names the command that
|
||||
# makes one (scripts/download-qwen38-bf16.sh --fetch) and stops.
|
||||
# free space the convert writes ~343,323 MiB next to the ~343,323 MiB
|
||||
# checkpoint; QWEN38_MIN_FREE_MIB (default 350000 = that figure
|
||||
# plus 6.5 GiB of slack) is the floor. The volume that cannot
|
||||
# hold both is discovered at minute 2, not hour 5.
|
||||
# output the campaign converts the reference ONCE. An existing
|
||||
# qwen38-bf16.rad is refused; --force retires it to
|
||||
# ~/.AGENTTRASH/<utc>-qwen38-bf16.rad (trash law: nothing is
|
||||
# deleted, and the move is a rename on the same volume).
|
||||
# verified scripts/download-qwen38-bf16.sh's verify mode must pass. A
|
||||
# failed verify exits 1, not 2: the tree being the wrong payload
|
||||
# is a result, a missing environment is a refusal.
|
||||
# plan rad-convert --plan-only -v first — the oracle for "does this
|
||||
# shape boot". A refused plan costs seconds and saves a convert.
|
||||
#
|
||||
# usage: scripts/build-qwen38-bf16-rad.sh [--dry-run] [--plan-only] [--force]
|
||||
# --dry-run print the argv it would exec (podman wrapping rad-convert),
|
||||
# one shell-quoted argument a line, and exit 0. No gate runs, no
|
||||
# container starts, nothing is written — the offline test seam.
|
||||
# It does resolve the checkpoint and output paths through the
|
||||
# /models mount, because an argv that carries host paths would
|
||||
# not run either.
|
||||
# --plan-only run the plan and every gate except the disk and output ones —
|
||||
# those two protect bytes the plan never writes, and this is the
|
||||
# probe to run when the disk gate itself is what refuses
|
||||
# --force retire an existing output container to the trash dir first
|
||||
#
|
||||
# knobs: FRANK_QWEN38_MODELS the host dir mounted at /models (default
|
||||
# /home/dank/models); FRANK_QWEN38_CKPT (default $MODELS/
|
||||
# Qwen3.8-Flash-Next, the same knob the verifier reads);
|
||||
# FRANK_QWEN38_BF16_OUT (default $MODELS/rad/qwen38-bf16.rad);
|
||||
# QWEN38_MIN_FREE_MIB; QWEN38_TRASH (default $HOME/.AGENTTRASH). The
|
||||
# verifier's own census knobs (FRANK_QWEN38_SHARDS / _BYTES / _REPO)
|
||||
# reach it through the environment untouched.
|
||||
# exit: 0 converted (or planned), 1 the checkpoint failed verification,
|
||||
# 2 refused before anything was written
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# ONE owner of the engine pin: the digest frank-archon serves on. The reference
|
||||
# must be built by the SAME engine build that scores it, or a KLD delta is
|
||||
# partly a version delta.
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/frank-archon.env"
|
||||
IMAGE="${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}"
|
||||
|
||||
MODELS="${FRANK_QWEN38_MODELS:-/home/dank/models}"
|
||||
CKPT="${FRANK_QWEN38_CKPT:-$MODELS/Qwen3.8-Flash-Next}"
|
||||
OUT="${FRANK_QWEN38_BF16_OUT:-$MODELS/rad/qwen38-bf16.rad}"
|
||||
TRASH="${QWEN38_TRASH:-$HOME/.AGENTTRASH}"
|
||||
MIN_FREE_MIB="${QWEN38_MIN_FREE_MIB:-350000}"
|
||||
LOG="/home/dank/logs/qwen38_bf16_convert.log"
|
||||
|
||||
DRY=0
|
||||
PLAN_ONLY=0
|
||||
FORCE=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY=1 ;;
|
||||
--plan-only) PLAN_ONLY=1 ;;
|
||||
--force) FORCE=1 ;;
|
||||
*) echo "usage: ${0##*/} [--dry-run] [--plan-only] [--force]" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# The convert sees the tree only through the /models mount, so both paths must
|
||||
# be under it; the container-side spelling is what goes into the argv.
|
||||
case "$CKPT" in
|
||||
"$MODELS"/*) CKPT_IN="/models/${CKPT#"$MODELS"/}" ;;
|
||||
*) echo "REFUSED: checkpoint $CKPT is not under the mounted models dir $MODELS — the convert cannot see it" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
case "$OUT" in
|
||||
"$MODELS"/*) OUT_IN="/models/${OUT#"$MODELS"/}" ;;
|
||||
*) echo "REFUSED: output $OUT is not under the mounted models dir $MODELS — rad-convert could not write it" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
|
||||
# Declare with NO recipe line — that is what keeps every tensor bf16 — and at the
|
||||
# bound this container will actually be served at. Both flags come off the pinned
|
||||
# image's own help: --kv-cache-dtype at convert "only picks the kernels it
|
||||
# checks" and the container is identical either way, so naming fp8 is free
|
||||
# insurance — if the fp8 KV kernels the fleet serves would not resolve, the
|
||||
# convert refuses here instead of the boot failing on a drained window.
|
||||
# --max-ctx is the bound declare is run at, whose default is the checkpoint's
|
||||
# training context (262,144 here, whose fp8 cache is 258.8 GiB against a 192 GiB
|
||||
# machine, so the default is not an option). 32,768 is set by the corpus, not by
|
||||
# taste: kld.jsonl's longest record is 38,263 characters — ~9,600 tokens at the
|
||||
# pessimistic chars/4, ~7,700 at the chars/4.97 this repo measured on the K3
|
||||
# corpus — so the window the rig boots at clears every record 2.6x over and the
|
||||
# reference boot cannot die on a sequence that will not fit. The rig's window is
|
||||
# one knob (FRANK_QWEN38_MAX_MODEL_LEN in scripts/run-qwen38-bf16.sh, default
|
||||
# 32,768) and that script refuses a corpus its window cannot hold, so raising one
|
||||
# without the other is caught before the drained window, not inside it. The
|
||||
# payload containers the gate scores declare at the training context because
|
||||
# production must hold the 200k shape; a declared bound is a declare-time check,
|
||||
# not a weight change, so the reference and the payloads carry the same numbers
|
||||
# whatever each declared at — which is why one can be measured against the other.
|
||||
CONVERT_ARGS=("$CKPT_IN" -o "$OUT_IN" --max-ctx 32768 --kv-cache-dtype fp8)
|
||||
PODMAN=(podman run --rm --entrypoint rad-convert -v "$MODELS:/models")
|
||||
PLAN_CMD=("${PODMAN[@]}" --name qwen38-bf16-plan "$IMAGE" "${CONVERT_ARGS[@]}" --plan-only -v)
|
||||
CONV_CMD=("${PODMAN[@]}" --name qwen38-bf16-convert "$IMAGE" "${CONVERT_ARGS[@]}")
|
||||
|
||||
if [ "$DRY" = "1" ]; then
|
||||
echo "dry run: no gate checked, no container started, nothing written"
|
||||
echo "# $CKPT -> $OUT (the convert sees them as $CKPT_IN and $OUT_IN)"
|
||||
echo "# plan argv:"
|
||||
printf '%q\n' "${PLAN_CMD[@]}"
|
||||
echo "# convert argv:"
|
||||
printf '%q\n' "${CONV_CMD[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- gate: the checkpoint is on disk ---------------------------------------
|
||||
if [ ! -d "$CKPT" ]; then
|
||||
echo "REFUSED: no checkpoint at $CKPT — fetch and verify it first: scripts/download-qwen38-bf16.sh --fetch" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---- gate: peak disk --------------------------------------------------------
|
||||
# The deepest existing ancestor is the right thing to ask: a fresh
|
||||
# /home/dank/models/rad has no free space of its own to report.
|
||||
OUT_DIR="$(dirname "$OUT")"
|
||||
PROBE="$OUT_DIR"
|
||||
while [ ! -d "$PROBE" ] && [ "$PROBE" != "/" ]; do PROBE="$(dirname "$PROBE")"; done
|
||||
FREE_MIB="$(df --output=avail -m "$PROBE" | tail -1 | tr -d ' ')"
|
||||
if ! [[ "$FREE_MIB" =~ ^[0-9]+$ ]]; then
|
||||
echo "REFUSED: cannot read the free space under $PROBE (df said: $FREE_MIB)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$PLAN_ONLY" != "1" ] && [ "$FREE_MIB" -lt "$MIN_FREE_MIB" ]; then
|
||||
echo "REFUSED peak-disk: $FREE_MIB MiB free under $PROBE, $MIN_FREE_MIB MiB wanted (QWEN38_MIN_FREE_MIB) — the bf16 container rewrites the checkpoint's own 343,323 MiB of tensors beside it" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---- gate: one conversion ---------------------------------------------------
|
||||
if [ -e "$OUT" ]; then
|
||||
if [ "$PLAN_ONLY" = "1" ]; then
|
||||
echo "note: $OUT already exists; --plan-only writes nothing, so it stays"
|
||||
elif [ "$FORCE" = "1" ]; then
|
||||
mkdir -p "$TRASH"
|
||||
DEST="$TRASH/$(date -u +%Y%m%dT%H%M%SZ)-$(basename "$OUT")"
|
||||
mv "$OUT" "$DEST"
|
||||
echo "TRASHED: retired the existing reference container $OUT -> $DEST"
|
||||
else
|
||||
echo "REFUSED: $OUT already exists — the campaign converts the reference once; re-run with --force to retire it to $TRASH first" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- gate: the tree really is the checkpoint (the full verify) -------------
|
||||
echo "== verifying $CKPT (scripts/download-qwen38-bf16.sh, full gates)"
|
||||
rc=0
|
||||
bash "$SCRIPT_DIR/download-qwen38-bf16.sh" --dir "$CKPT" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
echo "REFUSED: the checkpoint verifier refused the environment (rc=2); nothing was converted" >&2
|
||||
exit 2
|
||||
fi
|
||||
echo "REFUSED: $CKPT failed verification (rc=$rc) — a half tree converts to a half reference" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- plan, then convert ------------------------------------------------------
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
# rad-convert opens -o during declare as well, so the output directory exists
|
||||
# before the plan; that empty directory is all this creates ahead of it.
|
||||
mkdir -p "$OUT_DIR"
|
||||
echo "== rad-convert --plan-only -v (declare + plan; writes nothing)"
|
||||
"${PLAN_CMD[@]}" 2>&1 | tee -a "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "REFUSED plan: rad-convert --plan-only exited rc=$rc — the engine refuses this encoding/shape, no container was written (log: $LOG)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$PLAN_ONLY" = "1" ]; then
|
||||
echo "PLAN_OK: $CKPT_IN declares for $OUT_IN; no container written (log: $LOG)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "== convert: $CKPT -> $OUT ($(date -Is))"
|
||||
"${CONV_CMD[@]}" 2>&1 | tee -a "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "rad-convert rc=$rc; log: $LOG" >&2
|
||||
exit "$rc"
|
||||
fi
|
||||
MIB=$(( $(stat -c %s "$OUT") / 1048576 ))
|
||||
echo "BUILD_DONE: $OUT is ${MIB} MiB (log: $LOG)"
|
||||
echo "NEXT: scripts/run-qwen38-bf16.sh kldref records the reference logits from it"
|
||||
+149
-18
@@ -22,6 +22,17 @@ Mechanics notes:
|
||||
prompts do not bias the Hessians (the corpus is drawn round-robin over
|
||||
source classes, so loss is class-neutral as long as failures are rare;
|
||||
a systematic 4xx is loud in the log and must be fixed, not ignored).
|
||||
- a round that seals no tap bytes is NORMAL and continues: RADIANCE_CALIB_TOKENS
|
||||
is a pooled floor and this campaign's corpus is 10.03M tokens against a 12M
|
||||
floor, so round 1 seals nothing by construction. A tap that LOSES bytes fails
|
||||
the run, and a run that never sees the engine's completion line exits non-zero.
|
||||
The first version failed on a flat round, which aborted exactly one round too
|
||||
early (2026-10-29).
|
||||
- before any prompt, the tap must carry the rig's own marker
|
||||
(`--tap-marker`, written by run-qwen38-bf16.sh calib) naming THIS
|
||||
container, and that container must be running. Measured 2026-10-29:
|
||||
with neither check, four rounds of 10M tokens went into restored
|
||||
production after the rig's boot had died, and recorded nothing.
|
||||
|
||||
Usage: calib-push.py [--port 7770] [--parallel 64] [--container arbench]
|
||||
[--corpus /home/dank/corpora/out/calib.jsonl] [--max-rounds 4]
|
||||
@@ -29,11 +40,18 @@ Usage: calib-push.py [--port 7770] [--parallel 64] [--container arbench]
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
|
||||
if _HERE not in sys.path:
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
import qwen38_common as qc # noqa: E402 - sibling module, same directory
|
||||
|
||||
DONE_MARK = "calibration wrote"
|
||||
|
||||
|
||||
@@ -49,9 +67,9 @@ def tap_done(container):
|
||||
return DONE_MARK in (out.stdout + out.stderr)
|
||||
|
||||
|
||||
def push_one(url, prompt):
|
||||
def push_one(url, prompt, model):
|
||||
body = json.dumps({
|
||||
"model": "frank-archon",
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"max_tokens": 1,
|
||||
"temperature": 0,
|
||||
@@ -68,38 +86,151 @@ def push_one(url, prompt):
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
def push_round(url, prompts, model, parallel):
|
||||
"""Push the whole corpus once; returns how many requests were accepted."""
|
||||
ok = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(parallel) as ex:
|
||||
for good in ex.map(lambda p: push_one(url, p, model), prompts):
|
||||
ok += 1 if good else 0
|
||||
return ok
|
||||
|
||||
|
||||
def tap_bytes(path):
|
||||
"""Bytes the tap has sealed so far; 0 when the directory is absent.
|
||||
|
||||
A tap whose directory is wrong, full or read-only stops writing while the
|
||||
engine keeps answering 200 — the driver then re-pushes the whole corpus for
|
||||
every round it is allowed and the loss is invisible until the hours are gone.
|
||||
"""
|
||||
total = 0
|
||||
try:
|
||||
for root, _, files in os.walk(path):
|
||||
for f in files:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(root, f))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
return 0
|
||||
return total
|
||||
|
||||
|
||||
def round_verdict(before, after, done):
|
||||
"""What one round's tap bytes mean.
|
||||
|
||||
'done' the engine logged that it sealed the tap — the run is over
|
||||
'shrink' the tap LOST bytes: reset, pruned or replaced under us
|
||||
'grew' sealed bytes this round
|
||||
'flat' sealed nothing — which is legitimate: RADIANCE_CALIB_TOKENS is a
|
||||
pooled floor, and a round that does not reach it has nothing to
|
||||
write yet (the campaign's corpus is 10.03M tokens against a 12M
|
||||
floor, so round 1 is always flat). Failing on it aborted the run
|
||||
exactly one round too early.
|
||||
"""
|
||||
if done:
|
||||
return "done"
|
||||
if after < before:
|
||||
return "shrink"
|
||||
if after == before:
|
||||
return "flat"
|
||||
return "grew"
|
||||
|
||||
|
||||
def require_rig(a):
|
||||
"""Refuse unless the rig that opened this tap is up and serving right now.
|
||||
|
||||
Three facts, in order: the runner wrote its marker into the tap dir, the
|
||||
marker names the container this pusher targets, and that container is
|
||||
running. A marker without a live container is a boot that died; a live
|
||||
container without a marker is some other engine on the port — on 2026-10-29
|
||||
it was restored production, which took four rounds of 10M tokens before the
|
||||
old driver admitted it had recorded nothing.
|
||||
"""
|
||||
marker = os.path.join(a.tap_dir, a.tap_marker)
|
||||
if not os.path.isfile(marker):
|
||||
qc.refuse(f"no rig marker at {marker}: this tap was not opened by "
|
||||
"run-qwen38-bf16.sh calib, so a push here would feed whatever "
|
||||
"answers the port and record nothing")
|
||||
try:
|
||||
lines = open(marker, encoding="utf-8", errors="replace").read().splitlines()
|
||||
except OSError as exc:
|
||||
qc.refuse(f"cannot read the rig marker {marker}: {exc}")
|
||||
meta = dict(line.split("=", 1) for line in lines if "=" in line)
|
||||
who = meta.get("container", "")
|
||||
if who != a.container:
|
||||
qc.refuse(f"the rig marker names container {who!r} and this pusher targets "
|
||||
f"{a.container!r} — wrong tap, or a stale marker")
|
||||
try:
|
||||
p = subprocess.run(["podman", "inspect", "-f", "{{.State.Running}}", a.container],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except Exception as exc:
|
||||
qc.refuse(f"cannot inspect the rig container {a.container}: {exc}")
|
||||
if p.returncode != 0 or p.stdout.strip() != "true":
|
||||
qc.refuse(f"the rig container {a.container} is not running — whatever is "
|
||||
"answering the port, it is not the calibration rig")
|
||||
|
||||
|
||||
def parse_args():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=7770)
|
||||
ap.add_argument("--parallel", type=int, default=64)
|
||||
ap.add_argument("--container", default="arbench")
|
||||
ap.add_argument("--model", default="frank-archon")
|
||||
ap.add_argument("--tap-dir", default="/home/dank/campaign/qwen38-w5nl64/calib")
|
||||
ap.add_argument("--tap-marker", default=".rig-boot",
|
||||
help="the file run-qwen38-bf16.sh calib writes into the tap dir; without it the pusher refuses, because an unmarked tap is not a rig")
|
||||
ap.add_argument("--corpus", default="/home/dank/corpora/out/calib.jsonl")
|
||||
ap.add_argument("--max-rounds", type=int, default=4)
|
||||
a = ap.parse_args()
|
||||
url = f"http://127.0.0.1:{a.port}/v1/completions"
|
||||
return ap.parse_args()
|
||||
|
||||
|
||||
def load_prompts(a) -> list[str]:
|
||||
if not os.path.isfile(a.corpus):
|
||||
qc.refuse(f"no corpus at {a.corpus}")
|
||||
prompts = []
|
||||
with open(a.corpus) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
prompts.append(json.loads(line)["prompt"])
|
||||
print(f"corpus: {len(prompts)} prompts", flush=True)
|
||||
for lineno, rec in qc.iter_jsonl(a.corpus):
|
||||
if not isinstance(rec.get("prompt"), str):
|
||||
qc.refuse(f"{a.corpus}:{lineno} has no string 'prompt' field")
|
||||
prompts.append(rec["prompt"])
|
||||
if not prompts:
|
||||
qc.refuse(f"the corpus at {a.corpus} holds no prompts")
|
||||
return prompts
|
||||
|
||||
|
||||
def main():
|
||||
a = parse_args()
|
||||
url = f"http://127.0.0.1:{a.port}/v1/completions"
|
||||
prompts = load_prompts(a)
|
||||
require_rig(a)
|
||||
chars = sum(len(p) for p in prompts)
|
||||
print(f"corpus: {len(prompts)} prompts, ~{chars // 4} tokens (chars/4)",
|
||||
flush=True)
|
||||
|
||||
for rnd in range(1, a.max_rounds + 1):
|
||||
if tap_done(a.container):
|
||||
break
|
||||
before = tap_bytes(a.tap_dir)
|
||||
t0 = time.time()
|
||||
ok = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(a.parallel) as ex:
|
||||
for good in ex.map(lambda p: push_one(url, p), prompts):
|
||||
ok += 1 if good else 0
|
||||
dt = time.time() - t0
|
||||
ok = push_round(url, prompts, a.model, a.parallel)
|
||||
dt = max(time.time() - t0, 1e-9)
|
||||
print(f"round {rnd}: {ok}/{len(prompts)} ok in {dt:.0f}s "
|
||||
f"({sum(len(p) for p in prompts)/4/dt:.0f} chars/s est)",
|
||||
flush=True)
|
||||
f"({chars / 4 / dt:.0f} tokens/s est)", flush=True)
|
||||
if ok == 0:
|
||||
print("FAIL: not one prompt was accepted; fix the engine or the argv "
|
||||
"before spending another round", flush=True)
|
||||
sys.exit(1)
|
||||
if tap_done(a.container):
|
||||
break
|
||||
after = tap_bytes(a.tap_dir)
|
||||
verdict = round_verdict(before, after, False)
|
||||
if verdict == "shrink":
|
||||
print(f"FAIL: the tap at {a.tap_dir} LOST bytes this round "
|
||||
f"({before} B before, {after} B after) — it was reset or replaced "
|
||||
"under us", flush=True)
|
||||
sys.exit(1)
|
||||
if verdict == "flat":
|
||||
print(f"round {rnd}: the tap sealed nothing yet (byte floor not reached; "
|
||||
f"{before} B) — continuing", flush=True)
|
||||
else:
|
||||
print("max rounds exhausted, tap not done", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# deployed knob has since dropped to 262144.
|
||||
set -euo pipefail
|
||||
|
||||
IMG=ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a
|
||||
IMG=ghcr.io/peonist-ai/halogen-flash-server@sha256:e35133b206da3094cc4d70c78800b26235bd9124b9aeb0e029d4a7c153b9cff0
|
||||
CUR=/home/dank/models/halogen-flashnext-v2
|
||||
NEW=/home/dank/models/halogen-v2-abliterated
|
||||
CUR_CK=qwen38-flash-next-v2-swift15-abliterated.hgn
|
||||
|
||||
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env bash
|
||||
# convert-qwen38-w5nl64.sh — RUNS ON DANKSERVER. The CPU half of the Qwen3.8-Flash-Next five-bit
|
||||
# campaign: convert the vendor BF16 checkpoint into the campaign's arms, each one the whole
|
||||
# `w5nl64a8h` rung on BOTH expert projections with the protected experts kept bf16.
|
||||
#
|
||||
# Nothing here touches a GPU: the container is started without /dev/kfd or /dev/dri, so the
|
||||
# converter runs on the host backend and the fleet can keep serving through the whole script.
|
||||
# The engine pin is the digest that serves frank-archon, so a winning arm crosses no version
|
||||
# boundary between measurement and serving.
|
||||
#
|
||||
# Three arms, because the campaign's question is which search knob pays for its hours
|
||||
# (recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe):
|
||||
#
|
||||
# cd3 the recipe as written — coordinate descent three sweeps. The control.
|
||||
# cd6 six sweeps: the same search, twice the passes.
|
||||
# ao1 act_order=1 instead of cd: GPTQ sweeps columns in activation-importance order and
|
||||
# stores them permuted. This is a SEPARATE arm, not a fourth sweep — libquant refuses
|
||||
# cd= together with act_order=1 ("cd= sweeps the columns in their stored order, and
|
||||
# act_order=1 stores them permuted; one or the other", libquant/lq_registry.cpp), so
|
||||
# the two knobs cannot be combined into a best-of-both container.
|
||||
#
|
||||
# The arms are generated from ONE template with `--quant` overrides, not three near-duplicate
|
||||
# recipes: --quant rules are inserted BEFORE the recipe file and the first rule whose glob matches
|
||||
# a weight decides it (core/format/recipe.cpp: Recipe::match), so an override cleanly replaces the
|
||||
# expert line and the other 60 lines cannot drift between arms.
|
||||
#
|
||||
# Protected experts: apply-protected-cast.sh derives the bf16 set from THIS campaign's grams
|
||||
# (--target-count 48, two per layer so each rank's 256-expert half stays even) and splices it into
|
||||
# a per-run COPY of the template; the committed recipe keeps its markers and stays reviewable.
|
||||
#
|
||||
# Gates, in the order they bite — each one is a lesson the swift15 campaign paid for:
|
||||
# inputs checkpoint (dir + index), recipe, grams, imatrix, tokenizer. A missing grams
|
||||
# directory converts UNCALIBRATED with one stderr note, which is silently worse
|
||||
# than failing here.
|
||||
# reference the 335.3 GiB BF16 reference container must be GONE before the converts start.
|
||||
# It and the checkpoint together eat the volume; its job (the KLD recording and
|
||||
# the calibration grams) is finished by then, and both survive on disk without the
|
||||
# container beside it. The FP8-native anchor is FETCHED after the converts (disk
|
||||
# sequencing), so nothing here waits on it.
|
||||
# plan --plan-only runs FIRST, per arm, and prints "about X would be written". The
|
||||
# number is the bill: free space must cover it plus the 128k-prompt overshoot
|
||||
# margin, and the finished artifact is checked against it.
|
||||
# outputs no arm overwrites anything; move the old one to trash.
|
||||
#
|
||||
# Usage: convert-qwen38-w5nl64.sh [--arm cd3|cd6|ao1]... [--dry-run] [--plan-only]
|
||||
# [--target-count N] [--keep-recipe]
|
||||
set -u -o pipefail
|
||||
|
||||
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# THE TOOLCHAIN PIN. `:latest` on 2026-10-30 is v1.14.0 (revision 4a61d7296bc5),
|
||||
# the same image the tap producer boots: a tap must be converted by the line that
|
||||
# is current, and rad-convert's `gptq:calib=<dir>` input contract is version-stable
|
||||
# (RADGRAM2 + gram.r<rank>.<stem>.bin), so nothing else about the recipe moves with
|
||||
# it. Production stays on its own pin — this is the campaign's toolchain.
|
||||
IMAGE=${IMAGE:-stilldeadcode/radiance@sha256:69add58f7de1ed2bca2b1e20a4cc75717557df3f73569a178eca5aafe8c44093}
|
||||
CKPT=${CKPT:-/home/dank/models/Qwen3.8-Flash-Next}
|
||||
CAMPAIGN=${CAMPAIGN:-/home/dank/campaign/qwen38-w5nl64}
|
||||
RECIPE=${RECIPE:-$SELF/recipes/qwen4exp-qwen38-w5nl64-bf16trunk.recipe}
|
||||
CALIB=${CALIB:-$CAMPAIGN/calib}
|
||||
IMATRIX=${IMATRIX:-$CAMPAIGN/imatrix.gguf}
|
||||
OUTDIR=${OUTDIR:-/home/dank/models/rad}
|
||||
BF16_RAD=${BF16_RAD:-$OUTDIR/qwen38-bf16.rad} # the reference container, not an arm output
|
||||
STATE=${STATE:-$CAMPAIGN} # the dir the container mounts read-only: the
|
||||
# spliced recipe and the imatrix live here
|
||||
TARGET_COUNT=${TARGET_COUNT:-48}
|
||||
MIN_GRAMS=${MIN_GRAMS:-24576} # per-expert DOWN grams: 48 recorded layers x 512
|
||||
# experts. The checkpoint has 49 MoE layers, but the
|
||||
# MTP layer is never recorded, so its 512 experts are
|
||||
# absent from the tap's output by design
|
||||
MARGIN_MIB=${MARGIN_MIB:-40960} # the long-prompt overshoot, off the plan number
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
ARMS=()
|
||||
DRY=0
|
||||
PLAN_ONLY=0
|
||||
KEEP_RECIPE=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--arm) [ "$#" -ge 2 ] || { echo "--arm needs a value (cd3|cd6|ao1)" >&2; exit 2; }
|
||||
ARMS+=("$2"); shift 2 ;;
|
||||
--dry-run) DRY=1; shift ;;
|
||||
--plan-only) PLAN_ONLY=1; shift ;;
|
||||
--target-count) [ "$#" -ge 2 ] || { echo "--target-count needs a value" >&2; exit 2; }
|
||||
TARGET_COUNT="$2"; shift 2 ;;
|
||||
--keep-recipe) KEEP_RECIPE=1; shift ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ "${#ARMS[@]}" -gt 0 ] || ARMS=(cd3 cd6 ao1)
|
||||
for a in "${ARMS[@]}"; do
|
||||
case "$a" in cd3|cd6|ao1) ;; *) echo "unknown arm: $a (cd3|cd6|ao1)" >&2; exit 2 ;; esac
|
||||
done
|
||||
|
||||
# The expert rule's option set, spelled once. --quant replaces the template's line wholesale, so
|
||||
# every option has to be repeated here; `calib=$CALIB` is expanded by the recipe reader from the
|
||||
# container environment (core/format/recipe.cpp: expand refuses an unset name rather than taking
|
||||
# it for empty).
|
||||
expert_opts='table=w5nl,group=64,scale=fp8_e4m3,scale2=f32,block2=*x*,scale2_value=0.0001220703125,transform=fwht128,rule=search,calib=$CALIB'
|
||||
arm_quant() {
|
||||
case "$1" in
|
||||
# The control is the template itself: no override, so the recipe's own line decides.
|
||||
cd3) return 0 ;;
|
||||
cd6) echo "blk.*.ffn_*_exps.*.weight=gptq:${expert_opts},cd=6" ;;
|
||||
# act_order=1 WITHOUT cd: libquant refuses the pair (lq_registry.cpp:105, "cd= sweeps the
|
||||
# columns in their stored order, and act_order=1 stores them permuted; one or the other"), so
|
||||
# this arm is the interleave knob alone, not cd=3 plus it.
|
||||
ao1) echo "blk.*.ffn_*_exps.*.weight=gptq:${expert_opts},act_order=1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- gates ------------------------------------------------------------------------------------
|
||||
[ -d "$CKPT" ] || { echo "no checkpoint at $CKPT; run scripts/download-qwen38-bf16.sh --fetch" >&2; exit 2; }
|
||||
[ -f "$CKPT/model.safetensors.index.json" ] || { echo "checkpoint at $CKPT has no safetensors index — a torn fetch" >&2; exit 2; }
|
||||
[ -f "$CKPT/tokenizer.json" ] || { echo "no tokenizer.json in $CKPT; the .rad cannot carry one without it" >&2; exit 2; }
|
||||
[ -f "$RECIPE" ] || { echo "no recipe at $RECIPE" >&2; exit 2; }
|
||||
GRAMS=$(find "$CALIB" -maxdepth 1 -name 'gram.r*.bin' 2>/dev/null | wc -l)
|
||||
[ "$GRAMS" -ge "$MIN_GRAMS" ] || {
|
||||
echo "calib dir $CALIB holds $GRAMS gram files (<$MIN_GRAMS); run $0 after run-qwen38-bf16.sh calib" >&2; exit 2; }
|
||||
[ -f "$IMATRIX" ] || { echo "no imatrix at $IMATRIX; run scripts/grams-to-imatrix.py over $CALIB" >&2; exit 2; }
|
||||
# The container sees the campaign dir at one fixed mount point, so an imatrix kept elsewhere has
|
||||
# no path inside it: copy it in (or point IMATRIX at the campaign dir) rather than let the
|
||||
# converter fail halfway through a planned arm.
|
||||
[ "$(dirname "$IMATRIX")" = "$STATE" ] || {
|
||||
echo "imatrix $IMATRIX is not in the campaign dir $STATE the container mounts; move it there" >&2; exit 2; }
|
||||
if [ -e "$BF16_RAD" ]; then
|
||||
echo "the 335.3 GiB BF16 reference container $BF16_RAD is still on disk; its recording and" \
|
||||
"the calibration grams are already taken, so move it to trash — the converts need its" \
|
||||
"space (peak-disk law)" >&2
|
||||
exit 2
|
||||
fi
|
||||
for a in "${ARMS[@]}"; do
|
||||
[ -e "$OUTDIR/qwen38-w5nl64-$a.rad" ] && {
|
||||
echo "$OUTDIR/qwen38-w5nl64-$a.rad already exists; move it to trash first" >&2; exit 2; }
|
||||
# A killed run leaves its container behind (--rm only fires when podman itself
|
||||
# exits), and the next arm then dies on "name already in use" AFTER the plan
|
||||
# phase; refuse here, where the fix is one `podman rm -f`.
|
||||
podman container exists "qwen38-convert-$a" && {
|
||||
echo "container qwen38-convert-$a still exists (a killed run?);" \
|
||||
"'podman rm -f qwen38-convert-$a' and re-run" >&2
|
||||
exit 2; }
|
||||
done
|
||||
|
||||
# --- the protected plane, spliced into a per-run copy of the template --------------------------
|
||||
mkdir -p "$STATE"
|
||||
WORK="$STATE/recipe-$TS.recipe"
|
||||
cp "$RECIPE" "$WORK"
|
||||
if [ "$DRY" = 1 ]; then
|
||||
echo "would run: $SELF/apply-protected-cast.sh $CALIB $WORK $TARGET_COUNT"
|
||||
else
|
||||
# Positional, because the tool reads its recipe and calib dir by position: passing them as
|
||||
# environment variables would be silently ignored and it would rewrite the COMMITTED
|
||||
# template in place instead of this run's copy.
|
||||
"$SELF/apply-protected-cast.sh" "$CALIB" "$WORK" "$TARGET_COUNT" \
|
||||
|| { echo "the protected-expert derivation refused; the arms would convert unprotected" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
run_convert() { # run_convert ARM [extra flags...]
|
||||
local arm="$1"; shift
|
||||
local out="$OUTDIR/qwen38-w5nl64-$arm.rad" log="$STATE/convert-$arm-$TS.log"
|
||||
local argv=(--rm --name "qwen38-convert-$arm"
|
||||
-e "CALIB=/data/calib" -e "OMP_NUM_THREADS=$(nproc)"
|
||||
-v "$(dirname "$CKPT"):/models:ro"
|
||||
-v "$CALIB:/data/calib:ro"
|
||||
-v "$STATE:/campaign:ro"
|
||||
-v "$OUTDIR:/out")
|
||||
local conv=(/models/"${CKPT##*/}" --recipe "/campaign/$(basename "$WORK")"
|
||||
--tokenizer "/models/${CKPT##*/}/tokenizer.json"
|
||||
--imatrix /campaign/"${IMATRIX##*/}"
|
||||
# No --max-ctx: the context declare then runs at the checkpoint's training context
|
||||
# (262144), which is what the shipped container did — rad-info --meta on it carries
|
||||
# no override. A container declared at a smaller bound cannot be expected to serve
|
||||
# the 200k window D2 holds, and the convert is not where to discover that.
|
||||
# --kv-cache-dtype changes nothing in the artifact; it picks the kernels the declare
|
||||
# checks, and the fleet serves fp8 KV, so an unresolvable fp8 kernel must be said no
|
||||
# to by the convert, not by a boot on a drained window.
|
||||
--kv-cache-dtype fp8 -o "/out/$(basename "$out")")
|
||||
local q; q=$(arm_quant "$arm")
|
||||
[ -n "$q" ] && conv+=(--quant "$q")
|
||||
if [ "$DRY" = 1 ]; then
|
||||
echo "podman run ${argv[*]} \$IMAGE --entrypoint /opt/radiance/bin/rad-convert ${conv[*]} $*"
|
||||
return 0
|
||||
fi
|
||||
podman run "${argv[@]}" --entrypoint /opt/radiance/bin/rad-convert "$IMAGE" "${conv[@]}" "$@" \
|
||||
2>&1 | tee "$log"
|
||||
return "${PIPESTATUS[0]}"
|
||||
}
|
||||
|
||||
# --- plan, then bill, then convert -------------------------------------------------------------
|
||||
overall=0
|
||||
for arm in "${ARMS[@]}"; do
|
||||
echo "== arm $arm =="
|
||||
if [ "$DRY" = 1 ]; then
|
||||
# Both lines, in order: the plan runs before the convert, and the convert is what the
|
||||
# operator is about to pay an afternoon for.
|
||||
run_convert "$arm" --plan-only
|
||||
run_convert "$arm"
|
||||
continue
|
||||
fi
|
||||
rc=0; plan_log=$(run_convert "$arm" --plan-only) || rc=$?
|
||||
[ "$rc" -eq 0 ] || { echo "arm $arm: --plan-only failed (rc=$rc); nothing was written" >&2; overall=1; continue; }
|
||||
echo "$plan_log"
|
||||
# "about 141.2 GiB would be written" is the converter's own bill; parse it rather than
|
||||
# hardcoding a size, so the gate still works after any recipe edit.
|
||||
want=$(printf '%s' "$plan_log" | sed -n 's/.*about \([0-9.]*\) \([KMGT]iB\) would be written.*/\1 \2/p' | tail -1)
|
||||
[ -n "$want" ] || { echo "arm $arm: the plan printed no size; refusing to convert blind" >&2; overall=1; continue; }
|
||||
need_mib=$(awk -v n="${want% *}" -v u="${want#* }" \
|
||||
'BEGIN{ f = (u=="KiB") ? 2^-10 : (u=="MiB") ? 1 : (u=="GiB") ? 1024 : (u=="TiB") ? 1048576 : 0
|
||||
if (f == 0) { exit 1 }; printf "%d", n*f + 0.5 }') || {
|
||||
echo "arm $arm: the plan printed an unknown unit in '$want'" >&2; overall=1; continue; }
|
||||
free_mib=$(df --output=avail -m "$OUTDIR" | tail -1 | tr -d ' ')
|
||||
echo "plan: $want ($need_mib MiB); free ${free_mib} MiB on $OUTDIR (margin $MARGIN_MIB MiB)"
|
||||
if [ "$free_mib" -lt $((need_mib + MARGIN_MIB)) ]; then
|
||||
echo "arm $arm: needs ${need_mib} MiB + ${MARGIN_MIB} margin, ${free_mib} free; free space and re-run" >&2
|
||||
overall=1; continue
|
||||
fi
|
||||
if [ "$PLAN_ONLY" = 1 ]; then echo "planned, not converted (--plan-only)"; continue; fi
|
||||
rc=0; run_convert "$arm" || rc=$?
|
||||
[ "$rc" -eq 0 ] || { echo "arm $arm: rad-convert rc=$rc; log: $STATE/convert-$arm-$TS.log" >&2; overall=1; continue; }
|
||||
out="$OUTDIR/qwen38-w5nl64-$arm.rad"
|
||||
[ -f "$out" ] || { echo "arm $arm: rad-convert exited 0 but wrote no $out; log: $STATE/convert-$arm-$TS.log" >&2; overall=1; continue; }
|
||||
got_mib=$(( $(stat -c %s "$out") / 1048576 ))
|
||||
# The receipt: the artifact is what the plan predicted, within the metadata the plan does not
|
||||
# count. An arm that came out materially smaller lost a plane somewhere.
|
||||
lo=$(( need_mib * 98 / 100 )); hi=$(( need_mib * 103 / 100 ))
|
||||
if [ "$got_mib" -lt "$lo" ] || [ "$got_mib" -gt "$hi" ]; then
|
||||
echo "arm $arm: $out is ${got_mib} MiB, the plan said ${need_mib} MiB (allowed ${lo}-${hi})" >&2
|
||||
overall=1; continue
|
||||
fi
|
||||
echo "CONVERT_DONE $arm ${got_mib} MiB -> $out"
|
||||
done
|
||||
|
||||
if [ "$KEEP_RECIPE" = 0 ] && [ "$DRY" = 0 ]; then
|
||||
mkdir -p "$CAMPAIGN/.AGENTTRASH"
|
||||
mv "$WORK" "$CAMPAIGN/.AGENTTRASH/$(basename "$WORK")" 2>/dev/null || true
|
||||
fi
|
||||
exit "$overall"
|
||||
+48
-5
@@ -34,12 +34,16 @@ SERVICE_ORDER = (
|
||||
"hermes",
|
||||
"hermes-dashboard",
|
||||
"hermes-webui",
|
||||
"prometheus",
|
||||
"grafana",
|
||||
"loki",
|
||||
"alloy",
|
||||
"node-exporter",
|
||||
"cadvisor",
|
||||
"gpu-exporter",
|
||||
"alertmanager",
|
||||
# The prober must answer before prometheus re-runs the services job.
|
||||
"blackbox-exporter",
|
||||
"prometheus",
|
||||
"authentik-postgres",
|
||||
"authentik-server",
|
||||
"authentik-worker",
|
||||
@@ -92,7 +96,10 @@ DANKCLAW_SERVICES = {
|
||||
"loki",
|
||||
"alloy",
|
||||
"node-exporter",
|
||||
"cadvisor",
|
||||
"gpu-exporter",
|
||||
"alertmanager",
|
||||
"blackbox-exporter",
|
||||
"authentik-postgres",
|
||||
"authentik-server",
|
||||
"authentik-worker",
|
||||
@@ -124,6 +131,8 @@ DANKSERVER_SERVICES = {
|
||||
"readarr",
|
||||
"frank-archon",
|
||||
"node-exporter",
|
||||
"cadvisor",
|
||||
"gpu-exporter",
|
||||
}
|
||||
|
||||
# FrankBook-Pro runs the *arr stack + qBittorrent inside a podman machine on
|
||||
@@ -146,13 +155,14 @@ DANKCLAW_COMPOSE_SERVICES = set()
|
||||
# Quadlet unit now — the last compose services were the hermes trio, whose units
|
||||
# have owned their containers since 2026-09-12 and whose compose blocks were
|
||||
# dead weight that could only create a duplicate container.
|
||||
# jellyfin left the Dankserver file on 2026-10-30 for the same reason: its Quadlet
|
||||
# has owned the container since the cutover, so a compose edit must not restart it.
|
||||
|
||||
DANKSERVER_COMPOSE_SERVICES = {
|
||||
"mariadb",
|
||||
"redis",
|
||||
"romm",
|
||||
"copyparty",
|
||||
"jellyfin",
|
||||
"calibre-web",
|
||||
}
|
||||
COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"}
|
||||
@@ -271,6 +281,8 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
# Provisioned datasources and dashboards: git is the source of
|
||||
# truth, so a change here must restart the service that reads them.
|
||||
_add(services, "grafana")
|
||||
elif group == "blackbox":
|
||||
_add(services, "blackbox-exporter")
|
||||
elif group == "homepage":
|
||||
# Homepage is a Dankclaw-owned deployment. Shared homepage files
|
||||
# are intentionally not copied during a Dankserver-only run.
|
||||
@@ -293,6 +305,17 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis", "seerr"}:
|
||||
_add(services, parts[1])
|
||||
return services
|
||||
if len(parts) >= 3 and parts[1] == "host":
|
||||
# Host state a service needs at boot lives under dankclaw/host/.
|
||||
# The fabric-clock hold is frank-dragoon's NPU precondition (the
|
||||
# engine refuses to start with HALOGEN_NPU_MODELS set while the
|
||||
# clock moves) and the deployer installs and gates it, so changing
|
||||
# it re-dispatches that endpoint. `grub` is the tracked boot line,
|
||||
# which moves only through scripts/set-dankclaw-bootline.sh and is
|
||||
# a reboot decision — it plans no service.
|
||||
if parts[2].startswith("halogen-fabric-clock"):
|
||||
_add(services, "frank-dragoon")
|
||||
return services
|
||||
if len(parts) >= 4 and parts[1] == "authentik" and parts[2] == "quadlet":
|
||||
name = parts[3].removesuffix(".container")
|
||||
if name in {"authentik-postgres", "authentik-server", "authentik-worker", "authentik-outpost", "authentik-outpost-ldap"}:
|
||||
@@ -363,7 +386,7 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
# consumer (prometheus before grafana, loki before alloy).
|
||||
if len(parts) >= 4 and parts[2] == "quadlet":
|
||||
name = parts[3].removesuffix(".container")
|
||||
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "alertmanager"}:
|
||||
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "cadvisor", "alertmanager", "blackbox-exporter"}:
|
||||
_add(services, name)
|
||||
return services
|
||||
if len(parts) >= 2 and parts[1] == "gitea":
|
||||
@@ -399,6 +422,11 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
"tautulli": {"tautulli"},
|
||||
}
|
||||
_add(services, *quadlet_services.get(parts[1], set()))
|
||||
if len(parts) >= 2 and parts[1] == "host":
|
||||
# dankserver/host/systemd/media-meta-view.mount is the overlay view the
|
||||
# media servers bind (scripts/media-view.sh installs it on the host),
|
||||
# so editing it restarts jellyfin and plex and nothing else.
|
||||
_add(services, "jellyfin", "plex")
|
||||
if len(parts) >= 2 and parts[1] == "observability":
|
||||
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
|
||||
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
|
||||
@@ -406,8 +434,8 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
# node-dankserver job had nothing to scrape.
|
||||
if len(parts) >= 4 and parts[2] == "quadlet":
|
||||
name = parts[3].removesuffix(".container")
|
||||
if name == "node-exporter":
|
||||
_add(services, "node-exporter")
|
||||
if name in {"node-exporter", "cadvisor"}:
|
||||
_add(services, name)
|
||||
return services
|
||||
# frank-archon's quadlet-era directory left the tree on 2026-09-26
|
||||
# (the Lucebox DS4.1 unit was retired for the llama.cpp root unit —
|
||||
@@ -438,6 +466,21 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
# change to it redeploying alertmanager is the only path that
|
||||
# reaches it on the host.
|
||||
_add(services, "alertmanager")
|
||||
if name in {"gpu-exporter.py", "gpu-exporter.service"}:
|
||||
# The host-process GPU exporter is installed directly by the
|
||||
# gpu-exporter deploy on BOTH hosts, so its script and unit select
|
||||
# that service (target_services then keeps it only on the host being
|
||||
# planned). Editing the collector restarts the running exporter.
|
||||
_add(services, "gpu-exporter")
|
||||
if name == "media-view.sh":
|
||||
# The installer is what creates, mounts and reaps the view both media
|
||||
# servers read, so a change to it is a restart of that pair.
|
||||
_add(services, "jellyfin", "plex")
|
||||
if name == "jellyfin-providers.sh":
|
||||
# The deployer runs this before jellyfin restarts, so a change to
|
||||
# the installer is a change to the provider state Jellyfin loads at
|
||||
# start-up: it selects jellyfin and nothing else.
|
||||
_add(services, "jellyfin")
|
||||
if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}:
|
||||
# llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve
|
||||
# files here; each one selects frank-zealot only. The ik_llama.cpp
|
||||
|
||||
+393
-34
@@ -86,7 +86,7 @@ service_is_allowed() {
|
||||
dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
|
||||
dankclaw:comfyui|dankclaw:hermes|\
|
||||
dankclaw:hermes-dashboard|dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|\
|
||||
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|dankclaw:alertmanager|\
|
||||
dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|dankclaw:cadvisor|dankclaw:gpu-exporter|dankclaw:alertmanager|dankclaw:blackbox-exporter|\
|
||||
dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\
|
||||
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
|
||||
@@ -97,7 +97,7 @@ service_is_allowed() {
|
||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
||||
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
|
||||
dankserver:frank-archon|dankserver:node-exporter|\
|
||||
dankserver:frank-archon|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
||||
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
|
||||
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
|
||||
frankbook-pro:readarr)
|
||||
@@ -252,16 +252,24 @@ install_signal_relay() {
|
||||
# The relay binds the host's LAN IP (the one address a bridge container
|
||||
# can reach as host.containers.internal), so keep the unauthenticated
|
||||
# endpoint off the rest of the LAN: admit loopback, the host itself
|
||||
# (its own health probe) and the podman bridge subnet only. Idempotent:
|
||||
# -C before -I.
|
||||
# (its own health probe) and the podman bridge subnet only.
|
||||
# ORDER IS THE GATE: `iptables -I` inserts at position 1, so the old
|
||||
# shape (-C/-I x3 ACCEPT, then -C/-I the DROP) landed the blanket DROP
|
||||
# ABOVE the ACCEPTs and every probe timed out beside a LISTENing socket
|
||||
# (live 2026-10-21: 159 packets on the DROP, 0 on each ACCEPT; the whole
|
||||
# dankclaw drain aborted at the alertmanager gate twice before the cause
|
||||
# was read off `nft list ruleset`). Rebuild the tail instead of inserting
|
||||
# it: delete any blanket DROP wherever it sits, keep the three ACCEPTs
|
||||
# at the head (-C before -I, unchanged), then APPEND the DROP so it is
|
||||
# last and the ACCEPTs decide first. Converges on poisoned hosts.
|
||||
run_privileged iptables -D INPUT -p tcp --dport 18093 -j DROP 2>/dev/null || true
|
||||
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT 2>/dev/null \
|
||||
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 127.0.0.1 -j ACCEPT
|
||||
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT 2>/dev/null \
|
||||
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 192.168.0.143 -j ACCEPT
|
||||
run_privileged iptables -C INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT 2>/dev/null \
|
||||
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -s 10.89.0.0/16 -j ACCEPT
|
||||
run_privileged iptables -C INPUT -p tcp --dport 18093 -j DROP 2>/dev/null \
|
||||
|| run_privileged iptables -I INPUT -p tcp --dport 18093 -j DROP
|
||||
run_privileged iptables -A INPUT -p tcp --dport 18093 -j DROP
|
||||
# The interpreter needs a moment to bind after restart; a one-shot curl
|
||||
# raced the socket on 2026-09-17 and failed a healthy deploy.
|
||||
local i
|
||||
@@ -275,14 +283,81 @@ install_signal_relay() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Host-process GPU metrics exporter (amdgpu sysfs + nvidia-smi). Not a quadlet:
|
||||
# it runs python3 from the checkout on the host so it can read the DRM/cgroup
|
||||
# sysfs and shell out to nvidia-smi. Same two-address bind posture as
|
||||
# node-exporter/cadvisor (loopback + this host's LAN), so it is never a wildcard.
|
||||
deploy_gpu_exporter() {
|
||||
local unit_dir="$HOME/.config/systemd/user" lan dropin i
|
||||
case "$TARGET" in
|
||||
dankclaw) lan=192.168.0.143 ;;
|
||||
dankserver) lan=192.168.0.235 ;;
|
||||
*) echo "gpu-exporter: unknown target '$TARGET'" >&2; return 2 ;;
|
||||
esac
|
||||
mkdir -p "$unit_dir"
|
||||
cp scripts/gpu-exporter.service "$unit_dir/gpu-exporter.service"
|
||||
dropin="$unit_dir/gpu-exporter.service.d"
|
||||
mkdir -p "$dropin"
|
||||
printf '[Service]\nEnvironment=GPU_EXPORTER_BINDS=127.0.0.1,%s\n' "$lan" \
|
||||
> "$dropin/bind.conf"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable gpu-exporter.service
|
||||
systemctl --user restart gpu-exporter.service
|
||||
# The interpreter needs a moment to bind after restart (the same race that
|
||||
# once failed a healthy signal-relay deploy).
|
||||
for i in $(seq 1 15); do
|
||||
curl -fsS -m 5 http://127.0.0.1:9401/metrics >/dev/null && return 0
|
||||
sleep 1
|
||||
done
|
||||
echo "gpu-exporter is not answering on 127.0.0.1:9401/metrics" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
copy_loki_config() {
|
||||
run_privileged mkdir -p /opt/loki/config
|
||||
run_privileged cp shared/loki/loki-config.yaml /opt/loki/config/loki-config.yaml
|
||||
}
|
||||
|
||||
copy_alloy_config() {
|
||||
run_privileged mkdir -p /opt/alloy/config
|
||||
run_privileged cp shared/alloy/config.alloy /opt/alloy/config/config.alloy
|
||||
# The repo config carries the token GRAFANA_HOST_LABEL; the installed copy
|
||||
# carries the host's name, so Loki streams say WHICH machine's journal a
|
||||
# line came from without a second config file per host.
|
||||
# *_CONF_ROOT are test seams: the bats cases execute these copiers for
|
||||
# real against a sandbox with a stubbed run_privileged.
|
||||
local root="${ALLOY_CONF_ROOT:-/opt/alloy}" tmp
|
||||
tmp="$(mktemp)"
|
||||
sed "s/GRAFANA_HOST_LABEL/$TARGET/g" shared/alloy/config.alloy > "$tmp"
|
||||
if ! grep -q "host = \"$TARGET\"," "$tmp" || grep -q "GRAFANA_HOST_LABEL" "$tmp"; then
|
||||
echo "alloy config: host-label substitution failed" >&2
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
# Syntax gate: alloy's config language has no schema — a misplaced
|
||||
# attribute (e.g. `on_error` outside the json stage) parses only at RUN
|
||||
# time, and the unit then crash-loops with a healthy-looking deploy.
|
||||
# Validate against the very image that will run it, before installing.
|
||||
# Skipped only in the bats sandbox (no image there).
|
||||
if [ "$root" = "/opt/alloy" ] || podman image exists localhost/franksdank/alloy:latest 2>/dev/null; then
|
||||
if ! podman run --rm -v "$tmp:/c.alloy:ro" \
|
||||
--entrypoint /usr/bin/alloy localhost/franksdank/alloy:latest \
|
||||
validate /c.alloy; then
|
||||
echo "alloy config: validate FAILED against the pinned image" >&2
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
run_privileged mkdir -p "$root/config"
|
||||
run_privileged cp "$tmp" "$root/config/config.alloy"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
copy_blackbox_config() {
|
||||
# Single-file mount target: the unit binds this exact path into
|
||||
# /etc/blackbox_exporter/config.yml. cp (not cp -r) keeps it one file.
|
||||
# BLACKBOX_CONF_ROOT is the same test seam.
|
||||
local root="${BLACKBOX_CONF_ROOT:-/opt/blackbox}"
|
||||
run_privileged mkdir -p "$root"
|
||||
run_privileged cp shared/blackbox/blackbox.yml "$root/blackbox.yml"
|
||||
}
|
||||
|
||||
copy_grafana_provisioning() {
|
||||
@@ -291,14 +366,25 @@ copy_grafana_provisioning() {
|
||||
# datasource would be invisible to review, lost with the data volume, and
|
||||
# unknown to anyone reading the repository. The unit already mounts
|
||||
# /opt/grafana/provisioning read-only, so this only has to populate it.
|
||||
run_privileged mkdir -p /opt/grafana/provisioning/datasources \
|
||||
/opt/grafana/provisioning/dashboards
|
||||
# GRAFANA_PROV_ROOT is the test seam: point it at a sandbox and the whole
|
||||
# copy runs unprivileged (the bats case executes this function for real).
|
||||
local root="${GRAFANA_PROV_ROOT:-/opt/grafana}"
|
||||
run_privileged mkdir -p "$root/provisioning/datasources" \
|
||||
"$root/provisioning/dashboards"
|
||||
run_privileged cp shared/grafana/provisioning/datasources/prometheus.yml \
|
||||
/opt/grafana/provisioning/datasources/prometheus.yml
|
||||
"$root/provisioning/datasources/prometheus.yml"
|
||||
run_privileged cp shared/grafana/provisioning/dashboards/provider.yml \
|
||||
/opt/grafana/provisioning/dashboards/provider.yml
|
||||
run_privileged cp shared/grafana/provisioning/dashboards/llm-fleet.json \
|
||||
/opt/grafana/provisioning/dashboards/llm-fleet.json
|
||||
"$root/provisioning/dashboards/provider.yml"
|
||||
# EVERY dashboard JSON rides, not a hand-listed subset: the fleet provider
|
||||
# turns each file in the directory into a dashboard, so a copier that names
|
||||
# files one by one silently drops new boards (measured 2026-10-21: the
|
||||
# containers board existed in the repo since its commit and was NEVER
|
||||
# provisioned — the panel-census browser check could not find it while the
|
||||
# hand-placed older boards happened to be on disk). Same rule as the quadlet
|
||||
# tier: the directory is the contract, the copy is generated state.
|
||||
for dash in shared/grafana/provisioning/dashboards/*.json; do
|
||||
run_privileged cp "$dash" "$root/provisioning/dashboards/$(basename "$dash")"
|
||||
done
|
||||
}
|
||||
|
||||
render_caddyfile() {
|
||||
@@ -487,13 +573,15 @@ seed_grafana_env() {
|
||||
|| { echo "grafana env missing GRAFANA_OAUTH_CLIENT_SECRET; set it in dankclaw/.env" >&2; return 1; }
|
||||
}
|
||||
|
||||
# The five observability containers left compose on 2026-09-11 (they were
|
||||
# started by hand with podman-compose and never came back after a reboot).
|
||||
# They are ordinary rootless Quadlet units now: same images, same mounts, same
|
||||
# arguments, same container names, so nothing that addresses them by name —
|
||||
# Caddy, Prometheus' own scrape config, Alloy's loki.write endpoint — changed.
|
||||
# Only the published ports did: the three HTTP UIs bind 127.0.0.1, because the
|
||||
# compose services published on 0.0.0.0 and none of them authenticates.
|
||||
# The observability containers left compose on 2026-09-11 (they were started by
|
||||
# hand with podman-compose and never came back after a reboot). Five are ordinary
|
||||
# rootless Quadlet units: same images, same mounts, same arguments, same
|
||||
# container names, so nothing that addresses them by name — Caddy, Prometheus'
|
||||
# own scrape config — changed. Only the published ports did: the three HTTP UIs
|
||||
# bind 127.0.0.1, because the compose services published on 0.0.0.0 and none of
|
||||
# them authenticates. Alloy is the exception: a ROOT system service on
|
||||
# Network=host, because reading the host journal needs privileges a rootless
|
||||
# container cannot get. It is handled by deploy_alloy, not the loop below.
|
||||
deploy_observability() {
|
||||
local service="$1"
|
||||
# Observability units live under <target>/observability/quadlet: Dankclaw
|
||||
@@ -512,16 +600,17 @@ deploy_observability() {
|
||||
copy_loki_config
|
||||
build_compose_image loki
|
||||
;;
|
||||
alloy)
|
||||
copy_alloy_config
|
||||
build_compose_image alloy
|
||||
;;
|
||||
grafana)
|
||||
copy_grafana_provisioning
|
||||
seed_grafana_env || return 1
|
||||
;;
|
||||
node-exporter)
|
||||
;;
|
||||
cadvisor)
|
||||
;;
|
||||
blackbox-exporter)
|
||||
copy_blackbox_config
|
||||
;;
|
||||
*)
|
||||
echo "unknown observability service: $service" >&2
|
||||
return 2
|
||||
@@ -534,11 +623,75 @@ deploy_observability() {
|
||||
alertmanager) wait_for_health alertmanager http://127.0.0.1:9093/-/healthy 60 ;;
|
||||
grafana) wait_for_health grafana http://127.0.0.1:3002/api/health 90 ;;
|
||||
loki) wait_for_health loki http://127.0.0.1:3100/ready 60 ;;
|
||||
alloy) wait_for_health alloy http://127.0.0.1:12345/-/healthy 60 ;;
|
||||
node-exporter) wait_for_health node-exporter http://127.0.0.1:9100/metrics 60 ;;
|
||||
cadvisor) wait_for_health cadvisor http://127.0.0.1:8080/healthz 90 ;;
|
||||
# Loopback: the exporter binds 127.0.0.1 + LAN (the gateway address
|
||||
# is not bindable from the host netns — see the unit header).
|
||||
blackbox-exporter) wait_for_health blackbox-exporter http://127.0.0.1:9115/-/healthy 60 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Mirror an image the deployer built rootless into the ROOT image store. A root
|
||||
# quadlet reads root storage; a root build (build_compose_image is rootless) is
|
||||
# not possible on this host — the build container's resolver fails DNS — so the
|
||||
# only way to get a root-readable copy is save-and-load. Compares image ids so a
|
||||
# rebuilt alloy actually re-lands in root storage instead of leaving a stale tag.
|
||||
mirror_image_to_root() {
|
||||
local ref="$1" rid
|
||||
rid="$(podman image inspect "$ref" --format '{{.Id}}' 2>/dev/null)" || {
|
||||
echo "ERROR: rootless image $ref is not built; cannot mirror to root" >&2
|
||||
return 1
|
||||
}
|
||||
if [[ "$(run_privileged podman image inspect "$ref" --format '{{.Id}}' 2>/dev/null)" == "$rid" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local tar; tar="$(mktemp /var/tmp/alloy-mirror.XXXXXX.tar)"
|
||||
podman save -o "$tar" "$ref"
|
||||
run_privileged podman load -i "$tar"
|
||||
rm -f "$tar"
|
||||
}
|
||||
|
||||
# Install a quadlet into the SYSTEM generator dir (root manager) instead of the
|
||||
# user dir. Boot persistence is the unit's own [Install] section (multi-user
|
||||
# target); the v1 system generator creates the .wants on daemon-reload, so —
|
||||
# like the user units — no `systemctl enable` (generated units cannot be enabled
|
||||
# here).
|
||||
install_system_quadlet() {
|
||||
local source_dir="$1" unit="$2"
|
||||
run_privileged mkdir -p /etc/containers/systemd
|
||||
run_privileged cp "$source_dir/$unit.container" /etc/containers/systemd/
|
||||
run_privileged systemctl daemon-reload
|
||||
}
|
||||
|
||||
# Alloy as a ROOT system service on Network=host. See the unit file: a rootless
|
||||
# container cannot read the 0640 journal files, and it cannot reach Loki's
|
||||
# 127.0.0.1:3100 publish from a bridge netns. Root + host network fixes both;
|
||||
# the price is that this one container runs as root and on the host network.
|
||||
deploy_alloy() {
|
||||
copy_alloy_config
|
||||
build_compose_image alloy # rootless build (resolver works)
|
||||
mirror_image_to_root localhost/franksdank/alloy:latest
|
||||
|
||||
# Retire the rootless unit BEFORE the system one starts: both managers
|
||||
# generate alloy.service, and two containers named "alloy" both binding
|
||||
# 127.0.0.1:12345 cannot coexist. Move the user quadlet out of the generator
|
||||
# dir (it regenerates from there) and stop the user unit.
|
||||
if [[ -f "$quadlet_unit_dir/alloy.container" ]] ||
|
||||
systemctl --user is-active --quiet alloy.service 2>/dev/null; then
|
||||
systemctl --user stop alloy.service 2>/dev/null || true
|
||||
if [[ -f "$quadlet_unit_dir/alloy.container" ]]; then
|
||||
local trash="$HOME/.AGENTTRASH/$(date +%Y%m%d)-alloy-root-cutover"
|
||||
mkdir -p "$trash"
|
||||
mv -- "$quadlet_unit_dir/alloy.container" "$trash/alloy.container"
|
||||
echo "retired rootless alloy.container -> $trash"
|
||||
fi
|
||||
systemctl --user daemon-reload
|
||||
fi
|
||||
|
||||
install_system_quadlet dankclaw/observability/quadlet alloy
|
||||
run_privileged systemctl restart alloy.service
|
||||
wait_for_health alloy http://127.0.0.1:12345/-/healthy 60
|
||||
}
|
||||
build_compose_image() {
|
||||
local service="$1" image cfhash oldhash containerfile
|
||||
# Every file the image COPIES IN, beside the Containerfile. The skip below
|
||||
@@ -1049,7 +1202,76 @@ deploy_romm_stack() {
|
||||
systemctl --user restart "$service.service"
|
||||
}
|
||||
|
||||
ensure_media_view() {
|
||||
# Both media servers read their library through the SSD-backed overlay view
|
||||
# (scripts/media-view.sh, dankserver/host/systemd/media-meta-view.mount): the
|
||||
# array below stays read-only and every sidecar Jellyfin writes lands on the
|
||||
# NVMe.
|
||||
# Raise it BEFORE either container starts — a start against a bare view
|
||||
# directory would let a scan clear the library database. Each unit gates on
|
||||
# scripts/wait-for-mount.sh, which waits and then refuses the start. A
|
||||
# relayout must be followed by `media-view.sh reap`
|
||||
# (see the Media Relayout page), which this is not.
|
||||
run_privileged bash scripts/media-view.sh install
|
||||
bash scripts/media-view.sh verify
|
||||
}
|
||||
|
||||
ensure_user_io_delegate() {
|
||||
# The media servers ask for an I/O share (IOWeight= in their quadlets) so a
|
||||
# library scan cannot starve playback or the array's other reader. Two system
|
||||
# drop-ins are what make that request real, and both are host state, so the
|
||||
# deployer places them:
|
||||
# user.slice.d/io-accounting.conf io enters user.slice's subtree
|
||||
# user@1000.service.d/io-delegate.conf the user manager gets the controller
|
||||
# Without the first, `io` never appears in user.slice/cgroup.subtree_control,
|
||||
# so no container under it can have an io.weight file at all; without the
|
||||
# second the user manager never asks for the controller. systemd hands
|
||||
# user@.service cpu/memory/pids by default and drops a line it cannot honour
|
||||
# without a word — IOWeight= on its own is inert config.
|
||||
local src dst
|
||||
for src in \
|
||||
dankserver/host/systemd/user.slice.d/io-accounting.conf \
|
||||
dankserver/host/systemd/user@1000.service.d/io-delegate.conf; do
|
||||
dst=/etc/systemd/system/${src#dankserver/host/systemd/}
|
||||
run_privileged mkdir -p "$(dirname "$dst")"
|
||||
if ! run_privileged cmp -s "$src" "$dst"; then
|
||||
run_privileged install -m 644 "$src" "$dst"
|
||||
run_privileged systemctl daemon-reload
|
||||
fi
|
||||
done
|
||||
# Accounting lands immediately; the delegation is read when user@1000.service
|
||||
# starts, and restarting that unit would kill every rootless container on this
|
||||
# host. So the share becomes real at the next reboot — say so out loud rather
|
||||
# than let a quadlet line read as live config.
|
||||
if ! systemctl show user@1000.service -p DelegateControllers | grep -qw io; then
|
||||
echo "NOTE: io is not delegated to user@1000.service yet - IOWeight= is"
|
||||
echo "NOTE: inert until the next reboot (see [[Quadlet Lifecycle]])"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_provider_keys() {
|
||||
# Jellyfin's metadata-provider keys live in the container-owned plugin
|
||||
# configuration under the data root, never in git; scripts/jellyfin-providers.sh
|
||||
# is what places them, reading the per-service host env file (the fleet
|
||||
# convention: grafana, caddy, pihole, copyparty, romm, seerr) that holds
|
||||
# TMDB_API_KEY= and TVDB_API_KEY=, mode 600.
|
||||
# Missing keys mean a keyless provider — a degraded library, not a broken
|
||||
# deploy — so this warns rather than failing the restart. But when the keys
|
||||
# ARE placed it must happen before the restart: the plugin and its keys are
|
||||
# read at start-up, so a restart first would boot the old state.
|
||||
local keys=/home/dank/Containers/jellyfin/provider-keys
|
||||
if [ -f "$keys" ]; then
|
||||
bash scripts/jellyfin-providers.sh install
|
||||
else
|
||||
echo "WARN: $keys is absent - provider keys NOT placed"
|
||||
echo "WARN: create it (TMDB_API_KEY=, TVDB_API_KEY=, chmod 600) and re-deploy jellyfin"
|
||||
fi
|
||||
}
|
||||
|
||||
deploy_jellyfin() {
|
||||
ensure_media_view
|
||||
ensure_user_io_delegate
|
||||
ensure_provider_keys
|
||||
cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/"
|
||||
systemctl --user daemon-reload
|
||||
if ! systemctl --user is-active -q jellyfin.service; then
|
||||
@@ -1137,6 +1359,8 @@ deploy_copyparty() {
|
||||
}
|
||||
|
||||
deploy_plex() {
|
||||
ensure_media_view
|
||||
ensure_user_io_delegate
|
||||
cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/"
|
||||
systemctl --user daemon-reload
|
||||
if ! systemctl --user is-active -q plex.service; then
|
||||
@@ -1209,11 +1433,17 @@ deploy_authentik() {
|
||||
# generated". Boot persistence comes from [Install] WantedBy=default.target
|
||||
# inside each .container file, which the generator honors at user-manager
|
||||
# start (linger must stay on for the deploy user).
|
||||
systemctl --user start authentik-postgres.service
|
||||
# RESTART, not start: `start` on an already-active unit is a no-op, so an
|
||||
# image bump would install the new digest and keep serving the old
|
||||
# container while every gate stayed green (measured 2026-10-30: server and
|
||||
# worker stayed on the previous release beside an outpost that moved,
|
||||
# because only the outpost handler restarts). The generator has already
|
||||
# re-rendered the unit from the .container, so a restart is what applies it.
|
||||
systemctl --user restart authentik-postgres.service
|
||||
wait_container_healthy authentik-postgres 120 || return 1
|
||||
systemctl --user start authentik-server.service
|
||||
systemctl --user restart authentik-server.service
|
||||
wait_container_healthy authentik-server 300 || return 1
|
||||
systemctl --user start authentik-worker.service
|
||||
systemctl --user restart authentik-worker.service
|
||||
}
|
||||
|
||||
deploy_authentik_outpost() {
|
||||
@@ -1310,9 +1540,10 @@ deploy_anubis() {
|
||||
# conversion (2026-10-11) of the abliterated Qwen3.8-Flash-Next —
|
||||
# qwen38-flash-next-v2-abliterated.hgn (66,687,678,432 B, ~62 GiB resident)
|
||||
# + the REQUIRED n-gram lookup table + the vision tower — MTP spec decode
|
||||
# (HALOGEN_SPEC_ADAPT=0), 2 slots over ONE 401,408-position KV pool (2 × ctx,
|
||||
# raised 2026-10-18 so BOTH slots can hold a maximum-length request) at the
|
||||
# ctx 200704 ceiling, image input — on the Strix Halo iGPU. It stays a
|
||||
# (HALOGEN_SPEC_ADAPT=0), 2 slots over ONE 401,408-position KV pool at the
|
||||
# native ctx 262144 ceiling (raised 2026-10-21 from 200704 so the harness can
|
||||
# run a 200,000 window with its ~45k fit gap; the pool still carries two
|
||||
# 200k conversations at once), image input — on the Strix Halo iGPU. It stays a
|
||||
# ROOT unit because the serve script drives `podman run` directly (the frank-archon pattern):
|
||||
# relink_unit points /etc/systemd/system/frank-dragoon.service at the repo
|
||||
# file, boot persistence is that unit's [Install], and LimitMEMLOCK=infinity
|
||||
@@ -1328,7 +1559,10 @@ deploy_anubis() {
|
||||
# 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_DRAGOON_IMAGE="${LLM_DRAGOON_IMAGE:-ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a}"
|
||||
LLM_DRAGOON_IMAGE="${LLM_DRAGOON_IMAGE:-ghcr.io/peonist-ai/halogen-flash-server@sha256:e35133b206da3094cc4d70c78800b26235bd9124b9aeb0e029d4a7c153b9cff0}"
|
||||
# The payload dir is its own twin because the NPU lane's containment gate below
|
||||
# reads it; every artifact path under it stays a full literal as usual.
|
||||
LLM_DRAGOON_MODEL_DIR="${LLM_DRAGOON_MODEL_DIR:-/home/dank/models/halogen-v2-abliterated}"
|
||||
LLM_DRAGOON_CKPT="${LLM_DRAGOON_CKPT:-/home/dank/models/halogen-v2-abliterated/qwen38-flash-next-v2-abliterated.hgn}"
|
||||
LLM_DRAGOON_CKPT_BYTES="${LLM_DRAGOON_CKPT_BYTES:-66687678432}"
|
||||
LLM_DRAGOON_NGRAM="${LLM_DRAGOON_NGRAM:-/home/dank/models/halogen-v2-abliterated/qwen38-flash-next-ngram.hgn}"
|
||||
@@ -1337,6 +1571,18 @@ LLM_DRAGOON_VISION="${LLM_DRAGOON_VISION:-/home/dank/models/halogen-v2-abliterat
|
||||
LLM_DRAGOON_VISION_BYTES="${LLM_DRAGOON_VISION_BYTES:-897916416}"
|
||||
LLM_DRAGOON_TOK_DIR="${LLM_DRAGOON_TOK_DIR:-/home/dank/models/halogen-v2-abliterated/tokenizer}"
|
||||
LLM_DRAGOON_TOK_FILES="${LLM_DRAGOON_TOK_FILES:-tokenizer.json chat_template.jinja tokenizer_config.json merges.txt vocab.json generation_config.json}"
|
||||
# NPU lane (2026-10-19). HALOGEN_NPU_MODELS is the whole switch and the parity
|
||||
# twin of the env file's line: empty here AND in the env = the pre-lane
|
||||
# endpoint. The gates below mirror serve-frank-dragoon.sh, and the vendor's
|
||||
# fabric-clock host unit (dankclaw/host/, verbatim at tag v0.16.2) is installed
|
||||
# ONLY while the lane is named — on Strix Halo the iGPU and the NPU co-running
|
||||
# hang the machine while the fabric clock moves, so halogen refuses to boot with
|
||||
# the lane armed unless that clock is held.
|
||||
LLM_DRAGOON_NPU_MODELS="${LLM_DRAGOON_NPU_MODELS:-qwen3-embedding-0.6b}"
|
||||
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_XRT_DIR="${LLM_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}"
|
||||
LLM_DRAGOON_NETWORK="${LLM_DRAGOON_NETWORK:-dankclaw_default}"
|
||||
|
||||
retire_halogen_dragoon_quadlet() {
|
||||
local trash="$HOME/.AGENTTRASH/20260919-orca-cutover/quadlet"
|
||||
@@ -1415,6 +1661,77 @@ deploy_llm_dragoon() {
|
||||
fi
|
||||
done
|
||||
|
||||
# The container network has to exist before the run line names it (a typo
|
||||
# there is a restart-looping unit, not a config error anyone reads).
|
||||
if [[ -n "$LLM_DRAGOON_NETWORK" ]] && ! podman network exists "$LLM_DRAGOON_NETWORK"; then
|
||||
echo "ERROR: podman network $LLM_DRAGOON_NETWORK does not exist" >&2
|
||||
echo "frank-dragoon: create it or blank FRANK_DRAGOON_NETWORK (pasta default)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- NPU lane: the same predicates the serve script checks --------------
|
||||
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.
|
||||
if [[ "$LLM_DRAGOON_NPU_DIR/" != "$LLM_DRAGOON_MODEL_DIR"/* ]]; then
|
||||
echo "ERROR: NPU staging dir $LLM_DRAGOON_NPU_DIR is outside the payload dir $LLM_DRAGOON_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
|
||||
echo "ERROR: NPU node $LLM_DRAGOON_DEV_ACCEL absent — amdxdna is not bound (is iommu=pt still on the boot line?)" >&2
|
||||
return 1
|
||||
fi
|
||||
# Access, not existence: a rootless container cannot open a root:render
|
||||
# 0660 node (its namespace maps one uid/gid, and keep-groups cannot grant
|
||||
# an unmapped gid — measured 2026-10-19). Install the mode rule, re-trigger
|
||||
# the node, then PROVE the node is other-accessible before the restart.
|
||||
run_privileged install -m 0644 dankclaw/udev/98-halogen-npu-access.rules \
|
||||
/etc/udev/rules.d/98-halogen-npu-access.rules
|
||||
run_privileged udevadm control --reload-rules
|
||||
run_privileged udevadm trigger --sysname-match="$(basename "$LLM_DRAGOON_DEV_ACCEL")"
|
||||
# (`-o FILE` is not "other-writable" — it means "exists", and `-o` is also
|
||||
# the OR operator inside test), so the bit is read straight off the mode.
|
||||
if [[ $(( 8#$(stat -c '%a' "$LLM_DRAGOON_DEV_ACCEL") & 8#2 )) -eq 0 ]]; then
|
||||
echo "ERROR: $LLM_DRAGOON_DEV_ACCEL is still $(stat -c '%a %U:%G' "$LLM_DRAGOON_DEV_ACCEL") after installing the access rule — the lane cannot open it rootless" >&2
|
||||
return 1
|
||||
fi
|
||||
for xl in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
|
||||
if [[ ! -e "$LLM_DRAGOON_XRT_DIR/$xl" ]]; then
|
||||
echo "ERROR: XRT NPU library $LLM_DRAGOON_XRT_DIR/$xl absent (Ubuntu libxrt2 + libxrt-npu2)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
for nm in ${LLM_DRAGOON_NPU_MODELS//,/ }; do
|
||||
if [[ ! -d "$LLM_DRAGOON_NPU_DIR/$nm" ]]; then
|
||||
echo "ERROR: NPU model $nm not staged in $LLM_DRAGOON_NPU_DIR" >&2
|
||||
echo "frank-dragoon: stage it: bash scripts/download-frank-dragoon-npu.sh" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
# The hold must exist BEFORE the engine starts and survive every boot
|
||||
# (a resume hands the clock back too — the unit's WantedBy covers it).
|
||||
run_privileged install -m 0755 dankclaw/host/halogen-fabric-clock \
|
||||
/usr/local/sbin/halogen-fabric-clock
|
||||
run_privileged install -m 0644 dankclaw/host/halogen-fabric-clock.service \
|
||||
/etc/systemd/system/halogen-fabric-clock.service
|
||||
run_privileged systemctl daemon-reload
|
||||
run_privileged systemctl enable --now halogen-fabric-clock.service
|
||||
if ! run_privileged /usr/local/sbin/halogen-fabric-clock status; then
|
||||
echo "ERROR: fabric clock is not held — halogen refuses to start with HALOGEN_NPU_MODELS set" >&2
|
||||
echo "frank-dragoon: journalctl -u halogen-fabric-clock.service -n 40" >&2
|
||||
return 1
|
||||
fi
|
||||
elif [[ -x /usr/local/sbin/halogen-fabric-clock ]]; then
|
||||
# Lane rolled back: hand the clock back to the driver and drop the boot
|
||||
# hook. `disable` deletes the INSTALLED copy of this unit — unlike the
|
||||
# tracked LLM unit symlinks the fleet's disable law protects, this file
|
||||
# is repo-owned and the next lane-on deploy reinstalls it.
|
||||
run_privileged systemctl disable halogen-fabric-clock.service 2>/dev/null || true
|
||||
run_privileged /usr/local/sbin/halogen-fabric-clock release || true
|
||||
fi
|
||||
|
||||
retire_halogen_dragoon_quadlet
|
||||
# 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
|
||||
@@ -1434,6 +1751,33 @@ deploy_llm_dragoon() {
|
||||
run_privileged systemctl enable frank-dragoon.service
|
||||
run_privileged systemctl restart frank-dragoon.service
|
||||
wait_for_health frank-dragoon http://127.0.0.1:7770/health 1200
|
||||
# The lane's own gate. /health proves the engine is up, nothing about the
|
||||
# NPU programs; one real embedding is the observable output. The first
|
||||
# request after a boot is a warmup artifact (fleet law), so retry before
|
||||
# calling a booted lane dead.
|
||||
if [[ -n "$LLM_DRAGOON_NPU_MODELS" ]]; then
|
||||
for nm in ${LLM_DRAGOON_NPU_MODELS//,/ }; do
|
||||
lane_ok=0
|
||||
for _try in 1 2 3 4 5; do
|
||||
if curl -fsS -m 60 -H 'content-type: application/json' \
|
||||
-d "{\"model\":\"$nm\",\"input\":[\"frank-dragoon npu lane gate\"]}" \
|
||||
http://127.0.0.1:7770/v1/embeddings 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
v = json.load(sys.stdin)["data"][0]["embedding"]
|
||||
raise SystemExit(0 if isinstance(v, list) and len(v) and all(isinstance(x, (int, float)) for x in v) else 1)'; then
|
||||
lane_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [[ "$lane_ok" != 1 ]]; then
|
||||
echo "ERROR: NPU model $nm answered no vector on /v1/embeddings" >&2
|
||||
echo "frank-dragoon: read the boot ledger: tail -100 /home/dank/logs/frank_dragoon.log" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "frank-dragoon: NPU lane verified — $nm embedded"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# deploy_llm_zealot — frank-zealot is llama.cpp (PrismML fork build) on the
|
||||
@@ -1463,6 +1807,12 @@ LLM_ZEALOT_RUNTIME="${LLM_ZEALOT_RUNTIME:-/home/dank/GitPackages/llama-prism-b10
|
||||
# 868.32 MiB worst-case VRAM (595.29 model + 248.10 compute in the boot ledger)
|
||||
# lands AFTER the engine's fit check, so it is not inside the q8_0 budget.
|
||||
LLM_ZEALOT_MMPROJ="${LLM_ZEALOT_MMPROJ:-/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-mmproj-Q8_0.gguf}"
|
||||
# The refusal adapter is part of the payload identity now, so the deployer
|
||||
# checks it beside the other two files — but it has its OWN fetcher, so a
|
||||
# missing adapter must not send the operator to the bonsai9 script. Integrity
|
||||
# (bytes + sha256) is the fetcher's job at download time; this gate only asks
|
||||
# whether the file the env names exists, exactly like the two above.
|
||||
LLM_ZEALOT_LORA="${LLM_ZEALOT_LORA:-/home/dank/models/Bonsai2-9B-MTP/Ternary-Bonsai-2-9B-Uncensored-LoRA.gguf}"
|
||||
|
||||
deploy_llm_zealot() {
|
||||
# Refuse rather than restart-loop: a unit whose runtime or payload is
|
||||
@@ -1484,6 +1834,13 @@ deploy_llm_zealot() {
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
if [[ ! -f "$LLM_ZEALOT_LORA" ]]; then
|
||||
echo "ERROR: frank-zealot refusal adapter absent at $LLM_ZEALOT_LORA" >&2
|
||||
echo "frank-zealot: run scripts/download-frank-zealot-uncensored-lora.sh first;" >&2
|
||||
echo "frank-zealot: the env names the adapter, so the unit would refuse to boot" >&2
|
||||
echo "frank-zealot: without it — starting the unit anyway is a crash loop." >&2
|
||||
return 1
|
||||
fi
|
||||
relink_unit frank-zealot
|
||||
run_privileged systemctl daemon-reload
|
||||
run_privileged systemctl enable frank-zealot.service
|
||||
@@ -1815,7 +2172,9 @@ deploy_service() {
|
||||
copyparty) deploy_copyparty ;;
|
||||
plex) deploy_plex ;;
|
||||
tautulli) deploy_tautulli ;;
|
||||
prometheus|grafana|loki|alloy|node-exporter|alertmanager) deploy_observability "$service" ;;
|
||||
alloy) deploy_alloy ;;
|
||||
prometheus|grafana|loki|node-exporter|cadvisor|alertmanager|blackbox-exporter) deploy_observability "$service" ;;
|
||||
gpu-exporter) deploy_gpu_exporter ;;
|
||||
comfyui) deploy_comfyui ;;
|
||||
*) deploy_compose_service "$service" ;;
|
||||
esac
|
||||
|
||||
+6
-4
@@ -133,13 +133,15 @@ fi
|
||||
valid_service() {
|
||||
local service="$1"
|
||||
case "$TARGET:$service" in
|
||||
# dankclaw:searxng-network is client-only: a network quadlet with no
|
||||
# remote dispatch handler, so the remote gate does not (must not) list it.
|
||||
dankclaw:caddy|dankclaw:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|\
|
||||
dankclaw:searxng|dankclaw:marreta|dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|\
|
||||
dankclaw:gitea-server|dankclaw:homepage|\
|
||||
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
|
||||
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
|
||||
dankclaw:alertmanager|\
|
||||
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||
dankclaw:alertmanager|dankclaw:blackbox-exporter|\
|
||||
dankclaw:node-exporter|dankclaw:cadvisor|dankclaw:gpu-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
||||
dankclaw:open-notebook|\
|
||||
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
||||
@@ -147,8 +149,8 @@ valid_service() {
|
||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
|
||||
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\
|
||||
dankserver:frank-archon|\
|
||||
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
||||
dankserver:prowlarr|dankserver:frank-archon|\
|
||||
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
|
||||
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
|
||||
frankbook-pro:readarr)
|
||||
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user