Studio already falls back to the torch2.10 flash-attn / causal-conv1d / mamba-ssm wheels when it finds torch 2.11, because upstream publishes no 2.11-tagged builds. torch 2.12 is in exactly the same position, and the same wheels work there, so a 2.12 install currently drops to a source build for no reason. Measured on a B200, python 3.12, fresh uv venv on torch 2.12.1+cu130, wheels installed with --no-deps and torch verified unmoved afterwards, importing the compiled .so directly rather than only the Python package: causal-conv1d 1.6.1 9412 passed / 3888 skipped / 0 failed mamba-ssm 2.3.1 tests/ops, 20 passed flash-attn 2.8.1 splitkv + qkvpacked subset, 848 passed Against a torch 2.10 control the pass/fail/skip counts match and the failing test-ID sets are byte identical. The reuse window is bounded rather than open ended, so the comment now records that. flash-attn v2.8.3.post1's torch2.9 wheel fails to import on torch 2.10 and on torch 2.12 alike, with an undefined symbol out of flash_attn_2_cuda: torch broke extension ABI between 2.9 and 2.10 and has held it from 2.10 through 2.12. A wheel cannot skip a torch minor backwards, so torch 2.13 is deliberately left out of the table until it is measured. The torch2.10 flash-attn pin stays at 2.8.1. v2.8.3 looks like a free upgrade but publishes only 2 of the 8 torch2.10 assets that v2.8.1 does, keeping just cu13/cp312 for x86_64 and aarch64 and dropping every cu12 and every cp313 torch2.10 wheel, while v2.8.3.post1 dropped the torch2.10 assets entirely. Bumping the pin would silently 404 most users back to a source build, so the constant now carries that warning. Tests cover the 2.12 mapping through both direct_wheel_url and the flash-attn URL builder, that reuse only ever targets torch2.10, and that the selected flash-attn version is never a .post release.
244 lines
8.5 KiB
Python
244 lines
8.5 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
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import json
|
|
import logging
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Callable
|
|
|
|
from utils.native_path_leases import child_env_without_native_path_secret
|
|
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
FLASH_ATTN_RELEASE_BASE_URL = "https://github.com/Dao-AILab/flash-attention/releases/download"
|
|
|
|
|
|
@functools.lru_cache(maxsize = 1)
|
|
def has_blackwell_gpu() -> bool:
|
|
"""Return True if any visible NVIDIA GPU has compute capability >= 10.0 (Blackwell).
|
|
|
|
Cached for the process lifetime; tests mocking nvidia-smi must call
|
|
``has_blackwell_gpu.cache_clear()`` first.
|
|
"""
|
|
# Detection disabled for now: Dao-AILab ships Blackwell (sm_100+) flash-attn
|
|
# wheels and url_exists() already gates resolution, so we no longer skip
|
|
# flash-attn on Blackwell. The nvidia-smi probe below is kept for possible
|
|
# future arch-based gating; drop this early return to re-enable it.
|
|
return False
|
|
exe = shutil.which("nvidia-smi")
|
|
if not exe:
|
|
return False
|
|
try:
|
|
result = subprocess.run(
|
|
[exe, "--query-gpu=compute_cap", "--format=csv,noheader"],
|
|
stdout = subprocess.PIPE,
|
|
stderr = subprocess.DEVNULL,
|
|
text = True,
|
|
timeout = 10,
|
|
env = child_env_without_native_path_secret(),
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
if result.returncode != 0:
|
|
return False
|
|
for line in result.stdout.splitlines():
|
|
cap = line.strip()
|
|
if not cap:
|
|
continue
|
|
major_part = cap.split(".", 1)[0]
|
|
try:
|
|
major = int(major_part)
|
|
except ValueError:
|
|
continue
|
|
if major >= 10:
|
|
return True
|
|
return False
|
|
|
|
|
|
def linux_wheel_platform_tag() -> str | None:
|
|
machine = platform.machine().lower()
|
|
if sys.platform.startswith("linux"):
|
|
if machine in {"x86_64", "amd64"}:
|
|
return "linux_x86_64"
|
|
if machine in {"aarch64", "arm64"}:
|
|
return "linux_aarch64"
|
|
# No prebuilt wheels published for macOS or Windows
|
|
return None
|
|
|
|
|
|
def probe_torch_wheel_env(*, timeout: int | None = None) -> dict[str, str] | None:
|
|
platform_tag = linux_wheel_platform_tag()
|
|
if platform_tag is None:
|
|
return None
|
|
|
|
try:
|
|
probe = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
(
|
|
"import json, sys, re, torch; "
|
|
"parts = torch.__version__.split('+', 1)[0].split('.')[:2]; "
|
|
"minor = re.sub(r'[^0-9].*', '', parts[1]) if len(parts) > 1 else '0'; "
|
|
"torch_mm = parts[0] + '.' + minor; "
|
|
"print(json.dumps({"
|
|
"'python_tag': f'cp{sys.version_info.major}{sys.version_info.minor}', "
|
|
"'torch_mm': torch_mm, "
|
|
"'cuda_major': str(int(str(torch.version.cuda).split('.', 1)[0])) if torch.version.cuda else '', "
|
|
"'hip_version': str(torch.version.hip) if getattr(torch.version, 'hip', None) else '', "
|
|
"'cxx11abi': str(torch._C._GLIBCXX_USE_CXX11_ABI).upper()"
|
|
"}))"
|
|
),
|
|
],
|
|
stdout = subprocess.PIPE,
|
|
stderr = subprocess.PIPE,
|
|
text = True,
|
|
timeout = timeout,
|
|
env = child_env_without_native_path_secret(),
|
|
**windows_hidden_subprocess_kwargs(),
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return None
|
|
|
|
if probe.returncode != 0:
|
|
return None
|
|
|
|
try:
|
|
env = json.loads(probe.stdout.strip())
|
|
except json.JSONDecodeError:
|
|
return None
|
|
env["platform_tag"] = platform_tag
|
|
return env
|
|
|
|
|
|
# torch 2.11 and 2.12 ship no native prebuilt wheels for flash-attn /
|
|
# causal-conv1d / mamba-ssm, but the torch2.10 CUDA wheels load and pass each
|
|
# project's own suite on both (B200, py3.12, torch 2.12.1+cu130: causal-conv1d
|
|
# 9412 passed / 3888 skipped / 0 failed, mamba tests/ops 20 passed, flash-attn
|
|
# splitkv+qkvpacked 848 passed; pass/fail/skip counts and the failing test-ID
|
|
# sets are identical to a torch 2.10 control). Reuse them so a 2.11 / 2.12
|
|
# install still gets prebuilt accelerators instead of building from source.
|
|
#
|
|
# The window is bounded, not open ended: torch broke extension ABI between 2.9
|
|
# and 2.10, and the torch2.9 flash-attn .so raises "undefined symbol" on torch
|
|
# 2.10 and on 2.12 alike. A wheel cannot skip a torch minor backwards, so every
|
|
# new key here must be measured against the real wheels before it is added.
|
|
_PREBUILT_WHEEL_TORCH_MM = {"2.11": "2.10", "2.12": "2.10"}
|
|
|
|
|
|
def prebuilt_wheel_torch_mm(torch_mm: str) -> str:
|
|
"""Map a torch major.minor to the one whose prebuilt accelerator wheels to use."""
|
|
return _PREBUILT_WHEEL_TORCH_MM.get(torch_mm, torch_mm)
|
|
|
|
|
|
def direct_wheel_url(
|
|
*,
|
|
filename_prefix: str,
|
|
package_version: str,
|
|
release_tag: str,
|
|
release_base_url: str,
|
|
env: dict[str, str] | None,
|
|
) -> str | None:
|
|
if env is None or not env.get("cuda_major"):
|
|
return None
|
|
|
|
filename = (
|
|
f"{filename_prefix}-{package_version}"
|
|
f"+cu{env['cuda_major']}torch{prebuilt_wheel_torch_mm(env['torch_mm'])}"
|
|
f"cxx11abi{env['cxx11abi']}-{env['python_tag']}-{env['python_tag']}"
|
|
f"-{env['platform_tag']}.whl"
|
|
)
|
|
return f"{release_base_url}/{release_tag}/{filename}"
|
|
|
|
|
|
def flash_attn_package_version(torch_mm: str) -> str | None:
|
|
if torch_mm == "2.10":
|
|
# Newest flash-attn release still carrying the full torch2.10 asset
|
|
# matrix (cu12 + cu13, cp312 + cp313, x86_64 + aarch64). Do not bump
|
|
# this to "the latest release": v2.8.3 publishes only cu13/cp312 for
|
|
# torch2.10 and v2.8.3.post1 dropped every torch2.10 asset, so both
|
|
# 404 most users back to a source build, and post1's newest tag is
|
|
# torch2.9, which will not load here at all.
|
|
return "2.8.1"
|
|
try:
|
|
major, minor = (int(part) for part in torch_mm.split(".", 1))
|
|
except ValueError:
|
|
return None
|
|
if major == 2 and 4 <= minor <= 9:
|
|
return "2.8.3"
|
|
return None
|
|
|
|
|
|
def flash_attn_wheel_url(env: dict[str, str] | None) -> str | None:
|
|
if env is None:
|
|
return None
|
|
package_version = flash_attn_package_version(prebuilt_wheel_torch_mm(env["torch_mm"]))
|
|
if package_version is None:
|
|
return None
|
|
return direct_wheel_url(
|
|
filename_prefix = "flash_attn",
|
|
package_version = package_version,
|
|
release_tag = f"v{package_version}",
|
|
release_base_url = FLASH_ATTN_RELEASE_BASE_URL,
|
|
env = env,
|
|
)
|
|
|
|
|
|
def install_wheel(
|
|
wheel_url: str,
|
|
*,
|
|
python_executable: str,
|
|
use_uv: bool,
|
|
uv_needs_system: bool = False,
|
|
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
) -> list[tuple[str, subprocess.CompletedProcess[str]]]:
|
|
attempts: list[tuple[str, subprocess.CompletedProcess[str]]] = []
|
|
|
|
# Try uv first if available, then fall back to pip
|
|
if use_uv and shutil.which("uv"):
|
|
uv_cmd = ["uv", "pip", "install"]
|
|
if uv_needs_system:
|
|
uv_cmd.append("--system")
|
|
uv_cmd.extend(["--python", python_executable, "--no-deps", wheel_url])
|
|
result = run(
|
|
uv_cmd,
|
|
stdout = subprocess.PIPE,
|
|
stderr = subprocess.STDOUT,
|
|
text = True,
|
|
env = child_env_without_native_path_secret(),
|
|
)
|
|
attempts.append(("uv", result))
|
|
if result.returncode == 0:
|
|
return attempts
|
|
|
|
pip_cmd = [python_executable, "-m", "pip", "install", "--no-deps", wheel_url]
|
|
result = run(
|
|
pip_cmd,
|
|
stdout = subprocess.PIPE,
|
|
stderr = subprocess.STDOUT,
|
|
text = True,
|
|
env = child_env_without_native_path_secret(),
|
|
)
|
|
attempts.append(("pip", result))
|
|
return attempts
|
|
|
|
|
|
def url_exists(url: str) -> bool:
|
|
try:
|
|
request = urllib.request.Request(url, method = "HEAD")
|
|
with urllib.request.urlopen(request, timeout = 10):
|
|
return True
|
|
except urllib.error.HTTPError as exc:
|
|
_logger.debug("url_exists(%s): HTTP %s", url, exc.code)
|
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
_logger.debug("url_exists(%s): %s", url, exc)
|
|
return False
|