mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-22 06:20:56 +00:00
Follow up to #13861 and #13808. An audit of the merged on demand SSM
kernel change plus a T4 benchmark found a few gaps, fixed here.
## Why
- On a Kaggle T4 (sm75, torch 2.11 cu130, fp16, Qwen3.5-0.8B), Studio
chat decode was 4.4% slower without causal-conv1d (19.10 vs 19.95 tok/s,
6 alternating repeats, slower in all 9 pairs). Training speed, losses
and peak memory were unchanged. The zoo Triton conv covers
`causal_conv1d_fn` only, so single token decode fell back to the torch
update path. Training with trainable conv weights also allocates an fp32
dW scratch in the Triton backward, and packed `seq_idx` is not accepted
by the zoo dispatcher under transformers 5.20.
- Mamba-family models that have a working transformers torch path
(mamba, mamba2, falcon_mamba, jamba, zamba, zamba2, bamba) were made
fatal when mamba-ssm could not be installed.
- Export, local LoRA continuation, and inference model switching did not
provision or pick up kernels correctly once setup stopped installing
them.
- Setup on pre-Ampere GPUs (T4) downloaded flash-attn even though
Unsloth only enables FA2 at sm80+.
## Changes
1. Qwen3.5 / Qwen3.6 / Qwen3-Next request causal-conv1d on demand again
in training, inference and Clef (same as before #13861, minus the setup
time install). The zoo Triton platform probe is removed.
2. A missing mamba-ssm is fatal only for `nemotron_h`, `falcon_h1`,
`granitemoehybrid`. Other Mamba families install best effort and
continue on the torch path with a status message.
3. Local LoRA folders (`adapter_config.json`, no `config.json`) resolve
kernel needs from `base_model_name_or_path`; the training
`adapter_base_pin` wins when set.
4. The export worker provisions SSM kernels before importing Unsloth /
the export backend, with the same required vs optional split.
5. Inference re-enables the modeling source scan by reading
`transformers/models/<type>/modeling_*.py` as text, without importing
transformers.
6. If a kernel is newly installed after the model's
`transformers.models.<type>` module was already imported, the inference
worker asks for a restart and the orchestrator respawns it once.
7. Torch repair removes causal-conv1d / mamba-ssm whose compiled
extension no longer imports (never installs anything new). A hung import
check keeps the package.
8. Setup and the long context runtime install skip flash-attn below sm80
(and remove a stale copy at setup). No visible GPU and ROCm keep current
behavior.
## Tests
New and updated tests fail on main and pass here (`test_ssm_runtime.py`,
`test_ssm_restart_respawn.py`, `test_export_ssm_kernels.py`,
`test_clef_fast_paths.py`, `test_kernel_install.py`,
`test_training_worker_flash_attn.py`,
`test_ssm_kernels_install_python_stack.py`,
`test_flash_attn_install_python_stack.py`,
`test_windows_torch_flavor_invariant.py`). With
`test_install_python_stack.py` and `test_cuda_repair.py`: 966 passed
after merging current main.
## Hardening (second commit)
- Stale SSM kernel removal after torch repair needs proof: torch must
import first, and only an ImportError / OSError from the extension
itself removes the package. Signals, hangs, launch errors and a broken
torch keep it.
- `unsloth install-kernels --without-gpu` (or
`UNSLOTH_INSTALL_KERNELS_WITHOUT_GPU=1`) installs GPU gated kernels on a
GPU-less image builder. An already working kernel counts as success
before any skip, skipped kernels are listed, and the exit code is
unchanged (0) unless `--strict` is passed. The CLI respects `UV_OFFLINE`
/ HF offline variables and never falls back from uv to pip offline.
- Inference respawn refuses to spawn while the old worker survives
shutdown. SSM restart and the Xet fallback each get their own one-use
retry (3 spawns max).
- Restart detection only counts native `modeling_*` modules and the
selected checkpoint's remote code modules from its `auto_map`, matched
on the exact `transformers_modules` namespace.
- Export provisions SSM kernels inside the existing transformers
activation window (no extra Hub reachability probe), and skips
provisioning on macOS and for the MLX / CPU export backends.
- `UV_OFFLINE` counts as offline for on-demand installs (no URL probes,
source builds, or uv to pip fallback).
- Long context flash-attn install returns early when flash_attn already
imports, reuses the worker's own torch for the capability check, and
never applies the sm80 floor on ROCm.
## Install robustness (platform matrix review)
- A wheel is downloaded whenever its HEAD check is not a definite 404
(proxies that refuse HEAD no longer push required hybrids into a
failure), then import-verified; fallback only after a real failure.
- A failed install is remembered for the worker process (keyed by
kernel, torch, toolchain), so preflight and load share one attempt.
Optional Mamba families get mamba-ssm only as a prebuilt wheel, never a
source build. ROCm source builds get an 1800 s deadline and CUDA source
builds 7200 s (`UNSLOTH_SOURCE_BUILD_TIMEOUT` overrides); a timeout
kills the whole build tree.
- A runtime install whose extension does not import is uninstalled, so
transformers never finds a broken package.
- One cross-process lock per kernel and environment covers check,
install, verify and cleanup (training and runtime), with a recheck after
acquiring it.
- Only a CUDA or ROCm torch provisions anything; CPU, XPU, MPS and
unknown torch builds skip (export included).
- Optional mamba-ssm follows setup's sm80 floor (sm75 with Triton 3.4+);
the required hybrids keep the previous behavior.
- The resolver only returns URLs for cells that are actually published
(Unsloth release: torch 2.13/2.14 cp313, win_amd64 torch 2.10 to 2.12
cp311 to cp313; upstream causal-conv1d / mamba-ssm release cells), and
there is no source build of the pinned 1.6.1 / 2.3.1 sources on torch
2.13+, which need C++20.
- Training stays best effort for mamba-ssm, as before #13861.
- Export runs the malware and remote code consent gates before any
kernel provisioning.
- The extension is probed again after the install lock is acquired, so a
concurrent replacement is not treated as broken.
- The automatic HF offline window (Hub unreachable) no longer blocks
package installs; only user set offline variables do.
- `unsloth install-kernels --dry-run` prints the resolved URL while
offline.
- A Mamba model whose code needs mamba-ssm fails with a clear error on a
torch build without CUDA or ROCm.
## GPU checks (merge base vs head 4a6429a65)
Kaggle T4, torch 2.11.0+cu130, Qwen3.5-0.8B fp16:
| | base | head |
|---|---|---|
| causal-conv1d on demand | not installed | installed,
`causal_conv1d_update_kernel` engaged |
| decode tok/s (6 alternating repeats) | 18.70 | 19.41 (+3.8%, 6/6
pairs) |
| decode peak alloc | 1.748 GB | 1.748 GB |
| LoRA step time (3 repeats) | 1975.5 ms | 1970.7 ms (inside spread) |
| LoRA peak alloc | 2.338 GB | 2.338 GB |
| flash-attn at setup on sm75 | downloaded | skipped |
AMD gfx1151 (torch 2.11.0+rocm7.13.0): Qwen3.5 LoRA losses within 1.2e-4
relative (head vs merge commit bit identical), peak 2.389 GB both,
backend suites 262/262 and 275/275 passing.
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
232 lines
9.1 KiB
Python
232 lines
9.1 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Setup installs flash-attn but not causal-conv1d or mamba-ssm: those (~550 MB) install when a
|
|
model that needs them is trained or loaded (core/training/worker.py, utils/ssm_runtime.py), and
|
|
`unsloth install-kernels` still provisions them ahead of time for offline images. After torch
|
|
moves, setup still removes an installed copy whose compiled extension no longer imports."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import contextlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
STUDIO_DIR = Path(__file__).resolve().parents[2] / "studio"
|
|
sys.path.insert(0, str(STUDIO_DIR))
|
|
sys.path.insert(0, str(STUDIO_DIR / "backend"))
|
|
|
|
import install_python_stack as ips # noqa: E402
|
|
|
|
SOURCE = (STUDIO_DIR / "install_python_stack.py").read_text(encoding = "utf-8")
|
|
TREE = ast.parse(SOURCE)
|
|
|
|
|
|
def _called_names() -> set[str]:
|
|
return {
|
|
node.func.id
|
|
for node in ast.walk(TREE)
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
|
}
|
|
|
|
|
|
def test_setup_has_no_ssm_kernel_step():
|
|
assert not hasattr(ips, "_ensure_ssm_kernels")
|
|
assert "install_kernel" not in _called_names()
|
|
assert '_progress("mamba kernels")' not in SOURCE
|
|
assert "causal-conv1d + mamba-ssm" not in SOURCE
|
|
|
|
|
|
def test_flash_attn_and_the_stale_kernel_check_run_at_all_three_sites():
|
|
calls = [m.start() for m in re.finditer(r"\n\s+_ensure_flash_attn\(\)\n", SOURCE)]
|
|
assert len(calls) == 3
|
|
for start in calls:
|
|
following = SOURCE[start : start + 120]
|
|
assert "_remove_stale_ssm_kernels()" in following, following
|
|
assert "base_total += 1 # flash-attn, Linux and Windows" in SOURCE
|
|
|
|
|
|
class _Kernels:
|
|
"""Which SSM distributions are installed and how their extension import ends: True loads,
|
|
False raises ImportError, None hangs, an int is a raw exit code (negative = signal)."""
|
|
|
|
def __init__(
|
|
self,
|
|
installed: dict,
|
|
torch_imports = True,
|
|
):
|
|
self.installed = dict(installed)
|
|
self.torch_imports = torch_imports
|
|
self.probes: list[str] = []
|
|
self.removed: list[str] = []
|
|
|
|
def version(self, distribution):
|
|
return "1.0" if distribution in self.installed else None
|
|
|
|
def run(self, cmd, *args, **kwargs):
|
|
code = cmd[-1]
|
|
self.probes.append(code)
|
|
if code == "import torch":
|
|
if self.torch_imports is None:
|
|
raise subprocess.TimeoutExpired(cmd, 1)
|
|
if self.torch_imports:
|
|
return subprocess.CompletedProcess(cmd, 0, stderr = "")
|
|
return subprocess.CompletedProcess(
|
|
cmd, 1, stderr = "OSError: libtorch_cuda.so: cannot open shared object file"
|
|
)
|
|
assert "import torch" in code # the extension probe proves torch imports too
|
|
extension = "causal_conv1d_cuda" if "causal_conv1d_cuda" in code else "selective_scan_cuda"
|
|
distribution = "causal-conv1d" if extension == "causal_conv1d_cuda" else "mamba-ssm"
|
|
state = self.installed[distribution]
|
|
if state is None:
|
|
raise subprocess.TimeoutExpired(cmd, 1)
|
|
if state is True:
|
|
return subprocess.CompletedProcess(cmd, 0, stderr = "")
|
|
if state is False:
|
|
return subprocess.CompletedProcess(
|
|
cmd,
|
|
ips._SSM_EXTENSION_BROKEN_EXIT,
|
|
stderr = f"{extension}: ImportError: {extension}.so: undefined symbol: _ZN3c10",
|
|
)
|
|
return subprocess.CompletedProcess(cmd, state, stderr = "Killed")
|
|
|
|
def uninstall(self, distribution):
|
|
self.removed.append(distribution)
|
|
self.installed.pop(distribution, None)
|
|
return True
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _free_lock(name, **kwargs):
|
|
yield True
|
|
|
|
|
|
def _run_stale_check(monkeypatch, host: _Kernels, **flags):
|
|
monkeypatch.setattr(ips, "NO_TORCH", flags.get("no_torch", False))
|
|
monkeypatch.setattr(ips, "IS_MACOS", flags.get("macos", False))
|
|
monkeypatch.setattr(ips, "_installed_distribution_version", host.version)
|
|
monkeypatch.setattr(ips, "_uninstall_distribution", host.uninstall)
|
|
monkeypatch.setattr(ips, "_count_install_action", lambda: None)
|
|
monkeypatch.setattr(ips, "kernel_install_lock", flags.get("lock", _free_lock))
|
|
warnings = []
|
|
monkeypatch.setattr(ips, "_step", lambda label, value, color_fn = None: warnings.append(value))
|
|
monkeypatch.setattr(ips, "_note", lambda message, color_fn = None: warnings.append(message))
|
|
monkeypatch.setattr(ips.subprocess, "run", host.run)
|
|
installs = []
|
|
monkeypatch.setattr(ips, "install_prebuilt", lambda *a, **k: installs.append(a) or "installed")
|
|
ips._remove_stale_ssm_kernels()
|
|
assert installs == [] # never installs anything
|
|
return warnings
|
|
|
|
|
|
def test_stale_kernels_that_no_longer_import_are_removed(monkeypatch):
|
|
host = _Kernels({"causal-conv1d": False, "mamba-ssm": False})
|
|
warnings = _run_stale_check(monkeypatch, host)
|
|
assert host.removed == ["causal-conv1d", "mamba-ssm"]
|
|
assert len(warnings) == 2
|
|
# The probe's stderr tail says why.
|
|
assert "undefined symbol" in warnings[0] and "selective_scan_cuda" in warnings[1]
|
|
|
|
|
|
def test_kernels_that_still_import_are_kept(monkeypatch):
|
|
host = _Kernels({"causal-conv1d": True, "mamba-ssm": False})
|
|
_run_stale_check(monkeypatch, host)
|
|
assert host.removed == ["mamba-ssm"]
|
|
|
|
|
|
def test_absent_kernels_are_not_probed_or_installed(monkeypatch):
|
|
host = _Kernels({})
|
|
_run_stale_check(monkeypatch, host)
|
|
assert host.probes == [] and host.removed == []
|
|
|
|
|
|
def test_a_hung_probe_is_inconclusive_and_keeps_the_kernel(monkeypatch):
|
|
host = _Kernels({"causal-conv1d": None})
|
|
_run_stale_check(monkeypatch, host)
|
|
assert host.removed == []
|
|
|
|
|
|
def test_no_torch_or_macos_skips_the_check(monkeypatch):
|
|
for flags in ({"no_torch": True}, {"macos": True}):
|
|
host = _Kernels({"causal-conv1d": False})
|
|
_run_stale_check(monkeypatch, host, **flags)
|
|
assert host.probes == []
|
|
|
|
|
|
@pytest.mark.parametrize("torch_imports", [False, None])
|
|
def test_a_torch_that_does_not_import_keeps_every_kernel(monkeypatch, torch_imports):
|
|
# A half-finished repair or a transient failure in torch says nothing about the kernels, and an
|
|
# offline machine could not reinstall them.
|
|
host = _Kernels({"causal-conv1d": False, "mamba-ssm": False}, torch_imports = torch_imports)
|
|
warnings = _run_stale_check(monkeypatch, host)
|
|
assert host.removed == []
|
|
assert host.probes == ["import torch"]
|
|
if torch_imports is False:
|
|
assert "libtorch_cuda.so" in warnings[0]
|
|
|
|
|
|
@pytest.mark.parametrize("exit_code", [-9, -11, 1, 2])
|
|
def test_a_killed_or_unrelated_failure_is_inconclusive(monkeypatch, exit_code):
|
|
# A signal (OOM killer, segfault) or an exit that is not the extension's ImportError / OSError.
|
|
host = _Kernels({"causal-conv1d": exit_code, "mamba-ssm": exit_code})
|
|
warnings = _run_stale_check(monkeypatch, host)
|
|
assert host.removed == []
|
|
assert all("kept it" in message for message in warnings)
|
|
|
|
|
|
def test_the_probed_extensions_match_the_kernel_checks():
|
|
for name, extension in ips._SSM_KERNEL_EXTENSIONS.items():
|
|
assert ips.KERNELS[name][1] == f"import torch, {extension}"
|
|
|
|
|
|
def test_the_kernel_cli_still_provisions_both_ahead_of_time():
|
|
from utils import kernel_install
|
|
assert {"causal_conv1d", "mamba_ssm"} <= set(kernel_install.KERNELS)
|
|
assert callable(kernel_install.install_kernel)
|
|
|
|
|
|
def test_a_busy_install_lock_keeps_the_kernel(monkeypatch):
|
|
# A Studio load verifying a fresh install holds this lock: never remove it underneath that.
|
|
@contextlib.contextmanager
|
|
def _busy(name, **kwargs):
|
|
yield False
|
|
|
|
host = _Kernels({"causal-conv1d": False})
|
|
warnings = _run_stale_check(monkeypatch, host, lock = _busy)
|
|
assert host.removed == []
|
|
assert any("install lock is busy" in message for message in warnings)
|
|
|
|
|
|
def test_a_kernel_reinstalled_while_waiting_on_the_lock_is_kept(monkeypatch):
|
|
# Broken when first probed, but a Studio load / `unsloth install-kernels` held the lock and
|
|
# reinstalled it: the re-probe under the lock loads, so nothing is removed.
|
|
host = _Kernels({"causal-conv1d": False})
|
|
|
|
@contextlib.contextmanager
|
|
def _lock_after_reinstall(name, **kwargs):
|
|
host.installed["causal-conv1d"] = True
|
|
yield True
|
|
|
|
warnings = _run_stale_check(monkeypatch, host, lock = _lock_after_reinstall)
|
|
assert host.removed == []
|
|
assert any("now loads" in message and "kept it" in message for message in warnings)
|
|
extension_probes = [code for code in host.probes if code != "import torch"]
|
|
assert len(extension_probes) == 2 # probed again under the lock
|
|
|
|
|
|
def test_an_inconclusive_re_probe_under_the_lock_keeps_the_kernel(monkeypatch):
|
|
host = _Kernels({"causal-conv1d": False})
|
|
|
|
@contextlib.contextmanager
|
|
def _lock_then_hang(name, **kwargs):
|
|
host.installed["causal-conv1d"] = None
|
|
yield True
|
|
|
|
warnings = _run_stale_check(monkeypatch, host, lock = _lock_then_hang)
|
|
assert host.removed == []
|
|
assert any("kept it" in message for message in warnings)
|