hunyuandit model/pipeline review
Commit tested: 0f1abc4ae8b0eb2a3b40e82a310507281144c423
Review performed against the repository review rules.
Reviewed: target model/pipeline files, lazy exports, config/loading paths, dtype/device/offload behavior, attention processors, docs, examples, and tests. Fast and slow tests exist for both base and ControlNet pipelines, but ControlNet has several no-op fast tests noted below. Local .venv snippets were run; full pytest collection was blocked because this .venv torch build is missing torch._C._distributed_c10d.
Duplicate search: searched GitHub Issues and PRs for hunyuandit, target class/file names, from_transformer transformer_num_layers, prompt_embeds num_images_per_prompt attention_mask, MultiControlNet controlnet_conditioning_scale, _no_split_modules, and HunyuanAttnProcessor2_0 set_attention_backend. No exact duplicates found. Related closed issue: #9142 covered older multi-ControlNet loading, not the runtime scale/validation failures below.
Issue 1: from_transformer() cannot build a matching HunyuanDiT ControlNet
Affected code:
| config=transformer.config |
| activation_fn=config.activation_fn |
| attention_head_dim=config.attention_head_dim |
| cross_attention_dim=config.cross_attention_dim |
| cross_attention_dim_t5=config.cross_attention_dim_t5 |
| hidden_size=config.hidden_size |
| in_channels=config.in_channels |
| mlp_ratio=config.mlp_ratio |
| num_attention_heads=config.num_attention_heads |
| patch_size=config.patch_size |
| sample_size=config.sample_size |
| text_len=config.text_len |
| text_len_t5=config.text_len_t5 |
| |
| conditioning_channels=conditioning_channels |
| transformer_num_layers=transformer_num_layersorconfig.transformer_num_layers |
| |
| controlnet=cls( |
| conditioning_channels=conditioning_channels, |
| transformer_num_layers=transformer_num_layers, |
| activation_fn=activation_fn, |
| attention_head_dim=attention_head_dim, |
| cross_attention_dim=cross_attention_dim, |
| cross_attention_dim_t5=cross_attention_dim_t5, |
| hidden_size=hidden_size, |
| in_channels=in_channels, |
| mlp_ratio=mlp_ratio, |
| num_attention_heads=num_attention_heads, |
| patch_size=patch_size, |
| sample_size=sample_size, |
| text_len=text_len, |
| text_len_t5=text_len_t5, |
| ) |
Problem:
HunyuanDiT2DControlNetModel.from_transformer() reads config.transformer_num_layers, but HunyuanDiT2DModel stores num_layers. Even when transformer_num_layers is passed manually, the method does not copy pooled_projection_dim or use_style_cond_and_image_meta_size, so weight loading can hit size mismatches.
Impact:
The public constructor for deriving a ControlNet from a transformer is broken for normal HunyuanDiT transformers and can also create a ControlNet with incompatible conditioning embeddings.
Reproduction:
fromdiffusersimportHunyuanDiT2DControlNetModel, HunyuanDiT2DModeltransformer=HunyuanDiT2DModel(
sample_size=8, num_layers=4, patch_size=2,
attention_head_dim=4, num_attention_heads=2, in_channels=4,
cross_attention_dim=8, cross_attention_dim_t5=8,
pooled_projection_dim=4, hidden_size=8, text_len=4, text_len_t5=4,
use_style_cond_and_image_meta_size=False,
)
try:
HunyuanDiT2DControlNetModel.from_transformer(transformer)
exceptExceptionase:
print(type(e).__name__, str(e).splitlines()[0])
try:
HunyuanDiT2DControlNetModel.from_transformer(transformer, transformer_num_layers=4)
exceptExceptionase:
print(type(e).__name__, str(e).splitlines()[0])
Relevant precedent:
| config=dict(transformer.config) |
| config["num_layers"] =num_layers |
| config["num_single_layers"] =num_single_layers |
| config["attention_head_dim"] =attention_head_dim |
| config["num_attention_heads"] =num_attention_heads |
| |
| controlnet=cls.from_config(config) |
| |
| config=transformer.config |
| config["num_layers"] =num_layersorconfig.num_layers |
| config["extra_conditioning_channels"] =num_extra_conditioning_channels |
| controlnet=cls.from_config(config) |
Suggested fix:
config=transformer.configtransformer_num_layers=transformer_num_layersorconfig.num_layerscontrolnet=cls(
conditioning_channels=conditioning_channels,
transformer_num_layers=transformer_num_layers,
activation_fn=config.activation_fn,
attention_head_dim=config.attention_head_dim,
cross_attention_dim=config.cross_attention_dim,
cross_attention_dim_t5=config.cross_attention_dim_t5,
hidden_size=config.hidden_size,
in_channels=config.in_channels,
mlp_ratio=config.mlp_ratio,
num_attention_heads=config.num_attention_heads,
patch_size=config.patch_size,
sample_size=config.sample_size,
pooled_projection_dim=config.pooled_projection_dim,
text_len=config.text_len,
text_len_t5=config.text_len_t5,
use_style_cond_and_image_meta_size=config.use_style_cond_and_image_meta_size,
)
Issue 2: Precomputed prompt masks are not repeated for num_images_per_prompt
Affected code:
| prompt_attention_mask=text_inputs.attention_mask.to(device) |
| prompt_embeds=text_encoder( |
| text_input_ids.to(device), |
| attention_mask=prompt_attention_mask, |
| ) |
| prompt_embeds=prompt_embeds[0] |
| prompt_attention_mask=prompt_attention_mask.repeat(num_images_per_prompt, 1) |
| |
| prompt_embeds=prompt_embeds.to(dtype=dtype, device=device) |
| |
| bs_embed, seq_len, _=prompt_embeds.shape |
| # duplicate text embeddings for each generation per prompt, using mps friendly method |
| prompt_embeds=prompt_embeds.repeat(1, num_images_per_prompt, 1) |
| prompt_embeds=prompt_embeds.view(bs_embed*num_images_per_prompt, seq_len, -1) |
| negative_prompt_attention_mask=uncond_input.attention_mask.to(device) |
| negative_prompt_embeds=text_encoder( |
| uncond_input.input_ids.to(device), |
| attention_mask=negative_prompt_attention_mask, |
| ) |
| negative_prompt_embeds=negative_prompt_embeds[0] |
| negative_prompt_attention_mask=negative_prompt_attention_mask.repeat(num_images_per_prompt, 1) |
| |
| ifdo_classifier_free_guidance: |
| # duplicate unconditional embeddings for each generation per prompt, using mps friendly method |
| seq_len=negative_prompt_embeds.shape[1] |
| |
| negative_prompt_embeds=negative_prompt_embeds.to(dtype=dtype, device=device) |
| |
| negative_prompt_embeds=negative_prompt_embeds.repeat(1, num_images_per_prompt, 1) |
| negative_prompt_embeds=negative_prompt_embeds.view(batch_size*num_images_per_prompt, seq_len, -1) |
| prompt_attention_mask=text_inputs.attention_mask.to(device) |
| prompt_embeds=text_encoder( |
| text_input_ids.to(device), |
| attention_mask=prompt_attention_mask, |
| ) |
| prompt_embeds=prompt_embeds[0] |
| prompt_attention_mask=prompt_attention_mask.repeat(num_images_per_prompt, 1) |
| |
| prompt_embeds=prompt_embeds.to(dtype=dtype, device=device) |
| |
| bs_embed, seq_len, _=prompt_embeds.shape |
| # duplicate text embeddings for each generation per prompt, using mps friendly method |
| prompt_embeds=prompt_embeds.repeat(1, num_images_per_prompt, 1) |
| prompt_embeds=prompt_embeds.view(bs_embed*num_images_per_prompt, seq_len, -1) |
| negative_prompt_attention_mask=uncond_input.attention_mask.to(device) |
| negative_prompt_embeds=text_encoder( |
| uncond_input.input_ids.to(device), |
| attention_mask=negative_prompt_attention_mask, |
| ) |
| negative_prompt_embeds=negative_prompt_embeds[0] |
| negative_prompt_attention_mask=negative_prompt_attention_mask.repeat(num_images_per_prompt, 1) |
| |
| ifdo_classifier_free_guidance: |
| # duplicate unconditional embeddings for each generation per prompt, using mps friendly method |
| seq_len=negative_prompt_embeds.shape[1] |
| |
| negative_prompt_embeds=negative_prompt_embeds.to(dtype=dtype, device=device) |
| |
| negative_prompt_embeds=negative_prompt_embeds.repeat(1, num_images_per_prompt, 1) |
| negative_prompt_embeds=negative_prompt_embeds.view(batch_size*num_images_per_prompt, seq_len, -1) |
Problem:
When users pass prompt_embeds and attention masks directly, encode_prompt() repeats embeddings for num_images_per_prompt but leaves the provided masks at the original batch size. Generated masks are repeated, but caller-provided masks are not.
Impact:
Batched precomputed embeddings fail when num_images_per_prompt > 1; with batch size 1 the mask broadcasts, hiding the bug.
Reproduction:
importtorchfromdiffusersimportAutoencoderKL, DDPMScheduler, HunyuanDiT2DModel, HunyuanDiTPipelinetransformer=HunyuanDiT2DModel(
sample_size=16, num_layers=2, patch_size=2,
attention_head_dim=8, num_attention_heads=3, in_channels=4,
cross_attention_dim=32, cross_attention_dim_t5=32,
pooled_projection_dim=16, hidden_size=24,
).eval()
pipe=HunyuanDiTPipeline(
vae=AutoencoderKL(), text_encoder=None, tokenizer=None,
transformer=transformer, scheduler=DDPMScheduler(),
safety_checker=None, feature_extractor=None,
text_encoder_2=None, tokenizer_2=None, requires_safety_checker=False,
)
pipe.set_progress_bar_config(disable=True)
pipe(
prompt_embeds=torch.randn(2, 77, 32),
prompt_attention_mask=torch.ones(2, 77, dtype=torch.long),
prompt_embeds_2=torch.randn(2, 256, 32),
prompt_attention_mask_2=torch.ones(2, 256, dtype=torch.long),
num_images_per_prompt=2, guidance_scale=1.0,
num_inference_steps=1, height=16, width=16,
output_type="latent", use_resolution_binning=False,
)
Relevant precedent:
The same method is copied into the ControlNet pipeline, so the fix should be applied at the source and propagated.
Suggested fix:
# Keep generated masks unrepeated in the generation branch, then normalize both paths once.ifprompt_embedsisNone:
...
prompt_attention_mask=text_inputs.attention_mask.to(device)
prompt_embeds=text_encoder(...)[0]
prompt_embeds=prompt_embeds.to(dtype=dtype, device=device)
prompt_attention_mask=prompt_attention_mask.to(device).repeat(num_images_per_prompt, 1)
...
ifdo_classifier_free_guidanceandnegative_prompt_embedsisNone:
...
negative_prompt_attention_mask=uncond_input.attention_mask.to(device)
negative_prompt_embeds=text_encoder(...)[0]
ifdo_classifier_free_guidance:
negative_prompt_embeds=negative_prompt_embeds.to(dtype=dtype, device=device)
negative_prompt_attention_mask=negative_prompt_attention_mask.to(device).repeat(num_images_per_prompt, 1)
...
Issue 3: Multi-ControlNet inputs are neither normalized nor length-checked
Affected code:
| controlnet_conditioning_scale: float|list[float] =1.0, |
| elifisinstance(self.controlnet, HunyuanDiT2DMultiControlNetModel): |
| control_images= [] |
| |
| forcontrol_image_incontrol_image: |
| control_image_=self.prepare_image( |
| image=control_image_, |
| width=width, |
| height=height, |
| batch_size=batch_size*num_images_per_prompt, |
| num_images_per_prompt=num_images_per_prompt, |
| device=device, |
| dtype=self.dtype, |
| do_classifier_free_guidance=self.do_classifier_free_guidance, |
| guess_mode=False, |
| ) |
| |
| control_image_=self.vae.encode(control_image_).latent_dist.sample() |
| control_image_=control_image_*self.vae.config.scaling_factor |
| |
| control_images.append(control_image_) |
| |
| control_image=control_images |
| fori, (image, scale, controlnet) inenumerate(zip(controlnet_cond, conditioning_scale, self.nets)): |
| block_samples=controlnet( |
| hidden_states=hidden_states, |
| timestep=timestep, |
| controlnet_cond=image, |
| conditioning_scale=scale, |
| encoder_hidden_states=encoder_hidden_states, |
| text_embedding_mask=text_embedding_mask, |
| encoder_hidden_states_t5=encoder_hidden_states_t5, |
| text_embedding_mask_t5=text_embedding_mask_t5, |
| image_meta_size=image_meta_size, |
| style=style, |
| image_rotary_emb=image_rotary_emb, |
| return_dict=return_dict, |
| ) |
| |
| # merge samples |
| ifi==0: |
| control_block_samples=block_samples |
| else: |
| control_block_samples= [ |
| control_block_sample+block_sample |
| forcontrol_block_sample, block_sampleinzip(control_block_samples[0], block_samples[0]) |
| ] |
| control_block_samples= (control_block_samples,) |
| |
| returncontrol_block_samples |
Problem:
For HunyuanDiT2DMultiControlNetModel, conditioning_scale is iterated directly. The default scalar 1.0 raises TypeError, and mismatched lengths between control_image, conditioning_scale, and self.nets are silently truncated by zip().
Impact:
Multiple ControlNets are fragile: the documented scalar default fails, and missing images/scales can silently skip ControlNets.
Reproduction:
importtorchfromdiffusersimportHunyuanDiT2DMultiControlNetModelmulti=HunyuanDiT2DMultiControlNetModel([torch.nn.Identity(), torch.nn.Identity()])
try:
multi(torch.zeros(1), torch.zeros(1), [torch.zeros(1), torch.zeros(1)], conditioning_scale=1.0)
exceptExceptionase:
print(type(e).__name__, str(e))
classDummyControlNet(torch.nn.Module):
def__init__(self, value):
super().__init__()
self.value=valueself.calls=0defforward(self, *args, **kwargs):
self.calls+=1return ([torch.tensor(float(self.value))],)
first, second=DummyControlNet(1), DummyControlNet(2)
multi=HunyuanDiT2DMultiControlNetModel([first, second])
out=multi(torch.zeros(1), torch.zeros(1), [torch.zeros(1)], conditioning_scale=[1.0, 1.0], return_dict=False)
print(out[0][0].item(), first.calls, second.calls)
Relevant precedent:
| # align format for control guidance |
| ifnotisinstance(control_guidance_start, list) andisinstance(control_guidance_end, list): |
| control_guidance_start=len(control_guidance_end) * [control_guidance_start] |
| elifnotisinstance(control_guidance_end, list) andisinstance(control_guidance_start, list): |
| control_guidance_end=len(control_guidance_start) * [control_guidance_end] |
| elifnotisinstance(control_guidance_start, list) andnotisinstance(control_guidance_end, list): |
| mult=len(self.controlnet.nets) ifisinstance(self.controlnet, SD3MultiControlNetModel) else1 |
| control_guidance_start, control_guidance_end= ( |
| mult* [control_guidance_start], |
| mult* [control_guidance_end], |
| ifisinstance(controlnet_keep[i], list): |
| cond_scale= [c*sforc, sinzip(controlnet_conditioning_scale, controlnet_keep[i])] |
| else: |
| controlnet_cond_scale=controlnet_conditioning_scale |
| ifisinstance(controlnet_cond_scale, list): |
| controlnet_cond_scale=controlnet_cond_scale[0] |
| cond_scale=controlnet_cond_scale*controlnet_keep[i] |
Suggested fix:
ifisinstance(self.controlnet, HunyuanDiT2DMultiControlNetModel):
count=len(self.controlnet.nets)
ifnotisinstance(controlnet_conditioning_scale, list):
controlnet_conditioning_scale= [controlnet_conditioning_scale] *countifnotisinstance(control_image, list) orlen(control_image) !=count:
raiseValueError(f"`control_image` must be a list of {count} images for multiple ControlNets.")
iflen(controlnet_conditioning_scale) !=count:
raiseValueError(f"`controlnet_conditioning_scale` must contain {count} values.")Issue 4: HunyuanDiT models do not support device_map="auto"
Affected code:
| classHunyuanDiT2DModel(ModelMixin, AttentionMixin, ConfigMixin): |
| """ |
| HunYuanDiT: Diffusion model with a Transformer backbone. |
| |
| Inherit ModelMixin and ConfigMixin to be compatible with the sampler StableDiffusionPipeline of diffusers. |
| |
| Parameters: |
| num_attention_heads (`int`, *optional*, defaults to 16): |
| The number of heads to use for multi-head attention. |
| attention_head_dim (`int`, *optional*, defaults to 88): |
| The number of channels in each head. |
| in_channels (`int`, *optional*): |
| The number of channels in the input and output (specify if the input is **continuous**). |
| patch_size (`int`, *optional*): |
| The size of the patch to use for the input. |
| activation_fn (`str`, *optional*, defaults to `"geglu"`): |
| Activation function to use in feed-forward. |
| sample_size (`int`, *optional*): |
| The width of the latent images. This is fixed during training since it is used to learn a number of |
| position embeddings. |
| dropout (`float`, *optional*, defaults to 0.0): |
| The dropout probability to use. |
| cross_attention_dim (`int`, *optional*): |
| The number of dimension in the clip text embedding. |
| hidden_size (`int`, *optional*): |
| The size of hidden layer in the conditioning embedding layers. |
| num_layers (`int`, *optional*, defaults to 1): |
| The number of layers of Transformer blocks to use. |
| mlp_ratio (`float`, *optional*, defaults to 4.0): |
| The ratio of the hidden layer size to the input size. |
| learn_sigma (`bool`, *optional*, defaults to `True`): |
| Whether to predict variance. |
| cross_attention_dim_t5 (`int`, *optional*): |
| The number dimensions in t5 text embedding. |
| pooled_projection_dim (`int`, *optional*): |
| The size of the pooled projection. |
| text_len (`int`, *optional*): |
| The length of the clip text embedding. |
| text_len_t5 (`int`, *optional*): |
| The length of the T5 text embedding. |
| use_style_cond_and_image_meta_size (`bool`, *optional*): |
| Whether or not to use style condition and image meta size. True for version <=1.1, False for version >= 1.2 |
| """ |
| |
| _skip_layerwise_casting_patterns= ["pos_embed", "norm", "pooler"] |
| _supports_group_offloading=False |
| classHunyuanDiT2DControlNetModel(ModelMixin, ConfigMixin): |
| @register_to_config |
| def__init__( |
Problem:
Neither HunyuanDiT2DModel nor HunyuanDiT2DControlNetModel defines _no_split_modules. Diffusers rejects device_map="auto" for model classes without this attribute.
Impact:
Large HunyuanDiT checkpoints cannot use automatic model-level device placement, unlike related transformer/controlnet families.
Reproduction:
fromdiffusersimportHunyuanDiT2DControlNetModel, HunyuanDiT2DModelcommon=dict(
sample_size=8, patch_size=2, attention_head_dim=4, num_attention_heads=2,
in_channels=4, cross_attention_dim=8, cross_attention_dim_t5=8,
pooled_projection_dim=4, hidden_size=8, text_len=4, text_len_t5=4,
)
formodelin [
HunyuanDiT2DModel(num_layers=1, **common),
HunyuanDiT2DControlNetModel(transformer_num_layers=4, **common),
]:
try:
print(model.__class__.__name__, model._get_no_split_modules("auto"))
exceptExceptionase:
print(model.__class__.__name__, type(e).__name__, str(e).splitlines()[0])Relevant precedent:
| _no_split_modules= ["FluxTransformerBlock", "FluxSingleTransformerBlock"] |
| _skip_layerwise_casting_patterns= ["pos_embed", "norm"] |
| _repeated_blocks= ["FluxTransformerBlock", "FluxSingleTransformerBlock"] |
| _no_split_modules= ["SanaTransformerBlock", "PatchEmbed"] |
| _skip_layerwise_casting_patterns= ["patch_embed", "norm"] |
Suggested fix:
classHunyuanDiT2DModel(...):
_no_split_modules= ["HunyuanDiTBlock", "PatchEmbed"]
_skip_layerwise_casting_patterns= ["pos_embed", "norm", "pooler"]
classHunyuanDiT2DControlNetModel(...):
_no_split_modules= ["HunyuanDiTBlock", "PatchEmbed"]
_skip_layerwise_casting_patterns= ["pos_embed", "norm", "pooler"]
Issue 5: Attention backend selection is a no-op
Affected code:
| from ..attentionimportAttentionMixin, FeedForward |
| from ..attention_processorimportAttention, FusedHunyuanAttnProcessor2_0, HunyuanAttnProcessor2_0 |
| self.attn1=Attention( |
| query_dim=dim, |
| cross_attention_dim=None, |
| dim_head=dim//num_attention_heads, |
| heads=num_attention_heads, |
| qk_norm="layer_norm"ifqk_normelseNone, |
| eps=1e-6, |
| bias=True, |
| processor=HunyuanAttnProcessor2_0(), |
| ) |
| classHunyuanAttnProcessor2_0: |
| r""" |
| Processor for implementing scaled dot-product attention (enabled by default if you're using PyTorch 2.0). This is |
| used in the HunyuanDiT model. It applies a s normalization layer and rotary embedding on query and key vector. |
| """ |
| |
| def__init__(self): |
| ifnothasattr(F, "scaled_dot_product_attention"): |
| raiseImportError("AttnProcessor2_0 requires PyTorch 2.0, to use it, please upgrade PyTorch to 2.0.") |
| |
| def__call__( |
| self, |
| attn: Attention, |
| hidden_states: torch.Tensor, |
| encoder_hidden_states: torch.Tensor|None=None, |
| attention_mask: torch.Tensor|None=None, |
| temb: torch.Tensor|None=None, |
| image_rotary_emb: torch.Tensor|None=None, |
| ) ->torch.Tensor: |
| from .embeddingsimportapply_rotary_emb |
| |
| residual=hidden_states |
| ifattn.spatial_normisnotNone: |
| hidden_states=attn.spatial_norm(hidden_states, temb) |
| |
| input_ndim=hidden_states.ndim |
| |
| ifinput_ndim==4: |
| batch_size, channel, height, width=hidden_states.shape |
| hidden_states=hidden_states.view(batch_size, channel, height*width).transpose(1, 2) |
| |
| batch_size, sequence_length, _= ( |
| hidden_states.shapeifencoder_hidden_statesisNoneelseencoder_hidden_states.shape |
| ) |
| |
| ifattention_maskisnotNone: |
| attention_mask=attn.prepare_attention_mask(attention_mask, sequence_length, batch_size) |
| # scaled_dot_product_attention expects attention_mask shape to be |
| # (batch, heads, source_length, target_length) |
| attention_mask=attention_mask.view(batch_size, attn.heads, -1, attention_mask.shape[-1]) |
| |
| ifattn.group_normisnotNone: |
| hidden_states=attn.group_norm(hidden_states.transpose(1, 2)).transpose(1, 2) |
| |
| query=attn.to_q(hidden_states) |
| |
| ifencoder_hidden_statesisNone: |
| encoder_hidden_states=hidden_states |
| elifattn.norm_cross: |
| encoder_hidden_states=attn.norm_encoder_hidden_states(encoder_hidden_states) |
| |
| key=attn.to_k(encoder_hidden_states) |
| value=attn.to_v(encoder_hidden_states) |
| |
| inner_dim=key.shape[-1] |
| head_dim=inner_dim//attn.heads |
| |
| query=query.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) |
| |
| key=key.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) |
| value=value.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) |
| |
| ifattn.norm_qisnotNone: |
| query=attn.norm_q(query) |
| ifattn.norm_kisnotNone: |
| key=attn.norm_k(key) |
| |
| # Apply RoPE if needed |
| ifimage_rotary_embisnotNone: |
| query=apply_rotary_emb(query, image_rotary_emb) |
| ifnotattn.is_cross_attention: |
| key=apply_rotary_emb(key, image_rotary_emb) |
| |
| # the output of sdp = (batch, num_heads, seq_len, head_dim) |
| # TODO: add support for attn.scale when we move to Torch 2.1 |
| hidden_states=F.scaled_dot_product_attention( |
| query, key, value, attn_mask=attention_mask, dropout_p=0.0, is_causal=False |
| ) |
Problem:
HunyuanDiT uses legacy HunyuanAttnProcessor2_0 from attention_processor.py. That processor calls F.scaled_dot_product_attention directly and has no _attention_backend, so model.set_attention_backend("native") silently leaves all processors unchanged.
Impact:
Users cannot select modern attention backends for HunyuanDiT despite the model exposing the generic attention backend API.
Reproduction:
fromdiffusersimportHunyuanDiT2DModelmodel=HunyuanDiT2DModel(
sample_size=8, num_layers=1, patch_size=2,
attention_head_dim=4, num_attention_heads=2, in_channels=4,
cross_attention_dim=8, cross_attention_dim_t5=8,
pooled_projection_dim=4, hidden_size=8, text_len=4, text_len_t5=4,
)
print([hasattr(p, "_attention_backend") forpinmodel.attn_processors.values()])
model.set_attention_backend("native")
print([getattr(p, "_attention_backend", None) forpinmodel.attn_processors.values()])Relevant precedent:
| classFluxAttnProcessor: |
| _attention_backend=None |
| _parallel_config=None |
| |
| def__init__(self): |
| ifnothasattr(F, "scaled_dot_product_attention"): |
| raiseImportError(f"{self.__class__.__name__} requires PyTorch 2.0. Please upgrade your pytorch version.") |
| |
| def__call__( |
| self, |
| attn: "FluxAttention", |
| hidden_states: torch.Tensor, |
| encoder_hidden_states: torch.Tensor=None, |
| attention_mask: torch.Tensor|None=None, |
| image_rotary_emb: torch.Tensor|None=None, |
| ) ->torch.Tensor: |
| query, key, value, encoder_query, encoder_key, encoder_value=_get_qkv_projections( |
| attn, hidden_states, encoder_hidden_states |
| ) |
| |
| query=query.unflatten(-1, (attn.heads, -1)) |
| key=key.unflatten(-1, (attn.heads, -1)) |
| value=value.unflatten(-1, (attn.heads, -1)) |
| |
| query=attn.norm_q(query) |
| key=attn.norm_k(key) |
| |
| ifattn.added_kv_proj_dimisnotNone: |
| encoder_query=encoder_query.unflatten(-1, (attn.heads, -1)) |
| encoder_key=encoder_key.unflatten(-1, (attn.heads, -1)) |
| encoder_value=encoder_value.unflatten(-1, (attn.heads, -1)) |
| |
| encoder_query=attn.norm_added_q(encoder_query) |
| encoder_key=attn.norm_added_k(encoder_key) |
| |
| query=torch.cat([encoder_query, query], dim=1) |
| key=torch.cat([encoder_key, key], dim=1) |
| value=torch.cat([encoder_value, value], dim=1) |
| |
| ifimage_rotary_embisnotNone: |
| query=apply_rotary_emb(query, image_rotary_emb, sequence_dim=1) |
| key=apply_rotary_emb(key, image_rotary_emb, sequence_dim=1) |
| |
| hidden_states=dispatch_attention_fn( |
| query, |
| key, |
| value, |
| attn_mask=attention_mask, |
| backend=self._attention_backend, |
| parallel_config=self._parallel_config, |
Suggested fix:
Move Hunyuan attention processors to the model-file pattern used by newer transformers, add _attention_backend = None and _parallel_config = None, and route attention through dispatch_attention_fn(...) instead of calling F.scaled_dot_product_attention directly.
Issue 6: ControlNet fast tests contain passing TODO stubs
Affected code:
| deftest_sequential_cpu_offload_forward_pass(self): |
| # TODO(YiYi) need to fix later |
| pass |
| |
| deftest_sequential_offload_forward_pass_twice(self): |
| # TODO(YiYi) need to fix later |
| pass |
| |
| deftest_save_load_optional_components(self): |
| # TODO(YiYi) need to fix later |
| pass |
Problem:
test_sequential_cpu_offload_forward_pass, test_sequential_offload_forward_pass_twice, and test_save_load_optional_components contain only TODO comments and pass, so pytest reports them as successful without checking anything.
Impact:
The target’s ControlNet offload and serialization coverage is weaker than it appears, and regressions in optional component save/load behavior are not exercised.
Reproduction:
frompathlibimportPathtext=Path("tests/pipelines/controlnet_hunyuandit/test_controlnet_hunyuandit.py").read_text()
fornamein [
"test_sequential_cpu_offload_forward_pass",
"test_sequential_offload_forward_pass_twice",
"test_save_load_optional_components",
]:
start=text.index(f"def {name}")
end=text.find("\n def ", start+1)
block=text[start:endifend!=-1elselen(text)]
print(name, "TODO"inblockand"pass"inblock)Relevant precedent:
| @unittest.skip("The HunyuanDiT Attention pooling layer does not support sequential CPU offloading.") |
| deftest_sequential_cpu_offload_forward_pass(self): |
| # TODO(YiYi) need to fix later |
| # This is because it instantiates it's attention layer from torch.nn.MultiheadAttention, which calls to |
| # `torch.nn.functional.multi_head_attention_forward` with the weights and bias. Since the hook is never |
| # triggered with a forward pass call, the weights stay on the CPU. There are more examples where we skip |
| # this test because of MHA (example: HunyuanVideo Framepack) |
| pass |
| |
| @unittest.skip("The HunyuanDiT Attention pooling layer does not support sequential CPU offloading.") |
| deftest_sequential_offload_forward_pass_twice(self): |
| # TODO(YiYi) need to fix later |
| # This is because it instantiates it's attention layer from torch.nn.MultiheadAttention, which calls to |
| # `torch.nn.functional.multi_head_attention_forward` with the weights and bias. Since the hook is never |
| # triggered with a forward pass call, the weights stay on the CPU. There are more examples where we skip |
| # this test because of MHA (example: HunyuanVideo Framepack) |
| pass |
| deftest_save_load_optional_components(self): |
Suggested fix:
Implement the save/load optional component test for ControlNet, and either implement offload tests or explicitly skip them with the same unsupported-offload reason used by the base HunyuanDiT pipeline.
hunyuanditmodel/pipeline reviewCommit tested:
0f1abc4ae8b0eb2a3b40e82a310507281144c423Review performed against the repository review rules.
Reviewed: target model/pipeline files, lazy exports, config/loading paths, dtype/device/offload behavior, attention processors, docs, examples, and tests. Fast and slow tests exist for both base and ControlNet pipelines, but ControlNet has several no-op fast tests noted below. Local
.venvsnippets were run; full pytest collection was blocked because this.venvtorch build is missingtorch._C._distributed_c10d.Duplicate search: searched GitHub Issues and PRs for
hunyuandit, target class/file names,from_transformer transformer_num_layers,prompt_embeds num_images_per_prompt attention_mask,MultiControlNet controlnet_conditioning_scale,_no_split_modules, andHunyuanAttnProcessor2_0 set_attention_backend. No exact duplicates found. Related closed issue: #9142 covered older multi-ControlNet loading, not the runtime scale/validation failures below.Issue 1:
from_transformer()cannot build a matching HunyuanDiT ControlNetAffected code:
diffusers/src/diffusers/models/controlnets/controlnet_hunyuan.py
Lines 177 to 209 in 0f1abc4
Problem:
HunyuanDiT2DControlNetModel.from_transformer()readsconfig.transformer_num_layers, butHunyuanDiT2DModelstoresnum_layers. Even whentransformer_num_layersis passed manually, the method does not copypooled_projection_dimoruse_style_cond_and_image_meta_size, so weight loading can hit size mismatches.Impact:
The public constructor for deriving a ControlNet from a transformer is broken for normal HunyuanDiT transformers and can also create a ControlNet with incompatible conditioning embeddings.
Reproduction:
Relevant precedent:
diffusers/src/diffusers/models/controlnets/controlnet_flux.py
Lines 135 to 142 in 0f1abc4
diffusers/src/diffusers/models/controlnets/controlnet_sd3.py
Lines 257 to 260 in 0f1abc4
Suggested fix:
Issue 2: Precomputed prompt masks are not repeated for
num_images_per_promptAffected code:
diffusers/src/diffusers/pipelines/hunyuandit/pipeline_hunyuandit.py
Lines 349 to 362 in 0f1abc4
diffusers/src/diffusers/pipelines/hunyuandit/pipeline_hunyuandit.py
Lines 394 to 409 in 0f1abc4
diffusers/src/diffusers/pipelines/controlnet_hunyuandit/pipeline_hunyuandit_controlnet.py
Lines 377 to 390 in 0f1abc4
diffusers/src/diffusers/pipelines/controlnet_hunyuandit/pipeline_hunyuandit_controlnet.py
Lines 422 to 437 in 0f1abc4
Problem:
When users pass
prompt_embedsand attention masks directly,encode_prompt()repeats embeddings fornum_images_per_promptbut leaves the provided masks at the original batch size. Generated masks are repeated, but caller-provided masks are not.Impact:
Batched precomputed embeddings fail when
num_images_per_prompt > 1; with batch size 1 the mask broadcasts, hiding the bug.Reproduction:
Relevant precedent:
The same method is copied into the ControlNet pipeline, so the fix should be applied at the source and propagated.
Suggested fix:
Issue 3: Multi-ControlNet inputs are neither normalized nor length-checked
Affected code:
diffusers/src/diffusers/pipelines/controlnet_hunyuandit/pipeline_hunyuandit_controlnet.py
Line 642 in 0f1abc4
diffusers/src/diffusers/pipelines/controlnet_hunyuandit/pipeline_hunyuandit_controlnet.py
Lines 867 to 888 in 0f1abc4
diffusers/src/diffusers/models/controlnets/controlnet_hunyuan.py
Lines 374 to 400 in 0f1abc4
Problem:
For
HunyuanDiT2DMultiControlNetModel,conditioning_scaleis iterated directly. The default scalar1.0raisesTypeError, and mismatched lengths betweencontrol_image,conditioning_scale, andself.netsare silently truncated byzip().Impact:
Multiple ControlNets are fragile: the documented scalar default fails, and missing images/scales can silently skip ControlNets.
Reproduction:
Relevant precedent:
diffusers/src/diffusers/pipelines/controlnet_sd3/pipeline_stable_diffusion_3_controlnet.py
Lines 981 to 990 in 0f1abc4
diffusers/src/diffusers/pipelines/controlnet_sd3/pipeline_stable_diffusion_3_controlnet.py
Lines 1172 to 1178 in 0f1abc4
Suggested fix:
Issue 4: HunyuanDiT models do not support
device_map="auto"Affected code:
diffusers/src/diffusers/models/transformers/hunyuan_transformer_2d.py
Lines 201 to 246 in 0f1abc4
diffusers/src/diffusers/models/controlnets/controlnet_hunyuan.py
Lines 40 to 42 in 0f1abc4
Problem:
Neither
HunyuanDiT2DModelnorHunyuanDiT2DControlNetModeldefines_no_split_modules. Diffusers rejectsdevice_map="auto"for model classes without this attribute.Impact:
Large HunyuanDiT checkpoints cannot use automatic model-level device placement, unlike related transformer/controlnet families.
Reproduction:
Relevant precedent:
diffusers/src/diffusers/models/transformers/transformer_flux.py
Lines 566 to 568 in 0f1abc4
diffusers/src/diffusers/models/controlnets/controlnet_sana.py
Lines 43 to 44 in 0f1abc4
Suggested fix:
Issue 5: Attention backend selection is a no-op
Affected code:
diffusers/src/diffusers/models/transformers/hunyuan_transformer_2d.py
Lines 20 to 21 in 0f1abc4
diffusers/src/diffusers/models/transformers/hunyuan_transformer_2d.py
Lines 111 to 120 in 0f1abc4
diffusers/src/diffusers/models/attention_processor.py
Lines 3124 to 3201 in 0f1abc4
Problem:
HunyuanDiT uses legacy
HunyuanAttnProcessor2_0fromattention_processor.py. That processor callsF.scaled_dot_product_attentiondirectly and has no_attention_backend, somodel.set_attention_backend("native")silently leaves all processors unchanged.Impact:
Users cannot select modern attention backends for HunyuanDiT despite the model exposing the generic attention backend API.
Reproduction:
Relevant precedent:
diffusers/src/diffusers/models/transformers/transformer_flux.py
Lines 75 to 124 in 0f1abc4
Suggested fix:
Move Hunyuan attention processors to the model-file pattern used by newer transformers, add
_attention_backend = Noneand_parallel_config = None, and route attention throughdispatch_attention_fn(...)instead of callingF.scaled_dot_product_attentiondirectly.Issue 6: ControlNet fast tests contain passing TODO stubs
Affected code:
diffusers/tests/pipelines/controlnet_hunyuandit/test_controlnet_hunyuandit.py
Lines 177 to 187 in 0f1abc4
Problem:
test_sequential_cpu_offload_forward_pass,test_sequential_offload_forward_pass_twice, andtest_save_load_optional_componentscontain only TODO comments andpass, so pytest reports them as successful without checking anything.Impact:
The target’s ControlNet offload and serialization coverage is weaker than it appears, and regressions in optional component save/load behavior are not exercised.
Reproduction:
Relevant precedent:
diffusers/tests/pipelines/hunyuandit/test_hunyuan_dit.py
Lines 129 to 145 in 0f1abc4
diffusers/tests/pipelines/hunyuandit/test_hunyuan_dit.py
Line 222 in 0f1abc4
Suggested fix:
Implement the save/load optional component test for ControlNet, and either implement offload tests or explicitly skip them with the same unsupported-offload reason used by the base HunyuanDiT pipeline.