Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4ea67a21b | ||
|
|
e0c2b9d73f | ||
|
|
24741961eb | ||
|
|
727f90958e | ||
|
|
67c54777bb | ||
|
|
2c4e9a945b |
No files matched your search
@@ -457,14 +457,20 @@ steps:
|
||||
- group: ":card_index_dividers: Diffusion Test"
|
||||
key: nightly-diffusion-test-group
|
||||
depends_on: upload-nightly-pipeline
|
||||
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "diffusion-x2iat-test"
|
||||
if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test"
|
||||
steps:
|
||||
- label: ":full_moon: Diffusion · Distributed Test"
|
||||
- label: ":full_moon: Diffusion · Distributed Test with L4"
|
||||
timeout_in_minutes: 60
|
||||
commands:
|
||||
- pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda' --run-level "full_model"
|
||||
- pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and L4' --run-level "full_model"
|
||||
mirror_hardwares: l4_4
|
||||
|
||||
- label: ":full_moon: Diffusion · Distributed Test with H100"
|
||||
timeout_in_minutes: 60
|
||||
commands:
|
||||
- pytest -sv tests/diffusion/distributed/ -m 'full_model and cuda and H100' --run-level "full_model"
|
||||
mirror_hardwares: h100_4
|
||||
|
||||
- label: ":full_moon: Diffusion · Offloader Test"
|
||||
timeout_in_minutes: 60
|
||||
commands:
|
||||
|
||||
@@ -95,6 +95,80 @@ steps:
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
|
||||
exit $$EXIT
|
||||
- group: ":card_index_dividers: Diffusion I2I Model Test"
|
||||
key: nightly-HunyuanImage3-x2i-test-group
|
||||
depends_on: upload-nightly-pipeline
|
||||
if: >-
|
||||
build.env("NIGHTLY") == "1" ||
|
||||
build.pull_request.labels includes "nightly-test" ||
|
||||
build.pull_request.labels includes "diffusion-x2iat-test"
|
||||
steps:
|
||||
- label: ":full_moon: HunyuanImage3-DIT · Accuracy Test"
|
||||
timeout_in_minutes: 180
|
||||
mirror_hardwares: a3_npu_4
|
||||
env:
|
||||
VLLM_WORKER_MULTIPROC_METHOD: spawn
|
||||
HUNYUAN_IMAGE3_MODEL: "tencent/HunyuanImage-3.0-Instruct"
|
||||
HUNYUAN_IMAGE3_DEVICES: "0,1,2,3"
|
||||
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py -m "full_model and A3" --run-level full_model
|
||||
- group: ":card_index_dividers: Diffusion Benchmark Perf Test"
|
||||
key: nightly-diffusion-benchmark-group
|
||||
depends_on: upload-nightly-pipeline
|
||||
if: >-
|
||||
build.env("NIGHTLY") == "1" ||
|
||||
build.pull_request.labels includes "nightly-test" ||
|
||||
build.pull_request.labels includes "diffusion-x2iat-test"
|
||||
steps:
|
||||
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=2 CFGP=2"
|
||||
timeout_in_minutes: 180
|
||||
mirror_hardwares: a3_npu_4
|
||||
env:
|
||||
VLLM_WORKER_MULTIPROC_METHOD: spawn
|
||||
HF_TOKEN: "${HF_TOKEN}"
|
||||
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
|
||||
commands:
|
||||
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json
|
||||
EXIT=$$?
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
|
||||
exit $$EXIT
|
||||
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=2 SP=2"
|
||||
timeout_in_minutes: 180
|
||||
mirror_hardwares: a3_npu_4
|
||||
env:
|
||||
VLLM_WORKER_MULTIPROC_METHOD: spawn
|
||||
HF_TOKEN: "${HF_TOKEN}"
|
||||
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
|
||||
commands:
|
||||
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json
|
||||
EXIT=$$?
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
|
||||
exit $$EXIT
|
||||
- label: ":full_moon: Diffusion Perf · HunyuanImage3 TP=4"
|
||||
timeout_in_minutes: 180
|
||||
mirror_hardwares: a3_npu_4
|
||||
env:
|
||||
VLLM_WORKER_MULTIPROC_METHOD: spawn
|
||||
HF_TOKEN: "${HF_TOKEN}"
|
||||
DIFFUSION_ATTENTION_BACKEND: TORCH_SDPA
|
||||
commands:
|
||||
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
|
||||
- |
|
||||
set +e
|
||||
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m A3 --test-config-file tests/dfx/perf/tests/test_hunyuan_image_tp4.json
|
||||
EXIT=$$?
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
|
||||
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
|
||||
exit $$EXIT
|
||||
- group: ":card_index_dividers: Diffusion X2V Model Test"
|
||||
key: nightly-diffusion-x2v-group
|
||||
depends_on: upload-nightly-pipeline
|
||||
@@ -103,6 +177,14 @@ steps:
|
||||
build.pull_request.labels includes "nightly-test" ||
|
||||
build.pull_request.labels includes "diffusion-x2v-test"
|
||||
steps:
|
||||
- label: ":full_moon: Diffusion X2V · Function Test"
|
||||
mirror_hardwares: a2b3_npu_4
|
||||
env:
|
||||
VLLM_WORKER_MULTIPROC_METHOD: spawn
|
||||
VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1"
|
||||
HF_TOKEN: "${HF_TOKEN}"
|
||||
commands:
|
||||
- pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k npu_i2v --run-level "full_model"
|
||||
- label: ":full_moon: Diffusion X2V · Perf Test"
|
||||
key: nightly-diffusion-x2v-performance
|
||||
timeout_in_minutes: 180
|
||||
|
||||
@@ -45,6 +45,7 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \
|
||||
"hyperpyyaml>=1.2.0" \
|
||||
"onnx>=1.16.0" \
|
||||
"librosa>=0.10.0" \
|
||||
"torchmetrics>=1.9.0" \
|
||||
--index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \
|
||||
--trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \
|
||||
--default-timeout=60 && \
|
||||
|
||||
@@ -44,6 +44,7 @@ th {
|
||||
| `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | |
|
||||
| `LTX2DistilledPipeline` | LTX-2 distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | |
|
||||
| `LingBotVideoPipeline` | LingBot-Video dense and MoE T2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | |
|
||||
| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, FL2VA, and Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | | | |
|
||||
| `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
|
||||
| `HeliosPipeline`, `HeliosPyramidPipeline` | Helios | `BestWishYsh/Helios-Base`, `BestWishYsh/Helios-Mid`, `BestWishYsh/Helios-Distilled` | ✅︎ | ✅︎ | ✅︎ | |
|
||||
| `MagiHumanPipeline` | MagiHuman | `SII-GAIR/daVinci-MagiHuman-Base-1080p` | ✅︎ | ✅︎ | | |
|
||||
|
||||
@@ -14,8 +14,8 @@ The full set of backends and their platform defaults is in the **Backend Options
|
||||
|
||||
| Value | Notes |
|
||||
|---|---|
|
||||
| `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Supports **Skip-Softmax** sparse attention and **SAGE** quantized attention — see below. Requires `flashinfer`. |
|
||||
| `FLASH_ATTN` | Wraps FlashAttention 2. Default on Hopper / Ada / Ampere when `flash-attn` is installed. |
|
||||
| `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Supports **Skip-Softmax** sparse attention — see below. Requires `flashinfer`. |
|
||||
| `FLASH_ATTN` | Wraps FlashAttention 4 on Blackwell when `flash-attn-4` is installed, then falls back to FlashAttention 3/2. Default on Hopper / Ada / Ampere when a compatible FlashAttention package is installed. |
|
||||
| `CUDNN_ATTN` | Pins `sdpa_kernel([CUDNN_ATTENTION])`. Default on Blackwell (sm_10x / sm_12x) with cuDNN ≥ 9.5. Wins on mask-heavy DiTs (HunyuanVideo-1.5: 2× e2e vs SDPA). |
|
||||
| `FLASHINFER_ATTN` | Calls FlashInfer's dense `single_prefill_with_kv_cache` directly with `custom_mask` for non-causal masked attention. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer`. |
|
||||
| `TORCH_SDPA` | PyTorch `scaled_dot_product_attention` with the default backend dispatcher. Most conservative; always available. |
|
||||
@@ -222,6 +222,30 @@ Same prompt and seed across runs. `Total generation time` from `text_to_video.py
|
||||
|
||||
Pattern: mask-heavy DiTs (HV-1.5, Qwen-Image) favor `CUDNN_ATTN`; lighter-mask DiTs and TP-saturated configs (Wan 2.2, FLUX.2 TP=2) tie within noise.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### LTX-2.0: `CUDNN_ATTN` crashes under torch.compile
|
||||
|
||||
LTX-2's audio attention has a symbolic head_dim under torch.compile tracing. cuDNN's SDPA backend selector rejects symbolic dims and Dynamo aborts compilation. Tracked in [#3121](https://github.com/vllm-project/vllm-omni/issues/3121).
|
||||
|
||||
**Workaround**: explicitly select `FLASHINFER_ATTN` or `TORCH_SDPA` for LTX-2.0:
|
||||
|
||||
```bash
|
||||
DIFFUSION_ATTENTION_BACKEND=FLASHINFER_ATTN python examples/offline_inference/text_to_video/text_to_video.py \
|
||||
--model Lightricks/LTX-2 ...
|
||||
```
|
||||
|
||||
### FlashAttention-4 on Blackwell
|
||||
|
||||
Install the optional CUDA 13 extra to use the CuTe-based FA4 path:
|
||||
|
||||
```bash
|
||||
pip install 'vllm-omni[fa4]'
|
||||
```
|
||||
|
||||
`FLASH_ATTN` prefers `flash_attn.cute` on Blackwell and falls back to FA3/FA2
|
||||
when it is unavailable. Version `4.0.0b18` is required; earlier beta
|
||||
wheels had known JIT failures on Blackwell.
|
||||
|
||||
## Choosing a Backend Manually
|
||||
|
||||
|
||||
@@ -96,6 +96,12 @@ quack = [
|
||||
"quack-kernels>=0.3.11",
|
||||
]
|
||||
|
||||
# Optional Blackwell-native FlashAttention-4 backend. Keep this opt-in because
|
||||
# the CuTe/CUTLASS dependency is CUDA 13 and Blackwell specific.
|
||||
fa4 = [
|
||||
"flash-attn-4[cu13]==4.0.0b18",
|
||||
]
|
||||
|
||||
docs = [
|
||||
"mkdocs>=1.5.0",
|
||||
"mkdocs-api-autonav",
|
||||
|
||||
Executable
+162
@@ -0,0 +1,162 @@
|
||||
# MiniMax H3 on Ascend NPU
|
||||
|
||||
> Joint video and audio generation with text, first-frame, image/audio, or
|
||||
> multi-video conditions — Ascend NPU deployment guide
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: MiniMaxAI
|
||||
- Model: [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3)
|
||||
- Tasks: T2VA, FL2VA, and Ref2VA
|
||||
- Mode: OpenAI-compatible `/v1/videos` HTTP serving
|
||||
- Hardware: Atlas 800I A3
|
||||
- Maintainer: Community
|
||||
|
||||
This recipe adapts [MiniMax-H3.md](MiniMax-H3.md) for Ascend NPU
|
||||
environments. Differences from the GPU path:
|
||||
|
||||
- Runs on a CPU-only (aarch64) PyTorch build with `torch_npu`; no CUDA
|
||||
runtime is present.
|
||||
- Audio loading does **not** require TorchCodec (whose aarch64 wheels are
|
||||
built against CUDA torch and fail to load on CPU-only builds). vLLM-Omni
|
||||
automatically falls back to soundfile / ffmpeg for wav/mp3/m4a/mp4 audio
|
||||
inputs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Checkpoint
|
||||
|
||||
Same as the GPU recipe — Hugging Face access approval is required:
|
||||
|
||||
```bash
|
||||
hf auth login
|
||||
export MODEL_ROOT=/path/to/MiniMax-H3
|
||||
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
|
||||
```
|
||||
|
||||
### Environment
|
||||
|
||||
- Ascend driver & firmware: 25.5.1
|
||||
- CANN toolkit: 9.0.1
|
||||
- Python: 3.12
|
||||
- PyTorch: 2.10.0+cpu
|
||||
- torch_npu: 2.10.0.post2
|
||||
- Install vLLM-Omni from a checkout with MiniMax H3 support:
|
||||
|
||||
```bash
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
Install the **mindie-sd** operator library to enable Ascend-optimized fused
|
||||
operators (`adalayernorm`, etc.):
|
||||
|
||||
```bash
|
||||
git clone https://gitcode.com/Ascend/MindIE-SD.git && cd MindIE-SD
|
||||
|
||||
# Comment out the tik_ops build step (not needed for this use case)
|
||||
sed -i 's|^\(\s*\)source ${current_script_dir}/build_tik_ops.sh|\1# source ${current_script_dir}/build_tik_ops.sh|' build/build_ops.sh
|
||||
|
||||
python setup.py bdist_wheel
|
||||
cd dist
|
||||
pip install mindiesd-*.whl
|
||||
```
|
||||
|
||||
- `ffmpeg` and `ffprobe` must be available on `PATH`. They are used for
|
||||
reference-video preparation and MP4 output.
|
||||
- Reference-video decoding uses `decord` when available and falls back to
|
||||
PyAV otherwise.
|
||||
- Audio inputs do not require TorchCodec: wav/mp3/m4a/mp4 files are loaded
|
||||
at native sample rate through the soundfile / ffmpeg fallback.
|
||||
|
||||
## Start a server
|
||||
|
||||
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
|
||||
and FL2VA requests, or to `Ref2VA` for Ref2VA requests.
|
||||
|
||||
### Multi-NPU: 768P validated configuration
|
||||
|
||||
Validated on eight NPUs of an Atlas 800I A3 server with Ulysses sequence
|
||||
parallelism degree 8, text-encoder tensor parallelism degree 8, native tiled
|
||||
VAE patch parallelism degree 8, and layerwise offload:
|
||||
|
||||
```bash
|
||||
export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
|
||||
export PORT=9098
|
||||
export MODEL="${MODEL_ROOT}/FL2VA"
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
export HF_HUB_OFFLINE=1
|
||||
export TRANSFORMERS_OFFLINE=1
|
||||
|
||||
vllm serve "${MODEL}" \
|
||||
--omni \
|
||||
--host 0.0.0.0 \
|
||||
--port "${PORT}" \
|
||||
--trust-remote-code \
|
||||
--num-gpus 8 \
|
||||
--usp 8 \
|
||||
--ring 1 \
|
||||
--text-encoder-tp-size 8 \
|
||||
--enable-layerwise-offload \
|
||||
--vae-parallel-mode tile \
|
||||
--vae-use-tiling \
|
||||
--vae-patch-parallel-size 8 \
|
||||
--diffusion-attention-backend FLASH_ATTN
|
||||
```
|
||||
|
||||
Do not add `--enforce-eager`. The first request includes regional
|
||||
compilation; warm the server once before measuring steady-state latency.
|
||||
H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1.
|
||||
|
||||
To serve Ref2VA, stop the FL2VA server and restart with:
|
||||
|
||||
```bash
|
||||
export MODEL="${MODEL_ROOT}/Ref2VA"
|
||||
```
|
||||
|
||||
## HTTP API examples
|
||||
|
||||
The request format is identical to the GPU recipe; see
|
||||
[MiniMax-H3.md § HTTP API examples](MiniMax-H3.md#http-api-examples).
|
||||
Use the validated 768P shapes (e.g. `width=1344 height=768`) on NPU.
|
||||
|
||||
## Key parameters
|
||||
|
||||
Same as the GPU recipe; see
|
||||
[MiniMax-H3.md § Key parameters](MiniMax-H3.md#key-parameters).
|
||||
The validated resolution on NPU is 768P (e.g. 1344x768).
|
||||
|
||||
## Validated NPU evidence
|
||||
|
||||
Measured on an Atlas 800I A3 server (8x NPU) with CANN 9.0.1,
|
||||
PyTorch 2.10.0+cpu, and torch_npu 2.10.0.post2, using the multi-NPU
|
||||
configuration above:
|
||||
|
||||
| Workload | Configuration |
|
||||
|----------|---------------|
|
||||
| T2VA, 209 frames, 1344x768 | TE TP8, layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
| Ref2VA (prompt + video), 124 frames, 1344x768 | TE TP8, layerwise offload, Ulysses 8, VPP8 tile, regional compile |
|
||||
|
||||
These measurements describe the validated shapes rather than a general
|
||||
throughput guarantee.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Each server process loads only one checkpoint partition.
|
||||
- H3 currently executes one generation request per diffusion batch.
|
||||
- The first regional-compile request is a warmup and should not be included
|
||||
in steady-state performance measurements.
|
||||
- Image+audio Ref2VA accepts exactly one image and one audio reference.
|
||||
- Video Ref2VA accepts one or more video files, but not an additional
|
||||
standalone audio reference.
|
||||
- VAE patch parallelism requires size 1 or the full DiT group size and
|
||||
supports the H3 native `tile` mode only.
|
||||
|
||||
## Additional resources
|
||||
|
||||
- [MiniMax-H3.md](MiniMax-H3.md) — full GPU guide
|
||||
- [Supported models](../../docs/models/supported_models.md)
|
||||
- [Video API](../../docs/serving/videos_api.md)
|
||||
@@ -0,0 +1,329 @@
|
||||
# MiniMax H3
|
||||
|
||||
> Joint video and audio generation with text, first-frame, image/audio, or
|
||||
> multi-video conditions
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: MiniMaxAI
|
||||
- Model: [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3)
|
||||
- Tasks: T2VA, FL2VA, and Ref2VA
|
||||
- Mode: OpenAI-compatible `/v1/videos` HTTP serving
|
||||
- Maintainer: Community
|
||||
|
||||
MiniMax H3 is a CFG-distilled joint video/audio diffusion transformer. Its
|
||||
checkpoint has two independently served partitions:
|
||||
|
||||
- `FL2VA`: text-to-video+audio (`t2va`) and first-frame-to-video+audio
|
||||
(`fl2va`)
|
||||
- `Ref2VA`: image+audio or one-or-more-video reference-to-video+audio
|
||||
(`ref2va`)
|
||||
|
||||
The generated MP4 contains H.264 video and synchronized stereo audio.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The checkpoint requires Hugging Face access approval. Download it and point
|
||||
`MODEL_ROOT` at the directory containing the `FL2VA` and `Ref2VA`
|
||||
subdirectories:
|
||||
|
||||
```bash
|
||||
hf auth login
|
||||
export MODEL_ROOT=/path/to/MiniMax-H3
|
||||
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
|
||||
```
|
||||
|
||||
Install vLLM-Omni from the checkout containing MiniMax H3 support. On
|
||||
Blackwell, install the optional FlashAttention-4 dependency:
|
||||
|
||||
```bash
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
uv pip install -e '.[fa4]'
|
||||
```
|
||||
|
||||
`ffmpeg` and `ffprobe` must be available on `PATH`. They are used for
|
||||
reference-video preparation and MP4 output.
|
||||
|
||||
## Start a server
|
||||
|
||||
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
|
||||
and FL2VA requests, or to `Ref2VA` for either Ref2VA request.
|
||||
|
||||
### Single GPU: accuracy and memory first
|
||||
|
||||
The single-GPU configuration uses model-level CPU offload.
|
||||
This matches the accuracy-qualified reference path and prevents the Qwen3-VL
|
||||
encoder and DiT from being resident on the GPU at the same time.
|
||||
|
||||
```bash
|
||||
export MODEL="${MODEL_ROOT}/FL2VA"
|
||||
export PORT=8091
|
||||
|
||||
CUDA_VISIBLE_DEVICES=0 \
|
||||
VLLM_WORKER_MULTIPROC_METHOD=spawn \
|
||||
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
|
||||
vllm serve "${MODEL}" \
|
||||
--omni \
|
||||
--host 0.0.0.0 \
|
||||
--port "${PORT}" \
|
||||
--trust-remote-code \
|
||||
--num-gpus 1 \
|
||||
--enable-cpu-offload \
|
||||
--diffusion-attention-backend FLASH_ATTN
|
||||
```
|
||||
|
||||
Use a GPU with enough memory for the active H3 component and enough system RAM
|
||||
for the offloaded components. CPU offload reduces GPU memory pressure but adds
|
||||
PCIe/NVLink transfer latency.
|
||||
|
||||
### Four GPUs: measured best-practice throughput
|
||||
|
||||
The best validated four-GPU configuration on four NVIDIA B300 GPUs is:
|
||||
|
||||
- no CPU or layerwise offload;
|
||||
- Ulysses sequence parallelism degree 4;
|
||||
- native tiled VAE patch parallelism degree 4;
|
||||
- regional `torch.compile` for the repeated DiT blocks;
|
||||
- FlashAttention, with Ring and TP left at 1.
|
||||
|
||||
```bash
|
||||
export MODEL="${MODEL_ROOT}/FL2VA"
|
||||
export PORT=8091
|
||||
|
||||
CUDA_VISIBLE_DEVICES=0,1,2,3 \
|
||||
FLASHINFER_DISABLE_VERSION_CHECK=1 \
|
||||
VLLM_WORKER_MULTIPROC_METHOD=spawn \
|
||||
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
|
||||
vllm serve "${MODEL}" \
|
||||
--omni \
|
||||
--host 0.0.0.0 \
|
||||
--port "${PORT}" \
|
||||
--trust-remote-code \
|
||||
--num-gpus 4 \
|
||||
--usp 4 \
|
||||
--ring 1 \
|
||||
--vae-patch-parallel-size 4 \
|
||||
--vae-parallel-mode tile \
|
||||
--vae-use-tiling \
|
||||
--diffusion-attention-backend FLASH_ATTN
|
||||
```
|
||||
|
||||
Do not add `--enforce-eager` to this performance configuration. The first
|
||||
request includes regional compilation; warm the server once before measuring
|
||||
steady-state latency. H3 is CFG-distilled, so `--cfg-parallel-size` must remain
|
||||
1. The H3 VAE supports its native `tile` mode, not
|
||||
`spatial_shard_height` or `spatial_shard_width`.
|
||||
|
||||
### Text encoder tensor parallelism
|
||||
|
||||
The Qwen3-VL text encoder (~51.5 GB in BF16 for the retained 50 layers) is by
|
||||
default fully resident on the DiT main rank. On multi-GPU no-offload runs that
|
||||
rank becomes the peak-memory hotspot. Add `--text-encoder-tp-size N` to shard
|
||||
the encoder across the first `N` DiT ranks (the encoder is implemented with
|
||||
vLLM-style tensor-parallel layers and runs with distributed collectives over
|
||||
its own encoder process group):
|
||||
|
||||
```bash
|
||||
export MODEL="${MODEL_ROOT}/FL2VA"
|
||||
export PORT=8091
|
||||
|
||||
CUDA_VISIBLE_DEVICES=0,1,2,3 \
|
||||
FLASHINFER_DISABLE_VERSION_CHECK=1 \
|
||||
VLLM_WORKER_MULTIPROC_METHOD=spawn \
|
||||
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
|
||||
vllm serve "${MODEL}" \
|
||||
--omni \
|
||||
--host 0.0.0.0 \
|
||||
--port "${PORT}" \
|
||||
--trust-remote-code \
|
||||
--num-gpus 4 \
|
||||
--usp 4 \
|
||||
--ring 1 \
|
||||
--text-encoder-tp-size 4 \
|
||||
--vae-patch-parallel-size 4 \
|
||||
--vae-parallel-mode tile \
|
||||
--vae-use-tiling \
|
||||
--diffusion-attention-backend FLASH_ATTN
|
||||
```
|
||||
|
||||
`N` must divide the Qwen3-VL head counts (64 attention heads / 8 KV heads), so
|
||||
valid values on a 4-GPU server are 1, 2, and 4 (1, 2, 4, 8 on 8 GPUs). The
|
||||
encoder TP rank set is the first `N` DiT ranks; on 4 GPUs
|
||||
`--text-encoder-tp-size 4` shards the encoder 4-way, dropping the DiT main
|
||||
rank's no-offload peak by roughly `(N-1)/N` of the ~51.5 GB encoder while the
|
||||
other ranks each gain `~51.5/N` GB. The encoder output remains identical to
|
||||
the reference path within bf16 rounding: every encoder rank all-reduces the
|
||||
row-parallel projections, so the full `[seq, 5120]` layer-50 hidden state is
|
||||
replicated on every rank.
|
||||
|
||||
To serve Ref2VA, stop the FL2VA server and restart the same one- or four-GPU
|
||||
command with:
|
||||
|
||||
```bash
|
||||
export MODEL="${MODEL_ROOT}/Ref2VA"
|
||||
```
|
||||
|
||||
## HTTP API examples
|
||||
|
||||
The following requests use the synchronous endpoint so the returned body can
|
||||
be saved directly as an MP4. The asynchronous `POST /v1/videos` endpoint can
|
||||
also be used when job polling is preferred.
|
||||
|
||||
All four tasks use 24 FPS, 50 sigma points, seed values from the validated
|
||||
workloads, and the checkpoint-reference video/audio flow shifts of 12 and 3.
|
||||
Decimal durations are passed through `extra_params`.
|
||||
|
||||
Set the endpoint once:
|
||||
|
||||
```bash
|
||||
export API_URL="http://127.0.0.1:${PORT}/v1/videos/sync"
|
||||
```
|
||||
|
||||
### 1. T2VA: text to video and audio
|
||||
|
||||
Run this request against the `FL2VA` partition:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "${API_URL}" \
|
||||
-F 'prompt=In a snowy blue-purple forest, Ori carefully walks past a sleeping giant; footsteps crunch in the snow while the creature breathes and softly snorts.' \
|
||||
-F 'width=1344' \
|
||||
-F 'height=768' \
|
||||
-F 'fps=24' \
|
||||
-F 'num_inference_steps=50' \
|
||||
-F 'flow_shift=12' \
|
||||
-F 'seed=1101' \
|
||||
-F 'extra_params={"task":"t2va","duration":8.7,"audio_flow_shift":3.0}' \
|
||||
-o t2va.mp4
|
||||
```
|
||||
|
||||
### 2. FL2VA: first frame to video and audio
|
||||
|
||||
Run this request against the `FL2VA` partition. When width and height are
|
||||
omitted, H3 preserves the first-frame aspect ratio and uses a 768-pixel short
|
||||
edge.
|
||||
|
||||
```bash
|
||||
export FIRST_FRAME=/path/to/fl2va_first_frame.png
|
||||
|
||||
curl -sS -X POST "${API_URL}" \
|
||||
-F 'prompt=A man stands beside a yellow car at night. The car drives away; he follows it with his eyes and begins singing sadly, with synchronized voice and city ambience.' \
|
||||
-F 'fps=24' \
|
||||
-F 'num_inference_steps=50' \
|
||||
-F 'flow_shift=12' \
|
||||
-F 'seed=2101' \
|
||||
-F 'extra_params={"task":"fl2va","duration":8.7,"audio_flow_shift":3.0}' \
|
||||
-F "input_reference=@${FIRST_FRAME};type=image/png" \
|
||||
-o fl2va.mp4
|
||||
```
|
||||
|
||||
### 3. Ref2VA: image and audio reference
|
||||
|
||||
Run this request against the `Ref2VA` partition. `audio_reference` accepts an
|
||||
HTTP(S) URL or a `data:` URL. In one terminal, expose the local reference
|
||||
assets to the serving host:
|
||||
|
||||
```bash
|
||||
python -m http.server 8092 \
|
||||
--bind 127.0.0.1 \
|
||||
--directory /path/to/reference_assets
|
||||
```
|
||||
|
||||
Then submit the image and audio request from another terminal:
|
||||
|
||||
```bash
|
||||
export REF_IMAGE=/path/to/reference_assets/ref2va_image.png
|
||||
export AUDIO_URL=http://127.0.0.1:8092/ref2va_audio.mp3
|
||||
|
||||
curl -sS -X POST "${API_URL}" \
|
||||
-F 'prompt=A white cat with black mustache and eyebrow markings sits on a beige couch, lip-syncing precisely to the complete reference audio before shifting from confusion to deadpan speechlessness.' \
|
||||
-F 'width=1344' \
|
||||
-F 'height=768' \
|
||||
-F 'fps=24' \
|
||||
-F 'num_inference_steps=50' \
|
||||
-F 'flow_shift=12' \
|
||||
-F 'seed=3101' \
|
||||
-F 'extra_params={"task":"ref2va","duration":15.0,"audio_flow_shift":3.0}' \
|
||||
-F "input_reference=@${REF_IMAGE};type=image/png" \
|
||||
-F "audio_reference={\"audio_url\":\"${AUDIO_URL}\"}" \
|
||||
-o ref2va_image_audio.mp4
|
||||
```
|
||||
|
||||
The requested duration should cover the complete audio. If `duration` is
|
||||
shorter, the reference soundtrack is truncated to the generated clip.
|
||||
|
||||
### 4. Ref2VA: two video references
|
||||
|
||||
Run this request against the `Ref2VA` partition. Repeat the
|
||||
`input_references` multipart field once per source video. H3 consumes the
|
||||
videos in form order and preserves their original soundtracks during
|
||||
conditioning.
|
||||
|
||||
```bash
|
||||
export SUBJECT_VIDEO=/path/to/green_screen_subject.mp4
|
||||
export BACKGROUND_VIDEO=/path/to/fairytale_background.mov
|
||||
|
||||
curl -sS -X POST "${API_URL}" \
|
||||
-F 'prompt=Remove the green screen background of Video 1 and replace it with the fairytale environment from Video 2. Match the background motion to the character actions and relight the character to fit the scene.' \
|
||||
-F 'width=1344' \
|
||||
-F 'height=768' \
|
||||
-F 'fps=24' \
|
||||
-F 'num_inference_steps=50' \
|
||||
-F 'flow_shift=12' \
|
||||
-F 'seed=3101' \
|
||||
-F 'extra_params={"task":"ref2va","duration":15.0,"audio_flow_shift":3.0}' \
|
||||
-F "input_references=@${SUBJECT_VIDEO};type=video/mp4" \
|
||||
-F "input_references=@${BACKGROUND_VIDEO};type=video/quicktime" \
|
||||
-o ref2va_video_video.mp4
|
||||
```
|
||||
|
||||
The server stores uploaded multi-video references only for the lifetime of the
|
||||
request and deletes the temporary files after generation. Video Ref2VA uses
|
||||
the source-video soundtracks and does not accept a separate
|
||||
`audio_reference`.
|
||||
|
||||
## Key parameters
|
||||
|
||||
| Parameter | Recommended value | Notes |
|
||||
|-----------|-------------------|-------|
|
||||
| `task` | `t2va`, `fl2va`, or `ref2va` | Passed in `extra_params`; must match the served partition |
|
||||
| `duration` | Workload-specific | Decimal seconds in `extra_params`; converted to H3-compatible frame count |
|
||||
| `fps` | `24` | H3 output FPS is fixed |
|
||||
| `num_inference_steps` | `50` | Matches the reference accuracy workloads |
|
||||
| `flow_shift` | `12` | Video sigma shift |
|
||||
| `audio_flow_shift` | `3` | Audio sigma shift, passed in `extra_params` |
|
||||
| `seed` | Task-specific | Use a fixed value for reproducibility |
|
||||
| `width`, `height` | Multiples of 32 | Aspect ratio must be between 1:4 and 4:1 |
|
||||
|
||||
## Validated four-GPU evidence
|
||||
|
||||
The four-GPU recommendation was measured on four NVIDIA B300 GPUs with one
|
||||
excluded warmup followed by three requests.
|
||||
|
||||
| Workload | Configuration | Observed result |
|
||||
|----------|---------------|-----------------|
|
||||
| FL2VA, 209 frames, 1248x768 | no offload, U4, VPP4 tile, regional compile | 86.964 s mean HTTP client latency |
|
||||
| Two-video Ref2VA, 362 frames, 1344x768 | no offload, U4, VPP4 tile, regional compile | 784.394 s accounted model-stage mean |
|
||||
|
||||
These measurements describe the validated shapes rather than a general
|
||||
throughput guarantee. Multi-video Ref2VA is much slower because the two
|
||||
reference videos expand both the Qwen3-VL vision sequence and the packed DiT
|
||||
attention sequence.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Each server process loads only one checkpoint partition.
|
||||
- H3 currently executes one generation request per diffusion batch.
|
||||
- The first regional-compile request is a warmup and should not be included in
|
||||
steady-state performance measurements.
|
||||
- Image+audio Ref2VA accepts exactly one image and one audio reference.
|
||||
- Video Ref2VA accepts one or more video files, but not an additional standalone
|
||||
audio reference.
|
||||
- VAE patch parallelism requires size 1 or the full DiT group size and supports
|
||||
the H3 native `tile` mode only.
|
||||
|
||||
## Additional resources
|
||||
|
||||
- [Supported models](../../docs/models/supported_models.md)
|
||||
- [Video API](../../docs/serving/videos_api.md)
|
||||
- [Diffusion parallelism](../../docs/user_guide/diffusion/parallelism/overview.md)
|
||||
@@ -45,6 +45,7 @@ recipes/
|
||||
| [`inclusionAI/Ming-flash-omni-2.0.md`](./inclusionAI/Ming-flash-omni-2.0.md) | Online serving for multimodal chat + standalone TTS | 4x H100 / 1x H100 80GB |
|
||||
| [`inclusionAI/Ming-omni-tts.md`](./inclusionAI/Ming-omni-tts.md) | Offline + online dense Ming TTS/audio generation | 1x H100 80GB / 1x AMD MI300X (ROCm 7.2) |
|
||||
| [`IndexTeam/IndexTTS-2.md`](./IndexTeam/IndexTTS-2.md) | Online serving for voice-cloned TTS with optional emotion control | 1x L4 24GB or larger CUDA GPU |
|
||||
| [`MiniMaxAI/MiniMax-H3.md`](./MiniMaxAI/MiniMax-H3.md) | T2VA, FL2VA, image+audio Ref2VA, and multi-video Ref2VA serving | 1x GPU with CPU offload / 4x B300 |
|
||||
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB |
|
||||
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU |
|
||||
| [`MammothModa2/MammothModa2.md`](./MammothModa2/MammothModa2.md) | Preview and Dev text-to-image (AR → DiT); Dev text/image understanding | Preview: 1x L40S 48GB / 1x ≥40GB GPU; Dev: 1x NVIDIA GPU with sufficient cache headroom |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -440,6 +440,7 @@ def test_sub_config_fields_match_rfc_scopes():
|
||||
"ulysses_mode",
|
||||
"cfg_parallel_size",
|
||||
"vae_patch_parallel_size",
|
||||
"text_encoder_tp_size",
|
||||
"vae_parallel_mode",
|
||||
"use_hsdp",
|
||||
"mask_sp_padding",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"cuda": "H100"
|
||||
"cuda": "H100",
|
||||
"npu": "A3"
|
||||
},
|
||||
"num_cards": 4
|
||||
}
|
||||
@@ -27,16 +28,9 @@
|
||||
"async_chunk": false,
|
||||
"trust_remote_code": true,
|
||||
"connectors": {
|
||||
"rdma_connector": {
|
||||
"name": "MooncakeTransferEngineConnector",
|
||||
"extra": {
|
||||
"host": "auto",
|
||||
"zmq_port": 50051,
|
||||
"protocol": "rdma",
|
||||
"device_name": "",
|
||||
"memory_pool_size": 17179869184,
|
||||
"memory_pool_device": "cpu"
|
||||
}
|
||||
"shared_mem_connector": {
|
||||
"name": "SharedMemoryConnector",
|
||||
"extra": {}
|
||||
}
|
||||
},
|
||||
"stages": [
|
||||
@@ -66,7 +60,7 @@
|
||||
"need_send_cache": true
|
||||
},
|
||||
"output_connectors": {
|
||||
"to_stage_1": "rdma_connector"
|
||||
"to_stage_1": "shared_mem_connector"
|
||||
},
|
||||
"default_sampling_params": {
|
||||
"temperature": 0.0,
|
||||
@@ -96,7 +90,7 @@
|
||||
},
|
||||
"vae_use_tiling": true,
|
||||
"input_connectors": {
|
||||
"from_stage_0": "rdma_connector"
|
||||
"from_stage_0": "shared_mem_connector"
|
||||
},
|
||||
"default_sampling_params": {
|
||||
"num_inference_steps": 50,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"cuda": "H100"
|
||||
"cuda": "H100",
|
||||
"npu": "A3"
|
||||
},
|
||||
"num_cards": 4
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"cuda": "H100"
|
||||
"cuda": "H100",
|
||||
"npu": "A3"
|
||||
},
|
||||
"num_cards": 4
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
{
|
||||
"hardware_marks": {
|
||||
"res": {
|
||||
"cuda": "H100"
|
||||
"cuda": "H100",
|
||||
"npu": "A3"
|
||||
},
|
||||
"num_cards": 4
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
is_gpu = current_omni_platform.is_cuda_alike() or current_omni_platform.is_xpu()
|
||||
HAS_FLASH_ATTN = fa.HAS_FLASH_ATTN
|
||||
flash_attn_func = fa.flash_attn_func # noqa: N813
|
||||
@@ -330,6 +329,48 @@ def test_piecewise_flash_attn_uses_varlen_fallback(monkeypatch):
|
||||
assert calls[0]["softmax_scale"] == 0.5
|
||||
|
||||
|
||||
def test_packed_varlen_metadata_bypasses_mask_unpadding(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_varlen_func(q, k, v, **kwargs):
|
||||
calls.append((q.shape, k.shape, v.shape, kwargs))
|
||||
return q
|
||||
|
||||
monkeypatch.setattr(fa, "HAS_FLASH_ATTN", True)
|
||||
monkeypatch.setattr(fa, "flash_attn_varlen_func", fake_varlen_func)
|
||||
|
||||
impl = FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=False)
|
||||
query = torch.randn(1, 8, 2, 4)
|
||||
cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32)
|
||||
metadata = AttentionMetadata(
|
||||
attn_mask=torch.tensor([[True] * 6 + [False] * 2]),
|
||||
extra={
|
||||
"cu_seqlens_q": cu_seqlens,
|
||||
"cu_seqlens_k": cu_seqlens,
|
||||
"max_seqlen_q": 6,
|
||||
"max_seqlen_k": 6,
|
||||
},
|
||||
)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, metadata)
|
||||
|
||||
assert torch.equal(output, query)
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == torch.Size([8, 2, 4])
|
||||
assert calls[0][3]["cu_seqlens_q"] is cu_seqlens
|
||||
assert calls[0][3]["max_seqlen_q"] == 6
|
||||
|
||||
|
||||
def test_packed_varlen_metadata_must_be_complete(monkeypatch):
|
||||
monkeypatch.setattr(fa, "HAS_FLASH_ATTN", True)
|
||||
impl = FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=False)
|
||||
query = torch.randn(1, 8, 2, 4)
|
||||
metadata = AttentionMetadata(extra={"cu_seqlens_q": torch.tensor([0, 8], dtype=torch.int32)})
|
||||
|
||||
with pytest.raises(ValueError, match="Incomplete packed FlashAttention metadata"):
|
||||
impl.forward_cuda(query, query, query, metadata)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running FlashAttention Padding Tests...")
|
||||
print("=" * 60)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.ring import ring_kernels
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_selector import AttnType
|
||||
from vllm_omni.diffusion.attention.parallel import ring as ring_parallel
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_fa4_wrapper_returns_lse_and_normalizes_unbounded_window(monkeypatch):
|
||||
expected_out = object()
|
||||
expected_lse = object()
|
||||
captured = {}
|
||||
|
||||
def fake_fa4(q, k, v, **kwargs):
|
||||
captured.update(q=q, k=k, v=v, **kwargs)
|
||||
return expected_out, expected_lse
|
||||
|
||||
monkeypatch.setattr(ring_kernels, "HAS_FA4", True)
|
||||
monkeypatch.setattr(ring_kernels, "fa4_attn_func", fake_fa4)
|
||||
|
||||
out, lse = ring_kernels.flash_attn4_func_forward(
|
||||
"q",
|
||||
"k",
|
||||
"v",
|
||||
softmax_scale=0.125,
|
||||
causal=True,
|
||||
window_size=(-1, -1),
|
||||
)
|
||||
|
||||
assert (out, lse) == (expected_out, expected_lse)
|
||||
assert captured == {
|
||||
"q": "q",
|
||||
"k": "k",
|
||||
"v": "v",
|
||||
"softmax_scale": 0.125,
|
||||
"causal": True,
|
||||
"window_size": (None, None),
|
||||
"softcap": 0.0,
|
||||
"return_lse": True,
|
||||
}
|
||||
|
||||
|
||||
def test_ring_prefers_fa4_on_blackwell(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_ring_flash_attn_func(*args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return "output"
|
||||
|
||||
monkeypatch.setattr(ring_parallel, "HAS_FA4", True)
|
||||
monkeypatch.setattr(ring_parallel, "HAS_FA3", True)
|
||||
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (10, 3))
|
||||
|
||||
from vllm_omni.diffusion.attention.backends import ring_flash_attn
|
||||
|
||||
monkeypatch.setattr(ring_flash_attn, "ring_flash_attn_func", fake_ring_flash_attn_func)
|
||||
|
||||
query = SimpleNamespace(
|
||||
dtype=torch.bfloat16,
|
||||
shape=(1, 16, 4, 128),
|
||||
is_cuda=True,
|
||||
device=torch.device("cuda:0"),
|
||||
)
|
||||
strategy = ring_parallel.RingParallelAttention(SimpleNamespace(ring_group=object()))
|
||||
|
||||
assert strategy.run_attention(query, "key", "value", None) == "output"
|
||||
assert captured["attn_type"] is AttnType.FA4
|
||||
@@ -39,7 +39,7 @@ MODEL_CASES = [
|
||||
"needs_image": False,
|
||||
"is_moe": False,
|
||||
"mean_threshold": 3e-2,
|
||||
"p99_threshold": 3e-2,
|
||||
"p99_threshold": 4e-2,
|
||||
},
|
||||
id="wan22_t2v_a14b",
|
||||
),
|
||||
@@ -146,7 +146,7 @@ def _run_generate(
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 4})
|
||||
@pytest.mark.parametrize("model_case", MODEL_CASES)
|
||||
def test_vae_patch_parallel_tp2(model_case: dict[str, Any], tmp_path: Path):
|
||||
if current_omni_platform.is_npu():
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
@@ -0,0 +1,375 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from multiprocessing.reduction import ForkingPickler
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_pipeline_import_registry_and_component_discovery():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.registry import (
|
||||
_DIFFUSION_MODELS,
|
||||
_DIFFUSION_POST_PROCESS_FUNCS,
|
||||
)
|
||||
|
||||
assert _DIFFUSION_MODELS["MiniMaxH3Pipeline"] == (
|
||||
"minimax_h3",
|
||||
"pipeline_minimax_h3",
|
||||
"MiniMaxH3Pipeline",
|
||||
)
|
||||
assert _DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3Pipeline"] == "get_minimax_h3_post_process_func"
|
||||
assert MiniMaxH3Pipeline._dit_modules == ["transformer"]
|
||||
assert MiniMaxH3Pipeline._encoder_modules == ["text_encoder"]
|
||||
assert MiniMaxH3Pipeline._vae_modules == ["video_vae", "audio_vae"]
|
||||
|
||||
|
||||
def test_joint_postprocess_is_multiprocessing_picklable():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import (
|
||||
get_minimax_h3_post_process_func,
|
||||
)
|
||||
|
||||
postprocess = get_minimax_h3_post_process_func(SimpleNamespace())
|
||||
postprocess = ForkingPickler.loads(ForkingPickler.dumps(postprocess))
|
||||
video = torch.linspace(0, 1, 2 * 3 * 2 * 4 * 5).reshape(2, 3, 2, 4, 5)
|
||||
audio = torch.arange(12, dtype=torch.float32).reshape(1, 2, 6)
|
||||
|
||||
result = postprocess((video, audio), output_type="np")
|
||||
|
||||
assert isinstance(result["video"], list)
|
||||
assert result["video"][0].shape == (2, 4, 5, 3)
|
||||
np.testing.assert_array_equal(result["audio"], audio.numpy())
|
||||
assert result["audio_sample_rate"] == 32000
|
||||
assert result["fps"] == 24
|
||||
|
||||
|
||||
def test_cfg_parallel_is_rejected_for_distilled_checkpoint():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
parallel_config=SimpleNamespace(cfg_parallel_size=2),
|
||||
)
|
||||
with pytest.raises(ValueError, match="CFG-distilled"):
|
||||
MiniMaxH3Pipeline(od_config=od_config)
|
||||
|
||||
|
||||
def test_shape_contract_matches_three_reference_tasks():
|
||||
from vllm_omni.diffusion.models.minimax_h3.time_request import (
|
||||
MINIMAX_H3_SHAPE_PLANNER,
|
||||
minimax_h3_align_frame_count,
|
||||
)
|
||||
|
||||
assert minimax_h3_align_frame_count(round(8.7 * 24)) == 209
|
||||
assert MINIMAX_H3_SHAPE_PLANNER.video_latent_t(209) == 62
|
||||
assert MINIMAX_H3_SHAPE_PLANNER.audio_latent_t(8.7) == 348
|
||||
assert minimax_h3_align_frame_count(round(5.0 * 24)) == 124
|
||||
assert MINIMAX_H3_SHAPE_PLANNER.video_latent_t(124) == 37
|
||||
assert MINIMAX_H3_SHAPE_PLANNER.audio_latent_t(5.0) == 200
|
||||
assert MINIMAX_H3_SHAPE_PLANNER.frame_count_from_video_latent_t(62) == 209
|
||||
|
||||
|
||||
def test_shifted_sigma_schedule_matches_reference_values():
|
||||
from vllm_omni.diffusion.models.minimax_h3.time_request import (
|
||||
minimax_h3_time_shift_sigmas,
|
||||
)
|
||||
|
||||
sigmas = minimax_h3_time_shift_sigmas(num_steps=5, shift_scale=12.0)
|
||||
|
||||
assert sigmas == pytest.approx(
|
||||
[1.0, 0.9729729891, 0.9230769277, 0.8000000119, 0.0],
|
||||
abs=1e-7,
|
||||
)
|
||||
|
||||
|
||||
def test_reference_image_resize_contract():
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_reference_image_shape,
|
||||
)
|
||||
|
||||
assert _reference_image_shape(Image.new("RGB", (1080, 1440))) == (
|
||||
2048,
|
||||
2720,
|
||||
)
|
||||
with pytest.raises(ValueError, match="aspect ratio"):
|
||||
_reference_image_shape(Image.new("RGB", (100, 501)))
|
||||
|
||||
|
||||
def test_encoder_forward_uses_hook_compatible_encode_entrypoint():
|
||||
from vllm_omni.diffusion.models.minimax_h3.encoder import (
|
||||
MiniMaxH3Qwen3VLEncoder,
|
||||
)
|
||||
|
||||
encoder = object.__new__(MiniMaxH3Qwen3VLEncoder)
|
||||
torch.nn.Module.__init__(encoder)
|
||||
expected = torch.ones(2, 3)
|
||||
encoder.encode_ids = Mock(return_value=expected)
|
||||
input_ids = torch.tensor([1, 2])
|
||||
pixel_values = torch.ones(1, 4)
|
||||
image_grid_thw = torch.tensor([[1, 1, 1]])
|
||||
|
||||
actual = encoder(
|
||||
input_ids,
|
||||
pixel_values=pixel_values,
|
||||
image_grid_thw=image_grid_thw,
|
||||
)
|
||||
|
||||
assert actual is expected
|
||||
encoder.encode_ids.assert_called_once_with(
|
||||
input_ids,
|
||||
pixel_values=pixel_values,
|
||||
image_grid_thw=image_grid_thw,
|
||||
)
|
||||
|
||||
|
||||
def test_encoder_forward_forwards_video_inputs():
|
||||
from vllm_omni.diffusion.models.minimax_h3.encoder import (
|
||||
MiniMaxH3Qwen3VLEncoder,
|
||||
)
|
||||
|
||||
encoder = object.__new__(MiniMaxH3Qwen3VLEncoder)
|
||||
torch.nn.Module.__init__(encoder)
|
||||
expected = torch.ones(2, 3)
|
||||
encoder.encode_ids = Mock(return_value=expected)
|
||||
input_ids = torch.tensor([1, 2])
|
||||
pixel_values_videos = torch.ones(1, 4)
|
||||
video_grid_thw = torch.tensor([[2, 1, 1]])
|
||||
|
||||
actual = encoder(
|
||||
input_ids,
|
||||
pixel_values_videos=pixel_values_videos,
|
||||
video_grid_thw=video_grid_thw,
|
||||
)
|
||||
|
||||
assert actual is expected
|
||||
encoder.encode_ids.assert_called_once_with(
|
||||
input_ids,
|
||||
pixel_values=None,
|
||||
image_grid_thw=None,
|
||||
pixel_values_videos=pixel_values_videos,
|
||||
video_grid_thw=video_grid_thw,
|
||||
)
|
||||
|
||||
|
||||
def test_reference_video_shape_uses_h3_adapt_shape_policy():
|
||||
from vllm_omni.diffusion.models.minimax_h3.reference_video import (
|
||||
_reference_video_shape,
|
||||
)
|
||||
|
||||
assert _reference_video_shape(1280, 720) == (1344, 768)
|
||||
assert _reference_video_shape(3844, 2160) == (1344, 768)
|
||||
|
||||
|
||||
def test_text_encoder_stub_constructs_without_group_or_weights():
|
||||
from vllm_omni.diffusion.models.minimax_h3.encoder import (
|
||||
MiniMaxH3Qwen3VLEncoder,
|
||||
)
|
||||
|
||||
encoder = MiniMaxH3Qwen3VLEncoder(
|
||||
"/nonexistent/text_encoder",
|
||||
device=torch.device("cpu"),
|
||||
load_model=False,
|
||||
encoder_group=None,
|
||||
)
|
||||
assert not encoder.is_loaded
|
||||
assert encoder.tp_size == 1
|
||||
# The stub has no parameters, so it never contributes to the runner's
|
||||
# strict missing-parameter check on non-encoder ranks.
|
||||
assert list(encoder.named_parameters()) == []
|
||||
|
||||
|
||||
def test_no_offload_keeps_text_encoder_resident():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=False,
|
||||
enable_layerwise_offload=False,
|
||||
)
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
input_ids = torch.tensor([1, 2])
|
||||
|
||||
actual = pipeline._encode_text_hidden(input_ids, {})
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.offload_to_cpu.assert_not_called()
|
||||
|
||||
|
||||
def test_model_offload_uses_hooked_text_encoder_call():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=False,
|
||||
)
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder = Mock(return_value=expected)
|
||||
input_ids = torch.tensor([1, 2])
|
||||
vision_kwargs = {"pixel_values": torch.ones(1, 4)}
|
||||
|
||||
actual = pipeline._encode_text_hidden(input_ids, vision_kwargs)
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.assert_called_once_with(input_ids, **vision_kwargs)
|
||||
pipeline.text_encoder.load_to_device.assert_not_called()
|
||||
|
||||
|
||||
def test_layerwise_offload_releases_text_encoder():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=False,
|
||||
enable_layerwise_offload=True,
|
||||
)
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
|
||||
|
||||
|
||||
def test_video_vae_keeps_reference_fp32_weights(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
|
||||
class FakeRemote(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model = torch.nn.Linear(1, 1).half()
|
||||
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_component_config",
|
||||
lambda _path: {
|
||||
"latent_channels": 1,
|
||||
"latents_mean": [0.0],
|
||||
"latents_std": [1.0],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
vae_module,
|
||||
"_load_remote_component",
|
||||
lambda _path, _config: FakeRemote(),
|
||||
)
|
||||
|
||||
video_vae = vae_module.MiniMaxH3VideoVAE(
|
||||
"unused",
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
|
||||
assert next(video_vae.parameters()).dtype == torch.float32
|
||||
|
||||
|
||||
def test_video_vae_encode_uses_configured_parallel_tiling():
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import (
|
||||
MiniMaxH3VideoVAE,
|
||||
)
|
||||
|
||||
class FakeModel(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.weight = torch.nn.Parameter(torch.ones(1))
|
||||
self.parallel_tiling = True
|
||||
self.encode_calls = []
|
||||
|
||||
def encode_videos(self, frames, *, use_fp16_latent):
|
||||
assert self.parallel_tiling
|
||||
self.encode_calls.append((frames, use_fp16_latent))
|
||||
return [torch.ones(1, 1, 2, 2, 2)]
|
||||
|
||||
video_vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
torch.nn.Module.__init__(video_vae)
|
||||
video_vae.model = FakeModel()
|
||||
video_vae.config_dict = {
|
||||
"latent_channels": 1,
|
||||
"latents_mean": [0.0],
|
||||
"latents_std": [1.0],
|
||||
}
|
||||
|
||||
rows, shape = video_vae.encode_video("frames")
|
||||
|
||||
assert video_vae.model.parallel_tiling
|
||||
assert video_vae.model.encode_calls == [("frames", True)]
|
||||
assert rows.shape == (2, 4)
|
||||
assert shape == (2, 2, 2)
|
||||
|
||||
|
||||
def test_distributed_video_vae_encodes_references_sequentially(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import (
|
||||
pipeline_minimax_h3 as pipeline_module,
|
||||
)
|
||||
|
||||
prepared = [
|
||||
{"prepared_path": "video-1.mp4"},
|
||||
{"prepared_path": "video-2.mp4"},
|
||||
]
|
||||
|
||||
class FakeVideoVAE:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def is_distributed_enabled(self):
|
||||
return True
|
||||
|
||||
def encode_video(self, frames):
|
||||
self.calls.append(frames)
|
||||
index = len(self.calls)
|
||||
return torch.full((1, 2), index, dtype=torch.float32), (index, 2, 3)
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.video_vae = FakeVideoVAE()
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"_dit_rank_world",
|
||||
lambda: ("dit-group", 1, 4),
|
||||
)
|
||||
|
||||
def fake_broadcast_object_list(values, *, src, group, device):
|
||||
assert values == [None]
|
||||
assert (src, group, device) == (0, "dit-group", torch.device("cpu"))
|
||||
values[0] = prepared
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline_module.dist,
|
||||
"broadcast_object_list",
|
||||
fake_broadcast_object_list,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"load_video_frames",
|
||||
lambda path: f"frames:{path}",
|
||||
)
|
||||
|
||||
rows, shapes = pipeline._encode_video_conditions(None, count=2)
|
||||
|
||||
assert pipeline.video_vae.calls == [
|
||||
"frames:video-1.mp4",
|
||||
"frames:video-2.mp4",
|
||||
]
|
||||
torch.testing.assert_close(
|
||||
rows,
|
||||
torch.tensor([[1.0, 1.0], [2.0, 2.0]]),
|
||||
)
|
||||
assert shapes == [(1, 2, 3), (2, 2, 3)]
|
||||
@@ -0,0 +1,69 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.local_model,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.diffusion,
|
||||
pytest.mark.parallel,
|
||||
]
|
||||
|
||||
MODEL_ENV = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get(MODEL_ENV),
|
||||
reason=f"set {MODEL_ENV} to an authorized FL2VA checkpoint path",
|
||||
)
|
||||
def test_minimax_h3_t2va_ulysses8_smoke():
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.entrypoints.omni import Omni
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
if torch.accelerator.device_count() < 8:
|
||||
pytest.skip("MiniMax H3 Ulysses smoke requires eight GPUs")
|
||||
|
||||
engine = Omni(
|
||||
model=os.environ[MODEL_ENV],
|
||||
parallel_config=DiffusionParallelConfig(ulysses_degree=8),
|
||||
trust_remote_code=True,
|
||||
enable_cpu_offload=True,
|
||||
enforce_eager=True,
|
||||
)
|
||||
try:
|
||||
outputs = engine.generate(
|
||||
"A quiet cinematic night scene with matching ambient sound.",
|
||||
OmniDiffusionSamplingParams(
|
||||
height=256,
|
||||
width=448,
|
||||
num_frames=29,
|
||||
fps=24,
|
||||
num_inference_steps=2,
|
||||
seed=42,
|
||||
output_type="np",
|
||||
extra_args={
|
||||
"task": "t2va",
|
||||
"flow_shift": 12.0,
|
||||
"audio_flow_shift": 3.0,
|
||||
},
|
||||
),
|
||||
use_tqdm=False,
|
||||
)
|
||||
finally:
|
||||
engine.close()
|
||||
|
||||
assert len(outputs) == 1
|
||||
frames = np.asarray(outputs[0].images[0])
|
||||
assert frames.shape == (39, 256, 448, 3)
|
||||
multimodal = outputs[0].multimodal_output
|
||||
assert multimodal is not None
|
||||
assert np.asarray(multimodal["audio"]).shape[1] == 2
|
||||
assert multimodal["audio_sample_rate"] == 32000
|
||||
assert multimodal["fps"] == 24
|
||||
@@ -0,0 +1,146 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_video_patchify_round_trip_preserves_values():
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_tokens import (
|
||||
minimax_h3_patchify_video_latent,
|
||||
minimax_h3_unpatchify_video_tokens,
|
||||
)
|
||||
|
||||
latent = torch.arange(2 * 3 * 2 * 4 * 6).reshape(2, 3, 2, 4, 6)
|
||||
rows = minimax_h3_patchify_video_latent(
|
||||
latent,
|
||||
patch_size=(1, 2, 2),
|
||||
)
|
||||
restored = minimax_h3_unpatchify_video_tokens(
|
||||
rows,
|
||||
latent_shape=(2, 2, 3, 3),
|
||||
patch_size=(1, 2, 2),
|
||||
)
|
||||
|
||||
torch.testing.assert_close(restored, latent)
|
||||
|
||||
|
||||
def test_audio_pack_round_trip_preserves_channel_major_order():
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_tokens import (
|
||||
minimax_h3_pack_audio_latent,
|
||||
minimax_h3_unpack_audio_tokens,
|
||||
)
|
||||
|
||||
latent = torch.arange(2 * 4 * 5).reshape(2, 4, 5)
|
||||
rows = minimax_h3_pack_audio_latent(latent)
|
||||
restored = minimax_h3_unpack_audio_tokens(
|
||||
rows,
|
||||
audio_t=10,
|
||||
audio_channel=2,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(restored, latent)
|
||||
|
||||
|
||||
def test_t2va_and_fl2va_packing_keep_update_rows_separate():
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
)
|
||||
|
||||
common = dict(
|
||||
text_len=4,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
)
|
||||
t2va = minimax_h3_packed_sequence(
|
||||
**common,
|
||||
include_keyframe_cond=False,
|
||||
)
|
||||
fl2va = minimax_h3_packed_sequence(
|
||||
**common,
|
||||
include_keyframe_cond=True,
|
||||
keyframe_frame_indices=[0],
|
||||
frame_count=5,
|
||||
)
|
||||
|
||||
assert int(t2va["seq_len"]) == 64
|
||||
assert t2va["img_pos"].numel() == 12
|
||||
assert t2va["update_mask"].all()
|
||||
assert fl2va["img_pos"].numel() == 18
|
||||
assert fl2va["update_mask"].sum().item() == 12
|
||||
assert (~fl2va["update_mask"]).sum().item() == 6
|
||||
assert t2va["audio_pos"].numel() == fl2va["audio_pos"].numel() == 6
|
||||
|
||||
|
||||
def test_ref2va_packing_tracks_video_and_audio_update_masks():
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
)
|
||||
|
||||
packed = minimax_h3_packed_sequence_ref2va_blocks(
|
||||
text_len=4,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
ref_blocks=[
|
||||
{"kind": "image", "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "audio", "ref_audio_t": 2},
|
||||
],
|
||||
)
|
||||
|
||||
assert int(packed["seq_len"]) == 64
|
||||
assert packed["img_pos"].numel() == 16
|
||||
assert packed["update_mask"].sum().item() == 12
|
||||
assert packed["audio_pos"].numel() == 10
|
||||
assert packed["audio_update_mask"].sum().item() == 6
|
||||
assert packed["cu_seqlens"].tolist() == [0, 30, 64]
|
||||
|
||||
|
||||
def test_condition_noise_is_seeded_and_keeps_clean_anchor_at_timestep_one():
|
||||
from vllm_omni.diffusion.models.minimax_h3.condition_noise import (
|
||||
minimax_h3_audio_cond_noise_aug_rows,
|
||||
minimax_h3_imgvid_cond_noise_aug_rows,
|
||||
)
|
||||
|
||||
image_rows = torch.randn(4, 96)
|
||||
audio_rows = torch.randn(6, 32)
|
||||
|
||||
torch.testing.assert_close(
|
||||
minimax_h3_imgvid_cond_noise_aug_rows(
|
||||
image_rows,
|
||||
condition_shapes=[(1, 4, 4)],
|
||||
target_latent_t=2,
|
||||
imgvid_cond_num_frames=1,
|
||||
seed=42,
|
||||
noise_aug=1.0,
|
||||
),
|
||||
image_rows,
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
minimax_h3_audio_cond_noise_aug_rows(
|
||||
audio_rows,
|
||||
condition_audio_t=[3],
|
||||
seed=42,
|
||||
noise_aug=1.0,
|
||||
),
|
||||
audio_rows,
|
||||
)
|
||||
|
||||
first = minimax_h3_audio_cond_noise_aug_rows(
|
||||
audio_rows,
|
||||
condition_audio_t=[3],
|
||||
seed=42,
|
||||
noise_aug=0.25,
|
||||
)
|
||||
second = minimax_h3_audio_cond_noise_aug_rows(
|
||||
audio_rows,
|
||||
condition_audio_t=[3],
|
||||
seed=42,
|
||||
noise_aug=0.25,
|
||||
)
|
||||
torch.testing.assert_close(first, second)
|
||||
@@ -0,0 +1,94 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_grouped_qkv_checkpoint_reorder():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
_reorder_grouped_qkv_to_qkv,
|
||||
)
|
||||
|
||||
# Two groups with rows [q, k, v] become [q0, q1, k0, k1, v0, v1].
|
||||
grouped = torch.arange(6, dtype=torch.float32).reshape(6, 1)
|
||||
reordered = _reorder_grouped_qkv_to_qkv(
|
||||
grouped,
|
||||
num_query_groups=2,
|
||||
heads_per_group=1,
|
||||
head_dim=1,
|
||||
)
|
||||
|
||||
assert reordered[:, 0].tolist() == [0, 3, 1, 4, 2, 5]
|
||||
|
||||
|
||||
def test_transformer_declares_cache_sp_layerwise_offload_and_hsdp():
|
||||
from cache_dit import ForwardPattern
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3DiTModel,
|
||||
)
|
||||
|
||||
assert MiniMaxH3DiTModel._repeated_blocks == ["MiniMaxH3DiTBlock"]
|
||||
assert MiniMaxH3DiTModel._layerwise_offload_blocks_attrs == ["blocks"]
|
||||
assert MiniMaxH3DiTModel._cache_dit_adapter_config.block_forward_patterns["blocks"] == ForwardPattern.Pattern_3
|
||||
assert not MiniMaxH3DiTModel._cache_dit_adapter_config.has_separate_cfg
|
||||
assert set(MiniMaxH3DiTModel._sp_plan) == {"sp_prepare", "sp_gather"}
|
||||
|
||||
model = object.__new__(MiniMaxH3DiTModel)
|
||||
nn.Module.__init__(model)
|
||||
model.blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)])
|
||||
model.token_refiner = nn.Module()
|
||||
model.token_refiner.blocks = nn.ModuleList([nn.Linear(4, 4)])
|
||||
model.final_layer = nn.Linear(4, 4)
|
||||
|
||||
matched = [
|
||||
name
|
||||
for name, module in model.named_modules()
|
||||
if any(condition(name, module) for condition in MiniMaxH3DiTModel._hsdp_shard_conditions)
|
||||
]
|
||||
assert matched == ["blocks.0", "blocks.1"]
|
||||
|
||||
|
||||
def test_packed_attention_is_a_regional_compile_boundary():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3Attention,
|
||||
)
|
||||
|
||||
assert getattr(MiniMaxH3Attention._run_packed_attention, "_torchdynamo_disable", False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tp_size", "message"),
|
||||
[
|
||||
(3, "num_attention_heads"),
|
||||
(5, "num_attention_heads"),
|
||||
],
|
||||
)
|
||||
def test_tp_rejects_non_divisible_head_counts(tp_size, message):
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3DiTArchConfig,
|
||||
MiniMaxH3DiTModel,
|
||||
)
|
||||
|
||||
model = object.__new__(MiniMaxH3DiTModel)
|
||||
with pytest.raises(ValueError, match=message):
|
||||
model._validate_tp_config(
|
||||
arch=MiniMaxH3DiTArchConfig(),
|
||||
tp_size=tp_size,
|
||||
)
|
||||
|
||||
|
||||
def test_tp_accepts_checkpoint_supported_sizes():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3DiTArchConfig,
|
||||
MiniMaxH3DiTModel,
|
||||
)
|
||||
|
||||
model = object.__new__(MiniMaxH3DiTModel)
|
||||
arch = MiniMaxH3DiTArchConfig()
|
||||
for tp_size in (1, 2, 4, 7):
|
||||
model._validate_tp_config(arch=arch, tp_size=tp_size)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Pure-TP KV receive consensus (#5627) — CPU-testable mechanism.
|
||||
|
||||
In pure TP (LOCAL role) each rank fetches its own KV shard independently and
|
||||
``distribute_kv_cache`` is a no-op, so one rank's silent receive timeout used
|
||||
to send only that rank into the pipeline's no-KV prefill — desyncing TP
|
||||
collectives until the NCCL watchdog killed the worker. These tests cover the
|
||||
identical-state verdict exchange: skip guards, both rank roles, KV dropping
|
||||
on any cross-rank divergence (primary or CFG companion roles), that the
|
||||
exchange runs on the TP group and never on ``world`` (which also holds DP/PP
|
||||
ranks working on other requests), and the wiring from both distributed-receive
|
||||
entry points.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID
|
||||
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
|
||||
OmniKVCacheConfig,
|
||||
OmniKVTransferManager,
|
||||
ReceiveRole,
|
||||
_TransferTopoConfig,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _FakeGroup:
|
||||
"""Scripted stand-in for one rank's view of a GroupCoordinator."""
|
||||
|
||||
def __init__(self, rank_in_group, world_size, peer_signatures=None, verdict=None):
|
||||
self.rank_in_group = rank_in_group
|
||||
self.world_size = world_size
|
||||
self._peer_signatures = list(peer_signatures or [])
|
||||
self._verdict = verdict
|
||||
self.sent = []
|
||||
self.broadcasts = []
|
||||
|
||||
def recv_object(self, src):
|
||||
return self._peer_signatures.pop(0)
|
||||
|
||||
def send_object(self, obj, dst):
|
||||
self.sent.append((obj, dst))
|
||||
|
||||
def broadcast_object(self, obj=None, src=0):
|
||||
self.broadcasts.append(obj)
|
||||
return obj if obj is not None else self._verdict
|
||||
|
||||
@property
|
||||
def touched(self):
|
||||
return bool(self.sent or self.broadcasts)
|
||||
|
||||
|
||||
def _topo(role=ReceiveRole.LOCAL, tp_active=True, tp_group=None, world=None):
|
||||
tp_size = tp_group.world_size if tp_group is not None else 1
|
||||
return _TransferTopoConfig(
|
||||
role=role,
|
||||
tp_active=tp_active,
|
||||
cfg_size=1,
|
||||
cfg_rank=0,
|
||||
cfg_group=None,
|
||||
sp_size=1,
|
||||
sp_rank=0,
|
||||
sp_group=None,
|
||||
world=world,
|
||||
tp_group=tp_group,
|
||||
tp_size=tp_size,
|
||||
)
|
||||
|
||||
|
||||
def _manager(topo, need_recv_cache=True):
|
||||
mgr = OmniKVTransferManager(OmniKVCacheConfig(need_recv_cache=need_recv_cache))
|
||||
mgr._topo_config = topo
|
||||
return mgr
|
||||
|
||||
|
||||
def _req_with_kv(rid="r1", cfg_text=False):
|
||||
kv = SimpleNamespace(key_cache=[], value_cache=[])
|
||||
sp = SimpleNamespace(past_key_values=kv, kv_metadata={"ropes": [3]})
|
||||
if cfg_text:
|
||||
sp.cfg_text_past_key_values = SimpleNamespace(key_cache=[], value_cache=[])
|
||||
return SimpleNamespace(
|
||||
request_id=rid,
|
||||
past_key_values=kv,
|
||||
kv_metadata={"ropes": [3]},
|
||||
sampling_params=sp,
|
||||
)
|
||||
|
||||
|
||||
def _signature_of(req, received=True):
|
||||
return (received, tuple(sorted(OmniKVTransferManager._collect_request_kv_payload(req))))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Skip guards — no exchange may happen (a one-sided exchange would deadlock)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_pass_through_outside_pure_tp():
|
||||
group = _FakeGroup(0, 2, peer_signatures=[(False, ())])
|
||||
for topo in (
|
||||
_topo(role=ReceiveRole.LEADER, tp_active=True, tp_group=group),
|
||||
_topo(role=ReceiveRole.LOCAL, tp_active=False, tp_group=group),
|
||||
_topo(role=ReceiveRole.LOCAL, tp_active=True, tp_group=None),
|
||||
):
|
||||
mgr = _manager(topo)
|
||||
assert mgr._tp_local_receive_consensus(_req_with_kv(), True) is True
|
||||
assert not group.touched
|
||||
|
||||
|
||||
def test_pass_through_single_rank_group_no_recv_config_and_dummy():
|
||||
group = _FakeGroup(0, 1)
|
||||
assert _manager(_topo(tp_group=group))._tp_local_receive_consensus(_req_with_kv(), True) is True
|
||||
|
||||
group2 = _FakeGroup(0, 2, peer_signatures=[(False, ())])
|
||||
mgr = _manager(_topo(tp_group=group2), need_recv_cache=False)
|
||||
assert mgr._tp_local_receive_consensus(_req_with_kv(), True) is True
|
||||
|
||||
mgr3 = _manager(_topo(tp_group=group2))
|
||||
dummy = _req_with_kv(rid=DUMMY_DIFFUSION_REQUEST_ID)
|
||||
assert mgr3._tp_local_receive_consensus(dummy, False) is False
|
||||
assert not group2.touched
|
||||
|
||||
|
||||
def test_world_group_is_never_used_for_the_exchange():
|
||||
"""DP replicas / PP stages live in ``world`` and work on other requests —
|
||||
the exchange must be scoped to the TP group (review #5636, P1)."""
|
||||
tp_group = _FakeGroup(0, 2, peer_signatures=[(True, ())])
|
||||
world = _FakeGroup(0, 4, peer_signatures=[(False, ()), (False, ()), (False, ())])
|
||||
mgr = _manager(_topo(tp_group=tp_group, world=world))
|
||||
req = SimpleNamespace(request_id="r1", sampling_params=SimpleNamespace())
|
||||
|
||||
assert mgr._tp_local_receive_consensus(req, True) is True
|
||||
assert tp_group.touched
|
||||
assert not world.touched
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Rank 0: collects signatures, decides, broadcasts
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_rank0_drops_kv_when_peer_missed_primary():
|
||||
req = _req_with_kv()
|
||||
peer_sig = (False, ())
|
||||
group = _FakeGroup(0, 2, peer_signatures=[peer_sig])
|
||||
mgr = _manager(_topo(tp_group=group))
|
||||
|
||||
assert mgr._tp_local_receive_consensus(req, True) is False
|
||||
assert group.broadcasts == [False] # peers were told to fall back too
|
||||
assert req.past_key_values is None
|
||||
assert req.kv_metadata is None
|
||||
assert req.sampling_params.past_key_values is None
|
||||
assert req.sampling_params.kv_metadata is None
|
||||
|
||||
|
||||
def test_rank0_keeps_kv_when_all_ranks_match():
|
||||
req = _req_with_kv()
|
||||
group = _FakeGroup(0, 2, peer_signatures=[_signature_of(req)])
|
||||
mgr = _manager(_topo(tp_group=group))
|
||||
|
||||
assert mgr._tp_local_receive_consensus(req, True) is True
|
||||
assert group.broadcasts == [True]
|
||||
assert req.past_key_values is not None
|
||||
assert req.sampling_params.past_key_values is not None
|
||||
|
||||
|
||||
def test_rank0_drops_kv_on_companion_role_divergence():
|
||||
"""Primary received everywhere but cfg_text only landed on this rank —
|
||||
divergent branch caches would desync TP collectives (review #5636, P1)."""
|
||||
req = _req_with_kv(cfg_text=True)
|
||||
peer_sig = _signature_of(_req_with_kv(cfg_text=False)) # peer lacks cfg_text
|
||||
group = _FakeGroup(0, 2, peer_signatures=[peer_sig])
|
||||
mgr = _manager(_topo(tp_group=group))
|
||||
|
||||
assert mgr._tp_local_receive_consensus(req, True) is False
|
||||
assert group.broadcasts == [False]
|
||||
assert req.past_key_values is None
|
||||
assert req.sampling_params.cfg_text_past_key_values is None
|
||||
|
||||
|
||||
def test_rank0_keeps_symmetric_partial_state():
|
||||
"""cfg_text absent on every rank is consistent — Bagel's empty
|
||||
unconditional branch — and must NOT be cleared or failed."""
|
||||
req = _req_with_kv(cfg_text=False)
|
||||
group = _FakeGroup(0, 2, peer_signatures=[_signature_of(req)])
|
||||
mgr = _manager(_topo(tp_group=group))
|
||||
|
||||
assert mgr._tp_local_receive_consensus(req, True) is True
|
||||
assert req.past_key_values is not None
|
||||
|
||||
|
||||
def test_rank0_own_miss_fails_verdict_even_if_peers_received():
|
||||
req = SimpleNamespace(request_id="r1", sampling_params=SimpleNamespace())
|
||||
group = _FakeGroup(0, 3, peer_signatures=[(True, ("past_key_values",)), (True, ("past_key_values",))])
|
||||
mgr = _manager(_topo(tp_group=group))
|
||||
assert mgr._tp_local_receive_consensus(req, False) is False
|
||||
assert group.broadcasts == [False]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Rank > 0: reports signature, honors verdict
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_follower_rank_drops_kv_on_negative_verdict():
|
||||
req = _req_with_kv()
|
||||
sig_before_clear = _signature_of(req)
|
||||
group = _FakeGroup(1, 2, verdict=False)
|
||||
mgr = _manager(_topo(tp_group=group))
|
||||
|
||||
assert mgr._tp_local_receive_consensus(req, True) is False
|
||||
assert group.sent == [(sig_before_clear, 0)]
|
||||
assert req.past_key_values is None
|
||||
assert req.sampling_params.past_key_values is None
|
||||
|
||||
|
||||
def test_follower_rank_keeps_kv_on_positive_verdict():
|
||||
req = _req_with_kv()
|
||||
group = _FakeGroup(1, 2, verdict=True)
|
||||
mgr = _manager(_topo(tp_group=group))
|
||||
|
||||
assert mgr._tp_local_receive_consensus(req, True) is True
|
||||
assert group.sent == [(_signature_of(req), 0)]
|
||||
assert req.past_key_values is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Wiring — both distributed-receive entry points run the consensus
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _wired_manager(monkeypatch, consensus_calls):
|
||||
mgr = _manager(_topo(tp_group=_FakeGroup(0, 2)))
|
||||
|
||||
def fake_consensus(req, received):
|
||||
consensus_calls.append(received)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(mgr, "_tp_local_receive_consensus", fake_consensus)
|
||||
monkeypatch.setattr(mgr, "receive_multi_kv_cache", lambda *a, **k: True)
|
||||
return mgr
|
||||
|
||||
|
||||
def test_receive_multi_kv_cache_distributed_runs_consensus(monkeypatch):
|
||||
calls = []
|
||||
mgr = _wired_manager(monkeypatch, calls)
|
||||
assert mgr.receive_multi_kv_cache_distributed(_req_with_kv()) is False
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_consume_and_distribute_kv_cache_runs_consensus(monkeypatch):
|
||||
calls = []
|
||||
mgr = _wired_manager(monkeypatch, calls)
|
||||
assert mgr.consume_and_distribute_kv_cache(_req_with_kv()) is False
|
||||
assert calls == [True]
|
||||
@@ -682,6 +682,10 @@ class TestDistributedReceive:
|
||||
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1)
|
||||
req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace())
|
||||
world_group = _MockBroadcastGroup(world_size=2, rank_in_group=1)
|
||||
# broadcast_value=True: rank 0's verdict says every TP rank has the
|
||||
# same KV state, so the per-rank receive result stands (see #5627
|
||||
# consensus). The exchange runs on the TP group, not world.
|
||||
tp_group = _MockBroadcastGroup(world_size=2, rank_in_group=1, broadcast_value=True)
|
||||
mgr.receive_multi_kv_cache = MagicMock(return_value=True)
|
||||
|
||||
with (
|
||||
@@ -704,10 +708,19 @@ class TestDistributedReceive:
|
||||
return_value=0,
|
||||
),
|
||||
patch("vllm_omni.diffusion.distributed.parallel_state.get_sp_group", return_value=None),
|
||||
patch("vllm.distributed.parallel_state.get_tp_group", return_value=tp_group),
|
||||
):
|
||||
assert mgr.receive_multi_kv_cache_distributed(req, target_device=torch.device("cpu")) is True
|
||||
|
||||
mgr.receive_multi_kv_cache.assert_called_once_with(req, None, torch.device("cpu"))
|
||||
# Pure TP still fetches per-rank (no leader→follower distribution),
|
||||
# but each rank now reports its KV-state signature on the TP group
|
||||
# for the identical-state verdict instead of silently diverging on a
|
||||
# miss (#5627). World must stay untouched — it holds DP/PP ranks.
|
||||
assert len(tp_group.send_calls) == 1
|
||||
dst, sig = tp_group.send_calls[0]
|
||||
assert dst == 0 and sig[0] is True
|
||||
assert world_group.send_calls == []
|
||||
|
||||
# ── SP-only scenarios ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -32,10 +32,28 @@ PROMPT = "A brown and white dog is running on the grass."
|
||||
MEAN_THRESHOLD = 3e-2
|
||||
P99_THRESHOLD = 3e-1
|
||||
SSIM_THRESHOLD = 0.97
|
||||
PSNR_THRESHOLD = 30.0
|
||||
PSNR_THRESHOLD_CUDA = 30.0
|
||||
PSNR_THRESHOLD_NPU = 26.0
|
||||
|
||||
|
||||
def _psnr_threshold() -> float:
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
return PSNR_THRESHOLD_NPU if current_omni_platform.is_npu() else PSNR_THRESHOLD_CUDA
|
||||
|
||||
|
||||
BASELINE_PATH = get_asset_path("hunyuan/hunyuan_baseline.png")
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
|
||||
def _baseline_path() -> Path:
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
suffix = "_npu" if current_omni_platform.is_npu() else ""
|
||||
path = get_asset_path(f"hunyuan/hunyuan_baseline{suffix}.png")
|
||||
assert path.exists(), f"Baseline image not found at {path}"
|
||||
return path
|
||||
|
||||
|
||||
_OFFLINE_SCRIPT = _REPO_ROOT / "examples" / "offline_inference" / "hunyuan_image3" / "end2end.py"
|
||||
|
||||
# DiT-only deploy config with trust_remote_code (based on hunyuan_image3_dit.yaml).
|
||||
@@ -74,6 +92,23 @@ _DEPLOY_CONFIG = {
|
||||
},
|
||||
},
|
||||
],
|
||||
"platforms": {
|
||||
"npu": {
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": 0,
|
||||
"gpu_memory_utilization": 0.65,
|
||||
"devices": "0,1,2,3",
|
||||
"moe_backend": "auto",
|
||||
"max_num_batched_tokens": 32768,
|
||||
"parallel_config": {
|
||||
"tensor_parallel_size": 4,
|
||||
"enable_expert_parallel": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -216,8 +251,8 @@ def _run_vllm_omni_hunyuan_image3_offline(*, model: str, deploy_config: str, out
|
||||
|
||||
|
||||
def _assert_against_baseline(image: Image.Image, label: str) -> None:
|
||||
assert BASELINE_PATH.exists(), f"Baseline image not found at {BASELINE_PATH}"
|
||||
baseline_image = Image.open(BASELINE_PATH).convert("RGB")
|
||||
baseline_path = _baseline_path()
|
||||
baseline_image = Image.open(baseline_path).convert("RGB")
|
||||
|
||||
assert_images_pixel_close(
|
||||
model_name=f"{MODEL_NAME} ({label} vs baseline)",
|
||||
@@ -231,11 +266,11 @@ def _assert_against_baseline(image: Image.Image, label: str) -> None:
|
||||
vllm_image=image,
|
||||
diffusers_image=baseline_image,
|
||||
ssim_threshold=SSIM_THRESHOLD,
|
||||
psnr_threshold=PSNR_THRESHOLD,
|
||||
psnr_threshold=_psnr_threshold(),
|
||||
)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=4)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=4)
|
||||
def test_hunyuan_image3_pixel_accuracy_online(accuracy_artifact_root: Path) -> None:
|
||||
model = _model_name()
|
||||
output_dir = model_output_dir(accuracy_artifact_root, MODEL_NAME)
|
||||
@@ -249,7 +284,7 @@ def test_hunyuan_image3_pixel_accuracy_online(accuracy_artifact_root: Path) -> N
|
||||
_assert_against_baseline(image, "online")
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=4)
|
||||
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=4)
|
||||
def test_hunyuan_image3_pixel_accuracy_offline(accuracy_artifact_root: Path) -> None:
|
||||
model = _model_name()
|
||||
output_dir = model_output_dir(accuracy_artifact_root, MODEL_NAME)
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm.v1.engine.utils import EngineZmqAddresses
|
||||
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
|
||||
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
|
||||
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase
|
||||
@@ -476,6 +477,89 @@ class TestSingleStageModeDetection:
|
||||
assert not hasattr(engine, "_omni_master_server")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint-restriction resolution vs. tri-state trust_remote_code (#5495)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEndpointRestrictionsTrustRemoteCode:
|
||||
"""Regression tests for issue #5495.
|
||||
|
||||
``AsyncOmniEngine.__init__`` takes ``trust_remote_code: bool | None`` where
|
||||
``None`` means "not specified" (the CLI default, since ``--trust-remote-code``
|
||||
is ``store_true`` and its absence is mapped to ``None``). Endpoint-restriction
|
||||
resolution eagerly loads the HF config via vLLM's ``get_config``, which does
|
||||
``trust_remote_code |= ...`` internally and raises ``TypeError`` on ``None``.
|
||||
That used to be swallowed, leaving ``endpoint_restrictions`` empty so
|
||||
``/v1/completions`` was never shut down and the request crashed stage-1.
|
||||
|
||||
``__init__`` must collapse the ``None`` "not specified" case to ``False`` at
|
||||
the ``get_pipeline_config`` call site so the restriction is still computed.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_config_factory_caches(self):
|
||||
"""Isolate the process-wide ``functools.cache`` on StageConfigFactory.
|
||||
|
||||
``get_hf_config`` / ``try_infer_model_type`` cache per-model results for
|
||||
the whole process. These tests patch ``get_config`` with a synthetic
|
||||
Qwen3-Omni config for ``"fake-model"``; without clearing on teardown the
|
||||
synthetic entry would leak to later tests in the same worker (after the
|
||||
mock is restored), making the suite order-dependent. Clear both before
|
||||
and after.
|
||||
"""
|
||||
StageConfigFactory.get_hf_config.cache_clear()
|
||||
StageConfigFactory.try_infer_model_type.cache_clear()
|
||||
yield
|
||||
StageConfigFactory.get_hf_config.cache_clear()
|
||||
StageConfigFactory.try_infer_model_type.cache_clear()
|
||||
|
||||
def _make_engine_no_thread(self, mocker: MockerFixture, **kwargs: Any) -> AsyncOmniEngine:
|
||||
mocker.patch.object(
|
||||
AsyncOmniEngine,
|
||||
"_resolve_stage_configs",
|
||||
return_value=("/fake/path", [_make_stage_cfg(0)]),
|
||||
)
|
||||
mocker.patch.object(AsyncOmniEngine, "_bootstrap_orchestrator")
|
||||
mocker.patch("threading.Thread")
|
||||
mocker.patch("concurrent.futures.Future")
|
||||
return AsyncOmniEngine(model="fake-model", **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _install_qwen3_omni_config(mocker: MockerFixture) -> None:
|
||||
"""Patch get_config to a thinker+talker config, mirroring vLLM's
|
||||
``trust_remote_code |= ...`` (raises TypeError on None)."""
|
||||
from transformers import Qwen3OmniMoeConfig
|
||||
|
||||
def fake_get_config(model, trust_remote_code, **_):
|
||||
trust_remote_code |= False # TypeError if None reaches here
|
||||
return Qwen3OmniMoeConfig(enable_audio_output=True)
|
||||
|
||||
mocker.patch("vllm_omni.config.config_factory.get_config", side_effect=fake_get_config)
|
||||
|
||||
@pytest.mark.parametrize("trc_kwargs", [{}, {"trust_remote_code": False}, {"trust_remote_code": True}])
|
||||
def test_completions_restriction_survives(self, mocker: MockerFixture, trc_kwargs: dict):
|
||||
"""COMPLETIONS restriction is computed regardless of how (or whether)
|
||||
trust_remote_code is passed — including the unset case that resolves to
|
||||
the engine's ``None`` default."""
|
||||
from vllm_omni.config.endpoint_policy import OmniServingCapability
|
||||
|
||||
self._install_qwen3_omni_config(mocker)
|
||||
engine = self._make_engine_no_thread(mocker, **trc_kwargs)
|
||||
assert any(r.capability is OmniServingCapability.COMPLETIONS for r in engine.endpoint_restrictions), (
|
||||
f"COMPLETIONS restriction dropped for {trc_kwargs or 'unset (None default)'}"
|
||||
)
|
||||
|
||||
def test_unset_trust_remote_code_defaults_to_none(self, mocker: MockerFixture):
|
||||
"""Guard the tri-state premise: the engine keeps ``None`` (not ``False``)
|
||||
when trust_remote_code is not passed, so the call-site coercion is what
|
||||
prevents the crash."""
|
||||
import inspect
|
||||
|
||||
default = inspect.signature(AsyncOmniEngine.__init__).parameters["trust_remote_code"].default
|
||||
assert default is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AsyncOmniEngine single-stage initialization paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
@@ -62,11 +63,19 @@ class FakeAsyncOmni:
|
||||
self.stage_configs = [SimpleNamespace(stage_type="diffusion")]
|
||||
self.default_sampling_params_list = [OmniDiffusionSamplingParams()]
|
||||
self.captured_prompt = None
|
||||
self.captured_reference_video_bytes = None
|
||||
self.captured_sampling_params_list = None
|
||||
|
||||
async def generate(self, prompt, request_id, sampling_params_list):
|
||||
self.captured_prompt = prompt
|
||||
self.captured_sampling_params_list = sampling_params_list
|
||||
reference_videos = prompt.get("multi_modal_data", {}).get("video")
|
||||
if (
|
||||
isinstance(reference_videos, list)
|
||||
and reference_videos
|
||||
and all(isinstance(item, str) for item in reference_videos)
|
||||
):
|
||||
self.captured_reference_video_bytes = [Path(item).read_bytes() for item in reference_videos]
|
||||
num_outputs = sampling_params_list[0].num_outputs_per_prompt
|
||||
videos = [object() for _ in range(num_outputs)]
|
||||
yield MockVideoResult(videos)
|
||||
@@ -501,6 +510,44 @@ def test_v2v_video_generation_with_video_reference_form(test_client, mocker: Moc
|
||||
assert input_video[0].size == (32, 24)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"])
|
||||
def test_multi_video_generation_preserves_uploaded_files_until_generation(
|
||||
endpoint,
|
||||
test_client,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
|
||||
return_value=b"fake-video",
|
||||
)
|
||||
response = test_client.post(
|
||||
endpoint,
|
||||
data={
|
||||
"prompt": "Composite the subject from the first video into the second.",
|
||||
"extra_params": json.dumps({"task": "ref2va", "duration": 15.0}),
|
||||
},
|
||||
files=[
|
||||
("input_references", ("subject.mp4", b"subject-video", "video/mp4")),
|
||||
("input_references", ("background.mov", b"background-video", "video/quicktime")),
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
if endpoint.endswith("/sync"):
|
||||
assert response.content == b"fake-video"
|
||||
else:
|
||||
video_id = response.json()["id"]
|
||||
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
|
||||
|
||||
engine = test_client.app.state.openai_serving_video._engine_client
|
||||
input_videos = engine.captured_prompt["multi_modal_data"]["video"]
|
||||
assert engine.captured_reference_video_bytes == [b"subject-video", b"background-video"]
|
||||
assert len(input_videos) == 2
|
||||
assert all(not Path(path).exists() for path in input_videos)
|
||||
assert engine.captured_sampling_params_list[0].extra_args["task"] == "ref2va"
|
||||
assert engine.captured_sampling_params_list[0].extra_args["duration"] == 15.0
|
||||
|
||||
|
||||
def test_decode_video_bytes_can_keep_last_frames():
|
||||
from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ EXCLUDED_MODELS = [
|
||||
"LTX2DistilledPipeline",
|
||||
"LTX2T2VDMD2Pipeline",
|
||||
"LTX2I2VDMD2Pipeline",
|
||||
"MiniMaxH3Pipeline",
|
||||
"StableAudioPipeline",
|
||||
"WanImageToVideoPipeline",
|
||||
"WanS2VPipeline",
|
||||
|
||||
@@ -219,6 +219,46 @@ def test_store_true_explicit():
|
||||
assert ns.verbose is True
|
||||
|
||||
|
||||
def test_unset_trust_remote_code_falls_back_to_engine_default_none():
|
||||
"""Regression guard for issue #5495 (the CLI-plumbing half).
|
||||
|
||||
The CI test ``test_completions_rejected_for_thinker_talker`` does NOT pass
|
||||
``--trust-remote-code``. Because it is a ``store_true`` flag, an omitted
|
||||
value is dropped from ``get_explicit_kwargs_dict()`` (it is not user-typed),
|
||||
so it is never forwarded to ``AsyncOmni(**kwargs)`` and the engine falls back
|
||||
to ``AsyncOmniEngine.__init__``'s default of ``None`` — NOT argparse's
|
||||
``False``. That ``None`` is what used to break endpoint-restriction
|
||||
resolution (see ``TestQwen3OmniPipeline.
|
||||
test_endpoint_restrictions_survive_unset_trust_remote_code``).
|
||||
|
||||
This test locks in the "CI does not pass the flag => effective value is
|
||||
None" premise so a future refactor cannot silently change it.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
|
||||
|
||||
p = TrackingArgumentParser()
|
||||
# Mirror how --trust-remote-code is registered upstream (store_true).
|
||||
p.add_argument("--trust-remote-code", action="store_true")
|
||||
|
||||
# Scenario A: CI — flag omitted.
|
||||
ns = p.parse_args([])
|
||||
assert "trust_remote_code" not in ns.explicit_keys
|
||||
kwargs = ns.get_explicit_kwargs_dict()
|
||||
assert "trust_remote_code" not in kwargs
|
||||
engine_default = inspect.signature(AsyncOmniEngine.__init__).parameters["trust_remote_code"].default
|
||||
assert engine_default is None
|
||||
effective = kwargs.get("trust_remote_code", engine_default)
|
||||
assert effective is None # <- the value that reached the buggy restriction path
|
||||
|
||||
# Scenario B: flag passed (what earlier repros used) => True, restrictions fine.
|
||||
ns2 = p.parse_args(["--trust-remote-code"])
|
||||
assert "trust_remote_code" in ns2.explicit_keys
|
||||
kwargs2 = ns2.get_explicit_kwargs_dict()
|
||||
assert kwargs2.get("trust_remote_code", engine_default) is True
|
||||
|
||||
|
||||
def test_store_false_default():
|
||||
"""Ensure that store false is handled correctly when omitted."""
|
||||
p = TrackingArgumentParser()
|
||||
|
||||
@@ -113,6 +113,7 @@ class _ParallelConfigEngineOverrides(TypedDict, total=False):
|
||||
ulysses_mode: str
|
||||
cfg_parallel_size: int
|
||||
vae_patch_parallel_size: int
|
||||
text_encoder_tp_size: int
|
||||
use_hsdp: bool
|
||||
mask_sp_padding: bool
|
||||
hsdp_shard_size: int
|
||||
@@ -370,6 +371,7 @@ class OmniStageDiffusionParallelConfig(OmniStageParallelConfig):
|
||||
ulysses_mode: str = "strict"
|
||||
cfg_parallel_size: int = Field(default=1, ge=1)
|
||||
vae_patch_parallel_size: int = Field(default=1, ge=1)
|
||||
text_encoder_tp_size: int = Field(default=1, ge=1)
|
||||
vae_parallel_mode: str = "tile"
|
||||
use_hsdp: bool = False
|
||||
mask_sp_padding: bool = False
|
||||
|
||||
@@ -377,6 +377,7 @@ class StageDeployConfig:
|
||||
cfg_parallel_size: int | None = None
|
||||
vae_patch_parallel_size: int | None = None
|
||||
vae_parallel_mode: str | None = None
|
||||
text_encoder_tp_size: int | None = None
|
||||
use_hsdp: bool | None = None
|
||||
hsdp_shard_size: int | None = None
|
||||
hsdp_replicate_size: int | None = None
|
||||
|
||||
@@ -79,6 +79,10 @@ class AttentionMetadata:
|
||||
# Well-known optional keys (convention, not required on all forwards):
|
||||
# "kv_cache_dtype": str | None — quantized KV dtype (e.g. "fp8"); backends
|
||||
# decide whether/how to apply.
|
||||
# "cu_seqlens_q" / "cu_seqlens_k": int32 CUDA tensors describing packed
|
||||
# variable-length query/key sequences for FlashAttention.
|
||||
# "max_seqlen_q" / "max_seqlen_k": maximum sequence lengths paired with
|
||||
# the packed cu_seqlens tensors.
|
||||
|
||||
# Piecewise attention metadata (mixed causal/full masks).
|
||||
# full_attn_spans: per-sample [start, end) spans in global coordinates using full attention.
|
||||
|
||||
@@ -141,6 +141,46 @@ class FlashAttentionImpl(AttentionImpl):
|
||||
out_unpad = self._unwrap_flash_output(out_unpad)
|
||||
return _pad_input(out_unpad, indices_q, query.size(0), query_length)
|
||||
|
||||
def _forward_varlen_packed(
|
||||
self,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
*,
|
||||
cu_seqlens_q: torch.Tensor,
|
||||
cu_seqlens_k: torch.Tensor,
|
||||
max_seqlen_q: int,
|
||||
max_seqlen_k: int,
|
||||
) -> torch.Tensor:
|
||||
"""Run FlashAttention directly on an already packed sequence.
|
||||
|
||||
Some diffusion transformers already maintain exact packed-document
|
||||
boundaries. Reusing those boundaries avoids rebuilding boolean masks
|
||||
and gathering/scattering Q/K/V in every attention layer.
|
||||
"""
|
||||
from vllm_omni.diffusion.attention.backends.utils.fa import (
|
||||
flash_attn_varlen_func,
|
||||
)
|
||||
|
||||
if flash_attn_varlen_func is None:
|
||||
raise ImportError("Packed variable-length attention requires flash_attn_varlen_func")
|
||||
if query.shape[0] != 1 or key.shape[0] != 1 or value.shape[0] != 1:
|
||||
raise ValueError("Packed variable-length attention currently requires batch size 1")
|
||||
|
||||
out = flash_attn_varlen_func(
|
||||
q=query.flatten(0, 1),
|
||||
k=key.flatten(0, 1),
|
||||
v=value.flatten(0, 1),
|
||||
cu_seqlens_q=cu_seqlens_q,
|
||||
cu_seqlens_k=cu_seqlens_k,
|
||||
max_seqlen_q=max_seqlen_q,
|
||||
max_seqlen_k=max_seqlen_k,
|
||||
causal=self.causal,
|
||||
softmax_scale=self.softmax_scale,
|
||||
)
|
||||
out = self._unwrap_flash_output(out)
|
||||
return out.reshape_as(query)
|
||||
|
||||
def _forward_varlen_dense(
|
||||
self,
|
||||
query: torch.Tensor,
|
||||
@@ -206,6 +246,7 @@ class FlashAttentionImpl(AttentionImpl):
|
||||
|
||||
attention_mask = attn_metadata.attn_mask if attn_metadata is not None else None
|
||||
full_attn_spans = attn_metadata.full_attn_spans if attn_metadata is not None else None
|
||||
extra = attn_metadata.extra if attn_metadata is not None else {}
|
||||
|
||||
# Try piecewise attention
|
||||
if full_attn_spans is not None:
|
||||
@@ -233,6 +274,22 @@ class FlashAttentionImpl(AttentionImpl):
|
||||
query_ranges=attn_metadata.query_ranges,
|
||||
)
|
||||
|
||||
packed_keys = ("cu_seqlens_q", "cu_seqlens_k", "max_seqlen_q", "max_seqlen_k")
|
||||
present_packed_keys = [key for key in packed_keys if key in extra]
|
||||
if present_packed_keys:
|
||||
if len(present_packed_keys) != len(packed_keys):
|
||||
missing = sorted(set(packed_keys) - set(present_packed_keys))
|
||||
raise ValueError(f"Incomplete packed FlashAttention metadata; missing {missing}")
|
||||
return self._forward_varlen_packed(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
cu_seqlens_q=extra["cu_seqlens_q"],
|
||||
cu_seqlens_k=extra["cu_seqlens_k"],
|
||||
max_seqlen_q=extra["max_seqlen_q"],
|
||||
max_seqlen_k=extra["max_seqlen_k"],
|
||||
)
|
||||
|
||||
if attention_mask is not None and torch.any(~attention_mask):
|
||||
return self._forward_varlen_masked(
|
||||
query,
|
||||
|
||||
@@ -23,6 +23,19 @@ HAS_FA3 = False
|
||||
fa3_fwd_func = None # Low-level forward function (_flash_attn_forward)
|
||||
fa3_attn_func = None # High-level attention function (flash_attn_func)
|
||||
|
||||
# FA4 detection. The CuTe API returns LSE directly, so it can participate in
|
||||
# Ring Attention's numerically stable block-wise output accumulation.
|
||||
HAS_FA4 = False
|
||||
fa4_attn_func = None
|
||||
try:
|
||||
from flash_attn.cute import flash_attn_func as fa4_attn_func # noqa: F401
|
||||
|
||||
HAS_FA4 = True
|
||||
except Exception:
|
||||
# Optional CuTe/CUTLASS/Quack components can be importable but
|
||||
# ABI-incompatible. Treat the whole optional backend as unavailable.
|
||||
pass
|
||||
|
||||
# Try flash_attn_interface first (from flash-attention source build)
|
||||
try:
|
||||
from flash_attn_interface import _flash_attn_forward as fa3_fwd_func # noqa: F401
|
||||
|
||||
@@ -9,9 +9,11 @@ import torch
|
||||
from .ring_globals import (
|
||||
HAS_AITER,
|
||||
HAS_FA3,
|
||||
HAS_FA4,
|
||||
HAS_FLASH_ATTN,
|
||||
HAS_FLASHINFER,
|
||||
fa3_fwd_func,
|
||||
fa4_attn_func,
|
||||
)
|
||||
|
||||
_scaled_dot_product_flash_attention = torch.ops.aten._scaled_dot_product_flash_attention
|
||||
@@ -190,6 +192,40 @@ def fa3_forward(q, k, v, dropout_p, softmax_scale, causal, window_size, softcap,
|
||||
flash_attn3_func_forward = fa3_forward
|
||||
|
||||
|
||||
def flash_attn4_func_forward(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
dropout_p=0.0,
|
||||
softmax_scale=None,
|
||||
causal=False,
|
||||
window_size=(-1, -1),
|
||||
softcap=None,
|
||||
alibi_slopes=None,
|
||||
return_softmax=False,
|
||||
):
|
||||
"""FA4 forward pass returning the LSE required by Ring Attention."""
|
||||
del dropout_p, alibi_slopes, return_softmax
|
||||
assert HAS_FA4, "FA4 is not available"
|
||||
assert fa4_attn_func is not None, "FA4 high-level API is not available"
|
||||
|
||||
left, right = window_size if window_size else (-1, -1)
|
||||
out, softmax_lse = fa4_attn_func(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
softmax_scale=softmax_scale,
|
||||
causal=causal,
|
||||
window_size=(
|
||||
None if left == -1 else left,
|
||||
None if right == -1 else right,
|
||||
),
|
||||
softcap=softcap or 0.0,
|
||||
return_lse=True,
|
||||
)
|
||||
return out, softmax_lse
|
||||
|
||||
|
||||
def flash_attn_forward_aiter(
|
||||
q,
|
||||
k,
|
||||
|
||||
@@ -15,6 +15,7 @@ from .ring_globals import (
|
||||
)
|
||||
from .ring_kernels import (
|
||||
flash_attn3_func_forward,
|
||||
flash_attn4_func_forward,
|
||||
flash_attn_forward,
|
||||
flash_attn_forward_aiter,
|
||||
flashinfer_attn_forward,
|
||||
@@ -32,6 +33,7 @@ class AttnType(Enum):
|
||||
AITER = "aiter"
|
||||
FA = "fa"
|
||||
FA3 = "fa3"
|
||||
FA4 = "fa4"
|
||||
FLASHINFER = "flashinfer"
|
||||
TORCH = "torch"
|
||||
SAGE_AUTO = "sage_auto"
|
||||
@@ -77,6 +79,9 @@ def select_flash_attn_impl(
|
||||
elif impl_type == AttnType.FA3:
|
||||
return flash_attn3_func_forward
|
||||
|
||||
elif impl_type == AttnType.FA4:
|
||||
return flash_attn4_func_forward
|
||||
|
||||
elif impl_type == AttnType.FLASHINFER:
|
||||
return flashinfer_attn_forward
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _is_blackwell() -> bool:
|
||||
try:
|
||||
capability = current_omni_platform.get_device_capability()
|
||||
except Exception:
|
||||
return False
|
||||
return capability is not None and capability.major >= 10
|
||||
|
||||
|
||||
# Flash Attention function detection with fallback chain
|
||||
flash_attn_func = None
|
||||
flash_attn_varlen_func = None
|
||||
@@ -48,12 +57,27 @@ elif current_omni_platform.is_musa():
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pass
|
||||
else:
|
||||
# CUDA: try FA3 -> FA2 fallback chain
|
||||
# CUDA: prefer the CuTe-based FA4 package when present. This matters on
|
||||
# Blackwell, where an importable Hopper-only FA3 wheel may otherwise be
|
||||
# selected and fail at launch with "no kernel image is available".
|
||||
if _is_blackwell():
|
||||
try:
|
||||
from flash_attn.cute import flash_attn_func, flash_attn_varlen_func # noqa: F401
|
||||
|
||||
logger.info("Using CuTe FlashAttention-4 on Blackwell")
|
||||
except Exception as exc:
|
||||
# Optional FA4 dependencies may be present but ABI-incompatible
|
||||
# (for example, mismatched CUTLASS DSL and Quack builds). Treat
|
||||
# that as unavailable so backend discovery can continue.
|
||||
logger.debug("CuTe FlashAttention-4 is unavailable: %s", exc)
|
||||
|
||||
# Try FA3 -> FA2 fallback chain.
|
||||
# Try FA3 from fa3-fwd PyPI package
|
||||
try:
|
||||
from fa3_fwd_interface import flash_attn_func, flash_attn_varlen_func # noqa: F401
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pass
|
||||
if flash_attn_func is None:
|
||||
try:
|
||||
from fa3_fwd_interface import flash_attn_func, flash_attn_varlen_func # noqa: F401
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pass
|
||||
|
||||
# Fallback: Try FA3 from flash-attention source build
|
||||
if flash_attn_func is None:
|
||||
@@ -100,7 +124,16 @@ def is_flash_attn_installed() -> bool:
|
||||
Shared by CUDA/ROCm/MUSA platforms.
|
||||
"""
|
||||
try:
|
||||
# Check for any FA backend: FA3 (fa3_fwd_interface, flash_attn_interface) or FA2 (flash_attn)
|
||||
# Check for any FA backend: FA4 (flash_attn.cute), FA3
|
||||
# (fa3_fwd_interface, flash_attn_interface), or FA2 (flash_attn).
|
||||
if _is_blackwell():
|
||||
try:
|
||||
from flash_attn.cute import flash_attn_varlen_func # noqa: F401
|
||||
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("CuTe FlashAttention-4 is unavailable: %s", exc)
|
||||
|
||||
# Try FA3 from fa3-fwd PyPI package
|
||||
try:
|
||||
import fa3_fwd_interface # noqa: F401
|
||||
|
||||
@@ -10,7 +10,7 @@ import torch
|
||||
from vllm.logger import init_logger
|
||||
|
||||
# import torch.distributed as dist # Not used directly here, but good practice if needed
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_globals import HAS_AITER, HAS_FA3, HAS_FLASH_ATTN
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_globals import HAS_AITER, HAS_FA3, HAS_FA4, HAS_FLASH_ATTN
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_selector import AttnType
|
||||
from vllm_omni.diffusion.attention.parallel.base import (
|
||||
ParallelAttentionContext,
|
||||
@@ -145,9 +145,12 @@ class RingParallelAttention:
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.ring_flash_attn import ring_flash_attn_func
|
||||
|
||||
# Prefer FA3 over FA2 for better performance (FA3 supports Ampere/Ada/Hopper)
|
||||
# On ROCm, use AITER
|
||||
if HAS_FA3:
|
||||
# Prefer FA4 on Blackwell. An importable Hopper-only FA3 wheel can
|
||||
# otherwise be selected and fail at launch with "no kernel image".
|
||||
if HAS_FA4 and query.is_cuda and torch.cuda.get_device_capability(query.device)[0] >= 10:
|
||||
attn_type = AttnType.FA4
|
||||
# Prefer FA3 over FA2 on Ampere/Ada/Hopper. On ROCm, use AITER.
|
||||
elif HAS_FA3:
|
||||
attn_type = AttnType.FA3
|
||||
elif HAS_AITER:
|
||||
attn_type = AttnType.AITER
|
||||
|
||||
@@ -192,6 +192,16 @@ class DiffusionParallelConfig:
|
||||
vae_patch_parallel_size: int = 1
|
||||
"""Number of ranks used for VAE patch/tile parallelism (decode/encode)."""
|
||||
|
||||
text_encoder_tp_size: int = 1
|
||||
"""Number of ranks used to tensor-parallel shard the diffusion text encoder.
|
||||
|
||||
Ranks are the first ``text_encoder_tp_size`` DiT ranks. Defaults to 1,
|
||||
which keeps the encoder fully resident on the DiT main rank (historical
|
||||
behavior). Values > 1 shard the Qwen3-VL encoder across the encoder TP
|
||||
ranks and run the encode with distributed collectives over the encoder
|
||||
process group.
|
||||
"""
|
||||
|
||||
vae_parallel_mode: str = "tile"
|
||||
"""VAE parallel decode strategy.
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ _DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = {
|
||||
supports_multimodal_inputs=True,
|
||||
max_multimodal_image_inputs=BOOGU_IMAGE_MAX_INPUT_IMAGES,
|
||||
),
|
||||
"MiniMaxH3Pipeline": DiffusionModelMetadata(
|
||||
supports_multimodal_inputs=True,
|
||||
max_multimodal_image_inputs=1,
|
||||
),
|
||||
"WanPipeline": DiffusionModelMetadata(attention_mask_free=True),
|
||||
"WanImageToVideoPipeline": DiffusionModelMetadata(attention_mask_free=True),
|
||||
"WanVACEPipeline": DiffusionModelMetadata(attention_mask_free=True),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 audio-video diffusion support."""
|
||||
|
||||
from .pipeline_minimax_h3 import (
|
||||
MiniMaxH3Pipeline,
|
||||
get_minimax_h3_post_process_func,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3Pipeline",
|
||||
"get_minimax_h3_post_process_func",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 visual/audio condition-noise augmentation.
|
||||
|
||||
The request's condition timestep is applied to both the tensor value and the
|
||||
DiT timestep. Tokenizer artifacts remain clean
|
||||
and reusable; this module materializes the fixed noised anchors immediately
|
||||
before the denoise loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import torch
|
||||
|
||||
from .packed_tokens import minimax_h3_patchify_video_latent
|
||||
|
||||
# Channel-major packed audio rows always carry a stereo layout.
|
||||
MINIMAX_H3_AUDIO_COND_CHANNELS = 2
|
||||
|
||||
|
||||
def minimax_h3_imgvid_cond_noise_aug_rows(
|
||||
clean_rows: torch.Tensor,
|
||||
*,
|
||||
condition_shapes: Sequence[Sequence[int]],
|
||||
target_latent_t: int,
|
||||
imgvid_cond_num_frames: int,
|
||||
seed: int,
|
||||
noise_aug: float,
|
||||
) -> torch.Tensor:
|
||||
"""Apply the imgvid-condition RF noise recipe to packed clean rows.
|
||||
|
||||
``condition_shapes`` contains ``(latent_t, latent_h, latent_w)`` in packed
|
||||
visual-condition order. A new CPU generator with the same row seed is
|
||||
created for every condition. Under the dependent-noise policy, each draw
|
||||
uses the target temporal length plus the template's imgvid-condition frame
|
||||
count, then slices the prefix matching the current condition.
|
||||
"""
|
||||
|
||||
noise_aug = float(noise_aug)
|
||||
if not 0.0 <= noise_aug <= 1.0:
|
||||
raise ValueError(f"noise_aug must be in [0, 1], got {noise_aug}")
|
||||
if noise_aug == 1.0:
|
||||
return clean_rows
|
||||
if clean_rows.ndim != 2 or int(clean_rows.shape[1]) != 96:
|
||||
raise ValueError(f"clean imgvid condition rows must have shape [n, 96], got {list(clean_rows.shape)}")
|
||||
|
||||
target_latent_t = int(target_latent_t)
|
||||
imgvid_cond_num_frames = int(imgvid_cond_num_frames)
|
||||
if target_latent_t <= 0:
|
||||
raise ValueError(f"target_latent_t must be positive, got {target_latent_t}")
|
||||
if imgvid_cond_num_frames <= 0:
|
||||
raise ValueError(
|
||||
f"imgvid_cond_num_frames must be positive when condition rows exist, got {imgvid_cond_num_frames}"
|
||||
)
|
||||
|
||||
parsed_shapes: list[tuple[int, int, int]] = []
|
||||
expected_rows = 0
|
||||
for raw_shape in condition_shapes:
|
||||
if len(raw_shape) != 3:
|
||||
raise ValueError(
|
||||
f"each imgvid condition shape must be (latent_t, latent_h, latent_w), got {list(raw_shape)}"
|
||||
)
|
||||
latent_t, latent_h, latent_w = (int(value) for value in raw_shape)
|
||||
if latent_t <= 0 or latent_h <= 0 or latent_w <= 0:
|
||||
raise ValueError(f"imgvid condition shape must be positive, got {list(raw_shape)}")
|
||||
if latent_h % 2 or latent_w % 2:
|
||||
raise ValueError(
|
||||
f"imgvid condition spatial dimensions must be divisible by 2, got {(latent_t, latent_h, latent_w)}"
|
||||
)
|
||||
parsed_shapes.append((latent_t, latent_h, latent_w))
|
||||
expected_rows += latent_t * (latent_h // 2) * (latent_w // 2)
|
||||
if not parsed_shapes:
|
||||
raise ValueError("condition_shapes must not be empty")
|
||||
if int(clean_rows.shape[0]) != expected_rows:
|
||||
raise ValueError(
|
||||
f"clean imgvid condition rows {int(clean_rows.shape[0])} != shape-derived rows {expected_rows}"
|
||||
)
|
||||
|
||||
output_device = clean_rows.device
|
||||
clean_rows = clean_rows.detach().to(device="cpu", dtype=torch.float32)
|
||||
out: list[torch.Tensor] = []
|
||||
row_offset = 0
|
||||
timestep = torch.tensor(noise_aug, dtype=torch.float32, device="cpu")
|
||||
for latent_t, latent_h, latent_w in parsed_shapes:
|
||||
full_t = target_latent_t + imgvid_cond_num_frames
|
||||
if full_t < latent_t:
|
||||
raise ValueError(f"condition latent_t {latent_t} exceeds the noise draw length {full_t}")
|
||||
generator = torch.Generator(device="cpu").manual_seed(int(seed))
|
||||
noise = torch.randn(
|
||||
1,
|
||||
24,
|
||||
full_t,
|
||||
latent_h,
|
||||
latent_w,
|
||||
generator=generator,
|
||||
dtype=torch.float32,
|
||||
device="cpu",
|
||||
)[:, :, :latent_t]
|
||||
noise_rows = minimax_h3_patchify_video_latent(noise, patch_size=[1, 2, 2]).to(dtype=torch.float32)
|
||||
row_count = int(noise_rows.shape[0])
|
||||
clean_part = clean_rows[row_offset : row_offset + row_count].to(torch.float32)
|
||||
out.append(timestep * clean_part + (1.0 - timestep) * noise_rows)
|
||||
row_offset += row_count
|
||||
return torch.cat(out, dim=0).to(device=output_device, dtype=torch.float32).contiguous()
|
||||
|
||||
|
||||
def minimax_h3_audio_cond_noise_aug_rows(
|
||||
clean_rows: torch.Tensor,
|
||||
*,
|
||||
condition_audio_t: Sequence[int],
|
||||
seed: int,
|
||||
noise_aug: float,
|
||||
) -> torch.Tensor:
|
||||
"""Apply the audio-condition RF noise recipe to packed clean rows.
|
||||
|
||||
``condition_audio_t`` contains the latent T of each audio-bearing
|
||||
condition in canonical request order. Noise is drawn per condition
|
||||
element, with a fresh CPU generator seeded with ``seed + 1`` for every
|
||||
element. Consequently each condition restarts the
|
||||
same RNG stream; concatenating the rows and drawing once would be
|
||||
numerically different for ordered multi-reference requests.
|
||||
|
||||
The mix is intentionally evaluated on CPU in fp32 before the packed rows
|
||||
are transferred to the DiT device.
|
||||
"""
|
||||
|
||||
noise_aug = float(noise_aug)
|
||||
if not 0.0 <= noise_aug <= 1.0:
|
||||
raise ValueError(f"noise_aug must be in [0, 1], got {noise_aug}")
|
||||
if noise_aug == 1.0:
|
||||
return clean_rows
|
||||
if clean_rows.ndim != 2 or int(clean_rows.shape[1]) != 32:
|
||||
raise ValueError(f"clean audio condition rows must have shape [n, 32], got {list(clean_rows.shape)}")
|
||||
|
||||
audio_channels = MINIMAX_H3_AUDIO_COND_CHANNELS
|
||||
parsed_audio_t = [int(value) for value in condition_audio_t]
|
||||
if not parsed_audio_t:
|
||||
raise ValueError("condition_audio_t must not be empty")
|
||||
if any(value <= 0 for value in parsed_audio_t):
|
||||
raise ValueError(f"condition audio latent lengths must be positive, got {parsed_audio_t}")
|
||||
expected_rows = audio_channels * sum(parsed_audio_t)
|
||||
if int(clean_rows.shape[0]) != expected_rows:
|
||||
raise ValueError(f"clean audio condition rows {int(clean_rows.shape[0])} != shape-derived rows {expected_rows}")
|
||||
|
||||
out: list[torch.Tensor] = []
|
||||
row_offset = 0
|
||||
timestep = torch.tensor(noise_aug, dtype=torch.float32, device="cpu")
|
||||
for audio_t in parsed_audio_t:
|
||||
row_count = audio_channels * audio_t
|
||||
clean_part = clean_rows[row_offset : row_offset + row_count].detach().to(device="cpu", dtype=torch.float32)
|
||||
generator = torch.Generator(device="cpu").manual_seed(int(seed) + 1)
|
||||
noise = torch.randn(
|
||||
clean_part.shape,
|
||||
generator=generator,
|
||||
dtype=torch.float32,
|
||||
device="cpu",
|
||||
)
|
||||
out.append(timestep * clean_part + (1.0 - timestep) * noise)
|
||||
row_offset += row_count
|
||||
return torch.cat(out, dim=0).to(device=clean_rows.device, dtype=torch.float32).contiguous()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_audio_cond_noise_aug_rows",
|
||||
"minimax_h3_imgvid_cond_noise_aug_rows",
|
||||
]
|
||||
@@ -0,0 +1,249 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 cfg-distilled full denoise loop.
|
||||
|
||||
Per step, the positive presentation is forwarded exactly once. Video and audio
|
||||
target rows chain through the Euler-eta0 update while visual and audio condition
|
||||
rows stay pinned to their noised step-0 anchors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from .scheduling_minimax_h3_euler_ancestral import (
|
||||
minimax_h3_euler_eta0_step,
|
||||
minimax_h3_rf_v_to_x0,
|
||||
)
|
||||
|
||||
MINIMAX_H3_IMGVID_COND_TIMESTEP = 0.999
|
||||
# ref2va audio reference anchor timestep
|
||||
MINIMAX_H3_AUDIO_REF_COND_TIMESTEP = 1.0
|
||||
# Packed row widths: video rows are [1,2,2]-patchified 24-channel latents
|
||||
# (24 * 1 * 2 * 2 = 96); audio rows carry the 32-dim audio latent.
|
||||
MINIMAX_H3_VIDEO_ROW_WIDTH = 96
|
||||
MINIMAX_H3_AUDIO_ROW_WIDTH = 32
|
||||
|
||||
|
||||
class MiniMaxH3DenoiseBranch:
|
||||
"""Static per-branch state: packed layout + fixed forward kwargs.
|
||||
|
||||
`packed` is a minimax_h3_packed_sequence(...) result (or equivalent layout
|
||||
dict); `text_embeddings` is the branch's [text_len, 5120] hidden states;
|
||||
`token_tags` must already carry any fl2va vision-span overrides.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
packed: dict[str, torch.Tensor],
|
||||
text_embeddings: torch.Tensor,
|
||||
token_tags: torch.Tensor,
|
||||
device: torch.device,
|
||||
) -> None:
|
||||
seq_len = int(packed["seq_len"])
|
||||
self.seq_len = seq_len
|
||||
self.img_pos = packed["img_pos"].view(-1).to(torch.long)
|
||||
self.audio_pos = packed["audio_pos"].view(-1).to(torch.long)
|
||||
self.update_mask = packed["update_mask"].view(-1).to(torch.bool)
|
||||
# ref2va: audio_pos may include reference-audio anchor rows
|
||||
# (audio_update_mask False); absent means all rows are targets.
|
||||
if "audio_update_mask" in packed:
|
||||
self.audio_update_mask = packed["audio_update_mask"].view(-1).to(torch.bool)
|
||||
else:
|
||||
self.audio_update_mask = torch.ones(self.audio_pos.shape[0], dtype=torch.bool)
|
||||
text_len = int(packed["text_pos"].view(-1).shape[0])
|
||||
if list(text_embeddings.shape)[0] != text_len:
|
||||
raise ValueError(f"text_embeddings rows {list(text_embeddings.shape)} != packed text_len {text_len}")
|
||||
if int(token_tags.view(-1).shape[0]) != seq_len:
|
||||
raise ValueError(f"token_tags length {int(token_tags.view(-1).shape[0])} != seq_len {seq_len}")
|
||||
cu = packed["cu_seqlens"].to(torch.int32)
|
||||
self.img_pos_dev = self.img_pos.to(device)
|
||||
self.audio_pos_dev = self.audio_pos.to(device)
|
||||
self.update_mask_dev = self.update_mask.to(device)
|
||||
self.audio_update_mask_dev = self.audio_update_mask.to(device)
|
||||
self.x_base = torch.zeros(1, seq_len, MINIMAX_H3_VIDEO_ROW_WIDTH, dtype=torch.float32, device=device)
|
||||
self.audio_x_base = torch.zeros(1, seq_len, MINIMAX_H3_AUDIO_ROW_WIDTH, dtype=torch.float32, device=device)
|
||||
text_pos_dev = packed["text_pos"].view(-1).to(torch.long).to(device)
|
||||
self.static_kwargs: dict[str, Any] = {
|
||||
"img_position_ids": packed["img_position_ids"][None].to(device),
|
||||
"update_mask": self.update_mask_dev,
|
||||
"token_tags": token_tags.view(-1).to(torch.long).to(device),
|
||||
"skip_mask_out_condition": False,
|
||||
"prompt_embeds": text_embeddings.to(device),
|
||||
"img_pos_info": {"position_ids": self.img_pos_dev},
|
||||
"audio_pos_info": {"position_ids": self.audio_pos_dev},
|
||||
"text_pos_info": {"position_ids": text_pos_dev},
|
||||
"img_pos_for_infer_output_info": {"position_ids": self.img_pos_dev},
|
||||
"packed_seq_params": {
|
||||
"cu_seqlens_q": cu.to(device),
|
||||
"max_seqlen_q": int(cu[1]),
|
||||
},
|
||||
"refiner_packed_seq_params": {
|
||||
"cu_seqlens_q": torch.tensor([0, text_len, text_len], dtype=torch.int32, device=device),
|
||||
"max_seqlen_q": text_len,
|
||||
},
|
||||
}
|
||||
|
||||
def forward_kwargs(
|
||||
self,
|
||||
*,
|
||||
video_rows: torch.Tensor,
|
||||
audio_rows: torch.Tensor,
|
||||
t_video: float,
|
||||
t_audio: float,
|
||||
imgvid_cond_timestep: float,
|
||||
audio_ref_cond_timestep: float,
|
||||
) -> dict[str, Any]:
|
||||
x = self.x_base.clone()
|
||||
x[0].index_copy_(0, self.img_pos_dev, video_rows)
|
||||
audio_x = self.audio_x_base.clone()
|
||||
audio_x[0].index_copy_(0, self.audio_pos_dev, audio_rows)
|
||||
# Packed-sequence timestep semantics: non-media rows (text and
|
||||
# padding) inherit the current video timestep. Later steps must reuse
|
||||
# the previous step's updated rows; re-initializing from zeros is only
|
||||
# valid at step 0.
|
||||
timesteps = torch.full(
|
||||
(self.seq_len,),
|
||||
float(t_video),
|
||||
dtype=torch.float32,
|
||||
device=x.device,
|
||||
)
|
||||
timesteps[self.img_pos_dev[self.update_mask_dev]] = t_video
|
||||
timesteps[self.img_pos_dev[~self.update_mask_dev]] = imgvid_cond_timestep
|
||||
timesteps[self.audio_pos_dev[self.audio_update_mask_dev]] = t_audio
|
||||
timesteps[self.audio_pos_dev[~self.audio_update_mask_dev]] = audio_ref_cond_timestep
|
||||
unique_timesteps, inverse_indices = torch.unique(timesteps, sorted=True, return_inverse=True)
|
||||
return {
|
||||
**self.static_kwargs,
|
||||
"x": x,
|
||||
"audio_x": audio_x,
|
||||
"unique_timesteps": unique_timesteps,
|
||||
"inverse_indices": inverse_indices,
|
||||
}
|
||||
|
||||
|
||||
def minimax_h3_denoise_loop(
|
||||
*,
|
||||
model: Any,
|
||||
positive: MiniMaxH3DenoiseBranch,
|
||||
initial_video_rows: torch.Tensor,
|
||||
initial_audio_rows: torch.Tensor,
|
||||
keyframe_cond_rows: torch.Tensor | None,
|
||||
audio_ref_rows: torch.Tensor | None = None,
|
||||
sigmas_video: list[float],
|
||||
sigmas_audio: list[float],
|
||||
device: torch.device,
|
||||
imgvid_cond_noise_aug_for_inference: float = MINIMAX_H3_IMGVID_COND_TIMESTEP,
|
||||
audio_cond_noise_aug_for_inference: float = MINIMAX_H3_AUDIO_REF_COND_TIMESTEP,
|
||||
on_step: Callable[[int, torch.Tensor, torch.Tensor], None] | None = None,
|
||||
step_profiler: Callable[[int], AbstractContextManager] | None = None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Run the full denoise loop; returns final (video_rows, audio_rows).
|
||||
|
||||
``initial_video_rows`` covers all image rows of the positive layout. For a
|
||||
conditional task, pass ``keyframe_cond_rows`` and/or ``audio_ref_rows`` to
|
||||
pin those rows across every step. The model's raw positive velocity is the
|
||||
update signal; MiniMax H3 only supports cfg-distilled checkpoints.
|
||||
"""
|
||||
if len(sigmas_video) != len(sigmas_audio):
|
||||
raise ValueError("video/audio sigma schedules must have equal length")
|
||||
if len(sigmas_video) < 2:
|
||||
raise ValueError("sigma schedules need at least 2 entries")
|
||||
n_cond = int((~positive.update_mask).sum())
|
||||
if keyframe_cond_rows is None:
|
||||
if n_cond != 0:
|
||||
raise ValueError(f"layout has {n_cond} cond rows but keyframe_cond_rows is None")
|
||||
else:
|
||||
if int(keyframe_cond_rows.shape[0]) != n_cond:
|
||||
raise ValueError(f"keyframe_cond_rows {int(keyframe_cond_rows.shape[0])} != layout cond rows {n_cond}")
|
||||
video_rows = initial_video_rows.to(device=device, dtype=torch.float32).clone()
|
||||
audio_rows = initial_audio_rows.to(device=device, dtype=torch.float32).clone()
|
||||
update = positive.update_mask_dev
|
||||
audio_update = positive.audio_update_mask_dev
|
||||
if int(video_rows.shape[0]) != int(positive.img_pos.shape[0]):
|
||||
raise ValueError(
|
||||
f"initial video rows {int(video_rows.shape[0])} != positive layout rows {int(positive.img_pos.shape[0])}"
|
||||
)
|
||||
if int(audio_rows.shape[0]) != int(positive.audio_pos.shape[0]):
|
||||
raise ValueError(
|
||||
f"initial audio rows {int(audio_rows.shape[0])} != positive layout rows {int(positive.audio_pos.shape[0])}"
|
||||
)
|
||||
n_audio_ref = int((~positive.audio_update_mask).sum())
|
||||
if audio_ref_rows is None:
|
||||
if n_audio_ref != 0:
|
||||
raise ValueError(f"layout has {n_audio_ref} audio ref rows but audio_ref_rows is None")
|
||||
audio_anchor = None
|
||||
else:
|
||||
if int(audio_ref_rows.shape[0]) != n_audio_ref:
|
||||
raise ValueError(f"audio_ref_rows {int(audio_ref_rows.shape[0])} != layout audio ref rows {n_audio_ref}")
|
||||
audio_anchor = audio_ref_rows.to(device=device, dtype=torch.float32)
|
||||
cond_anchor = keyframe_cond_rows.to(device=device, dtype=torch.float32) if keyframe_cond_rows is not None else None
|
||||
if cond_anchor is not None:
|
||||
video_rows[~update] = cond_anchor
|
||||
if audio_anchor is not None:
|
||||
audio_rows[~audio_update] = audio_anchor
|
||||
|
||||
num_steps = len(sigmas_video) - 1
|
||||
for step in range(num_steps):
|
||||
step_cm = step_profiler(step) if step_profiler is not None else nullcontext()
|
||||
with step_cm:
|
||||
s_v, s_v_next = sigmas_video[step], sigmas_video[step + 1]
|
||||
s_a, s_a_next = sigmas_audio[step], sigmas_audio[step + 1]
|
||||
t_v, t_a = 1.0 - s_v, 1.0 - s_a
|
||||
imgvid_cond_t = max(t_v, float(imgvid_cond_noise_aug_for_inference))
|
||||
audio_ref_cond_t = max(t_a, float(audio_cond_noise_aug_for_inference))
|
||||
|
||||
fk = positive.forward_kwargs(
|
||||
video_rows=video_rows,
|
||||
audio_rows=audio_rows,
|
||||
t_video=t_v,
|
||||
t_audio=t_a,
|
||||
imgvid_cond_timestep=imgvid_cond_t,
|
||||
audio_ref_cond_timestep=audio_ref_cond_t,
|
||||
)
|
||||
with torch.inference_mode():
|
||||
v_video, v_audio = model(**fk)
|
||||
mv_video_t = v_video.float()[update]
|
||||
mv_audio_t = v_audio.float()[audio_update]
|
||||
|
||||
x0_video = minimax_h3_rf_v_to_x0(
|
||||
video_rows[update],
|
||||
mv_video_t,
|
||||
torch.tensor(t_v, dtype=torch.float32, device=device),
|
||||
)
|
||||
new_target = minimax_h3_euler_eta0_step(video_rows[update], x0_video, sigma_curr=s_v, sigma_next=s_v_next)
|
||||
video_rows = video_rows.clone()
|
||||
video_rows[update] = new_target
|
||||
if cond_anchor is not None:
|
||||
video_rows[~update] = cond_anchor # per-step imgvid cond reset
|
||||
|
||||
x0_audio = minimax_h3_rf_v_to_x0(
|
||||
audio_rows[audio_update],
|
||||
mv_audio_t,
|
||||
torch.tensor(t_a, dtype=torch.float32, device=device),
|
||||
)
|
||||
new_audio = minimax_h3_euler_eta0_step(
|
||||
audio_rows[audio_update], x0_audio, sigma_curr=s_a, sigma_next=s_a_next
|
||||
)
|
||||
audio_rows = audio_rows.clone()
|
||||
audio_rows[audio_update] = new_audio
|
||||
if audio_anchor is not None:
|
||||
audio_rows[~audio_update] = audio_anchor # per-step audio ref reset
|
||||
if on_step is not None:
|
||||
on_step(step, video_rows, audio_rows)
|
||||
|
||||
return video_rows, audio_rows
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_AUDIO_REF_COND_TIMESTEP",
|
||||
"MINIMAX_H3_AUDIO_ROW_WIDTH",
|
||||
"MINIMAX_H3_IMGVID_COND_TIMESTEP",
|
||||
"MINIMAX_H3_VIDEO_ROW_WIDTH",
|
||||
"MiniMaxH3DenoiseBranch",
|
||||
"minimax_h3_denoise_loop",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,572 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 packed-sequence materialization for FL2VA and Ref2VA tasks.
|
||||
|
||||
Layout: [text L | imgvid_cond C | audio A(=t*2ch) | video_target V | pad P].
|
||||
Builder rules:
|
||||
- placeholder ids; block-derived pos infos/masks/cu_seqlens/document_id
|
||||
- img_position_ids fp64 grid: text rows (row_idx,0,0); video/cond t counter
|
||||
continues text_len with temporal interp spans (frame_rescale 5/3 x
|
||||
frame_per_token (1,4,4,4,4)); each spatial sqrt_area axis uses evenly spaced
|
||||
coordinates excluding the right endpoint, then scales them by INTERP;
|
||||
audio channel-major blocks pinned to the w-grid extremes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES: tuple[tuple[int, ...], ...] = (
|
||||
(0,),
|
||||
(-1,),
|
||||
(0, -1),
|
||||
)
|
||||
|
||||
MINIMAX_H3_TEXT_ID = -5
|
||||
MINIMAX_H3_IMGVID_COND_ID = -11
|
||||
MINIMAX_H3_AUDIO_REF_COND_ID = -17
|
||||
MINIMAX_H3_AUDIO_FIRST_ID = -15
|
||||
MINIMAX_H3_AUDIO_ID = -14
|
||||
MINIMAX_H3_VIDEO_FIRST_ID = -3
|
||||
MINIMAX_H3_VIDEO_ID = -2
|
||||
MINIMAX_H3_VIDEO_LAST_ID = -4
|
||||
MINIMAX_H3_PAD_ID = -1
|
||||
|
||||
_INTERP = 32
|
||||
_T_GROUP = 5
|
||||
_FRAME_PER_TOKEN = (1, 4, 4, 4, 4)
|
||||
_FRAME_RESCALE = 5.0 / 3.0
|
||||
_SEQ_ALIGN = 64
|
||||
# MiniMax H3 packs video latents with a fixed [1, 2, 2] (t, h, w) patch.
|
||||
_PATCH_T = 1
|
||||
_PATCH_H = 2
|
||||
_PATCH_W = 2
|
||||
|
||||
|
||||
def _keyframe_cond_frame_indices(
|
||||
*,
|
||||
include_keyframe_cond: bool,
|
||||
keyframe_frame_indices: list[int] | tuple[int, ...] | None,
|
||||
) -> list[int]:
|
||||
if not include_keyframe_cond:
|
||||
if keyframe_frame_indices is not None:
|
||||
raise ValueError("keyframe_frame_indices must be omitted when keyframe cond is not included")
|
||||
return []
|
||||
if keyframe_frame_indices is None:
|
||||
raise ValueError("strict fl2va packed layout requires keyframe_frame_indices")
|
||||
if any(isinstance(value, bool) or not isinstance(value, int) for value in keyframe_frame_indices):
|
||||
raise ValueError("strict fl2va packed layout requires integer keyframe_frame_indices")
|
||||
out = list(keyframe_frame_indices)
|
||||
if tuple(out) not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES:
|
||||
raise ValueError(
|
||||
"strict fl2va packed layout requires keyframe_frame_indices in "
|
||||
f"{MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, got {out!r}"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_keyframe_frame_indices(
|
||||
frame_indices: Sequence[int],
|
||||
*,
|
||||
frame_count: int | None,
|
||||
) -> list[int]:
|
||||
if frame_indices and frame_count is None:
|
||||
raise ValueError("frame_count is required when keyframe_frame_indices are provided")
|
||||
if frame_count is None:
|
||||
return []
|
||||
if frame_count <= 0:
|
||||
raise ValueError("frame_count must be positive")
|
||||
seen: dict[int, int] = {}
|
||||
resolved: list[int] = []
|
||||
for block_index, semantic_index in enumerate(frame_indices):
|
||||
if semantic_index == -1:
|
||||
resolved_index = frame_count - 1
|
||||
elif 0 <= semantic_index < frame_count:
|
||||
resolved_index = semantic_index
|
||||
else:
|
||||
raise ValueError(f"keyframe frame index {semantic_index} must be -1 or in [0, {frame_count})")
|
||||
previous = seen.get(resolved_index)
|
||||
if previous is not None:
|
||||
raise ValueError(
|
||||
f"keyframe frame index at block {block_index} resolves to "
|
||||
f"{resolved_index}, already bound by block {previous}"
|
||||
)
|
||||
seen[resolved_index] = block_index
|
||||
resolved.append(resolved_index)
|
||||
return resolved
|
||||
|
||||
|
||||
def _temporal_position_span(temporal_length: int) -> float:
|
||||
"""Temporal position span for patch_t=1, in fp64.
|
||||
|
||||
NOTE: intentionally NOT merged with ``_video_t_span``. This variant sums
|
||||
via numpy (pairwise summation), matching the fl2va anchor computation,
|
||||
while ``_video_t_span`` sums sequentially, matching the ref2va
|
||||
t-origin accumulation. The two orders diverge in the last ulp
|
||||
from n=16 onward, so each path must keep its own summation order.
|
||||
"""
|
||||
spans = np.ones(int(temporal_length), dtype=np.float64) * _FRAME_RESCALE
|
||||
for token_index in range(_T_GROUP):
|
||||
spans[token_index::_T_GROUP] *= _FRAME_PER_TOKEN[token_index]
|
||||
return float(spans.sum())
|
||||
|
||||
|
||||
def minimax_h3_packed_sequence(
|
||||
*,
|
||||
text_len: int,
|
||||
latent_t: int,
|
||||
latent_h: int,
|
||||
latent_w: int,
|
||||
audio_t: int,
|
||||
audio_channel: int = 2,
|
||||
include_keyframe_cond: bool,
|
||||
keyframe_frame_indices: list[int] | tuple[int, ...] | None = None,
|
||||
frame_count: int | None = None,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
"""Build the packed-sequence structural fields for one CFG branch.
|
||||
|
||||
The used length is padded up to a multiple of 64.
|
||||
"""
|
||||
ph, pw = latent_h // _PATCH_H, latent_w // _PATCH_W
|
||||
frame_rows = ph * pw
|
||||
cond_frame_indices = _keyframe_cond_frame_indices(
|
||||
include_keyframe_cond=include_keyframe_cond,
|
||||
keyframe_frame_indices=keyframe_frame_indices,
|
||||
)
|
||||
resolved_cond_frame_indices = _resolve_keyframe_frame_indices(
|
||||
cond_frame_indices,
|
||||
frame_count=frame_count,
|
||||
)
|
||||
cond_rows = len(cond_frame_indices) * frame_rows
|
||||
video_rows = latent_t * frame_rows
|
||||
audio_rows = audio_t * audio_channel
|
||||
used = text_len + cond_rows + audio_rows + video_rows
|
||||
seq_len = ((used + _SEQ_ALIGN - 1) // _SEQ_ALIGN) * _SEQ_ALIGN
|
||||
|
||||
text_sl = slice(0, text_len)
|
||||
cond_sl = slice(text_len, text_len + cond_rows)
|
||||
audio_sl = slice(cond_sl.stop, cond_sl.stop + audio_rows)
|
||||
video_sl = slice(audio_sl.stop, audio_sl.stop + video_rows)
|
||||
pad_sl = slice(video_sl.stop, seq_len)
|
||||
|
||||
input_ids = torch.full((seq_len,), MINIMAX_H3_PAD_ID, dtype=torch.int64)
|
||||
input_ids[text_sl] = MINIMAX_H3_TEXT_ID
|
||||
if cond_rows:
|
||||
input_ids[cond_sl] = MINIMAX_H3_IMGVID_COND_ID
|
||||
input_ids[audio_sl] = MINIMAX_H3_AUDIO_ID
|
||||
input_ids[audio_sl.start] = MINIMAX_H3_AUDIO_FIRST_ID
|
||||
input_ids[video_sl] = MINIMAX_H3_VIDEO_ID
|
||||
input_ids[video_sl.start] = MINIMAX_H3_VIDEO_FIRST_ID
|
||||
input_ids[video_sl.stop - 1] = MINIMAX_H3_VIDEO_LAST_ID
|
||||
|
||||
image_mask = torch.zeros(seq_len, dtype=torch.bool)
|
||||
image_mask[cond_sl] = True
|
||||
image_mask[video_sl] = True
|
||||
audio_mask = torch.zeros(seq_len, dtype=torch.bool)
|
||||
audio_mask[audio_sl] = True
|
||||
|
||||
img_pos = torch.cat(
|
||||
[
|
||||
torch.arange(cond_sl.start, cond_sl.stop),
|
||||
torch.arange(video_sl.start, video_sl.stop),
|
||||
]
|
||||
)
|
||||
update_mask = torch.zeros(img_pos.shape[0], dtype=torch.bool)
|
||||
update_mask[cond_rows:] = True
|
||||
audio_pos = torch.arange(audio_sl.start, audio_sl.stop)
|
||||
text_pos = torch.arange(0, text_len)
|
||||
|
||||
g = torch.zeros(seq_len, 3, dtype=torch.float64)
|
||||
g[text_sl, 0] = torch.arange(text_len, dtype=torch.float64)
|
||||
|
||||
t_grid = _video_t_grid(latent_t, float(text_len))
|
||||
sqrt_area = np.sqrt(latent_h * latent_w)
|
||||
h_grid = _axis_from_sqrt_area(latent_h, _PATCH_H, sqrt_area)
|
||||
w_grid = _axis_from_sqrt_area(latent_w, _PATCH_W, sqrt_area)
|
||||
hh, ww = torch.meshgrid(h_grid, w_grid, indexing="ij")
|
||||
frame = torch.stack([hh.reshape(-1), ww.reshape(-1)], dim=-1)
|
||||
video_g = torch.empty(latent_t, frame_rows, 3, dtype=torch.float64)
|
||||
video_g[:, :, 0] = t_grid[:, None]
|
||||
video_g[:, :, 1:] = frame[None]
|
||||
for block_index, pixel_index in enumerate(resolved_cond_frame_indices):
|
||||
sl = slice(
|
||||
cond_sl.start + block_index * frame_rows,
|
||||
cond_sl.start + (block_index + 1) * frame_rows,
|
||||
)
|
||||
if pixel_index == 0:
|
||||
cond_t = float(text_len)
|
||||
elif frame_count is not None and pixel_index == frame_count - 1:
|
||||
cond_t = float(text_len) + _temporal_position_span(latent_t) - _FRAME_RESCALE
|
||||
else:
|
||||
raise ValueError(
|
||||
f"fl2va packed layout only supports first/last keyframe anchors, got resolved frame index {pixel_index}"
|
||||
)
|
||||
g[sl, 0] = cond_t
|
||||
g[sl, 1:] = frame
|
||||
g[video_sl] = video_g.reshape(-1, 3)
|
||||
audio_t_grid = float(text_len) + torch.arange(audio_t, dtype=torch.float64)
|
||||
g[audio_sl, 0] = audio_t_grid.repeat(audio_channel)
|
||||
g[audio_sl, 2] = torch.cat(
|
||||
[
|
||||
torch.full((audio_t,), float(w_grid[0]), dtype=torch.float64),
|
||||
torch.full((audio_rows - audio_t,), float(w_grid[-1]), dtype=torch.float64),
|
||||
]
|
||||
)
|
||||
|
||||
token_tags = torch.full((seq_len,), -1, dtype=torch.long) # PADDING
|
||||
token_tags[text_sl] = 1 # TEXT (fl2va image-segment override happens upstream)
|
||||
token_tags[audio_sl] = 2 # AUDIO
|
||||
token_tags[img_pos] = 0 # VIDEO
|
||||
|
||||
cu = torch.tensor([0, used, seq_len], dtype=torch.int32)
|
||||
doc = torch.zeros(seq_len, dtype=torch.int32)
|
||||
doc[pad_sl] = 1
|
||||
return {
|
||||
"seq_len": torch.tensor(seq_len),
|
||||
"input_ids": input_ids,
|
||||
"image_mask": image_mask,
|
||||
"audio_mask": audio_mask,
|
||||
"img_pos": img_pos,
|
||||
"audio_pos": audio_pos,
|
||||
"text_pos": text_pos,
|
||||
"update_mask": update_mask,
|
||||
"img_position_ids": g,
|
||||
"token_tags": token_tags,
|
||||
"cu_seqlens": cu,
|
||||
"document_id": doc,
|
||||
}
|
||||
|
||||
|
||||
def _positive_int(
|
||||
block: Mapping[str, object],
|
||||
key: str,
|
||||
path: str,
|
||||
*,
|
||||
allow_zero: bool = False,
|
||||
) -> int:
|
||||
value = block.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{path}.{key} must be an integer")
|
||||
if value < 0 or (value == 0 and not allow_zero):
|
||||
predicate = "non-negative" if allow_zero else "positive"
|
||||
raise ValueError(f"{path}.{key} must be {predicate}")
|
||||
return int(value)
|
||||
|
||||
|
||||
def _axis_from_sqrt_area(dim: int, patch: int, sqrt_area: float) -> torch.Tensor:
|
||||
ratio = dim / sqrt_area
|
||||
left = (1.0 - ratio) * 1.0 / 2.0
|
||||
right = left + ratio * 1.0
|
||||
grid = np.linspace(left, right, dim // patch, endpoint=False) * _INTERP
|
||||
return torch.from_numpy(grid).to(torch.float64)
|
||||
|
||||
|
||||
def _video_t_grid(n: int, origin: float) -> torch.Tensor:
|
||||
spans = torch.tensor(
|
||||
[_FRAME_RESCALE * _FRAME_PER_TOKEN[k % _T_GROUP] for k in range(n)],
|
||||
dtype=torch.float64,
|
||||
)
|
||||
return origin + torch.cat([torch.zeros(1, dtype=torch.float64), spans[:-1].cumsum(0)])
|
||||
|
||||
|
||||
def _video_t_span(n: int) -> float:
|
||||
# Sequential fp64 summation on purpose — see _temporal_position_span for
|
||||
# why the two span implementations must not be unified.
|
||||
return sum(_FRAME_RESCALE * _FRAME_PER_TOKEN[k % _T_GROUP] for k in range(n))
|
||||
|
||||
|
||||
def _range_for_slice(sl: slice) -> torch.Tensor:
|
||||
return torch.arange(sl.start, sl.stop, dtype=torch.long)
|
||||
|
||||
|
||||
def _cat_ranges(parts: list[torch.Tensor]) -> torch.Tensor:
|
||||
if parts:
|
||||
return torch.cat(parts)
|
||||
return torch.empty(0, dtype=torch.long)
|
||||
|
||||
|
||||
def minimax_h3_packed_sequence_ref2va_blocks(
|
||||
*,
|
||||
text_len: int,
|
||||
latent_t: int,
|
||||
latent_h: int,
|
||||
latent_w: int,
|
||||
audio_t: int,
|
||||
ref_blocks: Sequence[Mapping[str, object]],
|
||||
audio_channel: int = 2,
|
||||
seq_len: int | None = None,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
"""General ref2va-family packed layout.
|
||||
|
||||
``ref_blocks`` are consumed in request/plan order:
|
||||
- ``{"kind": "image", "latent_h": H, "latent_w": W}``
|
||||
- ``{"kind": "audio", "ref_audio_t": T}``
|
||||
- ``{"kind": "video"|"video_audio", "ref_audio_t": T,
|
||||
"latent_t": RT, "latent_h": RH, "latent_w": RW}``
|
||||
|
||||
Video-bearing blocks pack their audio rows immediately before their video
|
||||
rows; both share the same temporal origin and advance by the longer of the
|
||||
audio and video spans. Standalone audio advances the target origin by its
|
||||
own T, and image blocks advance it by one integer slot.
|
||||
"""
|
||||
if not isinstance(ref_blocks, Sequence) or isinstance(ref_blocks, (str, bytes)):
|
||||
raise ValueError("ref_blocks must be a sequence")
|
||||
|
||||
parsed: list[dict[str, object]] = []
|
||||
ref_visual_rows = 0
|
||||
ref_audio_rows = 0
|
||||
for index, raw in enumerate(ref_blocks):
|
||||
path = f"ref_blocks[{index}]"
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ValueError(f"{path} must be an object")
|
||||
kind = raw.get("kind", raw.get("type"))
|
||||
if not isinstance(kind, str) or not kind:
|
||||
raise ValueError(f"{path}.kind must be a non-empty string")
|
||||
if kind == "image":
|
||||
rh = _positive_int(raw, "latent_h", path)
|
||||
rw = _positive_int(raw, "latent_w", path)
|
||||
rows = (rh // _PATCH_H) * (rw // _PATCH_W)
|
||||
item = {"kind": kind, "latent_h": rh, "latent_w": rw, "rows": rows}
|
||||
ref_visual_rows += rows
|
||||
elif kind == "audio":
|
||||
rt = _positive_int(raw, "ref_audio_t", path, allow_zero=True)
|
||||
rows = rt * audio_channel
|
||||
item = {"kind": kind, "ref_audio_t": rt, "audio_rows": rows}
|
||||
ref_audio_rows += rows
|
||||
elif kind in ("video", "video_audio"):
|
||||
rt = _positive_int(raw, "ref_audio_t", path, allow_zero=True)
|
||||
vt = _positive_int(raw, "latent_t", path)
|
||||
vh = _positive_int(raw, "latent_h", path)
|
||||
vw = _positive_int(raw, "latent_w", path)
|
||||
frame_rows = (vh // _PATCH_H) * (vw // _PATCH_W)
|
||||
audio_rows = rt * audio_channel
|
||||
video_rows = vt * frame_rows
|
||||
item = {
|
||||
"kind": kind,
|
||||
"ref_audio_t": rt,
|
||||
"latent_t": vt,
|
||||
"latent_h": vh,
|
||||
"latent_w": vw,
|
||||
"frame_rows": frame_rows,
|
||||
"audio_rows": audio_rows,
|
||||
"video_rows": video_rows,
|
||||
}
|
||||
ref_audio_rows += audio_rows
|
||||
ref_visual_rows += video_rows
|
||||
else:
|
||||
raise ValueError(f"{path}.kind unsupported for ref2va: {kind!r}")
|
||||
parsed.append(item)
|
||||
|
||||
ph, pw = latent_h // _PATCH_H, latent_w // _PATCH_W
|
||||
frame_rows = ph * pw
|
||||
video_rows = latent_t * frame_rows
|
||||
audio_rows = audio_t * audio_channel
|
||||
ref_rows = ref_visual_rows + ref_audio_rows
|
||||
used = text_len + ref_rows + audio_rows + video_rows
|
||||
if seq_len is None:
|
||||
seq_len = ((used + _SEQ_ALIGN - 1) // _SEQ_ALIGN) * _SEQ_ALIGN
|
||||
if seq_len < used:
|
||||
raise ValueError(f"seq_len {seq_len} < used rows {used}")
|
||||
|
||||
text_sl = slice(0, text_len)
|
||||
cursor = text_len
|
||||
block_slices: list[dict[str, object]] = []
|
||||
for item in parsed:
|
||||
kind = str(item["kind"])
|
||||
if kind == "image":
|
||||
rows = int(item["rows"])
|
||||
visual_sl = slice(cursor, cursor + rows)
|
||||
cursor = visual_sl.stop
|
||||
block_slices.append({**item, "visual_sl": visual_sl})
|
||||
elif kind == "audio":
|
||||
rows = int(item["audio_rows"])
|
||||
audio_sl = slice(cursor, cursor + rows)
|
||||
cursor = audio_sl.stop
|
||||
block_slices.append({**item, "audio_sl": audio_sl})
|
||||
else:
|
||||
a_rows = int(item["audio_rows"])
|
||||
v_rows = int(item["video_rows"])
|
||||
audio_sl = slice(cursor, cursor + a_rows)
|
||||
visual_sl = slice(audio_sl.stop, audio_sl.stop + v_rows)
|
||||
cursor = visual_sl.stop
|
||||
block_slices.append({**item, "audio_sl": audio_sl, "visual_sl": visual_sl})
|
||||
|
||||
audio_sl = slice(cursor, cursor + audio_rows)
|
||||
video_sl = slice(audio_sl.stop, audio_sl.stop + video_rows)
|
||||
pad_sl = slice(video_sl.stop, seq_len)
|
||||
|
||||
input_ids = torch.full((seq_len,), MINIMAX_H3_PAD_ID, dtype=torch.int64)
|
||||
input_ids[text_sl] = MINIMAX_H3_TEXT_ID
|
||||
image_mask = torch.zeros(seq_len, dtype=torch.bool)
|
||||
audio_mask = torch.zeros(seq_len, dtype=torch.bool)
|
||||
|
||||
ref_img_pos_parts: list[torch.Tensor] = []
|
||||
ref_audio_pos_parts: list[torch.Tensor] = []
|
||||
g = torch.zeros(seq_len, 3, dtype=torch.float64)
|
||||
g[text_sl, 0] = torch.arange(text_len, dtype=torch.float64)
|
||||
|
||||
target_area = np.sqrt(latent_h * latent_w)
|
||||
h_grid = _axis_from_sqrt_area(latent_h, _PATCH_H, target_area)
|
||||
w_grid = _axis_from_sqrt_area(latent_w, _PATCH_W, target_area)
|
||||
hh, ww = torch.meshgrid(h_grid, w_grid, indexing="ij")
|
||||
target_frame = torch.stack([hh.reshape(-1), ww.reshape(-1)], dim=-1)
|
||||
|
||||
t_cursor = float(text_len)
|
||||
for item in block_slices:
|
||||
kind = str(item["kind"])
|
||||
if kind == "image":
|
||||
visual_sl = item["visual_sl"]
|
||||
assert isinstance(visual_sl, slice)
|
||||
input_ids[visual_sl] = MINIMAX_H3_IMGVID_COND_ID
|
||||
image_mask[visual_sl] = True
|
||||
ref_img_pos_parts.append(_range_for_slice(visual_sl))
|
||||
rh = int(item["latent_h"])
|
||||
rw = int(item["latent_w"])
|
||||
area = np.sqrt(rh * rw)
|
||||
ref_hh, ref_ww = torch.meshgrid(
|
||||
_axis_from_sqrt_area(rh, _PATCH_H, area),
|
||||
_axis_from_sqrt_area(rw, _PATCH_W, area),
|
||||
indexing="ij",
|
||||
)
|
||||
g[visual_sl, 0] = t_cursor
|
||||
g[visual_sl, 1] = ref_hh.reshape(-1)
|
||||
g[visual_sl, 2] = ref_ww.reshape(-1)
|
||||
t_cursor += 1.0
|
||||
elif kind == "audio":
|
||||
audio_ref_sl = item["audio_sl"]
|
||||
assert isinstance(audio_ref_sl, slice)
|
||||
ref_t = int(item["ref_audio_t"])
|
||||
input_ids[audio_ref_sl] = MINIMAX_H3_AUDIO_REF_COND_ID
|
||||
audio_mask[audio_ref_sl] = True
|
||||
ref_audio_pos_parts.append(_range_for_slice(audio_ref_sl))
|
||||
ref_t_grid = t_cursor + torch.arange(ref_t, dtype=torch.float64)
|
||||
g[audio_ref_sl, 0] = ref_t_grid.repeat(audio_channel)
|
||||
if ref_t:
|
||||
g[audio_ref_sl, 2] = torch.cat(
|
||||
[
|
||||
torch.full((ref_t,), float(w_grid[0]), dtype=torch.float64),
|
||||
torch.full(
|
||||
(int(item["audio_rows"]) - ref_t,),
|
||||
float(w_grid[-1]),
|
||||
dtype=torch.float64,
|
||||
),
|
||||
]
|
||||
)
|
||||
t_cursor += float(ref_t)
|
||||
else:
|
||||
audio_ref_sl = item["audio_sl"]
|
||||
visual_sl = item["visual_sl"]
|
||||
assert isinstance(audio_ref_sl, slice)
|
||||
assert isinstance(visual_sl, slice)
|
||||
ref_t = int(item["ref_audio_t"])
|
||||
vt = int(item["latent_t"])
|
||||
vh = int(item["latent_h"])
|
||||
vw = int(item["latent_w"])
|
||||
input_ids[audio_ref_sl] = MINIMAX_H3_AUDIO_REF_COND_ID
|
||||
input_ids[visual_sl] = MINIMAX_H3_IMGVID_COND_ID
|
||||
audio_mask[audio_ref_sl] = True
|
||||
image_mask[visual_sl] = True
|
||||
ref_audio_pos_parts.append(_range_for_slice(audio_ref_sl))
|
||||
ref_img_pos_parts.append(_range_for_slice(visual_sl))
|
||||
|
||||
ref_area = np.sqrt(vh * vw)
|
||||
rv_h_grid = _axis_from_sqrt_area(vh, _PATCH_H, ref_area)
|
||||
rv_w_grid = _axis_from_sqrt_area(vw, _PATCH_W, ref_area)
|
||||
rv_hh, rv_ww = torch.meshgrid(rv_h_grid, rv_w_grid, indexing="ij")
|
||||
|
||||
ref_t_grid = t_cursor + torch.arange(ref_t, dtype=torch.float64)
|
||||
g[audio_ref_sl, 0] = ref_t_grid.repeat(audio_channel)
|
||||
if ref_t:
|
||||
g[audio_ref_sl, 2] = torch.cat(
|
||||
[
|
||||
torch.full((ref_t,), float(rv_w_grid[0]), dtype=torch.float64),
|
||||
torch.full(
|
||||
(int(item["audio_rows"]) - ref_t,),
|
||||
float(rv_w_grid[-1]),
|
||||
dtype=torch.float64,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
rv_frame = torch.stack([rv_hh.reshape(-1), rv_ww.reshape(-1)], dim=-1)
|
||||
rv_g = torch.empty(vt, int(item["frame_rows"]), 3, dtype=torch.float64)
|
||||
rv_g[:, :, 0] = _video_t_grid(vt, t_cursor)[:, None]
|
||||
rv_g[:, :, 1:] = rv_frame[None]
|
||||
g[visual_sl] = rv_g.reshape(-1, 3)
|
||||
t_cursor += max(float(ref_t), _video_t_span(vt))
|
||||
|
||||
input_ids[audio_sl] = MINIMAX_H3_AUDIO_ID
|
||||
input_ids[audio_sl.start] = MINIMAX_H3_AUDIO_FIRST_ID
|
||||
input_ids[video_sl] = MINIMAX_H3_VIDEO_ID
|
||||
input_ids[video_sl.start] = MINIMAX_H3_VIDEO_FIRST_ID
|
||||
input_ids[video_sl.stop - 1] = MINIMAX_H3_VIDEO_LAST_ID
|
||||
audio_mask[audio_sl] = True
|
||||
image_mask[video_sl] = True
|
||||
|
||||
audio_t_grid = t_cursor + torch.arange(audio_t, dtype=torch.float64)
|
||||
g[audio_sl, 0] = audio_t_grid.repeat(audio_channel)
|
||||
g[audio_sl, 2] = torch.cat(
|
||||
[
|
||||
torch.full((audio_t,), float(w_grid[0]), dtype=torch.float64),
|
||||
torch.full((audio_rows - audio_t,), float(w_grid[-1]), dtype=torch.float64),
|
||||
]
|
||||
)
|
||||
|
||||
video_g = torch.empty(latent_t, frame_rows, 3, dtype=torch.float64)
|
||||
video_g[:, :, 0] = _video_t_grid(latent_t, t_cursor)[:, None]
|
||||
video_g[:, :, 1:] = target_frame[None]
|
||||
g[video_sl] = video_g.reshape(-1, 3)
|
||||
|
||||
target_img_pos = _range_for_slice(video_sl)
|
||||
target_audio_pos = _range_for_slice(audio_sl)
|
||||
img_pos = _cat_ranges(ref_img_pos_parts + [target_img_pos])
|
||||
audio_pos = _cat_ranges(ref_audio_pos_parts + [target_audio_pos])
|
||||
|
||||
update_mask = torch.zeros(img_pos.shape[0], dtype=torch.bool)
|
||||
update_mask[ref_visual_rows:] = True
|
||||
audio_update_mask = torch.zeros(audio_pos.shape[0], dtype=torch.bool)
|
||||
audio_update_mask[ref_audio_rows:] = True
|
||||
text_pos = torch.arange(0, text_len)
|
||||
|
||||
token_tags = torch.full((seq_len,), -1, dtype=torch.long) # PADDING
|
||||
token_tags[text_sl] = 1 # TEXT
|
||||
if ref_audio_rows:
|
||||
token_tags[_cat_ranges(ref_audio_pos_parts)] = 2 # AUDIO (reference)
|
||||
token_tags[audio_sl] = 2 # AUDIO (target)
|
||||
token_tags[img_pos] = 0 # VIDEO (refs + target)
|
||||
|
||||
cu = torch.tensor([0, used, seq_len], dtype=torch.int32)
|
||||
doc = torch.zeros(seq_len, dtype=torch.int32)
|
||||
doc[pad_sl] = 1
|
||||
return {
|
||||
"seq_len": torch.tensor(seq_len),
|
||||
"input_ids": input_ids,
|
||||
"image_mask": image_mask,
|
||||
"audio_mask": audio_mask,
|
||||
"img_pos": img_pos,
|
||||
"audio_pos": audio_pos,
|
||||
"text_pos": text_pos,
|
||||
"update_mask": update_mask,
|
||||
"audio_update_mask": audio_update_mask,
|
||||
"img_position_ids": g,
|
||||
"token_tags": token_tags,
|
||||
"cu_seqlens": cu,
|
||||
"document_id": doc,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_AUDIO_FIRST_ID",
|
||||
"MINIMAX_H3_AUDIO_ID",
|
||||
"MINIMAX_H3_AUDIO_REF_COND_ID",
|
||||
"MINIMAX_H3_IMGVID_COND_ID",
|
||||
"MINIMAX_H3_PAD_ID",
|
||||
"MINIMAX_H3_TEXT_ID",
|
||||
"MINIMAX_H3_VIDEO_FIRST_ID",
|
||||
"MINIMAX_H3_VIDEO_ID",
|
||||
"MINIMAX_H3_VIDEO_LAST_ID",
|
||||
"minimax_h3_packed_sequence",
|
||||
"minimax_h3_packed_sequence_ref2va_blocks",
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def _int_tuple(value: Sequence[int], name: str, length: int) -> tuple[int, ...]:
|
||||
if len(value) != length:
|
||||
raise ValueError(f"{name} must have length {length}, got {list(value)!r}")
|
||||
out = tuple(int(item) for item in value)
|
||||
if any(item <= 0 for item in out):
|
||||
raise ValueError(f"{name} values must be positive, got {list(value)!r}")
|
||||
return out
|
||||
|
||||
|
||||
def _rank(tensor: torch.Tensor, name: str, rank: int) -> None:
|
||||
if tensor.ndim != rank:
|
||||
raise ValueError(f"{name} must be rank {rank}, got shape={list(tensor.shape)}")
|
||||
|
||||
|
||||
def minimax_h3_patchify_video_latent(
|
||||
latent: torch.Tensor,
|
||||
*,
|
||||
patch_size: Sequence[int],
|
||||
) -> torch.Tensor:
|
||||
"""Pack video latent [B,C,T,H,W] into DiT token rows."""
|
||||
|
||||
_rank(latent, "video latent", 5)
|
||||
pt, ph, pw = _int_tuple(patch_size, "patch_size", 3)
|
||||
batch, channel, full_t, full_h, full_w = (int(dim) for dim in latent.shape)
|
||||
if full_t % pt or full_h % ph or full_w % pw:
|
||||
raise ValueError(
|
||||
"video latent spatial/time dims must be divisible by patch_size: "
|
||||
f"shape={list(latent.shape)}, patch_size={[pt, ph, pw]}"
|
||||
)
|
||||
t, h, w = full_t // pt, full_h // ph, full_w // pw
|
||||
packed = latent.reshape(batch, channel, t, pt, h, ph, w, pw)
|
||||
packed = torch.einsum("nctrhpwq->nthwcrpq", packed)
|
||||
return packed.reshape(batch * t * h * w, channel * pt * ph * pw).contiguous()
|
||||
|
||||
|
||||
def minimax_h3_unpatchify_video_tokens(
|
||||
rows: torch.Tensor,
|
||||
*,
|
||||
latent_shape: Sequence[int],
|
||||
patch_size: Sequence[int],
|
||||
) -> torch.Tensor:
|
||||
"""Unpack DiT video token rows into latent [B,C,T,H,W]."""
|
||||
|
||||
_rank(rows, "video token rows", 2)
|
||||
t, h, w, channel = _int_tuple(latent_shape, "latent_shape", 4)
|
||||
pt, ph, pw = _int_tuple(patch_size, "patch_size", 3)
|
||||
expected_dim = pt * ph * pw * channel
|
||||
if int(rows.shape[-1]) != expected_dim:
|
||||
raise ValueError(
|
||||
f"video token dim {int(rows.shape[-1])} != patch volume * channel "
|
||||
f"{expected_dim} for latent_shape={list(latent_shape)}, "
|
||||
f"patch_size={[pt, ph, pw]}"
|
||||
)
|
||||
rows_per_sample = t * h * w
|
||||
if int(rows.shape[0]) % rows_per_sample:
|
||||
raise ValueError(
|
||||
f"video rows {int(rows.shape[0])} must be divisible by t*h*w "
|
||||
f"{rows_per_sample} for latent_shape={list(latent_shape)}"
|
||||
)
|
||||
packed = rows.reshape(-1, t, h, w, channel, pt, ph, pw)
|
||||
latent = torch.einsum("nthwcrpq->nctrhpwq", packed)
|
||||
return latent.reshape(-1, channel, t * pt, h * ph, w * pw).contiguous()
|
||||
|
||||
|
||||
def minimax_h3_pack_audio_latent(latent: torch.Tensor) -> torch.Tensor:
|
||||
"""Pack audio VAE latent [audio_channel, latent_dim, T] into rows."""
|
||||
|
||||
_rank(latent, "audio latent", 3)
|
||||
audio_channel, latent_dim, steps = (int(dim) for dim in latent.shape)
|
||||
return (
|
||||
latent.permute(0, 2, 1)
|
||||
.reshape(
|
||||
audio_channel * steps,
|
||||
latent_dim,
|
||||
)
|
||||
.contiguous()
|
||||
)
|
||||
|
||||
|
||||
def minimax_h3_unpack_audio_tokens(
|
||||
rows: torch.Tensor,
|
||||
*,
|
||||
audio_t: int,
|
||||
audio_channel: int,
|
||||
) -> torch.Tensor:
|
||||
"""Unpack DiT audio token rows into audio VAE latent [C,latent_dim,T]."""
|
||||
|
||||
_rank(rows, "audio token rows", 2)
|
||||
audio_t = int(audio_t)
|
||||
audio_channel = int(audio_channel)
|
||||
if audio_t <= 0 or audio_channel <= 0:
|
||||
raise ValueError(f"audio_t and audio_channel must be positive, got {audio_t=} {audio_channel=}")
|
||||
if int(rows.shape[0]) != audio_t:
|
||||
raise ValueError(f"audio rows {int(rows.shape[0])} != audio_t {audio_t}")
|
||||
if audio_t % audio_channel:
|
||||
raise ValueError(f"audio_t must be divisible by audio_channel, got {audio_t=} {audio_channel=}")
|
||||
native = rows.reshape(audio_channel, audio_t // audio_channel, int(rows.shape[-1]))
|
||||
return native.permute(0, 2, 1).contiguous()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_pack_audio_latent",
|
||||
"minimax_h3_patchify_video_latent",
|
||||
"minimax_h3_unpack_audio_tokens",
|
||||
"minimax_h3_unpatchify_video_tokens",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,297 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 Qwen presentation building.
|
||||
|
||||
Builds the positive presentation token stream:
|
||||
- fl2va: '<Picture 1>: ' label + vision block (<|vision_start|> +
|
||||
N*<|image_pad|> + <|vision_end|>) + prompt text.
|
||||
- t2va: prompt text only (no vision block).
|
||||
Prompt text passes through verbatim (no stripping or rewriting).
|
||||
|
||||
All presentation variants are emitted through the shared ``_Presentation``
|
||||
accumulator so ids and AdaLN token tags cannot drift apart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
VISION_START = "<|vision_start|>"
|
||||
VISION_END = "<|vision_end|>"
|
||||
IMAGE_PAD = "<|image_pad|>"
|
||||
VIDEO_PAD = "<|video_pad|>"
|
||||
|
||||
_TEXT_TAG = 1
|
||||
_VIDEO_TAG = 0
|
||||
|
||||
|
||||
def _text_ids(tokenizer: Any, text: str) -> list[int]:
|
||||
return list(tokenizer(text, add_special_tokens=False)["input_ids"])
|
||||
|
||||
|
||||
def _vision_block_ids(tokenizer: Any, pad_token: str, count: int) -> list[int]:
|
||||
return (
|
||||
[tokenizer.convert_tokens_to_ids(VISION_START)]
|
||||
+ [tokenizer.convert_tokens_to_ids(pad_token)] * int(count)
|
||||
+ [tokenizer.convert_tokens_to_ids(VISION_END)]
|
||||
)
|
||||
|
||||
|
||||
class _Presentation:
|
||||
"""Accumulates aligned (ids, token_tags) presentation segments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.ids: list[int] = []
|
||||
self.tags: list[int] = []
|
||||
|
||||
def text(self, token_ids: list[int]) -> None:
|
||||
self.ids += token_ids
|
||||
self.tags += [_TEXT_TAG] * len(token_ids)
|
||||
|
||||
def vision(self, token_ids: list[int]) -> None:
|
||||
self.ids += token_ids
|
||||
self.tags += [_VIDEO_TAG] * len(token_ids)
|
||||
|
||||
def build(self) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
return (
|
||||
torch.tensor(self.ids, dtype=torch.long),
|
||||
torch.tensor(self.tags, dtype=torch.long),
|
||||
)
|
||||
|
||||
|
||||
def _timestamped_video_blocks(
|
||||
presentation: _Presentation,
|
||||
tokenizer: Any,
|
||||
*,
|
||||
counts: Sequence[int],
|
||||
timestamps: Sequence[float],
|
||||
context: str,
|
||||
) -> None:
|
||||
"""Emit per-temporal-block ``<{t:.1f} seconds>`` text + VIDEO vision."""
|
||||
|
||||
counts = [int(value) for value in counts]
|
||||
timestamps = [float(value) for value in timestamps]
|
||||
if not counts or len(counts) != len(timestamps):
|
||||
raise ValueError(f"{context}video block token counts and timestamps must align")
|
||||
for count, timestamp in zip(counts, timestamps):
|
||||
if count <= 0:
|
||||
raise ValueError(f"{context}video block token count must be positive")
|
||||
presentation.text(_text_ids(tokenizer, f"<{timestamp:.1f} seconds>"))
|
||||
presentation.vision(_vision_block_ids(tokenizer, VIDEO_PAD, count))
|
||||
|
||||
|
||||
def minimax_h3_text_only_ids(tokenizer: Any, prompt: str) -> torch.Tensor:
|
||||
"""t2va presentation: verbatim prompt, no special tokens."""
|
||||
if not prompt:
|
||||
raise ValueError("prompt must be non-empty")
|
||||
return torch.tensor(_text_ids(tokenizer, prompt), dtype=torch.long)
|
||||
|
||||
|
||||
def _multi_image_presentation(
|
||||
tokenizer: Any,
|
||||
*,
|
||||
prompt: str,
|
||||
image_token_counts: list[int],
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
if not image_token_counts:
|
||||
raise ValueError("image_token_counts must be non-empty")
|
||||
presentation = _Presentation()
|
||||
for index, count in enumerate(image_token_counts, start=1):
|
||||
if int(count) <= 0:
|
||||
raise ValueError("image_token_count must be positive")
|
||||
presentation.text(_text_ids(tokenizer, f"<Picture {index}>: "))
|
||||
presentation.vision(_vision_block_ids(tokenizer, IMAGE_PAD, count))
|
||||
presentation.text(_text_ids(tokenizer, prompt))
|
||||
return presentation.build()
|
||||
|
||||
|
||||
def minimax_h3_multi_image_presentation_ids(
|
||||
tokenizer: Any,
|
||||
*,
|
||||
prompt: str,
|
||||
image_token_counts: list[int],
|
||||
) -> torch.Tensor:
|
||||
"""Positive fl2va presentation with one or more labeled image slots."""
|
||||
ids, _ = _multi_image_presentation(tokenizer, prompt=prompt, image_token_counts=image_token_counts)
|
||||
return ids
|
||||
|
||||
|
||||
def minimax_h3_multi_image_presentation_token_tags(
|
||||
tokenizer: Any,
|
||||
*,
|
||||
prompt: str,
|
||||
image_token_counts: list[int],
|
||||
) -> torch.Tensor:
|
||||
"""AdaLN token tags aligned with minimax_h3_multi_image_presentation_ids."""
|
||||
_, tags = _multi_image_presentation(tokenizer, prompt=prompt, image_token_counts=image_token_counts)
|
||||
return tags
|
||||
|
||||
|
||||
def minimax_h3_ref2va_presentation(
|
||||
tokenizer: Any,
|
||||
*,
|
||||
prompt: str,
|
||||
condition_labels: list[tuple[str, int]],
|
||||
image_token_count: int | list[int] | None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""ref2va positive presentation:
|
||||
|
||||
per condition in request order — image i: ``<Picture i>: `` label followed
|
||||
by the vision block; audio j: ``<Audio j>: `` label only (audio content
|
||||
never enters Qwen) — then the verbatim prompt. Returns (ids, token_tags)
|
||||
with the vision block tagged VIDEO(0) and everything else TEXT(1).
|
||||
|
||||
condition_labels: [("image", 1), ("audio", 1), ...] with 1-based ordinals
|
||||
per type.
|
||||
"""
|
||||
return minimax_h3_ref2va_video_presentation(
|
||||
tokenizer,
|
||||
prompt=prompt,
|
||||
condition_labels=condition_labels,
|
||||
image_token_count=image_token_count,
|
||||
video_block_token_counts=None,
|
||||
video_block_timestamps=None,
|
||||
)
|
||||
|
||||
|
||||
def _as_int_list(value: int | Sequence[int] | None, *, name: str) -> list[int]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, int):
|
||||
return [int(value)]
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be an int or a sequence of ints")
|
||||
return [int(item) for item in value]
|
||||
|
||||
|
||||
def _as_nested_int_list(
|
||||
value: Sequence[int] | Sequence[Sequence[int]] | None,
|
||||
*,
|
||||
name: str,
|
||||
) -> list[list[int]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be a sequence")
|
||||
if len(value) == 0:
|
||||
return []
|
||||
first = value[0]
|
||||
if isinstance(first, Sequence) and not isinstance(first, (str, bytes)):
|
||||
out: list[list[int]] = []
|
||||
for group in value:
|
||||
if not isinstance(group, Sequence) or isinstance(group, (str, bytes)):
|
||||
raise ValueError(f"{name} must not mix nested and flat entries")
|
||||
out.append([int(item) for item in group])
|
||||
return out
|
||||
return [[int(item) for item in value]]
|
||||
|
||||
|
||||
def _as_nested_float_list(
|
||||
value: Sequence[float] | Sequence[Sequence[float]] | None,
|
||||
*,
|
||||
name: str,
|
||||
) -> list[list[float]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be a sequence")
|
||||
if len(value) == 0:
|
||||
return []
|
||||
first = value[0]
|
||||
if isinstance(first, Sequence) and not isinstance(first, (str, bytes)):
|
||||
out: list[list[float]] = []
|
||||
for group in value:
|
||||
if not isinstance(group, Sequence) or isinstance(group, (str, bytes)):
|
||||
raise ValueError(f"{name} must not mix nested and flat entries")
|
||||
out.append([float(item) for item in group])
|
||||
return out
|
||||
return [[float(item) for item in value]]
|
||||
|
||||
|
||||
def minimax_h3_ref2va_video_presentation(
|
||||
tokenizer: Any,
|
||||
*,
|
||||
prompt: str,
|
||||
condition_labels: list[tuple[str, int]],
|
||||
image_token_count: int | list[int] | None,
|
||||
video_block_token_counts: list[int] | list[list[int]] | None,
|
||||
video_block_timestamps: list[float] | list[list[float]] | None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""ref2va (optionally with video refs) positive presentation:
|
||||
|
||||
per condition in request order —
|
||||
- image i: ``<Picture i>: `` label + one image vision block;
|
||||
- audio j: ``<Audio j>: `` label only (audio content never enters Qwen);
|
||||
- video k: ``<Video k>: `` label, then per temporal block a timestamp
|
||||
text ``<{t:.1f} seconds>`` followed by a VIDEO vision block
|
||||
(<|vision_start|> + <|video_pad|> x n + <|vision_end|>). Timestamps are
|
||||
the mean of each merged frame pair (Qwen3VL temporal merge 2; odd frame
|
||||
counts repeat the last frame), emitting the
|
||||
``<0.2 seconds>`` ..
|
||||
``<4.0 seconds>`` sequence — note Python bankers-rounding at .1f.
|
||||
then the verbatim prompt. Vision blocks are tagged VIDEO(0), everything
|
||||
else TEXT(1).
|
||||
"""
|
||||
if not prompt:
|
||||
raise ValueError("prompt must be non-empty")
|
||||
presentation = _Presentation()
|
||||
image_token_counts = _as_int_list(image_token_count, name="image_token_count")
|
||||
video_counts_by_ref = _as_nested_int_list(
|
||||
video_block_token_counts,
|
||||
name="video_block_token_counts",
|
||||
)
|
||||
video_timestamps_by_ref = _as_nested_float_list(
|
||||
video_block_timestamps,
|
||||
name="video_block_timestamps",
|
||||
)
|
||||
if len(video_counts_by_ref) != len(video_timestamps_by_ref):
|
||||
raise ValueError("video block token counts and timestamps must align")
|
||||
image_seen = 0
|
||||
video_seen = 0
|
||||
for cond_type, ordinal in condition_labels:
|
||||
if cond_type == "image":
|
||||
image_seen += 1
|
||||
if image_seen > len(image_token_counts):
|
||||
raise ValueError("image_token_count required for an image reference")
|
||||
count = int(image_token_counts[image_seen - 1])
|
||||
if count <= 0:
|
||||
raise ValueError("image_token_count required for an image reference")
|
||||
presentation.text(_text_ids(tokenizer, f"<Picture {ordinal}>: "))
|
||||
presentation.vision(_vision_block_ids(tokenizer, IMAGE_PAD, count))
|
||||
elif cond_type == "audio":
|
||||
presentation.text(_text_ids(tokenizer, f"<Audio {ordinal}>: "))
|
||||
elif cond_type == "video":
|
||||
video_seen += 1
|
||||
if video_seen > len(video_counts_by_ref):
|
||||
raise ValueError("video reference requires block token counts and timestamps")
|
||||
counts = video_counts_by_ref[video_seen - 1]
|
||||
timestamps = video_timestamps_by_ref[video_seen - 1]
|
||||
if not counts or not timestamps:
|
||||
raise ValueError("video reference requires block token counts and timestamps")
|
||||
presentation.text(_text_ids(tokenizer, f"<Video {ordinal}>: "))
|
||||
_timestamped_video_blocks(
|
||||
presentation,
|
||||
tokenizer,
|
||||
counts=counts,
|
||||
timestamps=timestamps,
|
||||
context="",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported ref2va condition type {cond_type!r}")
|
||||
if image_seen != len(image_token_counts):
|
||||
raise ValueError("unused image_token_count entries")
|
||||
if video_seen != len(video_counts_by_ref):
|
||||
raise ValueError("unused video block token count entries")
|
||||
presentation.text(_text_ids(tokenizer, prompt))
|
||||
return presentation.build()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_multi_image_presentation_ids",
|
||||
"minimax_h3_multi_image_presentation_token_tags",
|
||||
"minimax_h3_ref2va_presentation",
|
||||
"minimax_h3_ref2va_video_presentation",
|
||||
"minimax_h3_text_only_ids",
|
||||
]
|
||||
@@ -0,0 +1,345 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 Ref2VA reference-video preparation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
MINIMAX_H3_FPS = 24.0
|
||||
MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS = 2.0
|
||||
MINIMAX_H3_QWEN_TEMPORAL_PATCH = 2
|
||||
MINIMAX_H3_BASE_SHORT_EDGE = 768
|
||||
MINIMAX_H3_MAX_PIXELS = 768 * 1344
|
||||
MINIMAX_H3_CANVAS_MULTIPLE = 32
|
||||
|
||||
|
||||
def _nearest_multiple(value: float, multiple: int) -> int:
|
||||
return max(multiple, int(round(float(value) / multiple)) * multiple)
|
||||
|
||||
|
||||
def _probe_video(path: str) -> dict[str, Any]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-count_frames",
|
||||
"-show_entries",
|
||||
("stream=width,height,r_frame_rate,nb_read_frames,nb_frames,sample_aspect_ratio:stream_tags=rotate"),
|
||||
"-of",
|
||||
"json",
|
||||
path,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
streams = json.loads(result.stdout).get("streams") or []
|
||||
if not streams:
|
||||
raise ValueError(f"media has no video stream: {path}")
|
||||
stream = streams[0]
|
||||
numerator, denominator = str(stream["r_frame_rate"]).split("/")
|
||||
raw_count = stream.get("nb_read_frames") or stream.get("nb_frames")
|
||||
if raw_count in (None, "", "N/A"):
|
||||
raise ValueError(f"cannot determine video frame count: {path}")
|
||||
return {
|
||||
"width": int(stream["width"]),
|
||||
"height": int(stream["height"]),
|
||||
"fps": float(numerator) / float(denominator),
|
||||
"frame_count": int(raw_count),
|
||||
}
|
||||
|
||||
|
||||
def _has_audio(path: str) -> bool:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=index",
|
||||
"-of",
|
||||
"json",
|
||||
path,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(json.loads(result.stdout).get("streams") or [])
|
||||
|
||||
|
||||
def _reference_video_shape(width: int, height: int) -> tuple[int, int]:
|
||||
ratio = float(width) / float(height)
|
||||
if not 0.25 <= ratio <= 4.0:
|
||||
raise ValueError(f"reference video aspect ratio must be in [1:4, 4:1], got {width}x{height}")
|
||||
if ratio >= 1.0:
|
||||
target_width = MINIMAX_H3_BASE_SHORT_EDGE * ratio
|
||||
target_height = float(MINIMAX_H3_BASE_SHORT_EDGE)
|
||||
else:
|
||||
target_width = float(MINIMAX_H3_BASE_SHORT_EDGE)
|
||||
target_height = MINIMAX_H3_BASE_SHORT_EDGE / ratio
|
||||
area = target_width * target_height
|
||||
if area > MINIMAX_H3_MAX_PIXELS:
|
||||
scale = math.sqrt(MINIMAX_H3_MAX_PIXELS / area)
|
||||
target_width *= scale
|
||||
target_height *= scale
|
||||
return (
|
||||
_nearest_multiple(target_width, MINIMAX_H3_CANVAS_MULTIPLE),
|
||||
_nearest_multiple(target_height, MINIMAX_H3_CANVAS_MULTIPLE),
|
||||
)
|
||||
|
||||
|
||||
def _transcode_reference_video(
|
||||
source: str,
|
||||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_frame_count: int,
|
||||
workdir: str,
|
||||
) -> str:
|
||||
output = str(Path(workdir) / "prepared.mp4")
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
source,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-an",
|
||||
"-vf",
|
||||
(f"fps={MINIMAX_H3_FPS:g},scale={target_width}:{target_height}:flags=lanczos,setsar=1"),
|
||||
"-frames:v",
|
||||
str(target_frame_count),
|
||||
"-metadata:s:v:0",
|
||||
"rotate=0",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def prepare_reference_videos(
|
||||
values: Any,
|
||||
*,
|
||||
target_frame_count: int,
|
||||
workdir: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
if isinstance(values, (str, os.PathLike)):
|
||||
values = [values]
|
||||
if not isinstance(values, (list, tuple)) or not values:
|
||||
raise ValueError("MiniMax H3 Ref2VA video input must be a path or a non-empty list of paths")
|
||||
prepared: list[dict[str, Any]] = []
|
||||
for index, value in enumerate(values):
|
||||
if not isinstance(value, (str, os.PathLike)):
|
||||
raise TypeError(
|
||||
f"MiniMax H3 multi-video Ref2VA currently requires file paths, got item {index}: {type(value)!r}"
|
||||
)
|
||||
source = str(value)
|
||||
meta = _probe_video(source)
|
||||
width, height = _reference_video_shape(meta["width"], meta["height"])
|
||||
item_workdir = Path(workdir) / f"video_{index}"
|
||||
item_workdir.mkdir(parents=True)
|
||||
prepared_path = _transcode_reference_video(
|
||||
source,
|
||||
target_width=width,
|
||||
target_height=height,
|
||||
target_frame_count=target_frame_count,
|
||||
workdir=str(item_workdir),
|
||||
)
|
||||
prepared.append(
|
||||
{
|
||||
"original_path": source,
|
||||
"prepared_path": prepared_path,
|
||||
"input_has_audio": _has_audio(source),
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
)
|
||||
return prepared
|
||||
|
||||
|
||||
def load_video_frames(path: str) -> np.ndarray:
|
||||
try:
|
||||
import decord
|
||||
except ImportError:
|
||||
import av
|
||||
|
||||
with av.open(path) as container:
|
||||
frames = [frame.to_ndarray(format="rgb24") for frame in container.decode(video=0)]
|
||||
if not frames:
|
||||
raise ValueError(f"video has no frames: {path}")
|
||||
return np.stack(frames)
|
||||
|
||||
reader = decord.VideoReader(path)
|
||||
if len(reader) <= 0:
|
||||
raise ValueError(f"video has no frames: {path}")
|
||||
frames = reader.get_batch(list(range(len(reader))))
|
||||
return frames.asnumpy() if hasattr(frames, "asnumpy") else np.asarray(frames)
|
||||
|
||||
|
||||
def sample_reference_video_frames(
|
||||
prepared_path: str,
|
||||
*,
|
||||
workdir: str,
|
||||
) -> dict[str, Any]:
|
||||
from PIL import Image
|
||||
|
||||
meta = _probe_video(prepared_path)
|
||||
ratio = MINIMAX_H3_FPS / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS
|
||||
indices: list[int] = []
|
||||
cursor = 0.0
|
||||
while True:
|
||||
frame_index = int(round(cursor))
|
||||
if frame_index >= meta["frame_count"]:
|
||||
break
|
||||
if not indices or frame_index > indices[-1]:
|
||||
indices.append(frame_index)
|
||||
cursor += ratio
|
||||
if not indices:
|
||||
raise ValueError(f"no frames sampled from {prepared_path}")
|
||||
|
||||
frame_dir = Path(workdir)
|
||||
frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
frames = []
|
||||
for output_index, source_index in enumerate(indices, start=1):
|
||||
output = frame_dir / f"frame_{output_index:06d}.png"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
prepared_path,
|
||||
"-vf",
|
||||
f"select=eq(n\\,{source_index})",
|
||||
"-vsync",
|
||||
"vfr",
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(output),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
frames.append(np.asarray(Image.open(output).convert("RGB")))
|
||||
|
||||
timestamps = [index / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS for index in range(len(indices))]
|
||||
timestamps += [timestamps[-1]] * ((-len(timestamps)) % MINIMAX_H3_QWEN_TEMPORAL_PATCH)
|
||||
block_timestamps = [
|
||||
(timestamps[index] + timestamps[index + MINIMAX_H3_QWEN_TEMPORAL_PATCH - 1]) / 2
|
||||
for index in range(
|
||||
0,
|
||||
len(timestamps),
|
||||
MINIMAX_H3_QWEN_TEMPORAL_PATCH,
|
||||
)
|
||||
]
|
||||
return {
|
||||
"frames": frames,
|
||||
"block_timestamps": block_timestamps,
|
||||
}
|
||||
|
||||
|
||||
def _soundfile_to_waveform(path: str) -> tuple[torch.Tensor, int]:
|
||||
import soundfile as sf
|
||||
|
||||
data, sample_rate = sf.read(path, dtype="float32", always_2d=True)
|
||||
# soundfile is (samples, channels); torchaudio.load is (channels, samples).
|
||||
waveform = torch.from_numpy(data).t().contiguous()
|
||||
return waveform, int(sample_rate)
|
||||
|
||||
|
||||
def load_audio_file(path: str) -> tuple[torch.Tensor, int]:
|
||||
"""Load an audio file as ``(waveform[C, T] float32, sample_rate)``.
|
||||
|
||||
torchaudio 2.6+ routes ``load`` through TorchCodec, whose wheels do not load
|
||||
on aarch64 with a CPU-only torch (they link CUDA libraries that are absent).
|
||||
Fall back to soundfile; if the container is not libsndfile-readable (e.g.
|
||||
mp3/m4a/mp4), demux to wav with ffmpeg first so any ffmpeg-supported input
|
||||
works at its native sample rate.
|
||||
"""
|
||||
try:
|
||||
import torchaudio
|
||||
|
||||
return torchaudio.load(path)
|
||||
except (ImportError, RuntimeError, OSError):
|
||||
pass
|
||||
try:
|
||||
return _soundfile_to_waveform(path)
|
||||
except Exception:
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="minimax_h3_audio_") as tmpdir:
|
||||
wav = str(Path(tmpdir) / "audio.wav")
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
path,
|
||||
"-vn",
|
||||
"-f",
|
||||
"wav",
|
||||
wav,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return _soundfile_to_waveform(wav)
|
||||
|
||||
|
||||
def load_video_audio(path: str) -> tuple[torch.Tensor, int]:
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="minimax_h3_video_audio_") as tmpdir:
|
||||
output = str(Path(tmpdir) / "audio.wav")
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
path,
|
||||
"-vn",
|
||||
"-ac",
|
||||
"2",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-f",
|
||||
"wav",
|
||||
output,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return load_audio_file(output)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"load_audio_file",
|
||||
"load_video_audio",
|
||||
"load_video_frames",
|
||||
"prepare_reference_videos",
|
||||
"sample_reference_video_frames",
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def _require_finite_tensor(tensor: torch.Tensor, name: str) -> None:
|
||||
if not bool(torch.isfinite(tensor).all().item()):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
|
||||
|
||||
def _validate_unit_timestep(timestep: torch.Tensor, name: str) -> None:
|
||||
if not isinstance(timestep, torch.Tensor):
|
||||
raise ValueError(f"{name} must be a torch.Tensor")
|
||||
if not torch.is_floating_point(timestep):
|
||||
raise ValueError(f"{name} must be a floating point tensor")
|
||||
_require_finite_tensor(timestep, name)
|
||||
out_of_range = (timestep < 0) | (timestep > 1)
|
||||
if bool(out_of_range.any().item()):
|
||||
raise ValueError(f"{name} must be in [0, 1]")
|
||||
|
||||
|
||||
def _validate_sigma(value: float, name: str) -> float:
|
||||
sigma = float(value)
|
||||
if not math.isfinite(sigma):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
if sigma < 0.0:
|
||||
raise ValueError(f"{name} must be non-negative")
|
||||
return sigma
|
||||
|
||||
|
||||
def _validate_timestep_sigma_pair(
|
||||
timestep: torch.Tensor,
|
||||
sigma_curr: float,
|
||||
name: str,
|
||||
) -> float:
|
||||
_validate_unit_timestep(timestep, f"{name}_timestep")
|
||||
sigma = _validate_sigma(sigma_curr, f"{name}_sigma_curr")
|
||||
expected = 1.0 - timestep.detach().to(dtype=torch.float32)
|
||||
actual = torch.full_like(expected, sigma)
|
||||
if not torch.allclose(actual, expected, rtol=1e-5, atol=1e-5):
|
||||
raise ValueError(f"{name}_sigma_curr must equal 1 - {name}_timestep")
|
||||
return sigma
|
||||
|
||||
|
||||
def minimax_h3_rf_v_to_x0(
|
||||
xt: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
if xt.shape != v.shape:
|
||||
raise ValueError(f"xt and v shapes must match, got {xt.shape} vs {v.shape}")
|
||||
if not torch.is_floating_point(xt):
|
||||
raise ValueError("xt must be a floating point tensor")
|
||||
if not torch.is_floating_point(v):
|
||||
raise ValueError("v must be a floating point tensor")
|
||||
_require_finite_tensor(xt, "xt")
|
||||
_require_finite_tensor(v, "v")
|
||||
_validate_unit_timestep(timestep, "timestep")
|
||||
cond_t = timestep.to(device=xt.device, dtype=xt.dtype)
|
||||
while cond_t.ndim < xt.ndim:
|
||||
cond_t = cond_t.unsqueeze(-1)
|
||||
sigma_t = 1 - cond_t
|
||||
x0 = xt + sigma_t * v
|
||||
_require_finite_tensor(x0, "x0")
|
||||
return x0
|
||||
|
||||
|
||||
def minimax_h3_euler_eta0_step(
|
||||
state: torch.Tensor,
|
||||
denoised: torch.Tensor,
|
||||
*,
|
||||
sigma_curr: float,
|
||||
sigma_next: float,
|
||||
) -> torch.Tensor:
|
||||
if state.shape != denoised.shape:
|
||||
raise ValueError(f"state and denoised shapes must match, got {state.shape} vs {denoised.shape}")
|
||||
if not torch.is_floating_point(state):
|
||||
raise ValueError("state must be a floating point tensor")
|
||||
if not torch.is_floating_point(denoised):
|
||||
raise ValueError("denoised must be a floating point tensor")
|
||||
_require_finite_tensor(state, "state")
|
||||
_require_finite_tensor(denoised, "denoised")
|
||||
sigma_curr = _validate_sigma(sigma_curr, "sigma_curr")
|
||||
sigma_next = _validate_sigma(sigma_next, "sigma_next")
|
||||
if sigma_curr == 0.0:
|
||||
if sigma_next != 0.0:
|
||||
raise ValueError("sigma_next must be 0 when sigma_curr is 0")
|
||||
return state
|
||||
compute_dtype = torch.float32
|
||||
if state.dtype not in (torch.float16, torch.bfloat16):
|
||||
compute_dtype = state.dtype
|
||||
sigma_curr_t = state.new_tensor(sigma_curr, dtype=compute_dtype)
|
||||
sigma_next_t = state.new_tensor(sigma_next, dtype=compute_dtype)
|
||||
sigma_ratio = sigma_next_t / sigma_curr_t
|
||||
out = sigma_ratio * state.to(dtype=compute_dtype) + (1.0 - sigma_ratio) * denoised.to(dtype=compute_dtype)
|
||||
out = out.to(dtype=state.dtype)
|
||||
_require_finite_tensor(out, "euler_eta0_step output")
|
||||
return out
|
||||
|
||||
|
||||
class MiniMaxH3EulerAncestralEta0SchedulerAdapter:
|
||||
def __init__(self, **config: Any) -> None:
|
||||
if config:
|
||||
raise ValueError(f"{type(self).__name__} does not accept config fields: {sorted(config)}")
|
||||
|
||||
def set_shift(self, _flow_shift: float) -> None:
|
||||
"""Ignore flow shift, matching the previous loader-specific path."""
|
||||
|
||||
def step_denoising(
|
||||
self,
|
||||
*,
|
||||
input_visual_latent: torch.Tensor,
|
||||
input_audio_latent: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
noise_pred_visual: torch.Tensor,
|
||||
noise_pred_audio: torch.Tensor,
|
||||
sigma_curr: float,
|
||||
sigma_next: float,
|
||||
video_timestep: torch.Tensor | None = None,
|
||||
audio_timestep: torch.Tensor | None = None,
|
||||
video_sigma_curr: float | None = None,
|
||||
video_sigma_next: float | None = None,
|
||||
audio_sigma_curr: float | None = None,
|
||||
audio_sigma_next: float | None = None,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
visual_timestep = timestep if video_timestep is None else video_timestep
|
||||
audio_timestep = timestep if audio_timestep is None else audio_timestep
|
||||
visual_sigma_curr = sigma_curr if video_sigma_curr is None else video_sigma_curr
|
||||
visual_sigma_next = sigma_next if video_sigma_next is None else video_sigma_next
|
||||
audio_sigma_curr = sigma_curr if audio_sigma_curr is None else audio_sigma_curr
|
||||
audio_sigma_next = sigma_next if audio_sigma_next is None else audio_sigma_next
|
||||
visual_sigma_curr = _validate_timestep_sigma_pair(
|
||||
visual_timestep,
|
||||
visual_sigma_curr,
|
||||
"video",
|
||||
)
|
||||
audio_sigma_curr = _validate_timestep_sigma_pair(
|
||||
audio_timestep,
|
||||
audio_sigma_curr,
|
||||
"audio",
|
||||
)
|
||||
|
||||
denoised_visual = minimax_h3_rf_v_to_x0(
|
||||
input_visual_latent,
|
||||
noise_pred_visual,
|
||||
visual_timestep,
|
||||
)
|
||||
denoised_audio = minimax_h3_rf_v_to_x0(
|
||||
input_audio_latent,
|
||||
noise_pred_audio,
|
||||
audio_timestep,
|
||||
)
|
||||
return {
|
||||
"output_visual_latent": minimax_h3_euler_eta0_step(
|
||||
input_visual_latent,
|
||||
denoised_visual,
|
||||
sigma_curr=visual_sigma_curr,
|
||||
sigma_next=visual_sigma_next,
|
||||
),
|
||||
"output_audio_latent": minimax_h3_euler_eta0_step(
|
||||
input_audio_latent,
|
||||
denoised_audio,
|
||||
sigma_curr=audio_sigma_curr,
|
||||
sigma_next=audio_sigma_next,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
EntryClass = MiniMaxH3EulerAncestralEta0SchedulerAdapter
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3EulerAncestralEta0SchedulerAdapter",
|
||||
"minimax_h3_euler_eta0_step",
|
||||
"minimax_h3_rf_v_to_x0",
|
||||
]
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _align_frame_count(frame_count: int) -> int:
|
||||
"""Snap ``frame_count`` up to the MiniMax H3 17n+5 frame boundary."""
|
||||
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 _frame_count_from_video_latent_t(out_t: int) -> int:
|
||||
if out_t == 1:
|
||||
return 1
|
||||
if out_t < 2 or (out_t - 2) % 5 != 0:
|
||||
raise ValueError("MiniMax H3 video latent T must be 1 or match 5n+2")
|
||||
return 17 * ((int(out_t) - 2) // 5) + 5
|
||||
|
||||
|
||||
def _audio_latent_t(duration_seconds: float) -> int:
|
||||
# Rounding happens at the 40 Hz audio latent boundary.
|
||||
return int(round(float(duration_seconds) * 40.0))
|
||||
|
||||
|
||||
def _time_shift_sigmas(
|
||||
*,
|
||||
num_steps: int = 50,
|
||||
shift_scale: float = 6.0,
|
||||
) -> list[float]:
|
||||
if shift_scale <= 0:
|
||||
raise ValueError("MiniMax H3 shift_scale must be > 0")
|
||||
if num_steps <= 0:
|
||||
raise ValueError("MiniMax H3 num_steps must be > 0")
|
||||
|
||||
import torch
|
||||
|
||||
# The rectified-flow sigma range is fixed at [1.0, 0.0].
|
||||
base = torch.linspace(
|
||||
1.0,
|
||||
0.0,
|
||||
int(num_steps),
|
||||
device="cpu",
|
||||
dtype=torch.float32,
|
||||
)
|
||||
shifted = float(shift_scale) * base / (1 + (float(shift_scale) - 1) * base)
|
||||
shifted, _ = torch.unique_consecutive(shifted, return_counts=True)
|
||||
# A one-point request is still exactly one point. Normal serving uses
|
||||
# multiple points, but preserving the requested cardinality keeps
|
||||
# ``num_inference_steps`` the sole schedule-size control.
|
||||
if num_steps > 1 and shifted[-1].item() > 0.0:
|
||||
shifted = torch.cat([shifted, torch.tensor([0.0], dtype=shifted.dtype)])
|
||||
return [float(value) for value in shifted.tolist()]
|
||||
|
||||
|
||||
class MiniMaxH3ShapePlanner:
|
||||
"""Compute MiniMax H3 frame, latent-shape, and time-shift values."""
|
||||
|
||||
def align_frame_count(self, frame_count: int) -> int:
|
||||
return _align_frame_count(frame_count)
|
||||
|
||||
def video_latent_t(self, frame_count: int) -> int:
|
||||
return _video_latent_t(frame_count)
|
||||
|
||||
def frame_count_from_video_latent_t(self, out_t: int) -> int:
|
||||
return _frame_count_from_video_latent_t(out_t)
|
||||
|
||||
def audio_latent_t(self, duration_seconds: float) -> int:
|
||||
return _audio_latent_t(duration_seconds)
|
||||
|
||||
def time_shift_sigmas(
|
||||
self,
|
||||
*,
|
||||
num_steps: int = 50,
|
||||
shift_scale: float = 6.0,
|
||||
) -> list[float]:
|
||||
return _time_shift_sigmas(
|
||||
num_steps=num_steps,
|
||||
shift_scale=shift_scale,
|
||||
)
|
||||
|
||||
|
||||
MINIMAX_H3_SHAPE_PLANNER = MiniMaxH3ShapePlanner()
|
||||
|
||||
|
||||
def minimax_h3_align_frame_count(frame_count: int) -> int:
|
||||
return MINIMAX_H3_SHAPE_PLANNER.align_frame_count(frame_count)
|
||||
|
||||
|
||||
def minimax_h3_frame_count_from_video_latent_t(out_t: int) -> int:
|
||||
return MINIMAX_H3_SHAPE_PLANNER.frame_count_from_video_latent_t(out_t)
|
||||
|
||||
|
||||
def minimax_h3_time_shift_sigmas(
|
||||
*,
|
||||
num_steps: int = 50,
|
||||
shift_scale: float = 6.0,
|
||||
) -> list[float]:
|
||||
return MINIMAX_H3_SHAPE_PLANNER.time_shift_sigmas(
|
||||
num_steps=num_steps,
|
||||
shift_scale=shift_scale,
|
||||
)
|
||||
@@ -0,0 +1,360 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 remote-code VAE adapters and exact latent contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from contextlib import AbstractContextManager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn as nn
|
||||
from PIL import Image
|
||||
from transformers.dynamic_module_utils import get_class_from_dynamic_module
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import (
|
||||
DistributedVaeMixin,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.parallel_state import get_dit_group
|
||||
|
||||
from .packed_tokens import minimax_h3_patchify_video_latent
|
||||
|
||||
MINIMAX_H3_KEYFRAME_ENCODE_SEED = 42
|
||||
MINIMAX_H3_AUDIO_SAMPLE_RATE = 32000
|
||||
MINIMAX_H3_AUDIO_CHANNELS = 2
|
||||
|
||||
|
||||
def _load_component_config(component_path: str) -> dict[str, Any]:
|
||||
config_path = Path(component_path) / "config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
channels = int(config["latent_channels"])
|
||||
for key in ("latents_mean", "latents_std"):
|
||||
values = config.get(key)
|
||||
if not isinstance(values, list) or len(values) != channels:
|
||||
raise ValueError(f"{config_path}: {key} must contain {channels} values")
|
||||
return config
|
||||
|
||||
|
||||
def _load_remote_component(
|
||||
component_path: str,
|
||||
config: dict[str, Any],
|
||||
) -> nn.Module:
|
||||
auto_map = config.get("auto_map") or {}
|
||||
class_reference = auto_map.get("AutoModel")
|
||||
if not isinstance(class_reference, str):
|
||||
raise ValueError(f"{component_path}/config.json must define auto_map.AutoModel")
|
||||
component_cls = get_class_from_dynamic_module(
|
||||
class_reference,
|
||||
component_path,
|
||||
)
|
||||
return component_cls.from_pretrained(component_path)
|
||||
|
||||
|
||||
class _AudioVAEDeterminismContext(AbstractContextManager):
|
||||
def __enter__(self):
|
||||
backends = torch.backends
|
||||
self._saved = (
|
||||
backends.cuda.matmul.allow_tf32,
|
||||
backends.cudnn.allow_tf32,
|
||||
backends.cudnn.benchmark,
|
||||
backends.cudnn.deterministic,
|
||||
backends.cudnn.enabled,
|
||||
backends.cuda.flash_sdp_enabled(),
|
||||
backends.cuda.mem_efficient_sdp_enabled(),
|
||||
backends.cuda.math_sdp_enabled(),
|
||||
)
|
||||
backends.cuda.matmul.allow_tf32 = False
|
||||
backends.cudnn.allow_tf32 = False
|
||||
backends.cudnn.benchmark = False
|
||||
backends.cudnn.deterministic = True
|
||||
backends.cudnn.enabled = False
|
||||
backends.cuda.enable_flash_sdp(False)
|
||||
backends.cuda.enable_mem_efficient_sdp(False)
|
||||
backends.cuda.enable_math_sdp(True)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
backends = torch.backends
|
||||
(
|
||||
backends.cuda.matmul.allow_tf32,
|
||||
backends.cudnn.allow_tf32,
|
||||
backends.cudnn.benchmark,
|
||||
backends.cudnn.deterministic,
|
||||
backends.cudnn.enabled,
|
||||
flash,
|
||||
memory_efficient,
|
||||
math_sdp,
|
||||
) = self._saved
|
||||
backends.cuda.enable_flash_sdp(flash)
|
||||
backends.cuda.enable_mem_efficient_sdp(memory_efficient)
|
||||
backends.cuda.enable_math_sdp(math_sdp)
|
||||
return False
|
||||
|
||||
|
||||
class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin):
|
||||
"""Adapter around the checkpoint's native parallel-tiled video VAE."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
component_path: str,
|
||||
*,
|
||||
device: torch.device,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config_dict = _load_component_config(component_path)
|
||||
self.remote = _load_remote_component(
|
||||
component_path,
|
||||
self.config_dict,
|
||||
)
|
||||
# Match the reference loader contract: video VAE weights stay FP32.
|
||||
# Keyframe encoding is numerically sensitive to first casting the
|
||||
# checkpoint through FP16; decode still runs under FP16 autocast.
|
||||
self.remote.eval().to(device=device, dtype=torch.float32)
|
||||
self.model = self.remote.model
|
||||
self.use_tiling = True
|
||||
self.use_slicing = False
|
||||
self.parallel_size = 1
|
||||
|
||||
def set_parallel_size(
|
||||
self,
|
||||
parallel_size: int,
|
||||
mode: str = "tile",
|
||||
) -> None:
|
||||
if mode != "tile":
|
||||
raise ValueError(f"MiniMax H3 VAE supports its native tile parallel mode only, got {mode!r}")
|
||||
group = get_dit_group()
|
||||
world_size = dist.get_world_size(group)
|
||||
rank = dist.get_rank(group)
|
||||
parallel_size = int(parallel_size)
|
||||
if parallel_size not in (1, world_size):
|
||||
raise ValueError(
|
||||
"MiniMax H3 native VAE patch parallelism currently requires "
|
||||
"vae_patch_parallel_size=1 or the full DiT group size "
|
||||
f"({world_size}), got {parallel_size}"
|
||||
)
|
||||
self.parallel_size = parallel_size
|
||||
enabled = parallel_size > 1
|
||||
|
||||
package = self.remote.__class__.__module__.rsplit(".", 1)[0]
|
||||
parallel_module = importlib.import_module(f"{package}.parallel")
|
||||
state = parallel_module.get_parallel_state()
|
||||
state.clear()
|
||||
state.update(
|
||||
group_size=parallel_size,
|
||||
group_rank=rank if enabled else 0,
|
||||
local_process_group=group if enabled else None,
|
||||
sp_size=parallel_size,
|
||||
sp_rank=rank if enabled else 0,
|
||||
sp_enabled=enabled,
|
||||
sp_process_group=group if enabled else None,
|
||||
tp_size=1,
|
||||
tp_rank=0,
|
||||
)
|
||||
self.model.parallel_tiling = enabled
|
||||
|
||||
def is_distributed_enabled(self) -> bool:
|
||||
return self.parallel_size > 1 and dist.is_initialized()
|
||||
|
||||
@torch.inference_mode()
|
||||
def encode_image(self, image: Image.Image) -> torch.Tensor:
|
||||
previous_parallel = self.model.parallel_tiling
|
||||
self.model.parallel_tiling = False
|
||||
parameter = next(self.parameters())
|
||||
previous_dtype = parameter.dtype
|
||||
if previous_dtype != torch.float32:
|
||||
self.to(torch.float32)
|
||||
devices = [parameter.device] if parameter.device.type == "cuda" else []
|
||||
try:
|
||||
with torch.random.fork_rng(devices=devices):
|
||||
torch.default_generator.manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED)
|
||||
for device in devices:
|
||||
with torch.cuda.device(device):
|
||||
torch.cuda.manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED)
|
||||
latent = self.model.encode_images(
|
||||
image,
|
||||
use_fp16_latent=True,
|
||||
)[0]
|
||||
finally:
|
||||
self.model.parallel_tiling = previous_parallel
|
||||
if previous_dtype != torch.float32:
|
||||
self.to(previous_dtype)
|
||||
|
||||
# Match the reference contract exactly: normalization and patchify
|
||||
# happen on CPU in FP32 after the sampled encode. The condition noise
|
||||
# path is sensitive enough that doing these elementwise operations on
|
||||
# CUDA can noticeably change the final conditioned video.
|
||||
latent = latent.float().cpu()
|
||||
if latent.ndim == 4:
|
||||
latent = latent[None]
|
||||
channels = int(self.config_dict["latent_channels"])
|
||||
mean = torch.tensor(
|
||||
self.config_dict["latents_mean"],
|
||||
).view(1, channels, 1, 1, 1)
|
||||
std = torch.tensor(
|
||||
self.config_dict["latents_std"],
|
||||
).view(1, channels, 1, 1, 1)
|
||||
return minimax_h3_patchify_video_latent(
|
||||
(latent - mean) / std,
|
||||
patch_size=(1, 2, 2),
|
||||
).float()
|
||||
|
||||
@torch.inference_mode()
|
||||
def encode_video(
|
||||
self,
|
||||
frames: Any,
|
||||
) -> tuple[torch.Tensor, tuple[int, int, int]]:
|
||||
parameter = next(self.parameters())
|
||||
previous_dtype = parameter.dtype
|
||||
if previous_dtype != torch.float32:
|
||||
self.to(torch.float32)
|
||||
devices = [parameter.device] if parameter.device.type == "cuda" else []
|
||||
try:
|
||||
with torch.random.fork_rng(devices=devices):
|
||||
torch.default_generator.manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED)
|
||||
for device in devices:
|
||||
with torch.cuda.device(device):
|
||||
torch.cuda.manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED)
|
||||
latent = self.model.encode_videos(
|
||||
frames,
|
||||
use_fp16_latent=True,
|
||||
)[0]
|
||||
finally:
|
||||
if previous_dtype != torch.float32:
|
||||
self.to(previous_dtype)
|
||||
|
||||
latent = latent.float().cpu()
|
||||
if latent.ndim == 4:
|
||||
latent = latent[None]
|
||||
channels = int(self.config_dict["latent_channels"])
|
||||
if latent.ndim != 5 or int(latent.shape[1]) != channels:
|
||||
raise ValueError(f"unexpected reference video latent shape {tuple(latent.shape)}")
|
||||
shape = (
|
||||
int(latent.shape[2]),
|
||||
int(latent.shape[3]),
|
||||
int(latent.shape[4]),
|
||||
)
|
||||
mean = torch.tensor(
|
||||
self.config_dict["latents_mean"],
|
||||
).view(1, channels, 1, 1, 1)
|
||||
std = torch.tensor(
|
||||
self.config_dict["latents_std"],
|
||||
).view(1, channels, 1, 1, 1)
|
||||
rows = minimax_h3_patchify_video_latent(
|
||||
(latent - mean) / std,
|
||||
patch_size=(1, 2, 2),
|
||||
).float()
|
||||
return rows, shape
|
||||
|
||||
@torch.inference_mode()
|
||||
def decode_latent(self, latent: torch.Tensor) -> torch.Tensor:
|
||||
channels = int(self.config_dict["latent_channels"])
|
||||
mean = torch.tensor(
|
||||
self.config_dict["latents_mean"],
|
||||
device=latent.device,
|
||||
dtype=latent.dtype,
|
||||
).view(1, channels, 1, 1, 1)
|
||||
std = torch.tensor(
|
||||
self.config_dict["latents_std"],
|
||||
device=latent.device,
|
||||
dtype=latent.dtype,
|
||||
).view(1, channels, 1, 1, 1)
|
||||
decoded = self.model.decode_base(latent * std + mean)
|
||||
frames = self.model.processor.revert_tensor(decoded)
|
||||
if frames.ndim == 4:
|
||||
frames = frames.unsqueeze(0).transpose(1, 2)
|
||||
if frames.ndim != 5:
|
||||
raise ValueError(f"unexpected decoded video shape {tuple(frames.shape)}")
|
||||
return frames.float()
|
||||
|
||||
|
||||
class MiniMaxH3AudioVAE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
component_path: str,
|
||||
*,
|
||||
device: torch.device,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config_dict = _load_component_config(component_path)
|
||||
self.remote = _load_remote_component(
|
||||
component_path,
|
||||
self.config_dict,
|
||||
)
|
||||
# The checkpoint's audio VAE contract is FP32 for both reference
|
||||
# encoding and waveform decoding.
|
||||
self.remote.eval().to(device=device, dtype=torch.float32)
|
||||
self.model = self.remote.model
|
||||
self.sample_rate = int(self.config_dict["sample_rate"])
|
||||
|
||||
@torch.inference_mode()
|
||||
def encode_waveform(
|
||||
self,
|
||||
waveform: torch.Tensor,
|
||||
sample_rate: int,
|
||||
) -> tuple[torch.Tensor, int]:
|
||||
import torchaudio
|
||||
|
||||
waveform = waveform.float()
|
||||
if waveform.ndim == 1:
|
||||
waveform = waveform[None]
|
||||
if int(sample_rate) != MINIMAX_H3_AUDIO_SAMPLE_RATE:
|
||||
waveform = torchaudio.transforms.Resample(
|
||||
int(sample_rate),
|
||||
MINIMAX_H3_AUDIO_SAMPLE_RATE,
|
||||
)(waveform)
|
||||
if waveform.shape[0] < MINIMAX_H3_AUDIO_CHANNELS:
|
||||
waveform = waveform.repeat(
|
||||
MINIMAX_H3_AUDIO_CHANNELS,
|
||||
1,
|
||||
)
|
||||
waveform = waveform[:MINIMAX_H3_AUDIO_CHANNELS]
|
||||
device = next(self.model.parameters()).device
|
||||
waveform = waveform.to(device)
|
||||
|
||||
with _AudioVAEDeterminismContext():
|
||||
audio = self.model.preprocess(
|
||||
waveform.unsqueeze(1),
|
||||
MINIMAX_H3_AUDIO_SAMPLE_RATE,
|
||||
)
|
||||
latent = self.model.encoder(audio)
|
||||
if bool(getattr(self.model, "attn_proj", False)):
|
||||
latent = self.model.pre_block(latent.transpose(1, 2)).transpose(1, 2)
|
||||
latent = self.model.mean_proj(latent).float().cpu()
|
||||
|
||||
channels = int(self.config_dict["latent_channels"])
|
||||
if latent.shape[-1] != channels:
|
||||
if latent.shape[1] != channels:
|
||||
raise ValueError(f"cannot canonicalize audio latent {tuple(latent.shape)}")
|
||||
latent = latent.transpose(1, 2).contiguous()
|
||||
mean = torch.tensor(
|
||||
self.config_dict["latents_mean"],
|
||||
).view(1, 1, channels)
|
||||
std = torch.tensor(
|
||||
self.config_dict["latents_std"],
|
||||
).view(1, 1, channels)
|
||||
rows = ((latent - mean) / std).reshape(-1, channels)
|
||||
return rows.float(), int(latent.shape[1])
|
||||
|
||||
@torch.inference_mode()
|
||||
def decode_latent(self, latent: torch.Tensor) -> torch.Tensor:
|
||||
channels = int(self.config_dict["latent_channels"])
|
||||
mean = torch.tensor(
|
||||
self.config_dict["latents_mean"],
|
||||
device=latent.device,
|
||||
dtype=latent.dtype,
|
||||
).view(1, channels, 1)
|
||||
std = torch.tensor(
|
||||
self.config_dict["latents_std"],
|
||||
device=latent.device,
|
||||
dtype=latent.dtype,
|
||||
).view(1, channels, 1)
|
||||
waveform = self.remote.decode(latent * std + mean)
|
||||
if waveform.ndim != 3 or waveform.shape[1] != 1:
|
||||
raise ValueError(f"unexpected decoded audio shape {tuple(waveform.shape)}")
|
||||
return waveform.permute(1, 0, 2).contiguous().float()
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3AudioVAE", "MiniMaxH3VideoVAE"]
|
||||
@@ -86,6 +86,11 @@ _DIFFUSION_MODELS = {
|
||||
"pipeline_ltx2",
|
||||
"LTX2I2VDMD2Pipeline",
|
||||
),
|
||||
"MiniMaxH3Pipeline": (
|
||||
"minimax_h3",
|
||||
"pipeline_minimax_h3",
|
||||
"MiniMaxH3Pipeline",
|
||||
),
|
||||
"StableAudioPipeline": (
|
||||
"stable_audio",
|
||||
"pipeline_stable_audio",
|
||||
@@ -502,6 +507,7 @@ _DIFFUSION_POST_PROCESS_FUNCS = {
|
||||
"LTX2DistilledPipeline": "get_ltx2_post_process_func",
|
||||
"LTX2T2VDMD2Pipeline": "get_ltx2_post_process_func",
|
||||
"LTX2I2VDMD2Pipeline": "get_ltx2_post_process_func",
|
||||
"MiniMaxH3Pipeline": "get_minimax_h3_post_process_func",
|
||||
"StableAudioPipeline": "get_stable_audio_post_process_func",
|
||||
"SoulXSingerPipeline": "get_soulxsinger_post_process_func",
|
||||
"SoulXSingerSVCPipeline": "get_soulxsinger_post_process_func",
|
||||
|
||||
@@ -69,6 +69,12 @@ class _TransferTopoConfig:
|
||||
sp_rank: int
|
||||
sp_group: Any
|
||||
world: Any
|
||||
# The stage's tensor-model-parallel group (vLLM parallel state). Only the
|
||||
# ranks in this group execute the same request in lockstep — DP replicas
|
||||
# and PP stages in ``world`` do not — so per-request consensus exchanges
|
||||
# must use this group, never ``world``.
|
||||
tp_group: Any = None
|
||||
tp_size: int = 1
|
||||
|
||||
@property
|
||||
def is_leader(self) -> bool:
|
||||
@@ -642,6 +648,16 @@ class OmniKVTransferManager:
|
||||
else:
|
||||
role = ReceiveRole.LEADER if world_rank == 0 else ReceiveRole.FOLLOWER
|
||||
|
||||
# The stage's TP group comes from vLLM parallel state (the TP-sharded
|
||||
# LM path); it may legitimately be uninitialized on non-TP stages.
|
||||
try:
|
||||
from vllm.distributed.parallel_state import get_tp_group
|
||||
|
||||
tp_group = get_tp_group()
|
||||
tp_size = tp_group.world_size
|
||||
except Exception:
|
||||
tp_group, tp_size = None, 1 # vLLM TP not initialized
|
||||
|
||||
return _TransferTopoConfig(
|
||||
role=role,
|
||||
tp_active=tp_active,
|
||||
@@ -652,6 +668,8 @@ class OmniKVTransferManager:
|
||||
sp_rank=sp_rank,
|
||||
sp_group=sp_group,
|
||||
world=world,
|
||||
tp_group=tp_group,
|
||||
tp_size=tp_size,
|
||||
)
|
||||
|
||||
def _resolve_sender_info(
|
||||
@@ -764,6 +782,20 @@ class OmniKVTransferManager:
|
||||
|
||||
return kv_payload
|
||||
|
||||
@staticmethod
|
||||
def _clear_request_kv_payload(req: Any) -> None:
|
||||
"""Drop every request-side KV field a receive may have applied.
|
||||
|
||||
Enumerates fields via ``_collect_request_kv_payload`` so clearing stays
|
||||
symmetric with what apply/broadcast set — the pipeline then takes the
|
||||
same no-KV path as a rank that never received.
|
||||
"""
|
||||
for key in OmniKVTransferManager._collect_request_kv_payload(req):
|
||||
if key.startswith("sp."):
|
||||
setattr(req.sampling_params, key[3:], None)
|
||||
else:
|
||||
setattr(req, key, None)
|
||||
|
||||
@staticmethod
|
||||
def _apply_request_kv_payload(
|
||||
req: Any,
|
||||
@@ -1753,6 +1785,64 @@ class OmniKVTransferManager:
|
||||
combined = combined.to(device)
|
||||
return self._unpack_kv_payload(combined)
|
||||
|
||||
def _tp_local_receive_consensus(self, req: Any, received: bool) -> bool:
|
||||
"""Identical-KV-state verdict across pure-TP ranks (LOCAL role).
|
||||
|
||||
In pure TP every rank fetches its own KV shard with an independent
|
||||
timeout and ``distribute_kv_cache`` is a no-op, so a single rank's
|
||||
silent miss sends only that rank into the pipeline's no-KV prefill —
|
||||
it then enters TP collectives its peers never join, and the NCCL
|
||||
watchdog kills the worker (#5627). Each rank reports a signature of
|
||||
its applied KV state — the primary-receive flag plus the set of
|
||||
applied payload roles, so partially-received CFG companion KV
|
||||
(``cfg_text``/``cfg_img``) diverging between ranks is caught too. If
|
||||
the signatures differ, every rank drops its KV so all take the same
|
||||
fallback path together; identical-but-partial state is kept, since
|
||||
symmetric absence is consistent (e.g. Bagel's empty unconditional
|
||||
branch).
|
||||
|
||||
The exchange runs over the stage's tensor-parallel group only: those
|
||||
are the ranks executing this request in lockstep. ``world`` would
|
||||
also contain DP replicas / PP stages working on different requests —
|
||||
mixing their flags corrupts the verdict, and a replica skipping the
|
||||
exchange (dummy request) while another enters it would deadlock.
|
||||
Within the TP group the skip guards below depend only on topology,
|
||||
config, and the request id — identical on every TP rank — so ranks
|
||||
always agree on whether the exchange happens.
|
||||
"""
|
||||
pt = self.topo_config
|
||||
group = pt.tp_group
|
||||
if not (pt.is_local and pt.tp_active) or group is None or pt.tp_size <= 1:
|
||||
return received
|
||||
if not self.config.need_recv_cache:
|
||||
return received
|
||||
request_id = self._resolve_request_id(req)
|
||||
if request_id is None or OmniDiffusionRequest.is_dummy_run_request_id(request_id):
|
||||
return received
|
||||
|
||||
signature = (bool(received), tuple(sorted(self._collect_request_kv_payload(req))))
|
||||
if group.rank_in_group == 0:
|
||||
signatures = [signature]
|
||||
signatures.extend(group.recv_object(src=src) for src in range(1, pt.tp_size))
|
||||
consistent = all(sig == signature for sig in signatures)
|
||||
group.broadcast_object(consistent, src=0)
|
||||
else:
|
||||
group.send_object(signature, dst=0)
|
||||
consistent = bool(group.broadcast_object(None, src=0))
|
||||
|
||||
if not consistent:
|
||||
logger.warning(
|
||||
"KV state for %s diverged across TP ranks (this rank: received=%s, roles=%s); "
|
||||
"dropping KV on every rank so all take the no-KV fallback together "
|
||||
"instead of desyncing TP collectives",
|
||||
request_id,
|
||||
signature[0],
|
||||
list(signature[1]),
|
||||
)
|
||||
self._clear_request_kv_payload(req)
|
||||
return False
|
||||
return received
|
||||
|
||||
def receive_multi_kv_cache_distributed(
|
||||
self,
|
||||
req: Any,
|
||||
@@ -1763,6 +1853,7 @@ class OmniKVTransferManager:
|
||||
received = False
|
||||
if not self.topo_config.is_follower:
|
||||
received = self.receive_multi_kv_cache(req, cfg_kv_collect_func, target_device)
|
||||
received = self._tp_local_receive_consensus(req, received)
|
||||
kv_payload = self.distribute_kv_cache(req, target_device, received=received)
|
||||
if kv_payload is not None:
|
||||
self._apply_request_kv_payload(req, kv_payload, target_device)
|
||||
@@ -1791,6 +1882,7 @@ class OmniKVTransferManager:
|
||||
if not received and not self.topo_config.is_follower and not payload_consumed:
|
||||
logger.debug("KV prefetch miss for %s; falling back to sync receive", self._resolve_request_id(req))
|
||||
received = self.receive_multi_kv_cache(req, None, target_device)
|
||||
received = self._tp_local_receive_consensus(req, received)
|
||||
kv_payload = self.distribute_kv_cache(req, target_device, received=received)
|
||||
if kv_payload is not None:
|
||||
self._apply_request_kv_payload(req, kv_payload, target_device)
|
||||
|
||||
@@ -490,6 +490,7 @@ class OrchestratorArgs:
|
||||
cfg_parallel_size: int = 1
|
||||
vae_patch_parallel_size: int = 1
|
||||
vae_parallel_mode: str = "tile"
|
||||
text_encoder_tp_size: int = 1
|
||||
default_sampling_params: str | None = None
|
||||
max_generated_image_size: int | None = None
|
||||
tts_max_instructions_length: int | None = None
|
||||
|
||||
@@ -213,6 +213,12 @@ class AsyncOmniEngine:
|
||||
# beforehand. The stage CLI exposes the same deploy YAML through
|
||||
# stage_configs_path.
|
||||
deploy_config_path = kwargs.get("deploy_config") or kwargs.get("stage_configs_path")
|
||||
# ``trust_remote_code`` is tri-state (bool | None): ``None`` means "not
|
||||
# specified" so stage-config resolution can defer to the deploy yaml's
|
||||
# per-stage value (see ``with_trust_remote_code_override``). The
|
||||
# restriction path below loads the top-level HF config via vLLM's
|
||||
# ``get_config``, which needs a real bool, so collapse ``None`` to the
|
||||
# default ``False`` here (#5495).
|
||||
pipeline_config = StageConfigFactory.get_pipeline_config(
|
||||
model=model,
|
||||
trust_remote_code=bool(trust_remote_code),
|
||||
@@ -959,6 +965,7 @@ class AsyncOmniEngine:
|
||||
pipeline_parallel_size = normalized_kwargs.get("pipeline_parallel_size") or 1
|
||||
vae_patch_parallel_size = normalized_kwargs.get("vae_patch_parallel_size") or 1
|
||||
vae_parallel_mode = normalized_kwargs.get("vae_parallel_mode") or "tile"
|
||||
text_encoder_tp_size = normalized_kwargs.get("text_encoder_tp_size") or 1
|
||||
enable_expert_parallel = normalized_kwargs.get("enable_expert_parallel") or False
|
||||
use_hsdp = normalized_kwargs.get("use_hsdp", False)
|
||||
hsdp_shard_size = normalized_kwargs.get("hsdp_shard_size", -1)
|
||||
@@ -979,6 +986,7 @@ class AsyncOmniEngine:
|
||||
cfg_parallel_size=cfg_parallel_size,
|
||||
vae_patch_parallel_size=vae_patch_parallel_size,
|
||||
vae_parallel_mode=vae_parallel_mode,
|
||||
text_encoder_tp_size=text_encoder_tp_size,
|
||||
use_hsdp=use_hsdp,
|
||||
hsdp_shard_size=hsdp_shard_size,
|
||||
hsdp_replicate_size=hsdp_replicate_size,
|
||||
|
||||
@@ -713,6 +713,15 @@ class OmniServeCommand(CLISubcommand):
|
||||
"Distributes VAE decode workload across multiple ranks by splitting the latent spatially. "
|
||||
"Equivalent to setting DiffusionParallelConfig.vae_patch_parallel_size.",
|
||||
)
|
||||
omni_config_group.add_argument(
|
||||
"--text-encoder-tp-size",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Tensor-parallel degree for the diffusion text encoder. "
|
||||
"Shards the Qwen3-VL encoder across the first N DiT ranks, "
|
||||
"removing the rank-0 encoder memory hotspot in no-offload runs. "
|
||||
"Equivalent to setting DiffusionParallelConfig.text_encoder_tp_size.",
|
||||
)
|
||||
omni_config_group.add_argument(
|
||||
"--vae-parallel-mode",
|
||||
type=str,
|
||||
|
||||
@@ -11,6 +11,7 @@ import os
|
||||
|
||||
# Image generation API imports
|
||||
import random
|
||||
import tempfile
|
||||
import time
|
||||
from argparse import Namespace
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -2856,6 +2857,18 @@ async def _cleanup_video(video_id: str):
|
||||
logger.warning("Failed to cleanup partial video file '%s'", video_id)
|
||||
|
||||
|
||||
def _cleanup_video_references(
|
||||
reference_video: ReferenceVideo | None,
|
||||
reference_audio: ReferenceAudio | None,
|
||||
) -> None:
|
||||
if reference_video is not None:
|
||||
for path in reference_video.cleanup_paths:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
if reference_audio is not None and os.path.exists(reference_audio.path):
|
||||
os.unlink(reference_audio.path)
|
||||
|
||||
|
||||
async def _run_video_generation_job(
|
||||
handler: OmniOpenAIServingVideo,
|
||||
request: VideoGenerationRequest,
|
||||
@@ -2936,17 +2949,37 @@ async def _run_video_generation_job(
|
||||
await VIDEO_STORE.pop(video_id)
|
||||
raise
|
||||
finally:
|
||||
if reference_audio is not None and os.path.exists(reference_audio.path):
|
||||
os.unlink(reference_audio.path)
|
||||
_cleanup_video_references(reference_video, reference_audio)
|
||||
|
||||
|
||||
VIDEO_SYNC_TIMEOUT_S = float(os.environ.get("VLLM_OMNI_VIDEO_SYNC_TIMEOUT", 600.0))
|
||||
|
||||
|
||||
async def _persist_uploaded_video_references(uploads: list[UploadFile]) -> list[str]:
|
||||
paths: list[str] = []
|
||||
try:
|
||||
for upload in uploads:
|
||||
suffix = Path(upload.filename or "").suffix.lower()
|
||||
if suffix not in {".mkv", ".mov", ".mp4", ".webm"}:
|
||||
suffix = ".mp4"
|
||||
fd, path = tempfile.mkstemp(prefix="vllm_omni_video_reference_", suffix=suffix)
|
||||
paths.append(path)
|
||||
with os.fdopen(fd, "wb") as output:
|
||||
while chunk := await upload.read(1024 * 1024):
|
||||
output.write(chunk)
|
||||
except Exception:
|
||||
for path in paths:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
raise
|
||||
return paths
|
||||
|
||||
|
||||
async def _parse_video_form(
|
||||
raw_request: Request,
|
||||
prompt: str = Form(...),
|
||||
input_reference: UploadFile | None = File(default=None),
|
||||
input_references: list[UploadFile] | None = File(default=None),
|
||||
image_reference: str | None = Form(default=None),
|
||||
video_reference: str | None = Form(default=None),
|
||||
audio_reference: str | None = Form(default=None),
|
||||
@@ -2987,11 +3020,19 @@ async def _parse_video_form(
|
||||
|
||||
Used by both ``POST /v1/videos`` (async) and ``POST /v1/videos/sync``.
|
||||
"""
|
||||
input_references = input_references or []
|
||||
input_reference_bytes = await input_reference.read() if input_reference is not None else None
|
||||
parsed_image_reference = _parse_form_json(image_reference)
|
||||
parsed_video_reference = _parse_form_json(video_reference)
|
||||
parsed_audio_reference = _parse_form_json(audio_reference)
|
||||
|
||||
if input_references and any(
|
||||
item is not None for item in (parsed_image_reference, parsed_video_reference, input_reference_bytes)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST.value,
|
||||
detail="Provide input_references alone, without input_reference, image_reference, or video_reference.",
|
||||
)
|
||||
provided_references = sum(
|
||||
item is not None for item in (parsed_image_reference, parsed_video_reference, input_reference_bytes)
|
||||
)
|
||||
@@ -3063,32 +3104,39 @@ async def _parse_video_form(
|
||||
)
|
||||
|
||||
decode_spec = ReferenceVideoDecodeSpec()
|
||||
if parsed_video_reference is not None or input_reference_bytes is not None:
|
||||
if not input_references and (parsed_video_reference is not None or input_reference_bytes is not None):
|
||||
stage_configs = (
|
||||
handler.stage_configs
|
||||
or app_stage_configs
|
||||
or getattr(getattr(handler, "_engine_client", None), "stage_configs", None)
|
||||
)
|
||||
decode_spec = _reference_video_decode_spec(request, stage_configs)
|
||||
try:
|
||||
media_data = await decode_input_reference(
|
||||
request.image_reference,
|
||||
request.video_reference,
|
||||
input_reference_bytes,
|
||||
max_video_frames=decode_spec.max_frames,
|
||||
video_keep=decode_spec.keep,
|
||||
)
|
||||
except InvalidInputReferenceError as exc:
|
||||
raise HTTPException(400, detail=str(exc) or "Invalid input reference.") from exc
|
||||
reference_image = None
|
||||
reference_video = None
|
||||
if input_references:
|
||||
paths = await _persist_uploaded_video_references(input_references)
|
||||
reference_video = ReferenceVideo(data=paths, cleanup_paths=tuple(paths))
|
||||
else:
|
||||
try:
|
||||
media_data = await decode_input_reference(
|
||||
request.image_reference,
|
||||
request.video_reference,
|
||||
input_reference_bytes,
|
||||
max_video_frames=decode_spec.max_frames,
|
||||
video_keep=decode_spec.keep,
|
||||
)
|
||||
except InvalidInputReferenceError as exc:
|
||||
raise HTTPException(400, detail=str(exc) or "Invalid input reference.") from exc
|
||||
|
||||
reference_image = ReferenceImage(data=media_data) if isinstance(media_data, Image.Image) else None
|
||||
reference_video = ReferenceVideo(data=media_data) if isinstance(media_data, list) else None
|
||||
reference_image = ReferenceImage(data=media_data) if isinstance(media_data, Image.Image) else None
|
||||
reference_video = ReferenceVideo(data=media_data) if isinstance(media_data, list) else None
|
||||
|
||||
reference_audio: ReferenceAudio | None = None
|
||||
if request.audio_reference is not None:
|
||||
try:
|
||||
audio_path = await decode_audio_url(request.audio_reference.audio_url)
|
||||
except InvalidInputReferenceError as exc:
|
||||
_cleanup_video_references(reference_video, None)
|
||||
raise HTTPException(400, detail=str(exc)) from exc
|
||||
reference_audio = ReferenceAudio(path=audio_path)
|
||||
|
||||
@@ -3201,8 +3249,7 @@ async def create_video_sync(
|
||||
detail=f"Video generation failed: {str(exc)}",
|
||||
) from exc
|
||||
finally:
|
||||
if reference_audio is not None and os.path.exists(reference_audio.path):
|
||||
os.unlink(reference_audio.path)
|
||||
_cleanup_video_references(reference_video, reference_audio)
|
||||
inference_time_s = time.perf_counter() - started_at
|
||||
|
||||
return Response(
|
||||
|
||||
@@ -49,7 +49,8 @@ class ReferenceImage:
|
||||
class ReferenceVideo:
|
||||
"""Reference video frames for video-conditioned generation."""
|
||||
|
||||
data: list[Image.Image]
|
||||
data: list[Image.Image] | list[str]
|
||||
cleanup_paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
Reference in new issue
Block a user