276 lines
9.5 KiB
Python
276 lines
9.5 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Validate MiniMax-H3 request-mode and DLO lifecycle behavior.
|
|
|
|
The DLO modes accept any positive data-parallel size. With AllGather enabled,
|
|
the script submits one complete DP wave and verifies recovery after an invalid
|
|
wave. It also checks that every mode shuts down all worker processes cleanly.
|
|
|
|
Examples:
|
|
|
|
VLLM_WORKER_MULTIPROC_METHOD=spawn \
|
|
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \
|
|
VLLM_OMNI_DLO_DP_WAVE_TIMEOUT=600 \
|
|
CUDA_VISIBLE_DEVICES=0,1,2,3 \
|
|
python examples/offline_inference/minimax_h3/dlo_lifecycle.py \
|
|
--model /path/to/MiniMax-H3/FL2VA --mode dlo --dp-size 4
|
|
|
|
# Rank-local mmap storage, with no DLO collective or DP wave scheduling.
|
|
VLLM_WORKER_MULTIPROC_METHOD=spawn \
|
|
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \
|
|
CUDA_VISIBLE_DEVICES=0,1 \
|
|
python examples/offline_inference/minimax_h3/dlo_lifecycle.py \
|
|
--model /path/to/MiniMax-H3/FL2VA --mode dlo-no-allgather --dp-size 2
|
|
|
|
VLLM_WORKER_MULTIPROC_METHOD=spawn \
|
|
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \
|
|
CUDA_VISIBLE_DEVICES=0,1 \
|
|
python examples/offline_inference/minimax_h3/dlo_lifecycle.py \
|
|
--model /path/to/MiniMax-H3/FL2VA --mode request --tp-size 2
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import copy
|
|
import json
|
|
import multiprocessing
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
|
|
from vllm_omni.entrypoints.async_omni import AsyncOmni
|
|
|
|
DEFAULT_PROMPTS = (
|
|
"At night, three cats march into a bedroom playing tiny brass instruments, "
|
|
"then abruptly file out, with synchronized room ambience.",
|
|
"A paper boat crosses a rain-filled street while distant traffic and water sounds remain synchronized.",
|
|
)
|
|
|
|
|
|
def positive_int(value: str) -> int:
|
|
parsed = int(value)
|
|
if parsed < 1:
|
|
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
return parsed
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--model", required=True, help="Path to MiniMax-H3/FL2VA")
|
|
parser.add_argument(
|
|
"--mode",
|
|
choices=("dlo", "dlo-no-allgather", "request"),
|
|
required=True,
|
|
help="DLO (with or without AllGather) or ordinary non-DLO request mode",
|
|
)
|
|
parser.add_argument(
|
|
"--dp-size",
|
|
type=positive_int,
|
|
default=2,
|
|
help="DLO data-parallel size (for example 1, 2, 4, or 8; default: 2)",
|
|
)
|
|
parser.add_argument(
|
|
"--tp-size",
|
|
type=positive_int,
|
|
default=2,
|
|
help="Tensor-parallel size used only by request mode (default: 2)",
|
|
)
|
|
parser.add_argument("--steps", type=int, default=2)
|
|
parser.add_argument("--duration", type=float, default=5.0)
|
|
parser.add_argument("--width", type=int, default=1344)
|
|
parser.add_argument("--height", type=int, default=768)
|
|
parser.add_argument("--batch-wait-ms", type=float, default=500.0)
|
|
parser.add_argument("--init-timeout", type=float, default=1800.0)
|
|
parser.add_argument("--output", type=Path)
|
|
return parser.parse_args()
|
|
|
|
|
|
def engine_kwargs(args: argparse.Namespace) -> dict[str, Any]:
|
|
is_dlo = args.mode in {"dlo", "dlo-no-allgather"}
|
|
common: dict[str, Any] = {
|
|
"model": args.model,
|
|
"trust_remote_code": True,
|
|
"num_gpus": args.dp_size if is_dlo else args.tp_size,
|
|
"usp": 1,
|
|
"ring": 1,
|
|
"vae_parallel_mode": "tile",
|
|
"vae_use_tiling": True,
|
|
"diffusion_attention_backend": "CUDNN_ATTN",
|
|
"request_batch_max_wait_ms": args.batch_wait_ms,
|
|
"enforce_eager": True,
|
|
"stage_init_timeout": args.init_timeout,
|
|
"init_timeout": args.init_timeout,
|
|
}
|
|
if is_dlo:
|
|
common.update(
|
|
tensor_parallel_size=1,
|
|
data_parallel_size=args.dp_size,
|
|
text_encoder_tp_size=1,
|
|
vae_patch_parallel_size=1,
|
|
enable_distributed_layerwise_offload=True,
|
|
dlo_use_allgather=args.mode == "dlo",
|
|
dlo_resident_layers=0,
|
|
)
|
|
else:
|
|
common.update(
|
|
tensor_parallel_size=args.tp_size,
|
|
data_parallel_size=1,
|
|
text_encoder_tp_size=args.tp_size,
|
|
vae_patch_parallel_size=args.tp_size,
|
|
enable_distributed_layerwise_offload=False,
|
|
)
|
|
return common
|
|
|
|
|
|
def sampling_params(
|
|
engine: AsyncOmni,
|
|
args: argparse.Namespace,
|
|
seed: int,
|
|
) -> list[Any]:
|
|
params = copy.deepcopy(engine.default_sampling_params_list)
|
|
diffusion = params[0]
|
|
diffusion.width = args.width
|
|
diffusion.height = args.height
|
|
diffusion.fps = 24
|
|
diffusion.num_inference_steps = args.steps
|
|
diffusion.seed = seed
|
|
diffusion.extra_args = {
|
|
"task": "t2va",
|
|
"duration": args.duration,
|
|
"aspect_ratio": "16:9",
|
|
"flow_shift": 12.0,
|
|
"audio_flow_shift": 3.0,
|
|
}
|
|
return params
|
|
|
|
|
|
async def generate_one(
|
|
engine: AsyncOmni,
|
|
args: argparse.Namespace,
|
|
*,
|
|
request_id: str,
|
|
prompt: str,
|
|
seed: int,
|
|
) -> Any:
|
|
final_output = None
|
|
async for output in engine.generate(
|
|
prompt=prompt,
|
|
request_id=request_id,
|
|
sampling_params_list=sampling_params(engine, args, seed),
|
|
):
|
|
if output.finished:
|
|
final_output = output
|
|
if final_output is None:
|
|
raise RuntimeError(f"{request_id} finished without an output")
|
|
return final_output
|
|
|
|
|
|
def output_summary(output: Any, args: argparse.Namespace) -> dict[str, Any]:
|
|
if not output.images:
|
|
raise RuntimeError(f"{output.request_id} returned no video")
|
|
frames = np.asarray(output.images[0])
|
|
audio = np.asarray(output.multimodal_output.get("audio"))
|
|
if frames.ndim != 4 or tuple(frames.shape[1:]) != (
|
|
args.height,
|
|
args.width,
|
|
3,
|
|
):
|
|
raise RuntimeError(f"{output.request_id} returned invalid video shape {tuple(frames.shape)}")
|
|
if audio.ndim != 3 or tuple(audio.shape[:2]) != (1, 2):
|
|
raise RuntimeError(f"{output.request_id} returned invalid audio shape {tuple(audio.shape)}")
|
|
if args.duration == 5.0 and args.height == 768 and args.width == 1344:
|
|
if tuple(frames.shape) != (124, 768, 1344, 3) or tuple(audio.shape) != (1, 2, 165600):
|
|
raise RuntimeError(
|
|
f"{output.request_id} default shape mismatch: video={tuple(frames.shape)}, audio={tuple(audio.shape)}"
|
|
)
|
|
return {
|
|
"request_id": output.request_id,
|
|
"frames_shape": list(frames.shape),
|
|
"audio_shape": list(audio.shape),
|
|
"peak_memory_mb": output.peak_memory_mb,
|
|
"stage_durations": output.stage_durations,
|
|
}
|
|
|
|
|
|
async def run(args: argparse.Namespace) -> dict[str, Any]:
|
|
engine = AsyncOmni(**engine_kwargs(args))
|
|
summary: dict[str, Any] = {
|
|
"mode": args.mode,
|
|
"engine_kwargs": engine_kwargs(args),
|
|
}
|
|
try:
|
|
uses_dp_wave = args.mode == "dlo" and args.dp_size > 1
|
|
if uses_dp_wave:
|
|
started = time.perf_counter()
|
|
asymmetric = await asyncio.gather(
|
|
generate_one(
|
|
engine,
|
|
args,
|
|
request_id="invalid-empty",
|
|
prompt="",
|
|
seed=1001,
|
|
),
|
|
*(
|
|
generate_one(
|
|
engine,
|
|
args,
|
|
request_id=f"invalid-peer-{index}",
|
|
prompt=DEFAULT_PROMPTS[index % len(DEFAULT_PROMPTS)],
|
|
seed=1001 + index,
|
|
)
|
|
for index in range(1, args.dp_size)
|
|
),
|
|
return_exceptions=True,
|
|
)
|
|
summary["asymmetric_wave_s"] = time.perf_counter() - started
|
|
summary["asymmetric_errors"] = [
|
|
f"{type(result).__name__}: {result}" for result in asymmetric if isinstance(result, BaseException)
|
|
]
|
|
if len(summary["asymmetric_errors"]) != args.dp_size:
|
|
raise RuntimeError("Every request in the asymmetric DP wave must fail before dispatch")
|
|
|
|
started = time.perf_counter()
|
|
request_count = args.dp_size if uses_dp_wave else 1
|
|
outputs = await asyncio.gather(
|
|
*(
|
|
generate_one(
|
|
engine,
|
|
args,
|
|
request_id=f"recovery-{index}",
|
|
prompt=DEFAULT_PROMPTS[index % len(DEFAULT_PROMPTS)],
|
|
seed=2000 + index,
|
|
)
|
|
for index in range(request_count)
|
|
)
|
|
)
|
|
summary["valid_wave_s"] = time.perf_counter() - started
|
|
summary["outputs"] = [output_summary(output, args) for output in outputs]
|
|
finally:
|
|
started = time.perf_counter()
|
|
engine.close()
|
|
summary["shutdown_s"] = time.perf_counter() - started
|
|
|
|
children = multiprocessing.active_children()
|
|
summary["active_children"] = [{"name": child.name, "pid": child.pid} for child in children]
|
|
if children:
|
|
raise RuntimeError(f"Worker processes remain after shutdown: {children}")
|
|
return summary
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
summary = asyncio.run(run(args))
|
|
rendered = json.dumps(summary, indent=2, sort_keys=True)
|
|
print(f"E2E_RESULT {rendered}", flush=True)
|
|
if args.output is not None:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(rendered + "\n", encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|