diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9371d8e0d2..f568689c1e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -231,12 +231,10 @@ jobs: # arm64 image lacks /usr/share/dotnet, hence `|| true`. - name: Reclaim disk run: | - # The hosted runners keep ~14-20 GB free, which is not enough for - # the image plus buildkit state (empirically confirmed: the Studio - # layer install died with ENOSPC on a staging run before this list - # was extended). None of these preinstalled toolchains are used - # here; some paths differ between the amd64 and arm64 runner - # images, hence `|| true`. + # Hosted runners keep only ~14-20 GB free -- not enough for the image + # plus buildkit state (Studio install hit ENOSPC before this list grew). + # None of these toolchains are used here; paths differ across the amd64 + # and arm64 runners, hence `|| true`. sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ /opt/hostedtoolcache "$AGENT_TOOLSDIRECTORY" \ /usr/local/.ghcup /usr/share/swift \ @@ -426,12 +424,10 @@ jobs: - name: Reclaim disk run: | - # The hosted runners keep ~14-20 GB free, which is not enough for - # the image plus buildkit state (empirically confirmed: the Studio - # layer install died with ENOSPC on a staging run before this list - # was extended). None of these preinstalled toolchains are used - # here; some paths differ between the amd64 and arm64 runner - # images, hence `|| true`. + # Hosted runners keep only ~14-20 GB free -- not enough for the image + # plus buildkit state (Studio install hit ENOSPC before this list grew). + # None of these toolchains are used here; paths differ across the amd64 + # and arm64 runners, hence `|| true`. sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ /opt/hostedtoolcache "$AGENT_TOOLSDIRECTORY" \ /usr/local/.ghcup /usr/share/swift \ diff --git a/docker/Dockerfile b/docker/Dockerfile index 3e250b6c9d..13bdb884e3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -586,21 +586,15 @@ COPY --from=builder /opt/unsloth-venv /opt/unsloth-venv # triton-lang/triton#8335. Fix: install cu13 ptxas and point Triton at # it with TRITON_PTXAS_PATH. # -# Both cu13 tools are activated ONLY for sm_103/sm_121, at runtime (see -# select_cuda_jit_tools in entrypoint.sh), NOT baked as a global ENV/symlink -# default: cu13 emits a cubin that a 570-579 driver cannot LOAD even when it -# targets an older arch (CUDA 13 requires a >= 580 driver), so forcing every -# host's JIT through cu13 would break the Ampere/Ada/Hopper/Turing GPUs this -# image still supports on 570+ drivers. sm_103/sm_121 launched after cu12.8 and -# only ship on >= 580 drivers, so gating cu13 to them is always safe. -# -# NVRTC and ptxas are CPU-side compilers; they do NOT call into libcuda, so -# cu13 installs alongside the cu128 runtime with no driver-floor bump at INSTALL -# time (570+). Their OUTPUT is a different story: a cu13 cubin needs a >= 580 -# driver to LOAD, so the tools are ACTIVATED per device at runtime (only for the -# sm_103/sm_121 hosts, which ship >= 580 drivers) -- see select_cuda_jit_tools -# in entrypoint.sh. Both arches carry the ~400 MB now: amd64 needs it for -# sm_103, arm64 for sm_121. +# Both cu13 tools are CPU-side compilers (no libcuda call), so they install +# alongside the cu128 runtime with no driver-floor bump at INSTALL time (570+). +# But their OUTPUT cubin needs a >= 580 driver to LOAD, so they are NOT baked as a +# global ENV/symlink default -- forcing every host's JIT through cu13 would break +# the Ampere/Ada/Hopper/Turing GPUs this image still supports on 570-579 drivers. +# They are activated per device at runtime only for sm_103/sm_121 (which launched +# after cu12.8 and only ship on >= 580 drivers, so gating cu13 to them is always +# safe) -- see select_cuda_jit_tools in entrypoint.sh. Both arches carry the +# ~400 MB: amd64 needs it for sm_103, arm64 for sm_121. RUN set -eux; \ # The nvidia/cuda base already configures the CUDA apt repo (x86_64 or # sbsa) with its own Signed-By keyring at @@ -632,15 +626,10 @@ RUN set -eux; \ 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 override. triton 3.6.0's own ptxas is cu12.8 (no sm_103/sm_121), so -# those two arches need the cu13 ptxas installed above. It is NOT baked as a -# global ENV: cu13 ptxas emits a cubin whose ABI a 570-579 driver cannot LOAD -# (CUDA 13 needs a >= 580 driver), even when targeting an older arch like sm_80, -# so pointing every host's Triton at it would break training on the Ampere/Ada/ -# Hopper/Turing GPUs this image still supports on 570+ drivers. TRITON_PTXAS_PATH -# is therefore selected per device at boot (only sm_103/sm_121, which ship >= 580 -# drivers, get cu13; everything else keeps Triton's bundled cu12.8 ptxas) -- see -# select_cuda_jit_tools in entrypoint.sh. +# (2) ptxas override. triton 3.6.0's ptxas is cu12.8 (no sm_103/sm_121), so those +# two arches need the cu13 ptxas installed above. Not baked as a global ENV for the +# same driver-floor reason as NVRTC (a cu13 cubin needs a >= 580 driver to load); +# TRITON_PTXAS_PATH is selected per device at boot -- see select_cuda_jit_tools. # Register the venv's torch + NVIDIA lib dirs with the loader so torchcodec # (installed in the builder, see the bake comment there) can dlopen them. diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index f392cb36cf..9c85d8d745 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -2,9 +2,8 @@ # # This is the image published as docker.io/unsloth/unsloth:studio (and the # default :latest). It layers Unsloth Studio on top of the lean core image -# (Dockerfile, published under the `core` tags) and runs the same service trio -# as the previous production -# image: Studio on 8000, JupyterLab on 8888, key-only sshd on 22. +# (Dockerfile, published under the `core` tags) and runs the same service trio as +# the previous production image: Studio on 8000, JupyterLab on 8888, sshd on 22. # # Build (local): # docker buildx build \ @@ -233,10 +232,8 @@ COPY --from=labext-builder /opt/labext-src/unsloth-jupyterlab/labextension /opt/ # The sloth-sticker install is the ONLY fail-soft branding step: it is scoped to # its own { ...; } group with a `|| echo` fallback below, so a missing Studio # "Sloth emojis" folder does not break the build, while the REQUIRED steps above -# it (JS resolve, favicon/logo/login copy) stay fatal. (The comment is kept out -# of the RUN body so no comment line sits inside a backslash continuation, which -# some Dockerfile parsers choke on.) login.html's onerror falls back to the -# Unsloth logo if the sticker dir is ever absent. +# it (JS resolve, favicon/logo/login copy) stay fatal. login.html's onerror falls +# back to the Unsloth logo if the sticker dir is ever absent. COPY jupyter/favicon.ico /tmp/unsloth-branding/favicon.ico COPY jupyter/logo.png /tmp/unsloth-branding/logo.png COPY jupyter/login.html /tmp/unsloth-branding/login.html diff --git a/docker/test_locally.sh b/docker/test_locally.sh index 7d0ddeb059..86fbabadfc 100755 --- a/docker/test_locally.sh +++ b/docker/test_locally.sh @@ -81,10 +81,9 @@ 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. +# Verify we can talk to the docker daemon as the current user -- catches the +# "user not in docker group" case up front, instead of a later buildx +# "permission denied on /var/run/docker.sock" that masquerades as a build failure. DOCKER_INFO_OUT=$(docker info 2>&1) DOCKER_INFO_RC=$? if [[ $DOCKER_INFO_RC -ne 0 ]]; then diff --git a/docker/unsloth_colab_compat.py b/docker/unsloth_colab_compat.py index 224ce9bb88..b01833bee8 100644 --- a/docker/unsloth_colab_compat.py +++ b/docker/unsloth_colab_compat.py @@ -34,11 +34,9 @@ from __future__ import annotations import sys -# Cell magics whose body is executed as code (Python or shell), so a hoisted -# `#@title`/`#@param`/comment line stays an inert comment. We ONLY hoist these. -# Anything not listed (content/data magics like %%writefile, %%file, %%html, -# %%javascript, %%latex, %%markdown, %%svg) is left untouched, because injecting -# the Colab form comment into its body would corrupt the written file / output. +# Cell magics whose body runs as code (Python or shell), so a hoisted comment +# stays inert. We ONLY hoist these; content/data magics (%%writefile, %%html, +# ...) are left untouched (see the module docstring). _SAFE_CELL_MAGICS = frozenset( { "capture", # the Colab install pattern: suppress pip/install output diff --git a/docker/unsloth_nb_strip_colab.py b/docker/unsloth_nb_strip_colab.py index 83807a2d1c..4e4e457307 100644 --- a/docker/unsloth_nb_strip_colab.py +++ b/docker/unsloth_nb_strip_colab.py @@ -20,14 +20,12 @@ # unsloth_nb_strip_colab.py [b.ipynb ...] # strip the listed notebooks in place (idempotent). # unsloth_nb_strip_colab.py --state --dest -# STATE-aware sync migration. STATE is the " " file that -# unsloth_sync_notebooks.sh records for every file it wrote. For each -# .ipynb entry that still hashes to its recorded value (i.e. WE own it and -# the user has not edited it), strip the intro and update the recorded hash -# in place. User-edited notebooks (current hash != recorded) are left -# untouched. This is the safe "rewrite, then record" step the sync runs -# after every STATE write, so it covers first-boot populate, deleted-file -# restore, GitHub refresh, and in-place image upgrades in one pass. +# STATE-aware sync migration. For each .ipynb in the " " +# STATE file (written by unsloth_sync_notebooks.sh) that still hashes to its +# recorded value (WE own it, unedited), strip the intro and update the +# recorded hash in place; user-edited notebooks (hash != recorded) are left +# untouched. Runs after every STATE write, covering first-boot populate, +# deleted-file restore, GitHub refresh and in-place image upgrades. # # Safe with refresh decisions: unsloth_nb_content_sig.py already classifies the # intro cell as boilerplate, so the body digest used to detect "only boilerplate @@ -43,15 +41,12 @@ import sys # The stable identifier for the offending line (covers every GPU/Cloud variant). _INTRO_PREFIX = "to run this, press" -# ipywidgets MIME types. The baked notebooks ship example tqdm/progress-bar -# widget outputs (model.safetensors download bars, dataset Map bars, ...) plus a -# metadata.widgets state block. JupyterLab's ipywidgets manager cannot always -# rebuild the Colab-saved state, so those outputs render as a stuck -# "Loading widget..." placeholder. Dropping the widget outputs + orphan state -# removes the placeholder; running the cell yourself still creates a fresh, -# working widget. Outputs are not part of the refresh signature -# (unsloth_nb_content_sig.middle_digest hashes only cell type+source), so this is -# safe for edit/refresh detection. +# The baked notebooks ship example tqdm/progress-bar widget outputs plus a +# metadata.widgets state block; JupyterLab's ipywidgets manager cannot always +# rebuild the Colab-saved state, so they render as a stuck "Loading widget..." +# placeholder. Dropping the widget outputs + orphan state removes it; running the +# cell still creates a fresh widget. Outputs are not part of the refresh signature +# (content_sig hashes only cell type+source), so this is safe for edit detection. _WIDGET_VIEW_MIME = "application/vnd.jupyter.widget-view+json" diff --git a/docker/unsloth_nb_view.py b/docker/unsloth_nb_view.py index 2bbbacd11b..89dd8c7b8d 100644 --- a/docker/unsloth_nb_view.py +++ b/docker/unsloth_nb_view.py @@ -48,12 +48,10 @@ _OTHER = "Other Notebooks" def clean_section(title): """README header text -> a filesystem-friendly folder label.""" - # Drop a trailing run of '#', surrounding whitespace and any emoji/symbols - # that sometimes lead a header; keep ASCII text, digits and a few separators. + # Drop trailing '#' and surrounding whitespace. title = title.strip().strip("#").strip() - # Strip a leading run of emoji / symbols / punctuation that some domain - # headers lead with (e.g. "🐧 AMD Notebooks", "📒 Kaggle Notebooks") so the - # folder label is clean text. + # Strip a leading run of emoji / symbols some domain headers lead with (e.g. + # "🐧 AMD Notebooks", "📒 Kaggle Notebooks") so the folder label is clean text. title = re.sub(r"^[^\w]+", "", title) title = title.replace("-", " ").replace("/", " ") title = re.sub(r"\s+", " ", title).strip() diff --git a/docker/unsloth_pip_shim.py b/docker/unsloth_pip_shim.py index 9d64b7f726..0aef89328a 100644 --- a/docker/unsloth_pip_shim.py +++ b/docker/unsloth_pip_shim.py @@ -95,14 +95,11 @@ _CONSTRAINT_FILE_FLAGS = {"-c", "--constraint", "--constraints"} # drop BOTH the flag and its value; dropping the value alone leaves pip a # dangling `-e` that swallows the next kept package and fails the whole cell. _EDITABLE_FLAGS = {"-e", "--editable"} -# -P/--upgrade-package is uv's selective-upgrade flag and -# --reinstall-package is uv's selective-reinstall flag: naming a baked -# package (e.g. `uv pip install -P torch peft` or -# `uv pip install --reinstall-package torch peft`) lets an ordinary install -# target refresh/reinstall that package and clobber the pinned stack. Filter the -# value through _KEEP too, dropping the flag+value pair for a protected name so -# no dangling selector is left to swallow the next kept target. Unlike -e none of -# these is itself an install target (no has_target). +# -P/--upgrade-package and --reinstall-package are uv's selective upgrade/reinstall +# flags: naming a baked package (`uv pip install -P torch peft`) lets an ordinary +# target refresh/reinstall it and clobber the pinned stack. Filter the value through +# _KEEP too, dropping the flag+value pair for a protected name so no dangling +# selector swallows the next target. Unlike -e, none is itself an install target. _UPGRADE_PKG_FLAGS = {"-P", "--upgrade-package", "--reinstall-package"} # Short value-flags pip/uv accept in the ATTACHED form, i.e. the 2-char flag # glued to its value in one token: `-rreqs.txt`, `-cconstraints.txt`, `-epath`, @@ -112,25 +109,17 @@ _UPGRADE_PKG_FLAGS = {"-P", "--upgrade-package", "--reinstall-package"} # attached `-c`/`-e`/`-P` value bypasses _KEEP. _ATTACHED_SHORT_FLAGS = {"-r", "-c", "-e", "-P"} # Resolver-wide reinstall / ignore-installed switches (pip --force-reinstall, -# --ignore-installed, -I; uv --reinstall) force the tool to REINSTALL packages -# that are already satisfied -- including the baked torch/transformers pulled in -# as dependencies of a kept target. Drop them in shim mode so a -# `pip install --force-reinstall peft` cannot rebuild the pinned stack under the -# guise of installing an unprotected package. The kept target still installs; its -# already-satisfied protected deps are left untouched. Per-package selectors -# (--reinstall-package / -P) are handled through _UPGRADE_PKG_FLAGS instead. -# uv's --exact is destructive the other way around: it performs an exact SYNC, -# REMOVING every installed package outside the kept target's closure (vLLM, -# bitsandbytes, the NVIDIA libs, ...), so `uv pip install --exact peft` would -# strip the baked stack after the argument filter kept it off the command line. +# --ignore-installed, -I; uv --reinstall) REINSTALL already-satisfied packages, +# including the baked torch/transformers a kept target pulls in as deps. Drop them +# so an unprotected install cannot rebuild the pinned stack; the kept target still +# installs. Per-package selectors (-P / --reinstall-package) go via _UPGRADE_PKG_FLAGS. +# uv's --exact is destructive the other way: an exact SYNC that REMOVES everything +# outside the kept target's closure (vLLM, bitsandbytes, NVIDIA libs), so drop it too. _REINSTALL_FLAGS = {"--force-reinstall", "--ignore-installed", "-I", "--reinstall", "--exact"} # Value-flags whose flag+value pair is dropped outright in shim mode. -# `--upgrade-strategy eager` makes pip upgrade EVERY dependency of a kept target -# regardless of whether the installed version already satisfies it, which would -# refresh the baked torch/transformers under the pinned CUDA stack. Dropping the -# flag falls back to pip's default `only-if-needed`, so a kept target still -# installs but already-satisfied protected deps stay put. (`only-if-needed` is -# the default, so dropping a `--upgrade-strategy only-if-needed` is a no-op.) +# `--upgrade-strategy eager` makes pip upgrade EVERY dependency of a kept target, +# refreshing the baked torch/transformers. Dropping it falls back to pip's default +# `only-if-needed`, so the target still installs but satisfied protected deps stay. _DROP_VALUE_FLAGS = {"--upgrade-strategy"} diff --git a/docker/unsloth_sync_notebooks.sh b/docker/unsloth_sync_notebooks.sh index 3ed9e8ebbb..1c66427783 100644 --- a/docker/unsloth_sync_notebooks.sh +++ b/docker/unsloth_sync_notebooks.sh @@ -157,12 +157,11 @@ if [ ! -f "$STATE" ]; then 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, 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. + # A pre-existing file here (bind-mounted or hand-created before first boot) + # is user data: keep it, and do NOT record it in the sync state -- if + # recorded, the GitHub refresh below would see the hash match, treat it as + # pristine and overwrite it. Only files we lay down (or that already match + # the template byte-for-byte) 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" diff --git a/tests/sh/test_select_cuda_jit_tools.sh b/tests/sh/test_select_cuda_jit_tools.sh index 7838b835af..61cea0f45c 100755 --- a/tests/sh/test_select_cuda_jit_tools.sh +++ b/tests/sh/test_select_cuda_jit_tools.sh @@ -3,19 +3,12 @@ # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # Unit tests for select_cuda_jit_tools() from docker/entrypoint.sh. # -# The image bakes CUDA 13 ptxas + NVRTC, but a cu13 cubin cannot LOAD on a -# 570-579 driver even when it targets an old arch like sm_80 (CUDA has forward, -# not backward, driver compatibility across major versions). So cu12.8 is the -# IMMUTABLE baked default (libnvrtc.so.12 -> .cu128.orig), and the cu13 tools are -# switched on ONLY for the two Blackwell datacenter arches that require them -- -# sm_103 (B300 / GB300) and sm_121 (GB10 / DGX Spark), which only ship on >= 580 -# drivers. Every other supported arch (Turing..sm_120) keeps the cu12.8 default, -# untouched, so a 570+ driver host -- including a non-root --user container that -# cannot rewrite the symlink -- is never broken. -# -# The function picks per device via nvidia-smi compute_cap: DC -> retarget -# libnvrtc.so.12 -> the staged .cu13 alias (and point Triton at cu13 ptxas); -# anything else -> leave the cu12.8 default in place and ptxas unset. +# cu12.8 is the immutable baked default (libnvrtc.so.12 -> .cu128.orig); the cu13 +# tools are switched on ONLY for sm_103 (B300 / GB300) and sm_121 (GB10 / DGX +# Spark), which ship on >= 580 drivers -- see the rationale in docker/entrypoint.sh. +# The function picks per device via nvidia-smi compute_cap: those two arches +# retarget libnvrtc.so.12 -> the staged .cu13 alias (and point Triton at cu13 +# ptxas); every other arch keeps the cu12.8 default and leaves ptxas unset. set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"