unsloth/docker/test_locally.sh
Daniel Han cceeeb1e1b Address 3 MAJOR review findings on the docker PR
1. Stop leaking secrets via docker run -e VAR=VALUE argv (run.sh, test_locally.sh)

   `docker run ... -e HF_TOKEN=hf_xxx ...` puts the literal token in
   the docker CLI's argv, which is visible to any user on the host
   via `ps auxe` / `/proc/<pid>/cmdline` for the lifetime of the
   process. Switch to the dash-only form `-e HF_TOKEN`, which tells
   docker to read the value from the parent shell's env and never
   appears in argv. Same fix for WANDB_API_KEY and UNSLOTH_LICENSE in
   run.sh and HF_TOKEN in test_locally.sh.

2. Stop stripping numpy/tests/ in the runtime layer (Dockerfile)

   The Dockerfile explicitly upgrades numpy >= 2.4 because numpy 2.2.6
   shipped a stripped wheel where `from numpy._core.tests._natype
   import pd_NA` fails. Numpy 2.4 restores `numpy/_core/tests/`, then
   the existing `find ${VENV} -name tests -exec rm -rf {} +` deleted
   it again -- re-introducing the same broken-import state on the
   deployed image (the build-time verification at line 220 runs
   BEFORE the strip so it passed). Whitelist numpy's tests directories
   from the strip; keep stripping the rest.

3. Align :latest tag gate between merge and smoke-test jobs
   (.github/workflows/docker-publish.yml)

   merge job:       enable = is-default-branch AND unsloth_ref == ''
   smoke-test job:  enable = is_default_branch only

   On `workflow_dispatch`, `github.event.inputs.unsloth_ref` defaults to
   "main" (not ""), so the merge step skipped `:latest` but the smoke
   step still emitted `:latest` as tags[0]. The smoke step then
   `docker pull`-ed a prior `:latest` from Docker Hub instead of the
   image just merged -- so the smoke test verified the OLD image, not
   the new one. Copy the merge step's exact `enable=` expression into
   the smoke-test step so the two stay byte-identical and a workflow_
   dispatch run validates whatever was actually merged.
2026-05-25 13:36:56 +00:00

400 lines
16 KiB
Bash
Executable file

#!/usr/bin/env bash
# End-to-end Docker validation for the unsloth-blackwell image.
#
# Runs three blocks:
# 1. Host pre-flight (docker, nvidia-smi, nvidia runtime registered)
# 2. Build the image (no GPU required at build time)
# 3a. Smoke test: 5-step LoRA on Llama-3.2-1B (~1-2 min)
# 3b. Real workload: gpt-oss-20B fine-tuning notebook with max_steps=10
# (~10 min, needs ~30GB free for the model cache)
#
# Usage:
# bash docker/test_locally.sh # all blocks (native arch)
# bash docker/test_locally.sh --skip-notebook # blocks 1-3a only (fast)
# bash docker/test_locally.sh --skip-build # assume $TAG already built
# bash docker/test_locally.sh --platform arm64 # cross-build for DGX Spark
# # (auto-skips smoke/notebook)
# TAG=my-image:latest bash docker/test_locally.sh
# HF_TOKEN=hf_xxx bash docker/test_locally.sh # for gated models (optional)
#
# All output is teed to $LOG_DIR (default /tmp/unsloth-docker-test/).
# Paste the listed log snippets back if anything fails.
set -uo pipefail
TAG="${TAG:-unsloth-blackwell:test}"
LOG_DIR="${LOG_DIR:-/tmp/unsloth-docker-test}"
SKIP_BUILD=0
SKIP_NOTEBOOK=0
# Platform selector. Empty = let buildx default to the host arch (no
# --platform passed). "amd64" / "arm64" = single-arch cross-build via QEMU
# (requires `bash docker/setup_qemu.sh` to have been run once).
PLATFORM=""
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--skip-notebook) SKIP_NOTEBOOK=1; shift ;;
--tag) TAG="$2"; shift 2 ;;
--log-dir) LOG_DIR="$2"; shift 2 ;;
--platform)
case "$2" in
amd64|arm64|linux/amd64|linux/arm64) PLATFORM="${2#linux/}" ;;
*) echo "ERROR: --platform must be amd64 or arm64 (got '$2')" >&2; exit 2 ;;
esac
shift 2
;;
--help|-h) sed -n '2,22p' "$0"; exit 0 ;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# When cross-building, the resulting image cannot be exercised on this host
# (CUDA does not work under QEMU runtime emulation). Auto-skip the GPU blocks
# and warn the user. They can paste back the build log either way to prove
# the wheels resolve + the build-time torch._C._cuda_getArchFlags() assertion
# passes on the foreign arch.
HOST_ARCH="$(uname -m)"
case "${HOST_ARCH}" in
x86_64|amd64) HOST_DOCKER_ARCH="amd64" ;;
aarch64|arm64) HOST_DOCKER_ARCH="arm64" ;;
*) HOST_DOCKER_ARCH="${HOST_ARCH}" ;;
esac
CROSS_ARCH=0
if [[ -n "${PLATFORM}" && "${PLATFORM}" != "${HOST_DOCKER_ARCH}" ]]; then
CROSS_ARCH=1
fi
mkdir -p "$LOG_DIR"
GREEN='\033[1;32m'; RED='\033[1;31m'; YELLOW='\033[1;33m'; BLUE='\033[1;34m'; NC='\033[0m'
banner() { printf "\n${BLUE}==== %s ====${NC}\n" "$*"; }
ok() { printf "${GREEN}OK${NC} %s\n" "$*"; }
warn() { printf "${YELLOW}WARN${NC} %s\n" "$*"; }
err() { printf "${RED}ERROR${NC} %s\n" "$*" >&2; }
fail() { err "$*"; exit 1; }
# ============================================================================
# Block 1: pre-flight
# ============================================================================
banner "Block 1: host pre-flight"
command -v docker >/dev/null 2>&1 || fail "docker not found on PATH"
echo " docker: $(docker --version)"
# Verify we can actually talk to the docker daemon as the current user.
# This catches the "user not in docker group" case up front, instead of
# letting docker buildx blow up with a "permission denied on /var/run/docker.sock"
# error that looks like a build failure but is really a host permissions issue.
DOCKER_INFO_OUT=$(docker info 2>&1)
DOCKER_INFO_RC=$?
if [[ $DOCKER_INFO_RC -ne 0 ]]; then
err "Cannot talk to the docker daemon as user '$USER'."
cat >&2 <<MSG
docker info exited $DOCKER_INFO_RC. The most common cause is that your user
is not in the 'docker' group. Fix:
sudo usermod -aG docker \$USER
newgrp docker # activate the new group in this shell
docker info | head -3 # verify
Then re-run this script in the same shell (or any new login session).
Alternative: run the script with sudo, but be aware it will use root's
home directory for HF cache (~/root/.cache/huggingface) which is probably
not what you want.
Raw docker info output:
$DOCKER_INFO_OUT
MSG
fail "docker daemon unreachable"
fi
echo " daemon: reachable as '$USER'"
if command -v nvidia-smi >/dev/null 2>&1; then
echo " host gpu: $(nvidia-smi --query-gpu=name --format=csv,noheader | head -1)"
echo " host driver: $(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1)"
else
warn "nvidia-smi not on the host -- you may not be able to run --gpus all"
fi
# This grep only makes sense once we know `docker info` succeeded above.
if echo "$DOCKER_INFO_OUT" | grep -qiE 'Runtimes:.*nvidia'; then
echo " nvidia runtime: registered with docker"
else
warn "docker info does not list 'nvidia' as a runtime"
warn "(on Docker 28+ with CDI this is often a false positive; the real"
warn " test is whether --gpus all works in Block 3a below)"
warn "if --gpus all fails, install nvidia-container-toolkit:"
warn " https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html"
warn " then: sudo systemctl restart docker"
fi
ok "pre-flight done"
# ============================================================================
# Block 2: build
# ============================================================================
if [[ $SKIP_BUILD -eq 1 ]]; then
warn "skipping build (--skip-build); expecting $TAG to exist"
else
banner "Block 2: build $TAG"
# Find the build context: current dir, docker/ subdir, or clone the PR branch
if [[ -f "Dockerfile" && -f "smoke_test.py" ]]; then
BUILD_CTX="$PWD"
elif [[ -f "docker/Dockerfile" ]]; then
BUILD_CTX="$PWD/docker"
else
BUILD_CTX="/tmp/unsloth-pr/docker"
if [[ ! -d /tmp/unsloth-pr/.git ]]; then
echo " cloning docker-blackwell-build branch..."
git clone --depth 1 -b docker-blackwell-build \
https://github.com/unslothai/unsloth.git /tmp/unsloth-pr 2>&1 | tail -3
else
git -C /tmp/unsloth-pr pull --ff-only 2>&1 | tail -2
fi
fi
echo " build context: $BUILD_CTX"
BUILD_LOG="$LOG_DIR/build.log"
echo " log: $BUILD_LOG"
# The Dockerfile uses BuildKit-only features ('# syntax=docker/dockerfile:1.7'
# and 'RUN ... <<\'PY\'' heredocs). Docker 28 removed the legacy builder
# entirely -- DOCKER_BUILDKIT=1 now delegates to buildx, so without the
# buildx component installed there is no fallback that works. Fail fast
# with install instructions before attempting the build.
if ! docker buildx version >/dev/null 2>&1; then
cat >&2 <<'MSG'
ERROR: docker buildx is not installed.
The Dockerfile requires BuildKit (syntax=docker/dockerfile:1.7 + RUN heredocs).
Docker 28 removed the legacy builder, so buildx is required for any build.
Install buildx, then re-run this script:
Ubuntu / Debian (apt):
sudo apt-get update && sudo apt-get install -y docker-buildx
Ubuntu / Debian (Docker's official repo, recommended):
# Follow https://docs.docker.com/engine/install/ubuntu/ -- the docker-ce
# package bundles docker-buildx-plugin and is what most production guides
# assume. The Ubuntu-shipped docker.io package omits buildx.
RHEL / Fedora (dnf):
sudo dnf install -y docker-buildx-plugin
Manual install (any distro):
https://github.com/docker/buildx/releases (download into ~/.docker/cli-plugins/)
Verify with: docker buildx version
MSG
fail "docker buildx required -- install per the message above"
fi
echo " builder: docker buildx ($(docker buildx version | head -1))"
BUILD_ARGS=( --progress=plain )
if [[ -n "${PLATFORM}" ]]; then
echo " platform: linux/${PLATFORM}"
BUILD_ARGS+=( --platform "linux/${PLATFORM}" )
if [[ ${CROSS_ARCH} -eq 1 ]]; then
echo " cross-build: yes (host=${HOST_DOCKER_ARCH}); verifying QEMU binfmt..."
if ! docker run --rm --privileged tonistiigi/binfmt 2>/dev/null \
| grep -q "\"linux/${PLATFORM}\""; then
cat >&2 <<MSG
ERROR: QEMU binfmt handler for linux/${PLATFORM} is not registered.
Run the one-time host setup first:
bash docker/setup_qemu.sh
Then re-run this script.
MSG
fail "QEMU binfmt missing for linux/${PLATFORM}"
fi
fi
else
echo " platform: (native, no --platform)"
fi
# --load works only for single-platform builds; we never multi-platform here.
BUILD_ARGS+=( --load )
docker buildx build "${BUILD_ARGS[@]}" -t "$TAG" "$BUILD_CTX" 2>&1 | tee "$BUILD_LOG"
rc=${PIPESTATUS[0]}
if [[ $rc -ne 0 ]]; then
fail "docker build exited $rc -- see $BUILD_LOG"
fi
# Sanity check the build's own self-test ran and passed
if grep -q "FAIL: missing wheels\|sm_100 (B200/GB200) missing\|sm_120 (RTX 5090) missing on amd64\|no Blackwell consumer SASS" "$BUILD_LOG"; then
fail "build-time sanity check failed -- see $BUILD_LOG"
fi
grep -E "OK: torch 2.10.0|OK: all required wheels|import cleanly on no-GPU host" "$BUILD_LOG" || \
warn "could not find 'OK:' lines in build log -- did the verification step run?"
ok "built $TAG"
fi
# When the image we just built (or were told to use) does not match the host
# architecture, the smoke test and notebook blocks would attempt to launch
# foreign-arch user-space under QEMU plus --gpus all -- which is broken by
# design: nvidia-container-toolkit cannot expose a GPU to a QEMU-emulated
# guest, and even if it could, CUDA kernels do not run under user-space CPU
# emulation. Skip those blocks with a loud warning so the user doesn't think
# they're seeing a real validation pass.
if [[ ${CROSS_ARCH} -eq 1 ]]; then
warn "cross-arch build (host=${HOST_DOCKER_ARCH}, image=${PLATFORM})."
warn "skipping smoke test + notebook -- CUDA does not work under QEMU runtime."
warn "to validate end-to-end on linux/${PLATFORM}, transfer the image to an"
warn "actual ${PLATFORM} host (e.g. DGX Spark for arm64) and re-run with --skip-build."
banner "summary"
echo " image: $TAG"
echo " platform: linux/${PLATFORM} (cross-built on ${HOST_DOCKER_ARCH})"
echo " log dir: $LOG_DIR"
echo
[[ $SKIP_BUILD -eq 0 ]] && echo " to paste back for PR validation:"
[[ $SKIP_BUILD -eq 0 ]] && echo " tail -80 $LOG_DIR/build.log"
ok "cross-arch build verified (wheels + arch-flags assertion passed)"
exit 0
fi
# ============================================================================
# Block 3a: smoke test
# ============================================================================
banner "Block 3a: smoke test (5-step LoRA on Llama-3.2-1B)"
SMOKE_LOG="$LOG_DIR/smoke.log"
echo " log: $SMOKE_LOG"
docker run --rm --gpus all "$TAG" python /workspace/smoke_test.py 2>&1 | tee "$SMOKE_LOG"
rc=${PIPESTATUS[0]}
if [[ $rc -ne 0 ]]; then
fail "smoke test exited $rc -- see $SMOKE_LOG"
fi
if ! grep -q "all checks passed" "$SMOKE_LOG"; then
fail "smoke test did not print 'all checks passed' -- see $SMOKE_LOG"
fi
ok "smoke test passed"
# ============================================================================
# Block 3b: gpt-oss-20B fine-tuning notebook
# ============================================================================
if [[ $SKIP_NOTEBOOK -eq 1 ]]; then
warn "skipping gpt-oss-20B notebook (--skip-notebook)"
else
banner "Block 3b: gpt-oss-20B fine-tuning notebook (10 LoRA steps)"
GPT_LOG="$LOG_DIR/gpt_oss.log"
HOST_RUN_DIR="$LOG_DIR/host"
mkdir -p "$HOST_RUN_DIR"
echo " log: $GPT_LOG"
echo " host dir: $HOST_RUN_DIR"
cat > "$HOST_RUN_DIR/run_notebook.sh" <<'INNER'
#!/bin/bash
set -e
cd /workspace/host
echo "=== install triton_kernels (MXFP4 support for unsloth/gpt-oss-20b) ==="
pip install -q 'git+https://github.com/triton-lang/triton.git@0add68262ab0a2e33b84524346cb27cbb2787356#subdirectory=python/triton_kernels' 2>&1 | tail -5
echo
echo "=== fetch + convert notebook ==="
# Use nbformat directly. We then post-process to:
# 1. Skip install cells -- the container already has unsloth + deps baked in;
# the notebook's install cell uses Jupyter !shell magic (raw `!pip install
# ...` lines) that nbformat dumps verbatim and Python cannot parse.
# 2. Comment out any stray !cmd / %magic lines in non-install cells.
pip install -q nbformat
curl -fsSL 'https://raw.githubusercontent.com/unslothai/notebooks/main/nb/gpt-oss-(20B)-Fine-tuning.ipynb' -o nb.ipynb
test -s nb.ipynb || { echo "FAIL: nb.ipynb was not downloaded"; exit 1; }
python - <<'PY'
import nbformat, re
nb = nbformat.read('nb.ipynb', as_version=4)
out, skipped = [], 0
INSTALL_MARKERS = (
"pip install", "uv pip install", "apt-get install",
"_original_packages", "COLAB_", "importlib.util.find_spec",
)
for c in nb.cells:
if c.cell_type != "code":
continue
src = c.source or ""
if any(m in src for m in INSTALL_MARKERS):
skipped += 1
first = next((ln for ln in src.splitlines() if ln.strip()), "")[:80]
out.append(f"# (skipped install/setup cell: {first!r})")
out.append("")
continue
for line in src.splitlines():
stripped = line.lstrip()
if stripped.startswith(("!", "%")):
out.append(f"# (jupyter magic stripped) {line}")
else:
out.append(line)
out.append("")
with open("nb.py", "w") as f:
f.write("\n".join(out) + "\n")
print(f" converted nb.py: {sum(1 for _ in open('nb.py'))} lines, {skipped} install cell(s) skipped")
PY
test -s nb.py || { echo "FAIL: nb.py was not produced"; exit 1; }
# Sanity-check: nb.py must parse as valid Python before we try to run it.
python -c "import ast; ast.parse(open('nb.py').read()); print(' nb.py is valid Python')"
echo
echo "=== patch nb.py: max_steps 30 -> 10, drop pre-train demo generations ==="
python - <<'PY'
import re
src = open('nb.py').read()
src = src.replace('max_steps = 30', 'max_steps = 10')
src = re.sub(
r'messages = \[\s*\{[\"\']role[\"\']: [\"\']user[\"\'], [\"\']content[\"\']: [\"\']Solve x\^5.*?\n_ = model\.generate.*?streamer = TextStreamer\(tokenizer\)\)\n',
'# (pre-train inference skipped)\n',
src, flags=re.DOTALL, count=3,
)
open('nb.py', 'w').write(src)
print(' patched. max_steps now:', re.search(r'max_steps = (\d+)', src).group(1))
PY
echo
echo "=== run gpt-oss-20B fine-tuning ==="
python -u nb.py
INNER
chmod +x "$HOST_RUN_DIR/run_notebook.sh"
# Only forward HF_TOKEN if the host has one set, so an empty
# `-e HF_TOKEN=` does not shadow whatever is already inside the image.
# Use the dash-only form `-e HF_TOKEN` so the secret value never
# lands in argv (visible via /proc/<pid>/cmdline to any user on
# the host for the lifetime of the docker CLI process).
HF_ARGS=()
[[ -n "${HF_TOKEN:-}" ]] && HF_ARGS+=(-e HF_TOKEN)
docker run --rm \
--gpus all \
--ipc=host \
--ulimit memlock=-1 \
--ulimit stack=67108864 \
-v "$HOST_RUN_DIR:/workspace/host" \
-v "$HOME/.cache/huggingface:/workspace/.cache/huggingface" \
"${HF_ARGS[@]}" \
-e HF_HUB_ENABLE_HF_TRANSFER=1 \
"$TAG" \
bash /workspace/host/run_notebook.sh 2>&1 | tee "$GPT_LOG"
rc=${PIPESTATUS[0]}
if [[ $rc -ne 0 ]]; then
fail "gpt-oss-20B notebook exited $rc -- see $GPT_LOG"
fi
ok "gpt-oss-20B notebook completed"
fi
# ============================================================================
# Summary
# ============================================================================
banner "summary"
echo " image: $TAG"
echo " log dir: $LOG_DIR"
echo
echo " to paste back for PR validation:"
[[ $SKIP_BUILD -eq 0 ]] && echo " tail -40 $LOG_DIR/build.log"
echo " cat $LOG_DIR/smoke.log"
[[ $SKIP_NOTEBOOK -eq 0 ]] && echo " tail -100 $LOG_DIR/gpt_oss.log"
echo
ok "all blocks completed"