docker: address review follow-ups (pip-shim flags, sync ownership, tags, zoo ref, arch list)

- pip shim: do not treat the value of an index-url / find-links / constraint flag
  as an install target. A cell like 'pip install --extra-index-url <url> torch'
  now no-ops after keeping the baked stack instead of exec'ing a bare
  'pip install --extra-index-url <url>' that fails. Positional . / url / vcs and
  -r/--requirement files still count as targets.
- notebook sync: on first boot, record only files we actually wrote (or that are
  byte-identical to the template), never a kept pre-existing user file; and on the
  GitHub refresh, treat a file present in DEST but absent from the sync state as
  user-owned and keep it. Previously a bind-mounted notebook was recorded as
  managed and then overwritten by upstream.
- docker-publish: add flavor latest=false to the Studio metadata steps too, so a
  v* tag push cannot emit an implicit :latest via metadata-action's latest=auto;
  :latest stays default-branch-only, and the smoke test pulls the published tag.
- unsloth-studio-update: resolve the unsloth-zoo ref independently of --ref (new
  --zoo-ref, else use the ref only when the zoo repo has it, else fall back to
  main) so 'update --ref <unsloth-tag/sha>' does not fail on a missing zoo ref.
- Dockerfile: drop 10.3 (compute_103) from TORCH_CUDA_ARCH_LIST in both the
  builder and runtime stages. B300 runs sm_100 SASS, and the bundled CUDA 12.8
  nvcc cannot compile compute_103 (added in 12.9), which broke arch-list-honoring
  source / JIT builds.
This commit is contained in:
Daniel Han 2026-06-26 09:06:09 +00:00
commit 0ebbdbb9cc
5 changed files with 79 additions and 22 deletions

View file

@ -429,9 +429,14 @@ 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. Same :latest gating rationale as the base job.
# :latest (default branch only). Tag pushes publish the version tag.
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }}
type=ref,event=tag
type=schedule,pattern=nightly
@ -508,6 +513,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=ref,event=tag

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

@ -78,23 +78,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
@ -158,6 +162,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"