[Feature] Support MiniMax-H3 FlashGen native LoRA with the legacy manager (#6666)
Signed-off-by: Zijun Huang <zijunhua@usc.edu> Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
This commit is contained in:
1 parent
143ccc5a2f
commit
08cdee8dc4
8 files changed
+1389
-6
No files matched your search
@@ -12,15 +12,17 @@ LoRA adapters are lightweight, model-specific fine-tuning weights that can be ap
|
||||
## LoRA Adapter Format
|
||||
|
||||
### PEFT (Parameter-Efficient Fine-Tuning) format (default)
|
||||
|
||||
A typical PEFT-format LoRA adapter directory structure:
|
||||
|
||||
```
|
||||
```text
|
||||
lora_adapter/
|
||||
├── adapter_config.json
|
||||
└── adapter_model.safetensors
|
||||
```
|
||||
|
||||
The `adapter_config.json` file contains metadata about the LoRA adapter, including:
|
||||
|
||||
- `r`: LoRA rank
|
||||
- `lora_alpha`: LoRA alpha scaling factor
|
||||
- `target_modules`: List of module names to apply LoRA to
|
||||
@@ -67,7 +69,7 @@ For distilled few-step LoRAs, pass `lora_backend="distill"` together with one or
|
||||
For Qwen-Image and Wan pipelines, the distill backend calls `pipeline.load_lora_weights(...)` during worker initialization.
|
||||
|
||||
| Pipeline | Supported distilled LoRA repo | Notes |
|
||||
|----------|--------------------------------|-------|
|
||||
| ---------- | -------------------------------- | ------- |
|
||||
| `QwenImagePipeline` | `lightx2v/Qwen-Image-2512-Lightning` | Used with Qwen-Image-2512 Lightning-style few-step inference. |
|
||||
| `Wan22Pipeline` | `lightx2v/Wan2.1-Distill-Loras`, `lightx2v/Wan2.2-Distill-Loras` | Wan2.1 uses one LoRA file. For dual-transformer Wan2.2 MoE, pass high-noise then low-noise LoRA files. |
|
||||
| `Wan22I2VPipeline` | `lightx2v/Wan2.2-Distill-Loras` | For dual-transformer Wan2.2 MoE, pass high-noise then low-noise LoRA files. |
|
||||
@@ -240,6 +242,37 @@ Notes:
|
||||
- Output quality and speed depend on the replacement checkpoints and sampling params you choose.
|
||||
- If you only need to fuse distilled LoRAs into a Wan2.2 checkpoint at load time (without the full LightX2V convert + assemble pipeline), you can instead pass them directly via `--lora-backend distill --lora-path <high>.safetensors <low>.safetensors`. See the [Distill backend](#distill-backend-fuse-distilled-lora-at-init) section above.
|
||||
|
||||
## MiniMax-H3 adapter-declared schedules
|
||||
|
||||
MiniMax-H3 supports two few-step mechanisms that must not be conflated:
|
||||
|
||||
- **Checkpoint-pinned schedule**: a merged release writes `base_schedule` into
|
||||
`model_index.json`. Requests must pass `num_inference_steps` as the interval
|
||||
count (for example `4` for `[1.0, 0.7, 0.4, 0.15, 0.0]`).
|
||||
- **Runtime Turbo LoRA**: LightX2V Turbo artifacts request five sigma points and
|
||||
enforce `flow_shift=6`, `audio_flow_shift=3`.
|
||||
- **Runtime native LoRA**: FlashGen-style artifacts declare
|
||||
`key_format=minimax-h3-native` and embed `base_schedule` in safetensors
|
||||
metadata. When active, the adapter schedule overrides the base checkpoint and
|
||||
requests must use the interval-count contract (`num_inference_steps=4`).
|
||||
Request-mode generation may omit the field and take the count from the adapter
|
||||
schedule; step execution requires it explicitly, because the step scheduler
|
||||
derives the total step count from the request before the adapter schedule is
|
||||
known.
|
||||
|
||||
Native artifacts also declare `qkv_layout=grouped`. The H3 loader reorders fused
|
||||
`qkv_proj` LoRA rows with the same `_reorder_grouped_qkv_to_qkv` helper used for
|
||||
base-weight loading, then binds the packed Q/K/V slices through the legacy PEFT
|
||||
manager without modifying `DiffusionLoRAManager`.
|
||||
|
||||
Both runtime adapters run with distributed layerwise offload in request-mode
|
||||
generation, where the manager keeps the LoRA A/B buffers resident on the compute
|
||||
device while DLO streams the base blocks. MiniMax-H3 step execution still
|
||||
rejects DLO, so `--step-execution` cannot be combined with
|
||||
`--enable-distributed-layerwise-offload`. Model-level CPU offload and standard
|
||||
layerwise offload are rejected in every mode, because the dynamic LoRA tensors
|
||||
are neither parameters nor registered buffers and therefore do not participate
|
||||
in those weight lifecycles.
|
||||
|
||||
## See Also
|
||||
|
||||
|
||||
@@ -860,6 +860,63 @@ fixed HBM usage. Model-level and standard layerwise offload remain unsupported.
|
||||
The five requested sigma points produce the four denoiser evaluations expected
|
||||
by the Turbo artifact.
|
||||
|
||||
### FlashGen native LoRA
|
||||
|
||||
The FlashGen 4-step T2VA artifact uses the native MiniMax-H3 module layout and
|
||||
declares its distilled sigma schedule in safetensors metadata. It is published on
|
||||
[ModelScope](https://modelscope.cn/models/FlashGen/Minimax-H3-4step-lora-flashgen):
|
||||
|
||||
```text
|
||||
FlashGen/Minimax-H3-4step-lora-flashgen/minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors
|
||||
```
|
||||
|
||||
Download only that file:
|
||||
|
||||
```bash
|
||||
python -m pip install modelscope
|
||||
export FLASHGEN_DIR=/path/to/minimax-h3-flashgen-lora
|
||||
export FLASHGEN_FILE=minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors
|
||||
modelscope download FlashGen/Minimax-H3-4step-lora-flashgen \
|
||||
--local_dir "${FLASHGEN_DIR}" \
|
||||
--include "${FLASHGEN_FILE}"
|
||||
export FLASHGEN_LORA="${FLASHGEN_DIR}/${FLASHGEN_FILE}"
|
||||
```
|
||||
|
||||
Start from a non-offloaded or DLO FL2VA server command and add
|
||||
`--task-type fl2va --lora-backend peft --lora-path "${FLASHGEN_LORA}"`.
|
||||
Each request must use T2VA and the distilled interval-count contract:
|
||||
|
||||
```bash
|
||||
-F 'num_inference_steps=4' \
|
||||
-F 'extra_params={"task":"t2va","duration":5.2}' \
|
||||
-F "lora={\"name\":\"h3-flashgen-v1.0\",\"path\":\"${FLASHGEN_LORA}\",\"scale\":1.0}"
|
||||
```
|
||||
|
||||
This path rejects Ref2VA and checkpoints that already pin `base_schedule` in
|
||||
`model_index.json`. The adapter metadata carries
|
||||
`base_schedule=1.0,0.7,0.4,0.15,0.0`, so `num_inference_steps=4` means four
|
||||
denoiser evaluations, not five sigma points. Request-mode generation may omit
|
||||
the field and take the count from the adapter schedule; `--step-execution`
|
||||
requires it explicitly, because the step scheduler reads the total step count
|
||||
off the request at admission, before the adapter schedule is known.
|
||||
|
||||
DLO is supported in request-mode generation on the same terms as the Turbo
|
||||
adapter: the request-switchable LoRA A/B buffers stay resident on the
|
||||
accelerator while DLO streams only the base blocks, so budget for that
|
||||
additional fixed HBM usage. The native artifact is rank 64 over 259 target
|
||||
modules, and its packed `qkv_proj` and `fc1` layers reuse the full-input A
|
||||
tensor per slice while B carries slice-local output rows, so the resident
|
||||
footprint exceeds the on-disk payload; measure it for your parallel layout
|
||||
rather than assuming the checkpoint size. Pure Ulysses replicates the adapter
|
||||
on every rank, while DiT tensor parallelism shards the B buffers. Model-level
|
||||
and standard layerwise offload remain unsupported, and `--step-execution`
|
||||
cannot be combined with `--enable-distributed-layerwise-offload`.
|
||||
|
||||
To validate a deployment, post the same fixed-seed T2VA request twice with the
|
||||
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.
|
||||
|
||||
## Key parameters
|
||||
|
||||
| Parameter | Recommended value | Notes |
|
||||
|
||||
@@ -223,6 +223,8 @@ def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch):
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=False,
|
||||
enable_layerwise_offload=False,
|
||||
@@ -321,6 +323,8 @@ def test_only_an_active_recognized_turbo_adapter_restricts_ref2va(monkeypatch):
|
||||
pipeline.partition = "combined"
|
||||
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
request = _request("generic-peft")
|
||||
|
||||
def load():
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
from vllm.lora.lora_weights import PackedLoRALayerWeights
|
||||
|
||||
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
|
||||
from vllm_omni.diffusion.models.minimax_h3.npu import lora as lora_module
|
||||
from vllm_omni.diffusion.models.minimax_h3.npu.lora import load_minimax_h3_native_lora
|
||||
from vllm_omni.diffusion.sched.sigma_schedule import DMD2SigmaSchedule
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
_TINY_HIDDEN = 2
|
||||
_TINY_FFN = 2
|
||||
_TINY_TIME = 2
|
||||
_TINY_BLOCK_ADALN = 4
|
||||
_TINY_FINAL_ADALN = 3
|
||||
_TINY_RANK = 4
|
||||
_TINY_NUM_QUERY_GROUPS = 2
|
||||
_TINY_HEADS_PER_GROUP = 1
|
||||
_TINY_HEAD_DIM = 1
|
||||
_TINY_QKV_SLICE = _TINY_NUM_QUERY_GROUPS * _TINY_HEADS_PER_GROUP * _TINY_HEAD_DIM
|
||||
# Grouped qkv stores (heads_per_group + 2) * head_dim rows per query group, so
|
||||
# the attention inner size follows from the head layout and cannot be picked
|
||||
# independently: 3 * inner must equal the grouped row count.
|
||||
_TINY_INNER = _TINY_QKV_SLICE
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _use_tiny_native_dimensions(monkeypatch):
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_RANK", _TINY_RANK)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_ALPHA", _TINY_RANK)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_HIDDEN_SIZE", _TINY_HIDDEN)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_ATTENTION_INNER_SIZE", _TINY_INNER)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_FFN_HIDDEN_SIZE", _TINY_FFN)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_TIME_EMBED_DIM", _TINY_TIME)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_BLOCK_ADALN_OUT", _TINY_BLOCK_ADALN)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_FINAL_ADALN_OUT", _TINY_FINAL_ADALN)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_NUM_QUERY_GROUPS", _TINY_NUM_QUERY_GROUPS)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_HEADS_PER_GROUP", _TINY_HEADS_PER_GROUP)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_HEAD_DIM", _TINY_HEAD_DIM)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_QKV_SLICE", _TINY_QKV_SLICE)
|
||||
monkeypatch.setattr(
|
||||
lora_module,
|
||||
"_NATIVE_TARGET_DIMS",
|
||||
{
|
||||
"attn.qkv_proj": (_TINY_HIDDEN, 3 * _TINY_INNER),
|
||||
"attn.out_proj": (_TINY_INNER, _TINY_HIDDEN),
|
||||
"mlp.fc1": (_TINY_HIDDEN, 2 * _TINY_FFN),
|
||||
"mlp.fc2": (_TINY_FFN, _TINY_HIDDEN),
|
||||
"adaln_proj.linear": (_TINY_TIME, _TINY_BLOCK_ADALN),
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(lora_module, "_NATIVE_FINAL_ADALN_DIMS", (_TINY_TIME, _TINY_FINAL_ADALN))
|
||||
monkeypatch.setattr(
|
||||
lora_module,
|
||||
"_NATIVE_EXPECTED_TARGETS",
|
||||
frozenset(
|
||||
[
|
||||
*(
|
||||
f"blocks.{block_index}.{suffix}"
|
||||
for block_index in range(50)
|
||||
for suffix in lora_module._NATIVE_TARGET_SUFFIXES
|
||||
)
|
||||
]
|
||||
+ [
|
||||
*(
|
||||
f"token_refiner.blocks.{block_index}.{suffix}"
|
||||
for block_index in range(2)
|
||||
for suffix in lora_module._NATIVE_TOKEN_REFINER_SUFFIXES
|
||||
)
|
||||
]
|
||||
+ ["final_layer.adaln_proj.linear"]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _request(path) -> LoRARequest:
|
||||
return LoRARequest(
|
||||
lora_name="flashgen",
|
||||
lora_int_id=7,
|
||||
lora_path=str(path),
|
||||
)
|
||||
|
||||
|
||||
def _write_tiny_native(
|
||||
path,
|
||||
*,
|
||||
omit_target: str | None = None,
|
||||
shape_overrides: dict[str, tuple[int, int]] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
rank = _TINY_RANK
|
||||
tensors = {}
|
||||
overrides = shape_overrides or {}
|
||||
for target in sorted(lora_module._NATIVE_EXPECTED_TARGETS):
|
||||
if target == omit_target:
|
||||
continue
|
||||
input_dim, output_dim = lora_module._native_target_dims(target)
|
||||
raw_target = f"transformer.{target}"
|
||||
a_name = f"{raw_target}.lora_A.default.weight"
|
||||
b_name = f"{raw_target}.lora_B.default.weight"
|
||||
tensors[a_name] = torch.ones(overrides.get(a_name, (rank, input_dim)))
|
||||
if target.endswith(".attn.qkv_proj"):
|
||||
grouped_rows = []
|
||||
for group in range(_TINY_NUM_QUERY_GROUPS):
|
||||
grouped_rows.extend(
|
||||
[
|
||||
torch.full((1, rank), float(group * 10 + 1)),
|
||||
torch.full((1, rank), float(group * 10 + 2)),
|
||||
torch.full((1, rank), float(group * 10 + 3)),
|
||||
]
|
||||
)
|
||||
tensors[b_name] = torch.cat(grouped_rows, dim=0)
|
||||
elif target.endswith(".mlp.fc1"):
|
||||
tensors[b_name] = torch.cat(
|
||||
(
|
||||
torch.full((output_dim // 2, rank), 2.0),
|
||||
torch.full((output_dim // 2, rank), 1.0),
|
||||
),
|
||||
dim=0,
|
||||
)
|
||||
else:
|
||||
tensors[b_name] = torch.ones(overrides.get(b_name, (output_dim, rank)))
|
||||
save_file(
|
||||
tensors,
|
||||
str(path),
|
||||
metadata=metadata
|
||||
or {
|
||||
"format": "pt",
|
||||
"key_format": "minimax-h3-native",
|
||||
"qkv_layout": "grouped",
|
||||
"lora_rank": str(rank),
|
||||
"lora_alpha": str(rank),
|
||||
"base_schedule": "1.0,0.7,0.4,0.15,0.0",
|
||||
"tasks": "t2va",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_h3_native_loads_and_packs_qkv_and_fc1(tmp_path):
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path)
|
||||
|
||||
loaded = load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
assert loaded is not None
|
||||
lora_model, peft_helper, schedule = loaded
|
||||
assert peft_helper.r == _TINY_RANK
|
||||
assert schedule.num_inference_steps == 4
|
||||
assert len(lora_model.loras) == 259
|
||||
assert "blocks.0.adaln_proj.linear" in lora_model.loras
|
||||
assert "final_layer.adaln_proj.linear" in lora_model.loras
|
||||
|
||||
# The writer tags group g with q=g*10+1, k=g*10+2, v=g*10+3, so a correct
|
||||
# grouped -> q/k/v permutation yields slices that are uniform per role.
|
||||
qkv = lora_model.get_lora("blocks.0.attn.qkv_proj")
|
||||
assert isinstance(qkv, PackedLoRALayerWeights)
|
||||
torch.testing.assert_close(qkv.lora_b[0], torch.tensor([[1.0] * _TINY_RANK, [11.0] * _TINY_RANK]))
|
||||
torch.testing.assert_close(qkv.lora_b[1], torch.tensor([[2.0] * _TINY_RANK, [12.0] * _TINY_RANK]))
|
||||
torch.testing.assert_close(qkv.lora_b[2], torch.tensor([[3.0] * _TINY_RANK, [13.0] * _TINY_RANK]))
|
||||
|
||||
fc1 = lora_model.get_lora("blocks.0.mlp.fc1")
|
||||
assert isinstance(fc1, PackedLoRALayerWeights)
|
||||
torch.testing.assert_close(fc1.lora_b[0], torch.full((_TINY_FFN, _TINY_RANK), 2.0))
|
||||
torch.testing.assert_close(fc1.lora_b[1], torch.full((_TINY_FFN, _TINY_RANK), 1.0))
|
||||
|
||||
|
||||
def test_h3_native_rejects_bad_metadata_and_ref2va(tmp_path):
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path, metadata={"key_format": "minimax-h3-native", "qkv_layout": "runtime"})
|
||||
with pytest.raises(ValueError, match="qkv_layout='grouped'"):
|
||||
load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
# The exact-filename guard runs before the partition check, so the artifact
|
||||
# has to keep the published name for this to reach the Ref2VA rejection.
|
||||
valid_dir = tmp_path / "valid"
|
||||
valid_dir.mkdir()
|
||||
valid = valid_dir / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(valid)
|
||||
with pytest.raises(ValueError, match="supports T2VA only"):
|
||||
load_minimax_h3_native_lora(
|
||||
partition="ref2va",
|
||||
lora_request=_request(valid),
|
||||
lora_path=valid,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
renamed = tmp_path / "renamed.safetensors"
|
||||
_write_tiny_native(renamed)
|
||||
with pytest.raises(ValueError, match="supports only"):
|
||||
load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(renamed),
|
||||
lora_path=renamed,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def test_h3_native_rejects_truncated_artifact(tmp_path):
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path, omit_target="blocks.49.mlp.fc2")
|
||||
with pytest.raises(ValueError, match="target set does not match"):
|
||||
load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def test_h3_native_rejects_schedule_with_wrong_interval_count(tmp_path):
|
||||
"""A mislabeled schedule must fail closed instead of silently changing steps."""
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(
|
||||
path,
|
||||
metadata={
|
||||
"format": "pt",
|
||||
"key_format": "minimax-h3-native",
|
||||
"qkv_layout": "grouped",
|
||||
"lora_rank": str(_TINY_RANK),
|
||||
"lora_alpha": str(_TINY_RANK),
|
||||
"base_schedule": "1.0,0.5,0.0",
|
||||
"tasks": "t2va",
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match="4-interval base_schedule"):
|
||||
load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_schedule", "message"),
|
||||
[
|
||||
(None, "requires safetensors metadata base_schedule"),
|
||||
("", "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"),
|
||||
],
|
||||
)
|
||||
def test_h3_native_rejects_malformed_schedule_metadata(tmp_path, raw_schedule, message):
|
||||
"""safetensors metadata is string-only, so the loader owns the parse contract."""
|
||||
metadata = {
|
||||
"format": "pt",
|
||||
"key_format": "minimax-h3-native",
|
||||
"qkv_layout": "grouped",
|
||||
"lora_rank": str(_TINY_RANK),
|
||||
"lora_alpha": str(_TINY_RANK),
|
||||
"tasks": "t2va",
|
||||
}
|
||||
if raw_schedule is not None:
|
||||
metadata["base_schedule"] = raw_schedule
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path, metadata=metadata)
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def test_h3_native_declared_file_fails_closed_on_invalid_metadata(tmp_path):
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path, metadata={"key_format": "other"})
|
||||
with pytest.raises(ValueError, match="requires safetensors metadata"):
|
||||
load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_native_schedule_and_task_validation(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._native_lora_adapter_ids = {7}
|
||||
pipeline._lora_sigma_schedules = {7: DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])}
|
||||
pipeline._base_schedule_by_partition = {"fl2va": None}
|
||||
|
||||
sampling = SimpleNamespace(
|
||||
lora_request=LoRARequest("flashgen", 7, "/tmp/native.safetensors"),
|
||||
lora_scale=1.0,
|
||||
num_inference_steps=4,
|
||||
extra_args={},
|
||||
)
|
||||
assert pipeline._sigma_schedule_for_request(sampling, "t2va").num_inference_steps == 4
|
||||
with pytest.raises(OmniClientError, match="num_inference_steps must be 4"):
|
||||
pipeline._validate_native_sampling(
|
||||
SimpleNamespace(lora_request=sampling.lora_request, num_inference_steps=5),
|
||||
task="t2va",
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="supports T2VA requests only"):
|
||||
pipeline._resolve_task(
|
||||
"fl2va",
|
||||
{},
|
||||
has_turbo_lora=False,
|
||||
has_native_lora=True,
|
||||
)
|
||||
|
||||
pipeline._base_schedule_by_partition = {"fl2va": DMD2SigmaSchedule.from_positions([1.0, 0.5, 0.0])}
|
||||
with pytest.raises(OmniClientError, match="already pins base_schedule"):
|
||||
pipeline._sigma_schedule_for_request(sampling, "t2va")
|
||||
|
||||
|
||||
def test_pipeline_native_step_count_follows_adapter_schedule():
|
||||
"""The accepted step count is the adapter's interval count, never a literal."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline._native_lora_adapter_ids = {7}
|
||||
pipeline._lora_sigma_schedules = {7: DMD2SigmaSchedule.from_positions([1.0, 0.5, 0.0])}
|
||||
request = LoRARequest("flashgen", 7, "/tmp/native.safetensors")
|
||||
|
||||
pipeline._validate_native_sampling(
|
||||
SimpleNamespace(lora_request=request, num_inference_steps=2),
|
||||
task="t2va",
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="num_inference_steps must be 2 or omitted, not 3"):
|
||||
pipeline._validate_native_sampling(
|
||||
SimpleNamespace(lora_request=request, num_inference_steps=3),
|
||||
task="t2va",
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="requires num_inference_steps=2"):
|
||||
pipeline._validate_native_sampling(
|
||||
SimpleNamespace(lora_request=request, num_inference_steps=4),
|
||||
task="t2va",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step_execution", [False, True])
|
||||
def test_pipeline_native_omitted_step_count_follows_execution_mode(step_execution):
|
||||
"""Only request mode can resolve an omitted count from the adapter schedule.
|
||||
|
||||
``StepScheduler`` reads ``num_inference_steps`` off the request at admission,
|
||||
before this pipeline sees it, so under step execution an omitted value can
|
||||
only disagree with the denoise loop. The advertised contract has to match.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline._native_lora_adapter_ids = {7}
|
||||
pipeline._lora_sigma_schedules = {7: DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])}
|
||||
pipeline.od_config = SimpleNamespace(step_execution=step_execution)
|
||||
sampling = SimpleNamespace(
|
||||
lora_request=LoRARequest("flashgen", 7, "/tmp/native.safetensors"),
|
||||
num_inference_steps=None,
|
||||
)
|
||||
|
||||
if not step_execution:
|
||||
# Request mode defaults to the adapter's own interval count.
|
||||
pipeline._validate_native_sampling(sampling, task="t2va")
|
||||
return
|
||||
|
||||
with pytest.raises(OmniClientError, match="explicit num_inference_steps=4 under step execution"):
|
||||
pipeline._validate_native_sampling(sampling, task="t2va")
|
||||
# The step-mode message must not offer omission it cannot accept.
|
||||
with pytest.raises(OmniClientError, match=r"must be 4, not 5$"):
|
||||
pipeline._validate_native_sampling(
|
||||
SimpleNamespace(lora_request=sampling.lora_request, num_inference_steps=5),
|
||||
task="t2va",
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_replaces_native_classification_after_reload(monkeypatch, tmp_path):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
|
||||
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path)
|
||||
request = _request(path)
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=False,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
|
||||
loaded = pipeline._load_diffusion_lora_adapter(
|
||||
lora_request=request,
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
assert loaded is not None
|
||||
assert request.lora_int_id in pipeline._native_lora_adapter_ids
|
||||
assert request.lora_int_id in pipeline._lora_sigma_schedules
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", lambda **_: None)
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_native_lora", lambda **_: None)
|
||||
assert pipeline._load_diffusion_lora_adapter(lora_request=request, lora_path=path, dtype=torch.float32) is None
|
||||
assert request.lora_int_id not in pipeline._native_lora_adapter_ids
|
||||
assert request.lora_int_id not in pipeline._lora_sigma_schedules
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offload_mode",
|
||||
[
|
||||
"model-level CPU offload (--enable-cpu-offload)",
|
||||
"layerwise offload (--enable-layerwise-offload)",
|
||||
],
|
||||
)
|
||||
def test_h3_native_rejects_offload_modes(tmp_path, offload_mode):
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path)
|
||||
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
unsupported_offload_mode=offload_mode,
|
||||
)
|
||||
|
||||
|
||||
def test_h3_native_allows_distributed_layerwise_offload(monkeypatch):
|
||||
"""DLO keeps LoRA A/B buffers resident, so native must not fail closed."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=False,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=True,
|
||||
)
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
captured: dict[str, object] = {}
|
||||
schedule = DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])
|
||||
|
||||
def load_native(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return object(), object(), schedule
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", lambda **_: None)
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_native_lora", load_native)
|
||||
|
||||
request = _request("flashgen")
|
||||
loaded = pipeline._load_diffusion_lora_adapter(
|
||||
lora_request=request,
|
||||
lora_path="flashgen",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
assert loaded is not None
|
||||
assert captured["unsupported_offload_mode"] is None
|
||||
assert request.lora_int_id in pipeline._native_lora_adapter_ids
|
||||
assert pipeline._lora_sigma_schedules[request.lora_int_id] is schedule
|
||||
|
||||
|
||||
def test_h3_native_qkv_reorder_matches_base_loader_contract():
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import _reorder_grouped_qkv_to_qkv
|
||||
|
||||
num_query_groups = 56
|
||||
heads_per_group = 1
|
||||
head_dim = 128
|
||||
grouped = torch.arange(num_query_groups * (heads_per_group + 2) * head_dim, dtype=torch.float32)
|
||||
grouped = grouped.reshape(num_query_groups, (heads_per_group + 2) * head_dim)
|
||||
reordered = _reorder_grouped_qkv_to_qkv(
|
||||
grouped.reshape(-1, 1),
|
||||
num_query_groups=num_query_groups,
|
||||
heads_per_group=heads_per_group,
|
||||
head_dim=head_dim,
|
||||
).reshape(-1)
|
||||
q_size = num_query_groups * heads_per_group * head_dim
|
||||
k_size = num_query_groups * head_dim
|
||||
q, k, v = torch.split(reordered, [q_size, k_size, k_size])
|
||||
assert q.reshape(num_query_groups, head_dim)[0, 0] == grouped[0, 0]
|
||||
assert k.reshape(num_query_groups, head_dim)[0, 0] == grouped[0, head_dim]
|
||||
assert v.reshape(num_query_groups, head_dim)[0, 0] == grouped[0, 2 * head_dim]
|
||||
|
||||
|
||||
def test_native_target_pattern_matches_component_qualified_names():
|
||||
"""The manager matches "<component>.<module>", so the pattern needs the prefix."""
|
||||
import regex as re
|
||||
|
||||
pattern = lora_module._NATIVE_TARGET_PATTERN
|
||||
matched = [
|
||||
target
|
||||
for target in lora_module._NATIVE_EXPECTED_TARGETS
|
||||
if re.search(pattern, f"transformer.{target}") is not None
|
||||
]
|
||||
assert len(matched) == 259
|
||||
assert re.search(pattern, "transformer.blocks.0.attn.q_proj") is None
|
||||
assert re.search(pattern, "transformer.token_refiner.blocks.0.adaln_proj.linear") is None
|
||||
|
||||
|
||||
def test_replace_layers_binds_every_native_target(monkeypatch):
|
||||
"""Regression for bound=0/259: layer replacement must reach all 259 modules."""
|
||||
from vllm.lora.layers import BaseLayerWithLoRA
|
||||
from vllm.lora.peft_helper import PEFTHelper
|
||||
|
||||
from vllm_omni.diffusion.lora import manager as manager_module
|
||||
|
||||
class _StubLoRALayer(BaseLayerWithLoRA):
|
||||
def __init__(self, base_layer):
|
||||
super().__init__()
|
||||
self.base_layer = base_layer
|
||||
|
||||
def _linear() -> torch.nn.Module:
|
||||
return torch.nn.Linear(_TINY_HIDDEN, _TINY_HIDDEN)
|
||||
|
||||
def _block(*, with_adaln: bool) -> torch.nn.Module:
|
||||
block = torch.nn.Module()
|
||||
block.attn = torch.nn.Module()
|
||||
block.attn.qkv_proj = _linear()
|
||||
block.attn.out_proj = _linear()
|
||||
block.mlp = torch.nn.Module()
|
||||
block.mlp.fc1 = _linear()
|
||||
block.mlp.fc2 = _linear()
|
||||
if with_adaln:
|
||||
block.adaln_proj = torch.nn.Module()
|
||||
block.adaln_proj.linear = _linear()
|
||||
return block
|
||||
|
||||
transformer = torch.nn.Module()
|
||||
transformer.blocks = torch.nn.ModuleList([_block(with_adaln=True) for _ in range(50)])
|
||||
transformer.token_refiner = torch.nn.Module()
|
||||
transformer.token_refiner.blocks = torch.nn.ModuleList([_block(with_adaln=False) for _ in range(2)])
|
||||
transformer.final_layer = torch.nn.Module()
|
||||
transformer.final_layer.adaln_proj = torch.nn.Module()
|
||||
transformer.final_layer.adaln_proj.linear = _linear()
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline.transformer = transformer
|
||||
|
||||
monkeypatch.setattr(manager_module, "from_layer_diffusion", lambda *, layer, **_: _StubLoRALayer(layer))
|
||||
|
||||
manager = object.__new__(DiffusionLoRAManager)
|
||||
manager.pipeline = pipeline
|
||||
manager.dtype = torch.float32
|
||||
manager.max_cached_adapters = 1
|
||||
manager._lora_modules = {}
|
||||
manager._max_lora_rank = 64
|
||||
manager._resident_lora_device = None
|
||||
|
||||
peft_helper = PEFTHelper.from_dict(
|
||||
{
|
||||
"r": _TINY_RANK,
|
||||
"lora_alpha": _TINY_RANK,
|
||||
"target_modules": lora_module._NATIVE_TARGET_PATTERN,
|
||||
}
|
||||
)
|
||||
manager._replace_layers_with_lora(peft_helper)
|
||||
|
||||
assert set(manager._lora_modules) == {f"transformer.{target}" for target in lora_module._NATIVE_EXPECTED_TARGETS}
|
||||
|
||||
|
||||
def test_legacy_manager_uses_native_loader(tmp_path):
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path)
|
||||
|
||||
class _Pipeline:
|
||||
def _load_diffusion_lora_adapter(self, **kwargs):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
MiniMaxH3Pipeline,
|
||||
)
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=False,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
return pipeline._load_diffusion_lora_adapter(**kwargs)
|
||||
|
||||
manager = object.__new__(DiffusionLoRAManager)
|
||||
manager.pipeline = _Pipeline()
|
||||
manager.dtype = torch.float32
|
||||
manager._expected_lora_modules = {"qkv_proj", "fc1", "adaln_proj.linear"}
|
||||
|
||||
lora_model, peft_helper = manager._load_adapter(_request(path))
|
||||
assert lora_model.id == 7
|
||||
assert peft_helper.lora_alpha == _TINY_RANK
|
||||
assert len(lora_model.loras) == 259
|
||||
|
||||
|
||||
def test_pipeline_schedule_inactive_when_scale_zero():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._native_lora_adapter_ids = {7}
|
||||
pipeline._lora_sigma_schedules = {7: DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])}
|
||||
pipeline._base_schedule_by_partition = {"fl2va": None}
|
||||
|
||||
sampling = SimpleNamespace(
|
||||
lora_request=LoRARequest("flashgen", 7, "/tmp/native.safetensors"),
|
||||
lora_scale=0.0,
|
||||
num_inference_steps=4,
|
||||
)
|
||||
assert pipeline._sigma_schedule_for_request(sampling, "t2va") is None
|
||||
assert not pipeline._has_active_native_lora(sampling)
|
||||
|
||||
|
||||
def test_pipeline_schedule_falls_back_after_eviction(monkeypatch, tmp_path):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
|
||||
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path)
|
||||
request = _request(path)
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=False,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
pipeline._base_schedule_by_partition = {"fl2va": None}
|
||||
|
||||
loaded = pipeline._load_diffusion_lora_adapter(
|
||||
lora_request=request,
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
assert loaded is not None
|
||||
assert request.lora_int_id in pipeline._lora_sigma_schedules
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", lambda **_: None)
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_native_lora", lambda **_: None)
|
||||
assert pipeline._load_diffusion_lora_adapter(lora_request=request, lora_path=path, dtype=torch.float32) is None
|
||||
assert request.lora_int_id not in pipeline._lora_sigma_schedules
|
||||
|
||||
sampling = SimpleNamespace(
|
||||
lora_request=request,
|
||||
lora_scale=1.0,
|
||||
num_inference_steps=4,
|
||||
)
|
||||
assert pipeline._sigma_schedule_for_request(sampling, "t2va") is None
|
||||
|
||||
|
||||
def test_native_packed_qkv_slices_are_tp2_divisible():
|
||||
assert lora_module._NATIVE_QKV_SLICE % 2 == 0
|
||||
assert lora_module._NATIVE_FFN_HIDDEN_SIZE % 2 == 0
|
||||
|
||||
|
||||
def test_lora_manager_activates_native_packed_qkv(tmp_path):
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path)
|
||||
loaded = load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
assert loaded is not None
|
||||
lora_model, _, _ = loaded
|
||||
packed = lora_model.get_lora("blocks.0.attn.qkv_proj")
|
||||
assert isinstance(packed, PackedLoRALayerWeights)
|
||||
assert len(packed.lora_b) == 3
|
||||
assert all(b.shape[0] == _TINY_QKV_SLICE for b in packed.lora_b)
|
||||
|
||||
class _DummyLoRALayer:
|
||||
def __init__(self):
|
||||
self.n_slices = 3
|
||||
self.output_slices = (_TINY_QKV_SLICE, _TINY_QKV_SLICE, _TINY_QKV_SLICE)
|
||||
self.set_calls: list[tuple[list, list]] = []
|
||||
self.reset_calls = 0
|
||||
|
||||
def reset_lora(self, index: int):
|
||||
self.reset_calls += 1
|
||||
|
||||
def set_lora(self, index: int, lora_a, lora_b):
|
||||
self.set_calls.append((lora_a, lora_b))
|
||||
|
||||
layer = _DummyLoRALayer()
|
||||
manager = object.__new__(DiffusionLoRAManager)
|
||||
manager.pipeline = torch.nn.Module()
|
||||
manager._lora_modules = {"blocks.0.attn.qkv_proj": layer}
|
||||
manager._registered_adapters = {
|
||||
lora_model.id: lora_model,
|
||||
}
|
||||
manager._active_adapter_id = None
|
||||
manager._adapter_scales = {}
|
||||
manager._activate_adapter(lora_model.id, scale=0.5)
|
||||
|
||||
assert manager._active_adapter_id == lora_model.id
|
||||
|
||||
assert len(layer.set_calls) == 1
|
||||
lora_a_list, lora_b_list = layer.set_calls[0]
|
||||
assert len(lora_a_list) == 3
|
||||
assert len(lora_b_list) == 3
|
||||
torch.testing.assert_close(lora_b_list[0], packed.lora_b[0] * 0.5)
|
||||
torch.testing.assert_close(lora_b_list[1], packed.lora_b[1] * 0.5)
|
||||
torch.testing.assert_close(lora_b_list[2], packed.lora_b[2] * 0.5)
|
||||
|
||||
|
||||
def test_lora_manager_tp2_splits_native_packed_qkv_per_rank():
|
||||
"""Each Q/K/V slice must be divisible by TP2 so rank-local layers can shard rows."""
|
||||
|
||||
tp_size = 2
|
||||
slice_rows = lora_module._NATIVE_QKV_SLICE
|
||||
assert slice_rows % tp_size == 0
|
||||
local_rows = slice_rows // tp_size
|
||||
|
||||
class _TpQkvLoRALayer:
|
||||
def __init__(self, tp_rank: int):
|
||||
self.tp_rank = tp_rank
|
||||
self.n_slices = 3
|
||||
self.output_slices = (local_rows, local_rows, local_rows)
|
||||
self.set_calls: list[tuple[list, list]] = []
|
||||
|
||||
def reset_lora(self, index: int):
|
||||
return
|
||||
|
||||
def set_lora(self, index: int, lora_a, lora_b):
|
||||
self.set_calls.append((lora_a, lora_b))
|
||||
|
||||
for tp_rank in range(tp_size):
|
||||
layer = _TpQkvLoRALayer(tp_rank)
|
||||
start = tp_rank * local_rows
|
||||
end = start + local_rows
|
||||
full_packed = PackedLoRALayerWeights(
|
||||
module_name="blocks.0.attn.qkv_proj",
|
||||
rank=lora_module._NATIVE_RANK,
|
||||
lora_alphas=[64, 64, 64],
|
||||
lora_a=[torch.ones(lora_module._NATIVE_RANK, lora_module._NATIVE_HIDDEN_SIZE)] * 3,
|
||||
lora_b=[torch.full((slice_rows, lora_module._NATIVE_RANK), float(i + 1)) for i in range(3)],
|
||||
scaling=[1.0, 1.0, 1.0],
|
||||
)
|
||||
local_b = [b[start:end].contiguous() for b in full_packed.lora_b]
|
||||
layer.set_lora(0, full_packed.lora_a, local_b)
|
||||
assert len(layer.set_calls) == 1
|
||||
_, lora_b_list = layer.set_calls[0]
|
||||
assert all(b.shape[0] == local_rows for b in lora_b_list)
|
||||
assert lora_b_list[0][0, 0].item() == 1.0
|
||||
assert lora_b_list[1][0, 0].item() == 2.0
|
||||
assert lora_b_list[2][0, 0].item() == 3.0
|
||||
|
||||
|
||||
# 259 modules, of which 52 qkv expand to 3 slices and 52 fc1 expand to 2.
|
||||
_EXPECTED_NATIVE_SLICE_PAIRS = 415
|
||||
|
||||
|
||||
def _iter_native_slice_pairs(lora_model):
|
||||
"""Yield ``(module_name, lora_a, lora_b)`` per slice, flattening packed weights."""
|
||||
for module_name, weights in lora_model.loras.items():
|
||||
lora_a = weights.lora_a if isinstance(weights.lora_a, list) else [weights.lora_a]
|
||||
lora_b = weights.lora_b if isinstance(weights.lora_b, list) else [weights.lora_b]
|
||||
for a, b in zip(lora_a, lora_b, strict=True):
|
||||
yield module_name, a, b
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
def test_native_lora_load_is_platform_agnostic(tmp_path, dtype):
|
||||
"""The artifact is trained on Ascend, but loading it is not Ascend-only.
|
||||
|
||||
This whole module runs in the CPU job with no ``torch_npu`` present, so a
|
||||
passing run is itself the evidence. The assertions pin the property that
|
||||
makes it portable: tensors land on CPU in exactly the requested dtype and
|
||||
the loader never picks a device, leaving placement to the pipeline.
|
||||
"""
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path)
|
||||
|
||||
loaded = load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=dtype,
|
||||
)
|
||||
|
||||
assert loaded is not None
|
||||
lora_model, _, _ = loaded
|
||||
tensors = {
|
||||
f"{module_name}.{side}": tensor
|
||||
for module_name, a, b in _iter_native_slice_pairs(lora_model)
|
||||
for side, tensor in (("lora_a", a), ("lora_b", b))
|
||||
}
|
||||
assert sum(1 for _ in _iter_native_slice_pairs(lora_model)) == _EXPECTED_NATIVE_SLICE_PAIRS
|
||||
assert {name: tensor.device.type for name, tensor in tensors.items() if tensor.device.type != "cpu"} == {}
|
||||
assert {name: tensor.dtype for name, tensor in tensors.items() if tensor.dtype != dtype} == {}
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires a CUDA device")
|
||||
def test_native_lora_delta_matches_between_cpu_and_cuda(tmp_path):
|
||||
"""The packed adapter produces the same delta weight on CUDA as on CPU.
|
||||
|
||||
Covers the GPU side of the same artifact: every ``lora_b @ lora_a`` product,
|
||||
including the reordered q/k/v slices, is recomputed on CUDA and compared
|
||||
against the CPU result, so a GPU deployment cannot silently diverge.
|
||||
"""
|
||||
path = tmp_path / "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_native(path)
|
||||
|
||||
loaded = load_minimax_h3_native_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
assert loaded is not None
|
||||
lora_model, _, _ = loaded
|
||||
checked = 0
|
||||
for module_name, a, b in _iter_native_slice_pairs(lora_model):
|
||||
cpu_delta = b @ a
|
||||
cuda_delta = b.cuda() @ a.cuda()
|
||||
assert cuda_delta.device.type == "cuda"
|
||||
torch.testing.assert_close(cuda_delta.cpu(), cpu_delta, msg=f"delta mismatch for {module_name}")
|
||||
checked += 1
|
||||
assert checked == _EXPECTED_NATIVE_SLICE_PAIRS
|
||||
@@ -1,6 +1,13 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Loader for the LightX2V Turbo MiniMax-H3 LoRA.
|
||||
|
||||
Keyed by ``key_format=minimax-h3-diffusers``. The native FlashGen contract lives
|
||||
in ``.npu.lora``; the two artifacts differ in rank, target naming and QKV
|
||||
layout, so each owns its own parsing and packing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""MiniMax-H3 components for artifacts produced on Ascend NPU.
|
||||
|
||||
The package name records where these artifacts come from, not where they can
|
||||
run. Everything here is plain ``torch`` plus ``safetensors`` and carries no
|
||||
``torch_npu`` dependency, so the same checkpoint loads and serves on CUDA and
|
||||
CPU as well. See ``tests/diffusion/models/minimax_h3/test_minimax_h3_native_lora.py``
|
||||
for the platform-agnostic and CUDA coverage that pins this.
|
||||
"""
|
||||
|
||||
from .lora import MINIMAX_H3_NATIVE_INFERENCE_STEPS, load_minimax_h3_native_lora
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_NATIVE_INFERENCE_STEPS",
|
||||
"load_minimax_h3_native_lora",
|
||||
]
|
||||
@@ -0,0 +1,304 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Loader for the native-layout MiniMax-H3 distilled LoRA (FlashGen v1.0).
|
||||
|
||||
This contract is keyed by ``key_format=minimax-h3-native`` and is distinct from
|
||||
the LightX2V Turbo contract in the sibling ``..lora`` module: it keeps the fused
|
||||
``qkv_proj``, adds ``adaln_proj.linear``, uses rank 64, and declares its own
|
||||
rectified-flow schedule in safetensors metadata.
|
||||
|
||||
The artifact is trained on Ascend NPU, but nothing below is NPU-specific. Only
|
||||
``torch`` and ``safetensors`` are used, tensors are read on CPU, and the compute
|
||||
device is chosen later by the pipeline, so the same file serves on CUDA too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from safetensors import safe_open
|
||||
from vllm.lora.lora_model import LoRAModel
|
||||
from vllm.lora.lora_weights import PackedLoRALayerWeights
|
||||
from vllm.lora.peft_helper import PEFTHelper
|
||||
from vllm.model_executor.models.utils import WeightsMapper
|
||||
|
||||
from vllm_omni.diffusion.sched.sigma_schedule import BASE_SCHEDULE_KEY, DMD2SigmaSchedule
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
|
||||
from ..minimax_h3_transformer import _reorder_grouped_qkv_to_qkv
|
||||
|
||||
_LORA_A_SUFFIX = ".lora_A.default.weight"
|
||||
_LORA_B_SUFFIX = ".lora_B.default.weight"
|
||||
|
||||
_NATIVE_RANK = 64
|
||||
_NATIVE_ALPHA = 64
|
||||
_NATIVE_HIDDEN_SIZE = 5376
|
||||
_NATIVE_ATTENTION_INNER_SIZE = 7168
|
||||
_NATIVE_FFN_HIDDEN_SIZE = 14336
|
||||
_NATIVE_TIME_EMBED_DIM = 2688
|
||||
_NATIVE_BLOCK_ADALN_OUT = 96768
|
||||
_NATIVE_FINAL_ADALN_OUT = 10752
|
||||
_NATIVE_NUM_QUERY_GROUPS = 56
|
||||
_NATIVE_HEADS_PER_GROUP = 1
|
||||
_NATIVE_HEAD_DIM = 128
|
||||
_NATIVE_QKV_SLICE = _NATIVE_ATTENTION_INNER_SIZE
|
||||
_NATIVE_KEY_FORMAT = "minimax-h3-native"
|
||||
_NATIVE_QKV_LAYOUT = "grouped"
|
||||
# Public because request validation in the pipeline speaks the same contract.
|
||||
MINIMAX_H3_NATIVE_INFERENCE_STEPS = 4
|
||||
_NATIVE_FILENAME = "minimax_h3_t2va_flashgen_4step_v1.0_768p_bf16.safetensors"
|
||||
_NATIVE_TARGET_SUFFIXES = (
|
||||
"attn.qkv_proj",
|
||||
"attn.out_proj",
|
||||
"mlp.fc1",
|
||||
"mlp.fc2",
|
||||
"adaln_proj.linear",
|
||||
)
|
||||
_NATIVE_TOKEN_REFINER_SUFFIXES = (
|
||||
"attn.qkv_proj",
|
||||
"attn.out_proj",
|
||||
"mlp.fc1",
|
||||
"mlp.fc2",
|
||||
)
|
||||
_NATIVE_TARGET_DIMS = {
|
||||
"attn.qkv_proj": (_NATIVE_HIDDEN_SIZE, 3 * _NATIVE_ATTENTION_INNER_SIZE),
|
||||
"attn.out_proj": (_NATIVE_ATTENTION_INNER_SIZE, _NATIVE_HIDDEN_SIZE),
|
||||
"mlp.fc1": (_NATIVE_HIDDEN_SIZE, 2 * _NATIVE_FFN_HIDDEN_SIZE),
|
||||
"mlp.fc2": (_NATIVE_FFN_HIDDEN_SIZE, _NATIVE_HIDDEN_SIZE),
|
||||
"adaln_proj.linear": (_NATIVE_TIME_EMBED_DIM, _NATIVE_BLOCK_ADALN_OUT),
|
||||
}
|
||||
_NATIVE_FINAL_ADALN_DIMS = (_NATIVE_TIME_EMBED_DIM, _NATIVE_FINAL_ADALN_OUT)
|
||||
_NATIVE_EXPECTED_TARGETS = frozenset(
|
||||
[*(f"blocks.{block_index}.{suffix}" for block_index in range(50) for suffix in _NATIVE_TARGET_SUFFIXES)]
|
||||
+ [
|
||||
*(
|
||||
f"token_refiner.blocks.{block_index}.{suffix}"
|
||||
for block_index in range(2)
|
||||
for suffix in _NATIVE_TOKEN_REFINER_SUFFIXES
|
||||
)
|
||||
]
|
||||
+ ["final_layer.adaln_proj.linear"]
|
||||
)
|
||||
_NATIVE_TARGET_PATTERN = (
|
||||
r"^(?:transformer\.blocks\.\d+\.(?:attn\.(?:qkv_proj|out_proj)|mlp\.(?:fc1|fc2)|adaln_proj\.linear)"
|
||||
r"|transformer\.token_refiner\.blocks\.\d+\.(?:attn\.(?:qkv_proj|out_proj)|mlp\.(?:fc1|fc2))"
|
||||
r"|transformer\.final_layer\.adaln_proj\.linear)$"
|
||||
)
|
||||
_NATIVE_WEIGHTS_MAPPER = WeightsMapper(
|
||||
orig_to_new_substr={
|
||||
"transformer.": "",
|
||||
".lora_A.default.": ".lora_A.",
|
||||
".lora_B.default.": ".lora_B.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _select_native_file(artifact_path: str | Path) -> Path | None:
|
||||
path = Path(artifact_path)
|
||||
if path.is_file():
|
||||
return path if path.suffix == ".safetensors" else None
|
||||
if not path.is_dir():
|
||||
return None
|
||||
|
||||
candidate = path / _NATIVE_FILENAME
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def _native_target_dims(target: str) -> tuple[int, int]:
|
||||
if target == "final_layer.adaln_proj.linear":
|
||||
return _NATIVE_FINAL_ADALN_DIMS
|
||||
if target.endswith("adaln_proj.linear"):
|
||||
return _NATIVE_TARGET_DIMS["adaln_proj.linear"]
|
||||
for suffix in ("attn.qkv_proj", "attn.out_proj", "mlp.fc1", "mlp.fc2"):
|
||||
if target.endswith(suffix):
|
||||
return _NATIVE_TARGET_DIMS[suffix]
|
||||
raise ValueError(f"Unsupported MiniMax-H3 native target: {target!r}")
|
||||
|
||||
|
||||
def _parse_native_base_schedule(metadata: Mapping[str, str]) -> DMD2SigmaSchedule | None:
|
||||
"""Read the adapter-declared schedule from safetensors string metadata.
|
||||
|
||||
safetensors metadata is string-only, so the native artifact ships the sigma
|
||||
positions comma separated. Absent means the adapter declares no schedule.
|
||||
"""
|
||||
raw = metadata.get(BASE_SCHEDULE_KEY)
|
||||
if raw is None:
|
||||
return None
|
||||
positions = [part.strip() for part in str(raw).split(",") if part.strip()]
|
||||
if not positions:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA {BASE_SCHEDULE_KEY} must not be empty")
|
||||
try:
|
||||
return DMD2SigmaSchedule.from_positions([float(position) for position in positions])
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA {BASE_SCHEDULE_KEY} is malformed: {exc}") from exc
|
||||
|
||||
|
||||
def _validate_native_metadata(metadata: Mapping[str, str]) -> DMD2SigmaSchedule:
|
||||
if metadata.get("key_format") != _NATIVE_KEY_FORMAT:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA requires safetensors metadata key_format={_NATIVE_KEY_FORMAT!r}")
|
||||
if metadata.get("qkv_layout") != _NATIVE_QKV_LAYOUT:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA requires safetensors metadata qkv_layout={_NATIVE_QKV_LAYOUT!r}")
|
||||
try:
|
||||
rank = int(metadata.get("lora_rank", ""))
|
||||
alpha = float(metadata.get("lora_alpha", ""))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("MiniMax-H3 native LoRA metadata lora_rank/lora_alpha must be numeric") from exc
|
||||
if rank != _NATIVE_RANK:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA requires lora_rank={_NATIVE_RANK}, got {rank!r}")
|
||||
if alpha != _NATIVE_ALPHA:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA requires lora_alpha={_NATIVE_ALPHA}, got {alpha!r}")
|
||||
tasks = {part.strip().lower() for part in str(metadata.get("tasks", "")).split(",") if part.strip()}
|
||||
if tasks != {"t2va"}:
|
||||
raise ValueError("MiniMax-H3 native LoRA v1.0 supports tasks=t2va only")
|
||||
schedule = _parse_native_base_schedule(metadata)
|
||||
if schedule is None:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA requires safetensors metadata {BASE_SCHEDULE_KEY}")
|
||||
# Request validation speaks in interval counts, so a mislabeled schedule
|
||||
# would otherwise silently change the step count instead of failing here.
|
||||
if schedule.num_inference_steps != MINIMAX_H3_NATIVE_INFERENCE_STEPS:
|
||||
raise ValueError(
|
||||
f"MiniMax-H3 native LoRA v1.0 requires a {MINIMAX_H3_NATIVE_INFERENCE_STEPS}-interval base_schedule "
|
||||
f"({MINIMAX_H3_NATIVE_INFERENCE_STEPS + 1} sigma positions), got {schedule.num_inference_steps}"
|
||||
)
|
||||
return schedule
|
||||
|
||||
|
||||
def _validate_native_tensors(checkpoint) -> dict[str, torch.Tensor]:
|
||||
tensors: dict[str, torch.Tensor] = {}
|
||||
pairs: dict[str, set[str]] = {}
|
||||
raw_targets: set[str] = set()
|
||||
for name in checkpoint.keys():
|
||||
if name.endswith(_LORA_A_SUFFIX):
|
||||
raw_target = name[: -len(_LORA_A_SUFFIX)]
|
||||
side = "a"
|
||||
elif name.endswith(_LORA_B_SUFFIX):
|
||||
raw_target = name[: -len(_LORA_B_SUFFIX)]
|
||||
side = "b"
|
||||
else:
|
||||
raise ValueError(f"Unconsumed MiniMax-H3 native tensor: {name!r}")
|
||||
if not raw_target.startswith("transformer."):
|
||||
raise ValueError(f"MiniMax-H3 native LoRA tensors must use transformer.* keys, got {name!r}")
|
||||
mapped_target = raw_target.removeprefix("transformer.")
|
||||
raw_targets.add(mapped_target)
|
||||
|
||||
target_sides = pairs.setdefault(mapped_target, set())
|
||||
if side in target_sides:
|
||||
raise ValueError(f"Duplicate MiniMax-H3 native tensor for {mapped_target}.{side}")
|
||||
target_sides.add(side)
|
||||
|
||||
tensor = checkpoint.get_tensor(name)
|
||||
if tensor.ndim != 2:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA tensors must be matrices, got {name}={tuple(tensor.shape)}")
|
||||
input_dim, output_dim = _native_target_dims(mapped_target)
|
||||
expected_shape = (_NATIVE_RANK, input_dim) if side == "a" else (output_dim, _NATIVE_RANK)
|
||||
if tuple(tensor.shape) != expected_shape:
|
||||
raise ValueError(
|
||||
f"MiniMax-H3 native tensor has invalid global shape: {name}={tuple(tensor.shape)}, "
|
||||
f"expected={expected_shape}"
|
||||
)
|
||||
tensors[name] = tensor
|
||||
|
||||
incomplete = sorted(target for target, sides in pairs.items() if sides != {"a", "b"})
|
||||
if incomplete:
|
||||
raise ValueError(f"Incomplete MiniMax-H3 native LoRA pairs: {incomplete}")
|
||||
missing = sorted(_NATIVE_EXPECTED_TARGETS - raw_targets)
|
||||
unexpected = sorted(raw_targets - _NATIVE_EXPECTED_TARGETS)
|
||||
if missing or unexpected:
|
||||
raise ValueError(
|
||||
"MiniMax-H3 native target set does not match the supported v1.0 artifact: "
|
||||
f"missing={len(missing)} {missing[:5]}, unexpected={len(unexpected)} {unexpected[:5]}"
|
||||
)
|
||||
return tensors
|
||||
|
||||
|
||||
def _pack_h3_native_fc1(lora_model: LoRAModel) -> None:
|
||||
for module_name, weights in tuple(lora_model.loras.items()):
|
||||
if not module_name.endswith(".mlp.fc1"):
|
||||
continue
|
||||
gate_b, up_b = weights.lora_b.chunk(2, dim=0)
|
||||
lora_model.loras[module_name] = PackedLoRALayerWeights(
|
||||
module_name=module_name,
|
||||
rank=weights.rank,
|
||||
lora_alphas=[weights.lora_alpha, weights.lora_alpha],
|
||||
lora_a=[weights.lora_a, weights.lora_a],
|
||||
lora_b=[gate_b.contiguous(), up_b.contiguous()],
|
||||
scaling=[weights.scaling, weights.scaling],
|
||||
)
|
||||
|
||||
|
||||
def _pack_h3_native_qkv(lora_model: LoRAModel) -> None:
|
||||
for module_name, weights in tuple(lora_model.loras.items()):
|
||||
if not module_name.endswith(".attn.qkv_proj"):
|
||||
continue
|
||||
reordered_b = _reorder_grouped_qkv_to_qkv(
|
||||
weights.lora_b,
|
||||
num_query_groups=_NATIVE_NUM_QUERY_GROUPS,
|
||||
heads_per_group=_NATIVE_HEADS_PER_GROUP,
|
||||
head_dim=_NATIVE_HEAD_DIM,
|
||||
)
|
||||
q_b, k_b, v_b = torch.split(
|
||||
reordered_b,
|
||||
[_NATIVE_QKV_SLICE, _NATIVE_QKV_SLICE, _NATIVE_QKV_SLICE],
|
||||
dim=0,
|
||||
)
|
||||
lora_model.loras[module_name] = PackedLoRALayerWeights(
|
||||
module_name=module_name,
|
||||
rank=weights.rank,
|
||||
lora_alphas=[weights.lora_alpha, weights.lora_alpha, weights.lora_alpha],
|
||||
lora_a=[weights.lora_a, weights.lora_a, weights.lora_a],
|
||||
lora_b=[q_b.contiguous(), k_b.contiguous(), v_b.contiguous()],
|
||||
scaling=[weights.scaling, weights.scaling, weights.scaling],
|
||||
)
|
||||
|
||||
|
||||
def load_minimax_h3_native_lora(
|
||||
*,
|
||||
partition: str,
|
||||
lora_request: LoRARequest,
|
||||
lora_path: str | Path,
|
||||
dtype: torch.dtype,
|
||||
unsupported_offload_mode: str | None = None,
|
||||
) -> tuple[LoRAModel, PEFTHelper, DMD2SigmaSchedule] | None:
|
||||
"""Load a native-layout MiniMax-H3 distilled LoRA through the legacy manager."""
|
||||
|
||||
lora_file = _select_native_file(lora_path)
|
||||
if lora_file is None:
|
||||
return None
|
||||
with safe_open(lora_file, framework="pt", device="cpu") as checkpoint:
|
||||
metadata = checkpoint.metadata() or {}
|
||||
if metadata.get("key_format") != _NATIVE_KEY_FORMAT:
|
||||
if lora_file.name == _NATIVE_FILENAME:
|
||||
raise ValueError(
|
||||
f"MiniMax-H3 native LoRA requires safetensors metadata key_format={_NATIVE_KEY_FORMAT!r}"
|
||||
)
|
||||
return None
|
||||
if lora_file.name != _NATIVE_FILENAME:
|
||||
raise ValueError(f"MiniMax-H3 native LoRA supports only {_NATIVE_FILENAME!r}, got {lora_file.name!r}")
|
||||
sigma_schedule = _validate_native_metadata(metadata)
|
||||
if partition == "ref2va":
|
||||
raise ValueError("MiniMax-H3 native LoRA supports T2VA only")
|
||||
if unsupported_offload_mode is not None:
|
||||
raise ValueError(f"MiniMax-H3 native dynamic LoRA does not support {unsupported_offload_mode}")
|
||||
tensors = _validate_native_tensors(checkpoint)
|
||||
|
||||
peft_helper = PEFTHelper.from_dict(
|
||||
{
|
||||
"r": _NATIVE_RANK,
|
||||
"lora_alpha": _NATIVE_ALPHA,
|
||||
"target_modules": _NATIVE_TARGET_PATTERN,
|
||||
}
|
||||
)
|
||||
lora_model = LoRAModel.from_lora_tensors(
|
||||
lora_model_id=lora_request.lora_int_id,
|
||||
tensors=tensors,
|
||||
peft_helper=peft_helper,
|
||||
device="cpu",
|
||||
dtype=dtype,
|
||||
weights_mapper=_NATIVE_WEIGHTS_MAPPER,
|
||||
)
|
||||
_pack_h3_native_qkv(lora_model)
|
||||
_pack_h3_native_fc1(lora_model)
|
||||
return lora_model, peft_helper, sigma_schedule
|
||||
@@ -123,6 +123,10 @@ from .minimax_h3_transformer import (
|
||||
MiniMaxH3DiTModel,
|
||||
_attention_isolates_packed_requests,
|
||||
)
|
||||
from .npu.lora import (
|
||||
MINIMAX_H3_NATIVE_INFERENCE_STEPS,
|
||||
load_minimax_h3_native_lora,
|
||||
)
|
||||
from .packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
@@ -672,6 +676,8 @@ class MiniMaxH3Pipeline(
|
||||
# A cache eviction may be followed by a different adapter reusing the
|
||||
# same client-supplied ID. Every real load replaces the classification.
|
||||
self._turbo_lora_adapter_ids.discard(lora_request.lora_int_id)
|
||||
self._native_lora_adapter_ids.discard(lora_request.lora_int_id)
|
||||
self._lora_sigma_schedules.pop(lora_request.lora_int_id, None)
|
||||
od_config = getattr(self, "od_config", None)
|
||||
offload_modes = []
|
||||
if getattr(od_config, "enable_cpu_offload", False):
|
||||
@@ -687,7 +693,25 @@ class MiniMaxH3Pipeline(
|
||||
)
|
||||
if loaded is not None:
|
||||
self._turbo_lora_adapter_ids.add(lora_request.lora_int_id)
|
||||
return loaded
|
||||
return loaded
|
||||
|
||||
# Selection is by the artifact's safetensors ``key_format``, not by the
|
||||
# running platform: the native loader is checkpoint-format parsing with
|
||||
# no ``torch_npu`` dependency, so it needs no ``current_omni_platform``
|
||||
# dispatch and binds the same adapter on NPU, CUDA and CPU.
|
||||
native_loaded = load_minimax_h3_native_lora(
|
||||
partition=self.partition,
|
||||
lora_request=lora_request,
|
||||
lora_path=lora_path,
|
||||
dtype=dtype,
|
||||
unsupported_offload_mode=" or ".join(offload_modes) or None,
|
||||
)
|
||||
if native_loaded is not None:
|
||||
lora_model, peft_helper, sigma_schedule = native_loaded
|
||||
self._native_lora_adapter_ids.add(lora_request.lora_int_id)
|
||||
self._lora_sigma_schedules[lora_request.lora_int_id] = sigma_schedule
|
||||
return lora_model, peft_helper
|
||||
return None
|
||||
|
||||
def _validate_diffusion_lora_binding(
|
||||
self,
|
||||
@@ -695,12 +719,20 @@ class MiniMaxH3Pipeline(
|
||||
lora_model: LoRAModel,
|
||||
bound_lora_names: frozenset[str],
|
||||
) -> None:
|
||||
if lora_model.id not in self._turbo_lora_adapter_ids:
|
||||
if lora_model.id in self._turbo_lora_adapter_ids:
|
||||
missing = sorted(set(lora_model.loras) - bound_lora_names)
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"MiniMax-H3 Turbo LoRA binding is incomplete: "
|
||||
f"bound={len(bound_lora_names)}/{len(lora_model.loras)}, missing={missing[:5]}"
|
||||
)
|
||||
return
|
||||
if lora_model.id not in self._native_lora_adapter_ids:
|
||||
return
|
||||
missing = sorted(set(lora_model.loras) - bound_lora_names)
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"MiniMax-H3 Turbo LoRA binding is incomplete: "
|
||||
"MiniMax-H3 native LoRA binding is incomplete: "
|
||||
f"bound={len(bound_lora_names)}/{len(lora_model.loras)}, missing={missing[:5]}"
|
||||
)
|
||||
|
||||
@@ -712,6 +744,65 @@ class MiniMaxH3Pipeline(
|
||||
and lora_request.lora_int_id in self._turbo_lora_adapter_ids
|
||||
)
|
||||
|
||||
def _has_active_native_lora(self, sampling: Any) -> bool:
|
||||
lora_request = sampling.lora_request
|
||||
return (
|
||||
lora_request is not None
|
||||
and not math.isclose(0.0, float(sampling.lora_scale))
|
||||
and lora_request.lora_int_id in self._native_lora_adapter_ids
|
||||
)
|
||||
|
||||
def _validate_native_sampling(self, sampling: Any, *, task: str) -> None:
|
||||
if task != "t2va":
|
||||
raise OmniClientError("MiniMax-H3 native LoRA supports T2VA requests only")
|
||||
# Derive the expected count from the adapter's own schedule so the
|
||||
# message can never disagree with the schedule the denoise loop runs.
|
||||
schedule = self._lora_sigma_schedules.get(sampling.lora_request.lora_int_id)
|
||||
expected_steps = MINIMAX_H3_NATIVE_INFERENCE_STEPS if schedule is None else schedule.num_inference_steps
|
||||
# Only request mode can take the count from the adapter schedule: step
|
||||
# mode admits the request in ``StepScheduler``, which reads
|
||||
# ``num_inference_steps`` off it before any pipeline hook runs. Reject
|
||||
# omission there rather than advertise a contract that would either fail
|
||||
# admission or disagree with the denoise loop.
|
||||
od_config = getattr(self, "od_config", None)
|
||||
omission_allowed = not getattr(od_config, "step_execution", False)
|
||||
or_omitted = " or omitted" if omission_allowed else ""
|
||||
sigma_steps = sampling.num_inference_steps
|
||||
if sigma_steps is None:
|
||||
if omission_allowed:
|
||||
return
|
||||
raise OmniClientError(
|
||||
f"MiniMax-H3 native LoRA requires an explicit num_inference_steps={expected_steps} "
|
||||
"under step execution, because the step scheduler derives the total step count from "
|
||||
"the request before the adapter schedule is known"
|
||||
)
|
||||
if int(sigma_steps) == expected_steps + 1:
|
||||
raise OmniClientError(
|
||||
"MiniMax-H3 native LoRA uses the distilled interval-count contract; "
|
||||
f"num_inference_steps must be {expected_steps}{or_omitted}, not {expected_steps + 1}"
|
||||
)
|
||||
if int(sigma_steps) != expected_steps:
|
||||
raise OmniClientError(
|
||||
f"MiniMax-H3 native LoRA requires num_inference_steps={expected_steps} "
|
||||
f"(one denoiser evaluation per sigma interval){or_omitted}"
|
||||
)
|
||||
|
||||
def _sigma_schedule_for_request(self, sampling: Any, task: str) -> DMD2SigmaSchedule | None:
|
||||
lora_request = sampling.lora_request
|
||||
if (
|
||||
lora_request is not None
|
||||
and not math.isclose(0.0, float(sampling.lora_scale))
|
||||
and lora_request.lora_int_id in self._lora_sigma_schedules
|
||||
):
|
||||
adapter_schedule = self._lora_sigma_schedules[lora_request.lora_int_id]
|
||||
checkpoint_schedule = self._base_schedule_for_task(task)
|
||||
if checkpoint_schedule is not None:
|
||||
raise OmniClientError(
|
||||
"MiniMax-H3 native LoRA cannot be activated on a checkpoint that already pins base_schedule"
|
||||
)
|
||||
return adapter_schedule
|
||||
return self._base_schedule_for_task(task)
|
||||
|
||||
def _validate_turbo_sampling(self, sampling: Any) -> None:
|
||||
extra = sampling.extra_args or {}
|
||||
sigma_points = sampling.num_inference_steps
|
||||
@@ -766,6 +857,8 @@ class MiniMaxH3Pipeline(
|
||||
str(od_config.model),
|
||||
)
|
||||
self._turbo_lora_adapter_ids: set[int] = set()
|
||||
self._native_lora_adapter_ids: set[int] = set()
|
||||
self._lora_sigma_schedules: dict[int, DMD2SigmaSchedule] = {}
|
||||
model_root = _resolve_minimax_h3_model_root(
|
||||
str(od_config.model),
|
||||
od_config.revision,
|
||||
@@ -981,6 +1074,7 @@ class MiniMaxH3Pipeline(
|
||||
multi_modal_data: dict[str, Any],
|
||||
*,
|
||||
has_turbo_lora: bool = False,
|
||||
has_native_lora: bool = False,
|
||||
) -> str:
|
||||
if requested is None:
|
||||
# A Ref2VA-only startup has no FL2VA transformer; preserve its
|
||||
@@ -1000,6 +1094,8 @@ class MiniMaxH3Pipeline(
|
||||
)
|
||||
if task == "ref2va" and has_turbo_lora:
|
||||
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")
|
||||
return task
|
||||
|
||||
def _resolve_shape(
|
||||
@@ -2001,13 +2097,17 @@ class MiniMaxH3Pipeline(
|
||||
logger.debug("MiniMax H3 request quality=%s", quality)
|
||||
extra = sampling.extra_args or {}
|
||||
has_turbo_lora = self._has_active_turbo_lora(sampling)
|
||||
has_native_lora = self._has_active_native_lora(sampling)
|
||||
task = self._resolve_task(
|
||||
extra.get("task"),
|
||||
multi_modal_data,
|
||||
has_turbo_lora=has_turbo_lora,
|
||||
has_native_lora=has_native_lora,
|
||||
)
|
||||
if has_turbo_lora:
|
||||
self._validate_turbo_sampling(sampling)
|
||||
if has_native_lora:
|
||||
self._validate_native_sampling(sampling, task=task)
|
||||
|
||||
raw_image = multi_modal_data.get("image")
|
||||
raw_videos = multi_modal_data.get("video")
|
||||
@@ -2224,7 +2324,7 @@ class MiniMaxH3Pipeline(
|
||||
ref_audio_t = audio_lengths[0]
|
||||
|
||||
seed = int(sampling.seed if sampling.seed is not None else 42)
|
||||
sigma_schedule = self._base_schedule_for_task(task)
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user