From 8402dcebdd3bbc74aca90e68363b3ef19b2d9d02 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 26 Jun 2026 08:27:11 +0000 Subject: [PATCH 1/2] docker-publish: pin one llama.cpp prebuilt release across both arch legs The base build-args never passed LLAMA_PREBUILT_TAG, so the Dockerfile fell back to latest and each matrix leg resolved whatever unslothai/llama.cpp release was current at its own build time. If latest moved between the amd64 and arm64 legs, one published manifest could carry different GGUF binaries per arch. Resolve the release once in a new prepare job (explicit llama_prebuilt_tag dispatch input for a frozen build, else follow the /releases/latest redirect to a concrete tag, mirroring docker/build.sh) and pass that single tag to both legs. --- .github/workflows/docker-publish.yml | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0226343192..9085b261a4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 From 0ebbdbb9cccf31e1611968639d9ec41bdc580c75 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 26 Jun 2026 09:06:09 +0000 Subject: [PATCH 2/2] 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 torch' now no-ops after keeping the baked stack instead of exec'ing a bare 'pip install --extra-index-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 ' 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. --- .github/workflows/docker-publish.yml | 10 +++++++++- docker/Dockerfile | 17 ++++++++++------ docker/unsloth_pip_shim.py | 30 +++++++++++++++++++++------- docker/unsloth_studio_update.sh | 21 +++++++++++++++++-- docker/unsloth_sync_notebooks.sh | 23 +++++++++++++++------ 5 files changed, 79 insertions(+), 22 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9085b261a4..d68f7c1889 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 5ae04ef038..8b937ab827 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/unsloth_pip_shim.py b/docker/unsloth_pip_shim.py index 635de00fe7..42c774f401 100644 --- a/docker/unsloth_pip_shim.py +++ b/docker/unsloth_pip_shim.py @@ -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 torch`) leaves no target, so no-op instead of + # exec'ing a bare `pip install --extra-index-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 diff --git a/docker/unsloth_studio_update.sh b/docker/unsloth_studio_update.sh index fb888ddd59..287d805ab8 100755 --- a/docker/unsloth_studio_update.sh +++ b/docker/unsloth_studio_update.sh @@ -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 ` 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" diff --git a/docker/unsloth_sync_notebooks.sh b/docker/unsloth_sync_notebooks.sh index 73db179e34..2727e4427b 100644 --- a/docker/unsloth_sync_notebooks.sh +++ b/docker/unsloth_sync_notebooks.sh @@ -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"