Spaces:
Running on Zero
Running on Zero
| """AoTI compile rig for `hugging-apps/moverse` (MoVerse Pano DiT, Stage I). | |
| Compiles the two repeated blocks of the **LoRA-fused** FLUX.1-Fill-dev transformer used by | |
| `hugging-apps/moverse` with `torch.export` + AOTInductor, and publishes the kernels to | |
| `hugging-apps/moverse-pano-aoti` in the layout `spaces.aoti_blocks_load()` expects: | |
| <BlockName>/package.pt2 | |
| The serving Space then needs a single call at boot: | |
| spaces.aoti_blocks_load(PIPE.transformer, "hugging-apps/moverse-pano-aoti") | |
| Why blocks and not the whole transformer: the 19 `FluxTransformerBlock` + 38 | |
| `FluxSingleTransformerBlock` instances account for essentially all of the DiT compute, one | |
| `.pt2` per *class* is reused by every instance (2 exports instead of 57, tiny artifacts), and | |
| marking the sequence dim dynamic keeps a single artifact valid across the Space's whole | |
| 512-1024 panorama-height range. | |
| The `.pt2` files hold **kernels only** (`package_constants_in_so=False`); weights are supplied | |
| at load time from the live module's `state_dict()`, so the serving Space keeps its own | |
| normally-loaded LoRA-fused checkpoint and the eager path still works. | |
| Both Spaces must run identical hardware (ZeroGPU / Blackwell sm_120) and identical pinned | |
| torch + diffusers — pinned in `requirements.txt` on both sides. | |
| """ | |
| import os | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| import gc # noqa: E402 | |
| import json # noqa: E402 | |
| import shutil # noqa: E402 | |
| import time # noqa: E402 | |
| import traceback # noqa: E402 | |
| from pathlib import Path # noqa: E402 | |
| import spaces # noqa: E402 (must precede torch) | |
| import torch # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| from huggingface_hub import hf_hub_download, upload_folder # noqa: E402 | |
| from diffusers import FluxTransformer2DModel # noqa: E402 | |
| # --------------------------------------------------------------------------- | |
| # Config — mirrors hugging-apps/moverse | |
| # --------------------------------------------------------------------------- | |
| MOVERSE_REPO = "Orange-3DV-Team/MoVerse" | |
| FLUX_REPO = "black-forest-labs/FLUX.1-Fill-dev" | |
| AOTI_REPO = os.environ.get("AOTI_REPO", "hugging-apps/moverse-pano-aoti") | |
| DTYPE = torch.bfloat16 | |
| DEVICE = "cuda" | |
| BLOCK_NAMES = ["FluxTransformerBlock", "FluxSingleTransformerBlock"] | |
| TXT_SEQ = 512 # FluxFillPipeline max_sequence_length — static across the Space | |
| # The serving Space exposes panorama heights 512..1024 (step 64, snapped to /16), width = 2h. | |
| # Latent token count = (h/16) * (w/16) -> 2048 .. 8192. | |
| COMPILE_HEIGHT = 960 # the Space default | |
| VERIFY_HEIGHTS = [512, 1024] # dynamic-shape check at both ends of the range | |
| WORK = Path(os.environ.get("AOTI_WORKDIR", "/home/user/aoti-work")) | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| # --------------------------------------------------------------------------- | |
| # LoRA-fused transformer (module scope, eager -> cuda; ZeroGPU packs it to disk) | |
| # --------------------------------------------------------------------------- | |
| print("[boot] downloading MoVerse Stage I LoRA ...", flush=True) | |
| _lora_path = hf_hub_download( | |
| MOVERSE_REPO, "gimbal360/pytorch_lora_weights.safetensors", token=HF_TOKEN | |
| ) | |
| print("[boot] loading FLUX.1-Fill-dev transformer (~24 GB) ...", flush=True) | |
| _t0 = time.perf_counter() | |
| TRANSFORMER = FluxTransformer2DModel.from_pretrained( | |
| FLUX_REPO, subfolder="transformer", torch_dtype=DTYPE, token=HF_TOKEN | |
| ) | |
| print(f"[boot] transformer loaded ({time.perf_counter() - _t0:.0f}s)", flush=True) | |
| print("[boot] loading + fusing pano LoRA ...", flush=True) | |
| TRANSFORMER.load_lora_adapter(_lora_path) # prefix="transformer", as the pipeline does | |
| TRANSFORMER.fuse_lora(lora_scale=1.0) | |
| TRANSFORMER = TRANSFORMER.to(DEVICE).eval() | |
| TRANSFORMER.requires_grad_(False) | |
| gc.collect() | |
| print("[boot] fused transformer ready", flush=True) | |
| BLOCK_LISTS = { | |
| "FluxTransformerBlock": TRANSFORMER.transformer_blocks, | |
| "FluxSingleTransformerBlock": TRANSFORMER.single_transformer_blocks, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Synthetic transformer inputs (shapes/dtypes matching FluxFillPipeline exactly) | |
| # --------------------------------------------------------------------------- | |
| def _latent_image_ids(lat_h: int, lat_w: int) -> torch.Tensor: | |
| """FluxFillPipeline._prepare_latent_image_ids, verbatim.""" | |
| ids = torch.zeros(lat_h, lat_w, 3) | |
| ids[..., 1] = ids[..., 1] + torch.arange(lat_h)[:, None] | |
| ids[..., 2] = ids[..., 2] + torch.arange(lat_w)[None, :] | |
| return ids.reshape(lat_h * lat_w, 3).to(device=DEVICE, dtype=DTYPE) | |
| def _transformer_inputs(pano_height: int) -> dict: | |
| """One denoising-step input set for a `pano_height x 2*pano_height` ERP.""" | |
| height = max(256, (int(pano_height) // 16) * 16) | |
| width = height * 2 | |
| lat_h, lat_w = height // 16, width // 16 | |
| seq = lat_h * lat_w | |
| cfg = TRANSFORMER.config | |
| gen = torch.Generator(device=DEVICE).manual_seed(0) | |
| def _randn(*shape): | |
| return torch.randn(*shape, generator=gen, device=DEVICE, dtype=DTYPE) | |
| # hidden_states = cat(latents[64ch], masked_image_latents[64ch] + mask[256ch]) = in_channels | |
| return { | |
| "hidden_states": _randn(1, seq, cfg.in_channels), | |
| "encoder_hidden_states": _randn(1, TXT_SEQ, cfg.joint_attention_dim), | |
| "pooled_projections": _randn(1, cfg.pooled_projection_dim), | |
| "timestep": torch.full((1,), 0.5, device=DEVICE, dtype=DTYPE), | |
| "guidance": torch.full((1,), 30.0, device=DEVICE, dtype=torch.float32), | |
| "img_ids": _latent_image_ids(lat_h, lat_w), | |
| "txt_ids": torch.zeros(TXT_SEQ, 3, device=DEVICE, dtype=DTYPE), | |
| "joint_attention_kwargs": None, | |
| "return_dict": False, | |
| } | |
| def _capture_block_inputs(block_name: str, pano_height: int): | |
| """Run one real transformer forward and intercept the first block of `block_name`.""" | |
| block = BLOCK_LISTS[block_name][0] | |
| with torch.no_grad(): | |
| with spaces.aoti_capture(block) as captured: | |
| TRANSFORMER(**_transformer_inputs(pano_height)) | |
| if not captured.kwargs and not captured.args: | |
| raise RuntimeError(f"failed to capture inputs for {block_name}") | |
| return captured.args, captured.kwargs | |
| def _dynamic_shapes(kwargs: dict) -> dict: | |
| """Mark every sequence-length position dynamic. | |
| `Dim.AUTO` rather than an explicit ranged `Dim`: export then derives and unifies the | |
| constraints itself (including `rotary_seq == TXT_SEQ + img_seq`). An explicit ranged Dim | |
| trips `ConstraintViolationError` on the tautological guards attention emits. | |
| """ | |
| auto = torch.export.Dim.AUTO | |
| spec: dict = {} | |
| for name in kwargs: | |
| if name == "hidden_states": | |
| spec[name] = {1: auto} # image tokens | |
| elif name == "image_rotary_emb": | |
| spec[name] = ({0: auto}, {0: auto}) # TXT_SEQ + image tokens | |
| else: | |
| spec[name] = None # encoder_hidden_states / temb are fixed-size | |
| return spec | |
| def _err(ref, got) -> tuple[float, float]: | |
| if isinstance(ref, (tuple, list)): | |
| pairs = [_err(a, b) for a, b in zip(ref, got)] | |
| return max(p[0] for p in pairs), max(p[1] for p in pairs) | |
| d = (ref.float() - got.float()).abs() | |
| scale = ref.float().abs().mean().clamp_min(1e-6) | |
| return d.max().item(), (d.mean() / scale).item() | |
| def _pt2_path(block_name: str) -> Path: | |
| return WORK / "package" / "submodules" / block_name / "package.pt2" | |
| # --------------------------------------------------------------------------- | |
| # Compile | |
| # --------------------------------------------------------------------------- | |
| INDUCTOR_TUNED = { | |
| "max_autotune": True, | |
| "coordinate_descent_tuning": True, | |
| "triton.cudagraphs": False, # AoTI packages are replayed across shapes/workers | |
| } | |
| def _compile_one( | |
| block_name: str, | |
| compile_height: int, | |
| auto_upload: bool = True, | |
| max_autotune: bool = True, | |
| ): | |
| """Export + AOTInductor-compile one block class, verify it, write the .pt2. | |
| Uploads from inside the GPU worker when `auto_upload` — the compiled artifact only lives in | |
| the container filesystem, so publishing straight away is one less thing to lose. | |
| """ | |
| from spaces.zero.torch.aoti import LazyAOTIModel | |
| log: list[str] = [] | |
| def emit(msg): | |
| log.append(msg) | |
| print(f"[compile] {msg}", flush=True) | |
| return "\n".join(log) | |
| block = BLOCK_LISTS[block_name][0] | |
| yield emit(f"{block_name}: capturing inputs at {compile_height}x{2 * compile_height} ...") | |
| t0 = time.perf_counter() | |
| args, kwargs = _capture_block_inputs(block_name, compile_height) | |
| shapes = {k: tuple(v.shape) for k, v in kwargs.items() if torch.is_tensor(v)} | |
| yield emit(f" captured in {time.perf_counter() - t0:.1f}s: {shapes}") | |
| yield emit(" torch.export.export (dynamic image sequence) ...") | |
| t0 = time.perf_counter() | |
| with torch.no_grad(): | |
| ep = torch.export.export(block, args, kwargs, dynamic_shapes=_dynamic_shapes(kwargs)) | |
| yield emit(f" exported in {time.perf_counter() - t0:.1f}s") | |
| configs = INDUCTOR_TUNED if max_autotune else None | |
| yield emit(f" aoti_compile_and_save (slow) inductor_configs={configs} ...") | |
| t0 = time.perf_counter() | |
| out_dir = _pt2_path(block_name).parent | |
| shutil.rmtree(out_dir, ignore_errors=True) | |
| spaces.aoti_compile_and_save(WORK / "package", ep, configs, submodule=block_name) | |
| pt2 = _pt2_path(block_name) | |
| yield emit( | |
| f" compiled in {time.perf_counter() - t0:.0f}s -> {pt2} ({pt2.stat().st_size / 1e6:.1f} MB)" | |
| ) | |
| # --- verify: kernels-only .pt2 + live state_dict must reproduce the eager output --- | |
| runner = LazyAOTIModel(str(pt2)).with_weights(block.state_dict()) | |
| for h in [compile_height, *VERIFY_HEIGHTS]: | |
| _, kw = _capture_block_inputs(block_name, h) | |
| with torch.no_grad(): | |
| ref = block(**kw) | |
| got = runner(**kw) | |
| abs_err, rel_err = _err(ref, got) | |
| verdict = "ok" if rel_err < 0.02 else "SUSPECT" | |
| yield emit( | |
| f" verify h={h} seq={tuple(kw['hidden_states'].shape)[1]}: " | |
| f"max|Δ|={abs_err:.4g} rel={rel_err:.2e} [{verdict}]" | |
| ) | |
| if auto_upload: | |
| yield emit(f" uploading to {AOTI_REPO} ...") | |
| yield emit(" " + upload().replace("\n", "\n ")) | |
| yield emit(f"{block_name}: done") | |
| def compile_dual(compile_height, auto_upload, max_autotune): | |
| yield from _compile_one("FluxTransformerBlock", compile_height, auto_upload, max_autotune) | |
| def compile_single(compile_height, auto_upload, max_autotune): | |
| yield from _compile_one("FluxSingleTransformerBlock", compile_height, auto_upload, max_autotune) | |
| def compile_all(compile_height, auto_upload, max_autotune): | |
| done: list[str] = [] | |
| for name in BLOCK_NAMES: | |
| chunk = "" | |
| for chunk in _compile_one(name, compile_height, auto_upload, max_autotune): | |
| yield "\n".join([*done, chunk]) | |
| done.append(chunk) | |
| yield "\n".join([*done, "", "All blocks compiled."]) | |
| # --------------------------------------------------------------------------- | |
| # Publish | |
| # --------------------------------------------------------------------------- | |
| CARD = f"""--- | |
| library_name: aoti | |
| tags: | |
| - zerogpu | |
| - aoti | |
| - flux | |
| base_model: {FLUX_REPO} | |
| --- | |
| # MoVerse Pano DiT — AoTI kernels | |
| AOTInductor-compiled repeated blocks for the LoRA-fused **FLUX.1-Fill-dev** transformer of | |
| [`hugging-apps/moverse`](https://huggingface.co/spaces/hugging-apps/moverse) (MoVerse Stage I, | |
| `{MOVERSE_REPO}` `gimbal360` LoRA fused at scale 1.0). | |
| Built by | |
| [`hugging-apps/moverse-aoti-compile`](https://huggingface.co/spaces/hugging-apps/moverse-aoti-compile). | |
| ```python | |
| import spaces | |
| spaces.aoti_blocks_load(pipe.transformer, "{AOTI_REPO}") | |
| ``` | |
| The archives contain **compiled kernels only** — no weights. Weights are bound at load time from | |
| the live module's `state_dict()`, so the serving Space keeps its own checkpoint and the eager | |
| path stays intact. | |
| | | | | |
| |---|---| | |
| | Hardware | ZeroGPU · NVIDIA RTX PRO 6000 Blackwell (sm_120) | | |
| | torch | 2.11.0 | | |
| | diffusers | 0.39.0 | | |
| | dtype | bfloat16 | | |
| | Inductor | `max_autotune` + `coordinate_descent_tuning` | | |
| | Dynamic | image sequence length (panorama height 512–1024, 2:1 ERP) | | |
| | Text sequence | 512 (static) | | |
| Measured on ZeroGPU at 960x1920 (7200 image tokens, 6 iterations): | |
| **1445 ms/step eager -> 831 ms/step, 1.74x.** | |
| Kernels are hardware- and version-specific: a Space loading them must run the same ZeroGPU | |
| hardware and the same pinned torch / diffusers. | |
| """ | |
| def upload(): | |
| pkg_dir = WORK / "package" / "submodules" | |
| if not pkg_dir.is_dir(): | |
| return "Nothing to upload — compile first." | |
| found = sorted(p.name for p in pkg_dir.iterdir() if (p / "package.pt2").is_file()) | |
| if not found: | |
| return "Nothing to upload — compile first." | |
| missing = [n for n in BLOCK_NAMES if n not in found] | |
| (pkg_dir / "README.md").write_text(CARD) | |
| (pkg_dir / "build_info.json").write_text( | |
| json.dumps( | |
| { | |
| "torch": torch.__version__, | |
| "blocks": found, | |
| "compile_height": COMPILE_HEIGHT, | |
| "inductor_configs": INDUCTOR_TUNED, | |
| "txt_seq": TXT_SEQ, | |
| "base_model": FLUX_REPO, | |
| "lora": f"{MOVERSE_REPO}/gimbal360/pytorch_lora_weights.safetensors", | |
| }, | |
| indent=2, | |
| ) | |
| ) | |
| try: | |
| commit = upload_folder( | |
| repo_id=AOTI_REPO, | |
| folder_path=str(pkg_dir), | |
| repo_type="model", | |
| token=HF_TOKEN, | |
| commit_message=f"AoTI kernels: {', '.join(found)}", | |
| ) | |
| except Exception: | |
| return f"Upload failed:\n{traceback.format_exc()}" | |
| msg = f"Uploaded {found} to {AOTI_REPO}\n{commit.commit_url}" | |
| if missing: | |
| msg += f"\n\nWARNING: not compiled yet, so not uploaded: {missing}" | |
| return msg | |
| # --------------------------------------------------------------------------- | |
| # Benchmark — eager vs the published artifacts | |
| # --------------------------------------------------------------------------- | |
| def benchmark(pano_height: int, source: str = "hub", iters: int = 6): | |
| """Time the whole DiT eager, then again with the blocks patched. | |
| `source="local"` patches from the container's freshly compiled artifacts, so a candidate | |
| build can be measured before it is published. | |
| """ | |
| from spaces.zero.torch.aoti import LazyAOTIModel | |
| inputs = _transformer_inputs(pano_height) | |
| iters = int(iters) | |
| def timeit(tag): | |
| with torch.no_grad(): | |
| TRANSFORMER(**inputs) # warmup / lazy .so load | |
| torch.cuda.synchronize() | |
| t0 = time.perf_counter() | |
| for _ in range(iters): | |
| TRANSFORMER(**inputs) | |
| torch.cuda.synchronize() | |
| dt = (time.perf_counter() - t0) / iters | |
| print(f"[bench] {tag}: {dt * 1000:.0f} ms/step", flush=True) | |
| return dt | |
| eager = timeit("eager") | |
| try: | |
| if source == "local": | |
| patched = [] | |
| for name in BLOCK_NAMES: | |
| pt2 = _pt2_path(name) | |
| if not pt2.is_file(): | |
| continue | |
| lazy = LazyAOTIModel(str(pt2)) | |
| for block in TRANSFORMER.modules(): | |
| if block.__class__.__name__ == name: | |
| spaces.aoti_patch(block, lazy) | |
| patched.append(name) | |
| if not patched: | |
| return f"eager: {eager * 1000:.0f} ms/step\nno local artifacts in {WORK}" | |
| else: | |
| spaces.aoti_blocks_load(TRANSFORMER, AOTI_REPO) | |
| except Exception: | |
| return ( | |
| f"eager: {eager * 1000:.0f} ms/step\naoti load ({source}) failed:\n" | |
| f"{traceback.format_exc()}" | |
| ) | |
| aoti = timeit("aoti") | |
| return ( | |
| f"{pano_height}x{2 * pano_height} · seq={tuple(inputs['hidden_states'].shape)[1]} · " | |
| f"{iters} iters · source={source}\n" | |
| f"eager : {eager * 1000:7.0f} ms/step\n" | |
| f"aoti : {aoti * 1000:7.0f} ms/step\n" | |
| f"speedup: {eager / aoti:.2f}x" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # UI | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(title="MoVerse AoTI compile") as demo: | |
| gr.Markdown( | |
| f""" | |
| # MoVerse Pano DiT — AoTI compile rig | |
| Offline AOTInductor compilation for | |
| [`hugging-apps/moverse`](https://huggingface.co/spaces/hugging-apps/moverse): exports the | |
| LoRA-fused FLUX.1-Fill-dev repeated blocks and publishes the kernels to | |
| [`{AOTI_REPO}`](https://huggingface.co/{AOTI_REPO}). | |
| **Run order:** `1 · Compile both blocks` → `2 · Upload to Hub` → `3 · Benchmark`. | |
| Each block takes several minutes of GPU time. Artifacts live in the container until uploaded, | |
| so upload before the Space sleeps. | |
| """ | |
| ) | |
| with gr.Row(): | |
| height = gr.Slider( | |
| 512, 1024, value=COMPILE_HEIGHT, step=64, label="Compile at panorama height" | |
| ) | |
| auto_upload = gr.Checkbox(value=True, label="Upload each block as soon as it compiles") | |
| max_autotune = gr.Checkbox(value=True, label="max-autotune (slower compile, ~1.5x faster kernels)") | |
| with gr.Row(): | |
| btn_all = gr.Button("1 · Compile both blocks", variant="primary") | |
| btn_dual = gr.Button("FluxTransformerBlock only") | |
| btn_single = gr.Button("FluxSingleTransformerBlock only") | |
| with gr.Row(): | |
| btn_up = gr.Button("2 · Upload to Hub", variant="primary") | |
| source = gr.Radio(["hub", "local"], value="hub", label="Benchmark artifacts from") | |
| btn_bench = gr.Button("3 · Benchmark eager vs AoTI") | |
| out = gr.Textbox(label="Log", lines=24, max_lines=40) | |
| compile_inputs = [height, auto_upload, max_autotune] | |
| btn_all.click(compile_all, inputs=compile_inputs, outputs=[out], api_name="compile_all") | |
| btn_dual.click(compile_dual, inputs=compile_inputs, outputs=[out], api_name="compile_dual") | |
| btn_single.click(compile_single, inputs=compile_inputs, outputs=[out], api_name="compile_single") | |
| btn_up.click(upload, outputs=[out], api_name="upload") | |
| btn_bench.click(benchmark, inputs=[height, source], outputs=[out], api_name="benchmark") | |
| if __name__ == "__main__": | |
| demo.queue(max_size=4).launch(show_error=True) | |