[DSv4 Perf] Optimize workspace reuse for eager break, 3.9% E2E TTFT improvement. (#49236)
Signed-off-by: yewentao256 <zhyanwentao@126.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Wentao Ye committed
Jul 31, 2026 at 11:45 UTC
df71917cf17c97d81f48122e6baa7c13d184ff90
15 files changed
+354
-30
csrc/libtorch_stable/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu
+24
-6
@@ -942,9 +942,10 @@ static void launchFullCacheKernel(
942
// ────────────────────────────────────────────────────────────────────────────
943
// Torch op wrapper
944
// ────────────────────────────────────────────────────────────────────────────
945
-torch::stable::Tensor fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
945
+void fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert_out(
946
torch::stable::Tensor const& q_in, // [N, num_heads_q, 512] bf16
947
torch::stable::Tensor const& kv, // [N, 512] bf16 (read-only)
948
+ torch::stable::Tensor& q_out, // [N, q_head_padded, 512]
949
torch::stable::Tensor& k_cache, // [num_blocks, block_bytes] uint8
950
torch::stable::Tensor const& slot_mapping, // [N] int64
951
torch::stable::Tensor const& position_ids, // [N] int64
@@ -970,8 +971,16 @@ torch::stable::Tensor fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
971
STD_TORCH_CHECK(kv.dim() == 2 && kv.size(1) == 512, "kv shape [N, 512]");
972
STD_TORCH_CHECK(q_in.scalar_type() == kv.scalar_type(),
973
"q_in and kv dtype must match");
974
+ STD_TORCH_CHECK(q_out.device() == q_in.device() && q_out.is_contiguous(),
975
+ "q_out must be contiguous and on the same device as q_in");
976
+ STD_TORCH_CHECK(q_out.scalar_type() == q_in.scalar_type(),
977
+ "q_out dtype must match q_in");
978
STD_TORCH_CHECK(q_head_padded >= q_in.size(1),
979
"q_head_padded must be >= q_in.size(1) (num_heads_q)");
980
+ STD_TORCH_CHECK(q_out.dim() == 3 && q_out.size(0) == q_in.size(0) &&
981
+ q_out.size(1) == q_head_padded &&
982
+ q_out.size(2) == q_in.size(2),
983
+ "q_out shape [N, q_head_padded, 512]");
984
STD_TORCH_CHECK(k_cache.scalar_type() == torch::headeronly::ScalarType::Byte,
985
"k_cache must be uint8");
986
STD_TORCH_CHECK(cos_sin_cache.dim() == 2 && cos_sin_cache.size(1) == 64,
@@ -999,11 +1008,6 @@ torch::stable::Tensor fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
1008
q_in.get_device_index());
1009
const cudaStream_t stream = get_current_cuda_stream(q_in.get_device_index());
1010
1002
- // Allocate the padded q output. The kernel writes every element (live
1003
- // region gets RMSNorm+RoPE; pad region gets zeros), so `empty` is safe.
1004
- auto q_out = torch::stable::new_empty(
1005
- q_in, {q_in.size(0), q_head_padded, q_in.size(2)}, q_in.scalar_type());
1006
-
1011
VLLM_STABLE_DISPATCH_HALF_TYPES(
1012
q_in.scalar_type(), "fused_deepseek_v4_qnorm_rope_kv_insert", [&] {
1013
using qkv_scalar_t = scalar_t;
@@ -1020,6 +1024,20 @@ torch::stable::Tensor fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
1024
num_heads_q_padded, cache_block_size_i, kv_block_stride,
1025
stream);
1026
});
1027
+}
1028
+
1029
+torch::stable::Tensor fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
1030
+ torch::stable::Tensor const& q_in, torch::stable::Tensor const& kv,
1031
+ torch::stable::Tensor& k_cache,
1032
+ torch::stable::Tensor const& slot_mapping,
1033
+ torch::stable::Tensor const& position_ids,
1034
+ torch::stable::Tensor const& cos_sin_cache, int64_t q_head_padded,
1035
+ double eps, int64_t cache_block_size) {
1036
+ auto q_out = torch::stable::new_empty(
1037
+ q_in, {q_in.size(0), q_head_padded, q_in.size(2)}, q_in.scalar_type());
1038
+ fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert_out(
1039
+ q_in, kv, q_out, k_cache, slot_mapping, position_ids, cos_sin_cache,
1040
+ q_head_padded, eps, cache_block_size);
1041
return q_out;
1042
}
1043
csrc/libtorch_stable/ops.h
+8
@@ -269,6 +269,14 @@ torch::stable::Tensor fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
269
torch::stable::Tensor const& cos_sin_cache, int64_t q_head_padded,
270
double eps, int64_t cache_block_size);
271
272
+void fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert_out(
273
+ torch::stable::Tensor const& q_in, torch::stable::Tensor const& kv,
274
+ torch::stable::Tensor& q_out, torch::stable::Tensor& k_cache,
275
+ torch::stable::Tensor const& slot_mapping,
276
+ torch::stable::Tensor const& position_ids,
277
+ torch::stable::Tensor const& cos_sin_cache, int64_t q_head_padded,
278
+ double eps, int64_t cache_block_size);
279
+
280
void fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(
281
torch::stable::Tensor& q, torch::stable::Tensor const& kv,
282
torch::stable::Tensor& k_cache, torch::stable::Tensor const& slot_mapping,
csrc/libtorch_stable/torch_bindings.cpp
+7
@@ -433,6 +433,11 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
433
"Tensor q_in, Tensor kv, Tensor! k_cache, "
434
"Tensor slot_mapping, Tensor position_ids, Tensor cos_sin_cache, "
435
"int q_head_padded, float eps, int cache_block_size) -> Tensor");
436
+ ops.def(
437
+ "fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert_out("
438
+ "Tensor q_in, Tensor kv, Tensor! q_out, Tensor! k_cache, "
439
+ "Tensor slot_mapping, Tensor position_ids, Tensor cos_sin_cache, "
440
+ "int q_head_padded, float eps, int cache_block_size) -> ()");
441
442
// FlashInfer V4 full-cache variants: write Q in place (bf16) or to a separate
443
// FP8 tensor, and KV into a contiguous 512-wide token-strided cache.
@@ -751,6 +756,8 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) {
756
ops.impl("fused_qk_norm_rope", TORCH_BOX(&fused_qk_norm_rope));
757
ops.impl("fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert",
758
TORCH_BOX(&fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert));
759
+ ops.impl("fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert_out",
760
+ TORCH_BOX(&fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert_out));
761
ops.impl(
762
"fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert",
763
TORCH_BOX(&fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert));
tests/kernels/test_compressor_kv_cache.py
+18
@@ -20,6 +20,7 @@ import torch
20
21
from vllm import _custom_ops as ops
22
from vllm.models.deepseek_v4.common.ops import (
23
+ compute_global_topk_indices_and_lens,
24
dequantize_and_gather_k_cache,
25
quantize_and_insert_k_cache,
26
)
@@ -34,6 +35,23 @@ from vllm.platforms import current_platform
35
from .test_fused_indexer_q_rope_quant import quantize_to_mxfp4
36
37
38
+def test_compute_global_topk_reuses_output_buffers():
39
+ device = "cuda"
40
+ topk_indices = torch.tensor(
41
+ [[0, 3, -1], [1, 2, -1]], dtype=torch.int32, device=device
42
+ )
43
+ token_to_req = torch.tensor([0, 1], dtype=torch.int32, device=device)
44
+ block_table = torch.tensor([[5, 7], [11, 13]], dtype=torch.int32, device=device)
45
+ is_valid = torch.tensor([True, False], device=device)
46
+ args = (topk_indices, token_to_req, block_table, 2, is_valid)
47
+ expected = compute_global_topk_indices_and_lens(*args)
48
+ outputs = tuple(torch.empty_like(tensor) for tensor in expected)
49
+ actual = compute_global_topk_indices_and_lens(*args, output_buffers=outputs)
50
+ for result, output, reference in zip(actual, outputs, expected):
51
+ assert result.data_ptr() == output.data_ptr()
52
+ torch.testing.assert_close(result, reference)
53
+
54
+
55
def _ue8m0_reference(x: torch.Tensor, block_size: int, fp8_max: float):
56
"""PyTorch reference for UE8M0 FP8 quantization (per-block, power-of-2 scale).
57
tests/kernels/test_fused_deepseek_v4_qnorm_rope_kv_insert.py
+12
-2
@@ -257,8 +257,18 @@ def test_q_path_matches_reference(num_tokens: int, n_heads: int, padded_heads: i
257
num_blocks, bs, HEAD_BYTES, dtype=torch.uint8, device=device
258
).view(num_blocks, -1)
259
slot_mapping = torch.full((num_tokens,), -1, dtype=torch.int64, device=device)
260
- q_out = _call_fused(
261
- q, padded_heads, kv, k_cache, slot_mapping, positions, cos_sin_cache, eps, bs
260
+ q_out = torch.empty(num_tokens, padded_heads, HEAD_DIM, dtype=dtype, device=device)
261
+ torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert_out(
262
+ q,
263
+ kv,
264
+ q_out,
265
+ k_cache,
266
+ slot_mapping,
267
+ positions,
268
+ cos_sin_cache,
269
+ padded_heads,
270
+ eps,
271
+ bs,
272
)
273
274
torch.testing.assert_close(q_out[:, :n_heads], q_ref, rtol=1e-2, atol=1e-2)
tests/kernels/test_fused_indexer_q_rope_quant.py
+26
@@ -150,6 +150,23 @@ def test_fused_indexer_q_rope_quant_matches_unfused(
150
q_quant_ref, weights_ref = _reference(
151
positions, q, cos_sin_cache, weights, softmax_scale, head_scale, use_fp4
152
)
153
+ output_buffers: tuple[torch.Tensor, ...] | None = None
154
+ OUTPUT_BUFFER_TEST_NUM_TOKENS = 7
155
+ if num_tokens == OUTPUT_BUFFER_TEST_NUM_TOKENS and cache_dtype == torch.float32:
156
+ if use_fp4:
157
+ q_ref, q_scale_ref = q_quant_ref
158
+ output_buffers = (
159
+ torch.empty_like(q_ref),
160
+ torch.empty_like(q_scale_ref)
161
+ .view(torch.uint8)
162
+ .reshape(num_tokens, N_HEAD, -1),
163
+ torch.empty_like(weights_ref),
164
+ )
165
+ else:
166
+ output_buffers = (
167
+ torch.empty_like(q_quant_ref),
168
+ torch.empty_like(weights_ref),
169
+ )
170
# use_cutedsl=False: force the triton path even when cutedsl is installed
171
# by patching the dispatcher's has_cutedsl() binding to return False.
172
cutedsl_patch = (
@@ -169,8 +186,17 @@ def test_fused_indexer_q_rope_quant_matches_unfused(
186
softmax_scale,
187
head_scale,
188
use_fp4,
189
+ output_buffers=output_buffers,
190
)
191
192
+ if output_buffers is not None:
193
+ if use_fp4:
194
+ assert q_quant_fused[0].data_ptr() == output_buffers[0].data_ptr()
195
+ assert q_quant_fused[1].data_ptr() == output_buffers[1].data_ptr()
196
+ else:
197
+ assert q_quant_fused.data_ptr() == output_buffers[0].data_ptr()
198
+ assert weights_fused.data_ptr() == output_buffers[-1].data_ptr()
199
+
200
if use_fp4:
201
q_quant_ref, q_scale_ref = q_quant_ref
202
q_quant_fused, q_scale_fused = q_quant_fused
vllm/models/deepseek_v4/attention.py
+35
-2
@@ -29,6 +29,7 @@ from vllm.models.deepseek_v4.common.ops import (
29
from vllm.models.deepseek_v4.common.ops.fused_indexer_q import MXFP4_BLOCK_SIZE
30
31
if TYPE_CHECKING:
32
+ from vllm.models.deepseek_v4.eager_scratch import DeepseekV4EagerScratchPool
33
from vllm.v1.attention.backends.mla.sparse_swa import (
34
DeepseekSparseSWAMetadata,
35
)
@@ -181,6 +182,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
182
prefix: str,
183
topk_indices_buffer: torch.Tensor | None = None,
184
aux_stream_list: list[torch.cuda.Stream] | None = None,
185
+ eager_scratch_pool: "DeepseekV4EagerScratchPool | None" = None,
186
) -> None:
187
super().__init__()
188
config = vllm_config.model_config.hf_config
@@ -269,6 +271,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
271
)
272
self.indexer_rotary_emb = self.rotary_emb
273
self.topk_indices_buffer = topk_indices_buffer
274
+ self.eager_scratch_pool = eager_scratch_pool
275
276
self.indexer = None
277
if self.compress_ratio == 4:
@@ -290,6 +293,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
293
compress_ratio=self.compress_ratio,
294
prefix=f"{prefix}.indexer",
295
aux_stream=indexer_aux_stream,
296
+ eager_scratch_pool=eager_scratch_pool,
297
)
298
299
# Will be None on ROCm for now.
@@ -340,6 +344,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
344
rotate=True,
345
prefix=f"{prefix}.compressor",
346
k_cache_prefix=self.prefix,
347
+ eager_scratch_pool=eager_scratch_pool,
348
)
349
350
def forward(
@@ -571,10 +576,24 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
576
if cache_dtype == torch.uint8:
577
# fp8_ds_mla UE8M0 paged path. Horizontally fused:
578
# Q side: per-head RMSNorm (no weight) + GPT-J RoPE, zero-filling
574
- # the padding head slots; the kernel allocates and returns
575
- # the padded q tensor.
579
+ # the padding head slots.
580
# KV side: GPT-J RoPE + UE8M0 FP8 quant + paged cache insert.
581
swa_kv_cache_2d = swa_kv_cache.view(swa_kv_cache.shape[0], -1)
582
+ if self.eager_scratch_pool is not None:
583
+ q_out = self.eager_scratch_pool.q_out(q.shape[0])
584
+ torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert_out(
585
+ q,
586
+ kv,
587
+ q_out,
588
+ swa_kv_cache_2d,
589
+ swa_metadata.slot_mapping,
590
+ positions,
591
+ cos_sin_cache,
592
+ self.padded_heads,
593
+ self.eps,
594
+ swa_metadata.block_size,
595
+ )
596
+ return q_out
597
return torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
598
q,
599
kv,
@@ -623,6 +642,13 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
642
)
643
return q_fp8
644
645
+ def _global_topk_output_buffers(
646
+ self, topk_indices: torch.Tensor
647
+ ) -> tuple[torch.Tensor, torch.Tensor] | None:
648
+ if self.compress_ratio != 4 or self.eager_scratch_pool is None:
649
+ return None
650
+ return self.eager_scratch_pool.global_topk_outputs(topk_indices)
651
+
652
def get_attn_backend(self) -> type[AttentionBackend]:
653
return self.backend_cls
654
@@ -702,6 +728,7 @@ class DeepseekV4Indexer(nn.Module):
728
compress_ratio: int = 1,
729
prefix: str = "",
730
aux_stream: torch.cuda.Stream | None = None,
731
+ eager_scratch_pool: "DeepseekV4EagerScratchPool | None" = None,
732
):
733
super().__init__()
734
self.vllm_config = vllm_config
@@ -714,6 +741,7 @@ class DeepseekV4Indexer(nn.Module):
741
self.rope_dim = config.qk_rope_head_dim # 64
742
self.q_lora_rank = q_lora_rank # 1536
743
self.compress_ratio = compress_ratio
744
+ self.eager_scratch_pool = eager_scratch_pool
745
self.use_fp4_kv = self.vllm_config.attention_config.use_fp4_indexer_cache
746
logger.info_once(
747
"Using %s indexer cache for Lightning Indexer.",
@@ -777,6 +805,7 @@ class DeepseekV4Indexer(nn.Module):
805
prefix=f"{prefix}.compressor",
806
k_cache_prefix=self.k_cache.prefix,
807
use_fp4_cache=self.use_fp4_kv,
808
+ eager_scratch_pool=eager_scratch_pool,
809
)
810
811
self.indexer_op = SparseAttnIndexer(
@@ -837,6 +866,9 @@ class DeepseekV4Indexer(nn.Module):
866
# ReplicatedLinear returns (output, bias); bias is None.
867
q, _ = self.wq_b(qr)
868
q = q.view(-1, self.n_head, self.head_dim)
869
+ outputs = None
870
+ if self.eager_scratch_pool is not None and self.use_fp4_kv:
871
+ outputs = self.eager_scratch_pool.indexer_q_outputs(q.shape[0])
872
return fused_indexer_q_rope_quant(
873
positions,
874
q,
@@ -845,6 +877,7 @@ class DeepseekV4Indexer(nn.Module):
877
self.softmax_scale,
878
self.n_head**-0.5,
879
use_fp4=self.use_fp4_kv,
880
+ output_buffers=outputs,
881
)
882
883
# compressor returns None and writes K to the indexer KV cache; the
vllm/models/deepseek_v4/common/ops/cache_utils.py
+10
-2
@@ -438,6 +438,7 @@ def compute_global_topk_indices_and_lens(
438
block_table: torch.Tensor,
439
block_size: int,
440
is_valid_token: torch.Tensor,
441
+ output_buffers: tuple[torch.Tensor, torch.Tensor] | None = None,
442
) -> tuple[torch.Tensor, torch.Tensor]:
443
"""Map local topk indices to global KV cache slots and count valid entries.
444
@@ -447,8 +448,15 @@ def compute_global_topk_indices_and_lens(
448
3. Masking padding tokens to length 0
449
"""
450
num_tokens = topk_indices.shape[0]
450
- global_topk_indices = torch.empty_like(topk_indices)
451
- topk_lens = torch.empty(num_tokens, dtype=torch.int32, device=topk_indices.device)
451
+ if output_buffers is None:
452
+ global_topk_indices = torch.empty_like(topk_indices)
453
+ topk_lens = torch.empty(
454
+ num_tokens, dtype=torch.int32, device=topk_indices.device
455
+ )
456
+ else:
457
+ global_topk_indices, topk_lens = output_buffers
458
+ assert global_topk_indices.shape == topk_indices.shape
459
+ assert topk_lens.shape == (num_tokens,)
460
_compute_global_topk_indices_and_lens_kernel[(num_tokens,)](
461
global_topk_indices,
462
global_topk_indices.stride(0),
vllm/models/deepseek_v4/common/ops/fused_indexer_q.py
+30
-12
@@ -295,6 +295,7 @@ def fused_indexer_q_rope_quant(
295
index_weights_softmax_scale: float,
296
index_weights_head_scale: float,
297
use_fp4: bool = False,
298
+ output_buffers: tuple[torch.Tensor, ...] | None = None,
299
) -> tuple[
300
torch.Tensor | tuple[torch.Tensor, torch.Tensor],
301
torch.Tensor,
@@ -332,7 +333,13 @@ def fused_indexer_q_rope_quant(
333
num_index_q_heads = index_q.shape[1]
334
index_q_head_dim = index_q.shape[2]
335
335
- index_weights_out = torch.empty_like(index_weights, dtype=torch.float32)
336
+ if output_buffers is None:
337
+ index_weights_out = torch.empty_like(index_weights, dtype=torch.float32)
338
+ else:
339
+ expected_num_buffers = 3 if use_fp4 else 2
340
+ assert len(output_buffers) == expected_num_buffers
341
+ index_weights_out = output_buffers[-1]
342
+ assert index_weights_out.shape == index_weights.shape
343
344
if use_fp4:
345
assert index_q_head_dim % MXFP4_BLOCK_SIZE == 0, (
@@ -340,16 +347,23 @@ def fused_indexer_q_rope_quant(
347
f"size {MXFP4_BLOCK_SIZE}"
348
)
349
num_scale_blocks = index_q_head_dim // MXFP4_BLOCK_SIZE
343
- index_q_packed = torch.empty(
344
- (num_tokens, num_index_q_heads, index_q_head_dim // 2),
345
- dtype=torch.uint8,
346
- device=index_q.device,
347
- )
348
- index_q_scale = torch.empty(
349
- (num_tokens, num_index_q_heads, num_scale_blocks),
350
- dtype=torch.uint8,
351
- device=index_q.device,
352
- )
350
+ packed_shape = (num_tokens, num_index_q_heads, index_q_head_dim // 2)
351
+ scale_shape = (num_tokens, num_index_q_heads, num_scale_blocks)
352
+ if output_buffers is None:
353
+ index_q_packed = torch.empty(
354
+ packed_shape,
355
+ dtype=torch.uint8,
356
+ device=index_q.device,
357
+ )
358
+ index_q_scale = torch.empty(
359
+ scale_shape,
360
+ dtype=torch.uint8,
361
+ device=index_q.device,
362
+ )
363
+ else:
364
+ index_q_packed, index_q_scale, _ = output_buffers
365
+ assert index_q_packed.shape == packed_shape
366
+ assert index_q_scale.shape == scale_shape
367
if has_cutedsl():
368
# lazily import, otherwise some tests fail due to CUDA driver init failure.
369
from vllm.models.deepseek_v4.nvidia.ops.fused_indexer_q_cutedsl import (
@@ -418,7 +432,11 @@ def fused_indexer_q_rope_quant(
432
fp8_dtype = current_platform.fp8_dtype()
433
use_fnuz = fp8_dtype == torch.float8_e4m3fnuz
434
fp8_max = 224.0 if use_fnuz else 448.0
421
- index_q_fp8 = torch.empty_like(index_q, dtype=fp8_dtype)
435
+ if output_buffers is None:
436
+ index_q_fp8 = torch.empty_like(index_q, dtype=fp8_dtype)
437
+ else:
438
+ index_q_fp8, _ = output_buffers
439
+ assert index_q_fp8.shape == index_q.shape
440
if has_cutedsl():
441
# lazily import, otherwise some tests fail due to CUDA driver init failure.
442
from vllm.models.deepseek_v4.nvidia.ops.fused_indexer_q_cutedsl import (
vllm/models/deepseek_v4/compressor.py
+10
-1
@@ -2,7 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
from dataclasses import dataclass
5
-from typing import Any, ClassVar, cast
5
+from typing import TYPE_CHECKING, Any, ClassVar, cast
6
7
import torch
8
from torch import nn
@@ -35,6 +35,9 @@ from vllm.v1.kv_cache_interface import (
35
SlidingWindowMLASpec,
36
)
37
38
+if TYPE_CHECKING:
39
+ from vllm.models.deepseek_v4.eager_scratch import DeepseekV4EagerScratchPool
40
+
41
42
def _prefer_two_stage_compressor() -> bool:
43
# Platforms that favor the triton variant of two-stage compressor split.
@@ -226,6 +229,7 @@ class DeepseekCompressor(nn.Module):
229
prefix: str = "",
230
k_cache_prefix="",
231
use_fp4_cache: bool = False,
232
+ eager_scratch_pool: "DeepseekV4EagerScratchPool | None" = None,
233
):
234
super().__init__()
235
self.compress_ratio = compress_ratio
@@ -235,6 +239,7 @@ class DeepseekCompressor(nn.Module):
239
self.prefix = prefix
240
self.k_cache_prefix = k_cache_prefix
241
self.use_fp4_cache = use_fp4_cache
242
+ self.eager_scratch_pool = eager_scratch_pool
243
244
config = vllm_config.model_config.hf_config
245
self.rope_head_dim = config.qk_rope_head_dim
@@ -428,6 +433,10 @@ class DeepseekCompressor(nn.Module):
433
store_full_fp8=store_full_fp8,
434
fp8_scale=fp8_scale,
435
)
436
+ if not self.overlap and self.eager_scratch_pool is not None:
437
+ extra_kwargs["compress_scratch"] = (
438
+ self.eager_scratch_pool.compressor_scratch(num_actual)
439
+ )
440
elif self._use_two_stage_fused_compressor:
441
# head=512 cr>=128 (no overlap): two-pass split compressor on the
442
# prefill suffix, single-pass on the decode prefix.
vllm/models/deepseek_v4/eager_scratch.py
new
+137
@@ -0,0 +1,137 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from math import prod
5
+
6
+import torch
7
+
8
+from vllm.models.deepseek_v4.common.ops.fused_indexer_q import MXFP4_BLOCK_SIZE
9
+from vllm.utils.math_utils import round_up
10
+
11
+
12
+class DeepseekV4EagerScratchPool:
13
+ """Model-wide outputs and scratch used inside the attention eager break."""
14
+
15
+ _ALIGNMENT = 256
16
+
17
+ def __init__(
18
+ self,
19
+ max_num_tokens: int,
20
+ padded_q_heads: int,
21
+ q_head_dim: int,
22
+ index_q_heads: int,
23
+ index_q_head_dim: int,
24
+ index_topk: int,
25
+ device: torch.device | str,
26
+ ) -> None:
27
+ self.max_num_tokens = max_num_tokens
28
+ self.index_topk = index_topk
29
+ self._q = torch.empty(
30
+ (max_num_tokens, padded_q_heads, q_head_dim),
31
+ dtype=torch.bfloat16,
32
+ device=device,
33
+ )
34
+
35
+ fp4_specs = (
36
+ ((max_num_tokens, index_q_heads, index_q_head_dim // 2), torch.uint8),
37
+ (
38
+ (
39
+ max_num_tokens,
40
+ index_q_heads,
41
+ index_q_head_dim // MXFP4_BLOCK_SIZE,
42
+ ),
43
+ torch.uint8,
44
+ ),
45
+ ((max_num_tokens, index_q_heads), torch.float32),
46
+ )
47
+ global_specs = (
48
+ ((max_num_tokens, index_topk), torch.int32),
49
+ ((max_num_tokens,), torch.int32),
50
+ )
51
+ compressor_specs = (((max_num_tokens, q_head_dim), torch.float32),)
52
+ # FP4 indexer is C4 only, global mapping after FP4 indexer
53
+ # compressor scratch is C128 only
54
+ # so here we use max instead of sum
55
+ aux_bytes = max(
56
+ self._packed_size(specs)
57
+ for specs in (fp4_specs, global_specs, compressor_specs)
58
+ )
59
+ storage = torch.empty(aux_bytes, dtype=torch.uint8, device=device)
60
+
61
+ self._q_outputs: dict[int, torch.Tensor] = {}
62
+ fp4_values, fp4_scales, fp4_weights = self._views(storage, fp4_specs)
63
+ self._fp4_template = (fp4_values, fp4_scales, fp4_weights)
64
+ self._fp4_outputs: dict[
65
+ int, tuple[torch.Tensor, torch.Tensor, torch.Tensor]
66
+ ] = {}
67
+ global_indices, global_lens = self._views(storage, global_specs)
68
+ self._global_template = (global_indices, global_lens)
69
+ self._global_outputs: dict[int, tuple[torch.Tensor, torch.Tensor]] = {}
70
+ self._compressor_template = self._views(storage, compressor_specs)[0]
71
+ self._compressor_outputs: dict[int, torch.Tensor] = {}
72
+ self._storage = storage
73
+
74
+ @classmethod
75
+ def _packed_size(
76
+ cls, specs: tuple[tuple[tuple[int, ...], torch.dtype], ...]
77
+ ) -> int:
78
+ offset = 0
79
+ for shape, dtype in specs:
80
+ offset = round_up(offset, cls._ALIGNMENT) + prod(shape) * dtype.itemsize
81
+ return round_up(offset, cls._ALIGNMENT)
82
+
83
+ @classmethod
84
+ def _views(
85
+ cls,
86
+ storage: torch.Tensor,
87
+ specs: tuple[tuple[tuple[int, ...], torch.dtype], ...],
88
+ ) -> list[torch.Tensor]:
89
+ offset = 0
90
+ views = []
91
+ for shape, dtype in specs:
92
+ offset = round_up(offset, cls._ALIGNMENT)
93
+ num_bytes = prod(shape) * dtype.itemsize
94
+ views.append(storage[offset : offset + num_bytes].view(dtype).view(shape))
95
+ offset += num_bytes
96
+ return views
97
+
98
+ def q_out(self, num_tokens: int) -> torch.Tensor:
99
+ output = self._q_outputs.get(num_tokens)
100
+ if output is None:
101
+ output = self._q[:num_tokens]
102
+ self._q_outputs[num_tokens] = output
103
+ return output
104
+
105
+ def compressor_scratch(self, num_tokens: int) -> torch.Tensor:
106
+ output = self._compressor_outputs.get(num_tokens)
107
+ if output is None:
108
+ output = self._compressor_template[:num_tokens]
109
+ self._compressor_outputs[num_tokens] = output
110
+ return output
111
+
112
+ def indexer_q_outputs(
113
+ self,
114
+ num_tokens: int,
115
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
116
+ output = self._fp4_outputs.get(num_tokens)
117
+ if output is None:
118
+ values, scales, weights = self._fp4_template
119
+ output = (
120
+ values[:num_tokens],
121
+ scales[:num_tokens],
122
+ weights[:num_tokens],
123
+ )
124
+ self._fp4_outputs[num_tokens] = output
125
+ return output
126
+
127
+ def global_topk_outputs(
128
+ self, topk_indices: torch.Tensor
129
+ ) -> tuple[torch.Tensor, torch.Tensor]:
130
+ num_tokens, topk = topk_indices.shape
131
+ assert topk == self.index_topk
132
+ output = self._global_outputs.get(num_tokens)
133
+ if output is None:
134
+ indices, lens = self._global_template
135
+ output = (indices[:num_tokens], lens[:num_tokens])
136
+ self._global_outputs[num_tokens] = output
137
+ return output
vllm/models/deepseek_v4/nvidia/flashinfer_sparse.py
+4
@@ -745,6 +745,9 @@ class DeepseekV4FlashInferSM120Attention(DeepseekV4Attention):
745
attn_metadata.block_table[:num_decodes],
746
block_size,
747
is_valid,
748
+ output_buffers=self._global_topk_output_buffers(
749
+ self.topk_indices_buffer[:num_decode_tokens]
750
+ ),
751
)
752
)
753
extra_sparse_indices = global_indices.view(num_decode_tokens, 1, -1)
@@ -834,6 +837,7 @@ class DeepseekV4FlashInferSM120Attention(DeepseekV4Attention):
837
attn_metadata.block_table,
838
block_size,
839
swa_metadata.is_valid_token[prefill_token_slice],
840
+ output_buffers=self._global_topk_output_buffers(local_topk_indices),
841
)
842
)
843
vllm/models/deepseek_v4/nvidia/flashmla.py
+3
@@ -176,6 +176,9 @@ class DeepseekV4FlashMLAAttention(DeepseekV4Attention):
176
attn_metadata.block_table[:num_decodes],
177
block_size,
178
is_valid,
179
+ output_buffers=self._global_topk_output_buffers(
180
+ self.topk_indices_buffer[:num_decode_tokens]
181
+ ),
182
)
183
topk_indices = global_indices.view(num_decode_tokens, 1, -1)
184
else:
vllm/models/deepseek_v4/nvidia/model.py
+20
@@ -66,6 +66,7 @@ from vllm.model_executor.models.utils import (
66
)
67
from vllm.model_executor.utils import set_weight_attrs
68
from vllm.models.deepseek_v4.attention import DeepseekV4Attention
69
+from vllm.models.deepseek_v4.eager_scratch import DeepseekV4EagerScratchPool
70
from vllm.models.deepseek_v4.nvidia.flashinfer_sparse import (
71
DeepseekV4FlashInferMLAAttention,
72
DeepseekV4FlashInferSM120Attention,
@@ -798,6 +799,7 @@ class DeepseekV4DecoderLayer(nn.Module):
799
prefix,
800
topk_indices_buffer: torch.Tensor | None = None,
801
aux_stream_list: list[torch.cuda.Stream] | None = None,
802
+ eager_scratch_pool: DeepseekV4EagerScratchPool | None = None,
803
):
804
super().__init__()
805
@@ -810,6 +812,7 @@ class DeepseekV4DecoderLayer(nn.Module):
812
prefix=f"{prefix}.attn",
813
topk_indices_buffer=topk_indices_buffer,
814
aux_stream_list=aux_stream_list,
815
+ eager_scratch_pool=eager_scratch_pool,
816
)
817
self.ffn = DeepseekV4MoE(vllm_config, prefix=f"{prefix}.ffn")
818
@@ -986,6 +989,22 @@ class DeepseekV4Model(nn.Module, EagleModelMixin):
989
# (compressor kv_score, indexer.weights_proj, indexer.compressor
990
# kv_score). fused_wqa_wkv stays on the default stream.
991
aux_stream_list = [torch.cuda.Stream() for _ in range(3)]
992
+ padded_heads = _select_dsv4_attn_cls(vllm_config).get_padded_num_q_heads(
993
+ config.num_attention_heads // get_tensor_model_parallel_world_size()
994
+ )
995
+ self.eager_scratch_pool: DeepseekV4EagerScratchPool | None = None
996
+ if not vllm_config.parallel_config.use_ubatching:
997
+ # TODO: support dbo if needed
998
+ # this requires the buffer to have ubatch dim
999
+ self.eager_scratch_pool = DeepseekV4EagerScratchPool(
1000
+ vllm_config.scheduler_config.max_num_batched_tokens,
1001
+ padded_heads,
1002
+ config.head_dim,
1003
+ config.index_n_heads,
1004
+ config.index_head_dim,
1005
+ config.index_topk,
1006
+ current_platform.device_type,
1007
+ )
1008
1009
# Reserved topk indices buffer for all Indexer layers to reuse.
1010
self.topk_indices_buffer = torch.empty(
@@ -1011,6 +1030,7 @@ class DeepseekV4Model(nn.Module, EagleModelMixin):
1030
prefix=prefix,
1031
topk_indices_buffer=self.topk_indices_buffer,
1032
aux_stream_list=aux_stream_list,
1033
+ eager_scratch_pool=self.eager_scratch_pool,
1034
),
1035
prefix=f"{prefix}.layers",
1036
)
vllm/models/deepseek_v4/nvidia/ops/sparse_attn_compress_cutedsl.py
+10
-5
@@ -2097,6 +2097,7 @@ def compress_norm_rope_store_cutedsl(
2097
store_full_kv: bool = False,
2098
store_full_fp8: bool = False,
2099
fp8_scale: torch.Tensor | None = None,
2100
+ compress_scratch: torch.Tensor | None = None,
2101
) -> None:
2102
if compress_ratio == 4:
2103
# For C4A, the single fused kernel is faster than the two-kernel version.
@@ -2129,11 +2130,15 @@ def compress_norm_rope_store_cutedsl(
2130
)
2131
else:
2132
# For C128, the two-kernel version is faster than the single fused kernel.
2132
- compressed_kv = torch.empty(
2133
- (num_actual, head_dim),
2134
- dtype=torch.float32,
2135
- device=state_cache.device,
2136
- )
2133
+ if compress_scratch is None:
2134
+ compressed_kv = torch.empty(
2135
+ (num_actual, head_dim),
2136
+ dtype=torch.float32,
2137
+ device=state_cache.device,
2138
+ )
2139
+ else:
2140
+ assert compress_scratch.shape == (num_actual, head_dim)
2141
+ compressed_kv = compress_scratch
2142
split_kv_compress_norm_rope_insert_sparse_attn_cutedsl(
2143
state_cache,
2144
token_to_req_indices,