e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
1147 lines
44 KiB
Python
1147 lines
44 KiB
Python
# Copyright 2023-present Daniel Han-Chen & the Unsloth team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from ..device_type import DEVICE_TYPE_TORCH
|
|
import importlib
|
|
import os
|
|
import torch
|
|
import re
|
|
import tempfile
|
|
import contextlib
|
|
import threading as _threading
|
|
import functools
|
|
from typing import Union
|
|
from .mapper import (
|
|
INT_TO_FLOAT_MAPPER,
|
|
FLOAT_TO_INT_MAPPER,
|
|
MAP_TO_UNSLOTH_16bit,
|
|
FLOAT_TO_FP8_BLOCK_MAPPER,
|
|
FLOAT_TO_FP8_ROW_MAPPER,
|
|
)
|
|
|
|
# https://github.com/huggingface/transformers/pull/26037 allows 4 bit loading!
|
|
from transformers import __version__ as transformers_version
|
|
from unsloth.models._utils import TorchAOConfig
|
|
from unsloth_zoo.utils import Version, get_quant_type
|
|
import gc
|
|
|
|
transformers_version = Version(transformers_version)
|
|
SUPPORTS_FOURBIT = transformers_version >= Version("4.37")
|
|
|
|
LOCAL_RANK_KEYS = ("LOCAL_RANK", "RANK")
|
|
WORLD_SIZE_KEYS = ("WORLD_SIZE",)
|
|
|
|
BAD_MAPPINGS = {
|
|
"unsloth/Qwen3-32B-unsloth-bnb-4bit".lower(): "unsloth/Qwen3-32B-bnb-4bit".lower(), # 32B dynamic quant is way too big
|
|
"unsloth/Qwen3-30B-A3B-unsloth-bnb-4bit".lower(): "unsloth/Qwen3-30B-A3B".lower(), # HF loads MoEs too slowly
|
|
"unsloth/Qwen3-30B-A3B-bnb-4bit".lower(): "unsloth/Qwen3-30B-A3B".lower(), # We rather do it on the fly
|
|
"unsloth/Qwen3-30B-A3B-Base-unsloth-bnb-4bit".lower(): "unsloth/Qwen3-30B-A3B-Base".lower(), # HF loads MoEs too slowly
|
|
"unsloth/Qwen3-30B-A3B-Base-bnb-4bit".lower(): "unsloth/Qwen3-30B-A3B-Base".lower(), # We rather do it on the fly
|
|
}
|
|
|
|
|
|
def _get_torchao_fp8_config(fp8_mode):
|
|
# Lazy import so a broken optional vLLM install doesn't break `import unsloth`.
|
|
from unsloth_zoo.vllm_utils import _get_torchao_fp8_config as _impl
|
|
return _impl(fp8_mode)
|
|
|
|
|
|
def _get_env_int(keys):
|
|
for key in keys:
|
|
value = os.environ.get(key)
|
|
if value is None:
|
|
continue
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _infer_distributed_ranks():
|
|
if (
|
|
torch.distributed.is_available()
|
|
and getattr(torch.distributed, "is_initialized", lambda: False)()
|
|
):
|
|
try:
|
|
return torch.distributed.get_rank(), torch.distributed.get_world_size()
|
|
except Exception:
|
|
pass
|
|
return _get_env_int(LOCAL_RANK_KEYS), _get_env_int(WORLD_SIZE_KEYS)
|
|
|
|
|
|
def is_distributed():
|
|
rank, world_size = _infer_distributed_ranks()
|
|
return (world_size or 1) > 1 or (rank is not None and rank > 0)
|
|
|
|
|
|
def prepare_device_map():
|
|
rank, world_size = _infer_distributed_ranks()
|
|
distributed = (world_size or 1) > 1 or (rank is not None and rank > 0)
|
|
if not distributed:
|
|
return None, False
|
|
|
|
local_rank = 0 if rank is None else rank
|
|
device_map = {"": f"{DEVICE_TYPE_TORCH}:{local_rank}"}
|
|
try:
|
|
if DEVICE_TYPE_TORCH == "cuda":
|
|
torch.cuda.set_device(local_rank)
|
|
elif DEVICE_TYPE_TORCH == "xpu" and hasattr(torch, "xpu"):
|
|
torch.xpu.set_device(local_rank)
|
|
except Exception:
|
|
pass
|
|
return device_map, True
|
|
|
|
|
|
def __get_model_name(
|
|
model_name,
|
|
load_in_4bit = True,
|
|
INT_TO_FLOAT_MAPPER = None,
|
|
FLOAT_TO_INT_MAPPER = None,
|
|
MAP_TO_UNSLOTH_16bit = None,
|
|
load_in_fp8 = False,
|
|
FLOAT_TO_FP8_BLOCK_MAPPER = None,
|
|
FLOAT_TO_FP8_ROW_MAPPER = None,
|
|
):
|
|
model_name = str(model_name)
|
|
lower_model_name = model_name.lower()
|
|
|
|
assert load_in_fp8 in (True, False, "block")
|
|
if load_in_fp8 != False:
|
|
if load_in_fp8 == True and (os.environ.get("UNSLOTH_HAS_FBGEMM", "0") == "1"):
|
|
if lower_model_name in FLOAT_TO_FP8_ROW_MAPPER:
|
|
# Faster row scaling only works if FBGEMM works!
|
|
return FLOAT_TO_FP8_ROW_MAPPER[lower_model_name]
|
|
elif lower_model_name in FLOAT_TO_FP8_BLOCK_MAPPER:
|
|
# Otherwise we use the slower blockwise type
|
|
return FLOAT_TO_FP8_BLOCK_MAPPER[lower_model_name]
|
|
else:
|
|
if lower_model_name in FLOAT_TO_FP8_BLOCK_MAPPER:
|
|
return FLOAT_TO_FP8_BLOCK_MAPPER[lower_model_name]
|
|
# No pre-quantized model found. vllm >= 0.12.0 quantizes to FP8 on the
|
|
# fly (return original name); older vllm falls through to offline quant.
|
|
if importlib.util.find_spec("vllm") is not None:
|
|
import vllm
|
|
if Version(vllm.__version__) >= Version("0.12.0"):
|
|
return model_name
|
|
return None
|
|
|
|
elif not SUPPORTS_FOURBIT and lower_model_name in INT_TO_FLOAT_MAPPER:
|
|
model_name = INT_TO_FLOAT_MAPPER[lower_model_name]
|
|
print(
|
|
f"Unsloth: Your transformers version of {transformers_version} does not support native "
|
|
f"4bit loading.\nThe minimum required version is 4.37.\n"
|
|
f'Try `pip install --upgrade "transformers>=4.37"`\n'
|
|
f"to obtain the latest transformers build, then restart this session.\n"
|
|
f"For now, we shall load `{model_name}` instead (still 4bit, just slower downloading)."
|
|
)
|
|
return model_name
|
|
|
|
elif not load_in_4bit and lower_model_name in INT_TO_FLOAT_MAPPER:
|
|
new_model_name = INT_TO_FLOAT_MAPPER[lower_model_name]
|
|
# logger.warning_once(
|
|
# f"Unsloth: You passed in `{model_name}` which is a 4bit model, yet you set\n"\
|
|
# f"`load_in_4bit = False`. We shall load `{new_model_name}` instead."
|
|
# )
|
|
return new_model_name
|
|
|
|
elif not load_in_4bit and lower_model_name in MAP_TO_UNSLOTH_16bit:
|
|
new_model_name = MAP_TO_UNSLOTH_16bit[lower_model_name]
|
|
return new_model_name
|
|
|
|
elif load_in_4bit and SUPPORTS_FOURBIT and lower_model_name in FLOAT_TO_INT_MAPPER:
|
|
# Keep an explicit -bnb-4bit name; otherwise map to the dynamic version.
|
|
if lower_model_name.endswith("-bnb-4bit"):
|
|
return model_name
|
|
|
|
new_model_name = FLOAT_TO_INT_MAPPER[lower_model_name]
|
|
# logger.warning_once(
|
|
# f"Unsloth: You passed in `{model_name}` and `load_in_4bit = True`.\n"\
|
|
# f"We shall load `{new_model_name}` for 4x faster loading."
|
|
# )
|
|
return new_model_name
|
|
|
|
return None
|
|
|
|
|
|
def _get_new_mapper():
|
|
try:
|
|
import requests
|
|
|
|
new_mapper = (
|
|
"https://raw.githubusercontent.com/unslothai/unsloth/main/unsloth/models/mapper.py"
|
|
)
|
|
with requests.get(new_mapper, timeout = 3) as new_mapper:
|
|
new_mapper = new_mapper.text
|
|
new_mapper = new_mapper[new_mapper.find("__INT_TO_FLOAT_MAPPER") :]
|
|
new_mapper = (
|
|
new_mapper.replace("INT_TO_FLOAT_MAPPER", "NEW_INT_TO_FLOAT_MAPPER")
|
|
.replace("FLOAT_TO_INT_MAPPER", "NEW_FLOAT_TO_INT_MAPPER")
|
|
.replace("MAP_TO_UNSLOTH_16bit", "NEW_MAP_TO_UNSLOTH_16bit")
|
|
)
|
|
|
|
exec(new_mapper, globals())
|
|
return (
|
|
NEW_INT_TO_FLOAT_MAPPER,
|
|
NEW_FLOAT_TO_INT_MAPPER,
|
|
NEW_MAP_TO_UNSLOTH_16bit,
|
|
)
|
|
except:
|
|
return {}, {}, {}
|
|
|
|
|
|
def _resolve_with_mappers(
|
|
model_name, load_in_4bit, load_in_fp8, int_to_float, float_to_int, map_to_unsloth_16bit
|
|
):
|
|
return __get_model_name(
|
|
model_name = model_name,
|
|
load_in_4bit = load_in_4bit,
|
|
INT_TO_FLOAT_MAPPER = int_to_float,
|
|
FLOAT_TO_INT_MAPPER = float_to_int,
|
|
MAP_TO_UNSLOTH_16bit = map_to_unsloth_16bit,
|
|
load_in_fp8 = load_in_fp8,
|
|
FLOAT_TO_FP8_BLOCK_MAPPER = FLOAT_TO_FP8_BLOCK_MAPPER,
|
|
FLOAT_TO_FP8_ROW_MAPPER = FLOAT_TO_FP8_ROW_MAPPER,
|
|
)
|
|
|
|
|
|
def get_model_name(
|
|
model_name,
|
|
load_in_4bit = True,
|
|
load_in_fp8 = False,
|
|
token = None,
|
|
trust_remote_code = False,
|
|
):
|
|
assert load_in_fp8 in (True, False, "block")
|
|
new_model_name = _resolve_with_mappers(
|
|
model_name = model_name,
|
|
load_in_4bit = load_in_4bit,
|
|
load_in_fp8 = load_in_fp8,
|
|
int_to_float = INT_TO_FLOAT_MAPPER,
|
|
float_to_int = FLOAT_TO_INT_MAPPER,
|
|
map_to_unsloth_16bit = MAP_TO_UNSLOTH_16bit,
|
|
)
|
|
# Remap "bad" names (e.g. oversized dynamic quants or MoEs)
|
|
if (
|
|
new_model_name is not None
|
|
and type(new_model_name) is str
|
|
and new_model_name.lower() in BAD_MAPPINGS
|
|
):
|
|
new_model_name = BAD_MAPPINGS[new_model_name.lower()]
|
|
elif new_model_name is None and model_name.lower() in BAD_MAPPINGS:
|
|
# Some bad names (e.g. the `-unsloth-bnb-4bit` dynamic quants) are keys
|
|
# of the mappers, not values, so the resolver returns None for them and
|
|
# the remap above is skipped; remap the input name directly instead.
|
|
new_model_name = BAD_MAPPINGS[model_name.lower()]
|
|
|
|
if (
|
|
new_model_name is None
|
|
and model_name.count("/") == 1
|
|
and model_name[0].isalnum()
|
|
and not _env_says_offline() # offline: skip the remote (raw GitHub) mapper refresh
|
|
):
|
|
# Try checking if a new Unsloth version allows it!
|
|
NEW_INT_TO_FLOAT_MAPPER, NEW_FLOAT_TO_INT_MAPPER, NEW_MAP_TO_UNSLOTH_16bit = (
|
|
_get_new_mapper()
|
|
)
|
|
upgraded_model_name = _resolve_with_mappers(
|
|
model_name = model_name,
|
|
load_in_4bit = load_in_4bit,
|
|
load_in_fp8 = load_in_fp8,
|
|
int_to_float = NEW_INT_TO_FLOAT_MAPPER,
|
|
float_to_int = NEW_FLOAT_TO_INT_MAPPER,
|
|
map_to_unsloth_16bit = NEW_MAP_TO_UNSLOTH_16bit,
|
|
)
|
|
if upgraded_model_name is not None:
|
|
raise NotImplementedError(
|
|
f"Unsloth: {model_name} is not supported in your current Unsloth version! Please update Unsloth via:\n\n"
|
|
"pip uninstall unsloth unsloth_zoo -y\n"
|
|
'pip install --upgrade --no-cache-dir "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"\n'
|
|
'pip install --upgrade --no-cache-dir "git+https://github.com/unslothai/unsloth-zoo.git"\n'
|
|
)
|
|
|
|
if new_model_name is None:
|
|
new_model_name = model_name
|
|
|
|
return new_model_name
|
|
|
|
|
|
def _offline_quantize_to_fp8(
|
|
model_name: str,
|
|
fp8_mode: str,
|
|
*,
|
|
text_only: bool = False,
|
|
) -> str:
|
|
"""Quantize the model to fp8 via torchao, save to a temp dir, return its path.
|
|
|
|
For vllm >= 0.12.0, prefer dynamic quantization in vllm instead (via
|
|
hf_overrides={"quantization_config_file": "torchao_config.json"}).
|
|
"""
|
|
from transformers import (
|
|
AutoModelForCausalLM,
|
|
AutoModelForImageTextToText,
|
|
AutoTokenizer,
|
|
AutoProcessor,
|
|
TorchAoConfig,
|
|
AutoConfig,
|
|
)
|
|
|
|
config = AutoConfig.from_pretrained(model_name)
|
|
is_vlm = any(
|
|
x.endswith(("ForConditionalGeneration", "ForVisionText2Text"))
|
|
for x in (getattr(config, "architectures", None) or [])
|
|
)
|
|
is_vlm = is_vlm or hasattr(config, "vision_config")
|
|
# Decide text-only before the cache name so the fp8 artifact and its path stay in sync. #5816
|
|
text_config = None
|
|
if text_only and hasattr(config, "vision_config"):
|
|
from ._utils import (
|
|
_get_text_only_config,
|
|
resolve_model_class,
|
|
_is_family_text_decoder,
|
|
)
|
|
|
|
candidate = _get_text_only_config(config, model_name)
|
|
text_class = resolve_model_class(AutoModelForCausalLM, candidate)
|
|
if text_class is not None and _is_family_text_decoder(
|
|
getattr(config, "model_type", ""),
|
|
getattr(candidate, "model_type", ""),
|
|
):
|
|
text_config = candidate
|
|
is_vlm = False
|
|
|
|
temp_dir = tempfile.gettempdir()
|
|
# Cache text-only and full-VLM artifacts separately so neither reuses the other. #5816
|
|
cache_name = model_name.split("/")[-1] + "-fp8-" + fp8_mode
|
|
if text_config is not None:
|
|
cache_name += "-text-only"
|
|
new_model_name = os.path.join(temp_dir, cache_name)
|
|
print(f"Unsloth: Quantizing '{model_name}' to fp8, using model_name='{new_model_name}' instead")
|
|
|
|
if not os.path.isdir(new_model_name):
|
|
from ._utils import _apply_text_only_key_mapping
|
|
|
|
qconfig = _get_torchao_fp8_config(fp8_mode)
|
|
qconfig = TorchAoConfig(qconfig)
|
|
load_kwargs = dict(torch_dtype = "auto", device_map = "auto", quantization_config = qconfig)
|
|
if text_config is not None:
|
|
_apply_text_only_key_mapping(load_kwargs, config, text_config)
|
|
config = text_config
|
|
auto_model = AutoModelForImageTextToText if is_vlm else AutoModelForCausalLM
|
|
auto_processor = AutoProcessor if is_vlm else AutoTokenizer
|
|
model = auto_model.from_pretrained(
|
|
model_name,
|
|
config = config,
|
|
**load_kwargs,
|
|
)
|
|
tokenizer = auto_processor.from_pretrained(model_name)
|
|
model.save_pretrained(new_model_name, safe_serialization = False)
|
|
del model
|
|
for _ in range(2):
|
|
torch.cuda.empty_cache()
|
|
gc.collect()
|
|
tokenizer.save_pretrained(new_model_name)
|
|
return new_model_name
|
|
|
|
|
|
def _tag_model_with_fp8_torchao_config(model: torch.nn.Module, fp8_mode: str):
|
|
"""Tag a model with a `TorchAOConfig` so downstream callers know how to handle it."""
|
|
try:
|
|
base_config = _get_torchao_fp8_config(fp8_mode)
|
|
model.torchao_config = TorchAOConfig(
|
|
qat_scheme = None,
|
|
base_config_and_filter_fns = [(base_config, None)],
|
|
)
|
|
except:
|
|
pass
|
|
|
|
|
|
_FP8_DTYPES = tuple(
|
|
dtype
|
|
for dtype in (getattr(torch, "float8_e4m3fn", None), getattr(torch, "float8_e5m2", None))
|
|
if dtype is not None
|
|
)
|
|
|
|
|
|
def _fp8_block_size_from_config(model):
|
|
"""Return the [block_out, block_in] block size of an fp8 checkpoint, or None if not block-fp8."""
|
|
config = getattr(model, "config", None)
|
|
quant = getattr(config, "quantization_config", None)
|
|
if quant is None:
|
|
return None
|
|
if hasattr(quant, "to_dict"):
|
|
quant = quant.to_dict()
|
|
if not isinstance(quant, dict):
|
|
return None
|
|
if quant.get("quant_method") != "fp8":
|
|
return None
|
|
block = quant.get("weight_block_size")
|
|
if not block:
|
|
return None
|
|
if isinstance(block, (int, float)):
|
|
block = [block, block]
|
|
elif isinstance(block, (list, tuple)):
|
|
if len(block) == 1:
|
|
block = [block[0], block[0]]
|
|
elif len(block) < 2:
|
|
return None
|
|
else:
|
|
return None
|
|
return [int(block[0]), int(block[1])]
|
|
|
|
|
|
def _load_fp8_weight_map(
|
|
model_name,
|
|
local_files_only,
|
|
token,
|
|
revision = None,
|
|
subfolder = None,
|
|
cache_dir = None,
|
|
):
|
|
"""Return the checkpoint's tensor->file map, using the same snapshot the load used.
|
|
|
|
Prefers the sharded `model.safetensors.index.json`; falls back to a single `model.safetensors`
|
|
(every tensor maps to that one file) so unsharded checkpoints are covered too.
|
|
"""
|
|
|
|
def _local_path(filename):
|
|
return (
|
|
os.path.join(model_name, subfolder, filename)
|
|
if subfolder
|
|
else os.path.join(model_name, filename)
|
|
)
|
|
|
|
def _remote_path(filename):
|
|
from huggingface_hub import hf_hub_download
|
|
return hf_hub_download(
|
|
model_name,
|
|
filename,
|
|
revision = revision,
|
|
subfolder = subfolder,
|
|
cache_dir = cache_dir,
|
|
local_files_only = local_files_only,
|
|
token = token,
|
|
)
|
|
|
|
index_file = "model.safetensors.index.json"
|
|
single_file = "model.safetensors"
|
|
is_local = os.path.isdir(model_name)
|
|
|
|
# Sharded checkpoint.
|
|
if is_local and os.path.exists(_local_path(index_file)):
|
|
index_path = _local_path(index_file)
|
|
elif not is_local:
|
|
try:
|
|
index_path = _remote_path(index_file)
|
|
except Exception:
|
|
index_path = None
|
|
else:
|
|
index_path = None
|
|
if index_path is not None:
|
|
import json
|
|
with open(index_path, "r") as f:
|
|
return json.load(f).get("weight_map", None)
|
|
|
|
# Unsharded single file: map every tensor to it.
|
|
try:
|
|
if is_local and os.path.exists(_local_path(single_file)):
|
|
single_path = _local_path(single_file)
|
|
elif not is_local:
|
|
single_path = _remote_path(single_file)
|
|
else:
|
|
return None
|
|
from safetensors import safe_open
|
|
with safe_open(single_path, framework = "pt") as f:
|
|
return {key: single_file for key in f.keys()}
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _resolve_fp8_shard(
|
|
model_name,
|
|
shard,
|
|
local_files_only,
|
|
token,
|
|
revision = None,
|
|
subfolder = None,
|
|
cache_dir = None,
|
|
):
|
|
"""Resolve a checkpoint shard filename to a local path (repo id or local dir)."""
|
|
if os.path.isdir(model_name):
|
|
return (
|
|
os.path.join(model_name, subfolder, shard)
|
|
if subfolder
|
|
else os.path.join(model_name, shard)
|
|
)
|
|
from huggingface_hub import hf_hub_download
|
|
|
|
return hf_hub_download(
|
|
model_name,
|
|
shard,
|
|
revision = revision,
|
|
subfolder = subfolder,
|
|
cache_dir = cache_dir,
|
|
local_files_only = local_files_only,
|
|
token = token,
|
|
)
|
|
|
|
|
|
def _match_fp8_module(module_by_name, base):
|
|
"""Resolve a checkpoint module name to a live module, allowing for VLM key remappings.
|
|
|
|
VLM loads can name the text tower differently from the checkpoint keys: `text_only=True`
|
|
strips the `language_model.` wrapper (so `model.language_model.layers.*` -> `model.layers.*`),
|
|
and full VLM loads may expose `model.language_model.*` while the checkpoint stores
|
|
`language_model.model.*`. Try the raw key first, then a few safe remappings.
|
|
"""
|
|
if base in module_by_name:
|
|
return module_by_name[base]
|
|
candidates = []
|
|
if "language_model." in base:
|
|
candidates.append(base.replace("language_model.", "", 1)) # text-only: drop wrapper
|
|
if "language_model.model." in base:
|
|
candidates.append(base.replace("language_model.model.", "model.language_model.", 1))
|
|
if base.startswith("language_model."):
|
|
candidates.append("model." + base) # add model. prefix
|
|
for candidate in candidates:
|
|
if candidate in module_by_name:
|
|
return module_by_name[candidate]
|
|
return None
|
|
|
|
|
|
def _restore_dropped_fp8_scales(
|
|
model,
|
|
model_name,
|
|
*,
|
|
local_files_only = False,
|
|
token = None,
|
|
revision = None,
|
|
subfolder = None,
|
|
cache_dir = None,
|
|
variant = None,
|
|
):
|
|
"""Re-apply block-fp8 `weight_scale_inv` tensors that transformers dropped on load.
|
|
|
|
On some block-scale fp8 checkpoints (e.g. Qwen3.6-27B-FP8, issue #6200) transformers fails to
|
|
convert a Linear (such as `mlp.gate_proj`) to an fp8 module, loading the raw quantized values
|
|
into a plain bf16 weight and discarding its `weight_scale_inv` as an unexpected key. The weight
|
|
is then used un-scaled, producing a garbage model. For every checkpoint scale whose live weight
|
|
is not fp8, dequantize the orphaned weight in place. Modules that were converted correctly keep
|
|
an fp8 weight and are skipped, so a healthy checkpoint is a no-op. Returns (restored, skipped).
|
|
"""
|
|
try:
|
|
block = _fp8_block_size_from_config(model)
|
|
if block is None or not _FP8_DTYPES:
|
|
return (0, 0)
|
|
# A variant load reads variant-named files; skip to avoid applying default scales to them.
|
|
if variant:
|
|
return (0, 0)
|
|
# No fp8 params means the checkpoint was dequantized on purpose (e.g. load_in_16bit);
|
|
# re-applying a scale would corrupt those already-correct 16bit weights, so do nothing.
|
|
if not any(p.dtype in _FP8_DTYPES for p in model.parameters()):
|
|
return (0, 0)
|
|
weight_map = _load_fp8_weight_map(
|
|
model_name, local_files_only, token, revision, subfolder, cache_dir
|
|
)
|
|
if not weight_map:
|
|
return (0, 0)
|
|
|
|
scale_keys = {k: v for k, v in weight_map.items() if k.endswith(".weight_scale_inv")}
|
|
if not scale_keys:
|
|
return (0, 0)
|
|
|
|
module_by_name = dict(model.named_modules())
|
|
bs0, bs1 = block
|
|
restored = 0
|
|
skipped = 0
|
|
failed = 0
|
|
offloaded = 0
|
|
shard_cache = {}
|
|
for scale_key, shard in scale_keys.items():
|
|
base = scale_key[: -len(".weight_scale_inv")]
|
|
module = _match_fp8_module(module_by_name, base)
|
|
if module is None:
|
|
continue
|
|
weight = getattr(module, "weight", None)
|
|
if not isinstance(weight, torch.Tensor) or weight.ndim != 2:
|
|
continue
|
|
if weight.device.type == "meta":
|
|
# Disk-offloaded layer: weight lives on meta until forward, so it cannot be
|
|
# scaled in place here. Count and warn rather than silently leave it unscaled.
|
|
offloaded += 1
|
|
continue
|
|
if weight.dtype in _FP8_DTYPES:
|
|
# Correctly converted fp8 module: the fp8 path already handles the scale.
|
|
skipped += 1
|
|
continue
|
|
|
|
# Errors after this point are per-tensor: warn and continue, never abort or hide them.
|
|
try:
|
|
if shard not in shard_cache:
|
|
from safetensors import safe_open
|
|
shard_path = _resolve_fp8_shard(
|
|
model_name,
|
|
shard,
|
|
local_files_only,
|
|
token,
|
|
revision,
|
|
subfolder,
|
|
cache_dir,
|
|
)
|
|
shard_cache[shard] = safe_open(shard_path, framework = "pt")
|
|
scale = shard_cache[shard].get_tensor(scale_key).to(torch.float32)
|
|
|
|
out_features, in_features = weight.shape
|
|
out_blocks = (out_features + bs0 - 1) // bs0
|
|
in_blocks = (in_features + bs1 - 1) // bs1
|
|
if tuple(scale.shape) == (out_blocks, in_blocks):
|
|
pass
|
|
elif tuple(scale.shape) == (in_blocks, out_blocks) and out_blocks != in_blocks:
|
|
# Transposed block layout: same handling as the fp8 forward path.
|
|
scale = scale.t().contiguous()
|
|
else:
|
|
# Shape does not match the block grid: skip rather than apply a wrong scale.
|
|
continue
|
|
scale = scale.to(weight.device)
|
|
with torch.no_grad():
|
|
if out_features % bs0 == 0 and in_features % bs1 == 0:
|
|
# Memory-frugal path: multiply block views in place against the broadcast
|
|
# fp32 scale, avoiding a full expanded scale and fp32 copy that could OOM.
|
|
# The in-place multiply promotes to fp32, matching the fallback exactly.
|
|
module.weight.data.view(out_blocks, bs0, in_blocks, bs1).mul_(
|
|
scale[:, None, :, None]
|
|
)
|
|
else:
|
|
scale_expanded = scale.repeat_interleave(bs0, dim = 0).repeat_interleave(
|
|
bs1, dim = 1
|
|
)[:out_features, :in_features]
|
|
module.weight.data = (weight.to(torch.float32) * scale_expanded).to(
|
|
weight.dtype
|
|
)
|
|
restored += 1
|
|
except Exception:
|
|
failed += 1
|
|
continue
|
|
|
|
if restored > 0:
|
|
print(f"Unsloth: Restored {restored} dropped FP8 weight_scale_inv tensor(s) on load")
|
|
if failed > 0:
|
|
print(f"Unsloth: {failed} dropped FP8 weight_scale_inv tensor(s) could not be restored")
|
|
if offloaded > 0:
|
|
print(
|
|
f"Unsloth: {offloaded} dropped FP8 weight_scale_inv tensor(s) skipped because the "
|
|
"layer is disk-offloaded; load without disk offload so the scales can be restored"
|
|
)
|
|
return (restored, skipped)
|
|
except Exception:
|
|
return (0, 0)
|
|
|
|
|
|
def check_and_disable_bitsandbytes_loading(
|
|
model_config,
|
|
load_in_4bit = True,
|
|
load_in_8bit = False,
|
|
verbose = True,
|
|
):
|
|
"""
|
|
Check if we should disable bitsandbytes loading (load_in_4bit/load_in_8bit)
|
|
because the model already has a non-bitsandbytes quantization config.
|
|
If so, disable BOTH 4bit and 8bit loading and print a warning message.
|
|
|
|
Args:
|
|
model_config: The AutoConfig object from the model
|
|
load_in_4bit: Whether load_in_4bit is currently enabled
|
|
load_in_8bit: Whether load_in_8bit is currently enabled
|
|
verbose: Whether to print warning messages
|
|
|
|
Returns:
|
|
tuple: (load_in_4bit, load_in_8bit, quant_method)
|
|
load_in_4bit/load_in_8bit will be False if they were disabled
|
|
quant_method is the detected quantization method or None
|
|
"""
|
|
quant_method = get_quant_type(model_config)
|
|
|
|
if quant_method is None or quant_method == "bitsandbytes":
|
|
return load_in_4bit, load_in_8bit, quant_method
|
|
|
|
# Model has a non-bitsandbytes quantization config (e.g., compressed-tensors, gptq, awq)
|
|
# We should disable BOTH bitsandbytes loading to avoid config conflicts
|
|
if load_in_4bit or load_in_8bit:
|
|
if verbose:
|
|
print(
|
|
f"Unsloth: Model already quantized with {quant_method}. "
|
|
f"Disabling `load_in_4bit` and `load_in_8bit` to avoid quantization config conflict."
|
|
)
|
|
load_in_4bit = False
|
|
load_in_8bit = False
|
|
|
|
return load_in_4bit, load_in_8bit, quant_method
|
|
|
|
|
|
def sync_unsloth_model_name_bnb_flags(load_in_4bit, load_in_8bit):
|
|
"""Make UNSLOTH_MODEL_NAME's `_load_in_4bit_`/`_load_in_8bit_` tokens match the EFFECTIVE bnb
|
|
state (after get_model_name remap + check_and_disable). The per-load env is built from the
|
|
pre-remap config (None for adapter-only PEFT repos), so its tokens can be wrong once the base
|
|
resolves. Only the gpt-oss patch reads them, so this is gated to gpt-oss; no-op otherwise."""
|
|
name = os.environ.get("UNSLOTH_MODEL_NAME", "")
|
|
if "gpt_oss" not in name.replace("-", "_"):
|
|
return
|
|
for flag, present in (
|
|
("_load_in_4bit_", bool(load_in_4bit)),
|
|
("_load_in_8bit_", bool(load_in_8bit)),
|
|
):
|
|
if present and flag not in name:
|
|
name += flag
|
|
elif not present and flag in name:
|
|
name = name.replace(flag, "")
|
|
os.environ["UNSLOTH_MODEL_NAME"] = name
|
|
|
|
|
|
def _get_fp8_mode_and_check_settings(
|
|
load_in_fp8: Union[bool, str],
|
|
fast_inference: bool,
|
|
full_finetuning: bool = False,
|
|
load_in_4bit: bool = False,
|
|
load_in_8bit: bool = False,
|
|
load_in_16bit: bool = False,
|
|
) -> str:
|
|
"""Validate `load_in_fp8` settings/environment and return the fp8 mode
|
|
("row" or "block"). Requires H100+, torchao 0.15.0+, torch 2.9.0+, and
|
|
fbgemm_gpu_genai 1.4.1+ if installed.
|
|
"""
|
|
assert load_in_fp8 is not False
|
|
if load_in_fp8 is True:
|
|
fp8_mode = "row" # default
|
|
else:
|
|
fp8_mode = load_in_fp8
|
|
|
|
# Check user settings
|
|
if fp8_mode not in ["row", "block"]:
|
|
raise ValueError(f"Unsloth: `load_in_fp8` can only be 'row' or 'block', got '{fp8_mode}'")
|
|
if full_finetuning:
|
|
raise ValueError("Unsloth: `load_in_fp8` is not compatible with full finetuning")
|
|
if load_in_4bit or load_in_8bit or load_in_16bit:
|
|
raise ValueError(
|
|
"Unsloth: `load_in_fp8` is not compatible with `load_in_4bit`, `load_in_8bit` or `load_in_16bit`",
|
|
)
|
|
|
|
# Check if this is Hopper or above
|
|
if not (
|
|
torch.cuda.is_available()
|
|
and torch.version.cuda
|
|
and torch.cuda.get_device_capability() >= (9, 0)
|
|
):
|
|
raise ValueError(
|
|
"Unsloth: On the fly `load_in_fp8` requires H100 GPUs or after. Try `unsloth/Qwen3-8B` instead."
|
|
)
|
|
|
|
# Check if torch >= 2.9.0
|
|
if Version(torch.__version__) < Version("2.9.0"):
|
|
raise ValueError(
|
|
"Unsloth: On the fly `load_in_fp8` requires torch 2.9.0+. Try `unsloth/Qwen3-8B` instead."
|
|
)
|
|
|
|
# Check if torchao has this PR: https://github.com/pytorch/ao/pull/3158,
|
|
# which will be released in 0.15.0.
|
|
if importlib.util.find_spec("torchao") is None:
|
|
raise ValueError(
|
|
"Unsloth: Please install torchao for on the fly float8 to work! Try `unsloth/Qwen3-8B` instead."
|
|
)
|
|
import torchao
|
|
|
|
error_message = (
|
|
"Unsloth: `load_in_fp8` requires torchao 0.15.0+ (or nightly).\n"
|
|
f"You have torchao version={torchao.__version__}\n"
|
|
"Use `pip install --upgrade --force-reinstall torchao`"
|
|
)
|
|
if Version(torchao.__version__) < Version("0.15.0"):
|
|
raise ValueError(error_message)
|
|
|
|
# If fbgemm_gpu_genai is installed and old, disable FBGEMM and use Triton instead
|
|
if (
|
|
importlib.util.find_spec("fbgemm_gpu") is not None
|
|
and importlib.util.find_spec("fbgemm_gpu.experimental") is not None
|
|
):
|
|
import fbgemm_gpu.experimental.gen_ai
|
|
if Version(fbgemm_gpu.__version__) < Version("1.4.1"):
|
|
# Old FBGEMM version - disable and use Triton kernels instead
|
|
os.environ["UNSLOTH_HAS_FBGEMM"] = "0"
|
|
from unsloth_zoo.log import logger
|
|
logger.info(
|
|
f"Unsloth: fbgemm_gpu_genai=={fbgemm_gpu.__version__} is old for FP8 loading. "
|
|
f"Using Triton kernels instead."
|
|
)
|
|
return fp8_mode
|
|
|
|
|
|
# Rotary inv_freq buffers are deliberately kept on CPU - Unsloth pre-builds a
|
|
# cos/sin cache per GPU instead (see LlamaRotaryEmbedding.multi_gpu_cos_cached)
|
|
# so the GPU-resident lookup never needs to move the tiny inv_freq tensor itself.
|
|
# torch.nn.parallel.DistributedDataParallel ignores device entirely when it
|
|
# broadcasts buffers across ranks, so a CPU buffer crashes NCCL's
|
|
# _broadcast_coalesced with "No backend type associated with device type cpu".
|
|
# Telling DDP to skip these specific buffers avoids that crash without moving
|
|
# inv_freq to GPU (which would break the per-GPU cache design) and without
|
|
# disabling buffer broadcast for every other module (the user's workaround).
|
|
# Re-run this after wrapping with PEFT too - the buffers' fully qualified
|
|
# names change once they sit under a PeftModel (eg "base_model.model...").
|
|
# https://github.com/unslothai/unsloth/issues/6656
|
|
_ROTARY_INV_FREQ_BUFFER_NAMES = ("inv_freq", "short_inv_freq", "long_inv_freq")
|
|
|
|
|
|
def _exclude_rope_inv_freq_from_ddp(model):
|
|
ignored = list(getattr(model, "_ddp_params_and_buffers_to_ignore", None) or [])
|
|
for module_name, module in model.named_modules():
|
|
for buffer_name, _ in module.named_buffers(recurse = False):
|
|
if buffer_name in _ROTARY_INV_FREQ_BUFFER_NAMES:
|
|
fqn = f"{module_name}.{buffer_name}" if module_name else buffer_name
|
|
if fqn not in ignored:
|
|
ignored.append(fqn)
|
|
if ignored:
|
|
try:
|
|
from torch.nn.parallel import DistributedDataParallel
|
|
DistributedDataParallel._set_params_and_buffers_to_ignore_for_model(model, ignored)
|
|
except Exception:
|
|
# Private PyTorch API - fall back to setting the attribute DDP reads
|
|
# directly if it ever moves or changes signature.
|
|
model._ddp_params_and_buffers_to_ignore = ignored
|
|
return model
|
|
|
|
|
|
# =============================================================================
|
|
# Offline loading - single source of truth (shared by vision.py, loader.py and
|
|
# the Studio exporter). Decide offline ONCE at the load boundary and force it
|
|
# ONCE around the whole load, so every nested HF call inherits it.
|
|
# =============================================================================
|
|
|
|
_OFFLINE_ENV_VALUES = {"1", "true", "yes", "on"}
|
|
_OFFLINE_ENV_KEYS = ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")
|
|
|
|
|
|
def _env_says_offline():
|
|
"""True if an HF offline env var is set to a truthy value."""
|
|
return any(
|
|
os.environ.get(_k, "").strip().lower() in _OFFLINE_ENV_VALUES for _k in _OFFLINE_ENV_KEYS
|
|
)
|
|
|
|
|
|
def _get_effective_local_files_only(kwargs):
|
|
"""Offline if local_files_only is truthy or an HF offline env var is set. Read-only."""
|
|
if kwargs.get("local_files_only", None):
|
|
return True
|
|
return _env_says_offline()
|
|
|
|
|
|
def _is_offline_related_error(exc):
|
|
"""True if exc (or its cause/context chain) is a lost-connection error, not a
|
|
missing file. Plain FileNotFoundError propagates; LocalEntryNotFoundError is offline."""
|
|
import socket
|
|
import ssl
|
|
import urllib.error
|
|
|
|
# Match network failures by type (locale independent), not just message wording.
|
|
_net_types = [ConnectionError, TimeoutError, socket.gaierror, urllib.error.URLError]
|
|
_offline_fnf_types = () # FileNotFoundError subclasses that count as offline
|
|
# urllib HTTPError is a URLError subclass: judge by status (5xx offline, 4xx propagates).
|
|
_http_types = (urllib.error.HTTPError,)
|
|
# TLS/cert failures are security-sensitive (MITM, expired CA): never offline-retry them.
|
|
_ssl_types = [ssl.SSLError]
|
|
try:
|
|
import requests
|
|
|
|
_net_types += [requests.exceptions.ConnectionError, requests.exceptions.Timeout]
|
|
_http_types += (requests.exceptions.HTTPError,)
|
|
_ssl_types.append(requests.exceptions.SSLError)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from huggingface_hub.errors import (
|
|
OfflineModeIsEnabled,
|
|
HfHubHTTPError,
|
|
LocalEntryNotFoundError,
|
|
)
|
|
|
|
_net_types += [OfflineModeIsEnabled, LocalEntryNotFoundError]
|
|
_offline_fnf_types = (LocalEntryNotFoundError,)
|
|
_http_types += (HfHubHTTPError,)
|
|
except Exception:
|
|
pass
|
|
_net_types = tuple(_net_types)
|
|
_ssl_types = tuple(_ssl_types)
|
|
|
|
def _http_status(e):
|
|
resp = getattr(e, "response", None)
|
|
code = getattr(resp, "status_code", None)
|
|
if code is None:
|
|
code = getattr(e, "status_code", None)
|
|
if code is None:
|
|
code = getattr(e, "code", None) # urllib.error.HTTPError uses .code
|
|
try:
|
|
return int(code)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
_wording = (
|
|
"couldn't connect",
|
|
"could not connect",
|
|
"connection error",
|
|
"connectionerror",
|
|
"max retries",
|
|
"offline",
|
|
"timed out",
|
|
"timeout",
|
|
"couldn't reach",
|
|
"could not reach",
|
|
"failed to resolve",
|
|
"getaddrinfo",
|
|
"name resolution",
|
|
"no address associated",
|
|
"network is unreachable",
|
|
"connection refused",
|
|
"we couldn't connect to",
|
|
"proxyerror",
|
|
# Raw socket.gaierror DNS wording (Linux / macOS)
|
|
"name or service not known",
|
|
"temporary failure in name resolution",
|
|
"nodename nor servname provided",
|
|
)
|
|
seen = set()
|
|
cur = exc
|
|
while cur is not None and id(cur) not in seen:
|
|
seen.add(id(cur))
|
|
# TLS/cert failure (corporate MITM, expired CA): security-sensitive, never retry from
|
|
# cache. Skip this node; a deeper cause in the chain may still be a genuine outage.
|
|
if isinstance(cur, _ssl_types) or isinstance(getattr(cur, "reason", None), _ssl_types):
|
|
cur = cur.__cause__ or cur.__context__
|
|
continue
|
|
is_fnf = isinstance(cur, FileNotFoundError) and not isinstance(cur, _offline_fnf_types)
|
|
# urllib HTTPError is a URLError (net type) but must be judged by status code below,
|
|
# unlike LocalEntryNotFoundError (an HfHubHTTPError that is always offline).
|
|
if (
|
|
isinstance(cur, _net_types)
|
|
and not is_fnf
|
|
and not isinstance(cur, urllib.error.HTTPError)
|
|
):
|
|
return True
|
|
if isinstance(cur, _http_types):
|
|
code = _http_status(cur)
|
|
if code is not None and 500 <= code < 600:
|
|
return True
|
|
# No status -> wording fallback (coded 4xx already decided above).
|
|
if code is None and not is_fnf and any(w in str(cur).lower() for w in _wording):
|
|
return True
|
|
# OSError wording fallback (HTTP status already decided above).
|
|
elif isinstance(cur, OSError) and not is_fnf:
|
|
if any(w in str(cur).lower() for w in _wording):
|
|
return True
|
|
cur = cur.__cause__ or cur.__context__
|
|
return False
|
|
|
|
|
|
# Process-wide HF offline state; the depth counter lets nested windows share one
|
|
# flip (first entrant saves originals, last exit restores). Lock guards flip/restore.
|
|
_force_offline_lock = _threading.RLock()
|
|
_force_offline_depth = 0
|
|
_force_offline_saved = [] # in-process module attributes
|
|
_force_offline_saved_env = {} # HF offline env-var originals
|
|
|
|
|
|
def _reset_hf_sessions():
|
|
"""Clear hub's per-thread cached Sessions so the next rebuilds against the current
|
|
offline flag. On hub 0.x the offline adapter is baked in at Session creation. Best-effort."""
|
|
try:
|
|
from huggingface_hub.utils._http import reset_sessions
|
|
except Exception:
|
|
try:
|
|
from huggingface_hub.utils import reset_sessions
|
|
except Exception:
|
|
return
|
|
try:
|
|
reset_sessions()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _force_hf_offline():
|
|
"""Force HF offline for the window. local_files_only alone is not enough
|
|
(transformers < 5 still pings /api/models), so set BOTH the env vars (cover
|
|
subprocesses + raw urllib/requests) AND the in-process hub/transformers constants.
|
|
Process-global; the refcount keeps restore correct under nesting / overlap."""
|
|
global _force_offline_depth, _force_offline_saved, _force_offline_saved_env
|
|
with _force_offline_lock:
|
|
if _force_offline_depth == 0:
|
|
saved = []
|
|
saved_env = {}
|
|
# Snapshot in-process constants BEFORE forcing the env: a module first imported
|
|
# here would otherwise initialize its constant from the just-set "1" and we would
|
|
# save (then restore) True, pinning the process offline after the window.
|
|
try:
|
|
import huggingface_hub.constants as _hfc
|
|
if hasattr(_hfc, "HF_HUB_OFFLINE"):
|
|
saved.append((_hfc, "HF_HUB_OFFLINE", _hfc.HF_HUB_OFFLINE))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import transformers.utils.hub as _tuh
|
|
for _attr in ("_is_offline_mode", "OFFLINE"):
|
|
if hasattr(_tuh, _attr):
|
|
saved.append((_tuh, _attr, getattr(_tuh, _attr)))
|
|
except Exception:
|
|
pass
|
|
# Now force the env vars and flip the snapshotted constants to offline.
|
|
for _k in _OFFLINE_ENV_KEYS:
|
|
saved_env[_k] = os.environ.get(_k)
|
|
os.environ[_k] = "1"
|
|
for _obj, _attr, _ in saved:
|
|
try:
|
|
setattr(_obj, _attr, True)
|
|
except Exception:
|
|
pass
|
|
_force_offline_saved = saved
|
|
_force_offline_saved_env = saved_env
|
|
# Rebuild cached sessions so they pick up the offline adapter.
|
|
_reset_hf_sessions()
|
|
_force_offline_depth += 1
|
|
try:
|
|
yield
|
|
finally:
|
|
with _force_offline_lock:
|
|
_force_offline_depth -= 1
|
|
if _force_offline_depth == 0:
|
|
for obj, attr, val in _force_offline_saved:
|
|
try:
|
|
setattr(obj, attr, val)
|
|
except Exception:
|
|
pass
|
|
_force_offline_saved = []
|
|
for _k, _v in _force_offline_saved_env.items():
|
|
if _v is None:
|
|
os.environ.pop(_k, None)
|
|
else:
|
|
os.environ[_k] = _v
|
|
_force_offline_saved_env = {}
|
|
# Drop offline-mounted sessions so later online calls rebuild for the network.
|
|
_reset_hf_sessions()
|
|
|
|
|
|
def _progress_bars_were_disabled():
|
|
"""Snapshot HF progress-bar state (None if unknown); pairs with _restore_progress_bars."""
|
|
try:
|
|
from huggingface_hub.utils import are_progress_bars_disabled
|
|
return are_progress_bars_disabled()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _restore_progress_bars(were_disabled):
|
|
"""Re-enable HF progress bars only if a failed attempt left them disabled after they
|
|
were enabled (a loader disables them around config probes and skips re-enabling on
|
|
error). No-op if the user had them disabled or the state is unknown."""
|
|
if were_disabled is False:
|
|
try:
|
|
from huggingface_hub.utils import enable_progress_bars
|
|
enable_progress_bars()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _offline_aware_load(fn):
|
|
"""Decide offline ONCE (local_files_only kwarg or env) and force it around the
|
|
whole load. If we started online and hit a network error, retry once forced-offline.
|
|
Network-up online path is unchanged: no window, no retry."""
|
|
|
|
@functools.wraps(fn)
|
|
def _wrapper(*args, **kwargs):
|
|
if _get_effective_local_files_only(kwargs):
|
|
kwargs["local_files_only"] = True
|
|
with _force_hf_offline():
|
|
return fn(*args, **kwargs)
|
|
_pb_were_disabled = _progress_bars_were_disabled() # restore before any retry
|
|
try:
|
|
return fn(*args, **kwargs)
|
|
except Exception as e:
|
|
# Skip if not network-related, or already retried by a nested decorator
|
|
# (else outer layers reload the whole model again).
|
|
if not _is_offline_related_error(e) or getattr(e, "_unsloth_offline_retried", False):
|
|
raise
|
|
# Retry OUTSIDE the except so the failed attempt's traceback (a partial model)
|
|
# is freed before reallocating, else a large VLM can OOM on the second load.
|
|
try:
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
|
torch.xpu.empty_cache()
|
|
except Exception:
|
|
pass
|
|
# A failed attempt may have left HF progress bars disabled; restore before retry.
|
|
_restore_progress_bars(_pb_were_disabled)
|
|
kwargs["local_files_only"] = True
|
|
try:
|
|
with _force_hf_offline():
|
|
return fn(*args, **kwargs)
|
|
except Exception as e:
|
|
# Tag so an enclosing _offline_aware_load skips its own redundant retry.
|
|
try:
|
|
e._unsloth_offline_retried = True
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
return _wrapper
|
|
|
|
|
|
def _has_local_tokenizer_files(path):
|
|
"""True if a local dir has a loadable tokenizer (BPE vocab.json needs merges.txt;
|
|
special_tokens_map.json is not required)."""
|
|
return (
|
|
os.path.exists(os.path.join(path, "tokenizer.json"))
|
|
or os.path.exists(os.path.join(path, "tokenizer.model"))
|
|
or (
|
|
os.path.exists(os.path.join(path, "vocab.json"))
|
|
and os.path.exists(os.path.join(path, "merges.txt"))
|
|
)
|
|
or os.path.exists(os.path.join(path, "vocab.txt"))
|
|
or os.path.exists(os.path.join(path, "spiece.model"))
|
|
)
|
|
|
|
|
|
def _has_local_processor_files(path):
|
|
"""True if a local dir ships a processor/image-processor config (a VLM needs this to
|
|
build AutoProcessor; tokenizer files alone are not enough)."""
|
|
return os.path.exists(os.path.join(path, "processor_config.json")) or os.path.exists(
|
|
os.path.join(path, "preprocessor_config.json")
|
|
)
|
|
|
|
|
|
def _resolve_checkpoint_tokenizer_name(
|
|
old_model_name,
|
|
kwargs,
|
|
require_processor = False,
|
|
):
|
|
"""tokenizer_name for a PEFT/checkpoint load: caller override, else the local checkpoint
|
|
dir if self-sufficient, else None (base repo). Always popped from kwargs (also passed
|
|
explicitly downstream). For a VLM (require_processor), the dir must also ship processor
|
|
files; otherwise fall back to the base repo whose cached processor still loads."""
|
|
explicit = kwargs.pop("tokenizer_name", None)
|
|
if explicit is not None:
|
|
return explicit
|
|
has_config = os.path.exists(os.path.join(old_model_name, "tokenizer_config.json"))
|
|
if not (has_config and _has_local_tokenizer_files(old_model_name)):
|
|
return None
|
|
if require_processor and not _has_local_processor_files(old_model_name):
|
|
return None
|
|
return old_model_name
|