multimodalart's picture
multimodalart HF Staff
AOTI-compiled decoder gen path: ~1.26x sampling speedup
a328413 verified
Raw
History Blame Contribute Delete
19.4 kB
"""ZeroGPU AOTI for SenseNova-U1.5-8B-MoT.
One compiled `Qwen3DecoderLayer` gen-path package, reused by all 42 layers.
The per-denoising-step hot path is `language_model.model` (94.9% of wall time on
a 2048x2048 T2I), and inside it all the time is in the 42 identical
`Qwen3DecoderLayer`s. Their image-generation path reads the preallocated flash
KV buffers off the cache object as *attributes*, which `torch.export` cannot
trace, so `sensenova_u1.models.neo_unify.modeling_qwen3` adds a pure-tensor
restatement (`gen_block_core` / `Qwen3GenBlock` / `Qwen3DecoderLayer.gen_core`)
that takes the prefix K/V as explicit tensor arguments. Only `gen_core` is
compiled; the und/prefill paths stay eager.
A Space only calls `maybe_load(model)`; everything else is the build path.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
AOTI = os.environ.get("SN_AOTI", "0") == "1"
AOTI_REPO = os.environ.get("SN_AOTI_REPO", "zerogpu-hacking/sensenova-u1-5-aoti")
AOTI_REPO_TYPE = os.environ.get("SN_AOTI_REPO_TYPE", "model")
# A package is valid for exactly one `<dtype>/torch<X.Y>/sm<cc>/<shape>`, and a
# mismatched one segfaults rather than raising, so `maybe_load` refuses anything
# that is not an exact match.
AOTI_KEY = os.environ.get("SN_AOTI_KEY", "bf16/torch2.8/sm120/dynamic")
AOTI_SHAPE = os.environ.get("SN_AOTI_SHAPE", "dynamic")
AOTI_DURATION = int(os.environ.get("SN_AOTI_DURATION", "1500"))
SUBMODULE = "layers"
METADATA_FILENAME = "metadata.json"
ALIASES_FILENAME = "constant_aliases.json"
# Image-token sequence length. The T2I aspect buckets run 1152x3456 .. 2880x1440
# at patch 16 x merge 2, i.e. 3888..4096 tokens; editing prefixes are longer, so
# leave generous headroom rather than recompiling per bucket.
S_MIN, S_MAX = int(os.environ.get("SN_AOTI_S_MIN", "256")), int(os.environ.get("SN_AOTI_S_MAX", "16384"))
# Text (or text+reference-image) prefix already in the KV cache.
P_MIN, P_MAX = int(os.environ.get("SN_AOTI_P_MIN", "8")), int(os.environ.get("SN_AOTI_P_MAX", "32768"))
_LOADED: set[int] = set()
def trunk_of(model):
"""The `Qwen3Model` whose `.layers` hold the 42 decoder layers."""
return model.language_model.model
def layers_of(model):
return trunk_of(model).layers
def artifact_key() -> str | None:
"""`<dtype>/torch<X.Y>/sm<cc>/<shape>` for this process, or None without CUDA."""
try:
import torch
torch_version = ".".join(torch.__version__.split(".")[:2])
major, minor = torch.cuda.get_device_capability()
except Exception:
return None
return f"bf16/torch{torch_version}/sm{major}{minor}/{AOTI_SHAPE}"
def status() -> str:
return (
f"AOTI **on** · `{AOTI_REPO}` ({AOTI_REPO_TYPE}) · shape `{AOTI_SHAPE}`"
if AOTI
else "AOTI **off** (`SN_AOTI=1` to load compiled decoder layers)"
)
# --------------------------------------------------------------------------- weights
def layer_weights(layer) -> dict:
"""Constant map for one layer.
`state_dict()` is NOT enough: the two rotary `inv_freq` buffers are
registered non-persistent, so they are absent from `state_dict()` but are
real graph constants once `package_constants_in_so=False`. Binding them
partially is a SIGSEGV, not an error, so include every parameter and buffer.
"""
import torch
from torch._functorch._aot_autograd.subclass_parametrization import (
unwrap_tensor_subclass_parameters,
)
module = layer
if any(type(p) is not torch.nn.Parameter for p in layer.parameters()):
from spaces.zero.torch.aoti import _shallow_clone_module
module = _shallow_clone_module(layer)
unwrap_tensor_subclass_parameters(module)
return {**dict(module.named_parameters()), **dict(module.named_buffers())}
def anonymous_constants(exported_program) -> list[str]:
return [
spec.target
for spec in exported_program.graph_signature.input_specs
if spec.kind.name == "CONSTANT_TENSOR" and str(spec.target).startswith("_tensor_constant")
]
def constant_aliases(exported_program) -> dict:
"""`{'_tensor_constant<N>': '<real fqn>'}` for anonymously lifted constants.
Empty for this model as long as everything stays a registered parameter or
buffer; written anyway so the loader can bind if that ever changes.
"""
targets = [
spec.target
for spec in exported_program.graph_signature.input_specs
if spec.kind.name == "CONSTANT_TENSOR"
]
return {f"_tensor_constant{i}": t for i, t in enumerate(targets)}
# --------------------------------------------------------------------------- load side
def patch_layers(model, package_dir, verbose: bool = True) -> int:
"""Point every layer's `gen_core` at the one compiled package.
Weights are read on the first call, not here: this can run at startup, and
ZeroGPU rebinds `param.data` to fresh CUDA tensors when the GPU worker
materializes the model, which would leave patch-time tensors stale.
"""
from spaces.zero.torch.aoti import LazyAOTIModel
package_dir = Path(package_dir)
pt2 = package_dir / "submodules" / SUBMODULE / "package.pt2"
if not pt2.is_file():
raise FileNotFoundError(pt2)
aliases_path = package_dir / "submodules" / SUBMODULE / ALIASES_FILENAME
aliases = json.loads(aliases_path.read_text()) if aliases_path.is_file() else {}
shared = LazyAOTIModel(pt2)
layers = layers_of(model)
for layer in layers:
layer.gen_core = _make_gen_core(shared, pt2, aliases, layer)
if verbose:
print(f"[sn-aoti] {len(layers)} decoder layers patched (gen_core)", flush=True)
return len(layers)
_WARNED: set[str] = set()
def _warn_once(message: str) -> None:
if message not in _WARNED:
_WARNED.add(message)
print(f"[sn-aoti] {message}", flush=True)
def _eager_gen_core(layer, hidden_states, indexes, prefix_k, prefix_v):
# sn_aoti is a top-level module, not part of the sensenova_u1 package,
# so this has to be an absolute import.
from sensenova_u1.models.neo_unify.modeling_qwen3 import gen_block_core
return gen_block_core(layer, hidden_states, indexes, prefix_k, prefix_v)
def _in_range(hidden_states, prefix_k) -> bool:
"""Is this call inside the shapes the package was exported for?
Batch is *specialized* to 1 (only dims 1 were made dynamic), and S/P have
explicit bounds. Everything the demo does sits well inside this, but an
out-of-range call must degrade to eager rather than rely on the compiled
guards -- so check rather than find out.
"""
batch, seq = hidden_states.shape[0], hidden_states.shape[1]
prefix = prefix_k.shape[1]
return batch == 1 and S_MIN <= seq <= S_MAX and P_MIN <= prefix <= P_MAX
def _make_gen_core(shared, pt2, aliases, layer):
"""One layer's replacement `gen_core`, binding its weights on first call."""
bound: dict = {}
state = {"disabled": False}
def gen_core(hidden_states, indexes, prefix_k, prefix_v):
if state["disabled"]:
return _eager_gen_core(layer, hidden_states, indexes, prefix_k, prefix_v)
if not _in_range(hidden_states, prefix_k):
_warn_once(
f"shape outside the compiled range (B={hidden_states.shape[0]}, "
f"S={hidden_states.shape[1]}, P={prefix_k.shape[1]}); running eager"
)
return _eager_gen_core(layer, hidden_states, indexes, prefix_k, prefix_v)
# Any failure here -- unbindable constants, a guard the compiled model
# rejects, anything -- disables the compiled path for this layer and
# runs eager instead. The invariant that matters is that a partially
# bound package is never *executed*; declining to use it satisfies that
# just as well as raising, and keeps the demo serving.
try:
first = not bound
if first:
bound["weights"] = _resolve_constants(shared, pt2, aliases, layer)
return shared(bound["weights"], first, hidden_states, indexes, prefix_k, prefix_v)
except Exception as error:
_warn_once(f"compiled path unusable ({type(error).__name__}: {error}); "
f"this layer runs eager from now on")
state["disabled"] = True
return _eager_gen_core(layer, hidden_states, indexes, prefix_k, prefix_v)
return gen_core
def _resolve_constants(shared, pt2, aliases, layer) -> dict:
"""Bind every compiled constant to one of the layer's own tensors, or refuse.
Name first, then the compile-side alias sidecar. Partial binding is not an
option: an unset constant is a SIGSEGV, not an exception.
"""
weights = layer_weights(layer)
fqns = _constant_fqns(shared, pt2)
resolved = {name: weights[name] for name in fqns if name in weights}
for name in [n for n in fqns if n not in resolved]:
target = aliases.get(name)
if target in weights:
resolved[name] = weights[target]
missing = [n for n in fqns if n not in resolved]
if missing:
raise RuntimeError(
f"{len(missing)} of {len(fqns)} AOTI constants unbindable from the layer's "
f"parameters/buffers: {missing[:8]}. Refusing to run: a partially bound "
f"package dereferences constants nobody set (SIGSEGV rather than an error)."
)
return resolved
def _constant_fqns(shared, pt2):
"""`get_constant_fqns()` needs the package loaded; do it once, off the GPU path."""
import torch
compiled = shared.compiled_model.get()
if compiled is None:
from spaces.zero.torch.aoti import _register_aoti_cleanup
with _register_aoti_cleanup():
compiled = torch._inductor.aoti_load_package(pt2)
shared.compiled_model.set(compiled)
return compiled.get_constant_fqns()
def maybe_load(model, verbose: bool = True) -> bool:
"""Patch the decoder stack with its compiled package, or leave it eager.
Safe to call at startup and safe to call when nothing is published: every
mismatch falls back to eager with one printed line rather than raising.
"""
if not AOTI or id(model) in _LOADED:
return False
key = artifact_key()
if key is None:
print("[sn-aoti] no CUDA device visible; running eager", flush=True)
return False
if key != AOTI_KEY:
print(f"[sn-aoti] this card wants `{key}`, only `{AOTI_KEY}` is published; running eager", flush=True)
return False
try:
from huggingface_hub import snapshot_download
from spaces.zero.torch.aoti import LazyAOTIModel # noqa: F401
except Exception as error:
print(f"[sn-aoti] no AOTI loader here ({type(error).__name__}: {error}); running eager", flush=True)
return False
try:
local = snapshot_download(
repo_id=AOTI_REPO, repo_type=AOTI_REPO_TYPE, allow_patterns=f"{key}/package/*"
)
except Exception as error:
print(f"[sn-aoti] {AOTI_REPO}:{key} unreachable ({type(error).__name__}: {error}); running eager", flush=True)
return False
package_dir = Path(local) / key / "package"
if not package_dir.is_dir():
print(f"[sn-aoti] no package at `{AOTI_REPO}:{key}/package`; running eager", flush=True)
return False
meta_path = package_dir / METADATA_FILENAME
if meta_path.is_file():
meta = json.loads(meta_path.read_text())
mismatch = _metadata_mismatch(meta, model)
if mismatch:
print(f"[sn-aoti] metadata mismatch ({mismatch}); running eager", flush=True)
return False
try:
patch_layers(model, package_dir, verbose=verbose)
except Exception as error:
print(f"[sn-aoti] patching failed ({type(error).__name__}: {error}); running eager", flush=True)
return False
_LOADED.add(id(model))
return True
def _metadata_mismatch(meta: dict, model) -> str | None:
"""Return a human-readable reason the published package does not fit, or None."""
import torch
checks = []
if "torch" in meta:
want = ".".join(str(meta["torch"]).split(".")[:2])
have = ".".join(torch.__version__.split(".")[:2])
checks.append(("torch", want, have))
if "sm" in meta:
major, minor = torch.cuda.get_device_capability()
checks.append(("sm", str(meta["sm"]), f"sm{major}{minor}"))
if "attn_backend" in meta:
# The attention kernel is baked into the graph. If flash-attn later
# becomes importable the eager path would switch to it while this
# package stays on SDPA: different numerics, and possibly slower.
checks.append(("attn_backend", str(meta["attn_backend"]),
__import__("sensenova_u1").effective_attn_backend()))
if "num_hidden_layers" in meta:
checks.append(("num_hidden_layers", str(meta["num_hidden_layers"]),
str(len(layers_of(model)))))
if "hidden_size" in meta:
checks.append(("hidden_size", str(meta["hidden_size"]),
str(model.language_model.config.hidden_size)))
for name, want, have in checks:
if want != have:
return f"{name}: package {want} != runtime {have}"
return None
# --------------------------------------------------------------------------- build side
def capture_layer_call(model, tokenizer, prompt: str, image_size=(2048, 2048), steps: int = 2):
"""Run a couple of real denoising steps and keep one layer-0 `gen_core` call.
The inputs must come from a real run: `prefix_k`/`prefix_v` are slices of the
preallocated flash KV cache built by `prepare_flash_kv_cache`, and their
prefix length depends on the tokenized prompt.
"""
import torch
layers = layers_of(model)
layer0 = layers[0]
original = layer0.gen_core
captured = {}
def recording(hidden_states, indexes, prefix_k, prefix_v):
if "args" not in captured:
captured["args"] = (hidden_states, indexes, prefix_k, prefix_v)
return original(hidden_states, indexes, prefix_k, prefix_v)
layer0.gen_core = recording
try:
# Same knobs the production demo uses, so the captured shapes and the
# baked-in graph match what will actually run.
with torch.inference_mode():
model.t2i_generate(
tokenizer, prompt, image_size=image_size, cfg_scale=4.0,
cfg_norm="none", timestep_shift=3.0, cfg_interval=(0.0, 1.0),
num_steps=steps, batch_size=1, seed=42, think_mode=False,
)
finally:
layer0.gen_core = original
if "args" not in captured:
raise RuntimeError("gen_core was never called - the fast denoise path did not engage.")
args = tuple(t.detach().clone() for t in captured["args"])
print(f"[sn-aoti] captured shapes: "
f"{[tuple(t.shape) for t in args]} dtypes={[str(t.dtype) for t in args]}", flush=True)
return args
def export_layer(model, args, shape: str = AOTI_SHAPE):
"""Export layer 0's gen core, with the image-token and prefix lengths dynamic."""
import torch
from sensenova_u1.models.neo_unify.modeling_qwen3 import Qwen3GenBlock
layer0 = layers_of(model)[0]
shim = Qwen3GenBlock(layer0)
# The shim shares the layer's submodules under the same names, so exported
# constant FQNs are exactly the layer's own parameter/buffer names -- which
# is what makes one package reusable by all 42 layers.
sd_layer, sd_shim = set(layer0.state_dict()), set(shim.state_dict())
assert sd_layer == sd_shim, (sorted(sd_layer - sd_shim)[:5], sorted(sd_shim - sd_layer)[:5])
if shape == "dynamic":
S = torch.export.Dim("S", min=S_MIN, max=S_MAX)
P = torch.export.Dim("P", min=P_MIN, max=P_MAX)
dynamic_shapes = (
{1: S}, # hidden_states [B, S, C]
{1: S}, # indexes [3, S]
{1: P}, # prefix_k [B, P, H_kv, D]
{1: P}, # prefix_v
)
else:
dynamic_shapes = None
print(f"[sn-aoti] exporting Qwen3GenBlock, shapes={shape} ...", flush=True)
with torch.inference_mode():
ep = torch.export.export(shim, args, {}, dynamic_shapes=dynamic_shapes)
anon = anonymous_constants(ep)
if anon:
print(f"[sn-aoti] WARNING {len(anon)} anonymously lifted constants: {anon[:6]}; "
f"the alias sidecar will carry their real names", flush=True)
return ep
def compile_and_save(ep, destination, model, args, extra: dict | None = None) -> Path:
"""Inductor-compile into `<destination>/package/submodules/layers/package.pt2`."""
import torch
import spaces
package_dir = Path(destination) / "package"
print("[sn-aoti] inductor compile (minutes) ...", flush=True)
spaces.aoti_compile_and_save(package_dir, ep, submodule=SUBMODULE)
subdir = package_dir / "submodules" / SUBMODULE
aliases = constant_aliases(ep)
if aliases:
(subdir / ALIASES_FILENAME).write_text(json.dumps(aliases, indent=2))
major, minor = torch.cuda.get_device_capability()
llm_cfg = model.language_model.config
meta = {
"model_id": "sensenova/SenseNova-U1.5-8B-MoT",
"module": "language_model.model.layers[*].gen_core "
"(Qwen3DecoderLayer image-generation path)",
"compiled_class": "Qwen3GenBlock",
"torch": torch.__version__,
"cuda": torch.version.cuda,
"sm": f"sm{major}{minor}",
"device": torch.cuda.get_device_name(0),
"dtype": "bfloat16",
"attn_backend": __import__("sensenova_u1").effective_attn_backend(),
"num_hidden_layers": llm_cfg.num_hidden_layers,
"hidden_size": llm_cfg.hidden_size,
"shape": AOTI_SHAPE,
"signature": ["hidden_states[B,S,C]", "indexes[3,S]",
"prefix_k[B,P,H_kv,D]", "prefix_v[B,P,H_kv,D]"],
"dynamic_dims": {"S": [S_MIN, S_MAX], "P": [P_MIN, P_MAX]},
"example_shapes": [list(t.shape) for t in args],
"key": artifact_key(),
"anonymous_constants": anonymous_constants(ep),
}
if extra:
meta.update(extra)
(package_dir / METADATA_FILENAME).write_text(json.dumps(meta, indent=2))
files = sorted(str(p.relative_to(package_dir)) for p in package_dir.rglob("*") if p.is_file())
total = sum(p.stat().st_size for p in package_dir.rglob("*") if p.is_file())
print(f"[sn-aoti] package written ({total/1e6:.1f} MB): {files}", flush=True)
return package_dir
def upload(package_dir, key: str, token: str | None = None) -> str:
"""Push the package under its key. CPU work - never inside GPU time."""
from huggingface_hub import HfApi
token = token or os.environ.get("HF_TOKEN")
if not token:
raise RuntimeError("HF_TOKEN is needed to push the AOTI package.")
api = HfApi(token=token)
api.create_repo(repo_id=AOTI_REPO, repo_type=AOTI_REPO_TYPE, private=False, exist_ok=True)
api.upload_folder(
folder_path=str(package_dir), path_in_repo=f"{key}/package",
repo_id=AOTI_REPO, repo_type=AOTI_REPO_TYPE,
commit_message=f"AOTI package for {key}",
)
return f"https://huggingface.co/{AOTI_REPO}/tree/main/{key}"