Merge remote-tracking branch 'origin/diffusion-auto-badges' into fold-integration

# Conflicts:
#	studio/backend/models/inference.py
#	studio/frontend/src/features/images/images-page.tsx
This commit is contained in:
Daniel Han 2026-07-07 01:08:43 +00:00
commit 2e855a018d
7 changed files with 294 additions and 4 deletions

View file

@ -0,0 +1,53 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Pure, torch-free per-family footprint summary for the inference info endpoint.
The Advanced Dtype selector offers int8/fp8/nvfp4/mxfp8 dense transformer-quant
schemes. This module turns the auto-policy's per-family bf16 component table plus
its per-scheme steady factors into a static "what would this cost resident" summary
the frontend can show BEFORE anything is loaded, so a user can size the tradeoff.
Estimates here are the STEADY resident footprint (transformer * factor + companions),
not the transient build peak -- the same quantity ``estimate_dense_quant`` reports as
``steady_total`` -- so the numbers match what stays on the card during generation.
Pure by design: only imports the auto-policy tables (themselves torch-free), does no
GPU probing, so it unit-tests on CPU-only hosts.
"""
from __future__ import annotations
from typing import Any
from .diffusion_auto_policy import _FAMILY_BF16_GB, _QUANT_STEADY_FACTOR
def _round1(value: float) -> float:
return round(value, 1)
def family_inference_infos() -> list[dict[str, Any]]:
"""Per-family bf16 component sizes + estimated resident footprint per quant scheme.
One dict per family in the auto-policy bf16 table (registry order), each carrying the
bf16-resident component sizes and the estimated resident GB under bf16 and each dense
quant scheme. bf16's estimate is the un-quantised transformer + companions; each
scheme scales the transformer by its steady factor and adds the same companions.
"""
infos: list[dict[str, Any]] = []
for name, (transformer_gb, text_encoders_gb, vae_gb) in _FAMILY_BF16_GB.items():
companions_gb = text_encoders_gb + vae_gb
estimated = {"bf16": _round1(transformer_gb + companions_gb)}
for scheme, factor in _QUANT_STEADY_FACTOR.items():
estimated[scheme] = _round1(transformer_gb * factor + companions_gb)
infos.append(
{
"family": name,
"transformer_bf16_gb": _round1(transformer_gb),
"text_encoders_bf16_gb": _round1(text_encoders_gb),
"vae_bf16_gb": _round1(vae_gb),
"estimated_resident_gb": estimated,
}
)
return infos

View file

@ -2068,6 +2068,22 @@ class DiffusionLoadProgressResponse(BaseModel):
error: Optional[str] = Field(None, description = "Failure message when phase is 'error'")
class DiffusionResolvedControl(BaseModel):
"""One Advanced control's engaged value + provenance, for the "Auto: X" badges.
``value`` is what actually applied (a scheme string, a mode string, ``null`` when the
control is off, or ``true``/``false`` for cpu_offload), so it is typed ``Any``.
``source`` is "auto" when this backend decided it or "explicit" when the caller did;
``reason`` is the short human-readable why the frontend shows as a tooltip.
"""
value: Any = Field(
None, description = "The engaged value: a string, a boolean (cpu_offload), or null."
)
source: str = Field(..., description = '"auto" (backend decided) or "explicit" (caller set it)')
reason: str = Field("", description = "Short human-readable reason for the resolved value.")
class DiffusionStatusResponse(BaseModel):
"""Current diffusion backend state."""
@ -2131,14 +2147,44 @@ class DiffusionStatusResponse(BaseModel):
"picker's enabled state). Diffusers only, for families with a ControlNet pipeline; False "
"for the native engine, GGUF-via-diffusers, and torchao fp8/int8 dense.",
)
resolved: Optional[dict] = Field(
# Additive: per-Advanced-control provenance {control: {value, source, reason}}. Present
# only on backends that record it; null when nothing is loaded or on older backends. The
# frontend renders an "Auto: X" badge next to each control whose source == "auto". Declared
# explicitly so pydantic's default extra='ignore' does not silently drop the resolved record.
resolved: Optional[Dict[str, DiffusionResolvedControl]] = Field(
None,
description = "Per-control auto-policy provenance (value/source/reason for each resolved "
"setting), or null. Declared explicitly so the field is not dropped by the default "
"extra='ignore', which would silently discard the backend's resolved record.",
description = "Per-control resolved value + provenance (source auto|explicit + reason), "
"keyed by Advanced control name; null when unloaded or unavailable.",
)
class DiffusionInferenceInfo(BaseModel):
"""One family's bf16 component sizes + estimated resident footprint per quant scheme.
Mirrors the dicts ``family_inference_infos()`` returns: the bf16-resident transformer /
text-encoder / VAE sizes, and the estimated resident GB under bf16 and each dense
transformer-quant scheme (transformer * factor + companions), rounded to 1 decimal."""
family: str = Field(..., description = "Diffusion family name (auto-policy table key).")
transformer_bf16_gb: float = Field(..., description = "bf16-resident transformer size in GB.")
text_encoders_bf16_gb: float = Field(
..., description = "bf16-resident text encoder(s) size in GB."
)
vae_bf16_gb: float = Field(..., description = "bf16-resident VAE size in GB.")
estimated_resident_gb: Dict[str, float] = Field(
...,
description = "Estimated resident GB keyed by scheme: bf16, int8, fp8, mxfp8, nvfp4.",
)
class DiffusionInferenceInfoResponse(BaseModel):
"""Static per-family footprint summary for the Advanced Dtype tradeoff (GET
/api/inference/images/info). Hardware-independent: no GPU probing, so it is served
from the pure auto-policy tables and is safe to fetch before anything is loaded."""
families: List[DiffusionInferenceInfo] = Field(default_factory = list)
# ── OpenAI-compatible images API (POST /v1/images/generations) ──
#
# Shapes mirror OpenAI's CreateImageRequest / ImagesResponse so off-the-shelf

View file

@ -1080,6 +1080,7 @@ from models.inference import (
DiffusionGenerateResponse,
DiffusionGenerateProgressResponse,
DiffusionStatusResponse,
DiffusionInferenceInfoResponse,
DiffusionLoadProgressResponse,
GalleryImage,
GalleryListResponse,
@ -11972,6 +11973,16 @@ async def diffusion_status(current_subject: str = Depends(get_current_subject)):
return DiffusionStatusResponse(**active_status())
@studio_router.get("/images/info", response_model = DiffusionInferenceInfoResponse)
async def diffusion_inference_info(current_subject: str = Depends(get_current_subject)):
"""Static per-family footprint summary for the Advanced Dtype tradeoff.
Hardware-independent (served from the pure auto-policy tables, no GPU probing), so it
is cheap and safe to fetch before anything is loaded."""
from core.inference.diffusion_inference_info import family_inference_infos
return DiffusionInferenceInfoResponse(families = family_inference_infos())
@studio_router.get("/images/load-progress", response_model = DiffusionLoadProgressResponse)
async def diffusion_load_progress(current_subject: str = Depends(get_current_subject)):
from core.inference.diffusion_engine_router import get_active_diffusion_engine

View file

@ -0,0 +1,57 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""CPU-only unit tests for the pure per-family inference-info helper.
Covers ``family_inference_infos()``: every auto-policy family appears, the component sizes
round-trip, and the quant estimates order correctly (quantised < bf16, nvfp4 < int8). No
torch / diffusers / GPU."""
from __future__ import annotations
from core.inference.diffusion_auto_policy import _FAMILY_BF16_GB, _QUANT_STEADY_FACTOR
from core.inference.diffusion_inference_info import family_inference_infos
def test_covers_every_auto_policy_family():
infos = family_inference_infos()
names = {info["family"] for info in infos}
assert names == set(_FAMILY_BF16_GB), "info must list exactly the auto-policy families"
# One entry per family, in registry order.
assert [info["family"] for info in infos] == list(_FAMILY_BF16_GB)
def test_each_family_reports_all_schemes():
for info in family_inference_infos():
estimated = info["estimated_resident_gb"]
assert set(estimated) == {"bf16", *_QUANT_STEADY_FACTOR}
# Every reported value is a float rounded to one decimal.
for value in estimated.values():
assert isinstance(value, float)
assert round(value, 1) == value
def test_component_sizes_match_the_table():
infos = {info["family"]: info for info in family_inference_infos()}
for name, (transformer, text_encoders, vae) in _FAMILY_BF16_GB.items():
info = infos[name]
assert info["transformer_bf16_gb"] == round(transformer, 1)
assert info["text_encoders_bf16_gb"] == round(text_encoders, 1)
assert info["vae_bf16_gb"] == round(vae, 1)
def test_quantised_estimate_is_below_bf16():
# A quantised transformer is smaller than bf16, so its resident estimate must be too
# (the companions are shared, and every steady factor is < 1).
for info in family_inference_infos():
estimated = info["estimated_resident_gb"]
for scheme in _QUANT_STEADY_FACTOR:
assert estimated[scheme] < estimated["bf16"], f"{info['family']} {scheme}"
def test_nvfp4_is_below_int8():
# nvfp4 packs two params per byte (~0.33x) vs int8's one byte per param (~0.55x), so
# nvfp4's estimate is the smaller of the two on every family.
for info in family_inference_infos():
estimated = info["estimated_resident_gb"]
assert estimated["nvfp4"] < estimated["int8"], info["family"]

View file

@ -722,3 +722,46 @@ def test_gpu_native_load_takes_arbiter(client, monkeypatch):
)
assert resp.status_code == 200
assert acquired == [gpu_arbiter.DIFFUSION]
def test_images_info_lists_every_family(client):
# The pure info endpoint is hardware-independent (no load required): it returns one
# entry per auto-policy family, each with the quant estimates the UI shows.
from core.inference.diffusion_auto_policy import _FAMILY_BF16_GB
resp = client.get("/api/inference/images/info")
assert resp.status_code == 200
families = resp.json()["families"]
assert {f["family"] for f in families} == set(_FAMILY_BF16_GB)
sample = families[0]
est = sample["estimated_resident_gb"]
# Quantised estimates undercut bf16, and nvfp4 undercuts int8 (matches the pure helper).
assert est["int8"] < est["bf16"]
assert est["nvfp4"] < est["int8"]
def test_status_passes_through_resolved(client, monkeypatch):
# The additive `resolved` provenance record round-trips through the status route so the
# frontend can render the "Auto: X" badges.
backend = diffusion_module.get_diffusion_backend()
resolved = {
"speed_mode": {"value": "eager", "source": "auto", "reason": "per-kind default"},
"transformer_quant": {"value": "int8", "source": "explicit", "reason": "requested"},
"cpu_offload": {"value": False, "source": "auto", "reason": "from the memory plan"},
"transformer_cache": {"value": None, "source": "auto", "reason": "few-step model"},
}
monkeypatch.setattr(
backend, "status", lambda: {**_unloaded_status(), "loaded": True, "resolved": resolved}
)
body = client.get("/api/inference/images/status").json()
assert body["resolved"] == resolved
assert body["resolved"]["speed_mode"]["source"] == "auto"
# The cpu_offload value stays a real boolean (not coerced to a string).
assert body["resolved"]["cpu_offload"]["value"] is False
def test_status_resolved_defaults_to_null(client):
# A backend status without a `resolved` key leaves the additive field null (older
# backends and the unloaded state).
body = client.get("/api/inference/images/status").json()
assert body["resolved"] is None

View file

@ -4,6 +4,16 @@
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
// One Advanced control's resolved value + provenance, for the "Auto: X" badges. `value`
// is the engaged value (a scheme/mode string, null when off, or a boolean for cpu_offload);
// `source` is "auto" (this backend decided) or "explicit" (the caller set it); `reason` is
// the short why shown as a tooltip.
export interface DiffusionResolvedControl {
value: string | boolean | null;
source: "auto" | "explicit";
reason: string;
}
export interface DiffusionStatus {
loaded: boolean;
repo_id: string | null;
@ -24,6 +34,11 @@ export interface DiffusionStatus {
// Whether the loaded model can apply a ControlNet (drives the ControlNet picker's enabled
// state). Diffusers only, for families with a ControlNet pipeline; false otherwise.
supports_controlnet?: boolean;
// Per-Advanced-control provenance, keyed by control name (speed_mode, transformer_quant,
// attention_backend, memory_mode, transformer_cache, cpu_offload). Present only when a
// model is loaded on a backend that records it; the "Auto: X" badges read it. Absent on
// older backends.
resolved?: Record<string, DiffusionResolvedControl> | null;
}
export interface DiffusionGenerateProgress {
@ -174,6 +189,28 @@ export async function getDiffusionStatus(): Promise<DiffusionStatus> {
return parseJson(await authFetch("/api/inference/images/status"));
}
// One family's bf16 component sizes + estimated resident footprint per quant scheme
// (from GET /api/inference/images/info). Hardware-independent, so it can be fetched before
// anything is loaded to size the Advanced Dtype tradeoff.
export interface DiffusionInferenceInfo {
family: string;
transformer_bf16_gb: number;
text_encoders_bf16_gb: number;
vae_bf16_gb: number;
// Estimated resident GB keyed by scheme: bf16, int8, fp8, mxfp8, nvfp4.
estimated_resident_gb: Record<string, number>;
}
export interface DiffusionInferenceInfoResponse {
families: DiffusionInferenceInfo[];
}
/** Static per-family footprint summary for the Advanced Dtype tradeoff. Hardware-independent
* (served from the pure auto-policy tables), so it is safe to fetch before a load. */
export async function getDiffusionInferenceInfo(): Promise<DiffusionInferenceInfoResponse> {
return parseJson(await authFetch("/api/inference/images/info"));
}
export async function getDiffusionLoadProgress(): Promise<DiffusionLoadProgress> {
return parseJson(await authFetch("/api/inference/images/load-progress"));
}

View file

@ -470,10 +470,44 @@ function Field({
);
}
// The engaged value of a resolved Advanced control, formatted for its "Auto: X" badge.
// Short scheme/mode tokens go uppercase (INT8, FP8, FBCACHE); the attention backend the
// backend reports as `_native_cudnn` shows as cuDNN; cpu_offload's boolean shows On/Off.
function formatResolvedValue(key: string, value: string | boolean | null): string {
if (key === "cpu_offload") return value ? "On" : "Off";
if (value === null || value === "") return "Off";
if (typeof value === "boolean") return value ? "On" : "Off";
if (value === "_native_cudnn" || value.toLowerCase() === "cudnn") return "cuDNN";
return value.toUpperCase();
}
// The "Auto: X" badge for one Advanced control: rendered only when the backend resolved
// that control itself (source === "auto"); an explicit user choice renders nothing. The
// reason is surfaced as a hover tooltip. Muted pill matching the panel's other chips.
function ResolvedBadge({
status,
controlKey,
}: {
status: DiffusionStatus | null;
controlKey: string;
}) {
const resolved = status?.resolved?.[controlKey];
if (!resolved || resolved.source !== "auto") return null;
return (
<span
title={resolved.reason || undefined}
className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium uppercase tracking-wider text-muted-foreground"
>
Auto: {formatResolvedValue(controlKey, resolved.value)}
</span>
);
}
// A compact labeled Select row for the Advanced Options panel.
function AdvancedSelect({
label,
hint,
badge,
desc,
value,
onValueChange,
@ -481,6 +515,8 @@ function AdvancedSelect({
}: {
label: string;
hint?: ReactNode;
// An optional inline badge next to the label (e.g. the "Auto: X" resolved-value pill).
badge?: ReactNode;
// A short always-visible description under the row (the hint tooltip carries the full
// detail). Used for controls whose label alone does not convey what they do.
desc?: string;
@ -494,6 +530,7 @@ function AdvancedSelect({
<span className="flex shrink-0 items-center gap-1 whitespace-nowrap text-xs font-medium text-muted-foreground">
{label}
{hint && <InfoHint>{hint}</InfoHint>}
{badge}
</span>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="h-8 w-[160px] text-xs">
@ -1866,6 +1903,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
<AdvancedSelect
label="Speed"
hint="Auto picks per model (GGUF compiles, dense stays eager). eager = fused kernels, no compile. default/max add torch.compile (max also TF32 + fused QKV)."
badge={<ResolvedBadge status={status} controlKey="speed_mode" />}
value={speedMode}
onValueChange={(v) => setSpeedMode(v as typeof speedMode)}
options={[
@ -1883,6 +1921,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
<AdvancedSelect
label="Dtype"
hint="Transformer compute dtype. Auto picks the fastest precision the hardware supports (at least INT8 on a capable GPU; FP8 on data-center cards) by loading the FULL base model and quantising its transformer onto low-precision tensor cores, and falls back to running the GGUF as-is when the device, VRAM or disk can't take it. Off always runs the GGUF as-is."
badge={<ResolvedBadge status={status} controlKey="transformer_quant" />}
value={transformerQuant}
onValueChange={(v) => setTransformerQuant(v as typeof transformerQuant)}
options={[
@ -1905,6 +1944,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
<AdvancedSelect
label="Attention"
hint="Attention kernel. Auto upgrades to cuDNN fused attention on NVIDIA when a speed profile is active. sage is INT8 attention (small quality cost)."
badge={<ResolvedBadge status={status} controlKey="attention_backend" />}
value={attentionBackend}
onValueChange={(v) => setAttentionBackend(v as typeof attentionBackend)}
options={[
@ -1918,6 +1958,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
<AdvancedSelect
label="Memory"
hint="auto measures free VRAM. fast keeps everything resident. balanced streams the transformer. low_vram offloads every component (lowest VRAM, slower)."
badge={<ResolvedBadge status={status} controlKey="memory_mode" />}
value={memoryMode}
onValueChange={(v) => setMemoryMode(v as typeof memoryMode)}
options={[
@ -1930,6 +1971,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
<AdvancedSelect
label="Step cache"
hint="First-Block-Cache reuses the transformer tail across steps for many-step models (~1.4x). Auto enables it for many-step schedules and skips it for few-step distilled models; Off disables it entirely."
badge={<ResolvedBadge status={status} controlKey="transformer_cache" />}
value={transformerCache}
onValueChange={(v) => setTransformerCache(v as typeof transformerCache)}
options={[
@ -1942,6 +1984,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
<span className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
CPU offload
<InfoHint>Offload to CPU to fit low-VRAM cards (slower). Overridden by Memory mode when that is not Auto.</InfoHint>
<ResolvedBadge status={status} controlKey="cpu_offload" />
</span>
<Switch checked={cpuOffload} onCheckedChange={setCpuOffload} />
</div>