diff --git a/docker/Dockerfile b/docker/Dockerfile index 3c531615f8..06f654ca7a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -252,9 +252,28 @@ RUN set -eux \ # unsloth_nb_compat.py). Each sidecar is transformers==X + matched # huggingface_hub/tokenizers/safetensors, --no-deps into its own --target under # ${VENV}/tf-sidecars. Prepending one to sys.path swaps transformers without -# touching the cu128 base. Versions mirror Studio's tiers (4.57.6 + -# 5.3.0/5.5.0/5.10.2). ~300MB after the strip below. Fail-soft per arch/wheel. +# touching the cu128 base. Candidate versions mirror Studio's tiers (4.57.6 + +# 5.3.0/5.5.0/5.10.2). Fail-soft per arch/wheel. +# +# Every candidate is then VERIFIED against the baked vLLM and dropped if it does +# not survive, because vLLM is version-locked to transformers and a sidecar it +# cannot import does not give the notebook an older transformers -- it gives it +# an ImportError at `import unsloth`, before the first model cell. Measured on +# this image (vLLM 0.26.0): 4.57.6 raises "Support for Transformers v4 ... was +# removed in vLLM v0.24.0" and 5.3.0 raises "cannot import name +# 'ALLOWED_LAYER_TYPES'", between them breaking 254 of the 433 shipped notebooks, +# whose transformers pins select exactly those two. 5.5.0 and 5.10.2 pass. +# +# vllm.transformers_utils.config is the gate because it is the vLLM module that +# reads the transformers API, it reproduces BOTH failures, and it imports without +# a GPU (the build host has none, so `import unsloth` cannot be used here). +# Deriving the kept set instead of hardcoding it means a later vLLM bump that +# widens or narrows the supported range re-tunes the image by itself. The lowest +# survivor is recorded as the selection FLOOR read by unsloth_nb_compat. RUN set -eux \ + && if ${VENV}/bin/python -c "import vllm" >/dev/null 2>&1; then HAVE_VLLM=1; else HAVE_VLLM=0; fi \ + && echo ">> sidecar verification: baked vLLM importable=${HAVE_VLLM}" \ + && KEPT="" \ && 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 \ @@ -271,8 +290,24 @@ RUN set -eux \ ${HFV:+"huggingface_hub==${HFV}"} \ ${TKV:+"tokenizers==${TKV}"} \ ${SFV:+"safetensors==${SFV}"}; \ - echo ">> sidecar transformers==${TFV} (hf_hub=${HFV} tokenizers=${TKV} safetensors=${SFV})"; \ + if [ "$HAVE_VLLM" = "1" ] && ! PYTHONPATH="$DEST" ${VENV}/bin/python \ + -c "import vllm.transformers_utils.config" >/dev/null 2>&1; then \ + echo ">> sidecar transformers==${TFV} DROPPED -- the baked vLLM cannot import under it:"; \ + PYTHONPATH="$DEST" ${VENV}/bin/python \ + -c "import vllm.transformers_utils.config" 2>&1 | tail -2 || true; \ + rm -rf "$DEST"; \ + continue; \ + fi; \ + KEPT="${KEPT} ${TFV}"; \ + echo ">> sidecar transformers==${TFV} kept (hf_hub=${HFV} tokenizers=${TKV} safetensors=${SFV})"; \ done \ + && if [ -z "$KEPT" ]; then \ + echo ">> FATAL: no transformers sidecar survived vLLM verification"; exit 1; \ + fi \ + && if [ "$HAVE_VLLM" = "1" ]; then \ + printf '%s\n' $KEPT | sort -V | head -1 > ${VENV}/tf-sidecars/.vllm_min_transformers; \ + fi \ + && echo ">> sidecars kept:${KEPT} floor=$(cat ${VENV}/tf-sidecars/.vllm_min_transformers 2>/dev/null || echo '(none)')" \ && { du -sh ${VENV}/tf-sidecars || true; } # Informational pin record (NOT byte-reproducible: pip freeze omits wheel hashes diff --git a/docker/unsloth_nb_compat.py b/docker/unsloth_nb_compat.py index 216acc6c9d..36cd1266c5 100644 --- a/docker/unsloth_nb_compat.py +++ b/docker/unsloth_nb_compat.py @@ -14,8 +14,14 @@ keep the base venv intact and ship coherent transformers "sidecars" -- each is a `pip install --target --no-deps transformers==X` plus the matched huggingface_hub/tokenizers/safetensors. To use version X we just prepend its sidecar dir to sys.path BEFORE transformers is imported; the rest of the stack -(torch, vllm, unsloth, peft, trl) comes from the base venv unchanged. Verified: -base unsloth loads + generates under both a 4.57.6 and a 5.5.0 sidecar on B200. +(torch, vllm, unsloth, peft, trl) comes from the base venv unchanged. + +That "rest of the stack" is the catch, and it is why selection has a FLOOR as +well as a ceiling (see sidecar_for): vLLM is version-locked to transformers, so a +sidecar older than what the baked vLLM accepts does not give the notebook an +older transformers, it gives it an ImportError at `import unsloth`. The image +therefore only ships sidecars whose vLLM import has been verified at build time, +and records the lowest of them as the floor. Two activation paths: * driven/headless: `unsloth-run ` sets PYTHONPATH at kernel launch. @@ -31,6 +37,20 @@ SIDECAR_ROOT = os.environ.get("UNSLOTH_TF_SIDECAR_ROOT", "/opt/unsloth-venv/tf-s # The pip/uv shim writes the transformers version a notebook asked for here. MARKER = os.environ.get("UNSLOTH_NB_TF_MARKER", "/tmp/unsloth_nb/requested_transformers") +# Lowest transformers the image's baked vLLM can import. A sidecar below this is +# not "an older transformers", it is a BROKEN image: `import unsloth` dies before +# the first model cell. Written by the Dockerfile's sidecar verification step +# (which imports vllm.transformers_utils.config under every candidate and drops +# the ones that raise), so it tracks whatever vLLM the image actually bakes +# instead of a literal that rots on the next bump. Measured on vLLM 0.26.0: +# +# transformers 4.57.6 FAIL "Support for Transformers v4 ... removed in vLLM v0.24.0" +# transformers 5.3.0 FAIL "cannot import name 'ALLOWED_LAYER_TYPES'" +# transformers 5.5.0 OK +# transformers 5.10.2 OK +# transformers 5.14.1 OK (the baked one, no sidecar) +FLOOR_FILE = os.path.join(SIDECAR_ROOT, ".vllm_min_transformers") + def _logging_enabled() -> bool: """Sidecar activation is silent by default; users found the per-cell @@ -70,6 +90,51 @@ def _baked(): return out +def min_version(): + """Lowest transformers this image's vLLM can import, or None if unrecorded. + + UNSLOTH_TF_SIDECAR_MIN overrides, so a hand-mounted sidecar root can declare + its own floor. Returns None when neither is set, which keeps the pre-floor + behaviour for any environment that never ran the build-time verification.""" + v = os.environ.get("UNSLOTH_TF_SIDECAR_MIN", "").strip() + if v: + return v + try: + with open(FLOOR_FILE) as f: + return f.read().strip() or None + except OSError: + return None + + +def _eligible(): + """Baked sidecars the floor allows, as a sorted [(Version, version_str, dir)]. + + Returns None when the versions cannot be parsed (no packaging available).""" + baked = _baked() + if not baked: + return [] + try: + from packaging.version import Version + except Exception: + return None + floor = min_version() + try: + low = Version(floor) if floor else None + except Exception: + low = None + rows = [] + for v, d in baked.items(): + try: + ver = Version(v) + except Exception: + continue + if low is not None and ver < low: + continue # vLLM cannot import it; activating it only breaks the run + rows.append((ver, v, d)) + rows.sort() + return rows + + def tier_for_model(model_name: str): """Best-effort minimum transformers version for a model id (or None).""" if not model_name: @@ -85,21 +150,42 @@ def tier_for_model(model_name: str): def sidecar_for(version: str): """Map a requested/needed transformers version to a baked sidecar dir. - Uses ceiling semantics: the smallest baked version >= the request, because a - model added in version X needs *at least* X. If the request is newer than - every baked sidecar, return None -> use the base venv (the newest 5.x).""" - baked = _baked() - if not baked or not version: + FLOOR then CEILING, in that order: + + * floor -- a sidecar the baked vLLM cannot import is never eligible, no + matter what the notebook pinned. Selecting one used to break `import + unsloth` in 254 of the 433 shipped notebooks, because the two common pin + families (4.5x -> the 4.57.6 sidecar, 5.2/5.3 -> the 5.3.0 sidecar) both + landed on a sidecar vLLM 0.26.0 refuses. A request below the floor is + clamped UP to the lowest eligible sidecar: that is the closest version to + what the notebook asked for that this image can actually run. + * ceiling -- among the eligible sidecars pick the smallest >= the request, + because a model added in version X needs *at least* X. + + A request newer than every eligible sidecar returns None -> use the base venv + (the newest 5.x), which is always vLLM-compatible.""" + if not version: return None - if version in baked: - return baked[version] + rows = _eligible() + if rows is None: # no packaging: only an exact, still-eligible match is safe + baked = _baked() + d = baked.get(version) + floor = min_version() + return d if (d and (not floor or version == floor)) else None + if not rows: + return None + for _ver, v, d in rows: + if v == version: + return d try: from packaging.version import Version want = Version(version) except Exception: return None - ge = sorted((Version(v), d) for v, d in baked.items() if Version(v) >= want) - return ge[0][1] if ge else None + for ver, _v, d in rows: + if ver >= want: + return d + return None def requested_version(): diff --git a/tests/python/test_docker_tf_sidecar_vllm_floor.py b/tests/python/test_docker_tf_sidecar_vllm_floor.py new file mode 100644 index 0000000000..44357d0f61 --- /dev/null +++ b/tests/python/test_docker_tf_sidecar_vllm_floor.py @@ -0,0 +1,220 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 + +"""Regression guard for transformers-sidecar selection in the Unsloth Docker image. + +The image runs unslothai/notebooks unchanged by refusing a notebook's +`transformers==X` install and activating a baked "sidecar" (transformers X plus +its matched huggingface_hub/tokenizers/safetensors) on sys.path instead. The +selection was a pure CEILING -- smallest baked version >= the request -- which +ignored that vLLM is version-locked to transformers. Two of the four baked +sidecars could not be imported by the baked vLLM 0.26.0 at all, and they were +exactly the two the common pins selected: + + sidecar 4.57.6 ImportError: Support for Transformers v4 is deprecated and + was removed in vLLM v0.24.0 + <- pins 4.48 / 4.52.3 / 4.55.4 / 4.56.1 / 4.56.2 / 4.57.x + = 241 of the 433 shipped notebooks + sidecar 5.3.0 ImportError: cannot import name 'ALLOWED_LAYER_TYPES' from + transformers.configuration_utils + <- pins 5.2.0 / 5.3.0 = 13 more notebooks + +254 of 433 notebooks therefore died at `from unsloth import FastModel`, before +the first model cell. Pointing UNSLOTH_TF_SIDECAR_ROOT at an empty directory, +changing nothing else, turned two of them into clean 22/22 and 25/25 passes. + +The fix is a FLOOR in front of the ceiling. Which versions are above the floor is +not hardcoded: the Dockerfile imports vllm.transformers_utils.config under every +candidate sidecar (the vLLM module that reads the transformers API -- it +reproduces both failures and needs no GPU, which matters because the build host +has none), deletes the ones that raise, and records the lowest survivor. A +request below the floor is clamped UP to the lowest eligible sidecar, which is +the closest thing to the notebook's pin the image can actually run. + +Static: parses the Dockerfile and drives unsloth_nb_compat against a synthetic +sidecar root. No docker, no GPU, no network. +""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCKERFILE = REPO_ROOT / "docker" / "Dockerfile" +COMPAT_PATH = REPO_ROOT / "docker" / "unsloth_nb_compat.py" + +# Every distinct transformers pin across the 433 shipped notebooks, and the +# sidecar each must resolve to once 4.57.6 and 5.3.0 are gone. +SHIPPED_PINS = [ + "4.48", "4.52.3", "4.55.4", "4.56.1", "4.56.2", + "4.57.0", "4.57.1", "4.57.3", "5.2.0", "5.3.0", + "5.5.0", "5.10.1", "5.11.0", +] + + +@pytest.fixture(scope = "module") +def dockerfile() -> str: + assert DOCKERFILE.is_file(), f"missing {DOCKERFILE}" + return DOCKERFILE.read_text() + + +@pytest.fixture(scope = "module") +def sidecar_block(dockerfile: str) -> str: + start = dockerfile.index("tf-sidecars/t_$(echo") + block = dockerfile[dockerfile.rindex("RUN set -eux", 0, start) :] + return block[: block.index("\n\n")] + + +def _load_compat(root, floor = None): + """Import a fresh unsloth_nb_compat bound to a synthetic sidecar root.""" + import os + + prev_root = os.environ.get("UNSLOTH_TF_SIDECAR_ROOT") + prev_min = os.environ.get("UNSLOTH_TF_SIDECAR_MIN") + os.environ["UNSLOTH_TF_SIDECAR_ROOT"] = str(root) + os.environ.pop("UNSLOTH_TF_SIDECAR_MIN", None) + try: + spec = importlib.util.spec_from_file_location("unsloth_nb_compat_under_test", COMPAT_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + finally: + if prev_root is None: + os.environ.pop("UNSLOTH_TF_SIDECAR_ROOT", None) + else: + os.environ["UNSLOTH_TF_SIDECAR_ROOT"] = prev_root + if prev_min is not None: + os.environ["UNSLOTH_TF_SIDECAR_MIN"] = prev_min + return mod + + +@pytest.fixture() +def fixed_root(tmp_path): + """The sidecar root the fixed Dockerfile produces: only verified sidecars, + plus the recorded floor.""" + for name in ("t_5_5_0", "t_5_10_2"): + (tmp_path / name).mkdir() + (tmp_path / ".vllm_min_transformers").write_text("5.5.0\n") + return tmp_path + + +@pytest.fixture() +def stale_root(tmp_path): + """A root that still carries the incompatible sidecars (a bind-mounted or + pre-fix directory). The recorded floor must keep them unselectable.""" + for name in ("t_4_57_6", "t_5_3_0", "t_5_5_0", "t_5_10_2"): + (tmp_path / name).mkdir() + (tmp_path / ".vllm_min_transformers").write_text("5.5.0\n") + return tmp_path + + +# -------------------------------------------------------------------------- +# The build must decide eligibility by measurement, not by a literal. +# -------------------------------------------------------------------------- +def test_build_verifies_every_sidecar_against_the_baked_vllm(sidecar_block: str): + assert "import vllm.transformers_utils.config" in sidecar_block, ( + "each baked sidecar must be proven importable by the baked vLLM; this is " + "the module that reads the transformers API and it reproduces both the " + "v4 refusal and the ALLOWED_LAYER_TYPES break" + ) + + +def test_build_verification_needs_no_gpu(sidecar_block: str): + # `import unsloth` raises NotImplementedError("cannot find any torch + # accelerator") on the build host, so it can never be the gate. + assert "import unsloth" not in sidecar_block, ( + "the sidecar gate must not import unsloth: the build host has no GPU" + ) + + +def test_an_unverifiable_sidecar_is_deleted_not_shipped(sidecar_block: str): + assert re.search(r'DROPPED', sidecar_block), "a failed candidate must be reported" + assert re.search(r'rm -rf "\$DEST"', sidecar_block), ( + "a sidecar the baked vLLM cannot import must be removed, not shipped: it " + "can never be selected safely and it costs image size" + ) + + +def test_build_records_the_selection_floor(sidecar_block: str): + assert ".vllm_min_transformers" in sidecar_block, ( + "the lowest verified version must be recorded for unsloth_nb_compat" + ) + assert "sort -V | head -1" in sidecar_block, "the floor is the LOWEST survivor" + + +def test_build_fails_when_no_sidecar_survives(sidecar_block: str): + assert "exit 1" in sidecar_block, ( + "an empty sidecar set means the whole per-notebook mechanism is dead; " + "that must fail the build rather than ship silently" + ) + + +def test_build_skips_the_gate_when_vllm_is_absent(sidecar_block: str): + # The vLLM install is fail-soft per arch; with no vLLM there is no constraint + # and every sidecar must survive rather than the build exploding. + assert "HAVE_VLLM" in sidecar_block + + +def test_compat_reads_the_floor_the_build_writes(): + assert ".vllm_min_transformers" in COMPAT_PATH.read_text(), ( + "unsloth_nb_compat must read the floor the Dockerfile records, not a " + "literal that rots on the next vLLM bump" + ) + + +# -------------------------------------------------------------------------- +# Selection: floor, then ceiling. +# -------------------------------------------------------------------------- +def test_floor_is_read_back(fixed_root): + assert _load_compat(fixed_root).min_version() == "5.5.0" + + +@pytest.mark.parametrize( + "pin, expected", + [ + # every pin below the floor clamps UP to the lowest eligible sidecar + ("4.48", "t_5_5_0"), ("4.52.3", "t_5_5_0"), ("4.55.4", "t_5_5_0"), + ("4.56.1", "t_5_5_0"), ("4.56.2", "t_5_5_0"), ("4.57.0", "t_5_5_0"), + ("4.57.1", "t_5_5_0"), ("4.57.3", "t_5_5_0"), ("5.2.0", "t_5_5_0"), + ("5.3.0", "t_5_5_0"), + # at and above the floor, the ceiling still decides + ("5.5.0", "t_5_5_0"), ("5.10.1", "t_5_10_2"), + # newer than every sidecar -> the baked transformers + ("5.11.0", None), + ], +) +def test_every_shipped_pin_resolves_to_a_vllm_compatible_sidecar(fixed_root, pin, expected): + got = _load_compat(fixed_root).sidecar_for(pin) + assert (Path(got).name if got else None) == expected + + +def test_no_shipped_pin_can_reach_an_incompatible_sidecar(stale_root): + compat = _load_compat(stale_root) + for pin in SHIPPED_PINS: + got = compat.sidecar_for(pin) + name = Path(got).name if got else None + assert name not in ("t_4_57_6", "t_5_3_0"), ( + f"pin {pin} selected {name}, which the baked vLLM cannot import" + ) + + +def test_model_tier_fallback_is_clamped_too(stale_root): + # tier_for_model maps qwen3-next and friends to 5.3.0; that tier must not + # reach the 5.3.0 sidecar either. + compat = _load_compat(stale_root) + tier = compat.tier_for_model("unsloth/Qwen3-Next-80B-A3B") + assert tier == "5.3.0" + assert Path(compat.sidecar_for(tier)).name == "t_5_5_0" + + +def test_an_unrecorded_floor_keeps_the_old_ceiling_behaviour(tmp_path): + # No .vllm_min_transformers (an environment that never ran the build-time + # verification): selection must not silently start dropping sidecars. + for name in ("t_4_57_6", "t_5_5_0"): + (tmp_path / name).mkdir() + compat = _load_compat(tmp_path) + assert compat.min_version() is None + assert Path(compat.sidecar_for("4.56.2")).name == "t_4_57_6"