Add FP8/FP4 compressed export to save_pretrained_merged
Adds compressed-tensors export (for vLLM) to save_pretrained_merged /
push_to_hub_merged via llm-compressor, alongside the existing lora /
merged_16bit / merged_4bit / gguf / torchao paths:
model.save_pretrained_merged("model", tokenizer, save_method="fp8")
Supported save_method values: fp8 (FP8_DYNAMIC), mxfp4, nvfp4 (W4A4) and
mxfp8. The LoRA is merged to 16bit at save_directory, then a quantized
checkpoint is written to save_directory + "-<fmt>". nvfp4 needs a small
calibration set (defaults to ultrachat, overridable via calibration_dataset).
Notes:
- llm-compressor is installed lazily on first use, pinning the current torch
and transformers via a constraints file so they are not upgraded (a plain
install pulls transformers>=5 and breaks Unsloth).
- Quantization runs in a separate process (unsloth/_compressed_quantize.py,
launched by file path) so Unsloth's transformers attention patches do not
interfere with the forward llm-compressor runs during calibration, mirroring
how GGUF export shells out to llama.cpp.
- mxfp8 needs a newer llm-compressor (transformers>=5); it is recognised and
raises a clear error until that stack is available.
This commit is contained in:
parent
b693ed7c91
commit
315ecdef5e
2 changed files with 617 additions and 2 deletions
168
unsloth/_compressed_quantize.py
Normal file
168
unsloth/_compressed_quantize.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# 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.
|
||||
"""Standalone llm-compressor runner for Unsloth's FP8/FP4 export.
|
||||
|
||||
Launched as a subprocess by file path (not `python -m`) so the Unsloth package, which patches
|
||||
transformers attention, is not imported here; llm-compressor needs an unpatched forward for
|
||||
calibration (e.g. NVFP4). Reads a merged 16bit checkpoint, writes a compressed-tensors one.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _build_calibration_dataset(tokenizer, kind, value, num_samples, max_seq_length):
|
||||
from datasets import DatasetDict, load_dataset, load_from_disk
|
||||
_tok = tokenizer.tokenizer if hasattr(tokenizer, "tokenizer") else tokenizer
|
||||
|
||||
if kind == "none":
|
||||
print(
|
||||
f"Unsloth: NVFP4 needs calibration data. Defaulting to {num_samples} samples of "
|
||||
"HuggingFaceH4/ultrachat_200k. For best accuracy pass your own training data via "
|
||||
"`calibration_dataset=...`.",
|
||||
flush=True,
|
||||
)
|
||||
ds = load_dataset("HuggingFaceH4/ultrachat_200k", split=f"train_sft[:{num_samples}]")
|
||||
ds = ds.shuffle(seed=42)
|
||||
elif kind == "hfid":
|
||||
ds = load_dataset(value, split=f"train[:{num_samples}]")
|
||||
ds = ds.shuffle(seed=42)
|
||||
elif kind == "disk":
|
||||
ds = load_from_disk(value)
|
||||
if isinstance(ds, DatasetDict):
|
||||
if "train" in ds:
|
||||
ds = ds["train"]
|
||||
elif len(ds) == 1:
|
||||
ds = next(iter(ds.values()))
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Unsloth: disk calibration_dataset is a DatasetDict with multiple splits; "
|
||||
"pass a single split, e.g. calibration_dataset=dataset['train']."
|
||||
)
|
||||
if num_samples and len(ds) > num_samples:
|
||||
ds = ds.shuffle(seed=42).select(range(num_samples))
|
||||
else:
|
||||
raise ValueError(f"Unknown calibration-dataset-kind: {kind}")
|
||||
|
||||
cols = set(ds.column_names)
|
||||
if "input_ids" in cols:
|
||||
return ds
|
||||
if "messages" in cols:
|
||||
def _prep(ex):
|
||||
return {"text": _tok.apply_chat_template(ex["messages"], tokenize=False)}
|
||||
ds = ds.map(_prep)
|
||||
elif "text" not in cols:
|
||||
raise RuntimeError(
|
||||
"Unsloth: calibration_dataset must contain a 'messages', 'text', or 'input_ids' "
|
||||
f"column (got: {sorted(cols)})."
|
||||
)
|
||||
|
||||
def _tokenize(sample):
|
||||
return _tok(
|
||||
sample["text"], padding=False, max_length=max_seq_length,
|
||||
truncation=True, add_special_tokens=False,
|
||||
)
|
||||
return ds.map(_tokenize, remove_columns=ds.column_names)
|
||||
|
||||
|
||||
def _from_pretrained(auto_model, model_path, trust_remote_code):
|
||||
import torch
|
||||
# transformers renamed torch_dtype -> dtype; support both.
|
||||
try:
|
||||
return auto_model.from_pretrained(
|
||||
model_path, device_map="auto", low_cpu_mem_usage=True,
|
||||
trust_remote_code=trust_remote_code, dtype=torch.bfloat16,
|
||||
)
|
||||
except TypeError:
|
||||
return auto_model.from_pretrained(
|
||||
model_path, device_map="auto", low_cpu_mem_usage=True,
|
||||
trust_remote_code=trust_remote_code, torch_dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--model", required=True, help="merged 16bit HF checkpoint dir")
|
||||
ap.add_argument("--scheme", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--needs-calibration", action="store_true")
|
||||
ap.add_argument("--calibration-dataset-kind", default="none", choices=["none", "hfid", "disk"])
|
||||
ap.add_argument("--calibration-dataset", default="")
|
||||
ap.add_argument("--num-calibration-samples", type=int, default=512)
|
||||
ap.add_argument("--max-seq-length", type=int, default=2048)
|
||||
ap.add_argument("--is-vlm", action="store_true")
|
||||
ap.add_argument("--trust-remote-code", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
from llmcompressor import oneshot
|
||||
from llmcompressor.modifiers.quantization import QuantizationModifier
|
||||
|
||||
# Import the VLM auto-class only when needed - some transformers versions lack it, and the
|
||||
# text path must not fail just because that newer class is unavailable.
|
||||
if args.is_vlm:
|
||||
from transformers import AutoProcessor
|
||||
try:
|
||||
from transformers import AutoModelForImageTextToText as _VLMModel
|
||||
except ImportError:
|
||||
try:
|
||||
from transformers import AutoModelForVision2Seq as _VLMModel
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"Unsloth: this transformers version has no VLM auto-model class for "
|
||||
"compressed multimodal export. Please upgrade transformers."
|
||||
) from e
|
||||
auto_model, auto_proc = _VLMModel, AutoProcessor
|
||||
else:
|
||||
auto_model, auto_proc = AutoModelForCausalLM, AutoTokenizer
|
||||
|
||||
model = _from_pretrained(auto_model, args.model, args.trust_remote_code)
|
||||
model.eval()
|
||||
tokenizer = auto_proc.from_pretrained(args.model, trust_remote_code=args.trust_remote_code)
|
||||
|
||||
recipe = QuantizationModifier(targets="Linear", scheme=args.scheme, ignore=["lm_head"])
|
||||
if args.needs_calibration:
|
||||
ds = _build_calibration_dataset(
|
||||
tokenizer, args.calibration_dataset_kind, args.calibration_dataset,
|
||||
args.num_calibration_samples, args.max_seq_length,
|
||||
)
|
||||
# "basic" pipeline runs a normal forward (no AST tracing / sequential splitting).
|
||||
oneshot(
|
||||
model=model, dataset=ds, recipe=recipe,
|
||||
max_seq_length=args.max_seq_length,
|
||||
num_calibration_samples=args.num_calibration_samples,
|
||||
pipeline="basic",
|
||||
)
|
||||
else:
|
||||
oneshot(model=model, recipe=recipe)
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
model.save_pretrained(args.out, save_compressed=True)
|
||||
tokenizer.save_pretrained(args.out)
|
||||
|
||||
cfg_path = os.path.join(args.out, "config.json")
|
||||
cfg = json.load(open(cfg_path)) if os.path.exists(cfg_path) else {}
|
||||
if "quantization_config" not in cfg:
|
||||
print(f"Unsloth: ERROR - no quantization_config written to {cfg_path}", flush=True)
|
||||
sys.exit(2)
|
||||
shards = glob.glob(os.path.join(args.out, "*.safetensors"))
|
||||
qfmt = cfg["quantization_config"].get("format")
|
||||
print(f"[compressed-quantize] OK scheme={args.scheme} format={qfmt} "
|
||||
f"shards={len(shards)} -> {args.out}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
451
unsloth/save.py
451
unsloth/save.py
|
|
@ -149,9 +149,56 @@ def has_curl():
|
|||
CURL_FLAG = "-DLLAMA_CURL=ON" if has_curl() else "-DLLAMA_CURL=OFF"
|
||||
|
||||
|
||||
# FP8/FP4 compressed export via llm-compressor (for vLLM).
|
||||
# save_method alias -> (llm-compressor scheme, needs_calibration, output dir suffix).
|
||||
COMPRESSED_EXPORT_SCHEMES = {
|
||||
"fp8" : ("FP8_DYNAMIC", False, "fp8"),
|
||||
"fp8_dynamic": ("FP8_DYNAMIC", False, "fp8"),
|
||||
"dynamic_fp8": ("FP8_DYNAMIC", False, "fp8"),
|
||||
"w8a8_fp8" : ("FP8_DYNAMIC", False, "fp8"),
|
||||
"mxfp8" : ("MXFP8", False, "mxfp8"),
|
||||
"w8a8_mxfp8" : ("MXFP8", False, "mxfp8"),
|
||||
"mxfp4" : ("MXFP4", False, "mxfp4"),
|
||||
"w4a4_mxfp4" : ("MXFP4", False, "mxfp4"),
|
||||
"nvfp4" : ("NVFP4", True, "nvfp4"),
|
||||
"w4a4_nvfp4" : ("NVFP4", True, "nvfp4"),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_compressed_method(save_method):
|
||||
"""Return (scheme, needs_calibration, suffix) if `save_method` is an FP8/FP4 compressed
|
||||
export, else None (so normal lora / merged_16bit / merged_4bit handling proceeds).
|
||||
|
||||
Near-miss FP8/FP4 names that are not supported raise a precise error instead of silently
|
||||
falling through to the generic "unknown save_method" message.
|
||||
"""
|
||||
if not isinstance(save_method, str):
|
||||
return None
|
||||
key = save_method.lower().strip().replace("-", "_").replace(" ", "_")
|
||||
if key in COMPRESSED_EXPORT_SCHEMES:
|
||||
return COMPRESSED_EXPORT_SCHEMES[key]
|
||||
if any(tag in key for tag in ("fp8", "fp4", "nvfp4", "mxfp")):
|
||||
supported = ", ".join(sorted(COMPRESSED_EXPORT_SCHEMES.keys()))
|
||||
raise RuntimeError(
|
||||
f"Unsloth: save_method='{save_method}' is not a supported compressed export.\n"
|
||||
f"Supported FP8/FP4 export methods: {supported}\n"
|
||||
"(only dynamic FP8, MXFP8, and W4A4 MXFP4/NVFP4 are wired up for now)."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def print_quantization_methods():
|
||||
for key, value in ALLOWED_QUANTS.items():
|
||||
print(f'"{key}" ==> {value}')
|
||||
print("\nCompressed-tensors FP8/FP4 export "
|
||||
"(save_pretrained_merged(..., save_method=...), for vLLM):")
|
||||
seen = set()
|
||||
for key, (scheme, needs_calib, _suffix) in COMPRESSED_EXPORT_SCHEMES.items():
|
||||
if scheme in seen:
|
||||
continue
|
||||
seen.add(scheme)
|
||||
note = "needs calibration data" if needs_calib else "data-free"
|
||||
print(f'"{key}" ==> llm-compressor {scheme} ({note})')
|
||||
|
||||
|
||||
def _quantize_q2_k_l(
|
||||
|
|
@ -1227,6 +1274,72 @@ def install_python_non_blocking(packages = []):
|
|||
return run_installer
|
||||
|
||||
|
||||
def install_llm_compressor():
|
||||
"""Import llm-compressor, installing it on first use for FP8/FP4 export.
|
||||
|
||||
Pins the current torch + transformers so pip does not upgrade them (a plain install pulls
|
||||
transformers>=5 and breaks Unsloth). Returns (oneshot, QuantizationModifier).
|
||||
"""
|
||||
try:
|
||||
from llmcompressor import oneshot
|
||||
from llmcompressor.modifiers.quantization import QuantizationModifier
|
||||
return oneshot, QuantizationModifier
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(
|
||||
"Unsloth: Installing llm-compressor for FP8/FP4 export "
|
||||
"(pinning your torch + transformers so they are not upgraded). "
|
||||
"This can take a few minutes..."
|
||||
)
|
||||
import importlib
|
||||
import tempfile
|
||||
constraints = ""
|
||||
try:
|
||||
import torch as _torch
|
||||
constraints += f"torch=={_torch.__version__.split('+')[0]}\n"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import transformers as _tf
|
||||
constraints += f"transformers=={_tf.__version__}\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cmd = [sys.executable, "-m", "pip", "install", "llmcompressor"]
|
||||
cpath = None
|
||||
if constraints:
|
||||
with tempfile.NamedTemporaryFile("w", suffix = ".txt", delete = False) as f:
|
||||
f.write(constraints)
|
||||
cpath = f.name
|
||||
cmd += ["-c", cpath]
|
||||
try:
|
||||
subprocess.check_call(cmd)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(
|
||||
"Unsloth: Failed to install llm-compressor. Install it manually with:\n"
|
||||
f" {sys.executable} -m pip install llmcompressor\n"
|
||||
"(pin torch and transformers to your current versions to avoid upgrading them).\n"
|
||||
f"Underlying error: {e}"
|
||||
)
|
||||
finally:
|
||||
if cpath is not None:
|
||||
try: os.remove(cpath)
|
||||
except Exception: pass
|
||||
|
||||
importlib.invalidate_caches()
|
||||
try:
|
||||
from llmcompressor import oneshot
|
||||
from llmcompressor.modifiers.quantization import QuantizationModifier
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
"Unsloth: llm-compressor was installed but could not be imported. "
|
||||
"Please restart your Python session and try again.\n"
|
||||
f"Underlying error: {repr(e)}"
|
||||
)
|
||||
return oneshot, QuantizationModifier
|
||||
|
||||
|
||||
def try_execute(commands, force_complete = False):
|
||||
for command in commands:
|
||||
with subprocess.Popen(
|
||||
|
|
@ -1694,6 +1807,9 @@ def unsloth_save_pretrained_merged(
|
|||
temporary_location: str = "_unsloth_temporary_saved_buffers",
|
||||
maximum_memory_usage: float = 0.75,
|
||||
datasets: Optional[List[str]] = None,
|
||||
calibration_dataset = None,
|
||||
num_calibration_samples: int = 512,
|
||||
max_seq_length: int = 2048,
|
||||
):
|
||||
"""
|
||||
Same as .save_pretrained(...) except 4bit weights are auto
|
||||
|
|
@ -1703,6 +1819,7 @@ def unsloth_save_pretrained_merged(
|
|||
1. `16bit`: Merge LoRA into float16 weights. Useful for GGUF / llama.cpp.
|
||||
2. `4bit`: Merge LoRA into int4 weights. Useful for DPO / HF inference.
|
||||
3. `lora`: Save LoRA adapters with no merging. Useful for HF inference.
|
||||
4. FP8 / FP4 compressed export for vLLM: `fp8`, `mxfp4`, `nvfp4`, `mxfp8`.
|
||||
"""
|
||||
if tokenizer is None:
|
||||
logger.warning_once(
|
||||
|
|
@ -1710,9 +1827,34 @@ def unsloth_save_pretrained_merged(
|
|||
"You can do it separately via `tokenizer.save_pretrained(...)`"
|
||||
)
|
||||
|
||||
# FP8 / FP4 compressed-tensors export (llm-compressor) -> handled separately.
|
||||
_compressed = _normalize_compressed_method(save_method)
|
||||
if _compressed is not None:
|
||||
scheme, needs_calibration, suffix = _compressed
|
||||
_unsloth_save_compressed_tensors(
|
||||
model = self,
|
||||
save_directory = save_directory,
|
||||
tokenizer = tokenizer,
|
||||
scheme = scheme,
|
||||
needs_calibration = needs_calibration,
|
||||
suffix = suffix,
|
||||
push_to_hub = push_to_hub,
|
||||
token = token,
|
||||
calibration_dataset = calibration_dataset,
|
||||
num_calibration_samples = num_calibration_samples,
|
||||
max_seq_length = max_seq_length,
|
||||
)
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
arguments = dict(locals())
|
||||
arguments["model"] = self
|
||||
del arguments["self"]
|
||||
del arguments["_compressed"]
|
||||
del arguments["calibration_dataset"]
|
||||
del arguments["num_calibration_samples"]
|
||||
del arguments["max_seq_length"]
|
||||
unsloth_save_model(**arguments)
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
|
|
@ -1722,7 +1864,7 @@ def unsloth_push_to_hub_merged(
|
|||
self,
|
||||
repo_id: str,
|
||||
tokenizer = None,
|
||||
save_method: str = "merged_16bit", # ["lora", "merged_16bit", "merged_4bit"]
|
||||
save_method: str = "merged_16bit", # ["lora", "merged_16bit", "merged_4bit", "fp8", "mxfp4", "nvfp4", "mxfp8"]
|
||||
use_temp_dir: Optional[bool] = None,
|
||||
commit_message: Optional[str] = "Trained with Unsloth",
|
||||
private: Optional[bool] = None,
|
||||
|
|
@ -1736,6 +1878,9 @@ def unsloth_push_to_hub_merged(
|
|||
temporary_location: str = "_unsloth_temporary_saved_buffers",
|
||||
maximum_memory_usage: float = 0.75,
|
||||
datasets: Optional[List[str]] = None,
|
||||
calibration_dataset = None,
|
||||
num_calibration_samples: int = 512,
|
||||
max_seq_length: int = 2048,
|
||||
):
|
||||
"""
|
||||
Same as .push_to_hub(...) except 4bit weights are auto
|
||||
|
|
@ -1745,6 +1890,7 @@ def unsloth_push_to_hub_merged(
|
|||
1. `16bit`: Merge LoRA into float16 weights. Useful for GGUF / llama.cpp.
|
||||
2. `4bit`: Merge LoRA into int4 weights. Useful for DPO / HF inference.
|
||||
3. `lora`: Save LoRA adapters with no merging. Useful for HF inference.
|
||||
4. FP8 / FP4 compressed export for vLLM: `fp8`, `mxfp4`, `nvfp4`, `mxfp8`.
|
||||
"""
|
||||
if tokenizer is None:
|
||||
logger.warning_once(
|
||||
|
|
@ -1752,12 +1898,42 @@ def unsloth_push_to_hub_merged(
|
|||
"You can do it separately via `tokenizer.push_to_hub(...)`"
|
||||
)
|
||||
|
||||
# FP8 / FP4 compressed-tensors export (llm-compressor) -> handled separately.
|
||||
_compressed = _normalize_compressed_method(save_method)
|
||||
if _compressed is not None:
|
||||
scheme, needs_calibration, suffix = _compressed
|
||||
_unsloth_save_compressed_tensors(
|
||||
model = self,
|
||||
save_directory = repo_id,
|
||||
tokenizer = tokenizer,
|
||||
scheme = scheme,
|
||||
needs_calibration = needs_calibration,
|
||||
suffix = suffix,
|
||||
push_to_hub = True,
|
||||
token = token,
|
||||
private = private,
|
||||
commit_message = commit_message,
|
||||
commit_description = commit_description,
|
||||
create_pr = create_pr,
|
||||
revision = revision,
|
||||
calibration_dataset = calibration_dataset,
|
||||
num_calibration_samples = num_calibration_samples,
|
||||
max_seq_length = max_seq_length,
|
||||
)
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
arguments = dict(locals())
|
||||
arguments["model"] = self
|
||||
arguments["save_directory"] = repo_id
|
||||
arguments["push_to_hub"] = True
|
||||
del arguments["self"]
|
||||
del arguments["repo_id"]
|
||||
del arguments["_compressed"]
|
||||
del arguments["calibration_dataset"]
|
||||
del arguments["num_calibration_samples"]
|
||||
del arguments["max_seq_length"]
|
||||
unsloth_save_model(**arguments)
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
|
|
@ -3166,7 +3342,7 @@ def unsloth_generic_save_pretrained_merged(
|
|||
self,
|
||||
save_directory: Union[str, os.PathLike],
|
||||
tokenizer = None,
|
||||
save_method: str = "merged_16bit", # ["lora", "merged_16bit", "merged_4bit"]
|
||||
save_method: str = "merged_16bit", # ["lora", "merged_16bit", "merged_4bit", "fp8", "mxfp4", "nvfp4", "mxfp8"]
|
||||
push_to_hub: bool = False,
|
||||
token: Optional[Union[str, bool]] = None,
|
||||
is_main_process: bool = True,
|
||||
|
|
@ -3180,6 +3356,9 @@ def unsloth_generic_save_pretrained_merged(
|
|||
temporary_location: str = "_unsloth_temporary_saved_buffers",
|
||||
maximum_memory_usage: float = 0.75,
|
||||
datasets: Optional[List[str]] = None,
|
||||
calibration_dataset = None,
|
||||
num_calibration_samples: int = 512,
|
||||
max_seq_length: int = 2048,
|
||||
):
|
||||
"""
|
||||
Same as .push_to_hub(...) except 4bit weights are auto
|
||||
|
|
@ -3189,6 +3368,10 @@ def unsloth_generic_save_pretrained_merged(
|
|||
1. `16bit`: Merge LoRA into float16 weights. Useful for GGUF / llama.cpp.
|
||||
2. `4bit`: Merge LoRA into int4 weights. Useful for DPO / HF inference.
|
||||
3. `lora`: Save LoRA adapters with no merging. Useful for HF inference.
|
||||
4. FP8 / FP4 compressed export for vLLM via llm-compressor:
|
||||
`fp8` (dynamic W8A8), `mxfp4`, `nvfp4` (W4A4), `mxfp8`. The LoRA is merged to 16bit at
|
||||
`save_directory`, then a quantized checkpoint is written to `save_directory + "-<fmt>"`.
|
||||
`nvfp4` needs calibration data (defaults to ultrachat; override with `calibration_dataset`).
|
||||
"""
|
||||
if tokenizer is None:
|
||||
logger.warning_once(
|
||||
|
|
@ -3196,9 +3379,34 @@ def unsloth_generic_save_pretrained_merged(
|
|||
"You can do it separately via `tokenizer.save_pretrained(...)`"
|
||||
)
|
||||
|
||||
# FP8 / FP4 compressed-tensors export (llm-compressor) -> handled separately.
|
||||
_compressed = _normalize_compressed_method(save_method)
|
||||
if _compressed is not None:
|
||||
scheme, needs_calibration, suffix = _compressed
|
||||
_unsloth_save_compressed_tensors(
|
||||
model = self,
|
||||
save_directory = save_directory,
|
||||
tokenizer = tokenizer,
|
||||
scheme = scheme,
|
||||
needs_calibration = needs_calibration,
|
||||
suffix = suffix,
|
||||
push_to_hub = push_to_hub,
|
||||
token = token,
|
||||
calibration_dataset = calibration_dataset,
|
||||
num_calibration_samples = num_calibration_samples,
|
||||
max_seq_length = max_seq_length,
|
||||
)
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
arguments = dict(locals())
|
||||
arguments["model"] = self
|
||||
del arguments["self"]
|
||||
del arguments["_compressed"]
|
||||
del arguments["calibration_dataset"]
|
||||
del arguments["num_calibration_samples"]
|
||||
del arguments["max_seq_length"]
|
||||
unsloth_generic_save(**arguments)
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
|
|
@ -3222,6 +3430,9 @@ def unsloth_generic_push_to_hub_merged(
|
|||
temporary_location: str = "_unsloth_temporary_saved_buffers",
|
||||
maximum_memory_usage: float = 0.75,
|
||||
datasets: Optional[List[str]] = None,
|
||||
calibration_dataset = None,
|
||||
num_calibration_samples: int = 512,
|
||||
max_seq_length: int = 2048,
|
||||
):
|
||||
"""
|
||||
Same as .push_to_hub(...) except 4bit weights are auto
|
||||
|
|
@ -3231,6 +3442,7 @@ def unsloth_generic_push_to_hub_merged(
|
|||
1. `16bit`: Merge LoRA into float16 weights. Useful for GGUF / llama.cpp.
|
||||
2. `4bit`: Merge LoRA into int4 weights. Useful for DPO / HF inference.
|
||||
3. `lora`: Save LoRA adapters with no merging. Useful for HF inference.
|
||||
4. FP8 / FP4 compressed export for vLLM: `fp8`, `mxfp4`, `nvfp4`, `mxfp8`.
|
||||
"""
|
||||
if tokenizer is None:
|
||||
logger.warning_once(
|
||||
|
|
@ -3238,12 +3450,42 @@ def unsloth_generic_push_to_hub_merged(
|
|||
"You can do it separately via `tokenizer.push_to_hub(...)`"
|
||||
)
|
||||
|
||||
# FP8 / FP4 compressed-tensors export (llm-compressor) -> handled separately.
|
||||
_compressed = _normalize_compressed_method(save_method)
|
||||
if _compressed is not None:
|
||||
scheme, needs_calibration, suffix = _compressed
|
||||
_unsloth_save_compressed_tensors(
|
||||
model = self,
|
||||
save_directory = repo_id,
|
||||
tokenizer = tokenizer,
|
||||
scheme = scheme,
|
||||
needs_calibration = needs_calibration,
|
||||
suffix = suffix,
|
||||
push_to_hub = True,
|
||||
token = token,
|
||||
private = private,
|
||||
commit_message = commit_message,
|
||||
commit_description = commit_description,
|
||||
create_pr = create_pr,
|
||||
revision = revision,
|
||||
calibration_dataset = calibration_dataset,
|
||||
num_calibration_samples = num_calibration_samples,
|
||||
max_seq_length = max_seq_length,
|
||||
)
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
arguments = dict(locals())
|
||||
arguments["model"] = self
|
||||
arguments["save_directory"] = repo_id
|
||||
arguments["push_to_hub"] = True
|
||||
del arguments["self"]
|
||||
del arguments["repo_id"]
|
||||
del arguments["_compressed"]
|
||||
del arguments["calibration_dataset"]
|
||||
del arguments["num_calibration_samples"]
|
||||
del arguments["max_seq_length"]
|
||||
unsloth_generic_save(**arguments)
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
|
|
@ -3388,6 +3630,211 @@ def _unsloth_save_torchao_with_given_config(
|
|||
pass
|
||||
|
||||
|
||||
def _scheme_is_available(scheme):
|
||||
"""True if `scheme` is a known preset in the installed compressed_tensors."""
|
||||
try:
|
||||
from compressed_tensors.quantization import quant_scheme as _qs
|
||||
presets = getattr(_qs, "PRESET_SCHEMES", None)
|
||||
if presets is None:
|
||||
return True
|
||||
return scheme in presets
|
||||
except Exception:
|
||||
# If we cannot introspect, let llm-compressor validate the scheme itself.
|
||||
return True
|
||||
|
||||
|
||||
def _print_compressed_hw_note(scheme, out_dir):
|
||||
if scheme == "FP8_DYNAMIC":
|
||||
hw = "NVIDIA GPUs with compute capability >= 8.9 (Ada / Hopper) or newer"
|
||||
else:
|
||||
hw = ("NVIDIA Blackwell (SM100+) for full activation quantization "
|
||||
"(older GPUs fall back to weight-only in vLLM)")
|
||||
print(
|
||||
f"Unsloth: Saved {scheme} compressed checkpoint to '{out_dir}'.\n"
|
||||
f"Unsloth: Load it with vLLM for accelerated inference. Hardware for full speed: {hw}."
|
||||
)
|
||||
|
||||
|
||||
def _unsloth_save_compressed_tensors(
|
||||
model,
|
||||
save_directory: Union[str, os.PathLike],
|
||||
tokenizer,
|
||||
scheme: str,
|
||||
needs_calibration: bool,
|
||||
suffix: str,
|
||||
push_to_hub: bool = False,
|
||||
token: Optional[Union[str, bool]] = None,
|
||||
calibration_dataset = None,
|
||||
num_calibration_samples: int = 512,
|
||||
max_seq_length: int = 2048,
|
||||
**merge_kwargs,
|
||||
):
|
||||
"""Export an FP8/FP4 compressed-tensors checkpoint via llm-compressor.
|
||||
|
||||
Mirrors the torchao PTQ path: LoRA is first merged into the base model at 16bit and
|
||||
written to `save_directory` (which is kept). The merged checkpoint is then quantized with
|
||||
llm-compressor's `QuantizationModifier(scheme)` in a separate process (so Unsloth's
|
||||
transformers monkey-patches do not interfere), and written to the sibling directory
|
||||
`save_directory + "-" + suffix`. The result is intended for vLLM inference.
|
||||
"""
|
||||
if isinstance(tokenizer, (PreTrainedTokenizerBase, ProcessorMixin)):
|
||||
tokenizer = patch_saving_functions(tokenizer)
|
||||
if token is None and push_to_hub:
|
||||
token = get_token()
|
||||
# Accept os.PathLike save directories (string concatenation below needs a str).
|
||||
save_directory = os.fspath(save_directory)
|
||||
|
||||
# 1) Merge LoRA -> 16bit on disk and KEEP it. Must be a local save: merge_and_overwrite_lora
|
||||
# deletes save_directory when push_to_hub=True, which would remove it before reload.
|
||||
is_peft = isinstance(model, PeftModelForCausalLM) or isinstance(model, PeftModel)
|
||||
if is_peft:
|
||||
print(f"Unsloth: Merging LoRA weights to 16bit before {scheme} quantization...")
|
||||
merge_args = dict(merge_kwargs)
|
||||
merge_args.update(dict(
|
||||
model = model,
|
||||
tokenizer = tokenizer,
|
||||
save_directory = save_directory,
|
||||
save_method = "merged_16bit",
|
||||
push_to_hub = False,
|
||||
token = token,
|
||||
))
|
||||
unsloth_generic_save(**merge_args)
|
||||
else:
|
||||
print(f"Unsloth: Saving base model to 16bit before {scheme} quantization...")
|
||||
os.makedirs(save_directory, exist_ok = True)
|
||||
model.save_pretrained(save_directory)
|
||||
if tokenizer is not None:
|
||||
tokenizer.save_pretrained(save_directory)
|
||||
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# 2) Lazily import / install llm-compressor (without breaking torch / transformers) and
|
||||
# gate on scheme availability up-front so the user gets a clear error fast.
|
||||
install_llm_compressor()
|
||||
if not _scheme_is_available(scheme):
|
||||
try:
|
||||
import transformers as _tf
|
||||
tf_ver = _tf.__version__
|
||||
except Exception:
|
||||
tf_ver = "unknown"
|
||||
raise RuntimeError(
|
||||
f"Unsloth: scheme '{scheme}' is not available in your installed "
|
||||
f"compressed-tensors / llm-compressor.\n"
|
||||
f"It requires a newer llm-compressor that needs transformers>=5.9 "
|
||||
f"(you have transformers {tf_ver}).\n"
|
||||
"Use save_method in {fp8, mxfp4, nvfp4}, or upgrade transformers + llm-compressor."
|
||||
)
|
||||
|
||||
# 3) Detect VLM + trust_remote_code from the in-memory model config.
|
||||
is_vlm = False
|
||||
if hasattr(model, "config") and hasattr(model.config, "architectures"):
|
||||
is_vlm = any(
|
||||
x.endswith(("ForConditionalGeneration", "ForVisionText2Text"))
|
||||
for x in (model.config.architectures or [])
|
||||
)
|
||||
is_vlm = is_vlm or hasattr(model.config, "vision_config")
|
||||
if is_vlm:
|
||||
logger.warning(
|
||||
"Unsloth: FP8/FP4 compressed export for vision / multimodal models is experimental; "
|
||||
"vision-tower layers may be affected."
|
||||
)
|
||||
trust_remote_code = bool(getattr(model.config, "auto_map", None)) if hasattr(model, "config") else False
|
||||
|
||||
# 4) Marshal the calibration dataset for the subprocess: None -> ultrachat default; a
|
||||
# str/PathLike is a local save_to_disk dir if it exists else a Hub id; a Dataset -> temp dir.
|
||||
calib_kind, calib_value = "none", ""
|
||||
calib_tmp = None
|
||||
if needs_calibration and calibration_dataset is not None:
|
||||
if isinstance(calibration_dataset, (str, os.PathLike)):
|
||||
calib_value = os.fspath(calibration_dataset)
|
||||
calib_kind = "disk" if os.path.isdir(calib_value) else "hfid"
|
||||
elif hasattr(calibration_dataset, "save_to_disk"):
|
||||
import tempfile
|
||||
parent = os.path.dirname(os.path.abspath(save_directory)) or None
|
||||
calib_tmp = tempfile.mkdtemp(prefix = "unsloth-calib-", dir = parent)
|
||||
shutil.rmtree(calib_tmp, ignore_errors = True) # save_to_disk wants a fresh path
|
||||
calibration_dataset.save_to_disk(calib_tmp)
|
||||
calib_kind, calib_value = "disk", calib_tmp
|
||||
else:
|
||||
raise TypeError(
|
||||
"Unsloth: calibration_dataset must be None, a Hugging Face dataset id, a local "
|
||||
"path saved with Dataset.save_to_disk(...), or a Dataset with save_to_disk()."
|
||||
)
|
||||
elif not needs_calibration and calibration_dataset is not None:
|
||||
logger.warning_once(
|
||||
f"Unsloth: scheme '{scheme}' is data-free; ignoring calibration_dataset."
|
||||
)
|
||||
|
||||
# 5) Quantize in a separate process: importing Unsloth patches transformers attention, which
|
||||
# breaks the forward llm-compressor runs for calibration. Run the converter by file path
|
||||
# (not `-m`) so the subprocess stays unpatched, like GGUF shelling out to llama.cpp.
|
||||
out_dir = save_directory + "-" + suffix
|
||||
runner = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_compressed_quantize.py")
|
||||
cmd = [
|
||||
sys.executable, runner,
|
||||
"--model", str(save_directory),
|
||||
"--scheme", scheme,
|
||||
"--out", out_dir,
|
||||
"--calibration-dataset-kind", calib_kind,
|
||||
"--num-calibration-samples", str(num_calibration_samples),
|
||||
"--max-seq-length", str(max_seq_length),
|
||||
]
|
||||
if needs_calibration: cmd.append("--needs-calibration")
|
||||
if calib_value: cmd += ["--calibration-dataset", calib_value]
|
||||
if is_vlm: cmd.append("--is-vlm")
|
||||
if trust_remote_code: cmd.append("--trust-remote-code")
|
||||
|
||||
print(f"Unsloth: Quantizing the merged model to {scheme} with llm-compressor "
|
||||
"(in a separate process)...")
|
||||
try:
|
||||
subprocess.check_call(cmd)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(
|
||||
f"Unsloth: {scheme} quantization failed (llm-compressor subprocess exit {e.returncode}). "
|
||||
"See the output above for details."
|
||||
)
|
||||
finally:
|
||||
if calib_tmp is not None and os.path.isdir(calib_tmp):
|
||||
try: shutil.rmtree(calib_tmp)
|
||||
except Exception: pass
|
||||
|
||||
# 6) Validate the artifact.
|
||||
cfg_path = os.path.join(out_dir, "config.json")
|
||||
cfg = json.load(open(cfg_path)) if os.path.exists(cfg_path) else {}
|
||||
if "quantization_config" not in cfg:
|
||||
raise RuntimeError(
|
||||
f"Unsloth: {scheme} export failed - no quantization_config written to {cfg_path}"
|
||||
)
|
||||
|
||||
# 7) Optional hub upload of the compressed artifact (not the intermediate 16bit one).
|
||||
if push_to_hub:
|
||||
print(f"Unsloth: Uploading {scheme} checkpoint to '{save_directory}' ...")
|
||||
from huggingface_hub import HfApi
|
||||
api = HfApi(token = token)
|
||||
api.create_repo(
|
||||
repo_id = save_directory, repo_type = "model",
|
||||
private = merge_kwargs.get("private", None), exist_ok = True,
|
||||
)
|
||||
api.upload_folder(
|
||||
folder_path = out_dir, repo_id = save_directory, repo_type = "model",
|
||||
commit_message = merge_kwargs.get("commit_message", None),
|
||||
commit_description = merge_kwargs.get("commit_description", None),
|
||||
create_pr = merge_kwargs.get("create_pr", False),
|
||||
revision = merge_kwargs.get("revision", None),
|
||||
)
|
||||
|
||||
# 8) Inference hardware note.
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
_print_compressed_hw_note(scheme, out_dir)
|
||||
return out_dir
|
||||
|
||||
|
||||
def unsloth_save_pretrained_torchao(
|
||||
self,
|
||||
save_directory: Union[str, os.PathLike],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue