The 320k pool (163840 per conversation over two slots) costs ~4.3 GiB of KV, and the BF16 projector (888 MiB) lands on ROCm0. Fit at the deployed point, computed, not estimated: ROCm0 29.19 / ROCm1 26.04 GiB of 31.86 -- 2.67 / 5.82 GiB free, both above the 1.5 GiB floor. 36:29 is one layer over the 35:30 balance on purpose: at this context the balance point is 34:31 (0.11 GiB gap), and the operator moved a layer to ROCm0 to buy headroom on the drafter card. The gap is 3.15 GiB by construction, so the seam's slack is that gap rounded up (3225 MiB) with the trade written down, and the ctx cap moves 262144 -> 327680 with the rope caveat (the GGUF carries no scaling; positions past the trained 262144 extrapolate on the base rope). Vision: NO_MMPROJ=0, the serve script now refuses to start without the projector when vision is on (a missing file is a deploy error, not a silent text-only server), the download script fetches and byte-verifies it, and the deploy handler's preflight checks for it. The projector is already on the host, byte- and sha256-verified against the Hub.
288 lines
15 KiB
Bash
Executable File
288 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bats
|
|
# Seam: the frank-medium mainline-llama.cpp root unit on dankserver.
|
|
# frank-medium became a native llama.cpp service on 2026-09-16, replacing the
|
|
# vllm-radiance replica pair + routing proxy that served this endpoint for a
|
|
# day. What this seam pins is what the flags alone do not say: boot persistence
|
|
# for a NATIVE runtime (no generated quadlet is involved), that BOTH cards work
|
|
# on ONE model through a layer split with no collective in the path, that the
|
|
# DFlash2 drafter is wired with a depth its block size actually allows, that the
|
|
# runtime and payload are pinned rather than floated, and that one slot's KV
|
|
# still fits a 32 GiB card beside the weights it holds.
|
|
|
|
load helpers
|
|
|
|
UNIT="$REPO_ROOT/scripts/frank-medium.service"
|
|
SERVE="$REPO_ROOT/scripts/serve-frank-medium.sh"
|
|
ENV_FILE="$REPO_ROOT/scripts/frank-medium.env"
|
|
BUILD="$REPO_ROOT/scripts/build-llama-rdna4.sh"
|
|
FETCH="$REPO_ROOT/scripts/download-frank-medium-gguf.sh"
|
|
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
|
|
|
# Measured constants (rocm-smi / HF blob sizes), used by the fit arithmetic.
|
|
CARD_BYTES=34208743424 # one R9700 as rocm-smi reports it
|
|
MODEL_BYTES=31457990112 # UD-Q8_K_XL target
|
|
DRAFT_BYTES=2056414816 # DFlash2 Q8_0 drafter
|
|
|
|
@test "frank-medium's unit, serve script, env and its build/fetch scripts exist" {
|
|
[ -f "$UNIT" ]
|
|
[ -f "$SERVE" ]
|
|
[ -f "$ENV_FILE" ]
|
|
[ -f "$BUILD" ]
|
|
[ -f "$FETCH" ]
|
|
}
|
|
|
|
@test "frank-medium is boot-persistent, self-healing and unprivileged" {
|
|
grep -q '^WantedBy=multi-user.target$' "$UNIT"
|
|
grep -q '^Restart=on-failure$' "$UNIT"
|
|
grep -q '^RestartSec=' "$UNIT"
|
|
grep -q '^User=dank$' "$UNIT"
|
|
grep -q '^LimitMEMLOCK=infinity$' "$UNIT"
|
|
}
|
|
|
|
@test "frank-medium runs the repo's own serve script with the repo's env" {
|
|
grep -q '^ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh$' "$UNIT"
|
|
grep -q 'FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env' "$UNIT"
|
|
}
|
|
|
|
@test "both R9700s serve ONE model, layer-split inside one process" {
|
|
grep -q '^SPLIT_MODE=layer$' "$ENV_FILE"
|
|
grep -q '^DEVICES=ROCm0,ROCm1$' "$ENV_FILE"
|
|
# The split is a layer PAIR: the shares add up to the model's 65 blocks (one
|
|
# -ts unit per layer) and ROCm1 -- the card that also carries the drafter and
|
|
# the output layers -- takes the FEWER of the two. WHICH pair balances the
|
|
# cards is the seam test's job, which recomputes it from this file; pinning a
|
|
# literal here would only mean that re-measuring the balance breaks a test
|
|
# about parallelism.
|
|
local split_hi split_lo
|
|
split_hi="$(sed -nE 's/^TENSOR_SPLIT=([0-9]+),([0-9]+)$/\1/p' "$ENV_FILE")"
|
|
split_lo="$(sed -nE 's/^TENSOR_SPLIT=([0-9]+),([0-9]+)$/\2/p' "$ENV_FILE")"
|
|
[ -n "$split_hi" ] && [ -n "$split_lo" ]
|
|
[ $(( split_hi + split_lo )) -eq 65 ]
|
|
[ "$split_lo" -lt "$split_hi" ]
|
|
grep -q -- '--split-mode "$SPLIT_MODE"' "$SERVE"
|
|
grep -q -- '--device "$DEVICES"' "$SERVE"
|
|
grep -q -- '--tensor-split "$TENSOR_SPLIT"' "$SERVE"
|
|
# Tensor split is the one shape this host cannot run: it needs a cross-card
|
|
# collective, and the second card cannot dispatch one (see the env file).
|
|
! grep -q '^SPLIT_MODE=tensor$' "$ENV_FILE"
|
|
}
|
|
|
|
@test "the iGPU is hidden so no layer can land on system RAM" {
|
|
grep -q '^HIP_VISIBLE_DEVICES=0,1$' "$ENV_FILE"
|
|
grep -q '^export HIP_VISIBLE_DEVICES$' "$SERVE"
|
|
}
|
|
|
|
@test "the drafter runs in a chain with the draftless n-gram matcher" {
|
|
# The chain is the measured winner (see the runbook's A/B): free where the
|
|
# matcher cannot match, ~2.6x the drafter alone on prompt-echo traffic. A
|
|
# bare `draft-dflash` here would silently give that up.
|
|
grep -qE '^SPEC_TYPE=draft-dflash,ngram-mod$' "$ENV_FILE"
|
|
grep -q '^DRAFT_MODEL_FILE=Qwen3\.8-27B-DFlash2-Q8_0\.gguf$' "$ENV_FILE"
|
|
grep -q -- '--spec-type "$SPEC_TYPE"' "$SERVE"
|
|
grep -q -- '--spec-draft-model "$DRAFT_MODEL"' "$SERVE"
|
|
# ngram-mod's own window -- and only when the type asks for it, so a pure
|
|
# DFlash or MTP configuration is not quietly handed n-gram parameters.
|
|
grep -q '^SPEC_NGRAM_N_MATCH=24$' "$ENV_FILE"
|
|
grep -q '^SPEC_NGRAM_N_MIN=48$' "$ENV_FILE"
|
|
grep -q '^SPEC_NGRAM_N_MAX=64$' "$ENV_FILE"
|
|
grep -q -- '--spec-ngram-mod-n-match "$SPEC_NGRAM_N_MATCH"' "$SERVE"
|
|
grep -q -- '--spec-ngram-mod-n-min "$SPEC_NGRAM_N_MIN"' "$SERVE"
|
|
grep -q -- '--spec-ngram-mod-n-max "$SPEC_NGRAM_N_MAX"' "$SERVE"
|
|
python3 -c "
|
|
lines = open('$SERVE').read().splitlines()
|
|
guard = next(i for i, l in enumerate(lines) if '*ngram*' in l)
|
|
indent = len(lines[guard]) - len(lines[guard].lstrip())
|
|
end = next(i for i in range(guard + 1, len(lines))
|
|
if lines[i].strip() == 'fi' and len(lines[i]) - len(lines[i].lstrip()) == indent)
|
|
assert '--spec-ngram-mod-n-match' in chr(10).join(lines[guard:end]), 'ngram flags must sit in the ngram branch'
|
|
"
|
|
# DFlash2 drafts into a block of 8, so at most 7 draft tokens per
|
|
# verification step -- llama.cpp asserts n_max <= block_size - 1.
|
|
local n_max
|
|
n_max="$(sed -nE 's/^SPEC_DRAFT_N_MAX=([0-9]+)$/\1/p' "$ENV_FILE")"
|
|
[ -n "$n_max" ] && [ "$n_max" -ge 1 ] && [ "$n_max" -le 7 ]
|
|
}
|
|
|
|
@test "a draft MODEL is passed only for DFlash types, never for MTP" {
|
|
# MTP builds its context against the TARGET model (its head lives in that
|
|
# GGUF). Passing --spec-draft-model alongside it points the draft context at
|
|
# the drafter's file, which has no MTP layers, and the server dies at load:
|
|
# context type MTP requested but model doesn't contain MTP layers
|
|
# failed to create MTP context
|
|
# So the flag must appear exactly once, inside the dflash branch.
|
|
python3 -c "
|
|
lines = open('$SERVE').read().splitlines()
|
|
guard = next(i for i, l in enumerate(lines) if '*dflash*' in l)
|
|
indent = len(lines[guard]) - len(lines[guard].lstrip())
|
|
end = next(i for i in range(guard + 1, len(lines))
|
|
if lines[i].strip() == 'fi' and len(lines[i]) - len(lines[i].lstrip()) == indent)
|
|
block = chr(10).join(lines[guard:end])
|
|
assert '--spec-draft-model' in block, 'the dflash branch must pass the draft model'
|
|
assert sum(l.count('--spec-draft-model') for l in lines) == 1, 'only that branch may pass it'
|
|
print('draft-model wiring ok')
|
|
"
|
|
}
|
|
|
|
@test "the runtime is pinned to a commit and to gfx1201 alone" {
|
|
# A floating ref would silently move the service between engine versions,
|
|
# and an unpinned arch would build for a sibling GPU that loads and then
|
|
# segfaults on these cards.
|
|
grep -Eq '^LLAMA_REF="\$\{LLAMA_REF:-[0-9a-f]{40}\}"$' "$BUILD"
|
|
grep -Eq '^HIP_ARCH="\$\{HIP_ARCH:-gfx1201\}"$' "$BUILD"
|
|
grep -q 'GGML_HIP=ON' "$BUILD"
|
|
grep -q 'frank-medium.env' "$SERVE"
|
|
}
|
|
|
|
@test "the payload is fetched resumably and verified by size" {
|
|
grep -q 'UD-Q8_K_XL\.gguf' "$FETCH"
|
|
grep -q 'Qwen3\.8-27B-DFlash2-Q8_0\.gguf' "$FETCH"
|
|
grep -q 'curl -fL -C -' "$FETCH"
|
|
grep -q 'verifying sizes' "$FETCH"
|
|
}
|
|
|
|
@test "both cards stay balanced and keep the operator's 1.5 GiB floor" {
|
|
local ctx parallel cache_k cache_v split_hi split_lo batch ubatch
|
|
ctx="$(sed -nE 's/^CTX_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
|
|
parallel="$(sed -nE 's/^PARALLEL=([0-9]+)$/\1/p' "$ENV_FILE")"
|
|
cache_k="$(sed -nE 's/^CACHE_TYPE_K=(.+)$/\1/p' "$ENV_FILE")"
|
|
cache_v="$(sed -nE 's/^CACHE_TYPE_V=(.+)$/\1/p' "$ENV_FILE")"
|
|
split_hi="$(sed -nE 's/^TENSOR_SPLIT=([0-9]+),([0-9]+)$/\1/p' "$ENV_FILE")"
|
|
split_lo="$(sed -nE 's/^TENSOR_SPLIT=([0-9]+),([0-9]+)$/\2/p' "$ENV_FILE")"
|
|
batch="$(sed -nE 's/^BATCH_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
|
|
ubatch="$(sed -nE 's/^UBATCH_SIZE=([0-9]+)$/\1/p' "$ENV_FILE")"
|
|
[ -n "$ctx" ] && [ -n "$parallel" ] && [ -n "$cache_k" ] && [ -n "$cache_v" ]
|
|
[ -n "$split_hi" ] && [ -n "$split_lo" ] && [ -n "$batch" ] && [ -n "$ubatch" ]
|
|
[ "$parallel" -ge 1 ]
|
|
[ "$batch" -ge "$ubatch" ]
|
|
[ "$cache_k" = "f16" ] && [ "$cache_v" = "f16" ]
|
|
# Per card, calibrated against rocm-smi at the 35:30 / ctx 262144 / batch
|
|
# 6144 x ubatch 512 / two slots / DFlash2 point, where the two cards
|
|
# measured 6.44 / 6.90 GiB free. The fit reproduces card 1 to 0.00 GiB
|
|
# and card 0 to 0.86 GiB at that point; it is a GATE, not a meter -- the
|
|
# deployed point is re-measured on the host after every split/ctx/batch
|
|
# move (the 2026-09-17 move to 36:29 / 327680 / mmproj is the current one).
|
|
#
|
|
# card 0 = its share of the weights and of the KV (qwen35 spends KV on
|
|
# its 16 full-attention layers only: 2 (K,V) x 4 heads x 256 dim
|
|
# x 2 B = 64 KiB per token at f16) plus ~1.4e6 B per ubatch unit
|
|
# of compute buffers
|
|
# card 1 = the same for its own share, plus the WHOLE drafter: its weights
|
|
# (2.06e9 B), its KV -- BOUNDED BY ITS SLIDING WINDOW, so
|
|
# 20480 B/pos x 2048 positions rather than the context -- and
|
|
# ~2.5e6 B per ubatch unit of buffers on top of a ~0.89 GiB
|
|
# constant. Its buffers are the heavier ones because it holds the
|
|
# output layers and the drafter's graphs.
|
|
#
|
|
# BOTH ubatch coefficients are measured at the deployed shape, from the
|
|
# 1536 -> 512 move: 1.30 GiB on card 0 and 2.41 GiB on card 1. That
|
|
# asymmetry is why shrinking the batch moved the balance toward card 1 and
|
|
# cost card 0 a layer -- the split and the batch cannot be chosen apart.
|
|
#
|
|
# The layer it charges for is the AVERAGE (0.70 GiB: 31.46e9 of weights plus
|
|
# 16 GiB of KV over 65 layers). This stack is a hybrid, so real steps vary
|
|
# around that -- 0.43 GiB for a DeltaNet layer, 1.43 GiB for a full-attention
|
|
# one -- which makes the drift check below CONSERVATIVE: it overstates the gap
|
|
# a one-layer move creates, and so trips on moves a measurement might bless.
|
|
# That is the intent: moving the split means re-measuring, not re-arguing.
|
|
#
|
|
# The slack is one DeltaNet layer, rounded up. At this shape the balance
|
|
# cannot be tuned finer than one layer, so 35:30's 0.46 GiB gap IS the
|
|
# achievable optimum and a tighter slack would only reject it.
|
|
#
|
|
# What this model cannot see is CONTIGUITY -- the mechanism by which the
|
|
# DSpark-era 8192/2048 failed, a 2020 MiB pp buffer that found no room while
|
|
# every total looked fine. Total bytes are not the whole story for one big
|
|
# allocation.
|
|
local kv_pos=65536 draft_kv_pos=20480 floor slack
|
|
local card_hi card_lo diff
|
|
local mmproj=931145888 # mmproj-BF16.gguf, autoloaded onto the primary device
|
|
floor=$(( 1536 * 1024 * 1024 )) # 1.5 GiB per card
|
|
# One DeltaNet layer at the old shape was the finest the balance could be
|
|
# tuned, so the slack used to be one DeltaNet layer. The 2026-09-17 move
|
|
# to 36:29 at ctx 327680 traded balance for headroom on the drafter card
|
|
# (the balance point at this context is 34:31, gap 0.11 GiB; 36:29's gap
|
|
# is 3.15 GiB BY CONSTRUCTION, and the operator chose it). The slack is
|
|
# that gap, rounded up: tighter rejects the deployed split, looser stops
|
|
# being a balance check at all.
|
|
slack=$(( 3225 * 1024 * 1024 ))
|
|
card_hi=$(( MODEL_BYTES * split_hi / (split_hi + split_lo) \
|
|
+ kv_pos * ctx * split_hi / (split_hi + split_lo) \
|
|
+ 381898377 \
|
|
+ mmproj \
|
|
+ 1363 * 1024 * ubatch ))
|
|
card_lo=$(( MODEL_BYTES * split_lo / (split_hi + split_lo) \
|
|
+ DRAFT_BYTES \
|
|
+ kv_pos * ctx * split_lo / (split_hi + split_lo) \
|
|
+ draft_kv_pos * (ctx < 2048 ? ctx : 2048) \
|
|
+ 955046413 \
|
|
+ 2468 * 1024 * ubatch ))
|
|
[ "$card_hi" -le $(( CARD_BYTES - floor )) ]
|
|
[ "$card_lo" -le $(( CARD_BYTES - floor )) ]
|
|
diff=$(( card_hi - card_lo )); [ "$diff" -ge 0 ] || diff=$(( -diff ))
|
|
[ "$diff" -le "$slack" ]
|
|
# ...and the window may not wander far past what the checkpoint was
|
|
# trained for (262144; the GGUF carries no rope scaling, so the extra 65k
|
|
# extrapolates on the base rope).
|
|
[ "$ctx" -le 327680 ]
|
|
}
|
|
|
|
@test "vision is enabled: the BF16 projector loads with the model" {
|
|
grep -q '^NO_MMPROJ=0$' "$ENV_FILE"
|
|
grep -q '^MM_PROJ_FILE=mmproj-BF16.gguf$' "$ENV_FILE"
|
|
# The serve script refuses to start without the projector when vision is
|
|
# on, so a missing file is a deploy error, not a silent text-only server.
|
|
grep -q 'vision is enabled but the projector is missing' "$SERVE"
|
|
# And the download script fetches and byte-verifies it.
|
|
grep -q '^MM_PROJ_SIZE=931145888$' "$FETCH"
|
|
grep -q -- 'check "$MODEL_DIR/$MM_PROJ_FILE" "$MM_PROJ_SIZE"' "$FETCH"
|
|
}
|
|
|
|
@test "the fleet endpoint and the predecessor's sampler defaults survive" {
|
|
grep -q '^ALIAS=frank-medium$' "$ENV_FILE"
|
|
grep -q '^LISTEN_PORT=7770$' "$ENV_FILE"
|
|
grep -q -- '--alias "$ALIAS"' "$SERVE"
|
|
grep -q -- '--port "$LISTEN_PORT"' "$SERVE"
|
|
grep -q -- '--host "$BIND_ADDR"' "$SERVE"
|
|
# Prometheus scrapes this endpoint for LLM fleet metrics.
|
|
grep -q -- '--metrics' "$SERVE"
|
|
# Requests that send no sampler fields get these instead of an error.
|
|
grep -q '^TEMPERATURE=0.7$' "$ENV_FILE"
|
|
grep -q '^TOP_P=0.95$' "$ENV_FILE"
|
|
}
|
|
|
|
@test "the deploy handler installs the root unit and refuses a missing payload" {
|
|
grep -q 'deploy_llm_medium' "$REMOTE_DEPLOY"
|
|
grep -q 'relink_unit frank-medium' "$REMOTE_DEPLOY"
|
|
grep -q 'systemctl enable frank-medium.service' "$REMOTE_DEPLOY"
|
|
grep -q 'systemctl restart frank-medium.service' "$REMOTE_DEPLOY"
|
|
grep -q '127.0.0.1:7770/health' "$REMOTE_DEPLOY"
|
|
grep -q 'build-llama-rdna4.sh' "$REMOTE_DEPLOY"
|
|
grep -q 'download-frank-medium-gguf.sh' "$REMOTE_DEPLOY"
|
|
grep -q '\[\[ "$missing" -eq 0 \]\] || return 1' "$REMOTE_DEPLOY"
|
|
}
|
|
|
|
@test "the deploy handler retires the vLLM quadlets and frees the port they held" {
|
|
grep -q 'retire_vllm_medium_quadlets' "$REMOTE_DEPLOY"
|
|
grep -q 'systemctl --user stop frank-medium-a.service frank-medium-b.service' "$REMOTE_DEPLOY"
|
|
grep -q 'wait_port_free 7770' "$REMOTE_DEPLOY"
|
|
# No image left to tag: the runtime is a native binary now.
|
|
# ...and no image work or quadlet install of the predecessor survives.
|
|
! grep -q 'install_quadlet frank-medium-a' "$REMOTE_DEPLOY"
|
|
! grep -q 'podman tag docker.io/stilldeadcode' "$REMOTE_DEPLOY"
|
|
}
|
|
|
|
@test "the retired vLLM deployment is gone from the repo, not merely unmapped" {
|
|
[ ! -d "$REPO_ROOT/dankserver/frank-medium" ]
|
|
[ ! -f "$REPO_ROOT/tests/frank_medium_quadlet.bats" ]
|
|
# ...and the archive is present, so the two assertions above cannot pass
|
|
# because a path went missing rather than because it stayed missing.
|
|
[ -f "$REPO_ROOT/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-a.container" ]
|
|
[ -f "$REPO_ROOT/dankserver/.AGENTTRASH/20260916-vllm-radiance/radiance-entrypoint.sh" ]
|
|
}
|
|
|
|
@test "the serve script refuses to start on an incomplete host" {
|
|
grep -q 'frank-medium: missing\|missing executable runtime\|missing or unreadable' "$SERVE"
|
|
grep -q 'missing or unreadable target model' "$SERVE"
|
|
grep -q 'missing or unreadable draft model' "$SERVE"
|
|
}
|