Files
vllm-project--vllm/vllm/model_executor/layers/hpc/rope_norm.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

417 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""HPC fused RoPE + QK-Norm + KV-Cache-Write (+ optional FP8 Q quant).
Decoupled from HpcAttentionImpl; extra params are passed via layer attrs.
"""
from __future__ import annotations
import importlib.util
from enum import IntEnum
from typing import Any
import torch
from vllm.config import get_current_vllm_config_or_none
from vllm.forward_context import ForwardContext, get_forward_context
from vllm.logger import init_logger
from vllm.model_executor.custom_op import CustomOp
from vllm.model_executor.layers.hpc.hpc_module import HpcModule
from vllm.utils.torch_utils import direct_register_custom_op
from vllm.v1.attention.backends.hpc_attn import HpcAttnMetadata
from vllm.v1.attention.backends.registry import AttentionBackendEnum
logger = init_logger(__name__)
_hpc_rope_norm_instances: dict[str, HpcRopeNorm] = {}
class QkNormPolicy(IntEnum):
"""Order of QK-RMSNorm relative to RoPE in the fused HPC rope_norm kernel.
The values are part of the HPC kernel ABI (passed through as ints), so they
must stay in sync with the kernel's expectations.
"""
# No QK-Norm: apply RoPE only.
NONE = 0
# Apply RoPE first, then QK-RMSNorm.
ROPE_THEN_NORM = 1
# Apply QK-RMSNorm first, then RoPE (e.g. HunYuan V3).
NORM_THEN_ROPE = 2
def hpc_rope_norm_forward(
qkv: torch.Tensor,
output: torch.Tensor,
layer_name: str,
) -> None:
"""Top-level custom op: RoPE + QK-Norm + KV-Cache-Write + FP8 Q quant.
Fully opaque to torch.compile (dynamo).
"""
forward_context: ForwardContext = get_forward_context()
attn_metadata: Any = forward_context.attn_metadata
if isinstance(attn_metadata, dict):
attn_metadata = attn_metadata[layer_name]
if attn_metadata is None:
output.zero_()
return
attn_layer = forward_context.no_compile_layers[layer_name]
# bind_kv_cache stores the per-layer KV cache as a single 5D tensor
# (num_blocks, 2, block_size, num_kv_heads, head_size), so use it directly.
kv_cache = attn_layer.kv_cache
if kv_cache.numel() == 0:
output.zero_()
return
assert kv_cache.dim() == 5, (
f"Expected kv_cache to have 5 dims, got {tuple(kv_cache.shape)}"
)
rope_norm = _hpc_rope_norm_instances[layer_name]
rope_norm._forward_impl(qkv, kv_cache, attn_metadata, attn_layer, output)
def hpc_rope_norm_forward_fake(
qkv: torch.Tensor,
output: torch.Tensor,
layer_name: str,
) -> None:
"""Fake impl for torch.compile trace; output is a mutated arg."""
return
direct_register_custom_op(
op_name="hpc_rope_norm_forward",
op_func=hpc_rope_norm_forward,
mutates_args=["output"],
fake_impl=hpc_rope_norm_forward_fake,
)
@CustomOp.register("hpc_rope_norm")
class HpcRopeNorm(CustomOp, HpcModule):
"""HPC fused RoPE + QK-Norm + KV-Cache-Write (+ optional FP8 Q quant).
Registered as a sub-module in model layers (e.g. HunYuanAttention).
Norm weights are extracted from fallback norm modules via
process_weights_after_loading() after all weights are loaded.
forward() is dispatched by CustomOp framework:
- In compiled mode: forward_cuda() calls torch.ops.vllm.hpc_rope_norm_forward
as a splitting point — internal Python control flow is opaque
to torch.compile and not captured by CUDA Graph.
- In eager/native mode: forward_native() falls back to forward_cuda().
"""
def __init__(
self,
num_heads: int,
num_kv_heads: int,
head_dim: int,
cos_sin_cache: torch.Tensor,
use_qk_norm: bool,
fallback_qnorm: torch.nn.Module | None,
fallback_knorm: torch.nn.Module | None,
kv_cache_dtype: str,
layer_name: str,
qk_norm_policy: QkNormPolicy = QkNormPolicy.ROPE_THEN_NORM,
) -> None:
super().__init__()
if importlib.util.find_spec("hpc") is None:
raise ImportError(
"HPCRopeNorm requires the hpc module to be installed. "
"Please install it from https://github.com/Tencent/hpc-ops"
)
self.num_heads = num_heads
self.num_kv_heads = num_kv_heads
self.head_dim = head_dim
self.use_qk_norm = use_qk_norm
self.q_size = num_heads * head_dim
self.kv_size = num_kv_heads * head_dim
# Register as a non-persistent buffer so it participates in sleep
# level-2 save/restore (CuMemAllocator) but is excluded from the
# checkpoint state_dict.
self.register_buffer("cos_sin_cache", cos_sin_cache.float(), persistent=False)
self.fallback_qnorm = fallback_qnorm
self.fallback_knorm = fallback_knorm
self.head_per_group = num_heads // num_kv_heads
# Pre-allocate norm weight tensors as Parameters so they are tracked by
# CuMemAllocator (for sleep/wake_up) and have stable addresses for CUDA
# Graph replay. process_weights_after_loading() updates them inplace via
# copy_() so refit does not invalidate captured graph tensor pointers.
# Shape is [head_dim] to match the HPC kernel's q/k_norm_weight layout.
if use_qk_norm and fallback_qnorm is not None:
self.qnorm_weight: torch.nn.Parameter | None = torch.nn.Parameter(
torch.empty(head_dim, dtype=torch.float32),
requires_grad=False,
)
else:
self.qnorm_weight = None
if use_qk_norm and fallback_knorm is not None:
self.knorm_weight: torch.nn.Parameter | None = torch.nn.Parameter(
torch.empty(head_dim, dtype=torch.float32),
requires_grad=False,
)
else:
self.knorm_weight = None
self.use_fp8 = "fp8" in kv_cache_dtype
# The RMSNorm/RoPE ordering is model dependent (e.g. HunYuan V3 applies
# QK-Norm before RoPE -> NORM_THEN_ROPE), so it is supplied by the
# caller. When QK-Norm is disabled the policy is forced to NONE.
self.qk_norm_policy = qk_norm_policy if use_qk_norm else QkNormPolicy.NONE
# Register layer_name + add self to the global instance registry so the
# module-level custom op (hpc_rope_norm_forward) can route back here.
self.layer_name: str | None = None
self.register_layer_name(layer_name)
import hpc
if self.use_fp8:
self._quant_type = (
hpc.QuantType.QPERTOKEN_PERHEAD_KPERTENSOR_VPERTENSOR.value
)
else:
self._quant_type = None
@classmethod
def support(
cls,
num_heads: int,
num_kv_heads: int,
head_dim: int,
kv_cache_dtype: str,
) -> bool:
"""Check whether HpcRopeNorm is supported for the given config."""
# HpcRopeNorm is only enabled together with the HPC attention backend.
vllm_config = get_current_vllm_config_or_none()
if (
vllm_config is None
or vllm_config.attention_config.backend != AttentionBackendEnum.HPC_ATTN
):
return False
if kv_cache_dtype not in ("fp8_e4m3", "auto"):
logger.warning_once(
f"hpc rope_norm not support kv_cache_dtype:{kv_cache_dtype}, "
"only support fp8_e4m3, bfloat16"
)
return False
if head_dim not in (128,):
logger.warning_once("hpc rope_norm only support head_dim == 128.")
return False
head_per_group = num_heads // num_kv_heads
if head_per_group not in (4, 8):
logger.warning_once("hpc rope_norm only support head_per_group in [4, 8].")
return False
logger.info_once("enable hpc rope_norm")
return True
def process_weights_after_loading(self, model: torch.nn.Module = None) -> None:
"""Copy norm weights (float32) from fallback norm modules inplace.
Uses copy_() to preserve tensor addresses for CUDA Graph / refit
compatibility. Called by the model's load_weights() after all weights
are loaded (and generically from the model loader for DummyModelLoader
/ sleep-wake_up reload paths).
"""
if self.use_qk_norm:
if self.fallback_qnorm is not None and self.qnorm_weight is not None:
self.qnorm_weight.data.copy_(self.fallback_qnorm.weight.data.float())
if self.fallback_knorm is not None and self.knorm_weight is not None:
self.knorm_weight.data.copy_(self.fallback_knorm.weight.data.float())
def register_layer_name(self, layer_name: str) -> None:
"""Register layer_name and add self to the global registry.
The global registry is needed because the bottom-level torch op
(hpc_rope_norm_forward) is a module-level function and needs to
route back to the correct instance via layer_name.
"""
self.layer_name = layer_name
_hpc_rope_norm_instances[layer_name] = self
logger.debug(
"[rope_norm] registered HpcRopeNorm for layer: %s",
layer_name,
)
def forward_native(
self,
qkv: torch.Tensor,
layer_name: str,
) -> torch.Tensor:
"""Native fallback path: delegates to forward_cuda().
For now, the default native path will use CUDA backend path.
Other platforms may override via OOT registration.
"""
return self.forward_cuda(qkv, layer_name)
def forward_cuda(
self,
qkv: torch.Tensor,
layer_name: str,
) -> torch.Tensor:
"""CUDA path: invoke the torch custom op as a compile splitting point."""
num_tokens = qkv.shape[0]
output = torch.empty(
(num_tokens, self.num_heads, self.head_dim),
dtype=torch.float8_e4m3fn if self.use_fp8 else qkv.dtype,
device=qkv.device,
)
torch.ops.vllm.hpc_rope_norm_forward(qkv, output, layer_name)
return output
def _forward_impl(
self,
qkv: torch.Tensor,
kv_cache: torch.Tensor,
attn_metadata: HpcAttnMetadata,
attn_layer: torch.nn.Module,
output: torch.Tensor,
) -> None:
"""Actual forward logic called by the custom op.
Writes processed q into *output* and attaches extra params
(e.g. FP8 scales) to *attn_layer* as attributes.
"""
import hpc
num_actual_tokens = attn_metadata.num_actual_tokens
num_prefill_reqs = attn_metadata.num_prefills
num_decode_reqs = attn_metadata.num_decodes
num_decode_tokens = attn_metadata.num_decode_tokens
qkv = qkv[:num_actual_tokens]
num_prefill_tokens = num_actual_tokens - num_decode_tokens
# KV cache for the FP8 path is stored as uint8; view it as fp8 so the
# rope_norm_store_kv_fp8 kernel can write quantized K/V in-place.
if self.use_fp8:
kv_cache = kv_cache.view(torch.float8_e4m3fn)
# Per-tensor K/V scales (shape [1]) used by the FP8 kernel.
k_scale = attn_layer._k_scale.reshape(1)
v_scale = attn_layer._v_scale.reshape(1)
q_norm_weight = (
self.qnorm_weight if self.qk_norm_policy != QkNormPolicy.NONE else None
)
k_norm_weight = (
self.knorm_weight if self.qk_norm_policy != QkNormPolicy.NONE else None
)
# --- Prefill ---
if num_prefill_reqs > 0:
seq_lens_prefill = attn_metadata.seq_lens[num_decode_reqs:]
cu_seqlens_prefill = attn_metadata.qo_indptr
max_seqlens = attn_metadata.max_query_len
block_table_prefill = attn_metadata.block_table_tensor[num_decode_reqs:]
qkv_prefill = qkv[num_decode_tokens:]
out_q_prefill = output[
num_decode_tokens : num_decode_tokens + num_prefill_tokens
]
if self.use_fp8:
_, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8(
key_cache=kv_cache[:, 0],
value_cache=kv_cache[:, 1],
qkv=qkv_prefill,
cos_sin=self.cos_sin_cache,
num_seqlen_per_req=seq_lens_prefill,
q_index=cu_seqlens_prefill,
kvcache_indices=block_table_prefill,
is_prefill=True,
k_scale=k_scale,
v_scale=v_scale,
quant_policy=self._quant_type,
max_seqlens=max_seqlens,
q_norm_weight=q_norm_weight,
k_norm_weight=k_norm_weight,
qk_norm_policy=self.qk_norm_policy,
out_q=out_q_prefill,
)
attn_metadata.hpc_prefill_q_scale = q_scale
else:
hpc.rope_norm_store_kv(
kv_cache[:, 0],
kv_cache[:, 1],
qkv_prefill,
self.cos_sin_cache,
seq_lens_prefill,
cu_seqlens_prefill,
block_table_prefill,
True, # is_prefill
q_norm_weight=q_norm_weight,
k_norm_weight=k_norm_weight,
out_q=out_q_prefill,
qk_norm_policy=self.qk_norm_policy,
)
# --- Decode ---
if num_decode_reqs > 0:
num_seq_kvcache = attn_metadata.seq_lens[:num_decode_reqs]
block_table_decode = attn_metadata.block_table_tensor[:num_decode_reqs]
qkv_decode = qkv[:num_decode_tokens]
# Single-token decode: q_index is the per-request prefix sum
# [0, 1, ..., num_decode_reqs].
decode_query_len = attn_metadata.decode_query_len
out_q_decode = output[:num_decode_tokens]
if self.use_fp8:
_, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8(
key_cache=kv_cache[:, 0],
value_cache=kv_cache[:, 1],
qkv=qkv_decode,
cos_sin=self.cos_sin_cache,
num_seqlen_per_req=num_seq_kvcache,
q_index=attn_metadata.qo_indptr_decode,
kvcache_indices=block_table_decode,
is_prefill=False,
k_scale=k_scale,
v_scale=v_scale,
quant_policy=self._quant_type,
max_seqlens=decode_query_len,
q_norm_weight=q_norm_weight,
k_norm_weight=k_norm_weight,
qk_norm_policy=self.qk_norm_policy,
out_q=out_q_decode,
)
attn_metadata.hpc_decode_q_scale = q_scale
if split_k_flag is not None:
attn_metadata.hpc_split_k_flag = split_k_flag
else:
hpc.rope_norm_store_kv(
kv_cache[:, 0],
kv_cache[:, 1],
qkv_decode,
self.cos_sin_cache,
num_seq_kvcache,
attn_metadata.qo_indptr_decode,
block_table_decode,
False, # is_prefill
q_norm_weight=q_norm_weight,
k_norm_weight=k_norm_weight,
out_q=out_q_decode,
qk_norm_policy=self.qk_norm_policy,
)