Skip to content

glm_image model/pipeline review #13587

Description

@hlky

glm_image model/pipeline review

Commit tested: 0f1abc4ae8b0eb2a3b40e82a310507281144c423

Review performed against the repository review rules.

Duplicate-search status: searched huggingface/diffusers Issues and PRs for glm_image, GLM-Image, GlmImagePipeline, GlmImageTransformer2DModel, attention_mask, prompt_embeds dtype device, check_inputs width, transformer version gating, and slow-test coverage. I found no duplicates for the findings below. Related but not duplicates: PR #12974 adjusted GLM transformer-version gating, PR #13007 added batch support, PR #13344 added model tests, and issue #13227 tracks an MPS loading corruption issue.

Issue 1: Attention masks do not actually mask padded text tokens

Affected code:

ifattention_maskisnotNone:
text_attn_mask=attention_mask
asserttext_attn_mask.dim() ==2, "the shape of text_attn_mask should be (batch_size, text_seq_length)"
text_attn_mask=text_attn_mask.float().to(query.device)
mix_attn_mask=torch.ones((batch_size, text_seq_length+image_seq_length), device=query.device)
mix_attn_mask[:, :text_seq_length] =text_attn_mask
mix_attn_mask=mix_attn_mask.unsqueeze(2)
attn_mask_matrix=mix_attn_mask @ mix_attn_mask.transpose(1, 2)
attention_mask= (attn_mask_matrix>0).unsqueeze(1).to(query.dtype)

attention_mask=torch.tensor(
[[1] *len(input_ids_) + [0] * (max_length-len(input_ids_)) forinput_ids_ininput_ids],
device=device,
)
input_ids=torch.tensor(
[
input_ids_+ [self.tokenizer.pad_token_id] * (max_length-len(input_ids_))
forinput_ids_ininput_ids
],
device=device,
)
outputs=self.text_encoder(input_ids, attention_mask=attention_mask)
glyph_embeds=outputs.last_hidden_state[attention_mask.bool()].unsqueeze(0)
all_glyph_embeds.append(glyph_embeds)
# Pad to same sequence length and stack (use left padding to match transformers)
max_seq_len=max(emb.size(1) forembinall_glyph_embeds)
padded_embeds= []
forembinall_glyph_embeds:
ifemb.size(1) <max_seq_len:
pad=torch.zeros(emb.size(0), max_seq_len-emb.size(1), emb.size(2), device=device, dtype=emb.dtype)
emb=torch.cat([pad, emb], dim=1) # left padding
padded_embeds.append(emb)
glyph_embeds=torch.cat(padded_embeds, dim=0)
returnglyph_embeds.to(device=device, dtype=dtype)

noise_pred_cond=self.transformer(
hidden_states=latent_model_input,
encoder_hidden_states=prompt_embeds,
prior_token_id=prior_token_ids,
prior_token_drop=prior_token_drop_cond,
timestep=timestep,
target_size=target_size,
crop_coords=crops_coords_top_left,
attention_kwargs=attention_kwargs,
return_dict=False,
kv_caches=kv_caches,
)[0].float()
# perform guidance
ifself.do_classifier_free_guidance:
ifprior_token_image_ids_per_sampleisnotNone:
kv_caches.set_mode("skip")
noise_pred_uncond=self.transformer(
hidden_states=latent_model_input,
encoder_hidden_states=negative_prompt_embeds,
prior_token_id=prior_token_ids,
prior_token_drop=prior_token_drop_uncond,
timestep=timestep,
target_size=target_size,
crop_coords=crops_coords_top_left,
attention_kwargs=attention_kwargs,
return_dict=False,
kv_caches=kv_caches,

Problem:
GlmImageAttnProcessor converts a boolean padding mask into a dense float 0/1 tensor. SDPA treats float masks as additive attention bias, so 0 does not block a token. The pipeline also discards the glyph encoder padding mask after constructing padded glyph embeddings, so batched variable-length glyph prompts have no valid text mask passed to the transformer.

Impact:
Masked or padded text tokens can still affect image tokens. This can make batched outputs differ from equivalent single-prompt outputs and makes the public attention_mask argument misleading. It also prevents the bool-mask varlen path described in the review rules.

Reproduction:

importtorchfromdiffusers.models.attention_processorimportAttentionfromdiffusers.models.transformers.transformer_glm_imageimportGlmImageAttnProcessorattn=Attention(query_dim=4, heads=1, dim_head=4, out_dim=4, bias=False, processor=GlmImageAttnProcessor())
withtorch.no_grad():
attn.to_q.weight.zero_()
attn.to_k.weight.zero_()
attn.to_v.weight.copy_(torch.eye(4))
attn.to_out[0].weight.copy_(torch.eye(4))
encoder_hidden_states=torch.tensor([[[0., 0., 0., 0.], [1000., 0., 0., 0.]]])
hidden_states=torch.zeros(1, 1, 4)
attention_mask=torch.tensor([[True, False]])
image_out, _=attn(
hidden_states=hidden_states,
encoder_hidden_states=encoder_hidden_states,
attention_mask=attention_mask,
)
print(image_out[0, 0, 0].item()) # current: ~155.66; expected: 0 if token 2 is masked

Relevant precedent:

ifencoder_hidden_states_maskisnotNone:
# Build joint mask: [text_mask, all_ones_for_image]
batch_size, image_seq_len=hidden_states.shape[:2]
image_mask=torch.ones((batch_size, image_seq_len), dtype=torch.bool, device=hidden_states.device)
joint_attention_mask=torch.cat([encoder_hidden_states_mask, image_mask], dim=1)
joint_attention_mask=joint_attention_mask[:, None, None, :]
block_attention_kwargs["attention_mask"] =joint_attention_mask

noise_pred=self.transformer(
hidden_states=latents,
timestep=timestep/1000,
guidance=guidance,
encoder_hidden_states_mask=prompt_embeds_mask,
encoder_hidden_states=prompt_embeds,
img_shapes=img_shapes,
attention_kwargs=self.attention_kwargs,
return_dict=False,
)[0]

Suggested fix:

# In GlmImageAttnProcessor.__call__, keep a bool key mask instead of a float QK matrix.ifattention_maskisnotNone:
attention_mask=attention_mask.to(device=query.device, dtype=torch.bool)
cached_seq_length=key.shape[1] -text_seq_length-image_seq_lengthcache_mask=torch.ones((batch_size, cached_seq_length), device=query.device, dtype=torch.bool)
image_mask=torch.ones((batch_size, image_seq_length), device=query.device, dtype=torch.bool)
attention_mask=torch.cat([cache_mask, attention_mask, image_mask], dim=1)

Also return a glyph padding mask from _get_glyph_embeds and pass it through the conditional and unconditional transformer calls.

Issue 2: Width validation accepts invalid resolutions and silently truncates latents

Affected code:

if (
heightisnotNone
andheight% (self.vae_scale_factor*self.transformer.config.patch_size*2) !=0
orwidthisnotNone
andwidth% (self.transformer.config.patch_size*2) !=0
):
# GLM-Image uses 32× downsampling, so the image dimensions must be multiples of 32.
raiseValueError(
f"`height` and `width` have to be divisible by {self.vae_scale_factor*4} but are {height} and {width}."
)

Problem:
check_inputs validates height with vae_scale_factor * patch_size * 2, but validates width only with patch_size * 2. With default GLM settings this accepts widths divisible by 4, even though the pipeline later floors latent width by width // vae_scale_factor.

Impact:
Invalid widths pass validation and produce latents for a smaller decoded width than requested.

Reproduction:

importtorchfromdiffusersimportGlmImagePipelineclassConfig:
patch_size=2classTransformer:
config=Config()
pipe=object.__new__(GlmImagePipeline)
pipe.vae_scale_factor=8pipe.transformer=Transformer()
pipe._callback_tensor_inputs= ["latents", "prompt_embeds"]
pipe.check_inputs(prompt="x", height=32, width=20, callback_on_step_end_tensor_inputs=["latents"])
latents=pipe.prepare_latents(1, 4, 32, 20, torch.float32, torch.device("cpu"), torch.Generator().manual_seed(0))
print(latents.shape[-1] *pipe.vae_scale_factor) # 16, not requested width 20

Relevant precedent:

ifheight%16!=0orwidth%16!=0:
raiseValueError(f"`height` and `width` have to be divisible by 16 but are {height} and {width}.")

Suggested fix:

multiple_of=self.vae_scale_factor*self.transformer.config.patch_size*2if (heightisnotNoneandheight%multiple_of!=0) or (widthisnotNoneandwidth%multiple_of!=0):
raiseValueError(f"`height` and `width` have to be divisible by {multiple_of} but are {height} and {width}.")

Issue 3: Precomputed conditioning tensors are not moved or cast

Affected code:

ifprompt_embedsisNone:
prompt_embeds=self._get_glyph_embeds(prompt, max_sequence_length, device, dtype)
# Repeat embeddings for num_images_per_prompt
ifnum_images_per_prompt>1:
prompt_embeds=prompt_embeds.repeat_interleave(num_images_per_prompt, dim=0)
# For GLM-Image, negative_prompt must be "" instead of None
ifdo_classifier_free_guidanceandnegative_prompt_embedsisNone:
negative_prompt=""
negative_prompt=batch_size* [negative_prompt] ifisinstance(negative_prompt, str) elsenegative_prompt
negative_prompt_embeds=self._get_glyph_embeds(negative_prompt, max_sequence_length, device, dtype)
ifnum_images_per_prompt>1:
negative_prompt_embeds=negative_prompt_embeds.repeat_interleave(num_images_per_prompt, dim=0)
returnprompt_embeds, negative_prompt_embeds

else:
# User provided prior_token_ids directly (from generate_prior_tokens)
prior_token_image_ids_per_sample=prior_token_image_ids
source_image_grid_thw_per_sample=source_image_grid_thw

Problem:
When users pass prompt_embeds, negative_prompt_embeds, or prior token tensors directly, the pipeline returns/uses them as-is instead of normalizing them to the execution device and dtype.

Impact:
Precomputed embeddings can fail at the transformer with dtype or device mismatches. Prior token tensors generated on CPU can also fail when the pipeline is on an accelerator.

Reproduction:

importtorchfromdiffusersimportGlmImagePipelinepipe=object.__new__(GlmImagePipeline)
prompt_embeds=torch.randn(1, 2, 4, dtype=torch.float64)
out, _=pipe.encode_prompt(
prompt=None,
do_classifier_free_guidance=False,
prompt_embeds=prompt_embeds,
device=torch.device("cpu"),
dtype=torch.float32,
)
print(out.dtype, outisprompt_embeds) # torch.float64 True

Relevant precedent:

prompt_embeds=self.text_encoder(text_input_ids.to(device), output_hidden_states=True).hidden_states[-2]
prompt_embeds=prompt_embeds.to(dtype=dtype, device=device)

Suggested fix:

ifprompt_embedsisNone:
prompt_embeds=self._get_glyph_embeds(prompt, max_sequence_length, device, dtype)
else:
prompt_embeds=prompt_embeds.to(device=device, dtype=dtype)
ifnegative_prompt_embedsisnotNone:
negative_prompt_embeds=negative_prompt_embeds.to(device=device, dtype=dtype)
ifprior_token_idsisnotNone:
prior_token_ids=prior_token_ids.to(device=device)
ifprior_token_image_idsisnotNone:
prior_token_image_ids= [x.to(device=device) forxinprior_token_image_ids]
ifsource_image_grid_thwisnotNone:
source_image_grid_thw= [x.to(device=device) forxinsource_image_grid_thw]

Issue 4: Transformers version gates are inconsistent with required GLM classes

Affected code:

# Because it's not released in stable as of 13/01/2026. So this is just a proxy.
GlmImageProcessor=ProcessorMixin
GlmImageForConditionalGeneration=PreTrainedModel
ifis_transformers_version(">=", "5.0.0.dev0"):
fromtransformersimportGlmImageForConditionalGeneration, GlmImageProcessor

ifis_transformers_available() andis_transformers_version(">=", "4.57.4"):
try:
fromtransformersimportGlmImageForConditionalGeneration, GlmImageProcessor
_additional_imports["GlmImageForConditionalGeneration"] =GlmImageForConditionalGeneration
_additional_imports["GlmImageProcessor"] =GlmImageProcessor
exceptImportError:
pass

ifis_transformers_version(">=", "5.0.0.dev0"):
fromtransformersimportGlmImageConfig, GlmImageForConditionalGeneration, GlmImageProcessor
enable_full_determinism()
@require_transformers_version_greater("4.57.4")
@require_torch_accelerator

Problem:
The pipeline file only imports real GlmImageProcessor / GlmImageForConditionalGeneration for transformers >= 5.0.0.dev0, but the package init tries >= 4.57.4 and the fast tests require only > 4.57.4.

Impact:
With transformers==4.57.6, the pipeline is importable but uses ProcessorMixin / PreTrainedModel placeholders, and the test decorator would allow tests whose GLM classes are not imported.

Reproduction:

importtransformersfromdiffusers.utilsimportis_transformers_versionfromdiffusers.pipelines.glm_image.pipeline_glm_imageimportGlmImageProcessor, GlmImageForConditionalGenerationfromtransformersimportProcessorMixin, PreTrainedModelprint(transformers.__version__)
print(is_transformers_version(">", "4.57.4")) # True in this envprint(is_transformers_version(">=", "5.0.0.dev0")) # Falseprint(hasattr(transformers, "GlmImageProcessor")) # Falseprint(GlmImageProcessorisProcessorMixin, GlmImageForConditionalGenerationisPreTrainedModel)

Relevant precedent:
Related prior version-gating PR: #12974

Suggested fix:

GLM_IMAGE_TRANSFORMERS_MIN_VERSION="5.0.0.dev0"# or the first released transformers version with these classesifis_transformers_available() andis_transformers_version(">=", GLM_IMAGE_TRANSFORMERS_MIN_VERSION):
fromtransformersimportGlmImageForConditionalGeneration, GlmImageProcessorelse:
raiseOptionalDependencyNotAvailable()

Use the same predicate in pipeline lazy imports and tests.

Issue 5: Slow tests are missing, and current fast coverage is not portable

Affected code:

@require_transformers_version_greater("4.57.4")
@require_torch_accelerator
classGlmImagePipelineFastTests(PipelineTesterMixin, unittest.TestCase):

processor=GlmImageProcessor.from_pretrained("zai-org/GLM-Image", subfolder="processor")

A Diffusion Transformer model for 2D data from [GlmImageTransformer2DModel] (TODO).

classGlmImagePipelineOutput(BaseOutput):
"""
Output class for CogView3 pipelines.

Problem:
There are no GLM Image slow tests. The pipeline “fast” tests are decorated with @require_torch_accelerator even though they run on CPU, and they load the processor from zai-org/GLM-Image instead of an internal tiny fixture. The model docs still contain a TODO, and the pipeline output docstring says CogView3.

Impact:
CPU CI can skip the pipeline fast suite, slow end-to-end loading of zai-org/GLM-Image is untested, and docs have stale placeholders.

Reproduction:

frompathlibimportPathtest_text=Path("tests/pipelines/glm_image/test_glm_image.py").read_text(encoding="utf-8")
model_doc=Path("docs/source/en/api/models/glm_image_transformer2d.md").read_text(encoding="utf-8")
print("@slow present:", "@slow"intest_text)
print("fast class requires accelerator:", "@require_torch_accelerator"intest_text)
print("fast test downloads full GLM processor:", "zai-org/GLM-Image"intest_text)
print("model docs still contain TODO:", "TODO"inmodel_doc)

Relevant precedent:

tokenizer=Qwen2Tokenizer.from_pretrained("hf-internal-testing/tiny-random-Qwen2VLForConditionalGeneration")

Suggested fix:
Use an hf-internal-testing tiny GLM processor fixture for fast tests, remove the accelerator requirement from CPU-only fast tests, add at least one @slow smoke test for GlmImagePipeline.from_pretrained("zai-org/GLM-Image", ...), and replace the stale TODO/CogView3 doc text.

Test status: a tiny CPU GlmImageTransformer2DModel forward pass succeeded. Targeted pytest collection for GLM model/pipeline tests failed in this local .venv because the installed Torch build lacks torch._C._distributed_c10d, so I could not use pytest results as signal for this audit.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions