Feat/Add HunyuanImage-3.0-Instruct ar part support: (#2713)
Signed-off-by: TaffyOfficial <2324465096@qq.com> Co-authored-by: TaffyOfficial <2324465096@qq.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10 files changed
+816
-82
No files matched your search
@@ -0,0 +1,88 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
Prompt construction utilities for HunyuanImage-3.0-Instruct examples.
|
||||
|
||||
Wraps system_prompt.get_system_prompt() with task-aware presets so that
|
||||
examples and tests don't need to manually concatenate system prompts,
|
||||
<img>, <think>, and <recaption> tags.
|
||||
|
||||
Usage:
|
||||
from prompt_utils import build_prompt
|
||||
|
||||
# IT2I (image editing, think+recaption mode)
|
||||
prompt = build_prompt("Make the petals neon pink", task="it2i_think")
|
||||
|
||||
# I2T (image understanding)
|
||||
prompt = build_prompt("Describe the content of the picture.", task="i2t")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from vllm_omni.diffusion.models.hunyuan_image3.system_prompt import (
|
||||
get_system_prompt,
|
||||
)
|
||||
|
||||
# task → (sys_type, bot_task, trigger_tag)
|
||||
# trigger_tag: "<think>", "<recaption>", or None
|
||||
_TASK_PRESETS: dict[str, tuple[str, str | None, str | None]] = {
|
||||
# Pure text generation (text → text, no image)
|
||||
"t2t": ("en_unified", None, None),
|
||||
# Image understanding (image → text)
|
||||
"i2t": ("en_unified", None, None),
|
||||
# Image editing (image+text → image), think+recaption mode
|
||||
"it2i_think": ("en_unified", "think", "<think>"),
|
||||
# Image editing, recaption-only mode
|
||||
"it2i_recaption": ("en_unified", "recaption", "<recaption>"),
|
||||
# Text-to-image, think mode
|
||||
"t2i_think": ("en_unified", "think", "<think>"),
|
||||
# Text-to-image, recaption mode
|
||||
"t2i_recaption": ("en_unified", "recaption", "<recaption>"),
|
||||
# Text-to-image, vanilla (no CoT)
|
||||
"t2i_vanilla": ("en_vanilla", "image", None),
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(
|
||||
user_prompt: str,
|
||||
task: str = "it2i_think",
|
||||
sys_type: str | None = None,
|
||||
custom_system_prompt: str | None = None,
|
||||
) -> str:
|
||||
"""Build a complete HunyuanImage-3.0 prompt with auto-selected system
|
||||
prompt and mode trigger tags.
|
||||
|
||||
Args:
|
||||
user_prompt: The user's raw instruction or question.
|
||||
task: One of the preset task keys (see _TASK_PRESETS).
|
||||
sys_type: Override the preset's sys_type for get_system_prompt().
|
||||
custom_system_prompt: Custom system prompt text (used when
|
||||
sys_type="custom").
|
||||
|
||||
Returns:
|
||||
Fully formatted prompt string ready for Omni.generate().
|
||||
"""
|
||||
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)
|
||||
# Instruct conversation template: \n\nUser: ... \n\nAssistant:
|
||||
parts.append("\n\nUser: ")
|
||||
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)
|
||||
@@ -0,0 +1,190 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for HunyuanImage3 AR sampler logic (stage transitions,
|
||||
ratio restriction, comprehension blocking)."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Fake token IDs for testing (avoid importing the real model).
|
||||
END_OF_THINK = 100
|
||||
RECAPTION = 101
|
||||
END_OF_RECAPTION = 102
|
||||
ANSWER = 103
|
||||
BOI = 104
|
||||
SIZE_TOKEN = 105
|
||||
EOS = 106
|
||||
RATIO_START = 200
|
||||
RATIO_END = 210
|
||||
RATIO_OTHER_START = 220
|
||||
RATIO_OTHER_END = 223
|
||||
|
||||
|
||||
class FakeSamplerModel:
|
||||
"""Minimal stub that replicates the sampler-relevant attributes of
|
||||
HunyuanImage3ForConditionalGeneration without loading real weights."""
|
||||
|
||||
def __init__(self, *, is_comprehension: bool = False):
|
||||
self._is_comprehension = is_comprehension
|
||||
self._eos_token_id = EOS
|
||||
self._end_of_think_id = END_OF_THINK
|
||||
self._recaption_id = RECAPTION
|
||||
self._end_of_recaption_id = END_OF_RECAPTION
|
||||
self._answer_id = ANSWER
|
||||
self._mrope_boi_token_id = BOI
|
||||
self._size_token_id = SIZE_TOKEN
|
||||
self._start_ratio_id = RATIO_START
|
||||
self._end_ratio_id = RATIO_END
|
||||
self._ratio_other_slices = [(RATIO_OTHER_START, RATIO_OTHER_END + 1)]
|
||||
self._all_ratio_ids = set(range(RATIO_START, RATIO_END + 1))
|
||||
self._all_ratio_ids.update(range(RATIO_OTHER_START, RATIO_OTHER_END + 1))
|
||||
|
||||
self._stage_transitions: dict[int, list[int]] = {}
|
||||
if not is_comprehension:
|
||||
self._stage_transitions[END_OF_THINK] = [RECAPTION]
|
||||
self._stage_transitions[END_OF_RECAPTION] = [ANSWER, BOI, SIZE_TOKEN]
|
||||
|
||||
self._blocked_token_ids: set[int] = set()
|
||||
if is_comprehension:
|
||||
self._blocked_token_ids.update([BOI, SIZE_TOKEN])
|
||||
self._blocked_token_ids.update(self._all_ratio_ids)
|
||||
|
||||
# Bind the real methods from the model class.
|
||||
from vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3 import (
|
||||
HunyuanImage3ForConditionalGeneration as _Real,
|
||||
)
|
||||
|
||||
_get_forced_token = _Real._get_forced_token
|
||||
_apply_ratio_restriction = _Real._apply_ratio_restriction
|
||||
|
||||
|
||||
class TestGetForcedToken:
|
||||
"""Tests for the stateless _get_forced_token method."""
|
||||
|
||||
def setup_method(self):
|
||||
self.model = FakeSamplerModel(is_comprehension=False)
|
||||
|
||||
def test_no_trigger_returns_none(self):
|
||||
assert self.model._get_forced_token([1, 2, 3]) is None
|
||||
|
||||
def test_empty_history_returns_none(self):
|
||||
assert self.model._get_forced_token([]) is None
|
||||
|
||||
def test_end_of_think_forces_recaption(self):
|
||||
assert self.model._get_forced_token([END_OF_THINK]) == RECAPTION
|
||||
|
||||
def test_end_of_think_completed(self):
|
||||
assert self.model._get_forced_token([END_OF_THINK, RECAPTION]) is None
|
||||
|
||||
def test_end_of_recaption_forces_answer(self):
|
||||
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION]
|
||||
assert self.model._get_forced_token(tokens) == ANSWER
|
||||
|
||||
def test_end_of_recaption_forces_boi_after_answer(self):
|
||||
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER]
|
||||
assert self.model._get_forced_token(tokens) == BOI
|
||||
|
||||
def test_end_of_recaption_forces_size_after_boi(self):
|
||||
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER, BOI]
|
||||
assert self.model._get_forced_token(tokens) == SIZE_TOKEN
|
||||
|
||||
def test_full_sequence_complete(self):
|
||||
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER, BOI, SIZE_TOKEN]
|
||||
assert self.model._get_forced_token(tokens) is None
|
||||
|
||||
def test_diverged_history_returns_none(self):
|
||||
tokens = [END_OF_RECAPTION, 999] # 999 != ANSWER
|
||||
assert self.model._get_forced_token(tokens) is None
|
||||
|
||||
def test_later_trigger_takes_precedence(self):
|
||||
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION]
|
||||
assert self.model._get_forced_token(tokens) == ANSWER
|
||||
|
||||
def test_trigger_with_extra_tokens_before(self):
|
||||
tokens = [1, 2, 3, END_OF_THINK]
|
||||
assert self.model._get_forced_token(tokens) == RECAPTION
|
||||
|
||||
|
||||
class TestComprehensionBlocking:
|
||||
"""Tests for comprehension mode token blocking."""
|
||||
|
||||
def test_blocked_tokens_masked(self):
|
||||
model = FakeSamplerModel(is_comprehension=True)
|
||||
vocab_size = 300
|
||||
logits = torch.zeros(1, vocab_size)
|
||||
logits[0, BOI] = 5.0
|
||||
logits[0, SIZE_TOKEN] = 3.0
|
||||
logits[0, RATIO_START] = 2.0
|
||||
min_score = torch.finfo(logits.dtype).min
|
||||
|
||||
for tid in model._blocked_token_ids:
|
||||
if tid < vocab_size:
|
||||
logits[0, tid] = min_score
|
||||
|
||||
assert logits[0, BOI].item() == min_score
|
||||
assert logits[0, SIZE_TOKEN].item() == min_score
|
||||
assert logits[0, RATIO_START].item() == min_score
|
||||
|
||||
def test_non_blocked_tokens_preserved(self):
|
||||
model = FakeSamplerModel(is_comprehension=True)
|
||||
vocab_size = 300
|
||||
logits = torch.zeros(1, vocab_size)
|
||||
logits[0, 50] = 7.0
|
||||
min_score = torch.finfo(logits.dtype).min
|
||||
|
||||
for tid in model._blocked_token_ids:
|
||||
if tid < vocab_size:
|
||||
logits[0, tid] = min_score
|
||||
|
||||
assert logits[0, 50].item() == 7.0
|
||||
|
||||
|
||||
class TestRatioRestriction:
|
||||
"""Tests for _apply_ratio_restriction (greedy: only argmax ratio survives)."""
|
||||
|
||||
def test_greedy_selects_single_ratio_token(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
vocab_size = 300
|
||||
logits = torch.zeros(1, vocab_size)
|
||||
logits[0, RATIO_START + 3] = 10.0
|
||||
logits[0, RATIO_START + 1] = 5.0
|
||||
logits[0, 50] = 20.0 # non-ratio, should be masked
|
||||
min_score = torch.finfo(logits.dtype).min
|
||||
|
||||
model._apply_ratio_restriction(logits, 0, min_score)
|
||||
|
||||
assert logits[0, RATIO_START + 3].item() == 0
|
||||
assert logits[0, RATIO_START + 1].item() == min_score
|
||||
assert logits[0, 50].item() == min_score
|
||||
|
||||
def test_extra_ratio_slices_considered(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
vocab_size = 300
|
||||
logits = torch.zeros(1, vocab_size)
|
||||
logits[0, RATIO_OTHER_START] = 15.0
|
||||
logits[0, RATIO_START] = 5.0
|
||||
min_score = torch.finfo(logits.dtype).min
|
||||
|
||||
model._apply_ratio_restriction(logits, 0, min_score)
|
||||
|
||||
assert logits[0, RATIO_OTHER_START].item() == 0
|
||||
assert logits[0, RATIO_START].item() == min_score
|
||||
|
||||
|
||||
class TestForceEosAfterRatio:
|
||||
"""Tests that a ratio token as last_token forces EOS."""
|
||||
|
||||
def test_ratio_token_forces_eos(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
vocab_size = 300
|
||||
logits = torch.randn(1, vocab_size)
|
||||
min_score = torch.finfo(logits.dtype).min
|
||||
|
||||
logits[0].fill_(min_score)
|
||||
logits[0, model._eos_token_id] = 0
|
||||
|
||||
assert logits[0, EOS].item() == 0
|
||||
non_eos_max = logits[0, :EOS].max().item()
|
||||
assert non_eos_max == min_score
|
||||
@@ -17,7 +17,7 @@ PROMPT = "A brown and white dog is running on the grass"
|
||||
MODEL_NAME = "tencent/HunyuanImage-3.0"
|
||||
LOCAL_CLIP_PATH = "openai/clip-vit-base-patch32"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
STAGE_CONFIG_PATH = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "hunyuan_image3_moe.yaml"
|
||||
STAGE_CONFIG_PATH = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "hunyuan_image3_t2i.yaml"
|
||||
|
||||
pytestmark = [pytest.mark.advanced_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@@ -77,7 +77,9 @@ from vllm.multimodal.processing import (
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.transformers_utils.tokenizer import get_tokenizer
|
||||
from vllm.utils.tensor_schema import TensorSchema
|
||||
from vllm.v1.outputs import SamplerOutput
|
||||
from vllm.v1.sample.metadata import SamplingMetadata
|
||||
from vllm.v1.sample.sampler import Sampler
|
||||
|
||||
from vllm_omni.model_executor.models.hunyuan_image3.autoencoder_kl_3d import AutoencoderKLConv3D
|
||||
from vllm_omni.model_executor.models.hunyuan_image3.siglip2 import LightProjector, Siglip2VisionTransformer
|
||||
@@ -175,8 +177,11 @@ class HunyuanModel(HunYuanModel):
|
||||
return True
|
||||
return False
|
||||
|
||||
skipped_unexpected: set[str] = set()
|
||||
|
||||
for name, loaded_weight in weights:
|
||||
if contains_unexpected_keyword(name, unexpected_keywords):
|
||||
skipped_unexpected.add(name)
|
||||
continue
|
||||
|
||||
if "rotary_emb.inv_freq" in name:
|
||||
@@ -362,6 +367,17 @@ class HunyuanModel(HunYuanModel):
|
||||
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
weight_loader(param, loaded_weight)
|
||||
loaded_params.add(name)
|
||||
|
||||
if skipped_unexpected:
|
||||
logger.warning_once(
|
||||
"Skipped %d weights matching unexpected_keywords "
|
||||
"(e.g. vae, vision_model, patch_embed, timestep_emb). "
|
||||
"If upstream renamed components, these may be silently "
|
||||
"lost. Skipped names: %s",
|
||||
len(skipped_unexpected),
|
||||
sorted(skipped_unexpected)[:10],
|
||||
)
|
||||
|
||||
return loaded_params
|
||||
|
||||
|
||||
@@ -1149,6 +1165,8 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
|
||||
|
||||
HunyuanImage3Inputs: TypeAlias = HunyuanImage3PixelInputs
|
||||
|
||||
prefer_model_sampler = True
|
||||
|
||||
packed_modules_mapping = {
|
||||
"qkv_proj": [
|
||||
"q_proj",
|
||||
@@ -1199,6 +1217,10 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
|
||||
else:
|
||||
self.lm_head = PPMissingLayer()
|
||||
|
||||
# --- AR-stage components ---
|
||||
# These are needed for image encoding in the AR stage.
|
||||
# If a future text-only stage is added, gate on vllm_config.model_config.model_stage.
|
||||
|
||||
# vae
|
||||
self.vae = AutoencoderKLConv3D.from_config(config.vae)
|
||||
self.patch_embed = UNetDown(
|
||||
@@ -1226,6 +1248,63 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
|
||||
self._mrope_joint_img_sep_token_id = tokenizer.convert_tokens_to_ids("<joint_img_sep>")
|
||||
self._mrope_max_num_patches = config.vit_processor.get("max_num_patches", 729)
|
||||
|
||||
# Special token IDs for logits processors (stage transitions).
|
||||
# These mirror the official tokenization_hunyuan_image_3.py setup.
|
||||
self._end_of_think_id = tokenizer.convert_tokens_to_ids("</think>")
|
||||
self._recaption_id = tokenizer.convert_tokens_to_ids("<recaption>")
|
||||
self._end_of_recaption_id = tokenizer.convert_tokens_to_ids("</recaption>")
|
||||
self._answer_id = tokenizer.convert_tokens_to_ids("<answer>")
|
||||
self._end_of_answer_id = tokenizer.convert_tokens_to_ids("</answer>")
|
||||
image_base_size = getattr(config, "image_base_size", 1024)
|
||||
self._size_token_id = tokenizer.convert_tokens_to_ids(f"<img_size_{image_base_size}>")
|
||||
self._start_ratio_id = tokenizer.convert_tokens_to_ids("<img_ratio_0>")
|
||||
self._end_ratio_id = tokenizer.convert_tokens_to_ids("<img_ratio_32>")
|
||||
ratio_33 = tokenizer.convert_tokens_to_ids("<img_ratio_33>")
|
||||
ratio_36 = tokenizer.convert_tokens_to_ids("<img_ratio_36>")
|
||||
self._ratio_other_slices = [(ratio_33, ratio_36 + 1)]
|
||||
# Build the full set of ratio token IDs for use as stop tokens.
|
||||
self._all_ratio_ids = set(range(self._start_ratio_id, self._end_ratio_id + 1))
|
||||
for s, e in self._ratio_other_slices:
|
||||
self._all_ratio_ids.update(range(s, e))
|
||||
|
||||
# Determine mode: comprehension (I2T/T2T) vs generation (IT2I/T2I).
|
||||
engine_output_type = getattr(vllm_config.model_config, "engine_output_type", None)
|
||||
self._is_comprehension = engine_output_type in (None, "text")
|
||||
|
||||
# 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.
|
||||
self._blocked_token_ids: set[int] = set()
|
||||
if self._is_comprehension:
|
||||
self._blocked_token_ids.update(
|
||||
[
|
||||
self._mrope_boi_token_id, # <boi>
|
||||
self._mrope_eoi_token_id, # <eoi>
|
||||
self._size_token_id, # <img_size_*>
|
||||
]
|
||||
)
|
||||
self._blocked_token_ids.update(self._all_ratio_ids)
|
||||
|
||||
# For generation mode, build stage transition map.
|
||||
# Official logic: </think> → [<recaption>],
|
||||
# </recaption> → [<answer>, <boi>, <img_size_*>]
|
||||
# After <img_size_*>, restrict vocab to ratio tokens only.
|
||||
# Stage-transition forced sequences, keyed by trigger token.
|
||||
self._stage_transitions: dict[int, list[int]] = {}
|
||||
if not self._is_comprehension:
|
||||
self._stage_transitions[self._end_of_think_id] = [
|
||||
self._recaption_id,
|
||||
]
|
||||
self._stage_transitions[self._end_of_recaption_id] = [
|
||||
self._answer_id,
|
||||
self._mrope_boi_token_id,
|
||||
self._size_token_id,
|
||||
]
|
||||
|
||||
self._sampler: Sampler | None = None
|
||||
self._eos_token_id: int = tokenizer.eos_token_id
|
||||
|
||||
self._replace_rotary_embeddings()
|
||||
|
||||
def _replace_rotary_embeddings(self):
|
||||
@@ -1257,6 +1336,12 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
|
||||
head_dim,
|
||||
rope_theta,
|
||||
)
|
||||
if replaced == 0:
|
||||
raise RuntimeError(
|
||||
"HunyuanImage3: _replace_rotary_embeddings replaced 0 layers. "
|
||||
"The custom interleaved 2D mRoPE is not active — model outputs "
|
||||
"will be incorrect. Check that model.layers[*].self_attn.rotary_emb exists."
|
||||
)
|
||||
|
||||
def _parse_and_validate_image_input(
|
||||
self,
|
||||
@@ -1274,6 +1359,10 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
|
||||
if vit_pixel_values is None or vae_pixel_values is None:
|
||||
return None
|
||||
|
||||
# Handle empty batch (e.g., during profiling with 0 images / T2T mode)
|
||||
if vit_pixel_values.numel() == 0 or vae_pixel_values.numel() == 0:
|
||||
return None
|
||||
|
||||
return HunyuanImage3PixelInputs(
|
||||
type="pixel_values",
|
||||
pixel_values={
|
||||
@@ -1472,6 +1561,112 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo
|
||||
logits = self.logits_processor(self.lm_head, hidden_states)
|
||||
return logits
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Custom sampler — applies HunyuanImage3-specific logits processors
|
||||
# before the standard sampling step.
|
||||
#
|
||||
# Comprehension (I2T / T2T):
|
||||
# Block generation-specific special tokens so sampling can't
|
||||
# accidentally produce <answer>, <boi>, ratio tokens, etc.
|
||||
#
|
||||
# Generation (IT2I / T2I think):
|
||||
# 1. _StageTransitionLogitsProcessor — force token sequences at
|
||||
# transition boundaries (</think> → <recaption>, etc.)
|
||||
# 2. _ConditionalSliceVocabLogitsProcessor — after <img_size_*>,
|
||||
# restrict vocab to ratio tokens only (greedy).
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def sample(
|
||||
self,
|
||||
logits: torch.Tensor,
|
||||
sampling_metadata: SamplingMetadata,
|
||||
) -> SamplerOutput | None:
|
||||
if logits is None or logits.numel() == 0:
|
||||
return None
|
||||
|
||||
if self._sampler is None:
|
||||
self._sampler = Sampler()
|
||||
|
||||
min_score = torch.finfo(logits.dtype).min
|
||||
|
||||
assert logits.shape[0] == 1, f"HunyuanImage3 sampler requires max_num_seqs=1, got batch size {logits.shape[0]}"
|
||||
|
||||
for req_idx in range(logits.shape[0]):
|
||||
decoded_tokens: list[int] = (
|
||||
sampling_metadata.output_token_ids[req_idx] if req_idx < len(sampling_metadata.output_token_ids) else []
|
||||
)
|
||||
last_token = decoded_tokens[-1] if decoded_tokens else -1
|
||||
|
||||
if self._is_comprehension:
|
||||
for tid in self._blocked_token_ids:
|
||||
logits[req_idx, tid] = min_score
|
||||
else:
|
||||
forced = self._get_forced_token(decoded_tokens)
|
||||
if forced is not None:
|
||||
logits[req_idx].fill_(min_score)
|
||||
logits[req_idx, forced] = 0
|
||||
elif last_token == self._size_token_id:
|
||||
self._apply_ratio_restriction(logits, req_idx, min_score)
|
||||
elif last_token in self._all_ratio_ids:
|
||||
logits[req_idx].fill_(min_score)
|
||||
logits[req_idx, self._eos_token_id] = 0
|
||||
|
||||
return self._sampler(logits=logits, sampling_metadata=sampling_metadata)
|
||||
|
||||
def _get_forced_token(self, decoded_tokens: list[int]) -> int | None:
|
||||
"""Derive the next forced token from output history (stateless).
|
||||
|
||||
Scans decoded_tokens backwards for the most recent trigger token,
|
||||
then prefix-matches the forced sequence against what followed.
|
||||
Returns the next token to force, or None if the sequence is complete
|
||||
or history has diverged from the expected forced sequence.
|
||||
"""
|
||||
for i in range(len(decoded_tokens) - 1, -1, -1):
|
||||
trigger = decoded_tokens[i]
|
||||
if trigger not in self._stage_transitions:
|
||||
continue
|
||||
|
||||
forced_seq = self._stage_transitions[trigger]
|
||||
emitted = decoded_tokens[i + 1 :]
|
||||
|
||||
matched = 0
|
||||
for expected, actual in zip(forced_seq, emitted):
|
||||
if actual != expected:
|
||||
# History diverged from the expected forced sequence.
|
||||
# Stop applying transition forcing for safety.
|
||||
return None
|
||||
matched += 1
|
||||
|
||||
if matched < len(forced_seq):
|
||||
return forced_seq[matched]
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _apply_ratio_restriction(
|
||||
self,
|
||||
logits: torch.Tensor,
|
||||
req_idx: int,
|
||||
min_score: float,
|
||||
) -> None:
|
||||
"""Port of official _ConditionalSliceVocabLogitsProcessor.__call__.
|
||||
|
||||
After the size token, only allow ratio tokens and pick greedily.
|
||||
"""
|
||||
original = logits[req_idx].clone()
|
||||
logits[req_idx].fill_(min_score)
|
||||
# Allow primary ratio range.
|
||||
logits[req_idx, self._start_ratio_id : self._end_ratio_id + 1] = original[
|
||||
self._start_ratio_id : self._end_ratio_id + 1
|
||||
]
|
||||
# Allow extra ratio slices.
|
||||
for s, e in self._ratio_other_slices:
|
||||
logits[req_idx, s:e] = original[s:e]
|
||||
# Force greedy: keep only the argmax.
|
||||
max_id = logits[req_idx].argmax().item()
|
||||
logits[req_idx].fill_(min_score)
|
||||
logits[req_idx, max_id] = 0
|
||||
|
||||
def make_empty_intermediate_tensors(
|
||||
self, batch_size: int, dtype: torch.dtype, device: torch.device
|
||||
) -> IntermediateTensors:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Stage config for HunyuanImage-3.0 Image-to-Text (I2T / image understanding).
|
||||
# Single LLM stage: AR model reads image + text prompt, generates text output.
|
||||
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0,1,2,3"
|
||||
max_batch_size: 1
|
||||
requires_multimodal_data: true
|
||||
engine_args:
|
||||
model_stage: AR
|
||||
max_num_seqs: 1
|
||||
model_arch: HunyuanImage3ForCausalMM
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
gpu_memory_utilization: 0.95
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 32768
|
||||
tensor_parallel_size: 4
|
||||
pipeline_parallel_size: 1
|
||||
hf_overrides:
|
||||
rope_parameters:
|
||||
mrope_section: [0, 32, 32]
|
||||
rope_type: default
|
||||
is_comprehension: true
|
||||
final_output: true
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 0.95
|
||||
top_k: 1024
|
||||
max_tokens: 2048
|
||||
stop_token_ids: [127957, 128026] # <|endoftext|>, </answer>
|
||||
detokenize: True
|
||||
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1
|
||||
max_inflight: 1
|
||||
@@ -0,0 +1,78 @@
|
||||
# Stage config for HunyuanImage-3.0 Image+Text-to-Image (image editing).
|
||||
# Stage 0: AR (HunyuanImage3ForConditionalGeneration) — reads (image, text), emits latent tokens
|
||||
# Stage 1: Diffusion (HunyuanImage3Pipeline / DiT + VAE) — denoise + decode latents → image
|
||||
|
||||
stage_args:
|
||||
# Stage 0: AR Model
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0,1,2,3"
|
||||
max_batch_size: 1
|
||||
requires_multimodal_data: true # AR needs the original image
|
||||
engine_args:
|
||||
model_stage: AR
|
||||
model_arch: HunyuanImage3ForCausalMM
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
gpu_memory_utilization: 0.95
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
engine_output_type: latent # AR outputs latent for DiT
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 32768
|
||||
tensor_parallel_size: 4
|
||||
pipeline_parallel_size: 1
|
||||
hf_overrides:
|
||||
rope_parameters:
|
||||
mrope_section: [0, 32, 32]
|
||||
rope_type: default
|
||||
is_comprehension: false # Generation task, not comprehension
|
||||
final_output: false # AR is not the final output
|
||||
default_sampling_params:
|
||||
temperature: 0.6
|
||||
top_p: 0.95
|
||||
top_k: 1024
|
||||
max_tokens: 4096
|
||||
stop_token_ids: [127957] # <|endoftext|>
|
||||
detokenize: false
|
||||
|
||||
# Stage 1: Diffusion (DiT + VAE)
|
||||
# Receives latents from AR stage, performs denoising + VAE decode
|
||||
- stage_id: 1
|
||||
stage_type: diffusion
|
||||
runtime:
|
||||
process: true
|
||||
devices: "4,5,6,7"
|
||||
max_batch_size: 1
|
||||
requires_multimodal_data: true # May need condition images
|
||||
engine_args:
|
||||
model_stage: dit
|
||||
model_arch: HunyuanImage3ForCausalMM
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
distributed_executor_backend: "mp"
|
||||
parallel_config:
|
||||
tensor_parallel_size: 4
|
||||
enable_expert_parallel: true
|
||||
omni_kv_config:
|
||||
need_recv_cache: true
|
||||
engine_input_source: [0] # Input from AR stage
|
||||
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.hunyuan_image3.ar2diffusion
|
||||
final_output: true
|
||||
final_output_type: image
|
||||
default_sampling_params:
|
||||
num_inference_steps: 50
|
||||
guidance_scale: 2.5
|
||||
|
||||
# Top-level runtime config
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1 # Trigger downstream only after full upstream completion
|
||||
max_inflight: 1 # Process serially within each stage
|
||||
edges:
|
||||
- from: 0 # AR → Diffusion
|
||||
to: 1
|
||||
window_size: -1
|
||||
@@ -1,81 +0,0 @@
|
||||
# Stage config for running Hunyuan-Image3.0 for multi-stage omni runtime.
|
||||
# Stage 0: AR Model (vLLM implementation)
|
||||
|
||||
# The following config has been verified on 8x L40S-48G GPU.
|
||||
modes:
|
||||
- mode: text-to-image
|
||||
stages: [1]
|
||||
- mode: image-to-text
|
||||
stages: [0]
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm # Use llm stage type for AR stages
|
||||
runtime:
|
||||
process: true # Run this stage in a separate process
|
||||
devices: "0,1,2,3,4,5,6,7" # Visible devices for this stage (CUDA_VISIBLE_DEVICES/torch.cuda.set_device)
|
||||
engine_args:
|
||||
model_stage: AR
|
||||
max_num_seqs: 1
|
||||
model_arch: HunyuanImage3ForCausalMM
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
gpu_memory_utilization: 0.3
|
||||
enforce_eager: true # Now we only support eager mode
|
||||
trust_remote_code: true
|
||||
engine_output_type: latent
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 32768
|
||||
tensor_parallel_size: 8
|
||||
pipeline_parallel_size: 1
|
||||
hf_overrides:
|
||||
rope_parameters:
|
||||
mrope_section: [0, 32, 32]
|
||||
rope_type: default
|
||||
is_comprehension: true
|
||||
final_output: true
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 2048
|
||||
seed: 42
|
||||
detokenize: True
|
||||
repetition_penalty: 1.1
|
||||
- stage_id: 1
|
||||
stage_type: diffusion
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0,1,2,3,4,5,6,7"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
model_stage: diffusion
|
||||
enforce_eager: true
|
||||
distributed_executor_backend: "mp"
|
||||
vae_use_slicing: false
|
||||
vae_use_tiling: false
|
||||
cache_backend: null
|
||||
cache_config: null
|
||||
enable_cache_dit_summary: false
|
||||
parallel_config:
|
||||
pipeline_parallel_size: 1
|
||||
data_parallel_size: 1
|
||||
tensor_parallel_size: 8
|
||||
enable_expert_parallel: false
|
||||
sequence_parallel_size: 1
|
||||
ulysses_degree: 1
|
||||
ring_degree: 1
|
||||
cfg_parallel_size: 1
|
||||
vae_patch_parallel_size: 1
|
||||
use_hsdp: false
|
||||
hsdp_shard_size: -1
|
||||
hsdp_replicate_size: 1
|
||||
final_output: true
|
||||
final_output_type: image
|
||||
|
||||
# Top-level runtime config (concise): default windows and stage edges
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1 # Simplified: trigger downstream only after full upstream completion
|
||||
max_inflight: 1 # Simplified: process serially within each stage
|
||||
@@ -0,0 +1,45 @@
|
||||
# Stage config for HunyuanImage-3.0 Text-to-Text (T2T / pure text generation).
|
||||
# Single LLM stage: AR model reads text prompt only, generates text output.
|
||||
# Sampling params aligned with official generation_config.json.
|
||||
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
runtime:
|
||||
process: true
|
||||
devices: "0,1,2,3"
|
||||
max_batch_size: 1
|
||||
requires_multimodal_data: false
|
||||
engine_args:
|
||||
model_stage: AR
|
||||
max_num_seqs: 1
|
||||
model_arch: HunyuanImage3ForCausalMM
|
||||
worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
gpu_memory_utilization: 0.95
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 32768
|
||||
tensor_parallel_size: 4
|
||||
pipeline_parallel_size: 1
|
||||
hf_overrides:
|
||||
rope_parameters:
|
||||
mrope_section: [0, 32, 32]
|
||||
rope_type: default
|
||||
is_comprehension: true
|
||||
final_output: true
|
||||
final_output_type: text
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 0.95
|
||||
top_k: 1024
|
||||
max_tokens: 2048
|
||||
stop_token_ids: [127957, 128026] # <|endoftext|>, </answer>
|
||||
detokenize: True
|
||||
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1
|
||||
max_inflight: 1
|
||||
@@ -0,0 +1,123 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Stage input processor for HunyuanImage3: AR → Diffusion transition.
|
||||
|
||||
In IT2I (image editing) mode:
|
||||
- Stage 0 (AR) receives (image + edit instruction), generates CoT/latent tokens
|
||||
- Stage 1 (DiT) receives the AR output + original image, denoises → edited image
|
||||
|
||||
The ar2diffusion function bridges these two stages, following the same
|
||||
signature pattern as glm_image.ar2diffusion.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.inputs import TextPrompt
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def ar2diffusion(
|
||||
stage_list: list[Any],
|
||||
engine_input_source: list[int],
|
||||
prompt: OmniTokensPrompt | TextPrompt | list | None = None,
|
||||
requires_multimodal_data: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Process AR stage outputs to create Diffusion stage inputs.
|
||||
|
||||
Args:
|
||||
stage_list: List of stage clients (set by orchestrator).
|
||||
engine_input_source: List of source stage IDs (from YAML).
|
||||
prompt: Original user prompt (may contain multimodal data).
|
||||
requires_multimodal_data: Whether to forward multimodal data.
|
||||
|
||||
Returns:
|
||||
List of dicts, each consumable by the HunyuanImage3 diffusion pipeline.
|
||||
"""
|
||||
if not engine_input_source:
|
||||
raise ValueError("engine_input_source cannot be empty")
|
||||
|
||||
source_stage_id = engine_input_source[0]
|
||||
if source_stage_id >= len(stage_list):
|
||||
raise IndexError(f"Invalid source stage_id: {source_stage_id}")
|
||||
|
||||
if stage_list[source_stage_id].engine_outputs is None:
|
||||
raise RuntimeError(f"Stage {source_stage_id} has no outputs yet")
|
||||
|
||||
ar_outputs = stage_list[source_stage_id].engine_outputs
|
||||
diffusion_inputs = []
|
||||
|
||||
# Normalize prompt to list
|
||||
if not isinstance(prompt, list):
|
||||
prompt = [prompt] if prompt is not None else [{}]
|
||||
|
||||
for i, ar_output in enumerate(ar_outputs):
|
||||
output = ar_output.outputs[0]
|
||||
generated_token_ids = output.token_ids
|
||||
generated_text = getattr(output, "text", "") or ""
|
||||
|
||||
# Get original prompt info
|
||||
original_prompt = prompt[i] if i < len(prompt) else {}
|
||||
if isinstance(original_prompt, dict):
|
||||
pass
|
||||
elif hasattr(original_prompt, "_asdict"):
|
||||
original_prompt = original_prompt._asdict()
|
||||
elif hasattr(original_prompt, "__dict__"):
|
||||
original_prompt = vars(original_prompt)
|
||||
else:
|
||||
original_prompt = {}
|
||||
|
||||
height = original_prompt.get("height", 1024)
|
||||
width = original_prompt.get("width", 1024)
|
||||
text_prompt = original_prompt.get("prompt", "")
|
||||
|
||||
logger.info(
|
||||
"[ar2diffusion] Request %d: AR generated %d tokens, text length=%d, target size=%dx%d",
|
||||
i,
|
||||
len(generated_token_ids),
|
||||
len(generated_text),
|
||||
height,
|
||||
width,
|
||||
)
|
||||
|
||||
token_tensor = torch.tensor(generated_token_ids, dtype=torch.long)
|
||||
|
||||
diffusion_input: dict[str, Any] = {
|
||||
"prompt": text_prompt,
|
||||
"height": height,
|
||||
"width": width,
|
||||
"extra": {
|
||||
"ar_token_ids": token_tensor,
|
||||
"ar_generated_text": generated_text,
|
||||
},
|
||||
}
|
||||
|
||||
# Forward multimodal data (original image for IT2I conditioning)
|
||||
mm_data = original_prompt.get("multi_modal_data")
|
||||
if mm_data:
|
||||
pil_image = mm_data.get("image")
|
||||
if pil_image is None:
|
||||
images = mm_data.get("images")
|
||||
if images:
|
||||
pil_image = images[0] if isinstance(images, list) else images
|
||||
if pil_image is not None:
|
||||
diffusion_input["pil_image"] = pil_image
|
||||
|
||||
# Forward multimodal output from AR (if any)
|
||||
if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output:
|
||||
mm_output = ar_output.multimodal_output
|
||||
if isinstance(mm_output, dict):
|
||||
diffusion_input["extra"]["ar_multimodal_output"] = mm_output
|
||||
|
||||
# Forward sampling params
|
||||
for key in ["seed", "num_inference_steps", "guidance_scale", "negative_prompt"]:
|
||||
if key in original_prompt:
|
||||
diffusion_input[key] = original_prompt[key]
|
||||
|
||||
diffusion_inputs.append(diffusion_input)
|
||||
|
||||
return diffusion_inputs
|
||||
@@ -1,6 +1,8 @@
|
||||
import sys
|
||||
from functools import cached_property
|
||||
|
||||
from aenum import extend_enum
|
||||
from vllm.config import ModelConfig as _OriginalModelConfig
|
||||
from vllm.inputs import TokensPrompt as _OriginalTokensPrompt
|
||||
from vllm.model_executor.layers.rotary_embedding import (
|
||||
MRotaryEmbedding as _OriginalMRotaryEmbedding,
|
||||
@@ -17,6 +19,56 @@ from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
from vllm_omni.model_executor.layers.rotary_embedding import OmniMRotaryEmbedding
|
||||
from vllm_omni.request import OmniRequest
|
||||
|
||||
# =============================================================================
|
||||
# Patch ModelConfig.is_mm_prefix_lm to support omni-specific models
|
||||
# =============================================================================
|
||||
# WHY: HunyuanImage-3.0 requires bidirectional attention for image tokens
|
||||
# (cond_token_attn_type: "joint_full" in config.json). vLLM gates this on
|
||||
# is_mm_prefix_lm, which checks an internal MM_PREFIX_LM_MODELS list that
|
||||
# does not include "hunyuan_image_3_moe" (the upstream HF model_type).
|
||||
#
|
||||
# WHY NOT model-level: is_mm_prefix_lm is checked in vLLM core (scheduler,
|
||||
# attention backend selection) before model code runs — no model-level hook.
|
||||
#
|
||||
# SCOPE: Only affects model_type in _OMNI_MM_PREFIX_LM_MODELS (currently
|
||||
# just "hunyuan_image_3_moe"). All other models fall through to the
|
||||
# original vLLM implementation unchanged.
|
||||
#
|
||||
# FRAGILITY: Relies on is_mm_prefix_lm being a cached_property on
|
||||
# ModelConfig. The __dict__ access + __set_name__ dance works around a
|
||||
# pydantic dataclass issue in vllm 0.19.0+. If vLLM changes
|
||||
# is_mm_prefix_lm to a regular method or removes it, this will break.
|
||||
#
|
||||
# TODO: Upstream a configurable MM_PREFIX_LM_MODELS or a model_config flag
|
||||
# so this patch can be removed.
|
||||
_OMNI_MM_PREFIX_LM_MODELS = ("hunyuan_image_3_moe",)
|
||||
# Access via __dict__ to avoid triggering cached_property.__get__ which fails
|
||||
# with "Cannot use cached_property instance without calling __set_name__" in
|
||||
# pydantic dataclasses (vllm 0.19.0+).
|
||||
_cp = _OriginalModelConfig.__dict__["is_mm_prefix_lm"]
|
||||
_original_is_mm_prefix_lm = _cp.func if hasattr(_cp, "func") else _cp.fget
|
||||
|
||||
|
||||
def _patched_is_mm_prefix_lm(self):
|
||||
if _original_is_mm_prefix_lm(self):
|
||||
return True
|
||||
model_type = getattr(self.hf_config, "model_type", "")
|
||||
return model_type in _OMNI_MM_PREFIX_LM_MODELS
|
||||
|
||||
|
||||
_patched_cp = cached_property(_patched_is_mm_prefix_lm)
|
||||
_patched_cp.__set_name__(_OriginalModelConfig, "is_mm_prefix_lm")
|
||||
_OriginalModelConfig.is_mm_prefix_lm = _patched_cp
|
||||
|
||||
# Sanity check: verify the patch is active. If vLLM changes the descriptor
|
||||
# type or __set_name__ semantics, this will fail loudly at import time
|
||||
# rather than silently falling back to unpatched behavior.
|
||||
_installed = _OriginalModelConfig.__dict__.get("is_mm_prefix_lm")
|
||||
assert _installed is _patched_cp, (
|
||||
"is_mm_prefix_lm patch failed to install — bidirectional attention "
|
||||
"for HunyuanImage3 will not work. Check vLLM ModelConfig changes."
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Patch GlmImageTextConfig to expose mrope_section in rope_parameters
|
||||
# =============================================================================
|
||||
|
||||
Reference in new issue
Block a user