* Auto policies: deferred dense compile, video compile default, step cache and precision auto Image dense loads with speed unset no longer sit at plain off: the load stays bit-identical eager, and the 3rd generation in a session engages the default compile profile plus the cuDNN attention upgrade mid-session (a one-off image never pays the warmup, repeated use amortises it). Video dense loads resolve straight to the default profile since a clip denoise amortises the compile within a single run, and never to max. Video also gains the image backend's tri-state auto policies: unset step cache now decides from the default schedule and re-checks the actual step count per generation, and unset precision (transformer_quant) hands the decision to the hardware ladder instead of staying off. Memory badge reason now says plainly that everything fits when no offload is planned. * Rename Dtype to Precision, add the video Precision control, step cache Auto option The images Advanced panel's Dtype row is now Precision (same control, clearer name), and the video Advanced panel gains the matching Precision select wired to the load route's existing transformer_quant field, gated to full-pipeline loads the way the image control gates to GGUF. Step cache selects on both pages gain an explicit Auto option as the default (the previous Off default silently behaved as auto and never let anyone pin off), and the Speed and Attention tooltips now state the deferred dense compile and the SageAttention black-frame caveat. * Model catalog: canonical diffusion model groups with device-aware routing One canonical name per image/video model, its published artifacts (GGUF, FP8, bnb-4bit, official BF16) as data, and pure routing helpers: suffix-stripped canonical keys (owner-preserving; cross-owner merges only via explicit aliases), group/artifact lookups, a flat back-compat options shim, load-spec resolution replacing the pages' lookup tables, search matching over old ids and format tokens, the GGUF fit ladder extracted from the variant expander, and pickDefaultArtifact/pickDefaultQuant deciding what a bare group click loads (downloaded first, then the best quality that fits 70 percent of VRAM, GGUF as the safe fallback). Checked by npm run catalog:check, following the i18n:check pattern. * Picker: one canonical row per diffusion model with a format second level The Images and Video pickers now render the curated catalog as one row per model in Recommended: clicking loads the best artifact for the device (the routed GGUF quant, a prequant FP8/bnb-4bit that fits, or the official BF16), and a chevron opens the per-format list, with the GGUF row nesting the usual quant expander. Live HF listing rows that belong to a group are deduplicated, search collapses member repos into their group (old ids and format tokens still match), and the On Device sections group cached member repos under the same canonical name with the per-repo rows inside. Curated groups render from the catalog rather than the HF listing, which finally surfaces LTX-2.3 in the video Recommended list (its hub pipeline_tag is image-to-video, so the text-to-video listing always missed it) and exposes the HunyuanVideo 720p repack next to 480p. Backend: /cached-models now tags trusted video-family repos text-to-video instead of blanket text-to-image, and the pickers admit catalog-known non-unsloth repos On Device, so cached Lightricks/Wan/Hunyuan pipelines finally appear in the Video picker. Chat pickers pass no catalog and are unchanged. * Download formats, tab icons, plain-language train tips, 3-loop autoplay The image Download button becomes a menu: PNG saves the original bytes with the embedded recipe, JPEG and WebP re-encode client-side from the fetched blob (JPEG flattened onto white). The video Download button gains MP4 (original, keeps audio), WebM and GIF; the latter two transcode server-side from the stored MP4 via PyAV (VP9 realtime profile for WebM, ~12 fps adaptive palette for GIF) behind a new gallery export route that 501s with a readable message when a codec is missing. Generated clips no longer loop forever: the player replays a clip three times per selection, then pauses with controls up; a new generation or a refresh gets its own three plays. The Create/Train tabs reuse the sidebar's New Chat and Train icons (TestTubeOutlineIcon moved to a shared lib module), and every Train tab helper text is now one plain sentence. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the Create/Train tab icon and label on one line TabsTrigger renders its children inside a plain inline span and the Tailwind preflight gives svg display:block, so the HugeiconsIcon forced the label onto a second line. Wrap icon plus label in their own inline flex row inside each trigger. * Strip -int8 and -nvfp4 prequant suffixes in the model catalog key canonicalKeyFor already lowercases before matching, so -GGUF/-FP8 in any case were covered; -int8 and -nvfp4 were not in the suffix table, so such repos rendered as standalone rows in Recommended and On Device instead of standardizing into their base-name group and routing through pickDefaultArtifact. Added both suffixes plus case-insensitivity and routing assertions to the catalog check. * Standardize non-catalog picker rows to their base model name The curated catalog already collapses its own groups, but hub listing rows and cached repos outside the catalog (ERNIE-Image, FLUX.2-klein, Qwen-Image-Edit-2509, FLUX.2-dev) still rendered raw ids with -GGUF / -FP8 style suffixes in Recommended and On Device. - model-catalog.ts: new stripArtifactSuffixesForDisplay, a case-preserving twin of canonicalKeyFor's stripping that keeps the owner prefix and original casing for display. - pickers.tsx: recommended hub rows and the downloaded GGUF/model rows pass their labels through it when a catalog is present, so only the diffusion pickers change; chat rows keep raw ids. Click targets keep the full repo id, and the format badge still shows the artifact kind. - Catalog check covers the new helper across GGUF/FP8/int8/nvfp4 in both cases plus no-op and suffix-only names. * Offer official BF16/FP8 artifacts per model group and fix gallery label clipping Model picker changes so groups are not limited to unsloth quant repos: - model-catalog.ts: each image group that has an official vendor pipeline now carries its BF16 (official) artifact as the top (highest quality) entry - Tongyi-MAI/Z-Image-Turbo, Qwen/Qwen-Image, Qwen/Qwen-Image-2512, Qwen/Qwen-Image-Edit-2511, black-forest-labs/FLUX.1-dev, FLUX.1-schnell and FLUX.1-Kontext-dev. The LTX-2.3 video group now lists Lightricks' own bf16 and fp8 distilled single-file checkpoints alongside the GGUF. Resident sizes are set from the actual weight totals (FLUX ships a duplicate single-file that from_pretrained ignores, so FLUX bf16 is ~32 GB not 54). The repos that used to be aliases are now real artifacts. - The router already prefers the highest-quality artifact that fits the 0.7 x GPU budget, so a datacenter GPU now defaults to official BF16 while consumer GPUs still route to the fitting quant or GGUF. That is why bnb-4bit was the Z-Image-Turbo default before: it was the only non-GGUF artifact and it was already downloaded. - diffusion.py: allowlist the four official image repos not previously trusted (qwen/qwen-image-2512, qwen/qwen-image-edit-2511, black-forest-labs/flux.1-schnell, flux.1-kontext-dev). All verified as safetensors-only diffusers model_index pipelines. The LTX-2.3 checkpoints are already on the video trust list. - catalog check: BF16-wins-on-datacenter, quant-wins-on-consumer, and the single-file load specs for the LTX-2.3 checkpoints. Also fixes the video gallery thumbnail caption: the leading duration was clipped by the rounded corner and selection border, so the strip now has enough left/bottom padding to clear the curve. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * video gallery: guard export transcode against a stream-less clip _transcode_webm and _transcode_gif indexed src.streams.video[0] before checking the stream list, so a container with no video stream raised a bare IndexError that the broad handlers then re-labeled as a missing libvpx or decoder. Raise an explicit RuntimeError naming the real cause in both the WebM and GIF paths. * Studio: honor explicit attention/format choices, fix distilled-LTX defaults and On Device catalog routing * Remove stray planning notes accidentally committed to the branch * video: add transformerQuant to the load callback deps handleLoad reads transformerQuant but omitted it from the useCallback dep array, so after the user changes only Precision and then selects a model or clicks Reapply, the memoized callback keeps the stale closure and loads the previous precision. The image page's equivalent callback already lists it. * model picker: honor the format filter when routing catalog clicks; add catalog rows to the roving list - routedArtifactFor now scopes a group's artifacts to the active format filter (the same matchesFormatFilter predicate the visibility check uses) before pickDefaultArtifact, so a group shown only because it owns a GGUF no longer routes a click to a large non-GGUF download. Covers both the Recommended and On Device grouped paths. - hubOptionKeys now includes the catalog-group, search-catalog-group, and grouped On Device row keys in exact render order, so arrow/Home/End roving reaches the catalog rows instead of giving them a duplicate missing id and skipping them. * model picker: don't treat a partial base cache as downloaded A partially-cached base repo (a cancelled download that left only some weights) was counted as downloaded, so an On Device click routed to a fresh multi-GB re-download instead of the complete GGUF. The picker's endpoint (/api/models/ cached-models) did not carry a partial flag at all, so a frontend-only guard could not see it. Surface partial from that endpoint by reusing the hub inventory scan's snapshot-partial detector, plumb it through CachedModelRepo (backend + frontend types), and skip partial base repos when building the downloaded set. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * model picker + diffusion: drop partial/unloadable cached rows, skip defer-compile before a LoRA gen - On Device (cached non-GGUF) rows filtered partial-download snapshots back in: sortedCachedModels gated on passesTaskGate + a groupForRepoId key match but, unlike downloadedSet, never checked c.partial, so an incomplete unsloth snapshot showed as a loadable On Device row (click errors or silently re-fetches multi-GB). It also admitted repos that only match the catalog by group KEY (a base / uncurated-quant sibling like Qwen/Qwen-Image-2512) which have no loadable artifact and dead-end at the trust gate. Add !c.partial and gate on artifactForRepoId (what loadSpecFor resolves) instead of groupForRepoId, so a cached row shows only when the backend can load it. - Deferred speed-auto engaged the compile profile on the 3rd generation BEFORE _apply_loras. A compiled transformer rejects LoRA (supports_lora is False) and _apply_loras raises before its unchanged-selection no-op, so once compile engaged every LoRA generation on that load failed permanently. Skip the deferral when a LoRA is requested (compile and LoRA are mutually exclusive) and let it engage on a later LoRA-free generation. * Scope the cached-model partial probe to the listed snapshot dir list_cached_models builds each row from the largest/complete copy across HF cache roots, but _cached_repo_partial probed is_snapshot_partial with no repo_cache_dir, so the scan spanned every root: a stale .incomplete copy in one root would flag a complete copy in another as partial and hide the usable model from the picker (the click then routes to a re-download). Forward the winning snapshot's repo_path so all three partial signals are scoped to that copy, matching the sibling inventory paths (models/dataset cache_inventory, local_inventory). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Do not auto-route to gated repos, prefer complete cached copies, defer compile past attached LoRA, scope group expand keys Four fixes: - pickDefaultArtifact's not-downloaded ladder returned the gated BF16 FLUX.1-dev / Kontext-dev before the open GGUF on a large GPU, so a bare group click routed to a repo the user may lack license/token access to. Add a gated flag and skip gated artifacts in the not-downloaded ladder (an already-downloaded gated artifact is still returned). - list_cached_models picked the largest duplicate cache copy and computed partial only on it, so a larger partial copy shadowed a smaller complete one; since partial rows are dropped from the picker the usable model vanished. Prefer completeness, then size. - the deferred-speed compile engaged on a no-LoRA generation while an adapter from a prior generation was still attached, baking it into the compiled graph (the later unload is swallowed on a compiled pipe); also defer while adapters remain attached. - routeGroupClick's GGUF fallback toggled the context-free canonicalId while the chevron toggles the context-scoped expandKey, leaving the format list un-collapsible in one context, dead in the other, and risking cross-context expansion; thread expandKey through. * Guard video pipeline repos from deletion, drop the always-failing LTX FP8 artifact, prefer 720p Hunyuan Three round-6 fixes: - cached non-GGUF video repos now surface in the Video On-Device picker with the normal delete action, but /delete-cached only guarded chat + the Images engine, so a loaded/loading Wan / LTX / Hunyuan pipeline could have its HF snapshot removed from under it. Add a VideoBackend loading_repo_ids accessor and a video loaded/loading guard mirroring the Images one. - the catalog advertised Lightricks/LTX-2.3-fp8 as loadable, but the LTX-2.3 loader refuses the official scaled-FP8 single file (.weight_scale/.input_scale) and points to GGUF/BF16, so a pick routed to a ~76 GB download that always fails on load. Remove the FP8 artifact. - pickDefaultArtifact only sorts by format, so the HunyuanVideo group's 480p (listed first) beat the 720p even on GPUs where 720p fits the budget. List 720p first so the fit loop prefers it and falls back to 480p only on smaller cards. * diffusion: add compute int8/fp8_dynamic text-encoder quant, wire into video Add two torchao compute text-encoder quant modes to the diffusion precision engine, alongside the existing layerwise fp8 and weight-only nvfp4: - int8: per-token activation + per-channel weight (torch._int_mm), with per-layer keep-bf16 selection. int8 degrades on large encoders unless the most quant-sensitive decoder blocks stay bf16, so it engages only for families with a measured keep-bf16 schedule (qwen-image / qwen-image-edit keep first+last 6, flux.2-dev keeps first 3); a family without one falls back to fp8. - fp8_dynamic: per-row fp8 compute (torch._scaled_mm), keeping the matmul in fp8 on the tensor cores instead of upcasting each forward like the layerwise fp8. The selective int8 caster reuses the committed transformer-quant factory (_make_quant_config / make_filter_fn / exclude_tokens_for_scheme) plus a small structural first/last-N block skip, so it depends only on committed APIs. Wire text-encoder quant into the video backend, which previously loaded the companion encoder (Gemma3 / UMT5 / Qwen2.5-VL) dense bf16 while quantising only the DiT. text_encoder_quant is plumbed through the load request, validation, the load chain, the resolved record, and status, mirroring the image backend; it applies for every load kind (the encoder is dense regardless of how the DiT was sourced). Widen the image and video load request Literals and add the video status field. Tests: int8 family-schedule routing and fp8 fallback, fp8_dynamic routing, hardware gates (int8 sm_80+, fp8_dynamic sm_89+), the structural block selection, the real int8 filter closure (keeps the first blocks plus the vision tower / lm_head / T5 wo dense), and the video route threading and 422 validation. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * text-encoder quant: skip the torchao modes under offload (both backends) quantize_text_encoders applied int8-with-schedule / fp8_dynamic / nvfp4 (all torchao) to the text encoder regardless of the offload policy. An offload placement then moves the quantized encoder with Module.to(), which torchao tensor subclasses reject (aten._has_compatible_shallow_copy_type is unimplemented) -- a hard crash, the same one the DiT path already skips torchao quant under offload to avoid. Add offload_active to quantize_text_encoders and skip the torchao modes when set; layerwise fp8 is not torchao and still streams under offload. Both the video and image loaders pass offload_active = (offload policy != none). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * diffusion: skip non-bf16 linears for scaled_mm quant schemes The fp8 / mxfp8 / nvfp4 schemes run on torch._scaled_mm and the fp4 / mx GEMMs, which assert a bfloat16 input weight. On a mixed-precision DiT that keeps some linears in fp32 for numerical stability (the Wan and Hunyuan video transformers do this), quantize_ hits the first fp32 linear, raises, and the best-effort wrapper swallows it to None, so the whole transformer stays dense with no error and no speedup or memory saving. Add a require_bf16 gate to make_filter_fn and pass it for the scaled_mm schemes in quantize_transformer (and the fp8_dynamic text-encoder caster). The gate skips non-bf16 linears so the scheme engages on the bf16 ones. int8 uses torch._int_mm, which quantizes fp32/fp16 weights fine, so it leaves the gate off and keeps its current coverage. Verified on Wan2.2-TI2V-5B: fp8 and mxfp8 now quantize 303 linears via the committed quantize_transformer path where they previously engaged 0. * prequant builder: mirror the scaled-mm bf16 gate offline The runtime DiT quantizer skips non-bf16 Linears for the scaled_mm schemes (fp8, nvfp4, mxfp8) so the scheme engages on a mixed-precision transformer instead of aborting on the first fp32 Linear. The offline prequant builder reused make_filter_fn without that gate, so building an fp8/nvfp4/mxfp8 checkpoint for a mixed-precision DiT (Wan, Hunyuan keep _keep_in_fp32_modules in fp32 even under torch_dtype=bf16) would hit the same fp32 Linear and abort, breaking the builder's stated offline == runtime, LPIPS-0 invariant. Thread require_bf16 = scheme in _SCALED_MM_SCHEMES through the builder, record it in the checkpoint metadata, and verify it on load (mirrors the existing exclude_name_tokens guard) so a future _SCALED_MM_SCHEMES change cannot silently load a checkpoint built under the old filter. * Keep nvfp4 fp32 linears quantised (bf16 gate is fp8/mxfp8 only) Verified on torchao 0.17 / B200: fp8 per-row asserts 'PerRow quantization only works for bfloat16 precision input weight' and mxfp8 asserts 'Only supporting bf16 out dtype', but NVFP4's high-precision conversion quantises an fp32 weight fine (forward included). So the bf16 skip-gate must be fp8/mxfp8 only, not all scaled_mm schemes -- otherwise nvfp4 leaves large fp32 projections dense, losing the intended memory/speed gain. Rename _SCALED_MM_SCHEMES -> _REQUIRE_BF16_SCHEMES = (fp8, mxfp8) and thread it through the runtime filter, the offline builder, and the loader require_bf16 verification (offline == runtime preserved). * [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>
479 lines
18 KiB
Python
479 lines
18 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
|
|
|
|
"""Hermetic CPU tests for the pre-quantized transformer load path.
|
|
|
|
torch / accelerate are stubbed via ``sys.modules`` (the module under test imports them
|
|
lazily), and ``transformer_cls`` is a fake that records calls -- so the resolver, the
|
|
meta-init + ``load_state_dict(assign=True)`` flow, and the validation/fallback behaviour
|
|
are all exercised without CUDA, torchao, or a real diffusers model.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import sys
|
|
import types
|
|
|
|
import core.inference.diffusion_prequant as pq
|
|
from core.inference.diffusion_families import DiffusionFamily
|
|
from core.inference.diffusion_prequant import (
|
|
PREQUANT_FORMAT,
|
|
PrequantSource,
|
|
load_prequantized_transformer,
|
|
resolve_prequant_source,
|
|
)
|
|
|
|
|
|
# ── resolve_prequant_source ──────────────────────────────────────────────────────
|
|
def _fam(prequant_repos = ()):
|
|
return DiffusionFamily(
|
|
name = "z-image",
|
|
pipeline_class = "ZImagePipeline",
|
|
transformer_class = "ZImageTransformer2DModel",
|
|
base_repo = "Tongyi-MAI/Z-Image-Turbo",
|
|
prequant_repos = prequant_repos,
|
|
)
|
|
|
|
|
|
def test_resolve_path_override_wins():
|
|
fam = _fam(prequant_repos = (("fp8", "org/hosted-fp8"),))
|
|
src = resolve_prequant_source(fam, "fp8", path_override = "/tmp/local.pt")
|
|
assert src == PrequantSource(kind = "path", location = "/tmp/local.pt", filename = None)
|
|
|
|
|
|
def test_resolve_family_repo_by_scheme():
|
|
fam = _fam(prequant_repos = (("fp8", "org/hosted-fp8"), ("int8", "org/hosted-int8")))
|
|
src = resolve_prequant_source(fam, "int8")
|
|
assert src.kind == "repo" and src.location == "org/hosted-int8"
|
|
assert src.filename == "transformer_int8.pt"
|
|
|
|
|
|
def test_resolve_wrong_scheme_is_none():
|
|
fam = _fam(prequant_repos = (("fp8", "org/hosted-fp8"),))
|
|
assert resolve_prequant_source(fam, "int8") is None
|
|
|
|
|
|
def test_resolve_nothing_configured_is_none():
|
|
assert resolve_prequant_source(_fam(), "fp8") is None
|
|
assert resolve_prequant_source(_fam(), "fp8", path_override = "") is None
|
|
|
|
|
|
# ── load_prequantized_transformer ────────────────────────────────────────────────
|
|
class _FakeTransformer:
|
|
calls: dict = {}
|
|
|
|
def __init__(self):
|
|
self.assigned = None
|
|
self.moved = None
|
|
self.eval_called = False
|
|
|
|
@classmethod
|
|
def load_config(cls, base, **kw):
|
|
cls.calls["load_config"] = {"base": base, **kw}
|
|
return {"cfg": True}
|
|
|
|
@classmethod
|
|
def from_config(cls, config):
|
|
cls.calls["from_config"] = config
|
|
return cls()
|
|
|
|
@classmethod
|
|
def from_pretrained(cls, *a, **k): # the dense path -- must never run here
|
|
cls.calls["from_pretrained"] = True
|
|
raise AssertionError("from_pretrained must not be called on the prequant path")
|
|
|
|
def load_state_dict(
|
|
self,
|
|
sd,
|
|
strict = True,
|
|
assign = False,
|
|
):
|
|
_FakeTransformer.calls["load_state_dict"] = {"strict": strict, "assign": assign}
|
|
self.assigned = sd
|
|
|
|
def parameters(self):
|
|
return []
|
|
|
|
def buffers(self):
|
|
return []
|
|
|
|
def to(self, device):
|
|
self.moved = device
|
|
return self
|
|
|
|
def eval(self):
|
|
self.eval_called = True
|
|
return self
|
|
|
|
|
|
def _stub_torch_accelerate(
|
|
monkeypatch,
|
|
ckpt,
|
|
*,
|
|
load_raises = False,
|
|
):
|
|
torch = types.ModuleType("torch")
|
|
|
|
def _load(
|
|
path,
|
|
weights_only = False,
|
|
map_location = None,
|
|
):
|
|
if load_raises:
|
|
raise RuntimeError("corrupt checkpoint")
|
|
return ckpt
|
|
|
|
torch.load = _load
|
|
monkeypatch.setitem(sys.modules, "torch", torch)
|
|
|
|
accelerate = types.ModuleType("accelerate")
|
|
accelerate.init_empty_weights = lambda: contextlib.nullcontext()
|
|
monkeypatch.setitem(sys.modules, "accelerate", accelerate)
|
|
|
|
|
|
def _good_ckpt(scheme = "fp8", base = "Tongyi-MAI/Z-Image-Turbo"):
|
|
meta = {"scheme": scheme, "base_model_id": base}
|
|
# fp8 checkpoints must record per-row granularity or the loader rejects them as stale.
|
|
if scheme == "fp8":
|
|
meta["fp8_granularity"] = "per_row"
|
|
return {
|
|
"format": PREQUANT_FORMAT,
|
|
"metadata": meta,
|
|
"state_dict": {"weight": object()},
|
|
}
|
|
|
|
|
|
def _load(
|
|
monkeypatch,
|
|
tmp_path,
|
|
ckpt,
|
|
*,
|
|
scheme = "fp8",
|
|
load_raises = False,
|
|
exists = True,
|
|
allow_local = True,
|
|
fast_accum = None,
|
|
):
|
|
_FakeTransformer.calls = {}
|
|
_stub_torch_accelerate(monkeypatch, ckpt, load_raises = load_raises)
|
|
# The local-path branch is opt-in via a directory ALLOWLIST (it unpickles an arbitrary
|
|
# file); these tests exercise the load mechanics, so allowlist tmp_path (where ckpt.pt
|
|
# lives) unless a test is checking the gate.
|
|
if allow_local:
|
|
monkeypatch.setenv(pq.ALLOW_LOCAL_PREQUANT_PATH_ENV, str(tmp_path))
|
|
else:
|
|
monkeypatch.delenv(pq.ALLOW_LOCAL_PREQUANT_PATH_ENV, raising = False)
|
|
path = tmp_path / "ckpt.pt"
|
|
if exists:
|
|
path.write_bytes(b"x")
|
|
source = PrequantSource(kind = "path", location = str(path), filename = None)
|
|
return load_prequantized_transformer(
|
|
_FakeTransformer,
|
|
"Tongyi-MAI/Z-Image-Turbo",
|
|
source,
|
|
device = "cuda",
|
|
dtype = "bfloat16",
|
|
hf_token = None,
|
|
scheme = scheme,
|
|
fast_accum = fast_accum,
|
|
logger = None,
|
|
)
|
|
|
|
|
|
def test_load_meta_init_and_assign(monkeypatch, tmp_path):
|
|
t = _load(monkeypatch, tmp_path, _good_ckpt())
|
|
assert t is not None
|
|
# meta-init path was used, not the dense from_pretrained.
|
|
assert "from_config" in _FakeTransformer.calls
|
|
assert "from_pretrained" not in _FakeTransformer.calls
|
|
# assign=True is the whole point (copy into meta is a no-op).
|
|
assert _FakeTransformer.calls["load_state_dict"] == {"strict": True, "assign": True}
|
|
assert t.moved == "cuda"
|
|
assert t._unsloth_runtime_quant == "fp8"
|
|
|
|
|
|
def test_load_puts_transformer_in_eval_mode(monkeypatch, tmp_path):
|
|
# Built via from_config (not from_pretrained), so the loader must eval() it to match
|
|
# the dense/GGUF paths; otherwise train-mode dropout makes inference nondeterministic.
|
|
t = _load(monkeypatch, tmp_path, _good_ckpt())
|
|
assert t is not None
|
|
assert t.eval_called is True
|
|
|
|
|
|
def test_load_missing_file_is_none(monkeypatch, tmp_path):
|
|
assert _load(monkeypatch, tmp_path, _good_ckpt(), exists = False) is None
|
|
|
|
|
|
def test_load_torch_load_raises_is_none(monkeypatch, tmp_path):
|
|
assert _load(monkeypatch, tmp_path, _good_ckpt(), load_raises = True) is None
|
|
|
|
|
|
def test_load_format_mismatch_is_none(monkeypatch, tmp_path):
|
|
bad = _good_ckpt()
|
|
bad["format"] = "something_else"
|
|
assert _load(monkeypatch, tmp_path, bad) is None
|
|
|
|
|
|
def test_load_scheme_mismatch_is_none(monkeypatch, tmp_path):
|
|
# checkpoint built for int8, but fp8 was requested.
|
|
assert _load(monkeypatch, tmp_path, _good_ckpt(scheme = "int8"), scheme = "fp8") is None
|
|
|
|
|
|
def test_load_base_mismatch_is_none(monkeypatch, tmp_path):
|
|
assert _load(monkeypatch, tmp_path, _good_ckpt(base = "other/model")) is None
|
|
|
|
|
|
def test_load_fp8_stale_per_tensor_is_rejected(monkeypatch, tmp_path):
|
|
# A pre-fix fp8 checkpoint has no fp8_granularity (old per-tensor layout); it must be
|
|
# rejected so the loader rebuilds instead of reproducing the noise failure.
|
|
stale = _good_ckpt(scheme = "fp8")
|
|
del stale["metadata"]["fp8_granularity"]
|
|
assert _load(monkeypatch, tmp_path, stale, scheme = "fp8") is None
|
|
# An explicit per-tensor granularity is likewise rejected.
|
|
per_tensor = _good_ckpt(scheme = "fp8")
|
|
per_tensor["metadata"]["fp8_granularity"] = "per_tensor"
|
|
assert _load(monkeypatch, tmp_path, per_tensor, scheme = "fp8") is None
|
|
|
|
|
|
def test_load_int8_ignores_fp8_granularity(monkeypatch, tmp_path):
|
|
# The granularity gate is fp8-only: an int8 checkpoint without it still loads.
|
|
assert _load(monkeypatch, tmp_path, _good_ckpt(scheme = "int8"), scheme = "int8") is not None
|
|
|
|
|
|
def test_load_missing_base_metadata_is_none(monkeypatch, tmp_path):
|
|
# A checkpoint whose keys happen to match a different base can load strict=True and then
|
|
# render from the wrong weights, so a base was requested but none recorded must be refused.
|
|
ckpt = _good_ckpt()
|
|
del ckpt["metadata"]["base_model_id"]
|
|
assert _load(monkeypatch, tmp_path, ckpt) is None
|
|
|
|
|
|
def test_load_fast_accum_mismatch_is_none(monkeypatch, tmp_path):
|
|
# fp8 fast-accum is baked into the saved kernels; an explicit request that contradicts
|
|
# the recorded value must fall to the dense path (which honors it), not silently use it.
|
|
ckpt = _good_ckpt()
|
|
ckpt["metadata"]["fast_accum"] = True
|
|
assert _load(monkeypatch, tmp_path, ckpt, fast_accum = False) is None
|
|
|
|
|
|
def test_load_fast_accum_match_ok(monkeypatch, tmp_path):
|
|
ckpt = _good_ckpt()
|
|
ckpt["metadata"]["fast_accum"] = True
|
|
assert _load(monkeypatch, tmp_path, ckpt, fast_accum = True) is not None
|
|
|
|
|
|
def test_load_fast_accum_auto_ignores_baked(monkeypatch, tmp_path):
|
|
# An auto (None) request must accept whatever the checkpoint baked, on any GPU class.
|
|
ckpt = _good_ckpt()
|
|
ckpt["metadata"]["fast_accum"] = True
|
|
assert _load(monkeypatch, tmp_path, ckpt, fast_accum = None) is not None
|
|
|
|
|
|
def test_load_exclude_tokens_mismatch_is_none(monkeypatch, tmp_path):
|
|
# An int8 checkpoint recording a stale exclusion set (would bake M=1 modulation linears
|
|
# as int8 and crash) must be rejected rather than loaded.
|
|
ckpt = _good_ckpt(scheme = "int8")
|
|
ckpt["metadata"]["exclude_name_tokens"] = ["stale_token"]
|
|
assert _load(monkeypatch, tmp_path, ckpt, scheme = "int8") is None
|
|
|
|
|
|
def test_load_exclude_tokens_match_ok(monkeypatch, tmp_path):
|
|
from core.inference.diffusion_transformer_quant import exclude_tokens_for_scheme
|
|
|
|
ckpt = _good_ckpt(scheme = "int8")
|
|
ckpt["metadata"]["exclude_name_tokens"] = list(exclude_tokens_for_scheme("int8"))
|
|
assert _load(monkeypatch, tmp_path, ckpt, scheme = "int8") is not None
|
|
|
|
|
|
def test_load_require_bf16_mismatch_is_none(monkeypatch, tmp_path):
|
|
# An fp8 (scaled_mm) checkpoint built WITHOUT the bf16 gate quantised a different layer set
|
|
# than the runtime filter now produces, so it must be rejected rather than loaded.
|
|
ckpt = _good_ckpt(scheme = "fp8")
|
|
ckpt["metadata"]["require_bf16"] = False
|
|
assert _load(monkeypatch, tmp_path, ckpt, scheme = "fp8") is None
|
|
|
|
|
|
def test_load_require_bf16_match_ok(monkeypatch, tmp_path):
|
|
ckpt = _good_ckpt(scheme = "fp8")
|
|
ckpt["metadata"]["require_bf16"] = True
|
|
assert _load(monkeypatch, tmp_path, ckpt, scheme = "fp8") is not None
|
|
|
|
|
|
def test_load_require_bf16_int8_true_is_none(monkeypatch, tmp_path):
|
|
# int8 (torch._int_mm) tolerates non-bf16 weights, so it never sets the gate; a checkpoint
|
|
# claiming it did contradicts the runtime filter and must be rejected.
|
|
ckpt = _good_ckpt(scheme = "int8")
|
|
ckpt["metadata"]["require_bf16"] = True
|
|
assert _load(monkeypatch, tmp_path, ckpt, scheme = "int8") is None
|
|
|
|
|
|
def test_load_require_bf16_nvfp4_false_ok(monkeypatch, tmp_path):
|
|
# nvfp4 quantises fp32 weights fine, so the runtime filter does NOT set the bf16 gate; a
|
|
# checkpoint built the same way (require_bf16=False) matches and loads.
|
|
ckpt = _good_ckpt(scheme = "nvfp4")
|
|
ckpt["metadata"]["require_bf16"] = False
|
|
assert _load(monkeypatch, tmp_path, ckpt, scheme = "nvfp4") is not None
|
|
|
|
|
|
def test_load_require_bf16_nvfp4_true_is_none(monkeypatch, tmp_path):
|
|
# An nvfp4 checkpoint claiming the bf16 gate contradicts the runtime filter (nvfp4 is not gated),
|
|
# so it quantised a different layer set and must be rejected.
|
|
ckpt = _good_ckpt(scheme = "nvfp4")
|
|
ckpt["metadata"]["require_bf16"] = True
|
|
assert _load(monkeypatch, tmp_path, ckpt, scheme = "nvfp4") is None
|
|
|
|
|
|
def test_resolve_checkpoint_path_expands_user(monkeypatch, tmp_path):
|
|
# The allowlist gate expands ~, so the existence check must too, or a "~/..." checkpoint
|
|
# that passed the gate is silently skipped.
|
|
import os
|
|
|
|
real = tmp_path / "transformer_fp8.pt"
|
|
real.write_bytes(b"x")
|
|
monkeypatch.setattr(os.path, "expanduser", lambda p: str(real) if p == "~/ckpt.pt" else p)
|
|
source = PrequantSource(kind = "path", location = "~/ckpt.pt", filename = None)
|
|
assert pq._resolve_checkpoint_path(source, None) == str(real)
|
|
|
|
|
|
# ── local-path opt-in gate (RCE guard) ───────────────────────────────────────────
|
|
def test_load_local_path_refused_by_default(monkeypatch, tmp_path):
|
|
# A valid checkpoint at a real file is still refused: torch.load must never run on a
|
|
# request-supplied path without the operator opt-in.
|
|
called = {"load": False}
|
|
|
|
def _explode(*a, **k):
|
|
called["load"] = True
|
|
raise AssertionError("torch.load must not run on a refused local path")
|
|
|
|
torch = types.ModuleType("torch")
|
|
torch.load = _explode
|
|
monkeypatch.setitem(sys.modules, "torch", torch)
|
|
monkeypatch.delenv(pq.ALLOW_LOCAL_PREQUANT_PATH_ENV, raising = False)
|
|
|
|
path = tmp_path / "ckpt.pt"
|
|
path.write_bytes(b"x")
|
|
source = PrequantSource(kind = "path", location = str(path), filename = None)
|
|
result = load_prequantized_transformer(
|
|
_FakeTransformer,
|
|
"Tongyi-MAI/Z-Image-Turbo",
|
|
source,
|
|
device = "cuda",
|
|
dtype = "bfloat16",
|
|
hf_token = None,
|
|
scheme = "fp8",
|
|
logger = None,
|
|
)
|
|
assert result is None
|
|
assert called["load"] is False
|
|
|
|
|
|
def test_load_local_path_allowed_with_optin(monkeypatch, tmp_path):
|
|
assert _load(monkeypatch, tmp_path, _good_ckpt(), allow_local = True) is not None
|
|
|
|
|
|
def test_load_repo_source_allowed_without_optin(monkeypatch, tmp_path):
|
|
# The hosted-repo branch is first-party and trusted: it loads with no opt-in env set.
|
|
_FakeTransformer.calls = {}
|
|
_stub_torch_accelerate(monkeypatch, _good_ckpt())
|
|
monkeypatch.delenv(pq.ALLOW_LOCAL_PREQUANT_PATH_ENV, raising = False)
|
|
|
|
downloaded = tmp_path / "transformer_fp8.pt"
|
|
downloaded.write_bytes(b"x")
|
|
hub = types.ModuleType("huggingface_hub")
|
|
hub.hf_hub_download = lambda repo_id, filename, token = None: str(downloaded)
|
|
monkeypatch.setitem(sys.modules, "huggingface_hub", hub)
|
|
|
|
source = PrequantSource(kind = "repo", location = "org/hosted-fp8", filename = "transformer_fp8.pt")
|
|
result = load_prequantized_transformer(
|
|
_FakeTransformer,
|
|
"Tongyi-MAI/Z-Image-Turbo",
|
|
source,
|
|
device = "cuda",
|
|
dtype = "bfloat16",
|
|
hf_token = None,
|
|
scheme = "fp8",
|
|
logger = None,
|
|
)
|
|
assert result is not None
|
|
|
|
|
|
def test_load_local_path_outside_allowlist_refused(monkeypatch, tmp_path):
|
|
# Even with the opt-in set, a path OUTSIDE every allowlisted directory must not be
|
|
# unpickled: enabling one trusted dir is not a wildcard for arbitrary request paths.
|
|
called = {"load": False}
|
|
|
|
def _explode(*a, **k):
|
|
called["load"] = True
|
|
raise AssertionError("torch.load must not run on a path outside the allowlist")
|
|
|
|
torch = types.ModuleType("torch")
|
|
torch.load = _explode
|
|
monkeypatch.setitem(sys.modules, "torch", torch)
|
|
|
|
allowed = tmp_path / "allowed"
|
|
allowed.mkdir()
|
|
monkeypatch.setenv(pq.ALLOW_LOCAL_PREQUANT_PATH_ENV, str(allowed))
|
|
|
|
outside = tmp_path / "evil.pt" # a real file, but outside the allowlisted dir
|
|
outside.write_bytes(b"x")
|
|
source = PrequantSource(kind = "path", location = str(outside), filename = None)
|
|
result = load_prequantized_transformer(
|
|
_FakeTransformer,
|
|
"Tongyi-MAI/Z-Image-Turbo",
|
|
source,
|
|
device = "cuda",
|
|
dtype = "bfloat16",
|
|
hf_token = None,
|
|
scheme = "fp8",
|
|
logger = None,
|
|
)
|
|
assert result is None
|
|
assert called["load"] is False
|
|
|
|
|
|
def test_load_min_features_mismatch_is_none(monkeypatch, tmp_path):
|
|
# A checkpoint built with a different --min-features quantises a different Linear set,
|
|
# so it must be rejected when the runtime threshold is supplied.
|
|
ckpt = _good_ckpt()
|
|
ckpt["metadata"]["min_features"] = 256 # built with 256, runtime asks for 512
|
|
_FakeTransformer.calls = {}
|
|
_stub_torch_accelerate(monkeypatch, ckpt)
|
|
monkeypatch.setenv(pq.ALLOW_LOCAL_PREQUANT_PATH_ENV, str(tmp_path))
|
|
path = tmp_path / "ckpt.pt"
|
|
path.write_bytes(b"x")
|
|
source = PrequantSource(kind = "path", location = str(path), filename = None)
|
|
result = load_prequantized_transformer(
|
|
_FakeTransformer,
|
|
"Tongyi-MAI/Z-Image-Turbo",
|
|
source,
|
|
device = "cuda",
|
|
dtype = "bfloat16",
|
|
hf_token = None,
|
|
scheme = "fp8",
|
|
min_features = 512,
|
|
logger = None,
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_load_base_fork_tail_matches(monkeypatch, tmp_path):
|
|
# A local path / fork id with the same final segment as the canonical base is accepted.
|
|
ckpt = _good_ckpt(base = "Tongyi-MAI/Z-Image-Turbo")
|
|
_FakeTransformer.calls = {}
|
|
_stub_torch_accelerate(monkeypatch, ckpt)
|
|
monkeypatch.setenv(pq.ALLOW_LOCAL_PREQUANT_PATH_ENV, str(tmp_path))
|
|
path = tmp_path / "ckpt.pt"
|
|
path.write_bytes(b"x")
|
|
source = PrequantSource(kind = "path", location = str(path), filename = None)
|
|
result = load_prequantized_transformer(
|
|
_FakeTransformer,
|
|
"/local/models/Z-Image-Turbo", # different prefix, same tail
|
|
source,
|
|
device = "cuda",
|
|
dtype = "bfloat16",
|
|
hf_token = None,
|
|
scheme = "fp8",
|
|
logger = None,
|
|
)
|
|
assert result is not None
|