* Studio: expose full compressed-tensors scheme set in an export formats dropdown * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: multi-select export formats, portable torchao FP8/INT8, GGUF LoRA, source parity Export page overhaul on top of the formats dropdown: - Unify merged precision into one sorted multi-select list (16-bit first, then 8-bit, then 4-bit). Drop "vLLM" from labels, add INT8 (W8A8), INT8 (W8A16), INT4 (W4A16), MXFP4, MXFP8. Quick formats render as toggle pills; the rest live in a multi-select "More formats" dropdown, so several formats export in one run. - Add a portable torchao FP8/INT8 save path (Float8WeightOnlyConfig / Int8WeightOnlyConfig) that needs no NVIDIA GPU to produce and loads in vLLM. FP8 serializes to safetensors, INT8 to .bin. Wired into save_pretrained_merged and push_to_hub_merged via a TORCHAO_EXPORT_SCHEMES registry and _unsloth_save_torchao, parallel to the compressed-tensors path. - Hide NVIDIA-only compressed-tensors formats when no NVIDIA GPU is present; keep 16-bit and portable FP8/INT8. The backend also rejects a compressed request on non-NVIDIA hardware so it stays authoritative. - Relax merged export to non-PEFT models so Local Model and Hugging Face sources get the same 16-bit / compressed / portable options. - GGUF: send the whole quant list in one call (merge once, quantize many). - LoRA: add a GGUF adapter option (convert_lora_to_gguf.py) with an outtype select (f16/bf16/f32/q8_0/auto), alongside the safetensors adapter. - Thread the new fields through models, routes, orchestrator, and worker; extend the export tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: gate export by accelerator with a torch-aware reason; fix export save dir naming Export runs through Unsloth, which requires a compute accelerator (NVIDIA/AMD/Intel GPU or Apple MLX) and has no CPU code path, so a bare-CPU host cannot export even with PyTorch installed. Add export_capability() in utils/hardware that reports export_supported plus a precise reason so the UI stops showing a generic "no GPU": - pytorch_not_installed: a --no-torch install (even a physical GPU is unusable) - no_accelerator: PyTorch present but no supported accelerator (bare CPU) - mlx_unavailable: Apple Silicon where the MLX stack is missing or too old Expose the fields on /api/system/hardware and /api/system, and guard the mutating export routes (load-checkpoint, export/merged|base|gguf|lora) with HTTP 400 and the reason, leaving read-only endpoints usable so the Export page still renders. Make core/export/export.py import without PyTorch and without a usable accelerator (the Unsloth import is caught) so the export worker degrades to a clear message instead of crashing at import. Frontend: keep /export reachable on chat-only hosts and gray out the method and format options with the backend reason (Alert plus disabled MethodPicker) instead of silently redirecting to /chat, so users see why export is unavailable. Also fix the export save directory producing "model/null" for Local Model and Hugging Face sources that have no run/checkpoint, naming the folder from the model id. * CI: validate Studio export capability gating on Linux, Windows and macOS Add a small pytest matrix that runs studio/backend/tests/test_export_capability.py on ubuntu-latest, windows-latest and macos-latest. It confirms, on each real OS, that hardware.export_capability() reports the right decision and reason (pytorch_not_installed, no_accelerator, or mlx_unavailable) and that the export backend imports without PyTorch and degrades to a clear message instead of crashing. Hosted runners have no GPU/MLX, so this covers the "export unavailable, here is why" path a Mac/Windows user without an accelerator sees; a real accelerator export is validated separately. The job installs only a CPU PyTorch plus the backend import deps (no unsloth, triton, or llama.cpp), so it runs in seconds with no GPU. * Studio export: address Codex review (source-aware gating, GGUF LoRA token/MLX/guard) Frontend (export-page): - Gate LoRA and quantized-model restrictions on the active source. isAdapter / isQuantized come from the selected checkpoint; in Local Model / Hugging Face ("model") source mode they were stale, so LoRA stayed wrongly enabled for a direct base model (backend then rejects "No adapter to export") and a stale "quantized" flag disabled every method for an unrelated, exportable model. Add effectiveIsAdapter / effectiveIsQuantized (false outside checkpoint mode) and use them in the method-reset effect and the MethodPicker disabled state. - Hide the GGUF LoRA option on a macOS/MLX host (the backend rejects GGUF LoRA on MLX), so users no longer pick it, wait through the load, and always fail. Disable the "GGUF adapter" button on a Mac host and never send loraGguf there. Backend (core/export/export.py): - Pass the HF token into the GGUF LoRA conversion (save_pretrained_gguf), so a gated/private base model's config fetch in convert_lora_to_gguf.py is authenticated; without it the load can succeed but the conversion fails. - Guard the save_pretrained_gguf capability check with getattr so an older Unsloth model that lacks the method returns the clean "not supported" message instead of an AttributeError that surfaces as a generic 500. * Studio export: address 2nd Codex review (CI index, empty merged, test import) - studio-export-capability-ci.yml: add --extra-index-url https://pypi.org/simple to the torch install so torch's transitive deps still resolve; --index-url alone replaces PyPI with only the CPU wheel index, which does not serve all of them. - export-page handleStart: reject an empty merged selection (mirrors canExport), so clicking the panel's Start button with every precision pill deselected no longer submits mergedSelections: [] and launches an unintended default 16-bit export. - test_export_imatrix_compressed: the torchao-registry test now reads unsloth/save.py as text (like the other ast/string checks) instead of `import unsloth.save`, which raised ModuleNotFoundError in the CPU studio-backend suite that has no unsloth installed. * Studio export: make comments succinct across the export changes * Studio export: use load token for local GGUF LoRA export of gated bases * Studio export: harden portable torchao path and gate multi-format Hub push torchao (_unsloth_save_torchao): - merge to an isolated temp staging dir so a co-selected 16-bit output at save_directory is not deleted - narrow VLM detection to vision_config / ForVisionText2Text so T5/BART/Whisper are not misrouted - forward trust_remote_code (from auto_map) to the reload so custom-code models export Export UI: - hide portable torchao formats on macOS/MLX (backend rejects quantized export there) - restrict a Hub merged export to a single format (each writes to the repo root) * Studio export: torchao tokenizer remote-code + XPU offload, scale GGUF timeout torchao (_unsloth_save_torchao): - honor auto_map in the staged tokenizer/processor configs (not just model.config) when deriving trust_remote_code, so custom-code tokenizers reload after the merge - offload single-device XPU models to CPU (and empty the XPU cache) before the reload, matching the CUDA path, so an Intel GPU that fits the model once does not OOM on the second copy Export orchestrator: - scale the GGUF wait timeout by the number of requested quants so a multi-quant list export of a large model does not time out at a flat 3600s * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio export: show portable torchao formats only on non-NVIDIA (CPU) hosts Portable torchao FP8/INT8 is the fallback for hosts without the NVIDIA compressed-tensors path. On an NVIDIA GPU the compressed-tensors FP8/FP4/INT formats are the intended path (llm-compressor auto-installs), so hide the portable duplicates there; keep them on CPU / non-NVIDIA hosts and continue hiding them on macOS/MLX. * Studio export: report all output folders and the exported formats - Multi-format merged export now collects every sibling output directory (one per selected precision) instead of only the last; the success banner lists them all. - Show the selected precision formats in the run summary (a Formats row, like GGUF Quantizations), so the panel says what is being exported rather than just 'Merged Model'. - Persist the selected formats in the run summary and seed them on mount, so navigating away and back (or toggling the export method) restores the selection instead of resetting to 16-bit. * Studio export: list all output formats, add GGUF LoRA target, default Q8_0, auto-select newest checkpoint - Progress/summary panel now shows a Formats row with the selected merged formats, and the success banner lists every output folder a multi-format merged run creates (one line per format) instead of only the last one. - Merged format selection is seeded from the active run, so navigating away and back (or switching method cards) no longer resets it to 16-bit. - GGUF / Llama.cpp now offers an Export target toggle (Full model or LoRA adapter) for adapter checkpoints, reusing the LoRA GGUF export path. - Removed the Auto GGUF LoRA output type and defaulted to Q8_0 in the UI, the request model, and the backend defaults; the outtype list is now Q8_0/F16/BF16/F32. Core save.py still accepts auto for external callers. - When a finetune has no checkpoint selected, auto-select the newest one. * Studio torchao export: robust reload class + optional VLM import Two fixes to the portable torchao FP8/INT8 export reload, from review of the narrowed VLM detection: - Encoder-decoder seq2seq checkpoints (T5/BART/Whisper) are not causal LMs. With the narrowed is_vlm test they now correctly skip the image-text class, but fell through to AutoModelForCausalLM and failed to reload after the merge. Reload them with their own architecture class from the config instead. - AutoModelForImageTextToText was imported unconditionally at the top of the torchao path, so on Transformers builds without that class the import aborted every torchao export (even text-only). Import it lazily only for a VLM, with the AutoModelForVision2Seq fallback used elsewhere in Unsloth. * Studio: enable FP8/FP4 compressed export for newer-transformers models The shipped llm-compressor 0.10.x pins transformers<=4.57.6, so FP8/FP4 export failed for models needing a transformers 5.x sidecar (Qwen3.5, Gemma-4, Qwen3-Next): the quantization subprocess crashed importing the removed TORCH_INIT_FUNCTIONS. Run the quantization against a dedicated llm-compressor-main "shadow": a --target package dir (transformers 5.10.2 + llm-compressor main + compressed-tensors) layered over the existing torch. It installs --no-deps so torch is never touched (works on any Studio torch build), is provisioned lazily and fingerprint-cached, and can be turned off with UNSLOTH_DISABLE_LLMCOMPRESSOR_MAIN. - transformers_version.py: provision + validate .venv_llmcompressor. - export.py: route all compressed exports through the shadow when available; else keep the workspace 0.10.x path and fail fast past its transformers ceiling. - save.py: launch _compressed_quantize.py with a clean PYTHONPATH = shadow. - _compressed_quantize.py: skip linear_attn / vision tower / MTP modules (matches the RedHatAI and NVIDIA reference quants, and is required by the grouped schemes). Verified all four schemes (fp8, w8a8, w4a16, mxfp4) on Qwen3.5-9B and Llama-3.2-1B, and fp8 on Gemma-4, end to end through Studio. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix GGUF LoRA export tests * Fix export CI expectations * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com> Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
246 lines
9.9 KiB
Python
246 lines
9.9 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
|
|
|
|
"""Tests for the GGUF imatrix option and compressed-tensors merged export wiring.
|
|
|
|
Schema checks use the real Pydantic models; the cross-layer threading is verified with ast so it
|
|
runs on CPU with no GPU, no model, and no llama.cpp.
|
|
"""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from models.export import ExportGGUFRequest, ExportMergedModelRequest
|
|
|
|
_BACKEND = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def _src(rel):
|
|
return (_BACKEND / rel).read_text(encoding = "utf-8")
|
|
|
|
|
|
def _func_src(rel, name):
|
|
src = _src(rel)
|
|
node = next(
|
|
n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.FunctionDef) and n.name == name
|
|
)
|
|
return ast.get_source_segment(src, node)
|
|
|
|
|
|
# -- schema -------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_gguf_request_imatrix_defaults_and_set():
|
|
assert ExportGGUFRequest(save_directory = "/tmp/x").imatrix is False
|
|
assert ExportGGUFRequest(save_directory = "/tmp/x").imatrix_path is None
|
|
r = ExportGGUFRequest(save_directory = "/tmp/x", imatrix = True, imatrix_path = "/i.dat")
|
|
assert r.imatrix is True and r.imatrix_path == "/i.dat"
|
|
|
|
|
|
def test_merged_request_accepts_compressed_formats():
|
|
for fmt in ("16-bit (FP16)", "FP8 (compressed-tensors)", "NVFP4 (compressed-tensors)"):
|
|
assert ExportMergedModelRequest(save_directory = "/tmp/x", format_type = fmt).format_type == fmt
|
|
|
|
|
|
def test_merged_request_rejects_unknown_format():
|
|
with pytest.raises(ValidationError):
|
|
ExportMergedModelRequest(save_directory = "/tmp/x", format_type = "bogus")
|
|
|
|
|
|
# -- threading (ast) ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_export_gguf_threads_imatrix_to_save_and_push():
|
|
# imatrix_file must reach both save paths, but only via the conditional **imatrix_kw.
|
|
g = _func_src("core/export/export.py", "export_gguf")
|
|
assert g.count("**imatrix_kw") >= 2
|
|
assert 'imatrix_kw = {"imatrix_file": imatrix_file} if imatrix_file is not None else {}' in g
|
|
# Unconditional pass-through (the old wiring) must be gone.
|
|
assert "imatrix_file = imatrix_file" not in g
|
|
|
|
|
|
def test_export_gguf_guards_unsupported_imatrix_build():
|
|
# An older unsloth without imatrix_file support gets a clean error, not a TypeError.
|
|
g = _func_src("core/export/export.py", "export_gguf")
|
|
assert "_supports_kwarg(" in g and '"imatrix_file"' in g
|
|
|
|
|
|
def test_export_merged_guards_unsupported_compressed_build():
|
|
m = _func_src("core/export/export.py", "export_merged_model")
|
|
assert "_compressed_export_supported()" in m
|
|
|
|
|
|
def test_supports_kwarg_helper():
|
|
# exec just the helper source so the test stays free of export.py's heavy import chain.
|
|
ns = {}
|
|
exec(_func_src("core/export/export.py", "_supports_kwarg"), ns)
|
|
supports = ns["_supports_kwarg"]
|
|
|
|
def has_it(a, imatrix_file = None):
|
|
pass
|
|
|
|
def lacks_it(a):
|
|
pass
|
|
|
|
def via_kwargs(a, **kw):
|
|
pass
|
|
|
|
assert supports(has_it, "imatrix_file") is True
|
|
assert supports(lacks_it, "imatrix_file") is False
|
|
assert supports(via_kwargs, "imatrix_file") is True
|
|
|
|
|
|
def test_orchestrator_and_worker_pass_imatrix():
|
|
assert "imatrix_file" in _func_src("core/export/orchestrator.py", "export_gguf")
|
|
assert 'imatrix_file = cmd.get("imatrix_file")' in _src("core/export/worker.py")
|
|
|
|
|
|
def test_route_resolves_imatrix_file():
|
|
assert "request.imatrix_path or (True if request.imatrix else None)" in _src("routes/export.py")
|
|
|
|
|
|
def test_export_merged_maps_compressed_to_save_method():
|
|
m = _func_src("core/export/export.py", "export_merged_model")
|
|
assert "is_compressed" in m and '"fp8"' in m and '"nvfp4"' in m
|
|
|
|
|
|
def test_compressed_hub_push_uploads_local_dir_without_recompressing():
|
|
# A compressed / torchao Hub push must upload the built output_path, not re-quantize.
|
|
m = _func_src("core/export/export.py", "export_merged_model")
|
|
assert "elif (is_compressed or is_torchao) and output_path and Path(output_path).is_dir():" in m
|
|
assert "hf_api.upload_folder(" in m and "folder_path = output_path" in m
|
|
|
|
|
|
# -- torchao portable FP8/INT8 (device-agnostic, no NVIDIA GPU) ---------------------------------
|
|
|
|
|
|
def test_merged_request_accepts_torchao_aliases():
|
|
# Portable torchao aliases pass through compressed_method (validated in the backend registry).
|
|
for alias in ("torchao_fp8", "torchao_int8"):
|
|
r = ExportMergedModelRequest(save_directory = "/tmp/x", compressed_method = alias)
|
|
assert r.compressed_method == alias
|
|
|
|
|
|
def test_export_merged_routes_torchao_and_skips_nvidia_guard():
|
|
m = _func_src("core/export/export.py", "export_merged_model")
|
|
# torchao is classified separately and its suffix comes from the torchao normalizer.
|
|
assert "_normalize_torchao_method(compressed_alias)" in m
|
|
assert "is_torchao = torchao_info is not None" in m
|
|
assert "is_compressed = compressed_alias is not None and not is_torchao" in m
|
|
# The NVIDIA guard applies to compressed-tensors only, not torchao.
|
|
assert "_has_nvidia_gpu()" in m
|
|
# torchao routes through save_method just like compressed.
|
|
assert "elif is_compressed or is_torchao:" in m
|
|
|
|
|
|
def test_export_merged_nvidia_guard_present():
|
|
m = _func_src("core/export/export.py", "export_merged_model")
|
|
assert "requires an NVIDIA GPU" in m
|
|
|
|
|
|
def test_has_nvidia_gpu_helper_reads_hardware_module():
|
|
h = _func_src("core/export/export.py", "_has_nvidia_gpu")
|
|
assert "DeviceType.CUDA" in h and "IS_ROCM" in h
|
|
|
|
|
|
def test_export_merged_relaxes_is_peft_guard():
|
|
# Non-PEFT (Local/HF base) models can now export merged; the old hard block must be gone.
|
|
m = _func_src("core/export/export.py", "export_merged_model")
|
|
assert "Use 'Export Base Model' instead." not in m
|
|
|
|
|
|
def test_unsloth_save_has_torchao_registry_and_path():
|
|
# Read unsloth/save.py as text (not import) so this runs in the CPU suite without unsloth.
|
|
save_py = (_BACKEND.parent.parent / "unsloth" / "save.py").read_text(encoding = "utf-8")
|
|
assert "def _normalize_torchao_method" in save_py
|
|
assert "def _unsloth_save_torchao" in save_py
|
|
assert "TORCHAO_EXPORT_SCHEMES = {" in save_py
|
|
# torchao aliases must map to (scheme, suffix) so the backend routes to the torchao path.
|
|
assert '"torchao_fp8": ("fp8", "torchao-fp8")' in save_py
|
|
assert '"torchao_int8": ("int8", "torchao-int8")' in save_py
|
|
|
|
|
|
# -- GGUF multi-quant list ----------------------------------------------------------------------
|
|
|
|
|
|
def test_gguf_request_accepts_list_of_quants():
|
|
r = ExportGGUFRequest(save_directory = "/tmp/x", quantization_method = ["Q4_K_M", "Q8_0"])
|
|
assert r.quantization_method == ["Q4_K_M", "Q8_0"]
|
|
r2 = ExportGGUFRequest(save_directory = "/tmp/x", quantization_method = "Q4_K_M")
|
|
assert r2.quantization_method == "Q4_K_M"
|
|
|
|
|
|
def test_export_gguf_normalizes_quant_list():
|
|
g = _func_src("core/export/export.py", "export_gguf")
|
|
assert "isinstance(quantization_method, (list, tuple))" in g
|
|
assert "quant_methods" in g
|
|
|
|
|
|
# -- GGUF LoRA adapter export -------------------------------------------------------------------
|
|
|
|
|
|
def test_lora_request_has_gguf_fields():
|
|
from models.export import ExportLoRAAdapterRequest
|
|
|
|
r = ExportLoRAAdapterRequest(save_directory = "/tmp/x")
|
|
assert r.gguf is False and r.gguf_outtype == "q8_0"
|
|
r2 = ExportLoRAAdapterRequest(save_directory = "/tmp/x", gguf = True, gguf_outtype = "q8_0")
|
|
assert r2.gguf is True and r2.gguf_outtype == "q8_0"
|
|
|
|
|
|
def test_lora_request_rejects_bad_outtype():
|
|
from models.export import ExportLoRAAdapterRequest
|
|
with pytest.raises(ValidationError):
|
|
ExportLoRAAdapterRequest(save_directory = "/tmp/x", gguf_outtype = "q3_k")
|
|
|
|
|
|
def test_export_lora_wires_gguf_save_method():
|
|
la = _func_src("core/export/export.py", "export_lora_adapter")
|
|
assert 'save_method = "lora"' in la
|
|
assert "quantization_method = outtype" in la
|
|
|
|
|
|
def test_orchestrator_and_worker_pass_lora_gguf():
|
|
o = _func_src("core/export/orchestrator.py", "export_lora_adapter")
|
|
assert '"gguf": gguf' in o and '"gguf_outtype": gguf_outtype' in o
|
|
w = _src("core/export/worker.py")
|
|
assert 'gguf = cmd.get("gguf", False)' in w
|
|
assert 'gguf_outtype = cmd.get("gguf_outtype", "q8_0")' in w
|
|
|
|
|
|
def test_route_passes_lora_gguf():
|
|
r = _src("routes/export.py")
|
|
assert "gguf = request.gguf" in r and "gguf_outtype = request.gguf_outtype" in r
|
|
|
|
|
|
# -- compressed_method ("all formats" dropdown) -------------------------------------------------
|
|
|
|
|
|
def test_merged_request_accepts_compressed_method():
|
|
# Defaults to None; any scheme alias is accepted (validation happens in the backend registry).
|
|
assert ExportMergedModelRequest(save_directory = "/tmp/x").compressed_method is None
|
|
for alias in ("fp8", "fp8_static", "w8a8", "w8a16", "w4a16", "mxfp4", "mxfp8", "nvfp4"):
|
|
r = ExportMergedModelRequest(save_directory = "/tmp/x", compressed_method = alias)
|
|
assert r.compressed_method == alias
|
|
|
|
|
|
def test_export_merged_resolves_alias_via_registry():
|
|
# The scheme + suffix must come from unsloth.save's registry normalizer, not a hardcoded dict.
|
|
m = _func_src("core/export/export.py", "export_merged_model")
|
|
assert "compressed_method" in m
|
|
assert "_normalize_compressed_method(compressed_alias)" in m
|
|
assert "compressed_alias = compressed_method or _LABEL_TO_ALIAS.get(format_type)" in m
|
|
assert "compressed_suffix" in m and 'f"{save_directory}-{compressed_suffix}"' in m
|
|
|
|
|
|
def test_orchestrator_and_worker_pass_compressed_method():
|
|
o = _func_src("core/export/orchestrator.py", "export_merged_model")
|
|
assert "compressed_method" in o and '"compressed_method": compressed_method' in o
|
|
assert 'compressed_method = cmd.get("compressed_method")' in _src("core/export/worker.py")
|
|
|
|
|
|
def test_route_passes_compressed_method():
|
|
assert "compressed_method = request.compressed_method" in _src("routes/export.py")
|