feat(preflight): compose-dependency + HF_TOKEN + KV-format checks (#37, #47, #219)

Closes the UX gap raised by @snoby on #37 — previously, launching a
compose that needs the DFlash draft model without first running
WITH_DFLASH_DRAFT=1 setup.sh would fail with vLLM's generic
"Invalid repository ID or local directory" pydantic error.

Adds three new preflight functions in scripts/preflight.sh:

1. preflight_hf_token (soft warn) — wired into setup.sh before the model
   download. Catches missing HF_TOKEN early and tells the user exactly
   what to do (visit hf.co/settings/tokens, accept T&C, export the token).
   Skip via PREFLIGHT_NO_HF_TOKEN=1.

2. preflight_compose_deps (hard error) — wired into switch.sh just before
   docker compose up. Parses the target compose YAML for known model-path
   patterns (qwen3.6-27b-dflash, qwen3.6-27b-mtp-head, qwen3.6-27b-autoround-int4)
   and verifies each exists on host. If missing, emits a clear "Fix:" line
   with the exact setup.sh invocation needed (e.g. WITH_DFLASH_DRAFT=1).
   Refuses to proceed with exit 1. Skip via PREFLIGHT_NO_COMPOSE_DEPS=1.

3. preflight_kv_format_hint (soft warn) — wired into switch.sh as the last
   pre-up check. Detects smallest VRAM via nvidia-smi; if <24 GB and the
   target compose uses turboquant_3bit_nc KV, emits the @efschu finding
   from #47 (TQ3 → fp8_e5m2 swap rule) with cross-link to docs/HARDWARE.md
   + a tools/kv-calc.py one-liner to predict the user's specific config.
   Skip via PREFLIGHT_NO_KV_HINT=1.

Tested on this rig:
- HF_TOKEN unset → warns
- Missing DFlash dir → hard errors with WITH_DFLASH_DRAFT=1 hint
- Missing main model → hard errors with generic setup.sh hint
- 24 GB rig + TQ3 compose → silent (correct, TQ3 is right pick)
- Simulated 20 GB rig + TQ3 compose → fires the fp8_e5m2 hint

All preflights are individually skippable via PREFLIGHT_NO_<NAME>=1
env vars (matches existing PREFLIGHT_NO_FETCH=1 / PREFLIGHT_NO_GENESIS_PIN=1
patterns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
noonghunna
2026-05-04 12:59:02 +00:00
parent 4e89c6aa40
commit b6c8708209
3 changed files with 153 additions and 5 deletions

View File

@@ -244,3 +244,146 @@ preflight_repo_drift() {
echo "[preflight] Skip this check: PREFLIGHT_NO_FETCH=1 bash scripts/launch.sh" >&2
return 0
}
# preflight_hf_token — verify HF_TOKEN is set; warn if not.
#
# Soft warning (returns 0) — Qwen3.6-27B is T&C-gated on HuggingFace, so
# missing HF_TOKEN will cause `hf download` to fail with a generic error
# later. Surfacing the issue early saves a round-trip.
#
# Skip via: PREFLIGHT_NO_HF_TOKEN=1
preflight_hf_token() {
if [[ "${PREFLIGHT_NO_HF_TOKEN:-0}" == "1" ]]; then
return 0
fi
if [[ -z "${HF_TOKEN:-}" ]]; then
echo "[preflight] WARNING: HF_TOKEN is not set in the environment." >&2
echo "[preflight] Qwen3.6-27B is T&C-gated on HuggingFace; downloads will fail without a token." >&2
echo "[preflight] Fix: visit https://huggingface.co/settings/tokens, create a read token," >&2
echo "[preflight] accept the model T&C at https://huggingface.co/Qwen/Qwen3-Next-80B-A3B-Instruct" >&2
echo "[preflight] (and any other Qwen3-Next variant you'll use)," >&2
echo "[preflight] then export HF_TOKEN=hf_... in your shell or .env file." >&2
return 0
fi
# Sanity check token format — HF tokens start with hf_ and are 30+ chars
if [[ ! "${HF_TOKEN}" =~ ^hf_ ]] || [[ "${#HF_TOKEN}" -lt 30 ]]; then
echo "[preflight] WARNING: HF_TOKEN doesn't look like a valid HF token (expected 'hf_...' format, 30+ chars)." >&2
echo "[preflight] If downloads fail later, regenerate at https://huggingface.co/settings/tokens" >&2
fi
return 0
}
# preflight_compose_deps <compose_file> — verify any model directories the compose
# expects to mount actually exist on the host. Catches the "you set up the repo
# but didn't WITH_DFLASH_DRAFT=1, then tried to launch dual-dflash-noviz" failure
# mode. See club-3090#37 for the canonical case.
#
# Hard error (returns 1) — refuses to proceed if a required model dir is missing.
# Skip via: PREFLIGHT_NO_COMPOSE_DEPS=1
preflight_compose_deps() {
local compose_file="$1"
if [[ "${PREFLIGHT_NO_COMPOSE_DEPS:-0}" == "1" ]]; then
return 0
fi
if [[ ! -f "$compose_file" ]]; then
echo "[preflight] ERROR: compose file not found: $compose_file" >&2
return 1
fi
local model_dir="${MODEL_DIR:-../../../../models-cache}"
# Resolve relative to repo root (compose mounts use ${MODEL_DIR:-../../../../models-cache})
if [[ "$model_dir" == ../* ]] || [[ "$model_dir" == ./* ]]; then
model_dir="$(cd "${ROOT_DIR:-$(dirname "$0")/..}" && cd "$(dirname "$compose_file")" && cd "$model_dir" 2>/dev/null && pwd)"
fi
local missing=()
local hint_dflash=0
local hint_mtp=0
# Scan compose file for model paths in --speculative-config blocks (DFlash draft, MTP head).
# The compose paths reference the in-container path /root/.cache/huggingface/<subdir>;
# the host path is ${MODEL_DIR}/<subdir> (set via the volumes: block).
if grep -qE '"model":[[:space:]]*"/root/.cache/huggingface/qwen3.6-27b-dflash"' "$compose_file"; then
if [[ ! -f "${model_dir}/qwen3.6-27b-dflash/config.json" ]]; then
missing+=("qwen3.6-27b-dflash (DFlash draft model)")
hint_dflash=1
fi
fi
if grep -qE '"model":[[:space:]]*"/root/.cache/huggingface/qwen3.6-27b-mtp-head"' "$compose_file"; then
if [[ ! -f "${model_dir}/qwen3.6-27b-mtp-head/config.json" ]]; then
missing+=("qwen3.6-27b-mtp-head (MTP draft head)")
hint_mtp=1
fi
fi
# Always check the main model exists (every compose mounts it).
if [[ ! -f "${model_dir}/qwen3.6-27b-autoround-int4/config.json" ]]; then
missing+=("qwen3.6-27b-autoround-int4 (main model)")
fi
if [[ ${#missing[@]} -eq 0 ]]; then
return 0
fi
echo "[preflight] ERROR: compose '$compose_file' expects model files that aren't on host." >&2
for item in "${missing[@]}"; do
echo "[preflight] missing: ${model_dir}/${item}" >&2
done
echo "[preflight]" >&2
echo "[preflight] Fix:" >&2
if [[ $hint_dflash -eq 1 ]]; then
echo "[preflight] WITH_DFLASH_DRAFT=1 bash scripts/setup.sh qwen3.6-27b" >&2
echo "[preflight] (downloads z-lab/Qwen3.6-27B-DFlash, ~1.75 GB; required for dual-dflash* composes)" >&2
fi
if [[ $hint_mtp -eq 1 ]]; then
echo "[preflight] bash scripts/setup.sh qwen3.6-27b (re-run with the right flags for MTP head)" >&2
fi
if [[ $hint_dflash -eq 0 ]] && [[ $hint_mtp -eq 0 ]]; then
echo "[preflight] bash scripts/setup.sh qwen3.6-27b" >&2
fi
echo "[preflight] Skip this check: PREFLIGHT_NO_COMPOSE_DEPS=1 bash scripts/switch.sh ..." >&2
return 1
}
# preflight_kv_format_hint <compose_file> — soft warning if the target compose
# uses a KV format known to be sub-optimal for the user's VRAM class.
#
# Specifically: dual-turbo.yml uses turboquant_3bit_nc which trips Cliff 2 at 90K
# on 20 GB Ampere even on TP=2. See docs/HARDWARE.md + #47 for the cross-rig data.
#
# Soft warning (returns 0). Skip via: PREFLIGHT_NO_KV_HINT=1
preflight_kv_format_hint() {
local compose_file="$1"
if [[ "${PREFLIGHT_NO_KV_HINT:-0}" == "1" ]]; then
return 0
fi
if [[ ! -f "$compose_file" ]] || ! command -v nvidia-smi >/dev/null 2>&1; then
return 0
fi
# Detect smallest VRAM among visible cards (the TP-split ceiling).
local min_vram_mib
min_vram_mib="$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | sort -n | head -1)"
if [[ -z "$min_vram_mib" ]] || [[ "$min_vram_mib" -ge 24000 ]]; then
return 0 # 24 GB+ cards — TQ3 is the right pick, no hint needed
fi
local vram_gb=$((min_vram_mib / 1024))
# Only fire on TQ3-using composes — that's where the 20 GB swap matters.
if grep -qE -- '--kv-cache-dtype[[:space:]]*\n?[[:space:]]*-?[[:space:]]*turboquant_3bit_nc' "$compose_file" 2>/dev/null; then
:
elif grep -qE 'turboquant_3bit_nc' "$compose_file"; then
:
else
return 0 # not a TQ3 compose
fi
echo "[preflight] HINT: smallest GPU has ~${vram_gb} GB VRAM and target compose uses TurboQuant 3-bit KV." >&2
echo "[preflight] On <24 GB Ampere, TQ3's activation peak during DeltaNet GDN forward exceeds" >&2
echo "[preflight] the per-card budget after TP split, and Cliff 2 fires at ~90K." >&2
echo "[preflight] Override with --kv-cache-dtype fp8_e5m2 in the compose file." >&2
echo "[preflight] Cross-rig validation: docs/HARDWARE.md + club-3090#47" >&2
echo "[preflight] Predict your config: bash tools/kv-calc.py --compose <name> --vram ${vram_gb} --kv-format <fp8_e5m2|turboquant_3bit_nc>" >&2
return 0
}

View File

@@ -82,6 +82,7 @@ echo "[preflight] checking environment..."
preflight_docker || exit 1
preflight_gpu 1 || exit 1
preflight_disk "${MODEL_DIR}" "${PREFLIGHT_DISK_GB}" || exit 1
preflight_hf_token # soft-warn only; downloads will surface the hard failure
echo "[preflight] ok."
echo ""

View File

@@ -149,16 +149,20 @@ up_variant() {
exit 1
fi
# Pre-up sanity: warn if the on-disk Genesis tree is out of sync with
# the GENESIS_PIN declared in setup.sh (catches "user pulled latest but
# didn't re-run setup.sh") AND if the repo itself is behind origin/master
# (catches "user cloned weeks ago, never pulled"). Both soft-warn via
# stderr and don't block boot. See club-3090#32 for the original case.
# Pre-up sanity:
# - genesis_pin: warn if on-disk Genesis tree differs from GENESIS_PIN in setup.sh
# - repo_drift: warn if local HEAD is behind origin/master
# - compose_deps: HARD error if compose mounts a model dir that doesn't exist on host
# (catches the "you didn't WITH_DFLASH_DRAFT=1 then tried dual-dflash-noviz" case;
# see club-3090#37 — this is the canonical fix raphael / snoby asked for)
# - kv_format_hint: soft warn if VRAM class needs --kv-cache-dtype override (#47)
if [[ -f "${ROOT_DIR}/scripts/preflight.sh" ]]; then
# shellcheck source=preflight.sh
source "${ROOT_DIR}/scripts/preflight.sh"
preflight_genesis_pin "${ROOT_DIR}" || true
preflight_repo_drift "${ROOT_DIR}" || true
preflight_compose_deps "${full_dir}/${file}" || exit 1
preflight_kv_format_hint "${full_dir}/${file}" || true
fi
echo "[switch] bringing up: ${v} (${dir}/${file})"