Files
club-3090/scripts/preflight.sh
noonghunna 8ae205bcce preflight: warn on single-card GPU_MEMORY_UTILIZATION override above default (#641)
* baselines: backfill dual-max quality_8pk_think_on (110/150)

The vllm/qwen-27b-dual-max row logged only the thinking-off 8-pack
(107/150); the thinking-enabled run existed since the 2026-06-07 #515
quant-A/B (fp8 dual-max full 8-pack = 110/150, pack-defaults regime,
autoround sibling 109) but was never promoted. Backfilled with
provenance so it isn't re-derived. Guards: test-baselines,
test-quality-thinking, test-quality-baseline all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfF565T9eSLaqGzidyJ1Pm

* preflight: warn on single-card GPU_MEMORY_UTILIZATION override above default

Two 5090 testers (#617 paul, and the same trap earlier) OOMed the tool-prefill
step of verify-stress by setting GPU_MEMORY_UTILIZATION=0.92 on the nvfp4
single-card slug whose validated default is 0.85. On one GPU a higher util
steals the free VRAM a large tool-response prefill needs for its activation
peak, so vLLM OOMs mid-prefill even though boot succeeds — a confusing failure
(boot ok, then HTTP 500 at step 2/8) that vLLM's own error hints at ("lower
--gpu-memory-utilization").

Add preflight_single_card_util(): an advisory WARN that fires only when the
user overrode GPU_MEMORY_UTILIZATION *above* the compose's shipped default on a
TP<=1 config. Runs even under --force (the nvfp4 slug launches with --force),
and no-ops for dual/multi-card, non-vLLM engines (no util default in the
compose), a plain default run, or util at/below the default. Wired into
switch.sh alongside the other force-independent hints.

Verified: behavior matrix (single>default WARN; single=default/below/none and
dual all silent) + test-preflight-compose-deps / test-model-switch /
test-switch-registry-parity / test-launch-compat all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfF565T9eSLaqGzidyJ1Pm

---------

Co-authored-by: noonghunna <10742901+noonghunna@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:18:33 +05:00

1427 lines
61 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# Pre-flight checks library. Sourced by setup.sh and launch.sh — not run
# directly. Functions return 0 on success, 1 on failure (caller decides
# whether to exit). Soft warnings print and return 0.
#
# Functions:
# preflight_docker — docker binary + 'docker compose' subcommand work
# preflight_gpu [min] — nvidia-smi works, GPU detected, count >= min
# preflight_disk <path> <gb>— free space at path covers <gb> gigabytes
# preflight_gpu_idle — warn if GPUs have significant VRAM already in use
# preflight_running — warn if a club-3090 container is already up
# preflight_genesis_pin — warn if on-disk Genesis tree differs from setup.sh's pin
# preflight_repo_drift — warn if local HEAD is behind origin/master
# preflight_compose_hardware— check compose VRAM/GPU-count/SM metadata
#
# Style: each function prints one or more "[preflight] ..." lines.
# Hard failures get a one-line ERROR + a "Fix:" hint.
# Avoid double-sourcing.
[[ -n "${_PREFLIGHT_LOADED:-}" ]] && return 0
_PREFLIGHT_LOADED=1
_PREFLIGHT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "${_PREFLIGHT_DIR}/lib/compose-meta.sh" ]]; then
# shellcheck source=lib/compose-meta.sh
source "${_PREFLIGHT_DIR}/lib/compose-meta.sh"
fi
preflight_docker() {
if ! command -v docker >/dev/null 2>&1; then
echo "[preflight] ERROR: 'docker' not found in PATH." >&2
echo " Fix: install Docker — https://docs.docker.com/engine/install/" >&2
return 1
fi
if ! docker compose version >/dev/null 2>&1; then
echo "[preflight] ERROR: 'docker compose' subcommand not available." >&2
echo " Fix: install Docker Compose v2 plugin (legacy 'docker-compose' is unsupported)." >&2
return 1
fi
if ! docker info >/dev/null 2>&1; then
echo "[preflight] ERROR: 'docker info' failed — daemon not running or no permission." >&2
echo " Fix: 'sudo systemctl start docker' OR add your user to the 'docker' group" >&2
echo " ('sudo usermod -aG docker \$USER' + log out/in)." >&2
return 1
fi
echo "[preflight] docker: $(docker --version | awk '{print $3}' | tr -d ',') (compose v2 ok)"
return 0
}
preflight_gpu() {
local min_count="${1:-1}"
if ! command -v nvidia-smi >/dev/null 2>&1; then
echo "[preflight] ERROR: 'nvidia-smi' not found — no NVIDIA driver detected." >&2
echo " Fix: install NVIDIA driver R550+ (CUDA 12.4+)." >&2
return 1
fi
local gpu_lines
gpu_lines=$(nvidia-smi -L 2>/dev/null || true)
local gpu_count
gpu_count=$(echo "$gpu_lines" | grep -c '^GPU ' || true)
if [[ "$gpu_count" -lt "$min_count" ]]; then
echo "[preflight] ERROR: needs ${min_count} GPU(s), found ${gpu_count}." >&2
if [[ "$gpu_count" -eq 0 ]]; then
echo " Fix: confirm 'nvidia-smi' lists your GPU(s); check driver/PCIe wiring." >&2
else
echo " Fix: pick a single-card variant, or install/wire the second GPU." >&2
fi
return 1
fi
echo "[preflight] gpu: ${gpu_count}× detected"
echo "$gpu_lines" | sed 's/^/[preflight] /'
# GPU arch class (display-only, #246). The launchers inject arch-aware env
# (KV dtype) from the hardware profiles; this banner just names the class.
local _cap _arch_class
_cap="$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -d '[:space:]')"
if [[ -n "$_cap" ]]; then
case "$_cap" in
8.6|8.7) _arch_class="ampere" ;;
8.9) _arch_class="ada" ;;
9.*) _arch_class="hopper" ;;
1[0-9].*) _arch_class="blackwell" ;;
*) _arch_class="unknown" ;;
esac
if [[ "$_arch_class" == "ampere" || "$_arch_class" == "unknown" ]]; then
echo "[preflight] arch: ${_arch_class} (sm_${_cap}) — compose defaults apply (no arch-aware override)"
else
echo "[preflight] arch: ${_arch_class} (sm_${_cap}) — arch-aware KV defaults active for pilot slugs (#246)"
fi
fi
# Interconnect capability (display-only; the engagement VERDICT lives in
# report.sh — pre-boot there is no container to audit). Silent on
# single-GPU / stock-PCIe rigs so the line is always signal.
if [[ "$gpu_count" -ge 2 ]]; then
local _p2p_cap=""
# shellcheck source=lib/p2p-state.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib/p2p-state.sh" 2>/dev/null && \
_p2p_cap="$(p2p_host_capability "$gpu_count")"
case "$_p2p_cap" in
nvlink)
if [[ "${NVLINK_MODE:-auto}" == "force_off" ]]; then
echo "[preflight] p2p: NVLink bridge detected but NVLINK_MODE=force_off — the bridge will sit idle this boot (~15% decode, BENCHMARKS #77)"
else
echo "[preflight] p2p: NVLink bridge detected — launcher auto-engages it (NVLINK_MODE=${NVLINK_MODE:-auto})"
fi ;;
pcie_p2p)
echo "[preflight] p2p: driver reports PCIe P2P available — launcher auto-engages it (patched-driver path, NVLINK_MODE=${NVLINK_MODE:-auto}; docs/PCIE_P2P.md)" ;;
esac
fi
# Cross-rig friendliness: surface a hint when 4090 / 5090 cards are
# detected. Composes run cross-rig but per-class gotchas (ctx derate,
# VRAM envelope, SM-gated kernels) live in the FAQ — easier to catch
# the hint here than for a user to discover it after a confusing run.
if echo "$gpu_lines" | grep -qE "RTX 4090"; then
echo "[preflight] note: 4090 detected → docs/FAQ.md#can-i-use-a-4090-instead-of-a-3090 (ctx ceiling ~1520% lower than headless 3090)"
fi
if echo "$gpu_lines" | grep -qE "RTX 5090"; then
echo "[preflight] note: 5090 detected → docs/FAQ.md#can-i-use-a-5090 (32 GB envelope unlocks single-card configs)"
fi
# nvidia-container-toolkit check — needed for docker GPU access.
if ! docker info 2>/dev/null | grep -qi 'Runtimes:.*nvidia'; then
echo "[preflight] WARN: Docker doesn't list the 'nvidia' runtime. If 'docker compose up' fails" >&2
echo " with 'unknown runtime' or 'could not select device driver', install:" >&2
echo " https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/" >&2
fi
return 0
}
_preflight_trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
_preflight_csv_token() {
local value="$1"
value="$(_preflight_trim "$value")"
printf '%s' "$value"
}
# UUID→index normalization (#610): launch.sh --gpus exports GPU UUIDs (the
# runtime-agnostic form — CDI ignores NVIDIA_VISIBLE_DEVICES, and indices
# renumber inside classic-runtime containers). Preflight's checks are all
# host-index-based, so translate any GPU-xxxx token back to its host index
# here — ONE choke point for every selector consumer. Unknown tokens pass
# through untouched (they fail the match downstream with the existing error).
_preflight_selector_normalize() {
local selector="$1" token out="" idx
[[ "$selector" != *GPU-* ]] && { printf '%s' "$selector"; return 0; }
local uuid_map
uuid_map="$(nvidia-smi --query-gpu=index,uuid --format=csv,noheader,nounits 2>/dev/null || true)"
IFS=',' read -ra _pf_norm_tokens <<< "$selector"
for token in "${_pf_norm_tokens[@]}"; do
token="$(_preflight_trim "$token")"
if [[ "$token" == GPU-* && -n "$uuid_map" ]]; then
idx="$(awk -F', *' -v u="$token" '$2 == u {print $1; exit}' <<< "$uuid_map")"
[[ -n "$idx" ]] && token="$idx"
fi
out="${out:+${out},}${token}"
done
printf '%s' "$out"
}
_preflight_selector() {
local raw=""
if [[ -n "${CLUB3090_GPU:-}" ]]; then
raw="${CLUB3090_GPU}"
elif [[ -n "${NVIDIA_VISIBLE_DEVICES:-}" && "${NVIDIA_VISIBLE_DEVICES}" != "all" && "${NVIDIA_VISIBLE_DEVICES}" != "void" ]]; then
raw="${NVIDIA_VISIBLE_DEVICES}"
elif [[ -n "${CUDA_VISIBLE_DEVICES:-}" && "${CUDA_VISIBLE_DEVICES}" != "all" && "${CUDA_VISIBLE_DEVICES}" != "void" ]]; then
raw="${CUDA_VISIBLE_DEVICES}"
fi
[[ -n "$raw" ]] && _preflight_selector_normalize "$raw"
}
_preflight_selector_is_specific() {
local selector="${1:-}"
[[ -n "$selector" && "$selector" != "all" && "$selector" != "void" ]]
}
_preflight_selector_allows_index() {
local selector="$1"
local idx="$2"
local token
if ! _preflight_selector_is_specific "$selector"; then
return 0
fi
IFS=',' read -ra _preflight_selector_tokens <<< "$selector"
for token in "${_preflight_selector_tokens[@]}"; do
token="$(_preflight_trim "$token")"
[[ "$token" == "$idx" ]] && return 0
done
return 1
}
_preflight_selector_first_numeric() {
local selector="$1"
local token
IFS=',' read -ra _preflight_selector_tokens <<< "$selector"
for token in "${_preflight_selector_tokens[@]}"; do
token="$(_preflight_trim "$token")"
if [[ "$token" =~ ^[0-9]+$ ]]; then
printf '%s' "$token"
return 0
fi
done
return 1
}
_preflight_sm_to_int() {
local sm="$1"
sm="${sm%%+}"
sm="${sm//sm_/}"
sm="${sm//SM_/}"
sm="${sm// /}"
[[ -z "$sm" ]] && { echo 0; return; }
local major minor
if [[ "$sm" == *.* ]]; then
major="${sm%%.*}"
minor="${sm#*.}"
else
major="$sm"
minor="0"
fi
major="${major//[^0-9]/}"
minor="${minor//[^0-9]/}"
[[ -z "$major" ]] && major=0
[[ -z "$minor" ]] && minor=0
if [[ "${#minor}" -eq 1 ]]; then
minor=$(( minor * 10 ))
else
minor="${minor:0:2}"
[[ -z "$minor" ]] && minor=0
fi
echo $(( major * 100 + minor ))
}
_preflight_vram_gb() {
local mib="$1"
echo $(( (mib + 1023) / 1024 ))
}
_preflight_hardware_suggestions() {
local variant="${1:-}"
echo "[preflight]" >&2
echo "[preflight] Suggested next steps:" >&2
echo "[preflight] - Pick a compose that matches the detected GPU VRAM/topology." >&2
if [[ "$variant" == vllm/gemma-mtp-tp1 ]]; then
echo "[preflight] - vllm/gemma-mtp-tp1 is DEPRECATED (no fp8 KV path for Gemma 4 on Ampere sm_86)." >&2
echo "[preflight] - Single 24 GB card, use: bash scripts/switch.sh beellama/gemma-dflash" >&2
echo "[preflight] - On 2x 24 GB cards, use: bash scripts/switch.sh vllm/gemma-31b-dual" >&2
fi
echo "[preflight] - On a single 24 GB card, start with: bash scripts/switch.sh beellama/dflash (single-card default)" >&2
echo "[preflight] - For maximum compatibility, use: bash scripts/switch.sh llamacpp/default" >&2
echo "[preflight] - Explicit bypass: bash scripts/switch.sh --force ${variant:-<variant>}" >&2
}
# preflight_compose_hardware <compose_file> [variant] [force]
#
# Reads compose header metadata and checks the target host before docker compose
# starts. This is intentionally conservative:
# - Missing metadata warns and allows the boot.
# - TP=1 composes auto-select the largest eligible GPU unless the user set
# CLUB3090_GPU, CUDA_VISIBLE_DEVICES, or NVIDIA_VISIBLE_DEVICES.
# - TP>=2 composes hard-fail only on insufficient GPU count or hard SM gates;
# heterogeneous VRAM below the requested floor warns because advanced users
# may be validating sub-24 GB configs with tuned memory-utilization.
preflight_compose_hardware() {
local compose_file="$1"
local variant="${2:-}"
local force="${3:-${FORCE:-0}}"
if [[ "${PREFLIGHT_NO_HARDWARE:-0}" == "1" ]]; then
return 0
fi
if [[ "$force" == "1" || "${FORCE:-0}" == "1" ]]; then
echo "[preflight] hardware: skipped (--force/FORCE=1)"
return 0
fi
if [[ ! -f "$compose_file" ]]; then
echo "[preflight] ERROR: compose file not found: $compose_file" >&2
return 1
fi
if ! command -v nvidia-smi >/dev/null 2>&1; then
echo "[preflight] WARN: nvidia-smi not found; skipping compose hardware metadata check." >&2
return 0
fi
if ! declare -F compose_meta_get >/dev/null 2>&1; then
echo "[preflight] WARN: compose metadata parser unavailable; skipping hardware metadata check." >&2
return 0
fi
local min_vram_gb min_gpu_count tp requires_sm
min_vram_gb="$(compose_meta_get "$compose_file" requires-min-vram-gb || true)"
min_gpu_count="$(compose_meta_get "$compose_file" requires-min-gpu-count || true)"
tp="$(compose_meta_get "$compose_file" tensor-parallel || true)"
requires_sm="$(compose_meta_get "$compose_file" requires-sm || true)"
if [[ -z "$min_vram_gb" || -z "$min_gpu_count" || -z "$tp" ]]; then
echo "[preflight] WARN: compose has no hardware metadata; allowing boot: $compose_file" >&2
return 0
fi
requires_sm="${requires_sm:-0.0}"
local required_sm_int
required_sm_int="$(_preflight_sm_to_int "$requires_sm")"
local gpu_query
gpu_query="$(nvidia-smi --query-gpu=index,name,memory.total,compute_cap --format=csv,noheader,nounits 2>/dev/null || true)"
if [[ -z "$gpu_query" ]]; then
echo "[preflight] WARN: could not query GPU VRAM/SM via nvidia-smi; skipping hardware metadata check." >&2
return 0
fi
local selector
selector="$(_preflight_selector || true)"
local total_count=0 selected_count=0 eligible_count=0 selected_below_vram=0 selected_below_sm=0
local best_idx="" best_name="" best_mib=0 best_sm=""
local first_idx="" first_name="" first_mib=0 first_sm=""
local idx name mem_mib sm rest vram_gb sm_int
while IFS=',' read -r idx name mem_mib sm rest; do
idx="$(_preflight_csv_token "$idx")"
name="$(_preflight_csv_token "$name")"
mem_mib="$(_preflight_csv_token "$mem_mib")"
sm="$(_preflight_csv_token "$sm")"
[[ -z "$idx" || -z "$mem_mib" ]] && continue
total_count=$(( total_count + 1 ))
_preflight_selector_allows_index "$selector" "$idx" || continue
selected_count=$(( selected_count + 1 ))
if [[ -z "$first_idx" ]]; then
first_idx="$idx"
first_name="$name"
first_mib="$mem_mib"
first_sm="$sm"
fi
vram_gb="$(_preflight_vram_gb "$mem_mib")"
sm_int="$(_preflight_sm_to_int "$sm")"
if (( vram_gb < min_vram_gb )); then
selected_below_vram=1
fi
if (( sm_int < required_sm_int )); then
selected_below_sm=1
fi
if (( vram_gb >= min_vram_gb && sm_int >= required_sm_int )); then
eligible_count=$(( eligible_count + 1 ))
if (( mem_mib > best_mib )); then
best_idx="$idx"
best_name="$name"
best_mib="$mem_mib"
best_sm="$sm"
fi
fi
done <<< "$gpu_query"
if (( total_count == 0 )); then
echo "[preflight] ERROR: no NVIDIA GPUs detected." >&2
_preflight_hardware_suggestions "$variant"
return 1
fi
if (( selected_count == 0 )); then
echo "[preflight] ERROR: GPU selector '${selector}' did not match any detected GPU index." >&2
_preflight_hardware_suggestions "$variant"
return 1
fi
local requires_sm_display="${requires_sm%%+}"
local sm_label=""
if (( required_sm_int > 0 )); then
sm_label=", sm_${requires_sm_display}+"
fi
if (( tp <= 1 )); then
if _preflight_selector_is_specific "$selector"; then
local first_vram_gb first_sm_int
first_vram_gb="$(_preflight_vram_gb "$first_mib")"
first_sm_int="$(_preflight_sm_to_int "$first_sm")"
if (( first_vram_gb < min_vram_gb || first_sm_int < required_sm_int )); then
echo "[preflight] ERROR: ${variant:-compose} requires one GPU with >=${min_vram_gb} GB VRAM${sm_label}." >&2
echo "[preflight] Explicit selector '${selector}' starts with GPU ${first_idx}: ${first_name}, ${first_vram_gb} GB, sm_${first_sm}." >&2
_preflight_hardware_suggestions "$variant"
return 1
fi
export CLUB3090_GPU="${CLUB3090_GPU:-$selector}"
export CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-$selector}"
export NVIDIA_VISIBLE_DEVICES="${NVIDIA_VISIBLE_DEVICES:-$selector}"
echo "[preflight] hardware: ${variant:-compose} TP=1 requires >=${min_vram_gb} GB${sm_label}; using explicit GPU ${first_idx} (${first_vram_gb} GB, sm_${first_sm})"
return 0
fi
if (( eligible_count == 0 )); then
echo "[preflight] ERROR: ${variant:-compose} requires one GPU with >=${min_vram_gb} GB VRAM${sm_label}; none found." >&2
echo "[preflight] Detected GPUs:" >&2
while IFS=',' read -r idx name mem_mib sm rest; do
idx="$(_preflight_csv_token "$idx")"
name="$(_preflight_csv_token "$name")"
mem_mib="$(_preflight_csv_token "$mem_mib")"
sm="$(_preflight_csv_token "$sm")"
[[ -z "$idx" || -z "$mem_mib" ]] && continue
echo "[preflight] GPU ${idx}: ${name}, $(_preflight_vram_gb "$mem_mib") GB, sm_${sm}" >&2
done <<< "$gpu_query"
_preflight_hardware_suggestions "$variant"
return 1
fi
export CLUB3090_GPU="$best_idx"
export CUDA_VISIBLE_DEVICES="$best_idx"
export NVIDIA_VISIBLE_DEVICES="$best_idx"
echo "[preflight] hardware: ${variant:-compose} TP=1 requires >=${min_vram_gb} GB${sm_label}; auto-selected GPU ${best_idx} ($(_preflight_vram_gb "$best_mib") GB, sm_${best_sm})"
return 0
fi
if (( selected_count < min_gpu_count )); then
echo "[preflight] ERROR: ${variant:-compose} requires ${min_gpu_count} visible GPU(s) for TP=${tp}; found ${selected_count}." >&2
_preflight_hardware_suggestions "$variant"
return 1
fi
if (( selected_below_sm == 1 )); then
echo "[preflight] ERROR: ${variant:-compose} requires sm_${requires_sm_display}+ on visible GPUs." >&2
while IFS=',' read -r idx name mem_mib sm rest; do
idx="$(_preflight_csv_token "$idx")"
name="$(_preflight_csv_token "$name")"
mem_mib="$(_preflight_csv_token "$mem_mib")"
sm="$(_preflight_csv_token "$sm")"
_preflight_selector_allows_index "$selector" "$idx" || continue
echo "[preflight] GPU ${idx}: ${name}, $(_preflight_vram_gb "$mem_mib") GB, sm_${sm}" >&2
done <<< "$gpu_query"
_preflight_hardware_suggestions "$variant"
return 1
fi
if (( selected_below_vram == 1 )); then
echo "[preflight] WARN: ${variant:-compose} requires >=${min_vram_gb} GB per visible GPU for TP=${tp}, but at least one selected GPU is smaller." >&2
echo "[preflight] Continuing because TP>=2 sub-24 GB rigs may use tuned gpu-memory-utilization/KV settings." >&2
fi
echo "[preflight] hardware: ${variant:-compose} TP=${tp} requires ${min_gpu_count} GPU(s), >=${min_vram_gb} GB each${sm_label}; ${selected_count} visible GPU(s) detected"
return 0
}
# preflight_lmcache_ram <compose_file>
# Guards LMCache composes against over-allocating CPU RAM for the L1 KV cache.
# Runs REGARDLESS of --force — host-choke prevention is NOT optional: an over-sized
# --l1-size-gb (100 GB on a 94 GB rig) once exhausted RAM and forced a server reboot
# (club-3090 #133). No-op for composes without an
# `LMCache-l1-gb` metadata header, so it's safe to call on every launch.
preflight_lmcache_ram() {
local compose_file="$1"
[[ -f "$compose_file" ]] || return 0
declare -F compose_meta_get >/dev/null 2>&1 || return 0
local l1_hdr
l1_hdr="$(compose_meta_get "$compose_file" lmcache-l1-gb || true)"
[[ -z "$l1_hdr" ]] && return 0 # not an LMCache compose
# Env override (LMCACHE_L1_GB) wins over the header default — the guard must track
# what the container will actually request.
local l1="${LMCACHE_L1_GB:-$l1_hdr}"
if ! [[ "$l1" =~ ^[0-9]+$ ]]; then
echo "[preflight] WARN: LMCACHE_L1_GB='${l1}' is not an integer; skipping LMCache RAM check." >&2
return 0
fi
local reserve=28 # GB headroom for the vLLM process (27B @262K TP=2) + OS
local need=$(( l1 + reserve ))
if [[ ! -r /proc/meminfo ]]; then
echo "[preflight] WARN: cannot read /proc/meminfo; skipping LMCache RAM check." >&2
return 0
fi
local kb avail_gb
kb="$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)"
avail_gb=$(( kb / 1024 / 1024 ))
if (( avail_gb < need )); then
local suggest=$(( avail_gb > reserve ? avail_gb - reserve : 8 ))
echo "[preflight] ERROR: LMCache --l1-size-gb=${l1} needs ~${need} GB RAM (l1 ${l1} + ~${reserve} GB vLLM+OS), but only ${avail_gb} GB is available." >&2
echo " (Guard against the l1-too-large host-OOM: a 100 GB cache on this 94 GB rig forced a reboot.)" >&2
echo " Fix: lower the cache — LMCACHE_L1_GB=${suggest} bash scripts/switch.sh ... — or free RAM." >&2
return 1
fi
# Soft: SHM must be >= l1 or LMCache silently falls back to slow pickle serialization.
local shm_gb
shm_gb="$(grep -oE 'shm_size:[[:space:]]*"?[0-9]+' "$compose_file" | grep -oE '[0-9]+' | head -1 || true)"
if [[ -n "$shm_gb" ]] && (( shm_gb < l1 )); then
echo "[preflight] WARN: shm_size (${shm_gb}g) < LMCACHE_L1_GB (${l1}) — LMCache SHM will fall back to slow pickle." >&2
echo " Fix: raise shm_size in the compose to >= ${l1}g." >&2
fi
echo "[preflight] lmcache: RAM ok — l1=${l1} GB needs ~${need} GB, ${avail_gb} GB available"
# L2 disk tier (optional) — soft-warn on low free space at the L2 host dir. L2 is OFF unless
# LMCACHE_L2=1 or LMCACHE_L2_ADAPTER is set; it grows ~33 GB per full 262K session (~131 KB/token,
# measured) and is unbounded, so a small disk can fill. Warn, don't fail — the user opted in.
if [[ -n "${LMCACHE_L2_ADAPTER:-}" || "${LMCACHE_L2:-0}" == "1" ]]; then
local l2dir="${LMCACHE_KV_DIR:-}"
if [[ -z "$l2dir" ]]; then
# default host dir = repo-root lmcache-kv/ (this compose sits 6 levels below the repo root)
l2dir="$(cd "$(dirname "$compose_file")/../../../../../.." 2>/dev/null && pwd)/lmcache-kv"
fi
local chkdir="$l2dir"; [[ -d "$chkdir" ]] || chkdir="$(dirname "$l2dir")"
local l2_avail_gb
l2_avail_gb="$(df -BG "$chkdir" 2>/dev/null | awk 'NR==2{gsub(/G/,"",$4); print $4}')"
if [[ -n "$l2_avail_gb" ]] && (( l2_avail_gb < 50 )); then
echo "[preflight] WARN: LMCache L2 enabled, only ${l2_avail_gb} GB free at ${l2dir}." >&2
echo " L2 is unbounded and grows ~33 GB per full 262K session — free space or point LMCACHE_L2_ADAPTER at a larger SSD." >&2
elif [[ -n "$l2_avail_gb" ]]; then
echo "[preflight] lmcache: L2 disk ok — ${l2_avail_gb} GB free at ${l2dir}"
fi
fi
return 0
}
preflight_disk() {
local path="$1"
local need_gb="$2"
# Walk up to find an existing parent (path may not exist yet).
while [[ -n "$path" && ! -d "$path" ]]; do
path="$(dirname "$path")"
done
local avail_kb
avail_kb=$(df -Pk "$path" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ -z "$avail_kb" ]]; then
echo "[preflight] WARN: could not check free space at ${path}" >&2
return 0
fi
local avail_gb=$(( avail_kb / 1024 / 1024 ))
if [[ "$avail_gb" -lt "$need_gb" ]]; then
echo "[preflight] ERROR: only ${avail_gb} GB free at ${path}, need ~${need_gb} GB." >&2
echo " Fix: free space, or set MODEL_DIR=<path-on-larger-volume> and re-run." >&2
return 1
fi
echo "[preflight] disk: ${avail_gb} GB free at ${path} (need ~${need_gb} GB)"
return 0
}
preflight_gpu_idle() {
command -v nvidia-smi >/dev/null 2>&1 || return 0
local mem_used_lines
mem_used_lines=$(nvidia-smi --query-gpu=index,memory.used --format=csv,noheader,nounits 2>/dev/null || true)
[[ -z "$mem_used_lines" ]] && return 0
local warned=0
while IFS=, read -r idx used; do
used=$(echo "$used" | tr -d ' ')
# Threshold: 1 GiB. Below that is desktop / X server / kernel modules — fine.
if [[ "$used" -gt 1024 ]]; then
if [[ $warned -eq 0 ]]; then
echo "[preflight] WARN: GPU(s) already have significant VRAM in use:" >&2
warned=1
fi
echo "[preflight] GPU $idx: ${used} MiB in use" >&2
fi
done <<< "$mem_used_lines"
if [[ $warned -eq 1 ]]; then
echo "[preflight] Boot may OOM. Free VRAM with 'nvidia-smi' / 'docker stop ...' first." >&2
fi
return 0
}
# preflight_compose_gpu_fit <compose_file> <force>
# HARD-fail (unless force=1) when the GPUs lack enough FREE VRAM for the compose's
# gpu_memory_utilization. vLLM aborts at boot if `free < util × total`; with
# restart:unless-stopped it then restart-loops, so `switch.sh` waits the full
# READY_TIMEOUT (600s) for an endpoint that never comes. This converts that silent
# timeout into an instant, actionable error — the failure @stage-chuk hit switching
# ai-studio → gemma on a desktop rig (club-3090 #535): GNOME on the GPUs (~1.3 GiB/card)
# leaves < the 0.95 budget free. We mirror vLLM's own check (util × total × 0.98 ≈ its
# mem_get_info total, i.e. nvidia-smi total minus ~2% driver-reserved) and briefly retry,
# because `docker compose down` returns before CUDA actually releases a torn-down scene.
preflight_compose_gpu_fit() {
local compose="$1" force="${2:-0}"
command -v nvidia-smi >/dev/null 2>&1 || return 0
[[ -f "$compose" ]] || return 0
# Effective util: an env GPU_MEMORY_UTILIZATION override wins over the compose default
# (`${GPU_MEMORY_UTILIZATION:-<X>}`), so the gate matches what vLLM will actually use.
local util_default util
util_default=$(grep -oE 'GPU_MEMORY_UTILIZATION:-[0-9.]+' "$compose" | head -1 | sed 's/.*-//')
util="${GPU_MEMORY_UTILIZATION:-$util_default}"
case "$util" in ''|*[!0-9.]*) return 0 ;; esac # unknown / non-numeric → can't gate
# Cards this compose uses (TP / min-gpu-count header; default 1).
local need_cards
need_cards=$(grep -oiE '#[[:space:]]*(Tensor-parallel|Requires-min-gpu-count):[[:space:]]*[0-9]+' "$compose" \
| grep -oE '[0-9]+' | sort -rn | head -1)
[[ -z "$need_cards" ]] && need_cards=1
# Settle window (~10s): a just-`down`ed scene's VRAM lags docker's return.
local attempt result idx fg ng fits
for attempt in 1 2 3 4 5; do
result=$(nvidia-smi --query-gpu=index,memory.total,memory.free --format=csv,noheader,nounits 2>/dev/null \
| awk -F, -v util="$util" -v n="$need_cards" '
{ gi[NR]=$1+0; f[NR]=$3+0; d[NR]=util*$2*0.98 }
END {
if (NR==0) { print "-1 0 0 1"; exit } # no cards visible → skip
for (i=1;i<=NR;i++) ord[i]=i # sort card rows by free desc
for (i=1;i<=NR;i++) for (j=i+1;j<=NR;j++) if (f[ord[j]]>f[ord[i]]) { t=ord[i]; ord[i]=ord[j]; ord[j]=t }
k=(n<NR ? n : NR); fits=1; wf=1e18; wn=0; wi=-1 # check the k best cards; report the worst
for (i=1;i<=k;i++){ c=ord[i]; if (f[c]<d[c]) fits=0; if (f[c]<wf){ wf=f[c]; wn=d[c]; wi=gi[c] } }
printf "%d %.1f %.1f %d", wi, wf/1024, wn/1024, fits
}')
read -r idx fg ng fits <<< "$result"
[[ "$fits" == "1" ]] && return 0
[[ "$attempt" -lt 5 ]] && sleep 2
done
echo "[preflight] ERROR: GPU ${idx} has ${fg} GiB free, but this config needs ~${ng} GiB/card" >&2
echo "[preflight] (gpu_memory_utilization=${util}, ${need_cards} card(s) for TP). Something else is holding VRAM —" >&2
echo "[preflight] a desktop on the GPU, another model, or a scene that isn't fully stopped (check: docker ps / nvidia-smi)." >&2
echo "[preflight] Fix: free that VRAM, or lower the ceiling —" >&2
echo "[preflight] GPU_MEMORY_UTILIZATION=0.90 bash scripts/switch.sh <variant>" >&2
echo "[preflight] — then retry. (Bypass this check with --force.)" >&2
if [[ "$force" == "1" ]]; then
echo "[preflight] WARN: --force set — launching anyway; vLLM may still abort at the free-memory check." >&2
return 0
fi
return 1
}
preflight_running() {
command -v docker >/dev/null 2>&1 || return 0
local running
running=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E '^(vllm-qwen36-27b|llama-cpp-qwen36-27b|ik-llama-qwen36-27b|vllm-gemma-4-31b)' || true)
if [[ -n "$running" ]]; then
echo "[preflight] note: a club-3090 container is already running:"
echo "$running" | sed 's/^/[preflight] /'
echo "[preflight] 'switch.sh' will bring it down before booting the new variant."
fi
return 0
}
# preflight_genesis_pin — warn if scripts/setup.sh's declared GENESIS_PIN
# differs from the on-disk Genesis tree HEAD. This catches the
# "user pulled the repo but didn't re-run setup.sh" failure mode where
# vLLM boots against an outdated Genesis tree (mysterious patch failures
# at runtime). Sourceable; soft-warning only — caller decides whether
# to abort. Returns 0 always; emits a [preflight] WARN line on mismatch.
preflight_genesis_pin() {
local repo_root="${1:-${ROOT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
local setup_script="${repo_root}/scripts/setup.sh"
local genesis_dir="${repo_root}/models/qwen3.6-27b/vllm/patches/genesis"
# If setup.sh isn't here we're in a weird state — skip silently.
[[ -f "$setup_script" ]] || return 0
# If Genesis hasn't been cloned yet, this isn't a mismatch — it's a
# missing-setup case. Skip; setup.sh will handle it on first run.
[[ -d "${genesis_dir}/.git" ]] || return 0
# Parse `GENESIS_PIN="${GENESIS_PIN:-<default>}"` to extract the default.
local declared_pin
declared_pin=$(grep -E '^GENESIS_PIN=' "$setup_script" 2>/dev/null | head -1 \
| sed -E 's/.*:-([^}]+)\}.*/\1/; t; s/.*=//' \
| tr -d '"' | tr -d "'")
[[ -z "$declared_pin" ]] && return 0
# Get on-disk HEAD short SHA (matches setup.sh's `git rev-parse --short HEAD`).
local ondisk_pin
ondisk_pin=$(cd "$genesis_dir" && git rev-parse --short HEAD 2>/dev/null)
[[ -z "$ondisk_pin" ]] && return 0
# Compare. setup.sh declares short-form pins (e.g. 2db18df); on-disk
# short SHA from git rev-parse --short matches that form. If declared
# pin is full-length, take its prefix matching ondisk's length.
local declared_short="${declared_pin:0:${#ondisk_pin}}"
if [[ "$declared_short" != "$ondisk_pin" ]]; then
echo "[preflight] WARN: Genesis tree out of sync with setup.sh's declared pin." >&2
echo "[preflight] declared (scripts/setup.sh): ${declared_pin}" >&2
echo "[preflight] on-disk (genesis/.git HEAD): ${ondisk_pin}" >&2
echo "[preflight] This usually means you pulled latest club-3090 but" >&2
echo "[preflight] didn't re-run setup.sh. vLLM may boot against an" >&2
echo "[preflight] outdated Genesis tree, causing mysterious patch" >&2
echo "[preflight] failures at runtime (see #32 for an example)." >&2
echo "[preflight] Fix: bash scripts/setup.sh qwen3.6-27b" >&2
fi
return 0
}
# preflight_repo_drift — warn if local HEAD is behind origin/master.
# Catches the most common stale-setup pattern: user cloned weeks ago, master
# has moved (Genesis pin bumps, compose changes, vendored patch updates),
# they re-run their compose, hit a stale config, and file an issue we
# already solved on master.
#
# Behavior:
# - Skips silently if not in a git repo, on a non-master branch, or if
# PREFLIGHT_NO_FETCH=1 (offline rigs / CI / forks tracking elsewhere).
# - Runs 'git fetch --quiet origin master' (~1-2s online).
# - Compares local HEAD vs origin/master. Behind > 0 → WARN with the
# count + last-fetch age + the one-line fix command.
# - Returns 0 always; soft-warning only.
preflight_repo_drift() {
local repo_root="${1:-${ROOT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
# Fast bail-outs — silent.
[[ "${PREFLIGHT_NO_FETCH:-0}" == "1" ]] && return 0
[[ -d "${repo_root}/.git" ]] || return 0
command -v git >/dev/null 2>&1 || return 0
# Only check on master — on a feature branch, "behind master" is expected
# state, not drift. Forks / contributors live there.
local current_branch
current_branch=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null)
[[ "$current_branch" == "master" ]] || return 0
# Verify origin remote points at noonghunna/club-3090. If they've forked
# and re-pointed origin elsewhere, we don't know what's "behind."
local origin_url
origin_url=$(git -C "$repo_root" config --get remote.origin.url 2>/dev/null)
[[ "$origin_url" == *"noonghunna/club-3090"* ]] || return 0
# Fetch silently. 5s timeout so we don't hang on flaky networks.
if ! timeout 5 git -C "$repo_root" fetch --quiet origin master 2>/dev/null; then
# Network failure / timeout — don't make this fatal or even noisy.
return 0
fi
local behind
behind=$(git -C "$repo_root" rev-list --count HEAD..origin/master 2>/dev/null)
[[ -z "$behind" || "$behind" == "0" ]] && return 0
# Last-fetch age. FETCH_HEAD's mtime is the cleanest proxy.
local fetch_head="${repo_root}/.git/FETCH_HEAD"
local age_str=""
if [[ -f "$fetch_head" ]]; then
local now mtime age_sec
now=$(date +%s)
mtime=$(stat -c %Y "$fetch_head" 2>/dev/null || stat -f %m "$fetch_head" 2>/dev/null)
if [[ -n "$mtime" ]]; then
age_sec=$(( now - mtime ))
if (( age_sec < 60 )); then age_str="just now"
elif (( age_sec < 3600 )); then age_str="${age_sec}s ago" # < 1h, surface seconds
elif (( age_sec < 86400 )); then age_str="$(( age_sec / 3600 ))h ago"
else age_str="$(( age_sec / 86400 ))d ago"; fi
fi
fi
echo "[preflight] WARN: Your club-3090 checkout is ${behind} commit(s) behind origin/master." >&2
[[ -n "$age_str" ]] && echo "[preflight] (last origin fetch: ${age_str})" >&2
echo "[preflight] Master may have new configs, patches, or Genesis pin bumps." >&2
echo "[preflight] Easy upgrade: bash scripts/update.sh" >&2
echo "[preflight] (Will refuse if you have local edits — commit or stash first.)" >&2
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_model_dir() {
local compose_file="$1"
local model_dir
if [[ -n "${MODEL_DIR:-}" ]]; then
model_dir="${MODEL_DIR}"
else
local root_dir="${ROOT_DIR:-}"
if [[ -z "$root_dir" ]]; then
root_dir="$(cd -- "${_PREFLIGHT_DIR}/.." && pwd)"
fi
model_dir="${root_dir}/models-cache"
echo "[preflight] MODEL_DIR not set — defaulting to ${model_dir}" >&2
fi
# Resolve relative paths against the compose location. Do not require the
# directory to already exist; this function is often called before download.
if [[ "$model_dir" == ../* ]] || [[ "$model_dir" == ./* ]]; then
local compose_dir
compose_dir="$(cd -- "$(dirname -- "$compose_file")" && pwd)"
model_dir="${compose_dir}/${model_dir}"
fi
printf '%s' "$model_dir"
}
_preflight_compose_path_default() {
local value="$1"
value="$(_preflight_trim "$value")"
value="${value#\"}"
value="${value%\"}"
value="${value#\'}"
value="${value%\'}"
value="${value%,}"
# Compose files commonly use ${VAR:-default/path}. Presence checks should use
# the path the compose will use by default; explicit env overrides are handled
# by callers for user-facing knobs such as GGUF_FILE.
value="$(printf '%s' "$value" | sed -E 's#\$\{[A-Za-z_][A-Za-z0-9_]*:-([^}]*)\}#\1#g')"
value="$(printf '%s' "$value" | sed -E 's#\$\{MODEL_DIR[^}]*\}/?##g')"
value="${value#/models/}"
value="${value#/root/.cache/huggingface/}"
# Strip a trailing `}` left when a path is the DEFAULT inside an outer
# ${VAR:-/root/.cache/huggingface/<path>} — the path grep anchors mid-expansion
# so it captures `<path>}`. A real model subdir never ends in `}`.
value="${value%\}}"
printf '%s' "$value"
}
_preflight_compose_vllm_subdir() {
local value
value="$(_preflight_compose_path_default "$1")"
value="${value%%/*}"
printf '%s' "$value"
}
_preflight_missing_rel() {
local model_dir="$1"
local item="$2"
local item_path="${item%% (*}"
local rel="${item_path#${model_dir}/}"
printf '%s' "$rel"
}
_preflight_list_has() {
local needle="$1"
shift
local value
for value in "$@"; do
[[ "$value" == "$needle" ]] && return 0
done
return 1
}
_preflight_hf_cli_available() {
command -v hf >/dev/null 2>&1 || command -v huggingface-cli >/dev/null 2>&1
}
_preflight_setup_root() {
if [[ -n "${ROOT_DIR:-}" ]]; then
printf '%s' "$ROOT_DIR"
else
cd -- "${_PREFLIGHT_DIR}/.." && pwd
fi
}
_preflight_weights_reader() {
local root_dir
root_dir="$(_preflight_setup_root)"
printf '%s' "${root_dir}/scripts/lib/profiles/weights.py"
}
_preflight_weight_recipe_for_path() {
local rel="$1"
local reader env_lines
reader="$(_preflight_weights_reader)"
command -v python3 >/dev/null 2>&1 || return 1
[[ -f "$reader" ]] || return 1
env_lines="$(python3 "$reader" lookup "$rel" 2>/dev/null)" || return 1
eval "$env_lines"
}
_preflight_weight_recipe_for_key() {
local key="$1"
local reader env_lines
reader="$(_preflight_weights_reader)"
command -v python3 >/dev/null 2>&1 || return 1
[[ -f "$reader" ]] || return 1
env_lines="$(python3 "$reader" entry "$key" 2>/dev/null)" || return 1
eval "$env_lines"
}
_preflight_weight_hf_command() {
local model_dir_expr="${1:-\$MODEL_DIR}"
[[ -n "${WEIGHT_REPO:-}" ]] || return 1
# Mirror an optional revision pin (#319) so the manual fallback fetches the
# same bytes setup.sh would. Unset -> no flag (track HEAD).
local rev=""
[[ -n "${WEIGHT_REVISION:-}" ]] && rev=" --revision ${WEIGHT_REVISION}"
if [[ -n "${WEIGHT_FILES:-}" ]]; then
printf 'hf download %s %s%s --local-dir %s/%s' \
"$WEIGHT_REPO" "$WEIGHT_FILES" "$rev" "$model_dir_expr" "$WEIGHT_SUBDIR"
else
printf 'hf download %s%s --local-dir %s/%s' \
"$WEIGHT_REPO" "$rev" "$model_dir_expr" "$WEIGHT_SUBDIR"
fi
}
_preflight_weight_setup_command() {
[[ -n "${WEIGHT_SETUP_MODEL:-}" ]] || return 1
if [[ -n "${WEIGHT_SETUP_ENV:-}" ]]; then
printf '%s bash scripts/setup.sh %s' "$WEIGHT_SETUP_ENV" "$WEIGHT_SETUP_MODEL"
else
printf 'bash scripts/setup.sh %s' "$WEIGHT_SETUP_MODEL"
fi
}
_preflight_weight_hint_keys() {
local model_dir="$1"
shift
local item rel key
local keys=()
for item in "$@"; do
rel="$(_preflight_missing_rel "$model_dir" "$item")"
if _preflight_weight_recipe_for_path "$rel"; then
key="$WEIGHT_KEY"
if ! _preflight_list_has "$key" "${keys[@]}"; then
keys+=("$key")
printf '%s\n' "$key"
fi
fi
done
}
_preflight_print_weight_hints() {
local model_dir="$1"
shift
local key any_hint=0 setup_cmd hf_cmd
echo "[preflight] If weights are already elsewhere, export MODEL_DIR=/path/to/models and retry." >&2
while IFS= read -r key; do
[[ -n "$key" ]] || continue
_preflight_weight_recipe_for_key "$key" || continue
any_hint=1
echo "[preflight]" >&2
echo "[preflight] ${WEIGHT_LABEL:-$key}:" >&2
if hf_cmd="$(_preflight_weight_hf_command '$MODEL_DIR' 2>/dev/null)"; then
echo "[preflight] ${hf_cmd}" >&2
fi
if setup_cmd="$(_preflight_weight_setup_command 2>/dev/null)"; then
echo "[preflight] or: MODEL_DIR=${model_dir} ${setup_cmd}" >&2
fi
if [[ -n "${WEIGHT_MANUAL_NOTE:-}" ]]; then
echo "[preflight] note: ${WEIGHT_MANUAL_NOTE}" >&2
fi
done < <(_preflight_weight_hint_keys "$model_dir" "$@")
if [[ "$any_hint" != "1" ]]; then
echo "[preflight] Check the compose header for its model-specific hf download command." >&2
fi
}
_preflight_offer_fetch_missing() {
local compose_file="$1"
local model_dir="$2"
shift 2
[[ "${PREFLIGHT_NO_FETCH_PROMPT:-0}" != "1" ]] || return 1
[[ -t 0 && -t 1 ]] || return 1
_preflight_hf_cli_available || return 1
local key setup_cmd answer root_dir
local keys=()
while IFS= read -r key; do
[[ -n "$key" ]] || continue
_preflight_weight_recipe_for_key "$key" || continue
[[ -n "${WEIGHT_SETUP_MODEL:-}" ]] || continue
[[ -n "${WEIGHT_REPO:-}" ]] || continue
if ! _preflight_list_has "$key" "${keys[@]}"; then
keys+=("$key")
fi
done < <(_preflight_weight_hint_keys "$model_dir" "$@")
[[ ${#keys[@]} -gt 0 ]] || return 1
echo "[preflight]" >&2
read -r -p "[preflight] Fetch missing weights now with scripts/setup.sh? [y/N]: " answer
[[ "$answer" =~ ^[Yy]$ ]] || return 1
root_dir="$(_preflight_setup_root)"
for key in "${keys[@]}"; do
_preflight_weight_recipe_for_key "$key" || continue
local env_args=("MODEL_DIR=${model_dir}" "WEIGHT_KEY=${key}")
echo "[preflight] fetching ${WEIGHT_LABEL:-$key} ..." >&2
env "${env_args[@]}" bash "${root_dir}/scripts/setup.sh" "${WEIGHT_SETUP_MODEL}"
done
PREFLIGHT_NO_FETCH_PROMPT=1 preflight_compose_deps "$compose_file"
}
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="$(_preflight_compose_model_dir "$compose_file")"
local compose_files=("$compose_file")
local compose_dir extends_file
compose_dir="$(cd -- "$(dirname -- "$compose_file")" && pwd)"
while IFS= read -r extends_file; do
extends_file="$(_preflight_trim "$extends_file")"
extends_file="${extends_file#\"}"
extends_file="${extends_file%\"}"
extends_file="${extends_file#'}"
extends_file="${extends_file%'}"
[[ -n "$extends_file" ]] || continue
[[ "$extends_file" == /* ]] || extends_file="${compose_dir}/${extends_file}"
[[ -f "$extends_file" ]] && compose_files+=("$extends_file")
done < <(grep -hE '^[[:space:]]*file:[[:space:]]*[^#[:space:]]+' "$compose_file" \
| sed -E 's/^[[:space:]]*file:[[:space:]]*//' || true)
local missing=()
# Engine detection: llama.cpp composes mount ${MODEL_DIR}:/models and pass
# `-m /models/<path>` or `--model /models/<path>`; vLLM composes mount
# ${MODEL_DIR}:/root/.cache/huggingface and pass
# `/root/.cache/huggingface/<subdir>`.
local is_llamacpp=0
# beellama.cpp (ghcr.io/{anbeeld/beellama.cpp,noonghunna/beellama-cpp}) is a
# llama.cpp-family server: it mounts ${MODEL_DIR}:/models and passes
# `-m /models/<path>` (+ `--spec-draft-model /models/<path>` for DFlash/MTP),
# so it belongs on the GGUF presence path, NOT the vLLM HF-cache path.
if grep -qhE 'image:.*(ggml-org/llama\.cpp|ikawrakow/ik-llama|beellama)' "${compose_files[@]}"; then
is_llamacpp=1
fi
if [[ $is_llamacpp -eq 1 ]]; then
local gguf_paths=()
local draft_paths=()
local mmproj_paths=()
local token path
# Target weights: -m / --model
while IFS= read -r token; do
path="$(_preflight_compose_path_default "$token")"
[[ -n "$path" ]] && gguf_paths+=("$path")
done < <(grep -hoE -- '(^|[[:space:]])(-m|--model)[[:space:]]+/models/[^[:space:]]+' "${compose_files[@]}" \
| awk '{print $NF}' || true)
# Speculative drafter: beellama --spec-draft-model, llama.cpp -md/--model-draft.
# A missing drafter GGUF otherwise surfaces only as a cryptic in-container
# "failed to open GGUF file" crash (see #288 beellama onboarding reports).
while IFS= read -r token; do
path="$(_preflight_compose_path_default "$token")"
[[ -n "$path" ]] && draft_paths+=("$path")
done < <(grep -hoE -- '(^|[[:space:]])(--spec-draft-model|--model-draft|-md)[[:space:]]+/models/[^[:space:]]+' "${compose_files[@]}" \
| awk '{print $NF}' || true)
while IFS= read -r token; do
path="$(_preflight_compose_path_default "$token")"
[[ -n "$path" ]] && mmproj_paths+=("$path")
done < <(grep -hoE -- '(^|[[:space:]])--mmproj[[:space:]]+/models/[^[:space:]]+' "${compose_files[@]}" \
| awk '{print $NF}' || true)
# Env overrides mirror the compose knobs (GGUF_FILE / DRAFT_FILE / MMPROJ_FILE),
# each replacing only its own path class.
if [[ -n "${GGUF_FILE:-}" ]]; then
gguf_paths=("$GGUF_FILE")
fi
if [[ -n "${DRAFT_FILE:-}" && ${#draft_paths[@]} -gt 0 ]]; then
draft_paths=("$DRAFT_FILE")
fi
if [[ -n "${MMPROJ_FILE:-}" && ${#mmproj_paths[@]} -gt 0 ]]; then
mmproj_paths=("$MMPROJ_FILE")
fi
for path in "${gguf_paths[@]}"; do
if [[ ! -f "${model_dir}/${path}" ]]; then
missing+=("${model_dir}/${path} (llama.cpp GGUF weights)")
fi
done
for path in "${draft_paths[@]}"; do
if [[ ! -f "${model_dir}/${path}" ]]; then
missing+=("${model_dir}/${path} (speculative drafter GGUF)")
fi
done
for path in "${mmproj_paths[@]}"; do
if [[ ! -f "${model_dir}/${path}" ]]; then
missing+=("${model_dir}/${path} (vision projector)")
fi
done
else
local seen_subdirs=" "
local subdir
# vLLM path — collect every in-container HF model path the compose names:
# main `--model` entries and JSON `--speculative-config` draft models.
while IFS= read -r token; do
subdir="$(_preflight_compose_vllm_subdir "$token")"
[[ -n "$subdir" ]] || continue
if [[ "$seen_subdirs" != *" ${subdir} "* ]]; then
seen_subdirs+="${subdir} "
if [[ ! -f "${model_dir}/${subdir}/config.json" ]]; then
missing+=("${model_dir}/${subdir}/config.json (HF model)")
fi
fi
# Char-class must NOT exclude `:` or `}` — model paths can be
# `/root/.cache/huggingface/${MODEL_SUBDIR:-default}` (vLLM) and excluding
# those truncated the token to `${MODEL_SUBDIR` before _preflight_compose_path_default
# could resolve the `:-default`, causing a false "missing" (the gemma-4-12b
# MODEL_SUBDIR/SPEC_MODEL_SUBDIR composes). Stop only at real delimiters
# (quote / whitespace / comma); the `${VAR:-default}` resolver runs downstream.
done < <(grep -hv '^[[:space:]]*#' "${compose_files[@]}" 2>/dev/null | grep -oE '/root/\.cache/huggingface/[^"'\''[:space:],]+' || true)
# Experimental SGLang composes mount individual MODEL_DIR subdirectories to
# /models/target and /models/drafter instead of using the HF cache mount.
while IFS= read -r token; do
path="$(_preflight_compose_path_default "$token")"
path="${path%%:*}"
[[ -n "$path" ]] || continue
if [[ ! -e "${model_dir}/${path}" ]]; then
missing+=("${model_dir}/${path} (MODEL_DIR volume path)")
fi
done < <(grep -hoE '\$\{MODEL_DIR[^}]*\}/[^"[:space:]]+' "${compose_files[@]}" || true)
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: ${item}" >&2
done
echo "[preflight]" >&2
echo "[preflight] Fix:" >&2
_preflight_print_weight_hints "$model_dir" "${missing[@]}"
if _preflight_offer_fetch_missing "$compose_file" "$model_dir" "${missing[@]}"; then
return 0
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 selected/visible cards (the TP-split ceiling).
local min_vram_mib="" mem_query selector idx mem_mib
selector="$(_preflight_selector || true)"
mem_query="$(nvidia-smi --query-gpu=index,memory.total --format=csv,noheader,nounits 2>/dev/null || true)"
while IFS=',' read -r idx mem_mib; do
idx="$(_preflight_csv_token "$idx")"
mem_mib="$(_preflight_csv_token "$mem_mib")"
[[ -z "$idx" || -z "$mem_mib" ]] && continue
_preflight_selector_allows_index "$selector" "$idx" || continue
if [[ -z "$min_vram_mib" || "$mem_mib" -lt "$min_vram_mib" ]]; then
min_vram_mib="$mem_mib"
fi
done <<< "$mem_query"
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
}
# autodetect_endpoint — discover the running club-3090 container + its host port.
#
# Caller-controlled: the bench / verify scripts default URL=http://localhost:8020
# and CONTAINER=vllm-qwen36-27b. That assumption breaks when the user is running
# a different variant (e.g. dual-turbo on 8011, dual-dflash on 8012, etc.) and
# silently makes verify-full / bench / verify-stress emit false negatives because
# they're hitting an empty port. Reported by sudepo on club-3090#52.
#
# Behaviour:
# - If $URL or $CONTAINER is already set in the environment, it WINS — never
# overwritten. This preserves explicit override behaviour.
# - Otherwise, scan `docker ps` for a club-3090-pattern container and extract
# its host port from the port-mapping. Print one [autodetect] line so the
# user knows what we picked.
# - If nothing is detected (no container running, docker unavailable), the
# hardcoded defaults stand — same behaviour as before this helper existed.
#
# Outputs (mutates env in caller's scope when sourced):
# URL — http://localhost:<port> if detected
# CONTAINER — running container name if detected
#
# Skip via: PREFLIGHT_NO_AUTODETECT=1
preflight_autodetect_endpoint() {
if [[ "${PREFLIGHT_NO_AUTODETECT:-0}" == "1" ]]; then
return 0
fi
command -v docker >/dev/null 2>&1 || return 0
local explicit_url="${URL:-}"
local explicit_container="${CONTAINER:-}"
if [[ -n "$explicit_url" && -n "$explicit_container" ]]; then
return 0 # both already set — caller knows what they're doing
fi
# Detect a running inference container by its ENGINE-INTERNAL port mapping
# (vLLM 8000 / llama.cpp 8080 / sglang 30000), NOT a hardcoded model-name
# allowlist — so any compose is found regardless of model: gemma-4-12b,
# qwen-35b-a3b, beellama, a BYO container, etc. (#310: the old allowlist only
# knew qwen36-27b / gemma-4-31b, so everything else silently fell back to 8020).
# Among matches, prefer a recognised club-3090 engine-family prefix; otherwise
# take the first. Users running endpoint-first via `--url` bypass this entirely
# (PREFLIGHT_NO_AUTODETECT=1 set there).
#
# The `|| true` is load-bearing: grep -E returns 1 when nothing matches, which
# under `set -euo pipefail` in the caller would silently abort rebench-full.sh
# before its own "endpoint not responding" path. Empty = the no-container case.
local engine_lines found_line
engine_lines=$(docker ps --format '{{.Names}}|{{.Ports}}' 2>/dev/null \
| grep -E '([0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]+->(8000|8080|30000)/tcp' || true)
if [[ -z "$engine_lines" ]]; then
return 0 # nothing serving on an engine port; defaults stand
fi
# Prefer a recognised club-3090 engine-family prefix when several match.
found_line=$(printf '%s\n' "$engine_lines" \
| grep -E '^(vllm-|llama-cpp-|ik-llama-|sglang-|beellama-)' | head -1 || true)
[[ -z "$found_line" ]] && found_line=$(printf '%s\n' "$engine_lines" | head -1)
# Several inference containers up → we picked one; tell the user how to override.
if [[ "$(printf '%s\n' "$engine_lines" | grep -c .)" -gt 1 ]]; then
echo "[autodetect] multiple inference containers running; picked '${found_line%%|*}' — set CONTAINER=/URL= to override" >&2
fi
local detected_name detected_port
detected_name="${found_line%%|*}"
# Extract host port from "0.0.0.0:8011->8000/tcp", "[::]:8011->8000/tcp",
# or "127.0.0.1:8011->8000/tcp" forms (BIND_HOST=127.0.0.1 produces the last).
# llama-cpp container maps to internal 8080, vllm to 8000, sglang to 30000.
detected_port=$(echo "${found_line#*|}" \
| grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]+->(8000|8080|30000)/tcp' \
| head -1 \
| sed -E 's|^[^:]+:([0-9]+)->.*|\1|')
# Apply, but only fields the user didn't already set explicitly.
if [[ -z "$explicit_container" && -n "$detected_name" ]]; then
CONTAINER="$detected_name"
fi
if [[ -z "$explicit_url" && -n "$detected_port" ]]; then
URL="http://localhost:${detected_port}"
fi
# One-line surface so the user sees what we chose.
if [[ -z "$explicit_url" || -z "$explicit_container" ]]; then
local note=""
[[ -z "$explicit_container" ]] && note="container=${CONTAINER}"
[[ -z "$explicit_url" ]] && note="${note:+$note }url=${URL}"
echo "[autodetect] using running ${note} (skip: PREFLIGHT_NO_AUTODETECT=1)" >&2
fi
return 0
}
# ---------------------------------------------------------------------------
# Resolve the SERVED model name from the running endpoint's /v1/models when the
# caller hasn't set MODEL explicitly. Mirrors what soak-test.sh / bench-agentic.sh
# / quality-test.sh already do — so verify-full / verify-stress / bench / verify
# send the model the server actually serves instead of a hardcoded qwen default.
#
# Why this exists (#372): report.sh autodetects container + URL + engine but NOT
# the served model, so the verify/bench scripts fell back to MODEL=qwen3.6-27b-
# autoround. Against a non-qwen vLLM endpoint (e.g. gemma-4-26b-a4b-awq) every
# request 404'd ("The model `qwen3.6-27b` does not exist"). llama.cpp
# ignores the request's model field, so the same wrong default silently "worked"
# there (#371) — which is exactly what masked the bug.
#
# Sets MODEL in the caller's scope. No-op when MODEL is already set (an explicit
# env/flag value always wins — critical for llama-swap / multi-model endpoints
# where /v1/models returns the first, often wrong, registered model), when there
# is no URL to query, or when the endpoint isn't reachable (the caller's own
# reachability check then surfaces the real outage). Callers keep their own
# last-resort literal after this, so behaviour is unchanged when detection no-ops.
preflight_autodetect_model() {
[[ -n "${MODEL:-}" ]] && return 0
local url="${1:-${URL:-}}"
[[ -n "$url" ]] || return 0
command -v curl >/dev/null 2>&1 || return 0
command -v python3 >/dev/null 2>&1 || return 0
local detected
detected="$(curl -sf -m 5 "${url%/}/v1/models" 2>/dev/null \
| python3 -c "import json,sys
try:
d = json.load(sys.stdin).get('data', [])
print(d[0]['id'] if d else '')
except Exception:
print('')" 2>/dev/null || true)"
if [[ -n "$detected" ]]; then
MODEL="$detected"
echo "[autodetect] served model='${MODEL}' (from ${url%/}/v1/models; set MODEL= to override)" >&2
fi
return 0
}
# ── #633 — ik-llama cu13/cu12 driver-aware image selection ────────────────────
# The pinned cu13 ik-llama image has a CUDA 13.2 runtime; on a driver whose
# supported CUDA < 13.2 the forward-compat path fails on GeForce (CUDA error
# 804) → silent CPU fallback → segfault crash-loop, with no actionable hint
# (launch just times out after 600 s). Auto-pick the cu12 sibling build (same
# build 4574, CUDA 12.6, backward-compatible with the 13.0 driver — validated on
# a 580.159 rig, #633) unless the user pinned IK_LLAMA_IMAGE. ik-llama only.
IK_LLAMA_CU13_MIN_CUDA="13.2"
IK_LLAMA_CU12_FALLBACK="${IK_LLAMA_CU12_FALLBACK:-ghcr.io/ikawrakow/ik-llama-cpp:cu12-server-4574}"
# _cuda_ge A B → 0 (true) iff major.minor A >= B
_cuda_ge() {
local a1="${1%%.*}" b1="${2%%.*}" a2 b2
a2="${1#*.}"; [[ "$a2" == "$1" ]] && a2=0
b2="${2#*.}"; [[ "$b2" == "$2" ]] && b2=0
(( 10#${a1:-0} > 10#${b1:-0} )) && return 0
(( 10#${a1:-0} < 10#${b1:-0} )) && return 1
(( 10#${a2:-0} >= 10#${b2:-0} ))
}
# Driver's max supported CUDA (major.minor), or "" if undetectable.
_driver_cuda_version() {
local v
v="$(nvidia-smi --query 2>/dev/null | grep -m1 -oE 'CUDA Version[[:space:]]*:[[:space:]]*[0-9]+\.[0-9]+' | grep -oE '[0-9]+\.[0-9]+' || true)"
[[ -z "$v" ]] && v="$(nvidia-smi 2>/dev/null | grep -m1 -oE 'CUDA Version:?[[:space:]]*[0-9]+\.[0-9]+' | grep -oE '[0-9]+\.[0-9]+' || true)"
printf '%s' "$v"
}
# preflight_single_card_util <compose_file> [variant]
# Advisory WARN (never blocks; runs even under --force) when the user raises
# GPU_MEMORY_UTILIZATION above the compose's validated default on a SINGLE-CARD
# (TP<=1) config. On one GPU a higher util shrinks the free VRAM a large
# tool-response prefill needs for its activation peak, so vLLM OOMs mid-prefill
# even though boot succeeds (verify-stress step 2/8). Two 5090 testers hit this
# by setting util=0.92 on the nvfp4 slug whose validated default is 0.85 (#617).
# No-op unless the user overrode the env AND the compose ships a
# `GPU_MEMORY_UTILIZATION:-<default>` (so it never fires for non-vLLM engines,
# dual/multi-card, or a plain default run).
preflight_single_card_util() {
local compose_file="$1" variant="${2:-}"
[[ -f "$compose_file" ]] || return 0
local user_util="${GPU_MEMORY_UTILIZATION:-}"
[[ -n "$user_util" ]] || return 0 # only when explicitly overridden
local tp=""
if declare -F compose_meta_get >/dev/null 2>&1; then
tp="$(compose_meta_get "$compose_file" tensor-parallel 2>/dev/null || true)"
fi
[[ "$tp" =~ ^[0-9]+$ ]] || return 0 # unknown topology → stay quiet
(( tp <= 1 )) || return 0 # single-card only
local default_util
default_util="$(command grep -oE 'GPU_MEMORY_UTILIZATION:-[0-9.]+' "$compose_file" | head -1 | sed -E 's/.*:-//')"
[[ -n "$default_util" ]] || return 0 # compose ships no util default
if awk -v u="$user_util" -v d="$default_util" 'BEGIN{exit !((u+0) > (d+0))}'; then
echo "[preflight] WARN: GPU_MEMORY_UTILIZATION=${user_util} exceeds ${variant:-this config}'s validated single-card default of ${default_util}." >&2
echo "[preflight] On one GPU a higher util steals the headroom a large tool-response prefill needs for its" >&2
echo "[preflight] activation peak — vLLM can OOM mid-prefill (verify-stress step 2/8) even though boot succeeds." >&2
echo "[preflight] Fix: grow context via MAX_MODEL_LEN and keep GPU_MEMORY_UTILIZATION <= ${default_util}. (#617)" >&2
fi
return 0
}
preflight_ik_llama_image() {
local variant="${1:-}"
[[ "$variant" == ik-llama/* ]] || return 0
if [[ -n "${IK_LLAMA_IMAGE:-}" ]]; then
echo "[preflight] ik-llama image pinned (.env/shell): ${IK_LLAMA_IMAGE}"
return 0
fi
local drv; drv="$(_driver_cuda_version)"
[[ -z "$drv" ]] && return 0 # undetectable → leave compose default (cu13)
_cuda_ge "$drv" "$IK_LLAMA_CU13_MIN_CUDA" && return 0 # >=13.2 → cu13 runtime OK
export IK_LLAMA_IMAGE="$IK_LLAMA_CU12_FALLBACK"
echo "[preflight] ⚠ driver CUDA ${drv} < ${IK_LLAMA_CU13_MIN_CUDA} (the cu13 ik-llama pin's runtime)." >&2
echo "[preflight] cu13 would forward-compat-fail on GeForce (error 804) → CPU fallback → crash loop (#633)." >&2
echo "[preflight] Auto-selected the cu12 sibling build (same build, CUDA 12.6, backward-compatible):" >&2
echo "[preflight] IK_LLAMA_IMAGE=${IK_LLAMA_CU12_FALLBACK}" >&2
echo "[preflight] Pin IK_LLAMA_IMAGE in .env to override." >&2
}