[ROCm][Perf] Enable CSA multi-stream overlap for DeepSeek-V4 (#51794)

Signed-off-by: shen-shanshan <467638484@qq.com>
Signed-off-by: Shanshan Shen <87969357+shen-shanshan@users.noreply.github.com>
This commit is contained in:
Shanshan Shen
2026-09-14 11:21:30 +00:00
committed by GitHub
parent e82794f48f
commit a6c5d6d0fc
4 changed files with 214 additions and 25 deletions
+1 -10
View File
@@ -957,16 +957,7 @@ class DeepseekV4Model(nn.Module, EagleModelMixin):
self.hc_dim = self.hc_mult * config.hidden_size
self.rms_norm_eps = config.rms_norm_eps
# Three aux streams: one per non-default input GEMM in
# DeepseekV4Attention._run_parallel_input_projections
# (compressor kv_score, indexer.weights_proj, indexer.compressor
# kv_score). fused_wqa_wkv stays on the default stream.
# Disable them on ROCm because of hang issues.
aux_stream_list = (
None
if current_platform.is_rocm()
else [torch.cuda.Stream() for _ in range(3)]
)
aux_stream_list = [torch.cuda.Stream() for _ in range(3)]
self.device = current_platform.device_type
# Reserved topk indices buffer for all Indexer layers to reuse.
+194
View File
@@ -23,6 +23,7 @@ from vllm.models.deepseek_v4.sparse_mla import (
from vllm.platforms import current_platform
from vllm.platforms.rocm import _ON_GFX950
from vllm.triton_utils import tl, triton
from vllm.utils.multi_stream_utils import execute_in_parallel
from vllm.v1.attention.backend import (
CommonAttentionMetadata,
)
@@ -571,6 +572,199 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
self.register_buffer("_fused_compressor_weight", None, persistent=False)
self._fused_compressor_split_sizes: tuple[int, int] | None = None
if self.indexer is None:
# Only enable multi-stream overlap for CSA layer now.
self.aux_stream_list = None
else:
# Disable indexer inner overlap.
self.indexer.aux_stream = None
def _enable_csa_multi_stream(self) -> bool:
"""All CSA multi-stream gates: env var, streams, and capture region.
Dict metadata marks piecewise cudagraph, whose eager breaks rebuild
the attention inputs on the owning stream. Forking side streams
there would rely on runtime HIP event sync, which is unreliable in
this overlap on ROCm (event waits can hang), so multi-stream only
runs where the fork/join becomes static graph edges: inside capture,
or with non-dict metadata (full cudagraph or the profile run), which
has no eager breaks.
"""
attn_metadata = get_forward_context().attn_metadata
return self.aux_stream_list is not None and (
torch.cuda.is_current_stream_capturing()
or not isinstance(attn_metadata, dict)
)
def _run_sequential_pipeline(
self,
hidden_states: torch.Tensor,
positions: torch.Tensor,
o_padded: torch.Tensor,
) -> None:
"""Disable ROCm streams when the current execution region cannot overlap."""
aux_streams = self.aux_stream_list
self.aux_stream_list = None
try:
qr_kv, kv_score, indexer_kv_score, indexer_weights = (
self._run_parallel_input_projections(hidden_states)
)
qr, qr_scale, kv = self._split_qkv_and_norm(qr_kv)
self._prepare_and_attn_fn(
hidden_states,
qr,
kv,
qr_scale,
kv_score,
indexer_kv_score,
indexer_weights,
positions,
o_padded,
)
finally:
self.aux_stream_list = aux_streams
def forward(
self,
positions: torch.Tensor,
hidden_states: torch.Tensor,
llama_4_scaling: torch.Tensor | None = None,
) -> torch.Tensor:
# Pre-allocate attention output with FlashMLA-padded head count.
# The op writes into `o_padded`; we slice to n_local_heads after.
num_tokens = hidden_states.shape[0]
o_padded = torch.empty(
(num_tokens, self.padded_heads, self.head_dim),
dtype=hidden_states.dtype,
device=hidden_states.device,
)
if self._enable_csa_multi_stream():
# The ROCm override consumes these sentinels inside the capture
# boundary, moving the stream fan-out ahead of the projections.
self._prepare_and_attn_fn(
hidden_states,
None,
None,
None,
None,
None,
None,
positions,
o_padded,
)
else:
self._run_sequential_pipeline(hidden_states, positions, o_padded)
o = o_padded[:, : self.n_local_heads, :]
# Inverse-RoPE + wo_a + wo_b output projection (platform-specific).
return self._o_proj(o, positions)
def _prepare_and_attn(
self,
hidden_states: torch.Tensor,
qr: torch.Tensor | None,
kv: torch.Tensor | None,
qr_scale: torch.Tensor | None,
kv_score: torch.Tensor | None,
indexer_kv_score: torch.Tensor | None,
indexer_weights: torch.Tensor | None,
positions: torch.Tensor,
o_padded: torch.Tensor,
) -> None:
"""Run the ROCm CSA fork/join inside the graph capture boundary."""
aux_streams = self.aux_stream_list
# The sequential pipeline disables aux_stream_list before calling
# back with real projection inputs; aux_streams is None ends that
# recursion here.
if aux_streams is None:
saved_streams = self.aux_stream_list
self.aux_stream_list = None
try:
super()._prepare_and_attn(
hidden_states,
cast(torch.Tensor, qr),
cast(torch.Tensor, kv),
qr_scale,
cast(torch.Tensor, kv_score),
cast(torch.Tensor, indexer_kv_score),
cast(torch.Tensor, indexer_weights),
positions,
o_padded,
)
finally:
self.aux_stream_list = saved_streams
return
# Re-check: forward's gate ran inside a captured segment that
# _prepare_and_attn_eager (MRV1) then broke, making this region eager.
if not self._enable_csa_multi_stream():
self._run_sequential_pipeline(hidden_states, positions, o_padded)
return
indexer = self.indexer
compressor = self.compressor
assert indexer is not None and compressor is not None
def default_chain():
qr_kv = self._fused_wqa_wkv_gemm(hidden_states)
qr_out, qr_scale_out, kv_out = self._split_qkv_and_norm(qr_kv)
q = self._wq_b_proj(qr_out, qr_scale_out).view(
-1, self.n_local_heads, self.head_dim
)
attn_metadata = get_forward_context().attn_metadata
q = self._fused_qnorm_rope_kv_insert(q, kv_out, positions, attn_metadata)
return q, qr_out, qr_scale_out, kv_out
def main_compressor_chain() -> None:
score = torch.mm(
hidden_states,
compressor.fused_wkv_wgate.weight.T,
out_dtype=torch.float32,
)
compressor(score, positions, self.rotary_emb)
def indexer_compressor_chain() -> None:
score = torch.mm(
hidden_states,
indexer.compressor.fused_wkv_wgate.weight.T,
out_dtype=torch.float32,
)
indexer.compressor(score, positions, self.indexer_rotary_emb)
(q, qr_out, qr_scale_out, kv_out), _ = execute_in_parallel(
default_chain,
[main_compressor_chain, indexer_compressor_chain],
self.ln_events[0],
self.ln_events[1:3],
aux_streams[:2],
enable=True,
)
indexer_weights_out, _ = indexer.weights_proj(hidden_states)
# The indexer compressor already ran on aux stream 1; build queries only.
index_q, index_q_scale, weights = indexer(
hidden_states,
qr_out,
None,
indexer_weights_out,
positions,
self.indexer_rotary_emb,
qr_scale_out,
skip_compressor=True,
)
self._sparse_indexer_and_attn(
hidden_states,
index_q,
index_q_scale,
weights,
q,
kv_out,
positions,
o_padded,
)
@classmethod
def get_padded_num_q_heads(cls, num_heads: int) -> int:
return num_heads
+17 -14
View File
@@ -318,7 +318,6 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
# graph and MRV1 produces garbage (#51430).
self._prepare_and_attn_fn = self._prepare_and_attn_eager
# Will be None on ROCm for now.
self.aux_stream_list = aux_stream_list
# [0]: GEMM start / post-GEMM event0. [1..3]: GEMM done events;
# [1] doubles as post-GEMM event1. Reuse is safe: GEMM fully joins
@@ -584,7 +583,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
# Keep Q projection and KV insertion on the default stream. The indexer
# and MLA compressor use aux streams 0 and 1; aux 2 is internal to the
# indexer. ROCm runs the same work sequentially without aux streams.
# indexer.
if indexer is not None:
assert compressor is not None
q, (indexer_inputs, _) = execute_in_parallel(
@@ -671,7 +670,6 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
# fused_wqa_wkv (heaviest) on default; the three lighter input GEMMs
# on aux streams 0..2 when their owning module exists. ln_events[0]
# is the fan-out start event; ln_events[1..3] are per-aux done events.
# On ROCm, aux_streams is None and execute_in_parallel runs serially.
aux_fns: list[Callable[[], Any] | None] = [None, None, None]
if self.compressor is not None:
@@ -1055,11 +1053,12 @@ class DeepseekV4Indexer(nn.Module):
self,
hidden_states: torch.Tensor,
qr: torch.Tensor,
compressed_kv_score: torch.Tensor,
compressed_kv_score: torch.Tensor | None,
indexer_weights: torch.Tensor,
positions: torch.Tensor,
rotary_emb: nn.Module,
qr_scale: torch.Tensor | None = None,
skip_compressor: bool = False,
) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]:
compressor = self.compressor
@@ -1072,7 +1071,8 @@ class DeepseekV4Indexer(nn.Module):
):
# candidates num smaller than topk, every candidate is selected
# but we still need to build k cache
compressor(compressed_kv_score, positions, rotary_emb)
if not skip_compressor:
compressor(compressed_kv_score, positions, rotary_emb)
assert self.topk_indices_buffer is not None
num_tokens = (
indexer_metadata.num_decode_tokens
@@ -1102,15 +1102,18 @@ class DeepseekV4Indexer(nn.Module):
use_fp4=self.use_fp4_kv,
)
# compressor returns None and writes K to the indexer KV cache; the
# join orders that write before indexer_op (skip_k_cache_insert=True).
(q_quant, weights), _ = maybe_execute_in_parallel(
wq_b_and_q_quant,
lambda: compressor(compressed_kv_score, positions, rotary_emb),
self.ln_events[0],
self.ln_events[1],
self.aux_stream,
)
if not skip_compressor:
# compressor returns None and writes K to the indexer KV cache; the
# join orders that write before indexer_op (skip_k_cache_insert=True).
(q_quant, weights), _ = maybe_execute_in_parallel(
wq_b_and_q_quant,
lambda: compressor(compressed_kv_score, positions, rotary_emb),
self.ln_events[0],
self.ln_events[1],
self.aux_stream,
)
else:
q_quant, weights = wq_b_and_q_quant()
if isinstance(q_quant, tuple):
q, q_scale = q_quant
else:
+2 -1
View File
@@ -198,11 +198,12 @@ class DeepseekV4CPUIndexer(DeepseekV4Indexer):
self,
hidden_states: torch.Tensor,
qr: torch.Tensor,
compressed_kv_score: torch.Tensor,
compressed_kv_score: torch.Tensor | None,
indexer_weights: torch.Tensor,
positions: torch.Tensor,
rotary_emb: nn.Module,
qr_scale: torch.Tensor | None = None,
skip_compressor: bool = False,
) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]:
"""CPU override: no aux streams, so wq_b_and_q_quant and the
compressor run straight-line instead of through