multimodalart HF Staff commited on
Commit
4fe31ed
·
verified ·
1 Parent(s): ad0a428

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/bridgev2_obs.jpg filter=lfs diff=lfs merge=lfs -text
37
+ examples/umi_sock_obs.jpg filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,27 @@
1
  ---
2
- title: Rxbrain Embodied Cognition
3
- emoji: 📚
4
- colorFrom: pink
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: RxBrain Embodied Cognition
3
+ emoji: 🔮
4
+ colorFrom: red
5
+ colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.20.0
 
8
  app_file: app.py
9
+ short_description: Embodied reasoning + visual imagination (Hy-RxBrain 1.0)
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 1h
12
  ---
13
 
14
+ # RxBrain Embodied Cognition Foundation Model
15
+
16
+ Interactive demo of **[tencent/Hy-Embodied-RxBrain-1.0](https://huggingface.co/tencent/Hy-Embodied-RxBrain-1.0)**,
17
+ a unified Mixture-of-Transformers model that couples language reasoning with visual imagination.
18
+
19
+ Three capabilities:
20
+
21
+ - **Visual QA** — question answering over images.
22
+ - **Text-to-Image** — imagine an image via the flow-matching head (decoded through a frozen FLUX VAE).
23
+ - **Embodied Planning** — decompose a task into interleaved steps, emitting both the action (text)
24
+ and the imagined goal frame (image) per step.
25
+
26
+ Runs on ZeroGPU. Imagination requires the external FLUX VAE (`ae.safetensors`), fetched from
27
+ `black-forest-labs/FLUX.1-schnell`.
app.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RxBrain — Embodied Cognition Foundation Model demo (ZeroGPU).
2
+
3
+ tencent/Hy-Embodied-RxBrain-1.0 : a unified Mixture-of-Transformers model that
4
+ couples language reasoning with visual imagination. This Space exposes three
5
+ capabilities, each mapped 1:1 onto the authors' official inference scripts:
6
+
7
+ * Visual Question Answering (vqa_inference.answer)
8
+ * Text-to-Image imagination (text2image_inference.generate_image)
9
+ * Interleaved embodied planning (interleave_inference.generate_step_joint)
10
+ """
11
+ import os
12
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
13
+
14
+ import spaces # noqa: E402 — must precede torch / CUDA-touching imports
15
+ import tempfile # noqa: E402
16
+ import traceback # noqa: E402
17
+
18
+ import torch # noqa: E402
19
+ import numpy as np # noqa: E402
20
+ import gradio as gr # noqa: E402
21
+ from PIL import Image # noqa: E402
22
+ from huggingface_hub import snapshot_download, hf_hub_download # noqa: E402
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Model / VAE loading (module scope, eager .to("cuda") for ZeroGPU)
26
+ # ---------------------------------------------------------------------------
27
+ MODEL_ID = "tencent/Hy-Embodied-RxBrain-1.0"
28
+ VAE_REPO = "black-forest-labs/FLUX.1-schnell"
29
+ DTYPE = torch.bfloat16
30
+
31
+ print("Downloading RxBrain checkpoint …")
32
+ CKPT_DIR = snapshot_download(MODEL_ID)
33
+ print(f"Checkpoint at {CKPT_DIR}")
34
+
35
+ print("Downloading FLUX VAE (ae.safetensors) …")
36
+ VAE_PATH = hf_hub_download(VAE_REPO, "ae.safetensors")
37
+ print(f"VAE at {VAE_PATH}")
38
+
39
+ from transformers.models.hunyuan_vl_mot import HunYuanVLMoTProcessor # noqa: E402
40
+ from model import UnifiedMoTForConditionalGeneration, maybe_init_generation_path # noqa: E402
41
+ from vae_model.autoencoder import load_ae # noqa: E402
42
+
43
+ # Inference helpers ported straight from the official scripts.
44
+ from vqa_inference import answer as _vqa_answer # noqa: E402
45
+ from text2image_inference import generate_image as _t2i_generate # noqa: E402
46
+ from interleave_inference import generate_step_joint as _interleave_step # noqa: E402
47
+
48
+ print("Loading processor …")
49
+ processor = HunYuanVLMoTProcessor.from_pretrained(CKPT_DIR, trust_remote_code=True)
50
+
51
+ print("Loading model …")
52
+ model = UnifiedMoTForConditionalGeneration.from_pretrained(CKPT_DIR, dtype=DTYPE)
53
+ maybe_init_generation_path(model, model_load_path=CKPT_DIR)
54
+ model.to("cuda").eval()
55
+
56
+ print("Loading VAE …")
57
+ vae, _ = load_ae(VAE_PATH)
58
+ vae.requires_grad_(False)
59
+ vae.eval()
60
+ vae.to("cuda", dtype=DTYPE)
61
+
62
+ DEVICE = torch.device("cuda")
63
+ print("All components ready.")
64
+
65
+
66
+ def _tensor_to_pil(img):
67
+ """(3, H, W) tensor in [-1, 1] -> PIL.Image."""
68
+ arr = ((img.cpu().permute(1, 2, 0).numpy() + 1.0) * 127.5).clip(0, 255).astype("uint8")
69
+ return Image.fromarray(arr)
70
+
71
+
72
+ def _save_input_image(image):
73
+ """Persist a Gradio image (PIL or path) to a temp jpg, return the path."""
74
+ if image is None:
75
+ return None
76
+ if isinstance(image, str):
77
+ return image
78
+ if isinstance(image, np.ndarray):
79
+ image = Image.fromarray(image)
80
+ f = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
81
+ image.convert("RGB").save(f.name, quality=95)
82
+ return f.name
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # 1. Visual Question Answering
87
+ # ---------------------------------------------------------------------------
88
+ @spaces.GPU(duration=120)
89
+ def vqa(image, question, max_new_tokens=256):
90
+ """Answer a question about an image using RxBrain's understanding path.
91
+
92
+ Args:
93
+ image: the input image to reason over.
94
+ question: a natural-language question about the image.
95
+ max_new_tokens: maximum number of answer tokens to decode.
96
+ Returns:
97
+ The model's answer text.
98
+ """
99
+ if image is None:
100
+ return "Please provide an input image."
101
+ if not question or not question.strip():
102
+ return "Please enter a question."
103
+ path = _save_input_image(image)
104
+ text = _vqa_answer(
105
+ model, processor, image_paths=[path], question=question.strip(),
106
+ device=DEVICE, dtype=DTYPE, max_new_tokens=int(max_new_tokens),
107
+ )
108
+ return text.strip()
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # 2. Text-to-Image imagination
113
+ # ---------------------------------------------------------------------------
114
+ def _t2i_duration(prompt, height=256, width=256, num_steps=25, cfg_scale=1.0, seed=0):
115
+ base = 40
116
+ per_step = 1.5 * (2 if cfg_scale != 1.0 else 1)
117
+ return int(min(180, base + num_steps * per_step))
118
+
119
+
120
+ @spaces.GPU(duration=_t2i_duration)
121
+ def text_to_image(prompt, height=256, width=256, num_steps=25, cfg_scale=1.0, seed=0):
122
+ """Imagine an image from a text prompt via the flow-matching head.
123
+
124
+ Args:
125
+ prompt: the text description to imagine.
126
+ height: output image height in pixels (multiple of 16).
127
+ width: output image width in pixels (multiple of 16).
128
+ num_steps: number of Euler-ODE flow-matching steps.
129
+ cfg_scale: classifier-free guidance scale (>1 enables CFG).
130
+ seed: RNG seed for reproducibility.
131
+ Returns:
132
+ The generated PIL image.
133
+ """
134
+ if not prompt or not prompt.strip():
135
+ raise gr.Error("Please enter a prompt.")
136
+ torch.manual_seed(int(seed))
137
+ h = int(height) // 16 * 16
138
+ w = int(width) // 16 * 16
139
+ img = _t2i_generate(
140
+ model, vae, processor, prompt.strip(),
141
+ height=h, width=w, num_steps=int(num_steps),
142
+ device=DEVICE, dtype=DTYPE, cfg_scale=float(cfg_scale),
143
+ )
144
+ return _tensor_to_pil(img)
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # 3. Interleaved Embodied Planning (text plan + goal images, step by step)
149
+ # ---------------------------------------------------------------------------
150
+ def _plan_duration(image, task, num_frames=3, num_steps=50, height=144, width=256, seed=0):
151
+ return int(min(230, 30 + num_frames * (25 + num_steps * 1.3)))
152
+
153
+
154
+ @spaces.GPU(duration=_plan_duration)
155
+ def embodied_plan(image, task, num_frames=3, num_steps=50, height=144, width=256, seed=0):
156
+ """Decompose an embodied task into steps, emitting both the next action
157
+ (text) and the imagined goal frame (image) for each step.
158
+
159
+ Args:
160
+ image: an observation frame of the current scene.
161
+ task: the embodied task to plan for.
162
+ num_frames: number of interleaved plan steps to roll out.
163
+ num_steps: number of flow-matching ODE steps per imagined frame.
164
+ height: imagined-frame height in pixels (multiple of 16).
165
+ width: imagined-frame width in pixels (multiple of 16).
166
+ seed: RNG seed for reproducibility.
167
+ Yields:
168
+ (gallery_of_goal_frames, plan_markdown) as each step completes.
169
+ """
170
+ if image is None:
171
+ raise gr.Error("Please provide an observation image.")
172
+ if not task or not task.strip():
173
+ raise gr.Error("Please enter a task description.")
174
+ torch.manual_seed(int(seed))
175
+ obs_path = _save_input_image(image)
176
+ h = int(height) // 16 * 16
177
+ w = int(width) // 16 * 16
178
+
179
+ prior_steps = [] # (text, frame_path) accumulated across steps
180
+ gallery = [] # (image, caption) for the gr.Gallery
181
+ plan_md_lines = [f"**Task:** {task.strip()}", ""]
182
+
183
+ for k in range(int(num_frames)):
184
+ text, img = _interleave_step(
185
+ model, vae, processor,
186
+ input_image_paths=[obs_path], task_text=task.strip(),
187
+ height=h, width=w, num_steps=int(num_steps),
188
+ device=DEVICE, dtype=DTYPE, max_text_tokens=96,
189
+ prior_steps=list(prior_steps),
190
+ )
191
+ pil = _tensor_to_pil(img)
192
+ frame_f = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
193
+ pil.save(frame_f.name)
194
+ prior_steps.append((text, frame_f.name))
195
+ gallery.append((pil, f"Step {k + 1}"))
196
+ plan_md_lines.append(f"**Step {k + 1}:** {text.strip() or '(imagined goal frame only)'}")
197
+ yield gallery, "\n\n".join(plan_md_lines)
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # UI
202
+ # ---------------------------------------------------------------------------
203
+ CSS = """
204
+ #col-container { max-width: 1100px; margin: 0 auto; }
205
+ .dark .gradio-container { color: var(--body-text-color); }
206
+ """
207
+
208
+ INTRO = """# 🔮 RxBrain — Embodied Cognition Foundation Model
209
+
210
+ **[tencent/Hy-Embodied-RxBrain-1.0](https://huggingface.co/tencent/Hy-Embodied-RxBrain-1.0)** — a
211
+ unified Mixture-of-Transformers model that couples **language reasoning** with **visual imagination**.
212
+ It answers questions about scenes, imagines images from text, and decomposes embodied tasks into
213
+ interleaved step-by-step *plans + goal images*.
214
+
215
+ *Tencent Robotics X × Futian Laboratory × Tencent Hunyuan · imagination decoded through a frozen FLUX VAE.*
216
+ """
217
+
218
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="RxBrain") as demo:
219
+ with gr.Column(elem_id="col-container"):
220
+ gr.Markdown(INTRO)
221
+
222
+ with gr.Tabs():
223
+ # ---------------- VQA ----------------
224
+ with gr.Tab("🧠 Visual QA"):
225
+ with gr.Row():
226
+ with gr.Column():
227
+ vqa_image = gr.Image(label="Image", type="pil", height=320)
228
+ vqa_question = gr.Textbox(
229
+ label="Question",
230
+ placeholder="What objects are on the stovetop?",
231
+ )
232
+ with gr.Accordion("Advanced", open=False):
233
+ vqa_max_tokens = gr.Slider(
234
+ 16, 512, value=256, step=16, label="Max new tokens")
235
+ vqa_btn = gr.Button("Answer", variant="primary")
236
+ with gr.Column():
237
+ vqa_out = gr.Textbox(label="Answer", lines=10)
238
+
239
+ vqa_btn.click(
240
+ vqa, inputs=[vqa_image, vqa_question, vqa_max_tokens],
241
+ outputs=vqa_out, api_name="vqa",
242
+ )
243
+ gr.Examples(
244
+ examples=[
245
+ ["examples/bridgev2_obs.jpg",
246
+ "What objects are on the stovetop, and where is the green toy?"],
247
+ ["examples/umi_sock_obs.jpg",
248
+ "Describe the scene and the objects the robot could manipulate."],
249
+ ["examples/xtrainer_garment_obs.jpg",
250
+ "What garment is on the surface and what is its current state?"],
251
+ ],
252
+ inputs=[vqa_image, vqa_question],
253
+ outputs=vqa_out, fn=vqa,
254
+ cache_examples=False, run_on_click=True,
255
+ )
256
+
257
+ # ---------------- T2I ----------------
258
+ with gr.Tab("🎨 Text-to-Image"):
259
+ with gr.Row():
260
+ with gr.Column():
261
+ t2i_prompt = gr.Textbox(
262
+ label="Prompt",
263
+ placeholder="a watercolor painting of a cat",
264
+ )
265
+ with gr.Accordion("Advanced", open=False):
266
+ with gr.Row():
267
+ t2i_h = gr.Slider(128, 384, value=256, step=16, label="Height")
268
+ t2i_w = gr.Slider(128, 384, value=256, step=16, label="Width")
269
+ t2i_steps = gr.Slider(10, 50, value=25, step=1, label="ODE steps")
270
+ t2i_cfg = gr.Slider(1.0, 7.0, value=1.0, step=0.5,
271
+ label="CFG scale (>1 enables guidance)")
272
+ t2i_seed = gr.Number(value=0, precision=0, label="Seed")
273
+ t2i_btn = gr.Button("Imagine", variant="primary")
274
+ with gr.Column():
275
+ t2i_out = gr.Image(label="Imagined image", height=320)
276
+
277
+ t2i_btn.click(
278
+ text_to_image,
279
+ inputs=[t2i_prompt, t2i_h, t2i_w, t2i_steps, t2i_cfg, t2i_seed],
280
+ outputs=t2i_out, api_name="text_to_image",
281
+ )
282
+ gr.Examples(
283
+ examples=[
284
+ ["a watercolor painting of a cat"],
285
+ ["a red apple on a wooden table, photorealistic"],
286
+ ["a robot arm gripping a colorful toy on a kitchen counter"],
287
+ ],
288
+ inputs=[t2i_prompt],
289
+ outputs=t2i_out, fn=text_to_image,
290
+ cache_examples=False, run_on_click=True,
291
+ )
292
+
293
+ # ---------------- Interleaved Planning ----------------
294
+ with gr.Tab("🧩 Embodied Planning"):
295
+ gr.Markdown(
296
+ "Give an observation frame + a task; RxBrain rolls out an interleaved "
297
+ "plan — each step emits an **action (text)** and the **imagined goal frame**."
298
+ )
299
+ with gr.Row():
300
+ with gr.Column():
301
+ plan_image = gr.Image(label="Observation", type="pil", height=280)
302
+ plan_task = gr.Textbox(label="Task", lines=3,
303
+ placeholder="Move the green toy over the metal pot.")
304
+ with gr.Accordion("Advanced", open=False):
305
+ plan_nframes = gr.Slider(1, 5, value=3, step=1, label="Plan steps")
306
+ plan_steps = gr.Slider(10, 50, value=50, step=1, label="ODE steps/frame")
307
+ with gr.Row():
308
+ plan_h = gr.Slider(128, 256, value=144, step=16, label="Frame height")
309
+ plan_w = gr.Slider(128, 384, value=256, step=16, label="Frame width")
310
+ plan_seed = gr.Number(value=0, precision=0, label="Seed")
311
+ plan_btn = gr.Button("Plan", variant="primary")
312
+ with gr.Column():
313
+ plan_gallery = gr.Gallery(label="Imagined goal frames", columns=3, height=280)
314
+ plan_text = gr.Markdown(label="Plan")
315
+
316
+ plan_btn.click(
317
+ embodied_plan,
318
+ inputs=[plan_image, plan_task, plan_nframes, plan_steps,
319
+ plan_h, plan_w, plan_seed],
320
+ outputs=[plan_gallery, plan_text], api_name="embodied_plan",
321
+ )
322
+ gr.Examples(
323
+ examples=[
324
+ ["examples/bridgev2_obs.jpg",
325
+ "Move the green toy from the left side of the stovetop and hold it over "
326
+ "the metal pot on the left burner. Please give the full plan as concrete "
327
+ "physical actions, one step per action, and imagine the state once each "
328
+ "step is completed. If only one step remains, provide only that final step."],
329
+ ["examples/umi_sock_obs.jpg",
330
+ "Fold the purple sock in half. Please lay out the high-level subgoal plan "
331
+ "and imagine the resulting frame after each major stage. If only one "
332
+ "subgoal remains, provide only that final subgoal."],
333
+ ],
334
+ inputs=[plan_image, plan_task],
335
+ outputs=[plan_gallery, plan_text], fn=embodied_plan,
336
+ cache_examples=False, run_on_click=True,
337
+ )
338
+
339
+ if __name__ == "__main__":
340
+ demo.queue(max_size=12).launch(mcp_server=True)
examples/bridgev2_obs.jpg ADDED

Git LFS Details

  • SHA256: 6de406a2da4e3342f6b0186e6e8890a3d73d84f6da3b2f8ac3b8bc6b82721b35
  • Pointer size: 131 Bytes
  • Size of remote file: 193 kB
examples/umi_sock_obs.jpg ADDED

Git LFS Details

  • SHA256: de00dc51acb902ebc25edf6cc7b8bc394f7674328a3c00cbab3a06128ba57b04
  • Pointer size: 131 Bytes
  • Size of remote file: 112 kB
examples/xtrainer_garment_obs.jpg ADDED
inference_utils.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference-time utilities for HY-Unified.
2
+
3
+ Self-contained slice of the pieces the inference scripts need — the image
4
+ transform used to derive train/inference resolution parity, and the
5
+ task-instruction prompts that cue each generation mode.
6
+
7
+ The SAME task strings are used at train and inference (parity is load-bearing:
8
+ ``interleave_inference`` / ``multiframe_inference`` build the prompt the same
9
+ way the model was trained). Single source of truth, imported by the scripts.
10
+ """
11
+
12
+ import torch
13
+ from PIL import Image
14
+ import torchvision.transforms as T
15
+ import torchvision.transforms.functional as TF
16
+ from torchvision.transforms import InterpolationMode
17
+
18
+
19
+ # Task-instruction prompts appended to the user turn so each generation MODE is
20
+ # explicitly cued.
21
+ TASK_INSTRUCTION_SINGLE_FRAME = "Generate future image of the goal"
22
+ TASK_INSTRUCTION_MULTI_FRAME = "Generate future video of the goal"
23
+ TASK_INSTRUCTION_INTERLEAVE = "Generate interleave goal planning"
24
+
25
+
26
+ class VAEImageTransform:
27
+ """Aspect-preserving resize + normalize to [-1, 1] for VAE encoding.
28
+
29
+ Sides are clamped to be ``stride``-divisible and within ``[min_size,
30
+ max_size]``; extreme aspect ratios are re-scaled to stay inside ``max_size``
31
+ (otherwise latent position IDs could go out of bounds).
32
+ """
33
+
34
+ def __init__(self, max_size=512, min_size=256, stride=16):
35
+ self.max_size = max_size
36
+ self.min_size = min_size
37
+ self.stride = stride
38
+
39
+ def _make_divisible(self, val):
40
+ return max(self.stride, round(val / self.stride) * self.stride)
41
+
42
+ def __call__(self, img: Image.Image) -> torch.Tensor:
43
+ w, h = img.size
44
+ scale = min(self.max_size / max(w, h), 1.0)
45
+ scale = max(scale, self.min_size / min(w, h))
46
+ new_w = self._make_divisible(round(w * scale))
47
+ new_h = self._make_divisible(round(h * scale))
48
+ # Clamp to max_size to handle extreme aspect ratios where the min_size
49
+ # constraint overrides the max_size constraint (e.g. w=1000,h=50 would
50
+ # produce new_w=5120 which causes position ID out-of-bounds).
51
+ max_div = self._make_divisible(self.max_size)
52
+ if new_w > max_div or new_h > max_div:
53
+ # Re-scale to fit within max_size while preserving aspect ratio.
54
+ shrink = min(max_div / new_w, max_div / new_h)
55
+ new_w = self._make_divisible(round(new_w * shrink))
56
+ new_h = self._make_divisible(round(new_h * shrink))
57
+ img = TF.resize(img, (new_h, new_w), InterpolationMode.BICUBIC, antialias=True)
58
+ tensor = TF.to_tensor(img) # [0, 1]
59
+ tensor = T.Normalize([0.5] * 3, [0.5] * 3)(tensor) # [-1, 1]
60
+ return tensor
interleave_inference.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interleaved multi-image inference with a SigLIP-context handoff.
2
+
3
+ Generates a sequence of frames autoregressively:
4
+
5
+ for k in 1..Y:
6
+ seq_k = chat_template(user[obs + decoded f_1..f_{k-1}] + text) + <Image>+LAT*N+</Image>+EOS
7
+ x_0 = Euler-ODE(seq_k) # prior frames condition via SigLIP
8
+ f_k = VAE.decode(x_0) # decode to pixels
9
+ # f_k is fed (as a SigLIP image) into seq_{k+1}
10
+
11
+ The crucial property: a previously generated frame enters the next step's context
12
+ as a **SigLIP-encoded image in a user turn** — NOT as its VAE latent — so the
13
+ context representation is identical at train and test (clean pixels either way),
14
+ avoiding the noised-VAE-latent train/test mismatch of single-sequence interleave.
15
+
16
+ `build_conditioned_sequence` is the single sequence constructor; training builds
17
+ the prompt the same way (apply_chat_template([user(images)+text, assistant:""])
18
+ -> strip EOS -> <Image>+LAT*N+</Image>+EOS), so train and infer token layouts
19
+ match exactly.
20
+
21
+ Run:
22
+ python interleave_inference.py --ckpt <ckpt> --vae <vae> \
23
+ --frames obs.jpg --task "put the cup on the shelf" \
24
+ --max_frames 3 --num_steps 50 --out_dir out_interleave
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import os
30
+ from typing import List, Optional
31
+
32
+ import torch
33
+ from PIL import Image
34
+ from transformers.models.hunyuan_vl_mot import HunYuanVLMoTProcessor
35
+
36
+ # NOTE: heavy imports (model package -> flash_attn, vae_model) are done lazily
37
+ # inside the functions that need them, so `build_conditioned_sequence` can be
38
+ # imported with only a processor.
39
+
40
+ # Input-image placeholder token ids the upstream HunYuanVL processor scatters
41
+ # ViT features into.
42
+ INPUT_IMAGE_PLACEHOLDER_IDS = (120687, 120688)
43
+
44
+
45
+ def build_conditioned_sequence(processor, input_image_paths: List[str], prompt_text: str,
46
+ prior_steps=None, task_instruction: Optional[str] = None):
47
+ """Build the prompt token ids + processor vision tensors for a
48
+ (multi-image + text) -> 1-generated-image sample.
49
+
50
+ apply_chat_template([user(...), assistant:""]) then strip trailing EOS.
51
+
52
+ prior_steps: optional list of (text, frame_path) for already-produced steps.
53
+ When given, the user turn is the interleaved context the JOINT converter
54
+ emits — [obs frames] + task + [text_1, frame_1, ..., text_m, frame_m] —
55
+ so prior plan texts stay in context and prior frames enter as SigLIP inputs.
56
+ input_image_paths are then the OBSERVATION frames only.
57
+ When None, the legacy [all images] + text layout is used.
58
+ task_instruction: optional mode instruction appended as the LAST user-text item,
59
+ e.g. "Generate interleave goal planning".
60
+
61
+ Returns (prompt_ids: list[int], proc_inputs: dict).
62
+ """
63
+ user_content = [{"type": "image", "image": p} for p in input_image_paths]
64
+ user_content.append({"type": "text", "text": prompt_text})
65
+ for t, f in (prior_steps or []):
66
+ if t:
67
+ user_content.append({"type": "text", "text": t})
68
+ user_content.append({"type": "image", "image": f})
69
+ if task_instruction:
70
+ user_content.append({"type": "text", "text": task_instruction})
71
+ prompt_messages = [
72
+ {"role": "user", "content": user_content},
73
+ {"role": "assistant", "content": ""},
74
+ ]
75
+ proc_inputs = processor.apply_chat_template(
76
+ prompt_messages, return_dict=True, tokenize=True, add_generation_prompt=False,
77
+ )
78
+ eos = processor.tokenizer.eos_token_id
79
+ prompt_ids = list(proc_inputs["input_ids"][0])
80
+ while prompt_ids and prompt_ids[-1] == eos:
81
+ prompt_ids.pop()
82
+ return prompt_ids, proc_inputs
83
+
84
+
85
+ @torch.no_grad()
86
+ def generate_step_joint(
87
+ model, vae, processor,
88
+ input_image_paths: List[str],
89
+ task_text: str,
90
+ height: int, width: int, num_steps: int,
91
+ device, dtype,
92
+ max_text_tokens: int = 64,
93
+ prior_steps=None,
94
+ ):
95
+ """v2 joint step: autoregressively decode the plan TEXT, then (on <Image>)
96
+ flow-match the FRAME. Returns (text_str, image_tensor).
97
+
98
+ prior_steps: list of (text, frame_path) already produced — threaded into the
99
+ context so the rollout matches the JOINT training layout (prior texts kept,
100
+ prior frames as SigLIP). When given, input_image_paths = observation frames only.
101
+
102
+ The prefix is built by the SAME `build_conditioned_sequence` used at train
103
+ time, so the text the model emits after `</answer>` and the <Image> trigger
104
+ match what training taught."""
105
+ from model.flow_matching_modules import unpatchify_latent
106
+ from text2image_inference import get_2d_position_ids
107
+
108
+ cfg = model.config
109
+ eos = cfg.eos_token_id
110
+ image_start = cfg.image_start_token_id
111
+ latent_ph = cfg.flow_latent_placeholder_id
112
+ inner = model.model
113
+
114
+ # Append the interleave mode instruction (train==infer parity).
115
+ from inference_utils import TASK_INSTRUCTION_INTERLEAVE
116
+ prompt_ids, proc = build_conditioned_sequence(processor, input_image_paths, task_text,
117
+ prior_steps=prior_steps,
118
+ task_instruction=TASK_INSTRUCTION_INTERLEAVE)
119
+ pixel_values = proc.get("pixel_values")
120
+ image_grid_thw = proc.get("image_grid_thw")
121
+ if pixel_values is not None:
122
+ pixel_values = pixel_values.to(device=device, dtype=dtype)
123
+ if image_grid_thw is not None:
124
+ image_grid_thw = image_grid_thw.to(device=device)
125
+
126
+ # ---- 1. autoregressive text decode until <Image> (KV-cached: exact, ~2.8x) ----
127
+ # prefill the prompt once (use_cache), then feed one token/step against the growing
128
+ # KV cache — the model's native past_key_values plumbing (Mode C handles the decode
129
+ # shape). Validated bit-identical to the non-cached per-token full-forward decode.
130
+ from transformers.cache_utils import DynamicCache
131
+
132
+ def _dmask(ids):
133
+ seq_len = len(ids)
134
+ inp = torch.tensor(ids, dtype=torch.long, device=device).unsqueeze(0)
135
+ mod = torch.zeros(1, seq_len, dtype=torch.long, device=device)
136
+ iim = torch.zeros(1, seq_len, dtype=torch.bool, device=device)
137
+ for pid in INPUT_IMAGE_PLACEHOLDER_IDS:
138
+ m = inp[0] == pid
139
+ mod[0, m] = 1
140
+ iim[0, m] = True
141
+ return inp, mod, iim
142
+
143
+ _empty_g = torch.zeros((0, 2), dtype=torch.int32, device=device)
144
+ pkv = DynamicCache()
145
+ prompt_len = len(prompt_ids)
146
+ inp, mod, iim = _dmask(list(prompt_ids))
147
+ out = inner(input_ids=inp, position_ids=torch.arange(prompt_len, device=device).unsqueeze(0),
148
+ pixel_values=pixel_values, image_grid_thw=image_grid_thw,
149
+ cu_seqlens=torch.tensor([0, prompt_len], dtype=torch.int32, device=device),
150
+ sample_ids=torch.zeros(1, prompt_len, dtype=torch.int32, device=device),
151
+ modality_mask=mod, input_image_mask=iim,
152
+ flow_embeds=None, flow_positions=None, g_seqlens=_empty_g,
153
+ use_cache=True, past_key_values=pkv, cache_position=torch.arange(prompt_len, device=device))
154
+ pkv = out.past_key_values
155
+ nxt = int(out.logits[0, -1].argmax().item())
156
+ cur = prompt_len
157
+ text_ids = []
158
+ for _ in range(max_text_tokens):
159
+ if nxt == image_start or nxt == eos:
160
+ break
161
+ text_ids.append(nxt)
162
+ out = inner(input_ids=torch.tensor([[nxt]], dtype=torch.long, device=device),
163
+ position_ids=torch.tensor([[cur]], device=device),
164
+ pixel_values=None, image_grid_thw=None,
165
+ cu_seqlens=torch.tensor([0, cur + 1], dtype=torch.int32, device=device),
166
+ sample_ids=torch.zeros(1, 1, dtype=torch.int32, device=device),
167
+ modality_mask=torch.zeros(1, 1, dtype=torch.long, device=device),
168
+ input_image_mask=torch.zeros(1, 1, dtype=torch.bool, device=device),
169
+ flow_embeds=None, flow_positions=None, g_seqlens=_empty_g,
170
+ use_cache=True, past_key_values=pkv, cache_position=torch.tensor([cur], device=device))
171
+ pkv = out.past_key_values
172
+ nxt = int(out.logits[0, -1].argmax().item())
173
+ cur += 1
174
+ seq = list(prompt_ids) + text_ids
175
+ text_str = processor.tokenizer.decode(text_ids, skip_special_tokens=True)
176
+
177
+ # ---- 2. flow-match the frame — diffusion PREFIX-CACHE: prefill (prompt+text+IMAGE_START)
178
+ # once, then each denoise step forwards ONLY the n latent tokens against the cached prefix
179
+ # (Mode C causal=False -> latents attend bidirectionally to prefix+latents, == the gen-block).
180
+ # Validated equivalent to the full-forward loop within ROCm non-determinism. ----
181
+ p = model.latent_patch_size
182
+ ds = cfg.vae_image_downsample
183
+ h_lat, w_lat = height // ds, width // ds
184
+ n_latent = h_lat * w_lat
185
+ prefix = seq + [image_start]
186
+ prefix_len = len(prefix) # = latent_start
187
+ inp_p, mod_p, iim_p = _dmask(prefix)
188
+ pkv_f = DynamicCache()
189
+ inner(input_ids=inp_p, position_ids=torch.arange(prefix_len, device=device).unsqueeze(0),
190
+ pixel_values=pixel_values, image_grid_thw=image_grid_thw,
191
+ cu_seqlens=torch.tensor([0, prefix_len], dtype=torch.int32, device=device),
192
+ sample_ids=torch.zeros(1, prefix_len, dtype=torch.int32, device=device),
193
+ modality_mask=mod_p, input_image_mask=iim_p,
194
+ flow_embeds=None, flow_positions=None, g_seqlens=_empty_g,
195
+ use_cache=True, past_key_values=pkv_f, cache_position=torch.arange(prefix_len, device=device))
196
+
197
+ latent_pos_ids = get_2d_position_ids(h_lat, w_lat, cfg.max_latent_size).to(device)
198
+ latent_pos_emb = model.latent_pos_embed(latent_pos_ids).to(dtype)
199
+ lat_ids = torch.tensor([[latent_ph] * n_latent], dtype=torch.long, device=device)
200
+ fp_rel = torch.tensor([[0, n_latent]], dtype=torch.int32, device=device) # rel to the n-token input
201
+ mod2 = torch.full((1, n_latent), 2, dtype=torch.long, device=device)
202
+ iim2 = torch.zeros(1, n_latent, dtype=torch.bool, device=device)
203
+ pos_l = torch.arange(prefix_len, prefix_len + n_latent, device=device).unsqueeze(0)
204
+ cu_l = torch.tensor([0, prefix_len + n_latent], dtype=torch.int32, device=device)
205
+ sid_l = torch.zeros(1, n_latent, dtype=torch.int32, device=device)
206
+
207
+ x = torch.randn(n_latent, p * p * cfg.vae_z_channels, device=device, dtype=dtype)
208
+ ts = torch.linspace(1.0, 0.0, num_steps + 1, device=device, dtype=dtype)
209
+ for i in range(num_steps):
210
+ t, dt = ts[i], ts[i] - ts[i + 1]
211
+ fe = (model.vae2llm(x.to(model.vae2llm.weight.dtype)).to(dtype)
212
+ + model.time_embedder(t.expand(n_latent)).to(dtype) + latent_pos_emb)
213
+ out = inner(input_ids=lat_ids, inputs_embeds=None, attention_mask=None,
214
+ position_ids=pos_l, pixel_values=None, image_grid_thw=None,
215
+ cu_seqlens=cu_l, sample_ids=sid_l, modality_mask=mod2, input_image_mask=iim2,
216
+ flow_embeds=fe, flow_positions=fp_rel, g_seqlens=_empty_g,
217
+ use_cache=True, past_key_values=pkv_f, cache_position=pos_l[0])
218
+ v = model.llm2vae(out.hidden_states[0, 0:n_latent]).to(dtype)
219
+ x = x - dt * v
220
+ pkv_f.crop(prefix_len) # drop the latents; keep the fixed prefix for next step
221
+ x_lat = unpatchify_latent(x.float(), h_lat, w_lat, p, cfg.vae_z_channels)
222
+ x_lat = x_lat.unsqueeze(0).to(device=device, dtype=next(vae.parameters()).dtype)
223
+ img = vae.decode(x_lat)
224
+ if hasattr(img, "sample"):
225
+ img = img.sample
226
+ return text_str, img.squeeze(0).float().clamp(-1, 1)
227
+
228
+
229
+ @torch.no_grad()
230
+ def interleave_generate_joint(
231
+ model, vae, processor,
232
+ obs_frames: List[str], task_text: str, num_frames: int,
233
+ height: int, width: int, num_steps: int, device, dtype, out_dir: str,
234
+ max_text_tokens: int = 64,
235
+ ):
236
+ """v2 rollout: at each step the model emits plan text + a frame; the decoded
237
+ frame AND its plan text are fed back as context for the next step — prior
238
+ frames via SigLIP, prior texts kept — matching the JOINT training layout."""
239
+ import os
240
+ os.makedirs(out_dir, exist_ok=True)
241
+ gen_paths, texts = [], []
242
+ prior_steps = [] # (text, frame_path) accumulated across steps
243
+ for k in range(num_frames):
244
+ text, img = generate_step_joint(
245
+ model, vae, processor, list(obs_frames), task_text,
246
+ height, width, num_steps, device, dtype, max_text_tokens,
247
+ prior_steps=list(prior_steps),
248
+ )
249
+ arr = ((img.cpu().permute(1, 2, 0).numpy() + 1.0) * 127.5).clip(0, 255).astype("uint8")
250
+ path = os.path.join(out_dir, f"joint_step{k+1}.png")
251
+ Image.fromarray(arr).save(path)
252
+ gen_paths.append(path)
253
+ texts.append(text)
254
+ prior_steps.append((text, path))
255
+ print(f" [joint step {k+1}/{num_frames}] prior={len(prior_steps)-1} | TEXT: {text[:80]!r} -> {path}")
256
+ return gen_paths, texts
257
+
258
+
259
+ def _row(paths, w, h):
260
+ """Horizontal strip of images (resized to w x h, 4px gaps)."""
261
+ imgs = [Image.open(p).convert("RGB").resize((w, h)) for p in paths]
262
+ n = len(imgs)
263
+ strip = Image.new("RGB", (w * n + 4 * (n - 1), h), (20, 20, 20))
264
+ for i, im in enumerate(imgs):
265
+ strip.paste(im, (i * (w + 4), 0))
266
+ return strip
267
+
268
+
269
+ def main():
270
+ ap = argparse.ArgumentParser(description="Joint interleaved rollout: decode ALL plan text + ALL frames.")
271
+ ap.add_argument("--ckpt", required=True)
272
+ ap.add_argument("--vae", required=True)
273
+ ap.add_argument("--frames", nargs="+", required=True, help="observation frame path(s)")
274
+ ap.add_argument("--task", required=True, help="overall task text")
275
+ ap.add_argument("--max_frames", type=int, default=None, help="cap #frames to generate")
276
+ ap.add_argument("--out_dir", default="out_interleave")
277
+ ap.add_argument("--height", type=int, default=144)
278
+ ap.add_argument("--width", type=int, default=256)
279
+ ap.add_argument("--num_steps", type=int, default=50, help="ODE steps per frame")
280
+ ap.add_argument("--max_text_tokens", type=int, default=96)
281
+ ap.add_argument("--seed", type=int, default=0)
282
+ ap.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
283
+ ap.add_argument("--understanding_max_pixels", type=int, default=524288,
284
+ help="Cap obs/prior-frame ViT input pixels to MATCH training. "
285
+ "Default 524288 (~494 tok/frame); the model default 4194304 (~4050 tok/frame) "
286
+ "is an 8x train/infer resolution mismatch that degrades eval. Set 0 to disable.")
287
+ args = ap.parse_args()
288
+
289
+ from model import UnifiedMoTForConditionalGeneration, maybe_init_generation_path
290
+ from vae_model.autoencoder import load_ae
291
+
292
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
293
+ dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype]
294
+ torch.manual_seed(args.seed)
295
+
296
+ # ---- resolve obs frames + task ----
297
+ obs, task, n_frames = args.frames, args.task, (args.max_frames or 1)
298
+
299
+ processor = HunYuanVLMoTProcessor.from_pretrained(args.ckpt, trust_remote_code=True)
300
+ # train==infer parity: cap obs/prior-frame ViT pixels to the SAME value training
301
+ # used. Prior frames re-enter via SigLIP at rollout, so this must match training too.
302
+ if args.understanding_max_pixels and args.understanding_max_pixels > 0:
303
+ ip = processor.image_processor
304
+ ip.max_pixels = args.understanding_max_pixels
305
+ if isinstance(getattr(ip, "size", None), dict) and "longest_edge" in ip.size:
306
+ ip.size["longest_edge"] = args.understanding_max_pixels
307
+ model = UnifiedMoTForConditionalGeneration.from_pretrained(args.ckpt, dtype=dtype)
308
+ maybe_init_generation_path(model, model_load_path=args.ckpt)
309
+ model.to(device).eval()
310
+ vae, _ = load_ae(args.vae)
311
+ vae.requires_grad_(False)
312
+ vae.eval()
313
+ vae.to(device, dtype=dtype)
314
+
315
+ os.makedirs(args.out_dir, exist_ok=True)
316
+ print(f"JOINT interleaved rollout | obs={len(obs)} frame(s) | steps={n_frames}")
317
+ print(f"TASK: {task}\n")
318
+
319
+ gen_paths, gen_texts = interleave_generate_joint(
320
+ model, vae, processor, obs, task, n_frames,
321
+ args.height, args.width, args.num_steps, device, dtype, args.out_dir,
322
+ max_text_tokens=args.max_text_tokens,
323
+ )
324
+
325
+ # ---- write the FULL decoded output (text) + montages (image) ----
326
+ lines = [f"TASK: {task}", ""]
327
+ for k in range(len(gen_texts)):
328
+ lines.append(f"--- step {k+1} ---")
329
+ lines.append(f"GEN text: {gen_texts[k]}")
330
+ lines.append("")
331
+ txt_path = os.path.join(args.out_dir, "result.txt")
332
+ open(txt_path, "w").write("\n".join(lines))
333
+ print("\n".join(lines))
334
+
335
+ # image montage: GEN row
336
+ width, height = args.width, args.height
337
+ gen_row = _row(gen_paths, width, height)
338
+ gen_row.save(os.path.join(args.out_dir, "rollout_GEN.png"))
339
+ print(f"\nDecoded {len(gen_paths)} frames + {len(gen_texts)} texts -> {args.out_dir}")
340
+ print(f" text : {txt_path}")
341
+ print(f" images: {args.out_dir}/joint_step*.png + rollout montage")
342
+
343
+
344
+ if __name__ == "__main__":
345
+ main()
model/__init__.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HY-Unified model package.
2
+
3
+ New entry points — built on transformers 4.57's upstream
4
+ hunyuan_vl_mot base model, extended with the third (`_g`) generation MoT path
5
+ and Flow Matching modules.
6
+
7
+ Importing this package triggers the module-level replacement of upstream's
8
+ `_flash_attention_forward_mot` with our packed/padded/decode three-stage
9
+ version (see `attention_mot_packed`).
10
+
11
+ Public surface:
12
+ UnifiedMoTConfig, UnifiedMoTTextConfig
13
+ UnifiedMoTForConditionalGeneration, UnifiedMoTModel, UnifiedMoTOutput
14
+ MoTDecoderLayer
15
+ mask_apply_3way
16
+ maybe_init_generation_path
17
+ TimestepEmbedder, PositionEmbedding
18
+ pack_latents, sample_timesteps, build_noisy_latent, patch_boundary_loss
19
+ """
20
+ # Order matters: attention rebind must happen before any HunYuanVLMoT* instantiation.
21
+ from . import attention_mot_packed # noqa: F401 (side effect)
22
+
23
+ from .config_unified_mot import UnifiedMoTConfig, UnifiedMoTTextConfig
24
+ from .modeling_decoder_mot import MoTDecoderLayer, mask_apply_3way
25
+ from .modeling_text_model_mot import MoTTextModel, MoTTextForCausalLM
26
+ from .modeling_unified_mot import (
27
+ UnifiedMoTForConditionalGeneration,
28
+ UnifiedMoTModel,
29
+ UnifiedMoTOutput,
30
+ HY_VL_MOT_IMAGE_TOKEN_ID,
31
+ HY_VL_MOT_VIDEO_TOKEN_ID,
32
+ )
33
+ from .mot_init_utils import (
34
+ maybe_init_generation_path,
35
+ init_mlp_g_from_mlp_v_net2wider,
36
+ verify_mlp_g_equals_mlp_v,
37
+ checkpoint_has_g_keys,
38
+ )
39
+ from .flow_matching_modules import (
40
+ TimestepEmbedder,
41
+ PositionEmbedding,
42
+ pack_latents,
43
+ sample_timesteps,
44
+ build_noisy_latent,
45
+ patch_boundary_loss,
46
+ )
47
+
48
+
49
+ __all__ = [
50
+ # Config
51
+ "UnifiedMoTConfig", "UnifiedMoTTextConfig",
52
+ # Models
53
+ "UnifiedMoTForConditionalGeneration", "UnifiedMoTModel", "UnifiedMoTOutput",
54
+ "MoTDecoderLayer", "MoTTextModel", "MoTTextForCausalLM",
55
+ "mask_apply_3way",
56
+ # Token IDs
57
+ "HY_VL_MOT_IMAGE_TOKEN_ID", "HY_VL_MOT_VIDEO_TOKEN_ID",
58
+ # Initialization
59
+ "maybe_init_generation_path", "init_mlp_g_from_mlp_v_net2wider",
60
+ "verify_mlp_g_equals_mlp_v", "checkpoint_has_g_keys",
61
+ # Flow Matching utils
62
+ "TimestepEmbedder", "PositionEmbedding",
63
+ "pack_latents", "sample_timesteps", "build_noisy_latent", "patch_boundary_loss",
64
+ ]
model/attention_mot_packed.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Three-stage packed/padded MoT flash attention for UnifiedMoT.
2
+
3
+ This module replaces the upstream `_flash_attention_forward_mot` (a module-level
4
+ function in `transformers.models.hunyuan_vl_mot.modeling_hunyuan_vl_mot`) with a
5
+ version that supports three modes:
6
+
7
+ A) Packed prefill (P-Pack training)
8
+ attention_mask is a dict carrying `cu_seqlens` (N+1,), `sample_ids` (1, T),
9
+ and `g_seqlens` (M, 2). Inputs are (1, T, H, D) — a single packed row of
10
+ concatenated samples. Three-pass attention:
11
+ 1) Causal varlen — `cu_seqlens` enforces sample boundaries
12
+ 2) Visual bidirectional override on each `v_seqlens` segment (absolute coords)
13
+ 3) Generation-block override on each `g_seqlens` segment, with KV range
14
+ `[sample_start, g_e]` so KV NEVER crosses sample boundaries
15
+
16
+ B) Padded prefill (legacy / inference fallback)
17
+ attention_mask dict has `padding_mask` (B, S). Same as upstream:
18
+ unpad → varlen → repad. Visual override layered on top. Generation override
19
+ not used in this mode.
20
+
21
+ C) Decode (KV-cache active, S_q != S_k)
22
+ Simple (B, 1, H, D) path with no varlen.
23
+
24
+ The replacement is **module-scope rebinding** of upstream's
25
+ `_flash_attention_forward_mot` — surgical and reversible. Apply by importing
26
+ this module before instantiating any HunYuanVLMoT* class:
27
+
28
+ from model import attention_mot_packed # noqa: F401
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from typing import Optional
34
+
35
+ import torch
36
+ import torch.nn as nn
37
+
38
+ from flash_attn import flash_attn_varlen_func
39
+ from transformers.models.hunyuan_vl_mot import modeling_hunyuan_vl_mot as _upstream
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Helpers
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def _has_ranges(obj) -> bool:
49
+ if obj is None:
50
+ return False
51
+ if torch.is_tensor(obj):
52
+ return obj.numel() > 0 and obj.shape[-1] in (2, 3)
53
+ if isinstance(obj, list):
54
+ if not obj:
55
+ return False
56
+ if torch.is_tensor(obj[0]):
57
+ return any(t.numel() > 0 for t in obj)
58
+ return True
59
+ return False
60
+
61
+
62
+ def _iter_g_ranges(obj):
63
+ """Yield (g_start, g_end, batch_idx) triples from `g_seqlens`.
64
+
65
+ Accepts:
66
+ - Tensor of shape (M, 2) — absolute packed coords (b_idx implicit = 0)
67
+ - Tensor of shape (M, 3) — (s, e, b_idx)
68
+ - List of tuples — same as above
69
+ - List of (N_i, 2) tensors (per-sample, padded mode)
70
+ """
71
+ if torch.is_tensor(obj):
72
+ for row in obj.tolist():
73
+ if len(row) == 2:
74
+ yield int(row[0]), int(row[1]), 0
75
+ else:
76
+ yield int(row[0]), int(row[1]), int(row[2])
77
+ elif isinstance(obj, list):
78
+ if obj and torch.is_tensor(obj[0]):
79
+ for b_idx, t in enumerate(obj):
80
+ if t.numel() == 0:
81
+ continue
82
+ for row in t.tolist():
83
+ yield int(row[0]), int(row[1]), b_idx
84
+ else:
85
+ for item in obj:
86
+ if torch.is_tensor(item):
87
+ item = item.tolist()
88
+ if len(item) == 2:
89
+ yield int(item[0]), int(item[1]), 0
90
+ else:
91
+ yield int(item[0]), int(item[1]), int(item[2])
92
+
93
+
94
+ def _find_sample_start(cu_seqlens: torch.Tensor, g_s: int) -> int:
95
+ """Largest cu_seqlens[i] <= g_s. Used when sample_ids is unavailable."""
96
+ cu = cu_seqlens.tolist() if torch.is_tensor(cu_seqlens) else cu_seqlens
97
+ lo, hi = 0, len(cu) - 1
98
+ while lo < hi:
99
+ mid = (lo + hi + 1) // 2
100
+ if cu[mid] <= g_s:
101
+ lo = mid
102
+ else:
103
+ hi = mid - 1
104
+ return int(cu[lo])
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Step 2 helper: visual bidirectional override on (B, S, H, D) tensors
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _apply_visual_bidirectional(
112
+ attn_output: torch.Tensor,
113
+ query: torch.Tensor,
114
+ key: torch.Tensor,
115
+ value: torch.Tensor,
116
+ v_seqlens,
117
+ ) -> torch.Tensor:
118
+ """Override input-image segments with non-causal flash attention.
119
+
120
+ Operates on (B, S, H, D). v_seqlens may be a (M, 2) tensor (B==1) or a list
121
+ of (N_i, 2) tensors (per-sample for B>1 padded mode).
122
+
123
+ PERF: convert each segs tensor to CPU once (`.tolist()`) instead of
124
+ issuing two `.item()` syncs per row inside the inner loop. Each layer
125
+ calls this once during forward and once during backward; without the
126
+ fix, 32 layers × ~25 segments × 2 .item()/seg × 2 (fwd+bwd) ≈ 3200
127
+ GPU→CPU syncs per step, each blocking on the previous attention kernel.
128
+ """
129
+ device = query.device
130
+ if isinstance(v_seqlens, list):
131
+ segs_list = v_seqlens
132
+ else:
133
+ segs_list = [v_seqlens] # B==1 path
134
+
135
+ visual_q = []
136
+ visual_k = []
137
+ visual_v = []
138
+ cu_v = [0]
139
+ max_v = 0
140
+ write_back = [] # (b_idx, s, e)
141
+ has_any = False
142
+
143
+ for b_idx, segs in enumerate(segs_list):
144
+ if segs is None or not torch.is_tensor(segs) or segs.numel() == 0:
145
+ continue
146
+ # PERF: one bulk D2H copy instead of per-element .item()
147
+ segs_cpu = segs.tolist()
148
+ for row in segs_cpu:
149
+ s = int(row[0])
150
+ e = int(row[1])
151
+ if e <= s:
152
+ continue
153
+ has_any = True
154
+ visual_q.append(query[b_idx, s:e])
155
+ visual_k.append(key[b_idx, s:e])
156
+ visual_v.append(value[b_idx, s:e])
157
+ ln = e - s
158
+ cu_v.append(cu_v[-1] + ln)
159
+ max_v = max(max_v, ln)
160
+ write_back.append((b_idx, s, e))
161
+
162
+ if not has_any:
163
+ # Preserve autograd graph topology across ranks even when this rank has
164
+ # no visual segments (matches upstream's `fake_visual` trick).
165
+ dummy = query[:1, :1].sum() * 0
166
+ return attn_output + dummy
167
+
168
+ vq = torch.cat(visual_q, dim=0)
169
+ vk = torch.cat(visual_k, dim=0)
170
+ vv = torch.cat(visual_v, dim=0)
171
+ cu_v_t = torch.tensor(cu_v, device=device, dtype=torch.int32)
172
+
173
+ vis_out = flash_attn_varlen_func(
174
+ vq, vk, vv,
175
+ cu_seqlens_q=cu_v_t, cu_seqlens_k=cu_v_t,
176
+ max_seqlen_q=max_v, max_seqlen_k=max_v,
177
+ causal=False,
178
+ )
179
+
180
+ attn_output = attn_output.clone()
181
+ off = 0
182
+ for b_idx, s, e in write_back:
183
+ ln = e - s
184
+ attn_output[b_idx, s:e] = vis_out[off:off + ln]
185
+ off += ln
186
+ return attn_output
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Step 3 helper: generation-block override (P-Pack only)
191
+ # ---------------------------------------------------------------------------
192
+
193
+ def _apply_generation_block(
194
+ attn_output: torch.Tensor,
195
+ query: torch.Tensor,
196
+ key: torch.Tensor,
197
+ value: torch.Tensor,
198
+ g_seqlens,
199
+ cu_seqlens: torch.Tensor,
200
+ sample_ids: Optional[torch.Tensor],
201
+ ) -> torch.Tensor:
202
+ """Q over each generation block, K/V from sample-start..g_end (no cross-sample).
203
+
204
+ PERF: previously each iteration of the per-block loop did:
205
+ - `sample_ids[0, g_s].item()` — D2H sync
206
+ - `cu_seqlens[sid].item()` — D2H sync
207
+ - `torch.tensor([0, q_len], device=cuda, ...)` — small tensor alloc + H2D
208
+ - same for cu_k_g
209
+ For 25 gen blocks × 32 layers × 2 (fwd+bwd) ≈ 3200 ops/step, each blocking
210
+ on the prior attention kernel. Now we do **one** D2H copy of cu_seqlens
211
+ and sample_ids[0] up front, build all `cu_q_g` / `cu_k_g` pairs as a
212
+ single (M, 2) tensor with one H2D, and view rows in the loop.
213
+ """
214
+ device = query.device
215
+
216
+ # ---- One-shot metadata D2H ------------------------------------------
217
+ if sample_ids is not None and torch.is_tensor(sample_ids):
218
+ sample_ids_cpu = (
219
+ sample_ids[0].tolist() if sample_ids.dim() == 2 else sample_ids.tolist()
220
+ )
221
+ else:
222
+ sample_ids_cpu = None
223
+ if torch.is_tensor(cu_seqlens):
224
+ cu_seqlens_cpu = cu_seqlens.tolist()
225
+ else:
226
+ cu_seqlens_cpu = list(cu_seqlens)
227
+
228
+ # ---- Pre-compute per-block specs entirely on CPU --------------------
229
+ specs = [] # (g_s, g_e, b_idx, sample_start, q_len, kv_len)
230
+ for g_s, g_e, b_idx in _iter_g_ranges(g_seqlens):
231
+ if g_e <= g_s:
232
+ continue
233
+ if sample_ids_cpu is not None:
234
+ sid = sample_ids_cpu[g_s]
235
+ sample_start = cu_seqlens_cpu[sid]
236
+ else:
237
+ sample_start = _find_sample_start(cu_seqlens_cpu, g_s)
238
+ q_len = g_e - g_s
239
+ kv_len = g_e - sample_start
240
+ specs.append((g_s, g_e, b_idx, sample_start, q_len, kv_len))
241
+
242
+ if not specs:
243
+ # No generation blocks on this rank — preserve autograd topology.
244
+ dummy = query[:1, :1].sum() * 0
245
+ return attn_output + dummy
246
+
247
+ # ---- Single H2D for all cu pairs ------------------------------------
248
+ cu_q_all = torch.tensor(
249
+ [[0, s[4]] for s in specs], device=device, dtype=torch.int32
250
+ )
251
+ cu_k_all = torch.tensor(
252
+ [[0, s[5]] for s in specs], device=device, dtype=torch.int32
253
+ )
254
+
255
+ attn_output = attn_output.clone()
256
+ for i, (g_s, g_e, b_idx, sample_start, q_len, kv_len) in enumerate(specs):
257
+ gen_out = flash_attn_varlen_func(
258
+ query[b_idx, g_s:g_e].contiguous(),
259
+ key[b_idx, sample_start:g_e].contiguous(),
260
+ value[b_idx, sample_start:g_e].contiguous(),
261
+ cu_seqlens_q=cu_q_all[i], cu_seqlens_k=cu_k_all[i],
262
+ max_seqlen_q=q_len, max_seqlen_k=kv_len,
263
+ causal=False,
264
+ )
265
+ attn_output[b_idx, g_s:g_e] = gen_out
266
+ return attn_output
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # Main entry point — drop-in replacement for upstream's _flash_attention_forward_mot
271
+ # ---------------------------------------------------------------------------
272
+
273
+ def flash_attention_forward_mot_packed(
274
+ module: nn.Module,
275
+ query: torch.Tensor,
276
+ key: torch.Tensor,
277
+ value: torch.Tensor,
278
+ attention_mask,
279
+ dropout: float = 0.0,
280
+ scaling: Optional[float] = None,
281
+ sliding_window: Optional[int] = None,
282
+ softcap: Optional[float] = None,
283
+ **kwargs,
284
+ ):
285
+ """Packed/padded/decode dispatcher. See module docstring."""
286
+ _upstream._check_flash_attn()
287
+
288
+ if kwargs.get("output_attentions", False):
289
+ logger.warning_once("`flash_attention_2` does not support `output_attentions=True`.")
290
+
291
+ # (B, heads, S, D) -> (B, S, heads, D)
292
+ query = query.transpose(1, 2)
293
+ key = key.transpose(1, 2)
294
+ value = value.transpose(1, 2)
295
+
296
+ if query.dtype == torch.float32:
297
+ if torch.is_autocast_enabled():
298
+ target_dtype = torch.get_autocast_gpu_dtype()
299
+ elif hasattr(module.config, "_pre_quantization_dtype"):
300
+ target_dtype = module.config._pre_quantization_dtype
301
+ else:
302
+ target_dtype = next(m for m in module.modules() if isinstance(m, nn.Linear)).weight.dtype
303
+ query, key, value = query.to(target_dtype), key.to(target_dtype), value.to(target_dtype)
304
+
305
+ bsz, s_q, n_heads, head_dim = query.shape
306
+ s_k = key.shape[1]
307
+
308
+ if isinstance(attention_mask, dict):
309
+ v_seqlens = attention_mask.get("v_seqlens", None)
310
+ g_seqlens = attention_mask.get("g_seqlens", None)
311
+ cu_seqlens_packed = attention_mask.get("cu_seqlens", None)
312
+ padding_mask = attention_mask.get("padding_mask", None)
313
+ sample_ids = attention_mask.get("sample_ids", None)
314
+ # PERF: pre-computed by the language_model wrapper so we avoid a D2H
315
+ # sync on every layer. None means we still have to compute it here.
316
+ max_seqlen_packed = attention_mask.get("max_seqlen_packed", None)
317
+ else:
318
+ # Dummy: allow odd callers (e.g., HF generation builders) that pass tensor masks.
319
+ v_seqlens = None
320
+ g_seqlens = None
321
+ cu_seqlens_packed = None
322
+ padding_mask = None
323
+ sample_ids = None
324
+ max_seqlen_packed = None
325
+
326
+ packed_mode = (cu_seqlens_packed is not None) and (s_q == s_k) and (bsz == 1)
327
+
328
+ # ---------------- Mode A: Packed prefill ---------------------------------
329
+ if packed_mode:
330
+ h_kv = key.shape[2]
331
+ q_flat = query.contiguous().view(s_q, n_heads, head_dim)
332
+ k_flat = key.contiguous().view(s_k, h_kv, head_dim)
333
+ v_flat = value.contiguous().view(s_k, h_kv, head_dim)
334
+
335
+ cu = cu_seqlens_packed.to(device=query.device, dtype=torch.int32)
336
+ # Use pre-computed max_seqlen if the wrapper supplied it (saves a
337
+ # D2H sync per layer × 32 layers).
338
+ if max_seqlen_packed is not None:
339
+ max_seqlen = max_seqlen_packed
340
+ else:
341
+ with torch.no_grad():
342
+ max_seqlen = int((cu[1:] - cu[:-1]).max().item())
343
+
344
+ attn_flat = flash_attn_varlen_func(
345
+ q_flat, k_flat, v_flat,
346
+ cu_seqlens_q=cu, cu_seqlens_k=cu,
347
+ max_seqlen_q=max_seqlen, max_seqlen_k=max_seqlen,
348
+ causal=True,
349
+ )
350
+ attn_output = attn_flat.reshape(1, s_q, n_heads, head_dim)
351
+
352
+ if v_seqlens is not None:
353
+ attn_output = _apply_visual_bidirectional(attn_output, query, key, value, v_seqlens)
354
+
355
+ if _has_ranges(g_seqlens):
356
+ attn_output = _apply_generation_block(
357
+ attn_output, query, key, value, g_seqlens, cu, sample_ids
358
+ )
359
+
360
+ return attn_output, None
361
+
362
+ # ---------------- Mode B: Padded prefill --------------------------------
363
+ if padding_mask is not None and bsz > 1 and s_q == s_k:
364
+ pad_bool = padding_mask.bool()
365
+ seqlens = padding_mask.sum(dim=-1).to(torch.int32)
366
+ cu_seqlens = torch.zeros(bsz + 1, device=query.device, dtype=torch.int32)
367
+ cu_seqlens[1:] = torch.cumsum(seqlens, dim=0)
368
+ max_seqlen = int(seqlens.max().item())
369
+
370
+ q_unpad = query[pad_bool]
371
+ k_unpad = key[pad_bool]
372
+ v_unpad = value[pad_bool]
373
+
374
+ out_unpad = flash_attn_varlen_func(
375
+ q_unpad, k_unpad, v_unpad,
376
+ cu_seqlens_q=cu_seqlens, cu_seqlens_k=cu_seqlens,
377
+ max_seqlen_q=max_seqlen, max_seqlen_k=max_seqlen,
378
+ causal=True,
379
+ )
380
+ attn_output = query.new_zeros(bsz, s_q, n_heads, head_dim)
381
+ attn_output[pad_bool] = out_unpad
382
+
383
+ if v_seqlens is not None:
384
+ attn_output = _apply_visual_bidirectional(attn_output, query, key, value, v_seqlens)
385
+
386
+ # Padded path supports generation-block override too (B>1 with per-sample triples)
387
+ if _has_ranges(g_seqlens):
388
+ # Build a synthetic cu_seqlens from padding_mask for sample_start lookup
389
+ cu_pad = torch.zeros(bsz + 1, device=query.device, dtype=torch.int32)
390
+ cu_pad[1:] = torch.cumsum(seqlens, dim=0)
391
+ attn_output = _apply_generation_block(
392
+ attn_output, query, key, value, g_seqlens, cu_pad, sample_ids
393
+ )
394
+
395
+ return attn_output, None
396
+
397
+ # ---------------- Mode C: Single-sample / decode ------------------------
398
+ h_kv = key.shape[2]
399
+ q_flat = query.contiguous().view(bsz * s_q, n_heads, head_dim)
400
+ k_flat = key.contiguous().view(bsz * s_k, h_kv, head_dim)
401
+ v_flat = value.contiguous().view(bsz * s_k, h_kv, head_dim)
402
+ cu_q_t = torch.arange(0, bsz + 1, dtype=torch.int32, device=query.device) * s_q
403
+ cu_k_t = torch.arange(0, bsz + 1, dtype=torch.int32, device=query.device) * s_k
404
+
405
+ attn_flat = flash_attn_varlen_func(
406
+ q_flat, k_flat, v_flat,
407
+ cu_seqlens_q=cu_q_t, cu_seqlens_k=cu_k_t,
408
+ max_seqlen_q=s_q, max_seqlen_k=s_k,
409
+ causal=(s_q == s_k),
410
+ )
411
+ attn_output = attn_flat.reshape(bsz, s_q, n_heads, head_dim)
412
+
413
+ # Visual override is valid during prefill (S_q == S_k) only
414
+ if v_seqlens is not None and s_q == s_k:
415
+ attn_output = _apply_visual_bidirectional(attn_output, query, key, value, v_seqlens)
416
+
417
+ if _has_ranges(g_seqlens) and s_q == s_k and bsz == 1:
418
+ # B==1 inference: synthetic cu_seqlens covers the whole row
419
+ cu_one = torch.tensor([0, s_q], device=query.device, dtype=torch.int32)
420
+ attn_output = _apply_generation_block(
421
+ attn_output, query, key, value, g_seqlens, cu_one, sample_ids
422
+ )
423
+
424
+ return attn_output, None
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # Activate: rebind upstream module-level reference
429
+ # ---------------------------------------------------------------------------
430
+
431
+ _upstream._flash_attention_forward_mot = flash_attention_forward_mot_packed
432
+ logger.info("attention_mot_packed: replaced upstream _flash_attention_forward_mot")
433
+
434
+
435
+ __all__ = ["flash_attention_forward_mot_packed"]
model/config_unified_mot.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for UnifiedMoT.
2
+
3
+ Subclasses transformers.models.hunyuan_vl_mot config to add HY-Unified
4
+ project-level fields used by the Flow Matching path:
5
+
6
+ - mlp_g_intermediate_size / mlp_g_init_noise_std (Net2Wider on generation MLP)
7
+ - boundary_loss_weight (patch-boundary smoothness L1)
8
+ - latent_patch_size / max_latent_size (FM latent grid)
9
+ - vae_image_downsample / vae_z_channels (VAE encoder spec)
10
+ - timestep_shift (t-distribution shift for FM)
11
+ - use_moe (toggle moe_layers wrapper)
12
+ - flow_loss_weight / text_loss_weight (loss aggregation knobs)
13
+
14
+ The text-level fields (mlp_g_*) live on text_config because the proxy in
15
+ HunYuanVLMoTConfig surfaces them at the top level too.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+ from transformers.models.hunyuan_vl_mot.configuration_hunyuan_vl_mot import (
22
+ HunYuanVLMoTConfig,
23
+ HunYuanVLMoTTextConfig,
24
+ HunYuanVLMoTVisionConfig,
25
+ )
26
+
27
+
28
+ class UnifiedMoTTextConfig(HunYuanVLMoTTextConfig):
29
+ """Extends upstream text config with the three-path MoT (mlp_g) knobs."""
30
+
31
+ model_type = "unified_mot"
32
+
33
+ def __init__(
34
+ self,
35
+ mlp_g_intermediate_size: Optional[int] = None,
36
+ mlp_g_init_noise_std: float = 1e-4,
37
+ use_moe: bool = False,
38
+ **kwargs,
39
+ ):
40
+ super().__init__(**kwargs)
41
+ self.mlp_g_intermediate_size = mlp_g_intermediate_size
42
+ self.mlp_g_init_noise_std = float(mlp_g_init_noise_std)
43
+ self.use_moe = bool(use_moe)
44
+
45
+
46
+ class UnifiedMoTConfig(HunYuanVLMoTConfig):
47
+ """Top-level config for the unified flow-matching model.
48
+
49
+ Inherits HunYuanVLMoTConfig (text_config + vision_config + token ids) and
50
+ layers on Flow-Matching specific fields.
51
+ """
52
+
53
+ model_type = "unified_mot"
54
+ sub_configs = {
55
+ "vision_config": HunYuanVLMoTVisionConfig,
56
+ "text_config": UnifiedMoTTextConfig,
57
+ }
58
+
59
+ def __init__(
60
+ self,
61
+ text_config=None,
62
+ vision_config=None,
63
+ # Flow Matching geometry
64
+ latent_patch_size: int = 2,
65
+ max_latent_size: int = 32,
66
+ vae_image_downsample: int = 16,
67
+ vae_z_channels: int = 16,
68
+ timestep_shift: float = 1.0,
69
+ # Loss aggregation
70
+ boundary_loss_weight: float = 0.0,
71
+ flow_loss_weight: float = 1.0,
72
+ text_loss_weight: float = 1.0,
73
+ # Special token used as the FM latent placeholder in the input sequence.
74
+ # Defaults to upstream's `latent_token_id` (120690) so the same checkpoint
75
+ # works without changes.
76
+ flow_latent_placeholder_id: Optional[int] = None,
77
+ **kwargs,
78
+ ):
79
+ # If text_config wasn't pre-built, route the **kwargs through so caller-level
80
+ # fields like mlp_g_intermediate_size still land on text_config.
81
+ if text_config is None:
82
+ text_config = UnifiedMoTTextConfig(**kwargs)
83
+ elif isinstance(text_config, dict):
84
+ text_config = UnifiedMoTTextConfig(**text_config)
85
+
86
+ super().__init__(text_config=text_config, vision_config=vision_config, **kwargs)
87
+
88
+ self.latent_patch_size = int(latent_patch_size)
89
+ self.max_latent_size = int(max_latent_size)
90
+ self.vae_image_downsample = int(vae_image_downsample)
91
+ self.vae_z_channels = int(vae_z_channels)
92
+ self.timestep_shift = float(timestep_shift)
93
+ self.boundary_loss_weight = float(boundary_loss_weight)
94
+ self.flow_loss_weight = float(flow_loss_weight)
95
+ self.text_loss_weight = float(text_loss_weight)
96
+ self.flow_latent_placeholder_id = (
97
+ int(flow_latent_placeholder_id)
98
+ if flow_latent_placeholder_id is not None
99
+ else int(self.latent_token_id)
100
+ )
101
+
102
+
103
+ __all__ = ["UnifiedMoTConfig", "UnifiedMoTTextConfig"]
model/flow_matching_modules.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow Matching helper modules for UnifiedMoT.
2
+
3
+ Provides:
4
+ - TimestepEmbedder, PositionEmbedding (re-exported from model_utils)
5
+ - patchify_latent / unpatchify_latent — reshape between (C, H_lat, W_lat)
6
+ and (h*w, p*p*C) packed-patch token form
7
+ - sample_timesteps — sigmoid(N(0,1)) → [0,1] with timestep_shift applied
8
+ - build_noisy_latent — linear interp x_t = (1-t)*x_0 + t*noise
9
+ - patch_boundary_loss — L1 across patch boundaries on predicted x_0
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import List, Tuple
14
+
15
+ import torch
16
+
17
+ # Re-exported from model_utils for a stable import path (see __all__ below).
18
+ from .model_utils import ( # noqa: F401 # pylint: disable=unused-import
19
+ TimestepEmbedder,
20
+ PositionEmbedding,
21
+ )
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Patchify / Unpatchify
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def patchify_latent(latent: torch.Tensor, h: int, w: int, patch_size: int, channels: int) -> torch.Tensor:
29
+ """(C, H_lat, W_lat) → (h*w, p*p*C) where H_lat=h*p, W_lat=w*p."""
30
+ p = patch_size
31
+ latent = latent[:, : h * p, : w * p].reshape(channels, h, p, w, p)
32
+ latent = torch.einsum("chpwq->hwpqc", latent).reshape(-1, p * p * channels)
33
+ return latent
34
+
35
+
36
+ def unpatchify_latent(packed: torch.Tensor, h: int, w: int, patch_size: int, channels: int) -> torch.Tensor:
37
+ """(h*w, p*p*C) → (C, H_lat, W_lat). Inverse of patchify_latent."""
38
+ p = patch_size
39
+ x = packed.reshape(h, w, p, p, channels)
40
+ x = torch.einsum("hwpqc->chpwq", x).reshape(channels, h * p, w * p)
41
+ return x
42
+
43
+
44
+ def pack_latents(
45
+ padded_latents: List[torch.Tensor],
46
+ shapes: List[Tuple[int, int]],
47
+ patch_size: int,
48
+ channels: int,
49
+ ) -> torch.Tensor:
50
+ """Concatenate per-image patchified latents into one (sum_h*w, p*p*C) tensor."""
51
+ parts = []
52
+ for latent, (h, w) in zip(padded_latents, shapes):
53
+ parts.append(patchify_latent(latent, h, w, patch_size, channels))
54
+ return torch.cat(parts, dim=0)
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Timestep sampling
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def sample_timesteps(
62
+ shapes: List[Tuple[int, int]],
63
+ timestep_shift: float,
64
+ device,
65
+ dtype,
66
+ ) -> torch.Tensor:
67
+ """Per-image t ~ sigmoid(N(0,1)) broadcast over each image's tokens, then shifted.
68
+
69
+ Returns a flat tensor of length sum(h*w), one t per token.
70
+ """
71
+ raw_t = torch.randn(len(shapes), device=device, dtype=dtype)
72
+ pieces = []
73
+ for (h, w), t in zip(shapes, raw_t):
74
+ pieces.append(torch.sigmoid(t).expand(h * w).clone())
75
+ timesteps = torch.cat(pieces, dim=0)
76
+ timesteps = timestep_shift * timesteps / (1.0 + (timestep_shift - 1.0) * timesteps)
77
+ return timesteps
78
+
79
+
80
+ def build_noisy_latent(
81
+ clean: torch.Tensor,
82
+ noise: torch.Tensor,
83
+ timesteps: torch.Tensor,
84
+ ) -> torch.Tensor:
85
+ """x_t = (1 - t) * x_0 + t * noise, t broadcast over feature dim."""
86
+ return (1.0 - timesteps[:, None]) * clean + timesteps[:, None] * noise
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Boundary loss
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def patch_boundary_loss(
94
+ x0_pred: torch.Tensor,
95
+ shapes: List[Tuple[int, int]],
96
+ patch_size: int,
97
+ channels: int,
98
+ ) -> torch.Tensor:
99
+ """L1 across horizontal/vertical patch boundaries on predicted x_0.
100
+
101
+ x0_pred: (sum_h*w, p*p*C). For each image's hxw grid, compares the right
102
+ column of patch (i,j) to the left column of patch (i,j+1) and similarly
103
+ for vertical neighbours. Encourages spatial smoothness in the latent
104
+ that the VAE decoder will turn into pixels.
105
+ """
106
+ p = patch_size
107
+ losses = []
108
+ offset = 0
109
+ for (h, w) in shapes:
110
+ n = h * w
111
+ x = x0_pred[offset : offset + n].reshape(h, w, p, p, channels)
112
+ if w > 1:
113
+ right_edge = x[:, :-1, :, p - 1, :] # (h, w-1, p, C)
114
+ left_edge = x[:, 1:, :, 0, :] # (h, w-1, p, C)
115
+ losses.append((right_edge - left_edge).abs().mean())
116
+ if h > 1:
117
+ bottom_edge = x[:-1, :, p - 1, :, :] # (h-1, w, p, C)
118
+ top_edge = x[1:, :, 0, :, :] # (h-1, w, p, C)
119
+ losses.append((bottom_edge - top_edge).abs().mean())
120
+ offset += n
121
+ if not losses:
122
+ return x0_pred.sum() * 0
123
+ return torch.stack(losses).mean()
124
+
125
+
126
+ __all__ = [
127
+ "TimestepEmbedder",
128
+ "PositionEmbedding",
129
+ "patchify_latent",
130
+ "unpatchify_latent",
131
+ "pack_latents",
132
+ "sample_timesteps",
133
+ "build_noisy_latent",
134
+ "patch_boundary_loss",
135
+ ]
model/model_utils.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ #
3
+ # Independent reimplementation of standard sinusoidal position- and
4
+ # timestep-embedding utilities, written from their public mathematical
5
+ # definitions (see the per-symbol references below). This file is NOT derived
6
+ # from the DiT source tree and carries no third-party (CC BY-NC) copyright; it
7
+ # is released under Apache-2.0 like the rest of this package.
8
+ #
9
+ # References for the underlying formulas (math only, no code reuse):
10
+ # * Sinusoidal position encoding — Vaswani et al., "Attention Is All You
11
+ # Need" (2017), Section 3.5.
12
+ # * Sinusoidal timestep embedding — Ho et al., "Denoising Diffusion
13
+ # Probabilistic Models" (2020); the same closed form is reused across
14
+ # diffusion and flow-matching models.
15
+ #
16
+ # The module layout, parameter names, and numerical outputs are intentionally
17
+ # identical to the previous version so that existing checkpoints load unchanged
18
+ # and inference results are bit-for-bit reproducible.
19
+
20
+ import math
21
+
22
+ import numpy as np
23
+ import torch
24
+ from torch import nn
25
+
26
+
27
+ # --------------------------------------------------------------------------- #
28
+ # Sinusoidal (sin-cos) position embeddings
29
+ #
30
+ # For a position p and channel index i the embedding interleaves
31
+ # sin(p / 10000^(2i/d)) and cos(p / 10000^(2i/d)),
32
+ # i.e. the classic Transformer positional encoding. The 2D variant encodes the
33
+ # height and width axes with half the channels each and concatenates them.
34
+ # --------------------------------------------------------------------------- #
35
+ def get_1d_sincos_pos_embed_from_grid(embed_dim, pos):
36
+ """1D sin-cos embedding for a flat array of positions.
37
+
38
+ Args:
39
+ embed_dim: even output dimension per position.
40
+ pos: array of positions, any shape; flattened to (M,).
41
+ Returns:
42
+ (M, embed_dim) array, [sin | cos] halves concatenated.
43
+ """
44
+ assert embed_dim % 2 == 0
45
+ # Inverse frequencies 1 / 10000^(k/(embed_dim/2)) for k = 0 .. embed_dim/2-1.
46
+ inv_freq = np.arange(embed_dim // 2, dtype=np.float64)
47
+ inv_freq /= embed_dim / 2.0
48
+ inv_freq = 1.0 / 10000**inv_freq # (embed_dim/2,)
49
+
50
+ angles = np.einsum("m,d->md", pos.reshape(-1), inv_freq) # outer product (M, D/2)
51
+ return np.concatenate([np.sin(angles), np.cos(angles)], axis=1) # (M, D)
52
+
53
+
54
+ def get_2d_sincos_pos_embed_from_grid(embed_dim, grid):
55
+ """2D sin-cos embedding: half the channels encode each spatial axis."""
56
+ assert embed_dim % 2 == 0
57
+ emb_h = get_1d_sincos_pos_embed_from_grid(embed_dim // 2, grid[0]) # (H*W, D/2)
58
+ emb_w = get_1d_sincos_pos_embed_from_grid(embed_dim // 2, grid[1]) # (H*W, D/2)
59
+ return np.concatenate([emb_h, emb_w], axis=1) # (H*W, D)
60
+
61
+
62
+ def get_2d_sincos_pos_embed(embed_dim, grid_size, cls_token=False, extra_tokens=0):
63
+ """Sin-cos table for a ``grid_size x grid_size`` patch grid.
64
+
65
+ Returns an ``(grid_size**2 [+ extra_tokens], embed_dim)`` array. When
66
+ ``cls_token`` is set and ``extra_tokens > 0``, that many zero rows are
67
+ prepended.
68
+ """
69
+ axis_h = np.arange(grid_size, dtype=np.float32)
70
+ axis_w = np.arange(grid_size, dtype=np.float32)
71
+ grid = np.meshgrid(axis_w, axis_h) # width varies fastest
72
+ grid = np.stack(grid, axis=0).reshape([2, 1, grid_size, grid_size])
73
+
74
+ pos_embed = get_2d_sincos_pos_embed_from_grid(embed_dim, grid)
75
+ if cls_token and extra_tokens > 0:
76
+ pos_embed = np.concatenate([np.zeros([extra_tokens, embed_dim]), pos_embed], axis=0)
77
+ return pos_embed
78
+
79
+
80
+ # --------------------------------------------------------------------------- #
81
+ # Timestep embedding
82
+ # --------------------------------------------------------------------------- #
83
+ class TimestepEmbedder(nn.Module):
84
+ """Embed scalar (possibly fractional) timesteps into vectors.
85
+
86
+ Sinusoidal frequency features are fed through a two-layer MLP with a SiLU
87
+ non-linearity. This is the standard timestep conditioning used by diffusion
88
+ and flow-matching models.
89
+ """
90
+
91
+ def __init__(self, hidden_size, frequency_embedding_size=256):
92
+ super().__init__()
93
+ self.mlp = nn.Sequential(
94
+ nn.Linear(frequency_embedding_size, hidden_size, bias=True),
95
+ nn.SiLU(),
96
+ nn.Linear(hidden_size, hidden_size, bias=True),
97
+ )
98
+ self.frequency_embedding_size = frequency_embedding_size
99
+
100
+ @staticmethod
101
+ def timestep_embedding(t, dim, max_period=10000):
102
+ """Sinusoidal features for a 1D tensor of (possibly fractional) timesteps.
103
+
104
+ Args:
105
+ t: (N,) tensor of timestep values.
106
+ dim: output feature dimension.
107
+ max_period: lowest angular frequency (longest period).
108
+ Returns:
109
+ (N, dim) tensor of [cos | sin] features (zero-padded if ``dim`` is odd).
110
+ """
111
+ half = dim // 2
112
+ # Geometrically spaced frequencies over [1, 1/max_period].
113
+ freqs = torch.exp(
114
+ -math.log(max_period) * torch.arange(start=0, end=half, dtype=torch.float32) / half
115
+ ).to(device=t.device)
116
+ args = t[:, None].float() * freqs[None]
117
+ embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
118
+ if dim % 2:
119
+ embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
120
+ return embedding
121
+
122
+ def forward(self, t):
123
+ freq_features = self.timestep_embedding(t, self.frequency_embedding_size)
124
+ freq_features = freq_features.to(next(self.mlp.parameters()).dtype)
125
+ return self.mlp(freq_features)
126
+
127
+
128
+ class PositionEmbedding(nn.Module):
129
+ """Fixed 2D sin-cos position table exposed as a lookup by position id."""
130
+
131
+ def __init__(self, max_num_patch_per_side, hidden_size):
132
+ super().__init__()
133
+ self.max_num_patch_per_side = max_num_patch_per_side
134
+ self.hidden_size = hidden_size
135
+ # Build the (non-trainable) table eagerly. If it were created lazily it
136
+ # could stay on the meta device through transformers'
137
+ # from_pretrained(dtype=...) path and later materialize as uninitialized
138
+ # memory when moved to the GPU.
139
+ table = get_2d_sincos_pos_embed(hidden_size, max_num_patch_per_side)
140
+ self.pos_embed = nn.Parameter(
141
+ torch.from_numpy(table).float(),
142
+ requires_grad=False,
143
+ )
144
+
145
+ def _reset_parameters(self):
146
+ """Recompute the table after a meta-init path (call post-from_pretrained)."""
147
+ table = get_2d_sincos_pos_embed(self.hidden_size, self.max_num_patch_per_side)
148
+ on_meta = self.pos_embed.is_meta or self.pos_embed.device.type == "meta"
149
+ materialized = torch.from_numpy(table).to(
150
+ device="cpu" if on_meta else self.pos_embed.device,
151
+ dtype=torch.float32 if on_meta else self.pos_embed.dtype,
152
+ )
153
+ if on_meta:
154
+ self.pos_embed = nn.Parameter(materialized.float(), requires_grad=False)
155
+ else:
156
+ self.pos_embed.data.copy_(materialized.to(self.pos_embed.dtype))
157
+
158
+ def forward(self, position_ids):
159
+ return self.pos_embed[position_ids]
model/modeling_decoder_mot.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MoT decoder layer with three-path routing (text / vision-input / vision-gen).
2
+
3
+ Subclasses `HunYuanVLMoTDecoderLayer` from upstream to add the third (`_g`)
4
+ path used by Flow Matching. Upstream provides `mlp / mlp_v` and matching
5
+ LayerNorms; we add `mlp_g / input_layernorm_g / post_attention_layernorm_g`.
6
+
7
+ `modality_mask` semantics:
8
+ * 0 = text token → mlp / input_layernorm / post_attention_layernorm
9
+ * 1 = input vision token → mlp_v / *_v
10
+ * 2 = generated vision token (FM latent) → mlp_g / *_g
11
+
12
+ Attention QKVO is **not** extended to a third path — generation tokens reuse
13
+ the `_v` projection (matches the existing HY-Unified design where
14
+ `q_proj_v / k_proj_v / v_proj_v / o_proj_v` cover both input and generated
15
+ vision tokens). We just need to convert the int modality mask to a bool
16
+ "vision" mask before calling `self.self_attn`.
17
+
18
+ mlp_g may optionally have a wider intermediate (Net2Wider scale-up). When
19
+ `config.mlp_g_intermediate_size` is set and larger than `intermediate_size`,
20
+ we build mlp_g with a cloned config that overrides `intermediate_size`.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import copy
25
+ from typing import Optional
26
+
27
+ import torch
28
+
29
+ from transformers.cache_utils import Cache
30
+ from transformers.processing_utils import Unpack
31
+ from transformers.utils import TransformersKwargs
32
+ from transformers.utils.deprecation import deprecate_kwarg
33
+
34
+ from transformers.models.hunyuan_vl_mot.modeling_hunyuan_vl_mot import (
35
+ HunYuanVLMoTDecoderLayer,
36
+ HunYuanVLMoTMLP,
37
+ HunYuanVLMoTRMSNorm,
38
+ )
39
+
40
+ # Side-effect import: replaces upstream's _flash_attention_forward_mot
41
+ from . import attention_mot_packed # noqa: F401 # pylint: disable=unused-import
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Three-way mask_apply
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def mask_apply_3way(
49
+ hidden_states: torch.Tensor,
50
+ modality_mask: Optional[torch.Tensor],
51
+ text_funcs,
52
+ vision_funcs,
53
+ gen_funcs,
54
+ out_dims=None,
55
+ padding_mask: Optional[torch.Tensor] = None,
56
+ ):
57
+ """Routes tokens to text/vision/generation function lists by modality_mask.
58
+
59
+ hidden_states: (B, S, D)
60
+ modality_mask: (B, S) int tensor with values in {0, 1, 2}, or None
61
+ 0 = text, 1 = vision input, 2 = vision generation
62
+ padding_mask: (B, S) int tensor, 1 = valid token, 0 = padding
63
+
64
+ Returns a list of stacked (B, S, out_d) tensors — one per `text_funcs[i]`.
65
+ """
66
+ if modality_mask is None:
67
+ # All-text: skip routing entirely
68
+ return [text_funcs[i](hidden_states) for i in range(len(text_funcs))]
69
+
70
+ bsz, seq_len, hidden_dim = hidden_states.size()
71
+ flat = hidden_states.reshape(bsz * seq_len, hidden_dim)
72
+ mask_flat = modality_mask.reshape(bsz * seq_len)
73
+ if padding_mask is not None:
74
+ valid_flat = padding_mask.reshape(bsz * seq_len).bool()
75
+ else:
76
+ valid_flat = None
77
+
78
+ placeholder = hidden_states[0:1, 0:1, :] # (1, 1, D)
79
+ zero_feature = 0
80
+
81
+ num_outputs = len(text_funcs)
82
+ if out_dims is None:
83
+ out_dims_resolved = [hidden_dim] * num_outputs
84
+ else:
85
+ out_dims_resolved = list(out_dims)
86
+
87
+ # Pre-allocate output buffers (empty, not zeros — we overwrite all valid
88
+ # positions and the rest are masked out by the caller / padding).
89
+ out_flat = [
90
+ torch.empty(bsz * seq_len, od, device=flat.device, dtype=flat.dtype)
91
+ for od in out_dims_resolved
92
+ ]
93
+ # Padding positions need to be zeroed — we won't touch them in the
94
+ # gather/scatter below. Cheaper than zeroing the whole tensor: only
95
+ # zero the rows that won't be hit by any of the three modalities.
96
+ if valid_flat is not None:
97
+ invalid_flat = ~valid_flat
98
+ if invalid_flat.any():
99
+ for buf in out_flat:
100
+ buf[invalid_flat] = 0
101
+ # else: all rows will be hit by exactly one of {text, vision, gen}.
102
+
103
+ def _dispatch(idx_mask, funcs):
104
+ """Run `funcs` on rows selected by `idx_mask`, scatter back. If no
105
+ rows are selected, multiply through a placeholder so the params still
106
+ receive grad (avoids "unused parameter" DDP errors)."""
107
+ nonlocal zero_feature
108
+ if idx_mask.any():
109
+ hs_sel = flat[idx_mask]
110
+ for i, fn in enumerate(funcs):
111
+ out_flat[i][idx_mask] = fn(hs_sel)
112
+ else:
113
+ for fn in funcs:
114
+ zero_feature = zero_feature + fn(placeholder).mean() * 0
115
+
116
+ # Text: mask == 0
117
+ text_idx = (mask_flat == 0)
118
+ if valid_flat is not None:
119
+ text_idx = text_idx & valid_flat
120
+ _dispatch(text_idx, text_funcs)
121
+
122
+ # Vision input: mask == 1
123
+ vis_idx = (mask_flat == 1)
124
+ if valid_flat is not None:
125
+ vis_idx = vis_idx & valid_flat
126
+ _dispatch(vis_idx, vision_funcs)
127
+
128
+ # Generation: mask == 2
129
+ gen_idx = (mask_flat == 2)
130
+ if valid_flat is not None:
131
+ gen_idx = gen_idx & valid_flat
132
+ _dispatch(gen_idx, gen_funcs)
133
+
134
+ result = [out.view(bsz, seq_len, -1) for out in out_flat]
135
+ result[0] = result[0] + zero_feature
136
+ return result
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Decoder layer subclass
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def _make_g_config(config, mlp_g_intermediate_size: int):
144
+ """Clone config with a wider intermediate_size for mlp_g (Net2Wider).
145
+
146
+ MUST be `copy.deepcopy`, NOT `copy.copy`. Reason:
147
+ HunYuanVLMoTConfig.__setattr__ is a proxy that re-routes any attribute
148
+ writes (when the key is in text_config.__dict__) into self.text_config.
149
+ Shallow-copying the outer config keeps `g_cfg.text_config is
150
+ config.text_config` — same object — so the subsequent
151
+ `g_cfg.intermediate_size = ...` setattr ends up mutating the SHARED
152
+ text_config used by every other decoder layer's mlp/mlp_v construction.
153
+ Layer 0 escapes (its mlp/mlp_v are built before _make_g_config runs),
154
+ but layer 1+ then sees text_config.intermediate_size = 12288 and builds
155
+ mlp/mlp_v at the wider size — at that point ckpt weights (which are 6144
156
+ for mlp/mlp_v) no longer match and `from_pretrained` fails with size
157
+ mismatch. Deepcopy the whole config tree to break this aliasing.
158
+ """
159
+ g_cfg = copy.deepcopy(config)
160
+ g_cfg.intermediate_size = int(mlp_g_intermediate_size)
161
+ return g_cfg
162
+
163
+
164
+ class MoTDecoderLayer(HunYuanVLMoTDecoderLayer):
165
+ """Three-path decoder layer: text / vision-input / vision-gen.
166
+
167
+ Adds `mlp_g`, `input_layernorm_g`, `post_attention_layernorm_g` on top of
168
+ upstream's two-path layer. `modality_mask` is interpreted as int{0,1,2}
169
+ instead of bool.
170
+ """
171
+
172
+ def __init__(self, config, layer_idx: int):
173
+ super().__init__(config, layer_idx)
174
+
175
+ # Text-config-aware lookup (HunYuanVLMoTConfig proxies these to text_config)
176
+ intermediate_size = getattr(config, "intermediate_size", None)
177
+ mlp_g_inter = getattr(config, "mlp_g_intermediate_size", None)
178
+
179
+ if mlp_g_inter is None or intermediate_size is None or mlp_g_inter == intermediate_size:
180
+ g_cfg = config
181
+ else:
182
+ assert mlp_g_inter >= intermediate_size, (
183
+ f"mlp_g_intermediate_size ({mlp_g_inter}) must be >= "
184
+ f"intermediate_size ({intermediate_size}); shrinking is not supported."
185
+ )
186
+ g_cfg = _make_g_config(config, mlp_g_inter)
187
+
188
+ self.mlp_g = HunYuanVLMoTMLP(g_cfg)
189
+ self.input_layernorm_g = HunYuanVLMoTRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
190
+ self.post_attention_layernorm_g = HunYuanVLMoTRMSNorm(
191
+ config.hidden_size, eps=config.rms_norm_eps
192
+ )
193
+
194
+ @deprecate_kwarg("past_key_value", new_name="past_key_values", version="4.58")
195
+ def forward(
196
+ self,
197
+ hidden_states: torch.Tensor,
198
+ attention_mask: Optional[torch.Tensor] = None,
199
+ position_ids: Optional[torch.LongTensor] = None,
200
+ past_key_values: Optional[Cache] = None,
201
+ use_cache: Optional[bool] = False,
202
+ cache_position: Optional[torch.LongTensor] = None,
203
+ position_embeddings: Optional[tuple[torch.Tensor, torch.Tensor]] = None,
204
+ modality_mask: Optional[torch.Tensor] = None,
205
+ **kwargs: Unpack[TransformersKwargs],
206
+ ) -> torch.Tensor:
207
+ # Pull padding_mask from attention_mask dict for GEMM exclusion
208
+ padding_mask = None
209
+ if isinstance(attention_mask, dict):
210
+ pm = attention_mask.get("padding_mask", None)
211
+ if pm is not None and hidden_states.shape[1] == pm.shape[1]:
212
+ padding_mask = pm
213
+
214
+ # Convert int modality_mask to bool for attention QKVO routing
215
+ # (attention has only 2 paths: text vs vision; gen tokens go through _v)
216
+ attn_modality_mask = None
217
+ if modality_mask is not None:
218
+ attn_modality_mask = (modality_mask > 0)
219
+
220
+ residual = hidden_states
221
+
222
+ # Pre-attention LayerNorm — three paths
223
+ hidden_states = mask_apply_3way(
224
+ hidden_states, modality_mask,
225
+ [self.input_layernorm],
226
+ [self.input_layernorm_v],
227
+ [self.input_layernorm_g],
228
+ padding_mask=padding_mask,
229
+ )[0]
230
+
231
+ # Self-attention (upstream's two-path attention with bool mask)
232
+ hidden_states, _ = self.self_attn(
233
+ hidden_states=hidden_states,
234
+ attention_mask=attention_mask,
235
+ position_ids=position_ids,
236
+ past_key_values=past_key_values,
237
+ use_cache=use_cache,
238
+ cache_position=cache_position,
239
+ position_embeddings=position_embeddings,
240
+ modality_mask=attn_modality_mask,
241
+ **kwargs,
242
+ )
243
+ hidden_states = residual + hidden_states
244
+
245
+ # MLP — three paths (LayerNorm + MLP fused per path to match upstream layout)
246
+ residual = hidden_states
247
+ hidden_states = mask_apply_3way(
248
+ hidden_states, modality_mask,
249
+ [lambda x: self.mlp(self.post_attention_layernorm(x))],
250
+ [lambda x: self.mlp_v(self.post_attention_layernorm_v(x))],
251
+ [lambda x: self.mlp_g(self.post_attention_layernorm_g(x))],
252
+ padding_mask=padding_mask,
253
+ )[0]
254
+ hidden_states = residual + hidden_states
255
+
256
+ # Zero out padding positions to keep residuals clean (matches upstream behavior)
257
+ if padding_mask is not None and hidden_states.shape[1] == padding_mask.shape[1]:
258
+ hidden_states = hidden_states * padding_mask.unsqueeze(-1)
259
+
260
+ return hidden_states
261
+
262
+
263
+ __all__ = ["MoTDecoderLayer", "mask_apply_3way"]
model/modeling_text_model_mot.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inner text-decoder subclass that threads packed kwargs into the causal_mask dict.
2
+
3
+ The upstream `_HunYuanVLMoTTextModel.forward` builds:
4
+
5
+ causal_mask = {"v_seqlens": visual_segs, "padding_mask": attention_mask}
6
+
7
+ and passes it to each decoder layer. For P-Pack training we additionally need
8
+ `cu_seqlens / sample_ids / g_seqlens / input_image_mask` in that dict so our
9
+ patched flash-attention function can dispatch to the packed code path.
10
+
11
+ Rather than monkey-patching upstream's forward, we subclass `_HunYuanVLMoTTextModel`
12
+ and `_HunYuanVLMoTTextForCausalLM` cleanly and replace the decoder layers with
13
+ `MoTDecoderLayer` (three-path mlp_t / mlp_v / mlp_g) inside __init__.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional, Union
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ from transformers.cache_utils import Cache, DynamicCache
22
+ from transformers.modeling_outputs import BaseModelOutputWithPast, CausalLMOutputWithPast
23
+ from transformers.processing_utils import Unpack
24
+ from transformers.utils import TransformersKwargs
25
+
26
+ from transformers.models.hunyuan_vl_mot.modeling_hunyuan_vl_mot import (
27
+ _HunYuanVLMoTTextModel,
28
+ _HunYuanVLMoTTextForCausalLM,
29
+ _modality_mask_to_segments,
30
+ )
31
+
32
+ from .modeling_decoder_mot import MoTDecoderLayer
33
+
34
+
35
+ class MoTTextModel(_HunYuanVLMoTTextModel):
36
+ """Pure text decoder using MoTDecoderLayer (3-path) and packed kwargs.
37
+
38
+ Drop-in replacement for `_HunYuanVLMoTTextModel`:
39
+ * Replaces all decoder layers with `MoTDecoderLayer`
40
+ * Forward accepts `cu_seqlens / sample_ids / g_seqlens / input_image_mask`
41
+ and threads them into the `attention_mask` dict that decoder layers see
42
+ * `modality_mask` may be int{0,1,2} (text/vision-input/vision-gen);
43
+ v_seqlens are derived from `input_image_mask` if provided, else from
44
+ `modality_mask > 0`
45
+ """
46
+
47
+ def __init__(self, config):
48
+ super().__init__(config)
49
+ # Replace decoder layers with MoT (3-path). Init weights match upstream
50
+ # for the shared keys; mlp_g/_g get default init (overwritten later by
51
+ # Net2Wider in mot_init_utils.maybe_init_generation_path).
52
+ self.layers = nn.ModuleList(
53
+ [MoTDecoderLayer(config, layer_idx) for layer_idx in range(config.num_hidden_layers)]
54
+ )
55
+
56
+ def forward(
57
+ self,
58
+ input_ids: Optional[torch.LongTensor] = None,
59
+ attention_mask: Optional[torch.Tensor] = None,
60
+ position_ids: Optional[torch.LongTensor] = None,
61
+ past_key_values: Optional[Cache] = None,
62
+ inputs_embeds: Optional[torch.FloatTensor] = None,
63
+ cache_position: Optional[torch.LongTensor] = None,
64
+ use_cache: Optional[bool] = None,
65
+ modality_mask: Optional[torch.Tensor] = None,
66
+ # Packed-mode signals
67
+ cu_seqlens: Optional[torch.Tensor] = None,
68
+ sample_ids: Optional[torch.Tensor] = None,
69
+ g_seqlens: Optional[torch.Tensor] = None,
70
+ input_image_mask: Optional[torch.Tensor] = None,
71
+ **kwargs: Unpack[TransformersKwargs],
72
+ ) -> BaseModelOutputWithPast:
73
+ if (input_ids is None) ^ (inputs_embeds is not None):
74
+ raise ValueError("Specify exactly one of input_ids or inputs_embeds")
75
+
76
+ if inputs_embeds is None:
77
+ inputs_embeds = self.embed_tokens(input_ids)
78
+
79
+ if use_cache and past_key_values is None:
80
+ past_key_values = DynamicCache(config=self.config)
81
+
82
+ if cache_position is None:
83
+ past_seen = past_key_values.get_seq_length() if past_key_values is not None else 0
84
+ cache_position = torch.arange(
85
+ past_seen, past_seen + inputs_embeds.shape[1], device=inputs_embeds.device
86
+ )
87
+
88
+ # Position IDs:
89
+ # * Packed mode (cu_seqlens given): caller must pass position_ids (per-sample arange)
90
+ # * Padded B>1: derive from attention_mask cumsum
91
+ # * B==1 / decode: cache_position
92
+ if position_ids is None:
93
+ if cu_seqlens is not None:
94
+ raise ValueError("Packed mode requires position_ids from the collator.")
95
+ if attention_mask is not None and attention_mask.shape[0] > 1:
96
+ position_ids = attention_mask.long().cumsum(dim=-1) - 1
97
+ position_ids = position_ids.clamp(min=0)
98
+ seq_len = inputs_embeds.shape[1]
99
+ if position_ids.shape[1] > seq_len:
100
+ position_ids = position_ids[:, -seq_len:]
101
+ else:
102
+ position_ids = cache_position.unsqueeze(0)
103
+ text_position_ids = position_ids
104
+
105
+ if modality_mask is None:
106
+ modality_mask = torch.zeros(
107
+ inputs_embeds.shape[:-1], dtype=torch.long, device=inputs_embeds.device
108
+ )
109
+
110
+ # v_seqlens source: input_image_mask (input images only) when training
111
+ # flow generation; otherwise modality_mask > 0.
112
+ if input_image_mask is not None:
113
+ vis_mask = input_image_mask.bool()
114
+ else:
115
+ vis_mask = modality_mask > 0
116
+ visual_segs = _modality_mask_to_segments(vis_mask)
117
+
118
+ # Truncate modality_mask if shape mismatch (decode KV cache)
119
+ seq_len = inputs_embeds.shape[1]
120
+ if modality_mask.shape[1] > seq_len:
121
+ modality_mask = modality_mask[:, -seq_len:]
122
+
123
+ # Build extended causal_mask dict with packed signals.
124
+ # When cu_seqlens is set, padding_mask MUST be None (we treat as packed).
125
+ # PERF: pre-compute max_seqlen ONCE here so the per-layer attention
126
+ # forward doesn't redo `.max().item()` (a D2H sync) 32 times.
127
+ max_seqlen_packed = None
128
+ if cu_seqlens is not None:
129
+ with torch.no_grad():
130
+ max_seqlen_packed = int((cu_seqlens[1:] - cu_seqlens[:-1]).max().item())
131
+
132
+ causal_mask = {
133
+ "v_seqlens": visual_segs,
134
+ "g_seqlens": g_seqlens,
135
+ "cu_seqlens": cu_seqlens,
136
+ "sample_ids": sample_ids,
137
+ "padding_mask": attention_mask if cu_seqlens is None else None,
138
+ "max_seqlen_packed": max_seqlen_packed,
139
+ }
140
+
141
+ hidden_states = inputs_embeds
142
+ position_embeddings = self.rotary_emb(hidden_states, text_position_ids)
143
+
144
+ for decoder_layer in self.layers:
145
+ hidden_states = decoder_layer(
146
+ hidden_states,
147
+ attention_mask=causal_mask,
148
+ position_ids=text_position_ids,
149
+ past_key_values=past_key_values,
150
+ cache_position=cache_position,
151
+ position_embeddings=position_embeddings,
152
+ modality_mask=modality_mask,
153
+ **kwargs,
154
+ )
155
+
156
+ hidden_states = self.norm(hidden_states)
157
+ return BaseModelOutputWithPast(
158
+ last_hidden_state=hidden_states,
159
+ past_key_values=past_key_values,
160
+ )
161
+
162
+
163
+ class MoTTextForCausalLM(_HunYuanVLMoTTextForCausalLM):
164
+ """Inner text + lm_head wrapper using MoTTextModel.
165
+
166
+ Threads packed kwargs and `shift_labels` through. When `shift_labels` is
167
+ provided, computes split text/image loss aggregates so the outer wrapper
168
+ can report them separately.
169
+ """
170
+
171
+ def __init__(self, config):
172
+ super().__init__(config)
173
+ # Replace inner text model with MoT (3-path) version
174
+ self.model = MoTTextModel(config)
175
+
176
+ def forward(
177
+ self,
178
+ input_ids: Optional[torch.LongTensor] = None,
179
+ attention_mask: Optional[torch.Tensor] = None,
180
+ position_ids: Optional[torch.LongTensor] = None,
181
+ past_key_values: Optional[Cache] = None,
182
+ inputs_embeds: Optional[torch.FloatTensor] = None,
183
+ labels: Optional[torch.LongTensor] = None,
184
+ use_cache: Optional[bool] = None,
185
+ cache_position: Optional[torch.LongTensor] = None,
186
+ logits_to_keep: Union[int, torch.Tensor] = 0,
187
+ modality_mask: Optional[torch.Tensor] = None,
188
+ # Packed kwargs threaded to inner MoTTextModel
189
+ cu_seqlens: Optional[torch.Tensor] = None,
190
+ sample_ids: Optional[torch.Tensor] = None,
191
+ g_seqlens: Optional[torch.Tensor] = None,
192
+ input_image_mask: Optional[torch.Tensor] = None,
193
+ # Optional pre-shifted labels (used when image regions need -100 masking)
194
+ shift_labels: Optional[torch.Tensor] = None,
195
+ **kwargs: Unpack[TransformersKwargs],
196
+ ) -> CausalLMOutputWithPast:
197
+ outputs: BaseModelOutputWithPast = self.model(
198
+ input_ids=input_ids,
199
+ attention_mask=attention_mask,
200
+ position_ids=position_ids,
201
+ past_key_values=past_key_values,
202
+ inputs_embeds=inputs_embeds,
203
+ use_cache=use_cache,
204
+ cache_position=cache_position,
205
+ modality_mask=modality_mask,
206
+ cu_seqlens=cu_seqlens,
207
+ sample_ids=sample_ids,
208
+ g_seqlens=g_seqlens,
209
+ input_image_mask=input_image_mask,
210
+ **kwargs,
211
+ )
212
+
213
+ hidden_states = outputs.last_hidden_state
214
+
215
+ # Choose label source: explicit shift_labels (collator-prepared, with
216
+ # image-region -100 masking) takes precedence over `labels`.
217
+ train_labels = shift_labels if shift_labels is not None else labels
218
+
219
+ if train_labels is not None:
220
+ flat_hs = hidden_states.reshape(-1, hidden_states.size(-1))
221
+ flat_labels = train_labels.reshape(-1)
222
+ valid = flat_labels >= 0
223
+ if valid.sum() == 0:
224
+ flat_hs_v = flat_hs[:1]
225
+ flat_labels_v = flat_labels[:1]
226
+ else:
227
+ flat_hs_v = flat_hs[valid]
228
+ flat_labels_v = flat_labels[valid]
229
+ logits = self.lm_head(flat_hs_v)
230
+ loss = self.loss_function(
231
+ logits=logits, labels=flat_labels_v, vocab_size=self.config.vocab_size, **kwargs
232
+ )
233
+ else:
234
+ slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
235
+ logits = self.lm_head(hidden_states[:, slice_indices, :])
236
+ loss = None
237
+
238
+ return CausalLMOutputWithPast(
239
+ loss=loss,
240
+ logits=logits,
241
+ past_key_values=outputs.past_key_values,
242
+ hidden_states=outputs.last_hidden_state,
243
+ )
244
+
245
+
246
+ __all__ = ["MoTTextModel", "MoTTextForCausalLM"]
model/modeling_unified_mot.py ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UnifiedMoT main model: HunYuanVLMoT base + Flow Matching for image generation.
2
+
3
+ Architecture:
4
+ * Inherits `transformers.models.hunyuan_vl_mot.HunYuanVLMoTModel` and uses
5
+ its HYViT2 vision tower via `self.visual`. The inner language model is
6
+ swapped to `MoTTextForCausalLM` so all decoder layers are 3-path
7
+ (mlp_t / mlp_v / mlp_g).
8
+ * Adds Flow Matching modules on the `ForConditionalGeneration` class:
9
+ `time_embedder`, `vae2llm`, `llm2vae`, `latent_pos_embed`.
10
+ * Forward supports both packed (P-Pack training) and padded modes.
11
+ * Computes split text-CE + flow-MSE + optional patch-boundary L1 loss
12
+ and exposes loss aggregates on the output (image_loss_sum / text_loss_sum /
13
+ image_loss_count / text_loss_count) for trainer-side weighting.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import List, Optional, Tuple, Union
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+
24
+ from transformers.cache_utils import Cache
25
+ from transformers.modeling_outputs import ModelOutput
26
+
27
+ from transformers.models.hunyuan_vl_mot.modeling_hunyuan_vl_mot import (
28
+ HY_VL_MOT_IMAGE_TOKEN_ID,
29
+ HY_VL_MOT_VIDEO_TOKEN_ID,
30
+ HunYuanVLMoTForConditionalGeneration,
31
+ HunYuanVLMoTModel,
32
+ HunYuanVLMoTPreTrainedModel,
33
+ HunYuanVLMoTModelOutputWithPast,
34
+ )
35
+
36
+ # Side-effect imports: replace upstream attention + extend decoder/text-model
37
+ from . import attention_mot_packed # noqa: F401 # pylint: disable=unused-import
38
+
39
+ from .config_unified_mot import UnifiedMoTConfig
40
+ from .modeling_text_model_mot import MoTTextForCausalLM
41
+ from .flow_matching_modules import (
42
+ TimestepEmbedder,
43
+ PositionEmbedding,
44
+ pack_latents,
45
+ sample_timesteps,
46
+ build_noisy_latent,
47
+ patch_boundary_loss,
48
+ )
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Output dataclass
53
+ # ---------------------------------------------------------------------------
54
+
55
+ @dataclass
56
+ class UnifiedMoTOutput(ModelOutput):
57
+ """Output of UnifiedMoTForConditionalGeneration.
58
+
59
+ Includes the standard CausalLMOutputWithPast fields plus split text/image
60
+ loss aggregates so the trainer can apply per-modality weights.
61
+ """
62
+
63
+ loss: Optional[torch.FloatTensor] = None
64
+ logits: Optional[torch.FloatTensor] = None
65
+ past_key_values: Optional[Cache] = None
66
+ hidden_states: Optional[Tuple[torch.FloatTensor]] = None
67
+ # Split aggregates (sum-reduced; count of contributing tokens)
68
+ image_loss_sum: Optional[torch.FloatTensor] = None
69
+ image_loss_count: Optional[torch.LongTensor] = None
70
+ text_loss_sum: Optional[torch.FloatTensor] = None
71
+ text_loss_count: Optional[torch.LongTensor] = None
72
+ boundary_loss: Optional[torch.FloatTensor] = None
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # UnifiedMoTModel — wraps HunYuanVLMoTModel, swaps language_model to MoT version
77
+ # ---------------------------------------------------------------------------
78
+
79
+ class UnifiedMoTModel(HunYuanVLMoTModel):
80
+ """HunYuanVLMoT wrapper with 3-path MoT decoder layers.
81
+
82
+ `__init__` first calls upstream's super-__init__ to set up `self.visual`,
83
+ `self.language_model`, and the misc plumbing, then **rebuilds**
84
+ `self.language_model` as `MoTTextForCausalLM` so its decoder layers are
85
+ `MoTDecoderLayer` (with `mlp_g`).
86
+
87
+ Forward injects optional `flow_embeds` at `flow_positions` (absolute packed
88
+ coords) before delegating to language_model. All packed kwargs pass
89
+ straight through to the inner text model.
90
+ """
91
+ config_class = UnifiedMoTConfig
92
+
93
+ def __init__(self, config: UnifiedMoTConfig):
94
+ super().__init__(config)
95
+ # Replace upstream's language_model with our MoT (3-path) version.
96
+ # Done via `_from_config` to honor PretrainedConfig conventions.
97
+ self.language_model = MoTTextForCausalLM._from_config(config)
98
+
99
+ # -----------------------------------------------------------------
100
+ # Forward
101
+ # -----------------------------------------------------------------
102
+
103
+ def forward(
104
+ self,
105
+ input_ids: Optional[torch.LongTensor] = None,
106
+ attention_mask: Optional[torch.Tensor] = None,
107
+ position_ids: Optional[torch.LongTensor] = None,
108
+ past_key_values: Optional[Cache] = None,
109
+ inputs_embeds: Optional[torch.FloatTensor] = None,
110
+ pixel_values: Optional[torch.Tensor] = None,
111
+ pixel_values_videos: Optional[torch.FloatTensor] = None,
112
+ image_grid_thw: Optional[torch.LongTensor] = None,
113
+ video_grid_thw: Optional[torch.LongTensor] = None,
114
+ cache_position: Optional[torch.LongTensor] = None,
115
+ logits_to_keep: Union[int, torch.Tensor] = 0,
116
+ labels: Optional[torch.LongTensor] = None,
117
+ # Packed-mode signals
118
+ cu_seqlens: Optional[torch.Tensor] = None,
119
+ sample_ids: Optional[torch.Tensor] = None,
120
+ modality_mask: Optional[torch.Tensor] = None,
121
+ input_image_mask: Optional[torch.Tensor] = None,
122
+ # Flow Matching
123
+ flow_embeds: Optional[torch.FloatTensor] = None, # (total_flow_tokens, D)
124
+ flow_positions: Optional[torch.Tensor] = None, # (M, 2) absolute packed coords
125
+ g_seqlens: Optional[torch.Tensor] = None, # (M, 2) absolute, used by attention
126
+ **kwargs,
127
+ ) -> HunYuanVLMoTModelOutputWithPast:
128
+ if (input_ids is None) ^ (inputs_embeds is not None):
129
+ raise ValueError("Specify exactly one of input_ids or inputs_embeds")
130
+
131
+ if inputs_embeds is None:
132
+ inputs_embeds = self.get_input_embeddings()(input_ids)
133
+
134
+ # ----- Vision: encode input images/videos and scatter into embeds -----
135
+ union_mask = None
136
+ has_input_media = (pixel_values is not None) or (pixel_values_videos is not None)
137
+ if self.training or has_input_media:
138
+ if has_input_media:
139
+ image_embeds, video_embeds, zero_feature = self.get_image_video_features(
140
+ pixel_values, pixel_values_videos,
141
+ image_grid_thw, video_grid_thw,
142
+ inputs_embeds.device, inputs_embeds.dtype,
143
+ )
144
+ if len(image_embeds) > 0:
145
+ image_embeds_cat = torch.cat(image_embeds, dim=0).to(
146
+ inputs_embeds.device, inputs_embeds.dtype
147
+ )
148
+ image_mask, _, union_mask = self.get_placeholder_mask(
149
+ input_ids, inputs_embeds, image_features=image_embeds_cat
150
+ )
151
+ inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds_cat)
152
+ if len(video_embeds) > 0:
153
+ video_embeds_cat = torch.cat(video_embeds, dim=0).to(
154
+ inputs_embeds.device, inputs_embeds.dtype
155
+ )
156
+ _, video_mask, union_mask = self.get_placeholder_mask(
157
+ input_ids, inputs_embeds, video_features=video_embeds_cat
158
+ )
159
+ inputs_embeds = inputs_embeds.masked_scatter(video_mask, video_embeds_cat)
160
+ inputs_embeds = inputs_embeds + zero_feature
161
+
162
+ # ----- Flow Matching: inject pre-built flow embeds at flow_positions ---
163
+ if flow_embeds is not None and flow_positions is not None and flow_positions.numel() > 0:
164
+ seq_len = inputs_embeds.shape[1]
165
+ replacement = inputs_embeds[0].clone()
166
+ position_mask = torch.zeros(seq_len, device=inputs_embeds.device, dtype=torch.bool)
167
+ # PERF: bulk D2H of flow_positions (small tensor) avoids 2 .item()
168
+ # calls per range × ~25 ranges per forward.
169
+ flow_positions_cpu = flow_positions.tolist()
170
+ idx = 0
171
+ for row in flow_positions_cpu:
172
+ s = int(row[0])
173
+ e = int(row[1])
174
+ n = e - s
175
+ replacement[s:e] = flow_embeds[idx : idx + n].to(inputs_embeds.dtype)
176
+ position_mask[s:e] = True
177
+ idx += n
178
+ mask_f = position_mask.to(inputs_embeds.dtype).unsqueeze(-1)
179
+ inputs_embeds = (
180
+ inputs_embeds[0] * (1.0 - mask_f) + replacement * mask_f
181
+ ).unsqueeze(0)
182
+
183
+ # ----- modality_mask sanity ------------------------------------------
184
+ if modality_mask is None:
185
+ if union_mask is not None:
186
+ # union_mask is bool (input image/video tokens). Promote to int.
187
+ modality_mask = union_mask.long()
188
+ else:
189
+ modality_mask = torch.zeros(
190
+ inputs_embeds.shape[:-1], dtype=torch.long, device=inputs_embeds.device
191
+ )
192
+
193
+ # ----- Forward through inner language model -------------------------
194
+ outputs = self.language_model(
195
+ input_ids=None,
196
+ position_ids=position_ids,
197
+ attention_mask=attention_mask,
198
+ past_key_values=past_key_values,
199
+ inputs_embeds=inputs_embeds,
200
+ cache_position=cache_position,
201
+ labels=labels,
202
+ modality_mask=modality_mask,
203
+ cu_seqlens=cu_seqlens,
204
+ sample_ids=sample_ids,
205
+ g_seqlens=g_seqlens,
206
+ input_image_mask=input_image_mask,
207
+ **kwargs,
208
+ )
209
+
210
+ return HunYuanVLMoTModelOutputWithPast(
211
+ loss=outputs.loss,
212
+ logits=outputs.logits,
213
+ past_key_values=outputs.past_key_values,
214
+ hidden_states=outputs.hidden_states,
215
+ )
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # UnifiedMoTForConditionalGeneration — main entry point
220
+ # ---------------------------------------------------------------------------
221
+
222
+ class UnifiedMoTForConditionalGeneration(HunYuanVLMoTForConditionalGeneration):
223
+ """Full unified model: MoT VL backbone + Flow Matching head."""
224
+ config_class = UnifiedMoTConfig
225
+
226
+ # mlp_g and FM modules don't exist in upstream HunYuanVLMoT base checkpoints;
227
+ # let `from_pretrained` skip them.
228
+ _keys_to_ignore_on_load_missing = [
229
+ r"time_embedder\..*",
230
+ r"vae2llm\..*",
231
+ r"llm2vae\..*",
232
+ r"latent_pos_embed\..*",
233
+ r".*\.mlp_g\..*",
234
+ r".*\.input_layernorm_g\..*",
235
+ r".*\.post_attention_layernorm_g\..*",
236
+ ]
237
+
238
+ def __init__(self, config: UnifiedMoTConfig):
239
+ # Bypass upstream's __init__ (which would build a regular HunYuanVLMoTModel
240
+ # and miss our 3-path layers). Init the PreTrainedModel base directly.
241
+ HunYuanVLMoTPreTrainedModel.__init__(self, config)
242
+ self.model = UnifiedMoTModel(config)
243
+ self.config = config
244
+
245
+ hidden_size = config.text_config.hidden_size
246
+
247
+ # Flow Matching geometry (cached for hot path)
248
+ self.latent_patch_size = config.latent_patch_size
249
+ self.timestep_shift = config.timestep_shift
250
+ self.max_latent_size = config.max_latent_size
251
+ self.latent_channel = config.vae_z_channels
252
+ # Effective downsample from pixel to packed-patch latent token grid
253
+ self.latent_downsample = config.vae_image_downsample # already includes patch_size
254
+ self.patch_latent_dim = self.latent_patch_size ** 2 * self.latent_channel
255
+
256
+ # FM modules — initialized from scratch (zero-loaded by from_pretrained)
257
+ self.time_embedder = TimestepEmbedder(hidden_size)
258
+ self.vae2llm = nn.Linear(self.patch_latent_dim, hidden_size)
259
+ self.llm2vae = nn.Linear(hidden_size, self.patch_latent_dim)
260
+ self.latent_pos_embed = PositionEmbedding(self.max_latent_size, hidden_size)
261
+
262
+ # Loss weights
263
+ self.boundary_loss_weight = config.boundary_loss_weight
264
+
265
+ self.post_init()
266
+
267
+ # -----------------------------------------------------------------
268
+ # Helpers — delegate vision encoding/placeholder to upstream methods
269
+ # -----------------------------------------------------------------
270
+
271
+ def get_image_features(self, *args, **kwargs):
272
+ return self.model.get_image_features(*args, **kwargs)
273
+
274
+ def get_video_features(self, *args, **kwargs):
275
+ return self.model.get_video_features(*args, **kwargs)
276
+
277
+ def get_image_video_features(self, *args, **kwargs):
278
+ return self.model.get_image_video_features(*args, **kwargs)
279
+
280
+ def get_placeholder_mask(self, *args, **kwargs):
281
+ return self.model.get_placeholder_mask(*args, **kwargs)
282
+
283
+ def get_input_embeddings(self):
284
+ return self.model.language_model.get_input_embeddings()
285
+
286
+ def set_input_embeddings(self, value):
287
+ self.model.language_model.set_input_embeddings(value)
288
+
289
+ def get_output_embeddings(self):
290
+ return self.model.language_model.get_output_embeddings()
291
+
292
+ def set_output_embeddings(self, value):
293
+ self.model.language_model.set_output_embeddings(value)
294
+
295
+ # -----------------------------------------------------------------
296
+ # FM input builder — turn padded VAE latents into flow_embeds
297
+ # -----------------------------------------------------------------
298
+
299
+ def _build_flow_embeds(
300
+ self,
301
+ padded_latents: List[torch.Tensor],
302
+ latent_shapes: List[Tuple[int, int]],
303
+ latent_positions: Optional[torch.Tensor],
304
+ device,
305
+ dtype,
306
+ ):
307
+ """Pack VAE latents → noisy x_t → vae2llm + time_embed + pos_embed.
308
+
309
+ Returns (flow_embeds, vae_latent_clean, noise, timesteps).
310
+ """
311
+ clean = pack_latents(padded_latents, latent_shapes, self.latent_patch_size, self.latent_channel)
312
+ clean = clean.to(device=device, dtype=dtype)
313
+ noise = torch.randn_like(clean)
314
+ timesteps = sample_timesteps(latent_shapes, self.timestep_shift, device, dtype)
315
+ noisy = build_noisy_latent(clean, noise, timesteps)
316
+
317
+ flow_embeds = self.vae2llm(noisy.to(self.vae2llm.weight.dtype)) + self.time_embedder(timesteps)
318
+ if latent_positions is not None:
319
+ flow_embeds = flow_embeds + self.latent_pos_embed(latent_positions)
320
+ return flow_embeds, clean, noise, timesteps
321
+
322
+ def _build_input_latent_embeds(
323
+ self,
324
+ padded_latents: List[torch.Tensor],
325
+ latent_shapes: List[Tuple[int, int]],
326
+ latent_positions: Optional[torch.Tensor],
327
+ device,
328
+ dtype,
329
+ ):
330
+ """For TI2I: clean (t=0) input image VAE latents projected into LLM space."""
331
+ clean = pack_latents(padded_latents, latent_shapes, self.latent_patch_size, self.latent_channel)
332
+ clean = clean.to(device=device, dtype=dtype)
333
+ t_zero = torch.zeros(clean.shape[0], device=device, dtype=dtype)
334
+ embeds = self.vae2llm(clean.to(self.vae2llm.weight.dtype)) + self.time_embedder(t_zero)
335
+ if latent_positions is not None:
336
+ embeds = embeds + self.latent_pos_embed(latent_positions)
337
+ return embeds
338
+
339
+ # -----------------------------------------------------------------
340
+ # Forward
341
+ # -----------------------------------------------------------------
342
+
343
+ def forward(
344
+ self,
345
+ input_ids: Optional[torch.LongTensor] = None,
346
+ attention_mask: Optional[torch.Tensor] = None,
347
+ position_ids: Optional[torch.LongTensor] = None,
348
+ past_key_values: Optional[Cache] = None,
349
+ inputs_embeds: Optional[torch.FloatTensor] = None,
350
+ pixel_values: Optional[torch.Tensor] = None,
351
+ pixel_values_videos: Optional[torch.FloatTensor] = None,
352
+ image_grid_thw: Optional[torch.LongTensor] = None,
353
+ video_grid_thw: Optional[torch.LongTensor] = None,
354
+ cache_position: Optional[torch.LongTensor] = None,
355
+ logits_to_keep: Union[int, torch.Tensor] = 0,
356
+ labels: Optional[torch.LongTensor] = None,
357
+ shift_labels: Optional[torch.LongTensor] = None,
358
+ # Packed signals
359
+ cu_seqlens: Optional[torch.Tensor] = None,
360
+ sample_ids: Optional[torch.Tensor] = None,
361
+ modality_mask: Optional[torch.Tensor] = None,
362
+ input_image_mask: Optional[torch.Tensor] = None,
363
+ # FM training inputs
364
+ use_flow_matching: bool = False,
365
+ padded_latent: Optional[List[torch.Tensor]] = None,
366
+ patchified_vae_latent_shapes: Optional[List[Tuple[int, int]]] = None,
367
+ vae_latent_indexes: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
368
+ vae_latent_positions: Optional[torch.Tensor] = None,
369
+ flow_positions: Optional[torch.Tensor] = None,
370
+ g_seqlens: Optional[torch.Tensor] = None,
371
+ # TI2I (input image VAE latent conditioning)
372
+ input_padded_latent: Optional[List[torch.Tensor]] = None,
373
+ input_vae_latent_shapes: Optional[List[Tuple[int, int]]] = None,
374
+ input_vae_latent_indexes: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
375
+ input_vae_latent_positions: Optional[torch.Tensor] = None,
376
+ **kwargs,
377
+ ) -> UnifiedMoTOutput:
378
+ device = (input_ids if input_ids is not None else inputs_embeds).device
379
+ flow_embeds = None
380
+ vae_latent_clean = None
381
+ noise = None
382
+ timesteps = None
383
+
384
+ # ---- TI2I clean input latents (t=0, gets injected at input_*_indexes) -
385
+ # When provided, prepend them into inputs_embeds via flow_embeds path
386
+ # using the same flow_positions mechanism. Many call sites only use one
387
+ # of (input, generation) latents, so we handle them additively.
388
+ input_flow_embeds = None
389
+ if input_padded_latent is not None and input_vae_latent_shapes is not None:
390
+ if inputs_embeds is None and input_ids is not None:
391
+ inputs_embeds = self.get_input_embeddings()(input_ids)
392
+ input_ids = None # already materialized
393
+ input_flow_embeds = self._build_input_latent_embeds(
394
+ input_padded_latent, input_vae_latent_shapes,
395
+ input_vae_latent_positions, device, inputs_embeds.dtype,
396
+ )
397
+ # Splice into inputs_embeds at the input_vae_latent_indexes positions
398
+ if input_vae_latent_indexes is not None:
399
+ b_idx, s_idx = input_vae_latent_indexes
400
+ inputs_embeds = inputs_embeds.clone()
401
+ inputs_embeds[b_idx, s_idx] = input_flow_embeds.to(inputs_embeds.dtype)
402
+
403
+ # ---- Generation flow embeds (noisy x_t, used during FM training) ----
404
+ if use_flow_matching and padded_latent is not None and patchified_vae_latent_shapes:
405
+ if inputs_embeds is None and input_ids is not None:
406
+ inputs_embeds = self.get_input_embeddings()(input_ids)
407
+ input_ids = None
408
+ flow_embeds, vae_latent_clean, noise, timesteps = self._build_flow_embeds(
409
+ padded_latent, patchified_vae_latent_shapes,
410
+ vae_latent_positions, device, inputs_embeds.dtype,
411
+ )
412
+
413
+ # ---- Forward through model -----------------------------------------
414
+ # Prefer pre-built inputs_embeds when we touched them above.
415
+ outputs = self.model(
416
+ input_ids=input_ids,
417
+ inputs_embeds=inputs_embeds,
418
+ attention_mask=attention_mask,
419
+ position_ids=position_ids,
420
+ past_key_values=past_key_values,
421
+ pixel_values=pixel_values,
422
+ pixel_values_videos=pixel_values_videos,
423
+ image_grid_thw=image_grid_thw,
424
+ video_grid_thw=video_grid_thw,
425
+ cache_position=cache_position,
426
+ logits_to_keep=logits_to_keep,
427
+ labels=None, # we compute loss ourselves
428
+ cu_seqlens=cu_seqlens,
429
+ sample_ids=sample_ids,
430
+ modality_mask=modality_mask,
431
+ input_image_mask=input_image_mask,
432
+ flow_embeds=flow_embeds,
433
+ flow_positions=flow_positions,
434
+ g_seqlens=g_seqlens,
435
+ **kwargs,
436
+ )
437
+
438
+ last_hidden_state = outputs.hidden_states # actually carries last_hidden_state per upstream output type
439
+ logits = outputs.logits
440
+
441
+ # ---- Compute losses ------------------------------------------------
442
+ text_loss_sum = None
443
+ text_loss_count = None
444
+ image_loss_sum = None
445
+ image_loss_count = None
446
+ boundary_loss = None
447
+ loss = None
448
+
449
+ # ---- Text CE loss (on shift_labels mask)
450
+ train_labels = shift_labels if shift_labels is not None else labels
451
+ if train_labels is not None and last_hidden_state is not None:
452
+ flat_hs = last_hidden_state.reshape(-1, last_hidden_state.size(-1))
453
+ flat_labels = train_labels.reshape(-1)
454
+ valid = flat_labels >= 0
455
+ n_valid = int(valid.sum().item())
456
+ if n_valid > 0:
457
+ hs_v = flat_hs[valid]
458
+ lbl_v = flat_labels[valid]
459
+ text_logits = self.get_output_embeddings()(hs_v).float()
460
+ ce_per_tok = F.cross_entropy(text_logits, lbl_v, reduction="none")
461
+ text_loss_sum = ce_per_tok.sum()
462
+ text_loss_count = torch.tensor(n_valid, device=device, dtype=torch.long)
463
+
464
+ # ---- Flow MSE loss + optional boundary loss
465
+ if vae_latent_clean is not None and vae_latent_indexes is not None and last_hidden_state is not None:
466
+ b_idx, s_idx = vae_latent_indexes
467
+ mse_hidden = last_hidden_state[b_idx, s_idx]
468
+ mse_preds = self.llm2vae(mse_hidden)
469
+ target = noise - vae_latent_clean # velocity target
470
+ has_mse = timesteps > 0
471
+ if has_mse.any():
472
+ mse_pred_f32 = mse_preds[has_mse].float()
473
+ target_f32 = target[has_mse].float()
474
+ # Per-token MSE summed for split aggregation; trainer divides by count
475
+ mse_per_tok = ((mse_pred_f32 - target_f32) ** 2).mean(dim=-1)
476
+ image_loss_sum = mse_per_tok.sum()
477
+ # PERF: avoid D2H .item() round-trip — has_mse.sum() is already
478
+ # a GPU tensor, just cast it to long.
479
+ image_loss_count = has_mse.sum().long()
480
+
481
+ if self.boundary_loss_weight > 0.0:
482
+ # Recover predicted x_0 = x_t - t * v_pred
483
+ noisy = build_noisy_latent(vae_latent_clean.detach(), noise.detach(), timesteps.detach())
484
+ x0_pred = noisy - timesteps[:, None].detach() * mse_preds
485
+ boundary_loss = patch_boundary_loss(
486
+ x0_pred, patchified_vae_latent_shapes,
487
+ self.latent_patch_size, self.latent_channel,
488
+ )
489
+
490
+ # ---- Aggregate scalar loss -----------------------------------------
491
+ # By default: mean text-CE + mean flow-MSE + boundary_weight * boundary
492
+ parts = []
493
+ if text_loss_sum is not None and text_loss_count is not None and text_loss_count.item() > 0:
494
+ parts.append(self.config.text_loss_weight * (text_loss_sum / text_loss_count.float()))
495
+ if image_loss_sum is not None and image_loss_count is not None and image_loss_count.item() > 0:
496
+ parts.append(self.config.flow_loss_weight * (image_loss_sum / image_loss_count.float()))
497
+ if boundary_loss is not None:
498
+ parts.append(self.boundary_loss_weight * boundary_loss)
499
+ if parts:
500
+ loss = parts[0]
501
+ for extra in parts[1:]:
502
+ loss = loss + extra
503
+
504
+ return UnifiedMoTOutput(
505
+ loss=loss,
506
+ logits=logits,
507
+ past_key_values=outputs.past_key_values,
508
+ hidden_states=last_hidden_state,
509
+ image_loss_sum=image_loss_sum,
510
+ image_loss_count=image_loss_count,
511
+ text_loss_sum=text_loss_sum,
512
+ text_loss_count=text_loss_count,
513
+ boundary_loss=boundary_loss,
514
+ )
515
+
516
+
517
+ __all__ = [
518
+ "UnifiedMoTModel",
519
+ "UnifiedMoTForConditionalGeneration",
520
+ "UnifiedMoTOutput",
521
+ "HY_VL_MOT_IMAGE_TOKEN_ID",
522
+ "HY_VL_MOT_VIDEO_TOKEN_ID",
523
+ ]
model/mot_init_utils.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initialization utilities for the MoT generation path (mlp_g, layernorm_g).
2
+
3
+ Two regimes are handled by a single entry point `maybe_init_generation_path`:
4
+
5
+ (a) same-size copy : mlp_g.intermediate_size == mlp_v.intermediate_size
6
+ -> plain state_dict copy (legacy behavior).
7
+ (b) Net2Wider expansion: mlp_g.intermediate_size > mlp_v.intermediate_size
8
+ -> function-preserving neuron duplication (Chen et al.,
9
+ "Net2Net") with small Gaussian noise to break symmetry.
10
+
11
+ Only integer-multiple widening is exact; non-integer multiples use round-robin
12
+ replicate-and-trim with reciprocal-count scaling (still function-preserving, but
13
+ logged as a warning — integer multiples are strongly preferred).
14
+
15
+ All layernorm_g state is always plain-copied from layernorm_v because layernorms
16
+ are on hidden_size and unaffected by MLP widening.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from typing import Optional
22
+
23
+ import torch
24
+ import torch.nn as nn
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @torch.no_grad()
30
+ def _net2wider_expand_mlp(
31
+ mlp_g: nn.Module,
32
+ mlp_v: nn.Module,
33
+ noise_std: float = 1e-4,
34
+ generator: Optional[torch.Generator] = None,
35
+ ) -> None:
36
+ """In-place initialize `mlp_g` to be function-equivalent to `mlp_v` via neuron
37
+ duplication. After this call, for any input x, `mlp_g(x) ~= mlp_v(x)` within
38
+ O(noise_std * ||x||).
39
+
40
+ Expects a SwiGLU-style MLP with linear modules `.gate_proj`, `.up_proj`,
41
+ `.down_proj`, all bias-free, with shapes:
42
+ gate_proj / up_proj : (D, H)
43
+ down_proj : (H, D)
44
+ """
45
+ d_v = mlp_v.gate_proj.out_features
46
+ d_g = mlp_g.gate_proj.out_features
47
+ assert mlp_v.gate_proj.in_features == mlp_g.gate_proj.in_features, (
48
+ f"hidden_size mismatch: mlp_v={mlp_v.gate_proj.in_features}, "
49
+ f"mlp_g={mlp_g.gate_proj.in_features}"
50
+ )
51
+ assert mlp_v.down_proj.out_features == mlp_g.down_proj.out_features, (
52
+ f"hidden_size mismatch on down_proj: mlp_v={mlp_v.down_proj.out_features}, "
53
+ f"mlp_g={mlp_g.down_proj.out_features}"
54
+ )
55
+ assert d_g >= d_v, (
56
+ f"mlp_g width {d_g} < mlp_v width {d_v} (shrinking unsupported)"
57
+ )
58
+
59
+ # Same-size fast path: plain copy, ignore noise.
60
+ if d_g == d_v:
61
+ mlp_g.load_state_dict(mlp_v.state_dict())
62
+ return
63
+
64
+ if d_g % d_v != 0:
65
+ logger.warning(
66
+ f"Net2Wider: d_g={d_g} not an integer multiple of d_v={d_v}; "
67
+ f"using replicate-and-trim. Integer multiples (e.g. 2x) are strongly "
68
+ f"preferred for cleanest function preservation."
69
+ )
70
+
71
+ device = mlp_v.gate_proj.weight.device
72
+ # indices[j] = source neuron index for destination neuron j
73
+ # Round-robin: for D_g = k*D_v this produces [0,1,...,D_v-1, 0,1,...,D_v-1, ...]
74
+ # which makes twins "adjacent in source" rather than "adjacent in destination".
75
+ # Either layout works mathematically; this choice keeps each source's twins
76
+ # spread out along the destination axis, which is marginally friendlier to
77
+ # downstream gather/scatter operations if any later code happens to assume
78
+ # contiguity within a source group.
79
+ indices = torch.arange(d_g, device=device) % d_v
80
+ counts = torch.bincount(indices, minlength=d_v) # (D_v,)
81
+
82
+ # --- gate_proj and up_proj: row-wise replication ---------------------------
83
+ for name in ("gate_proj", "up_proj"):
84
+ src = getattr(mlp_v, name).weight.data # (D_v, H)
85
+ dst_param = getattr(mlp_g, name).weight
86
+ dst = src[indices].clone().to(dtype=dst_param.dtype) # (D_g, H)
87
+ if noise_std > 0:
88
+ noise = torch.empty(
89
+ dst.shape, dtype=dst.dtype, device=dst.device,
90
+ ).normal_(mean=0.0, std=noise_std, generator=generator)
91
+ dst.add_(noise)
92
+ dst_param.data.copy_(dst)
93
+
94
+ # --- down_proj: column-wise replication with reciprocal-count scaling -----
95
+ # down_v: (H, D_v), down_g: (H, D_g).
96
+ # For each destination column j with source s = indices[j]:
97
+ # down_g[:, j] = down_v[:, s] / counts[s]
98
+ # so that Sum_{j: indices[j]=s} down_g[:, j] * h_s
99
+ # = down_v[:, s] * h_s (function preserved exactly).
100
+ down_v = mlp_v.down_proj.weight.data # (H, D_v)
101
+ dst_param = mlp_g.down_proj.weight
102
+ scale = (1.0 / counts.to(down_v.dtype)) # (D_v,)
103
+ dst = (down_v[:, indices] * scale[indices]).clone().to(dtype=dst_param.dtype) # (H, D_g)
104
+ if noise_std > 0:
105
+ noise = torch.empty(
106
+ dst.shape, dtype=dst.dtype, device=dst.device,
107
+ ).normal_(mean=0.0, std=noise_std, generator=generator)
108
+ dst.add_(noise)
109
+ dst_param.data.copy_(dst)
110
+
111
+
112
+ @torch.no_grad()
113
+ def init_mlp_g_from_mlp_v_net2wider(
114
+ layer,
115
+ noise_std: float = 1e-4,
116
+ generator: Optional[torch.Generator] = None,
117
+ ) -> dict:
118
+ """Initialize a single decoder layer's generation path (mlp_g + layernorm_g)
119
+ from its vision path (mlp_v + layernorm_v).
120
+
121
+ Auto-detects same-size vs expansion case by comparing MLP widths.
122
+ Layernorms are always plain-copied (shape is hidden_size, unchanged).
123
+
124
+ Defensive against meta-device parameters: when transformers'
125
+ `from_pretrained(dtype=...)` leaves `_g` params on the meta device (because
126
+ they aren't in the upstream state_dict), a plain `load_state_dict` on
127
+ those params doesn't materialize them — they stay meta and the `.to(...)`
128
+ that follows produces garbage GPU memory. To avoid that we rebuild the
129
+ `_g` parameters as fresh tensors on the same device/dtype as their `_v`
130
+ counterparts.
131
+ """
132
+ d_v = layer.mlp_v.gate_proj.out_features
133
+ d_g = layer.mlp_g.gate_proj.out_features
134
+
135
+ # Materialize meta-device params before in-place copy: rebuild _g linear
136
+ # weights on the same device/dtype as _v.
137
+ def _materialize_linear(dst_lin: nn.Linear, src_lin: nn.Linear):
138
+ if dst_lin.weight.is_meta or dst_lin.weight.device.type == "meta":
139
+ new_w = torch.empty_like(src_lin.weight)
140
+ dst_lin.weight = nn.Parameter(new_w, requires_grad=dst_lin.weight.requires_grad)
141
+ if dst_lin.bias is not None:
142
+ new_b = torch.empty_like(src_lin.bias) if src_lin.bias is not None else torch.zeros(
143
+ dst_lin.out_features, dtype=src_lin.weight.dtype, device=src_lin.weight.device,
144
+ )
145
+ dst_lin.bias = nn.Parameter(new_b, requires_grad=dst_lin.bias.requires_grad)
146
+
147
+ def _materialize_norm(dst_norm, src_norm):
148
+ if dst_norm.weight.is_meta or dst_norm.weight.device.type == "meta":
149
+ new_w = torch.empty_like(src_norm.weight)
150
+ dst_norm.weight = nn.Parameter(new_w, requires_grad=dst_norm.weight.requires_grad)
151
+
152
+ for name in ("gate_proj", "up_proj", "down_proj"):
153
+ if d_g == d_v:
154
+ _materialize_linear(getattr(layer.mlp_g, name), getattr(layer.mlp_v, name))
155
+ # else: widening path will rebuild mlp_g entirely in maybe_init_generation_path
156
+ _materialize_norm(layer.input_layernorm_g, layer.input_layernorm_v)
157
+ _materialize_norm(layer.post_attention_layernorm_g, layer.post_attention_layernorm_v)
158
+
159
+ _net2wider_expand_mlp(
160
+ layer.mlp_g, layer.mlp_v,
161
+ noise_std=noise_std if d_g > d_v else 0.0,
162
+ generator=generator,
163
+ )
164
+ layer.input_layernorm_g.load_state_dict(layer.input_layernorm_v.state_dict())
165
+ layer.post_attention_layernorm_g.load_state_dict(
166
+ layer.post_attention_layernorm_v.state_dict()
167
+ )
168
+ return {"D_v": d_v, "D_g": d_g, "expanded": d_g > d_v, "noise_std": noise_std}
169
+
170
+
171
+ @torch.no_grad()
172
+ def verify_mlp_g_equals_mlp_v(
173
+ layer,
174
+ num_samples: int = 4,
175
+ seq_len: int = 8,
176
+ ) -> float:
177
+ """Diagnostic: forward random activations through both paths (under their
178
+ respective post-attention layernorms) and return max absolute difference.
179
+
180
+ Called from unit tests and logged right after init on layer 0 as a sanity
181
+ check. Uses fp32 computation when possible (casts output back if needed).
182
+ """
183
+ hidden = layer.hidden_size
184
+ dev = layer.mlp_v.gate_proj.weight.device
185
+ dt = layer.mlp_v.gate_proj.weight.dtype
186
+ x = torch.randn(num_samples, seq_len, hidden, device=dev, dtype=dt)
187
+ y_v = layer.mlp_v(layer.post_attention_layernorm_v(x))
188
+ y_g = layer.mlp_g(layer.post_attention_layernorm_g(x))
189
+ return (y_v.float() - y_g.float()).abs().max().item()
190
+
191
+
192
+ def checkpoint_has_g_keys(model_load_path: Optional[str]) -> bool:
193
+ """True iff the checkpoint on disk already contains `_g.` weights.
194
+
195
+ This is the robust way to tell "generation path was previously saved; load
196
+ it as-is" from "generation path is absent; we need to initialize it from
197
+ mlp_v".
198
+
199
+ Returns False if the path does not exist or no safetensors file is found
200
+ (caller may fall back to a tensor-level check if needed).
201
+ """
202
+ if model_load_path is None:
203
+ return False
204
+ import os
205
+ import json as _json
206
+ index_path = os.path.join(model_load_path, "model.safetensors.index.json")
207
+ single_path = os.path.join(model_load_path, "model.safetensors")
208
+ if os.path.exists(index_path):
209
+ with open(index_path) as f:
210
+ keys = set(_json.load(f)["weight_map"].keys())
211
+ return any("_g." in k for k in keys)
212
+ if os.path.exists(single_path):
213
+ try:
214
+ from safetensors import safe_open
215
+ except ImportError:
216
+ return False
217
+ with safe_open(single_path, framework="pt") as f:
218
+ return any("_g." in k for k in f.keys())
219
+ return False
220
+
221
+
222
+ def _resolve_decoder_layers(model):
223
+ """Walk common wrapper paths to find the list of transformer decoder layers.
224
+
225
+ Supports:
226
+ UnifiedMoTForConditionalGeneration -> .model.language_model.model.layers
227
+ bare HunYuan MoT model -> .language_model.model.layers
228
+ even-barer HF model -> .model.layers
229
+ """
230
+ for path in (
231
+ "model.language_model.model.layers",
232
+ "language_model.model.layers",
233
+ "model.layers",
234
+ ):
235
+ obj = model
236
+ ok = True
237
+ for attr in path.split("."):
238
+ if not hasattr(obj, attr):
239
+ ok = False
240
+ break
241
+ obj = getattr(obj, attr)
242
+ if ok:
243
+ return obj
244
+ raise AttributeError(
245
+ "Could not locate decoder layers on model; tried "
246
+ "model.language_model.model.layers / language_model.model.layers / model.layers"
247
+ )
248
+
249
+
250
+ def maybe_init_generation_path(
251
+ model,
252
+ model_load_path: Optional[str] = None,
253
+ noise_std: float = 1e-4,
254
+ logger_: Optional[logging.Logger] = None,
255
+ seed: int = 0xA17C0D1F,
256
+ target_mlp_g_intermediate_size: Optional[int] = None,
257
+ ) -> bool:
258
+ """Top-level entry point used by train / inference / eval scripts.
259
+
260
+ Behavior:
261
+ * If `model_load_path` is provided and the checkpoint contains any `_g.`
262
+ key, do nothing and return False (generation path was already loaded
263
+ from the checkpoint — this is the T2I-resume / TI2I-continuation path).
264
+ * Otherwise iterate every decoder layer and call
265
+ `init_mlp_g_from_mlp_v_net2wider`:
266
+ - Same-size case -> plain state_dict copy (noise ignored).
267
+ - Expansion case -> Net2Wider neuron duplication + Gaussian noise
268
+ (std = `noise_std`).
269
+
270
+ `target_mlp_g_intermediate_size`: if provided and larger than the current
271
+ `mlp_g.intermediate_size`, REBUILD each layer's `mlp_g` to the wider size
272
+ before running Net2Wider init. Use this when the base checkpoint was
273
+ loaded with mlp_g at mlp_v's width (so from_pretrained accepted the
274
+ shapes) and you want to widen post-load.
275
+
276
+ Returns True if initialization was performed.
277
+ """
278
+ log = logger_ or logger
279
+
280
+ # ---- Re-materialize the latent_pos_embed (sin-cos table) ---------------
281
+ # PositionEmbedding's parameter is computed at __init__ time but
282
+ # `from_pretrained(dtype=...)` uses meta-init that bypasses the
283
+ # constructor's data assignment, leaving the param uninitialized.
284
+ pos_embed_module = None
285
+ for path in ("latent_pos_embed", "model.latent_pos_embed"):
286
+ obj = model
287
+ ok = True
288
+ for attr in path.split("."):
289
+ if not hasattr(obj, attr):
290
+ ok = False
291
+ break
292
+ obj = getattr(obj, attr)
293
+ if ok:
294
+ pos_embed_module = obj
295
+ break
296
+ if pos_embed_module is not None and hasattr(pos_embed_module, "_reset_parameters"):
297
+ pos_embed_module._reset_parameters()
298
+ log.info(
299
+ f"latent_pos_embed re-initialized from sin-cos table "
300
+ f"(shape={tuple(pos_embed_module.pos_embed.shape)})"
301
+ )
302
+
303
+ if checkpoint_has_g_keys(model_load_path):
304
+ log.info(
305
+ "Generation path present in checkpoint (found `_g.` keys); "
306
+ "skipping mlp_g initialization."
307
+ )
308
+ return False
309
+
310
+ layers = _resolve_decoder_layers(model)
311
+ d_v = layers[0].mlp_v.gate_proj.out_features
312
+ d_g_current = layers[0].mlp_g.gate_proj.out_features
313
+
314
+ # ---- Optional: rebuild mlp_g at a wider size before init ----
315
+ if target_mlp_g_intermediate_size is not None and target_mlp_g_intermediate_size > d_g_current:
316
+ from transformers.models.hunyuan_vl_mot.modeling_hunyuan_vl_mot import HunYuanVLMoTMLP
317
+ for layer in layers:
318
+ base_cfg = layer.mlp_g.gate_proj.weight # to inherit device/dtype
319
+ device = base_cfg.device
320
+ dtype = base_cfg.dtype
321
+ # Use a duck-typed mini config matching upstream MLP's expected fields
322
+ class _MiniCfg:
323
+ pass
324
+ mini = _MiniCfg()
325
+ mini.hidden_size = layer.mlp_v.gate_proj.in_features
326
+ mini.intermediate_size = target_mlp_g_intermediate_size
327
+ mini.hidden_act = "silu"
328
+ mini.mlp_bias = (layer.mlp_v.gate_proj.bias is not None)
329
+ new_mlp_g = HunYuanVLMoTMLP(mini).to(device=device, dtype=dtype)
330
+ layer.mlp_g = new_mlp_g
331
+ d_g = target_mlp_g_intermediate_size
332
+ log.info(f"Rebuilt mlp_g for all layers at intermediate_size={d_g}.")
333
+ else:
334
+ d_g = d_g_current
335
+
336
+ expansion = d_g > d_v
337
+ mode = "copy" if not expansion else f"net2wider (x{d_g / d_v:.3f})"
338
+ log.info(
339
+ f"Initializing mlp_g/layernorm_g from mlp_v/layernorm_v: "
340
+ f"mode={mode} (d_v={d_v}, d_g={d_g}, "
341
+ f"noise_std={noise_std if expansion else 0.0:g})"
342
+ )
343
+
344
+ # Single deterministic generator for reproducibility across layers.
345
+ gen = torch.Generator(device=layers[0].mlp_v.gate_proj.weight.device)
346
+ gen.manual_seed(seed)
347
+
348
+ for layer in layers:
349
+ init_mlp_g_from_mlp_v_net2wider(
350
+ layer,
351
+ noise_std=noise_std if expansion else 0.0,
352
+ generator=gen,
353
+ )
354
+
355
+ # Spot-check layer 0: forward a random input through both paths. The diff
356
+ # should be ~O(noise_std * sqrt(D_v) * ||x||); much larger indicates a bug.
357
+ try:
358
+ diff = verify_mlp_g_equals_mlp_v(layers[0])
359
+ log.info(
360
+ f" post-init forward diff (layer 0, random input): "
361
+ f"max |mlp_v - mlp_g| = {diff:.3e}"
362
+ )
363
+ except Exception as e: # pylint: disable=broad-except # pragma: no cover - diagnostic only
364
+ log.warning(f"verify_mlp_g_equals_mlp_v diagnostic failed: {e}")
365
+
366
+ return True
multiframe_inference.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Joint fixed-N multi-frame generation inference (world-model / x_to_N).
2
+
3
+ Unlike `interleave_inference.py` (SigLIP-handoff autoregressive rollout — a prior
4
+ frame re-enters the next step as a *clean SigLIP image*), multi-frame generation
5
+ lays out **all N `<Image>` blocks in ONE sequence** and integrates them **jointly**
6
+ with a single Euler ODE. A prior frame stays in context as its **VAE latent**
7
+ (at the shared ODE timestep), exactly matching training, where the assistant turn
8
+ held N latent blocks denoised together (per-frame independent timesteps + hybrid
9
+ block attention: causal across frames, bidirectional within). So inference is:
10
+
11
+ seq = prompt(+ViT obs) + "open text" + N×(<Image>[LAT]*n</Image>) + EOS
12
+ x_k ~ N(0,1) for k in 1..N
13
+ for t in linspace(1,0,steps): # ONE shared schedule
14
+ flow_embed_k = vae2llm(x_k) + time(t) + pos # k = 1..N, concatenated
15
+ hidden = model(... flow_embeds @ N flow_positions ...) # ONE forward
16
+ x_k -= dt * llm2vae(hidden @ block_k) # k = 1..N
17
+ frame_k = VAE.decode(x_k)
18
+
19
+ The N frames cohere because frame k attends to frames 1..k (cross-frame causal),
20
+ all in the single forward pass — no per-frame autoregressive loop.
21
+
22
+ Alignment with training (this is the load-bearing correspondence)
23
+ -----------------------------------------------------------------
24
+ TRAINING (modeling_unified_mot._build_flow_embeds + flow_matching_modules.sample_timesteps):
25
+ the N target frames are packed into one sequence; `sample_timesteps` draws ONE
26
+ INDEPENDENT timestep per frame (`randn(len(shapes))`), each broadcast over that
27
+ frame's tokens; `x_t=(1-t)·clean+t·noise` per frame; a SINGLE forward predicts
28
+ the velocity for ALL N frames and the FM-MSE is summed over all N. So: every
29
+ frame independently noised, one forward → N frames.
30
+
31
+ INFERENCE (here): one forward PER Euler step injects all N frames' current x_t
32
+ (each with its own `time_embedder(t_k)`) at the N flow_positions and reads the
33
+ velocity for ALL N frames — structurally identical to the training forward
34
+ (one forward, N frames, per-frame time embedding). We therefore do NOT loop
35
+ per frame and do NOT re-encode prior frames via SigLIP.
36
+
37
+ The only inference-time choice is the per-frame timestep SCHEDULE `t_k(step)`:
38
+ - lockstep (default, validated ~28 dB): all frames share the same t each step,
39
+ swept 1→0 together. This is the equal-t diagonal of the joint t-space that the
40
+ independent-per-frame training already covers, so it is in-distribution.
41
+ - Because training randomized t per frame (incl. cases where earlier frames are
42
+ much cleaner than later ones), a staggered "diffusion-forcing" schedule
43
+ (frame k offset so it lags frame k-1) is also in-distribution and tends to
44
+ help long autoregressive rollouts — left as a future `--schedule` option.
45
+ Either way each step is ONE forward over all N frames; we never collapse the
46
+ model's per-frame timestep capability into N separate passes.
47
+
48
+ Run:
49
+ python multiframe_inference.py --ckpt <ckpt> --vae <ae.safetensors> \
50
+ --frames obs0.jpg obs1.jpg --task "imagine the next frames" \
51
+ --num_frames 4 --num_steps 50 --out_dir multiframe_out
52
+ """
53
+ from __future__ import annotations
54
+
55
+ import argparse
56
+ import os
57
+ from typing import List, Optional
58
+
59
+ import torch
60
+ from PIL import Image
61
+ from transformers.models.hunyuan_vl_mot import HunYuanVLMoTProcessor
62
+
63
+ # Reuse the (obs frames + task) prompt constructor, ViT placeholder ids, and the
64
+ # montage helper from the interleave inference module.
65
+ from interleave_inference import (
66
+ build_conditioned_sequence,
67
+ INPUT_IMAGE_PLACEHOLDER_IDS,
68
+ _row,
69
+ )
70
+
71
+
72
+ @torch.no_grad()
73
+ def _ar_decode_opening_text(
74
+ inner, processor, prompt_ids, pixel_values, image_grid_thw,
75
+ image_start_id, eos_id, device, max_text_tokens,
76
+ ):
77
+ """Greedily decode the assistant opening text until the model emits <Image>.
78
+
79
+ Mirrors the text loop in interleave_inference.generate_step_joint. Returns the
80
+ decoded token ids (without the terminating <Image>/EOS)."""
81
+ seq = list(prompt_ids)
82
+ text_ids: List[int] = []
83
+
84
+ def logits_last(ids):
85
+ seq_len = len(ids)
86
+ inp = torch.tensor(ids, dtype=torch.long, device=device).unsqueeze(0)
87
+ mod = torch.zeros(1, seq_len, dtype=torch.long, device=device)
88
+ iim = torch.zeros(1, seq_len, dtype=torch.bool, device=device)
89
+ for pid in INPUT_IMAGE_PLACEHOLDER_IDS:
90
+ m = inp[0] == pid
91
+ mod[0, m] = 1
92
+ iim[0, m] = True
93
+ out = inner(
94
+ input_ids=inp, inputs_embeds=None, attention_mask=None,
95
+ position_ids=torch.arange(seq_len, device=device).unsqueeze(0),
96
+ pixel_values=pixel_values, image_grid_thw=image_grid_thw,
97
+ cu_seqlens=torch.tensor([0, seq_len], dtype=torch.int32, device=device),
98
+ sample_ids=torch.zeros(1, seq_len, dtype=torch.int32, device=device),
99
+ modality_mask=mod, input_image_mask=iim,
100
+ flow_embeds=None, flow_positions=None,
101
+ g_seqlens=torch.zeros((0, 2), dtype=torch.int32, device=device),
102
+ )
103
+ return out.logits[0, -1]
104
+
105
+ for _ in range(max_text_tokens):
106
+ nxt = int(logits_last(seq).argmax().item())
107
+ if nxt == image_start_id or nxt == eos_id:
108
+ break
109
+ text_ids.append(nxt)
110
+ seq.append(nxt)
111
+ return text_ids
112
+
113
+ @torch.no_grad()
114
+ def generate_multiframe_joint(
115
+ model, vae, processor,
116
+ obs_frames: List[str], task_text: str, num_frames: int,
117
+ height: int, width: int, num_steps: int, device, dtype,
118
+ max_text_tokens: int = 96, opening_text: Optional[str] = None,
119
+ ):
120
+ """Jointly denoise N frames in one sequence. Returns (opening_text, [imgs])."""
121
+ from model.flow_matching_modules import unpatchify_latent
122
+ from text2image_inference import get_2d_position_ids
123
+
124
+ cfg = model.config
125
+ eos = cfg.eos_token_id
126
+ # Multi-frame uses a single <Video> ... </Video> wrapper instead of N×<Image>.
127
+ video_start = cfg.video_start_token_id # 120122
128
+ video_end = cfg.video_end_token_id # 120123
129
+ latent_ph = cfg.flow_latent_placeholder_id
130
+ inner = model.model
131
+
132
+ p = model.latent_patch_size
133
+ ds = cfg.vae_image_downsample
134
+ h_lat, w_lat = height // ds, width // ds
135
+ n_latent = h_lat * w_lat
136
+ patch_dim = p * p * cfg.vae_z_channels
137
+
138
+ # Append the multi-frame task instruction to the user turn (train==infer parity).
139
+ # build_conditioned_sequence folds task_text into the prompt.
140
+ from inference_utils import TASK_INSTRUCTION_MULTI_FRAME
141
+ instr_text = (task_text + "\n" + TASK_INSTRUCTION_MULTI_FRAME) if task_text else TASK_INSTRUCTION_MULTI_FRAME
142
+ prompt_ids, proc = build_conditioned_sequence(processor, obs_frames, instr_text)
143
+ pixel_values = proc.get("pixel_values")
144
+ image_grid_thw = proc.get("image_grid_thw")
145
+ if pixel_values is not None:
146
+ pixel_values = pixel_values.to(device=device, dtype=dtype)
147
+ if image_grid_thw is not None:
148
+ image_grid_thw = image_grid_thw.to(device=device)
149
+
150
+ # ---- opening text: teacher-force from GT or AR-decode until <Video> ----
151
+ if opening_text is not None:
152
+ text_ids = processor.tokenizer.encode(opening_text, add_special_tokens=False)
153
+ text_str = opening_text
154
+ else:
155
+ text_ids = _ar_decode_opening_text(
156
+ inner, processor, prompt_ids, pixel_values, image_grid_thw,
157
+ video_start, eos, device, max_text_tokens,
158
+ )
159
+ text_str = processor.tokenizer.decode(text_ids, skip_special_tokens=True)
160
+
161
+ # ---- lay out the full sequence: <Video> + N×[LAT] + </Video> (bare latents,
162
+ # matches the dataset's multi-frame layout; one flow_positions row per frame) ----
163
+ seq = list(prompt_ids) + list(text_ids)
164
+ seq.append(video_start)
165
+ frame_spans = [] # (latent_start, latent_end) per frame
166
+ for _ in range(num_frames):
167
+ ls = len(seq)
168
+ seq.extend([latent_ph] * n_latent)
169
+ le = len(seq)
170
+ frame_spans.append((ls, le))
171
+ seq.append(video_end)
172
+ seq.append(eos)
173
+ seq_len = len(seq)
174
+ input_ids = torch.tensor(seq, dtype=torch.long, device=device).unsqueeze(0)
175
+
176
+ mod = torch.zeros(1, seq_len, dtype=torch.long, device=device)
177
+ iim = torch.zeros(1, seq_len, dtype=torch.bool, device=device)
178
+ for pid in INPUT_IMAGE_PLACEHOLDER_IDS:
179
+ m = input_ids[0] == pid
180
+ mod[0, m] = 1
181
+ iim[0, m] = True
182
+ for ls, le in frame_spans:
183
+ mod[0, ls:le] = 2 # generation-latent route
184
+
185
+ # flow_positions / g_seqlens in frame order — must match flow_embeds concat order
186
+ # (model injects flow_embeds slices into flow_positions rows in order; see
187
+ # modeling_unified_mot.py:177-183).
188
+ flow_positions = torch.tensor(
189
+ [[ls, le] for ls, le in frame_spans], dtype=torch.int32, device=device
190
+ )
191
+ g_seqlens = flow_positions.clone()
192
+ latent_pos_ids = get_2d_position_ids(h_lat, w_lat, cfg.max_latent_size).to(device)
193
+ cu = torch.tensor([0, seq_len], dtype=torch.int32, device=device)
194
+ sid = torch.zeros(1, seq_len, dtype=torch.int32, device=device)
195
+ pos = torch.arange(seq_len, device=device).unsqueeze(0)
196
+
197
+ # ---- N latent buffers, joint Euler ODE (ONE forward/step over all N frames) ----
198
+ # Per-frame timestep schedule t_k(step): training noised each frame at its OWN t,
199
+ # so we keep a per-frame t here too. Default = lockstep (all frames share the
200
+ # step's t) — the validated in-distribution schedule. `frame_t_offset[k]` is the
201
+ # hook for a staggered "diffusion-forcing" schedule (kept 0 = lockstep).
202
+ xs = [torch.randn(n_latent, patch_dim, device=device, dtype=dtype) for _ in range(num_frames)]
203
+ ts = torch.linspace(1.0, 0.0, num_steps + 1, device=device, dtype=dtype)
204
+ frame_t_offset = [0.0] * num_frames # all 0 -> lockstep; set per-frame for diffusion-forcing
205
+ pos_emb = model.latent_pos_embed(latent_pos_ids).to(dtype) # same per-frame (ids restart at 0)
206
+ for i in range(num_steps):
207
+ dt = ts[i] - ts[i + 1]
208
+ fe_parts = []
209
+ for k in range(num_frames):
210
+ t_k = (ts[i] + frame_t_offset[k]).clamp(0.0, 1.0) # this frame's timestep
211
+ time_emb_k = model.time_embedder(t_k.expand(n_latent)).to(dtype)
212
+ x_proj = model.vae2llm(xs[k].to(model.vae2llm.weight.dtype)).to(dtype)
213
+ fe_parts.append(x_proj + time_emb_k + pos_emb)
214
+ flow_embeds = torch.cat(fe_parts, dim=0) # (N*n_latent, D), frame order == flow_positions
215
+ out = inner(
216
+ input_ids=input_ids, inputs_embeds=None, attention_mask=None,
217
+ position_ids=pos, pixel_values=pixel_values, image_grid_thw=image_grid_thw,
218
+ cu_seqlens=cu, sample_ids=sid, modality_mask=mod, input_image_mask=iim,
219
+ flow_embeds=flow_embeds, flow_positions=flow_positions, g_seqlens=g_seqlens,
220
+ )
221
+ hidden = out.hidden_states
222
+ for k, (ls, le) in enumerate(frame_spans):
223
+ v = model.llm2vae(hidden[0, ls:le]).to(dtype)
224
+ xs[k] = xs[k] - dt * v
225
+
226
+ # ---- decode each frame ----
227
+ imgs = []
228
+ vae_dtype = next(vae.parameters()).dtype
229
+ for k in range(num_frames):
230
+ x_lat = unpatchify_latent(xs[k].float(), h_lat, w_lat, p, cfg.vae_z_channels)
231
+ x_lat = x_lat.unsqueeze(0).to(device=device, dtype=vae_dtype)
232
+ img = vae.decode(x_lat)
233
+ if hasattr(img, "sample"):
234
+ img = img.sample
235
+ imgs.append(img.squeeze(0).float().clamp(-1, 1))
236
+ return text_str, imgs
237
+
238
+
239
+ def vae_target_hw(img_path: str, max_size: int = 256, min_size: int = 96, stride: int = 16):
240
+ """(h_px, w_px) that training's VAEImageTransform produces for this image — so the
241
+ generated frame's latent grid matches what the model was trained to output. Runs the
242
+ REAL VAEImageTransform for exact parity; returns None on failure.
243
+
244
+ Why this matters: training resized each target frame aspect-preserving to <= max_size,
245
+ stride-divisible — jaka/umi 848x480 -> 256x144 (144 latent tok), xtrainer 640x480 ->
246
+ 256x192 (192 latent tok). Generating every robot at a fixed 256x144 is wrong for
247
+ xtrainer (4:3): wrong token count + wrong aspect, which corrupts both its output and
248
+ its PSNR-vs-GT comparison (GT got squished 4:3 -> 16:9).
249
+ """
250
+ try:
251
+ from inference_utils import VAEImageTransform
252
+ t = VAEImageTransform(max_size=max_size, min_size=min_size, stride=stride)
253
+ tensor = t(Image.open(img_path).convert("RGB")) # (3, H, W)
254
+ return int(tensor.shape[1]), int(tensor.shape[2])
255
+ except Exception: # pylint: disable=broad-except # any failure → caller falls back
256
+ return None
257
+
258
+
259
+ def main():
260
+ ap = argparse.ArgumentParser(description="Joint fixed-N multi-frame generation.")
261
+ ap.add_argument("--ckpt", required=True)
262
+ ap.add_argument("--vae", required=True)
263
+ ap.add_argument("--frames", nargs="+", required=True, help="observation frame path(s)")
264
+ ap.add_argument("--task", required=True, help="overall task text")
265
+ ap.add_argument("--num_frames", type=int, default=4,
266
+ help="number of future frames to generate")
267
+ ap.add_argument("--out_dir", default="multiframe_out")
268
+ ap.add_argument("--height", type=int, default=None,
269
+ help="override auto-derived generation height (px). Default: derive "
270
+ "per-record from the GT/obs frame via the training VAEImageTransform "
271
+ "(jaka/umi 848x480 -> 144, xtrainer 640x480 -> 192).")
272
+ ap.add_argument("--width", type=int, default=None,
273
+ help="override auto-derived generation width (px). Default: derived (256).")
274
+ ap.add_argument("--max_image_size", type=int, default=256,
275
+ help="VAEImageTransform max side — MUST match training (256).")
276
+ ap.add_argument("--min_image_size", type=int, default=96,
277
+ help="VAEImageTransform min side — MUST match training (96).")
278
+ ap.add_argument("--image_stride", type=int, default=16,
279
+ help="VAEImageTransform stride — MUST match training (16).")
280
+ ap.add_argument("--num_steps", type=int, default=50)
281
+ ap.add_argument("--max_text_tokens", type=int, default=96)
282
+ ap.add_argument("--seed", type=int, default=0)
283
+ ap.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
284
+ ap.add_argument("--understanding_max_pixels", type=int, default=524288,
285
+ help="Cap obs-frame ViT input pixels to MATCH training. "
286
+ "Default 524288 (~494 tok/frame); the model default 4194304 (~4050 tok/frame) "
287
+ "is an 8x train/infer resolution mismatch that degrades eval. Set 0 to disable.")
288
+ args = ap.parse_args()
289
+
290
+ from model import UnifiedMoTForConditionalGeneration, maybe_init_generation_path
291
+ from vae_model.autoencoder import load_ae
292
+
293
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
294
+ dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype]
295
+ torch.manual_seed(args.seed)
296
+
297
+ # ---- resolve obs frames + task ----
298
+ obs, task = args.frames, args.task
299
+ n_frames = args.num_frames or 1
300
+ if not n_frames:
301
+ raise ValueError("num_frames resolved to 0 — pass --num_frames >= 1")
302
+
303
+ processor = HunYuanVLMoTProcessor.from_pretrained(args.ckpt, trust_remote_code=True)
304
+ # train==infer parity: cap obs-frame ViT pixels to the SAME value training used.
305
+ # Without this, inference obs frames are ~8x higher-res than what the model saw at train.
306
+ if args.understanding_max_pixels and args.understanding_max_pixels > 0:
307
+ ip = processor.image_processor
308
+ ip.max_pixels = args.understanding_max_pixels
309
+ if isinstance(getattr(ip, "size", None), dict) and "longest_edge" in ip.size:
310
+ ip.size["longest_edge"] = args.understanding_max_pixels
311
+ model = UnifiedMoTForConditionalGeneration.from_pretrained(args.ckpt, dtype=dtype)
312
+ maybe_init_generation_path(model, model_load_path=args.ckpt)
313
+ model.to(device).eval()
314
+ vae, _ = load_ae(args.vae)
315
+ vae.requires_grad_(False)
316
+ vae.eval()
317
+ vae.to(device, dtype=dtype)
318
+
319
+ os.makedirs(args.out_dir, exist_ok=True)
320
+ print(f"JOINT multi-frame | obs={len(obs)} frame(s) | num_frames={n_frames}")
321
+ print(f"TASK: {task}\n")
322
+
323
+ # ---- generation resolution: match training's VAEImageTransform per record ----
324
+ # Training resized each TARGET frame aspect-preserving to <=max_image_size,
325
+ # stride-divisible (jaka/umi -> 256x144, xtrainer -> 256x192). Generating every
326
+ # robot at a fixed 256x144 was wrong for xtrainer (4:3). Derive from the data;
327
+ # explicit --height/--width override.
328
+ if args.height and args.width:
329
+ height, width = args.height, args.width
330
+ else:
331
+ ref = obs[0] if obs else None
332
+ hw = (vae_target_hw(ref, args.max_image_size, args.min_image_size, args.image_stride)
333
+ if ref else None)
334
+ if hw is None:
335
+ height, width = 144, 256
336
+ print("[warn] could not derive resolution from data; falling back to 144x256")
337
+ else:
338
+ height, width = hw
339
+ ds = model.config.vae_image_downsample
340
+ print(f" gen resolution: {width}x{height} px (latent {width // ds}x{height // ds} = {(width // ds) * (height // ds)} tok/frame)")
341
+
342
+ text_str, imgs = generate_multiframe_joint(
343
+ model, vae, processor, obs, task, n_frames,
344
+ height, width, args.num_steps, device, dtype,
345
+ max_text_tokens=args.max_text_tokens, opening_text=None,
346
+ )
347
+
348
+ # ---- save frames + montage + result.txt ----
349
+ gen_paths = []
350
+ for k, img in enumerate(imgs):
351
+ arr = ((img.cpu().permute(1, 2, 0).numpy() + 1.0) * 127.5).clip(0, 255).astype("uint8")
352
+ path = os.path.join(args.out_dir, f"frame{k + 1}.png")
353
+ Image.fromarray(arr).save(path)
354
+ gen_paths.append(path)
355
+
356
+ lines = [f"TASK: {task}", "",
357
+ f"OPEN text (decoded): {text_str}"]
358
+ lines.append("")
359
+ for k in range(len(imgs)):
360
+ lines.append(f"frame {k + 1}")
361
+ txt_path = os.path.join(args.out_dir, "result.txt")
362
+ open(txt_path, "w").write("\n".join(lines))
363
+ print("\n".join(lines))
364
+
365
+ from PIL import ImageDraw
366
+ # montage: INPUT (obs frames) / GEN (ours),
367
+ # each a horizontal strip of W×H cells (W,H = the derived generation resolution),
368
+ # with a left label column.
369
+ label_w = 64
370
+ gap = 6
371
+ rows = [("INPUT", list(obs)),
372
+ ("GEN", gen_paths)]
373
+ rows = [(lbl, paths) for lbl, paths in rows if paths] # drop empty
374
+ strips = [(lbl, _row(paths, width, height)) for lbl, paths in rows]
375
+ montage_w = label_w + max(s.width for _, s in strips)
376
+ montage_h = sum(s.height for _, s in strips) + gap * (len(strips) - 1)
377
+ montage = Image.new("RGB", (montage_w, montage_h), (0, 0, 0))
378
+ draw = ImageDraw.Draw(montage)
379
+ y = 0
380
+ for lbl, strip in strips:
381
+ montage.paste(strip, (label_w, y))
382
+ draw.text((6, y + strip.height // 2 - 4), lbl, fill=(255, 255, 255))
383
+ y += strip.height + gap
384
+ out_png = os.path.join(args.out_dir, "multiframe_input_GEN.png")
385
+ montage.save(out_png)
386
+ print(f"\nGenerated {len(imgs)} frames -> {args.out_dir}")
387
+ print(f" montage (INPUT/GEN): {out_png}")
388
+
389
+
390
+ if __name__ == "__main__":
391
+ main()
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cu130
2
+ torch==2.10.0
3
+ torchvision==0.25.0
4
+ safetensors
5
+ einops
6
+ numpy
7
+ pillow
8
+ https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/pt210-cu130-cp312/flash_attn-2.8.3-cp312-cp312-linux_x86_64.whl
9
+ git+https://github.com/huggingface/transformers@9293856c419762ebf98fbe2bd9440f9ce7069f1a
text2image_inference.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal text-to-image inference for the packed UnifiedMoT model.
2
+
3
+ Performs Euler-ODE sampling in the LLM hidden space:
4
+ x_T ~ N(0, 1)
5
+ for t in linspace(1, 0, num_steps + 1)[:-1]:
6
+ flow_embed = vae2llm(x_t) + time_embedder(t) + latent_pos_embed
7
+ hidden = model(... flow_embed injected at latent positions ...)
8
+ v_pred = llm2vae(hidden_at_latent_positions)
9
+ x_{t-dt} = x_t - dt * v_pred
10
+
11
+ Then VAE-decodes the final x_0 to pixels.
12
+
13
+ This script is single-sample / single-image. Classifier-free guidance (CFG)
14
+ is optional via ``--cfg_scale`` (>1 enables it): each ODE step runs a second
15
+ forward with an empty prompt, matching the 10% ``text_cond_dropout`` used in
16
+ training. ``--cfg_scale 1`` (default) disables CFG for the fastest path.
17
+
18
+ Run:
19
+ # no CFG (fastest)
20
+ python text2image_inference.py --ckpt /path/to/checkpoint \\
21
+ --prompt "a watercolor cat" \\
22
+ --vae /path/to/vae.safetensors \\
23
+ --out out.png \\
24
+ --height 256 --width 256 --num_steps 25
25
+
26
+ # with CFG
27
+ python text2image_inference.py --ckpt /path/to/checkpoint \\
28
+ --prompt "a watercolor cat" \\
29
+ --vae /path/to/vae.safetensors \\
30
+ --cfg_scale 5.0 --num_steps 50
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+
36
+ import torch
37
+ from PIL import Image
38
+ from transformers.models.hunyuan_vl_mot import HunYuanVLMoTProcessor
39
+
40
+ from model import (
41
+ UnifiedMoTConfig,
42
+ UnifiedMoTForConditionalGeneration,
43
+ maybe_init_generation_path,
44
+ )
45
+ from model.flow_matching_modules import (
46
+ unpatchify_latent,
47
+ )
48
+ from vae_model.autoencoder import load_ae
49
+
50
+
51
+ def parse_args():
52
+ p = argparse.ArgumentParser()
53
+ p.add_argument("--ckpt", required=True, help="Model checkpoint directory")
54
+ p.add_argument("--vae", required=True, help="VAE safetensors path")
55
+ p.add_argument("--prompt", required=True)
56
+ p.add_argument("--out", default="out.png")
57
+ p.add_argument("--height", type=int, default=256)
58
+ p.add_argument("--width", type=int, default=256)
59
+ p.add_argument("--num_steps", type=int, default=25)
60
+ p.add_argument("--cfg_scale", type=float, default=1.0,
61
+ help="Classifier-free guidance scale. >1 enables CFG "
62
+ "(runs an extra empty-prompt forward per step; 2-5 typical). "
63
+ "1.0 disables CFG.")
64
+ p.add_argument("--seed", type=int, default=0)
65
+ p.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
66
+ return p.parse_args()
67
+
68
+
69
+ def get_2d_position_ids(h: int, w: int, max_per_side: int) -> torch.Tensor:
70
+ """2D-flattened position ids matching model.PositionEmbedding lookup table."""
71
+ rows = torch.arange(h)[:, None] * max_per_side
72
+ cols = torch.arange(w)[None, :]
73
+ return (rows + cols).reshape(-1).long()
74
+
75
+
76
+ @torch.no_grad()
77
+ def _build_seq_meta(processor, prompt, cfg, n_latent_tokens, device):
78
+ """Build the packed (1, T) input sequence + routing tensors for one prompt.
79
+
80
+ Sequence:
81
+ chat_template(user/assistant) + <Image> + LATENT*N + </Image> + EOS
82
+ Returns a dict of everything the per-step forward needs (constant across
83
+ ODE steps except the latent region, which the caller patches each step).
84
+ """
85
+ eos = cfg.eos_token_id
86
+ prompt_messages = [
87
+ {"role": "user", "content": [{"type": "text", "text": prompt}]},
88
+ {"role": "assistant", "content": ""},
89
+ ]
90
+ prompt_inputs = processor.apply_chat_template(
91
+ prompt_messages, return_dict=True, tokenize=True, add_generation_prompt=False,
92
+ )
93
+ prompt_ids = list(prompt_inputs["input_ids"][0])
94
+ # Strip trailing EOS so the assistant turn flows into <Image>
95
+ while prompt_ids and prompt_ids[-1] == eos:
96
+ prompt_ids.pop()
97
+
98
+ latent_ph = cfg.flow_latent_placeholder_id # upstream latent_token_id
99
+ image_start = cfg.image_start_token_id
100
+ image_end = cfg.image_end_token_id
101
+
102
+ seq = (
103
+ prompt_ids
104
+ + [image_start]
105
+ + [latent_ph] * n_latent_tokens
106
+ + [image_end]
107
+ + [eos]
108
+ )
109
+ input_ids = torch.tensor(seq, dtype=torch.long, device=device).unsqueeze(0) # (1, T)
110
+ seq_len = input_ids.shape[1]
111
+ latent_start = len(prompt_ids) + 1 # right after <Image>
112
+ latent_end = latent_start + n_latent_tokens
113
+
114
+ modality_mask = torch.zeros(1, seq_len, dtype=torch.long, device=device)
115
+ modality_mask[0, latent_start:latent_end] = 2 # gen-latent route
116
+ flow_positions = torch.tensor([[latent_start, latent_end]], dtype=torch.int32, device=device)
117
+ g_seqlens = flow_positions.clone()
118
+ # Single sample → packed degenerate: still feed cu_seqlens/sample_ids so the
119
+ # same attention path runs as during training.
120
+ cu_seqlens = torch.tensor([0, seq_len], dtype=torch.int32, device=device)
121
+ sample_ids = torch.zeros(1, seq_len, dtype=torch.int32, device=device)
122
+ position_ids = torch.arange(seq_len, dtype=torch.long, device=device).unsqueeze(0)
123
+ return {
124
+ "input_ids": input_ids, "T": seq_len,
125
+ "latent_start": latent_start, "latent_end": latent_end,
126
+ "modality_mask": modality_mask, "g_seqlens": g_seqlens,
127
+ "cu_seqlens": cu_seqlens, "sample_ids": sample_ids,
128
+ "position_ids": position_ids,
129
+ }
130
+
131
+
132
+ @torch.no_grad()
133
+ def _forward_v(model, inner, base_embeds, flow_embed, meta, dtype):
134
+ """Run one forward with `flow_embed` injected at the latent span; return velocity."""
135
+ inputs_embeds = base_embeds.clone()
136
+ inputs_embeds[0, meta["latent_start"]:meta["latent_end"]] = flow_embed
137
+ out = inner(
138
+ input_ids=None,
139
+ inputs_embeds=inputs_embeds,
140
+ attention_mask=None,
141
+ position_ids=meta["position_ids"],
142
+ cu_seqlens=meta["cu_seqlens"],
143
+ sample_ids=meta["sample_ids"],
144
+ modality_mask=meta["modality_mask"],
145
+ input_image_mask=torch.zeros(1, meta["T"], dtype=torch.bool, device=inputs_embeds.device),
146
+ flow_embeds=None, # we already pre-built inputs_embeds
147
+ flow_positions=None,
148
+ g_seqlens=meta["g_seqlens"],
149
+ )
150
+ # FM velocity target during training is v = noise - x_0
151
+ return model.llm2vae(out.hidden_states[0, meta["latent_start"]:meta["latent_end"]]).to(dtype)
152
+
153
+
154
+ @torch.no_grad()
155
+ def generate_image(
156
+ model: UnifiedMoTForConditionalGeneration,
157
+ vae,
158
+ processor: HunYuanVLMoTProcessor,
159
+ prompt: str,
160
+ height: int,
161
+ width: int,
162
+ num_steps: int,
163
+ device,
164
+ dtype,
165
+ cfg_scale: float = 1.0,
166
+ ):
167
+ """T2I single-sample sampling (optional CFG). Returns a (3, H, W) tensor in [-1, 1].
168
+
169
+ With ``cfg_scale > 1`` each ODE step runs cond + uncond forwards and combines
170
+ v = v_uncond + cfg_scale * (v_cond - v_uncond)
171
+ The uncond branch uses an empty prompt, matching text_cond_dropout=0.1 in
172
+ training where 10% of samples have the caption replaced with "".
173
+ """
174
+ cfg: UnifiedMoTConfig = model.config
175
+ p = model.latent_patch_size
176
+ downsample = cfg.vae_image_downsample # pixel → patch-token (e.g. 16 = VAE(8) * patch(2))
177
+ h_lat = height // downsample
178
+ w_lat = width // downsample
179
+ n_latent_tokens = h_lat * w_lat
180
+
181
+ do_cfg = (cfg_scale != 1.0)
182
+ embed_layer = model.get_input_embeddings()
183
+
184
+ cond_meta = _build_seq_meta(processor, prompt, cfg, n_latent_tokens, device)
185
+ cond_base = embed_layer(cond_meta["input_ids"])
186
+ if do_cfg:
187
+ uncond_meta = _build_seq_meta(processor, "", cfg, n_latent_tokens, device)
188
+ uncond_base = embed_layer(uncond_meta["input_ids"])
189
+
190
+ # 2D position ids for latent_pos_embed lookup
191
+ latent_pos_ids = get_2d_position_ids(h_lat, w_lat, cfg.max_latent_size).to(device)
192
+
193
+ # Initial noise x_T
194
+ patch_latent_dim = p * p * cfg.vae_z_channels
195
+ x = torch.randn(n_latent_tokens, patch_latent_dim, device=device, dtype=dtype)
196
+
197
+ # Euler ODE: t from 1.0 → 0.0 in `num_steps`
198
+ ts = torch.linspace(1.0, 0.0, num_steps + 1, device=device, dtype=dtype)
199
+ inner = model.model # UnifiedMoTModel
200
+
201
+ for i in range(num_steps):
202
+ t = ts[i]
203
+ dt = ts[i] - ts[i + 1] # positive
204
+
205
+ # Build flow_embed for the current x_t (shared by cond & uncond branches)
206
+ time_emb = model.time_embedder(t.expand(n_latent_tokens)).to(dtype)
207
+ x_proj = model.vae2llm(x.to(model.vae2llm.weight.dtype)).to(dtype)
208
+ pos_emb = model.latent_pos_embed(latent_pos_ids).to(dtype)
209
+ flow_embed = x_proj + time_emb + pos_emb # (n_latent_tokens, D)
210
+
211
+ v_cond = _forward_v(model, inner, cond_base, flow_embed, cond_meta, dtype)
212
+ if do_cfg:
213
+ v_uncond = _forward_v(model, inner, uncond_base, flow_embed, uncond_meta, dtype)
214
+ v = v_uncond + cfg_scale * (v_cond - v_uncond)
215
+ else:
216
+ v = v_cond
217
+ # Euler step: x_{t-dt} = x_t - dt * v
218
+ x = x - dt * v
219
+
220
+ # Unpatchify + VAE decode
221
+ x_lat = unpatchify_latent(x.float(), h_lat, w_lat, p, cfg.vae_z_channels) # (C, H_lat, W_lat)
222
+ vae_dtype = next(vae.parameters()).dtype
223
+ x_lat = x_lat.unsqueeze(0).to(device=device, dtype=vae_dtype)
224
+ img = vae.decode(x_lat)
225
+ if hasattr(img, "sample"):
226
+ img = img.sample
227
+ return img.squeeze(0).float().clamp(-1, 1)
228
+
229
+
230
+ def main():
231
+ args = parse_args()
232
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
233
+ dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype]
234
+ torch.manual_seed(args.seed)
235
+
236
+ print(f"Loading processor from {args.ckpt}...")
237
+ processor = HunYuanVLMoTProcessor.from_pretrained(args.ckpt, trust_remote_code=True)
238
+
239
+ print(f"Loading model from {args.ckpt}...")
240
+ model = UnifiedMoTForConditionalGeneration.from_pretrained(args.ckpt, dtype=dtype)
241
+
242
+ maybe_init_generation_path(model, model_load_path=args.ckpt)
243
+ model.to(device)
244
+ model.eval()
245
+
246
+ print(f"Loading VAE from {args.vae}...")
247
+ vae, _ = load_ae(args.vae)
248
+ vae.requires_grad_(False)
249
+ vae.eval()
250
+ vae.to(device, dtype=dtype)
251
+
252
+ cfg_note = f", CFG {args.cfg_scale}" if args.cfg_scale != 1.0 else " (no CFG)"
253
+ print(f"Generating: '{args.prompt}' @ {args.width}x{args.height}, {args.num_steps} ODE steps{cfg_note}")
254
+ img = generate_image(
255
+ model, vae, processor, args.prompt,
256
+ height=args.height, width=args.width, num_steps=args.num_steps,
257
+ device=device, dtype=dtype, cfg_scale=args.cfg_scale,
258
+ )
259
+
260
+ # Save: (C, H, W) in [-1, 1] → uint8 PNG
261
+ arr = ((img.cpu().permute(1, 2, 0).numpy() + 1.0) * 127.5).clip(0, 255).astype("uint8")
262
+ Image.fromarray(arr).save(args.out)
263
+ print(f"Saved → {args.out}")
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()
vae_model/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .autoencoder import AutoEncoder
vae_model/autoencoder.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2024 Black Forest Labs.
2
+ # Copyright (c) 2025 Bytedance Ltd. and/or its affiliates.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # This file has been modified by ByteDance Ltd. and/or its affiliates. on 2025-05-20.
6
+ #
7
+ # Original file was released under Apache-2.0, with the full license text
8
+ # available at https://github.com/black-forest-labs/flux/blob/main/LICENSE.
9
+ #
10
+ # This modified file is released under the same license.
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import torch
15
+ from einops import rearrange
16
+ from torch import Tensor, nn
17
+ from safetensors.torch import load_file as load_sft
18
+
19
+
20
+ @dataclass
21
+ class AutoEncoderParams:
22
+ resolution: int
23
+ in_channels: int
24
+ downsample: int
25
+ ch: int
26
+ out_ch: int
27
+ ch_mult: list[int]
28
+ num_res_blocks: int
29
+ z_channels: int
30
+ scale_factor: float
31
+ shift_factor: float
32
+
33
+
34
+ def swish(x: Tensor) -> Tensor:
35
+ return x * torch.sigmoid(x)
36
+
37
+
38
+ class AttnBlock(nn.Module):
39
+ def __init__(self, in_channels: int):
40
+ super().__init__()
41
+ self.in_channels = in_channels
42
+
43
+ self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
44
+
45
+ self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1)
46
+ self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1)
47
+ self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1)
48
+ self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1)
49
+
50
+ def attention(self, h_: Tensor) -> Tensor:
51
+ h_ = self.norm(h_)
52
+ q = self.q(h_)
53
+ k = self.k(h_)
54
+ v = self.v(h_)
55
+
56
+ b, c, h, w = q.shape
57
+ q = rearrange(q, "b c h w -> b 1 (h w) c").contiguous()
58
+ k = rearrange(k, "b c h w -> b 1 (h w) c").contiguous()
59
+ v = rearrange(v, "b c h w -> b 1 (h w) c").contiguous()
60
+ h_ = nn.functional.scaled_dot_product_attention(q, k, v)
61
+
62
+ return rearrange(h_, "b 1 (h w) c -> b c h w", h=h, w=w, c=c, b=b)
63
+
64
+ def forward(self, x: Tensor) -> Tensor:
65
+ return x + self.proj_out(self.attention(x))
66
+
67
+
68
+ class ResnetBlock(nn.Module):
69
+ def __init__(self, in_channels: int, out_channels: int):
70
+ super().__init__()
71
+ self.in_channels = in_channels
72
+ out_channels = in_channels if out_channels is None else out_channels
73
+ self.out_channels = out_channels
74
+
75
+ self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
76
+ self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
77
+ self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True)
78
+ self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
79
+ if self.in_channels != self.out_channels:
80
+ self.nin_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0)
81
+
82
+ def forward(self, x):
83
+ h = x
84
+ h = self.norm1(h)
85
+ h = swish(h)
86
+ h = self.conv1(h)
87
+
88
+ h = self.norm2(h)
89
+ h = swish(h)
90
+ h = self.conv2(h)
91
+
92
+ if self.in_channels != self.out_channels:
93
+ x = self.nin_shortcut(x)
94
+
95
+ return x + h
96
+
97
+
98
+ class Downsample(nn.Module):
99
+ def __init__(self, in_channels: int):
100
+ super().__init__()
101
+ # no asymmetric padding in torch conv, must do it ourselves
102
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=2, padding=0)
103
+
104
+ def forward(self, x: Tensor):
105
+ pad = (0, 1, 0, 1)
106
+ x = nn.functional.pad(x, pad, mode="constant", value=0)
107
+ x = self.conv(x)
108
+ return x
109
+
110
+
111
+ class Upsample(nn.Module):
112
+ def __init__(self, in_channels: int):
113
+ super().__init__()
114
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=1, padding=1)
115
+
116
+ def forward(self, x: Tensor):
117
+ x = nn.functional.interpolate(x, scale_factor=2.0, mode="nearest")
118
+ x = self.conv(x)
119
+ return x
120
+
121
+
122
+ class Encoder(nn.Module):
123
+ def __init__(
124
+ self,
125
+ resolution: int,
126
+ in_channels: int,
127
+ ch: int,
128
+ ch_mult: list[int],
129
+ num_res_blocks: int,
130
+ z_channels: int,
131
+ ):
132
+ super().__init__()
133
+ self.ch = ch
134
+ self.num_resolutions = len(ch_mult)
135
+ self.num_res_blocks = num_res_blocks
136
+ self.resolution = resolution
137
+ self.in_channels = in_channels
138
+ # downsampling
139
+ self.conv_in = nn.Conv2d(in_channels, self.ch, kernel_size=3, stride=1, padding=1)
140
+
141
+ curr_res = resolution
142
+ in_ch_mult = (1,) + tuple(ch_mult)
143
+ self.in_ch_mult = in_ch_mult
144
+ self.down = nn.ModuleList()
145
+ block_in = self.ch
146
+ for i_level in range(self.num_resolutions):
147
+ block = nn.ModuleList()
148
+ attn = nn.ModuleList()
149
+ block_in = ch * in_ch_mult[i_level]
150
+ block_out = ch * ch_mult[i_level]
151
+ for _ in range(self.num_res_blocks):
152
+ block.append(ResnetBlock(in_channels=block_in, out_channels=block_out))
153
+ block_in = block_out
154
+ down = nn.Module()
155
+ down.block = block
156
+ down.attn = attn
157
+ if i_level != self.num_resolutions - 1:
158
+ down.downsample = Downsample(block_in)
159
+ curr_res = curr_res // 2
160
+ self.down.append(down)
161
+
162
+ # middle
163
+ self.mid = nn.Module()
164
+ self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in)
165
+ self.mid.attn_1 = AttnBlock(block_in)
166
+ self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in)
167
+
168
+ # end
169
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
170
+ self.conv_out = nn.Conv2d(block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1)
171
+
172
+ def forward(self, x: Tensor) -> Tensor:
173
+ # downsampling
174
+ hs = [self.conv_in(x)]
175
+ for i_level in range(self.num_resolutions):
176
+ for i_block in range(self.num_res_blocks):
177
+ h = self.down[i_level].block[i_block](hs[-1])
178
+ if len(self.down[i_level].attn) > 0:
179
+ h = self.down[i_level].attn[i_block](h)
180
+ hs.append(h)
181
+ if i_level != self.num_resolutions - 1:
182
+ hs.append(self.down[i_level].downsample(hs[-1]))
183
+
184
+ # middle
185
+ h = hs[-1]
186
+ h = self.mid.block_1(h)
187
+ h = self.mid.attn_1(h)
188
+ h = self.mid.block_2(h)
189
+ # end
190
+ h = self.norm_out(h)
191
+ h = swish(h)
192
+ h = self.conv_out(h)
193
+ return h
194
+
195
+
196
+ class Decoder(nn.Module):
197
+ def __init__(
198
+ self,
199
+ ch: int,
200
+ out_ch: int,
201
+ ch_mult: list[int],
202
+ num_res_blocks: int,
203
+ in_channels: int,
204
+ resolution: int,
205
+ z_channels: int,
206
+ ):
207
+ super().__init__()
208
+ self.ch = ch
209
+ self.num_resolutions = len(ch_mult)
210
+ self.num_res_blocks = num_res_blocks
211
+ self.resolution = resolution
212
+ self.in_channels = in_channels
213
+ self.ffactor = 2 ** (self.num_resolutions - 1)
214
+
215
+ # compute in_ch_mult, block_in and curr_res at lowest res
216
+ block_in = ch * ch_mult[self.num_resolutions - 1]
217
+ curr_res = resolution // 2 ** (self.num_resolutions - 1)
218
+ self.z_shape = (1, z_channels, curr_res, curr_res)
219
+
220
+ # z to block_in
221
+ self.conv_in = nn.Conv2d(z_channels, block_in, kernel_size=3, stride=1, padding=1)
222
+
223
+ # middle
224
+ self.mid = nn.Module()
225
+ self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in)
226
+ self.mid.attn_1 = AttnBlock(block_in)
227
+ self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in)
228
+
229
+ # upsampling
230
+ self.up = nn.ModuleList()
231
+ for i_level in reversed(range(self.num_resolutions)):
232
+ block = nn.ModuleList()
233
+ attn = nn.ModuleList()
234
+ block_out = ch * ch_mult[i_level]
235
+ for _ in range(self.num_res_blocks + 1):
236
+ block.append(ResnetBlock(in_channels=block_in, out_channels=block_out))
237
+ block_in = block_out
238
+ up = nn.Module()
239
+ up.block = block
240
+ up.attn = attn
241
+ if i_level != 0:
242
+ up.upsample = Upsample(block_in)
243
+ curr_res = curr_res * 2
244
+ self.up.insert(0, up) # prepend to get consistent order
245
+
246
+ # end
247
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
248
+ self.conv_out = nn.Conv2d(block_in, out_ch, kernel_size=3, stride=1, padding=1)
249
+
250
+ def forward(self, z: Tensor) -> Tensor:
251
+ # z to block_in
252
+ h = self.conv_in(z)
253
+
254
+ # middle
255
+ h = self.mid.block_1(h)
256
+ h = self.mid.attn_1(h)
257
+ h = self.mid.block_2(h)
258
+
259
+ # upsampling
260
+ for i_level in reversed(range(self.num_resolutions)):
261
+ for i_block in range(self.num_res_blocks + 1):
262
+ h = self.up[i_level].block[i_block](h)
263
+ if len(self.up[i_level].attn) > 0:
264
+ h = self.up[i_level].attn[i_block](h)
265
+ if i_level != 0:
266
+ h = self.up[i_level].upsample(h)
267
+
268
+ # end
269
+ h = self.norm_out(h)
270
+ h = swish(h)
271
+ h = self.conv_out(h)
272
+ return h
273
+
274
+
275
+ class DiagonalGaussian(nn.Module):
276
+ def __init__(self, sample: bool = True, chunk_dim: int = 1):
277
+ super().__init__()
278
+ self.sample = sample
279
+ self.chunk_dim = chunk_dim
280
+
281
+ def forward(self, z: Tensor) -> Tensor:
282
+ mean, logvar = torch.chunk(z, 2, dim=self.chunk_dim)
283
+ if self.sample:
284
+ std = torch.exp(0.5 * logvar)
285
+ return mean + std * torch.randn_like(mean)
286
+ else:
287
+ return mean
288
+
289
+
290
+ class AutoEncoder(nn.Module):
291
+ def __init__(self, params: AutoEncoderParams):
292
+ super().__init__()
293
+ self.encoder = Encoder(
294
+ resolution=params.resolution,
295
+ in_channels=params.in_channels,
296
+ ch=params.ch,
297
+ ch_mult=params.ch_mult,
298
+ num_res_blocks=params.num_res_blocks,
299
+ z_channels=params.z_channels,
300
+ )
301
+ self.decoder = Decoder(
302
+ resolution=params.resolution,
303
+ in_channels=params.in_channels,
304
+ ch=params.ch,
305
+ out_ch=params.out_ch,
306
+ ch_mult=params.ch_mult,
307
+ num_res_blocks=params.num_res_blocks,
308
+ z_channels=params.z_channels,
309
+ )
310
+ self.reg = DiagonalGaussian()
311
+
312
+ self.scale_factor = params.scale_factor
313
+ self.shift_factor = params.shift_factor
314
+
315
+ def encode(self, x: Tensor) -> Tensor:
316
+ z = self.reg(self.encoder(x))
317
+ z = self.scale_factor * (z - self.shift_factor)
318
+ return z
319
+
320
+ def decode(self, z: Tensor) -> Tensor:
321
+ z = z / self.scale_factor + self.shift_factor
322
+ return self.decoder(z)
323
+
324
+ def forward(self, x: Tensor) -> Tensor:
325
+ return self.decode(self.encode(x))
326
+
327
+
328
+ def print_load_warning(missing: list[str], unexpected: list[str]) -> None:
329
+ if len(missing) > 0 and len(unexpected) > 0:
330
+ print(f"Got {len(missing)} missing keys:\n\t" + "\n\t".join(missing))
331
+ print("\n" + "-" * 79 + "\n")
332
+ print(f"Got {len(unexpected)} unexpected keys:\n\t" + "\n\t".join(unexpected))
333
+ elif len(missing) > 0:
334
+ print(f"Got {len(missing)} missing keys:\n\t" + "\n\t".join(missing))
335
+ elif len(unexpected) > 0:
336
+ print(f"Got {len(unexpected)} unexpected keys:\n\t" + "\n\t".join(unexpected))
337
+
338
+
339
+ def load_ae(local_path: str) -> AutoEncoder:
340
+ ae_params = AutoEncoderParams(
341
+ resolution=256,
342
+ in_channels=3,
343
+ downsample=8,
344
+ ch=128,
345
+ out_ch=3,
346
+ ch_mult=[1, 2, 4, 4],
347
+ num_res_blocks=2,
348
+ z_channels=16,
349
+ scale_factor=0.3611,
350
+ shift_factor=0.1159,
351
+ )
352
+
353
+ # Loading the autoencoder
354
+ ae = AutoEncoder(ae_params)
355
+
356
+ if local_path is not None:
357
+ sd = load_sft(local_path)
358
+ missing, unexpected = ae.load_state_dict(sd, strict=False, assign=True)
359
+ print_load_warning(missing, unexpected)
360
+ return ae, ae_params
vqa_inference.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal VQA / text-understanding inference for RxBrain (UnifiedMoT).
2
+
3
+ Pure autoregressive text decoding: (image(s) + question) -> answer text.
4
+ No VAE / flow-matching needed. Reuses the SAME prompt construction
5
+ (`build_conditioned_sequence`) and KV-cached decode plumbing the interleaved
6
+ planner uses for its text step, but decodes all the way to EOS instead of
7
+ stopping at the <Image> modality-transition token.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ from typing import List
13
+
14
+ import torch
15
+ from transformers.models.hunyuan_vl_mot import HunYuanVLMoTProcessor
16
+ from transformers.cache_utils import DynamicCache
17
+
18
+ from model import UnifiedMoTForConditionalGeneration, maybe_init_generation_path
19
+ from interleave_inference import build_conditioned_sequence, INPUT_IMAGE_PLACEHOLDER_IDS
20
+
21
+
22
+ @torch.no_grad()
23
+ def answer(model, processor, image_paths: List[str], question: str,
24
+ device, dtype, max_new_tokens: int = 256) -> str:
25
+ cfg = model.config
26
+ eos = cfg.eos_token_id
27
+ inner = model.model
28
+
29
+ # No generation task-instruction: this is understanding, not image gen.
30
+ prompt_ids, proc = build_conditioned_sequence(processor, image_paths, question)
31
+
32
+ pixel_values = proc.get("pixel_values")
33
+ image_grid_thw = proc.get("image_grid_thw")
34
+ if pixel_values is not None:
35
+ pixel_values = pixel_values.to(device=device, dtype=dtype)
36
+ if image_grid_thw is not None:
37
+ image_grid_thw = image_grid_thw.to(device=device)
38
+
39
+ def _dmask(ids):
40
+ n = len(ids)
41
+ inp = torch.tensor(ids, dtype=torch.long, device=device).unsqueeze(0)
42
+ mod = torch.zeros(1, n, dtype=torch.long, device=device)
43
+ iim = torch.zeros(1, n, dtype=torch.bool, device=device)
44
+ for pid in INPUT_IMAGE_PLACEHOLDER_IDS:
45
+ m = inp[0] == pid
46
+ mod[0, m] = 1
47
+ iim[0, m] = True
48
+ return inp, mod, iim
49
+
50
+ _empty_g = torch.zeros((0, 2), dtype=torch.int32, device=device)
51
+ pkv = DynamicCache()
52
+ prompt_len = len(prompt_ids)
53
+ inp, mod, iim = _dmask(list(prompt_ids))
54
+ out = inner(input_ids=inp, position_ids=torch.arange(prompt_len, device=device).unsqueeze(0),
55
+ pixel_values=pixel_values, image_grid_thw=image_grid_thw,
56
+ cu_seqlens=torch.tensor([0, prompt_len], dtype=torch.int32, device=device),
57
+ sample_ids=torch.zeros(1, prompt_len, dtype=torch.int32, device=device),
58
+ modality_mask=mod, input_image_mask=iim,
59
+ flow_embeds=None, flow_positions=None, g_seqlens=_empty_g,
60
+ use_cache=True, past_key_values=pkv,
61
+ cache_position=torch.arange(prompt_len, device=device))
62
+ pkv = out.past_key_values
63
+ nxt = int(out.logits[0, -1].argmax().item())
64
+ cur = prompt_len
65
+ text_ids = []
66
+ for _ in range(max_new_tokens):
67
+ if nxt == eos:
68
+ break
69
+ text_ids.append(nxt)
70
+ out = inner(input_ids=torch.tensor([[nxt]], dtype=torch.long, device=device),
71
+ position_ids=torch.tensor([[cur]], device=device),
72
+ pixel_values=None, image_grid_thw=None,
73
+ cu_seqlens=torch.tensor([0, cur + 1], dtype=torch.int32, device=device),
74
+ sample_ids=torch.zeros(1, 1, dtype=torch.int32, device=device),
75
+ modality_mask=torch.zeros(1, 1, dtype=torch.long, device=device),
76
+ input_image_mask=torch.zeros(1, 1, dtype=torch.bool, device=device),
77
+ flow_embeds=None, flow_positions=None, g_seqlens=_empty_g,
78
+ use_cache=True, past_key_values=pkv,
79
+ cache_position=torch.tensor([cur], device=device))
80
+ pkv = out.past_key_values
81
+ nxt = int(out.logits[0, -1].argmax().item())
82
+ cur += 1
83
+ return processor.tokenizer.decode(text_ids, skip_special_tokens=True)
84
+
85
+
86
+ def main():
87
+ ap = argparse.ArgumentParser()
88
+ ap.add_argument("--ckpt", required=True)
89
+ ap.add_argument("--images", nargs="+", required=True)
90
+ ap.add_argument("--question", required=True)
91
+ ap.add_argument("--max_new_tokens", type=int, default=256)
92
+ ap.add_argument("--dtype", choices=["bfloat16", "float16", "float32"], default="bfloat16")
93
+ args = ap.parse_args()
94
+
95
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
96
+ dtype = getattr(torch, args.dtype)
97
+
98
+ print(f"Loading processor from {args.ckpt}...")
99
+ processor = HunYuanVLMoTProcessor.from_pretrained(args.ckpt, trust_remote_code=True)
100
+ print(f"Loading model from {args.ckpt}...")
101
+ model = UnifiedMoTForConditionalGeneration.from_pretrained(args.ckpt, dtype=dtype)
102
+ maybe_init_generation_path(model, model_load_path=args.ckpt)
103
+ model.to(device)
104
+ model.eval()
105
+
106
+ print(f"\nIMAGES: {args.images}")
107
+ print(f"Q: {args.question}")
108
+ ans = answer(model, processor, args.images, args.question, device, dtype, args.max_new_tokens)
109
+ print(f"A: {ans}")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()