[diffusion] feat: support LongCat-Image-Edit and LongCat-Image-Edit-Turbo (#35829)

Co-authored-by: 登辉 <yangdenghui.ydh@alibaba-inc.com>
Co-authored-by: Xiaoyu Zhang <1182563586@qq.com>
This commit is contained in:
iterhui
2026-08-25 12:42:08 +08:00
committed by GitHub
co-authored by 登辉 Xiaoyu Zhang
parent 67853c5804
commit 284ed9d1d3
12 changed files with 799 additions and 15 deletions
@@ -39,7 +39,7 @@ Rows are grouped when a family shares the same runtime path or optimization supp
</tr>
<tr>
<td>LongCat-Image</td>
<td><div className="sgd-id-list"><code>meituan-longcat/LongCat-Image</code></div></td>
<td><div className="sgd-id-list"><code>meituan-longcat/LongCat-Image</code><code>meituan-longcat/LongCat-Image-Edit</code><code>meituan-longcat/LongCat-Image-Edit-Turbo</code></div></td>
</tr>
<tr>
<td>SD3 / SD3.5</td>
@@ -1,3 +1,4 @@
import math
from dataclasses import dataclass, field
from typing import Callable
@@ -20,6 +21,10 @@ from sglang.multimodal_gen.configs.pipeline_configs.base import (
from sglang.multimodal_gen.configs.pipeline_configs.model_deployment_config import (
ModelDeploymentConfig,
)
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
get_sp_world_size,
model_parallel_is_initialized,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
@@ -252,6 +257,28 @@ class LongCatImagePipelineConfig(ImagePipelineConfig):
keep_resident_components=("text_encoder", "vae"),
)
def expand_conditioning_to_sample_batch(self, batch):
# Noise/reference latents are built at batch_size = prompts * num_outputs,
# but text encoding stays per-prompt; repeat the embeds to match. No-op
# for num_outputs == 1. Shared by T2I and Edit.
from sglang.multimodal_gen.runtime.utils.condition_expansion import (
PromptToSampleBatchExpander,
)
expander = PromptToSampleBatchExpander.from_batch(batch)
if expander is None:
return batch
for field_name in (
"prompt_embeds",
"negative_prompt_embeds",
"prompt_embeds_mask",
"negative_prompt_embeds_mask",
"prompt_seq_lens",
"negative_prompt_seq_lens",
):
expander.expand_field(batch, field_name)
return batch
# --- LatentPreparationStage hooks ---
def prepare_latent_shape(self, batch, batch_size, num_frames):
@@ -416,3 +443,164 @@ class LongCatImagePipelineConfig(ImagePipelineConfig):
noise_norm = torch.norm(noise_pred, dim=-1, keepdim=True)
scale = (cond_norm / (noise_norm + 1e-8)).clamp(min=cfg_renorm_min, max=1.0)
return noise_pred * scale
def _calculate_edit_dimensions(target_area, ratio):
"""Output size for LongCat-Image-Edit: fit `target_area`, ceil to /16.
Copied from diffusers pipeline_longcat_image_edit.calculate_dimensions.
Note this intentionally differs from sglang.multimodal_gen.utils
calculate_dimensions (which rounds to /32).
"""
width = math.sqrt(target_area * ratio)
height = width / ratio
width = width if width % 16 == 0 else (width // 16 + 1) * 16
height = height if height % 16 == 0 else (height // 16 + 1) * 16
return int(width), int(height)
@dataclass
class LongCatImageEditPipelineConfig(LongCatImagePipelineConfig):
"""Configuration for the LongCat-Image-Edit I2I pipeline.
Mirrors diffusers LongCatImageEditPipeline: the reference image is resized
to the output resolution, VAE-encoded with argmax sampling, packed, and
concatenated after the noisy latents along the sequence dim. RoPE ids use
modality 1 for noisy tokens and modality 2 for reference tokens, both
offset by the full text sequence length (VL image tokens + 512 body).
"""
task_type: ModelTaskType = ModelTaskType.I2I
# diffusers reference draws latent noise with a CPU generator
generator_device: str = "cpu"
# --- InputValidationStage hooks ---
def calculate_condition_image_size(self, image, width, height):
return _calculate_edit_dimensions(1024 * 1024, width / height)
# --- LatentPreparationStage hooks ---
def get_latent_dtype(self, prompt_dtype: torch.dtype) -> torch.dtype:
# The edit reference draws noise directly in the prompt-embeds dtype
# (bf16), unlike the T2I reference which draws in float32.
return prompt_dtype
def maybe_prepare_latent_ids(self, latents):
# img_ids (noisy + reference) are built per-step in
# prepare_*_cond_kwargs because the text start offset depends on the
# VL image token count, which is unknown at latent preparation time.
return None
# --- ImageVAEEncodingStage hooks ---
def preprocess_vae_encode(self, image, vae):
# AutoencoderKL is a 2D image VAE; drop the frames dim added by
# ImageVAEEncodingStage ([B, C, 1, H, W] -> [B, C, H, W]).
if image.dim() == 5 and image.shape[2] == 1:
image = image.squeeze(2)
return image
def postprocess_image_latent(self, latent_condition, batch):
if latent_condition.dim() == 5 and latent_condition.shape[2] == 1:
latent_condition = latent_condition.squeeze(2)
batch_size = batch.batch_size
if batch_size > latent_condition.shape[0]:
if batch_size % latent_condition.shape[0] != 0:
raise ValueError(
f"Cannot duplicate reference image of batch size "
f"{latent_condition.shape[0]} to {batch_size} prompts."
)
latent_condition = latent_condition.repeat(
batch_size // latent_condition.shape[0], 1, 1, 1
)
_, num_channels_latents, height, width = latent_condition.shape
return _pack_latents(
latent_condition, batch_size, num_channels_latents, height, width
)
# --- Denoising hooks ---
def shard_latents_for_sp(self, batch, latents):
# (h/2)*(w/2) is odd at most ~1MP edit resolutions, so SP has to pad, and
# the pads stay unmasked (USPAttention rejects a mask alongside the
# replicated text prefix). Repeat the last token instead of the base
# class's zeros, which would carry the RoPE of text token 0.
if latents.dim() == 3 and model_parallel_is_initialized():
sp_world_size = get_sp_world_size()
remainder = latents.shape[1] % sp_world_size
if remainder:
pad = latents[:, -1:].expand(-1, sp_world_size - remainder, -1)
latents = torch.cat([latents, pad], dim=1)
return super().shard_latents_for_sp(batch, latents)
def _maybe_shard_pos_ids_for_sp(self, batch, pos_ids):
# RoPE ids must be sharded exactly like their latents, so reuse the same
# helper. It reads the SP group, which only exists under model parallelism.
if not model_parallel_is_initialized() or get_sp_world_size() == 1:
return pos_ids
sharded, _ = self.shard_latents_for_sp(batch, pos_ids.unsqueeze(0))
return sharded.squeeze(0)
def _edit_img_ids(self, batch, num_token, device):
"""Position ids for [noisy | reference] packed latent tokens.
The reference image is resized to the output resolution, so both grids
share the same shape; they differ only in modality id (1 vs 2).
"""
vae_scale_factor = self.vae_config.get_vae_scale_factor()
h = 2 * (int(batch.height) // (vae_scale_factor * 2))
w = 2 * (int(batch.width) // (vae_scale_factor * 2))
noisy_ids = _prepare_pos_ids(
modality_id=1,
token_type="image",
start=(num_token, num_token),
height=h // 2,
width=w // 2,
)
ref_ids = _prepare_pos_ids(
modality_id=2,
token_type="image",
start=(num_token, num_token),
height=h // 2,
width=w // 2,
)
noisy_ids = self._maybe_shard_pos_ids_for_sp(batch, noisy_ids)
ref_ids = self._maybe_shard_pos_ids_for_sp(batch, ref_ids)
return torch.cat([noisy_ids, ref_ids], dim=0).to(device)
def prepare_pos_cond_kwargs(self, batch, device, rotary_emb, dtype):
num_token = batch.prompt_embeds[0].shape[1]
return {
"txt_ids": _prepare_pos_ids(
modality_id=0, token_type="text", start=(0, 0), num_token=num_token
).to(device),
"img_ids": self._edit_img_ids(batch, num_token, device),
}
def prepare_neg_cond_kwargs(self, batch, device, rotary_emb, dtype):
num_token = batch.negative_prompt_embeds[0].shape[1]
return {
"txt_ids": _prepare_pos_ids(
modality_id=0, token_type="text", start=(0, 0), num_token=num_token
).to(device),
"img_ids": self._edit_img_ids(batch, num_token, device),
}
def slice_noise_pred(self, noise, latents):
# Drop predictions over the appended reference-image tokens.
return noise[:, : latents.size(1)]
def post_denoising_loop(self, latents, batch):
# The SP gather leaves the noisy latents at their padded length; trim the
# trailing pad tokens before unpacking to the (h/2)*(w/2) grid.
if latents.dim() == 3:
vae_scale_factor = self.vae_config.get_vae_scale_factor()
h = 2 * (int(batch.height) // (vae_scale_factor * 2))
w = 2 * (int(batch.width) // (vae_scale_factor * 2))
expected = (h // 2) * (w // 2)
if latents.shape[1] > expected:
latents = latents[:, :expected, :]
return super().post_denoising_loop(latents, batch)
@@ -12,3 +12,28 @@ class LongCatImageSamplingParams(SamplingParams):
# Override base class defaults to enable LongCat-specific features by default
enable_cfg_renorm: bool = True
enable_prompt_rewrite: bool = True
@dataclass
class LongCatImageEditSamplingParams(SamplingParams):
"""Defaults for LongCat-Image-Edit (mirrors diffusers LongCatImageEditPipeline).
Output height/width are derived from the condition image (~1MP), so no
defaults are set here. The reference uses an empty negative prompt and
plain CFG (no renorm, no prompt rewrite).
"""
num_frames: int = 1
num_inference_steps: int = 50
guidance_scale: float = 4.5
negative_prompt: str = ""
enable_cfg_renorm: bool = False
enable_prompt_rewrite: bool = False
@dataclass
class LongCatImageEditTurboSamplingParams(LongCatImageEditSamplingParams):
"""LongCat-Image-Edit-Turbo: distilled, 8 steps, CFG disabled."""
num_inference_steps: int = 8
guidance_scale: float = 1.0
+31
View File
@@ -74,6 +74,7 @@ from sglang.multimodal_gen.configs.pipeline_configs.lingbot_video_moe import (
LingBotVideoMoEPipelineConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.longcat_image import (
LongCatImageEditPipelineConfig,
LongCatImagePipelineConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.longlive2 import LongLive2T2VConfig
@@ -150,6 +151,8 @@ from sglang.multimodal_gen.configs.sample.lingbot_world import (
LingBotWorldSamplingParams,
)
from sglang.multimodal_gen.configs.sample.longcat_image import (
LongCatImageEditSamplingParams,
LongCatImageEditTurboSamplingParams,
LongCatImageSamplingParams,
)
from sglang.multimodal_gen.configs.sample.longlive2 import LongLive2SamplingParams
@@ -1327,6 +1330,34 @@ def _register_configs():
],
)
# LongCat-Image-Edit-Turbo (registered before Edit so its detector wins)
register_configs(
sampling_param_cls=LongCatImageEditTurboSamplingParams,
pipeline_config_cls=LongCatImageEditPipelineConfig,
hf_model_paths=[
"meituan-longcat/LongCat-Image-Edit-Turbo",
],
model_detectors=[
lambda hf_id: "longcat" in hf_id.lower()
and "edit" in hf_id.lower()
and "turbo" in hf_id.lower(),
],
)
# LongCat-Image-Edit
register_configs(
sampling_param_cls=LongCatImageEditSamplingParams,
pipeline_config_cls=LongCatImageEditPipelineConfig,
hf_model_paths=[
"meituan-longcat/LongCat-Image-Edit",
],
model_detectors=[
lambda hf_id: "longcat" in hf_id.lower()
and "edit" in hf_id.lower()
and "turbo" not in hf_id.lower(),
],
)
_register_configs()
@@ -12,6 +12,10 @@ from transformers import PretrainedConfig
from transformers.utils import SAFE_WEIGHTS_INDEX_NAME
from sglang.multimodal_gen.configs.models import EncoderConfig
from sglang.multimodal_gen.configs.pipeline_configs.longcat_image import (
LongCatImageEditPipelineConfig,
LongCatImagePipelineConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.qwen_image import (
QwenImageEditPipelineConfig,
)
@@ -869,14 +873,16 @@ class TextEncoderLoader(ComponentLoader):
with model_device, skip_init_modules():
architectures = getattr(model_config, "architectures", [])
model_cls, _ = ModelRegistry.resolve_model_cls(architectures)
enable_image_understanding = (
True
if isinstance(
server_args.pipeline_config, QwenImageEditPipelineConfig
)
else False
enable_image_understanding = isinstance(
server_args.pipeline_config,
(QwenImageEditPipelineConfig, LongCatImageEditPipelineConfig),
)
model_config.enable_image_understanding = enable_image_understanding
# LongCat feeds its padded body to the DiT, so it must mask
# padding on the cache-free path; scoped so others are unchanged.
model_config.honor_cache_free_padding_mask = isinstance(
server_args.pipeline_config, LongCatImagePipelineConfig
)
model = model_cls(model_config)
if not isinstance(model, EncoderTensorParallelMixin):
@@ -443,6 +443,7 @@ class _LongCatSingleAttention(nn.Module):
self,
hidden_states: torch.Tensor,
image_rotary_emb: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
num_replicated_prefix: int = 0,
cos_sin_cache: Optional[torch.Tensor] = None,
positions: Optional[torch.Tensor] = None,
) -> torch.Tensor:
@@ -464,7 +465,7 @@ class _LongCatSingleAttention(nn.Module):
positions,
)
x = self.attn(q, k, v)
x = self.attn(q, k, v, num_replicated_prefix=num_replicated_prefix)
return x.flatten(2, 3).to(q.dtype)
@@ -564,6 +565,8 @@ class _SingleTransformerBlock(nn.Module):
attn_output = self.attn(
hidden_states=norm_hidden_states,
image_rotary_emb=image_rotary_emb,
# Text is replicated per SP rank; keep it out of the all-to-all.
num_replicated_prefix=text_seq_len,
cos_sin_cache=cos_sin_cache,
positions=positions,
)
@@ -194,6 +194,9 @@ class Qwen2_5_VLAttention(nn.Module):
def __init__(self, config: Qwen2_5_VLTextConfig, layer_idx: Optional[int] = None):
super().__init__()
self.config = config
self.honor_cache_free_padding_mask = getattr(
config, "honor_cache_free_padding_mask", False
)
self.layer_idx = layer_idx
if layer_idx is None:
logger.warning(
@@ -315,14 +318,14 @@ class Qwen2_5_VLAttention(nn.Module):
query_states = query_states.transpose(1, 2)
key_states = key_states.transpose(1, 2)
value_states = value_states.transpose(1, 2)
# Diffusion text encoding is cache-free and historically uses the native
# causal kernel; its trailing padding is removed during postprocessing.
# Cached generation still needs the explicit mask for padded batches.
# LongCat masks padding on the cache-free path too; others keep the
# original mask-free fast path unchanged.
honor_mask = use_cache or self.honor_cache_free_padding_mask
attn_output = self.attn(
query_states,
key_states,
value_states,
attn_mask=attention_mask if use_cache else None,
attn_mask=attention_mask if honor_mask else None,
)
attn_output = attn_output.reshape(bsz, q_len, -1).contiguous()
@@ -425,10 +428,27 @@ class Qwen2_5_VLDecoderLayer(nn.Module):
return hidden_states
def _build_causal_padding_mask(
attention_mask: torch.Tensor, inputs_embeds: torch.Tensor
) -> torch.Tensor:
"""Build a causal+padding bool mask ``[batch, 1, q_len, kv_len]``; True attends."""
q_len = inputs_embeds.shape[1]
kv_len = attention_mask.shape[-1]
device = inputs_embeds.device
q_idx = torch.arange(kv_len - q_len, kv_len, device=device).unsqueeze(-1)
kv_idx = torch.arange(kv_len, device=device).unsqueeze(0)
causal = (kv_idx <= q_idx).unsqueeze(0)
padding = attention_mask.to(device=device, dtype=torch.bool).unsqueeze(1)
return (causal & padding).unsqueeze(1)
class Qwen2_5_VLTextModel(nn.Module):
def __init__(self, config: PretrainedConfig):
super().__init__()
self.config = config
self.honor_cache_free_padding_mask = getattr(
config, "honor_cache_free_padding_mask", False
)
self.padding_idx = config.pad_token_id
self.vocab_size = config.vocab_size
@@ -552,6 +572,19 @@ class Qwen2_5_VLTextModel(nn.Module):
create_sliding_window_causal_mask(**mask_kwargs)
)
# create_causal_mask returns None for this _attn_implementation, so
# build the causal+padding mask here. LongCat-only.
if (
self.honor_cache_free_padding_mask
and causal_mask_mapping["full_attention"] is None
and isinstance(attention_mask, torch.Tensor)
and attention_mask.dim() == 2
and not bool(attention_mask.all())
):
causal_mask_mapping["full_attention"] = _build_causal_padding_mask(
attention_mask, inputs_embeds
)
hidden_states = inputs_embeds
# decoder layers
@@ -1123,7 +1156,12 @@ class Qwen2_5_VLForConditionalGeneration(TextEncoder):
super().__init__(config)
enable_image_understanding = config.enable_image_understanding
generation_config = config.generation_config
# LongCat-only; propagate to the text config (see TextEncoderLoader).
honor_cache_free_padding_mask = getattr(
config, "honor_cache_free_padding_mask", False
)
config = config.arch_config
config.text_config.honor_cache_free_padding_mask = honor_cache_free_padding_mask
self.model = Qwen2_5_VLModel(
config, enable_image_understanding=enable_image_understanding
)
@@ -1,12 +1,19 @@
"""LongCat-Image pipeline for SGLang."""
"""LongCat-Image pipelines (T2I and Edit) for SGLang."""
from sglang.multimodal_gen.runtime.pipelines_core import LoRAPipeline
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
ComposedPipelineBase,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages import (
ImageVAEEncodingStage,
InputValidationStage,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.longcat_image import (
LongCatPromptRewriteStage,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.longcat_image_edit import (
LongCatImageEditTextEncodingStage,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.utils import PRECISION_TO_TYPE
@@ -68,4 +75,62 @@ class LongCatImagePipeline(LoRAPipeline, ComposedPipelineBase):
self.add_standard_decoding_stage()
EntryClass = [LongCatImagePipeline]
class LongCatImageEditPipeline(LoRAPipeline, ComposedPipelineBase):
"""Pipeline for LongCat-Image-Edit image editing (I2I).
Mirrors diffusers LongCatImageEditPipeline. The output resolution is
derived from the condition image (~1MP, /16); the reference image is
VAE-encoded (argmax) and concatenated after the noisy latents; the
edit instruction is encoded jointly with the image via Qwen2.5-VL.
"""
pipeline_name = "LongCatImageEditPipeline"
_required_config_modules = [
"text_encoder",
"tokenizer",
"text_processor",
"vae",
"transformer",
"scheduler",
]
def create_pipeline_stages(self, server_args: ServerArgs):
# 1. Load the condition image, resize it to the calculated output
# resolution, and set batch.height/width (pipeline config hooks).
self.add_stage(InputValidationStage())
# 2. Joint text+image (VL) prompt encoding. Also encodes the negative
# prompt against the same image when CFG is enabled.
self.add_stage(
LongCatImageEditTextEncodingStage(
text_encoder=self.get_module("text_encoder"),
tokenizer=self.get_module("tokenizer"),
text_processor=self.get_module("text_processor"),
text_encoder_dtype=PRECISION_TO_TYPE[
server_args.pipeline_config.text_encoder_precisions[0]
],
)
)
# 3. Reference-image VAE encoding -> packed batch.image_latent, which
# DenoisingStage concatenates after the noisy latents (dim=1).
self.add_stage(ImageVAEEncodingStage(vae=self.get_module("vae")))
# 4. Latent preparation (noise drawn in prompt-embeds dtype).
self.add_standard_latent_preparation_stage()
# 5. Timestep preparation (mu from the packed noisy token count only).
self.add_standard_timestep_preparation_stage(
prepare_extra_kwargs=[_prepare_mu],
)
# 6. Standard denoising loop; slice_noise_pred drops the reference
# tokens from each prediction before CFG/scheduler step.
self.add_standard_denoising_stage()
# 7. Standard VAE decoding
self.add_standard_decoding_stage()
EntryClass = [LongCatImagePipeline, LongCatImageEditPipeline]
@@ -257,6 +257,7 @@ class ComposedPipelineBase(ABC):
"QwenImageEditPipeline": {"vae"},
"QwenImageEditPlusPipeline": {"vae"},
"QwenImageLayeredPipeline": {"vae", "transformer"},
"LongCatImageEditPipeline": {"vae"},
"GlmImagePipeline": {"vae", "transformer"},
"WanImageToVideoPipeline": {"vae"},
"WanImageToVideoDmdPipeline": {"vae"},
@@ -1063,6 +1064,10 @@ class ComposedPipelineBase(ABC):
main_process_only=True,
)
self.component_residency_manager = get_global_component_residency_manager(
self, server_args
)
self.executor.component_residency_manager = self.component_residency_manager
return self.executor.execute_group_with_profiling(
self.stages, batches, server_args
)
@@ -0,0 +1,256 @@
"""Text+image (VL) prompt encoding stage for LongCat-Image-Edit (I2I).
Mirrors diffusers ``LongCatImageEditPipeline._encode_prompt``: the condition
image (already resized to the output resolution by ``InputValidationStage``)
is downscaled by 2x and fed to the Qwen2.5-VL vision tower; the prompt body is
quote-aware tokenized to a fixed 512 tokens; the edit system prefix has its
``<|image_pad|>`` placeholder expanded to the per-image token count. The
resulting hidden states are sliced from the ``<|vision_start|>`` token through
the 512-token body, so the DiT conditioning includes the VL image tokens.
"""
import PIL.Image
import torch
from sglang.multimodal_gen.configs.pipeline_configs.longcat_image import (
_tokenize_prompt_for_encode,
)
from sglang.multimodal_gen.runtime.distributed import get_local_torch_device
from sglang.multimodal_gen.runtime.managers.forward_context import set_forward_context
from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager import (
ComponentUse,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
IMAGE_TOKEN = "<|image_pad|>"
# Copied from diffusers LongCatImageEditPipeline.prompt_template_encode_prefix/suffix.
PROMPT_TEMPLATE_ENCODE_PREFIX = (
"<|im_start|>system\nAs an image editing expert, first analyze the content and "
"attributes of the input image(s). Then, based on the user's editing instructions, "
"clearly and precisely determine how to modify the given image(s), ensuring that "
"only the specified parts are altered and all other aspects remain consistent with "
"the original(s).<|im_end|>\n<|im_start|>user\n"
"<|vision_start|><|image_pad|><|vision_end|>"
)
PROMPT_TEMPLATE_ENCODE_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n"
class LongCatImageEditTextEncodingStage(PipelineStage):
"""Encode the edit instruction together with the reference image.
The stage fills ``batch.prompt_embeds`` (and negative counterparts under
CFG) with hidden states sliced as ``[vision_start ... vision_end, 512-token
body]``. Both branches share the same VL image inputs, matching the
diffusers reference which encodes positive and negative prompts against
the same ``prompt_image``.
"""
deduplicated_output_fields = (
"prompt_embeds",
"negative_prompt_embeds",
"prompt_embeds_mask",
"negative_prompt_embeds_mask",
"prompt_seq_lens",
"negative_prompt_seq_lens",
)
def __init__(
self,
text_encoder,
tokenizer,
text_processor,
text_encoder_dtype: torch.dtype,
):
super().__init__()
self.text_encoder = text_encoder
self.tokenizer = tokenizer
self.text_processor = text_processor
self.text_encoder_dtype = text_encoder_dtype
self._suffix_ids: list[int] | None = None
def component_uses(
self, server_args: ServerArgs, stage_name: str | None = None
) -> list[ComponentUse]:
stage_name = self._component_stage_name(stage_name)
return [
ComponentUse(
stage_name,
"text_encoder",
target_dtype=self.text_encoder_dtype,
memory_intensive=True,
),
]
def _get_suffix_ids(self) -> list[int]:
if self._suffix_ids is None:
self._suffix_ids = self.tokenizer(
PROMPT_TEMPLATE_ENCODE_SUFFIX, add_special_tokens=False
)["input_ids"]
return self._suffix_ids
def _build_expanded_prefix_ids(
self, image_grid_thw: torch.Tensor
) -> tuple[list[int], int]:
"""Expand ``<|image_pad|>`` to the per-image token count and tokenize.
Returns (prefix_token_ids, prefix_len) where prefix_len is the index of
``<|vision_start|>`` — the slice start for the DiT conditioning.
"""
merge_length = self.text_processor.image_processor.merge_size**2
num_image_tokens = int(image_grid_thw.prod().item()) // merge_length
text = PROMPT_TEMPLATE_ENCODE_PREFIX
while IMAGE_TOKEN in text:
text = text.replace(IMAGE_TOKEN, "<|placeholder|>" * num_image_tokens, 1)
text = text.replace("<|placeholder|>", IMAGE_TOKEN)
prefix_ids = self.tokenizer(text, add_special_tokens=False)["input_ids"]
vision_start_id = self.tokenizer.convert_tokens_to_ids("<|vision_start|>")
prefix_len = prefix_ids.index(vision_start_id)
return prefix_ids, prefix_len
def _encode(
self,
prompt: list[str],
pixel_values: torch.Tensor,
image_grid_thw: torch.Tensor,
prefix_ids: list[int],
prefix_len: int,
device: torch.device,
) -> torch.Tensor:
body = _tokenize_prompt_for_encode(prompt, self.tokenizer)
suffix_ids = self._get_suffix_ids()
suffix_len = len(suffix_ids)
batch_size = body.input_ids.size(0)
prefix_ids_t = (
torch.tensor(prefix_ids, dtype=body.input_ids.dtype)
.unsqueeze(0)
.expand(batch_size, -1)
)
suffix_ids_t = (
torch.tensor(suffix_ids, dtype=body.input_ids.dtype)
.unsqueeze(0)
.expand(batch_size, -1)
)
prefix_mask_t = torch.ones(
batch_size, len(prefix_ids), dtype=body.attention_mask.dtype
)
suffix_mask_t = torch.ones(
batch_size, suffix_len, dtype=body.attention_mask.dtype
)
input_ids = torch.cat((prefix_ids_t, body.input_ids, suffix_ids_t), dim=-1).to(
device
)
attention_mask = torch.cat(
(prefix_mask_t, body.attention_mask, suffix_mask_t), dim=-1
).to(device)
with set_forward_context(current_timestep=0, attn_metadata=None):
outputs = self.text_encoder(
input_ids=input_ids,
attention_mask=attention_mask,
pixel_values=pixel_values,
image_grid_thw=image_grid_thw,
output_hidden_states=True,
use_cache=False,
)
hidden_states = outputs.hidden_states[-1]
# Keep [vision_start ... vision_end, 512-token body].
return hidden_states[:, prefix_len:-suffix_len, :]
@staticmethod
def _all_ones_conditioning(prompt_embeds: torch.Tensor):
batch_size, seq_len = prompt_embeds.shape[:2]
mask = torch.ones(
batch_size, seq_len, dtype=torch.bool, device=prompt_embeds.device
)
return mask, [seq_len] * batch_size
@torch.no_grad()
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
device = get_local_torch_device()
image = batch.condition_image
if isinstance(image, list):
if len(image) != 1:
raise ValueError(
"LongCat-Image-Edit supports exactly one condition image, "
f"got {len(image)}."
)
image = image[0]
if image is None:
# Mirrors ImageEncodingStage: nothing to encode without an image
# (e.g. warmup requests). Downstream stages will validate inputs.
logger.debug(
"LongCatImageEditTextEncodingStage skipped: no condition image."
)
return batch
prompt = batch.prompt if isinstance(batch.prompt, list) else [batch.prompt]
if len(prompt) != 1:
raise ValueError(
"LongCat-Image-Edit supports a single prompt per request, got "
f"{len(prompt)}."
)
# The VL tower sees the condition image at half the output resolution
# (matching diffusers: image_processor.resize(image, h // 2, w // 2)).
prompt_image = image.resize(
(int(batch.width) // 2, int(batch.height) // 2),
PIL.Image.Resampling.LANCZOS,
)
vl_inputs = self.text_processor.image_processor(
images=prompt_image, return_tensors="pt"
)
image_grid_thw = vl_inputs["image_grid_thw"].to(device)
prefix_ids, prefix_len = self._build_expanded_prefix_ids(image_grid_thw)
with self.use_declared_component(
component_name="text_encoder",
module=self.text_encoder,
) as text_encoder:
assert text_encoder is not None
self.text_encoder = text_encoder
pixel_values = vl_inputs["pixel_values"].to(
device=device, dtype=self.text_encoder_dtype
)
prompt_embeds = self._encode(
prompt, pixel_values, image_grid_thw, prefix_ids, prefix_len, device
)
batch.prompt_embeds.append(prompt_embeds)
mask, seq_lens = self._all_ones_conditioning(prompt_embeds)
batch.prompt_embeds_mask = [mask]
batch.prompt_seq_lens = [seq_lens]
if batch.do_classifier_free_guidance:
negative_prompt = batch.negative_prompt or ""
if isinstance(negative_prompt, list):
negative_prompt = negative_prompt[:1]
else:
negative_prompt = [negative_prompt]
negative_prompt_embeds = self._encode(
negative_prompt,
pixel_values,
image_grid_thw,
prefix_ids,
prefix_len,
device,
)
batch.negative_prompt_embeds.append(negative_prompt_embeds)
neg_mask, neg_seq_lens = self._all_ones_conditioning(
negative_prompt_embeds
)
batch.negative_prompt_embeds_mask = [neg_mask]
batch.negative_prompt_seq_lens = [neg_seq_lens]
return batch
@@ -0,0 +1,157 @@
"""Unit tests for LongCat-Image-Edit pipeline config hooks (CPU only)."""
import types
import pytest
import torch
from sglang.multimodal_gen.configs.pipeline_configs.base import ModelTaskType
from sglang.multimodal_gen.configs.pipeline_configs.longcat_image import (
LongCatImageEditPipelineConfig,
LongCatImagePipelineConfig,
_calculate_edit_dimensions,
)
@pytest.fixture
def edit_config():
return LongCatImageEditPipelineConfig()
def _make_batch(**kwargs):
return types.SimpleNamespace(**kwargs)
def test_edit_dimensions_match_diffusers_formula():
import math
for w, h in [(1000, 1500), (1024, 1024), (1920, 1080), (333, 777)]:
ratio = w / h
got_w, got_h = _calculate_edit_dimensions(1024 * 1024, ratio)
# diffusers reference (ceil to /16)
ref_w = math.sqrt(1024 * 1024 * ratio)
ref_h = ref_w / ratio
ref_w = ref_w if ref_w % 16 == 0 else (ref_w // 16 + 1) * 16
ref_h = ref_h if ref_h % 16 == 0 else (ref_h // 16 + 1) * 16
assert (got_w, got_h) == (int(ref_w), int(ref_h))
assert got_w % 16 == 0 and got_h % 16 == 0
def test_edit_config_task_type_and_generator(edit_config):
assert edit_config.task_type == ModelTaskType.I2I
assert edit_config.generator_device == "cpu"
def test_slice_noise_pred_drops_reference_tokens(edit_config):
latents = torch.zeros(1, 100, 64)
noise = torch.arange(200, dtype=torch.float32).view(1, 200, 1).expand(1, 200, 64)
sliced = edit_config.slice_noise_pred(noise, latents)
assert sliced.shape == (1, 100, 64)
assert torch.equal(sliced, noise[:, :100])
def test_maybe_prepare_latent_ids_returns_none(edit_config):
assert edit_config.maybe_prepare_latent_ids(torch.zeros(1, 16, 8, 8)) is None
def test_preprocess_vae_encode_squeezes_frames_dim(edit_config):
image_5d = torch.zeros(1, 3, 1, 64, 64)
assert edit_config.preprocess_vae_encode(image_5d, None).shape == (1, 3, 64, 64)
image_4d = torch.zeros(1, 3, 64, 64)
assert edit_config.preprocess_vae_encode(image_4d, None).shape == (1, 3, 64, 64)
def test_postprocess_image_latent_packs_2x2(edit_config):
latent = torch.randn(1, 16, 8, 8)
batch = _make_batch(batch_size=1)
packed = edit_config.postprocess_image_latent(latent, batch)
assert packed.shape == (1, 16, 64) # (8//2 * 8//2, 16*4)
def test_edit_img_ids_modalities_and_offset(edit_config):
# 1024x1024 -> latent 128x128 -> packed grid 64x64 -> 4096 tokens per image
batch = _make_batch(height=1024, width=1024)
num_token = 600 # text length including VL image tokens
img_ids = edit_config._edit_img_ids(batch, num_token, torch.device("cpu"))
assert img_ids.shape == (2 * 64 * 64, 3)
noisy_ids, ref_ids = img_ids[: 64 * 64], img_ids[64 * 64 :]
assert torch.all(noisy_ids[:, 0] == 1)
assert torch.all(ref_ids[:, 0] == 2)
# same grid layout, offset by text length
assert torch.equal(noisy_ids[:, 1:], ref_ids[:, 1:])
assert noisy_ids[0, 1].item() == num_token
assert noisy_ids[0, 2].item() == num_token
assert noisy_ids[-1, 1].item() == num_token + 63
assert noisy_ids[-1, 2].item() == num_token + 63
def test_prepare_pos_cond_kwargs_uses_prompt_embeds_length(edit_config):
prompt_embeds = torch.zeros(1, 700, 3072)
batch = _make_batch(height=512, width=512, prompt_embeds=[prompt_embeds])
kwargs = edit_config.prepare_pos_cond_kwargs(
batch, torch.device("cpu"), None, torch.float32
)
txt_ids, img_ids = kwargs["txt_ids"], kwargs["img_ids"]
assert txt_ids.shape == (700, 3)
assert torch.all(txt_ids[:, 0] == 0)
# 512 -> latent 64 -> packed grid 32x32
assert img_ids.shape == (2 * 32 * 32, 3)
assert img_ids[0, 1].item() == 700
def test_t2i_config_unchanged():
t2i = LongCatImagePipelineConfig()
assert t2i.task_type == ModelTaskType.T2I
# T2I still slices nothing and builds latent ids at preparation time
noise = torch.zeros(1, 10, 64)
assert t2i.slice_noise_pred(noise, torch.zeros(1, 5, 64)).shape == (1, 10, 64)
ids = t2i.maybe_prepare_latent_ids(torch.zeros(1, 16, 8, 8))
assert ids is not None and ids.shape == (16, 3)
def test_expand_conditioning_repeats_embeds_for_num_outputs():
# Single prompt, num_outputs_per_prompt=2: text encoding produces per-prompt
# (batch 1) embeds while latents are built at batch 2, so the conditioning
# must be repeated to match or the DiT sees mismatched batch dims.
config = LongCatImageEditPipelineConfig()
batch = _make_batch(
prompt=["edit it"],
num_outputs_per_prompt=2,
prompt_embeds=[torch.randn(1, 850, 3584)],
negative_prompt_embeds=[torch.randn(1, 850, 3584)],
prompt_embeds_mask=[torch.ones(1, 850, dtype=torch.bool)],
negative_prompt_embeds_mask=[torch.ones(1, 850, dtype=torch.bool)],
prompt_seq_lens=[[850]],
negative_prompt_seq_lens=[[850]],
)
pos0 = batch.prompt_embeds[0]
config.expand_conditioning_to_sample_batch(batch)
assert batch.prompt_embeds[0].shape == (2, 850, 3584)
assert batch.negative_prompt_embeds[0].shape == (2, 850, 3584)
assert batch.prompt_embeds_mask[0].shape == (2, 850)
assert batch.negative_prompt_embeds_mask[0].shape == (2, 850)
assert batch.prompt_seq_lens[0] == [850, 850]
assert batch.negative_prompt_seq_lens[0] == [850, 850]
# Each sample is the original prompt, not garbage.
assert torch.equal(batch.prompt_embeds[0][0], pos0[0])
assert torch.equal(batch.prompt_embeds[0][1], pos0[0])
def test_expand_conditioning_noop_for_single_output():
config = LongCatImagePipelineConfig()
pe = torch.randn(1, 850, 3584)
batch = _make_batch(
prompt=["a photo"],
num_outputs_per_prompt=1,
prompt_embeds=[pe],
negative_prompt_embeds=None,
prompt_embeds_mask=[torch.ones(1, 850, dtype=torch.bool)],
negative_prompt_embeds_mask=None,
prompt_seq_lens=[[850]],
negative_prompt_seq_lens=None,
)
config.expand_conditioning_to_sample_batch(batch)
assert batch.prompt_embeds[0] is pe # untouched
assert batch.prompt_embeds[0].shape == (1, 850, 3584)
@@ -148,7 +148,7 @@ def test_text_mlp_uses_single_rank_when_intermediate_size_is_not_tp_divisible(
assert isinstance(layer.mlp.down_proj, ReplicatedLinear)
def test_explicit_attention_mask_is_limited_to_cached_generation(monkeypatch):
def test_explicit_attention_mask_is_honored_without_a_cache(monkeypatch):
attention = Qwen2_5_VLAttention.__new__(Qwen2_5_VLAttention)
nn.Module.__init__(attention)
attention.q_proj = nn.Identity()
@@ -174,9 +174,19 @@ def test_explicit_attention_mask_is_limited_to_cached_generation(monkeypatch):
"position_ids": torch.zeros(3, 1, 2, dtype=torch.long),
}
# LongCat opts into masking the padded body on the cache-free path.
attention.honor_cache_free_padding_mask = True
attention(**kwargs, use_cache=False)
attention(**kwargs, use_cache=True)
assert attention.attn.masks[0] is explicit_mask
assert attention.attn.masks[1] is explicit_mask
# Every other pipeline keeps the original behavior: mask dropped when
# cache-free, honored only under cached generation.
attention.attn.masks.clear()
attention.honor_cache_free_padding_mask = False
attention(**kwargs, use_cache=False)
attention(**kwargs, use_cache=True)
assert attention.attn.masks[0] is None
assert attention.attn.masks[1] is explicit_mask