项目文件夹

文件
2026-07-13 13:33:03 +08:00

1041 行
42 KiB
Python

import argparse
import importlib.util
import json
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import numpy as np
MODULE_ORDER = ("vae", "t5", "dit", "e2e")
MODULE_ALIASES = {
"text_encoder": "t5",
"transformer": "dit",
"vae_decoder": "vae",
}
DEFAULT_PROMPT = "A calm ocean wave rolling onto a black sand beach at sunrise."
DEFAULT_NEGATIVE_PROMPT = ""
DEFAULT_E2E_WIDTH = 256
DEFAULT_E2E_HEIGHT = 256
DEFAULT_E2E_FRAMES = 9
DEFAULT_E2E_STEPS = 2
DEFAULT_CFG_SCALE = 5.0
DEFAULT_TEXT_LEN = 512
MANIFEST_VERSION = 1
def utc_now():
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
def normalize_module_name(name):
key = name.lower()
return MODULE_ALIASES.get(key, key)
def normalize_modules(values):
modules = []
for value in values or MODULE_ORDER:
name = normalize_module_name(value)
if name == "all":
return list(MODULE_ORDER)
if name not in MODULE_ORDER:
raise ValueError(
"Unsupported module '{}'. Expected one of: all, {}.".format(
value, ", ".join(MODULE_ORDER)
)
)
if name not in modules:
modules.append(name)
return modules or list(MODULE_ORDER)
def add_common_case_args(parser, include_model_path):
if include_model_path:
parser.add_argument("--model_path", required=True, help="Local Wan checkpoint or official Wan source root.")
parser.add_argument("--output_dir", required=True, help="Directory for golden artifacts and reports.")
parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="Positive prompt for tokenizer/T5 and e2e.")
parser.add_argument("--negative_prompt", default=DEFAULT_NEGATIVE_PROMPT, help="Negative prompt for CFG.")
parser.add_argument("--seed", type=int, default=42, help="Seed for deterministic latent and tensor generation.")
parser.add_argument("--width", type=int, default=DEFAULT_E2E_WIDTH, help="Video width.")
parser.add_argument("--height", type=int, default=DEFAULT_E2E_HEIGHT, help="Video height.")
parser.add_argument("--frames", type=int, default=DEFAULT_E2E_FRAMES, help="Video frame count.")
parser.add_argument("--steps", type=int, default=DEFAULT_E2E_STEPS, help="Low-spec e2e denoise steps.")
parser.add_argument("--cfg_scale", type=float, default=DEFAULT_CFG_SCALE, help="CFG guidance scale.")
parser.add_argument("--text_len", type=int, default=DEFAULT_TEXT_LEN, help="Tokenizer/T5 max token length.")
parser.add_argument(
"--dtype",
choices=("fp32", "float32", "fp16", "float16"),
default="fp32",
help="Reference dtype for torch capture or backend execution metadata.",
)
parser.add_argument(
"--device",
default=None,
help="Torch device for reference capture/e2e. Defaults to cuda for fp16 when available, else cpu.",
)
parser.add_argument(
"--module",
action="append",
default=[],
help="Subset to run: vae, t5, dit, e2e, or all. Repeatable.",
)
def build_parser():
parser = argparse.ArgumentParser(
description="Wan numerical alignment helper for reference, ONNX, and MNN module/e2e comparisons."
)
subparsers = parser.add_subparsers(dest="command", required=True)
capture = subparsers.add_parser(
"capture",
help="Run the local Wan reference path and save golden inputs/outputs plus manifest.json.",
)
add_common_case_args(capture, include_model_path=True)
compare_onnx = subparsers.add_parser(
"compare-onnx",
help="Run exported ONNX models with onnxruntime and compare against an existing manifest.",
)
compare_onnx.add_argument("--artifact_dir", required=True, help="Directory containing manifest.json and golden npy files.")
compare_onnx.add_argument("--onnx_root", required=True, help="Root produced by wan_onnx_export.py.")
compare_onnx.add_argument("--module", action="append", default=[], help="Subset: vae, t5, dit, e2e, or all.")
compare_onnx.add_argument("--atol", type=float, default=None, help="Override absolute tolerance.")
compare_onnx.add_argument("--rtol", type=float, default=None, help="Override relative tolerance.")
compare_mnn = subparsers.add_parser(
"compare-mnn",
help="Run converted MNN modules with PyMNN and compare against an existing manifest.",
)
compare_mnn.add_argument("--artifact_dir", required=True, help="Directory containing manifest.json and golden npy files.")
compare_mnn.add_argument("--mnn_root", required=True, help="Root produced by wan_convert_mnn.py.")
compare_mnn.add_argument("--module", action="append", default=[], help="Subset: vae, t5, dit, e2e, or all.")
compare_mnn.add_argument("--atol", type=float, default=None, help="Override absolute tolerance.")
compare_mnn.add_argument("--rtol", type=float, default=None, help="Override relative tolerance.")
compare_mnn.add_argument("--thread_num", type=int, default=1, help="PyMNN runtime thread count.")
compare_mnn.add_argument(
"--mnn_backend",
choices=("CPU", "CUDA"),
default="CPU",
help="MNN runtime backend (default CPU).",
)
e2e = subparsers.add_parser(
"e2e",
help="Run the fixed small e2e case on reference, ONNX, or MNN, dump intermediates, and optionally compare.",
)
e2e.add_argument("--artifact_dir", required=True, help="Directory containing manifest.json and output reports.")
e2e.add_argument(
"--backend",
required=True,
choices=("reference", "onnx", "mnn"),
help="Execution backend for the low-spec e2e rerun.",
)
e2e.add_argument("--model_path", help="Local Wan checkpoint root for backend=reference.")
e2e.add_argument("--onnx_root", help="ONNX root for backend=onnx.")
e2e.add_argument("--mnn_root", help="MNN root for backend=mnn.")
e2e.add_argument("--compare", action="store_true", help="Compare the backend e2e dump against the reference manifest.")
e2e.add_argument("--atol", type=float, default=None, help="Override absolute tolerance.")
e2e.add_argument("--rtol", type=float, default=None, help="Override relative tolerance.")
e2e.add_argument("--thread_num", type=int, default=1, help="PyMNN runtime thread count for backend=mnn.")
e2e.add_argument(
"--mnn_backend",
choices=("CPU", "CUDA"),
default="CPU",
help="MNN runtime backend for backend=mnn (default CPU).",
)
e2e.add_argument(
"--dtype",
choices=("fp32", "float32", "fp16", "float16"),
default="fp32",
help="Reference dtype for backend=reference.",
)
e2e.add_argument("--device", default=None, help="Torch device for backend=reference.")
return parser
def require_file(path, description):
path = Path(path)
if not path.exists():
raise FileNotFoundError("{} not found: {}".format(description, path))
return path
def load_json(path):
with Path(path).open("r", encoding="utf-8") as fp:
return json.load(fp)
def save_json(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fp:
json.dump(data, fp, indent=2, sort_keys=False)
fp.write("\n")
def write_npy(root, relative_path, array):
root = Path(root)
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
np.save(path.as_posix(), np.asarray(array))
array = np.asarray(array)
return {
"path": relative_path,
"shape": list(array.shape),
"dtype": str(array.dtype),
}
def load_npy(root, entry):
return np.load((Path(root) / entry["path"]).as_posix(), allow_pickle=False)
def cosine_similarity(lhs, rhs):
lhs = np.asarray(lhs, dtype=np.float64).reshape(-1)
rhs = np.asarray(rhs, dtype=np.float64).reshape(-1)
lhs_norm = np.linalg.norm(lhs)
rhs_norm = np.linalg.norm(rhs)
if lhs_norm == 0.0 and rhs_norm == 0.0:
return 1.0
if lhs_norm == 0.0 or rhs_norm == 0.0:
return 0.0
return float(np.dot(lhs, rhs) / (lhs_norm * rhs_norm))
def compare_arrays(reference, candidate, atol, rtol):
reference = np.asarray(reference)
candidate = np.asarray(candidate)
if reference.shape != candidate.shape:
raise ValueError("Shape mismatch: reference={} candidate={}".format(reference.shape, candidate.shape))
diff = np.abs(reference.astype(np.float64) - candidate.astype(np.float64))
return {
"shape": list(reference.shape),
"dtype_reference": str(reference.dtype),
"dtype_candidate": str(candidate.dtype),
"max_abs": float(diff.max()) if diff.size else 0.0,
"mean_abs": float(diff.mean()) if diff.size else 0.0,
"cosine": cosine_similarity(reference, candidate),
"allclose": bool(np.allclose(reference, candidate, atol=atol, rtol=rtol)),
"atol": float(atol),
"rtol": float(rtol),
}
def module_tolerance(module_name, atol_override=None, rtol_override=None):
base = {
"vae": (1e-3, 1e-3),
"t5": (1e-3, 1e-3),
"dit": (1e-2, 1e-3),
"e2e": (1e-2, 1e-3),
}
atol, rtol = base[module_name]
if atol_override is not None:
atol = atol_override
if rtol_override is not None:
rtol = rtol_override
return atol, rtol
def latent_frame_count(frames):
return max(1, (frames - 1) // 4 + 1)
def resolve_torch_dtype(dtype_name, torch):
if dtype_name in ("fp16", "float16"):
return torch.float16
return torch.float32
def pick_torch_device(dtype_name, explicit_device, torch):
if explicit_device:
return explicit_device
if dtype_name in ("fp16", "float16") and torch.cuda.is_available():
return "cuda"
return "cpu"
def load_wan_export_module():
module_path = Path(__file__).resolve().parent / "wan_onnx_export.py"
spec = importlib.util.spec_from_file_location("wan_onnx_export_local", module_path.as_posix())
if spec is None or spec.loader is None:
raise RuntimeError("Failed to load helper module: {}".format(module_path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_prompt_batch(tokenizer, prompt, negative_prompt, text_len, torch, device):
if tokenizer is None:
raise RuntimeError("Wan loader did not expose a tokenizer; capture requires a local tokenizer.")
prompts = [negative_prompt, prompt]
encoded = tokenizer(
prompts,
padding="max_length",
truncation=True,
max_length=text_len,
return_tensors="pt",
)
input_ids = encoded["input_ids"].to(device=device)
attention_mask = encoded["attention_mask"].to(device=device)
if input_ids.shape[0] != 2:
raise RuntimeError("Expected CFG prompt batch=2, got {}".format(tuple(input_ids.shape)))
return input_ids, attention_mask
def torch_to_numpy(tensor):
return tensor.detach().cpu().numpy()
def deterministic_latent(shape, seed, dtype=np.float32):
rng = np.random.default_rng(seed)
return rng.standard_normal(shape, dtype=np.float32).astype(dtype)
def deterministic_timesteps(steps, shift=3.0):
"""Build the FlowMatch timestep schedule used by Wan2.1 inference.
This must mirror WanDiffusion::runVideo in C++ exactly so the alignment
harness reflects real on-device behaviour: linear t in [1.0, 0.001]
re-mapped through the shifted scheduler then scaled to [0, 1000].
"""
if steps <= 0:
raise ValueError("--steps must be > 0")
if steps == 1:
return np.array([1000.0], dtype=np.float32)
values = np.empty(steps, dtype=np.float32)
for i in range(steps):
t_linear = 1.0 + i * (0.001 - 1.0) / (steps - 1)
t_shifted = (shift * t_linear) / (1.0 + (shift - 1.0) * t_linear)
values[i] = t_shifted * 1000.0
return values
@dataclass
class ReferenceRunner:
torch: object
device: str
dtype: object
latent_channels: int
tokenizer: object
source: str
text_encoder: object
transformer: object
vae_decoder: object
def run_t5(self, input_ids):
tensor = self.torch.from_numpy(np.asarray(input_ids)).to(device=self.device, dtype=self.torch.int32)
# Free GPU memory: T5 (~25GB fp32) competes with DiT/VAE on a single 32GB card.
# Move to device just-in-time and offload back to CPU after use.
is_cuda = isinstance(self.device, str) and self.device.startswith("cuda")
inner = getattr(self.text_encoder, "inner", None)
module = getattr(self.text_encoder, "module", None)
if is_cuda:
if inner is not None and hasattr(inner, "to"):
inner.to(device=self.device, dtype=self.dtype)
if module is not None and module is not inner and hasattr(module, "to"):
module.to(device=self.device, dtype=self.dtype)
with self.torch.no_grad():
result = torch_to_numpy(self.text_encoder(tensor))
if is_cuda:
if inner is not None and hasattr(inner, "to"):
inner.to("cpu")
if module is not None and module is not inner and hasattr(module, "to"):
module.to("cpu")
self.torch.cuda.empty_cache()
return result
def run_dit(self, hidden_states, timestep, encoder_hidden_states, encoder_attention_mask):
hidden_states = self.torch.from_numpy(np.asarray(hidden_states)).to(device=self.device, dtype=self.dtype)
timestep = self.torch.from_numpy(np.asarray(timestep)).to(device=self.device, dtype=self.dtype)
encoder_hidden_states = self.torch.from_numpy(np.asarray(encoder_hidden_states)).to(
device=self.device, dtype=self.dtype
)
encoder_attention_mask = self.torch.from_numpy(np.asarray(encoder_attention_mask)).to(
device=self.device, dtype=self.torch.int32
)
with self.torch.no_grad():
return torch_to_numpy(
self.transformer(hidden_states, timestep, encoder_hidden_states, encoder_attention_mask)
)
def run_vae(self, latent_sample):
latent_sample = self.torch.from_numpy(np.asarray(latent_sample)).to(device=self.device, dtype=self.dtype)
with self.torch.no_grad():
return torch_to_numpy(self.vae_decoder(latent_sample))
@dataclass
class OnnxRunner:
ort: object
text_session: object = None
dit_session: object = None
vae_session: object = None
@classmethod
def from_root(cls, onnx_root, modules=None):
try:
import onnxruntime as ort
except Exception as exc:
raise RuntimeError("compare-onnx requires onnxruntime: {}".format(exc))
onnx_root = Path(onnx_root)
modules = set(modules or ("t5", "dit", "vae"))
providers = ["CPUExecutionProvider"]
return cls(
ort=ort,
text_session=ort.InferenceSession(
require_file(onnx_root / "text_encoder" / "model.onnx", "text_encoder ONNX").as_posix(),
providers=providers,
)
if "t5" in modules
else None,
dit_session=ort.InferenceSession(
require_file(onnx_root / "transformer" / "model.onnx", "transformer ONNX").as_posix(),
providers=providers,
)
if "dit" in modules
else None,
vae_session=ort.InferenceSession(
require_file(onnx_root / "vae_decoder" / "model.onnx", "vae_decoder ONNX").as_posix(),
providers=providers,
)
if "vae" in modules
else None,
)
@staticmethod
def _run_single(session, feeds):
outputs = session.run(None, feeds)
if not outputs:
raise RuntimeError("ONNX session produced no outputs for {}".format(session))
return np.asarray(outputs[0])
def run_t5(self, input_ids):
return self._run_single(self.text_session, {"input_ids": np.asarray(input_ids, dtype=np.int32)})
def run_dit(self, hidden_states, timestep, encoder_hidden_states, encoder_attention_mask):
feeds = {
"hidden_states": np.asarray(hidden_states),
"timestep": np.asarray(timestep),
"encoder_hidden_states": np.asarray(encoder_hidden_states),
"encoder_attention_mask": np.asarray(encoder_attention_mask, dtype=np.int32),
}
return self._run_single(self.dit_session, feeds)
def run_vae(self, latent_sample):
return self._run_single(self.vae_session, {"latent_sample": np.asarray(latent_sample)})
@dataclass
class MnnRunner:
MNN: object
F: object
nn: object
text_module: object = None
dit_module: object = None
vae_module: object = None
@classmethod
def from_root(cls, mnn_root, thread_num=1, modules=None, backend="CPU"):
try:
import MNN
import MNN.expr as F
import MNN.nn as nn
except Exception as exc:
raise RuntimeError("compare-mnn requires PyMNN in PYTHONPATH: {}".format(exc))
mnn_root = Path(mnn_root)
modules = set(modules or ("t5", "dit", "vae"))
runtime_manager = None
if backend != "CPU":
runtime_manager = nn.create_runtime_manager([
{
"backend": backend,
"precision": "high",
"numThread": thread_num,
}
])
kwargs = {
"dynamic": False,
"shape_mutable": False,
"thread_num": thread_num,
}
if runtime_manager is not None:
kwargs["runtime_manager"] = runtime_manager
return cls(
MNN=MNN,
F=F,
nn=nn,
text_module=nn.load_module_from_file(
require_file(mnn_root / "text_encoder.mnn", "text_encoder MNN").as_posix(),
["input_ids"],
["last_hidden_state"],
**kwargs
)
if "t5" in modules
else None,
dit_module=nn.load_module_from_file(
require_file(mnn_root / "transformer.mnn", "transformer MNN").as_posix(),
["hidden_states", "timestep", "encoder_hidden_states", "encoder_attention_mask"],
["noise_pred"],
**kwargs
)
if "dit" in modules
else None,
vae_module=nn.load_module_from_file(
require_file(mnn_root / "vae_decoder.mnn", "vae_decoder MNN").as_posix(),
["latent_sample"],
["sample"],
**kwargs
)
if "vae" in modules
else None,
)
def _dtype_to_expr(self, array):
dtype = np.asarray(array).dtype
if dtype == np.float64:
return self.F.double
if dtype == np.float32 or dtype == np.float16:
return self.F.float
if dtype == np.int64:
return self.F.int64
if dtype == np.uint8:
return self.F.uint8
if dtype == np.int32 or dtype == np.int16 or dtype == np.int8:
return self.F.int
raise TypeError("Unsupported numpy dtype for PyMNN input: {}".format(dtype))
def _make_input_var(self, array, expected_var):
array = np.ascontiguousarray(array)
var = self.F.const(array, list(array.shape), self.F.NCHW, self._dtype_to_expr(array))
if getattr(expected_var, "data_format", None) == self.F.NC4HW4:
var = self.F.convert(var, self.F.NC4HW4)
return var
def _run_single(self, module, arrays):
info = module.get_info()
input_vars = info["inputs"]
if len(input_vars) != len(arrays):
raise RuntimeError(
"Input arity mismatch for MNN module. expected {} got {}".format(len(input_vars), len(arrays))
)
vars_in = [self._make_input_var(arrays[i], input_vars[i]) for i in range(len(arrays))]
outputs = module.forward(vars_in if len(vars_in) != 1 else vars_in[0])
if not isinstance(outputs, (list, tuple)):
outputs = [outputs]
output = outputs[0]
if getattr(output, "data_format", None) == self.F.NC4HW4:
output = self.F.convert(output, self.F.NCHW)
data = np.array(output.read(), copy=True)
return data.reshape(output.shape)
def run_t5(self, input_ids):
return self._run_single(self.text_module, [np.asarray(input_ids, dtype=np.int32)])
def run_dit(self, hidden_states, timestep, encoder_hidden_states, encoder_attention_mask):
return self._run_single(
self.dit_module,
[
np.asarray(hidden_states),
np.asarray(timestep),
np.asarray(encoder_hidden_states),
np.asarray(encoder_attention_mask, dtype=np.int32),
],
)
def run_vae(self, latent_sample):
return self._run_single(self.vae_module, [np.asarray(latent_sample)])
def create_reference_runner(args):
try:
import torch
except Exception as exc:
raise RuntimeError("capture/reference e2e requires torch: {}".format(exc))
wan_export = load_wan_export_module()
torch_dtype = resolve_torch_dtype(args.dtype, torch)
device = pick_torch_device(args.dtype, getattr(args, "device", None), torch)
if torch_dtype == torch.float16 and device == "cpu":
raise ValueError("fp16 reference execution requires --device cuda or an available CUDA device")
components = wan_export.load_wan_components(Path(args.model_path).resolve().as_posix(), torch_dtype, device)
for module in (components.text_encoder, components.transformer, components.vae_decoder):
wan_export.move_to(module, device=device, dtype=torch_dtype)
latent_channels = 16
if hasattr(wan_export, "infer_latent_channels"):
latent_channels = int(wan_export.infer_latent_channels(components))
elif getattr(components.vae_decoder, "config", None) is not None and hasattr(components.vae_decoder.config, "z_dim"):
latent_channels = int(components.vae_decoder.config.z_dim)
return ReferenceRunner(
torch=torch,
device=device,
dtype=torch_dtype,
latent_channels=latent_channels,
tokenizer=components.tokenizer,
source=components.source,
text_encoder=wan_export.TextEncoderWrapper(torch, components.text_encoder),
transformer=wan_export.TransformerWrapper(torch, components.transformer),
vae_decoder=wan_export.VaeDecoderWrapper(components.vae_decoder),
)
def build_case_metadata(args, latent_channels):
latent_h = max(1, args.height // 8)
latent_w = max(1, args.width // 8)
latent_t = latent_frame_count(args.frames)
return {
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
"seed": int(args.seed),
"width": int(args.width),
"height": int(args.height),
"frames": int(args.frames),
"steps": int(args.steps),
"cfg_scale": float(args.cfg_scale),
"text_len": int(args.text_len),
"dtype": args.dtype,
"latent_channels": int(latent_channels),
"latent_shape": [1, int(latent_channels), int(latent_t), int(latent_h), int(latent_w)],
"cfg_batch_order": ["negative", "positive"],
}
def run_e2e_case(runner, metadata, input_ids, attention_mask):
text = runner.run_t5(input_ids)
latent_shape = metadata["latent_shape"]
sample = deterministic_latent(latent_shape, metadata["seed"])
timesteps = deterministic_timesteps(metadata["steps"])
steps = []
current = sample.astype(np.float32, copy=True)
for index, timestep_value in enumerate(timesteps):
sample_before = current.copy()
hidden_states = np.repeat(sample_before, 2, axis=0)
timestep = np.full((2,), timestep_value, dtype=np.float32)
noise_pred = runner.run_dit(hidden_states, timestep, text, attention_mask)
if noise_pred.shape[0] != 2:
raise RuntimeError("Expected DiT noise prediction batch=2 for CFG, got {}".format(noise_pred.shape))
noise_pred_uncond = noise_pred[0:1].astype(np.float32, copy=True)
noise_pred_cond = noise_pred[1:2].astype(np.float32, copy=True)
guided_noise = noise_pred_uncond + np.float32(metadata["cfg_scale"]) * (noise_pred_cond - noise_pred_uncond)
# Match WanDiffusion::stepFlowMatch in C++:
# sample + modelOutput * (nextT - t) / 1000.0
next_timestep_value = float(timesteps[index + 1]) if index + 1 < len(timesteps) else 0.0
delta = np.float32((next_timestep_value - float(timestep_value)) / 1000.0)
sample_after = sample_before + delta * guided_noise
steps.append(
{
"index": index,
"timestep": float(timestep_value),
"sample_before": sample_before,
"noise_pred": noise_pred.astype(np.float32, copy=True),
"noise_pred_uncond": noise_pred_uncond,
"noise_pred_cond": noise_pred_cond,
"guided_noise": guided_noise.astype(np.float32, copy=True),
"sample_after": sample_after.astype(np.float32, copy=True),
}
)
current = sample_after.astype(np.float32, copy=True)
final_video = runner.run_vae(current)
return {
"text_embeddings": text.astype(np.float32, copy=True),
"initial_latent": sample.astype(np.float32, copy=True),
"steps": steps,
"final_latent": current.astype(np.float32, copy=True),
"final_video": np.asarray(final_video),
}
def capture_command(args):
modules = normalize_modules(args.module)
output_dir = Path(args.output_dir).resolve()
runner = create_reference_runner(args)
metadata = build_case_metadata(args, latent_channels=runner.latent_channels)
input_ids_torch, attention_mask_torch = build_prompt_batch(
runner.tokenizer, args.prompt, args.negative_prompt, args.text_len, runner.torch, runner.device
)
input_ids = torch_to_numpy(input_ids_torch).astype(np.int32, copy=False)
attention_mask = torch_to_numpy(attention_mask_torch).astype(np.int32, copy=False)
output_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"version": MANIFEST_VERSION,
"created_at": utc_now(),
"reference": {
"source": runner.source,
"model_path": Path(args.model_path).resolve().as_posix(),
"device": runner.device,
"dtype": args.dtype,
},
"case": metadata,
"modules": {},
"reports": {},
}
manifest["modules"]["t5"] = {
"inputs": {
"input_ids": write_npy(output_dir, "t5/input_ids.npy", input_ids),
"attention_mask": write_npy(output_dir, "t5/attention_mask.npy", attention_mask),
}
}
if "t5" in modules or "dit" in modules or "e2e" in modules:
last_hidden_state = runner.run_t5(input_ids).astype(np.float32, copy=True)
manifest["modules"]["t5"]["outputs"] = {
"last_hidden_state": write_npy(output_dir, "t5/last_hidden_state.npy", last_hidden_state)
}
if "dit" in modules:
dit_hidden_states = deterministic_latent(metadata["latent_shape"], args.seed + 101)
dit_hidden_states = np.repeat(dit_hidden_states, 2, axis=0)
dit_timestep = np.full((2,), np.float32(0.75), dtype=np.float32)
dit_output = runner.run_dit(
dit_hidden_states,
dit_timestep,
last_hidden_state,
attention_mask,
).astype(np.float32, copy=True)
manifest["modules"]["dit"] = {
"inputs": {
"hidden_states": write_npy(output_dir, "dit/hidden_states.npy", dit_hidden_states),
"timestep": write_npy(output_dir, "dit/timestep.npy", dit_timestep),
"encoder_hidden_states": write_npy(output_dir, "dit/encoder_hidden_states.npy", last_hidden_state),
"encoder_attention_mask": write_npy(output_dir, "dit/encoder_attention_mask.npy", attention_mask),
},
"outputs": {
"noise_pred": write_npy(output_dir, "dit/noise_pred.npy", dit_output),
},
}
if "vae" in modules:
vae_latent = deterministic_latent(metadata["latent_shape"], args.seed + 202)
vae_output = runner.run_vae(vae_latent)
manifest["modules"]["vae"] = {
"inputs": {
"latent_sample": write_npy(output_dir, "vae/latent_sample.npy", vae_latent),
},
"outputs": {
"sample": write_npy(output_dir, "vae/sample.npy", vae_output),
},
}
if "e2e" in modules:
e2e = run_e2e_case(runner, metadata, input_ids, attention_mask)
e2e_manifest = {
"inputs": {
"input_ids": write_npy(output_dir, "e2e/input_ids.npy", input_ids),
"attention_mask": write_npy(output_dir, "e2e/attention_mask.npy", attention_mask),
"initial_latent": write_npy(output_dir, "e2e/initial_latent.npy", e2e["initial_latent"]),
"text_embeddings": write_npy(output_dir, "e2e/text_embeddings.npy", e2e["text_embeddings"]),
},
"steps": [],
"outputs": {
"final_latent": write_npy(output_dir, "e2e/final_latent.npy", e2e["final_latent"]),
"final_video": write_npy(output_dir, "e2e/final_video.npy", e2e["final_video"]),
},
}
for step in e2e["steps"]:
step_dir = "e2e/steps/step_{:02d}".format(step["index"])
e2e_manifest["steps"].append(
{
"index": step["index"],
"timestep": step["timestep"],
"sample_before": write_npy(output_dir, step_dir + "/sample_before.npy", step["sample_before"]),
"noise_pred": write_npy(output_dir, step_dir + "/noise_pred.npy", step["noise_pred"]),
"noise_pred_uncond": write_npy(
output_dir, step_dir + "/noise_pred_uncond.npy", step["noise_pred_uncond"]
),
"noise_pred_cond": write_npy(
output_dir, step_dir + "/noise_pred_cond.npy", step["noise_pred_cond"]
),
"guided_noise": write_npy(output_dir, step_dir + "/guided_noise.npy", step["guided_noise"]),
"sample_after": write_npy(output_dir, step_dir + "/sample_after.npy", step["sample_after"]),
}
)
manifest["modules"]["e2e"] = e2e_manifest
save_json(output_dir / "manifest.json", manifest)
save_json(
output_dir / "reports" / "capture_report.json",
{
"created_at": utc_now(),
"output_dir": output_dir.as_posix(),
"modules": modules,
"reference": manifest["reference"],
"case": manifest["case"],
},
)
print("Captured Wan alignment artifacts to {}".format(output_dir))
def load_manifest(artifact_dir):
artifact_dir = Path(artifact_dir).resolve()
manifest = load_json(require_file(artifact_dir / "manifest.json", "manifest.json"))
return artifact_dir, manifest
def manifest_modules(manifest, include_e2e):
modules = [name for name in MODULE_ORDER if name in manifest.get("modules", {})]
if not include_e2e:
modules = [name for name in modules if name != "e2e"]
return modules
def compare_module_output(artifact_dir, manifest, module_name, candidate_outputs, atol_override=None, rtol_override=None):
atol, rtol = module_tolerance(module_name, atol_override, rtol_override)
module_manifest = manifest["modules"].get(module_name)
if module_manifest is None:
raise RuntimeError("Manifest does not contain module '{}'".format(module_name))
outputs = module_manifest["outputs"]
report = {
"module": module_name,
"tensors": {},
"pass": True,
}
for output_name, entry in outputs.items():
reference = load_npy(artifact_dir, entry)
candidate = candidate_outputs[output_name]
metrics = compare_arrays(reference, candidate, atol=atol, rtol=rtol)
report["tensors"][output_name] = metrics
report["pass"] = report["pass"] and metrics["allclose"]
return report
def run_backend_module(artifact_dir, manifest, runner, module_name):
module = manifest["modules"][module_name]
if module_name == "t5":
input_ids = load_npy(artifact_dir, module["inputs"]["input_ids"])
return {"last_hidden_state": runner.run_t5(input_ids)}
if module_name == "dit":
return {
"noise_pred": runner.run_dit(
load_npy(artifact_dir, module["inputs"]["hidden_states"]),
load_npy(artifact_dir, module["inputs"]["timestep"]),
load_npy(artifact_dir, module["inputs"]["encoder_hidden_states"]),
load_npy(artifact_dir, module["inputs"]["encoder_attention_mask"]),
)
}
if module_name == "vae":
return {"sample": runner.run_vae(load_npy(artifact_dir, module["inputs"]["latent_sample"]))}
raise ValueError("Unsupported module backend run: {}".format(module_name))
def build_runner_from_compare_args(command, args, modules):
if command == "compare-onnx":
return OnnxRunner.from_root(args.onnx_root, modules=modules)
if command == "compare-mnn":
return MnnRunner.from_root(
args.mnn_root,
thread_num=args.thread_num,
modules=modules,
backend=getattr(args, "mnn_backend", "CPU"),
)
raise ValueError("Unsupported compare command: {}".format(command))
def compare_command(command, args):
artifact_dir, manifest = load_manifest(args.artifact_dir)
requested_modules = normalize_modules(args.module) if args.module else manifest_modules(manifest, include_e2e=True)
modules = [m for m in requested_modules if m != "e2e"]
runner = build_runner_from_compare_args(command, args, requested_modules)
report = {
"created_at": utc_now(),
"command": command,
"artifact_dir": artifact_dir.as_posix(),
"modules": {},
"pass": True,
}
for module_name in modules:
start = time.time()
outputs = run_backend_module(artifact_dir, manifest, runner, module_name)
module_report = compare_module_output(
artifact_dir, manifest, module_name, outputs, atol_override=args.atol, rtol_override=args.rtol
)
module_report["elapsed_ms"] = round((time.time() - start) * 1000.0, 3)
report["modules"][module_name] = module_report
report["pass"] = report["pass"] and module_report["pass"]
if "e2e" in requested_modules:
e2e_report = e2e_compare(
artifact_dir,
manifest,
runner,
backend_name="onnx" if command == "compare-onnx" else "mnn",
atol_override=args.atol,
rtol_override=args.rtol,
write_outputs=True,
)
report["modules"]["e2e"] = e2e_report
report["pass"] = report["pass"] and e2e_report["pass"]
report_path = artifact_dir / "reports" / "{}.json".format(command.replace("-", "_"))
save_json(report_path, report)
print("Wrote {}".format(report_path))
if not report["pass"]:
raise SystemExit(1)
def materialize_e2e_outputs(artifact_dir, backend_name, e2e_result):
output_root = Path(artifact_dir) / "reports" / "e2e" / backend_name
output_root.mkdir(parents=True, exist_ok=True)
dumped = {
"text_embeddings": write_npy(output_root, "text_embeddings.npy", e2e_result["text_embeddings"]),
"initial_latent": write_npy(output_root, "initial_latent.npy", e2e_result["initial_latent"]),
"final_latent": write_npy(output_root, "final_latent.npy", e2e_result["final_latent"]),
"final_video": write_npy(output_root, "final_video.npy", e2e_result["final_video"]),
"steps": [],
}
for step in e2e_result["steps"]:
step_dir = "steps/step_{:02d}".format(step["index"])
dumped["steps"].append(
{
"index": step["index"],
"timestep": step["timestep"],
"sample_before": write_npy(output_root, step_dir + "/sample_before.npy", step["sample_before"]),
"noise_pred": write_npy(output_root, step_dir + "/noise_pred.npy", step["noise_pred"]),
"noise_pred_uncond": write_npy(output_root, step_dir + "/noise_pred_uncond.npy", step["noise_pred_uncond"]),
"noise_pred_cond": write_npy(output_root, step_dir + "/noise_pred_cond.npy", step["noise_pred_cond"]),
"guided_noise": write_npy(output_root, step_dir + "/guided_noise.npy", step["guided_noise"]),
"sample_after": write_npy(output_root, step_dir + "/sample_after.npy", step["sample_after"]),
}
)
return dumped
def e2e_compare(artifact_dir, manifest, runner, backend_name, atol_override=None, rtol_override=None, write_outputs=False):
module = manifest["modules"].get("e2e")
if module is None:
raise RuntimeError("Manifest does not contain e2e artifacts. Re-run capture with --module e2e or --module all.")
metadata = manifest["case"]
input_ids = load_npy(artifact_dir, module["inputs"]["input_ids"])
attention_mask = load_npy(artifact_dir, module["inputs"]["attention_mask"])
result = run_e2e_case(runner, metadata, input_ids, attention_mask)
dumped = materialize_e2e_outputs(artifact_dir, backend_name, result) if write_outputs else None
atol, rtol = module_tolerance("e2e", atol_override, rtol_override)
report = {
"module": "e2e",
"backend": backend_name,
"pass": True,
"tensors": {
"text_embeddings": compare_arrays(
load_npy(artifact_dir, module["inputs"]["text_embeddings"]), result["text_embeddings"], atol, rtol
),
"initial_latent": compare_arrays(
load_npy(artifact_dir, module["inputs"]["initial_latent"]), result["initial_latent"], atol, rtol
),
"final_latent": compare_arrays(
load_npy(artifact_dir, module["outputs"]["final_latent"]), result["final_latent"], atol, rtol
),
"final_video": compare_arrays(
load_npy(artifact_dir, module["outputs"]["final_video"]), result["final_video"], atol, rtol
),
},
"steps": [],
}
for name in ("text_embeddings", "initial_latent", "final_latent", "final_video"):
report["pass"] = report["pass"] and report["tensors"][name]["allclose"]
if len(module["steps"]) != len(result["steps"]):
raise RuntimeError(
"E2E step count mismatch: manifest={} backend={}".format(len(module["steps"]), len(result["steps"]))
)
for step_ref, step_result in zip(module["steps"], result["steps"]):
step_report = {
"index": step_ref["index"],
"timestep": step_ref["timestep"],
"tensors": {},
"pass": True,
}
for tensor_name in (
"sample_before",
"noise_pred",
"noise_pred_uncond",
"noise_pred_cond",
"guided_noise",
"sample_after",
):
metrics = compare_arrays(
load_npy(artifact_dir, step_ref[tensor_name]),
step_result[tensor_name],
atol=atol,
rtol=rtol,
)
step_report["tensors"][tensor_name] = metrics
step_report["pass"] = step_report["pass"] and metrics["allclose"]
report["steps"].append(step_report)
report["pass"] = report["pass"] and step_report["pass"]
if dumped is not None:
report["dumped_outputs"] = dumped
return report
def e2e_command(args):
artifact_dir, manifest = load_manifest(args.artifact_dir)
if args.backend == "reference":
if not args.model_path:
raise ValueError("--model_path is required for e2e --backend reference")
runner = create_reference_runner(args)
elif args.backend == "onnx":
if not args.onnx_root:
raise ValueError("--onnx_root is required for e2e --backend onnx")
runner = OnnxRunner.from_root(args.onnx_root)
else:
if not args.mnn_root:
raise ValueError("--mnn_root is required for e2e --backend mnn")
runner = MnnRunner.from_root(
args.mnn_root,
thread_num=args.thread_num,
backend=getattr(args, "mnn_backend", "CPU"),
)
report = e2e_compare(
artifact_dir,
manifest,
runner,
backend_name=args.backend,
atol_override=args.atol,
rtol_override=args.rtol,
write_outputs=True,
)
out_path = artifact_dir / "reports" / "e2e_{}.json".format(args.backend)
save_json(
out_path,
{
"created_at": utc_now(),
"artifact_dir": artifact_dir.as_posix(),
"report": report,
},
)
print("Wrote {}".format(out_path))
if args.compare and not report["pass"]:
raise SystemExit(1)
def main():
parser = build_parser()
args = parser.parse_args()
try:
if args.command == "capture":
capture_command(args)
elif args.command == "compare-onnx":
compare_command("compare-onnx", args)
elif args.command == "compare-mnn":
compare_command("compare-mnn", args)
elif args.command == "e2e":
e2e_command(args)
else:
parser.error("Unknown command: {}".format(args.command))
except KeyboardInterrupt:
raise
except SystemExit:
raise
except Exception as exc:
import traceback
traceback.print_exc()
print("ERROR:", exc, file=sys.stderr)
raise SystemExit(2)
if __name__ == "__main__":
main()