Spaces:
Running on Zero
Running on Zero
File size: 22,461 Bytes
333ff0e 75c39e8 333ff0e 75c39e8 333ff0e 75c39e8 333ff0e 75c39e8 333ff0e 75c39e8 333ff0e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 | """Ahead-of-time (AoT) compilation of the MMDiT backbone for Hugging Face ZeroGPU (H200).
On ZeroGPU the GPU is allocated per-request inside a *forked, short-lived* process, so a JIT
``torch.compile`` (what :meth:`diffu.model.backbone.Backbone.compile_blocks` does) cannot persist its
compiled artifacts across calls — every request pays the cold-start again. The fix (HF ``zerogpu-aoti``
recipe) is to compile ONCE at Space startup with ``torch.export`` + ``spaces.aoti_compile`` and install
the frozen graph onto the module, so every subsequent forked request reuses it.
This module is a NO-OP off ZeroGPU (local ``gpu`` / ``cpu``): the studio keeps its eager / JIT path there.
────────────────────────────────────────────────────────────────────────────────────────────────────
Honesty / what can only be validated on real ZeroGPU H200 hardware
────────────────────────────────────────────────────────────────────────────────────────────────────
The ``spaces.aoti_capture / aoti_compile / aoti_apply`` helpers and the ``@spaces.GPU`` fork model only
exist inside a live Spaces runtime, so the *export → compile → apply → speedup* path here can be
smoke-checked locally (export a fixed-width graph) but only END-TO-END validated on the Space. Two parts
of THIS model are genuine exportability risks and are why every step below is wrapped in a fallback:
1. ``Backbone.forward`` takes ``n_content: int`` (a *Python int*, not a tensor). Under ``torch.export``
a plain int specializes to a constant → the exported graph would be frozen to ONE line width. We
avoid this by exporting a thin wrapper whose forward is tensors-only and derives
``n_content = context_tokens.shape[1]`` INTERNALLY, so it rides the dynamic width symbol. This is
only valid when ``style_in_context=False`` (the released arch), where ``context == content`` and so
``context.shape[1] == n_content``. We assert that and fall back otherwise.
2. The custom 2D-RoPE path (:class:`diffu.model.rope.RoPEJointAttnProcessor`) memoizes ``(cos, sin)`` in
``Backbone._rope_cache`` keyed by the python ints ``(h_t, w_t, device, dtype)`` and builds them with
``arange(w_t)``. Under a DYNAMIC width, ``w_t`` becomes a ``SymInt`` and that dict lookup /
arange-on-symint is exactly the kind of data-dependent control flow ``torch.export`` can refuse to
trace cleanly (guard on a symbolic hash, or a graph break). If the dynamic-width export raises, we
fall back — in order — to a FIXED-width AoT (still a real H200 win for the common bucket), then to
the eager JIT ``compile_blocks()``, then to plain eager. None of these change numerics.
So: treat dynamic-width AoT as "attempt, measure on the Space." The fixed-width and JIT fallbacks are the
safety net and are what guarantees the Space still boots and runs if export doesn't like the RoPE graph.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import torch.nn as _nn # runtime base class for the export wrapper; this module is only imported when AoT is on
if TYPE_CHECKING:
import torch
import torch.nn as nn
from diffu.config import Config
from diffu_studio.checkpoints import LoadedModel
# A representative line to drive one real denoise so the captured shapes match production traffic.
# Mid-length Swedish so the natural-width lands in the middle of the bucket span (not the min/max edge).
_SAMPLE_TEXT = "Kongl. Maj:ts nådiga förordning"
_SAMPLE_STYLE_SIZE = 224 # DINO input side (matches diffu.generate.load_style)
# ──────────────────────────────────────────────────────────────────────────────────────────────────
# Public entry point
# ──────────────────────────────────────────────────────────────────────────────────────────────────
def aot_compile_backbone(
loaded: LoadedModel,
*,
cfg_scale: float = 5.0,
num_steps: int = 4,
duration: int = 1500,
) -> bool:
"""AoT-compile ``loaded.model.backbone`` in place for ZeroGPU. Returns True iff a compiled graph was
installed; a NO-OP returning False off Spaces or on any failure (the caller keeps the eager path).
Runs the whole capture→export→compile inside ``@spaces.GPU(duration=1500)`` (long startup budget) so a
GPU is held for the one-time compile. ``cfg_scale>0`` makes the capture use the 2×-batch CFG path the
studio actually runs (see :meth:`diffu.model.diffu.Diffu._denoise_latents`), so the exported batch dim
matches. ``num_steps`` is tiny — we only need ONE real backbone call to capture shapes.
"""
from diffu_studio.device import _on_zerogpu # reuse the studio's single source of truth
if not _on_zerogpu():
return False # local gpu / cpu: keep eager (or opt into JIT compile_blocks elsewhere)
try:
import spaces
except ImportError: # SPACES_ZERO_GPU set but the package missing — misconfigured Space, stay eager
print("[studio] AoT skipped: `spaces` not importable despite SPACES_ZERO_GPU")
return False
# Capture `loaded` by CLOSURE (not as a @spaces.GPU argument): ZeroGPU forks the decorated call into a
# fresh process and pickles its ARGS; a ~2B-param CUDA module must not go through that path. The blog's
# startup pattern references the model as a global/closure for exactly this reason.
@spaces.GPU(duration=duration)
def _startup() -> bool:
return _compile_and_apply(loaded, spaces, cfg_scale=cfg_scale, num_steps=num_steps)
try:
return _startup()
except Exception as exc: # noqa: BLE001 — AoT is an optimization; a Space must still boot without it
print(f"[studio] AoT compile failed ({type(exc).__name__}: {exc}); falling back to eager/JIT")
_fallback_jit(loaded)
return False
# ──────────────────────────────────────────────────────────────────────────────────────────────────
# Implementation (runs on the GPU, inside @spaces.GPU)
# ──────────────────────────────────────────────────────────────────────────────────────────────────
def _compile_and_apply(loaded: LoadedModel, spaces: Any, *, cfg_scale: float, num_steps: int) -> bool:
import torch
model, cfg = loaded.model, loaded.cfg
backbone = model.backbone
# --- Guard the assumptions the tensors-only wrapper relies on ------------------------------------
if not model.glyph_line:
# Non-glyph-line: content seq length is fixed (max_chars), not width-derived, and n_content!=seq.
# Not the released arch; skip AoT rather than export a graph with wrong dynamic dims.
print("[studio] AoT skipped: backbone is not in glyph_line mode (n_content is not width-derived)")
return False
if getattr(model, "style_in_context", False):
# context = cat(content, style) → context.shape[1] != n_content, so we can't derive n_content
# from the context length. Would need to also thread the style-token count; not worth it here.
print("[studio] AoT skipped: style_in_context=True breaks the n_content = context.shape[1] identity")
return False
# --- (OPTIONAL) torchao FP8 — H200 (sm_90) has hardware FP8; quantize BEFORE export so the frozen ---
# --- graph bakes in the fp8 kernels. Gated off by default: it shifts numerics and must be CER-checked
# --- on the Space before trusting it. Enable via DIFFU_STUDIO_FP8=1.
_maybe_quantize_fp8(backbone)
# --- Capture ONE real backbone call so the example inputs are production-representative -----------
# aokit.exporting.capture hooks the module's forward and records the (args, kwargs) of the first call.
import aokit
style = _dummy_style(model, size=_SAMPLE_STYLE_SIZE)
latent_hw = _sample_latent_hw(model, cfg, _SAMPLE_TEXT)
with aokit.exporting.capture(backbone) as call:
# A short real denoise → exercises the exact _denoise_latents → backbone(...) path (incl. the 2×
# CFG batch when cfg_scale>0). We only need the first backbone call's shapes; num_steps is minimal.
model.generate([_SAMPLE_TEXT], style, latent_hw=latent_hw, num_steps=num_steps, cfg_scale=cfg_scale)
# call.args = (latent, timestep, context_tokens, pooled); call.kwargs = {"n_content": <int>}
# We DROP n_content here: the wrapper recomputes it from context_tokens.shape[1] so it stays dynamic.
latent, timestep, context_tokens, pooled = call.args[:4]
example_args = (latent, timestep, context_tokens, pooled)
# --- Dynamic shapes: ONE fundamental symbol = w_tok (patch-column count) -------------------------
# latent[..., w] with w = patch * w_tok ; context seq = w_tok ; batch shared across all four inputs.
patch = backbone.patch
w_min, w_max = _width_token_bounds(cfg, patch)
batch = latent.shape[0] # 2 under CFG (b=1 line), 1 otherwise — see below for the dynamic-batch note.
w_tok = torch.export.Dim("w_tok", min=w_min, max=w_max)
# Derived: the latent's width dim is patch * w_tok (encodes divisibility-by-patch for free), and the
# context sequence dim IS w_tok. h (dim 2) and channels (dim 1) are static (fixed line height / VAE C).
dynamic_shapes = (
{3: patch * w_tok}, # latent [B, C, h, patch*w_tok] — dynamic width only
{}, # timestep [B] — static (batch fixed; see dynamic-batch note)
{1: w_tok}, # context [B, w_tok, ctx] — seq tied to width
{}, # pooled [B, ctx] — static
)
# NOTE (dynamic batch): if you want ONE graph to serve both cfg_scale>0 (batch 2b) and cfg_scale==0
# (batch b), add a `batch = torch.export.Dim("batch", min=1, max=2)` and put `{0: batch}` on all four
# entries (and on timestep). It widens the export surface (more guards) so it is left OFF by default —
# the studio's default cfg_scale=5 always takes the 2× path, so a fixed batch is the low-risk choice.
# --- Export the tensors-only wrapper (n_content derived inside) → aokit weightless AoT package -----
import os
from pathlib import Path
import aokit
from aokit import LazyAOTIModel
wrapper = _BackboneExportWrapper(backbone)
with torch.no_grad():
exported = torch.export.export(wrapper, args=example_args, dynamic_shapes=dynamic_shapes)
# aokit.compile_and_save writes a WEIGHTLESS AOTInductor .pt2 (graph decoupled from weights) under
# <pkg>/root/; LazyAOTIModel lazy-loads it per forked ZeroGPU request and re-binds the live backbone
# weights — the ZeroGPU-friendly path from the aokit README. (Regional variant: compile_and_save(...,
# submodule="transformer_blocks") + aokit.load_from_package_dir(model.backbone.transformer, pkg) — the
# model already marks JointTransformerBlock as its repeated block; cuts cold-start, same speedup.)
pkg_dir = Path(os.environ.get("DIFFU_AOKIT_DIR", "/tmp/diffu_aokit_backbone"))
aokit.compile_and_save(package_dir=str(pkg_dir), exported_program=exported)
# wrapper.state_dict() keys are "_backbone.*" — matching the exported graph's constant FQNs.
compiled = LazyAOTIModel(pkg_dir / "root" / "package.pt2").with_weights(dict(wrapper.state_dict()))
# --- Install onto the REAL backbone --------------------------------------------------------------
# We can't use spaces.aoti_apply(compiled, backbone) verbatim: aoti_apply swaps in a forward whose
# signature matches the exported graph (4 tensors), but every call site does
# `backbone(..., n_content=nc)` (see diffu.model.diffu._denoise_latents). So we install a thin shim
# that accepts + IGNORES n_content (the graph recomputes it) and delegates to the compiled callable.
_install_compiled_forward(backbone, compiled)
backbone._aot_compiled = True # gate flag: compile_blocks() must not ALSO run (they conflict)
print(
f"[studio] AoT backbone compiled — dynamic width w_tok∈[{w_min},{w_max}] "
f"(latent width {patch * w_min}–{patch * w_max}), batch={batch}"
)
return True
class _BackboneExportWrapper(_nn.Module):
"""Tensors-only ``nn.Module`` view of ``Backbone.forward`` for ``torch.export`` (which requires an
``nn.Module``) — recomputes ``n_content`` inside so a graph-freezing Python int becomes a value that
rides the dynamic width symbol. ``_backbone`` is registered as a submodule, so the exported graph
carries the backbone's real weights and its constant FQNs are ``_backbone.*`` — matched by installing
``wrapper.state_dict()`` (see ``LazyAOTIModel.with_weights`` in ``_compile_and_apply``). Valid because
``style_in_context=False`` → context == content, so ``context.shape[1] == n_content`` (asserted upstream).
"""
def __init__(self, backbone: _nn.Module) -> None:
super().__init__()
self._backbone = backbone
def forward(
self,
latent: torch.Tensor,
timestep: torch.Tensor,
context_tokens: torch.Tensor,
pooled: torch.Tensor,
) -> torch.Tensor:
n_content = context_tokens.shape[1] # SymInt under dynamic width; == n_content when context==content
return self._backbone(latent, timestep, context_tokens, pooled, n_content=n_content)
def _install_compiled_forward(backbone: nn.Module, compiled: Any) -> None:
"""Point ``backbone.forward`` at the compiled callable, keeping the ``n_content=`` call-site signature.
The AoT graph recomputes n_content from ``context_tokens.shape[1]``, so the passed-in ``n_content`` is
intentionally dropped. We stash the eager forward so :func:`_fallback_jit` / a reload can restore it.
"""
backbone._eager_forward = backbone.forward # keep for restore / fallback
def _compiled_forward(
latent: torch.Tensor,
timestep: torch.Tensor,
context_tokens: torch.Tensor,
pooled: torch.Tensor,
n_content: int | None = None, # accepted for call-site compatibility; recomputed in the graph
) -> torch.Tensor:
return compiled(latent, timestep, context_tokens, pooled)
backbone.forward = _compiled_forward # type: ignore[method-assign]
# ──────────────────────────────────────────────────────────────────────────────────────────────────
# Shape / input helpers
# ──────────────────────────────────────────────────────────────────────────────────────────────────
def _width_token_bounds(cfg: Config, patch: int) -> tuple[int, int]:
"""(min, max) patch-column count the studio can ask the backbone for, from the width bucketing.
Upstream (:func:`diffu.generate.natural_width`) rounds the canvas to a multiple of 16 and caps it at
``cfg.data.max_line_width``; the latent is ``canvas // vae.downscale_factor`` and the backbone patches
that by ``patch``. The narrowest canvas a line can get is ``round_up(line_height, 16)`` (a 1-char line
still gets a canvas at least as tall as it is wide). We widen the range by a small slack so a bucket at
the exact edge never falls outside the exported symbol.
"""
ds = cfg.vae.downscale_factor
max_canvas = cfg.data.max_line_width
min_canvas = ((cfg.data.line_height + 15) // 16) * 16 # round_up(line_height, 16)
w_max = (max_canvas // ds) // patch
w_min = max(2, (min_canvas // ds) // patch - 1) # -1 slack; Dim min must be ≥2 (0/1 specialize)
return w_min, w_max
def _sample_latent_hw(model: nn.Module, cfg: Config, text: str) -> tuple[int, int]:
"""(h, w) latent grid for ``text`` at the studio's canonical auto-width geometry (matches line.py)."""
from diffu.generate import natural_width
ds = cfg.vae.downscale_factor
canvas_w = natural_width(model.guidance_renderer, text, cfg)
return (cfg.data.line_height // ds, max(1, canvas_w // ds))
def _dummy_style(model: nn.Module, *, size: int) -> torch.Tensor:
"""A neutral ImageNet-normalized ``[1,3,S,S]`` style ref on the model's device/dtype for the capture
denoise. Content of the style is irrelevant — we only need it to produce representative SHAPES."""
import torch
p = next(model.parameters())
return torch.zeros(1, 3, size, size, device=p.device, dtype=p.dtype)
# ──────────────────────────────────────────────────────────────────────────────────────────────────
# Optional levers (clearly gated; each shifts numerics or trades cold-start — measure on the Space)
# ──────────────────────────────────────────────────────────────────────────────────────────────────
def _maybe_quantize_fp8(backbone: nn.Module) -> None:
"""(OPTIONAL) torchao dynamic-FP8 on the transformer BEFORE export — H200 has hardware fp8 matmul.
Off by default (``DIFFU_STUDIO_FP8=1`` to enable). FP8 changes numerics, so a page-CER check on the
Space is required before trusting it. Must run before ``torch.export`` so the exported graph captures
the quantized (fp8) linears. Only the big MMDiT ``transformer`` is quantized; the small conditioning
encoders stay full-precision.
"""
import os
if os.environ.get("DIFFU_STUDIO_FP8") != "1":
return
try:
from torchao.quantization import Float8DynamicActivationFloat8WeightConfig, quantize_
quantize_(backbone.transformer, Float8DynamicActivationFloat8WeightConfig())
print("[studio] applied torchao FP8 dynamic quant to backbone.transformer (pre-export)")
except Exception as exc: # noqa: BLE001 — fp8 is opt-in; never fail the compile on it
print(f"[studio] FP8 quant skipped ({type(exc).__name__}: {exc})")
# ──────────────────────────────────────────────────────────────────────────────────────────────────
# (OPTIONAL) Regional AoT — cut cold-start by compiling only the repeated JointTransformerBlock
# ──────────────────────────────────────────────────────────────────────────────────────────────────
# Full-graph export of the whole 24-layer MMDiT is the biggest compile (and the most exposed to the RoPE
# tracing risk above). The repeated-block trick (what compile_blocks() already does for the JIT) also
# applies to AoT: export ONE `JointTransformerBlock` with dynamic seq length, aoti_compile it, and swap the
# compiled block into all layers. It slashes cold-start and shrinks the export surface (the RoPE freqs are
# passed IN via joint_attention_kwargs, so a single block's graph is smaller). It is more plumbing — you
# must export the block's own forward signature (hidden_states, encoder_hidden_states, temb, image_rotary_emb)
# and rebuild the kwargs at call time — so it is left as a documented follow-up rather than the default path.
# If the whole-backbone export above proves too fragile on the Space, this is the recommended next step.
# ──────────────────────────────────────────────────────────────────────────────────────────────────
# Fallback
# ──────────────────────────────────────────────────────────────────────────────────────────────────
def _fallback_jit(loaded: LoadedModel) -> None:
"""AoT failed — try the eager JIT ``compile_blocks()`` so we still get the −70%-kernel-launch win.
Gated by ``_aot_compiled``: if a compiled forward WAS installed we must NOT also JIT-compile (they
conflict). On ZeroGPU the JIT won't persist across forks (that's why we tried AoT), but within a single
warm request it still helps; off Spaces it's the normal win. Best-effort — never raises.
"""
backbone = loaded.model.backbone
if getattr(backbone, "_aot_compiled", False):
return
try:
backbone.compile_blocks()
except Exception as exc: # noqa: BLE001 — last-resort optimization; eager is always correct
print(f"[studio] JIT compile_blocks fallback skipped ({type(exc).__name__}: {exc}); running eager")
|