unsloth/docker/Dockerfile
Daniel Han 9f96419446 docker: per-run transformers marker, exact vLLM deadline, non-root workspace
Three fixes from review:

unsloth-run now gives each invocation its own UNSLOTH_NB_TF_MARKER (a
temp file, cleaned up afterwards) unless the caller pinned one. The
shared default marker leaked one run's transformers pin into later or
concurrent runs in the same container: a notebook pinned to 4.57.6
left the marker behind and the next unpinned run's kernel activated
the stale sidecar. An empty marker reads as no pin, so pre-creating
the file is safe.

The vLLM startup wait in dataprep/synthetic.py capped every poll at a
full second regardless of the remaining budget, so a fractional
timeout could overshoot by up to a second. The final wait is now
clamped to the remaining time; verified empirically (timeout=1.1
elapses 1.10s).

/workspace and the default HF/Triton cache dirs were root-owned, so
docker run --user without a bind mount could not sync notebooks or
populate caches. They are now world-writable (a+rwX), matching the
documented non-root use the /opt prebuilt placement already supports.
2026-07-19 14:14:34 +00:00

605 lines
35 KiB
Docker

# syntax=docker/dockerfile:1.7
# -----------------------------------------------------------------------------
# Unsloth + unsloth-zoo for every current NVIDIA arch (Turing -> Blackwell),
# on linux/amd64 and linux/arm64.
#
# Why it works:
# * cu128 wheels ship native SASS (no PTX), verified via `cuobjdump --list-elf`:
# amd64: sm_70 sm_75 sm_80 sm_86 sm_90 sm_100 sm_120
# arm64: sm_80 sm_90 sm_90a sm_100 sm_100a sm_120 sm_120a
# * SASS is forward-compatible within a major: sm_86->sm_89 (Ada),
# sm_100->sm_103 (B300/GB300), sm_120->sm_121 (DGX Spark/GB10), so every
# non-Jetson GPU on https://developer.nvidia.com/cuda/gpus runs precompiled
# SASS (torch, llama.cpp, source-built ops).
# * Triton kernels JIT per-device at first run; the bundled cu12.8 ptxas/NVRTC
# cannot emit compute_103/compute_121, so the cu13 override below handles
# amd64 sm_103 and arm64 sm_121 (SASS still runs there via forward-compat, so
# only JIT-heavy paths need it).
# * Rare source builds compile against
# TORCH_CUDA_ARCH_LIST="7.5;8.0;8.6;8.9;9.0;10.0;12.0+PTX"; the host GPU is
# irrelevant, nvcc emits whatever the arch list says.
#
# Cross-arch build (arm64 / sm_121): built via QEMU binfmt on an x86_64 host
# (`docker run --privileged --rm tonistiigi/binfmt --install all` once, then
# `docker buildx build --platform linux/arm64 ...`). QEMU is build-time only;
# the image runs natively on aarch64. xformers has no cu128 aarch64 wheel, so
# arm64 falls back to Unsloth's SDPA (~5-10% slower, functionally complete).
#
# Build host needs Docker buildkit + buildx, and QEMU binfmt for arm64-on-x86_64;
# nvidia-container-toolkit only for test-time `--gpus all`. No GPU at build time.
# -----------------------------------------------------------------------------
ARG CUDA_VERSION=12.8.1
ARG UBUNTU_VERSION=24.04
ARG PYTHON_VERSION=3.12
# =============================================================================
# Stage 1: builder -- toolkit + dev headers, builds any source extensions
# =============================================================================
FROM nvidia/cuda:${CUDA_VERSION}-cudnn-devel-ubuntu${UBUNTU_VERSION} AS builder
# TARGETARCH (buildx: amd64/arm64) selects the unsloth extras matching the
# wheels available for the platform (xformers aarch64 gap -- see header).
ARG TARGETARCH
ARG PYTHON_VERSION
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
# Cross-compile for every current NVIDIA arch (developer.nvidia.com/cuda/gpus):
# sm_75 Turing (T4, RTX 20xx) | sm_80 A100/A30 | sm_86 A40/RTX 30xx
# sm_89 Ada (L4/L40/RTX 40xx) | sm_90 Hopper (H100/H200/GH200)
# sm_100 Blackwell DC (B100/B200/GB200) | sm_120 Blackwell (RTX 50xx, RTX PRO 6000)
# sm_103 (B300/GB300) and sm_121 (GB10) are omitted: CUDA 12.8 nvcc cannot
# compile compute_103/121 and sm_100/sm_120 SASS cover them via forward-compat.
# +PTX on the highest lets future revisions JIT. Same list on both arches:
# nvcc emits archs absent from the host, irrelevant extras only cost a little
# compile time (no source builds on this pin set).
TORCH_CUDA_ARCH_LIST="7.5;8.0;8.6;8.9;9.0;10.0;12.0+PTX" \
MAX_JOBS=4 \
CUDA_HOME=/usr/local/cuda \
# Build-host-independence guards: the build must NEVER introspect a GPU (host
# may be a B200, RTX 6000, or GPU-less CI) so all yield byte-identical images.
# 1) no JIT-compiled sm_NNN blob into unsloth_compiled_cache/ at import.
UNSLOTH_COMPILE_DISABLE=1 \
UNSLOTH_COMPILE_OVERWRITE=0 \
# 2) don't probe torch.cuda.is_available() at setup (would silently skip wheels).
UNSLOTH_DISABLE_GPU_PROBE=1 \
# 3) empty CUDA_VISIBLE_DEVICES so stray torch.cuda calls see no devices, not
# host-specific paths (re-enabled at runtime via `docker run --gpus all`).
CUDA_VISIBLE_DEVICES=""
RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common ca-certificates curl git build-essential \
ninja-build cmake pkg-config \
&& add-apt-repository -y ppa:deadsnakes/ppa \
&& apt-get update && apt-get install -y --no-install-recommends \
python${PYTHON_VERSION} python${PYTHON_VERSION}-venv python${PYTHON_VERSION}-dev \
&& ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python \
&& ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python3 \
&& rm -rf /var/lib/apt/lists/*
# Isolated prefix. We do NOT touch the system Python (Ubuntu 24.04 marks it
# externally-managed, PEP 668); the venv bootstraps pip via ensurepip (from the
# python3.12-venv apt package) and gets uv a few lines below.
ENV VENV=/opt/unsloth-venv
RUN python -m venv ${VENV} && ${VENV}/bin/pip install -U pip wheel setuptools
# Unified install: torch + triton + bitsandbytes + unsloth + unsloth_zoo in a
# SINGLE uv pass. Mandatory -- splitting it lets bnb's transitive `cuda-toolkit`
# silently upgrade torch to 2.12.0+cu130 in a later pass, breaking the pinned
# cu128 xformers wheel (the cu cascade hits after xformers is on disk).
#
# Flags:
# --index-strategy unsafe-best-match: the PyTorch index serves an old
# requests==2.28.1 that conflicts with datasets>=2.32.2; both indexes
# (pytorch cu128 + pypi) are equally trusted, so override uv's first-wins.
# --extra-index-url .../cu128: torch +cu128 wheels + the xformers/cu128 URLs
# from unsloth's cu128onlytorch2110 extra.
#
# Plain `huggingface` extra + explicit xformers pin (amd64): the cu128 extras on
# main stop at torch2100 (xformers 0.0.34 -> torch 2.10.0), conflicting with the
# torch 2.11.0 held below; a missing extra name would only WARN and drop xformers
# until the check below failed. Pinning xformers==0.0.35 (untied to torch) keeps
# this self-contained; arm64 stays xformers-less (no cu128 aarch64 wheel).
#
# No flash-attn: FA3 is hard-refused on Blackwell (Dao-AILab/flash-attention#1810);
# FA2 has no cu128+torch2.11+cp312 wheel (~30min fragile source build on CI) and
# Unsloth falls back to xformers/SDPA anyway. Users on Ampere/Ada/Hopper can
# `pip install flash-attn` on top at deploy time.
ARG UNSLOTH_REF=main
ARG UNSLOTH_ZOO_REF=main
RUN set -eux \
&& case "${TARGETARCH:-amd64}" in \
amd64) UNSLOTH_EXTRA="huggingface"; XFORMERS_PIN="xformers==0.0.35" ;; \
arm64) UNSLOTH_EXTRA="huggingface"; XFORMERS_PIN="" ;; \
*) echo "ERROR: unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& echo ">> TARGETARCH=${TARGETARCH:-amd64}, unsloth extra=[${UNSLOTH_EXTRA}], xformers=[${XFORMERS_PIN}]" \
&& ${VENV}/bin/pip install uv \
&& ${VENV}/bin/uv pip install \
--python ${VENV}/bin/python \
--index-strategy unsafe-best-match \
--extra-index-url https://download.pytorch.org/whl/cu128 \
"torch==2.11.0" "torchvision==0.26.0" "torchaudio==2.11.0" \
${XFORMERS_PIN} \
"triton>=3.6.0" \
"bitsandbytes>=0.49.2,!=0.46.0,!=0.48.0" \
"unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo@${UNSLOTH_ZOO_REF}" \
"unsloth[${UNSLOTH_EXTRA}] @ git+https://github.com/unslothai/unsloth@${UNSLOTH_REF}" \
"timm>=1.0.11" "addict"
# vLLM: required by Unsloth's GRPO path (fast_inference=True). Installed as a
# SECOND uv pass so the unified pass settles on torch 2.11.0 first, then vLLM
# bolts on top: with torch held, uv picks the newest compatible vLLM (0.20+ pins
# torch 2.11.0) and tracks our pin. PyPI ships x86_64 + aarch64 wheels since 0.17,
# so this runs on arm64 too; amd64 failures abort, arm64 is fail-soft (aarch64
# kernels are validated on Spark hardware, not CI).
# https://docs.vllm.ai/en/latest/getting_started/installation/gpu/
# https://wheels.vllm.ai/nightly
ARG INSTALL_VLLM=auto
RUN set -eux \
&& WANT_VLLM=0 \
&& case "${INSTALL_VLLM}" in \
auto|1|true|yes) WANT_VLLM=1 ;; \
0|false|no) WANT_VLLM=0 ;; \
*) echo "ERROR: invalid INSTALL_VLLM=${INSTALL_VLLM}" >&2; exit 1 ;; \
esac \
&& if [ "${WANT_VLLM}" = "1" ]; then \
echo ">> installing vLLM (TARGETARCH=${TARGETARCH:-amd64})"; \
# Explicit && chain, not `set -e` -- POSIX shells disable errexit inside a
# condition context (verified on dash), masking install failures.
# Step 1: uv resolves vLLM's deps with torch==2.11.0 held, landing on the
# newest matching vLLM (fails loudly if none); unsafe-best-match picks the
# best wheel across the three indexes.
# Step 2: vLLM pulls numpy down to 2.2.6 whose wheel ships a broken
# numpy.testing (tests/ stripped), crashing `from numpy import *` and thus
# `import unsloth`; upgrade numpy back to a self-consistent release.
# Step 3: vLLM pins numba 0.61.2 which hard-refuses numpy>=2.3, but the
# stack needs numpy>=2.3; lift numba to one supporting numpy 2.4 (0.65
# imports cleanly, vllm still imports).
{ ${VENV}/bin/uv pip install \
--python ${VENV}/bin/python \
--pre \
--index-strategy unsafe-best-match \
--extra-index-url https://wheels.vllm.ai/nightly \
--extra-index-url https://download.pytorch.org/whl/cu128 \
"torch==2.11.0" \
vllm \
&& ${VENV}/bin/uv pip install \
--python ${VENV}/bin/python \
--upgrade "numpy>=2.4" \
&& ${VENV}/bin/uv pip install \
--python ${VENV}/bin/python \
--upgrade "numba>=0.62" \
&& ${VENV}/bin/python -c "import vllm; print('vllm', vllm.__version__)" \
&& ${VENV}/bin/python -c "import numpy.testing, numpy; print('numpy', numpy.__version__, 'testing ok')" \
&& ${VENV}/bin/python -c "import numba; print('numba', numba.__version__, 'imports ok')" \
# flashinfer-jit-cache: precompiled cubins so flashinfer ops don't hit the
# JIT path (standalone `vllm serve` dies there for fmha_gen on sm_100a;
# in-process GRPO survives since zoo blocks the FlashInfer JIT). Removes
# the runtime-compile failure class for ~1.5 GB.
&& { ${VENV}/bin/uv pip install \
--python ${VENV}/bin/python \
--index-url https://flashinfer.ai/whl/cu128 \
"flashinfer-jit-cache==0.6.6" \
|| echo ">> flashinfer-jit-cache unavailable for ${TARGETARCH:-amd64}; vllm serve may require nvcc for uncached ops"; } \
&& echo ">> vLLM installed (numpy + numba re-upgraded post-vllm)"; \
} || { \
if [ "${TARGETARCH:-amd64}" != "amd64" ]; then \
echo ">> vLLM skipped on ${TARGETARCH}: install or import check failed (fail-soft on non-amd64)"; \
# A partial install must not poison the base stack: drop vllm and
# restore the numpy/numba floor it may have moved (numpy 2.2.6's
# broken numpy.testing breaks `import unsloth`). arm64 staging CI
# re-verifies `import unsloth` after this.
${VENV}/bin/uv pip uninstall --python ${VENV}/bin/python vllm || true; \
${VENV}/bin/uv pip install --python ${VENV}/bin/python \
--upgrade "numpy>=2.4" "numba>=0.62"; \
${VENV}/bin/python -c "import numpy.testing, numba; print('numpy/numba restored')"; \
else \
echo "ERROR: vLLM install failed on amd64" >&2; exit 1; \
fi; \
}; \
else \
echo ">> vLLM skipped (INSTALL_VLLM=${INSTALL_VLLM}, TARGETARCH=${TARGETARCH:-amd64})"; \
fi
# JupyterLab so the image runs unslothai/notebooks out of the box:
# docker run --gpus all -p 8888:8888 unsloth/unsloth \
# jupyter lab --ip 0.0.0.0 --port 8888 --allow-root --no-browser
# Separate pass AFTER the torch-pinned resolves: pure-Python, never names torch,
# so uv can't disturb the cu128 pin set. These are declared by notebook install
# cells (neutralised by the in-image runner), so bake them here:
# matplotlib plotting; some trust_remote_code files import it (DeepSeek-OCR)
# soundfile TTS audio read/write (bundles libsndfile)
# evaluate+jiwer Whisper WER metric
# tensorboard default TrainingArguments report_to backend
# langid DeepSeek-R1 GRPO reward language-id check
# easydict some vision trust_remote_code modeling files
# protobuf slow->fast tokenizer conversion for sentencepiece
# omegaconf TTS + NeMo-Gym RL notebook configs
# einx TTS codec tensor-rearrange (Llasa/Oute/Spark)
# librosa Whisper audio features (pulls numba, already pinned >=0.65)
# ftfy Oute TTS text normalisation
# decord is separate below (no aarch64 wheel). Pinned (==) for reproducible
# rebuilds; bump deliberately. The resolve must NOT move torch/numpy/numba --
# the assertion below fails the build if it did. Transitive deps land in
# /opt/unsloth-venv/requirements.lock.txt.
RUN ${VENV}/bin/uv pip install \
--python ${VENV}/bin/python \
"jupyterlab==4.6.0" "notebook==7.6.0" "ipywidgets==8.1.8" "matplotlib==3.11.0" \
"soundfile==0.14.0" "evaluate==0.4.6" "jiwer==4.0.0" "tensorboard==2.20.0" \
"langid==1.1.6" "easydict==1.13" "protobuf==6.33.6" \
"omegaconf==2.3.1" "einx==0.4.3" "librosa==0.11.0" "ftfy==6.3.1" \
&& ${VENV}/bin/python -c "import torch, numpy, numba; from packaging.version import Version; assert torch.__version__.startswith('2.11.0'), torch.__version__; assert Version(numpy.__version__) >= Version('2.3'), numpy.__version__; assert Version(numba.__version__) >= Version('0.65'), numba.__version__; print('notebook-deps pins OK:', torch.__version__, numpy.__version__, numba.__version__)"
# decord (ERNIE-VL video decode) has wheels only for x86_64. Installed alone:
# HARD on amd64 (a missing wheel is a real regression), fail-soft elsewhere.
RUN if [ "${TARGETARCH:-amd64}" = "amd64" ]; then \
${VENV}/bin/uv pip install --python ${VENV}/bin/python "decord==0.6.0"; \
else \
${VENV}/bin/uv pip install --python ${VENV}/bin/python "decord==0.6.0" \
|| echo ">> decord skipped (no matching wheel for ${TARGETARCH:-}); ERNIE-VL video decode unavailable"; \
fi
# Audio decode out of the box: TTS/STT notebooks decode datasets' Audio features
# through torchcodec. Three traps: (1) torchcodec 0.11 must pair with torch 2.11;
# (2) the wheel must come from the cu128 channel, not the PyPI default (cu13,
# dlopens libnvrtc.so.13); (3) its libs dlopen venv torch/NVIDIA libs that ld.so
# can't see, registered via ld.so.conf.d in the runtime stage (this layer only
# installs the wheels; the import check needs ffmpeg from the runtime stage).
# Fail-soft on arches without a matching wheel.
RUN set -eux \
&& { ${VENV}/bin/uv pip install \
--python ${VENV}/bin/python \
--index-url https://download.pytorch.org/whl/cu128 \
"torchcodec==0.11.0" \
&& ${VENV}/bin/uv pip install --python ${VENV}/bin/python nvidia-npp-cu12; } \
|| echo ">> torchcodec bake skipped (no matching wheel for ${TARGETARCH:-amd64})"
# transformers SIDECARS for per-notebook version activation (see
# unsloth_nb_compat.py). unslothai/notebooks pin many transformers versions; the
# base venv ships the newest 5.x. Each sidecar is transformers==X + matched
# huggingface_hub/tokenizers/safetensors, installed --no-deps into its own
# --target under ${VENV}/tf-sidecars. Activating one (prepend to sys.path before
# any ML import) swaps transformers WITHOUT touching the cu128 torch/vLLM/unsloth
# base -- verified: base unsloth loads + generates under 4.57.6 and 5.5.0 on B200.
# Versions mirror Studio's tiers (4.57.6 + 5.3.0/5.5.0/5.10.2); companions are
# resolved at build time. ~300MB after the strip below. Fail-soft per arch/wheel.
RUN set -eux \
&& for TFV in 4.57.6 5.3.0 5.5.0 5.10.2; do \
SCRATCH="$(mktemp -d)"; \
if ! ${VENV}/bin/uv pip install --python ${VENV}/bin/python \
--target "$SCRATCH" "transformers==${TFV}" >/dev/null 2>&1; then \
echo ">> sidecar resolve failed for ${TFV}; skipping"; rm -rf "$SCRATCH"; continue; \
fi; \
pin() { ls -d "$SCRATCH/$1"-*.dist-info 2>/dev/null \
| sed -E "s@.*/$1-([0-9][0-9A-Za-z.]*)\.dist-info@\1@" | head -1; }; \
HFV="$(pin huggingface_hub)"; TKV="$(pin tokenizers)"; SFV="$(pin safetensors)"; \
rm -rf "$SCRATCH"; \
DEST="${VENV}/tf-sidecars/t_$(echo "${TFV}" | tr . _)"; \
${VENV}/bin/uv pip install --python ${VENV}/bin/python --target "$DEST" --no-deps \
"transformers==${TFV}" \
${HFV:+"huggingface_hub==${HFV}"} \
${TKV:+"tokenizers==${TKV}"} \
${SFV:+"safetensors==${SFV}"}; \
echo ">> sidecar transformers==${TFV} (hf_hub=${HFV} tokenizers=${TKV} safetensors=${SFV})"; \
done \
&& { du -sh ${VENV}/tf-sidecars || true; }
# 5) Informational pin record (NOT byte-reproducible: pip freeze omits wheel
# hashes and unsloth/unsloth_zoo/vllm --pre float from VCS/nightly). Read via
# `docker run --rm <image> cat /opt/unsloth-venv/requirements.lock.txt`.
RUN ${VENV}/bin/pip freeze --exclude-editable > ${VENV}/requirements.lock.txt \
&& head -50 ${VENV}/requirements.lock.txt
# 6) Strip pip cache & __pycache__ to shrink the layer copied to runtime.
# The `-name tests` strip excludes numpy's tests dirs: numpy 2.4 ships
# numpy/_core/tests/ back, and removing it re-triggers the
# `from numpy._core.tests._natype import pd_NA` ImportError the earlier upgrade
# fixed. Other verified-safe size cuts:
# * npp: torchcodec dlopens only libnppicc + libnppc; drop the other ~10 libs (~388MB).
# * static .a archives (~143MB): link-time only, nothing links venv archives.
# * nvshmem device .bc (~30MB): device-relink only; host .so kept.
# Do NOT strip headers (torch/include): causal-conv1d / mamba-ssm build against
# them at notebook time with --no-build-isolation.
RUN set -eux \
&& find ${VENV} -depth -type d -name __pycache__ -exec rm -rf {} + \
&& find ${VENV} -depth -type d -name tests \
! -path "*numpy/_core/tests*" \
! -path "*numpy/tests*" \
! -path "*numpy/ma/tests*" \
-exec rm -rf {} + \
&& rm -rf /root/.cache/pip /root/.cache/uv \
&& SP=${VENV}/lib/python${PYTHON_VERSION}/site-packages \
&& if [ -d "$SP/nvidia/npp/lib" ]; then \
find "$SP/nvidia/npp/lib" -maxdepth 1 -name 'libnpp*.so.*' \
! -name 'libnppicc.so.*' ! -name 'libnppc.so.*' -delete; \
fi \
&& find ${VENV} -name '*.a' -delete \
&& rm -f "$SP"/nvidia/nvshmem/lib/libnvshmem_device.bc \
&& echo "venv size after prune:" && du -sh ${VENV}
# Build-time verification.
# (1) arch-list check uses the RAW C++ accessor: torch.cuda.get_arch_list()
# returns [] with no GPU visible (CUDA_VISIBLE_DEVICES is empty here).
# (2) required packages verified via metadata only -- we do NOT import unsloth/
# unsloth_zoo (their __init__ needs a real CUDA device). Import correctness is
# exercised at deploy time by smoke_test.py with --gpus all.
RUN TARGETARCH="${TARGETARCH:-amd64}" ${VENV}/bin/python - <<'PY'
import os, platform
target = os.environ.get("TARGETARCH", "amd64")
mach = platform.machine()
print(f"build target: TARGETARCH={target} platform.machine()={mach}")
import torch
arches = torch._C._cuda_getArchFlags().split()
print("torch", torch.__version__, "cuda", torch.version.cuda)
print("arches:", arches)
assert torch.__version__.startswith("2.11.0"), f"torch silently moved: {torch.__version__}"
assert "+cu128" in torch.__version__, f"cu build silently changed: {torch.__version__}"
assert "sm_100" in arches, f"sm_100 (B200/GB200) missing: {arches}"
# cu128 wheels ship sm_120 native SASS on both amd64 and aarch64. On arm64 DGX
# Spark (sm_121) runs it via forward-compat; sm_121 is never in a cu128 wheel.
assert "sm_120" in arches, f"sm_120 missing: {arches}"
print(f"OK: torch 2.11.0+cu128 with sm_100 + sm_120 native SASS intact ({target})")
from importlib.metadata import version, PackageNotFoundError
# xformers is amd64-only (aarch64 wheel gap -- see header).
REQUIRED = ["torch", "triton", "bitsandbytes", "unsloth",
"unsloth_zoo", "transformers", "trl", "peft", "accelerate"]
if target == "amd64":
REQUIRED.insert(2, "xformers")
missing = []
for pkg in REQUIRED:
try:
v = version(pkg.replace("_", "-"))
print(f" {pkg:14s} {v}")
except PackageNotFoundError:
missing.append(pkg)
if missing:
raise SystemExit(f"FAIL: missing wheels: {missing}")
print("OK: all required wheels present")
# Lightweight imports: these init without touching CUDA, unlike unsloth.
import importlib
LIGHT_IMPORTS = ["bitsandbytes", "triton"]
if target == "amd64":
LIGHT_IMPORTS.insert(0, "xformers")
for pkg in LIGHT_IMPORTS:
importlib.import_module(pkg)
print(f"OK: {' + '.join(LIGHT_IMPORTS)} import cleanly on no-GPU host")
PY
# =============================================================================
# Stage 2: runtime -- slim runtime image, no nvcc, no cuDNN/cuBLAS layers
# =============================================================================
# The "-base-" variant (vs "-cudnn-runtime-") drops ~2.7 GB of system CUDA libs
# we never load: torch wheels bake their own cuDNN/cuBLAS/etc into torch/lib/ and
# libtorch_cuda.so's RPATH resolves through the wheel, not the system (verified
# via `readelf -d torch/lib/libtorch_cuda.so`). The base still provides
# nvidia-smi + libcuda stubs + libnvidia-ml, all the entrypoint + torch.cuda need.
FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu${UBUNTU_VERSION} AS runtime
# The base manifest is multi-arch; buildx picks the right one for
# TARGETPLATFORM at this FROM line, no conditional needed.
ARG TARGETARCH
ARG PYTHON_VERSION
ARG CUDA_VERSION
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH=/opt/unsloth-venv/bin:${PATH} \
HF_HOME=/workspace/.cache/huggingface \
TRITON_CACHE_DIR=/workspace/.cache/triton \
# Keep the arch list at runtime so an in-container source build gets the same
# SASS coverage as the builder; 10.3 omitted (cu12.8 can't emit compute_103;
# sm_103 runs sm_100 SASS via forward-compat).
TORCH_CUDA_ARCH_LIST="7.5;8.0;8.6;8.9;9.0;10.0;12.0+PTX"
# System packages needed by the notebooks:
# zstd Ollama installer (`curl ollama.com/install.sh | sh`) extracts a zstd tarball
# ffmpeg torchcodec dlopens system FFmpeg libs (not bundled in the wheel)
# wget notebooks fetch assets with `!wget URL`
# ninja-build flashinfer cpp_ext JIT shells out to ninja; subprocesses may not
# inherit the venv pip ninja on PATH
# cuda-nvcc + cudart-dev flash-linear-attention's TileLang backend (Qwen3.5 in
# Studio) JIT-compiles CUDA kernels via /usr/local/cuda/bin/nvcc,
# absent from the -base image
RUN CUDA_PKG="$(echo "${CUDA_VERSION}" | awk -F. '{print $1"-"$2}')" \
&& apt-get update && apt-get install -y --no-install-recommends \
software-properties-common ca-certificates curl wget git libgomp1 \
gcc g++ zstd ffmpeg ninja-build \
"cuda-nvcc-${CUDA_PKG}" "cuda-cudart-dev-${CUDA_PKG}" \
&& add-apt-repository -y ppa:deadsnakes/ppa \
&& apt-get update && apt-get install -y --no-install-recommends \
python${PYTHON_VERSION} python${PYTHON_VERSION}-venv python${PYTHON_VERSION}-dev \
&& ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python \
&& ln -sf /usr/bin/python${PYTHON_VERSION} /usr/local/bin/python3 \
&& test -x /usr/local/cuda/bin/nvcc \
&& rm -rf /var/lib/apt/lists/*
# gcc + g++ + python3.12-dev in runtime: Triton's nvidia backend lazily compiles
# a C extension (CudaUtils) on first GPU access; without a C compiler + Python
# headers the first forward pass dies with "Failed to find C compiler". ~250MB,
# the cost of correct JIT (pre-compiling would need a GPU).
COPY --from=builder /opt/unsloth-venv /opt/unsloth-venv
# Blackwell JIT fix for sm_103 (amd64) and sm_121 (arm64) -- the cu12.8 JIT gap
# from the header. Two JIT paths need the cu13 override:
# (1) torch's bundled libnvrtc.so.12 (CUDA 12.8): any NVRTC JIT path (e.g.
# torch.fft.rfft(complex).abs() in mel-spectrogram code) errors on
# sm_103/sm_121. Fix: stage a cu13 NVRTC alias beside the cu12.8 default.
# (2) Triton's bundled ptxas (still 12.8 in triton 3.6.0) tops out at sm_120,
# rejects sm_103, silently downgrades sm_121 to sm_80 (triton-lang/triton#8335).
# Fix: install cu13 ptxas, point Triton at it via TRITON_PTXAS_PATH.
# Both cu13 tools are CPU-side compilers (no install-time driver bump), but their
# output cubin needs a >=580 driver to LOAD, so neither is a global default (would
# break Ampere/Ada/Hopper/Turing on 570-579 drivers). select_cuda_jit_tools in
# entrypoint.sh activates them per device, only for sm_103/sm_121 (>=580 drivers,
# always safe). Both arches carry the ~400 MB.
RUN set -eux; \
# The nvidia/cuda base already configures the CUDA apt repo with its own
# Signed-By keyring; adding cuda-keyring_1.1-1_all.deb would add a second
# sources file with a different Signed-By and make `apt-get update` refuse
# the repo ("Conflicting values set for option Signed-By"). The base repo
# is monolithic and serves 13.x too, so install cu13 packages directly
# without touching the keyring. Verified on x86_64 and arm runners.
apt-get update; \
apt-get install -y --no-install-recommends \
cuda-nvrtc-13-0 \
cuda-nvcc-13-0; \
# cu13's config-common postinst flips the /usr/local/cuda alternative to
# cuda-13.0 (priority 130 beats 12.8's 128). Pin it back: TileLang JIT
# and torch.utils.cpp_extension resolve /usr/local/cuda/bin/nvcc, and
# cu13-emitted cubins need driver >= 580 while this image supports 570+.
# The cu13 tools stay reachable by absolute path (how the entrypoint
# activates them on sm_103/sm_121), and --set switches the alternative
# to manual mode so later apt operations cannot flip it again.
update-alternatives --set cuda /usr/local/cuda-12.8; \
rm -rf /var/lib/apt/lists/*; \
# (1) NVRTC staging: keep the wheel's cu12.8 lib as .cu128.orig, point
# libnvrtc.so.12 at it (relative symlink), stage .cu13 -> the cu13 lib;
# select_cuda_jit_tools retargets the symlink only on sm_103/sm_121.
# The default needs no runtime write, so a non-root --user container
# keeps cu12.8, loadable on every supported 570+ driver.
NVRTC_DIR=/opt/unsloth-venv/lib/python${PYTHON_VERSION}/site-packages/nvidia/cuda_nvrtc/lib; \
if [ -f "${NVRTC_DIR}/libnvrtc.so.12" ] && [ ! -L "${NVRTC_DIR}/libnvrtc.so.12" ]; then \
mv "${NVRTC_DIR}/libnvrtc.so.12" "${NVRTC_DIR}/libnvrtc.so.12.cu128.orig"; \
ln -s libnvrtc.so.12.cu128.orig "${NVRTC_DIR}/libnvrtc.so.12"; \
ln -s /usr/local/cuda-13.0/lib64/libnvrtc.so.13 "${NVRTC_DIR}/libnvrtc.so.12.cu13"; \
fi
# (2) ptxas: the cu13 nvcc package above provides it; TRITON_PTXAS_PATH is set
# per device at boot (select_cuda_jit_tools) for the same driver-floor reason.
# Register the venv's torch + NVIDIA lib dirs with the loader so torchcodec can
# dlopen them. ld.so.conf.d, NOT LD_LIBRARY_PATH: the cache is consulted after
# DT_RUNPATH, so llama.cpp keeps resolving its own $ORIGIN libs first. Import
# check runs here (ffmpeg lives in this stage); fail-soft if the wheel is absent.
RUN set -eux \
&& SP=/opt/unsloth-venv/lib/python${PYTHON_VERSION}/site-packages \
&& printf "%s\n" "$SP/torch/lib" "$SP/nvidia/cuda_nvrtc/lib" \
"$SP/nvidia/cuda_runtime/lib" "$SP/nvidia/npp/lib" \
> /etc/ld.so.conf.d/zz-unsloth-venv.conf \
&& ldconfig \
&& { /opt/unsloth-venv/bin/python -c \
"import torchcodec; print('torchcodec', torchcodec.__version__)" \
|| echo ">> torchcodec unavailable on this arch (audio decode falls back)"; }
# Prebuilt llama.cpp so GGUF export works out of the box. unsloth_zoo's
# save_pretrained_gguf() -> check_llama_cpp() looks for llama-quantize +
# convert_hf_to_gguf.py + gguf-py/ in $UNSLOTH_LLAMA_CPP_PATH; without a baked
# install the first export hits install_llama_cpp()'s prompt + slow source build.
#
# NOT studio/install_llama_prebuilt.py: it selects a bundle for the CURRENT host
# (nvidia-smi etc., which leak through a GPU build host). The build must never
# introspect the host, so pin release + asset by build target (see
# fetch_llama_prebuilt.py):
# * amd64 -> app-<tag>-linux-x64-cuda12-portable.tar.gz (sm_70..sm_120)
# arm64 -> app-<tag>-linux-arm64-cuda13-portable.tar.gz (sm_90..sm_121)
# * portable bundles carry their own CUDA libs, so they also run CPU-only
# * sha256-verified against the release's llama-prebuilt-sha256.json
# * converter + gguf-py from the SAME release's source tarball (mappings match)
# /opt (not /root) so it survives `docker run --user`; UNSLOTH_LLAMA_CPP_PATH lets
# zoo find it regardless of $HOME. Default "latest" resolves the newest
# unslothai/llama.cpp release at build time; build.sh pins it to a concrete tag so
# the cache busts only on new upstream releases. --build-arg LLAMA_PREBUILT_TAG=<tag>
# for a frozen build.
ARG LLAMA_PREBUILT_TAG=latest
COPY fetch_llama_prebuilt.py /tmp/fetch_llama_prebuilt.py
RUN /opt/unsloth-venv/bin/python /tmp/fetch_llama_prebuilt.py \
"${LLAMA_PREBUILT_TAG}" "${TARGETARCH:-amd64}" /opt/unsloth/llama.cpp \
&& rm -f /tmp/fetch_llama_prebuilt.py \
&& cat /opt/unsloth/llama.cpp/UNSLOTH_PREBUILT_INFO.json
ENV UNSLOTH_LLAMA_CPP_PATH=/opt/unsloth/llama.cpp
WORKDIR /workspace
# World-writable so `docker run --user <uid>` (documented non-root use) can
# create notebooks and populate the default caches without a bind mount.
RUN mkdir -p ${HF_HOME} ${TRITON_CACHE_DIR} \
&& chmod -R a+rwX /workspace
# ---------------------------------------------------------------------------
# Per-notebook transformers version activation -- run unslothai/notebooks
# UNCHANGED (see unsloth_nb_compat.py). Pieces:
# * unsloth_nb_compat.py -> site-packages: tier detection + sidecar resolution
# + activation + IPython hook.
# * pip/uv shim on a PATH dir AHEAD of the venv bin: makes `!pip install` /
# `!uv pip install` cells safe + idempotent (keeps the baked stack, records
# the requested transformers to activate its sidecar).
# * unsloth_nb_pip_magic.py -> site-packages: re-points `%pip`/`%uv` magics and
# `!python -m pip` at the same shim so in-process installs can't bypass PATH.
# * IPython startup hook: activates the right sidecar before the first model cell.
# * unsloth-run: headless `unsloth-run <notebook|url>`, the robust driven path.
# ---------------------------------------------------------------------------
COPY unsloth_nb_compat.py unsloth_pip_shim.py unsloth_nb_pip_magic.py unsloth_ipython_startup.py unsloth_run.py unsloth_sync_notebooks.sh unsloth_nb_content_sig.py unsloth_nb_view.py unsloth_nb_strip_colab.py unsloth_colab_compat.py /opt/unsloth-nb/
RUN set -eux \
&& SP=/opt/unsloth-venv/lib/python${PYTHON_VERSION}/site-packages \
&& cp /opt/unsloth-nb/unsloth_nb_compat.py "$SP/unsloth_nb_compat.py" \
&& cp /opt/unsloth-nb/unsloth_nb_pip_magic.py "$SP/unsloth_nb_pip_magic.py" \
&& cp /opt/unsloth-nb/unsloth_colab_compat.py "$SP/unsloth_colab_compat.py" \
&& chmod +x /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/unsloth_run.py /opt/unsloth-nb/unsloth_sync_notebooks.sh /opt/unsloth-nb/unsloth_nb_content_sig.py /opt/unsloth-nb/unsloth_nb_view.py /opt/unsloth-nb/unsloth_nb_strip_colab.py \
&& mkdir -p /opt/unsloth-nb/bin \
&& for t in pip pip3 uv; do ln -sf /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/bin/$t; done \
&& ln -sf /opt/unsloth-nb/unsloth_run.py /usr/local/bin/unsloth-run \
&& ln -sf /opt/unsloth-nb/unsloth_sync_notebooks.sh /usr/local/bin/unsloth-sync-notebooks \
&& ln -sf /opt/unsloth-nb/unsloth_nb_content_sig.py /usr/local/bin/unsloth-nb-content-sig \
&& ln -sf /opt/unsloth-nb/unsloth_nb_view.py /usr/local/bin/unsloth-nb-view \
&& ln -sf /opt/unsloth-nb/unsloth_nb_strip_colab.py /usr/local/bin/unsloth-nb-strip-colab \
&& mkdir -p /opt/unsloth-nb/ipython/profile_default/startup \
&& cp /opt/unsloth-nb/unsloth_ipython_startup.py /opt/unsloth-nb/ipython/profile_default/startup/00-unsloth-nb.py \
&& chmod -R a+rX /opt/unsloth-nb/ipython \
&& /opt/unsloth-venv/bin/python -c "import sys, glob; sys.path.insert(0, '$SP'); import unsloth_nb_compat, unsloth_colab_compat; print('nb-compat OK; baked sidecars:', sorted(glob.glob('/opt/unsloth-venv/tf-sidecars/t_*')))" \
&& /opt/unsloth-venv/bin/python /opt/unsloth-nb/unsloth_pip_shim.py --unsloth-selfcheck-value-flags
# Shim dir AHEAD of the venv bin so `!pip`/`!uv` resolve to the shim, not the real tool.
ENV PATH=/opt/unsloth-nb/bin:${PATH}
# Load the notebook startup hook (sidecar activation + %pip/%uv magic re-point)
# for EVERY kernel, any uid: IPYTHONDIR (via ENV) points IPython at this shared
# profile, so it loads under `--user <uid>` too -- unlike /root/.ipython, which
# only a root kernel reads. Writable state (history.sqlite) still lands per-user.
ENV IPYTHONDIR=/opt/unsloth-nb/ipython
# Pre-clone unslothai/notebooks so JupyterLab opens with them present. Baked as a
# READ-ONLY template (~206MB, .git stripped); on boot the entrypoint copies it to
# /workspace/unsloth-notebooks and best-effort refreshes from GitHub, never
# overwriting a user-touched notebook and skipping rewrites when only the install
# header/announcements/footer moved (see unsloth_sync_notebooks.sh +
# unsloth_nb_content_sig.py). Inherited as-is by the studio image.
#
# UNSLOTH_NOTEBOOKS_REF pins ONE commit/branch/tag so a multi-arch publish bakes
# identical templates into both legs. The publish workflow resolves the live HEAD
# sha once (like LLAMA_PREBUILT_TAG); default "main" tracks the tip. Fetched at
# depth 1 (sha by object, branch/tag by name).
ARG UNSLOTH_NOTEBOOKS_REF=main
RUN set -eux \
&& git init -q /opt/unsloth-notebooks \
&& git -C /opt/unsloth-notebooks remote add origin https://github.com/unslothai/notebooks \
&& git -C /opt/unsloth-notebooks fetch -q --depth 1 origin "${UNSLOTH_NOTEBOOKS_REF}" \
&& git -C /opt/unsloth-notebooks checkout -q FETCH_HEAD \
&& git -C /opt/unsloth-notebooks rev-parse HEAD > /opt/unsloth-notebooks/.unsloth_template_commit \
&& rm -rf /opt/unsloth-notebooks/.git \
&& du -sh /opt/unsloth-notebooks
# JupyterLab lives in the venv. notebooks are pre-populated into
# /workspace/unsloth-notebooks on boot; mount a volume on /workspace to persist.
EXPOSE 8888
COPY smoke_test.py /workspace/smoke_test.py
COPY entrypoint.sh /usr/local/bin/unsloth-entrypoint
RUN chmod +x /usr/local/bin/unsloth-entrypoint
# Entrypoint runs three fast pre-flight checks before user code:
# 1. nvidia-smi sees at least one GPU (catches missing --gpus all)
# 2. torch.cuda.is_available() is True (catches host driver too old)
# 3. compute capability >= sm_80 (catches pre-Ampere GPUs)
# Each check fails with an actionable error pointing to the fix.
# Bypass for offline tooling: docker run -e UNSLOTH_SKIP_GPU_CHECK=1 ...
ENTRYPOINT ["/usr/local/bin/unsloth-entrypoint"]
# Default command: interactive python REPL.
# Override examples:
# docker run --gpus all unsloth/unsloth:latest python /workspace/smoke_test.py
# docker run --gpus all -it unsloth/unsloth:latest bash
CMD ["python"]