#!/usr/bin/env bash # install.sh — Qwen3.5-122B-A10B + DFlash speculative decode on NVIDIA DGX Spark # (GB10 / SM121, 128 GB / 119 GiB unified), via vLLM in Docker. # # curl -sSL https://raw.githubusercontent.com/Entrpi/qwen3.5-122B-A10B-on-spark/main/install.sh | bash # curl -sSL https://raw.githubusercontent.com/Entrpi/qwen3.5-122B-A10B-on-spark/main/install.sh | bash -s -- --help # # What this does (every step idempotent — safe to re-run): # # 1. Verifies the host is a DGX Spark / GB10 (SM121) with Docker + the NVIDIA # container runtime, and enough free disk for the chosen profile. # 2. Pulls the prebuilt sm121 vLLM image (DFlash-enabled, vLLM 0.23). # 3. Downloads the INT4 target + DFlash drafter from Hugging Face into the HF # cache — OR reuses a checkpoint you already have (--model-dir / --hf-home). # 4. (optional) Builds the hybrid INT4+FP8 checkpoint for the "dense" profile. # 5. Starts the vLLM server for the chosen --profile, waits until READY, and # runs the "capital of France" smoke test (expects "Paris"). # # The script makes NO changes outside: # - the Docker image cache (the pulled image) # - $HF_HOME (default ~/.cache/huggingface) # - $HYBRID_DIR (only with --build-hybrid) # - the running container named $NAME (only with --start / smoke) # # This repo provides the install + serve + patch + benchmark layer ON TOP of: # - Intel/Qwen3.5-122B-A10B-int4-AutoRound (target weights) # - z-lab/Qwen3.5-122B-A10B-DFlash (block-diffusion drafter) # - ghcr.io/aeon-7/aeon-vllm-ultimate (sm121 DFlash-enabled vLLM) # # License: MIT. Source: https://github.com/Entrpi/qwen3.5-122B-A10B-on-spark set -euo pipefail # ============================================================================ # 0. defaults + flag parsing # ============================================================================ # Prebuilt sm121 vLLM image with the DFlash PRs + the .pth that auto-applies our # KV-unify patch is NOT baked in — we apply patches at serve time from runtime/. IMAGE="${QWEN_IMAGE:-ghcr.io/aeon-7/aeon-vllm-ultimate:2026-06-18-v0.23.0-dflashfix}" TARGET_REPO="${TARGET_REPO:-Intel/Qwen3.5-122B-A10B-int4-AutoRound}" # INT4 target (~62 GiB) DRAFT_REPO="${DRAFT_REPO:-z-lab/Qwen3.5-122B-A10B-DFlash}" # 0.8B drafter (~1.6 GiB) FP8_REPO="${FP8_REPO:-Qwen/Qwen3.5-122B-A10B-FP8}" # FP8 donor for --build-hybrid HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}" HYBRID_DIR="${HYBRID_DIR:-$HOME/qwen3.5-122b-hybrid-int4-fp8}" MODEL_DIR="" # --model-dir: a pre-downloaded INT4 checkpoint dir # This repo's own dir (works for `curl | bash` too: falls back to a clone). REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "$HOME/code/qwen3.5-122B-A10B-on-spark")}" REPO_URL="${REPO_URL:-https://github.com/Entrpi/qwen3.5-122B-A10B-on-spark.git}" NAME="${NAME:-qwen-spark}" PROFILE="dflash" # dflash | dense | base | mtp NSPEC="" # override num_speculative_tokens (default per profile) PORT="${PORT:-8000}" CTX="${CTX:-16384}" GPU_MEM="${GPU_MEM:-0.8}" BACKEND="${BACKEND:-flash_attn}" FORCE_HW=0 SKIP_PULL=0 SKIP_DOWNLOAD=0 BUILD_HYBRID=0 START_SERVER=0 SKIP_SMOKE=0 usage() { cat <&2; usage; exit 2 ;; esac done case "$PROFILE" in dflash|dense|base|mtp) ;; *) echo "Bad --profile: $PROFILE" >&2; exit 2 ;; esac c_red() { printf '\033[31m%s\033[0m' "$*"; } c_green() { printf '\033[32m%s\033[0m' "$*"; } c_yellow(){ printf '\033[33m%s\033[0m' "$*"; } log() { printf '%s %s\n' "[$(date +%H:%M:%S)]" "$*"; } die() { printf '\n%s %s\n' "$(c_red FATAL:)" "$*" >&2; exit 1; } warn(){ printf '%s %s\n' "$(c_yellow WARN:)" "$*" >&2; } ok() { printf '%s %s\n' "$(c_green OK:)" "$*"; } # ============================================================================ # 1. host verification # ============================================================================ verify_host() { log "Verifying host..." local m; m=$(uname -m) if [[ "$m" != "aarch64" ]] && [[ "$FORCE_HW" -eq 0 ]]; then die "Expected aarch64 (Grace+Blackwell); got $m. Pass --force to skip." fi command -v docker >/dev/null 2>&1 || die "docker not found. Install Docker + the NVIDIA container runtime." command -v nvidia-smi >/dev/null 2>&1 || die "nvidia-smi not found. Need the NVIDIA driver." local gpu; gpu=$(nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader 2>/dev/null || true) [[ -n "$gpu" ]] || die "nvidia-smi failed to enumerate GPUs." log "GPU: $gpu" if ! echo "$gpu" | grep -qE '12\.1|GB10|Spark'; then [[ "$FORCE_HW" -eq 1 ]] || die "Not detecting GB10/SM12.1. Pass --force (and maybe --backend) to proceed." warn "Host is not GB10/SM121; proceeding under --force (untested)." fi # Docker can see the GPU? if ! docker info 2>/dev/null | grep -qiE 'nvidia|Default Runtime: nvidia' \ && ! docker run --rm --gpus all "$IMAGE" true 2>/dev/null; then warn "Could not confirm Docker GPU access (nvidia-container-toolkit). 'docker run --gpus all' must work." fi # Disk local need=75; [[ "$BUILD_HYBRID" -eq 1 ]] && need=150 local free; free=$(df -BG "$HOME" | awk 'NR==2{gsub("G","",$4);print $4}') if (( free < need )) && [[ "$SKIP_DOWNLOAD" -eq 0 ]] && [[ -z "$MODEL_DIR" ]]; then die "Need >= ${need} GiB free under $HOME for profile '$PROFILE'; have ${free} GiB. Use --model-dir / --no-download, or free space." fi ok "Host checks passed (free ${free} GiB)." } # ============================================================================ # 2. pull image # ============================================================================ pull_image() { if [[ "$SKIP_PULL" -eq 1 ]]; then log "Skipping docker pull (--no-pull)."; return; fi if docker image inspect "$IMAGE" >/dev/null 2>&1; then ok "Image present: $IMAGE"; return; fi log "Pulling $IMAGE (~40 GiB, one-time) ..." docker pull "$IMAGE" ok "Image pulled." } # ============================================================================ # 3. download models (idempotent — snapshot_download no-ops if cached) # ============================================================================ hf_get() { # repo -> populate HF cache via the image's huggingface_hub local repo="$1" docker run --rm --net=host -e HF_HOME=/hf ${HF_TOKEN:+-e HF_TOKEN="$HF_TOKEN"} \ -v "$HF_HOME:/hf" --entrypoint python3 "$IMAGE" \ -c "from huggingface_hub import snapshot_download as s; s('$repo')" } download_models() { if [[ "$SKIP_DOWNLOAD" -eq 1 ]]; then log "Skipping HF download (--no-download)."; return; fi mkdir -p "$HF_HOME" if [[ -n "$MODEL_DIR" ]]; then [[ -f "$MODEL_DIR/config.json" ]] || die "--model-dir $MODEL_DIR has no config.json" log "Using pre-downloaded target at $MODEL_DIR (skipping target download)." else log "Fetching target $TARGET_REPO into $HF_HOME ..." hf_get "$TARGET_REPO" fi log "Fetching drafter $DRAFT_REPO ..." hf_get "$DRAFT_REPO" ok "Models ready." } # ============================================================================ # 4. optional: build the hybrid INT4+FP8 checkpoint # ============================================================================ build_hybrid() { [[ "$BUILD_HYBRID" -eq 1 ]] || return 0 if [[ -f "$HYBRID_DIR/model.safetensors.index.json" ]]; then ok "Hybrid ckpt present: $HYBRID_DIR"; return; fi local gptq="$MODEL_DIR" if [[ -z "$gptq" ]]; then gptq=$(docker run --rm -v "$HF_HOME:/hf" -e HF_HOME=/hf --entrypoint python3 "$IMAGE" \ -c "from huggingface_hub import snapshot_download as s; print(s('$TARGET_REPO'))" | tail -1) gptq="/hf-snap" # mount the cache; resolve inside the container below fi mkdir -p "$HYBRID_DIR" log "Building hybrid INT4+FP8 checkpoint -> $HYBRID_DIR (~20 min) ..." docker run --rm --net=host -e HF_HOME=/hf ${HF_TOKEN:+-e HF_TOKEN="$HF_TOKEN"} \ -v "$HF_HOME:/hf" -v "$HYBRID_DIR:/out" -v "$REPO_DIR/tools:/tools:ro" \ ${MODEL_DIR:+-v "$MODEL_DIR:/gptq:ro"} \ --entrypoint bash "$IMAGE" -c ' set -e GPTQ="'"${MODEL_DIR:+/gptq}"'" if [ -z "$GPTQ" ]; then GPTQ=$(python3 -c "from huggingface_hub import snapshot_download as s; print(s(\"'"$TARGET_REPO"'\"))") fi python3 /tools/build-hybrid-checkpoint.py --gptq-dir "$GPTQ" \ --fp8-repo "'"$FP8_REPO"'" --output /out --force rm -rf /out/.fp8_cache' ok "Hybrid checkpoint built: $HYBRID_DIR" } # ============================================================================ # 5. start server (+ smoke test) # ============================================================================ ensure_runtime() { # make sure runtime/ (serve wrapper + patches) is on disk if [[ -f "$REPO_DIR/runtime/serve.sh" ]]; then return; fi log "runtime/ not found next to install.sh — cloning repo to $HOME/code/qwen3.5-122B-A10B-on-spark" REPO_DIR="$HOME/code/qwen3.5-122B-A10B-on-spark" [[ -d "$REPO_DIR/.git" ]] || git clone --depth 1 "$REPO_URL" "$REPO_DIR" [[ -f "$REPO_DIR/runtime/serve.sh" ]] || die "runtime/serve.sh still missing after clone." } start_server() { [[ "$START_SERVER" -eq 1 ]] || { log "Setup complete. Re-run with --start to launch the server."; return; } ensure_runtime # profile -> serve args + env + mounts local nspec model_env=() mounts=() serve_args case "$PROFILE" in dflash) nspec="${NSPEC:-12}"; serve_args="$nspec $BACKEND" ;; dense) nspec="${NSPEC:-12}"; serve_args="$nspec $BACKEND" [[ -f "$HYBRID_DIR/model.safetensors.index.json" ]] || die "dense profile needs the hybrid ckpt — run with --build-hybrid first." model_env=(-e MODEL=/model -e INC_HYBRID=1 -e INT8_LMHEAD_V3=1) mounts=(-v "$HYBRID_DIR:/model:ro") ;; base) nspec="${NSPEC:-0}"; serve_args="$nspec $BACKEND" ;; mtp) nspec="${NSPEC:-2}"; serve_args="$nspec $BACKEND" ;; esac if [[ -n "$MODEL_DIR" && "$PROFILE" != "dense" ]]; then model_env=(-e MODEL=/model); mounts=(-v "$MODEL_DIR:/model:ro") fi local wrapper="/host/serve.sh"; [[ "$PROFILE" == "mtp" ]] && wrapper="/host/mtp_serve.sh" log "Starting profile=$PROFILE (nspec=$nspec, ctx=$CTX, gpu-mem=$GPU_MEM) as container '$NAME' ..." docker rm -f "$NAME" >/dev/null 2>&1 || true # shellcheck disable=SC2086 docker run -d --name "$NAME" --gpus all --net=host --ipc=host --ulimit memlock=-1:-1 \ -e HF_HOME=/hf -e MAX_MODEL_LEN="$CTX" -e GPU_MEM="$GPU_MEM" ${HF_TOKEN:+-e HF_TOKEN="$HF_TOKEN"} \ "${model_env[@]}" \ -v "$HF_HOME:/hf" -v "$REPO_DIR/runtime:/host:ro" "${mounts[@]}" \ --entrypoint bash "$IMAGE" "$wrapper" $serve_args >/dev/null log "Container started. Model load + compile is ~8-12 min. Tail: docker logs -f $NAME" log "Waiting for http://127.0.0.1:$PORT/health ..." local i for i in $(seq 1 180); do if ! docker ps --format '{{.Names}}' | grep -q "^$NAME$"; then docker logs "$NAME" 2>&1 | tail -30; die "Container exited during load. See log above." fi if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then ok "Server READY on http://127.0.0.1:$PORT" break fi sleep 5 done curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 || die "Server not ready within ~15 min. docker logs $NAME" [[ "$SKIP_SMOKE" -eq 1 ]] && { log "Skipping smoke test (--no-smoke)."; return; } log "Smoke test: 'capital of France' ..." local out out=$(curl -s "http://127.0.0.1:$PORT/v1/completions" -H 'Content-Type: application/json' \ -d "{\"model\":\"qwen\",\"prompt\":\"What is the capital of France? Answer in one word.\",\"max_tokens\":8,\"temperature\":0}" \ | python3 -c "import sys,json;print(json.load(sys.stdin)['choices'][0]['text'])" 2>/dev/null || true) echo " -> $out" echo "$out" | grep -qi paris && ok "Smoke test PASSED — 'Paris'." || die "Smoke test FAILED — 'Paris' not in output." } # ============================================================================ # main # ============================================================================ verify_host pull_image download_models build_hybrid start_server echo ok "Done (profile=$PROFILE)." echo " Server: http://127.0.0.1:$PORT/v1 (model name: qwen)" echo " Logs: docker logs -f $NAME" echo " Stop: docker rm -f $NAME" echo " Bench: python3 scripts/bench_decode.py --base-url http://127.0.0.1:$PORT --model qwen --prompt 'Write an essay about tea.'" echo " Agent: python3 scripts/hermes_bench.py --base-url http://127.0.0.1:$PORT # real tool-call turns"