[Feature][MiniMax-H3] Fuse the FastH3 four-step adapter at load time (#6714)

Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
汪志鹏andHongsheng Liu authored and GitHub committed 2026-08-31 12:04:58 +08:00
1 parent 5e06d2e73c
commit 86b85c078b
13 files changed
+1420 -54

No files matched your search

+66
View File
@@ -918,6 +918,72 @@ adapter and twice without it, then compare the four output digests. The adapter
is bound and deterministic when each pair matches internally and the two pairs
differ from each other.
### FastH3 adapter
[FastH3](https://haoailab.com/blogs/fasth3-preview/) is FastVideo's four-step
DMD2 student of H3-Base. It reuses H3's text encoder, VAEs, tokenizers, and
schedulers unchanged, replacing 49 denoiser evaluations with four. It is fused
into the checkpoint at load time rather than switched per request, because it
carries full-rank deltas that no LoRA layer can express.
The bundle publishes four variants, so download one and point `--lora-path` at
it; the repository root is refused rather than guessed at:
```bash
export FASTH3_DIR=/path/to/fasth3
hf download FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA \
dense-datafree/adapter_model.safetensors --local-dir "${FASTH3_DIR}"
export FASTH3_LORA="${FASTH3_DIR}/dense-datafree/adapter_model.safetensors"
```
Add `--task-type fl2va --lora-path "${FASTH3_LORA}"` to a non-offloaded server
command. T2VA is served by the FL2VA partition, so `--task-type fl2va` is
correct even though FastH3 preview v1 distills T2VA only. Because the adapter is
fused, `--lora-backend` does not apply and a request carrying a `lora=` field is
rejected rather than served without the adapter it asked for.
```bash
-F 'num_inference_steps=4' \
-F 'extra_params={"task":"t2va","duration":4.4}'
```
Requests must ask for `num_inference_steps=4` and `task=t2va`: the release's five
sigma points bound four denoiser evaluations, and that count is what the step
scheduler admits a request on. The server denoises on the release's own ladder,
keeping H3's per-modality shifts at the checkpoint values, so a request that
overrides `flow_shift` or `audio_flow_shift` is rejected - it would sample the
student at noise levels it was never distilled at.
Only a release that identifies itself as FastH3 is fused; any other
`fastvideo-lora-v2` adapter stays on the dynamic LoRA route. A claimed artifact
is then held to its own metadata: one that misdeclares its tensor counts or
leaves a transformer block unedited is refused at startup instead of serving
mostly base H3 weights on a four-step schedule. Offload is refused for the same
reason - `--enable-cpu-offload`, `--enable-layerwise-offload` and
`--enable-distributed-layerwise-offload` all bypass the fusion, so they fail fast.
> [!NOTE]
> Only the **Dense / Data-Free** variant is supported today. The three VSA
> variants carry `.set_weight` compression gates for modules that do not exist
> in vLLM-Omni's dense H3 attention, and the release states dense attention is
> not a supported substitute for them, so they are refused at startup rather
> than silently run wrong. Video Sparse Attention for H3's packed
> `[text | cond | audio | video]` sequence is follow-up work.
Measured on 8x NVIDIA B300 with USP8, VAE patch-parallel 8, `TRTLLM_ATTN`, at
1344x768, 4.4 s, seed 1101, one warmup excluded and two runs recorded:
| Adapter | Steps | End-to-end | Diffusion engine |
| --- | ---: | ---: | ---: |
| none (base H3) | 50 | 25.8 / 26.4 s | 16.22 / 16.28 s |
| FastH3 Dense | 4 (5 sigma points) | 11.7 / 11.8 s | 2.37 / 2.36 s |
The denoising speedup is 6.9x. End-to-end is 2.2x because text encoding, VAE
decoding and muxing are a fixed cost that dominates a clip this short; longer
generations move the end-to-end figure toward the denoising one. Fusing the
adapter does not measurably change startup: weight loading took 77.3 s with it
against 85.8 s without.
## Key parameters
| Parameter | Recommended value | Notes |
+14 -15
View File
@@ -43,21 +43,16 @@ def test_jit_build_includes_cuda_headers_from_nvidia_wheels(tmp_path, monkeypatc
@dataclass
class _FakeAllocation:
"""Byte workspace stand-in that slices and reinterprets like ``symm_mem.empty()``."""
size: int
device: torch.device
def __post_init__(self) -> None:
self.buffer = torch.empty(self.size, dtype=torch.uint8)
class _FakeHandle:
rank = 0
def __init__(self, allocation: _FakeAllocation) -> None:
self.allocation = allocation
self.views: list[tuple[tuple[int, ...], torch.dtype]] = []
def get_buffer(self, rank, shape, dtype):
assert rank == self.rank
self.views.append((shape, dtype))
return self.allocation, shape, dtype
def __getitem__(self, item: slice) -> torch.Tensor:
return self.buffer[item]
def test_workspace_reuses_peak_capacity_across_shapes(monkeypatch) -> None:
@@ -93,16 +88,20 @@ def test_workspace_reuses_peak_capacity_across_shapes(monkeypatch) -> None:
return allocation
monkeypatch.setattr(a2a_permute.symm_mem, "empty", empty)
monkeypatch.setattr(a2a_permute.symm_mem, "rendezvous", lambda allocation, _group: _FakeHandle(allocation))
# The handle is only retained to keep the rendezvous registration alive.
monkeypatch.setattr(a2a_permute.symm_mem, "rendezvous", lambda _allocation, _group: object())
device = torch.device("cuda:0")
first = a2a_permute._get_symm_buffer((2, 3), torch.float16, device, "group")
smaller = a2a_permute._get_symm_buffer((1, 4), torch.float16, device, "group")
larger = a2a_permute._get_symm_buffer((4, 3), torch.float16, device, "group")
assert first[0].size == 12
assert smaller[0] is first[0]
assert larger[0].size == 24
assert (first.shape, first.dtype) == ((2, 3), torch.float16)
assert (smaller.shape, smaller.dtype) == ((1, 4), torch.float16)
assert (larger.shape, larger.dtype) == ((4, 3), torch.float16)
# A sub-capacity request reuses the workspace; only growth allocates again.
assert smaller.untyped_storage().data_ptr() == first.untyped_storage().data_ptr()
assert larger.untyped_storage().data_ptr() != first.untyped_storage().data_ptr()
assert [allocation.size for allocation in allocations] == [12, 24]
assert len(a2a_permute._SYMM_WORKSPACES) == 1
assert len(all_reduce_calls) == 1
@@ -496,13 +496,16 @@ def test_joint_postprocess_is_multiprocessing_picklable():
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)
# decode() hands over quantised frames already laid out as (B, T, H, W, C).
video = torch.arange(2 * 2 * 4 * 5 * 3, dtype=torch.uint8).reshape(2, 2, 4, 5, 3)
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].dtype == np.uint8
assert result["video"][0].shape == (2, 4, 5, 3)
np.testing.assert_array_equal(result["video"][0], video[0].numpy())
np.testing.assert_array_equal(result["audio"], audio.numpy())
assert result["audio_sample_rate"] == 32000
assert result["fps"] == 24
@@ -595,7 +598,7 @@ def test_base_schedule_overrides_the_uniform_sigma_positions():
@pytest.mark.parametrize(
"base_schedule",
[
[0.9, 0.5, 0.0],
[1.5, 0.5, 0.0],
[1.0, 0.5, 0.1],
[1.0, 0.5, 0.5, 0.0],
[1.0],
@@ -0,0 +1,498 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
import torch
from safetensors.torch import save_file
from vllm_omni.diffusion.models.minimax_h3.fasth3 import (
FASTH3_BASE_MODEL,
FASTH3_BASE_SCHEDULE,
FASTH3_DENOISE_STEPS,
FASTH3_FORMAT,
FastH3AdapterError,
FastH3WeightFusion,
resolve_fasth3_fusion,
)
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import _reorder_grouped_qkv_to_qkv
from vllm_omni.diffusion.models.minimax_h3.time_request import minimax_h3_time_shift_sigmas
from vllm_omni.errors import OmniClientError
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
_RANK = 2
_HIDDEN = 4
_HEAD_DIM = 2
_HEADS = 3
_INNER = _HEAD_DIM * _HEADS # attention inner size
_FFN = 5
def _factors(out_dim: int, in_dim: int) -> tuple[torch.Tensor, torch.Tensor]:
"""A rank-2 pair whose product is reproducible and non-symmetric."""
a = torch.arange(_RANK * in_dim, dtype=torch.float32).reshape(_RANK, in_dim) / (in_dim * _RANK)
b = torch.arange(out_dim * _RANK, dtype=torch.float32).reshape(out_dim, _RANK) / (out_dim * _RANK)
return a, b
def _write_adapter(
path,
*,
tensors=None,
drop: str | None = None,
blocks: int = 1,
metadata: dict[str, str | None] | None = None,
) -> None:
"""Write an artifact in the published ``fastvideo-lora-v2`` shape."""
payload: dict[str, torch.Tensor] = {}
for block in range(blocks):
for suffix, (out_dim, in_dim) in {
"attn.to_q": (_INNER, _HIDDEN),
"attn.to_k": (_INNER, _HIDDEN),
"attn.to_v": (_INNER, _HIDDEN),
"attn.to_out.0": (_HIDDEN, _INNER),
"ff.net.0.proj": (2 * _FFN, _HIDDEN),
"ff.net.2": (_HIDDEN, _FFN),
}.items():
a, b = _factors(out_dim, in_dim)
payload[f"transformer_blocks.{block}.{suffix}.lora_A.weight"] = a
payload[f"transformer_blocks.{block}.{suffix}.lora_B.weight"] = b
payload.update(tensors or {})
if drop is not None:
payload.pop(drop, None)
path.parent.mkdir(parents=True, exist_ok=True)
# The published writer records what it emitted; mirror it so the fixtures
# are as self-describing as the real artifact.
declared = {
"format": FASTH3_FORMAT,
"finetuned_model": "FastVideo/FastVideo-FastH3-Dense-4-step-v1",
"base_model": FASTH3_BASE_MODEL,
"rank": str(_RANK),
"low_rank_tensors": str(sum(1 for key in payload if key.endswith((".lora_A.weight", ".lora_B.weight")))),
"diff_tensors": str(sum(1 for key in payload if key.endswith((".diff", ".diff_b")))),
"set_weight_tensors": str(sum(1 for key in payload if key.endswith(".set_weight"))),
}
# safetensors metadata is string-only, so a ``None`` override drops the key.
declared.update(metadata or {})
save_file(payload, str(path), metadata={key: value for key, value in declared.items() if value is not None})
def _claim(path, **kwargs) -> FastH3WeightFusion | None:
"""Whatever from_path decides, including declining the artifact."""
kwargs.setdefault("head_dim", _HEAD_DIM)
kwargs.setdefault("num_blocks", 1)
kwargs.setdefault("num_refiner_blocks", 0)
return FastH3WeightFusion.from_path(path, **kwargs)
def _load(path, **kwargs) -> FastH3WeightFusion:
"""The same, for the tests that only make sense on a claimed adapter."""
fusion = _claim(path, **kwargs)
assert fusion is not None, f"{path} was not claimed as a FastH3 adapter"
return fusion
def test_only_an_artifact_carrying_the_release_identity_is_claimed(tmp_path):
plain = tmp_path / "peft" / "adapter_model.safetensors"
plain.parent.mkdir(parents=True)
save_file({"transformer_blocks.0.attn.to_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))}, str(plain))
# No fastvideo-lora-v2 metadata: this is somebody else's LoRA and has to
# stay on the dynamic route rather than being fused.
assert _claim(plain.parent) is None
assert _claim(tmp_path / "missing") is None
claimed = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(claimed)
assert _claim(claimed.parent) is not None
@pytest.mark.parametrize(
"identity",
[
# An ordinary H3 adapter out of FastVideo's own extraction tools.
{"finetuned_model": "someone/minimax-h3-style-lora"},
# Somebody else's adapter that merely names the student it imitates.
{"finetuned_model": "someone/fasth3-style-lora-for-h3"},
{"finetuned_model": ""},
{"finetuned_model": None},
# A FastH3 name over a different base model.
{"base_model": "Wan-AI/Wan2.2-TI2V-5B"},
],
)
def test_a_generic_fastvideo_h3_adapter_stays_on_the_dynamic_route(identity, tmp_path):
"""The fixture edits every block, so only the identity separates it from FastH3."""
path = tmp_path / "generic" / "adapter_model.safetensors"
_write_adapter(path, metadata=identity)
assert _claim(path.parent) is None
def test_a_claimed_artifact_must_declare_every_tensor_count(tmp_path):
"""Declining is for other people's adapters; a FastH3 file is held to its word."""
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, metadata={"diff_tensors": None})
with pytest.raises(FastH3AdapterError, match="omits diff_tensors"):
_claim(path.parent)
def test_the_published_bundle_root_is_refused_rather_than_guessed(tmp_path):
root = tmp_path / "FastVideo-FastH3-4-step-Preview-v1-LoRA"
for slug in ("dense-datafree", "vsa-datafree"):
_write_adapter(root / slug / "adapter_model.safetensors")
(root / "adapter_manifest.json").write_text(json.dumps({"schema_version": "fasth3-lora-bundle-v1"}))
with pytest.raises(FastH3AdapterError, match="point --lora-path at one variant"):
_load(root)
# One variant inside it is unambiguous.
assert _claim(root / "dense-datafree") is not None
def test_a_multi_shard_non_fasth3_adapter_stays_on_the_dynamic_route(tmp_path):
# A plain PEFT LoRA saved as several shards must not hard-fail startup with a
# FastH3-specific message; it has to fall through to the dynamic LoRA route.
directory = tmp_path / "peft-sharded"
directory.mkdir()
for shard in ("adapter_model-00001-of-00002.safetensors", "adapter_model-00002-of-00002.safetensors"):
save_file(
{"transformer_blocks.0.attn.to_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))}, str(directory / shard)
)
assert _claim(directory) is None
def test_low_rank_factors_reach_the_fused_projections(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
qkv = torch.zeros((3 * _INNER, _HIDDEN), dtype=torch.float32)
fused = fusion.fuse("blocks.0.attn.qkv_proj.weight", qkv).cpu()
# The checkpoint stores one head group at a time as [q, k, v], so the delta
# has to survive the loader's own unpacking as three separate projections.
q, k, v = torch.split(
_reorder_grouped_qkv_to_qkv(fused, num_query_groups=_HEADS, heads_per_group=1, head_dim=_HEAD_DIM),
[_INNER, _INNER, _INNER],
)
a, b = _factors(_INNER, _HIDDEN)
expected = b @ a
for got in (q, k, v):
assert torch.allclose(got, expected, atol=1e-5)
def test_the_fused_mlp_delta_is_swapped_into_gate_first_order(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
fused = fusion.fuse("blocks.0.mlp.fc1.weight", torch.zeros((2 * _FFN, _HIDDEN))).cpu()
a, b = _factors(2 * _FFN, _HIDDEN)
value, gate = (b @ a).chunk(2, dim=0)
# The diffusers export is value-first; H3's fc1 is gate-first.
assert torch.allclose(fused, torch.cat((gate, value), dim=0), atol=1e-5)
def test_diff_and_diff_b_edit_weights_and_biases(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(
path,
tensors={
"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 0.25),
"transformer_blocks.0.adaln_proj.linear.diff_b": torch.full((_HIDDEN,), -0.5),
"context_embedder.diff": torch.full((_HIDDEN, _HIDDEN), 0.125),
"context_embedder.diff_b": torch.full((_HIDDEN,), 2.0),
},
)
fusion = _load(path.parent)
# A full-rank delta on an RMSNorm vector is exactly what a LoRA layer
# cannot express, and why this release is fused instead of switched.
assert torch.allclose(fusion.fuse("blocks.0.norm1.weight", torch.ones(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 1.25))
assert torch.allclose(
fusion.fuse("blocks.0.adaln_proj.linear.bias", torch.ones(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 0.5)
)
assert torch.allclose(
fusion.fuse("condition_proj.weight", torch.zeros((_HIDDEN, _HIDDEN))).cpu(),
torch.full((_HIDDEN, _HIDDEN), 0.125),
)
assert torch.allclose(fusion.fuse("condition_proj.bias", torch.zeros(_HIDDEN)).cpu(), torch.full((_HIDDEN,), 2.0))
def test_a_streamed_checkpoint_keeps_the_parameters_the_adapter_never_edits(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 3.0)})
fusion = _load(path.parent)
untouched = torch.arange(6, dtype=torch.bfloat16).reshape(2, 3)
streamed = dict(
fusion.apply([("blocks.0.norm1.weight", torch.zeros(_HIDDEN)), ("blocks.0.attn.q_norm.weight", untouched)])
)
assert torch.allclose(streamed["blocks.0.norm1.weight"].cpu(), torch.full((_HIDDEN,), 3.0))
assert streamed["blocks.0.attn.q_norm.weight"] is untouched
# validate_fully_applied() releases the deltas, so a second stream would
# fuse nothing at all; it has to fail rather than serve base H3 weights.
with pytest.raises(FastH3AdapterError, match="already been fused"):
next(fusion.apply([("blocks.0.norm1.weight", torch.zeros(_HIDDEN))]))
def test_the_fused_result_keeps_the_parameter_dtype(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.norm1.diff": torch.full((_HIDDEN,), 0.5)})
fusion = _load(path.parent)
fused = fusion.fuse("blocks.0.norm1.weight", torch.ones(_HIDDEN, dtype=torch.bfloat16))
assert fused.dtype == torch.bfloat16
def test_a_vsa_variant_is_recognised_by_its_compression_gates(tmp_path):
dense = tmp_path / "dense" / "adapter_model.safetensors"
_write_adapter(dense)
assert _load(dense.parent).requires_vsa is False
sparse = tmp_path / "vsa" / "adapter_model.safetensors"
_write_adapter(sparse, tensors={"transformer_blocks.0.attn.to_gate_compress.set_weight": torch.ones((2, 2))})
assert _load(sparse.parent).requires_vsa is True
def test_a_tensor_naming_no_h3_parameter_is_an_error(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.attn.norm_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))})
# Dropping it silently would load a model that is not the distilled student.
with pytest.raises(FastH3AdapterError, match="name no known"):
_load(path.parent)
def test_an_unpaired_low_rank_factor_is_an_error(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, drop="transformer_blocks.0.attn.to_q.lora_B.weight")
with pytest.raises(FastH3AdapterError, match="unpaired factor"):
_load(path.parent)
def test_a_delta_the_checkpoint_never_offered_is_reported(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
fusion = _load(path.parent)
fusion.fuse("blocks.0.attn.qkv_proj.weight", torch.zeros((3 * _INNER, _HIDDEN)))
# An unapplied delta is the failure that matters: the model would load and
# generate, just not as the distilled student.
with pytest.raises(FastH3AdapterError, match="never provided"):
fusion.validate_fully_applied()
for name, shape in (
("blocks.0.attn.out_proj.weight", (_HIDDEN, _INNER)),
("blocks.0.mlp.fc1.weight", (2 * _FFN, _HIDDEN)),
("blocks.0.mlp.fc2.weight", (_HIDDEN, _FFN)),
):
fusion.fuse(name, torch.zeros(shape))
fusion.validate_fully_applied()
def _pipeline_stub(fusion=None):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.partition = "combined"
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
pipeline._turbo_lora_adapter_ids = set()
pipeline._fasth3 = fusion
pipeline.od_config = SimpleNamespace()
pipeline.default_video_shift, pipeline.default_audio_shift = 12.0, 3.0
return pipeline
def _sampling(steps=FASTH3_DENOISE_STEPS, **kwargs) -> SimpleNamespace:
fields = {"num_inference_steps": steps, "extra_args": {}, "lora_request": None}
return SimpleNamespace(**{**fields, **kwargs})
def _check_request(fusion, sampling) -> None:
fusion.check_request(sampling, video_shift=12.0, audio_shift=3.0)
def _fusion(tmp_path, **kwargs) -> FastH3WeightFusion:
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, **kwargs)
return _load(path.parent)
@pytest.mark.parametrize("task", ["fl2va", "ref2va"])
def test_an_active_fasth3_fusion_restricts_requests_to_t2va(task, tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
with pytest.raises(OmniClientError, match="distills \\['t2va'\\] only"):
pipeline._resolve_task(task, {})
assert pipeline._resolve_task("t2va", {}) == "t2va"
assert _pipeline_stub()._resolve_task(task, {}) == task
@pytest.mark.parametrize("num_inference_steps", [None, 5, 50])
def test_fasth3_requires_one_forward_per_sigma_interval(num_inference_steps, tmp_path):
fusion = _fusion(tmp_path)
# The five sigma points bound four forwards, and a request states forwards:
# the interval contract the pinned schedules use, and the count the step
# scheduler admits a request on before any pipeline hook runs.
with pytest.raises(OmniClientError, match=f"num_inference_steps={FASTH3_DENOISE_STEPS}"):
_check_request(fusion, _sampling(num_inference_steps))
_check_request(fusion, _sampling())
# The pinned branch coerces the string form; this one cannot disagree.
_check_request(fusion, _sampling(str(FASTH3_DENOISE_STEPS)))
def test_the_request_denoises_on_the_release_ladder(tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
positions, num_steps = pipeline._resolve_sigma_positions("t2va", _sampling())
# dmd_denoising_steps [999, 749, 500, 250] out of 1000, closed with the
# terminal 0.0: the pre-shift positions the student was distilled at, not
# the uniform ladder num_inference_steps would otherwise derive.
assert positions == (0.999, 0.749, 0.5, 0.25, 0.0)
# Five sigma points bound four transformer forwards, and forwards is the
# unit num_steps carries downstream (Cache-DiT, quality hints).
assert num_steps == FASTH3_DENOISE_STEPS == len(positions) - 1
# H3's own per-modality shifts still apply on top, and are what turn those
# positions into the noise levels the four forwards run at.
assert minimax_h3_time_shift_sigmas(shift_scale=12.0, base_schedule=positions) == pytest.approx(
[0.999917, 0.972833, 0.923077, 0.8, 0.0], abs=1e-6
)
assert minimax_h3_time_shift_sigmas(shift_scale=3.0, base_schedule=positions) == pytest.approx(
[0.999666, 0.899520, 0.75, 0.5, 0.0], abs=1e-6
)
# Without a fused adapter the same request keeps the undistilled ladder.
assert _pipeline_stub()._resolve_sigma_positions("t2va", _sampling()) == (None, FASTH3_DENOISE_STEPS)
def test_the_contract_leaves_the_checkpoint_shifts_alone(tmp_path):
pipeline = _pipeline_stub(_fusion(tmp_path))
pipeline.partition = "fl2va"
pipeline._fasth3.check_serving_contract(
partition=pipeline.partition,
od_config=pipeline.od_config,
video_shift=pipeline.default_video_shift,
audio_shift=pipeline.default_audio_shift,
)
# The ladder is stated pre-shift, so H3's 12/3 shifts are what place the
# student on the levels it was distilled at; the contract must not touch them.
assert pipeline.default_video_shift == 12.0
assert pipeline.default_audio_shift == 3.0
@pytest.mark.parametrize(
"extra",
[{"flow_shift": 1.0}, {"audio_flow_shift": 1.0}, {"flow_shift": "bad"}],
)
def test_a_request_may_not_move_the_student_off_its_rungs(extra, tmp_path):
fusion = _fusion(tmp_path)
with pytest.raises(OmniClientError, match="FastH3 requires"):
_check_request(fusion, _sampling(extra_args=extra))
_check_request(fusion, _sampling(extra_args={"flow_shift": 12.0, "audio_flow_shift": 3.0}))
def test_a_request_may_not_carry_a_lora_on_a_fused_server(tmp_path):
fusion = _fusion(tmp_path)
# The dynamic LoRA manager is skipped for a fused adapter, so an adapter
# asked for per request would be neither applied nor reported.
with pytest.raises(OmniClientError, match="per-request lora is unavailable"):
_check_request(fusion, _sampling(lora_request=SimpleNamespace(lora_int_id=1)))
def test_offload_is_refused_because_it_bypasses_the_fusion(tmp_path):
fusion = _fusion(tmp_path)
for flag in ("enable_cpu_offload", "enable_layerwise_offload", "enable_distributed_layerwise_offload"):
# A host-weight plan installs the transformer without load_weights(),
# so the fusion and its completeness check would both be skipped.
with pytest.raises(ValueError, match="cannot be combined with"):
fusion.check_serving_contract(
partition="fl2va",
od_config=SimpleNamespace(**{flag: True}),
video_shift=12.0,
audio_shift=3.0,
)
def test_adopting_the_contract_refuses_a_vsa_variant_and_ref2va(tmp_path):
sparse = tmp_path / "vsa" / "adapter_model.safetensors"
_write_adapter(sparse, tensors={"transformer_blocks.0.attn.to_gate_compress.set_weight": torch.ones((2, 2))})
contract = {"od_config": SimpleNamespace(), "video_shift": 12.0, "audio_shift": 3.0}
with pytest.raises(ValueError, match="Video Sparse Attention variant"):
_load(sparse.parent).check_serving_contract(partition="fl2va", **contract)
with pytest.raises(ValueError, match="cannot serve a Ref2VA partition"):
_fusion(tmp_path).check_serving_contract(partition="ref2va", **contract)
def test_a_full_rank_delta_on_a_fused_parameter_is_refused(tmp_path):
# Only low-rank factors are placed into H3's fused QKV and gate/up layouts,
# so a .diff aimed at one would otherwise be added transposed.
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, tensors={"transformer_blocks.0.attn.to_q.diff": torch.ones((_INNER, _HIDDEN))})
with pytest.raises(FastH3AdapterError, match="fused layout"):
_claim(path.parent)
def test_an_adapter_that_leaves_blocks_untouched_is_refused(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, blocks=2)
# Every tensor in this file is well formed and pairs up, so the fusion's
# own completeness check passes; only the model's depth reveals that a
# third of it would have been served as base H3 weights.
with pytest.raises(FastH3AdapterError, match="missing=\\[2\\]"):
_claim(path.parent, num_blocks=3)
with pytest.raises(FastH3AdapterError, match="unknown=\\[1\\]"):
_claim(path.parent, num_blocks=1)
assert _claim(path.parent, num_blocks=2) is not None
def test_an_adapter_shorter_than_it_declares_is_refused(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path, metadata={"low_rank_tensors": "724", "diff_tensors": "85"})
# The writer records what it emitted, which is the file's one statement
# about its own completeness.
with pytest.raises(FastH3AdapterError, match="declares low_rank_tensors=724 but carries 12"):
_claim(path.parent)
def test_only_a_configured_adapter_path_is_read(tmp_path):
path = tmp_path / "fasth3" / "adapter_model.safetensors"
_write_adapter(path)
transformer = SimpleNamespace(
arch=SimpleNamespace(attention_head_dim=_HEAD_DIM, num_layers=1, token_refiner_num_layers=0)
)
unbuilt = object() # a transformer whose arch must not be touched
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=str(path)), transformer) is not None
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=[str(path)]), transformer) is not None
# Nothing configured, or more than one artifact, stays on the dynamic route
# without reading anything off the model.
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=None), unbuilt) is None
assert resolve_fasth3_fusion(SimpleNamespace(), unbuilt) is None
assert resolve_fasth3_fusion(SimpleNamespace(lora_path=[str(path), str(path)]), unbuilt) is None
def test_a_pipeline_that_fused_its_adapter_needs_no_lora_manager(tmp_path):
assert _pipeline_stub(_fusion(tmp_path)).lora_is_fused is True
assert _pipeline_stub().lora_is_fused is False
def test_the_ladder_is_the_one_the_release_publishes():
# FASTH3_DENOISE_STEPS is derived from it, so the two cannot drift apart.
assert FASTH3_BASE_SCHEDULE.base_schedule == (0.999, 0.749, 0.5, 0.25, 0.0)
assert FASTH3_BASE_SCHEDULE.num_inference_steps == FASTH3_DENOISE_STEPS == 4
@@ -259,7 +259,11 @@ def test_h3_native_rejects_schedule_with_wrong_interval_count(tmp_path):
("", "must not be empty"),
(",,", "must not be empty"),
("1.0,oops,0.15,0.0", "is malformed"),
("0.9,0.7,0.4,0.15,0.0", "is malformed"),
# A capped opening rung below 1.0 is a legal DMD2 schedule, so the
# out-of-range cases are a first position above 1.0 and a tail that
# never reaches clean latents.
("1.5,0.7,0.4,0.15,0.0", "is malformed"),
("1.0,0.7,0.4,0.15", "is malformed"),
],
)
def test_h3_native_rejects_malformed_schedule_metadata(tmp_path, raw_schedule, message):
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import pytest
from vllm_omni.diffusion.sched import DMD2SigmaSchedule
@@ -18,7 +18,8 @@ def test_step_count_is_intervals_not_boundaries():
[
[],
[1.0],
[0.9, 0.5, 0.0],
[1.5, 0.5, 0.0],
[0.0, 0.5, 0.0],
[1.0, 0.5, 0.1],
[1.0, 0.4, 0.6, 0.0],
[1.0, 0.5, 0.5, 0.0],
@@ -31,6 +32,15 @@ def test_malformed_positions_are_rejected(positions):
DMD2SigmaSchedule.from_positions(positions)
def test_a_capped_first_position_is_a_schedule_not_a_malformed_one():
# A DMD2 student trained with max_timestep_ratio < 1 never sees pure noise,
# so its opening rung sits just below 1.0 (FastH3: 999/1000).
schedule = DMD2SigmaSchedule.from_positions([0.999, 0.749, 0.5, 0.25, 0.0])
assert schedule.base_schedule[0] == 0.999
assert schedule.num_inference_steps == 4
def test_one_base_schedule_drives_several_shift_scales():
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
@@ -520,6 +520,32 @@ def test_interleaved_video_uses_legacy_fallback_automatically(monkeypatch):
assert len(coerce_calls) == 1
def test_uint8_frames_reach_the_muxer_without_a_float_round_trip():
"""uint8 is the muxer's own dtype, so normalisation must leave it alone.
MiniMax-H3 quantises on the accelerator to keep the response small across
its two process hops. Re-normalising to float here would undo that at the
last step and cost a full-size conversion in each direction.
"""
video = np.arange(2 * 4 * 5 * 3, dtype=np.uint8).reshape(2, 4, 5, 3)
frames, frame_shape, common_dtype = video_api_utils._prepare_video_frames(video)
assert common_dtype == np.dtype(np.uint8)
assert frame_shape == (4, 5, 3)
np.testing.assert_array_equal(np.stack(frames), video)
def test_non_uint8_integer_frames_are_still_normalised():
"""Only uint8 is the muxer's dtype; other integer payloads still scale."""
video = np.full((2, 4, 5, 3), 255, dtype=np.int32)
frames, _, common_dtype = video_api_utils._prepare_video_frames(video)
assert np.issubdtype(common_dtype, np.floating)
np.testing.assert_allclose(frames[0], 1.0)
@pytest.mark.parametrize(
"video",
[
@@ -552,9 +578,10 @@ def test_prepared_automatic_fallback_preserves_legacy_quantization(monkeypatch,
monkeypatch.setattr(media_utils, "mux_video_audio_bytes", fake_compat_mux)
prepared_frames, frame_shape, _ = video_api_utils._prepare_video_frames(video)
reference = np.rint(np.clip(np.stack([frame[..., :3] for frame in prepared_frames]), 0.0, 1.0) * 255.0).astype(
np.uint8
)
stacked = np.stack([frame[..., :3] for frame in prepared_frames])
# uint8 frames are the muxer's own dtype and reach it unchanged; everything
# else is still quantised out of the normalised [0, 1] range.
reference = stacked if stacked.dtype == np.uint8 else np.rint(np.clip(stacked, 0.0, 1.0) * 255.0).astype(np.uint8)
assert video_api_utils._encode_video_bytes(video, fps=12) == b"legacy-video"
assert len(mux_inputs) == 1
@@ -142,7 +142,7 @@ def _get_symm_buffer(
stream_id=stream_id,
)
_SYMM_WORKSPACES[key] = workspace
return workspace.handle.get_buffer(workspace.handle.rank, symm_shape, dtype)
return workspace.allocation[:required_bytes].view(dtype).view(symm_shape)
def clear_a2a_permute_workspaces() -> None:
@@ -0,0 +1,679 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""FastVideo FastH3: a four-step DMD2 student of MiniMax-H3.
FastH3 replaces H3's 49 denoiser evaluations with four. It ships as an adapter
over the base checkpoint rather than as a full release, so it reuses H3's text
encoder, video VAE, audio VAE, tokenizers and schedulers unchanged.
The artifact is *not* a PEFT LoRA, and it is not request-switchable. Its own
metadata states the reconstruction as::
W = W_base + lora_B @ lora_A; then .diff/.diff_b added and .set_weight assigned
so besides rank-64 factors it carries full-rank ``.diff``/``.diff_b`` deltas for
RMSNorm weights, biases, patch projections and the final layer - none of which a
LoRA layer can express - and the VSA variants add ``.set_weight`` tensors for
compression gates that do not exist in the base transformer at all. The adapter
is therefore fused into the checkpoint stream at load time, before the weights
are sharded, which is also what the release's model card requires.
The low-rank factors carry no alpha: the reconstruction adds ``lora_B @ lora_A``
directly, i.e. a scale of exactly 1.
Two checkpoint spellings meet here. The adapter is written in the diffusers
namespace (``transformer_blocks.0.attn.to_q``) while vLLM-Omni loads H3's native
one (``blocks.0.attn.qkv_proj``), whose attention and MLP projections are fused.
Every mapping and layout convention below was verified tensor by tensor against
the released full checkpoint (``FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree``):
``W_base + delta`` reproduces it to bf16 rounding.
"""
from __future__ import annotations
import math
from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
import torch
from safetensors import safe_open
from vllm.logger import init_logger
from vllm_omni.diffusion.sched.sigma_schedule import DMD2SigmaSchedule
from vllm_omni.errors import OmniClientError
from vllm_omni.platforms import current_omni_platform
if TYPE_CHECKING:
from .minimax_h3_transformer import MiniMaxH3DiTModel
logger = init_logger(__name__)
# FastVideo's generic adapter container, not a FastH3 marker: their tools emit
# it for ordinary H3 adapters too.
FASTH3_FORMAT = "fastvideo-lora-v2"
FASTH3_MANIFEST = "adapter_manifest.json"
# The release identity: the distilled student the adapter came from, over the
# base it edits. The name is held to FastVideo's own namespace as well as the
# student's name, so an unrelated adapter that merely mentions FastH3 is not
# claimed.
FASTH3_BASE_MODEL = "MiniMaxAI/MiniMax-H3"
_FASTH3_IDENTITY_KEY = "finetuned_model"
_FASTH3_IDENTITY_NAMESPACE = "fastvideo/"
_FASTH3_IDENTITY_MARKER = "fasth3"
# The rectified-flow positions the student was distilled at, and the ladder the
# server samples on. The release states them as `dmd_denoising_steps`
# [999, 749, 500, 250] (`sampling.base_timesteps` in the bundle manifest):
# timestep indices out of 1000, i.e. pre-shift positions, closed here with the
# terminal 0.0 every rectified-flow schedule ends on. The opening rung is 0.999
# rather than 1.0 because training capped the noise level there
# (`max_timestep_ratio`).
#
# These are positions, not final sigmas: H3's schedulers apply their own
# per-modality shift on top (12 for video, 3 for audio), which is what
# reproduces the levels the student saw. Nothing here overrides those shifts.
FASTH3_BASE_SCHEDULE = DMD2SigmaSchedule.from_positions((0.999, 0.749, 0.5, 0.25, 0.0))
# Its five points bound four transformer forwards, one per sigma interval. That
# count is the one a request states, the one Cache-DiT is refreshed with and the
# one step execution admits a request on - the same interval contract H3's
# pinned checkpoint schedules and native LoRAs already use.
FASTH3_DENOISE_STEPS = FASTH3_BASE_SCHEDULE.num_inference_steps
# Preview v1 distills the text-to-video-and-audio path only.
FASTH3_SUPPORTED_TASKS = frozenset({"t2va"})
_LORA_A = ".lora_A.weight"
_LORA_B = ".lora_B.weight"
_DIFF = ".diff"
_DIFF_B = ".diff_b"
_SET_WEIGHT = ".set_weight"
# Adapter module prefix -> the native parameter it edits, minus the
# ``.weight``/``.bias`` suffix.
_MODEL_LEVEL_TARGETS = {
"proj_in": "video_patch_proj",
"proj_out": "final_layer.video_out",
"audio_proj_in": "audio_patch_proj",
"audio_proj_out": "final_layer.audio_out",
"context_embedder": "condition_proj",
"time_embedder.linear_1": "time_embedder.proj_in",
"time_embedder.linear_2": "time_embedder.proj_out",
"norm_out.linear": "final_layer.adaln_proj.linear",
"norm_out.norm": "final_layer.norm",
}
# Per-block adapter suffix -> (native suffix, how a delta enters the native
# parameter). H3 stores attention as one grouped QKV matrix and the MLP as one
# fused gate/up matrix, so those deltas need placing rather than adding.
_PLAIN, _QKV_Q, _QKV_K, _QKV_V, _SWAP_HALVES = "plain", "q", "k", "v", "swap_halves"
_QKV_SLOTS = (_QKV_Q, _QKV_K, _QKV_V)
_BLOCK_TARGETS = {
"attn.to_q": ("attn.qkv_proj", _QKV_Q),
"attn.to_k": ("attn.qkv_proj", _QKV_K),
"attn.to_v": ("attn.qkv_proj", _QKV_V),
"attn.to_out.0": ("attn.out_proj", _PLAIN),
"ff.net.0.proj": ("mlp.fc1", _SWAP_HALVES),
"ff.net.2": ("mlp.fc2", _PLAIN),
"adaln_proj.linear": ("adaln_proj.linear", _PLAIN),
"norm1": ("norm1", _PLAIN),
"norm2": ("norm2", _PLAIN),
}
# Adapter block prefix -> native block prefix.
_BLOCK_PREFIXES = (
("token_refiner.refiner_blocks.", "token_refiner.blocks."),
("transformer_blocks.", "blocks."),
)
class FastH3AdapterError(ValueError):
"""The artifact is a FastH3 adapter, but it cannot be applied as one."""
@dataclass
class _ParamPatch:
"""Everything the adapter contributes to one native parameter."""
# layout -> (lora_A, lora_B). A grouped QKV parameter collects three.
low_rank: dict[str, tuple[torch.Tensor | None, torch.Tensor | None]] = field(default_factory=dict)
diff: torch.Tensor | None = None
layout: str = _PLAIN
def _swap_halves(tensor: torch.Tensor) -> torch.Tensor:
"""Exchange the two halves of a fused gate/up matrix.
The diffusers export stores the feed-forward projection value-first while
H3's native ``mlp.fc1`` is gate-first, so a delta computed in the diffusers
layout has to be swapped before it can be added to the native parameter.
"""
if tensor.shape[0] % 2:
raise FastH3AdapterError(f"fused gate/up delta must split evenly, got {tuple(tensor.shape)}")
first, second = tensor.chunk(2, dim=0)
return torch.cat((second, first), dim=0)
def _place_in_grouped_qkv(deltas: Mapping[str, torch.Tensor], *, head_dim: int) -> torch.Tensor:
"""Interleave per-projection deltas into H3's grouped QKV layout.
The checkpoint stores one head group at a time as ``[q, k, v]``, which is
what :func:`_reorder_grouped_qkv_to_qkv` unpacks on the way in. A delta
built from the separate diffusers projections has to be folded back into
that order.
"""
missing = sorted(set(_QKV_SLOTS) - set(deltas))
if missing:
raise FastH3AdapterError(f"grouped QKV delta is missing its {missing} projections")
parts = []
for slot in _QKV_SLOTS:
delta = deltas[slot]
if delta.shape[0] % head_dim:
raise FastH3AdapterError(
f"QKV {slot} delta rows {delta.shape[0]} are not a multiple of head_dim {head_dim}"
)
parts.append(delta.reshape(delta.shape[0] // head_dim, head_dim, *delta.shape[1:]))
groups = parts[0].shape[0]
if any(part.shape[0] != groups for part in parts):
raise FastH3AdapterError("QKV projections disagree on the number of head groups")
return torch.cat(parts, dim=1).reshape(groups * 3 * head_dim, *parts[0].shape[2:])
def _resolve_native_target(module: str) -> tuple[str, str, tuple[str, int] | None] | None:
"""Map an adapter module path to ``(native path, layout, block)``.
``block`` is the ``(native block prefix, index)`` the module sits in, or
``None`` for a model-level one. Coverage is checked per block, so the index
has to survive the mapping rather than being folded into the path.
"""
native = _MODEL_LEVEL_TARGETS.get(module)
if native is not None:
return native, _PLAIN, None
for adapter_prefix, native_prefix in _BLOCK_PREFIXES:
if not module.startswith(adapter_prefix):
continue
remainder = module[len(adapter_prefix) :]
index, _, suffix = remainder.partition(".")
if not index.isdigit():
return None
target = _BLOCK_TARGETS.get(suffix)
if target is None:
return None
native_suffix, layout = target
return f"{native_prefix}{index}.{native_suffix}", layout, (native_prefix, int(index))
return None
def _split_adapter_key(name: str) -> tuple[str, str] | None:
"""Split an adapter tensor name into ``(module path, role)``."""
for marker, role in (
(_LORA_A, "lora_a"),
(_LORA_B, "lora_b"),
(_DIFF_B, "diff_b"),
(_DIFF, "diff"),
(_SET_WEIGHT, "set_weight"),
):
if name.endswith(marker):
return name[: -len(marker)], role
return None
def _is_fasth3_release(metadata: Mapping[str, str]) -> bool:
"""Whether this is a FastH3 release rather than merely FastVideo's format.
Claiming on the container alone would fuse an ordinary H3 adapter into the
checkpoint and put every request on a four-step schedule it was never
distilled for, and so would a bare ``fasth3`` substring: an adapter someone
else named after the student is not the student.
"""
if metadata.get("format") != FASTH3_FORMAT:
return False
identity = metadata.get(_FASTH3_IDENTITY_KEY, "").lower()
if not identity.startswith(_FASTH3_IDENTITY_NAMESPACE) or _FASTH3_IDENTITY_MARKER not in identity:
return False
# Only held against a file that states it.
base_model = metadata.get("base_model")
return base_model is None or base_model.casefold() == FASTH3_BASE_MODEL.casefold()
def _check_declared_counts(
metadata: Mapping[str, str],
counted: Mapping[str, int],
*,
weights_path: Path,
) -> None:
"""Hold the artifact to the tensor counts its own metadata declares.
The writer of the ``fastvideo-lora-v2`` format records how many tensors of
each kind it emitted, which is the one statement in the file about its own
completeness. A truncated or partially re-exported artifact is otherwise
indistinguishable from a small one, so a claimed file has to carry all of
them rather than opt out by omission.
"""
for key, seen in counted.items():
declared = metadata.get(key)
if declared is None:
raise FastH3AdapterError(
f"{weights_path} declares the FastH3 identity but omits {key}; "
"a claimed adapter has to state what it emitted"
)
try:
expected = int(declared)
except (TypeError, ValueError) as exc:
raise FastH3AdapterError(f"{weights_path} declares a non-numeric {key}={declared!r}") from exc
if expected != seen:
raise FastH3AdapterError(
f"{weights_path} declares {key}={expected} but carries {seen}; the adapter is incomplete "
"and would leave most of the transformer on base H3 weights"
)
def _check_block_coverage(
seen: Mapping[str, set[int]],
*,
expected: Mapping[str, int],
weights_path: Path,
) -> None:
"""Every block of the model this adapter is loaded against must be edited.
The release drops tensors training left unchanged, so per-parameter
coverage is legitimately sparse - but a distilled student touches every
block, so a block with no edits at all means the artifact does not match
this model.
"""
for prefix, count in expected.items():
indices = seen.get(prefix, set())
wanted = set(range(count))
if indices == wanted:
continue
missing = sorted(wanted - indices)
extra = sorted(indices - wanted)
raise FastH3AdapterError(
f"{weights_path} edits {len(indices)} of the model's {count} {prefix}* blocks "
f"(missing={missing[:5]}, unknown={extra[:5]}); it is not an adapter for this checkpoint"
)
class FastH3WeightFusion:
"""Fuse a FastH3 adapter into the H3 checkpoint stream as it is loaded."""
def __init__(
self,
*,
source: Path,
patches: Mapping[str, _ParamPatch],
head_dim: int,
requires_vsa: bool,
) -> None:
self._source = source
self._patches = dict(patches)
self._head_dim = head_dim
self.requires_vsa = requires_vsa
self._applied: set[str] = set()
self._device: torch.device | None = None
@property
def source(self) -> Path:
return self._source
@property
def base_schedule(self) -> tuple[float, ...]:
"""The rectified-flow positions this student samples on.
The fused checkpoint is a four-step student, so the ladder comes from
the release rather than from the many-step teacher's metadata or from
the uniform one ``num_inference_steps`` would otherwise derive.
"""
return FASTH3_BASE_SCHEDULE.base_schedule
@classmethod
def from_path(
cls,
path: str | Path,
*,
head_dim: int,
num_blocks: int,
num_refiner_blocks: int,
) -> FastH3WeightFusion | None:
"""Build a fusion from an adapter file or directory, else ``None``.
Returning ``None`` keeps every other ``--lora-path`` artifact on the
dynamic LoRA route; only a file carrying the FastH3 release identity is
claimed here.
The block counts are the model's, and the artifact has to cover them:
claiming a partial adapter would switch the server onto the four-step
contract while most of the transformer still held base H3 weights.
"""
weights_path = _resolve_adapter_file(path)
if weights_path is None:
return None
patches: dict[str, _ParamPatch] = {}
gate_tensors: list[str] = []
unmapped: list[str] = []
blocks_seen: dict[str, set[int]] = {}
counted = {"low_rank_tensors": 0, "diff_tensors": 0}
with safe_open(weights_path, framework="pt", device="cpu") as checkpoint:
metadata = checkpoint.metadata() or {}
if not _is_fasth3_release(metadata):
return None
for name in checkpoint.keys():
split = _split_adapter_key(name)
if split is None:
unmapped.append(name)
continue
module, role = split
if role == "set_weight":
# A VSA compression gate: a module the base transformer does
# not have, so there is nothing to fuse it into.
gate_tensors.append(name)
continue
target = _resolve_native_target(module)
if target is None:
unmapped.append(name)
continue
native_module, layout, block = target
if block is not None:
blocks_seen.setdefault(block[0], set()).add(block[1])
counted["diff_tensors" if role in ("diff", "diff_b") else "low_rank_tensors"] += 1
native_param = f"{native_module}.{'bias' if role == 'diff_b' else 'weight'}"
patch = patches.setdefault(native_param, _ParamPatch(layout=layout))
tensor = checkpoint.get_tensor(name)
if role in ("diff", "diff_b"):
if patch.diff is not None:
raise FastH3AdapterError(f"duplicate {role} for {native_param}")
patch.diff = tensor
else:
a, b = patch.low_rank.get(layout, (None, None))
patch.low_rank[layout] = (tensor, b) if role == "lora_a" else (a, tensor)
if unmapped:
raise FastH3AdapterError(
f"FastH3 adapter at {weights_path} has {len(unmapped)} tensors that name no known "
f"H3 parameter: {sorted(unmapped)[:5]}"
)
_check_declared_counts(
metadata,
{**counted, "set_weight_tensors": len(gate_tensors)},
weights_path=weights_path,
)
_check_block_coverage(
blocks_seen,
expected={"blocks.": num_blocks, "token_refiner.blocks.": num_refiner_blocks},
weights_path=weights_path,
)
for native_param, patch in patches.items():
for slot, (a, b) in patch.low_rank.items():
if a is None or b is None:
raise FastH3AdapterError(f"FastH3 adapter has an unpaired factor for {native_param} slot {slot!r}")
# Only the low-rank factors are placed into H3's fused QKV and
# gate/up layouts; a full-rank delta is added as it comes, so one
# aimed at a fused parameter would silently land transposed.
if patch.diff is not None and patch.layout != _PLAIN:
raise FastH3AdapterError(
f"FastH3 adapter carries a full-rank delta for {native_param}, which H3 stores in the "
f"{patch.layout!r} fused layout; this loader can only place low-rank factors there"
)
fusion = cls(
source=weights_path,
patches=patches,
head_dim=head_dim,
requires_vsa=bool(gate_tensors),
)
logger.info(
"FastH3 adapter %s: rank=%s, parameters patched=%d, low-rank=%s, diff=%s, set_weight=%d",
weights_path,
metadata.get("rank", "?"),
len(patches),
metadata.get("low_rank_tensors", "?"),
metadata.get("diff_tensors", "?"),
len(gate_tensors),
)
return fusion
def _compute_device(self, weight: torch.Tensor) -> torch.device:
"""Where to reconstruct a delta.
H3's per-block modulation projection is 96768x2688, so rebuilding all
343 patched parameters is a few TFLOP of rank-64 products. On CPU that
adds minutes to a load that already has a startup deadline, so the
accelerator does the arithmetic whenever there is one.
"""
if weight.device.type != "cpu":
return weight.device
if self._device is None:
# Ask the platform rather than PyTorch's global accelerator
# registry, so an out-of-tree backend controls its own placement.
self._device = current_omni_platform.get_torch_device()
return self._device
@staticmethod
def _widen(tensor: torch.Tensor, device: torch.device) -> torch.Tensor:
"""Move to ``device``, then widen to float32.
Asking ``Tensor.to`` for a device and a dtype at once converts on the
host and ships twice the bytes; splitting it moves bfloat16 and widens
on the accelerator.
"""
return tensor.to(device, non_blocking=True).to(torch.float32)
def fuse(self, name: str, weight: torch.Tensor) -> torch.Tensor:
"""Return ``weight`` with this adapter's contribution added."""
patch = self._patches.get(name)
if patch is None:
return weight
self._applied.add(name)
device = self._compute_device(weight)
delta: torch.Tensor | None = None
if patch.low_rank:
if patch.layout in _QKV_SLOTS:
per_slot = {
slot: self._widen(b, device) @ self._widen(a, device) for slot, (a, b) in patch.low_rank.items()
}
delta = _place_in_grouped_qkv(per_slot, head_dim=self._head_dim)
else:
a, b = patch.low_rank[patch.layout]
if patch.layout == _SWAP_HALVES:
# Permuting the rows of B permutes the rows of the product,
# so swap the rank-64 factor instead of the full delta.
b = _swap_halves(b)
delta = self._widen(b, device) @ self._widen(a, device)
if patch.diff is not None:
diff = self._widen(patch.diff, device)
delta = diff if delta is None else delta + diff
if delta is None:
return weight
if delta.shape != weight.shape:
raise FastH3AdapterError(
f"FastH3 delta for {name} has shape {tuple(delta.shape)}, parameter is {tuple(weight.shape)}"
)
# Leave the result on the compute device. These weights are bound for
# the accelerator anyway, so returning them to host memory would pay a
# device-to-host copy of the whole checkpoint only for the loader to
# send it straight back: measured at 152s against 15s for 60 GiB of
# patched projections, against 17s for the unavoidable upload alone.
# Fold the base weight into the freshly built delta in place. Promoting
# the weight to float32 on its own would allocate two more buffers the
# size of the parameter, and H3's largest patched projection is 0.5 GiB.
return delta.add_(weight.to(device, non_blocking=True)).to(weight.dtype)
def apply(self, weights: Iterable[tuple[str, torch.Tensor]]) -> Iterator[tuple[str, torch.Tensor]]:
"""Fuse every streamed checkpoint tensor on its way into the model."""
if self._applied:
# ``validate_fully_applied`` released the deltas, so a second stream
# would fuse nothing and then pass its own completeness check: the
# server would serve base H3 weights on the student's ladder.
raise FastH3AdapterError(f"{self._source} has already been fused into this checkpoint")
for name, weight in weights:
yield name, self.fuse(name, weight)
def validate_fully_applied(self) -> None:
"""Close the fusion: every edit must have met its parameter.
A silently unapplied delta is the failure mode that matters here: the
model would load and generate, just not as the distilled student. The
weights are loaded once, so the mapped payloads are dropped afterwards
rather than held for the life of the process.
"""
missing = sorted(set(self._patches) - self._applied)
if missing:
raise FastH3AdapterError(
f"FastH3 adapter edits {len(missing)} parameters the checkpoint never provided: {missing[:5]}"
)
for patch in self._patches.values():
patch.low_rank.clear()
patch.diff = None
def check_serving_contract(
self,
*,
partition: str,
od_config: Any,
video_shift: float,
audio_shift: float,
) -> None:
"""Hold a starting server to the ladder this student was trained on."""
if partition == "ref2va":
raise ValueError("FastH3 preview v1 distills T2VA only, so it cannot serve a Ref2VA partition")
offloads = [
flag
for flag in ("enable_cpu_offload", "enable_layerwise_offload", "enable_distributed_layerwise_offload")
if getattr(od_config, flag, False)
]
if offloads:
# A host-weight plan installs the transformer without going through
# load_weights(), which is where the fusion and its completeness
# check live. Serving base H3 weights under a four-step schedule
# would otherwise degrade output with nothing to signal it.
raise ValueError(
f"FastH3 is fused while the checkpoint streams in, so it cannot be combined with "
f"{sorted(offloads)}. Serve it without offload."
)
if self.requires_vsa:
raise ValueError(
f"{self.source} is a Video Sparse Attention variant of FastH3, and its "
"compression gates have no counterpart in vLLM-Omni's dense H3 attention. Use the "
"release's Dense variant until VSA lands for H3's packed sequence."
)
logger.info(
"FastH3 adapter active: sigma points %s for %d transformer forwards, "
"flow_shift=%g, audio_flow_shift=%g, tasks=%s",
list(self.base_schedule),
FASTH3_DENOISE_STEPS,
video_shift,
audio_shift,
sorted(FASTH3_SUPPORTED_TASKS),
)
def check_task(self, task: str) -> None:
"""Refuse a task this preview never distilled."""
if task not in FASTH3_SUPPORTED_TASKS:
raise OmniClientError(
f"FastH3 preview v1 distills {sorted(FASTH3_SUPPORTED_TASKS)} only, got task={task!r}"
)
def check_request(self, sampling: Any, *, video_shift: float, audio_shift: float) -> None:
"""Refuse a request that would sample the student off its rungs."""
if sampling.lora_request is not None:
# The adapter is already in the weights and the dynamic LoRA manager
# is skipped, so nothing would apply the requested one. Serving the
# request anyway would quietly ignore it.
raise OmniClientError(
f"this server fused {self.source} into the checkpoint at startup, so per-request "
"lora is unavailable; drop the lora field"
)
if int(sampling.num_inference_steps or 0) != FASTH3_DENOISE_STEPS:
raise OmniClientError(
f"FastH3 is a four-step student and requires num_inference_steps={FASTH3_DENOISE_STEPS} "
"(one transformer forward per sigma interval)"
)
# The checkpoint's per-modality shifts turn the release's positions into
# the noise levels the student was distilled at, so a request that moves
# them samples where it was never trained.
extra = sampling.extra_args or {}
for key, expected in (("flow_shift", video_shift), ("audio_flow_shift", audio_shift)):
try:
requested = float(extra.get(key, expected))
except (TypeError, ValueError) as exc:
raise OmniClientError(f"FastH3 requires {key}={expected:g}") from exc
if not math.isclose(requested, expected):
raise OmniClientError(f"FastH3 requires {key}={expected:g}, got {requested:g}")
def resolve_fasth3_fusion(od_config: Any, transformer: MiniMaxH3DiTModel) -> FastH3WeightFusion | None:
"""Claim ``--lora-path`` when it points at a FastH3 adapter.
FastH3 rewrites RMSNorm weights and biases, so it cannot be expressed as a
request-switchable LoRA and is fused into the checkpoint instead. Any other
artifact returns None here and stays on the dynamic LoRA route.
"""
lora_path = getattr(od_config, "lora_path", None)
if isinstance(lora_path, (list, tuple)):
if len(lora_path) != 1:
return None
lora_path = lora_path[0]
if not lora_path:
return None
# Placing a delta into the fused QKV parameter needs the head size, and
# completeness is judged against the model's depth, so the architecture is
# only read once an adapter is actually configured.
arch = transformer.arch
return FastH3WeightFusion.from_path(
lora_path,
head_dim=arch.attention_head_dim,
num_blocks=arch.num_layers,
num_refiner_blocks=arch.token_refiner_num_layers,
)
def _safetensors_metadata(path: Path) -> Mapping[str, str]:
"""The header metadata of a safetensors file, or ``{}`` if unreadable."""
try:
with safe_open(path, framework="pt", device="cpu") as checkpoint:
return checkpoint.metadata() or {}
except Exception: # noqa: BLE001 - a path that will not open is simply not ours
return {}
def _resolve_adapter_file(path: str | Path) -> Path | None:
"""Find the single adapter file at ``path``, or ``None``."""
candidate = Path(path)
if candidate.is_file():
return candidate if candidate.suffix == ".safetensors" else None
if not candidate.is_dir():
return None
named = candidate / "adapter_model.safetensors"
if named.is_file():
return named
files = sorted(candidate.glob("*.safetensors"))
if len(files) == 1:
return files[0]
# The published repository bundles four variants under one root, marked by an
# adapter_manifest.json. Such a bundle is ambiguous rather than loadable - but
# only hold that against a directory that actually carries FastH3 artifacts;
# an unrelated multi-shard LoRA stays on the dynamic route via ``None``.
if (candidate / FASTH3_MANIFEST).is_file() or any(
_is_fasth3_release(_safetensors_metadata(file)) for file in files
):
raise FastH3AdapterError(
f"{candidate} holds several FastH3 adapters; point --lora-path at one variant "
"(for example dense-datafree/adapter_model.safetensors)"
)
return None
__all__ = [
"FASTH3_BASE_MODEL",
"FASTH3_BASE_SCHEDULE",
"FASTH3_DENOISE_STEPS",
"FASTH3_FORMAT",
"FASTH3_SUPPORTED_TASKS",
"FastH3AdapterError",
"FastH3WeightFusion",
"resolve_fasth3_fusion",
]
@@ -117,6 +117,7 @@ from .denoise_loop import (
minimax_h3_publish_denoise_progress,
)
from .encoder import MiniMaxH3Qwen3VLEncoder
from .fasth3 import FastH3WeightFusion, resolve_fasth3_fusion
from .lora import load_minimax_h3_turbo_lora
from .minimax_h3_transformer import (
MiniMaxH3Attention,
@@ -354,17 +355,24 @@ def _minimax_h3_post_process(output, output_type: str = "np"):
The callable crosses the multiprocessing result queue, so it must remain a
module-level function that the standard pickle module can resolve.
``_prepare_minimax_h3_video_output`` already quantises the video to uint8
frames on the accelerator, so there is nothing left to scale or transpose
here.
"""
if not isinstance(output, tuple) or len(output) != 2:
return output
video, audio = output
if video.dtype != torch.uint8 or video.ndim != 5 or video.shape[-1] not in (3, 4):
# Float or channel-first frames would reach the muxer as a black or
# banded video rather than as an error.
raise ValueError(
f"MiniMax-H3 post-processing expects (B, T, H, W, C) uint8, got {tuple(video.shape)} {video.dtype}"
)
if output_type == "latent":
return output
if output_type == "np":
if video.dtype == torch.uint8 and video.ndim == 5 and video.shape[-1] == 3:
video = video.detach().cpu().numpy()
else:
video = video.detach().float().cpu().permute(0, 2, 3, 4, 1).clamp(0, 1).numpy()
video = video.detach().cpu().numpy()
audio = audio.detach().float().cpu().numpy()
video = [sample for sample in video]
return {
@@ -678,6 +686,8 @@ class MiniMaxH3Pipeline(
# Only distilled releases pin a schedule, so the default keeps the legacy
# uniform path available to partially constructed pipelines.
_base_schedule_by_partition: ClassVar[Mapping[str, DMD2SigmaSchedule | None]] = {}
# Set from --lora-path during construction; absent means no FastH3 adapter.
_fasth3: FastH3WeightFusion | None = None
def _load_diffusion_lora_adapter(
self,
@@ -948,6 +958,15 @@ class MiniMaxH3Pipeline(
quant_config=transformer_quant_config,
)
self._fasth3 = resolve_fasth3_fusion(od_config, self.transformer)
if self._fasth3 is not None:
self._fasth3.check_serving_contract(
partition=self.partition,
od_config=od_config,
video_shift=self.default_video_shift,
audio_shift=self.default_audio_shift,
)
if self.load_text_encoder:
self.tokenizer = Qwen2TokenizerFast.from_pretrained(
str(model_path),
@@ -1056,7 +1075,12 @@ class MiniMaxH3Pipeline(
raise ValueError(f"MiniMax-H3 weight source {prefix!r} is not contiguous")
loaded_prefixes.add(prefix)
component = getattr(self, prefix.removesuffix("."))
loaded = component.load_weights((name[len(prefix) :], tensor) for name, tensor in grouped_weights)
stream = ((name[len(prefix) :], tensor) for name, tensor in grouped_weights)
if prefix == "transformer." and self._fasth3 is not None:
# Fuse before the model shards anything, which is also the only
# point where the checkpoint's fused QKV/MLP layouts are intact.
stream = self._fasth3.apply(stream)
loaded = component.load_weights(stream)
if prefix != "text_encoder.":
component.post_load_weights()
loaded_with_prefix.update(prefix + name for name in loaded)
@@ -1069,13 +1093,49 @@ class MiniMaxH3Pipeline(
if component is None:
continue
loaded_with_prefix.update(f"{component_name}.{name}" for name, _ in component.named_parameters())
if self._fasth3 is not None:
self._fasth3.validate_fully_applied()
return loaded_with_prefix
@property
def lora_is_fused(self) -> bool:
"""True when --lora-path was consumed as a load-time weight fusion."""
return self._fasth3 is not None
def _transformer_for_task(self, task: str) -> MiniMaxH3DiTModel:
if task == "ref2va" and hasattr(self, "transformers_ref"):
return self.transformers_ref
return self.transformer
def _resolve_sigma_positions(self, task: str, sampling: Any) -> tuple[tuple[float, ...] | None, int]:
"""Pick the rectified-flow positions this request denoises on.
Returns them explicitly, or ``None`` to leave the uniform ladder to be
derived from the step count, together with the count the rest of the
request speaks in.
"""
if self._fasth3 is not None:
# A fused student carries its own positions; the checkpoint
# underneath it is the many-step teacher, whose schedule does not
# apply. Its five points bound four transformer forwards, and
# forwards is the unit ``check_request``, the pinned-schedule branch
# below and Cache-DiT all speak in.
positions = self._fasth3.base_schedule
return positions, len(positions) - 1
sigma_schedule = self._sigma_schedule_for_request(sampling, task)
if sigma_schedule is None:
return None, int(sampling.num_inference_steps or 50)
# The schedule lists sigma boundaries; the denoise loop runs one step
# per interval, and that count is what requests and Cache-DiT speak in.
num_steps = sigma_schedule.num_inference_steps
requested_steps = sampling.num_inference_steps
if requested_steps is not None and int(requested_steps) != num_steps:
raise OmniClientError(
"this MiniMax H3 checkpoint pins a distilled sigma schedule; num_inference_steps "
f"must be {num_steps} or omitted, got {int(requested_steps)}"
)
return sigma_schedule.base_schedule, num_steps
def _base_schedule_for_task(self, task: str) -> DMD2SigmaSchedule | None:
"""Return the distilled schedule of the partition that serves ``task``."""
partition = "ref2va" if task == "ref2va" else "fl2va"
@@ -1109,6 +1169,8 @@ class MiniMaxH3Pipeline(
raise OmniClientError("MiniMax-H3 Turbo LoRA supports T2VA/FL2VA requests only")
if has_native_lora and task != "t2va":
raise OmniClientError("MiniMax-H3 native LoRA supports T2VA requests only")
if self._fasth3 is not None:
self._fasth3.check_task(task)
return task
def _resolve_shape(
@@ -2121,6 +2183,12 @@ class MiniMaxH3Pipeline(
self._validate_turbo_sampling(sampling)
if has_native_lora:
self._validate_native_sampling(sampling, task=task)
if self._fasth3 is not None:
self._fasth3.check_request(
sampling,
video_shift=self.default_video_shift,
audio_shift=self.default_audio_shift,
)
raw_image = multi_modal_data.get("image")
raw_videos = multi_modal_data.get("video")
@@ -2337,22 +2405,7 @@ class MiniMaxH3Pipeline(
ref_audio_t = audio_lengths[0]
seed = int(sampling.seed if sampling.seed is not None else 42)
sigma_schedule = self._sigma_schedule_for_request(sampling, task)
if sigma_schedule is None:
base_schedule = None
num_steps = int(sampling.num_inference_steps or 50)
else:
# The schedule lists sigma boundaries; the denoise loop runs one
# step per interval, and that count is what requests and Cache-DiT
# speak in.
base_schedule = sigma_schedule.base_schedule
num_steps = sigma_schedule.num_inference_steps
requested_steps = sampling.num_inference_steps
if requested_steps is not None and int(requested_steps) != num_steps:
raise OmniClientError(
"this MiniMax H3 checkpoint pins a distilled sigma schedule; num_inference_steps "
f"must be {num_steps} or omitted, got {int(requested_steps)}"
)
base_schedule, num_steps = self._resolve_sigma_positions(task, sampling)
video_shift = float(extra.get("flow_shift", self.default_video_shift))
audio_shift = float(extra.get("audio_flow_shift", self.default_audio_shift))
quality_plan = self._quality_policy.resolve(
+6 -3
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
@@ -37,8 +37,11 @@ class DMD2SigmaSchedule:
# have to be rejected before the monotonicity check rather than after it.
if any(not math.isfinite(value) for value in values):
raise ValueError("DMD2 base_schedule entries must be finite")
if values[0] != 1.0 or values[-1] != 0.0:
raise ValueError("DMD2 base_schedule must start at 1.0 and end at 0.0")
# A student whose training capped the noise level (``max_timestep_ratio``)
# starts just below 1.0 rather than at it, so the opening position is
# bounded rather than pinned; the terminal one is always clean latents.
if not 0.0 < values[0] <= 1.0 or values[-1] != 0.0:
raise ValueError("DMD2 base_schedule must start in (0.0, 1.0] and end at 0.0")
if any(curr <= nxt for curr, nxt in zip(values, values[1:], strict=False)):
raise ValueError("DMD2 base_schedule must be strictly decreasing")
+18 -1
View File
@@ -538,7 +538,16 @@ class DiffusionWorker:
def init_lora_manager(self) -> None:
"""Initialize the LoRA manager for this worker."""
if self.model_runner.pipeline is None:
pipeline = self.model_runner.pipeline
if pipeline is None:
return
# A release whose weights cannot be expressed as switchable LoRA layers
# is fused into the checkpoint while the pipeline loads. There is then
# no adapter left to register, and handing the same path to the manager
# would only fail on a format it does not accept.
if getattr(pipeline, "lora_is_fused", False):
logger.info("LoRA was fused into the checkpoint at load time; skipping the dynamic LoRA manager.")
return
lora_path = self.od_config.lora_path
@@ -739,11 +748,15 @@ class DiffusionWorker:
logger.warning("LoRA activation skipped: %s", exc)
def remove_lora(self, adapter_id: int) -> bool:
if self.lora_manager is None:
return False
return self.lora_manager.remove_adapter(adapter_id)
def add_lora(self, lora_request: LoRARequest) -> bool:
# NOTE (Alex): We have not implemented the API routing
# for the frontend server yet.
if self.lora_manager is None:
return False
return self.lora_manager.add_adapter(lora_request)
def submit_interaction(
@@ -756,9 +769,13 @@ class DiffusionWorker:
self.model_runner.submit_interaction(request_id, interaction)
def list_loras(self) -> list[int]:
if self.lora_manager is None:
return []
return self.lora_manager.list_adapters()
def pin_lora(self, adapter_id: int) -> bool:
if self.lora_manager is None:
return False
return self.lora_manager.pin_adapter(adapter_id)
def sleep(self, level: int = 1) -> int:
@@ -396,7 +396,7 @@ def _normalize_video_tensor(video_tensor: torch.Tensor) -> np.ndarray:
# Cast to float32 first: bf16 (e.g. SANA-WM's refiner output) has no
# numpy dtype, so ``.numpy()`` below raises on it.
video_tensor = video_tensor.float().clamp(-1, 1) * 0.5 + 0.5
else:
elif video_tensor.dtype != torch.uint8:
video_tensor = video_tensor.to(torch.float32) / 255.0
video_array = video_tensor.numpy()
return _normalize_single_video_array(video_array)
@@ -419,7 +419,7 @@ def _normalize_single_video_array(video_array: np.ndarray) -> np.ndarray:
if np.issubdtype(video_array.dtype, np.floating):
if video_array.size and (video_array.min() < 0.0 or video_array.max() > 1.0):
video_array = np.clip(video_array, -1.0, 1.0) * 0.5 + 0.5
elif np.issubdtype(video_array.dtype, np.integer):
elif video_array.dtype != np.uint8 and np.issubdtype(video_array.dtype, np.integer):
video_array = video_array.astype(np.float32) / 255.0
return video_array
@@ -435,7 +435,7 @@ def _normalize_video_array(video_array: np.ndarray) -> list[np.ndarray] | np.nda
def _normalize_frames(frames: list[Any]) -> list[np.ndarray]:
"""Normalize a list of frames into numpy arrays with values in [0,1]."""
"""Normalize a list of frames into numpy arrays, uint8 ones unchanged."""
normalized: list[np.ndarray] = []
for frame in frames:
if isinstance(frame, torch.Tensor):
@@ -453,7 +453,7 @@ def _normalize_frames(frames: list[Any]) -> list[np.ndarray]:
if np.issubdtype(frame_array.dtype, np.floating):
if frame_array.size and (frame_array.min() < 0.0 or frame_array.max() > 1.0):
frame_array = np.clip(frame_array, -1.0, 1.0) * 0.5 + 0.5
elif np.issubdtype(frame_array.dtype, np.integer):
elif frame_array.dtype != np.uint8 and np.issubdtype(frame_array.dtype, np.integer):
frame_array = frame_array.astype(np.float32) / 255.0
normalized.append(frame_array)
@@ -461,7 +461,14 @@ def _normalize_frames(frames: list[Any]) -> list[np.ndarray]:
def _coerce_video_to_frames(video: Any) -> list[np.ndarray]:
"""Convert a video payload into a list of normalized float32 frames."""
"""Convert a video payload into a list of normalized frames.
Frames are float32 in [0, 1], except uint8 ones, which pass through: the
muxer's own dtype is uint8, so normalising here would only pay for a
full-size conversion each way. The direct planar path additionally needs
contiguous channel planes and falls back for interleaved RGB whatever the
dtype; the standard muxer takes the uint8 frames as they are.
"""
if isinstance(video, torch.Tensor):
video_array = _normalize_video_tensor(video)
return list(video_array)