Merge main and align text encoder TP regression documentation
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
commit
afc4decaca
53 files changed
+10815
-57
No files matched your search
@@ -340,7 +340,7 @@ On supported GPU hosts, wrap a test or soak with the repository monitor:
|
||||
|
||||
```bash
|
||||
bash tests/dfx/stability/scripts/resource_monitor.sh run --backend gpu -- \
|
||||
pytest -s -v tests/dfx/stability/scripts/test_stability_<model>.py -m slow
|
||||
pytest -s -v tests/dfx/stability/scripts/run_stability_<model>.py -m slow
|
||||
```
|
||||
|
||||
Check the target revision's monitor backends. Do not claim CPU/NPU monitoring
|
||||
|
||||
@@ -49,6 +49,7 @@ This extension offers the following nodes based on the output modalities (at **C
|
||||
|
||||
- **Generate Image** for text-to-image and image-to-image tasks
|
||||
- **Generate Video** for text-to-video, first-frame/image-to-video, and reference-conditioned video
|
||||
- **Latent Mask Editing** for MiniMax-H3 latent-mask editing (source media plus video/audio noise masks)
|
||||
- **FastH3 Deployment** for routing text-to-video requests to a MiniMax-H3 server with FastH3 fused at startup
|
||||
- **Multimodality Understanding** for multimodality-to-text and multimodality-to-audio tasks
|
||||
- **TTS** and **TTS Voice Clone** for TTS tasks
|
||||
@@ -67,7 +68,7 @@ Every node carries the vLLM-Omni mark in its title bar and is tinted by what it
|
||||
| Amber | AR / Diffusion / Multi-Stage Sampling Params | Sampling parameters that apply to any model |
|
||||
| Purple | Qwen TTS Params, Wan Video Params, MiniMax-H3 Video Params | Parameters that only one model family accepts |
|
||||
| Red | LoRA, FastH3 Deployment | Which weights the server is expected to have loaded |
|
||||
| Teal | Video References | Reference media |
|
||||
| Teal | Video References, Latent Mask Editing | Reference media |
|
||||
|
||||
Recolouring a node by hand (right click -> Colors) overrides its tint, and the choice is kept.
|
||||
|
||||
@@ -263,6 +264,18 @@ Open the **vLLM-Omni FastH3 Text to Video** template, then:
|
||||
|
||||
The node records which server the workflow targets; it does not start one, nor switch adapters or attention backends on a running server.
|
||||
|
||||
#### Latent-mask editing (MiniMax-H3)
|
||||
|
||||
The [WF-05 template](example_workflows/vLLM-Omni%20MiniMax-H3%20Latent%20Mask%20Editing.json) contains inpainting, object removal, continuation, and extension examples in one graph. See the [workflow guide](docs/wf05-h3-latent-editing.md) for inputs, mask settings, dependencies, and preview limitations.
|
||||
|
||||
Connect a **Latent Mask Editing** node to **Generate Video → latent_edit** to edit a source clip instead of generating from scratch. It uploads the source media and serializes the video/audio noise masks the MiniMax H3 API accepts:
|
||||
|
||||
- `source_video` / `source_audio` — the media to edit.
|
||||
- `video_mask` — a ComfyUI mask image; `0` preserves a region, `1` regenerates it, fractional values blend. A 2D mask `[H, W]` is applied to every frame; a 3D mask `[T, H, W]` is treated as a temporal mask (one slice per frame) for continuation or extension. It is resized to the video latent grid.
|
||||
- `audio_mask` — a scalar in `[0, 1]`; `0` keeps the source audio, `1` regenerates it, fractional values blend.
|
||||
|
||||
A non-trivial mask requires its matching source, and a source without a mask is rejected. This node only forwards inputs to the server; the served model must declare latent-mask editing support (MiniMax-H3).
|
||||
|
||||
### TTS (e.g., Qwen TTS series)
|
||||
|
||||
(Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni TTS**)
|
||||
|
||||
@@ -21,7 +21,9 @@ from .comfyui_vllm_omni.nodes import (
|
||||
VLLMOmniGenerateImage,
|
||||
VLLMOmniGenerateMusic,
|
||||
VLLMOmniGenerateVideo,
|
||||
VLLMOmniLatentMaskEditing,
|
||||
VLLMOmniMiniMaxH3Params,
|
||||
VLLMOmniMiniMaxH3TemporalMask,
|
||||
VLLMOmniQwenTTSParams,
|
||||
VLLMOmniRemoteLoRA,
|
||||
VLLMOmniSamplingParamsList,
|
||||
@@ -34,6 +36,7 @@ from .comfyui_vllm_omni.nodes import (
|
||||
|
||||
# A dictionary that contains all nodes you want to export with their names
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"VLLMOmniMiniMaxH3TemporalMask": VLLMOmniMiniMaxH3TemporalMask,
|
||||
# === Generation ===
|
||||
"VLLMOmniGenerateImage": VLLMOmniGenerateImage,
|
||||
"VLLMOmniGenerateVideo": VLLMOmniGenerateVideo,
|
||||
@@ -42,6 +45,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"VLLMOmniGenerateMusic": VLLMOmniGenerateMusic,
|
||||
"VLLMOmniVoiceClone": VLLMOmniVoiceClone,
|
||||
"VLLMOmniVideoReferences": VLLMOmniVideoReferences,
|
||||
"VLLMOmniLatentMaskEditing": VLLMOmniLatentMaskEditing,
|
||||
# === Params ===
|
||||
"VLLMOmniARSampling": VLLMOmniARSampling,
|
||||
"VLLMOmniDiffusionSampling": VLLMOmniDiffusionSampling,
|
||||
@@ -55,6 +59,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
|
||||
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"VLLMOmniMiniMaxH3TemporalMask": "MiniMax-H3 Temporal Mask",
|
||||
# === Generation ===
|
||||
"VLLMOmniGenerateImage": "Generate Image",
|
||||
"VLLMOmniGenerateVideo": "Generate Video",
|
||||
@@ -63,6 +68,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"VLLMOmniGenerateMusic": "Generate Music",
|
||||
"VLLMOmniVoiceClone": "TTS Voice Cloning",
|
||||
"VLLMOmniVideoReferences": "Video References",
|
||||
"VLLMOmniLatentMaskEditing": "Latent Mask Editing",
|
||||
# === Params ===
|
||||
"VLLMOmniARSampling": "AR Sampling Params",
|
||||
"VLLMOmniDiffusionSampling": "Diffusion Sampling Params",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
import torch
|
||||
from comfy_api.input import AudioInput, VideoInput
|
||||
|
||||
from .utils.api_client import VLLMOmniClient
|
||||
from .utils.latent_mask import _align_frame_count, _video_latent_t
|
||||
from .utils.logger import get_logger
|
||||
from .utils.models import lookup_model_spec
|
||||
from .utils.types import (
|
||||
@@ -17,6 +19,7 @@ from .utils.types import (
|
||||
AutoregressionSamplingParams,
|
||||
DiffusionSamplingParams,
|
||||
FastH3Deployment,
|
||||
LatentMaskEditing,
|
||||
MiniMaxH3ModelSpecificParams,
|
||||
QwenTTSModelSpecificParams,
|
||||
VideoReferences,
|
||||
@@ -210,6 +213,7 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase):
|
||||
"lora": ("REMOTE_LORA",),
|
||||
"model_params": ("VIDEO_PARAMS",),
|
||||
"fast_h3": ("FASTH3_DEPLOYMENT",),
|
||||
"latent_edit": ("LATENT_MASK_EDITING",),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -262,6 +266,7 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase):
|
||||
model_params: dict | None = None,
|
||||
lora: dict | None = None,
|
||||
fast_h3: dict | None = None,
|
||||
latent_edit: dict | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
if kwargs:
|
||||
@@ -350,6 +355,7 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase):
|
||||
sampling_params=sampling_params,
|
||||
lora=lora,
|
||||
model_params=model_params,
|
||||
latent_edit=latent_edit,
|
||||
)
|
||||
return (output,)
|
||||
|
||||
@@ -1051,3 +1057,89 @@ class VLLMOmniVideoReferences:
|
||||
if value is not None:
|
||||
refs[f"{kind}_{index}"] = value
|
||||
return (refs,)
|
||||
|
||||
|
||||
class VLLMOmniLatentMaskEditing:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {
|
||||
"source_video": ("VIDEO",),
|
||||
"source_audio": ("AUDIO",),
|
||||
"video_mask": ("MASK",),
|
||||
"audio_mask": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT_MASK_EDITING",)
|
||||
RETURN_NAMES = ("latent_edit",)
|
||||
FUNCTION = "get_latent_edit"
|
||||
CATEGORY = "vLLM-Omni"
|
||||
|
||||
def get_latent_edit(
|
||||
self,
|
||||
source_video: VideoInput | None = None,
|
||||
source_audio: AudioInput | None = None,
|
||||
video_mask: torch.Tensor | None = None,
|
||||
audio_mask: float = -1.0,
|
||||
**kwargs,
|
||||
):
|
||||
if kwargs:
|
||||
logger.info("Uncaught kwargs: %s", kwargs)
|
||||
edit = LatentMaskEditing()
|
||||
if source_video is not None:
|
||||
edit["source_video"] = source_video
|
||||
if source_audio is not None:
|
||||
edit["source_audio"] = source_audio
|
||||
if video_mask is not None:
|
||||
edit["video_mask"] = video_mask
|
||||
if audio_mask >= 0.0:
|
||||
edit["audio_mask"] = audio_mask
|
||||
return (edit,)
|
||||
|
||||
|
||||
class VLLMOmniMiniMaxH3TemporalMask:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"source_fps": ("FLOAT", {"default": 24.0, "min": 0.01}),
|
||||
"duration": ("FLOAT", {"default": 5.0, "min": 0.01, "max": 15.0}),
|
||||
"mode": (["continuation", "extension"],),
|
||||
"preserve_fraction": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MASK", "FLOAT", "IMAGE", "MASK")
|
||||
RETURN_NAMES = ("mask", "preview_fps", "preview_images", "preview_mask")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "vLLM-Omni"
|
||||
|
||||
def build(self, images, source_fps, duration, mode, preserve_fraction=0.5):
|
||||
if images.shape[0] <= 0 or not math.isfinite(source_fps) or source_fps <= 0:
|
||||
raise ValueError("Source must contain frames and have a positive finite FPS.")
|
||||
if not math.isfinite(duration) or duration <= 0 or not 0 <= preserve_fraction <= 1:
|
||||
raise ValueError("Invalid duration or preserve_fraction.")
|
||||
frames = _align_frame_count(max(1, round(duration * 24)))
|
||||
source_seconds = images.shape[0] / source_fps
|
||||
if mode == "extension":
|
||||
if frames / 24 <= source_seconds:
|
||||
raise ValueError("Extension output must be longer than the source; increase duration.")
|
||||
boundary = source_seconds
|
||||
elif mode == "continuation":
|
||||
boundary = min(source_seconds, frames / 24) * preserve_fraction
|
||||
else:
|
||||
raise ValueError(f"Unknown temporal mask mode: {mode}")
|
||||
available = min(frames, math.floor(boundary * 24 + 1e-8))
|
||||
prefix = 0 if available < 5 else 5 + 17 * ((available - 5) // 17)
|
||||
preserved = _video_latent_t(prefix) if prefix else 0
|
||||
total = _video_latent_t(frames)
|
||||
mask = torch.ones(total, 1, 1)
|
||||
mask[:preserved] = 0
|
||||
indices = torch.arange(frames, device=images.device)
|
||||
source_indices = (indices * (source_fps / 24)).floor().long().clamp(max=images.shape[0] - 1)
|
||||
preview_images = images.index_select(0, source_indices)
|
||||
preview_mask = mask.index_select(0, torch.arange(frames) * total // frames)
|
||||
return mask, 24.0, preview_images, preview_mask
|
||||
@@ -30,6 +30,7 @@ from .format import (
|
||||
video_to_base64,
|
||||
video_to_bytes,
|
||||
)
|
||||
from .latent_mask import scalar_mask_to_json, video_mask_to_grid_json
|
||||
from .logger import get_logger, pretty_printer
|
||||
from .models import lookup_model_spec
|
||||
from .types import (
|
||||
@@ -283,6 +284,7 @@ class VLLMOmniClient:
|
||||
sampling_params: dict | None = None,
|
||||
model_params: dict | None = None,
|
||||
lora: dict | None = None,
|
||||
latent_edit: dict | None = None,
|
||||
spec_model: str | None = None,
|
||||
**extra_params,
|
||||
) -> VideoInput:
|
||||
@@ -386,6 +388,53 @@ class VLLMOmniClient:
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
# === latent-mask editing (MiniMax H3) ===
|
||||
if latent_edit is not None:
|
||||
source_video = latent_edit.get("source_video")
|
||||
source_audio = latent_edit.get("source_audio")
|
||||
video_mask = latent_edit.get("video_mask")
|
||||
audio_mask = latent_edit.get("audio_mask")
|
||||
|
||||
if video_mask is None and audio_mask is None:
|
||||
raise ValueError("Latent-mask editing requires at least one mask.")
|
||||
|
||||
video_mask_trivial = video_mask is None or bool((video_mask == 1.0).all().item())
|
||||
audio_mask_trivial = audio_mask is None or audio_mask == 1.0
|
||||
if not video_mask_trivial and source_video is None:
|
||||
raise ValueError("A non-trivial video mask requires a source video.")
|
||||
if not audio_mask_trivial and source_audio is None and source_video is None:
|
||||
raise ValueError("A non-trivial audio mask requires a source audio or a source video with audio.")
|
||||
|
||||
if source_video is not None:
|
||||
form.add_field(
|
||||
"source_video",
|
||||
video_to_bytes(source_video, "source.mp4"),
|
||||
filename="source.mp4",
|
||||
content_type="video/mp4",
|
||||
)
|
||||
if source_audio is not None:
|
||||
form.add_field(
|
||||
"source_audio",
|
||||
audio_to_bytes(source_audio, "source_audio.mp3"),
|
||||
filename="source_audio.mp3",
|
||||
content_type="audio/mpeg",
|
||||
)
|
||||
if video_mask is not None:
|
||||
mask_json = video_mask_to_grid_json(video_mask, width=width, height=height, num_frames=num_frames)
|
||||
form.add_field(
|
||||
"video_noise_mask",
|
||||
mask_json.encode("utf-8"),
|
||||
filename="video-mask.json",
|
||||
content_type="application/json",
|
||||
)
|
||||
if audio_mask is not None:
|
||||
form.add_field(
|
||||
"audio_noise_mask",
|
||||
scalar_mask_to_json(audio_mask).encode("utf-8"),
|
||||
filename="audio-mask.json",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
if len(keyframe_images) == 2:
|
||||
for image_filename, image in keyframe_images:
|
||||
form.add_field(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import json
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
def scalar_mask_to_json(value: float) -> str:
|
||||
if not 0.0 <= value <= 1.0:
|
||||
raise ValueError(f"mask value must be in [0, 1], got {value}")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _align_frame_count(frame_count: int) -> int:
|
||||
if frame_count <= 0:
|
||||
return 1
|
||||
current = int(frame_count)
|
||||
while current % 17 != 5:
|
||||
current += 1
|
||||
return current
|
||||
|
||||
|
||||
def _video_latent_t(frame_count: int) -> int:
|
||||
if frame_count <= 5:
|
||||
return 2
|
||||
return ((int(frame_count) - 5) // 17) * 5 + 2
|
||||
|
||||
|
||||
def video_mask_to_grid(mask: torch.Tensor, *, width: int, height: int, num_frames: int) -> torch.Tensor:
|
||||
tv = _video_latent_t(_align_frame_count(num_frames))
|
||||
height = int(height) // 32 * 32
|
||||
width = int(width) // 32 * 32
|
||||
gh, gw = height // 16, width // 16
|
||||
|
||||
if mask.ndim == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
elif mask.ndim != 3:
|
||||
raise ValueError(f"expected a 2D or 3D mask tensor, got {mask.ndim}D")
|
||||
|
||||
grid = F.interpolate(mask.unsqueeze(1).float(), size=(gh, gw), mode="area").squeeze(1)
|
||||
if grid.shape[0] == 1:
|
||||
grid = grid.expand(tv, gh, gw)
|
||||
elif grid.shape[0] != tv:
|
||||
grid = F.interpolate(grid.unsqueeze(0).unsqueeze(0), size=(tv, gh, gw), mode="nearest").squeeze(0).squeeze(0)
|
||||
return grid
|
||||
|
||||
|
||||
def video_mask_to_grid_json(mask: torch.Tensor, *, width: int, height: int, num_frames: int) -> str:
|
||||
return json.dumps(video_mask_to_grid(mask, width=width, height=height, num_frames=num_frames).tolist())
|
||||
@@ -40,6 +40,10 @@ class VideoReferences(dict):
|
||||
pass
|
||||
|
||||
|
||||
class LatentMaskEditing(dict):
|
||||
pass
|
||||
|
||||
|
||||
class FastH3Deployment(dict):
|
||||
"""Descriptor for a server that fused FastH3 at startup."""
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# WF-05: MiniMax-H3 latent-mask editing
|
||||
|
||||
Edit a source video with MiniMax-H3 using spatial or temporal masks. The workflow includes object removal, inpainting, continuation, and extension, with a mask preview for each case.
|
||||
|
||||
## Requirements
|
||||
|
||||
- ComfyUI with ComfyUI-vLLM-Omni installed.
|
||||
- An H3 service using the FL2VA checkpoint partition with latent-mask editing support. Follow the [H3 recipe](../../../recipes/MiniMaxAI/MiniMax-H3.md) for server setup.
|
||||
|
||||
## Use
|
||||
|
||||
Open **vLLM-Omni MiniMax-H3 Latent Mask Editing.json** from the example workflows and set the Generate Video URL/model for the case you want to run.
|
||||
|
||||
1. Upload your source video in Load Video.
|
||||
2. Adjust the case's mask. For removal and inpainting, set Mask Canvas to the video dimensions, Mask Rectangle to the region size, and Combine Masks x/y to its position. For continuation and extension, set MiniMax-H3 Temporal Mask's mode and duration; continuation also uses `preserve_fraction`. Match the duration in Generate Video, and use a duration longer than the source for extension. Mask values are `0` to preserve and `1` to regenerate.
|
||||
3. Adjust the prompt to describe the desired content. Set the output dimensions and duration in Generate Video. If editing audio, update the sound description and audio mask (`0` preserves, `1` regenerates the whole clip).
|
||||
4. Select the case's Mask Preview Save Video node and choose **Execute to selected output nodes** to preview the mask as a red overlay without inference, or select its Result Save Video node to generate the edited video. Running the entire workflow generates all four cases.
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
{
|
||||
"id": "latent-mask-editing-7380",
|
||||
"revision": 0,
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
40,
|
||||
20
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
210
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "Latent-mask editing server prerequisite",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Requires a vLLM-Omni server that declares latent-mask editing support (MiniMax-H3, EDIT-01). The source clip and video/audio noise masks are uploaded to /v1/videos; 0 preserves, 1 regenerates, fractional blends."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#000"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadVideo",
|
||||
"pos": [
|
||||
40,
|
||||
280
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
120
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "file",
|
||||
"name": "file",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "file"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "video",
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoadImageMask",
|
||||
"pos": [
|
||||
40,
|
||||
460
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "channel",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "channel"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "mask",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImageMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"red"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "VLLMOmniLatentMaskEditing",
|
||||
"pos": [
|
||||
560,
|
||||
260
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
300
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "source_video",
|
||||
"name": "source_video",
|
||||
"type": "VIDEO",
|
||||
"link": 1,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "source_audio",
|
||||
"name": "source_audio",
|
||||
"type": "AUDIO",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "video_mask",
|
||||
"name": "video_mask",
|
||||
"type": "MASK",
|
||||
"link": 2,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "audio_mask",
|
||||
"name": "audio_mask",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "audio_mask"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "latent_edit",
|
||||
"name": "latent_edit",
|
||||
"type": "LATENT_MASK_EDITING",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniLatentMaskEditing"
|
||||
},
|
||||
"widgets_values": [
|
||||
0.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "VLLMOmniMiniMaxH3Params",
|
||||
"pos": [
|
||||
40,
|
||||
680
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
140
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "audio_flow_shift",
|
||||
"name": "audio_flow_shift",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "audio_flow_shift"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "flow_shift",
|
||||
"name": "flow_shift",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "flow_shift"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "video params",
|
||||
"name": "video params",
|
||||
"type": "VIDEO_PARAMS",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniMiniMaxH3Params"
|
||||
},
|
||||
"widgets_values": [
|
||||
3.0,
|
||||
12.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VLLMOmniGenerateVideo",
|
||||
"pos": [
|
||||
1080,
|
||||
200
|
||||
],
|
||||
"size": [
|
||||
540,
|
||||
680
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "frame",
|
||||
"name": "frame",
|
||||
"type": "IMAGE",
|
||||
"link": null,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "references",
|
||||
"name": "references",
|
||||
"type": "VIDEO_REFERENCES",
|
||||
"link": null,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "sampling_params",
|
||||
"name": "sampling_params",
|
||||
"type": "SAMPLING_PARAMS",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "lora",
|
||||
"name": "lora",
|
||||
"type": "REMOTE_LORA",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "model_params",
|
||||
"name": "model_params",
|
||||
"type": "VIDEO_PARAMS",
|
||||
"link": 4,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "fast_h3",
|
||||
"name": "fast_h3",
|
||||
"type": "FASTH3_DEPLOYMENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_edit",
|
||||
"name": "latent_edit",
|
||||
"type": "LATENT_MASK_EDITING",
|
||||
"link": 3,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "url",
|
||||
"name": "url",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "url"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "prompt",
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "negative_prompt",
|
||||
"name": "negative_prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "negative_prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "width",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "height",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "fps",
|
||||
"name": "fps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "fps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "duration",
|
||||
"name": "duration",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "duration"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "video",
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniGenerateVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"http://127.0.0.1:8000/v1",
|
||||
"MiniMaxAI/MiniMax-H3",
|
||||
"Partially restyle the clip and soundtrack",
|
||||
"",
|
||||
160,
|
||||
120,
|
||||
24,
|
||||
4.458
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveVideo",
|
||||
"pos": [
|
||||
1700,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "video",
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"link": 5,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "filename_prefix",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "format",
|
||||
"name": "format",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": {
|
||||
"name": "format"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "video",
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"vllm-omni/latent-edit",
|
||||
"mp4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"VIDEO"
|
||||
],
|
||||
[
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
6,
|
||||
6,
|
||||
"LATENT_MASK_EDITING"
|
||||
],
|
||||
[
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
4,
|
||||
"VIDEO_PARAMS"
|
||||
],
|
||||
[
|
||||
5,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"VIDEO"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
+560
@@ -0,0 +1,560 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 7,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadVideo",
|
||||
"pos": [
|
||||
40,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
120
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "file",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "file"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SolidMask",
|
||||
"pos": [
|
||||
40,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SolidMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
0.0,
|
||||
160,
|
||||
120
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "SolidMask",
|
||||
"pos": [
|
||||
40,
|
||||
500
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SolidMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
1.0,
|
||||
160,
|
||||
120
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "BatchMasksNode",
|
||||
"pos": [
|
||||
250,
|
||||
380
|
||||
],
|
||||
"size": [
|
||||
200,
|
||||
100
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "masks.mask0",
|
||||
"type": "MASK",
|
||||
"link": 2,
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "masks.mask1",
|
||||
"type": "MASK",
|
||||
"link": 3,
|
||||
"slot_index": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "BatchMasksNode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VLLMOmniLatentMaskEditing",
|
||||
"pos": [
|
||||
450,
|
||||
180
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
120
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source_video",
|
||||
"type": "VIDEO",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "source_audio",
|
||||
"type": "AUDIO",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "video_mask",
|
||||
"type": "MASK",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "audio_mask",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "audio_mask"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "latent_edit",
|
||||
"type": "LATENT_MASK_EDITING",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniLatentMaskEditing"
|
||||
},
|
||||
"widgets_values": [
|
||||
-1.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VLLMOmniMiniMaxH3Params",
|
||||
"pos": [
|
||||
40,
|
||||
700
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "audio_flow_shift",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "audio_flow_shift"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "flow_shift",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "flow_shift"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "video params",
|
||||
"type": "VIDEO_PARAMS",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniMiniMaxH3Params"
|
||||
},
|
||||
"widgets_values": [
|
||||
3.0,
|
||||
12.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VLLMOmniGenerateVideo",
|
||||
"pos": [
|
||||
700,
|
||||
180
|
||||
],
|
||||
"size": [
|
||||
450,
|
||||
380
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "frame",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "references",
|
||||
"type": "VIDEO_REFERENCES",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "sampling_params",
|
||||
"type": "SAMPLING_PARAMS",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "lora",
|
||||
"type": "LORA",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model_params",
|
||||
"type": "VIDEO_PARAMS",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "fast_h3",
|
||||
"type": "FAST_H3",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_edit",
|
||||
"type": "LATENT_MASK_EDITING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "url"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative_prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "negative_prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "fps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "fps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "duration"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniGenerateVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"http://127.0.0.1:8000/v1",
|
||||
"MiniMaxAI/MiniMax-H3",
|
||||
"temporal mask: regenerate the ending",
|
||||
"",
|
||||
160,
|
||||
120,
|
||||
24,
|
||||
4.458
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveVideo",
|
||||
"pos": [
|
||||
900,
|
||||
180
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "format"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"temporal_mask",
|
||||
"mp4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"VIDEO"
|
||||
],
|
||||
[
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
2,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
5,
|
||||
6,
|
||||
0,
|
||||
8,
|
||||
6,
|
||||
"LATENT_MASK_EDITING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
4,
|
||||
"VIDEO_PARAMS"
|
||||
],
|
||||
[
|
||||
7,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"VIDEO"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
{
|
||||
"id": "latent-mask-editing-7380",
|
||||
"revision": 0,
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
40,
|
||||
20
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
210
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "Latent-mask editing server prerequisite",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Requires a vLLM-Omni server that declares latent-mask editing support (MiniMax-H3, EDIT-01). The source clip and video/audio noise masks are uploaded to /v1/videos; 0 preserves, 1 regenerates, fractional blends."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#000"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadVideo",
|
||||
"pos": [
|
||||
40,
|
||||
280
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
120
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "file",
|
||||
"name": "file",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "file"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "video",
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SolidMask",
|
||||
"pos": [
|
||||
40,
|
||||
460
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "width",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "height",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "mask",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SolidMask"
|
||||
},
|
||||
"widgets_values": [
|
||||
1.0,
|
||||
160,
|
||||
120
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "VLLMOmniLatentMaskEditing",
|
||||
"pos": [
|
||||
560,
|
||||
260
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
300
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "source_video",
|
||||
"name": "source_video",
|
||||
"type": "VIDEO",
|
||||
"link": 1,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "source_audio",
|
||||
"name": "source_audio",
|
||||
"type": "AUDIO",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "video_mask",
|
||||
"name": "video_mask",
|
||||
"type": "MASK",
|
||||
"link": 2,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "audio_mask",
|
||||
"name": "audio_mask",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "audio_mask"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "latent_edit",
|
||||
"name": "latent_edit",
|
||||
"type": "LATENT_MASK_EDITING",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniLatentMaskEditing"
|
||||
},
|
||||
"widgets_values": [
|
||||
0.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "VLLMOmniMiniMaxH3Params",
|
||||
"pos": [
|
||||
40,
|
||||
680
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
140
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "audio_flow_shift",
|
||||
"name": "audio_flow_shift",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "audio_flow_shift"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "flow_shift",
|
||||
"name": "flow_shift",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "flow_shift"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "video params",
|
||||
"name": "video params",
|
||||
"type": "VIDEO_PARAMS",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniMiniMaxH3Params"
|
||||
},
|
||||
"widgets_values": [
|
||||
3.0,
|
||||
12.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VLLMOmniGenerateVideo",
|
||||
"pos": [
|
||||
1080,
|
||||
200
|
||||
],
|
||||
"size": [
|
||||
540,
|
||||
680
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "frame",
|
||||
"name": "frame",
|
||||
"type": "IMAGE",
|
||||
"link": null,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "references",
|
||||
"name": "references",
|
||||
"type": "VIDEO_REFERENCES",
|
||||
"link": null,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "sampling_params",
|
||||
"name": "sampling_params",
|
||||
"type": "SAMPLING_PARAMS",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "lora",
|
||||
"name": "lora",
|
||||
"type": "REMOTE_LORA",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "model_params",
|
||||
"name": "model_params",
|
||||
"type": "VIDEO_PARAMS",
|
||||
"link": 4,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "fast_h3",
|
||||
"name": "fast_h3",
|
||||
"type": "FASTH3_DEPLOYMENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_edit",
|
||||
"name": "latent_edit",
|
||||
"type": "LATENT_MASK_EDITING",
|
||||
"link": 3,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "url",
|
||||
"name": "url",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "url"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "model"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "prompt",
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "negative_prompt",
|
||||
"name": "negative_prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "negative_prompt"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "width",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "height",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "fps",
|
||||
"name": "fps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "fps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "duration",
|
||||
"name": "duration",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "duration"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "video",
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VLLMOmniGenerateVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"http://127.0.0.1:8000/v1",
|
||||
"MiniMaxAI/MiniMax-H3",
|
||||
"Partially restyle the clip and soundtrack",
|
||||
"",
|
||||
160,
|
||||
120,
|
||||
24,
|
||||
4.458
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveVideo",
|
||||
"pos": [
|
||||
1700,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
420,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "video",
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"link": 5,
|
||||
"shape": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "filename_prefix",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "format",
|
||||
"name": "format",
|
||||
"type": "COMFY_DYNAMICCOMBO_V3",
|
||||
"widget": {
|
||||
"name": "format"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "video",
|
||||
"name": "video",
|
||||
"type": "VIDEO",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SaveVideo"
|
||||
},
|
||||
"widgets_values": [
|
||||
"vllm-omni/latent-edit",
|
||||
"mp4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"VIDEO"
|
||||
],
|
||||
[
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
6,
|
||||
6,
|
||||
"LATENT_MASK_EDITING"
|
||||
],
|
||||
[
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
4,
|
||||
"VIDEO_PARAMS"
|
||||
],
|
||||
[
|
||||
5,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"VIDEO"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
+5241
File diff suppressed because it is too large.
Load diff
@@ -52,6 +52,7 @@ const FAMILY_BY_OUTPUT = {
|
||||
REMOTE_LORA: "deployment",
|
||||
FASTH3_DEPLOYMENT: "deployment",
|
||||
VIDEO_REFERENCES: "media",
|
||||
LATENT_MASK_EDITING: "media",
|
||||
};
|
||||
|
||||
function familyOf(nodeData) {
|
||||
|
||||
@@ -41,6 +41,17 @@ LingBot-Video MoE transformer.
|
||||
- **Dense pipeline**: decoded-video MAE, MSE, PSNR, latency, and optional steady-state timings
|
||||
- **MoE transformer**: bitwise router, sparse-block, shared-expert, and full-transformer parity
|
||||
|
||||
### [LingBot-World](lingbot_world/README.md) — Realtime Streaming Cadence
|
||||
|
||||
Client-side benchmark for a `WS /v1/realtime/video` world-model session, where one
|
||||
request is a whole rollout and one AR block streams out as one video chunk. It
|
||||
measures a cadence rather than a throughput: there is exactly one session, and what
|
||||
matters is whether the next chunk arrives before the viewer finishes the last one.
|
||||
|
||||
- **Layout**: `lingbot_world/benchmark_lingbot_world_realtime.py` (WS client), `lingbot_world/workload.py` (rollout and metric math), `lingbot_world/configs/` (single-GPU eager and Ulysses-4 compiled deploy configs)
|
||||
- **Workload**: built-in image-conditioned rollout with a generated camera script, or a JSON spec with per-chunk actions and mid-rollout prompt updates
|
||||
- **Key metrics**: TTFC (time to first chunk), steady-state chunk inter-arrival percentiles, VIDEO_RTF (wall seconds per video second; lower is better), chunk-deadline attainment, and simulated playback underruns
|
||||
|
||||
### [Distributed](distributed/omni_connectors/README.md) — RDMA Connector Testing
|
||||
|
||||
RDMA environment setup and transfer tests for `MooncakeTransferEngineConnector`, including pytest-based single-node checks and manual cross-node benchmarks.
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# LingBot-World Benchmarks — Realtime Streaming Cadence
|
||||
|
||||
`benchmark_lingbot_world_realtime.py` measures LingBot-World 2.0 as it is
|
||||
actually deployed: one `WS /v1/realtime/video` session that streams one video
|
||||
chunk per AR block, for as long as the world keeps running.
|
||||
|
||||
## Why this is not the diffusion serving benchmark
|
||||
|
||||
[`benchmarks/diffusion`](../diffusion/README.md) measures "one request, one
|
||||
finished video" and reports request throughput. LingBot-World has neither shape:
|
||||
|
||||
- **One request is the whole rollout.** `prepare_encode` runs once, then every AR
|
||||
block is four DMD steps plus one clean-KV commit forward, and each block
|
||||
streams out as a chunk. Throughput in requests per second is meaningless.
|
||||
- **There is exactly one session.** The stepwise deploy config pins
|
||||
`max_num_seqs: 1`, and the AR-Diffusion consumer rejects a stage with more than
|
||||
one replica, because session-affine routing does not exist yet. There is no
|
||||
concurrency to sweep.
|
||||
- **What matters is cadence.** A deployment is healthy when chunk *N + 1* arrives
|
||||
before a viewer finishes watching chunk *N*. That is a latency distribution
|
||||
against a wall clock, not a throughput number.
|
||||
|
||||
## Quick start
|
||||
|
||||
Serve the model, then point the benchmark at it. The benchmark never starts a
|
||||
server.
|
||||
|
||||
```bash
|
||||
# Terminal 1 — one GPU, eager (portable smoke configuration)
|
||||
vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \
|
||||
--omni \
|
||||
--deploy-config benchmarks/lingbot_world/configs/single_gpu_eager.yaml \
|
||||
--port 8000
|
||||
|
||||
# Terminal 2
|
||||
pip install websockets
|
||||
python benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py \
|
||||
--host 127.0.0.1 --port 8000 \
|
||||
--num-chunks 16 \
|
||||
--output-json /tmp/lingbot_world_realtime.json
|
||||
```
|
||||
|
||||
With no `--image`, the first frame comes from the shared vLLM image asset, so the
|
||||
default workload needs no setup and this repository carries no benchmark binary.
|
||||
Pass `--image /path/to/frame.png` to condition the rollout on your own frame.
|
||||
|
||||
### Realtime topology
|
||||
|
||||
The single-GPU config is a smoke configuration, not a realtime one: one card runs
|
||||
all five forwards of every block serially and nothing is compiled. Use the
|
||||
four-GPU config for numbers that mean anything about realtime behavior.
|
||||
|
||||
```bash
|
||||
vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \
|
||||
--omni \
|
||||
--deploy-config benchmarks/lingbot_world/configs/usp4_compiled.yaml \
|
||||
--port 8000
|
||||
|
||||
python benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py \
|
||||
--port 8000 --num-chunks 40 --warmup-sessions 1 --sessions 3 \
|
||||
--target-fps 12 --output-json /tmp/lingbot_world_repeated.json
|
||||
```
|
||||
|
||||
`usp4_compiled.yaml` sets pure Ulysses sequence parallelism over four ranks and
|
||||
`enforce_eager: false`. The pipeline validates the parallel shape itself:
|
||||
`sequence_parallel_size` must equal `ulysses_degree`, `ring_degree` and
|
||||
`allgather_degree` must be 1, `ulysses_mode` must be `strict`, and pipeline,
|
||||
CFG, and VAE parallel sizes above 1 are rejected. This benchmark config sets
|
||||
`tensor_parallel_size: 1`; the pipeline validator does not reject larger TP sizes.
|
||||
|
||||
Compiled mode can pay compilation and capture costs on the first rollout.
|
||||
Use `--warmup-sessions 1` before measured repetitions. Excluding the first six
|
||||
chunks addresses the attention-window ramp; it does not guarantee that
|
||||
compilation and capture have finished.
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Meaning |
|
||||
| --- | --- |
|
||||
| `ttfc_ms` | **Time to first chunk**: `session.start` sent until the first chunk's bytes arrive. Pays for prompt encode, first-frame VAE encode, and any compile or capture. |
|
||||
| `interval_all` | Inter-arrival latency across every chunk: mean, median, std, min, p90, p95, p99, max. |
|
||||
| `interval_steady` | The same summary over steady-state chunks only. |
|
||||
| `video_rtf` | Wall seconds spent per second of video, matching vLLM-Omni's own RTF (`generation_s / video_duration`). **Lower is better; below 1.0 is real time.** |
|
||||
| `video_rtfx` | The reciprocal, under the name this repo uses for it: video seconds per wall second, where higher is better. |
|
||||
| `steady_video_rtf` | `video_rtf` restricted to steady-state chunks. |
|
||||
| `chunk_deadline_ms` | Wall-clock budget one steady chunk must meet to hold real time: 12 frames ÷ `--target-fps`. Override directly with `--chunk-slo-ms`. |
|
||||
| `slo_attainment` | Fraction of steady chunks that met the deadline. |
|
||||
| `playback` | Underrun count, total stall, and stall ratio from a simulated viewer. |
|
||||
|
||||
### Which frame rate counts as real time
|
||||
|
||||
**The checkpoint declares no frame rate.** There is none in `model_index.json`, the
|
||||
scheduler config, or the transformer config, and the pipeline never reads one. A
|
||||
chunk is twelve pixel frames; how long those frames *last* is a property of how the
|
||||
output is played, not of the model.
|
||||
|
||||
That choice moves the verdict without moving anything measured:
|
||||
|
||||
| basis | video per chunk | deadline | RTF at a measured 1357 ms/chunk |
|
||||
| --- | --- | --- | --- |
|
||||
| 16 fps | 0.750 s | 750 ms | 1.809 |
|
||||
| 12 fps | 1.000 s | 1000 ms | 1.357 |
|
||||
|
||||
So `--fps` (sent to the server, which uses it to label the muxed MP4) and
|
||||
`--target-fps` (the real-time basis for RTF, the deadline, and the playback
|
||||
simulation) are separate flags. `--target-fps` defaults to `--fps`; set it when the
|
||||
two differ, and the report prints both. State the basis whenever you quote a number
|
||||
— an RTF without its fps is not a measurement. (RTF is a cost here, so a bigger number is a
|
||||
worse result; a steady chunk's RTF is simply its interval divided by its video duration.)
|
||||
An RTF above 1 means the measured deployment is slower than real time at the
|
||||
stated target frame rate. The benchmark measures this failure as well as success.
|
||||
|
||||
### Why warmup chunks are not optional
|
||||
|
||||
The DiT attends over a sliding window of `sliding_window_num_frames = 18` latent
|
||||
frames with `sink_size = 9`, so the key count a block attends over keeps growing
|
||||
until eighteen latent frames of history exist — six chunks at three frames per
|
||||
block. **Chunk cost is not stationary before chunk six.** A mean over a short
|
||||
rollout is therefore lower than anything a running session will sustain, which is
|
||||
why `--warmup-chunks` defaults to 6 and steady-state metrics are reported
|
||||
separately. Keep `--num-chunks` comfortably above the warmup, or the benchmark
|
||||
tells you no chunk reached steady state. The terminal chunk skips next-chunk
|
||||
preparation, so it is excluded from steady metrics too; it remains in overall
|
||||
RTF, all-chunk intervals, and playback simulation.
|
||||
|
||||
The default 16-chunk rollout provides only nine steady intervals. Percentiles
|
||||
are interpolated sample summaries; the report warns when fewer than 100
|
||||
intervals contribute, and even 100 samples do not guarantee a stable p99.
|
||||
Use longer rollouts for tail analysis and at least `--sessions 3` after
|
||||
`--warmup-sessions 1` for reported comparisons. Session warmup pays compilation
|
||||
cost; chunk warmup separately excludes the attention-window ramp. The command
|
||||
above yields 33 steady intervals per session (indices 6 through 38), or 99 across
|
||||
three sessions. Report each session's mean and the spread across sessions; this
|
||||
sample still does not establish a reliable p99.
|
||||
|
||||
### Why playback is simulated
|
||||
|
||||
A p99 interval alone cannot say whether a viewer saw a stall: the same p99 is
|
||||
invisible behind a three-chunk buffer and a visible freeze behind none. The
|
||||
benchmark replays chunk arrivals against a wall clock — playback starts once
|
||||
`--playback-buffer-chunks` chunks have landed and then consumes video at `--target-fps`
|
||||
— and reports every moment the player ran dry.
|
||||
|
||||
### What the client-side number is worth
|
||||
|
||||
On a four-GPU Ulysses run the client's mean inter-arrival over all fifteen
|
||||
post-first-chunk intervals was **1326.743 ms**, against the server's own
|
||||
`StageRequestStats.inter_output_latency_ms` of **1327.479 ms** for the same
|
||||
request — a difference of **0.74 ms**. This cross-check shows agreement in
|
||||
average output cadence for that run, including warmup and the terminal chunk.
|
||||
It does not measure encoding or transport latency: a fixed delivery delay can
|
||||
cancel out of inter-arrival differences. Attributing that latency requires
|
||||
per-chunk timestamps at both boundaries or server-side profiling.
|
||||
|
||||
Compare matching interval populations when investigating discrepancies; both
|
||||
measurement bugs and delivery buffering can change the observed cadence.
|
||||
|
||||
### What this benchmark cannot see
|
||||
|
||||
Chunk metadata carries no server-side timestamps, so the benchmark cannot split
|
||||
DiT time from VAE decode time; use `--enable-diffusion-pipeline-profiler` on the
|
||||
server for that. It also reports no GPU memory, which is a server-side quantity.
|
||||
|
||||
## Custom rollouts
|
||||
|
||||
`--workload rollout.json` replaces the built-in workload, including mid-rollout
|
||||
prompt changes, which the CLI flags cannot express:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "The camera moves slowly forward through the scene.",
|
||||
"image": "/path/to/first_frame.png",
|
||||
"num_chunks": 24,
|
||||
"camera_pattern": "orbit",
|
||||
"fps": 16,
|
||||
"seed": 42,
|
||||
"prompt_updates": [
|
||||
{"after_chunk": 8, "prompt": "Rain begins to fall", "transition_chunks": 2}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Supply `camera_action_script` directly instead of `num_chunks` to control every
|
||||
frame's action. It must hold exactly one three-entry action list per chunk; one
|
||||
AR block is three latent frames, and the server rejects any other shape.
|
||||
Explicit `--image`, `--prompt`, `--negative-prompt`, `--width`, `--height`,
|
||||
`--fps`, `--seed`, and `--flow-shift` flags override the matching workload-file
|
||||
values. `--num-chunks` and `--camera-pattern` apply only to the built-in
|
||||
workload; when using `--workload`, set those fields in the file instead.
|
||||
An explicit `camera_action_script` takes precedence over the file's
|
||||
`num_chunks` and `camera_pattern`. Each prompt update must use a distinct
|
||||
`after_chunk` boundary so its interaction event ID is unique.
|
||||
|
||||
Camera actions change what the world does, not what it costs, so `--camera-pattern`
|
||||
(`forward`, `orbit`, `hold`) keeps a rollout representative rather than sweeping a
|
||||
cost dimension.
|
||||
|
||||
## Options worth knowing
|
||||
|
||||
| Flag | Purpose |
|
||||
| --- | --- |
|
||||
| `--target-fps` | Playback rate the real-time verdict is measured against, independent of the mux label. |
|
||||
| `--sessions N` | N sequential measured rollouts; the report adds spread across them. |
|
||||
| `--warmup-sessions N` | Unmeasured rollouts first, to pay compile and capture cost. |
|
||||
| `--print-chunks` | One line per chunk as it arrives. |
|
||||
| `--save-video PATH` | Remux the last measured session for eyeballing (needs vLLM-Omni). |
|
||||
| `--ping-interval` | `session.ping` cadence keeping the server's stall clock fresh during a slow first chunk. `0` disables. |
|
||||
| `--first-chunk-timeout` | Raise it when the server is still compiling or capturing graphs. |
|
||||
|
||||
## Output
|
||||
|
||||
`--output-json` writes the run's configuration, every chunk's arrival time and
|
||||
size, per-session metrics, and the aggregate, along with the git revision and
|
||||
hostname so a number can be traced back to what produced it.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/benchmarks/test_lingbot_world_realtime.py` covers the chunk and frame
|
||||
arithmetic against the server's own formula, the metric math, and the protocol
|
||||
handling against a scripted WebSocket server. It needs no GPU:
|
||||
|
||||
```bash
|
||||
pytest tests/benchmarks/test_lingbot_world_realtime.py
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Realtime streaming benchmark for LingBot-World 2.0 over ``WS /v1/realtime/video``.
|
||||
|
||||
Every other diffusion benchmark in this repository measures "one request, one
|
||||
finished video". LingBot-World is not that: one request is a whole rollout, and
|
||||
the thing a deployment lives or dies by is whether chunk *N + 1* keeps arriving
|
||||
before a viewer has finished watching chunk *N*. This benchmark therefore
|
||||
measures a **cadence**, not a throughput:
|
||||
|
||||
* ``TTFC`` -- session.start to the first chunk's bytes, which pays for prompt
|
||||
encode, the first-frame VAE encode, and any compile or capture the deploy
|
||||
config asks for;
|
||||
* **inter-arrival latency** over all chunks and, separately, over the steady
|
||||
state after the attention window saturates;
|
||||
* ``VIDEO_RTF`` -- wall seconds spent per second of video generated, matching how
|
||||
vLLM-Omni computes RTF everywhere else (``generation_s / video_duration``).
|
||||
**Lower is better and below 1.0 is real time**; ``VIDEO_RTFX`` is reported
|
||||
alongside it as the reciprocal, under the repo's own name for that;
|
||||
* **playback continuity** -- a simulated viewer with a start buffer, because a
|
||||
p99 that is harmless behind a three-chunk buffer is a visible stall behind
|
||||
none.
|
||||
|
||||
The server is not started here. Bring one up first (see the README), then point
|
||||
this script at it. Requires ``websockets``; ``--save-video`` additionally needs
|
||||
a vLLM-Omni installation for the remux helper.
|
||||
|
||||
Example:
|
||||
|
||||
python benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py \\
|
||||
--host 127.0.0.1 --port 8000 \\
|
||||
--num-chunks 16 \\
|
||||
--output-json /tmp/lingbot_world_realtime.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from benchmarks.lingbot_world.workload import ( # noqa: E402
|
||||
CAMERA_PATTERNS,
|
||||
DEFAULT_FLOW_SHIFT,
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_HEIGHT,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROMPT,
|
||||
DEFAULT_SEED,
|
||||
DEFAULT_WARMUP_CHUNKS,
|
||||
DEFAULT_WIDTH,
|
||||
ChunkRecord,
|
||||
Workload,
|
||||
aggregate_metrics,
|
||||
compute_metrics,
|
||||
default_workload,
|
||||
load_workload,
|
||||
steady_chunk_deadline_ms,
|
||||
)
|
||||
|
||||
# The shared vLLM asset keeps the default workload runnable with no setup and
|
||||
# adds no binary to this repository. Any real photograph conditions a rollout.
|
||||
_DEFAULT_IMAGE_ASSET = "2560px-Gfp-wisconsin-madison-the-nature-boardwalk"
|
||||
_WS_PATH = "/v1/realtime/video"
|
||||
|
||||
|
||||
class BenchmarkError(RuntimeError):
|
||||
"""A session failed in a way that invalidates its numbers."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Input resolution
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def resolve_image_reference(value: str | None) -> str:
|
||||
"""Return an ``http(s)`` or ``data:`` URL for the session's first frame."""
|
||||
if value is None:
|
||||
from vllm.assets.image import ImageAsset
|
||||
|
||||
value = str(ImageAsset(_DEFAULT_IMAGE_ASSET).get_path("jpg"))
|
||||
if value.startswith(("http://", "https://", "data:")):
|
||||
return value
|
||||
path = Path(value).expanduser()
|
||||
if not path.is_file():
|
||||
raise BenchmarkError(f"--image is neither a URL nor a readable file: {value}")
|
||||
media_type = mimetypes.guess_type(path.name)[0] or "image/png"
|
||||
return f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode()}"
|
||||
|
||||
|
||||
def build_workload(args: argparse.Namespace) -> Workload:
|
||||
if args.workload is not None:
|
||||
# The file names its own first frame, so resolving happens inside
|
||||
# load_workload -- never eagerly, which would fetch the default asset a
|
||||
# --workload run has no use for.
|
||||
return load_workload(
|
||||
args.workload,
|
||||
image_resolver=lambda value: resolve_image_reference(value if args.image is None else args.image),
|
||||
overrides={
|
||||
"width": args.width_override,
|
||||
"height": args.height_override,
|
||||
"fps": args.fps_override,
|
||||
"seed": args.seed_override,
|
||||
"prompt": args.prompt_override,
|
||||
"flow_shift": args.flow_shift,
|
||||
"negative_prompt": args.negative_prompt,
|
||||
},
|
||||
)
|
||||
return default_workload(
|
||||
image_reference=resolve_image_reference(args.image),
|
||||
num_chunks=args.num_chunks,
|
||||
camera_pattern=args.camera_pattern,
|
||||
prompt=args.prompt_override if args.prompt_override is not None else DEFAULT_PROMPT,
|
||||
width=args.width_override if args.width_override is not None else DEFAULT_WIDTH,
|
||||
height=args.height_override if args.height_override is not None else DEFAULT_HEIGHT,
|
||||
fps=args.fps_override if args.fps_override is not None else DEFAULT_FPS,
|
||||
seed=args.seed_override if args.seed_override is not None else DEFAULT_SEED,
|
||||
flow_shift=args.flow_shift if args.flow_shift is not None else DEFAULT_FLOW_SHIFT,
|
||||
negative_prompt=args.negative_prompt,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# One streaming session
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionResult:
|
||||
request_id: str | None
|
||||
records: list[ChunkRecord]
|
||||
media: bytes
|
||||
ttfc_s: float | None
|
||||
video_start_s: float | None
|
||||
wall_s: float
|
||||
done_event: dict[str, Any]
|
||||
|
||||
|
||||
async def _keepalive(websocket: Any, send_lock: asyncio.Lock, interval: float) -> None:
|
||||
"""Refresh the server's stall clock while a slow first chunk is generating.
|
||||
|
||||
A compiled deploy config records CUDA graphs on the first block, which can
|
||||
outlast the server's ~60 s stall timeout. ``session.ping`` is the documented
|
||||
way to say the client is still there.
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
async with send_lock:
|
||||
await websocket.send(json.dumps({"type": "session.ping"}))
|
||||
|
||||
|
||||
async def run_session(
|
||||
url: str,
|
||||
model: str,
|
||||
workload: Workload,
|
||||
*,
|
||||
first_chunk_timeout: float,
|
||||
chunk_timeout: float,
|
||||
ping_interval: float,
|
||||
collect_media: bool,
|
||||
print_chunks: bool,
|
||||
) -> SessionResult:
|
||||
"""Drive one rollout and timestamp every chunk as its bytes land."""
|
||||
try:
|
||||
from websockets.asyncio.client import connect
|
||||
except ImportError: # pragma: no cover - exercised only without websockets
|
||||
try:
|
||||
from websockets import connect # type: ignore[attr-defined]
|
||||
except ImportError as exc:
|
||||
raise BenchmarkError("This benchmark needs the websockets package: pip install websockets") from exc
|
||||
|
||||
payload = workload.session_start_payload(model)
|
||||
records: list[ChunkRecord] = []
|
||||
media = bytearray()
|
||||
pending: dict[str, Any] | None = None
|
||||
request_id: str | None = None
|
||||
ttfc_s: float | None = None
|
||||
video_start_s: float | None = None
|
||||
last_arrival: float | None = None
|
||||
done_event: dict[str, Any] = {}
|
||||
send_lock = asyncio.Lock()
|
||||
keepalive: asyncio.Task[None] | None = None
|
||||
# Fire remaining prompt updates in chunk order as their boundary passes.
|
||||
updates = sorted(workload.prompt_updates, key=lambda update: update.after_chunk)
|
||||
update_index = 0
|
||||
|
||||
async with connect(url, max_size=None, ping_interval=None) as websocket:
|
||||
async with send_lock:
|
||||
await websocket.send(json.dumps(payload, ensure_ascii=False))
|
||||
started = time.perf_counter()
|
||||
if ping_interval > 0:
|
||||
keepalive = asyncio.create_task(_keepalive(websocket, send_lock, ping_interval))
|
||||
|
||||
try:
|
||||
while True:
|
||||
timeout = first_chunk_timeout if not records else chunk_timeout
|
||||
try:
|
||||
deadline = started + (last_arrival if last_arrival is not None else 0.0) + timeout
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
raise asyncio.TimeoutError
|
||||
message = await asyncio.wait_for(websocket.recv(), timeout=remaining)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise BenchmarkError(
|
||||
f"No media chunk for {timeout:g} s after {len(records)} chunk(s); "
|
||||
"the server may still be loading, compiling, or capturing graphs."
|
||||
) from exc
|
||||
now = time.perf_counter()
|
||||
|
||||
if isinstance(message, (bytes, bytearray)):
|
||||
if pending is None:
|
||||
raise BenchmarkError("Received a binary frame with no preceding video.chunk_metadata event.")
|
||||
if collect_media:
|
||||
media.extend(message)
|
||||
if pending.get("kind") == "media":
|
||||
index = int(pending["generation_chunk_index"])
|
||||
arrival = now - started
|
||||
if ttfc_s is None:
|
||||
ttfc_s = arrival
|
||||
records.append(
|
||||
ChunkRecord(
|
||||
index=index,
|
||||
arrival_s=arrival,
|
||||
inter_arrival_s=arrival - (last_arrival if last_arrival is not None else 0.0),
|
||||
byte_length=len(message),
|
||||
num_frames=int(pending["num_frames"]),
|
||||
)
|
||||
)
|
||||
last_arrival = arrival
|
||||
if print_chunks:
|
||||
print(
|
||||
f" chunk {index:3d} t={arrival:8.3f}s "
|
||||
f"interval={records[-1].inter_arrival_s * 1000:8.1f}ms "
|
||||
f"frames={records[-1].num_frames:3d} bytes={len(message)}",
|
||||
flush=True,
|
||||
)
|
||||
while update_index < len(updates) and updates[update_index].after_chunk <= index:
|
||||
async with send_lock:
|
||||
await websocket.send(json.dumps(updates[update_index].to_payload(), ensure_ascii=False))
|
||||
update_index += 1
|
||||
pending = None
|
||||
continue
|
||||
|
||||
event = json.loads(message)
|
||||
event_type = event.get("type")
|
||||
if event_type == "video.chunk_metadata":
|
||||
pending = event
|
||||
elif event_type == "video.start":
|
||||
request_id = event.get("request_id")
|
||||
video_start_s = now - started
|
||||
elif event_type == "error":
|
||||
# Say what was asked for alongside what the server refused:
|
||||
# some builds cap a rollout's num_frames, and the operator
|
||||
# needs to know which --num-chunks would fit.
|
||||
raise BenchmarkError(
|
||||
f"Server rejected the session after {len(records)} chunk(s): "
|
||||
f"{event.get('message', event)} "
|
||||
f"(requested {workload.num_chunks} chunks = num_frames {workload.num_frames})"
|
||||
)
|
||||
elif event_type == "session.done":
|
||||
done_event = event
|
||||
break
|
||||
finally:
|
||||
if keepalive is not None:
|
||||
keepalive.cancel()
|
||||
try:
|
||||
await keepalive
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
wall_s = time.perf_counter() - started
|
||||
|
||||
if not records:
|
||||
raise BenchmarkError("The session finished without delivering a single media chunk.")
|
||||
if done_event.get("stopped"):
|
||||
raise BenchmarkError(f"The session was stopped before it finished: {done_event}")
|
||||
observed = [record.index for record in records]
|
||||
if observed != list(range(len(records))):
|
||||
raise BenchmarkError(f"Chunk indices are not contiguous from zero: {observed}")
|
||||
if len(records) != workload.num_chunks:
|
||||
raise BenchmarkError(
|
||||
f"Expected {workload.num_chunks} chunks for num_frames={workload.num_frames}, received {len(records)}."
|
||||
)
|
||||
|
||||
return SessionResult(
|
||||
request_id=request_id,
|
||||
records=records,
|
||||
media=bytes(media),
|
||||
ttfc_s=ttfc_s,
|
||||
video_start_s=video_start_s,
|
||||
wall_s=wall_s,
|
||||
done_event=done_event,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reporting
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _git_revision() -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "-C", str(REPO_ROOT), "rev-parse", "HEAD"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return None
|
||||
|
||||
|
||||
def _print_row(label: str, value: str) -> None:
|
||||
print(f"{label:<44}{value:>16}")
|
||||
|
||||
|
||||
def print_report(
|
||||
metrics: dict[str, Any],
|
||||
*,
|
||||
workload: Workload,
|
||||
endpoint: str,
|
||||
sessions: int,
|
||||
target_fps: float,
|
||||
) -> None:
|
||||
header = " LingBot-World realtime streaming benchmark "
|
||||
print("\n{s:{c}^{n}}".format(s=header, n=60, c="="))
|
||||
_print_row("Endpoint:", endpoint)
|
||||
_print_row("Geometry:", f"{workload.width}x{workload.height}")
|
||||
_print_row("Chunks per session:", str(workload.num_chunks))
|
||||
_print_row("Sessions:", str(sessions))
|
||||
frames = workload.num_frames
|
||||
_print_row("Real-time basis:", f"{target_fps:g} fps")
|
||||
_print_row("Video seconds per session:", f"{frames / target_fps:.2f} ({frames} frames)")
|
||||
if abs(target_fps - workload.fps) > 1e-9:
|
||||
_print_row("Mux label sent to server:", f"{workload.fps} fps")
|
||||
|
||||
if sessions == 1:
|
||||
print("{s:{c}^{n}}".format(s=" Cadence ", n=60, c="-"))
|
||||
if metrics.get("ttfc_ms") is not None:
|
||||
_print_row("TTFC (session.start -> first chunk):", f"{metrics['ttfc_ms']:.1f} ms")
|
||||
interval_all = metrics.get("interval_all") or {}
|
||||
interval_steady = metrics.get("interval_steady") or {}
|
||||
for title, summary in (("All chunks", interval_all), ("Steady state", interval_steady)):
|
||||
if not summary:
|
||||
continue
|
||||
print(f" {title} (n={int(summary['count'])}):")
|
||||
_print_row(" mean / median:", f"{summary['mean_ms']:.1f} / {summary['median_ms']:.1f} ms")
|
||||
_print_row(" std / max:", f"{summary['std_ms']:.1f} / {summary['max_ms']:.1f} ms")
|
||||
tail = f"{summary['p90_ms']:.0f} / {summary['p95_ms']:.0f} / {summary['p99_ms']:.0f} ms"
|
||||
_print_row(" p90 / p95 / p99:", tail)
|
||||
if summary["count"] < 100:
|
||||
print(
|
||||
" NOTE: fewer than 100 intervals; tail percentiles are descriptive, not reliable tail estimates."
|
||||
)
|
||||
|
||||
print("{s:{c}^{n}}".format(s=" Real time ", n=60, c="-"))
|
||||
_print_row("VIDEO_RTF (wall s / video s, <1 real time):", f"{metrics['video_rtf']:.3f}")
|
||||
_print_row("VIDEO_RTFX (video s / wall s):", f"{metrics['video_rtfx']:.3f}")
|
||||
if metrics.get("steady_video_rtf") is not None:
|
||||
_print_row("VIDEO_RTF steady state:", f"{metrics['steady_video_rtf']:.3f}")
|
||||
_print_row("Frames per second:", f"{metrics['frames_per_second']:.2f}")
|
||||
_print_row("Chunk deadline:", f"{metrics['chunk_deadline_ms']:.1f} ms")
|
||||
if metrics.get("slo_attainment") is not None:
|
||||
_print_row(
|
||||
"Steady chunks within deadline:",
|
||||
f"{metrics['slo_attainment'] * 100:.1f}% ({metrics['slo_violations']} late)",
|
||||
)
|
||||
if not metrics.get("steady_state_reached", False):
|
||||
print(
|
||||
f" NOTE: no chunk reached the steady state; the attention window needs "
|
||||
f"{metrics['warmup_chunks']} chunks. Raise --num-chunks."
|
||||
)
|
||||
|
||||
playback = metrics.get("playback") or {}
|
||||
print("{s:{c}^{n}}".format(s=" Playback continuity ", n=60, c="-"))
|
||||
_print_row("Start buffer:", f"{playback.get('buffer_chunks')} chunk(s)")
|
||||
_print_row("Underruns:", str(playback.get("underrun_count")))
|
||||
_print_row("Total stall:", f"{playback.get('total_stall_ms', 0.0):.1f} ms")
|
||||
_print_row("Stall ratio:", f"{playback.get('stall_ratio', 0.0) * 100:.2f}%")
|
||||
if playback.get("first_underrun_chunk") is not None:
|
||||
_print_row("First underrun at chunk:", str(playback["first_underrun_chunk"]))
|
||||
else:
|
||||
print("{s:{c}^{n}}".format(s=" Across sessions ", n=60, c="-"))
|
||||
if metrics.get("ttfc_ms_mean") is not None:
|
||||
_print_row("TTFC mean / max:", f"{metrics['ttfc_ms_mean']:.1f} / {metrics['ttfc_ms_max']:.1f} ms")
|
||||
if metrics.get("steady_interval_ms_mean") is not None:
|
||||
_print_row("Steady interval mean:", f"{metrics['steady_interval_ms_mean']:.1f} ms")
|
||||
_print_row("Steady interval spread:", f"{metrics['steady_interval_ms_spread']:.1f} ms")
|
||||
_print_row("VIDEO_RTF mean (lower is better):", f"{metrics['video_rtf_mean']:.3f}")
|
||||
_print_row(
|
||||
"VIDEO_RTF best / worst:",
|
||||
f"{metrics['video_rtf_best']:.3f} / {metrics['video_rtf_worst']:.3f}",
|
||||
)
|
||||
_print_row("Chunk deadline:", f"{metrics['chunk_deadline_ms']:.1f} ms")
|
||||
_print_row("Underruns (all sessions):", str(metrics["underrun_count"]))
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def save_video(media: bytes, path: Path, *, fps: int) -> None:
|
||||
from vllm_omni.diffusion.utils.media_utils import finalize_streaming_video_bytes
|
||||
|
||||
playback_bytes = finalize_streaming_video_bytes(media, input_format="m4s", fps=fps)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp_path.write_bytes(playback_bytes)
|
||||
os.replace(tmp_path, path)
|
||||
print(f"Saved {len(media)} streamed bytes -> {len(playback_bytes)} playback bytes at {path}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Benchmark LingBot-World 2.0 realtime streaming over WS /v1/realtime/video.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
endpoint = parser.add_argument_group("endpoint")
|
||||
endpoint.add_argument("--host", default="127.0.0.1")
|
||||
endpoint.add_argument("--port", type=int, default=8000)
|
||||
endpoint.add_argument("--base-url", default=None, help="Full ws:// URL; overrides --host/--port.")
|
||||
endpoint.add_argument("--model", default=DEFAULT_MODEL)
|
||||
|
||||
workload = parser.add_argument_group("workload")
|
||||
workload.add_argument("--workload", default=None, help="JSON rollout spec; overrides the built-in workload.")
|
||||
workload.add_argument("--image", default=None, help="First frame: path or URL. Defaults to the shared vLLM asset.")
|
||||
workload.add_argument("--prompt", dest="prompt_override", default=None, help=f"Default: {DEFAULT_PROMPT!r}")
|
||||
workload.add_argument("--negative-prompt", default=None)
|
||||
workload.add_argument("--num-chunks", type=int, default=16, help="AR blocks per session; ignored with --workload.")
|
||||
workload.add_argument(
|
||||
"--camera-pattern",
|
||||
choices=CAMERA_PATTERNS,
|
||||
default="forward",
|
||||
help="Camera pattern for the built-in workload only; ignored with --workload.",
|
||||
)
|
||||
workload.add_argument("--width", dest="width_override", type=int, default=None, help=f"Default: {DEFAULT_WIDTH}")
|
||||
workload.add_argument("--height", dest="height_override", type=int, default=None, help=f"Default: {DEFAULT_HEIGHT}")
|
||||
workload.add_argument("--fps", dest="fps_override", type=int, default=None, help=f"Default: {DEFAULT_FPS}")
|
||||
workload.add_argument("--seed", dest="seed_override", type=int, default=None, help=f"Default: {DEFAULT_SEED}")
|
||||
workload.add_argument("--flow-shift", type=float, default=None, help=f"Default: {DEFAULT_FLOW_SHIFT}")
|
||||
|
||||
run = parser.add_argument_group("run")
|
||||
run.add_argument("--sessions", type=int, default=1, help="Sequential measured rollouts.")
|
||||
run.add_argument("--warmup-sessions", type=int, default=0, help="Unmeasured rollouts first (pays compile cost).")
|
||||
run.add_argument(
|
||||
"--warmup-chunks",
|
||||
type=int,
|
||||
default=DEFAULT_WARMUP_CHUNKS,
|
||||
help="Chunks excluded from steady state; the attention window saturates after 6.",
|
||||
)
|
||||
run.add_argument(
|
||||
"--chunk-slo-ms",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Deadline a steady chunk must meet. Default: one chunk of video at --target-fps.",
|
||||
)
|
||||
run.add_argument(
|
||||
"--target-fps",
|
||||
type=float,
|
||||
default=None,
|
||||
help=(
|
||||
"Playback rate used for real-time factor, the chunk deadline, and the playback "
|
||||
"simulation. The checkpoint declares no frame rate, so this is a consumption "
|
||||
"choice, not a model property. Default: the --fps sent to the server."
|
||||
),
|
||||
)
|
||||
run.add_argument("--playback-buffer-chunks", type=int, default=1, help="Chunks buffered before playback starts.")
|
||||
run.add_argument("--first-chunk-timeout", type=float, default=900.0)
|
||||
run.add_argument("--chunk-timeout", type=float, default=300.0)
|
||||
run.add_argument("--ping-interval", type=float, default=20.0, help="session.ping cadence; 0 disables.")
|
||||
|
||||
output = parser.add_argument_group("output")
|
||||
output.add_argument("--output-json", default=None)
|
||||
output.add_argument("--save-video", default=None, help="Write the last measured session's video here.")
|
||||
output.add_argument("--print-chunks", action="store_true")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.sessions < 1:
|
||||
parser.error("--sessions must be at least 1.")
|
||||
if args.warmup_sessions < 0:
|
||||
parser.error("--warmup-sessions must be non-negative.")
|
||||
if args.warmup_chunks < 0:
|
||||
parser.error("--warmup-chunks must be non-negative.")
|
||||
if args.playback_buffer_chunks < 1:
|
||||
parser.error("--playback-buffer-chunks must be at least 1.")
|
||||
if args.target_fps is not None and args.target_fps <= 0:
|
||||
parser.error("--target-fps must be positive.")
|
||||
if args.workload is None and args.num_chunks < 1:
|
||||
parser.error("--num-chunks must be at least 1.")
|
||||
return args
|
||||
|
||||
|
||||
def endpoint_url(args: argparse.Namespace) -> str:
|
||||
if args.base_url is not None:
|
||||
base = args.base_url.rstrip("/")
|
||||
for prefix, replacement in (("http://", "ws://"), ("https://", "wss://")):
|
||||
if base.startswith(prefix):
|
||||
base = replacement + base[len(prefix) :]
|
||||
break
|
||||
return base if base.endswith(_WS_PATH) else base + _WS_PATH
|
||||
return f"ws://{args.host}:{args.port}{_WS_PATH}"
|
||||
|
||||
|
||||
async def run_benchmark(args: argparse.Namespace) -> dict[str, Any]:
|
||||
workload = build_workload(args)
|
||||
url = endpoint_url(args)
|
||||
# The mux label and the real-time basis are different questions; only the
|
||||
# latter decides whether a run passes.
|
||||
target_fps = args.target_fps if args.target_fps is not None else float(workload.fps)
|
||||
if max(1, args.warmup_chunks) >= workload.num_chunks - 1:
|
||||
print(
|
||||
f"WARNING: --warmup-chunks={args.warmup_chunks} leaves no nonterminal steady-state chunk in a "
|
||||
f"{workload.num_chunks}-chunk rollout; steady-state metrics will be empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print(f"Connecting to {url}")
|
||||
print(
|
||||
f"Rollout: {workload.num_chunks} chunks, num_frames={workload.num_frames}, "
|
||||
f"{workload.video_seconds:.2f}s of video"
|
||||
)
|
||||
print(
|
||||
f"Real-time basis: {target_fps:g} fps -> a steady 12-frame chunk is "
|
||||
f"{steady_chunk_deadline_ms(target_fps):.1f} ms of video"
|
||||
+ ("" if args.target_fps is None else f" (mux label stays {workload.fps} fps)")
|
||||
)
|
||||
|
||||
for index in range(args.warmup_sessions):
|
||||
print(f"Warmup session {index + 1}/{args.warmup_sessions} (not measured)...")
|
||||
await run_session(
|
||||
url,
|
||||
args.model,
|
||||
workload,
|
||||
first_chunk_timeout=args.first_chunk_timeout,
|
||||
chunk_timeout=args.chunk_timeout,
|
||||
ping_interval=args.ping_interval,
|
||||
collect_media=False,
|
||||
print_chunks=False,
|
||||
)
|
||||
|
||||
session_metrics: list[dict[str, Any]] = []
|
||||
session_payloads: list[dict[str, Any]] = []
|
||||
last_result: SessionResult | None = None
|
||||
for index in range(args.sessions):
|
||||
if args.sessions > 1:
|
||||
print(f"Session {index + 1}/{args.sessions}...")
|
||||
result = await run_session(
|
||||
url,
|
||||
args.model,
|
||||
workload,
|
||||
first_chunk_timeout=args.first_chunk_timeout,
|
||||
chunk_timeout=args.chunk_timeout,
|
||||
ping_interval=args.ping_interval,
|
||||
collect_media=args.save_video is not None,
|
||||
print_chunks=args.print_chunks,
|
||||
)
|
||||
last_result = result
|
||||
metrics = compute_metrics(
|
||||
result.records,
|
||||
fps=target_fps,
|
||||
warmup_chunks=args.warmup_chunks,
|
||||
slo_ms=args.chunk_slo_ms,
|
||||
playback_buffer_chunks=args.playback_buffer_chunks,
|
||||
ttfc_s=result.ttfc_s,
|
||||
session_wall_s=result.wall_s,
|
||||
)
|
||||
session_metrics.append(metrics)
|
||||
session_payloads.append(
|
||||
{
|
||||
"session_index": index,
|
||||
"request_id": result.request_id,
|
||||
"video_start_ms": (result.video_start_s * 1000.0) if result.video_start_s is not None else None,
|
||||
"chunks": [record.to_dict() for record in result.records],
|
||||
"metrics": metrics,
|
||||
}
|
||||
)
|
||||
|
||||
aggregate = aggregate_metrics(session_metrics, fps=target_fps)
|
||||
print_report(aggregate, workload=workload, endpoint=url, sessions=args.sessions, target_fps=target_fps)
|
||||
|
||||
if args.save_video is not None and last_result is not None:
|
||||
save_video(last_result.media, Path(args.save_video), fps=workload.fps)
|
||||
|
||||
document = {
|
||||
"benchmark": "lingbot_world_realtime",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"endpoint": url,
|
||||
"model": args.model,
|
||||
"git_revision": _git_revision(),
|
||||
"hostname": platform.node(),
|
||||
"workload": workload.describe(),
|
||||
"config": {
|
||||
"sessions": args.sessions,
|
||||
"warmup_sessions": args.warmup_sessions,
|
||||
"warmup_chunks": args.warmup_chunks,
|
||||
"camera_pattern": args.camera_pattern,
|
||||
"chunk_slo_ms": args.chunk_slo_ms,
|
||||
"target_fps": target_fps,
|
||||
"mux_fps": workload.fps,
|
||||
"playback_buffer_chunks": args.playback_buffer_chunks,
|
||||
"workload_file": args.workload,
|
||||
},
|
||||
"sessions": session_payloads,
|
||||
"metrics": aggregate,
|
||||
}
|
||||
if args.output_json is not None:
|
||||
path = Path(args.output_json)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n")
|
||||
print(f"Wrote {path}")
|
||||
return document
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
asyncio.run(run_benchmark(args))
|
||||
except BenchmarkError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
# Benchmark deploy config: LingBot-World 2.0 on one GPU, eager.
|
||||
#
|
||||
# This is the portable smoke configuration -- it matches the shipped
|
||||
# vllm_omni/deploy/lingbot_world_v2_stepwise.yaml so a number measured here is
|
||||
# comparable to the default deployment. It is NOT a realtime configuration: one
|
||||
# GPU runs all five forwards of every AR block serially and nothing is compiled,
|
||||
# so expect a chunk interval several times the real-time deadline.
|
||||
#
|
||||
# Use configs/usp4_compiled.yaml for the realtime topology.
|
||||
#
|
||||
# vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \
|
||||
# --omni --deploy-config benchmarks/lingbot_world/configs/single_gpu_eager.yaml \
|
||||
# --port 8000
|
||||
|
||||
pipeline: lingbot_world
|
||||
async_chunk: false
|
||||
distributed_executor_backend: mp
|
||||
dtype: bfloat16
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0"
|
||||
# AR-Diffusion binds one session per runner invocation; the runner rejects
|
||||
# anything larger.
|
||||
max_num_seqs: 1
|
||||
enforce_eager: true
|
||||
model_class_name: LingBotWorldCausalDMDPipeline
|
||||
engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine
|
||||
step_execution: true
|
||||
streaming_output: true
|
||||
default_sampling_params:
|
||||
# The causal-fast checkpoint pins a four-step DMD schedule; this sizes the
|
||||
# step budget before the pipeline is reached.
|
||||
num_inference_steps: 4
|
||||
output_type: pil
|
||||
extra_args:
|
||||
flow_shift: 5.0
|
||||
model_config:
|
||||
# The AR cache geometry is fixed at load time, so a benchmark request must
|
||||
# ask for exactly this resolution.
|
||||
ar_diffusion_height: 480
|
||||
ar_diffusion_width: 832
|
||||
ar_diffusion_kv_config:
|
||||
gpu_memory_fraction: 0.6
|
||||
warmup_cudagraph: true
|
||||
@@ -0,0 +1,60 @@
|
||||
# Benchmark deploy config: LingBot-World 2.0 on four GPUs, Ulysses SP-4, compiled.
|
||||
#
|
||||
# This is the realtime topology. Two things differ from the single-GPU config,
|
||||
# and both matter to the number the benchmark reports:
|
||||
#
|
||||
# parallel_config Pure Ulysses sequence parallelism over four ranks. The
|
||||
# pipeline validates this: LingBot requires
|
||||
# sequence_parallel_size == ulysses_degree, ring_degree and
|
||||
# allgather_degree of 1, ulysses_mode "strict", and
|
||||
# ulysses_a2a_permute disabled. Pipeline, CFG, and VAE
|
||||
# parallel sizes above 1 are rejected. This config uses
|
||||
# TP=1; the validator does not reject larger TP sizes.
|
||||
# enforce_eager false puts LingBotDMDBlockRunner on the compiled path
|
||||
# (regional torch.compile with cudagraph step marking). The
|
||||
# first rollout therefore pays compile and capture cost --
|
||||
# use --warmup-sessions 1 before measured repetitions.
|
||||
# Chunk warmup only excludes the attention-window ramp.
|
||||
#
|
||||
# Serve it under a GPU reservation, since it needs four cards:
|
||||
#
|
||||
# gpu run --gpus 4 -- vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \
|
||||
# --omni --deploy-config benchmarks/lingbot_world/configs/usp4_compiled.yaml \
|
||||
# --port 8000
|
||||
#
|
||||
# `devices` is the rank->card mapping inside the reservation, so it stays
|
||||
# "0,1,2,3" no matter which physical cards CUDA_VISIBLE_DEVICES selected.
|
||||
|
||||
pipeline: lingbot_world
|
||||
async_chunk: false
|
||||
distributed_executor_backend: mp
|
||||
dtype: bfloat16
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
devices: "0,1,2,3"
|
||||
max_num_seqs: 1
|
||||
enforce_eager: false
|
||||
model_class_name: LingBotWorldCausalDMDPipeline
|
||||
engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine
|
||||
step_execution: true
|
||||
streaming_output: true
|
||||
parallel_config:
|
||||
tensor_parallel_size: 1
|
||||
sequence_parallel_size: 4
|
||||
ulysses_degree: 4
|
||||
ring_degree: 1
|
||||
default_sampling_params:
|
||||
num_inference_steps: 4
|
||||
output_type: pil
|
||||
extra_args:
|
||||
flow_shift: 5.0
|
||||
model_config:
|
||||
ar_diffusion_height: 480
|
||||
ar_diffusion_width: 832
|
||||
ar_diffusion_kv_config:
|
||||
# Each rank holds its own shard of the paged KV cache, so the per-card
|
||||
# fraction can stay at the single-GPU value; lower it if the four-rank
|
||||
# start OOMs on smaller cards.
|
||||
gpu_memory_fraction: 0.6
|
||||
warmup_cudagraph: true
|
||||
@@ -0,0 +1,631 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Workload construction and metric math for the LingBot-World realtime benchmark.
|
||||
|
||||
Nothing here imports torch, vllm, or websockets, so the arithmetic that decides
|
||||
what a benchmark number *means* is unit-testable without a GPU or a server.
|
||||
|
||||
Two model properties drive every formula in this module, both read from the
|
||||
checkpoint's ``transformer/config.json`` rather than assumed:
|
||||
|
||||
``num_frames_per_block = 3``
|
||||
One AR block commits three latent frames, so one streamed chunk is three
|
||||
latent frames and a request's ``camera_action_script`` carries exactly one
|
||||
three-entry action list per chunk.
|
||||
|
||||
``sliding_window_num_frames = 18`` with ``sink_size = 9``
|
||||
Self-attention sees nine sink frames plus a nine-frame recent window, so the
|
||||
key count a block attends over keeps growing until eighteen latent frames of
|
||||
history exist -- six chunks. Chunk cost is therefore *not* stationary before
|
||||
chunk six, and a mean taken over a short rollout reports a number no steady
|
||||
session will ever produce. :data:`DEFAULT_WARMUP_CHUNKS` exists for that
|
||||
reason and is not a round-number guess.
|
||||
|
||||
The causal VAE expands the first latent frame to one pixel frame and every later
|
||||
latent frame to four, so chunk zero carries nine pixel frames and every chunk
|
||||
after it carries twelve. Video-duration math has to respect that asymmetry or
|
||||
real-time factor comes out wrong on short rollouts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# transformer.config.num_frames_per_block: latent frames committed per AR block.
|
||||
FRAMES_PER_BLOCK = 3
|
||||
# vae_scale_factor_temporal: latent frames -> pixel frames, after the first.
|
||||
TEMPORAL_COMPRESSION = 4
|
||||
# (sink_size 9 + recent window 9) / FRAMES_PER_BLOCK: chunks until the attention
|
||||
# window saturates and per-chunk cost becomes stationary.
|
||||
DEFAULT_WARMUP_CHUNKS = 6
|
||||
|
||||
DEFAULT_MODEL = "robbyant/lingbot-world-v2-14b-causal-fast-diffusers"
|
||||
DEFAULT_PROMPT = "The camera moves slowly forward through the scene."
|
||||
# The AR KV cache geometry is fixed at load time, so a request must ask for
|
||||
# exactly the resolution the deploy config declares.
|
||||
DEFAULT_WIDTH = 832
|
||||
DEFAULT_HEIGHT = 480
|
||||
DEFAULT_FPS = 16
|
||||
DEFAULT_FLOW_SHIFT = 5.0
|
||||
DEFAULT_SEED = 42
|
||||
|
||||
_HOLD = "hold"
|
||||
_ACTION_KEYS = ("w", "a", "s", "d")
|
||||
|
||||
|
||||
def num_frames_for_chunks(num_chunks: int) -> int:
|
||||
"""Pixel-frame count whose rollout is exactly ``num_chunks`` chunks.
|
||||
|
||||
Inverse of :func:`chunks_for_num_frames`, which is the server's own
|
||||
``((num_frames - 1) // 4 + 1) // 3``.
|
||||
"""
|
||||
if num_chunks < 1:
|
||||
raise ValueError("num_chunks must be at least 1.")
|
||||
return (num_chunks * FRAMES_PER_BLOCK - 1) * TEMPORAL_COMPRESSION + 1
|
||||
|
||||
|
||||
def chunks_for_num_frames(num_frames: int) -> int:
|
||||
"""Chunks the server generates for ``num_frames``, mirroring its own formula."""
|
||||
if num_frames < 1:
|
||||
raise ValueError("num_frames must be at least 1.")
|
||||
return ((num_frames - 1) // TEMPORAL_COMPRESSION + 1) // FRAMES_PER_BLOCK
|
||||
|
||||
|
||||
def pixel_frames_in_chunk(chunk_index: int) -> int:
|
||||
"""Pixel frames carried by one chunk.
|
||||
|
||||
The causal decoder expands the session's opening latent frame once and every
|
||||
later latent frame four times, so chunk zero is short by three frames.
|
||||
"""
|
||||
if chunk_index < 0:
|
||||
raise ValueError("chunk_index must be non-negative.")
|
||||
if chunk_index == 0:
|
||||
return (FRAMES_PER_BLOCK - 1) * TEMPORAL_COMPRESSION + 1
|
||||
return FRAMES_PER_BLOCK * TEMPORAL_COMPRESSION
|
||||
|
||||
|
||||
def camera_actions_for_chunk(pattern: str, chunk_index: int) -> list[list[str]]:
|
||||
"""One three-entry action list for ``chunk_index``.
|
||||
|
||||
Camera actions select camera embeddings; they change what the world does,
|
||||
not how much arithmetic it costs. The pattern therefore exists to keep a
|
||||
benchmark rollout representative rather than to sweep a cost dimension.
|
||||
"""
|
||||
if chunk_index < 0:
|
||||
raise ValueError("chunk_index must be non-negative.")
|
||||
if pattern == "forward":
|
||||
return [["w"] for _ in range(FRAMES_PER_BLOCK)]
|
||||
if pattern == _HOLD:
|
||||
return [[] for _ in range(FRAMES_PER_BLOCK)]
|
||||
if pattern == "orbit":
|
||||
key = _ACTION_KEYS[chunk_index % len(_ACTION_KEYS)]
|
||||
return [[key] for _ in range(FRAMES_PER_BLOCK)]
|
||||
raise ValueError(f"Unknown camera pattern {pattern!r}; expected one of {sorted(CAMERA_PATTERNS)}.")
|
||||
|
||||
|
||||
CAMERA_PATTERNS = ("forward", "orbit", _HOLD)
|
||||
|
||||
|
||||
def build_camera_script(num_chunks: int, pattern: str) -> list[list[list[str]]]:
|
||||
"""Full ``camera_action_script``: one action list per generated chunk."""
|
||||
if num_chunks < 1:
|
||||
raise ValueError("num_chunks must be at least 1.")
|
||||
return [camera_actions_for_chunk(pattern, index) for index in range(num_chunks)]
|
||||
|
||||
|
||||
def _validate_camera_script(script: Sequence[Any]) -> list[list[list[str]]]:
|
||||
validated: list[list[list[str]]] = []
|
||||
for chunk_index, actions in enumerate(script):
|
||||
if not isinstance(actions, Sequence) or isinstance(actions, (str, bytes)):
|
||||
raise ValueError(f"camera_action_script[{chunk_index}] must be a list of three action lists.")
|
||||
if len(actions) != FRAMES_PER_BLOCK:
|
||||
raise ValueError(
|
||||
f"camera_action_script[{chunk_index}] must hold exactly {FRAMES_PER_BLOCK} action lists "
|
||||
f"(got {len(actions)}); one AR block is {FRAMES_PER_BLOCK} latent frames."
|
||||
)
|
||||
frames: list[list[str]] = []
|
||||
for frame_index, frame in enumerate(actions):
|
||||
if not isinstance(frame, Sequence) or isinstance(frame, (str, bytes)):
|
||||
raise ValueError(f"camera_action_script[{chunk_index}][{frame_index}] must be a list of strings.")
|
||||
if any(not isinstance(action, str) for action in frame):
|
||||
raise ValueError(f"camera_action_script[{chunk_index}][{frame_index}] must contain only strings.")
|
||||
frames.append([str(action) for action in frame])
|
||||
validated.append(frames)
|
||||
if not validated:
|
||||
raise ValueError("camera_action_script must not be empty.")
|
||||
return validated
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptUpdate:
|
||||
"""A mid-rollout ``session.interaction`` scheduled on a chunk boundary."""
|
||||
|
||||
after_chunk: int
|
||||
prompt: str
|
||||
transition_chunks: int = 3
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.after_chunk < 0:
|
||||
raise ValueError("PromptUpdate.after_chunk must be non-negative.")
|
||||
if not self.prompt.strip():
|
||||
raise ValueError("PromptUpdate.prompt must contain non-whitespace text.")
|
||||
if self.transition_chunks < 0:
|
||||
raise ValueError("PromptUpdate.transition_chunks must be non-negative.")
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "session.interaction",
|
||||
"interaction": {
|
||||
"event_id": f"chunk-{self.after_chunk}",
|
||||
"event": {"prompt": self.prompt},
|
||||
"transition_chunks": self.transition_chunks,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Workload:
|
||||
"""One rollout: what the benchmark asks the server to generate."""
|
||||
|
||||
prompt: str
|
||||
image_reference: str
|
||||
camera_script: list[list[list[str]]]
|
||||
width: int = DEFAULT_WIDTH
|
||||
height: int = DEFAULT_HEIGHT
|
||||
fps: int = DEFAULT_FPS
|
||||
seed: int = DEFAULT_SEED
|
||||
flow_shift: float = DEFAULT_FLOW_SHIFT
|
||||
negative_prompt: str | None = None
|
||||
prompt_updates: tuple[PromptUpdate, ...] = ()
|
||||
extra_params: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.prompt.strip():
|
||||
raise ValueError("Workload.prompt must contain non-whitespace text.")
|
||||
if not self.image_reference:
|
||||
raise ValueError("Workload.image_reference is required; LingBot-World is image-conditioned.")
|
||||
if self.width <= 0 or self.height <= 0 or self.width % 16 or self.height % 16:
|
||||
raise ValueError("Workload width/height must be positive multiples of 16.")
|
||||
if self.fps <= 0:
|
||||
raise ValueError("Workload.fps must be positive.")
|
||||
object.__setattr__(self, "camera_script", _validate_camera_script(self.camera_script))
|
||||
seen_update_chunks: set[int] = set()
|
||||
for update in self.prompt_updates:
|
||||
if update.after_chunk in seen_update_chunks:
|
||||
raise ValueError(f"Duplicate prompt update after chunk {update.after_chunk}; event IDs must be unique.")
|
||||
seen_update_chunks.add(update.after_chunk)
|
||||
if update.after_chunk >= self.num_chunks:
|
||||
raise ValueError(
|
||||
f"Prompt update after chunk {update.after_chunk} never fires in a {self.num_chunks}-chunk rollout."
|
||||
)
|
||||
|
||||
@property
|
||||
def num_chunks(self) -> int:
|
||||
return len(self.camera_script)
|
||||
|
||||
@property
|
||||
def num_frames(self) -> int:
|
||||
return num_frames_for_chunks(self.num_chunks)
|
||||
|
||||
@property
|
||||
def video_seconds(self) -> float:
|
||||
return self.num_frames / self.fps
|
||||
|
||||
def session_start_payload(self, model: str) -> dict[str, Any]:
|
||||
"""The ``session.start`` event for ``WS /v1/realtime/video``."""
|
||||
extra_params: dict[str, Any] = {
|
||||
"flow_shift": self.flow_shift,
|
||||
"camera_action_script": self.camera_script,
|
||||
}
|
||||
extra_params.update(self.extra_params)
|
||||
payload: dict[str, Any] = {
|
||||
"type": "session.start",
|
||||
"model": model,
|
||||
"prompt": self.prompt,
|
||||
"image_reference": {"image_url": self.image_reference},
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"num_frames": self.num_frames,
|
||||
"fps": self.fps,
|
||||
"seed": self.seed,
|
||||
"extra_params": extra_params,
|
||||
}
|
||||
if self.negative_prompt is not None:
|
||||
payload["negative_prompt"] = self.negative_prompt
|
||||
return payload
|
||||
|
||||
def describe(self) -> dict[str, Any]:
|
||||
return {
|
||||
"num_chunks": self.num_chunks,
|
||||
"num_frames": self.num_frames,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"fps": self.fps,
|
||||
"seed": self.seed,
|
||||
"flow_shift": self.flow_shift,
|
||||
"video_seconds": self.video_seconds,
|
||||
"prompt_updates": [
|
||||
{
|
||||
"after_chunk": update.after_chunk,
|
||||
"transition_chunks": update.transition_chunks,
|
||||
}
|
||||
for update in self.prompt_updates
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def default_workload(
|
||||
*,
|
||||
image_reference: str,
|
||||
num_chunks: int,
|
||||
camera_pattern: str = "forward",
|
||||
prompt: str = DEFAULT_PROMPT,
|
||||
width: int = DEFAULT_WIDTH,
|
||||
height: int = DEFAULT_HEIGHT,
|
||||
fps: int = DEFAULT_FPS,
|
||||
seed: int = DEFAULT_SEED,
|
||||
flow_shift: float = DEFAULT_FLOW_SHIFT,
|
||||
negative_prompt: str | None = None,
|
||||
) -> Workload:
|
||||
"""Built-in rollout: a fixed prompt and a generated camera script."""
|
||||
return Workload(
|
||||
prompt=prompt,
|
||||
image_reference=image_reference,
|
||||
camera_script=build_camera_script(num_chunks, camera_pattern),
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
seed=seed,
|
||||
flow_shift=flow_shift,
|
||||
negative_prompt=negative_prompt,
|
||||
)
|
||||
|
||||
|
||||
def load_workload(
|
||||
path: str | Path,
|
||||
*,
|
||||
image_resolver: Any = None,
|
||||
overrides: Mapping[str, Any] | None = None,
|
||||
) -> Workload:
|
||||
"""Read a rollout spec from JSON.
|
||||
|
||||
``image_resolver`` turns the spec's ``image`` field into an ``http(s)`` or
|
||||
``data:`` URL; the caller owns that because inlining a local file is I/O.
|
||||
Keys in ``overrides`` that are not ``None`` win over the file, so CLI flags
|
||||
stay meaningful next to ``--workload``.
|
||||
"""
|
||||
document = json.loads(Path(path).read_text())
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("A workload file must contain a JSON object.")
|
||||
|
||||
merged = dict(document)
|
||||
for key, value in (overrides or {}).items():
|
||||
if value is not None:
|
||||
merged[key] = value
|
||||
|
||||
camera_script = merged.get("camera_action_script")
|
||||
if camera_script is None:
|
||||
num_chunks = merged.get("num_chunks")
|
||||
if num_chunks is None:
|
||||
raise ValueError("A workload file needs either camera_action_script or num_chunks.")
|
||||
camera_script = build_camera_script(int(num_chunks), str(merged.get("camera_pattern", "forward")))
|
||||
|
||||
image = merged.get("image") or merged.get("image_reference")
|
||||
if image is None:
|
||||
raise ValueError("A workload file needs an image or image_reference field.")
|
||||
image_reference = image_resolver(str(image)) if image_resolver is not None else str(image)
|
||||
|
||||
updates_field = merged.get("prompt_updates") or ()
|
||||
prompt_updates = tuple(
|
||||
PromptUpdate(
|
||||
after_chunk=int(entry["after_chunk"]),
|
||||
prompt=str(entry["prompt"]),
|
||||
transition_chunks=int(entry.get("transition_chunks", 3)),
|
||||
)
|
||||
for entry in updates_field
|
||||
)
|
||||
|
||||
extra_params = merged.get("extra_params") or {}
|
||||
if not isinstance(extra_params, Mapping):
|
||||
raise ValueError("Workload extra_params must be a JSON object.")
|
||||
|
||||
return Workload(
|
||||
prompt=str(merged.get("prompt", DEFAULT_PROMPT)),
|
||||
image_reference=image_reference,
|
||||
camera_script=camera_script,
|
||||
width=int(merged.get("width", DEFAULT_WIDTH)),
|
||||
height=int(merged.get("height", DEFAULT_HEIGHT)),
|
||||
fps=int(merged.get("fps", DEFAULT_FPS)),
|
||||
seed=int(merged.get("seed", DEFAULT_SEED)),
|
||||
flow_shift=float(merged.get("flow_shift", DEFAULT_FLOW_SHIFT)),
|
||||
negative_prompt=merged.get("negative_prompt"),
|
||||
prompt_updates=prompt_updates,
|
||||
extra_params=dict(extra_params),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Metrics
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkRecord:
|
||||
"""One media chunk as the client observed it."""
|
||||
|
||||
index: int
|
||||
arrival_s: float
|
||||
"""Seconds from ``session.start`` being sent to this chunk's bytes arriving."""
|
||||
inter_arrival_s: float
|
||||
"""Seconds since the previous chunk; for chunk zero this equals ``arrival_s``."""
|
||||
byte_length: int
|
||||
num_frames: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"index": self.index,
|
||||
"arrival_s": self.arrival_s,
|
||||
"inter_arrival_ms": self.inter_arrival_s * 1000.0,
|
||||
"byte_length": self.byte_length,
|
||||
"num_frames": self.num_frames,
|
||||
}
|
||||
|
||||
|
||||
def percentile(values: Sequence[float], fraction: float) -> float:
|
||||
"""Linearly interpolated percentile, so a 10-sample run still reports a p95.
|
||||
|
||||
Implemented here rather than pulled from numpy to keep this module importable
|
||||
in a bare environment; the benchmark's own numbers must not depend on which
|
||||
optional package happens to be installed.
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("percentile() needs at least one value.")
|
||||
if not 0.0 <= fraction <= 1.0:
|
||||
raise ValueError("percentile fraction must be within [0, 1].")
|
||||
ordered = sorted(values)
|
||||
if len(ordered) == 1:
|
||||
return ordered[0]
|
||||
position = fraction * (len(ordered) - 1)
|
||||
lower = int(position)
|
||||
upper = min(lower + 1, len(ordered) - 1)
|
||||
weight = position - lower
|
||||
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
||||
|
||||
|
||||
def _stddev(values: Sequence[float]) -> float:
|
||||
if len(values) < 2:
|
||||
return 0.0
|
||||
mean = sum(values) / len(values)
|
||||
return (sum((value - mean) ** 2 for value in values) / (len(values) - 1)) ** 0.5
|
||||
|
||||
|
||||
def _latency_summary(values: Sequence[float]) -> dict[str, float]:
|
||||
"""Millisecond summary of a set of inter-arrival intervals."""
|
||||
if not values:
|
||||
return {}
|
||||
milliseconds = [value * 1000.0 for value in values]
|
||||
return {
|
||||
"count": float(len(milliseconds)),
|
||||
"mean_ms": sum(milliseconds) / len(milliseconds),
|
||||
"median_ms": percentile(milliseconds, 0.5),
|
||||
"std_ms": _stddev(milliseconds),
|
||||
"min_ms": min(milliseconds),
|
||||
"p90_ms": percentile(milliseconds, 0.90),
|
||||
"p95_ms": percentile(milliseconds, 0.95),
|
||||
"p99_ms": percentile(milliseconds, 0.99),
|
||||
"max_ms": max(milliseconds),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlaybackResult:
|
||||
"""What a viewer would have seen, given a start buffer and a playback rate."""
|
||||
|
||||
underrun_count: int
|
||||
total_stall_s: float
|
||||
playback_span_s: float
|
||||
first_underrun_chunk: int | None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"underrun_count": self.underrun_count,
|
||||
"total_stall_ms": self.total_stall_s * 1000.0,
|
||||
"playback_span_s": self.playback_span_s,
|
||||
"stall_ratio": (self.total_stall_s / self.playback_span_s) if self.playback_span_s > 0 else 0.0,
|
||||
"first_underrun_chunk": self.first_underrun_chunk,
|
||||
}
|
||||
|
||||
|
||||
def simulate_playback(
|
||||
records: Sequence[ChunkRecord],
|
||||
*,
|
||||
fps: float,
|
||||
buffer_chunks: int = 1,
|
||||
) -> PlaybackResult:
|
||||
"""Replay arrivals against a wall clock and count stalls.
|
||||
|
||||
Playback starts when ``buffer_chunks`` chunks have arrived and then consumes
|
||||
video at ``fps``. A chunk that has not arrived by the moment the player needs
|
||||
it is an underrun, and the player resumes only when the bytes land -- which
|
||||
is what a viewer experiences, and what a single p99 number cannot tell you:
|
||||
the same p99 is harmless behind a three-chunk buffer and fatal behind none.
|
||||
"""
|
||||
if fps <= 0:
|
||||
raise ValueError("fps must be positive.")
|
||||
if buffer_chunks < 1:
|
||||
raise ValueError("buffer_chunks must be at least 1.")
|
||||
if not records:
|
||||
return PlaybackResult(0, 0.0, 0.0, None)
|
||||
if buffer_chunks > len(records):
|
||||
raise ValueError(f"buffer_chunks={buffer_chunks} exceeds the {len(records)} chunks received.")
|
||||
|
||||
ordered = sorted(records, key=lambda record: record.index)
|
||||
start = ordered[buffer_chunks - 1].arrival_s
|
||||
cursor = start
|
||||
underruns = 0
|
||||
total_stall = 0.0
|
||||
first_underrun: int | None = None
|
||||
|
||||
for record in ordered:
|
||||
if record.arrival_s > cursor:
|
||||
stall = record.arrival_s - cursor
|
||||
underruns += 1
|
||||
total_stall += stall
|
||||
if first_underrun is None:
|
||||
first_underrun = record.index
|
||||
cursor = record.arrival_s
|
||||
cursor += record.num_frames / fps
|
||||
|
||||
return PlaybackResult(
|
||||
underrun_count=underruns,
|
||||
total_stall_s=total_stall,
|
||||
playback_span_s=cursor - start,
|
||||
first_underrun_chunk=first_underrun,
|
||||
)
|
||||
|
||||
|
||||
def steady_chunk_deadline_ms(fps: float) -> float:
|
||||
"""Wall-clock budget a steady chunk must meet to hold real time.
|
||||
|
||||
``fps`` here is the rate the frames are *intended to be played at*, which the
|
||||
checkpoint does not declare: nothing in ``model_index.json``, the scheduler
|
||||
config, or the transformer config carries a frame rate, and the pipeline
|
||||
never reads one. It is a property of how the output is consumed, so both
|
||||
this deadline and real-time factor scale linearly with the choice and the
|
||||
caller has to state it rather than inherit it from a muxing default.
|
||||
"""
|
||||
if fps <= 0:
|
||||
raise ValueError("fps must be positive.")
|
||||
return pixel_frames_in_chunk(1) / fps * 1000.0
|
||||
|
||||
|
||||
def compute_metrics(
|
||||
records: Sequence[ChunkRecord],
|
||||
*,
|
||||
fps: float,
|
||||
warmup_chunks: int = DEFAULT_WARMUP_CHUNKS,
|
||||
slo_ms: float | None = None,
|
||||
playback_buffer_chunks: int = 1,
|
||||
ttfc_s: float | None = None,
|
||||
session_wall_s: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize one session.
|
||||
|
||||
``warmup_chunks`` are excluded from the steady-state block because the
|
||||
attention window has not saturated before then (see the module docstring).
|
||||
The terminal chunk also stays out of steady metrics because it skips
|
||||
next-chunk preparation. All chunks stay in the playback simulation, which is
|
||||
what a session really costs end to end.
|
||||
"""
|
||||
if not records:
|
||||
raise ValueError("compute_metrics() needs at least one chunk record.")
|
||||
if warmup_chunks < 0:
|
||||
raise ValueError("warmup_chunks must be non-negative.")
|
||||
|
||||
ordered = sorted(records, key=lambda record: record.index)
|
||||
deadline_ms = slo_ms if slo_ms is not None else steady_chunk_deadline_ms(fps)
|
||||
|
||||
all_intervals = [record.inter_arrival_s for record in ordered[1:]]
|
||||
# The terminal chunk skips next-chunk preparation and is not representative
|
||||
# of sustained generation. Keep it in end-to-end and playback metrics.
|
||||
steady = [record for record in ordered[:-1] if record.index >= warmup_chunks]
|
||||
steady_intervals = [record.inter_arrival_s for record in steady if record.index > 0]
|
||||
|
||||
total_frames = sum(record.num_frames for record in ordered)
|
||||
video_seconds = total_frames / fps
|
||||
wall_seconds = session_wall_s if session_wall_s is not None else ordered[-1].arrival_s
|
||||
|
||||
metrics: dict[str, Any] = {
|
||||
"chunks_received": len(ordered),
|
||||
"warmup_chunks": warmup_chunks,
|
||||
"steady_chunks": len(steady_intervals),
|
||||
"steady_state_reached": bool(steady_intervals),
|
||||
"total_frames": total_frames,
|
||||
"total_bytes": sum(record.byte_length for record in ordered),
|
||||
"video_seconds": video_seconds,
|
||||
"wall_seconds": wall_seconds,
|
||||
# RTF is processing time over content duration, as vLLM-Omni computes it
|
||||
# (`patch.py`: generation_s / video_duration; `compute_audio_rtf`: latency
|
||||
# / audio_duration, "SLO red line < 1"). LOWER IS BETTER and below 1.0 is
|
||||
# real time. RTFX is the reciprocal the repo reports under its own name,
|
||||
# kept here because "fraction of real time achieved" reads better on a
|
||||
# dashboard -- but the two must never be confused for one another.
|
||||
"video_rtf": (wall_seconds / video_seconds) if video_seconds > 0 else 0.0,
|
||||
"video_rtfx": (video_seconds / wall_seconds) if wall_seconds > 0 else 0.0,
|
||||
"frames_per_second": (total_frames / wall_seconds) if wall_seconds > 0 else 0.0,
|
||||
"chunk_deadline_ms": deadline_ms,
|
||||
"interval_all": _latency_summary(all_intervals),
|
||||
"interval_steady": _latency_summary(steady_intervals),
|
||||
}
|
||||
if ttfc_s is not None:
|
||||
metrics["ttfc_ms"] = ttfc_s * 1000.0
|
||||
|
||||
if steady_intervals:
|
||||
steady_frames = sum(record.num_frames for record in steady if record.index > 0)
|
||||
steady_wall = sum(steady_intervals)
|
||||
steady_video = steady_frames / fps
|
||||
metrics["steady_video_rtf"] = (steady_wall / steady_video) if steady_video > 0 else 0.0
|
||||
metrics["steady_video_rtfx"] = (steady_video / steady_wall) if steady_wall > 0 else 0.0
|
||||
within = sum(1 for value in steady_intervals if value * 1000.0 <= deadline_ms)
|
||||
metrics["slo_attainment"] = within / len(steady_intervals)
|
||||
metrics["slo_violations"] = len(steady_intervals) - within
|
||||
|
||||
metrics["playback"] = simulate_playback(
|
||||
ordered,
|
||||
fps=fps,
|
||||
buffer_chunks=playback_buffer_chunks,
|
||||
).to_dict()
|
||||
metrics["playback"]["buffer_chunks"] = playback_buffer_chunks
|
||||
return metrics
|
||||
|
||||
|
||||
def aggregate_metrics(sessions: Iterable[Mapping[str, Any]], *, fps: float) -> dict[str, Any]:
|
||||
"""Combine per-session metrics across sequential repeats.
|
||||
|
||||
Steady intervals are pooled rather than averaging per-session means, so a
|
||||
session that happened to produce fewer steady chunks does not carry the same
|
||||
weight as a full one.
|
||||
"""
|
||||
entries = list(sessions)
|
||||
if not entries:
|
||||
raise ValueError("aggregate_metrics() needs at least one session.")
|
||||
if len(entries) == 1:
|
||||
single = dict(entries[0])
|
||||
single["sessions"] = 1
|
||||
return single
|
||||
|
||||
deadline_ms = entries[0]["chunk_deadline_ms"]
|
||||
if any(entry["chunk_deadline_ms"] != deadline_ms for entry in entries):
|
||||
raise ValueError("Cannot aggregate sessions with different chunk deadlines.")
|
||||
ttfcs = [float(entry["ttfc_ms"]) for entry in entries if entry.get("ttfc_ms") is not None]
|
||||
# Lower is better, so "max" is the worst session, not the best.
|
||||
rtfs = [float(entry["video_rtf"]) for entry in entries]
|
||||
steady_means = [
|
||||
float(entry["interval_steady"]["mean_ms"]) for entry in entries if entry.get("interval_steady", {}).get("count")
|
||||
]
|
||||
steady_count = sum(entry.get("interval_steady", {}).get("count", 0) for entry in entries)
|
||||
steady_total_ms = sum(
|
||||
entry["interval_steady"]["mean_ms"] * entry["interval_steady"]["count"]
|
||||
for entry in entries
|
||||
if entry.get("interval_steady", {}).get("count")
|
||||
)
|
||||
return {
|
||||
"sessions": len(entries),
|
||||
"chunks_received": sum(int(entry["chunks_received"]) for entry in entries),
|
||||
"total_frames": sum(int(entry["total_frames"]) for entry in entries),
|
||||
"video_seconds": sum(float(entry["video_seconds"]) for entry in entries),
|
||||
"wall_seconds": sum(float(entry["wall_seconds"]) for entry in entries),
|
||||
"video_rtf_mean": sum(rtfs) / len(rtfs),
|
||||
"video_rtf_best": min(rtfs),
|
||||
"video_rtf_worst": max(rtfs),
|
||||
"ttfc_ms_mean": (sum(ttfcs) / len(ttfcs)) if ttfcs else None,
|
||||
"ttfc_ms_max": max(ttfcs) if ttfcs else None,
|
||||
"steady_interval_ms_mean": (steady_total_ms / steady_count) if steady_count else None,
|
||||
"steady_interval_ms_spread": (max(steady_means) - min(steady_means)) if steady_means else None,
|
||||
"underrun_count": sum(int(entry["playback"]["underrun_count"]) for entry in entries),
|
||||
"chunk_deadline_ms": deadline_ms,
|
||||
}
|
||||
@@ -177,10 +177,10 @@ Failed jobs: 1/2
|
||||
cd tests
|
||||
|
||||
# Stability: Qwen3-Omni
|
||||
pytest -s -v dfx/stability/scripts/test_stability_qwen3_omni.py -m slow
|
||||
pytest -s -v dfx/stability/scripts/run_stability_qwen3_omni.py -m slow
|
||||
|
||||
# Stability: Wan2.2 (v1/videos diffusion benchmark loop)
|
||||
pytest -s -v dfx/stability/scripts/test_stability_wan22.py -m slow
|
||||
pytest -s -v dfx/stability/scripts/run_stability_wan22.py -m slow
|
||||
|
||||
# Reliability: Qwen3-Omni (H100 × 2)
|
||||
pytest -s -v dfx/reliability/test_reliability_qwen3_omni.py -m slow
|
||||
|
||||
@@ -555,7 +555,7 @@ L5 level testing focuses on the performance of model services under ***long-runn
|
||||
|
||||
- ***Trigger Timing***: **`Weekly`** (weekly) or **`Days before Release`** (several days before a major release). Due to long execution times, the frequency is lower.
|
||||
- ***Run Command***:
|
||||
- ***Stability***: `pytest -s -v tests/dfx/stability/scripts/test_stability_qwen3_omni.py` or `pytest -s -v tests/dfx/stability/scripts/test_stability_wan22.py` (or add `test_stability_<model>.py` alongside a matching JSON config)
|
||||
- ***Stability***: `pytest -s -v tests/dfx/stability/scripts/run_stability_qwen3_omni.py` or `pytest -s -v tests/dfx/stability/scripts/run_stability_wan22.py` (or add `run_stability_<model>.py` alongside a matching JSON config)
|
||||
- ***Reliability***: `pytest -s -v tests/dfx/reliability/test_reliability_<model>.py -m slow` (current suites: `qwen3_omni`, `wan22`, `hunyuan_image`).
|
||||
- ***Script Example***:
|
||||
|
||||
@@ -564,7 +564,7 @@ L5 level testing focuses on the performance of model services under ***long-runn
|
||||
|
||||
##### Stability
|
||||
|
||||
When you want to add L5-level stability test cases, add or extend the appropriate JSON file under `tests/dfx/stability/tests/` (for example `test_qwen3_omni.json` for Omni bench traffic, or `test_wan22.json` for diffusion `/v1/videos` workloads). Pair the JSON with `tests/dfx/stability/scripts/test_stability_<model>.py`. The following illustrates the Qwen3-Omni shape:
|
||||
When you want to add L5-level stability test cases, add or extend the appropriate JSON file under `tests/dfx/stability/tests/` (for example `test_qwen3_omni.json` for Omni bench traffic, or `test_wan22.json` for diffusion `/v1/videos` workloads). Pair the JSON with `tests/dfx/stability/scripts/run_stability_<model>.py`. The following illustrates the Qwen3-Omni shape:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -213,6 +213,47 @@ accepts:
|
||||
`s`, `d`, `i`, `j`, `k`, and `l`;
|
||||
- `lingbot.camera_trajectory.v1` for explicit pose/intrinsics trajectories.
|
||||
|
||||
## Online FP8 linear layers (experimental)
|
||||
|
||||
Pass `quantization_config={"method": "fp8"}` to the existing `AsyncOmni`
|
||||
construction to use vLLM's online FP8 linear implementation. On the tested
|
||||
Hopper-class GPU this dispatches to CUTLASS FP8 GEMM with online per-tensor
|
||||
activation scaling; no LingBot-specific quantization kernel is introduced.
|
||||
|
||||
Only the transformer's existing vLLM parallel linear layers are eligible:
|
||||
self-attention, cross-attention, FFN, camera injectors, and C2WS projections.
|
||||
The ordinary PyTorch linear layers (including the output head and
|
||||
time/text embeddings), normalization, convolutions, VAE, text encoder, and
|
||||
AR KV cache are not converted to FP8 by this option.
|
||||
|
||||
Quality-sensitive projections can remain BF16 through fully qualified
|
||||
`ignored_layers` names. For example, the following configuration tests
|
||||
retaining the camera/C2WS path in BF16:
|
||||
|
||||
```python
|
||||
quantization_config = {
|
||||
"method": "fp8",
|
||||
"ignored_layers": [
|
||||
f"transformer.blocks.{i}.cam_injector_layer{j}"
|
||||
for i in range(40)
|
||||
for j in (1, 2)
|
||||
]
|
||||
+ [
|
||||
"transformer.c2ws_hidden_states_layer1",
|
||||
"transformer.c2ws_hidden_states_layer2",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Use `transformer.blocks.<i>.self_attn.qkv` to exclude the fused Q/K/V
|
||||
projection as a unit. The example exclusion list is an ablation, not a
|
||||
universal quality guarantee or a default. Validate the intended scene, seed,
|
||||
camera trajectory, and session length against BF16 before selecting a policy.
|
||||
An FP8 kernel speedup alone is not an end-to-end performance claim.
|
||||
The long-session E8 quality gate in
|
||||
[#7074](https://github.com/vllm-project/vllm-omni/issues/7074) remains required
|
||||
for Tier-2 acceptance.
|
||||
|
||||
## Validation
|
||||
|
||||
Real-checkpoint validation uses 480x832 output, four DMD steps, and seed 42.
|
||||
@@ -246,7 +287,7 @@ tested commit.
|
||||
AR blocks in one request. `max_num_seqs` must be one in both cases.
|
||||
- Stateful streaming VAE decode is not implemented; the realtime example emits
|
||||
latent chunks.
|
||||
- SP/USP, pipeline/CFG parallelism, HSDP, VAE parallelism, quantization,
|
||||
- SP/USP, pipeline/CFG parallelism, HSDP, VAE parallelism, quantization methods other than online FP8,
|
||||
Cache-DiT, TeaCache, causal-pretrain, and the 1.3B checkpoint are not claimed.
|
||||
- No AMD GPU, Ascend NPU, or Intel GPU support is claimed.
|
||||
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for the LingBot-World realtime streaming benchmark.
|
||||
|
||||
The arithmetic is tested against the server's own formulas rather than against
|
||||
hand-copied expectations, and the protocol handling runs against a real
|
||||
WebSocket server that replays ``/v1/realtime/video`` with scripted delays -- so
|
||||
a benchmark number can be wrong only if the server itself is, not because the
|
||||
client parsed incorrectly an event or timed the wrong edge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ruff: noqa: E402, I001
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from benchmarks.lingbot_world.benchmark_lingbot_world_realtime import (
|
||||
BenchmarkError,
|
||||
build_workload,
|
||||
print_report,
|
||||
endpoint_url,
|
||||
parse_args,
|
||||
resolve_image_reference,
|
||||
run_session,
|
||||
)
|
||||
from benchmarks.lingbot_world.workload import (
|
||||
DEFAULT_WARMUP_CHUNKS,
|
||||
FRAMES_PER_BLOCK,
|
||||
ChunkRecord,
|
||||
PromptUpdate,
|
||||
Workload,
|
||||
aggregate_metrics,
|
||||
build_camera_script,
|
||||
chunks_for_num_frames,
|
||||
compute_metrics,
|
||||
default_workload,
|
||||
load_workload,
|
||||
num_frames_for_chunks,
|
||||
percentile,
|
||||
pixel_frames_in_chunk,
|
||||
simulate_playback,
|
||||
steady_chunk_deadline_ms,
|
||||
)
|
||||
|
||||
_IMAGE = "data:image/png;base64,Zm9v"
|
||||
|
||||
|
||||
def _workload(num_chunks: int = 8, fps: int = 16) -> Workload:
|
||||
return default_workload(image_reference=_IMAGE, num_chunks=num_chunks, fps=fps)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Chunk and frame arithmetic
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_chunks", list(range(1, 33)))
|
||||
def test_frame_count_round_trips_through_the_server_formula(num_chunks: int) -> None:
|
||||
"""``num_frames`` must land on a rollout of exactly the requested chunks.
|
||||
|
||||
The server derives chunk count as ``((num_frames - 1) // 4 + 1) // 3``; if
|
||||
the benchmark's inverse is off by one frame the run silently generates a
|
||||
different number of blocks than it reports.
|
||||
"""
|
||||
assert chunks_for_num_frames(num_frames_for_chunks(num_chunks)) == num_chunks
|
||||
|
||||
|
||||
def test_recipe_frame_counts_match_the_documented_chunk_counts() -> None:
|
||||
# The recipe states three chunks for num_frames 33 and seven for 81.
|
||||
assert chunks_for_num_frames(33) == 3
|
||||
assert chunks_for_num_frames(81) == 7
|
||||
assert num_frames_for_chunks(3) == 33
|
||||
assert num_frames_for_chunks(7) == 81
|
||||
|
||||
|
||||
def test_first_chunk_is_short_because_the_decoder_expands_it_once() -> None:
|
||||
assert pixel_frames_in_chunk(0) == 9
|
||||
assert pixel_frames_in_chunk(1) == 12
|
||||
assert pixel_frames_in_chunk(7) == 12
|
||||
# A whole rollout's frames are the sum of its chunks' frames.
|
||||
assert sum(pixel_frames_in_chunk(index) for index in range(5)) == num_frames_for_chunks(5)
|
||||
|
||||
|
||||
def test_camera_script_holds_one_three_entry_action_list_per_chunk() -> None:
|
||||
script = build_camera_script(4, "orbit")
|
||||
assert len(script) == 4
|
||||
assert all(len(actions) == FRAMES_PER_BLOCK for actions in script)
|
||||
# orbit advances through the action keys so successive chunks differ.
|
||||
assert script[0] != script[1]
|
||||
|
||||
|
||||
def test_unknown_camera_pattern_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="Unknown camera pattern"):
|
||||
build_camera_script(2, "sideways")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Workload
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_session_start_payload_matches_the_documented_protocol() -> None:
|
||||
workload = _workload(num_chunks=3)
|
||||
payload = workload.session_start_payload("robbyant/lingbot-world-v2-14b-causal-fast-diffusers")
|
||||
assert payload["type"] == "session.start"
|
||||
assert payload["num_frames"] == 33
|
||||
assert payload["image_reference"] == {"image_url": _IMAGE}
|
||||
assert payload["extra_params"]["flow_shift"] == 5.0
|
||||
assert len(payload["extra_params"]["camera_action_script"]) == 3
|
||||
|
||||
|
||||
def test_workload_rejects_a_camera_script_with_the_wrong_block_width() -> None:
|
||||
with pytest.raises(ValueError, match="exactly 3 action lists"):
|
||||
Workload(prompt="p", image_reference=_IMAGE, camera_script=[[["w"], ["w"]]])
|
||||
|
||||
|
||||
def test_workload_rejects_geometry_the_cache_cannot_serve() -> None:
|
||||
with pytest.raises(ValueError, match="multiples of 16"):
|
||||
Workload(prompt="p", image_reference=_IMAGE, camera_script=build_camera_script(1, "forward"), width=831)
|
||||
|
||||
|
||||
def test_prompt_update_beyond_the_rollout_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="never fires"):
|
||||
Workload(
|
||||
prompt="p",
|
||||
image_reference=_IMAGE,
|
||||
camera_script=build_camera_script(2, "forward"),
|
||||
prompt_updates=(PromptUpdate(after_chunk=5, prompt="later"),),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("from_file", [False, True])
|
||||
def test_duplicate_prompt_update_boundaries_are_rejected(tmp_path: Path, from_file: bool) -> None:
|
||||
updates = [
|
||||
{"after_chunk": 1, "prompt": "Rain begins"},
|
||||
{"after_chunk": 0, "prompt": "Clouds gather"},
|
||||
{"after_chunk": 1, "prompt": "Rain stops"},
|
||||
]
|
||||
with pytest.raises(ValueError, match="Duplicate prompt update after chunk 1"):
|
||||
if from_file:
|
||||
path = tmp_path / "rollout.json"
|
||||
path.write_text(json.dumps({"image": _IMAGE, "num_chunks": 3, "prompt_updates": updates}))
|
||||
build_workload(parse_args(["--workload", str(path)]))
|
||||
else:
|
||||
Workload(
|
||||
prompt="p",
|
||||
image_reference=_IMAGE,
|
||||
camera_script=build_camera_script(3, "forward"),
|
||||
prompt_updates=(
|
||||
PromptUpdate(after_chunk=1, prompt="Rain begins"),
|
||||
PromptUpdate(after_chunk=0, prompt="Clouds gather"),
|
||||
PromptUpdate(after_chunk=1, prompt="Rain stops"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_workload_file_is_loaded_and_cli_overrides_win(tmp_path: Path) -> None:
|
||||
path = tmp_path / "rollout.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"prompt": "A quiet street",
|
||||
"image": "https://example.invalid/frame.png",
|
||||
"num_chunks": 4,
|
||||
"camera_pattern": "hold",
|
||||
"fps": 12,
|
||||
"seed": 7,
|
||||
"prompt_updates": [{"after_chunk": 1, "prompt": "Rain begins", "transition_chunks": 2}],
|
||||
}
|
||||
)
|
||||
)
|
||||
workload = load_workload(path, overrides={"fps": 24, "seed": None})
|
||||
assert workload.prompt == "A quiet street"
|
||||
assert workload.num_chunks == 4
|
||||
assert workload.fps == 24, "an explicit CLI value must beat the file"
|
||||
assert workload.seed == 7, "a None override must leave the file value alone"
|
||||
assert workload.prompt_updates[0].transition_chunks == 2
|
||||
|
||||
|
||||
def test_workload_file_without_a_rollout_length_is_rejected(tmp_path: Path) -> None:
|
||||
path = tmp_path / "rollout.json"
|
||||
path.write_text(json.dumps({"image": "https://example.invalid/frame.png"}))
|
||||
with pytest.raises(ValueError, match="camera_action_script or num_chunks"):
|
||||
load_workload(path)
|
||||
|
||||
|
||||
def test_local_image_is_inlined_as_a_data_url(tmp_path: Path) -> None:
|
||||
image = tmp_path / "frame.png"
|
||||
image.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
reference = resolve_image_reference(str(image))
|
||||
assert reference.startswith("data:image/png;base64,")
|
||||
assert resolve_image_reference("https://example.invalid/a.png") == "https://example.invalid/a.png"
|
||||
with pytest.raises(BenchmarkError, match="neither a URL nor a readable file"):
|
||||
resolve_image_reference(str(tmp_path / "missing.png"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Metric math
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_rtf_is_a_cost_not_a_speed() -> None:
|
||||
"""RTF must match vLLM-Omni's own formula, where lower is better.
|
||||
|
||||
``vllm_omni/metrics/definitions.py::compute_audio_rtf`` is
|
||||
``latency / duration`` with the docstring "SLO red line < 1", and
|
||||
``benchmarks/patch/patch.py`` sets ``video_rtf = generation_s /
|
||||
video_duration``. Emitting the reciprocal under the same field name would
|
||||
silently invert every dashboard that aggregates it, so the direction is
|
||||
pinned here rather than left to a comment.
|
||||
"""
|
||||
# Two chunks, 9 + 12 = 21 frames at 12 fps = 1.75 s of video, generated in 3.5 s.
|
||||
slow = compute_metrics(_records([1.0, 2.5]), fps=12, warmup_chunks=0, session_wall_s=3.5)
|
||||
assert slow["video_seconds"] == pytest.approx(1.75)
|
||||
assert slow["video_rtf"] == pytest.approx(2.0), "twice as slow as real time"
|
||||
assert slow["video_rtfx"] == pytest.approx(0.5)
|
||||
|
||||
fast = compute_metrics(_records([0.4, 0.475]), fps=12, warmup_chunks=0, session_wall_s=0.875)
|
||||
assert fast["video_rtf"] == pytest.approx(0.5), "twice as fast as real time"
|
||||
assert fast["video_rtf"] < 1.0 < slow["video_rtf"]
|
||||
assert fast["video_rtf"] * fast["video_rtfx"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_steady_rtf_is_the_chunk_interval_over_its_deadline() -> None:
|
||||
"""A steady chunk's RTF is just its interval divided by its video duration."""
|
||||
metrics = compute_metrics(_records([1.0] + [1.357] * 15), fps=12, warmup_chunks=6)
|
||||
# 12 frames at 12 fps = 1000 ms of video per steady chunk.
|
||||
assert metrics["chunk_deadline_ms"] == pytest.approx(1000.0)
|
||||
assert metrics["steady_video_rtf"] == pytest.approx(1.357, abs=1e-3)
|
||||
|
||||
|
||||
def test_percentile_interpolates_so_short_runs_still_report_tails() -> None:
|
||||
values = [10.0, 20.0, 30.0, 40.0]
|
||||
assert percentile(values, 0.0) == 10.0
|
||||
assert percentile(values, 1.0) == 40.0
|
||||
assert percentile(values, 0.5) == 25.0
|
||||
assert percentile([5.0], 0.99) == 5.0
|
||||
|
||||
|
||||
def _records(intervals: list[float]) -> list[ChunkRecord]:
|
||||
"""One record per entry; ``intervals[0]`` is the TTFC, the rest are gaps."""
|
||||
records: list[ChunkRecord] = []
|
||||
arrival = 0.0
|
||||
for index, interval in enumerate(intervals):
|
||||
arrival += interval
|
||||
records.append(
|
||||
ChunkRecord(
|
||||
index=index,
|
||||
arrival_s=arrival,
|
||||
inter_arrival_s=interval,
|
||||
byte_length=1000,
|
||||
num_frames=pixel_frames_in_chunk(index),
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def test_steady_state_excludes_the_unsaturated_attention_window() -> None:
|
||||
"""Warmup chunks must not drag the steady-state mean down.
|
||||
|
||||
The first six chunks attend over a growing window, so they are genuinely
|
||||
cheaper. A benchmark that averages them in reports a cadence the session
|
||||
will never sustain.
|
||||
"""
|
||||
intervals = [1.0] + [0.2] * (DEFAULT_WARMUP_CHUNKS - 1) + [0.8] * 4
|
||||
metrics = compute_metrics(_records(intervals), fps=16, warmup_chunks=DEFAULT_WARMUP_CHUNKS)
|
||||
assert metrics["interval_steady"]["count"] == 3
|
||||
assert metrics["interval_steady"]["mean_ms"] == pytest.approx(800.0)
|
||||
assert metrics["interval_all"]["mean_ms"] < metrics["interval_steady"]["mean_ms"]
|
||||
|
||||
|
||||
def test_short_rollouts_report_that_steady_state_was_never_reached() -> None:
|
||||
metrics = compute_metrics(_records([1.0, 0.5, 0.5]), fps=16, warmup_chunks=DEFAULT_WARMUP_CHUNKS)
|
||||
assert metrics["steady_state_reached"] is False
|
||||
assert metrics["interval_steady"] == {}
|
||||
assert "slo_attainment" not in metrics
|
||||
|
||||
|
||||
def test_real_time_factor_and_deadline_use_the_true_frame_counts() -> None:
|
||||
# Eight chunks: 9 + 7*12 = 93 frames at 16 fps = 5.8125 s of video.
|
||||
metrics = compute_metrics(_records([1.0] + [0.5] * 7), fps=16, warmup_chunks=0, session_wall_s=5.8125)
|
||||
assert metrics["total_frames"] == 93
|
||||
assert metrics["video_seconds"] == pytest.approx(5.8125)
|
||||
assert metrics["video_rtf"] == pytest.approx(1.0)
|
||||
assert metrics["video_rtfx"] == pytest.approx(1.0)
|
||||
# A steady chunk carries 12 frames, so real time allows 750 ms at 16 fps.
|
||||
assert metrics["chunk_deadline_ms"] == pytest.approx(750.0)
|
||||
assert steady_chunk_deadline_ms(12) == pytest.approx(1000.0)
|
||||
|
||||
|
||||
def test_slo_attainment_counts_only_steady_chunks() -> None:
|
||||
intervals = [1.0] + [0.1] * 5 + [0.7, 0.9, 0.7, 0.9]
|
||||
metrics = compute_metrics(_records(intervals), fps=16, warmup_chunks=6, slo_ms=750.0)
|
||||
assert metrics["slo_attainment"] == pytest.approx(2 / 3)
|
||||
assert metrics["slo_violations"] == 1
|
||||
|
||||
|
||||
def test_playback_simulation_finds_stalls_a_percentile_would_hide() -> None:
|
||||
"""One late chunk behind a one-chunk buffer is a visible stall."""
|
||||
# 12 frames at 16 fps = 750 ms of playback per chunk; chunk 3 takes 2 s.
|
||||
intervals = [1.0, 0.5, 0.5, 2.0, 0.5]
|
||||
result = simulate_playback(_records(intervals), fps=16, buffer_chunks=1)
|
||||
assert result.underrun_count >= 1
|
||||
assert result.first_underrun_chunk == 3
|
||||
assert result.total_stall_s > 0.0
|
||||
|
||||
|
||||
def test_a_deeper_buffer_absorbs_the_same_jitter() -> None:
|
||||
intervals = [1.0, 0.5, 0.5, 1.4, 0.2, 0.2]
|
||||
fast = simulate_playback(_records(intervals), fps=16, buffer_chunks=1)
|
||||
buffered = simulate_playback(_records(intervals), fps=16, buffer_chunks=3)
|
||||
assert buffered.underrun_count < fast.underrun_count
|
||||
|
||||
|
||||
def test_playback_rejects_a_buffer_deeper_than_the_rollout() -> None:
|
||||
with pytest.raises(ValueError, match="exceeds"):
|
||||
simulate_playback(_records([1.0, 0.5]), fps=16, buffer_chunks=9)
|
||||
|
||||
|
||||
def test_aggregate_pools_sessions_and_reports_spread() -> None:
|
||||
fast = compute_metrics(_records([1.0] + [0.1] * 5 + [0.6] * 4), fps=16, warmup_chunks=6)
|
||||
slow = compute_metrics(_records([1.0] + [0.1] * 5 + [0.9] * 4), fps=16, warmup_chunks=6)
|
||||
aggregate = aggregate_metrics([fast, slow], fps=16)
|
||||
assert aggregate["sessions"] == 2
|
||||
assert aggregate["steady_interval_ms_mean"] == pytest.approx(750.0)
|
||||
assert aggregate["steady_interval_ms_spread"] == pytest.approx(300.0)
|
||||
assert aggregate["video_rtf_best"] <= aggregate["video_rtf_mean"] <= aggregate["video_rtf_worst"]
|
||||
|
||||
|
||||
def test_single_session_aggregate_is_the_session_itself() -> None:
|
||||
metrics = compute_metrics(_records([1.0] + [0.5] * 3), fps=16, warmup_chunks=0)
|
||||
aggregate = aggregate_metrics([metrics], fps=16)
|
||||
assert aggregate["sessions"] == 1
|
||||
assert aggregate["video_rtf"] == metrics["video_rtf"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_endpoint_url_normalizes_http_and_appends_the_ws_path() -> None:
|
||||
assert endpoint_url(parse_args([])) == "ws://127.0.0.1:8000/v1/realtime/video"
|
||||
assert endpoint_url(parse_args(["--host", "h", "--port", "9"])) == "ws://h:9/v1/realtime/video"
|
||||
args = parse_args(["--base-url", "http://example.invalid:8099"])
|
||||
assert endpoint_url(args) == "ws://example.invalid:8099/v1/realtime/video"
|
||||
args = parse_args(["--base-url", "ws://example.invalid:8099/v1/realtime/video"])
|
||||
assert endpoint_url(args) == "ws://example.invalid:8099/v1/realtime/video"
|
||||
|
||||
|
||||
def test_cli_rejects_a_zero_chunk_buffer() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
parse_args(["--playback-buffer-chunks", "0"])
|
||||
with pytest.raises(SystemExit):
|
||||
parse_args(["--target-fps", "0"])
|
||||
|
||||
|
||||
def test_the_real_time_basis_is_independent_of_the_mux_label() -> None:
|
||||
"""The checkpoint declares no frame rate, so the basis must be statable.
|
||||
|
||||
The same measured cadence has RTF 1.809 at 16 fps and 1.357 at 12 fps;
|
||||
tying that verdict to the fps sent to the muxer would let a playback label
|
||||
silently decide whether a run passes.
|
||||
"""
|
||||
records = _records([1.0] + [1.357] * 15)
|
||||
at_16 = compute_metrics(records, fps=16, warmup_chunks=6)
|
||||
at_12 = compute_metrics(records, fps=12, warmup_chunks=6)
|
||||
assert at_16["chunk_deadline_ms"] == pytest.approx(750.0)
|
||||
assert at_12["chunk_deadline_ms"] == pytest.approx(1000.0)
|
||||
# RTF is cost, so the harsher 16 fps basis scores WORSE (higher).
|
||||
assert at_16["video_rtf"] > at_12["video_rtf"]
|
||||
# Same wall clock, same bytes: only the basis moved.
|
||||
assert at_16["video_rtf"] / at_12["video_rtf"] == pytest.approx(16 / 12)
|
||||
assert parse_args([]).target_fps is None, "default defers to the workload fps"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Protocol handling against a scripted server
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _serve(script):
|
||||
"""Return an async context manager yielding a ws:// URL for ``script``."""
|
||||
from websockets.asyncio.server import Server, serve
|
||||
|
||||
class _Server:
|
||||
def __init__(self) -> None:
|
||||
self.server: Server | None = None
|
||||
self.start_payloads: list[dict] = []
|
||||
|
||||
async def __aenter__(self) -> str:
|
||||
async def handler(websocket) -> None:
|
||||
self.start_payloads.append(json.loads(await websocket.recv()))
|
||||
await script(websocket, self.start_payloads[-1])
|
||||
|
||||
self.server = await serve(handler, "127.0.0.1", 0)
|
||||
port = next(iter(self.server.sockets)).getsockname()[1]
|
||||
return f"ws://127.0.0.1:{port}/v1/realtime/video"
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
assert self.server is not None
|
||||
self.server.close()
|
||||
await self.server.wait_closed()
|
||||
|
||||
return _Server()
|
||||
|
||||
|
||||
async def _emit_rollout(websocket, payload, *, intervals=None, chunks=None, trailer=True) -> None:
|
||||
"""Replay the documented event order: start, metadata+binary per chunk, done."""
|
||||
num_chunks = chunks if chunks is not None else len(payload["extra_params"]["camera_action_script"])
|
||||
await websocket.send(json.dumps({"type": "video.start", "request_id": "req-1", "format": "m4s"}))
|
||||
for index in range(num_chunks):
|
||||
if intervals is not None:
|
||||
await asyncio.sleep(intervals[index])
|
||||
frames = pixel_frames_in_chunk(index)
|
||||
body = bytes([index % 256]) * (100 + index)
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "video.chunk_metadata",
|
||||
"request_id": "req-1",
|
||||
"kind": "media",
|
||||
"num_frames": frames,
|
||||
"byte_length": len(body),
|
||||
"generation_chunk_index": index,
|
||||
}
|
||||
)
|
||||
)
|
||||
await websocket.send(body)
|
||||
if trailer:
|
||||
tail = b"\x00" * 8
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "video.chunk_metadata",
|
||||
"request_id": "req-1",
|
||||
"kind": "trailer",
|
||||
"num_frames": 0,
|
||||
"byte_length": len(tail),
|
||||
"generation_chunk_index": None,
|
||||
}
|
||||
)
|
||||
)
|
||||
await websocket.send(tail)
|
||||
await websocket.send(json.dumps({"type": "session.done", "stopped": False, "chunks": num_chunks}))
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout=30))
|
||||
|
||||
|
||||
def test_session_records_one_chunk_per_block_and_ignores_the_trailer() -> None:
|
||||
workload = _workload(num_chunks=4)
|
||||
|
||||
async def scenario() -> None:
|
||||
server = _serve(_emit_rollout)
|
||||
url = await server.__aenter__()
|
||||
try:
|
||||
result = await run_session(
|
||||
url,
|
||||
"model",
|
||||
workload,
|
||||
first_chunk_timeout=10,
|
||||
chunk_timeout=10,
|
||||
ping_interval=0,
|
||||
collect_media=True,
|
||||
print_chunks=False,
|
||||
)
|
||||
finally:
|
||||
await server.__aexit__(None, None, None)
|
||||
|
||||
assert [record.index for record in result.records] == [0, 1, 2, 3]
|
||||
assert [record.num_frames for record in result.records] == [9, 12, 12, 12]
|
||||
assert result.request_id == "req-1"
|
||||
assert result.ttfc_s is not None and result.ttfc_s > 0
|
||||
# The trailer's bytes are kept for remuxing but carry no chunk record.
|
||||
assert len(result.media) == sum(record.byte_length for record in result.records) + 8
|
||||
assert server.start_payloads[0]["num_frames"] == workload.num_frames
|
||||
|
||||
_run(scenario())
|
||||
|
||||
|
||||
def test_measured_intervals_track_the_server_cadence() -> None:
|
||||
workload = _workload(num_chunks=3)
|
||||
|
||||
async def script(websocket, payload) -> None:
|
||||
await _emit_rollout(websocket, payload, intervals=[0.05, 0.30, 0.05])
|
||||
|
||||
async def scenario() -> None:
|
||||
server = _serve(script)
|
||||
url = await server.__aenter__()
|
||||
try:
|
||||
result = await run_session(
|
||||
url,
|
||||
"model",
|
||||
workload,
|
||||
first_chunk_timeout=10,
|
||||
chunk_timeout=10,
|
||||
ping_interval=0,
|
||||
collect_media=False,
|
||||
print_chunks=False,
|
||||
)
|
||||
finally:
|
||||
await server.__aexit__(None, None, None)
|
||||
|
||||
intervals = [record.inter_arrival_s for record in result.records]
|
||||
assert intervals[1] == pytest.approx(0.30, abs=0.15)
|
||||
assert intervals[2] < intervals[1], "a fast chunk must not inherit the slow one's interval"
|
||||
assert result.records[0].arrival_s == pytest.approx(result.ttfc_s)
|
||||
|
||||
_run(scenario())
|
||||
|
||||
|
||||
def test_a_short_rollout_fails_loudly_instead_of_reporting_a_partial_cadence() -> None:
|
||||
workload = _workload(num_chunks=4)
|
||||
|
||||
async def script(websocket, payload) -> None:
|
||||
await _emit_rollout(websocket, payload, chunks=2)
|
||||
|
||||
async def scenario() -> None:
|
||||
server = _serve(script)
|
||||
url = await server.__aenter__()
|
||||
try:
|
||||
with pytest.raises(BenchmarkError, match="Expected 4 chunks"):
|
||||
await run_session(
|
||||
url,
|
||||
"model",
|
||||
workload,
|
||||
first_chunk_timeout=10,
|
||||
chunk_timeout=10,
|
||||
ping_interval=0,
|
||||
collect_media=False,
|
||||
print_chunks=False,
|
||||
)
|
||||
finally:
|
||||
await server.__aexit__(None, None, None)
|
||||
|
||||
_run(scenario())
|
||||
|
||||
|
||||
def test_a_server_error_event_aborts_the_session() -> None:
|
||||
workload = _workload(num_chunks=2)
|
||||
|
||||
async def script(websocket, payload) -> None:
|
||||
await websocket.send(json.dumps({"type": "error", "message": "resolution mismatch"}))
|
||||
|
||||
async def scenario() -> None:
|
||||
server = _serve(script)
|
||||
url = await server.__aenter__()
|
||||
try:
|
||||
with pytest.raises(BenchmarkError, match="resolution mismatch.*requested 2 chunks"):
|
||||
await run_session(
|
||||
url,
|
||||
"model",
|
||||
workload,
|
||||
first_chunk_timeout=10,
|
||||
chunk_timeout=10,
|
||||
ping_interval=0,
|
||||
collect_media=False,
|
||||
print_chunks=False,
|
||||
)
|
||||
finally:
|
||||
await server.__aexit__(None, None, None)
|
||||
|
||||
_run(scenario())
|
||||
|
||||
|
||||
def test_a_stalled_server_times_out_rather_than_hanging() -> None:
|
||||
workload = _workload(num_chunks=2)
|
||||
|
||||
async def script(websocket, payload) -> None:
|
||||
await websocket.send(json.dumps({"type": "video.start", "request_id": "req-1", "format": "m4s"}))
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def scenario() -> None:
|
||||
server = _serve(script)
|
||||
url = await server.__aenter__()
|
||||
try:
|
||||
with pytest.raises(BenchmarkError, match="No media chunk for"):
|
||||
await run_session(
|
||||
url,
|
||||
"model",
|
||||
workload,
|
||||
first_chunk_timeout=0.4,
|
||||
chunk_timeout=0.4,
|
||||
ping_interval=0,
|
||||
collect_media=False,
|
||||
print_chunks=False,
|
||||
)
|
||||
finally:
|
||||
await server.__aexit__(None, None, None)
|
||||
|
||||
_run(scenario())
|
||||
|
||||
|
||||
def test_keepalive_pings_reach_the_server_during_a_slow_first_chunk() -> None:
|
||||
"""A compiled first block can outlast the server's stall timeout."""
|
||||
workload = _workload(num_chunks=1)
|
||||
seen: list[str] = []
|
||||
|
||||
async def script(websocket, payload) -> None:
|
||||
await websocket.send(json.dumps({"type": "video.start", "request_id": "req-1", "format": "m4s"}))
|
||||
|
||||
async def drain() -> None:
|
||||
async for message in websocket:
|
||||
event = json.loads(message)
|
||||
seen.append(event["type"])
|
||||
if event["type"] == "session.ping":
|
||||
await websocket.send(json.dumps({"type": "session.pong"}))
|
||||
|
||||
drainer = asyncio.create_task(drain())
|
||||
await asyncio.sleep(0.35)
|
||||
await _emit_rollout(websocket, payload, trailer=False)
|
||||
drainer.cancel()
|
||||
|
||||
async def scenario() -> None:
|
||||
server = _serve(script)
|
||||
url = await server.__aenter__()
|
||||
try:
|
||||
result = await run_session(
|
||||
url,
|
||||
"model",
|
||||
workload,
|
||||
first_chunk_timeout=10,
|
||||
chunk_timeout=10,
|
||||
ping_interval=0.1,
|
||||
collect_media=False,
|
||||
print_chunks=False,
|
||||
)
|
||||
finally:
|
||||
await server.__aexit__(None, None, None)
|
||||
assert len(result.records) == 1
|
||||
assert "session.ping" in seen
|
||||
|
||||
_run(scenario())
|
||||
|
||||
|
||||
def test_prompt_updates_are_sent_on_their_chunk_boundary() -> None:
|
||||
workload = Workload(
|
||||
prompt="start",
|
||||
image_reference=_IMAGE,
|
||||
camera_script=build_camera_script(3, "forward"),
|
||||
prompt_updates=(PromptUpdate(after_chunk=0, prompt="Rain begins", transition_chunks=1),),
|
||||
)
|
||||
received: list[dict] = []
|
||||
|
||||
async def script(websocket, payload) -> None:
|
||||
async def drain() -> None:
|
||||
async for message in websocket:
|
||||
received.append(json.loads(message))
|
||||
|
||||
drainer = asyncio.create_task(drain())
|
||||
await _emit_rollout(websocket, payload, intervals=[0.02, 0.15, 0.02], trailer=False)
|
||||
await asyncio.sleep(0.05)
|
||||
drainer.cancel()
|
||||
|
||||
async def scenario() -> None:
|
||||
server = _serve(script)
|
||||
url = await server.__aenter__()
|
||||
try:
|
||||
await run_session(
|
||||
url,
|
||||
"model",
|
||||
workload,
|
||||
first_chunk_timeout=10,
|
||||
chunk_timeout=10,
|
||||
ping_interval=0,
|
||||
collect_media=False,
|
||||
print_chunks=False,
|
||||
)
|
||||
finally:
|
||||
await server.__aexit__(None, None, None)
|
||||
interactions = [event for event in received if event.get("type") == "session.interaction"]
|
||||
assert len(interactions) == 1
|
||||
assert interactions[0]["interaction"]["event"]["prompt"] == "Rain begins"
|
||||
assert interactions[0]["interaction"]["transition_chunks"] == 1
|
||||
|
||||
_run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delivered", [0, 1])
|
||||
def test_pongs_do_not_extend_media_deadlines(delivered) -> None:
|
||||
seen = []
|
||||
|
||||
async def script(websocket, payload) -> None:
|
||||
if delivered:
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "video.chunk_metadata",
|
||||
"kind": "media",
|
||||
"generation_chunk_index": 0,
|
||||
"num_frames": 9,
|
||||
}
|
||||
)
|
||||
)
|
||||
await websocket.send(b"media")
|
||||
async for message in websocket:
|
||||
seen.append(json.loads(message)["type"])
|
||||
await websocket.send(json.dumps({"type": "session.pong"}))
|
||||
|
||||
async def scenario() -> None:
|
||||
async with _serve(script) as url:
|
||||
with pytest.raises(BenchmarkError, match=f"after {delivered} chunk"):
|
||||
await asyncio.wait_for(
|
||||
run_session(
|
||||
url,
|
||||
"model",
|
||||
_workload(num_chunks=2),
|
||||
first_chunk_timeout=0.25,
|
||||
chunk_timeout=0.25,
|
||||
ping_interval=0.03,
|
||||
collect_media=False,
|
||||
print_chunks=False,
|
||||
),
|
||||
timeout=2,
|
||||
)
|
||||
assert "session.ping" in seen
|
||||
|
||||
_run(scenario())
|
||||
|
||||
|
||||
def test_terminal_chunk_is_only_excluded_from_steady_metrics() -> None:
|
||||
records = _records([1.0] + [0.1] * 5 + [1.2, 0.8, 0.1])
|
||||
metrics = compute_metrics(records, fps=12, warmup_chunks=6)
|
||||
assert metrics["steady_chunks"] == 2
|
||||
assert metrics["interval_steady"]["mean_ms"] == pytest.approx(1000)
|
||||
assert metrics["steady_video_rtf"] == pytest.approx(1)
|
||||
assert metrics["slo_attainment"] == pytest.approx(0.5)
|
||||
assert metrics["interval_all"]["count"] == 8
|
||||
assert metrics["total_frames"] == 105
|
||||
assert metrics["wall_seconds"] == pytest.approx(records[-1].arrival_s)
|
||||
assert metrics["playback"]["total_stall_ms"] == pytest.approx(
|
||||
simulate_playback(records, fps=12).total_stall_s * 1000
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_chunk_alone_does_not_establish_steady_state() -> None:
|
||||
metrics = compute_metrics(_records([1.0] * 7), fps=12, warmup_chunks=6)
|
||||
assert not metrics["steady_state_reached"]
|
||||
assert "steady_video_rtf" not in metrics
|
||||
|
||||
|
||||
def test_aggregate_preserves_custom_deadline_and_weights_intervals() -> None:
|
||||
fast = compute_metrics(_records([1.0, 0.5, 0.1]), fps=12, warmup_chunks=0, slo_ms=700)
|
||||
slow = compute_metrics(_records([1.0, 0.9, 0.9, 0.9, 0.1]), fps=12, warmup_chunks=0, slo_ms=700)
|
||||
aggregate = aggregate_metrics([fast, slow], fps=12)
|
||||
assert aggregate["chunk_deadline_ms"] == 700
|
||||
assert aggregate["steady_interval_ms_mean"] == pytest.approx(800)
|
||||
slow["chunk_deadline_ms"] = 800
|
||||
with pytest.raises(ValueError, match="different chunk deadlines"):
|
||||
aggregate_metrics([fast, slow], fps=12)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flags, expected_shift, expected_negative",
|
||||
[
|
||||
([], 7.0, "file negative"),
|
||||
(["--flow-shift", "5", "--negative-prompt", "CLI negative"], 5.0, "CLI negative"),
|
||||
(["--negative-prompt", ""], 7.0, ""),
|
||||
],
|
||||
)
|
||||
def test_cli_workload_generation_overrides(tmp_path, flags, expected_shift, expected_negative) -> None:
|
||||
path = tmp_path / "workload.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"image": _IMAGE,
|
||||
"num_chunks": 3,
|
||||
"flow_shift": 7.0,
|
||||
"negative_prompt": "file negative",
|
||||
}
|
||||
)
|
||||
)
|
||||
workload = build_workload(parse_args(["--workload", str(path), *flags]))
|
||||
assert workload.flow_shift == expected_shift
|
||||
assert workload.negative_prompt == expected_negative
|
||||
|
||||
|
||||
def test_builtin_workload_keeps_default_flow_shift() -> None:
|
||||
assert build_workload(parse_args(["--image", _IMAGE])).flow_shift == 5.0
|
||||
|
||||
|
||||
def test_short_sample_report_qualifies_tail_percentiles(capsys) -> None:
|
||||
metrics = compute_metrics(_records([1.0] * 16), fps=12)
|
||||
print_report(metrics, workload=_workload(num_chunks=16), endpoint="ws://test", sessions=1, target_fps=12)
|
||||
assert "not reliable tail estimates" in capsys.readouterr().out
|
||||
+4
-2
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Hunyuan-Image stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py``.
|
||||
|
||||
@@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.diffusion
|
||||
# Pytest marks (hardware, local_model, slow, diffusion) come from test_hunyuan_image.json.
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True)
|
||||
def test_stability_hunyuan_image(omni_server, stability_benchmark_params):
|
||||
@@ -0,0 +1,70 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""
|
||||
HunyuanVideo-1.5 I2V stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py`` / ``v1/videos``.
|
||||
|
||||
Configuration: ``tests/dfx/stability/tests/test_hunyuanvideo15.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dfx.conftest import (
|
||||
create_benchmark_indices,
|
||||
create_test_parameter_mapping,
|
||||
create_unique_server_pytest_params,
|
||||
load_configs,
|
||||
)
|
||||
from tests.dfx.stability.helpers import _run_one_diffusion_batch, run_stability_benchmark_loop
|
||||
|
||||
STABILITY_DIR = Path(__file__).resolve().parent.parent
|
||||
DEPLOY_CONFIGS_DIR = STABILITY_DIR / "deploy"
|
||||
CONFIG_FILE_PATH = str(STABILITY_DIR / "tests" / "test_hunyuanvideo15.json")
|
||||
DEFAULT_NUM_PROMPTS_PER_BATCH = 50
|
||||
STABILITY_SERVER_TIMEOUT_ARGS = ["--stage-init-timeout", "600", "--init-timeout", "900"]
|
||||
|
||||
try:
|
||||
BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH)
|
||||
except FileNotFoundError:
|
||||
BENCHMARK_CONFIGS = []
|
||||
|
||||
test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
|
||||
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
# Pytest marks (hardware, local_model, slow, diffusion) come from test_hunyuanvideo15.json.
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True)
|
||||
def test_stability_hunyuanvideo15(omni_server, stability_benchmark_params):
|
||||
test_name = stability_benchmark_params["test_name"]
|
||||
params = stability_benchmark_params["params"]
|
||||
duration_sec = params.get("duration_sec", 300)
|
||||
num_prompts_per_batch = params.get("num_prompts_per_batch", DEFAULT_NUM_PROMPTS_PER_BATCH)
|
||||
request_rate = params.get("request_rate")
|
||||
max_concurrency = params.get("max_concurrency")
|
||||
|
||||
bench_params = {
|
||||
k: v
|
||||
for k, v in params.items()
|
||||
if k not in ("duration_sec", "request_rate", "max_concurrency", "num_prompts_per_batch")
|
||||
}
|
||||
|
||||
result = run_stability_benchmark_loop(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
model=omni_server.model,
|
||||
duration_sec=duration_sec,
|
||||
params=bench_params,
|
||||
request_rate=request_rate,
|
||||
max_concurrency=max_concurrency,
|
||||
result_dir=str(STABILITY_DIR),
|
||||
num_prompts_per_batch=num_prompts_per_batch,
|
||||
run_one_batch=_run_one_diffusion_batch,
|
||||
)
|
||||
|
||||
assert result.get("failed", 0) == 0, f"[{test_name}] Failed requests detected: {result.get('errors', [])}"
|
||||
assert result.get("completed", 0) > 0, f"[{test_name}] No requests completed"
|
||||
@@ -0,0 +1,71 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
MiniCPM-o-4.5 stability: OmniServer + ``vllm bench serve --omni`` for a fixed duration.
|
||||
|
||||
Configuration: ``tests/dfx/stability/tests/test_minicpmo_4_5.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dfx.conftest import (
|
||||
create_benchmark_indices,
|
||||
create_test_parameter_mapping,
|
||||
create_unique_server_pytest_params,
|
||||
load_configs,
|
||||
)
|
||||
from tests.dfx.stability.helpers import _run_one_vllm_bench_batch, run_stability_benchmark_loop
|
||||
|
||||
STABILITY_DIR = Path(__file__).resolve().parent.parent
|
||||
DEPLOY_CONFIGS_DIR = STABILITY_DIR / "deploy"
|
||||
CONFIG_FILE_PATH = str(STABILITY_DIR / "tests" / "test_minicpmo_4_5.json")
|
||||
DEFAULT_NUM_PROMPTS_PER_BATCH = 20
|
||||
STABILITY_SERVER_TIMEOUT_ARGS = ["--stage-init-timeout", "600"]
|
||||
|
||||
try:
|
||||
BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH)
|
||||
except FileNotFoundError:
|
||||
BENCHMARK_CONFIGS = []
|
||||
|
||||
test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
|
||||
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
# Pytest marks (hardware, local_model, slow, omni) come from test_minicpmo_4_5.json.
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True)
|
||||
def test_stability_minicpmo_4_5(omni_server, stability_benchmark_params):
|
||||
test_name = stability_benchmark_params["test_name"]
|
||||
params = stability_benchmark_params["params"]
|
||||
duration_sec = params.get("duration_sec", 300)
|
||||
num_prompts_per_batch = params.get("num_prompts_per_batch", DEFAULT_NUM_PROMPTS_PER_BATCH)
|
||||
request_rate = params.get("request_rate")
|
||||
max_concurrency = params.get("max_concurrency")
|
||||
|
||||
bench_params = {
|
||||
k: v
|
||||
for k, v in params.items()
|
||||
if k not in ("duration_sec", "request_rate", "max_concurrency", "num_prompts_per_batch")
|
||||
}
|
||||
|
||||
result = run_stability_benchmark_loop(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
model=omni_server.model,
|
||||
duration_sec=duration_sec,
|
||||
params=bench_params,
|
||||
request_rate=request_rate,
|
||||
max_concurrency=max_concurrency,
|
||||
result_dir=str(STABILITY_DIR),
|
||||
num_prompts_per_batch=num_prompts_per_batch,
|
||||
run_one_batch=_run_one_vllm_bench_batch,
|
||||
)
|
||||
|
||||
assert result.get("failed", 0) == 0, f"[{test_name}] Failed requests detected: {result.get('errors', [])}"
|
||||
assert result.get("completed", 0) > 0, f"[{test_name}] No requests completed"
|
||||
@@ -0,0 +1,91 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
MiniMax-H3 T2V stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py`` / ``v1/videos``.
|
||||
|
||||
Configuration: ``tests/dfx/stability/tests/test_minimax_h3.json``.
|
||||
|
||||
``--lora-path`` is resolved like e2e FastH3 / Buildkite ``test-merge.yml``:
|
||||
set ``VLLM_TEST_MINIMAX_H3_FASTH3_LORA`` to the adapter file, or populate the local
|
||||
HF cache for ``FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dfx.conftest import (
|
||||
create_benchmark_indices,
|
||||
create_test_parameter_mapping,
|
||||
create_unique_server_pytest_params,
|
||||
load_configs,
|
||||
)
|
||||
from tests.dfx.stability.helpers import _run_one_diffusion_batch, run_stability_benchmark_loop
|
||||
from tests.e2e.online_serving.minimax_h3._common import FASTH3_LORA
|
||||
|
||||
STABILITY_DIR = Path(__file__).resolve().parent.parent
|
||||
DEPLOY_CONFIGS_DIR = STABILITY_DIR / "deploy"
|
||||
CONFIG_FILE_PATH = str(STABILITY_DIR / "tests" / "test_minimax_h3.json")
|
||||
DEFAULT_NUM_PROMPTS_PER_BATCH = 20
|
||||
STABILITY_SERVER_TIMEOUT_ARGS = ["--stage-init-timeout", "1800", "--init-timeout", "1800"]
|
||||
|
||||
|
||||
def _inject_fasth3_lora_path(configs: list[dict]) -> list[dict]:
|
||||
"""Fill serve_args.lora-path from VLLM_TEST_MINIMAX_H3_FASTH3_LORA / HF cache."""
|
||||
if not FASTH3_LORA:
|
||||
return configs
|
||||
for config in configs:
|
||||
serve_args = config.setdefault("server_params", {}).setdefault("serve_args", {})
|
||||
serve_args["lora-path"] = FASTH3_LORA
|
||||
return configs
|
||||
|
||||
|
||||
try:
|
||||
BENCHMARK_CONFIGS = _inject_fasth3_lora_path(load_configs(CONFIG_FILE_PATH))
|
||||
except FileNotFoundError:
|
||||
BENCHMARK_CONFIGS = []
|
||||
|
||||
test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
|
||||
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
# Pytest marks (hardware, local_model, slow, diffusion) come from test_minimax_h3.json.
|
||||
@pytest.mark.skipif(
|
||||
FASTH3_LORA is None,
|
||||
reason="set VLLM_TEST_MINIMAX_H3_FASTH3_LORA or populate the local HF cache",
|
||||
)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True)
|
||||
def test_stability_minimax_h3(omni_server, stability_benchmark_params):
|
||||
test_name = stability_benchmark_params["test_name"]
|
||||
params = stability_benchmark_params["params"]
|
||||
duration_sec = params.get("duration_sec", 300)
|
||||
num_prompts_per_batch = params.get("num_prompts_per_batch", DEFAULT_NUM_PROMPTS_PER_BATCH)
|
||||
request_rate = params.get("request_rate")
|
||||
max_concurrency = params.get("max_concurrency")
|
||||
|
||||
bench_params = {
|
||||
k: v
|
||||
for k, v in params.items()
|
||||
if k not in ("duration_sec", "request_rate", "max_concurrency", "num_prompts_per_batch")
|
||||
}
|
||||
|
||||
result = run_stability_benchmark_loop(
|
||||
host=omni_server.host,
|
||||
port=omni_server.port,
|
||||
model=omni_server.model,
|
||||
duration_sec=duration_sec,
|
||||
params=bench_params,
|
||||
request_rate=request_rate,
|
||||
max_concurrency=max_concurrency,
|
||||
result_dir=str(STABILITY_DIR),
|
||||
num_prompts_per_batch=num_prompts_per_batch,
|
||||
run_one_batch=_run_one_diffusion_batch,
|
||||
)
|
||||
|
||||
assert result.get("failed", 0) == 0, f"[{test_name}] Failed requests detected: {result.get('errors', [])}"
|
||||
assert result.get("completed", 0) > 0, f"[{test_name}] No requests completed"
|
||||
+4
-2
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Qwen3-Omni stability: OmniServer + ``vllm bench serve --omni`` for a fixed duration.
|
||||
|
||||
@@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.omni
|
||||
# Pytest marks (hardware, local_model, slow, omni) come from test_qwen3_omni.json.
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True)
|
||||
def test_stability_qwen3_omni(omni_server, stability_benchmark_params):
|
||||
+4
-2
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Qwen3-TTS stability: OmniServer + ``vllm bench serve --omni`` for a fixed duration.
|
||||
|
||||
@@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.tts
|
||||
# Pytest marks (hardware, local_model, slow, tts) come from test_qwen3_tts.json.
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True)
|
||||
def test_stability_qwen3_tts(omni_server, stability_benchmark_params):
|
||||
+4
-2
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Qwen-Image stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py``.
|
||||
|
||||
@@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.diffusion
|
||||
# Pytest marks (hardware, local_model, slow, diffusion) come from test_qwen_image.json.
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True)
|
||||
def test_stability_qwen_image(omni_server, stability_benchmark_params):
|
||||
+4
-2
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""
|
||||
Wan2.2 T2V stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py`` / ``v1/videos``.
|
||||
|
||||
@@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i
|
||||
benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.diffusion
|
||||
# Pytest marks (hardware, local_model, slow, diffusion) come from test_wan22.json.
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True)
|
||||
def test_stability_wan22(omni_server, stability_benchmark_params):
|
||||
@@ -7,10 +7,11 @@
|
||||
"res": {
|
||||
"cuda": "H800"
|
||||
},
|
||||
"num_cards": 2
|
||||
"num_cards": 4
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"diffusion"
|
||||
],
|
||||
"server_params": {
|
||||
@@ -44,5 +45,53 @@
|
||||
"enable_negative_prompt": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_name": "test_hunyuan_image_stability_tp2_sp2_a3",
|
||||
"description": "A3: same as perf test_hunyuan_image_tp2_sp2 — DiT-only, TP=2 USP=2, 4 dies, no fp8",
|
||||
"mark": [
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"npu": "A3"
|
||||
},
|
||||
"num_cards": 4
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"diffusion"
|
||||
],
|
||||
"server_params": {
|
||||
"model": "tencent/HunyuanImage-3.0-Instruct",
|
||||
"serve_args": {
|
||||
"deploy-config": "vllm_omni/deploy/hunyuan_image3_dit.yaml",
|
||||
"tensor-parallel-size": 2,
|
||||
"ulysses-degree": 2,
|
||||
"vae-patch-parallel-size": 4,
|
||||
"vae-use-tiling": true,
|
||||
"enable-diffusion-pipeline-profiler": true
|
||||
}
|
||||
},
|
||||
"benchmark_params": [
|
||||
{
|
||||
"dataset": "random",
|
||||
"task": "t2i",
|
||||
"backend": "vllm-omni",
|
||||
"duration_sec": 86400,
|
||||
"max_concurrency": 1,
|
||||
"num_prompts_per_batch": 50,
|
||||
"width": {
|
||||
"min": 512,
|
||||
"max": 1024
|
||||
},
|
||||
"height": {
|
||||
"min": 512,
|
||||
"max": 1024
|
||||
},
|
||||
"num_inference_steps": 50,
|
||||
"enable_negative_prompt": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
[
|
||||
{
|
||||
"test_name": "test_hunyuanvideo15_i2v_cachedit_tp2_vae_patch2_stability",
|
||||
"description": "HunyuanVideo-1.5 I2V 24h: CacheDiT + TP=2 + VAE patch parallel=2 + VAE tiling",
|
||||
"mark": [
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"cuda": ["H800"]
|
||||
},
|
||||
"num_cards": 2
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"diffusion"
|
||||
],
|
||||
"server_params": {
|
||||
"model": "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v",
|
||||
"serve_args": {
|
||||
"flow-shift": 5.0,
|
||||
"cache-backend": "cache_dit",
|
||||
"tensor-parallel-size": 2,
|
||||
"vae-patch-parallel-size": 2,
|
||||
"vae-use-tiling": true,
|
||||
"enable-diffusion-pipeline-profiler": true
|
||||
}
|
||||
},
|
||||
"benchmark_params": [
|
||||
{
|
||||
"dataset": "random",
|
||||
"task": "i2v",
|
||||
"backend": "v1/videos",
|
||||
"duration_sec": 86400,
|
||||
"max_concurrency": 1,
|
||||
"num_prompts_per_batch": 50,
|
||||
"num_input_images": 1,
|
||||
"seed": 42,
|
||||
"enable_negative_prompt": true,
|
||||
"random_request_config": [
|
||||
{
|
||||
"width": 832,
|
||||
"height": 480,
|
||||
"num_inference_steps": 4,
|
||||
"num_frames": 33,
|
||||
"fps": 24,
|
||||
"weight": 1
|
||||
}
|
||||
],
|
||||
"extra_body": {
|
||||
"guidance_scale": 6.0,
|
||||
"flow_shift": 5.0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
[
|
||||
{
|
||||
"test_name": "test_minicpmo_4_5_stability_ready",
|
||||
"description": "MiniCPM-o-4.5 ready deploy + seed-tts chat/audio stability (from perf test_minicpmo_4_5_ready)",
|
||||
"mark": [
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"cuda": "H800"
|
||||
},
|
||||
"num_cards": 1
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"omni"
|
||||
],
|
||||
"server_params": {
|
||||
"model": "openbmb/MiniCPM-o-4_5",
|
||||
"extra_cli_args": [
|
||||
"--deploy-config",
|
||||
"vllm_omni/deploy/minicpmo_4_5.yaml",
|
||||
"--trust-remote-code"
|
||||
]
|
||||
},
|
||||
"benchmark_params": [
|
||||
{
|
||||
"dataset_name": "seed-tts",
|
||||
"dataset_path": "zhaochenyang20/seed-tts-eval",
|
||||
"backend": "openai-chat-omni",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"duration_sec": 86400,
|
||||
"max_concurrency": 1,
|
||||
"num_prompts_per_batch": 50,
|
||||
"no_oversample": true,
|
||||
"disable_shuffle": true,
|
||||
"trust_remote_code": true,
|
||||
"seed_tts_locale": "en",
|
||||
"extra_body": {
|
||||
"modalities": [
|
||||
"text",
|
||||
"audio"
|
||||
],
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": false,
|
||||
"use_tts_template": true
|
||||
}
|
||||
},
|
||||
"percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"test_name": "test_minimax_h3_t2v_stability_4gpu",
|
||||
"description": "MiniMax-H3 FastH3 T2VA on 4 GPUs (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile); fused lora-path from VLLM_TEST_MINIMAX_H3_FASTH3_LORA; requests must use num_inference_steps=4",
|
||||
"mark": [
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"cuda": "H800"
|
||||
},
|
||||
"num_cards": 4
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"diffusion"
|
||||
],
|
||||
"server_params": {
|
||||
"model": "MiniMaxAI/MiniMax-H3",
|
||||
"serve_args": {
|
||||
"trust-remote-code": true,
|
||||
"task-type": "fl2va",
|
||||
"num-gpus": 4,
|
||||
"usp": 4,
|
||||
"ring": 1,
|
||||
"use-hsdp": true,
|
||||
"hsdp-shard-size": 4,
|
||||
"text-encoder-tp-size": 4,
|
||||
"vae-patch-parallel-size": 4,
|
||||
"vae-parallel-mode": "tile",
|
||||
"vae-use-tiling": true,
|
||||
"stage-init-timeout": 1800,
|
||||
"init-timeout": 1800
|
||||
}
|
||||
},
|
||||
"benchmark_params": [
|
||||
{
|
||||
"dataset": "random",
|
||||
"task": "t2v",
|
||||
"backend": "v1/videos",
|
||||
"duration_sec": 86400,
|
||||
"max_concurrency": 1,
|
||||
"num_prompts_per_batch": 50,
|
||||
"seed": 42,
|
||||
"random_request_config": [
|
||||
{
|
||||
"width": 1344,
|
||||
"height": 768,
|
||||
"prompt": "Cinematic drone shot pushing through misty mountain ridges at sunrise, golden light catching the clouds, slow and smooth.",
|
||||
"num_inference_steps": 4,
|
||||
"num_frames": 209,
|
||||
"fps": 24,
|
||||
"weight": 1
|
||||
}
|
||||
],
|
||||
"extra_body": {
|
||||
"extra_params": "{\"task\":\"t2va\",\"duration\":8.7,\"aspect_ratio\":\"16:9\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -5,12 +5,14 @@
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"cuda": "H800"
|
||||
"cuda": "H800",
|
||||
"npu": "A3"
|
||||
},
|
||||
"num_cards": 2
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"omni"
|
||||
],
|
||||
"server_params": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"tts"
|
||||
],
|
||||
"server_params": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"diffusion"
|
||||
],
|
||||
"server_params": {
|
||||
@@ -36,5 +37,44 @@
|
||||
"enable_negative_prompt": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_name": "test_qwen_image_stability_a3",
|
||||
"mark": [
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"npu": "A3"
|
||||
},
|
||||
"num_cards": 1
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"diffusion"
|
||||
],
|
||||
"server_params": {
|
||||
"model": "Qwen/Qwen-Image"
|
||||
},
|
||||
"benchmark_params": [
|
||||
{
|
||||
"dataset": "random",
|
||||
"task": "t2i",
|
||||
"backend": "vllm-omni",
|
||||
"duration_sec": 86400,
|
||||
"max_concurrency": 1,
|
||||
"num_prompts_per_batch": 50,
|
||||
"width": {
|
||||
"min": 512,
|
||||
"max": 1000
|
||||
},
|
||||
"height": {
|
||||
"min": 512,
|
||||
"max": 1000
|
||||
},
|
||||
"num_inference_steps": 50,
|
||||
"enable_negative_prompt": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -12,6 +12,7 @@
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"diffusion"
|
||||
],
|
||||
"server_params": {
|
||||
@@ -54,6 +55,7 @@
|
||||
}
|
||||
},
|
||||
"local_model",
|
||||
"slow",
|
||||
"diffusion"
|
||||
],
|
||||
"server_params": {
|
||||
|
||||
@@ -493,6 +493,84 @@ def test_dist_zero_pad_only_applies_global_width_edges(monkeypatch: pytest.Monke
|
||||
assert last.shape == (1, 1, 2, 3)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
class _PlainDecoder(torch.nn.Module):
|
||||
"""A decoder with nothing to patch, so the install only wraps ``forward``."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def forward(self, x, feat_cache=None, feat_idx=None, first_chunk=False):
|
||||
self.calls.append({"feat_cache": feat_cache, "feat_idx": feat_idx, "first_chunk": first_chunk})
|
||||
return x
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("world_size, dst", [(4, -1), (4, 4), (4, 5), (1, 1), (4, 1.5)])
|
||||
def test_install_rejects_invalid_destination_before_mutating_decoder(monkeypatch, world_size, dst):
|
||||
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, world_size))
|
||||
vae = SimpleNamespace(decoder=torch.nn.Identity())
|
||||
original_forward = vae.decoder.forward
|
||||
|
||||
def unexpected_patch(*args, **kwargs):
|
||||
pytest.fail("invalid destination must fail before patching modules")
|
||||
|
||||
monkeypatch.setattr(wan_spatial_shard, "_patch_decoder_modules", unexpected_patch)
|
||||
with pytest.raises(ValueError, match="dst must be None or an integer"):
|
||||
wan_spatial_shard.install_wan_spatial_shard_decode(vae, object(), dst=dst)
|
||||
assert vae.decoder.forward == original_forward
|
||||
assert not getattr(vae, "_vllm_omni_wan_spatial_shard_installed", False)
|
||||
|
||||
|
||||
def _install_on_plain_decoder(monkeypatch: pytest.MonkeyPatch, *, dst):
|
||||
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (1, 2))
|
||||
monkeypatch.setattr(
|
||||
wan_spatial_shard,
|
||||
"split_for_parallel_decode",
|
||||
lambda x, *, upsample_count, split_dim, group: (x, x.shape[wan_spatial_shard._spatial_dim(split_dim)]),
|
||||
)
|
||||
gathers: list[dict] = []
|
||||
|
||||
def gather(x, *, expected_extent, split_dim, group, dst=None):
|
||||
gathers.append({"expected_extent": expected_extent, "split_dim": split_dim, "dst": dst})
|
||||
return x
|
||||
|
||||
monkeypatch.setattr(wan_spatial_shard, "gather_and_trim_extent", gather)
|
||||
vae = SimpleNamespace(decoder=_PlainDecoder())
|
||||
wan_spatial_shard.install_wan_spatial_shard_decode(vae, object(), split_dim="width", dst=dst)
|
||||
return vae, gathers
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("dst", [0, 1, None])
|
||||
def test_install_passes_the_assembling_rank_through_to_the_gather(monkeypatch: pytest.MonkeyPatch, dst):
|
||||
vae, gathers = _install_on_plain_decoder(monkeypatch, dst=dst)
|
||||
cache = [None]
|
||||
|
||||
out = vae.decoder(torch.zeros(1, 3, 1, 4, 6), feat_cache=cache, feat_idx=[0], first_chunk=True)
|
||||
|
||||
assert out.shape == (1, 3, 1, 4, 6)
|
||||
assert gathers == [{"expected_extent": 6, "split_dim": "width", "dst": dst}]
|
||||
# The streaming arguments reach the wrapped decoder untouched.
|
||||
assert vae.decoder.calls == [{"feat_cache": cache, "feat_idx": [0], "first_chunk": True}]
|
||||
assert vae._vllm_omni_wan_spatial_shard_dst == dst
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_install_refuses_to_change_the_assembling_rank(monkeypatch: pytest.MonkeyPatch):
|
||||
vae, _ = _install_on_plain_decoder(monkeypatch, dst=None)
|
||||
|
||||
# Same settings again: a no-op, as before.
|
||||
wan_spatial_shard.install_wan_spatial_shard_decode(vae, object(), split_dim="width", dst=None)
|
||||
with pytest.raises(ValueError, match="already patched to assemble on None"):
|
||||
wan_spatial_shard.install_wan_spatial_shard_decode(vae, object(), split_dim="width", dst=0)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_spatial_shard_height_gate_falls_back_for_partial_group(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -626,6 +704,105 @@ def _spatial_shard_decode_worker(rank: int, split_dim: str, return_dict, master_
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
_STREAMING_SHARD_CHUNKS = 3
|
||||
|
||||
|
||||
def _streaming_shard_worker(rank: int, return_dict, master_port: str) -> None:
|
||||
"""Sharded streaming decode with every rank keeping the frame, against an unsharded streaming reference.
|
||||
|
||||
Two sessions are interleaved chunk by chunk, so the per-session temporal cache
|
||||
and the halo exchange are exercised across successive calls, then one session
|
||||
is reset and must reproduce its opening chunk. Every rank checks every chunk.
|
||||
"""
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
destroy_model_parallel,
|
||||
get_sp_group,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.streaming_decode import WanStreamingDecoder
|
||||
|
||||
os.environ["MASTER_ADDR"] = "127.0.0.1"
|
||||
os.environ["MASTER_PORT"] = master_port
|
||||
device = current_omni_platform.get_torch_device(rank)
|
||||
current_omni_platform.set_device(device)
|
||||
dtype = torch.float32
|
||||
backend = current_omni_platform.dist_backend
|
||||
init_distributed_environment(world_size=_SPATIAL_SHARD_WORLD_SIZE, rank=rank, local_rank=rank, backend=backend)
|
||||
initialize_model_parallel(
|
||||
sequence_parallel_size=_SPATIAL_SHARD_WORLD_SIZE, ulysses_degree=_SPATIAL_SHARD_WORLD_SIZE, backend=backend
|
||||
)
|
||||
# VLLM_OMNI_TEST_WAN_VAE may point at a local diffusers checkpoint (with or
|
||||
# without a ``vae`` subfolder) for machines without hub access.
|
||||
model = os.environ.get("VLLM_OMNI_TEST_WAN_VAE", _SPATIAL_SHARD_MODEL)
|
||||
local_vae_only = os.path.isdir(model) and not os.path.isdir(os.path.join(model, "vae"))
|
||||
load = {"torch_dtype": dtype} if local_vae_only else {"subfolder": _SPATIAL_SHARD_SUBFOLDER, "torch_dtype": dtype}
|
||||
try:
|
||||
reference_vae = DistributedAutoencoderKLWan.from_pretrained(model, **load).to(device=device, dtype=dtype).eval()
|
||||
sharded_vae = DistributedAutoencoderKLWan.from_pretrained(model, **load).to(device=device, dtype=dtype).eval()
|
||||
wan_spatial_shard.install_wan_spatial_shard_decode(
|
||||
sharded_vae, get_sp_group().device_group, split_dim="width", dst=None
|
||||
)
|
||||
reference = WanStreamingDecoder(reference_vae, bytes_per_pixel_fp32=1.0)
|
||||
sharded = WanStreamingDecoder(sharded_vae, bytes_per_pixel_fp32=1.0)
|
||||
|
||||
generator = torch.Generator(device=device).manual_seed(0)
|
||||
# Width 104 latents = 832 px: not divisible by two ranks' halo-padded shards without padding.
|
||||
shape = (1, reference_vae.config.z_dim, 1, _SPATIAL_SHARD_LATENT_HEIGHT, _SPATIAL_SHARD_LATENT_WIDTH)
|
||||
chunks = {
|
||||
name: [
|
||||
torch.randn(shape, generator=generator, device=device, dtype=dtype)
|
||||
for _ in range(_STREAMING_SHARD_CHUNKS)
|
||||
]
|
||||
for name in ("a", "b")
|
||||
}
|
||||
max_diff = 0.0
|
||||
with torch.inference_mode():
|
||||
ref_states = {name: reference.new_decode_state(name) for name in chunks}
|
||||
shard_states = {name: sharded.new_decode_state(name) for name in chunks}
|
||||
for index in range(_STREAMING_SHARD_CHUNKS):
|
||||
for name in ("a", "b"):
|
||||
expected = reference.decode_chunk(chunks[name][index], ref_states[name]).float()
|
||||
got = sharded.decode_chunk(chunks[name][index], shard_states[name]).float()
|
||||
if got.shape != expected.shape:
|
||||
raise AssertionError(
|
||||
f"rank {rank} {name}[{index}]: {tuple(got.shape)} != {tuple(expected.shape)}"
|
||||
)
|
||||
max_diff = max(max_diff, (got - expected).abs().max().item())
|
||||
# Reset session a: a fresh state must reproduce its opening chunk, on this rank too.
|
||||
shard_states["a"].release()
|
||||
ref_states["a"].release()
|
||||
shard_states["a"] = sharded.new_decode_state("a")
|
||||
ref_states["a"] = reference.new_decode_state("a")
|
||||
expected = reference.decode_chunk(chunks["a"][0], ref_states["a"]).float()
|
||||
got = sharded.decode_chunk(chunks["a"][0], shard_states["a"]).float()
|
||||
max_diff = max(max_diff, (got - expected).abs().max().item())
|
||||
return_dict[f"max_abs_diff_rank{rank}"] = max_diff
|
||||
return_dict[f"shape_rank{rank}"] = tuple(got.shape)
|
||||
finally:
|
||||
destroy_model_parallel()
|
||||
if dist.is_initialized():
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": ["H100", "B200"]}, num_cards=_SPATIAL_SHARD_WORLD_SIZE)
|
||||
def test_streaming_shard_decode_matches_unsharded_streaming_on_every_rank():
|
||||
manager = mp.get_context("spawn").Manager()
|
||||
return_dict = manager.dict()
|
||||
mp.spawn(_streaming_shard_worker, args=(return_dict, "29510"), nprocs=_SPATIAL_SHARD_WORLD_SIZE, join=True)
|
||||
|
||||
for rank in range(_SPATIAL_SHARD_WORLD_SIZE):
|
||||
assert f"max_abs_diff_rank{rank}" in return_dict, f"rank {rank} did not report"
|
||||
diff = return_dict[f"max_abs_diff_rank{rank}"]
|
||||
shape = return_dict[f"shape_rank{rank}"]
|
||||
print(f"streaming shard rank {rank}: max_abs_diff={diff:.6e} shape={shape}")
|
||||
assert shape[-1] == _SPATIAL_SHARD_LATENT_WIDTH * 8 and shape[-2] == _SPATIAL_SHARD_LATENT_HEIGHT * 8
|
||||
assert diff <= _SPATIAL_SHARD_TOLERANCE, f"rank {rank}: {diff} exceeds {_SPATIAL_SHARD_TOLERANCE}"
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
|
||||
@@ -134,6 +134,8 @@ def _install_vllm_stubs() -> None:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.quant_config = kwargs.pop("quant_config", None)
|
||||
self.prefix = kwargs.pop("prefix", "")
|
||||
del kwargs
|
||||
self.return_bias = return_bias
|
||||
self.weight = nn.Parameter(torch.empty(output_size, input_size))
|
||||
@@ -164,6 +166,8 @@ def _install_vllm_stubs() -> None:
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.quant_config = kwargs.pop("quant_config", None)
|
||||
self.prefix = kwargs.pop("prefix", "")
|
||||
del kwargs
|
||||
self.num_heads = total_num_heads
|
||||
self.num_kv_heads = total_num_kv_heads or total_num_heads
|
||||
|
||||
@@ -35,6 +35,8 @@ def _tiny_model(
|
||||
num_layers: int = 2,
|
||||
num_frames_per_block: int = 1,
|
||||
sliding_window_num_frames: int = 3,
|
||||
quant_config=None,
|
||||
prefix: str = "",
|
||||
attention_head_dim: int = 2,
|
||||
rope_max_seq_len: int = 16,
|
||||
):
|
||||
@@ -55,6 +57,8 @@ def _tiny_model(
|
||||
num_frames_per_block=num_frames_per_block,
|
||||
sliding_window_num_frames=sliding_window_num_frames,
|
||||
local_attn_size=-1,
|
||||
quant_config=quant_config,
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
|
||||
@@ -485,11 +489,52 @@ def test_constructor_rejects_non_null_image_embedding_fields(field: str) -> None
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_rejects_unsupported_quantization_with_runtime_error() -> None:
|
||||
@pytest.mark.parametrize("prefix", ["", "transformer"])
|
||||
@pytest.mark.parametrize("quantized", [False, True])
|
||||
def test_constructor_propagates_quantization_to_parallel_linears(prefix: str, quantized: bool) -> None:
|
||||
module = attention_tests._load_module()
|
||||
quant_config = object() if quantized else None
|
||||
model = _tiny_model(module, num_layers=1, quant_config=quant_config, prefix=prefix)
|
||||
block = model.blocks[0]
|
||||
quantized_linears = [
|
||||
model.c2ws_hidden_states_layer1,
|
||||
model.c2ws_hidden_states_layer2,
|
||||
block.self_attn.qkv,
|
||||
block.self_attn.o,
|
||||
block.cross_attn.q,
|
||||
block.cross_attn.k,
|
||||
block.cross_attn.v,
|
||||
block.cross_attn.o,
|
||||
block.ffn[0],
|
||||
block.ffn[2],
|
||||
block.cam_injector_layer1,
|
||||
block.cam_injector_layer2,
|
||||
]
|
||||
|
||||
with pytest.raises(RuntimeError, match="quant_config.*not supported"):
|
||||
module.CausalLingBotWorldTransformer3DModel(quant_config=object())
|
||||
assert len(quantized_linears) == 12
|
||||
assert all(layer.quant_config is quant_config for layer in quantized_linears)
|
||||
expected_names = {
|
||||
"c2ws_hidden_states_layer1",
|
||||
"c2ws_hidden_states_layer2",
|
||||
*(
|
||||
f"blocks.0.{name}"
|
||||
for name in (
|
||||
"self_attn.qkv",
|
||||
"self_attn.o",
|
||||
"cross_attn.q",
|
||||
"cross_attn.k",
|
||||
"cross_attn.v",
|
||||
"cross_attn.o",
|
||||
"ffn.0",
|
||||
"ffn.2",
|
||||
"cam_injector_layer1",
|
||||
"cam_injector_layer2",
|
||||
)
|
||||
),
|
||||
}
|
||||
assert {layer.prefix for layer in quantized_linears} == {
|
||||
f"{prefix}.{name}" if prefix else name for name in expected_names
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
|
||||
@@ -737,6 +737,99 @@ def test_component_discovery_uses_official_checkpoint_contract() -> None:
|
||||
assert module._loader_state.prefetch_calls == [("checkpoint", ("tokenizer", "text_encoder", "vae"), False)]
|
||||
|
||||
|
||||
def _ulysses_config(size: int):
|
||||
parallel_config = _od_config().parallel_config
|
||||
parallel_config.sequence_parallel_size = size
|
||||
parallel_config.ulysses_degree = size
|
||||
return _od_config(parallel_config=parallel_config)
|
||||
|
||||
|
||||
def _record_shard_install(monkeypatch, module, *, world_size: int):
|
||||
"""Stand in for the Ulysses group and the decoder patch; return what the install was asked for."""
|
||||
installs: list[dict] = []
|
||||
group = object()
|
||||
|
||||
def install(vae, group_arg, split_dim, *, dst):
|
||||
installs.append({"vae": vae, "group": group_arg, "split_dim": split_dim, "dst": dst})
|
||||
|
||||
monkeypatch.setattr(module, "install_wan_spatial_shard_decode", install)
|
||||
monkeypatch.setattr(module.LingBotWorldCausalDMDPipeline, "_vae_shard_group", lambda self: (group, world_size))
|
||||
return installs, group
|
||||
|
||||
|
||||
def test_a_multi_rank_deployment_shards_the_decoder_across_the_ulysses_ranks(monkeypatch) -> None:
|
||||
"""Multi-rank deployments shard decode by default."""
|
||||
module = _load_pipeline_module()
|
||||
installs, group = _record_shard_install(monkeypatch, module, world_size=2)
|
||||
|
||||
pipeline = module.LingBotWorldCausalDMDPipeline(od_config=_ulysses_config(2))
|
||||
|
||||
# Along the width, and assembled on every rank: each rank's post_decode consumes the frame.
|
||||
assert installs == [{"vae": pipeline.vae, "group": group, "split_dim": "width", "dst": None}]
|
||||
assert pipeline._vae_shard_split_dim == "width"
|
||||
|
||||
|
||||
def test_a_single_rank_deployment_leaves_the_decoder_alone(monkeypatch) -> None:
|
||||
module = _load_pipeline_module()
|
||||
installs, _ = _record_shard_install(monkeypatch, module, world_size=1)
|
||||
|
||||
pipeline = module.LingBotWorldCausalDMDPipeline(od_config=_ulysses_config(1))
|
||||
|
||||
assert installs == [] and pipeline._vae_shard_split_dim is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
@pytest.mark.parametrize("width, local_width", [(832, 208), (840, 216)])
|
||||
def test_streaming_decode_reservation_uses_padded_shards(monkeypatch, enabled, width, local_width):
|
||||
module = _load_pipeline_module()
|
||||
installs, _ = _record_shard_install(monkeypatch, module, world_size=4)
|
||||
config = _ulysses_config(4)
|
||||
config.model_config.update(lingbot_vae_spatial_sharding=enabled, ar_diffusion_height=480, ar_diffusion_width=width)
|
||||
pipeline = module.LingBotWorldCausalDMDPipeline(od_config=config)
|
||||
from vllm_omni.experimental.ar_diffusion.streaming_decode import WanStreamingDecoder
|
||||
|
||||
# Exercise the real byte estimator without constructing a checkpoint VAE.
|
||||
decoder = object.__new__(WanStreamingDecoder)
|
||||
decoder._bytes_per_pixel_fp32 = 16.0
|
||||
monkeypatch.setattr(pipeline, "_streaming_decoder", lambda: decoder)
|
||||
expected_width = local_width if enabled else width
|
||||
assert pipeline._streaming_decode_bytes_per_session() == 16 * 480 * expected_width
|
||||
assert len(installs) == int(enabled)
|
||||
assert pipeline._vae_shard_world_size == (4 if enabled else 1)
|
||||
assert config.parallel_config.sequence_parallel_size == config.parallel_config.ulysses_degree == 4
|
||||
|
||||
|
||||
def test_disabling_vae_sharding_does_not_require_a_shard_group(monkeypatch):
|
||||
module = _load_pipeline_module()
|
||||
config = _ulysses_config(4)
|
||||
config.model_config["lingbot_vae_spatial_sharding"] = False
|
||||
|
||||
def unexpected_group(self):
|
||||
pytest.fail("disabled VAE sharding must not access its process group")
|
||||
|
||||
monkeypatch.setattr(module.LingBotWorldCausalDMDPipeline, "_vae_shard_group", unexpected_group)
|
||||
pipeline = module.LingBotWorldCausalDMDPipeline(od_config=config)
|
||||
assert pipeline._vae_shard_split_dim is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["false", 0, None])
|
||||
def test_vae_sharding_switch_requires_a_boolean(value):
|
||||
module = _load_pipeline_module()
|
||||
config = _ulysses_config(4)
|
||||
config.model_config["lingbot_vae_spatial_sharding"] = value
|
||||
with pytest.raises(ValueError, match="lingbot_vae_spatial_sharding must be a boolean"):
|
||||
module.LingBotWorldCausalDMDPipeline(od_config=config)
|
||||
|
||||
|
||||
def test_vae_shard_refuses_a_group_of_the_wrong_size(monkeypatch) -> None:
|
||||
module = _load_pipeline_module()
|
||||
installs, _ = _record_shard_install(monkeypatch, module, world_size=4)
|
||||
|
||||
with pytest.raises(RuntimeError, match="sequence_parallel_size=2 but the Ulysses group has 4 ranks"):
|
||||
module.LingBotWorldCausalDMDPipeline(od_config=_ulysses_config(2))
|
||||
assert installs == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "feature"),
|
||||
[
|
||||
@@ -758,8 +851,9 @@ def test_unsupported_parallel_modes_fail_before_component_loading(field: str, va
|
||||
assert module._loader_state.prefetch_calls == []
|
||||
|
||||
|
||||
def test_pure_ulysses_parallel_config_is_supported() -> None:
|
||||
def test_pure_ulysses_parallel_config_is_supported(monkeypatch) -> None:
|
||||
module = _load_pipeline_module()
|
||||
_record_shard_install(monkeypatch, module, world_size=2)
|
||||
parallel_config = _od_config().parallel_config
|
||||
parallel_config.sequence_parallel_size = 2
|
||||
parallel_config.ulysses_degree = 2
|
||||
@@ -795,13 +889,14 @@ def test_unsupported_sp_config_fails_before_component_loading(overrides):
|
||||
assert module._loader_state.prefetch_calls == []
|
||||
|
||||
|
||||
def test_unsupported_quantization_fails_before_component_loading() -> None:
|
||||
def test_quantization_reaches_transformer_factory() -> None:
|
||||
module = _load_pipeline_module()
|
||||
quant_config = object()
|
||||
|
||||
with pytest.raises(NotImplementedError, match="quantization"):
|
||||
module.LingBotWorldCausalDMDPipeline(od_config=_od_config(quantization_config=object()))
|
||||
pipeline = module.LingBotWorldCausalDMDPipeline(od_config=_od_config(quantization_config=quant_config))
|
||||
|
||||
assert module._loader_state.prefetch_calls == []
|
||||
assert pipeline.transformer is not None
|
||||
assert _FakeTransformerFactory.last_call[1:] == (quant_config, "transformer")
|
||||
|
||||
|
||||
def test_official_scheduler_config_matches_fixed_dmd_contract() -> None:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Minimal mock of vLLM-Omni's ``/v1/videos`` API for e2e tests.
|
||||
|
||||
Records every received multipart field to ``MOCK_STATE_FILE`` (JSON) so a test
|
||||
can assert exactly what the client serialized, and returns a synthetic MP4.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import Response
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
app = FastAPI()
|
||||
_JOBS: dict[str, dict] = {}
|
||||
_STATE_FILE = os.environ.get("MOCK_STATE_FILE")
|
||||
|
||||
|
||||
def _record(fields: dict) -> None:
|
||||
if _STATE_FILE:
|
||||
with open(_STATE_FILE, "w") as f:
|
||||
json.dump(fields, f)
|
||||
|
||||
|
||||
def _synthetic_mp4() -> bytes:
|
||||
return subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=320x240:rate=24:duration=1",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=32000:duration=1",
|
||||
"-map",
|
||||
"0:v",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-ar",
|
||||
"32000",
|
||||
"-ac",
|
||||
"2",
|
||||
"-movflags",
|
||||
"frag_keyframe+empty_moov",
|
||||
"-f",
|
||||
"mp4",
|
||||
"pipe:1",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
).stdout
|
||||
|
||||
|
||||
@app.post("/v1/videos")
|
||||
async def create_video(request: Request):
|
||||
form = await request.form()
|
||||
fields = {}
|
||||
for key, value in form.items():
|
||||
if isinstance(value, UploadFile):
|
||||
data = await value.read()
|
||||
fields[key] = {
|
||||
"file": True,
|
||||
"size": len(data),
|
||||
"filename": value.filename,
|
||||
"content_type": value.content_type,
|
||||
}
|
||||
if value.content_type == "application/json":
|
||||
fields[key]["json"] = json.loads(data)
|
||||
else:
|
||||
fields[key] = str(value)
|
||||
_record(fields)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
_JOBS[job_id] = {"status": "queued"}
|
||||
return {"id": job_id, "status": "queued"}
|
||||
|
||||
|
||||
@app.get("/v1/videos/{job_id}")
|
||||
async def get_video(job_id: str):
|
||||
if job_id not in _JOBS:
|
||||
return {"status": "failed", "error": "unknown job"}
|
||||
return {"id": job_id, "status": "completed"}
|
||||
|
||||
|
||||
@app.get("/v1/videos/{job_id}/content")
|
||||
async def get_video_content(job_id: str):
|
||||
if job_id not in _JOBS:
|
||||
return Response(status_code=404)
|
||||
mp4 = await asyncio.to_thread(_synthetic_mp4)
|
||||
return Response(content=mp4, media_type="video/mp4")
|
||||
|
||||
|
||||
@app.delete("/v1/videos/{job_id}")
|
||||
async def delete_video(job_id: str):
|
||||
_JOBS.pop(job_id, None)
|
||||
return {"deleted": True}
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Unit tests for latent-mask serialization helpers."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from comfyui_vllm_omni.utils.latent_mask import scalar_mask_to_json, video_mask_to_grid_json
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_scalar_mask_to_json():
|
||||
assert scalar_mask_to_json(0.5) == "0.5"
|
||||
assert scalar_mask_to_json(1.0) == "1.0"
|
||||
with pytest.raises(ValueError):
|
||||
scalar_mask_to_json(1.5)
|
||||
with pytest.raises(ValueError):
|
||||
scalar_mask_to_json(-0.1)
|
||||
|
||||
|
||||
def test_video_mask_grid_shape():
|
||||
mask = torch.zeros(256, 448)
|
||||
grid = json.loads(video_mask_to_grid_json(mask, width=448, height=256, num_frames=107))
|
||||
assert len(grid) == 2 + 5 * ((107 - 5) // 17)
|
||||
assert len(grid[0]) == 256 // 16
|
||||
assert len(grid[0][0]) == 448 // 16
|
||||
|
||||
|
||||
def test_video_mask_grid_shape_non_aligned_frames():
|
||||
# 96 snaps up to 107 (17n+5), so Tv == 32, matching the server.
|
||||
grid = json.loads(video_mask_to_grid_json(torch.zeros(256, 448), width=448, height=256, num_frames=96))
|
||||
assert len(grid) == 32
|
||||
|
||||
|
||||
def test_video_mask_grid_shape_short_clip():
|
||||
# num_frames <= 5 -> Tv == 2.
|
||||
grid = json.loads(video_mask_to_grid_json(torch.zeros(64, 64), width=64, height=64, num_frames=1))
|
||||
assert len(grid) == 2
|
||||
|
||||
|
||||
def test_video_mask_grid_shape_unaligned_width():
|
||||
# width=500 floors to 480 (multiple of 32) -> gw == 30, not 31.
|
||||
grid = json.loads(video_mask_to_grid_json(torch.zeros(64, 64), width=500, height=256, num_frames=22))
|
||||
assert len(grid[0][0]) == 30
|
||||
|
||||
|
||||
def test_video_mask_3d_input():
|
||||
grid = json.loads(video_mask_to_grid_json(torch.ones(1, 256, 448), width=448, height=256, num_frames=107))
|
||||
assert grid[0][0][0] == 1.0
|
||||
|
||||
|
||||
def test_video_mask_temporal():
|
||||
# 3D mask: first half 0 (preserve), second half 1 (regenerate).
|
||||
mask = torch.zeros(7, 64, 64)
|
||||
mask[4:] = 1.0
|
||||
grid = json.loads(video_mask_to_grid_json(mask, width=64, height=64, num_frames=22))
|
||||
assert len(grid) == 7
|
||||
assert all(v == 0.0 for row in grid[0] for v in row)
|
||||
assert all(v == 1.0 for row in grid[-1] for v in row)
|
||||
|
||||
|
||||
def test_video_mask_temporal_resize():
|
||||
# 2 temporal slices resize to tv == 7.
|
||||
mask = torch.zeros(2, 64, 64)
|
||||
mask[1] = 1.0
|
||||
grid = json.loads(video_mask_to_grid_json(mask, width=64, height=64, num_frames=22))
|
||||
assert len(grid) == 7
|
||||
|
||||
|
||||
def test_video_mask_uniform_preserved():
|
||||
grid = json.loads(video_mask_to_grid_json(torch.full((64, 64), 0.5), width=64, height=64, num_frames=107))
|
||||
assert all(v == 0.5 for row in grid for cell in row for v in cell)
|
||||
|
||||
|
||||
def test_video_mask_values_in_range():
|
||||
grid = json.loads(video_mask_to_grid_json(torch.rand(128, 128), width=128, height=128, num_frames=22))
|
||||
assert all(0.0 <= v <= 1.0 for row in grid for cell in row for v in cell)
|
||||
@@ -0,0 +1,99 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""End-to-end test for latent-mask editing serialization.
|
||||
|
||||
Exercises ``VLLMOmniClient.generate_video`` with a ``latent_edit`` payload
|
||||
against a mock ``/v1/videos`` server and asserts the multipart fields the
|
||||
client produced. Runs on CPU: the ComfyUI ``comfy_api`` / ``comfy_extras``
|
||||
modules are mocked by this directory's ``conftest.py``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from comfy_api.input import VideoInput
|
||||
from comfyui_vllm_omni.utils.api_client import VLLMOmniClient
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_server(tmp_path):
|
||||
port = _free_port()
|
||||
state_file = tmp_path / "state.json"
|
||||
env = dict(os.environ, MOCK_STATE_FILE=str(state_file))
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "mock_videos_server:app", "--host", "127.0.0.1", "--port", str(port)],
|
||||
cwd=_TESTS_DIR,
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
yield f"http://127.0.0.1:{port}/v1", state_file
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=10)
|
||||
|
||||
|
||||
def test_latent_edit_serialization(mock_server):
|
||||
base_url, state_file = mock_server
|
||||
|
||||
# A non-trivial video mask requires a source video. The mocked VideoInput
|
||||
# only needs to provide ``save_to`` for the client's multipart upload.
|
||||
source_video = VideoInput(b"mock_source_video")
|
||||
mask = torch.zeros(1, 120, 160)
|
||||
latent_edit = {"source_video": source_video, "video_mask": mask, "audio_mask": 0.5}
|
||||
|
||||
async def run():
|
||||
client = VLLMOmniClient(base_url)
|
||||
return await client.generate_video(
|
||||
model="MiniMaxAI/MiniMax-H3",
|
||||
prompt="restyle the clip",
|
||||
width=160,
|
||||
height=120,
|
||||
num_frames=22,
|
||||
fps=24,
|
||||
latent_edit=latent_edit,
|
||||
)
|
||||
|
||||
out = asyncio.run(run())
|
||||
assert out is not None
|
||||
|
||||
with open(state_file) as f:
|
||||
fields = json.load(f)
|
||||
|
||||
assert fields["source_video"]["file"] is True
|
||||
assert fields["source_video"]["content_type"] == "video/mp4"
|
||||
for name in ("video_noise_mask", "audio_noise_mask"):
|
||||
assert fields[name]["file"] is True
|
||||
assert fields[name]["content_type"] == "application/json"
|
||||
assert fields["audio_noise_mask"]["json"] == 0.5
|
||||
|
||||
video_mask = fields["video_noise_mask"]["json"]
|
||||
assert len(video_mask) == 7 # num_frames=22 aligns to 22 (17n+5) -> Tv = 7
|
||||
assert len(video_mask[0]) == 6 # height 120 floors to 96 (multiple of 32) -> gh 6
|
||||
assert len(video_mask[0][0]) == 10 # width 160 -> gw 10
|
||||
@@ -563,10 +563,11 @@ def test_from_cli_args_preserves_text_encoder_tp_size():
|
||||
def test_text_encoder_tp_size_reaches_default_diffusion_parallel_config():
|
||||
"""The preserved CLI value must land in DiffusionParallelConfig.
|
||||
|
||||
Forward the explicitly supplied override after from_cli_args, without
|
||||
injecting unrelated LLM defaults from the full EngineArgs dataclass into
|
||||
the strict diffusion ingress. The generic diffusion fallback resolves
|
||||
the field through ``DiffusionParallelConfig.from_stage_overrides``.
|
||||
Forward the preserved explicit override to the generic diffusion
|
||||
fallback, which resolves it through
|
||||
``DiffusionParallelConfig.from_stage_overrides``. Serializing all engine
|
||||
defaults would also forward unrelated LLM-only fields to strict diffusion
|
||||
ingress, unlike the explicit-kwargs library entrypoint.
|
||||
"""
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
# From repo root: pytest -s -v --run-level full_model -m "<mark>" tests/dfx/stability/scripts/...
|
||||
# -m comes from --pytest-mark / PYTEST_MARK when set (e.g. H800 or A3).
|
||||
# Otherwise -m defaults to H800. PYTEST_MARK is rejected unless stability is enabled.
|
||||
# model_type: omni → qwen3_omni; tts → qwen3_tts; diffusion → qwen_image + wan22 + hunyuan_image; all → all five
|
||||
# model_type: omni → qwen3_omni + minicpmo_4_5; tts → qwen3_tts;
|
||||
# diffusion → qwen_image + wan22 + hunyuan_image + minimax_h3 + hunyuanvideo15; all → all eight
|
||||
# stability_minimax_h3: download FastH3 LoRA only when VLLM_TEST_MINIMAX_H3_FASTH3_LORA is unset,
|
||||
# then export before pytest (collection skipif needs the path).
|
||||
# LABEL_SUBSTR: if set, script path / job key / filename must contain it
|
||||
#
|
||||
# Requirements: bash, python3, PyYAML (pip install pyyaml)
|
||||
@@ -596,11 +599,14 @@ def _write_job_timeouts_manifest(jobs_dir: Path, job_timeouts: dict[str, int]) -
|
||||
|
||||
# Fixed stability scripts (when stability is selected); MODEL_TYPES narrows which run.
|
||||
STABILITY_CASES: list[tuple[str, str, tuple[str, ...]]] = [
|
||||
("stability_qwen3_omni", "tests/dfx/stability/scripts/test_stability_qwen3_omni.py", ("omni",)),
|
||||
("stability_qwen3_tts", "tests/dfx/stability/scripts/test_stability_qwen3_tts.py", ("tts",)),
|
||||
("stability_qwen_image", "tests/dfx/stability/scripts/test_stability_qwen_image.py", ("diffusion",)),
|
||||
("stability_wan22", "tests/dfx/stability/scripts/test_stability_wan22.py", ("diffusion",)),
|
||||
("stability_hunyuan_image", "tests/dfx/stability/scripts/test_stability_hunyuan_image.py", ("diffusion",)),
|
||||
("stability_qwen3_omni", "tests/dfx/stability/scripts/run_stability_qwen3_omni.py", ("omni",)),
|
||||
("stability_qwen3_tts", "tests/dfx/stability/scripts/run_stability_qwen3_tts.py", ("tts",)),
|
||||
("stability_minicpmo_4_5", "tests/dfx/stability/scripts/run_stability_minicpmo_4_5.py", ("omni",)),
|
||||
("stability_qwen_image", "tests/dfx/stability/scripts/run_stability_qwen_image.py", ("diffusion",)),
|
||||
("stability_wan22", "tests/dfx/stability/scripts/run_stability_wan22.py", ("diffusion",)),
|
||||
("stability_hunyuan_image", "tests/dfx/stability/scripts/run_stability_hunyuan_image.py", ("diffusion",)),
|
||||
("stability_minimax_h3", "tests/dfx/stability/scripts/run_stability_minimax_h3.py", ("diffusion",)),
|
||||
("stability_hunyuanvideo15", "tests/dfx/stability/scripts/run_stability_hunyuanvideo15.py", ("diffusion",)),
|
||||
]
|
||||
|
||||
|
||||
@@ -612,6 +618,25 @@ def _stability_model_matches(model_types: list[str], families: tuple[str, ...])
|
||||
|
||||
STABILITY_DEFAULT_PYTEST_MARK = "H800"
|
||||
|
||||
# Extra shell lines before pytest. MiniMax-H3 FastH3 skipif reads
|
||||
# VLLM_TEST_MINIMAX_H3_FASTH3_LORA at collection; merge CI downloads the same way.
|
||||
_FASTH3_LORA_DOWNLOAD = (
|
||||
'VLLM_TEST_MINIMAX_H3_FASTH3_LORA="$(python3 -c '
|
||||
"'from huggingface_hub import hf_hub_download; "
|
||||
"print(hf_hub_download(repo_id=\"FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA\", "
|
||||
"filename=\"dense-datafree/adapter_model.safetensors\"))')\""
|
||||
)
|
||||
STABILITY_JOB_PRELUDE: dict[str, list[str]] = {
|
||||
"stability_minimax_h3": [
|
||||
"if [ -f vllm_omni/diffusion/models/minimax_h3/fasth3.py ]; then",
|
||||
' if [ -z "${VLLM_TEST_MINIMAX_H3_FASTH3_LORA:-}" ]; then',
|
||||
f" {_FASTH3_LORA_DOWNLOAD}",
|
||||
" fi",
|
||||
" export VLLM_TEST_MINIMAX_H3_FASTH3_LORA",
|
||||
"fi",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _shell_quote_pytest_mark(marker_expr: str) -> str:
|
||||
"""Shell-safe pytest ``-m`` value for generated bash job scripts."""
|
||||
@@ -839,6 +864,7 @@ def run_stability_mode(jobs_dir: Path, model_types: list[str]) -> int:
|
||||
+ (" (--pytest-mark)" if mark_from_cli else f" (default {STABILITY_DEFAULT_PYTEST_MARK})"),
|
||||
"set -euo pipefail",
|
||||
f'cd "{REPO_ROOT}"',
|
||||
*STABILITY_JOB_PRELUDE.get(key, []),
|
||||
pytest_line,
|
||||
]
|
||||
_write_job_script(key, script_lines, jobs_dir)
|
||||
|
||||
@@ -34,6 +34,9 @@ stages:
|
||||
extra_args:
|
||||
flow_shift: 5.0
|
||||
model_config:
|
||||
# Multi-rank width sharding changes floating-point numerics at shard borders.
|
||||
# Set false for full-frame VAE decode while keeping DiT sequence parallelism.
|
||||
lingbot_vae_spatial_sharding: true
|
||||
ar_diffusion_height: 480
|
||||
ar_diffusion_width: 832
|
||||
ar_diffusion_kv_config:
|
||||
|
||||
@@ -781,22 +781,34 @@ def _decoder_upsample_count(decoder: nn.Module) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_dim: str = "height") -> None:
|
||||
def install_wan_spatial_shard_decode(
|
||||
vae: Any,
|
||||
group: dist.ProcessGroup,
|
||||
split_dim: str = "height",
|
||||
*,
|
||||
dst: int | None = 0,
|
||||
) -> None:
|
||||
"""Patch ``vae.decoder`` once for spatially-sharded decode.
|
||||
|
||||
This mutates the already-loaded decoder in place by swapping its spatial
|
||||
convolutions/padding for halo-exchanging variants and wrapping
|
||||
``decoder.forward``. The patch is permanent for the lifetime of the VAE
|
||||
instance and is applied only once (subsequent calls are no-ops). A given
|
||||
instance is bound to a single ``split_dim``; switching between
|
||||
``"height"`` and ``"width"`` requires a fresh VAE instance and raises here
|
||||
otherwise.
|
||||
instance is bound to a single ``split_dim`` and ``dst``; switching either
|
||||
requires a fresh VAE instance and raises here otherwise.
|
||||
|
||||
Only group-relative rank 0 assembles the final decoded frame, mirroring the
|
||||
distributed tiled-decode ``broadcast_result=False`` contract; the other ranks
|
||||
take part in the collectives but return an empty placeholder.
|
||||
With ``dst=0`` (the default) only group-relative rank 0 assembles the
|
||||
final decoded frame, mirroring the distributed tiled-decode
|
||||
``broadcast_result=False`` contract; the other ranks take part in the
|
||||
collectives but return an empty placeholder. ``dst=None`` lets every rank
|
||||
keep the assembled frame, for a caller whose every rank goes on to
|
||||
consume the decoded pixels; the gather is an all-gather either way, so
|
||||
this costs no extra communication.
|
||||
"""
|
||||
_spatial_dim(split_dim)
|
||||
_, world_size = _rank_world(group)
|
||||
if dst is not None and (not isinstance(dst, int) or not 0 <= dst < world_size):
|
||||
raise ValueError(f"Wan spatial-shard dst must be None or an integer in [0, {world_size}), got {dst!r}.")
|
||||
if getattr(vae, "_vllm_omni_wan_spatial_shard_installed", False):
|
||||
installed_split_dim = getattr(vae, "_vllm_omni_wan_spatial_shard_split_dim", "height")
|
||||
if installed_split_dim != split_dim:
|
||||
@@ -804,6 +816,12 @@ def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_d
|
||||
"Wan spatial-shard VAE decoder was already patched for "
|
||||
f"{installed_split_dim!r} split; create a fresh VAE instance to use {split_dim!r} split."
|
||||
)
|
||||
installed_dst = getattr(vae, "_vllm_omni_wan_spatial_shard_dst", 0)
|
||||
if installed_dst != dst:
|
||||
raise ValueError(
|
||||
"Wan spatial-shard VAE decoder was already patched to assemble on "
|
||||
f"{installed_dst!r}; create a fresh VAE instance to assemble on {dst!r}."
|
||||
)
|
||||
return
|
||||
decoder = getattr(vae, "decoder", None)
|
||||
if decoder is None:
|
||||
@@ -844,11 +862,12 @@ def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_d
|
||||
out = orig_forward(x, feat_cache=feat_cache, feat_idx=feat_idx, first_chunk=first_chunk)
|
||||
finally:
|
||||
_SPATIAL_SHARD_CONTEXT.reset(token)
|
||||
return gather_and_trim_extent(out, expected_extent=expected_extent, split_dim=split_dim, group=group, dst=0)
|
||||
return gather_and_trim_extent(out, expected_extent=expected_extent, split_dim=split_dim, group=group, dst=dst)
|
||||
|
||||
decoder.forward = MethodType(_forward, decoder)
|
||||
vae._vllm_omni_wan_spatial_shard_installed = True
|
||||
vae._vllm_omni_wan_spatial_shard_split_dim = split_dim
|
||||
vae._vllm_omni_wan_spatial_shard_dst = dst
|
||||
logger.info("Installed Wan VAE %s-sharded decode.", split_dim)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from vllm.model_executor.models.utils import AutoWeightsLoader
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan
|
||||
from vllm_omni.diffusion.distributed.autoencoders.wan_spatial_shard import install_wan_spatial_shard_decode
|
||||
from vllm_omni.diffusion.distributed.utils import get_local_device
|
||||
from vllm_omni.diffusion.interaction.mixin import InteractionMixin
|
||||
from vllm_omni.diffusion.interaction.modality_handlers.camera import CameraSession
|
||||
@@ -92,6 +93,10 @@ logger = init_logger(__name__)
|
||||
# measured to hold.
|
||||
_STREAMING_DECODE_BYTES_PER_PIXEL_FP32 = 37832 * 1024 / (64 * 64)
|
||||
|
||||
# The frame dimension the decoder is sharded along when the pipeline runs on
|
||||
# more than one Ulysses rank; see ``_install_sharded_vae_decode``.
|
||||
_VAE_SHARD_SPLIT_DIM = "width"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tqdm.std import tqdm as TqdmProgressBar
|
||||
|
||||
@@ -236,8 +241,6 @@ def _validate_scheduler_config(config: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _validate_parallel_config(od_config: OmniDiffusionConfig) -> None:
|
||||
if getattr(od_config, "quantization_config", None) is not None:
|
||||
raise NotImplementedError("LingBot World v1 does not support quantization.")
|
||||
parallel_config = getattr(od_config, "parallel_config", None)
|
||||
if parallel_config is None:
|
||||
return
|
||||
@@ -580,6 +583,7 @@ class LingBotWorldCausalDMDPipeline(
|
||||
super().__init__()
|
||||
del prefix
|
||||
_validate_parallel_config(od_config)
|
||||
parallel_config = getattr(od_config, "parallel_config", None)
|
||||
self.od_config = od_config
|
||||
model_config = getattr(od_config, "model_config", None) or {}
|
||||
reuse_last_step_kv = model_config.get("lingbot_reuse_last_step_kv", False)
|
||||
@@ -683,6 +687,14 @@ class LingBotWorldCausalDMDPipeline(
|
||||
self._streaming_decode_states: dict[str, StreamingDecodeState] = {}
|
||||
self._cached_streaming_decoder: WanStreamingDecoder | None = None
|
||||
self._streaming_decode_unsupported = False
|
||||
self._vae_shard_split_dim: str | None = None
|
||||
self._vae_shard_world_size = 1
|
||||
vae_sharding = model_config.get("lingbot_vae_spatial_sharding", True)
|
||||
if not isinstance(vae_sharding, bool):
|
||||
raise ValueError("lingbot_vae_spatial_sharding must be a boolean.")
|
||||
sequence_parallel_size = int(getattr(parallel_config, "sequence_parallel_size", 1) or 1)
|
||||
if sequence_parallel_size > 1 and vae_sharding:
|
||||
self._install_sharded_vae_decode(sequence_parallel_size)
|
||||
self.setup_diffusion_pipeline_profiler(
|
||||
profiler_targets=[
|
||||
"vae.encode",
|
||||
@@ -803,9 +815,24 @@ class LingBotWorldCausalDMDPipeline(
|
||||
decoder = self._streaming_decoder()
|
||||
if decoder is None:
|
||||
return 0
|
||||
height, width = self._ar_height, self._ar_width
|
||||
if self._vae_shard_split_dim is not None:
|
||||
# The decoder pads the latent extent to a multiple of the group
|
||||
# size before splitting. Account for that padding on every rank,
|
||||
# including ranks whose final pixels are trimmed after gathering.
|
||||
scale = self.vae_scale_factor_spatial
|
||||
extent = width if self._vae_shard_split_dim == "width" else height
|
||||
latent_extent = (extent + scale - 1) // scale
|
||||
local_extent = (latent_extent + self._vae_shard_world_size - 1) // self._vae_shard_world_size
|
||||
if self._vae_shard_split_dim == "width":
|
||||
width = local_extent * scale
|
||||
else:
|
||||
height = local_extent * scale
|
||||
# Temporal caches retain local inputs, before spatial halo exchange.
|
||||
# Halo receive buffers belong to the decoder, not to each session.
|
||||
return decoder.declared_state_bytes(
|
||||
height=self._ar_height,
|
||||
width=self._ar_width,
|
||||
height=height,
|
||||
width=width,
|
||||
dtype=self.vae.dtype,
|
||||
)
|
||||
|
||||
@@ -1659,6 +1686,51 @@ class LingBotWorldCausalDMDPipeline(
|
||||
tile_latent_min_width = int(getattr(self.vae, "tile_sample_min_width", None) or 0) // compression
|
||||
return bool(latents.shape[-2] > tile_latent_min_height or latents.shape[-1] > tile_latent_min_width)
|
||||
|
||||
def _vae_shard_group(self) -> tuple[Any, int]:
|
||||
"""The Ulysses ranks the decoder is sharded across, and how many there are."""
|
||||
from vllm_omni.diffusion.distributed.parallel_state import get_sp_group
|
||||
|
||||
group = get_sp_group().device_group
|
||||
return group, torch.distributed.get_world_size(group=group)
|
||||
|
||||
def _install_sharded_vae_decode(self, sequence_parallel_size: int) -> None:
|
||||
"""Shard the Wan decoder across the Ulysses ranks along the width.
|
||||
|
||||
The DiT runs on ``sequence_parallel_size`` ranks, and the decode of
|
||||
one AR block used to run on every one of them in full: each rank
|
||||
decoded the whole frame so that its own ``post_decode`` could carry
|
||||
on. Splitting the frame across those same ranks with halo exchange at
|
||||
the convolution borders makes each decode a quarter of the work at
|
||||
four ranks; every rank still ends the block with the assembled frame
|
||||
because the gather is an all-gather, so nothing downstream changes.
|
||||
The streaming decoder is unaffected: ``feat_cache``, ``feat_idx`` and
|
||||
``first_chunk`` pass straight through to the patched decoder, and the
|
||||
per-session temporal cache shrinks by the same factor.
|
||||
|
||||
This is the default for multi-rank deployments; setting
|
||||
``model_config.lingbot_vae_spatial_sharding=False`` keeps full-frame
|
||||
decode on each rank without changing DiT parallelism.
|
||||
``vae_patch_parallel_size`` stays rejected because it would make the
|
||||
registry set up the tiled distributed executor and force
|
||||
tiling on a decoder that is already sharded. Width rather than height
|
||||
because at 480x832 it is the longer side, so each rank keeps a
|
||||
208-pixel shard against the same halo, and it is the split this was
|
||||
measured on: 269.9 -> 98.7 ms per block on 4xH200. Numerically it is
|
||||
not bit-exact against a single-rank decode: the halo-exchanged
|
||||
convolutions accumulate in a different order at the shard borders
|
||||
(max abs diff 0.0195 on [-1, 1] at 480x832).
|
||||
"""
|
||||
group, world_size = self._vae_shard_group()
|
||||
if world_size != sequence_parallel_size:
|
||||
raise RuntimeError(
|
||||
f"LingBot World sequence_parallel_size={sequence_parallel_size} but the Ulysses group has "
|
||||
f"{world_size} ranks."
|
||||
)
|
||||
install_wan_spatial_shard_decode(self.vae, group, split_dim=_VAE_SHARD_SPLIT_DIM, dst=None)
|
||||
self._vae_shard_split_dim = _VAE_SHARD_SPLIT_DIM
|
||||
self._vae_shard_world_size = world_size
|
||||
logger.info("LingBot World VAE decode sharded along %s across %d ranks.", _VAE_SHARD_SPLIT_DIM, world_size)
|
||||
|
||||
def _streaming_decoder(self) -> WanStreamingDecoder | None:
|
||||
"""The session-owned streaming decoder, or ``None`` on a VAE that cannot stream.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import math
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Self, cast
|
||||
from typing import TYPE_CHECKING, Any, Self, cast
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
@@ -36,6 +36,9 @@ from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import (
|
||||
paged_write_attn,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class LingBotAttentionCache:
|
||||
@@ -170,6 +173,7 @@ class LingBotSelfAttention(nn.Module):
|
||||
num_heads: int,
|
||||
*,
|
||||
eps: float = 1e-6,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -188,6 +192,7 @@ class LingBotSelfAttention(nn.Module):
|
||||
head_size=self.head_dim,
|
||||
total_num_heads=num_heads,
|
||||
bias=True,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "qkv"),
|
||||
)
|
||||
self.num_local_heads = self.qkv.num_heads
|
||||
@@ -205,6 +210,7 @@ class LingBotSelfAttention(nn.Module):
|
||||
bias=True,
|
||||
input_is_parallel=True,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "o"),
|
||||
)
|
||||
self.norm_q = _LingBotRMSNorm(self.tp_inner_dim, eps)
|
||||
@@ -416,6 +422,7 @@ class LingBotCrossAttention(nn.Module):
|
||||
num_heads: int,
|
||||
*,
|
||||
eps: float = 1e-6,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -443,6 +450,7 @@ class LingBotCrossAttention(nn.Module):
|
||||
bias=True,
|
||||
gather_output=False,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "q"),
|
||||
)
|
||||
self.k = ColumnParallelLinear(
|
||||
@@ -451,6 +459,7 @@ class LingBotCrossAttention(nn.Module):
|
||||
bias=True,
|
||||
gather_output=False,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "k"),
|
||||
)
|
||||
self.v = ColumnParallelLinear(
|
||||
@@ -459,6 +468,7 @@ class LingBotCrossAttention(nn.Module):
|
||||
bias=True,
|
||||
gather_output=False,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "v"),
|
||||
)
|
||||
self.o = RowParallelLinear(
|
||||
@@ -467,6 +477,7 @@ class LingBotCrossAttention(nn.Module):
|
||||
bias=True,
|
||||
input_is_parallel=True,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "o"),
|
||||
)
|
||||
self.norm_q = _LingBotRMSNorm(self.tp_inner_dim, eps)
|
||||
@@ -528,6 +539,7 @@ class LingBotAttentionBlock(nn.Module):
|
||||
ffn_dim: int | None = None,
|
||||
cross_attn_norm: bool = True,
|
||||
eps: float = 1e-6,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -538,12 +550,14 @@ class LingBotAttentionBlock(nn.Module):
|
||||
dim,
|
||||
num_heads,
|
||||
eps=eps,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "self_attn"),
|
||||
)
|
||||
self.cross_attn = LingBotCrossAttention(
|
||||
dim,
|
||||
num_heads,
|
||||
eps=eps,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "cross_attn"),
|
||||
)
|
||||
self.norm2 = LayerNorm(dim, eps=eps, elementwise_affine=False)
|
||||
@@ -555,6 +569,7 @@ class LingBotAttentionBlock(nn.Module):
|
||||
bias=True,
|
||||
gather_output=False,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "ffn.0"),
|
||||
),
|
||||
nn.GELU(approximate="tanh"),
|
||||
@@ -564,6 +579,7 @@ class LingBotAttentionBlock(nn.Module):
|
||||
bias=True,
|
||||
input_is_parallel=True,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "ffn.2"),
|
||||
),
|
||||
)
|
||||
@@ -574,6 +590,7 @@ class LingBotAttentionBlock(nn.Module):
|
||||
bias=True,
|
||||
gather_output=False,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "cam_injector_layer1"),
|
||||
)
|
||||
self.cam_injector_layer2 = RowParallelLinear(
|
||||
@@ -582,6 +599,7 @@ class LingBotAttentionBlock(nn.Module):
|
||||
bias=True,
|
||||
input_is_parallel=True,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "cam_injector_layer2"),
|
||||
)
|
||||
self.cam_scale_layer = nn.Linear(dim, dim)
|
||||
@@ -823,7 +841,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
|
||||
num_frames_per_block: int = 3,
|
||||
sliding_window_num_frames: int = 18,
|
||||
local_attn_size: int = -1,
|
||||
quant_config: object | None = None,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -853,11 +871,6 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
|
||||
):
|
||||
if field_value is not None:
|
||||
raise ValueError(f"{field_name} must be None because LingBot World v2 has no image embedding path.")
|
||||
if quant_config is not None:
|
||||
raise RuntimeError(
|
||||
"quant_config is not supported by the LingBot World transformer; construct the unquantized model."
|
||||
)
|
||||
|
||||
dim = num_attention_heads * attention_head_dim
|
||||
self.dim = dim
|
||||
self.config = SimpleNamespace(
|
||||
@@ -898,6 +911,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
|
||||
bias=True,
|
||||
gather_output=False,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "c2ws_hidden_states_layer1"),
|
||||
)
|
||||
self.c2ws_hidden_states_layer2 = RowParallelLinear(
|
||||
@@ -906,6 +920,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
|
||||
bias=True,
|
||||
input_is_parallel=True,
|
||||
return_bias=False,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, "c2ws_hidden_states_layer2"),
|
||||
)
|
||||
self.text_embedding = nn.Sequential(
|
||||
@@ -930,6 +945,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
|
||||
ffn_dim=ffn_dim,
|
||||
cross_attn_norm=cross_attn_norm,
|
||||
eps=eps,
|
||||
quant_config=quant_config,
|
||||
prefix=_projection_prefix(prefix, f"blocks.{index}"),
|
||||
)
|
||||
for index in range(num_layers)
|
||||
@@ -976,7 +992,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module):
|
||||
cls,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
quant_config: Any | None = None,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> Self:
|
||||
checkpoint_contract = {
|
||||
|
||||
Reference in new issue
Block a user