File size: 10,211 Bytes
b34c6c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
"""Model loading, validation cases, camera poses and per-forward timing for the
HY-WorldPlay base.

Run inside the ``hy_worldplay_july20260720`` environment with the upstream clone
on ``sys.path`` (``enter_repo`` does that).  Weights come from the
HY-WorldPlay-DEV-Predictor project's HF cache; the validation cases (25 VBench
I2V images cropped to 832x480, 4 camera actions each) come from that project's
``hyworldplay_predictor_vbench_val25_test50`` dataset.
"""

import json
import os
import sys
import time
from types import SimpleNamespace

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPO = os.path.join(ROOT, "repos/HY-WorldPlay")
DEV = "/local/zoubin/cz/projects/HY-WorldPlay-DEV-Predictor"
HF = "/local/zoubin/cz/checkpoints/hy_worldplay/huggingface/hub"
BASE_MODEL = os.path.join(
    HF, "models--tencent--HunyuanVideo-1.5/snapshots/9b49404b3f5df2a8f0b31df27a0c7ab872e7b038")
ACTION_CKPT = os.path.join(
    HF, "models--tencent--HY-WorldPlay/snapshots/f4c29235647707b571479a69b569e4166f9f5bf8/"
        "ar_distilled_action_model/diffusion_pytorch_model.safetensors")
DATASET = os.path.join(DEV, "datasets/hyworldplay_predictor_vbench_val25_test50")

# The four bidirectional camera actions of the validation protocol, for 32 latents
# (125 frames).  ``pose_for_latents`` rescales them for shorter search videos.
ACTIONS = (("w_s", "w", "s"), ("a_d", "a", "d"), ("up_down", "up", "down"),
           ("left_right", "left", "right"))

NUM_STEPS = 4
CHUNK_LATENTS = 4


def enter_repo():
    if REPO not in sys.path:
        sys.path.insert(0, REPO)
    if ROOT not in sys.path:
        sys.path.insert(0, ROOT)
    # 125-frame videos sit at ~71 GB with the stock pipeline; without expandable
    # segments the allocator fragments another ~9 GB and the cache banks do not fit.
    os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
    os.environ.setdefault("HF_HUB_OFFLINE", "1")
    os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")


def init_state():
    """Single-process inference state identical to the DEV project's ``init_state``."""
    from hyvideo.commons.infer_state import initialize_infer_state
    initialize_infer_state(SimpleNamespace(
        sage_blocks_range="0-53", use_sageattn=False, enable_torch_compile=False,
        use_fp8_gemm=False, quant_type="fp8-per-block", include_patterns="double_blocks",
        use_vae_parallel=False))


def load_pipeline(device=None):
    import torch
    from hyvideo.pipelines.worldplay_video_pipeline import HunyuanVideo_1_5_Pipeline

    init_state()
    pipe = HunyuanVideo_1_5_Pipeline.create_pipeline(
        pretrained_model_name_or_path=BASE_MODEL,
        transformer_version="480p_i2v",
        enable_offloading=True,        # text encoder / VAE live on CPU between uses
        enable_group_offloading=False,
        create_sr_pipeline=False,
        force_sparse_attn=False,
        transformer_dtype=torch.bfloat16,
        action_ckpt=ACTION_CKPT,
    )
    pipe.transformer.eval().requires_grad_(False)
    return pipe


def pose_for_latents(action, num_latents):
    """``"w-15,s-16"`` for 32 latents; the first move gets floor((n-1)/2) latents,
    the reverse move the rest, so the video still turns around half-way."""
    _, fwd, back = action
    n = num_latents - 1
    return f"{fwd}-{n // 2},{back}-{n - n // 2}"


def load_cases(split="validation"):
    """The 25 validation (or 50 test) cases with image paths on this machine."""
    rows = []
    with open(os.path.join(DATASET, "cases.jsonl")) as f:
        for line in f:
            r = json.loads(line)
            if r["split"] != split:
                continue
            r["image_path"] = os.path.join(DATASET, split, "input_images",
                                           f"case_{r['split_case_id']:04d}.jpg")
            rows.append(r)
    rows.sort(key=lambda r: r["split_case_id"])
    return rows


class ForwardTimer:
    """Wraps ``pipe.transformer.forward`` with CUDA events and classifies every call:
    text-cache pass, per-chunk context pass (``cache_vision=True``), or a denoising
    forward.  Only the denoising forwards make up ``denoise_dit_ms``; the context
    passes are the analogue of Self-Forcing's KV-refresh pass and are reported
    separately, never added in.  Also tells the cache controller where it is."""

    def __init__(self, transformer, controller=None, num_steps=NUM_STEPS, first_chunk_steps=None):
        import torch
        self.transformer = transformer
        self.controller = controller
        self.num_steps = num_steps
        # chunk 0 may run a different number of denoising steps than the rest
        self.first_chunk_steps = first_chunk_steps or num_steps
        self.torch = torch
        self.reset()

    def reset(self):
        self.events = {"denoise": [], "context": [], "text": []}
        self.denoise_calls = 0
        if self.controller is not None:
            self.controller.reset_video()

    def __enter__(self):
        self._orig = self.transformer.forward
        timer = self

        def forward(*args, **kw):
            torch = timer.torch
            if kw.get("ar_txt_inference"):
                kind = "text"
            elif kw.get("cache_vision"):
                kind = "context"
            else:
                kind = "denoise"
            if kind == "denoise" and timer.controller is not None:
                if timer.denoise_calls < timer.first_chunk_steps:
                    block, step = 0, timer.denoise_calls
                else:
                    rest = timer.denoise_calls - timer.first_chunk_steps
                    block, step = 1 + rest // timer.num_steps, rest % timer.num_steps
                if step == 0:
                    timer.controller.begin_chunk(block)
                timer.controller.denoise_step(step)
            s = torch.cuda.Event(enable_timing=True)
            e = torch.cuda.Event(enable_timing=True)
            s.record()
            out = timer._orig(*args, **kw)
            e.record()
            timer.events[kind].append((s, e))
            if kind == "denoise":
                timer.denoise_calls += 1
                if timer.controller is not None:
                    timer.controller.end_step()
            return out

        self.transformer.forward = forward
        return self

    def __exit__(self, *exc):
        self.transformer.forward = self._orig

    def summary(self):
        self.torch.cuda.synchronize()
        per = {k: [s.elapsed_time(e) for s, e in v] for k, v in self.events.items()}
        return {
            "denoise_dit_ms": sum(per["denoise"]),
            "per_step_denoise_ms": per["denoise"],
            "num_denoise_forwards": len(per["denoise"]),
            "context_kv_dit_ms": sum(per["context"]),
            "num_context_forwards": len(per["context"]),
            "text_cache_ms": sum(per["text"]),
        }


class _ChunkTimesteps:
    """Stands in for the pipeline's ``timesteps`` tensor so that chunk 0 iterates a
    longer schedule than the following chunks.  ``_ar_rollout_inner`` only iterates
    it (once per chunk), reads ``.dtype`` and ``len()``; the scheduler is re-pointed
    at the matching grid at the start of every chunk (the pipeline itself calls
    ``set_timesteps(num_inference_steps)`` just before, so this always wins)."""

    def __init__(self, scheduler, first_n, n, device, extra):
        self.scheduler, self.first_n, self.n, self.device, self.extra = scheduler, first_n, n, device, extra
        self.calls = 0
        self.dtype = scheduler.timesteps.dtype

    def __len__(self):
        return self.n

    def __iter__(self):
        k = self.first_n if self.calls == 0 else self.n
        self.calls += 1
        self.scheduler.set_timesteps(k, device=self.device, **self.extra)
        return iter(self.scheduler.timesteps)


def generate(pipe, case, action, controller=None, num_frames=125, seed=0, decode=True,
             num_steps=NUM_STEPS, first_chunk_steps=None):
    """One video.  Returns (video [1,C,T,H,W] in [0,1] or None, latents, timing).
    ``first_chunk_steps`` gives chunk 0 its own step count (the others run ``num_steps``)."""
    import torch
    from hyvideo.generate import pose_to_input
    import hyvideo.pipelines.worldplay_video_pipeline as wp

    patched = first_chunk_steps is not None and first_chunk_steps != num_steps
    orig_retrieve = wp.retrieve_timesteps

    def retrieve_first_chunk(scheduler, n, device=None, timesteps=None, sigmas=None, **kw):
        ts, n_out = orig_retrieve(scheduler, n, device, timesteps=timesteps, sigmas=sigmas, **kw)
        return _ChunkTimesteps(scheduler, first_chunk_steps, n_out, device, kw), n_out

    num_latents = (num_frames - 1) // 4 + 1
    assert num_latents % CHUNK_LATENTS == 0
    pose = pose_for_latents(action, num_latents)
    viewmats, Ks, act = pose_to_input(pose, num_latents)

    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    if patched:
        wp.retrieve_timesteps = retrieve_first_chunk
    try:
      with ForwardTimer(pipe.transformer, controller, num_steps=num_steps,
                        first_chunk_steps=first_chunk_steps) as timer, torch.no_grad():
        wall0 = time.time()
        out = pipe(
            enable_sr=False, prompt=case["caption"], aspect_ratio="16:9",
            num_inference_steps=num_steps, video_length=num_frames, negative_prompt="",
            seed=seed, output_type="pt" if decode else "latent", prompt_rewrite=False,
            return_pre_sr_video=True, reference_image=case["image_path"],
            viewmats=viewmats.unsqueeze(0), Ks=Ks.unsqueeze(0), action=act.unsqueeze(0),
            few_step=True, chunk_latent_frames=CHUNK_LATENTS, model_type="ar",
            user_height=480, user_width=832, transformer_resident_ar_rollout=True,
        )
        wall = time.time() - wall0
    finally:
        if patched:
            wp.retrieve_timesteps = orig_retrieve
    timing = timer.summary()
    timing["wall_s"] = wall
    timing["peak_mem_gib"] = torch.cuda.max_memory_allocated() / 1024 ** 3
    video = out.videos if decode else None
    latents = None if decode else out.videos
    return video, latents, timing