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")