Files
hao-ai-lab-FastVideo/tests/local_tests/helpers/magi_human_upstream.py

338 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Stubs to make upstream daVinci-MagiHuman code importable in-process.
The upstream DiT (daVinci-MagiHuman/inference/model/dit/dit_module.py) hard-
imports SandAI's internal `magi_compiler` + a distributed-runtime init
that requires `torchrun`. Neither is available in a single-process
parity test. This module installs the minimum stubs to let the upstream
DiT load and run on a single GPU with cp_world_size == 1 (which makes
Ulysses's scatter/gather a no-op).
Use:
from tests.local_tests.helpers.magi_human_upstream import (
install_stubs, load_upstream_dit,
)
install_stubs()
model = load_upstream_dit(base_shard_dir, device=torch.device("cuda"))
"""
from __future__ import annotations
import importlib
import sys
import types
from pathlib import Path
from typing import Callable
# ---------------------------------------------------------------------------
# magi_compiler stubs — identity decorators.
# ---------------------------------------------------------------------------
def _install_magi_compiler_stub() -> None:
"""Stub magi_compiler + register the `torch.ops.infra.*` ops upstream
calls via `torch.ops`.
Upstream code decorates plain Python fns with
`@magi_register_custom_op(name="infra::flash_attn_func", ...)` and
then calls them as `torch.ops.infra.flash_attn_func(...)`. Our stub
decorator has to both (a) preserve the decorated fn for direct call
sites and (b) register the fn under the advertised torch.ops
namespace so `torch.ops.infra.*` resolves.
For parity testing we route `infra::flash_attn_func` through
`F.scaled_dot_product_attention`, matching the FastVideo DiT's
kernel choice so drift measured in this test is architectural,
not kernel-dependent.
"""
if "magi_compiler" in sys.modules:
return
import torch
import torch.nn.functional as F
pkg = types.ModuleType("magi_compiler")
def magi_compile(config_patch=None):
def decorator(cls_or_fn):
return cls_or_fn
return decorator
# Create one Library per (namespace, schema) pair. Track by namespace
# so we don't define the same op twice on re-import.
_libs: dict[str, torch.library.Library] = {}
_defined: set[tuple[str, str]] = set()
def _sdpa_flash_attn_func(q, k, v):
# Upstream shape: [batch=1, L, H, D]. SDPA expects [B, H, L, D]
# and no native GQA; expand K/V to match Q heads.
num_heads_q = q.shape[2]
num_heads_kv = k.shape[2]
if num_heads_q != num_heads_kv:
assert num_heads_q % num_heads_kv == 0
repeat = num_heads_q // num_heads_kv
k = k.repeat_interleave(repeat, dim=2)
v = v.repeat_interleave(repeat, dim=2)
q2 = q.transpose(1, 2).contiguous()
k2 = k.transpose(1, 2).contiguous()
v2 = v.transpose(1, 2).contiguous()
out = F.scaled_dot_product_attention(q2, k2, v2)
return out.transpose(1, 2).contiguous()
def _sdpa_segments(q, k, v, q_ranges, k_ranges):
# Upstream flex op shape: [L, H, D]. FFA accumulates each block's
# independently normalized attention output into the destination query
# slice. This SDPA fallback mirrors the accumulator semantics for
# SR-1080p parity tests without requiring SandAI's MagiAttention wheel.
out = torch.zeros(
q.shape[0],
q.shape[1],
q.shape[2],
dtype=q.dtype,
device=q.device,
)
num_heads_q = q.shape[1]
num_heads_kv = k.shape[1]
for q_range, k_range in zip(q_ranges.tolist(), k_ranges.tolist()):
qs, qe = int(q_range[0]), int(q_range[1])
ks, ke = int(k_range[0]), int(k_range[1])
q_block = q[qs:qe]
k_block = k[ks:ke]
v_block = v[ks:ke]
if num_heads_q != num_heads_kv:
assert num_heads_q % num_heads_kv == 0
repeat = num_heads_q // num_heads_kv
k_block = k_block.repeat_interleave(repeat, dim=1)
v_block = v_block.repeat_interleave(repeat, dim=1)
block_out = F.scaled_dot_product_attention(
q_block.transpose(0, 1).unsqueeze(0).contiguous(),
k_block.transpose(0, 1).unsqueeze(0).contiguous(),
v_block.transpose(0, 1).unsqueeze(0).contiguous(),
)
out[qs:qe] += block_out.squeeze(0).transpose(0, 1).contiguous()
lse = torch.empty((q.shape[0], q.shape[1]), dtype=torch.float32, device=q.device)
return out, lse
def magi_register_custom_op(name=None,
mutates_args=(),
infer_output_meta_fn=None,
is_subgraph_boundary=False,
**kwargs):
def decorator(fn):
if not name:
return fn
namespace, op_name = name.split("::", 1)
if namespace not in _libs:
_libs[namespace] = torch.library.Library(namespace, "FRAGMENT")
if (namespace, op_name) in _defined:
# Already registered in a previous test run — reuse.
return fn
# Route known ops through SDPA; leave unknown ones as direct fn.
returns = "(Tensor, Tensor)" if op_name == "flex_flash_attn_func" else "Tensor"
schema_name = f"{op_name}({_infer_schema(fn)}) -> {returns}"
try:
_libs[namespace].define(schema_name)
except Exception:
pass
if op_name == "flash_attn_func":
torch.library.impl(_libs[namespace], op_name, "CUDA")(_sdpa_flash_attn_func)
torch.library.impl(_libs[namespace], op_name, "CPU")(_sdpa_flash_attn_func)
elif op_name == "flex_flash_attn_func":
torch.library.impl(_libs[namespace], op_name, "CUDA")(_sdpa_segments)
else:
# For ops we don't care about (compile-only wrappers), the
# Python fn path inside the module body is used directly —
# we just need `torch.ops.<ns>.<op>` to exist so module-
# load-time attribute lookups succeed.
torch.library.impl(_libs[namespace], op_name, "CUDA")(fn)
_defined.add((namespace, op_name))
return fn
return decorator
pkg.magi_compile = magi_compile
sys.modules["magi_compiler"] = pkg
api = types.ModuleType("magi_compiler.api")
api.magi_register_custom_op = magi_register_custom_op
sys.modules["magi_compiler.api"] = api
pkg.api = api
config_mod = types.ModuleType("magi_compiler.config")
class CompileConfig:
class offload_config: # pragma: no cover - pass-through
gpu_resident_weight_ratio = 1.0
config_mod.CompileConfig = CompileConfig
sys.modules["magi_compiler.config"] = config_mod
pkg.config = config_mod
def _infer_schema(fn) -> str:
"""Return a minimal torch.library schema string for the given fn.
For our stub we just need *something* parseable; all real ops we
care about take `(q, k, v)` or `(q, k, v, q_ranges, k_ranges)` or
variants. Use generic `Tensor a, Tensor b, ...` arg names.
"""
import inspect
sig = inspect.signature(fn)
parts = []
for i, name in enumerate(sig.parameters):
arg_name = name if name.isidentifier() else f"a{i}"
parts.append(f"Tensor {arg_name}")
return ", ".join(parts)
# ---------------------------------------------------------------------------
# distributed / CP stubs — single-GPU, cp_world_size == 1.
# ---------------------------------------------------------------------------
def _install_distributed_stubs() -> None:
"""Monkey-patch upstream distributed + parallelism modules for cp=1."""
# inference.infra.distributed.*
# The real module requires NCCL / parallel_state to be initialized
# from torchrun; here we short-circuit the handful of getters the DiT
# actually calls.
import inference.infra.distributed as dist_mod
dist_mod.get_cp_world_size = lambda: 1
dist_mod.get_cp_group = lambda: None
dist_mod.get_cp_rank = lambda: 0
dist_mod.get_tp_rank = lambda: 0
dist_mod.get_pp_rank = lambda: 0
# inference.infra.parallelism.*
# At cp_world_size=1, scatter/gather are trivially no-ops.
import inference.infra.parallelism.gather_scatter_primitive as gs
def _scatter_noop(x, cp_split_sizes, group=None):
return x
def _gather_noop(x, cp_split_sizes, group=None):
return x
gs.scatter_to_context_parallel_region = _scatter_noop
gs.gather_from_context_parallel_region = _gather_noop
# Re-import ulysses_scheduler with patched scatter/gather in place.
import inference.infra.parallelism.ulysses_scheduler as us
us.scatter_to_context_parallel_region = _scatter_noop
us.gather_from_context_parallel_region = _gather_noop
us.get_cp_world_size = lambda: 1
us.get_cp_group = lambda: None
# all-to-all primitives used by flash_attn_with_cp. At cp=1 they are
# entered only as a no-op path (the `if cp_world_size > 1` branch is
# skipped), so no stubs needed there.
def install_stubs() -> None:
"""Install all stubs. Idempotent."""
_install_magi_compiler_stub()
repo_root = Path(__file__).resolve().parents[3]
upstream = repo_root / "daVinci-MagiHuman"
path_s = str(upstream)
if path_s not in sys.path:
sys.path.insert(0, path_s)
# Reload inference.* after sys.path mutation so it picks up the real
# upstream package (not a stale one).
for name in list(sys.modules):
if name == "inference" or name.startswith("inference."):
del sys.modules[name]
import inference # noqa: F401
_install_distributed_stubs()
# ---------------------------------------------------------------------------
# Upstream DiTModel loader — instantiate + load base shards.
# ---------------------------------------------------------------------------
def _base_arch_dict() -> dict:
"""Return the upstream `ModelConfig`-equivalent dict for the base variant.
Matches `inference/common/config.py::ModelConfig` defaults for base.
"""
import torch
return dict(
num_layers=40,
hidden_size=5120,
head_dim=128,
num_query_groups=8,
video_in_channels=48 * 4,
audio_in_channels=64,
text_in_channels=3584,
checkpoint_qk_layernorm_rope=False,
params_dtype=torch.float32,
tread_config=dict(
selection_rate=0.5,
start_layer_idx=2,
end_layer_idx=25,
),
mm_layers=[0, 1, 2, 3, 36, 37, 38, 39],
local_attn_layers=[],
enable_attn_gating=True,
activation_type="swiglu7",
gelu7_layers=[0, 1, 2, 3],
# derived
num_heads_q=40,
num_heads_kv=8,
post_norm_layers=[],
)
def load_upstream_dit(base_shard_dir, device=None, dtype=None, local_attn_layers=None):
"""Instantiate upstream `DiTModel` and load the base shards into it.
Args:
base_shard_dir: path to `base/` (contains `model-0000*-of-00007.safetensors`
and `model.safetensors.index.json`).
device: torch device (default cuda if available).
dtype: dtype cast (default: leave checkpoint dtypes as-is).
Returns:
An upstream `DiTModel` in `.eval()` mode with weights loaded.
"""
import glob
import json
import types as _types
import torch
from safetensors.torch import load_file
from inference.common.config import ModelConfig # upstream pydantic class
from inference.model.dit.dit_module import DiTModel
arch_dict = _base_arch_dict()
if local_attn_layers is not None:
arch_dict["local_attn_layers"] = list(local_attn_layers)
# ModelConfig is a pydantic BaseModel — build via kwargs.
model_config = ModelConfig(**arch_dict)
model = DiTModel(model_config=model_config)
# Load all base shards into a single state dict.
base_shard_dir = Path(base_shard_dir)
shard_paths = sorted(base_shard_dir.glob("*.safetensors"))
state = {}
for p in shard_paths:
state.update(load_file(str(p)))
missing, unexpected = model.load_state_dict(state, strict=False)
if missing:
raise RuntimeError(f"Upstream DiT missing {len(missing)} keys: {missing[:5]}")
if unexpected:
raise RuntimeError(f"Upstream DiT unexpected {len(unexpected)} keys: {unexpected[:5]}")
device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device=device)
if dtype is not None:
model = model.to(dtype=dtype)
model.eval()
return model