Files
vllm-omni/docs/contributing/model/adding_quantization_model.md
T

17 KiB
Raw Blame History

Support FP8 Quantization

This section describes how to add FP8 quantization support to a diffusion model. We use the Qwen-Image pipeline as the reference implementation.


Table of Contents


Overview

What is FP8 Quantization?

FP8 quantization reduces model memory footprint by storing weights in 8-bit floating point (float8_e4m3fn) instead of 16-bit (bfloat16/float16). This saves ~50% of weight memory.

A diffusion pipeline typically has three quantizable components:

Component Layer Types Quantization Approach
DiT (transformer) nn.Linear Replace with vLLM quantized linear layers
Text encoder nn.Linear FP8 weight storage via hooks
VAE nn.Conv2d, nn.Conv3d FP8 weight storage via hooks

Architecture

The quantization system has two mechanisms:

1. vLLM Quantized Linear Layers (for DiT)

vLLM provides drop-in replacements for nn.Linear that perform FP8 W8A8 computation on supported hardware. These are configured via quant_config passed to layer constructors.

Key classes:

Class Location Purpose
build_quant_config vllm_omni/quantization/__init__.py Build quantization config from string/dict/per-component spec
ComponentQuantizationConfig vllm_omni/quantization/component_config.py Per-component routing by layer prefix

2. FP8 Weight Storage via Hooks (for text encoder and VAE)

For models loaded via from_pretrained() (outside vLLM's weight pipeline), we use forward hooks to store weights in FP8 and dequantize on-the-fly:

At rest:     weight.data = fp8_tensor (half memory)
Pre-hook:    weight.data = fp8_weight.to(bf16) * scale
Forward:     Normal computation in BF16
Post-hook:   weight.data = fp8_weight (back to FP8)

Key function:

Function Location Purpose
apply_fp8_weight_storage vllm_omni/diffusion/models/utils.py Apply FP8 hooks to Linear/Conv layers (introduced in #1412)

Step-by-Step Implementation

Step 1: Add quant_config to the Transformer

Pass quant_config to vLLM parallel linear layers in the transformer. This enables FP8 W8A8 for DiT linear layers.

1.1: Accept quant_config in Constructor

from vllm.model_executor.layers.quantization.base_config import QuantizationConfig

class YourTransformer2DModel(nn.Module):
    def __init__(
        self,
        *,
        od_config: OmniDiffusionConfig,
        quant_config: QuantizationConfig | None = None,
        # ... other params
    ):
        self.quant_config = quant_config
        # Pass to sub-modules
        self.blocks = nn.ModuleList([
            YourTransformerBlock(quant_config=quant_config, ...)
            for _ in range(num_layers)
        ])

1.2: Pass quant_config to vLLM Linear Layers

Replace nn.Linear with vLLM parallel linear layers that accept quant_config:

from vllm.model_executor.layers.linear import (
    ColumnParallelLinear,
    QKVParallelLinear,
    RowParallelLinear,
)

class YourAttentionBlock(nn.Module):
    def __init__(self, hidden_size, num_heads, quant_config=None, prefix=""):
        # QKV projection
        self.to_qkv = QKVParallelLinear(
            hidden_size=hidden_size,
            head_size=hidden_size // num_heads,
            total_num_heads=num_heads,
            quant_config=quant_config,
            prefix=f"{prefix}.to_qkv",
        )
        # Output projection
        self.to_out = RowParallelLinear(
            input_size=hidden_size,
            output_size=hidden_size,
            quant_config=quant_config,
            prefix=f"{prefix}.to_out",
        )

class YourFeedForward(nn.Module):
    def __init__(self, hidden_size, intermediate_size, quant_config=None, prefix=""):
        self.gate_up_proj = ColumnParallelLinear(
            input_size=hidden_size,
            output_size=intermediate_size * 2,
            quant_config=quant_config,
            prefix=f"{prefix}.gate_up_proj",
        )
        self.down_proj = RowParallelLinear(
            input_size=intermediate_size,
            output_size=hidden_size,
            quant_config=quant_config,
            prefix=f"{prefix}.down_proj",
        )

!!! important Always pass the prefix parameter. It is used by ignored_layers matching to skip sensitive layers.

1.3: Identify Sensitive Layers

Some layers degrade output quality when quantized. Common sensitive layers:

Layer Why Sensitive Typical Models Quant Methods Affected
img_mlp Processes denoising latents with shifting dynamic range Qwen-Image FP8, Int8
feed_forward FFN layers in DiT blocks, large dynamic range Z-Image Int8
proj_out Final output projection, small quantization errors amplified Various FP8, Int8
lm_head Output vocabulary projection, precision-critical Omni (thinker) NVFP4
mlp.gate MoE router gates, routing decisions are precision-critical MoE models NVFP4

Quantization quality benchmarks (LPIPS: lower = better, <0.01 imperceptible, >0.1 noticeable):

Model Method All Layers With ignored_layers Recommendation
Qwen-Image-2512 Int8 LPIPS 0.0197 0.0027 (skip img_mlp) Skip img_mlp
Z-Image-Turbo Int8 LPIPS 0.1597 0.0290 (skip feed_forward) Skip feed_forward
Z-Image-Turbo FP8 LPIPS ~0.005 — All layers OK

To identify sensitive layers for a new model:

  1. Run inference with all layers quantized
  2. Compare output quality with BF16 baseline using LPIPS (see benchmarks/diffusion/quantization_quality.py)
  3. Selectively disable quantization per layer (via ignored_layers) until quality matches
  4. Document the recommended ignored_layers in the model's supported models table

Step 2: Wire Up Quantization in the Pipeline

2.1: Create quant_config and Pass to Transformer

In the pipeline's __init__, extract the vLLM quant config and pass it to the transformer:

from vllm_omni.quantization import build_quant_config

class YourPipeline(nn.Module):
    def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
        super().__init__()
        # ... load scheduler, text_encoder, vae ...

        # Build quant config from OmniDiffusionConfig
        quant_config = build_quant_config(od_config.quantization_config)

        # Pass to transformer
        self.transformer = YourTransformer2DModel(
            od_config=od_config,
            quant_config=quant_config,
            # ... other kwargs
        )

2.2: Apply FP8 Weight Storage to Text Encoder and VAE

For models loaded via from_pretrained(), apply hook-based FP8 weight storage.

!!! note The apply_fp8_weight_storage function is provided by vllm_omni/diffusion/models/utils.py, introduced in PR #1412.

from vllm_omni.diffusion.models.utils import apply_fp8_weight_storage

class YourPipeline(nn.Module):
    def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
        super().__init__()

        self.text_encoder = SomeTextEncoder.from_pretrained(...)
        self.vae = SomeVAE.from_pretrained(...).to(self.device)

        # Apply FP8 weight storage to text encoder and VAE
        if (
            od_config.quantization_config is not None
            and getattr(od_config.quantization_config, "quant_method", None) == "fp8"
        ):
            apply_fp8_weight_storage(self.vae)
            apply_fp8_weight_storage(self.text_encoder)

        # ... rest of init

apply_fp8_weight_storage walks all nn.Linear, nn.Conv2d, and nn.Conv3d modules in the model and applies FP8 hooks to each.

2.3: Fix load_weights to Include Pre-loaded Components

Since text encoders and VAEs are loaded via from_pretrained() (not through vLLM's weight pipeline), their parameters won't appear in the loaded_weights set. Mark them explicitly:

def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
    loader = AutoWeightsLoader(self)
    loaded_weights = loader.load_weights(weights)
    # Mark pre-loaded component weights as loaded
    loaded_weights |= {f"vae.{name}" for name, _ in self.vae.named_parameters()}
    loaded_weights |= {f"text_encoder.{name}" for name, _ in self.text_encoder.named_parameters()}
    return loaded_weights

Step 3: Add to Supported Models Documentation

Update the FP8 supported models table in docs/user_guide/diffusion/quantization/fp8.md:

| Your-Model | `org/Your-Model` | ✅ | ✅ | ✅ | `sensitive_layer` |

Testing

Quick Verification

from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams

# Test with FP8 enabled
omni = Omni(model="your-model", quantization="fp8")

outputs = omni.generate(
    "A cat sitting on a windowsill",
    OmniDiffusionSamplingParams(num_inference_steps=50),
)

Quality Gate Test (LPIPS)

We provide a pytest-based quality gate at tests/diffusion/quantization/test_quantization_quality.py. It generates outputs with both BF16 and your quantized method using the same seed, computes LPIPS perceptual distance, and fails if it exceeds a threshold. This is the recommended way to validate that a quantization method does not unacceptably degrade output quality.

Step 1: Add your test config — edit QUALITY_CONFIGS in the test file:

QualityTestConfig(
    id="int8_z_image",              # pytest ID
    model="Tongyi-MAI/Z-Image-Turbo",
    quantization="int8",            # your quantization method
    task="t2i",                     # "t2i" or "t2v"
    prompt="a cup of coffee on a wooden table, morning light",
    max_lpips=0.15,                 # fail threshold (see guide below)
    num_inference_steps=20,
),

Step 2: Run the test:

# Install dependency
pip install lpips

# Run all quality tests
pytest tests/diffusion/quantization/test_quantization_quality.py -v -m ""

# Run only your method
pytest tests/diffusion/quantization/test_quantization_quality.py -v -m "" -k "int8"

# Run a specific model
pytest tests/diffusion/quantization/test_quantization_quality.py -v -m "" -k "z_image"

Step 3: Read the output:

Quantization Quality: int8_z_image
  Model:         Tongyi-MAI/Z-Image-Turbo
  Method:        int8
  LPIPS:         0.0290  (threshold: 0.15)
  BF16 memory:   19.15 GiB
  Quant memory:  14.80 GiB  (23% reduction)
  Result:        PASS

LPIPS threshold guide:

LPIPS Range Meaning Recommendation
< 0.01 Imperceptible Ideal — no visible difference
0.01 – 0.05 Minor differences Good — acceptable for most use cases
0.05 – 0.15 Noticeable on close inspection Acceptable with ignored_layers tuning
0.15 – 0.30 Clearly visible Investigate — likely needs ignored_layers
> 0.30 Significant degradation Quantization method may not suit this model

!!! tip If your test fails, don't raise the threshold — instead, identify sensitive layers and add them to ignored_layers (see next section). Then re-run the test with the lower LPIPS.

Quality Benchmark (Detailed)

For a full multi-prompt benchmark with Markdown output (suitable for PR descriptions), use the standalone benchmark script:

python benchmarks/diffusion/quantization_quality.py \
    --model Tongyi-MAI/Z-Image-Turbo \
    --quantization fp8 \
    --prompts \
        "a cup of coffee on the table" \
        "an aerial view of a coral reef" \
    --height 1024 --width 1024 \
    --num-inference-steps 50 --seed 42

This generates BF16 and quantized outputs, computes per-prompt LPIPS, and prints a Markdown summary table ready to paste into your PR.

Identifying Sensitive Layers with ignored_layers

Not all layers tolerate quantization equally. ignored_layers lets you keep specific layers in BF16 while quantizing everything else. This is critical for achieving good quality with aggressive quantization methods (Int8, NVFP4).

Why some layers are sensitive:

Layer Type Why Sensitive Typical Impact
img_mlp Processes denoising latents with shifting dynamic range across timesteps. No built-in normalization to absorb quantization error. Color shifts, blurriness
feed_forward FFN layers with large dynamic range in DiT blocks. Artifacts, loss of detail
proj_out Final output projection — small errors get amplified since there are no subsequent layers to correct them. Overall quality loss
lm_head Vocabulary projection in LLMs — precision-critical for token selection. Garbage text output
mlp.gate MoE router gates — routing decisions are binary and precision-critical. Wrong expert selection, quality collapse

How to find sensitive layers for your model:

  1. Run the quality gate test with all layers quantized:

    pytest tests/diffusion/quantization/test_quantization_quality.py -v -m "" -k "your_model"
    
  2. If LPIPS exceeds your threshold, try skipping common sensitive layers one at a time:

    QualityTestConfig(
        id="fp8_your_model_skip_mlp",
        model="your-org/Your-Model",
        quantization="fp8",
        task="t2i",
        prompt="a cup of coffee on a wooden table",
        max_lpips=0.05,
    ),
    

    And in the test, modify the Omni call:

    omni_qt = Omni(
        model=config.model,
        quantization_config={
            "method": config.quantization,
            "ignored_layers": ["img_mlp"],
        },
    )
    
  3. Compare LPIPS with and without ignored_layers to isolate the problematic layers.

  4. Document the recommended ignored_layers in the supported models table.

Real-world examples:

Model Method All Layers (LPIPS) With ignored_layers Which Layers to Skip
Qwen-Image-2512 Int8 0.0197 0.0027 img_mlp
Z-Image-Turbo Int8 0.1597 0.0290 feed_forward
Z-Image-Turbo FP8 ~0.005 — None needed

What to Verify (Checklist)

  1. Model loads without errors — Check logs for quantization initialization messages
  2. Quality gate passes — LPIPS within threshold vs BF16 baseline
  3. Memory reduction — Expect ~30-50% savings on quantized components
  4. Sensitive layers identified — Document which layers (if any) need ignored_layers
  5. ignored_layers pattern works — Verify that the layer name prefix matches your transformer's module names

Troubleshooting

Issue: Output Quality Degradation

Symptoms: Generated images have visible artifacts, color shifts, or blurriness compared to BF16 baseline.

Causes & Solutions:

  • Sensitive layers being quantized:

    Solution: Identify and skip sensitive layers:

    omni = Omni(
        model="your-model",
        quantization_config={
            "method": "fp8",
            "ignored_layers": ["img_mlp"],
        },
    )
    
  • All layers sensitive (rare):

    Solution: The model may not be suitable for FP8 quantization. Consider weight-only quantization or other methods.

Issue: Missing Weights Warning

Symptoms: Warnings about unloaded weights for vae.* or text_encoder.* parameters.

Solution: Ensure load_weights marks pre-loaded component weights. See Step 2.3.

Issue: CUDA Out of Memory

Symptoms: OOM errors during model loading despite FP8 being enabled.

Causes & Solutions:

  • Text encoder/VAE loaded in BF16 before FP8 conversion:

    FP8 weight storage is applied after from_pretrained(), so peak load memory is still BF16. This is a one-time cost during initialization.


Reference Implementations

Model Pipeline File Transformer File Notes
Z-Image models/z_image/pipeline_z_image.py models/z_image/z_image_transformer.py DiT + text encoder FP8
Qwen-Image models/qwen_image/pipeline_qwen_image.py models/qwen_image/qwen_image_transformer.py DiT + text encoder + VAE FP8

All files are under vllm_omni/diffusion/.


Summary

Adding FP8 quantization support to a new model:

  1. ✅ Add quant_config to transformer — Accept and pass to vLLM parallel linear layers with proper prefix
  2. ✅ Identify sensitive layers — Profile which layers need ignored_layers for quality
  3. ✅ Apply FP8 weight storage — Call apply_fp8_weight_storage() on text encoder and VAE in pipeline __init__
  4. ✅ Fix load_weights — Mark pre-loaded component weights as loaded
  5. ✅ Update docs — Add model to the supported models table in docs/user_guide/diffusion/quantization/fp8.md
  6. ✅ Test — Verify quality, memory savings, and sensitive layer behavior