mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 00:33:49 +00:00
* Studio: report the precision that actually ran, and refuse one that cannot
The loader already knew the truth and threw it away at the API boundary. Status
reported the ENGAGED transformer / text-encoder precision, but nothing echoed
back what the caller ASKED for, so once a fallback happened the request was
gone: the Advanced panel kept its dropdown on FP8 while a Q4_K_M GGUF ran, the
"Auto: X" badge was suppressed for exactly the case that needed it
(source !== "auto" rendered nothing), and a successfully generated image or
saved clip carried no evidence of the precision behind it.
Backend
- DiffusionResolvedControl gains `requested` (the raw ask, null when left to the
backend) and `status` ("applied" | "fell_back" | "unsupported") beside the
existing value/source/reason. Both default, so an older payload still parses.
- build_resolved_record keeps the request beside the engaged value and derives a
mismatch for the controls that answer in the vocabulary they are asked in.
memory_mode and attention_backend do not, so they are classified by the call
site instead of compared blindly.
- Every transformer decline site now records WHY, in the caller's terms: an
uncached hosted prequant, a re-plan that still needs offload, a dense-fit
miss, a failed quant build, an unsupported scheme, and the wrong load kind.
- quantize_text_encoders returns a TEQuantOutcome (mode + reason + status). The
int8 -> fp8 downgrade, the offload skip and the unsupported-device path were
all bare `return None`; the last one had no log line at all.
- Explicit precision fails closed. Host-level impossibilities are refused in
begin_load, so /images/load and /video/load answer 409 before anything is
evicted; footprint-dependent declines raise inside the load and surface on
load-progress. `auto` still falls back silently, and
UNSLOTH_DIFFUSION_ALLOW_PRECISION_FALLBACK=1 restores the old behaviour.
- Saved output metadata: images add text_encoder_quant / memory_mode /
offload_policy; video clips gain the whole build block images already had.
All read from the engaged state, none added to the required-key sets, so
older PNGs and sidecars still list.
Frontend
- resolved-precision.ts holds the badge/select decisions as pure functions. A
declined request now renders "FP8 -> OFF" in a warning tone with the reason in
the tooltip, instead of nothing.
- The Advanced selects reseed from the loaded build, so a declined scheme stops
advertising itself, and a "Loaded build" summary reports the transformer and
text-encoder precision plus the memory mode and resolved offload behaviour.
- A 409 refusal is surfaced as a titled, actionable toast; transformer_quant is
no longer sent for load kinds that cannot use it.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Raise the precision refusal before the GPU handoff, not inside it
The 409 exists to preserve two things: the chat model holding the GPU,
and the several GB the load would otherwise pull down before failing.
The check was made in begin_load, which is too late for both.
begin_load runs inside acquire_for, and acquire_for evicts the current
owner under the arbiter lock BEFORE it runs the register callback. On
the image path it also runs after select_and_activate_engine, which
unloads the resident model on an engine switch. So an impossible
explicit precision was refused having already destroyed exactly what
the refusal was meant to protect: the user got a 409 and an empty GPU.
Both checks are now made by the route, alongside the sibling refusals
that already run there (the unloadable pick, the gated companion), and
before the device is taken. The copy in begin_load stays, since it is
the load path's own invariant and other callers reach it directly.
Diffusers only, on the image path. The native sd.cpp engine accepts
transformer_quant / text_encoder_quant for interface parity and ignores
them, so gating that path on a torchao capability would refuse loads
that work today. A probe failure leaves pending_name None and skips the
route check, which is the pre-existing behaviour rather than a new one.
`auto` is never refused, so a caller that left the precision to the
backend cannot reach any of this.
* Stop the Advanced reseed firing on generation-time record rewrites
Two separate ways the resolved record was read too literally.
The reseed effect keyed on JSON.stringify(resolved). That record is not
load-time-only: the backend rewrites entries of it during GENERATION.
speed_mode and attention_backend change when the deferred compile
profile engages on the 3rd image, and transformer_cache changes
whenever the step-cache threshold flips. Each of those moved the key
with no load behind it, so the effect re-ran and overwrote a Precision
the user had picked but not yet loaded. An edit made after a load is
meant to survive until the next LOAD replaces it. resolvedSeedKey
covers only the three controls the effect actually writes, and for
attention only the request side, since that is the field the reseed
reads for an auto or honored request and the one a rewrite leaves
alone. A real reload still re-fires: it always moves a request or an
engaged value on one of the three.
isResolvedHonored treated every status that was not "applied" as a
decline. `status` is typed wider than the backend's union on purpose,
so a newer backend can add a value, but that reading threw the
tolerance away: an unknown status painted a red "FP8 -> FP8" over a
request that was honored, and on memory_mode (asked "low_vram",
answered "sequential") a "LOW_VRAM -> SEQUENTIAL" that never happened.
Only the two statuses that mean a decline are now read as one. Staying
quiet is the safe direction, since the build that adds a status ships
the frontend that understands it.
* Name the fault behind a refused precision, and stop caching an OOM as one
Two things the fail-closed 409 made load-bearing that were fine while a
declined explicit scheme fell back quietly.
select_transformer_quant_scheme answers None for three different faults
and the refusal reported all of them as "'fp8' is not usable for family
'X' on this GPU". Measured here on a B200: torchao could not import at
all (cannot import name 'ScalingType' from torch.nn.functional, a
torch/torchao version skew), the smoke probe swallowed that, and every
explicit scheme was refused with a message blaming Blackwell hardware
that runs all of them. A skew is fixed by a pip install; a GPU limit is
not; a family the accuracy gate rules out is neither. explain_unusable_
scheme separates the three, shared by the image and video resolvers so
they cannot drift.
The smoke probe also cached an out-of-memory as a verdict on the scheme.
That probe now runs on the ROUTE thread, which is the point of raising
the refusal before the GPU handoff, so it meets a full GPU by design:
the resident chat model has not been evicted yet. One transient OOM
therefore refused that scheme for the rest of the process, on a host
that runs it fine seconds later. Allocator failures are no longer
remembered; every other failure still is, because those really are
properties of the build. torch.OutOfMemoryError subclasses RuntimeError
rather than MemoryError and has moved between torch and torch.cuda, so
both names are tried with the message as the backstop.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Correct a comment the reseed-key change left behind
The dependency is no longer the serialized record; it is the load-time
projection of it, which is the whole point of the previous commit.
* Close the three places an explicit precision was still reported wrong
The native engine was exempt from the precision gate, on the grounds that refusing there would
break loads that work today. But the loads it works for are exactly the silent mismatch this
change exists to remove: sd.cpp accepts transformer_quant and text_encoder_quant for interface
parity, ignores them, and reports null, so an explicit FP8 succeeded having quantised nothing.
The diffusers path already refuses on the same CPU-only host, so the exemption also left the two
engines disagreeing about one request. It now refuses, with a message naming the engine; auto,
none and an omitted value still pass through untouched, and the existing escape hatch waives it.
The Loaded build panel labelled any non-GGUF load BF16, but a single-file safetensors keeps
whatever precision it was saved in and FP8 checkpoints are explicitly supported, so the one
panel whose job is to say what actually loaded was asserting a wrong number. It reads "As in
checkpoint" for single_file now, on the video page as well.
And the video loader rewrites an omitted transformer_quant to "off" under speed_mode="off"
before building the resolved record, so the record claimed the user had pinned bf16: the Auto
badge disappeared and the Precision select reseeded to none, leaving quantisation pinned off
after a Speed change and reload. The raw request is captured before the rewrite and reported.
Four tests, each confirmed against a mutation.
* Report the truth on partial casts, native builds and unprovable probes
- text-encoder quant that cast one encoder and not its sibling reported
"applied" and both loaders let the load through, recording the requested
mode while conditioning ran off a mixture; it is now reported as a
fallback and refused like any other declined explicit precision.
- the refusal message pointed users at "Choose Auto" for text_encoder_quant,
which both request models reject, so following it returned a 422.
- the native sd.cpp engine reports dtype "gguf" and no model_kind, so the
Loaded build panel labelled every native checkpoint BF16; the label rule
is now one shared helper covering both pages.
- native generation results carried no offload state, so every native image
recipe persisted it as null.
- the video load route probed CUDA precision before the training guard, which
allocated next to a training subprocess for a load about to be refused.
- a smoke-probe OOM before the arbiter eviction is not a verdict on the
scheme, but it reached the new route gate as one and refused the load the
eviction was about to make room for.
- the torchao import error is interpolated into the 409 detail and names the
absolute file that raised it; paths are stripped there and logged in full.
* Do not read a native null text-encoder quant as BF16
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Refuse an unhonourable precision before the download plan is staged
The load routes refuse an explicit precision this host cannot honour, but the UI
plans and stages first, so the refusal arrived after the GGUF and its companions
(tens of GB on the video side) had already been pulled. Both checks are
network-free, so /images/download-plan and /video/download-plan now make them
before building the plan, and map the refusal to the same 409 the load routes
give.
Also fixes three Loaded-build panel rows that were only correct for diffusers:
the dense dtype label no longer calls a float16/float32 load BF16, the attention
row names the native sd.cpp engine instead of Native SDPA, and the Memory row
renders when an offload is active but no memory mode is set.
* Keep the plan-time precision check off the GPU, and off the load's blind spot
The plan runs before the load's training guard, so an uncached scheme sent
assert_precision_available into its quantise-and-matmul smoke probe and
initialised CUDA in the Studio process beside a running trainer. Staging needs no
GPU, so the check is skipped while training is active on both the image and video
plan routes; the load still refuses the same pick afterwards.
The video page also asked for its plan without the selected precision while
sending it on the load, so the plan cleared a scheme the load would reject and
staged the pipeline first. It now sends it under the same pipeline-only rule.
A single_file transformer is no longer labelled 'As in checkpoint':
from_single_file is handed the resolved torch_dtype, so an fp8 checkpoint is
upcast on load and the panel was hiding the dtype it actually runs in.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Keep two video route tests off the host's precision support
Both assert that a text_encoder_quant reaches the backend, and both now run
through a precision gate whose answer depends on the machine: a GPU-less runner
refuses fp8 with a 409 and the forwarding under test never happens. They run
under the product's own fallback escape hatch instead.
* Gate diffusion precision on the engine and mode that actually run
Three places reported or refused a precision that was not the one the
runtime would use.
The load route asked predict_engine which gate to apply, and a probe
failure left pending_name None, skipping both arms. Selection could then
land on sd.cpp anyway, which accepts the knobs and ignores them, so an
explicit fp8 loaded, quantised nothing and reported null. The gate is now
re-asked of the engine that was actually activated, and only when the
prediction missed, so a correct one is never paid twice.
quantize_text_encoders rewrites an int8 request to layerwise fp8 on any
family with no keep-bf16 schedule, and that path needs no torchao. Both
precision asserts consulted te_quant_supported about the raw int8 and so
refused loads the runtime would run and report as fell_back. New
effective_te_quant() resolves the downgrade before support is consulted.
The Recipe popover's Memory row substituted "auto" for a null memory_mode,
which is what the native engine always records, claiming the memory planner
had picked a mode on the one path that never runs it. An absent mode now
reports the offload alone.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Refuse an offload-bound precision up front, and read the video precision live
An explicit dense precision with Memory=balanced/low_vram (or the legacy
cpu_offload flag) is incompatible on its face: those requests name their
offload policy without measuring anything, offload hooks move modules with
Module.to(), and torchao tensors do not survive it, so the loader skips the
dense build. The strict refusal then landed after the resident image model
had been torn down. The pre-handoff gate now takes the memory request and
refuses the pair before the GPU is acquired. fast and auto are decided from
the measured footprint and are untouched.
The video page's loadOrStage is memoized on [stage, pickGuard], so its plain
capture of transformerQuant froze at whatever was selected when the callback
was built. The ordinary auto to FP8 change then asked the plan with no
precision, skipping the pre-download refusal, and staged tens of GB before
the load rejected the same pick. It reads through a ref now, the same way
handleLoad already does.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Extend the offload precision gate to the encoder and to video
The pre-handoff gate refused an offload-bound dense transformer quant but
not the two adjacent cases.
quantize_text_encoders reports the torchao encoder modes (int8,
fp8_dynamic, nvfp4) unsupported once offload is active, for the same reason:
the hooks move modules with Module.to() and those tensor subclasses do not
survive it. The image gate now refuses them alongside an offload-forcing
memory request. Layerwise fp8 is a dtype cast and is untouched.
assert_video_precision_available took no memory request at all, so a video
load with an explicit precision and balanced or low_vram passed the route
preflight and was refused inside load_pipeline, after acquire_for and the
teardown had evicted the resident model. It takes memory_mode now and
applies both rules.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Refuse a torchao encoder mode a host cannot import, and plan with the memory request
te_quant_supported only asks the device: a CUDA bf16 host with a broken or
absent torchao passed every capability check, and the casters import torchao
only after the pipeline has been downloaded and built, so the refusal came
through load-progress instead of the pre-load 409. Both gates now ask
torchao_quantize_importable() for the torchao-backed encoder modes. Layerwise
fp8 is a plain dtype cast and does not need it.
The video staged plan sent the precision but not the memory mode, and the
route refuses the incompatible pair only when it can see both -- so the plan
succeeded and tens of GB were staged before /video/load rejected the same
pick. It reads the memory mode through a live ref, like the precision.
---------
Co-authored-by: Daniel Han <moonshotaisubstack@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
456 lines
16 KiB
Python
456 lines
16 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
|
|
|
|
"""Speed + accuracy lever benchmark for the IMAGE diffusion backend (per-lever LPIPS).
|
|
|
|
Drives the SAME production lever functions the image loader calls -- ``apply_step_cache``,
|
|
``apply_attention_backend``, ``apply_speed_optims``, ``quantize_text_encoders``, the
|
|
compile-safe eager patches -- with the loader's own default arguments and order, so each
|
|
measured configuration reflects a real load. For each config it loads the pipeline fresh
|
|
(quant/compile mutate irreversibly), warms up (to pay the one-time compile), renders a
|
|
fixed prompt set at a fixed seed, and reports total latency, median per-step ms, peak
|
|
resident GB, and mean LPIPS(AlexNet) vs the bit-exact reference config (speed off,
|
|
native attention, uncached, dense) rendered at the same seed/settings.
|
|
|
|
Every generation starts from a clean step cache, exactly like the production backend:
|
|
diffusers keys FBCache residuals on the long-lived transformer and never resets them, so
|
|
without the per-generation reset the measured prompts would compare their first-block
|
|
residual against the PREVIOUS prompt's final one -- a state production never runs.
|
|
FBCache rows produced before this reset existed may overstate both the speedup and the
|
|
quality cost.
|
|
|
|
Lever isolation knobs (for before/after measurement of shipped fixes):
|
|
--no-epc force torch._inductor.config.emulate_precision_casts back off after
|
|
the speed layer enables it (the pre-fix compile numerics).
|
|
--unarm-cache restore the cache hooks' eager inner forwards after the speed layer
|
|
arms them (the pre-fix cache x compile composition).
|
|
|
|
Example:
|
|
CUDA_VISIBLE_DEVICES=3 python scripts/image_speedmem_bench.py --family flux.1-dev \\
|
|
--config compile --out outputs/image_speedmem
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gc
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import types
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
os.environ.setdefault("BITSANDBYTES_NOWELCOME", "1")
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
_BACKEND_ROOT = _REPO_ROOT / "studio" / "backend"
|
|
for _p in (str(_BACKEND_ROOT), str(_REPO_ROOT / "scripts")):
|
|
if _p not in sys.path:
|
|
sys.path.insert(0, _p)
|
|
|
|
# Fixed prompt set so the LPIPS mean is not hostage to a single composition.
|
|
PROMPTS = [
|
|
"A cozy reading nook by a rain-streaked window, warm lamplight, a cat asleep on a stack of books",
|
|
"A lone lighthouse on a rocky cliff at sunset, dramatic clouds, crashing waves, highly detailed",
|
|
"A bustling night market street in the rain, neon signs reflected in puddles, cinematic",
|
|
"A photograph of an astronaut riding a horse on the surface of the moon, detailed, 8k",
|
|
]
|
|
|
|
# Production defaults per family (diffusion_families.default_generation_params).
|
|
_FAMILIES: dict[str, dict[str, Any]] = {
|
|
"qwen-image": {"repo": "Qwen/Qwen-Image", "family": "qwen-image"},
|
|
"flux.1-dev": {"repo": "black-forest-labs/FLUX.1-dev", "family": "flux.1"},
|
|
"flux.2-klein-4b": {"repo": "black-forest-labs/FLUX.2-klein-4B", "family": "flux.2-klein"},
|
|
"sdxl": {"repo": "stabilityai/stable-diffusion-xl-base-1.0", "family": "sdxl"},
|
|
}
|
|
|
|
# te speed attn cache
|
|
_CONFIGS: dict[str, dict[str, Any]] = {
|
|
# bit-exact reference: everything off / native / dense
|
|
"reference": dict(te = "none", speed = "off", attn = "native", cache = "off"),
|
|
# non-compile floor: eager patches + attention auto-upgrade
|
|
"eager": dict(te = "none", speed = "eager", attn = "auto", cache = "off"),
|
|
# default dense tier (regional compile), uncached
|
|
"compile": dict(te = "none", speed = "default", attn = "auto", cache = "off"),
|
|
# max tier (max-autotune regional compile + TF32 + fused QKV), uncached
|
|
"speedmax": dict(te = "none", speed = "max", attn = "auto", cache = "off"),
|
|
# default tier + FBCache (the auto path for 20+ step schedules)
|
|
"fbcache": dict(te = "none", speed = "default", attn = "auto", cache = "fbcache"),
|
|
# FBCache without compile: isolates the cache's drift from the compile floor
|
|
"fbcache_eager": dict(te = "none", speed = "eager", attn = "auto", cache = "fbcache"),
|
|
# TE quant isolation on the bit-exact stack: the conditioning perturbation ALONE
|
|
"te_fp8dyn": dict(te = "fp8_dynamic", speed = "off", attn = "native", cache = "off"),
|
|
"te_fp8": dict(te = "fp8", speed = "off", attn = "native", cache = "off"),
|
|
}
|
|
|
|
|
|
def _sync() -> None:
|
|
import torch
|
|
if torch.cuda.is_available():
|
|
torch.cuda.synchronize()
|
|
|
|
|
|
def _reset_peak() -> None:
|
|
import torch
|
|
if torch.cuda.is_available():
|
|
torch.cuda.reset_peak_memory_stats()
|
|
|
|
|
|
def _alloc_gb() -> float:
|
|
import torch
|
|
return torch.cuda.memory_allocated() / 1e9 if torch.cuda.is_available() else 0.0
|
|
|
|
|
|
def _peak_gb() -> float:
|
|
import torch
|
|
return torch.cuda.max_memory_allocated() / 1e9 if torch.cuda.is_available() else 0.0
|
|
|
|
|
|
def _empty() -> None:
|
|
import torch
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
|
|
|
|
_LP: dict = {}
|
|
|
|
|
|
def _lpips_alex(ref_arr, arr) -> Optional[float]:
|
|
"""LPIPS(AlexNet) between two HxWx3 uint8 images (net on CPU). None if lpips missing."""
|
|
try:
|
|
import lpips
|
|
import torch
|
|
|
|
fn = _LP.get("fn")
|
|
if fn is None:
|
|
fn = lpips.LPIPS(net = "alex", verbose = False).eval()
|
|
_LP["fn"] = fn
|
|
|
|
def _t(a):
|
|
import torch as _torch
|
|
return _torch.from_numpy(a).float().permute(2, 0, 1).unsqueeze(0) / 127.5 - 1.0
|
|
|
|
with torch.no_grad():
|
|
return float(fn(_t(ref_arr), _t(arr)).item())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _import_diffusers():
|
|
import torch # noqa: F401
|
|
import torchao # noqa: F401
|
|
import diffusers.utils.import_utils as iu
|
|
|
|
iu._bitsandbytes_available = False
|
|
import diffusers
|
|
|
|
return diffusers
|
|
|
|
|
|
def _target():
|
|
"""Stand-in for DiffusionDeviceTarget: what the real lever functions read."""
|
|
import torch
|
|
return types.SimpleNamespace(
|
|
device = "cuda",
|
|
dtype = torch.bfloat16,
|
|
supports_default_torch_compile = True,
|
|
)
|
|
|
|
|
|
def _find_family(name: str):
|
|
from core.inference.diffusion_families import _FAMILIES as ALL
|
|
for fam in ALL:
|
|
if fam.name == name:
|
|
return fam
|
|
raise SystemExit(f"unknown family '{name}'")
|
|
|
|
|
|
def _apply_levers(
|
|
pipe,
|
|
cfg: dict,
|
|
*,
|
|
fam_obj,
|
|
no_epc: bool = False,
|
|
unarm_cache: bool = False,
|
|
logger = None,
|
|
) -> dict:
|
|
"""Apply the configured levers with the loader's own argument values, in the loader's
|
|
order (diffusion.py): TE quant -> attention -> step cache -> eager patches -> speed."""
|
|
from core.inference.diffusion_precision import quantize_text_encoders
|
|
from core.inference.diffusion_attention import (
|
|
apply_attention_backend,
|
|
select_attention_backend,
|
|
)
|
|
from core.inference.diffusion_cache import apply_step_cache, _restore_hooked_block_inners
|
|
from core.inference.diffusion_eager_patches import (
|
|
install_compile_safe_patches,
|
|
uninstall_patches,
|
|
)
|
|
from core.inference.diffusion_arch_patches import (
|
|
install_arch_patches,
|
|
uninstall_arch_patches,
|
|
)
|
|
from core.inference.diffusion_speed import apply_speed_optims
|
|
|
|
tgt = _target()
|
|
engaged: dict[str, Any] = {"te": None, "attn": None, "cache": None, "speed_optims": {}}
|
|
|
|
if cfg["te"] != "none":
|
|
# Returns (mode, reason, status) now; this bench only reports the mode that engaged.
|
|
engaged["te"] = quantize_text_encoders(
|
|
pipe, tgt, mode = cfg["te"], family = fam_obj.name, logger = logger
|
|
).mode
|
|
|
|
speed_mode = cfg["speed"]
|
|
engaged["attn"] = apply_attention_backend(
|
|
pipe,
|
|
select_attention_backend(
|
|
tgt, None if cfg["attn"] == "auto" else cfg["attn"], speed_active = speed_mode != "off"
|
|
),
|
|
logger = logger,
|
|
)
|
|
|
|
if cfg["cache"] != "off":
|
|
engaged["cache"] = apply_step_cache(
|
|
pipe, mode = cfg["cache"], quant_active = False, logger = logger
|
|
)
|
|
|
|
if speed_mode != "off":
|
|
install_compile_safe_patches()
|
|
install_arch_patches()
|
|
else:
|
|
uninstall_patches()
|
|
uninstall_arch_patches()
|
|
|
|
engaged["speed_optims"] = apply_speed_optims(
|
|
pipe,
|
|
tgt,
|
|
is_gguf = False,
|
|
family = fam_obj,
|
|
speed_mode = speed_mode,
|
|
cache_active = engaged["cache"] is not None,
|
|
offload_active = False,
|
|
)
|
|
|
|
if no_epc:
|
|
import torch
|
|
cfg_ind = getattr(getattr(torch, "_inductor", None), "config", None)
|
|
if cfg_ind is not None and hasattr(cfg_ind, "emulate_precision_casts"):
|
|
cfg_ind.emulate_precision_casts = False
|
|
engaged["epc_forced_off"] = True
|
|
if unarm_cache:
|
|
transformer = getattr(pipe, "transformer", None)
|
|
if transformer is not None:
|
|
_restore_hooked_block_inners(transformer)
|
|
engaged["cache_unarmed"] = True
|
|
return engaged
|
|
|
|
|
|
def _reset_step_cache(pipe) -> None:
|
|
"""Clear stale FBCache residuals before a generation, mirroring the production
|
|
backend's ``_reset_step_cache`` (diffusion.py): diffusers keys the residuals on the
|
|
long-lived denoiser and never resets them itself, and the transformer-level entry
|
|
point in diffusers 0.39 is ``_reset_stateful_cache`` (``reset_stateful_hooks`` lives
|
|
only on the HookRegistry, so the getattr fallback is a silent no-op). Best-effort:
|
|
an uncached denoiser (or SDXL's unet, which has no FBCache path) is a no-op."""
|
|
denoiser = getattr(pipe, "transformer", None) or getattr(pipe, "unet", None)
|
|
reset = getattr(denoiser, "_reset_stateful_cache", None) or getattr(
|
|
denoiser, "reset_stateful_hooks", None
|
|
)
|
|
if callable(reset):
|
|
try:
|
|
reset()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _generate(
|
|
pipe,
|
|
fam_obj,
|
|
*,
|
|
steps: int,
|
|
guidance: float,
|
|
size: int,
|
|
seed: int,
|
|
limit: Optional[int] = None,
|
|
) -> tuple:
|
|
"""Render every prompt at a fixed per-prompt seed; returns (arrays, total_s, step_ms)."""
|
|
import numpy as np
|
|
import torch
|
|
|
|
call_params = {}
|
|
try:
|
|
import inspect
|
|
call_params = inspect.signature(pipe.__call__).parameters
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
step_times: list[float] = []
|
|
last: dict[str, float] = {}
|
|
|
|
def _cb(p, i, t, kw):
|
|
now = time.perf_counter()
|
|
if "t" in last:
|
|
step_times.append(now - last["t"])
|
|
last["t"] = now
|
|
return kw
|
|
|
|
arrs = []
|
|
total = 0.0
|
|
for idx, prompt in enumerate(PROMPTS[: limit or len(PROMPTS)]):
|
|
kwargs: dict[str, Any] = {
|
|
"prompt": prompt,
|
|
"num_inference_steps": steps,
|
|
"width": size,
|
|
"height": size,
|
|
"generator": torch.Generator("cuda").manual_seed(seed + idx),
|
|
}
|
|
if fam_obj.cfg_kwarg in call_params:
|
|
kwargs[fam_obj.cfg_kwarg] = guidance
|
|
if "callback_on_step_end" in call_params:
|
|
kwargs["callback_on_step_end"] = _cb
|
|
last.clear()
|
|
# Reset the step cache like production, else step 1 compares against the previous prompt.
|
|
_reset_step_cache(pipe)
|
|
_sync()
|
|
t0 = time.perf_counter()
|
|
with torch.inference_mode():
|
|
image = pipe(**kwargs).images[0]
|
|
_sync()
|
|
total += time.perf_counter() - t0
|
|
arrs.append(np.array(image.convert("RGB")))
|
|
med_step = sorted(step_times)[len(step_times) // 2] * 1000.0 if step_times else None
|
|
return arrs, total, med_step, step_times
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description = __doc__.splitlines()[0])
|
|
ap.add_argument("--family", required = True, choices = sorted(_FAMILIES))
|
|
ap.add_argument("--config", required = True, choices = sorted(_CONFIGS))
|
|
ap.add_argument("--steps", type = int, default = None, help = "override the family default")
|
|
ap.add_argument("--size", type = int, default = 1024)
|
|
ap.add_argument("--seed", type = int, default = 42)
|
|
ap.add_argument("--out", default = "outputs/image_speedmem")
|
|
ap.add_argument("--no-epc", action = "store_true")
|
|
ap.add_argument("--unarm-cache", action = "store_true")
|
|
ap.add_argument("--tag", default = None, help = "output row name (default: config name)")
|
|
args = ap.parse_args()
|
|
|
|
import logging
|
|
|
|
logging.basicConfig(level = logging.INFO, format = "%(levelname)s %(name)s: %(message)s")
|
|
logger = logging.getLogger("image_speedmem")
|
|
|
|
fam_spec = _FAMILIES[args.family]
|
|
cfg = _CONFIGS[args.config]
|
|
tag = args.tag or args.config
|
|
|
|
import numpy as np
|
|
import torch
|
|
|
|
diffusers = _import_diffusers()
|
|
from core.inference.diffusion_families import default_generation_params
|
|
|
|
fam_obj = _find_family(fam_spec["family"])
|
|
steps, guidance = default_generation_params(fam_spec["repo"])
|
|
if args.steps is not None:
|
|
steps = args.steps
|
|
|
|
out_dir = Path(args.out) / args.family
|
|
out_dir.mkdir(parents = True, exist_ok = True)
|
|
ref_npz = out_dir / f"ref_seed{args.seed}_st{steps}_{args.size}.npz"
|
|
|
|
logger.info(
|
|
"family=%s config=%s steps=%d guidance=%s size=%d seed=%d",
|
|
args.family,
|
|
args.config,
|
|
steps,
|
|
guidance,
|
|
args.size,
|
|
args.seed,
|
|
)
|
|
|
|
_reset_peak()
|
|
t0 = time.perf_counter()
|
|
pipe = diffusers.DiffusionPipeline.from_pretrained(fam_spec["repo"], torch_dtype = torch.bfloat16)
|
|
load_s = time.perf_counter() - t0
|
|
|
|
engaged = _apply_levers(
|
|
pipe,
|
|
cfg,
|
|
fam_obj = fam_obj,
|
|
no_epc = args.no_epc,
|
|
unarm_cache = args.unarm_cache,
|
|
logger = logger,
|
|
)
|
|
pipe.to("cuda")
|
|
weights_gb = _alloc_gb()
|
|
|
|
# Warmup pays the one-time compile / cuDNN autotune outside the timed runs.
|
|
wt0 = time.perf_counter()
|
|
_generate(
|
|
pipe,
|
|
fam_obj,
|
|
steps = steps,
|
|
guidance = guidance,
|
|
size = args.size,
|
|
seed = args.seed + 1000,
|
|
limit = 1,
|
|
)
|
|
warmup_s = time.perf_counter() - wt0
|
|
|
|
_reset_peak()
|
|
arrs, total_s, med_step_ms, step_times = _generate(
|
|
pipe, fam_obj, steps = steps, guidance = guidance, size = args.size, seed = args.seed
|
|
)
|
|
gen_peak = _peak_gb()
|
|
|
|
# Persist / score against the reference.
|
|
lpips_vals: list[float] = []
|
|
if args.config == "reference" and not (args.no_epc or args.unarm_cache):
|
|
np.savez_compressed(ref_npz, *arrs)
|
|
if ref_npz.exists():
|
|
ref = np.load(ref_npz)
|
|
refs = [ref[k] for k in ref.files]
|
|
for r, a in zip(refs, arrs):
|
|
v = _lpips_alex(r, a)
|
|
if v is not None:
|
|
lpips_vals.append(v)
|
|
|
|
from PIL import Image
|
|
|
|
for i, a in enumerate(arrs):
|
|
Image.fromarray(a).save(out_dir / f"{tag}_p{i}.png")
|
|
|
|
row = {
|
|
"family": args.family,
|
|
"config": args.config,
|
|
"tag": tag,
|
|
"steps": steps,
|
|
"guidance": guidance,
|
|
"size": args.size,
|
|
"seed": args.seed,
|
|
"engaged": {k: v for k, v in engaged.items()},
|
|
"load_s": round(load_s, 2),
|
|
"warmup_s": round(warmup_s, 2),
|
|
"total_gen_s": round(total_s, 2),
|
|
"per_image_s": round(total_s / len(PROMPTS), 3),
|
|
"median_step_ms": round(med_step_ms, 1) if med_step_ms else None,
|
|
"step_times_s": [round(t, 4) for t in step_times],
|
|
"weights_gb": round(weights_gb, 2),
|
|
"gen_peak_gb": round(gen_peak, 2),
|
|
"lpips_vs_ref_mean": round(sum(lpips_vals) / len(lpips_vals), 4) if lpips_vals else None,
|
|
"lpips_vs_ref_per_prompt": [round(v, 4) for v in lpips_vals] or None,
|
|
}
|
|
(out_dir / f"{tag}.json").write_text(json.dumps(row, indent = 2, default = str))
|
|
print(json.dumps(row, indent = 2, default = str))
|
|
|
|
del pipe
|
|
_empty()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|