Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c28a4a6
Fix three notebook regressions caught by Blackwell Docker validation
danielhanchen May 24, 2026
0f9153c
patch_vllm_graph_capture: bail out cleanly when vllm is not installed
danielhanchen May 24, 2026
a5770ba
load_vllm: raise actionable ImportError when vllm is not installed
danielhanchen May 24, 2026
62b7017
llama_cpp.install_package: handle EOFError on input() in non-TTY cont…
danielhanchen May 24, 2026
1d96f7c
patch_deepseek_v2_moe_capitalisation_alias: bridge transformers 4 -> …
danielhanchen May 24, 2026
6dec606
notebook_deps + vllm decompose_size_nodes monkey patches
danielhanchen May 25, 2026
30d603e
load_vllm: force off FlashInfer when nvcc / ninja missing
danielhanchen May 25, 2026
08d5844
notebook_deps: add einops to the auto-install allow-list
danielhanchen May 25, 2026
fd59961
notebook_deps: add easydict to the auto-install allow-list
danielhanchen May 25, 2026
d17e568
notebook_deps: add snac to the auto-install allow-list
danielhanchen May 25, 2026
4f7b57c
load_vllm: block `import flashinfer` when nvcc/ninja are missing
danielhanchen May 25, 2026
b8e681c
notebook_deps: add torchcodec to the auto-install allow-list (HF data…
danielhanchen May 25, 2026
55f17fc
Merge remote-tracking branch 'origin/main' into docker-blackwell-note…
danielhanchen May 27, 2026
6b9a4c6
Merge origin/main into docker-blackwell-notebook-fixes
danielhanchen Jul 27, 2026
c2c2820
notebook_deps: use the AGPL-3.0 header new zoo files carry
danielhanchen Jul 27, 2026
79a246d
gemma3: do not let the automatic longest-padding rewrite the caller's…
danielhanchen Jul 27, 2026
03387da
notebook_deps: reach the auto-installer, and let its retry succeed
danielhanchen Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion unsloth_zoo/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,18 @@ def install_package(package, sudo = False, print_output = False, print_outputs =

print(f"Unsloth: Installing packages: {package}")
if not (IS_COLAB_ENVIRONMENT or IS_KAGGLE_ENVIRONMENT):
acceptance = input(f"Missing system packages. We need to execute `{install_cmd}` - do you accept? Press ENTER. Type NO if not.")
# Non-interactive contexts (Docker w/o TTY, headless CI) raise
# EOFError on input(). Treat that like an implicit ENTER ie accept
# the install. Opt out via UNSLOTH_AUTO_INSTALL=0.
try:
acceptance = input(f"Missing system packages. We need to execute `{install_cmd}` - do you accept? Press ENTER. Type NO if not.")
except EOFError:
if os.environ.get("UNSLOTH_AUTO_INSTALL", "1") != "1":
raise RuntimeError(
f"Unsloth: Execution of `{install_cmd}` was cancelled (no TTY and UNSLOTH_AUTO_INSTALL=0)!\n"\
"Please install llama.cpp manually via https://docs.unsloth.ai/basics/troubleshooting-and-faqs#how-do-i-manually-save-to-gguf"
)
acceptance = ""
Comment on lines +329 to +335

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not treat interactive EOF as installation consent

This handler cannot distinguish a headless stdin from an interactive user pressing Ctrl-D to cancel the prompt, so the latter now implicitly authorizes an apt-get, yum, or pacman command. Restrict implicit acceptance to a confirmed non-TTY input stream and preserve cancellation semantics for EOF on an interactive terminal.

Useful? React with 👍 / 👎.

if "no" in str(acceptance).lower():
raise RuntimeError(
f"Unsloth: Execution of `{install_cmd}` was cancelled!\n"\
Expand Down
6 changes: 5 additions & 1 deletion unsloth_zoo/patching_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,11 @@ def patch_torch_compile(debug = False, O3 = False, ignore_errors = True):
else:
DEBUGGING = ""
os.environ.pop("TORCHDYNAMO_VERBOSE", None)
os.environ.pop("TORCHINDUCTOR_COMPILE_THREADS", None)
# Preserve the single-worker forcing put in place by unsloth/_gpu_init
# to keep cgroup-pinned containers from spawning Inductor subprocess
# workers that can't see the GPU.
if os.environ.get("UNSLOTH_FORCE_SINGLE_COMPILE_WORKER", "0") != "1":
os.environ.pop("TORCHINDUCTOR_COMPILE_THREADS", None)
os.environ.pop("TORCHINDUCTOR_FORCE_DISABLE_CACHES", None)
os.environ.pop("TORCH_LOGS", None)
torch._logging.set_logs(all = logging.CRITICAL)
Expand Down
1 change: 1 addition & 0 deletions unsloth_zoo/temporary_patches/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@


from .common import *
from .notebook_deps import *
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
from .gemma import *
from .misc import *
from .gemma3n import *
Expand Down
6 changes: 6 additions & 0 deletions unsloth_zoo/temporary_patches/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ def determine_compile_threads():
# See https://github.com/pytorch/pytorch/blob/ab2294d8289a7757a2fc321cdefac88e2b378edf/torch/_inductor/config.py#L771
# Windows thread count = 1. See https://github.com/unslothai/unsloth-zoo/pull/187
if sys.platform == "win32": return 1
# Honour the explicit single-worker forcing set by unsloth/_gpu_init for
# cgroup-pinned containers where the Inductor compile worker pool cannot
# see the GPU. Otherwise determine_compile_threads ignores the env var
# and the options dict still passes the multi-worker default.
if os.environ.get("TORCHINDUCTOR_COMPILE_THREADS") == "1":
return 1
cpu_count = os.cpu_count()
return min(32, max(4, cpu_count))
pass
Expand Down
13 changes: 13 additions & 0 deletions unsloth_zoo/temporary_patches/gemma.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ def _gemma3_call_impl(
tokenizer_init_kwargs=self.tokenizer.init_kwargs,
**kwargs,
)
# TRL GRPO paged + reward paths call Gemma3Processor(text=[...]) with no
# padding= kwarg; upstream Gemma3ProcessorKwargs default is padding=False
# so ragged completions blow up BatchFeature tensor stacking. Force
# longest-padding only when caller did not pin padding AND we have >1
# text row (single-image inference is byte-identical).
_user_padding = kwargs.get("padding", None)
if _user_padding is None:
_user_padding = kwargs.get("text_kwargs", {}).get("padding", None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check for _user_padding can crash if text_kwargs is explicitly passed as None in kwargs. Using a more robust check would prevent a potential AttributeError when calling .get() on a NoneType object.

        _user_padding = kwargs.get("padding")
        if _user_padding is None:
            _text_kwargs = kwargs.get("text_kwargs")
            if isinstance(_text_kwargs, dict):
                _user_padding = _text_kwargs.get("padding")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard optional text_kwargs before reading padding

The new _user_padding detection assumes kwargs["text_kwargs"] is always a dict, but kwargs.get("text_kwargs", {}).get("padding") will raise AttributeError when callers pass text_kwargs=None (a common pass-through pattern for optional kwargs). In that case Gemma3Processor.__call__ now fails before _merge_kwargs can normalize defaults, turning previously valid invocations into runtime crashes.

Useful? React with 👍 / 👎.

_text_rows = (
len(text) if isinstance(text, (list, tuple)) and not isinstance(text, str) else 1
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _text_rows calculation does not account for cases where text is None but images is a batch (e.g., a list of lists of images). In such cases, text is generated as a list of strings later in the function (line 162). If these generated strings have different lengths (due to varying image counts per batch item), the processor will crash when stacking tensors because padding was not forced to 'longest'. Consider checking the batch size of images when text is None.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and fixed. The row count is now computed after the image token expansion, immediately before the tokenizer call, so it counts the rows the tokenizer actually sees, including the ones synthesised from images when the caller passes no text at all.

Worth recording that the specific crash described is not reachable today: text=None with images raises TypeError: 'NoneType' object is not subscriptable earlier, at the not isinstance(text[0], str) check, and it does so on stock transformers too. The relocation makes the count correct by construction rather than depending on that.

if _user_padding is None and _text_rows > 1:
output_kwargs["text_kwargs"]["padding"] = "longest"

batched_images = None
if images is not None:
Expand Down
21 changes: 21 additions & 0 deletions unsloth_zoo/temporary_patches/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1524,3 +1524,24 @@ def _min_pixels(self):
pass
pass
TEMPORARY_PATCHES.append(patch_qwen2vl_image_processor_pixel_attrs)


def patch_deepseek_v2_moe_capitalisation_alias():
"""
transformers 5.0 renamed `DeepseekV2MoE` -> `DeepseekV2Moe` (camelCase
consistency pass). Remote-code models like deepseek-ai/DeepSeek-OCR
ship a modeling file that still imports the old name, so loading them
on transformers 5.x raises `ImportError: cannot import name
'DeepseekV2MoE'`. Add a backward-compat alias so the old name keeps
resolving regardless of which transformers version is installed.
Forward-compatible: when transformers 4.x is installed and ships
`DeepseekV2MoE` natively, the alias check is a no-op.
"""
try:
from transformers.models.deepseek_v2 import modeling_deepseek_v2 as _m
except ImportError:
return
if not hasattr(_m, "DeepseekV2MoE") and hasattr(_m, "DeepseekV2Moe"):
_m.DeepseekV2MoE = _m.DeepseekV2Moe
pass
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
TEMPORARY_PATCHES.append(patch_deepseek_v2_moe_capitalisation_alias)
224 changes: 224 additions & 0 deletions unsloth_zoo/temporary_patches/notebook_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Auto-install missing notebook-only Python deps on first use.
#
# Four notebooks failed in the Blackwell docker validation because the slim
# venv shipped without timm / traitlets / addict / matplotlib, and the
# raising frame is buried inside HF code (`transformers.utils.import_utils.
# requires_backends` for TimmWrapper, `transformers.dynamic_module_utils.
# check_imports` for the Deepseek-OCR trust_remote_code modeling file, and
# a bare ModuleNotFoundError for traitlets from the IPython chain). Wrap
# all three call sites with a thin retry that pip-installs the offending
# package (allow-list only) and re-tries the original import. Honours the
# existing `UNSLOTH_AUTO_INSTALL=0` opt-out (used by `llama_cpp.py`) and
# the standard offline flags so air-gapped envs keep emitting the
# upstream ImportError verbatim.

import importlib
import importlib.metadata
import importlib.util
import os
import shutil
import site
import subprocess
import sys

from ..log import logger

# pypi-name -> import-name (None means same).
_ALLOW_LIST = {
"timm": None, # vision backbones (TimmWrapperModel)
"addict": None, # Deepseek-OCR config dicts
"einops": None, # Deepseek-OCR deepencoder + many other vision models
"easydict": None, # Deepseek-OCR deepencoder.py:12 `from easydict import EasyDict`
"snac": None, # Orpheus TTS neural audio codec
"torchcodec": None, # HF datasets audio Feature decoder (>= datasets 4.x)
"matplotlib": None, # Deepseek-OCR + a few HF image utils
"traitlets": None, # Jupyter/IPython widget chain
"soundfile": None, # audio processors
"librosa": None, # audio processors
"scipy": None, # several processors
"pyctcdecode": None, # ASR
"tiktoken": None, # tokenizer remote-code paths
"blobfile": None, # tiktoken backing store
"pillow_heif": "pillow_heif", # HEIF images
"decord": None, # video processors
"av": "av", # pyav (video processors)
"num2words": None, # speech text norm
"jieba": None, # zh tokenizer
"sentencepiece": None, # tokenizers
}

_AUTO_INSTALL = os.environ.get("UNSLOTH_AUTO_INSTALL", "1") == "1"
_NO_NETWORK = (
os.environ.get("UNSLOTH_OFFLINE", "0") == "1"
or os.environ.get("HF_HUB_OFFLINE", "0") == "1"
or os.environ.get("TRANSFORMERS_OFFLINE", "0") == "1"
)
_attempted: set = set()


def _in_venv() -> bool:
return (
hasattr(sys, "real_prefix")
or (getattr(sys, "base_prefix", sys.prefix) != sys.prefix)
or bool(os.environ.get("VIRTUAL_ENV"))
or bool(os.environ.get("CONDA_PREFIX"))
)


def _pip_install(pkg: str) -> bool:
if pkg in _attempted:
return False
_attempted.add(pkg)
if shutil.which("uv") and _in_venv():
cmd = ["uv", "pip", "install", "--quiet", pkg]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Target uv at the running interpreter

In a notebook kernel whose sys.executable differs from the inherited VIRTUAL_ENV or CONDA_PREFIX, this command installs into the activated environment rather than the interpreter running Unsloth, so the subsequent find_spec still fails while an unrelated environment is modified. This is reproducible with interpreter A and VIRTUAL_ENV pointing at B: uv reports Using Python ... environment at: B; its uv pip install --help also identifies -p/--python as the option selecting the interpreter into which packages are installed. Pass --python, sys.executable as the fallback pip command effectively does.

Useful? React with 👍 / 👎.

else:
cmd = [
sys.executable, "-m", "pip", "install", "--quiet",
"--disable-pip-version-check", "--no-input", pkg,
]
# Outside a venv on Linux/Mac as non-root: probe write access to
# site-packages and fall back to --user. Windows has no geteuid;
# site-packages there is usually writable inside the venv anyway.
if not _in_venv() and hasattr(os, "geteuid") and os.geteuid() != 0:
try:
sp = site.getsitepackages()[0]
probe = os.path.join(sp, ".unsloth_write_probe")
open(probe, "w").close()
os.remove(probe)
except Exception:
cmd.append("--user")
logger.warning(
f"Unsloth: auto-installing missing notebook dep `{pkg}` via "
f"`{' '.join(cmd)}`. Set UNSLOTH_AUTO_INSTALL=0 to disable."
)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
except Exception as e:
logger.warning(f"Unsloth: auto-install of `{pkg}` failed to launch: {e}")
return False
if r.returncode != 0:
tail = (r.stderr or "")[-500:]
logger.warning(f"Unsloth: auto-install of `{pkg}` failed:\n{tail}")
return False
importlib.invalidate_caches()
try:
list(importlib.metadata.distributions())
except Exception:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
pass
return True


def _try_install_and_import(pkg: str) -> bool:
if pkg not in _ALLOW_LIST:
return False
if not _AUTO_INSTALL or _NO_NETWORK:
return False
import_name = _ALLOW_LIST[pkg] or pkg.replace("-", "_")
if importlib.util.find_spec(import_name) is not None:
return True
if not _pip_install(pkg):
return False
return importlib.util.find_spec(import_name) is not None


def patch_requires_backends_autoinstall():
"""
Wrap ``transformers.utils.import_utils.requires_backends`` so that an
allow-listed missing backend triggers a one-shot pip install and a
second attempt. Preserves the original ImportError when the install
fails or the dep isn't on the allow-list, so user-facing error bytes
stay identical to upstream when ``UNSLOTH_AUTO_INSTALL=0``.
"""
try:
from transformers.utils import import_utils as iu
except Exception:
return # transformers absent (MLX-only path) -- nothing to patch.
if getattr(iu.requires_backends, "_unsloth_patched", False):
return
_orig = iu.requires_backends

def requires_backends(obj, backends):
try:
return _orig(obj, backends)
except ImportError:
if not _AUTO_INSTALL or _NO_NETWORK:
raise
wanted_iter = backends if isinstance(backends, (list, tuple)) else [backends]
wanted = [b for b in wanted_iter if isinstance(b, str) and b in _ALLOW_LIST]
if not wanted:
raise
installed_any = False
for b in wanted:
if _try_install_and_import(b):
installed_any = True
if not installed_any:
raise
for b in wanted:
flag = f"_{b.replace('-', '_')}_available"
if hasattr(iu, flag):
setattr(iu, flag, True)
return _orig(obj, backends)

requires_backends._unsloth_patched = True
iu.requires_backends = requires_backends

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Patch the exported requires_backends alias

When a Transformers model such as modeling_timm_wrapper executes from ...utils import requires_backends, it retrieves the function already exported by transformers.utils; replacing only transformers.utils.import_utils.requires_backends leaves that alias pointing at the original function. Consequently, the stated TimmWrapper path still raises immediately for a missing timm package and never reaches this installer. Update the public alias as well, and account for modules that may already have imported it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on transformers 5.14.1. With the patch installed and timm forced unavailable, only transformers.utils.import_utils.requires_backends reached the installer; transformers.utils.requires_backends and modeling_timm_wrapper.requires_backends did not, and TimmWrapperModel(TimmWrapperConfig()) raised ImportError with the installer never called. Fixed by rebinding the public alias plus every already imported module level copy, identity scoped so only aliases of the exact wrapped function are touched.

Worth recording that the alias fix alone was not enough to make the path work: BACKENDS_MAPPING[backend][0] is a functools.lru_cache wrapper (all 45 entries are) that has already cached False, and the module level _timm_available flag the retry used to set no longer exists in 5.x, so the retry still raised the ImportError the install had just removed. That cache is now invalidated after a successful install.



def patch_check_imports_autoinstall():
"""
trust_remote_code modeling files (e.g. Deepseek-OCR's modeling_deepseekocr.py)
declare their import requirements at the top of the file and raise via
``dynamic_module_utils.check_imports`` (ImportError "This modeling file
requires the following packages..."). That call site never reaches
``requires_backends``, so wrap it too.
"""
try:
from transformers import dynamic_module_utils as dmu
except Exception:
return
if getattr(dmu.check_imports, "_unsloth_patched", False):
return
_orig = dmu.check_imports

def check_imports(filename):
try:
return _orig(filename)
except ImportError as e:
if not _AUTO_INSTALL or _NO_NETWORK:
raise
msg = str(e)
if "This modeling file requires" not in msg:
raise
# Message format: "... environment: pkg1, pkg2. Run `pip install...`"
try:
tail = msg.split("environment:", 1)[1]
pkgs_str = tail.split(".", 1)[0]
except Exception:
raise
pkgs = [p.strip() for p in pkgs_str.split(",") if p.strip() in _ALLOW_LIST]
if not pkgs:
raise
ok = all(_try_install_and_import(p) for p in pkgs)
if not ok:
raise
return _orig(filename)

check_imports._unsloth_patched = True
dmu.check_imports = check_imports


def _ensure_notebook_chain():
"""
Pre-emptive ensure for deps that raise bare ModuleNotFoundError outside
transformers (the Jupyter/IPython chain). Kept tiny: only ``traitlets``
is touched today; expand only when a new failure mode appears.
"""
if not _AUTO_INSTALL or _NO_NETWORK:
return
for pkg in ("traitlets",):
if importlib.util.find_spec(pkg) is None:
_try_install_and_import(pkg)


patch_requires_backends_autoinstall()
patch_check_imports_autoinstall()
_ensure_notebook_chain()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer notebook dependency installs from import-time execution

This module invokes _ensure_notebook_chain() at import time, and temporary_patches/__init__.py now imports this file unconditionally, so a normal import unsloth can immediately trigger pip installation attempts for traitlets. That introduces network/package-manager side effects in non-notebook runs and can block or fail startup in restricted/offline environments before any notebook code path is used.

Useful? React with 👍 / 👎.

Loading
Loading