diff --git a/tests/assets/hunyuan_image3/hunyuan_image3_instruct_cot_ref.txt b/tests/assets/hunyuan_image3/hunyuan_image3_instruct_cot_ref.txt
new file mode 100644
index 000000000..fb4005138
--- /dev/null
+++ b/tests/assets/hunyuan_image3/hunyuan_image3_instruct_cot_ref.txt
@@ -0,0 +1 @@
+首先,我分析所有输入图像:图像1是一个圆形的logo,设计现代且抽象。它由不同色调的蓝色(深蓝、中蓝、浅蓝)和白色构成,这些色块以流畅的曲线相互交织,形成一个动态的、类似旋涡或波浪的图案。整个logo是扁平化的矢量图形,背景为纯黑色。图像2展示了四个并排摆放的卡通动物造型冰箱贴,这幅图像以产品摄影的精致风格,呈现了一枚根据`image_1`标志定制的圆形珐琅冰箱贴。最终图像使用`image_2`的分辨率。冰箱贴居中放置在`image_2`的灰色织物背景上,其设计完美复刻了`image_1`中由深蓝、中蓝、浅蓝和白色构成的动态旋涡图案。整个冰箱贴被赋予了`image_2`中冰箱贴特有的高级质感:表面覆盖着一层光滑如镜的珐琅釉面,反射出柔和而清晰的高光;图案的每一个色块边缘都由纤细的抛光金属边框精确勾勒,增强了立体感。柔和的顶光在冰箱贴的弧形边缘上形成平滑的过渡,并在其下方投下淡淡的、轮廓模糊的阴影,使其与织物背景无缝融合,营造出一种真实、静谧的视觉效果。最终图像完整保留了`image_1`中标志的全部设计元素。这包括其完美的圆形轮廓,以及内部由深蓝、中蓝、浅蓝和白色组成的精确旋涡状图案布局、形状和色彩关系。最终图像的分辨率、背景和材质均来自`image_2`。背景中灰色织物的纹理和质感被完整保留。冰箱贴的材质被完美重构,精确复刻了`image_2`中冰箱贴所展示的光滑珐琅质感、抛光金属边框的视觉效果,以及整体柔和、均匀的布光环境和由此产生的自然阴影。[
]{3600}
diff --git a/tests/assets/hunyuan_image3/hunyuan_image_distill_cot_ref.txt b/tests/assets/hunyuan_image3/hunyuan_image_distill_cot_ref.txt
new file mode 100644
index 000000000..3cc04fa5c
--- /dev/null
+++ b/tests/assets/hunyuan_image3/hunyuan_image_distill_cot_ref.txt
@@ -0,0 +1 @@
+首先,我分析所有输入图像:图像1是一个圆形logo,背景为黑色,主体由深蓝、中蓝、浅蓝和白色组成的抽象曲线构成,整体呈现出一种动态的、类似旋涡的视觉效果。图像2展示了四个并排摆放在灰色织物表面上的冰箱贴。这些冰箱贴的造型分别是长颈鹿、小鸟、蜜蜂和海豚,其关键特征是材质:它们具有光滑、高反光的珐琅质感,边缘有明显的银色金属包边,呈现出立体感。用户的指令是“基于图一的logo,参考图二中冰箱贴的材质,制作一个新的冰箱贴”。这个指令要求将一个二维的平面设计(logo)转化为一个具有特定三维物理属性(冰箱贴材质)的实体物品。核心在于保留logo的设计精髓,同时精确复刻参考图像中冰箱贴的材质和工艺感。为了构建最终的答案图像,我会将图一的圆形logo作为新冰箱贴的主体图案。这个圆形冰箱贴将拥有与图二冰箱贴一致的厚度和立体感。其表面会覆盖一层高光泽的透明釉质,完美再现珐琅的质感。logo内部的蓝色和白色区域将保持其原有的色彩,但会因为表面的光泽而出现明亮的高光反射。整个圆形的边缘会包裹一圈纤细而有光泽的银色金属边框,这不仅增加了产品的精致感,也与图二中的金属包边工艺相呼应。最后,将这个新制作的圆形冰箱贴放置在图二所示的灰色织物背景上,确保其光照、阴影和反射效果与周围环境和其他冰箱贴完全一致,使其看起来就像是和它们一起被拍摄的,从而创造出一个逼真且风格统一的视觉效果。这幅图像以产品摄影的精准风格,展示了一枚根据`image_1`的logo设计定制的圆形冰箱贴。最终图像使用`image_2`的分辨率。该冰箱贴居中放置于`image_2`的灰色织物背景之上,其设计完美复刻了`image_1`中由深蓝、中蓝、浅蓝及白色构成的动态旋涡状抽象图案。整个冰箱贴被赋予了与`image_2`中冰箱贴完全一致的高级珐琅质感,表面覆盖着一层晶莹剔透的高光釉面,在柔和的顶光照射下,呈现出清晰而温润的高光反射。一圈纤薄而精致的银色金属边框紧密包裹着圆形主体,其金属光泽与釉面的质感形成精妙对比。冰箱贴在织物背景上投下了一层极其自然的柔和阴影,证明了其与环境光影的无缝融合,整体视觉效果真实可信,宛如一件精心制作的实体工艺品。最终图像完整保留了`image_1`中logo的全部核心设计元素。这包括其完美的圆形轮廓,以及内部由深蓝、中蓝、浅蓝和白色四种颜色构成的抽象旋涡图案的精确形态、色彩和布局。最终图像的分辨率、背景和材质风格完全来自`image_2`。这包括其作为背景的灰色织物表面的独特纹理,以及应用于新冰箱贴上的、与`image_2`中冰箱贴完全相同的珐琅材质(高光釉面质感)和银色金属边框的物理属性。画面的整体光照环境,包括柔和的光源方向和质感,也与`image_2`保持一致。[
]{3600}
diff --git a/tests/assets/hunyuan_image3/hunyuan_image_distill_ref.png b/tests/assets/hunyuan_image3/hunyuan_image_distill_ref.png
new file mode 100644
index 000000000..a352826c0
Binary files /dev/null and b/tests/assets/hunyuan_image3/hunyuan_image_distill_ref.png differ
diff --git a/tests/assets/hunyuan_image3/hunyuan_image_instruct_ref.png b/tests/assets/hunyuan_image3/hunyuan_image_instruct_ref.png
new file mode 100644
index 000000000..f3787ef0c
Binary files /dev/null and b/tests/assets/hunyuan_image3/hunyuan_image_instruct_ref.png differ
diff --git a/tests/diffusion/models/hunyuan_image3/test_diffusion_kv_request.py b/tests/diffusion/models/hunyuan_image3/test_diffusion_kv_request.py
index 3876762fa..2a17cd691 100644
--- a/tests/diffusion/models/hunyuan_image3/test_diffusion_kv_request.py
+++ b/tests/diffusion/models/hunyuan_image3/test_diffusion_kv_request.py
@@ -24,6 +24,7 @@ from vllm_omni.diffusion.models.hunyuan_image3.request_layout import (
build_hunyuan_diffusion_kv_requests,
extract_hunyuan_prompt_inputs,
hunyuan_num_image_tokens,
+ hunyuan_num_special_tokens,
normalize_hunyuan_cot_text,
prepare_hunyuan_layout,
)
@@ -114,7 +115,7 @@ class _FakeImageProcessor:
self.image_sizes: list[tuple[int, int]] = []
self.vision_encoder_processor = SimpleNamespace(patch_size=1)
- def build_image_info(self, image_size):
+ def build_image_info(self, image_size, **kwargs):
self.image_sizes.append(image_size)
return ImageInfo(
image_type="gen_image",
@@ -125,6 +126,7 @@ class _FakeImageProcessor:
image_token_length=self.image_token_length,
base_size=1024,
ratio_index=0,
+ **kwargs,
)
@@ -166,6 +168,29 @@ def _request(
)
+def test_distilled_layout_uses_embedded_cfg_and_meanflow_tokens() -> None:
+ tokenizer, image_processor = _components([12])
+ request = _request(guidance_scale=2.5)
+
+ prepared_layout = prepare_hunyuan_layout(
+ request,
+ tokenizer_wrapper=tokenizer,
+ image_processor=image_processor,
+ generation_config=SimpleNamespace(sequence_template="instruct", drop_think=False),
+ image_base_size=1024,
+ cfg_distilled=True,
+ use_meanflow=True,
+ )
+
+ image_info = prepared_layout.generated_image_info
+ assert image_info.add_guidance_token
+ assert image_info.add_timestep_r_token
+ assert tokenizer.calls[0]["cfg_factor"] == 1
+ assert hunyuan_num_special_tokens(image_info) == 3
+ assert hunyuan_num_image_tokens(image_info) == 19
+ assert len(build_hunyuan_diffusion_kv_requests(request, prepared_layout)) == 1
+
+
def test_builds_kv_request_lengths_without_model_execution() -> None:
tokenizer, image_processor = _components([12])
request = _request(guidance_scale=1.0)
@@ -385,9 +410,13 @@ def test_prepared_model_inputs_match_local_tokenization(
torch.testing.assert_close(prepared_mask, local_mask)
assert prepared_inputs["full_attn_spans"] == local_inputs["full_attn_spans"]
- num_image_tokens = hunyuan_num_image_tokens(prepared_layout.generated_image_info)
- local_inputs.update(attention_mask=local_mask, num_image_tokens=num_image_tokens)
- prepared_inputs.update(attention_mask=prepared_mask, num_image_tokens=num_image_tokens)
+ image_info = prepared_layout.generated_image_info
+ generation_token_counts = {
+ "num_image_tokens": hunyuan_num_image_tokens(image_info),
+ "num_special_tokens": hunyuan_num_special_tokens(image_info),
+ }
+ local_inputs.update(attention_mask=local_mask, **generation_token_counts)
+ prepared_inputs.update(attention_mask=prepared_mask, **generation_token_counts)
local_step_inputs = pipeline._update_model_kwargs_for_generation(ModelOutput(), local_inputs)
prepared_step_inputs = pipeline._update_model_kwargs_for_generation(ModelOutput(), prepared_inputs)
diff --git a/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_distil_inputs.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_distil_inputs.py
new file mode 100644
index 000000000..7df82b767
--- /dev/null
+++ b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_distil_inputs.py
@@ -0,0 +1,294 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Unit tests for HunyuanImage3 prepare_inputs_for_generation to ensure
+distilled model parameters (guidance, timesteps_r) are properly passed."""
+
+import pytest
+import torch
+
+from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import (
+ HunyuanImage3Text2ImagePipeline,
+)
+
+pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
+
+
+class MockHunyuanImage3Pipeline:
+ """Minimal mock that replicates prepare_inputs_for_generation method."""
+
+ def __init__(self):
+ # Mock config for distilled models
+ self.config = type("Config", (), {})()
+ self.hf_config = type("HFConfig", (), {"cfg_distilled": True, "use_meanflow": True})()
+
+ # Bind the real method from the pipeline class
+ from vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 import (
+ HunyuanImage3Pipeline as _Real,
+ )
+
+ prepare_inputs_for_generation = _Real.prepare_inputs_for_generation
+ _update_model_kwargs_for_generation = _Real._update_model_kwargs_for_generation
+
+
+class TestPrepareInputsForGenerationDistilled:
+ """Tests that distilled model parameters are passed through
+ prepare_inputs_for_generation."""
+
+ def setup_method(self):
+ self.pipeline = MockHunyuanImage3Pipeline()
+
+ def _create_mock_kwargs(self, include_distilled_params: bool = True) -> dict:
+ """Create mock kwargs for prepare_inputs_for_generation."""
+ kwargs = {
+ "position_ids": torch.tensor([[0, 1, 2]]),
+ "custom_pos_emb": (torch.tensor([[0.0]]), torch.tensor([[1.0]])),
+ "mode": "gen_image",
+ "images": torch.randn(1, 4, 64, 64),
+ "image_mask": torch.ones(1, 64, 64),
+ "timestep": torch.tensor([0.5]),
+ "gen_timestep_scatter_index": torch.tensor([[5]]),
+ "cond_vae_images": None,
+ "cond_timestep": None,
+ "cond_vae_image_mask": None,
+ "cond_vit_images": None,
+ "cond_vit_image_mask": None,
+ "vit_kwargs": None,
+ "cond_timestep_scatter_index": None,
+ "query_lens": [10],
+ "seq_lens": [100],
+ "num_image_tokens": 4096,
+ "num_special_tokens": 3,
+ "ar_kv_reuse_len": 0,
+ "full_attn_spans": None,
+ "use_cache": True,
+ }
+ if include_distilled_params:
+ # CFG distilled model parameters
+ kwargs["guidance"] = torch.tensor([2500.0])
+ kwargs["guidance_scatter_index"] = torch.tensor([[4]])
+ # MeanFlow distilled model parameters
+ kwargs["timesteps_r"] = torch.tensor([0.3])
+ kwargs["timesteps_r_scatter_index"] = torch.tensor([[3]])
+ else:
+ kwargs["guidance"] = None
+ kwargs["guidance_scatter_index"] = None
+ kwargs["timesteps_r"] = None
+ kwargs["timesteps_r_scatter_index"] = None
+ return kwargs
+
+ def test_guidance_passed_to_model_inputs(self):
+ """Test that guidance is passed through prepare_inputs_for_generation."""
+ kwargs = self._create_mock_kwargs(include_distilled_params=True)
+ input_ids = torch.tensor([[1, 2, 3]])
+ attention_mask = torch.ones(1, 3)
+
+ model_inputs = self.pipeline.prepare_inputs_for_generation(
+ input_ids=input_ids,
+ past_key_values=None,
+ attention_mask=attention_mask,
+ inputs_embeds=None,
+ tokenizer_output=None,
+ batch_gen_image_info=None,
+ generator=None,
+ **kwargs,
+ )
+
+ assert "guidance" in model_inputs, "guidance should be in model_inputs"
+ assert model_inputs["guidance"] is not None, "guidance should not be None"
+ assert torch.equal(model_inputs["guidance"], kwargs["guidance"])
+
+ def test_guidance_scatter_index_passed_to_model_inputs(self):
+ """Test that guidance_scatter_index is passed through."""
+ kwargs = self._create_mock_kwargs(include_distilled_params=True)
+ input_ids = torch.tensor([[1, 2, 3]])
+ attention_mask = torch.ones(1, 3)
+
+ model_inputs = self.pipeline.prepare_inputs_for_generation(
+ input_ids=input_ids,
+ past_key_values=None,
+ attention_mask=attention_mask,
+ inputs_embeds=None,
+ tokenizer_output=None,
+ batch_gen_image_info=None,
+ generator=None,
+ **kwargs,
+ )
+
+ assert "guidance_scatter_index" in model_inputs
+ assert model_inputs["guidance_scatter_index"] is not None
+ assert torch.equal(model_inputs["guidance_scatter_index"], kwargs["guidance_scatter_index"])
+
+ def test_timesteps_r_passed_to_model_inputs(self):
+ """Test that timesteps_r is passed through for MeanFlow models."""
+ kwargs = self._create_mock_kwargs(include_distilled_params=True)
+ input_ids = torch.tensor([[1, 2, 3]])
+ attention_mask = torch.ones(1, 3)
+
+ model_inputs = self.pipeline.prepare_inputs_for_generation(
+ input_ids=input_ids,
+ past_key_values=None,
+ attention_mask=attention_mask,
+ inputs_embeds=None,
+ tokenizer_output=None,
+ batch_gen_image_info=None,
+ generator=None,
+ **kwargs,
+ )
+
+ assert "timesteps_r" in model_inputs, "timesteps_r should be in model_inputs"
+ assert model_inputs["timesteps_r"] is not None
+ assert torch.equal(model_inputs["timesteps_r"], kwargs["timesteps_r"])
+
+ def test_timesteps_r_scatter_index_passed_to_model_inputs(self):
+ """Test that timesteps_r_scatter_index is passed through."""
+ kwargs = self._create_mock_kwargs(include_distilled_params=True)
+ input_ids = torch.tensor([[1, 2, 3]])
+ attention_mask = torch.ones(1, 3)
+
+ model_inputs = self.pipeline.prepare_inputs_for_generation(
+ input_ids=input_ids,
+ past_key_values=None,
+ attention_mask=attention_mask,
+ inputs_embeds=None,
+ tokenizer_output=None,
+ batch_gen_image_info=None,
+ generator=None,
+ **kwargs,
+ )
+
+ assert "timesteps_r_scatter_index" in model_inputs
+ assert model_inputs["timesteps_r_scatter_index"] is not None
+ assert torch.equal(model_inputs["timesteps_r_scatter_index"], kwargs["timesteps_r_scatter_index"])
+
+ def test_num_special_tokens_passed_to_model_inputs(self):
+ """Test that num_special_tokens is passed through."""
+ kwargs = self._create_mock_kwargs(include_distilled_params=True)
+ input_ids = torch.tensor([[1, 2, 3]])
+ attention_mask = torch.ones(1, 3)
+
+ model_inputs = self.pipeline.prepare_inputs_for_generation(
+ input_ids=input_ids,
+ past_key_values=None,
+ attention_mask=attention_mask,
+ inputs_embeds=None,
+ tokenizer_output=None,
+ batch_gen_image_info=None,
+ generator=None,
+ **kwargs,
+ )
+
+ assert "num_special_tokens" in model_inputs
+ assert model_inputs["num_special_tokens"] == kwargs["num_special_tokens"]
+
+ def test_decode_update_keeps_all_special_token_positions(self):
+ """Test decode step uses timestep, guidance, and timestep_r positions."""
+ model_kwargs = self._create_mock_kwargs(include_distilled_params=True)
+ model_kwargs.update(
+ {
+ "tokenizer_output": object(),
+ "attention_mask": torch.ones(1, 1, 7, 7, dtype=torch.bool),
+ "image_mask": torch.tensor([[False, False, False, False, True, True, True]]),
+ "gen_timestep_scatter_index": torch.tensor([[1]]),
+ "guidance_scatter_index": torch.tensor([[2]]),
+ "timesteps_r_scatter_index": torch.tensor([[3]]),
+ }
+ )
+
+ updated = self.pipeline._update_model_kwargs_for_generation({}, model_kwargs)
+
+ assert torch.equal(updated["position_ids"], torch.tensor([[1, 2, 3, 4, 5, 6]]))
+ assert updated["num_special_tokens"] == 3
+ assert updated["guidance_scatter_index"] is model_kwargs["guidance_scatter_index"]
+ assert updated["timesteps_r_scatter_index"] is model_kwargs["timesteps_r_scatter_index"]
+ assert updated["attention_mask"].shape == (1, 1, 6, 7)
+
+ def test_distilled_params_none_when_not_provided(self):
+ """Test that distilled params are None when not provided."""
+ kwargs = self._create_mock_kwargs(include_distilled_params=False)
+ input_ids = torch.tensor([[1, 2, 3]])
+ attention_mask = torch.ones(1, 3)
+
+ model_inputs = self.pipeline.prepare_inputs_for_generation(
+ input_ids=input_ids,
+ past_key_values=None,
+ attention_mask=attention_mask,
+ inputs_embeds=None,
+ tokenizer_output=None,
+ batch_gen_image_info=None,
+ generator=None,
+ **kwargs,
+ )
+
+ # All distilled params should be present but None
+ assert "guidance" in model_inputs
+ assert model_inputs["guidance"] is None
+ assert "guidance_scatter_index" in model_inputs
+ assert model_inputs["guidance_scatter_index"] is None
+ assert "timesteps_r" in model_inputs
+ assert model_inputs["timesteps_r"] is None
+ assert "timesteps_r_scatter_index" in model_inputs
+ assert model_inputs["timesteps_r_scatter_index"] is None
+
+ def test_all_distilled_keys_present_in_model_inputs(self):
+ """Test that all 4 distilled keys are always present (even if None)."""
+ kwargs = self._create_mock_kwargs(include_distilled_params=False)
+ input_ids = torch.tensor([[1, 2, 3]])
+ attention_mask = torch.ones(1, 3)
+
+ model_inputs = self.pipeline.prepare_inputs_for_generation(
+ input_ids=input_ids,
+ past_key_values=None,
+ attention_mask=attention_mask,
+ inputs_embeds=None,
+ tokenizer_output=None,
+ batch_gen_image_info=None,
+ generator=None,
+ **kwargs,
+ )
+
+ required_distilled_keys = [
+ "guidance",
+ "guidance_scatter_index",
+ "timesteps_r",
+ "timesteps_r_scatter_index",
+ ]
+ for key in required_distilled_keys:
+ assert key in model_inputs, f"{key} must be in model_inputs dict"
+
+
+def test_ar_kv_reuse_rebases_distilled_scatter_indices():
+ pipeline = object.__new__(HunyuanImage3Text2ImagePipeline)
+ model_kwargs = {
+ "query_lens": [8],
+ "attention_mask": torch.ones(1, 1, 8, 12, dtype=torch.bool),
+ "position_ids": torch.arange(8).unsqueeze(0),
+ "image_mask": torch.ones(1, 8, dtype=torch.bool),
+ "gen_timestep_scatter_index": torch.tensor([[5]]),
+ "guidance_scatter_index": torch.tensor([[6]]),
+ "timesteps_r_scatter_index": torch.tensor([[7]]),
+ }
+
+ truncated = pipeline._truncate_reused_prefix(
+ input_ids=torch.arange(8).unsqueeze(0),
+ model_kwargs=model_kwargs,
+ positive_reuse_len=3,
+ )
+
+ assert truncated.tolist() == [[3, 4, 5, 6, 7]]
+ assert model_kwargs["gen_timestep_scatter_index"].tolist() == [[2]]
+ assert model_kwargs["guidance_scatter_index"].tolist() == [[3]]
+ assert model_kwargs["timesteps_r_scatter_index"].tolist() == [[4]]
+
+
+def test_cfg_parallel_splits_meanflow_scatter_indices():
+ model_kwargs = {
+ "timesteps_r_scatter_index": torch.tensor([[1], [2], [3], [4]]),
+ }
+
+ HunyuanImage3Text2ImagePipeline._split_model_kwargs_for_cfg_parallel(
+ model_kwargs,
+ batch_size=2,
+ cfg_rank=1,
+ )
+
+ assert model_kwargs["timesteps_r_scatter_index"].tolist() == [[3], [4]]
diff --git a/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_step_execution.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_step_execution.py
index b62fe950e..b840da6c0 100644
--- a/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_step_execution.py
+++ b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_step_execution.py
@@ -38,6 +38,7 @@ def _pipeline():
cache_backend=None,
diffusion_kv_cache_skip_step_indices=None,
)
+ pipeline.hf_config = SimpleNamespace(cfg_distilled=False, use_meanflow=False)
pipeline._pipeline = SimpleNamespace()
return pipeline
@@ -451,3 +452,44 @@ def test_denoise_step_uses_input_batch_group_order_and_splits_back(monkeypatch):
assert states[1].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (2, 1, 2, 6)
assert states[0].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(2, 4)], [(2, 4)]]
assert states[1].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(4, 6)], [(4, 6)]]
+
+
+def test_distilled_step_supplies_guidance_and_meanflow_timestep(monkeypatch):
+ pipeline = _pipeline()
+ pipeline.hf_config = SimpleNamespace(cfg_distilled=True, use_meanflow=True)
+ monkeypatch.setattr(HunyuanImage3Pipeline, "device", property(lambda self: torch.device("cpu")))
+ state = _state("distilled", 1)
+ state.latents = torch.zeros(1, 1)
+ state.extra[_STEP_GUIDANCE_SCALE] = 2.5
+ state.extra[_STEP_MODEL_KWARGS].update(
+ {
+ "attention_mask": torch.ones(1, 1, 2, 4, dtype=torch.bool),
+ "full_attn_spans": [[(2, 4)]],
+ }
+ )
+ state.extra[_STEP_PROMPT_KV] = [
+ {
+ "key": torch.zeros(1, 2, 1, 1),
+ "value": torch.zeros(1, 2, 1, 1),
+ "lens": torch.tensor([2]),
+ }
+ ]
+ state.scheduler = SimpleNamespace(get_timestep_r=lambda _timestep: torch.tensor(0.25))
+ captured = {}
+
+ pipeline._restore_prompt_kv_cache = lambda *_args: None
+
+ def fake_prepare_inputs(input_ids, images, timestep, **model_kwargs):
+ del input_ids, images, timestep
+ captured.update(model_kwargs)
+ return {"model_kwargs": model_kwargs}
+
+ pipeline.prepare_inputs_for_generation = fake_prepare_inputs
+ pipeline.forward_call = lambda **_kwargs: {"diffusion_prediction": torch.tensor([[1.0]])}
+ pipeline._update_model_kwargs_for_generation = lambda _output, model_kwargs: model_kwargs
+
+ output = pipeline.denoise_step(InputBatch.make_batch([state]))
+
+ torch.testing.assert_close(output, torch.tensor([[1.0]]))
+ torch.testing.assert_close(captured["guidance"], torch.tensor([2500.0], dtype=torch.bfloat16))
+ torch.testing.assert_close(captured["timesteps_r"], torch.tensor([0.25]))
diff --git a/tests/e2e/accuracy/test_hunyuan_image3.py b/tests/e2e/accuracy/test_hunyuan_image3.py
index eed0ef6e8..3e37f22b6 100644
--- a/tests/e2e/accuracy/test_hunyuan_image3.py
+++ b/tests/e2e/accuracy/test_hunyuan_image3.py
@@ -94,15 +94,28 @@ def _default_ar_dit_devices() -> tuple[str, str]:
AR_DEVICES, DIT_DEVICES = _default_ar_dit_devices()
-MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct"
-HUNYUAN_IMAGE_REF_PATH = get_asset_path("hunyuan/hunyuan_image_ref.png")
-NUM_INFERENCE_STEPS = 50
+
+# Model configuration based on HUNYUAN_MODEL_PATH
+DISTIL_MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct-Distil"
+INSTRUCT_MODEL_NAME = "tencent/HunyuanImage-3.0-Instruct"
+MODEL_PATH = os.environ.get("HUNYUAN_MODEL_PATH", DISTIL_MODEL_NAME)
+
+# Determine model type and set parameters accordingly
+IS_DISTIL = "distil" in MODEL_PATH.lower()
+MODEL_NAME = DISTIL_MODEL_NAME if IS_DISTIL else INSTRUCT_MODEL_NAME
+NUM_INFERENCE_STEPS = 8 if IS_DISTIL else 50
GUIDANCE_SCALE = 2.5
+# Asset paths based on model type
+ASSET_SUBDIR = "hunyuan_image3"
+REF_IMAGE_NAME = "hunyuan_image_distill_ref.png" if IS_DISTIL else "hunyuan_image_instruct_ref.png"
+COT_REF_NAME = "hunyuan_image_distill_cot_ref.txt" if IS_DISTIL else "hunyuan_image3_instruct_cot_ref.txt"
+HUNYUAN_IMAGE_REF_PATH = get_asset_path(f"{ASSET_SUBDIR}/{REF_IMAGE_NAME}")
+HUNYUAN_COT_REF_PATH = get_asset_path(f"{ASSET_SUBDIR}/{COT_REF_NAME}")
+
# ============================================================================
# Constants
# ============================================================================
-MODEL_PATH = os.environ.get("HUNYUAN_MODEL_PATH", MODEL_NAME)
# Test input
PROMPT = "基于图一的logo,参考图二中冰箱贴的材质,制作一个新的冰箱贴"
TEST_IMAGE_URLS = [
@@ -113,23 +126,52 @@ SEED = 42
AR_TP_SIZE = len(AR_DEVICES.split(","))
DIT_TP_SIZE = len(DIT_DEVICES.split(","))
-# Precision thresholds
+# Precision thresholds organized by model variant and test type
THRESHOLDS = {
- # AR text comparison
- "text_prefix_match": 10, # First 10 characters must match exactly
- "cot_semantic_sim": 0.9, # Full CoT semantic similarity
- # Image comparison
- "clip_score": 90, # CLIP image semantic similarity
- "ssim": 0.26, # Structural similarity
- "psnr": 12.5, # Peak signal-to-noise ratio (dB)
+ "instruct": {
+ # AR text comparison
+ "text_prefix_match": 10, # First 10 characters must match exactly
+ "cot_semantic_sim": 0.9, # Full CoT semantic similarity
+ # Image comparison
+ "clip_score": 90, # CLIP image semantic similarity
+ "ssim": 0.26, # Structural similarity
+ "psnr": 12.5, # Peak signal-to-noise ratio (dB)
+ },
+ # The 8-step distilled model has more structural variance across GPU
+ # runtimes; CLIP and PSNR retain the same semantic and pixel safeguards.
+ "distil": {
+ "text_prefix_match": 10,
+ "cot_semantic_sim": 0.9,
+ "clip_score": 90,
+ "ssim": 0.23,
+ "psnr": 12.5,
+ },
+ # Thresholds for quantized DiT accuracy tests (bf16 vs quant comparison)
+ "quant": {
+ "psnr": 10.0,
+ "ssim": 0.20,
+ "clip_score": 20.0, # Minimum CLIP score for quantized output
+ "clip_score_drop": float(
+ os.environ.get("HUNYUAN_IMAGE3_QUANT_CLIP_SCORE_DROP_THRESHOLD", "5.0")
+ ), # Max allowed drop from bf16
+ },
}
+
+# Helper to get thresholds for current model variant
+def _get_thresholds() -> dict:
+ """Return appropriate thresholds based on test context."""
+ return THRESHOLDS["distil" if IS_DISTIL else "instruct"]
+
+
+def _get_quant_thresholds() -> dict:
+ """Return thresholds for quantization accuracy tests."""
+ return THRESHOLDS["quant"]
+
+
+# Quant test configuration
QUANT_PROMPT = "A brown and white dog is running on the grass."
QUANT_HEIGHT, QUANT_WIDTH = 1024, 1024
-QUANT_PSNR_THRESHOLD = 10.0
-QUANT_SSIM_THRESHOLD = 0.20
-QUANT_CLIP_SCORE_THRESHOLD = 20.0
-QUANT_CLIP_SCORE_DROP_THRESHOLD = float(os.environ.get("HUNYUAN_IMAGE3_QUANT_CLIP_SCORE_DROP_THRESHOLD", "5.0"))
QUANT_RUN_ENV = "HUNYUAN_IMAGE3_RUN_QUANT_ACCURACY"
QUANT_BF16_ENV = "HUNYUAN_IMAGE3_BF16_MODEL"
QUANT_FP8_ENV = "HUNYUAN_IMAGE3_FP8_MODEL"
@@ -167,7 +209,6 @@ _DEPLOY_CONFIG = {
"top_p": 1,
"top_k": -1,
"max_tokens": 8192,
- "stop_token_ids": [128025],
"detokenize": True,
"skip_special_tokens": False,
"include_stop_str_in_output": True,
@@ -246,19 +287,6 @@ def _quant_accuracy_cases() -> list[pytest.ParameterSet]:
return params
-# fmt: off
-COT_REF = ("首先,我分析所有输入图像:图像1是一个圆形的logo,设计现代且抽象。它由不同色调的蓝色(深蓝、中蓝、浅蓝)和白色构成,这些色块以流畅的曲线相互交织,形成一个动态的、类似旋涡或波浪的图案。整个logo是扁平化的矢量图形,背景为纯黑色。图像2展示了四个并排摆放的卡通动物造型冰箱贴,"
- "它们被放置在灰色的织物背景上。这些冰箱贴的关键特征是其材质:它们具有光滑、高光的珐琅或烤漆质感,边缘有明显的金属包边,整体呈现出一种立体的、有厚度的实体感。用户的指令是“基于图一的logo,参考图二中冰箱贴的材质,制作一个新的冰箱贴”。这个指令要求将一个二维的平面设计(logo)"
- "转化为一个具有特定物理属性(材质和立体感)的三维物体。核心任务是保留logo的视觉识别性,同时赋予其冰箱贴的实体质感。为了构建答案图像,我会将图一的圆形logo作为基础形状。然后,我会将图二中冰箱贴的材质特性应用到这个logo上。具体来说,logo中的每一个色块(深蓝、中蓝、浅蓝、白色)"
- "都会被渲染成具有高光泽度的珐琅质感,表面会反射出柔和的环境光,形成自然的高光。logo中不同颜色区域之间的分界线,将被处理成纤细的、带有金属光泽的凸起边缘,这既能清晰地勾勒出图案,也符合珐琅工艺品的典型特征。整个冰箱贴会呈现出轻微的厚度和圆润的边缘,使其看起来像一个真实的、可触摸的物体。"
- "最后,将这个制作完成的冰箱贴放置在图二所示的灰色织物背景上,并为其添加一个微妙的、柔和的阴影,以增强其立体感和与背景的融合度,最终呈现出一个精致、逼真的产品展示图。这幅图像以产品摄影的精致风格,呈现了一枚根据`image_1`标志定制的圆形珐琅冰箱贴。最终图像使用`image_2`的分辨率。"
- "冰箱贴居中放置在`image_2`的灰色织物背景上,其设计完美复刻了`image_1`中由深蓝、中蓝、浅蓝和白色构成的动态旋涡图案。整个冰箱贴被赋予了`image_2`中冰箱贴特有的高级质感:表面覆盖着一层光滑如镜的珐琅釉面,反射出柔和而清晰的高光;图案的每一个色块边缘都由纤细的抛光金属边框精确勾勒,增强了立体感。"
- "柔和的顶光在冰箱贴的弧形边缘上形成平滑的过渡,并在其下方投下淡淡的、轮廓模糊的阴影,使其与织物背景无缝融合,营造出一种真实、静谧的视觉效果。最终图像完整保留了`image_1`中标志的全部设计元素。这包括其完美的圆形轮廓,以及内部由深蓝、中蓝、浅蓝和白色组成的精确旋涡状图案布局、形状和色彩关系。"
- "最终图像的分辨率、背景和材质均来自`image_2`。背景中灰色织物的纹理和质感被完整保留。冰箱贴的材质被完美重构,精确复刻了`image_2`中冰箱贴所展示的光滑珐琅质感、抛光金属边框的视觉效果,以及整体柔和、均匀的布光环境和由此产生的自然阴影。"
- "[
]{3600}")
-# fmt: on
-
-
def _make_config(enable_kv_reuse: bool, path: Path) -> None:
config = copy.deepcopy(_DEPLOY_CONFIG)
config["stages"][0]["omni_kv_config"] = {"need_send_cache": enable_kv_reuse}
@@ -432,34 +460,36 @@ def test_image_to_image_alignment_online(accuracy_artifact_root: Path) -> None:
online_cot = online_cot.lstrip("\n")
scorer = SemanticSimilarityScorer()
clip_scorer = CLIPScorer()
- cot_results = scorer.text_similarity(online_cot, COT_REF)
+ cot_ref = HUNYUAN_COT_REF_PATH.read_text(encoding="utf-8")
+ cot_results = scorer.text_similarity(online_cot, cot_ref)
image_ref = Image.open(HUNYUAN_IMAGE_REF_PATH).convert("RGB")
image_clip_score = clip_scorer.image_image_score(online_image, image_ref)
ssim_value, psnr_value = compute_image_ssim_psnr(prediction=online_image, reference=image_ref, compare_mode="RGB")
+ thresholds = _get_thresholds()
table = [
- ["COT similarity to reference", f"{cot_results['cot_semantic_sim']:.4f}", 0.9644],
- ["COT prefix match", f"{cot_results['text_prefix_match_count']:.4f}", 29],
- ["Image-Image similarity", f"{image_clip_score:.4f}", 94.5538],
- ["SSIM", f"{ssim_value:.4f}", 0.242],
- ["PSNR (dB)", f"{psnr_value:.2f}", 14.1],
+ ["COT similarity to reference", f"{cot_results['cot_semantic_sim']:.4f}", thresholds["cot_semantic_sim"]],
+ ["COT prefix match", f"{cot_results['text_prefix_match_count']:.4f}", thresholds["text_prefix_match"]],
+ ["Image-Image similarity", f"{image_clip_score:.4f}", thresholds["clip_score"]],
+ ["SSIM", f"{ssim_value:.4f}", thresholds["ssim"]],
+ ["PSNR (dB)", f"{psnr_value:.2f}", thresholds["psnr"]],
]
- print("[ONLINE] " + tabulate(table, headers=["Metric", "Value", "L20x Reference"], tablefmt="grid"))
+ print("[ONLINE] " + tabulate(table, headers=["Metric", "Value", "Threshold"], tablefmt="grid"))
_assert_cot_structural_markers(online_cot, "ONLINE")
- assert cot_results["cot_semantic_sim"] >= THRESHOLDS["cot_semantic_sim"], (
- f"[ONLINE] COT semantic similarity {cot_results['cot_semantic_sim']:.4f} below threshold {THRESHOLDS['cot_semantic_sim']}"
+ assert cot_results["cot_semantic_sim"] >= thresholds["cot_semantic_sim"], (
+ f"[ONLINE] COT semantic similarity {cot_results['cot_semantic_sim']:.4f} below threshold {thresholds['cot_semantic_sim']}"
)
- assert cot_results["text_prefix_match_count"] >= THRESHOLDS["text_prefix_match"], (
- f"[ONLINE] COT prefix match {cot_results['text_prefix_match_count']} below threshold {THRESHOLDS['text_prefix_match']}"
+ assert cot_results["text_prefix_match_count"] >= thresholds["text_prefix_match"], (
+ f"[ONLINE] COT prefix match {cot_results['text_prefix_match_count']} below threshold {thresholds['text_prefix_match']}"
)
- assert image_clip_score >= THRESHOLDS["clip_score"], (
- f"[ONLINE] Image-Image similarity {image_clip_score:.4f} below threshold {THRESHOLDS['clip_score']}"
+ assert image_clip_score >= thresholds["clip_score"], (
+ f"[ONLINE] Image-Image similarity {image_clip_score:.4f} below threshold {thresholds['clip_score']}"
)
- assert ssim_value >= THRESHOLDS["ssim"], f"[ONLINE] SSIM {ssim_value:.4f} below threshold {THRESHOLDS['ssim']}"
- assert psnr_value >= THRESHOLDS["psnr"], (
- f"[ONLINE] PSNR {psnr_value:.2f} dB below threshold {THRESHOLDS['psnr']} dB"
+ assert ssim_value >= thresholds["ssim"], f"[ONLINE] SSIM {ssim_value:.4f} below threshold {thresholds['ssim']}"
+ assert psnr_value >= thresholds["psnr"], (
+ f"[ONLINE] PSNR {psnr_value:.2f} dB below threshold {thresholds['psnr']} dB"
)
@@ -546,34 +576,36 @@ def test_image_to_image_alignment(accuracy_artifact_root: Path) -> None:
scorer = SemanticSimilarityScorer()
clip_scorer = CLIPScorer()
- cot_results = scorer.text_similarity(omni_cot, COT_REF)
+ cot_ref = HUNYUAN_COT_REF_PATH.read_text(encoding="utf-8")
+ cot_results = scorer.text_similarity(omni_cot, cot_ref)
image_ref = Image.open(HUNYUAN_IMAGE_REF_PATH).convert("RGB")
image_clip_score = clip_scorer.image_image_score(omni_image, image_ref)
ssim_value, psnr_value = compute_image_ssim_psnr(prediction=omni_image, reference=image_ref, compare_mode="RGB")
+ thresholds = _get_thresholds()
table = [
- ["COT similarity to reference", f"{cot_results['cot_semantic_sim']:.4f}", 0.9644],
- ["COT prefix match", f"{cot_results['text_prefix_match_count']:.4f}", 29],
- ["Image-Image similarity", f"{image_clip_score:.4f}", 94.5538],
- ["SSIM", f"{ssim_value:.4f}", 0.242],
- ["PSNR (dB)", f"{psnr_value:.2f}", 14.1],
+ ["COT similarity to reference", f"{cot_results['cot_semantic_sim']:.4f}", thresholds["cot_semantic_sim"]],
+ ["COT prefix match", f"{cot_results['text_prefix_match_count']:.4f}", thresholds["text_prefix_match"]],
+ ["Image-Image similarity", f"{image_clip_score:.4f}", thresholds["clip_score"]],
+ ["SSIM", f"{ssim_value:.4f}", thresholds["ssim"]],
+ ["PSNR (dB)", f"{psnr_value:.2f}", thresholds["psnr"]],
]
- print(tabulate(table, headers=["Metric", "Value", "L20x Reference"], tablefmt="grid"))
+ print(tabulate(table, headers=["Metric", "Value", "Threshold"], tablefmt="grid"))
_assert_cot_structural_markers(omni_cot, "OFFLINE")
- assert cot_results["cot_semantic_sim"] >= THRESHOLDS["cot_semantic_sim"], (
- f"COT semantic similarity {cot_results['cot_semantic_sim']:.4f} is below threshold {THRESHOLDS['cot_semantic_sim']}"
+ assert cot_results["cot_semantic_sim"] >= thresholds["cot_semantic_sim"], (
+ f"COT semantic similarity {cot_results['cot_semantic_sim']:.4f} is below threshold {thresholds['cot_semantic_sim']}"
)
- assert cot_results["text_prefix_match_count"] >= THRESHOLDS["text_prefix_match"], (
- f"COT prefix match count {cot_results['text_prefix_match_count']} is below threshold {THRESHOLDS['text_prefix_match']}"
+ assert cot_results["text_prefix_match_count"] >= thresholds["text_prefix_match"], (
+ f"COT prefix match count {cot_results['text_prefix_match_count']} is below threshold {thresholds['text_prefix_match']}"
)
- assert image_clip_score >= THRESHOLDS["clip_score"], (
- f"Image-Image similarity{image_clip_score:.4f} is below threshold {THRESHOLDS['clip_score']}"
+ assert image_clip_score >= thresholds["clip_score"], (
+ f"Image-Image similarity{image_clip_score:.4f} is below threshold {thresholds['clip_score']}"
)
- assert ssim_value >= THRESHOLDS["ssim"], f"SSIM {ssim_value:.4f} is below threshold {THRESHOLDS['ssim']}"
- assert psnr_value >= THRESHOLDS["psnr"], f"PSNR {psnr_value:.2f} dB is below threshold {THRESHOLDS['psnr']} dB"
+ assert ssim_value >= thresholds["ssim"], f"SSIM {ssim_value:.4f} is below threshold {thresholds['ssim']}"
+ assert psnr_value >= thresholds["psnr"], f"PSNR {psnr_value:.2f} dB is below threshold {thresholds['psnr']} dB"
@pytest.mark.parametrize("case", _quant_accuracy_cases())
@@ -607,12 +639,13 @@ def test_quantized_dit_matches_bf16_accuracy(
prediction=quant_image,
reference=bf16_image,
)
+ quant_thresholds = _get_quant_thresholds()
assert_similarity(
model_name=f"{MODEL_NAME} {case.name} vs bf16",
vllm_image=quant_image,
diffusers_image=bf16_image,
- ssim_threshold=QUANT_SSIM_THRESHOLD,
- psnr_threshold=QUANT_PSNR_THRESHOLD,
+ ssim_threshold=quant_thresholds["ssim"],
+ psnr_threshold=quant_thresholds["psnr"],
width=QUANT_WIDTH,
height=QUANT_HEIGHT,
)
@@ -647,15 +680,15 @@ def test_quantized_dit_matches_bf16_accuracy(
print(f" bf16 model: {bf16_model}")
print(f" quant model: {quant_model}")
print(f" BF16 CLIP score: {bf16_clip_score:.4f}")
- print(f" quant CLIP score: {quant_clip_score:.4f} threshold>={QUANT_CLIP_SCORE_THRESHOLD:.4f}")
- print(f" CLIP score drop: {clip_score_drop:.4f} threshold<={QUANT_CLIP_SCORE_DROP_THRESHOLD:.4f}")
+ print(f" quant CLIP score: {quant_clip_score:.4f} threshold>={quant_thresholds['clip_score']:.4f}")
+ print(f" CLIP score drop: {clip_score_drop:.4f} threshold<={quant_thresholds['clip_score_drop']:.4f}")
print(f" metrics: {metrics_path}")
- assert quant_clip_score >= QUANT_CLIP_SCORE_THRESHOLD, (
+ assert quant_clip_score >= quant_thresholds["clip_score"], (
f"{case.name} CLIP score below threshold: got {quant_clip_score:.4f}, "
- f"expected >= {QUANT_CLIP_SCORE_THRESHOLD:.4f}"
+ f"expected >= {quant_thresholds['clip_score']:.4f}"
)
- assert clip_score_drop <= QUANT_CLIP_SCORE_DROP_THRESHOLD, (
+ assert clip_score_drop <= quant_thresholds["clip_score_drop"], (
f"{case.name} CLIP score drop too large: got {clip_score_drop:.4f}, "
- f"expected <= {QUANT_CLIP_SCORE_DROP_THRESHOLD:.4f}"
+ f"expected <= {quant_thresholds['clip_score_drop']:.4f}"
)
diff --git a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_tokenizer.py b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_tokenizer.py
index e7bb75fbe..7fae00c36 100644
--- a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_tokenizer.py
+++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_tokenizer.py
@@ -22,6 +22,7 @@ class TokenizerEncodeOutput(BaseOutput):
tokens: torch.Tensor | None = None
timestep_scatter_index: torch.Tensor | None = None
guidance_scatter_index: torch.Tensor | None = None
+ timesteps_r_scatter_index: torch.Tensor | None = None # For MeanFlow distilled models
text_slices: list[slice] | None = None
gen_image_slices: list[slice] | None = None
joint_image_slices: list[slice] | None = None
@@ -196,6 +197,7 @@ class TokenizerWrapper:
extra_token_pos,
add_timestep_token=False,
add_image_shape_token=False,
+ add_timestep_r_token=False,
base_size=None,
ratio_idx=None,
image_type=None,
@@ -221,6 +223,10 @@ class TokenizerWrapper:
token_seq.extend([self.special_token_map[""]])
extra_token_pos["guidance"].append(token_count)
token_count += 1
+ if add_timestep_r_token:
+ token_seq.extend([self.special_token_map[""]])
+ extra_token_pos["gen_timestep_r"].append(token_count)
+ token_count += 1
return token_count
def encode_sequence(
@@ -230,6 +236,7 @@ class TokenizerWrapper:
total_length=None,
add_timestep_token=False,
add_guidance_token=False,
+ add_timestep_r_token=False,
last_key_only_prefix=False,
add_eos=True,
use_front_boi_token=True,
@@ -329,6 +336,7 @@ class TokenizerWrapper:
2
+ (1 if source.get("timestep", add_timestep_token) else 0)
+ (1 if source.get("guidance", add_guidance_token) else 0)
+ + (1 if source.get("timestep_r", add_timestep_r_token) else 0)
+ (2 if source.get("image_shape", add_image_shape_token) else 0)
)
if drop_last is True and token_count + extra_count + source["length"] > total_length:
@@ -344,6 +352,7 @@ class TokenizerWrapper:
extra_token_pos=extra_token_pos,
add_timestep_token=source.get("timestep", add_timestep_token),
add_guidance_token=source.get("guidance", add_guidance_token),
+ add_timestep_r_token=source.get("timestep_r", add_timestep_r_token),
add_image_shape_token=source.get("image_shape", add_image_shape_token),
base_size=source.get("base_size"),
ratio_idx=source.get("ratio_idx"),
@@ -739,6 +748,7 @@ class TokenizerWrapper:
length=section["token_length"],
timestep=section.get("add_timestep_token", False),
guidance=section.get("add_guidance_token", False),
+ timestep_r=section.get("add_timestep_r_token", False),
front_boi=section.get("use_front_boi_token", False),
image_shape=section.get("add_image_shape_token", False),
base_size=section.get("base_size"),
@@ -777,6 +787,11 @@ class TokenizerWrapper:
guidance_scatter_index = (
torch.tensor(extra_token_pos["guidance"], dtype=torch.long) if "guidance" in extra_token_pos else None
)
+ timesteps_r_scatter_index = (
+ torch.tensor(extra_token_pos["gen_timestep_r"], dtype=torch.long)
+ if "gen_timestep_r" in extra_token_pos
+ else None
+ )
cond_timestep_scatter_index = (
torch.tensor(extra_token_pos["cond_timestep"], dtype=torch.long)
if "cond_timestep" in extra_token_pos
@@ -847,6 +862,7 @@ class TokenizerWrapper:
tokens=full_seq_token_tensor,
timestep_scatter_index=timestep_scatter_index,
guidance_scatter_index=guidance_scatter_index,
+ timesteps_r_scatter_index=timesteps_r_scatter_index,
text_slices=text_slices,
gen_image_slices=gen_image_slices,
joint_image_slices=joint_image_slices,
diff --git a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py
index a1ad2c986..856e6f189 100644
--- a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py
+++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py
@@ -630,6 +630,7 @@ class ImageInfo:
self.add_timestep_token = kwargs.get("add_timestep_token", True)
self.add_guidance_token = kwargs.get("add_guidance_token", False)
+ self.add_timestep_r_token = kwargs.get("add_timestep_r_token", False)
self.use_front_boi_token = kwargs.get("use_front_boi_token", True)
self.add_image_shape_token = kwargs.get("add_image_shape_token", True)
@@ -667,6 +668,7 @@ class ImageInfo:
token_length=self.image_token_length,
add_timestep_token=self.add_timestep_token,
add_guidance_token=self.add_guidance_token,
+ add_timestep_r_token=self.add_timestep_r_token,
use_front_boi_token=self.use_front_boi_token,
add_image_shape_token=self.add_image_shape_token,
base_size=self.base_size,
@@ -1442,6 +1444,9 @@ class HunyuanImage3Config(PretrainedConfig):
vit=None,
vit_processor=None,
vit_aligner=None,
+ # Distillation flags
+ cfg_distilled: bool = False,
+ use_meanflow: bool = False,
**kwargs,
):
self.vocab_size = vocab_size
@@ -1521,6 +1526,10 @@ class HunyuanImage3Config(PretrainedConfig):
self.patch_embed_hidden_dim = patch_embed_hidden_dim
self.image_base_size = image_base_size
+ # Distillation flags
+ self.cfg_distilled = cfg_distilled
+ self.use_meanflow = use_meanflow
+
# token id
self.eod_token_id = eod_token_id
self.im_start_id = im_start_id
@@ -1555,7 +1564,7 @@ class HunyuanImage3ImageProcessor:
)
self.vision_encoder_processor = Siglip2ImageProcessorFast.from_dict(config.vit_processor)
- def build_image_info(self, image_size):
+ def build_image_info(self, image_size, add_guidance_token=False, add_timestep_r_token=False):
# parse image size (HxW, H:W, or )
if isinstance(image_size, str):
if image_size.startswith(" bool:
+ """Read an optional Hunyuan config flag with legacy-safe defaults."""
+
+ return bool(getattr(config, name, False))
+
+
+class HunyuanImage3FlowMatchEulerDiscreteScheduler(FlowMatchEulerDiscreteScheduler):
+ def get_timestep_r(self, timestep: float | torch.FloatTensor):
+ if self.step_index is None:
+ self._init_step_index(timestep)
+ if self.step_index + 1 < len(self.timesteps):
+ return self.timesteps[self.step_index + 1]
+ return self.timesteps.new_zeros(())
+
+
def default(val, d):
return val if val is not None else d
@@ -264,6 +279,8 @@ def get_hunyuan_image_3_pre_process_func(od_config: OmniDiffusionConfig):
image_processor=image_processor,
generation_config=generation_config,
image_base_size=hf_config.image_base_size,
+ cfg_distilled=_config_flag(hf_config, "cfg_distilled"),
+ use_meanflow=_config_flag(hf_config, "use_meanflow"),
)
request.prepared_layout = prepared_layout
request.diffusion_kv_requests = request_layout_utils.build_hunyuan_diffusion_kv_requests(
@@ -398,6 +415,11 @@ class HunyuanImage3Pipeline(
out_norm=True,
)
self.time_embed_2 = TimestepEmbedder(hidden_size=self.hf_config.hidden_size)
+ # Distillation-specific embedding modules
+ if _config_flag(self.hf_config, "cfg_distilled"):
+ self.guidance_emb = TimestepEmbedder(hidden_size=self.hf_config.hidden_size)
+ if _config_flag(self.hf_config, "use_meanflow"):
+ self.timestep_r_emb = TimestepEmbedder(hidden_size=self.hf_config.hidden_size)
self.lm_head = nn.Linear(self.hf_config.hidden_size, self.hf_config.vocab_size, bias=False)
self.vllm_config = get_current_vllm_config()
self.post_init()
@@ -419,6 +441,8 @@ class HunyuanImage3Pipeline(
"model.ln_f",
"time_embed",
"time_embed_2",
+ "guidance_emb",
+ "timestep_r_emb",
"final_layer.model",
]
device = get_local_device()
@@ -428,11 +452,8 @@ class HunyuanImage3Pipeline(
if mod:
mod.to(device)
- unexpected_keywords = [
- "guidance_emb",
- "timestep_r_emb",
- ]
- skip_prefixes.extend(unexpected_keywords)
+ # Note: guidance_emb and timestep_r_emb are no longer skipped
+ # to support HunyuanImage-3.0-Distil and MeanFlow distilled models
loader = AutoWeightsLoader(
self,
skip_prefixes=skip_prefixes,
@@ -452,7 +473,7 @@ class HunyuanImage3Pipeline(
def pipeline(self):
if self._pipeline is None:
# shift hard code
- self.scheduler = FlowMatchEulerDiscreteScheduler(
+ self.scheduler = HunyuanImage3FlowMatchEulerDiscreteScheduler(
num_train_timesteps=1000,
shift=self.generation_config.flow_shift,
use_dynamic_shifting=False,
@@ -902,6 +923,70 @@ class HunyuanImage3Pipeline(
return x
+ def instantiate_guidance_tokens(
+ self,
+ x: torch.Tensor,
+ guidance: BatchRaggedTensor | None = None,
+ guidance_scatter_index: torch.Tensor | None = None,
+ ):
+ """Instantiate guidance embedding tokens for CFG distilled models.
+
+ Args:
+ x: Input sequence tensor (batch_size, seq_len, n_embd)
+ guidance: Guidance scale values (as tensor, typically guidance_scale * 1000)
+ guidance_scatter_index: Index positions to scatter guidance embeddings
+ """
+ if guidance is None or guidance_scatter_index is None:
+ return x
+ if not hasattr(self, "guidance_emb"):
+ return x
+
+ batch_size, seq_len, n_embd = x.shape
+ guidance_src = self.guidance_emb(guidance.reshape(-1)) # (bsz * n, n_embd)
+ x.scatter_(
+ dim=1,
+ index=guidance_scatter_index.unsqueeze(-1).repeat(1, 1, n_embd),
+ src=guidance_src.reshape(batch_size, -1, n_embd),
+ )
+ return x
+
+ def instantiate_timestep_r_tokens(
+ self,
+ x: torch.Tensor,
+ timesteps_r: BatchRaggedTensor | None = None,
+ timesteps_r_scatter_index: torch.Tensor | None = None,
+ ):
+ """Instantiate timestep_r embedding tokens for MeanFlow distilled models.
+
+ Args:
+ x: Input sequence tensor (batch_size, seq_len, n_embd)
+ timesteps_r: Timestep r values for MeanFlow
+ timesteps_r_scatter_index: Index positions to scatter embeddings
+ """
+ if timesteps_r is None or timesteps_r_scatter_index is None:
+ return x
+ if not hasattr(self, "timestep_r_emb"):
+ return x
+
+ batch_size, seq_len, n_embd = x.shape
+
+ if isinstance(timesteps_r, list):
+ for i, timestep_r in enumerate(timesteps_r):
+ timestep_r_src = self.timestep_r_emb(timestep_r) # (n, n_embd)
+ x[i : i + 1].scatter_(
+ dim=1,
+ index=timesteps_r_scatter_index[i].unsqueeze(0).unsqueeze(-1).repeat(1, 1, n_embd),
+ src=timestep_r_src.reshape(1, -1, n_embd),
+ )
+ else:
+ timesteps_r_src = self.timestep_r_emb(timesteps_r.reshape(-1)) # (bsz * n, n_embd)
+ x.scatter_(
+ dim=1,
+ index=timesteps_r_scatter_index.unsqueeze(-1).repeat(1, 1, n_embd),
+ src=timesteps_r_src.reshape(batch_size, -1, n_embd),
+ )
+ return x
+
def instantiate_vit_image_tokens(
self,
x: torch.Tensor,
@@ -936,12 +1021,12 @@ class HunyuanImage3Pipeline(
return x
- def ragged_final_layer(self, x, image_mask, timestep, token_h, token_w, first_step):
+ def ragged_final_layer(self, x, image_mask, timestep, token_h, token_w, first_step, num_special_tokens: int = None):
bsz, seq_len, n_embd = x.shape
if first_step:
image_output = x.masked_select(image_mask.unsqueeze(-1).bool()).reshape(bsz, -1, n_embd)
else:
- image_output = x[:, 1:, :]
+ image_output = x[:, num_special_tokens:, :]
timestep_emb = self.time_embed_2(timestep)
pred = self.final_layer(image_output, timestep_emb, token_h, token_w)
return pred
@@ -1130,7 +1215,12 @@ class HunyuanImage3Pipeline(
batch_gen_image_info = [prepared_layout.generated_image_info]
else:
batch_gen_image_info = [
- self.image_processor.build_image_info(image_size) for _ in range(batch_size)
+ self.image_processor.build_image_info(
+ image_size,
+ add_guidance_token=_config_flag(getattr(self, "hf_config", None), "cfg_distilled"),
+ add_timestep_r_token=_config_flag(getattr(self, "hf_config", None), "use_meanflow"),
+ )
+ for _ in range(batch_size)
]
if batch_cond_image_info is not None:
@@ -1146,7 +1236,11 @@ class HunyuanImage3Pipeline(
generator = [torch.Generator(self.device).manual_seed(seed) for seed in seeds]
# 3. apply chat template
+ # For CFG distilled models, cfg_factor is always 1 (CFG is embedded in the model)
+ # For non-distilled models, cfg_factor depends on guidance_scale
cfg_factor = {"gen_text": 1, "gen_image": 1 + int(guidance_scale > 1.0)}
+ if _config_flag(getattr(self, "hf_config", None), "cfg_distilled"):
+ cfg_factor["gen_image"] = 1
bot_task = kwargs.pop("bot_task", "auto")
if prepared_layout is None:
# Pull template options from generation_config only when the
@@ -1268,6 +1362,14 @@ class HunyuanImage3Pipeline(
if vit_kwargs is not None
else None,
cond_timestep_scatter_index=to_device(output.cond_timestep_scatter_index, device),
+ # for CFG distilled models
+ guidance_scatter_index=to_device(output.guidance_scatter_index, device)
+ if hasattr(output, "guidance_scatter_index") and output.guidance_scatter_index is not None
+ else None,
+ # for MeanFlow distilled models
+ timesteps_r_scatter_index=to_device(output.timesteps_r_scatter_index, device)
+ if hasattr(output, "timesteps_r_scatter_index") and output.timesteps_r_scatter_index is not None
+ else None,
# for inner usage
tokenizer_output=output,
batch_gen_image_info=batch_gen_image_info,
@@ -1350,9 +1452,16 @@ class HunyuanImage3Pipeline(
"cond_vit_image_mask": kwargs.get("cond_vit_image_mask"),
"vit_kwargs": kwargs.get("vit_kwargs"),
"cond_timestep_scatter_index": kwargs.get("cond_timestep_scatter_index"),
+ # For CFG distilled models
+ "guidance": kwargs.get("guidance"),
+ "guidance_scatter_index": kwargs.get("guidance_scatter_index"),
+ # For MeanFlow distilled models
+ "timesteps_r": kwargs.get("timesteps_r"),
+ "timesteps_r_scatter_index": kwargs.get("timesteps_r_scatter_index"),
"query_lens": kwargs.get("query_lens"),
"seq_lens": kwargs.get("seq_lens"),
"num_image_tokens": kwargs.get("num_image_tokens"),
+ "num_special_tokens": kwargs.get("num_special_tokens"),
"ar_kv_reuse_len": kwargs.get("ar_kv_reuse_len", 0),
"full_attn_spans": kwargs.get("full_attn_spans"),
}
@@ -1372,6 +1481,11 @@ class HunyuanImage3Pipeline(
"mode": mode,
"custom_pos_emb": model_kwargs["custom_pos_emb"],
"num_image_tokens": model_kwargs["num_image_tokens"],
+ "num_special_tokens": model_kwargs["num_special_tokens"],
+ "guidance": model_kwargs.get("guidance"),
+ "guidance_scatter_index": model_kwargs.get("guidance_scatter_index"),
+ "timesteps_r": model_kwargs.get("timesteps_r"),
+ "timesteps_r_scatter_index": model_kwargs.get("timesteps_r_scatter_index"),
}
if "full_attn_spans" in model_kwargs:
updated_model_kwargs["full_attn_spans"] = model_kwargs["full_attn_spans"]
@@ -1400,15 +1514,24 @@ class HunyuanImage3Pipeline(
bsz, seq_len = image_mask.shape
offset = model_kwargs.get("ar_kv_reuse_offset", 0) # should be an absolute position.
index = torch.arange(offset, offset + seq_len, device=image_mask.device).unsqueeze(0).repeat(bsz, 1)
- position_ids = index.masked_select(image_mask.bool()).reshape(bsz, -1)
- timestep_position_ids = index[
- torch.arange(bsz), model_kwargs["gen_timestep_scatter_index"][:, -1]
- ].unsqueeze(-1)
- updated_model_kwargs["position_ids"] = torch.cat([timestep_position_ids, position_ids], dim=1)
+ image_position_ids = index.masked_select(image_mask.bool()).reshape(bsz, -1)
+ special_position_ids = [
+ index[torch.arange(bsz), model_kwargs["gen_timestep_scatter_index"][:, -1]].unsqueeze(-1)
+ ]
+ if model_kwargs.get("guidance_scatter_index") is not None:
+ special_position_ids.append(
+ index[torch.arange(bsz), model_kwargs["guidance_scatter_index"][:, -1]].unsqueeze(-1)
+ )
+ if model_kwargs.get("timesteps_r_scatter_index") is not None:
+ special_position_ids.append(
+ index[torch.arange(bsz), model_kwargs["timesteps_r_scatter_index"][:, -1]].unsqueeze(-1)
+ )
+ special_position_ids = torch.cat(special_position_ids, dim=1)
+ updated_model_kwargs["position_ids"] = torch.cat([special_position_ids, image_position_ids], dim=1)
# attention mask
mask_list = []
- current_starts = timestep_position_ids.reshape(-1)
+ current_starts = special_position_ids[:, 0]
max_current_start = int(current_starts.max().item())
for attention_mask_i, position_ids_i, current_start_i in zip(
model_kwargs["attention_mask"], updated_model_kwargs["position_ids"], current_starts
@@ -1461,6 +1584,7 @@ class HunyuanImage3Pipeline(
image_info: ImageInfo = batch_gen_image_info[0]
kwargs["num_image_tokens"] = request_layout_utils.hunyuan_num_image_tokens(image_info)
+ kwargs["num_special_tokens"] = request_layout_utils.hunyuan_num_special_tokens(image_info)
# 50 and 5.0 hard code
results = self.pipeline(
batch_size=len(batch_gen_image_info),
@@ -1514,9 +1638,16 @@ class HunyuanImage3Pipeline(
query_lens: list[int] | None = None,
seq_lens: list[int] | None = None,
num_image_tokens: int | None = None,
+ num_special_tokens: int | None = None,
uncond_cfg_prefill: bool = False,
ar_kv_reuse_len: int = 0,
full_attn_spans: list[list[tuple[int, int]]] | None = None,
+ # for CFG distilled models
+ guidance: torch.Tensor | None = None,
+ guidance_scatter_index: torch.Tensor | None = None,
+ # for MeanFlow distilled models
+ timesteps_r: torch.Tensor | None = None,
+ timesteps_r_scatter_index: torch.Tensor | None = None,
) -> tuple | CausalMMOutputWithPast:
return_dict = return_dict if return_dict is not None else self.config.use_return_dict
# Sanity Check of Inputs
@@ -1578,11 +1709,23 @@ class HunyuanImage3Pipeline(
inputs_embeds, images, timestep, image_mask
)
inputs_embeds = self.instantiate_timestep_tokens(inputs_embeds, timestep, gen_timestep_scatter_index)
+ # Instantiate guidance and timestep_r tokens for distilled models
+ inputs_embeds = self.instantiate_guidance_tokens(inputs_embeds, guidance, guidance_scatter_index)
+ inputs_embeds = self.instantiate_timestep_r_tokens(
+ inputs_embeds, timesteps_r, timesteps_r_scatter_index
+ )
else:
t_emb = self.time_embed(timestep)
image_emb, token_h, token_w = self.patch_embed(images, t_emb)
timestep_emb = self.timestep_emb(timestep).reshape(bsz, -1, n_embd)
- inputs_embeds = torch.cat([timestep_emb, image_emb], dim=1)
+ extra_embeds = []
+ if hasattr(self, "guidance_emb") and guidance is not None:
+ guidance_emb = self.guidance_emb(guidance.reshape(-1)).reshape(bsz, -1, n_embd)
+ extra_embeds.append(guidance_emb)
+ if hasattr(self, "timestep_r_emb") and timesteps_r is not None:
+ timesteps_r_emb = self.timestep_r_emb(timesteps_r.reshape(-1)).reshape(bsz, -1, n_embd)
+ extra_embeds.append(timesteps_r_emb)
+ inputs_embeds = torch.cat([timestep_emb, *extra_embeds, image_emb], dim=1)
# Instantiate placeholder tokens: ,
for cond images
# Should only run once with kv-cache enabled.
@@ -1641,7 +1784,7 @@ class HunyuanImage3Pipeline(
)
hidden_states = hidden_states.reshape(bsz, seq_len, n_embd)
diffusion_prediction = self.ragged_final_layer(
- hidden_states, image_mask, timestep, token_h, token_w, first_step
+ hidden_states, image_mask, timestep, token_h, token_w, first_step, num_special_tokens
)
if not return_dict:
@@ -1771,6 +1914,7 @@ class HunyuanImage3Pipeline(
target_height = int(image_info.image_height)
target_width = int(image_info.image_width)
model_kwargs["num_image_tokens"] = request_layout_utils.hunyuan_num_image_tokens(image_info)
+ model_kwargs["num_special_tokens"] = request_layout_utils.hunyuan_num_special_tokens(image_info)
timesteps, _ = retrieve_timesteps(
self.scheduler,
@@ -1819,13 +1963,14 @@ class HunyuanImage3Pipeline(
state.timesteps = timesteps
state.step_index = 0
state.scheduler = req_scheduler
- state.do_true_cfg = guidance_scale > 1.0
+ cfg_factor = request_layout_utils.hunyuan_cfg_factor(image_info, guidance_scale)
+ state.do_true_cfg = cfg_factor > 1
state.extra = {
_STEP_MODEL_KWARGS: model_kwargs,
_STEP_INPUT_IDS: input_ids,
_STEP_GENERATOR: model_kwargs["generator"],
_STEP_GUIDANCE_SCALE: guidance_scale,
- _STEP_CFG_FACTOR: 1 + int(guidance_scale > 1.0),
+ _STEP_CFG_FACTOR: cfg_factor,
_STEP_OUTPUT_SIZE: (target_height, target_width),
_STEP_COT_TEXT_LIST: cot_text_list,
_STEP_AR_KV: self._snapshot_injected_ar_kv(),
@@ -1990,6 +2135,21 @@ class HunyuanImage3Pipeline(
row_branches,
first_step,
)
+ if _config_flag(self.hf_config, "cfg_distilled"):
+ model_kwargs["guidance"] = torch.tensor(
+ [1000.0 * state.extra[_STEP_GUIDANCE_SCALE] for state in states] * cfg_factor,
+ device=latents.device,
+ dtype=torch.bfloat16,
+ )
+ if _config_flag(self.hf_config, "use_meanflow"):
+ model_kwargs["timesteps_r"] = torch.cat(
+ [
+ state.scheduler.get_timestep_r(state.current_timestep).reshape(1).to(device=latents.device)
+ for _ in range(cfg_factor)
+ for state in states
+ ],
+ dim=0,
+ )
if first_step:
self._restore_injected_ar_kv(states, row_state_indexes, row_branches)
else:
diff --git a/vllm_omni/diffusion/models/hunyuan_image3/request_layout.py b/vllm_omni/diffusion/models/hunyuan_image3/request_layout.py
index d09c20c52..32353ed62 100644
--- a/vllm_omni/diffusion/models/hunyuan_image3/request_layout.py
+++ b/vllm_omni/diffusion/models/hunyuan_image3/request_layout.py
@@ -50,6 +50,7 @@ def _image_info_to_payload(image_info: ImageInfo) -> dict[str, Any]:
"ratio_index": _to_python_scalar(image_info.ratio_index),
"add_timestep_token": image_info.add_timestep_token,
"add_guidance_token": image_info.add_guidance_token,
+ "add_timestep_r_token": image_info.add_timestep_r_token,
"use_front_boi_token": image_info.use_front_boi_token,
"add_image_shape_token": image_info.add_image_shape_token,
}
@@ -68,6 +69,7 @@ def _image_info_from_payload(payload: dict[str, Any]) -> ImageInfo:
ratio_index=payload.get("ratio_index"),
add_timestep_token=payload.get("add_timestep_token", True),
add_guidance_token=payload.get("add_guidance_token", False),
+ add_timestep_r_token=payload.get("add_timestep_r_token", False),
use_front_boi_token=payload.get("use_front_boi_token", True),
add_image_shape_token=payload.get("add_image_shape_token", True),
)
@@ -197,7 +199,21 @@ def resolve_hunyuan_guidance_scale(sampling: Any, default_scale: float = 5.0) ->
def hunyuan_num_image_tokens(image_info: ImageInfo) -> int:
"""Return the generated-image span overwritten on every denoise step."""
- return int(image_info.image_token_length) + int(image_info.add_timestep_token) + int(image_info.add_guidance_token)
+ return int(image_info.image_token_length) + hunyuan_num_special_tokens(image_info)
+
+
+def hunyuan_num_special_tokens(image_info: ImageInfo) -> int:
+ """Return the generated-image prefix tokens emitted before latent tokens."""
+
+ return (
+ int(image_info.add_timestep_token) + int(image_info.add_guidance_token) + int(image_info.add_timestep_r_token)
+ )
+
+
+def hunyuan_cfg_factor(image_info: ImageInfo, guidance_scale: float) -> int:
+ """Return the execution branch count for standard or embedded CFG."""
+
+ return 1 if image_info.add_guidance_token else 1 + int(guidance_scale > 1.0)
def build_hunyuan_batch_rope_image_info(
@@ -242,6 +258,8 @@ def prepare_hunyuan_layout(
image_processor: HunyuanImage3ImageProcessor,
generation_config: GenerationConfig,
image_base_size: int,
+ cfg_distilled: bool = False,
+ use_meanflow: bool = False,
) -> HunyuanPreparedLayout:
"""Build the CPU token/image layout reused by Scheduler and Worker."""
@@ -261,7 +279,12 @@ def prepare_hunyuan_layout(
height = sampling.height or 1024
width = sampling.width or 1024
guidance_scale = resolve_hunyuan_guidance_scale(sampling)
- generated_image_info = image_processor.build_image_info((height, width))
+ image_info_kwargs: dict[str, bool] = {}
+ if cfg_distilled:
+ image_info_kwargs["add_guidance_token"] = True
+ if use_meanflow:
+ image_info_kwargs["add_timestep_r_token"] = True
+ generated_image_info = image_processor.build_image_info((height, width), **image_info_kwargs)
result = tokenizer_wrapper.apply_chat_template(
batch_prompt=prompt,
mode="gen_image",
@@ -273,7 +296,7 @@ def prepare_hunyuan_layout(
bot_task=tokenizer_bot_task,
image_base_size=image_base_size,
sequence_template=getattr(generation_config, "sequence_template", "pretrain"),
- cfg_factor=1 + int(guidance_scale > 1.0),
+ cfg_factor=hunyuan_cfg_factor(generated_image_info, guidance_scale),
drop_think=getattr(generation_config, "drop_think", False),
)
tokenizer_output = result["output"]
@@ -337,7 +360,10 @@ def build_hunyuan_diffusion_kv_requests(
"""Build one persistent Scheduler KV request per Hunyuan execution row."""
tokenizer_output = prepared_layout.tokenizer_output
- cfg_factor = 1 + int(resolve_hunyuan_guidance_scale(request.sampling_params) > 1.0)
+ cfg_factor = hunyuan_cfg_factor(
+ prepared_layout.generated_image_info,
+ resolve_hunyuan_guidance_scale(request.sampling_params),
+ )
if prepared_layout.num_branches != cfg_factor:
raise ValueError(
"Hunyuan tokenizer sequence count does not match CFG execution: "