* 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>
388 lines
18 KiB
Python
388 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
|
|
|
|
"""Load a *pre-quantized* transformer instead of quantising a dense one on the GPU.
|
|
|
|
The opt-in fast transformer_quant path (see ``diffusion_transformer_quant.py``) loads
|
|
the dense bf16 transformer and torchao-``quantize_``s it in place. That materialises the
|
|
full bf16 weights on the GPU before quantising, so the load peak is ~2x the GGUF's and it
|
|
pulls the full bf16 download. When a transformer has already been quantised once and saved
|
|
(``scripts/build_prequant_checkpoint.py``), this module loads those weights directly:
|
|
|
|
1. build the transformer skeleton on the ``meta`` device (no storage) via
|
|
``accelerate.init_empty_weights`` + ``from_config``;
|
|
2. ``load_state_dict(assign=True)`` the quantized state dict (the torchao weight subclass
|
|
tensors are assigned in, not copied), so the dense bf16 never touches the GPU;
|
|
3. move to the device.
|
|
|
|
Measured (B200, Z-Image fp8): transformer GPU load peak 12.9 -> 6.3 GB, download 12 ->
|
|
6.28 GB, output bit-identical (LPIPS 0.0). The checkpoint carries the exact same scheme +
|
|
``min_features`` as the runtime path, so the result is identical to quantising on the fly.
|
|
|
|
Best-effort and lazily imported throughout: a missing / mismatched / unreadable checkpoint
|
|
returns None and the caller falls back to the dense-quantise path (and then to GGUF). All
|
|
behaviour is gated on a configured source -- with nothing configured this module is inert.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Optional
|
|
|
|
# torch.save dict layout this module reads (and the build script writes). Bumped if the
|
|
# on-disk structure changes so an old/foreign artifact is rejected rather than mis-loaded.
|
|
PREQUANT_FORMAT = "unsloth_prequant_transformer_state_dict_v1"
|
|
|
|
# Loading a checkpoint ends in ``torch.load(weights_only=False)``, which executes arbitrary
|
|
# code embedded in the pickle. A hosted family *repo* checkpoint is first-party and trusted,
|
|
# but a ``source.kind == "path"`` can originate from the ``transformer_prequant_path`` field
|
|
# of a load request -- i.e. an authenticated API caller naming an arbitrary local file.
|
|
# Unpickling that is remote code execution, so a request-supplied path is unpickled ONLY when
|
|
# it resolves inside an operator-configured ALLOWLIST of directories. A bare on/off toggle is
|
|
# deliberately NOT accepted as a wildcard: enabling local checkpoints for one trusted
|
|
# directory must never also permit unpickling any other path a request happens to name. The
|
|
# trusted hosted-repo path is unaffected.
|
|
ALLOW_LOCAL_PREQUANT_PATH_ENV = "UNSLOTH_ALLOW_LOCAL_PREQUANT_PATH"
|
|
|
|
_PREQUANT_TOGGLE_TOKENS = {"1", "true", "yes", "on", "0", "false", "no", "off"}
|
|
|
|
|
|
def _allowed_prequant_roots() -> list:
|
|
"""Operator-allowlisted directories whose pre-quant checkpoints may be unpickled.
|
|
|
|
Set ``UNSLOTH_ALLOW_LOCAL_PREQUANT_PATH`` to one or more directories (separated by
|
|
``os.pathsep``). A bare truthy/falsey toggle is ignored on purpose -- it must name a
|
|
directory, so there is no "allow everything" mode."""
|
|
import os
|
|
|
|
raw = (os.environ.get(ALLOW_LOCAL_PREQUANT_PATH_ENV) or "").strip()
|
|
if not raw:
|
|
return []
|
|
roots = []
|
|
for part in raw.split(os.pathsep):
|
|
part = part.strip()
|
|
if not part or part.lower() in _PREQUANT_TOGGLE_TOKENS:
|
|
continue # a bare on/off value is not a directory -> never a wildcard allow
|
|
try:
|
|
roots.append(os.path.realpath(os.path.expanduser(part)))
|
|
except Exception: # noqa: BLE001 — a bad entry is simply not allowlisted
|
|
continue
|
|
return roots
|
|
|
|
|
|
def _local_prequant_path_allowed(path: str) -> bool:
|
|
"""True only when ``path`` resolves inside an operator-allowlisted directory; an
|
|
arbitrary request-supplied path is never unpickled. ``realpath`` first so a symlink
|
|
cannot point an allowlisted name at a file outside the allowed roots."""
|
|
import os
|
|
|
|
roots = _allowed_prequant_roots()
|
|
if not roots:
|
|
return False
|
|
try:
|
|
real = os.path.realpath(os.path.expanduser(path))
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
return any(real == r or real.startswith(r + os.sep) for r in roots)
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class PrequantSource:
|
|
"""Where a pre-quantized transformer checkpoint lives. ``kind`` is "path" (a local
|
|
file) or "repo" (a Hub repo id in ``location`` + ``filename`` inside it)."""
|
|
|
|
kind: str
|
|
location: str
|
|
filename: Optional[str] = None
|
|
|
|
|
|
def prequant_filename(scheme: str) -> str:
|
|
"""The conventional checkpoint filename for ``scheme`` inside a Hub repo."""
|
|
return f"transformer_{scheme}.pt"
|
|
|
|
|
|
def resolve_prequant_source(
|
|
fam: Any,
|
|
scheme: str,
|
|
*,
|
|
path_override: Optional[str] = None,
|
|
) -> Optional[PrequantSource]:
|
|
"""Resolve where the pre-quantized checkpoint for ``(fam, scheme)`` should come from.
|
|
|
|
Priority: (1) an explicit local ``path_override`` (testing / power users); (2) the
|
|
family's hosted repo for ``scheme``; (3) None -> no pre-quant, caller quantises dense.
|
|
Pure: no IO, no torch -- it only decides the source, the loader fetches it.
|
|
"""
|
|
override = (path_override or "").strip()
|
|
if override:
|
|
return PrequantSource(kind = "path", location = override, filename = None)
|
|
try:
|
|
from .diffusion_families import family_prequant_repo
|
|
repo_id = family_prequant_repo(fam, scheme)
|
|
except Exception: # noqa: BLE001 — a bad family object must not break the load
|
|
repo_id = None
|
|
if repo_id:
|
|
return PrequantSource(kind = "repo", location = repo_id, filename = prequant_filename(scheme))
|
|
return None
|
|
|
|
|
|
def load_prequantized_transformer(
|
|
transformer_cls: Any,
|
|
base: str,
|
|
source: PrequantSource,
|
|
*,
|
|
device: str,
|
|
dtype: Any,
|
|
hf_token: Optional[str] = None,
|
|
scheme: str,
|
|
min_features: Optional[int] = None,
|
|
fast_accum: Optional[bool] = None,
|
|
logger: Any = None,
|
|
) -> Optional[Any]:
|
|
"""Load the pre-quantized transformer described by ``source`` onto ``device``.
|
|
|
|
Returns the placed, already-quantized transformer, or None on any problem (missing /
|
|
mismatched / unreadable checkpoint, or a meta-init the class does not support) so the
|
|
caller falls back to the dense-quantise path. Best-effort: never raises for an
|
|
ordinary unavailable artifact.
|
|
"""
|
|
try:
|
|
# weights_only=False (required below) executes pickle code, so a caller-supplied
|
|
# local path is unpickled ONLY when it resolves inside an operator-allowlisted
|
|
# directory. The hosted family repo is first-party and always allowed.
|
|
if source.kind == "path" and not _local_prequant_path_allowed(source.location):
|
|
_warn(
|
|
logger,
|
|
f"{scheme}:path",
|
|
RuntimeError(
|
|
"request-supplied local pre-quant path refused (unpickling an arbitrary "
|
|
f"file is unsafe); set {ALLOW_LOCAL_PREQUANT_PATH_ENV} to an allowlisted "
|
|
"directory containing trusted checkpoints to permit it",
|
|
),
|
|
)
|
|
return None
|
|
|
|
path = _resolve_checkpoint_path(source, hf_token)
|
|
if path is None:
|
|
return None
|
|
|
|
import torch
|
|
|
|
# torchao weight subclasses are not safetensors-serializable, so the checkpoint is
|
|
# a torch.save pickle. weights_only=False is required to rebuild those subclasses.
|
|
# The local-path branch is gated above; the repo branch is a first-party artifact.
|
|
ckpt = torch.load(path, weights_only = False, map_location = "cpu")
|
|
if not _validate_checkpoint(
|
|
ckpt, scheme, base, logger, min_features = min_features, fast_accum = fast_accum
|
|
):
|
|
return None
|
|
state_dict = ckpt["state_dict"]
|
|
|
|
config = transformer_cls.load_config(base, subfolder = "transformer", token = hf_token)
|
|
from accelerate import init_empty_weights
|
|
|
|
with init_empty_weights():
|
|
transformer = transformer_cls.from_config(config)
|
|
# assign=True swaps in the loaded (quantized) tensors rather than copying into the
|
|
# meta tensors (a copy into meta is a no-op); strict=True since the saved state
|
|
# dict is the full state dict of the same class (non-persistent buffers excluded).
|
|
transformer.load_state_dict(state_dict, strict = True, assign = True)
|
|
if _has_meta_tensors(transformer):
|
|
# A class with non-persistent buffers (computed in __init__, absent from the
|
|
# state dict) leaves those on meta. Rebuild on CPU so the buffers hold their
|
|
# real values, then re-assign the quantized weights. The dense bf16 lives in
|
|
# CPU RAM only -- the GPU still receives just the quantized footprint.
|
|
transformer = transformer_cls.from_config(config)
|
|
transformer.load_state_dict(state_dict, strict = True, assign = True)
|
|
|
|
transformer = transformer.to(device)
|
|
# Built via from_config (not from_pretrained), so it starts in TRAIN mode; the
|
|
# dense and GGUF paths load through from_pretrained, which diffusers documents as
|
|
# returning an eval()'d module. Match that here so any train/eval-sensitive layer
|
|
# (e.g. dropout) can't make prequant inference nondeterministic or diverge from
|
|
# the other load paths.
|
|
try:
|
|
transformer.eval()
|
|
except Exception: # noqa: BLE001 — eval() is best-effort
|
|
pass
|
|
try: # diagnostic marker, mirrors the runtime-quant path
|
|
transformer._unsloth_runtime_quant = scheme
|
|
except Exception: # noqa: BLE001 — marker is best-effort
|
|
pass
|
|
if logger is not None:
|
|
logger.info(
|
|
"diffusion.prequant: loaded %s checkpoint (%s) onto %s",
|
|
scheme,
|
|
source.kind,
|
|
device,
|
|
)
|
|
return transformer
|
|
except Exception as exc: # noqa: BLE001 — fall back to the dense-quantise path
|
|
_warn(logger, f"{scheme}:{source.kind}", exc)
|
|
return None
|
|
|
|
|
|
def _resolve_checkpoint_path(source: PrequantSource, hf_token: Optional[str]) -> Optional[str]:
|
|
"""The local file path for ``source``, downloading from the Hub if needed; None if absent."""
|
|
if source.kind == "path":
|
|
import os
|
|
|
|
# Expand ~ once: the allowlist gate (_local_prequant_path_allowed) already
|
|
# expands it, so a "~/..." path that passed the gate must be expanded here too
|
|
# or os.path.isfile() sees the literal "~" and silently skips a real checkpoint.
|
|
expanded = os.path.expanduser(source.location)
|
|
return expanded if os.path.isfile(expanded) else None
|
|
if source.kind == "repo":
|
|
from huggingface_hub import hf_hub_download
|
|
return hf_hub_download(repo_id = source.location, filename = source.filename, token = hf_token)
|
|
return None
|
|
|
|
|
|
def _validate_checkpoint(
|
|
ckpt: Any,
|
|
scheme: str,
|
|
base: str,
|
|
logger: Any,
|
|
min_features: Optional[int] = None,
|
|
fast_accum: Optional[bool] = None,
|
|
) -> bool:
|
|
"""Reject a checkpoint that is the wrong format / scheme / base model / filter.
|
|
|
|
``min_features`` (when given) is the runtime Linear-feature threshold: a checkpoint
|
|
built with a different ``--min-features`` quantises a different set of Linear layers,
|
|
so ``load_state_dict(assign=True)`` would silently install a model that does not match
|
|
what the dense path produces while status still reports the requested scheme. Reject it.
|
|
|
|
``fast_accum`` (fp8 only) is the runtime accumulate choice: when the caller forces it
|
|
explicitly (not None) and the checkpoint recorded a different baked value, the loaded
|
|
fp8 kernels would ignore the request while status still reports fp8, so reject and let
|
|
the dense path honor it. A checkpoint that predates the metadata (field absent) is
|
|
accepted unchanged for backward compatibility."""
|
|
if not isinstance(ckpt, dict) or ckpt.get("format") != PREQUANT_FORMAT:
|
|
_warn(logger, scheme, ValueError("unrecognised pre-quant checkpoint format"))
|
|
return False
|
|
if "state_dict" not in ckpt:
|
|
_warn(logger, scheme, ValueError("pre-quant checkpoint has no state_dict"))
|
|
return False
|
|
meta = ckpt.get("metadata") or {}
|
|
if meta.get("scheme") != scheme:
|
|
_warn(logger, scheme, ValueError(f"checkpoint scheme {meta.get('scheme')!r} != {scheme!r}"))
|
|
return False
|
|
# fp8 REQUIRES per-row granularity (per-tensor collapses outlier-heavy DiTs to noise). A
|
|
# checkpoint built before that fix carries the old per-tensor layout and either omits
|
|
# ``fp8_granularity`` or records something other than per-row; reject it so the loader
|
|
# falls back to rebuilding/re-quantising instead of installing a broken fp8 transformer.
|
|
from .diffusion_transformer_quant import FP8_GRANULARITY, TQ_FP8
|
|
|
|
if scheme == TQ_FP8 and meta.get("fp8_granularity") != FP8_GRANULARITY:
|
|
_warn(
|
|
logger,
|
|
scheme,
|
|
ValueError(
|
|
f"fp8 checkpoint granularity {meta.get('fp8_granularity')!r} != "
|
|
f"{FP8_GRANULARITY!r} (stale per-tensor artifact); rebuild it"
|
|
),
|
|
)
|
|
return False
|
|
ckpt_base = meta.get("base_model_id")
|
|
if base:
|
|
# A checkpoint whose keys happen to match a different base can load strict=True and
|
|
# then generate from the wrong weights while status reports the requested scheme.
|
|
# Our builder always records base_model_id, so a checkpoint that omits it against a
|
|
# requested base is untrustworthy -- refuse rather than silently accept it.
|
|
if not ckpt_base:
|
|
_warn(
|
|
logger,
|
|
scheme,
|
|
ValueError(
|
|
f"checkpoint metadata missing base_model_id; refusing for base {base!r}"
|
|
),
|
|
)
|
|
return False
|
|
if not _same_base_model(ckpt_base, base):
|
|
_warn(logger, scheme, ValueError(f"checkpoint base {ckpt_base!r} != {base!r}"))
|
|
return False
|
|
if min_features is not None:
|
|
ckpt_min = meta.get("min_features")
|
|
if ckpt_min is not None and int(ckpt_min) != int(min_features):
|
|
_warn(
|
|
logger,
|
|
scheme,
|
|
ValueError(f"checkpoint min_features {ckpt_min!r} != runtime {min_features!r}"),
|
|
)
|
|
return False
|
|
# The int8 exclusion set (M=1 modulation / conditioning-embedder projections) is derived
|
|
# from the scheme, but a future change to that token list would leave older checkpoints
|
|
# with a stale baked set that still passes scheme+min_features and then crashes at the
|
|
# first denoise step. When the checkpoint records the set, reject a mismatch; absent
|
|
# (older artifact) is accepted since scheme+min_features already pin today's filter.
|
|
ckpt_excludes = meta.get("exclude_name_tokens")
|
|
if ckpt_excludes is not None:
|
|
from .diffusion_transformer_quant import exclude_tokens_for_scheme
|
|
expected = tuple(exclude_tokens_for_scheme(scheme))
|
|
if tuple(ckpt_excludes) != expected:
|
|
_warn(
|
|
logger,
|
|
scheme,
|
|
ValueError(
|
|
f"checkpoint exclude_name_tokens {tuple(ckpt_excludes)!r} != {expected!r}"
|
|
),
|
|
)
|
|
return False
|
|
# require_bf16 (skip non-bf16 Linears) is the bf16-weight gate, pinned by the scheme (fp8 and
|
|
# mxfp8 assert a bf16 weight; nvfp4 / int8 quantise fp32 fine). Like exclude_name_tokens it is
|
|
# pinned by the scheme today, but recording and verifying it guards against a future
|
|
# _REQUIRE_BF16_SCHEMES change silently loading a checkpoint built under the old filter (it would
|
|
# carry a different quantised layer set). Absent (older artifact) is accepted since scheme already
|
|
# pins today's gate.
|
|
ckpt_require_bf16 = meta.get("require_bf16")
|
|
if ckpt_require_bf16 is not None:
|
|
from .diffusion_transformer_quant import _REQUIRE_BF16_SCHEMES
|
|
expected_require_bf16 = scheme in _REQUIRE_BF16_SCHEMES
|
|
if bool(ckpt_require_bf16) != expected_require_bf16:
|
|
_warn(
|
|
logger,
|
|
scheme,
|
|
ValueError(
|
|
f"checkpoint require_bf16 {bool(ckpt_require_bf16)!r} != {expected_require_bf16!r}"
|
|
),
|
|
)
|
|
return False
|
|
# fp8 fast-accum is baked into the saved kernels; only enforce when the caller forces it.
|
|
if fast_accum is not None:
|
|
ckpt_fa = meta.get("fast_accum")
|
|
if ckpt_fa is not None and bool(ckpt_fa) != bool(fast_accum):
|
|
_warn(
|
|
logger,
|
|
scheme,
|
|
ValueError(f"checkpoint fast_accum {ckpt_fa!r} != requested {bool(fast_accum)!r}"),
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _same_base_model(a: str, b: str) -> bool:
|
|
"""Tolerant compare of two base-model ids: an exact match, or the same final
|
|
path/repo segment (so a local path or a fork id matches the canonical repo, e.g.
|
|
``/models/Z-Image-Turbo`` vs ``Tongyi-MAI/Z-Image-Turbo``)."""
|
|
|
|
def _tail(x: str) -> str:
|
|
return x.replace("\\", "/").rstrip("/").split("/")[-1].lower()
|
|
|
|
return a == b or _tail(a) == _tail(b)
|
|
|
|
|
|
def _has_meta_tensors(module: Any) -> bool:
|
|
"""True if any parameter or buffer is still on the meta device after loading."""
|
|
from itertools import chain
|
|
try:
|
|
return any(
|
|
getattr(t, "is_meta", False) for t in chain(module.parameters(), module.buffers())
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
|
|
|
|
def _warn(logger: Any, what: str, exc: Exception) -> None:
|
|
if logger is not None:
|
|
logger.warning("diffusion.prequant: %s failed: %s", what, exc)
|