[Qwen4Exp] FP8 E4M3 KV reader for QSA with a static scale sidecar
FP8 E4M3 KV read path for the Qwen4Exp sparse attention (QSA), indexer and pre-indexer kernels on sm_86, dequantised with a static per-layer scale loaded from a JSON sidecar, validated against the model's QSA layer set and applied per pipeline stage. Includes the offline absmax collector that produces the sidecar and a CUDA-graph-safe clipping counter. Co-Authored-By: Hinata <hinata@idihore.id> Signed-off-by: MOVZX <movzx@yahoo.com>
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
from collections.abc import Iterable
|
||||
from itertools import islice
|
||||
|
||||
import os
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from transformers import Qwen4ExpConfig, Qwen4ExpTextConfig
|
||||
@@ -80,7 +82,10 @@ from .hyperconnection import GatedResidual, HyperConnectionConfig
|
||||
from .low_latency_gemm import enable_qwen4_exp_low_latency_gemm
|
||||
from .ops.cute_dsl.hc_down_silu import request_hc_down_silu_warmup
|
||||
from .ple_layer import Qwen4ExpPLELayer
|
||||
from .qsa import Qwen4ExpQSAAttention
|
||||
from .qsa import Qwen4ExpQSAAttention, load_qsa_static_kv_scales
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Transformers v5.18 renamed `qwen_sparse_attention` to `indexed_attention`
|
||||
# TODO: Delete qwen_... once Transformers 5.18.0 is the minimum required version.
|
||||
@@ -161,6 +166,104 @@ _EXTRA_WEIGHTS_MAPPER = WeightsMapper(
|
||||
)
|
||||
|
||||
|
||||
_QSA_KV_SCALES_ENV = "VLLM_QSA_KV_SCALES"
|
||||
|
||||
|
||||
def _maybe_load_qsa_static_kv_scales(model: nn.Module) -> None:
|
||||
"""Apply a static QSA K/V scale sidecar when VLLM_QSA_KV_SCALES names one.
|
||||
|
||||
Placed where the loader's own docstring requires: after checkpoint weights
|
||||
are loaded, before profiling, warmup or cudagraph capture. Unset env var ->
|
||||
no-op (the attention layer logs the default 1.0 per layer so the
|
||||
uncalibrated state stays visible). Set but unusable -> the exception
|
||||
propagates: a calibration that was requested and silently not applied is
|
||||
worse than one never requested.
|
||||
"""
|
||||
path = os.environ.get(_QSA_KV_SCALES_ENV, "").strip()
|
||||
if not path:
|
||||
return
|
||||
# The PLE offload worker builds the whole target on the meta device and loads
|
||||
# only PLE weights through load_weights; a fill_ on its meta scale buffers
|
||||
# would log success without calibrating any GPU worker.
|
||||
local_qsa: list[Qwen4ExpQSAAttention] = []
|
||||
inner: Qwen4ExpModel | None = None
|
||||
for module in model.modules():
|
||||
if isinstance(module, Qwen4ExpModel) and inner is None:
|
||||
inner = module
|
||||
if isinstance(module, Qwen4ExpQSAAttention):
|
||||
local_qsa.append(module)
|
||||
for layer in local_qsa:
|
||||
k_scale = getattr(layer, "_k_scale", None)
|
||||
if isinstance(k_scale, torch.Tensor) and k_scale.device.type == "meta":
|
||||
logger.info(
|
||||
"Qwen4Exp QSA: model is on meta (PLE offload worker); "
|
||||
"static K/V scales are applied by the GPU workers only"
|
||||
)
|
||||
return
|
||||
if inner is None:
|
||||
raise ValueError("Qwen4Exp QSA: no Qwen4ExpModel found for the sidecar")
|
||||
if not inner._qsa_layer_ids:
|
||||
# A requested calibration for a model without QSA layers is a
|
||||
# configuration error, never a silent no-op.
|
||||
raise ValueError(
|
||||
f"Qwen4Exp QSA: {_QSA_KV_SCALES_ENV} is set but the model has no "
|
||||
"QSA (indexer) attention layers"
|
||||
)
|
||||
# Whole-model QSA layer set (all pipeline stages) = "<stack>.<idx>.self_attn.attn"
|
||||
# for every configured QSA index. The stack prefix is read from a LOCAL QSA
|
||||
# layer's registered layer_name whenever one exists: this hook also runs
|
||||
# inside the nested Qwen4ExpForCausalLM.load_weights, where a module path
|
||||
# relative to that submodule ("model.layers") would not match the names
|
||||
# registered under the outer prefix ("language_model.model.layers") - the
|
||||
# earlier boot failure. Only a stage with no QSA layer
|
||||
# falls back to the module path (then no local name can disagree).
|
||||
if local_qsa:
|
||||
stack, sep, tail = local_qsa[0].layer_name.rpartition(".layers.")
|
||||
if not sep or not tail.split(".")[0].isdigit():
|
||||
raise ValueError(
|
||||
f"Qwen4Exp QSA: unexpected layer name {local_qsa[0].layer_name!r}"
|
||||
)
|
||||
layers_path = f"{stack}.layers"
|
||||
else:
|
||||
layers_path = next(
|
||||
(name for name, module in model.named_modules() if module is inner.layers),
|
||||
None,
|
||||
)
|
||||
if layers_path is None:
|
||||
raise ValueError("Qwen4Exp QSA: cannot locate the decoder layer stack")
|
||||
expected = {
|
||||
f"{layers_path}.{idx}.self_attn.attn" for idx in inner._qsa_layer_ids
|
||||
}
|
||||
if not local_qsa:
|
||||
# A pipeline stage without a full-attention layer (only for a very
|
||||
# uneven split): validate the complete sidecar here too, apply nothing.
|
||||
logger.warning(
|
||||
"Qwen4Exp QSA: this pipeline stage holds no QSA layer "
|
||||
"(pipeline layers %d-%d); sidecar %s validated, nothing to apply",
|
||||
inner.start_layer,
|
||||
inner.end_layer,
|
||||
path,
|
||||
)
|
||||
# strict: every QSA layer of the whole model must be in the sidecar. An empty
|
||||
# or partial file silently leaving layers at scale 1.0 is exactly the failure
|
||||
# this call site exists to prevent.
|
||||
applied = load_qsa_static_kv_scales(
|
||||
model, path, strict=True, expected_layer_names=expected
|
||||
)
|
||||
shown = ", ".join(sorted(applied)[:4])
|
||||
logger.info(
|
||||
"Qwen4Exp QSA: applied static K/V scales from %s to %d of %d layer(s) "
|
||||
"(pipeline layers %d-%d): %s%s",
|
||||
path,
|
||||
len(applied),
|
||||
len(expected),
|
||||
inner.start_layer,
|
||||
inner.end_layer,
|
||||
shown,
|
||||
" ..." if len(applied) > 4 else "",
|
||||
)
|
||||
|
||||
|
||||
class Qwen4ExpSparseMoeBlock(Qwen3NextSparseMoeBlock):
|
||||
"""Qwen3Next MoE with Qwen4Exp HC validation."""
|
||||
|
||||
@@ -869,7 +972,9 @@ class Qwen4ExpForCausalLM(
|
||||
self,
|
||||
ignore_unexpected_suffixes=_QWEN4_EXP_IGNORED_MISSING_SUFFIXES.copy(),
|
||||
)
|
||||
return loader.load_weights(weights, mapper=mapper)
|
||||
loaded = loader.load_weights(weights, mapper=mapper)
|
||||
_maybe_load_qsa_static_kv_scales(self)
|
||||
return loaded
|
||||
|
||||
|
||||
class Qwen4ExpProcessingInfo(Qwen3VLProcessingInfo):
|
||||
@@ -1073,7 +1178,9 @@ class Qwen4ExpForConditionalGeneration(
|
||||
self,
|
||||
ignore_unexpected_suffixes=_QWEN4_EXP_IGNORED_MISSING_SUFFIXES.copy(),
|
||||
)
|
||||
return loader.load_weights(weights, mapper=mapper)
|
||||
loaded = loader.load_weights(weights, mapper=mapper)
|
||||
_maybe_load_qsa_static_kv_scales(self)
|
||||
return loaded
|
||||
|
||||
@classmethod
|
||||
def get_mamba_state_dtype_from_config(
|
||||
|
||||
@@ -26,13 +26,32 @@ def _is_sm120() -> bool:
|
||||
def _is_sm90() -> bool:
|
||||
"""True on sm_90 (H100/H200/H20): selects the sm_90 tuning table."""
|
||||
return current_platform.get_device_capability() == (9, 0)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _decode_e4m3_to_bf16(b):
|
||||
# Shifting the 7 magnitude bits left by 7 lands the E4M3 exponent in the
|
||||
# FP16 exponent field and the 3 mantissa bits at the top of the FP16
|
||||
# mantissa field, giving the right number under the wrong exponent bias
|
||||
# (E4M3 7 vs FP16 15). That is a constant factor of 2^8. E4M3 subnormals
|
||||
# land on FP16 subnormals and the same factor renormalizes them, which is
|
||||
# only true because sm_86 does not flush FP16 subnormals -- verified on
|
||||
# device, not assumed.
|
||||
#
|
||||
# Bit-exact against torch on all 254 finite codepoints: every E4M3 value
|
||||
# fits in BF16's 7 mantissa bits, and x256 is exact in FP16.
|
||||
#
|
||||
# Deviation: 0x7F/0xFF are NaN in e4m3fn but decode to +-480 here. The CUDA
|
||||
# writer clamps to +-448 and never emits them, so this is unreachable from
|
||||
# a cache written by reshape_and_cache_flash.
|
||||
b = b.to(tl.uint16)
|
||||
bits = ((b & 0x80) << 8) | ((b & 0x7F) << 7)
|
||||
return (bits.to(tl.uint16).to(tl.float16, bitcast=True) * 256.0).to(tl.bfloat16)
|
||||
@triton.jit(do_not_specialize=["num_rows", "num_requests"])
|
||||
def _qsa_sparse_paged_gqa_splitk_kernel(
|
||||
q_ptr,
|
||||
k_cache_ptr,
|
||||
v_cache_ptr,
|
||||
k_scale_ptr,
|
||||
v_scale_ptr,
|
||||
indices_ptr,
|
||||
block_table_ptr,
|
||||
token_to_req_ptr,
|
||||
@@ -67,6 +86,7 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
|
||||
NUM_QUERY_HEADS: tl.constexpr,
|
||||
NUM_SPLITS: tl.constexpr,
|
||||
NUM_TILES: tl.constexpr,
|
||||
KV_DTYPE: tl.constexpr,
|
||||
BLOCK_M: tl.constexpr,
|
||||
BLOCK_N: tl.constexpr,
|
||||
IS_FP8: tl.constexpr,
|
||||
@@ -104,6 +124,10 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
|
||||
# here for the exp2-based online softmax.
|
||||
score_scale = softmax_scale * 1.4426950408889634
|
||||
|
||||
if KV_DTYPE != 0:
|
||||
k_scale_value = tl.load(k_scale_ptr)
|
||||
v_scale_value = tl.load(v_scale_ptr)
|
||||
|
||||
tile_end = tl.minimum(NUM_TILES, tl.cdiv(tl.minimum(valid_count, TOPK), BLOCK_N))
|
||||
|
||||
for tile in range(split_id, tile_end, NUM_SPLITS):
|
||||
@@ -132,44 +156,87 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
|
||||
valid &= (physical_page >= 0) & (physical_page < num_cache_blocks)
|
||||
# physical_page * block stride can overflow int32 for large caches.
|
||||
safe_page = tl.maximum(physical_page, 0).to(tl.int64)
|
||||
keys = tl.load(
|
||||
k_cache_ptr
|
||||
+ safe_page[None, :] * stride_k_block
|
||||
+ page_offset[None, :] * stride_k_token
|
||||
+ kv_head * stride_k_head
|
||||
+ dim_offsets[:, None],
|
||||
mask=valid[None, :],
|
||||
other=0.0,
|
||||
)
|
||||
values = tl.load(
|
||||
v_cache_ptr
|
||||
+ safe_page[:, None] * stride_v_block
|
||||
+ page_offset[:, None] * stride_v_token
|
||||
+ kv_head * stride_v_head
|
||||
+ dim_offsets[None, :],
|
||||
mask=valid[:, None],
|
||||
other=0.0,
|
||||
)
|
||||
if IS_FP8:
|
||||
# e4m3 -> Q dtype is exact; keep the QK dot in Q's dtype (fp8 QK
|
||||
# measured slower here and less accurate).
|
||||
keys = keys.to(query.dtype)
|
||||
if KV_DTYPE == 0:
|
||||
keys = tl.load(
|
||||
k_cache_ptr
|
||||
+ safe_page[None, :] * stride_k_block
|
||||
+ page_offset[None, :] * stride_k_token
|
||||
+ kv_head * stride_k_head
|
||||
+ dim_offsets[:, None],
|
||||
mask=valid[None, :],
|
||||
other=0.0,
|
||||
)
|
||||
values = tl.load(
|
||||
v_cache_ptr
|
||||
+ safe_page[:, None] * stride_v_block
|
||||
+ page_offset[:, None] * stride_v_token
|
||||
+ kv_head * stride_v_head
|
||||
+ dim_offsets[None, :],
|
||||
mask=valid[:, None],
|
||||
other=0.0,
|
||||
)
|
||||
elif KV_DTYPE == 1:
|
||||
key_bytes = tl.load(
|
||||
k_cache_ptr
|
||||
+ safe_page[None, :] * stride_k_block
|
||||
+ page_offset[None, :] * stride_k_token
|
||||
+ kv_head * stride_k_head
|
||||
+ dim_offsets[:, None],
|
||||
mask=valid[None, :],
|
||||
other=0,
|
||||
)
|
||||
# UNSCALED: k_scale is folded into the score scaling below.
|
||||
keys = _decode_e4m3_to_bf16(key_bytes)
|
||||
else:
|
||||
keys = tl.load(
|
||||
k_cache_ptr
|
||||
+ safe_page[None, :] * stride_k_block
|
||||
+ page_offset[None, :] * stride_k_token
|
||||
+ kv_head * stride_k_head
|
||||
+ dim_offsets[:, None],
|
||||
mask=valid[None, :],
|
||||
other=0.0,
|
||||
).to(tl.bfloat16)
|
||||
scores = tl.dot(query, keys)
|
||||
# Scaling scores avoids re-quantizing a scaled query to BF16; for fp8
|
||||
# caches the K dequant scale is already folded into softmax_scale on the
|
||||
# host.
|
||||
scores *= score_scale
|
||||
# Scaling scores avoids re-quantizing a scaled query to BF16.
|
||||
if KV_DTYPE == 0:
|
||||
scores *= softmax_scale_log2
|
||||
else:
|
||||
# k_scale is a scalar, so scaling each SCORE is equivalent to
|
||||
# scaling every key element -- but costs one multiply per score
|
||||
# instead of one per element, and skips the fp32 round trip and
|
||||
# the BF16 re-quantization of the scaled key (which for a
|
||||
# non-power-of-two scale also lost mantissa bits). Strictly fewer
|
||||
# roundings than the per-element form it replaces.
|
||||
scores *= softmax_scale_log2 * k_scale_value
|
||||
scores = tl.where(valid[None, :], scores, -1.0e20)
|
||||
next_max = tl.maximum(max_value, tl.max(scores, axis=1))
|
||||
alpha = tl.math.exp2(max_value - next_max)
|
||||
probabilities = tl.where(
|
||||
valid[None, :], tl.math.exp2(scores - next_max[:, None]), 0.0
|
||||
)
|
||||
if IS_FP8:
|
||||
# Dequant V to fp16 (not bf16) for the PV dot: P <= 1 (online
|
||||
# softmax) so fp16 has the range, its wider mantissa is more
|
||||
# accurate, and the fp8->fp16 upcast with an fp16 PV dot is faster.
|
||||
values = values.to(tl.float16)
|
||||
if KV_DTYPE == 1:
|
||||
value_bytes = tl.load(
|
||||
v_cache_ptr
|
||||
+ safe_page[:, None] * stride_v_block
|
||||
+ page_offset[:, None] * stride_v_token
|
||||
+ kv_head * stride_v_head
|
||||
+ dim_offsets[None, :],
|
||||
mask=valid[:, None],
|
||||
other=0,
|
||||
)
|
||||
# UNSCALED: v_scale is folded into the epilogue below.
|
||||
values = _decode_e4m3_to_bf16(value_bytes)
|
||||
elif KV_DTYPE == 2:
|
||||
values = tl.load(
|
||||
v_cache_ptr
|
||||
+ safe_page[:, None] * stride_v_block
|
||||
+ page_offset[:, None] * stride_v_token
|
||||
+ kv_head * stride_v_head
|
||||
+ dim_offsets[None, :],
|
||||
mask=valid[:, None],
|
||||
other=0.0,
|
||||
).to(tl.bfloat16)
|
||||
accumulator = tl.dot(
|
||||
probabilities.to(values.dtype),
|
||||
values,
|
||||
@@ -189,6 +256,13 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
|
||||
accumulator * inv_normalizer[:, None],
|
||||
0.0,
|
||||
)
|
||||
if KV_DTYPE != 0:
|
||||
# v_scale folded out of the per-element V decode: the attention output
|
||||
# is linear in V, so one multiply per output element replaces one per
|
||||
# cache element per tile. Safe under split-K too -- every partial is
|
||||
# scaled by the same constant before the LSE merge, which is itself
|
||||
# linear in the partial outputs.
|
||||
normalized_output *= v_scale_value
|
||||
output_mask = head_offsets[:, None] < GROUP_SIZE
|
||||
if NUM_SPLITS == 1:
|
||||
# Preserve the unfused path's BF16 attention-output rounding before
|
||||
@@ -600,17 +674,18 @@ def qsa_sparse_paged_attention(
|
||||
token_to_req: torch.Tensor,
|
||||
use_prefill_config: bool,
|
||||
out: torch.Tensor | None = None,
|
||||
k_scale: float | None = None,
|
||||
v_scale: float | None = None,
|
||||
k_scale: torch.Tensor | None = None,
|
||||
v_scale: torch.Tensor | None = None,
|
||||
_force_num_warps: int | None = None,
|
||||
*,
|
||||
output_gate: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Run sparse GQA directly over paged BF16 or FP8-e4m3 K/V caches.
|
||||
"""Run sparse GQA over paged BF16 or FP8 (E4M3 via static scalar scales) K/V caches.
|
||||
|
||||
With fp8 caches, k_scale/v_scale are the layer's per-tensor dequant scales
|
||||
as host floats (e.g. layer._k_scale_float/_v_scale_float): k_scale is
|
||||
pre-multiplied into the softmax scale and v_scale becomes the kernel's
|
||||
output scale, so the kernel needs no device scale buffers.
|
||||
``_force_num_warps`` overrides the tuned launch profile for the warp-count
|
||||
equivalence test only; production callers leave it None. Hardware policy
|
||||
(E4M3 only on CUDA sm_86) is enforced once at Qwen4ExpQSAAttention /
|
||||
Qwen4ExpQSAFlashAttentionImpl construction, not per call.
|
||||
|
||||
logical_indices is the PACKED selection buffer: [rows, selection_width + 1]
|
||||
with the trailing column holding each row's valid-entry count (written by
|
||||
@@ -634,19 +709,26 @@ def qsa_sparse_paged_attention(
|
||||
raise ValueError("QSA sparse attention requires valid grouped-query heads")
|
||||
head_dim = q.shape[2]
|
||||
assert head_dim >= 16 and (head_dim & (head_dim - 1)) == 0
|
||||
assert q.dtype == torch.bfloat16
|
||||
assert k_cache.dtype == v_cache.dtype
|
||||
is_fp8 = k_cache.dtype == torch.float8_e4m3fn
|
||||
if is_fp8:
|
||||
assert k_scale is not None and v_scale is not None
|
||||
# Host pre-multiply: fold the K dequant scale into the attention scale
|
||||
# and pass V's dequant scale as the kernel's output scale.
|
||||
softmax_scale = (head_dim**-0.5) * float(k_scale)
|
||||
output_scale = float(v_scale)
|
||||
if q.dtype != torch.bfloat16:
|
||||
raise ValueError("QSA sparse attention requires BF16 queries")
|
||||
if k_cache.dtype != v_cache.dtype:
|
||||
raise ValueError("QSA sparse attention requires matching K/V dtypes")
|
||||
fp8_dtype = current_platform.fp8_dtype()
|
||||
if k_cache.dtype == torch.bfloat16:
|
||||
kv_dtype = 0
|
||||
elif k_cache.dtype in (torch.uint8, fp8_dtype):
|
||||
kv_dtype = 1
|
||||
# Present platform E4M3 storage as bytes so Triton never materializes
|
||||
# the unsupported fp8e4nv type on SM86.
|
||||
if k_cache.dtype == fp8_dtype:
|
||||
k_cache = k_cache.view(torch.uint8)
|
||||
v_cache = v_cache.view(torch.uint8)
|
||||
elif k_cache.dtype == torch.float8_e5m2:
|
||||
kv_dtype = 2
|
||||
else:
|
||||
assert k_cache.dtype == torch.bfloat16
|
||||
softmax_scale = head_dim**-0.5
|
||||
output_scale = 1.0
|
||||
raise ValueError(
|
||||
"QSA supports only BF16, uint8/E4M3, and E5M2 K/V caches"
|
||||
)
|
||||
assert logical_indices.dtype == block_table.dtype == torch.int32
|
||||
assert token_to_req.dtype == torch.int32
|
||||
assert q.device == k_cache.device == v_cache.device
|
||||
@@ -667,12 +749,37 @@ def qsa_sparse_paged_attention(
|
||||
if not q.shape[0]:
|
||||
return out
|
||||
|
||||
if kv_dtype == 0:
|
||||
# Scale pointers are compile-time dead in the unchanged BF16 kernel.
|
||||
k_scale_ptr = q
|
||||
v_scale_ptr = q
|
||||
else:
|
||||
if k_scale is None:
|
||||
k_scale = torch.tensor(1.0, dtype=torch.float32, device=q.device)
|
||||
if v_scale is None:
|
||||
v_scale = torch.tensor(1.0, dtype=torch.float32, device=q.device)
|
||||
if (
|
||||
k_scale.numel() != 1
|
||||
or v_scale.numel() != 1
|
||||
or k_scale.dtype != torch.float32
|
||||
or v_scale.dtype != torch.float32
|
||||
or k_scale.device != q.device
|
||||
or v_scale.device != q.device
|
||||
):
|
||||
raise ValueError(
|
||||
"QSA K/V scales must be scalar float32 tensors on the query device"
|
||||
)
|
||||
k_scale_ptr = k_scale
|
||||
v_scale_ptr = v_scale
|
||||
|
||||
group_size = q.shape[1] // k_cache.shape[2]
|
||||
block_m = triton.next_power_of_2(group_size)
|
||||
selection_width = logical_indices.shape[1] - 1 # trailing column is the count
|
||||
block_n, partial_warps, num_tiles, num_splits = _select_config(
|
||||
q.shape[0], k_cache.shape[2], use_prefill_config, selection_width, is_fp8
|
||||
)
|
||||
if _force_num_warps is not None:
|
||||
partial_warps = _force_num_warps
|
||||
|
||||
# Split=1 writes output directly and compiles out all workspace accesses.
|
||||
if num_splits == 1:
|
||||
@@ -695,6 +802,8 @@ def qsa_sparse_paged_attention(
|
||||
q,
|
||||
k_cache,
|
||||
v_cache,
|
||||
k_scale_ptr,
|
||||
v_scale_ptr,
|
||||
logical_indices,
|
||||
block_table,
|
||||
token_to_req,
|
||||
@@ -729,6 +838,7 @@ def qsa_sparse_paged_attention(
|
||||
NUM_QUERY_HEADS=q.shape[1],
|
||||
NUM_SPLITS=num_splits,
|
||||
NUM_TILES=num_tiles,
|
||||
KV_DTYPE=kv_dtype,
|
||||
BLOCK_M=block_m,
|
||||
BLOCK_N=block_n,
|
||||
IS_FP8=is_fp8,
|
||||
@@ -792,13 +902,32 @@ def warmup_qsa_sparse_paged_attention(
|
||||
q_ptr = TritonWarmupTensor(
|
||||
torch.bfloat16, shape=(num_rows, num_query_heads, head_dim)
|
||||
)
|
||||
# Mirror qsa_sparse_paged_attention's dtype dispatch: E4M3 storage is launched
|
||||
# as a uint8 view with scalar float32 scale pointers.
|
||||
if key_cache.dtype == torch.bfloat16:
|
||||
warm_kv_dtype = 0
|
||||
warm_cache_dtype = key_cache.dtype
|
||||
elif key_cache.dtype in (torch.uint8, current_platform.fp8_dtype()):
|
||||
warm_kv_dtype = 1
|
||||
warm_cache_dtype = torch.uint8
|
||||
elif key_cache.dtype == torch.float8_e5m2:
|
||||
warm_kv_dtype = 2
|
||||
warm_cache_dtype = key_cache.dtype
|
||||
else:
|
||||
raise ValueError("QSA warmup: unsupported K/V cache dtype")
|
||||
if warm_kv_dtype == 0:
|
||||
k_scale_ptr = q_ptr
|
||||
v_scale_ptr = q_ptr
|
||||
else:
|
||||
k_scale_ptr = TritonWarmupTensor(torch.float32)
|
||||
v_scale_ptr = TritonWarmupTensor(torch.float32)
|
||||
k_cache_ptr = TritonWarmupTensor(
|
||||
cache_dtype,
|
||||
warm_cache_dtype,
|
||||
shape=tuple(key_cache.shape),
|
||||
strides=tuple(key_cache.stride()),
|
||||
)
|
||||
v_cache_ptr = TritonWarmupTensor(
|
||||
cache_dtype,
|
||||
warm_cache_dtype,
|
||||
shape=tuple(value_cache.shape),
|
||||
strides=tuple(value_cache.stride()),
|
||||
)
|
||||
@@ -838,6 +967,8 @@ def warmup_qsa_sparse_paged_attention(
|
||||
q_ptr,
|
||||
k_cache_ptr,
|
||||
v_cache_ptr,
|
||||
k_scale_ptr,
|
||||
v_scale_ptr,
|
||||
indices_ptr,
|
||||
block_table_ptr,
|
||||
token_to_req_ptr,
|
||||
@@ -872,6 +1003,7 @@ def warmup_qsa_sparse_paged_attention(
|
||||
NUM_QUERY_HEADS=num_query_heads,
|
||||
NUM_SPLITS=num_splits,
|
||||
NUM_TILES=num_tiles,
|
||||
KV_DTYPE=warm_kv_dtype,
|
||||
BLOCK_M=block_m,
|
||||
BLOCK_N=block_n,
|
||||
IS_FP8=is_fp8,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import os
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from transformers import Qwen4ExpTextConfig
|
||||
@@ -61,6 +63,886 @@ class Qwen4ExpPLEGroupedNorm(nn.Module):
|
||||
return (normalized * (1.0 + self.weight.float())).to(input_dtype)
|
||||
|
||||
|
||||
class Qwen4ExpPLEFp8EmbeddingMethod(QuantizeMethodBase):
|
||||
"""FP8 PLE embedding with one global checkpoint scale."""
|
||||
|
||||
def create_weights(
|
||||
self,
|
||||
layer: 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:
|
||||
del input_size, output_size, params_dtype
|
||||
weight_loader = extra_weight_attrs.get("weight_loader")
|
||||
weight = create_fp8_weight_parameter(
|
||||
sum(output_partition_sizes), input_size_per_partition, weight_loader
|
||||
)
|
||||
layer.register_parameter("weight", weight)
|
||||
|
||||
weight_scale = create_fp8_scale_parameter(
|
||||
PerTensorScaleParameter,
|
||||
output_partition_sizes,
|
||||
input_size_per_partition,
|
||||
None,
|
||||
weight_loader,
|
||||
scale_dtype=torch.float32,
|
||||
)
|
||||
layer.register_parameter("weight_scale", weight_scale)
|
||||
|
||||
def process_weights_after_loading(self, layer: nn.Module) -> None:
|
||||
"""Reject FP8 PLE checkpoints without a global scale."""
|
||||
sentinel = torch.finfo(torch.float32).min
|
||||
if torch.any(layer.weight_scale == sentinel):
|
||||
raise ValueError("FP8 PLE checkpoint is missing its global scale")
|
||||
|
||||
def apply(
|
||||
self,
|
||||
layer: nn.Module,
|
||||
x: torch.Tensor,
|
||||
bias: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
raise NotImplementedError("PLE FP8 weights only support embedding lookup")
|
||||
|
||||
def embedding(self, layer: nn.Module, input_: torch.Tensor) -> torch.Tensor:
|
||||
return F.embedding(input_, layer.weight)
|
||||
|
||||
|
||||
_NVFP4_BLOCK_SIZE = 16
|
||||
|
||||
|
||||
class Qwen4ExpPLENVFp4EmbeddingMethod(QuantizeMethodBase):
|
||||
"""NVFP4 PLE embedding kept packed at runtime.
|
||||
|
||||
The table stays as uint8 codes with FP8 block scales (16 values per
|
||||
block) and one FP32 global scale. A lookup only gathers the packed rows;
|
||||
the PLE layer dequantizes them after lookup.
|
||||
"""
|
||||
|
||||
def create_weights(
|
||||
self,
|
||||
layer: 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:
|
||||
del input_size, output_size, extra_weight_attrs
|
||||
if input_size_per_partition % _NVFP4_BLOCK_SIZE:
|
||||
raise ValueError(
|
||||
"NVFP4 PLE embedding requires the embedding dim to be a "
|
||||
f"multiple of {_NVFP4_BLOCK_SIZE}, got {input_size_per_partition}"
|
||||
)
|
||||
self.params_dtype = params_dtype
|
||||
self.head_dim = input_size_per_partition
|
||||
self.packed_row_width = (
|
||||
input_size_per_partition // 2
|
||||
+ input_size_per_partition // _NVFP4_BLOCK_SIZE
|
||||
)
|
||||
rows = sum(output_partition_sizes)
|
||||
weight = nn.Parameter(
|
||||
torch.empty(rows, input_size_per_partition // 2, dtype=torch.uint8),
|
||||
requires_grad=False,
|
||||
)
|
||||
set_weight_attrs(weight, {"input_dim": 1, "output_dim": 0})
|
||||
layer.register_parameter("weight", weight)
|
||||
|
||||
weight_scale = nn.Parameter(
|
||||
torch.empty(
|
||||
rows,
|
||||
input_size_per_partition // _NVFP4_BLOCK_SIZE,
|
||||
dtype=torch.float8_e4m3fn,
|
||||
),
|
||||
requires_grad=False,
|
||||
)
|
||||
set_weight_attrs(weight_scale, {"input_dim": 1, "output_dim": 0})
|
||||
layer.register_parameter("weight_scale", weight_scale)
|
||||
|
||||
weight_scale_2 = nn.Parameter(
|
||||
torch.empty((), dtype=torch.float32), requires_grad=False
|
||||
)
|
||||
layer.register_parameter("weight_scale_2", weight_scale_2)
|
||||
self._lut: torch.Tensor | None = None
|
||||
|
||||
def process_weights_after_loading(self, layer: nn.Module) -> None:
|
||||
# Build the lookup table before CUDA graph capture.
|
||||
self._lut = torch.tensor(
|
||||
_FP4_VALUES, dtype=torch.float32, device=layer.weight.device
|
||||
)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
layer: nn.Module,
|
||||
x: torch.Tensor,
|
||||
bias: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
raise NotImplementedError("PLE NVFP4 weights only support embedding lookup")
|
||||
|
||||
def embedding(self, layer: nn.Module, input_: torch.Tensor) -> torch.Tensor:
|
||||
"""Gather packed rows for deferred dequantization."""
|
||||
codes = F.embedding(input_, layer.weight)
|
||||
scales = F.embedding(input_, layer.weight_scale)
|
||||
return torch.cat((codes, scales.view(torch.uint8)), dim=-1)
|
||||
|
||||
def dequantize(
|
||||
self,
|
||||
packed_rows: torch.Tensor,
|
||||
scale_2: torch.Tensor,
|
||||
output_dtype: torch.dtype,
|
||||
) -> torch.Tensor:
|
||||
"""Dequantize rows containing NVFP4 codes and block scales."""
|
||||
lut = self._lut
|
||||
if lut is None or lut.device != packed_rows.device:
|
||||
lut = torch.tensor(
|
||||
_FP4_VALUES, dtype=torch.float32, device=packed_rows.device
|
||||
)
|
||||
self._lut = lut
|
||||
return _dequant_nvfp4_rows(
|
||||
packed_rows, self.head_dim, scale_2, output_dtype, lut
|
||||
)
|
||||
|
||||
|
||||
def _ple_dtype_is_fp8(ple_embedding_dtype: object) -> bool:
|
||||
"""Return whether the model config declares FP8 PLE checkpoint weights."""
|
||||
|
||||
if ple_embedding_dtype is None:
|
||||
return False
|
||||
if isinstance(ple_embedding_dtype, torch.dtype):
|
||||
return ple_embedding_dtype == torch.float8_e4m3fn
|
||||
return str(ple_embedding_dtype).rsplit(".", 1)[-1] == "float8_e4m3fn"
|
||||
|
||||
|
||||
def _get_ple_embedding_quant_method(
|
||||
quant_config: QuantizationConfig | None,
|
||||
prefix: str,
|
||||
ple_embedding_dtype: object = None,
|
||||
) -> QuantizeMethodBase | None:
|
||||
"""Select a packed PLE embedding method for quantized checkpoint shards."""
|
||||
|
||||
if isinstance(quant_config, ModelOptMixedPrecisionConfig):
|
||||
if quant_config._resolve_quant_algo(prefix) == "FP8":
|
||||
return Qwen4ExpPLEFp8EmbeddingMethod()
|
||||
return None
|
||||
|
||||
if isinstance(quant_config, Fp8Config):
|
||||
if not quant_config.is_checkpoint_fp8_serialized:
|
||||
return None
|
||||
ignored_layers = quant_config.ignored_layers
|
||||
if is_layer_skipped(
|
||||
prefix,
|
||||
ignored_layers,
|
||||
quant_config.packed_modules_mapping,
|
||||
match_mode=quant_config.ignored_layers_match_mode,
|
||||
):
|
||||
return None
|
||||
# PLE checkpoint shards form one runtime embedding parameter.
|
||||
shard_prefix = f"{prefix}.shard_"
|
||||
if any(name.startswith(shard_prefix) for name in ignored_layers):
|
||||
return None
|
||||
return Qwen4ExpPLEFp8EmbeddingMethod()
|
||||
|
||||
if isinstance(quant_config, ModelOptNvFp4Config):
|
||||
if not quant_config.is_checkpoint_nvfp4_serialized:
|
||||
return None
|
||||
if not quant_config.is_layer_excluded(prefix):
|
||||
logger.info_once("PLE embedding %s uses the runtime NVFP4 method", prefix)
|
||||
return Qwen4ExpPLENVFp4EmbeddingMethod()
|
||||
if _ple_dtype_is_fp8(ple_embedding_dtype):
|
||||
logger.info_once(
|
||||
"Excluded ModelOpt PLE embedding %s uses the runtime FP8 method",
|
||||
prefix,
|
||||
)
|
||||
return Qwen4ExpPLEFp8EmbeddingMethod()
|
||||
|
||||
# Checkpoints pairing quantized experts with a global-scale FP8 PLE table
|
||||
# (the RadixArk Flash-Next table grafted onto a CompressedTensors checkpoint)
|
||||
# list the PLE under exclude_modules, so the FP8-ness is visible only in the
|
||||
# tensors' dtype / the config's ple_embedding_dtype, not in the quant config
|
||||
# type dispatched above. Without this branch the offload worker treats the
|
||||
# table as BF16 and materializes ~96 GiB in host RAM (OOM-killed on the
|
||||
# 96 GB host). Honour either the config key (the -e1
|
||||
# checkpoint copy) or the explicit VLLM_PLE_FP8_GLOBAL_SCALE=1 opt-in the
|
||||
# previous release uses. Placement after the typed dispatch is the
|
||||
# position already proven in service.
|
||||
if _ple_dtype_is_fp8(ple_embedding_dtype) or os.environ.get(
|
||||
"VLLM_PLE_FP8_GLOBAL_SCALE"
|
||||
) == "1":
|
||||
logger.info_once(
|
||||
"PLE embedding %s: config declares an FP8 table with quant_config %s; "
|
||||
"using the runtime FP8 method (global weight_scale)",
|
||||
prefix,
|
||||
type(quant_config).__name__,
|
||||
)
|
||||
return Qwen4ExpPLEFp8EmbeddingMethod()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
_FP4_VALUES = (0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)
|
||||
|
||||
|
||||
def _dequant_nvfp4_codes(
|
||||
packed: torch.Tensor,
|
||||
scale: torch.Tensor,
|
||||
scale_2: torch.Tensor,
|
||||
lut: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Unpack NVFP4 codes with FP8 block scales and an FP32 global scale."""
|
||||
half = packed.shape[-1]
|
||||
codes = torch.stack([packed & 0xF, (packed >> 4) & 0xF], dim=-1).reshape(
|
||||
*packed.shape[:-1], half * 2
|
||||
)
|
||||
if lut is None:
|
||||
lut = torch.tensor(_FP4_VALUES, dtype=torch.float32, device=packed.device)
|
||||
magnitude = lut[(codes & 0x7).long()]
|
||||
sign = ((codes >> 3) & 1).to(torch.float32)
|
||||
fp4 = (magnitude * (1 - 2 * sign)).reshape(
|
||||
*packed.shape[:-1], -1, _NVFP4_BLOCK_SIZE
|
||||
)
|
||||
output = fp4 * scale.float().unsqueeze(-1) * scale_2.float()
|
||||
return output.reshape(*packed.shape[:-1], half * 2)
|
||||
|
||||
|
||||
def _dequant_nvfp4_rows(
|
||||
packed_rows: torch.Tensor,
|
||||
head_dim: int,
|
||||
scale_2: torch.Tensor,
|
||||
output_dtype: torch.dtype,
|
||||
lut: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Dequantize rows containing NVFP4 codes followed by block scales."""
|
||||
half = head_dim // 2
|
||||
codes = packed_rows[..., :half]
|
||||
scales = packed_rows[..., half:].contiguous().view(torch.float8_e4m3fn)
|
||||
return _dequant_nvfp4_codes(codes, scales, scale_2, lut).to(output_dtype)
|
||||
|
||||
|
||||
def _get_shared_nvfp4_outer_scale(
|
||||
outer_scales: dict[int, torch.Tensor],
|
||||
) -> torch.Tensor:
|
||||
"""Validate and return the global scale shared by NVFP4 PLE shards."""
|
||||
first_index, reference = next(iter(outer_scales.items()))
|
||||
reference = reference.reshape(())
|
||||
for shard_index, outer_scale in outer_scales.items():
|
||||
if not torch.equal(outer_scale.reshape(()), reference):
|
||||
raise ValueError(
|
||||
"NVFP4 PLE shards must share the same global scale, but "
|
||||
f"shards {first_index} and {shard_index} differ"
|
||||
)
|
||||
return reference
|
||||
|
||||
|
||||
class Qwen4ExpNGramEmbedding(PleOffloadLayer):
|
||||
_offload_quant_method: QuantizeMethodBase | None = None
|
||||
_MASK64 = (1 << 64) - 1
|
||||
_SPLITMIX_GAMMA = 0x9E3779B97F4A7C15
|
||||
_SPLITMIX_M1 = 0xBF58476D1CE4E5B9
|
||||
_SPLITMIX_M2 = 0x94D049BB133111EB
|
||||
_PLE_LAYER_PRIME = 10007
|
||||
|
||||
@classmethod
|
||||
def _splitmix64(cls, value: int) -> int:
|
||||
"""Mix an integer into a deterministic unsigned 64-bit value."""
|
||||
value = (value + cls._SPLITMIX_GAMMA) & cls._MASK64
|
||||
value = ((value ^ (value >> 30)) * cls._SPLITMIX_M1) & cls._MASK64
|
||||
value = ((value ^ (value >> 27)) * cls._SPLITMIX_M2) & cls._MASK64
|
||||
return (value ^ (value >> 31)) & cls._MASK64
|
||||
|
||||
@staticmethod
|
||||
def _is_prime_64(value: int) -> bool:
|
||||
"""Return whether a 64-bit integer is prime."""
|
||||
if value < 2:
|
||||
return False
|
||||
for prime in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
|
||||
if value % prime == 0:
|
||||
return value == prime
|
||||
exponent = value - 1
|
||||
shifts = 0
|
||||
while exponent % 2 == 0:
|
||||
exponent //= 2
|
||||
shifts += 1
|
||||
for base in (2, 325, 9375, 28178, 450775, 9780504, 1795265022):
|
||||
if base % value == 0:
|
||||
continue
|
||||
witness = pow(base, exponent, value)
|
||||
if witness in (1, value - 1):
|
||||
continue
|
||||
for _ in range(shifts - 1):
|
||||
witness = pow(witness, 2, value)
|
||||
if witness == value - 1:
|
||||
break
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _nth_prime_after(cls, start: int, count: int) -> int:
|
||||
"""Return the ``count``-th prime strictly greater than ``start``."""
|
||||
prime = int(start)
|
||||
for _ in range(count):
|
||||
candidate = prime + 1
|
||||
if candidate <= 2:
|
||||
prime = 2
|
||||
continue
|
||||
if candidate % 2 == 0:
|
||||
candidate += 1
|
||||
while not cls._is_prime_64(candidate):
|
||||
candidate += 2
|
||||
prime = candidate
|
||||
return prime
|
||||
|
||||
@classmethod
|
||||
def _make_layer_multipliers(
|
||||
cls,
|
||||
*,
|
||||
ngram_size: int,
|
||||
unigram_vocab_size: int,
|
||||
seed: int,
|
||||
ple_dense_layer_id: int,
|
||||
) -> list[int]:
|
||||
"""Build deterministic hash multipliers for one PLE layer."""
|
||||
max_multiplier = ((1 << 63) - 1) // unigram_vocab_size
|
||||
half_bound = max(1, max_multiplier // 2)
|
||||
base_seed = seed + cls._PLE_LAYER_PRIME * ple_dense_layer_id
|
||||
multipliers = []
|
||||
for index in range(ngram_size):
|
||||
value = base_seed + cls._SPLITMIX_GAMMA * (index + 1)
|
||||
multipliers.append(2 * (cls._splitmix64(value) % half_bound) + 1)
|
||||
return multipliers
|
||||
|
||||
@classmethod
|
||||
def _make_vocab_layout(
|
||||
cls,
|
||||
*,
|
||||
ngram_vocab_size_base: int,
|
||||
ngram_heads: int,
|
||||
ple_dense_layer_id: int,
|
||||
) -> tuple[list[int], list[int], int]:
|
||||
"""Build per-head vocabulary sizes, offsets, and total row count."""
|
||||
sizes: list[int] = []
|
||||
offsets: list[int] = []
|
||||
offset = 0
|
||||
for local_head in range(ngram_heads):
|
||||
global_head = ple_dense_layer_id * ngram_heads + local_head
|
||||
size = cls._nth_prime_after(ngram_vocab_size_base - 1, global_head + 1)
|
||||
sizes.append(size)
|
||||
offsets.append(offset)
|
||||
offset += size
|
||||
return sizes, offsets, offset
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Qwen4ExpTextConfig,
|
||||
embedding_dim: int,
|
||||
ple_dense_layer_id: int,
|
||||
prefix: str,
|
||||
layer_name: str,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
params_dtype: torch.dtype | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.layer_name = layer_name
|
||||
self.embedding_dim = embedding_dim
|
||||
self.ngram_size = int(config.ngram_size)
|
||||
self.heads_per_ngram = int(config.heads_per_ngram)
|
||||
self.ngram_heads = (self.ngram_size - 1) * self.heads_per_ngram
|
||||
if self.ngram_size < 2:
|
||||
raise ValueError(f"ngram_size must be >= 2, got {self.ngram_size}")
|
||||
if self.heads_per_ngram <= 0:
|
||||
raise ValueError(f"heads_per_ngram must be > 0, got {self.heads_per_ngram}")
|
||||
if embedding_dim % self.ngram_heads:
|
||||
raise ValueError(
|
||||
"ple_embed_dim must be divisible by total ngram heads: "
|
||||
f"{embedding_dim} % {self.ngram_heads} != 0"
|
||||
)
|
||||
self.head_dim = embedding_dim // self.ngram_heads
|
||||
self.eos_token_id = int(config.eos_token_id)
|
||||
self.unigram_vocab_size = int(config.vocab_size)
|
||||
self.split_ngram_parts = int(getattr(config, "split_ngram_parts", 512))
|
||||
if self.split_ngram_parts <= 0:
|
||||
raise ValueError("split_ngram_parts must be positive")
|
||||
|
||||
multipliers = self._make_layer_multipliers(
|
||||
ngram_size=self.ngram_size,
|
||||
unigram_vocab_size=self.unigram_vocab_size,
|
||||
seed=int(getattr(config, "seed", 1234)),
|
||||
ple_dense_layer_id=ple_dense_layer_id,
|
||||
)
|
||||
self.register_buffer(
|
||||
"layer_multipliers",
|
||||
torch.tensor(multipliers, dtype=torch.long),
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
sizes, offsets, total_vocab_size = self._make_vocab_layout(
|
||||
ngram_vocab_size_base=int(config.ngram_vocab_size_base),
|
||||
ngram_heads=self.ngram_heads,
|
||||
ple_dense_layer_id=ple_dense_layer_id,
|
||||
)
|
||||
self.register_buffer(
|
||||
"ngram_heads_vocab_sizes",
|
||||
torch.tensor(sizes, dtype=torch.long),
|
||||
persistent=True,
|
||||
)
|
||||
self.register_buffer(
|
||||
"ngram_heads_offsets",
|
||||
torch.tensor(offsets, dtype=torch.long),
|
||||
persistent=True,
|
||||
)
|
||||
divisor = int(config.make_ngram_vocab_size_divisible_by)
|
||||
padded_vocab_size = ((total_vocab_size + divisor - 1) // divisor) * divisor
|
||||
self.ngram_embedding = PLEVocabParallelEmbedding(
|
||||
padded_vocab_size,
|
||||
self.head_dim,
|
||||
params_dtype=params_dtype,
|
||||
padding_size=divisor,
|
||||
prefix=f"{prefix}.ngram_embedding",
|
||||
quant_method=_get_ple_embedding_quant_method(
|
||||
quant_config,
|
||||
f"{prefix}.ngram_embedding",
|
||||
getattr(config, "ple_embedding_dtype", None),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _shift_precompute(
|
||||
tokens: torch.Tensor, eos_token_id: int
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
if tokens.dim() != 2:
|
||||
raise ValueError("tokens must be a 2D tensor")
|
||||
batch_size, seq_len = tokens.shape
|
||||
positions = torch.arange(seq_len, device=tokens.device, dtype=torch.int64)
|
||||
eos_positions = torch.where(tokens == eos_token_id, positions, -1)
|
||||
previous_eos_inclusive = torch.cummax(eos_positions, dim=1).values
|
||||
previous_eos = torch.cat(
|
||||
[
|
||||
eos_positions.new_full((batch_size, 1), -1),
|
||||
previous_eos_inclusive[:, :-1],
|
||||
],
|
||||
dim=1,
|
||||
)
|
||||
return positions, positions.unsqueeze(0) - previous_eos - 1
|
||||
|
||||
@staticmethod
|
||||
def _shift_apply(
|
||||
tokens: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
position_in_segment: torch.Tensor,
|
||||
shift: int,
|
||||
eos_token_id: int,
|
||||
) -> torch.Tensor:
|
||||
if shift == 0:
|
||||
return tokens
|
||||
source = positions - shift
|
||||
gather_indices = source.clamp_min(0).unsqueeze(0).expand(tokens.shape[0], -1)
|
||||
shifted = tokens.gather(1, gather_indices)
|
||||
valid = (source.unsqueeze(0) >= 0) & (position_in_segment >= shift)
|
||||
return torch.where(valid, shifted, tokens.new_full((), eos_token_id))
|
||||
|
||||
def compute_ngram_ids(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
query_start_loc: torch.Tensor,
|
||||
ngram_context: torch.Tensor,
|
||||
output: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Compute n-gram embedding indices for the current request layout."""
|
||||
input_ids = input_ids.reshape(-1)
|
||||
num_reqs = query_start_loc.numel() - 1
|
||||
num_tokens = input_ids.shape[0]
|
||||
|
||||
if input_ids.is_cuda:
|
||||
return ple_ngram_ids(
|
||||
input_ids=input_ids,
|
||||
query_start_loc=query_start_loc,
|
||||
ngram_context=ngram_context,
|
||||
layer_multipliers=self.layer_multipliers,
|
||||
ngram_heads_vocab_sizes=self.ngram_heads_vocab_sizes,
|
||||
ngram_heads_offsets=self.ngram_heads_offsets,
|
||||
eos_token_id=self.eos_token_id,
|
||||
heads_per_ngram=self.heads_per_ngram,
|
||||
output=output,
|
||||
)
|
||||
input_ids = input_ids.long()
|
||||
query_start_loc = query_start_loc.long()
|
||||
if num_reqs <= 0:
|
||||
raise ValueError("PLE requires at least one request")
|
||||
|
||||
if is_offload_process():
|
||||
max_seq_len = max(
|
||||
1,
|
||||
int((query_start_loc[1:] - query_start_loc[:-1]).max().item()),
|
||||
)
|
||||
# CUDA graphs can pad input_ids beyond the valid request layout.
|
||||
# Keep those rows from overwriting the final real token.
|
||||
num_valid_tokens = min(int(query_start_loc[-1].item()), num_tokens)
|
||||
else:
|
||||
max_seq_len = num_tokens
|
||||
num_valid_tokens = num_tokens
|
||||
|
||||
positions = torch.arange(num_tokens, device=input_ids.device, dtype=torch.int64)
|
||||
packed = torch.full(
|
||||
(num_reqs, max_seq_len),
|
||||
self.eos_token_id,
|
||||
device=input_ids.device,
|
||||
dtype=torch.int64,
|
||||
)
|
||||
request_indices = torch.searchsorted(query_start_loc, positions, right=True) - 1
|
||||
request_indices.clamp_(max=num_reqs - 1)
|
||||
columns = (positions - query_start_loc[request_indices]).clamp(
|
||||
0, packed.shape[1] - 1
|
||||
)
|
||||
packed[request_indices[:num_valid_tokens], columns[:num_valid_tokens]] = (
|
||||
input_ids[:num_valid_tokens]
|
||||
)
|
||||
ngram_context = ngram_context[:num_reqs].to(
|
||||
device=input_ids.device, dtype=torch.long
|
||||
)
|
||||
|
||||
context = torch.cat([ngram_context, packed], dim=-1)
|
||||
positions_2d, position_in_segment = self._shift_precompute(
|
||||
context, self.eos_token_id
|
||||
)
|
||||
shifted = [context]
|
||||
for shift in range(1, self.ngram_size):
|
||||
shifted.append(
|
||||
self._shift_apply(
|
||||
context,
|
||||
positions_2d,
|
||||
position_in_segment,
|
||||
shift,
|
||||
self.eos_token_id,
|
||||
)
|
||||
)
|
||||
adjusted_columns = columns + self.ngram_size - 1
|
||||
id_blocks = []
|
||||
for ngram in range(2, self.ngram_size + 1):
|
||||
start = (ngram - 2) * self.heads_per_ngram
|
||||
end = start + self.heads_per_ngram
|
||||
mixed = shifted[0] * self.layer_multipliers[0]
|
||||
for index in range(1, ngram):
|
||||
mixed = torch.bitwise_xor(
|
||||
mixed, shifted[index] * self.layer_multipliers[index]
|
||||
)
|
||||
sizes = self.ngram_heads_vocab_sizes[start:end]
|
||||
offsets = self.ngram_heads_offsets[start:end]
|
||||
ids = torch.remainder(mixed.unsqueeze(-1), sizes) + offsets
|
||||
id_blocks.append(ids[request_indices, adjusted_columns])
|
||||
return torch.cat(id_blocks, dim=-1)
|
||||
|
||||
def forward_impl( # type: ignore[override]
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
input_ids: torch.Tensor,
|
||||
query_start_loc: torch.Tensor,
|
||||
ngram_context: torch.Tensor,
|
||||
output_buffer: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
del hidden_states
|
||||
if input_ids.is_cuda:
|
||||
# Keep request-dependent ID generation outside piecewise graphs.
|
||||
ngram_ids = input_ids.new_empty(
|
||||
(input_ids.numel(), self.ngram_heads), dtype=torch.long
|
||||
)
|
||||
torch.ops.vllm.qwen4_exp_compute_ple_ngram_ids(
|
||||
input_ids,
|
||||
query_start_loc,
|
||||
ngram_context,
|
||||
ngram_ids,
|
||||
self.layer_name,
|
||||
)
|
||||
else:
|
||||
ngram_ids = self.compute_ngram_ids(
|
||||
input_ids,
|
||||
query_start_loc,
|
||||
ngram_context,
|
||||
)
|
||||
num_tokens = input_ids.reshape(-1).shape[0]
|
||||
if output_buffer is not None:
|
||||
quant_method = getattr(self.ngram_embedding, "quant_method", None)
|
||||
if isinstance(quant_method, Qwen4ExpPLENVFp4EmbeddingMethod):
|
||||
output_dim = self.get_offload_output_dim(self.embedding_dim)
|
||||
output = output_buffer[:num_tokens, :output_dim]
|
||||
output.copy_(self.ngram_embedding(ngram_ids).flatten(-2))
|
||||
return output
|
||||
output = output_buffer[:num_tokens, : self.embedding_dim]
|
||||
torch.index_select(
|
||||
self.ngram_embedding.weight,
|
||||
0,
|
||||
ngram_ids.reshape(-1),
|
||||
out=output.reshape(-1, self.head_dim),
|
||||
)
|
||||
return output
|
||||
return self.ngram_embedding(ngram_ids).flatten(-2)
|
||||
|
||||
def get_offload_output_dtype(self, default_dtype: torch.dtype) -> torch.dtype:
|
||||
"""Keep quantized lookup results in their embedding storage dtype."""
|
||||
embedding = getattr(self, "ngram_embedding", None)
|
||||
weight = getattr(embedding, "weight", None)
|
||||
if weight is not None:
|
||||
return weight.dtype
|
||||
if isinstance(self._offload_quant_method, Qwen4ExpPLENVFp4EmbeddingMethod):
|
||||
return torch.uint8
|
||||
if isinstance(self._offload_quant_method, Qwen4ExpPLEFp8EmbeddingMethod):
|
||||
return torch.float8_e4m3fn
|
||||
return default_dtype
|
||||
|
||||
def get_offload_output_dim(self, default_dim: int) -> int:
|
||||
"""Keep NVFP4 lookup rows packed while transferring them to the GPU."""
|
||||
quant_method = getattr(
|
||||
getattr(self, "ngram_embedding", None), "quant_method", None
|
||||
)
|
||||
if quant_method is None:
|
||||
quant_method = self._offload_quant_method
|
||||
if isinstance(quant_method, Qwen4ExpPLENVFp4EmbeddingMethod):
|
||||
if default_dim % _NVFP4_BLOCK_SIZE:
|
||||
raise ValueError(
|
||||
"NVFP4 PLE output dim must be a multiple of "
|
||||
f"{_NVFP4_BLOCK_SIZE}, got {default_dim}"
|
||||
)
|
||||
return default_dim // 2 + default_dim // _NVFP4_BLOCK_SIZE
|
||||
return default_dim
|
||||
|
||||
def initialize_dummy_offload_metadata(self, device: torch.device) -> None:
|
||||
"""Initialize quantization metadata skipped by the dummy loader."""
|
||||
quant_method = self._offload_quant_method
|
||||
if isinstance(quant_method, Qwen4ExpPLEFp8EmbeddingMethod):
|
||||
self.register_buffer(
|
||||
"_offload_weight_scale",
|
||||
torch.ones((), dtype=torch.float32, device=device),
|
||||
persistent=False,
|
||||
)
|
||||
elif isinstance(quant_method, Qwen4ExpPLENVFp4EmbeddingMethod):
|
||||
self.register_buffer(
|
||||
"_offload_weight_scale_2",
|
||||
torch.ones((), dtype=torch.float32, device=device),
|
||||
persistent=False,
|
||||
)
|
||||
self.register_buffer(
|
||||
"_offload_nvfp4_lut",
|
||||
torch.tensor(_FP4_VALUES, dtype=torch.float32, device=device),
|
||||
persistent=False,
|
||||
)
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
"""Load hash buffers and checkpoint-split embedding rows."""
|
||||
|
||||
# Prefix grouping may invoke this method repeatedly for one module.
|
||||
# Treat the global FP8 scale as incrementally loaded state.
|
||||
# GPU workers retain only dequantization metadata. The CPU process owns
|
||||
# the embedding table and transfers quantized lookup rows unchanged.
|
||||
if envs.VLLM_PLE_CPU_OFFLOAD and not is_offload_process():
|
||||
retained: set[str] = set()
|
||||
quant_method = self._offload_quant_method
|
||||
if isinstance(quant_method, Qwen4ExpPLEFp8EmbeddingMethod):
|
||||
for name, loaded_weight in weights:
|
||||
if name != "ngram_embedding.weight_scale":
|
||||
continue
|
||||
self.register_buffer(
|
||||
"_offload_weight_scale",
|
||||
loaded_weight.to(
|
||||
device=torch.accelerator.current_accelerator()
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
retained.add(name)
|
||||
elif isinstance(quant_method, Qwen4ExpPLENVFp4EmbeddingMethod):
|
||||
outer_scales: dict[int, torch.Tensor] = {}
|
||||
for name, loaded_weight in weights:
|
||||
if not name.startswith(
|
||||
"ngram_embedding.shard_"
|
||||
) or not name.endswith(".weight_scale_2"):
|
||||
continue
|
||||
shard_text = name[
|
||||
len("ngram_embedding.shard_") : -len(".weight_scale_2")
|
||||
]
|
||||
shard_index = int(shard_text)
|
||||
outer_scales[shard_index] = loaded_weight
|
||||
retained.add(name)
|
||||
if not retained:
|
||||
raise ValueError(
|
||||
"NVFP4 PLE offload checkpoint is missing its global scale"
|
||||
)
|
||||
scale_2 = _get_shared_nvfp4_outer_scale(outer_scales).to(
|
||||
device=torch.accelerator.current_accelerator()
|
||||
)
|
||||
self.register_buffer(
|
||||
"_offload_weight_scale_2", scale_2, persistent=False
|
||||
)
|
||||
self.register_buffer(
|
||||
"_offload_nvfp4_lut",
|
||||
torch.tensor(
|
||||
_FP4_VALUES, dtype=torch.float32, device=scale_2.device
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
else:
|
||||
for _ in weights:
|
||||
pass
|
||||
return retained
|
||||
|
||||
persistent_buffers = {
|
||||
"layer_multipliers": self.layer_multipliers,
|
||||
"ngram_heads_offsets": self.ngram_heads_offsets,
|
||||
"ngram_heads_vocab_sizes": self.ngram_heads_vocab_sizes,
|
||||
}
|
||||
loaded: set[str] = set()
|
||||
regular_weights: list[tuple[str, torch.Tensor]] = []
|
||||
shard_prefix = "ngram_embedding.shard_"
|
||||
quant_method = getattr(self.ngram_embedding, "quant_method", None)
|
||||
nvfp4_runtime = isinstance(quant_method, Qwen4ExpPLENVFp4EmbeddingMethod)
|
||||
packed_codes: dict[int, torch.Tensor] = {}
|
||||
packed_scales: dict[int, torch.Tensor] = {}
|
||||
packed_outer_scales: dict[int, torch.Tensor] = {}
|
||||
|
||||
for name, loaded_weight in weights:
|
||||
leaf_name = name.rsplit(".", 1)[-1]
|
||||
if leaf_name.startswith("hashstats_") or leaf_name == "token_lookup":
|
||||
continue
|
||||
if name in persistent_buffers:
|
||||
buffer = persistent_buffers[name]
|
||||
if buffer.shape != loaded_weight.shape:
|
||||
raise ValueError(
|
||||
f"Shape mismatch for {name}: expected "
|
||||
f"{tuple(buffer.shape)}, got {tuple(loaded_weight.shape)}"
|
||||
)
|
||||
buffer.copy_(loaded_weight.to(device=buffer.device, dtype=buffer.dtype))
|
||||
loaded.add(name)
|
||||
continue
|
||||
|
||||
if nvfp4_runtime and name.startswith(shard_prefix):
|
||||
suffix = name[len(shard_prefix) :]
|
||||
for ending, sink in (
|
||||
(".weight_scale_2", packed_outer_scales),
|
||||
(".weight_scale", packed_scales),
|
||||
(".weight", packed_codes),
|
||||
):
|
||||
if not suffix.endswith(ending):
|
||||
continue
|
||||
shard_text = suffix[: -len(ending)]
|
||||
shard_index = int(shard_text)
|
||||
if shard_index >= self.split_ngram_parts:
|
||||
raise ValueError(
|
||||
f"PLE embedding shard index {shard_index} exceeds "
|
||||
f"split_ngram_parts={self.split_ngram_parts}"
|
||||
)
|
||||
sink[shard_index] = loaded_weight
|
||||
break
|
||||
else:
|
||||
regular_weights.append((name, loaded_weight))
|
||||
continue
|
||||
|
||||
if (
|
||||
not nvfp4_runtime
|
||||
and name.startswith(shard_prefix)
|
||||
and name.endswith(".weight")
|
||||
):
|
||||
shard_text = name[len(shard_prefix) : -len(".weight")]
|
||||
if not shard_text.isdigit():
|
||||
regular_weights.append((name, loaded_weight))
|
||||
continue
|
||||
shard_index = int(shard_text)
|
||||
if shard_index >= self.split_ngram_parts:
|
||||
raise ValueError(
|
||||
f"PLE embedding shard index {shard_index} exceeds "
|
||||
f"split_ngram_parts={self.split_ngram_parts}"
|
||||
)
|
||||
embedding = self.ngram_embedding
|
||||
shard_size = (
|
||||
embedding.org_vocab_size + self.split_ngram_parts - 1
|
||||
) // self.split_ngram_parts
|
||||
checkpoint_start = shard_index * shard_size
|
||||
expected_rows = max(
|
||||
0,
|
||||
min(shard_size, embedding.org_vocab_size - checkpoint_start),
|
||||
)
|
||||
expected_shape = (expected_rows, embedding.embedding_dim)
|
||||
if tuple(loaded_weight.shape) != expected_shape:
|
||||
raise ValueError(
|
||||
f"Shape mismatch for PLE embedding shard {shard_index}: "
|
||||
f"expected {expected_shape}, got "
|
||||
f"{tuple(loaded_weight.shape)}"
|
||||
)
|
||||
embedding.weight.weight_loader(
|
||||
embedding.weight,
|
||||
loaded_weight,
|
||||
checkpoint_start=checkpoint_start,
|
||||
)
|
||||
loaded.add("ngram_embedding.weight")
|
||||
continue
|
||||
regular_weights.append((name, loaded_weight))
|
||||
|
||||
if nvfp4_runtime:
|
||||
if not packed_codes:
|
||||
raise ValueError("NVFP4 PLE checkpoint contains no packed shards")
|
||||
for shard_index in packed_codes:
|
||||
if (
|
||||
shard_index not in packed_scales
|
||||
or shard_index not in packed_outer_scales
|
||||
):
|
||||
raise ValueError(
|
||||
f"NVFP4 PLE shard {shard_index} is missing its scale tensors"
|
||||
)
|
||||
shared_outer_scale = _get_shared_nvfp4_outer_scale(
|
||||
{
|
||||
shard_index: packed_outer_scales[shard_index]
|
||||
for shard_index in packed_codes
|
||||
}
|
||||
)
|
||||
|
||||
for shard_index, codes in packed_codes.items():
|
||||
embedding = self.ngram_embedding
|
||||
shard_size = (
|
||||
embedding.org_vocab_size + self.split_ngram_parts - 1
|
||||
) // self.split_ngram_parts
|
||||
checkpoint_start = shard_index * shard_size
|
||||
expected_rows = max(
|
||||
0, min(shard_size, embedding.org_vocab_size - checkpoint_start)
|
||||
)
|
||||
expected_codes_shape = (expected_rows, embedding.embedding_dim // 2)
|
||||
if tuple(codes.shape) != expected_codes_shape:
|
||||
raise ValueError(
|
||||
f"Shape mismatch for packed PLE shard {shard_index}: "
|
||||
f"expected {expected_codes_shape}, got {tuple(codes.shape)}"
|
||||
)
|
||||
tp_bounds = {
|
||||
"checkpoint_start": checkpoint_start,
|
||||
"tp_start": embedding.shard_indices.org_vocab_start_index,
|
||||
"tp_end": embedding.shard_indices.org_vocab_end_index,
|
||||
}
|
||||
scales = packed_scales[shard_index]
|
||||
expected_scales_shape = (
|
||||
expected_rows,
|
||||
embedding.embedding_dim // _NVFP4_BLOCK_SIZE,
|
||||
)
|
||||
if tuple(scales.shape) != expected_scales_shape:
|
||||
raise ValueError(
|
||||
f"Scale shape mismatch for packed PLE shard {shard_index}: "
|
||||
f"expected {expected_scales_shape}, got {tuple(scales.shape)}"
|
||||
)
|
||||
copy_ple_embedding_shard_(embedding.weight.data, codes, **tp_bounds)
|
||||
copy_ple_embedding_shard_(embedding.weight_scale.data, scales, **tp_bounds)
|
||||
|
||||
if nvfp4_runtime:
|
||||
self.ngram_embedding.weight_scale_2.data.copy_(shared_outer_scale)
|
||||
loaded.update(
|
||||
{
|
||||
"ngram_embedding.weight",
|
||||
"ngram_embedding.weight_scale",
|
||||
"ngram_embedding.weight_scale_2",
|
||||
}
|
||||
)
|
||||
|
||||
if regular_weights:
|
||||
loaded.update(AutoWeightsLoader(self).load_weights(regular_weights))
|
||||
return loaded
|
||||
|
||||
|
||||
class Qwen4ExpPLELayer(nn.Module, MambaBase):
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
import atexit
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Collection, Mapping
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, cast
|
||||
|
||||
import torch
|
||||
@@ -16,6 +23,7 @@ from vllm.config import VllmConfig
|
||||
from vllm.config.cache import CacheDType
|
||||
from vllm.distributed import get_tensor_model_parallel_world_size
|
||||
from vllm.forward_context import get_forward_context
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.attention.attention import (
|
||||
set_default_quant_scales,
|
||||
)
|
||||
@@ -32,7 +40,14 @@ from vllm.model_executor.models.qwen3_next import Qwen3NextAttention
|
||||
from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.platforms.interface import DeviceCapability
|
||||
from vllm.transformers_utils.configs.qwen4_exp import (
|
||||
Qwen4ExpTextConfig,
|
||||
)
|
||||
from vllm.utils.torch_utils import (
|
||||
LayerNameType,
|
||||
_encode_layer_name,
|
||||
_resolve_layer_name,
|
||||
direct_register_custom_op,
|
||||
kv_cache_dtype_str_to_dtype,
|
||||
)
|
||||
from vllm.v1.attention.backend import (
|
||||
@@ -58,6 +73,260 @@ from ..common.qsa_cache import QSAForwardMetadata
|
||||
from . import model
|
||||
from .indexer_qsa import QSAIndexer
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# --- FP8 K/V scale calibration collector -----------------------
|
||||
# Active ONLY when VLLM_QSA_KV_COLLECT names a writable directory. Records a
|
||||
# GPU-side running absmax of the pre-quantization BF16 K/V per layer and dumps
|
||||
# per-rank JSON (periodically + at exit) for an offline max-merge into the
|
||||
# VLLM_QSA_KV_SCALES sidecar. Calibration must run with --enforce-eager: the
|
||||
# collector mutates module-external state per step, which cudagraph capture
|
||||
# would freeze and torch.compile may not retrace. With the env unset this is a
|
||||
# constant-false branch and prod behaviour is unchanged.
|
||||
_QSA_KV_COLLECT_ENV = "VLLM_QSA_KV_COLLECT"
|
||||
_qsa_collect_dir = os.environ.get(_QSA_KV_COLLECT_ENV, "").strip()
|
||||
_qsa_collect_state: dict[str, list[torch.Tensor]] = {}
|
||||
_qsa_collect_calls = 0
|
||||
|
||||
|
||||
_qsa_collect_ident: str | None = None
|
||||
|
||||
|
||||
def _qsa_collect_identity() -> str:
|
||||
"""Stable per-process dump name: (pp, tp) rank, resolved once and cached so
|
||||
a teardown-time lookup failure cannot move the final flush to a PID-named
|
||||
file. TP-only runs keep the historical name."""
|
||||
global _qsa_collect_ident
|
||||
if _qsa_collect_ident is not None:
|
||||
return _qsa_collect_ident
|
||||
try:
|
||||
from vllm.distributed import get_pp_group, get_tensor_model_parallel_rank
|
||||
|
||||
rank = get_tensor_model_parallel_rank()
|
||||
pp_group = get_pp_group()
|
||||
pp_tag = f"pp{pp_group.rank_in_group}_" if pp_group.world_size > 1 else ""
|
||||
_qsa_collect_ident = f"{pp_tag}rank{rank}"
|
||||
except Exception:
|
||||
return f"pid{os.getpid()}" # not cached: retry once the groups exist
|
||||
return _qsa_collect_ident
|
||||
|
||||
|
||||
def _qsa_collect_dump() -> None:
|
||||
if not _qsa_collect_state:
|
||||
return
|
||||
out = {
|
||||
name: {"k_absmax": float(st[0].item()), "v_absmax": float(st[1].item())}
|
||||
for name, st in _qsa_collect_state.items()
|
||||
}
|
||||
path = Path(_qsa_collect_dir) / f"qsa_absmax_{_qsa_collect_identity()}.json"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(out, indent=1), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _qsa_collect_absmax(
|
||||
layer_name: str, key: torch.Tensor, value: torch.Tensor
|
||||
) -> None:
|
||||
global _qsa_collect_calls
|
||||
k_abs = key.detach().abs().amax().float()
|
||||
v_abs = value.detach().abs().amax().float()
|
||||
st = _qsa_collect_state.get(layer_name)
|
||||
if st is None:
|
||||
if not _qsa_collect_state:
|
||||
atexit.register(_qsa_collect_dump)
|
||||
_qsa_collect_state[layer_name] = [k_abs, v_abs]
|
||||
else:
|
||||
torch.maximum(st[0], k_abs, out=st[0])
|
||||
torch.maximum(st[1], v_abs, out=st[1])
|
||||
_qsa_collect_calls += 1
|
||||
if _qsa_collect_calls % 2000 == 0:
|
||||
_qsa_collect_dump()
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# --- FP8 K/V runtime clipping counter --------------------------
|
||||
# Under-coverage guard for the calibrated scales sidecar: counts K/V elements
|
||||
# that would saturate E4M3 after scaling (|x| > 448 * scale), i.e. inputs that
|
||||
# exceed the calibration-time absmax. Active ONLY when VLLM_QSA_KV_CLIP_COUNT
|
||||
# is set to a positive read interval in seconds.
|
||||
#
|
||||
# Graph-safety: unlike the calibration collector above, this MUST work under
|
||||
# FULL cudagraph replay, where the Python forward does not run per step. The
|
||||
# counters are persistent per-layer GPU int64 tensors allocated on the first
|
||||
# (eager, pre-capture) forward; the increment is pure captured GPU ops, so
|
||||
# graph replay keeps counting. A daemon reader thread .item()s the counters on
|
||||
# a cadence and logs: WARNING on any new clips, INFO heartbeat every 10th pass
|
||||
# so a quiet log still proves the instrument is alive.
|
||||
#
|
||||
# Since v2.5 the reader thread is started AFTER warmup and cudagraph capture
|
||||
# (guard_telemetry.register_post_capture_hook, run by start_reader() from
|
||||
# gpu_worker), never from the first eager forward. Before, it started inside the
|
||||
# first forward, i.e. before capture, and its device-to-host copies could run on
|
||||
# a second thread while a capture was active -- the call class
|
||||
# cudaStreamCaptureModeGlobal rejects. That is why COUNTERS shipped off.
|
||||
_QSA_CLIP_ENV = "VLLM_QSA_KV_CLIP_COUNT"
|
||||
_qsa_clip_raw = os.environ.get(_QSA_CLIP_ENV, "").strip()
|
||||
try:
|
||||
_qsa_clip_interval = float(_qsa_clip_raw) if _qsa_clip_raw else 0.0
|
||||
except ValueError as exc: # fail closed: a set-but-broken gate must not be silent
|
||||
raise ValueError(
|
||||
f"{_QSA_CLIP_ENV} must be a read interval in seconds, got {_qsa_clip_raw!r}"
|
||||
) from exc
|
||||
if _qsa_clip_raw and not (math.isfinite(_qsa_clip_interval) and _qsa_clip_interval > 0.0):
|
||||
raise ValueError(
|
||||
f"{_QSA_CLIP_ENV} must be a finite positive number of seconds, got {_qsa_clip_raw!r}"
|
||||
)
|
||||
_qsa_clip_counters: dict[str, torch.Tensor] = {}
|
||||
# v2 reader fix: the reader must never block on the compute
|
||||
# stream; see guard_telemetry.NonBlockingReader.
|
||||
from vllm.v1.worker.guard_telemetry import ( # noqa: E402
|
||||
NonBlockingReader as _NonBlockingReader,
|
||||
)
|
||||
|
||||
_qsa_clip_nb = _NonBlockingReader()
|
||||
_qsa_clip_reader_started = False
|
||||
_qsa_clip_lock = threading.Lock()
|
||||
|
||||
|
||||
def _qsa_clip_reader() -> None:
|
||||
last: dict[str, tuple[int, int]] = {}
|
||||
passes = 0
|
||||
while True:
|
||||
time.sleep(_qsa_clip_interval)
|
||||
passes += 1
|
||||
try:
|
||||
totals_k = totals_v = 0
|
||||
for name, ctr in list(_qsa_clip_counters.items()):
|
||||
if not _qsa_clip_nb.registered(name):
|
||||
_qsa_clip_nb.register(name, ctr) # reader thread: never capturing
|
||||
ready = _qsa_clip_nb.poll()
|
||||
for name, ctr in list(_qsa_clip_counters.items()):
|
||||
row = ready.get(name)
|
||||
if row is None:
|
||||
# copy still in flight: report on the next pass
|
||||
prev_k, prev_v = last.get(name, (0, 0))
|
||||
totals_k += prev_k
|
||||
totals_v += prev_v
|
||||
continue
|
||||
k_total = int(row[0])
|
||||
v_total = int(row[1])
|
||||
totals_k += k_total
|
||||
totals_v += v_total
|
||||
prev_k, prev_v = last.get(name, (0, 0))
|
||||
if k_total > prev_k or v_total > prev_v:
|
||||
logger.warning(
|
||||
"QSA KV CLIPPING on %s: k +%d (total %d), v +%d (total %d)"
|
||||
" -- inputs exceeded the calibration absmax; the scales"
|
||||
" sidecar may need re-calibration",
|
||||
name,
|
||||
k_total - prev_k,
|
||||
k_total,
|
||||
v_total - prev_v,
|
||||
v_total,
|
||||
)
|
||||
last[name] = (k_total, v_total)
|
||||
if passes % 10 == 1:
|
||||
logger.info(
|
||||
"QSA clip counters alive: %d layer(s), totals k=%d v=%d",
|
||||
len(_qsa_clip_counters),
|
||||
totals_k,
|
||||
totals_v,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("QSA clip reader pass failed; thread continues")
|
||||
|
||||
|
||||
def _qsa_clip_start_reader() -> None:
|
||||
"""Start the reader thread. Runs after warmup + capture (post-capture hook); idempotent."""
|
||||
global _qsa_clip_reader_started
|
||||
with _qsa_clip_lock:
|
||||
if _qsa_clip_reader_started:
|
||||
return
|
||||
_qsa_clip_reader_started = True
|
||||
threading.Thread(
|
||||
target=_qsa_clip_reader,
|
||||
name="qsa-clip-reader",
|
||||
daemon=True,
|
||||
).start()
|
||||
# The leading "QSA clip counter armed (read interval Ns)" is the token the serving smoke greps
|
||||
# (v2.2.0 logged it from the first forward); keep it verbatim when editing this line.
|
||||
logger.info(
|
||||
"QSA clip counter armed (read interval %.0fs); reader started after capture "
|
||||
"(%d layer(s) registered)",
|
||||
_qsa_clip_interval,
|
||||
len(_qsa_clip_counters),
|
||||
)
|
||||
|
||||
|
||||
if _qsa_clip_interval > 0.0:
|
||||
from vllm.v1.worker.guard_telemetry import register_post_capture_hook # noqa: E402
|
||||
|
||||
register_post_capture_hook(_qsa_clip_start_reader)
|
||||
|
||||
|
||||
def _qsa_clip_count(
|
||||
layer: Qwen4ExpQSAAttention,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
slot_mapping: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
ctr = _qsa_clip_counters.get(layer.layer_name)
|
||||
if ctr is None:
|
||||
# First forward is eager (profiling/warmup precede capture), so the
|
||||
# allocation never happens inside cudagraph capture. The READER is not
|
||||
# started here: _qsa_clip_start_reader runs after capture.
|
||||
ctr = torch.zeros(2, dtype=torch.int64, device=key.device)
|
||||
_qsa_clip_counters[layer.layer_name] = ctr # registered with the reader by the reader thread (never during capture)
|
||||
# Thresholds are 1-D so the comparison promotes to float32; a 0-dim float32
|
||||
# tensor promotes like a Python scalar and the threshold would be rounded to
|
||||
# the BF16 grid of K/V, undercounting values just above the calibrated ceiling.
|
||||
k_ceiling = (448.0 * layer._k_scale).to(torch.float32).view(1)
|
||||
v_ceiling = (448.0 * layer._v_scale).to(torch.float32).view(1)
|
||||
k_over = key.detach().abs() > k_ceiling
|
||||
v_over = value.detach().abs() > v_ceiling
|
||||
if slot_mapping is not None:
|
||||
# The CUDA writer (reshape_and_cache_flash) skips rows with a negative
|
||||
# slot: padding rows of a captured batch size and, under the V2 model
|
||||
# runner, stale rows of the persistent input buffers. Count only what
|
||||
# is actually written, with fixed-shape masking so the op is capturable.
|
||||
n = min(slot_mapping.shape[0], key.shape[0])
|
||||
written = (slot_mapping[:n] >= 0).view(-1, 1, 1)
|
||||
k_over = k_over[:n] & written
|
||||
v_over = v_over[:n] & written
|
||||
ctr[0] += k_over.sum()
|
||||
ctr[1] += v_over.sum()
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
_QSA_FP8_CACHE_DTYPES = ("fp8", "fp8_e4m3")
|
||||
_QSA_SUPPORTED_CACHE_DTYPES = ("auto", "bfloat16", *_QSA_FP8_CACHE_DTYPES)
|
||||
|
||||
|
||||
def _is_qsa_fp8_cache_dtype(cache_dtype: str) -> bool:
|
||||
return cache_dtype in _QSA_FP8_CACHE_DTYPES
|
||||
|
||||
|
||||
def _validated_qsa_fp8_dtype(cache_dtype: str) -> torch.dtype | None:
|
||||
"""Return the platform E4M3 dtype after enforcing the validated HW gate."""
|
||||
|
||||
if not _is_qsa_fp8_cache_dtype(cache_dtype):
|
||||
return None
|
||||
if not current_platform.is_cuda():
|
||||
raise ValueError(
|
||||
"Qwen4Exp QSA E4M3 KV cache is supported only on CUDA; "
|
||||
"ROCm and other platforms have not been validated"
|
||||
)
|
||||
|
||||
capability = current_platform.get_device_capability()
|
||||
if capability is None or capability.to_int() != 86:
|
||||
cap_str = (
|
||||
capability.as_version_str() if capability is not None else "unknown"
|
||||
)
|
||||
raise ValueError(
|
||||
"Qwen4Exp QSA E4M3 KV cache is validated only on SM86, but "
|
||||
f"{current_platform.get_device_name()} has compute capability "
|
||||
f"{cap_str}. Re-run with --kv-cache-dtype bfloat16."
|
||||
)
|
||||
return current_platform.fp8_dtype()
|
||||
|
||||
|
||||
class Qwen4ExpQSAQKVIndexerLinear(MergedColumnParallelLinear):
|
||||
"""Pack sharded Q/gate, K, V and replicated indexer Q/K in one GEMM."""
|
||||
@@ -135,10 +404,6 @@ class Qwen4ExpQSAFlashAttentionBackend(FlashAttentionBackend):
|
||||
"""FullAttentionSpec backend used by the merged QSA owner."""
|
||||
|
||||
supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16]
|
||||
# fp8/fp8_e4m3: e4m3 bytes in a uint8 cache, written by reshape_and_cache
|
||||
# with the layer's per-tensor scales and dequantized on load inside the QSA
|
||||
# Triton kernel. flash-attn never runs over this cache, so its fp8 probe
|
||||
# does not apply (see supports_kv_cache_dtype and the impl constructor).
|
||||
supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [
|
||||
"auto",
|
||||
"bfloat16",
|
||||
@@ -148,6 +413,12 @@ class Qwen4ExpQSAFlashAttentionBackend(FlashAttentionBackend):
|
||||
|
||||
@classmethod
|
||||
def supports_kv_cache_dtype(cls, kv_cache_dtype: CacheDType | None) -> bool:
|
||||
# The parent additionally probes flash-attn for quantized-KV support
|
||||
# and rejects fp8 wherever FA has no kernel for it (all of sm_86).
|
||||
# QSA never runs flash-attn over this cache: reshape_and_cache_flash
|
||||
# writes the e4m3 bytes and our Triton kernel dequantizes them on load,
|
||||
# so the class list alone decides. See the impl constructor for the
|
||||
# matching "auto"-then-restore of the same probe.
|
||||
return kv_cache_dtype is None or kv_cache_dtype in cls.supported_kv_cache_dtypes
|
||||
|
||||
@classmethod
|
||||
@@ -163,10 +434,36 @@ class Qwen4ExpQSAFlashAttentionBackend(FlashAttentionBackend):
|
||||
use_mm_prefix: bool,
|
||||
device_capability: DeviceCapability,
|
||||
) -> str | None:
|
||||
# QSA dequantizes the fp8 KV in its own Triton kernel and never runs
|
||||
# flash-attn over the quantized cache, so the parent's fp8-KV rejection
|
||||
# does not apply and every combination it is handed is accepted here.
|
||||
return None
|
||||
# Suppress ONLY the parent's fp8 clause ("FP8 KV cache requires FA3 on
|
||||
# SM90 or FA4 on SM100"), which is about flash-attn's own kernels and
|
||||
# so does not apply to a cache QSA reads in Triton. The real device
|
||||
# gate for our fp8 path is `_validated_qsa_fp8_dtype`, which raises on
|
||||
# any capability != 86.
|
||||
#
|
||||
# Everything else the parent rejects still holds and is returned
|
||||
# unchanged: the sink/compute-capability clause and the
|
||||
# mm-prefix/FA-version clause. Returning None for every combination
|
||||
# would advertise fewer restrictions than `forward_qsa` enforces (it
|
||||
# raises on ALiBi, sinks and sliding windows).
|
||||
#
|
||||
# Same shape as the impl constructor: hide the fp8 dtype from the
|
||||
# probe, keep the rest of the parent's behaviour.
|
||||
probe_kv_cache_dtype = kv_cache_dtype
|
||||
if probe_kv_cache_dtype is not None and _is_qsa_fp8_cache_dtype(
|
||||
probe_kv_cache_dtype
|
||||
):
|
||||
probe_kv_cache_dtype = None
|
||||
return super().supports_combination(
|
||||
head_size,
|
||||
dtype,
|
||||
probe_kv_cache_dtype,
|
||||
block_size,
|
||||
use_mla,
|
||||
has_sink,
|
||||
use_sparse,
|
||||
use_mm_prefix,
|
||||
device_capability,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
@@ -214,14 +511,18 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
|
||||
kv_sharing_target_layer_name: str | None = None,
|
||||
sinks: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
# The parent constructor probes flash-attn for quantized-KV support and
|
||||
# raises where it is unavailable (sm120), but QSA dequantizes fp8 inside
|
||||
# its own Triton kernel and never runs flash-attn over the cache. Hand
|
||||
# the parent "auto" for that probe and restore the real dtype afterwards:
|
||||
# the parent only uses it there, and do_kv_cache_update reads the
|
||||
# attribute at call time.
|
||||
# QSA never dispatches to a FlashAttention kernel: attention runs through
|
||||
# forward_qsa -> qsa_sparse_paged_attention (our Triton sparse-GQA kernel)
|
||||
# and the cache write goes through the CUDA reshape_and_cache_flash op.
|
||||
# FlashAttentionImpl.__init__ nevertheless rejects any quantized KV dtype
|
||||
# that FA itself cannot read, and sm_86 has no FP8 FA kernel. Present
|
||||
# "auto" to the base class, then restore the real dtype. The only other
|
||||
# kv_cache_dtype use in that constructor is an SM90/FA4 dequant path that
|
||||
# cannot trigger here, and supports_quant_query_input is set below anyway.
|
||||
# The parameters are spelled out rather than forwarded as *args/**kwargs
|
||||
# so the substitution names the parameter instead of indexing position 6.
|
||||
real_kv_cache_dtype = kv_cache_dtype
|
||||
if kv_cache_dtype in ("fp8", "fp8_e4m3"):
|
||||
if _is_qsa_fp8_cache_dtype(kv_cache_dtype):
|
||||
kv_cache_dtype = "auto"
|
||||
super().__init__(
|
||||
num_heads,
|
||||
@@ -243,10 +544,12 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
|
||||
raise NotImplementedError(
|
||||
"Qwen4Exp QSA does not support decode context parallelism"
|
||||
)
|
||||
if self.kv_cache_dtype not in ("auto", "bfloat16", "fp8", "fp8_e4m3"):
|
||||
if self.kv_cache_dtype not in _QSA_SUPPORTED_CACHE_DTYPES:
|
||||
raise NotImplementedError(
|
||||
"Qwen4Exp QSA requires a BF16 or FP8-e4m3 main KV cache"
|
||||
"Qwen4Exp QSA supports BF16 or static per-tensor E4M3 main "
|
||||
f"KV caches, not {self.kv_cache_dtype!r}"
|
||||
)
|
||||
self.qsa_fp8_dtype = _validated_qsa_fp8_dtype(self.kv_cache_dtype)
|
||||
self.supports_quant_query_input = False
|
||||
|
||||
def forward_qsa(
|
||||
@@ -283,26 +586,42 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
|
||||
logical_indices = topk_buffer[:num_tokens]
|
||||
token_to_req = token_to_req[:num_tokens]
|
||||
key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
|
||||
k_scale = v_scale = None
|
||||
if self.kv_cache_dtype in ("fp8", "fp8_e4m3"):
|
||||
# The cache is allocated as uint8; reinterpret the e4m3 bytes
|
||||
# (same itemsize, so shape and strides are preserved).
|
||||
key_cache = key_cache.view(torch.float8_e4m3fn)
|
||||
value_cache = value_cache.view(torch.float8_e4m3fn)
|
||||
# Host-side per-tensor dequant scales (Python floats), as used by
|
||||
# other host-scale backends; folded into the kernel's scales.
|
||||
k_scale = layer._k_scale_float
|
||||
v_scale = layer._v_scale_float
|
||||
if query.dtype != torch.bfloat16 or key_cache.dtype not in (
|
||||
torch.bfloat16,
|
||||
torch.float8_e4m3fn,
|
||||
):
|
||||
if query.dtype != torch.bfloat16:
|
||||
raise NotImplementedError("Qwen4Exp QSA requires a BF16 query")
|
||||
if key_cache.dtype != value_cache.dtype:
|
||||
raise TypeError("Qwen4Exp QSA K and V cache dtypes must match")
|
||||
if _is_qsa_fp8_cache_dtype(self.kv_cache_dtype):
|
||||
if self.qsa_fp8_dtype is None:
|
||||
raise RuntimeError("QSA FP8 dtype was not initialized")
|
||||
if key_cache.dtype not in (torch.uint8, self.qsa_fp8_dtype):
|
||||
raise NotImplementedError(
|
||||
"Qwen4Exp QSA E4M3 cache storage must use raw uint8 or "
|
||||
f"the platform FP8 dtype, not {key_cache.dtype}"
|
||||
)
|
||||
elif key_cache.dtype != torch.bfloat16:
|
||||
raise NotImplementedError(
|
||||
"Qwen4Exp QSA requires BF16 Q and BF16 or FP8-e4m3 K/V"
|
||||
"Qwen4Exp QSA BF16 mode requires a BF16 main KV cache"
|
||||
)
|
||||
|
||||
from .ops.qsa import qsa_sparse_paged_attention
|
||||
|
||||
# Announce the effective scales once per layer. load_qsa_static_kv_scales()
|
||||
# is an explicit integration call, so a deployment that forgets it runs at
|
||||
# the vLLM default of 1.0 -- which is a legitimate configuration but must
|
||||
# never be a silent one. This makes the actual values greppable per rank.
|
||||
if _is_qsa_fp8_cache_dtype(self.kv_cache_dtype) and not getattr(
|
||||
self, "_qsa_scales_logged", False
|
||||
):
|
||||
self._qsa_scales_logged = True
|
||||
logger.info(
|
||||
"Qwen4Exp QSA E4M3 KV active on %s: k_scale=%.6g v_scale=%.6g%s",
|
||||
getattr(layer, "layer_name", "<unnamed>"),
|
||||
float(layer._k_scale),
|
||||
float(layer._v_scale),
|
||||
"" if float(layer._k_scale) != 1.0 or float(layer._v_scale) != 1.0
|
||||
else " (DEFAULT 1.0 -- no static calibration loaded)",
|
||||
)
|
||||
|
||||
qsa_sparse_paged_attention(
|
||||
query[:num_tokens],
|
||||
key_cache,
|
||||
@@ -311,9 +630,9 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
|
||||
attn_metadata.block_table,
|
||||
token_to_req,
|
||||
use_prefill_config,
|
||||
output[:num_tokens],
|
||||
k_scale=k_scale,
|
||||
v_scale=v_scale,
|
||||
out=output[:num_tokens],
|
||||
k_scale=layer._k_scale,
|
||||
v_scale=layer._v_scale,
|
||||
output_gate=output_gate[:num_tokens],
|
||||
)
|
||||
return output
|
||||
@@ -341,17 +660,20 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
|
||||
raise ValueError("Qwen4Exp QSA requires a paged KV cache")
|
||||
if model_config.dtype != torch.bfloat16:
|
||||
raise NotImplementedError("Qwen4Exp QSA currently requires BF16")
|
||||
if cache_config.cache_dtype not in (
|
||||
"auto",
|
||||
"bfloat16",
|
||||
"fp8",
|
||||
"fp8_e4m3",
|
||||
):
|
||||
if cache_config.cache_dtype not in _QSA_SUPPORTED_CACHE_DTYPES:
|
||||
raise NotImplementedError(
|
||||
"Qwen4Exp QSA requires a BF16 or FP8-e4m3 main KV cache"
|
||||
"Qwen4Exp QSA supports BF16 or static per-tensor E4M3 main "
|
||||
f"KV caches, not {cache_config.cache_dtype!r}"
|
||||
)
|
||||
qsa_fp8_dtype = _validated_qsa_fp8_dtype(cache_config.cache_dtype)
|
||||
kv_cache_scheme = getattr(quant_config, "kv_cache_scheme", None)
|
||||
if kv_cache_scheme is not None:
|
||||
raise NotImplementedError(
|
||||
"Qwen4Exp QSA does not implement kv_cache_scheme variants. "
|
||||
"Dynamic, per-head, per-token, and other non-scalar KV scales "
|
||||
"are unsupported; use static scalar per-layer K/V scales from "
|
||||
"load_qsa_static_kv_scales()."
|
||||
)
|
||||
if getattr(quant_config, "kv_cache_scheme", None) is not None:
|
||||
raise NotImplementedError("Qwen4Exp QSA does not support KV quantization")
|
||||
parallel_config = vllm_config.parallel_config
|
||||
if (
|
||||
parallel_config.prefill_context_parallel_size > 1
|
||||
@@ -448,12 +770,23 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
|
||||
self.kv_cache_torch_dtype = kv_cache_dtype_str_to_dtype(
|
||||
self.kv_cache_dtype, model_config
|
||||
)
|
||||
if self.kv_cache_torch_dtype not in (torch.bfloat16, torch.uint8):
|
||||
if _is_qsa_fp8_cache_dtype(self.kv_cache_dtype):
|
||||
if qsa_fp8_dtype is None:
|
||||
raise RuntimeError("QSA FP8 dtype was not initialized")
|
||||
if self.kv_cache_torch_dtype not in (torch.uint8, qsa_fp8_dtype):
|
||||
raise NotImplementedError(
|
||||
"Qwen4Exp QSA E4M3 cache storage resolved to unsupported "
|
||||
f"dtype {self.kv_cache_torch_dtype}"
|
||||
)
|
||||
elif self.kv_cache_torch_dtype != torch.bfloat16:
|
||||
raise NotImplementedError(
|
||||
"Qwen4Exp QSA requires BF16 or FP8-e4m3 (uint8) cache storage"
|
||||
"Qwen4Exp QSA BF16 mode requires BF16 cache storage"
|
||||
)
|
||||
self.kv_sharing_target_layer_name = None
|
||||
self.kv_cache = torch.tensor([])
|
||||
# The checkpoint has no KV scale scheme. These registered scalar
|
||||
# buffers therefore remain 1.0 unless explicitly replaced after
|
||||
# weight loading by load_qsa_static_kv_scales().
|
||||
set_default_quant_scales(self, register_buffer=True)
|
||||
|
||||
self.attn_backend = Qwen4ExpQSAFlashAttentionBackend
|
||||
@@ -578,6 +911,10 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
|
||||
)
|
||||
if selected.shape != (num_tokens, self.indexer.packed_output_width):
|
||||
raise RuntimeError("QSA indexer returned an invalid selection shape")
|
||||
if _qsa_clip_interval > 0.0 and _is_qsa_fp8_cache_dtype(
|
||||
self.kv_cache_dtype
|
||||
):
|
||||
_qsa_clip_count(self, key, value, main_metadata.slot_mapping)
|
||||
impl = cast(Qwen4ExpQSAFlashAttentionImpl, self.impl)
|
||||
if main_outputs is None:
|
||||
assert key is not None and value is not None
|
||||
@@ -617,34 +954,235 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
|
||||
else:
|
||||
projected_qk, _ = self.indexer.index_qk_proj(hidden_states)
|
||||
num_tokens = hidden_states.shape[0]
|
||||
if not self.use_fused_qsa_prepare:
|
||||
q, k, v, gate = self._project_qkv_gate(qkv, positions)
|
||||
assert gate is not None
|
||||
query = q.view(num_tokens, self.num_heads, self.head_dim)
|
||||
key = k.view(num_tokens, self.num_kv_heads, self.head_dim)
|
||||
value = v.view(num_tokens, self.num_kv_heads, self.head_dim)
|
||||
query = q.view(num_tokens, self.num_heads, self.head_dim)
|
||||
key = k.view(num_tokens, self.num_kv_heads, self.head_dim)
|
||||
value = v.view(num_tokens, self.num_kv_heads, self.head_dim)
|
||||
if _qsa_collect_dir:
|
||||
_qsa_collect_absmax(self.layer_name, key, value)
|
||||
attn_output = torch.empty_like(query)
|
||||
# Keep the index projection outside the eager break / splitting op.
|
||||
projected_qk, _ = self.indexer.index_qk_proj(hidden_states)
|
||||
# Compile-profile guard: the QSA transaction reads per-step attention metadata from
|
||||
# the forward context, so under VLLM_COMPILE it must be opaque to
|
||||
# Dynamo and a piecewise split point (compilation.py _attention_ops).
|
||||
# Restores the op #55272 removed, with #55309's fused output gate.
|
||||
encoded_layer_name = _encode_layer_name(self.layer_name)
|
||||
if current_platform.opaque_attention_op():
|
||||
torch.ops.vllm.qwen4_exp_qsa_with_output(
|
||||
projected_qk,
|
||||
positions,
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
attn_output,
|
||||
gate,
|
||||
encoded_layer_name,
|
||||
)
|
||||
else:
|
||||
# Norm/RoPE/gate and the K/V cache write happen inside _run_qsa.
|
||||
query = key = value = gate = None
|
||||
attn_output = qkv.new_empty(num_tokens, self.num_heads, self.head_dim)
|
||||
self._run_qsa(
|
||||
projected_qk,
|
||||
positions,
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
attn_output,
|
||||
gate,
|
||||
qkv,
|
||||
)
|
||||
qwen4_exp_qsa_with_output(
|
||||
projected_qk,
|
||||
positions,
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
attn_output,
|
||||
gate,
|
||||
encoded_layer_name,
|
||||
)
|
||||
flat_output = attn_output.view(num_tokens, -1)
|
||||
output, _ = self.o_proj(flat_output)
|
||||
return output
|
||||
|
||||
|
||||
def load_qsa_static_kv_scales(
|
||||
layers: nn.Module | Mapping[str, nn.Module],
|
||||
sidecar_path: str | Path,
|
||||
*,
|
||||
strict: bool = True,
|
||||
expected_layer_names: Collection[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Load static scalar QSA K/V scales into already-loaded model layers.
|
||||
|
||||
Call this on every model worker after checkpoint weight loading finishes
|
||||
and before any profiling, warmup, or cudagraph capture. ``layers`` may be
|
||||
the loaded root module or ``compilation_config.static_forward_context``.
|
||||
|
||||
The JSON schema is::
|
||||
|
||||
{
|
||||
"model.layers.3.self_attn.attn": {
|
||||
"k_scale": 0.25,
|
||||
"v_scale": 2.0
|
||||
}
|
||||
}
|
||||
|
||||
``strict=False`` permits a deliberate subset; omitted layers keep the
|
||||
vLLM default scale of 1.0. Unknown names and non-scalar values are always
|
||||
errors.
|
||||
|
||||
Pipeline parallelism: a rank holds only its stage's layers, so the sidecar
|
||||
legitimately names layers this rank does not own. Pass
|
||||
``expected_layer_names`` = the QSA layer names of the WHOLE model; names are
|
||||
validated against that set, ``strict`` requires the sidecar to cover all
|
||||
of it (checkable on every rank), and the scales are applied to the local
|
||||
intersection only. Without it (TP-only callers) the local layers are the
|
||||
universe, as before.
|
||||
"""
|
||||
|
||||
path = Path(sidecar_path)
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{path} must contain a JSON object keyed by layer name")
|
||||
|
||||
if isinstance(layers, nn.Module):
|
||||
qsa_layers = {
|
||||
layer.layer_name: layer
|
||||
for layer in layers.modules()
|
||||
if isinstance(layer, Qwen4ExpQSAAttention)
|
||||
}
|
||||
else:
|
||||
qsa_layers = {
|
||||
name: layer
|
||||
for name, layer in layers.items()
|
||||
if isinstance(name, str)
|
||||
and isinstance(layer, Qwen4ExpQSAAttention)
|
||||
}
|
||||
|
||||
if not qsa_layers and expected_layer_names is None:
|
||||
raise ValueError("No Qwen4Exp QSA attention layers were supplied")
|
||||
non_fp8_layers = sorted(
|
||||
name
|
||||
for name, layer in qsa_layers.items()
|
||||
if not _is_qsa_fp8_cache_dtype(layer.kv_cache_dtype)
|
||||
)
|
||||
if non_fp8_layers:
|
||||
raise ValueError(
|
||||
"Static QSA KV scales may only be injected into E4M3 layers; "
|
||||
f"non-FP8 layers: {non_fp8_layers}"
|
||||
)
|
||||
|
||||
if expected_layer_names is None:
|
||||
universe = set(qsa_layers)
|
||||
else:
|
||||
universe = set(expected_layer_names)
|
||||
not_expected = sorted(name for name in qsa_layers if name not in universe)
|
||||
if not_expected:
|
||||
raise ValueError(
|
||||
"Local QSA layers outside expected_layer_names: "
|
||||
f"{not_expected}"
|
||||
)
|
||||
unknown = sorted(name for name in raw if name not in universe)
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown QSA layer names in {path}: {unknown}")
|
||||
missing = sorted(name for name in universe if name not in raw)
|
||||
if strict and missing:
|
||||
raise ValueError(f"Missing QSA layer scales in {path}: {missing}")
|
||||
remote = sorted(name for name in raw if name not in qsa_layers)
|
||||
if remote:
|
||||
logger.info(
|
||||
"Qwen4Exp QSA: %d sidecar layer(s) belong to other pipeline stages "
|
||||
"and are skipped on this rank: %s%s",
|
||||
len(remote),
|
||||
", ".join(remote[:4]),
|
||||
" ..." if len(remote) > 4 else "",
|
||||
)
|
||||
|
||||
validated: dict[str, tuple[float, float]] = {}
|
||||
for layer_name, entry in raw.items():
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(f"{layer_name} scales must be a JSON object")
|
||||
if set(entry) != {"k_scale", "v_scale"}:
|
||||
raise ValueError(
|
||||
f"{layer_name} must contain exactly k_scale and v_scale"
|
||||
)
|
||||
|
||||
values: list[float] = []
|
||||
for field in ("k_scale", "v_scale"):
|
||||
value = entry[field]
|
||||
if type(value) not in (int, float):
|
||||
raise ValueError(
|
||||
f"{layer_name}.{field} must be a scalar JSON number"
|
||||
)
|
||||
scale = float(value)
|
||||
if not math.isfinite(scale) or scale <= 0.0:
|
||||
raise ValueError(
|
||||
f"{layer_name}.{field} must be finite and positive"
|
||||
)
|
||||
values.append(scale)
|
||||
|
||||
layer = qsa_layers.get(layer_name)
|
||||
if layer is None:
|
||||
continue # another pipeline stage's layer: schema-checked only
|
||||
for field in ("_k_scale", "_v_scale"):
|
||||
tensor = getattr(layer, field, None)
|
||||
if not isinstance(tensor, torch.Tensor) or tensor.numel() != 1:
|
||||
raise ValueError(
|
||||
f"{layer_name}.{field} must be an existing scalar tensor"
|
||||
)
|
||||
validated[layer_name] = (values[0], values[1])
|
||||
|
||||
with torch.no_grad():
|
||||
for layer_name, (k_scale, v_scale) in validated.items():
|
||||
layer = qsa_layers[layer_name]
|
||||
layer._k_scale.fill_(k_scale)
|
||||
layer._v_scale.fill_(v_scale)
|
||||
|
||||
return sorted(validated)
|
||||
|
||||
|
||||
def qwen4_exp_qsa_with_output(
|
||||
projected_qk: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
output: torch.Tensor,
|
||||
output_gate: torch.Tensor,
|
||||
layer_name: LayerNameType,
|
||||
) -> None:
|
||||
"""Run the complete QSA state/update/attend transaction."""
|
||||
|
||||
layer_name = _resolve_layer_name(layer_name)
|
||||
layer = get_forward_context().no_compile_layers[layer_name]
|
||||
if not isinstance(layer, Qwen4ExpQSAAttention):
|
||||
raise TypeError(f"{layer_name} is not a Qwen4Exp QSA owner")
|
||||
layer._run_qsa(
|
||||
projected_qk,
|
||||
positions,
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
output,
|
||||
output_gate,
|
||||
)
|
||||
|
||||
|
||||
def qwen4_exp_qsa_with_output_fake(
|
||||
projected_qk: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
output: torch.Tensor,
|
||||
output_gate: torch.Tensor,
|
||||
layer_name: LayerNameType,
|
||||
) -> None:
|
||||
del projected_qk, positions, query, key, value, output, output_gate, layer_name
|
||||
|
||||
|
||||
direct_register_custom_op(
|
||||
op_name="qwen4_exp_qsa_with_output",
|
||||
op_func=qwen4_exp_qsa_with_output,
|
||||
mutates_args=["output"],
|
||||
fake_impl=qwen4_exp_qsa_with_output_fake,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QSAIndexer",
|
||||
"Qwen4ExpQSAAttention",
|
||||
"Qwen4ExpQSAFlashAttentionBackend",
|
||||
"Qwen4ExpQSAFlashAttentionImpl",
|
||||
"load_qsa_static_kv_scales",
|
||||
"qwen4_exp_qsa_with_output",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user