[Bugfix][HunyuanImage3] Fix offline AR garbage output by switching to Instruct chat template (#3243)

Signed-off-by: zuiho <wu15922848573@outlook.com>
Signed-off-by: TaffyOfficial <2324465096@qq.com>
Signed-off-by: zuiho-kai <31877877+zuiho-kai@users.noreply.github.com>
Signed-off-by: zuiho <2324465096@qq.com>
Co-authored-by: TaffyOfficial <2324465096@qq.com>
Co-authored-by: zuiho-kai <31877877+zuiho-kai@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-05-06 00:31:02 +08:00
1 parent a77c56725d
commit 44cde33eb5
7 files changed
+813 -68

No files matched your search

@@ -135,17 +135,19 @@ python end2end.py --model tencent/HunyuanImage-3.0-Instruct \
## Prompt Format
HunyuanImage-3.0 uses a pretrain template format:
HunyuanImage-3.0-Instruct uses an instruct chat template:
```
<|startoftext|>{system_prompt}{<img>}{trigger_tag}{user_prompt}
<|startoftext|>{system_prompt}\n\nUser: {<img>?}{user_prompt}\n\nAssistant: {trigger_tag?}
```
- `<img>`: Placeholder for each input image (auto-inserted by `prompt_utils.py`)
- Trigger tags: `<think>` (CoT), `<recaption>` (recaptioning)
- `<img>`: Placeholder for each input image (single token; expanded by the multimodal pipeline)
- Trigger tags: `<think>` (CoT), `<recaption>` (recaptioning) — placed AFTER `Assistant: `
- System prompt: Auto-selected based on task
- `t2i_vanilla` is the only task that uses the bare pretrain template (no chat structure)
The `prompt_utils.build_prompt()` handles this formatting automatically.
The shared `vllm_omni.diffusion.models.hunyuan_image3.prompt_utils.build_prompt_tokens()`
helper handles segment-by-segment tokenization (matches HF `apply_chat_template` byte-for-byte).
------
@@ -16,23 +16,12 @@ Usage:
import argparse
import os
from vllm_omni.diffusion.models.hunyuan_image3.system_prompt import (
get_system_prompt,
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
build_prompt_tokens,
)
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniPromptType
# task → (sys_type, bot_task, trigger_tag)
_TASK_PRESETS: dict[str, tuple[str, str | None, str | None]] = {
"t2t": ("en_unified", None, None),
"i2t": ("en_unified", None, None),
"it2i_think": ("en_unified", "think", "<think>"),
"it2i_recaption": ("en_unified", "recaption", "<recaption>"),
"t2i_think": ("en_unified", "think", "<think>"),
"t2i_recaption": ("en_unified", "recaption", "<recaption>"),
"t2i_vanilla": ("en_vanilla", "image", None),
}
# Modality → prompt_utils task mapping
_MODALITY_TASK_MAP = {
"text2img": "t2i_think",
@@ -42,36 +31,6 @@ _MODALITY_TASK_MAP = {
}
def build_prompt(
user_prompt: str,
task: str = "it2i_think",
sys_type: str | None = None,
custom_system_prompt: str | None = None,
) -> str:
"""Build a HunyuanImage-3.0 prompt using pretrain template format."""
if task not in _TASK_PRESETS:
raise ValueError(f"Unknown task {task!r}. Choose from: {sorted(_TASK_PRESETS)}")
preset_sys_type, preset_bot_task, trigger_tag = _TASK_PRESETS[task]
effective_sys_type = sys_type or preset_sys_type
system_prompt = get_system_prompt(effective_sys_type, preset_bot_task, custom_system_prompt)
sys_text = system_prompt.strip() if system_prompt else ""
has_image_input = task.startswith("i2t") or task.startswith("it2i")
parts = ["<|startoftext|>"]
if sys_text:
parts.append(sys_text)
if has_image_input:
parts.append("<img>")
if trigger_tag:
parts.append(trigger_tag)
parts.append(user_prompt)
return "".join(parts)
# Modality → default stage config
_MODALITY_DEFAULT_CONFIG = {
"text2img": "hunyuan_image3_t2i.yaml",
@@ -179,12 +138,18 @@ def main():
input_image = Image.open(args.image_path).convert("RGB")
# Load tokenizer for segment-wise prompt tokenization (matches HF
# apply_chat_template byte-for-byte; see build_prompt_tokens docstring).
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
# Format prompts
formatted_prompts: list[OmniPromptType] = []
for p in prompts:
formatted_text = build_prompt(p, task=task, sys_type=args.sys_type)
token_ids = build_prompt_tokens(p, tokenizer, task=task, sys_type=args.sys_type)
prompt_dict: dict = {"prompt": formatted_text}
prompt_dict: dict = {"prompt_token_ids": token_ids}
if args.modality == "text2img":
prompt_dict["modalities"] = ["image"]
@@ -0,0 +1,292 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for HunyuanImage3 prompt construction (PR #3243).
Two layers:
1. Pure-logic tests with a recording fake tokenizer -- protect the
prompt template structure (BOS, User:/Assistant: framing, trigger
placement, image placeholder position) and protect the segment-
by-segment tokenization contract (each segment must hit
`tokenizer.encode` in isolation).
2. Real-tokenizer regression -- run when the HunyuanImage3-Instruct
tokenizer is in the local HF cache. Asserts the segment-tokenized
output diverges from the naive full-string encode, which is the
bug-tripping fixture for the cross-segment BPE merge fix
(commit 7bd429ed).
"""
from __future__ import annotations
import ast
import os
import pathlib
import pytest
from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
available_tasks,
build_prompt,
build_prompt_tokens,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# -------------------- Pure-logic structural tests --------------------
class FakeTokenizer:
"""Minimal tokenizer stub that records every encode() call.
Returns deterministic ids: special tokens map to small ints (1-4),
encode() returns one id per character starting at 100. This lets
tests both verify segmentation (by inspecting `encode_calls`) and
locate substrings inside the returned id list.
"""
SPECIAL = {
"<|startoftext|>": 1,
"<img>": 2,
"<think>": 3,
"<recaption>": 4,
}
def __init__(self) -> None:
self.encode_calls: list[str] = []
def convert_tokens_to_ids(self, tok: str) -> int:
return self.SPECIAL.get(tok, 0)
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
self.encode_calls.append(text)
return list(range(100, 100 + len(text)))
def test_available_tasks_covers_all_modalities():
tasks = set(available_tasks())
assert tasks >= {
"t2t",
"i2t",
"it2i_think",
"it2i_recaption",
"t2i_think",
"t2i_recaption",
"t2i_vanilla",
}
@pytest.mark.parametrize(
"task",
[
"t2t",
"i2t",
"it2i_think",
"it2i_recaption",
"t2i_think",
"t2i_recaption",
],
)
def test_build_prompt_string_structure_chat_template(task: str):
"""Chat-template tasks must produce <|startoftext|>...User: ...Assistant: ...
with image placeholder (when applicable) and trigger tag AFTER `Assistant: `."""
s = build_prompt("HELLO", task=task)
assert s.startswith("<|startoftext|>")
assert "User: " in s
assert "Assistant: " in s
assert s.index("User: ") < s.index("HELLO") < s.index("Assistant: ")
if task.startswith(("i2t", "it2i")):
assert s.index("User: ") < s.index("<img>") < s.index("HELLO"), (
"<img> placeholder must sit between `User: ` and the user prompt"
)
else:
assert "<img>" not in s
# Trigger tag must be the FINAL token of the prompt (after `Assistant: `).
# Note: the system prompt itself mentions <think>/<recaption> as mode
# documentation, so substring index() catches the wrong occurrence -- use
# endswith() which directly captures "trigger is at the tail" (the Part A
# fix: trigger goes AFTER `Assistant: `, not before user_prompt).
if task in ("it2i_think", "t2i_think"):
assert s.endswith("Assistant: <think>"), (
f"Trigger <think> must be appended right after `Assistant: ` (Part A fix). Got tail: ...{s[-40:]!r}"
)
if task in ("it2i_recaption", "t2i_recaption"):
assert s.endswith("Assistant: <recaption>"), (
f"Trigger <recaption> must be appended right after `Assistant: ` (Part A fix). Got tail: ...{s[-40:]!r}"
)
if task in ("t2t", "i2t"):
assert s.endswith("Assistant: "), "Plain (no-trigger) task must end at `Assistant: ` with no trailing tag."
def test_build_prompt_vanilla_uses_pretrain_template():
"""t2i_vanilla is the only task that bypasses chat structure -- direct
text->image generation driven by the vanilla system prompt."""
s = build_prompt("HELLO", task="t2i_vanilla")
assert s.startswith("<|startoftext|>")
assert "User: " not in s
assert "Assistant: " not in s
assert "<think>" not in s
assert "<recaption>" not in s
assert s.endswith("HELLO")
def test_build_prompt_unknown_task_raises():
with pytest.raises(ValueError, match="Unknown task"):
build_prompt("x", task="bogus")
with pytest.raises(ValueError, match="Unknown task"):
build_prompt_tokens("x", FakeTokenizer(), task="bogus")
def test_build_prompt_tokens_segments_each_boundary():
"""Regression for cross-segment BPE merge bug (commit 7bd429ed):
each template segment must hit tokenizer.encode() independently;
user_prompt MUST NOT be concatenated with the following separator
in the same encode() call."""
tok = FakeTokenizer()
build_prompt_tokens("写诗。", tok, task="i2t")
# Each canonical segment is encoded in its own call.
assert "User: " in tok.encode_calls
assert "写诗。" in tok.encode_calls, (
"user_prompt must be encoded alone -- if it is concatenated with the "
"trailing separator, BPE will merge across the boundary (the PR-#3243 bug)."
)
assert "\n\nAssistant: " in tok.encode_calls
# No call must contain user_prompt glued to neighboring text.
for call in tok.encode_calls:
if call != "写诗。":
assert "写诗。" not in call, f"user_prompt leaked into a multi-segment encode call: {call!r}"
def test_build_prompt_tokens_image_placeholder_present_for_image_tasks():
tok = FakeTokenizer()
ids = build_prompt_tokens("hi", tok, task="i2t")
assert ids[0] == 1, "BOS (<|startoftext|>) must be the first token"
assert 2 in ids, "<img> placeholder must be present for i2t/it2i tasks"
def test_build_prompt_tokens_no_image_for_text_only_tasks():
tok = FakeTokenizer()
ids = build_prompt_tokens("hi", tok, task="t2t")
assert 2 not in ids, "<img> must NOT appear for text-only tasks"
@pytest.mark.parametrize(
"task,trigger_id",
[("it2i_think", 3), ("t2i_think", 3), ("it2i_recaption", 4), ("t2i_recaption", 4)],
)
def test_build_prompt_tokens_trigger_is_last_token(task: str, trigger_id: int):
"""Trigger tag id must be the LAST token (after `Assistant: ` segment)."""
tok = FakeTokenizer()
ids = build_prompt_tokens("hi", tok, task=task)
assert ids[-1] == trigger_id
def test_build_prompt_tokens_no_trigger_for_plain_tasks():
"""Tasks without trigger_tag (t2t / i2t) must NOT append a trigger id."""
tok = FakeTokenizer()
ids = build_prompt_tokens("hi", tok, task="t2t")
assert ids[-1] not in {3, 4} # neither <think> nor <recaption>
# -------------------- end2end.py wiring guard --------------------
def _repo_root() -> pathlib.Path:
# tests/diffusion/models/hunyuan_image3/test_prompt_utils.py -> repo root
return pathlib.Path(__file__).resolve().parents[4]
def test_end2end_routes_through_shared_prompt_utils():
"""Regression for the *delivery vector* of PR #3243.
Background: the wrong-template bug that PR #3243 fixes was introduced
when end2end.py grew its own hand-rolled prompt builder that diverged
from the canonical instruct chat template. To prevent that exact
failure mode from recurring, end2end.py MUST:
1. Import the prompt builders from the shared prompt_utils module.
2. NOT redefine `build_prompt` or `build_prompt_tokens` locally.
A local redefinition is precisely how a future merge can silently
re-introduce a pretrain-style template (trigger BEFORE user_prompt,
no User:/Assistant: framing, etc.) without touching prompt_utils,
bypassing every other test in this file.
"""
end2end_path = _repo_root() / "examples" / "offline_inference" / "hunyuan_image3" / "end2end.py"
assert end2end_path.is_file(), f"end2end.py not found at {end2end_path}"
tree = ast.parse(end2end_path.read_text(encoding="utf-8"))
local_func_names = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
forbidden = {"build_prompt", "build_prompt_tokens"}
redefined = local_func_names & forbidden
assert not redefined, (
f"end2end.py defines {sorted(redefined)} locally. This is exactly how "
"the wrong prompt template re-entered the example before PR #3243. "
"Use the shared `vllm_omni.diffusion.models.hunyuan_image3.prompt_utils` "
"helpers instead."
)
imported_from_prompt_utils: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and node.module.endswith("hunyuan_image3.prompt_utils"):
imported_from_prompt_utils.update(alias.name for alias in node.names)
assert "build_prompt_tokens" in imported_from_prompt_utils, (
"end2end.py must import build_prompt_tokens from "
"vllm_omni.diffusion.models.hunyuan_image3.prompt_utils -- the shared "
"helper is the single source of truth for the AR-prefill template."
)
# -------------------- Real-tokenizer regression --------------------
_HUNYUAN_MODEL_ID = "tencent/HunyuanImage-3.0-Instruct"
def _hf_cached(model_id: str) -> bool:
hf_home = os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface")
snap_dir = os.path.join(hf_home, "hub", f"models--{model_id.replace('/', '--')}", "snapshots")
return os.path.isdir(snap_dir) and any(os.scandir(snap_dir))
@pytest.mark.skipif(
not _hf_cached(_HUNYUAN_MODEL_ID),
reason=f"{_HUNYUAN_MODEL_ID} tokenizer not in HF cache",
)
def test_segment_tokenize_diverges_from_full_string_encode():
"""Regression for PR #3243 segment-tokenization fix.
The naive `tokenizer.encode(build_prompt(...))` lets BPE merge tokens
across segment boundaries (notably `。\\n\\n` -> a single id), which
drifts the AR prefill away from HF's apply_chat_template output. The
segment-by-segment build_prompt_tokens must produce a STRICTLY
DIFFERENT id sequence on a prompt that triggers the merge.
If someone "simplifies" build_prompt_tokens to call encode() on the
full string, this assertion fires.
"""
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(_HUNYUAN_MODEL_ID, trust_remote_code=True)
user_prompt = "写一首关于夜的诗。"
seg_ids = build_prompt_tokens(user_prompt, tok, task="i2t")
full_ids = tok.encode(build_prompt(user_prompt, task="i2t"), add_special_tokens=False)
assert seg_ids != full_ids, (
"build_prompt_tokens output equals naive full-string encode -- "
"the BPE-merge-bypass behavior is no longer exercised. This means "
"the segment-by-segment fix from PR #3243 has been silently undone."
)
# Segmenting prevents merges, so the segment id list should have AT LEAST
# as many tokens as the merged version (a merge consumes 2+ ids -> 1).
assert len(seg_ids) >= len(full_ids), (
f"segment-encoded length ({len(seg_ids)}) shorter than full-string "
f"merged length ({len(full_ids)}) -- impossible if segmentation is "
f"genuinely bypassing merges."
)
@@ -0,0 +1,152 @@
# SPDX-License-Identifier: Apache-2.0
"""Shared prompt-template construction for HunyuanImage-3.0-Instruct.
Single source of truth for the AR-prefill prompt format used by the
example scripts and any downstream caller that needs to build
HunyuanImage3 chat-template token sequences without invoking the full
diffusion pipeline tokenizer wrapper.
The DiT pipeline (`pipeline_hunyuan_image3.py`) builds prompts through
`TokenizerWrapper.apply_chat_template`, which eagerly consumes
`JointImageInfo` objects produced by image preprocessing. The example
flow uses an `<img>` placeholder + `multi_modal_data` instead, so it
needs a lighter-weight builder that only requires a HF tokenizer. This
module provides that builder; the task -> template mapping below is the
canonical mapping for both flows.
"""
from __future__ import annotations
from .system_prompt import get_system_prompt
# task -> (sys_type, bot_task, trigger_tag)
_TASK_PRESETS: dict[str, tuple[str, str | None, str | None]] = {
"t2t": ("en_unified", None, None),
"i2t": ("en_unified", None, None),
"it2i_think": ("en_unified", "think", "<think>"),
"it2i_recaption": ("en_unified", "recaption", "<recaption>"),
"t2i_think": ("en_unified", "think", "<think>"),
"t2i_recaption": ("en_unified", "recaption", "<recaption>"),
"t2i_vanilla": ("en_vanilla", "image", None),
}
def available_tasks() -> list[str]:
"""Sorted list of task keys accepted by `build_prompt` / `build_prompt_tokens`."""
return sorted(_TASK_PRESETS)
def build_prompt(
user_prompt: str,
task: str = "it2i_think",
sys_type: str | None = None,
custom_system_prompt: str | None = None,
) -> str:
"""Build a HunyuanImage-3.0 prompt as a string (legacy/compat path).
NOTE: when this string is passed to the engine, the engine's tokenizer
will run a single BPE pass over the whole string, which can merge
tokens across segment boundaries (e.g. `。\\n\\n` -> id 3490). For
inputs that need to match HF baseline byte-for-byte, use
`build_prompt_tokens` instead and feed the result via prompt_token_ids.
"""
if task not in _TASK_PRESETS:
raise ValueError(f"Unknown task {task!r}. Choose from: {available_tasks()}")
preset_sys_type, preset_bot_task, trigger_tag = _TASK_PRESETS[task]
effective_sys_type = sys_type or preset_sys_type
system_prompt = get_system_prompt(effective_sys_type, preset_bot_task, custom_system_prompt)
sys_text = system_prompt.strip() if system_prompt else ""
has_image_input = task.startswith("i2t") or task.startswith("it2i")
# t2i_vanilla: pretrain mode for direct text->image generation. The
# vanilla system prompt drives the model with no chat structure.
if task == "t2i_vanilla":
parts = ["<|startoftext|>"]
if sys_text:
parts.append(sys_text)
parts.append(user_prompt)
return "".join(parts)
# All other tasks (t2t / i2t / t2i_think / t2i_recaption /
# it2i_think / it2i_recaption) use HunyuanImage3 Instruct chat template:
# <|startoftext|>{system?}\n\nUser: {<img>?}{user_prompt}\n\nAssistant: {trigger?}
# generation_config.json declares sequence_template="instruct", so the
# AR prefill MUST use this template -- verified to match HF's
# apply_chat_template output token-for-token (modulo BPE boundary merges).
# The trigger_tag (e.g. <think>) MUST come AFTER the `Assistant: ` prefix:
# if it goes BEFORE user_prompt (the old pretrain layout) the model puts
# the user's instructions inside the "thinking section" and collapses
# into repetition garbage under greedy decoding.
parts = ["<|startoftext|>"]
if sys_text:
parts.append(f"{sys_text}\n\n")
parts.append("User: ")
if has_image_input:
parts.append("<img>")
parts.append(user_prompt)
parts.append("\n\nAssistant: ")
if trigger_tag:
parts.append(trigger_tag)
return "".join(parts)
def build_prompt_tokens(
user_prompt: str,
tokenizer,
task: str = "it2i_think",
sys_type: str | None = None,
custom_system_prompt: str | None = None,
) -> list[int]:
"""Segment-by-segment tokenization that matches HF apply_chat_template.
Calling tokenizer.encode(build_prompt(...)) on the full string lets BPE
merge tokens across segment boundaries (e.g. user_prompt ends with `。`
and the next segment is `\\n\\n` -> they merge into a single token id
3490 instead of HF's [1811, 271]). HF's apply_chat_template tokenizes
each segment independently and concatenates token_ids, so no cross-
boundary merge happens. We replicate that here and feed the result to
Omni via OmniTokensPrompt (prompt_token_ids).
"""
if task not in _TASK_PRESETS:
raise ValueError(f"Unknown task {task!r}. Choose from: {available_tasks()}")
preset_sys_type, preset_bot_task, trigger_tag = _TASK_PRESETS[task]
effective_sys_type = sys_type or preset_sys_type
bos_id = tokenizer.convert_tokens_to_ids("<|startoftext|>")
img_id = tokenizer.convert_tokens_to_ids("<img>")
trig_id = tokenizer.convert_tokens_to_ids(trigger_tag) if trigger_tag else None
has_image_input = task.startswith("i2t") or task.startswith("it2i")
# t2i_vanilla uses pretrain template with no chat structure; the vanilla
# system prompt drives the model directly. No segment boundaries to
# protect, fall back to whole-string encode.
if task == "t2i_vanilla":
s = build_prompt(user_prompt, task, sys_type, custom_system_prompt)
return tokenizer.encode(s, add_special_tokens=False)
system_prompt = get_system_prompt(effective_sys_type, preset_bot_task, custom_system_prompt)
# Do NOT strip -- HF apply_chat_template keeps the system prompt's
# natural trailing newline; stripping it would shift one token id.
sys_text = system_prompt or ""
ids: list[int] = [bos_id]
if sys_text:
ids += tokenizer.encode(sys_text, add_special_tokens=False)
ids += tokenizer.encode("\n\n", add_special_tokens=False)
ids += tokenizer.encode("User: ", add_special_tokens=False)
if has_image_input:
ids += [img_id]
ids += tokenizer.encode(user_prompt, add_special_tokens=False)
ids += tokenizer.encode("\n\nAssistant: ", add_special_tokens=False)
if trig_id is not None:
ids += [trig_id]
return ids
__all__ = ["build_prompt", "build_prompt_tokens", "available_tasks"]
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import gc
import math
import typing
from collections.abc import Callable, Iterable, Mapping, Sequence
@@ -17,14 +18,29 @@ from transformers.feature_extraction_utils import BatchFeature
from transformers.image_utils import ImageInput
from transformers.tokenization_utils_base import PreTokenizedInput, TextInput
from vllm.compilation.decorators import support_torch_compile
from vllm.config import VllmConfig
from vllm.config import VllmConfig, get_current_vllm_config
from vllm.config.multimodal import BaseDummyOptions
from vllm.distributed import get_pp_group
from vllm.distributed import (
get_ep_group,
get_pp_group,
get_tensor_model_parallel_world_size,
)
from vllm.inputs import MultiModalDataDict
from vllm.logger import init_logger
from vllm.model_executor.layers.fused_moe import fused_moe_make_expert_params_mapping
try:
from vllm.model_executor.layers.fused_moe.shared_fused_moe import SharedFusedMoE
except ImportError:
# PyPI vllm 0.20.x neither exports `SharedFusedMoE` from the package top-level
# nor ships a `shared_fused_moe.py` submodule. The functionality lives on
# `FusedMoE` directly (which gained a `shared_experts` parameter), so alias
# the symbol — call sites only use the classmethod `make_expert_params_mapping`
# and `__init__(shared_experts=..., ...)` which are present on `FusedMoE`.
from vllm.model_executor.layers.fused_moe import FusedMoE as SharedFusedMoE
from vllm.model_executor.layers.linear import (
ColumnParallelLinear,
ReplicatedLinear,
RowParallelLinear,
)
from vllm.model_executor.layers.logits_processor import LogitsProcessor
@@ -37,7 +53,9 @@ from vllm.model_executor.model_loader.weight_utils import (
maybe_remap_kv_scale_name,
)
from vllm.model_executor.models.hunyuan_v1 import (
HunYuanMLP,
HunYuanModel,
HunYuanSparseMoeBlock,
_get_cla_factor,
_is_moe,
)
@@ -842,8 +860,6 @@ class HunyuanImage3Processor:
else:
raise TypeError(f"Unsupported image type: {type(image_input)}.")
torch_dtype = getattr(self.hf_config, "torch_dtype", torch.bfloat16)
batch_data = []
for image in images:
current_info = {}
@@ -853,20 +869,38 @@ class HunyuanImage3Processor:
# VIT processing
vit_pixel_values = self.vision_encoder_processor(image)
# shape: (seq_len, num_channels * patch_size * patch_size)
current_info["vit_pixel_values"] = vit_pixel_values["pixel_values"].squeeze(0)
# shape: (seq_len, )
current_info["vit_pixel_attention_mask"] = vit_pixel_values["pixel_attention_mask"].squeeze(0)
# shape: (2, )
current_info["vit_spatial_shapes"] = vit_pixel_values["spatial_shapes"].squeeze(0)
# transformers>=5.x returns lists; stack to tensor when needed
_pv = vit_pixel_values["pixel_values"]
if isinstance(_pv, list):
_pv = torch.stack(_pv, dim=0)
current_info["vit_pixel_values"] = _pv.squeeze(0)
_pam = vit_pixel_values["pixel_attention_mask"]
if isinstance(_pam, list):
_pam = torch.stack(_pam, dim=0)
current_info["vit_pixel_attention_mask"] = _pam.squeeze(0)
_ss = vit_pixel_values["spatial_shapes"]
if isinstance(_ss, list):
_ss = torch.tensor(_ss, dtype=torch.long)
current_info["vit_spatial_shapes"] = _ss.squeeze(0)
# VAE processing
# VAE processing.
# The resize/crop math here mirrors HF's `resize_and_crop` with
# crop_type="center" (hunyuan3.0_ins/image_processor.py:61). VAE
# normalize uses the same transforms.Compose([ToTensor,
# Normalize([0.5], [0.5])]) as HF's `pil_image_to_tensor`. So
# numerical output of this branch should match HF up to floating-
# point reduction order.
image_width, image_height = self.reso_group.get_target_size(image.width, image.height)
resized_image = self._resize_and_crop(image, (image_width, image_height))
vae_pixel_values = self.vae_processor(resized_image)
token_height = image_height // (self.hf_config.vae_downsample_factor[0] * self.hf_config.patch_size)
token_width = image_width // (self.hf_config.vae_downsample_factor[1] * self.hf_config.patch_size)
current_info["vae_pixel_values"] = vae_pixel_values.squeeze(0).to(dtype=torch_dtype)
# Keep fp32 — the VAE encoder casts to model dtype at its boundary
# (see _vae_encode). Casting to bf16 here costs ~7e-4 mean-abs-diff
# bf16 quantization error on every pixel vs HF (which keeps fp32
# in build_cond_images), measurable as a real numerical drift in
# downstream image embeddings.
current_info["vae_pixel_values"] = vae_pixel_values.squeeze(0)
current_info["vae_token_grid_hw"] = torch.tensor([token_height, token_width])
# size
@@ -1052,6 +1086,25 @@ class HunyuanImage3MultiModalProcessor(BaseMultiModalProcessor[HunyuanImage3Proc
if ratio_token_id is None:
raise ValueError(f"Ratio token '<img_ratio_{_ratio_index}>' not found in tokenizer vocabulary")
# NOTE on the timestep slot:
# HF's apply_chat_template emits the literal <timestep> token id
# 128017 here. HF's modeling forward (`instantiate_continuous_tokens`,
# see hunyuan3.0_ins/modeling_hunyuan_image_3.py:1964) then *scatter-
# replaces* the embedding at that position with `timestep_emb(0)`
# for cond images. So the wte embedding of <timestep> is irrelevant
# at runtime — what matters is the timestep_emb injection.
#
# vllm-omni achieves the same effect via the multimodal-embedding
# merger: we put an <img> (128006) placeholder here and ship a
# `timestep_emb(0)` tensor at the head of `embed_multimodal()`'s
# combined_embeddings. The merger replaces this placeholder's
# embedding with the timestep tensor, yielding a final hidden
# state numerically equivalent to HF at that position.
#
# Keep this slot as <img> (NOT <timestep>): switching to <timestep>
# requires either (a) a second PromptReplacement targeting 128017,
# or (b) the merger's embed_token_id to be a list — neither is
# currently supported by PromptUpdateDetails.select_token_id.
replacement = (
[boi_token_id]
+ [base_size_token_id]
@@ -1070,6 +1123,197 @@ class HunyuanImage3MultiModalProcessor(BaseMultiModalProcessor[HunyuanImage3Proc
]
def _hunyuan_image3_unpack_packed_topk(
hidden_states: torch.Tensor,
gating_output: torch.Tensor,
topk: int,
renormalize: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Unpack pre-computed ``(topk_weights, topk_indices)`` packed by
:class:`HunyuanImage3SparseMoeBlock` into ``gating_output``.
Used as ``custom_routing_function`` for the underlying ``SharedFusedMoE``,
bypassing its bf16 ``topk_softmax`` CUDA op so the routing decision can
be made in fp32 (matching the reference implementation).
Layout of ``gating_output`` (shape ``[num_tokens, top_k * 2]``)::
[:, :top_k] -> topk_weights (already softmax'd + renormalized in fp32,
stored as fp32 for transport)
[:, top_k:] -> topk_indices (cast to fp32 for transport, restored to int32)
"""
topk_weights = gating_output[:, :topk].contiguous()
topk_indices = gating_output[:, topk:]
return topk_weights.to(torch.float32), topk_indices.to(torch.int32)
class HunyuanImage3SparseMoeBlock(HunYuanSparseMoeBlock):
"""MoE block with FP32 routing for byte-level alignment with HF.
The reference ``modeling_hunyuan_image_3.py`` runs the router in fp32:
- ``HunyuanTopKGate.wg`` is constructed as ``nn.Linear(..., dtype=torch.float32)``
and ``hidden_states`` is cast to fp32 before the matmul (line 1114-1116).
- ``HunyuanMoE.forward`` wraps the gate call in
``with torch.autocast('cuda', enabled=False):`` to defeat any AMP cast
(line 1204-1205), then calls ``easy_topk`` which does
``F.softmax`` → ``torch.topk`` → divide by
``torch.clamp(weight_sums, min=1e-8)`` → cast back to bf16, all in fp32
(line 1132-1139, 1206-1207).
vLLM's stock ``HunYuanSparseMoeBlock`` instead builds the gate as a
default-dtype ``ReplicatedLinear`` (bf16) and lets ``SharedFusedMoE``'s
``topk_softmax`` CUDA op consume bf16 logits, which can flip top-k
boundary decisions vs HF on close routing scores. With ``num_experts=64``,
``top_k=8`` per layer × 32 MoE layers, even a small per-token flip rate
cascades into divergent expert outputs and KV-cache state, eventually
flipping the top-1 decoded token.
This subclass:
1. Replaces ``self.gate`` with a fp32 ``ReplicatedLinear``.
2. Replaces ``self.experts`` with a ``SharedFusedMoE`` whose routing is a
no-op unpack of our pre-computed (topk_weights, topk_indices) — the
fp32 softmax/topk/renormalize is done in :meth:`forward` here, exactly
mirroring HF's ``easy_topk`` math (including ``clamp(min=1e-8)``).
"""
def __init__(
self,
config,
quant_config=None,
layer_id: int = -1,
prefix: str = "",
enable_eplb: bool = False,
) -> None:
# Bypass ``HunYuanSparseMoeBlock.__init__`` — it would build a wasteful
# bf16 gate + a stub ``SharedFusedMoE`` we'd then have to del+recreate
# (which trips ``ValueError: Duplicate layer name`` because the stub
# already registered itself in ``compilation_config.static_forward_context``).
# Instead, set up ``nn.Module`` ourselves and construct the fp32 gate
# + ``custom_routing_function``-driven ``SharedFusedMoE`` directly,
# mirroring the parent's structure 1:1 except for the routing dtype.
nn.Module.__init__(self)
self.tp_size = get_tensor_model_parallel_world_size()
self.ep_group = get_ep_group().device_group
self.ep_rank = get_ep_group().rank_in_group
self.ep_size = self.ep_group.size()
self.n_routed_experts = config.num_experts
if self.tp_size > config.num_experts:
raise ValueError(
f"Tensor parallel size {self.tp_size} is greater than the number of experts {config.num_experts}."
)
if isinstance(config.moe_topk, list):
top_k = config.moe_topk[layer_id]
else:
top_k = config.moe_topk
self.top_k = top_k
intermediate_size = config.intermediate_size
if config.moe_intermediate_size is not None:
intermediate_size = (
config.moe_intermediate_size
if isinstance(config.moe_intermediate_size, int)
else config.moe_intermediate_size[layer_id]
)
vllm_config = get_current_vllm_config()
eplb_config = vllm_config.parallel_config.eplb_config
self.enable_eplb = enable_eplb
self.n_logical_experts = self.n_routed_experts
self.n_redundant_experts = eplb_config.num_redundant_experts
self.n_physical_experts = self.n_logical_experts + self.n_redundant_experts
self.n_local_physical_experts = self.n_physical_experts // self.ep_size
self.physical_expert_start = self.ep_rank * self.n_local_physical_experts
self.physical_expert_end = self.physical_expert_start + self.n_local_physical_experts
# FP32 router gate (HF: ``wg = nn.Linear(..., dtype=torch.float32)``).
self.gate = ReplicatedLinear(
config.hidden_size,
config.num_experts,
bias=False,
quant_config=None,
params_dtype=torch.float32,
prefix=f"{prefix}.gate",
)
if config.use_mixed_mlp_moe > 0:
num_shared_expert = (
config.num_shared_expert[layer_id]
if isinstance(config.num_shared_expert, list)
else config.num_shared_expert
)
self.shared_mlp = HunYuanMLP(
hidden_size=config.hidden_size,
intermediate_size=config.intermediate_size * num_shared_expert,
hidden_act=config.hidden_act,
quant_config=quant_config,
reduce_results=False,
prefix=f"{prefix}.shared_mlp",
)
else:
self.shared_mlp = None
# Experts with our ``_hunyuan_image3_unpack_packed_topk`` custom
# routing — we feed it (topk_weights, topk_indices) packed into
# ``router_logits`` in ``forward()`` so the bf16 ``topk_softmax``
# CUDA op is bypassed entirely. ``renormalize=False`` because we
# already did clamp+divide in fp32 to match HF's
# ``topk_weight = topk_weight_1 / clamp(sum, min=1e-8)``.
self.experts = SharedFusedMoE(
shared_experts=self.shared_mlp,
num_experts=self.n_routed_experts,
top_k=top_k,
hidden_size=config.hidden_size,
intermediate_size=intermediate_size,
renormalize=False,
quant_config=quant_config,
prefix=f"{prefix}.experts",
enable_eplb=self.enable_eplb,
num_redundant_experts=self.n_redundant_experts,
custom_routing_function=_hunyuan_image3_unpack_packed_topk,
)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
orig_shape = hidden_states.shape
hidden_dim = hidden_states.shape[-1]
hidden_states = hidden_states.view(-1, hidden_dim)
# FP32 router (HF: `with torch.autocast('cuda', enabled=False): ...`
# plus `if self.wg.weight.dtype == torch.float32: hidden_states.float()`).
# ``self.gate.weight`` is fp32 (params_dtype=torch.float32), so the
# ReplicatedLinear matmul runs in fp32 once we cast the input.
router_logits, _ = self.gate(hidden_states.float())
# softmax + topk + clamp-divide renormalization, all in fp32 — matches
# ``HunyuanTopKGate.easy_topk`` exactly.
gates = torch.softmax(router_logits, dim=-1, dtype=torch.float32)
topk_weights, topk_indices = torch.topk(gates, self.top_k, dim=-1)
weight_sums = topk_weights.sum(dim=-1, keepdim=True)
topk_weights = topk_weights / weight_sums.clamp(min=1e-8)
# Cast topk weights to model dtype for the expert MLP combine.
# HF: ``topk_weights = topk_weights.to(hidden_states.dtype)`` (line 1207).
topk_weights = topk_weights.to(hidden_states.dtype)
# Pack (weights, indices) into the ``router_logits`` slot so
# ``_hunyuan_image3_unpack_packed_topk`` can pull them back out
# inside ``SharedFusedMoE``. Both halves are stored as fp32 for
# transport — the indices get cast back to int32 on unpack.
packed_routing = torch.cat([topk_weights.float(), topk_indices.to(torch.float32)], dim=-1)
# vllm 0.20+ FusedMoE merges shared-experts internally and runs the
# TP all-reduce inside its forward (we no longer pass
# `reduce_results=False`). The tuple `(routed, shared)` return shape
# from the legacy SharedFusedMoE is gone; the result is the
# already-combined, already-reduced tensor.
final_hidden_states = self.experts(hidden_states=hidden_states, router_logits=packed_routing)
return final_hidden_states.view(orig_shape)
class HunyuanImage3RotaryEmbedding(nn.Module):
"""Custom interleaved 2D Rotary Embedding for HunyuanImage3.
@@ -1273,8 +1517,14 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
# For comprehension mode, block image generation tokens but allow
# text structure tokens (<think>, <answer>, etc.) so the model can
# follow its natural generation pattern. Stop tokens in YAML will
# terminate at </answer> or EOS.
# follow its natural generation pattern. The yaml stop_token_ids
# for i2t/t2t now includes </think> (128024) so the AR-only output
# terminates after the analysis section, matching HF's
# `bot_task="think"` behavior. Without that stop, the model
# continues into a recaption section even in comprehension mode
# (the stage-transition processor only fires in generation mode,
# but the instruct-tuned model writes recaption on its own from
# internal habit).
self._blocked_token_ids: set[int] = set()
if self._is_comprehension:
self._blocked_token_ids.update(
@@ -1306,6 +1556,75 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
self._eos_token_id: int = tokenizer.eos_token_id
self._replace_rotary_embeddings()
self._patch_moe_blocks()
def _patch_moe_blocks(self):
"""Replace stock ``HunYuanSparseMoeBlock`` instances with
:class:`HunyuanImage3SparseMoeBlock`, which routes in fp32 to match
the HF reference (``modeling_hunyuan_image_3.HunyuanMoE``).
Stock vLLM builds the router gate as a default-dtype (bf16)
``ReplicatedLinear`` and lets ``SharedFusedMoE``'s ``topk_softmax``
kernel consume bf16 logits, which is the largest deterministic
precision gap remaining vs HF after the prompt/preprocessing
alignment fixes. See ``HunyuanImage3SparseMoeBlock`` docstring for
the full rationale.
Must run before weight loading (still inside ``__init__``) so the
replacement gate's fp32 ``params_dtype`` is honored when the
checkpoint is loaded.
"""
if not _is_moe(self.config):
return
enable_eplb = getattr(self.vllm_config.parallel_config, "enable_eplb", False)
ccfg = self.vllm_config.compilation_config
replaced = 0
for layer_id, layer in enumerate(self.model.layers):
mlp = getattr(layer, "mlp", None)
if isinstance(mlp, HunYuanSparseMoeBlock) and not isinstance(mlp, HunyuanImage3SparseMoeBlock):
# Pop the OLD experts' registration from
# ``static_forward_context`` first — otherwise the new
# ``SharedFusedMoE`` built inside
# :class:`HunyuanImage3SparseMoeBlock` will trip
# ``ValueError: Duplicate layer name`` (see
# vllm/model_executor/layers/fused_moe/layer.py:327).
old_prefix = f"model.layers.{layer_id}.mlp.experts"
ccfg.static_forward_context.pop(old_prefix, None)
if old_prefix in ccfg.static_all_moe_layers:
ccfg.static_all_moe_layers.remove(old_prefix)
# Free the OLD MoE block's GPU buffers BEFORE allocating
# the replacement. The parent ``SharedFusedMoE`` pre-
# allocates the full ``[num_experts, ...]`` expert weight
# tensors at ``__init__`` (~750 MiB per layer per worker
# on this 80B model with TP=2), so without this drop we
# transiently double the MoE footprint and OOM near the
# gpu_memory_utilization cap.
layer.mlp = None
del mlp
gc.collect()
torch.cuda.empty_cache()
layer.mlp = HunyuanImage3SparseMoeBlock(
config=self.config,
quant_config=self.quant_config,
layer_id=layer_id,
prefix=f"model.layers.{layer_id}.mlp",
enable_eplb=enable_eplb,
)
replaced += 1
logger.info(
"Replaced %d HunYuanSparseMoeBlock layers with "
"HunyuanImage3SparseMoeBlock (fp32 router matching HF reference)",
replaced,
)
if replaced == 0:
logger.warning(
"HunyuanImage3: _patch_moe_blocks replaced 0 layers. "
"Routing will run in bf16 instead of fp32 — output will "
"diverge from the HF reference more than necessary. "
"Check that model.layers[*].mlp is HunYuanSparseMoeBlock."
)
def _replace_rotary_embeddings(self):
"""Replace vLLM's standard MRotaryEmbedding with the custom
@@ -1384,6 +1703,18 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
"""
config = self.vae.config
# Cast pixel input to model dtype here (at the encoder boundary)
# rather than inside HunyuanImage3Processor.process_image. This
# matches HF's path which keeps fp32 pixels in build_cond_images and
# only casts inside the VAE forward — preserving fp32 precision in
# the multimodal_data dict and minimizing precision drift vs HF.
# Verified by pixel-tensor diff: removing the early bf16 cast brings
# omni's vae_pixel_values byte-identical to HF's (within fp32 noise),
# whereas an early cast leaves a ~7e-4 mean-abs-diff bf16 quantization
# error on every element.
if images.dtype != self.vae.dtype:
images = images.to(dtype=self.vae.dtype)
vae_encode_result = self.vae.encode(images)
latents = vae_encode_result.latent_dist.sample()
@@ -1494,11 +1825,14 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
"Each image should have both VAE and ViT embeddings."
)
# Order per image: timestep -> VAE tokens -> ViT tokens
# Order per image: timestep -> VAE tokens -> ViT tokens.
# The <img> placeholder at the timestep slot (see _get_prompt_updates)
# gets its embedding replaced by `timestep_emb(0)` here, which is what
# HF achieves via instantiate_continuous_tokens at runtime.
combined_embeddings: list[torch.Tensor] = []
num_images = len(vae_token_embeddings)
for img_idx in range(num_images):
# 1. Timestep embedding
# 1. Timestep embedding (cond image timestep == 0)
timestep = torch.zeros((1,)).to(vit_embeddings.device).to(vit_embeddings.dtype)
timestep_emb = self._timestep_encode(timestep)
@@ -34,7 +34,7 @@ stage_args:
top_p: 0.95
top_k: 1024
max_tokens: 2048
stop_token_ids: [127957, 128026] # <|endoftext|>, </answer>
stop_token_ids: [127957, 128024, 128026] # <|endoftext|>, </think>, </answer>
detokenize: True
runtime:
@@ -35,7 +35,7 @@ stage_args:
top_p: 0.95
top_k: 1024
max_tokens: 2048
stop_token_ids: [127957, 128026] # <|endoftext|>, </answer>
stop_token_ids: [127957, 128024, 128026] # <|endoftext|>, </think>, </answer>
detokenize: True
runtime: