Skip to content

Commit de53bfc

Browse files
committed
[perf]: align Wan/Cosmos T5 encoding to diffusers canonical recipe (gated on use_batched_cfg)
Diffusers' WanPipeline._get_t5_prompt_embeds (pipeline_wan.py:173-190) and CosmosTextToWorldPipeline._get_t5_prompt_embeds (pipeline_cosmos_ text2world.py:197-237) both: (a) tokenize(padding="max_length", max_length=N) (b) T5 encoder forward (sees padded input) (c) trim T5 output to per-sample real length (d) re-pad with EXPLICIT ZEROS to max_length Wan and Cosmos were trained against this recipe. FV's current variable-length T5 output deviates. This change replicates the canonical recipe — but only when use_batched_cfg=True, so users who don't opt in see zero behaviour change. HunyuanVideo's diffusers pipeline (pipeline_hunyuan_video.py _get_llama_prompt_embeds) does NOT do (c)+(d) — uses encoder output as-is, padded positions retain T5's natural bias-driven values. Gated on dit_config MRO matching {WanVideoConfig, CosmosVideoConfig, Cosmos25VideoConfig} so HunyuanVideo and every other DiT bypass the recipe entirely. Long-term: the right fix is to wire context_lens through to Wan's attention layer so it can mask padding directly (rather than relying on training-distribution-zero positions). That would let batched-CFG pad to max(real_lens) instead of max_length and become bit-equivalent across more configs. Out of scope for this PR; tracked as a follow-up (see PR body and ground_truth W3e).
1 parent 00f86f4 commit de53bfc

1 file changed

Lines changed: 89 additions & 2 deletions

File tree

fastvideo/pipelines/stages/text_encoding.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,37 @@ def forward(
6464
if batch.prompt_embeds is not None and len(batch.prompt_embeds) > 0:
6565
return batch
6666

67-
# Encode positive prompt with all available encoders
67+
# Encode positive prompt with all available encoders.
68+
#
69+
# For DiTs whose canonical diffusers pipeline uses the
70+
# padding="max_length" + trim+zero-pad recipe (Wan, Cosmos),
71+
# we force padding="max_length" at the tokenizer call so the
72+
# T5 encoder sees padded input — matching the training
73+
# distribution AND giving matched shapes for CFG cat
74+
# downstream. For all other DiTs (HunyuanVideo and friends)
75+
# we keep FV's current variable-length default so we don't
76+
# change their behaviour.
6877
assert batch.prompt is not None
6978
prompt_text: str | list[str] = batch.prompt
7079
all_indices: list[int] = list(range(len(self.text_encoders)))
80+
# Recipe alignment gate: only fires when ALL of these are true:
81+
# - user opted into batched-CFG (fastvideo_args.use_batched_cfg)
82+
# - CFG is on for this generation
83+
# - DiT family is one we've verified against diffusers source
84+
# (Wan/Cosmos do trim+pad-with-zeros; HunyuanVideo doesn't)
85+
# When use_batched_cfg=False (the default), behaviour is bit-for-
86+
# bit unchanged for every existing FV user.
87+
_zero_pad_recipe_dits = {"WanVideoConfig", "CosmosVideoConfig", "Cosmos25VideoConfig"}
88+
_dit_cfg_mro_names = {cls.__name__ for cls in type(fastvideo_args.pipeline_config.dit_config).__mro__}
89+
_use_canonical_recipe = (bool(_zero_pad_recipe_dits & _dit_cfg_mro_names) and fastvideo_args.use_batched_cfg)
90+
_cfg_padding = ("max_length" if (batch.do_classifier_free_guidance and _use_canonical_recipe) else None)
7191
prompt_embeds_list, prompt_masks_list = self.encode_text(
7292
prompt_text,
7393
fastvideo_args,
7494
encoder_index=all_indices,
7595
return_attention_mask=True,
7696
max_length=batch.max_sequence_length,
97+
padding=_cfg_padding,
7798
)
7899
if self._last_audio_embeds is not None:
79100
batch.extra["ltx2_audio_prompt_embeds"] = self._last_audio_embeds
@@ -84,7 +105,8 @@ def forward(
84105
for am in prompt_masks_list:
85106
batch.prompt_attention_mask.append(am)
86107

87-
# Encode negative prompt if CFG is enabled
108+
# Encode negative prompt if CFG is enabled. Same padding policy
109+
# as the positive prompt so shapes line up for CFG cat.
88110
if batch.do_classifier_free_guidance:
89111
assert isinstance(batch.negative_prompt, str)
90112
neg_embeds_list, neg_masks_list = self.encode_text(
@@ -93,11 +115,61 @@ def forward(
93115
encoder_index=all_indices,
94116
return_attention_mask=True,
95117
max_length=batch.max_sequence_length,
118+
padding=_cfg_padding,
96119
)
97120
if self._last_audio_embeds is not None:
98121
batch.extra["ltx2_audio_negative_embeds"] = self._last_audio_embeds
99122

100123
assert batch.negative_prompt_embeds is not None
124+
125+
# CFG diffusers-canonical trim+pad-with-zeros. Verified
126+
# from diffusers source:
127+
# - Wan: pipeline_wan.py:187-190 trims T5 output to per-
128+
# sample real length then re-pads with explicit zeros.
129+
# - Cosmos: pipeline_cosmos_text2world.py:233-235 sets
130+
# `prompt_embeds[i, length:] = 0` after the encoder.
131+
# - HunyuanVideo: pipeline_hunyuan_video.py
132+
# _get_llama_prompt_embeds uses encoder output AS-IS,
133+
# padded positions retain T5's natural values.
134+
# Already gated above via `_use_canonical_recipe`: the
135+
# tokenizer was variable-length for non-Wan/Cosmos pipelines,
136+
# so the encoder output already matches FV's current default.
137+
# Skip the trim+zero-pad in that case to preserve existing
138+
# behaviour.
139+
if not _use_canonical_recipe:
140+
for ne in neg_embeds_list:
141+
batch.negative_prompt_embeds.append(ne)
142+
if batch.negative_attention_mask is not None:
143+
for nm in neg_masks_list:
144+
batch.negative_attention_mask.append(nm)
145+
return batch
146+
for idx, (pe, ne) in enumerate(zip(prompt_embeds_list, neg_embeds_list, strict=True)):
147+
if pe.dim() < 2 or ne.dim() < 2:
148+
continue
149+
pe_mask = prompt_masks_list[idx] if idx < len(prompt_masks_list) else None
150+
ne_mask = neg_masks_list[idx] if idx < len(neg_masks_list) else None
151+
pe_real_len = (int(pe_mask.gt(0).sum(dim=-1).max().item()) if pe_mask is not None else pe.shape[1])
152+
ne_real_len = (int(ne_mask.gt(0).sum(dim=-1).max().item()) if ne_mask is not None else ne.shape[1])
153+
# Pad to the FULL tokenizer max_length (= shape after
154+
# padding="max_length"), matching diffusers. Both pe and
155+
# ne come out of the tokenizer at this shape already
156+
# since we forced padding="max_length" upstream, but be
157+
# defensive in case any encoder bypasses that.
158+
target_len = max(pe.shape[1], ne.shape[1], pe_real_len, ne_real_len)
159+
prompt_embeds_list[idx] = self._trim_then_zero_pad(pe, pe_real_len, target_len)
160+
neg_embeds_list[idx] = self._trim_then_zero_pad(ne, ne_real_len, target_len)
161+
if pe_mask is not None and idx < len(prompt_masks_list):
162+
prompt_masks_list[idx] = self._trim_then_zero_pad(pe_mask, pe_real_len, target_len)
163+
if ne_mask is not None and idx < len(neg_masks_list):
164+
neg_masks_list[idx] = self._trim_then_zero_pad(ne_mask, ne_real_len, target_len)
165+
# Replace pos entries that were appended above with the
166+
# trimmed-and-padded versions.
167+
batch.prompt_embeds.clear()
168+
batch.prompt_embeds.extend(prompt_embeds_list)
169+
if batch.prompt_attention_mask is not None:
170+
batch.prompt_attention_mask.clear()
171+
batch.prompt_attention_mask.extend(prompt_masks_list)
172+
101173
for ne in neg_embeds_list:
102174
batch.negative_prompt_embeds.append(ne)
103175
if batch.negative_attention_mask is not None:
@@ -106,6 +178,21 @@ def forward(
106178

107179
return batch
108180

181+
@staticmethod
182+
def _trim_then_zero_pad(t: torch.Tensor, real_len: int, target_len: int) -> torch.Tensor:
183+
"""Diffusers-style trim+pad: cut tensor to ``real_len`` along
184+
dim 1, then zero-pad to ``target_len``. Idempotent when
185+
``real_len == target_len == t.shape[1]``."""
186+
if t.shape[1] == target_len and real_len == target_len:
187+
return t
188+
trimmed = t[:, :real_len]
189+
if trimmed.shape[1] == target_len:
190+
return trimmed
191+
pad_amount = target_len - trimmed.shape[1]
192+
trailing = trimmed.dim() - 2
193+
pad_spec = (0, 0) * trailing + (0, pad_amount)
194+
return torch.nn.functional.pad(trimmed, pad_spec, value=0)
195+
109196
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
110197
"""Verify text encoding stage inputs."""
111198
result = VerificationResult()

0 commit comments

Comments
 (0)