[diffusion] model: support ERNIE-Image (#22439)

This commit is contained in:
dyhsup
2026-04-11 17:18:11 +08:00
committed by GitHub
parent 7ab94e438c
commit 8cca9747f5
13 changed files with 1402 additions and 1 deletions
@@ -0,0 +1,50 @@
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass, field
from typing import Tuple
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
def _is_transformer_layer(n: str, m) -> bool:
return "layers" in n and str.isdigit(n.split(".")[-1])
@dataclass
class ErnieImageArchConfig(DiTArchConfig):
patch_size: int = 1
in_channels: int = 128
out_channels: int = 128
num_layers: int = 36
attention_head_dim: int = 128
num_attention_heads: int = 32
ffn_hidden_size: int = 12288
text_in_dim: int = 3072
rope_theta: int = 256
rope_axes_dim: Tuple[int, int, int] = (32, 48, 48)
eps: float = 1e-6
qk_layernorm: bool = True
stacked_params_mapping: list[tuple[str, str, str]] = field(default_factory=list)
param_names_mapping: dict = field(
default_factory=lambda: {
r"(.*)\.mlp\.gate_proj\.(.*)": (r"\1.mlp.gate_up_proj.\2", 0, 2),
r"(.*)\.mlp\.up_proj\.(.*)": (r"\1.mlp.gate_up_proj.\2", 1, 2),
}
)
_fsdp_shard_conditions: list = field(
default_factory=lambda: [_is_transformer_layer]
)
def __post_init__(self):
super().__post_init__()
self.hidden_size = self.num_attention_heads * self.attention_head_dim
self.num_channels_latents = self.out_channels
@dataclass
class ErnieImageDitConfig(DiTConfig):
arch_config: DiTArchConfig = field(default_factory=ErnieImageArchConfig)
prefix: str = "ernieimage"
@@ -0,0 +1,72 @@
# SPDX-License-Identifier: Apache-2.0
"""Mistral3 text encoder configuration for SGLang diffusion models."""
from dataclasses import dataclass, field
from sglang.multimodal_gen.configs.models.encoders.base import (
TextEncoderArchConfig,
TextEncoderConfig,
)
def _is_transformer_layer(n: str, m) -> bool:
return "layers" in n and str.isdigit(n.split(".")[-1])
def _is_embeddings(n: str, m) -> bool:
return n.endswith("embed_tokens")
def _is_final_norm(n: str, m) -> bool:
return n.endswith("norm")
@dataclass
class Mistral3EncoderArchConfig(TextEncoderArchConfig):
"""Mistral3 text encoder architecture config for ErnieImage.
Uses Mistral3Model (vision-language model) as text encoder,
extracting the second-to-last hidden state layer.
"""
vocab_size: int = 131072
hidden_size: int = 3072
intermediate_size: int = 9216
num_hidden_layers: int = 26
num_attention_heads: int = 32
num_key_value_heads: int = 8
hidden_act: str = "silu"
max_position_embeddings: int = 262144
rms_norm_eps: float = 1e-5
pad_token_id: int = 11
bos_token_id: int = 1
eos_token_id: int = 2
tie_word_embeddings: bool = True
head_dim: int = 128
hidden_state_skip_layer: int = 2 # Use second-to-last hidden state
text_len: int = 0
stacked_params_mapping: list[tuple[str, str, str]] = field(
default_factory=lambda: [
(".qkv_proj", ".q_proj", "q"),
(".qkv_proj", ".k_proj", "k"),
(".qkv_proj", ".v_proj", "v"),
(".gate_up_proj", ".gate_proj", 0),
(".gate_up_proj", ".up_proj", 1),
]
)
_fsdp_shard_conditions: list = field(
default_factory=lambda: [_is_transformer_layer, _is_embeddings, _is_final_norm]
)
def __post_init__(self):
# Let the parent populate tokenizer_kwargs["max_length"] = self.text_len
super().__post_init__()
@dataclass
class Mistral3EncoderConfig(TextEncoderConfig):
arch_config: TextEncoderArchConfig = field(
default_factory=Mistral3EncoderArchConfig
)
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass, field
from sglang.multimodal_gen.configs.models.vaes.base import VAEArchConfig, VAEConfig
@dataclass
class ErnieImageVAEArchConfig(VAEArchConfig):
spatial_compression_ratio: int = 8
base_dim: int = 96
decoder_base_dim: int | None = None
z_dim: int = 32
dim_mult: tuple[int, ...] = (1, 2, 4, 4)
num_res_blocks: int = 2
attn_scales: tuple[float, ...] = ()
temperal_downsample: tuple[bool, ...] = (False, True, True)
dropout: float = 0.0
is_residual: bool = False
in_channels: int = 3
out_channels: int = 3
patch_size: int | None = None
scale_factor_temporal: int = 4
scale_factor_spatial: int = 8
clip_output: bool = True
@dataclass
class ErnieImageVAEConfig(VAEConfig):
arch_config: ErnieImageVAEArchConfig = field(
default_factory=ErnieImageVAEArchConfig
)
use_feature_cache: bool = True
use_tiling: bool = False
use_temporal_tiling: bool = False
use_parallel_tiling: bool = False
def get_vae_scale_factor(self):
# 8 spatial compression (VAE) * 2 patch = 16 total, consistent with pipeline config
return self.arch_config.scale_factor_spatial
def __post_init__(self):
self.blend_num_frames = (
self.tile_sample_min_num_frames - self.tile_sample_stride_num_frames
) * 2
def post_init(self):
if self.arch_config.dim_mult:
self.arch_config.vae_scale_factor = 2 ** (
len(self.arch_config.dim_mult) - 1
)
else:
self.arch_config.vae_scale_factor = self.arch_config.scale_factor_spatial
self.arch_config.spatial_compression_ratio = self.arch_config.vae_scale_factor
@@ -0,0 +1,206 @@
# SPDX-License-Identifier: Apache-2.0
from dataclasses import dataclass, field
from typing import Callable
import torch
from sglang.multimodal_gen.configs.models import DiTConfig, EncoderConfig, VAEConfig
from sglang.multimodal_gen.configs.models.dits.ernie_image import ErnieImageDitConfig
from sglang.multimodal_gen.configs.models.encoders.mistral3 import Mistral3EncoderConfig
from sglang.multimodal_gen.configs.models.vaes.ernie_image import ErnieImageVAEConfig
from sglang.multimodal_gen.configs.pipeline_configs.base import (
ImagePipelineConfig,
ModelTaskType,
shard_rotary_emb_for_sp,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
def ernie_image_postprocess_text(outputs, _text_inputs, hidden_layer_index=-2):
hidden_states = outputs.hidden_states[hidden_layer_index]
return hidden_states
def _patchify_latents(latents: torch.Tensor) -> torch.Tensor:
b, c, h, w = latents.shape
latents = latents.view(b, c, h // 2, 2, w // 2, 2)
latents = latents.permute(0, 1, 3, 5, 2, 4).reshape(b, c * 4, h // 2, w // 2)
return latents
def _unpatchify_latents(latents: torch.Tensor) -> torch.Tensor:
b, c, h, w = latents.shape
latents = latents.reshape(b, c // 4, 2, 2, h, w)
latents = latents.permute(0, 1, 4, 2, 5, 3).reshape(b, c // 4, h * 2, w * 2)
return latents
@dataclass
class ErnieImagePipelineConfig(ImagePipelineConfig):
"""Configuration for the ErnieImage text-to-image pipeline."""
should_use_guidance: bool = False
task_type: ModelTaskType = ModelTaskType.T2I
pe_model_max_length: int = None
vae_tiling: bool = False
vae_sp: bool = False
dit_config: DiTConfig = field(default_factory=ErnieImageDitConfig)
vae_config: VAEConfig = field(default_factory=ErnieImageVAEConfig)
enable_autocast: bool = False
text_encoder_configs: tuple[EncoderConfig, ...] = field(
default_factory=lambda: (Mistral3EncoderConfig(),)
)
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16",))
preprocess_text_funcs: tuple[Callable[[str], str], ...] = field(
default_factory=lambda: (None,)
)
postprocess_text_funcs: tuple[Callable, ...] = field(
default_factory=lambda: (ernie_image_postprocess_text,)
)
text_encoder_extra_args: list[dict] = field(
default_factory=lambda: [
dict(
padding=False,
truncation=True,
max_length=None,
add_special_tokens=True,
),
]
)
def tokenize_prompt(self, prompt: list[str], tokenizer, tok_kwargs) -> dict:
max_length = tok_kwargs.get("max_length")
if max_length is not None:
check = tokenizer(
prompt,
truncation=False,
return_tensors="pt",
add_special_tokens=tok_kwargs.get("add_special_tokens", True),
)
for i, ids in enumerate(check["input_ids"]):
if ids.shape[-1] > max_length:
logger.warning(
"Prompt #%d has %d tokens, exceeds max_length=%d. "
"The tail will be silently truncated.",
i,
ids.shape[-1],
max_length,
)
return tokenizer(prompt, **tok_kwargs)
def prepare_sigmas(self, sigmas, num_inference_steps):
return self._prepare_sigmas(sigmas, num_inference_steps)
def get_vae_scale_factor(self):
return 16
def prepare_latent_shape(self, batch, batch_size, num_frames):
vae_scale_factor = self.get_vae_scale_factor()
latent_h = batch.height // vae_scale_factor
latent_w = batch.width // vae_scale_factor
num_channels = self.dit_config.arch_config.in_channels # 128
shape = (batch_size, num_channels, latent_h, latent_w)
return shape
def maybe_pack_latents(self, latents, batch_size, batch):
return latents
def get_decode_scale_and_shift(self, device, dtype, vae):
if hasattr(vae, "bn") and vae.bn is not None:
bn_mean = vae.bn.running_mean.view(1, -1, 1, 1).to(device, dtype)
bn_var = vae.bn.running_var.view(1, -1, 1, 1).to(device, dtype)
bn_std = torch.sqrt(bn_var + 1e-5)
return 1.0 / bn_std, bn_mean
return 1.0, None
@staticmethod
def get_freqs_cis(img_shapes, txt_seq_lens, rotary_emb, device, dtype):
freqs = rotary_emb(img_shapes, txt_seq_lens, device=device)
if isinstance(freqs, tuple) and len(freqs) == 2:
img_freqs, txt_freqs = freqs
img_cos = img_freqs.real.to(dtype=torch.float32).contiguous()
img_sin = img_freqs.imag.to(dtype=torch.float32).contiguous()
txt_cos = txt_freqs.real.to(dtype=torch.float32).contiguous()
txt_sin = txt_freqs.imag.to(dtype=torch.float32).contiguous()
img_cache = torch.cat([img_cos, img_sin], dim=-1)
txt_cache = torch.cat([txt_cos, txt_sin], dim=-1)
return img_cache, txt_cache
cos = freqs.real.to(dtype=torch.float32).contiguous()
sin = freqs.imag.to(dtype=torch.float32).contiguous()
return torch.cat([cos, sin], dim=-1)
def _prepare_cond_kwargs(self, batch, prompt_embeds, rotary_emb, device, dtype):
batch_size = prompt_embeds[0].shape[0]
height = batch.height
width = batch.width
vae_scale_factor = self.get_vae_scale_factor()
img_shapes = [
[
(
1,
height // vae_scale_factor,
width // vae_scale_factor,
)
]
] * batch_size
txt_seq_lens = [prompt_embeds[0].shape[1]]
if rotary_emb is None:
return {
"img_shapes": img_shapes,
"txt_seq_lens": txt_seq_lens,
"freqs_cis": None,
}
freqs_cis = self.get_freqs_cis(
img_shapes, txt_seq_lens, rotary_emb, device, dtype
)
if isinstance(freqs_cis, tuple):
img_cache, txt_cache = freqs_cis
img_cache = shard_rotary_emb_for_sp(img_cache)
freqs_cis = (img_cache, txt_cache)
return {
"txt_seq_lens": txt_seq_lens,
"freqs_cis": freqs_cis,
"img_shapes": img_shapes,
}
def prepare_pos_cond_kwargs(self, batch, device, rotary_emb, dtype):
return self._prepare_cond_kwargs(
batch, batch.prompt_embeds, rotary_emb, device, dtype
)
def prepare_neg_cond_kwargs(self, batch, device, rotary_emb, dtype):
return self._prepare_cond_kwargs(
batch, batch.negative_prompt_embeds, rotary_emb, device, dtype
)
def _check_vae_has_bn(self, vae):
if not hasattr(self, "_vae_has_bn_cache"):
self._vae_has_bn_cache = hasattr(vae, "bn") and vae.bn is not None
return self._vae_has_bn_cache
def preprocess_decoding(self, latents, server_args=None, vae=None):
if vae is not None and self._check_vae_has_bn(vae):
latents = _unpatchify_latents(latents)
return latents
def post_denoising_loop(self, latents, batch):
return latents
@@ -0,0 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
"""Sampling parameters for ErnieImage."""
from dataclasses import dataclass
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
@dataclass
class ErnieImageSamplingParams(SamplingParams):
negative_prompt: str = " "
num_frames: int = 1
guidance_scale: float = 5.0
num_inference_steps: int = 50
use_pe: bool = True
@@ -197,6 +197,9 @@ class SamplingParams:
return_file_paths_only: bool = True
enable_sequence_shard: bool | None = None
# Prompt enhancement (ErnieImage)
use_pe: bool | None = None
def _set_output_file_ext(self):
# add extension if needed
if not any(
+17
View File
@@ -42,6 +42,9 @@ from sglang.multimodal_gen.configs.pipeline_configs import (
ZImagePipelineConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.base import PipelineConfig
from sglang.multimodal_gen.configs.pipeline_configs.ernie_image import (
ErnieImagePipelineConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.flux import (
Flux2KleinPipelineConfig,
Flux2PipelineConfig,
@@ -74,6 +77,7 @@ from sglang.multimodal_gen.configs.pipeline_configs.wan import (
Wan2_2_T2V_A14B_Config,
Wan2_2_TI2V_5B_Config,
)
from sglang.multimodal_gen.configs.sample.ernie_image import ErnieImageSamplingParams
from sglang.multimodal_gen.configs.sample.flux import (
Flux2KleinSamplingParams,
Flux2SamplingParams,
@@ -913,6 +917,19 @@ def _register_configs():
],
)
# ErnieImage
register_configs(
sampling_param_cls=ErnieImageSamplingParams,
pipeline_config_cls=ErnieImagePipelineConfig,
hf_model_paths=[
"baidu/ERNIE-Image",
"baidu/ERNIE-Image-Turbo",
],
model_detectors=[
lambda hf_id: "ernie-image" in hf_id.lower(),
],
)
_register_configs()
@@ -36,6 +36,15 @@ router = APIRouter(prefix="/v1/images", tags=["images"])
logger = init_logger(__name__)
def _get_extra_field(request, field_name):
"""Get a field from model_extra, with fallback to nested extra_body dict."""
extra = request.model_extra or {}
value = extra.get(field_name)
if value is None and isinstance(extra.get("extra_body"), dict):
value = extra["extra_body"].get(field_name)
return value
def _read_b64_for_paths(paths: list[str]) -> list[str]:
"""Read and base64-encode each file. Must be called before cloud upload deletes them."""
result = []
@@ -137,6 +146,7 @@ async def generations(
upscaling_model_path=request.upscaling_model_path,
upscaling_scale=request.upscaling_scale,
perf_dump_path=request.perf_dump_path,
use_pe=_get_extra_field(request, "use_pe"),
)
batch = prepare_request(
server_args=server_args,
@@ -4,7 +4,7 @@ from abc import ABC
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
# Image API protocol models
@@ -24,6 +24,8 @@ class ImageResponse(BaseModel):
class ImageGenerationsRequest(BaseModel):
model_config = ConfigDict(extra="allow")
prompt: str
model: Optional[str] = None
n: Optional[int] = 1
@@ -0,0 +1,162 @@
# SPDX-License-Identifier: Apache-2.0
import json
import os
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from sglang.multimodal_gen.runtime.distributed import get_local_torch_device
from sglang.multimodal_gen.runtime.loader.component_loaders.component_loader import (
ComponentLoader,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
def _read_model_max_length(model_path: str) -> int | None:
"""Read model_max_length from tokenizer_config.json in the given directory."""
config_path = os.path.join(model_path, "tokenizer_config.json")
if os.path.exists(config_path):
try:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
val = config.get("model_max_length")
if val is not None:
return int(val)
except Exception as e:
logger.warning(
"Failed to read tokenizer_config.json from %s: %s", model_path, e
)
return None
class PEModelWrapper:
def __init__(self, model, tokenizer, device, model_max_length: int):
self.model = model
self.pe_tokenizer = tokenizer
self.device = device
self.model_max_length = model_max_length
def generate(self, prompt: str, sampling_params: dict) -> dict:
inputs = self.pe_tokenizer(
prompt,
return_tensors="pt",
truncation=True,
max_length=self.model_max_length,
).to(self.device)
input_len = inputs["input_ids"].shape[1]
generate_kwargs = dict(
**inputs,
max_new_tokens=sampling_params.get("max_new_tokens", self.model_max_length),
do_sample=True,
)
temperature = sampling_params.get("temperature")
top_p = sampling_params.get("top_p")
if temperature is not None:
generate_kwargs["temperature"] = temperature
if top_p is not None:
generate_kwargs["top_p"] = top_p
with torch.no_grad():
output_ids = self.model.generate(**generate_kwargs)
new_tokens = output_ids[0, input_len:]
text = self.pe_tokenizer.decode(new_tokens, skip_special_tokens=True)
return {"text": text}
def to(self, *args, **kwargs):
"""Move underlying model to device."""
self.model = self.model.to(*args, **kwargs)
if args:
device = args[0]
if isinstance(device, (str, torch.device)):
self.device = torch.device(device)
return self
class PELoader(ComponentLoader):
"""Loader for prompt-enhancement causal LM (Ministral-3 based)."""
component_names = ["pe"]
expected_library = "transformers"
def load_customized(
self, component_model_path: str, server_args: ServerArgs, component_name: str
):
logger.info("Loading PE model from %s ...", component_model_path)
pe_tokenizer_dir = os.path.join(
os.path.dirname(component_model_path), "pe_tokenizer"
)
if not os.path.exists(
os.path.join(component_model_path, "tokenizer_config.json")
) and os.path.exists(os.path.join(pe_tokenizer_dir, "tokenizer_config.json")):
tokenizer_path = pe_tokenizer_dir
logger.info(
"PE tokenizer files not found in %s, using %s",
component_model_path,
tokenizer_path,
)
else:
tokenizer_path = component_model_path
model_max_length = _read_model_max_length(tokenizer_path)
if model_max_length is None:
raise RuntimeError(
f"Cannot load PE model: 'model_max_length' not found in "
f"{os.path.join(tokenizer_path, 'tokenizer_config.json')}. "
"Please ensure the PE component directory (or its sibling "
"pe_tokenizer/ directory) contains a valid tokenizer_config.json "
"with a 'model_max_length' field."
)
logger.info(
"PE model_max_length=%d (from tokenizer_config.json)", model_max_length
)
tokenizer = AutoTokenizer.from_pretrained(
tokenizer_path,
trust_remote_code=server_args.trust_remote_code,
)
if tokenizer.pad_token_id is None:
tokenizer.pad_token_id = tokenizer.eos_token_id
attn_impl = "flash_attention_2"
try:
model = AutoModelForCausalLM.from_pretrained(
component_model_path,
torch_dtype=torch.bfloat16,
trust_remote_code=server_args.trust_remote_code,
attn_implementation=attn_impl,
)
logger.info("PE model: using Flash Attention 2")
except (ValueError, ImportError):
logger.warning("Flash Attention 2 not available, falling back to SDPA")
attn_impl = "sdpa"
model = AutoModelForCausalLM.from_pretrained(
component_model_path,
torch_dtype=torch.bfloat16,
trust_remote_code=server_args.trust_remote_code,
attn_implementation=attn_impl,
)
device = get_local_torch_device()
model = model.to(device).eval()
logger.info(
"PE model loaded on %s: %s (attn=%s)",
device,
model.__class__.__name__,
attn_impl,
)
return PEModelWrapper(
model=model,
tokenizer=tokenizer,
device=device,
model_max_length=model_max_length,
)
@@ -0,0 +1,477 @@
# Copyright 2026 Baidu ERNIE-Image Team and The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Any, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from diffusers.models.embeddings import TimestepEmbedding, Timesteps
from sglang.multimodal_gen.configs.models.dits.ernie_image import (
ErnieImageDitConfig,
)
from sglang.multimodal_gen.runtime.distributed import (
get_tp_world_size,
)
from sglang.multimodal_gen.runtime.layers.attention.layer import USPAttention
from sglang.multimodal_gen.runtime.layers.layernorm import RMSNorm, apply_qk_norm
from sglang.multimodal_gen.runtime.layers.linear import (
ColumnParallelLinear,
MergedColumnParallelLinear,
RowParallelLinear,
)
from sglang.multimodal_gen.runtime.layers.quantization import QuantizationConfig
from sglang.multimodal_gen.runtime.models.dits.base import CachableDiT
from sglang.multimodal_gen.runtime.utils.layerwise_offload import OffloadableDiTMixin
def _rope(pos: torch.Tensor, dim: int, theta: int) -> torch.Tensor:
assert dim % 2 == 0
scale = torch.arange(0, dim, 2, dtype=torch.float64, device=pos.device) / dim
omega = 1.0 / (theta**scale)
out = torch.einsum("...n,d->...nd", pos, omega) # codespell:ignore nd
return out.float()
class EmbedND3(nn.Module):
"""3D rotary positional embedding for (temporal/batch_idx, height, width)."""
def __init__(self, dim: int, theta: int, axes_dim: Tuple[int, int, int]):
super().__init__()
self.dim = dim
self.theta = theta
self.axes_dim = list(axes_dim)
def forward(self, ids: torch.Tensor) -> torch.Tensor:
emb = torch.cat(
[_rope(ids[..., i], self.axes_dim[i], self.theta) for i in range(3)],
dim=-1,
)
emb = emb.unsqueeze(1).permute(2, 0, 1, 3)
return torch.stack([emb, emb], dim=-1).reshape(*emb.shape[:-1], -1)
class ErnieImageSelfAttention(nn.Module):
"""Self-attention with separate Q/K/V projections and QK LayerNorm.
Module name hierarchy matches diffusers Attention naming convention:
self_attention.to_q, self_attention.to_k, self_attention.to_v,
self_attention.to_out.0, self_attention.norm_q, self_attention.norm_k.
Supports tensor parallelism: Q/K/V projections use ColumnParallelLinear
(output dim sharded by heads), output projection uses RowParallelLinear
(input dim sharded, all-reduce after matmul).
"""
def __init__(
self,
hidden_size: int,
num_heads: int,
head_dim: int,
eps: float = 1e-6,
qk_layernorm: bool = True,
prefix: str = "",
):
super().__init__()
self.num_heads = num_heads
self.head_dim = head_dim
tp_size = get_tp_world_size()
self.num_local_heads = num_heads // tp_size
assert (
num_heads % tp_size == 0
), f"num_heads ({num_heads}) must be divisible by tp_size ({tp_size})"
self.to_q = ColumnParallelLinear(
hidden_size,
hidden_size,
bias=False,
gather_output=False,
prefix=f"{prefix}.to_q",
)
self.to_k = ColumnParallelLinear(
hidden_size,
hidden_size,
bias=False,
gather_output=False,
prefix=f"{prefix}.to_k",
)
self.to_v = ColumnParallelLinear(
hidden_size,
hidden_size,
bias=False,
gather_output=False,
prefix=f"{prefix}.to_v",
)
self.to_out = nn.ModuleList(
[
RowParallelLinear(
hidden_size,
hidden_size,
bias=False,
input_is_parallel=True,
prefix=f"{prefix}.to_out.0",
),
]
)
self.qk_layernorm = qk_layernorm
if qk_layernorm:
self.norm_q = RMSNorm(head_dim, eps=eps)
self.norm_k = RMSNorm(head_dim, eps=eps)
self.attn = USPAttention(
num_heads=self.num_local_heads,
head_size=head_dim,
prefix=f"{prefix}.attn",
)
def forward(
self,
x: torch.Tensor,
rotary_pos_emb: torch.Tensor,
) -> torch.Tensor:
B, S, H = x.shape
q, _ = self.to_q(x)
k, _ = self.to_k(x)
v, _ = self.to_v(x)
q = q.view(B, S, self.num_local_heads, self.head_dim)
k = k.view(B, S, self.num_local_heads, self.head_dim)
v = v.view(B, S, self.num_local_heads, self.head_dim)
if self.qk_layernorm:
q, k = apply_qk_norm(
q,
k,
self.norm_q,
self.norm_k,
self.head_dim,
)
q = _apply_rotary_bshd(q, rotary_pos_emb)
k = _apply_rotary_bshd(k, rotary_pos_emb)
attn_out = self.attn(q, k, v)
attn_out = attn_out.reshape(B, S, self.num_local_heads * self.head_dim)
out, _ = self.to_out[0](attn_out)
return out
class ErnieImageMLP(nn.Module):
def __init__(
self,
hidden_size: int,
ffn_hidden_size: int,
prefix: str = "",
):
super().__init__()
self.gate_up_proj = MergedColumnParallelLinear(
hidden_size,
[ffn_hidden_size, ffn_hidden_size],
bias=False,
gather_output=False,
prefix=f"{prefix}.gate_up_proj",
)
self.linear_fc2 = RowParallelLinear(
ffn_hidden_size,
hidden_size,
bias=False,
input_is_parallel=True,
prefix=f"{prefix}.linear_fc2",
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
gate_up, _ = self.gate_up_proj(x)
gate, up = gate_up.chunk(2, dim=-1)
x = up * F.gelu(gate)
x, _ = self.linear_fc2(x)
return x
class ErnieImageSharedAdaLNBlock(nn.Module):
"""Single-stream transformer block with externally-computed Shared AdaLN."""
def __init__(
self,
hidden_size: int,
num_heads: int,
head_dim: int,
ffn_hidden_size: int,
eps: float = 1e-6,
qk_layernorm: bool = True,
prefix: str = "",
):
super().__init__()
self.adaLN_sa_ln = RMSNorm(hidden_size, eps=eps)
self.self_attention = ErnieImageSelfAttention(
hidden_size,
num_heads,
head_dim,
eps,
qk_layernorm,
prefix=f"{prefix}.self_attention",
)
self.adaLN_mlp_ln = RMSNorm(hidden_size, eps=eps)
self.mlp = ErnieImageMLP(hidden_size, ffn_hidden_size, prefix=f"{prefix}.mlp")
def forward(
self,
x: torch.Tensor,
rotary_pos_emb: torch.Tensor,
shift_msa: torch.Tensor,
scale_msa: torch.Tensor,
gate_msa: torch.Tensor,
shift_mlp: torch.Tensor,
scale_mlp: torch.Tensor,
gate_mlp: torch.Tensor,
) -> torch.Tensor:
residual = x
x = self.adaLN_sa_ln(x) * (1 + scale_msa) + shift_msa
x = residual + gate_msa * self.self_attention(x, rotary_pos_emb)
residual = x
x = self.adaLN_mlp_ln(x) * (1 + scale_mlp) + shift_mlp
x = residual + gate_mlp * self.mlp(x)
return x
def _apply_rotary_bshd(x: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor:
freqs = freqs.permute(1, 0, 2, 3)
rot_dim = freqs.shape[-1]
x_rot, x_pass = x[..., :rot_dim], x[..., rot_dim:]
cos_ = torch.cos(freqs).to(x.dtype)
sin_ = torch.sin(freqs).to(x.dtype)
x1, x2 = x_rot.chunk(2, dim=-1)
x_rotated = torch.cat((-x2, x1), dim=-1)
x_rot = x_rot * cos_ + x_rotated * sin_
return torch.cat((x_rot, x_pass), dim=-1)
class ErnieImageTransformer2DModel(CachableDiT, OffloadableDiTMixin):
"""ErnieImage DiT: Single-stream transformer with Shared AdaLN."""
_supports_gradient_checkpointing = True
_no_split_modules = ["ErnieImageSharedAdaLNBlock"]
_skip_layerwise_casting_patterns = ["pos_embed", "norm"]
_fsdp_shard_conditions = ErnieImageDitConfig().arch_config._fsdp_shard_conditions
_compile_conditions = []
param_names_mapping = ErnieImageDitConfig().arch_config.param_names_mapping
reverse_param_names_mapping = {}
def __init__(
self,
config: ErnieImageDitConfig,
hf_config: dict[str, Any],
quant_config: Optional[QuantizationConfig] = None,
):
super().__init__(config=config, hf_config=hf_config)
arch = config.arch_config
self.hidden_size = arch.hidden_size
self.num_attention_heads = arch.num_attention_heads
self.num_channels_latents = arch.out_channels
self.head_dim = arch.attention_head_dim
self.num_layers = arch.num_layers
self.patch_size = arch.patch_size
self.out_channels = arch.out_channels
self.inner_dim = self.hidden_size
tp_size = get_tp_world_size()
self.x_embedder = nn.ModuleDict(
{
"proj": nn.Conv2d(
arch.in_channels,
self.inner_dim,
kernel_size=arch.patch_size,
stride=arch.patch_size,
bias=True,
),
}
)
if arch.text_in_dim != self.inner_dim:
self.text_proj = nn.Linear(arch.text_in_dim, self.inner_dim, bias=False)
else:
self.text_proj = None
self.time_proj = Timesteps(
self.inner_dim,
flip_sin_to_cos=False,
downscale_freq_shift=0,
)
self.time_embedding = TimestepEmbedding(
in_channels=self.inner_dim,
time_embed_dim=self.inner_dim,
)
self.pos_embed = EmbedND3(
dim=self.head_dim,
theta=arch.rope_theta,
axes_dim=arch.rope_axes_dim,
)
self.adaLN_modulation = nn.Sequential(
nn.SiLU(),
nn.Linear(self.inner_dim, 6 * self.inner_dim),
)
self.layers = nn.ModuleList(
[
ErnieImageSharedAdaLNBlock(
hidden_size=self.inner_dim,
num_heads=self.num_attention_heads,
head_dim=self.head_dim,
ffn_hidden_size=arch.ffn_hidden_size,
eps=arch.eps,
qk_layernorm=arch.qk_layernorm,
prefix=f"layers.{i}",
)
for i in range(self.num_layers)
]
)
self.final_norm = nn.ModuleDict(
{
"norm": nn.LayerNorm(
self.inner_dim, elementwise_affine=False, eps=arch.eps
),
"linear": nn.Linear(self.inner_dim, self.inner_dim * 2),
}
)
self.final_linear = ColumnParallelLinear(
self.inner_dim,
arch.patch_size * arch.patch_size * self.out_channels,
bias=True,
gather_output=True,
prefix="final_linear",
)
self.layer_names = ["layers"]
self.__post_init__()
def forward(
self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor | list[torch.Tensor],
timestep: torch.LongTensor,
encoder_hidden_states_image: torch.Tensor | list[torch.Tensor] | None = None,
guidance=None,
**kwargs,
) -> torch.Tensor:
"""
Args:
hidden_states: [B, C, H, W] latent images (patchified, 128 channels)
encoder_hidden_states: [B, T, text_dim] or list of text embeddings
timestep: [B] timestep values
Returns:
output: [B, C, H, W] predicted noise / denoised output
"""
device, dtype = hidden_states.device, hidden_states.dtype
B, C, H, W = hidden_states.shape
p = self.patch_size
Hp, Wp = H // p, W // p
N_img = Hp * Wp
img_tokens = self.x_embedder["proj"](hidden_states) # [B, D, Hp, Wp]
img_tokens = img_tokens.reshape(B, self.inner_dim, N_img).transpose(
1, 2
) # [B, N_img, D]
if isinstance(encoder_hidden_states, (list, tuple)):
encoder_hidden_states = encoder_hidden_states[0]
text_tokens = encoder_hidden_states # [B, T, text_dim]
if self.text_proj is not None and text_tokens.numel() > 0:
text_tokens = self.text_proj(text_tokens)
Tmax = text_tokens.shape[1]
x = torch.cat([img_tokens, text_tokens], dim=1) # [B, S, D]
grid_yx = torch.stack(
torch.meshgrid(
torch.arange(Hp, device=device, dtype=torch.float32),
torch.arange(Wp, device=device, dtype=torch.float32),
indexing="ij",
),
dim=-1,
).reshape(-1, 2)
image_ids = torch.cat(
[
torch.full((B, N_img, 1), Tmax, device=device, dtype=torch.float32),
grid_yx.view(1, N_img, 2).expand(B, -1, -1),
],
dim=-1,
)
if Tmax > 0:
text_ids = torch.cat(
[
torch.arange(Tmax, device=device, dtype=torch.float32)
.view(1, Tmax, 1)
.expand(B, -1, -1),
torch.zeros((B, Tmax, 2), device=device),
],
dim=-1,
)
else:
text_ids = torch.zeros((B, 0, 3), device=device)
all_ids = torch.cat([image_ids, text_ids], dim=1)
rotary_pos_emb = self.pos_embed(all_ids)
t_emb = self.time_proj(timestep.to(dtype))
c = self.time_embedding(t_emb.to(dtype=dtype))
mod_params = self.adaLN_modulation(c)
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
t.unsqueeze(1) for t in mod_params.chunk(6, dim=-1)
)
for layer in self.layers:
x = layer(
x,
rotary_pos_emb,
shift_msa,
scale_msa,
gate_msa,
shift_mlp,
scale_mlp,
gate_mlp,
)
scale, shift = self.final_norm["linear"](c).chunk(2, dim=-1)
x = self.final_norm["norm"](x) * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
patches, _ = self.final_linear(x[:, :N_img, :])
output = patches.view(B, Hp, Wp, p, p, self.out_channels)
output = output.permute(0, 5, 1, 3, 2, 4).contiguous()
output = output.view(B, self.out_channels, H, W)
return output
EntryClass = ErnieImageTransformer2DModel
@@ -0,0 +1,232 @@
# SPDX-License-Identifier: Apache-2.0
"""ErnieImage text-to-image pipeline."""
import json
import os
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
ComposedPipelineBase,
)
from sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline import LoRAPipeline
from sglang.multimodal_gen.runtime.pipelines_core.stages.input_validation import (
InputValidationStage,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.ernie_image_pe import (
PromptEnhancementStage,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.text_encoding import (
TextEncodingStage,
)
from sglang.multimodal_gen.runtime.utils.hf_diffusers_utils import (
maybe_download_model,
maybe_download_model_index,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class ErnieImagePipeline(LoRAPipeline, ComposedPipelineBase):
pipeline_name = "ErnieImagePipeline"
_required_config_modules = [
"text_encoder",
"tokenizer",
"vae",
"transformer",
"scheduler",
]
def _has_pe_in_model_index(self, server_args) -> bool:
try:
model_index = maybe_download_model_index(server_args.model_path)
return "pe" in model_index and model_index["pe"] is not None
except Exception:
return False
def _read_tokenizer_model_max_length(self, model_path: str):
"""Read model_max_length from tokenizer/tokenizer_config.json.
Supports both local paths and HuggingFace Hub model IDs.
Returns None if the value cannot be determined.
"""
tokenizer_config_subpath = os.path.join("tokenizer", "tokenizer_config.json")
# Local path
if os.path.exists(model_path):
config_path = os.path.join(model_path, tokenizer_config_subpath)
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
return config.get("model_max_length")
return None
# Remote HuggingFace Hub model ID
try:
import tempfile
from huggingface_hub import hf_hub_download
with tempfile.TemporaryDirectory() as tmp_dir:
config_path = hf_hub_download(
repo_id=model_path,
filename=tokenizer_config_subpath,
local_dir=tmp_dir,
)
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
return config.get("model_max_length")
except Exception as e:
logger.warning(
"Failed to read tokenizer_config.json from %s: %s", model_path, e
)
return None
def _resolve_pe_tokenizer_path(self, model_path: str, server_args) -> str:
"""Resolve the directory that contains the PE tokenizer files."""
pe_component_path = server_args.component_paths.get(
"pe", os.path.join(model_path, "pe")
)
if os.path.exists(os.path.join(pe_component_path, "tokenizer_config.json")):
return pe_component_path
pe_tokenizer_dir = os.path.join(model_path, "pe_tokenizer")
if os.path.exists(os.path.join(pe_tokenizer_dir, "tokenizer_config.json")):
return pe_tokenizer_dir
return pe_component_path
def _read_pe_model_max_length(self, model_path: str, server_args) -> int | None:
# If model_path is a Hub ID, download the full model first (or use cache)
# so that pe/tokenizer_config.json is available locally.
if not os.path.exists(model_path):
try:
model_path = maybe_download_model(
model_path, force_diffusers_model=True
)
except Exception as e:
logger.warning(
"Failed to download model to read pe/tokenizer_config.json: %s", e
)
return None
tokenizer_path = self._resolve_pe_tokenizer_path(model_path, server_args)
config_path = os.path.join(tokenizer_path, "tokenizer_config.json")
if os.path.exists(config_path):
try:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
val = config.get("model_max_length")
if val is not None:
return int(val)
except Exception as e:
logger.warning(
"Failed to read tokenizer_config.json from %s: %s",
tokenizer_path,
e,
)
return None
def load_modules(self, server_args, loaded_modules=None):
has_pe = self._has_pe_in_model_index(server_args)
if has_pe:
if "pe" not in self._required_config_modules:
self._required_config_modules.insert(0, "pe")
logger.info("PE model detected in model_index.json, will load PE module.")
pipeline_config = server_args.pipeline_config
# --- Text encoder max_length ---
text_model_max_length = self._read_tokenizer_model_max_length(
server_args.model_path
)
if text_model_max_length is not None:
# 1. Update arch_config.text_len so the model knows the true sequence length
if (
hasattr(pipeline_config, "text_encoder_configs")
and pipeline_config.text_encoder_configs
):
arch_config = pipeline_config.text_encoder_configs[0].arch_config
arch_config.text_len = text_model_max_length
arch_config.tokenizer_kwargs["max_length"] = text_model_max_length
# 2. Update text_encoder_extra_args used by TextEncodingStage tokenization
if (
hasattr(pipeline_config, "text_encoder_extra_args")
and pipeline_config.text_encoder_extra_args
):
pipeline_config.text_encoder_extra_args[0][
"max_length"
] = text_model_max_length
logger.info(
"Set text encoder model_max_length=%d from tokenizer/tokenizer_config.json",
text_model_max_length,
)
else:
logger.warning(
"Could not read model_max_length from tokenizer/tokenizer_config.json, "
"text encoder will use the default text_len from arch config."
)
# --- PE model_max_length ---
if has_pe:
pe_model_max_length = self._read_pe_model_max_length(
server_args.model_path, server_args
)
if pe_model_max_length is not None:
pipeline_config.pe_model_max_length = pe_model_max_length
logger.info(
"Set PE model_max_length=%d from pe/tokenizer_config.json",
pe_model_max_length,
)
else:
raise RuntimeError(
"PE model is present but 'model_max_length' could not be read from "
"pe/tokenizer_config.json. Please ensure the PE component directory "
"contains a valid tokenizer_config.json with a 'model_max_length' field."
)
return super().load_modules(server_args, loaded_modules)
def create_pipeline_stages(self, server_args):
self.add_stage(InputValidationStage())
pe_model = self.get_module("pe")
if pe_model is not None:
pe_tokenizer = getattr(pe_model, "pe_tokenizer", None)
if pe_tokenizer is None:
from transformers import AutoTokenizer
pe_tokenizer_path = self._resolve_pe_tokenizer_path(
self.model_path, server_args
)
logger.warning(
"pe_tokenizer not found on pe_model (%s), loading from %s",
type(pe_model).__name__,
pe_tokenizer_path,
)
pe_tokenizer = AutoTokenizer.from_pretrained(
pe_tokenizer_path,
trust_remote_code=server_args.trust_remote_code,
)
self.add_stage(
PromptEnhancementStage(
pe_model=pe_model,
pe_tokenizer=pe_tokenizer,
),
"prompt_enhancement_stage",
)
self.add_stage(
TextEncodingStage(
text_encoders=[self.get_module("text_encoder")],
tokenizers=[self.get_module("tokenizer")],
),
"prompt_encoding_stage_primary",
)
self.add_standard_timestep_preparation_stage()
self.add_standard_latent_preparation_stage()
self.add_standard_denoising_stage()
self.add_standard_decoding_stage()
EntryClass = ErnieImagePipeline
@@ -0,0 +1,98 @@
# SPDX-License-Identifier: Apache-2.0
"""
Prompt enhancement stage for ErnieImage pipeline.
"""
import json
import torch
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__)
class PromptEnhancementStage(PipelineStage):
def __init__(self, pe_model, pe_tokenizer):
super().__init__()
self.pe_model = pe_model
self.pe_tokenizer = pe_tokenizer
@torch.no_grad()
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
# Skip if use_pe is disabled or tokenizer unavailable
use_pe = getattr(batch, "use_pe", True)
if not use_pe or self.pe_model is None:
return batch
if self.pe_tokenizer is None:
logger.warning(
"pe_tokenizer is None, skipping prompt enhancement. "
"Check PE model loading logs for errors."
)
return batch
# Read max_new_tokens from pipeline config (injected from tokenizer_config.json at load time)
max_new_tokens = server_args.pipeline_config.pe_model_max_length
prompt = batch.prompt
if isinstance(prompt, str):
prompts = [prompt]
else:
prompts = list(prompt)
height = getattr(batch, "height", 1024)
width = getattr(batch, "width", 1024)
enhanced = []
for p in prompts:
enhanced_p = self._enhance_single_prompt(
p, width, height, max_new_tokens=max_new_tokens
)
enhanced.append(enhanced_p)
if isinstance(batch.prompt, str):
batch.prompt = enhanced[0]
else:
batch.prompt = enhanced
logger.info("PE enhanced prompt: %s", batch.prompt)
return batch
def _enhance_single_prompt(
self,
prompt: str,
width: int,
height: int,
max_new_tokens: int,
temperature: float = None,
top_p: float = None,
) -> str:
user_content = json.dumps(
{"prompt": prompt, "width": width, "height": height},
ensure_ascii=False,
)
messages = [{"role": "user", "content": user_content}]
input_text = self.pe_tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=False,
)
sampling_params = {"max_new_tokens": max_new_tokens}
if temperature is not None:
sampling_params["temperature"] = temperature
if top_p is not None:
sampling_params["top_p"] = top_p
output = self.pe_model.generate(
prompt=input_text,
sampling_params=sampling_params,
)
return output["text"].strip()