Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,33 @@ vllm serve unsloth/Qwen3.5-4B-MTP-GGUF:Q4_K_M \
For a GGUF without a `nextn` block, omit `--speculative-config`; the backbone
loads normally without MTP.

### vLLM-Omni MiniMax-H3

The validated MiniMax-H3 path uses a non-pruned DiT GGUF while its text encoder
and VAEs stay on their Hugging Face weights:

```python
from vllm_omni.entrypoints.omni import Omni

omni = Omni(
model="/path/to/MiniMax-H3/FL2VA",
tensor_parallel_size=1,
text_encoder_tp_size=1,
diffusion_quantization_config={
"method": "gguf",
"gguf_model": (
"leejet/MiniMax-H3-GGUF/"
"minimax_h3_fl2va-Q4_K_M.gguf"
),
},
)
```

Use one partition per process. A single `gguf_model` cannot initialize H3's
combined FL2VA and Ref2VA DiTs. Pruned checkpoints containing
`adaln_t_table` are not yet supported; use a file without `pruned` in its
name.

## Tested model coverage

The plugin uses vLLM's model implementations and a generic GGUF weight
Expand All @@ -94,6 +121,7 @@ starting points:
| Vision-language | Qwen 3.6 | UD-IQ2_XXS backbone with BF16 projector |
| Image generation | Z-Image-Turbo | Q4_0 |
| Image generation | FLUX.2-klein | Q8_0 |
| Video generation | MiniMax-H3 | Q4_K_M |

Other vLLM-supported architectures may work when their GGUF tensor names map
to the corresponding Hugging Face model. A model appearing in vLLM's general
Expand Down
98 changes: 98 additions & 0 deletions tests/diffusion/test_diffusion_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ def load_weights(self, weights) -> set[str]:
return loaded


class _MappedTextEncoderModel(_FakeModel):
"""Model whose encoder loader maps checkpoint names to fused parameters."""

def __init__(self) -> None:
super().__init__()
self.text_encoder = nn.Linear(2, 2, bias=False)

def load_weights(self, weights) -> set[str]:
mapped = (
(
"text_encoder.weight"
if name == "text_encoder.checkpoint_projection.weight"
else name,
tensor,
)
for name, tensor in weights
)
return super().load_weights(mapped)


def _make_sources():
return [
DiffusionWeightSource(prefix="transformer.", subfolder="transformer"),
Expand Down Expand Up @@ -282,6 +302,84 @@ def hf_fn(source: DiffusionWeightSource):
assert torch.allclose(model.vae.weight, torch.full((2, 2), 4.0))


def test_load_gguf_rejects_multiple_matching_component_sources(
monkeypatch: pytest.MonkeyPatch,
):
"""A single GGUF must not initialize two partition-specific DiTs."""
model = _FakeModel()

class _Adapter:
def weights_iterator(self):
raise AssertionError(
"weights must not be read after source validation fails"
)

import vllm_gguf_plugin.weights_adapter.diffusion.loader as _loader_mod

monkeypatch.setattr(
_loader_mod, "resolve_gguf_model_path", lambda **kw: "dummy.gguf"
)
monkeypatch.setattr(
_loader_mod, "get_diffusion_gguf_adapter", lambda *a, **kw: _Adapter()
)

sources = [
DiffusionWeightSource(prefix="transformer.", subfolder="transformer"),
DiffusionWeightSource(prefix="transformers_ref.", subfolder="transformer"),
]
with pytest.raises(ValueError, match="must match exactly one weight source"):
load_diffusion_gguf_weights(
gguf_model="dummy.gguf",
model=model,
model_class_name="MiniMaxH3Pipeline",
model_type=None,
sources=sources,
hf_weights_fn=lambda _source: iter(()),
)


def test_hf_component_weights_reach_model_checkpoint_mapper(
monkeypatch: pytest.MonkeyPatch,
):
"""HF component keys may need model-specific mapping before they are loadable."""
model = _MappedTextEncoderModel()

class _Adapter:
def weights_iterator(self):
yield "weight", torch.ones((2, 2))
yield "bias", torch.zeros(2)

import vllm_gguf_plugin.weights_adapter.diffusion.loader as _loader_mod

monkeypatch.setattr(
_loader_mod, "resolve_gguf_model_path", lambda **kw: "dummy.gguf"
)
monkeypatch.setattr(
_loader_mod, "get_diffusion_gguf_adapter", lambda *a, **kw: _Adapter()
)

sources = [
DiffusionWeightSource(prefix="transformer.", subfolder="transformer"),
DiffusionWeightSource(prefix="text_encoder.", subfolder="text_encoder"),
]

def hf_fn(source: DiffusionWeightSource):
if source.prefix == "text_encoder.":
yield "text_encoder.checkpoint_projection.weight", torch.full((2, 2), 5.0)

loaded = load_diffusion_gguf_weights(
gguf_model="dummy.gguf",
model=model,
model_class_name=None,
model_type=None,
sources=sources,
hf_weights_fn=hf_fn,
)

assert "text_encoder.weight" in loaded
assert torch.allclose(model.text_encoder.weight, torch.full((2, 2), 5.0))


def test_load_gguf_skips_hf_when_complete(monkeypatch: pytest.MonkeyPatch):
"""No HF fallback when GGUF covers all transformer weights."""
model = _FakeModel()
Expand Down
114 changes: 114 additions & 0 deletions tests/diffusion/test_minimax_h3_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the MiniMax-H3 diffusion GGUF adapter."""

from __future__ import annotations

from types import SimpleNamespace

import pytest
import torch

from vllm_gguf_plugin.weights_adapter.diffusion import (
MiniMaxH3DiffusionGGUFAdapter,
get_diffusion_gguf_adapter,
)

pytestmark = [pytest.mark.cpu]


def test_minimax_h3_adapter_selected_for_pipeline():
adapter = get_diffusion_gguf_adapter(
"dummy.gguf",
model_class_name="MiniMaxH3Pipeline",
model_type=None,
)
assert isinstance(adapter, MiniMaxH3DiffusionGGUFAdapter)
assert adapter.unquantized_modules == ("text_encoder",)


@pytest.mark.parametrize("model_type", ["minimax_h3", "minimax-h3", "minimaxh3"])
def test_minimax_h3_adapter_selected_for_model_type(model_type: str):
assert MiniMaxH3DiffusionGGUFAdapter.is_compatible(
model_class_name=None,
model_type=model_type,
)


def test_quantized_qkv_keeps_converter_fused_layout(
monkeypatch: pytest.MonkeyPatch,
):
import vllm_gguf_plugin.weights_adapter.diffusion.minimax_h3 as h3_module

qweight = torch.arange(12).reshape(3, 4)
monkeypatch.setattr(
h3_module,
"gguf_quant_weights_iterator",
lambda _path: iter(
[
("blocks.0.attn.qkv_proj.qweight_type", torch.tensor(10)),
("blocks.0.attn.qkv_proj.qweight", qweight),
]
),
)

weights = dict(MiniMaxH3DiffusionGGUFAdapter("dummy.gguf").weights_iterator())

assert weights["blocks.0.attn.qkv_proj.qweight_type"].item() == 10
assert weights["blocks.0.attn.qkv_proj.qweight"] is qweight


def test_dense_qkv_is_restored_to_grouped_checkpoint_layout(
monkeypatch: pytest.MonkeyPatch,
):
import vllm_gguf_plugin.weights_adapter.diffusion.minimax_h3 as h3_module

projection_rows = 56 * 128
fused = torch.arange(3 * projection_rows).reshape(-1, 1)
monkeypatch.setattr(
h3_module,
"gguf_quant_weights_iterator",
lambda _path: iter([("blocks.0.attn.qkv_proj.weight", fused)]),
)

weights = dict(MiniMaxH3DiffusionGGUFAdapter("dummy.gguf").weights_iterator())
grouped = weights["blocks.0.attn.qkv_proj.weight"].reshape(56, 3 * 128)

assert torch.equal(grouped[0, :128], fused[:128, 0])
assert torch.equal(
grouped[0, 128:256], fused[projection_rows : projection_rows + 128, 0]
)
assert torch.equal(
grouped[0, 256:], fused[2 * projection_rows : 2 * projection_rows + 128, 0]
)


def test_pruned_adaln_layout_is_rejected_early(monkeypatch: pytest.MonkeyPatch):
import vllm_gguf_plugin.weights_adapter.diffusion.minimax_h3 as h3_module

reader = SimpleNamespace(tensors=[SimpleNamespace(name="adaln_t_table")])
monkeypatch.setattr(h3_module.gguf, "GGUFReader", lambda _path: reader)

adapter = MiniMaxH3DiffusionGGUFAdapter("pruned.gguf")
with pytest.raises(ValueError, match="Unsupported MiniMax-H3 GGUF schema"):
adapter.unquantized_module_names()


def test_incomplete_time_embedder_layout_is_rejected_early(
monkeypatch: pytest.MonkeyPatch,
):
import vllm_gguf_plugin.weights_adapter.diffusion.minimax_h3 as h3_module

reader = SimpleNamespace(
tensors=[
SimpleNamespace(
name="time_embedder.proj_in.weight",
tensor_type=SimpleNamespace(name="F32"),
)
]
)
monkeypatch.setattr(h3_module.gguf, "GGUFReader", lambda _path: reader)

adapter = MiniMaxH3DiffusionGGUFAdapter("incomplete.gguf")
with pytest.raises(ValueError, match="Unsupported MiniMax-H3 GGUF schema"):
adapter.unquantized_module_names()
3 changes: 3 additions & 0 deletions vllm_gguf_plugin/weights_adapter/diffusion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
load_diffusion_gguf_weights,
resolve_gguf_model_path,
)
from .minimax_h3 import MiniMaxH3DiffusionGGUFAdapter
from .qwen_image import QwenImageDiffusionGGUFAdapter
from .z_image import ZImageDiffusionGGUFAdapter

_ADAPTER_CLASSES: list[type[DiffusionGGUFAdapter]] = [
MiniMaxH3DiffusionGGUFAdapter,
QwenImageDiffusionGGUFAdapter,
ZImageDiffusionGGUFAdapter,
Flux2KleinDiffusionGGUFAdapter,
Expand Down Expand Up @@ -40,6 +42,7 @@ def get_diffusion_gguf_adapter(
"DiffusionWeightSource",
"Flux2KleinDiffusionGGUFAdapter",
"MappedTensor",
"MiniMaxH3DiffusionGGUFAdapter",
"QwenImageDiffusionGGUFAdapter",
"ZImageDiffusionGGUFAdapter",
"get_diffusion_gguf_adapter",
Expand Down
35 changes: 16 additions & 19 deletions vllm_gguf_plugin/weights_adapter/diffusion/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,15 +168,6 @@ def _gguf_weights_for_loadable_names(
)


def _hf_weights_for_loadable_names(
weights: Iterable[tuple[str, torch.Tensor]],
loadable_names: set[str],
) -> Iterable[tuple[str, torch.Tensor]]:
for name, tensor in weights:
if name in loadable_names:
yield name, tensor


def load_diffusion_gguf_weights(
gguf_model: str,
model: nn.Module,
Expand Down Expand Up @@ -223,9 +214,18 @@ def load_diffusion_gguf_weights(
adapter = get_diffusion_gguf_adapter(gguf_file, model_class_name, model_type)
loaded: set[str] = set()
loadable_names: set[str] | None = None
gguf_sources = [source for source in sources if _is_gguf_source(source, adapter)]
if len(gguf_sources) != 1:
matched_prefixes = ", ".join(source.prefix for source in gguf_sources) or "none"
raise ValueError(
f"{type(adapter).__name__} must match exactly one weight source for a "
f"single gguf_model, but matched {len(gguf_sources)} "
f"({matched_prefixes})."
)
gguf_source = gguf_sources[0]

for source in sources:
if _is_gguf_source(source, adapter):
if source is gguf_source:
loadable_names = loadable_names or _get_loadable_names(model)
gguf_iter = (
(source.prefix + name, tensor)
Expand All @@ -235,16 +235,13 @@ def load_diffusion_gguf_weights(
_gguf_weights_for_loadable_names(gguf_iter, loadable_names)
)
else:
# Non-transformer components always load from HF.
loadable_names = loadable_names or _get_loadable_names(model)
# Non-transformer components always load from HF. Preserve their
# checkpoint names because model loaders may map or fuse them.
loaded |= model.load_weights(
_hf_weights_for_loadable_names(
(
(name, tensor)
for name, tensor in hf_weights_fn(source)
if not source.prefix or name.startswith(source.prefix)
),
loadable_names,
(
(name, tensor)
for name, tensor in hf_weights_fn(source)
if not source.prefix or name.startswith(source.prefix)
)
)

Expand Down
Loading