lip-forcing / scripts /export_merged_checkpoint.py
multimodalart's picture
multimodalart HF Staff
Initial Lip Forcing 14B streaming demo
9368ee7 verified
Raw
History Blame Contribute Delete
7.77 kB
#!/usr/bin/env python3
r"""Export a self-contained, merged single-file student checkpoint for inference.
Why this exists
---------------
The Self-Forcing student is *saved as a trainable-only delta* (LoRA +
selectively-unfrozen modules — ~5 GB at 14B; see ``checkpointer.py``), so
running it normally requires assembling THREE weight sources at load time:
base Wan 2.1 T2V + OmniAvatar-LS V2V adapter + this SF delta
This script performs that assembly once, merges the LoRA into the base, and
writes ONE fully-baked ``.pth`` (plain keys, no PEFT wrappers) that the
inference scripts load with ``--ckpt_path`` ALONE — no ``--base_model_paths``
or ``--omniavatar_ckpt_path`` needed. This is the recommended artifact to
publish for the inference-only audience.
Run on a machine that has the three sources + enough RAM/VRAM (e.g. one H200).
It re-uses the exact assembly path from ``scripts/inference/_loader.py``
so the exported weights are numerically identical to a normal 3-source load.
Example
-------
python scripts/export_merged_checkpoint.py \
--model_size 14B \
--ckpt_path /path/to/0000600.pth \ # SF delta (.pth or .net_model dir)
--base_model_paths /path/wan2.1_t2v_14b-00001.safetensors,/path/...-00002.safetensors \
--omniavatar_ckpt_path /path/to/omniavatar_ls_14b.pt \
--output_path /path/to/lipforcing_14b.pth
Then inference needs only:
python scripts/inference/inference_streaming.py --model_size 14B \
--ckpt_path /path/to/lipforcing_14b.pth \
--vae_path ... --wav2vec_path ... --mask_path ... --taehv_ckpt ...
"""
import argparse
import os
import sys
import torch
# --- Path setup (mirror the inference scripts) ----------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LIPFORCING_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
sys.path.insert(0, LIPFORCING_ROOT)
sys.path.insert(0, os.path.join(SCRIPT_DIR, "inference")) # for `import _loader`
os.environ.setdefault("OMNIAVATAR_ROOT", LIPFORCING_ROOT)
from _loader import load_diffusion_model # noqa: E402
_DTYPES = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}
def _to_plain_merged_state_dict(core: torch.nn.Module) -> dict:
"""Map a post-merge PEFT ``_core`` state_dict to plain (unwrapped) keys.
After ``LoraLayer.merge()`` the merged weights live in
``<module>.base_layer.weight`` while the now-redundant ``lora_A``/``lora_B``
tensors remain. A model constructed WITHOUT the OmniAvatar adapter has no
PEFT wrappers, so its keys are plain (``<module>.weight``). We therefore
drop the lora_* tensors and strip the ``base_layer.`` infix.
(For the 1.3B path the OmniAvatar LoRA is already folded at construction, so
``_core`` has plain keys to begin with and this is a near no-op.)
"""
raw = core.state_dict()
out, n_drop, n_unwrap, n_plain = {}, 0, 0, 0
for k, v in raw.items():
if ".lora_A." in k or ".lora_B." in k or k.endswith(".lora_A") or k.endswith(".lora_B"):
n_drop += 1
continue
if ".base_layer." in k:
k = k.replace(".base_layer.", ".")
n_unwrap += 1
else:
n_plain += 1
out[k] = v
print(f" merged state_dict: {len(out)} tensors "
f"({n_unwrap} unwrapped from PEFT base_layer, {n_plain} plain, {n_drop} lora_* dropped)")
return out
def _verify_reload(plain_sd: dict, args) -> bool:
"""Build a fresh model WITHOUT base/omni and confirm it accepts the merged
state_dict with no missing parameters and no unexpected keys. This is the
exact structure the inference scripts construct when only ``--ckpt_path`` is
given, so a clean result here means single-flag inference will load fully.
"""
from lipforcing.networks.OmniAvatar.network_causal import CausalOmniAvatarWan
probe = CausalOmniAvatarWan(
model_size=args.model_size, in_dim=65, mode="v2v", use_audio=True,
audio_hidden_size=32, chunk_size=args.chunk_size, total_num_frames=21,
base_model_paths=None, omniavatar_ckpt_path=None, merge_lora=False,
lora_rank=128, lora_alpha=64, net_pred_type="flow", schedule_type="rf",
mask_all_frames=True, dtype=args.dtype, local_attn_size=args.local_attn_size,
sink_size=args.sink_size, use_dynamic_rope=args.use_dynamic_rope,
)
prefixed = {"_core." + k: v for k, v in plain_sd.items()}
missing, unexpected = probe.load_state_dict(prefixed, strict=False)
param_keys = set(dict(probe.named_parameters()).keys())
missing_params = [m for m in missing if m in param_keys]
print(f" verify reload: {len(missing_params)} missing params, {len(unexpected)} unexpected keys")
if missing_params:
print(f" WARN missing params (first 10): {missing_params[:10]}")
if unexpected:
print(f" WARN unexpected keys (first 10): {unexpected[:10]}")
return not missing_params and not unexpected
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--model_size", default="14B", choices=["14B", "1.3B"])
ap.add_argument("--ckpt_path", required=True,
help="Self-Forcing student delta: .pth, .net_model distcp dir, or .pth + sibling dir.")
ap.add_argument("--base_model_paths", required=True,
help="Comma-separated base Wan 2.1 T2V safetensor paths (1.3B or 14B).")
ap.add_argument("--omniavatar_ckpt_path", required=True,
help="OmniAvatar-LS V2V adapter checkpoint (.pt/.pth) — LoRA + audio + patch-embed.")
ap.add_argument("--output_path", required=True, help="Output single-file merged .pth.")
ap.add_argument("--dtype", default="bf16", choices=list(_DTYPES),
help="Compute dtype for assembly (default bf16, matches training/inference).")
ap.add_argument("--save_dtype", default=None, choices=list(_DTYPES),
help="Dtype to store floating tensors in (default: same as --dtype).")
ap.add_argument("--chunk_size", type=int, default=3)
ap.add_argument("--local_attn_size", type=int, default=-1)
ap.add_argument("--sink_size", type=int, default=0)
ap.add_argument("--use_dynamic_rope", action="store_true", default=False)
ap.add_argument("--device", default="cuda")
ap.add_argument("--no_verify", action="store_true", help="Skip the reload sanity check.")
args = ap.parse_args()
args.merge_lora_post_load = True # force the LoRA->base merge for 14B
dtype = _DTYPES[args.dtype]
device = torch.device(args.device)
print(f"Assembling {args.model_size} student (base Wan + OmniAvatar-LS + SF delta) ...")
model = load_diffusion_model(args, device, dtype) # constructs + loads 3 sources + merges LoRA
plain = _to_plain_merged_state_dict(model._core)
save_dtype = _DTYPES[args.save_dtype] if args.save_dtype else dtype
plain = {k: (v.to(save_dtype) if v.is_floating_point() else v).cpu() for k, v in plain.items()}
if not args.no_verify:
ok = _verify_reload(plain, args)
print(f" verify: {'OK — single-flag inference will load fully.' if ok else 'MISMATCH — inspect the warnings above before publishing.'}")
out_dir = os.path.dirname(os.path.abspath(args.output_path))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
print(f"Writing merged checkpoint -> {args.output_path} ...")
torch.save(plain, args.output_path)
sz = os.path.getsize(args.output_path) / 1e9
print(f"Done. {len(plain)} tensors, {sz:.1f} GB. Load it with --ckpt_path alone (no base/omni flags).")
if __name__ == "__main__":
main()