Merge remote-tracking branch 'origin/docker-blackwell-build' into pr-jupyter-studio-ux

# Conflicts:
#	.github/workflows/docker-publish.yml
This commit is contained in:
Daniel Han 2026-06-26 09:18:34 +00:00
commit a56a7a8e89
5 changed files with 118 additions and 23 deletions

View file

@ -44,6 +44,10 @@ on:
description: 'unsloth-zoo git ref to bake in'
required: false
default: 'main'
llama_prebuilt_tag:
description: 'unslothai/llama.cpp prebuilt release tag to bake (blank = newest)'
required: false
default: ''
env:
REGISTRY: docker.io
@ -66,6 +70,36 @@ permissions:
contents: read
jobs:
# ---------------------------------------------------------------------------
# Resolve the llama.cpp prebuilt release ONCE, up front, so both arch legs of
# the base build bake the identical GGUF binaries. Resolving "latest" inside
# each leg would let upstream publish a new release between the amd64 and
# arm64 builds, putting different binaries under one published image tag.
# An explicit dispatch input pins a frozen release; otherwise we follow the
# /releases/latest redirect to a concrete tag (mirrors docker/build.sh).
# ---------------------------------------------------------------------------
prepare:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
llama_tag: ${{ steps.llama.outputs.tag }}
steps:
- name: Resolve llama.cpp prebuilt tag
id: llama
env:
INPUT_TAG: ${{ github.event.inputs.llama_prebuilt_tag }}
run: |
TAG="$INPUT_TAG"
if [ -z "$TAG" ]; then
TAG="$(curl -fsSL -o /dev/null -w '%{url_effective}' \
https://github.com/unslothai/llama.cpp/releases/latest \
| sed -n 's#.*/releases/tag/##p')"
fi
echo "tag=${TAG:-latest}" >> "$GITHUB_OUTPUT"
echo "llama.cpp prebuilt tag: ${TAG:-latest}"
# ---------------------------------------------------------------------------
# Per-arch build. The matrix fans out two parallel jobs on the matching
# native runner. Each pushes a single-arch image *by digest* (no human-
@ -75,6 +109,7 @@ jobs:
# that you get when two jobs push the same tag separately.
# ---------------------------------------------------------------------------
build:
needs: prepare
strategy:
fail-fast: false
matrix:
@ -170,12 +205,15 @@ jobs:
# UNSLOTH_ZOO_REF (from the resolve step above): explicit dispatch
# input, else the pushed tag IF the zoo repo has it, else main -- a
# branch SHA does not exist in the zoo repo.
# LLAMA_PREBUILT_TAG (from the prepare job): one concrete tag shared
# by both arch legs so the published manifest is reproducible.
build-args: |
CUDA_VERSION=12.8.1
UBUNTU_VERSION=24.04
PYTHON_VERSION=3.12
UNSLOTH_REF=${{ github.event.inputs.unsloth_ref || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || github.sha || 'main' }}
UNSLOTH_ZOO_REF=${{ steps.zoo_ref.outputs.ref }}
LLAMA_PREBUILT_TAG=${{ needs.prepare.outputs.llama_tag }}
# Stash the per-arch digest as an artifact for the merge job to pick up.
# Filenames need to be unique across the matrix; `platform` contains a
@ -391,10 +429,15 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# latest=false disables metadata-action's implicit latest=auto, which
# would otherwise emit :latest on a v* tag push and bypass the
# default-branch-only gate below. :latest is published only by the
# explicit type=raw rule (default-branch pushes), matching the base job.
flavor: latest=false
tags: |
# The full Studio image owns the unprefixed namespace, headed by
# :latest, with a stable :studio alias. Same gating rationale as
# the core job.
# :latest plus a stable :studio alias (default branch only). Tag
# pushes publish the version tag. Same gating rationale as the core job.
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }}
type=raw,value=studio,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }}
type=ref,event=tag
@ -472,6 +515,9 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Mirror the studio tag rules (incl. latest=false) so the smoke test
# pulls the tag just published, not an implicit latest=auto :latest.
flavor: latest=false
tags: |
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }}
type=raw,value=studio,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }}

View file

@ -15,7 +15,7 @@
# https://developer.nvidia.com/cuda/gpus is covered.
# * Unsloth's runtime kernels are Triton, which JIT-compiles per device at first run.
# * Anything that DOES need to be source-built (rare on this pin set) compiles
# against TORCH_CUDA_ARCH_LIST="7.5;8.0;8.6;8.9;9.0;10.0;10.3;12.0+PTX",
# against TORCH_CUDA_ARCH_LIST="7.5;8.0;8.6;8.9;9.0;10.0;12.0+PTX",
# covering every current NVIDIA compute capability per
# https://developer.nvidia.com/cuda/gpus.
# The host GPU is irrelevant for compilation; nvcc emits whatever the arch
@ -65,7 +65,10 @@ ENV DEBIAN_FRONTEND=noninteractive \
# sm_89 Ada L4, L40, L40S, RTX 40-series (amd64 only)
# sm_90 Hopper H100, H200, GH200 (Grace-Hopper is arm64)
# sm_100 Blackwell DC B100, B200, GB200 (GB200 is arm64)
# sm_103 Blackwell DC B300, GB300
# sm_103 Blackwell DC B300, GB300 -- covered by sm_100 SASS (see above),
# NOT a separate target here: the bundled CUDA 12.8 nvcc cannot
# compile compute_103 (added in CUDA 12.9), so listing 10.3 would
# break any source / JIT build that honors TORCH_CUDA_ARCH_LIST.
# sm_120 Blackwell RTX 50-series, RTX PRO 6000 Blackwell (amd64 only)
# sm_121 Blackwell GB10 (DGX Spark) (arm64 only)
# +PTX on the highest lets future arch revisions run via JIT-PTX.
@ -73,7 +76,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
# don't exist on the build host, and any extras that aren't relevant for
# the target just bloat compile time slightly (not size, since we don't
# source-build any extension at install time on the pin set).
TORCH_CUDA_ARCH_LIST="7.5;8.0;8.6;8.9;9.0;10.0;10.3;12.0+PTX" \
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,
@ -502,9 +505,11 @@ ENV DEBIAN_FRONTEND=noninteractive \
TRITON_CACHE_DIR=/workspace/.cache/triton \
# Keep the arch list visible at runtime in case the user source-builds anything
# extra inside the container (e.g. a custom CUDA op). Same list as the builder
# stage so a `pip install some-cuda-ext` inside the container gets a SASS
# blob that covers every supported arch.
TORCH_CUDA_ARCH_LIST="7.5;8.0;8.6;8.9;9.0;10.0;10.3;12.0+PTX"
# stage so a `pip install some-cuda-ext` inside the container gets a SASS blob
# that covers every supported arch. 10.3 (B300) is intentionally omitted: it
# runs sm_100 SASS, and the bundled CUDA 12.8 nvcc cannot compile compute_103
# (added in CUDA 12.9), so listing it would fail any such in-container build.
TORCH_CUDA_ARCH_LIST="7.5;8.0;8.6;8.9;9.0;10.0;12.0+PTX"
# zstd: the official Ollama notebooks run `curl ollama.com/install.sh | sh`
# inside the container, and that installer extracts a zstd tarball -- without

View file

@ -71,6 +71,10 @@ _VALUE_FLAGS = {
"--abi",
"--implementation",
}
# Of those value-flags, the ones whose VALUE is itself an install target: a
# requirements file pulls real requirements. An index-url / find-links /
# constraint / target value is an option, not something to install.
_REQ_FILE_FLAGS = {"-r", "--requirement"}
def _canon(token):
@ -116,19 +120,30 @@ def main():
head, tail = argv[: i + 1], argv[i + 1 :]
keep_args, dropped, recorded = [], [], None
has_target = False
skip_next = False
prev_flag = None
for tok in tail:
if skip_next:
keep_args.append(tok)
# The value of -r/--requirement pulls real requirements (a target);
# the value of an index-url / find-links / constraint / etc. flag is
# an option, not something to install.
if prev_flag in _REQ_FILE_FLAGS:
has_target = True
skip_next = False
prev_flag = None
continue
if tok in _VALUE_FLAGS:
keep_args.append(tok)
skip_next = True
prev_flag = tok
continue
name = _canon(tok)
if name is None:
keep_args.append(tok) # flag / url / path
keep_args.append(tok) # bare flag, or a positional url / path / vcs
if not tok.startswith("-"):
has_target = True # standalone . / ./pkg / git+... / *.whl
continue
if name == "transformers":
v = _version_pin(tok)
@ -140,6 +155,7 @@ def main():
dropped.append(tok)
continue
keep_args.append(tok)
has_target = True # a kept package spec
if recorded:
try:
@ -155,12 +171,12 @@ def main():
if dropped:
print("[unsloth-nb] kept baked versions, skipped: " + " ".join(dropped))
# Anything left to actually install? Count any non-flag token as a target,
# not just tokens with a canonical pkg name: editable / local / url / vcs
# installs (`-e .`, `.`, `git+https://...`, a wheel URL) carry no canonical
# name but must still run, and a `-r`/`-c` file pulls in real requirements.
has_install_target = any(not t.startswith("-") for t in keep_args)
if not has_install_target:
# Anything left to actually install? `has_target` was set during the scan for
# a kept package spec, a positional url / path / vcs / editable target, or a
# -r/--requirement file. A line carrying only baked packages plus option flags
# (e.g. `--extra-index-url <url> torch`) leaves no target, so no-op instead of
# exec'ing a bare `pip install --extra-index-url <url>` that would fail.
if not has_target:
print("[unsloth-nb] nothing to install after keeping the baked stack; ok.")
return
cmd = [REAL[tool]] + head + keep_args

View file

@ -23,6 +23,7 @@ set -euo pipefail
STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-/opt/unsloth-studio}"
REF=""
ZOO_REF=""
NO_DEPS="--no-deps"
RESTART=1
PACKAGES="unsloth unsloth_zoo"
@ -32,6 +33,7 @@ usage() { sed -n '2,21p' "$0"; }
while [ $# -gt 0 ]; do
case "$1" in
--ref) REF="$2"; shift 2;;
--zoo-ref) ZOO_REF="$2"; shift 2;;
--with-deps) NO_DEPS=""; shift;;
--no-restart) RESTART=0; shift;;
--packages) PACKAGES="$2"; shift 2;;
@ -63,8 +65,23 @@ echo "[studio-update] before: unsloth $(version_of)"
# (or any branch/tag/sha); otherwise take the latest PyPI release.
if [ -n "$REF" ]; then
SPECS="git+https://github.com/unslothai/unsloth.git@${REF}#egg=unsloth"
SPECS="$SPECS git+https://github.com/unslothai/unsloth-zoo.git@${REF}#egg=unsloth_zoo"
echo "[studio-update] installing from git @${REF}"
# unsloth-zoo does NOT track unsloth's tags/SHAs (its release cadence differs;
# the publish workflow resolves the zoo ref separately for the same reason).
# Use --zoo-ref if given; else use the unsloth ref only when the zoo repo
# actually has it, falling back to main so `--ref <unsloth-tag>` does not fail
# on a tag/SHA that simply does not exist in unsloth-zoo.
_zoo_ref="$ZOO_REF"
if [ -z "$_zoo_ref" ]; then
if git ls-remote --exit-code https://github.com/unslothai/unsloth-zoo.git \
"$REF" >/dev/null 2>&1; then
_zoo_ref="$REF"
else
_zoo_ref="main"
echo "[studio-update] unsloth-zoo has no ref '${REF}'; using zoo main"
fi
fi
SPECS="$SPECS git+https://github.com/unslothai/unsloth-zoo.git@${_zoo_ref}#egg=unsloth_zoo"
echo "[studio-update] installing from git: unsloth @${REF}, unsloth-zoo @${_zoo_ref}"
else
SPECS="$PACKAGES"
echo "[studio-update] installing latest release of: $PACKAGES"

View file

@ -152,23 +152,27 @@ record_state() {
# 1) First-boot populate from the baked template (instant, works offline).
if [ ! -f "$STATE" ]; then
: > "$STATE.tmp" 2>/dev/null || true
( cd "$TEMPLATE" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do
rel="${rel#./}"
case "$rel" in .unsloth_template_commit) continue ;; esac
mkdir -p "$DEST/$(dirname "$rel")" 2>/dev/null || true
# A pre-existing file at this path (bind-mounted or hand-created before
# the first boot) is user data: never clobber it. Only lay down the baked
# template when the path is empty or already byte-identical to it. The
# refresh path below has the same ownership rule; this keeps first boot
# symmetric so a mounted notebook survives the very first start too.
# the first boot) is user data: never clobber it, and -- crucially -- do
# NOT record it in the sync state. If it were recorded, the GitHub refresh
# below would see its hash match the recorded hash, treat it as pristine
# and overwrite it with upstream. Only files we actually lay down (or that
# are already byte-identical to the template) are recorded as managed.
if [ -e "$DEST/$rel" ] \
&& [ "$(hash_of "$DEST/$rel")" != "$(hash_of "$TEMPLATE/$rel")" ]; then
echo "[unsloth-nb] kept existing user file: $DEST/$rel"
continue
fi
cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null || true
if cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null; then
printf '%s %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$STATE.tmp"
fi
done
record_state
mv "$STATE.tmp" "$STATE" 2>/dev/null || rm -f "$STATE.tmp"
cp -a "$TEMPLATE/.unsloth_template_commit" "$SYNCED" 2>/dev/null || true
echo "[unsloth-nb] notebooks ready at $DEST"
fi
@ -232,6 +236,13 @@ while IFS= read -r -d '' f; do
dst="$DEST/$rel"
if [ -e "$dst" ]; then
rec="${LAST[$rel]:-}"
if [ -z "$rec" ]; then
# File exists in DEST but the sync state never recorded it -> it is a
# pre-existing user / bind-mounted file. Treat it as user-owned: keep
# it and do not adopt it into the state (so it stays protected).
kept=$((kept + 1))
continue
fi
if [ -n "$rec" ] && [ "$(hash_of "$dst")" != "$rec" ]; then
# User changed this file since we wrote it -> keep theirs, keep marker.
printf '%s %s\n' "$rec" "$rel" >> "$TMPSTATE"