[NPU][Quant] Add W4A4 MXFP4 online & MXFP4 dual-scale online/offline quantization support for Wan2.2 T2V / I2V inference on Ascend NPU (#3578)

Signed-off-by: hyh_hh <huyinghong1@huawei.com>
Co-authored-by: hyh_hh <huyinghong1@huawei.com>
This commit is contained in:
hxhhhlalalaandhyh_hh authored and GitHub committed 2026-05-20 06:21:29 +00:00
1 parent 109bba8cdf
commit 9dd36e3afd
14 files changed
+3646 -61

No files matched your search

+479
View File
@@ -0,0 +1,479 @@
# W4A4 MXFP4 Quantization
## Overview
W4A4 MXFP4 (Microscaling FP4) quantizes both weights and activations to FP4
(`float4_e2m1fn_x2`, packed 2 values per byte) using the OCP MX format: groups
of 32 K-dimension elements share a single `float8_e8m0fnu` exponent scale.
vLLM-Omni provides two quantization methods with different scale structures:
| Method | Scale structure | Mode | Use case |
|--------|----------------|------|----------|
| `mxfp4` | Single-scale (per-32 fine only) | Online only | Quick accuracy baseline; no checkpoint prep needed |
| `mxfp4_dualscale` | Dual-scale (fine per-32 + coarse per-512 + per-channel `mul_scale`) | Online + Offline | Production; better accuracy; offline recommended |
!!! tip "Recommended: `mxfp4_dualscale` offline"
For production deployments, use the `mxfp4_dualscale` offline mode with a
pre-quantized checkpoint produced by msModelSlim. Offline checkpoints load
calibrated `mul_scale` tensors from disk, providing measurably better accuracy
than any online method. The one-time preprocessing cost amortises across all
subsequent inference runs.
Use `mxfp4` online only for quick experimentation where preprocessing time
is not acceptable and accuracy loss is tolerable.
!!! warning "Online single-scale ≠ Offline dual-scale"
`mxfp4_dualscale` offline mode uses `NPUMxfp4DualScaleLinearMethod`:
fine scale (per-32 K), coarse scale (per-512 K), and per-input-channel
`mul_scale` from calibration — all loaded from the checkpoint.
`mxfp4_dualscale` online mode uses `NPUMxfp4DualScaleOnlineLinearMethod`:
dual-level scales computed on the fly from BF16 weights; no calibration
`mul_scale` is available. Loading an offline checkpoint with the online
method (or vice versa) will produce incorrect results or shape errors.
## Hardware Support
| Device | Support |
|--------|---------|
| NVIDIA Blackwell GPU (SM 100+) | ⭕ |
| NVIDIA Ada/Hopper GPU (SM 89+) | ⭕ |
| NVIDIA Ampere GPU (SM 80+) | ⭕ |
| AMD ROCm | ⭕ |
| Intel XPU | ⭕ |
| Ascend NPU (Atlas 950 A5) | ✅ |
Legend: `✅` supported, `❌` unsupported, `⭕` not verified in this guide.
## Model Type Support
### Diffusion Model (Wan2.2)
| Model | Online | Offline | Notes |
|-------|--------|---------|-------|
| Wan2.2-T2V-A14B | `mxfp4` / `mxfp4_dualscale` | `mxfp4_dualscale` | MoE cascade (`transformer` + `transformer_2`); both transformers quantized with the same config |
| Wan2.2-I2V-A14B | `mxfp4` / `mxfp4_dualscale` | `mxfp4_dualscale` | MoE cascade; same scheme as T2V-A14B |
| Wan2.2-TI2V-5B | ❌ | ❌ | Parameter count too small; W4A4 causes unacceptable accuracy loss |
The choice between `mxfp4` and `mxfp4_dualscale` in **online mode** is about
quantization quality, not model compatibility — both work on cascade (A14B) and
single-transformer models alike, the same as `mxfp8` online:
- `mxfp4`: single-scale, lower overhead, simpler compute, online only
- `mxfp4_dualscale`: dual-scale + optional BF16 fallback, better accuracy, online **and** offline
**Offline** checkpoints for A14B are always in `mxfp4_dualscale` format (produced
by the merge script); there is no offline `mxfp4` single-scale format.
!!! note "Per-layer BF16 fallback in offline cascade models"
The A14B offline checkpoint uses `quant_method: mxfp4_dualscale`. Most
linear layers are stored as W4A4 MXFP4 DualScale; precision-sensitive layers
retain their original BF16 weights and are listed in `ignored_layers` inside
each transformer's `config.json`. The two transformers may have different
`ignored_layers` sets — the pipeline reads each transformer's own `config.json`
and rebuilds the config locally when they differ, so routing is always
per-transformer-accurate.
!!! warning "TI2V-5B not supported"
Wan2.2-TI2V-5B is excluded from W4A4 quantization. Its smaller parameter
count makes it significantly more sensitive to 4-bit quantization noise,
resulting in unacceptable accuracy loss. Use [MXFP8](mxfp8.md) for TI2V-5B.
## Configuration
### `mxfp4` — Single-Scale Online Mode
Online mode requires no pre-processing. vLLM-Omni quantizes BF16 weights to
MXFP4 at load time using `npu_dynamic_mx_quant`. A single block scale
(`float8_e8m0fnu`, one per 32 K elements) is computed on the fly; no
calibration `mul_scale` is available. Applies equally to single-transformer
and cascade (A14B) models — both transformers in a cascade receive the same
quantization config automatically.
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(model="<your-model>", quantization="mxfp4")
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
```bash
# Single-transformer or cascade model — same command
python text_to_video.py --model <your-model> --quantization mxfp4
# Online serving
vllm serve <your-model> --omni --quantization mxfp4
```
### `mxfp4_dualscale` — DualScale Online Mode
Online DualScale mode computes both fine and coarse scales on the fly from BF16
weights using `npu_dynamic_dual_level_mx_quant`. Applies equally to
single-transformer and cascade (A14B) models. Compared to `mxfp4` online,
DualScale provides better quantization accuracy at higher compute cost.
The default configuration keeps the leading 5 transformer blocks in BF16
(`num_bf16_fallback_layers=5`). Accuracy evaluation on Wan2.2-A14B shows this
is sufficient to meet quality requirements and is the recommended setting.
```python
omni = Omni(model="<your-model>", quantization="mxfp4_dualscale")
```
```bash
python text_to_video.py --model <your-model> --quantization mxfp4_dualscale
```
If accuracy debugging identifies additional precision-sensitive layers, they
can be pinned to BF16 via the Python API:
```python
omni = Omni(
model="<your-model>",
quantization={
"method": "mxfp4_dualscale",
"ignored_layers": ["blocks.10.attn1.to_q"], # explicit per-layer override
},
)
```
BF16 fallback routing in online mode applies two rules in priority order:
1. **`ignored_layers`** (explicit per-layer override): any layer whose prefix
matches is kept in BF16 regardless of block index.
2. **`num_bf16_fallback_layers`** (coarse leading-block rule): the first N
transformer blocks (`blocks.0` … `blocks.N-1`) fall back to BF16. Defaults
to `5` (recommended). Layers outside `blocks.N.*`
(e.g. `condition_embedder`) are always quantized.
### `mxfp4_dualscale` — DualScale Offline Mode (Recommended)
Offline mode loads a pre-quantized DualScale checkpoint from msModelSlim. A
preprocessing step converts the raw quantized output to the diffusers format
expected by vLLM-Omni and injects the quantization config into each
`transformer/config.json` so that vLLM-Omni auto-detects the offline path
without a `--quantization` flag.
BF16 fallback layers may be interleaved anywhere in the transformer — they are
not restricted to leading blocks. The merge script detects them from
`quant_model_description.json` and writes their prefixes into `ignored_layers`
inside `config.json`. At runtime, each layer's prefix is matched against
`ignored_layers` to decide BF16 vs. MXFP4 DualScale.
#### Checkpoint tensor layout
Each quantized linear layer stores four tensors:
| Tensor | Shape | dtype | Description |
|--------|-------|-------|-------------|
| `weight` | `(N, K)` | float8_e4m3fn | FP4 packed (2 values per byte) |
| `weight_scale` | `(N, K//32)` | uint8 | Fine block scale (`float8_e8m0fnu` bit pattern) |
| `weight_dual_scale` | `(N, K//512, 1)` | float32 | Coarse block scale |
| `mul_scale` | `(K,)` | float32 | Per-input-channel smooth pre-scale (from calibration) |
BF16 fallback layers have no quantization tensors; only the original `weight`
(and optional `bias`) are present, loaded directly from the base checkpoint.
#### Step 1 — Quantize with msModelSlim
```bash
msmodelslim quant \
--model_path /path/to/Wan2.2-T2V-A14B-Diffusers \
--save_path /path/to/wan2_2_t2v_quantized_raw \
--device npu \
--model_type Wan2_2 \
--config_path /path/to/wan2_2_w4a4_mxfp4_dualscale.yaml \
--trust_remote_code True
```
After this step, `--save_path` contains raw quantized safetensors files,
scale files, and a metadata JSON (`quant_model_description*.json`).
For cascade MoE models (T2V-A14B, I2V-A14B), msModelSlim outputs two
subdirectories: `high_noise_model/` (transformer) and `low_noise_model/`
(transformer_2).
#### Step 2 — Preprocess with merge_mxfp4_dualscale_checkpoint.py
The script (`vllm_omni/quantization/tools/merge_mxfp4_dualscale_checkpoint.py`):
1. Copies the original diffusers model to `--output-path` (VAE, text encoder,
scheduler, etc. are preserved).
2. Remaps tensor names from msModelSlim convention to diffusers convention and
strips `.linear.` / `.div.` wrappers added by the quantization tool.
3. Overlays MXFP4 tensors (weight, fine/coarse scales, `mul_scale`) onto the
BF16 base checkpoint. Non-quantized layers keep their original BF16 weights.
4. Detects all linear layers that remain in BF16 and writes their prefixes into
`ignored_layers` in `config.json`.
5. Injects `quantization_config` so vLLM-Omni auto-detects offline MXFP4
DualScale.
For cascade MoE models, steps 2–5 run separately for each transformer.
```bash
python vllm_omni/quantization/tools/merge_mxfp4_dualscale_checkpoint.py \
--model-type Wan2.2-T2V-A14B \
--original-model /path/to/Wan2.2-T2V-A14B-Diffusers \
--quant-path /path/to/wan2_2_t2v_quantized_raw \
--output-path /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale
```
| Argument | Description |
|----------|-------------|
| `--model-type` | Model variant: `Wan2.2-T2V-A14B` or `Wan2.2-I2V-A14B` |
| `--original-model` | Root directory of the original BF16 diffusers model |
| `--quant-path` | Root directory of the msModelSlim quantized output |
| `--output-path` | Output directory for the merged model (created by the script) |
The script outputs a complete diffusers model directory at `--output-path`,
with each transformer subfolder containing:
- `diffusion_pytorch_model.safetensors` — MXFP4 weights + scale tensors, with BF16 fallback layers from the base checkpoint
- `config.json` — original transformer config with `quantization_config` injected
- `quant_model_description.json` — quantization metadata (reference only)
The `quantization_config` injected into `config.json` for each transformer:
```json
{
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": true,
"ignored_layers": [
"blocks.0.attn1.to_qkv",
"blocks.0.attn1.to_out",
"proj_out"
]
}
```
`ignored_layers` lists every linear layer that retains its original BF16 weight,
using vllm-omni model parameter names (QKV-fused, FFN underscored, `to_out`
unindexed). The exact entries are determined by the quantization tool (msModelSlim)
and may differ between `transformer` and `transformer_2` in a cascade model.
#### Step 3 — Serve
```bash
python text_to_video.py --model /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale
# Online serving
vllm serve /path/to/Wan2.2-T2V-A14B-MXFP4-DualScale --omni
```
```python
omni = Omni(model="/path/to/Wan2.2-T2V-A14B-MXFP4-DualScale")
```
!!! note
No `--quantization` flag is needed for offline mode. The preprocessing
script injects `quantization_config` into each `transformer/config.json`,
which vLLM-Omni reads automatically to activate the correct offline path.
## Parameters
### `mxfp4` (single-scale, online only)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `method` | str | — | `"mxfp4"` |
| `ignored_layers` | list[str] | `[]` | Layer prefixes to keep in BF16 |
### `mxfp4_dualscale` (dual-scale, online + offline)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `method` | str | — | `"mxfp4_dualscale"` |
| `is_checkpoint_serialized` | bool | `False` | `True` for offline DualScale checkpoints; auto-set from `config.json` when using the preprocessing script |
| `ignored_layers` | list[str] | `[]` | Layer prefixes to keep in BF16. **Works in both modes**: offline — populated by the merge script for interleaved sensitive layers; online — user-supplied for explicit per-layer precision override |
| `num_bf16_fallback_layers` | int | `5` | **Online mode only**: leading N transformer blocks (`blocks.0` … `blocks.N-1`) kept in BF16. Applied after `ignored_layers`; ignored in offline mode. Default of `5` is the evaluated recommended value for Wan2.2-A14B |
#### BF16 fallback priority (online mode)
```
for each linear layer:
if prefix in ignored_layers → BF16 (explicit override, highest priority)
elif block_idx < num_bf16_fallback_layers → BF16 (coarse leading-block rule)
else → MXFP4 DualScale online
```
Layers outside `blocks.N.*` (e.g. `condition_embedder.*`) are always quantized
unless they appear in `ignored_layers`.
## Validation and Notes
1. **Online single-scale (`mxfp4`)** quantizes BF16 weights at load time using
`npu_dynamic_mx_quant` (single-scale). No calibration `mul_scale` is
available — all output partitions receive an identity pre-scale. No offline
checkpoint format exists for this method.
2. **Online dual-scale (`mxfp4_dualscale`, `is_checkpoint_serialized=False`)**
quantizes BF16 weights using `npu_dynamic_dual_level_mx_quant` (fine + coarse
scales computed on the fly). No calibration `mul_scale`; leading blocks or
explicit `ignored_layers` stay in BF16 for accuracy.
3. **Offline dual-scale (`mxfp4_dualscale`, `is_checkpoint_serialized=True`)** —
**recommended for production** — loads four tensors per quantized layer: FP4
weight, fine scale (`uint8` reinterpreted as `float8_e8m0fnu`), coarse scale
(`float32`), and per-input-channel `mul_scale` (`float32`). BF16 fallback
layers have no quantization tensors and are routed via `ignored_layers`.
4. **Scale dtype**: fine scales are stored as `uint8` in safetensors (same bit
layout as `float8_e8m0fnu`) and reinterpreted at load time without a lossy
float32 round-trip.
5. **Cascade model config propagation**: in a cascade model (transformer +
transformer_2), vLLM-Omni reads each transformer's own `config.json` and
rebuilds the quant config locally when `ignored_layers` differs between
transformers, ensuring per-layer routing is accurate for each. The first
transformer's config is propagated to `od_config` so the second transformer
can reuse it as a starting point.
6. **Self-attention QKV fusion**: Q, K, V projection weights are fused into a
single `QKVParallelLinear` layer at runtime. `ignored_layers` entries use the
fused name (`attn1.to_qkv`), written automatically by the merge script.
7. W4A4 carries higher quantization noise than W8A8 (16 vs 256 levels). The
DualScale offline method mitigates this with calibrated `mul_scale` smooth
quantization. Use `ignored_layers` and `num_bf16_fallback_layers` to trade
off compression vs. accuracy for precision-sensitive layers.
## Adapting MXFP4 for a New Model
This section is aimed at developers who want to add MXFP4 support to a model
other than Wan2.2. The three integration points are: (1) discovering the correct
runtime layer names, (2) wiring `ignored_layers` into the model, and (3) writing
a merge script for offline checkpoints.
### Step 1 — Discover runtime layer names
`ignored_layers` entries must match the **runtime parameter names** used inside
vllm-omni, which may differ from the names stored in the diffusers checkpoint.
The canonical source of truth is the model's own `named_parameters()`.
```python
from vllm_omni import Omni
# Load the model without quantization to inspect parameter names.
omni = Omni(model="/path/to/your-model") # no --quantization flag
for name, _ in omni.pipeline.transformer.named_parameters():
if "weight" in name and "scale" not in name:
print(name)
```
Compare the printed names against the diffusers checkpoint keys
(`safetensors.safe_open` or `torch.load`) to identify any renames your model
applies. Common patterns that differ in Wan2.2 (and may appear in other
models):
| Diffusers checkpoint name | vllm-omni runtime name | Reason |
|---------------------------|------------------------|--------|
| `attn1.to_q`, `attn1.to_k`, `attn1.to_v` | `attn1.to_qkv` | Self-attention Q/K/V fused into `QKVParallelLinear` |
| `ffn.net.0.proj` | `ffn.net_0.proj` | Dots in sub-module names replaced with underscores |
| `ffn.net.2` | `ffn.net_2` | Same underscore rule |
| `to_out.0` | `to_out` | Sequential index stripped |
If your model has different fusion patterns, inspect `packed_modules_mapping`
on the model class — this dict records how checkpoint keys are mapped to
fused runtime parameters.
!!! warning "Partial QKV fallback is not allowed"
If your model fuses Q, K, V into a single layer, `ignored_layers` must
include **all three or none**. A partial fallback (e.g. `to_q` in BF16 but
`to_k`, `to_v` quantized) cannot be expressed at runtime because they share
one `QKVParallelLinear`. The merge script enforces this and raises an error
if only some of the trio appear as non-quantized.
### Step 2 — Add ignored_layers to the model
#### Online mode
Pass `ignored_layers` directly in the quantization config using the **runtime
names** discovered in Step 1. No code changes to the model are required.
```python
omni = Omni(
model="/path/to/your-model",
quantization={
"method": "mxfp4_dualscale",
"ignored_layers": [
"blocks.0.attn1.to_qkv", # runtime name, not diffusers name
"blocks.0.attn1.to_out",
"blocks.0.ffn.net_0.proj",
],
},
)
```
```bash
# CLI does not support list-typed ignored_layers directly.
# Use the Python API or set ignored_layers in config.json (offline).
python your_script.py --model /path/to/your-model --quantization mxfp4_dualscale
```
The `num_bf16_fallback_layers` coarse rule is an alternative to listing layers
individually: set it to N to keep all linear layers in blocks 0 … N-1 in BF16.
The right value depends on the model's sensitivity; evaluate on a validation
set and pick the smallest N that meets your accuracy target.
#### Offline mode
For offline checkpoints, `ignored_layers` is written into each transformer's
`config.json` by the merge script (see Step 3). No manual editing is needed if
the merge script is correct. The injected block:
```json
{
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": true,
"ignored_layers": [
"blocks.0.attn1.to_qkv",
"blocks.0.attn1.to_out"
]
}
```
To add a layer manually (e.g. to pin an additional layer to BF16 without
re-running the merge script), edit `config.json` inside the transformer
subfolder. Use runtime names, not diffusers checkpoint names.
### Step 3 — Write a merge script for offline mode
The merge script for a new model mirrors
`vllm_omni/quantization/tools/merge_mxfp4_dualscale_checkpoint.py`. The four
things it must do:
1. **Remap tensor names** from the quantization tool convention to diffusers
convention (strip wrappers like `.linear.`, `.div.`; fix any prefix
differences).
2. **Collect ignored_layers**: after loading, enumerate all `*.weight` keys that
have no corresponding `*.weight_scale` (i.e. layers the tool left in BF16).
Convert diffusers names to vllm-omni runtime names (fuse QKV, rename FFN
sub-modules, etc.). Write the result to `config.json`.
3. **Inject `quantization_config`** into `config.json`:
```python
config["quantization_config"] = {
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": True,
"ignored_layers": ignored_layers, # runtime names
}
```
4. **Save** the merged safetensors and the updated `config.json`.
The key helper to implement is the diffusers-to-runtime name translator
(equivalent to `_diffusers_to_vllm_ignored` in the Wan2.2 merge script).
For each non-quantized diffusers weight key, apply your model's specific
renaming rules and collect the results.
!!! tip "Validate before serving"
After producing the offline checkpoint, load it without a `--quantization`
flag and verify that vLLM-Omni auto-detects the correct method. Check that
the layer count reported in the startup log matches expectations: quantized
layer count + `ignored_layers` count should equal total linear layer count.
Any mismatch indicates a name-mapping bug in the merge script.
+14 -11
View File
@@ -9,20 +9,20 @@ type has a different quantization scope.
| Mode | Guide | Description | Methods |
|------|-------|-------------|---------|
| Online quantization | [Online Quantization](online.md) | vLLM-Omni computes quantized weights and scales while loading the model. | FP8 W8A8, Int8 W8A8, MXFP8 W8A8 |
| Online quantization | [Online Quantization](online.md) | vLLM-Omni computes quantized weights and scales while loading the model. | FP8 W8A8, Int8 W8A8, MXFP8 W8A8, MXFP4 W4A4 |
| Runtime attention quantization | [Quantized KV Cache](quantized_kvcache.md) | vLLM-Omni dynamically quantizes eligible diffusion Flash Attention tensors during inference. | FP8 FA |
| Pre-quantized checkpoints | Method-specific guides | The checkpoint or an offline quantizer provides quantized weights and scales before serving. | ModelOpt, GGUF, AutoRound, msModelSlim, serialized Int8, offline MXFP8 |
| Pre-quantized checkpoints | Method-specific guides | The checkpoint or an offline quantizer provides quantized weights and scales before serving. | ModelOpt, GGUF, AutoRound, msModelSlim, serialized Int8, offline MXFP8, offline MXFP4 DualScale |
## Hardware Support
| Device | FP8 W8A8 | Int8 W8A8 | ModelOpt | MXFP8 W8A8 | GGUF | AutoRound | msModelSlim |
|--------|----------|-----------|----------|------------|------|-----------|-------------|
| NVIDIA Blackwell GPU (SM 100+) | ✅ | ✅ | ✅ | ⭕ | ✅ | ✅ | ❌ |
| NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | ✅ | ✅ | ⭕ | ✅ | ✅ | ❌ |
| NVIDIA Ampere GPU (SM 80+) | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ❌ |
| Intel XPU | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ✅ | ❌ |
| Ascend NPU | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| Device | FP8 W8A8 | Int8 W8A8 | ModelOpt | MXFP8 W8A8 | MXFP4 W4A4 | GGUF | AutoRound | msModelSlim |
|--------|----------|-----------|----------|------------|------------|------|-----------|-------------|
| NVIDIA Blackwell GPU (SM 100+) | ✅ | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
| NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
| NVIDIA Ampere GPU (SM 80+) | ✅ | ✅ | ⭕ | ⭕ | ⭕ | ✅ | ✅ | ❌ |
| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ❌ |
| Intel XPU | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ✅ | ❌ |
| Ascend NPU | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ |
Legend: `✅` supported, `❌` unsupported, `⭕` not verified in this
guide. FP8 on Ampere may use a weight-only path where available.
@@ -42,6 +42,7 @@ otherwise.
| Int8 W8A8 | [Int8](int8.md) | Online or serialized W8A8 | Qwen-Image; Wan2.2 is not validated | Validated for Qwen-Image and Z-Image |
| ModelOpt | [ModelOpt](modelopt.md) | Pre-quantized FP8 checkpoints | Qwen-Image, Z-Image, FLUX.2, HunyuanImage-3.0 | Validated for ModelOpt FP8 diffusion checkpoints |
| MXFP8 W8A8 | [MXFP8](mxfp8.md) | Online W8A8 or offline pre-quantized | Wan2.2-T2V-A14B, I2V-A14B, TI2V-5B | Ascend NPU only; validated for Wan2.2 |
| MXFP4 W4A4 | [MXFP4](mxfp4.md) | `mxfp4`: online single-scale only; `mxfp4_dualscale`: online or offline dual-scale (offline recommended) | Wan2.2-T2V-A14B, I2V-A14B | Ascend NPU only; validated for Wan2.2 A14B cascade models; TI2V-5B not supported; offline `mxfp4_dualscale` uses calibrated `mul_scale` for best accuracy |
| GGUF | [GGUF](gguf.md) | Pre-quantized transformer weights | Qwen-Image | Validated where a model-specific GGUF adapter exists |
| AutoRound | [AutoRound](autoround.md) | Pre-quantized W4A16 checkpoints | FLUX.1-dev; Qwen-Image/Wan2.2 not validated | Checkpoint-driven |
| msModelSlim | [msModelSlim](msmodelslim.md) | Pre-quantized Ascend checkpoints | Wan2.2 recipe; HunyuanImage-3.0 inference target | Ascend/NPU path |
@@ -58,6 +59,7 @@ in BF16 unless the model guide explicitly adds support.
| ModelOpt | [ModelOpt](modelopt.md) | Thinker or language-model checkpoint config | Qwen3-Omni thinker | ModelOpt checkpoint path |
| Int8 | [Int8](int8.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
| MXFP8 | [MXFP8](mxfp8.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
| MXFP4 | [MXFP4](mxfp4.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
| GGUF | [GGUF](gguf.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
| AutoRound | [AutoRound](autoround.md) | Thinker or language-model checkpoint config | Qwen2.5-Omni, Qwen3-Omni | Supported through AutoRound checkpoints |
| msModelSlim | [msModelSlim](msmodelslim.md) | Not currently validated for omni/TTS stages | Qwen3-Omni, Qwen3-TTS | Not validated |
@@ -73,6 +75,7 @@ attached to the intended stage rather than applied globally.
| Int8 | [Int8](int8.md) | Stage-specific DiT or transformer module | BAGEL, GLM-Image | Requires model-specific validation |
| ModelOpt | [ModelOpt](modelopt.md) | Checkpoint-defined diffusion stage | BAGEL, GLM-Image | Requires model-specific validation |
| MXFP8 | [MXFP8](mxfp8.md) | Stage-specific DiT or transformer module | BAGEL, GLM-Image | Not validated |
| MXFP4 | [MXFP4](mxfp4.md) | Stage-specific DiT or transformer module | BAGEL, GLM-Image | Not validated |
| GGUF | [GGUF](gguf.md) | Stage-specific transformer weights | BAGEL, GLM-Image | No validated adapter listed |
| AutoRound | [AutoRound](autoround.md) | Checkpoint-defined stage | BAGEL, GLM-Image | No validated checkpoint listed |
| msModelSlim | [msModelSlim](msmodelslim.md) | Ascend-generated stage weights | GLM-Image | Requires model-specific adaptation |
@@ -100,7 +103,7 @@ config = build_quant_config({
| Component | Default quantized? | Notes |
|-----------|--------------------|-------|
| Diffusion transformer | Yes | Primary target for FP8, Int8, ModelOpt, GGUF, AutoRound, and msModelSlim |
| Diffusion transformer | Yes | Primary target for FP8, Int8, ModelOpt, MXFP8, MXFP4, GGUF, AutoRound, and msModelSlim |
| Text encoder | No | Keep BF16 unless a method-specific guide documents support |
| VAE | No | Keep BF16; storage-only paths are method-specific |
| Scheduler/tokenizer | No | Loaded from the base model repository |
@@ -173,8 +173,8 @@ def parse_args() -> argparse.Namespace:
"--quantization",
type=str,
default=None,
choices=["fp8", "mxfp8", "int8", "gguf"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 online quant (NPU). fp8: online FP8 (GPU).",
choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8", "gguf"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 (NPU). mxfp4: W4A4 MXFP4 (NPU). mxfp4_dualscale: W4A4 MXFP4 dual-scale + BF16 fallback mixed (NPU). fp8: online FP8 (GPU).",
)
# Distributed and parallel execution
@@ -163,8 +163,8 @@ def parse_args() -> argparse.Namespace:
"--quantization",
type=str,
default=None,
choices=["fp8", "mxfp8", "int8", "gguf"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 online quant (NPU). fp8: online FP8 (GPU).",
choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8", "gguf"],
help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 (NPU). mxfp4: W4A4 MXFP4 (NPU). mxfp4_dualscale: W4A4 MXFP4 dual-scale + BF16 fallback mixed (NPU). fp8: online FP8 (GPU).",
)
# Distributed and parallel execution
@@ -0,0 +1,383 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for transformer quant-config auto-detection.
The loader path at pipeline_wan2_2.py carries two quantization contracts:
create_transformer_from_config (~L137)
- Reads quantization_config from config.json (injected by the merge scripts)
- Auto-detects the quant method when no CLI quant_config is provided
- Rejects method mismatches (CLI vs disk)
- Upgrades online → offline when disk marks is_checkpoint_*_serialized=True
- Rebuilds when the active ignored_layers differs from the disk value
Wan22Pipeline._create_transformer (~L456)
- Passes od_config.quantization_config (set by CLI or externally) to
create_transformer_from_config for each transformer independently.
- When od_config.quantization_config is None, each transformer auto-detects
from its own config.json; od_config is NOT modified by this call.
All tests are pure-CPU and do not load model weights.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 as wan22_module
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import create_transformer_from_config
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
# Minimum config that create_transformer_from_config accepts without raising.
_MIN_CFG: dict = {"patch_size": [1, 2, 2]}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_fake_transformer():
"""Return (FakeTransformer class, captured list).
Each _FakeTransformer.__init__ call appends its **kwargs to captured,
letting tests inspect what quant_config was passed per transformer.
"""
captured: list[dict] = []
class _FakeTransformer:
def __init__(self, **kwargs):
captured.append(kwargs)
return _FakeTransformer, captured
class _FakePipeline:
"""Minimal stand-in exposing only what _create_transformer needs from self."""
def __init__(self, od_config: SimpleNamespace) -> None:
self.od_config = od_config
# Bind the real unbound method so the tests exercise production code.
_create_transformer = wan22_module.Wan22Pipeline._create_transformer
# ---------------------------------------------------------------------------
# create_transformer_from_config — auto-detection
# ---------------------------------------------------------------------------
def test_create_transformer_detects_mxfp8_serialized_from_config_json(monkeypatch):
"""When config.json carries MXFP8 quant and no CLI quant_config is provided,
the transformer must receive a DiffusionMXFP8Config with serialized=True."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
create_transformer_from_config(config)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP8Config)
assert qc.is_checkpoint_mxfp8_serialized is True
def test_create_transformer_detects_mxfp4_dualscale_from_config_json(monkeypatch):
"""config.json with mxfp4_dualscale + ignored_layers must produce
a DiffusionMXFP4DualScaleMixedConfig with the correct ignored_layers and
serialized flag."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q", "blocks.0.attn1.to_k"],
"is_checkpoint_serialized": True,
},
}
create_transformer_from_config(config)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP4DualScaleMixedConfig)
assert set(qc.ignored_layers) == {"blocks.0.attn1.to_q", "blocks.0.attn1.to_k"}
assert qc.is_checkpoint_serialized is True
def test_create_transformer_without_quantization_config_passes_no_quant(monkeypatch):
"""A plain BF16 config.json (no quantization_config key) must result in no
quant_config being passed to WanTransformer3DModel."""
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
create_transformer_from_config(_MIN_CFG)
assert "quant_config" not in captured[0]
# ---------------------------------------------------------------------------
# create_transformer_from_config — method-mismatch guard
# ---------------------------------------------------------------------------
def test_create_transformer_rejects_method_mismatch(monkeypatch):
"""Passing a CLI quant_config whose get_name() differs from the config.json
quant_method must raise ValueError immediately (prevents silent weight corruption).
fp8 (vLLM built-in, get_name()=='fp8') vs disk 'mxfp8' triggers the guard.
These are distinct methods; using the same method for both would not trigger it.
"""
from vllm.model_executor.layers.quantization.fp8 import Fp8Config
FakeTransformer, _ = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
fp8_cli = Fp8Config(is_checkpoint_fp8_serialized=True, activation_scheme="dynamic")
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
with pytest.raises(ValueError, match="quant_method"):
create_transformer_from_config(config, quant_config=fp8_cli)
# ---------------------------------------------------------------------------
# create_transformer_from_config — online → offline upgrade
# ---------------------------------------------------------------------------
def test_create_transformer_upgrades_to_serialized_when_disk_marks_it(monkeypatch):
"""CLI passes online (is_checkpoint_mxfp8_serialized=False) but config.json
marks is_checkpoint_mxfp8_serialized=True → must switch to offline (serialized)
so that pre-quantized FP8 tensors are loaded correctly."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
online_cli = DiffusionMXFP8Config(is_checkpoint_mxfp8_serialized=False)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
create_transformer_from_config(config, quant_config=online_cli)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP8Config)
assert qc.is_checkpoint_mxfp8_serialized is True
# ---------------------------------------------------------------------------
# create_transformer_from_config — ignored_layers rebuild
# ---------------------------------------------------------------------------
def test_create_transformer_rebuilds_when_ignored_layers_differ(monkeypatch):
"""When the active quant_config has different ignored_layers than config.json,
the config must be rebuilt from disk so per-layer BF16 routing is authoritative
for this specific transformer."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
stale = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=True,
ignored_layers=["blocks.0.attn1.to_q"],
)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q", "blocks.1.attn1.to_q"],
"is_checkpoint_serialized": True,
},
}
create_transformer_from_config(config, quant_config=stale)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP4DualScaleMixedConfig)
assert set(qc.ignored_layers) == {"blocks.0.attn1.to_q", "blocks.1.attn1.to_q"}
def test_create_transformer_does_not_rebuild_when_ignored_layers_match(monkeypatch):
"""When the active quant_config already has the same ignored_layers,
the same instance must be passed through unchanged (no unnecessary rebuild)."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
matching = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=True,
ignored_layers=["blocks.0.attn1.to_q"],
)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q"],
"is_checkpoint_serialized": True,
},
}
create_transformer_from_config(config, quant_config=matching)
assert captured[0].get("quant_config") is matching
# ---------------------------------------------------------------------------
# Wan22Pipeline._create_transformer — od_config passthrough
# ---------------------------------------------------------------------------
def test_pipeline_create_transformer_auto_detects_from_config_json(monkeypatch):
"""When od_config.quantization_config is None, _create_transformer must
auto-detect the quant method from config.json and pass it to the transformer.
od_config itself must remain unchanged (None)."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
od_config = SimpleNamespace(quantization_config=None)
pipeline = _FakePipeline(od_config)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
pipeline._create_transformer(config)
qc = captured[0].get("quant_config")
assert isinstance(qc, DiffusionMXFP8Config)
assert qc.is_checkpoint_mxfp8_serialized is True
# od_config must NOT be modified — each transformer auto-detects independently.
assert od_config.quantization_config is None
def test_pipeline_create_transformer_does_not_overwrite_existing_od_config(monkeypatch):
"""If od_config.quantization_config is already set (e.g. via CLI), _create_transformer
must pass it through to the transformer unchanged and leave od_config unmodified."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, _ = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
existing = DiffusionMXFP8Config(is_checkpoint_mxfp8_serialized=True)
od_config = SimpleNamespace(quantization_config=existing)
pipeline = _FakePipeline(od_config)
config = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp8",
"is_checkpoint_mxfp8_serialized": True,
},
}
pipeline._create_transformer(config)
assert od_config.quantization_config is existing
# ---------------------------------------------------------------------------
# Wan22Pipeline._create_transformer — cascade contracts
# ---------------------------------------------------------------------------
def test_pipeline_cascade_both_transformers_get_mxfp8_serialized_config(monkeypatch):
"""Cascade model (transformer + transformer_2) with MXFP8 checkpoint:
- Each transformer auto-detects from its own config.json independently.
Both must receive is_checkpoint_mxfp8_serialized=True."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
od_config = SimpleNamespace(quantization_config=None)
pipeline = _FakePipeline(od_config)
mxfp8_qc = {"quant_method": "mxfp8", "is_checkpoint_mxfp8_serialized": True}
pipeline._create_transformer({**_MIN_CFG, "quantization_config": mxfp8_qc})
pipeline._create_transformer({**_MIN_CFG, "quantization_config": mxfp8_qc})
assert len(captured) == 2
for i, kwargs in enumerate(captured):
qc = kwargs.get("quant_config")
assert isinstance(qc, DiffusionMXFP8Config), f"transformer[{i}]: expected DiffusionMXFP8Config, got {type(qc)}"
assert qc.is_checkpoint_mxfp8_serialized is True, f"transformer[{i}]: expected serialized=True"
def test_pipeline_cascade_mxfp4_dualscale_each_transformer_gets_correct_ignored_layers(monkeypatch):
"""Cascade with mxfp4_dualscale where transformer and transformer_2 have
different ignored_layers in their config.json.
Expected outcome:
transformer → ignored_layers=["blocks.0.attn1.to_q"] (auto-detected from its config.json)
transformer_2 → ignored_layers=["blocks.0.attn1.to_q", "blocks.1.attn1.to_q"]
(auto-detected from its own config.json independently)
od_config → quantization_config remains None (not modified by _create_transformer)
"""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
FakeTransformer, captured = _make_fake_transformer()
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
od_config = SimpleNamespace(quantization_config=None)
pipeline = _FakePipeline(od_config)
cfg1 = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q"],
"is_checkpoint_serialized": True,
},
}
cfg2 = {
**_MIN_CFG,
"quantization_config": {
"quant_method": "mxfp4_dualscale",
"ignored_layers": ["blocks.0.attn1.to_q", "blocks.1.attn1.to_q"],
"is_checkpoint_serialized": True,
},
}
pipeline._create_transformer(cfg1)
pipeline._create_transformer(cfg2)
assert len(captured) == 2
qc1 = captured[0].get("quant_config")
qc2 = captured[1].get("quant_config")
assert isinstance(qc1, DiffusionMXFP4DualScaleMixedConfig)
assert isinstance(qc2, DiffusionMXFP4DualScaleMixedConfig)
assert set(qc1.ignored_layers) == {"blocks.0.attn1.to_q"}, f"transformer expected 1 layer, got {qc1.ignored_layers}"
assert set(qc2.ignored_layers) == {
"blocks.0.attn1.to_q",
"blocks.1.attn1.to_q",
}, f"transformer_2 expected 2 layers, got {qc2.ignored_layers}"
# od_config must remain unchanged — _create_transformer does not modify it.
assert od_config.quantization_config is None
@@ -0,0 +1,539 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for MXFP4 quantization configs and the MXFP4 DualScale + BF16 mixed config."""
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@pytest.fixture(autouse=True)
def _patch_tp_state(monkeypatch):
"""Patch TP rank/world_size so ModelWeightParameter can be instantiated on CPU
without an initialized distributed group. Returns TP=1 rank=0 for all tests."""
monkeypatch.setattr("vllm.model_executor.parameter.get_tensor_model_parallel_rank", lambda: 0)
monkeypatch.setattr("vllm.model_executor.parameter.get_tensor_model_parallel_world_size", lambda: 1)
# ---------------------------------------------------------------------------
# DiffusionMXFP4Config
# ---------------------------------------------------------------------------
def test_mxfp4_config_get_name():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
assert DiffusionMXFP4Config.get_name() == "mxfp4"
def test_mxfp4_config_from_config_defaults():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = DiffusionMXFP4Config.from_config({})
assert cfg.is_checkpoint_mxfp4_serialized is False
assert cfg.ignored_layers == []
def test_mxfp4_config_from_config_serialized():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = DiffusionMXFP4Config.from_config({"is_checkpoint_mxfp4_serialized": True})
assert cfg.is_checkpoint_mxfp4_serialized is True
def test_mxfp4_config_from_config_ignored_layers():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = DiffusionMXFP4Config.from_config({"ignored_layers": ["proj_out"]})
assert cfg.ignored_layers == ["proj_out"]
def test_mxfp4_config_from_config_modules_to_not_convert_fallback():
"""modules_to_not_convert must be accepted as an alias for ignored_layers."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = DiffusionMXFP4Config.from_config({"modules_to_not_convert": ["proj_out"]})
assert cfg.ignored_layers == ["proj_out"]
# ---------------------------------------------------------------------------
# build_quant_config integration
# ---------------------------------------------------------------------------
def test_build_quant_config_mxfp4_string():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = build_quant_config("mxfp4")
assert isinstance(cfg, DiffusionMXFP4Config)
assert cfg.get_name() == "mxfp4"
assert cfg.is_checkpoint_mxfp4_serialized is False
def test_build_quant_config_mxfp4_dict():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config
cfg = build_quant_config({"method": "mxfp4", "is_checkpoint_mxfp4_serialized": True})
assert isinstance(cfg, DiffusionMXFP4Config)
assert cfg.is_checkpoint_mxfp4_serialized is True
def test_build_quant_config_mxfp4_dualscale_string():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = build_quant_config("mxfp4_dualscale")
assert isinstance(cfg, DiffusionMXFP4DualScaleMixedConfig)
assert cfg.is_checkpoint_serialized is False
assert cfg.num_bf16_fallback_layers == 5
assert cfg.ignored_layers == []
def test_build_quant_config_mxfp4_dualscale_dict_offline():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = build_quant_config(
{
"method": "mxfp4_dualscale",
"is_checkpoint_serialized": True,
"ignored_layers": ["blocks.0.attn1.to_q", "blocks.0.attn1.to_k"],
}
)
assert isinstance(cfg, DiffusionMXFP4DualScaleMixedConfig)
assert cfg.is_checkpoint_serialized is True
assert cfg.ignored_layers == ["blocks.0.attn1.to_q", "blocks.0.attn1.to_k"]
def test_build_quant_config_mxfp4_dualscale_dict_online_custom_fallback():
from vllm_omni.quantization import build_quant_config
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = build_quant_config({"method": "mxfp4_dualscale", "num_bf16_fallback_layers": 10})
assert isinstance(cfg, DiffusionMXFP4DualScaleMixedConfig)
assert cfg.num_bf16_fallback_layers == 10
# ---------------------------------------------------------------------------
# Block-index dispatch (_parse_block_idx)
# ---------------------------------------------------------------------------
def test_parse_block_idx_valid():
from vllm_omni.quantization.mxfp4_config import _parse_block_idx
assert _parse_block_idx("blocks.0.attn1.to_q") == 0
assert _parse_block_idx("blocks.5.ffn.net.0.proj") == 5
assert _parse_block_idx("blocks.40.norm1.weight") == 40
def test_parse_block_idx_non_block_prefixes():
"""Prefixes that do not start with 'blocks.N.' must return None."""
from vllm_omni.quantization.mxfp4_config import _parse_block_idx
assert _parse_block_idx("condition_embedder.time_embedder.linear_1") is None
assert _parse_block_idx("proj_out.weight") is None
assert _parse_block_idx("model.layers.0.self_attn.q_proj") is None
assert _parse_block_idx("scale_shift_table") is None
# ---------------------------------------------------------------------------
# SUPPORTED_QUANTIZATION_METHODS
# ---------------------------------------------------------------------------
def test_supported_methods_include_mxfp4_variants():
from vllm_omni.quantization import SUPPORTED_QUANTIZATION_METHODS
assert "mxfp4" in SUPPORTED_QUANTIZATION_METHODS
assert "mxfp8" in SUPPORTED_QUANTIZATION_METHODS
assert "mxfp4_dualscale" in SUPPORTED_QUANTIZATION_METHODS
# ---------------------------------------------------------------------------
# DiffusionMXFP4DualScaleMixedConfig — config roundtrips
# ---------------------------------------------------------------------------
def test_mixed_dualscale_config_get_name():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
assert DiffusionMXFP4DualScaleMixedConfig.get_name() == "mxfp4_dualscale"
def test_mixed_dualscale_config_no_args_defaults():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig()
assert cfg.is_checkpoint_serialized is False
assert cfg.ignored_layers == []
assert cfg.num_bf16_fallback_layers == 5
def test_mixed_dualscale_config_from_config_offline():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig.from_config(
{
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": True,
"ignored_layers": ["blocks.0.attn1.to_q", "proj_out"],
}
)
assert cfg.is_checkpoint_serialized is True
assert cfg.ignored_layers == ["blocks.0.attn1.to_q", "proj_out"]
assert cfg.num_bf16_fallback_layers == 5 # default
def test_mixed_dualscale_config_from_config_online_custom_fallback():
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig.from_config({"num_bf16_fallback_layers": 10})
assert cfg.is_checkpoint_serialized is False
assert cfg.num_bf16_fallback_layers == 10
def test_mixed_dualscale_config_from_config_modules_to_not_convert_fallback():
"""modules_to_not_convert must be accepted as an alias for ignored_layers."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig.from_config(
{"is_checkpoint_serialized": True, "modules_to_not_convert": ["proj_out"]}
)
assert cfg.ignored_layers == ["proj_out"]
# ---------------------------------------------------------------------------
# DiffusionMXFP4DualScaleMixedConfig — get_quant_method dispatch
# ---------------------------------------------------------------------------
def test_mixed_dualscale_offline_ignored_layer_returns_unquantized(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Offline: a prefix in ignored_layers must return UnquantizedLinearMethod."""
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=True,
ignored_layers=["blocks.0.attn1.to_q"],
)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = cfg.get_quant_method(layer, "blocks.0.attn1.to_q")
assert isinstance(method, UnquantizedLinearMethod)
def test_mixed_dualscale_offline_non_ignored_returns_mxfp4(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Offline: a prefix NOT in ignored_layers must return NPUMxfp4DualScaleLinearMethod."""
from vllm.model_executor.layers.linear import LinearBase
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
cfg = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=True,
ignored_layers=["blocks.0.attn1.to_q"],
)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = cfg.get_quant_method(layer, "blocks.1.attn1.to_q")
assert isinstance(method, NPUMxfp4DualScaleLinearMethod)
def test_mixed_dualscale_online_fallback_block_returns_unquantized(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Online: blocks < num_bf16_fallback_layers must return UnquantizedLinearMethod."""
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig(is_checkpoint_serialized=False, num_bf16_fallback_layers=5)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
assert isinstance(cfg.get_quant_method(layer, "blocks.0.attn1.to_q"), UnquantizedLinearMethod)
assert isinstance(cfg.get_quant_method(layer, "blocks.4.ffn.net.0.proj"), UnquantizedLinearMethod)
def test_mixed_dualscale_online_quantized_block_returns_mxfp4(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Online: blocks >= num_bf16_fallback_layers must return NPUMxfp4DualScaleOnlineLinearMethod."""
from vllm.model_executor.layers.linear import LinearBase
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import (
DiffusionMXFP4DualScaleMixedConfig,
NPUMxfp4DualScaleOnlineLinearMethod,
)
cfg = DiffusionMXFP4DualScaleMixedConfig(is_checkpoint_serialized=False, num_bf16_fallback_layers=5)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
assert isinstance(cfg.get_quant_method(layer, "blocks.5.attn1.to_q"), NPUMxfp4DualScaleOnlineLinearMethod)
assert isinstance(cfg.get_quant_method(layer, "blocks.40.ffn.net.0.proj"), NPUMxfp4DualScaleOnlineLinearMethod)
def test_mixed_dualscale_online_non_block_prefix_returns_mxfp4(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Online: layers outside 'blocks.N.*' (condition_embedder etc.) always use MXFP4 online."""
from vllm.model_executor.layers.linear import LinearBase
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import (
DiffusionMXFP4DualScaleMixedConfig,
NPUMxfp4DualScaleOnlineLinearMethod,
)
cfg = DiffusionMXFP4DualScaleMixedConfig(is_checkpoint_serialized=False, num_bf16_fallback_layers=5)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = cfg.get_quant_method(layer, "condition_embedder.time_embedder.linear_1")
assert isinstance(method, NPUMxfp4DualScaleOnlineLinearMethod)
def test_mixed_dualscale_online_ignored_layers_override(
mocker,
monkeypatch: pytest.MonkeyPatch,
):
"""Online: explicit ignored_layers must return UnquantizedLinearMethod regardless of block index.
A layer that is NOT in the leading-block range (block 10 >= num_bf16_fallback_layers=5)
but IS listed in ignored_layers must still fall back to BF16. This lets power users
pin specific interleaved layers to BF16 during online quantization without needing an
offline checkpoint.
"""
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig(
is_checkpoint_serialized=False,
num_bf16_fallback_layers=5,
ignored_layers=["blocks.10.attn1.to_q"],
)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
# block 10 is above the leading-block threshold but is in ignored_layers → BF16
assert isinstance(cfg.get_quant_method(layer, "blocks.10.attn1.to_q"), UnquantizedLinearMethod)
def test_mixed_dualscale_non_linear_returns_none(monkeypatch: pytest.MonkeyPatch):
"""Non-LinearBase layers (norms, embeddings) must return None → no quantization."""
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
cfg = DiffusionMXFP4DualScaleMixedConfig()
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
norm_layer = torch.nn.LayerNorm(64)
assert cfg.get_quant_method(norm_layer, "blocks.0.norm1") is None
# ---------------------------------------------------------------------------
# TP=2 create_weights: parameter shapes and input_dim/output_dim
#
# Two scenarios mirror real Wan2.2 A14B linear layer types:
# Column-parallel (to_q, ffn.net_0): output is sharded (N/TP), input is full (K).
# Row-parallel (to_out, ffn.net_2): input is sharded (K/TP), output is full (N).
#
# Tests verify:
# 1. Registered parameter shapes are correct for each partition configuration.
# 2. input_dim/output_dim attributes are set so RowParallelLinear.weight_loader
# can shard scale tensors correctly (the fix for the TP>1 shape-mismatch bug).
# 3. Simulated loader slicing: slicing the full checkpoint tensor along the
# declared input_dim produces the exact shape stored in the parameter —
# proving the dim declaration is consistent with the allocation.
# ---------------------------------------------------------------------------
# K must be divisible by 32 (fine groups) and 512 (coarse groups).
_TP2_K, _TP2_N, _TP2 = 1024, 512, 2
class _FakeLayer(torch.nn.Module):
"""Bare nn.Module that accepts register_parameter without a real weight_loader."""
def _create_weights(method, *, input_size_per_partition, output_partition_sizes):
layer = _FakeLayer()
method.create_weights(
layer=layer,
input_size_per_partition=input_size_per_partition,
output_partition_sizes=output_partition_sizes,
input_size=_TP2_K,
output_size=_TP2_N,
params_dtype=torch.bfloat16,
)
return layer
def _shard(tensor, param, rank, tp, dim_attr):
"""Slice `tensor` along the dimension given by `param.<dim_attr>` for `rank`."""
dim = getattr(param, dim_attr)
if dim is None:
return tensor # not sharded along this axis
shard_size = param.shape[dim]
slices = [slice(None)] * tensor.ndim
slices[dim] = slice(rank * shard_size, (rank + 1) * shard_size)
return tensor[tuple(slices)]
# ── DualScale method ─────────────────────────────────────────────────────────
def test_dualscale_column_parallel_tp2_shapes():
"""Column-parallel TP=2: output halved, fine/coarse groups stay full, mul_scale full."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N // _TP2])
assert layer.weight.shape == (_TP2_N // _TP2, _TP2_K)
assert layer.weight_scale.shape == (_TP2_N // _TP2, _TP2_K // 32)
assert layer.weight_dual_scale.shape == (_TP2_N // _TP2, _TP2_K // 512, 1)
assert layer.mul_scale.shape == (_TP2_K,)
def test_dualscale_row_parallel_tp2_shapes():
"""Row-parallel TP=2: input halved, fine/coarse groups halved, mul_scale halved."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
assert layer.weight.shape == (_TP2_N, _TP2_K // _TP2)
assert layer.weight_scale.shape == (_TP2_N, (_TP2_K // _TP2) // 32)
assert layer.weight_dual_scale.shape == (_TP2_N, (_TP2_K // _TP2) // 512, 1)
assert layer.mul_scale.shape == (_TP2_K // _TP2,)
def test_dualscale_scale_parameter_input_dims():
"""weight_scale/weight_dual_scale must have input_dim=1; mul_scale must have input_dim=0.
RowParallelLinear.weight_loader only shards a parameter when input_dim is set.
Without these, loading a full checkpoint tensor into a per-rank shape causes a
shape mismatch for TP>1 row-parallel layers (to_out, ffn.net_2).
"""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N])
assert layer.weight_scale.input_dim == 1
assert layer.weight_scale.output_dim == 0
assert layer.weight_dual_scale.input_dim == 1
assert layer.weight_dual_scale.output_dim == 0
assert layer.mul_scale.input_dim == 0
assert layer.mul_scale.output_dim is None
def test_dualscale_row_parallel_tp2_loader_simulation():
"""Slicing full checkpoint tensors along input_dim must match row-parallel parameter shapes.
Simulates what RowParallelLinear.weight_loader does: for each scale parameter,
take the slice at rank*shard_size:(rank+1)*shard_size along input_dim.
The resulting shape must equal the per-rank parameter shape allocated by create_weights.
"""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
# Full checkpoint tensors (what the loader reads from disk).
ckpt_weight_scale = torch.zeros(_TP2_N, _TP2_K // 32)
ckpt_weight_dual_scale = torch.zeros(_TP2_N, _TP2_K // 512, 1)
ckpt_mul_scale = torch.zeros(_TP2_K)
for rank in range(_TP2):
assert _shard(ckpt_weight_scale, layer.weight_scale, rank, _TP2, "input_dim").shape == layer.weight_scale.shape
assert (
_shard(ckpt_weight_dual_scale, layer.weight_dual_scale, rank, _TP2, "input_dim").shape
== layer.weight_dual_scale.shape
)
assert _shard(ckpt_mul_scale, layer.mul_scale, rank, _TP2, "input_dim").shape == layer.mul_scale.shape
def test_dualscale_column_parallel_tp2_loader_simulation():
"""Slicing full checkpoint tensors along output_dim must match column-parallel parameter shapes.
For column-parallel layers, the loader shards along output_dim (rows).
mul_scale has output_dim=None → not sharded (full tensor, same for all ranks).
"""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4DualScaleMixedConfig, NPUMxfp4DualScaleLinearMethod
method = NPUMxfp4DualScaleLinearMethod(DiffusionMXFP4DualScaleMixedConfig())
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N // _TP2])
ckpt_weight_scale = torch.zeros(_TP2_N, _TP2_K // 32)
ckpt_weight_dual_scale = torch.zeros(_TP2_N, _TP2_K // 512, 1)
ckpt_mul_scale = torch.zeros(_TP2_K)
for rank in range(_TP2):
assert _shard(ckpt_weight_scale, layer.weight_scale, rank, _TP2, "output_dim").shape == layer.weight_scale.shape
assert (
_shard(ckpt_weight_dual_scale, layer.weight_dual_scale, rank, _TP2, "output_dim").shape
== layer.weight_dual_scale.shape
)
# mul_scale: output_dim=None → no sharding → full tensor fits the column-parallel parameter
assert _shard(ckpt_mul_scale, layer.mul_scale, rank, _TP2, "output_dim").shape == layer.mul_scale.shape
# ── Single-scale method ───────────────────────────────────────────────────────
def test_single_scale_row_parallel_tp2_shapes():
"""Row-parallel TP=2: input halved → weight_scale groups halved."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, NPUMxfp4LinearMethod
method = NPUMxfp4LinearMethod(DiffusionMXFP4Config())
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
assert layer.weight.shape == (_TP2_N, _TP2_K // _TP2)
assert layer.weight_scale.shape == (_TP2_N, (_TP2_K // _TP2) // 32)
def test_single_scale_scale_parameter_input_dims():
"""Single-scale weight_scale must have input_dim=1 for RowParallel TP sharding."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, NPUMxfp4LinearMethod
method = NPUMxfp4LinearMethod(DiffusionMXFP4Config())
layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N])
assert layer.weight_scale.input_dim == 1
assert layer.weight_scale.output_dim == 0
def test_single_scale_row_parallel_tp2_loader_simulation():
"""Slicing full checkpoint weight_scale along input_dim matches row-parallel parameter shape."""
from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, NPUMxfp4LinearMethod
method = NPUMxfp4LinearMethod(DiffusionMXFP4Config())
layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N])
ckpt_weight_scale = torch.zeros(_TP2_N, _TP2_K // 32)
for rank in range(_TP2):
assert _shard(ckpt_weight_scale, layer.weight_scale, rank, _TP2, "input_dim").shape == layer.weight_scale.shape
@@ -0,0 +1,399 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for merge_mxfp4_dualscale_checkpoint.py key-remapping helpers.
These are pure-Python unit tests that exercise the transformation functions
without loading any actual checkpoint files or requiring NPU hardware.
"""
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
# ---------------------------------------------------------------------------
# SUPPORTED_MODEL_TYPES
# ---------------------------------------------------------------------------
def test_supported_model_types_includes_a14b():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import SUPPORTED_MODEL_TYPES
assert "Wan2.2-T2V-A14B" in SUPPORTED_MODEL_TYPES
assert "Wan2.2-I2V-A14B" in SUPPORTED_MODEL_TYPES
def test_supported_model_types_excludes_ti2v_5b():
"""TI2V-5B is explicitly NOT supported under W4A4 quantization."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import SUPPORTED_MODEL_TYPES
assert "Wan2.2-TI2V-5B" not in SUPPORTED_MODEL_TYPES
# ---------------------------------------------------------------------------
# _apply_rename_dict
# ---------------------------------------------------------------------------
def test_apply_rename_dict_self_attn_qkvo():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("blocks.0.self_attn.q.weight") == "blocks.0.attn1.to_q.weight"
assert _apply_rename_dict("blocks.0.self_attn.k.weight") == "blocks.0.attn1.to_k.weight"
assert _apply_rename_dict("blocks.0.self_attn.v.weight") == "blocks.0.attn1.to_v.weight"
assert _apply_rename_dict("blocks.0.self_attn.o.weight") == "blocks.0.attn1.to_out.0.weight"
def test_apply_rename_dict_ffn():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("blocks.1.ffn.0.weight") == "blocks.1.ffn.net.0.proj.weight"
assert _apply_rename_dict("blocks.1.ffn.2.weight") == "blocks.1.ffn.net.2.weight"
def test_apply_rename_dict_norm_order_swap():
"""norm2↔norm3 swap: quant tool uses norm1/norm3/norm2 order,
Diffusers uses norm1/norm2/norm3."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("blocks.0.norm2.weight") == "blocks.0.norm3.weight"
assert _apply_rename_dict("blocks.0.norm3.weight") == "blocks.0.norm2.weight"
def test_apply_rename_dict_cross_attn():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("blocks.0.cross_attn.q.weight") == "blocks.0.attn2.to_q.weight"
assert _apply_rename_dict("blocks.0.cross_attn.k.weight") == "blocks.0.attn2.to_k.weight"
assert _apply_rename_dict("blocks.0.cross_attn.v.weight") == "blocks.0.attn2.to_v.weight"
assert _apply_rename_dict("blocks.0.cross_attn.o.weight") == "blocks.0.attn2.to_out.0.weight"
def test_apply_rename_dict_head_and_modulation():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _apply_rename_dict
assert _apply_rename_dict("head.head.weight") == "proj_out.weight"
# ---------------------------------------------------------------------------
# _strip_mxfp4_wrapper
# ---------------------------------------------------------------------------
def test_strip_mxfp4_wrapper_linear_weight():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.linear.weight") == "blocks.0.attn1.to_q.weight"
def test_strip_mxfp4_wrapper_linear_weight_scale():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.linear.weight_scale") == "blocks.0.attn1.to_q.weight_scale"
def test_strip_mxfp4_wrapper_linear_weight_dual_scale():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert (
_strip_mxfp4_wrapper("blocks.0.attn1.to_q.linear.weight_dual_scale") == "blocks.0.attn1.to_q.weight_dual_scale"
)
def test_strip_mxfp4_wrapper_linear_bias():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.linear.bias") == "blocks.0.attn1.to_q.bias"
def test_strip_mxfp4_wrapper_div_mul_scale():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.div.mul_scale") == "blocks.0.attn1.to_q.mul_scale"
def test_strip_mxfp4_wrapper_noop_for_plain_weight():
"""MXFP8 / FLOAT tensors have no wrapper — must be returned unchanged."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _strip_mxfp4_wrapper
assert _strip_mxfp4_wrapper("blocks.0.attn1.to_q.weight") == "blocks.0.attn1.to_q.weight"
assert _strip_mxfp4_wrapper("blocks.0.norm_q.weight") == "blocks.0.norm_q.weight"
assert _strip_mxfp4_wrapper("condition_embedder.time_embedder.linear_1.weight") == (
"condition_embedder.time_embedder.linear_1.weight"
)
# ---------------------------------------------------------------------------
# _classify_blocks
# ---------------------------------------------------------------------------
def test_classify_blocks_mixed_mxfp8_and_mxfp4():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _classify_blocks
quant_meta = {
"blocks.0.attn1.to_q.weight": "W8A8_MXFP8",
"blocks.1.attn1.to_q.weight": "W8A8_MXFP8",
"blocks.2.attn1.to_q.weight": "W4A4_MXFP4_DUALSCALE",
"blocks.3.attn1.to_q.weight": "W4A4_MXFP4_DUALSCALE",
"condition_embedder.time_embedder.linear_1.weight": "FLOAT",
}
block_types = _classify_blocks(quant_meta)
assert block_types[0] == "mxfp8"
assert block_types[1] == "mxfp8"
assert block_types[2] == "mxfp4_dualscale"
assert block_types[3] == "mxfp4_dualscale"
# Non-block key must not produce an entry
assert None not in block_types
def test_classify_blocks_float_entries_skipped():
"""FLOAT-typed tensors must not contribute to block classification."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _classify_blocks
quant_meta = {
"blocks.0.bias": "FLOAT",
"blocks.1.attn1.to_q.weight": "W4A4_MXFP4_DUALSCALE",
}
block_types = _classify_blocks(quant_meta)
assert 0 not in block_types
assert block_types[1] == "mxfp4_dualscale"
def test_classify_blocks_empty():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _classify_blocks
assert _classify_blocks({}) == {}
# ---------------------------------------------------------------------------
# _is_mxfp4_tensor
# ---------------------------------------------------------------------------
def test_is_mxfp4_tensor_quantized_weight():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _is_mxfp4_tensor
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.linear.weight", "W4A4_MXFP4_DUALSCALE") is True
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.linear.weight_scale", "W4A4_MXFP4_DUALSCALE") is True
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.linear.weight_dual_scale", "W4A4_MXFP4_DUALSCALE") is True
def test_is_mxfp4_tensor_companion_bias_and_mul_scale():
"""Companion tensors (bias, mul_scale) are FLOAT but belong to MXFP4 layers
and must be included in the merged checkpoint."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _is_mxfp4_tensor
# bias inside .linear. wrapper → must be included
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.linear.bias", "FLOAT") is True
# mul_scale inside .div. wrapper → must be included
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.div.mul_scale", "FLOAT") is True
def test_is_mxfp4_tensor_bf16_fallback_weight():
"""BF16 fallback linear layers have plain .weight keys (no wrapper) → must NOT be included."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _is_mxfp4_tensor
assert _is_mxfp4_tensor("blocks.0.attn1.to_q.weight", "FLOAT") is False
assert _is_mxfp4_tensor("condition_embedder.time_embedder.linear_1.weight", "FLOAT") is False
def test_is_mxfp4_tensor_norm_weight():
"""Norm layers are always FLOAT with plain .weight → not MXFP4 related."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _is_mxfp4_tensor
assert _is_mxfp4_tensor("blocks.0.norm1.weight", "FLOAT") is False
# ---------------------------------------------------------------------------
# _collect_ignored_layers
# ---------------------------------------------------------------------------
def test_collect_ignored_layers_basic():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _collect_ignored_layers
# block 0: all three of Q/K/V are BF16 → fused as to_qkv in ignored_layers
# block 1: to_qkv is MXFP4 → not in ignored_layers
merged = {
"blocks.0.attn1.to_q.weight": torch.zeros(4, 4),
"blocks.0.attn1.to_k.weight": torch.zeros(4, 4),
"blocks.0.attn1.to_v.weight": torch.zeros(4, 4),
"blocks.1.attn1.to_q.weight": torch.zeros(4, 4),
"blocks.1.attn1.to_q.weight_scale": torch.zeros(4, 1),
}
mxfp4_prefixes = {"blocks.1.attn1.to_q"}
ignored = _collect_ignored_layers(merged, mxfp4_prefixes)
assert "blocks.0.attn1.to_qkv" in ignored # fused vllm-omni name
assert "blocks.0.attn1.to_q" not in ignored # diffusers name must not appear
assert "blocks.0.attn1.to_k" not in ignored
assert "blocks.1.attn1.to_qkv" not in ignored # MXFP4, not ignored
def test_collect_ignored_layers_empty_mxfp4():
"""When no layers are MXFP4 (all BF16), all .weight prefixes become ignored_layers."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _collect_ignored_layers
# Use non-attn layers to avoid the Q/K/V completeness requirement.
merged = {
"blocks.0.ffn.net.0.proj.weight": torch.zeros(4, 4),
"proj_out.weight": torch.zeros(4, 4),
}
ignored = _collect_ignored_layers(merged, set())
assert sorted(ignored) == ["blocks.0.ffn.net_0.proj", "proj_out"]
def test_collect_ignored_layers_returns_sorted():
"""ignored_layers must be sorted for deterministic config.json output."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _collect_ignored_layers
# Use FFN layers (no Q/K/V fusion concern) to isolate the sort guarantee.
merged = {
"blocks.2.ffn.net.0.proj.weight": torch.zeros(1),
"blocks.0.ffn.net.0.proj.weight": torch.zeros(1),
"blocks.1.ffn.net.0.proj.weight": torch.zeros(1),
}
ignored = _collect_ignored_layers(merged, set())
assert ignored == sorted(ignored)
# ---------------------------------------------------------------------------
# _build_quant_config (new mxfp4_dualscale format)
# ---------------------------------------------------------------------------
def test_build_quant_config_new_format():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _build_quant_config
config = _build_quant_config(["blocks.0.attn1.to_q", "proj_out"])
assert config["quant_method"] == "mxfp4_dualscale"
assert config["is_checkpoint_serialized"] is True
assert config["ignored_layers"] == ["blocks.0.attn1.to_q", "proj_out"]
assert "num_mxfp8_blocks" not in config
def test_build_quant_config_empty_ignored_layers():
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _build_quant_config
config = _build_quant_config([])
assert config["ignored_layers"] == []
# ---------------------------------------------------------------------------
# _diffusers_to_vllm_ignored
# ---------------------------------------------------------------------------
def test_remap_self_attn_qkv_fusion():
"""All three of attn1.to_q/k/v in BF16 → fused attn1.to_qkv."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
result = _diffusers_to_vllm_ignored(["blocks.0.attn1.to_k", "blocks.0.attn1.to_q", "blocks.0.attn1.to_v"])
assert result == ["blocks.0.attn1.to_qkv"]
def test_remap_self_attn_qkv_partial_raises():
"""Partial Q/K/V in ignored_layers is invalid — must raise ValueError.
Self-attention Q/K/V are fused into a single to_qkv layer at runtime;
partial precision (some BF16, some MXFP4) cannot be expressed and must
be caught early in the merge script.
"""
import pytest
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
with pytest.raises(ValueError, match="Partial BF16 fallback"):
_diffusers_to_vllm_ignored(["blocks.0.attn1.to_q", "blocks.0.attn1.to_k"])
def test_remap_cross_attn_kept_separate():
"""Cross-attention (attn2) to_q/k/v must NOT be fused."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
result = _diffusers_to_vllm_ignored(["blocks.0.attn2.to_k", "blocks.0.attn2.to_q", "blocks.0.attn2.to_v"])
assert "blocks.0.attn2.to_qkv" not in result
assert "blocks.0.attn2.to_q" in result
assert "blocks.0.attn2.to_k" in result
assert "blocks.0.attn2.to_v" in result
def test_remap_ffn_dot_to_underscore():
"""ffn.net.0.proj → ffn.net_0.proj; ffn.net.2 → ffn.net_2."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
result = _diffusers_to_vllm_ignored(["blocks.0.ffn.net.0.proj", "blocks.0.ffn.net.2"])
assert "blocks.0.ffn.net_0.proj" in result
assert "blocks.0.ffn.net_2" in result
assert "blocks.0.ffn.net.0.proj" not in result
assert "blocks.0.ffn.net.2" not in result
def test_remap_to_out_strips_index():
"""attn1.to_out.0 and attn2.to_out.0 must have the .0 suffix removed."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
result = _diffusers_to_vllm_ignored(["blocks.0.attn1.to_out.0", "blocks.0.attn2.to_out.0"])
assert "blocks.0.attn1.to_out" in result
assert "blocks.0.attn2.to_out" in result
assert "blocks.0.attn1.to_out.0" not in result
assert "blocks.0.attn2.to_out.0" not in result
def test_remap_noop_for_non_block_layers():
"""Layers outside 'blocks.N.*' (condition_embedder, proj_out) pass through unchanged."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
inputs = [
"condition_embedder.time_embedder.linear_1",
"condition_embedder.text_embedder.linear_1",
"proj_out",
]
result = _diffusers_to_vllm_ignored(inputs)
assert result == sorted(inputs)
def test_remap_mixed_block_layers():
"""Typical mixed block: QKV fused, to_out stripped, FFN renamed, cross-attn separate."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
inputs = [
"blocks.0.attn1.to_k",
"blocks.0.attn1.to_out.0",
"blocks.0.attn1.to_q",
"blocks.0.attn1.to_v",
"blocks.0.attn2.to_k",
"blocks.0.attn2.to_out.0",
"blocks.0.attn2.to_q",
"blocks.0.attn2.to_v",
"blocks.0.ffn.net.0.proj",
"blocks.0.ffn.net.2",
]
result = _diffusers_to_vllm_ignored(inputs)
assert "blocks.0.attn1.to_qkv" in result
assert "blocks.0.attn1.to_out" in result
assert "blocks.0.attn2.to_q" in result
assert "blocks.0.attn2.to_k" in result
assert "blocks.0.attn2.to_v" in result
assert "blocks.0.attn2.to_out" in result
assert "blocks.0.ffn.net_0.proj" in result
assert "blocks.0.ffn.net_2" in result
# Old names must be gone
assert "blocks.0.attn1.to_q" not in result
assert "blocks.0.ffn.net.0.proj" not in result
def test_remap_returns_sorted():
"""Output of _diffusers_to_vllm_ignored must always be sorted."""
from vllm_omni.quantization.tools.merge_mxfp4_dualscale_checkpoint import _diffusers_to_vllm_ignored
inputs = [
"blocks.2.ffn.net.0.proj",
"blocks.0.attn1.to_q",
"blocks.0.attn1.to_k",
"blocks.0.attn1.to_v",
"blocks.1.attn2.to_out.0",
]
result = _diffusers_to_vllm_ignored(inputs)
assert result == sorted(result)
@@ -0,0 +1,308 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for MXFP8 quantization config and linear method dispatch.
Coverage:
- DiffusionMXFP8Config.from_config roundtrips (CPU, no NPU required)
- get_quant_method dispatch (mocked platform)
- MXFPLinearMethodBase.apply() reshape skeleton (CPU)
- Weight / scale shape-transform arithmetic from process_weights_after_loading (CPU)
- build_quant_config integration
- MXFP8_QUANT_CONFIG structure as the auto-detection contract
"""
import pytest
import torch
from pytest_mock import MockerFixture
from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization import build_quant_config
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
npu_available = pytest.mark.skipif(not current_omni_platform.is_npu(), reason="NPU platform not available")
# ---------------------------------------------------------------------------
# DiffusionMXFP8Config — from_config roundtrips
# ---------------------------------------------------------------------------
def test_mxfp8_config_get_name():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
assert DiffusionMXFP8Config.get_name() == "mxfp8"
def test_mxfp8_config_from_config_defaults():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = DiffusionMXFP8Config.from_config({})
assert cfg.is_checkpoint_mxfp8_serialized is False
assert cfg.ignored_layers == []
def test_mxfp8_config_from_config_serialized():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = DiffusionMXFP8Config.from_config({"is_checkpoint_mxfp8_serialized": True})
assert cfg.is_checkpoint_mxfp8_serialized is True
def test_mxfp8_config_from_config_ignored_layers():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = DiffusionMXFP8Config.from_config({"ignored_layers": ["proj_out"]})
assert cfg.ignored_layers == ["proj_out"]
def test_mxfp8_config_from_config_modules_to_not_convert_fallback():
"""modules_to_not_convert must be accepted as an alias for ignored_layers."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = DiffusionMXFP8Config.from_config({"modules_to_not_convert": ["proj_out"]})
assert cfg.ignored_layers == ["proj_out"]
# ---------------------------------------------------------------------------
# build_quant_config integration
# ---------------------------------------------------------------------------
def test_build_quant_config_mxfp8_string():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = build_quant_config("mxfp8")
assert isinstance(cfg, DiffusionMXFP8Config)
assert cfg.get_name() == "mxfp8"
assert cfg.is_checkpoint_mxfp8_serialized is False
def test_build_quant_config_mxfp8_dict():
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
cfg = build_quant_config({"method": "mxfp8", "is_checkpoint_mxfp8_serialized": True})
assert isinstance(cfg, DiffusionMXFP8Config)
assert cfg.is_checkpoint_mxfp8_serialized is True
def test_build_quant_config_mxfp8_config_json_format():
"""Verify that the exact quantization_config injected by merge_mxfp8_checkpoint.py
is accepted by build_quant_config and selects the offline (serialized) path.
This is the critical auto-detection contract: TransformerConfig.from_dict()
reads quant_method + is_checkpoint_mxfp8_serialized to pick NPUMxfp8LinearMethod.
"""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import MXFP8_QUANT_CONFIG
cfg = build_quant_config(MXFP8_QUANT_CONFIG)
assert isinstance(cfg, DiffusionMXFP8Config)
assert cfg.is_checkpoint_mxfp8_serialized is True
def test_mxfp8_quant_config_structure():
"""MXFP8_QUANT_CONFIG must contain exactly the keys that auto-detection reads."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import MXFP8_QUANT_CONFIG
assert MXFP8_QUANT_CONFIG.get("quant_method") == "mxfp8"
assert MXFP8_QUANT_CONFIG.get("is_checkpoint_mxfp8_serialized") is True
# ---------------------------------------------------------------------------
# get_quant_method dispatch
# ---------------------------------------------------------------------------
def test_get_quant_method_npu_offline(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""Offline (serialized) path on NPU must return NPUMxfp8LinearMethod."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config, NPUMxfp8LinearMethod
config = DiffusionMXFP8Config(is_checkpoint_mxfp8_serialized=True)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = config.get_quant_method(layer, "blocks.0.attn1.to_q")
assert isinstance(method, NPUMxfp8LinearMethod)
def test_get_quant_method_npu_online(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""Online (BF16 checkpoint) path on NPU must return NPUMxfp8OnlineLinearMethod."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config, NPUMxfp8OnlineLinearMethod
config = DiffusionMXFP8Config(is_checkpoint_mxfp8_serialized=False)
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = config.get_quant_method(layer, "blocks.0.attn1.to_q")
assert isinstance(method, NPUMxfp8OnlineLinearMethod)
def test_get_quant_method_non_npu_raises(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""Non-NPU platform must raise NotImplementedError (CUDA not yet supported)."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
config = DiffusionMXFP8Config()
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: False)
monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: False)
with pytest.raises(NotImplementedError):
config.get_quant_method(layer, "blocks.0.attn1.to_q")
def test_get_quant_method_ignored_layer(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""A prefix in ignored_layers must bypass quantization → UnquantizedLinearMethod."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
config = DiffusionMXFP8Config(ignored_layers=["proj_out"])
layer = mocker.Mock(spec=LinearBase)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
method = config.get_quant_method(layer, "proj_out")
assert isinstance(method, UnquantizedLinearMethod)
def test_get_quant_method_non_linear_returns_none(monkeypatch: pytest.MonkeyPatch):
"""Non-LinearBase layers (norms, embeddings) must get None → no quantization."""
from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config
config = DiffusionMXFP8Config()
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
norm_layer = torch.nn.LayerNorm(64)
assert config.get_quant_method(norm_layer, "blocks.0.norm1") is None
# ---------------------------------------------------------------------------
# MXFPLinearMethodBase.apply() — reshape skeleton (CPU, no NPU)
# ---------------------------------------------------------------------------
def test_apply_reshape_skeleton():
"""apply() must flatten batch dims → _apply_inner → restore original leading dims."""
from vllm_omni.quantization.mxfp8_config import MXFPLinearMethodBase
OUT_FEATURES = 4
class _StubMethod(MXFPLinearMethodBase):
def create_weights(
self,
layer,
input_size_per_partition,
output_partition_sizes,
input_size,
output_size,
params_dtype,
**extra_weight_attrs,
):
pass
def _quantize_activation(self, x):
return x, None
def _quant_matmul(self, x_q, x_scale, layer, bias, ori_dtype):
return torch.zeros(x_q.shape[0], OUT_FEATURES, dtype=ori_dtype)
method = _StubMethod()
x = torch.randn(2, 3, 8) # (batch=2, seq=3, K=8)
out = method.apply(None, x)
assert out.shape == (2, 3, OUT_FEATURES)
def test_apply_reshape_with_bias():
"""apply() must pass bias through to _apply_inner unchanged."""
from vllm_omni.quantization.mxfp8_config import MXFPLinearMethodBase
received_bias = []
class _StubMethod(MXFPLinearMethodBase):
def create_weights(
self,
layer,
input_size_per_partition,
output_partition_sizes,
input_size,
output_size,
params_dtype,
**extra_weight_attrs,
):
pass
def _quantize_activation(self, x):
return x, None
def _quant_matmul(self, x_q, x_scale, layer, bias, ori_dtype):
received_bias.append(bias)
return torch.zeros(x_q.shape[0], 4, dtype=ori_dtype)
method = _StubMethod()
bias = torch.zeros(4)
method.apply(None, torch.randn(2, 8), bias=bias)
assert received_bias[0] is bias
# ---------------------------------------------------------------------------
# process_weights_after_loading shape arithmetic (pure torch, no NPU ops)
#
# These tests replicate the CPU-safe portions of process_weights_after_loading
# to guard the key layout contract: (N,K) weight → (K,N) and (N,S) scale →
# (S/2,N,2). They do NOT call NPU ops; they test only the math.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"n, k",
[(64, 128), (32, 64), (16, 96)],
ids=["64x128", "32x64", "16x96"],
)
def test_weight_transpose_contract(n: int, k: int):
"""Weight must be transposed from (N, K) to (K, N) and be contiguous."""
w = torch.zeros(n, k, dtype=torch.uint8)
w = w.transpose(0, 1).contiguous()
assert w.shape == (k, n)
assert w.is_contiguous()
@pytest.mark.parametrize(
"n, k_groups, expected_groups",
[
(64, 4, 2), # even K_groups — no padding
(64, 3, 2), # odd K_groups — padded to 4
(32, 1, 1), # odd K_groups — padded to 2
],
ids=["even", "odd-to-4", "odd-to-2"],
)
def test_weight_scale_reshape_contract(n: int, k_groups: int, expected_groups: int):
"""Scale must be reshaped from (N, K_groups) to (K_groups_even//2, N, 2).
Odd K_groups must be padded to even before the reshape.
"""
s = torch.zeros(n, k_groups, dtype=torch.uint8)
if k_groups % 2 == 1:
s = torch.cat([s, torch.zeros(n, 1, dtype=s.dtype)], dim=1)
k_groups += 1
s = s.reshape(n, k_groups // 2, 2).transpose(0, 1).contiguous()
assert s.shape == (expected_groups, n, 2)
assert s.is_contiguous()
def test_num_groups_formula():
"""K_groups formula: ceil(K / 32) — spot-check boundary values."""
assert (31 + 31) // 32 == 1 # K=31 → 1 group
assert (32 + 31) // 32 == 1 # K=32 → 1 group
assert (33 + 31) // 32 == 2 # K=33 → 2 groups
assert (128 + 31) // 32 == 4 # K=128 → 4 groups (even)
assert (96 + 31) // 32 == 3 # K=96 → 3 groups (odd → needs padding)
# ---------------------------------------------------------------------------
# SUPPORTED_QUANTIZATION_METHODS
# ---------------------------------------------------------------------------
def test_supported_methods_include_mxfp8():
from vllm_omni.quantization import SUPPORTED_QUANTIZATION_METHODS
assert "mxfp8" in SUPPORTED_QUANTIZATION_METHODS
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for merge_mxfp8_checkpoint.py key-remapping helpers and model metadata.
These are pure-Python unit tests that exercise the transformation functions
without loading any actual checkpoint files or requiring NPU hardware.
Key contracts verified:
- SUPPORTED_MODEL_TYPES includes Wan2.2-TI2V-5B (MXFP8 supports it; MXFP4 does not)
- MXFP8_QUANT_CONFIG structure matches what TransformerConfig.from_dict() reads
- _remap_keys correctly translates msModelSlim naming → Diffusers naming
- _get_transformer_dirs routes cascade vs single-transformer models
- _get_quant_subdir maps high/low noise subdirs for cascade models
"""
import pathlib
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
# ---------------------------------------------------------------------------
# SUPPORTED_MODEL_TYPES and CASCADE_MODEL_TYPES
# ---------------------------------------------------------------------------
def test_supported_model_types_includes_all_wan22():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import SUPPORTED_MODEL_TYPES
assert "Wan2.2-T2V-A14B" in SUPPORTED_MODEL_TYPES
assert "Wan2.2-I2V-A14B" in SUPPORTED_MODEL_TYPES
def test_supported_model_types_includes_ti2v_5b():
"""MXFP8 supports TI2V-5B (contrast: MXFP4 explicitly excludes it)."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import SUPPORTED_MODEL_TYPES
assert "Wan2.2-TI2V-5B" in SUPPORTED_MODEL_TYPES
def test_cascade_model_types_excludes_ti2v():
"""TI2V-5B is a single-transformer model — must not be in CASCADE_MODEL_TYPES."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import CASCADE_MODEL_TYPES
assert "Wan2.2-TI2V-5B" not in CASCADE_MODEL_TYPES
assert "Wan2.2-T2V-A14B" in CASCADE_MODEL_TYPES
assert "Wan2.2-I2V-A14B" in CASCADE_MODEL_TYPES
# ---------------------------------------------------------------------------
# MXFP8_QUANT_CONFIG — auto-detection contract
# ---------------------------------------------------------------------------
def test_mxfp8_quant_config_has_required_keys():
"""MXFP8_QUANT_CONFIG must carry exactly the keys that auto-detection reads:
quant_method (selects DiffusionMXFP8Config) and is_checkpoint_mxfp8_serialized
(selects NPUMxfp8LinearMethod over the online path)."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import MXFP8_QUANT_CONFIG
assert MXFP8_QUANT_CONFIG["quant_method"] == "mxfp8"
assert MXFP8_QUANT_CONFIG["is_checkpoint_mxfp8_serialized"] is True
# ---------------------------------------------------------------------------
# _get_transformer_dirs
# ---------------------------------------------------------------------------
def test_get_transformer_dirs_cascade():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_transformer_dirs
assert _get_transformer_dirs("Wan2.2-T2V-A14B") == ["transformer", "transformer_2"]
assert _get_transformer_dirs("Wan2.2-I2V-A14B") == ["transformer", "transformer_2"]
def test_get_transformer_dirs_single():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_transformer_dirs
assert _get_transformer_dirs("Wan2.2-TI2V-5B") == ["transformer"]
# ---------------------------------------------------------------------------
# _get_quant_subdir
# ---------------------------------------------------------------------------
def test_get_quant_subdir_cascade_high_noise():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_quant_subdir
base = pathlib.Path("/quant")
result = _get_quant_subdir("Wan2.2-T2V-A14B", base, "transformer")
assert result == base / "high_noise_model"
def test_get_quant_subdir_cascade_low_noise():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_quant_subdir
base = pathlib.Path("/quant")
result = _get_quant_subdir("Wan2.2-T2V-A14B", base, "transformer_2")
assert result == base / "low_noise_model"
def test_get_quant_subdir_non_cascade():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _get_quant_subdir
base = pathlib.Path("/quant")
result = _get_quant_subdir("Wan2.2-TI2V-5B", base, "transformer")
assert result == base
# ---------------------------------------------------------------------------
# _remap_keys — msModelSlim naming → Diffusers naming
# ---------------------------------------------------------------------------
def test_remap_keys_self_attn_q():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {"blocks.0.self_attn.q.weight": torch.zeros(1)}
meta = {"blocks.0.self_attn.q.weight": "W8A8_MXFP8"}
new_state, new_meta = _remap_keys(state, meta)
assert "blocks.0.attn1.to_q.weight" in new_state
assert new_meta.get("blocks.0.attn1.to_q.weight") == "W8A8_MXFP8"
def test_remap_keys_self_attn_all_heads():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
pairs = {
"self_attn.q": "attn1.to_q",
"self_attn.k": "attn1.to_k",
"self_attn.v": "attn1.to_v",
"self_attn.o": "attn1.to_out.0",
}
for src_part, dst_part in pairs.items():
src_key = f"blocks.0.{src_part}.weight"
state = {src_key: torch.zeros(1)}
new_state, _ = _remap_keys(state, {})
expected = f"blocks.0.{dst_part}.weight"
assert expected in new_state, f"{src_key} → expected {expected}, got {list(new_state)}"
def test_remap_keys_ffn():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {
"blocks.1.ffn.0.weight": torch.zeros(1),
"blocks.1.ffn.2.weight": torch.zeros(1),
}
new_state, _ = _remap_keys(state, {})
assert "blocks.1.ffn.net.0.proj.weight" in new_state
assert "blocks.1.ffn.net.2.weight" in new_state
def test_remap_keys_norm_order_swap():
"""norm2↔norm3 swap: msModelSlim uses norm1/norm3/norm2 order,
Diffusers uses norm1/norm2/norm3."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {
"blocks.0.norm2.weight": torch.zeros(1),
"blocks.0.norm3.weight": torch.zeros(1),
}
new_state, _ = _remap_keys(state, {})
# norm2 → norm3 and norm3 → norm2
assert "blocks.0.norm3.weight" in new_state
assert "blocks.0.norm2.weight" in new_state
# Both must be present (swap, not collapse)
assert len([k for k in new_state if "norm" in k and "norm_q" not in k and "norm_k" not in k]) == 2
def test_remap_keys_head():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {"head.head.weight": torch.zeros(1)}
new_state, _ = _remap_keys(state, {})
assert "proj_out.weight" in new_state
def test_remap_keys_cross_attn():
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {"blocks.0.cross_attn.q.weight": torch.zeros(1)}
new_state, _ = _remap_keys(state, {})
assert "blocks.0.attn2.to_q.weight" in new_state
def test_remap_keys_meta_only_mapped_for_existing_state_keys():
"""quant_meta entries are only emitted for keys present in state_dict."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
state = {"blocks.0.self_attn.q.weight": torch.zeros(1)}
# meta has an extra key not in state_dict
meta = {
"blocks.0.self_attn.q.weight": "W8A8_MXFP8",
"blocks.0.self_attn.q.weight_scale": "W8A8_MXFP8",
}
_, new_meta = _remap_keys(state, meta)
assert "blocks.0.attn1.to_q.weight" in new_meta
# weight_scale was in meta but not state_dict → must be absent
assert "blocks.0.attn1.to_q.weight_scale" not in new_meta
def test_remap_keys_preserves_tensors():
"""Tensor values must survive the key rename unchanged."""
from vllm_omni.quantization.tools.merge_mxfp8_checkpoint import _remap_keys
t = torch.randn(4, 8)
state = {"blocks.0.self_attn.q.weight": t}
new_state, _ = _remap_keys(state, {})
assert torch.equal(new_state["blocks.0.attn1.to_q.weight"], t)
@@ -159,54 +159,10 @@ def create_transformer_from_config(
if "pos_embed_seq_len" in config:
kwargs["pos_embed_seq_len"] = config["pos_embed_seq_len"]
# Auto-detect quantization from transformer's config.json when not explicitly provided.
# merge_mxfp8_checkpoint.py injects quantization_config into config.json so that
# offline quantized checkpoints are recognized here without a CLI flag.
if "quantization_config" in config:
from vllm_omni.quantization.factory import build_quant_config
from vllm_omni.quantization.factory import resolve_quant_config_from_disk
disk_qc = config["quantization_config"]
if isinstance(disk_qc, dict) and "quant_method" in disk_qc:
qc_method = disk_qc["quant_method"]
qc_kwargs = {k: v for k, v in disk_qc.items() if k != "quant_method"}
if quant_config is None:
# No CLI flag: full auto-detection.
quant_config = build_quant_config(qc_method, **qc_kwargs)
logger.info(
"Auto-detected quantization from transformer config.json: method=%s kwargs=%s",
qc_method,
qc_kwargs,
)
elif quant_config.get_name() != qc_method:
# The caller supplied a quant_config of a different method than
# what the checkpoint was built with. Loading serialized tensors
# (e.g. MXFP8 weight scales) with the wrong linear method would
# produce corrupt output or a shape mismatch crash. Reject early
# so the user gets a clear message instead of a silent failure.
raise ValueError(
f"Checkpoint config.json declares quant_method={qc_method!r} but the "
f"active quantization config is {quant_config.get_name()!r}. "
"Pass a matching --quantization flag or omit it for auto-detection."
)
elif (
qc_kwargs.get("is_checkpoint_mxfp8_serialized", False)
and hasattr(quant_config, "is_checkpoint_mxfp8_serialized")
and not quant_config.is_checkpoint_mxfp8_serialized
):
# Same method: CLI provided online mode but config.json marks this
# as a pre-quantized offline checkpoint. Switch to offline mode so
# users can pass --quantization mxfp8 without knowing the
# online/offline distinction.
quant_config = build_quant_config(qc_method, **qc_kwargs)
logger.info(
"config.json marks checkpoint as serialized; switching from online to offline MXFP8 mode.",
)
elif isinstance(disk_qc, str) and quant_config is None:
quant_config = build_quant_config(disk_qc)
logger.info(
"Auto-detected quantization from transformer config.json: method=%s",
disk_qc,
)
quant_config = resolve_quant_config_from_disk(quant_config, config["quantization_config"])
if quant_config is not None:
kwargs["quant_config"] = quant_config
+102
View File
@@ -48,6 +48,29 @@ def _build_mxfp8(**kw: Any) -> QuantizationConfig:
return DiffusionMXFP8Config(**kw)
def _build_mxfp4(**kw: Any) -> QuantizationConfig:
"""Lazy import for W4A4 MXFP4 diffusion config (NPU only)."""
from .mxfp4_config import DiffusionMXFP4Config
return DiffusionMXFP4Config(**kw)
def _build_mxfp4_dualscale(**kw: Any) -> QuantizationConfig:
"""Lazy import for MXFP4 DualScale + BF16 mixed diffusion config (NPU only).
Offline mode (is_checkpoint_serialized=True):
ignored_layers from config.json marks interleaved BF16 fallback layers.
All other linear layers use W4A4 MXFP4 DualScale.
Online mode (is_checkpoint_serialized=False):
num_bf16_fallback_layers leading transformer blocks use BF16 original weights
(default 5 when not specified). Remaining blocks use W4A4 MXFP4 DualScale online.
"""
from .mxfp4_config import DiffusionMXFP4DualScaleMixedConfig
return DiffusionMXFP4DualScaleMixedConfig(**kw)
def _build_inc(**kw: Any) -> QuantizationConfig:
"""Lazy import for INC/AutoRound config with checkpoint kwarg normalization."""
from .inc_config import OmniINCConfig
@@ -66,6 +89,8 @@ _OVERRIDES: dict[str, Callable[..., QuantizationConfig]] = {
"gguf": _build_gguf,
"int8": _build_int8,
"mxfp8": _build_mxfp8,
"mxfp4": _build_mxfp4,
"mxfp4_dualscale": _build_mxfp4_dualscale,
"inc": _build_inc,
"auto-round": _build_inc,
"auto_round": _build_inc,
@@ -255,3 +280,80 @@ def build_quant_config(
return _build_from_method_and_config(method, merged)
raise TypeError(f"quantization config must be str, dict, QuantizationConfig, or None, got {type(spec).__name__}")
def _disk_marks_serialized(qc_kwargs: dict[str, Any], quant_config: object) -> bool:
"""Return True when config.json says serialized but the active quant_config does not.
Matches any flag following the is_checkpoint_*_serialized naming convention,
so new quant methods don't require updating an explicit allowlist.
"""
for key, val in qc_kwargs.items():
if key.startswith("is_checkpoint_") and key.endswith("_serialized"):
if val and hasattr(quant_config, key) and not getattr(quant_config, key):
return True
return False
def resolve_quant_config_from_disk(
quant_config: QuantizationConfig | None,
disk_qc: dict[str, Any] | str | None,
) -> QuantizationConfig | None:
"""Reconcile an active quant_config against quantization_config from a transformer's config.json.
Used when loading individual transformer blocks that each have their own config.json
(e.g. cascade models with separate transformer and transformer_2 directories).
Rules:
- disk_qc is None: return quant_config unchanged.
- quant_config is None: auto-detect from disk_qc (full build).
- Methods mismatch: raise ValueError — prevents silent weight corruption.
- Disk marks serialized but quant_config is online: rebuild from disk.
- ignored_layers differ: rebuild from disk (per-transformer BF16 routing).
"""
if disk_qc is None:
return quant_config
if isinstance(disk_qc, str):
if quant_config is None:
logger.info("Auto-detected quantization from config.json: method=%s", disk_qc)
return build_quant_config(disk_qc)
return quant_config
if not isinstance(disk_qc, Mapping) or "quant_method" not in disk_qc:
return quant_config
qc_method: str = disk_qc["quant_method"]
qc_kwargs: dict[str, Any] = {k: v for k, v in disk_qc.items() if k != "quant_method"}
if quant_config is None:
logger.info(
"Auto-detected quantization from config.json: method=%s kwargs=%s",
qc_method,
qc_kwargs,
)
return build_quant_config(qc_method, **qc_kwargs)
if quant_config.get_name() != qc_method:
raise ValueError(
f"Checkpoint config.json declares quant_method={qc_method!r} but the "
f"active quantization config is {quant_config.get_name()!r}. "
"Pass a matching --quantization flag or omit it for auto-detection."
)
if _disk_marks_serialized(qc_kwargs, quant_config):
logger.info(
"config.json marks checkpoint as serialized; switching to offline %s mode.",
qc_method,
)
return build_quant_config(qc_method, **qc_kwargs)
if (
"ignored_layers" in qc_kwargs
and hasattr(quant_config, "ignored_layers")
and set(qc_kwargs.get("ignored_layers") or []) != set(quant_config.ignored_layers or [])
):
logger.info("config.json ignored_layers differs from active config; rebuilding quant_config.")
return build_quant_config(qc_method, **qc_kwargs)
return quant_config
+709
View File
@@ -0,0 +1,709 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""W4A4 MXFP4 (Microscaling FP4) online/offline quantization for diffusion transformers.
Architecture mirrors mxfp8_config.py:
MXFPLinearMethodBase – platform-agnostic skeleton (imported from mxfp8_config)
NPUMxfp4LinearMethod – NPU single-scale offline (W4A4 MXFP4)
NPUMxfp4OnlineLinearMethod – NPU single-scale online (BF16 → FP4)
NPUMxfp4DualScaleLinearMethod – NPU dual-scale offline (W4A4 MXFP4 DualScale)
NPUMxfp4DualScaleOnlineLinearMethod – NPU dual-scale online (BF16 → FP4)
Quantization configs:
DiffusionMXFP4Config – single-scale online/offline (quant_method="mxfp4")
DiffusionMXFP4DualScaleMixedConfig – dual-scale + per-layer BF16 fallback (quant_method="mxfp4_dualscale")
Offline: ignored_layers from config.json routes interleaved BF16 layers
Online: num_bf16_fallback_layers leading blocks stay in BF16 (default 5)
Key differences from MXFP8:
1. Precision: float4_e2m1fn_x2 (FP4 packed, 2 values per element).
npu_dynamic_mx_quant(x) without dst_type defaults to float4_e2m1fn_x2.
2. Weight layout: stored as (N, K) — NOT pre-transposed.
FP4 uses a packed format; transposing a packed tensor is not safely contiguous.
Transpose is done inline in _quant_matmul via layer.weight.transpose(0, 1).
3. GEMM signature: npu_quant_matmul requires explicit
x1_dtype=float4_e2m1fn_x2 and x2_dtype=float4_e2m1fn_x2.
Scale layout: (N, S/2, 2) — same reshape as MXFP8, also NOT pre-transposed;
transposed inline in _quant_matmul.
Dual-scale (W4A4_MXFP4_DUALSCALE):
Two-level quantization: fine scale (per-32 K) + coarse scale (per-512 K) + per-channel
activation pre-scale (mul_scale from calibration). Uses npu_dynamic_dual_level_mx_quant
and npu_dual_level_quant_matmul. Weight stored in NZ hardware format via npu_format_cast.
Checkpoint tensor shapes for dual-scale:
weight : (N, K) float8_e4m3fn – FP4 packed
weight_scale : (N, K//32) uint8 – fine scale (float8_e8m0fnu bits)
weight_dual_scale : (N, K//512, 1) float32 – coarse scale (extra dim avoids shape assert)
mul_scale : (K,) float32 – per-input-channel activation pre-scale
Reference: MindIE-SD W4A4MXFP4QuantLinear / W4A4MXFP4DualQuantLinear (mindiesd/quantization/layer.py).
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
import torch
from torch.nn import Module
from vllm.logger import init_logger
from vllm.model_executor.layers.linear import (
LinearBase,
UnquantizedLinearMethod,
)
from vllm.model_executor.layers.quantization import QuantizationMethods
from vllm.model_executor.layers.quantization.base_config import (
QuantizationConfig,
QuantizeMethodBase,
)
from vllm.model_executor.layers.quantization.fp8 import _copy_missing_attrs
from vllm.model_executor.layers.quantization.utils.quant_utils import is_layer_skipped
from vllm.model_executor.model_loader.weight_utils import initialize_single_dummy_weight
from vllm.model_executor.parameter import ModelWeightParameter
from vllm.model_executor.utils import replace_parameter
from vllm_omni.platforms import current_omni_platform
from vllm_omni.quantization.mxfp8_config import (
MXFPLinearMethodBase,
_LazyWeightMixin,
)
if TYPE_CHECKING:
from vllm.model_executor.models.utils import WeightsMapper
logger = init_logger(__name__)
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
class DiffusionMXFP4Config(QuantizationConfig):
"""W4A4 MXFP4 quantization config for diffusion transformers.
Supports both online (BF16 checkpoint → quantize at load time) and offline
(pre-quantized MXFP4 checkpoint) modes, mirroring DiffusionMXFP8Config.
MX (microscaling) format: groups of 32 K-dimension elements share one
float8_e8m0fnu exponent scale. Weight and activation are float4_e2m1fn_x2.
"""
def __init__(
self,
is_checkpoint_mxfp4_serialized: bool = False,
ignored_layers: list[str] | None = None,
) -> None:
super().__init__()
self.is_checkpoint_mxfp4_serialized = is_checkpoint_mxfp4_serialized
self.ignored_layers = ignored_layers or []
@classmethod
def get_name(cls) -> QuantizationMethods:
return "mxfp4"
@classmethod
def get_supported_act_dtypes(cls) -> list[torch.dtype]:
return [torch.bfloat16, torch.float16]
@classmethod
def get_min_capability(cls) -> int:
return 80
@classmethod
def get_config_filenames(cls) -> list[str]:
return []
def apply_vllm_mapper(self, hf_to_vllm_mapper: WeightsMapper) -> None:
if self.ignored_layers:
self.ignored_layers = hf_to_vllm_mapper.apply_list(self.ignored_layers)
@classmethod
def from_config(cls, config: dict[str, Any]) -> DiffusionMXFP4Config:
is_serialized = cls.get_from_keys_or(config, ["is_checkpoint_mxfp4_serialized"], False)
ignored_layers = cls.get_from_keys_or(config, ["ignored_layers"], None)
if not ignored_layers:
ignored_layers = cls.get_from_keys_or(config, ["modules_to_not_convert"], None)
return cls(
is_checkpoint_mxfp4_serialized=is_serialized,
ignored_layers=ignored_layers,
)
def get_quant_method(
self,
layer: torch.nn.Module,
prefix: str,
) -> QuantizeMethodBase | None:
if isinstance(layer, LinearBase):
if is_layer_skipped(
prefix=prefix,
ignored_layers=self.ignored_layers,
fused_mapping=self.packed_modules_mapping,
):
return UnquantizedLinearMethod()
if current_omni_platform.is_npu():
if self.is_checkpoint_mxfp4_serialized:
return NPUMxfp4LinearMethod(self)
return NPUMxfp4OnlineLinearMethod(self)
raise NotImplementedError(
"DiffusionMXFP4Config (W4A4 MXFP4) is currently only supported "
"on NPU (Ascend) platforms. CUDA support is not yet implemented."
)
return None
# ---------------------------------------------------------------------------
# NPU MXFP4 single-scale offline method (pre-quantized checkpoint)
# ---------------------------------------------------------------------------
class NPUMxfp4LinearMethod(MXFPLinearMethodBase):
"""NPU W4A4 MXFP4 offline linear method for pre-quantized checkpoints.
Weight canonical layout after process_weights_after_loading:
weight : (N, K) in float4_e2m1fn_x2 — NOT pre-transposed (FP4 packed)
weight_scale: (N, S/2, 2) in float8_e8m0fnu — NOT pre-transposed
Both are transposed inline in _quant_matmul, unlike MXFP8 which pre-transposes.
NPUMxfp4OnlineLinearMethod normalizes to the same layout so apply() is shared.
"""
def __init__(self, quant_config: DiffusionMXFP4Config) -> None:
self.quant_config = quant_config
self.out_dtype = torch.get_default_dtype()
def create_weights(
self,
layer: torch.nn.Module,
input_size_per_partition: int,
output_partition_sizes: list[int],
input_size: int,
output_size: int,
params_dtype: torch.dtype,
**extra_weight_attrs,
) -> None:
output_size_per_partition = sum(output_partition_sizes)
weight_loader = extra_weight_attrs.get("weight_loader")
layer.logical_widths = output_partition_sizes
layer.input_size_per_partition = input_size_per_partition
layer.output_size_per_partition = output_size_per_partition
layer.orig_dtype = params_dtype
layer.weight_block_size = None
# BF16 placeholder; cast to float4_e2m1fn_x2 in process_weights.
layer.register_parameter(
"weight",
ModelWeightParameter(
data=torch.empty(output_size_per_partition, input_size_per_partition, dtype=params_dtype),
input_dim=1,
output_dim=0,
weight_loader=weight_loader,
),
)
# Scale stored as uint8 in safetensors (float8_e8m0fnu is same bit width).
# Using uint8 avoids a lossy float32 round-trip when loading the checkpoint.
num_groups = (input_size_per_partition + 31) // 32
layer.register_parameter(
"weight_scale",
ModelWeightParameter(
data=torch.empty(output_size_per_partition, num_groups, dtype=torch.uint8),
input_dim=1,
output_dim=0,
weight_loader=weight_loader,
),
)
def process_weights_after_loading(self, layer: Module) -> None:
if getattr(layer, "_already_called_process_weights_after_loading", False):
return
import torch_npu
# NPU: cast to float4_e2m1fn_x2. Weight stays (N, K) — no pre-transpose.
w = layer.weight
if w.dtype != torch_npu.float4_e2m1fn_x2:
w = torch_npu.npu_dtype_cast(w.npu(), torch_npu.float4_e2m1fn_x2)
# Scale: checkpoint stores uint8 bytes that ARE float8_e8m0fnu bits.
# Only convert if neither uint8 nor the target NPU dtype already.
# (N, K_groups) → (N, K_groups/2, 2). Not pre-transposed; done inline in _quant_matmul.
s = layer.weight_scale.data
if s.dtype not in (torch.uint8, torch_npu.float8_e8m0fnu):
s = s.to(torch_npu.float8_e8m0fnu)
N, K_groups = s.shape
if K_groups % 2 == 1:
s = torch.cat([s, torch.zeros(N, 1, dtype=s.dtype, device=s.device)], dim=1)
s = s.reshape(N, -1, 2).contiguous()
replace_parameter(layer, "weight", w)
replace_parameter(layer, "weight_scale", s)
layer._already_called_process_weights_after_loading = True
# --- NPU MXFP4 ops — shared with online path via inheritance ---
def _quantize_activation(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
import torch_npu
# No dst_type: npu_dynamic_mx_quant defaults to float4_e2m1fn_x2.
return torch_npu.npu_dynamic_mx_quant(x)
def _quant_matmul(
self,
x_q: torch.Tensor,
x_scale: torch.Tensor,
layer: torch.nn.Module,
bias: torch.Tensor | None,
ori_dtype: torch.dtype,
) -> torch.Tensor:
import torch_npu
if bias is not None and bias.dtype != torch.float32:
bias = bias.to(torch.float32)
# FP4 differences vs FP8:
# weight (N,K) transposed inline → (K,N); scale (N,S/2,2) transposed inline → (S/2,N,2).
# x1_dtype / x2_dtype required — FP4 dtype not inferred from tensor dtype.
return torch_npu.npu_quant_matmul(
x_q,
layer.weight.transpose(0, 1), # (K, N) inline
layer.weight_scale.transpose(0, 1), # (S/2, N, 2) inline
scale_dtype=torch_npu.float8_e8m0fnu,
x1_dtype=torch_npu.float4_e2m1fn_x2,
x2_dtype=torch_npu.float4_e2m1fn_x2,
pertoken_scale=x_scale,
pertoken_scale_dtype=torch_npu.float8_e8m0fnu,
bias=bias,
output_dtype=ori_dtype,
group_sizes=[1, 1, 32],
)
# ---------------------------------------------------------------------------
# NPU MXFP4 single-scale online method (BF16 checkpoint → quantize at load time)
# ---------------------------------------------------------------------------
class NPUMxfp4OnlineLinearMethod(_LazyWeightMixin, NPUMxfp4LinearMethod):
"""NPU W4A4 MXFP4 online linear method.
MRO: NPUMxfp4OnlineLinearMethod → _LazyWeightMixin → NPUMxfp4LinearMethod
→ MXFPLinearMethodBase → LinearMethodBase
create_weights : _LazyWeightMixin (meta device + patched loader)
process_weights : NPUMxfp4OnlineLinearMethod (BF16 → FP4 + normalize)
apply / ops : NPUMxfp4LinearMethod / MXFPLinearMethodBase (shared)
"""
def process_weights_after_loading(self, layer: Module) -> None:
if getattr(layer, "_already_called_process_weights_after_loading", False):
return
import torch_npu
if layer.weight.device == torch.device("meta"):
weight = ModelWeightParameter(
data=torch.empty_like(layer.weight, device=layer._load_device),
input_dim=1,
output_dim=0,
weight_loader=layer.weight.weight_loader,
)
_copy_missing_attrs(layer.weight, weight)
layer.register_parameter("weight", weight)
initialize_single_dummy_weight(layer.weight)
# NPU: quantize BF16/FP16 (N, K) → FP4. No dst_type → float4_e2m1fn_x2.
weight_fp4, weight_scale_raw = torch_npu.npu_dynamic_mx_quant(layer.weight)
# Weight stays (N, K) — no pre-transpose for FP4 packed format.
# Scale: (N, S) → (N, S/2, 2). Not pre-transposed; done inline in _quant_matmul.
weight_scale = weight_scale_raw.reshape(weight_scale_raw.shape[0], -1, 2).contiguous()
replace_parameter(layer, "weight", weight_fp4)
replace_parameter(layer, "weight_scale", weight_scale)
layer._already_called_process_weights_after_loading = True
# ---------------------------------------------------------------------------
# NPU MXFP4 dual-scale offline method (W4A4_MXFP4_DUALSCALE checkpoint)
# ---------------------------------------------------------------------------
class NPUMxfp4DualScaleLinearMethod(MXFPLinearMethodBase):
"""NPU W4A4 MXFP4 dual-scale offline method for pre-quantized checkpoints.
Checkpoint tensors and their canonical post-load shapes:
weight : (N, K) float8_e4m3fn – FP4 packed (2 values per byte)
weight_scale : (N, K//32) uint8 – fine scale (float8_e8m0fnu bits); reshaped to (N, K//64, 2)
weight_dual_scale : (N, K//512, 1) float32 – coarse scale; transposed to (K//512, N)
mul_scale : (K,) float32 – per-input-channel activation pre-scale (from calibration)
Forward pass:
x_q, l0, l1 = npu_dynamic_dual_level_mx_quant(x, smooth_scale=mul_scale)
out = npu_dual_level_quant_matmul(x_q, weight, l0, weight_dual_scale, l1, weight_scale)
Reference: MindIE-SD W4A4MXFP4DualQuantLinear.
"""
def __init__(self, quant_config: Any) -> None:
self.quant_config = quant_config
self.out_dtype = torch.get_default_dtype()
def create_weights(
self,
layer: torch.nn.Module,
input_size_per_partition: int,
output_partition_sizes: list[int],
input_size: int,
output_size: int,
params_dtype: torch.dtype,
**extra_weight_attrs,
) -> None:
output_size_per_partition = sum(output_partition_sizes)
weight_loader = extra_weight_attrs.get("weight_loader")
layer.logical_widths = output_partition_sizes
layer.input_size_per_partition = input_size_per_partition
layer.output_size_per_partition = output_size_per_partition
layer.orig_dtype = params_dtype
layer.weight_block_size = None
# FP4 packed: 2 values per float8_e4m3fn byte → checkpoint stores as float8_e4m3fn; register as BF16
layer.register_parameter(
"weight",
ModelWeightParameter(
data=torch.empty(output_size_per_partition, input_size_per_partition, dtype=params_dtype),
input_dim=1,
output_dim=0,
weight_loader=weight_loader,
),
)
# Fine scale: one uint8 exponent (float8_e8m0fnu bit pattern) per group of 32 K elements.
# input_dim=1: RowParallelLinear weight_loader shards along the K-group dimension (dim 1)
# so each rank receives only its (K/TP)//32 groups; ColumnParallelLinear only uses
# output_dim=0 for sharding and leaves dim 1 intact.
num_groups_fine = (input_size_per_partition + 31) // 32
layer.register_parameter(
"weight_scale",
ModelWeightParameter(
data=torch.empty(output_size_per_partition, num_groups_fine, dtype=torch.uint8),
input_dim=1,
output_dim=0,
weight_loader=weight_loader,
),
)
# Coarse scale: one float32 per group of 512 K elements.
# Shape (N, K_coarse, 1) matches checkpoint layout exactly, avoiding the
# shape-mismatch assert in linear.py:1344.
# input_dim=1: same TP sharding rationale as weight_scale above.
num_groups_coarse = (input_size_per_partition + 511) // 512
layer.register_parameter(
"weight_dual_scale",
ModelWeightParameter(
data=torch.empty(output_size_per_partition, num_groups_coarse, 1, dtype=torch.float32),
input_dim=1,
output_dim=0,
weight_loader=weight_loader,
),
)
# mul_scale is a float32 calibration tensor; register as float32
# to avoid precision loss from an implicit BF16 cast during weight loading.
# input_dim=0: RowParallelLinear shards the 1-D per-input-channel tensor along dim 0
# so each rank receives only its K/TP channels; ColumnParallelLinear leaves it intact.
layer.register_parameter(
"mul_scale",
ModelWeightParameter(
data=torch.empty(input_size_per_partition, dtype=torch.float32),
input_dim=0,
output_dim=None,
weight_loader=weight_loader,
),
)
setattr(layer.mul_scale, "ignore_warning", True)
def process_weights_after_loading(self, layer: Module) -> None:
if getattr(layer, "_already_called_process_weights_after_loading", False):
return
import torch_npu
# float8_e4m3fn (FP4 packed) → float4_e2m1fn_x2 → NZ hardware format (format ID 29).
# NZ layout is required by npu_dual_level_quant_matmul for FP4 weight matrices.
w = torch_npu.npu_dtype_cast(layer.weight.data.npu(), torch_npu.float4_e2m1fn_x2)
w = torch_npu.npu_format_cast(w.view(torch.int8), 29, customize_dtype=torch.int8)
# Fine scale: (N, K//32) uint8 → cast to float8_e8m0fnu → (N, K//64, 2).
# The cast reinterprets the stored uint8 bit-patterns as float8_e8m0fnu exponents,
# which is required for npu_dual_level_quant_matmul to compute correct scale values.
s = layer.weight_scale.data.npu()
s = s.reshape(s.shape[0], -1, 2).contiguous()
# Coarse scale: (N, K//512, 1) → squeeze → (N, K//512) → transpose → (K//512, N).
ds = layer.weight_dual_scale.data.squeeze(-1).transpose(0, 1).npu().contiguous()
ms = layer.mul_scale.to(torch.bfloat16).data.view(-1).npu().contiguous()
replace_parameter(layer, "weight", w)
replace_parameter(layer, "weight_scale", s)
replace_parameter(layer, "weight_dual_scale", ds)
replace_parameter(layer, "mul_scale", ms)
layer._already_called_process_weights_after_loading = True
def _apply_inner(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: torch.Tensor | None,
ori_dtype: torch.dtype,
) -> torch.Tensor:
"""Dual-scale inner loop: dtype guard → 3-tuple quantize (mul_scale as smooth) → matmul.
Overrides the default single-scale _apply_inner from MXFPLinearMethodBase.
apply() in the base class handles reshape/unreshape; this method is not
responsible for that.
"""
if ori_dtype not in (torch.bfloat16, torch.float16):
x = x.to(torch.bfloat16)
x_q, l0_scale, l1_scale = self._quantize_activation(x, layer.mul_scale)
return self._quant_matmul(x_q, l0_scale, l1_scale, layer, bias, ori_dtype)
def _quantize_activation( # type: ignore[override]
self,
x: torch.Tensor,
smooth_scale: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
import torch_npu
return torch_npu.npu_dynamic_dual_level_mx_quant(x, smooth_scale=smooth_scale)
def _quant_matmul( # type: ignore[override]
self,
x_q: torch.Tensor,
l0_scale: torch.Tensor,
l1_scale: torch.Tensor,
layer: torch.nn.Module,
bias: torch.Tensor | None,
ori_dtype: torch.dtype,
) -> torch.Tensor:
import torch_npu
if bias is not None and bias.dtype != torch.float32:
bias = bias.to(torch.float32)
# weight_scale is (N, K//64, 2) float8_e8m0fnu — operator expects output-major layout.
# weight_dual_scale is (K//512, N) float32 — transposed to K-major in process_weights.
return torch_npu.npu_dual_level_quant_matmul(
x_q,
layer.weight,
l0_scale,
layer.weight_dual_scale,
l1_scale,
layer.weight_scale,
bias=bias,
output_dtype=ori_dtype,
)
# ---------------------------------------------------------------------------
# NPU MXFP4 dual-scale online method (BF16 checkpoint → quantize at load time)
# ---------------------------------------------------------------------------
class NPUMxfp4DualScaleOnlineLinearMethod(_LazyWeightMixin, NPUMxfp4DualScaleLinearMethod):
"""NPU W4A4 MXFP4 dual-scale online method: quantises BF16 weights at load time.
MRO: NPUMxfp4DualScaleOnlineLinearMethod → _LazyWeightMixin
→ NPUMxfp4DualScaleLinearMethod → MXFPLinearMethodBase
create_weights : _LazyWeightMixin (meta device + patched loader)
process_weights : NPUMxfp4DualScaleOnlineLinearMethod (BF16 → FP4 + dual scales)
apply / _quant_matmul : NPUMxfp4DualScaleLinearMethod (shared with offline path)
"""
def process_weights_after_loading(self, layer: Module) -> None:
if getattr(layer, "_already_called_process_weights_after_loading", False):
return
import torch_npu
if layer.weight.device == torch.device("meta"):
weight = ModelWeightParameter(
data=torch.empty_like(layer.weight, device=layer._load_device),
input_dim=1,
output_dim=0,
weight_loader=layer.weight.weight_loader,
)
_copy_missing_attrs(layer.weight, weight)
layer.register_parameter("weight", weight)
initialize_single_dummy_weight(layer.weight)
# Quantize BF16 weight → FP4 + dual-level scales (no smooth pre-scale for online).
# Returns: (weight_fp4, l0_scale[coarse per-512], l1_scale[fine per-32])
weight_fp4, w_l0_scale, w_l1_scale = torch_npu.npu_dynamic_dual_level_mx_quant(
layer.weight.data.npu(), smooth_scale=None
)
# NZ hardware format for the FP4 weight (same as offline path).
w = torch_npu.npu_format_cast(weight_fp4.view(torch.int8), 29, customize_dtype=torch.int8)
# Fine scale (l1): (N, K//32) → (N, K//64, 2). Dtype from op output (float8_e8m0fnu).
s = w_l1_scale.reshape(w_l1_scale.shape[0], -1, 2).contiguous()
# Coarse scale (l0): (N, K_coarse) → (K_coarse, N). Dtype from op output.
ds = w_l0_scale.reshape(w_l0_scale.shape[0], -1).transpose(0, 1).contiguous()
# No calibration available: identity pre-scale (no smooth quantization effect).
ms = torch.ones(layer.input_size_per_partition, dtype=torch.bfloat16, device="npu")
replace_parameter(layer, "weight", w)
replace_parameter(layer, "weight_scale", s)
replace_parameter(layer, "weight_dual_scale", ds)
replace_parameter(layer, "mul_scale", ms)
layer._already_called_process_weights_after_loading = True
# ---------------------------------------------------------------------------
# Block-index helper (shared by DiffusionMXFP4DualScaleMixedConfig)
# ---------------------------------------------------------------------------
_BLOCK_IDX_RE = re.compile(r"^blocks\.(\d+)\.")
def _parse_block_idx(prefix: str) -> int | None:
"""Extract block index from prefix like 'blocks.5.attn1.to_q'."""
m = _BLOCK_IDX_RE.match(prefix)
return int(m.group(1)) if m else None
# ---------------------------------------------------------------------------
# Config: MXFP4 DualScale + per-layer BF16 fallback
# ---------------------------------------------------------------------------
class DiffusionMXFP4DualScaleMixedConfig(QuantizationConfig):
"""W4A4 MXFP4 DualScale with per-layer BF16 fallback for diffusion transformers.
Sensitive layers fall back to BF16 (original weights) while all other linear
layers use W4A4 MXFP4 DualScale. BF16 fallback layers may be interleaved
anywhere in the transformer.
Offline mode (is_checkpoint_serialized=True):
Layers whose prefix appears in ignored_layers → UnquantizedLinearMethod (BF16)
All other linear layers → NPUMxfp4DualScaleLinearMethod
ignored_layers is injected into transformer/config.json by the merge script
and contains the prefixes of all non-MXFP4 linear layers.
Online mode (is_checkpoint_serialized=False):
Layer routing applies two rules in priority order:
1. ignored_layers (explicit per-layer BF16 override, user-supplied) → BF16
2. Blocks 0 .. num_bf16_fallback_layers-1 (coarse leading-block rule) → BF16
3. All other linear layers → NPUMxfp4DualScaleOnlineLinearMethod
num_bf16_fallback_layers defaults to 5 when not specified.
Set ignored_layers to pin arbitrary interleaved layers to BF16 without
needing an offline checkpoint (useful for accuracy debugging).
Layers outside "blocks.N.*" (condition_embedder etc.) always use online MXFP4
unless they appear in ignored_layers.
Config injected by merge_mxfp4_dualscale_checkpoint.py:
{
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": true,
"ignored_layers": ["blocks.0.attn1.to_q", ...]
}
"""
def __init__(
self,
is_checkpoint_serialized: bool = False,
ignored_layers: list[str] | None = None,
num_bf16_fallback_layers: int = 5,
) -> None:
super().__init__()
self.is_checkpoint_serialized = is_checkpoint_serialized
self.ignored_layers = ignored_layers or []
self.num_bf16_fallback_layers = num_bf16_fallback_layers
@classmethod
def get_name(cls) -> QuantizationMethods:
return "mxfp4_dualscale"
@classmethod
def get_supported_act_dtypes(cls) -> list[torch.dtype]:
return [torch.bfloat16, torch.float16]
@classmethod
def get_min_capability(cls) -> int:
return 80
@classmethod
def get_config_filenames(cls) -> list[str]:
return []
def apply_vllm_mapper(self, hf_to_vllm_mapper: WeightsMapper) -> None:
if self.ignored_layers:
self.ignored_layers = hf_to_vllm_mapper.apply_list(self.ignored_layers)
@classmethod
def from_config(cls, config: dict[str, Any]) -> DiffusionMXFP4DualScaleMixedConfig:
is_serialized = cls.get_from_keys_or(config, ["is_checkpoint_serialized"], False)
ignored_layers = cls.get_from_keys_or(config, ["ignored_layers"], None)
if not ignored_layers:
ignored_layers = cls.get_from_keys_or(config, ["modules_to_not_convert"], None)
num_bf16_fallback_layers = cls.get_from_keys_or(config, ["num_bf16_fallback_layers"], 5)
return cls(
is_checkpoint_serialized=is_serialized,
ignored_layers=ignored_layers,
num_bf16_fallback_layers=num_bf16_fallback_layers,
)
def get_quant_method(
self,
layer: torch.nn.Module,
prefix: str,
) -> QuantizeMethodBase | None:
if not isinstance(layer, LinearBase):
return None
if self.is_checkpoint_serialized:
# Offline: ignored_layers lists interleaved BF16 fallback layer prefixes.
if is_layer_skipped(
prefix=prefix,
ignored_layers=self.ignored_layers,
fused_mapping=self.packed_modules_mapping,
):
return UnquantizedLinearMethod()
if not current_omni_platform.is_npu():
raise NotImplementedError(
"DiffusionMXFP4DualScaleMixedConfig is currently only supported on NPU (Ascend) platforms."
)
return NPUMxfp4DualScaleLinearMethod(self)
# Online: explicit ignored_layers take priority (user-specified per-layer BF16 override),
# then fall back to the coarse leading-block rule.
if is_layer_skipped(
prefix=prefix,
ignored_layers=self.ignored_layers,
fused_mapping=self.packed_modules_mapping,
):
return UnquantizedLinearMethod()
block_idx = _parse_block_idx(prefix)
if block_idx is not None and block_idx < self.num_bf16_fallback_layers:
return UnquantizedLinearMethod()
if not current_omni_platform.is_npu():
raise NotImplementedError(
"DiffusionMXFP4DualScaleMixedConfig is currently only supported on NPU (Ascend) platforms."
)
return NPUMxfp4DualScaleOnlineLinearMethod(self)
+2
View File
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
@@ -0,0 +1,491 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Merge W4A4_MXFP4_DUALSCALE quantized Wan2.2 weights into HF Diffusers format.
msModelSlim produces a checkpoint where each linear layer is either:
- W4A4_MXFP4_DUALSCALE: quantized weights with fine/coarse scales and mul_scale
- FLOAT (BF16 fallback): kept as original BF16 weights for precision-sensitive layers
BF16 fallback layers may be interleaved anywhere in the transformer (not just leading
blocks). The merge script detects them from quant_model_description.json and writes
their prefixes into config.json as ignored_layers so the runtime dynamically routes
each layer to the correct quantization method at weight-loading time.
MXFP4_DUALSCALE key structure per linear layer (msModelSlim naming)
---------------------------------------------------------------------
blocks.N.X.linear.weight W4A4_MXFP4_DUALSCALE – int8 (FP4 packed)
blocks.N.X.linear.weight_scale W4A4_MXFP4_DUALSCALE – uint8 (float8_e8m0fnu fine scale, per-32K)
blocks.N.X.linear.weight_dual_scale W4A4_MXFP4_DUALSCALE – float32 (coarse scale, per-512K)
blocks.N.X.linear.bias FLOAT – bias (if present)
blocks.N.X.div.mul_scale FLOAT – float32 per-input-channel activation pre-scale
BF16 fallback key structure (no wrappers, plain linear weights):
blocks.N.X.weight FLOAT
condition_embedder.*.weight FLOAT (always BF16 — not quantized)
Self-attention QKV notes
------------------------
Self-attention Q/K/V weights are separate in the checkpoint (self_attn.q/k/v) but fused
into a single to_qkv layer in vllm-omni. The transformer's load_weights() handles this via
stacked_params_mapping. This script keeps Q/K/V keys separate — do NOT pre-fuse them here.
For mul_scale specifically: even though Q/K/V process the same input (same mul_scale value),
they are kept separate. load_weights() routes all three to the same to_qkv.mul_scale parameter
and each overwrites the previous. Since Q=K=V for mul_scale, the final value is correct.
NOTE: Pre-fusing as to_qkv.mul_scale would BREAK loading because ".attn1.to_q" is a
substring of ".attn1.to_qkv", causing load_weights() stacked_params_mapping to produce a
garbage key ("to_qkvkv") that is not in params_dict, triggering a break that skips the
direct-load else branch entirely.
Supported model types:
- Wan2.2-T2V-A14B (MoE cascade: transformer + transformer_2)
- Wan2.2-I2V-A14B (MoE cascade: transformer + transformer_2)
Note: Wan2.2-TI2V-5B is NOT supported. Its smaller parameter count causes
unacceptable accuracy loss under W4A4 quantization. Use MXFP8 for TI2V-5B.
Usage:
python merge_mxfp4_dualscale_checkpoint.py \\
--model-type Wan2.2-T2V-A14B \\
--original-model /path/to/Wan2.2-T2V-A14B-Diffusers \\
--quant-path /path/to/msmodelslim-output \\
--output-path /path/to/merged-output
"""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import shutil
from typing import Any
import torch
from safetensors.torch import load_file, save_file
# ---------------------------------------------------------------------------
# Key rename: msModelSlim naming → Diffusers / vllm-omni naming
# Identical to merge_mxfp8_checkpoint.py; applied to all blocks uniformly.
# ---------------------------------------------------------------------------
TRANSFORMER_KEYS_RENAME_DICT: dict[str, str] = {
"time_embedding.0": "condition_embedder.time_embedder.linear_1",
"time_embedding.2": "condition_embedder.time_embedder.linear_2",
"text_embedding.0": "condition_embedder.text_embedder.linear_1",
"text_embedding.2": "condition_embedder.text_embedder.linear_2",
"time_projection.1": "condition_embedder.time_proj",
"head.modulation": "scale_shift_table",
"head.head": "proj_out",
"modulation": "scale_shift_table",
"ffn.0": "ffn.net.0.proj",
"ffn.2": "ffn.net.2",
# Norm order swap (quant tool: norm1, norm3, norm2 → diffusers: norm1, norm2, norm3)
"norm2": "norm__placeholder",
"norm3": "norm2",
"norm__placeholder": "norm3",
# Self-attention
"self_attn.q": "attn1.to_q",
"self_attn.k": "attn1.to_k",
"self_attn.v": "attn1.to_v",
"self_attn.o": "attn1.to_out.0",
"self_attn.norm_q": "attn1.norm_q",
"self_attn.norm_k": "attn1.norm_k",
# Cross-attention
"cross_attn.q": "attn2.to_q",
"cross_attn.k": "attn2.to_k",
"cross_attn.v": "attn2.to_v",
"cross_attn.o": "attn2.to_out.0",
"cross_attn.norm_q": "attn2.norm_q",
"cross_attn.norm_k": "attn2.norm_k",
"attn2.to_k_img": "attn2.add_k_proj",
"attn2.to_v_img": "attn2.add_v_proj",
"attn2.norm_k_img": "attn2.norm_added_k",
# I2V image embedder
"img_emb.proj.0": "condition_embedder.image_embedder.norm1",
"img_emb.proj.1": "condition_embedder.image_embedder.ff.net.0.proj",
"img_emb.proj.3": "condition_embedder.image_embedder.ff.net.2",
"img_emb.proj.4": "condition_embedder.image_embedder.norm2",
"img_emb.emb_pos": "condition_embedder.image_embedder.pos_embed",
}
SUPPORTED_MODEL_TYPES = ["Wan2.2-T2V-A14B", "Wan2.2-I2V-A14B"]
CASCADE_MODEL_TYPES = {"Wan2.2-T2V-A14B", "Wan2.2-I2V-A14B"}
# Suffixes that appear inside .linear.* wrapper for MXFP4 tensors.
_LINEAR_ATTRS = ("weight_dual_scale", "weight_scale", "weight", "bias")
_BLOCK_IDX_RE = re.compile(r"^blocks\.(\d+)\.")
# ---------------------------------------------------------------------------
# Key transformation helpers
# ---------------------------------------------------------------------------
def _parse_block_idx(key: str) -> int | None:
"""Extract block index from a key like 'blocks.5.attn1.to_q.weight'."""
m = _BLOCK_IDX_RE.match(key)
return int(m.group(1)) if m else None
def _apply_rename_dict(key: str) -> str:
for src, dst in TRANSFORMER_KEYS_RENAME_DICT.items():
key = key.replace(src, dst)
return key
def _strip_mxfp4_wrapper(key: str) -> str:
"""Strip .linear.ATTR or .div.mul_scale wrappers added by msModelSlim.
MXFP4 tensors are wrapped in sub-modules:
X.linear.weight / weight_scale / weight_dual_scale / bias
X.div.mul_scale
MXFP8 and FLOAT tensors have no wrappers — this function is a no-op for them.
Examples after apply_rename_dict:
attn1.to_q.linear.weight → attn1.to_q.weight
attn1.to_q.linear.weight_scale → attn1.to_q.weight_scale
attn1.to_q.div.mul_scale → attn1.to_q.mul_scale
attn1.norm_q.weight → attn1.norm_q.weight (unchanged)
"""
# Check longest attribute names first to avoid partial suffix matches.
for attr in _LINEAR_ATTRS:
suffix = f".linear.{attr}"
if key.endswith(suffix):
return key[: -len(suffix)] + f".{attr}"
if key.endswith(".div.mul_scale"):
return key[: -len(".div.mul_scale")] + ".mul_scale"
return key
# ---------------------------------------------------------------------------
# Quantization metadata helpers
# ---------------------------------------------------------------------------
# Known quantized weight types (FLOAT tensors don't determine block type).
_MXFP8_TYPE = "mxfp8"
_MXFP4_DUALSCALE_TYPE = "mxfp4_dualscale"
def _classify_blocks(quant_meta: dict[str, str]) -> dict[int, str]:
"""Classify each transformer block by quantization type from quant_meta.
Returns a dict mapping block_idx → 'mxfp8' | 'mxfp4_dualscale'.
A block's type is determined by the first quantized (non-FLOAT) tensor found for it.
"""
block_types: dict[int, str] = {}
for key, qtype in quant_meta.items():
idx = _parse_block_idx(key)
if idx is None or idx in block_types:
continue
if qtype.startswith("W8A8_MXFP8"):
block_types[idx] = _MXFP8_TYPE
elif qtype.startswith("W4A4_MXFP4_DUALSCALE"):
block_types[idx] = _MXFP4_DUALSCALE_TYPE
return block_types
def _print_block_summary(block_types: dict[int, str]) -> None:
"""Print a compact run-length summary of the block layout."""
if not block_types:
print(" Block layout: (empty)")
return
sorted_indices = sorted(block_types)
runs: list[tuple[int, int, str]] = []
run_start = sorted_indices[0]
run_type = block_types[run_start]
for idx in sorted_indices[1:]:
if block_types[idx] != run_type:
runs.append((run_start, idx - 1, run_type))
run_start = idx
run_type = block_types[idx]
runs.append((run_start, sorted_indices[-1], run_type))
print(f" Block layout ({len(sorted_indices)} blocks classified):")
for start, end, btype in runs:
count = end - start + 1
range_str = f"{start}" if start == end else f"{start}–{end}"
print(f" blocks {range_str:>8}: {btype} ({count} block{'s' if count > 1 else ''})")
# ---------------------------------------------------------------------------
# Safetensors I/O
# ---------------------------------------------------------------------------
def _load_safetensors_dir(directory: pathlib.Path, glob: str = "*.safetensors") -> dict[str, torch.Tensor]:
candidates = sorted(directory.glob(glob))
if not candidates:
raise FileNotFoundError(f"No safetensors matching '{glob}' found in {directory}")
state: dict[str, torch.Tensor] = {}
for f in candidates:
state.update(load_file(str(f)))
return state
def _load_quant_safetensors(directory: pathlib.Path) -> dict[str, torch.Tensor]:
try:
return _load_safetensors_dir(directory, "quant_model_weight*.safetensors")
except FileNotFoundError:
return _load_safetensors_dir(directory)
def _load_quant_meta(directory: pathlib.Path) -> dict[str, str]:
candidates = sorted(directory.glob("quant_model_description*.json"))
if not candidates:
print(f" WARNING: No quant_model_description*.json in {directory}; treating all tensors as FLOAT.")
return {}
with open(candidates[0]) as f:
return json.load(f)
# ---------------------------------------------------------------------------
# Per-transformer conversion
# ---------------------------------------------------------------------------
def _is_mxfp4_tensor(key: str, qtype: str) -> bool:
"""Return True for MXFP4 quantized tensors and their companion tensors.
MXFP4 DualScale layers produce four tensor types:
.linear.weight / .linear.weight_scale / .linear.weight_dual_scale (W4A4_MXFP4_DUALSCALE)
.linear.bias / .div.mul_scale (FLOAT but companion to a quantized layer)
BF16 fallback layers have plain .weight / .bias without .linear. / .div. wrappers.
"""
if qtype.startswith("W4A4_MXFP4_DUALSCALE"):
return True
# Companion tensors: bias and mul_scale that belong to an MXFP4 layer.
return ".linear." in key or ".div.mul_scale" in key
def _diffusers_to_vllm_ignored(diffusers_ignored: list[str]) -> list[str]:
"""Translate diffusers checkpoint prefixes to vllm-omni model parameter names.
Three transformations align the naming conventions:
1. Self-attention QKV fusion (attn1 only, not attn2):
attn1.to_q + attn1.to_k + attn1.to_v → attn1.to_qkv
Cross-attention (attn2) to_q/k/v are not fused and remain separate.
2. FFN naming:
ffn.net.0.proj → ffn.net_0.proj
ffn.net.2 → ffn.net_2
3. to_out index:
attn1.to_out.0 / attn2.to_out.0 → attn1.to_out / attn2.to_out
"""
ignored_set = set(diffusers_ignored)
result: set[str] = set()
for name in diffusers_ignored:
m = re.match(r"^(.*\.attn1)\.to_([qkv])$", name)
if m:
prefix = m.group(1)
if all(f"{prefix}.to_{c}" in ignored_set for c in ("q", "k", "v")):
result.add(f"{prefix}.to_qkv")
else:
present = [f"to_{c}" for c in ("q", "k", "v") if f"{prefix}.to_{c}" in ignored_set]
missing = [f"to_{c}" for c in ("q", "k", "v") if f"{prefix}.to_{c}" not in ignored_set]
raise ValueError(
f"Partial BF16 fallback for '{prefix}': "
f"{', '.join(present)} in ignored_layers but {', '.join(missing)} is not. "
f"Self-attention Q/K/V are fused into a single to_qkv layer at runtime; "
f"all three must share the same precision. "
f"Either quantize all of to_q/to_k/to_v or keep all three in BF16."
)
continue
name = re.sub(r"\.ffn\.net\.0\.proj$", ".ffn.net_0.proj", name)
name = re.sub(r"\.ffn\.net\.2$", ".ffn.net_2", name)
name = re.sub(r"\.to_out\.0$", ".to_out", name)
result.add(name)
return sorted(result)
def _collect_ignored_layers(merged: dict[str, Any], mxfp4_layer_prefixes: set[str]) -> list[str]:
"""Collect vllm-omni parameter name prefixes for BF16 fallback layers.
A layer is BF16 if it has a .weight tensor but its prefix is not in
mxfp4_layer_prefixes. Prefixes are returned in vllm-omni parameter naming
(QKV-fused, FFN underscored, to_out unindexed) so the list can be written
directly into ignored_layers in config.json without further translation.
"""
all_weight_prefixes = {key[: -len(".weight")] for key in merged if key.endswith(".weight")}
return _diffusers_to_vllm_ignored(sorted(all_weight_prefixes - mxfp4_layer_prefixes))
def _convert_transformer(
quant_subdir: pathlib.Path,
output_dir: pathlib.Path,
original_transformer_dir: pathlib.Path,
) -> None:
"""Convert one transformer directory to the mxfp4_dualscale + BF16 mixed format."""
output_dir.mkdir(parents=True, exist_ok=True)
# BF16 base: provides the scaffold for non-MXFP4 tensors (norms, embeddings,
# BF16 fallback linear layers that msModelSlim may omit or keep as FLOAT).
print(f" Loading BF16 base from {original_transformer_dir} …")
base_state = _load_safetensors_dir(original_transformer_dir)
print(f" {len(base_state)} BF16 tensors loaded")
print(f" Loading quantized weights from {quant_subdir} …")
quant_state = _load_quant_safetensors(quant_subdir)
quant_meta = _load_quant_meta(quant_subdir)
print(f" {len(quant_state)} quant tensors, {len(quant_meta)} meta entries")
# Classify blocks for a compact summary (informational only in the new scheme).
block_types = _classify_blocks(quant_meta)
_print_block_summary(block_types)
# Remap MXFP4 quantized tensors only.
# BF16 fallback tensors (FLOAT in quant_meta, no .linear./.div. wrapper) are
# skipped here; the base_state provides them unchanged.
remapped: dict[str, torch.Tensor] = {}
remapped_meta: dict[str, str] = {}
mxfp4_layer_prefixes: set[str] = set()
skipped: list[str] = []
for key, tensor in quant_state.items():
qtype = quant_meta.get(key, "FLOAT")
if not _is_mxfp4_tensor(key, qtype):
continue # BF16 fallback — base_state already covers this tensor
renamed = _apply_rename_dict(key)
final_key = _strip_mxfp4_wrapper(renamed)
if final_key.endswith(".quant_type"):
skipped.append(key)
continue
remapped[final_key] = tensor
remapped_meta[final_key] = qtype
# Track MXFP4 layer prefixes (via their .weight keys) for ignored_layers.
if final_key.endswith(".weight") and qtype.startswith("W4A4_MXFP4_DUALSCALE"):
mxfp4_layer_prefixes.add(final_key[: -len(".weight")])
if skipped:
print(f" Skipped {len(skipped)} metadata keys (quant_type markers): {skipped[:5]}")
print(f" {len(remapped)} MXFP4 tensors remapped, {len(mxfp4_layer_prefixes)} quantized layers")
# Merge: base_state provides BF16 scaffold; MXFP4 tensors override their BF16 counterparts
# and add the new scale tensors (weight_scale, weight_dual_scale, mul_scale).
merged = {**base_state, **remapped}
# Determine ignored_layers: BF16 fallback layer prefixes in vllm-omni parameter naming.
ignored_layers = _collect_ignored_layers(merged, mxfp4_layer_prefixes)
print(f" {len(ignored_layers)} BF16 fallback layers → ignored_layers in config.json")
# Save weights.
out_weights = output_dir / "diffusion_pytorch_model.safetensors"
save_file(merged, str(out_weights))
print(f" Saved {len(merged)} tensors → {out_weights}")
# Save remapped quant metadata (for inspection / debugging).
out_meta_path = output_dir / "quant_model_description.json"
with open(out_meta_path, "w") as f:
json.dump(remapped_meta, f, indent=2)
# Inject quantization_config into config.json.
src_config = original_transformer_dir / "config.json"
if src_config.is_file():
with open(src_config) as f:
config = json.load(f)
config["quantization_config"] = _build_quant_config(ignored_layers)
out_config = output_dir / "config.json"
with open(out_config, "w") as f:
json.dump(config, f, indent=2)
print(f" Injected quantization_config (mxfp4_dualscale, {len(ignored_layers)} ignored_layers) → {out_config}")
else:
print(f" WARNING: No config.json at {src_config}; quantization_config not injected.")
def _build_quant_config(ignored_layers: list[str]) -> dict[str, Any]:
return {
"quant_method": "mxfp4_dualscale",
"is_checkpoint_serialized": True,
"ignored_layers": ignored_layers,
}
# ---------------------------------------------------------------------------
# Model-type helpers
# ---------------------------------------------------------------------------
def _get_transformer_dirs(model_type: str) -> list[str]:
return ["transformer", "transformer_2"] if model_type in CASCADE_MODEL_TYPES else ["transformer"]
def _get_quant_subdir(model_type: str, quant_path: pathlib.Path, transformer_dir: str) -> pathlib.Path:
if model_type in CASCADE_MODEL_TYPES:
sub = "high_noise_model" if transformer_dir == "transformer" else "low_noise_model"
return quant_path / sub
return quant_path
# ---------------------------------------------------------------------------
# Main repack
# ---------------------------------------------------------------------------
def repack(
model_type: str,
original_model_path: pathlib.Path,
quant_path: pathlib.Path,
output_path: pathlib.Path,
) -> None:
transformer_dirs = _get_transformer_dirs(model_type)
print(f"Copying original model to {output_path} (skipping {transformer_dirs}) …")
shutil.copytree(
str(original_model_path),
str(output_path),
ignore=shutil.ignore_patterns(*transformer_dirs),
)
for tdir in transformer_dirs:
q_subdir = _get_quant_subdir(model_type, quant_path, tdir)
out_tdir = output_path / tdir
orig_tdir = original_model_path / tdir
print(f"\nConverting {tdir} (quant source: {q_subdir.name}) …")
_convert_transformer(q_subdir, out_tdir, orig_tdir)
print(f"\nDone. Merged model → {output_path}")
print("\nRun inference (quantization auto-detected from config.json):")
print(f" python text_to_video.py --model {output_path}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--model-type", required=True, choices=SUPPORTED_MODEL_TYPES, help="Model variant.")
parser.add_argument("--original-model", required=True, help="Original HF Diffusers model directory (BF16).")
parser.add_argument("--quant-path", required=True, help="msModelSlim quantized weights directory.")
parser.add_argument("--output-path", required=True, help="Output directory for merged model.")
args = parser.parse_args()
repack(
model_type=args.model_type,
original_model_path=pathlib.Path(args.original_model),
quant_path=pathlib.Path(args.quant_path),
output_path=pathlib.Path(args.output_path),
)
if __name__ == "__main__":
main()