From 0a6d1930c36079dc08aeee6df41be5ebef195f39 Mon Sep 17 00:00:00 2001 From: Xiaoyu Zhang <1182563586@qq.com> Date: Wed, 22 Jul 2026 22:06:22 +0800 Subject: [PATCH] [Attention Backend] Add HPC-Ops attention backend (#30540) Co-authored-by: Claude Fable 5 Co-authored-by: Halcyon <56064364+VAthree@users.noreply.github.com> --- .../advanced_features/attention_backend.mdx | 33 +- python/sglang/srt/arg_groups/overrides.py | 10 + .../layers/attention/attention_registry.py | 17 + .../srt/layers/attention/hpc_ops_backend.py | 674 ++++++++++++++++++ python/sglang/srt/models/hunyuan_v3.py | 56 ++ python/sglang/srt/server_args.py | 1 + 6 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 python/sglang/srt/layers/attention/hpc_ops_backend.py diff --git a/docs_new/docs/advanced_features/attention_backend.mdx b/docs_new/docs/advanced_features/attention_backend.mdx index 5010de17c..a944751ab 100644 --- a/docs_new/docs/advanced_features/attention_backend.mdx +++ b/docs_new/docs/advanced_features/attention_backend.mdx @@ -122,6 +122,16 @@ The support matrix is split into two parts: MHA (standard attention) and MLA (mu ❌ ❌ + + **HPC-Ops** + 64 + ✅ + ❌ + ❌ + ❌ + ❌ + ❌ + **AITER (ROCm)** ✅ @@ -312,7 +322,7 @@ Speculative decoding topk: `topk` is the number of draft tokens sampled per step Page size controls how many tokens are grouped into a KV cache block. For the prefix cache to take effect, the number of tokens must fill at least one complete page. For example, if your prompt is only 32 tokens and `page_size = 64`, it won't fill a complete page and cannot be matched in the prefix cache (pages cannot be padded). With 65 tokens and `page_size = 64`, only the first page of 64 tokens will be cached and matched; the remaining 1 token is discarded. Use `page_size = 1` for maximum prefix reuse (token-level matching). Note that higher page sizes generally improve attention kernel performance, so prefer `page_size > 1` when prefix cache reuse is not critical. -Many backends that do not natively operate on pages can emulate `page_size > 1` at the wrapper layer by expanding page tables to per-token indices. The "Page Size > 1 (native)" column indicates true in-kernel paging. Some backends require fixed native page sizes and cannot be reduced/emulated differently: TRTLLM MHA (16/32/64), TRTLLM MLA (32/64), CuteDSL MLA (32/64), FlashMLA (64), Cutlass MLA (128), Ascend (128). +Many backends that do not natively operate on pages can emulate `page_size > 1` at the wrapper layer by expanding page tables to per-token indices. The "Page Size > 1 (native)" column indicates true in-kernel paging. Some backends require fixed native page sizes and cannot be reduced/emulated differently: TRTLLM MHA (16/32/64), TRTLLM MLA (32/64), CuteDSL MLA (32/64), FlashMLA (64), Cutlass MLA (128), Ascend (128), HPC-Ops (64). MLA page-size constraints: - FlashInfer MLA: page_size = 1. @@ -617,6 +627,27 @@ python3 -m sglang.launch_server \ --trust-remote-code ``` +- HPC-Ops (MHA kernels from [HPC-Ops](https://github.com/Tencent/hpc-ops) by the Tencent Hunyuan AI Infra team; Hopper+, requires installing the `hpc` package from source, page size 64, bf16 or fp8_e4m3 KV cache, head_dim 128, q/kv head group 4 or 8) +```bash Command +python3 -m sglang.launch_server \ + --model Qwen/Qwen3-30B-A3B-Instruct-2507-FP8 \ + --attention-backend hpc_ops \ + --page-size 64 \ + --trust-remote-code + +# FP8 models should also set --kv-cache-dtype fp8_e4m3 to run the FP8 attention +# kernels. This enables the fused QKNorm+RoPE+FP8-quant+StoreKV op, which is +# currently wired for Hunyuan V3 and requires per-rank (q_heads, kv_heads) of +# (64, 8) or (8, 1), e.g. --tp 1 or --tp 8 for Hy3. +python3 -m sglang.launch_server \ + --tp 8 \ + --model tencent/Hy3-FP8 \ + --attention-backend hpc_ops \ + --kv-cache-dtype fp8_e4m3 \ + --page-size 64 \ + --trust-remote-code +``` + - FlashAttention 4 (MHA & MLA) ```bash Command # FA4 for both prefill and decode on SM90/SM100 diff --git a/python/sglang/srt/arg_groups/overrides.py b/python/sglang/srt/arg_groups/overrides.py index 8a60582e5..3f46f5ac9 100644 --- a/python/sglang/srt/arg_groups/overrides.py +++ b/python/sglang/srt/arg_groups/overrides.py @@ -1737,6 +1737,16 @@ def _mla_backend_page_constraints(view: Any) -> dict: f"TensorRT-LLM MHA only supports page_size of 16, 32 or 64, changing page_size from {page_size} to 64." ) page_size = 64 + if ( + view.attention_backend == "hpc_ops" + or view.decode_attention_backend == "hpc_ops" + or view.prefill_attention_backend == "hpc_ops" + ): + if page_size != 64: + logger.warning( + f"HPC-Ops attention only supports a page_size of 64, changing page_size from {page_size} to 64." + ) + page_size = 64 if page_size != view.page_size: return {"page_size": page_size} return {} diff --git a/python/sglang/srt/layers/attention/attention_registry.py b/python/sglang/srt/layers/attention/attention_registry.py index 8ca793357..7b511bdd5 100644 --- a/python/sglang/srt/layers/attention/attention_registry.py +++ b/python/sglang/srt/layers/attention/attention_registry.py @@ -244,6 +244,23 @@ def create_trtllm_mha_backend(runner): return TRTLLMHAAttnBackend(runner) +@register_attention_backend("hpc_ops") +def create_hpc_ops_backend(runner): + if runner.use_mla_backend: + raise ValueError("hpc_ops backend can only be used with non-MLA models.") + if runner.model_config.is_encoder_decoder: + raise ValueError( + "Cross attention is not supported in the hpc_ops attention backend." + ) + if runner.server_args.speculative_algorithm is not None: + raise ValueError( + "hpc_ops backend does not support speculative decoding for now." + ) + from sglang.srt.layers.attention.hpc_ops_backend import HPCOpsAttnBackend + + return HPCOpsAttnBackend(runner) + + @register_attention_backend("intel_amx") def create_intel_amx_backend(runner): from sglang.srt.layers.attention.intel_amx_backend import IntelAMXAttnBackend diff --git a/python/sglang/srt/layers/attention/hpc_ops_backend.py b/python/sglang/srt/layers/attention/hpc_ops_backend.py new file mode 100644 index 000000000..69c630e5a --- /dev/null +++ b/python/sglang/srt/layers/attention/hpc_ops_backend.py @@ -0,0 +1,674 @@ +from __future__ import annotations + +""" +Attention backend powered by HPC-Ops (https://github.com/Tencent/hpc-ops), +a production-grade operator library for LLM inference developed by the +Tencent Hunyuan AI Infra team. + +The backend wraps the paged MHA kernels ``attention_with_kvcache_prefill_bf16`` +(extend) and ``attention_decode_bf16`` (decode). It reads the SGLang +token-major KV pool as a paged NHD cache ``(num_pages, page_size, num_kv_heads, +head_dim)`` without any copy, so the only hard requirements are the kernel +constraints: + +- NVIDIA Hopper or newer (sm90+) +- ``--page-size 64`` +- bf16 model dtype; bf16 or fp8_e4m3 KV cache (the FP8 path additionally + requires the model to run the fused QKNorm+RoPE+quant+StoreKV op via + ``fused_qk_rope_store_kv_fp8`` — wired for HunYuan V3) +- head_dim == 128 and num_q_heads // num_kv_heads in {4, 8} +- default softmax scaling (``head_dim ** -0.5``), no sliding window, + no logit cap, decoder-only attention + +Note that the HPC-Ops kernels are currently tuned primarily for H20: on other +GPUs (H100/H200/B200, ...) the speedup over the default attention backend may +be limited or absent. + +Enable it explicitly with ``--attention-backend hpc_ops``. +""" + +import functools +import importlib.util +from typing import TYPE_CHECKING, Optional + +import msgspec +import torch + +from sglang.kernels.ops.kvcache.trtllm_mha_graph_metadata import ( + update_trtllm_mha_graph_metadata, +) +from sglang.kernels.ops.kvcache.trtllm_mha_page_table import ( + build_trtllm_mha_page_table, +) +from sglang.srt.compilation.compilation_config import register_split_op +from sglang.srt.layers.attention.base_attn_backend import AttentionBackend +from sglang.srt.mem_cache.memory_pool import KVWriteLoc +from sglang.srt.model_executor.forward_batch_info import ForwardBatch +from sglang.srt.model_executor.forward_context import get_attn_backend +from sglang.srt.model_executor.runner_backend_utils.breakable_cuda_graph import ( + eager_on_graph, + is_in_breakable_cuda_graph, +) +from sglang.srt.model_executor.runner_backend_utils.tc_piecewise_cuda_graph import ( + get_tc_piecewise_forward_context, +) +from sglang.srt.utils.custom_op import register_custom_op + +if TYPE_CHECKING: + from sglang.srt.layers.radix_attention import RadixAttention + from sglang.srt.model_executor.model_runner import ModelRunner + +# (num_q_heads // num_kv_heads) values supported by the HPC-Ops MHA kernels. +_SUPPORTED_GQA_GROUP_SIZES = (4, 8) +_SUPPORTED_HEAD_DIM = 128 +_REQUIRED_PAGE_SIZE = 64 +# The fused QKNorm+RoPE+FP8-quant+StoreKV kernel is specialized per +# (num_q_heads, num_kv_heads); only these shard shapes exist today. +FP8_ROPE_SUPPORTED_HEAD_CONFIGS = ((8, 1), (64, 8)) +# Minimum tokens each SM processes per task in the dynamic-scheduled decode +# path (matches the HPC-Ops default). +_FP8_DYNAMIC_SCHED_MIN_PROCESS_LEN = 512 + + +@functools.cache +def has_hpc_ops() -> bool: + """Return True if the ``hpc`` package (HPC-Ops) is installed.""" + return importlib.util.find_spec("hpc") is not None + + +class HPCOpsMetadata(msgspec.Struct): + """Per-forward-batch metadata consumed by the HPC-Ops kernels.""" + + # KV cache length per request (including tokens written this iteration). + # shape: [batch_size], int32 + cache_seqlens_int32: Optional[torch.Tensor] = None + # Cumulative query lengths, extend mode only. shape: [batch_size + 1], int32 + cu_seqlens_q: Optional[torch.Tensor] = None + # Cumulative KV lengths. Only used as a scratch output of the fused + # cuda-graph metadata kernel. shape: [batch_size + 1], int32 + cu_seqlens_k: Optional[torch.Tensor] = None + # Paged KV block table. shape: [batch_size, max_num_pages], int32 + page_table: Optional[torch.Tensor] = None + # Maximum query length among the batch (1 for decode). + max_seq_len_q: int = 1 + + # --- FP8 pass-through fields --- + # Written by fused_qk_rope_store_kv_fp8() and consumed by the attention + # call of the same layer (the fused RoPE op runs right before attention, + # so the fields always hold the current layer's scales). + # Dynamic per-token-per-head Q scale. + # shape: extend [bs, num_q_heads, max_seq_len_q_pad128]; decode [tokens, num_q_heads] + hpc_q_scale: Optional[torch.Tensor] = None + # Split-K flag tensor for FP8 decode. shape: [bs, num_kv_heads], int32 + hpc_split_k_flag: Optional[torch.Tensor] = None + # Pre-scheduled decode task map (dynamic-scheduled FP8 decode); None falls + # back to the kernel's static split-K scheduling. + hpc_task_map: Optional[torch.Tensor] = None + + +class HPCOpsAttnBackend(AttentionBackend): + """HPC-Ops paged MHA attention backend (bf16 or fp8_e4m3 KV cache).""" + + # The page table is built on-device from seq_lens, so the D2H sync for + # seq_lens_cpu is not needed (same as trtllm_mha / triton). + needs_cpu_seq_lens: bool = False + + def __init__(self, model_runner: ModelRunner): + super().__init__() + + if not has_hpc_ops(): + raise ImportError( + "The hpc_ops attention backend requires the `hpc` package. " + "Install it from https://github.com/Tencent/hpc-ops" + ) + + if model_runner.spec_algorithm.is_speculative(): + raise ValueError( + "The hpc_ops attention backend does not support speculative " + "decoding (MTP/EAGLE) yet." + ) + + self.page_size = model_runner.page_size + if self.page_size != _REQUIRED_PAGE_SIZE: + raise ValueError( + f"The hpc_ops attention backend requires --page-size " + f"{_REQUIRED_PAGE_SIZE}, got {self.page_size}." + ) + if model_runner.kv_cache_dtype not in (torch.bfloat16, torch.float8_e4m3fn): + raise ValueError( + "The hpc_ops attention backend only supports bf16 or fp8_e4m3 " + f"KV cache, got {model_runner.kv_cache_dtype}." + ) + # The FP8 path needs per-token-per-head Q scales that only the fused + # HPC-Ops QKNorm+RoPE+quant+StoreKV op produces, so it requires the + # model to call fused_qk_rope_store_kv_fp8() (wired for HunYuan V3). + self.use_fp8 = model_runner.kv_cache_dtype == torch.float8_e4m3fn + if self.use_fp8: + heads = ( + model_runner.model_config.num_attention_heads + // model_runner.ps.tp_size, + model_runner.model_config.get_num_kv_heads(model_runner.ps.tp_size), + ) + if heads not in FP8_ROPE_SUPPORTED_HEAD_CONFIGS: + raise ValueError( + "The hpc_ops FP8 KV cache path requires per-rank " + f"(num_q_heads, num_kv_heads) in " + f"{FP8_ROPE_SUPPORTED_HEAD_CONFIGS} (the fused RoPE+quant " + f"kernel is specialized per shape), got {heads}. Adjust " + "--tp accordingly or use --kv-cache-dtype bfloat16." + ) + + config = model_runner.model_config + head_dim = config.head_dim + num_q_heads = config.num_attention_heads // model_runner.ps.tp_size + num_kv_heads = config.get_num_kv_heads(model_runner.ps.tp_size) + gqa_group_size = num_q_heads // num_kv_heads + if head_dim != _SUPPORTED_HEAD_DIM or gqa_group_size not in ( + _SUPPORTED_GQA_GROUP_SIZES + ): + raise ValueError( + "The hpc_ops attention backend requires head_dim == " + f"{_SUPPORTED_HEAD_DIM} and num_q_heads // num_kv_heads in " + f"{_SUPPORTED_GQA_GROUP_SIZES}, got head_dim={head_dim}, " + f"num_q_heads={num_q_heads}, num_kv_heads={num_kv_heads}." + ) + + self.max_context_len = model_runner.model_config.context_len + self.max_num_pages = ( + self.max_context_len + self.page_size - 1 + ) // self.page_size + self.device = model_runner.device + self.req_to_token = model_runner.req_to_token_pool.req_to_token + self.token_to_kv_pool = model_runner.token_to_kv_pool + + # Per-request query prefix sums [0, 1, ..., bs] for the FP8 fused RoPE + # op in decode mode; a static buffer so it is CUDA-graph friendly. + self._decode_qo_indptr = torch.arange( + model_runner.req_to_token_pool.size + 1, + dtype=torch.int32, + device=self.device, + ) + # Fallback per-tensor KV scale for checkpoints without kv scales. + self._ones_scale = torch.ones(1, dtype=torch.float32, device=self.device) + + # Dynamic-scheduled FP8 decode: the task workspace is sized by the + # decode CUDA-graph max batch size in init_cuda_graph_state (sizing it + # by the full request-pool capacity would cost hundreds of MB); eager + # decode batches beyond that fall back to static split-K. + self.num_kv_heads = num_kv_heads + self._fp8_task_map: Optional[torch.Tensor] = None + self._fp8_task_map_max_bs = 0 + + # CUDA graph state (allocated in init_cuda_graph_state). + self.decode_cuda_graph_metadata = {} + + self.forward_metadata: Optional[HPCOpsMetadata] = None + + def init_forward_metadata(self, forward_batch: ForwardBatch): + """Build metadata for the eager (non-CUDA-graph) path.""" + batch_size = forward_batch.batch_size + device = forward_batch.seq_lens.device + + metadata = HPCOpsMetadata() + metadata.cache_seqlens_int32 = forward_batch.seq_lens.to(torch.int32) + + if forward_batch.forward_mode.is_decode_or_idle(): + metadata.max_seq_len_q = 1 + else: + assert forward_batch.forward_mode.is_extend(), ( + "The hpc_ops attention backend does not support forward mode " + f"{forward_batch.forward_mode}." + ) + max_q = max(forward_batch.extend_seq_lens_cpu) + metadata.max_seq_len_q = ( + int(max_q.item()) if isinstance(max_q, torch.Tensor) else int(max_q) + ) + metadata.cu_seqlens_q = torch.nn.functional.pad( + torch.cumsum(forward_batch.extend_seq_lens, dim=0, dtype=torch.int32), + (1, 0), + ) + + metadata.page_table = torch.empty( + (batch_size, self.max_num_pages), dtype=torch.int32, device=device + ) + build_trtllm_mha_page_table( + req_to_token=self.req_to_token, + req_pool_indices=forward_batch.req_pool_indices, + cache_seqlens=metadata.cache_seqlens_int32, + page_table=metadata.page_table, + page_size=self.page_size, + ) + if ( + self.use_fp8 + and self._fp8_task_map is not None + and forward_batch.forward_mode.is_decode() + and batch_size <= self._fp8_task_map_max_bs + ): + metadata.hpc_task_map = self._assign_fp8_decode_tasks( + metadata.cache_seqlens_int32 + ) + self.forward_metadata = metadata + + def init_cuda_graph_state(self, max_bs: int, max_num_tokens: int): + if self.use_fp8: + import hpc + + self._fp8_task_map = hpc.get_attention_decode_task_workspace( + max_bs, + self.max_context_len, + self.num_kv_heads, + min_process_len=_FP8_DYNAMIC_SCHED_MIN_PROCESS_LEN, + ) + self._fp8_task_map_max_bs = max_bs + + self.decode_cuda_graph_metadata = { + "cache_seqlens": torch.zeros(max_bs, dtype=torch.int32, device=self.device), + "cu_seqlens_k": torch.zeros( + max_bs + 1, dtype=torch.int32, device=self.device + ), + "page_table": torch.zeros( + max_bs, self.max_num_pages, dtype=torch.int32, device=self.device + ), + } + + def init_forward_metadata_out_graph( + self, forward_batch: ForwardBatch, in_capture: bool = False + ): + bs = forward_batch.batch_size + assert forward_batch.forward_mode.is_decode_or_idle(), ( + "The hpc_ops attention backend only supports CUDA graphs for " + f"normal decode, got {forward_batch.forward_mode}." + ) + if in_capture: + metadata = HPCOpsMetadata() + metadata.cache_seqlens_int32 = self.decode_cuda_graph_metadata[ + "cache_seqlens" + ][:bs] + metadata.cu_seqlens_k = self.decode_cuda_graph_metadata["cu_seqlens_k"][ + : bs + 1 + ] + metadata.page_table = self.decode_cuda_graph_metadata["page_table"][:bs, :] + metadata.max_seq_len_q = 1 + self.decode_cuda_graph_metadata[bs] = metadata + + self.forward_metadata = self.decode_cuda_graph_metadata[bs] + + def init_forward_metadata_in_graph(self, forward_batch: ForwardBatch): + """Rebuild cache_seqlens and the page table on-device (graph-recordable).""" + bs = forward_batch.batch_size + metadata = self.decode_cuda_graph_metadata[bs] + update_trtllm_mha_graph_metadata( + req_pool_indices=forward_batch.req_pool_indices[:bs], + seq_lens=forward_batch.seq_lens[:bs], + req_to_token=self.req_to_token, + cache_seqlens=metadata.cache_seqlens_int32, + cu_seqlens_k=metadata.cu_seqlens_k, + page_table=metadata.page_table, + bs=bs, + seqlen_offset=0, + max_seq_pages=self.max_num_pages, + page_size=self.page_size, + ) + if self.use_fp8 and self._fp8_task_map is not None: + # Recorded into the decode graph, so the task map is re-populated + # from the live seq_lens at every replay. + metadata.hpc_task_map = self._assign_fp8_decode_tasks( + metadata.cache_seqlens_int32 + ) + self.forward_metadata = metadata + + def get_cuda_graph_seq_len_fill_value(self) -> int: + return 1 + + def _check_layer_supported(self, layer: RadixAttention): + if layer.sliding_window_size is not None and layer.sliding_window_size > -1: + raise ValueError( + "The hpc_ops attention backend does not support sliding window." + ) + if layer.logit_cap > 0: + raise ValueError( + "The hpc_ops attention backend does not support logit cap." + ) + # The HPC-Ops kernels hard-code the softmax scale to head_dim ** -0.5. + if abs(layer.scaling - layer.head_dim**-0.5) > 1e-8: + raise ValueError( + "The hpc_ops attention backend only supports the default " + f"softmax scaling head_dim ** -0.5, got {layer.scaling}." + ) + + def _paged_kv_buffers(self, layer: RadixAttention): + k_cache, v_cache = self.token_to_kv_pool.get_kv_buffer(layer.layer_id) + k_cache = k_cache.view(-1, self.page_size, layer.tp_k_head_num, layer.head_dim) + v_cache = v_cache.view(-1, self.page_size, layer.tp_v_head_num, layer.head_dim) + return k_cache, v_cache + + def _layer_kv_scales(self, layer: RadixAttention): + """Per-tensor K/V scales as fp32 [1] tensors (ones when absent).""" + k_scale = ( + layer.k_scale.reshape(1).float() + if layer.k_scale is not None + else self._ones_scale + ) + v_scale = ( + layer.v_scale.reshape(1).float() + if layer.v_scale is not None + else self._ones_scale + ) + return k_scale, v_scale + + def fused_qk_rope_store_kv_fp8( + self, + layer: RadixAttention, + forward_batch: ForwardBatch, + qkv: torch.Tensor, + cos_sin_cache: torch.Tensor, + q_norm_weight: Optional[torch.Tensor], + k_norm_weight: Optional[torch.Tensor], + qk_norm_policy: int, + ) -> torch.Tensor: + """Fused QKNorm + RoPE + FP8 quant + paged-KV write (FP8 mode only). + + Called by the model layer instead of its own norm/rope/KV-write; the + dynamic per-token-per-head Q scale and the split-K flag are stashed in + the forward metadata and consumed by the attention call of the same + layer. Returns the FP8 query of shape [num_tokens, num_q_heads, + head_dim]. The subsequent ``self.attn(...)`` call must pass + ``save_kv_cache=False`` (K/V are already written here). + + Under a captured prefill graph (breakable / tc_piecewise) this routes + through a graph-splitting op — like attention itself — so the Python + Q-scale hand-off between this op and attention stays alive at replay. + + Note: the HPC-Ops RMSNorm hard-codes eps=1e-6; checkpoints with a + slightly different rms_norm_eps (e.g. HunYuan V3's 1e-5) accept this + approximation, matching the reference HPC-Ops integration. + """ + assert self.use_fp8, "fused_qk_rope_store_kv_fp8 requires fp8 KV cache" + + is_extend = not forward_batch.forward_mode.is_decode_or_idle() + out_q = torch.empty( + (qkv.shape[0], layer.tp_q_head_num, layer.head_dim), + dtype=torch.float8_e4m3fn, + device=qkv.device, + ) + + if is_extend and get_tc_piecewise_forward_context() is not None: + # Captured prefill graph: run through the splitting op (eager at + # capture AND at replay, keeping the metadata hand-off alive). + if is_in_breakable_cuda_graph(): + breakable_hpc_ops_fp8_rope_store_kv( + qkv, + cos_sin_cache, + out_q, + layer.layer_id, + qk_norm_policy, + q_norm_weight=q_norm_weight, + k_norm_weight=k_norm_weight, + ) + else: + hpc_ops_fp8_rope_store_kv( + qkv, + cos_sin_cache, + out_q, + layer.layer_id, + qk_norm_policy, + q_norm_weight=q_norm_weight, + k_norm_weight=k_norm_weight, + ) + else: + self._run_fp8_rope_store_kv( + layer=layer, + forward_batch=forward_batch, + qkv=qkv, + cos_sin_cache=cos_sin_cache, + q_norm_weight=q_norm_weight, + k_norm_weight=k_norm_weight, + qk_norm_policy=qk_norm_policy, + is_extend=is_extend, + out_q=out_q, + ) + return out_q + + def _run_fp8_rope_store_kv( + self, + layer: RadixAttention, + forward_batch: Optional[ForwardBatch], + qkv: torch.Tensor, + cos_sin_cache: torch.Tensor, + q_norm_weight: Optional[torch.Tensor], + k_norm_weight: Optional[torch.Tensor], + qk_norm_policy: int, + is_extend: bool, + out_q: torch.Tensor, + ) -> None: + """Invoke the fused kernel and stash the Q scales in the metadata.""" + import hpc + + metadata = self.forward_metadata + k_cache, v_cache = self._paged_kv_buffers(layer) + k_scale, v_scale = self._layer_kv_scales(layer) + + if is_extend: + q_index = metadata.cu_seqlens_q + max_seqlens = metadata.max_seq_len_q + else: + q_index = self._decode_qo_indptr[: forward_batch.batch_size + 1] + max_seqlens = 1 + + # QPERTOKEN_PERHEAD_KPERTENSOR_VPERTENSOR: dynamic per-token-per-head + # Q quant, static per-tensor K/V quant. + quant_policy = hpc.QuantType.QPERTOKEN_PERHEAD_KPERTENSOR_VPERTENSOR.value + _, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8( + key_cache=k_cache, + value_cache=v_cache, + qkv=qkv, + cos_sin=cos_sin_cache, + num_seqlen_per_req=metadata.cache_seqlens_int32, + q_index=q_index, + kvcache_indices=metadata.page_table, + is_prefill=is_extend, + k_scale=k_scale, + v_scale=v_scale, + quant_policy=quant_policy, + max_seqlens=max_seqlens, + q_norm_weight=q_norm_weight, + k_norm_weight=k_norm_weight, + out_q=out_q, + qk_norm_policy=qk_norm_policy, + ) + metadata.hpc_q_scale = q_scale + metadata.hpc_split_k_flag = split_k_flag + + def _assign_fp8_decode_tasks(self, cache_seqlens: torch.Tensor) -> torch.Tensor: + """Populate the dynamic-scheduled decode task map from live KV lengths. + + The scheduler pass bins every (request, kv_head, KV-tile) chunk into + per-CTA task lists so each CTA gets roughly equal token work — this is + what balances decode batches with skewed sequence lengths. + """ + import hpc + + # NOTE: assign's ``mtp`` means tokens-per-request (>= 1; plain decode + # is 1), unlike attention_decode's ``mtp`` which counts extra draft + # tokens (plain decode is 0). Passing 0 here makes the scheduler + # kernel launch with an invalid configuration. + hpc.assign_attention_decode_task( + cache_seqlens, + self._fp8_task_map, + self.num_kv_heads, + mtp=1, + new_kv_included=True, + min_process_len=_FP8_DYNAMIC_SCHED_MIN_PROCESS_LEN, + ) + return self._fp8_task_map + + def _take_fp8_scales(self, metadata: HPCOpsMetadata): + """Pop the per-layer FP8 scales written by the fused RoPE op.""" + q_scale = metadata.hpc_q_scale + split_k_flag = metadata.hpc_split_k_flag + if q_scale is None: + raise RuntimeError( + "The hpc_ops attention backend with an fp8_e4m3 KV cache " + "requires the model to run the fused HPC-Ops " + "QKNorm+RoPE+quant+StoreKV op (fused_qk_rope_store_kv_fp8), " + "which produces the per-token-per-head Q scales. This is " + "currently wired for HunYuan V3 only; use " + "--kv-cache-dtype bfloat16 for other models." + ) + metadata.hpc_q_scale = None + metadata.hpc_split_k_flag = None + return q_scale, split_k_flag + + def forward_extend( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + layer: RadixAttention, + forward_batch: ForwardBatch, + save_kv_cache: bool = True, + **kwargs, + ) -> torch.Tensor: + import hpc + + self._check_layer_supported(layer) + + if save_kv_cache and k is not None: + self.token_to_kv_pool.set_kv_buffer( + layer, + KVWriteLoc(forward_batch.out_cache_loc), + k, + v, + layer.k_scale, + layer.v_scale, + ) + + metadata = self.forward_metadata + k_cache, v_cache = self._paged_kv_buffers(layer) + + if self.use_fp8: + q_scale, _ = self._take_fp8_scales(metadata) + k_scale, v_scale = self._layer_kv_scales(layer) + o = hpc.attention_with_kvcache_prefill_fp8( + q.view(-1, layer.tp_q_head_num, layer.head_dim), + k_cache, + v_cache, + q_scale, + k_scale, + v_scale, + metadata.cu_seqlens_q, + metadata.page_table, + metadata.cache_seqlens_int32, + metadata.max_seq_len_q, + ) + else: + o = hpc.attention_with_kvcache_prefill_bf16( + q.view(-1, layer.tp_q_head_num, layer.head_dim), + k_cache, + v_cache, + metadata.cu_seqlens_q, + metadata.page_table, + metadata.cache_seqlens_int32, + metadata.max_seq_len_q, + ) + return o.view(-1, layer.tp_q_head_num * layer.head_dim) + + def forward_decode( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + layer: RadixAttention, + forward_batch: ForwardBatch, + save_kv_cache: bool = True, + **kwargs, + ) -> torch.Tensor: + import hpc + + self._check_layer_supported(layer) + + if save_kv_cache and k is not None: + self.token_to_kv_pool.set_kv_buffer( + layer, + KVWriteLoc(forward_batch.out_cache_loc), + k, + v, + layer.k_scale, + layer.v_scale, + ) + + metadata = self.forward_metadata + k_cache, v_cache = self._paged_kv_buffers(layer) + + if self.use_fp8: + q_scale, split_k_flag = self._take_fp8_scales(metadata) + k_scale, v_scale = self._layer_kv_scales(layer) + o = hpc.attention_decode_fp8( + q.view(-1, layer.tp_q_head_num, layer.head_dim), + k_cache, + v_cache, + metadata.page_table, + metadata.cache_seqlens_int32, + q_scale, + k_scale, + v_scale, + mtp=0, + new_kv_included=True, + splitk=True, + task_map=metadata.hpc_task_map, + split_flag=split_k_flag, + ) + else: + o = hpc.attention_decode_bf16( + q.view(-1, layer.tp_q_head_num, layer.head_dim), + k_cache, + v_cache, + metadata.page_table, + metadata.cache_seqlens_int32, + mtp=0, + new_kv_included=True, + splitk=True, + ) + return o.view(-1, layer.tp_q_head_num * layer.head_dim) + + +@register_custom_op(mutates_args=["out_q"]) +@register_split_op() +def hpc_ops_fp8_rope_store_kv( + qkv: torch.Tensor, + cos_sin_cache: torch.Tensor, + out_q: torch.Tensor, + layer_id: int, + qk_norm_policy: int, + *, + q_norm_weight: Optional[torch.Tensor] = None, + k_norm_weight: Optional[torch.Tensor] = None, +) -> None: + """Graph-splitting wrapper for the fused QKNorm+RoPE+FP8-quant+StoreKV op. + + Like ``unified_attention_with_output``, this runs eagerly between captured + prefill-graph segments (at capture and at every replay), so the Python + hand-off of the dynamic Q scales to the following attention op stays + alive. ``out_q`` is preallocated by the captured segment and mutated in + place, which is what stitches the surrounding graph segments together. + """ + context = get_tc_piecewise_forward_context() + forward_batch = context.forward_batch + attention_layer = context.attention_layers[layer_id] + real_num_tokens = forward_batch.num_token_non_padded_cpu + + backend = get_attn_backend() + backend._run_fp8_rope_store_kv( + layer=attention_layer, + forward_batch=forward_batch, + qkv=qkv[:real_num_tokens], + cos_sin_cache=cos_sin_cache, + q_norm_weight=q_norm_weight, + k_norm_weight=k_norm_weight, + qk_norm_policy=qk_norm_policy, + is_extend=True, + out_q=out_q[:real_num_tokens], + ) + + +breakable_hpc_ops_fp8_rope_store_kv = eager_on_graph(True)(hpc_ops_fp8_rope_store_kv) diff --git a/python/sglang/srt/models/hunyuan_v3.py b/python/sglang/srt/models/hunyuan_v3.py index 8fdeb1a32..6dedbe220 100644 --- a/python/sglang/srt/models/hunyuan_v3.py +++ b/python/sglang/srt/models/hunyuan_v3.py @@ -42,6 +42,7 @@ from sglang.srt.layers.vocab_parallel_embedding import ( VocabParallelEmbedding, ) from sglang.srt.managers.schedule_batch import ForwardBatch +from sglang.srt.model_executor.forward_context import get_attn_backend from sglang.srt.model_executor.runner import get_is_capture_mode from sglang.srt.model_loader.weight_utils import default_weight_loader from sglang.srt.runtime_context import get_parallel, get_stream @@ -313,6 +314,35 @@ class HYV3Attention(nn.Module): self.q_norm = RMSNorm(self.head_dim, rms_norm_eps) self.k_norm = RMSNorm(self.head_dim, rms_norm_eps) + # HPC-Ops FP8 attention path: the fused QKNorm+RoPE+FP8-quant+StoreKV + # op replaces the norm/rope/KV-write below and produces the + # per-token-per-head Q scales the FP8 attention kernels require. + # Resolved lazily on first forward (None = undecided) because the + # attention backend does not exist yet at layer-construction time. + self.use_hpc_ops_fp8_attn: Optional[bool] = ( + None + if is_cuda() + and self.head_dim == 128 + and (self.num_heads, self.num_kv_heads) in ((8, 1), (64, 8)) + else False + ) + self._hpc_cos_sin_fp32: Optional[torch.Tensor] = None + self._hpc_q_norm_w_fp32: Optional[torch.Tensor] = None + self._hpc_k_norm_w_fp32: Optional[torch.Tensor] = None + + def _resolve_hpc_ops_fp8_attn(self) -> bool: + from sglang.srt.layers.attention.hpc_ops_backend import HPCOpsAttnBackend + + backend = get_attn_backend() + use_fp8_path = isinstance(backend, HPCOpsAttnBackend) and backend.use_fp8 + if use_fp8_path: + # The fused kernel wants a fp32 cos/sin table and fp32 norm weights. + self._hpc_cos_sin_fp32 = self.rotary_emb.cos_sin_cache.float() + if self.use_qk_norm: + self._hpc_q_norm_w_fp32 = self.q_norm.weight.detach().float() + self._hpc_k_norm_w_fp32 = self.k_norm.weight.detach().float() + return use_fp8_path + def forward( self, positions: torch.Tensor, @@ -320,6 +350,32 @@ class HYV3Attention(nn.Module): forward_batch: ForwardBatch, ) -> torch.Tensor: qkv, _ = self.qkv_proj(hidden_states) + + if self.use_hpc_ops_fp8_attn is None: + self.use_hpc_ops_fp8_attn = self._resolve_hpc_ops_fp8_attn() + if self.use_hpc_ops_fp8_attn and not forward_batch.forward_mode.is_idle(): + # HunYuan V3 applies QK-Norm before RoPE -> qk_norm_policy=2. + q = get_attn_backend().fused_qk_rope_store_kv_fp8( + layer=self.attn, + forward_batch=forward_batch, + qkv=qkv, + cos_sin_cache=self._hpc_cos_sin_fp32, + q_norm_weight=self._hpc_q_norm_w_fp32, + k_norm_weight=self._hpc_k_norm_w_fp32, + qk_norm_policy=2 if self.use_qk_norm else 0, + ) + # q is FP8; RadixAttention.forward sizes the output buffer as + # bf16 for fp8 queries. + attn_output = self.attn( + q.view(-1, self.q_size), + None, + None, + forward_batch, + save_kv_cache=False, + ) + output, _ = self.o_proj(attn_output) + return output + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1) if self.use_qk_norm: diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index ab600d873..14947fbf3 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -211,6 +211,7 @@ ATTENTION_BACKEND_CHOICES = [ "tokenspeed_mla", "trtllm_mha", "dual_chunk_flash_attn", + "hpc_ops", # HPC-Ops (https://github.com/Tencent/hpc-ops), Hopper+, requires --page-size 64 # AMD specific "aiter", "wave",