Files
vllm-omni/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_sampler.py
T

373 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni 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
TIMESTEP = 224
CFG = 110
IMG_TOK = 111
JOINT_SEP = 112
EOI = 113
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]
# Mirrors the model: the blocklist is always built so generation-mode
# engines can apply it to per-request comprehension rows (#6088).
self._cfg_token_id = CFG
self._blocked_token_ids: set[int] = {BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP}
self._blocked_token_ids.update(self._all_ratio_ids)
self._blocked_token_ids_tensor = None
self._sampler = None
# 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
_row_is_comprehension = staticmethod(_Real._row_is_comprehension)
sample = _Real.sample
class FakeTokenEmbedding:
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return torch.stack(
[
input_ids.to(dtype=torch.float32),
input_ids.to(dtype=torch.float32) + 0.5,
],
dim=-1,
)
class FakeEmbeddingModel:
def __init__(self):
self.model = FakeTokenEmbedding()
self._timestep_token_id = TIMESTEP
from vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3 import (
HunyuanImage3ForConditionalGeneration as _Real,
)
embed_input_ids = _Real.embed_input_ids
def _timestep_encode(self, timestep: torch.Tensor) -> torch.Tensor:
return torch.full(
(1, 2),
-7.0,
device=timestep.device,
dtype=timestep.dtype,
)
class TestTimestepEmbedding:
def test_timestep_embedding_avoids_scalar_item_sync(self, monkeypatch):
model = FakeEmbeddingModel()
input_ids = torch.tensor([1, TIMESTEP, 2, TIMESTEP], dtype=torch.long)
def fail_item(_self):
raise AssertionError("embed_input_ids should not synchronize timestep count")
monkeypatch.setattr(torch.Tensor, "item", fail_item)
actual = model.embed_input_ids(input_ids)
expected = model.model.embed_input_ids(input_ids)
expected[input_ids == TIMESTEP] = -7.0
torch.testing.assert_close(actual, expected)
def test_timestep_embedding_preserves_no_timestep_inputs(self, monkeypatch):
model = FakeEmbeddingModel()
input_ids = torch.tensor([1, 2, 3], dtype=torch.long)
def fail_item(_self):
raise AssertionError("embed_input_ids should not synchronize timestep count")
monkeypatch.setattr(torch.Tensor, "item", fail_item)
actual = model.embed_input_ids(input_ids)
torch.testing.assert_close(actual, model.model.embed_input_ids(input_ids))
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
class _RecordingSampler:
"""Stub sampler: returns the (mutated) logits so tests can assert on them."""
def __call__(self, *, logits, sampling_metadata):
return logits
def _metadata(output_token_ids):
from types import SimpleNamespace
return SimpleNamespace(output_token_ids=output_token_ids)
class TestPerRequestTaskMode:
"""Generation-mode engines must honor per-request comprehension rows (#6088).
A text chat (I2T/T2T) served by an AR+DiT deployment is marked with
extra_args={"ar_task_mode": "comprehension"}; its rows get the stateless
image-token blocking instead of stage-transition forcing, while untagged
rows keep the generation behavior.
"""
VOCAB = 300
def _run(self, model, histories, per_req_extra_args):
logits = torch.zeros(len(histories), self.VOCAB)
logits[:, 50] = 7.0 # ordinary text token, must survive comprehension
model._sampler = _RecordingSampler()
out = model.sample(logits, _metadata(histories), per_req_extra_args=per_req_extra_args)
return out
def test_comprehension_row_skips_forcing_and_blocks_image_tokens(self):
model = FakeSamplerModel(is_comprehension=False)
min_score = torch.finfo(torch.float32).min
logits = self._run(
model,
histories=[[END_OF_THINK], [END_OF_THINK]],
per_req_extra_args=[{"ar_task_mode": "comprehension"}, None],
)
# Row 0 (comprehension): no </think> -> <recaption> forcing (ordinary
# text token survives, which a force-fill would have erased) and every
# DiT scaffold token is masked.
assert logits[0, 50].item() == 7.0
for tid in (BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP, RATIO_START, RATIO_OTHER_START):
assert logits[0, tid].item() == min_score, f"token {tid} not blocked"
# Row 1 (generation): forced to <recaption> exactly as before.
assert logits[1, RECAPTION].item() == 0
assert logits[1, 50].item() == min_score
def test_no_extra_args_keeps_generation_behavior(self):
model = FakeSamplerModel(is_comprehension=False)
min_score = torch.finfo(torch.float32).min
logits = self._run(model, histories=[[END_OF_THINK]], per_req_extra_args=None)
assert logits[0, RECAPTION].item() == 0
assert logits[0, 50].item() == min_score
def test_empty_or_foreign_extra_args_keeps_generation_behavior(self):
model = FakeSamplerModel(is_comprehension=False)
logits = self._run(
model,
histories=[[END_OF_THINK], [END_OF_THINK]],
per_req_extra_args=[{}, {"other_key": 1}],
)
assert logits[0, RECAPTION].item() == 0
assert logits[1, RECAPTION].item() == 0
def test_comprehension_row_without_trigger_history(self):
model = FakeSamplerModel(is_comprehension=False)
min_score = torch.finfo(torch.float32).min
logits = self._run(
model,
histories=[[1, 2, 3]],
per_req_extra_args=[{"ar_task_mode": "comprehension"}],
)
assert logits[0, 50].item() == 7.0
assert logits[0, CFG].item() == min_score
def test_static_comprehension_engine_blocks_cfg(self):
model = FakeSamplerModel(is_comprehension=True)
min_score = torch.finfo(torch.float32).min
logits = self._run(model, histories=[[1, 2]], per_req_extra_args=None)
for tid in (BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP):
assert logits[0, tid].item() == min_score
assert logits[0, 50].item() == 7.0
def test_extra_args_shorter_than_batch_defaults_to_generation(self):
model = FakeSamplerModel(is_comprehension=False)
logits = self._run(
model,
histories=[[END_OF_THINK], [END_OF_THINK]],
per_req_extra_args=[{"ar_task_mode": "comprehension"}],
)
min_score = torch.finfo(torch.float32).min
assert logits[0, 50].item() == 7.0 # tagged row: comprehension
assert logits[1, RECAPTION].item() == 0 # missing entry: generation
assert logits[1, 50].item() == min_score