ajh-code commited on
Commit
e4bfc4f
·
verified ·
1 Parent(s): 0d00722

Add vendor/mage_flow/models/modules/text_encoder.py

Browse files
vendor/mage_flow/models/modules/text_encoder.py ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qwen3-VL text encoder: custom HF model + packing-aware forward patches + TextEncoder wrapper."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ try:
9
+ from typing import Unpack
10
+ except ImportError:
11
+ from typing_extensions import Unpack
12
+
13
+ import torch
14
+ from loguru import logger
15
+ from torch import nn
16
+ from transformers import AutoProcessor, AutoTokenizer, Cache, Qwen3VLForConditionalGeneration
17
+ from transformers.cache_utils import DynamicCache
18
+ from transformers.masking_utils import create_causal_mask
19
+ from transformers.modeling_flash_attention_utils import FlashAttentionKwargs
20
+ from transformers.modeling_outputs import BaseModelOutputWithPast
21
+ from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
22
+ from transformers.models.qwen3_vl.modeling_qwen3_vl import (
23
+ Qwen3VLCausalLMOutputWithPast,
24
+ apply_rotary_pos_emb,
25
+ eager_attention_forward,
26
+ )
27
+ from transformers.utils import ModelOutput
28
+
29
+ from ._attn_backend import flash_attn_varlen_func
30
+
31
+
32
+ # ===========================================================================
33
+ # Custom Qwen3-VL model (customizable forward output)
34
+ # ===========================================================================
35
+
36
+ @dataclass
37
+ class Qwen3VLModelOutput(ModelOutput):
38
+ """Flexible output class for custom Qwen3-VL model."""
39
+
40
+ loss: torch.FloatTensor | None = None
41
+ logits: torch.FloatTensor | None = None
42
+ past_key_values: Cache | None = None
43
+ hidden_states: tuple[torch.FloatTensor, ...] | None = None
44
+ last_hidden_state: torch.FloatTensor | None = None
45
+ attentions: tuple[torch.FloatTensor, ...] | None = None
46
+ rope_deltas: torch.LongTensor | None = None
47
+
48
+
49
+ class CustomQwen3VLForConditionalGeneration(Qwen3VLForConditionalGeneration):
50
+ """
51
+ Custom Qwen3-VL model that allows customizing the forward output.
52
+
53
+ This class inherits from Qwen3VLForConditionalGeneration and provides
54
+ hooks to customize what is returned from the forward pass.
55
+
56
+ Example usage:
57
+ ```python
58
+ model = CustomQwen3VLForConditionalGeneration.from_pretrained(
59
+ "Qwen/Qwen3-VL-8B-Instruct",
60
+ attn_implementation="flash_attention_2" # Use flash attention for faster inference
61
+ )
62
+
63
+ # Option 1: Use built-in output modes
64
+ model.set_output_mode("embedding") # Only return last hidden state (default)
65
+ model.set_output_mode("full") # Return everything
66
+ model.set_output_mode("logits") # Only return logits
67
+
68
+ # Option 2: Set a custom output processor
69
+ def my_custom_output(hidden_states, logits, outputs, **kwargs):
70
+ return {"embeddings": hidden_states, "pooled": hidden_states.mean(dim=1)}
71
+ model.set_output_processor(my_custom_output)
72
+ ```
73
+ """
74
+
75
+ # Output mode constants
76
+ OUTPUT_MODE_FULL = "full"
77
+ OUTPUT_MODE_EMBEDDING = "embedding"
78
+ OUTPUT_MODE_LOGITS = "logits"
79
+ OUTPUT_MODE_HIDDEN = "hidden"
80
+
81
+ def __init__(self, config):
82
+ super().__init__(config)
83
+ self._output_mode = self.OUTPUT_MODE_EMBEDDING
84
+ self._skip_lm_head = True
85
+
86
+ def set_output_mode(self, mode: str):
87
+ """
88
+ Set the output mode for the forward pass.
89
+
90
+ Args:
91
+ mode: One of:
92
+ - "full": Return full Qwen3VLCausalLMOutputWithPast
93
+ - "embedding": Only return last hidden state (skip lm_head) (default)
94
+ - "logits": Only return logits
95
+ - "hidden": Return all hidden states
96
+ """
97
+ valid_modes = [
98
+ self.OUTPUT_MODE_FULL,
99
+ self.OUTPUT_MODE_EMBEDDING,
100
+ self.OUTPUT_MODE_LOGITS,
101
+ self.OUTPUT_MODE_HIDDEN,
102
+ ]
103
+ if mode not in valid_modes:
104
+ raise ValueError(f"Invalid output mode: {mode}. Must be one of {valid_modes}")
105
+ self._output_mode = mode
106
+ self._skip_lm_head = mode == self.OUTPUT_MODE_EMBEDDING
107
+
108
+ def forward(
109
+ self,
110
+ input_ids: torch.LongTensor | None = None,
111
+ attention_mask: torch.Tensor | None = None,
112
+ position_ids: torch.LongTensor | None = None,
113
+ past_key_values: Cache | None = None,
114
+ inputs_embeds: torch.FloatTensor | None = None,
115
+ labels: torch.LongTensor | None = None,
116
+ pixel_values: torch.Tensor | None = None,
117
+ pixel_values_videos: torch.FloatTensor | None = None,
118
+ image_grid_thw: torch.LongTensor | None = None,
119
+ video_grid_thw: torch.LongTensor | None = None,
120
+ cache_position: torch.LongTensor | None = None,
121
+ logits_to_keep: int | torch.Tensor = 0,
122
+ output_attentions: bool | None = None,
123
+ output_hidden_states: bool | None = None,
124
+ return_dict: bool | None = None,
125
+ **kwargs,
126
+ ) -> Qwen3VLCausalLMOutputWithPast | Qwen3VLModelOutput | dict | torch.Tensor:
127
+ """
128
+ Forward pass with customizable output.
129
+
130
+ Returns different outputs based on the configured output mode or custom processor.
131
+ """
132
+ output_attentions = output_attentions if output_attentions is not None else self.config.output_attentions
133
+ output_hidden_states = (
134
+ output_hidden_states if output_hidden_states is not None else self.config.output_hidden_states
135
+ )
136
+
137
+ # Get outputs from the base model (Qwen3VLModel)
138
+ outputs = self.model(
139
+ input_ids=input_ids,
140
+ pixel_values=pixel_values,
141
+ pixel_values_videos=pixel_values_videos,
142
+ image_grid_thw=image_grid_thw,
143
+ video_grid_thw=video_grid_thw,
144
+ position_ids=position_ids,
145
+ attention_mask=attention_mask,
146
+ past_key_values=past_key_values,
147
+ inputs_embeds=inputs_embeds,
148
+ cache_position=cache_position,
149
+ output_attentions=output_attentions,
150
+ output_hidden_states=output_hidden_states,
151
+ return_dict=True,
152
+ **kwargs,
153
+ )
154
+
155
+ # Get the last hidden state
156
+ hidden_states = outputs[0] # This is the last hidden state
157
+
158
+ # Compute logits if not skipping lm_head
159
+ logits = None
160
+ if not self._skip_lm_head:
161
+ slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
162
+ logits = self.lm_head(hidden_states[:, slice_indices, :])
163
+
164
+ # Compute loss if labels are provided
165
+ loss = None
166
+ if labels is not None and logits is not None:
167
+ loss = self.loss_function(
168
+ logits=logits, labels=labels, vocab_size=self.config.text_config.vocab_size, **kwargs
169
+ )
170
+
171
+ # Return based on output mode
172
+ if self._output_mode == self.OUTPUT_MODE_EMBEDDING:
173
+ return Qwen3VLModelOutput(
174
+ last_hidden_state=hidden_states,
175
+ past_key_values=outputs.past_key_values,
176
+ attentions=outputs.attentions,
177
+ rope_deltas=outputs.rope_deltas,
178
+ )
179
+ elif self._output_mode == self.OUTPUT_MODE_LOGITS:
180
+ return logits
181
+ elif self._output_mode == self.OUTPUT_MODE_HIDDEN:
182
+ return Qwen3VLModelOutput(
183
+ last_hidden_state=hidden_states,
184
+ hidden_states=outputs.hidden_states,
185
+ past_key_values=outputs.past_key_values,
186
+ attentions=outputs.attentions,
187
+ rope_deltas=outputs.rope_deltas,
188
+ )
189
+ else: # OUTPUT_MODE_FULL
190
+ return Qwen3VLCausalLMOutputWithPast(
191
+ loss=loss,
192
+ logits=logits,
193
+ past_key_values=outputs.past_key_values,
194
+ hidden_states=outputs.hidden_states,
195
+ attentions=outputs.attentions,
196
+ rope_deltas=outputs.rope_deltas,
197
+ )
198
+
199
+
200
+ # ===========================================================================
201
+ # Packing-aware forward patches (cu_seqlens) for the Qwen3-VL text encoder
202
+ # ===========================================================================
203
+
204
+ def model_forward(
205
+ self,
206
+ input_ids: torch.LongTensor | None = None,
207
+ attention_mask: torch.Tensor | None = None,
208
+ position_ids: torch.LongTensor | None = None,
209
+ past_key_values: Cache | None = None,
210
+ inputs_embeds: torch.FloatTensor | None = None,
211
+ use_cache: bool | None = None,
212
+ cache_position: torch.LongTensor | None = None,
213
+ # args for deepstack
214
+ visual_pos_masks: torch.Tensor | None = None,
215
+ deepstack_visual_embeds: list[torch.Tensor] | None = None,
216
+ **kwargs: Unpack[FlashAttentionKwargs],
217
+ ) -> tuple | BaseModelOutputWithPast:
218
+ r"""
219
+ visual_pos_masks (`torch.Tensor` of shape `(batch_size, seqlen)`, *optional*):
220
+ The mask of the visual positions.
221
+ deepstack_visual_embeds (`list[torch.Tensor]`, *optional*):
222
+ The deepstack visual embeddings. The shape is (num_layers, visual_seqlen, embed_dim).
223
+ The feature is extracted from the different visual encoder layers, and fed to the decoder
224
+ hidden states. It's from the paper DeepStack(https://arxiv.org/abs/2406.04334).
225
+ """
226
+ if (input_ids is None) ^ (inputs_embeds is not None):
227
+ raise ValueError("You must specify exactly one of input_ids or inputs_embeds")
228
+
229
+ # torch.jit.trace() doesn't support cache objects in the output
230
+ if use_cache and past_key_values is None and not torch.jit.is_tracing():
231
+ past_key_values = DynamicCache(config=self.config)
232
+
233
+ if inputs_embeds is None:
234
+ inputs_embeds = self.embed_tokens(input_ids)
235
+
236
+ if cache_position is None:
237
+ past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0
238
+ cache_position = torch.arange(
239
+ past_seen_tokens, past_seen_tokens + inputs_embeds.shape[1], device=inputs_embeds.device
240
+ )
241
+
242
+ # the hard coded `3` is for temporal, height and width.
243
+ if position_ids is None:
244
+ position_ids = cache_position.view(1, 1, -1).expand(3, inputs_embeds.shape[0], -1)
245
+ elif position_ids.ndim == 2:
246
+ position_ids = position_ids[None, ...].expand(3, position_ids.shape[0], -1)
247
+
248
+ if position_ids.ndim == 3 and position_ids.shape[0] == 4:
249
+ text_position_ids = position_ids[0]
250
+ position_ids = position_ids[1:]
251
+ else:
252
+ text_position_ids = position_ids[0]
253
+
254
+ if kwargs.get("cu_seqlens") is None:
255
+ attention_mask = create_causal_mask(
256
+ config=self.config,
257
+ input_embeds=inputs_embeds,
258
+ attention_mask=attention_mask,
259
+ cache_position=cache_position,
260
+ past_key_values=past_key_values,
261
+ position_ids=text_position_ids,
262
+ )
263
+
264
+ hidden_states = inputs_embeds
265
+
266
+ # create position embeddings to be shared across the decoder layers
267
+ position_embeddings = self.rotary_emb(hidden_states, position_ids)
268
+
269
+ # decoder layers
270
+ for layer_idx, decoder_layer in enumerate(self.layers):
271
+ layer_outputs = decoder_layer(
272
+ hidden_states,
273
+ attention_mask=attention_mask,
274
+ position_ids=text_position_ids,
275
+ past_key_values=past_key_values,
276
+ cache_position=cache_position,
277
+ position_embeddings=position_embeddings,
278
+ **kwargs,
279
+ )
280
+ hidden_states = layer_outputs
281
+
282
+ # add visual features to the hidden states of first several layers
283
+ if deepstack_visual_embeds is not None and layer_idx in range(len(deepstack_visual_embeds)):
284
+ hidden_states = self._deepstack_process(
285
+ hidden_states,
286
+ visual_pos_masks,
287
+ deepstack_visual_embeds[layer_idx],
288
+ )
289
+
290
+ hidden_states = self.norm(hidden_states)
291
+
292
+ return BaseModelOutputWithPast(
293
+ last_hidden_state=hidden_states,
294
+ past_key_values=past_key_values,
295
+ )
296
+
297
+
298
+ def forward(
299
+ self,
300
+ hidden_states: torch.Tensor,
301
+ position_embeddings: tuple[torch.Tensor, torch.Tensor],
302
+ attention_mask: torch.Tensor | None,
303
+ past_key_values: Cache | None = None,
304
+ cache_position: torch.LongTensor | None = None,
305
+ **kwargs: Unpack[FlashAttentionKwargs],
306
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
307
+ input_shape = hidden_states.shape[:-1]
308
+ hidden_shape = (*input_shape, -1, self.head_dim)
309
+
310
+ query_states = self.q_norm(self.q_proj(hidden_states).view(hidden_shape)).transpose(1, 2)
311
+ key_states = self.k_norm(self.k_proj(hidden_states).view(hidden_shape)).transpose(1, 2)
312
+ value_states = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
313
+
314
+ cos, sin = position_embeddings
315
+ query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)
316
+
317
+ if past_key_values is not None:
318
+ # sin and cos are specific to RoPE models; cache_position needed for the static cache
319
+ cache_kwargs = {"sin": sin, "cos": cos, "cache_position": cache_position}
320
+ key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx, cache_kwargs)
321
+
322
+ cu_seqlens = kwargs.get("cu_seqlens", None)
323
+
324
+ if cu_seqlens is None:
325
+ attention_interface: Callable = eager_attention_forward
326
+ if self.config._attn_implementation != "eager":
327
+ attention_interface = ALL_ATTENTION_FUNCTIONS[self.config._attn_implementation]
328
+
329
+ attn_output, attn_weights = attention_interface(
330
+ self,
331
+ query_states,
332
+ key_states,
333
+ value_states,
334
+ attention_mask,
335
+ dropout=0.0 if not self.training else self.attention_dropout,
336
+ scaling=self.scaling,
337
+ **kwargs,
338
+ )
339
+ else:
340
+ max_seqlen = torch.diff(cu_seqlens).max().item() if cu_seqlens is not None else None
341
+ query_states = query_states.transpose(1, 2).squeeze(0)
342
+ key_states = key_states.transpose(1, 2).squeeze(0)
343
+ value_states = value_states.transpose(1, 2).squeeze(0)
344
+ attn_output = flash_attn_varlen_func(
345
+ q=query_states,
346
+ k=key_states,
347
+ v=value_states,
348
+ cu_seqlens_q=cu_seqlens,
349
+ cu_seqlens_k=cu_seqlens,
350
+ max_seqlen_q=max_seqlen,
351
+ max_seqlen_k=max_seqlen,
352
+ causal=True,
353
+ window_size=(-1, -1),
354
+ softmax_scale=self.head_dim**-0.5,
355
+ dropout_p=0.0,
356
+ )
357
+
358
+ attn_output = attn_output.reshape(*input_shape, -1).contiguous()
359
+ attn_output = self.o_proj(attn_output)
360
+ return attn_output, None
361
+
362
+
363
+ def qwen3_patch_forward():
364
+ """Patch the Qwen3-VL text model + attention forwards to support packed
365
+ varlen (cu_seqlens) inputs used by ``TextEncoder.forward``."""
366
+ from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLTextAttention, Qwen3VLTextModel
367
+
368
+ Qwen3VLTextModel.forward = model_forward
369
+ Qwen3VLTextAttention.forward = forward
370
+
371
+
372
+ # ===========================================================================
373
+ # TextEncoder wrapper (packed text -> DiT conditioning embeddings)
374
+ # ===========================================================================
375
+ _FA2_ALIASES = {"flash2", "fa2", "flash_attention_2", "flash_attn_2"}
376
+ _FA4_ALIASES = {"flash4", "fa4", "flash_attention_4", "flash_attn_4"}
377
+ _SDPA_ALIASES = {"sdpa", "torch_sdpa", "scaled_dot_product_attention"}
378
+
379
+
380
+ def _resolve_hf_attn_impl(attn_type: str) -> str:
381
+ """Map a project-level attn_type to a HuggingFace ``attn_implementation`` string.
382
+
383
+ ``VF_HF_ATTN_IMPL`` env var, if set, takes precedence (useful for forcing
384
+ sdpa on machines without flash-attn). For FA4 we additionally probe that
385
+ the CUTE-DSL kernel is importable and (when available) ask the HF helper
386
+ to confirm; if not, fall back to sdpa rather than crashing at load time.
387
+ """
388
+ override = os.environ.get("VF_HF_ATTN_IMPL")
389
+ if override:
390
+ return override
391
+
392
+ name = attn_type.lower().strip()
393
+ if name in _FA2_ALIASES:
394
+ return "flash_attention_2"
395
+ if name in _FA4_ALIASES:
396
+ try:
397
+ import flash_attn.cute # noqa: F401
398
+ fa4_importable = True
399
+ except Exception:
400
+ fa4_importable = False
401
+ if fa4_importable:
402
+ try:
403
+ from transformers.utils.import_utils import is_flash_attn_4_available
404
+ if is_flash_attn_4_available():
405
+ return "flash_attention_4"
406
+ except ImportError:
407
+ return "flash_attention_4"
408
+ logger.warning(
409
+ "attn_type=flash4 requested but flash_attn.cute is unavailable; "
410
+ "falling back to sdpa for HF text encoder."
411
+ )
412
+ return "sdpa"
413
+ if name in _SDPA_ALIASES:
414
+ return "sdpa"
415
+ raise ValueError(
416
+ f"Unknown attn_type {attn_type!r}; expected one of "
417
+ f"{sorted(_FA2_ALIASES | _FA4_ALIASES | _SDPA_ALIASES)}"
418
+ )
419
+
420
+
421
+ SEQ_MULTI_OF = 32
422
+
423
+
424
+
425
+ class TextEncoder(nn.Module):
426
+ def __init__(
427
+ self,
428
+ model_name: str,
429
+ version: str,
430
+ tokenizer_max_length: int,
431
+ prompt_template: dict | None,
432
+ dit_structure: dict,
433
+ use_packed_text_infer: bool = False,
434
+ attn_type: str = "flash2",
435
+ **hf_kwargs,
436
+ ):
437
+ super().__init__()
438
+ self.model_name = model_name
439
+ self.tokenizer_max_length = tokenizer_max_length
440
+ self.tokenizer: AutoTokenizer = AutoTokenizer.from_pretrained(version)
441
+ self.tokenizer.padding_side = "right"
442
+
443
+ hf_attn_impl = _resolve_hf_attn_impl(attn_type)
444
+ logger.info(f"TextEncoder attn_type={attn_type} -> attn_implementation={hf_attn_impl}")
445
+
446
+ logger.info("init vl model: qwen3")
447
+ self.hf_module: CustomQwen3VLForConditionalGeneration = CustomQwen3VLForConditionalGeneration.from_pretrained(
448
+ version,
449
+ attn_implementation=hf_attn_impl,
450
+ **hf_kwargs
451
+ )
452
+
453
+ # Use local_files_only if version is a local path (absolute path or contains path separators)
454
+ is_local = version.startswith("/") or os.sep in version
455
+ self.processor = AutoProcessor.from_pretrained(version, local_files_only=is_local)
456
+
457
+ self.hf_module = self.hf_module.eval().requires_grad_(False)
458
+
459
+ prompt_template = prompt_template or {}
460
+ self.prompt_template_encode = prompt_template.get("template", "")
461
+ self.prompt_template_encode_start_idx = prompt_template.get("start_idx", 0)
462
+ self.dit_structure = dit_structure
463
+ self.use_packed_text_infer = use_packed_text_infer
464
+
465
+ def forward(
466
+ self,
467
+ input_ids: torch.Tensor,
468
+ cu_seqlens: torch.Tensor,
469
+ inputs: dict | None = None,
470
+ drop_idx_override: int | None = None,
471
+ ):
472
+ """Encode packed text (varlen ``cu_seqlens``) into DiT conditioning embeddings.
473
+
474
+ This is the sole text-embedding path — both t2i and edit call it. Uses
475
+ Flash-Attention-2's varlen capability (``cu_seqlens``) via the patched
476
+ Qwen3-VL forward to process several concatenated sequences in a single
477
+ launch, with no padding. Verified numerically identical to a padded-batch
478
+ forward with per-sample cu_seqlens isolation (zero cross-contamination).
479
+
480
+ Args:
481
+ input_ids: Packed token ids ``[Total_L]``.
482
+ cu_seqlens: Cumulative sequence lengths ``[B+1]``.
483
+ inputs: Optional dict with additional model inputs (e.g. ``pixel_values``,
484
+ ``image_grid_thw`` for the multimodal edit path). Passed through to
485
+ the text encoder.
486
+ drop_idx_override: If set, override the number of leading (system-prompt)
487
+ tokens to drop per sequence. Use 0 for multi-turn where the system
488
+ prompt is embedded in the conversation and should not be stripped.
489
+
490
+ Returns:
491
+ dict with keys:
492
+ - ``txt``: text embeddings ``[Total_L - B*drop_idx, D]`` (system prompt dropped)
493
+ - ``vec``: pooled text embeddings ``[B, D]``
494
+ - ``txt_seq_lens``: per-sequence lengths ``[B]`` (after dropping system prompt)
495
+ """
496
+ # Compute seqlens from cu_seqlens
497
+ seqlens = cu_seqlens[1:] - cu_seqlens[:-1]
498
+ seqlens_list = seqlens.cpu().tolist()
499
+
500
+ # Build position_ids for packing: each sequence starts from 0
501
+ position_ids_list = []
502
+ for length in seqlens_list:
503
+ position_ids_list.append(torch.arange(length, device=input_ids.device))
504
+ position_ids = torch.cat(position_ids_list) # [Total_L]
505
+
506
+ # Reshape for model input: [1, Total_L]
507
+ input_ids_packed = input_ids.unsqueeze(0) # [1, Total_L]
508
+ position_ids_packed = position_ids.unsqueeze(0) # [1, Total_L]
509
+
510
+ # Move to text encoder device
511
+ device = self.hf_module.device
512
+ input_ids_packed = input_ids_packed.to(device)
513
+ position_ids_packed = position_ids_packed.to(device)
514
+
515
+ # Get text embeddings (the text encoder is always frozen)
516
+ with torch.no_grad():
517
+ forward_kwargs = {
518
+ "input_ids": input_ids_packed,
519
+ "cu_seqlens": cu_seqlens,
520
+ "position_ids": position_ids_packed,
521
+ "output_hidden_states": False,
522
+ "max_seqlen": None,
523
+ }
524
+ # Pass multimodal inputs for edit mode (reference images)
525
+ if inputs is not None:
526
+ for key in ("pixel_values", "image_grid_thw"):
527
+ if key in inputs and inputs[key] is not None:
528
+ val = inputs[key]
529
+ if hasattr(val, "to"):
530
+ val = val.to(device)
531
+ forward_kwargs[key] = val
532
+ outputs = self.hf_module(**forward_kwargs)
533
+
534
+ # Extract hidden state
535
+ if hasattr(outputs, "last_hidden_state") and outputs.last_hidden_state is not None:
536
+ hidden = outputs.last_hidden_state # [1, Total_L, D]
537
+ elif hasattr(outputs, "hidden_states"):
538
+ hidden = outputs.hidden_states[-1]
539
+
540
+ # Remove batch dimension: [Total_L, D]
541
+ hidden = hidden.squeeze(0)
542
+
543
+ # Get drop_idx (system prompt length to skip).
544
+ # For multi-turn, drop_idx_override=0 is passed since system prompt is in the messages.
545
+ if drop_idx_override is not None:
546
+ drop_idx = drop_idx_override
547
+ else:
548
+ drop_idx = self.prompt_template_encode_start_idx
549
+
550
+ # Split hidden states by sequence
551
+ hidden_split = torch.split(hidden, seqlens_list, dim=0)
552
+
553
+ # Extract valid embeddings (drop system prompt) and compute vec
554
+ txt_list = []
555
+ vec_list = []
556
+ valid_lengths = []
557
+
558
+ for h in hidden_split:
559
+ # Drop system prompt tokens
560
+ h_valid = h[drop_idx:] # [seq_len - drop_idx, D]
561
+ txt_list.append(h_valid)
562
+ valid_lengths.append(h_valid.shape[0])
563
+
564
+ # Compute pooled embedding (mean of valid tokens only, after dropping system prompt)
565
+ vec_list.append(h_valid.mean(dim=0)) # [D]
566
+
567
+ txt = torch.cat(txt_list, dim=0) # [Total_valid, D]
568
+ vec = torch.stack(vec_list, dim=0) # [B, D]
569
+ txt_seq_lens = torch.tensor(valid_lengths, device=input_ids.device)
570
+
571
+ result = {
572
+ "txt": txt,
573
+ "vec": vec,
574
+ "txt_seq_lens": txt_seq_lens,
575
+ }
576
+
577
+ return result
578
+
579
+ # ------------------------------------------------------------------
580
+ # Mandatory content-policy screening (same Qwen3-VL weights)
581
+ # ------------------------------------------------------------------
582
+ # The policy classifier lives HERE, on the text encoder, so it runs on the
583
+ # exact weights that produce the diffusion conditioning and is not a
584
+ # separable, toggleable pre-pass in the pipeline. The classifier needs
585
+ # autoregressive ``.generate()`` (JSON verdict) whereas conditioning is a
586
+ # single embedding forward — they cannot be one GPU forward without a
587
+ # trained classification head, so "fused" here means: same module, same
588
+ # weights, always run, FAIL-CLOSED (any error blocks).
589
+
590
+ def screen_text(self, prompt: str, max_new_tokens: int = 160):
591
+ """Classify a text-to-image ``prompt`` against the content policy.
592
+
593
+ Returns a ``FilterVerdict``. FAIL-CLOSED: any error (generation, parse)
594
+ returns ``violates=True`` so a broken classifier cannot be used as a
595
+ bypass. An empty prompt is not a violation.
596
+ """
597
+ from .mage_text import (
598
+ CONTENT_FILTER_SYSTEM, FilterVerdict, _extract_json_object,
599
+ _full_output_mode,
600
+ )
601
+
602
+ if not prompt or not prompt.strip():
603
+ return FilterVerdict(False, [], "empty prompt", "")
604
+ try:
605
+ tokenizer = self.tokenizer
606
+ hf = self.hf_module
607
+ device = next(hf.parameters()).device
608
+
609
+ messages = [
610
+ {"role": "system", "content": CONTENT_FILTER_SYSTEM},
611
+ {"role": "user", "content": f"Prompt to classify:\n{prompt}"},
612
+ ]
613
+ text = tokenizer.apply_chat_template(
614
+ messages, tokenize=False, add_generation_prompt=True)
615
+ inputs = tokenizer(text, return_tensors="pt").to(device)
616
+
617
+ eos_id = tokenizer.eos_token_id
618
+ pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else eos_id
619
+
620
+ with _full_output_mode(hf), torch.no_grad():
621
+ out = hf.generate(
622
+ **inputs, max_new_tokens=max_new_tokens, do_sample=False,
623
+ pad_token_id=pad_id, eos_token_id=eos_id)
624
+ gen = tokenizer.decode(
625
+ out[0, inputs.input_ids.shape[1]:], skip_special_tokens=True).strip()
626
+
627
+ parsed = _extract_json_object(gen)
628
+ violates = bool(parsed.get("violates", False))
629
+ cats = [c for c in (parsed.get("categories", []) or []) if isinstance(c, str)]
630
+ reason = str(parsed.get("reason", "")).strip()
631
+ return FilterVerdict(violates, cats, reason, gen)
632
+ except Exception as exc: # noqa: BLE001
633
+ # FAIL-CLOSED: block on any screening error.
634
+ return FilterVerdict(
635
+ True, ["policy"], f"filter error (blocked): {type(exc).__name__}: {exc}", "")
636
+
637
+ def screen_edit(self, prompt: str, ref_images, max_new_tokens: int = 192):
638
+ """Classify an image-EDIT request (source image(s) + instruction).
639
+
640
+ Considers BOTH the source image(s) and the instruction via multimodal
641
+ Qwen3-VL. Falls back to :meth:`screen_text` when no image is given.
642
+ FAIL-CLOSED: any error returns ``violates=True``.
643
+ """
644
+ from PIL import Image
645
+
646
+ from .mage_text import (
647
+ CONTENT_FILTER_EDIT_SYSTEM, FilterVerdict, _extract_json_object,
648
+ _full_output_mode,
649
+ )
650
+
651
+ pils = [ref_images] if isinstance(ref_images, Image.Image) else list(ref_images)
652
+ pils = [p.convert("RGB") for p in pils if p is not None]
653
+ if not pils:
654
+ return self.screen_text(prompt, max_new_tokens=max_new_tokens)
655
+
656
+ instruction = (prompt or "").strip() or "(no textual instruction)"
657
+ try:
658
+ processor = self.processor
659
+ tokenizer = self.tokenizer
660
+ hf = self.hf_module
661
+ device = next(hf.parameters()).device
662
+
663
+ user_content = [{"type": "image"} for _ in pils]
664
+ user_content.append({
665
+ "type": "text",
666
+ "text": (
667
+ f"There {'is' if len(pils) == 1 else 'are'} {len(pils)} source "
668
+ f"image(s) above. Edit instruction: {instruction}\n"
669
+ "Classify this edit request."
670
+ ),
671
+ })
672
+ messages = [
673
+ {"role": "system", "content": CONTENT_FILTER_EDIT_SYSTEM},
674
+ {"role": "user", "content": user_content},
675
+ ]
676
+ text = processor.apply_chat_template(
677
+ messages, tokenize=False, add_generation_prompt=True)
678
+ inputs = processor(
679
+ text=[text], images=pils, padding=True, return_tensors="pt").to(device)
680
+
681
+ eos_id = tokenizer.eos_token_id
682
+ pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else eos_id
683
+
684
+ # Keep only the kwargs Qwen3-VL .generate() consumes.
685
+ gen_inputs = {
686
+ k: inputs[k]
687
+ for k in ("input_ids", "attention_mask", "pixel_values", "image_grid_thw")
688
+ if k in inputs and inputs[k] is not None
689
+ }
690
+ input_len = gen_inputs["input_ids"].shape[1]
691
+
692
+ with _full_output_mode(hf), torch.no_grad():
693
+ out = hf.generate(
694
+ **gen_inputs, max_new_tokens=max_new_tokens, do_sample=False,
695
+ pad_token_id=pad_id, eos_token_id=eos_id)
696
+ gen = tokenizer.decode(out[0, input_len:], skip_special_tokens=True).strip()
697
+
698
+ parsed = _extract_json_object(gen)
699
+ violates = bool(parsed.get("violates", False))
700
+ cats = [c for c in (parsed.get("categories", []) or []) if isinstance(c, str)]
701
+ reason = str(parsed.get("reason", "")).strip()
702
+ return FilterVerdict(violates, cats, reason, gen)
703
+ except Exception as exc: # noqa: BLE001
704
+ # FAIL-CLOSED: block on any screening error.
705
+ return FilterVerdict(
706
+ True, ["policy"], f"edit filter error (blocked): {type(exc).__name__}: {exc}", "")
707
+