chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# CLAUDE.md — sglang-diffusion (multimodal_gen)
|
||||
|
||||
## What is this?
|
||||
|
||||
SGLang's diffusion/multimodal generation subsystem. Separate from the LLM runtime (`srt`). Supports 20+ image/video diffusion models (Wan, FLUX, HunyuanVideo, LTX, Qwen-Image, etc.) with distributed inference, LoRA, and multiple attention backends.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# One-shot generation
|
||||
sglang generate --model-path Wan-AI/Wan2.1-T2V-1.3B-Diffusers --prompt "A curious raccoon" --save-output
|
||||
|
||||
# Start server
|
||||
sglang serve --model-path Wan-AI/Wan2.1-T2V-1.3B-Diffusers --num-gpus 4
|
||||
|
||||
# Python API
|
||||
from sglang import DiffGenerator
|
||||
gen = DiffGenerator.from_pretrained("Wan-AI/Wan2.1-T2V-1.3B-Diffusers")
|
||||
result = gen.generate(sampling_params_kwargs={"prompt": "A curious raccoon"})
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CLI / Python API / HTTP Server (FastAPI + OpenAI-compatible)
|
||||
↓ ZMQ
|
||||
Scheduler (request queue, batching, dispatch)
|
||||
↓ multiprocessing pipes
|
||||
GPU Worker(s) → ComposedPipeline (stages: TextEncode → Denoise → Decode)
|
||||
```
|
||||
|
||||
### Key Directories
|
||||
|
||||
```
|
||||
runtime/
|
||||
├── entrypoints/ # CLI (generate/serve), HTTP server, Python API (DiffGenerator)
|
||||
├── managers/ # scheduler.py, gpu_worker.py
|
||||
├── pipelines_core/ # ComposedPipelineBase, stages/, schedule_batch.py (Req/OutputBatch)
|
||||
├── pipelines/ # Model-specific pipelines (wan, flux, hunyuan, ltx, qwen_image, ...)
|
||||
├── models/ # encoders/, dits/, vaes/, schedulers/
|
||||
├── layers/ # attention/, lora/, quantization/
|
||||
├── loader/ # Model loading, weight utils
|
||||
├── server_args.py # ServerArgs (all CLI/config params)
|
||||
└── distributed/ # Multi-GPU (TP, SP: ulysses/ring)
|
||||
configs/
|
||||
├── pipeline_configs/ # Per-model pipeline configs
|
||||
├── sample/ # SamplingParams
|
||||
└── models/ # DiT, VAE, Encoder configs
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
| Class | Location | Purpose |
|
||||
|-------|----------|---------|
|
||||
| `DiffGenerator` | `runtime/entrypoints/diffusion_generator.py` | Python API entry point |
|
||||
| `ComposedPipelineBase` | `runtime/pipelines_core/composed_pipeline_base.py` | Pipeline orchestrator (stages) |
|
||||
| `Scheduler` | `runtime/managers/scheduler.py` | ZMQ event loop, request dispatch |
|
||||
| `GPUWorker` | `runtime/managers/gpu_worker.py` | GPU inference worker |
|
||||
| `Req` / `OutputBatch` | `runtime/pipelines_core/schedule_batch.py` | Request/output containers |
|
||||
| `ServerArgs` | `runtime/server_args.py` | All config params |
|
||||
| `SamplingParams` | `configs/sample/sampling_params.py` | Generation params |
|
||||
| `PipelineConfig` | `configs/pipeline_configs/base.py` | Model structure config |
|
||||
|
||||
### Key Functions
|
||||
|
||||
| Function | Module | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `build_pipeline()` | `runtime/pipelines_core/__init__.py` | Instantiate pipeline from model_path |
|
||||
| `get_model_info()` | `registry.py` | Resolve pipeline + config classes |
|
||||
| `launch_server()` | `runtime/launch_server.py` | Start multi-process server |
|
||||
|
||||
## Adding a New Model
|
||||
|
||||
1. Create pipeline in `runtime/pipelines/` extending `ComposedPipelineBase`
|
||||
2. Define stages via `create_pipeline_stages()` (TextEncoding → Denoising → Decoding)
|
||||
3. Add config in `configs/pipeline_configs/`
|
||||
4. Register in `registry.py` via `register_configs()`
|
||||
|
||||
## Multi-GPU
|
||||
|
||||
```bash
|
||||
# Sequence parallelism (video frames across GPUs)
|
||||
sglang serve --model-path ... --num-gpus 4 --ulysses-degree 2 --ring-degree 2
|
||||
|
||||
# Tensor parallelism (model layers across GPUs)
|
||||
sglang serve --model-path ... --num-gpus 2 --tp-size 2
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Tests live in test/ subdirectory
|
||||
python -m pytest python/sglang/multimodal_gen/test/
|
||||
|
||||
# No need to pre-download models — auto-downloaded at runtime
|
||||
# Dependencies assumed already installed via `pip install -e "python[diffusion]"`
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
For questions about optimal performance, fastest commands, VRAM reduction, or best flag combinations for a given model/GPU setup, **read the [sglang-diffusion-performance skill](skills/sglang-diffusion-performance/SKILL.md)**. It contains a complete table of lossless and lossy optimization flags with trade-offs, quick recipes, and tuning tips.
|
||||
|
||||
### Perf Measurement
|
||||
|
||||
Look for `Pixel data generated successfully in xxxx seconds` in console output. With warmup enabled, use the line containing `warmup excluded` for accurate timing.
|
||||
|
||||
## Env Vars
|
||||
|
||||
Defined in `envs.py` (300+ vars). Key ones:
|
||||
- `SGLANG_DIFFUSION_ATTENTION_BACKEND` — attention backend override
|
||||
- `SGLANG_CACHE_DIT_ENABLED` — enable Cache-DiT acceleration
|
||||
- `SGLANG_CLOUD_STORAGE_TYPE` — cloud output storage (s3, etc.)
|
||||
@@ -0,0 +1,626 @@
|
||||
---
|
||||
name: sglang-diffusion-add-model
|
||||
description: Use when adding a new diffusion model or Diffusers pipeline to SGLang.
|
||||
---
|
||||
|
||||
# Add a Diffusion Model to SGLang
|
||||
|
||||
Use this skill when adding a new diffusion model or pipeline variant to `sglang.multimodal_gen`.
|
||||
|
||||
## Two Pipeline Styles
|
||||
|
||||
### Style A: Hybrid Monolithic Pipeline (Recommended)
|
||||
|
||||
The recommended default for most new models. Uses a three-stage structure:
|
||||
|
||||
```
|
||||
BeforeDenoisingStage (model-specific) --> DenoisingStage (standard) --> DecodingStage (standard)
|
||||
```
|
||||
|
||||
- **BeforeDenoisingStage**: A single, model-specific stage that consolidates all pre-processing logic: input validation, text encoding, image encoding, latent preparation, timestep setup. This stage is unique per model.
|
||||
- **DenoisingStage**: Framework-standard stage for the denoising loop (DiT/UNet forward passes). Shared across models.
|
||||
- **DecodingStage**: Framework-standard stage for VAE decoding. Shared across models.
|
||||
|
||||
**Why recommended?** Modern diffusion models have highly heterogeneous pre-processing requirements (different text encoders, different latent formats, different conditioning mechanisms). The Hybrid approach keeps pre-processing isolated per model, avoids fragile shared stages with excessive conditional logic, and lets developers port Diffusers reference code quickly.
|
||||
|
||||
### Style B: Modular Composition Style
|
||||
|
||||
Uses the framework's fine-grained standard stages (`TextEncodingStage`, `LatentPreparationStage`, `TimestepPreparationStage`, etc.) to build the pipeline by composition.
|
||||
|
||||
This style is appropriate when:
|
||||
- **The new model's pre-processing can largely reuse existing stages** — e.g., a model that uses standard CLIP/T5 text encoding + standard latent preparation with minimal customization. In this case, `add_standard_t2i_stages()` or `add_standard_ti2i_stages()` may be all you need.
|
||||
- **A model-specific optimization needs to be extracted as a standalone stage** — e.g., a specialized encoding or conditioning step that benefits from being a separate stage for profiling, parallelism control, or reuse across multiple pipeline variants.
|
||||
|
||||
See existing Modular examples: `QwenImagePipeline` (uses `add_standard_t2i_stages`), `FluxPipeline`, `WanPipeline`, `SanaPipeline`, `StableDiffusion3Pipeline`, and `ZImagePipeline`.
|
||||
|
||||
### How to Choose
|
||||
|
||||
| Situation | Recommended Style |
|
||||
|-----------|-------------------|
|
||||
| Model has unique/complex pre-processing (VLM captioning, AR token generation, custom latent packing, etc.) | **Hybrid** — consolidate into a BeforeDenoisingStage |
|
||||
| Model fits neatly into standard text-to-image or text+image-to-image pattern | **Modular** — use `add_standard_t2i_stages()` / `add_standard_ti2i_stages()` |
|
||||
| Porting a Diffusers pipeline with many custom steps | **Hybrid** — copy the `__call__` logic into a single stage |
|
||||
| Adding a variant of an existing model that shares most logic | **Modular** — reuse existing stages, customize via PipelineConfig callbacks |
|
||||
| A specific pre-processing step needs special parallelism or profiling isolation | **Modular** — extract that step as a dedicated stage |
|
||||
|
||||
**Key principle (both styles)**: The stage(s) before `DenoisingStage` must produce a `Req` batch object with all the standard tensor fields that `DenoisingStage` expects (latents, timesteps, prompt_embeds, etc.). As long as this contract is met, the pipeline remains composable regardless of which style you use.
|
||||
|
||||
---
|
||||
|
||||
## Key Files and Directories
|
||||
|
||||
| Purpose | Path |
|
||||
|---------|------|
|
||||
| Pipeline classes | `python/sglang/multimodal_gen/runtime/pipelines/` |
|
||||
| Model-specific stages | `python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/` |
|
||||
| PipelineStage base class | `python/sglang/multimodal_gen/runtime/pipelines_core/stages/base.py` |
|
||||
| Pipeline base class | `python/sglang/multimodal_gen/runtime/pipelines_core/composed_pipeline_base.py` |
|
||||
| Standard stages (Denoising, Decoding) | `python/sglang/multimodal_gen/runtime/pipelines_core/stages/` |
|
||||
| Pipeline configs | `python/sglang/multimodal_gen/configs/pipeline_configs/` |
|
||||
| Sampling params | `python/sglang/multimodal_gen/configs/sample/` |
|
||||
| DiT model implementations | `python/sglang/multimodal_gen/runtime/models/dits/` |
|
||||
| VAE implementations | `python/sglang/multimodal_gen/runtime/models/vaes/` |
|
||||
| Encoder implementations | `python/sglang/multimodal_gen/runtime/models/encoders/` |
|
||||
| Scheduler implementations | `python/sglang/multimodal_gen/runtime/models/schedulers/` |
|
||||
| Model/VAE/DiT configs | `python/sglang/multimodal_gen/configs/models/dits/`, `vaes/`, `encoders/` |
|
||||
| Central registry | `python/sglang/multimodal_gen/registry.py` |
|
||||
| Model component registry | `python/sglang/multimodal_gen/runtime/models/registry.py` |
|
||||
| Current support list | `docs/diffusion/compatibility_matrix.md` |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation
|
||||
|
||||
### Step 1: Obtain and Study the Reference Implementation
|
||||
|
||||
**Before writing any code, obtain the model's reference implementation or Diffusers pipeline code.** You need the actual source code to work from — do not guess or assume the model's architecture. If the user already gave a HuggingFace model ID or repo, inspect that yourself first. Ask the user only when the reference implementation is private, ambiguous, or otherwise unavailable. Typical sources are:
|
||||
- The model's Diffusers pipeline source (e.g., the `pipeline_*.py` file from the `diffusers` library or HuggingFace repo)
|
||||
- Or the model's official reference implementation (e.g., from the model author's GitHub repo)
|
||||
- Or the HuggingFace model ID so you can look up `model_index.json` and the associated pipeline class
|
||||
|
||||
Once you have the reference code, study it thoroughly:
|
||||
|
||||
1. Find the model's `model_index.json` to identify required modules (text_encoder, vae, transformer, scheduler, etc.)
|
||||
2. Read the Diffusers pipeline's `__call__` method end-to-end. Identify:
|
||||
- How text prompts are encoded
|
||||
- How latents are prepared (shape, dtype, scaling)
|
||||
- How timesteps/sigmas are computed
|
||||
- What conditioning kwargs the DiT/UNet expects
|
||||
- How the denoising loop works (classifier-free guidance, etc.)
|
||||
- How VAE decoding is done (scaling factors, tiling, etc.)
|
||||
|
||||
### Step 2: Evaluate Reuse of Existing Pipelines and Stages
|
||||
|
||||
**Before creating any new files, check whether an existing pipeline or stage can be reused or extended.** Only create new pipelines/stages when the existing ones would require extensive modifications or when no similar implementation exists.
|
||||
|
||||
Specifically:
|
||||
1. **Compare the new model's architecture against existing pipelines** before creating files. Current native families include LTX-2/2.3, HunyuanVideo/FastHunyuan, Wan/FastWan/TurboWan/LingBot World, MOVA, FLUX/FLUX.2/Klein, Z-Image, Qwen-Image/edit/layered, GLM-Image, SD3, Hunyuan3D, Helios, Cosmos3, SANA/SANA-WM, FireRed, ERNIE-Image, JoyAI, and Ideogram4. If the new model shares most of its structure with an existing one (e.g., same text encoders, similar latent format, compatible denoising loop), prefer:
|
||||
- Adding a new config variant to the existing pipeline rather than creating a new pipeline class
|
||||
- Reusing the existing `BeforeDenoisingStage` with minor parameter differences
|
||||
- Using `add_standard_t2i_stages()` / `add_standard_ti2i_stages()` / `add_standard_ti2v_stages()` if the model fits standard patterns
|
||||
2. **Check existing stages** in `runtime/pipelines_core/stages/` and `stages/model_specific_stages/`. If an existing stage handles 80%+ of what the new model needs, extend it rather than duplicating it.
|
||||
3. **Check existing model components** — many models share VAEs (e.g., `AutoencoderKL`), text encoders (CLIP, T5), and schedulers. Reuse these directly instead of re-implementing.
|
||||
|
||||
**Rule of thumb**: Only create a new file when the existing implementation would need substantial structural changes to accommodate the new model, or when no architecturally similar implementation exists.
|
||||
|
||||
### Step 3: Implement Model Components
|
||||
|
||||
Adapt or implement the model's core components in the appropriate directories.
|
||||
|
||||
**DiT/Transformer** (`runtime/models/dits/{model_name}.py`):
|
||||
|
||||
```python
|
||||
# python/sglang/multimodal_gen/runtime/models/dits/my_model.py
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from sglang.multimodal_gen.runtime.layers.layernorm import (
|
||||
LayerNormScaleShift,
|
||||
RMSNormScaleShift,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.attention.selector import (
|
||||
get_attn_backend,
|
||||
)
|
||||
|
||||
|
||||
class MyModelTransformer2DModel(nn.Module):
|
||||
"""DiT model for MyModel.
|
||||
|
||||
Adapt from the Diffusers/reference implementation. Key points:
|
||||
- Use SGLang's fused LayerNorm/RMSNorm ops (see `existing-fast-paths.md` under the benchmark/profile skill)
|
||||
- Use SGLang's attention backend selector
|
||||
- Keep the same parameter naming as Diffusers for weight loading compatibility
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
super().__init__()
|
||||
# ... model layers ...
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
encoder_hidden_states: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
# ... model-specific kwargs ...
|
||||
) -> torch.Tensor:
|
||||
# ... forward pass ...
|
||||
return output
|
||||
```
|
||||
|
||||
**Tensor Parallel (TP) and Sequence Parallel (SP)**: For multi-GPU deployment, it is recommended to add TP/SP support to the DiT model. This can be done incrementally after the single-GPU implementation is verified. Reference existing implementations and adapt to your model's architecture:
|
||||
|
||||
- **Wan model** (`runtime/models/dits/wanvideo.py`) — Full TP + SP reference:
|
||||
- TP: Uses `ColumnParallelLinear` for Q/K/V projections, `RowParallelLinear` for output projections, attention heads divided by `tp_size`
|
||||
- SP: Sequence dimension sharding via `get_sp_world_size()`, padding for alignment, `sequence_model_parallel_all_gather` for aggregation
|
||||
- Cross-attention skips SP (`skip_sequence_parallel=is_cross_attention`)
|
||||
- **Qwen-Image model** (`runtime/models/dits/qwen_image.py`) — SP + USPAttention reference:
|
||||
- SP: Uses `USPAttention` (Ulysses + Ring Attention), configured via `--ulysses-degree` / `--ring-degree`
|
||||
- TP: Uses `MergedColumnParallelLinear` for QKV (with Nunchaku quantization), `ReplicatedLinear` otherwise
|
||||
|
||||
**Important**: These are references only — each model has its own architecture and parallelism requirements. Consider:
|
||||
- How attention heads can be divided across TP ranks
|
||||
- Whether the model's sequence dimension is naturally shardable for SP
|
||||
- Which linear layers benefit from column/row parallel sharding vs. replication
|
||||
- Whether cross-attention or other special modules need SP exclusion
|
||||
|
||||
Key imports for distributed support:
|
||||
```python
|
||||
from sglang.multimodal_gen.runtime.distributed import (
|
||||
divide,
|
||||
get_sp_group,
|
||||
get_sp_world_size,
|
||||
get_tp_world_size,
|
||||
sequence_model_parallel_all_gather,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.layers.linear import (
|
||||
ColumnParallelLinear,
|
||||
RowParallelLinear,
|
||||
ReplicatedLinear,
|
||||
)
|
||||
```
|
||||
|
||||
**VAE** (`runtime/models/vaes/{model_name}.py`): Implement if the model uses a non-standard VAE. Many models reuse existing VAEs.
|
||||
|
||||
**Encoders** (`runtime/models/encoders/{model_name}.py`): Implement if the model uses custom text/image encoders.
|
||||
|
||||
**Schedulers** (`runtime/models/schedulers/{scheduler_name}.py`): Implement if the model requires a custom scheduler not available in Diffusers.
|
||||
|
||||
### Step 4: Create Model Configs
|
||||
|
||||
**DiT Config** (`configs/models/dits/{model_name}.py`):
|
||||
|
||||
```python
|
||||
# python/sglang/multimodal_gen/configs/models/dits/mymodel.py
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyModelDitConfig(DiTConfig):
|
||||
arch_config: dict = field(default_factory=lambda: {
|
||||
"in_channels": 16,
|
||||
"num_layers": 24,
|
||||
"patch_size": 2,
|
||||
# ... model-specific architecture params ...
|
||||
})
|
||||
```
|
||||
|
||||
**VAE Config** (`configs/models/vaes/{model_name}.py`):
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.vaes.base import VAEConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyModelVAEConfig(VAEConfig):
|
||||
vae_scale_factor: int = 8
|
||||
# ... VAE-specific params ...
|
||||
```
|
||||
|
||||
**Sampling Params** (`configs/sample/{model_name}.py`):
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.base import SamplingParams
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyModelSamplingParams(SamplingParams):
|
||||
num_inference_steps: int = 50
|
||||
guidance_scale: float = 7.5
|
||||
height: int = 1024
|
||||
width: int = 1024
|
||||
# ... model-specific defaults ...
|
||||
```
|
||||
|
||||
### Step 5: Create PipelineConfig
|
||||
|
||||
The `PipelineConfig` holds static model configuration and defines callback methods used by the standard `DenoisingStage` and `DecodingStage`.
|
||||
|
||||
```python
|
||||
# python/sglang/multimodal_gen/configs/pipeline_configs/my_model.py
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models import DiTConfig, VAEConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.base import (
|
||||
ImagePipelineConfig,
|
||||
ModelTaskType,
|
||||
# PipelineConfig, # common base for many video pipelines
|
||||
# SpatialImagePipelineConfig, # alternative base for spatial image models
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.dits.mymodel import MyModelDitConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.mymodel import MyModelVAEConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyModelPipelineConfig(ImagePipelineConfig):
|
||||
"""Pipeline config for MyModel.
|
||||
|
||||
This config provides callbacks that the standard DenoisingStage and
|
||||
DecodingStage use during execution. The BeforeDenoisingStage handles
|
||||
all model-specific pre-processing independently.
|
||||
"""
|
||||
|
||||
task_type: ModelTaskType = ModelTaskType.T2I
|
||||
vae_precision: str = "bf16"
|
||||
should_use_guidance: bool = True
|
||||
vae_tiling: bool = False
|
||||
enable_autocast: bool = False
|
||||
|
||||
dit_config: DiTConfig = field(default_factory=MyModelDitConfig)
|
||||
vae_config: VAEConfig = field(default_factory=MyModelVAEConfig)
|
||||
|
||||
# --- Callbacks used by DenoisingStage ---
|
||||
|
||||
def get_freqs_cis(self, batch, device, rotary_emb, dtype):
|
||||
"""Prepare rotary position embeddings for the DiT."""
|
||||
# Model-specific RoPE computation
|
||||
...
|
||||
return freqs_cis
|
||||
|
||||
def prepare_pos_cond_kwargs(self, batch, latent_model_input, t, **kwargs):
|
||||
"""Build positive conditioning kwargs for each denoising step."""
|
||||
return {
|
||||
"hidden_states": latent_model_input,
|
||||
"encoder_hidden_states": batch.prompt_embeds[0],
|
||||
"timestep": t,
|
||||
# ... model-specific kwargs ...
|
||||
}
|
||||
|
||||
def prepare_neg_cond_kwargs(self, batch, latent_model_input, t, **kwargs):
|
||||
"""Build negative conditioning kwargs for CFG."""
|
||||
return {
|
||||
"hidden_states": latent_model_input,
|
||||
"encoder_hidden_states": batch.negative_prompt_embeds[0],
|
||||
"timestep": t,
|
||||
# ... model-specific kwargs ...
|
||||
}
|
||||
|
||||
# --- Callbacks used by DecodingStage ---
|
||||
|
||||
def get_decode_scale_and_shift(self):
|
||||
"""Return (scale, shift) for latent denormalization before VAE decode."""
|
||||
return self.vae_config.latents_std, self.vae_config.latents_mean
|
||||
|
||||
def post_denoising_loop(self, latents, batch):
|
||||
"""Optional post-processing after the denoising loop finishes."""
|
||||
return latents.to(torch.bfloat16)
|
||||
|
||||
def post_decoding(self, frames, server_args):
|
||||
"""Optional post-processing after VAE decoding."""
|
||||
return frames
|
||||
```
|
||||
|
||||
There is no separate `VideoPipelineConfig` base class. For video models, choose
|
||||
`ModelTaskType.T2V`, `ModelTaskType.I2V`, or `ModelTaskType.TI2V`, and follow
|
||||
existing video configs such as Wan, LTX, Hunyuan, Helios, or MOVA when deciding
|
||||
whether to subclass `PipelineConfig` directly or use a model-specific base.
|
||||
|
||||
**Important**: The `prepare_pos_cond_kwargs` / `prepare_neg_cond_kwargs` methods define what the DiT receives at each denoising step. These must match the DiT's `forward()` signature.
|
||||
|
||||
### Step 6: Implement the BeforeDenoisingStage (Core Step)
|
||||
|
||||
This is the heart of the Hybrid pattern. Create a single stage that handles ALL pre-processing.
|
||||
|
||||
```python
|
||||
# python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/my_model.py
|
||||
|
||||
import torch
|
||||
from typing import List, Optional, Union
|
||||
|
||||
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.distributed import get_local_torch_device
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class MyModelBeforeDenoisingStage(PipelineStage):
|
||||
"""Monolithic pre-processing stage for MyModel.
|
||||
|
||||
Consolidates all logic before the denoising loop:
|
||||
- Input validation
|
||||
- Text/image encoding
|
||||
- Latent preparation
|
||||
- Timestep/sigma computation
|
||||
|
||||
This stage produces a Req batch with all fields required by
|
||||
the standard DenoisingStage.
|
||||
"""
|
||||
|
||||
def __init__(self, vae, text_encoder, tokenizer, transformer, scheduler):
|
||||
super().__init__()
|
||||
self.vae = vae
|
||||
self.text_encoder = text_encoder
|
||||
self.tokenizer = tokenizer
|
||||
self.transformer = transformer
|
||||
self.scheduler = scheduler
|
||||
# ... other initialization (image processors, scale factors, etc.) ...
|
||||
|
||||
# --- Internal helper methods ---
|
||||
# Copy/adapt directly from the Diffusers reference pipeline.
|
||||
# These are private to this stage; no need to make them reusable.
|
||||
|
||||
def _encode_prompt(self, prompt, device, dtype):
|
||||
"""Encode text prompt into embeddings."""
|
||||
# ... model-specific text encoding logic ...
|
||||
return prompt_embeds, negative_prompt_embeds
|
||||
|
||||
def _prepare_latents(self, batch_size, height, width, dtype, device, generator):
|
||||
"""Create initial noisy latents."""
|
||||
# ... model-specific latent preparation ...
|
||||
return latents
|
||||
|
||||
def _prepare_timesteps(self, num_inference_steps, device):
|
||||
"""Compute the timestep/sigma schedule."""
|
||||
# ... model-specific timestep computation ...
|
||||
return timesteps, sigmas
|
||||
|
||||
# --- Main forward method ---
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
"""Execute all pre-processing and populate batch for DenoisingStage.
|
||||
|
||||
This method mirrors the first half of a Diffusers pipeline __call__,
|
||||
up to (but not including) the denoising loop.
|
||||
"""
|
||||
device = get_local_torch_device()
|
||||
dtype = torch.bfloat16
|
||||
generator = torch.Generator(device=device).manual_seed(batch.seed)
|
||||
|
||||
# 1. Encode prompt
|
||||
prompt_embeds, negative_prompt_embeds = self._encode_prompt(
|
||||
batch.prompt, device, dtype
|
||||
)
|
||||
|
||||
# 2. Prepare latents
|
||||
latents = self._prepare_latents(
|
||||
batch_size=1,
|
||||
height=batch.height,
|
||||
width=batch.width,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
generator=generator,
|
||||
)
|
||||
|
||||
# 3. Prepare timesteps
|
||||
timesteps, sigmas = self._prepare_timesteps(
|
||||
batch.num_inference_steps, device
|
||||
)
|
||||
|
||||
# 4. Populate batch with everything DenoisingStage needs
|
||||
batch.prompt_embeds = [prompt_embeds]
|
||||
batch.negative_prompt_embeds = [negative_prompt_embeds]
|
||||
batch.latents = latents
|
||||
batch.timesteps = timesteps
|
||||
batch.num_inference_steps = len(timesteps)
|
||||
batch.sigmas = sigmas
|
||||
batch.generator = generator
|
||||
batch.raw_latent_shape = latents.shape
|
||||
batch.height = batch.height
|
||||
batch.width = batch.width
|
||||
|
||||
return batch
|
||||
```
|
||||
|
||||
**Key fields that `DenoisingStage` expects on the batch** (set these in your `forward`):
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `batch.latents` | `torch.Tensor` | Initial noisy latent tensor |
|
||||
| `batch.timesteps` | `torch.Tensor` | Timestep schedule |
|
||||
| `batch.num_inference_steps` | `int` | Number of denoising steps |
|
||||
| `batch.sigmas` | `list[float]` | Sigma schedule (as a list, not numpy) |
|
||||
| `batch.prompt_embeds` | `list[torch.Tensor]` | Positive prompt embeddings (wrapped in list) |
|
||||
| `batch.negative_prompt_embeds` | `list[torch.Tensor]` | Negative prompt embeddings (wrapped in list) |
|
||||
| `batch.generator` | `torch.Generator` | RNG generator for reproducibility |
|
||||
| `batch.raw_latent_shape` | `tuple` | Original latent shape before any packing |
|
||||
| `batch.height` / `batch.width` | `int` | Output dimensions |
|
||||
|
||||
### Step 7: Define the Pipeline Class
|
||||
|
||||
The pipeline class is minimal -- it just wires the stages together.
|
||||
|
||||
```python
|
||||
# python/sglang/multimodal_gen/runtime/pipelines/my_model.py
|
||||
|
||||
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 DenoisingStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.my_model import (
|
||||
MyModelBeforeDenoisingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class MyModelPipeline(LoRAPipeline, ComposedPipelineBase):
|
||||
pipeline_name = "MyModelPipeline" # Must match model_index.json _class_name
|
||||
|
||||
_required_config_modules = [
|
||||
"text_encoder",
|
||||
"tokenizer",
|
||||
"vae",
|
||||
"transformer",
|
||||
"scheduler",
|
||||
# ... list all modules from model_index.json ...
|
||||
]
|
||||
|
||||
def create_pipeline_stages(self, server_args: ServerArgs):
|
||||
# 1. Monolithic pre-processing (model-specific)
|
||||
self.add_stage(
|
||||
MyModelBeforeDenoisingStage(
|
||||
vae=self.get_module("vae"),
|
||||
text_encoder=self.get_module("text_encoder"),
|
||||
tokenizer=self.get_module("tokenizer"),
|
||||
transformer=self.get_module("transformer"),
|
||||
scheduler=self.get_module("scheduler"),
|
||||
),
|
||||
)
|
||||
|
||||
# 2. Standard denoising loop (framework-provided)
|
||||
self.add_stage(
|
||||
DenoisingStage(
|
||||
transformer=self.get_module("transformer"),
|
||||
scheduler=self.get_module("scheduler"),
|
||||
),
|
||||
)
|
||||
|
||||
# 3. Standard VAE decoding (framework-provided)
|
||||
self.add_standard_decoding_stage()
|
||||
|
||||
|
||||
# REQUIRED: This is how the registry discovers the pipeline
|
||||
EntryClass = [MyModelPipeline]
|
||||
```
|
||||
|
||||
### Step 8: Register the Model
|
||||
|
||||
In `python/sglang/multimodal_gen/registry.py`, register your configs:
|
||||
|
||||
```python
|
||||
register_configs(
|
||||
sampling_param_cls=MyModelSamplingParams,
|
||||
pipeline_config_cls=MyModelPipelineConfig,
|
||||
hf_model_paths=[
|
||||
"org/my-model-name", # HuggingFace model ID(s)
|
||||
],
|
||||
model_detectors=[
|
||||
lambda path: "my-model" in path.lower(),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
`register_configs()` does not take a `model_family` argument. It registers the
|
||||
sampling and pipeline config classes, then resolves models by exact
|
||||
`hf_model_paths` or optional detector predicates. Prefer exact `hf_model_paths`
|
||||
for public checkpoints used in docs or tests; use detector predicates only for
|
||||
families where local mirrors, renamed repos, or generated paths are common.
|
||||
|
||||
The `EntryClass` in your pipeline file is automatically discovered by the registry's `_discover_and_register_pipelines()` function -- no additional registration needed for the pipeline class itself.
|
||||
|
||||
### Step 9: Verify Output Quality
|
||||
|
||||
After implementation, **you must verify that the generated output is not noise**. A noisy or garbled output image/video is the most common sign of an incorrect implementation. Common causes include:
|
||||
|
||||
- Incorrect latent scale/shift factors (`get_decode_scale_and_shift` returning wrong values)
|
||||
- Wrong timestep/sigma schedule (order, dtype, or value range)
|
||||
- Mismatched conditioning kwargs (fields not matching the DiT's `forward()` signature)
|
||||
- Incorrect VAE decoder configuration (wrong `vae_scale_factor`, missing denormalization)
|
||||
- Rotary embedding style mismatch (`is_neox_style` set incorrectly)
|
||||
- Wrong prompt embedding format (missing list wrapping, wrong encoder output selection)
|
||||
|
||||
**If the output is noise, the implementation is incorrect — do not ship it.** Debug by:
|
||||
1. Comparing intermediate tensor values (latents, prompt_embeds, timesteps) against the Diffusers reference pipeline
|
||||
2. Running the Diffusers pipeline and SGLang pipeline side-by-side with the same seed
|
||||
3. Checking each stage's output shape and value range independently
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
### Hybrid Style (recommended for most new models)
|
||||
|
||||
| Model | Pipeline | BeforeDenoisingStage | PipelineConfig |
|
||||
|-------|----------|---------------------|----------------|
|
||||
| GLM-Image | `runtime/pipelines/glm_image.py` | `stages/model_specific_stages/glm_image.py` | `configs/pipeline_configs/glm_image.py` |
|
||||
| Qwen-Image-Layered | `runtime/pipelines/qwen_image.py` (`QwenImageLayeredPipeline`) | `stages/model_specific_stages/qwen_image_layered.py` | `configs/pipeline_configs/qwen_image.py` (`QwenImageLayeredPipelineConfig`) |
|
||||
| Cosmos3 | `runtime/pipelines/cosmos3_pipeline.py` | `stages/model_specific_stages/cosmos3.py` | `configs/pipeline_configs/cosmos3.py` |
|
||||
| ErnieImage | `runtime/pipelines/ernie_image.py` | `stages/model_specific_stages/ernie_image_pe.py` | `configs/pipeline_configs/ernie_image.py` |
|
||||
| Hunyuan3D | `runtime/pipelines/hunyuan3d_pipeline.py` | `stages/model_specific_stages/hunyuan3d/` | `configs/pipeline_configs/hunyuan3d.py` |
|
||||
| SANA-WM | `runtime/pipelines/sana_wm_pipeline.py`, `sana_wm_realtime_pipeline.py` | `stages/model_specific_stages/sana_wm/` | `configs/pipeline_configs/sana_wm.py` |
|
||||
| LingBot World realtime | `runtime/pipelines/lingbot_world_causal_dmd_pipeline.py` | `stages/model_specific_stages/lingbot_world/` | `configs/pipeline_configs/lingbot_world.py` |
|
||||
|
||||
### Modular Style (when standard stages fit well)
|
||||
|
||||
| Model | Pipeline | Notes |
|
||||
|-------|----------|-------|
|
||||
| Qwen-Image (T2I) | `runtime/pipelines/qwen_image.py` | Uses `add_standard_t2i_stages()` — standard text encoding + latent prep fits this model |
|
||||
| Qwen-Image-Edit | `runtime/pipelines/qwen_image.py` | Uses `add_standard_ti2i_stages()` — standard image-to-image flow |
|
||||
| Flux | `runtime/pipelines/flux.py` | Uses `add_standard_t2i_stages()` with custom `prepare_mu` |
|
||||
| FLUX.2 / FLUX.2 Klein | `runtime/pipelines/flux_2.py`, `flux_2_klein.py` | Reuses FLUX.2 stages; Klein differences live in config and sampling params |
|
||||
| Z-Image | `runtime/pipelines/zimage_pipeline.py` | Uses standard image pipeline stages plus Z-Image-specific config/model code |
|
||||
| Ideogram4 | `runtime/pipelines/ideogram.py` | Uses dedicated text encoding and denoising stages while keeping standard latent prep |
|
||||
| SANA | `runtime/pipelines/sana.py` | Spatial image pipeline; reuse the spatial image config pattern |
|
||||
| Stable Diffusion 3/3.5 | `runtime/pipelines/stable_diffusion_3.py` | Spatial image pipeline; compare scheduler, VAE scale, and conditioning layout |
|
||||
| LTX-2 / LTX-2.3 | `runtime/pipelines/ltx_2_pipeline.py` | Video pipeline family with one-stage, two-stage, and HQ variants |
|
||||
| Helios | `runtime/pipelines/helios_pipeline.py` | Video pipeline family with custom denoising and decoding stages |
|
||||
| FireRed/JoyAI image edit | `runtime/pipelines/qwen_image.py`, `runtime/pipelines/joy_image.py` | FireRed reuses Qwen edit-plus config; JoyAI has its own edit pipeline |
|
||||
| Wan | `runtime/pipelines/wan_pipeline.py` | Uses `add_standard_ti2v_stages()` |
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before submitting, verify:
|
||||
|
||||
**Common (both styles):**
|
||||
- [ ] **Pipeline file** exists at `runtime/pipelines/{model_name}.py` with `EntryClass`
|
||||
- [ ] **PipelineConfig** at `configs/pipeline_configs/{model_name}.py`
|
||||
- [ ] **SamplingParams** at `configs/sample/{model_name}.py`
|
||||
- [ ] **DiT model** at `runtime/models/dits/{model_name}.py`
|
||||
- [ ] **DiT config** at `configs/models/dits/{model_name}.py`
|
||||
- [ ] **VAE** — reuse existing (e.g., `AutoencoderKL`) or create new at `runtime/models/vaes/`
|
||||
- [ ] **VAE config** — reuse existing or create new at `configs/models/vaes/{model_name}.py`
|
||||
- [ ] **Registry entry** in `registry.py` via `register_configs()`
|
||||
- [ ] `pipeline_name` matches Diffusers `model_index.json` `_class_name`
|
||||
- [ ] `_required_config_modules` lists all modules from `model_index.json`
|
||||
- [ ] `PipelineConfig` callbacks (`prepare_pos_cond_kwargs`, `get_freqs_cis`, etc.) match DiT's `forward()` signature
|
||||
- [ ] Latent scale/shift factors are correctly configured
|
||||
- [ ] Use fused kernels where possible (see `existing-fast-paths.md` under the benchmark/profile skill)
|
||||
- [ ] Weight names match Diffusers for automatic loading
|
||||
- [ ] **TP/SP support** considered for DiT model (recommended; reference `wanvideo.py` for TP+SP, `qwen_image.py` for USPAttention)
|
||||
- [ ] **Output quality verified** — generated images/videos are not noise; compared against Diffusers reference output
|
||||
|
||||
**Hybrid style only:**
|
||||
- [ ] **BeforeDenoisingStage** at `stages/model_specific_stages/{model_name}.py`
|
||||
- [ ] `BeforeDenoisingStage.forward()` populates all fields needed by `DenoisingStage`
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **`batch.sigmas` must be a Python list**, not a numpy array. Use `.tolist()` to convert.
|
||||
2. **`batch.prompt_embeds` is a list of tensors** (one per encoder), not a single tensor. Wrap with `[tensor]`.
|
||||
3. **Don't forget `batch.raw_latent_shape`** -- `DecodingStage` uses it to unpack latents.
|
||||
4. **Rotary embedding style matters**: `is_neox_style=True` = split-half rotation, `is_neox_style=False` = interleaved. Check the reference model carefully.
|
||||
5. **VAE precision**: Many VAEs need fp32 or bf16 for numerical stability. Set `vae_precision` in the PipelineConfig accordingly.
|
||||
6. **Avoid forcing model-specific logic into shared stages**: If your model's pre-processing doesn't naturally fit the existing standard stages, prefer the Hybrid pattern with a dedicated BeforeDenoisingStage rather than adding conditional branches to shared stages.
|
||||
|
||||
## After Implementation: Tests and Performance Data
|
||||
|
||||
After the model produces non-noise output, read
|
||||
[references/testing-and-accuracy.md](references/testing-and-accuracy.md) before
|
||||
adding GPU cases, component-accuracy skips/hooks, suite entries, or benchmark
|
||||
claims. That reference tracks the current `gpu_cases.py` / `testcase_configs.py`
|
||||
/ `accuracy_testcase_configs.py` / `run_suite.py` split and the component-accuracy
|
||||
decision rules.
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# Testing And Accuracy
|
||||
|
||||
Use this reference after a new diffusion model or pipeline variant can already
|
||||
produce a non-noise image or video.
|
||||
|
||||
## Test Placement
|
||||
|
||||
- Add concrete GPU integration cases in `python/sglang/multimodal_gen/test/server/gpu_cases.py`.
|
||||
- Keep reusable dataclasses, constants, thresholds, and testcase factory helpers in `python/sglang/multimodal_gen/test/server/testcase_configs.py`.
|
||||
- Add the case id to `python/sglang/multimodal_gen/test/server/accuracy_testcase_configs.py`
|
||||
only when it should be part of component-accuracy coverage. Adding a GPU case
|
||||
alone does not enroll it there.
|
||||
- Let `python/sglang/multimodal_gen/test/run_suite.py` own suite selection, runtime-based partitioning, and standalone test files. Do not hard-code CI shard lists elsewhere.
|
||||
- If a new standalone test file is added to a suite, update `STANDALONE_FILE_EST_TIMES` after the first measured CI/runtime value is known.
|
||||
|
||||
Useful local entrypoints from repo root:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 python/sglang/multimodal_gen/test/run_suite.py --suite unit
|
||||
PYTHONPATH=python python3 python/sglang/multimodal_gen/test/run_suite.py --suite component-accuracy-1-gpu -k <case_id>
|
||||
PYTHONPATH=python python3 python/sglang/multimodal_gen/test/run_suite.py --suite 1-gpu --total-partitions 1 --partition-id 0 -k <case_id>
|
||||
```
|
||||
|
||||
## Component Accuracy When Adding A GPU Case
|
||||
|
||||
If you add a new entry to `ONE_GPU_CASES`, `TWO_GPU_CASES`, or a B200-specific
|
||||
case group in `gpu_cases.py`, treat component accuracy as part of the
|
||||
model-adding workflow. Do not assume the new testcase will automatically fit or
|
||||
enter the existing component-accuracy harness.
|
||||
|
||||
The component-accuracy harness compares SGLang components against Diffusers/HF
|
||||
reference components. This is stricter than pipeline-level inference. New GPU
|
||||
cases commonly fail here for one of three reasons:
|
||||
|
||||
1. The model family needs explicit hook wiring in `python/sglang/multimodal_gen/test/server/accuracy_hooks.py`.
|
||||
- Add hook logic only when the harness cannot call the raw component correctly without it.
|
||||
- Valid reasons include missing required forward arguments, required autocast/runtime context, or family-specific input preparation for the same component contract.
|
||||
- Do not change the compared output mode or add harness-side behavior that changes the component contract just to make the test pass.
|
||||
|
||||
2. The component is already covered by another testcase with the same source component and topology.
|
||||
- Do not add redundant component-accuracy coverage.
|
||||
- Add a skip entry in `python/sglang/multimodal_gen/test/server/accuracy_config.py` with a concrete reason such as `Representative VAE accuracy is already covered by ... for the same source component and topology`.
|
||||
- This is the preferred path for variant-only cases such as LoRA, Cache-DiT, upscaling, or other cases that reuse the same underlying component weights and topology.
|
||||
|
||||
3. The HF/Diffusers reference component cannot be loaded or compared faithfully in the harness.
|
||||
- Add a skip entry in `accuracy_config.py` with the exact technical failure.
|
||||
- Good reasons include missing/unsupported HF component layout, incomplete checkpoints, unsupported raw component contract, or proven divergence after matched weight transfer and matching output shape.
|
||||
- Keep the skip reason concrete and technical. Do not write vague reasons like "component accuracy flaky" or "needs investigation."
|
||||
|
||||
When adding a new GPU case, make this decision explicitly:
|
||||
|
||||
- if the case should have component-accuracy coverage, add its case id to
|
||||
`accuracy_testcase_configs.py`
|
||||
- if the family needs minimal harness wiring, add the smallest possible change in `accuracy_hooks.py`
|
||||
- if the case is only a variant of an already covered source component and topology, add a skip in `accuracy_config.py`
|
||||
- if the HF/Diffusers reference component cannot be compared faithfully, add a skip in `accuracy_config.py`
|
||||
- if the case is intentionally GPU-smoke-only, leave it out of `accuracy_testcase_configs.py` and keep that choice explicit in the PR notes
|
||||
|
||||
Do not add a new GPU case and wait for CI to discover missing component-accuracy
|
||||
wiring.
|
||||
|
||||
## Follow-up Scope
|
||||
|
||||
Once the model is working and output quality is verified, cover the follow-up
|
||||
scope the user requested. If the user did not specify test or benchmark depth,
|
||||
propose the smallest useful validation set before launching long GPU runs.
|
||||
|
||||
Tests should cover:
|
||||
|
||||
- pipeline construction and stage wiring
|
||||
- single-GPU inference producing non-noise output
|
||||
- multi-GPU inference if TP/SP is supported
|
||||
- relevant unit tests for new math, parsing, scheduling, or loader behavior
|
||||
|
||||
For performance data:
|
||||
|
||||
- use the `warmup excluded` latency line for command-line generation
|
||||
- keep prompt, seed, shape, step count, model path, backend, and GPU topology fixed
|
||||
- use `sglang-diffusion-benchmark-profile` for denoise perf dumps and profiler traces
|
||||
- use `python/sglang/multimodal_gen/benchmarks/bench_serving.py` for serving benchmarks
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: sglang-diffusion-benchmark-profile
|
||||
description: Use when benchmarking denoise latency or profiling a diffusion bottleneck in SGLang.
|
||||
---
|
||||
|
||||
# SGLang Diffusion Benchmark and Profile
|
||||
|
||||
Use this skill when measuring denoise performance, finding the slow op, checking whether an existing fast path can solve it, or verifying that a hotspot is real before any kernel work in `sglang.multimodal_gen`.
|
||||
|
||||
This skill is diagnosis-first. It owns:
|
||||
- checked-in denoise benchmark presets
|
||||
- perf dump collection and before/after comparison
|
||||
- `torch.profiler` trace capture and quick hotspot ranking
|
||||
- mapping hot kernels back to known fast paths and fusion families
|
||||
- packaging confirmed kernel work with enough evidence for the appropriate kernel, Nsight, or framework-specific optimization workflow
|
||||
|
||||
This skill does not own low-level kernel authoring or standalone Nsight workflows.
|
||||
|
||||
## Preflight
|
||||
|
||||
Before running any benchmark, profiler, or kernel-validation command:
|
||||
- use `scripts/diffusion_skill_env.py` to derive the repo root from `sglang.__file__`
|
||||
- verify the repo is writable
|
||||
- export `HF_TOKEN` before using gated Hugging Face models such as `black-forest-labs/FLUX.*`
|
||||
- export `FLASHINFER_DISABLE_VERSION_CHECK=1`
|
||||
- choose idle GPU(s) before starting perf work
|
||||
|
||||
## Native Backend Gate
|
||||
|
||||
All diffusion benchmark and profiling results owned by this skill must come from the native SGLang diffusion backend.
|
||||
|
||||
Treat any of the following as a hard stop condition:
|
||||
- `Falling back to diffusers backend`
|
||||
- `Using diffusers backend`
|
||||
- `Loaded diffusers pipeline`
|
||||
|
||||
If any benchmark, perf-dump, or `torch.profiler` command prints one of those signals:
|
||||
- stop the workflow immediately
|
||||
- do not keep the generated numbers or traces as SGLang benchmark evidence
|
||||
- do not continue to hotspot classification or kernel work
|
||||
- first fix model resolution, pipeline selection, overlay/materialization, or other backend-selection issues so the model runs on the native SGLang diffusion path
|
||||
|
||||
## Main Reference
|
||||
|
||||
- [benchmark-and-profile.md](benchmark-and-profile.md) — canonical denoise benchmark, perf dump, and `torch.profiler` workflow; uses checked-in nightly-aligned presets plus current-source extras such as FLUX.2 Klein, Cosmos3, Ideogram4, ERNIE/GLM/SANA image models, FastWan2.2, `LTX-2.3` one-stage/two-stage/HQ, HunyuanVideo, MOVA, Helios, JoyAI/FireRed image edit, and Hunyuan3D shape
|
||||
- [existing-fast-paths.md](existing-fast-paths.md) — map bottlenecks to existing fused kernels, packed QKV paths, fused `QK norm + RoPE`, distributed overlap patterns, and open optimization PRs before proposing new code
|
||||
- [scripts/diffusion_skill_env.py](scripts/diffusion_skill_env.py) — preflight helper: repo root discovery via `sglang.__file__`, write-access probe, benchmark/profile output directories, idle GPU selection
|
||||
- [scripts/bench_diffusion_denoise.py](scripts/bench_diffusion_denoise.py) — end-to-end denoise benchmark preset runner via `sglang generate`; supports `--no-torch-compile`, validates nightly preset drift with `--validate-nightly-alignment`, and saves perf dumps by label for `compare_perf.py`
|
||||
|
||||
## Opportunity Discovery Rule
|
||||
|
||||
Before calling a diffusion hotspot "new", first classify it with `existing-fast-paths.md`.
|
||||
|
||||
Always rule out these existing families first:
|
||||
- HunyuanVideo VAE GroupNorm+SiLU
|
||||
- LTX upsampler GroupNorm+SiLU
|
||||
- Z-Image residual-form modulation
|
||||
- SANA packed self-attention Q/K/V and cross-attention K/V GEMMs
|
||||
- fused diffusion `QK norm + RoPE`
|
||||
- LTX2 split RoPE
|
||||
- LTX2 residual-gate add
|
||||
- varlen USP attention pack/scatter
|
||||
- NVFP4 / Nunchaku packed QKV
|
||||
- Nunchaku fused GELU MLP
|
||||
- Ulysses / USP attention overlap
|
||||
- turbo-layer async all-to-all overlap
|
||||
- `torch.compile` compute / communication reorder
|
||||
- dual-stream diffusion execution
|
||||
|
||||
If the user explicitly requires `torch.compile` to stay off, do not use the
|
||||
default benchmark preset invocation unchanged. Either pass the checked-in
|
||||
benchmark helper its no-compile switch or run the equivalent manual command
|
||||
without `--enable-torch-compile`.
|
||||
|
||||
For FLUX-family manual profiling runs with a quantized transformer override:
|
||||
- use `sglang generate` directly
|
||||
- pass the override as `--transformer-path <dir>`
|
||||
- prefer `--prompt-path <file>` when also fixing `--output-file-name`
|
||||
- if the base model is already cached locally and the machine has unreliable HF access, use the local cached `--model-path` plus `HF_HUB_OFFLINE=1`
|
||||
- remember that `--profile` changes latency substantially; use the non-profile perf dump for the real before/after benchmark claim
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
---
|
||||
name: benchmark-and-profile-reference
|
||||
description: Reference commands and workflow for denoise benchmarks, perf dumps, and torch.profiler analysis in SGLang Diffusion.
|
||||
---
|
||||
|
||||
# SGLang Diffusion Benchmark and Profile Guide
|
||||
|
||||
**Primary Metric: Denoise Latency**
|
||||
- Denoise latency is the total DiT forward-pass time across all inference steps.
|
||||
- It is the dominant cost for diffusion inference and the main optimization target.
|
||||
- End-to-end latency and peak memory are secondary sanity checks.
|
||||
|
||||
> **Correctness First**: Faster but incorrect output is not an improvement. Always compare generated images or videos against a reference baseline before and after any change.
|
||||
|
||||
## Scope
|
||||
|
||||
This guide intentionally stops at:
|
||||
- checked-in denoise benchmarks
|
||||
- structured perf dumps
|
||||
- `torch.profiler` trace capture
|
||||
- hotspot ranking
|
||||
- mapping hotspots to known fast paths
|
||||
|
||||
If the hotspot survives this checklist, package the perf dump, profiler trace,
|
||||
exact command, and shape/topology notes for the appropriate kernel, Nsight, or
|
||||
framework-specific optimization workflow. Do not grow this skill back into a
|
||||
general Nsight or kernel-authoring guide.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
ENV_PY=python/sglang/multimodal_gen/.claude/skills/sglang-diffusion-benchmark-profile/scripts/diffusion_skill_env.py
|
||||
BENCH_PY=python/sglang/multimodal_gen/.claude/skills/sglang-diffusion-benchmark-profile/scripts/bench_diffusion_denoise.py
|
||||
ROOT=$(python3 "$ENV_PY" print-root)
|
||||
cd "$ROOT"
|
||||
python3 "$ENV_PY" check-write-access >/dev/null
|
||||
|
||||
export HF_TOKEN=<your_hf_token> # required for gated repos such as black-forest-labs/FLUX.*
|
||||
export FLASHINFER_DISABLE_VERSION_CHECK=1
|
||||
export CUDA_VISIBLE_DEVICES=$(python3 "$ENV_PY" print-idle-gpus --count 1)
|
||||
|
||||
ASSET_DIR=$(python3 "$ENV_PY" print-assets-dir --mkdir)
|
||||
BENCH_DIR=$(python3 "$ENV_PY" print-output-dir --kind benchmarks --mkdir)
|
||||
PROFILE_DIR=$(python3 "$ENV_PY" print-output-dir --kind profiles --mkdir)
|
||||
export PROFILE_DIR
|
||||
|
||||
check() {
|
||||
local label="$1"
|
||||
shift
|
||||
"$@" &>/dev/null && echo "[OK] $label" || echo "[MISS] $label"
|
||||
}
|
||||
|
||||
check "sglang" python3 -c "import sglang"
|
||||
check "torch+CUDA" python3 -c "import torch; assert torch.cuda.is_available()"
|
||||
check "torch.profiler" python3 -c "import torch.profiler"
|
||||
```
|
||||
|
||||
## Native Backend Gate
|
||||
|
||||
Every benchmark and profile result in this guide must come from the native SGLang diffusion backend.
|
||||
|
||||
If the command log contains any of:
|
||||
- `Falling back to diffusers backend`
|
||||
- `Using diffusers backend`
|
||||
- `Loaded diffusers pipeline`
|
||||
|
||||
then stop immediately:
|
||||
- do not record the perf dump or trace as valid benchmark evidence
|
||||
- do not compare it against other runs
|
||||
- do not continue to hotspot ranking or kernel optimization
|
||||
- first fix backend selection so the model stays on the native SGLang diffusion path
|
||||
|
||||
The checked-in benchmark helper pins `--backend=sglang` so native presets fail
|
||||
fast instead of silently falling back through `--backend=auto`. Do the same for
|
||||
manual native profiling commands unless you are intentionally collecting a
|
||||
diffusers baseline.
|
||||
|
||||
Environment notes:
|
||||
- all commands below assume you are inside the configured diffusion container shell
|
||||
- export `HF_TOKEN` before any gated Hugging Face model run
|
||||
- export `FLASHINFER_DISABLE_VERSION_CHECK=1` before any benchmark or profiler run
|
||||
- re-run `print-idle-gpus` before each perf command if GPU availability may have changed
|
||||
- keep benchmark commands within 4 GPUs or fewer
|
||||
|
||||
Download input images required by some presets:
|
||||
|
||||
```bash
|
||||
wget -O "${ASSET_DIR}/cat.png" \
|
||||
https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png
|
||||
wget -O "${ASSET_DIR}/mova_single_person.jpg" \
|
||||
https://github.com/OpenMOSS/MOVA/raw/main/assets/single_person.jpg
|
||||
```
|
||||
|
||||
## Benchmark Presets
|
||||
|
||||
Treat `"$BENCH_PY"` as the source of truth for preset order.
|
||||
|
||||
Nightly diffusion comparison is server/API based (`sglang serve` plus requests).
|
||||
This skill stays on `sglang generate` for local benchmarking and profiling, but
|
||||
the nightly-aligned presets in `bench_diffusion_denoise.py` mirror
|
||||
`scripts/ci/utils/diffusion/comparison_configs.json` on model, task, prompt,
|
||||
reference image, size, frames, seed, GPU count, and SGLang serve args. If
|
||||
`comparison_configs.json` omits sampling params such as steps or guidance, the
|
||||
nightly-aligned `sglang generate` preset omits them too and relies on the same
|
||||
runtime defaults.
|
||||
When in doubt, re-check that JSON before trusting this reference.
|
||||
|
||||
List the current preset order:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" --list-models
|
||||
```
|
||||
|
||||
Check that the nightly presets still match the Nvidia nightly comparison config:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" --validate-nightly-alignment
|
||||
```
|
||||
|
||||
Run one preset and save a perf dump:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" \
|
||||
--model ltx2 \
|
||||
--label baseline \
|
||||
--output-dir "${BENCH_DIR}"
|
||||
```
|
||||
|
||||
Keep `torch.compile` off when the task requires it:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" \
|
||||
--model flux \
|
||||
--label baseline \
|
||||
--output-dir "${BENCH_DIR}" \
|
||||
--no-torch-compile
|
||||
```
|
||||
|
||||
Run the `LTX-2.3` one-stage skill preset:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" \
|
||||
--model ltx23-one-stage \
|
||||
--label baseline \
|
||||
--output-dir "${BENCH_DIR}"
|
||||
```
|
||||
|
||||
Run the nightly-aligned `LTX-2.3` TI2V two-stage preset:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" \
|
||||
--model ltx23-ti2v-two-stage \
|
||||
--label baseline \
|
||||
--output-dir "${BENCH_DIR}"
|
||||
```
|
||||
|
||||
Run the `LTX-2.3` two-stage skill preset:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" \
|
||||
--model ltx23-two-stage \
|
||||
--label baseline \
|
||||
--output-dir "${BENCH_DIR}"
|
||||
```
|
||||
|
||||
Run the full preset sweep only when you have enough GPU time for both the
|
||||
nightly-aligned cases and the source-tracked extras:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" \
|
||||
--all \
|
||||
--label prXXXX \
|
||||
--output-dir "${BENCH_DIR}"
|
||||
```
|
||||
|
||||
Nightly-aligned presets come first, followed by current-source extras from the
|
||||
registry / GPU test cases, then broader skill-only stress presets.
|
||||
|
||||
Use the preset categories this way:
|
||||
- **Nightly-aligned**: exact mirrors of
|
||||
`scripts/ci/utils/diffusion/comparison_configs.json`; use these when the goal
|
||||
is apples-to-apples comparison with CI / nightly coverage.
|
||||
- **Current-source extras**: models or request shapes with explicit support
|
||||
evidence in the current registry, GPU cases, compatibility matrix, pipeline
|
||||
files, or unit tests, but without a nightly comparison case yet.
|
||||
- **Skill-only stress / coverage presets**: extra profiling scenarios kept by
|
||||
this skill to stress a topology, high-resolution path, multi-GPU mode, or
|
||||
model-specific stage. These may be older than the latest registry additions,
|
||||
so re-check the active source tree before treating them as support-matrix
|
||||
commitments.
|
||||
|
||||
| Preset | Model | Nightly | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `flux` | `black-forest-labs/FLUX.1-dev` | Yes: `flux1_dev_t2i_1024` | Prompt, 1024x1024, seed 42, 2 GPUs, TP size 2, `--dit-layerwise-offload false`; no explicit steps/guidance override |
|
||||
| `flux2` | `black-forest-labs/FLUX.2-dev` | Yes: `flux2_dev_t2i_1024` | Prompt, 1024x1024, seed 42, 2 GPUs, TP size 2, `--dit-layerwise-offload false`; no explicit steps/guidance override |
|
||||
| `qwen` | `Qwen/Qwen-Image-2512` | Yes: `qwen_image_2512_t2i_1024` | Prompt, 1024x1024, seed 42, 2 GPUs, TP size 2; no explicit steps/guidance override |
|
||||
| `qwen-edit` | `Qwen/Qwen-Image-Edit-2511` | Yes: `qwen_image_edit_2511` | Uses the nightly cat image and edit prompt, 2 GPUs, TP size 2 |
|
||||
| `zimage` | `Tongyi-MAI/Z-Image-Turbo` | Yes: `zimage_turbo_t2i_1024` | Prompt, 1024x1024, seed 42, 2 GPUs, TP size 2; no explicit steps/guidance override |
|
||||
| `wan-t2v` | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | Yes: `wan22_t2v_a14b_720p` | 1280x720, 81 frames, 4 GPUs, CFG parallel, Ulysses degree 2, text encoder CPU offload and pinned CPU memory |
|
||||
| `wan-ti2v` | `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | Yes: `wan22_ti2v_5b_720p` | Nightly cat image and motion prompt, 1280x720, 81 frames, seed 42 |
|
||||
| `ltx2` | `Lightricks/LTX-2` | Yes: `ltx2_twostage_t2v` | `LTX2TwoStagePipeline`, 2 GPUs, CFG parallel, 768x512, 121 frames, seed 42 |
|
||||
| `ltx23-ti2v-two-stage` | `Lightricks/LTX-2.3` | Yes: `ltx2.3_twostage_ti2v_2gpus` | Nightly cat image, motion prompt, `LTX2TwoStagePipeline`, 2 GPUs, `--cfg-parallel-size 2`, 768x512, 121 frames, seed 42 |
|
||||
| `wan-i2v` | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | Yes: `wan22_i2v_a14b_720p` | Nightly cat image and motion prompt, 1280x720, 81 frames, 4 GPUs, CFG parallel, Ulysses degree 2, text encoder CPU offload and pinned CPU memory |
|
||||
| `qwen-image` | `Qwen/Qwen-Image` | No | Current-source extra covering the base Qwen-Image native path, separate from the nightly `Qwen-Image-2512` case |
|
||||
| `qwen-edit-2509` | `Qwen/Qwen-Image-Edit-2509` | No | Current-source extra for the pre-2511 edit-plus path; uses the cat image, 1024x1024 |
|
||||
| `zimage-base` | `Tongyi-MAI/Z-Image` | No | Current-source extra for non-turbo Z-Image; keep it separate from `zimage` / `Z-Image-Turbo` |
|
||||
| `flux2-klein` | `black-forest-labs/FLUX.2-klein-4B` | No | Current-source extra for the distilled FLUX.2 Klein path; gated repo, 1024x1024, DiT layerwise offload disabled |
|
||||
| `flux2-klein-base` | `black-forest-labs/FLUX.2-klein-base-4B` | No | Current-source extra for the undistilled FLUX.2 Klein Base path; gated repo, 1024x1024, DiT layerwise offload disabled |
|
||||
| `cosmos3-nano-t2i` | `nvidia/Cosmos3-Nano` | No | Current-source extra for the single-frame Cosmos3 image path; sets `SGLANG_DISABLE_COSMOS3_GUARDRAILS=1` in the helper environment |
|
||||
| `cosmos3-nano-t2v` | `nvidia/Cosmos3-Nano` | No | Current-source extra for a short Cosmos3 video path; sets `SGLANG_DISABLE_COSMOS3_GUARDRAILS=1` in the helper environment |
|
||||
| `ideogram4-fp8` | `ideogram-ai/ideogram-4-fp8` | No | Current-source extra matching the native Ideogram 4 FP8 pipeline; do not override steps/guidance directly because the sampling preset owns them |
|
||||
| `ernie-image-turbo` | `baidu/ERNIE-Image-Turbo` | No | Current-source extra for ERNIE-Image Turbo |
|
||||
| `glm-image` | `zai-org/GLM-Image` | No | Current-source extra for GLM-Image |
|
||||
| `sana-1.5-1.6b` | `Efficient-Large-Model/SANA1.5_1.6B_1024px_diffusers` | No | Current-source extra for a SANA native image path |
|
||||
| `fastwan22-ti2v-5b` | `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers` | No | Current-source extra matching the FastWan2.2 TI2V registered path |
|
||||
| `ltx23-hq-two-stage` | `Lightricks/LTX-2.3` | No | Current-source extra for `LTX2TwoStageHQPipeline` with `--ltx2-two-stage-device-mode=original`; high-resolution and VRAM-heavy |
|
||||
| `ltx23-one-stage` | `Lightricks/LTX-2.3` | No | Skill-only extra preset for the native `LTX-2.3` one-stage baseline; 2 GPUs, 768x512, 121 frames, fps 24, 30 steps, guidance 3.0, seed 1234 |
|
||||
| `ltx23-two-stage` | `Lightricks/LTX-2.3` | No | Skill-only high-resolution stress preset for the native `LTX-2.3` two-stage path; uses `LTX2TwoStagePipeline`, 2 GPUs, 1536x1024, 121 frames, fps 24, 30 steps, guidance 3.0, seed 1234 |
|
||||
| `ltx23-two-stage-cfg-parallel` | `Lightricks/LTX-2.3` | No | Skill-only high-resolution CFG-parallel stress preset matching `ltx23-two-stage` plus `--cfg-parallel-size 2` |
|
||||
| `hunyuanvideo` | `hunyuanvideo-community/HunyuanVideo` | No | Skill-only extra preset |
|
||||
| `mova-720p` | `OpenMOSS-Team/MOVA-720p` | No | Skill-only extra preset |
|
||||
| `helios` | `BestWishYsh/Helios-Base` | No | Skill-only extra preset |
|
||||
| `joyai-edit` | `jdopensource/JoyAI-Image-Edit-Diffusers` | No | Skill-only JoyAI image-edit preset; uses the cat image, 1024x1024, 40 steps, guidance 4.0, 2-GPU CFG parallel |
|
||||
| `firered-edit-1.0` | `FireRedTeam/FireRed-Image-Edit-1.0` | No | Skill-only FireRed 1.0 image-edit preset; QwenImageEditPlus native path; uses 2-GPU CFG parallel |
|
||||
| `firered-edit-1.1` | `FireRedTeam/FireRed-Image-Edit-1.1` | No | Skill-only FireRed 1.1 image-edit preset; QwenImageEditPlus native path; uses 2-GPU CFG parallel |
|
||||
| `hunyuan3d-shape` | `tencent/Hunyuan3D-2` | No | Skill-only Hunyuan3D shape-generation preset; primary metric is `Hunyuan3DShapeDenoisingStage` |
|
||||
|
||||
For Wan2.2 video models, remember the difference between **nightly alignment**
|
||||
and **best latency tuning**:
|
||||
- the nightly-aligned 4-GPU commands intentionally keep `--enable-cfg-parallel --ulysses-degree=2` so CFG and ring behavior stay covered
|
||||
- do not assume that is the fastest topology
|
||||
- for pure latency tuning, benchmark pure Ulysses too, for example `--ulysses-degree=4 --ring-degree=1` on 4 GPUs, and on 8 GPUs compare pure `--ulysses-degree=8` against `--enable-cfg-parallel --ulysses-degree=4`
|
||||
|
||||
### Manual command example: LTX-2 Two-Stage
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--model-path=Lightricks/LTX-2 \
|
||||
--pipeline-class-name=LTX2TwoStagePipeline \
|
||||
--prompt="A cat and a dog baking a cake together in a kitchen." \
|
||||
--width=768 --height=512 \
|
||||
--num-frames=121 \
|
||||
--seed=42 --num-gpus=2 --enable-cfg-parallel \
|
||||
--save-output --enable-torch-compile --warmup
|
||||
```
|
||||
|
||||
`LTX2TwoStagePipeline` is a native path. The spatial upsampler and distilled
|
||||
LoRA are auto-resolved from the same model snapshot unless you override them.
|
||||
|
||||
### Manual command example: LTX-2.3 TI2V Two-Stage
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--model-path=Lightricks/LTX-2.3 \
|
||||
--pipeline-class-name=LTX2TwoStagePipeline \
|
||||
--prompt="The cat starts walking slowly towards the camera." \
|
||||
--image-path="${ASSET_DIR}/cat.png" \
|
||||
--width=768 --height=512 \
|
||||
--num-frames=121 \
|
||||
--seed=42 --num-gpus=2 --cfg-parallel-size=2 \
|
||||
--save-output --enable-torch-compile --warmup
|
||||
```
|
||||
|
||||
This matches the nightly comparison case `ltx2.3_twostage_ti2v_2gpus`.
|
||||
|
||||
### Manual command example: LTX-2.3 One-Stage
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--model-path=Lightricks/LTX-2.3 \
|
||||
--prompt="A beautiful sunset over the ocean" \
|
||||
--negative-prompt="shaky, glitchy, low quality, worst quality, deformed, distorted, disfigured, motion smear, motion artifacts, fused fingers, bad anatomy, weird hand, ugly, transition, static." \
|
||||
--width=768 --height=512 \
|
||||
--num-frames=121 --fps=24 \
|
||||
--num-inference-steps=30 --guidance-scale=3.0 \
|
||||
--seed=1234 --num-gpus=2 \
|
||||
--save-output --enable-torch-compile --warmup
|
||||
```
|
||||
|
||||
Use this when you want the native `LTX2Pipeline` baseline for `LTX-2.3` at the
|
||||
validated one-stage resolution.
|
||||
|
||||
### Manual command example: LTX-2.3 Two-Stage High-Resolution Stress
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--model-path=Lightricks/LTX-2.3 \
|
||||
--pipeline-class-name=LTX2TwoStagePipeline \
|
||||
--prompt="A beautiful sunset over the ocean" \
|
||||
--negative-prompt="shaky, glitchy, low quality, worst quality, deformed, distorted, disfigured, motion smear, motion artifacts, fused fingers, bad anatomy, weird hand, ugly, transition, static." \
|
||||
--width=1536 --height=1024 \
|
||||
--num-frames=121 --fps=24 \
|
||||
--num-inference-steps=30 --guidance-scale=3.0 \
|
||||
--seed=1234 --num-gpus=2 \
|
||||
--save-output --enable-torch-compile --warmup
|
||||
```
|
||||
|
||||
This matches the skill-only `ltx23-two-stage` preset. Use it as a
|
||||
high-resolution stress target, not as a nightly comparison case.
|
||||
|
||||
### Manual command example: JoyAI Image Edit
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--backend=sglang \
|
||||
--model-path=jdopensource/JoyAI-Image-Edit-Diffusers \
|
||||
--prompt="Make the cat wear a red hat" \
|
||||
--image-path="${ASSET_DIR}/cat.png" \
|
||||
--width=1024 --height=1024 \
|
||||
--num-inference-steps=40 --guidance-scale=4.0 \
|
||||
--num-gpus=2 --enable-cfg-parallel --ulysses-degree=1 \
|
||||
--dit-layerwise-offload false --dit-cpu-offload false \
|
||||
--save-output --enable-torch-compile --warmup
|
||||
```
|
||||
|
||||
### Manual command example: FireRed Image Edit
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--backend=sglang \
|
||||
--model-path=FireRedTeam/FireRed-Image-Edit-1.1 \
|
||||
--prompt="Make the cat wear a red hat" \
|
||||
--image-path="${ASSET_DIR}/cat.png" \
|
||||
--width=1024 --height=1024 \
|
||||
--num-inference-steps=40 --guidance-scale=4.0 \
|
||||
--num-gpus=2 --enable-cfg-parallel --ulysses-degree=1 \
|
||||
--dit-layerwise-offload false --dit-cpu-offload false \
|
||||
--save-output --enable-torch-compile --warmup
|
||||
```
|
||||
|
||||
Use `FireRedTeam/FireRed-Image-Edit-1.0` in the same command when comparing the
|
||||
1.0 checkpoint. Both FireRed presets use the native `QwenImageEditPlusPipeline`
|
||||
path. On H100, 2-GPU CFG parallel reduced 40-step denoise latency versus the
|
||||
otherwise matching 2-GPU Ulysses command: FireRed 1.0 from 13419.15 ms to
|
||||
10955.90 ms, and FireRed 1.1 from 13414.72 ms to 10934.21 ms.
|
||||
|
||||
### Manual command example: Hunyuan3D Shape
|
||||
|
||||
```bash
|
||||
OUTPUT_DIR=$(python3 "$ENV_PY" print-output-dir --kind benchmarks --mkdir)
|
||||
CONFIG_DIR="${OUTPUT_DIR}/generated_configs"
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
printf '{"paint_enable": false}\n' > "${CONFIG_DIR}/hunyuan3d-shape.json"
|
||||
|
||||
sglang generate \
|
||||
--backend=sglang \
|
||||
--model-path=tencent/Hunyuan3D-2 \
|
||||
--prompt="generate 3d mesh" \
|
||||
--image-path="${ASSET_DIR}/cat.png" \
|
||||
--config="${CONFIG_DIR}/hunyuan3d-shape.json" \
|
||||
--num-inference-steps=50 --guidance-scale=5.0 \
|
||||
--dit-layerwise-offload false --dit-cpu-offload false \
|
||||
--save-output --enable-torch-compile --warmup
|
||||
```
|
||||
|
||||
For Hunyuan3D, compare the denoise stage separately from mesh export and paint
|
||||
stages. The benchmark helper reports `Hunyuan3DShapeDenoisingStage` as the
|
||||
primary denoise metric.
|
||||
|
||||
### Manual command example: Wan2.2-I2V-A14B 720P
|
||||
|
||||
```bash
|
||||
# Select four idle GPUs first:
|
||||
# export CUDA_VISIBLE_DEVICES=$(python3 "$ENV_PY" print-idle-gpus --count 4)
|
||||
sglang generate \
|
||||
--model-path=Wan-AI/Wan2.2-I2V-A14B-Diffusers \
|
||||
--prompt="The cat starts walking slowly towards the camera." \
|
||||
--image-path="${ASSET_DIR}/cat.png" \
|
||||
--width=1280 --height=720 --num-frames=81 \
|
||||
--seed=42 --save-output \
|
||||
--num-gpus=4 --enable-cfg-parallel --ulysses-degree=2 \
|
||||
--text-encoder-cpu-offload --pin-cpu-memory \
|
||||
--warmup --enable-torch-compile
|
||||
```
|
||||
|
||||
`Wan2.2-I2V-A14B` uses the 720p max-area config by default, and explicit
|
||||
`--width/--height` overrides control the target area while preserving the
|
||||
reference-image aspect ratio.
|
||||
|
||||
## Perf Dump Workflow
|
||||
|
||||
For every benchmark run, write a perf dump JSON:
|
||||
|
||||
```bash
|
||||
sglang generate ... --warmup --perf-dump-path "${BENCH_DIR}/<result>.json"
|
||||
```
|
||||
|
||||
Before/after comparison:
|
||||
|
||||
```bash
|
||||
python3 python/sglang/multimodal_gen/benchmarks/compare_perf.py \
|
||||
"${BENCH_DIR}/baseline.json" \
|
||||
"${BENCH_DIR}/new.json"
|
||||
```
|
||||
|
||||
Always keep:
|
||||
- denoise latency
|
||||
- end-to-end latency
|
||||
- peak GPU memory
|
||||
- exact command line, model shape, dtype, and GPU topology
|
||||
|
||||
Never keep a perf dump produced after a diffusers-backend fallback.
|
||||
|
||||
## `torch.profiler` Workflow
|
||||
|
||||
### 1. Establish the baseline
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 "$BENCH_PY" \
|
||||
--model flux \
|
||||
--label baseline \
|
||||
--output-dir "${BENCH_DIR}"
|
||||
```
|
||||
|
||||
Keep model shape, seed, and GPU topology fixed for every comparison. Save one
|
||||
reference image or video before changing code. If the active task requires
|
||||
`torch.compile` off, add `--no-torch-compile` here too.
|
||||
|
||||
### 2. Capture a representative trace
|
||||
|
||||
By default SGLang profiles the denoising stage. The default sampling window is
|
||||
5 profiled timesteps after warmup.
|
||||
|
||||
```bash
|
||||
SGLANG_DIFFUSION_TORCH_PROFILER_DIR="${PROFILE_DIR}/torch" \
|
||||
sglang generate \
|
||||
--model-path=black-forest-labs/FLUX.1-dev \
|
||||
--prompt="A futuristic cyberpunk city at night" \
|
||||
--width=1024 --height=1024 --num-inference-steps=50 \
|
||||
--seed=42 --enable-torch-compile --warmup \
|
||||
--profile
|
||||
```
|
||||
|
||||
Use `--profile-all-stages` only when you really need text encoder, VAE, or
|
||||
other non-denoise stages too.
|
||||
|
||||
The generated trace path is printed in the console and also lands under
|
||||
`SGLANG_DIFFUSION_TORCH_PROFILER_DIR`. The diffusion profiler falls back to
|
||||
`SGLANG_TORCH_PROFILER_DIR` and then `./logs` when the diffusion-specific env
|
||||
var is unset. Open the trace in Perfetto if you want a timeline view:
|
||||
- https://ui.perfetto.dev/
|
||||
|
||||
### 3. Rank the hot CUDA kernels
|
||||
|
||||
Use this parser for a quick top-k table without opening a browser:
|
||||
|
||||
```python
|
||||
import collections
|
||||
import glob
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
|
||||
log_dir = (
|
||||
os.environ.get("SGLANG_DIFFUSION_TORCH_PROFILER_DIR")
|
||||
or os.environ.get("SGLANG_TORCH_PROFILER_DIR")
|
||||
or "./logs"
|
||||
)
|
||||
trace_path = sorted(
|
||||
glob.glob(f"{log_dir}/*.trace.json.gz"),
|
||||
key=os.path.getmtime,
|
||||
reverse=True,
|
||||
)[0]
|
||||
|
||||
with gzip.open(trace_path, "rb") as f:
|
||||
data = json.loads(f.read())
|
||||
|
||||
cuda_ops = collections.defaultdict(lambda: {"total_us": 0, "count": 0})
|
||||
for event in data.get("traceEvents", []):
|
||||
if event.get("cat") in ("kernel", "gpu_memcpy") and "dur" in event:
|
||||
cuda_ops[event.get("name", "unknown")]["total_us"] += event["dur"]
|
||||
cuda_ops[event.get("name", "unknown")]["count"] += 1
|
||||
|
||||
print(f"{'Kernel':<90} {'Total(ms)':>10} {'Count':>6}")
|
||||
for name, stat in sorted(cuda_ops.items(), key=lambda item: -item[1]["total_us"])[:30]:
|
||||
print(f"{name:<90} {stat['total_us'] / 1000:>10.3f} {stat['count']:>6}")
|
||||
```
|
||||
|
||||
If you need better attribution, add `record_function(...)` scopes around DiT
|
||||
attention, norm, modulation, MLP, or communication boundaries and re-run.
|
||||
|
||||
### 4. Classify the hotspot with `existing-fast-paths.md`
|
||||
|
||||
Do not jump from a hot kernel straight into new code. First classify it against
|
||||
the known mainline families.
|
||||
|
||||
| What the trace shows | First interpretation |
|
||||
| --- | --- |
|
||||
| `fused_inplace_qknorm_rope` missing, but separate qk norm plus rope show up | Check whether the fused diffusion `QK norm + RoPE` path should have engaged |
|
||||
| `to_q -> to_k -> to_v` on NVFP4 or Nunchaku FLUX-family checkpoints | Treat as a packed-QKV fast-path miss or checkpoint-format mismatch |
|
||||
| `fused_norm_tanh_mul_add*` missing on Z-Image | Treat as a missing mainline modulation path, not a new fusion request |
|
||||
| LTX-2 split RoPE appears as a long PyTorch elementwise chain | Check the `apply_ltx2_split_rotary_emb` Triton path and its shape guards |
|
||||
| masked attention spends time packing/unpacking Q/K/V | Check whether fused varlen USP pack/scatter should have engaged |
|
||||
| `all_to_all`, ring attention, or async A2A dominate | Classify against Ulysses, USP, or turbo-layer overlap first |
|
||||
| split `fc1 -> gelu -> quant -> fc2.lora_down` on Nunchaku FLUX | Treat as a missing fused GELU MLP path |
|
||||
| attention kernels dominate | Confirm backend, topology, and shape guards before proposing a new kernel |
|
||||
|
||||
If the hot path is already covered by a mainline optimization family, fix the
|
||||
enablement, shape guard, backend choice, or checkpoint mapping first.
|
||||
|
||||
### 5. Hand off only real kernel work
|
||||
|
||||
Only after the hotspot survives the fast-path checklist:
|
||||
|
||||
1. save a baseline perf dump
|
||||
2. save a representative `torch.profiler` trace
|
||||
3. note the exact model, shape, dtype, and GPU topology
|
||||
4. hand the work to the appropriate kernel, Nsight, or framework-specific optimization workflow
|
||||
|
||||
This skill intentionally stops here. It tells you whether you are looking at:
|
||||
- a missing existing optimization
|
||||
- a configuration or backend problem
|
||||
- or a real kernel opportunity worth handing off
|
||||
|
||||
## Minimal Merge Checklist
|
||||
|
||||
- [ ] fixed-shape baseline perf dump saved
|
||||
- [ ] fixed-shape new perf dump saved
|
||||
- [ ] `compare_perf.py` table generated
|
||||
- [ ] one representative `torch.profiler` trace saved
|
||||
- [ ] hotspot classified against `existing-fast-paths.md`
|
||||
- [ ] reference image or video checked for correctness
|
||||
- [ ] any remaining kernel work handed off with perf/profile evidence attached
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
# SGLang Diffusion Fast Paths
|
||||
|
||||
Use this guide when mapping a diffusion bottleneck to an existing fused path or
|
||||
distributed overlap pattern in `sglang.multimodal_gen`. Prefer reuse and
|
||||
configuration first before handing the problem to a kernel, Nsight, or
|
||||
framework-specific optimization workflow.
|
||||
|
||||
**Key Files**
|
||||
- `python/sglang/multimodal_gen/runtime/layers/layernorm.py`
|
||||
- `python/sglang/multimodal_gen/runtime/layers/elementwise.py`
|
||||
- `python/sglang/multimodal_gen/runtime/layers/fused_scale_shift_gate.py`
|
||||
- `python/sglang/multimodal_gen/runtime/layers/rotary_embedding/utils.py`
|
||||
- `python/sglang/jit_kernel/diffusion/triton/scale_shift.py`
|
||||
- `python/sglang/jit_kernel/diffusion/group_norm_silu.py`
|
||||
- `python/sglang/jit_kernel/diffusion/triton/group_norm_silu.py`
|
||||
- `python/sglang/jit_kernel/diffusion/triton/norm.py`
|
||||
- `python/sglang/jit_kernel/diffusion/triton/rmsnorm_onepass.py`
|
||||
- `python/sglang/jit_kernel/diffusion/triton/rotary.py`
|
||||
- `python/sglang/jit_kernel/diffusion/triton/ltx2_rotary.py`
|
||||
- `python/sglang/jit_kernel/diffusion/residual_gate_add.py`
|
||||
- `python/sglang/jit_kernel/csrc/diffusion/residual_gate_add.cuh`
|
||||
- `python/sglang/jit_kernel/diffusion/triton/varlen_pack_pad.py`
|
||||
- `python/sglang/jit_kernel/diffusion/cutedsl/scale_residual_norm_scale_shift.py`
|
||||
- `test/registered/jit/diffusion/test_qwen_image_modulation.py`
|
||||
- `test/registered/jit/diffusion/test_group_norm_silu.py`
|
||||
- `test/registered/jit/diffusion/test_residual_gate_add.py`
|
||||
- `test/registered/jit/diffusion/test_varlen_pack_pad.py`
|
||||
- `test/registered/jit/diffusion/test_varlen_uspattn_equivalence.py`
|
||||
- `test/registered/jit/benchmark/diffusion/bench_qwen_image_modulation.py`
|
||||
- `test/registered/jit/benchmark/diffusion/bench_group_norm_silu.py`
|
||||
- `test/registered/jit/benchmark/diffusion/bench_residual_gate_add.py`
|
||||
- `python/sglang/jit_kernel/norm.py`
|
||||
- `python/sglang/multimodal_gen/runtime/platforms/cuda.py`
|
||||
- `python/sglang/multimodal_gen/runtime/layers/attention/selector.py`
|
||||
- `docs/diffusion/performance/attention_backends.md` (repo root)
|
||||
|
||||
**Core Fusion Patterns**
|
||||
|
||||
1. Scale/Shift elementwise and gate fusion (AdaLN modulation)
|
||||
- Kernels: `fuse_scale_shift_kernel`, `fuse_layernorm_scale_shift_gate_select01_kernel`, `fuse_residual_layernorm_scale_shift_gate_select01_kernel`
|
||||
- Locations: `elementwise.py`, `layernorm.py`, `fused_scale_shift_gate.py`, `qwen_image.py`, `triton/scale_shift.py`
|
||||
- Use cases: `x * (1 + scale) + shift`, `a * (k + b) + c`, and Qwen-style `(layernorm/residual layernorm) + scale/shift + gate select`.
|
||||
- Constraints: `x` must be CUDA and contiguous. `scale/shift` support 0D/1D/2D/3D/4D broadcast. 4D `[B, F, 1, C]` requires `L % F == 0`.
|
||||
- NPU fallback: `scale_shift.py` swaps to `npu_fallback` native path.
|
||||
- Validation: `test/registered/jit/diffusion/test_qwen_image_modulation.py`.
|
||||
|
||||
2. Norm + Scale/Shift fusion (CuTe DSL)
|
||||
- Kernels: `fused_norm_scale_shift`, `fused_scale_residual_norm_scale_shift`
|
||||
- Locations: `layernorm.py`, `cutedsl/scale_residual_norm_scale_shift.py`
|
||||
- Use cases:
|
||||
- `y = norm(x) * (1 + scale) + shift`
|
||||
- `y = norm(residual + gate * x) * (1 + scale) + shift`
|
||||
- Constraints: `D % 256 == 0` and `D <= 8192`. `x/residual/gate/scale/shift` must pass shape and stride validation. Dtypes limited to fp16/bf16/fp32.
|
||||
- Behavior: CuTe DSL compilation cached by `(dtype, ndim, D, norm_type)`. `None` tensors replaced by scalar placeholders. If constraints fail, `layernorm.py` warns and falls back to native PyTorch.
|
||||
|
||||
3. Z-Image fused tanh/gate modulation
|
||||
- Kernels: `fused_norm_tanh_mul_add`, `fused_norm_tanh_mul_add_norm_scale`
|
||||
- Locations: `layernorm.py`, `cutedsl/norm_tanh_mul_add_norm_scale.py`, `zimage.py`
|
||||
- Use cases:
|
||||
- `y = tanh(gate) * norm(x) + shift`
|
||||
- `y, y2 = tanh(gate) * norm(x) + shift`, then `y2 = norm(y) * (1 + scale)`
|
||||
- Constraints: same CuTe DSL envelope as the norm+scale/shift family in practice: contiguous last dim, fp16/bf16/fp32, and `D % 256 == 0`, `D <= 8192`.
|
||||
- Validation: `test/registered/jit/diffusion/test_norm_tanh_mul_add_norm_scale.py`
|
||||
- Behavior: this is already a mainline fast path, so if Z-Image traces show the unfused chain, treat it as a missing or regressed existing optimization before proposing a new kernel.
|
||||
|
||||
4. Triton LayerNorm/RMSNorm fusion
|
||||
- Kernels: `rms_norm_fn`, `layer_norm_fn`, `norm_infer`
|
||||
- Locations: `triton/norm.py`, `layernorm.py`
|
||||
- Use cases: fp32 RMSNorm with residual/dropout/rowscale/x1 branches, and inference-friendly `norm_infer`.
|
||||
- Constraints: last dim must be contiguous, and `N * element_size < 64KB`.
|
||||
- Validation: `test/registered/jit/test_rmsnorm.py`.
|
||||
|
||||
5. Triton one-pass RMSNorm (small hidden size fast path)
|
||||
- Kernel: `triton_one_pass_rms_norm`
|
||||
- Locations: `triton/rmsnorm_onepass.py`, `layernorm.py`
|
||||
- Use case: `hidden_size <= 128` in `RMSNorm.forward_cuda`.
|
||||
- `torch.compile` note: keep this path behind the custom-op wrapper in `rmsnorm_onepass.py`; direct `wrap_triton` can recompile on dynamic row counts.
|
||||
|
||||
6. Triton RoPE fusion
|
||||
- Kernel: `apply_rotary_embedding`
|
||||
- Locations: `triton/rotary.py`, `rotary_embedding/utils.py`
|
||||
- Use case: GPT-J style RoPE when not Neox.
|
||||
- Constraints: `head_size` must be even.
|
||||
- NPU fallback: `npu_fallback.apply_rotary_embedding_native`.
|
||||
- Validation: `test/registered/jit/test_rope.py`.
|
||||
|
||||
7. LTX2 split RoPE fusion
|
||||
- Kernel: `apply_ltx2_split_rotary_emb`
|
||||
- Locations: `triton/ltx2_rotary.py`, `runtime/models/dits/ltx_2.py`
|
||||
- Use case: LTX-2 split rotary embedding over `[B, S, num_heads * head_dim]` with separate `cos` and `sin` tensors.
|
||||
- Constraints: `cos` and `sin` shapes must match `[B, H, S, head_dim / 2]`, and `inner_dim == H * head_dim`.
|
||||
- Workflow rule: if LTX-2 traces show a large split-RoPE PyTorch chain, check whether the LTX2-specific Triton path was disabled by shape or dtype before proposing a new RoPE kernel.
|
||||
|
||||
8. LTX2 residual-gate add fusion
|
||||
- Kernel: `diffusion_residual_gate_add`
|
||||
- Locations: `diffusion/residual_gate_add.py`, `csrc/diffusion/residual_gate_add.cuh`, `runtime/models/dits/ltx_2.py`
|
||||
- Use case: `residual + update * gate` in LTX2 self-attention, prompt cross-attention, audio/video cross-attention, and feed-forward residual updates.
|
||||
- Constraints: `residual`, `update`, and `gate` must be CUDA tensors on the same device, contiguous, same dtype (`fp16`, `bf16`, or `fp32`), with `update.shape == residual.shape`; `gate` can match `residual` or be row-broadcast with the last dimension matching.
|
||||
- Behavior: `_ltx2_residual_gate_add(...)` uses the CUDA custom op while guards pass. On a runtime exception outside `torch.compile`, it logs once, disables the fast path for the process, and falls back to `residual + update * gate`.
|
||||
- Validation: `test/registered/jit/diffusion/test_residual_gate_add.py`.
|
||||
- Microbench: `test/registered/jit/benchmark/diffusion/bench_residual_gate_add.py`.
|
||||
- Workflow rule: if LTX2 traces show repeated elementwise `mul` + `add` ladders around attention or MLP residuals, check whether this existing CUDA path was disabled by shape, dtype, contiguity, or a prior runtime failure before proposing another elementwise fusion.
|
||||
|
||||
9. HunyuanVideo / LTX upsampler GroupNorm + SiLU fusion
|
||||
- Kernel: `triton_group_norm_silu`
|
||||
- Locations: `diffusion/group_norm_silu.py`, `triton/group_norm_silu.py`, `runtime/models/vaes/hunyuanvae.py`, `runtime/models/upsampler/latent_upsampler.py`
|
||||
- Use case: `activation(group_norm(x))` when the activation is non-inplace `nn.SiLU` and the GroupNorm is affine.
|
||||
- Enablement: mainline uses `apply_group_norm_silu(...)` in HunyuanVideo VAE paths and LTX latent upsampler paths by default; there is no env toggle. The wrapper dispatches to Triton only when guards pass.
|
||||
- Constraints: CUDA inference path only; no grad, `x.requires_grad == False`, `nn.GroupNorm`, `nn.SiLU(inplace=False)`, affine norm with weight and bias. Unsupported cases fall back to native `activation(norm(x))`.
|
||||
- Validation: `test/registered/jit/diffusion/test_group_norm_silu.py`.
|
||||
- Microbench: `test/registered/jit/benchmark/diffusion/bench_group_norm_silu.py`.
|
||||
|
||||
**Faster CUDA Kernel Usage Points**
|
||||
|
||||
1. sgl-kernel RMSNorm and fused add RMSNorm
|
||||
- Location: `layernorm.py`
|
||||
- Behavior:
|
||||
- Standard `bf16`/`fp16` CUDA paths use `sgl_kernel.fused_add_rmsnorm` and `sgl_kernel.rmsnorm`.
|
||||
- The Z-Image `fp32` `32x2560` path under `torch.compile` avoids `wrap_triton` and uses the native fp32 path.
|
||||
- `hidden_size <= 128` uses Triton one-pass.
|
||||
- ROCm falls back to native.
|
||||
|
||||
2. Attention backend selection (FlashAttention, Sage, SDPA)
|
||||
- Locations: `platforms/cuda.py`, `attention/selector.py`, `docs/diffusion/performance/attention_backends.md`
|
||||
- Behavior: CUDA prefers FlashAttention (FA3/FA4) when supported, otherwise Torch SDPA. Force via `--attention-backend` or `global_force_attn_backend`.
|
||||
|
||||
3. FlashInfer RoPE (Q/K inplace)
|
||||
- Location: `rotary_embedding/utils.py`
|
||||
- Behavior: `flashinfer.rope.apply_rope_with_cos_sin_cache_inplace` when available, otherwise Triton RoPE fallback.
|
||||
|
||||
4. Varlen USP attention pack/scatter
|
||||
- Locations: `runtime/layers/attention/layer.py`, `triton/varlen_pack_pad.py`
|
||||
- Behavior: masked `USPAttention.forward` can gather dense Q/K/V into packed `[total_valid, H, D]` rows with `fused_pack_qkv`, run varlen attention, then scatter back with `fused_scatter_to_padded`.
|
||||
- Validation: `test/registered/jit/diffusion/test_varlen_pack_pad.py` and `test_varlen_uspattn_equivalence.py`.
|
||||
- Workflow rule: if a masked attention trace spends time in Python/advanced indexing pack or scatter, first check whether this fused varlen path should have engaged.
|
||||
|
||||
**QK Norm Optimization**
|
||||
|
||||
- Entry point: `apply_qk_norm` in `layernorm.py`.
|
||||
- Fast path: JIT fused inplace QK norm from `python/sglang/jit_kernel/norm.py` via `fused_inplace_qknorm`.
|
||||
- Preconditions for fused path:
|
||||
- CUDA only.
|
||||
- `allow_inplace=True` and `q_eps == k_eps`.
|
||||
- `can_use_fused_inplace_qknorm(head_dim, dtype)` returns true.
|
||||
- Supported head dims: `64, 128, 256, 512, 1024`.
|
||||
- Behavior: Fused path operates on `q` and `k` in place after reshaping to `[B, -1, head_dim]`. If preconditions fail, fall back to per-tensor RMSNorm.
|
||||
- Validation: `test/registered/jit/test_qknorm.py` and `test/registered/jit/test_qknorm_across_heads.py`.
|
||||
|
||||
**QK Norm + RoPE Optimization**
|
||||
|
||||
- Entry point: `apply_qk_norm_rope` in `layernorm.py`.
|
||||
- Fast path: JIT fused inplace QK norm + RoPE from `python/sglang/jit_kernel/diffusion/qknorm_rope.py` via `fused_inplace_qknorm_rope`.
|
||||
- Toggle: `SGLANG_ENABLE_FUSED_QKNORM_ROPE=1` keeps the fused path enabled by default.
|
||||
- Preconditions for fused path:
|
||||
- CUDA only.
|
||||
- `allow_inplace=True` and `q_eps == k_eps`.
|
||||
- `q` / `k` are contiguous 4D tensors with the same shape.
|
||||
- `q.dtype` is `fp16` or `bf16`, and norm weights match tensor dtype.
|
||||
- `can_use_fused_inplace_qknorm_rope(head_dim, rope_dim, is_neox, dtype)` returns true.
|
||||
- Supported head dims: `64, 128, 256`.
|
||||
- Behavior: `apply_qk_norm_rope` prefers the fused JIT kernel when all guards pass; otherwise it falls back to `apply_qk_norm(...)` plus `apply_flashinfer_rope_qk_inplace(...)`.
|
||||
- Validation: `test/registered/jit/diffusion/test_qknorm_rope.py`.
|
||||
- Workflow rule: treat LTX2 traces that miss the generic fused path as an enablement/shape-guard issue first, and check the separate LTX2 split-RoPE path before proposing new attention-prep kernels.
|
||||
|
||||
**Nunchaku Fused GELU MLP**
|
||||
|
||||
- Entry point: `_fused_gelu_mlp` in `runtime/models/dits/flux.py`.
|
||||
- Fast path: Nunchaku checkpoints can fuse `fc1 GEMM + GELU + shift + re-quant + fc2.lora_down` before the second GEMM instead of materializing a standalone GELU activation.
|
||||
- Scope: this is a model-specific fast path for Nunchaku-quantized FLUX-family checkpoints.
|
||||
- Workflow rule: if a Nunchaku trace shows split `fc1 -> gelu -> quant -> fc2.lora_down`, treat it as a missing existing fast path before proposing a new fusion.
|
||||
|
||||
**NVFP4 / Nunchaku Packed QKV**
|
||||
|
||||
- Entry points: `runtime/models/dits/flux.py`, `runtime/models/dits/flux_2.py`, and the FLUX config remapping in `configs/models/dits/flux.py`.
|
||||
- Fast path: quantized FLUX-family checkpoints can store attention projections in packed QKV form, and SGLang intentionally switches to `MergedColumnParallelLinear` paths such as `to_qkv`, `to_added_qkv`, and `to_qkv_mlp_proj` instead of separate `to_q`, `to_k`, `to_v`.
|
||||
- FLUX.2 NVFP4 note: `flux_2.py` explicitly enables fused packed QKV when `quant_config` is `ModelOptFp4Config`, because the NVFP4 checkpoint stores image-attention QKV packed on disk.
|
||||
- Nunchaku note: raw and converted Nunchaku checkpoint names are remapped onto fused `to_qkv` / `to_added_qkv` names in `configs/models/dits/flux.py`; correctness on NVFP4-style checkpoints also depends on quant metadata such as `wtscale` and attention `wcscales`.
|
||||
- Workflow rule: if an NVFP4 or Nunchaku trace shows split `to_q -> to_k -> to_v` where packed QKV is expected, treat it as a missing quantized fast path or checkpoint-format mismatch before proposing a new attention fusion.
|
||||
|
||||
**SANA Packed Projection GEMMs**
|
||||
|
||||
- Entry point: `runtime/models/dits/sana.py`.
|
||||
- Fast path: SANA self-attention uses one `MergedColumnParallelLinear` `to_qkv` GEMM for Q/K/V, and SANA cross-attention uses one `MergedColumnParallelLinear` `to_kv` GEMM for encoder K/V.
|
||||
- Scope: this is a mainline SANA model fast path. Query projection in cross-attention remains separate because it uses denoising hidden states, while K/V share step-invariant encoder hidden states.
|
||||
- Workflow rule: if a SANA trace shows separate self-attention `to_q`, `to_k`, `to_v` GEMMs, or separate cross-attention `to_k` and `to_v` GEMMs, treat that as a regressed existing packed-projection path before proposing a new GEMM fusion.
|
||||
|
||||
**Common Entry Points in Diffusion Models**
|
||||
- AdaLN modulation: `LayerNormScaleShift`, `RMSNormScaleShift`, `ScaleResidual*` in `layernorm.py`.
|
||||
- Qwen-Image gating: `fuse_layernorm_scale_shift_gate_select01_kernel` and `fuse_residual_layernorm_scale_shift_gate_select01_kernel` through `fused_scale_shift_gate.py` and `qwen_image.py`.
|
||||
- Z-Image residual-form modulation: `fused_norm_tanh_mul_add` and `fused_norm_tanh_mul_add_norm_scale` in `zimage.py`.
|
||||
- HunyuanVideo VAE and LTX upsampler GroupNorm+SiLU: `apply_group_norm_silu` in `hunyuanvae.py` and `latent_upsampler.py`; default-eligible when wrapper guards pass.
|
||||
- QK norm: `apply_qk_norm` used in `flux.py`, `flux_2.py`, `qwen_image.py`, `zimage.py`, `wanvideo.py`, `ltx_2.py`, `hunyuanvideo.py`.
|
||||
- QK norm + RoPE: `apply_qk_norm_rope` in `layernorm.py`; use this path when the model wants fused attention prep instead of separate QK norm and RoPE calls.
|
||||
- LTX2 split RoPE: `apply_ltx2_split_rotary_emb` in `ltx_2.py`.
|
||||
- LTX2 residual-gate add: `_ltx2_residual_gate_add` in `ltx_2.py` wraps the CUDA `diffusion_residual_gate_add` custom op for attention, cross-attention, and MLP residual updates.
|
||||
- Varlen USP attention: `fused_pack_qkv` and `fused_scatter_to_padded` in `attention/layer.py`.
|
||||
- SANA packed projections: `to_qkv` and `to_kv` in `sana.py`.
|
||||
- Nunchaku fused GELU MLP: `_fused_gelu_mlp` in `flux.py` for quantized FLUX-family checkpoints.
|
||||
- NVFP4 / packed QKV attention: `to_qkv`, `to_added_qkv`, and `to_qkv_mlp_proj` in FLUX-family quantized paths.
|
||||
- RoPE: `_apply_rotary_emb` prefers Triton; Q/K RoPE prefers FlashInfer when present.
|
||||
|
||||
**Existing Overlap / Communication Families**
|
||||
|
||||
- Ulysses / USP attention: treat `all_to_all`, `ring_attn`, and head / sequence reshards as an existing distributed attention family, not a new overlap idea.
|
||||
- Turbo-layer async all-to-all: `all_to_all_single(..., async_op=True)` plus staged waits already form an existing overlap family in `turbo_layer.py`.
|
||||
- TorchInductor compute / communication reorder: `torch._inductor.config.reorder_for_compute_comm_overlap = True` can already partially overlap compiled denoise traces.
|
||||
- Dual-stream diffusion models: `use_dual_stream = True` in models such as `hunyuan3d.py` is an existing overlap family.
|
||||
- Workflow rule: if a hotspot is communication-heavy, rule out these in-repo overlap families before proposing a brand new overlap design.
|
||||
|
||||
**Historical PR Watchlist**
|
||||
|
||||
These SGLang PRs are useful as upstream direction and prior art, not as
|
||||
current-main behavior. Re-check the PR state and the active source tree before
|
||||
relying on any file path, flag, or claim about whether the work has merged.
|
||||
|
||||
- Norm, modulation, and packed projection fusions:
|
||||
- #24025 LTX2 QK norm fusion.
|
||||
- #24059 Helios fused norm modulation.
|
||||
- #24117 Z-Image packed QKV.
|
||||
- #19488 Wan cross-block elementwise fusion.
|
||||
- #19249 Z-Image `scale residual norm scale shift` plus `add gate norm` fusion.
|
||||
- #18897 dual norm fusion for FLUX-family paths (draft).
|
||||
- #20429 Qwen-Image layernorm and `fuse_scale_shift_gate_select01` work.
|
||||
- #20530 MOVA fused RMSNorm + interleaved RoPE.
|
||||
- #29361 LTX2 residual-gate CUDA fast path for `residual + update * gate`.
|
||||
- VAE and decode-side acceleration:
|
||||
- #22531 LTX2 parallel VAE support and #20927 batched tiled VAE decode (draft).
|
||||
- Attention, communication, and runtime scheduling:
|
||||
- #22805 FLUX.2 packed QKV for all-to-all.
|
||||
- #21742 hybrid attention schedule.
|
||||
- #24053 USP attention with replicated prefixes.
|
||||
- #18764 dynamic batching v0.
|
||||
- #24200 disaggregated diffusion v2.
|
||||
- Cache and CUDA graph:
|
||||
- #21613 TeaCache refactor.
|
||||
- #24227 WanVideo TeaCache skipping fix.
|
||||
- #20447 TeaCache support for GLM-Image, Qwen-Image, and related models.
|
||||
- #19516 Qwen-Image CUDA Graph.
|
||||
- #21912 Z-Image Turbo FP8 full quantization and CUDA Graph.
|
||||
|
||||
**Constraints and Fallbacks**
|
||||
- `scale_shift` Triton requires CUDA + contiguous `x`. NPU swaps to native.
|
||||
- CuTe DSL fused norms require `D % 256 == 0` and `D <= 8192`.
|
||||
- Triton norm kernels error on feature size >= 64KB.
|
||||
- FlashAttention requires fp16/bf16 and SM80+; otherwise SDPA.
|
||||
|
||||
**Integration Checklist for New Models**
|
||||
|
||||
1. Reuse `LayerNormScaleShift` or `ScaleResidual*` modules instead of re-implementing fusion logic.
|
||||
2. Keep tensors contiguous and satisfy D alignment (`% 256`) and size (`<= 8192`) for CuTe fused paths.
|
||||
3. Use `fuse_scale_shift_kernel` for AdaLN modulation and keep a PyTorch fallback.
|
||||
4. Use `apply_qk_norm` and ensure head_dim is in the supported list for fused QK norm.
|
||||
5. If using FlashInfer RoPE, avoid `pack qkv` and ensure Q/K are contiguous.
|
||||
6. For attention, follow `selector.py` priority; override with CLI only if needed.
|
||||
|
||||
**When Extending or Modifying Kernels**
|
||||
- Add `torch.library.custom_op` and `register_fake` for compile and meta support.
|
||||
- Keep CuTe compile cache keys aligned to `(dtype, ndim, D)`.
|
||||
- Avoid implicit broadcasts that force hidden `contiguous()` copies.
|
||||
- Preserve NPU and ROCm fallback paths.
|
||||
- If none of the families above match, package the evidence from the benchmark/profile skill and hand the kernel work to the appropriate kernel, Nsight, or framework-specific optimization workflow.
|
||||
+1011
File diff suppressed because it is too large
Load Diff
+174
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
OUTPUT_DIR_NAMES = {
|
||||
"benchmarks": Path("outputs/diffusion_benchmarks"),
|
||||
"profiles": Path("outputs/diffusion_profiles"),
|
||||
}
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
import sglang
|
||||
|
||||
return Path(sglang.__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def get_assets_dir(repo_root: Path | None = None) -> Path:
|
||||
root = repo_root or get_repo_root()
|
||||
return root / "inputs" / "diffusion_benchmark" / "figs"
|
||||
|
||||
|
||||
def get_output_dir(name: str, repo_root: Path | None = None) -> Path:
|
||||
if name not in OUTPUT_DIR_NAMES:
|
||||
raise KeyError(f"Unknown output dir name: {name}")
|
||||
root = repo_root or get_repo_root()
|
||||
return root / OUTPUT_DIR_NAMES[name]
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def check_write_access(repo_root: Path | None = None) -> Path:
|
||||
root = repo_root or get_repo_root()
|
||||
probe_dir = ensure_dir(root / ".cache" / "diffusion_skill_write_test")
|
||||
probe_file = probe_dir / "probe.txt"
|
||||
probe_file.write_text("ok", encoding="utf-8")
|
||||
return probe_file
|
||||
|
||||
|
||||
def _run_nvidia_smi(query: str) -> list[list[str]]:
|
||||
command = [
|
||||
"nvidia-smi",
|
||||
f"--query-{query}",
|
||||
"--format=csv,noheader,nounits",
|
||||
]
|
||||
result = subprocess.run(command, check=True, capture_output=True, text=True)
|
||||
rows: list[list[str]] = []
|
||||
for raw_line in result.stdout.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
rows.append([field.strip() for field in csv.reader([line]).__next__()])
|
||||
return rows
|
||||
|
||||
|
||||
def get_gpu_inventory() -> list[dict[str, int | str]]:
|
||||
rows = _run_nvidia_smi("gpu=index,uuid,memory.used,memory.total,utilization.gpu")
|
||||
inventory = []
|
||||
for index, uuid, memory_used, memory_total, utilization_gpu in rows:
|
||||
inventory.append(
|
||||
{
|
||||
"index": int(index),
|
||||
"uuid": uuid,
|
||||
"memory_used_mib": int(memory_used),
|
||||
"memory_total_mib": int(memory_total),
|
||||
"utilization_gpu_pct": int(utilization_gpu),
|
||||
}
|
||||
)
|
||||
return inventory
|
||||
|
||||
|
||||
def get_busy_gpu_uuids() -> set[str]:
|
||||
rows = _run_nvidia_smi("compute-apps=gpu_uuid,pid,process_name,used_gpu_memory")
|
||||
return {gpu_uuid for gpu_uuid, *_ in rows}
|
||||
|
||||
|
||||
def pick_idle_gpus(
|
||||
required_gpus: int,
|
||||
max_memory_used_mib: int = 32,
|
||||
max_utilization_gpu_pct: int = 5,
|
||||
) -> list[int]:
|
||||
inventory = get_gpu_inventory()
|
||||
busy_uuids = get_busy_gpu_uuids()
|
||||
|
||||
idle = [
|
||||
int(gpu["index"])
|
||||
for gpu in inventory
|
||||
if gpu["uuid"] not in busy_uuids
|
||||
and int(gpu["memory_used_mib"]) <= max_memory_used_mib
|
||||
and int(gpu["utilization_gpu_pct"]) <= max_utilization_gpu_pct
|
||||
]
|
||||
if len(idle) < required_gpus:
|
||||
raise RuntimeError(
|
||||
"Not enough idle GPUs. "
|
||||
f"required={required_gpus}, idle={idle}, inventory={inventory}, busy={sorted(busy_uuids)}"
|
||||
)
|
||||
return idle[:required_gpus]
|
||||
|
||||
|
||||
def configure_runtime_env(required_gpus: int = 1) -> str | None:
|
||||
os.environ.setdefault("FLASHINFER_DISABLE_VERSION_CHECK", "1")
|
||||
if os.environ.get("CUDA_VISIBLE_DEVICES"):
|
||||
return None
|
||||
selected = ",".join(str(index) for index in pick_idle_gpus(required_gpus))
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = selected
|
||||
return selected
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Resolve SGLang diffusion skill paths and idle GPUs."
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=[
|
||||
"print-root",
|
||||
"print-assets-dir",
|
||||
"print-output-dir",
|
||||
"print-idle-gpus",
|
||||
"check-write-access",
|
||||
],
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kind",
|
||||
choices=sorted(OUTPUT_DIR_NAMES),
|
||||
help="Output directory kind for print-output-dir.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--count",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of idle GPUs to print.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mkdir",
|
||||
action="store_true",
|
||||
help="Create the requested directory before printing it.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "print-root":
|
||||
print(get_repo_root())
|
||||
return
|
||||
if args.command == "print-assets-dir":
|
||||
path = get_assets_dir()
|
||||
if args.mkdir:
|
||||
ensure_dir(path)
|
||||
print(path)
|
||||
return
|
||||
if args.command == "print-output-dir":
|
||||
if not args.kind:
|
||||
raise SystemExit("--kind is required for print-output-dir")
|
||||
path = get_output_dir(args.kind)
|
||||
if args.mkdir:
|
||||
ensure_dir(path)
|
||||
print(path)
|
||||
return
|
||||
if args.command == "print-idle-gpus":
|
||||
print(",".join(str(index) for index in pick_idle_gpus(args.count)))
|
||||
return
|
||||
if args.command == "check-write-access":
|
||||
print(check_write_access())
|
||||
return
|
||||
raise SystemExit(f"Unhandled command: {args.command}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,407 @@
|
||||
---
|
||||
name: sglang-diffusion-modelopt-quant
|
||||
description: Use when quantizing a diffusion DiT with NVIDIA ModelOpt and making the resulting FP8 or NVFP4 checkpoint loadable, verifiable, and benchmarkable in SGLang Diffusion.
|
||||
---
|
||||
|
||||
# SGLang Diffusion ModelOpt Quant
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill when the task is to take a diffusion transformer through the full ModelOpt workflow:
|
||||
|
||||
- quantize it with NVIDIA ModelOpt
|
||||
- adapt the exported checkpoint to SGLang Diffusion
|
||||
- verify that quality holds up
|
||||
- benchmark whether the quantized checkpoint is actually faster
|
||||
|
||||
This skill owns the ModelOpt-to-SGLang bridge. It is not a generic kernel-tuning skill.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Use ModelOpt's official `quantize.py` as the PTQ source of truth.
|
||||
- Keep the workflow generic. Put model-specific fallback logic in small isolated branches, not in the main conversion path.
|
||||
- Benchmark only when BF16 and quantized commands are identical except for the checkpoint override being tested.
|
||||
- For diffusion FP8, keep `dit_cpu_offload=false`. `dit_layerwise_offload=true` is valid on the fixed path when you want lower DiT residency.
|
||||
- For multi-transformer pipelines, use per-component overrides when different components need different checkpoints.
|
||||
- For B200 NVFP4 validation, keep backend-sensitive environment variables explicit. Wan2.2 NVFP4 is commonly validated with `SGLANG_DIFFUSION_FLASHINFER_FP4_GEMM_BACKEND=cudnn`; benchmark the default CUTLASS path separately if that is what you are evaluating.
|
||||
- When a branch is missing the validated helper tools, refresh `python/sglang/multimodal_gen/tools/build_modelopt_fp8_transformer.py`, `python/sglang/multimodal_gen/tools/build_modelopt_nvfp4_transformer.py`, and `python/sglang/multimodal_gen/tools/compare_diffusion_trajectory_similarity.py` instead of inventing one-off scripts elsewhere.
|
||||
- After validating a new ModelOpt quant path, update the ModelOpt support matrix in `docs/diffusion/quantization.md` before closing the task.
|
||||
|
||||
## Read First
|
||||
|
||||
Read these sources before changing code:
|
||||
|
||||
- NVIDIA ModelOpt diffusers guide: `examples/diffusers/README.md`
|
||||
- ModelOpt quantization entrypoint: `examples/diffusers/quantization/quantize.py`
|
||||
- ModelOpt diffusers quant presets: `examples/diffusers/quantization/config.py`
|
||||
- SGLang diffusion quant runtime:
|
||||
- `python/sglang/multimodal_gen/runtime/layers/quantization/modelopt_quant.py`
|
||||
- `python/sglang/multimodal_gen/runtime/utils/quantization_utils.py`
|
||||
- `python/sglang/multimodal_gen/runtime/loader/transformer_load_utils.py`
|
||||
- Helper tools in this repo:
|
||||
- [`python/sglang/multimodal_gen/tools/build_modelopt_fp8_transformer.py`](../../../tools/build_modelopt_fp8_transformer.py)
|
||||
- [`python/sglang/multimodal_gen/tools/build_modelopt_nvfp4_transformer.py`](../../../tools/build_modelopt_nvfp4_transformer.py)
|
||||
- [`python/sglang/multimodal_gen/tools/compare_diffusion_trajectory_similarity.py`](../../../tools/compare_diffusion_trajectory_similarity.py)
|
||||
|
||||
If you are working on a new model family, inspect the transformer's config and tensor naming before changing the generic converter.
|
||||
|
||||
## What SGLang Supports Here
|
||||
|
||||
This repo now contains:
|
||||
|
||||
- flat `quant_method=modelopt` plus `quant_algo=FP8/NVFP4` resolution
|
||||
- diffusion-side ModelOpt FP8 linear loading
|
||||
- diffusion-side NVFP4 loading from ModelOpt exports
|
||||
- FLUX.2 packed-QKV detection that distinguishes packed NVFP4 checkpoints from standard diffusers exports
|
||||
- automatic protection against incompatible FP8 CPU offload while keeping layerwise DiT offload available
|
||||
- separate online diffusion quantization paths such as `--quantization fp8` / `mxfp4`; keep those out of this ModelOpt PTQ/export workflow unless the user explicitly asks for runtime quantization
|
||||
- FP8 transformer build:
|
||||
[`python/sglang/multimodal_gen/tools/build_modelopt_fp8_transformer.py`](../../../tools/build_modelopt_fp8_transformer.py)
|
||||
- NVFP4 mixed transformer build:
|
||||
[`python/sglang/multimodal_gen/tools/build_modelopt_nvfp4_transformer.py`](../../../tools/build_modelopt_nvfp4_transformer.py)
|
||||
- trajectory similarity validation:
|
||||
[`python/sglang/multimodal_gen/tools/compare_diffusion_trajectory_similarity.py`](../../../tools/compare_diffusion_trajectory_similarity.py)
|
||||
|
||||
Validated documentation and CI coverage currently center on these ModelOpt diffusion transformer override families:
|
||||
|
||||
- FP8: FLUX.1-dev, FLUX.2-dev, Wan2.2, HunyuanVideo, Qwen Image, Qwen Image Edit
|
||||
- NVFP4: FLUX.1-dev, FLUX.2-dev, Wan2.2
|
||||
|
||||
Treat a new family, a new precision, or a new checkpoint layout as unsupported until it has a documented matrix row and a matching validation story.
|
||||
Current B200 CI also contains an Ideogram4 NVFP4 native load case
|
||||
(`ideogram4_nvfp4_t2i` via `Comfy-Org/Ideogram-4`). Treat that as source
|
||||
evidence for an existing NVFP4 path, but do not expand the ModelOpt support
|
||||
matrix to Ideogram4 unless `docs/diffusion/quantization.md` is updated with the
|
||||
exact checkpoint, loader path, quality check, and benchmark scope.
|
||||
Before writing CLI examples, re-read the active branch's `docs/diffusion/quantization.md`: FLUX.2 NVFP4 is an official `black-forest-labs/*` repo rather than a `lmsys/*` converted repo, and its preferred flag depends on the current documented loader flow. Use `--transformer-path` for a component override directory with `config.json`; use `--transformer-weights-path` when the repo or path should be probed as raw weights.
|
||||
|
||||
B200 CI coverage can include loose BF16-vs-quantized quality checks. Inspect the active branch's `run_suite.py` before assuming they are part of the suite; mainline and feature branches may differ. Those checks are intended to catch blank, corrupted, or obviously divergent images, not exact image parity.
|
||||
|
||||
Mainline documentation now uses `lmsys/*` for the eight converted ModelOpt
|
||||
checkpoint repos; the FLUX.2 NVFP4 raw export remains
|
||||
`black-forest-labs/FLUX.2-dev-NVFP4`. Do not use older `BBuf/*` examples unless
|
||||
you are explicitly testing a historical branch.
|
||||
|
||||
## Related PR Watchlist
|
||||
|
||||
These related SGLang PRs are useful as ModelOpt diffusion support history.
|
||||
Re-check the PR state and the active source tree before treating any item as
|
||||
current behavior, and keep the docs/CI matrix as the support boundary.
|
||||
|
||||
- #23155 added Qwen Image ModelOpt FP8 support.
|
||||
- #23199 adds HunyuanVideo ModelOpt FP8 support.
|
||||
- #23373 adds a runtime quantization flag; keep PTQ/export workflows separate from runtime quant examples until the CLI behavior is merged.
|
||||
- #24024 adds transformer FP8-cast compatibility mode.
|
||||
- #24186 re-enables B200 multimodal CI with NVFP4 fixes for FLUX.2 and Wan2.2.
|
||||
|
||||
Do not expand the validated matrix beyond the documented rows solely because a
|
||||
related PR exists. Add a row only after the exact checkpoint, loader path,
|
||||
accuracy check, and benchmark scope are validated on the active branch.
|
||||
|
||||
## Documentation Maintenance
|
||||
|
||||
- Keep the validated ModelOpt support matrix in `docs/diffusion/quantization.md`.
|
||||
- Each row should record the validated scope, the Hugging Face repo or path for the quantized DiT weights, and the key caveats.
|
||||
- If the quantized DiT weights are not published yet, write `unpublished` explicitly instead of leaving the field blank.
|
||||
|
||||
## FP8 Vs NVFP4
|
||||
|
||||
FP8 and NVFP4 are not wired into SGLang in exactly the same way.
|
||||
|
||||
FP8:
|
||||
|
||||
- the validated ModelOpt diffusers FP8 export still needs an extra SGLang-side conversion step
|
||||
- SGLang expects explicit `weight_scale` and `input_scale`
|
||||
- the validated path also materializes SGLang-native `float8_e4m3fn` weights from `backbone.pt`
|
||||
|
||||
NVFP4:
|
||||
|
||||
- the official diffusers export often already contains packed FP4 weights, scale tensors, and enough safetensors metadata for SGLang to rebuild the quant config
|
||||
- in that case SGLang mainly needs to detect the checkpoint family and rearrange tensors into the runtime layout
|
||||
- this is why NVFP4 often does not need an extra offline conversion pass like FP8 does
|
||||
- backend choice matters on B200; record whether the run used the default CUTLASS path or a cuDNN-backed FlashInfer FP4 GEMM path
|
||||
|
||||
Important caveat:
|
||||
|
||||
- "often" does not mean "always"
|
||||
- the exact load path still depends on the checkpoint family, especially whether a model uses a packed-QKV layout
|
||||
|
||||
## Generic Workflow
|
||||
|
||||
### 1. Verify The BF16 Baseline First
|
||||
|
||||
Before quantizing anything:
|
||||
|
||||
- run the original BF16 model in SGLang
|
||||
- fix the prompt, seed, size, step count, and GPU topology
|
||||
- save the output and `perf.json`
|
||||
|
||||
Do not start quantization work until the BF16 path is already healthy.
|
||||
|
||||
### 2. Quantize With Official ModelOpt
|
||||
|
||||
Use ModelOpt's official script. Generic template:
|
||||
|
||||
```bash
|
||||
python quantize.py \
|
||||
--model <model-name> \
|
||||
--override-model-path <hf-repo-or-local-model> \
|
||||
--model-dtype <Half|BFloat16> \
|
||||
--format <fp8|fp4> \
|
||||
--batch-size 1 \
|
||||
--calib-size <calib-size> \
|
||||
--n-steps <calib-steps> \
|
||||
--quantize-mha \
|
||||
--prompts-file <prompt-file> \
|
||||
--quantized-torch-ckpt-save-path <out>/ckpt \
|
||||
--hf-ckpt-dir <out>/hf
|
||||
```
|
||||
|
||||
For current ModelOpt diffusion examples, use `--format fp4` for NVFP4 exports.
|
||||
Do not assume the checked-out ModelOpt version accepts a literal `nvfp4` format string unless you verified it locally.
|
||||
|
||||
For multi-transformer models:
|
||||
|
||||
- quantize each backbone deliberately
|
||||
- keep each output directory separate
|
||||
- save both `backbone.pt` and the matching `hf/<component>` export
|
||||
|
||||
### 3. Convert FP8 Exports For SGLang
|
||||
|
||||
FP8 requires an extra conversion step:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 -m sglang.multimodal_gen.tools.build_modelopt_fp8_transformer \
|
||||
--modelopt-hf-dir <out>/hf \
|
||||
--modelopt-backbone-ckpt <out>/ckpt/backbone.pt \
|
||||
--base-transformer-dir <base-model-transformer-dir> \
|
||||
--output-dir <out>/sglang_transformer \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
What the converter does:
|
||||
|
||||
- reads `weight_quantizer._amax` and `input_quantizer._amax` from `backbone.pt`
|
||||
- writes `weight_scale` and `input_scale`
|
||||
- materializes eligible FP8 weights as `float8_e4m3fn`
|
||||
- preserves ModelOpt `ignore` layers as BF16
|
||||
- strips stale `_quantizer.*` tensors and fallback-layer scales that should not survive into the SGLang-native checkpoint
|
||||
|
||||
For `FLUX.1-dev`, the validated fallback set currently keeps these modules in BF16:
|
||||
|
||||
- `transformer_blocks.*.norm1.linear`
|
||||
- `transformer_blocks.*.norm1_context.linear`
|
||||
- `transformer_blocks.*.ff.net.0.proj`
|
||||
- `transformer_blocks.*.ff.net.2`
|
||||
- `transformer_blocks.*.ff_context.net.0.proj`
|
||||
- `transformer_blocks.*.ff_context.net.2`
|
||||
- `single_transformer_blocks.*.norm.linear`
|
||||
- `single_transformer_blocks.*.proj_mlp`
|
||||
|
||||
Use `--model-type flux1` to force that profile, or rely on `--model-type auto` when the export config identifies `FluxTransformer2DModel`.
|
||||
|
||||
HunyuanVideo uses `HunyuanVideoTransformer3DModel`, so the validated
|
||||
HunyuanVideo FP8 fallback preset keeps these modules in BF16:
|
||||
|
||||
- `context_embedder.*`
|
||||
- `x_embedder.proj`
|
||||
- `time_text_embed.(timestep_embedder|guidance_embedder|text_embedder).linear_[12]`
|
||||
- `norm_out.linear`
|
||||
- `proj_out`
|
||||
- `transformer_blocks.*.norm1.linear`
|
||||
- `transformer_blocks.*.norm1_context.linear`
|
||||
- `single_transformer_blocks.*.norm.linear`
|
||||
|
||||
Use `--model-type hunyuan-video` to force that profile, or rely on
|
||||
`--model-type auto` when the export config identifies
|
||||
`HunyuanVideoTransformer3DModel`.
|
||||
|
||||
HunyuanVideo ModelOpt exports use diffusers module names that differ from
|
||||
SGLang runtime names for fused QKV and fused QKV+MLP layers. Keep the
|
||||
diffusers-to-runtime mapping in `build_modelopt_fp8_transformer.py` in sync
|
||||
with `runtime/models/dits/hunyuanvideo.py` before trusting converted scale
|
||||
tensors.
|
||||
|
||||
Qwen Image and Qwen Image Edit share `QwenImageTransformer2DModel`, so one
|
||||
ModelOpt FP8 fallback preset covers both. The validated Qwen Image fallback set
|
||||
keeps these modules in BF16:
|
||||
|
||||
- `img_in`
|
||||
- `txt_in`
|
||||
- `time_text_embed.timestep_embedder.linear_1`
|
||||
- `time_text_embed.timestep_embedder.linear_2`
|
||||
- `norm_out.linear`
|
||||
- `proj_out`
|
||||
- `transformer_blocks.*.img_mlp.net.2`
|
||||
- `transformer_blocks.*.img_mod`
|
||||
- `transformer_blocks.*.txt_mod`
|
||||
|
||||
Use `--model-type qwen-image` to force that profile, or rely on
|
||||
`--model-type auto` when the export config identifies
|
||||
`QwenImageTransformer2DModel`.
|
||||
|
||||
Qwen modulation weights can appear in safetensors as `.img_mod.1.weight` and
|
||||
`.txt_mod.1.weight`. Canonicalize those module names to `.img_mod` and
|
||||
`.txt_mod` before fallback matching.
|
||||
|
||||
For Qwen Image FP8, explicit BF16 fallback tensors must be written before
|
||||
honoring ModelOpt ignored weights. Otherwise converter stats can report a
|
||||
fallback while the output checkpoint still retains the source FP8 tensor, which
|
||||
causes severe image-quality regressions.
|
||||
|
||||
For FLUX.1-dev NVFP4 model families that need a mixed BF16+NVFP4 checkpoint, build the merged transformer explicitly:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 -m sglang.multimodal_gen.tools.build_modelopt_nvfp4_transformer \
|
||||
--base-transformer-dir <base-model-transformer-dir> \
|
||||
--modelopt-hf-dir <out>/hf/transformer \
|
||||
--output-dir <out>/transformer-mixed \
|
||||
--pattern-preset flux1-nvfp4
|
||||
```
|
||||
|
||||
The validated FLUX.1-dev mixed builder also needs to preserve:
|
||||
|
||||
- `quant_type: NVFP4` in `config.json`
|
||||
- `swap_weight_nibbles: false` for the validated diffusers export
|
||||
|
||||
### 4. Load The Quantized Checkpoint In SGLang
|
||||
|
||||
Single-transformer example:
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--model-path <base-model> \
|
||||
--transformer-path <quantized-transformer> \
|
||||
--prompt "<prompt>" \
|
||||
--seed <seed> \
|
||||
--save-output
|
||||
```
|
||||
|
||||
Multi-transformer example:
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--model-path <base-model> \
|
||||
--transformer-path <quantized-transformer> \
|
||||
--transformer-2-path <another-transformer-or-bf16-override> \
|
||||
--prompt "<prompt>" \
|
||||
--seed <seed> \
|
||||
--save-output
|
||||
```
|
||||
|
||||
Guideline:
|
||||
|
||||
- use the global `--transformer-path` only when the model effectively has one transformer override to apply
|
||||
- use per-component overrides when different backbones need different checkpoints
|
||||
- the preferred CLI form is `--<component>-path`
|
||||
- config-expanded forms such as `--component_paths.transformer_2=...` also resolve to the same internal override map
|
||||
|
||||
### 5. Validate Accuracy
|
||||
|
||||
Use two levels of validation.
|
||||
|
||||
Reduced deterministic validation:
|
||||
|
||||
- keep prompt, seed, resolution, and step count fixed
|
||||
- compare BF16 and quantized runs
|
||||
- capture denoising trajectories
|
||||
- inspect per-step latent cosine similarity plus MAE or RMSE
|
||||
- compare final frames with image metrics such as PSNR or MAE
|
||||
|
||||
Tool:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=python python3 -m sglang.multimodal_gen.tools.compare_diffusion_trajectory_similarity \
|
||||
--model-path <base-model> \
|
||||
--model-id <optional-native-model-id> \
|
||||
--prompt "<prompt>" \
|
||||
--width <w> \
|
||||
--height <h> \
|
||||
--num-inference-steps <steps> \
|
||||
--guidance-scale <cfg> \
|
||||
--seed <seed> \
|
||||
--candidate-transformer-path <quantized-transformer> \
|
||||
--output-json <report.json>
|
||||
```
|
||||
|
||||
Use `--model-id FLUX.1-dev` when `--model-path` points to a local directory but the runtime still needs the native FLUX.1 model registration.
|
||||
|
||||
Full-output validation:
|
||||
|
||||
- run the same user-facing generation config in BF16 and quantized mode
|
||||
- inspect the output visually
|
||||
- only claim "quality preserved" for the exact scope you actually checked
|
||||
|
||||
### 6. Benchmark Correctly
|
||||
|
||||
Benchmark only when these match between BF16 and quantized:
|
||||
|
||||
- prompt
|
||||
- seed
|
||||
- width and height
|
||||
- frame count
|
||||
- inference step count
|
||||
- GPU count and topology
|
||||
- offload flags
|
||||
- compile settings
|
||||
- profiler settings
|
||||
|
||||
Only the quantized checkpoint path should differ.
|
||||
|
||||
Interpretation rule:
|
||||
|
||||
- the primary expected gain is in denoising
|
||||
- text-encoding and VAE differences are secondary and should not be over-attributed unless they were quantized too
|
||||
|
||||
### 7. Add Model-Specific Fallbacks Only When Needed
|
||||
|
||||
If the generic FP8 path fails on a new model family:
|
||||
|
||||
- inspect which modules are numerically sensitive or loader-incompatible
|
||||
- keep fallback patterns small and explicit
|
||||
- isolate them in the converter instead of scattering ad-hoc exceptions
|
||||
- re-run deterministic trajectory checks after every fallback change
|
||||
|
||||
Do not turn one validated model quirk into a generic rule unless another family also needs it.
|
||||
|
||||
## FP8 Offload Constraint
|
||||
|
||||
Current diffusion ModelOpt FP8 support requires:
|
||||
|
||||
- `dit_cpu_offload=false`
|
||||
- `dit_layerwise_offload` may be enabled when you want lower DiT residency
|
||||
|
||||
Reason:
|
||||
|
||||
- the FP8 linear path depends on a CUTLASS-compatible weight layout after loading
|
||||
- `dit_cpu_offload` is still treated conservatively
|
||||
- the fixed layerwise offload path now preserves non-contiguous tensor strides instead of flattening and rebuilding FP8 weights into a contiguous layout
|
||||
|
||||
Runtime behavior:
|
||||
|
||||
- SGLang still force-disables `dit_cpu_offload` when it detects `modelopt_fp8`
|
||||
- benchmark commands should still pin offload flags explicitly so the command line itself makes the comparison rule obvious
|
||||
|
||||
## Claim Discipline
|
||||
|
||||
When documenting results:
|
||||
|
||||
- claim only scopes that were actually validated end to end
|
||||
- do not collapse "single-transformer FP8 override" into "full-model FP8"
|
||||
- do not call a practical deployment comparison a benchmark if BF16 and quantized commands used different offload behavior
|
||||
|
||||
## Current Code Areas
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `runtime/layers/quantization/__init__.py` | registers diffusion quant methods |
|
||||
| `runtime/layers/quantization/modelopt_quant.py` | ModelOpt FP8 and NVFP4 runtime loading |
|
||||
| `runtime/utils/quantization_utils.py` | resolves flat ModelOpt configs and reconstructs NVFP4 config from metadata |
|
||||
| `runtime/loader/transformer_load_utils.py` | guards incompatible FP8 offload modes |
|
||||
| `runtime/models/dits/flux_2.py` | packed-QKV handling for the packed FLUX.2 NVFP4 family |
|
||||
| `tools/build_modelopt_fp8_transformer.py` | Build an SGLang-loadable FP8 transformer from a ModelOpt export |
|
||||
| `tools/build_modelopt_nvfp4_transformer.py` | Build mixed BF16+NVFP4 transformer directories when a family needs preserved BF16 layers |
|
||||
| `tools/compare_diffusion_trajectory_similarity.py` | reduced deterministic BF16-vs-quantized validation |
|
||||
| `docs/diffusion/quantization.md` | public ModelOpt support matrix and CLI examples |
|
||||
| `test/server/testcase_configs.py` | reusable ModelOpt testcase constants, thresholds, and helpers |
|
||||
| `test/server/gpu_cases.py` | concrete GPU and B200 ModelOpt CI case lists |
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
name: sglang-diffusion-performance
|
||||
description: Use when choosing the fastest SGLang Diffusion flags for a model, GPU, and VRAM budget.
|
||||
---
|
||||
|
||||
# SGLang Diffusion Performance Tuning
|
||||
|
||||
Use this skill when the user wants the fastest command line, lower VRAM, or the right performance flags for a specific model and GPU setup.
|
||||
|
||||
Before running any `sglang generate` command below inside the diffusion container:
|
||||
- use `python/sglang/multimodal_gen/.claude/skills/sglang-diffusion-benchmark-profile/scripts/diffusion_skill_env.py` to derive the repo root, verify write access, and choose idle GPU(s)
|
||||
- export `HF_TOKEN` first when the selected model lives in a gated Hugging Face repo such as `black-forest-labs/FLUX.*`
|
||||
- export `FLASHINFER_DISABLE_VERSION_CHECK=1`
|
||||
- `cd` to the repo root resolved from `sglang.__file__`
|
||||
|
||||
## Native Backend Gate
|
||||
|
||||
Performance numbers are useful only when the intended backend actually ran.
|
||||
|
||||
- Treat any log containing `Falling back to diffusers backend`, `Using diffusers backend`, or `Loaded diffusers pipeline` as invalid for native SGLang performance tuning.
|
||||
- Use `--backend diffusers` only for an explicit diffusers baseline. For native recipes, leave the default backend or pin `--backend sglang`.
|
||||
- If a fallback happened, fix pipeline registration/model-path/config issues first, then rerun. Do not compare perf dumps collected from a fallback run.
|
||||
- When the runtime auto-selects parallel settings because the user omitted them, keep the result as an auto-tuned baseline. For reproducible tuning, pin `--num-gpus`, `--ulysses-degree`, `--ring-degree`, and `--enable-cfg-parallel` explicitly.
|
||||
|
||||
Reference: [SGLang-Diffusion Advanced Optimizations Blog](https://lmsys.org/blog/2026-02-16-sglang-diffusion-advanced-optimizations/)
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Lossless Optimizations
|
||||
|
||||
These options are intended to preserve output quality. In practice, some paths (most notably `torch.compile`) can still introduce small floating-point drift, so validate on your target model when numerical parity matters.
|
||||
|
||||
| Option | CLI Flag / Env Var | What It Does | Speedup | Limitations / Notes |
|
||||
|---|---|---|---|---|
|
||||
| **torch.compile** | `--enable-torch-compile` | Applies `torch.compile` to the DiT forward pass, fusing ops and reducing kernel launch overhead. | ~1.2–1.5x on denoising | First request is slow (compilation). May cause minor precision drifts due to [PyTorch issue #145213](https://github.com/pytorch/pytorch/issues/145213). Pair with `--warmup` for best results. |
|
||||
| **Warmup** | `--warmup` | Runs dummy forward passes to warm up CUDA caches, JIT, and `torch.compile`. Eliminates cold-start penalty. | Removes first-request latency spike | Adds startup time. Without `--warmup-resolutions`, warmup happens on first request. |
|
||||
| **Warmup Resolutions** | `--warmup-resolutions 256x256 720x720` | Pre-compiles and warms up specific resolutions at server startup (instead of lazily on first request). | Faster first request per resolution | Each resolution adds to startup time. Serving mode only; useful when you know your target resolutions in advance. |
|
||||
| **Multi-GPU (SP)** | `--num-gpus N --ulysses-degree N` | Sequence parallelism across GPUs. Shards sequence tokens (not frames) to minimize padding. | Near-linear scaling with N GPUs | Requires NCCL; inter-GPU bandwidth matters. `ulysses_degree * ring_degree = sp_degree`. For Wan2.2 video, start by benchmarking pure Ulysses before assuming a mixed Ulysses/Ring layout is fastest. |
|
||||
| **CFG Parallel** | `--enable-cfg-parallel` | Runs conditional and unconditional CFG branches in parallel across GPUs. For CFG models on multi-GPU, benchmark this against pure Ulysses on your topology instead of assuming one always wins. | Often faster than pure SP for CFG models | Requires `num_gpus >= 2`. Halves the Ulysses group size (e.g. 8 GPU → two 4-GPU groups). Only for models that use CFG. Nightly coverage configs may intentionally use smaller Ulysses groups to keep ring behavior exercised; that does not automatically make them the lowest-latency choice. |
|
||||
| **Layerwise Offload** | `--dit-layerwise-offload` | Async layer-by-layer H2D prefetch with compute overlap. Only ~2 DiT layers reside on GPU at a time, dramatically reducing VRAM. For some video models the copy stream can be almost fully hidden behind compute. | Saves VRAM (40 GB → ~11 GB for Wan A14B); can be near-zero speed cost on the right workload | Enabled by default for Wan/MOVA video models. Incompatible with Cache-DiT. For **image models** or highly parallelized setups (many GPUs, small per-GPU compute), the copy stream may not be fully hidden and can cause slowdown. |
|
||||
| **Offload Prefetch Size** | `--dit-offload-prefetch-size F` | Fine-grained control over layerwise offload: how many layers to prefetch ahead. `0.0` = 1 layer (min VRAM), `0.1` = 10% of layers, `≥1` = absolute layer count. | Tune for cases where default offload has copy stream interference (e.g. image models). 0.05–0.1 is a good starting point. | Values ≥ 0.5 approach no-offload VRAM with worse performance. Use lower values when copy overlap is weak; disable offload when memory allows and latency dominates. |
|
||||
| **FSDP Inference** | `--use-fsdp-inference` | Uses PyTorch FSDP to shard model weights across GPUs with prefetch. Low latency, low VRAM. | Reduces per-GPU VRAM | Mutually exclusive with `--dit-layerwise-offload`. More overhead than SP on high-bandwidth interconnects. |
|
||||
| **CPU Offload (components)** | `--text-encoder-cpu-offload`, `--image-encoder-cpu-offload`, `--vae-cpu-offload`, `--dit-cpu-offload` | Offloads specific pipeline components to CPU when not in use. | Reduces peak VRAM | Adds H2D transfer latency when the component is needed. Auto-enabled for low-VRAM GPUs (<30 GB). **Tip:** after the first request completes, the console prints a peak VRAM analysis with suggestions on which offload flags can be safely disabled — look for the `"Components that could stay resident"` log line. |
|
||||
| **Pin CPU Memory** | `--pin-cpu-memory` | Uses pinned (page-locked) memory for CPU offload transfers. | Faster H2D transfers | Slightly higher host memory usage. Enabled by default; disable only as workaround for CUDA errors. |
|
||||
| **Attention Backend (lossless)** | `--attention-backend fa` | Selects a lossless attention kernel for SGLang-native pipelines: `fa` (FlashAttention 2/3/4 alias) or `torch_sdpa`. | FA is usually faster than SDPA on long sequences | FA requires compatible GPU (Ampere+). For `--backend diffusers`, valid backend names differ; use the names documented in `docs/diffusion/performance/attention_backends.md`. |
|
||||
| **Parallel Folding** | *(automatic when SP > 1)* | Reuses the SP process group as TP for the T5 text encoder, so text encoding is parallelized "for free". | Faster text encoding on multi-GPU | Automatic; no user action needed. Only applies to T5-based pipelines. |
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Lossy Optimizations
|
||||
|
||||
These options **trade output quality** for speed or VRAM savings. Results will differ from the baseline.
|
||||
|
||||
| Option | CLI Flag / Env Var | What It Does | Speedup | Quality Impact / Limitations |
|
||||
|---|---|---|---|---|
|
||||
| **Approximate Attention** | `--attention-backend sage_attn` / `sage_attn_3` / `sliding_tile_attn` / `video_sparse_attn` / `sparse_video_gen_2_attn` / `vmoba_attn` / `sla_attn` / `sage_sla_attn` | Replaces exact attention with approximate or sparse variants. `sage_attn`: INT8/FP8 quantized Q·K; `sliding_tile_attn`: spatial-temporal tile skipping; others: model-specific sparse patterns. | ~1.5–2x on attention (varies by backend) | Quality degradation varies by backend and model. `sage_attn` is the most general; sparse backends (`sliding_tile_attn`, `video_sparse_attn`, etc.) are video-model-specific and may require config files (e.g. `--mask-strategy-file-path` for STA). Requires corresponding packages installed. |
|
||||
| **Cache-DiT** | Native: `SGLANG_CACHE_DIT_ENABLED=true` plus `SGLANG_CACHE_DIT_*` env vars. Diffusers backend: `--backend diffusers --cache-dit-config <yaml-or-json>` | Caches intermediate residuals across denoising steps and skips redundant computations via DBCache, TaylorSeer, and optional SCM. | ~1.5-2x on supported models | Quality depends on cache policy. Incompatible with `--dit-layerwise-offload`. Do not pass `--cache-dit-config` for native SGLang tuning unless you are intentionally using the diffusers backend flow. |
|
||||
| **Quantized Models (Nunchaku / SVDQuant)** | `--enable-svdquant --transformer-weights-path <path>` + optional `--quantization-precision int4\|nvfp4`, `--quantization-rank 32` | W4A4-style quantization via [Nunchaku](https://nunchaku.tech). Reduces DiT weight memory by ~4x. Precision/rank can be auto-inferred from weight filename or set explicitly. | ~1.5–2x compute speedup | Lossy quantization; quality depends on rank and precision. Requires pre-quantized weights. Ampere (SM8x) or SM12x only (no Hopper SM90). Higher rank = better quality but more memory. |
|
||||
| **Pre-quantized Transformer Override** | `--transformer-path <dir-or-repo>` / `--transformer-weights-path <path>` | Load a quantized transformer component or raw transformer weights. For converted ModelOpt FP8/NVFP4 directories, prefer `--transformer-path`; use `--transformer-weights-path` for weight-only artifacts the model loader expects. | ~1.3–1.5x compute (dtype dependent) | Requires a validated quantized transformer override, such as one produced by the ModelOpt helper tools. Quality is usually slightly worse than BF16 and depends on the format, fallback layers, and calibration scope. |
|
||||
| **Component Precision Override** | `--dit-precision fp16`, `--vae-precision fp16\|bf16` | On-the-fly dtype conversion for individual components. E.g. convert a BF16 model to FP16 at load time, or run VAE in BF16 instead of FP32. | Reduces memory; FP16 can be faster on some GPUs | May affect numerical stability. VAE is FP32 by default for accuracy; lowering it is lossy. DiT defaults to BF16. |
|
||||
| **Fewer Inference Steps** | `--num-inference-steps N` (sampling param) | Reduces the number of denoising steps. Fewer steps = faster. | Linear speedup | Quality degrades with too few steps. Model-dependent optimal range. |
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
### Maximum speed, video model, multi-GPU, lossless (Wan A14B, 8 GPUs)
|
||||
|
||||
```bash
|
||||
sglang generate --model-path Wan-AI/Wan2.2-T2V-A14B-Diffusers \
|
||||
--num-gpus 8 --enable-cfg-parallel --ulysses-degree 4 \
|
||||
--enable-torch-compile --warmup \
|
||||
--text-encoder-cpu-offload true \
|
||||
--prompt "..." --save-output
|
||||
```
|
||||
|
||||
Note: `--dit-layerwise-offload` is enabled by default for Wan/MOVA video models and is often a good default, but still benchmark it on your exact workload if latency matters.
|
||||
|
||||
For Wan2.2 specifically:
|
||||
- the nightly-aligned 4-GPU benchmark may use `--enable-cfg-parallel --ulysses-degree=2` to keep CFG and ring behavior covered
|
||||
- that is a **coverage** choice, not a guaranteed best-performance choice
|
||||
- for pure latency tuning, benchmark pure Ulysses too, for example `--ulysses-degree=4 --ring-degree=1` on 4 GPUs
|
||||
- on 8 GPUs, compare pure `--ulysses-degree=8` against `--enable-cfg-parallel --ulysses-degree=4`
|
||||
|
||||
### Nightly-aligned model, 2 GPUs: LTX-2 two-stage
|
||||
|
||||
```bash
|
||||
sglang generate --model-path Lightricks/LTX-2 \
|
||||
--pipeline-class-name LTX2TwoStagePipeline \
|
||||
--prompt "A cat and a dog baking a cake together in a kitchen." \
|
||||
--width 768 --height 512 \
|
||||
--num-frames 121 \
|
||||
--seed 42 --num-gpus 2 --enable-cfg-parallel \
|
||||
--enable-torch-compile --warmup --save-output
|
||||
```
|
||||
|
||||
Note: this generate recipe is aligned with the nightly comparison case `ltx2_twostage_t2v`. The nightly config omits explicit steps and guidance, so this command omits them too and uses runtime defaults. `LTX2TwoStagePipeline` is a native path and auto-resolves the spatial upsampler plus distilled LoRA from the same model snapshot unless you override them.
|
||||
|
||||
### Nightly-aligned model, 2 GPUs: LTX-2.3 TI2V two-stage
|
||||
|
||||
```bash
|
||||
sglang generate --model-path Lightricks/LTX-2.3 \
|
||||
--pipeline-class-name LTX2TwoStagePipeline \
|
||||
--prompt "The cat starts walking slowly towards the camera." \
|
||||
--image-path "${ASSET_DIR}/cat.png" \
|
||||
--width 768 --height 512 \
|
||||
--num-frames 121 \
|
||||
--seed 42 --num-gpus 2 --cfg-parallel-size 2 \
|
||||
--enable-torch-compile --warmup --save-output
|
||||
```
|
||||
|
||||
Note: this matches the nightly comparison case `ltx2.3_twostage_ti2v_2gpus`. The nightly config omits explicit steps and guidance, so this command omits them too and uses runtime defaults. Download `${ASSET_DIR}/cat.png` with the benchmark/profile skill before running it.
|
||||
|
||||
### Native baseline, 2 GPUs: LTX-2.3 one-stage
|
||||
|
||||
```bash
|
||||
sglang generate --model-path Lightricks/LTX-2.3 \
|
||||
--prompt "A beautiful sunset over the ocean" \
|
||||
--negative-prompt "shaky, glitchy, low quality, worst quality, deformed, distorted, disfigured, motion smear, motion artifacts, fused fingers, bad anatomy, weird hand, ugly, transition, static." \
|
||||
--width 768 --height 512 \
|
||||
--num-frames 121 --fps 24 \
|
||||
--num-inference-steps 30 --guidance-scale 3.0 \
|
||||
--seed 1234 --num-gpus 2 \
|
||||
--enable-torch-compile --warmup --save-output
|
||||
```
|
||||
|
||||
Note: use this as the native `LTX2Pipeline` baseline for `LTX-2.3`. It keeps the validated one-stage resolution and explicit `LTX-2.3` sampling defaults, and matches the `ltx23-one-stage` benchmark preset in `sglang-diffusion-benchmark-profile`.
|
||||
|
||||
### Skill-only stress target, 2 GPUs: LTX-2.3 two-stage high resolution
|
||||
|
||||
```bash
|
||||
sglang generate --model-path Lightricks/LTX-2.3 \
|
||||
--pipeline-class-name LTX2TwoStagePipeline \
|
||||
--prompt "A beautiful sunset over the ocean" \
|
||||
--negative-prompt "shaky, glitchy, low quality, worst quality, deformed, distorted, disfigured, motion smear, motion artifacts, fused fingers, bad anatomy, weird hand, ugly, transition, static." \
|
||||
--width 1536 --height 1024 \
|
||||
--num-frames 121 --fps 24 \
|
||||
--num-inference-steps 30 --guidance-scale 3.0 \
|
||||
--seed 1234 --num-gpus 2 \
|
||||
--enable-torch-compile --warmup --save-output
|
||||
```
|
||||
|
||||
Note: this is a high-resolution stress target for the native `LTX-2.3` two-stage path. It matches the skill-only `ltx23-two-stage` benchmark preset, not a nightly comparison case.
|
||||
|
||||
### Maximum speed, image model, single GPU, lossless
|
||||
|
||||
```bash
|
||||
sglang generate --model-path <IMAGE_MODEL> \
|
||||
--enable-torch-compile --warmup \
|
||||
--dit-layerwise-offload false \
|
||||
--dit-cpu-offload false \
|
||||
--prompt "..." --save-output
|
||||
```
|
||||
|
||||
Note: for image models, per-layer compute is smaller, so layerwise offload may not fully hide H2D transfer. Disable DiT layerwise and CPU offload if VRAM allows; otherwise a large image DiT can stay resident on CPU and make the denoise loop H2D-bound.
|
||||
|
||||
### Image-edit baselines: JoyAI and FireRed
|
||||
|
||||
```bash
|
||||
sglang generate --backend=sglang \
|
||||
--model-path jdopensource/JoyAI-Image-Edit-Diffusers \
|
||||
--prompt "Make the cat wear a red hat" \
|
||||
--image-path "${ASSET_DIR}/cat.png" \
|
||||
--width 1024 --height 1024 \
|
||||
--num-inference-steps 40 --guidance-scale 4.0 \
|
||||
--num-gpus 2 --enable-cfg-parallel --ulysses-degree 1 \
|
||||
--dit-layerwise-offload false --dit-cpu-offload false \
|
||||
--enable-torch-compile --warmup --save-output
|
||||
```
|
||||
|
||||
```bash
|
||||
sglang generate --backend=sglang \
|
||||
--model-path FireRedTeam/FireRed-Image-Edit-1.1 \
|
||||
--prompt "Make the cat wear a red hat" \
|
||||
--image-path "${ASSET_DIR}/cat.png" \
|
||||
--width 1024 --height 1024 \
|
||||
--num-inference-steps 40 --guidance-scale 4.0 \
|
||||
--num-gpus 2 --enable-cfg-parallel --ulysses-degree 1 \
|
||||
--dit-layerwise-offload false --dit-cpu-offload false \
|
||||
--enable-torch-compile --warmup --save-output
|
||||
```
|
||||
|
||||
Use `FireRedTeam/FireRed-Image-Edit-1.0` in the same command when comparing
|
||||
FireRed 1.0. These are native image-edit paths; keep the reference image, prompt,
|
||||
seed, and output size fixed when comparing denoise numbers. On H100, 2-GPU CFG
|
||||
parallel was faster than the otherwise matching 2-GPU Ulysses command: FireRed
|
||||
1.0 improved from 13419.15 ms to 10955.90 ms, and FireRed 1.1 improved from
|
||||
13414.72 ms to 10934.21 ms.
|
||||
|
||||
### Hunyuan3D shape baseline
|
||||
|
||||
```bash
|
||||
OUTPUT_DIR=$(python3 "$ENV_PY" print-output-dir --kind benchmarks --mkdir)
|
||||
CONFIG_DIR="${OUTPUT_DIR}/generated_configs"
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
printf '{"paint_enable": false}\n' > "${CONFIG_DIR}/hunyuan3d-shape.json"
|
||||
|
||||
sglang generate --backend=sglang \
|
||||
--model-path tencent/Hunyuan3D-2 \
|
||||
--prompt "generate 3d mesh" \
|
||||
--image-path "${ASSET_DIR}/cat.png" \
|
||||
--config "${CONFIG_DIR}/hunyuan3d-shape.json" \
|
||||
--num-inference-steps 50 --guidance-scale 5.0 \
|
||||
--dit-layerwise-offload false --dit-cpu-offload false \
|
||||
--enable-torch-compile --warmup --save-output
|
||||
```
|
||||
|
||||
For Hunyuan3D, treat `Hunyuan3DShapeDenoisingStage` as the primary latency
|
||||
metric. Mesh export and paint stages are useful end-to-end checks but should not
|
||||
drive DiT optimization decisions.
|
||||
|
||||
### Low VRAM, decent speed (single GPU)
|
||||
|
||||
```bash
|
||||
sglang generate --model-path <MODEL> \
|
||||
--enable-torch-compile --warmup \
|
||||
--dit-layerwise-offload --dit-offload-prefetch-size 0.1 \
|
||||
--text-encoder-cpu-offload true --vae-cpu-offload true \
|
||||
--prompt "..." --save-output
|
||||
```
|
||||
|
||||
### Maximum speed, lossy native path (SageAttention + Cache-DiT)
|
||||
|
||||
```bash
|
||||
SGLANG_CACHE_DIT_ENABLED=true sglang generate --model-path <MODEL> \
|
||||
--attention-backend sage_attn \
|
||||
--dit-layerwise-offload false \
|
||||
--enable-torch-compile --warmup \
|
||||
--prompt "..." --save-output
|
||||
```
|
||||
|
||||
Add native Cache-DiT knobs such as `SGLANG_CACHE_DIT_SCM_PRESET=medium`,
|
||||
`SGLANG_CACHE_DIT_RDT=0.24`, or `SGLANG_CACHE_DIT_TAYLORSEER=true` only after
|
||||
you have a BF16 baseline output to compare against.
|
||||
|
||||
For a diffusers-backend Cache-DiT YAML/JSON config baseline, make the fallback
|
||||
explicit:
|
||||
|
||||
```bash
|
||||
sglang generate --backend diffusers --model-path <MODEL> \
|
||||
--cache-dit-config <config.yaml> \
|
||||
--dit-layerwise-offload false \
|
||||
--prompt "..." --save-output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model-Specific Starting Points
|
||||
|
||||
Use these as first commands to benchmark, not as universal winners.
|
||||
|
||||
| Model family | First performance shape | Starting flags | Notes |
|
||||
|---|---|---|---|
|
||||
| FLUX.1 / FLUX.2 image | 1024x1024, runtime-default steps/guidance, 1 GPU | `--enable-torch-compile --warmup --dit-layerwise-offload false` | `black-forest-labs/FLUX.*` repos are gated; for FP8/NVFP4 use validated `--transformer-path` or `--transformer-weights-path` flows from the quant skill. |
|
||||
| FLUX.2 Klein / Klein Base | 1024x1024, runtime-default steps/guidance, 1 GPU | `--enable-torch-compile --warmup --dit-layerwise-offload false` | Current registry has `black-forest-labs/FLUX.2-klein-4B`, `FLUX.2-klein-9B`, and base variants. Klein is step-distilled; Klein Base is not. |
|
||||
| Qwen-Image / Qwen-Image-Edit | 1024x1024, runtime-default steps/guidance, 1 GPU | `--enable-torch-compile --warmup`; optionally native `SGLANG_CACHE_DIT_ENABLED=true` | Cache-DiT is lossy. For edit tasks, keep reference image, seed, and output size fixed. |
|
||||
| Z-Image / Z-Image-Turbo | 1024x1024, runtime-default steps/guidance, 1 GPU | `--enable-torch-compile --warmup` | Keep base Z-Image separate from Turbo: base uses 50-step CFG defaults, Turbo uses 9-step zero-CFG defaults. Mainline has Z-Image tanh/gate norm fusions. |
|
||||
| Wan2.2 A14B T2V/I2V | 1280x720, 81 frames | Nightly: `--num-gpus 4 --enable-cfg-parallel --ulysses-degree 2 --text-encoder-cpu-offload --pin-cpu-memory` | For lowest latency, also benchmark pure Ulysses on the same GPUs. |
|
||||
| Wan2.2 TI2V 5B | 1280x720, 81 frames, 1 GPU | `--enable-torch-compile --warmup` | Keep the input image and motion prompt fixed when comparing sparse attention or Cache-DiT. |
|
||||
| Wan2.1 / FastWan / TurboWan variants | 480p or 720p video, family defaults | `--enable-torch-compile --warmup`; add `--ulysses-degree` / CFG parallel only after measuring | Current registry includes Wan2.1, FastWan2.1, FastWan2.2 TI2V, TurboWan2.1, TurboWan2.2 I2V, and Wan2.1-Fun InP. Use the compatibility matrix and benchmark presets before choosing topology. |
|
||||
| Cosmos3 Nano / Super | T2I: 1024x1024 with `--num-frames 1`; T2V/I2V: 480p/720p video | `SGLANG_DISABLE_COSMOS3_GUARDRAILS=1` for benchmark isolation; `--enable-torch-compile --warmup` | One checkpoint serves T2I/T2V/I2V. Mode is request-driven: `num_frames == 1` means T2I, `--image-path` means I2V. |
|
||||
| Ideogram 4 FP8/NVFP4 | 1024x1024, native preset defaults | `--enable-torch-compile --warmup` | Do not set `--num-inference-steps` or `--guidance-scale` directly unless you also update the Ideogram preset; sampling params derive them from `preset`. |
|
||||
| ERNIE-Image / GLM-Image / SANA / SD3 | 1024-class image, family defaults | `--enable-torch-compile --warmup`; disable offload only after checking VRAM | Treat these as current native image families. Start with benchmark/profile presets for ERNIE, GLM, and SANA; use registry/config defaults for SD3 unless you add a new preset. |
|
||||
| LTX-2 / LTX-2.3 | 768x512 or HQ 1920x1088, 121 frames | `--pipeline-class-name LTX2TwoStagePipeline --enable-torch-compile --warmup`; HQ uses `LTX2TwoStageHQPipeline` | Use benchmark/profile presets for nightly alignment, one-stage, high-resolution stress, and HQ. Device mode choices are `original` and `resident`; `resident` is fastest but uses more VRAM. `snapshot` is a deprecated alias for `original`, so do not use it in new commands. |
|
||||
| HunyuanVideo | 848x480 or 720p class video | `--text-encoder-cpu-offload --pin-cpu-memory --enable-torch-compile --warmup` | Check VAE decode separately. GroupNorm+SiLU is default-eligible in mainline when wrapper guards pass; use `bench_group_norm_silu.py` when VAE residual blocks are hot. |
|
||||
| JoyAI-Image-Edit | 1024-class TI2I, 40 steps, guidance 4.0 | `--backend=sglang --num-gpus 2 --enable-cfg-parallel --ulysses-degree 1 --enable-torch-compile --warmup --dit-layerwise-offload false --dit-cpu-offload false` | Newly supported image-edit path. Keep the input image, prompt, seed, and output size fixed; 2-GPU CFG parallel is the validated H100 starting point. |
|
||||
| FireRed-Image-Edit 1.0 / 1.1 | 1024x1024 image edit, 40 steps, guidance 4.0 | `--backend=sglang --num-gpus 2 --enable-cfg-parallel --ulysses-degree 1 --enable-torch-compile --warmup --dit-layerwise-offload false --dit-cpu-offload false` | Uses the native `QwenImageEditPlusPipeline` path. 2-GPU CFG parallel is the validated H100 starting point; benchmark 1.0 and 1.1 separately because checkpoint differences can change denoise latency. |
|
||||
| Hunyuan3D-2 shape | Shape generation, 50 steps, guidance 5.0 | `--backend=sglang --enable-torch-compile --warmup --dit-layerwise-offload false --dit-cpu-offload false` | Focus on `Hunyuan3DShapeDenoisingStage`; keep mesh export/paint timings separate from denoise. |
|
||||
| MOVA / Helios / LingBot World | Use the benchmark/profile presets or server test cases first | `--enable-torch-compile --warmup`; pin offload and topology flags explicitly | These video/realtime families have model-specific stages and condition handling. Keep prompt/image/action inputs fixed and prefer perf dumps over wall time alone. |
|
||||
|
||||
## Historical PR Watchlist
|
||||
|
||||
Treat these performance PRs as direction and prior art only. Re-check the PR
|
||||
state and the active source tree before relying on any path, flag, or claim
|
||||
about whether the work has merged:
|
||||
|
||||
- Fusion/kernel: #24025 LTX2 QK norm, #24059 Helios norm modulation, #24117 Z-Image packed QKV, #19488 Wan elementwise cross-block fusion, #19249 Z-Image gate/norm fusion, #20429 Qwen-Image layernorm/modulation, #20530 MOVA RMSNorm+RoPE.
|
||||
- VAE/decode: #22531 LTX2 parallel VAE, #20927 batched tiled VAE decode.
|
||||
- Runtime/parallel/cache: #22805 FLUX.2 packed QKV for A2A, #21742 hybrid attention schedule, #24053 USP replicated-prefix fix, #21613 TeaCache refactor, #24227 WanVideo TeaCache fix, #18764 dynamic batching, #24200 disaggregated diffusion.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Benchmarking**: always use `--warmup` and look for the line ending with `(with warmup excluded)` for accurate timing.
|
||||
- **Perf dump**: use `--perf-dump-path result.json` to save structured metrics, then compare with `python python/sglang/multimodal_gen/benchmarks/compare_perf.py baseline.json result.json`.
|
||||
- **Offload tuning**: after the first request, the runtime logs peak GPU memory and which components could stay resident. Use this to decide which `--*-cpu-offload` flags to disable.
|
||||
- **Backend selection**: `--backend sglang` (default, auto-detected) enables native optimizations (fused kernels, SP, native Cache-DiT env knobs, etc.). `--backend diffusers` falls back to Diffusers pipelines and is the path that accepts `--cache-dit-config` plus diffusers attention backend names.
|
||||
- **Wan2.2-I2V sizing**: explicit `--width/--height` on `Wan2.2-I2V-A14B` control the target area while preserving the condition-image aspect ratio.
|
||||
- **Mainline diffusion fast paths**: before proposing a new kernel or overlap scheme, check `sglang-diffusion-benchmark-profile/existing-fast-paths.md`. It covers GroupNorm+SiLU, Z-Image residual-form modulation, fused diffusion `QK norm + RoPE`, LTX2 split RoPE, LTX2 residual-gate add, varlen USP pack/scatter, packed QKV/NVFP4 expectations, and existing multi-GPU overlap families such as Ulysses / USP and turbo-layer async all-to-all.
|
||||
- **NVFP4 trace interpretation**: on FLUX.2 NVFP4 and Nunchaku-style checkpoints, packed QKV is expected. SGLang intentionally uses fused projection modules such as `to_qkv` / `to_added_qkv` instead of separate `to_q` / `to_k` / `to_v`, so a split-QKV trace usually means the quantized path did not engage rather than a brand new fusion opportunity.
|
||||
- **Hotspot workflow split**: use `sglang-diffusion-benchmark-profile` to prove and classify a slowdown with perf dumps plus `torch.profiler`; hand concrete kernel work off with the perf/profile evidence attached instead of expanding the benchmark skill.
|
||||
@@ -0,0 +1,105 @@
|
||||
<div align="center" style="display:block; margin:auto;">
|
||||
<img src=https://github.com/lm-sys/lm-sys.github.io/releases/download/test/sgl-diffusion-logo.png width="80%"/>
|
||||
</div>
|
||||
|
||||
**SGLang diffusion is an inference framework for accelerated image/video generation.**
|
||||
|
||||
SGLang diffusion features an end-to-end unified pipeline for accelerating diffusion models. It is designed to be modular and extensible, allowing users to easily add new models and optimizations.
|
||||
|
||||
## Key Features
|
||||
|
||||
SGLang Diffusion has the following features:
|
||||
- Broad model support: Wan, FastWan, FLUX, Qwen-Image, Z-Image, Ideogram 4, Krea-2, Cosmos3, LTX-2/LTX-2.3, LingBot World, SANA-WM, JoyEcho, MOVA, GLM-Image, ERNIE-Image, Hunyuan3D, and more
|
||||
- Fast inference speed: empowered by optimized `sgl-kernel` kernels, scheduler/runtime improvements, caching acceleration, and native diffusion hot-path optimizations
|
||||
- Ease of use: OpenAI-compatible api, CLI, and python sdk support
|
||||
- Multi-platform support:
|
||||
- NVIDIA GPUs (H100, H200, A100, B200, 4090, 5090)
|
||||
- AMD GPUs (MI300X, MI325X, MI355X)
|
||||
- Intel XPUs
|
||||
- Ascend NPU (A2, A3)
|
||||
- Apple Silicon (M-series via MPS)
|
||||
- Moore Threads GPUs (MTT S5000)
|
||||
|
||||
### AMD/ROCm Support
|
||||
|
||||
SGLang Diffusion supports AMD Instinct GPUs through ROCm. On AMD platforms, we use the Triton attention backend and leverage AITER kernels for optimized layernorm and other operations. See the [installation guide](https://docs.sglang.io/docs/sglang-diffusion/installation) for setup instructions.
|
||||
|
||||
### Moore Threads/MUSA Support
|
||||
|
||||
SGLang Diffusion supports Moore Threads GPUs (MTGPU) through the MUSA software stack. On MUSA platforms, we use FlashAttention (FA3) when available; also supports Sage Attention when installed; otherwise falls back to the Torch SDPA backend. See the [installation guide](https://docs.sglang.io/docs/sglang-diffusion/installation) for setup instructions.
|
||||
|
||||
### Apple MPS Support
|
||||
|
||||
SGLang Diffusion supports Apple Silicon (M-series) via the MPS backend. Since Triton is Linux-only, all Triton kernels are replaced with PyTorch-native fallbacks on MPS. Norm operations can be optionally accelerated with MLX fused Metal kernels (`SGLANG_USE_MLX=1`). See the [installation guide](https://docs.sglang.io/docs/sglang-diffusion/installation) for setup instructions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
uv pip install 'sglang[diffusion]' --prerelease=allow
|
||||
```
|
||||
|
||||
For more installation methods (e.g. pypi, uv, docker, ROCm/AMD, MUSA/Moore Threads), check the [installation guide](https://docs.sglang.io/docs/sglang-diffusion/installation).
|
||||
|
||||
## Inference
|
||||
|
||||
Here's a minimal example to generate a video using the default settings:
|
||||
|
||||
```python
|
||||
from sglang.multimodal_gen import DiffGenerator
|
||||
|
||||
def main():
|
||||
# Create a diff generator from a pre-trained model
|
||||
generator = DiffGenerator.from_pretrained(
|
||||
model_path="Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
||||
num_gpus=1, # Adjust based on your hardware
|
||||
)
|
||||
|
||||
# Generate the video
|
||||
video = generator.generate(
|
||||
sampling_params_kwargs=dict(
|
||||
prompt="A curious raccoon peers through a vibrant field of yellow sunflowers, its eyes wide with interest.",
|
||||
return_frames=True, # Also return frames from this call (defaults to False)
|
||||
output_path="my_videos/", # Controls where videos are saved
|
||||
save_output=True
|
||||
)
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
Or, more simply, with the CLI:
|
||||
|
||||
```bash
|
||||
sglang generate --model-path Wan-AI/Wan2.1-T2V-1.3B-Diffusers \
|
||||
--text-encoder-cpu-offload --pin-cpu-memory \
|
||||
--prompt "A curious raccoon" \
|
||||
--save-output
|
||||
```
|
||||
|
||||
### LoRA support
|
||||
|
||||
Apply LoRA adapters via `--lora-path`:
|
||||
|
||||
```bash
|
||||
sglang generate \
|
||||
--model-path Qwen/Qwen-Image-Edit-2511 \
|
||||
--lora-path prithivMLmods/Qwen-Image-Edit-2511-Anime \
|
||||
--prompt "Transform into anime." \
|
||||
--image-path "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png" \
|
||||
--save-output
|
||||
```
|
||||
|
||||
For more usage examples (e.g. OpenAI compatible API, server mode), check the [CLI reference](https://docs.sglang.io/docs/sglang-diffusion/api/cli).
|
||||
|
||||
## Contributing
|
||||
|
||||
All contributions are welcome. The contribution guide is available [here](https://docs.sglang.io/docs/sglang-diffusion/contributing).
|
||||
|
||||
## Acknowledgement
|
||||
|
||||
We learnt and reused code from the following projects:
|
||||
|
||||
- [FastVideo](https://github.com/hao-ai-lab/FastVideo.git). The major components of this repo are based on a fork of FastVideo on Sept. 24, 2025.
|
||||
- [xDiT](https://github.com/xdit-project/xDiT). We used the parallelism library from it.
|
||||
- [diffusers](https://github.com/huggingface/diffusers) We used the pipeline design from it.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
from sglang.multimodal_gen.configs.pipeline_configs import PipelineConfig
|
||||
from sglang.multimodal_gen.configs.sample import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
|
||||
__all__ = ["DiffGenerator", "PipelineConfig", "SamplingParams"]
|
||||
|
||||
# Trigger multimodal CI tests
|
||||
@@ -0,0 +1,58 @@
|
||||
# ComfyUI SGLDiffusion Plugin
|
||||
|
||||
A ComfyUI plugin for integrating with SGLang Diffusion server, supporting image and video generation capabilities.
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Install SGLang**: Follow the [Installation Guide](../../../../../docs/diffusion/installation.md) to install `sglang[diffusion]`.
|
||||
2. **Install Plugin**: Copy this entire directory (`ComfyUI_SGLDiffusion`) to your ComfyUI `custom_nodes/` folder.
|
||||
3. **Restart ComfyUI**: Restart ComfyUI to load the plugin.
|
||||
|
||||
## Usage
|
||||
|
||||
The plugin supports two modes of operation: **Server Mode** (via HTTP API) and **Integrated Mode** (tight integration with ComfyUI).
|
||||
|
||||
### Supported Models
|
||||
- **Z-Image**: High-speed image generation models (e.g., `Z-Image-Turbo`)
|
||||
- **FLUX**: State-of-the-art text-to-image models (e.g., `FLUX.1-dev`)
|
||||
- **Qwen-Image**: Multi-modal image generation models (e.g., `Qwen-Image`,`Qwen-Image-2512`). *Note: Image editing support is currently experimental and may have some issues.*
|
||||
|
||||
### Mode 1: Server Mode (HTTP API)
|
||||
Connect to a standalone SGLang Diffusion server.
|
||||
|
||||
1. **Start SGLang Diffusion Server**: Ensure the server is running and accessible.
|
||||
2. **Connect to Server**: Use the `SGLDiffusion Server Model` node to connect (default: `http://localhost:3000/v1`).
|
||||
3. **Generate Content**:
|
||||
- `SGLDiffusion Generate Image`: For text-to-image and image editing.
|
||||
- `SGLDiffusion Generate Video`: For text-to-video and image-to-video.
|
||||
4. **LoRA Support**: Use `SGLDiffusion Server Set LoRA` and `SGLDiffusion Server Unset LoRA`.
|
||||
|
||||
### Mode 2: Integrated Mode (Tight Integration)
|
||||
Leverage SGLang's high-performance sampling directly within ComfyUI while using ComfyUI's front-end nodes (CLIP, VAE, etc.).
|
||||
|
||||
1. **Load Model**: Use the `SGLDiffusion UNET Loader` node to load your diffusion model.
|
||||
2. **Configure Options**: Use the `SGLDiffusion Options` node to set runtime parameters like `num_gpus`, `tp_size`, `model_type`, or `enable_torch_compile`.
|
||||
3. **Sample**: Connect the loaded model to standard ComfyUI samplers. SGLang will handle the sampling process efficiently.
|
||||
4. **LoRA Support**: Use the `SGLDiffusion LoRA Loader` for native LoRA integration.
|
||||
|
||||
## Example Workflows
|
||||
|
||||
Reference workflow files are provided in the `workflows/` directory:
|
||||
|
||||
- **`flux_sgld_sp.json`**: Multi-GPU (Sequence Parallelism) workflow for FLUX models. High-performance inference across multiple cards.
|
||||
- **`qwen_image_sgld.json`**: Qwen-Image generation with LoRA support. Optimized for multi-modal image tasks.
|
||||
- **`z-image_sgld.json`**: High-speed image generation using Z-Image.
|
||||
- **`sgld_text2img.json`**: Server-mode text-to-image generation with LoRA support.
|
||||
- **`sgld_image2video.json`**: Server-mode image-to-video generation.
|
||||
|
||||
For other workflows supporting the models, you can easily use SGLang by replacing the official `UNET Loader` node with the `SGLDUNETLoader` node. Similarly, for LoRA support, replace the official LoRA loader with the `SGLDiffusion LoRA Loader`.
|
||||
|
||||
To use these workflows:
|
||||
1. Open ComfyUI.
|
||||
2. Load the workflow JSON file from the `workflows/` directory.
|
||||
3. Adjust the parameters and model paths as needed.
|
||||
4. Run the workflow.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
This plugin provides a high-performance backend for diffusion models in ComfyUI. By leveraging SGLang's optimized kernels and parallelization techniques (Tensor Parallelism, TeaCache, etc.), it significantly accelerates the sampling process, especially for large models like FLUX.
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
ComfyUI SGLang Diffusion nodes package.
|
||||
"""
|
||||
|
||||
try:
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
except ImportError:
|
||||
# ComfyUI dependencies not available (e.g., in test environment)
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Core components for SGLang Diffusion ComfyUI integration.
|
||||
Provides generator, model patcher, and server API client.
|
||||
"""
|
||||
|
||||
from .generator import SGLDiffusionGenerator
|
||||
from .model_patcher import SGLDModelPatcher
|
||||
from .server_api import SGLDiffusionServerAPI
|
||||
|
||||
__all__ = [
|
||||
"SGLDiffusionGenerator",
|
||||
"SGLDModelPatcher",
|
||||
"SGLDiffusionServerAPI",
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Generator for SGLang Diffusion ComfyUI integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import psutil
|
||||
from comfy import model_detection, model_management
|
||||
from comfy.utils import (
|
||||
calculate_parameters,
|
||||
load_torch_file,
|
||||
state_dict_prefix_replace,
|
||||
unet_to_diffusers,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from sglang.multimodal_gen import DiffGenerator
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"Error: sglang.multimodal_gen is not installed. Please install it using 'pip install sglang[diffusion]'"
|
||||
)
|
||||
|
||||
from ..executors import (
|
||||
FluxExecutor,
|
||||
QwenImageEditExecutor,
|
||||
QwenImageExecutor,
|
||||
ZImageExecutor,
|
||||
)
|
||||
from .model_patcher import SGLDModelPatcher
|
||||
|
||||
|
||||
class SGLDiffusionGenerator:
|
||||
"""Generator for SGLang Diffusion models in ComfyUI."""
|
||||
|
||||
def __init__(self):
|
||||
self.model_path = None
|
||||
self.generator = None
|
||||
self.executor = None
|
||||
self.last_options = None
|
||||
|
||||
self.pipeline_class_dict = {
|
||||
"flux": "ComfyUIFluxPipeline",
|
||||
"lumina2": "ComfyUIZImagePipeline", # zimage
|
||||
"qwen_image": "ComfyUIQwenImagePipeline",
|
||||
"qwen_image_edit": "ComfyUIQwenImageEditPipeline",
|
||||
}
|
||||
self.executor_class_dict = {
|
||||
"flux": FluxExecutor,
|
||||
"lumina2": ZImageExecutor,
|
||||
"qwen_image": QwenImageExecutor,
|
||||
"qwen_image_edit": QwenImageEditExecutor,
|
||||
}
|
||||
|
||||
def __del__(self):
|
||||
self.close_generator()
|
||||
|
||||
def init_generator(
|
||||
self, model_path: str, pipeline_class_name: str, kwargs: dict = None
|
||||
):
|
||||
"""Initialize the diffusion generator."""
|
||||
if self.generator is not None:
|
||||
return self.generator
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
# Set comfyui_mode for ComfyUI integration
|
||||
kwargs["comfyui_mode"] = True
|
||||
self.generator = DiffGenerator.from_pretrained(
|
||||
model_path=model_path,
|
||||
pipeline_class_name=pipeline_class_name,
|
||||
**kwargs,
|
||||
)
|
||||
return self.generator
|
||||
|
||||
def kill_generator(self):
|
||||
"""Kill worker processes manually because generator shutdown cannot terminate them."""
|
||||
current_pid = os.getpid()
|
||||
worker_processes = []
|
||||
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
|
||||
try:
|
||||
# Look for sglang-diffusionWorker processes
|
||||
if proc.info["cmdline"]:
|
||||
cmdline = " ".join(proc.info["cmdline"])
|
||||
if "sgl_diffusion::" in cmdline:
|
||||
if proc.info["pid"] != current_pid:
|
||||
worker_processes.append(proc)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
if worker_processes:
|
||||
logger.info(
|
||||
f"Found {len(worker_processes)} worker processes to terminate..."
|
||||
)
|
||||
for proc in worker_processes:
|
||||
try:
|
||||
logger.info(
|
||||
f"Terminating worker process {proc.info['pid']}: {proc.info['name']}"
|
||||
)
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except psutil.TimeoutExpired:
|
||||
logger.warning(
|
||||
f"Process {proc.info['pid']} did not terminate, forcing kill..."
|
||||
)
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
except (psutil.NoSuchProcess, psutil.TimeoutExpired):
|
||||
pass
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
def close_generator(self):
|
||||
"""Close and cleanup the generator and all associated resources."""
|
||||
if self.generator is not None:
|
||||
self.generator.shutdown()
|
||||
self.kill_generator()
|
||||
# Clear other references
|
||||
self.last_options = None
|
||||
self.model_path = None
|
||||
self.generator = None
|
||||
self.executor = None
|
||||
|
||||
def get_comfyui_model(self, model_path: str, model_options: dict = None):
|
||||
"""Get ComfyUI model from model path."""
|
||||
if model_options is None:
|
||||
model_options = {}
|
||||
dtype = model_options.get("dtype", None)
|
||||
# Allow loading unets from checkpoint files
|
||||
sd = load_torch_file(model_path)
|
||||
diffusion_model_prefix = model_detection.unet_prefix_from_state_dict(sd)
|
||||
temp_sd = state_dict_prefix_replace(
|
||||
sd, {diffusion_model_prefix: ""}, filter_keys=True
|
||||
)
|
||||
if len(temp_sd) > 0:
|
||||
sd = temp_sd
|
||||
|
||||
parameters = calculate_parameters(sd)
|
||||
load_device = model_management.get_torch_device()
|
||||
|
||||
model_detect_config = model_detection.detect_unet_config(sd, "")
|
||||
model_type = model_detect_config.get("image_model", None)
|
||||
if model_type is None or model_type not in self.pipeline_class_dict:
|
||||
raise ValueError(f"Unsupported model type: {model_type}")
|
||||
model_config = model_detection.model_config_from_unet(sd, "")
|
||||
|
||||
if model_config is not None:
|
||||
new_sd = sd
|
||||
else:
|
||||
new_sd = model_detection.convert_diffusers_mmdit(sd, "")
|
||||
if new_sd is not None: # diffusers mmdit
|
||||
model_config = model_detection.model_config_from_unet(new_sd, "")
|
||||
if model_config is None:
|
||||
return None
|
||||
else: # diffusers unet
|
||||
model_config = model_detection.model_config_from_diffusers_unet(sd)
|
||||
if model_config is None:
|
||||
return None
|
||||
|
||||
diffusers_keys = unet_to_diffusers(model_config.unet_config)
|
||||
new_sd = {}
|
||||
for k in diffusers_keys:
|
||||
if k in sd:
|
||||
new_sd[diffusers_keys[k]] = sd.pop(k)
|
||||
if dtype is None:
|
||||
unet_dtype = model_management.unet_dtype(
|
||||
model_params=parameters,
|
||||
supported_dtypes=model_config.supported_inference_dtypes,
|
||||
)
|
||||
else:
|
||||
unet_dtype = dtype
|
||||
|
||||
manual_cast_dtype = model_management.unet_manual_cast(
|
||||
unet_dtype, load_device, model_config.supported_inference_dtypes
|
||||
)
|
||||
model_config.set_inference_dtype(unet_dtype, manual_cast_dtype)
|
||||
model_config.custom_operations = model_options.get("custom_operations", None)
|
||||
model_config.unet_config["disable_unet_model_creation"] = True
|
||||
comfyui_model = model_config.get_model({})
|
||||
return comfyui_model, model_config, model_type
|
||||
|
||||
def load_model(
|
||||
self, model_path: str, model_options: dict = None, sgld_options: dict = None
|
||||
):
|
||||
"""Load model and return model patcher."""
|
||||
gather_options = {
|
||||
"model_path": model_path,
|
||||
"model_options": model_options,
|
||||
"sgld_options": sgld_options,
|
||||
}
|
||||
if (
|
||||
self.last_options is not None
|
||||
and self.last_options == gather_options
|
||||
and self.generator is not None
|
||||
):
|
||||
return self.generator
|
||||
else:
|
||||
self.close_generator()
|
||||
|
||||
self.last_options = gather_options
|
||||
self.model_path = model_path
|
||||
|
||||
comfyui_model, model_config, model_type = self.get_comfyui_model(
|
||||
model_path, model_options
|
||||
)
|
||||
if model_type is None or model_type not in self.pipeline_class_dict:
|
||||
raise ValueError(f"Unsupported model type: {model_type}")
|
||||
|
||||
set_model_type = sgld_options.pop("model_type", None) if sgld_options else None
|
||||
if set_model_type is not None and set_model_type in self.pipeline_class_dict:
|
||||
model_type = set_model_type
|
||||
|
||||
pipeline_class_name = self.pipeline_class_dict[model_type]
|
||||
self.generator = self.init_generator(
|
||||
model_path, pipeline_class_name, sgld_options
|
||||
)
|
||||
|
||||
executor_class = self.executor_class_dict[model_type]
|
||||
self.executor = executor_class(
|
||||
self.generator, model_path, comfyui_model, model_config
|
||||
)
|
||||
comfyui_model.diffusion_model = self.executor
|
||||
|
||||
load_device = model_management.get_torch_device()
|
||||
offload_device = model_management.unet_offload_device()
|
||||
|
||||
return SGLDModelPatcher(
|
||||
comfyui_model, load_device, offload_device, model_type=model_type
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Model patcher for SGLang Diffusion ComfyUI integration.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
from comfy.model_patcher import ModelPatcher
|
||||
|
||||
|
||||
class SGLDModelPatcher(ModelPatcher):
|
||||
"""Model patcher for SGLang Diffusion models in ComfyUI."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model,
|
||||
load_device,
|
||||
offload_device,
|
||||
size=0,
|
||||
weight_inplace_update=False,
|
||||
model_type=None,
|
||||
):
|
||||
super().__init__(
|
||||
model, load_device, offload_device, size, weight_inplace_update
|
||||
)
|
||||
self.lora_cache = {}
|
||||
self.model_type = model_type
|
||||
self.model_size_dict = {
|
||||
"flux": 27 * 1024 * 1024 * 1024,
|
||||
"lumina2": 8 * 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
def clone(self):
|
||||
"""Clone the model patcher."""
|
||||
n = SGLDModelPatcher(
|
||||
self.model,
|
||||
self.load_device,
|
||||
self.offload_device,
|
||||
self.size,
|
||||
weight_inplace_update=self.weight_inplace_update,
|
||||
)
|
||||
n.patches = {}
|
||||
for k in self.patches:
|
||||
n.patches[k] = self.patches[k][:]
|
||||
n.patches_uuid = self.patches_uuid
|
||||
|
||||
n.object_patches = self.object_patches.copy()
|
||||
n.model_options = copy.deepcopy(self.model_options)
|
||||
n.backup = self.backup
|
||||
n.object_patches_backup = self.object_patches_backup
|
||||
n.lora_cache = copy.copy(self.lora_cache)
|
||||
return n
|
||||
|
||||
def model_size(self):
|
||||
"""Get the model size in bytes."""
|
||||
if self.model_type in self.model_size_dict:
|
||||
return self.model_size_dict[self.model_type]
|
||||
else:
|
||||
return 0
|
||||
|
||||
def load(
|
||||
self,
|
||||
device_to=None,
|
||||
lowvram_model_memory=0,
|
||||
force_patch_weights=False,
|
||||
full_load=False,
|
||||
):
|
||||
"""Load model (no-op for SGLang Diffusion)."""
|
||||
pass
|
||||
|
||||
def patch_model(
|
||||
self,
|
||||
device_to=None,
|
||||
lowvram_model_memory=0,
|
||||
load_weights=True,
|
||||
force_patch_weights=False,
|
||||
):
|
||||
"""Patch model (no-op for SGLang Diffusion)."""
|
||||
pass
|
||||
|
||||
def unpatch_model(self, device_to=None, unpatch_weights=True):
|
||||
"""Unpatch model (no-op for SGLang Diffusion)."""
|
||||
pass
|
||||
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
SGLang Diffusion Server API client.
|
||||
Provides a low-level interface for interacting with SGLang Diffusion HTTP server.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class SGLDiffusionServerAPI:
|
||||
"""Client for SGLang Diffusion HTTP server API."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str = "sk-proj-1234567890"):
|
||||
"""
|
||||
Initialize the API client.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the SGLang Diffusion server (e.g., "http://localhost:30010/v1")
|
||||
api_key: API key for authentication (default: "sk-proj-1234567890")
|
||||
"""
|
||||
# Ensure base_url doesn't end with /v1 if it's already there
|
||||
if base_url.endswith("/v1"):
|
||||
self.base_url = base_url
|
||||
elif base_url.endswith("/v1/"):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
else:
|
||||
self.base_url = f"{base_url.rstrip('/')}/v1"
|
||||
|
||||
self.api_key = api_key
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
|
||||
def get_model_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the model served by this server.
|
||||
|
||||
Returns:
|
||||
Dictionary containing model information including:
|
||||
- model_path: Path to the model
|
||||
- task_type: Type of task (e.g., "T2V", "I2I")
|
||||
- pipeline_name: Name of the pipeline
|
||||
- num_gpus: Number of GPUs
|
||||
- dit_precision: DiT model precision
|
||||
- vae_precision: VAE model precision
|
||||
"""
|
||||
try:
|
||||
# Remove /v1 from base_url for /models endpoint
|
||||
models_url = self.base_url.removesuffix("/v1") + "/models"
|
||||
response = requests.get(models_url, headers=self.headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Failed to get model info: {str(e)}")
|
||||
|
||||
def generate_image(
|
||||
self,
|
||||
prompt: str,
|
||||
image_path: Optional[str] = None,
|
||||
mask_path: Optional[str] = None,
|
||||
size: Optional[str] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
n: int = 1,
|
||||
negative_prompt: Optional[str] = None,
|
||||
guidance_scale: Optional[float] = None,
|
||||
num_inference_steps: Optional[int] = None,
|
||||
seed: Optional[int] = None,
|
||||
enable_teacache: bool = False,
|
||||
response_format: str = "b64_json",
|
||||
quality: Optional[str] = "auto",
|
||||
style: Optional[str] = "vivid",
|
||||
background: Optional[str] = "auto",
|
||||
output_format: Optional[str] = None,
|
||||
generator_device: Optional[str] = "cuda",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate or edit an image using SGLang Diffusion API.
|
||||
If image_path is provided, calls the edit endpoint; otherwise calls the generation endpoint.
|
||||
|
||||
Args:
|
||||
prompt: Text prompt for image generation/editing
|
||||
image_path: Optional path to input image file for editing. If provided, uses edit API.
|
||||
mask_path: Optional path to mask image file (only used when image_path is provided)
|
||||
size: Image size in format "WIDTHxHEIGHT" (e.g., "1024x1024")
|
||||
width: Image width (used if size is not provided)
|
||||
height: Image height (used if size is not provided)
|
||||
n: Number of images to generate (1-10)
|
||||
negative_prompt: Negative prompt to avoid certain elements
|
||||
guidance_scale: Classifier-free guidance scale
|
||||
num_inference_steps: Number of denoising steps
|
||||
seed: Random seed for reproducible generation
|
||||
enable_teacache: Enable TEA cache acceleration
|
||||
response_format: Response format ("b64_json" or "url")
|
||||
quality: Image quality ("auto", "standard", "hd") - only for generation
|
||||
style: Image style ("vivid" or "natural") - only for generation
|
||||
background: Background type ("auto", "transparent", "opaque")
|
||||
output_format: Output format ("png", "jpeg", "webp")
|
||||
generator_device: Device for random generator ("cuda" or "cpu")
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response with generated/edited image data
|
||||
"""
|
||||
if not prompt:
|
||||
raise ValueError("Prompt cannot be empty")
|
||||
|
||||
# Determine size
|
||||
if size is None:
|
||||
if width is not None and height is not None:
|
||||
size = f"{width}x{height}"
|
||||
else:
|
||||
size = "1024x1024"
|
||||
|
||||
# Build common parameters
|
||||
common_params = self._build_image_common_params(
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
n=n,
|
||||
response_format=response_format,
|
||||
negative_prompt=negative_prompt,
|
||||
guidance_scale=guidance_scale,
|
||||
num_inference_steps=num_inference_steps,
|
||||
seed=seed,
|
||||
enable_teacache=enable_teacache,
|
||||
background=background,
|
||||
output_format=output_format,
|
||||
generator_device=generator_device,
|
||||
)
|
||||
|
||||
# If image_path is provided, use edit endpoint
|
||||
if image_path:
|
||||
if not os.path.exists(image_path):
|
||||
raise FileNotFoundError(f"Image file not found: {image_path}")
|
||||
|
||||
# Prepare multipart form data for edit
|
||||
files: Dict[str, Any] = {}
|
||||
data = common_params.copy()
|
||||
|
||||
# Add image file
|
||||
files["image"] = (
|
||||
os.path.basename(image_path),
|
||||
open(image_path, "rb"),
|
||||
self._get_content_type(image_path),
|
||||
)
|
||||
|
||||
# Add mask file if provided
|
||||
if mask_path:
|
||||
if not os.path.exists(mask_path):
|
||||
raise FileNotFoundError(f"Mask file not found: {mask_path}")
|
||||
files["mask"] = (
|
||||
os.path.basename(mask_path),
|
||||
open(mask_path, "rb"),
|
||||
self._get_content_type(mask_path),
|
||||
)
|
||||
|
||||
# Prepare headers for multipart form data
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/images/edits",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=300, # 5 minutes timeout for generation
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Failed to edit image: {str(e)}")
|
||||
finally:
|
||||
# Close file handles
|
||||
for file_tuple in files.values():
|
||||
if isinstance(file_tuple, tuple) and len(file_tuple) > 1:
|
||||
file_tuple[1].close()
|
||||
else:
|
||||
# Use generation endpoint - add generation-specific parameters
|
||||
payload = common_params.copy()
|
||||
if quality:
|
||||
payload["quality"] = quality
|
||||
if style:
|
||||
payload["style"] = style
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/images/generations",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=300, # 5 minutes timeout for generation
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Failed to generate image: {str(e)}")
|
||||
|
||||
def generate_video(
|
||||
self,
|
||||
prompt: str,
|
||||
size: Optional[str] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
seconds: Optional[int] = 4,
|
||||
fps: Optional[int] = None,
|
||||
num_frames: Optional[int] = None,
|
||||
negative_prompt: Optional[str] = None,
|
||||
guidance_scale: Optional[float] = None,
|
||||
num_inference_steps: Optional[int] = None,
|
||||
seed: Optional[int] = None,
|
||||
enable_teacache: bool = False,
|
||||
generator_device: Optional[str] = "cuda",
|
||||
input_reference: Optional[str] = None,
|
||||
output_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a video using SGLang Diffusion API and wait for completion.
|
||||
|
||||
Args:
|
||||
prompt: Text prompt for video generation
|
||||
size: Video size in format "WIDTHxHEIGHT" (e.g., "1280x720")
|
||||
width: Video width (used if size is not provided)
|
||||
height: Video height (used if size is not provided)
|
||||
seconds: Duration of the video in seconds
|
||||
fps: Frames per second
|
||||
num_frames: Number of frames (overrides seconds * fps if provided)
|
||||
negative_prompt: Negative prompt to avoid certain elements
|
||||
guidance_scale: Classifier-free guidance scale
|
||||
num_inference_steps: Number of denoising steps
|
||||
seed: Random seed for reproducible generation
|
||||
enable_teacache: Enable TEA cache acceleration
|
||||
generator_device: Device for random generator ("cuda" or "cpu")
|
||||
input_reference: Path to input reference image for image-to-video
|
||||
|
||||
Returns:
|
||||
Dictionary containing completed video job information with file_path
|
||||
"""
|
||||
if not prompt:
|
||||
raise ValueError("Prompt cannot be empty")
|
||||
|
||||
# Determine size
|
||||
if size is None:
|
||||
if width is not None and height is not None:
|
||||
size = f"{width}x{height}"
|
||||
else:
|
||||
size = "720x1280"
|
||||
|
||||
# Prepare request payload
|
||||
payload: Dict[str, Any] = {
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if seconds is not None:
|
||||
payload["seconds"] = seconds
|
||||
if fps is not None:
|
||||
payload["fps"] = fps
|
||||
if num_frames is not None:
|
||||
payload["num_frames"] = num_frames
|
||||
if negative_prompt:
|
||||
payload["negative_prompt"] = negative_prompt
|
||||
if guidance_scale is not None:
|
||||
payload["guidance_scale"] = guidance_scale
|
||||
if num_inference_steps is not None:
|
||||
payload["num_inference_steps"] = num_inference_steps
|
||||
if seed is not None and seed >= 0:
|
||||
payload["seed"] = seed
|
||||
if enable_teacache:
|
||||
payload["enable_teacache"] = True
|
||||
if generator_device:
|
||||
payload["generator_device"] = generator_device
|
||||
if input_reference:
|
||||
payload["input_reference"] = input_reference
|
||||
if output_path:
|
||||
payload["output_path"] = output_path
|
||||
|
||||
try:
|
||||
# Create video generation job
|
||||
response = requests.post(
|
||||
f"{self.base_url}/videos",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
video_job = response.json()
|
||||
video_id = video_job.get("id")
|
||||
|
||||
# Wait for completion with fixed polling
|
||||
poll_interval = 5 # 5 seconds
|
||||
max_wait_time = 3600 # 1 hour
|
||||
max_consecutive_errors = 5
|
||||
consecutive_errors = 0
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
try:
|
||||
status_response = requests.get(
|
||||
f"{self.base_url}/videos/{video_id}",
|
||||
headers=self.headers,
|
||||
timeout=30,
|
||||
)
|
||||
status_response.raise_for_status()
|
||||
status = status_response.json()
|
||||
|
||||
# Reset error counter on successful request
|
||||
consecutive_errors = 0
|
||||
|
||||
if status.get("status") == "completed":
|
||||
return status
|
||||
elif status.get("status") == "failed":
|
||||
error = status.get("error", {})
|
||||
error_msg = (
|
||||
error.get("message", "Unknown error")
|
||||
if error
|
||||
else "Unknown error"
|
||||
)
|
||||
raise RuntimeError(f"Video generation failed: {error_msg}")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
# Connection errors - likely server is down
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors >= max_consecutive_errors:
|
||||
raise RuntimeError(
|
||||
f"Lost connection to server after {consecutive_errors} consecutive errors. "
|
||||
f"Server may be unavailable: {str(e)}"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Other network errors - continue polling but track errors
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors >= max_consecutive_errors:
|
||||
raise RuntimeError(
|
||||
f"Network error after {consecutive_errors} consecutive failures: {str(e)}"
|
||||
)
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
raise TimeoutError(
|
||||
f"Video generation timed out after {max_wait_time} seconds"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Failed to generate video: {str(e)}")
|
||||
|
||||
def _build_image_common_params(
|
||||
self,
|
||||
prompt: str,
|
||||
size: str,
|
||||
n: int,
|
||||
response_format: str,
|
||||
negative_prompt: Optional[str] = None,
|
||||
guidance_scale: Optional[float] = None,
|
||||
num_inference_steps: Optional[int] = None,
|
||||
seed: Optional[int] = None,
|
||||
enable_teacache: bool = False,
|
||||
background: Optional[str] = None,
|
||||
output_format: Optional[str] = None,
|
||||
generator_device: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build common parameters for both image generation and editing.
|
||||
|
||||
Returns:
|
||||
Dictionary containing common parameters
|
||||
"""
|
||||
params: Dict[str, Any] = {
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": max(1, min(n, 10)),
|
||||
"response_format": response_format,
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if negative_prompt:
|
||||
params["negative_prompt"] = negative_prompt
|
||||
if guidance_scale is not None:
|
||||
params["guidance_scale"] = guidance_scale
|
||||
if num_inference_steps is not None:
|
||||
params["num_inference_steps"] = num_inference_steps
|
||||
if seed is not None and seed >= 0:
|
||||
params["seed"] = seed
|
||||
if enable_teacache:
|
||||
params["enable_teacache"] = True
|
||||
if background:
|
||||
params["background"] = background
|
||||
if output_format:
|
||||
params["output_format"] = output_format
|
||||
if generator_device:
|
||||
params["generator_device"] = generator_device
|
||||
|
||||
return params
|
||||
|
||||
def _get_content_type(self, file_path: str) -> str:
|
||||
"""Get content type based on file extension."""
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
content_types = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
return content_types.get(ext, "image/png")
|
||||
|
||||
def decode_image_from_response(
|
||||
self, response_data: Dict[str, Any], index: int = 0
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Decode base64 image from API response.
|
||||
|
||||
Args:
|
||||
response_data: API response dictionary
|
||||
index: Index of the image in the response (default: 0)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
if "data" not in response_data or not response_data["data"]:
|
||||
raise ValueError("No image data in response")
|
||||
|
||||
if index >= len(response_data["data"]):
|
||||
raise IndexError(f"Image index {index} out of range")
|
||||
|
||||
image_data = response_data["data"][index]
|
||||
if "b64_json" not in image_data or not image_data["b64_json"]:
|
||||
raise ValueError("No base64 image data found")
|
||||
|
||||
image_bytes = base64.b64decode(image_data["b64_json"])
|
||||
image = Image.open(io.BytesIO(image_bytes))
|
||||
|
||||
# Convert to RGB if needed
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
|
||||
return image
|
||||
|
||||
def set_lora(
|
||||
self,
|
||||
lora_nickname: str,
|
||||
lora_path: Optional[str] = None,
|
||||
target: str = "all",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Set a LoRA adapter for the specified transformer(s).
|
||||
|
||||
Args:
|
||||
lora_nickname: The nickname of the adapter (required).
|
||||
lora_path: Path to the LoRA adapter (local path or HF repo id).
|
||||
Required for the first load; optional if re-activating a cached nickname.
|
||||
target: Which transformer(s) to apply the LoRA to. One of:
|
||||
- "all": Apply to all transformers (default)
|
||||
- "transformer": Apply only to the primary transformer (high noise for Wan2.2)
|
||||
- "transformer_2": Apply only to transformer_2 (low noise for Wan2.2)
|
||||
- "critic": Apply only to the critic model
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response with status and message
|
||||
"""
|
||||
if not lora_nickname:
|
||||
raise ValueError("lora_nickname cannot be empty")
|
||||
|
||||
# Prepare request payload
|
||||
payload: Dict[str, Any] = {
|
||||
"lora_nickname": lora_nickname,
|
||||
"target": target,
|
||||
}
|
||||
|
||||
# Add optional lora_path if provided
|
||||
if lora_path:
|
||||
payload["lora_path"] = lora_path
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/set_lora",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Failed to set LoRA adapter: {str(e)}")
|
||||
|
||||
def unset_lora(
|
||||
self,
|
||||
target: str = "all",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Unset (unmerge) LoRA weights from the base model.
|
||||
|
||||
Args:
|
||||
target: same as set_lora
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response with status and message
|
||||
"""
|
||||
# Prepare request payload
|
||||
payload: Dict[str, Any] = {
|
||||
"target": target,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/unmerge_lora_weights",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Failed to unset LoRA adapter: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api = SGLDiffusionServerAPI(
|
||||
base_url="http://localhost:30010/v1", api_key="sk-proj-1234567890"
|
||||
)
|
||||
model_info = api.get_model_info()
|
||||
print(api.get_model_info())
|
||||
if model_info.get("task_type") == "T2V" or model_info.get("task_type") == "I2V":
|
||||
print(
|
||||
api.generate_video(
|
||||
prompt="A calico cat playing a piano on stage",
|
||||
num_inference_steps=1,
|
||||
size="480x480",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(
|
||||
api.generate_image(
|
||||
prompt="A calico cat playing a piano on stage", size="1024x1024"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
ComfyUI SGLang Diffusion executors package.
|
||||
Provides executor classes for different model types.
|
||||
"""
|
||||
|
||||
from .base import SGLDiffusionExecutor
|
||||
from .flux import FluxExecutor
|
||||
from .qwen_image import QwenImageEditExecutor, QwenImageExecutor
|
||||
from .zimage import ZImageExecutor
|
||||
|
||||
__all__ = [
|
||||
"SGLDiffusionExecutor",
|
||||
"FluxExecutor",
|
||||
"ZImageExecutor",
|
||||
"QwenImageExecutor",
|
||||
"QwenImageEditExecutor",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Base executor class for SGLang Diffusion ComfyUI integration.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
class SGLDiffusionExecutor(torch.nn.Module):
|
||||
"""Base executor class for SGLang Diffusion models in ComfyUI."""
|
||||
|
||||
def __init__(self, generator, model_path, model, config):
|
||||
super(SGLDiffusionExecutor, self).__init__()
|
||||
self.generator = generator
|
||||
self.model_path = model_path
|
||||
self.model = model
|
||||
self.dtype = config.unet_config["dtype"]
|
||||
self.config = config
|
||||
self.loras = []
|
||||
|
||||
@staticmethod
|
||||
def should_suppress_logs(timestep):
|
||||
"""Determine if logs should be suppressed based on timestep value."""
|
||||
if torch.is_tensor(timestep):
|
||||
return bool((timestep < 1.0).item())
|
||||
return bool(timestep < 1.0)
|
||||
|
||||
def set_lora(self, lora_nickname=None, lora_path=None, strength=None, target=None):
|
||||
"""Set LoRA adapter using SGLang Diffusion API."""
|
||||
if len(lora_nickname) > 0:
|
||||
self.generator.set_lora(
|
||||
lora_nickname=lora_nickname,
|
||||
lora_path=lora_path,
|
||||
strength=strength,
|
||||
target=target,
|
||||
)
|
||||
|
||||
def _unpack_latents(self, latents, height, width, channels):
|
||||
"""Unpack latents from packed format to standard format."""
|
||||
batch_size = latents.shape[0]
|
||||
latents = latents.view(batch_size, height // 2, width // 2, channels, 2, 2)
|
||||
latents = latents.permute(0, 3, 1, 4, 2, 5)
|
||||
latents = latents.reshape(batch_size, channels, height, width)
|
||||
|
||||
return latents
|
||||
|
||||
def _pack_latents(self, latents):
|
||||
"""Pack latents from standard format to packed format."""
|
||||
batch_size, num_channels_latents, height, width = latents.shape
|
||||
latents = latents.view(
|
||||
batch_size, num_channels_latents, height // 2, 2, width // 2, 2
|
||||
)
|
||||
latents = latents.permute(0, 2, 4, 1, 3, 5)
|
||||
latents = latents.reshape(
|
||||
batch_size, (height // 2) * (width // 2), num_channels_latents * 4
|
||||
)
|
||||
return latents
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Flux executor for SGLang Diffusion ComfyUI integration.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
try:
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
|
||||
except ImportError:
|
||||
print(
|
||||
"Error: sglang.multimodal_gen is not installed. Please install it using 'pip install sglang[diffusion]'"
|
||||
)
|
||||
|
||||
from .base import SGLDiffusionExecutor
|
||||
|
||||
|
||||
class FluxExecutor(SGLDiffusionExecutor):
|
||||
"""Executor for Flux models in ComfyUI."""
|
||||
|
||||
def __init__(self, generator, model_path, model, config):
|
||||
super().__init__(generator, model_path, model, config)
|
||||
|
||||
def forward(self, x, timestep, context, y=None, guidance=None, **kwargs):
|
||||
"""Forward pass for Flux model."""
|
||||
hidden_states = self._pack_latents(x)
|
||||
timesteps = timestep * 1000.0
|
||||
encoder_hidden_states = context
|
||||
pooled_projections = y
|
||||
guidance = guidance * 1000.0
|
||||
|
||||
B, C, H, W = x.shape
|
||||
height = H * 8
|
||||
width = W * 8
|
||||
# Create SamplingParams
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
self.model_path,
|
||||
server_args=self.generator.server_args,
|
||||
prompt=" ",
|
||||
guidance_scale=3.5, # Flux typically uses embedded_cfg_scale=3.5
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=1,
|
||||
num_inference_steps=1,
|
||||
save_output=False,
|
||||
suppress_logs=self.should_suppress_logs(timestep),
|
||||
)
|
||||
|
||||
# Prepare request (converts SamplingParams to Req)
|
||||
req = prepare_request(
|
||||
server_args=self.generator.server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
req.latents = hidden_states # Set as [B, S, D] format directly
|
||||
req.timesteps = timesteps # ComfyUI's timesteps parameter
|
||||
req.prompt_embeds = [pooled_projections, encoder_hidden_states] # [CLIP, T5]
|
||||
req.raw_latent_shape = torch.tensor(hidden_states.shape, dtype=torch.long)
|
||||
|
||||
# Set pooled_projections (required by Flux)
|
||||
req.pooled_embeds = [pooled_projections] # List format as per Req definition
|
||||
req.do_classifier_free_guidance = False
|
||||
req.generator = [
|
||||
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
|
||||
# Send request to scheduler
|
||||
output_batch = self.generator._send_to_scheduler_and_wait_for_response([req])
|
||||
noise_pred = output_batch.noise_pred
|
||||
return self._unpack_latents(noise_pred, H, W, C).to(x.device)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
QwenImage executor for SGLang Diffusion ComfyUI integration.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
try:
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
|
||||
except ImportError:
|
||||
print(
|
||||
"Error: sglang.multimodal_gen is not installed. Please install it using 'pip install sglang[diffusion]'"
|
||||
)
|
||||
|
||||
import comfy.ldm.common_dit
|
||||
|
||||
from .base import SGLDiffusionExecutor
|
||||
|
||||
|
||||
class QwenImageExecutor(SGLDiffusionExecutor):
|
||||
"""Executor for QwenImage models in ComfyUI."""
|
||||
|
||||
def __init__(self, generator, model_path, model, config):
|
||||
super().__init__(generator, model_path, model, config)
|
||||
self.patch_size = 2
|
||||
|
||||
def _pack_latents(self, x):
|
||||
"""Process hidden states for QwenImage model."""
|
||||
latents = comfy.ldm.common_dit.pad_to_patch_size(
|
||||
x, (1, self.patch_size, self.patch_size)
|
||||
)
|
||||
orig_shape = latents.shape
|
||||
latents = latents.view(
|
||||
orig_shape[0],
|
||||
orig_shape[1],
|
||||
orig_shape[-3],
|
||||
orig_shape[-2] // 2,
|
||||
2,
|
||||
orig_shape[-1] // 2,
|
||||
2,
|
||||
)
|
||||
latents = latents.permute(0, 2, 3, 5, 1, 4, 6)
|
||||
latents = latents.reshape(
|
||||
orig_shape[0],
|
||||
orig_shape[-3] * (orig_shape[-2] // 2) * (orig_shape[-1] // 2),
|
||||
orig_shape[1] * 4,
|
||||
)
|
||||
return latents, orig_shape
|
||||
|
||||
def _unpack_latents(self, latents, num_embeds, orig_shape, x):
|
||||
"""Unpack hidden states from packed format to standard format."""
|
||||
latents = latents[:, :num_embeds].view(
|
||||
orig_shape[0],
|
||||
orig_shape[-3],
|
||||
orig_shape[-2] // 2,
|
||||
orig_shape[-1] // 2,
|
||||
orig_shape[1],
|
||||
2,
|
||||
2,
|
||||
)
|
||||
latents = latents.permute(0, 4, 1, 2, 5, 3, 6)
|
||||
latents = latents.reshape(orig_shape)[:, :, :, : x.shape[-2], : x.shape[-1]]
|
||||
return latents
|
||||
|
||||
def forward(self, x, timestep, context, **kwargs):
|
||||
"""Forward pass for QwenImage model."""
|
||||
latents, orig_shape = self._pack_latents(x)
|
||||
num_embeds = latents.shape[1]
|
||||
height = orig_shape[-2] * 8
|
||||
width = orig_shape[-1] * 8
|
||||
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
self.model_path,
|
||||
server_args=self.generator.server_args,
|
||||
prompt=" ",
|
||||
guidance_scale=1.0,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=1,
|
||||
num_inference_steps=1,
|
||||
save_output=False,
|
||||
suppress_logs=self.should_suppress_logs(timestep),
|
||||
)
|
||||
|
||||
# Prepare request (converts SamplingParams to Req)
|
||||
req = prepare_request(
|
||||
server_args=self.generator.server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
# Set ComfyUI-specific inputs directly on the Req object
|
||||
req.latents = latents
|
||||
req.timesteps = timestep * 1000.0
|
||||
req.prompt_embeds = [context]
|
||||
req.raw_latent_shape = torch.tensor(latents.shape, dtype=torch.long)
|
||||
req.do_classifier_free_guidance = False
|
||||
req.generator = [
|
||||
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
|
||||
output_batch = self.generator._send_to_scheduler_and_wait_for_response([req])
|
||||
noise_pred = output_batch.noise_pred
|
||||
|
||||
return self._unpack_latents(noise_pred, num_embeds, orig_shape, x)
|
||||
|
||||
|
||||
class QwenImageEditExecutor(QwenImageExecutor):
|
||||
"""Executor for QwenImageEdit models in ComfyUI."""
|
||||
|
||||
def __init__(self, generator, model_path, model, config):
|
||||
super().__init__(generator, model_path, model, config)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x,
|
||||
timestep,
|
||||
context,
|
||||
attention_mask=None,
|
||||
ref_latents=None,
|
||||
additional_t_cond=None,
|
||||
transformer_options={},
|
||||
**kwargs,
|
||||
):
|
||||
"""Forward pass for QwenImageEdit model."""
|
||||
latents, orig_shape = self._pack_latents(x)
|
||||
num_embeds = latents.shape[1]
|
||||
height = orig_shape[-2] * 8
|
||||
width = orig_shape[-1] * 8
|
||||
|
||||
# Prepare vae_image_sizes for the condition image (ref_latents)
|
||||
vae_image_sizes = []
|
||||
pack_ref_latents = None
|
||||
|
||||
# TODO: sgld now don't support multiple condition images, so we only support one condition image for now.
|
||||
if ref_latents is not None and len(ref_latents) > 0:
|
||||
pack_ref_latents, orig_ref_shape = self._pack_latents(ref_latents[0])
|
||||
vae_image_sizes = [(orig_ref_shape[-1], orig_ref_shape[-2])]
|
||||
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
self.model_path,
|
||||
server_args=self.generator.server_args,
|
||||
prompt=" ",
|
||||
guidance_scale=1.0,
|
||||
image_path="",
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=1,
|
||||
num_inference_steps=1,
|
||||
save_output=False,
|
||||
suppress_logs=self.should_suppress_logs(timestep),
|
||||
)
|
||||
|
||||
# Prepare request (converts SamplingParams to Req)
|
||||
req = prepare_request(
|
||||
server_args=self.generator.server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
# Set ComfyUI-specific inputs directly on the Req object
|
||||
req.latents = latents
|
||||
req.image_latent = pack_ref_latents
|
||||
req.timesteps = timestep * 1000.0
|
||||
req.vae_image_sizes = vae_image_sizes
|
||||
req.prompt_embeds = [context]
|
||||
req.raw_latent_shape = torch.tensor(latents.shape, dtype=torch.long)
|
||||
req.do_classifier_free_guidance = False
|
||||
req.generator = [
|
||||
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
|
||||
output_batch = self.generator._send_to_scheduler_and_wait_for_response([req])
|
||||
noise_pred = output_batch.noise_pred
|
||||
|
||||
return self._unpack_latents(noise_pred, num_embeds, orig_shape, x)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
ZImage executor for SGLang Diffusion ComfyUI integration.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
try:
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
|
||||
except ImportError:
|
||||
print(
|
||||
"Error: sglang.multimodal_gen is not installed. Please install it using 'pip install sglang[diffusion]'"
|
||||
)
|
||||
|
||||
from .base import SGLDiffusionExecutor
|
||||
|
||||
|
||||
class ZImageExecutor(SGLDiffusionExecutor):
|
||||
"""Executor for ZImage models in ComfyUI."""
|
||||
|
||||
def __init__(self, generator, model_path, model, config):
|
||||
super().__init__(generator, model_path, model, config)
|
||||
|
||||
def forward(self, x, timesteps, context, **kwargs):
|
||||
"""Forward pass for ZImage model."""
|
||||
B, C, H, W = x.shape
|
||||
height = H * 8
|
||||
width = W * 8
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
self.model_path,
|
||||
server_args=self.generator.server_args,
|
||||
prompt=" ",
|
||||
guidance_scale=1.0,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=1, # For images
|
||||
num_inference_steps=1, # Single step for ComfyUI
|
||||
save_output=False,
|
||||
suppress_logs=self.should_suppress_logs(timesteps),
|
||||
)
|
||||
|
||||
# Prepare request (converts SamplingParams to Req)
|
||||
req = prepare_request(
|
||||
server_args=self.generator.server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
latents = x.unsqueeze(2)
|
||||
context = context.squeeze(0)
|
||||
# Set ComfyUI-specific inputs directly on the Req object
|
||||
req.latents = latents # ComfyUI's x parameter
|
||||
req.timesteps = timesteps * 1000.0 # ComfyUI's timesteps parameter
|
||||
req.prompt_embeds = [
|
||||
context
|
||||
] # ComfyUI's context parameter (must be List[Tensor])
|
||||
req.raw_latent_shape = torch.tensor(latents.shape, dtype=torch.long)
|
||||
req.do_classifier_free_guidance = False
|
||||
req.generator = [
|
||||
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
|
||||
output_batch = self.generator._send_to_scheduler_and_wait_for_response([req])
|
||||
noise_pred = output_batch.noise_pred
|
||||
|
||||
return noise_pred.permute(1, 0, 2, 3).to(x.device)
|
||||
@@ -0,0 +1,715 @@
|
||||
"""
|
||||
ComfyUI nodes for SGLang Diffusion integration.
|
||||
Provides nodes for connecting to SGLang Diffusion server and generating images/videos.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
import torch
|
||||
|
||||
from .core import SGLDiffusionGenerator, SGLDiffusionServerAPI
|
||||
from .utils import (
|
||||
convert_b64_to_tensor_image,
|
||||
convert_video_to_comfy_video,
|
||||
get_image_path,
|
||||
is_empty_image,
|
||||
)
|
||||
|
||||
|
||||
class SGLDOptions:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {
|
||||
"model_type": (
|
||||
["auto-detect", "qwen_image", "qwen_image_edit", "flux", "lumina2"],
|
||||
{"default": "auto-detect"},
|
||||
),
|
||||
"enable_torch_compile": (
|
||||
"BOOLEAN",
|
||||
{"default": False},
|
||||
),
|
||||
"num_gpus": ("INT", {"default": 1, "min": 1, "step": 1}),
|
||||
"tp_size": ("INT", {"default": -1, "min": -1, "step": 1}),
|
||||
"sp_degree": ("INT", {"default": -1, "min": -1, "step": 1}),
|
||||
"ulysses_degree": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"ring_degree": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"dp_size": ("INT", {"default": 1, "min": 1, "step": 1}),
|
||||
"dp_degree": ("INT", {"default": 1, "min": 1, "step": 1}),
|
||||
"enable_cfg_parallel": (
|
||||
"BOOLEAN",
|
||||
{"default": False},
|
||||
),
|
||||
"attention_backend": (
|
||||
"STRING",
|
||||
{"default": ""},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SGLD_OPTIONS",)
|
||||
RETURN_NAMES = ("sgld_options",)
|
||||
FUNCTION = "create_options"
|
||||
CATEGORY = "SGLDiffusion"
|
||||
|
||||
def create_options(
|
||||
self,
|
||||
model_type: str = "auto-detect",
|
||||
enable_torch_compile: bool = False,
|
||||
num_gpus: int = 1,
|
||||
tp_size: int = -1,
|
||||
sp_degree: int = -1,
|
||||
ulysses_degree: int = -1,
|
||||
ring_degree: int = -1,
|
||||
dp_size: int = 1,
|
||||
dp_degree: int = 1,
|
||||
enable_cfg_parallel: bool = False,
|
||||
attention_backend: str = "",
|
||||
):
|
||||
"""
|
||||
Build a dictionary of SGLang Diffusion runtime options.
|
||||
"""
|
||||
# Convert -1 to None for optional parameters (matching ServerArgs defaults)
|
||||
ulysses_degree = None if ulysses_degree == -1 else ulysses_degree
|
||||
ring_degree = None if ring_degree == -1 else ring_degree
|
||||
attention_backend = None if attention_backend == "" else attention_backend
|
||||
|
||||
options = {
|
||||
"model_type": model_type,
|
||||
"enable_torch_compile": enable_torch_compile,
|
||||
"num_gpus": num_gpus,
|
||||
"tp_size": tp_size,
|
||||
"sp_degree": sp_degree,
|
||||
"ulysses_degree": ulysses_degree,
|
||||
"ring_degree": ring_degree,
|
||||
"dp_size": dp_size,
|
||||
"dp_degree": dp_degree,
|
||||
"enable_cfg_parallel": enable_cfg_parallel,
|
||||
"attention_backend": attention_backend,
|
||||
}
|
||||
|
||||
# Strip None to keep payload clean
|
||||
options = {k: v for k, v in options.items() if v is not None}
|
||||
return (options,)
|
||||
|
||||
|
||||
class SGLDLoraLoader:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"lora_name": (folder_paths.get_filename_list("loras"),),
|
||||
"strength_model": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0, "max": 10, "step": 0.01},
|
||||
),
|
||||
"nickname": ("STRING", {"default": ""}),
|
||||
"target": (
|
||||
["all", "transformer", "transformer_2", "critic"],
|
||||
{"default": "all"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
FUNCTION = "load_lora"
|
||||
|
||||
CATEGORY = "SGLDiffusion"
|
||||
|
||||
def load_lora(
|
||||
self, model, lora_name, strength_model=1.0, nickname="", target="all"
|
||||
):
|
||||
"""Load LoRA adapter using SGLang Diffusion API."""
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
assert model is not None
|
||||
bi = model.clone()
|
||||
nickname = nickname if nickname != "" else str("lora" + str(uuid.uuid4()))
|
||||
# set lora in the model
|
||||
bi.patches[nickname] = (lora_path, strength_model, target)
|
||||
|
||||
# prepare input for the SGLang Diffusion API
|
||||
lora_input = {
|
||||
"lora_nickname": [],
|
||||
"lora_path": [],
|
||||
"strength": [],
|
||||
"target": [],
|
||||
}
|
||||
for nickname, lora_info in bi.patches.items():
|
||||
lora_input["lora_nickname"].append(nickname)
|
||||
lora_input["lora_path"].append(lora_info[0])
|
||||
lora_input["strength"].append(lora_info[1])
|
||||
lora_input["target"].append(lora_info[2])
|
||||
|
||||
# call the SGLang Diffusion API
|
||||
model.model.diffusion_model.set_lora(**lora_input)
|
||||
return (model,)
|
||||
|
||||
|
||||
class SGLDUNETLoader:
|
||||
def __init__(self):
|
||||
self.generator = SGLDiffusionGenerator()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"unet_name": (folder_paths.get_filename_list("diffusion_models"),),
|
||||
"weight_dtype": (["default", "fp8_e4m3fn", "fp8_e5m2"],),
|
||||
},
|
||||
"optional": {
|
||||
"sgld_options": ("SGLD_OPTIONS",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
FUNCTION = "load_unet"
|
||||
|
||||
CATEGORY = "SGLDiffusion"
|
||||
|
||||
def load_unet(self, unet_name, weight_dtype, sgld_options: dict = None):
|
||||
model_options = {}
|
||||
if weight_dtype == "fp8_e4m3fn":
|
||||
model_options["dtype"] = torch.float8_e4m3fn
|
||||
elif weight_dtype == "fp8_e5m2":
|
||||
model_options["dtype"] = torch.float8_e5m2
|
||||
|
||||
unet_path = folder_paths.get_full_path("diffusion_models", unet_name)
|
||||
|
||||
model = self.generator.load_model(
|
||||
unet_path, model_options=model_options, sgld_options=sgld_options
|
||||
)
|
||||
return (model,)
|
||||
|
||||
|
||||
class SGLDiffusionServerModel:
|
||||
"""Node to load and manage SGLang Diffusion server connection."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"base_url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "http://localhost:3000/v1",
|
||||
"multiline": False,
|
||||
},
|
||||
),
|
||||
"api_key": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "sk-proj-1234567890",
|
||||
"multiline": False,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SGLD_CLIENT", "STRING")
|
||||
RETURN_NAMES = ("sgld_client", "model_info")
|
||||
FUNCTION = "load_server"
|
||||
CATEGORY = "SGLDiffusion"
|
||||
|
||||
def load_server(self, base_url: str, api_key: str):
|
||||
"""Initialize OpenAI client for SGLang Diffusion server."""
|
||||
client = SGLDiffusionServerAPI(base_url=base_url, api_key=api_key)
|
||||
try:
|
||||
model_info = client.get_model_info()
|
||||
# Format model_info as a readable string
|
||||
info_lines = ["=== SGLDiffusion Model Info ==="]
|
||||
for key, value in model_info.items():
|
||||
info_lines.append(f"{key}: {value}")
|
||||
model_info_str = "\n".join(info_lines)
|
||||
except Exception as e:
|
||||
model_info_str = f"Failed to get model info: {str(e)}"
|
||||
return (client, model_info_str)
|
||||
|
||||
|
||||
class SGLDiffusionGenerateImage:
|
||||
"""Node to generate images using SGLang Diffusion."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"sgld_client": ("SGLD_CLIENT",),
|
||||
"positive_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Text prompt for image generation",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"negative_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Negative prompt to avoid certain elements",
|
||||
},
|
||||
),
|
||||
"image": (
|
||||
"IMAGE",
|
||||
{
|
||||
"default": None,
|
||||
"tooltip": "input image to use for editing",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": -1,
|
||||
"max": 2**32 - 1,
|
||||
},
|
||||
),
|
||||
"steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 6,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"cfg": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 7.0,
|
||||
"min": 1.0,
|
||||
"max": 20.0,
|
||||
"step": 0.1,
|
||||
},
|
||||
),
|
||||
"width": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 256,
|
||||
"max": 4096,
|
||||
"step": 64,
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 256,
|
||||
"max": 4096,
|
||||
"step": 64,
|
||||
},
|
||||
),
|
||||
"enable_teacache": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "generate_image"
|
||||
CATEGORY = "SGLDiffusion"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def generate_image(
|
||||
self,
|
||||
sgld_client: SGLDiffusionServerAPI,
|
||||
positive_prompt: str,
|
||||
negative_prompt: str = "",
|
||||
image: torch.Tensor = None,
|
||||
seed: int = 1024,
|
||||
steps: int = 6,
|
||||
cfg: float = 7.0,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
enable_teacache: bool = False,
|
||||
):
|
||||
"""Generate image using SGLang Diffusion API."""
|
||||
if not positive_prompt:
|
||||
raise ValueError("Prompt cannot be empty")
|
||||
|
||||
size = f"{width}x{height}"
|
||||
|
||||
# Prepare request parameters
|
||||
request_params = {
|
||||
"prompt": positive_prompt,
|
||||
"size": size,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
|
||||
# Add optional parameters if provided
|
||||
if negative_prompt:
|
||||
request_params["negative_prompt"] = negative_prompt
|
||||
if cfg is not None:
|
||||
request_params["guidance_scale"] = cfg
|
||||
if steps is not None:
|
||||
request_params["num_inference_steps"] = steps
|
||||
if seed is not None and seed >= 0:
|
||||
request_params["seed"] = seed
|
||||
if enable_teacache:
|
||||
request_params["enable_teacache"] = True
|
||||
if image is not None:
|
||||
# If the image is empty, use the size of the image to generate the image
|
||||
if is_empty_image(image):
|
||||
width, height = image.shape[2], image.shape[1]
|
||||
size = f"{width}x{height}"
|
||||
request_params["size"] = size
|
||||
else:
|
||||
request_params["image_path"] = get_image_path(image)
|
||||
|
||||
# Call API
|
||||
try:
|
||||
response = sgld_client.generate_image(**request_params)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to generate image: {str(e)}")
|
||||
|
||||
# Decode base64 image
|
||||
if not response["data"] or not response["data"][0]["b64_json"]:
|
||||
raise RuntimeError("No image data in response")
|
||||
image_data = response["data"][0]["b64_json"]
|
||||
image = convert_b64_to_tensor_image(image_data)
|
||||
|
||||
return (image,)
|
||||
|
||||
|
||||
class SGLDiffusionGenerateVideo:
|
||||
"""Node to generate videos using SGLang Diffusion."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"sgld_client": ("SGLD_CLIENT",),
|
||||
"positive_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Text prompt for video generation",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"negative_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Negative prompt to avoid certain elements",
|
||||
},
|
||||
),
|
||||
"image": (
|
||||
"IMAGE",
|
||||
{
|
||||
"default": None,
|
||||
"tooltip": "input image to use for image-to-video",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": -1,
|
||||
"max": 2**32 - 1,
|
||||
},
|
||||
),
|
||||
"steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 6,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"cfg": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 7.0,
|
||||
"min": 1.0,
|
||||
"max": 20.0,
|
||||
"step": 0.1,
|
||||
},
|
||||
),
|
||||
"width": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1280,
|
||||
"min": 256,
|
||||
"max": 4096,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
"default": 720,
|
||||
"min": 256,
|
||||
"max": 4096,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"num_frames": (
|
||||
"INT",
|
||||
{
|
||||
"default": 120,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"fps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 24,
|
||||
"min": 1,
|
||||
"max": 60,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"seconds": (
|
||||
"INT",
|
||||
{
|
||||
"default": 5,
|
||||
"min": 1,
|
||||
"max": 60,
|
||||
"step": 1,
|
||||
},
|
||||
),
|
||||
"enable_teacache": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO", "STRING")
|
||||
RETURN_NAMES = ("video", "video_path")
|
||||
FUNCTION = "generate_video"
|
||||
CATEGORY = "SGLDiffusion"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def generate_video(
|
||||
self,
|
||||
sgld_client: SGLDiffusionServerAPI,
|
||||
positive_prompt: str,
|
||||
negative_prompt: str = "",
|
||||
image: torch.Tensor = None,
|
||||
seed: int = 1024,
|
||||
steps: int = 6,
|
||||
cfg: float = 7.0,
|
||||
width: int = 1280,
|
||||
height: int = 720,
|
||||
num_frames: int = 120,
|
||||
fps: int = 24,
|
||||
seconds: int = 5,
|
||||
enable_teacache: bool = False,
|
||||
):
|
||||
"""Generate video using SGLang Diffusion API."""
|
||||
if not positive_prompt:
|
||||
raise ValueError("Prompt cannot be empty")
|
||||
|
||||
size = f"{width}x{height}"
|
||||
output_dir = folder_paths.get_temp_directory()
|
||||
|
||||
# Prepare request parameters
|
||||
request_params = {
|
||||
"prompt": positive_prompt,
|
||||
"size": size,
|
||||
"seconds": seconds,
|
||||
"fps": fps,
|
||||
"output_path": output_dir,
|
||||
}
|
||||
|
||||
# Add optional parameters if provided
|
||||
if negative_prompt:
|
||||
request_params["negative_prompt"] = negative_prompt
|
||||
if cfg is not None:
|
||||
request_params["guidance_scale"] = cfg
|
||||
if steps is not None:
|
||||
request_params["num_inference_steps"] = steps
|
||||
if seed is not None and seed >= 0:
|
||||
request_params["seed"] = seed
|
||||
if enable_teacache:
|
||||
request_params["enable_teacache"] = True
|
||||
if num_frames is not None:
|
||||
request_params["num_frames"] = num_frames
|
||||
if image is not None:
|
||||
# If the image is empty, use the size of the image to generate the video
|
||||
if is_empty_image(image):
|
||||
width, height = image.shape[2], image.shape[1]
|
||||
size = f"{width}x{height}"
|
||||
request_params["size"] = size
|
||||
else:
|
||||
request_params["input_reference"] = get_image_path(image)
|
||||
|
||||
# Call API
|
||||
try:
|
||||
response = sgld_client.generate_video(**request_params)
|
||||
video_path = response.get("file_path", "")
|
||||
video = convert_video_to_comfy_video(video_path, height, width)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to generate video: {str(e)}")
|
||||
|
||||
return (video, video_path)
|
||||
|
||||
|
||||
class SGLDiffusionServerSetLora:
|
||||
"""Node to set LoRA adapter for SGLang Diffusion server."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"sgld_client": ("SGLD_CLIENT",),
|
||||
"lora_name": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "The name of the LoRA adapter",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"lora_nickname": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "The nickname of the LoRA adapter",
|
||||
},
|
||||
),
|
||||
"target": (
|
||||
[
|
||||
"all",
|
||||
"transformer",
|
||||
"transformer_2",
|
||||
"critic",
|
||||
],
|
||||
{
|
||||
"default": "all",
|
||||
"tooltip": "Which transformer(s) to apply the LoRA to",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SGLD_CLIENT",)
|
||||
RETURN_NAMES = ("sgld_client",)
|
||||
FUNCTION = "set_lora"
|
||||
CATEGORY = "SGLDiffusion"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def set_lora(
|
||||
self,
|
||||
sgld_client: SGLDiffusionServerAPI,
|
||||
lora_name: str = "",
|
||||
lora_nickname: str = "",
|
||||
target: str = "all",
|
||||
):
|
||||
"""Set LoRA adapter using SGLang Diffusion API."""
|
||||
if lora_nickname == "":
|
||||
lora_nickname = os.path.splitext(lora_name)[0]
|
||||
|
||||
# Prepare request parameters
|
||||
request_params = {
|
||||
"lora_nickname": lora_nickname,
|
||||
"lora_path": lora_name,
|
||||
"target": target,
|
||||
}
|
||||
|
||||
# Call API
|
||||
try:
|
||||
sgld_client.set_lora(**request_params)
|
||||
return (sgld_client,)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to set LoRA adapter: {str(e)}")
|
||||
|
||||
|
||||
class SGLDiffusionServerUnsetLora:
|
||||
"""Node to unset LoRA adapter for SGLang Diffusion server."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"sgld_client": ("SGLD_CLIENT",),
|
||||
},
|
||||
"optional": {
|
||||
"target": (
|
||||
[
|
||||
"all",
|
||||
"transformer",
|
||||
"transformer_2",
|
||||
"critic",
|
||||
],
|
||||
{
|
||||
"default": "all",
|
||||
"tooltip": "Which transformer(s) to unset the LoRA from",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SGLD_CLIENT",)
|
||||
RETURN_NAMES = ("sgld_client",)
|
||||
FUNCTION = "unset_lora"
|
||||
CATEGORY = "SGLDiffusion"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def unset_lora(
|
||||
self,
|
||||
sgld_client: SGLDiffusionServerAPI,
|
||||
target: str = "all",
|
||||
):
|
||||
"""Unset LoRA adapter using SGLang Diffusion API."""
|
||||
try:
|
||||
sgld_client.unset_lora(target=target)
|
||||
return (sgld_client,)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to unset LoRA adapter: {str(e)}")
|
||||
|
||||
|
||||
# Register nodes
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SGLDiffusionServerModel": SGLDiffusionServerModel,
|
||||
"SGLDiffusionGenerateImage": SGLDiffusionGenerateImage,
|
||||
"SGLDiffusionGenerateVideo": SGLDiffusionGenerateVideo,
|
||||
"SGLDiffusionServerSetLora": SGLDiffusionServerSetLora,
|
||||
"SGLDiffusionServerUnsetLora": SGLDiffusionServerUnsetLora,
|
||||
"SGLDUNETLoader": SGLDUNETLoader,
|
||||
"SGLDOptions": SGLDOptions,
|
||||
"SGLDLoraLoader": SGLDLoraLoader,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SGLDiffusionServerModel": "SGLDiffusion Server Model",
|
||||
"SGLDiffusionGenerateImage": "SGLDiffusion Generate Image",
|
||||
"SGLDiffusionGenerateVideo": "SGLDiffusion Generate Video",
|
||||
"SGLDiffusionServerSetLora": "SGLDiffusion Server Set LoRA",
|
||||
"SGLDiffusionServerUnsetLora": "SGLDiffusion Server Unset LoRA",
|
||||
"SGLDUNETLoader": "SGLDiffusion UNET Loader",
|
||||
"SGLDOptions": "SGLDiffusion Options",
|
||||
"SGLDLoraLoader": "SGLDiffusion LoRA Loader",
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# ComfyUI SGLDiffusion Pipeline Tests
|
||||
|
||||
This directory contains tests for each ComfyUI pipeline integration.
|
||||
|
||||
## Test Files
|
||||
|
||||
- `test_zimage_pipeline.py` - Tests for ComfyUIZImagePipeline
|
||||
- `test_flux_pipeline.py` - Tests for ComfyUIFluxPipeline
|
||||
- `test_qwen_image_pipeline.py` - Tests for ComfyUIQwenImagePipeline
|
||||
- `test_qwen_image_edit_pipeline.py` - Tests for ComfyUIQwenImageEditPipeline (I2I/edit mode)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all tests
|
||||
|
||||
```bash
|
||||
pytest python/sglang/multimodal_gen/apps/ComfyUI_SGLDiffusion/test/ -v -s
|
||||
```
|
||||
|
||||
### Run a specific test file
|
||||
|
||||
```bash
|
||||
pytest python/sglang/multimodal_gen/apps/ComfyUI_SGLDiffusion/test/test_zimage_pipeline.py -v -s
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
You can configure model paths via environment variables. Model paths support two formats:
|
||||
- **Safetensors file**: Path to a single `.safetensors` file (e.g., `/path/to/model.safetensors`)
|
||||
- **Diffusers format**: HuggingFace model ID or local diffusers directory (e.g., `Tongyi-MAI/Z-Image-Turbo`)
|
||||
|
||||
Environment variables:
|
||||
- `SGLANG_TEST_ZIMAGE_MODEL_PATH` - Path to ZImage model (default: `Tongyi-MAI/Z-Image-Turbo`)
|
||||
- `SGLANG_TEST_FLUX_MODEL_PATH` - Path to Flux model (default: `black-forest-labs/FLUX.1-dev`)
|
||||
- `SGLANG_TEST_QWEN_IMAGE_MODEL_PATH` - Path to QwenImage model (default: `Qwen/Qwen-Image`)
|
||||
- `SGLANG_TEST_QWEN_IMAGE_EDIT_MODEL_PATH` - Path to QwenImageEdit model (default: `Qwen/Qwen-Image-Edit-2511`)
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Using HuggingFace model ID (diffusers format)
|
||||
export SGLANG_TEST_ZIMAGE_MODEL_PATH="Tongyi-MAI/Z-Image-Turbo"
|
||||
pytest python/sglang/multimodal_gen/apps/ComfyUI_SGLDiffusion/test/test_zimage_pipeline.py -v -s
|
||||
|
||||
# Using safetensors file
|
||||
export SGLANG_TEST_ZIMAGE_MODEL_PATH="/path/to/z_image_turbo_bf16.safetensors"
|
||||
pytest python/sglang/multimodal_gen/apps/ComfyUI_SGLDiffusion/test/test_zimage_pipeline.py -v -s
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Each test file follows a similar structure:
|
||||
|
||||
1. **Setup**: Creates a `DiffGenerator` with the appropriate pipeline class
|
||||
2. **Input Preparation**: Creates dummy tensors for latents, timesteps, and embeddings
|
||||
3. **Request Preparation**: Uses `prepare_request` to convert `SamplingParams` to `Req`
|
||||
4. **ComfyUI Inputs**: Sets ComfyUI-specific inputs directly on the `Req` object
|
||||
5. **Execution**: Sends request to scheduler and waits for response
|
||||
6. **Validation**: Checks that `noise_pred` is retrieved from `OutputBatch`
|
||||
|
||||
## Notes
|
||||
|
||||
- These tests use `comfyui_mode=True` to enable ComfyUI-specific behavior
|
||||
- Tests use pre-processed inputs (latents, timesteps, embeddings) as ComfyUI would provide
|
||||
- The tests verify that `noise_pred` can be retrieved from the `OutputBatch` after processing
|
||||
- All tests use dummy/ones tensors for simplicity - in production, these would be actual model outputs
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Test suite for ComfyUI SGLDiffusion pipelines.
|
||||
|
||||
This package contains tests for each ComfyUI pipeline integration:
|
||||
- ZImagePipeline
|
||||
- FluxPipeline
|
||||
- QwenImagePipeline
|
||||
- QwenImageEditPipeline
|
||||
"""
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Test for ComfyUIFluxPipeline with pass-through scheduler."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
|
||||
|
||||
|
||||
def test_comfyui_flux_pipeline_direct() -> None:
|
||||
"""Test ComfyUIFluxPipeline with custom inputs."""
|
||||
model_path = os.environ.get(
|
||||
"SGLANG_TEST_FLUX_MODEL_PATH",
|
||||
"black-forest-labs/FLUX.1-dev", # Supports both safetensors file and diffusers format
|
||||
)
|
||||
|
||||
generator = DiffGenerator.from_pretrained(
|
||||
model_path=model_path,
|
||||
pipeline_class_name="ComfyUIFluxPipeline",
|
||||
num_gpus=2,
|
||||
comfyui_mode=True,
|
||||
)
|
||||
|
||||
batch_size = 1
|
||||
hidden_states_seq_len = 3600
|
||||
hidden_states_dim = 64
|
||||
height = 1280
|
||||
width = 720
|
||||
|
||||
encoder_seq_len = 512
|
||||
encoder_dim = 4096
|
||||
pooled_dim = 768
|
||||
|
||||
hidden_states = torch.ones(
|
||||
batch_size,
|
||||
hidden_states_seq_len,
|
||||
hidden_states_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
encoder_hidden_states = torch.ones(
|
||||
batch_size,
|
||||
encoder_seq_len,
|
||||
encoder_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
pooled_projections = torch.ones(
|
||||
batch_size,
|
||||
pooled_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
timesteps = torch.tensor([1000], dtype=torch.long, device="cuda")
|
||||
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
generator.server_args.model_path,
|
||||
server_args=generator.server_args,
|
||||
prompt="a beautiful girl",
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=1,
|
||||
num_inference_steps=1,
|
||||
save_output=True,
|
||||
return_trajectory_latents=True,
|
||||
)
|
||||
|
||||
req = prepare_request(
|
||||
server_args=generator.server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
|
||||
req.latents = hidden_states
|
||||
req.timesteps = timesteps
|
||||
req.raw_latent_shape = torch.tensor(hidden_states.shape, dtype=torch.long)
|
||||
|
||||
clip_dim = 768
|
||||
req.prompt_embeds = [pooled_projections, encoder_hidden_states]
|
||||
|
||||
if req.guidance_scale > 1.0:
|
||||
dummy_neg_clip_embedding = torch.zeros(
|
||||
batch_size,
|
||||
77,
|
||||
clip_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
negative_encoder_hidden_states = torch.ones(
|
||||
batch_size,
|
||||
encoder_seq_len,
|
||||
encoder_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
req.negative_prompt_embeds = [
|
||||
dummy_neg_clip_embedding,
|
||||
negative_encoder_hidden_states,
|
||||
]
|
||||
else:
|
||||
req.negative_prompt_embeds = None
|
||||
|
||||
req.pooled_embeds = [pooled_projections]
|
||||
req.neg_pooled_embeds = []
|
||||
|
||||
if (
|
||||
req.guidance_scale > 1.0
|
||||
and req.negative_prompt_embeds is not None
|
||||
and len(req.negative_prompt_embeds) > 0
|
||||
):
|
||||
req.do_classifier_free_guidance = True
|
||||
else:
|
||||
req.do_classifier_free_guidance = False
|
||||
|
||||
if req.seed is not None:
|
||||
generator_device = req.generator_device
|
||||
device_str = "cuda" if generator_device == "cuda" else "cpu"
|
||||
req.generator = [
|
||||
torch.Generator(device_str).manual_seed(req.seed + i)
|
||||
for i in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
else:
|
||||
req.generator = [
|
||||
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
|
||||
output_batch = generator._send_to_scheduler_and_wait_for_response([req])
|
||||
noise_pred = output_batch.noise_pred
|
||||
|
||||
assert noise_pred is not None, "noise_pred should not be None in OutputBatch"
|
||||
assert isinstance(noise_pred, torch.Tensor), "noise_pred should be a torch.Tensor"
|
||||
assert (
|
||||
noise_pred.device.type == "cuda"
|
||||
), f"noise_pred should be on cuda, got {noise_pred.device}"
|
||||
assert (
|
||||
noise_pred.dtype == torch.bfloat16
|
||||
), f"noise_pred should be bfloat16, got {noise_pred.dtype}"
|
||||
|
||||
print("✓ Successfully retrieved noise_pred from OutputBatch!")
|
||||
print(f" noise_pred shape: {noise_pred.shape}")
|
||||
print(f" noise_pred dtype: {noise_pred.dtype}")
|
||||
print(f" noise_pred device: {noise_pred.device}")
|
||||
|
||||
latents = output_batch.output if output_batch.output is not None else req.latents
|
||||
assert latents is not None, "latents should not be None"
|
||||
print(f"latents.shape: {latents.shape}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
"""Test for ComfyUIQwenImageEditPipeline with pass-through scheduler (I2I/edit mode)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
|
||||
|
||||
|
||||
def test_comfyui_qwen_image_edit_pipeline_direct() -> None:
|
||||
"""Test ComfyUIQwenImageEditPipeline with edit mode (I2I) and custom inputs."""
|
||||
model_path = os.environ.get(
|
||||
"SGLANG_TEST_QWEN_IMAGE_EDIT_MODEL_PATH",
|
||||
"Qwen/Qwen-Image-Edit-2511", # Supports both safetensors file and diffusers format
|
||||
)
|
||||
|
||||
generator = DiffGenerator.from_pretrained(
|
||||
model_path=model_path,
|
||||
pipeline_class_name="ComfyUIQwenImageEditPipeline",
|
||||
num_gpus=1,
|
||||
comfyui_mode=True,
|
||||
dit_layerwise_offload=False,
|
||||
)
|
||||
|
||||
batch_size = 1
|
||||
noisy_image_seq_len = 3600
|
||||
hidden_states_dim = 64
|
||||
condition_image_seq_len = 6889
|
||||
condition_image_dim = 64
|
||||
encoder_seq_len = 45
|
||||
encoder_dim = 3584
|
||||
height = 720
|
||||
width = 1280
|
||||
|
||||
vae_scale_factor = 8
|
||||
condition_height_latent = 1328 // vae_scale_factor
|
||||
condition_width_latent = 1328 // vae_scale_factor
|
||||
|
||||
noisy_image_latents = torch.ones(
|
||||
batch_size,
|
||||
noisy_image_seq_len,
|
||||
hidden_states_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
condition_image_latents = torch.ones(
|
||||
batch_size,
|
||||
condition_image_seq_len,
|
||||
condition_image_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
encoder_hidden_states = torch.ones(
|
||||
batch_size,
|
||||
encoder_seq_len,
|
||||
encoder_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
timesteps = torch.tensor([1000], dtype=torch.long, device="cuda")
|
||||
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
generator.server_args.model_path,
|
||||
server_args=generator.server_args,
|
||||
prompt=" ",
|
||||
guidance_scale=1.0,
|
||||
height=height,
|
||||
width=width,
|
||||
image_path="",
|
||||
num_frames=1,
|
||||
num_inference_steps=1,
|
||||
seed=42,
|
||||
save_output=False,
|
||||
return_frames=False,
|
||||
)
|
||||
|
||||
req = prepare_request(
|
||||
server_args=generator.server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
|
||||
req.latents = noisy_image_latents
|
||||
req.image_latent = condition_image_latents
|
||||
req.timesteps = timesteps
|
||||
req.prompt_embeds = [encoder_hidden_states]
|
||||
req.negative_prompt_embeds = None
|
||||
req.vae_image_sizes = [(condition_width_latent, condition_height_latent)]
|
||||
req.raw_latent_shape = torch.tensor(noisy_image_latents.shape, dtype=torch.long)
|
||||
|
||||
if req.guidance_scale > 1.0 and req.negative_prompt_embeds is not None:
|
||||
req.do_classifier_free_guidance = True
|
||||
else:
|
||||
req.do_classifier_free_guidance = False
|
||||
|
||||
if req.seed is not None:
|
||||
generator_device = req.generator_device
|
||||
device_str = "cpu" if generator_device == "cpu" else "cuda"
|
||||
req.generator = [
|
||||
torch.Generator(device_str).manual_seed(req.seed + i)
|
||||
for i in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
else:
|
||||
req.generator = [
|
||||
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
|
||||
output_batch = generator._send_to_scheduler_and_wait_for_response([req])
|
||||
noise_pred = output_batch.noise_pred
|
||||
|
||||
assert noise_pred is not None, "noise_pred should not be None in OutputBatch"
|
||||
assert isinstance(noise_pred, torch.Tensor), "noise_pred should be a torch.Tensor"
|
||||
assert (
|
||||
noise_pred.device.type == "cuda"
|
||||
), f"noise_pred should be on cuda, got {noise_pred.device}"
|
||||
assert (
|
||||
noise_pred.dtype == torch.bfloat16
|
||||
), f"noise_pred should be bfloat16, got {noise_pred.dtype}"
|
||||
|
||||
print("✓ Successfully retrieved noise_pred from OutputBatch (Edit Mode)!")
|
||||
print(f" noise_pred shape: {noise_pred.shape}")
|
||||
print(f" noise_pred dtype: {noise_pred.dtype}")
|
||||
print(f" noise_pred device: {noise_pred.device}")
|
||||
|
||||
latents = output_batch.output if output_batch.output is not None else req.latents
|
||||
assert latents is not None, "latents should not be None"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
"""Test for ComfyUIQwenImagePipeline with pass-through scheduler."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
|
||||
|
||||
|
||||
def test_comfyui_qwen_image_pipeline_direct() -> None:
|
||||
"""Test ComfyUIQwenImagePipeline with custom inputs."""
|
||||
model_path = os.environ.get(
|
||||
"SGLANG_TEST_QWEN_IMAGE_MODEL_PATH",
|
||||
"Qwen/Qwen-Image", # Supports both safetensors file and diffusers format
|
||||
)
|
||||
|
||||
generator = DiffGenerator.from_pretrained(
|
||||
model_path=model_path,
|
||||
pipeline_class_name="ComfyUIQwenImagePipeline",
|
||||
num_gpus=2,
|
||||
comfyui_mode=True,
|
||||
dit_layerwise_offload=False,
|
||||
)
|
||||
|
||||
batch_size = 1
|
||||
hidden_states_seq_len = 6889
|
||||
hidden_states_dim = 64
|
||||
encoder_seq_len = 45
|
||||
encoder_dim = 3584
|
||||
height = 1328
|
||||
width = 1328
|
||||
dtype = torch.bfloat16
|
||||
|
||||
hidden_states = torch.ones(
|
||||
batch_size,
|
||||
hidden_states_seq_len,
|
||||
hidden_states_dim,
|
||||
device="cuda",
|
||||
dtype=dtype,
|
||||
)
|
||||
|
||||
encoder_hidden_states = torch.ones(
|
||||
batch_size,
|
||||
encoder_seq_len,
|
||||
encoder_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
timesteps = torch.tensor([1000], dtype=torch.long, device="cuda")
|
||||
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
generator.server_args.model_path,
|
||||
server_args=generator.server_args,
|
||||
prompt=" ",
|
||||
guidance_scale=3.0,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=1,
|
||||
num_inference_steps=1,
|
||||
seed=42,
|
||||
save_output=False,
|
||||
return_frames=False,
|
||||
)
|
||||
|
||||
req = prepare_request(
|
||||
server_args=generator.server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
|
||||
req.latents = hidden_states
|
||||
req.timesteps = timesteps
|
||||
req.prompt_embeds = [encoder_hidden_states]
|
||||
req.negative_prompt_embeds = [encoder_hidden_states]
|
||||
req.raw_latent_shape = torch.tensor(hidden_states.shape, dtype=torch.long)
|
||||
|
||||
if req.guidance_scale > 1.0 and req.negative_prompt_embeds is not None:
|
||||
req.do_classifier_free_guidance = True
|
||||
else:
|
||||
req.do_classifier_free_guidance = False
|
||||
|
||||
if req.seed is not None:
|
||||
generator_device = req.generator_device
|
||||
device_str = "cpu" if generator_device == "cpu" else "cuda"
|
||||
req.generator = [
|
||||
torch.Generator(device_str).manual_seed(req.seed + i)
|
||||
for i in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
else:
|
||||
req.generator = [
|
||||
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
|
||||
output_batch = generator._send_to_scheduler_and_wait_for_response([req])
|
||||
noise_pred = output_batch.noise_pred
|
||||
|
||||
assert noise_pred is not None, "noise_pred should not be None in OutputBatch"
|
||||
assert isinstance(noise_pred, torch.Tensor), "noise_pred should be a torch.Tensor"
|
||||
assert (
|
||||
noise_pred.device.type == "cuda"
|
||||
), f"noise_pred should be on cuda, got {noise_pred.device}"
|
||||
assert (
|
||||
noise_pred.dtype == torch.bfloat16
|
||||
), f"noise_pred should be bfloat16, got {noise_pred.dtype}"
|
||||
|
||||
print("✓ Successfully retrieved noise_pred from OutputBatch!")
|
||||
print(f" noise_pred shape: {noise_pred.shape}")
|
||||
print(f" noise_pred dtype: {noise_pred.dtype}")
|
||||
print(f" noise_pred device: {noise_pred.device}")
|
||||
|
||||
latents = output_batch.output if output_batch.output is not None else req.latents
|
||||
assert latents is not None, "latents should not be None"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Test for ComfyUIZImagePipeline with pass-through scheduler."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
|
||||
|
||||
|
||||
def test_comfyui_zimage_pipeline_direct() -> None:
|
||||
"""Test ComfyUIZImagePipeline with custom inputs."""
|
||||
model_path = os.environ.get(
|
||||
"SGLANG_TEST_ZIMAGE_MODEL_PATH",
|
||||
"Tongyi-MAI/Z-Image-Turbo", # Supports both safetensors file and diffusers format
|
||||
)
|
||||
|
||||
generator = DiffGenerator.from_pretrained(
|
||||
model_path=model_path,
|
||||
pipeline_class_name="ComfyUIZImagePipeline",
|
||||
num_gpus=1,
|
||||
sp_degree=1,
|
||||
comfyui_mode=True,
|
||||
)
|
||||
|
||||
batch_size = 1
|
||||
num_channels = 16
|
||||
num_frames = 1
|
||||
height = 720
|
||||
width = 1280
|
||||
latent_height = height // 8
|
||||
latent_width = width // 8
|
||||
|
||||
latents = torch.ones(
|
||||
batch_size,
|
||||
num_channels,
|
||||
num_frames,
|
||||
latent_height,
|
||||
latent_width,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
timesteps = torch.tensor([1000], dtype=torch.long, device="cuda")
|
||||
|
||||
context_seq_len = 19
|
||||
context_dim = 2560
|
||||
context = torch.ones(
|
||||
context_seq_len,
|
||||
context_dim,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
generator.server_args.model_path,
|
||||
server_args=generator.server_args,
|
||||
prompt="a beautiful girl",
|
||||
guidance_scale=1.0,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=1,
|
||||
num_inference_steps=1,
|
||||
seed=42,
|
||||
save_output=False,
|
||||
return_frames=False,
|
||||
)
|
||||
|
||||
req = prepare_request(
|
||||
server_args=generator.server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
|
||||
req.latents = latents
|
||||
req.timesteps = timesteps
|
||||
req.prompt_embeds = [context]
|
||||
req.negative_prompt_embeds = None
|
||||
req.raw_latent_shape = torch.tensor(latents.shape, dtype=torch.long)
|
||||
|
||||
if req.guidance_scale > 1.0 and req.negative_prompt_embeds is not None:
|
||||
req.do_classifier_free_guidance = True
|
||||
else:
|
||||
req.do_classifier_free_guidance = False
|
||||
|
||||
if req.seed is not None:
|
||||
generator_device = req.generator_device
|
||||
device_str = "cpu" if generator_device == "cpu" else "cuda"
|
||||
req.generator = [
|
||||
torch.Generator(device_str).manual_seed(req.seed + i)
|
||||
for i in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
else:
|
||||
req.generator = [
|
||||
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
|
||||
]
|
||||
|
||||
output_batch = generator._send_to_scheduler_and_wait_for_response([req])
|
||||
noise_pred = output_batch.noise_pred
|
||||
|
||||
assert noise_pred is not None, "noise_pred should not be None in OutputBatch"
|
||||
assert isinstance(noise_pred, torch.Tensor), "noise_pred should be a torch.Tensor"
|
||||
assert (
|
||||
noise_pred.device.type == "cuda"
|
||||
), f"noise_pred should be on cuda, got {noise_pred.device}"
|
||||
assert (
|
||||
noise_pred.dtype == torch.bfloat16
|
||||
), f"noise_pred should be bfloat16, got {noise_pred.dtype}"
|
||||
|
||||
print("✓ Successfully retrieved noise_pred from OutputBatch!")
|
||||
print(f" noise_pred shape: {noise_pred.shape}")
|
||||
print(f" noise_pred dtype: {noise_pred.dtype}")
|
||||
print(f" noise_pred device: {noise_pred.device}")
|
||||
|
||||
latents = output_batch.output if output_batch.output is not None else req.latents
|
||||
assert latents is not None, "latents should not be None"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,176 @@
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import torch
|
||||
from comfy_api.input import VideoInput
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _ensure_dir(path: str) -> None:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def _to_numpy_image(image: torch.Tensor) -> np.ndarray:
|
||||
"""Convert ComfyUI image tensor to uint8 numpy array (H, W, C)."""
|
||||
if image.dim() == 4:
|
||||
image = image[0]
|
||||
if image.dim() == 3 and image.shape[0] in (1, 3, 4):
|
||||
image = image.permute(1, 2, 0)
|
||||
elif image.dim() == 2:
|
||||
image = image.unsqueeze(-1)
|
||||
np_img = image.detach().cpu().numpy()
|
||||
np_img = np.clip(np_img, 0.0, 1.0)
|
||||
np_img = (np_img * 255).astype(np.uint8)
|
||||
if np_img.shape[-1] == 1:
|
||||
np_img = np.repeat(np_img, 3, axis=-1)
|
||||
return np_img
|
||||
|
||||
|
||||
def _to_hwc_tensor(image: torch.Tensor) -> torch.Tensor:
|
||||
"""Convert ComfyUI image tensor to HWC format (normalized [0, 1])."""
|
||||
img = image.clone()
|
||||
if img.dim() == 4:
|
||||
img = img[0]
|
||||
if img.dim() == 3 and img.shape[0] in (1, 3, 4):
|
||||
img = img.permute(1, 2, 0)
|
||||
elif img.dim() == 2:
|
||||
img = img.unsqueeze(-1)
|
||||
|
||||
img = torch.clamp(img, 0.0, 1.0)
|
||||
if img.shape[-1] == 1:
|
||||
img = img.repeat(1, 1, 3)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def is_empty_image(image: torch.Tensor, tolerance: float = 1e-6) -> bool:
|
||||
"""
|
||||
Check if the input image is an empty/solid color image (like ComfyUI's empty image).
|
||||
Args:
|
||||
image: Input tensor image in ComfyUI format (BCHW, CHW, HWC, etc.)
|
||||
tolerance: Tolerance for floating point comparison (default: 1e-6)
|
||||
|
||||
Returns:
|
||||
True if the image is empty (all pixels have same color), False otherwise
|
||||
"""
|
||||
if image is None:
|
||||
return True
|
||||
|
||||
# Convert to HWC format
|
||||
img_hwc = _to_hwc_tensor(image)
|
||||
|
||||
# Get the first pixel's RGB values
|
||||
first_pixel = img_hwc[0, 0, :]
|
||||
|
||||
h, w, c = img_hwc.shape
|
||||
pixels = img_hwc.reshape(-1, c)
|
||||
|
||||
diff = torch.abs(pixels - first_pixel)
|
||||
max_diff = torch.max(diff)
|
||||
|
||||
return max_diff.item() <= tolerance
|
||||
|
||||
|
||||
def get_image_path(image: torch.Tensor) -> str:
|
||||
"""
|
||||
Save tensor image to ComfyUI temp directory as PNG and return the path.
|
||||
"""
|
||||
temp_dir = folder_paths.get_temp_directory()
|
||||
|
||||
# Build file name
|
||||
ts = time.strftime("%Y%m%d-%H%M%S")
|
||||
unique = uuid.uuid4().hex[:8]
|
||||
file_name = f"sgl_output_{ts}_{unique}.png"
|
||||
file_path = os.path.join(temp_dir, file_name)
|
||||
|
||||
# Save image
|
||||
np_img = _to_numpy_image(image)
|
||||
img = Image.fromarray(np_img)
|
||||
img.save(file_path, format="PNG")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def convert_b64_to_tensor_image(b64_image: str) -> torch.Tensor:
|
||||
"""
|
||||
Convert base64 encoded image to ComfyUI IMAGE format (torch.Tensor).
|
||||
|
||||
Args:
|
||||
b64_image: Base64 encoded image string
|
||||
|
||||
Returns:
|
||||
torch.Tensor with shape [batch_size, height, width, channels] (BHWC format),
|
||||
values normalized to [0, 1] range, RGB format (3 channels)
|
||||
"""
|
||||
# Decode base64
|
||||
image_bytes = base64.b64decode(b64_image)
|
||||
|
||||
# Open image and convert to RGB
|
||||
pil_image = Image.open(io.BytesIO(image_bytes))
|
||||
if pil_image.mode != "RGB":
|
||||
pil_image = pil_image.convert("RGB")
|
||||
|
||||
# Convert to numpy array and normalize to [0, 1]
|
||||
image_array = np.array(pil_image).astype(np.float32) / 255.0
|
||||
|
||||
# Add batch dimension: [height, width, channels] -> [1, height, width, channels]
|
||||
image_array = image_array[np.newaxis, ...]
|
||||
|
||||
# Convert to torch.Tensor
|
||||
tensor_image = torch.from_numpy(image_array)
|
||||
|
||||
return tensor_image
|
||||
|
||||
|
||||
class SGLDVideoInput(VideoInput):
|
||||
def __init__(self, video_path: str, height: int, width: int):
|
||||
super().__init__()
|
||||
|
||||
self.video_path = video_path
|
||||
self.height = height
|
||||
self.width = width
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""
|
||||
Returns the dimensions of the video input.
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height)
|
||||
"""
|
||||
return self.width, self.height
|
||||
|
||||
def get_components(self):
|
||||
"""
|
||||
Returns the components of the video input.
|
||||
This is required by the VideoInput abstract base class.
|
||||
"""
|
||||
return [self.video_path]
|
||||
|
||||
def save_to(self, path: str, format=None, codec=None, metadata=None):
|
||||
"""
|
||||
Abstract method to save the video input to a file.
|
||||
"""
|
||||
save_path = path
|
||||
# Copy video file from video_path to save_path
|
||||
if os.path.exists(self.video_path):
|
||||
# Ensure destination directory exists
|
||||
save_dir = os.path.dirname(save_path)
|
||||
if save_dir:
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
shutil.copy2(self.video_path, save_path)
|
||||
|
||||
|
||||
def convert_video_to_comfy_video(
|
||||
video_path: str, height: int, width: int
|
||||
) -> VideoInput:
|
||||
"""
|
||||
Convert video to ComfyUI VIDEO format (VideoInput).
|
||||
"""
|
||||
video_input = SGLDVideoInput(video_path, height, width)
|
||||
return video_input
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"40",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"10",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"vae_name": "ae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"inputs": {
|
||||
"clip_name1": "t5xxl_fp16.safetensors",
|
||||
"clip_name2": "clip_l.safetensors",
|
||||
"type": "flux",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "DualCLIPLoader",
|
||||
"_meta": {
|
||||
"title": "DualCLIPLoader"
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"inputs": {
|
||||
"scheduler": "normal",
|
||||
"steps": 25,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"46",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"38": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"46",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"42",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
},
|
||||
"40": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"45",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"38",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"47",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"17",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"44",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"42": {
|
||||
"inputs": {
|
||||
"guidance": 3.5,
|
||||
"conditioning": [
|
||||
"43",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "FluxGuidance",
|
||||
"_meta": {
|
||||
"title": "FluxGuidance"
|
||||
}
|
||||
},
|
||||
"43": {
|
||||
"inputs": {
|
||||
"text": "beautiful photography of a gonger haired artist with Lots of Colorful coloursplashes in face and pn her hands, she is natural, having her hair in a casual bun, looking happily into camera, cinematic,",
|
||||
"speak_and_recognation": {
|
||||
"__value__": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"clip": [
|
||||
"11",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Prompt)"
|
||||
}
|
||||
},
|
||||
"44": {
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": {
|
||||
"title": "EmptySD3LatentImage"
|
||||
}
|
||||
},
|
||||
"45": {
|
||||
"inputs": {
|
||||
"noise_seed": 747172083610812
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"46": {
|
||||
"inputs": {
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"model": [
|
||||
"51",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"_meta": {
|
||||
"title": "ModelSamplingFlux"
|
||||
}
|
||||
},
|
||||
"47": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"51": {
|
||||
"inputs": {
|
||||
"unet_name": "flux1-dev.safetensors",
|
||||
"weight_dtype": "default",
|
||||
"sgld_options": [
|
||||
"52",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SGLDUNETLoader",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion UNET Loader"
|
||||
}
|
||||
},
|
||||
"52": {
|
||||
"inputs": {
|
||||
"model_type": "auto-detect",
|
||||
"enable_torch_compile": false,
|
||||
"num_gpus": 2,
|
||||
"tp_size": -1,
|
||||
"sp_degree": -1,
|
||||
"ulysses_degree": -1,
|
||||
"ring_degree": -1,
|
||||
"dp_size": 1,
|
||||
"dp_degree": 1,
|
||||
"enable_cfg_parallel": false,
|
||||
"attention_backend": "",
|
||||
"cache_strategy": "none"
|
||||
},
|
||||
"class_type": "SGLDOptions",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion Options"
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 808633539418610,
|
||||
"steps": 4,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"66",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"58",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": "\"A vibrant, warm neon-lit street scene in Hong Kong at the afternoon, with a mix of colorful Chinese and English signs glowing brightly. The atmosphere is lively, cinematic, and rain-washed with reflections on the pavement. The colors are vivid, full of pink, blue, red, and green hues. Crowded buildings with overlapping neon signs. 1980s Hong Kong style. Signs include:\n\"龍鳳冰室\" \"金華燒臘\" \"HAPPY HAIR\" \"鴻運茶餐廳\" \"EASY BAR\" \"永發魚蛋粉\" \"添記粥麵\" \"SUNSHINE MOTEL\" \"美都餐室\" \"富記糖水\" \"太平館\" \"雅芳髮型屋\" \"STAR KTV\" \"銀河娛樂城\" \"百樂門舞廳\" \"BUBBLE CAFE\" \"萬豪麻雀館\" \"CITY LIGHTS BAR\" \"瑞祥香燭莊\" \"文記文具\" \"GOLDEN JADE HOTEL\" \"LOVELY BEAUTY\" \"合興百貨\" \"興旺電器\" And the background is warm yellow street and with all stores' lights on.",
|
||||
"speak_and_recognation": {
|
||||
"__value__": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"clip": [
|
||||
"38",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Positive Prompt)"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"speak_and_recognation": {
|
||||
"__value__": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"clip": [
|
||||
"38",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Negative Prompt)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"39",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"38": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "Load CLIP"
|
||||
}
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"58": {
|
||||
"inputs": {
|
||||
"width": 1328,
|
||||
"height": 1328,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": {
|
||||
"title": "EmptySD3LatentImage"
|
||||
}
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI"
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
},
|
||||
"66": {
|
||||
"inputs": {
|
||||
"shift": 3.1000000000000005,
|
||||
"model": [
|
||||
"78",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"_meta": {
|
||||
"title": "ModelSamplingAuraFlow"
|
||||
}
|
||||
},
|
||||
"77": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_2512_bf16.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "SGLDUNETLoader",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion UNET Loader"
|
||||
}
|
||||
},
|
||||
"78": {
|
||||
"inputs": {
|
||||
"lora_name": "Qwen-Image-2512-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"strength_model": 1,
|
||||
"nickname": "",
|
||||
"target": "all",
|
||||
"model": [
|
||||
"77",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SGLDLoraLoader",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion LoRA Loader"
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"base_url": "http://localhost:3000/v1",
|
||||
"api_key": "sk-proj-1234567890"
|
||||
},
|
||||
"class_type": "SGLDiffusionServerModel",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion Server Model"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"prompt": "The girl turn the body and spin around in place.",
|
||||
"main": "none",
|
||||
"lighting": "none",
|
||||
"speak_and_recognation": {
|
||||
"__value__": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
}
|
||||
},
|
||||
"class_type": "easy prompt",
|
||||
"_meta": {
|
||||
"title": "Prompt"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"anything": [
|
||||
"1",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "easy showAnything",
|
||||
"_meta": {
|
||||
"title": "Show Any"
|
||||
}
|
||||
},
|
||||
"15": {
|
||||
"inputs": {
|
||||
"positive_prompt": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"negative_prompt": "",
|
||||
"seed": 2435791308,
|
||||
"steps": 50,
|
||||
"cfg": 4,
|
||||
"width": 704,
|
||||
"height": 1280,
|
||||
"num_frames": 16,
|
||||
"fps": 16,
|
||||
"seconds": 1,
|
||||
"enable_teacache": false,
|
||||
"sgld_client": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"17",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SGLDiffusionGenerateVideo",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion Generate Video"
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/ComfyUI",
|
||||
"format": "auto",
|
||||
"codec": "auto",
|
||||
"video-preview": "",
|
||||
"video": [
|
||||
"15",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "save video"
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"inputs": {
|
||||
"image": "tmpe_w0bd_0.jpg"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "load image"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"base_url": "http://localhost:3000/v1",
|
||||
"api_key": "sk-proj-1234567890"
|
||||
},
|
||||
"class_type": "SGLDiffusionServerModel",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion Server Model"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"prompt": "a bicycle, illustration in the style of SMPL, thick black lines on a white background",
|
||||
"main": "none",
|
||||
"lighting": "none",
|
||||
"speak_and_recognation": {
|
||||
"__value__": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
}
|
||||
},
|
||||
"class_type": "easy prompt",
|
||||
"_meta": {
|
||||
"title": "Prompt"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"anything": [
|
||||
"1",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "easy showAnything",
|
||||
"_meta": {
|
||||
"title": "Show Any"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": [
|
||||
"6",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "save image"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"positive_prompt": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"negative_prompt": "",
|
||||
"seed": 4215918563,
|
||||
"steps": 50,
|
||||
"cfg": 4,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"enable_teacache": false,
|
||||
"sgld_client": [
|
||||
"11",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"14",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SGLDiffusionGenerateImage",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion Generate Image"
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"inputs": {
|
||||
"lora_name": "dvyio/flux-lora-simple-illustration",
|
||||
"lora_nickname": "",
|
||||
"target": "all",
|
||||
"sgld_client": [
|
||||
"1",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SGLDiffusionSetLora",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion Set LoRA"
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1,
|
||||
"color": 0
|
||||
},
|
||||
"class_type": "EmptyImage",
|
||||
"_meta": {
|
||||
"title": "empty image"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 3338398,
|
||||
"steps": 9,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"28",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"13",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": "cute anime style girl with massive fluffy fennec ears and a big fluffy tail blonde messy long hair blue eyes wearing a maid outfit with a long black gold leaf pattern dress and a white apron, it is a postcard held by a hand in front of a beautiful realistic city at sunset and there is cursive writing that says \"ZImage, Now in ComfyUI\"",
|
||||
"speak_and_recognation": {
|
||||
"__value__": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"clip": [
|
||||
"18",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Positive Prompt)"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"text": "blurry ugly bad",
|
||||
"speak_and_recognation": {
|
||||
"__value__": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"clip": [
|
||||
"18",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Negative Prompt)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"17",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": {
|
||||
"title": "EmptySD3LatentImage"
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"inputs": {
|
||||
"vae_name": "ae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAE Loader"
|
||||
}
|
||||
},
|
||||
"18": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "lumina2",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIP Loader"
|
||||
}
|
||||
},
|
||||
"28": {
|
||||
"inputs": {
|
||||
"unet_name": "z_image_turbo_bf16.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "SGLDUNETLoader",
|
||||
"_meta": {
|
||||
"title": "SGLDiffusion UNET Loader"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# SGLang Diffusion Realtime WebUI
|
||||
|
||||
Standalone browser demo for `/v1/realtime_video/generate`.
|
||||
|
||||
Open `index.html` directly in a browser, point it at an SGLang Diffusion server,
|
||||
and generate. The app sends msgpack init / event messages and renders lossless
|
||||
raw RGB frame batches on a canvas.
|
||||
|
||||
The first version is intentionally static: no npm install, no build step, and no
|
||||
server-side dependencies. Presets are UI-side templates for prompt, LingBot
|
||||
example images, album artwork references, and session parameters. The default
|
||||
preset preloads a reference image so the demo can be tested without a file
|
||||
upload.
|
||||
|
||||
By default, `Continuous session` is enabled for long-running camera control.
|
||||
Keyboard and pointer controls send state transitions instead of scripted preset
|
||||
actions. The telemetry `Chunk wait` measures request-to-chunk arrival time, not
|
||||
client-side RGB decode time. Continuous playback adapts to the measured chunk
|
||||
production rate so the canvas does not play a chunk at target FPS and then sit
|
||||
on the last frame while waiting for the next chunk.
|
||||
|
||||
The interface shape follows camera-control-first video playgrounds such as
|
||||
Reactor LingBot: reference image, scene prompt, enhancement, clip controls,
|
||||
move/look camera controls, recordings history, and model telemetry.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
const RAW_RGB_CONTENT_TYPE = "application/x-raw-rgb";
|
||||
const RAW_RGB_DELTA_GZIP_CONTENT_TYPE = "application/x-raw-rgb-delta-gzip";
|
||||
const RAW_RGBA_DELTA_GZIP_CONTENT_TYPE = "application/x-raw-rgba-delta-gzip";
|
||||
const WEBP_FRAME_CONTENT_TYPE = "image/webp";
|
||||
const JPEG_FRAME_CONTENT_TYPE = "image/jpeg";
|
||||
|
||||
let lastFrame = null;
|
||||
|
||||
function reset() {
|
||||
lastFrame = null;
|
||||
}
|
||||
|
||||
async function gunzipBytes(payload) {
|
||||
if (typeof DecompressionStream === "undefined") {
|
||||
throw new Error("This browser does not support gzip stream decoding");
|
||||
}
|
||||
const stream = new Blob([payload]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
async function restoreDeltaGzipFrames(header, payload) {
|
||||
const frameBytes = Number(header.bytes_per_frame);
|
||||
const count = Number(header.num_frames);
|
||||
const expectedSize = frameBytes * count;
|
||||
const restored = await gunzipBytes(payload);
|
||||
if (restored.length !== expectedSize) {
|
||||
throw new Error(`delta payload size mismatch: expected ${expectedSize}, got ${restored.length}`);
|
||||
}
|
||||
|
||||
let previous = header.delta_reference === "previous-frame" ? lastFrame : null;
|
||||
if (header.delta_reference === "previous-frame") {
|
||||
if (!previous) throw new Error("Missing previous frame for delta payload");
|
||||
if (previous.byteLength !== frameBytes) {
|
||||
throw new Error("Previous frame size does not match current delta payload");
|
||||
}
|
||||
}
|
||||
|
||||
for (let f = 0; f < count; f++) {
|
||||
const offset = f * frameBytes;
|
||||
if (previous) {
|
||||
for (let i = 0; i < frameBytes; i++) restored[offset + i] ^= previous[i];
|
||||
}
|
||||
previous = restored.slice(offset, offset + frameBytes);
|
||||
}
|
||||
lastFrame = previous;
|
||||
return restored;
|
||||
}
|
||||
|
||||
function rawFramesToRgbaBuffers(header, payload) {
|
||||
const width = Number(header.width);
|
||||
const height = Number(header.height);
|
||||
const channels = Number(header.channels);
|
||||
const count = Number(header.num_frames);
|
||||
const frameBytes = Number(header.bytes_per_frame);
|
||||
const pixels = width * height;
|
||||
const buffers = [];
|
||||
|
||||
for (let f = 0; f < count; f++) {
|
||||
const offset = f * frameBytes;
|
||||
if (channels === 4) {
|
||||
buffers.push(payload.buffer.slice(
|
||||
payload.byteOffset + offset,
|
||||
payload.byteOffset + offset + frameBytes,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
const rgba = new Uint8ClampedArray(pixels * 4);
|
||||
let src = offset;
|
||||
let dst = 0;
|
||||
for (let p = 0; p < pixels; p++) {
|
||||
rgba[dst++] = payload[src++];
|
||||
rgba[dst++] = payload[src++];
|
||||
rgba[dst++] = payload[src++];
|
||||
src += channels - 3;
|
||||
rgba[dst++] = 255;
|
||||
}
|
||||
buffers.push(rgba.buffer);
|
||||
}
|
||||
return buffers;
|
||||
}
|
||||
|
||||
function splitEncodedPayload(header, payload) {
|
||||
const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
|
||||
const lengths = Array.isArray(header.payload_lengths) && header.payload_lengths.length
|
||||
? header.payload_lengths.map(Number)
|
||||
: [bytes.byteLength];
|
||||
const payloads = [];
|
||||
let offset = 0;
|
||||
for (const length of lengths) {
|
||||
payloads.push(bytes.buffer.slice(
|
||||
bytes.byteOffset + offset,
|
||||
bytes.byteOffset + offset + length,
|
||||
));
|
||||
offset += length;
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
|
||||
async function encodedFramesToImageBitmaps(header, payload) {
|
||||
if (typeof createImageBitmap === "undefined") {
|
||||
throw new Error("This browser does not support worker image decoding");
|
||||
}
|
||||
|
||||
const frames = await Promise.all(splitEncodedPayload(header, payload).map((framePayload) => (
|
||||
createImageBitmap(new Blob([framePayload], { type: header.content_type }))
|
||||
)));
|
||||
return {
|
||||
width: frames[0]?.width || 0,
|
||||
height: frames[0]?.height || 0,
|
||||
frame_type: "bitmap",
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
async function decode(header, payload) {
|
||||
let rawPayload;
|
||||
if (
|
||||
header.content_type === WEBP_FRAME_CONTENT_TYPE ||
|
||||
header.content_type === JPEG_FRAME_CONTENT_TYPE
|
||||
) {
|
||||
const decoded = await encodedFramesToImageBitmaps(header, payload);
|
||||
return {
|
||||
id: header.__decode_id,
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
chunk: Number(header.chunk_index),
|
||||
frame_type: decoded.frame_type,
|
||||
frames: decoded.frames,
|
||||
};
|
||||
} else if (header.content_type === RAW_RGB_CONTENT_TYPE) {
|
||||
rawPayload = new Uint8Array(payload);
|
||||
const frameBytes = Number(header.bytes_per_frame);
|
||||
const count = Number(header.num_frames);
|
||||
lastFrame = count > 0
|
||||
? rawPayload.slice((count - 1) * frameBytes, count * frameBytes)
|
||||
: null;
|
||||
} else if (
|
||||
header.content_type === RAW_RGB_DELTA_GZIP_CONTENT_TYPE ||
|
||||
header.content_type === RAW_RGBA_DELTA_GZIP_CONTENT_TYPE
|
||||
) {
|
||||
rawPayload = await restoreDeltaGzipFrames(header, payload);
|
||||
} else {
|
||||
throw new Error(`Unsupported content type ${header.content_type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: header.__decode_id,
|
||||
width: Number(header.width),
|
||||
height: Number(header.height),
|
||||
chunk: Number(header.chunk_index),
|
||||
frames: rawFramesToRgbaBuffers(header, rawPayload),
|
||||
};
|
||||
}
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const message = event.data;
|
||||
try {
|
||||
if (message.type === "reset") {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const result = await decode(message.header, message.payload);
|
||||
self.postMessage({ type: "decoded", ...result }, result.frames);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
id: message.header?.__decode_id,
|
||||
message: error.message || "decode failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>sglang-diffusion Realtime Studio</title>
|
||||
<link rel="stylesheet" href="./styles.css?v=realtime-record-v49" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="panel controls" aria-label="Session controls">
|
||||
<div class="brand">
|
||||
<span>sglang-diffusion</span>
|
||||
<strong>Realtime Studio</strong>
|
||||
</div>
|
||||
<label>Server<input id="serverUrl" value="ws://127.0.0.1:30000/v1/realtime_video/generate" /></label>
|
||||
<label>Model<input id="model" value="" placeholder="auto from /v1/models" /></label>
|
||||
<div class="section-title">Reference</div>
|
||||
<label class="reference-upload">
|
||||
<input id="firstFrame" type="file" accept="image/*" />
|
||||
<canvas id="referencePreview" width="320" height="180"></canvas>
|
||||
<span id="referenceName">Preset reference</span>
|
||||
</label>
|
||||
<div class="section-title">Generate the scene</div>
|
||||
<label>Prompt<textarea id="prompt" rows="4">A cinematic handheld shot of a quiet city street at dusk, soft reflections, natural motion.</textarea></label>
|
||||
<button id="enhanceBtn" class="wide">Enhance</button>
|
||||
<div class="split">
|
||||
<label>Size<input id="size" value="832x480" /></label>
|
||||
<label>FPS<input id="fps" type="number" value="25" min="1" max="60" /></label>
|
||||
</div>
|
||||
<div class="split">
|
||||
<label>Frames<input id="numFrames" type="number" value="9" min="5" step="4" /></label>
|
||||
<label>Seed<input id="seed" type="number" value="42" /></label>
|
||||
</div>
|
||||
<div class="split">
|
||||
<label>Steps<input id="steps" type="number" value="4" min="1" /></label>
|
||||
<label>Guidance<input id="guidance" type="number" value="1" step="0.1" /></label>
|
||||
</div>
|
||||
<div class="split">
|
||||
<label>Sink<input id="sinkSize" type="number" value="9" min="0" /></label>
|
||||
<label>Window<input id="windowFrames" type="number" value="18" min="1" /></label>
|
||||
</div>
|
||||
<div class="split">
|
||||
<label>Transport
|
||||
<select id="transportFormat">
|
||||
<option value="webp" selected>WebP preview</option>
|
||||
<option value="jpeg">JPEG preview</option>
|
||||
<option value="">Lossless delta</option>
|
||||
<option value="raw">Raw RGB</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Quality<input id="transportQuality" type="number" value="95" min="1" max="100" /></label>
|
||||
</div>
|
||||
<div class="split output-options">
|
||||
<label class="toggle-row"><input id="superResolution" type="checkbox" />Super resolution</label>
|
||||
<label>Scale
|
||||
<select id="upscalingScale">
|
||||
<option value="2" selected>2x</option>
|
||||
<option value="4">4x</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>SR model
|
||||
<select id="upscalingModel">
|
||||
<option value="">Quality x2</option>
|
||||
<option
|
||||
value="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth"
|
||||
selected
|
||||
>
|
||||
Fast general
|
||||
</option>
|
||||
<option value="/scratch/realesr-animevideov3.pth">Fast anime</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toggle-row"><input id="frameInterpolation" type="checkbox" />Smooth 2x frames</label>
|
||||
<label class="toggle-row"><input id="continuous" type="checkbox" checked />Continuous session</label>
|
||||
<div class="actions">
|
||||
<button id="connectBtn" class="primary">Generate</button>
|
||||
<button id="stopBtn">Close session</button>
|
||||
</div>
|
||||
<button id="sendPromptBtn" class="wide">Send prompt update</button>
|
||||
</section>
|
||||
|
||||
<section class="workspace" aria-label="Realtime workspace">
|
||||
<section class="stage" aria-label="Realtime preview">
|
||||
<div class="topbar">
|
||||
<span id="statusDot" class="dot"></span>
|
||||
<span id="statusText">Idle</span>
|
||||
<span id="chunkText">chunk -</span>
|
||||
<button id="recordBtn" class="record-button" type="button" aria-pressed="false" title="Record preview">
|
||||
<span class="record-button-icon" aria-hidden="true"></span>
|
||||
<span id="recordLabel">Record</span>
|
||||
<span id="recordDuration" class="record-button-duration">00:00</span>
|
||||
</button>
|
||||
<span class="topbar-spacer"></span>
|
||||
<label class="preview-scale-control">Preview
|
||||
<input id="previewScale" type="range" min="80" max="170" value="120" />
|
||||
<b id="previewScaleText">120%</b>
|
||||
</label>
|
||||
<span class="stage-stat">output <b id="outputSizeText">832x480</b></span>
|
||||
<span class="stage-stat">render <b id="renderFps">0</b> fps</span>
|
||||
<span class="stage-stat">source <b id="theoreticalFpsText">-</b></span>
|
||||
<span class="stage-stat">buffer <b id="stageLatencyText">-</b></span>
|
||||
</div>
|
||||
<div class="preview-frame">
|
||||
<canvas id="viewport" width="1280" height="720"></canvas>
|
||||
<div id="previewOverlay" class="preview-overlay" aria-hidden="true">
|
||||
<span class="preview-loader"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage-controls" aria-label="Camera controls">
|
||||
<div class="control-cluster" aria-label="Move camera">
|
||||
<span class="control-title">Move</span>
|
||||
<div class="camera-pad move-pad">
|
||||
<span></span>
|
||||
<button data-action="w" data-key="W">Forward</button>
|
||||
<span></span>
|
||||
<button data-action="a" data-key="A">Left</button>
|
||||
<button data-action="s" data-key="S">Back</button>
|
||||
<button data-action="d" data-key="D">Right</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-cluster" aria-label="Look around">
|
||||
<span class="control-title">Look</span>
|
||||
<div class="camera-pad look-pad">
|
||||
<span></span>
|
||||
<button data-action="i" data-key="↑">Pitch +</button>
|
||||
<span></span>
|
||||
<button data-action="j" data-key="←">Yaw -</button>
|
||||
<button data-action="k" data-key="↓">Pitch -</button>
|
||||
<button data-action="l" data-key="→">Yaw +</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<span id="queueText">queue 0</span>
|
||||
<span id="frameText">frames 0</span>
|
||||
<span id="byteText">0 MB</span>
|
||||
</div>
|
||||
<div class="telemetry stage-telemetry">
|
||||
<span>Payload<b id="payloadMode">webp</b></span>
|
||||
<span>Server send<b id="serverSendText">-</b></span>
|
||||
<span>Chunk bytes<b id="chunkPayloadText">-</b></span>
|
||||
<span>Chunk wait<b id="latencyText">-</b></span>
|
||||
<span>Decode<b id="decodeText">-</b></span>
|
||||
<span>Display lag<b id="displayLagText">-</b></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel presets" aria-label="Presets and camera">
|
||||
<div class="section-title">LingBot</div>
|
||||
<div class="spec-grid">
|
||||
<span><b>25 fps</b> target</span>
|
||||
<span><b>chunked</b> stream</span>
|
||||
<span><b>480p/720p</b></span>
|
||||
<span><b>Cam + Act</b></span>
|
||||
</div>
|
||||
<div class="section-title">Presets</div>
|
||||
<div id="presetList" class="preset-list"></div>
|
||||
<div class="section-title">History</div>
|
||||
<div id="historyList" class="history-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script src="./playback_controller.js?v=realtime-playback-v13"></script>
|
||||
<script src="./app.js?v=realtime-record-v75"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,517 @@
|
||||
(function attachRealtimePlaybackController(global) {
|
||||
const DEFAULT_CONFIG = {
|
||||
targetFps: 25,
|
||||
minSourceFps: 1,
|
||||
serverFpsAlphaUp: 0.28,
|
||||
serverFpsAlphaDown: 0.2,
|
||||
deliveryFpsAlphaUp: 0.08,
|
||||
deliveryFpsAlphaDown: 0.55,
|
||||
targetLeadChunkRatio: 1.5,
|
||||
minTargetLeadMs: 1500,
|
||||
maxTargetLeadMs: 2600,
|
||||
maxLeadExtraChunkRatio: 8.0,
|
||||
startLeadChunkRatio: 1.85,
|
||||
minStartLeadMs: 1700,
|
||||
resumeLeadChunkRatio: 2.5,
|
||||
minResumeLeadMs: 1000,
|
||||
maxResumeLeadMs: 1800,
|
||||
rebufferLeadBoostMs: 250,
|
||||
rebufferLeadBoostDecayMsPerSecond: 120,
|
||||
deliveryLeadBoostDecayMsPerSecond: 80,
|
||||
maxDeliveryLeadBoostMs: 2000,
|
||||
deliveryStallExpectedMultiplier: 1.25,
|
||||
receiveStallPlaybackRateMin: 0.65,
|
||||
receiveStallPlaybackRateSlewPerSecond: 0.5,
|
||||
lowWaterRatio: 0.4,
|
||||
playbackRateGain: 0.14,
|
||||
playbackRateMin: 0.92,
|
||||
playbackRateMax: 1.08,
|
||||
emergencyPlaybackRateMin: 0.9,
|
||||
emergencyPlaybackRateMax: 1.12,
|
||||
playbackRateSlewPerSecond: 0.08,
|
||||
eventCutoverMaxMs: 420,
|
||||
eventCutoverMaxFrames: 10,
|
||||
settleEventCutoverMaxMs: 720,
|
||||
settleEventCutoverMaxFrames: 18,
|
||||
startupWarmupMinMs: 1500,
|
||||
startupWarmupExpectedMultiplier: 3,
|
||||
};
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function finitePositive(value) {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
class RealtimePlaybackController {
|
||||
constructor(config = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.reset({ targetFps: this.config.targetFps });
|
||||
}
|
||||
|
||||
reset({ targetFps } = {}) {
|
||||
this.targetFps = Math.max(1, Number(targetFps || this.config.targetFps));
|
||||
this.sourceFps = this.targetFps;
|
||||
this.serverFps = this.targetFps;
|
||||
this.deliveryFps = this.targetFps;
|
||||
this.hasServerSample = false;
|
||||
this.hasDeliverySample = false;
|
||||
this.latestChunkDurationMs = 1000 / this.targetFps;
|
||||
this.latestChunkFrames = 1;
|
||||
this.playbackRate = 1;
|
||||
this.renderFps = this.targetFps;
|
||||
this.queue = [];
|
||||
this.lastDrawAt = 0;
|
||||
this.lastRateUpdateAt = 0;
|
||||
this.renderedFrames = 0;
|
||||
this.droppedFrames = 0;
|
||||
this.buffering = true;
|
||||
this.pendingEventId = 0;
|
||||
this.pendingEventSentAt = 0;
|
||||
this.pendingEventCutoverMode = "motion";
|
||||
this.lastDropReason = "";
|
||||
this.lastDropAt = 0;
|
||||
this.lastDropCount = 0;
|
||||
this.rebufferLeadBoostMs = 0;
|
||||
this.deliveryLeadBoostMs = 0;
|
||||
this.chunkReceives = new Map();
|
||||
this.serverStatChunks = new Set();
|
||||
this.lastFinalReceiveAt = 0;
|
||||
this.receiveStalled = false;
|
||||
}
|
||||
|
||||
setTargetFps(targetFps) {
|
||||
const nextTargetFps = Math.max(1, Number(targetFps || this.config.targetFps));
|
||||
this.targetFps = nextTargetFps;
|
||||
if (!this.hasServerSample && !this.hasDeliverySample) {
|
||||
this.serverFps = nextTargetFps;
|
||||
this.deliveryFps = nextTargetFps;
|
||||
this.sourceFps = nextTargetFps;
|
||||
this.renderFps = nextTargetFps;
|
||||
} else {
|
||||
this.serverFps = clamp(this.serverFps, this.config.minSourceFps, nextTargetFps);
|
||||
this.deliveryFps = clamp(this.deliveryFps, this.config.minSourceFps, nextTargetFps);
|
||||
this.sourceFps = clamp(this.sourceFps, this.config.minSourceFps, nextTargetFps);
|
||||
this.renderFps = this.sourceFps * this.playbackRate;
|
||||
}
|
||||
this.latestChunkDurationMs = Math.max(this.latestChunkDurationMs, 1000 / this.targetFps);
|
||||
}
|
||||
|
||||
clear() {
|
||||
const frames = this.queue.splice(0);
|
||||
this.lastDrawAt = 0;
|
||||
this.buffering = true;
|
||||
return frames;
|
||||
}
|
||||
|
||||
noteInputEvent(eventId, now, { cutoverMode = "motion" } = {}) {
|
||||
this.pendingEventId = Number(eventId || 0);
|
||||
this.pendingEventSentAt = Number(now || 0);
|
||||
this.pendingEventCutoverMode = cutoverMode;
|
||||
}
|
||||
|
||||
observeServerStats(stats, now) {
|
||||
const chunkIndex = Number(stats.chunk_index || 0);
|
||||
const numFrames = Number(stats.num_frames || 0);
|
||||
const chunkTotalMs = Number(stats.chunk_total_ms || 0);
|
||||
if (numFrames > 0 && chunkTotalMs > 0) {
|
||||
this.serverStatChunks.add(chunkIndex);
|
||||
if (this.serverStatChunks.size > 128) {
|
||||
this.serverStatChunks.delete(this.serverStatChunks.values().next().value);
|
||||
}
|
||||
const expectedMs = numFrames / Math.max(1, this.targetFps) * 1000;
|
||||
const isStartupWarmup =
|
||||
chunkIndex === 0 &&
|
||||
chunkTotalMs > Math.max(
|
||||
this.config.startupWarmupMinMs,
|
||||
expectedMs * this.config.startupWarmupExpectedMultiplier,
|
||||
);
|
||||
if (isStartupWarmup) return this.snapshot();
|
||||
this.#observeFpsSample("server", {
|
||||
fps: numFrames / (chunkTotalMs / 1000),
|
||||
frameCount: numFrames,
|
||||
durationMs: chunkTotalMs,
|
||||
now,
|
||||
});
|
||||
}
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
enqueueDecodedFrames(header, frames, now) {
|
||||
const chunkIndex = Number(header.chunk_index || 0);
|
||||
const eventId = Number(header.event_id || 0);
|
||||
const receivedAt = Number(header.__received_at || now);
|
||||
const preparedFrames = frames.map((frame) => ({
|
||||
...frame,
|
||||
chunk: Number(frame.chunk ?? chunkIndex),
|
||||
chunkIndex,
|
||||
eventId,
|
||||
}));
|
||||
const droppedFrames = [];
|
||||
let cutover = null;
|
||||
|
||||
if (this.pendingEventId && eventId >= this.pendingEventId) {
|
||||
const oldEventFrameCount = this.#oldEventFrameCount(eventId);
|
||||
const graceFrames = this.#eventGraceFrames();
|
||||
const dropCount = Math.max(0, oldEventFrameCount - graceFrames);
|
||||
if (dropCount > 0) {
|
||||
droppedFrames.push(...this.queue.splice(graceFrames, dropCount));
|
||||
this.#recordDrop(dropCount, "event cutover", now);
|
||||
}
|
||||
cutover = {
|
||||
eventId,
|
||||
latencyMs: this.pendingEventSentAt ? now - this.pendingEventSentAt : 0,
|
||||
};
|
||||
this.pendingEventId = 0;
|
||||
this.pendingEventSentAt = 0;
|
||||
this.pendingEventCutoverMode = "motion";
|
||||
}
|
||||
|
||||
this.queue.push(...preparedFrames);
|
||||
this.#observeChunkArrival(header, preparedFrames.length, receivedAt, now);
|
||||
droppedFrames.push(...this.#trimBacklog(now));
|
||||
return { droppedFrames, cutover, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
render(now, { hasPendingInput = true } = {}) {
|
||||
this.#decayRebufferBoost(now);
|
||||
this.#updateReceiveStallGuard(now);
|
||||
const droppedFrames = this.#trimBacklog(now);
|
||||
if (!this.queue.length) {
|
||||
if (this.renderedFrames && hasPendingInput && !this.buffering) {
|
||||
this.buffering = true;
|
||||
this.rebufferLeadBoostMs = Math.max(
|
||||
this.rebufferLeadBoostMs,
|
||||
this.config.rebufferLeadBoostMs,
|
||||
);
|
||||
}
|
||||
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
const bufferMs = this.bufferDurationMs;
|
||||
if (
|
||||
hasPendingInput &&
|
||||
this.receiveStalled &&
|
||||
this.renderedFrames &&
|
||||
bufferMs < this.targetLeadMs
|
||||
) {
|
||||
this.buffering = true;
|
||||
this.lastDrawAt = 0;
|
||||
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
if (
|
||||
hasPendingInput &&
|
||||
this.buffering &&
|
||||
bufferMs < (this.renderedFrames ? this.#resumeLeadMs() : this.#startLeadMs())
|
||||
) {
|
||||
this.buffering = true;
|
||||
this.lastDrawAt = 0;
|
||||
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
if (this.buffering) {
|
||||
this.buffering = false;
|
||||
this.lastDrawAt = 0;
|
||||
}
|
||||
|
||||
this.#updatePlaybackRate(now);
|
||||
const targetMs = 1000 / Math.max(1, this.renderFps);
|
||||
const elapsedMs = this.lastDrawAt ? now - this.lastDrawAt : targetMs;
|
||||
if (elapsedMs < targetMs) {
|
||||
return { action: "wait", droppedFrames, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
const frame = this.queue.shift();
|
||||
this.renderedFrames += 1;
|
||||
this.lastDrawAt = !this.lastDrawAt || elapsedMs > targetMs * 4
|
||||
? now
|
||||
: now - (elapsedMs % targetMs);
|
||||
return { action: "draw", frame, droppedFrames, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
get queuedFrames() {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
get bufferDurationMs() {
|
||||
return this.queue.length / Math.max(1, this.sourceFps) * 1000;
|
||||
}
|
||||
|
||||
get targetLeadMs() {
|
||||
const base = clamp(
|
||||
this.latestChunkDurationMs * this.config.targetLeadChunkRatio,
|
||||
this.config.minTargetLeadMs,
|
||||
this.config.maxTargetLeadMs,
|
||||
);
|
||||
return clamp(
|
||||
base + this.rebufferLeadBoostMs + this.deliveryLeadBoostMs,
|
||||
this.config.minTargetLeadMs,
|
||||
this.config.maxTargetLeadMs +
|
||||
this.config.rebufferLeadBoostMs +
|
||||
this.config.maxDeliveryLeadBoostMs,
|
||||
);
|
||||
}
|
||||
|
||||
get maxLeadMs() {
|
||||
return this.targetLeadMs + this.latestChunkDurationMs * this.config.maxLeadExtraChunkRatio;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
queueFrames: this.queue.length,
|
||||
bufferMs: this.bufferDurationMs,
|
||||
targetLeadMs: this.targetLeadMs,
|
||||
maxLeadMs: this.maxLeadMs,
|
||||
sourceFps: this.sourceFps,
|
||||
serverFps: this.serverFps,
|
||||
deliveryFps: this.deliveryFps,
|
||||
targetFps: this.targetFps,
|
||||
renderFps: this.renderFps,
|
||||
playbackRate: this.playbackRate,
|
||||
droppedFrames: this.droppedFrames,
|
||||
lastDropAt: this.lastDropAt,
|
||||
lastDropCount: this.lastDropCount,
|
||||
buffering: this.buffering,
|
||||
lastDropReason: this.lastDropReason,
|
||||
};
|
||||
}
|
||||
|
||||
#observeFpsSample(kind, { fps, frameCount, durationMs, now }) {
|
||||
if (!finitePositive(fps)) return;
|
||||
const cappedFps = clamp(fps, this.config.minSourceFps, this.targetFps);
|
||||
const isDelivery = kind === "delivery";
|
||||
const currentFps = isDelivery ? this.deliveryFps : this.serverFps;
|
||||
const hasSample = isDelivery ? this.hasDeliverySample : this.hasServerSample;
|
||||
let nextFps;
|
||||
if (!hasSample) {
|
||||
nextFps = cappedFps;
|
||||
} else {
|
||||
const alpha = cappedFps > currentFps
|
||||
? (isDelivery ? this.config.deliveryFpsAlphaUp : this.config.serverFpsAlphaUp)
|
||||
: (isDelivery ? this.config.deliveryFpsAlphaDown : this.config.serverFpsAlphaDown);
|
||||
nextFps = currentFps * (1 - alpha) + cappedFps * alpha;
|
||||
}
|
||||
if (isDelivery) {
|
||||
this.deliveryFps = nextFps;
|
||||
this.hasDeliverySample = true;
|
||||
this.#observeDeliveryJitter(frameCount, durationMs);
|
||||
} else {
|
||||
this.serverFps = nextFps;
|
||||
this.hasServerSample = true;
|
||||
}
|
||||
const effectiveFps = this.hasServerSample
|
||||
? this.serverFps
|
||||
: (this.hasDeliverySample ? this.deliveryFps : this.targetFps);
|
||||
this.sourceFps = clamp(effectiveFps, this.config.minSourceFps, this.targetFps);
|
||||
if (!isDelivery || !this.hasServerSample) {
|
||||
this.latestChunkFrames = Math.max(1, Number(frameCount || this.latestChunkFrames));
|
||||
this.latestChunkDurationMs = clamp(
|
||||
Number(durationMs || (this.latestChunkFrames / Math.max(1, this.sourceFps) * 1000)),
|
||||
1000 / Math.max(1, this.targetFps),
|
||||
2500,
|
||||
);
|
||||
}
|
||||
this.#updatePlaybackRate(now);
|
||||
}
|
||||
|
||||
#observeDeliveryJitter(frameCount, durationMs) {
|
||||
if (!this.hasServerSample || !finitePositive(durationMs)) return;
|
||||
const expectedMs = Number(frameCount || 0) / Math.max(1, this.serverFps) * 1000;
|
||||
if (expectedMs <= 0) return;
|
||||
if (durationMs <= expectedMs * this.config.deliveryStallExpectedMultiplier) return;
|
||||
const boostMs = clamp(
|
||||
durationMs - expectedMs,
|
||||
0,
|
||||
this.config.maxDeliveryLeadBoostMs,
|
||||
);
|
||||
this.deliveryLeadBoostMs = Math.max(this.deliveryLeadBoostMs, boostMs);
|
||||
}
|
||||
|
||||
#updateReceiveStallGuard(now) {
|
||||
this.receiveStalled = false;
|
||||
if (!this.lastFinalReceiveAt || !this.hasServerSample) return;
|
||||
const elapsedMs = now - this.lastFinalReceiveAt;
|
||||
const expectedMs = Math.max(
|
||||
this.latestChunkDurationMs,
|
||||
this.latestChunkFrames / Math.max(1, this.serverFps) * 1000,
|
||||
);
|
||||
if (elapsedMs <= expectedMs * this.config.deliveryStallExpectedMultiplier) return;
|
||||
this.receiveStalled = true;
|
||||
this.deliveryLeadBoostMs = Math.max(
|
||||
this.deliveryLeadBoostMs,
|
||||
clamp(elapsedMs - expectedMs, 0, this.config.maxDeliveryLeadBoostMs),
|
||||
);
|
||||
}
|
||||
|
||||
#observeChunkArrival(header, frameCount, receivedAt, now) {
|
||||
const chunkIndex = Number(header.chunk_index || 0);
|
||||
const state = this.chunkReceives.get(chunkIndex) || {
|
||||
firstAt: receivedAt,
|
||||
frames: 0,
|
||||
};
|
||||
state.frames += Number(frameCount || 0);
|
||||
state.lastAt = receivedAt;
|
||||
this.chunkReceives.set(chunkIndex, state);
|
||||
|
||||
const frameBatchIndex = Number(header.frame_batch_index || 0);
|
||||
const numFrameBatches = Number(header.num_frame_batches || 1);
|
||||
const isFinalFrameBatch =
|
||||
Boolean(header.is_final_frame_batch) ||
|
||||
frameBatchIndex + 1 >= numFrameBatches;
|
||||
if (!isFinalFrameBatch) return;
|
||||
const durationMs = this.lastFinalReceiveAt
|
||||
? receivedAt - this.lastFinalReceiveAt
|
||||
: 0;
|
||||
this.lastFinalReceiveAt = receivedAt;
|
||||
if (state.frames > 0 && durationMs > 0) {
|
||||
this.#observeFpsSample("delivery", {
|
||||
fps: state.frames / (durationMs / 1000),
|
||||
frameCount: state.frames,
|
||||
durationMs,
|
||||
now,
|
||||
});
|
||||
}
|
||||
this.chunkReceives.delete(chunkIndex);
|
||||
}
|
||||
|
||||
#updatePlaybackRate(now) {
|
||||
const bufferMs = this.bufferDurationMs;
|
||||
const targetLeadMs = Math.max(1, this.targetLeadMs);
|
||||
const error = (bufferMs - targetLeadMs) / targetLeadMs;
|
||||
const emergency =
|
||||
bufferMs > this.maxLeadMs ||
|
||||
bufferMs < targetLeadMs * this.config.lowWaterRatio ||
|
||||
(this.receiveStalled && bufferMs < targetLeadMs);
|
||||
const minRate = emergency
|
||||
? (
|
||||
this.receiveStalled
|
||||
? this.config.receiveStallPlaybackRateMin
|
||||
: this.config.emergencyPlaybackRateMin
|
||||
)
|
||||
: this.config.playbackRateMin;
|
||||
const maxRate = this.receiveStalled && bufferMs < targetLeadMs
|
||||
? 1
|
||||
: emergency
|
||||
? this.config.emergencyPlaybackRateMax
|
||||
: this.config.playbackRateMax;
|
||||
const desiredRate = clamp(
|
||||
1 + error * this.config.playbackRateGain,
|
||||
minRate,
|
||||
maxRate,
|
||||
);
|
||||
|
||||
if (!this.lastRateUpdateAt) {
|
||||
this.playbackRate = desiredRate;
|
||||
} else {
|
||||
const dtSeconds = Math.max(0.001, (now - this.lastRateUpdateAt) / 1000);
|
||||
const slewPerSecond = this.receiveStalled
|
||||
? this.config.receiveStallPlaybackRateSlewPerSecond
|
||||
: this.config.playbackRateSlewPerSecond;
|
||||
const maxDelta = slewPerSecond * dtSeconds;
|
||||
this.playbackRate = clamp(
|
||||
desiredRate,
|
||||
this.playbackRate - maxDelta,
|
||||
this.playbackRate + maxDelta,
|
||||
);
|
||||
}
|
||||
this.lastRateUpdateAt = now;
|
||||
this.renderFps = clamp(
|
||||
this.sourceFps * this.playbackRate,
|
||||
this.config.minSourceFps,
|
||||
this.targetFps * this.config.emergencyPlaybackRateMax,
|
||||
);
|
||||
}
|
||||
|
||||
#trimBacklog(now) {
|
||||
const droppedFrames = [];
|
||||
while (this.queue.length && this.bufferDurationMs > this.maxLeadMs) {
|
||||
const firstChunk = this.queue[0].chunkIndex;
|
||||
let dropCount = 0;
|
||||
while (
|
||||
dropCount < this.queue.length &&
|
||||
this.queue[dropCount].chunkIndex === firstChunk
|
||||
) {
|
||||
dropCount += 1;
|
||||
}
|
||||
if (!dropCount || dropCount >= this.queue.length) break;
|
||||
droppedFrames.push(...this.queue.splice(0, dropCount));
|
||||
this.#recordDrop(dropCount, "backlog", now);
|
||||
}
|
||||
return droppedFrames;
|
||||
}
|
||||
|
||||
#oldEventFrameCount(nextEventId) {
|
||||
let count = 0;
|
||||
while (count < this.queue.length && this.queue[count].eventId < nextEventId) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
#eventGraceFrames() {
|
||||
const byTime = Math.max(
|
||||
1,
|
||||
Math.round(this.sourceFps * this.#eventCutoverMaxMs() / 1000),
|
||||
);
|
||||
const byChunkRatio = this.pendingEventCutoverMode === "settle" ? 1.5 : 0.85;
|
||||
const byChunk = Math.max(1, Math.round(this.latestChunkFrames * byChunkRatio));
|
||||
return Math.min(this.#eventCutoverMaxFrames(), byTime, byChunk);
|
||||
}
|
||||
|
||||
#eventCutoverMaxMs() {
|
||||
return this.pendingEventCutoverMode === "settle"
|
||||
? this.config.settleEventCutoverMaxMs
|
||||
: this.config.eventCutoverMaxMs;
|
||||
}
|
||||
|
||||
#eventCutoverMaxFrames() {
|
||||
return this.pendingEventCutoverMode === "settle"
|
||||
? this.config.settleEventCutoverMaxFrames
|
||||
: this.config.eventCutoverMaxFrames;
|
||||
}
|
||||
|
||||
#startLeadMs() {
|
||||
return Math.max(
|
||||
this.config.minStartLeadMs,
|
||||
this.latestChunkDurationMs * this.config.startLeadChunkRatio,
|
||||
this.targetLeadMs,
|
||||
);
|
||||
}
|
||||
|
||||
#resumeLeadMs() {
|
||||
return clamp(
|
||||
this.latestChunkDurationMs * this.config.resumeLeadChunkRatio,
|
||||
this.config.minResumeLeadMs,
|
||||
this.config.maxResumeLeadMs,
|
||||
);
|
||||
}
|
||||
|
||||
#decayRebufferBoost(now) {
|
||||
if ((!this.rebufferLeadBoostMs && !this.deliveryLeadBoostMs) || !this.lastRateUpdateAt) return;
|
||||
const dtSeconds = Math.max(0, (now - this.lastRateUpdateAt) / 1000);
|
||||
this.rebufferLeadBoostMs = Math.max(
|
||||
0,
|
||||
this.rebufferLeadBoostMs - dtSeconds * this.config.rebufferLeadBoostDecayMsPerSecond,
|
||||
);
|
||||
this.deliveryLeadBoostMs = Math.max(
|
||||
0,
|
||||
this.deliveryLeadBoostMs - dtSeconds * this.config.deliveryLeadBoostDecayMsPerSecond,
|
||||
);
|
||||
}
|
||||
|
||||
#recordDrop(count, reason, now) {
|
||||
this.droppedFrames += count;
|
||||
this.lastDropAt = Number(now || 0);
|
||||
this.lastDropCount = count;
|
||||
this.lastDropReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
global.RealtimePlaybackController = RealtimePlaybackController;
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { RealtimePlaybackController };
|
||||
}
|
||||
})(typeof globalThis !== "undefined" ? globalThis : window);
|
||||
@@ -0,0 +1,116 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { RealtimePlaybackController } = require("./playback_controller.js");
|
||||
|
||||
function frames(count, chunk) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
image: { close() {} },
|
||||
chunk,
|
||||
index,
|
||||
}));
|
||||
}
|
||||
|
||||
function enqueueChunk(controller, {
|
||||
chunk,
|
||||
eventId = 0,
|
||||
frameCount = 12,
|
||||
durationMs = 480,
|
||||
now,
|
||||
}) {
|
||||
controller.observeServerStats({
|
||||
chunk_index: chunk,
|
||||
num_frames: frameCount,
|
||||
chunk_total_ms: durationMs,
|
||||
}, now);
|
||||
return controller.enqueueDecodedFrames({
|
||||
chunk_index: chunk,
|
||||
event_id: eventId,
|
||||
num_frames: frameCount,
|
||||
__received_at: now,
|
||||
is_final_frame_batch: true,
|
||||
}, frames(frameCount, chunk), now);
|
||||
}
|
||||
|
||||
function renderFor(controller, startMs, durationMs) {
|
||||
let rendered = 0;
|
||||
for (let now = startMs; now <= startMs + durationMs; now += 16.67) {
|
||||
const decision = controller.render(now, { hasPendingInput: true });
|
||||
if (decision.action === "draw") rendered += 1;
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function stableSourceDoesNotDrop() {
|
||||
const controller = new RealtimePlaybackController({ targetFps: 25 });
|
||||
let now = 0;
|
||||
for (let chunk = 0; chunk < 8; chunk += 1) {
|
||||
now += 480;
|
||||
enqueueChunk(controller, { chunk, now });
|
||||
renderFor(controller, now, 480);
|
||||
}
|
||||
const snapshot = controller.snapshot();
|
||||
assert.equal(snapshot.droppedFrames, 0);
|
||||
assert.ok(snapshot.sourceFps > 24 && snapshot.sourceFps <= 25);
|
||||
}
|
||||
|
||||
function slowServerCapsRenderFps() {
|
||||
const controller = new RealtimePlaybackController({ targetFps: 25 });
|
||||
let now = 0;
|
||||
for (let chunk = 0; chunk < 8; chunk += 1) {
|
||||
now += 1360;
|
||||
enqueueChunk(controller, { chunk, durationMs: 1360, now });
|
||||
renderFor(controller, now, 1360);
|
||||
}
|
||||
const snapshot = controller.snapshot();
|
||||
assert.ok(snapshot.sourceFps > 8 && snapshot.sourceFps < 10);
|
||||
assert.ok(snapshot.renderFps <= 10);
|
||||
}
|
||||
|
||||
function backlogDropsContiguousOldFrames() {
|
||||
const controller = new RealtimePlaybackController({ targetFps: 25 });
|
||||
let now = 100;
|
||||
for (let chunk = 0; chunk < 13; chunk += 1) {
|
||||
enqueueChunk(controller, { chunk, now, durationMs: 480 });
|
||||
now += 20;
|
||||
}
|
||||
const snapshot = controller.snapshot();
|
||||
assert.ok(snapshot.droppedFrames > 0);
|
||||
assert.equal(snapshot.lastDropReason, "backlog");
|
||||
}
|
||||
|
||||
function eventCutoverKeepsShortGrace() {
|
||||
const controller = new RealtimePlaybackController({ targetFps: 25 });
|
||||
enqueueChunk(controller, { chunk: 1, frameCount: 24, durationMs: 960, now: 1000 });
|
||||
controller.noteInputEvent(5, 1050);
|
||||
const result = enqueueChunk(controller, {
|
||||
chunk: 2,
|
||||
eventId: 5,
|
||||
frameCount: 12,
|
||||
durationMs: 480,
|
||||
now: 1150,
|
||||
});
|
||||
assert.ok(result.cutover);
|
||||
assert.ok(result.droppedFrames.length >= 14);
|
||||
assert.equal(controller.queue[0].chunk, 1);
|
||||
assert.equal(controller.queue[0].index, 0);
|
||||
}
|
||||
|
||||
function settleEventCutoverKeepsWiderGrace() {
|
||||
const controller = new RealtimePlaybackController({ targetFps: 25 });
|
||||
enqueueChunk(controller, { chunk: 1, frameCount: 24, durationMs: 960, now: 1000 });
|
||||
controller.noteInputEvent(5, 1050, { cutoverMode: "settle" });
|
||||
const result = enqueueChunk(controller, {
|
||||
chunk: 2,
|
||||
eventId: 5,
|
||||
frameCount: 12,
|
||||
durationMs: 480,
|
||||
now: 1150,
|
||||
});
|
||||
assert.ok(result.cutover);
|
||||
assert.ok(result.droppedFrames.length <= 12);
|
||||
}
|
||||
|
||||
stableSourceDoesNotDrop();
|
||||
slowServerCapsRenderFps();
|
||||
backlogDropsContiguousOldFrames();
|
||||
eventCutoverKeepsShortGrace();
|
||||
settleEventCutoverKeepsWiderGrace();
|
||||
@@ -0,0 +1,714 @@
|
||||
:root {
|
||||
--paper: #eef1ec;
|
||||
--panel: #fbfaf5;
|
||||
--ink: #171a16;
|
||||
--muted: #687164;
|
||||
--line: #cbd2c4;
|
||||
--accent: #b9543c;
|
||||
--green: #4d765f;
|
||||
--blue: #3f607c;
|
||||
--pressed: #8c9288;
|
||||
--pressed-border: #aeb4aa;
|
||||
--pressed-ring: rgba(238, 241, 236, 0.2);
|
||||
--shadow: 0 18px 60px rgba(23, 26, 22, 0.12);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(23, 26, 22, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(23, 26, 22, 0.035) 1px, transparent 1px),
|
||||
var(--paper);
|
||||
background-size: 28px 28px;
|
||||
color: var(--ink);
|
||||
font-family: ui-sans-serif, "Avenir Next", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button:disabled { cursor: wait; opacity: 0.64; transform: none; }
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: color-mix(in oklch, var(--panel), white 20%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: var(--panel);
|
||||
background: var(--ink);
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand strong { font-size: 18px; font-weight: 650; }
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 12px 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-tooltip {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 50%;
|
||||
color: var(--muted);
|
||||
background: #fffdf7;
|
||||
cursor: help;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.help-tooltip::after {
|
||||
content: attr(aria-label);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 20;
|
||||
width: 280px;
|
||||
max-width: min(280px, calc(100vw - 48px));
|
||||
padding: 9px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--ink);
|
||||
box-shadow: 0 12px 36px rgba(23, 26, 22, 0.24);
|
||||
color: var(--panel);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.help-tooltip:hover::after,
|
||||
.help-tooltip:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fffdf7;
|
||||
color: var(--ink);
|
||||
padding: 10px 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea { resize: vertical; line-height: 1.45; }
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.12); }
|
||||
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.output-options {
|
||||
align-items: end;
|
||||
}
|
||||
.output-options .toggle-row {
|
||||
min-height: 40px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.actions { display: grid; grid-template-columns: 1fr 0.7fr; gap: 10px; margin-top: 16px; }
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.toggle-row input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--ink);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink);
|
||||
background: #fffdf7;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
color 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--ink);
|
||||
background: color-mix(in oklch, #fffdf7, var(--green) 10%);
|
||||
box-shadow: 0 8px 18px rgba(23, 26, 22, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
button:active:not(:disabled),
|
||||
button.is-pressed:not(:disabled) {
|
||||
border-color: var(--pressed-border);
|
||||
background: var(--pressed);
|
||||
color: #fffdf7;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 253, 247, 0.18),
|
||||
inset 0 2px 7px rgba(23, 26, 22, 0.16);
|
||||
transform: translateY(0);
|
||||
}
|
||||
button.is-key-active:not(:disabled) {
|
||||
border-color: var(--pressed-border);
|
||||
background: var(--pressed);
|
||||
color: #fffdf7;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 253, 247, 0.22),
|
||||
0 0 0 3px var(--pressed-ring),
|
||||
0 10px 22px rgba(23, 26, 22, 0.18);
|
||||
transform: none;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.18);
|
||||
}
|
||||
.primary { background: var(--ink); color: var(--panel); border-color: var(--ink); }
|
||||
.primary:hover:not(:disabled) {
|
||||
background: color-mix(in oklch, var(--ink), var(--green) 18%);
|
||||
color: var(--panel);
|
||||
}
|
||||
.primary:active:not(:disabled),
|
||||
.primary.is-pressed:not(:disabled) {
|
||||
background: var(--pressed);
|
||||
border-color: var(--pressed-border);
|
||||
color: var(--panel);
|
||||
}
|
||||
.wide { width: 100%; margin-top: 10px; }
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto auto;
|
||||
align-self: start;
|
||||
justify-self: center;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: min(1500px, 100%);
|
||||
overflow: hidden;
|
||||
border: 1px solid #11140f;
|
||||
border-radius: 8px;
|
||||
background: #11140f;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
justify-self: center;
|
||||
width: min(calc(1040px * var(--preview-scale, 1.2)), 100%);
|
||||
overflow: hidden;
|
||||
background: #11140f;
|
||||
contain: paint;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.preview-frame::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(238, 241, 236, 0.045),
|
||||
transparent 34%,
|
||||
rgba(0, 0, 0, 0.18)
|
||||
);
|
||||
}
|
||||
|
||||
.preview-frame::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.stage[data-preview-state="waiting"] .preview-frame::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.topbar, .timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
color: #e8eadf;
|
||||
background: rgba(17, 20, 15, 0.88);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar > * {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.topbar-spacer { flex: 1; }
|
||||
.record-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 118px;
|
||||
width: 118px;
|
||||
min-height: 28px;
|
||||
height: 28px;
|
||||
padding: 0 9px;
|
||||
border-color: rgba(232, 234, 223, 0.22);
|
||||
background: rgba(238, 241, 236, 0.08);
|
||||
color: #e8eadf;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.record-button:hover:not(:disabled) {
|
||||
border-color: rgba(232, 234, 223, 0.44);
|
||||
background: rgba(238, 241, 236, 0.14);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.record-button:active:not(:disabled),
|
||||
.record-button.is-pressed:not(:disabled),
|
||||
.record-button:focus-visible {
|
||||
transform: none;
|
||||
}
|
||||
.record-button.is-recording {
|
||||
border-color: color-mix(in oklch, var(--accent), white 18%);
|
||||
background: var(--accent);
|
||||
color: #fffdf7;
|
||||
}
|
||||
.record-button.is-saving {
|
||||
cursor: wait;
|
||||
opacity: 0.76;
|
||||
}
|
||||
#recordLabel {
|
||||
flex: 0 0 36px;
|
||||
text-align: left;
|
||||
}
|
||||
.record-button-icon {
|
||||
flex: 0 0 9px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
min-width: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.16);
|
||||
}
|
||||
.record-button.is-recording .record-button-icon {
|
||||
border-radius: 2px;
|
||||
background: #fffdf7;
|
||||
box-shadow: none;
|
||||
}
|
||||
.record-button-duration {
|
||||
display: inline-block;
|
||||
flex: 0 0 34px;
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
color: rgba(232, 234, 223, 0.7);
|
||||
}
|
||||
.record-button.is-recording .record-button-duration {
|
||||
color: rgba(255, 253, 247, 0.86);
|
||||
}
|
||||
.preview-scale-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 170px;
|
||||
min-width: 170px;
|
||||
margin: 0;
|
||||
color: rgba(232, 234, 223, 0.72);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
.preview-scale-control input {
|
||||
width: 92px;
|
||||
min-width: 72px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
accent-color: #eef1ec;
|
||||
}
|
||||
.preview-scale-control b {
|
||||
min-width: 36px;
|
||||
color: #fffdf7;
|
||||
font-weight: 650;
|
||||
}
|
||||
#statusText {
|
||||
display: inline-block;
|
||||
min-width: 92px;
|
||||
line-height: 1;
|
||||
}
|
||||
#chunkText {
|
||||
display: inline-block;
|
||||
min-width: 70px;
|
||||
line-height: 1;
|
||||
}
|
||||
.stage-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
color: rgba(232, 234, 223, 0.72);
|
||||
line-height: 1;
|
||||
}
|
||||
.stage-stat b {
|
||||
display: inline-block;
|
||||
color: #fffdf7;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#outputSizeText { min-width: 206px; }
|
||||
#renderFps { min-width: 2ch; text-align: right; }
|
||||
#theoreticalFpsText { min-width: 116px; }
|
||||
#stageLatencyText { min-width: 120px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.topbar {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: 44px;
|
||||
padding: 8px 14px;
|
||||
row-gap: 7px;
|
||||
}
|
||||
.topbar-spacer { display: none; }
|
||||
.preview-scale-control { flex-basis: 170px; min-width: 170px; }
|
||||
#outputSizeText { min-width: 156px; }
|
||||
#theoreticalFpsText { min-width: 100px; }
|
||||
#stageLatencyText { min-width: 108px; }
|
||||
}
|
||||
|
||||
.timeline { justify-content: flex-end; border-top: 1px solid rgba(232, 234, 223, 0.12); }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||
.dot.live { background: #8ecf9d; box-shadow: 0 0 0 4px rgba(142, 207, 157, 0.14); }
|
||||
.dot.error { background: var(--accent); }
|
||||
|
||||
#viewport {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: min(calc(56vh * var(--preview-scale, 1.2)), 82vh);
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
image-rendering: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: none;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stage[data-preview-state="waiting"] .preview-overlay {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.preview-loader {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(238, 241, 236, 0.22);
|
||||
border-top-color: rgba(238, 241, 236, 0.82);
|
||||
animation: previewProgressSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.stage-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px 14px 13px;
|
||||
border-top: 1px solid rgba(232, 234, 223, 0.12);
|
||||
background: #151912;
|
||||
}
|
||||
|
||||
.control-cluster {
|
||||
display: grid;
|
||||
grid-template-columns: 46px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-title {
|
||||
color: rgba(232, 234, 223, 0.62);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stage-controls .camera-pad {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stage-controls .camera-pad button {
|
||||
position: relative;
|
||||
border-color: rgba(232, 234, 223, 0.18);
|
||||
background: #eef1ec;
|
||||
color: #11140f;
|
||||
}
|
||||
|
||||
.stage-controls .camera-pad button:active:not(:disabled),
|
||||
.stage-controls .camera-pad button.is-pressed:not(:disabled),
|
||||
.stage-controls .camera-pad button.is-key-active:not(:disabled) {
|
||||
border-color: var(--pressed-border);
|
||||
background: var(--pressed);
|
||||
color: #fffdf7;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 253, 247, 0.22),
|
||||
0 0 0 3px var(--pressed-ring),
|
||||
0 10px 22px rgba(23, 26, 22, 0.18);
|
||||
}
|
||||
|
||||
.stage-controls .camera-pad button::after {
|
||||
content: attr(data-key);
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
top: 5px;
|
||||
color: color-mix(in oklch, var(--muted), var(--ink) 18%);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.stage-controls .camera-pad button:active::after,
|
||||
.stage-controls .camera-pad button.is-pressed::after,
|
||||
.stage-controls .camera-pad button.is-key-active::after {
|
||||
color: rgba(255, 253, 247, 0.78);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 16px 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.reference-upload {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.reference-upload input {
|
||||
border: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
#referencePreview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #e5e7df;
|
||||
}
|
||||
|
||||
#referenceName {
|
||||
min-height: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.spec-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.spec-grid span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-height: 46px;
|
||||
align-content: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fffdf7;
|
||||
padding: 9px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.spec-grid b {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.presets {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.preset-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 7px;
|
||||
min-height: 0;
|
||||
max-height: 230px;
|
||||
margin-bottom: 12px;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
.preset {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 4px 9px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fffdf7;
|
||||
text-align: left;
|
||||
}
|
||||
.preset-thumb {
|
||||
display: block;
|
||||
grid-row: span 2;
|
||||
width: 72px;
|
||||
height: 46px;
|
||||
min-height: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
border: 1px solid color-mix(in oklch, var(--line), var(--ink) 8%);
|
||||
}
|
||||
.preset b { min-width: 0; font-size: 13px; }
|
||||
.preset span { min-width: 0; color: var(--muted); font-size: 11px; line-height: 1.25; }
|
||||
.preset[data-tone="green"] { border-left: 4px solid var(--green); }
|
||||
.preset[data-tone="blue"] { border-left: 4px solid var(--blue); }
|
||||
.preset[data-tone="accent"] { border-left: 4px solid var(--accent); }
|
||||
.preset:hover:not(:disabled) {
|
||||
background: color-mix(in oklch, #fffdf7, var(--blue) 9%);
|
||||
}
|
||||
|
||||
.camera-pad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.camera-pad span { min-height: 36px; }
|
||||
.camera-pad button { min-height: 36px; font-size: 12px; }
|
||||
.telemetry { display: grid; gap: 7px; margin-top: 10px; }
|
||||
.telemetry span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.telemetry b { color: var(--ink); font-weight: 650; }
|
||||
|
||||
.stage-telemetry {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 0;
|
||||
border-top: 1px solid rgba(232, 234, 223, 0.12);
|
||||
background: #11140f;
|
||||
}
|
||||
|
||||
.stage-telemetry span {
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-right: 1px solid rgba(232, 234, 223, 0.1);
|
||||
border-bottom: 1px solid rgba(232, 234, 223, 0.1);
|
||||
padding: 0 14px;
|
||||
color: rgba(232, 234, 223, 0.62);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stage-telemetry b {
|
||||
color: #fffdf7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
max-height: 92px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.history-list span {
|
||||
display: block;
|
||||
border-left: 3px solid var(--blue);
|
||||
background: #fffdf7;
|
||||
padding: 8px 9px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.presets { position: static; max-height: none; overflow: visible; }
|
||||
.spec-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.preset-list { min-height: 260px; max-height: 420px; }
|
||||
#viewport { max-height: 420px; }
|
||||
.stage-controls { grid-template-columns: 1fr; }
|
||||
.stage-telemetry { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.topbar { flex-wrap: wrap; height: auto; min-height: 44px; padding: 10px 14px; }
|
||||
.topbar-spacer { display: none; }
|
||||
.preview-scale-control { min-width: 160px; }
|
||||
}
|
||||
|
||||
@keyframes previewProgressSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# SGLang Diffusion WebUI User Guide
|
||||
|
||||
SGLang Diffusion WebUI provides an intuitive Gradio-based interface for image and video generation, supporting parameter
|
||||
tuning and real-time previews.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The WebUI runs on Gradio. To get started, install Gradio first:
|
||||
|
||||
```bash
|
||||
pip install gradio==6.1.0
|
||||
```
|
||||
|
||||
## Launch WebUI Service
|
||||
|
||||
SGLang Diffusion now includes an integrated WebUI. Simply add the `--webui` parameter when starting the service.
|
||||
|
||||
### Launch Text-to-Image Service
|
||||
|
||||
```bash
|
||||
sglang serve --model-path black-forest-labs/FLUX.1-dev --num-gpus 1 --webui --webui-port 2333
|
||||
```
|
||||
|
||||
### Launch Text-to-Video Service
|
||||
|
||||
```bash
|
||||
sglang serve --model-path Wan-AI/Wan2.2-T2V-A14B-Diffusers --num-gpus 1 --webui --webui-port 2333
|
||||
```
|
||||
|
||||
### Launch Image-to-Image Service
|
||||
```bash
|
||||
sglang serve --model-path Qwen/Qwen-Image-Edit-2511 --num-gpus 1 --webui --webui-port 2333
|
||||
```
|
||||
|
||||
### Launch Image-to-Video Service
|
||||
```bash
|
||||
sglang serve --model-path Wan-AI/Wan2.2-TI2V-5B-Diffusers --num-gpus 1 --webui --webui-port 2333
|
||||
```
|
||||
|
||||
## Port Forwarding
|
||||
|
||||
Once the WebUI service is running, you need to use **SSH port forwarding** to securely access the remote service from
|
||||
your local machine.
|
||||
|
||||
In most cases: Your IDE (like VS Code, Cursor, etc.) can handle this automatically. Check your IDE's remote development
|
||||
or port forwarding features. Otherwise, execute this command manually.
|
||||
|
||||
```bash
|
||||
ssh -L ${WEBUI_PORT}:localhost:${WEBUI_PORT} user_name@machine_name
|
||||
```
|
||||
|
||||
Learn more about port forwarding: [Port Forwarding](https://en.wikipedia.org/wiki/Port_forwarding).
|
||||
|
||||
## Interface Instructions
|
||||
|
||||
You can view your model path and task name directly in the UI. We'd appreciate any feedback you'd like to share.
|
||||
|
||||
Once launched, access the interface at `http://localhost:${WEBUI_PORT}` in your browser.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import run_sgl_diffusion_webui
|
||||
|
||||
__all__ = ["run_sgl_diffusion_webui"]
|
||||
@@ -0,0 +1,265 @@
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import (
|
||||
DataType,
|
||||
SamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
post_process_sample,
|
||||
prepare_request,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.scheduler_client import sync_scheduler_client
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.srt.environ import envs
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def add_webui_args(parser: argparse.ArgumentParser):
|
||||
"""Add the arguments for the generate command."""
|
||||
parser = ServerArgs.add_cli_args(parser)
|
||||
parser = SamplingParams.add_cli_args(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run_sgl_diffusion_webui(server_args: ServerArgs):
|
||||
# import gradio in function to avoid CI crash
|
||||
|
||||
import gradio as gr
|
||||
|
||||
def resolve_model_repo_id(model_path: str) -> str:
|
||||
from pathlib import Path
|
||||
|
||||
from huggingface_hub.utils import HFValidationError, validate_repo_id
|
||||
|
||||
try:
|
||||
validate_repo_id(model_path)
|
||||
return model_path
|
||||
except HFValidationError:
|
||||
pass
|
||||
|
||||
p = Path(model_path).expanduser()
|
||||
parts = p.parts
|
||||
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Invalid model_path: {model_path}")
|
||||
|
||||
candidate = f"{parts[-2]}/{parts[-1]}"
|
||||
validate_repo_id(candidate) # let it raise if invalid
|
||||
return candidate
|
||||
|
||||
# Prefer the hub pipeline tag for Hub models; fall back to the loaded pipeline's
|
||||
# own task_type for local checkpoints (e.g. a single .safetensors path), which
|
||||
# have no hub repo to query.
|
||||
task_name = None
|
||||
try:
|
||||
repo_id = resolve_model_repo_id(server_args.model_path)
|
||||
if envs.SGLANG_USE_MODELSCOPE.get():
|
||||
from modelscope.hub.api import HubApi
|
||||
|
||||
api = HubApi()
|
||||
model_info_obj = api.model_info(repo_id)
|
||||
task_name = model_info_obj.tasks[0]["Name"].replace("-synthesis", "")
|
||||
else:
|
||||
from huggingface_hub import model_info
|
||||
|
||||
task_name = model_info(repo_id).pipeline_tag
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"Could not resolve task from the model hub (%s); using the loaded "
|
||||
"pipeline's task_type.",
|
||||
e,
|
||||
)
|
||||
|
||||
# init client
|
||||
sync_scheduler_client.initialize(server_args)
|
||||
|
||||
if task_name in ("text-to-video", "image-to-video", "video-to-video"):
|
||||
task_type = "video"
|
||||
elif task_name in ("text-to-image", "image-to-image"):
|
||||
task_type = "image"
|
||||
else:
|
||||
task_type = (
|
||||
"image" if server_args.pipeline_config.task_type.is_image_gen() else "video"
|
||||
)
|
||||
task_name = task_name or server_args.pipeline_config.task_type.name
|
||||
video_visible_only = task_type == "video"
|
||||
image_visible_only = task_type == "image"
|
||||
|
||||
# server_args will be reused in gradio_generate function
|
||||
def gradio_generate(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
reference_image_paths_str,
|
||||
seed,
|
||||
num_frames,
|
||||
frames_per_second,
|
||||
width,
|
||||
height,
|
||||
num_inference_steps,
|
||||
guidance_scale,
|
||||
enable_teacache,
|
||||
):
|
||||
"""
|
||||
NOTE: The input and output of function which is called by gradio button must be gradio components
|
||||
So we use global variable sampling_params_kwargs to avoid pass this param, because gradio does not support this.
|
||||
return [ np.ndarray, None ] | [None, np.ndarray]
|
||||
"""
|
||||
if reference_image_paths_str:
|
||||
if "," in reference_image_paths_str:
|
||||
logger.warning(
|
||||
f"Warning: please use English comma to separate the reference image paths, and the reference image paths is: {reference_image_paths_str}"
|
||||
)
|
||||
reference_image_paths_str = reference_image_paths_str.replace(",", ",")
|
||||
image_path = [path.strip() for path in reference_image_paths_str.split(",")]
|
||||
else:
|
||||
image_path = None
|
||||
|
||||
sampling_params_kwargs = dict(
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
image_path=image_path,
|
||||
seed=seed,
|
||||
num_frames=num_frames,
|
||||
fps=frames_per_second,
|
||||
width=width,
|
||||
height=height,
|
||||
guidance_scale=guidance_scale,
|
||||
num_inference_steps=num_inference_steps,
|
||||
enable_teacache=enable_teacache,
|
||||
return_file_paths_only=False,
|
||||
)
|
||||
sampling_params = SamplingParams.from_user_sampling_params_args(
|
||||
server_args.model_path,
|
||||
server_args=server_args,
|
||||
**sampling_params_kwargs,
|
||||
)
|
||||
batch = prepare_request(
|
||||
server_args=server_args,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
result = sync_scheduler_client.forward([batch])
|
||||
save_file_path = str(os.path.join(batch.output_path, batch.output_file_name))
|
||||
if result.output is None:
|
||||
sampling_params_str = "\n".join(
|
||||
[f"{key}: {value}" for key, value in sampling_params_kwargs.items()]
|
||||
)
|
||||
no_output_msg = f"No output is generated by client, and their sampling params is: {sampling_params_str}"
|
||||
|
||||
if batch.data_type == DataType.VIDEO:
|
||||
if os.path.exists(save_file_path):
|
||||
logger.warning(no_output_msg)
|
||||
return None, save_file_path
|
||||
else:
|
||||
no_output_msg += f"\nAnd the expected output file was not found at: {save_file_path}"
|
||||
raise ValueError(no_output_msg)
|
||||
else:
|
||||
raise ValueError(no_output_msg)
|
||||
|
||||
frames = post_process_sample(
|
||||
result.output[0],
|
||||
batch.data_type,
|
||||
batch.fps,
|
||||
batch.save_output,
|
||||
save_file_path,
|
||||
)
|
||||
if batch.data_type == DataType.VIDEO:
|
||||
# gradio video need video path to show video
|
||||
return None, save_file_path
|
||||
else:
|
||||
return frames[0], None
|
||||
|
||||
with gr.Blocks() as demo:
|
||||
gr.Markdown("# 🚀 SGLang Diffusion Application")
|
||||
with gr.Row():
|
||||
gr.Textbox(label="Model", value=server_args.model_path)
|
||||
gr.Textbox(label="Task name", value=task_name)
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column(scale=4):
|
||||
prompt = gr.Textbox(label="Prompt", value="A curious raccoon")
|
||||
negative_prompt = gr.Textbox(
|
||||
label="Negative_prompt",
|
||||
value="Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards",
|
||||
)
|
||||
with gr.Column(scale=1):
|
||||
seed = gr.Number(label="seed", precision=0, value=1234)
|
||||
run_btn = gr.Button("Generate", variant="primary", size="lg")
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column():
|
||||
width = gr.Number(label="width", precision=0, value=720)
|
||||
height = gr.Number(label="height", precision=0, value=480)
|
||||
num_inference_steps = gr.Slider(
|
||||
minimum=0, maximum=50, value=20, step=1, label="num_inference_steps"
|
||||
)
|
||||
guidance_scale = gr.Slider(
|
||||
minimum=0.0, maximum=10, value=5, step=0.01, label="guidance_scale"
|
||||
)
|
||||
num_frames = gr.Slider(
|
||||
minimum=1,
|
||||
maximum=181,
|
||||
value=81,
|
||||
step=1,
|
||||
label="num_frames",
|
||||
visible=video_visible_only,
|
||||
)
|
||||
frames_per_second = gr.Slider(
|
||||
minimum=4,
|
||||
maximum=60,
|
||||
value=16,
|
||||
step=1,
|
||||
label="frames_per_second",
|
||||
visible=video_visible_only,
|
||||
)
|
||||
reference_image_paths_str = gr.Textbox(
|
||||
label="reference images",
|
||||
placeholder="Examples: 'image1.png, image2.png' or 'https://example.com/image1.png, https://example.com/image2.png'",
|
||||
)
|
||||
enable_teacache = gr.Checkbox(label="enable_teacache", value=False)
|
||||
|
||||
with gr.Column():
|
||||
image_out = gr.Image(
|
||||
label="Generated Image", visible=image_visible_only, format="png"
|
||||
)
|
||||
video_out = gr.Video(
|
||||
label="Generated Video", visible=video_visible_only
|
||||
)
|
||||
|
||||
run_btn.click(
|
||||
fn=gradio_generate,
|
||||
inputs=[
|
||||
prompt,
|
||||
negative_prompt,
|
||||
reference_image_paths_str,
|
||||
seed,
|
||||
num_frames,
|
||||
frames_per_second,
|
||||
width,
|
||||
height,
|
||||
num_inference_steps,
|
||||
guidance_scale,
|
||||
enable_teacache,
|
||||
],
|
||||
outputs=[image_out, video_out],
|
||||
)
|
||||
|
||||
_, local_url, _ = demo.launch(
|
||||
server_port=server_args.webui_port,
|
||||
quiet=True,
|
||||
prevent_thread_lock=True,
|
||||
show_error=True,
|
||||
)
|
||||
|
||||
# print banner
|
||||
delimiter = "=" * 80
|
||||
url = local_url or f"http://localhost:{server_args.webui_port}"
|
||||
print(f"""
|
||||
{delimiter}
|
||||
\033[1mSGLang Diffusion WebUI available at:\033[0m \033[1;4;92m{url}\033[0m
|
||||
{delimiter}
|
||||
""")
|
||||
|
||||
demo.block_thread()
|
||||
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
Benchmark offline throughput for multimodal generation models (Image/Video Generation).
|
||||
|
||||
This script benchmarks generation throughput without running a server, using low-level APIs.
|
||||
It provides detailed metrics on throughput, latency, and resource utilization.
|
||||
|
||||
# Usage Examples
|
||||
|
||||
## Text-to-Video with VBench dataset
|
||||
python -m sglang.multimodal_gen.benchmarks.bench_offline_throughput \\
|
||||
--model-path Wan-AI/Wan2.1-T2V-1.3B-Diffusers \\
|
||||
--dataset vbench \\
|
||||
--num-prompts 20 \\
|
||||
--batch-size 1 \\
|
||||
--width 512 --height 512 --num-frames 16
|
||||
|
||||
## Random dataset for stress testing
|
||||
python -m sglang.multimodal_gen.benchmarks.bench_offline_throughput \\
|
||||
--model-path Wan-AI/Wan2.1-T2V-1.3B-Diffusers \\
|
||||
--dataset random \\
|
||||
--num-prompts 100 \\
|
||||
--batch-size 1 \\
|
||||
--num-inference-steps 20 \\
|
||||
--output-file results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import torch
|
||||
from tqdm import tqdm
|
||||
|
||||
from sglang.multimodal_gen.benchmarks.datasets import RandomDataset, VBenchDataset
|
||||
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs, set_global_server_args
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import (
|
||||
configure_logger,
|
||||
init_logger,
|
||||
)
|
||||
from sglang.multimodal_gen.test.test_utils import print_divider, print_value_formatted
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchOutput:
|
||||
"""Container for batch generation results."""
|
||||
|
||||
latency: float = 0.0
|
||||
latency_per_sample: float = 0.0
|
||||
num_samples: int = 0
|
||||
total_frames: int = 0
|
||||
peak_memory_mb: float = 0.0
|
||||
success: bool = False
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchArgs:
|
||||
"""Benchmark configuration for multimodal generation."""
|
||||
|
||||
# Diffusion Model Configuration
|
||||
num_inference_steps: int = 20
|
||||
guidance_scale: float = 7.5
|
||||
seed: int = 42
|
||||
disable_safety_checker: bool = False
|
||||
|
||||
# Output Configuration
|
||||
width: int = 32
|
||||
height: int = 32
|
||||
num_frames: int = 1
|
||||
fps: int = 24
|
||||
|
||||
# Dataset & Benchmark
|
||||
dataset: str = "random"
|
||||
dataset_path: str = ""
|
||||
task_name: str = "unknown"
|
||||
num_prompts: int = 10
|
||||
num_outputs_per_prompt: int = 1
|
||||
batch_size: int = 1
|
||||
random_request_config: str = None
|
||||
random_request_seed: int = 42
|
||||
|
||||
# Benchmark Execution
|
||||
skip_warmup: bool = False
|
||||
output_file: str = ""
|
||||
disable_tqdm: bool = False
|
||||
|
||||
# Profiling
|
||||
profile: bool = False
|
||||
num_profiled_timesteps: int = 5
|
||||
profile_all_stages: bool = False
|
||||
|
||||
@staticmethod
|
||||
def add_cli_args(parser: argparse.ArgumentParser):
|
||||
"""Add benchmark-specific CLI arguments."""
|
||||
# Diffusion Model Configuration
|
||||
parser.add_argument(
|
||||
"--num-inference-steps",
|
||||
type=int,
|
||||
default=20,
|
||||
help="Number of denoising steps",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--guidance-scale",
|
||||
type=float,
|
||||
default=7.5,
|
||||
help="Classifier-free guidance scale",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=42, help="Random seed")
|
||||
parser.add_argument(
|
||||
"--disable-safety-checker",
|
||||
action="store_true",
|
||||
help="Disable NSFW detection",
|
||||
)
|
||||
|
||||
# Output Configuration
|
||||
parser.add_argument("--width", type=int, default=32, help="Image/video width")
|
||||
parser.add_argument("--height", type=int, default=32, help="Image/video height")
|
||||
parser.add_argument(
|
||||
"--num-frames", type=int, default=1, help="Number of frames for video"
|
||||
)
|
||||
parser.add_argument("--fps", type=int, default=24, help="FPS for video")
|
||||
|
||||
# Dataset & Benchmark
|
||||
parser.add_argument(
|
||||
"--dataset",
|
||||
type=str,
|
||||
default="random",
|
||||
choices=["vbench", "random"],
|
||||
help="Dataset to use",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dataset-path",
|
||||
type=str,
|
||||
default="",
|
||||
help="Path to dataset (prompts file or image directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task-name",
|
||||
type=str,
|
||||
default="unknown",
|
||||
help="Task name for benchmark identification",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-prompts",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Total number of prompts to benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-outputs-per-prompt",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of generated outputs requested per prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Batch size per generation call (currently only bs=1 is supported)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--random-request-config",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"JSON string defining random request profiles. "
|
||||
"Each profile may contain: width, height, num_inference_steps, etc. "
|
||||
"The 'weight' field controls sampling probability (relative weight)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--random-request-seed",
|
||||
type=int,
|
||||
default=42,
|
||||
help="Random seed for sampling request profiles (default: 42).",
|
||||
)
|
||||
|
||||
# Benchmark Execution
|
||||
parser.add_argument(
|
||||
"--skip-warmup", action="store_true", help="Skip warmup batch"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-file",
|
||||
type=str,
|
||||
default="",
|
||||
help="Output JSON file for results (append mode)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disable-tqdm",
|
||||
action="store_true",
|
||||
help="Disable progress bar",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Enable PyTorch profiler for diffusion generation. "
|
||||
"Set SGLANG_DIFFUSION_TORCH_PROFILER_DIR to control trace output directory."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-profiled-timesteps",
|
||||
type=int,
|
||||
default=5,
|
||||
help=(
|
||||
"Number of denoising timesteps to profile after warmup. "
|
||||
"Use -1 to profile all denoising timesteps."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile-all-stages",
|
||||
action="store_true",
|
||||
help="Profile all diffusion pipeline stages instead of only denoising steps.",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cli_args(cls, args: argparse.Namespace):
|
||||
"""Create BenchArgs from parsed CLI arguments."""
|
||||
attrs = [attr.name for attr in dataclasses.fields(cls)]
|
||||
return cls(**{attr: getattr(args, attr) for attr in attrs})
|
||||
|
||||
|
||||
def initialize_engine(server_args: ServerArgs) -> DiffGenerator:
|
||||
"""Initialize diffusion pipeline engine."""
|
||||
logger.info("Initializing engine...")
|
||||
engine = DiffGenerator.from_server_args(server_args, local_mode=True)
|
||||
logger.info("Engine initialized successfully")
|
||||
return engine
|
||||
|
||||
|
||||
def generate_batch(
|
||||
engine: DiffGenerator,
|
||||
bench_args: BenchArgs,
|
||||
prompts: List[str],
|
||||
user_sampling_params: List[Dict[str, Any]],
|
||||
) -> BatchOutput:
|
||||
"""Generate batch of images/videos synchronously."""
|
||||
assert len(user_sampling_params) == len(prompts), (
|
||||
f"user_sampling_params length ({len(user_sampling_params)}) must match "
|
||||
f"prompts length ({len(prompts)})"
|
||||
)
|
||||
|
||||
output = BatchOutput()
|
||||
start_time = time.perf_counter()
|
||||
|
||||
torch.get_device_module().reset_peak_memory_stats()
|
||||
|
||||
for prompt, params in zip(prompts, user_sampling_params):
|
||||
try:
|
||||
sampling_params_kwargs = dict(params)
|
||||
sampling_params_kwargs["prompt"] = prompt
|
||||
result = engine.generate(sampling_params_kwargs=sampling_params_kwargs)
|
||||
|
||||
if result is not None:
|
||||
if isinstance(result, list):
|
||||
output.total_frames += len(result)
|
||||
else:
|
||||
output.total_frames += 1
|
||||
output.num_samples += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Generation failed for prompt '{prompt[:50]}...': {e}")
|
||||
output.error = str(e)
|
||||
|
||||
output.latency = time.perf_counter() - start_time
|
||||
output.latency_per_sample = output.latency / len(prompts) if prompts else 0.0
|
||||
output.success = output.num_samples > 0
|
||||
output.peak_memory_mb = torch.get_device_module().max_memory_allocated() / (
|
||||
1024 * 1024
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Batch generated: {output.num_samples}/{len(prompts)} samples in {output.latency:.2f}s"
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def calculate_metrics(
|
||||
outputs: List[BatchOutput],
|
||||
total_duration: float,
|
||||
resolution: Tuple[int, int, int],
|
||||
num_requests: int,
|
||||
all_sampling_params: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Calculate generation-specific throughput metrics."""
|
||||
successful = [o for o in outputs if o.success]
|
||||
num_success = sum(o.num_samples for o in successful)
|
||||
total_frames = sum(o.total_frames for o in successful)
|
||||
peak_memory = max((o.peak_memory_mb for o in outputs), default=0)
|
||||
|
||||
width, height, frames = resolution
|
||||
if all_sampling_params:
|
||||
total_pixels = sum(
|
||||
p.get("width", width)
|
||||
* p.get("height", height)
|
||||
* p.get("num_frames", frames)
|
||||
for p in all_sampling_params[:num_success]
|
||||
)
|
||||
else:
|
||||
total_pixels = num_success * width * height * frames
|
||||
|
||||
metrics = {
|
||||
"num_requests": num_requests,
|
||||
"successful_requests": num_success,
|
||||
"failed_requests": num_requests - num_success,
|
||||
"total_duration_seconds": total_duration,
|
||||
"total_frames_generated": total_frames,
|
||||
"total_pixels_generated": total_pixels,
|
||||
"images_per_second": num_success / total_duration if total_duration > 0 else 0,
|
||||
"frames_per_second": total_frames / total_duration if total_duration > 0 else 0,
|
||||
"megapixels_per_second": (
|
||||
total_pixels / (total_duration * 1e6) if total_duration > 0 else 0
|
||||
),
|
||||
"requests_per_second": (
|
||||
num_success / total_duration if total_duration > 0 else 0
|
||||
),
|
||||
"latency_per_request_seconds": (
|
||||
total_duration / num_success if num_success > 0 else 0
|
||||
),
|
||||
"peak_memory_mb": peak_memory,
|
||||
}
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def throughput_test(
|
||||
server_args: ServerArgs,
|
||||
bench_args: BenchArgs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Main throughput benchmark function."""
|
||||
configure_logger(server_args=server_args)
|
||||
logger.info("Starting offline throughput benchmark...")
|
||||
|
||||
engine = initialize_engine(server_args)
|
||||
|
||||
if bench_args.random_request_config and bench_args.dataset != "random":
|
||||
raise ValueError(
|
||||
"--random-request-config can only be used with --dataset random"
|
||||
)
|
||||
|
||||
if bench_args.num_outputs_per_prompt != 1:
|
||||
raise ValueError(
|
||||
"bench_offline_throughput currently supports only --num-outputs-per-prompt 1"
|
||||
)
|
||||
|
||||
logger.info(f"Loading {bench_args.dataset} dataset...")
|
||||
if bench_args.dataset == "vbench":
|
||||
bench_args.task_name = str(engine.server_args.pipeline_config.task_type)
|
||||
dataset = VBenchDataset(bench_args)
|
||||
elif bench_args.dataset == "random":
|
||||
dataset = RandomDataset(bench_args)
|
||||
else:
|
||||
raise ValueError(f"Unknown dataset: {bench_args.dataset}")
|
||||
|
||||
_sampling_params = {
|
||||
"guidance_scale": bench_args.guidance_scale,
|
||||
"num_inference_steps": bench_args.num_inference_steps,
|
||||
"height": bench_args.height,
|
||||
"width": bench_args.width,
|
||||
"num_frames": bench_args.num_frames,
|
||||
"num_outputs_per_prompt": bench_args.num_outputs_per_prompt,
|
||||
"seed": bench_args.seed,
|
||||
"profile": bench_args.profile,
|
||||
"num_profiled_timesteps": bench_args.num_profiled_timesteps,
|
||||
"profile_all_stages": bench_args.profile_all_stages,
|
||||
}
|
||||
if bench_args.disable_safety_checker:
|
||||
_sampling_params["safety_checker"] = None
|
||||
|
||||
total_count = min(bench_args.num_prompts, len(dataset))
|
||||
all_prompts = [dataset[i].prompt for i in range(total_count)]
|
||||
|
||||
if bench_args.random_request_config:
|
||||
all_sampling_params = []
|
||||
for i in range(total_count):
|
||||
params = dict(_sampling_params)
|
||||
params.update(dataset.get_sampling_params(i))
|
||||
all_sampling_params.append(params)
|
||||
else:
|
||||
all_sampling_params = [_sampling_params] * total_count
|
||||
|
||||
if not bench_args.skip_warmup:
|
||||
logger.info("Running warmup batch...")
|
||||
warmup_count = min(bench_args.batch_size, total_count)
|
||||
warmup_prompts = all_prompts[:warmup_count]
|
||||
warmup_sampling_params = [
|
||||
{**p, "profile": False} for p in all_sampling_params[:warmup_count]
|
||||
]
|
||||
generate_batch(engine, bench_args, warmup_prompts, warmup_sampling_params)
|
||||
|
||||
logger.info(f"Running benchmark with {bench_args.num_prompts} prompts...")
|
||||
outputs: List[BatchOutput] = []
|
||||
|
||||
start_time = time.perf_counter()
|
||||
|
||||
num_batches = (total_count + bench_args.batch_size - 1) // bench_args.batch_size
|
||||
pbar = tqdm(
|
||||
total=num_batches,
|
||||
disable=bench_args.disable_tqdm,
|
||||
desc="Benchmark",
|
||||
)
|
||||
|
||||
for batch_start in range(0, total_count, bench_args.batch_size):
|
||||
batch_end = min(batch_start + bench_args.batch_size, total_count)
|
||||
batch_prompts = all_prompts[batch_start:batch_end]
|
||||
batch_sampling_params = all_sampling_params[batch_start:batch_end]
|
||||
|
||||
batch_output = generate_batch(
|
||||
engine, bench_args, batch_prompts, batch_sampling_params
|
||||
)
|
||||
outputs.append(batch_output)
|
||||
|
||||
pbar.update(1)
|
||||
|
||||
pbar.close()
|
||||
total_duration = time.perf_counter() - start_time
|
||||
|
||||
resolution = (bench_args.width, bench_args.height, bench_args.num_frames)
|
||||
metrics = calculate_metrics(
|
||||
outputs,
|
||||
total_duration,
|
||||
resolution=resolution,
|
||||
num_requests=total_count,
|
||||
all_sampling_params=all_sampling_params,
|
||||
)
|
||||
|
||||
display_results(
|
||||
metrics,
|
||||
bench_args,
|
||||
model_path=server_args.model_path,
|
||||
)
|
||||
|
||||
if bench_args.output_file:
|
||||
save_results(metrics, bench_args, server_args)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def display_results(
|
||||
metrics: Dict[str, Any],
|
||||
bench_args: BenchArgs,
|
||||
model_path: str,
|
||||
):
|
||||
"""Display benchmark results in console."""
|
||||
print(
|
||||
"\n{s:{c}^{n}}".format(s=" Offline Throughput Benchmark Result ", n=110, c="=")
|
||||
)
|
||||
print_value_formatted("Model:", model_path)
|
||||
print_value_formatted("Dataset:", bench_args.dataset)
|
||||
print_value_formatted(
|
||||
"Resolution:",
|
||||
f"{bench_args.width}x{bench_args.height}x{bench_args.num_frames}",
|
||||
)
|
||||
print_value_formatted("Num Inference Steps:", bench_args.num_inference_steps)
|
||||
print_divider(75)
|
||||
print_value_formatted("Total Requests:", metrics["num_requests"])
|
||||
print_value_formatted("Successful Requests:", metrics["successful_requests"])
|
||||
print_value_formatted("Failed Requests:", metrics["failed_requests"])
|
||||
print_value_formatted(
|
||||
"Total Duration (seconds):", metrics["total_duration_seconds"]
|
||||
)
|
||||
print_divider(75)
|
||||
print_value_formatted("Frames Generated:", metrics["total_frames_generated"])
|
||||
print_value_formatted(
|
||||
"Megapixels Generated:", metrics["total_pixels_generated"] / 1e6
|
||||
)
|
||||
print_divider(75)
|
||||
print_value_formatted(
|
||||
"Frame Throughput (frames/sec):", metrics["frames_per_second"]
|
||||
)
|
||||
print_value_formatted("MP Throughput (MP/sec):", metrics["megapixels_per_second"])
|
||||
print_value_formatted("Requests Per Second:", metrics["requests_per_second"])
|
||||
print_value_formatted(
|
||||
"Latency Per Request (sec):", metrics["latency_per_request_seconds"]
|
||||
)
|
||||
print_value_formatted("Peak Memory (MB):", metrics["peak_memory_mb"])
|
||||
print_divider(110, "=")
|
||||
|
||||
|
||||
def save_results(
|
||||
metrics: Dict[str, Any],
|
||||
bench_args: BenchArgs,
|
||||
server_args: ServerArgs,
|
||||
):
|
||||
"""Save benchmark results to JSON file."""
|
||||
result = {
|
||||
"metadata": {
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"model_path": server_args.model_path,
|
||||
"task_type": bench_args.task_name,
|
||||
"backend": "engine",
|
||||
},
|
||||
"configuration": {
|
||||
"num_inference_steps": bench_args.num_inference_steps,
|
||||
"guidance_scale": bench_args.guidance_scale,
|
||||
"seed": bench_args.seed,
|
||||
"batch_size": bench_args.batch_size,
|
||||
"num_prompts": bench_args.num_prompts,
|
||||
"resolution": f"{bench_args.width}x{bench_args.height}x{bench_args.num_frames}",
|
||||
"dataset": bench_args.dataset,
|
||||
},
|
||||
"results": metrics,
|
||||
}
|
||||
|
||||
with open(bench_args.output_file, "a") as f:
|
||||
f.write(json.dumps(result) + "\n")
|
||||
|
||||
logger.info(f"Results saved to {bench_args.output_file}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Offline throughput benchmark for multimodal generation models"
|
||||
)
|
||||
|
||||
ServerArgs.add_cli_args(parser)
|
||||
BenchArgs.add_cli_args(parser)
|
||||
|
||||
args, unknown_args = parser.parse_known_args()
|
||||
|
||||
server_args = ServerArgs.from_cli_args(args, unknown_args)
|
||||
bench_args = BenchArgs.from_cli_args(args)
|
||||
|
||||
set_global_server_args(server_args)
|
||||
|
||||
result = throughput_test(server_args, bench_args)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,842 @@
|
||||
"""
|
||||
Benchmark online serving for diffusion models (Image/Video Generation).
|
||||
|
||||
|
||||
Usage:
|
||||
# launch a server and benchmark on it
|
||||
|
||||
# T2V or T2I or any other multimodal generation model
|
||||
sglang serve --model-path Wan-AI/Wan2.2-T2V-A14B-Diffusers --num-gpus 1 --port 1231
|
||||
|
||||
# benchmark it and make sure the port is the same as the server's port
|
||||
python3 -m sglang.multimodal_gen.benchmarks.bench_serving --dataset vbench --num-prompts 20 --port 1231
|
||||
|
||||
# benchmark with SLO metrics enabled
|
||||
python3 -m sglang.multimodal_gen.benchmarks.bench_serving --dataset vbench --num-prompts 20 --port 1231 --slo --slo-scale 3.0 --warmup-requests 2
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
import numpy as np
|
||||
import requests
|
||||
from tqdm.asyncio import tqdm
|
||||
|
||||
from sglang.multimodal_gen.benchmarks.datasets import (
|
||||
RandomDataset,
|
||||
RequestFuncInput,
|
||||
RequestFuncOutput,
|
||||
VBenchDataset,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import (
|
||||
configure_logger,
|
||||
init_logger,
|
||||
)
|
||||
from sglang.multimodal_gen.test.test_utils import print_divider, print_value_formatted
|
||||
from sglang.srt.utils.network import NetworkAddress
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Patch size used for computing area units (e.g. in latent diffusion models).
|
||||
PATCH_SIZE = 16
|
||||
PATCH_AREA = PATCH_SIZE * PATCH_SIZE
|
||||
|
||||
|
||||
def _get_response_output_count(resp_json: Dict[str, Any]) -> int:
|
||||
if isinstance(resp_json.get("num_outputs"), int):
|
||||
return resp_json["num_outputs"]
|
||||
if isinstance(resp_json.get("data"), list):
|
||||
return len(resp_json["data"])
|
||||
if isinstance(resp_json.get("file_paths"), list):
|
||||
return len(resp_json["file_paths"])
|
||||
if isinstance(resp_json.get("urls"), list):
|
||||
return len(resp_json["urls"])
|
||||
if resp_json.get("file_path") or resp_json.get("url"):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _compute_scale_factor(req: RequestFuncInput, args) -> Optional[float]:
|
||||
"""Computes the composite scale factor (area × frames × steps) for a request."""
|
||||
width = req.width or args.width
|
||||
height = req.height or args.height
|
||||
if None in (width, height):
|
||||
return None
|
||||
frames = req.num_frames or args.num_frames
|
||||
steps = req.num_inference_steps or args.num_inference_steps
|
||||
|
||||
frame_scale = frames if isinstance(frames, int) and frames > 0 else 1
|
||||
step_scale = steps if isinstance(steps, int) and steps > 0 else 1
|
||||
|
||||
area_units = max((float(width) * float(height)) / float(PATCH_AREA), 1.0)
|
||||
return area_units * float(frame_scale) * float(step_scale)
|
||||
|
||||
|
||||
def _compute_expected_latency_ms_from_base(
|
||||
req: RequestFuncInput, args, base_time_ms: Optional[float]
|
||||
) -> Optional[float]:
|
||||
"""Scales latency linearly by pixel area, frame count, and inference steps."""
|
||||
if base_time_ms is None:
|
||||
return None
|
||||
scale = _compute_scale_factor(req, args)
|
||||
if scale is None:
|
||||
return None
|
||||
return float(base_time_ms) * scale
|
||||
|
||||
|
||||
def _infer_slo_base_time_ms_from_warmups(
|
||||
warmup_pairs: List[tuple], args
|
||||
) -> Optional[float]:
|
||||
"""Derives median base latency from successful warmup runs."""
|
||||
candidates_ms: List[float] = []
|
||||
for req, out in warmup_pairs:
|
||||
if not out.success or out.latency <= 0:
|
||||
logger.warning(
|
||||
f"Skipping warmup result: success={out.success}, latency={out.latency:.3f}"
|
||||
)
|
||||
continue
|
||||
|
||||
scale = _compute_scale_factor(req, args)
|
||||
if scale is None or scale <= 0:
|
||||
continue
|
||||
|
||||
candidates_ms.append((out.latency * 1000.0) / scale)
|
||||
|
||||
return float(np.median(candidates_ms)) if candidates_ms else None
|
||||
|
||||
|
||||
def _populate_slo_ms_from_warmups(
|
||||
requests_list: List[RequestFuncInput], warmup_pairs: List[tuple], args
|
||||
) -> List[RequestFuncInput]:
|
||||
"""Assigns estimated SLO targets to requests lacking them."""
|
||||
if not any(req.slo_ms is None for req in requests_list):
|
||||
return requests_list
|
||||
|
||||
base_time_ms = _infer_slo_base_time_ms_from_warmups(warmup_pairs, args)
|
||||
if base_time_ms is None:
|
||||
return requests_list
|
||||
|
||||
slo_scale = float(getattr(args, "slo_scale", 3.0))
|
||||
if slo_scale <= 0:
|
||||
raise ValueError(f"slo_scale must be positive, got {slo_scale}.")
|
||||
|
||||
updated: List[RequestFuncInput] = []
|
||||
for req in requests_list:
|
||||
if req.slo_ms is not None:
|
||||
updated.append(req)
|
||||
continue
|
||||
expected_ms = _compute_expected_latency_ms_from_base(req, args, base_time_ms)
|
||||
if expected_ms is not None:
|
||||
# Create a new RequestFuncInput with updated slo_ms
|
||||
updated.append(replace(req, slo_ms=expected_ms * slo_scale))
|
||||
else:
|
||||
updated.append(req)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
async def async_request_image_sglang(
|
||||
input: RequestFuncInput,
|
||||
session: aiohttp.ClientSession,
|
||||
pbar: Optional[tqdm] = None,
|
||||
) -> RequestFuncOutput:
|
||||
output = RequestFuncOutput()
|
||||
output.start_time = time.perf_counter()
|
||||
|
||||
# Check if we need to use multipart (for image edits with input images)
|
||||
if input.image_paths and len(input.image_paths) > 0:
|
||||
# Use multipart/form-data for image edits
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("model", input.model)
|
||||
data.add_field("prompt", input.prompt)
|
||||
data.add_field("response_format", "b64_json")
|
||||
data.add_field("n", str(input.num_outputs_per_prompt))
|
||||
|
||||
if input.width and input.height:
|
||||
data.add_field("size", f"{input.width}x{input.height}")
|
||||
|
||||
# Merge extra parameters
|
||||
for key, value in input.extra_body.items():
|
||||
data.add_field(key, str(value))
|
||||
|
||||
# Add image file(s)
|
||||
for idx, img_path in enumerate(input.image_paths):
|
||||
if os.path.exists(img_path):
|
||||
data.add_field(
|
||||
"image",
|
||||
open(img_path, "rb"),
|
||||
filename=os.path.basename(img_path),
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
else:
|
||||
output.error = f"Image file not found: {img_path}"
|
||||
output.success = False
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
|
||||
try:
|
||||
async with session.post(input.api_url, data=data) as response:
|
||||
if response.status == 200:
|
||||
resp_json = await response.json()
|
||||
output.response_body = resp_json
|
||||
output.success = True
|
||||
output.output_count = _get_response_output_count(resp_json)
|
||||
if "peak_memory_mb" in resp_json:
|
||||
output.peak_memory_mb = resp_json["peak_memory_mb"]
|
||||
else:
|
||||
output.error = f"HTTP {response.status}: {await response.text()}"
|
||||
output.success = False
|
||||
except Exception as e:
|
||||
output.error = str(e)
|
||||
output.success = False
|
||||
else:
|
||||
# Use JSON for text-to-image generation
|
||||
payload = {
|
||||
"model": input.model,
|
||||
"prompt": input.prompt,
|
||||
"n": input.num_outputs_per_prompt,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
|
||||
if input.width and input.height:
|
||||
payload["size"] = f"{input.width}x{input.height}"
|
||||
if input.num_inference_steps:
|
||||
payload["num_inference_steps"] = input.num_inference_steps
|
||||
|
||||
# Merge extra parameters
|
||||
payload.update(input.extra_body)
|
||||
|
||||
try:
|
||||
async with session.post(input.api_url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
resp_json = await response.json()
|
||||
output.response_body = resp_json
|
||||
output.success = True
|
||||
output.output_count = _get_response_output_count(resp_json)
|
||||
if "peak_memory_mb" in resp_json:
|
||||
output.peak_memory_mb = resp_json["peak_memory_mb"]
|
||||
else:
|
||||
output.error = f"HTTP {response.status}: {await response.text()}"
|
||||
output.success = False
|
||||
except Exception as e:
|
||||
output.error = str(e)
|
||||
output.success = False
|
||||
|
||||
output.latency = time.perf_counter() - output.start_time
|
||||
|
||||
# Check SLO if defined
|
||||
if input.slo_ms is not None and output.success:
|
||||
output.slo_achieved = (output.latency * 1000.0) <= input.slo_ms
|
||||
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
|
||||
|
||||
async def async_request_video_sglang(
|
||||
input: RequestFuncInput,
|
||||
session: aiohttp.ClientSession,
|
||||
pbar: Optional[tqdm] = None,
|
||||
) -> RequestFuncOutput:
|
||||
output = RequestFuncOutput()
|
||||
output.start_time = time.perf_counter()
|
||||
|
||||
# 1. Submit Job
|
||||
job_id = None
|
||||
# Check if we need to upload images (Multipart) or just send JSON
|
||||
if input.image_paths and len(input.image_paths) > 0:
|
||||
# Use multipart/form-data
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("model", input.model)
|
||||
data.add_field("prompt", input.prompt)
|
||||
data.add_field("num_outputs_per_prompt", str(input.num_outputs_per_prompt))
|
||||
|
||||
if input.width and input.height:
|
||||
data.add_field("size", f"{input.width}x{input.height}")
|
||||
|
||||
# Add extra body fields to form data if possible, or assume simple key-values
|
||||
# Note: Nested dicts in extra_body might need JSON serialization if API expects it stringified
|
||||
if input.extra_body:
|
||||
data.add_field("extra_body", json.dumps(input.extra_body))
|
||||
|
||||
# Explicitly add fps/num_frames if they are not in extra_body (bench_serving logic overrides)
|
||||
if input.num_frames:
|
||||
data.add_field("num_frames", str(input.num_frames))
|
||||
if input.fps:
|
||||
data.add_field("fps", str(input.fps))
|
||||
|
||||
# Add image file
|
||||
# Currently only support single image upload as 'input_reference' per API spec
|
||||
img_path = input.image_paths[0]
|
||||
if os.path.exists(img_path):
|
||||
data.add_field(
|
||||
"input_reference",
|
||||
open(img_path, "rb"),
|
||||
filename=os.path.basename(img_path),
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
else:
|
||||
output.error = f"Image file not found: {img_path}"
|
||||
output.success = False
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
|
||||
try:
|
||||
async with session.post(input.api_url, data=data) as response:
|
||||
if response.status == 200:
|
||||
resp_json = await response.json()
|
||||
job_id = resp_json.get("id")
|
||||
else:
|
||||
output.error = (
|
||||
f"Submit failed HTTP {response.status}: {await response.text()}"
|
||||
)
|
||||
output.success = False
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
except Exception as e:
|
||||
output.error = f"Submit exception: {str(e)}"
|
||||
output.success = False
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
|
||||
else:
|
||||
# Use JSON
|
||||
payload: Dict[str, Any] = {
|
||||
"model": input.model,
|
||||
"prompt": input.prompt,
|
||||
"num_outputs_per_prompt": input.num_outputs_per_prompt,
|
||||
}
|
||||
if input.width and input.height:
|
||||
payload["size"] = f"{input.width}x{input.height}"
|
||||
if input.num_frames:
|
||||
payload["num_frames"] = input.num_frames
|
||||
if input.num_inference_steps:
|
||||
payload["num_inference_steps"] = input.num_inference_steps
|
||||
if input.fps:
|
||||
payload["fps"] = input.fps
|
||||
|
||||
payload.update(input.extra_body)
|
||||
|
||||
try:
|
||||
async with session.post(input.api_url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
resp_json = await response.json()
|
||||
job_id = resp_json.get("id")
|
||||
else:
|
||||
output.error = (
|
||||
f"Submit failed HTTP {response.status}: {await response.text()}"
|
||||
)
|
||||
output.success = False
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
except Exception as e:
|
||||
output.error = f"Submit exception: {str(e)}"
|
||||
output.success = False
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
|
||||
if not job_id:
|
||||
output.error = "No job_id returned"
|
||||
output.success = False
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
|
||||
# 2. Poll for completion
|
||||
# Assuming the API returns a 'status' field.
|
||||
# We construct the check URL. Assuming api_url is like .../v1/videos
|
||||
# The check url should be .../v1/videos/{id}
|
||||
check_url = f"{input.api_url}/{job_id}"
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with session.get(check_url) as response:
|
||||
if response.status == 200:
|
||||
status_data = await response.json()
|
||||
status = status_data.get("status")
|
||||
if status == "completed":
|
||||
output.success = True
|
||||
output.response_body = status_data
|
||||
output.output_count = _get_response_output_count(status_data)
|
||||
if "peak_memory_mb" in status_data:
|
||||
output.peak_memory_mb = status_data["peak_memory_mb"]
|
||||
break
|
||||
elif status == "failed":
|
||||
output.success = False
|
||||
output.error = f"Job failed: {status_data.get('error')}"
|
||||
break
|
||||
else:
|
||||
# queued or processing
|
||||
await asyncio.sleep(1.0)
|
||||
else:
|
||||
output.success = False
|
||||
output.error = (
|
||||
f"Poll failed HTTP {response.status}: {await response.text()}"
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
output.success = False
|
||||
output.error = f"Poll exception: {str(e)}"
|
||||
break
|
||||
|
||||
output.latency = time.perf_counter() - output.start_time
|
||||
|
||||
# Check SLO if defined
|
||||
if input.slo_ms is not None and output.success:
|
||||
output.slo_achieved = (output.latency * 1000.0) <= input.slo_ms
|
||||
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
return output
|
||||
|
||||
|
||||
def calculate_metrics(
|
||||
outputs: List[RequestFuncOutput],
|
||||
total_duration: float,
|
||||
requests_list: List[RequestFuncInput],
|
||||
args,
|
||||
slo_enabled: bool,
|
||||
):
|
||||
success_outputs = [o for o in outputs if o.success]
|
||||
error_outputs = [o for o in outputs if not o.success]
|
||||
|
||||
num_success = len(success_outputs)
|
||||
latencies = [o.latency for o in success_outputs]
|
||||
completed_outputs = sum(o.output_count for o in success_outputs)
|
||||
peak_memories = [
|
||||
o.peak_memory_mb
|
||||
for o in success_outputs
|
||||
if o.peak_memory_mb is not None and o.peak_memory_mb > 0
|
||||
]
|
||||
|
||||
metrics = {
|
||||
"duration": total_duration,
|
||||
"completed_requests": num_success,
|
||||
"completed_outputs": completed_outputs,
|
||||
"failed_requests": len(error_outputs),
|
||||
"throughput_qps": num_success / total_duration if total_duration > 0 else 0,
|
||||
"output_throughput_ops": (
|
||||
completed_outputs / total_duration if total_duration > 0 else 0
|
||||
),
|
||||
"latency_mean": np.mean(latencies) if latencies else 0,
|
||||
"latency_median": np.median(latencies) if latencies else 0,
|
||||
"latency_p50": np.percentile(latencies, 50) if latencies else 0,
|
||||
"latency_p90": np.percentile(latencies, 90) if latencies else 0,
|
||||
"latency_p95": np.percentile(latencies, 95) if latencies else 0,
|
||||
"latency_p99": np.percentile(latencies, 99) if latencies else 0,
|
||||
"num_outputs_per_prompt": args.num_outputs_per_prompt,
|
||||
"peak_memory_mb_max": max(peak_memories) if peak_memories else 0,
|
||||
"peak_memory_mb_mean": np.mean(peak_memories) if peak_memories else 0,
|
||||
"peak_memory_mb_median": np.median(peak_memories) if peak_memories else 0,
|
||||
}
|
||||
|
||||
if slo_enabled:
|
||||
slo_defined_total = 0
|
||||
slo_met_success = 0
|
||||
|
||||
for req, out in zip(requests_list, outputs):
|
||||
if req.slo_ms is None:
|
||||
continue
|
||||
slo_defined_total += 1
|
||||
if out.slo_achieved:
|
||||
slo_met_success += 1
|
||||
|
||||
slo_attain_all = (
|
||||
(slo_met_success / slo_defined_total) if slo_defined_total > 0 else 0.0
|
||||
)
|
||||
|
||||
metrics.update(
|
||||
{
|
||||
"slo_attainment_rate": slo_attain_all,
|
||||
"slo_met_success": slo_met_success,
|
||||
"slo_scale": getattr(args, "slo_scale", 3.0),
|
||||
}
|
||||
)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def wait_for_service(base_url: str, timeout: int = 1200) -> None:
|
||||
logger.info(f"Waiting for service at {base_url}...")
|
||||
start_time = time.time()
|
||||
while True:
|
||||
try:
|
||||
# Try /health endpoint first
|
||||
resp = requests.get(f"{base_url}/health", timeout=1)
|
||||
if resp.status_code == 200:
|
||||
logger.info("Service is ready.")
|
||||
break
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError(
|
||||
f"Service at {base_url} did not start within {timeout} seconds."
|
||||
)
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
async def benchmark(args):
|
||||
from huggingface_hub import model_info
|
||||
|
||||
# Construct base_url if not provided
|
||||
if args.base_url is None:
|
||||
args.base_url = NetworkAddress(args.host, args.port).to_url()
|
||||
|
||||
# Wait for service
|
||||
wait_for_service(args.base_url)
|
||||
|
||||
# Fetch model info
|
||||
try:
|
||||
resp = requests.get(f"{args.base_url}/v1/model_info", timeout=5)
|
||||
if resp.status_code == 200:
|
||||
info = resp.json()
|
||||
if "model_path" in info and info["model_path"]:
|
||||
args.model = info["model_path"]
|
||||
logger.info(f"Updated model name from server: {args.model}")
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to fetch model info: {e}. Using default: {args.model}")
|
||||
|
||||
valid_tasks = (
|
||||
"text-to-video",
|
||||
"image-to-video",
|
||||
"video-to-video",
|
||||
"text-to-image",
|
||||
"image-to-image",
|
||||
)
|
||||
|
||||
# Resolve task_name with priority: args.task > local config > HF pipeline_tag
|
||||
if args.task:
|
||||
task_name = args.task
|
||||
logger.info(f"Using task from --task: {task_name}")
|
||||
elif os.path.exists(args.model):
|
||||
config_path = os.path.join(args.model, "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
task_name = config.get("pipeline_tag", "text-to-image")
|
||||
logger.info(f"Inferred task from local config.json: {task_name}")
|
||||
else:
|
||||
task_name = "text-to-image"
|
||||
logger.info(f"No config.json found, defaulting task to: {task_name}")
|
||||
else:
|
||||
task_name = model_info(args.model).pipeline_tag
|
||||
logger.info(f"Inferred task from HuggingFace pipeline_tag: {task_name}")
|
||||
|
||||
if task_name not in valid_tasks:
|
||||
raise ValueError(
|
||||
f"Task '{task_name}' is not a valid multimodal generation task. "
|
||||
f"Use --task to specify one of: {', '.join(valid_tasks)}"
|
||||
)
|
||||
|
||||
if task_name in ("text-to-video", "image-to-video", "video-to-video"):
|
||||
api_url = f"{args.base_url}/v1/videos"
|
||||
request_func = async_request_video_sglang
|
||||
else: # text-to-image or image-to-image
|
||||
api_url = (
|
||||
f"{args.base_url}/v1/images/edits"
|
||||
if task_name == "image-to-image"
|
||||
else f"{args.base_url}/v1/images/generations"
|
||||
)
|
||||
request_func = async_request_image_sglang
|
||||
|
||||
setattr(args, "task_name", task_name)
|
||||
|
||||
if args.random_request_config and args.dataset != "random":
|
||||
raise ValueError(
|
||||
"--random-request-config can only be used with --dataset random"
|
||||
)
|
||||
|
||||
if args.dataset == "vbench":
|
||||
dataset = VBenchDataset(args, api_url, args.model)
|
||||
elif args.dataset == "random":
|
||||
dataset = RandomDataset(args, api_url, args.model)
|
||||
else:
|
||||
raise ValueError(f"Unknown dataset: {args.dataset}")
|
||||
|
||||
logger.info("Loading requests...")
|
||||
requests_list = dataset.get_requests()
|
||||
logger.info(f"Prepared {len(requests_list)} requests from {args.dataset} dataset.")
|
||||
|
||||
# Limit concurrency
|
||||
if args.max_concurrency is not None:
|
||||
semaphore = asyncio.Semaphore(args.max_concurrency)
|
||||
else:
|
||||
semaphore = None
|
||||
|
||||
async def limited_request_func(req, session, pbar):
|
||||
if semaphore:
|
||||
async with semaphore:
|
||||
return await request_func(req, session, pbar)
|
||||
else:
|
||||
return await request_func(req, session, pbar)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Run warmup requests
|
||||
warmup_pairs: List[tuple] = []
|
||||
if args.warmup_requests and requests_list:
|
||||
# The server always overrides warmup requests to use
|
||||
# num_inference_steps=1 (see Req.set_as_warmup), so we match
|
||||
# that here to keep the benchmark's SLO estimation consistent.
|
||||
warmup_steps = 1
|
||||
logger.info(
|
||||
f"Running {args.warmup_requests} warmup request(s) with "
|
||||
f"num_inference_steps={warmup_steps}..."
|
||||
)
|
||||
for i in range(args.warmup_requests):
|
||||
warm_req = requests_list[i % len(requests_list)]
|
||||
warm_req = replace(
|
||||
warm_req,
|
||||
num_inference_steps=warmup_steps,
|
||||
)
|
||||
warm_out = await limited_request_func(warm_req, session, None)
|
||||
warmup_pairs.append((warm_req, warm_out))
|
||||
logger.info(
|
||||
f"Warmup {i+1}/{args.warmup_requests}: "
|
||||
f"latency={warm_out.latency:.2f}s, success={warm_out.success}"
|
||||
)
|
||||
|
||||
# Populate SLO values from warmups if enabled
|
||||
if args.slo:
|
||||
requests_list = _populate_slo_ms_from_warmups(
|
||||
requests_list=requests_list, warmup_pairs=warmup_pairs, args=args
|
||||
)
|
||||
|
||||
# Run benchmark
|
||||
pbar = tqdm(total=len(requests_list), disable=args.disable_tqdm)
|
||||
start_time = time.perf_counter()
|
||||
tasks = []
|
||||
for req in requests_list:
|
||||
if args.request_rate != float("inf"):
|
||||
# Poisson process: inter-arrival times follow exponential distribution
|
||||
interval = np.random.exponential(1.0 / args.request_rate)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
task = asyncio.create_task(limited_request_func(req, session, pbar))
|
||||
tasks.append(task)
|
||||
|
||||
outputs = await asyncio.gather(*tasks)
|
||||
total_duration = time.perf_counter() - start_time
|
||||
|
||||
pbar.close()
|
||||
|
||||
# Calculate metrics
|
||||
metrics = calculate_metrics(outputs, total_duration, requests_list, args, args.slo)
|
||||
|
||||
print("\n{s:{c}^{n}}".format(s=" Serving Benchmark Result ", n=60, c="="))
|
||||
|
||||
# Section 1: Configuration
|
||||
print_value_formatted("Task:", task_name)
|
||||
print_value_formatted("Model:", args.model)
|
||||
print_value_formatted("Dataset:", args.dataset)
|
||||
|
||||
# Section 2: Execution & Traffic
|
||||
print_divider(50)
|
||||
print_value_formatted("Benchmark duration (s):", metrics["duration"])
|
||||
print_value_formatted("Request rate:", str(args.request_rate))
|
||||
print_value_formatted(
|
||||
"Max request concurrency:",
|
||||
str(args.max_concurrency) if args.max_concurrency else "not set",
|
||||
)
|
||||
print_value_formatted(
|
||||
"Successful requests:",
|
||||
f"{metrics['completed_requests']}/{len(requests_list)}",
|
||||
)
|
||||
print_value_formatted("Completed outputs:", metrics["completed_outputs"])
|
||||
print_value_formatted("Outputs per prompt:", metrics["num_outputs_per_prompt"])
|
||||
|
||||
# Section 3: Performance Metrics
|
||||
print_divider(50)
|
||||
|
||||
print_value_formatted("Request throughput (req/s):", metrics["throughput_qps"])
|
||||
print_value_formatted(
|
||||
"Output throughput (outputs/s):", metrics["output_throughput_ops"]
|
||||
)
|
||||
|
||||
print_value_formatted("Latency Mean (s):", metrics["latency_mean"])
|
||||
print_value_formatted("Latency Median (s):", metrics["latency_median"])
|
||||
print_value_formatted("Latency P90 (s):", metrics["latency_p90"])
|
||||
print_value_formatted("Latency P95 (s):", metrics["latency_p95"])
|
||||
print_value_formatted("Latency P99 (s):", metrics["latency_p99"])
|
||||
|
||||
if metrics["peak_memory_mb_max"] > 0:
|
||||
print_divider(50)
|
||||
print_value_formatted("Peak Memory Max (MB):", metrics["peak_memory_mb_max"])
|
||||
print_value_formatted("Peak Memory Mean (MB):", metrics["peak_memory_mb_mean"])
|
||||
print_value_formatted(
|
||||
"Peak Memory Median (MB):", metrics["peak_memory_mb_median"]
|
||||
)
|
||||
|
||||
if args.slo and "slo_attainment_rate" in metrics:
|
||||
print_divider(50)
|
||||
print(
|
||||
"{:<40} {:<15.2%}".format(
|
||||
"SLO Attainment Rate:", metrics["slo_attainment_rate"]
|
||||
)
|
||||
)
|
||||
print("{:<40} {:<15}".format("SLO Met (Success):", metrics["slo_met_success"]))
|
||||
print("{:<40} {:<15.2f}".format("SLO Scale:", metrics["slo_scale"]))
|
||||
|
||||
print_divider(60)
|
||||
|
||||
if args.output_file:
|
||||
with open(args.output_file, "w") as f:
|
||||
json.dump(metrics, f, indent=2)
|
||||
print(f"Metrics saved to {args.output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Benchmark serving for diffusion models."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
type=str,
|
||||
default=None,
|
||||
help="DEPRECATED: --task is deprecated and will be ignored. The task will be inferred from --model.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Base URL of the server (e.g., http://localhost:30000). Overrides host/port.",
|
||||
)
|
||||
parser.add_argument("--host", type=str, default="localhost", help="Server host.")
|
||||
parser.add_argument("--port", type=int, default=30000, help="Server port.")
|
||||
parser.add_argument("--model", type=str, default="default", help="Model name.")
|
||||
parser.add_argument(
|
||||
"--dataset",
|
||||
type=str,
|
||||
default="vbench",
|
||||
choices=["vbench", "random"],
|
||||
help="Dataset to use.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task",
|
||||
type=str,
|
||||
choices=[
|
||||
"text-to-video",
|
||||
"image-to-video",
|
||||
"text-to-image",
|
||||
"image-to-image",
|
||||
"video-to-video",
|
||||
],
|
||||
default=None,
|
||||
help="The task will be inferred from huggingface pipeline_tag. When huggingface pipeline_tag is not provided, --task will be used.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dataset-path",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to local dataset file (optional).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-prompts", type=int, default=10, help="Number of prompts to benchmark."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-outputs-per-prompt",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of generated outputs requested per prompt.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-concurrency",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Maximum number of concurrent requests, default to `1`. This can be used "
|
||||
"to help simulate an environment where a higher level component "
|
||||
"is enforcing a maximum number of concurrent requests. While the "
|
||||
"--request-rate argument controls the rate at which requests are "
|
||||
"initiated, this argument will control how many are actually allowed "
|
||||
"to execute at a time. This means that when used in combination, the "
|
||||
"actual request rate may be lower than specified with --request-rate, "
|
||||
"if the server is not processing requests fast enough to keep up.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--request-rate",
|
||||
type=float,
|
||||
default=float("inf"),
|
||||
help="Number of requests per second. If this is inf, then all the requests are sent at time 0. "
|
||||
"Otherwise, we use Poisson process to synthesize the request arrival times. Default is inf.",
|
||||
)
|
||||
parser.add_argument("--width", type=int, default=None, help="Image/Video width.")
|
||||
parser.add_argument("--height", type=int, default=None, help="Image/Video height.")
|
||||
parser.add_argument(
|
||||
"--random-request-config",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"JSON string defining random request profiles. "
|
||||
"Each profile may contain: width, height, num_inference_steps, "
|
||||
"num_outputs_per_prompt, etc. "
|
||||
"The 'weight' field controls sampling probability (relative weight). "
|
||||
"Example: "
|
||||
'[{"width":512,"height":512,"num_inference_steps":20,"weight":0.15},'
|
||||
'{"width":768,"height":768,"num_inference_steps":20,"weight":0.85}]'
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--random-request-seed",
|
||||
type=int,
|
||||
default=42,
|
||||
help="Random seed for sampling request profiles (default: 42).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-frames", type=int, default=None, help="Number of frames (for video)."
|
||||
)
|
||||
parser.add_argument("--fps", type=int, default=None, help="FPS (for video).")
|
||||
parser.add_argument(
|
||||
"--output-file", type=str, default=None, help="Output JSON file for metrics."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disable-tqdm", action="store_true", help="Disable progress bar."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
type=str,
|
||||
default="INFO",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||
help="Log level.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slo",
|
||||
action="store_true",
|
||||
help="Enable SLO calculation. Uses trace-provided slo_ms or infers from warmups.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slo-scale",
|
||||
type=float,
|
||||
default=3.0,
|
||||
help="SLO target multiplier: slo_ms = estimated_exec_time_ms * slo_scale (default: 3).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warmup-requests",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of warmup requests to run before measurement.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-inference-steps",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Number of inference steps for diffusion models.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
configure_logger(args)
|
||||
|
||||
asyncio.run(benchmark(args))
|
||||
@@ -0,0 +1,301 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
def calculate_diff(base: float, new: float) -> Tuple[float, float]:
|
||||
"""Returns (diff, diff_percent)."""
|
||||
diff = new - base
|
||||
if base == 0:
|
||||
percent = 0.0
|
||||
else:
|
||||
percent = (diff / base) * 100
|
||||
return diff, percent
|
||||
|
||||
|
||||
def calculate_upper_bound(baseline: float, rel_tol: float, min_abs_tol: float) -> float:
|
||||
"""Calculates the upper bound for performance regression check."""
|
||||
rel_limit = baseline * (1 + rel_tol)
|
||||
abs_limit = baseline + min_abs_tol
|
||||
return max(rel_limit, abs_limit)
|
||||
|
||||
|
||||
def calculate_lower_bound(baseline: float, rel_tol: float, min_abs_tol: float) -> float:
|
||||
"""Calculates the lower bound for performance improvement check."""
|
||||
rel_lower = baseline * (1 - rel_tol)
|
||||
abs_lower = baseline - min_abs_tol
|
||||
return min(rel_lower, abs_lower)
|
||||
|
||||
|
||||
def get_perf_status_emoji(
|
||||
baseline: float,
|
||||
new: float,
|
||||
rel_tol: float = 0.1,
|
||||
min_abs_tol: float = 120.0,
|
||||
) -> str:
|
||||
"""
|
||||
Determines the status emoji based on performance difference.
|
||||
|
||||
Logic:
|
||||
Upper bound (Slower): max(baseline * (1 + rel_tol), baseline + min_abs_tol)
|
||||
Lower bound (Faster): min(baseline * (1 - rel_tol), baseline - min_abs_tol)
|
||||
"""
|
||||
upper_bound = calculate_upper_bound(baseline, rel_tol, min_abs_tol)
|
||||
lower_bound = calculate_lower_bound(baseline, rel_tol, min_abs_tol)
|
||||
|
||||
if new > upper_bound:
|
||||
return "🔴"
|
||||
elif new < lower_bound:
|
||||
return "🟢"
|
||||
else:
|
||||
return "⚪️"
|
||||
|
||||
|
||||
def consolidate_steps(
|
||||
steps_list: List[Dict[str, Any]],
|
||||
) -> Tuple[Dict[str, float], List[str], Dict[str, int]]:
|
||||
"""
|
||||
Aggregates specific repeating steps (like denoising_step_*) into groups.
|
||||
Returns:
|
||||
- aggregated_durations: {name: duration_ms}
|
||||
- ordered_names: list of names in execution order
|
||||
- counts: {name: count_of_steps_aggregated}
|
||||
"""
|
||||
durations = {}
|
||||
counts = {}
|
||||
ordered_names = []
|
||||
seen_names = set()
|
||||
|
||||
# Regex for steps to group
|
||||
# Group "denoising_step_0", "denoising_step_1" -> "Denoising Loop"
|
||||
denoise_pattern = re.compile(r"^denoising_step_(\d+)$")
|
||||
denoising_group_name = "Denoising Loop"
|
||||
|
||||
for step in steps_list:
|
||||
name = step.get("name", "unknown")
|
||||
dur = step.get("duration_ms", 0.0)
|
||||
|
||||
match = denoise_pattern.match(name)
|
||||
if match:
|
||||
key = denoising_group_name
|
||||
if key not in durations:
|
||||
durations[key] = 0.0
|
||||
counts[key] = 0
|
||||
if key not in seen_names:
|
||||
ordered_names.append(key)
|
||||
seen_names.add(key)
|
||||
durations[key] += dur
|
||||
counts[key] += 1
|
||||
else:
|
||||
# Standard stage (preserve order)
|
||||
if name not in durations:
|
||||
durations[name] = 0.0
|
||||
counts[name] = 0
|
||||
if name not in seen_names:
|
||||
ordered_names.append(name)
|
||||
seen_names.add(name)
|
||||
durations[name] += dur
|
||||
counts[name] += 1
|
||||
|
||||
return durations, ordered_names, counts
|
||||
|
||||
|
||||
def _load_benchmark_file(file_path: str) -> Dict[str, Any]:
|
||||
"""Loads a benchmark JSON file."""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _get_status_emoji_from_diff_percent(diff_pct):
|
||||
if diff_pct < -2.0:
|
||||
return "✅"
|
||||
elif diff_pct > 2.0:
|
||||
return "❌"
|
||||
else:
|
||||
return "⚪️"
|
||||
|
||||
|
||||
def _print_single_comparison_report(
|
||||
others_data, base_e2e, combined_order, base_durations, others_processed, base_counts
|
||||
):
|
||||
new_data = others_data[0]
|
||||
new_e2e = new_data.get("total_duration_ms", 0)
|
||||
diff_ms, diff_pct = calculate_diff(base_e2e, new_e2e)
|
||||
status = _get_status_emoji_from_diff_percent(diff_pct)
|
||||
|
||||
print("#### 1. High-level Summary")
|
||||
print("| Metric | Baseline | New | Diff | Status |")
|
||||
print("| :--- | :--- | :--- | :--- | :--- |")
|
||||
print(
|
||||
f"| **E2E Latency** | {base_e2e:.2f} ms | {new_e2e:.2f} ms | **{diff_ms:+.2f} ms ({diff_pct:+.1f}%)** | {status} |"
|
||||
)
|
||||
print(
|
||||
f"| **Throughput** | {1000 / base_e2e if base_e2e else 0:.2f} req/s | {1000 / new_e2e if new_e2e else 0:.2f} req/s | - | - |"
|
||||
)
|
||||
print("\n")
|
||||
|
||||
print("#### 2. Stage Breakdown")
|
||||
print("| Stage Name | Baseline (ms) | New (ms) | Diff (ms) | Diff (%) | Status |")
|
||||
print("| :--- | :--- | :--- | :--- | :--- | :--- |")
|
||||
|
||||
new_durations, _, new_counts = others_processed[0]
|
||||
|
||||
for stage in combined_order:
|
||||
b_val = base_durations.get(stage, 0.0)
|
||||
n_val = new_durations.get(stage, 0.0)
|
||||
b_count = base_counts.get(stage, 1)
|
||||
n_count = new_counts.get(stage, 1)
|
||||
|
||||
s_diff, s_pct = calculate_diff(b_val, n_val)
|
||||
|
||||
count_str = ""
|
||||
if stage == "Denoising Loop":
|
||||
count_str = (
|
||||
f" ({n_count} steps)"
|
||||
if n_count == b_count
|
||||
else f" ({b_count}->{n_count} steps)"
|
||||
)
|
||||
|
||||
status_emoji = get_perf_status_emoji(b_val, n_val)
|
||||
print(
|
||||
f"| {stage}{count_str} | {b_val:.2f} | {n_val:.2f} | {s_diff:+.2f} | {s_pct:+.1f}% | {status_emoji} |"
|
||||
)
|
||||
|
||||
|
||||
def _print_multi_comparison_report(
|
||||
base_e2e,
|
||||
others_data,
|
||||
other_labels,
|
||||
combined_order,
|
||||
base_durations,
|
||||
others_processed,
|
||||
):
|
||||
print("#### 1. High-level Summary")
|
||||
header = "| Metric | Baseline | " + " | ".join(other_labels) + " |"
|
||||
sep = "| :--- | :--- | " + " | ".join([":---"] * len(other_labels)) + " |"
|
||||
print(header)
|
||||
print(sep)
|
||||
|
||||
# E2E Row
|
||||
row_e2e = f"| **E2E Latency** | {base_e2e:.2f} ms |"
|
||||
for i, d in enumerate(others_data):
|
||||
val = d.get("total_duration_ms", 0)
|
||||
diff_ms, diff_pct = calculate_diff(base_e2e, val)
|
||||
|
||||
status = _get_status_emoji_from_diff_percent(diff_pct)
|
||||
|
||||
row_e2e += f" {val:.2f} ms ({diff_pct:+.1f}%) {status} |"
|
||||
print(row_e2e)
|
||||
print("\n")
|
||||
|
||||
print("#### 2. Stage Breakdown")
|
||||
# Header: Stage | Baseline | Label1 | Label2 ...
|
||||
header = "| Stage Name | Baseline | " + " | ".join(other_labels) + " |"
|
||||
sep = "| :--- | :--- | " + " | ".join([":---"] * len(other_labels)) + " |"
|
||||
print(header)
|
||||
print(sep)
|
||||
|
||||
for stage in combined_order:
|
||||
b_val = base_durations.get(stage, 0.0)
|
||||
row_str = f"| {stage} | {b_val:.2f} |"
|
||||
|
||||
for i, (n_durations, _, n_counts) in enumerate(others_processed):
|
||||
n_val = n_durations.get(stage, 0.0)
|
||||
_, s_pct = calculate_diff(b_val, n_val)
|
||||
status_emoji = get_perf_status_emoji(b_val, n_val)
|
||||
|
||||
row_str += f" {n_val:.2f} ({s_pct:+.1f}%) {status_emoji} |"
|
||||
print(row_str)
|
||||
|
||||
|
||||
def compare_benchmarks(file_paths: List[str], output_format: str = "markdown"):
|
||||
"""
|
||||
Compares benchmark JSON files and prints a report.
|
||||
First file is baseline, others will be compared against it.
|
||||
"""
|
||||
if len(file_paths) < 2:
|
||||
print("Error: Need at least 2 files to compare.")
|
||||
return
|
||||
|
||||
try:
|
||||
data_list = [_load_benchmark_file(f) for f in file_paths]
|
||||
except Exception as e:
|
||||
print(f"Error loading benchmark files: {e}")
|
||||
return
|
||||
|
||||
base_data = data_list[0]
|
||||
others_data = data_list[1:]
|
||||
|
||||
# Use filenames as labels if multiple comparisons, else just "New"
|
||||
other_labels = [os.path.basename(p) for p in file_paths[1:]]
|
||||
|
||||
base_e2e = base_data.get("total_duration_ms", 0)
|
||||
|
||||
base_durations, base_order, base_counts = consolidate_steps(
|
||||
base_data.get("steps", [])
|
||||
)
|
||||
|
||||
others_processed = []
|
||||
for d in others_data:
|
||||
dur, order, counts = consolidate_steps(d.get("steps", []))
|
||||
others_processed.append((dur, order, counts))
|
||||
|
||||
combined_order = []
|
||||
# Collect all unique stages maintaining order from newest to baseline
|
||||
for _, order, _ in reversed(others_processed):
|
||||
for name in order:
|
||||
if name not in combined_order:
|
||||
combined_order.append(name)
|
||||
for name in base_order:
|
||||
if name not in combined_order:
|
||||
combined_order.append(name)
|
||||
|
||||
if output_format == "markdown":
|
||||
print("### Performance Comparison Report\n")
|
||||
|
||||
if len(others_data) == 1:
|
||||
_print_single_comparison_report(
|
||||
others_data,
|
||||
base_e2e,
|
||||
combined_order,
|
||||
base_durations,
|
||||
others_processed,
|
||||
base_counts,
|
||||
)
|
||||
else:
|
||||
_print_multi_comparison_report(
|
||||
base_e2e,
|
||||
others_data,
|
||||
other_labels,
|
||||
combined_order,
|
||||
base_durations,
|
||||
others_processed,
|
||||
)
|
||||
|
||||
print("\n")
|
||||
# Metadata
|
||||
print("<details>")
|
||||
print("<summary>Metadata</summary>\n")
|
||||
print(f"- Baseline Commit: `{base_data.get('commit_hash', 'N/A')}`")
|
||||
for i, d in enumerate(others_data):
|
||||
label = "New" if len(others_data) == 1 else other_labels[i]
|
||||
print(f"- {label} Commit: `{d.get('commit_hash', 'N/A')}`")
|
||||
print(f"- Timestamp: {datetime.now().isoformat()}")
|
||||
print("</details>")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare sglang-diffusion performance JSON files."
|
||||
)
|
||||
parser.add_argument(
|
||||
"files",
|
||||
nargs="+",
|
||||
help="List of JSON files. First is baseline, others are compared against it.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
compare_benchmarks(args.files)
|
||||
@@ -0,0 +1,361 @@
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestFuncInput:
|
||||
prompt: str
|
||||
api_url: str = ""
|
||||
model: str = ""
|
||||
num_outputs_per_prompt: int = 1
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
num_frames: Optional[int] = None
|
||||
fps: Optional[int] = None
|
||||
extra_body: Dict[str, Any] = field(default_factory=dict)
|
||||
image_paths: Optional[List[str]] = None
|
||||
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
slo_ms: Optional[float] = None
|
||||
num_inference_steps: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestFuncOutput:
|
||||
success: bool = False
|
||||
latency: float = 0.0
|
||||
error: str = ""
|
||||
start_time: float = 0.0
|
||||
response_body: Dict[str, Any] = field(default_factory=dict)
|
||||
peak_memory_mb: float = 0.0
|
||||
slo_achieved: Optional[bool] = None
|
||||
output_count: int = 0
|
||||
|
||||
|
||||
def is_dir_not_empty(path: str) -> bool:
|
||||
return os.path.isdir(path) and bool(os.listdir(path))
|
||||
|
||||
|
||||
class BaseDataset(ABC):
|
||||
def __init__(self, args, api_url: str = "", model: str = ""):
|
||||
self.args = args
|
||||
self.api_url = api_url
|
||||
self.model = model
|
||||
self.items: List[Dict[str, Any]] = []
|
||||
|
||||
@abstractmethod
|
||||
def __len__(self) -> int:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __getitem__(self, idx: int) -> RequestFuncInput:
|
||||
pass
|
||||
|
||||
def get_requests(self) -> List[RequestFuncInput]:
|
||||
return [self[i] for i in range(len(self))]
|
||||
|
||||
|
||||
class VBenchDataset(BaseDataset):
|
||||
"""
|
||||
Dataset loader for VBench prompts.
|
||||
Supports t2v, i2v.
|
||||
"""
|
||||
|
||||
T2V_PROMPT_URL = "https://raw.githubusercontent.com/Vchitect/VBench/master/prompts/prompts_per_dimension/subject_consistency.txt"
|
||||
I2V_DOWNLOAD_SCRIPT_URL = "https://raw.githubusercontent.com/Vchitect/VBench/master/vbench2_beta_i2v/download_data.sh"
|
||||
|
||||
def __init__(self, args, api_url: str = "", model: str = ""):
|
||||
super().__init__(args, api_url, model)
|
||||
self.cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "sglang")
|
||||
self.items = self._load_data()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_task_name(task_name: Any) -> Any:
|
||||
"""Normalize enum-style task values to legacy benchmark task-name strings."""
|
||||
enum_to_task_name = {
|
||||
"T2V": "text-to-video",
|
||||
"I2V": "image-to-video",
|
||||
"TI2V": "image-to-video",
|
||||
"T2I": "text-to-image",
|
||||
"I2I": "image-to-image",
|
||||
"TI2I": "image-to-image",
|
||||
}
|
||||
|
||||
# Handle Enum-like objects, e.g., ModelTaskType.T2I
|
||||
enum_name = getattr(task_name, "name", None)
|
||||
if isinstance(enum_name, str):
|
||||
return enum_to_task_name.get(enum_name, task_name)
|
||||
|
||||
# Handle direct string inputs or enum string repr
|
||||
if isinstance(task_name, str):
|
||||
if task_name in enum_to_task_name:
|
||||
return enum_to_task_name[task_name]
|
||||
if "." in task_name:
|
||||
suffix = task_name.split(".")[-1]
|
||||
return enum_to_task_name.get(suffix, task_name)
|
||||
|
||||
return task_name
|
||||
|
||||
def _load_data(self) -> List[Dict[str, Any]]:
|
||||
task_name = self._normalize_task_name(self.args.task_name)
|
||||
|
||||
if task_name in ("text-to-video", "text-to-image", "video-to-video"):
|
||||
return self._load_t2v_prompts()
|
||||
elif task_name in ("image-to-video", "image-to-image"):
|
||||
return self._load_i2v_data()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Illegal task name is found in VBenchDataset {self.args.task_name}"
|
||||
)
|
||||
|
||||
def _download_file(self, url: str, dest_path: str) -> None:
|
||||
"""Download a file from URL to destination path."""
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
resp = requests.get(url)
|
||||
resp.raise_for_status()
|
||||
with open(dest_path, "w") as f:
|
||||
f.write(resp.text)
|
||||
|
||||
def _load_t2v_prompts(self) -> List[Dict[str, Any]]:
|
||||
path = self.args.dataset_path
|
||||
|
||||
if not path:
|
||||
path = os.path.join(self.cache_dir, "vbench_subject_consistency.txt")
|
||||
if not os.path.exists(path):
|
||||
logger.info(f"Downloading VBench T2V prompts to {path}...")
|
||||
try:
|
||||
self._download_file(self.T2V_PROMPT_URL, path)
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to download VBench prompts: {e}")
|
||||
return [{"prompt": "A cat sitting on a bench"}] * 50
|
||||
|
||||
prompts = []
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
prompts.append({"prompt": line})
|
||||
|
||||
return self._resize_data(prompts)
|
||||
|
||||
def _auto_download_i2v_dataset(self) -> Optional[str]:
|
||||
"""Auto-download VBench I2V dataset and return the dataset directory."""
|
||||
vbench_i2v_dir = os.path.join(self.cache_dir, "vbench_i2v", "vbench2_beta_i2v")
|
||||
info_json_path = os.path.join(vbench_i2v_dir, "data", "i2v-bench-info.json")
|
||||
crop_dir = os.path.join(vbench_i2v_dir, "data", "crop")
|
||||
origin_dir = os.path.join(vbench_i2v_dir, "data", "origin")
|
||||
|
||||
if (
|
||||
os.path.exists(info_json_path)
|
||||
and is_dir_not_empty(crop_dir)
|
||||
and is_dir_not_empty(origin_dir)
|
||||
):
|
||||
return vbench_i2v_dir
|
||||
|
||||
logger.info(f"Downloading VBench I2V dataset to {vbench_i2v_dir}...")
|
||||
try:
|
||||
cache_root = os.path.join(self.cache_dir, "vbench_i2v")
|
||||
script_path = os.path.join(cache_root, "download_data.sh")
|
||||
|
||||
self._download_file(self.I2V_DOWNLOAD_SCRIPT_URL, script_path)
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
logger.info("Executing download_data.sh (this may take a while)...")
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", script_path],
|
||||
cwd=cache_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Download script failed: {result.stderr}")
|
||||
missing_packages = re.findall(r"(\S+): command not found", result.stderr)
|
||||
if missing_packages:
|
||||
missing_packages = list(set(missing_packages))
|
||||
package_list = ", ".join(f"'{cmd}'" for cmd in missing_packages)
|
||||
raise RuntimeError(
|
||||
f"Download script failed because the following commands are not installed: {package_list}.\n"
|
||||
"Please install them (e.g., on Ubuntu: `sudo apt install ...`) and try again."
|
||||
)
|
||||
logger.info(
|
||||
f"Successfully downloaded VBench I2V dataset to {vbench_i2v_dir}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to download VBench I2V dataset: {e}")
|
||||
logger.info("Please manually download following instructions at:")
|
||||
logger.info(
|
||||
"https://github.com/Vchitect/VBench/tree/master/vbench2_beta_i2v#22-download"
|
||||
)
|
||||
return None
|
||||
|
||||
return vbench_i2v_dir if os.path.exists(info_json_path) else None
|
||||
|
||||
def _load_from_i2v_json(self, json_path: str) -> List[Dict[str, Any]]:
|
||||
"""Load I2V data from i2v-bench-info.json format."""
|
||||
with open(json_path, "r") as f:
|
||||
items = json.load(f)
|
||||
|
||||
base_dir = os.path.dirname(
|
||||
os.path.dirname(json_path)
|
||||
) # Go up to vbench2_beta_i2v
|
||||
origin_dir = os.path.join(base_dir, "data", "origin")
|
||||
|
||||
data = []
|
||||
for item in items:
|
||||
img_path = os.path.join(origin_dir, item.get("file_name", ""))
|
||||
if os.path.exists(img_path):
|
||||
data.append({"prompt": item.get("caption", ""), "image_path": img_path})
|
||||
else:
|
||||
logger.warning(f"Image not found: {img_path}")
|
||||
|
||||
logger.info(f"Loaded {len(data)} I2V samples from VBench I2V dataset")
|
||||
return data
|
||||
|
||||
def _scan_directory_for_images(self, path: str) -> List[Dict[str, Any]]:
|
||||
"""Scan directory for image files."""
|
||||
exts = ["*.jpg", "*.jpeg", "*.png", "*.webp"]
|
||||
files = []
|
||||
|
||||
for ext in exts:
|
||||
files.extend(glob.glob(os.path.join(path, ext)))
|
||||
files.extend(glob.glob(os.path.join(path, ext.upper())))
|
||||
|
||||
origin_dir = os.path.join(path, "data", "origin")
|
||||
if os.path.exists(origin_dir):
|
||||
files.extend(glob.glob(os.path.join(origin_dir, ext)))
|
||||
files.extend(glob.glob(os.path.join(origin_dir, ext.upper())))
|
||||
|
||||
return [
|
||||
{"prompt": os.path.splitext(os.path.basename(f))[0], "image_path": f}
|
||||
for f in files
|
||||
]
|
||||
|
||||
def _create_dummy_data(self) -> List[Dict[str, Any]]:
|
||||
"""Create dummy data with a placeholder image in cache directory."""
|
||||
logger.info("No I2V data found. Using dummy placeholders.")
|
||||
|
||||
dummy_image = os.path.join(self.cache_dir, "dummy_image.jpg")
|
||||
if not os.path.exists(dummy_image):
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
img.save(dummy_image)
|
||||
logger.info(f"Created dummy image at {dummy_image}")
|
||||
|
||||
return [{"prompt": "A moving cat", "image_path": dummy_image}] * 10
|
||||
|
||||
def _load_i2v_data(self) -> List[Dict[str, Any]]:
|
||||
"""Load I2V data from VBench I2V dataset or user-provided path."""
|
||||
path = self.args.dataset_path
|
||||
if not path:
|
||||
path = self._auto_download_i2v_dataset()
|
||||
if not path:
|
||||
return self._resize_data(self._create_dummy_data())
|
||||
|
||||
info_json_candidates = [
|
||||
os.path.join(path, "data", "i2v-bench-info.json"),
|
||||
path if path.endswith(".json") else None,
|
||||
]
|
||||
|
||||
for json_path in info_json_candidates:
|
||||
if json_path and os.path.exists(json_path):
|
||||
try:
|
||||
return self._resize_data(self._load_from_i2v_json(json_path))
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to load {json_path}: {e}")
|
||||
|
||||
if os.path.isdir(path):
|
||||
data = self._scan_directory_for_images(path)
|
||||
if data:
|
||||
return self._resize_data(data)
|
||||
|
||||
return self._resize_data(self._create_dummy_data())
|
||||
|
||||
def _resize_data(self, data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Resize data to match num_prompts."""
|
||||
if not self.args.num_prompts:
|
||||
return data
|
||||
|
||||
if len(data) < self.args.num_prompts:
|
||||
factor = (self.args.num_prompts // len(data)) + 1
|
||||
data = data * factor
|
||||
|
||||
return data[: self.args.num_prompts]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.items)
|
||||
|
||||
def __getitem__(self, idx: int) -> RequestFuncInput:
|
||||
item = self.items[idx]
|
||||
return RequestFuncInput(
|
||||
prompt=item.get("prompt", ""),
|
||||
api_url=self.api_url,
|
||||
model=self.model,
|
||||
num_outputs_per_prompt=self.args.num_outputs_per_prompt,
|
||||
width=self.args.width,
|
||||
height=self.args.height,
|
||||
num_frames=self.args.num_frames,
|
||||
fps=self.args.fps,
|
||||
num_inference_steps=self.args.num_inference_steps,
|
||||
image_paths=[item["image_path"]] if "image_path" in item else None,
|
||||
)
|
||||
|
||||
|
||||
class RandomDataset(BaseDataset):
|
||||
def __init__(self, args, api_url: str = "", model: str = ""):
|
||||
super().__init__(args, api_url, model)
|
||||
self.num_prompts = args.num_prompts or 100
|
||||
|
||||
self.random_request_config = args.random_request_config
|
||||
if self.random_request_config:
|
||||
self.random_request_config = json.loads(self.random_request_config)
|
||||
weights = [p.pop("weight") for p in self.random_request_config]
|
||||
seed = args.random_request_seed
|
||||
rng = random.Random(seed)
|
||||
self._sampled_requests = rng.choices(
|
||||
self.random_request_config, weights=weights, k=self.num_prompts
|
||||
)
|
||||
else:
|
||||
self._sampled_requests = None
|
||||
|
||||
def get_sampling_params(self, idx: int) -> dict:
|
||||
"""Return the per-request sampling profile dict, or empty dict if not mix-diffusion."""
|
||||
if self._sampled_requests:
|
||||
return self._sampled_requests[idx]
|
||||
return {}
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.num_prompts
|
||||
|
||||
def __getitem__(self, idx: int) -> RequestFuncInput:
|
||||
profile = self._sampled_requests[idx] if self._sampled_requests else {}
|
||||
|
||||
return RequestFuncInput(
|
||||
prompt=f"Random prompt {idx} for benchmarking diffusion models",
|
||||
api_url=self.api_url,
|
||||
model=self.model,
|
||||
num_outputs_per_prompt=profile.get(
|
||||
"num_outputs_per_prompt", self.args.num_outputs_per_prompt
|
||||
),
|
||||
width=profile.get("width", self.args.width),
|
||||
height=profile.get("height", self.args.height),
|
||||
num_frames=profile.get("num_frames", self.args.num_frames),
|
||||
num_inference_steps=profile.get(
|
||||
"num_inference_steps", self.args.num_inference_steps
|
||||
),
|
||||
fps=profile.get("fps", self.args.fps),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# Configs for pipelines, and pipeline modules (in models folder)
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"temporal_chunk_size": 2,
|
||||
"temporal_topk": 2,
|
||||
"spatial_chunk_size": [4, 13],
|
||||
"spatial_topk": 6,
|
||||
"st_chunk_size": [4, 4, 13],
|
||||
"st_topk": 18,
|
||||
"moba_select_mode": "topk",
|
||||
"moba_threshold": 0.25,
|
||||
"moba_threshold_type": "query_head",
|
||||
"first_full_layer": 0,
|
||||
"first_full_step": 12,
|
||||
"temporal_layer": 1,
|
||||
"spatial_layer": 1,
|
||||
"st_layer": 1
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"temporal_chunk_size": 2,
|
||||
"temporal_topk": 3,
|
||||
"spatial_chunk_size": [3, 4],
|
||||
"spatial_topk": 20,
|
||||
"st_chunk_size": [4, 6, 4],
|
||||
"st_topk": 15,
|
||||
"moba_select_mode": "threshold",
|
||||
"moba_threshold": 0.25,
|
||||
"moba_threshold_type": "query_head",
|
||||
"first_full_layer": 0,
|
||||
"first_full_step": 12,
|
||||
"temporal_layer": 1,
|
||||
"spatial_layer": 1,
|
||||
"st_layer": 1
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
from sglang.multimodal_gen.configs.models.base import ModelConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import EncoderConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.base import VAEConfig
|
||||
|
||||
__all__ = ["ModelConfig", "VAEConfig", "DiTConfig", "EncoderConfig"]
|
||||
@@ -0,0 +1,65 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sglang.multimodal_gen.configs.models.base import ArchConfig, ModelConfig
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdapterArchConfig(ArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=list)
|
||||
_compile_conditions: list = field(default_factory=list)
|
||||
|
||||
# convert weights name from HF-format to SGLang-dit-format
|
||||
param_names_mapping: dict = field(default_factory=dict)
|
||||
|
||||
# Reverse mapping for saving checkpoints: custom -> hf
|
||||
reverse_param_names_mapping: dict = field(default_factory=dict)
|
||||
_supported_attention_backends: set[AttentionBackendEnum] = field(
|
||||
default_factory=lambda: {
|
||||
AttentionBackendEnum.SLIDING_TILE_ATTN,
|
||||
AttentionBackendEnum.SAGE_ATTN,
|
||||
AttentionBackendEnum.FA,
|
||||
AttentionBackendEnum.AITER,
|
||||
AttentionBackendEnum.AITER_SAGE,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
AttentionBackendEnum.VIDEO_SPARSE_ATTN,
|
||||
AttentionBackendEnum.VMOBA_ATTN,
|
||||
AttentionBackendEnum.SAGE_ATTN_3,
|
||||
AttentionBackendEnum.LASER_ATTN,
|
||||
AttentionBackendEnum.BLOCK_SPARSE_ATTN,
|
||||
AttentionBackendEnum.RAIN_FUSION_ATTN,
|
||||
}
|
||||
)
|
||||
|
||||
hidden_size: int = 0
|
||||
num_attention_heads: int = 0
|
||||
num_channels_latents: int = 0
|
||||
exclude_lora_layers: list[str] = field(default_factory=list)
|
||||
boundary_ratio: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self._compile_conditions:
|
||||
self._compile_conditions = self._fsdp_shard_conditions.copy()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdapterConfig(ModelConfig):
|
||||
arch_config: AdapterArchConfig = field(default_factory=AdapterArchConfig)
|
||||
|
||||
# sglang-diffusion Adapter-specific parameters
|
||||
prefix: str = ""
|
||||
|
||||
@staticmethod
|
||||
def add_cli_args(parser: Any, prefix: str = "dit-config") -> Any:
|
||||
"""Add CLI arguments for AdapterConfig fields"""
|
||||
parser.add_argument(
|
||||
f"--{prefix}.prefix",
|
||||
type=str,
|
||||
dest=f"{prefix.replace('-', '_')}.prefix",
|
||||
default=AdapterConfig.prefix,
|
||||
help="Prefix for the Adapter",
|
||||
)
|
||||
|
||||
return parser
|
||||
@@ -0,0 +1,37 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.adapter.base import (
|
||||
AdapterArchConfig,
|
||||
AdapterConfig,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LTX2ConnectorArchConfig(AdapterArchConfig):
|
||||
audio_connector_attention_head_dim: int = 128
|
||||
audio_connector_num_attention_heads: int = 30
|
||||
audio_connector_num_layers: int = 2
|
||||
audio_connector_num_learnable_registers: int = 128
|
||||
audio_feature_extractor_out_features: int = 0
|
||||
caption_channels: int = 3840
|
||||
causal_temporal_positioning: bool = False
|
||||
connector_rope_base_seq_len: int = 4096
|
||||
connector_apply_gated_attention: bool = False
|
||||
feature_extractor_in_features: int = 0
|
||||
rope_double_precision: bool = True
|
||||
rope_theta: float = 10000.0
|
||||
rope_type: str = "split"
|
||||
text_proj_in_factor: int = 49
|
||||
video_feature_extractor_out_features: int = 0
|
||||
video_connector_attention_head_dim: int = 128
|
||||
video_connector_num_attention_heads: int = 30
|
||||
video_connector_num_layers: int = 2
|
||||
video_connector_num_learnable_registers: int = 128
|
||||
|
||||
|
||||
@dataclass
|
||||
class LTX2ConnectorConfig(AdapterConfig):
|
||||
|
||||
arch_config: AdapterArchConfig = field(default_factory=LTX2ConnectorArchConfig)
|
||||
|
||||
prefix: str = "LTX2"
|
||||
@@ -0,0 +1,100 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import Any, Dict
|
||||
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
# 1. ArchConfig contains all fields from diffuser's/transformer's config.json (i.e. all fields related to the architecture of the model)
|
||||
# 2. ArchConfig should be inherited & overridden by each model arch_config
|
||||
# 3. Any field in ArchConfig is fixed upon initialization, and should be hidden away from users
|
||||
@dataclass
|
||||
class ArchConfig:
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=list
|
||||
) # mapping from huggingface weight names to custom names
|
||||
extra_attrs: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
d = object.__getattribute__(self, "__dict__")
|
||||
extras = d.get("extra_attrs")
|
||||
if extras is not None and name in extras:
|
||||
return extras[name]
|
||||
raise AttributeError(
|
||||
f"'{self.__class__.__name__}' object has no attribute '{name}'"
|
||||
)
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key in type(self).__dataclass_fields__:
|
||||
object.__setattr__(self, key, value)
|
||||
else:
|
||||
d = object.__getattribute__(self, "__dict__")
|
||||
extras = d.get("extra_attrs")
|
||||
if extras is None:
|
||||
extras = {}
|
||||
d["extra_attrs"] = extras
|
||||
extras[key] = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
# Every model config parameter can be categorized into either ArchConfig or everything else
|
||||
# Diffuser/Transformer parameters
|
||||
arch_config: ArchConfig = field(default_factory=ArchConfig)
|
||||
|
||||
# sglang-diffusion-specific parameters here
|
||||
# i.e. STA, quantization, teacache
|
||||
|
||||
def __getattr__(self, name):
|
||||
# Only called if 'name' is not found in ModelConfig directly
|
||||
if hasattr(self.arch_config, name):
|
||||
return getattr(self.arch_config, name)
|
||||
raise AttributeError(
|
||||
f"'{type(self).__name__}' object has no attribute '{name}'"
|
||||
)
|
||||
|
||||
def __getstate__(self):
|
||||
# Return a dictionary of attributes to pickle
|
||||
# Convert to dict and exclude any problematic attributes
|
||||
state = self.__dict__.copy()
|
||||
return state
|
||||
|
||||
def __setstate__(self, state):
|
||||
# Restore instance attributes from the unpickled state
|
||||
self.__dict__.update(state)
|
||||
|
||||
# This should be used only when loading from transformers/diffusers
|
||||
def update_model_arch(self, source_model_dict: dict[str, Any]) -> None:
|
||||
"""
|
||||
Update arch_config with source_model_dict
|
||||
"""
|
||||
arch_config = self.arch_config
|
||||
|
||||
for key, value in source_model_dict.items():
|
||||
setattr(arch_config, key, value)
|
||||
|
||||
if hasattr(arch_config, "__post_init__"):
|
||||
arch_config.__post_init__()
|
||||
|
||||
def update_model_config(self, source_model_dict: dict[str, Any]) -> None:
|
||||
assert (
|
||||
"arch_config" not in source_model_dict
|
||||
), "Source model config shouldn't contain arch_config."
|
||||
|
||||
valid_fields = {f.name for f in fields(self)}
|
||||
|
||||
for key, value in source_model_dict.items():
|
||||
if key in valid_fields:
|
||||
setattr(self, key, value)
|
||||
else:
|
||||
logger.warning(
|
||||
"%s does not contain field '%s'!", type(self).__name__, key
|
||||
)
|
||||
raise AttributeError(f"Invalid field: {key}")
|
||||
|
||||
if hasattr(self, "__post_init__"):
|
||||
self.__post_init__()
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from sglang.multimodal_gen.configs.models.bridges.mova_dual_tower import (
|
||||
MOVADualTowerConfig,
|
||||
)
|
||||
|
||||
__all__ = ["MOVADualTowerConfig"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Configuration for MOVA dual tower bridge model."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
|
||||
|
||||
def _is_conditioner_block(name: str, module) -> bool:
|
||||
"""Check if module is a ConditionalCrossAttentionBlock."""
|
||||
return "ConditionalCrossAttentionBlock" in type(module).__name__
|
||||
|
||||
|
||||
@dataclass
|
||||
class MOVADualTowerArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [_is_conditioner_block]
|
||||
)
|
||||
|
||||
# Model architecture parameters
|
||||
visual_layers: int = 40
|
||||
audio_layers: int = 30
|
||||
visual_hidden_dim: int = 5120
|
||||
audio_hidden_dim: int = 1536
|
||||
audio_fps: float = 50.0
|
||||
head_dim: int = 128
|
||||
interaction_strategy: str = "full"
|
||||
apply_cross_rope: bool = True
|
||||
apply_first_frame_bias_in_rope: bool = False
|
||||
trainable_condition_scale: bool = False
|
||||
pooled_adaln: bool = False
|
||||
eps: float = 1e-6
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.hidden_size = self.visual_hidden_dim
|
||||
self.num_attention_heads = self.visual_hidden_dim // self.head_dim
|
||||
|
||||
|
||||
@dataclass
|
||||
class MOVADualTowerConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=MOVADualTowerArchConfig)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.cosmos3video import Cosmos3VideoConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.helios import HeliosConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.hunyuan3d import Hunyuan3DDiTConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.hunyuanvideo import HunyuanVideoConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.ideogram import Ideogram4DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.lingbot_world import (
|
||||
LingBotWorldVideoConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.dits.mova_audio import MOVAAudioConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.mova_video import MOVAVideoConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.stablediffusion3 import (
|
||||
StableDiffusion3TransformerConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.dits.wanvideo import WanVideoConfig
|
||||
|
||||
__all__ = [
|
||||
"Cosmos3VideoConfig",
|
||||
"HeliosConfig",
|
||||
"HunyuanVideoConfig",
|
||||
"Ideogram4DiTConfig",
|
||||
"LingBotWorldVideoConfig",
|
||||
"WanVideoConfig",
|
||||
"Hunyuan3DDiTConfig",
|
||||
"MOVAAudioConfig",
|
||||
"MOVAVideoConfig",
|
||||
"StableDiffusion3TransformerConfig",
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sglang.multimodal_gen.configs.models.base import ArchConfig, ModelConfig
|
||||
from sglang.multimodal_gen.runtime.layers.quantization import QuantizationConfig
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiTArchConfig(ArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=list)
|
||||
_compile_conditions: list = field(default_factory=list)
|
||||
|
||||
# convert weights name from HF-format to SGLang-dit-format
|
||||
param_names_mapping: dict = field(default_factory=dict)
|
||||
|
||||
# convert weights name from misc-format to HF-format
|
||||
# usually applicable if the LoRA is trained with official repo implementation
|
||||
lora_param_names_mapping: dict = field(default_factory=dict)
|
||||
|
||||
# Reverse mapping for saving checkpoints: custom -> hf
|
||||
reverse_param_names_mapping: dict = field(default_factory=dict)
|
||||
_supported_attention_backends: set[AttentionBackendEnum] = field(
|
||||
default_factory=lambda: {
|
||||
AttentionBackendEnum.SLIDING_TILE_ATTN,
|
||||
AttentionBackendEnum.SAGE_ATTN,
|
||||
AttentionBackendEnum.FA,
|
||||
AttentionBackendEnum.AITER,
|
||||
AttentionBackendEnum.AITER_SAGE,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
AttentionBackendEnum.VIDEO_SPARSE_ATTN,
|
||||
AttentionBackendEnum.SPARSE_VIDEO_GEN_2_ATTN,
|
||||
AttentionBackendEnum.VMOBA_ATTN,
|
||||
AttentionBackendEnum.SAGE_ATTN_3,
|
||||
AttentionBackendEnum.LASER_ATTN,
|
||||
AttentionBackendEnum.BLOCK_SPARSE_ATTN,
|
||||
AttentionBackendEnum.RAIN_FUSION_ATTN,
|
||||
}
|
||||
)
|
||||
|
||||
hidden_size: int = 0
|
||||
num_attention_heads: int = 0
|
||||
num_channels_latents: int = 0
|
||||
exclude_lora_layers: list[str] = field(default_factory=list)
|
||||
boundary_ratio: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self._compile_conditions:
|
||||
self._compile_conditions = self._fsdp_shard_conditions.copy()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiTConfig(ModelConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=DiTArchConfig)
|
||||
|
||||
# sglang-diffusion DiT-specific parameters
|
||||
prefix: str = ""
|
||||
quant_config: QuantizationConfig | None = None
|
||||
torch_compile_mode: str = "max-autotune-no-cudagraphs"
|
||||
|
||||
@staticmethod
|
||||
def add_cli_args(parser: Any, prefix: str = "dit-config") -> Any:
|
||||
"""Add CLI arguments for DiTConfig fields"""
|
||||
parser.add_argument(
|
||||
f"--{prefix}.prefix",
|
||||
type=str,
|
||||
dest=f"{prefix.replace('-', '_')}.prefix",
|
||||
default=DiTConfig.prefix,
|
||||
help="Prefix for the DiT model",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
f"--{prefix}.quant-config",
|
||||
type=str,
|
||||
dest=f"{prefix.replace('-', '_')}.quant_config",
|
||||
default=None,
|
||||
help="Quantization configuration for the DiT model",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.torch-compile-mode",
|
||||
type=str,
|
||||
dest=f"{prefix.replace('-', '_')}.torch_compile_mode",
|
||||
default=DiTConfig.torch_compile_mode,
|
||||
help="torch.compile mode for the DiT model",
|
||||
)
|
||||
|
||||
return parser
|
||||
@@ -0,0 +1,206 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Cosmos3 video DiT architecture configuration."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_module_list_entry_in
|
||||
|
||||
|
||||
def is_layers(n: str, m) -> bool:
|
||||
return is_module_list_entry_in(n, ("layers", "gen_layers"))
|
||||
|
||||
|
||||
def _build_cosmos3_param_names_mapping() -> dict:
|
||||
"""Map diffusers-format Cosmos3 weights to the sglang model namespace.
|
||||
|
||||
Source keys (diffusers transformer ckpt) → target keys (sglang model):
|
||||
embed_tokens.weight -> language_model.embed_tokens.weight
|
||||
layers.X.input_layernorm.weight -> language_model.layers.X.input_layernorm.weight
|
||||
layers.X.input_layernorm_moe_gen.weight -> gen_layers.X.input_layernorm.weight
|
||||
layers.X.self_attn.{to_q,to_k,to_v}.weight -> language_model.layers.X.self_attn.to_qkv.weight (concat dim 0)
|
||||
layers.X.self_attn.{add_q,add_k,add_v}_proj.weight -> gen_layers.X.cross_attention.to_qkv.weight (concat dim 0)
|
||||
layers.X.mlp.{gate,up}_proj.weight -> language_model.layers.X.mlp.gate_up_proj.weight (concat dim 0)
|
||||
layers.X.mlp_moe_gen.{gate,up}_proj.weight -> gen_layers.X.mlp.gate_up_proj.weight (concat dim 0)
|
||||
norm_moe_gen.weight -> norm_moe_gen.weight
|
||||
time_embedder.linear_{1,2}.weight -> (pass-through)
|
||||
proj_in.weight, proj_out.weight -> (pass-through)
|
||||
vae2llm.weight -> proj_in.weight (FP8 ckpt alias)
|
||||
llm2vae.weight -> proj_out.weight (FP8 ckpt alias)
|
||||
|
||||
GEN patterns (`*_moe_gen`, `add_*`, `to_add_out`, `norm_added_*`) must
|
||||
precede the UND catch-all so the catch-all can't claim GEN keys.
|
||||
`norm.weight` and `lm_head.weight` are inherited from Qwen3-VL
|
||||
pretraining and not used at inference; both are skipped.
|
||||
Audio and action keys (``audio_proj_*``, ``action_proj_*``, modality
|
||||
embeds) pass through unchanged.
|
||||
"""
|
||||
return {
|
||||
# Inherited from Qwen3-VL pretraining; unused at diffusion inference.
|
||||
r"^lm_head\.weight$": "",
|
||||
r"^norm\.weight$": "",
|
||||
# Top-level norms / embeddings.
|
||||
r"^norm_moe_gen\.(.*)$": r"norm_moe_gen.\1",
|
||||
r"^embed_tokens\.(.*)$": r"language_model.embed_tokens.\1",
|
||||
# FP8 checkpoint aliases for the latent projection layers.
|
||||
r"^vae2llm\.(.*)$": r"proj_in.\1",
|
||||
r"^llm2vae\.(.*)$": r"proj_out.\1",
|
||||
# GEN pathway: per-layer (must run before the UND catch-all below).
|
||||
# Q/K/V merge into MergedColumnParallelLinear to_qkv (concat order: Q, K, V).
|
||||
r"^layers\.(\d+)\.self_attn\.add_q_proj\.(.*)$": (
|
||||
r"gen_layers.\1.cross_attention.to_qkv.\2",
|
||||
0,
|
||||
3,
|
||||
),
|
||||
r"^layers\.(\d+)\.self_attn\.add_k_proj\.(.*)$": (
|
||||
r"gen_layers.\1.cross_attention.to_qkv.\2",
|
||||
1,
|
||||
3,
|
||||
),
|
||||
r"^layers\.(\d+)\.self_attn\.add_v_proj\.(.*)$": (
|
||||
r"gen_layers.\1.cross_attention.to_qkv.\2",
|
||||
2,
|
||||
3,
|
||||
),
|
||||
r"^layers\.(\d+)\.self_attn\.to_add_out\.(.*)$": r"gen_layers.\1.cross_attention.to_out.\2",
|
||||
r"^layers\.(\d+)\.self_attn\.norm_added_q\.(.*)$": r"gen_layers.\1.cross_attention.norm_q.\2",
|
||||
r"^layers\.(\d+)\.self_attn\.norm_added_k\.(.*)$": r"gen_layers.\1.cross_attention.norm_k.\2",
|
||||
r"^layers\.(\d+)\.input_layernorm_moe_gen\.(.*)$": r"gen_layers.\1.input_layernorm.\2",
|
||||
r"^layers\.(\d+)\.post_attention_layernorm_moe_gen\.(.*)$": r"gen_layers.\1.post_attention_layernorm.\2",
|
||||
# GEN MLP gate/up merge into MergedColumnParallelLinear gate_up_proj.
|
||||
# Must precede the mlp_moe_gen catch-all below.
|
||||
r"^layers\.(\d+)\.mlp_moe_gen\.gate_proj\.(.*)$": (
|
||||
r"gen_layers.\1.mlp.gate_up_proj.\2",
|
||||
0,
|
||||
2,
|
||||
),
|
||||
r"^layers\.(\d+)\.mlp_moe_gen\.up_proj\.(.*)$": (
|
||||
r"gen_layers.\1.mlp.gate_up_proj.\2",
|
||||
1,
|
||||
2,
|
||||
),
|
||||
r"^layers\.(\d+)\.mlp_moe_gen\.(.*)$": r"gen_layers.\1.mlp.\2",
|
||||
# UND pathway: Q/K/V merge into to_qkv; remaining attention keys
|
||||
# (to_out, norm_q, norm_k) and layernorms pass through the catch-all.
|
||||
r"^layers\.(\d+)\.self_attn\.to_q\.(.*)$": (
|
||||
r"language_model.layers.\1.self_attn.to_qkv.\2",
|
||||
0,
|
||||
3,
|
||||
),
|
||||
r"^layers\.(\d+)\.self_attn\.to_k\.(.*)$": (
|
||||
r"language_model.layers.\1.self_attn.to_qkv.\2",
|
||||
1,
|
||||
3,
|
||||
),
|
||||
r"^layers\.(\d+)\.self_attn\.to_v\.(.*)$": (
|
||||
r"language_model.layers.\1.self_attn.to_qkv.\2",
|
||||
2,
|
||||
3,
|
||||
),
|
||||
# UND MLP gate/up merge into MergedColumnParallelLinear gate_up_proj.
|
||||
# Must precede the layers catch-all below.
|
||||
r"^layers\.(\d+)\.mlp\.gate_proj\.(.*)$": (
|
||||
r"language_model.layers.\1.mlp.gate_up_proj.\2",
|
||||
0,
|
||||
2,
|
||||
),
|
||||
r"^layers\.(\d+)\.mlp\.up_proj\.(.*)$": (
|
||||
r"language_model.layers.\1.mlp.gate_up_proj.\2",
|
||||
1,
|
||||
2,
|
||||
),
|
||||
# UND pathway: layernorms + remaining attention/mlp keys pass through
|
||||
# under language_model.layers namespace.
|
||||
r"^layers\.(\d+)\.(.*)$": r"language_model.layers.\1.\2",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cosmos3VideoArchConfig(DiTArchConfig):
|
||||
"""Architecture config for Cosmos3 Omni Transformer.
|
||||
|
||||
Cosmos3 uses a dual-pathway design:
|
||||
- Understanding (UND): Causal self-attention for text tokens
|
||||
- Generation (GEN): Cross-attention from visual to cached UND K/V
|
||||
|
||||
Field names mirror the diffusers ``transformer/config.json`` so values
|
||||
flow through ``update_model_arch`` without translation. Defaults are
|
||||
Qwen3-8B-Instruct-derived and overridden by the checkpoint at load time.
|
||||
"""
|
||||
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_layers])
|
||||
|
||||
# Transformer architecture
|
||||
hidden_size: int = 4096
|
||||
num_hidden_layers: int = 36
|
||||
num_attention_heads: int = 32
|
||||
num_key_value_heads: int = 8 # GQA
|
||||
head_dim: int = 128
|
||||
intermediate_size: int = 12288
|
||||
|
||||
# Latent space configuration
|
||||
latent_patch_size: int = 2
|
||||
latent_channel: int = 48
|
||||
out_channels: int = 48
|
||||
|
||||
# RoPE configuration (Qwen3-VL 3D mRoPE: temporal, height, width)
|
||||
mrope_section: tuple[int, int, int] = (24, 20, 20)
|
||||
rope_theta: float = 5000000.0
|
||||
# Populated from rope_scaling in the diffusers config when present.
|
||||
rope_scaling: dict | None = None
|
||||
|
||||
# Temporal configuration
|
||||
base_fps: float = 24.0
|
||||
temporal_compression_factor: int = 4
|
||||
unified_3d_mrope_temporal_modality_margin: int = 15000
|
||||
|
||||
# Audio (sound) modality
|
||||
sound_gen: bool = False
|
||||
sound_dim: int = 64
|
||||
sound_latent_fps: float = 25.0
|
||||
temporal_compression_factor_sound: int = 1
|
||||
|
||||
# Action modality
|
||||
action_gen: bool = False
|
||||
action_dim: int = 64
|
||||
num_embodiment_domains: int = 32
|
||||
|
||||
# Timestep embedding
|
||||
timestep_scale: float = 0.001
|
||||
frequency_embedding_size: int = 256
|
||||
|
||||
# Vocab size (Qwen3-VL tokenizer)
|
||||
vocab_size: int = 151936
|
||||
|
||||
# RMSNorm epsilon
|
||||
rms_norm_eps: float = 1e-6
|
||||
|
||||
# Weight mapping from checkpoint to model
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=_build_cosmos3_param_names_mapping
|
||||
)
|
||||
reverse_param_names_mapping: dict = field(default_factory=dict)
|
||||
lora_param_names_mapping: dict = field(default_factory=dict)
|
||||
# FP8 checkpoint quantization_config.ignore uses checkpoint module names;
|
||||
# translate them to model names so is_layer_excluded matches correctly.
|
||||
quant_ignore_remap: dict = field(
|
||||
default_factory=lambda: {"vae2llm": "proj_in", "llm2vae": "proj_out"}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
# Diffusers configs nest the mrope sizes under `rope_scaling`; lift it.
|
||||
if isinstance(self.rope_scaling, dict) and "mrope_section" in self.rope_scaling:
|
||||
self.mrope_section = tuple(self.rope_scaling["mrope_section"])
|
||||
self.in_channels = self.latent_channel
|
||||
self.num_channels_latents = self.out_channels
|
||||
# Patch latent dimension: (patch_size^2) * latent_channel
|
||||
self.patch_latent_dim = (self.latent_patch_size**2) * self.latent_channel
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cosmos3VideoConfig(DiTConfig):
|
||||
"""DiT config wrapper for Cosmos3 Video model."""
|
||||
|
||||
arch_config: DiTArchConfig = field(default_factory=Cosmos3VideoArchConfig)
|
||||
prefix: str = "Cosmos3"
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_layer
|
||||
|
||||
|
||||
@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_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,116 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@dataclass
|
||||
class FluxArchConfig(DiTArchConfig):
|
||||
patch_size: int = 1
|
||||
in_channels: int = 64
|
||||
out_channels: int | None = None
|
||||
num_layers: int = 19
|
||||
num_single_layers: int = 38
|
||||
attention_head_dim: int = 128
|
||||
num_attention_heads: int = 24
|
||||
joint_attention_dim: int = 4096
|
||||
pooled_projection_dim: int = 768
|
||||
guidance_embeds: bool = True
|
||||
axes_dims_rope: Tuple[int, int, int] = (16, 56, 56)
|
||||
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
|
||||
exclude_lora_layers: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
"time_guidance_embed.timestep_embedder.linear_1",
|
||||
"time_guidance_embed.timestep_embedder.linear_2",
|
||||
"time_guidance_embed.guidance_embedder.linear_1",
|
||||
"time_guidance_embed.guidance_embedder.linear_2",
|
||||
]
|
||||
)
|
||||
|
||||
# nunchaku checkpoint uses different weight names; map to sglang flux layout
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# HF diffusers format: strip leading "transformer." prefix
|
||||
r"^transformer\.(\w*)\.(.*)$": r"\1.\2",
|
||||
# FLUX2-nvfp4 format: double blocks - image attention QKV (packed, fused)
|
||||
r"^double_blocks\.(\d+)\.img_attn\.qkv\.(.*)$": r"transformer_blocks.\1.attn.to_qkv.\2",
|
||||
r"^double_blocks\.(\d+)\.img_attn\.proj\.(.*)$": r"transformer_blocks.\1.attn.to_out.0.\2",
|
||||
r"^double_blocks\.(\d+)\.img_attn\.norm\.query_norm\.(.*)$": r"transformer_blocks.\1.attn.norm_q.\2",
|
||||
r"^double_blocks\.(\d+)\.img_attn\.norm\.key_norm\.(.*)$": r"transformer_blocks.\1.attn.norm_k.\2",
|
||||
# FLUX2-nvfp4 format: double blocks - text/context attention QKV (packed, fused)
|
||||
r"^double_blocks\.(\d+)\.txt_attn\.qkv\.(.*)$": r"transformer_blocks.\1.attn.to_added_qkv.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_attn\.proj\.(.*)$": r"transformer_blocks.\1.attn.to_add_out.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_attn\.norm\.query_norm\.(.*)$": r"transformer_blocks.\1.attn.norm_added_q.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_attn\.norm\.key_norm\.(.*)$": r"transformer_blocks.\1.attn.norm_added_k.\2",
|
||||
# FLUX2-nvfp4 format: double blocks - image MLP
|
||||
r"^double_blocks\.(\d+)\.img_mlp\.0\.(.*)$": r"transformer_blocks.\1.ff.linear_in.\2",
|
||||
r"^double_blocks\.(\d+)\.img_mlp\.2\.(.*)$": r"transformer_blocks.\1.ff.linear_out.\2",
|
||||
# FLUX2-nvfp4 format: double blocks - text/context MLP
|
||||
r"^double_blocks\.(\d+)\.txt_mlp\.0\.(.*)$": r"transformer_blocks.\1.ff_context.linear_in.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_mlp\.2\.(.*)$": r"transformer_blocks.\1.ff_context.linear_out.\2",
|
||||
# FLUX2-nvfp4 format: single blocks
|
||||
r"^single_blocks\.(\d+)\.linear1\.(.*)$": r"single_transformer_blocks.\1.attn.to_qkv_mlp_proj.\2",
|
||||
r"^single_blocks\.(\d+)\.linear2\.(.*)$": r"single_transformer_blocks.\1.attn.to_out.\2",
|
||||
r"^single_blocks\.(\d+)\.norm\.query_norm\.(.*)$": r"single_transformer_blocks.\1.attn.norm_q.\2",
|
||||
r"^single_blocks\.(\d+)\.norm\.key_norm\.(.*)$": r"single_transformer_blocks.\1.attn.norm_k.\2",
|
||||
# FLUX2-nvfp4 format: non-block input/output projections
|
||||
r"^img_in\.(.*)$": r"x_embedder.\1",
|
||||
r"^txt_in\.(.*)$": r"context_embedder.\1",
|
||||
r"^time_in\.in_layer\.(.*)$": r"time_guidance_embed.timestep_embedder.linear_1.\1",
|
||||
r"^time_in\.out_layer\.(.*)$": r"time_guidance_embed.timestep_embedder.linear_2.\1",
|
||||
r"^guidance_in\.in_layer\.(.*)$": r"time_guidance_embed.guidance_embedder.linear_1.\1",
|
||||
r"^guidance_in\.out_layer\.(.*)$": r"time_guidance_embed.guidance_embedder.linear_2.\1",
|
||||
r"^double_stream_modulation_img\.lin\.(.*)$": r"double_stream_modulation_img.linear.\1",
|
||||
r"^double_stream_modulation_txt\.lin\.(.*)$": r"double_stream_modulation_txt.linear.\1",
|
||||
r"^single_stream_modulation\.lin\.(.*)$": r"single_stream_modulation.linear.\1",
|
||||
r"^final_layer\.adaLN_modulation\.1\.(.*)$": r"norm_out.linear.\1",
|
||||
r"^final_layer\.linear\.(.*)$": r"proj_out.\1",
|
||||
# FLUX2-nvfp4 format: RMSNorm uses "scale" parameter; rename to "weight" (model uses .weight)
|
||||
r"^(.*)\.scale$": r"\1.weight",
|
||||
# transformer_blocks nunchaku format (raw export - before internal conversion)
|
||||
r"^transformer_blocks\.(\d+)\.mlp_fc1\.(.*)$": r"transformer_blocks.\1.ff.net.0.proj.\2",
|
||||
r"^transformer_blocks\.(\d+)\.mlp_fc2\.(.*)$": r"transformer_blocks.\1.ff.net.2.\2",
|
||||
r"^transformer_blocks\.(\d+)\.mlp_context_fc1\.(.*)$": r"transformer_blocks.\1.ff_context.net.0.proj.\2",
|
||||
r"^transformer_blocks\.(\d+)\.mlp_context_fc2\.(.*)$": r"transformer_blocks.\1.ff_context.net.2.\2",
|
||||
# nunchaku packed QKV → fused to_qkv / to_added_qkv (matches use_fused_qkv in model)
|
||||
r"^transformer_blocks\.(\d+)\.qkv_proj\.(.*)$": r"transformer_blocks.\1.attn.to_qkv.\2",
|
||||
r"^transformer_blocks\.(\d+)\.qkv_proj_context\.(.*)$": r"transformer_blocks.\1.attn.to_added_qkv.\2",
|
||||
r"^transformer_blocks\.(\d+)\.out_proj\.(.*)$": r"transformer_blocks.\1.attn.to_out.0.\2",
|
||||
r"^transformer_blocks\.(\d+)\.out_proj_context\.(.*)$": r"transformer_blocks.\1.attn.to_add_out.\2",
|
||||
r"^transformer_blocks\.(\d+)\.norm_q\.(.*)$": r"transformer_blocks.\1.attn.norm_q.\2",
|
||||
r"^transformer_blocks\.(\d+)\.norm_k\.(.*)$": r"transformer_blocks.\1.attn.norm_k.\2",
|
||||
r"^transformer_blocks\.(\d+)\.norm_added_q\.(.*)$": r"transformer_blocks.\1.attn.norm_added_q.\2",
|
||||
r"^transformer_blocks\.(\d+)\.norm_added_k\.(.*)$": r"transformer_blocks.\1.attn.norm_added_k.\2",
|
||||
# nunchaku format (already converted): add_qkv_proj → fused to_added_qkv
|
||||
r"^transformer_blocks\.(\d+)\.attn\.add_qkv_proj\.(.*)$": r"transformer_blocks.\1.attn.to_added_qkv.\2",
|
||||
# single_transformer_blocks nunchaku format (raw export - before internal conversion)
|
||||
r"^single_transformer_blocks\.(\d+)\.qkv_proj\.(.*)$": r"single_transformer_blocks.\1.attn.to_qkv_mlp_proj.\2",
|
||||
r"^single_transformer_blocks\.(\d+)\.out_proj\.(.*)$": r"single_transformer_blocks.\1.attn.to_out.\2",
|
||||
r"^single_transformer_blocks\.(\d+)\.norm_q\.(.*)$": r"single_transformer_blocks.\1.attn.norm_q.\2",
|
||||
r"^single_transformer_blocks\.(\d+)\.norm_k\.(.*)$": r"single_transformer_blocks.\1.attn.norm_k.\2",
|
||||
# nunchaku quantization parameter name conversions (apply to all blocks)
|
||||
r"^(.*)\.smooth_orig$": r"\1.smooth_factor_orig",
|
||||
r"^(.*)\.smooth$": r"\1.smooth_factor",
|
||||
r"^(.*)\.lora_down$": r"\1.proj_down",
|
||||
r"^(.*)\.lora_up$": r"\1.proj_up",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class FluxConfig(DiTConfig):
|
||||
|
||||
arch_config: DiTArchConfig = field(default_factory=FluxArchConfig)
|
||||
|
||||
prefix: str = "Flux"
|
||||
@@ -0,0 +1,39 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlmImageArchConfig(DiTArchConfig):
|
||||
patch_size: int = 2
|
||||
in_channels: int = 16
|
||||
out_channels: int | None = 16
|
||||
num_layers: int = 30
|
||||
attention_head_dim: int = 128
|
||||
num_attention_heads: int = 32
|
||||
condition_dim: int = 256
|
||||
prior_vq_quantizer_codebook_size: int = 16384
|
||||
text_embed_dim: int = 1472
|
||||
time_embed_dim: int = 512
|
||||
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# LoRA mappings
|
||||
r"^(transformer_blocks\.\d+\.attn\..*\.lora_[AB])\.default$": r"\1",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlmImageDitConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=GlmImageArchConfig)
|
||||
|
||||
prefix: str = "glmimage"
|
||||
@@ -0,0 +1,77 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_block
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeliosArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_block])
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# Patch embeddings
|
||||
r"^patch_embedding\.(.*)$": r"patch_embedding.proj.\1",
|
||||
# Condition embedder: text
|
||||
r"^condition_embedder\.text_embedder\.linear_1\.(.*)$": r"condition_embedder.text_embedder.fc_in.\1",
|
||||
r"^condition_embedder\.text_embedder\.linear_2\.(.*)$": r"condition_embedder.text_embedder.fc_out.\1",
|
||||
# Condition embedder: time
|
||||
r"^condition_embedder\.time_embedder\.linear_1\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_in.\1",
|
||||
r"^condition_embedder\.time_embedder\.linear_2\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_out.\1",
|
||||
r"^condition_embedder\.time_proj\.(.*)$": r"condition_embedder.time_modulation.linear.\1",
|
||||
# Blocks: self-attention (keep attn1. prefix, drop .0. from to_out)
|
||||
r"^blocks\.(\d+)\.attn1\.to_out\.0\.(.*)$": r"blocks.\1.attn1.to_out.\2",
|
||||
# Blocks: cross-attention output (drop .0. from to_out)
|
||||
r"^blocks\.(\d+)\.attn2\.to_out\.0\.(.*)$": r"blocks.\1.attn2.to_out.\2",
|
||||
# Blocks: feed-forward
|
||||
r"^blocks\.(\d+)\.ffn\.net\.0\.proj\.(.*)$": r"blocks.\1.ffn.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.net\.2\.(.*)$": r"blocks.\1.ffn.fc_out.\2",
|
||||
# Blocks: cross-attn residual norm
|
||||
r"^blocks\.(\d+)\.norm2\.(.*)$": r"blocks.\1.self_attn_residual_norm.\2",
|
||||
}
|
||||
)
|
||||
|
||||
reverse_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
|
||||
lora_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
text_len: int = 226
|
||||
num_attention_heads: int = 40
|
||||
attention_head_dim: int = 128
|
||||
in_channels: int = 16
|
||||
out_channels: int = 16
|
||||
text_dim: int = 4096
|
||||
freq_dim: int = 256
|
||||
ffn_dim: int = 13824
|
||||
num_layers: int = 40
|
||||
cross_attn_norm: bool = True
|
||||
qk_norm: str = "rms_norm_across_heads"
|
||||
eps: float = 1e-6
|
||||
added_kv_proj_dim: int | None = None
|
||||
rope_max_seq_len: int = 1024
|
||||
pos_embed_seq_len: int | None = None
|
||||
exclude_lora_layers: list[str] = field(default_factory=lambda: ["embedder"])
|
||||
|
||||
# Helios-specific
|
||||
rope_dim: tuple[int, int, int] = (44, 42, 42)
|
||||
rope_theta: float = 10000.0
|
||||
guidance_cross_attn: bool = True
|
||||
zero_history_timestep: bool = True
|
||||
has_multi_term_memory_patch: bool = True
|
||||
is_amplify_history: bool = False
|
||||
history_scale_mode: str = "per_head"
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeliosConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=HeliosArchConfig)
|
||||
|
||||
prefix: str = "Helios"
|
||||
@@ -0,0 +1,100 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hunyuan3DDiTArchConfig(DiTArchConfig):
|
||||
"""Architecture config for Hunyuan3D DiT (Flux-style for Hunyuan3D-2.0)."""
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# Strip leading "model." prefix used by some exports
|
||||
r"^model\.(.*)$": r"\1",
|
||||
# MLP linear renames (double-stream blocks)
|
||||
r"^(double_blocks\.\d+\.img_mlp)\.0\.(.*)$": r"\1.fc_in.\2",
|
||||
r"^(double_blocks\.\d+\.img_mlp)\.2\.(.*)$": r"\1.fc_out.\2",
|
||||
r"^(double_blocks\.\d+\.txt_mlp)\.0\.(.*)$": r"\1.fc_in.\2",
|
||||
r"^(double_blocks\.\d+\.txt_mlp)\.2\.(.*)$": r"\1.fc_out.\2",
|
||||
# Double-stream attention: fuse split Q/K/V into fused qkv for both txt_attn and img_attn
|
||||
r"^(double_blocks\.\d+\.(?:txt_attn|img_attn))\.(?:to_q|q_proj|query)\.(.*)$": (
|
||||
r"\1.qkv.\2",
|
||||
0,
|
||||
3,
|
||||
),
|
||||
r"^(double_blocks\.\d+\.(?:txt_attn|img_attn))\.(?:to_k|k_proj|key)\.(.*)$": (
|
||||
r"\1.qkv.\2",
|
||||
1,
|
||||
3,
|
||||
),
|
||||
r"^(double_blocks\.\d+\.(?:txt_attn|img_attn))\.(?:to_v|v_proj|value)\.(.*)$": (
|
||||
r"\1.qkv.\2",
|
||||
2,
|
||||
3,
|
||||
),
|
||||
# Double-stream out projection (image/text): to_out[.0].{weight,bias} and
|
||||
# txt_attn.to_add_out[.0].{weight,bias} -> proj.{weight,bias}
|
||||
r"^(double_blocks\.\d+\.(?:txt_attn|img_attn))\.to_out(?:\.0)?\.(weight|bias)$": r"\1.proj.\2",
|
||||
r"^(double_blocks\.\d+\.txt_attn)\.to_add_out(?:\.0)?\.(weight|bias)$": r"\1.proj.\2",
|
||||
# Double-stream Q/K norm aliases and convert HF 'weight' to internal 'scale'
|
||||
r"^(double_blocks\.\d+\.(?:txt_attn|img_attn))\.norm_q\.(.*)$": r"\1.norm.query_norm.\2",
|
||||
r"^(double_blocks\.\d+\.(?:txt_attn|img_attn))\.norm_k\.(.*)$": r"\1.norm.key_norm.\2",
|
||||
r"^(.*norm\.query_norm)\.weight$": r"\1.scale",
|
||||
r"^(.*norm\.key_norm)\.weight$": r"\1.scale",
|
||||
# Single-stream blocks: pack Q/K/V and MLP into linear1 ([Q, K, V, MLP]) and map out-proj to linear2
|
||||
# Apply to both single_blocks.* and single_transformer_blocks.* exports
|
||||
r"^(?:single_blocks|single_transformer_blocks)\.(\d+)\.attn\.(?:to_q|q_proj|query)\.(.*)$": (
|
||||
r"single_blocks.\1.linear1.\2",
|
||||
0,
|
||||
4,
|
||||
),
|
||||
r"^(?:single_blocks|single_transformer_blocks)\.(\d+)\.attn\.(?:to_k|k_proj|key)\.(.*)$": (
|
||||
r"single_blocks.\1.linear1.\2",
|
||||
1,
|
||||
4,
|
||||
),
|
||||
r"^(?:single_blocks|single_transformer_blocks)\.(\d+)\.attn\.(?:to_v|v_proj|value)\.(.*)$": (
|
||||
r"single_blocks.\1.linear1.\2",
|
||||
2,
|
||||
4,
|
||||
),
|
||||
r"^(?:single_blocks|single_transformer_blocks)\.(\d+)\.(?:proj_mlp|mlp_fc1)\.(.*)$": (
|
||||
r"single_blocks.\1.linear1.\2",
|
||||
3,
|
||||
4,
|
||||
),
|
||||
# Single-stream out projection variants -> linear2 (only weight/bias)
|
||||
r"^(?:single_blocks|single_transformer_blocks)\.(\d+)\.(?:proj_out|out_proj)(?:\.0)?\.(weight|bias)$": r"single_blocks.\1.linear2.\2",
|
||||
r"^(?:single_blocks|single_transformer_blocks)\.(\d+)\.attn\.to_out(?:\.0)?\.(weight|bias)$": r"single_blocks.\1.linear2.\2",
|
||||
# Single-stream Q/K norm aliases
|
||||
r"^(?:single_blocks|single_transformer_blocks)\.(\d+)\.attn\.norm_q\.(.*)$": r"single_blocks.\1.norm.query_norm.\2",
|
||||
r"^(?:single_blocks|single_transformer_blocks)\.(\d+)\.attn\.norm_k\.(.*)$": r"single_blocks.\1.norm.key_norm.\2",
|
||||
}
|
||||
)
|
||||
|
||||
in_channels: int = 64
|
||||
hidden_size: int = 1024
|
||||
num_attention_heads: int = 16
|
||||
num_layers: int = 16
|
||||
num_single_layers: int = 32
|
||||
mlp_ratio: float = 4.0
|
||||
context_in_dim: int = 1536
|
||||
axes_dim: tuple[int, ...] = (64,)
|
||||
theta: int = 10000
|
||||
qkv_bias: bool = True
|
||||
guidance_embed: bool = False
|
||||
time_factor: float = 1000.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.num_channels_latents == 0:
|
||||
self.num_channels_latents = self.in_channels
|
||||
super().__post_init__()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hunyuan3DDiTConfig(DiTConfig):
|
||||
"""DiT configuration for Hunyuan3D shape generation (Flux-style)."""
|
||||
|
||||
arch_config: Hunyuan3DDiTArchConfig = field(default_factory=Hunyuan3DDiTArchConfig)
|
||||
subfolder: str = "hunyuan3d-dit-v2-0"
|
||||
@@ -0,0 +1,174 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_double_block,
|
||||
is_refiner_block,
|
||||
is_single_block,
|
||||
is_txt_in,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HunyuanVideoArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_double_block, is_single_block, is_refiner_block]
|
||||
)
|
||||
|
||||
_compile_conditions: list = field(
|
||||
default_factory=lambda: [is_double_block, is_single_block, is_txt_in]
|
||||
)
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# 1. context_embedder.time_text_embed submodules (specific rules, applied first):
|
||||
r"^context_embedder\.time_text_embed\.timestep_embedder\.linear_1\.(.*)$": r"txt_in.t_embedder.mlp.fc_in.\1",
|
||||
r"^context_embedder\.time_text_embed\.timestep_embedder\.linear_2\.(.*)$": r"txt_in.t_embedder.mlp.fc_out.\1",
|
||||
r"^context_embedder\.proj_in\.(.*)$": r"txt_in.input_embedder.\1",
|
||||
r"^context_embedder\.time_text_embed\.text_embedder\.linear_1\.(.*)$": r"txt_in.c_embedder.fc_in.\1",
|
||||
r"^context_embedder\.time_text_embed\.text_embedder\.linear_2\.(.*)$": r"txt_in.c_embedder.fc_out.\1",
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.norm1\.(.*)$": r"txt_in.refiner_blocks.\1.norm1.\2",
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.norm2\.(.*)$": r"txt_in.refiner_blocks.\1.norm2.\2",
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.attn\.to_q\.(.*)$": (
|
||||
r"txt_in.refiner_blocks.\1.self_attn_qkv.\2",
|
||||
0,
|
||||
3,
|
||||
),
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.attn\.to_k\.(.*)$": (
|
||||
r"txt_in.refiner_blocks.\1.self_attn_qkv.\2",
|
||||
1,
|
||||
3,
|
||||
),
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.attn\.to_v\.(.*)$": (
|
||||
r"txt_in.refiner_blocks.\1.self_attn_qkv.\2",
|
||||
2,
|
||||
3,
|
||||
),
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.attn\.to_out\.0\.(.*)$": r"txt_in.refiner_blocks.\1.self_attn_proj.\2",
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.ff\.net\.0(?:\.proj)?\.(.*)$": r"txt_in.refiner_blocks.\1.mlp.fc_in.\2",
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.ff\.net\.2(?:\.proj)?\.(.*)$": r"txt_in.refiner_blocks.\1.mlp.fc_out.\2",
|
||||
r"^context_embedder\.token_refiner\.refiner_blocks\.(\d+)\.norm_out\.linear\.(.*)$": r"txt_in.refiner_blocks.\1.adaLN_modulation.linear.\2",
|
||||
# 3. x_embedder mapping:
|
||||
r"^x_embedder\.proj\.(.*)$": r"img_in.proj.\1",
|
||||
# 4. Top-level time_text_embed mappings:
|
||||
r"^time_text_embed\.timestep_embedder\.linear_1\.(.*)$": r"time_in.mlp.fc_in.\1",
|
||||
r"^time_text_embed\.timestep_embedder\.linear_2\.(.*)$": r"time_in.mlp.fc_out.\1",
|
||||
r"^time_text_embed\.guidance_embedder\.linear_1\.(.*)$": r"guidance_in.mlp.fc_in.\1",
|
||||
r"^time_text_embed\.guidance_embedder\.linear_2\.(.*)$": r"guidance_in.mlp.fc_out.\1",
|
||||
r"^time_text_embed\.text_embedder\.linear_1\.(.*)$": r"vector_in.fc_in.\1",
|
||||
r"^time_text_embed\.text_embedder\.linear_2\.(.*)$": r"vector_in.fc_out.\1",
|
||||
# 5. transformer_blocks mapping:
|
||||
r"^transformer_blocks\.(\d+)\.norm1\.linear\.(.*)$": r"double_blocks.\1.img_mod.linear.\2",
|
||||
r"^transformer_blocks\.(\d+)\.norm1_context\.linear\.(.*)$": r"double_blocks.\1.txt_mod.linear.\2",
|
||||
r"^transformer_blocks\.(\d+)\.attn\.norm_q\.(.*)$": r"double_blocks.\1.img_attn_q_norm.\2",
|
||||
r"^transformer_blocks\.(\d+)\.attn\.norm_k\.(.*)$": r"double_blocks.\1.img_attn_k_norm.\2",
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_q\.(.*)$": (
|
||||
r"double_blocks.\1.img_attn_qkv.\2",
|
||||
0,
|
||||
3,
|
||||
),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_k\.(.*)$": (
|
||||
r"double_blocks.\1.img_attn_qkv.\2",
|
||||
1,
|
||||
3,
|
||||
),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_v\.(.*)$": (
|
||||
r"double_blocks.\1.img_attn_qkv.\2",
|
||||
2,
|
||||
3,
|
||||
),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.add_q_proj\.(.*)$": (
|
||||
r"double_blocks.\1.txt_attn_qkv.\2",
|
||||
0,
|
||||
3,
|
||||
),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.add_k_proj\.(.*)$": (
|
||||
r"double_blocks.\1.txt_attn_qkv.\2",
|
||||
1,
|
||||
3,
|
||||
),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.add_v_proj\.(.*)$": (
|
||||
r"double_blocks.\1.txt_attn_qkv.\2",
|
||||
2,
|
||||
3,
|
||||
),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_out\.0\.(.*)$": r"double_blocks.\1.img_attn_proj.\2",
|
||||
# Corrected: merge attn.to_add_out into the main projection.
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_add_out\.(.*)$": r"double_blocks.\1.txt_attn_proj.\2",
|
||||
r"^transformer_blocks\.(\d+)\.attn\.norm_added_q\.(.*)$": r"double_blocks.\1.txt_attn_q_norm.\2",
|
||||
r"^transformer_blocks\.(\d+)\.attn\.norm_added_k\.(.*)$": r"double_blocks.\1.txt_attn_k_norm.\2",
|
||||
r"^transformer_blocks\.(\d+)\.ff\.net\.0(?:\.proj)?\.(.*)$": r"double_blocks.\1.img_mlp.fc_in.\2",
|
||||
r"^transformer_blocks\.(\d+)\.ff\.net\.2(?:\.proj)?\.(.*)$": r"double_blocks.\1.img_mlp.fc_out.\2",
|
||||
r"^transformer_blocks\.(\d+)\.ff_context\.net\.0(?:\.proj)?\.(.*)$": r"double_blocks.\1.txt_mlp.fc_in.\2",
|
||||
r"^transformer_blocks\.(\d+)\.ff_context\.net\.2(?:\.proj)?\.(.*)$": r"double_blocks.\1.txt_mlp.fc_out.\2",
|
||||
# 6. single_transformer_blocks mapping:
|
||||
r"^single_transformer_blocks\.(\d+)\.attn\.norm_q\.(.*)$": r"single_blocks.\1.q_norm.\2",
|
||||
r"^single_transformer_blocks\.(\d+)\.attn\.norm_k\.(.*)$": r"single_blocks.\1.k_norm.\2",
|
||||
r"^single_transformer_blocks\.(\d+)\.attn\.to_q\.(.*)$": (
|
||||
r"single_blocks.\1.linear1.\2",
|
||||
0,
|
||||
4,
|
||||
),
|
||||
r"^single_transformer_blocks\.(\d+)\.attn\.to_k\.(.*)$": (
|
||||
r"single_blocks.\1.linear1.\2",
|
||||
1,
|
||||
4,
|
||||
),
|
||||
r"^single_transformer_blocks\.(\d+)\.attn\.to_v\.(.*)$": (
|
||||
r"single_blocks.\1.linear1.\2",
|
||||
2,
|
||||
4,
|
||||
),
|
||||
r"^single_transformer_blocks\.(\d+)\.proj_mlp\.(.*)$": (
|
||||
r"single_blocks.\1.linear1.\2",
|
||||
3,
|
||||
4,
|
||||
),
|
||||
# Corrected: map proj_out to modulation.linear rather than a separate proj_out branch.
|
||||
r"^single_transformer_blocks\.(\d+)\.proj_out\.(.*)$": r"single_blocks.\1.linear2.\2",
|
||||
r"^single_transformer_blocks\.(\d+)\.norm\.linear\.(.*)$": r"single_blocks.\1.modulation.linear.\2",
|
||||
# 7. Final layers mapping:
|
||||
r"^norm_out\.linear\.(.*)$": r"final_layer.adaLN_modulation.linear.\1",
|
||||
r"^proj_out\.(.*)$": r"final_layer.linear.\1",
|
||||
}
|
||||
)
|
||||
|
||||
reverse_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
|
||||
patch_size: int = 2
|
||||
patch_size_t: int = 1
|
||||
in_channels: int = 16
|
||||
out_channels: int = 16
|
||||
num_attention_heads: int = 24
|
||||
attention_head_dim: int = 128
|
||||
mlp_ratio: float = 4.0
|
||||
num_layers: int = 20
|
||||
num_single_layers: int = 40
|
||||
num_refiner_layers: int = 2
|
||||
rope_axes_dim: tuple[int, int, int] = (16, 56, 56)
|
||||
guidance_embeds: bool = False
|
||||
dtype: torch.dtype | None = None
|
||||
text_embed_dim: int = 4096
|
||||
pooled_projection_dim: int = 768
|
||||
rope_theta: int = 256
|
||||
qk_norm: str = "rms_norm"
|
||||
exclude_lora_layers: list[str] = field(
|
||||
default_factory=lambda: ["img_in", "txt_in", "time_in", "vector_in"]
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.hidden_size: int = self.attention_head_dim * self.num_attention_heads
|
||||
self.num_channels_latents: int = self.in_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class HunyuanVideoConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=HunyuanVideoArchConfig)
|
||||
|
||||
prefix: str = "Hunyuan"
|
||||
@@ -0,0 +1,39 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_layer
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ideogram4DiTArchConfig(DiTArchConfig):
|
||||
adaln_dim: int = 512
|
||||
attention_head_dim: int = 256
|
||||
in_channels: int = 128
|
||||
intermediate_size: int = 12288
|
||||
llm_features_dim: int = 53248
|
||||
mrope_section: tuple[int, int, int] | list[int] = (24, 20, 20)
|
||||
norm_eps: float = 1e-5
|
||||
num_attention_heads: int = 18
|
||||
num_layers: int = 34
|
||||
rope_theta: int = 5_000_000
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_layer])
|
||||
_supported_attention_backends: set[AttentionBackendEnum] = field(
|
||||
default_factory=lambda: {
|
||||
AttentionBackendEnum.FA,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.in_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ideogram4DiTConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=Ideogram4DiTArchConfig)
|
||||
prefix: str = "ideogram4"
|
||||
@@ -0,0 +1,22 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.ltx_2 import (
|
||||
LTX2ArchConfig,
|
||||
LTX2Config,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JoyEchoArchConfig(LTX2ArchConfig):
|
||||
"""JoyEcho DiT architecture config (LTX-2.3 AV base)."""
|
||||
|
||||
caption_proj_before_connector: bool = True
|
||||
cross_attention_adaln: bool = True
|
||||
apply_gated_attention: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class JoyEchoConfig(LTX2Config):
|
||||
arch_config: JoyEchoArchConfig = field(default_factory=JoyEchoArchConfig)
|
||||
prefix: str = "JoyEcho"
|
||||
@@ -0,0 +1,67 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_blocks_or_double_blocks
|
||||
|
||||
|
||||
@dataclass
|
||||
class JoyImageArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_blocks_or_double_blocks]
|
||||
)
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# Condition embedder mappings
|
||||
r"^condition_embedder\.text_embedder\.linear_1\.(.*)$": r"condition_embedder.text_embedder.fc_in.\1",
|
||||
r"^condition_embedder\.text_embedder\.linear_2\.(.*)$": r"condition_embedder.text_embedder.fc_out.\1",
|
||||
r"^condition_embedder\.time_embedder\.linear_1\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_in.\1",
|
||||
r"^condition_embedder\.time_embedder\.linear_2\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_out.\1",
|
||||
r"^condition_embedder\.time_proj\.(.*)$": r"condition_embedder.time_modulation.linear.\1",
|
||||
# Double blocks mappings
|
||||
r"^double_blocks\.(\d+)\.attn\.(.*)$": r"double_blocks.\1.\2",
|
||||
r"^double_blocks\.(\d+)\.img_mlp\.net\.0\.proj\.(.*)$": r"double_blocks.\1.img_mlp.fc_in.\2",
|
||||
r"^double_blocks\.(\d+)\.img_mlp\.net\.2\.(.*)$": r"double_blocks.\1.img_mlp.fc_out.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_mlp\.net\.0\.proj\.(.*)$": r"double_blocks.\1.txt_mlp.fc_in.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_mlp\.net\.2\.(.*)$": r"double_blocks.\1.txt_mlp.fc_out.\2",
|
||||
r"^double_blocks\.(\d+)\.img_attn_qkv\.(.*)$": r"double_blocks.\1.img_attn_qkv.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_attn_qkv\.(.*)$": r"double_blocks.\1.txt_attn_qkv.\2",
|
||||
r"^double_blocks\.(\d+)\.img_attn_proj\.(.*)$": r"double_blocks.\1.img_attn_proj.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_attn_proj\.(.*)$": r"double_blocks.\1.txt_attn_proj.\2",
|
||||
r"^double_blocks\.(\d+)\.img_mod\.(.*)$": r"double_blocks.\1.img_mod.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_mod\.(.*)$": r"double_blocks.\1.txt_mod.\2",
|
||||
r"^double_blocks\.(\d+)\.img_attn_q_norm\.(.*)$": r"double_blocks.\1.img_attn_q_norm.\2",
|
||||
r"^double_blocks\.(\d+)\.img_attn_k_norm\.(.*)$": r"double_blocks.\1.img_attn_k_norm.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_attn_q_norm\.(.*)$": r"double_blocks.\1.txt_attn_q_norm.\2",
|
||||
r"^double_blocks\.(\d+)\.txt_attn_k_norm\.(.*)$": r"double_blocks.\1.txt_attn_k_norm.\2",
|
||||
}
|
||||
)
|
||||
|
||||
reverse_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
|
||||
# Model architecture parameters
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
num_attention_heads: int = 32
|
||||
attention_head_dim: int = 128
|
||||
in_channels: int = 16
|
||||
out_channels: int = 16
|
||||
mm_double_blocks_depth: int = 40
|
||||
freq_dim: int = 256
|
||||
text_states_dim: int = 4096
|
||||
mlp_width_ratio: float = 4.0
|
||||
rope_theta: int = 10000
|
||||
rope_dim_list: list[int] = field(default_factory=lambda: [16, 56, 56])
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class JoyImageDiTConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=JoyImageArchConfig)
|
||||
prefix: str = "JoyImage"
|
||||
@@ -0,0 +1,77 @@
|
||||
# Krea-2 (K2) single-stream MMDiT architecture config.
|
||||
#
|
||||
# Parameter names follow the released K2 checkpoint, so the MMDiT safetensors load
|
||||
# without remapping (identity `param_names_mapping`).
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Tuple
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class Krea2ArchConfig(DiTArchConfig):
|
||||
features: int = 6144 # hidden dim
|
||||
tdim: int = 256 # timestep embedding dim
|
||||
txtdim: int = 2560 # text-encoder hidden dim (Qwen3-VL-4B hidden_size)
|
||||
heads: int = 48
|
||||
kvheads: int = 12 # GQA 4:1
|
||||
multiplier: int = 4 # SwiGLU expansion multiplier
|
||||
layers: int = 28
|
||||
patch: int = 2
|
||||
channels: int = 16 # VAE latent channels
|
||||
bias: bool = False
|
||||
theta: float = 1e3 # RoPE theta
|
||||
txtlayers: int = 12 # number of text-encoder hidden-state layers fused by txtfusion
|
||||
txtheads: int = 20
|
||||
txtkvheads: int = 20
|
||||
|
||||
# 3-axis RoPE split over head_dim=128: [global, h, w] = (32, 48, 48).
|
||||
axes_dims: Tuple[int, int, int] = (32, 48, 48)
|
||||
|
||||
# Joint (text+image) sequence is padded to a multiple of this many tokens.
|
||||
seq_multiple_of: int = 256
|
||||
|
||||
# Packed patch-token width (channels * patch**2); used by the VAE unpack path.
|
||||
in_channels: int = 64
|
||||
|
||||
# BaseDiT-required instance attrs (overwritten in __post_init__).
|
||||
hidden_size: int = 6144
|
||||
num_attention_heads: int = 48
|
||||
num_channels_latents: int = 16
|
||||
|
||||
# Module/parameter names match the released checkpoint, so weights load with an
|
||||
# identity mapping.
|
||||
param_names_mapping: dict = field(default_factory=dict)
|
||||
|
||||
# Diffusers LoRA checkpoints prefix every DiT key with the pipeline component name
|
||||
# (e.g. transformer.transformer_blocks.0.attn.to_q.lora_A). Strip that prefix so the
|
||||
# LoRA keys line up with this model's module names. Only the LoRA path uses this; the
|
||||
# main checkpoint still loads with the identity param_names_mapping above.
|
||||
lora_param_names_mapping: dict = field(
|
||||
default_factory=lambda: {r"^transformer\.": ""}
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
self.hidden_size = self.features
|
||||
self.num_attention_heads = self.heads
|
||||
self.num_channels_latents = self.channels
|
||||
assert self.features % self.heads == 0
|
||||
assert (
|
||||
sum(self.axes_dims) == self.features // self.heads
|
||||
), f"sum(axes_dims)={sum(self.axes_dims)} != head_dim={self.features // self.heads}"
|
||||
|
||||
@property
|
||||
def head_dim(self) -> int:
|
||||
return self.features // self.heads
|
||||
|
||||
@property
|
||||
def in_features_packed(self) -> int:
|
||||
"""Patch-embed input width: channels * patch**2."""
|
||||
return self.channels * self.patch**2
|
||||
|
||||
|
||||
@dataclass
|
||||
class Krea2DitConfig(DiTConfig):
|
||||
arch_config: Krea2ArchConfig = field(default_factory=Krea2ArchConfig)
|
||||
prefix: str = "k2"
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
# Adapted from: https://github.com/Robbyant/lingbot-world
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
|
||||
|
||||
def is_blocks(n: str, m) -> bool:
|
||||
return "blocks" in n and str.isdigit(n.split(".")[-1])
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotWorldArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_blocks])
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^patch_embedding\.(.*)$": r"patch_embedding.proj.\1",
|
||||
r"^patch_embedding_wancamctrl\.(.*)$": r"patch_embedding_wancamctrl.proj.\1",
|
||||
r"^c2ws_hidden_states_layer1\.(.*)$": r"c2ws_mlp.fc_in.\1",
|
||||
r"^c2ws_hidden_states_layer2\.(.*)$": r"c2ws_mlp.fc_out.\1",
|
||||
r"^text_embedding\.0\.(.*)$": r"condition_embedder.text_embedder.fc_in.\1",
|
||||
r"^text_embedding\.2\.(.*)$": r"condition_embedder.text_embedder.fc_out.\1",
|
||||
r"^time_embedding\.0\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_in.\1",
|
||||
r"^time_embedding\.2\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_out.\1",
|
||||
r"^time_projection\.1\.(.*)$": r"condition_embedder.time_modulation.linear.\1",
|
||||
r"^blocks\.(\d+)\.modulation$": r"blocks.\1.scale_shift_table",
|
||||
r"^blocks\.(\d+)\.self_attn\.q\.(.*)$": r"blocks.\1.to_q.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.k\.(.*)$": r"blocks.\1.to_k.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.v\.(.*)$": r"blocks.\1.to_v.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.o\.(.*)$": r"blocks.\1.to_out.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.norm_q\.(.*)$": r"blocks.\1.norm_q.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.norm_k\.(.*)$": r"blocks.\1.norm_k.\2",
|
||||
r"^blocks\.(\d+)\.norm3\.(.*)$": r"blocks.\1.self_attn_residual_norm.norm.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.q\.(.*)$": r"blocks.\1.attn2.to_q.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.k\.(.*)$": r"blocks.\1.attn2.to_k.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.v\.(.*)$": r"blocks.\1.attn2.to_v.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.o\.(.*)$": r"blocks.\1.attn2.to_out.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.norm_q\.(.*)$": r"blocks.\1.attn2.norm_q.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.norm_k\.(.*)$": r"blocks.\1.attn2.norm_k.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.0\.(.*)$": r"blocks.\1.ffn.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.2\.(.*)$": r"blocks.\1.ffn.fc_out.\2",
|
||||
r"^blocks\.(\d+)\.cam_injector_layer1\.(.*)$": r"blocks.\1.cam_conditioner.cam_injector.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.cam_injector_layer2\.(.*)$": r"blocks.\1.cam_conditioner.cam_injector.fc_out.\2",
|
||||
r"^blocks\.(\d+)\.cam_scale_layer\.(.*)$": r"blocks.\1.cam_conditioner.cam_scale_layer.\2",
|
||||
r"^blocks\.(\d+)\.cam_shift_layer\.(.*)$": r"blocks.\1.cam_conditioner.cam_shift_layer.\2",
|
||||
r"^head\.modulation$": r"scale_shift_table",
|
||||
r"^head\.head\.(.*)$": r"proj_out.\1",
|
||||
}
|
||||
)
|
||||
reverse_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
lora_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
text_len: int = 512
|
||||
num_attention_heads: int = 40
|
||||
attention_head_dim: int = 128
|
||||
in_channels: int = 36
|
||||
out_channels: int = 16
|
||||
text_dim: int = 4096
|
||||
freq_dim: int = 256
|
||||
ffn_dim: int = 13824
|
||||
num_layers: int = 40
|
||||
cross_attn_norm: bool = True
|
||||
qk_norm: str = "rms_norm_across_heads"
|
||||
eps: float = 1e-6
|
||||
image_dim: int | None = None
|
||||
added_kv_proj_dim: int | None = None
|
||||
rope_max_seq_len: int = 1024
|
||||
pos_embed_seq_len: int | None = None
|
||||
exclude_lora_layers: list[str] = field(default_factory=lambda: ["embedder"])
|
||||
boundary_ratio: float | None = None
|
||||
local_attn_size: int = -1
|
||||
sink_size: int = 3
|
||||
num_frames_per_block: int = 3
|
||||
sliding_window_num_frames: int = 45
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotWorldVideoConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=LingBotWorldArchConfig)
|
||||
|
||||
prefix: str = "Wan"
|
||||
@@ -0,0 +1,190 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_blocks_or_transformer_blocks
|
||||
|
||||
|
||||
class LTXModelType(Enum):
|
||||
"""
|
||||
Model type enum mirroring upstream `LTXModelType`.
|
||||
|
||||
Upstream reference:
|
||||
- `LTX-2/packages/ltx-core/src/ltx_core/model/transformer/model.py::LTXModelType`
|
||||
"""
|
||||
|
||||
AudioVideo = "ltx av model"
|
||||
VideoOnly = "ltx video only model"
|
||||
AudioOnly = "ltx audio only model"
|
||||
|
||||
def is_video_enabled(self) -> bool:
|
||||
return self in (LTXModelType.AudioVideo, LTXModelType.VideoOnly)
|
||||
|
||||
def is_audio_enabled(self) -> bool:
|
||||
return self in (LTXModelType.AudioVideo, LTXModelType.AudioOnly)
|
||||
|
||||
|
||||
class LTX2RopeType(str, Enum):
|
||||
"""
|
||||
Minimal RoPE type enum mirroring LTX-2 upstream `LTXRopeType`.
|
||||
|
||||
Upstream reference:
|
||||
- `LTX-2/packages/ltx-core/src/ltx_core/model/transformer/rope.py::LTXRopeType`
|
||||
"""
|
||||
|
||||
INTERLEAVED = "interleaved"
|
||||
SPLIT = "split"
|
||||
|
||||
|
||||
class LTX2AttentionFunction(str, Enum):
|
||||
"""
|
||||
Placeholder enum for upstream `AttentionFunction.DEFAULT`.
|
||||
|
||||
Upstream reference:
|
||||
- `LTX-2/packages/ltx-core/src/ltx_core/model/transformer/attention.py`
|
||||
"""
|
||||
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LTX2ArchConfig(DiTArchConfig):
|
||||
"""Architecture configuration for LTX-2 Video Transformer."""
|
||||
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_blocks_or_transformer_blocks]
|
||||
)
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# Parameter name mappings from HuggingFace checkpoint keys to SGLang module names.
|
||||
# We use upstream variable names (patchify_proj, adaln_single) but HF uses different keys.
|
||||
#
|
||||
# HF key -> SGLang key (upstream naming)
|
||||
r"^model\.diffusion_model\.(.*)$": r"\1",
|
||||
r"^proj_in\.(.*)$": r"patchify_proj.\1",
|
||||
r"^time_embed\.(.*)$": r"adaln_single.\1",
|
||||
r"^audio_proj_in\.(.*)$": r"audio_patchify_proj.\1",
|
||||
r"^audio_time_embed\.(.*)$": r"audio_adaln_single.\1",
|
||||
# FeedForward
|
||||
r"(.*)ff\.net\.0\.proj\.(.*)$": r"\1ff.proj_in.\2",
|
||||
r"(.*)ff\.net\.2\.(.*)$": r"\1ff.proj_out.\2",
|
||||
# Attention Norms
|
||||
r"(.*)\.norm_q\.(.*)$": r"\1.q_norm.\2",
|
||||
r"(.*)\.norm_k\.(.*)$": r"\1.k_norm.\2",
|
||||
# Scale Shift Tables (Global)
|
||||
r"^av_cross_attn_video_scale_shift\.(.*)$": r"av_ca_video_scale_shift_adaln_single.\1",
|
||||
r"^av_cross_attn_audio_scale_shift\.(.*)$": r"av_ca_audio_scale_shift_adaln_single.\1",
|
||||
r"^av_cross_attn_video_a2v_gate\.(.*)$": r"av_ca_a2v_gate_adaln_single.\1",
|
||||
r"^av_cross_attn_audio_v2a_gate\.(.*)$": r"av_ca_v2a_gate_adaln_single.\1",
|
||||
# Scale Shift Tables (Block Level)
|
||||
# HF: scale_shift_table_a2v_ca_video -> SGLang: video_a2v_cross_attn_scale_shift_table
|
||||
r"(.*)scale_shift_table_a2v_ca_video": r"\1video_a2v_cross_attn_scale_shift_table",
|
||||
r"(.*)scale_shift_table_a2v_ca_audio": r"\1audio_a2v_cross_attn_scale_shift_table",
|
||||
}
|
||||
)
|
||||
|
||||
reverse_param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# Reverse mapping: SGLang module names -> HF checkpoint keys (for saving).
|
||||
r"^patchify_proj\.(.*)$": r"proj_in.\1",
|
||||
r"^adaln_single\.(.*)$": r"time_embed.\1",
|
||||
r"^audio_patchify_proj\.(.*)$": r"audio_proj_in.\1",
|
||||
r"^audio_adaln_single\.(.*)$": r"audio_time_embed.\1",
|
||||
# FeedForward
|
||||
r"(.*)ff\.proj_in\.(.*)$": r"\1ff.net.0.proj.\2",
|
||||
r"(.*)ff\.proj_out\.(.*)$": r"\1ff.net.2.\2",
|
||||
# Attention Norms
|
||||
r"(.*)\.q_norm\.(.*)$": r"\1.norm_q.\2",
|
||||
r"(.*)\.k_norm\.(.*)$": r"\1.norm_k.\2",
|
||||
# Scale Shift Tables (Global)
|
||||
r"^av_ca_video_scale_shift_adaln_single\.(.*)$": r"av_cross_attn_video_scale_shift.\1",
|
||||
r"^av_ca_audio_scale_shift_adaln_single\.(.*)$": r"av_cross_attn_audio_scale_shift.\1",
|
||||
r"^av_ca_a2v_gate_adaln_single\.(.*)$": r"av_cross_attn_video_a2v_gate.\1",
|
||||
r"^av_ca_v2a_gate_adaln_single\.(.*)$": r"av_cross_attn_audio_v2a_gate.\1",
|
||||
# Scale Shift Tables (Block Level)
|
||||
# SGLang: video_a2v_cross_attn_scale_shift_table -> HF: scale_shift_table_a2v_ca_video
|
||||
r"(.*)video_a2v_cross_attn_scale_shift_table": r"\1scale_shift_table_a2v_ca_video",
|
||||
r"(.*)audio_a2v_cross_attn_scale_shift_table": r"\1scale_shift_table_a2v_ca_audio",
|
||||
}
|
||||
)
|
||||
|
||||
lora_param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# LoRA parameter name mappings from official repo format to HF format.
|
||||
# This is applied before param_names_mapping when loading LoRA adapters.
|
||||
# Will be populated if LoRA adapters use different naming conventions.
|
||||
}
|
||||
)
|
||||
|
||||
# Model type and attention configuration
|
||||
model_type: LTXModelType = LTXModelType.AudioVideo
|
||||
attention_type: LTX2AttentionFunction = LTX2AttentionFunction.DEFAULT
|
||||
rope_type: LTX2RopeType = LTX2RopeType.INTERLEAVED
|
||||
double_precision_rope: bool = False
|
||||
quantize_video_rope_coords_to_hidden_dtype: bool = False
|
||||
apply_gated_attention: bool = False
|
||||
cross_attention_adaln: bool = False
|
||||
caption_proj_before_connector: bool = False
|
||||
|
||||
# Video parameters
|
||||
num_attention_heads: int = 32
|
||||
attention_head_dim: int = 128
|
||||
in_channels: int = 128
|
||||
out_channels: int = 128
|
||||
num_layers: int = 48
|
||||
cross_attention_dim: int = 4096
|
||||
norm_eps: float = 1e-6
|
||||
caption_channels: int = 3840
|
||||
positional_embedding_theta: float = 10000.0
|
||||
positional_embedding_max_pos: list[int] | None = None
|
||||
timestep_scale_multiplier: int = 1000
|
||||
use_middle_indices_grid: bool = True
|
||||
|
||||
# Audio parameters
|
||||
audio_num_attention_heads: int = 32
|
||||
audio_attention_head_dim: int = 64
|
||||
audio_in_channels: int = 128
|
||||
audio_out_channels: int = 128
|
||||
audio_cross_attention_dim: int = 2048
|
||||
audio_positional_embedding_max_pos: list[int] | None = None
|
||||
av_ca_timestep_scale_multiplier: int = 1
|
||||
|
||||
# 2.3 connector-related fields may show up in transformer/config.json.
|
||||
connector_attention_head_dim: int = 128
|
||||
connector_num_attention_heads: int = 30
|
||||
connector_num_layers: int = 2
|
||||
audio_connector_attention_head_dim: int = 128
|
||||
audio_connector_num_attention_heads: int = 30
|
||||
audio_connector_num_layers: int = 2
|
||||
|
||||
# SGLang-specific parameters
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
text_len: int = 512
|
||||
enable_packed_qkv_input_a2a: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
# Video derived values
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
if self.positional_embedding_max_pos is None:
|
||||
self.positional_embedding_max_pos = [20, 2048, 2048]
|
||||
|
||||
# Audio derived values
|
||||
self.audio_hidden_size = (
|
||||
self.audio_num_attention_heads * self.audio_attention_head_dim
|
||||
)
|
||||
if self.audio_positional_embedding_max_pos is None:
|
||||
self.audio_positional_embedding_max_pos = [20]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LTX2Config(DiTConfig):
|
||||
"""Configuration for LTX-2 Video Transformer."""
|
||||
|
||||
arch_config: LTX2ArchConfig = field(default_factory=LTX2ArchConfig)
|
||||
|
||||
prefix: str = "ltx2"
|
||||
torch_compile_mode: str = "default"
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copied and adapted from: mossVG/mova/diffusion/models/wan_audio_dit.py
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_block
|
||||
|
||||
|
||||
@dataclass
|
||||
class MOVAAudioArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_block])
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^blocks\.(\d+)\.ffn\.0\.(.*)$": r"blocks.\1.ffn.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.2\.(.*)$": r"blocks.\1.ffn.fc_out.\2",
|
||||
r"^blocks\.(\d+)\.norm3\.(.*)$": r"blocks.\1.self_attn_norm.\2",
|
||||
r"^text_embedding\.0\.(.*)$": r"text_embedding.fc_in.\1",
|
||||
r"^text_embedding\.2\.(.*)$": r"text_embedding.fc_out.\1",
|
||||
r"^time_embedding\.0\.(.*)$": r"time_embedding.fc_in.\1",
|
||||
r"^time_embedding\.2\.(.*)$": r"time_embedding.fc_out.\1",
|
||||
r"^img_emb\.proj\.1\.(.*)$": r"img_emb.fc_in.\1",
|
||||
r"^img_emb\.proj\.3\.(.*)$": r"img_emb.fc_out.\1",
|
||||
}
|
||||
)
|
||||
reverse_param_names_mapping: dict = field(default_factory=dict)
|
||||
lora_param_names_mapping: dict = field(default_factory=dict)
|
||||
|
||||
dim: int = 1536
|
||||
in_dim: int = 128
|
||||
ffn_dim: int = 6144
|
||||
out_dim: int = 128
|
||||
text_dim: int = 4096
|
||||
freq_dim: int = 256
|
||||
eps: float = 1e-6
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
num_heads: int = 12
|
||||
num_layers: int = 30
|
||||
has_image_input: bool = False
|
||||
has_image_pos_emb: bool = False
|
||||
has_ref_conv: bool = False
|
||||
add_control_adapter: bool = False
|
||||
in_dim_control_adapter: int = 24
|
||||
separated_timestep: bool = False
|
||||
require_vae_embedding: bool = False
|
||||
require_clip_embedding: bool = False
|
||||
fuse_vae_embedding_in_latents: bool = False
|
||||
vae_type: str = "dac"
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.hidden_size = self.dim
|
||||
self.num_attention_heads = self.num_heads
|
||||
self.num_channels_latents = self.out_dim
|
||||
assert (
|
||||
not self.has_image_input
|
||||
), "has_image_input must be False; it's a config from Diffsynth Studio, which means the model uses CLIP for image encoding (we don't)."
|
||||
|
||||
|
||||
@dataclass
|
||||
class MOVAAudioConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=MOVAAudioArchConfig)
|
||||
prefix: str = "mova_audio"
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copied and adapted from: mossVG/mova/diffusion/models/wan_video_dit.py
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_block
|
||||
|
||||
|
||||
@dataclass
|
||||
class MOVAVideoArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_block])
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^blocks\.(\d+)\.ffn\.0\.(.*)$": r"blocks.\1.ffn.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.2\.(.*)$": r"blocks.\1.ffn.fc_out.\2",
|
||||
r"^blocks\.(\d+)\.norm3\.(.*)$": r"blocks.\1.self_attn_norm.\2",
|
||||
r"^text_embedding\.0\.(.*)$": r"text_embedding.fc_in.\1",
|
||||
r"^text_embedding\.2\.(.*)$": r"text_embedding.fc_out.\1",
|
||||
r"^time_embedding\.0\.(.*)$": r"time_embedding.fc_in.\1",
|
||||
r"^time_embedding\.2\.(.*)$": r"time_embedding.fc_out.\1",
|
||||
r"^img_emb\.proj\.1\.(.*)$": r"img_emb.fc_in.\1",
|
||||
r"^img_emb\.proj\.3\.(.*)$": r"img_emb.fc_out.\1",
|
||||
}
|
||||
)
|
||||
reverse_param_names_mapping: dict = field(default_factory=dict)
|
||||
lora_param_names_mapping: dict = field(default_factory=dict)
|
||||
|
||||
dim: int = 5120
|
||||
in_dim: int = 16
|
||||
ffn_dim: int = 13824
|
||||
out_dim: int = 16
|
||||
text_dim: int = 4096
|
||||
freq_dim: int = 256
|
||||
eps: float = 1e-6
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
num_heads: int = 40
|
||||
num_layers: int = 40
|
||||
has_image_input: bool = False
|
||||
has_image_pos_emb: bool = False
|
||||
has_ref_conv: bool = False
|
||||
add_control_adapter: bool = False
|
||||
in_dim_control_adapter: int = 24
|
||||
separated_timestep: bool = False
|
||||
require_vae_embedding: bool = True
|
||||
require_clip_embedding: bool = True
|
||||
fuse_vae_embedding_in_latents: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.hidden_size = self.dim
|
||||
self.num_attention_heads = self.num_heads
|
||||
self.num_channels_latents = self.out_dim
|
||||
assert (
|
||||
not self.has_image_input
|
||||
), "has_image_input must be False; it's a config from Diffsynth Studio, which means the model uses CLIP for image encoding (we don't)."
|
||||
|
||||
|
||||
@dataclass
|
||||
class MOVAVideoConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=MOVAVideoArchConfig)
|
||||
prefix: str = "mova_video"
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# 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
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_transformer_block
|
||||
|
||||
|
||||
@dataclass
|
||||
class QwenImageArchConfig(DiTArchConfig):
|
||||
patch_size: int = 1
|
||||
in_channels: int = 64
|
||||
out_channels: int | None = None
|
||||
num_layers: int = 19
|
||||
num_single_layers: int = 38
|
||||
attention_head_dim: int = 128
|
||||
num_attention_heads: int = 24
|
||||
joint_attention_dim: int = 4096
|
||||
pooled_projection_dim: int = 768
|
||||
guidance_embeds: bool = False
|
||||
axes_dims_rope: Tuple[int, int, int] = (16, 56, 56)
|
||||
zero_cond_t: bool = False
|
||||
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_transformer_block])
|
||||
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# LoRA mappings
|
||||
r"^(transformer_blocks\.\d+\.attn\..*\.lora_[AB])\.default$": r"\1",
|
||||
# SVDquant mappings
|
||||
r"(.*)\.add_qkv_proj\.(.+)$": r"\1.to_added_qkv.\2",
|
||||
r"(transformer_blocks\.\d+\.(img_mlp|txt_mlp)\..*\.(smooth_factor_orig|wcscales))$": r"\1",
|
||||
r".*\.wtscale$": r"",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class QwenImageEditPlus_2511_ArchConfig(QwenImageArchConfig):
|
||||
zero_cond_t: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class QwenImageDitConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=QwenImageArchConfig)
|
||||
|
||||
prefix: str = "qwenimage"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QwenImageEditPlus_2511_DitConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(
|
||||
default_factory=QwenImageEditPlus_2511_ArchConfig
|
||||
)
|
||||
|
||||
prefix: str = "qwenimageedit"
|
||||
@@ -0,0 +1,64 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# Architecture and model configuration for SANA DiT (Diffusion Transformer).
|
||||
#
|
||||
# SANA uses a linear-attention-based transformer that replaces standard
|
||||
# quadratic self-attention with ReLU-based linear attention, enabling
|
||||
# efficient high-resolution image synthesis. Cross-attention (standard SDPA)
|
||||
# is used for text conditioning via Gemma2 embeddings.
|
||||
#
|
||||
# Defaults below correspond to the SANA-1.6B / 1024px variant.
|
||||
# For 4.8B, override num_layers=36, num_attention_heads=64, etc.
|
||||
#
|
||||
# Reference: https://arxiv.org/abs/2410.10629
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class SanaArchConfig(DiTArchConfig):
|
||||
patch_size: int = 1
|
||||
in_channels: int = 32
|
||||
out_channels: int = 32
|
||||
num_layers: int = 20
|
||||
attention_head_dim: int = 32
|
||||
num_attention_heads: int = 70
|
||||
num_cross_attention_heads: int = 20
|
||||
cross_attention_head_dim: int = 112
|
||||
cross_attention_dim: int = 2240
|
||||
caption_channels: int = 2304
|
||||
|
||||
mlp_ratio: float = 2.5
|
||||
# "rms_norm_across_heads" applies RMSNorm over the full (num_heads * head_dim)
|
||||
|
||||
qk_norm: str = "rms_norm_across_heads"
|
||||
norm_elementwise_affine: bool = False
|
||||
norm_eps: float = 1e-6
|
||||
sample_size: int = 32
|
||||
guidance_embeds: bool = False
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
# self linear-attn: merge q/k/v into to_qkv (concat order q, k, v)
|
||||
r"^(transformer_blocks\.\d+\.attn1)\.to_q\.(.*)$": (r"\1.to_qkv.\2", 0, 3),
|
||||
r"^(transformer_blocks\.\d+\.attn1)\.to_k\.(.*)$": (r"\1.to_qkv.\2", 1, 3),
|
||||
r"^(transformer_blocks\.\d+\.attn1)\.to_v\.(.*)$": (r"\1.to_qkv.\2", 2, 3),
|
||||
# cross-attn: merge k/v into to_kv (q stays separate)
|
||||
r"^(transformer_blocks\.\d+\.attn2)\.to_k\.(.*)$": (r"\1.to_kv.\2", 0, 2),
|
||||
r"^(transformer_blocks\.\d+\.attn2)\.to_v\.(.*)$": (r"\1.to_kv.\2", 1, 2),
|
||||
r"^transformer\.(.*)$": r"\1",
|
||||
}
|
||||
)
|
||||
|
||||
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 SanaConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=SanaArchConfig)
|
||||
prefix: str = "Sana"
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_blocks_or_transformer_blocks
|
||||
|
||||
|
||||
@dataclass
|
||||
class SanaWMArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_blocks_or_transformer_blocks]
|
||||
)
|
||||
|
||||
# --- Core dims (upstream: depth=20, hidden=2240, heads=20, linear_head_dim=112) ---
|
||||
patch_size: int = 1
|
||||
in_channels: int = 128 # LTX-2 VAE latent channels
|
||||
out_channels: int = 128
|
||||
num_layers: int = 20
|
||||
|
||||
# Patch embedder uses (1, patch_size, patch_size) — temporal patch is always 1.
|
||||
patch_size_t: int = 1
|
||||
|
||||
num_attention_heads: int = 20
|
||||
attention_head_dim: int = 112 # = linear_head_dim
|
||||
linear_head_dim: int = 112
|
||||
|
||||
# --- Cross-attention (text conditioning) ---
|
||||
# In upstream, cross-attn uses num_heads (=20) with head_dim = hidden/num_heads = 112.
|
||||
num_cross_attention_heads: int = 20
|
||||
cross_attention_head_dim: int = 112
|
||||
cross_attention_dim: int = 2240 # query dim used inside MultiHeadCrossAttention
|
||||
cross_norm: bool = True
|
||||
|
||||
# Gemma-2-2b-it hidden size (input to y_embedder.y_proj).
|
||||
caption_channels: int = 2304
|
||||
model_max_length: int = 300
|
||||
y_norm: bool = True
|
||||
y_norm_scale_factor: float = 0.01
|
||||
y_norm_eps: float = 1e-5
|
||||
|
||||
mlp_ratio: float = 3.0
|
||||
qk_norm: bool = True
|
||||
norm_eps: float = 1e-6
|
||||
timestep_norm_scale_factor: float = 1.0
|
||||
|
||||
# --- Hybrid GDN/Softmax attention ---
|
||||
# softmax_every_n=4 => blocks where (i+1)%4 == 0 use softmax main branch,
|
||||
# i.e. block indices {3, 7, 11, 15, 19}.
|
||||
softmax_every_n: int = 4
|
||||
|
||||
# --- GDN ShortConvolution params ---
|
||||
conv_kernel_size: int = 4
|
||||
k_conv_only: bool = True
|
||||
chunk_gdn_chunk_size: int = 21
|
||||
update_rule: str = "torch_chunk" # main branch update rule
|
||||
cam_update_rule: str = "torch_chunk" # camera branch update rule
|
||||
# main GDN scan backend: "auto" uses the SANA-WM Triton fast path on
|
||||
# supported CUDA inference runs, otherwise falls back to the torch scan.
|
||||
gdn_backend: str = "auto"
|
||||
|
||||
# --- Camera conditioning ---
|
||||
cam_attn_compress: int = 1 # cam_dim == in_dim
|
||||
init_cam_from_base: bool = True
|
||||
use_chunk_plucker_post_attn: bool = True
|
||||
use_chunk_plucker_input: bool = False
|
||||
chunk_plucker_channels: int = 48 # 8 orig frames × 6D Plücker
|
||||
chunk_plucker_post_attn_blocks: int = 20
|
||||
|
||||
chunk_split_strategy: str = "first_chunk_plus_one"
|
||||
chunk_size: int = 10
|
||||
# Upstream currently forwards chunk metadata through the softmax blocks but
|
||||
# does not apply a chunk-causal mask there. Keep this disabled by default
|
||||
# for checkpoint-output parity; it can be enabled for experiments.
|
||||
use_chunked_softmax_attention: bool = False
|
||||
|
||||
# --- Temporal FFN (GLUMBConvTemp) ---
|
||||
ffn_type: str = "GLUMBConvTemp"
|
||||
t_kernel_size: int = 3
|
||||
mlp_acts: tuple = field(default_factory=lambda: ("silu", "silu", None))
|
||||
|
||||
# --- Position embedding ---
|
||||
pos_embed_type: str = "wan_rope"
|
||||
|
||||
# --- VAE coupling (LTX-2) ---
|
||||
vae_temporal_stride: int = 8 # original-frames per latent frame
|
||||
vae_spatial_stride: int = 32 # pixels per latent token (per spatial axis)
|
||||
|
||||
sample_size: int = 32 # legacy, unused
|
||||
guidance_embeds: bool = False
|
||||
class_dropout_prob: float = 0.0
|
||||
|
||||
# the released checkpoints store raw upstream parameter names; streaming
|
||||
# also keeps an unused all-zero pos_embed while the native model uses RoPE
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
"^pos_embed$": "",
|
||||
}
|
||||
)
|
||||
|
||||
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 SanaWMConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=SanaWMArchConfig)
|
||||
prefix: str = "SanaWM"
|
||||
@@ -0,0 +1,66 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_blocks_or_transformer_blocks
|
||||
|
||||
|
||||
@dataclass
|
||||
class SanaWMRefinerArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_blocks_or_transformer_blocks]
|
||||
)
|
||||
|
||||
# Core dims
|
||||
in_channels: int = 128
|
||||
out_channels: int = 128
|
||||
patch_size: int = 1
|
||||
patch_size_t: int = 1
|
||||
num_layers: int = 28
|
||||
num_attention_heads: int = 32
|
||||
attention_head_dim: int = 64
|
||||
cross_attention_dim: int = 4096
|
||||
caption_channels: int = 4096
|
||||
|
||||
qk_norm: bool = True
|
||||
norm_eps: float = 1e-6
|
||||
apply_gated_attention: bool = False
|
||||
|
||||
timestep_scale_multiplier: float = 1000.0
|
||||
rope_type: str = "interleaved"
|
||||
|
||||
# RoPE coord generation
|
||||
sampling_rate: int = 16000
|
||||
hop_length: int = 160
|
||||
scale_factors: tuple = (8, 32, 32)
|
||||
base_num_frames: int = 20
|
||||
base_height: int = 2048
|
||||
base_width: int = 2048
|
||||
causal_offset: int = 1
|
||||
|
||||
# Map Diffusers-style param keys to sglang's LTX-2 primitive naming.
|
||||
# The refiner reuses LTX2Attention / LTX2FeedForward, so it inherits the
|
||||
# same naming differences vs Diffusers:
|
||||
# * ff.net.0.proj / ff.net.2 -> proj_in / proj_out (LTX2FeedForward)
|
||||
# * norm_q / norm_k -> q_norm / k_norm (LTX2Attention)
|
||||
# Keep this aligned with LTX2ArchConfig.param_names_mapping in ltx_2.py.
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^(transformer_blocks\.\d+\.ff)\.net\.0\.proj\.(.*)$": r"\1.proj_in.\2",
|
||||
r"^(transformer_blocks\.\d+\.ff)\.net\.2\.(.*)$": r"\1.proj_out.\2",
|
||||
r"(.*)\.norm_q\.(.*)$": r"\1.q_norm.\2",
|
||||
r"(.*)\.norm_k\.(.*)$": r"\1.k_norm.\2",
|
||||
}
|
||||
)
|
||||
|
||||
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 SanaWMRefinerConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=SanaWMRefinerArchConfig)
|
||||
prefix: str = "SanaWMRefiner"
|
||||
@@ -0,0 +1,37 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""StableDiffusion3 Transformer model configuration"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class StableDiffusion3TransformerArchConfig(DiTArchConfig):
|
||||
"""Architecture configuration for StableDiffusion3 Transformer, applicable to SD3-medium, SD3.5-medium, SD3.5-large."""
|
||||
|
||||
sample_size: int = 128
|
||||
patch_size: int = 2
|
||||
in_channels: int = 16
|
||||
out_channels: int = 16
|
||||
num_layers: int = 18
|
||||
attention_head_dim: int = 64
|
||||
num_attention_heads: int = 18
|
||||
cross_attention_dim: int = 4096
|
||||
joint_attention_dim: int = 4096
|
||||
caption_projection_dim: int = 1152
|
||||
pooled_projection_dim: int = 2048
|
||||
pos_embed_max_size: int = 96
|
||||
dual_attention_layers: tuple[int, ...] = ()
|
||||
qk_norm: str | None = None
|
||||
|
||||
_class_name: str = "SD3Transformer2DModel"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StableDiffusion3TransformerConfig(DiTConfig):
|
||||
"""Configuration for StableDiffusion3 Transformer model."""
|
||||
|
||||
arch_config: StableDiffusion3TransformerArchConfig = field(
|
||||
default_factory=StableDiffusion3TransformerArchConfig
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_block
|
||||
|
||||
|
||||
@dataclass
|
||||
class WanVideoArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_block])
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^patch_embedding\.(.*)$": r"patch_embedding.proj.\1",
|
||||
r"^condition_embedder\.text_embedder\.linear_1\.(.*)$": r"condition_embedder.text_embedder.fc_in.\1",
|
||||
r"^condition_embedder\.text_embedder\.linear_2\.(.*)$": r"condition_embedder.text_embedder.fc_out.\1",
|
||||
r"^condition_embedder\.time_embedder\.linear_1\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_in.\1",
|
||||
r"^condition_embedder\.time_embedder\.linear_2\.(.*)$": r"condition_embedder.time_embedder.mlp.fc_out.\1",
|
||||
r"^condition_embedder\.time_proj\.(.*)$": r"condition_embedder.time_modulation.linear.\1",
|
||||
r"^condition_embedder\.image_embedder\.ff\.net\.0\.proj\.(.*)$": r"condition_embedder.image_embedder.ff.fc_in.\1",
|
||||
r"^condition_embedder\.image_embedder\.ff\.net\.2\.(.*)$": r"condition_embedder.image_embedder.ff.fc_out.\1",
|
||||
r"^blocks\.(\d+)\.attn1\.to_q\.(.*)$": r"blocks.\1.to_q.\2",
|
||||
r"^blocks\.(\d+)\.attn1\.to_k\.(.*)$": r"blocks.\1.to_k.\2",
|
||||
r"^blocks\.(\d+)\.attn1\.to_v\.(.*)$": r"blocks.\1.to_v.\2",
|
||||
r"^blocks\.(\d+)\.attn1\.to_out\.0\.(.*)$": r"blocks.\1.to_out.\2",
|
||||
r"^blocks\.(\d+)\.attn1\.norm_q\.(.*)$": r"blocks.\1.norm_q.\2",
|
||||
r"^blocks\.(\d+)\.attn1\.norm_k\.(.*)$": r"blocks.\1.norm_k.\2",
|
||||
r"^blocks\.(\d+)\.attn1\.attn_op\.local_attn\.proj_l\.(.*)$": r"blocks.\1.attn1.local_attn.proj_l.\2",
|
||||
r"^blocks\.(\d+)\.attn2\.norm_added_q\.(.*)$": "",
|
||||
r"^blocks\.(\d+)\.attn2\.to_out\.0\.(.*)$": r"blocks.\1.attn2.to_out.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.net\.0\.proj\.(.*)$": r"blocks.\1.ffn.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.net\.2\.(.*)$": r"blocks.\1.ffn.fc_out.\2",
|
||||
r"^blocks\.(\d+)\.norm2\.(.*)$": r"blocks.\1.self_attn_residual_norm.norm.\2",
|
||||
}
|
||||
)
|
||||
|
||||
reverse_param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^patch_embedding\.proj\.(.*)$": r"patch_embedding.\1",
|
||||
r"^condition_embedder\.text_embedder\.fc_in\.(.*)$": r"condition_embedder.text_embedder.linear_1.\1",
|
||||
r"^condition_embedder\.text_embedder\.fc_out\.(.*)$": r"condition_embedder.text_embedder.linear_2.\1",
|
||||
r"^condition_embedder\.time_embedder\.mlp\.fc_in\.(.*)$": r"condition_embedder.time_embedder.linear_1.\1",
|
||||
r"^condition_embedder\.time_embedder\.mlp\.fc_out\.(.*)$": r"condition_embedder.time_embedder.linear_2.\1",
|
||||
r"^condition_embedder\.time_modulation\.linear\.(.*)$": r"condition_embedder.time_proj.\1",
|
||||
r"^condition_embedder\.image_embedder\.ff\.fc_in\.(.*)$": r"condition_embedder.image_embedder.ff.net.0.proj.\1",
|
||||
r"^condition_embedder\.image_embedder\.ff\.fc_out\.(.*)$": r"condition_embedder.image_embedder.ff.net.2.\1",
|
||||
r"^blocks\.(\d+)\.to_q\.(.*)$": r"blocks.\1.attn1.to_q.\2",
|
||||
r"^blocks\.(\d+)\.to_k\.(.*)$": r"blocks.\1.attn1.to_k.\2",
|
||||
r"^blocks\.(\d+)\.to_v\.(.*)$": r"blocks.\1.attn1.to_v.\2",
|
||||
r"^blocks\.(\d+)\.to_out\.(.*)$": r"blocks.\1.attn1.to_out.0.\2",
|
||||
r"^blocks\.(\d+)\.norm_q\.(.*)$": r"blocks.\1.attn1.norm_q.\2",
|
||||
r"^blocks\.(\d+)\.norm_k\.(.*)$": r"blocks.\1.attn1.norm_k.\2",
|
||||
r"^blocks\.(\d+)\.attn1\.local_attn\.proj_l\.(.*)$": r"blocks.\1.attn1.attn_op.local_attn.proj_l.\2",
|
||||
r"^blocks\.(\d+)\.attn2\.to_out\.(.*)$": r"blocks.\1.attn2.to_out.0.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.fc_in\.(.*)$": r"blocks.\1.ffn.net.0.proj.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.fc_out\.(.*)$": r"blocks.\1.ffn.net.2.\2",
|
||||
r"^blocks\.(\d+)\.self_attn_residual_norm\.norm\.(.*)$": r"blocks.\1.norm2.\2",
|
||||
}
|
||||
)
|
||||
|
||||
# Some LoRA adapters use the original official layer names instead of hf layer names,
|
||||
# so apply this before the param_names_mapping
|
||||
lora_param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^blocks\.(\d+)\.self_attn\.q\.(.*)$": r"blocks.\1.attn1.to_q.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.k\.(.*)$": r"blocks.\1.attn1.to_k.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.v\.(.*)$": r"blocks.\1.attn1.to_v.\2",
|
||||
r"^blocks\.(\d+)\.self_attn\.o\.(.*)$": r"blocks.\1.attn1.to_out.0.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.q\.(.*)$": r"blocks.\1.attn2.to_q.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.k\.(.*)$": r"blocks.\1.attn2.to_k.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.v\.(.*)$": r"blocks.\1.attn2.to_v.\2",
|
||||
r"^blocks\.(\d+)\.cross_attn\.o\.(.*)$": r"blocks.\1.attn2.to_out.0.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.0\.(.*)$": r"blocks.\1.ffn.fc_in.\2",
|
||||
r"^blocks\.(\d+)\.ffn\.2\.(.*)$": r"blocks.\1.ffn.fc_out.\2",
|
||||
}
|
||||
)
|
||||
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
text_len = 512
|
||||
num_attention_heads: int = 40
|
||||
attention_head_dim: int = 128
|
||||
in_channels: int = 16
|
||||
out_channels: int = 16
|
||||
text_dim: int = 4096
|
||||
freq_dim: int = 256
|
||||
ffn_dim: int = 13824
|
||||
num_layers: int = 40
|
||||
cross_attn_norm: bool = True
|
||||
qk_norm: str = "rms_norm_across_heads"
|
||||
eps: float = 1e-6
|
||||
image_dim: int | None = None
|
||||
added_kv_proj_dim: int | None = None
|
||||
rope_max_seq_len: int = 1024
|
||||
pos_embed_seq_len: int | None = None
|
||||
exclude_lora_layers: list[str] = field(default_factory=lambda: ["embedder"])
|
||||
|
||||
# Wan MoE
|
||||
boundary_ratio: float | None = None
|
||||
|
||||
# Causal Wan
|
||||
local_attn_size: int = (
|
||||
-1
|
||||
) # Window size for temporal local attention (-1 indicates global attention)
|
||||
sink_size: int = (
|
||||
0 # Size of the attention sink, we keep the first `sink_size` frames unchanged when rolling the KV cache
|
||||
)
|
||||
num_frames_per_block: int = 3
|
||||
sliding_window_num_frames: int = 21
|
||||
attention_type: str = "original"
|
||||
sla_topk: float = 0.1
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.hidden_size = self.num_attention_heads * self.attention_head_dim
|
||||
self.num_channels_latents = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class WanVideoConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=WanVideoArchConfig)
|
||||
|
||||
prefix: str = "Wan"
|
||||
@@ -0,0 +1,101 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# 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
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_zimage_layer
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZImageArchConfig(DiTArchConfig):
|
||||
all_patch_size: Tuple[int, ...] = (2,)
|
||||
all_f_patch_size: Tuple[int, ...] = (1,)
|
||||
in_channels: int = 16
|
||||
out_channels: int | None = None
|
||||
dim: int = 3840
|
||||
num_layers: int = 30
|
||||
n_refiner_layers: int = 2
|
||||
num_attention_heads: int = 30
|
||||
n_kv_heads: int = 30
|
||||
norm_eps: float = 1e-5
|
||||
qk_norm: bool = True
|
||||
cap_feat_dim: int = 2560
|
||||
rope_theta: float = 256.0
|
||||
t_scale: float = 1000.0
|
||||
axes_dims: Tuple[int, int, int] = (32, 48, 48)
|
||||
axes_lens: Tuple[int, int, int] = (1024, 512, 512)
|
||||
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_zimage_layer])
|
||||
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
(".feed_forward.w13", ".feed_forward.w1", "gate"),
|
||||
(".feed_forward.w13", ".feed_forward.w3", "up"),
|
||||
]
|
||||
)
|
||||
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"(.*)\.attention\.to_q\.weight$": (r"\1.attention.to_qkv.weight", 0, 3),
|
||||
r"(.*)\.attention\.to_k\.weight$": (r"\1.attention.to_qkv.weight", 1, 3),
|
||||
r"(.*)\.attention\.to_v\.weight$": (r"\1.attention.to_qkv.weight", 2, 3),
|
||||
r"(.*)\.attention\.to_q\.weight_scale_inv$": (
|
||||
r"\1.attention.to_qkv.weight_scale_inv",
|
||||
0,
|
||||
3,
|
||||
),
|
||||
r"(.*)\.attention\.to_k\.weight_scale_inv$": (
|
||||
r"\1.attention.to_qkv.weight_scale_inv",
|
||||
1,
|
||||
3,
|
||||
),
|
||||
r"(.*)\.attention\.to_v\.weight_scale_inv$": (
|
||||
r"\1.attention.to_qkv.weight_scale_inv",
|
||||
2,
|
||||
3,
|
||||
),
|
||||
r"(.*)\.attention\.to_q\.(lora_A|lora_B)$": (
|
||||
r"\1.attention.to_qkv.\2",
|
||||
0,
|
||||
3,
|
||||
),
|
||||
r"(.*)\.attention\.to_k\.(lora_A|lora_B)$": (
|
||||
r"\1.attention.to_qkv.\2",
|
||||
1,
|
||||
3,
|
||||
),
|
||||
r"(.*)\.attention\.to_v\.(lora_A|lora_B)$": (
|
||||
r"\1.attention.to_qkv.\2",
|
||||
2,
|
||||
3,
|
||||
),
|
||||
r"(.*)\.feed_forward\.w1\.weight$": (r"\1.feed_forward.w13.weight", 0, 2),
|
||||
r"(.*)\.feed_forward\.w3\.weight$": (r"\1.feed_forward.w13.weight", 1, 2),
|
||||
r"(.*)\.feed_forward\.w1\.(lora_A|lora_B)$": (
|
||||
r"\1.feed_forward.w13.\2",
|
||||
0,
|
||||
2,
|
||||
),
|
||||
r"(.*)\.feed_forward\.w3\.(lora_A|lora_B)$": (
|
||||
r"\1.feed_forward.w13.\2",
|
||||
1,
|
||||
2,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.out_channels = self.out_channels or self.in_channels
|
||||
self.num_channels_latents = self.in_channels
|
||||
self.hidden_size = self.dim
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZImageDitConfig(DiTConfig):
|
||||
arch_config: ZImageArchConfig = field(default_factory=ZImageArchConfig)
|
||||
|
||||
prefix: str = "zimage"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
BaseEncoderOutput,
|
||||
EncoderConfig,
|
||||
ImageEncoderConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.encoders.clip import (
|
||||
CLIPTextConfig,
|
||||
CLIPVisionConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.encoders.flux_2 import (
|
||||
FLUX_2_SYSTEM_MESSAGE,
|
||||
Flux2MistralTextConfig,
|
||||
build_flux2_text_messages,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.encoders.gemma2 import Gemma2Config
|
||||
from sglang.multimodal_gen.configs.models.encoders.gemma_3 import Gemma3Config
|
||||
from sglang.multimodal_gen.configs.models.encoders.ideogram import (
|
||||
Ideogram4TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.encoders.llama import LlamaConfig
|
||||
from sglang.multimodal_gen.configs.models.encoders.qwen3 import Qwen3TextConfig
|
||||
from sglang.multimodal_gen.configs.models.encoders.qwen3vl import Qwen3VLConfig
|
||||
from sglang.multimodal_gen.configs.models.encoders.t5 import T5Config
|
||||
|
||||
__all__ = [
|
||||
"EncoderConfig",
|
||||
"TextEncoderConfig",
|
||||
"ImageEncoderConfig",
|
||||
"BaseEncoderOutput",
|
||||
"CLIPTextConfig",
|
||||
"CLIPVisionConfig",
|
||||
"FLUX_2_SYSTEM_MESSAGE",
|
||||
"Flux2MistralTextConfig",
|
||||
"build_flux2_text_messages",
|
||||
"LlamaConfig",
|
||||
"Qwen3TextConfig",
|
||||
"Qwen3VLConfig",
|
||||
"T5Config",
|
||||
"Gemma2Config",
|
||||
"Gemma3Config",
|
||||
"Ideogram4TextEncoderConfig",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.base import ArchConfig, ModelConfig
|
||||
from sglang.multimodal_gen.runtime.layers.quantization import QuantizationConfig
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncoderArchConfig(ArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [])
|
||||
architectures: list[str] = field(default_factory=lambda: [])
|
||||
_supported_attention_backends: set[AttentionBackendEnum] = field(
|
||||
default_factory=lambda: {
|
||||
AttentionBackendEnum.FA,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
AttentionBackendEnum.SAGE_ATTN_3,
|
||||
}
|
||||
)
|
||||
output_hidden_states: bool = False
|
||||
use_return_dict: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextEncoderArchConfig(EncoderArchConfig):
|
||||
vocab_size: int = 0
|
||||
hidden_size: int = 0
|
||||
num_hidden_layers: int = 0
|
||||
num_attention_heads: int = 0
|
||||
pad_token_id: int = 0
|
||||
eos_token_id: int = 0
|
||||
text_len: int = 0
|
||||
hidden_state_skip_layer: int = 0
|
||||
decoder_start_token_id: int = 0
|
||||
output_past: bool = True
|
||||
scalable_attention: bool = True
|
||||
tie_word_embeddings: bool = False
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=list
|
||||
) # mapping from huggingface weight names to custom names
|
||||
tokenizer_kwargs: dict[str, Any] = field(default_factory=dict)
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [])
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.tokenizer_kwargs = {
|
||||
"truncation": True,
|
||||
"max_length": self.text_len,
|
||||
"return_tensors": "pt",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageEncoderArchConfig(EncoderArchConfig):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseEncoderOutput:
|
||||
last_hidden_state: torch.FloatTensor | None = None
|
||||
pooler_output: torch.FloatTensor | None = None
|
||||
hidden_states: tuple[torch.FloatTensor, ...] | None = None
|
||||
attentions: tuple[torch.FloatTensor, ...] | None = None
|
||||
attention_mask: torch.Tensor | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncoderConfig(ModelConfig):
|
||||
arch_config: ArchConfig = field(default_factory=EncoderArchConfig)
|
||||
|
||||
prefix: str = ""
|
||||
quant_config: QuantizationConfig | None = None
|
||||
lora_config: Any | None = None
|
||||
|
||||
# Parallel folding: during the encoding stage the whole DiT replica is idle,
|
||||
# so TP-shard the encoder across those otherwise-unused GPUs instead of
|
||||
# running it on a single rank
|
||||
parallel_folding_mode: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextEncoderConfig(EncoderConfig):
|
||||
arch_config: ArchConfig = field(default_factory=TextEncoderArchConfig)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageEncoderConfig(EncoderConfig):
|
||||
arch_config: ArchConfig = field(default_factory=ImageEncoderArchConfig)
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
ImageEncoderArchConfig,
|
||||
ImageEncoderConfig,
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_embeddings,
|
||||
is_layer,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIPTextArchConfig(TextEncoderArchConfig):
|
||||
vocab_size: int = 49408
|
||||
hidden_size: int = 512
|
||||
intermediate_size: int = 2048
|
||||
projection_dim: int = 512
|
||||
num_hidden_layers: int = 12
|
||||
num_attention_heads: int = 8
|
||||
max_position_embeddings: int = 77
|
||||
hidden_act: str = "quick_gelu"
|
||||
layer_norm_eps: float = 1e-5
|
||||
dropout: float = 0.0
|
||||
attention_dropout: float = 0.0
|
||||
initializer_range: float = 0.02
|
||||
initializer_factor: float = 1.0
|
||||
pad_token_id: int = 1
|
||||
bos_token_id: int = 49406
|
||||
eos_token_id: int = 49407
|
||||
text_len: int = 77
|
||||
_supported_attention_backends: set[AttentionBackendEnum] = field(
|
||||
default_factory=lambda: {
|
||||
AttentionBackendEnum.TORCH_SDPA, # Force TORCH_SDPA to support attention_mask
|
||||
}
|
||||
)
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
("qkv_proj", "q_proj", "q"),
|
||||
("qkv_proj", "k_proj", "k"),
|
||||
("qkv_proj", "v_proj", "v"),
|
||||
]
|
||||
)
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_layer, is_embeddings]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIPVisionArchConfig(ImageEncoderArchConfig):
|
||||
hidden_size: int = 768
|
||||
intermediate_size: int = 3072
|
||||
projection_dim: int = 512
|
||||
num_hidden_layers: int = 12
|
||||
num_attention_heads: int = 12
|
||||
num_channels: int = 3
|
||||
image_size: int = 224
|
||||
patch_size: int = 32
|
||||
hidden_act: str = "quick_gelu"
|
||||
layer_norm_eps: float = 1e-5
|
||||
dropout: float = 0.0
|
||||
attention_dropout: float = 0.0
|
||||
initializer_range: float = 0.02
|
||||
initializer_factor: float = 1.0
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
("qkv_proj", "q_proj", "q"),
|
||||
("qkv_proj", "k_proj", "k"),
|
||||
("qkv_proj", "v_proj", "v"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIPTextConfig(TextEncoderConfig):
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=CLIPTextArchConfig)
|
||||
|
||||
num_hidden_layers_override: int | None = None
|
||||
require_post_norm: bool | None = None
|
||||
prefix: str = "clip"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIPVisionConfig(ImageEncoderConfig):
|
||||
arch_config: ImageEncoderArchConfig = field(default_factory=CLIPVisionArchConfig)
|
||||
|
||||
num_hidden_layers_override: int | None = None
|
||||
require_post_norm: bool | None = None
|
||||
prefix: str = "clip"
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""FLUX.2 Mistral text encoder configuration and prompt formatting."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_layer
|
||||
|
||||
FLUX_2_SYSTEM_MESSAGE = (
|
||||
"You are an AI that reasons about image descriptions. You give structured responses focusing on object relationships, object\n"
|
||||
"attribution and actions without speculation."
|
||||
)
|
||||
|
||||
|
||||
def build_flux2_text_messages(prompts: list[str]) -> list[list[dict]]:
|
||||
cleaned_prompts = [prompt.replace("[IMG]", "") for prompt in prompts]
|
||||
return [
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": FLUX_2_SYSTEM_MESSAGE}],
|
||||
},
|
||||
{"role": "user", "content": [{"type": "text", "text": prompt}]},
|
||||
]
|
||||
for prompt in cleaned_prompts
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Flux2MistralTextArchConfig(TextEncoderArchConfig):
|
||||
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"),
|
||||
]
|
||||
)
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_layer])
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.tokenizer_kwargs = {
|
||||
"padding": "max_length",
|
||||
"truncation": True,
|
||||
"max_length": 512,
|
||||
"add_special_tokens": True,
|
||||
"return_attention_mask": True,
|
||||
"return_tensors": "pt",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Flux2MistralTextConfig(TextEncoderConfig):
|
||||
arch_config: TextEncoderArchConfig = field(
|
||||
default_factory=Flux2MistralTextArchConfig
|
||||
)
|
||||
prefix: str = "flux_2_mistral"
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# Text encoder configuration for Gemma2 2B, used by SANA for text conditioning.
|
||||
#
|
||||
# SANA uses the hidden states from Gemma2 (not logits) as the conditioning
|
||||
# signal for cross-attention in the DiT. The encoder output dimension (2304)
|
||||
# is projected to the DiT's inner_dim via caption_projection.
|
||||
#
|
||||
# Defaults match google/gemma-2-2b-it (the model used in SANA HF checkpoints).
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_embed_tokens,
|
||||
is_final_norm,
|
||||
is_layer,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gemma2ArchConfig(TextEncoderArchConfig):
|
||||
vocab_size: int = 256000
|
||||
hidden_size: int = 2304
|
||||
intermediate_size: int = 9216
|
||||
num_hidden_layers: int = 26
|
||||
num_attention_heads: int = 8
|
||||
num_key_value_heads: int = 4
|
||||
head_dim: int = 256
|
||||
hidden_act: str = "gelu_pytorch_tanh"
|
||||
hidden_activation: str = "gelu_pytorch_tanh"
|
||||
max_position_embeddings: int = 8192
|
||||
rms_norm_eps: float = 1e-6
|
||||
use_cache: bool = True
|
||||
pad_token_id: int = 0
|
||||
eos_token_id: int = 1
|
||||
bos_token_id: int = 2
|
||||
tie_word_embeddings: bool = True
|
||||
rope_theta: float = 10000.0
|
||||
attention_bias: bool = False
|
||||
attention_dropout: float = 0.0
|
||||
|
||||
# Gemma2 alternates between global and sliding-window attention
|
||||
# on odd/even layers, respectively.
|
||||
sliding_window: int = 4096
|
||||
|
||||
# query_pre_attn_scalar replaces the standard 1/sqrt(head_dim) scaling.
|
||||
query_pre_attn_scalar: int = 256
|
||||
|
||||
# Softcapping bounds raw attention logits via tanh(logits/cap)*cap.
|
||||
# NOTE: SDPA does not natively support softcapping; the runtime model
|
||||
# currently skips this (see Gemma2Attention.forward). Quality impact
|
||||
# is minimal for short text-encoder sequences but should be revisited
|
||||
# for longer context.
|
||||
attn_logit_softcapping: float = 50.0
|
||||
final_logit_softcapping: float = 30.0
|
||||
|
||||
text_len: int = 300
|
||||
|
||||
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_layer, is_embed_tokens, is_final_norm]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gemma2Config(TextEncoderConfig):
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=Gemma2ArchConfig)
|
||||
prefix: str = "gemma_2"
|
||||
@@ -0,0 +1,74 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_embed_tokens,
|
||||
is_final_norm,
|
||||
is_layer,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gemma3ArchConfig(TextEncoderArchConfig):
|
||||
"""Minimal Gemma text-encoder config for tokenizer kwargs.
|
||||
|
||||
Note: runtime will load the actual `text_encoder/` module from the model repo
|
||||
(e.g. Gemma3Model) via transformers; this config mainly controls tokenization.
|
||||
"""
|
||||
|
||||
vocab_size: int = 32000
|
||||
hidden_size: int = 4096
|
||||
intermediate_size: int = 11008
|
||||
num_hidden_layers: int = 32
|
||||
num_attention_heads: int = 32
|
||||
num_key_value_heads: int | None = None
|
||||
hidden_act: str = "gelu_pytorch_tanh"
|
||||
max_position_embeddings: int = 2048
|
||||
initializer_range: float = 0.02
|
||||
rms_norm_eps: float = 1e-6
|
||||
use_cache: bool = True
|
||||
pad_token_id: int = 0
|
||||
bos_token_id: int = 1
|
||||
eos_token_id: int = 2
|
||||
pretraining_tp: int = 1
|
||||
tie_word_embeddings: bool = True
|
||||
rope_theta: float = 10000.0
|
||||
rope_scaling: dict | None = None
|
||||
rope_local_base_freq: float = 10000.0
|
||||
sliding_window: int = 4096
|
||||
layer_types: list[str] = field(default_factory=list)
|
||||
query_pre_attn_scalar: int | None = None
|
||||
attention_bias: bool = False
|
||||
attention_dropout: float = 0.0
|
||||
mlp_bias: bool = False
|
||||
head_dim: int | None = None
|
||||
hidden_state_skip_layer: int = 2
|
||||
text_len: int = 1024
|
||||
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
(".qkv_proj", ".q_proj", "q"),
|
||||
(".qkv_proj", ".k_proj", "k"),
|
||||
(".qkv_proj", ".v_proj", "v"),
|
||||
(".gate_up_proj", ".gate_proj", "0"), # type: ignore
|
||||
(".gate_up_proj", ".up_proj", "1"), # type: ignore
|
||||
]
|
||||
)
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_layer, is_embed_tokens, is_final_norm]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gemma3Config(TextEncoderConfig):
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=Gemma3ArchConfig)
|
||||
|
||||
prefix: str = "gemma_3"
|
||||
@@ -0,0 +1,37 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.qwen3vl import (
|
||||
Qwen3VLArchConfig,
|
||||
Qwen3VLConfig,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ideogram4TextEncoderConfig(Qwen3VLConfig):
|
||||
"""Use the local Ideogram text_encoder as a language-only Qwen3-VL encoder."""
|
||||
|
||||
def update_model_arch(self, source_model_dict):
|
||||
super().update_model_arch(source_model_dict)
|
||||
self.post_diffusers_config_update()
|
||||
|
||||
def post_diffusers_config_update(self):
|
||||
self.arch_config.architectures = ["IdeogramQwen3VLTextEncoder"]
|
||||
quant_config = getattr(self.arch_config, "quantization_config", None)
|
||||
if isinstance(quant_config, dict):
|
||||
quant_method = quant_config.get("quant_method")
|
||||
load_in_4bit = quant_config.get("load_in_4bit", False)
|
||||
else:
|
||||
quant_method = getattr(quant_config, "quant_method", None)
|
||||
load_in_4bit = getattr(quant_config, "load_in_4bit", False)
|
||||
quant_method_name = str(quant_method).lower()
|
||||
use_bitsandbytes = "bitsandbytes" in quant_method_name and load_in_4bit
|
||||
self.arch_config.ideogram_bnb_4bit_weight_only = use_bitsandbytes
|
||||
self.arch_config.ideogram_fp8_weight_only = not use_bitsandbytes
|
||||
self.arch_config.requires_gpu_resident_text_encoder = use_bitsandbytes
|
||||
|
||||
def finalize_model_arch(self):
|
||||
self.post_diffusers_config_update()
|
||||
|
||||
arch_config: Qwen3VLArchConfig = field(default_factory=Qwen3VLArchConfig)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_embed_tokens,
|
||||
is_final_norm,
|
||||
is_layer,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlamaArchConfig(TextEncoderArchConfig):
|
||||
vocab_size: int = 32000
|
||||
hidden_size: int = 4096
|
||||
intermediate_size: int = 11008
|
||||
num_hidden_layers: int = 32
|
||||
num_attention_heads: int = 32
|
||||
num_key_value_heads: int | None = None
|
||||
hidden_act: str = "silu"
|
||||
max_position_embeddings: int = 2048
|
||||
initializer_range: float = 0.02
|
||||
rms_norm_eps: float = 1e-6
|
||||
use_cache: bool = True
|
||||
pad_token_id: int = 0
|
||||
bos_token_id: int = 1
|
||||
eos_token_id: int = 2
|
||||
pretraining_tp: int = 1
|
||||
tie_word_embeddings: bool = False
|
||||
rope_theta: float = 10000.0
|
||||
rope_scaling: float | None = None
|
||||
attention_bias: bool = False
|
||||
attention_dropout: float = 0.0
|
||||
mlp_bias: bool = False
|
||||
head_dim: int | None = None
|
||||
hidden_state_skip_layer: int = 2
|
||||
text_len: int = 256
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
(".qkv_proj", ".q_proj", "q"),
|
||||
(".qkv_proj", ".k_proj", "k"),
|
||||
(".qkv_proj", ".v_proj", "v"),
|
||||
(".gate_up_proj", ".gate_proj", 0), # type: ignore
|
||||
(".gate_up_proj", ".up_proj", 1), # type: ignore
|
||||
]
|
||||
)
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_layer, is_embed_tokens, is_final_norm]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlamaConfig(TextEncoderConfig):
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=LlamaArchConfig)
|
||||
|
||||
prefix: str = "llama"
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_embed_tokens,
|
||||
is_final_norm,
|
||||
is_layer,
|
||||
)
|
||||
|
||||
|
||||
@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_layer, is_embed_tokens, 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,79 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Qwen3 text encoder configuration for SGLang diffusion models."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_embed_tokens,
|
||||
is_final_norm,
|
||||
is_layer,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Qwen3TextArchConfig(TextEncoderArchConfig):
|
||||
"""Architecture config for Qwen3 text encoder.
|
||||
|
||||
Qwen3 is similar to LLaMA but with QK-Norm (RMSNorm on Q and K before attention).
|
||||
"""
|
||||
|
||||
vocab_size: int = 151936
|
||||
hidden_size: int = 2560
|
||||
intermediate_size: int = 9728
|
||||
num_hidden_layers: int = 36
|
||||
num_attention_heads: int = 32
|
||||
num_key_value_heads: int = 8
|
||||
hidden_act: str = "silu"
|
||||
max_position_embeddings: int = 40960
|
||||
initializer_range: float = 0.02
|
||||
rms_norm_eps: float = 1e-6
|
||||
use_cache: bool = True
|
||||
pad_token_id: int = 151643
|
||||
bos_token_id: int = 151643
|
||||
eos_token_id: int = 151645
|
||||
tie_word_embeddings: bool = True
|
||||
rope_theta: float = 1000000.0
|
||||
rope_scaling: dict | None = None
|
||||
attention_bias: bool = False
|
||||
attention_dropout: float = 0.0
|
||||
mlp_bias: bool = False
|
||||
head_dim: int = 128
|
||||
text_len: int = 512
|
||||
output_hidden_states: bool = True # Klein needs hidden states from layers 9, 18, 27
|
||||
|
||||
# Stacked params for weight loading with tensor parallelism
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
(".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 sharding conditions for CPU offload
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_layer, is_embed_tokens, is_final_norm]
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.tokenizer_kwargs = {
|
||||
"padding": "max_length",
|
||||
"truncation": True,
|
||||
"max_length": self.text_len,
|
||||
"return_tensors": "pt",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Qwen3TextConfig(TextEncoderConfig):
|
||||
"""Top-level config for Qwen3 text encoder."""
|
||||
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=Qwen3TextArchConfig)
|
||||
prefix: str = "qwen3"
|
||||
@@ -0,0 +1,87 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_embed_tokens,
|
||||
is_final_norm,
|
||||
is_layer,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Qwen3VLArchConfig(TextEncoderArchConfig):
|
||||
"""Architecture configuration for Qwen3-VL text encoder.
|
||||
|
||||
Qwen3-VL-8B-Instruct is used by JoyImage model.
|
||||
Architecture is similar to Qwen2.5-VL but with Qwen3 improvements.
|
||||
"""
|
||||
|
||||
vocab_size: int = 32000
|
||||
hidden_size: int = 4096
|
||||
intermediate_size: int = 11008
|
||||
num_hidden_layers: int = 32
|
||||
num_attention_heads: int = 32
|
||||
num_key_value_heads: int | None = None
|
||||
hidden_act: str = "silu"
|
||||
max_position_embeddings: int = 2048
|
||||
initializer_range: float = 0.02
|
||||
rms_norm_eps: float = 1e-6
|
||||
use_cache: bool = True
|
||||
pad_token_id: int = -1
|
||||
eos_token_id: int = 2
|
||||
pretraining_tp: int = 1
|
||||
tie_word_embeddings: bool = False
|
||||
rope_theta: float = 10000.0
|
||||
rope_scaling: float | None = None
|
||||
attention_bias: bool = False
|
||||
attention_dropout: float = 0.0
|
||||
mlp_bias: bool = False
|
||||
head_dim: int | None = None
|
||||
hidden_state_skip_layer: int = 2
|
||||
text_len: int = 2048
|
||||
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
(".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_layer, is_embed_tokens, is_final_norm]
|
||||
)
|
||||
|
||||
# JoyImage specific settings
|
||||
text_token_max_length: int = 2048
|
||||
prompt_template_encode_start_idx = {
|
||||
"image": 34,
|
||||
"video": 91,
|
||||
}
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.tokenizer_kwargs = {
|
||||
"padding": True,
|
||||
"truncation": True,
|
||||
"max_length": self.text_len
|
||||
+ self.prompt_template_encode_start_idx["image"],
|
||||
"return_tensors": "pt",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Qwen3VLConfig(TextEncoderConfig):
|
||||
"""Configuration for Qwen3-VL text encoder.
|
||||
|
||||
Used by JoyImage model.
|
||||
"""
|
||||
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=Qwen3VLArchConfig)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_embed_tokens,
|
||||
is_final_norm,
|
||||
is_layer,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QwenImageArchConfig(TextEncoderArchConfig):
|
||||
vocab_size: int = 32000
|
||||
hidden_size: int = 4096
|
||||
intermediate_size: int = 11008
|
||||
num_hidden_layers: int = 32
|
||||
num_attention_heads: int = 32
|
||||
num_key_value_heads: int | None = None
|
||||
hidden_act: str = "silu"
|
||||
max_position_embeddings: int = 2048
|
||||
initializer_range: float = 0.02
|
||||
rms_norm_eps: float = 1e-6
|
||||
use_cache: bool = True
|
||||
pad_token_id: int = -1
|
||||
eos_token_id: int = 2
|
||||
pretraining_tp: int = 1
|
||||
tie_word_embeddings: bool = False
|
||||
rope_theta: float = 10000.0
|
||||
rope_scaling: float | None = None
|
||||
attention_bias: bool = False
|
||||
attention_dropout: float = 0.0
|
||||
mlp_bias: bool = False
|
||||
head_dim: int | None = None
|
||||
hidden_state_skip_layer: int = 2
|
||||
text_len: int = 512
|
||||
vision_start_token_id: int = 151652
|
||||
vision_end_token_id: int = 151653
|
||||
vision_token_id: int = 151654
|
||||
image_token_id: int = 151655
|
||||
video_token_id: int = 151656
|
||||
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
(".qkv_proj", ".q_proj", "q"),
|
||||
(".qkv_proj", ".k_proj", "k"),
|
||||
(".qkv_proj", ".v_proj", "v"),
|
||||
(".gate_up_proj", ".gate_proj", 0), # type: ignore
|
||||
(".gate_up_proj", ".up_proj", 1), # type: ignore
|
||||
]
|
||||
)
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [is_layer, is_embed_tokens, is_final_norm]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Qwen2_5VLConfig(TextEncoderConfig):
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=QwenImageArchConfig)
|
||||
# prefix: str = "qwen_image"
|
||||
@@ -0,0 +1,86 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import argparse
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.fsdp import (
|
||||
is_final_layer_norm,
|
||||
is_shared,
|
||||
is_t5_block,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class T5ArchConfig(TextEncoderArchConfig):
|
||||
vocab_size: int = 32128
|
||||
d_model: int = 512
|
||||
d_kv: int = 64
|
||||
d_ff: int = 2048
|
||||
num_layers: int = 6
|
||||
num_decoder_layers: int | None = None
|
||||
num_heads: int = 8
|
||||
relative_attention_num_buckets: int = 32
|
||||
relative_attention_max_distance: int = 128
|
||||
dropout_rate: float = 0.1
|
||||
layer_norm_epsilon: float = 1e-6
|
||||
initializer_factor: float = 1.0
|
||||
feed_forward_proj: str = "relu"
|
||||
dense_act_fn: str = ""
|
||||
is_gated_act: bool = False
|
||||
is_encoder_decoder: bool = True
|
||||
use_cache: bool = True
|
||||
pad_token_id: int = 0
|
||||
eos_token_id: int = 1
|
||||
classifier_dropout: float = 0.0
|
||||
text_len: int = 512
|
||||
stacked_params_mapping: list[tuple[str, str, str]] = field(
|
||||
default_factory=lambda: [
|
||||
# (param_name, shard_name, shard_id)
|
||||
(".qkv_proj", ".q", "q"),
|
||||
(".qkv_proj", ".k", "k"),
|
||||
(".qkv_proj", ".v", "v"),
|
||||
]
|
||||
)
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [
|
||||
is_t5_block,
|
||||
is_shared,
|
||||
is_final_layer_norm,
|
||||
]
|
||||
)
|
||||
|
||||
# Referenced from https://github.com/huggingface/transformers/blob/main/src/transformers/models/t5/configuration_t5.py
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
act_info = self.feed_forward_proj.split("-")
|
||||
self.dense_act_fn: str = act_info[-1]
|
||||
self.is_gated_act: bool = act_info[0] == "gated"
|
||||
if self.feed_forward_proj == "gated-gelu":
|
||||
self.dense_act_fn = "gelu_new"
|
||||
|
||||
self.tokenizer_kwargs = {
|
||||
"padding": "max_length",
|
||||
"truncation": True,
|
||||
"max_length": self.text_len,
|
||||
"add_special_tokens": True,
|
||||
"return_attention_mask": True,
|
||||
"return_tensors": "pt",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class T5Config(TextEncoderConfig):
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=T5ArchConfig)
|
||||
|
||||
prefix: str = "t5"
|
||||
|
||||
@staticmethod
|
||||
def add_cli_args(
|
||||
parser: argparse.ArgumentParser, prefix: str = "t5-config"
|
||||
) -> argparse.ArgumentParser:
|
||||
return parser
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
def is_module_list_entry(name: str, container_name: str) -> bool:
|
||||
# Match only direct block entries, not their inner submodules.
|
||||
parts = name.split(".")
|
||||
return len(parts) >= 2 and parts[-2] == container_name and parts[-1].isdigit()
|
||||
|
||||
|
||||
def is_module_list_entry_in(name: str, container_names: tuple[str, ...]) -> bool:
|
||||
parts = name.split(".")
|
||||
return len(parts) >= 2 and parts[-2] in container_names and parts[-1].isdigit()
|
||||
|
||||
|
||||
def is_layer(name: str, module: object) -> bool:
|
||||
return is_module_list_entry(name, "layers")
|
||||
|
||||
|
||||
def is_block(name: str, module: object) -> bool:
|
||||
return is_module_list_entry(name, "blocks")
|
||||
|
||||
|
||||
def is_t5_block(name: str, module: object) -> bool:
|
||||
return is_module_list_entry(name, "block")
|
||||
|
||||
|
||||
def is_transformer_block(name: str, module: object) -> bool:
|
||||
return is_module_list_entry(name, "transformer_blocks")
|
||||
|
||||
|
||||
def is_double_block(name: str, module: object) -> bool:
|
||||
return is_module_list_entry(name, "double_blocks")
|
||||
|
||||
|
||||
def is_single_block(name: str, module: object) -> bool:
|
||||
return is_module_list_entry(name, "single_blocks")
|
||||
|
||||
|
||||
def is_refiner_block(name: str, module: object) -> bool:
|
||||
return is_module_list_entry(name, "refiner_blocks")
|
||||
|
||||
|
||||
def is_blocks_or_double_blocks(name: str, module: object) -> bool:
|
||||
return is_module_list_entry_in(name, ("blocks", "double_blocks"))
|
||||
|
||||
|
||||
def is_blocks_or_transformer_blocks(name: str, module: object) -> bool:
|
||||
return is_module_list_entry_in(name, ("blocks", "transformer_blocks"))
|
||||
|
||||
|
||||
def is_zimage_layer(name: str, module: object) -> bool:
|
||||
last_part = name.split(".")[-1]
|
||||
# Preserve Z-Image's finer historical FSDP granularity for perf.
|
||||
return last_part.isdigit() and (
|
||||
"layers" in name or "noise_refiner" in name or "context_refiner" in name
|
||||
)
|
||||
|
||||
|
||||
def is_embed_tokens(name: str, module: object) -> bool:
|
||||
return name.endswith("embed_tokens")
|
||||
|
||||
|
||||
def is_embeddings(name: str, module: object) -> bool:
|
||||
return name.endswith("embeddings")
|
||||
|
||||
|
||||
def is_final_norm(name: str, module: object) -> bool:
|
||||
return name.endswith("norm")
|
||||
|
||||
|
||||
def is_shared(name: str, module: object) -> bool:
|
||||
return name.endswith("shared")
|
||||
|
||||
|
||||
def is_final_layer_norm(name: str, module: object) -> bool:
|
||||
return name.endswith("final_layer_norm")
|
||||
|
||||
|
||||
def is_txt_in(name: str, module: object) -> bool:
|
||||
return name.split(".")[-1] == "txt_in"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
from sglang.multimodal_gen.configs.models.vaes.dac import DacVAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.hunyuan3d import Hunyuan3DVAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.hunyuanvae import HunyuanVAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.stablediffusion3 import (
|
||||
StableDiffusion3VAEConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.vaes.wanvae import WanVAEConfig
|
||||
|
||||
__all__ = [
|
||||
"DacVAEConfig",
|
||||
"HunyuanVAEConfig",
|
||||
"StableDiffusion3VAEConfig",
|
||||
"WanVAEConfig",
|
||||
"Hunyuan3DVAEConfig",
|
||||
]
|
||||
@@ -0,0 +1,234 @@
|
||||
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import argparse
|
||||
import dataclasses
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.base import ArchConfig, ModelConfig
|
||||
from sglang.multimodal_gen.utils import StoreBoolean
|
||||
|
||||
AUTO_PARALLEL_DECODE_MODE = "auto"
|
||||
SPATIAL_SHARD_PARALLEL_DECODE_MODES = ("spatial_shard", "spatial")
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def is_spatial_shard_parallel_decode_mode(mode: str) -> bool:
|
||||
return mode in SPATIAL_SHARD_PARALLEL_DECODE_MODES
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def is_auto_parallel_decode_mode(mode: str) -> bool:
|
||||
return mode == AUTO_PARALLEL_DECODE_MODE
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _should_use_auto_spatial_shard_parallel_decode(
|
||||
z_shape: tuple[int, ...],
|
||||
world_size: int,
|
||||
min_latent_elements_per_rank: int,
|
||||
) -> bool:
|
||||
if world_size <= 1 or z_shape[-2] < world_size:
|
||||
return False
|
||||
latent_elements_per_rank = (
|
||||
z_shape[0] * z_shape[-3] * z_shape[-2] * z_shape[-1]
|
||||
) // world_size
|
||||
return latent_elements_per_rank >= min_latent_elements_per_rank
|
||||
|
||||
|
||||
def should_use_spatial_shard_parallel_decode(
|
||||
config: Any, z: torch.Tensor | None = None, world_size: int = 1
|
||||
) -> bool:
|
||||
if not config.use_parallel_decode:
|
||||
return False
|
||||
|
||||
if is_spatial_shard_parallel_decode_mode(config.parallel_decode_mode):
|
||||
return True
|
||||
|
||||
if not is_auto_parallel_decode_mode(config.parallel_decode_mode):
|
||||
return False
|
||||
|
||||
if not config.auto_parallel_decode_prefers_spatial_shard():
|
||||
return False
|
||||
|
||||
if z is None:
|
||||
return True
|
||||
|
||||
return config.should_use_auto_spatial_shard_parallel_decode(z, world_size)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VAEArchConfig(ArchConfig):
|
||||
scaling_factor: float | torch.Tensor = 0
|
||||
|
||||
temporal_compression_ratio: int = 4
|
||||
# or vae_scale_factor?
|
||||
spatial_compression_ratio: int = 8
|
||||
|
||||
|
||||
@dataclass
|
||||
class VAEConfig(ModelConfig):
|
||||
arch_config: VAEArchConfig = field(default_factory=VAEArchConfig)
|
||||
|
||||
# sglang-diffusion VAE-specific parameters
|
||||
load_encoder: bool = True
|
||||
load_decoder: bool = True
|
||||
|
||||
tile_sample_min_height: int = 256
|
||||
tile_sample_min_width: int = 256
|
||||
tile_sample_min_num_frames: int = 16
|
||||
tile_sample_stride_height: int = 192
|
||||
tile_sample_stride_width: int = 192
|
||||
tile_sample_stride_num_frames: int = 12
|
||||
blend_num_frames: int = 0
|
||||
|
||||
use_tiling: bool = True
|
||||
use_temporal_tiling: bool = True
|
||||
use_parallel_tiling: bool = True
|
||||
use_temporal_scaling_frames: bool = True
|
||||
use_parallel_decode: bool = True
|
||||
parallel_decode_mode: str = AUTO_PARALLEL_DECODE_MODE
|
||||
auto_parallel_decode_min_latent_elements_per_rank: int = 4096
|
||||
|
||||
def __post_init__(self):
|
||||
self.blend_num_frames = (
|
||||
self.tile_sample_min_num_frames - self.tile_sample_stride_num_frames
|
||||
)
|
||||
|
||||
def post_init(self):
|
||||
pass
|
||||
|
||||
def auto_parallel_decode_prefers_spatial_shard(self) -> bool:
|
||||
return False
|
||||
|
||||
def should_use_auto_spatial_shard_parallel_decode(
|
||||
self, z: torch.Tensor, world_size: int
|
||||
) -> bool:
|
||||
return _should_use_auto_spatial_shard_parallel_decode(
|
||||
tuple(z.shape),
|
||||
world_size,
|
||||
self.auto_parallel_decode_min_latent_elements_per_rank,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def add_cli_args(parser: Any, prefix: str = "vae-config") -> Any:
|
||||
"""Add CLI arguments for VAEConfig fields"""
|
||||
parser.add_argument(
|
||||
f"--{prefix}.load-encoder",
|
||||
action=StoreBoolean,
|
||||
dest=f"{prefix.replace('-', '_')}.load_encoder",
|
||||
default=None,
|
||||
help="Whether to load the VAE encoder",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.load-decoder",
|
||||
action=StoreBoolean,
|
||||
dest=f"{prefix.replace('-', '_')}.load_decoder",
|
||||
default=None,
|
||||
help="Whether to load the VAE decoder",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.tile-sample-min-height",
|
||||
type=int,
|
||||
dest=f"{prefix.replace('-', '_')}.tile_sample_min_height",
|
||||
default=None,
|
||||
help="Minimum height for VAE tile sampling",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.tile-sample-min-width",
|
||||
type=int,
|
||||
dest=f"{prefix.replace('-', '_')}.tile_sample_min_width",
|
||||
default=None,
|
||||
help="Minimum width for VAE tile sampling",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.tile-sample-min-num-frames",
|
||||
type=int,
|
||||
dest=f"{prefix.replace('-', '_')}.tile_sample_min_num_frames",
|
||||
default=None,
|
||||
help="Minimum number of frames for VAE tile sampling",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.tile-sample-stride-height",
|
||||
type=int,
|
||||
dest=f"{prefix.replace('-', '_')}.tile_sample_stride_height",
|
||||
default=None,
|
||||
help="Stride height for VAE tile sampling",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.tile-sample-stride-width",
|
||||
type=int,
|
||||
dest=f"{prefix.replace('-', '_')}.tile_sample_stride_width",
|
||||
default=None,
|
||||
help="Stride width for VAE tile sampling",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.tile-sample-stride-num-frames",
|
||||
type=int,
|
||||
dest=f"{prefix.replace('-', '_')}.tile_sample_stride_num_frames",
|
||||
default=None,
|
||||
help="Stride number of frames for VAE tile sampling",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.blend-num-frames",
|
||||
type=int,
|
||||
dest=f"{prefix.replace('-', '_')}.blend_num_frames",
|
||||
default=None,
|
||||
help="Number of frames to blend for VAE tile sampling",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.use-tiling",
|
||||
action=StoreBoolean,
|
||||
dest=f"{prefix.replace('-', '_')}.use_tiling",
|
||||
default=None,
|
||||
help="Whether to use tiling for VAE",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.use-temporal-tiling",
|
||||
action=StoreBoolean,
|
||||
dest=f"{prefix.replace('-', '_')}.use_temporal_tiling",
|
||||
default=None,
|
||||
help="Whether to use temporal tiling for VAE",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.use-parallel-tiling",
|
||||
action=StoreBoolean,
|
||||
dest=f"{prefix.replace('-', '_')}.use_parallel_tiling",
|
||||
default=None,
|
||||
help="Whether to use parallel tiling for VAE",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.use-parallel-decode",
|
||||
action=StoreBoolean,
|
||||
dest=f"{prefix.replace('-', '_')}.use_parallel_decode",
|
||||
default=None,
|
||||
help="Whether to use parallel decode for VAE",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}.parallel-decode-mode",
|
||||
choices=("tiled", "patch", "spatial_shard", "spatial", "auto"),
|
||||
dest=f"{prefix.replace('-', '_')}.parallel_decode_mode",
|
||||
default=None,
|
||||
help="Parallel decode mode for VAE",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
def get_vae_scale_factor(self):
|
||||
return 2 ** (len(self.arch_config.block_out_channels) - 1)
|
||||
|
||||
def encode_sample_mode(self):
|
||||
return "argmax"
|
||||
|
||||
@classmethod
|
||||
def from_cli_args(cls, args: argparse.Namespace) -> "VAEConfig":
|
||||
kwargs = {}
|
||||
for attr in dataclasses.fields(cls):
|
||||
value = getattr(args, attr.name, None)
|
||||
if value is not None:
|
||||
kwargs[attr.name] = value
|
||||
return cls(**kwargs)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user