[Diffusion] Fuse Qwen-Image FP8 QKV projection and Blackwell epilogue (#37123)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xiaoyu Zhang
2026-09-01 08:34:41 +08:00
committed by GitHub
co-authored by Cursor
parent 07c8f7294d
commit 6715debb2a
9 changed files with 1062 additions and 92 deletions
@@ -0,0 +1,265 @@
#pragma once
#include <sgl_kernel/tensor.h>
#include <sgl_kernel/runtime.cuh>
#include <sgl_kernel/type.cuh>
#include <sgl_kernel/utils.cuh>
#include <sgl_kernel/vec.cuh>
#include <sgl_kernel/warp.cuh>
#include <algorithm>
#include <cstdint>
namespace sglang {
namespace qwen_qkv_epilogue {
constexpr int kHeadDim = 128;
constexpr int kThreads = 256;
constexpr int kWarps = kThreads / device::kWarpThreads;
constexpr int kElemsPerThread = kHeadDim / device::kWarpThreads;
constexpr int kVecSize = kElemsPerThread / 2;
struct Params {
void* joint_q;
void* joint_k;
void* joint_v;
const void* img_q;
const void* img_k;
const void* img_v;
const void* txt_q;
const void* txt_k;
const void* txt_v;
const void* img_q_weight;
const void* img_k_weight;
const void* txt_q_weight;
const void* txt_k_weight;
const void* img_cache;
const void* txt_cache;
int64_t input_token_stride_bytes;
int64_t output_token_stride_bytes;
int64_t head_stride_bytes;
uint32_t img_tokens;
uint32_t txt_tokens;
uint32_t num_heads;
float img_eps;
float txt_eps;
};
__global__ void qwen_qkv_epilogue_kernel(const Params __grid_constant__ params) {
using namespace device;
using Packed = packed_t<bf16_t>;
using Storage = AlignedVector<Packed, kVecSize>;
const uint32_t lane = threadIdx.x % kWarpThreads;
const uint32_t warp = threadIdx.x / kWarpThreads;
const uint32_t start = blockIdx.x * kWarps + warp;
const uint32_t workers = gridDim.x * kWarps;
const uint32_t total_tokens = params.txt_tokens + params.img_tokens;
const uint32_t token_head_works = total_tokens * params.num_heads;
const uint32_t total_works = 3 * token_head_works;
for (uint32_t work = start; work < total_works; work += workers) {
const uint32_t kind = work / token_head_works; // 0: Q, 1: K, 2: V.
const uint32_t token_head = work % token_head_works;
const uint32_t joint_token = token_head / params.num_heads;
const uint32_t head = token_head % params.num_heads;
const bool is_text = joint_token < params.txt_tokens;
const uint32_t source_token = is_text ? joint_token : joint_token - params.txt_tokens;
const void* input_base;
void* output_base;
if (kind == 0) {
input_base = is_text ? params.txt_q : params.img_q;
output_base = params.joint_q;
} else if (kind == 1) {
input_base = is_text ? params.txt_k : params.img_k;
output_base = params.joint_k;
} else {
input_base = is_text ? params.txt_v : params.img_v;
output_base = params.joint_v;
}
const void* input =
pointer::offset(input_base, source_token * params.input_token_stride_bytes, head * params.head_stride_bytes);
void* output =
pointer::offset(output_base, joint_token * params.output_token_stride_bytes, head * params.head_stride_bytes);
auto input_vec = load_as<Storage>(input, lane);
if (kind == 2) {
store_as<Storage>(output, input_vec, lane);
continue;
}
const void* weight_base;
if (kind == 0) {
weight_base = is_text ? params.txt_q_weight : params.img_q_weight;
} else {
weight_base = is_text ? params.txt_k_weight : params.img_k_weight;
}
const auto weight_vec = load_as<Storage>(weight_base, lane);
float elems[kElemsPerThread];
float sum_of_squares = 0.0f;
#pragma unroll
for (uint32_t j = 0; j < kVecSize; ++j) {
const auto [x0, x1] = cast<fp32x2_t>(input_vec[j]);
elems[2 * j] = x0;
elems[2 * j + 1] = x1;
sum_of_squares += x0 * x0 + x1 * x1;
}
sum_of_squares = warp::reduce_sum(sum_of_squares);
const float eps = is_text ? params.txt_eps : params.img_eps;
const float norm_factor = math::rsqrt(sum_of_squares / static_cast<float>(kHeadDim) + eps);
#pragma unroll
for (uint32_t j = 0; j < kVecSize; ++j) {
const auto [w0, w1] = cast<fp32x2_t>(weight_vec[j]);
elems[2 * j] *= norm_factor * w0;
elems[2 * j + 1] *= norm_factor * w1;
}
const auto* cache = static_cast<const float*>(is_text ? params.txt_cache : params.img_cache);
const auto* cos_ptr = cache + source_token * kHeadDim;
const auto* sin_ptr = cos_ptr + kHeadDim / 2;
#pragma unroll
for (uint32_t i = 0; i < kElemsPerThread; i += 2) {
const float x = elems[i];
const float y = elems[i + 1];
const uint32_t cache_idx = (lane * kElemsPerThread + i) / 2;
const float cos = __ldg(cos_ptr + cache_idx);
const float sin = __ldg(sin_ptr + cache_idx);
elems[i] = x * cos - y * sin;
elems[i + 1] = y * cos + x * sin;
}
#pragma unroll
for (uint32_t j = 0; j < kVecSize; ++j) {
input_vec[j] = cast<Packed, fp32x2_t>({elems[2 * j], elems[2 * j + 1]});
}
store_as<Storage>(output, input_vec, lane);
}
}
struct QwenQKVEpilogueKernel {
static void
run(tvm::ffi::TensorView joint_q,
tvm::ffi::TensorView joint_k,
tvm::ffi::TensorView joint_v,
tvm::ffi::TensorView img_q,
tvm::ffi::TensorView img_k,
tvm::ffi::TensorView img_v,
tvm::ffi::TensorView txt_q,
tvm::ffi::TensorView txt_k,
tvm::ffi::TensorView txt_v,
tvm::ffi::TensorView img_q_weight,
tvm::ffi::TensorView img_k_weight,
tvm::ffi::TensorView txt_q_weight,
tvm::ffi::TensorView txt_k_weight,
tvm::ffi::TensorView img_cache,
tvm::ffi::TensorView txt_cache,
double img_eps,
double txt_eps) {
using namespace host;
auto NI = SymbolicSize{"img_tokens"};
auto NT = SymbolicSize{"txt_tokens"};
auto N = SymbolicSize{"joint_tokens"};
auto H = SymbolicSize{"num_heads"};
auto D = SymbolicSize{"head_dim"};
auto device = SymbolicDevice{};
D.set_value(kHeadDim);
device.set_options<kDLCUDA>();
TensorMatcher({NI, H, D})
.with_strides({-1, D, 1})
.with_dtype<bf16_t>()
.with_device(device)
.verify(img_q)
.verify(img_k)
.verify(img_v);
TensorMatcher({NT, H, D})
.with_strides({-1, D, 1})
.with_dtype<bf16_t>()
.with_device(device)
.verify(txt_q)
.verify(txt_k)
.verify(txt_v);
N.set_value(NI.unwrap() + NT.unwrap());
TensorMatcher({N, H, D}).with_dtype<bf16_t>().with_device(device).verify(joint_q).verify(joint_k).verify(joint_v);
TensorMatcher({D})
.with_dtype<bf16_t>()
.with_device(device)
.verify(img_q_weight)
.verify(img_k_weight)
.verify(txt_q_weight)
.verify(txt_k_weight);
TensorMatcher({-1, D}).with_dtype<fp32_t>().with_device(device).verify(img_cache);
TensorMatcher({-1, D}).with_dtype<fp32_t>().with_device(device).verify(txt_cache);
RuntimeCheck(
img_q.stride(0) == img_k.stride(0) && img_q.stride(0) == img_v.stride(0),
"image QKV inputs must use the same token stride");
RuntimeCheck(
txt_q.stride(0) == txt_k.stride(0) && txt_q.stride(0) == txt_v.stride(0),
"text QKV inputs must use the same token stride");
RuntimeCheck(img_q.stride(0) == txt_q.stride(0), "image/text QKV token strides must match");
RuntimeCheck(
img_q.stride(1) == kHeadDim && img_k.stride(1) == kHeadDim && img_v.stride(1) == kHeadDim,
"image QKV heads must be contiguous");
RuntimeCheck(
txt_q.stride(1) == kHeadDim && txt_k.stride(1) == kHeadDim && txt_v.stride(1) == kHeadDim,
"text QKV heads must be contiguous");
RuntimeCheck(joint_q.is_contiguous(), "joint QKV outputs must be contiguous");
RuntimeCheck(joint_k.is_contiguous(), "joint QKV outputs must be contiguous");
RuntimeCheck(joint_v.is_contiguous(), "joint QKV outputs must be contiguous");
RuntimeCheck(img_cache.size(0) >= NI.unwrap(), "img cache is shorter than img tokens");
RuntimeCheck(txt_cache.size(0) >= NT.unwrap(), "txt cache is shorter than txt tokens");
const uint32_t img_tokens = static_cast<uint32_t>(NI.unwrap());
const uint32_t txt_tokens = static_cast<uint32_t>(NT.unwrap());
const uint32_t num_heads = static_cast<uint32_t>(H.unwrap());
const uint32_t total_works = 3 * (img_tokens + txt_tokens) * num_heads;
if (total_works == 0) return;
const int64_t head_stride_bytes = kHeadDim * sizeof(bf16_t);
const int64_t input_token_stride_bytes = img_q.stride(0) * sizeof(bf16_t);
const int64_t output_token_stride_bytes = num_heads * head_stride_bytes;
const auto params = Params{
.joint_q = joint_q.data_ptr(),
.joint_k = joint_k.data_ptr(),
.joint_v = joint_v.data_ptr(),
.img_q = img_q.data_ptr(),
.img_k = img_k.data_ptr(),
.img_v = img_v.data_ptr(),
.txt_q = txt_q.data_ptr(),
.txt_k = txt_k.data_ptr(),
.txt_v = txt_v.data_ptr(),
.img_q_weight = img_q_weight.data_ptr(),
.img_k_weight = img_k_weight.data_ptr(),
.txt_q_weight = txt_q_weight.data_ptr(),
.txt_k_weight = txt_k_weight.data_ptr(),
.img_cache = img_cache.data_ptr(),
.txt_cache = txt_cache.data_ptr(),
.input_token_stride_bytes = input_token_stride_bytes,
.output_token_stride_bytes = output_token_stride_bytes,
.head_stride_bytes = head_stride_bytes,
.img_tokens = img_tokens,
.txt_tokens = txt_tokens,
.num_heads = num_heads,
.img_eps = static_cast<float>(img_eps),
.txt_eps = static_cast<float>(txt_eps),
};
const uint32_t sm_count = runtime::get_sm_count(device.unwrap().device_id);
static const uint32_t blocks_per_sm = runtime::get_blocks_per_sm(qwen_qkv_epilogue_kernel, kThreads);
const uint32_t needed_blocks = div_ceil(total_works, uint32_t(kWarps));
const uint32_t blocks = std::min(blocks_per_sm * sm_count, needed_blocks);
LaunchKernel(blocks, kThreads, device.unwrap())(qwen_qkv_epilogue_kernel, params);
}
};
} // namespace qwen_qkv_epilogue
} // namespace sglang
@@ -37,6 +37,7 @@ from sglang.kernels.spec import (
)
_CUDA = frozenset({CapabilityRequirement.CUDA})
_CUDA_SM100_PLUS = frozenset({CapabilityRequirement.cuda(min_sm=(10, 0))})
_HIP = frozenset({CapabilityRequirement.HIP})
# ---------------------------------------------------------------------------
@@ -193,6 +194,13 @@ _SPECS: tuple[tuple[str, KernelBackend, str, frozenset, str], ...] = (
_CUDA,
"Fused in-place QK RMS-norm + RoPE.",
),
(
"diffusion.qwen_qkv_epilogue",
KernelBackend.JIT,
"rope.qwen_qkv_epilogue_jit:try_fused_qwen_qkv_epilogue",
_CUDA_SM100_PLUS,
"Qwen-Image QK RMS-norm, RoPE, and joint QKV writes.",
),
(
"diffusion.ltx2_qknorm_split_rope",
KernelBackend.JIT,
@@ -426,6 +434,7 @@ _EXPORTS: dict[str, str] = {
"can_use_fused_inplace_qknorm_rope": "rope.qknorm_rope_jit",
"fused_inplace_qknorm_rope": "rope.qknorm_rope_jit",
"fused_qknorm_rope_pack_kv": "rope.qknorm_rope_jit",
"try_fused_qwen_qkv_epilogue": "rope.qwen_qkv_epilogue_jit",
"can_use_fused_rope_rotate_half": "rope.rope_rotate_half_bitexact",
"fused_rope_rotate_half_bitexact": "rope.rope_rotate_half_bitexact",
"can_use_interleaved_rope_fp64": "rope.interleaved_rope_fp64_jit",
@@ -0,0 +1,148 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
from sglang.kernels.jit.utils import cache_once, load_jit
if TYPE_CHECKING:
from tvm_ffi.module import Module
_HEAD_DIM = 128
_ALIGN = 32
@cache_once
def qwen_qkv_epilogue_module() -> Module:
return load_jit(
"qwen_qkv_epilogue_bf16",
cuda_files=["diffusion/qwen_qkv_epilogue.cuh"],
cuda_wrappers=[
(
"qwen_qkv_epilogue",
"qwen_qkv_epilogue::QwenQKVEpilogueKernel::run",
)
],
)
def _qkv_tensor(tensor: torch.Tensor, like: torch.Tensor | None = None) -> bool:
return (
isinstance(tensor, torch.Tensor)
and tensor.is_cuda
and tensor.dtype == torch.bfloat16
and tensor.ndim == 4
and tensor.shape[0] == 1
and tensor.shape[-1] == _HEAD_DIM
and tensor.numel() > 0
and tensor.stride(-1) == 1
and tensor.stride(-2) == _HEAD_DIM
and tensor.data_ptr() % _ALIGN == 0
and (
like is None
or (
tensor.device == like.device
and tensor.shape[0] == like.shape[0]
and tensor.shape[1] == like.shape[1]
and tensor.shape[2:] == like.shape[2:]
and tensor.stride(1) == like.stride(1)
)
)
)
def try_fused_qwen_qkv_epilogue(
img_q: torch.Tensor,
img_k: torch.Tensor,
img_v: torch.Tensor,
txt_q: torch.Tensor,
txt_k: torch.Tensor,
txt_v: torch.Tensor,
img_q_weight: torch.Tensor,
img_k_weight: torch.Tensor,
txt_q_weight: torch.Tensor,
txt_k_weight: torch.Tensor,
img_cache: torch.Tensor,
txt_cache: torch.Tensor,
img_eps: float,
txt_eps: float,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None:
"""Fuse Q/K normalization, RoPE, and QKV joint-buffer writes.
The caller retains an explicit unfused path for every unsupported shape,
layout, architecture, or compilation mode.
"""
if torch.compiler.is_compiling():
return None
if not (
_qkv_tensor(img_q)
and _qkv_tensor(img_k, img_q)
and _qkv_tensor(img_v, img_q)
and _qkv_tensor(txt_q)
and _qkv_tensor(txt_k, txt_q)
and _qkv_tensor(txt_v, txt_q)
and img_q.shape[2] == txt_q.shape[2]
and torch.version.cuda is not None
and torch.cuda.get_device_capability(img_q.device)[0] >= 10
):
return None
heads = img_q.shape[2]
weights = []
# RMSNorm weights are shared across heads, unlike projection biases.
for tensor in (img_q_weight, img_k_weight, txt_q_weight, txt_k_weight):
if not (
isinstance(tensor, torch.Tensor)
and tensor.is_cuda
and tensor.device == img_q.device
and tensor.dtype == torch.bfloat16
and tensor.shape == (_HEAD_DIM,)
and tensor.is_contiguous()
and tensor.data_ptr() % _ALIGN == 0
):
return None
weights.append(tensor)
if not (
isinstance(img_cache, torch.Tensor)
and isinstance(txt_cache, torch.Tensor)
and img_cache.is_cuda
and txt_cache.is_cuda
and img_cache.device == img_q.device
and txt_cache.device == img_q.device
and img_cache.dtype == torch.float32
and txt_cache.dtype == torch.float32
and img_cache.ndim == 2
and txt_cache.ndim == 2
and img_cache.shape[1] == _HEAD_DIM
and txt_cache.shape[1] == _HEAD_DIM
and img_cache.shape[0] >= img_q.shape[1]
and txt_cache.shape[0] >= txt_q.shape[1]
and img_cache.is_contiguous()
and txt_cache.is_contiguous()
):
return None
joint_shape = (1, txt_q.shape[1] + img_q.shape[1], heads, _HEAD_DIM)
joint_q = torch.empty(joint_shape, dtype=img_q.dtype, device=img_q.device)
joint_k = torch.empty_like(joint_q)
joint_v = torch.empty_like(joint_q)
qwen_qkv_epilogue_module().qwen_qkv_epilogue(
joint_q.view(-1, heads, _HEAD_DIM),
joint_k.view(-1, heads, _HEAD_DIM),
joint_v.view(-1, heads, _HEAD_DIM),
img_q.view(-1, heads, _HEAD_DIM),
img_k.view(-1, heads, _HEAD_DIM),
img_v.view(-1, heads, _HEAD_DIM),
txt_q.view(-1, heads, _HEAD_DIM),
txt_k.view(-1, heads, _HEAD_DIM),
txt_v.view(-1, heads, _HEAD_DIM),
*weights,
img_cache,
txt_cache,
float(img_eps),
float(txt_eps),
)
return joint_q, joint_k, joint_v
@@ -39,7 +39,6 @@ from sglang.srt.layers.quantization.modelopt_quant import (
)
from sglang.srt.layers.quantization.utils import (
convert_to_channelwise,
is_layer_skipped,
requantize_with_max_scale,
)
from sglang.srt.layers.utils.common import copy_or_rebind_param
@@ -123,10 +122,7 @@ class ModelOptQuantConfig(QuantizationConfig):
from sglang.multimodal_gen.runtime.layers.linear import LinearBase
if isinstance(layer, LinearBase):
if self.is_layer_excluded(prefix) or (
self.packed_modules_mapping
and is_layer_skipped(prefix, [], self.packed_modules_mapping)
):
if self.is_layer_excluded(prefix) or self._is_packed_layer_excluded(prefix):
return UnquantizedLinearMethod()
return Linear(self)
return None
@@ -163,6 +159,24 @@ class ModelOptQuantConfig(QuantizationConfig):
return True
return False
def _is_packed_layer_excluded(self, prefix: str) -> bool:
proj_name = prefix.rsplit(".", 1)[-1]
shard_names = self.packed_modules_mapping.get(proj_name)
if shard_names is None:
return False
base_prefix = prefix[: -len(proj_name)]
shard_exclusions = [
self.is_layer_excluded(base_prefix + shard_name)
for shard_name in shard_names
]
if any(shard_exclusions) and not all(shard_exclusions):
raise ValueError(
f"Detected some but not all shards of {prefix} are quantized. "
"All shards of fused layers must have the same precision."
)
return all(shard_exclusions)
class ModelOptFp8Config(ModelOptQuantConfig):
"""Config class for ModelOpt FP8 diffusion checkpoints."""
@@ -475,16 +489,36 @@ class ModelOptFp8LinearMethod(LinearMethodBase):
if input_scale is not None:
copy_or_rebind_param(layer, "input_scale", input_scale)
max_w_scale, quantized_weight = requantize_with_max_scale(
weight, layer.weight_scale, layer.logical_widths
complete_shard_scales = (
self.cutlass_fp8_supported
and len(layer.logical_widths) > 1
and bool(
torch.all(
layer.weight_scale > torch.finfo(torch.float8_e4m3fn).min
).item()
)
)
if complete_shard_scales:
# CUTLASS accepts a scale per output channel. Preserve each
# checkpoint shard's original FP8 values and scale instead of
# requantizing all packed shards to the largest scale.
quantized_weight = weight
processed_weight_scale = convert_to_channelwise(
layer.weight_scale, layer.logical_widths
)
else:
processed_weight_scale, quantized_weight = requantize_with_max_scale(
weight, layer.weight_scale, layer.logical_widths
)
if self.cutlass_fp8_supported:
processed_weight_scale = convert_to_channelwise(
processed_weight_scale, layer.logical_widths
)
# Preserve the parameter subclass metadata while rebinding to the
# transposed FP8 view expected by the runtime.
layer.weight.data = quantized_weight.t().detach()
layer.weight.requires_grad_(False)
if self.cutlass_fp8_supported:
max_w_scale = convert_to_channelwise(max_w_scale, layer.logical_widths)
copy_or_rebind_param(layer, "weight_scale", max_w_scale)
copy_or_rebind_param(layer, "weight_scale", processed_weight_scale)
copy_or_rebind_param(layer, "input_scale", layer.input_scale.max())
def apply(
@@ -26,6 +26,8 @@ def get_qkv_projections(
attn: Any,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor | None = None,
*,
make_contiguous: bool = True,
) -> tuple[
torch.Tensor,
torch.Tensor,
@@ -41,10 +43,15 @@ def get_qkv_projections(
set by those blocks' constructors, and ``use_fused_added_qkv`` whenever
``added_kv_proj_dim`` is not ``None`` — direct attribute access so a
renamed flag fails loudly instead of silently unfusing.
``make_contiguous=False`` preserves zero-copy views for a caller that can
consume packed projection output strides directly.
"""
if attn.use_fused_qkv:
qkv, _ = attn.to_qkv(hidden_states)
query, key, value = [t.contiguous() for t in qkv.chunk(3, dim=-1)]
query, key, value = qkv.chunk(3, dim=-1)
if make_contiguous:
query, key, value = [t.contiguous() for t in (query, key, value)]
else:
query, _ = attn.to_q(hidden_states)
key, _ = attn.to_k(hidden_states)
@@ -54,9 +61,11 @@ def get_qkv_projections(
if encoder_hidden_states is not None and attn.added_kv_proj_dim is not None:
if attn.use_fused_added_qkv:
added_qkv, _ = attn.to_added_qkv(encoder_hidden_states)
encoder_query, encoder_key, encoder_value = [
t.contiguous() for t in added_qkv.chunk(3, dim=-1)
]
encoder_query, encoder_key, encoder_value = added_qkv.chunk(3, dim=-1)
if make_contiguous:
encoder_query, encoder_key, encoder_value = [
t.contiguous() for t in (encoder_query, encoder_key, encoder_value)
]
else:
encoder_query, _ = attn.add_q_proj(encoder_hidden_states)
encoder_key, _ = attn.add_k_proj(encoder_hidden_states)
@@ -27,6 +27,7 @@ from sglang.kernels.ops.diffusion import (
try_fused_bias_mul_add,
try_fused_bias_scale_residual_norm_scale_shift,
try_fused_norm_scale_shift_fp8,
try_fused_qwen_qkv_epilogue,
try_fused_scale_residual_norm_scale_shift_fp8,
)
from sglang.multimodal_gen.configs.models.dits.qwenimage import QwenImageDitConfig
@@ -265,6 +266,48 @@ def _qwen_modulation_cache_key(
)
def _modelopt_quant_name(
quant_config: Optional[QuantizationConfig],
) -> str | None:
return None if quant_config is None else quant_config.get_name()
_MODEL_OPT_FP8_QKV_PARAM_NAMES_MAPPING = {
# ModelOpt FP8 uses one QKV GEMM per stream. Merge the three Diffusers
# projections and their static scales into MergedColumnParallelLinear.
r"^(transformer_blocks\.\d+\.attn)\.to_q\.(weight|bias|weight_scale|input_scale)$": (
r"\1.to_qkv.\2",
0,
3,
),
r"^(transformer_blocks\.\d+\.attn)\.to_k\.(weight|bias|weight_scale|input_scale)$": (
r"\1.to_qkv.\2",
1,
3,
),
r"^(transformer_blocks\.\d+\.attn)\.to_v\.(weight|bias|weight_scale|input_scale)$": (
r"\1.to_qkv.\2",
2,
3,
),
r"^(transformer_blocks\.\d+\.attn)\.add_q_proj\.(weight|bias|weight_scale|input_scale)$": (
r"\1.to_added_qkv.\2",
0,
3,
),
r"^(transformer_blocks\.\d+\.attn)\.add_k_proj\.(weight|bias|weight_scale|input_scale)$": (
r"\1.to_added_qkv.\2",
1,
3,
),
r"^(transformer_blocks\.\d+\.attn)\.add_v_proj\.(weight|bias|weight_scale|input_scale)$": (
r"\1.to_added_qkv.\2",
2,
3,
),
}
class QwenTimestepProjEmbeddings(nn.Module):
def __init__(self, embedding_dim, use_additional_t_cond=False):
super().__init__()
@@ -681,8 +724,14 @@ class QwenImageCrossAttention(nn.Module):
self.added_kv_proj_dim = added_kv_proj_dim
self.prefix = prefix
self.defer_output_bias = _defer_modelopt_output_bias(quant_config)
self.use_fused_qkv = isinstance(quant_config, NunchakuConfig)
quant_name = _modelopt_quant_name(quant_config)
self.use_fused_qkv_epilogue = quant_name in {
"modelopt_fp4",
"modelopt_fp8",
}
self.use_fused_qkv = (
isinstance(quant_config, NunchakuConfig) or quant_name == "modelopt_fp8"
)
self.inner_dim = out_dim if out_dim is not None else head_dim * num_heads
self.inner_kv_dim = self.inner_dim
@@ -733,7 +782,9 @@ class QwenImageCrossAttention(nn.Module):
self.norm_k = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity()
if added_kv_proj_dim is not None:
self.use_fused_added_qkv = isinstance(quant_config, NunchakuConfig)
self.use_fused_added_qkv = (
isinstance(quant_config, NunchakuConfig) or quant_name == "modelopt_fp8"
)
if self.use_fused_added_qkv:
self.to_added_qkv = MergedColumnParallelLinear(
added_kv_proj_dim,
@@ -854,7 +905,12 @@ class QwenImageCrossAttention(nn.Module):
txt_query,
txt_key,
txt_value,
) = _get_qkv_projections(self, hidden_states, encoder_hidden_states)
) = _get_qkv_projections(
self,
hidden_states,
encoder_hidden_states,
make_contiguous=not self.use_fused_qkv_epilogue,
)
# Reshape for multi-head attention
img_query = img_query.unflatten(-1, (self.local_num_heads, self.head_dim))
@@ -875,35 +931,71 @@ class QwenImageCrossAttention(nn.Module):
img_cache, txt_cache = image_rotary_emb
if self.qk_norm:
img_query, img_key = apply_qk_norm_with_optional_rope(
q=img_query,
k=img_key,
q_norm=self.norm_q,
k_norm=self.norm_k,
head_dim=self.head_dim,
cos_sin_cache=img_cache,
is_neox=False,
allow_inplace=True,
)
txt_query, txt_key = apply_qk_norm_with_optional_rope(
q=txt_query,
k=txt_key,
q_norm=self.norm_added_q,
k_norm=self.norm_added_k,
head_dim=self.head_dim,
cos_sin_cache=txt_cache,
is_neox=False,
allow_inplace=True,
)
elif img_cache is not None and txt_cache is not None:
img_query, img_key = apply_flashinfer_rope_qk_inplace(
img_query, img_key, img_cache, is_neox=False
)
txt_query, txt_key = apply_flashinfer_rope_qk_inplace(
txt_query, txt_key, txt_cache, is_neox=False
joint_qkv = None
if (
self.use_fused_qkv_epilogue
and self.qk_norm
and img_cache is not None
and txt_cache is not None
and not sp_text_sharded
and sp_txt_pad == 0
):
joint_qkv = try_fused_qwen_qkv_epilogue(
img_query,
img_key,
img_value,
txt_query,
txt_key,
txt_value,
self.norm_q.weight,
self.norm_k.weight,
self.norm_added_q.weight,
self.norm_added_k.weight,
img_cache,
txt_cache,
self.norm_q.variance_epsilon,
self.norm_added_q.variance_epsilon,
)
if joint_qkv is None:
# Fused ModelOpt FP8 projections expose zero-copy Q/K/V views into
# one packed GEMM output. Unsupported epilogue cases keep the old
# contiguous contract before entering the generic QKNorm/RoPE path.
img_query, img_key, img_value = [
tensor.contiguous() for tensor in (img_query, img_key, img_value)
]
txt_query, txt_key, txt_value = [
tensor.contiguous() for tensor in (txt_query, txt_key, txt_value)
]
if self.qk_norm:
img_query, img_key = apply_qk_norm_with_optional_rope(
q=img_query,
k=img_key,
q_norm=self.norm_q,
k_norm=self.norm_k,
head_dim=self.head_dim,
cos_sin_cache=img_cache,
is_neox=False,
allow_inplace=True,
)
txt_query, txt_key = apply_qk_norm_with_optional_rope(
q=txt_query,
k=txt_key,
q_norm=self.norm_added_q,
k_norm=self.norm_added_k,
head_dim=self.head_dim,
cos_sin_cache=txt_cache,
is_neox=False,
allow_inplace=True,
)
elif img_cache is not None and txt_cache is not None:
img_query, img_key = apply_flashinfer_rope_qk_inplace(
img_query, img_key, img_cache, is_neox=False
)
txt_query, txt_key = apply_flashinfer_rope_qk_inplace(
txt_query, txt_key, txt_cache, is_neox=False
)
# Joint order [text, image]; join_seqs relocates any SP text tail-pad
# behind the image (see sp_shard.join_seqs for why).
seg_qkv = None
@@ -923,7 +1015,9 @@ class QwenImageCrossAttention(nn.Module):
img_value,
sp_txt_pad,
)
if seg_qkv is not None:
if joint_qkv is not None:
joint_query, joint_key, joint_value = joint_qkv
elif seg_qkv is not None:
joint_query, joint_key, joint_value = seg_qkv
else:
joint_query = join_seqs(txt_query, img_query, sp_txt_pad)
@@ -1807,8 +1901,21 @@ class QwenImageTransformer2DModel(CachableDiT, LayerwiseOffloadableModuleMixin):
_repeated_blocks = ["QwenImageTransformerBlock"]
param_names_mapping = QwenImageDitConfig().arch_config.param_names_mapping
packed_modules_mapping = {
"to_qkv": ["to_q", "to_k", "to_v"],
"to_added_qkv": ["add_q_proj", "add_k_proj", "add_v_proj"],
}
_fsdp_shard_conditions = [is_transformer_block]
@classmethod
def get_param_names_mapping_for_quant_config(
cls, quant_config: Optional[QuantizationConfig]
) -> dict:
mapping = dict(cls.param_names_mapping)
if _modelopt_quant_name(quant_config) == "modelopt_fp8":
mapping.update(_MODEL_OPT_FP8_QKV_PARAM_NAMES_MAPPING)
return mapping
@classmethod
def get_nunchaku_quant_rules(cls) -> dict[str, list[str]]:
return {
@@ -1839,6 +1946,12 @@ class QwenImageTransformer2DModel(CachableDiT, LayerwiseOffloadableModuleMixin):
quant_config: Optional[QuantizationConfig] = None,
):
super().__init__(config=config, hf_config=hf_config)
# Only ModelOpt FP8 constructs packed QKV modules for checkpoints with
# Diffusers-style split Q/K/V names. Keep the mapping instance-local so
# eager and NVFP4 checkpoints still target their split projections.
self.param_names_mapping = self.get_param_names_mapping_for_quant_config(
quant_config
)
arch = self.config
patch_size = arch.patch_size
in_channels = arch.in_channels
@@ -4,6 +4,7 @@ layerwise offload: requantize_with_max_scale() runs scaled_fp8_quant(), a
CUDA-only kernel, so a CPU-resident postprocess must never come back."""
import unittest
from unittest.mock import patch
import torch
from torch import nn
@@ -82,9 +83,6 @@ class TestModelOptFp8LayerwiseOffloadLoad(unittest.TestCase):
ensure_distributed_env_defaults()
maybe_init_distributed_environment_and_model_parallel(tp_size=1, sp_size=1)
state_dict, weight_ref = _make_serialized_fp8_checkpoint()
expected_max_scale = state_dict["qkv.weight_scale"].max()
# The plan a layerwise-offload component gets when
# _needs_device_weight_postprocess() returns True: load and postprocess
# on GPU, then defer the CPU placement.
@@ -95,50 +93,79 @@ class TestModelOptFp8LayerwiseOffloadLoad(unittest.TestCase):
)
self.assertTrue(load_plan.defer_cpu_placement)
model = fsdp_load.maybe_load_fsdp_model(
model_cls=_FusedFp8Model,
init_params={
"quant_config": ModelOptFp8Config(is_checkpoint_fp8_serialized=True)
},
weight_dir_list=[],
device=torch.device("cuda"),
hsdp_replicate_dim=1,
hsdp_shard_dim=1,
param_dtype=torch.bfloat16,
reduce_dtype=torch.float32,
component_starts_on_cpu=True,
weight_load_plan=load_plan,
weights_iterator=iter(state_dict.items()),
)
for cutlass_supported in (False, True):
with self.subTest(cutlass_supported=cutlass_supported):
state_dict, weight_ref = _make_serialized_fp8_checkpoint()
checkpoint_weight = state_dict["qkv.weight"].clone()
checkpoint_scales = state_dict["qkv.weight_scale"].clone()
expected_max_scale = checkpoint_scales.max()
# Postprocess ran: the weight was requantized to the shared max scale
# and rebound transposed.
weight = model.qkv.weight
self.assertEqual(weight.dtype, torch.float8_e4m3fn)
self.assertEqual(tuple(weight.shape), (_IN_FEATURES, 2 * _SHARD_OUT))
weight_scale = model.qkv.weight_scale
torch.testing.assert_close(
weight_scale.flatten(),
expected_max_scale.expand(weight_scale.numel()),
check_device=False,
)
torch.testing.assert_close(
model.qkv.input_scale.flatten().max(), torch.tensor(0.5), check_device=False
)
with patch(
"sglang.multimodal_gen.runtime.layers.quantization."
"modelopt_quant.cutlass_fp8_supported",
return_value=cutlass_supported,
):
model = fsdp_load.maybe_load_fsdp_model(
model_cls=_FusedFp8Model,
init_params={
"quant_config": ModelOptFp8Config(
is_checkpoint_fp8_serialized=True
)
},
weight_dir_list=[],
device=torch.device("cuda"),
hsdp_replicate_dim=1,
hsdp_shard_dim=1,
param_dtype=torch.bfloat16,
reduce_dtype=torch.float32,
component_starts_on_cpu=True,
weight_load_plan=load_plan,
weights_iterator=iter(state_dict.items()),
)
# The round trip through both quantizations stays close to the source.
# Loose on purpose: this guards against garbage (wrong scale, wrong
# shard order), not fp8 precision.
dequant = weight.t().float().cpu() * expected_max_scale
torch.testing.assert_close(
dequant, weight_ref, rtol=0.5, atol=float(expected_max_scale) * 8
)
# Both paths rebind the runtime weight transposed. CUTLASS can
# consume a channelwise scale, so it preserves the checkpoint's
# FP8 shards; the fallback requantizes them to one max scale.
weight = model.qkv.weight
self.assertEqual(weight.dtype, torch.float8_e4m3fn)
self.assertEqual(tuple(weight.shape), (_IN_FEATURES, 2 * _SHARD_OUT))
weight_scale = model.qkv.weight_scale.flatten()
if cutlass_supported:
expected_scales = torch.repeat_interleave(
checkpoint_scales, _SHARD_OUT
)
self.assertTrue(torch.equal(weight.t(), checkpoint_weight))
else:
expected_scales = expected_max_scale.expand(weight_scale.numel())
torch.testing.assert_close(
weight_scale,
expected_scales,
check_device=False,
)
torch.testing.assert_close(
model.qkv.input_scale.flatten().max(),
torch.tensor(0.5),
check_device=False,
)
# Layerwise offload contract: the component lands on CPU afterwards,
# with the non-checkpoint buffer rebuilt.
self.assertEqual(weight.device.type, "cpu")
self.assertFalse(model.inv_freq.is_meta)
self.assertEqual(model.inv_freq.device.type, "cpu")
# The round trip stays close to the source. Loose on purpose:
# this guards against garbage (wrong scale or shard order), not
# FP8 precision.
dequant = weight.t().float().cpu() * weight_scale.float().cpu().view(
-1, 1
)
torch.testing.assert_close(
dequant,
weight_ref,
rtol=0.5,
atol=float(expected_max_scale) * 8,
)
# Layerwise offload contract: the component lands on CPU
# afterwards, with the non-checkpoint buffer rebuilt.
self.assertEqual(weight.device.type, "cpu")
self.assertFalse(model.inv_freq.is_meta)
self.assertEqual(model.inv_freq.device.type, "cpu")
if __name__ == "__main__":
@@ -79,6 +79,7 @@ from sglang.multimodal_gen.runtime.layers.quantization.modelopt_quant import (
ModelOptFp4Config,
ModelOptFp4LinearMethod,
ModelOptFp8Config,
ModelOptFp8LinearMethod,
_prepare_nvfp4_weight_bytes,
)
from sglang.multimodal_gen.runtime.layers.quantization.mxfp8 import MXFP8Config
@@ -103,9 +104,16 @@ from sglang.multimodal_gen.runtime.loader.transformer_load_utils import (
resolve_transformer_checkpoint_files,
resolve_transformer_quant_load_spec,
)
from sglang.multimodal_gen.runtime.loader.utils import (
get_param_names_mapping,
hf_to_custom_state_dict,
)
from sglang.multimodal_gen.runtime.loader.weight_load_plan import WeightLoadPlan
from sglang.multimodal_gen.runtime.models.dits.flux import FluxSingleTransformerBlock
from sglang.multimodal_gen.runtime.models.dits.minimax_h3 import MiniMaxH3DiTModel
from sglang.multimodal_gen.runtime.models.dits.qwen_image import (
QwenImageTransformer2DModel,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
from sglang.multimodal_gen.runtime.platforms.interface import DeviceCapability
from sglang.multimodal_gen.runtime.utils.quantization_utils import (
@@ -149,6 +157,169 @@ def _make_quant_config(name: str, **attrs):
class TestTransformerQuantHelpers(unittest.TestCase):
def test_modelopt_fp8_packed_cutlass_preserves_checkpoint_shard_scales(self):
method = ModelOptFp8LinearMethod(
ModelOptFp8Config(is_checkpoint_fp8_serialized=True)
)
method.cutlass_fp8_supported = True
layer = torch.nn.Module()
layer.logical_widths = [2, 2, 2]
weight = (
torch.arange(24, dtype=torch.float32).reshape(6, 4).to(torch.float8_e4m3fn)
)
layer.register_parameter(
"weight", torch.nn.Parameter(weight.clone(), requires_grad=False)
)
layer.register_parameter(
"weight_scale",
torch.nn.Parameter(
torch.tensor([0.1, 0.2, 0.3], dtype=torch.float32),
requires_grad=False,
),
)
layer.register_parameter(
"input_scale",
torch.nn.Parameter(torch.ones(3, dtype=torch.float32), requires_grad=False),
)
method.process_weights_after_loading(layer)
torch.testing.assert_close(layer.weight, weight.t(), rtol=0, atol=0)
torch.testing.assert_close(
layer.weight_scale,
torch.tensor([[0.1], [0.1], [0.2], [0.2], [0.3], [0.3]]),
)
torch.testing.assert_close(layer.input_scale, torch.tensor(1.0))
def test_modelopt_fp8_packed_cutlass_requantizes_incomplete_shard_scales(self):
method = ModelOptFp8LinearMethod(
ModelOptFp8Config(is_checkpoint_fp8_serialized=True)
)
method.cutlass_fp8_supported = True
layer = torch.nn.Module()
layer.logical_widths = [2, 2, 2]
weight = (
torch.arange(24, dtype=torch.float32).reshape(6, 4).to(torch.float8_e4m3fn)
)
layer.register_parameter(
"weight", torch.nn.Parameter(weight.clone(), requires_grad=False)
)
layer.register_parameter(
"weight_scale",
torch.nn.Parameter(
torch.tensor(
[0.1, torch.finfo(torch.float32).min, 0.3],
dtype=torch.float32,
),
requires_grad=False,
),
)
layer.register_parameter(
"input_scale",
torch.nn.Parameter(torch.ones(3, dtype=torch.float32), requires_grad=False),
)
with patch(
"sglang.multimodal_gen.runtime.layers.quantization.modelopt_quant."
"requantize_with_max_scale",
return_value=(torch.tensor(0.3), weight.clone()),
) as requantize:
method.process_weights_after_loading(layer)
requantize.assert_called_once()
torch.testing.assert_close(layer.weight, weight.t(), rtol=0, atol=0)
torch.testing.assert_close(
layer.weight_scale, torch.full((6, 1), 0.3), rtol=0, atol=0
)
def test_modelopt_packed_layer_requires_consistent_shard_precision(self):
prefix = "blocks.0.attn.to_qkv"
mapping = {"to_qkv": ["to_q", "to_k", "to_v"]}
layer = LinearBase(input_size=16, output_size=48)
quantized = ModelOptFp8Config(
is_checkpoint_fp8_serialized=True,
packed_modules_mapping=mapping,
)
self.assertIsInstance(
quantized.get_quant_method(layer, prefix), ModelOptFp8LinearMethod
)
excluded = ModelOptFp8Config(
is_checkpoint_fp8_serialized=True,
exclude_modules=[
"blocks.0.attn.to_q",
"blocks.0.attn.to_k",
"blocks.0.attn.to_v",
],
packed_modules_mapping=mapping,
)
self.assertIsInstance(
excluded.get_quant_method(layer, prefix), UnquantizedLinearMethod
)
partial = ModelOptFp8Config(
is_checkpoint_fp8_serialized=True,
exclude_modules=["blocks.0.attn.to_q"],
packed_modules_mapping=mapping,
)
with self.assertRaisesRegex(ValueError, "some but not all shards"):
partial.get_quant_method(layer, prefix)
def test_qwen_modelopt_fp8_qkv_checkpoint_tensors_are_merged(self):
mapping = get_param_names_mapping(
QwenImageTransformer2DModel.get_param_names_mapping_for_quant_config(
ModelOptFp8Config(is_checkpoint_fp8_serialized=True)
)
)
prefix = "transformer_blocks.0.attn"
source = {}
for shard_id, shard_name in enumerate(("q", "k", "v")):
source[f"{prefix}.to_{shard_name}.weight"] = torch.full(
(2, 3), shard_id + 1, dtype=torch.float8_e4m3fn
)
source[f"{prefix}.to_{shard_name}.bias"] = torch.full(
(2,), shard_id + 1, dtype=torch.bfloat16
)
source[f"{prefix}.to_{shard_name}.weight_scale"] = torch.tensor(
[0.1 * (shard_id + 1)], dtype=torch.float32
)
source[f"{prefix}.to_{shard_name}.input_scale"] = torch.tensor(
[0.2 * (shard_id + 1)], dtype=torch.float32
)
merged, _ = hf_to_custom_state_dict(source, mapping)
self.assertEqual(merged[f"{prefix}.to_qkv.weight"].shape, (6, 3))
self.assertEqual(merged[f"{prefix}.to_qkv.bias"].shape, (6,))
torch.testing.assert_close(
merged[f"{prefix}.to_qkv.weight_scale"],
torch.tensor([0.1, 0.2, 0.3], dtype=torch.float32),
)
torch.testing.assert_close(
merged[f"{prefix}.to_qkv.input_scale"],
torch.tensor([0.2, 0.4, 0.6], dtype=torch.float32),
)
self.assertEqual(
QwenImageTransformer2DModel.packed_modules_mapping["to_qkv"],
["to_q", "to_k", "to_v"],
)
def test_qwen_non_fp8_qkv_checkpoint_tensors_are_not_merged(self):
prefix = "transformer_blocks.0.attn"
source_name = f"{prefix}.to_q.weight"
for quant_config in (None, ModelOptFp4Config()):
with self.subTest(quant_config=quant_config):
mapping = get_param_names_mapping(
QwenImageTransformer2DModel.get_param_names_mapping_for_quant_config(
quant_config
)
)
target_name, merge_index, total_shards = mapping(source_name)
self.assertEqual(target_name, source_name)
self.assertIsNone(merge_index)
self.assertIsNone(total_shards)
@patch(
"sglang.multimodal_gen.runtime.loader.transformer_load_utils.build_nvfp4_config_from_safetensors_list",
return_value=None,
@@ -1021,8 +1192,8 @@ class TestTransformerQuantHelpers(unittest.TestCase):
warning.assert_called_once()
def test_modelopt_fp8_always_needs_device_weight_postprocess(self):
# Even a serialized checkpoint requantizes fused shards through
# scaled_fp8_quant(), which cannot process CPU tensors.
# Serialized checkpoints still transpose weights and may requantize
# packed shards through scaled_fp8_quant() on the runtime device.
self.assertTrue(
_needs_device_weight_postprocess(
ModelOptFp8Config(is_checkpoint_fp8_serialized=True)