[Model] DeepSeek-V4.1-Flash Model Definitions (#56228)

Signed-off-by: Roger Wang <hey@rogerw.io> Signed-off-by: zjy0516 <riverclouds.zhu@qq.com> Co-authored-by: Kimi Code <noreply@moonshot.cn> Co-authored-by: zjy0516 <riverclouds.zhu@qq.com> Co-authored-by: Codex <noreply@openai.com>

Roger Wang committed Sep 10, 2026 at 05:16 UTC 9b959b86577c082c0b2bf9e2c22263255a36ad83
47 files changed +12902 -196
tests/models/test_deepseek_v4_mega_moe.py
+47 -11
@@ -169,7 +169,18 @@ def test_deepseek_v4_mega_moe_weight_loader_uses_ep_expert_ownership():
169 assert torch.count_nonzero(experts.w13_weight[1]) == 0
170
171
172 -def test_deepseek_v4_mega_moe_finalizes_native_shared_expert_weights(monkeypatch):
172 +@pytest.mark.parametrize(
173 + "hidden_size,intermediate_size,block_size,fused",
174 + [
175 + pytest.param(128, 512, 128, True, id="aligned-block128"),
176 + pytest.param(5120, 2304, 32, False, id="deepseek-v41-flash"),
177 + ],
178 +)
179 +def test_deepseek_v4_mega_moe_finalizes_native_shared_expert_weights(
180 + monkeypatch, hidden_size, intermediate_size, block_size, fused
181 +):
182 + """V4.1-Flash's routed padding must preserve the serial shared-expert fallback."""
183 +
184 class FakeDeepGemm:
185 transformed_dims: list[tuple[int, int]] = []
186 scale_inputs: list[tuple[int, ...]] = []
@@ -216,8 +227,8 @@ def test_deepseek_v4_mega_moe_finalizes_native_shared_expert_weights(monkeypatch
227 num_local_experts=1,
228 experts_start_idx=0,
229 top_k=1,
219 - hidden_size=128,
220 - intermediate_size=128,
230 + hidden_size=hidden_size,
231 + intermediate_size=intermediate_size,
232 num_shared_experts=1,
233 )
234 experts._check_runtime_supported = lambda: None
@@ -232,24 +243,49 @@ def test_deepseek_v4_mega_moe_finalizes_native_shared_expert_weights(monkeypatch
243
244 shared_experts = SimpleNamespace(
245 gate_up_proj=SimpleNamespace(
235 - weight=fp8_parameter(256, 128),
236 - weight_block_size=(128, 128),
237 - weight_scale_inv=scale_parameter(2, 1, dtype=torch.float8_e8m0fnu),
246 + weight=fp8_parameter(2 * intermediate_size, hidden_size),
247 + weight_block_size=(block_size, block_size),
248 + weight_scale_inv=scale_parameter(
249 + 2 * intermediate_size // block_size,
250 + hidden_size // block_size,
251 + dtype=torch.float8_e8m0fnu,
252 + ),
253 ),
254 down_proj=SimpleNamespace(
240 - weight=fp8_parameter(128, 128),
241 - weight_block_size=(128, 128),
242 - weight_scale_inv=scale_parameter(1, 1, dtype=torch.float8_e8m0fnu),
255 + weight=fp8_parameter(hidden_size, intermediate_size),
256 + weight_block_size=(block_size, block_size),
257 + weight_scale_inv=scale_parameter(
258 + hidden_size // block_size,
259 + intermediate_size // block_size,
260 + dtype=torch.float8_e8m0fnu,
261 + ),
262 ),
263 )
264 monkeypatch.setattr("vllm.utils.deep_gemm._import_deep_gemm", lambda: FakeDeepGemm)
265
266 original_gate_up_ptr = shared_experts.gate_up_proj.weight.data_ptr()
267 + original_down_ptr = shared_experts.down_proj.weight.data_ptr()
268 experts.finalize_weights(shared_experts)
269
270 + assert FakeDeepGemm.scale_inputs[-2:] == [
271 + (1, 2 * intermediate_size, hidden_size // 32),
272 + (1, hidden_size, intermediate_size // 32),
273 + ]
274 + assert experts.has_fused_shared_experts is fused
275 + if not fused:
276 + assert experts.intermediate_size == 2560
277 + assert FakeDeepGemm.transformed_dims == [(3, 3)]
278 + assert experts.num_shared_experts == 0
279 + assert shared_experts.gate_up_proj.weight.data_ptr() == original_gate_up_ptr
280 + assert shared_experts.down_proj.weight.data_ptr() == original_down_ptr
281 + assert shared_experts.gate_up_proj.weight.shape == (
282 + 2 * intermediate_size,
283 + hidden_size,
284 + )
285 + assert shared_experts.down_proj.weight.shape == (hidden_size, intermediate_size)
286 + return
287 +
288 assert FakeDeepGemm.transformed_dims == [(3, 3), (2, 2)]
251 - assert FakeDeepGemm.scale_inputs[-2:] == [(1, 256, 4), (1, 128, 4)]
252 - assert experts.has_fused_shared_experts
289 assert shared_experts.gate_up_proj.weight.data_ptr() != original_gate_up_ptr
290 assert (
291 experts._transformed_shared_l1_weights[0].data_ptr()
tests/parser/engine/trace_builder.py
+40 -17
@@ -31,6 +31,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
31 from vllm.parser.engine.registered_adapters import (
32 DeepSeekV4Parser,
33 DeepSeekV32Parser,
34 + DeepSeekV41Parser,
35 Gemma4Parser,
36 Glm47MoeParser,
37 InklingParser,
@@ -658,7 +659,7 @@ _DSV4_VOCAB: dict[str, int] = {
659 }
660
661
661 -def _dsv4_param_text(key: str, value: Any) -> str:
662 +def _dsv4_param_text(key: str, value: Any, dsml: str = _DSML) -> str:
663 is_string = isinstance(value, str)
664 if is_string:
665 val_str = value
@@ -670,36 +671,39 @@ def _dsv4_param_text(key: str, value: Any) -> str:
671 val_str = json.dumps(value, ensure_ascii=False)
672 string_attr = "true" if is_string else "false"
673 return (
673 - f'<{_DSML}parameter name="{key}" string="{string_attr}">'
674 - f"{val_str}</{_DSML}parameter>\n"
674 + f'<{dsml}parameter name="{key}" string="{string_attr}">'
675 + f"{val_str}</{dsml}parameter>\n"
676 )
677
678
678 -def _dsv4_tool_text(tc: ToolCallSpec) -> str:
679 - parts = [f'<{_DSML}invoke name="{tc.name}">\n']
679 +def _dsv4_tool_text(tc: ToolCallSpec, dsml: str = _DSML) -> str:
680 + parts = [f'<{dsml}invoke name="{tc.name}">\n']
681 for key, value in tc.arguments.items():
681 - parts.append(_dsv4_param_text(key, value))
682 - parts.append(f"</{_DSML}invoke>\n")
682 + parts.append(_dsv4_param_text(key, value, dsml))
683 + parts.append(f"</{dsml}invoke>\n")
684 return "".join(parts)
685
686
687 def _dsml_tool_segs(
688 scenario: Scenario,
689 tag: str,
690 + dsml: str = _DSML,
691 ) -> list[tuple[str, bool]]:
692 if not scenario.tool_calls:
693 return []
694 parts = ["\n"]
695 for tc in scenario.tool_calls:
694 - parts.append(_dsv4_tool_text(tc))
696 + parts.append(_dsv4_tool_text(tc, dsml))
697 return [
696 - (f"<{_DSML}{tag}>", True),
698 + (f"<{dsml}{tag}>", True),
699 ("".join(parts), False),
698 - (f"</{_DSML}{tag}>", True),
700 + (f"</{dsml}{tag}>", True),
701 ]
702
703
702 -def _dsv4_segments(scenario: Scenario, thinking: bool) -> list[tuple[str, bool]]:
704 +def _dsv4_segments(
705 + scenario: Scenario, thinking: bool, *, v41: bool = False
706 +) -> list[tuple[str, bool]]:
707 segs: list[tuple[str, bool]] = []
708
709 if thinking:
@@ -716,13 +720,31 @@ def _dsv4_segments(scenario: Scenario, thinking: bool) -> list[tuple[str, bool]]
720 if scenario.content is not None:
721 segs.append((scenario.content, False))
722
719 - segs.extend(_dsml_tool_segs(scenario, "tool_calls"))
723 + segs.extend(
724 + _dsml_tool_segs(scenario, "calls", f"{_DSML} ")
725 + if v41
726 + else _dsml_tool_segs(scenario, "tool_calls")
727 + )
728 return segs
729
730
723 -def _build_deepseek_v4(scenario: Scenario, validate: bool = True) -> Sample:
731 +def _build_deepseek_v4(
732 + scenario: Scenario, validate: bool = True, *, v41: bool = False
733 +) -> Sample:
734 thinking = scenario.reasoning is not None
735 chat_kwargs = {"thinking": thinking}
736 + parser_cls = DeepSeekV41Parser if v41 else DeepSeekV4Parser
737 + model = "deepseek_v41" if v41 else "deepseek_v4"
738 + vocab = (
739 + {
740 + "<think>": 128821,
741 + "</think>": 128822,
742 + f"<{_DSML} calls>": 128823,
743 + f"</{_DSML} calls>": 128824,
744 + }
745 + if v41
746 + else _DSV4_VOCAB
747 + )
748
749 if thinking:
750 expected_reasoning: str | None = scenario.reasoning or ""
@@ -730,10 +752,10 @@ def _build_deepseek_v4(scenario: Scenario, validate: bool = True) -> Sample:
752 expected_reasoning = None
753
754 sample = _make_sample(
733 - sample_id=f"deepseek_v4-{scenario.id}",
755 + sample_id=f"{model}-{scenario.id}",
756 description=scenario.description,
735 - vocab=_DSV4_VOCAB,
736 - segments=_dsv4_segments(scenario, thinking),
757 + vocab=vocab,
758 + segments=_dsv4_segments(scenario, thinking, v41=v41),
759 expected_reasoning=expected_reasoning,
760 expected_content=_qwen3_expected_content(scenario),
761 expected_tool_calls=_expected_tc(scenario),
@@ -744,7 +766,7 @@ def _build_deepseek_v4(scenario: Scenario, validate: bool = True) -> Sample:
766 kwargs = {}
767 if chat_kwargs:
768 kwargs["chat_template_kwargs"] = chat_kwargs
747 - _validate_sample(sample, DeepSeekV4Parser, **kwargs)
769 + _validate_sample(sample, parser_cls, **kwargs)
770 return sample
771
772
@@ -1025,6 +1047,7 @@ def _build_inkling(scenario: Scenario, validate: bool = True) -> Sample:
1047 _BUILDERS: dict[str, Any] = {
1048 "deepseek_v32": _build_deepseek_v32,
1049 "deepseek_v4": _build_deepseek_v4,
1050 + "deepseek_v41": functools.partial(_build_deepseek_v4, v41=True),
1051 "gemma4": _build_gemma4,
1052 "minimax_m2": _build_minimax_m2,
1053 "nemotron_v3": _build_nemotron_v3,
vllm/model_executor/kernels/attention/dsa/candidate_blocks.py new
+234
@@ -0,0 +1,234 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import torch
5 +
6 +from vllm.triton_utils import tl, triton
7 +
8 +
9 +@triton.jit
10 +def _max_with_nan(a, b):
11 + return tl.maximum(a, b, propagate_nan=tl.PropagateNan.ALL)
12 +
13 +
14 +@triton.jit(do_not_specialize=["width", "nblocks"])
15 +def _block_scores_kernel(
16 + logits,
17 + starts,
18 + ends,
19 + scores,
20 + stride_row,
21 + stride_col,
22 + stride_start,
23 + stride_end,
24 + width,
25 + nblocks,
26 + BLOCK_SIZE: tl.constexpr,
27 + HAS_STARTS: tl.constexpr,
28 + ROW_REPEAT: tl.constexpr,
29 + TILE: tl.constexpr,
30 +):
31 + row = tl.program_id(0).to(tl.int64)
32 + blocks = tl.program_id(1) * TILE + tl.arange(0, TILE)
33 + start = tl.load(starts + row // ROW_REPEAT * stride_start) if HAS_STARTS else 0
34 + end = tl.load(ends + row // ROW_REPEAT * stride_end)
35 + offsets = tl.arange(0, triton.next_power_of_2(BLOCK_SIZE))
36 + cols = start + blocks[:, None] * BLOCK_SIZE + offsets[None, :]
37 + values = tl.load(
38 + logits + row * stride_row + cols * stride_col,
39 + (blocks[:, None] < nblocks)
40 + & (offsets[None, :] < BLOCK_SIZE)
41 + & (cols < end)
42 + & (cols < width),
43 + other=-float("inf"),
44 + )
45 + reduced = tl.reduce(values, 1, _max_with_nan)
46 + reduced = tl.where(
47 + (end > start) & (blocks == (end - start - 1) // BLOCK_SIZE),
48 + float("inf"),
49 + reduced,
50 + )
51 + tl.store(scores + row * nblocks + blocks, reduced, blocks < nblocks)
52 +
53 +
54 +@triton.jit(do_not_specialize=["k"])
55 +def _store_candidates_kernel(
56 + values,
57 + indices,
58 + output,
59 + out_stride_row,
60 + out_stride_col,
61 + k,
62 + OUT_K: tl.constexpr,
63 + TILE: tl.constexpr,
64 +):
65 + row = tl.program_id(0).to(tl.int64)
66 + cols = tl.program_id(1) * TILE + tl.arange(0, TILE)
67 + value = tl.load(values + row * k + cols, cols < k, other=-float("inf"))
68 + index = tl.load(indices + row * k + cols, cols < k, other=-1)
69 + tl.store(
70 + output + row * out_stride_row + cols * out_stride_col,
71 + tl.where(value > -float("inf"), index, -1),
72 + cols < OUT_K,
73 + )
74 +
75 +
76 +@triton.jit(do_not_specialize=["width", "nblocks"])
77 +def _candidate_flags_kernel(
78 + candidates,
79 + starts,
80 + flags,
81 + stride_row,
82 + stride_col,
83 + stride_start,
84 + width,
85 + nblocks,
86 + BLOCK_SIZE: tl.constexpr,
87 + K: tl.constexpr,
88 + HAS_STARTS: tl.constexpr,
89 + ROW_REPEAT: tl.constexpr,
90 +):
91 + row = tl.program_id(0).to(tl.int64)
92 + offsets = tl.arange(0, 1024)
93 + for tile in range(tl.cdiv(nblocks + 1, 1024)):
94 + slots = tile * 1024 + offsets
95 + tl.store(flags + row * (nblocks + 1) + slots, 0, slots <= nblocks)
96 + start = tl.load(starts + row // ROW_REPEAT * stride_start) if HAS_STARTS else 0
97 + cols = tl.arange(0, triton.next_power_of_2(K))
98 + block = tl.load(
99 + candidates + row * stride_row + cols * stride_col, cols < K, other=-1
100 + ).to(tl.int64)
101 + # Preserve the packed-column clamp for candidates beyond the logits width.
102 + block = tl.where(start + block * BLOCK_SIZE >= width, nblocks, block)
103 + tl.debug_barrier()
104 + tl.store(flags + row * (nblocks + 1) + block, 1, (cols < K) & (block >= 0))
105 +
106 +
107 +@triton.jit(do_not_specialize=["width", "nblocks"])
108 +def _mask_candidates_kernel(
109 + logits,
110 + starts,
111 + ends,
112 + flags,
113 + stride_row,
114 + stride_col,
115 + stride_start,
116 + stride_end,
117 + width,
118 + nblocks,
119 + BLOCK_SIZE: tl.constexpr,
120 + HAS_STARTS: tl.constexpr,
121 + ROW_REPEAT: tl.constexpr,
122 + TILE: tl.constexpr,
123 +):
124 + row = tl.program_id(0).to(tl.int64)
125 + cols = tl.program_id(1) * TILE + tl.arange(0, TILE)
126 + start = tl.load(starts + row // ROW_REPEAT * stride_start) if HAS_STARTS else 0
127 + end = tl.load(ends + row // ROW_REPEAT * stride_end)
128 + valid = (cols >= start) & (cols < end) & (cols < width)
129 + block = (cols - start) // BLOCK_SIZE
130 + keep = tl.load(flags + row * (nblocks + 1) + block, valid, other=0)
131 + edge = tl.load(flags + row * (nblocks + 1) + nblocks)
132 + keep = (keep != 0) | ((cols == width - 1) & (edge != 0))
133 + tl.store(
134 + logits + row * stride_row + cols * stride_col,
135 + -float("inf"),
136 + (cols < width) & ~(valid & keep),
137 + )
138 +
139 +
140 +def select_candidate_blocks(
141 + logits: torch.Tensor,
142 + row_ks: torch.Tensor | None,
143 + row_ke: torch.Tensor,
144 + topk_blocks: int,
145 + block_size: int,
146 + out: torch.Tensor,
147 + row_repeat: int = 1,
148 +) -> None:
149 + """Select local block IDs by maximum score, pinning each row's newest block.
150 +
151 + Row bounds are in packed column space; absent starts mean zero.
152 + Decode rows share bounds in groups of ``row_repeat``. Output is -1 padded.
153 + """
154 + assert logits.is_cuda
155 + rows, width = logits.shape
156 + if not rows:
157 + return
158 + if not width:
159 + out.fill_(-1)
160 + return
161 + nblocks = triton.cdiv(width, block_size)
162 + scores = logits.new_empty((rows, nblocks))
163 + _block_scores_kernel[(rows, triton.cdiv(nblocks, 128))](
164 + logits,
165 + row_ks,
166 + row_ke,
167 + scores,
168 + *logits.stride(),
169 + row_ks.stride(0) if row_ks is not None else 0,
170 + row_ke.stride(0),
171 + width,
172 + nblocks,
173 + block_size,
174 + row_ks is not None,
175 + row_repeat,
176 + 128,
177 + )
178 + # Keep the existing top-k tie behavior.
179 + top = scores.topk(min(topk_blocks, nblocks), dim=-1)
180 + _store_candidates_kernel[(rows, triton.cdiv(topk_blocks, 256))](
181 + top.values,
182 + top.indices,
183 + out,
184 + *out.stride(),
185 + top.values.shape[1],
186 + topk_blocks,
187 + 256,
188 + )
189 +
190 +
191 +def apply_candidate_mask(
192 + logits: torch.Tensor,
193 + row_ks: torch.Tensor | None,
194 + row_ke: torch.Tensor,
195 + candidate_blocks: torch.Tensor,
196 + block_size: int,
197 + row_repeat: int = 1,
198 +) -> None:
199 + """Mask packed logits outside causal bounds and request-local candidates."""
200 + assert logits.is_cuda
201 + rows, width = logits.shape
202 + if not rows or not width:
203 + return
204 + nblocks = triton.cdiv(width, block_size)
205 + flags = torch.empty((rows, nblocks + 1), device=logits.device, dtype=torch.uint8)
206 + start_stride = row_ks.stride(0) if row_ks is not None else 0
207 + _candidate_flags_kernel[(rows,)](
208 + candidate_blocks,
209 + row_ks,
210 + flags,
211 + *candidate_blocks.stride(),
212 + start_stride,
213 + width,
214 + nblocks,
215 + block_size,
216 + candidate_blocks.shape[1],
217 + row_ks is not None,
218 + row_repeat,
219 + )
220 + _mask_candidates_kernel[(rows, triton.cdiv(width, 1024))](
221 + logits,
222 + row_ks,
223 + row_ke,
224 + flags,
225 + *logits.stride(),
226 + start_stride,
227 + row_ke.stride(0),
228 + width,
229 + nblocks,
230 + block_size,
231 + row_ks is not None,
232 + row_repeat,
233 + 1024,
234 + )
vllm/model_executor/kernels/linear/__init__.py
+15 -3
@@ -109,6 +109,9 @@ from vllm.model_executor.kernels.linear.mxfp8 import (
109 from vllm.model_executor.kernels.linear.mxfp8.b12x import (
110 B12xMxfp8LinearKernel,
111 )
112 +from vllm.model_executor.kernels.linear.mxfp8.deep_gemm import (
113 + DeepGemmMxfp8BmmLinearKernel,
114 +)
115 from vllm.model_executor.kernels.linear.mxfp8.emulation import (
116 EmulationMxfp8LinearKernel,
117 )
@@ -301,6 +304,7 @@ _LINEAR_BACKEND_KERNEL_MAP: dict[str, set[type]] = {
304 },
305 "deep_gemm": {
306 DeepGemmFp8BlockScaledMMKernel,
307 + DeepGemmMxfp8BmmLinearKernel,
308 },
309 "torch": {
310 PerTensorTorchFP8ScaledMMLinearKernel,
@@ -851,13 +855,21 @@ def choose_mp_linear_kernel(
855 )
856
857
854 -def init_mxfp8_linear_kernel() -> Mxfp8LinearKernel:
858 +def init_mxfp8_linear_kernel(*, bmm_batch_size: int | None = None) -> Mxfp8LinearKernel:
859 """Select and instantiate the best MXFP8 linear kernel for the
860 current platform."""
857 - config = Mxfp8LinearLayerConfig()
861 + config = Mxfp8LinearLayerConfig(bmm_batch_size=bmm_batch_size)
862
863 platform = current_platform._enum
860 - possible = list(_POSSIBLE_MXFP8_KERNELS.get(platform, []))
864 + possible: list[type[Mxfp8LinearKernel]]
865 + if bmm_batch_size is not None:
866 + possible = (
867 + [DeepGemmMxfp8BmmLinearKernel, EmulationMxfp8LinearKernel]
868 + if current_platform.is_cuda()
869 + else []
870 + )
871 + else:
872 + possible = list(_POSSIBLE_MXFP8_KERNELS.get(platform, []))
873
874 # Apply --linear-backend filtering when set.
875 possible = _resolve_backend_kernels(possible, "MXFP8")
vllm/model_executor/kernels/linear/mxfp8/Mxfp8LinearKernel.py
+1 -1
@@ -17,7 +17,7 @@ class Mxfp8LinearLayerConfig:
17 uint8 (E8M0) per-block scales at block size 32.
18 """
19
20 - pass
20 + bmm_batch_size: int | None = None
21
22
23 class Mxfp8LinearKernel(ABC):
vllm/model_executor/kernels/linear/mxfp8/deep_gemm.py new
+128
@@ -0,0 +1,128 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import torch
5 +
6 +from vllm.model_executor.layers.quantization.utils.fp8_utils import (
7 + deepgemm_post_process_fp8_weight_block,
8 + per_token_group_quant_fp8_packed_for_deepgemm,
9 +)
10 +from vllm.model_executor.utils import replace_parameter
11 +from vllm.platforms import current_platform
12 +from vllm.utils.deep_gemm import (
13 + fp8_einsum,
14 + is_deep_gemm_supported,
15 +)
16 +from vllm.utils.torch_utils import direct_register_custom_op
17 +
18 +from .Mxfp8LinearKernel import Mxfp8LinearKernel, Mxfp8LinearLayerConfig
19 +
20 +
21 +class DeepGemmMxfp8BmmLinearKernel(Mxfp8LinearKernel):
22 + """Grouped MXFP8 einsum with per-row, 32-element weight scales."""
23 +
24 + def __init__(self, config: Mxfp8LinearLayerConfig):
25 + super().__init__(config)
26 + self.recipe = (1, 1, 32)
27 +
28 + @classmethod
29 + def is_supported(
30 + cls, compute_capability: int | None = None
31 + ) -> tuple[bool, str | None]:
32 + if not current_platform.is_cuda() or not is_deep_gemm_supported():
33 + return False, "DeepGEMM BMM requires a supported CUDA device."
34 + if not current_platform.is_device_capability_family(100):
35 + return False, "DeepGEMM MXFP8 BMM requires Blackwell."
36 + return True, None
37 +
38 + @classmethod
39 + def can_implement(cls, config: Mxfp8LinearLayerConfig) -> tuple[bool, str | None]:
40 + if config.bmm_batch_size is None or config.bmm_batch_size <= 0:
41 + return False, "DeepGEMM BMM requires a positive batch size."
42 + return True, None
43 +
44 + def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
45 + weight = layer.weight.data
46 + if weight.ndim == 3:
47 + return
48 + scale = layer.weight_scale.data
49 + assert weight.dtype == torch.float8_e4m3fn and weight.ndim == 2
50 + assert scale.shape == (weight.shape[0], weight.shape[1] // 32)
51 + assert self.config.bmm_batch_size is not None
52 + weight, scale = deepgemm_post_process_fp8_weight_block(
53 + wq=weight,
54 + ws=scale,
55 + quant_block_shape=(1, 32),
56 + use_e8m0=False,
57 + is_bmm=True,
58 + bmm_batch_size=self.config.bmm_batch_size,
59 + )
60 + replace_parameter(layer, "weight", weight)
61 + replace_parameter(layer, "weight_scale", scale)
62 + layer.weight_block_size = [1, 32]
63 +
64 + def apply_weights(
65 + self,
66 + layer: torch.nn.Module,
67 + x: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
68 + bias: torch.Tensor | None = None,
69 + ) -> torch.Tensor:
70 + """Project [tokens, groups, K] input to [tokens, groups, N]."""
71 + if isinstance(x, tuple):
72 + q_input, input_scale = x
73 + else:
74 + tokens, groups, k = x.shape
75 + q_input, input_scale = per_token_group_quant_fp8_packed_for_deepgemm(
76 + x.reshape(tokens, groups * k),
77 + group_size=32,
78 + use_ue8m0=True,
79 + )
80 + q_input = q_input.view(tokens, groups, k)
81 + input_scale = input_scale.view(tokens, groups, k // 128)
82 + output = torch.ops.vllm.deepgemm_mxfp8_bmm(
83 + q_input, input_scale, layer.weight, layer.weight_scale, list(self.recipe)
84 + )
85 + if bias is not None:
86 + output = output + bias.view(output.shape[1:])
87 + return output
88 +
89 +
90 +def _deepgemm_mxfp8_bmm_fake(
91 + x: torch.Tensor,
92 + x_scale: torch.Tensor,
93 + weight: torch.Tensor,
94 + weight_scale: torch.Tensor,
95 + recipe: list[int],
96 +) -> torch.Tensor:
97 + return torch.empty(
98 + (x.shape[0], weight.shape[0], weight.shape[1]),
99 + device=x.device,
100 + dtype=torch.bfloat16,
101 + )
102 +
103 +
104 +def _deepgemm_mxfp8_bmm(
105 + x: torch.Tensor,
106 + x_scale: torch.Tensor,
107 + weight: torch.Tensor,
108 + weight_scale: torch.Tensor,
109 + recipe: list[int],
110 +) -> torch.Tensor:
111 + output = torch.empty(
112 + (x.shape[0], weight.shape[0], weight.shape[1]),
113 + device=x.device,
114 + dtype=torch.bfloat16,
115 + )
116 + fp8_einsum(
117 + "bhr,hdr->bhd",
118 + (x, x_scale),
119 + (weight, weight_scale),
120 + output,
121 + recipe=tuple(recipe),
122 + )
123 + return output
124 +
125 +
126 +direct_register_custom_op(
127 + "deepgemm_mxfp8_bmm", _deepgemm_mxfp8_bmm, fake_impl=_deepgemm_mxfp8_bmm_fake
128 +)
vllm/model_executor/kernels/linear/mxfp8/flashinfer.py
+38 -14
@@ -4,11 +4,19 @@
4 import torch
5 from torch.nn.parameter import Parameter
6
7 +from vllm.model_executor.layers.fusion.quant_activation import (
8 + QuantizedActivation,
9 + as_quantized_activation,
10 +)
11 from vllm.model_executor.layers.quantization.utils.mxfp8_utils import (
12 MXFP8_BLOCK_SIZE,
13 mxfp8_e4m3_quantize,
14 swizzle_mxfp8_scale,
15 )
16 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
17 + QuantKey,
18 + kMxfp8Dynamic,
19 +)
20 from vllm.platforms import current_platform
21 from vllm.utils import flashinfer as vllm_flashinfer
22 from vllm.utils.flashinfer import has_flashinfer, has_flashinfer_cutedsl
@@ -35,6 +43,10 @@ class FlashInferCutlassMxfp8LinearKernel(Mxfp8LinearKernel):
43 def can_implement(cls, c: Mxfp8LinearLayerConfig) -> tuple[bool, str | None]:
44 return True, None
45
46 + def input_quant_key(self) -> QuantKey:
47 + # Activations use FlashInfer's F8_128x4 swizzled scale layout.
48 + return kMxfp8Dynamic
49 +
50 def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
51 weight = layer.weight.data # [N, K]
52 N, K = weight.shape
@@ -51,16 +63,13 @@ class FlashInferCutlassMxfp8LinearKernel(Mxfp8LinearKernel):
63 def apply_weights(
64 self,
65 layer: torch.nn.Module,
54 - x: torch.Tensor,
66 + x: torch.Tensor | QuantizedActivation,
67 bias: torch.Tensor | None = None,
68 ) -> torch.Tensor:
69 weight = layer.weight
70 weight_scale = layer.weight_scale
59 - out_dtype = x.dtype
71 N, K = weight.shape
72
62 - input_shape = x.shape
63 - input_2d = x.view(-1, K)
73 min_dim = 128
74
75 assert min_dim <= K, (
@@ -75,9 +84,16 @@ class FlashInferCutlassMxfp8LinearKernel(Mxfp8LinearKernel):
84 f"out_features is too small for mm_mxfp8."
85 )
86
78 - input_mxfp8, input_scale = mxfp8_e4m3_quantize(
79 - input_2d, is_sf_swizzled_layout=True
80 - )
87 + qa = as_quantized_activation(x, self.input_quant_key())
88 + if qa is not None:
89 + input_mxfp8, input_scale = qa.data, qa.scale
90 + out_dtype, input_shape = qa.orig_dtype, qa.orig_shape
91 + else:
92 + assert isinstance(x, torch.Tensor)
93 + out_dtype, input_shape = x.dtype, x.shape
94 + input_mxfp8, input_scale = mxfp8_e4m3_quantize(
95 + x.view(-1, K), is_sf_swizzled_layout=True
96 + )
97
98 if not weight.is_contiguous():
99 weight = weight.contiguous()
@@ -118,6 +134,10 @@ class FlashInferCutedslMxfp8LinearKernel(Mxfp8LinearKernel):
134 def can_implement(cls, c: Mxfp8LinearLayerConfig) -> tuple[bool, str | None]:
135 return True, None
136
137 + def input_quant_key(self) -> QuantKey:
138 + # Activations use FlashInfer's F8_128x4 swizzled scale layout.
139 + return kMxfp8Dynamic
140 +
141 def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
142 weight = layer.weight.data # [N, K]
143 N, K = weight.shape
@@ -135,16 +155,13 @@ class FlashInferCutedslMxfp8LinearKernel(Mxfp8LinearKernel):
155 def apply_weights(
156 self,
157 layer: torch.nn.Module,
138 - x: torch.Tensor,
158 + x: torch.Tensor | QuantizedActivation,
159 bias: torch.Tensor | None = None,
160 ) -> torch.Tensor:
161 weight = layer.weight # [K, N], column-major
162 weight_scale = layer.weight_scale
143 - out_dtype = x.dtype
163 K, N = weight.shape
164
146 - input_shape = x.shape
147 - input_2d = x.view(-1, K)
165 min_dim = 128
166
167 assert min_dim <= K, (
@@ -159,9 +176,16 @@ class FlashInferCutedslMxfp8LinearKernel(Mxfp8LinearKernel):
176 f"out_features is too small for mm_mxfp8."
177 )
178
162 - input_mxfp8, input_scale = mxfp8_e4m3_quantize(
163 - input_2d, is_sf_swizzled_layout=True
164 - )
179 + qa = as_quantized_activation(x, self.input_quant_key())
180 + if qa is not None:
181 + input_mxfp8, input_scale = qa.data, qa.scale
182 + out_dtype, input_shape = qa.orig_dtype, qa.orig_shape
183 + else:
184 + assert isinstance(x, torch.Tensor)
185 + out_dtype, input_shape = x.dtype, x.shape
186 + input_mxfp8, input_scale = mxfp8_e4m3_quantize(
187 + x.view(-1, K), is_sf_swizzled_layout=True
188 + )
189
190 output = vllm_flashinfer.mm_mxfp8(
191 input_mxfp8,
vllm/model_executor/kernels/mhc/tilelang.py
+200
@@ -87,6 +87,190 @@ def _tilelang_hc_prenorm_gemm(
87 )
88
89
90 +def mhc_pre_delayed_tilelang(
91 + residual: torch.Tensor,
92 + fn: torch.Tensor,
93 + hc_scale: torch.Tensor,
94 + hc_base: torch.Tensor,
95 + rms_eps: float,
96 + hc_pre_eps: float,
97 + hc_sinkhorn_eps: float,
98 + hc_post_mult_value: float,
99 + sinkhorn_repeat: int,
100 + pre_mix: torch.Tensor | None = None,
101 + x: torch.Tensor | None = None,
102 + norm_weight: torch.Tensor | None = None,
103 + norm_eps: float = 1e-6,
104 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
105 + """Run mHC pre with a carried pre-mix and return the next pre-mix.
106 +
107 + Args:
108 + residual: BF16 residual streams of shape (tokens, hc_mult, hidden_size).
109 + fn: FP32 projection of shape (hc_mult * (hc_mult + 2), input_size).
110 + hc_scale: FP32 scales of shape (3,).
111 + hc_base: FP32 bias of shape (hc_mult * (hc_mult + 2),).
112 + rms_eps: RMS normalization epsilon.
113 + hc_pre_eps: Pre-mix epsilon.
114 + hc_sinkhorn_eps: Sinkhorn epsilon.
115 + hc_post_mult_value: Post-mix multiplier.
116 + sinkhorn_repeat: Number of Sinkhorn iterations.
117 + pre_mix: FP32 coefficients from the previous sublayer, or None to
118 + select residual stream zero at model entry.
119 + x: Optional BF16 projection input of shape (tokens, input_size), for
120 + the first layer's broadcast embedding and summed projection.
121 + norm_weight: Optional BF16 RMSNorm weight for the collapsed input.
122 + norm_eps: RMSNorm epsilon for the collapsed input.
123 +
124 + Returns:
125 + Post and residual coefficients, optionally normalized BF16 layer input,
126 + and the next FP32 pre-mix, with shapes (tokens, hc_mult, 1),
127 + (tokens, hc_mult, hc_mult), (tokens, hidden_size), and (tokens, hc_mult).
128 + """
129 + from vllm.model_executor.kernels.mhc.tilelang_kernels import (
130 + mhc_pre_big_fuse_tilelang,
131 + )
132 + from vllm.model_executor.kernels.mhc.warmup import (
133 + MHC_PRE_NORM_KERNEL,
134 + compute_mhc_pre_num_splits,
135 + )
136 + from vllm.utils.deep_gemm import (
137 + is_deep_gemm_supported,
138 + tf32_hc_prenorm_gemm,
139 + )
140 +
141 + assert residual.ndim == 3 and residual.dtype == torch.bfloat16
142 + assert residual.is_contiguous()
143 + num_tokens, hc_mult, hidden_size = residual.shape
144 + if x is None:
145 + x = residual.view(num_tokens, hc_mult * hidden_size)
146 + assert x.ndim == 2 and x.dtype == torch.bfloat16 and x.is_contiguous()
147 + assert x.shape[0] == num_tokens
148 + input_size = x.shape[1]
149 + mix_size = hc_mult * (hc_mult + 2)
150 + assert fn.shape == (mix_size, input_size) and fn.dtype == torch.float32
151 + assert hc_scale.shape == (3,) and hc_scale.dtype == torch.float32
152 + assert hc_base.shape == (mix_size,) and hc_base.dtype == torch.float32
153 + if pre_mix is not None:
154 + assert pre_mix.shape == (num_tokens, hc_mult)
155 + assert pre_mix.dtype == torch.float32 and pre_mix.is_contiguous()
156 +
157 + next_pre_mix = torch.empty(
158 + num_tokens, hc_mult, dtype=torch.float32, device=residual.device
159 + )
160 + post = torch.empty_like(next_pre_mix)
161 + comb = torch.empty(
162 + num_tokens, hc_mult * hc_mult, dtype=torch.float32, device=residual.device
163 + )
164 + layer_input = torch.empty(
165 + num_tokens, hidden_size, dtype=torch.bfloat16, device=residual.device
166 + )
167 + outputs = (
168 + post.unsqueeze(-1),
169 + comb.view(num_tokens, hc_mult, hc_mult),
170 + layer_input,
171 + next_pre_mix,
172 + )
173 + if num_tokens == 0:
174 + return outputs
175 +
176 + use_deep_gemm = is_deep_gemm_supported()
177 + n_splits = (
178 + compute_mhc_pre_num_splits(input_size, num_tokens) if use_deep_gemm else 1
179 + )
180 + mixes = torch.empty(
181 + n_splits, num_tokens, mix_size, dtype=torch.float32, device=residual.device
182 + )
183 + sqrsum = torch.empty(
184 + n_splits, num_tokens, dtype=torch.float32, device=residual.device
185 + )
186 + if use_deep_gemm:
187 + tf32_hc_prenorm_gemm(x, fn, mixes, sqrsum, n_splits)
188 + else:
189 + _tilelang_hc_prenorm_gemm(x, fn, mixes, sqrsum, input_size, 1)
190 + if norm_weight is not None:
191 + assert norm_weight.shape == (hidden_size,)
192 + assert norm_weight.dtype == torch.bfloat16 and norm_weight.is_contiguous()
193 + MHC_PRE_NORM_KERNEL(
194 + mixes,
195 + sqrsum,
196 + hc_scale,
197 + hc_base,
198 + residual,
199 + post,
200 + comb,
201 + layer_input,
202 + norm_weight,
203 + pre_mix if pre_mix is not None else post,
204 + next_pre_mix,
205 + hidden_size=hidden_size,
206 + rms_eps=rms_eps,
207 + hc_pre_eps=hc_pre_eps,
208 + hc_sinkhorn_eps=hc_sinkhorn_eps,
209 + hc_post_mult_value=hc_post_mult_value,
210 + sinkhorn_repeat=sinkhorn_repeat,
211 + norm_eps=norm_eps,
212 + hc_mult=hc_mult,
213 + use_pre_mix_in=pre_mix is not None,
214 + save_pre_mix=True,
215 + rms_numel=input_size,
216 + )
217 + return outputs
218 + mhc_pre_big_fuse_tilelang(
219 + mixes,
220 + sqrsum,
221 + hc_scale,
222 + hc_base,
223 + residual,
224 + post,
225 + comb,
226 + layer_input,
227 + pre_mix if pre_mix is not None else post,
228 + next_pre_mix,
229 + hidden_size,
230 + rms_eps,
231 + hc_pre_eps,
232 + hc_sinkhorn_eps,
233 + hc_post_mult_value,
234 + sinkhorn_repeat,
235 + n_splits,
236 + hc_mult,
237 + use_pre_mix_in=pre_mix is not None,
238 + save_pre_mix=True,
239 + rms_numel=input_size,
240 + )
241 + return outputs
242 +
243 +
244 +def _mhc_pre_delayed_tilelang_fake(
245 + residual: torch.Tensor,
246 + fn: torch.Tensor,
247 + hc_scale: torch.Tensor,
248 + hc_base: torch.Tensor,
249 + rms_eps: float,
250 + hc_pre_eps: float,
251 + hc_sinkhorn_eps: float,
252 + hc_post_mult_value: float,
253 + sinkhorn_repeat: int,
254 + pre_mix: torch.Tensor | None = None,
255 + x: torch.Tensor | None = None,
256 + norm_weight: torch.Tensor | None = None,
257 + norm_eps: float = 1e-6,
258 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
259 + num_tokens, hc_mult, hidden_size = residual.shape
260 + return (
261 + torch.empty(
262 + num_tokens, hc_mult, 1, dtype=torch.float32, device=residual.device
263 + ),
264 + torch.empty(
265 + num_tokens, hc_mult, hc_mult, dtype=torch.float32, device=residual.device
266 + ),
267 + torch.empty(
268 + num_tokens, hidden_size, dtype=torch.bfloat16, device=residual.device
269 + ),
270 + torch.empty(num_tokens, hc_mult, dtype=torch.float32, device=residual.device),
271 + )
272 +
273 +
274 def mhc_pre_tilelang(
275 residual: torch.Tensor,
276 fn: torch.Tensor,
@@ -219,6 +403,9 @@ def mhc_pre_tilelang(
403 post_mix,
404 comb_mix,
405 layer_input,
406 + # Pre-mix buffers are unused in V4 mode.
407 + post_mix,
408 + post_mix,
409 hidden_size,
410 rms_eps,
411 hc_pre_eps,
@@ -239,6 +426,8 @@ def mhc_pre_tilelang(
426 comb_mix,
427 layer_input,
428 norm_weight,
429 + post_mix,
430 + post_mix,
431 hidden_size,
432 rms_eps,
433 hc_pre_eps,
@@ -622,6 +811,9 @@ def mhc_fused_post_pre_tilelang(
811 post_mix_cur,
812 comb_mix_cur,
813 layer_input_cur,
814 + # Pre-mix buffers are unused in V4 mode.
815 + post_mix_cur,
816 + post_mix_cur,
817 hidden_size,
818 rms_eps,
819 hc_pre_eps,
@@ -642,6 +834,8 @@ def mhc_fused_post_pre_tilelang(
834 comb_mix_cur,
835 layer_input_cur,
836 norm_weight,
837 + post_mix_cur,
838 + post_mix_cur,
839 hidden_size,
840 rms_eps,
841 hc_pre_eps,
@@ -762,6 +956,12 @@ def _hc_head_fused_kernel_tilelang_fake(
956 )
957
958
959 +direct_register_custom_op(
960 + op_name="mhc_pre_delayed_tilelang",
961 + op_func=mhc_pre_delayed_tilelang,
962 + mutates_args=[],
963 + fake_impl=_mhc_pre_delayed_tilelang_fake,
964 +)
965 direct_register_custom_op(
966 op_name="mhc_pre_tilelang",
967 op_func=mhc_pre_tilelang,
vllm/model_executor/kernels/mhc/tilelang_kernels.py
+67 -18
@@ -38,6 +38,8 @@ def mhc_pre_big_fuse_tilelang(
38 post_mix,
39 comb_mix,
40 layer_input,
41 + pre_mix_in,
42 + pre_mix_out,
43 hidden_size: int,
44 rms_eps: float,
45 hc_pre_eps: float,
@@ -46,11 +48,20 @@ def mhc_pre_big_fuse_tilelang(
48 sinkhorn_repeat: int,
49 n_splits: int = 16,
50 hc_mult: int = 4,
51 + use_pre_mix_in: bool = False,
52 + save_pre_mix: bool = False,
53 + rms_numel: int = 0,
54 ):
50 - """Deeply fused kernels, everything other than gemm & sqrsum in mHC pre block."""
55 + """Fuse coefficient generation and residual collapse after the projection.
56 +
57 + With save_pre_mix, store the new pre-mix and collapse with pre_mix_in,
58 + or select stream zero when use_pre_mix_in is false.
59 + """
60 num_tokens = T.dynamic("num_tokens")
61 hc_mult3 = hc_mult * (2 + hc_mult)
62 hidden_block = math.gcd(512, hidden_size)
63 + if rms_numel == 0:
64 + rms_numel = hc_mult * hidden_size
65
66 gemm_out_mul: T.Tensor[[n_splits, num_tokens, hc_mult3], T.float32] # type: ignore[no-redef, valid-type]
67 gemm_out_sqrsum: T.Tensor[[n_splits, num_tokens], T.float32] # type: ignore[no-redef, valid-type]
@@ -62,6 +73,9 @@ def mhc_pre_big_fuse_tilelang(
73 comb_mix: T.Tensor[[num_tokens, hc_mult * hc_mult], T.float32] # type: ignore[no-redef, valid-type]
74 layer_input: T.Tensor[[num_tokens, hidden_size], T.bfloat16] # type: ignore[no-redef, valid-type]
75
76 + pre_mix_in: T.Tensor[[num_tokens, hc_mult], T.float32] # type: ignore[no-redef, valid-type]
77 + pre_mix_out: T.Tensor[[num_tokens, hc_mult], T.float32] # type: ignore[no-redef, valid-type]
78 +
79 with T.Kernel(num_tokens, threads=96) as i:
80 if ENABLE_PDL:
81 T.pdl_sync()
@@ -73,7 +87,7 @@ def mhc_pre_big_fuse_tilelang(
87 rms[0] = 0
88 for i_split in T.serial(n_splits):
89 rms[0] += gemm_out_sqrsum[i_split, i]
76 - rms[0] = T.rsqrt(rms[0] / (hc_mult * hidden_size) + rms_eps)
90 + rms[0] = T.rsqrt(rms[0] / rms_numel + rms_eps)
91 for j in T.Parallel(hc_mult3):
92 mixes[j] = 0
93 for i_split in T.serial(n_splits):
@@ -87,6 +101,11 @@ def mhc_pre_big_fuse_tilelang(
101 # _pre_split_mixes_fwd (post & comb)
102 cm = T.alloc_fragment((hc_mult, hc_mult), T.float32)
103 for j in T.Parallel(hc_mult):
104 + if save_pre_mix:
105 + pre_mix_out[i, j] = (
106 + T.sigmoid(mixes_shared[j] * hc_scale[0] + hc_base[j])
107 + + hc_pre_eps
108 + )
109 post_mix[i, j] = (
110 T.sigmoid(
111 mixes_shared[j + hc_mult] * hc_scale[1] + hc_base[j + hc_mult]
@@ -137,12 +156,15 @@ def mhc_pre_big_fuse_tilelang(
156 # _pre_split_mixes_fwd (pre)
157 pre_mix_shared = T.alloc_shared(hc_mult, T.float32)
158 for j in T.Parallel(hc_mult):
140 - pre_mix_shared[j] = (
141 - T.sigmoid(
142 - mixes_shared[j] * hc_scale[0] + hc_base[j],
159 + if use_pre_mix_in:
160 + pre_mix_shared[j] = pre_mix_in[i, j]
161 + elif save_pre_mix:
162 + pre_mix_shared[j] = T.if_then_else(j == 0, 1.0, 0.0)
163 + else:
164 + pre_mix_shared[j] = (
165 + T.sigmoid(mixes_shared[j] * hc_scale[0] + hc_base[j])
166 + + hc_pre_eps
167 )
144 - + hc_pre_eps
145 - )
168 ###################################################################
169 # _pre_apply_mix_fwd
170 for i0_h in T.Pipelined(hidden_size // hidden_block, num_stages=2):
@@ -179,6 +201,8 @@ def mhc_pre_big_fuse_with_norm_tilelang(
201 comb_mix,
202 layer_input,
203 norm_weight,
204 + pre_mix_in,
205 + pre_mix_out,
206 hidden_size: int,
207 rms_eps: float,
208 hc_pre_eps: float,
@@ -189,11 +213,16 @@ def mhc_pre_big_fuse_with_norm_tilelang(
213 n_splits: int = 16,
214 hc_mult: int = 4,
215 gemm_last_dim: int = -1,
216 + use_pre_mix_in: bool = False,
217 + save_pre_mix: bool = False,
218 + rms_numel: int = 0,
219 ):
220 num_tokens = T.dynamic("num_tokens")
221 hc_mult3 = hc_mult * (2 + hc_mult)
222 if gemm_last_dim < 0:
223 gemm_last_dim = hc_mult3
224 + if rms_numel == 0:
225 + rms_numel = hc_mult * hidden_size
226 hidden_block = math.gcd(1024, hidden_size)
227
228 gemm_out_mul: T.Tensor[[n_splits, num_tokens, gemm_last_dim], T.float32] # type: ignore[no-redef, valid-type]
@@ -206,6 +235,9 @@ def mhc_pre_big_fuse_with_norm_tilelang(
235 layer_input: T.Tensor[[num_tokens, hidden_size], T.bfloat16] # type: ignore[no-redef, valid-type]
236 norm_weight: T.Tensor[[hidden_size], T.bfloat16] # type: ignore[no-redef, valid-type]
237
238 + pre_mix_in: T.Tensor[[num_tokens, hc_mult], T.float32] # type: ignore[no-redef, valid-type]
239 + pre_mix_out: T.Tensor[[num_tokens, hc_mult], T.float32] # type: ignore[no-redef, valid-type]
240 +
241 with T.Kernel(num_tokens, threads=96) as i:
242 rms = T.alloc_fragment(1, T.float32)
243 mixes = T.alloc_fragment(hc_mult3, T.float32)
@@ -217,7 +249,7 @@ def mhc_pre_big_fuse_with_norm_tilelang(
249
250 for i_split in T.serial(n_splits):
251 rms[0] += gemm_out_sqrsum[i_split, i]
220 - rms[0] = T.rsqrt(rms[0] / (hc_mult * hidden_size) + rms_eps)
252 + rms[0] = T.rsqrt(rms[0] / rms_numel + rms_eps)
253 for j in T.Parallel(hc_mult3):
254 mixes[j] = 0
255 for i_split in T.serial(n_splits):
@@ -229,6 +261,11 @@ def mhc_pre_big_fuse_with_norm_tilelang(
261 if T.get_thread_binding() < 32:
262 cm = T.alloc_fragment((hc_mult, hc_mult), T.float32)
263 for j in T.Parallel(hc_mult):
264 + if save_pre_mix:
265 + pre_mix_out[i, j] = (
266 + T.sigmoid(mixes_shared[j] * hc_scale[0] + hc_base[j])
267 + + hc_pre_eps
268 + )
269 post_mix[i, j] = (
270 T.sigmoid(
271 mixes_shared[j + hc_mult] * hc_scale[1] + hc_base[j + hc_mult]
@@ -270,12 +307,15 @@ def mhc_pre_big_fuse_with_norm_tilelang(
307 else:
308 pre_mix_shared = T.alloc_shared(hc_mult, T.float32)
309 for j in T.Parallel(hc_mult):
273 - pre_mix_shared[j] = (
274 - T.sigmoid(
275 - mixes_shared[j] * hc_scale[0] + hc_base[j],
310 + if use_pre_mix_in:
311 + pre_mix_shared[j] = pre_mix_in[i, j]
312 + elif save_pre_mix:
313 + pre_mix_shared[j] = T.if_then_else(j == 0, 1.0, 0.0)
314 + else:
315 + pre_mix_shared[j] = (
316 + T.sigmoid(mixes_shared[j] * hc_scale[0] + hc_base[j])
317 + + hc_pre_eps
318 )
277 - + hc_pre_eps
278 - )
319
320 # Pass 1: stash unnormalized weighted-sum output in shared memory
321 # as bf16 (matches the rounding that RMSNorm would see) while
@@ -298,9 +338,18 @@ def mhc_pre_big_fuse_with_norm_tilelang(
338 for i1_h in T.Parallel(hidden_block):
339 ol[i1_h] += pre * xl[i_hc, i1_h]
340
301 - for i1_h in T.Parallel(hidden_block):
302 - sumsq_per_pos[i1_h] += ol[i1_h] * ol[i1_h]
303 - output_shared[i0_h * hidden_block + i1_h] = T.bfloat16(ol[i1_h])
341 + if save_pre_mix:
342 + # Keep the BF16 boundary before the delayed input RMSNorm.
343 + rounded = T.alloc_fragment(hidden_block, T.bfloat16)
344 + T.copy(ol, rounded)
345 + for i1_h in T.Parallel(hidden_block):
346 + value = T.float32(rounded[i1_h])
347 + sumsq_per_pos[i1_h] += value * value
348 + output_shared[i0_h * hidden_block + i1_h] = rounded[i1_h]
349 + else:
350 + for i1_h in T.Parallel(hidden_block):
351 + sumsq_per_pos[i1_h] += ol[i1_h] * ol[i1_h]
352 + output_shared[i0_h * hidden_block + i1_h] = T.bfloat16(ol[i1_h])
353
354 sumsq = T.alloc_fragment(1, T.float32)
355 T.reduce_sum(sumsq_per_pos, sumsq, dim=0)
@@ -704,7 +753,7 @@ def hc_prenorm_gemm_tilelang(
753
754 for it in T.serial(k_iters):
755 i_k = i_s * k_per_split + it * n_thr + tid
707 - x_val = x[i_n, i_k]
756 + x_val = T.cast(x[i_n, i_k], T.float32)
757 for i_o in T.unroll(tile_n):
758 out_idx = i_t * tile_n + i_o
759 if out_idx < n_out:
@@ -794,7 +843,7 @@ def hc_prenorm_gemm_block_m_tilelang(
843 for i_m in T.unroll(block_m):
844 token_idx = i_mt * block_m + i_m
845 if token_idx < num_tokens:
797 - x_val = x[token_idx, i_k]
846 + x_val = T.cast(x[token_idx, i_k], T.float32)
847 for i_o in T.unroll(tile_n):
848 acc[i_m, i_o] += x_val * fn_val[i_o]
849 if i_t == 0:
vllm/model_executor/kernels/mhc/torch.py
+42
@@ -3,6 +3,48 @@
3 import torch
4
5
6 +def mhc_pre_delayed_torch(
7 + residual: torch.Tensor,
8 + fn: torch.Tensor,
9 + hc_scale: torch.Tensor,
10 + hc_base: torch.Tensor,
11 + rms_eps: float,
12 + hc_pre_eps: float,
13 + hc_sinkhorn_eps: float,
14 + hc_post_mult_value: float,
15 + sinkhorn_repeat: int,
16 + pre_mix: torch.Tensor | None = None,
17 + x: torch.Tensor | None = None,
18 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
19 + """Reference for mHC pre using coefficients from the previous sublayer."""
20 + hc_mult = residual.shape[1]
21 + x = (residual.flatten(1) if x is None else x).float()
22 + mixes = (x @ fn.t()) * torch.rsqrt(x.square().mean(-1, keepdim=True) + rms_eps)
23 + pre = (
24 + torch.sigmoid(mixes[:, :hc_mult] * hc_scale[0] + hc_base[:hc_mult]) + hc_pre_eps
25 + )
26 + post = (
27 + torch.sigmoid(
28 + mixes[:, hc_mult : 2 * hc_mult] * hc_scale[1]
29 + + hc_base[hc_mult : 2 * hc_mult]
30 + )
31 + * hc_post_mult_value
32 + )
33 + comb = mixes[:, 2 * hc_mult :].view(-1, hc_mult, hc_mult) * hc_scale[2]
34 + comb = comb + hc_base[2 * hc_mult :].view(1, hc_mult, hc_mult)
35 + comb = torch.softmax(comb, dim=-1) + hc_sinkhorn_eps
36 + comb = comb / (comb.sum(dim=-2, keepdim=True) + hc_sinkhorn_eps)
37 + for _ in range(sinkhorn_repeat - 1):
38 + comb = comb / (comb.sum(dim=-1, keepdim=True) + hc_sinkhorn_eps)
39 + comb = comb / (comb.sum(dim=-2, keepdim=True) + hc_sinkhorn_eps)
40 + layer_input = (
41 + residual[:, 0]
42 + if pre_mix is None
43 + else (pre_mix.unsqueeze(-1) * residual.float()).sum(dim=1).to(residual.dtype)
44 + )
45 + return post.unsqueeze(-1), comb, layer_input, pre
46 +
47 +
48 def mhc_pre_torch(
49 residual: torch.Tensor,
50 fn: torch.Tensor,
vllm/model_executor/kernels/mhc/triton.py
+45 -1
@@ -78,7 +78,7 @@ def _hc_head_reduce_store_kernel(
78 out_stride_h: tl.constexpr,
79 BLOCK_H: tl.constexpr,
80 ):
81 - token_idx = tl.program_id(0)
81 + token_idx = tl.program_id(0).to(tl.int64)
82 block_idx = tl.program_id(1)
83 offsets = block_idx * BLOCK_H + tl.arange(0, BLOCK_H)
84 mask = offsets < hidden_size
@@ -105,6 +105,42 @@ def _hc_head_reduce_store_kernel(
105 )
106
107
108 +def hc_collapse_triton(x: Tensor, pre_mix: Tensor) -> Tensor:
109 + """Collapse BF16 residual streams with FP32 pre-mix coefficients."""
110 + assert x.ndim == 3 and x.dtype == torch.bfloat16
111 + num_tokens, hc_mult, hidden_size = x.shape
112 + assert pre_mix.shape == (num_tokens, hc_mult)
113 + assert pre_mix.dtype == torch.float32
114 + out = torch.empty(num_tokens, hidden_size, dtype=x.dtype, device=x.device)
115 + if num_tokens == 0:
116 + return out
117 +
118 + block_h = 1024
119 + _hc_head_reduce_store_kernel[(num_tokens, triton.cdiv(hidden_size, block_h))](
120 + pre_mix,
121 + x,
122 + out,
123 + hidden_size,
124 + hc_mult,
125 + pre_mix.stride(0),
126 + pre_mix.stride(1),
127 + x.stride(0),
128 + x.stride(1),
129 + x.stride(2),
130 + out.stride(0),
131 + out.stride(1),
132 + BLOCK_H=block_h,
133 + num_warps=4,
134 + # Preserve the separate FP32 multiply and sum in the Torch reference.
135 + enable_fp_fusion=False,
136 + )
137 + return out
138 +
139 +
140 +def _hc_collapse_triton_fake(x: Tensor, pre_mix: Tensor) -> Tensor:
141 + return torch.empty(x.shape[0], x.shape[2], dtype=x.dtype, device=x.device)
142 +
143 +
144 def hc_head_reduce_triton_kernel(
145 x: torch.Tensor,
146 hc_fn: torch.Tensor,
@@ -172,3 +208,11 @@ direct_register_custom_op(
208 op_func=_hc_head_triton,
209 mutates_args=["out"],
210 )
211 +
212 +
213 +direct_register_custom_op(
214 + op_name="hc_collapse_triton",
215 + op_func=hc_collapse_triton,
216 + mutates_args=[],
217 + fake_impl=_hc_collapse_triton_fake,
218 +)
vllm/model_executor/kernels/mhc/warmup.py new
+74
@@ -0,0 +1,74 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from dataclasses import asdict, dataclass
5 +from typing import Any
6 +
7 +from vllm.model_executor.kernels.mhc.tilelang_kernels import (
8 + compute_num_split,
9 + mhc_pre_big_fuse_with_norm_tilelang,
10 +)
11 +from vllm.model_executor.warmup.jit_warmup import VllmJitKernel
12 +from vllm.platforms import current_platform
13 +from vllm.utils.deep_gemm import is_deep_gemm_supported
14 +from vllm.utils.math_utils import cdiv
15 +
16 +
17 +def compute_mhc_pre_num_splits(input_size: int, num_tokens: int) -> int:
18 + splits = compute_num_split(64, input_size, cdiv(num_tokens, 64))
19 + # Bound both GEMM and fused-normalization specializations during startup.
20 + return 1 if splits == 1 else 4 if splits <= 4 else 16
21 +
22 +
23 +class MHCPreNormKernel(VllmJitKernel["MHCPreNormKernel.CompileKey"]):
24 + @dataclass(frozen=True)
25 + class CompileKey:
26 + hidden_size: int
27 + rms_eps: float
28 + hc_pre_eps: float
29 + hc_sinkhorn_eps: float
30 + hc_post_mult_value: float
31 + sinkhorn_repeat: int
32 + norm_eps: float
33 + n_splits: int
34 + hc_mult: int
35 + use_pre_mix_in: bool
36 + rms_numel: int
37 + save_pre_mix: bool = True
38 +
39 + kernel: Any = staticmethod(mhc_pre_big_fuse_with_norm_tilelang)
40 +
41 + def dispatch(self, *, n_splits, **fields) -> CompileKey: # type: ignore[override]
42 + return self.CompileKey(n_splits=n_splits, **fields)
43 +
44 + def get_warmup_keys(self, *, max_tokens: int, **fields) -> list[CompileKey]:
45 + # The split heuristic changes only at 64-token boundaries.
46 + splits = (
47 + sorted(
48 + {
49 + compute_mhc_pre_num_splits(fields["rms_numel"], num_tokens)
50 + for num_tokens in range(1, max_tokens + 1, 64)
51 + }
52 + )
53 + if is_deep_gemm_supported()
54 + else [1]
55 + )
56 + return self._trace_dispatch(self.dispatch)(n_splits=splits, **fields)
57 +
58 + def compile(self, compile_key: CompileKey) -> None:
59 + if compile_key not in self._compiled_cache:
60 + self._compiled_cache[compile_key] = self.kernel.compile(
61 + **asdict(compile_key)
62 + )
63 +
64 + def __call__(self, *tensors, **fields):
65 + compile_key = self.dispatch(
66 + n_splits=tensors[0].shape[0],
67 + **fields,
68 + )
69 + if not current_platform.is_cuda():
70 + return self.kernel(*tensors, **asdict(compile_key))
71 + return self._get_or_compile(compile_key)(*tensors)
72 +
73 +
74 +MHC_PRE_NORM_KERNEL = MHCPreNormKernel()
vllm/model_executor/layers/fused_moe/router/fused_moe_router.py
+4
@@ -18,6 +18,10 @@ class FusedMoERouter(ABC):
18 def __init__(self, eplb_state: EplbLayerState | None = None):
19 self._routing_replay_out: torch.Tensor | None = None
20 self.eplb_state = eplb_state
21 + # Deepseek V4.1 vision checkpoints carry a second per-expert routing
22 + # bias for image sentinel tokens; attached by model code when present.
23 + self.bias_vl: torch.Tensor | None = None
24 + self.image_sentinel_lo: int = 0
25
26 @abstractmethod
27 def set_capture_fn(
vllm/model_executor/layers/quantization/modelopt.py
+28 -2
@@ -1821,6 +1821,7 @@ class CkptCtx:
1821 """Per-checkpoint facts a QuantKey cannot carry."""
1822
1823 group_size: int | None = None
1824 + scale_block_size: tuple[int, int] | None = None
1825
1826
1827 @dataclass(frozen=True)
@@ -2153,6 +2154,23 @@ class KMxfp8Static(QuantKeyScheme):
2154
2155 key = kMxfp8Static
2156
2157 + @staticmethod
2158 + def get_scale_weight_loader(weight_loader: Callable, ctx: CkptCtx) -> Callable:
2159 + block_rows, block_cols = ctx.scale_block_size or (1, MXFP8_BLOCK_SIZE)
2160 + if block_rows < 1 or block_cols != MXFP8_BLOCK_SIZE:
2161 + raise NotImplementedError(
2162 + f"MXFP8 checkpoint scale block {ctx.scale_block_size} is unsupported"
2163 + )
2164 +
2165 + def scaled_loader(param, loaded_weight, *args, **kwargs):
2166 + assert loaded_weight.dtype in (torch.uint8, torch.float8_e8m0fnu)
2167 + loaded_weight = loaded_weight.view(torch.uint8).repeat_interleave(
2168 + block_rows, dim=0
2169 + )
2170 + return weight_loader(param, loaded_weight, *args, **kwargs)
2171 +
2172 + return scaled_loader if block_rows > 1 else weight_loader
2173 +
2174 def create_weights(self, layer, role, ctx, shapes, wl) -> None:
2175 if role is not WEIGHT:
2176 self.reject(role)
@@ -2171,6 +2189,7 @@ class KMxfp8Static(QuantKeyScheme):
2189 input_dim=1,
2190 output_dim=0,
2191 )
2192 + scale_loader = self.get_scale_weight_loader(wl, ctx)
2193 self.register_params(
2194 layer,
2195 "weight_scale",
@@ -2180,10 +2199,11 @@ class KMxfp8Static(QuantKeyScheme):
2199 ),
2200 MXFP8_SCALE_DTYPE,
2201 ModelWeightParameter,
2183 - wl,
2202 + scale_loader,
2203 input_dim=1,
2204 output_dim=0,
2205 )
2206 + layer.weight_block_size = [1, MXFP8_BLOCK_SIZE]
2207
2208 def process(self, layer, role) -> None:
2209 if role is not WEIGHT:
@@ -2194,6 +2214,8 @@ class KMxfp8Static(QuantKeyScheme):
2214 # the fp8 asserts when the weight is already >=2-byte.
2215 if layer.weight.element_size() >= 2:
2216 return
2217 + if getattr(layer, "is_bmm", False) and layer.weight.ndim == 3:
2218 + return
2219 assert layer.weight.ndim == 2 and layer.weight.dtype == MXFP8_VALUE_DTYPE
2220 assert layer.weight_scale.ndim == 2
2221 assert layer.weight_scale.dtype == MXFP8_SCALE_DTYPE
@@ -2255,7 +2277,9 @@ def select_linear_kernel(
2277 # after #50273); W4A4 → use_a16=False.
2278 return init_nvfp4_linear_kernel(use_a16=spec.activation is None)
2279 if w.scale.dtype == MXFP8_SCALE_DTYPE:
2258 - return init_mxfp8_linear_kernel()
2280 + return init_mxfp8_linear_kernel(
2281 + bmm_batch_size=getattr(layer, "bmm_batch_size", None)
2282 + )
2283 # fp8 family: init_fp8 routes block-vs-plain itself off the activation key,
2284 # and needs a real key -- weight-only fp8 is not a ModelOpt format.
2285 act = spec.activation
@@ -2482,6 +2506,8 @@ class ModelOptLinearMethod(LinearMethodBase):
2506 persistent=False,
2507 )
2508 layer._nvfp4_group_size_for_gather = self.ctx.group_size
2509 + if self.spec.weight == kMxfp8Static and getattr(layer, "is_bmm", False):
2510 + self.kernel = init_mxfp8_linear_kernel(bmm_batch_size=layer.bmm_batch_size)
2511 self.kernel.process_weights_after_loading(layer)
2512
2513 def apply(self, layer, x, bias=None):
vllm/model_executor/layers/sparse_attn_indexer.py
+97 -4
@@ -13,6 +13,12 @@ from vllm.distributed import get_dcp_group, get_pcp_group
13 from vllm.forward_context import get_forward_context
14 from vllm.logger import init_logger
15 from vllm.model_executor.custom_op import CustomOp
16 +from vllm.model_executor.kernels.attention.dsa.candidate_blocks import (
17 + apply_candidate_mask as _apply_candidate_mask,
18 +)
19 +from vllm.model_executor.kernels.attention.dsa.candidate_blocks import (
20 + select_candidate_blocks as _select_candidate_blocks,
21 +)
22 from vllm.model_executor.layers.quantization.utils.quant_utils import (
23 get_fp8_min_max,
24 )
@@ -316,6 +322,9 @@ def sparse_attn_indexer(
322 dcp_world_size: int = 1,
323 cp_kv_cache_interleave_size: int = 1,
324 skip_topk_buffer_clear: bool = False,
325 + candidate_blocks: torch.Tensor | None = None,
326 + candidate_block_size: int = 0,
327 + candidate_write: bool = False,
328 ) -> torch.Tensor:
329 # careful! this will be None in dummy run
330 forward_context = get_forward_context()
@@ -323,6 +332,14 @@ def sparse_attn_indexer(
332 fp8_dtype = current_platform.fp8_dtype()
333 k_cache_prefix = _resolve_layer_name(k_cache_prefix)
334
335 + if candidate_blocks is not None:
336 + # Candidate blocks are request-local; the DCP-sharded logits layout
337 + # would need per-rank translation that is not implemented.
338 + assert dcp_world_size == 1, (
339 + "v4.1 two-level candidate filtering is not supported with DCP."
340 + )
341 + assert candidate_block_size > 0
342 +
343 # assert isinstance(attn_metadata, dict)
344 if not isinstance(attn_metadata, dict):
345 # Reserve workspace for indexer during profiling run
@@ -361,6 +378,9 @@ def sparse_attn_indexer(
378 use_pcp,
379 dense_mha_metadata_layer_name,
380 use_fp4_cache,
381 + candidate_blocks=candidate_blocks,
382 + candidate_block_size=candidate_block_size,
383 + candidate_write=candidate_write,
384 )
385 attn_metadata_narrowed = attn_metadata[k_cache_prefix]
386 assert isinstance(attn_metadata_narrowed, DeepseekV32IndexerMetadata)
@@ -506,6 +526,30 @@ def sparse_attn_indexer(
526 clean_logits=False,
527 )
528 num_rows = logits.shape[0]
529 + if candidate_blocks is not None:
530 + # Two-level selection (v4.1): the candidate source
531 + # publishes its top blocks; later indexers mask their
532 + # scores to them. Both before the row top-k.
533 + chunk_candidates = candidate_blocks[
534 + chunk.token_start : chunk.token_end
535 + ]
536 + if candidate_write:
537 + _select_candidate_blocks(
538 + logits,
539 + cu_seqlen_ks,
540 + cu_seqlen_ke,
541 + chunk_candidates.shape[1],
542 + candidate_block_size,
543 + chunk_candidates,
544 + )
545 + else:
546 + _apply_candidate_mask(
547 + logits,
548 + cu_seqlen_ks,
549 + cu_seqlen_ke,
550 + chunk_candidates,
551 + candidate_block_size,
552 + )
553 ops.top_k_per_row_prefill(
554 logits,
555 cu_seqlen_ks,
@@ -612,6 +656,34 @@ def sparse_attn_indexer(
656 indices=decode_metadata.indices,
657 )
658 num_rows = logits.shape[0]
659 + if candidate_blocks is not None:
660 + # Two-level selection (v4.1) on the decode logits; columns are
661 + # request-local compressed positions. seq_lens is (B, next_n)
662 + # for native spec decode (per-row effective lens) and (B, 1)
663 + # otherwise.
664 + vis = seq_lens.reshape(-1)
665 + row_repeat = next_n if vis.numel() != num_rows else 1
666 + vis = vis[:num_rows]
667 + decode_candidates = candidate_blocks[:num_rows]
668 + if candidate_write:
669 + _select_candidate_blocks(
670 + logits,
671 + None,
672 + vis,
673 + decode_candidates.shape[1],
674 + candidate_block_size,
675 + decode_candidates,
676 + row_repeat,
677 + )
678 + else:
679 + _apply_candidate_mask(
680 + logits,
681 + None,
682 + vis,
683 + decode_candidates,
684 + candidate_block_size,
685 + row_repeat,
686 + )
687 topk_indices = topk_indices_buffer[:num_padded_tokens, :topk_tokens]
688
689 use_cooperative_topk = (
@@ -712,6 +784,9 @@ def sparse_attn_indexer_fake(
784 dcp_world_size: int = 1,
785 cp_kv_cache_interleave_size: int = 1,
786 skip_topk_buffer_clear: bool = False,
787 + candidate_blocks: torch.Tensor | None = None,
788 + candidate_block_size: int = 0,
789 + candidate_write: bool = False,
790 ) -> torch.Tensor:
791 return topk_indices_buffer
792
@@ -719,7 +794,7 @@ def sparse_attn_indexer_fake(
794 direct_register_custom_op(
795 op_name="sparse_attn_indexer",
796 op_func=sparse_attn_indexer,
722 - mutates_args=["topk_indices_buffer"],
797 + mutates_args=["topk_indices_buffer", "candidate_blocks"],
798 fake_impl=sparse_attn_indexer_fake,
799 dispatch_key=current_platform.dispatch_key,
800 )
@@ -751,6 +826,9 @@ class SparseAttnIndexer(CustomOp):
826 skip_k_cache_insert: bool = False,
827 use_fp4_cache: bool = False,
828 compress_ratio: int = 1,
829 + candidate_blocks: torch.Tensor | None = None,
830 + candidate_block_size: int = 0,
831 + candidate_write: bool = False,
832 ):
833 super().__init__()
834 self.k_cache = k_cache
@@ -764,6 +842,11 @@ class SparseAttnIndexer(CustomOp):
842 self.skip_k_cache_insert = skip_k_cache_insert
843 self.use_fp4_cache = use_fp4_cache
844 self.compress_ratio = compress_ratio
845 + # v4.1 two-level selection: the candidate source indexer writes the
846 + # top candidate blocks here; later indexers mask their scores with it.
847 + self.candidate_blocks = candidate_blocks
848 + self.candidate_block_size = candidate_block_size
849 + self.candidate_write = candidate_write
850 self.dense_mha_metadata_layer_name = ""
851 # DCP scalars are constant for the run; resolve them here (config is set
852 # during model construction) and pass them into the custom op, rather
@@ -871,6 +954,9 @@ class SparseAttnIndexer(CustomOp):
954 self.dcp_rank,
955 self.dcp_world_size,
956 self.cp_kv_cache_interleave_size,
957 + candidate_blocks=self.candidate_blocks,
958 + candidate_block_size=self.candidate_block_size,
959 + candidate_write=self.candidate_write,
960 )
961
962 def forward_xpu(
@@ -893,12 +979,16 @@ class SparseAttnIndexer(CustomOp):
979 assert isinstance(q_quant, torch.Tensor), (
980 "AMD sparse_attn_indexer expects a single FP8 q_quant tensor"
981 )
896 - from vllm.platforms.rocm import on_gfx11
982 + from vllm.platforms.rocm import on_gfx11, on_gfx950
983
984 if (
985 rocm_aiter_ops.is_enabled()
986 or rocm_aiter_ops.is_rdna_aiter_enabled()
987 or on_gfx11()
988 + # The so-called AITER sparse indexer op has a native gfx950 path:
989 + # its cache insert, MQA logits, and top-k fallbacks are implemented
990 + # by local Triton/C++ kernels and do not require the aiter package.
991 + or on_gfx950()
992 ):
993 return torch.ops.vllm.rocm_aiter_sparse_attn_indexer(
994 hidden_states,
@@ -916,10 +1006,13 @@ class SparseAttnIndexer(CustomOp):
1006 self.topk_indices_buffer,
1007 skip_k_cache_insert=self.skip_k_cache_insert,
1008 compress_ratio=self.compress_ratio,
1009 + candidate_blocks=self.candidate_blocks,
1010 + candidate_block_size=self.candidate_block_size,
1011 + candidate_write=self.candidate_write,
1012 )
1013 raise RuntimeError(
921 - "Sparse attention indexer ROCm path is only supported on AITER. "
922 - "Please enable aiter with VLLM_ROCM_USE_AITER=1"
1014 + "Sparse attention indexer ROCm path requires AITER or a supported "
1015 + "native architecture (gfx950/gfx11)."
1016 )
1017
1018 def forward_cpu(
vllm/models/deepseek_v4/common/ops/fused_inv_rope_fp8_quant.py
+105 -70
@@ -32,37 +32,39 @@ class FusedInvRopeFP8QuantKernel(
32 fp8_max: float
33 quant_group_size: int
34 chunks_per_head: int
35 - rope_start: int
35 + nope_dim: int
36 half_rope: int
37 tma_aligned_scales: bool
38 launch_pdl: bool
39 + quantize: bool
40
41 @staticmethod
42 # scale_stride_k = align(num_tokens, 4) is only 4-aligned, so its Triton int
43 # class varies with batch size; not specialized so one warmup key covers all
43 - # (int64-cast, scalar addressing only, so perf-neutral).
44 + # (int64-cast, so perf-neutral).
45 @triton.jit(do_not_specialize=["num_tokens", "scale_stride_k"])
46 def kernel(
47 o_ptr,
48 positions_ptr,
49 cos_sin_cache_ptr,
49 - fp8_ptr,
50 + out_ptr,
51 scale_ptr,
52 num_tokens,
53 heads_per_group: tl.constexpr,
54 o_stride_token,
55 o_stride_head,
56 cache_stride_pos,
56 - fp8_stride_group,
57 - fp8_stride_token,
57 + out_stride_group,
58 + out_stride_token,
59 scale_stride_group,
60 scale_stride_k,
61 fp8_max: tl.constexpr,
62 eps: tl.constexpr,
63 QUANT_GROUP_SIZE: tl.constexpr,
64 CHUNKS_PER_HEAD: tl.constexpr,
64 - ROPE_START: tl.constexpr,
65 + NOPE_DIM: tl.constexpr,
66 HALF_ROPE: tl.constexpr,
67 + QUANTIZE: tl.constexpr,
68 TMA_ALIGNED_SCALES: tl.constexpr,
69 launch_pdl: tl.constexpr,
70 ):
@@ -74,8 +76,8 @@ class FusedInvRopeFP8QuantKernel(
76 o_stride_token = o_stride_token.to(tl.int64)
77 o_stride_head = o_stride_head.to(tl.int64)
78 cache_stride_pos = cache_stride_pos.to(tl.int64)
77 - fp8_stride_group = fp8_stride_group.to(tl.int64)
78 - fp8_stride_token = fp8_stride_token.to(tl.int64)
79 + out_stride_group = out_stride_group.to(tl.int64)
80 + out_stride_token = out_stride_token.to(tl.int64)
81 scale_stride_group = scale_stride_group.to(tl.int64)
82 scale_stride_k = scale_stride_k.to(tl.int64)
83
@@ -88,14 +90,18 @@ class FusedInvRopeFP8QuantKernel(
90 tl.extra.cuda.gdc_wait()
91 # Padding rows in the TMA-aligned scale buffer: fill with zero and skip quant.
92 if pid_token >= num_tokens:
93 + if not QUANTIZE:
94 + return
95 if TMA_ALIGNED_SCALES:
96 + packed_offsets = tl.arange(0, CHUNKS_PER_HEAD // 4)
97 scale_addr = (
98 scale_ptr
99 + g * scale_stride_group
100 + pid_token
96 - + head_in_group * scale_stride_k
101 + + (head_in_group * (CHUNKS_PER_HEAD // 4) + packed_offsets)
102 + * scale_stride_k
103 )
98 - tl.store(scale_addr, tl.zeros((), dtype=tl.int32))
104 + tl.store(scale_addr, tl.zeros((CHUNKS_PER_HEAD // 4,), dtype=tl.int32))
105 else:
106 block_offsets = tl.arange(0, CHUNKS_PER_HEAD)
107 qb_indices = qb_start + block_offsets
@@ -114,9 +120,7 @@ class FusedInvRopeFP8QuantKernel(
120 offsets = tl.arange(0, HEAD_DIM)
121 x = tl.load(input_base + offsets).to(tl.float32)
122
117 - rope_abs_start: tl.constexpr = (
118 - CHUNKS_PER_HEAD - 1
119 - ) * QUANT_GROUP_SIZE + ROPE_START
123 + rope_abs_start: tl.constexpr = NOPE_DIM
124 pos = tl.load(positions_ptr + pid_token)
125 cache_base = cos_sin_cache_ptr + pos * cache_stride_pos
126 is_rope = offsets >= rope_abs_start
@@ -134,6 +138,16 @@ class FusedInvRopeFP8QuantKernel(
138 rotated = tl.where(is_even, x_add, x_sub)
139 x = tl.where(is_rope, rotated, x)
140
141 + if not QUANTIZE:
142 + out_base = (
143 + out_ptr
144 + + g * out_stride_group
145 + + pid_token * out_stride_token
146 + + qb_start * QUANT_GROUP_SIZE
147 + )
148 + tl.store(out_base + offsets, x)
149 + return
150 +
151 x_2d = tl.reshape(tl.abs(x), (CHUNKS_PER_HEAD, QUANT_GROUP_SIZE))
152 block_absmax = tl.maximum(tl.max(x_2d, axis=1), eps)
153 scale_raw = block_absmax * (1.0 / fp8_max)
@@ -148,25 +162,31 @@ class FusedInvRopeFP8QuantKernel(
162 )
163 x_quant = tl.clamp(x / scales_exp, -fp8_max, fp8_max).to(tl.float8e4nv)
164
151 - fp8_base = (
152 - fp8_ptr
153 - + g * fp8_stride_group
154 - + pid_token * fp8_stride_token
165 + out_base = (
166 + out_ptr
167 + + g * out_stride_group
168 + + pid_token * out_stride_token
169 + qb_start * QUANT_GROUP_SIZE
170 )
157 - tl.store(fp8_base + offsets, x_quant)
171 + tl.store(out_base + offsets, x_quant)
172
173 block_offsets = tl.arange(0, CHUNKS_PER_HEAD)
174 qb_indices = qb_start + block_offsets
175 if TMA_ALIGNED_SCALES:
176 scale_bits = scales.to(tl.int32, bitcast=True)
177 ue8m0_bytes = (scale_bits >> 23) & 0xFF
164 - packed_val = tl.sum(ue8m0_bytes << (block_offsets * 8))
178 + packed_val = tl.sum(
179 + tl.reshape(ue8m0_bytes, (CHUNKS_PER_HEAD // 4, 4))
180 + << (tl.arange(0, 4)[None, :] * 8),
181 + axis=1,
182 + )
183 + packed_offsets = tl.arange(0, CHUNKS_PER_HEAD // 4)
184 scale_addr = (
185 scale_ptr
186 + g * scale_stride_group
187 + pid_token
169 - + head_in_group * scale_stride_k
188 + + (head_in_group * (CHUNKS_PER_HEAD // 4) + packed_offsets)
189 + * scale_stride_k
190 )
191 tl.store(scale_addr, packed_val)
192 else:
@@ -188,7 +208,7 @@ class FusedInvRopeFP8QuantKernel(
208 quant_group_size: int,
209 runtime_fp8_max: float | None = None,
210 runtime_chunks_per_head: int | None = None,
191 - runtime_rope_start: int | None = None,
211 + runtime_nope_dim: int | None = None,
212 runtime_half_rope: int | None = None,
213 **compile_key_fields: bool,
214 ) -> CompileKey:
@@ -202,11 +222,7 @@ class FusedInvRopeFP8QuantKernel(
222 if runtime_chunks_per_head is not None
223 else head_dim // quant_group_size
224 )
205 - rope_start = (
206 - runtime_rope_start
207 - if runtime_rope_start is not None
208 - else nope_dim % quant_group_size
209 - )
225 + nope_dim = runtime_nope_dim if runtime_nope_dim is not None else nope_dim
226 half_rope = (
227 runtime_half_rope if runtime_half_rope is not None else rope_dim // 2
228 )
@@ -216,7 +232,7 @@ class FusedInvRopeFP8QuantKernel(
232 fp8_max=fp8_max,
233 quant_group_size=quant_group_size,
234 chunks_per_head=chunks_per_head,
219 - rope_start=rope_start,
235 + nope_dim=nope_dim,
236 half_rope=half_rope,
237 )
238
@@ -250,11 +266,12 @@ class FusedInvRopeFP8QuantKernel(
266 quant_group_size=128,
267 tma_aligned_scales=capability.major >= 10,
268 launch_pdl=current_platform.is_arch_support_pdl(),
269 + quantize=True,
270 )
271
272 def warmup_inputs(self, compile_key: CompileKey) -> dict[str, Any]:
273 head_dim = compile_key.chunks_per_head * compile_key.quant_group_size
257 - fp8_dim = compile_key.heads_per_group * head_dim
274 + out_dim = compile_key.heads_per_group * head_dim
275 scale_dtype = torch.int32 if compile_key.tma_aligned_scales else torch.float32
276 return dict(
277 o=TritonWarmupTensor(
@@ -266,10 +283,10 @@ class FusedInvRopeFP8QuantKernel(
283 torch.float32,
284 shape=(1, compile_key.half_rope * 2),
285 ),
269 - fp8_buf=TritonWarmupTensor(
270 - torch.float8_e4m3fn,
271 - shape=(compile_key.heads_per_group, 1, fp8_dim),
272 - strides=(fp8_dim, fp8_dim, 1),
286 + out_buf=TritonWarmupTensor(
287 + torch.float8_e4m3fn if compile_key.quantize else torch.bfloat16,
288 + shape=(compile_key.heads_per_group, 1, out_dim),
289 + strides=(out_dim, out_dim, 1),
290 ),
291 scale_buf=TritonWarmupTensor(
292 scale_dtype,
@@ -280,9 +297,10 @@ class FusedInvRopeFP8QuantKernel(
297 heads_per_group=compile_key.heads_per_group,
298 quant_group_size=compile_key.quant_group_size,
299 chunks_per_head=compile_key.chunks_per_head,
283 - rope_start=compile_key.rope_start,
300 + nope_dim=compile_key.nope_dim,
301 half_rope=compile_key.half_rope,
302 tma_aligned_scales=compile_key.tma_aligned_scales,
303 + quantize=compile_key.quantize,
304 fp8_max=compile_key.fp8_max,
305 launch_pdl=compile_key.launch_pdl,
306 grid=(1, compile_key.heads_per_group),
@@ -294,35 +312,37 @@ class FusedInvRopeFP8QuantKernel(
312 o: torch.Tensor,
313 positions: torch.Tensor,
314 cos_sin_cache: torch.Tensor,
297 - fp8_buf: torch.Tensor,
315 + out_buf: torch.Tensor,
316 scale_buf: torch.Tensor,
317 num_tokens: int,
318 *,
319 heads_per_group: int,
320 quant_group_size: int,
321 chunks_per_head: int,
304 - rope_start: int,
322 + nope_dim: int,
323 half_rope: int,
324 tma_aligned_scales: bool,
325 + quantize: bool,
326 fp8_max: float,
327 launch_pdl: bool,
328 grid: tuple[int, int],
329 ) -> LaunchSpec:
330 return grid, dict(
312 - fp8_ptr=fp8_buf,
331 + out_ptr=out_buf,
332 scale_ptr=scale_buf,
333 o_stride_token=o.stride(0),
334 o_stride_head=o.stride(1),
335 cache_stride_pos=cos_sin_cache.stride(0),
317 - fp8_stride_group=fp8_buf.stride(0),
318 - fp8_stride_token=fp8_buf.stride(1),
319 - scale_stride_group=scale_buf.stride(0),
320 - scale_stride_k=scale_buf.stride(2),
336 + out_stride_group=out_buf.stride(0),
337 + out_stride_token=out_buf.stride(1),
338 + scale_stride_group=scale_buf.stride(0) if quantize else 0,
339 + scale_stride_k=scale_buf.stride(2) if quantize else 0,
340 eps=1e-10,
341 QUANT_GROUP_SIZE=quant_group_size,
342 CHUNKS_PER_HEAD=chunks_per_head,
324 - ROPE_START=rope_start,
343 + NOPE_DIM=nope_dim,
344 HALF_ROPE=half_rope,
345 + QUANTIZE=quantize,
346 TMA_ALIGNED_SCALES=tma_aligned_scales,
347 launch_pdl=launch_pdl,
348 num_stages=1,
@@ -340,6 +360,7 @@ def fused_inv_rope_fp8_quant(
360 rope_dim: int = 64,
361 quant_group_size: int = 128,
362 tma_aligned_scales: bool = False,
363 + quantize: bool = True,
364 ) -> tuple[torch.Tensor, torch.Tensor]:
365 """Fused inverse RoPE + block-scaled FP8 quantization.
366
@@ -354,10 +375,11 @@ def fused_inv_rope_fp8_quant(
375 quant_group_size: FP8 quantization block size (default 128).
376 tma_aligned_scales: Output INT32 packed UE8M0 for SM100 (True)
377 or FP32 for SM90 (False).
378 + quantize: Quantize the rotated output to FP8 and return its scales.
379
380 Returns:
359 - o_fp8: [T, G, D] float8_e4m3fn, strides (D, T*D, 1).
360 - o_scale: Pre-transformed scale tensor for fp8_einsum.
381 + Rotated output in [T, G, D] and its FP8 scales. The scale tensor is
382 + empty when quantization is disabled.
383 """
384 from vllm.utils.deep_gemm import get_tma_aligned_size
385
@@ -365,7 +387,6 @@ def fused_inv_rope_fp8_quant(
387 assert num_heads == n_groups * heads_per_group
388 assert head_dim == nope_dim + rope_dim
389 assert head_dim % quant_group_size == 0
368 - assert nope_dim % quant_group_size == (quant_group_size - rope_dim)
390 assert rope_dim % 2 == 0
391 assert cos_sin_cache.shape[-1] == rope_dim
392 assert cos_sin_cache.dtype == torch.float32
@@ -377,23 +398,26 @@ def fused_inv_rope_fp8_quant(
398 fp8_dtype = torch.float8_e4m3fn
399 fp8_max = torch.finfo(fp8_dtype).max
400
380 - tma_aligned_T = get_tma_aligned_size(num_tokens, 4)
381 - if tma_aligned_scales:
401 + tma_aligned_T = get_tma_aligned_size(num_tokens, 4) if quantize else num_tokens
402 + if quantize and tma_aligned_scales:
403 + assert chunks_per_head % 4 == 0
404 packed_sf_k = (num_scale_blocks + 3) // 4
405 scale_inner = packed_sf_k
384 - else:
406 + elif quantize:
407 scale_inner = num_scale_blocks
408 + else:
409 + scale_inner = 0
410
411 # Run kernel through a custom op so inductor sees an opaque boundary.
412 # It's a pytorch bug, see https://github.com/vllm-project/vllm/issues/41106
389 - fp8_buf, scale_buf = torch.ops.vllm.fused_inv_rope_fp8_quant_kernel(
413 + out_buf, scale_buf = torch.ops.vllm.fused_inv_rope_fp8_quant_kernel(
414 o,
415 positions,
416 cos_sin_cache,
417 heads_per_group,
418 quant_group_size,
419 chunks_per_head,
396 - nope_dim % quant_group_size,
420 + nope_dim,
421 rope_dim // 2,
422 tma_aligned_scales,
423 fp8_max,
@@ -402,8 +426,11 @@ def fused_inv_rope_fp8_quant(
426 n_groups,
427 d,
428 scale_inner,
429 + quantize,
430 )
406 - return fp8_buf.transpose(0, 1), scale_buf.transpose(0, 1)
431 + output = out_buf.transpose(0, 1)
432 + scales = scale_buf.transpose(0, 1) if quantize else scale_buf
433 + return output, scales
434
435
436 def _fused_inv_rope_fp8_quant_kernel_impl(
@@ -413,7 +440,7 @@ def _fused_inv_rope_fp8_quant_kernel_impl(
440 heads_per_group: int,
441 quant_group_size: int,
442 chunks_per_head: int,
416 - rope_start: int,
443 + nope_dim: int,
444 half_rope: int,
445 tma_aligned_scales: bool,
446 fp8_max: float,
@@ -422,41 +449,46 @@ def _fused_inv_rope_fp8_quant_kernel_impl(
449 n_groups: int,
450 d: int,
451 scale_inner: int,
452 + quantize: bool,
453 ) -> tuple[torch.Tensor, torch.Tensor]:
426 - fp8_buf = torch.empty(
427 - (n_groups, num_tokens, d),
428 - dtype=torch.float8_e4m3fn,
429 - device=o.device,
430 - )
454 scale_dtype = torch.int32 if tma_aligned_scales else torch.float32
432 - scale_buf = torch.empty(
433 - n_groups * scale_inner * tma_aligned_T,
434 - dtype=scale_dtype,
455 + out_buf = torch.empty(
456 + (n_groups, num_tokens, d),
457 + dtype=torch.float8_e4m3fn if quantize else o.dtype,
458 device=o.device,
436 - ).as_strided(
437 - (n_groups, num_tokens, scale_inner),
438 - (scale_inner * tma_aligned_T, 1, tma_aligned_T),
459 )
460 + if quantize:
461 + scale_buf = torch.empty(
462 + n_groups * scale_inner * tma_aligned_T,
463 + dtype=scale_dtype,
464 + device=o.device,
465 + ).as_strided(
466 + (n_groups, num_tokens, scale_inner),
467 + (scale_inner * tma_aligned_T, 1, tma_aligned_T),
468 + )
469 + else:
470 + scale_buf = torch.empty(0, dtype=scale_dtype, device=o.device)
471 grid = (tma_aligned_T, n_groups * heads_per_group)
472 launch_pdl = current_platform.is_arch_support_pdl()
473 _FUSED_INV_ROPE_FP8_QUANT_KERNEL(
474 o,
475 positions,
476 cos_sin_cache,
446 - fp8_buf,
477 + out_buf,
478 scale_buf,
479 num_tokens,
480 heads_per_group=heads_per_group,
481 quant_group_size=quant_group_size,
482 chunks_per_head=chunks_per_head,
452 - rope_start=rope_start,
483 + nope_dim=nope_dim,
484 half_rope=half_rope,
485 tma_aligned_scales=tma_aligned_scales,
486 + quantize=quantize,
487 fp8_max=fp8_max,
488 launch_pdl=launch_pdl,
489 grid=grid,
490 )
459 - return fp8_buf, scale_buf
491 + return out_buf, scale_buf
492
493
494 def _fused_inv_rope_fp8_quant_kernel_fake(
@@ -466,7 +498,7 @@ def _fused_inv_rope_fp8_quant_kernel_fake(
498 heads_per_group: int,
499 quant_group_size: int,
500 chunks_per_head: int,
469 - rope_start: int,
501 + nope_dim: int,
502 half_rope: int,
503 tma_aligned_scales: bool,
504 fp8_max: float,
@@ -475,13 +507,16 @@ def _fused_inv_rope_fp8_quant_kernel_fake(
507 n_groups: int,
508 d: int,
509 scale_inner: int,
510 + quantize: bool,
511 ) -> tuple[torch.Tensor, torch.Tensor]:
479 - fp8_buf = torch.empty(
512 + scale_dtype = torch.int32 if tma_aligned_scales else torch.float32
513 + out_buf = torch.empty(
514 (n_groups, num_tokens, d),
481 - dtype=torch.float8_e4m3fn,
515 + dtype=torch.float8_e4m3fn if quantize else o.dtype,
516 device=o.device,
517 )
484 - scale_dtype = torch.int32 if tma_aligned_scales else torch.float32
518 + if not quantize:
519 + return out_buf, torch.empty(0, dtype=scale_dtype, device=o.device)
520 scale_buf = torch.empty(
521 n_groups * scale_inner * tma_aligned_T,
522 dtype=scale_dtype,
@@ -490,7 +525,7 @@ def _fused_inv_rope_fp8_quant_kernel_fake(
525 (n_groups, num_tokens, scale_inner),
526 (scale_inner * tma_aligned_T, 1, tma_aligned_T),
527 )
493 - return fp8_buf, scale_buf
528 + return out_buf, scale_buf
529
530
531 direct_register_custom_op(
vllm/models/deepseek_v4/common/vision.py
+93 -2
@@ -11,13 +11,18 @@ embed and norms are replicated, so the residual stream is full-width on
11 every rank.
12 """
13
14 +import itertools
15 from functools import lru_cache
16
17 import torch
18 import torch.nn.functional as F
19 from torch import nn
20
20 -from vllm.distributed import get_tensor_model_parallel_world_size
21 +from vllm.distributed import (
22 + get_tensor_model_parallel_rank,
23 + get_tensor_model_parallel_world_size,
24 + tensor_model_parallel_all_gather,
25 +)
26 from vllm.model_executor.layers.activation import SiluAndMul
27 from vllm.model_executor.layers.attention.mm_encoder_attention import (
28 MMEncoderAttention,
@@ -29,7 +34,10 @@ from vllm.model_executor.layers.linear import (
34 ReplicatedLinear,
35 RowParallelLinear,
36 )
32 -from vllm.model_executor.models.vision import is_vit_use_data_parallel
37 +from vllm.model_executor.models.vision import (
38 + get_load_balance_assignment,
39 + is_vit_use_data_parallel,
40 +)
41
42
43 @lru_cache(8)
@@ -206,6 +214,7 @@ class DeepseekV4Aligner(nn.Module):
214 super().__init__()
215 use_data_parallel = is_vit_use_data_parallel(config.vision_n_heads)
216 self.downsample_ratio = config.vision_downsample_ratio
217 + self.out_dim = config.hidden_size
218 in_dim = config.vision_dim * self.downsample_ratio**2
219 self.w1 = ColumnParallelLinear(
220 in_dim,
@@ -230,3 +239,85 @@ class DeepseekV4Aligner(nn.Module):
239 hidden, _ = self.w1(x)
240 out, _ = self.w2(F.gelu(hidden))
241 return out
242 +
243 +
244 +def run_dp_sharded_vision_tower(
245 + vision_model: DeepseekV4ViT,
246 + aligner: DeepseekV4Aligner,
247 + patches: torch.Tensor,
248 + vit_grid: list[list[int]],
249 +) -> list[torch.Tensor]:
250 + """Run the ViT + aligner with images sharded across TP ranks.
251 +
252 + Every rank holds the full tower weights (``--mm-encoder-tp-mode data``)
253 + and receives the full ``patches`` batch. Images are assigned to ranks by
254 + patch count (greedy load balancing), each rank encodes only its share,
255 + and per-image embeddings are exchanged with one padded all-gather and
256 + returned in the original image order.
257 +
258 + Args:
259 + vision_model: The (weight-replicated) ViT tower.
260 + aligner: The (weight-replicated) spatial-merge projector.
261 + patches: ``(sum(n_vit_h * n_vit_w), 3, p, p)`` patches of all images.
262 + vit_grid: ``[n_vit_h, n_vit_w]`` per image.
263 +
264 + Returns:
265 + One ``(n_aligner_rows, hidden_size)`` embedding tensor per image.
266 + """
267 + tp_size = get_tensor_model_parallel_world_size()
268 + tp_rank = get_tensor_model_parallel_rank()
269 +
270 + sizes = [h * w for h, w in vit_grid]
271 + cum_patches = [0, *itertools.accumulate(sizes)]
272 + image_to_tp_rank, gpu_sample_counts, _ = get_load_balance_assignment(sizes, tp_size)
273 + cum_sample_counts = [0, *itertools.accumulate(gpu_sample_counts)]
274 +
275 + # Rows the aligner emits per image: each grid dim is padded up to a
276 + # multiple of the merge ratio before r x r patches fold into one row.
277 + r = aligner.downsample_ratio
278 + rows_per_image = [-(-h // r) * -(-w // r) for h, w in vit_grid]
279 +
280 + def rank_image_idxs(g: int) -> list[int]:
281 + return image_to_tp_rank[cum_sample_counts[g] : cum_sample_counts[g + 1]]
282 +
283 + # All-gather needs one shape on every rank; pad each rank's packed
284 + # output to the largest per-rank row count (computable locally).
285 + max_rows = max(
286 + sum(rows_per_image[i] for i in rank_image_idxs(g)) for g in range(tp_size)
287 + )
288 +
289 + local_embeds = [
290 + aligner(
291 + vision_model(
292 + patches[cum_patches[i] : cum_patches[i + 1]],
293 + vit_grid[i][0],
294 + vit_grid[i][1],
295 + ),
296 + vit_grid[i][0],
297 + vit_grid[i][1],
298 + )
299 + for i in rank_image_idxs(tp_rank)
300 + ]
301 + if local_embeds:
302 + embeds_local = torch.cat(local_embeds, dim=0)
303 + else:
304 + embeds_local = patches.new_zeros((0, aligner.out_dim))
305 + if embeds_local.shape[0] < max_rows:
306 + embeds_local = torch.cat(
307 + [
308 + embeds_local,
309 + embeds_local.new_zeros(
310 + (max_rows - embeds_local.shape[0], embeds_local.shape[1])
311 + ),
312 + ],
313 + dim=0,
314 + )
315 + gathered = tensor_model_parallel_all_gather(embeds_local.contiguous(), dim=0)
316 +
317 + out: list[torch.Tensor] = [None] * len(vit_grid) # type: ignore[list-item]
318 + for g in range(tp_size):
319 + offset = g * max_rows
320 + for i in rank_image_idxs(g):
321 + out[i] = gathered[offset : offset + rows_per_image[i]]
322 + offset += rows_per_image[i]
323 + return out
vllm/models/deepseek_v4/nvidia/model.py
+51 -15
@@ -368,9 +368,17 @@ class DeepseekV4MegaMoEExperts(nn.Module):
368 gate_up = shared_experts.gate_up_proj
369 down = shared_experts.down_proj
370 gate_up_weight = gate_up.weight.data
371 - gate_up_scale = gate_up.weight_scale_inv.data
371 + gate_up_scale = (
372 + gate_up.weight_scale
373 + if hasattr(gate_up, "weight_scale")
374 + else gate_up.weight_scale_inv
375 + ).data
376 down_weight = down.weight.data
373 - down_scale = down.weight_scale_inv.data
377 + down_scale = (
378 + down.weight_scale
379 + if hasattr(down, "weight_scale")
380 + else down.weight_scale_inv
381 + ).data
382
383 # MegaMoE's shared FP8 MMA consumes a 1x32 scale for every weight row,
384 # while the checkpoint uses coarser block-FP8 scales (usually
@@ -507,6 +515,23 @@ class DeepseekV4MegaMoEExperts(nn.Module):
515
516 if self._transformed_l1_weights is None:
517 self._check_runtime_supported()
518 + # MegaMoE's 1x32 activation scales need 16-byte TMA rows, so
519 + # pad each gate/up half and the down projection to a multiple of 512.
520 + padded_size = (self.intermediate_size + 511) // 512 * 512
521 + padding = padded_size - self.intermediate_size
522 + if padding:
523 + for param in (self.w13_weight, self.w13_weight_scale):
524 + gate_up = param.data.unflatten(1, (2, self.intermediate_size))
525 + param.data = torch.nn.functional.pad(
526 + gate_up, (0, 0, 0, padding)
527 + ).flatten(1, 2)
528 + self.w2_weight.data = torch.nn.functional.pad(
529 + self.w2_weight.data, (0, padding // 2)
530 + )
531 + self.w2_weight_scale.data = torch.nn.functional.pad(
532 + self.w2_weight_scale.data, (0, padding // 32)
533 + )
534 + self.intermediate_size = padded_size
535 w13_scale = deep_gemm.transform_sf_into_required_layout(
536 self._ue8m0_uint8_to_float(self.w13_weight_scale.data).contiguous(),
537 2 * self.intermediate_size,
@@ -752,6 +777,11 @@ class DeepseekV4MoE(nn.Module):
777 vllm_config: VllmConfig,
778 prefix: str = "",
779 use_sequence_parallel: bool = False,
780 + *,
781 + num_hash_layers: int,
782 + n_routed_experts: int | None = None,
783 + n_activated_experts: int | None = None,
784 + image_sentinel_lo: int = IMAGE_SENTINEL_BASE_ID,
785 ):
786 super().__init__()
787
@@ -774,8 +804,14 @@ class DeepseekV4MoE(nn.Module):
804 self.routed_scaling_factor = getattr(config, "routed_scaling_factor", 1.0)
805 self.hidden_size = config.hidden_size
806
777 - self.n_routed_experts = config.n_routed_experts
778 - self.n_activated_experts = config.num_experts_per_tok
807 + self.n_routed_experts = (
808 + config.n_routed_experts if n_routed_experts is None else n_routed_experts
809 + )
810 + self.n_activated_experts = (
811 + config.num_experts_per_tok
812 + if n_activated_experts is None
813 + else n_activated_experts
814 + )
815 self.moe_intermediate_size = config.moe_intermediate_size
816 self.swiglu_limit = config.swiglu_limit
817 self.renormalize = config.norm_topk_prob
@@ -793,7 +829,7 @@ class DeepseekV4MoE(nn.Module):
829
830 self.gate = GateLinear(
831 input_size=config.hidden_size,
796 - output_size=config.n_routed_experts,
832 + output_size=self.n_routed_experts,
833 bias=False,
834 out_dtype=torch.float32,
835 prefix=f"{prefix}.gate",
@@ -805,9 +841,9 @@ class DeepseekV4MoE(nn.Module):
841 # Image tokens borrow five consecutive reserved in-vocab ids starting
842 # at IMAGE_SENTINEL_BASE_ID; 0 disables vision routing (text model).
843 self.image_sentinel_lo = (
808 - IMAGE_SENTINEL_BASE_ID if getattr(config, "vision_n_layers", 0) > 0 else 0
844 + image_sentinel_lo if getattr(config, "vision_n_layers", 0) > 0 else 0
845 )
810 - is_hash_moe = extract_layer_index(prefix) < config.num_hash_layers
846 + is_hash_moe = extract_layer_index(prefix) < num_hash_layers
847 self.hash_indices_dtype = torch.int64 if self.use_mega_moe else torch.int32
848 if is_hash_moe:
849 # hash MoE doesn't use e_score_correction_bias
@@ -816,8 +852,8 @@ class DeepseekV4MoE(nn.Module):
852 self.gate.tid2eid = nn.Parameter(
853 torch.randint(
854 0,
819 - config.n_routed_experts,
820 - (config.vocab_size, config.num_experts_per_tok),
855 + self.n_routed_experts,
856 + (config.vocab_size, self.n_activated_experts),
857 dtype=self.hash_indices_dtype,
858 ),
859 requires_grad=False,
@@ -828,7 +864,7 @@ class DeepseekV4MoE(nn.Module):
864 # Vision checkpoints ship a gate bias on hash layers too (it is
865 # unused for routing there; image tokens use bias_vl instead).
866 self.gate.e_score_correction_bias = nn.Parameter(
831 - torch.empty(config.n_routed_experts, dtype=torch.float32),
867 + torch.empty(self.n_routed_experts, dtype=torch.float32),
868 requires_grad=False,
869 )
870
@@ -837,7 +873,7 @@ class DeepseekV4MoE(nn.Module):
873 # instead of e_score_correction_bias / the hash table. Created on
874 # every MoE layer, hash layers included.
875 self.gate.bias_vl = nn.Parameter(
840 - torch.empty(config.n_routed_experts, dtype=torch.float32),
876 + torch.empty(self.n_routed_experts, dtype=torch.float32),
877 requires_grad=False,
878 )
879
@@ -874,7 +910,6 @@ class DeepseekV4MoE(nn.Module):
910
911 eplb_config = vllm_config.parallel_config.eplb_config
912 self.n_redundant_experts = eplb_config.num_redundant_experts
877 - self.n_routed_experts = config.n_routed_experts
913 self.n_shared_experts = config.n_shared_experts or 0
914 self.n_logical_experts = self.n_routed_experts
915 self.n_physical_experts = self.n_logical_experts + self.n_redundant_experts
@@ -922,7 +957,7 @@ class DeepseekV4MoE(nn.Module):
957 num_local_experts=self.n_local_physical_experts,
958 experts_start_idx=self.physical_expert_start,
959 num_logical_experts=self.n_logical_experts,
925 - top_k=config.num_experts_per_tok,
960 + top_k=self.n_activated_experts,
961 hidden_size=config.hidden_size,
962 intermediate_size=config.moe_intermediate_size,
963 num_shared_experts=(self.n_shared_experts if fuse_shared_experts else 0),
@@ -961,8 +996,8 @@ class DeepseekV4MoE(nn.Module):
996 self.experts = FusedMoEFactory(
997 shared_experts=self.shared_experts,
998 gate=self.gate,
964 - num_experts=config.n_routed_experts,
965 - top_k=config.num_experts_per_tok,
999 + num_experts=self.n_routed_experts,
1000 + top_k=self.n_activated_experts,
1001 hidden_size=config.hidden_size,
1002 intermediate_size=config.moe_intermediate_size,
1003 renormalize=config.norm_topk_prob,
@@ -1120,6 +1155,7 @@ class DeepseekV4DecoderLayer(nn.Module):
1155 vllm_config,
1156 prefix=f"{prefix}.ffn",
1157 use_sequence_parallel=self.use_sequence_parallel,
1158 + num_hash_layers=config.num_hash_layers,
1159 )
1160
1161 self.attn_norm = RMSNorm(self.hidden_size, self.rms_norm_eps)
vllm/models/deepseek_v4/nvidia/ops/fused_indexer_q_cutedsl.py
+7 -3
@@ -165,9 +165,13 @@ class IndexerQRopeQuantKernel:
165 # NOTE: token_id may exceed bounds, hence we need to add load/store guards
166 # we can't do early exit because CuteDSL doesn't support it. and we also need
167 # all threads in a warp to be active since we utilize warp shuffle later.
168 - # must_in_bounds is constexpr, True when 1 threadblock fit within 1 token
169 - # position. the compiler will remove bounds check when that happens.
170 - must_in_bounds = cutlass.const_expr(self.tb_size % self.threads_per_token == 0)
168 + # must_in_bounds is constexpr, True when a token spans a whole number of
169 + # threadblocks. Then num_tokens * threads_per_token is always a multiple of
170 + # tb_size, so the trailing block never straddles num_tokens and the compiler
171 + # removes the bounds check. Otherwise the guard has to stay: e.g. 32 heads
172 + # with coarsen=4 needs 64 threads per token, so an odd num_tokens leaves half
173 + # of the last block addressing token num_tokens.
174 + must_in_bounds = cutlass.const_expr(self.threads_per_token % self.tb_size == 0)
175 in_bounds = must_in_bounds or (token_id < num_tokens)
176
177 cp_op = cute.nvgpu.CopyUniversalOp()
vllm/models/deepseek_v4/nvidia/ops/o_proj.py
+32 -18
@@ -10,17 +10,18 @@ from vllm.platforms import current_platform
10 from vllm.utils.deep_gemm import fp8_einsum
11
12
13 -def compute_fp8_einsum_recipe() -> tuple[tuple[int, int, int], bool]:
13 +def compute_fp8_einsum_recipe(
14 + block_size: int = 128,
15 +) -> tuple[tuple[int, int, int], bool]:
16 """fp8_einsum recipe + scale layout for the current GPU arch.
17
16 - SM90: FP32 block scales stay [g, r/128, d/128] → sfb_gran_mn=128.
17 - SM100: INT32 packed scales become [g, r, ...] → sfb_gran_mn=1.
18 + SM90 keeps block-row FP32 scales. SM100 uses packed per-row E8M0 scales.
19
20 Returns ``(einsum_recipe, tma_aligned_scales)`` for ``deep_gemm_fp8_o_proj``.
21 """
22 cap = current_platform.get_device_capability()
23 assert cap is not None, "DeepseekV4 attention requires a CUDA device"
23 - einsum_recipe = (1, 128, 128) if cap.major <= 9 else (1, 1, 128)
24 + einsum_recipe = (1, 128, 128) if cap.major <= 9 else (1, 1, block_size)
25 tma_aligned_scales = cap.major >= 10
26 return einsum_recipe, tma_aligned_scales
27
@@ -40,12 +41,13 @@ def deep_gemm_fp8_o_proj(
41 einsum_recipe: tuple[int, int, int],
42 tma_aligned_scales: bool,
43 ) -> torch.Tensor:
43 - """O projection: inverse RoPE + FP8 quant + einsum + wo_b.
44 + """O projection: inverse RoPE + grouped wo_a + wo_b.
45
45 - Shared by the FlashMLA and FlashInfer CUDA backends. ``einsum_recipe`` /
46 - ``tma_aligned_scales`` come from ``compute_fp8_einsum_recipe``.
46 + Shared by the FlashMLA and FlashInfer CUDA backends. The attention
47 + layer selects the recipe at initialization.
48 """
48 - o_fp8, o_scale = fused_inv_rope_fp8_quant(
49 + use_fp8 = wo_a.weight.dtype == torch.float8_e4m3fn
50 + o_proj_input, o_scale = fused_inv_rope_fp8_quant(
51 o,
52 positions,
53 cos_sin_cache,
@@ -53,21 +55,33 @@ def deep_gemm_fp8_o_proj(
55 heads_per_group=heads_per_group,
56 nope_dim=nope_dim,
57 rope_dim=rope_dim,
58 + quant_group_size=einsum_recipe[2],
59 tma_aligned_scales=tma_aligned_scales,
60 + quantize=use_fp8,
61 )
62 z = torch.empty(
63 (o.shape[0], n_groups, o_lora_rank),
64 device=o.device,
65 dtype=torch.bfloat16,
66 )
63 - weight_scale = (
64 - wo_a.weight_scale if hasattr(wo_a, "weight_scale") else wo_a.weight_scale_inv
65 - )
66 - fp8_einsum(
67 - "bhr,hdr->bhd",
68 - (o_fp8, o_scale),
69 - (wo_a.weight, weight_scale),
70 - z,
71 - recipe=einsum_recipe,
72 - )
67 + if use_fp8:
68 + weight_scale = (
69 + wo_a.weight_scale
70 + if hasattr(wo_a, "weight_scale")
71 + else wo_a.weight_scale_inv
72 + )
73 + fp8_einsum(
74 + "bhr,hdr->bhd",
75 + (o_proj_input, o_scale),
76 + (wo_a.weight, weight_scale),
77 + z,
78 + recipe=einsum_recipe,
79 + )
80 + else:
81 + grouped_weight = wo_a.weight.view(n_groups, o_lora_rank, -1)
82 + torch.bmm(
83 + o_proj_input.transpose(0, 1),
84 + grouped_weight.transpose(1, 2),
85 + out=z.transpose(0, 1),
86 + )
87 return wo_b(z.flatten(1))
vllm/models/deepseek_v4_1/__init__.py new
+20
@@ -0,0 +1,20 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepSeek V4.1 hardware-isolated model entry point."""
4 +
5 +from vllm.platforms import current_platform
6 +
7 +from .quant_config import DeepseekV4FP8Config
8 +
9 +if current_platform.is_rocm():
10 + from .amd.dspark import DSparkDeepseekV4ForCausalLM
11 + from .amd.vl_model import DeepseekV41ForCausalLM
12 +else:
13 + from .nvidia.dspark import DSparkDeepseekV4ForCausalLM
14 + from .nvidia.vl_model import DeepseekV41ForCausalLM
15 +
16 +__all__ = [
17 + "DSparkDeepseekV4ForCausalLM",
18 + "DeepseekV4FP8Config",
19 + "DeepseekV41ForCausalLM",
20 +]
vllm/models/deepseek_v4_1/amd/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/models/deepseek_v4_1/amd/dspark.py new
+546
@@ -0,0 +1,546 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DSpark draft model for DeepSeek-V4.1 (semi-autoregressive speculative decoding).
4 +
5 +See: qwen3_dspark.py for base architecture. This one is specialized to the
6 +DSV4.1 DSpark, which reuses the target model's architecture similarly to MTP.
7 +Its weights ship in the target checkpoint under the ``mtp.{0,1,2}.*`` prefix.
8 +
9 +To implement non-causal attention, we leverage the sparse attention implementation to
10 +include the future query tokens in the top-k indices for each query token.
11 +"""
12 +
13 +from collections.abc import Iterable
14 +
15 +import regex as re
16 +import torch
17 +import torch.nn as nn
18 +
19 +import vllm.envs as envs
20 +from vllm.config import VllmConfig, get_current_vllm_config
21 +from vllm.distributed import (
22 + get_tensor_model_parallel_rank,
23 + get_tensor_model_parallel_world_size,
24 +)
25 +from vllm.forward_context import get_forward_context, is_forward_context_available
26 +from vllm.logger import init_logger
27 +from vllm.model_executor.kernels.mhc.torch import mhc_post_torch
28 +from vllm.model_executor.layers.fused_moe import (
29 + fused_moe_make_expert_params_mapping,
30 +)
31 +from vllm.model_executor.layers.layernorm import RMSNorm
32 +from vllm.model_executor.layers.linear import ReplicatedLinear
33 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
34 +from vllm.model_executor.layers.vocab_parallel_embedding import (
35 + ParallelLMHead,
36 + VocabParallelEmbedding,
37 +)
38 +from vllm.model_executor.model_loader.weight_utils import default_weight_loader
39 +from vllm.model_executor.models.qwen3_dspark import (
40 + DSparkConfidenceHead,
41 + DSparkMarkovHead,
42 +)
43 +from vllm.model_executor.models.utils import maybe_prefix
44 +from vllm.models.common.ops.sequence_parallel import (
45 + sp_all_gather,
46 + sp_padding_mask,
47 + sp_shard,
48 +)
49 +
50 +from .model import (
51 + DeepseekV4DecoderLayer,
52 + DeepseekV4Model,
53 + _linear_scale_param_name,
54 + _use_sequence_parallel,
55 +)
56 +
57 +logger = init_logger(__name__)
58 +
59 +# MoE expert scale suffix differs by expert dtype (mirrors deepseek_v4 loaders):
60 +# fp4 experts register ``.weight_scale``; block-fp8 experts ``.weight_scale_inv``.
61 +_EXPERT_SCALE_RE = re.compile(r"\.experts\.\d+\.w[123]\.scale$")
62 +
63 +
64 +class DSparkDeepseekV4Model(nn.Module):
65 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
66 + super().__init__()
67 + assert vllm_config.speculative_config is not None
68 + config = vllm_config.speculative_config.draft_model_config.hf_config
69 + self.config = config
70 + self.hidden_size = config.hidden_size
71 + self.hc_mult = config.hc_mult
72 + self.hc_eps = config.hc_eps
73 + self.rms_norm_eps = config.rms_norm_eps
74 + self.num_hidden_layers = config.num_hidden_layers
75 + self.target_layer_ids = tuple(config.dspark_target_layer_ids)
76 + self.use_sequence_parallel = _use_sequence_parallel(vllm_config)
77 +
78 + self.num_dspark_layers = (
79 + getattr(config, "n_mtp_layers", None)
80 + or getattr(config, "num_nextn_predict_layers", None)
81 + or 3
82 + )
83 +
84 + # Shared with the target (aliased by the speculator's loading utility).
85 + self.embed_tokens = VocabParallelEmbedding(
86 + config.vocab_size,
87 + config.hidden_size,
88 + prefix=maybe_prefix(prefix, "embed_tokens"),
89 + )
90 +
91 + self.main_proj = ReplicatedLinear(
92 + config.hidden_size * len(self.target_layer_ids),
93 + config.hidden_size,
94 + bias=False,
95 + return_bias=False,
96 + quant_config=vllm_config.quant_config,
97 + prefix=maybe_prefix(prefix, "main_proj"),
98 + )
99 + self.main_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
100 +
101 + self.topk_indices_buffer = torch.empty(
102 + vllm_config.scheduler_config.max_num_batched_tokens,
103 + config.index_topk,
104 + dtype=torch.int32,
105 + )
106 +
107 + current_vllm_config = get_current_vllm_config()
108 + self.layers = nn.ModuleList(
109 + [
110 + DeepseekV4DecoderLayer(
111 + current_vllm_config,
112 + prefix=maybe_prefix(prefix, f"layers.{self.num_hidden_layers + i}"),
113 + topk_indices_buffer=self.topk_indices_buffer,
114 + )
115 + for i in range(self.num_dspark_layers)
116 + ]
117 + )
118 +
119 + # Heads: final norm, and the Markov + confidence heads.
120 + # Loaded from the "final" MTP layer weights (mtp.*) in the target
121 + # checkpoint. v4.1 has no learned hc_head: the hc copies are
122 + # collapsed with a pre-mix derived from the last layer's hc_ffn
123 + # projection at the end of forward().
124 + self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
125 + draft_vocab_size = (
126 + getattr(config, "draft_vocab_size", None) or config.vocab_size
127 + )
128 + self.markov_head = DSparkMarkovHead(
129 + config.vocab_size,
130 + draft_vocab_size,
131 + config.dspark_markov_rank,
132 + prefix=maybe_prefix(prefix, "markov_head"),
133 + )
134 + self.confidence_head: DSparkConfidenceHead | None = None
135 + if getattr(config, "enable_confidence_head", True):
136 + self.confidence_head = DSparkConfidenceHead(
137 + config.hidden_size + config.dspark_markov_rank,
138 + prefix=maybe_prefix(prefix, "confidence_head"),
139 + )
140 +
141 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
142 + return self.embed_tokens(input_ids)
143 +
144 + def combine_hidden_states(self, aux_hidden_states: torch.Tensor) -> torch.Tensor:
145 + """main_x = main_norm(main_proj(concat of target aux hidden states)).
146 +
147 + ``aux_hidden_states`` is [T, hidden_size * len(target_layer_ids)].
148 + """
149 + return self.main_norm(self.main_proj(aux_hidden_states))
150 +
151 + @torch.inference_mode()
152 + def precompute_and_store_context_kv(
153 + self,
154 + main_x: torch.Tensor,
155 + context_positions: torch.Tensor,
156 + context_slot_mappings: list[torch.Tensor | None] | None = None,
157 + ) -> None:
158 + """Insert the sliding-window context KV for every draft layer.
159 +
160 + Mirrors the reference DSparkAttention: each layer derives its context KV
161 + from the SAME projected target hidden ``main_x``, via that layer's own
162 + ``wkv`` + ``kv_norm`` + RoPE + quant, then writes it at the
163 + layer's context slots.
164 +
165 + ``context_slot_mappings`` is a per-layer list (each entry is the context
166 + slot mapping for that layer's kv-cache group, since the hybrid manager may
167 + place draft layers in different groups). ``None`` (or a ``None`` entry)
168 + runs the projection to reserve workspace but writes nothing (profiling).
169 + """
170 + for i, layer in enumerate(self.layers):
171 + slot_mapping = (
172 + None if context_slot_mappings is None else context_slot_mappings[i]
173 + )
174 + attn = layer.attn
175 + # Optimized DSV4 MLA path: wkv part of the fused wq_a|wkv projection
176 + # (q_lora part discarded), then RoPE/quant/insert via the fused op.
177 + qr_kv, _ = attn.fused_wqa_wkv(main_x)
178 + kv = qr_kv[..., attn.q_lora_rank :]
179 + kv = attn.kv_norm(kv)
180 + if slot_mapping is None:
181 + continue
182 + _insert_context_kv(attn, kv, context_positions, slot_mapping)
183 +
184 + def forward(
185 + self,
186 + input_ids: torch.Tensor,
187 + positions: torch.Tensor,
188 + inputs_embeds: torch.Tensor | None = None,
189 + ) -> torch.Tensor:
190 + if inputs_embeds is None:
191 + inputs_embeds = self.embed_input_ids(input_ids)
192 + full_num_tokens = positions.shape[0]
193 + if self.use_sequence_parallel:
194 + if envs.VLLM_MOE_SKIP_PADDING and is_forward_context_available():
195 + forward_context = get_forward_context()
196 + forward_context.is_padding = sp_padding_mask(
197 + forward_context.is_padding, inputs_embeds
198 + )
199 + inputs_embeds = sp_shard(inputs_embeds)
200 + input_ids = sp_shard(input_ids)
201 + # Expand to hc_mult copies for hyper-connections ([T, H] -> [T, hc, H]).
202 + hidden_states = inputs_embeds.unsqueeze(-2).repeat(1, self.hc_mult, 1)
203 +
204 + residual = post_mix = res_mix = pre_mix = None
205 + for layer in self.layers:
206 + hidden_states, residual, post_mix, res_mix, pre_mix = layer(
207 + hidden_states,
208 + positions,
209 + input_ids,
210 + pre_mix,
211 + post_mix,
212 + res_mix,
213 + residual,
214 + )
215 + hidden_states = mhc_post_torch(hidden_states, residual, post_mix, res_mix)
216 + if self.use_sequence_parallel:
217 + hidden_states = sp_all_gather(hidden_states)[:full_num_tokens]
218 + pre_mix = sp_all_gather(pre_mix)[:full_num_tokens]
219 + # Collapse the hc copies with the pre-mix from the last layer's FFN
220 + # mixes — the mix the reference forward_head applies (hc_pre with the
221 + # last block's ffn pre-mix). Return the PRE-norm head hidden;
222 + # compute_logits applies self.norm.
223 + hidden_states = DeepseekV4DecoderLayer._hc_collapse(hidden_states, pre_mix)
224 + return hidden_states
225 +
226 +
227 +def _insert_context_kv(
228 + attn: nn.Module,
229 + kv: torch.Tensor,
230 + positions: torch.Tensor,
231 + slot_mapping: torch.Tensor,
232 +) -> None:
233 + """RoPE + quant + paged-cache insert of (already kv_norm'd) context KV.
234 +
235 + Reuses the DSV4 fused insert ops (which also process a query; we pass a dummy
236 + query and discard it, since context tokens have no query). Mirrors
237 + ``DeepseekV4Attention._fused_qnorm_rope_kv_insert``.
238 + """
239 + swa_cache = attn.swa_cache_layer.kv_cache
240 + block_size = attn.swa_cache_layer.block_size
241 + cos_sin_cache = attn.rotary_emb.cos_sin_cache
242 + cache_dtype = swa_cache.dtype
243 + n_ctx = kv.shape[0]
244 + dummy_q = torch.zeros(
245 + (n_ctx, attn.n_local_heads, attn.head_dim),
246 + dtype=kv.dtype,
247 + device=kv.device,
248 + )
249 + if cache_dtype == torch.uint8:
250 + # fp8_ds_mla UE8M0 paged layout
251 + swa_2d = swa_cache.view(swa_cache.shape[0], -1)
252 + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
253 + dummy_q,
254 + kv,
255 + swa_2d,
256 + slot_mapping,
257 + positions,
258 + cos_sin_cache,
259 + attn.padded_heads,
260 + attn.eps,
261 + block_size,
262 + )
263 + elif cache_dtype == torch.bfloat16:
264 + swa_3d = swa_cache.view(-1, block_size, attn.head_dim)
265 + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(
266 + dummy_q,
267 + kv,
268 + swa_3d,
269 + slot_mapping,
270 + positions,
271 + cos_sin_cache,
272 + attn.eps,
273 + block_size,
274 + )
275 + else: # per-tensor fp8 (torch.float8_e4m3fn)
276 + # TODO(ben): double-check if this is being dispatched correctly for FI backend
277 + swa_3d = swa_cache.view(-1, block_size, attn.head_dim)
278 + dummy_q_fp8 = torch.zeros_like(dummy_q, dtype=torch.float8_e4m3fn)
279 + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert(
280 + dummy_q,
281 + kv,
282 + dummy_q_fp8,
283 + swa_3d,
284 + slot_mapping,
285 + positions,
286 + cos_sin_cache,
287 + attn._flashinfer_fp8_kv_scale,
288 + attn._flashinfer_fp8_q_scale_inv,
289 + attn.eps,
290 + block_size,
291 + )
292 +
293 +
294 +class DSparkDeepseekV4ForCausalLM(nn.Module):
295 + # Draft weights ship in the target checkpoint (mtp.*) without embed/head, so
296 + # load_dspark_model always aliases the target's.
297 + has_own_embed_tokens = False
298 + has_own_lm_head = False
299 + # Full-vocab draft: draft ids are target ids, no remapping needed.
300 + draft_id_to_target_id = None
301 +
302 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
303 + super().__init__()
304 + assert vllm_config.speculative_config is not None
305 + self.draft_model_config = vllm_config.speculative_config.draft_model_config
306 + self.config = self.draft_model_config.hf_config
307 + self.quant_config = vllm_config.quant_config
308 + self.linear_scale_name = _linear_scale_param_name(
309 + vllm_config, getattr(self.config, "expert_dtype", "fp4")
310 + )
311 + self.pad_shared_expert = getattr(
312 + self.quant_config, "weight_block_size", None
313 + ) is not None and not _use_sequence_parallel(vllm_config)
314 + self.model = DSparkDeepseekV4Model(
315 + vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
316 + )
317 + # Shared with the target (aliased by the speculator's load utility).
318 + self.lm_head = ParallelLMHead(
319 + self.config.vocab_size,
320 + self.config.hidden_size,
321 + prefix=maybe_prefix(prefix, "lm_head"),
322 + )
323 + self.logits_processor = LogitsProcessor(self.config.vocab_size)
324 +
325 + # --- Hooks used by the speculator -------------------------------------
326 +
327 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
328 + return self.model.embed_input_ids(input_ids)
329 +
330 + def combine_hidden_states(self, aux_hidden_states: torch.Tensor) -> torch.Tensor:
331 + return self.model.combine_hidden_states(aux_hidden_states)
332 +
333 + def get_draft_kv_cache_layer_names(self) -> list[str]:
334 + # DSV4 MLA path: each draft layer's sliding-window cache is a separate
335 + # layer, named by its prefix.
336 + return [layer.attn.swa_cache_layer.prefix for layer in self.model.layers]
337 +
338 + def precompute_and_store_context_kv(
339 + self,
340 + context_states: torch.Tensor,
341 + context_positions: torch.Tensor,
342 + context_slot_mappings: list[torch.Tensor | None] | None = None,
343 + ) -> None:
344 + self.model.precompute_and_store_context_kv(
345 + context_states, context_positions, context_slot_mappings
346 + )
347 +
348 + def forward(
349 + self,
350 + input_ids: torch.Tensor,
351 + positions: torch.Tensor,
352 + inputs_embeds: torch.Tensor | None = None,
353 + ) -> torch.Tensor:
354 + # Returns the pre-norm collapsed head hidden ([T, hidden_size]).
355 + return self.model(input_ids, positions, inputs_embeds)
356 +
357 + def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
358 + """Base logits U_k = lm_head(norm(head_hidden))."""
359 + return self.logits_processor(self.lm_head, self.model.norm(hidden_states))
360 +
361 + def compute_draft_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
362 + # Full-vocab draft: base logits, no d2t scatter.
363 + return self.compute_logits(hidden_states)
364 +
365 + def map_draft_to_target(self, draft_ids: torch.Tensor) -> torch.Tensor:
366 + return draft_ids # full-vocab: draft ids are target ids
367 +
368 + def markov_embed(self, token_ids: torch.Tensor) -> torch.Tensor:
369 + return self.model.markov_head.embed(token_ids)
370 +
371 + def markov_bias(self, markov_embed: torch.Tensor) -> torch.Tensor:
372 + return self.model.markov_head.bias(markov_embed, self.logits_processor)
373 +
374 + def compute_confidence(
375 + self, head_hidden: torch.Tensor, markov_embed: torch.Tensor
376 + ) -> torch.Tensor:
377 + """Per-position acceptance probability for each drafted token."""
378 + assert self.model.confidence_head is not None
379 + return torch.sigmoid(self.model.confidence_head(head_hidden, markov_embed))
380 +
381 + # --- Weight loading ----------------------------------------------------
382 +
383 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
384 + """Load the ``mtp.{0,1,2}.*`` draft weights from the target checkpoint.
385 +
386 + Non-mtp weights (embed/head/main layers) belong to the target model and
387 + are skipped here. ``embed_tokens``/``lm_head`` are aliased from the target.
388 + """
389 + # Draft MoE layers use the dspark_* expert counts, not the
390 + # backbone's (see DeepseekV4MoE and the reference
391 + # ModelArgs.get_moe_config).
392 + n_draft_experts = (
393 + getattr(self.config, "dspark_n_routed_experts", 0)
394 + or self.config.n_routed_experts
395 + )
396 + expert_mapping = fused_moe_make_expert_params_mapping(
397 + self,
398 + ckpt_gate_proj_name="w1",
399 + ckpt_down_proj_name="w2",
400 + ckpt_up_proj_name="w3",
401 + num_experts=n_draft_experts,
402 + )
403 + expert_scale_suffix = (
404 + ".weight_scale"
405 + if getattr(self.config, "expert_dtype", "fp4") == "fp4"
406 + else ".weight_scale_inv"
407 + )
408 +
409 + # (param_name, ckpt_shard_name, shard_id) for non-expert stacked params.
410 + stacked_params_mapping = [
411 + ("gate_up_proj", "w1", 0),
412 + ("gate_up_proj", "w3", 1),
413 + ("attn.fused_wqa_wkv", "attn.wq_a", 0),
414 + ("attn.fused_wqa_wkv", "attn.wkv", 1),
415 + ]
416 +
417 + params_dict = dict(self.named_parameters())
418 + loaded_params: set[str] = set()
419 + loaded_confidence_head = False
420 +
421 + tp_size = get_tensor_model_parallel_world_size()
422 + tp_rank = get_tensor_model_parallel_rank()
423 + n_local_head = self.config.num_attention_heads // tp_size
424 + head_start = n_local_head * tp_rank
425 + head_end = n_local_head * (tp_rank + 1)
426 +
427 + for name, loaded_weight in weights:
428 + mapped = self._remap_dspark_name(name)
429 + if mapped is None:
430 + continue
431 + name = mapped
432 + if "confidence_head." in name:
433 + loaded_confidence_head = True
434 +
435 + # ``.scale`` -> per-method scale suffix.
436 + if name.endswith(".scale"):
437 + suffix = (
438 + expert_scale_suffix
439 + if _EXPERT_SCALE_RE.search(name)
440 + else f".{self.linear_scale_name}"
441 + )
442 + name = name.removesuffix(".scale") + suffix
443 + if ".shared_experts.w2" in name:
444 + name = name.replace(".shared_experts.w2", ".shared_experts.down_proj")
445 + if self.pad_shared_expert and ".shared_experts." in name:
446 + loaded_weight = DeepseekV4Model._pad_shared_expert_weight(
447 + self.quant_config, name, loaded_weight
448 + )
449 +
450 + # E8M0 expert scales: keep raw exponent bytes.
451 + if ".experts." in name:
452 + if (
453 + "weight_scale" in name
454 + and loaded_weight.dtype == torch.float8_e8m0fnu
455 + ):
456 + loaded_weight = loaded_weight.view(torch.uint8)
457 + for param_name, weight_name, expert_id, shard_id in expert_mapping:
458 + if weight_name not in name:
459 + continue
460 + name_mapped = name.replace(weight_name, param_name)
461 + param = params_dict[name_mapped]
462 + success = param.weight_loader(
463 + param,
464 + loaded_weight,
465 + name_mapped,
466 + shard_id=shard_id,
467 + expert_id=expert_id,
468 + return_success=True,
469 + )
470 + if success:
471 + loaded_params.add(name_mapped)
472 + break
473 + continue
474 +
475 + # Stacked rules only apply to decoder-layer weights. Head-stack params
476 + # (main_proj/norm/markov_head/confidence_head) load directly —
477 + # otherwise e.g. "markov_w1" would collide with the "w1" shard rule.
478 + is_layer_param = name.startswith("model.layers.")
479 + for param_name, weight_name, stacked_shard_id in stacked_params_mapping:
480 + if not is_layer_param or weight_name not in name:
481 + continue
482 + name = name.replace(weight_name, param_name)
483 + param = params_dict[name]
484 + param.weight_loader(param, loaded_weight, stacked_shard_id)
485 + loaded_params.add(name)
486 + break
487 + else:
488 + if "attn_sink" in name:
489 + narrow = loaded_weight[head_start:head_end]
490 + params_dict[name][: narrow.shape[0]].copy_(narrow)
491 + loaded_params.add(name)
492 + continue
493 + if name.endswith(".ffn.gate.bias"):
494 + name = name.replace(
495 + ".ffn.gate.bias", ".ffn.gate.e_score_correction_bias"
496 + )
497 + param = params_dict[name]
498 + weight_loader = getattr(param, "weight_loader", default_weight_loader)
499 + weight_loader(param, loaded_weight)
500 + loaded_params.add(name)
501 +
502 + if self.model.confidence_head is not None and not loaded_confidence_head:
503 + self.model.confidence_head = None
504 + self.process_weights_after_loading()
505 + logger.info_once("DSpark draft model loaded: %d params", len(loaded_params))
506 + return loaded_params
507 +
508 + def process_weights_after_loading(self) -> None:
509 + # ROCm linears and fused-MoE runners finalize their own quantized
510 + # parameters. NVIDIA-only MegaMoE/WO-A requantization is not needed.
511 + return
512 +
513 + def _remap_dspark_name(self, name: str) -> str | None:
514 + """Map a checkpoint ``mtp.{i}.*`` name to this model's parameter path.
515 +
516 + Returns None for non-mtp weights (owned by the target model).
517 + """
518 + m = re.match(r"mtp\.(\d+)\.(.*)", name)
519 + if m is None:
520 + return None
521 + stage = int(m.group(1))
522 + rest = m.group(2)
523 + if rest.startswith("confidence_head.") and self.model.confidence_head is None:
524 + return None
525 + # The checkpoint calls the Markov head's factors ``embed``/``head``;
526 + # DSparkMarkovHead registers them as ``markov_w1``/``markov_w2``.
527 + if rest.startswith("markov_head.embed."):
528 + return "model.markov_head.markov_w1." + rest.removeprefix(
529 + "markov_head.embed."
530 + )
531 + if rest.startswith("markov_head.head."):
532 + return "model.markov_head.markov_w2." + rest.removeprefix(
533 + "markov_head.head."
534 + )
535 + # Head-stack params live at model level (mtp.last), context combiner at
536 + # model level (mtp.0); everything else is a per-layer decoder block.
537 + head_prefixes = (
538 + "norm.",
539 + "markov_head.",
540 + "confidence_head.",
541 + )
542 + if rest.startswith(("main_proj.", "main_norm.")) or rest.startswith(
543 + head_prefixes
544 + ):
545 + return f"model.{rest}"
546 + return f"model.layers.{stage}.{rest}"
vllm/models/deepseek_v4_1/amd/model.py new
+1086
@@ -0,0 +1,1086 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import typing
4 +from collections.abc import Callable, Iterable
5 +from itertools import islice
6 +
7 +import regex as re
8 +import torch
9 +import torch.nn as nn
10 +
11 +import vllm.envs as envs
12 +from vllm.config import VllmConfig
13 +from vllm.distributed import (
14 + get_pp_group,
15 + get_tensor_model_parallel_rank,
16 + get_tensor_model_parallel_world_size,
17 +)
18 +from vllm.forward_context import get_forward_context, is_forward_context_available
19 +from vllm.logger import init_logger
20 +from vllm.model_executor.kernels.mhc.torch import (
21 + mhc_post_torch,
22 + mhc_pre_delayed_torch,
23 +)
24 +from vllm.model_executor.layers.fused_moe import (
25 + fused_moe_make_expert_params_mapping,
26 +)
27 +from vllm.model_executor.layers.layernorm import RMSNorm
28 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
29 +from vllm.model_executor.layers.quantization import QuantizationConfig
30 +from vllm.model_executor.layers.vocab_parallel_embedding import (
31 + ParallelLMHead,
32 + VocabParallelEmbedding,
33 +)
34 +from vllm.model_executor.model_loader.weight_utils import default_weight_loader
35 +from vllm.model_executor.models.interfaces import (
36 + EagleModelMixin,
37 + MixtureOfExperts,
38 + SupportsEagle3,
39 + SupportsLoRA,
40 + SupportsPP,
41 +)
42 +from vllm.model_executor.models.utils import (
43 + AutoWeightsLoader,
44 + PPMissingLayer,
45 + WeightsMapper,
46 + extract_layer_index,
47 + is_pp_missing_parameter,
48 + make_layers,
49 + maybe_prefix,
50 +)
51 +from vllm.models.common.ops.sequence_parallel import (
52 + sp_all_gather,
53 + sp_padding_mask,
54 + sp_reduce_scatter,
55 + sp_shard,
56 +)
57 +from vllm.models.deepseek_v4.amd.model import (
58 + DeepseekV4MoE as DeepseekV4MoEBase,
59 +)
60 +from vllm.models.deepseek_v4_1.amd.rocm import DeepseekV41ROCMAiterMLAAttention
61 +from vllm.models.deepseek_v4_1.attention import DeepseekV4Attention
62 +from vllm.sequence import IntermediateTensors
63 +from vllm.utils.math_utils import cdiv
64 +from vllm.v1.attention.backends.registry import AttentionBackendEnum
65 +from vllm.v1.worker.ubatching import dbo_current_ubatch_id
66 +
67 +from ..common.engram import Engram, EngramLayout, NgramHashState
68 +from ..common.mm_preprocess import IMAGE_SENTINEL_BASE_ID, image_sentinel_mask
69 +
70 +if typing.TYPE_CHECKING:
71 + from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
72 +
73 +logger = init_logger(__name__)
74 +
75 +
76 +class DeepseekV4MoE(DeepseekV4MoEBase):
77 + def __init__(
78 + self,
79 + vllm_config: VllmConfig,
80 + prefix: str = "",
81 + use_sequence_parallel: bool = False,
82 + ):
83 + config = vllm_config.model_config.hf_config
84 + n_routed_experts = config.n_routed_experts
85 + n_activated_experts = config.num_experts_per_tok
86 + if extract_layer_index(prefix) >= config.num_hidden_layers:
87 + n_routed_experts = (
88 + getattr(config, "dspark_n_routed_experts", 0) or n_routed_experts
89 + )
90 + n_activated_experts = (
91 + getattr(config, "dspark_num_experts_per_tok", 0) or n_activated_experts
92 + )
93 + # The reused ROCm V4 MoE reads expert counts from the HF config. Draft
94 + # layers use V4.1's smaller DSpark expert set, so temporarily expose
95 + # the per-layer counts while constructing the module, then restore the
96 + # shared config for the remaining backbone layers.
97 + old_routed = config.n_routed_experts
98 + old_activated = config.num_experts_per_tok
99 + old_hash_layers = getattr(config, "num_hash_layers", None)
100 + config.n_routed_experts = n_routed_experts
101 + config.num_experts_per_tok = n_activated_experts
102 + config.num_hash_layers = 0
103 + try:
104 + super().__init__(vllm_config, prefix=prefix)
105 + finally:
106 + config.n_routed_experts = old_routed
107 + config.num_experts_per_tok = old_activated
108 + if old_hash_layers is None:
109 + delattr(config, "num_hash_layers")
110 + else:
111 + config.num_hash_layers = old_hash_layers
112 +
113 + # The V4.1 checkpoint contains a second per-expert routing bias for
114 + # image sentinel tokens. The reused ROCm V4 MoE predates this field,
115 + # so register it here and attach the same Parameter to the router,
116 + # matching the CUDA V4.1 implementation. Text tokens continue to use
117 + # e_score_correction_bias.
118 + self.gate.bias_vl = None
119 + self.image_sentinel_lo = 0
120 + if getattr(config, "vision_n_layers", 0) > 0:
121 + self.gate.bias_vl = nn.Parameter(
122 + torch.empty(self.n_routed_experts, dtype=torch.float32),
123 + requires_grad=False,
124 + )
125 + self.image_sentinel_lo = IMAGE_SENTINEL_BASE_ID
126 + self.experts.router.bias_vl = self.gate.bias_vl
127 + self.experts.router.image_sentinel_lo = self.image_sentinel_lo
128 + self.use_mega_moe = False
129 + # Expose the MixtureOfExperts protocol fields expected by the V4.1
130 + # outer model. The current ROCm runner has no redundant EPLB copies,
131 + # so logical and physical expert counts are identical.
132 + self.n_logical_experts = self.n_routed_experts
133 + self.n_physical_experts = self.n_routed_experts
134 + self.n_local_physical_experts = self.n_local_experts
135 + self.n_redundant_experts = 0
136 +
137 +
138 +def _select_dsv4_attn_cls(vllm_config: VllmConfig) -> type[DeepseekV4Attention]:
139 + """Pick the ROCm sparse-MLA implementation for DeepSeek V4.1."""
140 + backend = vllm_config.attention_config.backend
141 + if backend is not None and (
142 + backend is not AttentionBackendEnum.ROCM_FLASHMLA_SPARSE_DSV4
143 + ):
144 + raise ValueError(
145 + f"{backend.name} is not supported for DeepSeek V4.1 on ROCm; "
146 + "use ROCM_FLASHMLA_SPARSE_DSV4."
147 + )
148 + return DeepseekV41ROCMAiterMLAAttention
149 +
150 +
151 +def _use_sequence_parallel(vllm_config: VllmConfig) -> bool:
152 + # The ROCm fused-MoE path currently uses ordinary tensor/expert parallel.
153 + return False
154 +
155 +
156 +class DeepseekV4DecoderLayer(nn.Module):
157 + def __init__(
158 + self,
159 + vllm_config,
160 + prefix,
161 + topk_indices_buffer: torch.Tensor | None = None,
162 + aux_stream_list: list[torch.cuda.Stream] | None = None,
163 + candidate_block_buffer: torch.Tensor | None = None,
164 + engram_layout: EngramLayout | None = None,
165 + ):
166 + super().__init__()
167 + config = vllm_config.model_config.hf_config
168 + self.hidden_size = config.hidden_size
169 + self.use_sequence_parallel = _use_sequence_parallel(vllm_config)
170 +
171 + self.engram: Engram | None = None
172 + if engram_layout is not None:
173 + layer_id = extract_layer_index(prefix)
174 + if layer_id in engram_layout.layer_ids:
175 + self.engram = Engram(
176 + config,
177 + vllm_config.quant_config,
178 + engram_layout,
179 + engram_layout.layer_ids.index(layer_id),
180 + use_sequence_parallel=self.use_sequence_parallel,
181 + prefix=f"{prefix}.engram",
182 + )
183 +
184 + self.rms_norm_eps = config.rms_norm_eps
185 + self.attn = _select_dsv4_attn_cls(vllm_config)(
186 + vllm_config,
187 + prefix=f"{prefix}.attn",
188 + topk_indices_buffer=topk_indices_buffer,
189 + aux_stream_list=aux_stream_list,
190 + candidate_block_buffer=candidate_block_buffer,
191 + )
192 + if self.use_sequence_parallel:
193 + self.attn.wo_b.reduce_results = False
194 + self.ffn = DeepseekV4MoE(
195 + vllm_config,
196 + prefix=f"{prefix}.ffn",
197 + use_sequence_parallel=self.use_sequence_parallel,
198 + )
199 +
200 + self.attn_norm = RMSNorm(self.hidden_size, self.rms_norm_eps)
201 + self.ffn_norm = RMSNorm(self.hidden_size, self.rms_norm_eps)
202 + self.hc_mult = config.hc_mult
203 + self.hc_sinkhorn_iters = config.hc_sinkhorn_iters
204 + self.hc_eps = config.hc_eps
205 + self.hc_post_alpha = 2.0
206 + mix_hc = (2 + self.hc_mult) * self.hc_mult
207 + hc_dim = self.hc_mult * self.hidden_size
208 + self.hc_attn_fn = nn.Parameter(
209 + torch.empty(
210 + (mix_hc, hc_dim),
211 + dtype=torch.float32,
212 + ),
213 + requires_grad=False,
214 + )
215 + self.hc_attn_fn_broadcast: torch.Tensor | None = None
216 + self.hc_ffn_fn = nn.Parameter(
217 + torch.empty(
218 + (mix_hc, hc_dim),
219 + dtype=torch.float32,
220 + ),
221 + requires_grad=False,
222 + )
223 + self.hc_attn_base = nn.Parameter(
224 + torch.empty(
225 + mix_hc,
226 + dtype=torch.float32,
227 + ),
228 + requires_grad=False,
229 + )
230 + self.hc_ffn_base = nn.Parameter(
231 + torch.empty(
232 + mix_hc,
233 + dtype=torch.float32,
234 + ),
235 + requires_grad=False,
236 + )
237 + self.hc_attn_scale = nn.Parameter(
238 + torch.empty(
239 + 3,
240 + dtype=torch.float32,
241 + ),
242 + requires_grad=False,
243 + )
244 + self.hc_ffn_scale = nn.Parameter(
245 + torch.empty(
246 + 3,
247 + dtype=torch.float32,
248 + ),
249 + requires_grad=False,
250 + )
251 +
252 + @staticmethod
253 + def _hc_collapse(x: torch.Tensor, pre_mix: torch.Tensor) -> torch.Tensor:
254 + """Reference hyper-connection collapse used by DSpark on ROCm."""
255 + return (pre_mix.unsqueeze(-1) * x.float()).sum(dim=1).to(x.dtype)
256 +
257 + def forward(
258 + self,
259 + x: torch.Tensor,
260 + positions: torch.Tensor,
261 + input_ids: torch.Tensor | None,
262 + pre_mix: torch.Tensor | None = None,
263 + post_mix: torch.Tensor | None = None,
264 + res_mix: torch.Tensor | None = None,
265 + residual: torch.Tensor | None = None,
266 + engram_hashes: torch.Tensor | None = None,
267 + engram_mask: torch.Tensor | None = None,
268 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
269 + # The reference collapses each sublayer's input with the *previous*
270 + # sublayer's pre-mix: attention uses the pre-mix carried in (identity
271 + # for the first layer), the FFN uses this layer's attention pre-mix.
272 + if residual is None:
273 + if x.dim() == 2:
274 + # First layer: the stream is the embedding broadcast to hc
275 + # copies and the identity pre-mix selects copy 0.
276 + assert self.hc_attn_fn_broadcast is not None
277 + residual = x.unsqueeze(1).expand(-1, self.hc_mult, -1).contiguous()
278 + post_mix, res_mix, x, attn_pre = mhc_pre_delayed_torch(
279 + residual,
280 + self.hc_attn_fn_broadcast,
281 + self.hc_attn_scale,
282 + self.hc_attn_base,
283 + self.rms_norm_eps,
284 + self.hc_eps,
285 + self.hc_eps,
286 + self.hc_post_alpha,
287 + self.hc_sinkhorn_iters,
288 + x=x,
289 + )
290 + else:
291 + residual = x
292 + post_mix, res_mix, x, attn_pre = mhc_pre_delayed_torch(
293 + residual,
294 + self.hc_attn_fn,
295 + self.hc_attn_scale,
296 + self.hc_attn_base,
297 + self.rms_norm_eps,
298 + self.hc_eps,
299 + self.hc_eps,
300 + self.hc_post_alpha,
301 + self.hc_sinkhorn_iters,
302 + pre_mix=pre_mix,
303 + )
304 + else:
305 + residual = mhc_post_torch(x, residual, post_mix, res_mix)
306 + if self.engram is not None and engram_hashes is not None:
307 + # Engram injection happens between the previous sublayer's
308 + # post and this block's pre, on the full hc stream, so the
309 + # mix coefficients see the injected stream.
310 + residual = self.engram(
311 + residual,
312 + engram_hashes[:, self.engram.layer_hash_index],
313 + engram_mask,
314 + )
315 + post_mix, res_mix, x, attn_pre = mhc_pre_delayed_torch(
316 + residual,
317 + self.hc_attn_fn,
318 + self.hc_attn_scale,
319 + self.hc_attn_base,
320 + self.rms_norm_eps,
321 + self.hc_eps,
322 + self.hc_eps,
323 + self.hc_post_alpha,
324 + self.hc_sinkhorn_iters,
325 + pre_mix=pre_mix,
326 + )
327 + x = self.attn_norm(x)
328 +
329 + if self.use_sequence_parallel:
330 + x = sp_all_gather(x)[: positions.shape[0]]
331 +
332 + x = self.attn(positions, x, None)
333 + if self.use_sequence_parallel:
334 + x = sp_reduce_scatter(x)
335 +
336 + residual = mhc_post_torch(x, residual, post_mix, res_mix)
337 + post_mix, res_mix, x, ffn_pre = mhc_pre_delayed_torch(
338 + residual,
339 + self.hc_ffn_fn,
340 + self.hc_ffn_scale,
341 + self.hc_ffn_base,
342 + self.rms_norm_eps,
343 + self.hc_eps,
344 + self.hc_eps,
345 + self.hc_post_alpha,
346 + self.hc_sinkhorn_iters,
347 + pre_mix=attn_pre,
348 + )
349 + x = self.ffn_norm(x)
350 + x = self.ffn(x, input_ids)
351 + return x, residual, post_mix, res_mix, ffn_pre
352 +
353 +
354 +class DeepseekV4Model(nn.Module, EagleModelMixin):
355 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
356 + super().__init__()
357 +
358 + config = vllm_config.model_config.hf_config
359 + quant_config = vllm_config.quant_config
360 + self.config = config
361 + self.quant_config = quant_config
362 + self.parallel_config = vllm_config.parallel_config
363 + self.use_mega_moe = False
364 + self.use_sequence_parallel = _use_sequence_parallel(vllm_config)
365 + if self.use_mega_moe and not vllm_config.parallel_config.enable_expert_parallel:
366 + raise NotImplementedError(
367 + "DeepSeek V4 MegaMoE currently requires expert parallel. "
368 + "Enable it with --enable-expert-parallel, or pick a different "
369 + "moe backend."
370 + )
371 + self.vocab_size = config.vocab_size
372 + self.hc_eps = config.hc_eps
373 + self.hc_mult = config.hc_mult
374 + self.hc_dim = self.hc_mult * config.hidden_size
375 + self.rms_norm_eps = config.rms_norm_eps
376 +
377 + # Three aux streams: one per non-default input GEMM in
378 + # DeepseekV4Attention._run_parallel_input_projections
379 + # (compressor kv_score, indexer.weights_proj). fused_wqa_wkv stays on
380 + # the default stream.
381 + aux_stream_list = [torch.cuda.Stream() for _ in range(3)]
382 +
383 + # Reserved topk indices buffer for all Indexer layers to reuse.
384 + self.topk_indices_buffer = torch.empty(
385 + vllm_config.scheduler_config.max_num_batched_tokens,
386 + config.index_topk,
387 + dtype=torch.int32,
388 + )
389 +
390 + # Two-level candidate filtering: the indexer at
391 + # candidate_source_layer_id publishes the top candidate blocks of
392 + # compressed positions here; later ratio-1 indexers (24/28/32/36)
393 + # mask their scores with it.
394 + candidate_source_layer = getattr(config, "candidate_source_layer_id", -1)
395 + candidate_topk_blocks = getattr(config, "candidate_topk_blocks", 0)
396 + if candidate_source_layer >= 0 and candidate_topk_blocks > 0:
397 + self.candidate_block_buffer = torch.empty(
398 + vllm_config.scheduler_config.max_num_batched_tokens,
399 + candidate_topk_blocks,
400 + dtype=torch.int32,
401 + )
402 + else:
403 + self.candidate_block_buffer = None
404 +
405 + if get_pp_group().is_first_rank:
406 + self.embed_tokens = VocabParallelEmbedding(
407 + config.vocab_size,
408 + config.hidden_size,
409 + quant_config=quant_config,
410 + prefix=f"{prefix}.embed_tokens",
411 + )
412 + else:
413 + self.embed_tokens = PPMissingLayer()
414 +
415 + self.engram_layout = EngramLayout.from_config(config)
416 +
417 + self.start_layer, self.end_layer, self.layers = make_layers(
418 + config.num_hidden_layers,
419 + lambda prefix: DeepseekV4DecoderLayer(
420 + vllm_config,
421 + prefix=prefix,
422 + topk_indices_buffer=self.topk_indices_buffer,
423 + aux_stream_list=aux_stream_list,
424 + candidate_block_buffer=self.candidate_block_buffer,
425 + engram_layout=self.engram_layout,
426 + ),
427 + prefix=f"{prefix}.layers",
428 + )
429 +
430 + # The n-gram hash needs a slot-keyed rolling store of compressed ids
431 + # (chunked prefill / decode lookback); key it off the first local
432 + # layer's sliding-window KV cache. Only PP ranks owning an engram
433 + # layer need it.
434 + self.engram_hash: NgramHashState | None = None
435 + self.engram_swa_prefix: str | None = None
436 + if self.engram_layout is not None:
437 + local_engram = any(
438 + isinstance(layer, DeepseekV4DecoderLayer) and layer.engram is not None
439 + for layer in islice(self.layers, self.start_layer, self.end_layer)
440 + )
441 + if local_engram:
442 + first_layer = next(
443 + iter(islice(self.layers, self.start_layer, self.end_layer))
444 + )
445 + swa_cache_module = first_layer.attn.swa_cache_layer
446 + self.engram_hash = NgramHashState(
447 + vllm_config, self.engram_layout, swa_cache_module
448 + )
449 + self.engram_swa_prefix = swa_cache_module.prefix
450 +
451 + if get_pp_group().is_last_rank:
452 + self.norm = RMSNorm(config.hidden_size, self.rms_norm_eps)
453 + else:
454 + self.norm = PPMissingLayer()
455 +
456 + spec_config = vllm_config.speculative_config
457 + needs_mtp_hidden_states = spec_config is not None and (
458 + spec_config.use_eagle() or spec_config.uses_draft_model()
459 + )
460 + if get_pp_group().is_last_rank and needs_mtp_hidden_states:
461 + self._mtp_hidden_buffer = torch.empty(
462 + vllm_config.scheduler_config.max_num_batched_tokens,
463 + self.hc_dim,
464 + dtype=vllm_config.model_config.dtype,
465 + )
466 + else:
467 + self._mtp_hidden_buffer = None
468 +
469 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
470 + return self.embed_tokens(input_ids)
471 +
472 + def make_empty_intermediate_tensors(
473 + self,
474 + batch_size: int,
475 + dtype: torch.dtype,
476 + device: torch.device,
477 + ) -> IntermediateTensors:
478 + # PP intermediate tensors carry the multi-stream hidden_states
479 + # of shape (num_tokens, hc_mult, hidden_size) — V4 expands the
480 + # token embedding to hc_mult streams before the first decoder
481 + # layer and keeps that shape until the final hc collapse — plus the
482 + # (num_tokens, hc_mult) pre-mix the next rank's first layer needs
483 + # for its attention collapse.
484 + return IntermediateTensors(
485 + {
486 + "hidden_states": torch.zeros(
487 + (batch_size, self.hc_mult, self.config.hidden_size),
488 + dtype=dtype,
489 + device=device,
490 + ),
491 + "pre_mix": torch.zeros(
492 + (batch_size, self.hc_mult),
493 + dtype=torch.float32,
494 + device=device,
495 + ),
496 + }
497 + )
498 +
499 + def forward(
500 + self,
501 + input_ids: torch.Tensor,
502 + positions: torch.Tensor,
503 + intermediate_tensors: IntermediateTensors | None,
504 + inputs_embeds: torch.Tensor | None = None,
505 + lookback_token_ids: torch.Tensor | None = None,
506 + ) -> torch.Tensor | IntermediateTensors:
507 + if get_pp_group().is_first_rank:
508 + if inputs_embeds is not None:
509 + hidden_states = inputs_embeds
510 + else:
511 + hidden_states = self.embed_input_ids(input_ids)
512 + else:
513 + assert intermediate_tensors is not None
514 + hidden_states = intermediate_tensors["hidden_states"]
515 +
516 + if self.use_mega_moe:
517 + input_ids = input_ids.to(torch.int64)
518 +
519 + # Engram n-gram hashes for the whole (flattened) batch, computed once
520 + # on the full token stream — before any sequence-parallel sharding —
521 + # and consumed by the engram layers (1 and 14) below. Skipped on
522 + # profile runs (KV cache unbound -> no slot space to key the rolling
523 + # compressed-id cache into).
524 + engram_hashes: torch.Tensor | None = None
525 + engram_mask: torch.Tensor | None = None
526 + if (
527 + self.engram_hash is not None
528 + and input_ids is not None
529 + and is_forward_context_available()
530 + ):
531 + attn_metadata = get_forward_context().attn_metadata
532 + if isinstance(attn_metadata, list):
533 + attn_metadata = attn_metadata[dbo_current_ubatch_id()]
534 + if isinstance(attn_metadata, dict) and self.engram_hash.ensure_cache():
535 + assert self.engram_swa_prefix is not None
536 + swa_metadata = typing.cast(
537 + "DeepseekSparseSWAMetadata", attn_metadata[self.engram_swa_prefix]
538 + )
539 + # Image-span tokens are dead: they break n-grams (hash op
540 + # takes True=dead) and their gate is zeroed (Engram.forward
541 + # takes True=keep).
542 + image_mask = image_sentinel_mask(input_ids)
543 + engram_mask = ~image_mask
544 + if lookback_token_ids is None:
545 + if not self.engram_hash.use_slot_cache:
546 + raise NotImplementedError(
547 + "engram needs `lookback_token_ids` from the model "
548 + "runner (the DBO/ubatch wrapper drops model kwargs)"
549 + )
550 + num_reqs = swa_metadata.num_decodes + swa_metadata.num_prefills
551 + lookback_token_ids = input_ids.new_full(
552 + (num_reqs, self.engram_hash.lookback_depth), -1
553 + )
554 + engram_hashes = self.engram_hash(
555 + input_ids,
556 + positions,
557 + swa_metadata.query_start_loc,
558 + image_mask,
559 + lookback_token_ids,
560 + image_sentinel_mask(lookback_token_ids),
561 + swa_metadata.slot_mapping,
562 + swa_metadata.block_table,
563 + )
564 + # Gather all Engram rows before entering the decoder layers.
565 + for layer in islice(self.layers, self.start_layer, self.end_layer):
566 + engram = getattr(layer, "engram", None)
567 + if engram is not None:
568 + engram.prepare_embeddings(
569 + engram_hashes[:, engram.layer_hash_index]
570 + )
571 +
572 + full_num_tokens = positions.shape[0]
573 + if self.use_sequence_parallel:
574 + if envs.VLLM_MOE_SKIP_PADDING and is_forward_context_available():
575 + forward_context = get_forward_context()
576 + forward_context.is_padding = sp_padding_mask(
577 + forward_context.is_padding, hidden_states
578 + )
579 + hidden_states = sp_shard(hidden_states)
580 + input_ids = sp_shard(input_ids)
581 +
582 + residual, post_mix, res_mix = None, None, None
583 + pre_mix: torch.Tensor | None = None
584 + if not get_pp_group().is_first_rank:
585 + assert intermediate_tensors is not None
586 + pre_mix = intermediate_tensors["pre_mix"]
587 + aux_hidden_states: list[torch.Tensor] = []
588 + final_aux_recon: torch.Tensor | None = None # avoid duplicate mhc_post call
589 + for idx, layer in enumerate(
590 + islice(self.layers, self.start_layer, self.end_layer),
591 + start=self.start_layer,
592 + ):
593 + hidden_states, residual, post_mix, res_mix, pre_mix = layer(
594 + hidden_states,
595 + positions,
596 + input_ids,
597 + pre_mix,
598 + post_mix,
599 + res_mix,
600 + residual,
601 + engram_hashes,
602 + engram_mask,
603 + )
604 + if idx + 1 in self.aux_hidden_state_layers:
605 + # Reconstruct the aux hidden state for draft models
606 + aux_recon = mhc_post_torch(hidden_states, residual, post_mix, res_mix)
607 + aux_hidden_state = aux_recon.mean(dim=1)
608 + if self.use_sequence_parallel:
609 + aux_hidden_state = sp_all_gather(aux_hidden_state)[:full_num_tokens]
610 + aux_hidden_states.append(aux_hidden_state)
611 + final_aux_recon = aux_recon
612 + if layer is not None:
613 + # Reuse if the last layer was captured as an aux hidden state
614 + if self.end_layer in self.aux_hidden_state_layers:
615 + hidden_states = final_aux_recon
616 + else:
617 + hidden_states = mhc_post_torch(
618 + hidden_states, residual, post_mix, res_mix
619 + )
620 +
621 + if not get_pp_group().is_last_rank:
622 + return IntermediateTensors(
623 + {"hidden_states": hidden_states, "pre_mix": pre_mix}
624 + )
625 +
626 + if self.use_sequence_parallel:
627 + hidden_states = sp_all_gather(hidden_states)[:full_num_tokens]
628 + pre_mix = sp_all_gather(pre_mix)[:full_num_tokens]
629 +
630 + if self._mtp_hidden_buffer is not None:
631 + num_tokens = hidden_states.shape[0]
632 + self._mtp_hidden_buffer[:num_tokens].copy_(hidden_states.flatten(1))
633 +
634 + # Collapse the hc copies with the pre-mix from the last layer's FFN
635 + # mixes — the mix the reference applies via
636 + # ``last_layer.hc_pre(h, pre_mix)`` (v4.1 has no learned hc_head).
637 + assert pre_mix is not None
638 + hidden_states = (
639 + (pre_mix.unsqueeze(-1) * hidden_states.float())
640 + .sum(dim=1)
641 + .to(hidden_states.dtype)
642 + )
643 + hidden_states = self.norm(hidden_states)
644 + if len(aux_hidden_states) > 0:
645 + return hidden_states, aux_hidden_states
646 + return hidden_states
647 +
648 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
649 + stacked_params_mapping = [
650 + # (param_name, shard_name, shard_id)
651 + ("gate_up_proj", "w1", 0),
652 + ("gate_up_proj", "w3", 1),
653 + ("attn.fused_wqa_wkv", "attn.wq_a", 0),
654 + ("attn.fused_wqa_wkv", "attn.wkv", 1),
655 + ("compressor.fused_wkv_wgate", "compressor.wkv", 0),
656 + ("compressor.fused_wkv_wgate", "compressor.wgate", 1),
657 + ]
658 + params_dict = dict(self.named_parameters())
659 + loaded_params: set[str] = set()
660 +
661 + def _resolve_param_name(name: str) -> str:
662 + """Resolve scale naming across ROCm FP8 and MXFP8 methods."""
663 + if name.endswith("_inv"):
664 + direct_name = name.removesuffix("_inv")
665 + if direct_name in params_dict:
666 + return direct_name
667 + inv_name = f"{name}_inv"
668 + if name not in params_dict and inv_name in params_dict:
669 + return inv_name
670 + return name
671 +
672 + # TP for attention
673 + tp_size = get_tensor_model_parallel_world_size()
674 + tp_rank = get_tensor_model_parallel_rank()
675 + n_head = self.config.num_attention_heads
676 + n_local_head = n_head // tp_size
677 + head_rank_start = n_local_head * tp_rank
678 + head_rank_end = n_local_head * (tp_rank + 1)
679 +
680 + # Pre-compute expert mapping ONCE.
681 + expert_mapping = self.get_expert_mapping()
682 +
683 + # Block-FP8 shared experts: pad the intermediate up to the TP-uniform
684 + # block count so the standard loaders below slice it evenly (trailing
685 + # ranks land on the zero pad). SP / unquantized ones need no padding.
686 + pad_shared_expert = (
687 + getattr(self.quant_config, "weight_block_size", None) is not None
688 + and not self.use_sequence_parallel
689 + )
690 +
691 + for name, loaded_weight in weights:
692 + if name.startswith(("vision.", "aligner.", "image_")):
693 + # Vision weights are loaded by the outer multimodal wrapper.
694 + logger.warning_once("Skipping non-text weight: %s", name)
695 + continue
696 + if pad_shared_expert and ".shared_experts." in name:
697 + loaded_weight = self._pad_shared_expert_weight(
698 + self.quant_config, name, loaded_weight
699 + )
700 + for param_name, weight_name, shard_id in stacked_params_mapping:
701 + # Skip non-stacked layers and experts (experts handled below).
702 + if ".experts." in name:
703 + continue
704 + if weight_name not in name:
705 + continue
706 + name = name.replace(weight_name, param_name)
707 +
708 + if is_pp_missing_parameter(name, self):
709 + break
710 + if name not in params_dict:
711 + head, _, leaf = name.rpartition(".")
712 + suffixed = f"{head}.base_layer.{leaf}"
713 + if suffixed in params_dict:
714 + name = suffixed
715 + name = _resolve_param_name(name)
716 + param = params_dict[name]
717 + weight_loader = param.weight_loader
718 + weight_loader(param, loaded_weight, shard_id)
719 + loaded_params.add(name)
720 + break
721 + else:
722 + if ".experts." in name:
723 + # E8M0 scales are stored as float8_e8m0fnu in
724 + # checkpoints but the MoE param is uint8. copy_()
725 + # would do a numeric conversion (e.g. 2^-7 → 0),
726 + # destroying the raw exponent bytes.
727 + if (
728 + "weight_scale" in name
729 + and loaded_weight.dtype == torch.float8_e8m0fnu
730 + ):
731 + loaded_weight = loaded_weight.view(torch.uint8)
732 + for mapping in expert_mapping:
733 + param_name, weight_name, expert_id, expert_shard_id = mapping
734 + if weight_name not in name:
735 + continue
736 + name_mapped = name.replace(weight_name, param_name)
737 + if is_pp_missing_parameter(name_mapped, self):
738 + continue
739 + name_mapped = _resolve_param_name(name_mapped)
740 + param = params_dict[name_mapped]
741 + # We should ask the weight loader to return success or not
742 + # here since otherwise we may skip experts with other
743 + # available replicas.
744 + weight_loader = typing.cast(
745 + Callable[..., bool], param.weight_loader
746 + )
747 + success = weight_loader(
748 + param,
749 + loaded_weight,
750 + name_mapped,
751 + shard_id=expert_shard_id,
752 + expert_id=expert_id,
753 + return_success=True,
754 + )
755 + if success:
756 + name = name_mapped
757 + break
758 + loaded_params.add(name_mapped)
759 + continue
760 + elif "attn_sink" in name:
761 + if is_pp_missing_parameter(name, self):
762 + continue
763 + narrow_weight = loaded_weight[head_rank_start:head_rank_end]
764 + n = narrow_weight.shape[0]
765 + params_dict[name][:n].copy_(narrow_weight)
766 + loaded_params.add(name)
767 + continue
768 + else:
769 + if is_pp_missing_parameter(name, self):
770 + continue
771 + # Non-LoRA params on a LoRA-wrapped module live at
772 + # ``<head>.base_layer.<leaf>``; the checkpoint is plain.
773 + if name not in params_dict:
774 + head, _, leaf = name.rpartition(".")
775 + suffixed = f"{head}.base_layer.{leaf}"
776 + if suffixed in params_dict:
777 + name = suffixed
778 + name = _resolve_param_name(name)
779 + param = params_dict[name]
780 + weight_loader = getattr(
781 + param, "weight_loader", default_weight_loader
782 + )
783 + weight_loader(param, loaded_weight)
784 + loaded_params.add(name)
785 + continue
786 +
787 + return loaded_params
788 +
789 + @staticmethod
790 + def _pad_shared_expert_weight(
791 + quant_config: QuantizationConfig | None,
792 + name: str,
793 + loaded_weight: torch.Tensor,
794 + ) -> torch.Tensor:
795 + """Zero-pad a block-FP8 shared-expert weight/scale on its intermediate
796 + axis so the standard TP loaders split it into even, block-aligned shards
797 + (trailing ranks get the zero pad). gate (w1)/up (w3) [I, H] pad dim 0;
798 + down (w2 -> down_proj) [H, I] pads dim 1.
799 + """
800 + block_size = getattr(quant_config, "weight_block_size", None)
801 + assert block_size is not None
802 + # Round the intermediate axis up to a whole number of TP shards. The axis
803 + # is in elements for weights (step = block) and in blocks for scales.
804 + step = (
805 + 1 if name.endswith(("weight_scale_inv", "weight_scale")) else block_size[0]
806 + )
807 + dim = 1 if ".down_proj." in name else 0
808 + mult = get_tensor_model_parallel_world_size() * step
809 + pad = cdiv(loaded_weight.shape[dim], mult) * mult - loaded_weight.shape[dim]
810 + if pad == 0:
811 + return loaded_weight
812 + pad_shape = list(loaded_weight.shape)
813 + pad_shape[dim] = pad
814 + return torch.cat([loaded_weight, loaded_weight.new_zeros(pad_shape)], dim=dim)
815 +
816 + def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
817 + # Params for weights, fp8 weight scales, fp8 activation scales
818 + # (param_name, weight_name, expert_id, shard_id)
819 + return fused_moe_make_expert_params_mapping(
820 + self,
821 + ckpt_gate_proj_name="w1",
822 + ckpt_down_proj_name="w2",
823 + ckpt_up_proj_name="w3",
824 + num_experts=self.config.n_routed_experts,
825 + )
826 +
827 + def finalize_mega_moe_weights(self) -> None:
828 + return
829 +
830 + def finalize_mhc_broadcast_weights(self) -> None:
831 + if not get_pp_group().is_first_rank or self.start_layer >= self.end_layer:
832 + return
833 + layer = self.layers[self.start_layer]
834 + if isinstance(layer, DeepseekV4DecoderLayer):
835 + broadcast = (
836 + layer.hc_attn_fn.detach()
837 + .view(-1, layer.hc_mult, layer.hidden_size)
838 + .sum(dim=1)
839 + )
840 + if layer.hc_attn_fn_broadcast is None:
841 + layer.hc_attn_fn_broadcast = broadcast
842 + else:
843 + layer.hc_attn_fn_broadcast.copy_(broadcast)
844 +
845 +
846 +def _linear_scale_param_name(vllm_config: VllmConfig, expert_dtype: str) -> str:
847 + """Parameter name the linear quant method registers for weight scales.
848 +
849 + Native MXFP8 checkpoints ([32, 32] blocks with MXFP4 experts) route linear
850 + layers through ModelOptLinearMethod, which registers ``weight_scale``;
851 + block-FP8 linear layers register ``weight_scale_inv``.
852 + """
853 + use_mxfp8 = (
854 + getattr(vllm_config.quant_config, "weight_block_size", None) == [32, 32]
855 + and expert_dtype == "fp4"
856 + )
857 + return "weight_scale" if use_mxfp8 else "weight_scale_inv"
858 +
859 +
860 +def _make_deepseek_v4_weights_mapper(
861 + expert_dtype: str, linear_scale_name: str = "weight_scale_inv"
862 +) -> WeightsMapper:
863 + if expert_dtype == "fp4":
864 + # MXFP4 experts use Mxfp4MoEMethod, which registers scales as
865 + # ``w{1,2,3}_weight_scale`` (no _inv suffix). Linear scales use the
866 + # parameter name registered by their quantization method.
867 + scale_regex = {
868 + # ``.base_layer.``-namespace variant (LoRA-wrapped experts).
869 + re.compile(
870 + r"(\.experts\.\d+\.w[123]\.base_layer)\.scale$"
871 + ): r"\1.weight_scale",
872 + re.compile(r"(\.experts\.\d+\.w[123])\.scale$"): r"\1.weight_scale",
873 + # The ``embed.weight`` -> ``embed_tokens.weight`` suffix rule
874 + # renames the engram fp8 table but not its scale; route the
875 + # scale explicitly to the same module.
876 + re.compile(r"(engram\.embed)\.scale$"): r"\1_tokens.weight_scale_inv",
877 + re.compile(r"\.scale$"): f".{linear_scale_name}",
878 + }
879 + else:
880 + # FP8 experts use Fp8MoEMethod (block_quant=True), which registers
881 + # scales as ``w{13,2}_weight_scale_inv``. Map all ``.scale`` keys
882 + # there.
883 + scale_regex = {
884 + # ``.base_layer.``-namespace variant of the above.
885 + re.compile(
886 + r"(\.experts\.\d+\.w[123]\.base_layer)\.scale$"
887 + ): r"\1.weight_scale_inv",
888 + # Same engram reroute as the fp4 branch above.
889 + re.compile(r"(engram\.embed)\.scale$"): r"\1_tokens.weight_scale_inv",
890 + re.compile(r"\.scale$"): f".{linear_scale_name}",
891 + }
892 + return WeightsMapper(
893 + orig_to_new_prefix={
894 + "layers.": "model.layers.",
895 + "embed.": "model.embed.",
896 + "norm.": "model.norm.",
897 + "mtp.": "model.mtp.",
898 + },
899 + orig_to_new_regex=scale_regex,
900 + orig_to_new_suffix={
901 + "head.weight": "lm_head.weight",
902 + "embed.weight": "embed_tokens.weight",
903 + ".ffn.gate.bias": ".ffn.gate.e_score_correction_bias",
904 + },
905 + orig_to_new_substr={
906 + ".shared_experts.w2": ".shared_experts.down_proj",
907 + "mtp.": None,
908 + # The v4.1 checkpoint declares the VL arch even for text-only
909 + # use; the text backbone drops the vision-tower weights.
910 + "vision.": None,
911 + "aligner.": None,
912 + "image_": None,
913 + },
914 + )
915 +
916 +
917 +class DeepseekV4MixtureOfExperts(MixtureOfExperts):
918 + moe_mlp_layers: list["DeepseekV4MoE"]
919 +
920 + def extract_moe_parameters(self, example_moe: "DeepseekV4MoE | None") -> None:
921 + if example_moe is None:
922 + self.num_moe_layers = 0
923 + self.num_expert_groups = 0
924 + self.num_logical_experts = 0
925 + self.num_physical_experts = 0
926 + self.num_local_physical_experts = 0
927 + self.num_routed_experts = 0
928 + self.num_shared_experts = 0
929 + self.num_redundant_experts = 0
930 + return
931 + self.num_logical_experts = example_moe.n_logical_experts
932 + self.num_physical_experts = example_moe.n_physical_experts
933 + self.num_local_physical_experts = example_moe.n_local_physical_experts
934 + self.num_routed_experts = example_moe.n_routed_experts
935 + self.num_shared_experts = example_moe.n_shared_experts
936 + self.num_redundant_experts = example_moe.n_redundant_experts
937 +
938 + def update_physical_experts_metadata(
939 + self,
940 + num_physical_experts: int,
941 + num_local_physical_experts: int,
942 + ) -> None:
943 + assert self.num_local_physical_experts == num_local_physical_experts
944 + self.num_physical_experts = num_physical_experts
945 + self.num_local_physical_experts = num_local_physical_experts
946 + self.num_redundant_experts = num_physical_experts - self.num_logical_experts
947 + for moe in self.moe_mlp_layers:
948 + moe.n_local_physical_experts = num_local_physical_experts
949 + moe.n_physical_experts = num_physical_experts
950 + moe.n_redundant_experts = self.num_redundant_experts
951 + moe.experts.update_expert_map()
952 +
953 +
954 +class DeepseekV41LLMForCausalLM(
955 + nn.Module,
956 + SupportsPP,
957 + SupportsEagle3,
958 + SupportsLoRA,
959 + DeepseekV4MixtureOfExperts,
960 +):
961 + model_cls = DeepseekV4Model
962 +
963 + # Default mapper assumes the original FP4-expert checkpoint layout.
964 + # Overridden per-instance in __init__ when expert_dtype != "fp4".
965 + hf_to_vllm_mapper = _make_deepseek_v4_weights_mapper("fp4")
966 +
967 + packed_modules_mapping = {
968 + "gate_up_proj": ["w1", "w3"],
969 + "fused_wqa_wkv": ["wq_a", "wkv"],
970 + "fused_wkv_wgate": ["wkv", "wgate"],
971 + }
972 +
973 + # The MTP draft head is not LoRA-adapted.
974 + lora_skip_prefixes = ["mtp."]
975 +
976 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
977 + super().__init__()
978 +
979 + config = vllm_config.model_config.hf_config
980 + self.config = config
981 + expert_dtype = getattr(config, "expert_dtype", "fp4")
982 + self.hf_to_vllm_mapper = _make_deepseek_v4_weights_mapper(
983 + expert_dtype, _linear_scale_param_name(vllm_config, expert_dtype)
984 + )
985 +
986 + self.model = self.model_cls(
987 + vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
988 + )
989 + if get_pp_group().is_last_rank:
990 + self.lm_head = ParallelLMHead(
991 + config.vocab_size,
992 + config.hidden_size,
993 + prefix=maybe_prefix(prefix, "lm_head"),
994 + )
995 + else:
996 + self.lm_head = PPMissingLayer()
997 + self.logits_processor = LogitsProcessor(config.vocab_size)
998 + self.make_empty_intermediate_tensors = ( # type: ignore[method-assign]
999 + self.model.make_empty_intermediate_tensors
1000 + )
1001 +
1002 + self.set_moe_parameters()
1003 +
1004 + def set_moe_parameters(self) -> None:
1005 + self.num_expert_groups = getattr(self.config, "n_group", 1)
1006 + self.num_moe_layers = self.config.num_hidden_layers
1007 + self.moe_layers: list[nn.Module] = []
1008 + self.moe_mlp_layers: list[DeepseekV4MoE] = []
1009 + example_moe: DeepseekV4MoE | None = None
1010 + for layer in self.model.layers:
1011 + if isinstance(layer, PPMissingLayer):
1012 + continue
1013 + if not isinstance(layer, DeepseekV4DecoderLayer):
1014 + continue
1015 + if isinstance(layer.ffn, DeepseekV4MoE):
1016 + example_moe = layer.ffn
1017 + self.moe_mlp_layers.append(layer.ffn)
1018 + self.moe_layers.append(layer.ffn.experts)
1019 +
1020 + self.num_moe_layers = len(self.moe_layers)
1021 + self.extract_moe_parameters(example_moe)
1022 +
1023 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
1024 + return self.model.embed_input_ids(input_ids)
1025 +
1026 + def compute_logits(
1027 + self,
1028 + hidden_states: torch.Tensor,
1029 + ) -> torch.Tensor | None:
1030 + logits = self.logits_processor(self.lm_head, hidden_states)
1031 + return logits
1032 +
1033 + def compute_logits_local(
1034 + self,
1035 + hidden_states: torch.Tensor,
1036 + ) -> torch.Tensor:
1037 + return self.logits_processor(self.lm_head, hidden_states, skip_gather=True)
1038 +
1039 + @staticmethod
1040 + def get_model_state_cls():
1041 + from ..nvidia.model_state import DeepseekV41ModelState
1042 +
1043 + return DeepseekV41ModelState
1044 +
1045 + @property
1046 + def token_lookback_depth(self) -> int:
1047 + """Tokens before a chunk start the engram hash needs; the model runner
1048 + passes them as `lookback_token_ids`."""
1049 + engram_hash = self.model.engram_hash
1050 + return engram_hash.lookback_depth if engram_hash is not None else 0
1051 +
1052 + def forward(
1053 + self,
1054 + input_ids: torch.Tensor,
1055 + positions: torch.Tensor,
1056 + intermediate_tensors: IntermediateTensors | None = None,
1057 + inputs_embeds: torch.Tensor | None = None,
1058 + lookback_token_ids: torch.Tensor | None = None,
1059 + ) -> torch.Tensor | IntermediateTensors:
1060 + hidden_states = self.model(
1061 + input_ids,
1062 + positions,
1063 + intermediate_tensors,
1064 + inputs_embeds,
1065 + lookback_token_ids=lookback_token_ids,
1066 + )
1067 + return hidden_states
1068 +
1069 + def get_mtp_target_hidden_states(self) -> torch.Tensor | None:
1070 + """Pre-collapse residual stream buffer (max_num_batched_tokens,
1071 + hc_mult * hidden_size) for the MTP draft model. Populated by
1072 + forward(); valid after each target step."""
1073 + return getattr(self.model, "_mtp_hidden_buffer", None)
1074 +
1075 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
1076 + loader = AutoWeightsLoader(self)
1077 + loaded_params = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
1078 + self.process_weights_after_loading()
1079 + return loaded_params
1080 +
1081 + def process_weights_after_loading(self) -> None:
1082 + self.model.finalize_mega_moe_weights()
1083 + self.model.finalize_mhc_broadcast_weights()
1084 +
1085 + def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
1086 + return self.model.get_expert_mapping()
vllm/models/deepseek_v4_1/amd/rocm.py new
+905
@@ -0,0 +1,905 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import functools
5 +from dataclasses import dataclass
6 +from typing import cast
7 +
8 +import torch
9 +
10 +from vllm.distributed import (
11 + get_tensor_model_parallel_world_size,
12 + tensor_model_parallel_all_reduce,
13 +)
14 +from vllm.forward_context import get_forward_context
15 +from vllm.logger import init_logger
16 +from vllm.models.deepseek_v4_1.attention import DeepseekV4Attention
17 +from vllm.models.deepseek_v4_1.common.ops import dequantize_and_gather_k_cache
18 +from vllm.models.deepseek_v4_1.sparse_mla import (
19 + DeepseekV4FlashMLAMetadata,
20 + DeepseekV4SparseMLABackend,
21 + DeepseekV4SparseMLAMetadataBuilder,
22 + DeepseekV41SparseSWAMetadataBuilder,
23 +)
24 +from vllm.platforms import current_platform
25 +from vllm.platforms.rocm import _ON_GFX950
26 +from vllm.triton_utils import tl, triton
27 +from vllm.v1.attention.backend import (
28 + CommonAttentionMetadata,
29 +)
30 +from vllm.v1.attention.backends.mla.sparse_swa import (
31 + DeepseekSparseSWABackend,
32 + DeepseekSparseSWAMetadata,
33 +)
34 +from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
35 + build_ragged_indices_from_dense,
36 + rocm_inv_rope_einsum,
37 + rocm_sparse_attn_decode,
38 + rocm_sparse_attn_prefill,
39 +)
40 +from vllm.v1.worker.workspace import current_workspace_manager
41 +
42 +logger = init_logger(__name__)
43 +
44 +
45 +def _trust_dsv4_extra_cache_nan_free(
46 + kv_cache_dtype: str,
47 + has_kv_transfer: bool,
48 + has_extra_cache: bool,
49 +) -> bool:
50 + return (
51 + _ON_GFX950
52 + and kv_cache_dtype == "fp8_ds_mla"
53 + and not has_kv_transfer
54 + and has_extra_cache
55 + )
56 +
57 +
58 +def _build_indptr_from_lengths(lengths: torch.Tensor) -> torch.Tensor:
59 + lengths = lengths.to(dtype=torch.int32).contiguous()
60 + indptr = torch.zeros(lengths.shape[0] + 1, dtype=torch.int32, device=lengths.device)
61 + torch.cumsum(lengths, dim=0, out=indptr[1:])
62 + return indptr
63 +
64 +
65 +def apply_pre_quantized_block_scaled_mm(
66 + linear: torch.nn.Module,
67 + x_fp8: torch.Tensor,
68 + x_scale: torch.Tensor,
69 +) -> torch.Tensor:
70 + """Block-scaled fp8 GEMM on pre-quantized activations.
71 +
72 + The fused q/kv norm kernel writes fp8 qr + per-1x128 scales; this
73 + drives the linear's block-scaled GEMM directly with them, bypassing
74 + apply_weights which would re-quantize the fp8 input. Only valid for
75 + the wq_b-style column/replicated linears: their output is the local
76 + TP shard, so no all-reduce is needed.
77 + """
78 + from vllm.model_executor.kernels.linear.scaled_mm.BlockScaledMMLinearKernel import (
79 + FP8BlockParams,
80 + )
81 +
82 + params = FP8BlockParams.from_layer(linear)
83 + weight_scale = (
84 + params.weight_scale
85 + if params.weight_scale_inv is None
86 + else params.weight_scale_inv
87 + )
88 + kernel = linear.quant_method.fp8_linear
89 + out = kernel.apply_block_scaled_mm(
90 + A=x_fp8, B=params.weight, As=x_scale, Bs=weight_scale
91 + )
92 + return out.to(dtype=kernel.config.out_dtype)
93 +
94 +
95 +# ROCm sparse prefill keeps this dense combine local so AMD-specific SWA changes
96 +# do not touch the shared DeepSeek V4 cache utilities.
97 +_SPARSE_PREFILL_TOPK_ALIGNMENT = 128
98 +
99 +
100 +@triton.jit
101 +def _combine_topk_swa_indices_kernel(
102 + combined_indices_ptr,
103 + combined_indices_stride,
104 + combined_lens_ptr,
105 + topk_indices_ptr,
106 + topk_indices_stride,
107 + query_start_loc_ptr,
108 + seq_lens_ptr,
109 + gather_lens_ptr,
110 + M,
111 + N,
112 + TOP_K: tl.constexpr,
113 + COMPRESS_RATIO: tl.constexpr,
114 + WINDOW_SIZE: tl.constexpr,
115 + TOPK_WIDTH: tl.constexpr,
116 + PADDED_TOP_K: tl.constexpr,
117 +):
118 + batch_idx = tl.program_id(0)
119 + worker_id = tl.program_id(1)
120 + num_workers = tl.num_programs(1)
121 +
122 + base = tl.load(query_start_loc_ptr)
123 + query_start = tl.load(query_start_loc_ptr + batch_idx) - base
124 + query_end = tl.load(query_start_loc_ptr + batch_idx + 1) - base
125 + query_len = query_end - query_start
126 + seq_len = tl.load(seq_lens_ptr + batch_idx)
127 + gather_len = tl.load(gather_lens_ptr + batch_idx)
128 + start_pos = seq_len - query_len
129 + gather_start = seq_len - gather_len
130 +
131 + for token_idx in range(query_start + worker_id, query_end, num_workers):
132 + token_idx_in_query = token_idx - query_start
133 + pos = start_pos + token_idx_in_query
134 + topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
135 + swa_len = tl.minimum(pos + 1, WINDOW_SIZE)
136 +
137 + topk_offset = tl.arange(0, PADDED_TOP_K)
138 + topk_mask = topk_offset < topk_len
139 + safe_topk_offset = tl.where(topk_offset < TOPK_WIDTH, topk_offset, 0)
140 + topk_indices = tl.load(
141 + topk_indices_ptr + token_idx * topk_indices_stride + safe_topk_offset,
142 + mask=topk_mask,
143 + other=-1,
144 + )
145 + valid_topk = (topk_indices >= 0) & (topk_indices < N)
146 + topk_indices = tl.where(valid_topk, topk_indices + M * batch_idx, -1)
147 + tl.store(
148 + combined_indices_ptr + token_idx * combined_indices_stride + topk_offset,
149 + topk_indices,
150 + mask=topk_mask,
151 + )
152 +
153 + swa_offset = tl.arange(0, WINDOW_SIZE)
154 + tl.store(
155 + combined_indices_ptr
156 + + token_idx * combined_indices_stride
157 + + topk_len
158 + + swa_offset,
159 + M * batch_idx + N + swa_offset + pos - swa_len + 1 - gather_start,
160 + mask=swa_offset < swa_len,
161 + )
162 +
163 + tl.store(combined_lens_ptr + token_idx, topk_len + swa_len)
164 +
165 +
166 +def combine_topk_swa_indices(
167 + topk_indices: torch.Tensor,
168 + query_start_loc: torch.Tensor,
169 + seq_lens: torch.Tensor,
170 + gather_lens: torch.Tensor,
171 + window_size: int,
172 + compress_ratio: int,
173 + topk: int,
174 + M: int,
175 + N: int,
176 +) -> tuple[torch.Tensor, torch.Tensor]:
177 + """Combine compressed-attention and sliding-window indices with Torch.
178 +
179 + The Triton implementation inherited from DeepSeek V4 launches a
180 + two-dimensional grid with 128 workers per request. On gfx950 it can issue
181 + an out-of-bounds access for V4.1's mixed prefill metadata (including the
182 + synthetic mixed-token warmup). This path is prefill-only and the tensors
183 + are small, so use ordinary Torch indexing until a gfx950-safe fused kernel
184 + is available.
185 + """
186 + topk_indices = topk_indices.reshape(topk_indices.shape[0], -1).contiguous()
187 + num_tokens = topk_indices.shape[0]
188 + combined_topk = (
189 + (topk + window_size + _SPARSE_PREFILL_TOPK_ALIGNMENT - 1)
190 + // _SPARSE_PREFILL_TOPK_ALIGNMENT
191 + * _SPARSE_PREFILL_TOPK_ALIGNMENT
192 + )
193 + combined_indices = torch.full(
194 + (num_tokens, combined_topk),
195 + fill_value=-1,
196 + dtype=torch.int32,
197 + device=topk_indices.device,
198 + )
199 + combined_lens = torch.empty(
200 + num_tokens, dtype=torch.int32, device=topk_indices.device
201 + )
202 +
203 + # query_start_loc may have a non-zero base for a narrowed mixed batch.
204 + query_lens = query_start_loc[1:] - query_start_loc[:-1]
205 + req_ids = torch.repeat_interleave(
206 + torch.arange(seq_lens.shape[0], device=seq_lens.device), query_lens
207 + )
208 + query_starts = query_start_loc[:-1] - query_start_loc[0]
209 + token_offsets = torch.arange(num_tokens, device=seq_lens.device) - (
210 + torch.repeat_interleave(query_starts, query_lens)
211 + )
212 + positions = seq_lens[req_ids] - query_lens[req_ids] + token_offsets
213 +
214 + logical_topk_width = min(topk, topk_indices.shape[1])
215 + topk_lens = torch.minimum(
216 + (positions + 1) // compress_ratio,
217 + torch.full_like(positions, logical_topk_width),
218 + ).clamp_min(0)
219 + topk_offsets = torch.arange(logical_topk_width, device=seq_lens.device)
220 + topk_mask = topk_offsets[None, :] < topk_lens[:, None]
221 + topk_values = topk_indices[:, :logical_topk_width].to(torch.int32)
222 + topk_valid = topk_mask & (topk_values >= 0) & (topk_values < N)
223 + combined_indices[:, :logical_topk_width] = torch.where(
224 + topk_valid,
225 + topk_values + (M * req_ids).to(torch.int32)[:, None],
226 + -1,
227 + )
228 +
229 + swa_lens = torch.minimum(
230 + positions + 1, torch.full_like(positions, window_size)
231 + ).clamp_min(0)
232 + swa_offsets = torch.arange(window_size, device=seq_lens.device)
233 + swa_mask = swa_offsets[None, :] < swa_lens[:, None]
234 + swa_columns = topk_lens[:, None] + swa_offsets[None, :]
235 + gather_starts = seq_lens - gather_lens
236 + swa_values = (
237 + M * req_ids[:, None]
238 + + N
239 + + swa_offsets[None, :]
240 + + positions[:, None]
241 + - swa_lens[:, None]
242 + + 1
243 + - gather_starts[req_ids, None]
244 + ).to(torch.int32)
245 + rows = torch.arange(num_tokens, device=seq_lens.device)[:, None].expand_as(
246 + swa_columns
247 + )
248 + flat_dst = rows[swa_mask] * combined_topk + swa_columns[swa_mask]
249 + combined_indices.view(-1).index_copy_(0, flat_dst, swa_values[swa_mask])
250 + combined_lens.copy_((topk_lens + swa_lens).to(torch.int32))
251 + return combined_indices, combined_lens
252 +
253 +
254 +@triton.jit
255 +def _compute_topk_lens_kernel(
256 + topk_lens_ptr,
257 + topk_indices_ptr,
258 + topk_indices_stride,
259 + topk,
260 + is_valid_token_ptr,
261 + TRITON_BLOCK_SIZE: tl.constexpr,
262 +):
263 + token_idx = tl.program_id(0)
264 + is_valid_token = tl.load(is_valid_token_ptr + token_idx)
265 +
266 + count = tl.zeros((), dtype=tl.int32)
267 + for i in range(0, topk, TRITON_BLOCK_SIZE):
268 + offset = i + tl.arange(0, TRITON_BLOCK_SIZE)
269 + mask = offset < topk
270 + local_idx = tl.load(
271 + topk_indices_ptr + token_idx * topk_indices_stride + offset,
272 + mask=mask,
273 + other=-1,
274 + )
275 + count += tl.sum((local_idx >= 0).to(tl.int32), axis=0)
276 +
277 + tl.store(topk_lens_ptr + token_idx, tl.where(is_valid_token, count, 0))
278 +
279 +
280 +@triton.jit
281 +def _pack_global_topk_ragged_kernel(
282 + global_topk_ragged_ptr,
283 + topk_indptr_ptr,
284 + topk_indices_ptr,
285 + topk_indices_stride,
286 + token_to_req_indices_ptr,
287 + block_table_ptr,
288 + block_table_stride,
289 + block_size,
290 + topk,
291 + BLOCK_SIZE: tl.constexpr,
292 +):
293 + token_idx = tl.program_id(0)
294 + block_idx = tl.program_id(1)
295 + offset = block_idx * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
296 +
297 + out_start = tl.load(topk_indptr_ptr + token_idx)
298 + out_end = tl.load(topk_indptr_ptr + token_idx + 1)
299 + out_len = out_end - out_start
300 + if block_idx * BLOCK_SIZE >= out_len:
301 + return
302 +
303 + req_idx = tl.load(token_to_req_indices_ptr + token_idx)
304 + mask = (offset < out_len) & (offset < topk)
305 + local_idx = tl.load(
306 + topk_indices_ptr + token_idx * topk_indices_stride + offset,
307 + mask=mask,
308 + other=-1,
309 + )
310 + valid = mask & (local_idx >= 0)
311 + block_indices = local_idx // block_size
312 + block_numbers = tl.load(
313 + block_table_ptr + req_idx * block_table_stride + block_indices,
314 + mask=valid,
315 + other=0,
316 + )
317 + block_offsets = local_idx % block_size
318 + slot_ids = tl.where(valid, block_numbers * block_size + block_offsets, -1)
319 + tl.store(global_topk_ragged_ptr + out_start + offset, slot_ids, mask=mask)
320 +
321 +
322 +def compute_global_topk_ragged_indices_and_indptr(
323 + topk_indices: torch.Tensor,
324 + token_to_req_indices: torch.Tensor,
325 + block_table: torch.Tensor,
326 + block_size: int,
327 + is_valid_token: torch.Tensor,
328 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
329 + topk_indices = topk_indices.reshape(topk_indices.shape[0], -1).contiguous()
330 + num_tokens = topk_indices.shape[0]
331 + topk = topk_indices.shape[1]
332 +
333 + topk_lens = torch.empty(num_tokens, dtype=torch.int32, device=topk_indices.device)
334 + _compute_topk_lens_kernel[(num_tokens,)](
335 + topk_lens,
336 + topk_indices,
337 + topk_indices.stride(0),
338 + topk,
339 + is_valid_token,
340 + TRITON_BLOCK_SIZE=1024,
341 + )
342 +
343 + topk_indptr = _build_indptr_from_lengths(topk_lens)
344 + global_topk_ragged = torch.empty(
345 + num_tokens * topk,
346 + dtype=torch.int32,
347 + device=topk_indices.device,
348 + )
349 + if global_topk_ragged.numel() > 0:
350 + block = 128
351 + _pack_global_topk_ragged_kernel[(num_tokens, triton.cdiv(topk, block))](
352 + global_topk_ragged,
353 + topk_indptr,
354 + topk_indices,
355 + topk_indices.stride(0),
356 + token_to_req_indices,
357 + block_table,
358 + block_table.stride(0),
359 + block_size,
360 + topk,
361 + BLOCK_SIZE=block,
362 + )
363 + return global_topk_ragged, topk_indptr, topk_lens
364 +
365 +
366 +def _copy_ragged_to_graph_buffers(
367 + ragged_indices: torch.Tensor,
368 + ragged_indptr: torch.Tensor,
369 + ragged_indices_buffer: torch.Tensor,
370 + ragged_indptr_buffer: torch.Tensor,
371 + num_rows: int,
372 + max_entries_per_row: int,
373 +) -> tuple[torch.Tensor, torch.Tensor]:
374 + """Copy dynamic ragged metadata into persistent CUDA graph buffers.
375 +
376 + FULL decode graphs capture kernel argument addresses. Keep the returned
377 + tensors backed by stable storage, while indptr continues to bound reads.
378 + """
379 + indptr_out = ragged_indptr_buffer[: num_rows + 1]
380 + indptr_out.copy_(ragged_indptr, non_blocking=True)
381 +
382 + max_entries = max(num_rows * max_entries_per_row, 1)
383 + ragged_out = ragged_indices_buffer[:max_entries]
384 + source_entries = ragged_indices.numel()
385 + if source_entries > 0:
386 + ragged_out[:source_entries].copy_(ragged_indices, non_blocking=True)
387 + if _ON_GFX950:
388 + # Preserve the graph-stable base pointer while exposing source capacity
389 + # to the sync-free split selector; indptr still carries the true NNZ.
390 + ragged_out = ragged_out[: max(source_entries, 1)]
391 + return ragged_out, indptr_out
392 +
393 +
394 +@dataclass
395 +class DeepseekV4ROCMAiterSparseSWAMetadata(DeepseekSparseSWAMetadata):
396 + decode_swa_ragged_indices: torch.Tensor | None = None
397 + decode_swa_ragged_indptr: torch.Tensor | None = None
398 +
399 +
400 +class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekV41SparseSWAMetadataBuilder):
401 + # Keep fused multi-step decode disabled until update_draft_decode_metadata()
402 + # also refreshes the ROCm-specific ragged SWA indices and indptrs.
403 + supports_draft_decode_metadata_update = False
404 +
405 + def __init__(self, *args, **kwargs):
406 + super().__init__(*args, **kwargs)
407 + max_tokens = self.vllm_config.scheduler_config.max_num_batched_tokens
408 + # The non-causal (DSpark draft) path widens each token's SWA index list
409 + # to ``noncausal_index_width`` (>= window_size), so size the persistent
410 + # ragged buffer to the wider bound to cover both causal and non-causal.
411 + swa_index_width = max(self.window_size, self.noncausal_index_width)
412 + self.decode_swa_ragged_indices_buffer = torch.empty(
413 + max_tokens * swa_index_width,
414 + dtype=torch.int32,
415 + device=self.device,
416 + )
417 + self.decode_swa_ragged_indptr_buffer = torch.empty(
418 + max_tokens + 1,
419 + dtype=torch.int32,
420 + device=self.device,
421 + )
422 +
423 + def build(
424 + self,
425 + common_prefix_len: int,
426 + common_attn_metadata: CommonAttentionMetadata,
427 + fast_build: bool = False,
428 + ) -> DeepseekV4ROCMAiterSparseSWAMetadata:
429 + base = super().build(
430 + common_prefix_len=common_prefix_len,
431 + common_attn_metadata=common_attn_metadata,
432 + fast_build=fast_build,
433 + )
434 +
435 + ragged_indices = None
436 + ragged_indptr = None
437 + if (
438 + base.num_decode_tokens > 0
439 + and base.decode_swa_indices is not None
440 + and base.decode_swa_lens is not None
441 + ):
442 + ragged_indices, ragged_indptr = build_ragged_indices_from_dense(
443 + base.decode_swa_indices.reshape(
444 + base.num_decode_tokens, base.decode_swa_width
445 + ),
446 + base.decode_swa_lens,
447 + )
448 + ragged_indices, ragged_indptr = _copy_ragged_to_graph_buffers(
449 + ragged_indices,
450 + ragged_indptr,
451 + self.decode_swa_ragged_indices_buffer,
452 + self.decode_swa_ragged_indptr_buffer,
453 + base.num_decode_tokens,
454 + base.decode_swa_width,
455 + )
456 +
457 + return DeepseekV4ROCMAiterSparseSWAMetadata(
458 + **vars(base),
459 + decode_swa_ragged_indices=ragged_indices,
460 + decode_swa_ragged_indptr=ragged_indptr,
461 + )
462 +
463 +
464 +class DeepseekV4ROCMAiterMLASparseBackend(DeepseekV4SparseMLABackend):
465 + @staticmethod
466 + def get_name() -> str:
467 + return "ROCM_FLASHMLA_SPARSE_DSV4"
468 +
469 + @staticmethod
470 + def get_builder_cls() -> type[DeepseekV4SparseMLAMetadataBuilder]:
471 + return DeepseekV4SparseMLAMetadataBuilder
472 +
473 +
474 +class DeepseekV41ROCMAiterSparseSWABackend(DeepseekSparseSWABackend):
475 + @staticmethod
476 + def get_builder_cls() -> type["DeepseekV4ROCMAiterSparseSWAMetadataBuilder"]:
477 + return DeepseekV4ROCMAiterSparseSWAMetadataBuilder
478 +
479 +
480 +class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
481 + """ROCm sparse MLA attention layer for DeepSeek V4.1."""
482 +
483 + backend_cls = DeepseekV4ROCMAiterMLASparseBackend
484 + swa_backend_cls = DeepseekV41ROCMAiterSparseSWABackend
485 +
486 + def __init__(self, *args, **kwargs):
487 + vllm_config = args[0] if args else kwargs["vllm_config"]
488 + super().__init__(*args, **kwargs)
489 + # CUDA executes WO_A with a quantized grouped-BMM kernel. ROCm's
490 + # correctness path below dequantizes WO_A once and uses torch.einsum,
491 + # so retain the ordinary MXFP8 linear kernel for post-load processing
492 + # instead of asking for the CUDA-only BMM kernel.
493 + self.wo_a.is_bmm = False
494 + self._has_kv_transfer = vllm_config.kv_transfer_config is not None
495 + # Block scale for the preshuffled weight; None = not preshuffled.
496 + self._wqa_wkv_scale: torch.Tensor | None = None
497 + self._wo_b_scale: torch.Tensor | None = None
498 + self._fused_compressor_weight: torch.Tensor | None
499 + self.register_buffer("_fused_compressor_weight", None, persistent=False)
500 + self._fused_compressor_split_sizes: tuple[int, int] | None = None
501 +
502 + @classmethod
503 + def get_padded_num_q_heads(cls, num_heads: int) -> int:
504 + return num_heads
505 +
506 + def prepare_attn_preshuffle(self) -> None:
507 + from vllm._aiter_ops import rocm_aiter_ops
508 +
509 + if not rocm_aiter_ops.is_enabled():
510 + return
511 + from vllm.model_executor.layers.quantization.utils.fp8_utils import (
512 + _upcast_e8m0_to_fp32,
513 + )
514 + from vllm.model_executor.utils import replace_parameter
515 +
516 + def _prep(linear) -> torch.Tensor | None:
517 + w = getattr(linear, "weight", None)
518 + if w is None or w.dim() != 2:
519 + return None
520 + # K % 128 (group-128 quant) and N % 16 (shuffle_weight) must hold.
521 + if w.shape[-1] % 128 != 0 or w.shape[0] % 16 != 0:
522 + return None
523 + ws = getattr(linear, "weight_scale_inv", None) # per-block scale
524 + if ws is None:
525 + return None
526 + if ws.dtype == torch.float8_e8m0fnu:
527 + ws = _upcast_e8m0_to_fp32(ws).contiguous()
528 + # Shuffle the weight in place (single weight, no unshuffled copy).
529 + replace_parameter(
530 + linear,
531 + "weight",
532 + rocm_aiter_ops.shuffle_weight(w.data, layout=(16, 16)),
533 + )
534 + return ws
535 +
536 + self._wqa_wkv_scale = _prep(self.fused_wqa_wkv)
537 + self._wo_b_scale = _prep(self.wo_b)
538 +
539 + def prepare_compressor_gemm_fusion(self) -> bool:
540 + # V4.1 derives index keys from the source compressor's emitted latent
541 + # and has no nested ``indexer.compressor``. Keep the projections
542 + # separate and use the shared linear/PyTorch correctness path.
543 + return False
544 +
545 + def _bpre_attn_gemm(
546 + self,
547 + weight: torch.Tensor,
548 + scale: torch.Tensor,
549 + x: torch.Tensor,
550 + reduce_tp: bool,
551 + ) -> torch.Tensor:
552 + from vllm._aiter_ops import rocm_aiter_ops
553 +
554 + x_fp8, x_scale = rocm_aiter_ops.group_fp8_quant(x, transpose_scale=True)
555 + out = rocm_aiter_ops.gemm_a8w8_blockscale_bpreshuffle(
556 + x_fp8, weight, x_scale, scale, output_dtype=x.dtype
557 + )
558 + if reduce_tp and get_tensor_model_parallel_world_size() > 1:
559 + out = tensor_model_parallel_all_reduce(out)
560 + return out
561 +
562 + def _fused_wqa_wkv_gemm(self, hidden_states: torch.Tensor) -> torch.Tensor:
563 + if self._wqa_wkv_scale is not None and hidden_states.dim() == 2:
564 + return self._bpre_attn_gemm(
565 + self.fused_wqa_wkv.weight, self._wqa_wkv_scale, hidden_states, False
566 + )
567 + return super()._fused_wqa_wkv_gemm(hidden_states)
568 +
569 + def _run_parallel_input_projections(
570 + self, hidden_states: torch.Tensor
571 + ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None]:
572 + return super()._run_parallel_input_projections(hidden_states)
573 +
574 + @functools.cached_property
575 + def _wq_b_uses_aiter_block_scaled(self) -> bool:
576 + """True when both wq_b GEMMs run the aiter block-scaled fp8 kernel.
577 +
578 + Cached: the linear kernels and the aiter env gates are fixed once
579 + the model is built, so this is evaluated at the first forward
580 + only.
581 +
582 + The fused norm+quant path is only valid if the quant and GEMM it
583 + replaces are exactly the aiter ones; otherwise fall back to the
584 + shared path.
585 + """
586 + from vllm._aiter_ops import rocm_aiter_ops
587 + from vllm.model_executor.kernels.linear.scaled_mm import (
588 + Fp8BlockScaledMMLinearKernel,
589 + )
590 +
591 + if not rocm_aiter_ops.is_linear_fp8_enabled():
592 + return False
593 +
594 + linears = [self.wq_b]
595 + if self.indexer is not None:
596 + linears.append(self.indexer.wq_b)
597 + for linear in linears:
598 + kernel = getattr(getattr(linear, "quant_method", None), "fp8_linear", None)
599 + if not isinstance(kernel, Fp8BlockScaledMMLinearKernel):
600 + return False
601 + return True
602 +
603 + def _split_qkv_and_norm(
604 + self, qr_kv: torch.Tensor
605 + ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor]:
606 + """Fuse q/kv RMSNorm + per-1x128 fp8 q quant into one aiter kernel.
607 +
608 + The shared path norms q and kv in one triton kernel and the wq_b
609 + linears then re-read the bf16 qr to quantize it. The aiter kernel
610 + computes both RMSNorms (fp32 accumulate) and the fp8 group quant
611 + in a single pass, writing fp8 qr + group scales directly; both
612 + wq_b GEMMs (attention and indexer) then consume that pair and
613 + skip their own input quant. kv stays bf16: the fused insert
614 + kernel RoPE/quantizes it itself. Falls back to the shared path
615 + when the aiter linear path is not active.
616 + """
617 + qr, kv = qr_kv.split([self.q_lora_rank, self.head_dim], dim=-1)
618 + if not (
619 + qr.dim() == 2
620 + and qr.shape[0] > 0
621 + and self.q_lora_rank % 128 == 0
622 + and self._wq_b_uses_aiter_block_scaled
623 + ):
624 + return super()._split_qkv_and_norm(qr_kv)
625 +
626 + from vllm._aiter_ops import rocm_aiter_ops
627 +
628 + return rocm_aiter_ops.fused_qk_rmsnorm_group_quant(
629 + q=qr,
630 + q_weight=self.q_norm.weight.data,
631 + q_epsilon=self.eps,
632 + kv=kv,
633 + kv_weight=self.kv_norm.weight.data,
634 + kv_epsilon=self.eps,
635 + group_size=128,
636 + transpose_scale=False,
637 + )
638 +
639 + def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
640 + # ROCm BF16 reference wo_a path (inverse RoPE + einsum) + wo_b.
641 + z = rocm_inv_rope_einsum(
642 + self.rotary_emb,
643 + o,
644 + positions,
645 + self.rope_head_dim,
646 + self.n_local_groups,
647 + self.o_lora_rank,
648 + self.wo_a,
649 + )
650 + zf = z.flatten(1)
651 + if self._wo_b_scale is not None and zf.dim() == 2:
652 + result = self._bpre_attn_gemm(self.wo_b.weight, self._wo_b_scale, zf, True)
653 + else:
654 + result = self.wo_b(zf)
655 + return result
656 +
657 + def forward_mqa(
658 + self,
659 + q: torch.Tensor,
660 + kv: torch.Tensor,
661 + positions: torch.Tensor,
662 + output: torch.Tensor,
663 + ) -> None:
664 + assert output.shape == q.shape, (
665 + f"output buffer shape {output.shape} must match q shape {q.shape}"
666 + )
667 + assert output.dtype == q.dtype, (
668 + f"output buffer dtype {output.dtype} must match q dtype {q.dtype}"
669 + )
670 +
671 + forward_context = get_forward_context()
672 + attn_metadata = forward_context.attn_metadata
673 +
674 + if attn_metadata is None:
675 + # Warmup dummy run: no real metadata. Reserve the same bf16
676 + # gather workspace _forward_prefill would; the dequantize / topk
677 + # / sparse_fwd kernels are skipped this step.
678 + swa_only = self.compress_ratio == 0
679 + N = (
680 + 0
681 + if swa_only
682 + else (self.max_model_len + self.compress_ratio - 1)
683 + // self.compress_ratio
684 + )
685 + M = N + self.window_size + self.max_num_batched_tokens
686 + current_workspace_manager().get_simultaneous(
687 + ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
688 + )
689 + output.zero_()
690 + return
691 +
692 + assert isinstance(attn_metadata, dict)
693 + rocm_metadata = cast(
694 + DeepseekV4FlashMLAMetadata | None,
695 + attn_metadata.get(self.compressed_cache_prefix)
696 + if self.compressed_cache_prefix is not None
697 + else None,
698 + )
699 + swa_metadata = cast(
700 + DeepseekV4ROCMAiterSparseSWAMetadata | None,
701 + attn_metadata.get(self.swa_cache_layer.prefix),
702 + )
703 + assert swa_metadata is not None
704 +
705 + swa_only = self.compress_ratio == 0
706 + self_kv_cache = None if swa_only else self._compressed_kv_cache()
707 + swa_kv_cache = self.swa_cache_layer.kv_cache
708 +
709 + num_decodes = swa_metadata.num_decodes
710 + num_prefills = swa_metadata.num_prefills
711 + num_decode_tokens = swa_metadata.num_decode_tokens
712 +
713 + if num_prefills > 0:
714 + self._forward_prefill(
715 + q=q[num_decode_tokens:],
716 + positions=positions[num_decode_tokens:],
717 + compressed_k_cache=self_kv_cache,
718 + swa_k_cache=swa_kv_cache,
719 + output=output[num_decode_tokens:],
720 + attn_metadata=rocm_metadata,
721 + swa_metadata=swa_metadata,
722 + )
723 + if num_decodes > 0:
724 + self._forward_decode(
725 + q=q[:num_decode_tokens],
726 + kv_cache=self_kv_cache,
727 + swa_metadata=swa_metadata,
728 + attn_metadata=rocm_metadata,
729 + swa_only=swa_only,
730 + output=output[:num_decode_tokens],
731 + )
732 +
733 + def _forward_decode(
734 + self,
735 + q: torch.Tensor,
736 + kv_cache: torch.Tensor | None,
737 + swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
738 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
739 + swa_only: bool,
740 + output: torch.Tensor,
741 + ) -> None:
742 + num_decodes = swa_metadata.num_decodes
743 + num_decode_tokens = swa_metadata.num_decode_tokens
744 +
745 + topk_lens = None
746 + topk_ragged_indices = None
747 + topk_ragged_indptr = None
748 + if not swa_only:
749 + # Local indices filled by the index-source layer's indexer.
750 + assert attn_metadata is not None
751 + assert swa_metadata.is_valid_token is not None
752 + assert self.topk_indices_buffer is not None
753 + block_size = attn_metadata.block_size // self.compress_ratio
754 + is_valid = swa_metadata.is_valid_token[:num_decode_tokens]
755 + (
756 + topk_ragged_indices,
757 + topk_ragged_indptr,
758 + topk_lens,
759 + ) = compute_global_topk_ragged_indices_and_indptr(
760 + self.topk_indices_buffer[:num_decode_tokens],
761 + swa_metadata.token_to_req_indices,
762 + attn_metadata.block_table[:num_decodes],
763 + block_size,
764 + is_valid,
765 + )
766 +
767 + rocm_sparse_attn_decode(
768 + q=q,
769 + kv_cache=kv_cache,
770 + swa_k_cache=self.swa_cache_layer.kv_cache,
771 + swa_only=swa_only,
772 + topk_indices=None,
773 + topk_lens=topk_lens,
774 + swa_indices=swa_metadata.decode_swa_indices,
775 + swa_lens=swa_metadata.decode_swa_lens,
776 + swa_ragged_indices=swa_metadata.decode_swa_ragged_indices,
777 + swa_ragged_indptr=swa_metadata.decode_swa_ragged_indptr,
778 + topk_ragged_indices=topk_ragged_indices,
779 + topk_ragged_indptr=topk_ragged_indptr,
780 + attn_sink=self.attn_sink,
781 + scale=self.scale,
782 + head_dim=self.head_dim,
783 + nope_head_dim=self.nope_head_dim,
784 + rope_head_dim=self.rope_head_dim,
785 + output=output,
786 + extra_cache_nan_free=_trust_dsv4_extra_cache_nan_free(
787 + self.kv_cache_dtype,
788 + self._has_kv_transfer,
789 + not swa_only and kv_cache is not None,
790 + ),
791 + )
792 +
793 + def _forward_prefill(
794 + self,
795 + q: torch.Tensor,
796 + positions: torch.Tensor,
797 + compressed_k_cache: torch.Tensor | None,
798 + swa_k_cache: torch.Tensor,
799 + output: torch.Tensor,
800 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
801 + swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
802 + ) -> None:
803 + swa_only = attn_metadata is None
804 +
805 + num_prefills = swa_metadata.num_prefills
806 + num_prefill_tokens = swa_metadata.num_prefill_tokens
807 + num_decodes = swa_metadata.num_decodes
808 + num_decode_tokens = swa_metadata.num_decode_tokens
809 +
810 + seq_lens = swa_metadata.prefill_seq_lens
811 + gather_lens = swa_metadata.prefill_gather_lens
812 + assert seq_lens is not None
813 + assert gather_lens is not None
814 +
815 + query_start_loc_cpu = swa_metadata.query_start_loc_cpu
816 + query_start_loc = swa_metadata.query_start_loc
817 + assert query_start_loc_cpu is not None
818 + assert query_start_loc is not None
819 + prefill_token_base = query_start_loc_cpu[num_decodes]
820 +
821 + # Local indices filled by the index source; SWA-only layers pass
822 + # top_k=0 and never read them.
823 + assert self.topk_indices_buffer is not None
824 + topk_indices = self.topk_indices_buffer[num_decode_tokens:]
825 + topk_indices = topk_indices[:num_prefill_tokens]
826 + if not swa_only:
827 + top_k = topk_indices.shape[-1]
828 + N = (self.max_model_len + self.compress_ratio - 1) // self.compress_ratio
829 + else:
830 + top_k = 0
831 + N = 0
832 +
833 + M = N + self.window_size + self.max_num_batched_tokens
834 + num_chunks = (num_prefills + self.PREFILL_CHUNK_SIZE - 1) // (
835 + self.PREFILL_CHUNK_SIZE
836 + )
837 +
838 + workspace_manager = current_workspace_manager()
839 + kv = workspace_manager.get_simultaneous(
840 + ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
841 + )[0]
842 + for chunk_idx in range(num_chunks):
843 + chunk_start = chunk_idx * self.PREFILL_CHUNK_SIZE
844 + chunk_end = min(chunk_start + self.PREFILL_CHUNK_SIZE, num_prefills)
845 + chunk_size = chunk_end - chunk_start
846 + if not swa_only:
847 + assert attn_metadata is not None
848 + assert compressed_k_cache is not None
849 + block_table = attn_metadata.block_table[num_decodes:]
850 + # compressed_k_cache is OCP on every platform (Triton encoder).
851 + dequantize_and_gather_k_cache(
852 + kv[:chunk_size],
853 + compressed_k_cache,
854 + seq_lens=seq_lens[chunk_start:chunk_end] // self.compress_ratio,
855 + gather_lens=None,
856 + block_table=block_table[chunk_start:chunk_end],
857 + block_size=attn_metadata.block_size // self.compress_ratio,
858 + offset=0,
859 + use_fnuz=False,
860 + )
861 +
862 + swa_block_table = swa_metadata.block_table[num_decodes:]
863 + dequantize_and_gather_k_cache(
864 + kv[:chunk_size],
865 + swa_k_cache,
866 + seq_lens=seq_lens[chunk_start:chunk_end],
867 + gather_lens=gather_lens[chunk_start:chunk_end],
868 + block_table=swa_block_table[chunk_start:chunk_end],
869 + block_size=swa_metadata.block_size,
870 + offset=N,
871 + use_fnuz=current_platform.is_fp8_fnuz(),
872 + )
873 +
874 + query_start = (
875 + query_start_loc_cpu[num_decodes + chunk_start] - prefill_token_base
876 + )
877 + query_end = (
878 + query_start_loc_cpu[num_decodes + chunk_end] - prefill_token_base
879 + )
880 +
881 + combined_indices, combined_lens = combine_topk_swa_indices(
882 + topk_indices[query_start:query_end],
883 + query_start_loc[
884 + num_decodes + chunk_start : num_decodes + chunk_end + 1
885 + ],
886 + seq_lens[chunk_start:chunk_end],
887 + gather_lens[chunk_start:chunk_end],
888 + self.window_size,
889 + self.compress_ratio,
890 + top_k,
891 + M,
892 + N,
893 + )
894 + rocm_sparse_attn_prefill(
895 + q=q[query_start:query_end],
896 + kv=kv.view(-1, 1, q.shape[-1]),
897 + indices=combined_indices,
898 + topk_length=combined_lens,
899 + scale=self.scale,
900 + head_dim=self.head_dim,
901 + nope_head_dim=self.nope_head_dim,
902 + rope_head_dim=self.rope_head_dim,
903 + attn_sink=self.attn_sink,
904 + output=output[query_start:query_end],
905 + )
vllm/models/deepseek_v4_1/amd/vl_model.py new
+357
@@ -0,0 +1,357 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepSeek-V4.1 vision variant.
4 +
5 +Thin multimodal wrapper around the text-only ``DeepseekV41LLMForCausalLM``:
6 +
7 +- ``vision`` ViT + ``aligner`` produce per-image embeddings for the IMAGE
8 + positions; three learned vectors (``image_start`` / ``image_newline`` /
9 + ``image_end``) fill the delimiter positions. Every image-span position
10 + carries ``image_token_id`` (129264) in ``input_ids``; the per-position
11 + roles come from the processor's ``types`` tensor (see
12 + ``common/mm_preprocess.py``, and ``common/vision.py`` for the tower).
13 +- Merged embeddings enter the text model via ``inputs_embeds``, i.e. before
14 + its hyper-connection stream expansion. Raw ``input_ids`` still flow into
15 + the model so the MoE router can apply ``bias_vl`` to image tokens
16 + (``requires_raw_input_tokens``).
17 +"""
18 +
19 +from collections.abc import Iterable
20 +from typing import Annotated
21 +
22 +import torch
23 +from torch import nn
24 +
25 +from vllm.distributed import get_tensor_model_parallel_world_size
26 +from vllm.model_executor.models.interfaces import (
27 + MultiModalEmbeddings,
28 + SupportsEagle3,
29 + SupportsMultiModal,
30 + SupportsPP,
31 +)
32 +from vllm.model_executor.models.utils import (
33 + AutoWeightsLoader,
34 + WeightsMapper,
35 + maybe_prefix,
36 +)
37 +from vllm.model_executor.models.vision import is_vit_use_data_parallel
38 +from vllm.models.deepseek_v4.common.vision import (
39 + DeepseekV4Aligner,
40 + DeepseekV4ViT,
41 + run_dp_sharded_vision_tower,
42 +)
43 +from vllm.multimodal import MULTIMODAL_REGISTRY
44 +from vllm.utils.tensor_schema import TensorSchema, TensorShape
45 +
46 +from ..common.mm_preprocess import (
47 + IMAGE,
48 + IMAGE_END,
49 + IMAGE_NEW_LINE,
50 + IMAGE_PAD_ID,
51 + IMAGE_PLACEHOLDER,
52 + IMAGE_SENTINEL_BASE_ID,
53 + IMAGE_START,
54 + DeepseekV4VLDummyInputsBuilder,
55 + DeepseekV4VLMultiModalProcessor,
56 + DeepseekV4VLProcessingInfo,
57 +)
58 +from .model import (
59 + DeepseekV41LLMForCausalLM,
60 + _linear_scale_param_name,
61 + _make_deepseek_v4_weights_mapper,
62 +)
63 +
64 +
65 +class DeepseekV4VLImagePixelInputs(TensorSchema):
66 + """ViT patch inputs for one batched set of images.
67 +
68 + Dimensions:
69 + - np: Total ViT patches across images (sum of n_vit_h * n_vit_w)
70 + - c: Number of image channels (3)
71 + - p: ViT patch size
72 + - ni: Number of images
73 + - ns: Total image-span positions (sum of n_llm_h * (n_llm_w + 1) + 2)
74 + """
75 +
76 + patches: Annotated[
77 + torch.Tensor, TensorShape("np", 3, "p", "p", dynamic_dims={"np"})
78 + ]
79 + # [n_vit_h, n_vit_w] per image
80 + vit_grid: Annotated[torch.Tensor, TensorShape("ni", 2)]
81 + # [n_llm_h, n_llm_w] per image
82 + llm_grid: Annotated[torch.Tensor, TensorShape("ni", 2)]
83 + types: Annotated[torch.Tensor, TensorShape("ns", dynamic_dims={"ns"})]
84 +
85 +
86 +def _make_deepseek_v4_vl_weights_mapper(
87 + expert_dtype: str, linear_scale_name: str
88 +) -> WeightsMapper:
89 + """Text-checkpoint mapping rules re-rooted under ``language_model.``."""
90 + base = _make_deepseek_v4_weights_mapper(expert_dtype, linear_scale_name)
91 + orig_to_new_prefix: dict[str, str | None] = {
92 + "layers.": "language_model.model.layers.",
93 + "embed.": "language_model.model.embed.",
94 + "norm.": "language_model.model.norm.",
95 + "hc_head": "language_model.model.hc_head",
96 + "mtp.": "language_model.model.mtp.",
97 + }
98 + return WeightsMapper(
99 + orig_to_new_prefix=orig_to_new_prefix,
100 + orig_to_new_regex=base.orig_to_new_regex,
101 + orig_to_new_suffix={
102 + "head.weight": "language_model.lm_head.weight",
103 + "embed.weight": "embed_tokens.weight",
104 + ".ffn.gate.bias": ".ffn.gate.e_score_correction_bias",
105 + },
106 + orig_to_new_substr={
107 + ".shared_experts.w2": ".shared_experts.down_proj",
108 + # The MTP/DSpark draft heads are not supported for the vision
109 + # variant; drop their weights.
110 + "mtp.": None,
111 + },
112 + )
113 +
114 +
115 +@MULTIMODAL_REGISTRY.register_processor(
116 + DeepseekV4VLMultiModalProcessor,
117 + info=DeepseekV4VLProcessingInfo,
118 + dummy_inputs=DeepseekV4VLDummyInputsBuilder,
119 +)
120 +class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3):
121 + """Multimodal entry point for DeepSeek-V4.1 checkpoints with a vision tower.
122 +
123 + ``SupportsEagle3`` (aux hidden-state plumbing for MTP/DSpark drafters)
124 + delegates through ``language_model`` via the protocol defaults.
125 + """
126 +
127 + supports_encoder_tp_data = True
128 +
129 + # The MoE router needs raw token ids to detect image-span tokens
130 + # (all carrying image_token_id, see common/mm_preprocess.py) and apply
131 + # bias_vl.
132 + requires_raw_input_tokens = True
133 +
134 + @classmethod
135 + def get_placeholder_str(cls, modality: str, i: int) -> str | None:
136 + if modality == "image":
137 + return IMAGE_PLACEHOLDER
138 + raise ValueError(f"Unsupported modality: {modality!r}")
139 +
140 + def __init__(self, *, vllm_config, prefix: str = "") -> None:
141 + super().__init__()
142 + model_config = vllm_config.model_config
143 + config = model_config.hf_config
144 + self.config = config
145 + self.multimodal_config = model_config.multimodal_config
146 + assert self.multimodal_config is not None
147 +
148 + # The tower is always built; _mark_tower_model stubs it out
149 + # (StageMissingLayer, weights skipped) when the image limit is 0.
150 + with self._mark_tower_model(vllm_config, {"image"}):
151 + self.use_data_parallel = is_vit_use_data_parallel(config.vision_n_heads)
152 + self.vision = DeepseekV4ViT(config)
153 + self.aligner = DeepseekV4Aligner(config)
154 + self.image_start = nn.Parameter(
155 + torch.empty(config.hidden_size, dtype=torch.float32)
156 + )
157 + self.image_end = nn.Parameter(
158 + torch.empty(config.hidden_size, dtype=torch.float32)
159 + )
160 + self.image_newline = nn.Parameter(
161 + torch.empty(config.hidden_size, dtype=torch.float32)
162 + )
163 + self.vision.to(dtype=model_config.dtype)
164 + self.aligner.to(dtype=model_config.dtype)
165 +
166 + with self._mark_language_model(vllm_config):
167 + self.language_model = DeepseekV41LLMForCausalLM(
168 + vllm_config=vllm_config,
169 + prefix=maybe_prefix(prefix, "language_model"),
170 + )
171 + # The outer mapper (see load_weights) fully resolves HF names into
172 + # this wrapper's namespace before AutoWeightsLoader strips the
173 + # "language_model." prefix and delegates to the child's load_weights,
174 + # so the child's own mapper must be a no-op. Its suffix rules are not
175 + # idempotent (e.g. "lm_head.weight".endswith("head.weight") would
176 + # re-fire "head.weight" -> "lm_head.weight").
177 + self.language_model.hf_to_vllm_mapper = WeightsMapper()
178 + self.make_empty_intermediate_tensors = ( # type: ignore[method-assign]
179 + self.language_model.make_empty_intermediate_tensors
180 + )
181 +
182 + expert_dtype = getattr(config, "expert_dtype", "fp4")
183 + self.hf_to_vllm_mapper = _make_deepseek_v4_vl_weights_mapper(
184 + expert_dtype, _linear_scale_param_name(vllm_config, expert_dtype)
185 + )
186 +
187 + def _parse_and_validate_image_input(
188 + self, **kwargs: object
189 + ) -> DeepseekV4VLImagePixelInputs | None:
190 + patches = kwargs.pop("patches", None)
191 + if patches is None:
192 + return None
193 + return DeepseekV4VLImagePixelInputs(
194 + patches=patches,
195 + vit_grid=kwargs.pop("vit_grid"),
196 + llm_grid=kwargs.pop("llm_grid"),
197 + types=kwargs.pop("types"),
198 + resolve_bindings={"p": self.config.vision_patch_size},
199 + )
200 +
201 + def _encode_image(
202 + self,
203 + patches: torch.Tensor,
204 + n_vit_h: int,
205 + n_vit_w: int,
206 + ) -> torch.Tensor:
207 + # Aligner rows in reading order, one per IMAGE slot.
208 + return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
209 +
210 + def _build_image_span(
211 + self, image_embeds: torch.Tensor, types: torch.Tensor
212 + ) -> torch.Tensor:
213 + """Full image span: aligner rows at IMAGE slots, the learned
214 + delimiter vectors at IMAGE_START/IMAGE_NEW_LINE/IMAGE_END."""
215 + types = types.to(image_embeds.device)
216 + span = image_embeds.new_empty(types.numel(), image_embeds.shape[-1])
217 + dtype = image_embeds.dtype
218 + span[types == IMAGE_START] = self.image_start.to(dtype)
219 + span[types == IMAGE_END] = self.image_end.to(dtype)
220 + span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
221 + span[types == IMAGE] = image_embeds
222 + return span
223 +
224 + def _process_image_input(
225 + self,
226 + image_input: DeepseekV4VLImagePixelInputs,
227 + ) -> tuple[torch.Tensor, ...]:
228 + patches = image_input.patches.to(self.aligner.w1.weight.dtype)
229 + vit_grid = image_input.vit_grid.tolist()
230 +
231 + image_embeds_list: list[torch.Tensor]
232 + if self.use_data_parallel and get_tensor_model_parallel_world_size() > 1:
233 + # Data-parallel ViT: shard images across TP ranks and all-gather
234 + # the per-image embeddings (weights are replicated on every rank).
235 + image_embeds_list = run_dp_sharded_vision_tower(
236 + self.vision, self.aligner, patches, vit_grid
237 + )
238 + else:
239 + image_embeds_list = []
240 + vit_offset = 0
241 + for n_vit_h, n_vit_w in vit_grid:
242 + n_vit = n_vit_h * n_vit_w
243 + image_embeds_list.append(
244 + self._encode_image(
245 + patches[vit_offset : vit_offset + n_vit], n_vit_h, n_vit_w
246 + )
247 + )
248 + vit_offset += n_vit
249 +
250 + embeds: list[torch.Tensor] = []
251 + span_offset = 0
252 + for image_embeds, (n_llm_h, n_llm_w) in zip(
253 + image_embeds_list, image_input.llm_grid.tolist(), strict=True
254 + ):
255 + span_len = n_llm_h * (n_llm_w + 1) + 2
256 + embeds.append(
257 + self._build_image_span(
258 + image_embeds,
259 + image_input.types[span_offset : span_offset + span_len],
260 + )
261 + )
262 + span_offset += span_len
263 + return tuple(embeds)
264 +
265 + def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
266 + image_input = self._parse_and_validate_image_input(**kwargs)
267 + if image_input is None:
268 + return []
269 + return self._process_image_input(image_input)
270 +
271 + def embed_input_ids(
272 + self,
273 + input_ids: torch.Tensor,
274 + multimodal_embeddings: MultiModalEmbeddings | None = None,
275 + *,
276 + is_multimodal: torch.Tensor | None = None,
277 + ) -> torch.Tensor:
278 + from vllm.model_executor.models.utils import _merge_multimodal_embeddings
279 +
280 + # Compressor-alignment pads borrow a reserved id; embed them as the
281 + # plain image token (the checkpoint has no image_pad vector).
282 + # Branch-free: safe inside compiled/captured regions.
283 + input_ids = input_ids.masked_fill(
284 + input_ids == IMAGE_PAD_ID, IMAGE_SENTINEL_BASE_ID
285 + )
286 + inputs_embeds = self.language_model.embed_input_ids(input_ids)
287 +
288 + if multimodal_embeddings is None or len(multimodal_embeddings) == 0:
289 + return inputs_embeds
290 +
291 + assert is_multimodal is not None
292 + return _merge_multimodal_embeddings(
293 + inputs_embeds=inputs_embeds,
294 + multimodal_embeddings=multimodal_embeddings,
295 + is_multimodal=is_multimodal,
296 + )
297 +
298 + @staticmethod
299 + def get_model_state_cls():
300 + from ..nvidia.model_state import DeepseekV41ModelState
301 +
302 + return DeepseekV41ModelState
303 +
304 + @property
305 + def token_lookback_depth(self) -> int:
306 + return self.language_model.token_lookback_depth
307 +
308 + def forward(
309 + self,
310 + input_ids: torch.Tensor,
311 + positions: torch.Tensor,
312 + intermediate_tensors=None,
313 + inputs_embeds: torch.Tensor | None = None,
314 + lookback_token_ids: torch.Tensor | None = None,
315 + **kwargs,
316 + ) -> torch.Tensor:
317 + return self.language_model(
318 + input_ids,
319 + positions,
320 + intermediate_tensors,
321 + inputs_embeds,
322 + lookback_token_ids=lookback_token_ids,
323 + )
324 +
325 + def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
326 + return self.language_model.compute_logits(hidden_states)
327 +
328 + def compute_logits_local(self, hidden_states: torch.Tensor) -> torch.Tensor:
329 + return self.language_model.compute_logits_local(hidden_states)
330 +
331 + def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
332 + return self.language_model.get_expert_mapping()
333 +
334 + def get_mtp_target_hidden_states(self) -> torch.Tensor | None:
335 + """Pre-hc_head residual stream buffer for the MTP/DSpark draft model."""
336 + return self.language_model.get_mtp_target_hidden_states()
337 +
338 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
339 + # Map HF names into this wrapper's namespace up front and sort, so
340 + # the "language_model." group reaches the child loader as one
341 + # contiguous block (AutoWeightsLoader delegates per contiguous group,
342 + # and the child's load_weights finalizes fused expert weights, which
343 + # must not run on a partially loaded model).
344 + mapped = sorted(self.hf_to_vllm_mapper.apply(weights), key=lambda x: x[0])
345 + loader = AutoWeightsLoader(self)
346 + loaded_params = loader.load_weights(mapped)
347 + # The child's load_weights already ran its post-load finalization.
348 + self._weights_finalized = True
349 + return loaded_params
350 +
351 + def process_weights_after_loading(self) -> None:
352 + # Model-level post-load hook (called by the loader after any load
353 + # format). Under DummyModelLoader the child's load_weights — and
354 + # hence its finalize step — is bypassed, so run it here instead.
355 + if getattr(self, "_weights_finalized", False):
356 + return
357 + self.language_model.process_weights_after_loading()
vllm/models/deepseek_v4_1/attention.py new
+1225
@@ -0,0 +1,1225 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +DeepseekV4 MLA Attention Layer
5 +"""
6 +
7 +from abc import ABC, abstractmethod
8 +from collections.abc import Callable
9 +from functools import cached_property
10 +from typing import TYPE_CHECKING, Any, ClassVar, cast
11 +
12 +import regex as re
13 +import torch
14 +import torch.nn as nn
15 +import torch.nn.functional as F
16 +from transformers import DeepseekV2Config, DeepseekV3Config
17 +
18 +import vllm.envs as envs
19 +from vllm.compilation.breakable_cudagraph import eager_break_during_capture
20 +from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
21 +from vllm.model_executor.layers.linear import (
22 + ColumnParallelLinear,
23 + MergedColumnParallelLinear,
24 + ReplicatedLinear,
25 + RowParallelLinear,
26 +)
27 +from vllm.model_executor.layers.sparse_attn_indexer import SparseAttnIndexer
28 +from vllm.models.common.ops import fused_q_kv_rmsnorm
29 +from vllm.models.deepseek_v4_1.common.ops import (
30 + MXFP4_BLOCK_SIZE,
31 + fused_indexer_q_rope_quant,
32 + indexer_k_norm_rope_store,
33 +)
34 +
35 +if TYPE_CHECKING:
36 + from vllm.v1.attention.backends.mla.sparse_swa import (
37 + DeepseekSparseSWAMetadata,
38 + )
39 +
40 +from vllm.config import (
41 + CacheConfig,
42 + VllmConfig,
43 + get_current_vllm_config,
44 +)
45 +from vllm.distributed import get_tensor_model_parallel_world_size
46 +from vllm.forward_context import get_forward_context
47 +from vllm.logger import init_logger
48 +from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
49 +from vllm.model_executor.layers.layernorm import RMSNorm
50 +from vllm.model_executor.layers.quantization import QuantizationConfig
51 +from vllm.model_executor.models.utils import extract_layer_index
52 +from vllm.models.deepseek_v4_1.common.rope import build_deepseek_v4_rope
53 +from vllm.models.deepseek_v4_1.compressor import DeepseekCompressor
54 +from vllm.triton_utils import tl, triton
55 +from vllm.utils.multi_stream_utils import (
56 + execute_in_parallel,
57 + maybe_execute_in_parallel,
58 +)
59 +from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
60 +from vllm.v1.attention.backends.mla.indexer import (
61 + DeepseekV4IndexerBackend,
62 + dsa_indexer_uses_fp4,
63 + get_max_prefill_buffer_size,
64 +)
65 +from vllm.v1.attention.backends.mla.sparse_swa import DeepseekV4SWACache
66 +from vllm.v1.kv_cache_interface import (
67 + KVCacheSpec,
68 + MLAAttentionSpec,
69 + get_kv_quant_mode,
70 +)
71 +
72 +logger = init_logger(__name__)
73 +
74 +
75 +def _replace_layer_index(prefix: str, layer_id: int) -> str:
76 + """Swap the layer index inside a ``...layers.<idx>...`` prefix."""
77 + new_prefix, n = re.subn(r"\.layers\.\d+\.", f".layers.{layer_id}.", prefix)
78 + assert n == 1, f"Cannot locate layer index in prefix {prefix}"
79 + return new_prefix
80 +
81 +
82 +def _indexer_k_cache_head_dim(index_head_dim: int, use_fp4_kv: bool) -> int:
83 + """Per-token byte width of the paged indexer K cache row."""
84 + if use_fp4_kv:
85 + # MXFP4 stores two values per byte plus one UE8M0 byte per 32 values.
86 + # head_dim bytes = 64 packed values + 4 UE8M0 scales = 68.
87 + return index_head_dim // 2 + index_head_dim // MXFP4_BLOCK_SIZE
88 + # NOTE(yifan): FP8 indexer cache uses the same layout as V3.2:
89 + # head_dim bytes = 128 fp8 + 4 fp32 scale = 132.
90 + return index_head_dim + index_head_dim // 128 * 4
91 +
92 +
93 +@triton.jit
94 +def _fill_short_context_topk_indices(
95 + output,
96 + positions,
97 + TOP_K: tl.constexpr,
98 + COMPRESS_RATIO: tl.constexpr,
99 + PADDED_TOP_K: tl.constexpr,
100 +):
101 + # small triton kernel that selects every candidate, -1 otherwise
102 + row = tl.program_id(0)
103 + offsets = tl.arange(0, PADDED_TOP_K)
104 + num_compressed = (tl.load(positions + row) + 1) // COMPRESS_RATIO
105 + tl.store(
106 + output + row * TOP_K + offsets,
107 + tl.where(offsets < num_compressed, offsets, -1),
108 + mask=offsets < TOP_K,
109 + )
110 +
111 +
112 +def _resolve_dsv4_kv_cache_dtype(
113 + use_fp8_ds_mla_layout: bool,
114 + kv_cache_dtype: str,
115 + cache_config: CacheConfig | None,
116 +) -> tuple[str, torch.dtype]:
117 + """Map ``(layout, --kv-cache-dtype)`` to ``(cache_dtype_str, torch_dtype)``.
118 +
119 + Both layouts are paged; they differ in the per-token block format. The
120 + ``fp8_ds_mla`` format is UE8M0 block-scaled fp8 packed as ``uint8`` (the
121 + canonical ``fp8_ds_mla`` string is written back onto ``cache_config`` so the
122 + page-size specs pick the 576B per-token slot). Plain-row backends store each
123 + token's KV row in its element dtype: bf16 or per-tensor FP8 E4M3.
124 + """
125 + if use_fp8_ds_mla_layout:
126 + # fp8_ds_mla block format: UE8M0 block-scaled fp8 packed as uint8.
127 + if kv_cache_dtype == "auto":
128 + kv_cache_dtype = "fp8"
129 + if not kv_cache_dtype.startswith("fp8"):
130 + raise ValueError(
131 + "DeepseekV4 fp8_ds_mla layout only supports fp8 "
132 + f"kv-cache, got {kv_cache_dtype}. Please set "
133 + "`--kv-cache-dtype fp8` or select a backend that supports "
134 + "bfloat16 KV cache."
135 + )
136 + if kv_cache_dtype != "fp8_ds_mla":
137 + if cache_config is not None:
138 + cache_config.cache_dtype = "fp8_ds_mla"
139 + kv_cache_dtype = "fp8_ds_mla"
140 + logger.info_once("Using DeepSeek's fp8_ds_mla KV cache format.")
141 + return kv_cache_dtype, torch.uint8
142 +
143 + # Plain bf16 / per-tensor fp8 KV row (FlashInfer).
144 + if kv_cache_dtype.startswith("fp8"):
145 + return kv_cache_dtype, torch.float8_e4m3fn
146 + # auto / bfloat16 -> plain bf16 KV row.
147 + return kv_cache_dtype, torch.bfloat16
148 +
149 +
150 +class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
151 + """DeepseekV4 MLA attention layer.
152 +
153 + The platform-specific sparse-MLA forward (``forward_mqa`` /
154 + ``get_padded_num_q_heads`` / ``_o_proj`` / ``backend_cls``) is provided by a
155 + subclass — ``DeepseekV4FlashMLAAttention`` /
156 + ``DeepseekV4FlashInferSM120Attention`` /
157 + ``DeepseekV4FlashInferMLAAttention`` (CUDA) or
158 + ``DeepseekV41ROCMAiterMLAAttention`` (ROCm) — selected by the platform-specific
159 + deepseek_v4_1 model module. The base is never instantiated directly.
160 + """
161 +
162 + # Provided by the platform subclass.
163 + backend_cls: ClassVar[type[AttentionBackend]]
164 + # Backend for the SWA cache layer; None uses the default SWA backend.
165 + swa_backend_cls: ClassVar[type[AttentionBackend] | None] = None
166 + # KV-cache per-token block format (both layouts are paged). True (default)
167 + # = fp8_ds_mla (UE8M0 block-scaled fp8 packed as uint8); False = plain
168 + # bf16 / per-tensor fp8 KV row. Backends can override the instance hook when
169 + # a single attention class dispatches across arch-specific layouts.
170 + use_fp8_ds_mla_layout: ClassVar[bool] = True
171 + # Prefill is processed in fixed-size chunks; this bounds the bf16 kv-gather
172 + # workspace allocated in _forward_prefill and is also read by the dummy-run
173 + # path to pre-reserve that workspace.
174 + PREFILL_CHUNK_SIZE: ClassVar[int] = 4
175 +
176 + @classmethod
177 + @abstractmethod
178 + def get_padded_num_q_heads(cls, num_heads: int) -> int:
179 + """Q head count the q/output buffers are allocated at.
180 +
181 + The layer allocates the q/output buffers at
182 + ``[N, get_padded_num_q_heads(n_local_heads), head_dim]``. Must satisfy
183 + ``result >= num_heads``. Backends with no padding constraint return
184 + ``num_heads``.
185 + """
186 + raise NotImplementedError
187 +
188 + @abstractmethod
189 + def forward_mqa(
190 + self,
191 + q: torch.Tensor,
192 + kv: torch.Tensor,
193 + positions: torch.Tensor,
194 + output: torch.Tensor,
195 + ) -> None:
196 + """Platform-specific sparse MLA forward; writes attention into ``output``."""
197 + raise NotImplementedError
198 +
199 + @abstractmethod
200 + def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
201 + """Inverse-RoPE + wo_a + wo_b output projection (platform-specific)."""
202 + raise NotImplementedError
203 +
204 + def _uses_fp8_ds_mla_layout(self) -> bool:
205 + """Return whether this instance stores fp8 KV in fp8_ds_mla layout."""
206 + return self.use_fp8_ds_mla_layout
207 +
208 + def __init__(
209 + self,
210 + vllm_config: VllmConfig,
211 + prefix: str,
212 + topk_indices_buffer: torch.Tensor | None = None,
213 + aux_stream_list: list[torch.cuda.Stream] | None = None,
214 + candidate_block_buffer: torch.Tensor | None = None,
215 + ) -> None:
216 + super().__init__()
217 + config = vllm_config.model_config.hf_config
218 + quant_config = vllm_config.quant_config
219 + cache_config = vllm_config.cache_config
220 + tp_size = get_tensor_model_parallel_world_size()
221 + layer_id = extract_layer_index(prefix)
222 + self.layer_id = layer_id
223 +
224 + self.prefix = prefix # Alias for compatibility with compressor
225 + self.hidden_size = config.hidden_size
226 + self.n_heads = config.num_attention_heads
227 + assert self.n_heads % tp_size == 0
228 + self.n_local_heads = self.n_heads // tp_size
229 + self.q_lora_rank = config.q_lora_rank
230 + self.o_lora_rank = config.o_lora_rank
231 + self.head_dim = config.head_dim
232 + self.rope_head_dim = config.qk_rope_head_dim
233 + self.nope_head_dim = self.head_dim - self.rope_head_dim
234 + self.n_groups = config.o_groups
235 + self.n_local_groups = self.n_groups // tp_size
236 + self.window_size = config.sliding_window
237 + # Vision variant: image spans are visible bidirectionally, widening
238 + # prefill SWA index rows by up to max_image_tokens columns.
239 + self.max_image_tokens = (
240 + getattr(config, "vision_max_n_token", 0)
241 + if getattr(config, "vision_n_layers", 0) > 0
242 + else 0
243 + )
244 + # ---- v4.1 sparse-attention topology ----
245 + # compress_ratios has one entry per layer (MTP layers included):
246 + # 0 = pure sliding window, 1 = full-length compressed cache,
247 + # 2 = ratio-2 compressed. Compressors and compressed-KV caches live
248 + # only on ``kv_source_layer_ids``; indexers only on
249 + # ``index_source_layer_ids``. Consumers reuse the most recently
250 + # published source below them.
251 + compress_ratios = getattr(config, "compress_ratios", None)
252 + if compress_ratios is not None and layer_id < len(compress_ratios):
253 + self.compress_ratio = int(compress_ratios[layer_id])
254 + else:
255 + # MTP layers past the configured list are pure sliding-window.
256 + self.compress_ratio = 0
257 + if self.compress_ratio not in (0, 1, 2):
258 + raise ValueError(
259 + f"DeepSeek V4.1 layer {layer_id} has compress_ratio="
260 + f"{self.compress_ratio}; only 0 (sliding window), 1 and 2 are "
261 + "supported."
262 + )
263 + self.kv_source_layers = tuple(
264 + getattr(config, "kv_source_layer_ids", None) or ()
265 + )
266 + self.index_source_layers = tuple(
267 + getattr(config, "index_source_layer_ids", None) or ()
268 + )
269 + self.candidate_source_layer = getattr(config, "candidate_source_layer_id", -1)
270 + self.candidate_topk_blocks = getattr(config, "candidate_topk_blocks", 0)
271 + self.candidate_block_size = getattr(config, "candidate_block_size", 0)
272 +
273 + is_backbone = layer_id < config.num_hidden_layers
274 + self.is_kv_source = is_backbone and layer_id in self.kv_source_layers
275 + self.is_index_source = is_backbone and layer_id in self.index_source_layers
276 + if self.compress_ratio > 0:
277 + if not self.kv_source_layers or not self.index_source_layers:
278 + raise ValueError(
279 + "DeepSeek V4.1 requires kv_source_layer_ids / "
280 + "index_source_layer_ids in the config for compressed "
281 + f"layers (layer {layer_id} has "
282 + f"compress_ratio={self.compress_ratio})."
283 + )
284 + self.kv_source_layer_id = max(
285 + s for s in self.kv_source_layers if s <= layer_id
286 + )
287 + self.index_source_layer_id = max(
288 + s for s in self.index_source_layers if s <= layer_id
289 + )
290 + else:
291 + self.kv_source_layer_id = None
292 + self.index_source_layer_id = None
293 + self.eps = config.rms_norm_eps
294 + self.scale = self.head_dim**-0.5
295 +
296 + # Padded Q head count is dictated by the platform subclass.
297 + self.padded_heads = self.get_padded_num_q_heads(self.n_local_heads)
298 + # Sink padded to the same head count, initialized to -inf (no sink
299 + # effect). Weight loading fills the first n_local_heads slots.
300 + self.attn_sink = nn.Parameter(
301 + torch.full((self.padded_heads,), -float("inf"), dtype=torch.float32),
302 + requires_grad=False,
303 + )
304 +
305 + self.fused_wqa_wkv = MergedColumnParallelLinear(
306 + self.hidden_size,
307 + [self.q_lora_rank, self.head_dim],
308 + bias=False,
309 + quant_config=quant_config,
310 + prefix=f"{prefix}.fused_wqa_wkv",
311 + disable_tp=True, # fused ReplicatedLinear
312 + )
313 + self.q_norm = RMSNorm(self.q_lora_rank, self.eps)
314 + self.wq_b = ColumnParallelLinear(
315 + self.q_lora_rank,
316 + self.n_heads * self.head_dim,
317 + bias=False,
318 + quant_config=quant_config,
319 + return_bias=False,
320 + prefix=f"{prefix}.wq_b",
321 + )
322 +
323 + self.kv_norm = RMSNorm(self.head_dim, self.eps)
324 + self.wo_a = ColumnParallelLinear(
325 + self.n_heads * self.head_dim // self.n_groups,
326 + self.n_groups * self.o_lora_rank,
327 + bias=False,
328 + quant_config=quant_config,
329 + return_bias=False,
330 + prefix=f"{prefix}.wo_a",
331 + )
332 + self.wo_a.is_bmm = True
333 + self.wo_a.bmm_batch_size = self.n_local_groups
334 + self._o_proj_block_size = (
335 + 32 if getattr(self.wo_a, "weight_block_size", None) == [1, 32] else 128
336 + )
337 + self.wo_b = RowParallelLinear(
338 + self.n_groups * self.o_lora_rank,
339 + self.hidden_size,
340 + bias=False,
341 + quant_config=quant_config,
342 + return_bias=False,
343 + prefix=f"{prefix}.wo_b",
344 + )
345 +
346 + # Initialize rotary embedding before the indexer/compressor consume it.
347 + self.rotary_emb = build_deepseek_v4_rope(
348 + config,
349 + head_dim=self.head_dim,
350 + rope_head_dim=self.rope_head_dim,
351 + max_position_embeddings=config.max_position_embeddings,
352 + compress_ratio=self.compress_ratio,
353 + )
354 + self.indexer_rotary_emb = self.rotary_emb
355 + self.topk_indices_buffer = topk_indices_buffer
356 + self.candidate_block_buffer = candidate_block_buffer
357 +
358 + # Register with compilation context for metadata lookup. Done before
359 + # indexer/compressor creation so consumers can resolve their source
360 + # layers through it.
361 + compilation_config = vllm_config.compilation_config
362 + if prefix and prefix in compilation_config.static_forward_context:
363 + raise ValueError(f"Duplicate layer name: {prefix}")
364 + if prefix:
365 + compilation_config.static_forward_context[prefix] = self
366 + self.kv_cache = torch.tensor([])
367 + self._static_forward_context = compilation_config.static_forward_context
368 +
369 + self.indexer = None
370 + if self.is_index_source:
371 + index_k_cache: DeepseekV4IndexerCache | None
372 + # Index K cache: owned only by kv-source layers (their indexer has
373 + # wk/k_norm); non-owning index sources share the K cache of the
374 + # latest kv source below them (its indexer produces the keys).
375 + if self.is_kv_source:
376 + index_k_cache = DeepseekV4IndexerCache(
377 + head_dim=_indexer_k_cache_head_dim(
378 + config.index_head_dim, dsa_indexer_uses_fp4(vllm_config)
379 + ),
380 + dtype=torch.uint8,
381 + prefix=f"{prefix}.indexer.k_cache",
382 + cache_config=cache_config,
383 + compress_ratio=self.compress_ratio,
384 + )
385 + else:
386 + assert self.kv_source_layer_id is not None
387 + k_cache_prefix = (
388 + f"{_replace_layer_index(prefix, self.kv_source_layer_id)}"
389 + ".indexer.k_cache"
390 + )
391 + index_k_cache = self._static_forward_context.get(k_cache_prefix)
392 + if index_k_cache is None:
393 + raise NotImplementedError(
394 + f"Indexer K cache source {k_cache_prefix} not found on "
395 + "this rank; PP splits inside a v4.1 kv-sharing group "
396 + "are not supported."
397 + )
398 + is_candidate_source = layer_id == self.candidate_source_layer
399 + uses_candidates = 0 <= self.candidate_source_layer < layer_id
400 + self.indexer = DeepseekV4Indexer(
401 + vllm_config,
402 + config=config,
403 + hidden_size=self.hidden_size,
404 + q_lora_rank=self.q_lora_rank,
405 + quant_config=quant_config,
406 + cache_config=cache_config,
407 + topk_indices_buffer=topk_indices_buffer,
408 + compress_ratio=self.compress_ratio,
409 + prefix=f"{prefix}.indexer",
410 + owns_k=self.is_kv_source,
411 + k_cache=index_k_cache,
412 + main_head_dim=self.head_dim,
413 + candidate_block_buffer=(
414 + candidate_block_buffer
415 + if (is_candidate_source or uses_candidates)
416 + else None
417 + ),
418 + candidate_block_size=self.candidate_block_size,
419 + candidate_write=is_candidate_source,
420 + )
421 +
422 + self._prepare_and_attn_fn = self._prepare_and_attn
423 + if not vllm_config.use_v2_model_runner:
424 + # MRV1's piecewise capture only tolerates the wide eager region: with
425 + # the narrow one the attention input preparation stays in the captured
426 + # graph and MRV1 produces garbage (#51430).
427 + self._prepare_and_attn_fn = self._prepare_and_attn_eager
428 +
429 + # Will be None on ROCm for now.
430 + self.aux_stream_list = aux_stream_list
431 + # [0]: GEMM start / post-GEMM event0. [1..3]: GEMM done events;
432 + # [1] doubles as post-GEMM event1. Reuse is safe: GEMM fully joins
433 + # before post-GEMM starts.
434 + self.ln_events = [torch.cuda.Event() for _ in range(4)]
435 +
436 + assert cache_config is not None, "DeepseekV4 attention requires cache_config"
437 + # ---- Attention / KV-cache setup ----
438 + self.max_num_batched_tokens = (
439 + vllm_config.scheduler_config.max_num_batched_tokens
440 + )
441 + self.max_model_len = vllm_config.model_config.max_model_len
442 +
443 + # Resolve the kv-cache dtype from this backend's block format. The same
444 + # resolution drives the SWA cache tensor dtype below.
445 + self.kv_cache_dtype, self.kv_cache_torch_dtype = _resolve_dsv4_kv_cache_dtype(
446 + self._uses_fp8_ds_mla_layout(), cache_config.cache_dtype, cache_config
447 + )
448 +
449 + self.swa_cache_layer = DeepseekV4SWACache(
450 + head_dim=self.head_dim,
451 + window_size=self.window_size,
452 + dtype=self.kv_cache_torch_dtype,
453 + prefix=f"{prefix}.swa_cache",
454 + cache_config=cache_config,
455 + backend_cls=self.swa_backend_cls,
456 + block_size=32,
457 + )
458 +
459 + # The attention layer itself was already registered with the
460 + # compilation context above (before indexer/compressor creation).
461 +
462 + # Compressors live only on kv-source layers; consumers read the
463 + # source's compressed cache through the forward context.
464 + self.compressor = None
465 + if self.is_kv_source:
466 + self.compressor = DeepseekCompressor(
467 + vllm_config=vllm_config,
468 + compress_ratio=self.compress_ratio,
469 + hidden_size=self.hidden_size,
470 + head_dim=self.head_dim,
471 + rotate=True,
472 + prefix=f"{prefix}.compressor",
473 + k_cache_prefix=self.prefix,
474 + )
475 + # Prefix of the attention layer owning this layer's compressed KV
476 + # cache (self for kv sources).
477 + if self.compress_ratio > 0:
478 + assert self.kv_source_layer_id is not None
479 + self.compressed_cache_prefix: str | None = _replace_layer_index(
480 + prefix, self.kv_source_layer_id
481 + )
482 + if (
483 + not self.is_kv_source
484 + and self.compressed_cache_prefix not in self._static_forward_context
485 + ):
486 + raise NotImplementedError(
487 + f"Compressed-KV source {self.compressed_cache_prefix} not "
488 + "found on this rank; PP splits inside a v4.1 kv-sharing "
489 + "group are not supported."
490 + )
491 + else:
492 + self.compressed_cache_prefix = None
493 +
494 + if vllm_config.kernel_config.enable_jit_warmup:
495 + from vllm.v1.attention.backends.mla.sparse_swa import (
496 + _COMPUTE_PREFILL_METADATA_KERNEL,
497 + _COMPUTE_SWA_INDICES_AND_LENS_KERNEL,
498 + )
499 +
500 + _COMPUTE_PREFILL_METADATA_KERNEL.register_warmup()
501 + _COMPUTE_SWA_INDICES_AND_LENS_KERNEL.register_warmup(
502 + window_size=self.window_size,
503 + block_size=self.swa_cache_layer.block_size,
504 + max_image_tokens=self.max_image_tokens,
505 + )
506 +
507 + if self.compress_ratio > 1:
508 + from vllm.v1.attention.backends.mla.compressor_utils import (
509 + _COMPRESSED_SLOT_MAPPING_KERNEL,
510 + )
511 +
512 + _COMPRESSED_SLOT_MAPPING_KERNEL.register_warmup()
513 +
514 + if self.indexer is not None:
515 + from vllm.v1.attention.backends.mla.indexer import (
516 + _BUILD_PREFILL_CHUNK_METADATA_KERNEL,
517 + _PREPARE_UNIFORM_DECODE_KERNEL,
518 + )
519 +
520 + _PREPARE_UNIFORM_DECODE_KERNEL.register_warmup()
521 + _BUILD_PREFILL_CHUNK_METADATA_KERNEL.register_warmup()
522 +
523 + spec_config = vllm_config.speculative_config
524 + if spec_config is not None and spec_config.use_dspark():
525 + from vllm.v1.attention.backends.mla.sparse_swa import (
526 + _COMPUTE_DSPARK_NONCAUSAL_SWA_INDICES_KERNEL,
527 + )
528 +
529 + _COMPUTE_DSPARK_NONCAUSAL_SWA_INDICES_KERNEL.register_warmup(
530 + window_size=self.window_size,
531 + num_speculative_tokens=spec_config.num_speculative_tokens,
532 + block_size=self.swa_cache_layer.block_size,
533 + )
534 +
535 + if self.backend_cls.get_name() in (
536 + "FLASHMLA_SPARSE_DSV41",
537 + "ROCM_FLASHMLA_SPARSE_DSV4",
538 + ):
539 + from vllm.models.deepseek_v4_1.common.ops.cache_utils import (
540 + _COMBINE_TOPK_SWA_INDICES_KERNEL,
541 + )
542 +
543 + _COMBINE_TOPK_SWA_INDICES_KERNEL.register_warmup()
544 +
545 + def forward(
546 + self,
547 + positions: torch.Tensor,
548 + hidden_states: torch.Tensor,
549 + llama_4_scaling: torch.Tensor | None = None,
550 + ) -> torch.Tensor:
551 + # Pre-allocate attention output with FlashMLA-padded head count.
552 + # The op writes into `o_padded`; we slice to n_local_heads after.
553 + num_tokens = hidden_states.shape[0]
554 + o_padded = torch.empty(
555 + (num_tokens, self.padded_heads, self.head_dim),
556 + dtype=hidden_states.dtype,
557 + device=hidden_states.device,
558 + )
559 +
560 + # Keep the attention input preparation in the captured graph. Only the
561 + # sparse indexer and MLA attention run in the eager break below.
562 + qr_kv, kv_score, indexer_weights = self._run_parallel_input_projections(
563 + hidden_states
564 + )
565 + qr, qr_scale, kv = self._split_qkv_and_norm(qr_kv)
566 +
567 + self._prepare_and_attn_fn(
568 + hidden_states,
569 + qr,
570 + kv,
571 + qr_scale,
572 + kv_score,
573 + indexer_weights,
574 + positions,
575 + o_padded,
576 + )
577 + o = o_padded[:, : self.n_local_heads, :]
578 +
579 + # Inverse-RoPE + wo_a + wo_b output projection (platform-specific).
580 + return self._o_proj(o, positions)
581 +
582 + @cached_property
583 + def _can_fuse_query_quant(self) -> bool:
584 + from vllm.models.deepseek_v4_1.common.ops.query_quant import (
585 + can_fuse_query_quant,
586 + )
587 +
588 + linears = [self.wq_b]
589 + if self.indexer is not None:
590 + linears.append(self.indexer.wq_b)
591 + return can_fuse_query_quant(linears)
592 +
593 + def _split_qkv_and_norm(
594 + self, qr_kv: torch.Tensor
595 + ) -> tuple[torch.Tensor | QuantizedActivation, torch.Tensor | None, torch.Tensor]:
596 + """Split the fused q-lora / kv projection and RMSNorm both halves.
597 +
598 + Compatible MXFP8 projections share the quantized Q and scales;
599 + other projection backends consume the normalized Q directly.
600 + """
601 + qr, kv = qr_kv.split([self.q_lora_rank, self.head_dim], dim=-1)
602 + if self.q_lora_rank % 32 == 0 and self._can_fuse_query_quant:
603 + from vllm.models.deepseek_v4_1.common.ops.query_quant import (
604 + fused_q_kv_rmsnorm_quant,
605 + )
606 +
607 + qr_quant, kv = fused_q_kv_rmsnorm_quant(
608 + qr,
609 + kv,
610 + self.q_norm.weight.data,
611 + self.kv_norm.weight.data,
612 + self.eps,
613 + )
614 + return qr_quant, None, kv
615 + qr, kv = fused_q_kv_rmsnorm(
616 + qr,
617 + kv,
618 + self.q_norm.weight.data,
619 + self.kv_norm.weight.data,
620 + self.eps,
621 + )
622 + return qr, None, kv
623 +
624 + @eager_break_during_capture
625 + def _prepare_and_attn_eager(
626 + self,
627 + hidden_states: torch.Tensor,
628 + qr: torch.Tensor | QuantizedActivation,
629 + kv: torch.Tensor,
630 + qr_scale: torch.Tensor | None,
631 + kv_score: torch.Tensor,
632 + indexer_weights: torch.Tensor,
633 + positions: torch.Tensor,
634 + o_padded: torch.Tensor,
635 + ) -> None:
636 + """Wide eager region: the whole of ``_prepare_and_attn`` runs eagerly.
637 +
638 + The nested ``_sparse_indexer_and_attn`` break runs inline, since
639 + ``add_eager`` clears ``_capturing`` before invoking this.
640 + """
641 + self._prepare_and_attn(
642 + hidden_states,
643 + qr,
644 + kv,
645 + qr_scale,
646 + kv_score,
647 + indexer_weights,
648 + positions,
649 + o_padded,
650 + )
651 +
652 + def _prepare_and_attn(
653 + self,
654 + hidden_states: torch.Tensor,
655 + qr: torch.Tensor | QuantizedActivation,
656 + kv: torch.Tensor,
657 + qr_scale: torch.Tensor | None,
658 + kv_score: torch.Tensor,
659 + indexer_weights: torch.Tensor,
660 + positions: torch.Tensor,
661 + o_padded: torch.Tensor,
662 + ) -> None:
663 + """Attention input preparation followed by the sparse indexer and MLA.
664 +
665 + Only the latter runs in the eager break.
666 +
667 + Q/SWA preparation overlaps state saving and compression. Once the
668 + latent is ready, main-cache insertion overlaps indexer preparation;
669 + both cache writes finish before sparse attention reads either cache.
670 + """
671 + attn_metadata = get_forward_context().attn_metadata
672 + indexer = self.indexer
673 + compressor = self.compressor
674 + aux_streams = self.aux_stream_list
675 +
676 + def project_query_and_cache_kv() -> torch.Tensor:
677 + q = self._wq_b_proj(qr, qr_scale).view(
678 + -1, self.n_local_heads, self.head_dim
679 + )
680 + return self._fused_qnorm_rope_kv_insert(q, kv, positions, attn_metadata)
681 +
682 + index_q: torch.Tensor | None = None
683 + index_q_scale: torch.Tensor | None = None
684 + index_weights_out: torch.Tensor | None = None
685 + latent: torch.Tensor | None = None
686 + aux_stream = aux_streams[0] if aux_streams is not None else None
687 +
688 + if compressor is not None:
689 + # Q projection / KV insertion on the default stream overlaps the
690 + # compressor on aux stream 0 (sequential on ROCm).
691 + q, latent = maybe_execute_in_parallel(
692 + project_query_and_cache_kv,
693 + lambda: compressor(kv_score, positions),
694 + self.ln_events[0],
695 + self.ln_events[1],
696 + aux_stream,
697 + )
698 + else:
699 + q = project_query_and_cache_kv()
700 +
701 + def prepare_indexer():
702 + if indexer is None:
703 + return None, None, None
704 + return indexer(
705 + qr,
706 + latent,
707 + indexer_weights,
708 + positions,
709 + self.indexer_rotary_emb,
710 + qr_scale,
711 + )
712 +
713 + if compressor is not None:
714 + indexer_result, _ = maybe_execute_in_parallel(
715 + prepare_indexer,
716 + lambda: compressor.insert_cache(latent, positions, self.rotary_emb),
717 + self.ln_events[0],
718 + self.ln_events[1],
719 + aux_stream,
720 + )
721 + else:
722 + indexer_result = prepare_indexer()
723 + index_q, index_q_scale, index_weights_out = indexer_result
724 +
725 + self._sparse_indexer_and_attn(
726 + hidden_states,
727 + index_q,
728 + index_q_scale,
729 + index_weights_out,
730 + q,
731 + kv,
732 + positions,
733 + o_padded,
734 + )
735 +
736 + def _fused_wqa_wkv_gemm(self, hidden_states: torch.Tensor) -> torch.Tensor:
737 + # Override point: the ROCm layer preshuffles this weight in place, so
738 + # it cannot go through fused_wqa_wkv directly.
739 + # MergedColumnParallelLinear returns (output, bias); bias is None.
740 + qr_kv, _ = self.fused_wqa_wkv(hidden_states)
741 + return qr_kv
742 +
743 + def _wq_b_proj(
744 + self,
745 + qr: torch.Tensor | QuantizedActivation,
746 + qr_scale: torch.Tensor | None = None,
747 + ) -> torch.Tensor:
748 + """Project normalized Q, bypassing quantization when already fused."""
749 + assert qr_scale is None, "ROCm-only path"
750 + return self.wq_b(qr)
751 +
752 + def _run_parallel_input_projections(
753 + self, hidden_states: torch.Tensor
754 + ) -> tuple[
755 + torch.Tensor,
756 + torch.Tensor | None,
757 + torch.Tensor | None,
758 + ]:
759 + aux_streams = self.aux_stream_list
760 + if aux_streams is not None:
761 + aux_streams = aux_streams[:2]
762 +
763 + # fused_wqa_wkv (heaviest) on default; the two lighter input GEMMs on
764 + # aux streams 0/1 when their owning module exists. ln_events[0] is the
765 + # fan-out start event; ln_events[1..2] are per-aux done events. The
766 + # v4.1 indexer derives K from the kv-source compressor's latent, so
767 + # unlike v4.0 there is no indexer K GEMM over hidden_states here.
768 + aux_fns: list[Callable[[], Any] | None] = [None, None]
769 +
770 + if self.compressor is not None:
771 + # Local ref so the closure keeps a non-None type for mypy.
772 + compressor = self.compressor
773 +
774 + def compressor_kv_score() -> torch.Tensor:
775 + return torch.mm(
776 + hidden_states,
777 + compressor.fused_wkv_wgate.weight.T,
778 + out_dtype=torch.float32,
779 + )
780 +
781 + aux_fns[0] = compressor_kv_score
782 +
783 + if self.indexer is not None:
784 + indexer = self.indexer
785 +
786 + def indexer_weights_proj() -> torch.Tensor:
787 + # ReplicatedLinear returns (output, bias); bias is None.
788 + weights, _ = indexer.weights_proj(hidden_states)
789 + return weights
790 +
791 + aux_fns[1] = indexer_weights_proj
792 +
793 + qr_kv, (kv_score, indexer_weights) = execute_in_parallel(
794 + lambda: self._fused_wqa_wkv_gemm(hidden_states),
795 + aux_fns,
796 + self.ln_events[0],
797 + self.ln_events[1:3],
798 + aux_streams,
799 + enable=hidden_states.shape[0]
800 + <= envs.VLLM_MULTI_STREAM_GEMM_TOKEN_THRESHOLD,
801 + )
802 +
803 + return qr_kv, kv_score, indexer_weights
804 +
805 + @eager_break_during_capture
806 + def _sparse_indexer_and_attn(
807 + self,
808 + hidden_states: torch.Tensor,
809 + index_q: torch.Tensor | None,
810 + index_q_scale: torch.Tensor | None,
811 + index_weights: torch.Tensor | None,
812 + q: torch.Tensor,
813 + kv: torch.Tensor,
814 + positions: torch.Tensor,
815 + out: torch.Tensor,
816 + ) -> None:
817 + if self.indexer is not None and index_q is not None:
818 + assert index_weights is not None
819 + q_quant = (index_q, index_q_scale) if index_q_scale is not None else index_q
820 + self.indexer.indexer_op(
821 + hidden_states,
822 + q_quant,
823 + None,
824 + index_weights,
825 + )
826 +
827 + # MLA attention writes into the pre-allocated `out` buffer
828 + # ([num_tokens, padded_heads, head_dim]).
829 + self.forward_mqa(q, kv, positions, out)
830 +
831 + def _fused_qnorm_rope_kv_insert(
832 + self,
833 + q: torch.Tensor,
834 + kv: torch.Tensor,
835 + positions: torch.Tensor,
836 + attn_metadata: (
837 + dict[str, AttentionMetadata] | list[dict[str, AttentionMetadata]] | None
838 + ),
839 + ) -> torch.Tensor:
840 + if not isinstance(attn_metadata, dict):
841 + # Profile run: kernel doesn't fire; produce a padded tensor so
842 + # downstream FlashMLA gets the right shape.
843 + if self.n_local_heads < self.padded_heads:
844 + return F.pad(
845 + q,
846 + (0, 0, 0, self.padded_heads - self.n_local_heads),
847 + value=0.0,
848 + )
849 + return q
850 +
851 + swa_metadata = cast(
852 + "DeepseekSparseSWAMetadata | None",
853 + attn_metadata.get(self.swa_cache_layer.prefix),
854 + )
855 + assert swa_metadata is not None
856 +
857 + swa_kv_cache = self.swa_cache_layer.kv_cache
858 + # The fused insert ops require int64 position_ids; the runner's positions
859 + # buffer is already int64, so no cast is needed.
860 + assert positions.dtype == torch.int64
861 + cos_sin_cache = self.rotary_emb.cos_sin_cache
862 + cache_dtype = swa_kv_cache.dtype
863 +
864 + # kv is unchanged; attention reads kv solely via swa_kv_cache.
865 + if cache_dtype == torch.uint8:
866 + # fp8_ds_mla UE8M0 paged path. Horizontally fused:
867 + # Q side: GPT-J RoPE, zero-filling the padding head slots; the
868 + # kernel allocates and returns the padded q tensor.
869 + # KV side: GPT-J RoPE + UE8M0 FP8 quant + paged cache insert.
870 + swa_kv_cache_2d = swa_kv_cache.view(swa_kv_cache.shape[0], -1)
871 + return torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
872 + q,
873 + kv,
874 + swa_kv_cache_2d,
875 + swa_metadata.slot_mapping,
876 + positions,
877 + cos_sin_cache,
878 + self.padded_heads,
879 + self.eps,
880 + swa_metadata.block_size,
881 + False,
882 + )
883 +
884 + # Plain-row path: the [num_blocks, block_size, 512] cache stores the KV
885 + # row in its element dtype (no Q padding). bf16 rewrites q in place;
886 + # per-tensor fp8 writes a separately-allocated fp8 q and quantizes the
887 + # KV row.
888 + block_size = swa_metadata.block_size
889 + assert swa_kv_cache.shape[1:] == (block_size, self.head_dim)
890 + swa_kv_cache_3d = swa_kv_cache
891 + if cache_dtype == torch.bfloat16:
892 + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(
893 + q,
894 + kv,
895 + swa_kv_cache_3d,
896 + swa_metadata.slot_mapping,
897 + positions,
898 + cos_sin_cache,
899 + self.eps,
900 + block_size,
901 + False,
902 + )
903 + return q
904 +
905 + # per-tensor fp8 (torch.float8_e4m3fn)
906 + q_fp8 = torch.empty_like(q, dtype=torch.float8_e4m3fn)
907 + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert(
908 + q,
909 + kv,
910 + q_fp8,
911 + swa_kv_cache_3d,
912 + swa_metadata.slot_mapping,
913 + positions,
914 + cos_sin_cache,
915 + self._flashinfer_fp8_kv_scale,
916 + self._flashinfer_fp8_q_scale_inv,
917 + self.eps,
918 + block_size,
919 + False,
920 + )
921 + return q_fp8
922 +
923 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
924 + # [B, H=1, N, C] -> [B, N, C]
925 + self.kv_cache = kv_cache.squeeze(1)
926 +
927 + def get_attn_backend(self) -> type[AttentionBackend]:
928 + return self.backend_cls
929 +
930 + def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec | None:
931 + # Only kv-source layers own a compressed-KV cache; consumers read the
932 + # source's cache through the forward context, and cr==0 layers are
933 + # pure SWA. The SWA cache is allocated separately as
934 + # DeepseekV4SWACache.
935 + if not self.is_kv_source:
936 + return None
937 + # fp8_ds_mla is a UE8M0 block-scaled uint8 layout and needs 576B
938 + # alignment; plain bf16 / per-tensor fp8 rows use natural element-size
939 + # pages.
940 + uses_fp8_ds_mla_layout = self.kv_cache_dtype == "fp8_ds_mla"
941 + return MLAAttentionSpec(
942 + block_size=vllm_config.cache_config.block_size,
943 + num_kv_heads=1,
944 + head_size=self.head_dim,
945 + dtype=torch.uint8 if uses_fp8_ds_mla_layout else self.kv_cache_torch_dtype,
946 + tokens_per_state=self.compress_ratio,
947 + cache_dtype_str=self.kv_cache_dtype,
948 + alignment=576 if uses_fp8_ds_mla_layout else 512,
949 + model_version="deepseek_v4",
950 + kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
951 + # DeepseekV4: 448B NoPE + 128B RoPE + 8B fp8 scale = 584B per token;
952 + # head_size stays semantic (512).
953 + state_content_bytes=584 if uses_fp8_ds_mla_layout else None,
954 + )
955 +
956 + def _compressed_kv_cache(self) -> torch.Tensor:
957 + """The compressed-KV cache tensor of this layer's kv source (own
958 + cache for kv-source layers)."""
959 + if self.is_kv_source:
960 + return self.kv_cache
961 + assert self.compressed_cache_prefix is not None
962 + source = self._static_forward_context[self.compressed_cache_prefix]
963 + return source.kv_cache
964 +
965 +
966 +class DeepseekV4IndexerCache(torch.nn.Module, AttentionLayerBase):
967 + def __init__(
968 + self,
969 + head_dim: int,
970 + dtype: torch.dtype,
971 + prefix: str,
972 + cache_config: CacheConfig,
973 + compress_ratio: int = 1,
974 + ):
975 + super().__init__()
976 + self.kv_cache = torch.tensor([])
977 + self.head_dim = head_dim
978 + self.prefix = prefix
979 + self.cache_config = cache_config
980 + self.dtype = dtype
981 + self.compress_ratio = compress_ratio
982 + compilation_config = get_current_vllm_config().compilation_config
983 + if prefix in compilation_config.static_forward_context:
984 + raise ValueError(f"Duplicate layer name: {prefix}")
985 + compilation_config.static_forward_context[prefix] = self
986 +
987 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
988 + # [B, H=1, N, C] -> [B, N, C]
989 + self.kv_cache = kv_cache.squeeze(1)
990 +
991 + def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
992 + # head_dim already carries the fp8 scale padding
993 + # tokens_per_state=1 for V3.2, >1 for DeepseekV4; same cache layout.
994 + uses_fp8_ds_mla_layout = vllm_config.cache_config.cache_dtype == "fp8_ds_mla"
995 + return MLAAttentionSpec(
996 + block_size=self.cache_config.block_size,
997 + num_kv_heads=1,
998 + head_size=self.head_dim,
999 + dtype=self.dtype,
1000 + tokens_per_state=self.compress_ratio,
1001 + # 576B for FlashMLA packing; 512B for FlashInfer sparse (#44577).
1002 + alignment=576 if uses_fp8_ds_mla_layout else 512,
1003 + )
1004 +
1005 + def forward(self): ...
1006 +
1007 + def get_attn_backend(self) -> type[AttentionBackend]:
1008 + return DeepseekV4IndexerBackend
1009 +
1010 +
1011 +class DeepseekV4Indexer(nn.Module):
1012 + """DeepSeek V4.1 sparse-attention indexer.
1013 +
1014 + Exists only on ``index_source_layer_ids``; consumers reuse the topk
1015 + indices it publishes into the shared ``topk_indices_buffer``. Unlike v4.0
1016 + the index key is derived from the kv-source layer's compressor latent
1017 + (``k = k_norm(wk(latent))``, ``owns_k``) instead of an indexer-local
1018 + compressor over hidden states, so there is no hidden-state K GEMM here.
1019 + Non-owning index sources share the kv source's paged K cache.
1020 +
1021 + Two-level candidate filtering: the indexer at ``candidate_source_layer_id``
1022 + additionally publishes the top ``candidate_topk_blocks`` blocks of
1023 + ``candidate_block_size`` compressed positions (``candidate_write``);
1024 + later indexers mask their scores to those blocks before their own top-k.
1025 + """
1026 +
1027 + def __init__(
1028 + self,
1029 + vllm_config: VllmConfig,
1030 + config: DeepseekV2Config | DeepseekV3Config,
1031 + hidden_size: int,
1032 + q_lora_rank: int,
1033 + quant_config: QuantizationConfig | None,
1034 + cache_config: CacheConfig | None,
1035 + topk_indices_buffer: torch.Tensor | None,
1036 + compress_ratio: int,
1037 + prefix: str,
1038 + *,
1039 + owns_k: bool,
1040 + k_cache: DeepseekV4IndexerCache,
1041 + main_head_dim: int,
1042 + candidate_block_buffer: torch.Tensor | None = None,
1043 + candidate_block_size: int = 0,
1044 + candidate_write: bool = False,
1045 + ):
1046 + super().__init__()
1047 + self.vllm_config = vllm_config
1048 + self.config = config
1049 + self.quant_config = quant_config
1050 + self.topk_tokens = config.index_topk
1051 + self.n_head = config.index_n_heads # 32
1052 + self.head_dim = config.index_head_dim # 128
1053 + self.rope_dim = config.qk_rope_head_dim # 64
1054 + self.q_lora_rank = q_lora_rank # 1280
1055 + self.compress_ratio = compress_ratio
1056 + self.owns_k = owns_k
1057 + self.use_fp4_kv = dsa_indexer_uses_fp4(vllm_config)
1058 + logger.info_once(
1059 + "Using %s indexer cache for Lightning Indexer.",
1060 + "MXFP4" if self.use_fp4_kv else "FP8",
1061 + )
1062 +
1063 + # no tensor parallel, just replicated
1064 + self.wq_b = ReplicatedLinear(
1065 + self.q_lora_rank,
1066 + self.head_dim * self.n_head,
1067 + bias=False,
1068 + quant_config=quant_config,
1069 + prefix=f"{prefix}.wq_b",
1070 + )
1071 + self.weights_proj = ReplicatedLinear(
1072 + hidden_size,
1073 + self.n_head,
1074 + bias=False,
1075 + quant_config=None,
1076 + prefix=f"{prefix}.weights_proj",
1077 + )
1078 + self.softmax_scale = self.head_dim**-0.5
1079 +
1080 + self.scale_fmt = "ue8m0"
1081 + self.quant_block_size = 128 # TODO: get from config
1082 + self.topk_indices_buffer = topk_indices_buffer
1083 +
1084 + self.max_model_len = (
1085 + vllm_config.model_config.max_model_len // self.compress_ratio
1086 + )
1087 + self.prefix = prefix
1088 +
1089 + self.max_total_seq_len = (
1090 + get_max_prefill_buffer_size(vllm_config) // self.compress_ratio
1091 + )
1092 +
1093 + assert cache_config is not None, "Deepseek V4 indexer requires cache_config"
1094 + if owns_k:
1095 + # wk maps the main compressor's (pre-RoPE) latent to index keys.
1096 + # The checkpoint stores it in bf16 with no quantization scales.
1097 + self.wk = ReplicatedLinear(
1098 + main_head_dim,
1099 + self.head_dim,
1100 + bias=False,
1101 + quant_config=None,
1102 + prefix=f"{prefix}.wk",
1103 + )
1104 + self.k_norm = RMSNorm(self.head_dim, config.rms_norm_eps)
1105 + self.k_cache = k_cache
1106 +
1107 + self.indexer_op = SparseAttnIndexer(
1108 + self.k_cache,
1109 + self.quant_block_size,
1110 + self.scale_fmt,
1111 + self.topk_tokens,
1112 + self.head_dim,
1113 + self.max_model_len,
1114 + self.max_total_seq_len,
1115 + self.topk_indices_buffer,
1116 + skip_k_cache_insert=True,
1117 + use_fp4_cache=self.use_fp4_kv,
1118 + compress_ratio=self.compress_ratio,
1119 + candidate_blocks=candidate_block_buffer,
1120 + candidate_block_size=candidate_block_size,
1121 + candidate_write=candidate_write,
1122 + )
1123 +
1124 + def _produce_k(
1125 + self,
1126 + latent: torch.Tensor | None,
1127 + positions: torch.Tensor,
1128 + rotary_emb: nn.Module,
1129 + ) -> None:
1130 + """Turn the compressor's pre-RoPE latent into paged index keys.
1131 +
1132 + ``k_norm(wk(latent))`` at group-boundary tokens, RoPE'd at the group
1133 + position and MXFP4/FP8-quantized into the indexer K cache.
1134 + """
1135 + attn_metadata = get_forward_context().attn_metadata
1136 + if not isinstance(attn_metadata, dict) or latent is None:
1137 + # Profile run: the indexer K cache is not bound yet and the
1138 + # compressor skipped its latent output.
1139 + return
1140 + assert self.owns_k
1141 + indexer_metadata = cast(Any, attn_metadata[self.k_cache.prefix])
1142 + # ReplicatedLinear returns (output, bias); bias is None. Rows at
1143 + # non-boundary tokens hold garbage latent and are skipped by the
1144 + # store kernel.
1145 + k_pre, _ = self.wk(latent)
1146 + indexer_k_norm_rope_store(
1147 + k_pre,
1148 + positions,
1149 + rotary_emb.cos_sin_cache,
1150 + self.k_norm.weight,
1151 + self.k_norm.variance_epsilon,
1152 + self.k_cache.kv_cache,
1153 + indexer_metadata.slot_mapping,
1154 + self.compress_ratio,
1155 + self.use_fp4_kv,
1156 + )
1157 +
1158 + def forward(
1159 + self,
1160 + qr: torch.Tensor | QuantizedActivation,
1161 + latent: torch.Tensor | None,
1162 + indexer_weights: torch.Tensor,
1163 + positions: torch.Tensor,
1164 + rotary_emb: nn.Module,
1165 + qr_scale: torch.Tensor | None = None,
1166 + ) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]:
1167 + attn_metadata = get_forward_context().attn_metadata
1168 + if isinstance(attn_metadata, dict):
1169 + indexer_metadata = cast(Any, attn_metadata[self.k_cache.prefix])
1170 + if (
1171 + indexer_metadata.max_seq_len // self.compress_ratio <= self.topk_tokens
1172 + and not torch.cuda.is_current_stream_capturing()
1173 + ):
1174 + # candidates num smaller than topk, every candidate is selected
1175 + # but we still need to build k cache
1176 + if self.owns_k:
1177 + self._produce_k(latent, positions, rotary_emb)
1178 + assert self.topk_indices_buffer is not None
1179 + num_tokens = (
1180 + indexer_metadata.num_decode_tokens
1181 + + indexer_metadata.num_prefill_tokens
1182 + )
1183 + if num_tokens > 0:
1184 + _fill_short_context_topk_indices[(num_tokens,)](
1185 + self.topk_indices_buffer,
1186 + positions,
1187 + TOP_K=self.topk_tokens,
1188 + COMPRESS_RATIO=self.compress_ratio,
1189 + PADDED_TOP_K=triton.next_power_of_2(self.topk_tokens),
1190 + num_warps=8,
1191 + )
1192 + return None, None, None
1193 +
1194 + if self.owns_k:
1195 + # K write must land before indexer_op reads the cache
1196 + # (skip_k_cache_insert=True).
1197 + self._produce_k(latent, positions, rotary_emb)
1198 +
1199 + q = self._wq_b_proj(qr, qr_scale)
1200 + q = q.view(-1, self.n_head, self.head_dim)
1201 + q_quant, weights = fused_indexer_q_rope_quant(
1202 + positions,
1203 + q,
1204 + rotary_emb.cos_sin_cache,
1205 + indexer_weights,
1206 + self.softmax_scale,
1207 + self.n_head**-0.5,
1208 + use_fp4=self.use_fp4_kv,
1209 + )
1210 + if isinstance(q_quant, tuple):
1211 + q, q_scale = q_quant
1212 + else:
1213 + q, q_scale = q_quant, None
1214 + return q, q_scale, weights
1215 +
1216 + def _wq_b_proj(
1217 + self,
1218 + qr: torch.Tensor | QuantizedActivation,
1219 + qr_scale: torch.Tensor | None = None,
1220 + ) -> torch.Tensor:
1221 + """Project normalized Q with the main attention's shared quantization."""
1222 + assert qr_scale is None, "ROCm-only path"
1223 + # ReplicatedLinear returns (output, bias); bias is None.
1224 + q, _ = self.wq_b(qr)
1225 + return q
vllm/models/deepseek_v4_1/common/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/models/deepseek_v4_1/common/engram.py new
+1033
@@ -0,0 +1,1033 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Engram: n-gram hash lookups gated into the hyper-connection stream.
4 +
5 +Port of the reference ``inference/engram.py`` + ``Engram`` /
6 +``ParallelEngramEmbedding`` from ``inference/model.py`` (DeepSeek V4.1
7 +checkpoint layout). Engram modules live on the backbone layers listed in
8 +``engram_layer_ids`` only.
9 +
10 +Two pieces of cross-forward state are needed because vLLM streams tokens
11 +chunk-by-chunk while an n-gram at position ``p`` needs the token ids at
12 +``p-1..p-3``:
13 +
14 +- ``token_map``: token id -> compressed vocab id, built once from the
15 + model's tokenizer at init (deterministic; asserted against
16 + ``engram_compressed_vocab_size``).
17 +- ``hash_cache``: one int32 slot per KV slot of the first local layer's
18 + sliding-window cache, holding the compressed id (or DEAD) of the token
19 + last written to that slot. Slots are stable per (request, position) —
20 + the block table pins a position to a physical slot, prefix-cache hits
21 + reuse both the physical blocks and the identical token ids, and
22 + spec-decode rollbacks rewrite the same slots — so lookbacks read back
23 + exactly what the owning request wrote. Lookback depth (3) is far inside
24 + the sliding window (128), so window eviction never frees a block a
25 + live lookback still needs.
26 +
27 + Slots are not part of the KV cache, so KV loaded from another instance
28 + (P/D, offload connectors) leaves them unwritten. The runner therefore
29 + passes ``lookback_token_ids``, the ids just before each request's chunk
30 + start, which take precedence over the slots. The V2 runner reads them
31 + from its device-resident token history and needs no slot cache; the V1
32 + runner's CPU token table holds placeholders for generated tokens under
33 + async scheduling, so it passes prompt positions only and keeps the slot
34 + cache for the rest.
35 +"""
36 +
37 +import weakref
38 +
39 +import numpy as np
40 +import torch
41 +from torch import nn
42 +
43 +from vllm.config import VllmConfig, get_current_vllm_config
44 +from vllm.distributed import (
45 + get_tensor_model_parallel_rank,
46 + get_tensor_model_parallel_world_size,
47 + tensor_model_parallel_all_gather,
48 +)
49 +from vllm.logger import init_logger
50 +from vllm.model_executor.layers.linear import ReplicatedLinear
51 +from vllm.model_executor.layers.quantization import QuantizationConfig
52 +from vllm.model_executor.utils import set_weight_attrs
53 +from vllm.triton_utils import tl, triton
54 +from vllm.utils.platform_utils import is_uva_available
55 +from vllm.utils.torch_utils import get_accelerator_view_from_cpu_tensor
56 +
57 +logger = init_logger(__name__)
58 +
59 +# Cache value for tokens that take no part in an n-gram (image spans).
60 +DEAD_ID = -1
61 +
62 +
63 +def _is_prime(n: int) -> bool:
64 + """Deterministic Miller-Rabin for n < 2**32 (avoids a sympy import)."""
65 + if n < 2:
66 + return False
67 + for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
68 + if n % p == 0:
69 + return n == p
70 + d = n - 1
71 + r = 0
72 + while d % 2 == 0:
73 + d //= 2
74 + r += 1
75 + for a in (2, 7, 61):
76 + x = pow(a, d, n)
77 + if x in (1, n - 1):
78 + continue
79 + for _ in range(r - 1):
80 + x = x * x % n
81 + if x == n - 1:
82 + break
83 + else:
84 + return False
85 + return True
86 +
87 +
88 +def find_next_prime(start: int, seen_primes: set[int]) -> int:
89 + """The smallest prime above `start` that has not been handed out yet."""
90 + candidate = start + 1
91 + while not _is_prime(candidate) or candidate in seen_primes:
92 + candidate += 1
93 + return candidate
94 +
95 +
96 +def build_compressed_token_map(tokenizer) -> tuple[list[int], int]:
97 + """Map every token id onto a smaller id space where tokens that normalize
98 + alike collapse together.
99 +
100 + N-grams are hashed over these compressed ids, so " The", "the" and "THE"
101 + all hash the same way. The compressed size matters beyond bounds checking:
102 + every hash multiplier is derived from it.
103 + """
104 + from tokenizers import Regex, normalizers
105 +
106 + # A private-use char, so a token that is exactly one space survives
107 + # Strip() instead of collapsing to the empty string and merging with
108 + # unrelated tokens.
109 + sentinel = "\ue000"
110 + normalizer = normalizers.Sequence(
111 + [
112 + normalizers.NFKC(),
113 + normalizers.NFD(),
114 + normalizers.StripAccents(),
115 + normalizers.Lowercase(),
116 + normalizers.Replace(Regex(r"[ \t\r\n]+"), " "),
117 + normalizers.Replace(Regex(r"^ $"), sentinel),
118 + normalizers.Strip(),
119 + normalizers.Replace(sentinel, " "),
120 + ]
121 + )
122 +
123 + # The raw Rust tokenizer, matching what training decodes with
124 + # (no clean_up_tokenization_spaces).
125 + backend = tokenizer.backend_tokenizer
126 + key_to_new: dict[str, int] = {}
127 + lookup = [0] * len(tokenizer)
128 + for token_id in range(len(tokenizer)):
129 + text = backend.decode([token_id], skip_special_tokens=False)
130 + if "\ufffd" in text:
131 + # A partial UTF-8 byte token: nothing to normalize, so key it
132 + # by its raw form.
133 + key = backend.id_to_token(token_id)
134 + else:
135 + normalized = normalizer.normalize_str(text)
136 + key = normalized if normalized else text
137 +
138 + new_id = key_to_new.get(key)
139 + if new_id is None:
140 + new_id = len(key_to_new)
141 + key_to_new[key] = new_id
142 + lookup[token_id] = new_id
143 +
144 + return lookup, len(key_to_new)
145 +
146 +
147 +def compute_hash_multipliers(
148 + layer_ids: tuple[int, ...], max_ngram_size: int, compressed_vocab_size: int
149 +) -> torch.Tensor:
150 + """One multiplier per (layer, lookback), from a per-layer RNG so layers
151 + hash differently. Kept odd and bounded so `token_id * multiplier` cannot
152 + overflow int64.
153 + """
154 + max_long = np.iinfo(np.int64).max
155 + multiplier_bound = max(1, (max_long // compressed_vocab_size) // 2)
156 + rows = []
157 + for layer_id in layer_ids:
158 + generator = np.random.default_rng(10007 * layer_id)
159 + values = generator.integers(
160 + low=0,
161 + high=multiplier_bound,
162 + size=(max_ngram_size,),
163 + dtype=np.int64,
164 + )
165 + rows.append(torch.tensor(values * 2 + 1))
166 + return torch.stack(rows)
167 +
168 +
169 +class EngramLayout:
170 + """Bucket layout of the n-gram hash tables.
171 +
172 + A position is hashed as `max_ngram_size - 1` n-grams (2-gram .. max), each
173 + split over `n_heads` heads. Every (n-gram size, head) pair owns its own
174 + prime-sized bucket range in the layer's table; the primes are drawn in
175 + order and never reused, which keeps the ranges disjoint.
176 + """
177 +
178 + def __init__(self, config) -> None:
179 + self.layer_ids: tuple[int, ...] = tuple(config.engram_layer_ids)
180 + self.num_embeddings: tuple[int, ...] = tuple(config.engram_num_embeddings)
181 + self.max_ngram_size: int = config.engram_max_ngram_size
182 + self.n_heads: int = config.engram_n_heads
183 + self.head_dim: int = config.engram_head_dim
184 + self.compressed_vocab_size: int = config.engram_compressed_vocab_size
185 + self.pad_token_id: int = config.engram_pad_token_id
186 + assert len(self.layer_ids) == len(self.num_embeddings)
187 +
188 + primes = []
189 + seen: set[int] = set()
190 + for _ in self.layer_ids:
191 + per_ngram = []
192 + for _ in range(self.max_ngram_size - 1):
193 + sizes, current = [], config.engram_vocab_size - 1
194 + for _ in range(self.n_heads):
195 + current = find_next_prime(current, seen)
196 + seen.add(current)
197 + sizes.append(current)
198 + per_ngram.append(tuple(sizes))
199 + primes.append(tuple(per_ngram))
200 + self.primes: tuple[tuple[tuple[int, ...], ...], ...] = tuple(primes)
201 + self.n_hash_cols = (self.max_ngram_size - 1) * self.n_heads
202 + flat = [[p for per_ngram in layer for p in per_ngram] for layer in primes]
203 + offsets = [np.cumsum([0, *sizes[:-1]]) for sizes in flat]
204 + self.offsets = torch.tensor(np.array(offsets)) # [n_layers, n_hash_cols]
205 +
206 + @classmethod
207 + def from_config(cls, config) -> "EngramLayout | None":
208 + if not getattr(config, "engram_layer_ids", None):
209 + return None
210 + return cls(config)
211 +
212 +
213 +@triton.jit(do_not_specialize=["num_tokens"])
214 +def _write_hash_cache_kernel(
215 + input_ids,
216 + token_map,
217 + dead_mask,
218 + slot_mapping,
219 + cache,
220 + num_tokens,
221 + input_stride,
222 + mask_stride,
223 + slot_stride,
224 + BLOCK_SIZE: tl.constexpr,
225 + dead_id,
226 +):
227 + token_idx = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
228 + slot = tl.load(
229 + slot_mapping + token_idx * slot_stride, token_idx < num_tokens, other=-1
230 + ).to(tl.int64)
231 + valid = (token_idx < num_tokens) & (slot >= 0)
232 + token = tl.load(input_ids + token_idx * input_stride, valid, other=0)
233 + value = tl.load(token_map + token, valid, other=0)
234 + dead = tl.load(dead_mask + token_idx * mask_stride, valid, other=False)
235 + value = tl.where(dead, dead_id, value)
236 + tl.store(cache + slot, value, valid)
237 +
238 +
239 +@triton.jit(
240 + do_not_specialize=[
241 + "num_tokens",
242 + "num_slots",
243 + "num_query_rows",
244 + "num_table_rows",
245 + "max_blocks",
246 + ]
247 +)
248 +def _hash_ids_kernel(
249 + input_ids,
250 + token_map,
251 + dead_mask,
252 + positions,
253 + block_table,
254 + query_start_loc,
255 + multipliers,
256 + primes,
257 + offsets,
258 + cache,
259 + lookback_token_ids,
260 + lookback_dead_mask,
261 + output,
262 + num_tokens,
263 + num_slots,
264 + pad_id,
265 + input_stride,
266 + mask_stride,
267 + position_stride,
268 + table_stride,
269 + table_col_stride,
270 + query_stride,
271 + num_query_rows,
272 + num_table_rows,
273 + max_blocks,
274 + cache_block_size,
275 + MAX_NGRAM: tl.constexpr,
276 + num_heads,
277 + BLOCK_T: tl.constexpr,
278 + BLOCK_H: tl.constexpr,
279 + dead_id,
280 + lookback_depth,
281 + lookback_row_stride,
282 + lookback_col_stride,
283 + lookback_mask_row_stride,
284 + lookback_mask_col_stride,
285 +):
286 + token = tl.program_id(0) * BLOCK_T + tl.arange(0, BLOCK_T)
287 + layer = tl.program_id(1)
288 + num_layers = tl.num_programs(1)
289 + valid = token < num_tokens
290 + # Upper bound in query_start_loc[1:], including repeated padding boundaries.
291 + lo = tl.full((BLOCK_T,), 0, tl.int32)
292 + hi = tl.full((BLOCK_T,), num_query_rows, tl.int32)
293 + while tl.sum((lo < hi).to(tl.int32), 0) > 0:
294 + mid = (lo + hi) // 2
295 + end = tl.load(
296 + query_start_loc + (mid + 1) * query_stride,
297 + lo < hi,
298 + other=0,
299 + )
300 + right = token >= end
301 + active = lo < hi
302 + lo = tl.where(active & right, mid + 1, lo)
303 + hi = tl.where(active & ~right, mid, hi)
304 + req = tl.minimum(lo, num_query_rows - 1).to(tl.int64)
305 + chunk_idx = tl.load(query_start_loc + req * query_stride)
306 + chunk_idx = tl.minimum(chunk_idx, num_tokens - 1).to(tl.int64)
307 + chunk_start = tl.load(positions + chunk_idx * position_stride)
308 + position = tl.load(positions + token * position_stride, valid, other=0).to(tl.int64)
309 + head = tl.arange(0, BLOCK_H)
310 + blocked = tl.full((BLOCK_T,), False, tl.int1)
311 + rolling = tl.full((BLOCK_T,), 0, tl.int64)
312 + for shift in tl.static_range(MAX_NGRAM):
313 + lookback = position - shift
314 + in_batch = lookback >= chunk_start
315 + batch_idx = tl.maximum(token - shift, 0)
316 + batch_token = tl.load(
317 + input_ids + batch_idx * input_stride, valid & in_batch, other=0
318 + )
319 + batch_source = tl.load(token_map + batch_token, valid & in_batch, other=0)
320 + batch_dead = tl.load(
321 + dead_mask + batch_idx * mask_stride, valid & in_batch, other=False
322 + )
323 + batch_source = tl.where(batch_dead, dead_id, batch_source)
324 +
325 + col = chunk_start - 1 - lookback
326 + in_window = valid & ~in_batch & (col >= 0) & (col < lookback_depth)
327 + col = tl.minimum(tl.maximum(col, 0), lookback_depth - 1)
328 + window_token = tl.load(
329 + lookback_token_ids + req * lookback_row_stride + col * lookback_col_stride,
330 + in_window,
331 + other=-1,
332 + )
333 + known = in_window & (window_token >= 0)
334 + window_source = tl.load(token_map + window_token, known, other=0)
335 + window_dead = tl.load(
336 + lookback_dead_mask
337 + + req * lookback_mask_row_stride
338 + + col * lookback_mask_col_stride,
339 + known,
340 + other=False,
341 + )
342 + window_source = tl.where(window_dead, dead_id, window_source)
343 +
344 + if cache is not None:
345 + clamped = tl.minimum(
346 + tl.maximum(lookback, 0), max_blocks * cache_block_size - 1
347 + )
348 + block_row = tl.minimum(req, num_table_rows - 1)
349 + needs_cache = valid & ~in_batch & ~known
350 + block = tl.load(
351 + block_table
352 + + block_row * table_stride
353 + + (clamped // cache_block_size) * table_col_stride,
354 + needs_cache,
355 + other=0,
356 + ).to(tl.int64)
357 + slot = tl.minimum(
358 + tl.maximum(block * cache_block_size + clamped % cache_block_size, 0),
359 + num_slots - 1,
360 + )
361 + fallback = tl.load(cache + slot, needs_cache, other=0)
362 + else:
363 + fallback = tl.full((BLOCK_T,), pad_id, tl.int32)
364 + source = tl.where(
365 + in_batch, batch_source, tl.where(known, window_source, fallback)
366 + ).to(tl.int64)
367 + blocked |= (lookback < 0) | (source == dead_id)
368 + value = tl.where(blocked, pad_id, source)
369 + multiplier = tl.load(multipliers + layer * MAX_NGRAM + shift)
370 + rolling ^= value * multiplier
371 + if shift > 0:
372 + col = (shift - 1) * num_heads + head
373 + param_offset = layer * (MAX_NGRAM - 1) * num_heads + col
374 + prime = tl.load(primes + param_offset, head < num_heads, other=1)
375 + offset = tl.load(offsets + param_offset, head < num_heads, other=0)
376 + hashed = rolling[:, None] % prime[None, :] + offset[None, :]
377 + out_offset = (token.to(tl.int64) * num_layers + layer)[:, None] * (
378 + (MAX_NGRAM - 1) * num_heads
379 + ) + col[None, :]
380 + tl.store(output + out_offset, hashed, valid[:, None] & (head < num_heads))
381 +
382 +
383 +class NgramHashState(nn.Module):
384 + """Maps each position to the hash ids of the n-grams ending there.
385 +
386 + Stateless on the V2 runner, which supplies every lookback token id. On
387 + the V1 runner it also keeps `hash_cache`, the slot-keyed rolling store
388 + of compressed ids (see module docstring), for generated tokens.
389 + """
390 +
391 + def __init__(
392 + self,
393 + vllm_config: VllmConfig,
394 + layout: EngramLayout,
395 + swa_cache_module: nn.Module,
396 + ) -> None:
397 + super().__init__()
398 + self.layout = layout
399 + self.swa_cache_module = swa_cache_module
400 + self.block_size: int = swa_cache_module.block_size
401 + self.lookback_depth: int = layout.max_ngram_size - 1
402 + self.use_slot_cache: bool = not vllm_config.use_v2_model_runner
403 + self._cache: torch.Tensor | None = None
404 + self._kv_cache_ref: weakref.ReferenceType[torch.Tensor] | None = None
405 +
406 + model_config = vllm_config.model_config
407 + from transformers import AutoTokenizer
408 +
409 + tokenizer = AutoTokenizer.from_pretrained(
410 + model_config.tokenizer,
411 + trust_remote_code=model_config.trust_remote_code,
412 + revision=model_config.revision,
413 + )
414 + token_map, vocab_size = build_compressed_token_map(tokenizer)
415 + if vocab_size != layout.compressed_vocab_size:
416 + raise ValueError(
417 + f"Compressed vocab size mismatch: built {vocab_size} from the "
418 + f"tokenizer, config expects {layout.compressed_vocab_size}; "
419 + "every hash multiplier derives from it, so the engram tables "
420 + "would be silently rehashed."
421 + )
422 + self.pad_id = token_map[layout.pad_token_id]
423 + multipliers = compute_hash_multipliers(
424 + layout.layer_ids, layout.max_ngram_size, vocab_size
425 + )
426 + self.register_buffer(
427 + "token_map", torch.tensor(token_map, dtype=torch.int32), persistent=False
428 + )
429 + self.register_buffer("primes", torch.tensor(layout.primes), persistent=False)
430 + self.register_buffer("offsets", layout.offsets, persistent=False)
431 + self.register_buffer("multipliers", multipliers, persistent=False)
432 + logger.info(
433 + "Built engram token map (%d -> %d ids) for layers %s",
434 + len(token_map),
435 + vocab_size,
436 + layout.layer_ids,
437 + )
438 +
439 + def ensure_cache(self) -> bool:
440 + """Lazily size the slot-keyed cache from the bound SWA KV cache.
441 +
442 + Returns False while the KV cache is unbound (profile run); the caller
443 + skips engram hashing then. Without the slot cache only that check
444 + remains.
445 + """
446 + kv_cache = self.swa_cache_module.kv_cache
447 + if kv_cache.numel() == 0:
448 + self._cache = None
449 + self._kv_cache_ref = None
450 + return False
451 + if not self.use_slot_cache:
452 + return True
453 + if self._kv_cache_ref is not None and self._kv_cache_ref() is kv_cache:
454 + return True
455 + # Graph memory profiling binds a temporary, smaller KV cache first.
456 + # Rebinding must discard its hash history without retaining KV storage.
457 + self._cache = torch.zeros(
458 + kv_cache.shape[0] * self.block_size,
459 + dtype=torch.int32,
460 + device=kv_cache.device,
461 + )
462 + self._kv_cache_ref = weakref.ref(kv_cache)
463 + return True
464 +
465 + def forward(
466 + self,
467 + input_ids: torch.Tensor,
468 + positions: torch.Tensor,
469 + query_start_loc: torch.Tensor,
470 + dead_mask: torch.Tensor,
471 + lookback_token_ids: torch.Tensor,
472 + lookback_dead_mask: torch.Tensor,
473 + slot_mapping: torch.Tensor | None,
474 + block_table: torch.Tensor | None,
475 + ) -> torch.Tensor:
476 + """Compute [tokens, layers, hash columns] int32 n-gram hashes.
477 +
478 + History comes from the current chunk, then the runner's lookback
479 + window, then the optional V1 slot cache. V2 needs only one launch.
480 + """
481 + cache = self._cache if self.use_slot_cache else None
482 + num_tokens = input_ids.shape[0]
483 + num_layers, max_ngram = self.multipliers.shape
484 + num_heads = self.primes.shape[-1]
485 + output = input_ids.new_empty(
486 + (num_tokens, num_layers, (max_ngram - 1) * num_heads), dtype=torch.int32
487 + )
488 + if num_tokens == 0:
489 + return output
490 + if self.use_slot_cache:
491 + assert cache is not None and slot_mapping is not None
492 + assert block_table is not None
493 + # Finish writes before other thread blocks read fallback history.
494 + _write_hash_cache_kernel[(triton.cdiv(num_tokens, 256),)](
495 + input_ids,
496 + self.token_map,
497 + dead_mask,
498 + slot_mapping,
499 + cache,
500 + num_tokens,
501 + input_ids.stride(0),
502 + dead_mask.stride(0),
503 + slot_mapping.stride(0),
504 + 256,
505 + DEAD_ID,
506 + )
507 + _hash_ids_kernel[(triton.cdiv(num_tokens, 32), num_layers)](
508 + input_ids,
509 + self.token_map,
510 + dead_mask,
511 + positions,
512 + block_table,
513 + query_start_loc,
514 + self.multipliers,
515 + self.primes,
516 + self.offsets,
517 + cache,
518 + lookback_token_ids,
519 + lookback_dead_mask,
520 + output,
521 + num_tokens,
522 + cache.shape[0] if cache is not None else 0,
523 + self.pad_id,
524 + input_stride=input_ids.stride(0),
525 + mask_stride=dead_mask.stride(0),
526 + position_stride=positions.stride(0),
527 + table_stride=block_table.stride(0) if block_table is not None else 0,
528 + table_col_stride=block_table.stride(1) if block_table is not None else 0,
529 + query_stride=query_start_loc.stride(0),
530 + num_query_rows=query_start_loc.numel() - 1,
531 + num_table_rows=block_table.shape[0] if block_table is not None else 0,
532 + max_blocks=block_table.shape[1] if block_table is not None else 0,
533 + cache_block_size=self.block_size,
534 + MAX_NGRAM=max_ngram,
535 + num_heads=num_heads,
536 + BLOCK_T=32,
537 + BLOCK_H=triton.next_power_of_2(num_heads),
538 + dead_id=DEAD_ID,
539 + lookback_depth=lookback_token_ids.shape[1],
540 + lookback_row_stride=lookback_token_ids.stride(0),
541 + lookback_col_stride=lookback_token_ids.stride(1),
542 + lookback_mask_row_stride=lookback_dead_mask.stride(0),
543 + lookback_mask_col_stride=lookback_dead_mask.stride(1),
544 + num_warps=4,
545 + )
546 + return output
547 +
548 +
549 +def _engram_head_shard_weight_loader(
550 + param: torch.nn.Parameter, loaded_weight: torch.Tensor
551 +) -> None:
552 + """Load this rank's complete head buckets. ue8m0 scales arrive as
553 + float8_e8m0fnu; keep the raw bytes (the param stores uint8)."""
554 + part_rows = param.shape[0]
555 + if loaded_weight.dtype == torch.float8_e8m0fnu:
556 + loaded_weight = loaded_weight.view(torch.uint8)
557 + shard = loaded_weight.narrow(0, param.engram_vocab_start, part_rows)
558 + assert shard.shape == param.shape, (
559 + f"engram shard {tuple(shard.shape)} does not fit param {tuple(param.shape)}"
560 + )
561 + param.data.copy_(shard)
562 +
563 +
564 +@triton.jit
565 +def _engram_lookup_kernel(
566 + weight,
567 + scales,
568 + ids,
569 + out,
570 + vocab_start,
571 + vocab_end,
572 + num_rows,
573 + ids_stride_t,
574 + ids_stride_h,
575 + HEAD_START: tl.constexpr,
576 + LOCAL_HEADS: tl.constexpr,
577 + TOTAL_HEADS: tl.constexpr,
578 + DIM: tl.constexpr,
579 + QUANT_BLOCK: tl.constexpr,
580 + BLOCK_R: tl.constexpr,
581 + GRID: tl.constexpr,
582 +):
583 + """Gather fp8 rows, apply their ue8m0 block scales, write bf16.
584 +
585 + Only this rank's heads are read; padded heads write zeros for all-gather.
586 + `weight`/`scales` may address pinned host memory through UVA.
587 + """
588 + cols = tl.arange(0, DIM)
589 + scale_cols = cols // QUANT_BLOCK
590 + for base in tl.range(tl.program_id(0) * BLOCK_R, num_rows, GRID * BLOCK_R):
591 + rows = base + tl.arange(0, BLOCK_R)
592 + valid = rows < num_rows
593 + head = HEAD_START + rows % LOCAL_HEADS
594 + token = (rows // LOCAL_HEADS).to(tl.int64)
595 + index = tl.load(
596 + ids + token * ids_stride_t + head * ids_stride_h,
597 + mask=valid & (head < TOTAL_HEADS),
598 + other=-1,
599 + ).to(tl.int64)
600 + owned = valid & (head < TOTAL_HEADS)
601 + owned &= (index >= vocab_start) & (index < vocab_end)
602 + local = tl.where(owned, index - vocab_start, 0)
603 + values = tl.load(
604 + weight + local[:, None] * DIM + cols[None, :],
605 + mask=owned[:, None],
606 + other=0.0,
607 + )
608 + scale = tl.load(
609 + scales + local[:, None] * (DIM // QUANT_BLOCK) + scale_cols[None, :],
610 + mask=owned[:, None],
611 + other=0,
612 + )
613 + # ue8m0 is a power of two, so its byte *is* the fp32 exponent field.
614 + scale = (scale.to(tl.int32) << 23).to(tl.float32, bitcast=True)
615 + tl.store(
616 + out + rows[:, None] * DIM + cols[None, :],
617 + (values.to(tl.float32) * scale).to(tl.bfloat16),
618 + mask=valid[:, None],
619 + )
620 +
621 +
622 +class ParallelEngramEmbedding(nn.Module):
623 + """The n-gram hash table, sharded by complete hash heads over TP ranks.
624 + Rows stay fp8 and are dequantized with ue8m0 per-32 scales on lookup.
625 +
626 + With `cpu_offload` the shard lives in pinned host memory and is read over
627 + UVA instead of HBM; the TP sharding is unchanged either way.
628 + """
629 +
630 + def __init__(
631 + self,
632 + num_embeddings: int,
633 + dim: int,
634 + head_sizes: tuple[int, ...],
635 + block_size: int = 32,
636 + cpu_offload: bool = False,
637 + ):
638 + super().__init__()
639 + tp_size = get_tensor_model_parallel_world_size()
640 + tp_rank = get_tensor_model_parallel_rank()
641 + assert head_sizes and all(size > 0 for size in head_sizes)
642 + assert sum(head_sizes) <= num_embeddings
643 + if cpu_offload and not is_uva_available():
644 + raise RuntimeError("Engram CPU offload requires UVA support")
645 + self.num_embeddings = num_embeddings
646 + self.dim = dim
647 + self.block_size = block_size
648 + self.n_hash_cols = len(head_sizes)
649 + self.part_n_hash_cols = triton.cdiv(self.n_hash_cols, tp_size)
650 + self.head_start = tp_rank * self.part_n_hash_cols
651 + head_end = self.head_start + self.part_n_hash_cols
652 + self.vocab_start_idx = sum(head_sizes[: self.head_start])
653 + self.vocab_end_idx = sum(head_sizes[:head_end])
654 + self.part_num_embeddings = self.vocab_end_idx - self.vocab_start_idx
655 + self.tp_size = tp_size
656 + self.cpu_offload = cpu_offload
657 + self._views: tuple[torch.Tensor, torch.Tensor] | None = None
658 + self._view_src: tuple[int, int] | None = None
659 + self._num_sms = torch.cuda.get_device_properties(
660 + torch.accelerator.current_device_index()
661 + ).multi_processor_count
662 +
663 + # Explicit device: model init runs under a `torch.device("cuda")`
664 + # context, which would otherwise put the shard in HBM.
665 + kwargs = {"device": "cpu", "pin_memory": True} if cpu_offload else {}
666 + self.weight = nn.Parameter(
667 + torch.empty(
668 + self.part_num_embeddings, dim, dtype=torch.float8_e4m3fn, **kwargs
669 + ),
670 + requires_grad=False,
671 + )
672 + self.weight_scale_inv = nn.Parameter(
673 + torch.empty(
674 + self.part_num_embeddings,
675 + dim // block_size,
676 + dtype=torch.uint8,
677 + **kwargs,
678 + ),
679 + requires_grad=False,
680 + )
681 + for param in (self.weight, self.weight_scale_inv):
682 + set_weight_attrs(
683 + param,
684 + {
685 + "weight_loader": _engram_head_shard_weight_loader,
686 + "engram_vocab_start": self.vocab_start_idx,
687 + },
688 + )
689 + if cpu_offload:
690 + logger.info(
691 + "Engram table offloaded to pinned host memory: %d rows x %d, "
692 + "%.2f GiB per rank",
693 + self.part_num_embeddings,
694 + dim,
695 + self.part_num_embeddings * (dim + dim // block_size) / 1024**3,
696 + )
697 +
698 + def _storage(self) -> tuple[torch.Tensor, torch.Tensor]:
699 + """Parameters when resident, else cached UVA views of the pinned shard.
700 +
701 + Rebuilt if anything swaps `.data`, so a stale device pointer cannot
702 + survive silently.
703 + """
704 + if not self.cpu_offload:
705 + return self.weight.data, self.weight_scale_inv.data
706 + src = (self.weight.data_ptr(), self.weight_scale_inv.data_ptr())
707 + if self._view_src != src:
708 + self._views = (
709 + get_accelerator_view_from_cpu_tensor(self.weight.data),
710 + get_accelerator_view_from_cpu_tensor(self.weight_scale_inv.data),
711 + )
712 + self._view_src = src
713 + assert self._views is not None
714 + return self._views
715 +
716 + def lookup(
717 + self, indices: torch.Tensor, out: torch.Tensor, background: bool = False
718 + ) -> None:
719 + """Look up local heads of [T, heads] into [T, local_heads, dim] bf16.
720 +
721 + `background` limits the grid to leave SMs for concurrent work.
722 + """
723 + rows = indices.shape[0] * self.part_n_hash_cols
724 + if not rows:
725 + return
726 + weight, scales = self._storage()
727 + # The table dwarfs TLB reach, so a persistent grid near the SM count
728 + # beats one program per row; halve it to leave SMs for the main stream.
729 + tiles = triton.cdiv(rows, 16)
730 + grid = min(tiles, self._num_sms // 2 if background else self._num_sms)
731 + _engram_lookup_kernel[(grid,)](
732 + weight,
733 + scales,
734 + indices,
735 + out,
736 + self.vocab_start_idx,
737 + self.vocab_end_idx,
738 + rows,
739 + indices.stride(0),
740 + indices.stride(1),
741 + HEAD_START=self.head_start,
742 + LOCAL_HEADS=self.part_n_hash_cols,
743 + TOTAL_HEADS=self.n_hash_cols,
744 + DIM=self.dim,
745 + QUANT_BLOCK=self.block_size,
746 + BLOCK_R=16,
747 + GRID=grid,
748 + )
749 +
750 + def forward(self, indices: torch.Tensor) -> torch.Tensor:
751 + """indices: [num_tokens, n_hash_cols] -> [num_tokens, n_hash_cols, dim]
752 + bf16, gathered from all TP shards."""
753 + out = torch.empty(
754 + (indices.shape[0], self.part_n_hash_cols, self.dim),
755 + dtype=torch.bfloat16,
756 + device=indices.device,
757 + )
758 + self.lookup(indices, out)
759 + if self.tp_size > 1:
760 + out = tensor_model_parallel_all_gather(out, dim=1)
761 + out = out[:, : self.n_hash_cols]
762 + return out
763 +
764 +
765 +@triton.jit(do_not_specialize=["num_kv_tokens"])
766 +def _fused_engram_post_wkv_kernel(
767 + hidden_states,
768 + kv,
769 + q_weight,
770 + k_weight,
771 + token_mask,
772 + output,
773 + num_kv_tokens,
774 + hidden_stride_t,
775 + hidden_stride_h,
776 + hidden_stride_d,
777 + kv_stride_t,
778 + kv_stride_d,
779 + q_stride_h,
780 + q_stride_d,
781 + k_stride_h,
782 + k_stride_d,
783 + mask_stride,
784 + output_stride_t,
785 + output_stride_h,
786 + output_stride_d,
787 + eps,
788 + clamp_value,
789 + DIM: tl.constexpr,
790 + HC_MULT: tl.constexpr,
791 + BLOCK_SIZE: tl.constexpr,
792 + HAS_MASK: tl.constexpr,
793 +):
794 + program_idx = tl.program_id(0)
795 + token_idx = program_idx // HC_MULT
796 + hc_idx = program_idx % HC_MULT
797 + token_idx = token_idx.to(tl.int64)
798 + source_idx = token_idx
799 + source_valid = source_idx < num_kv_tokens
800 +
801 + dim_offsets = tl.arange(0, BLOCK_SIZE)
802 + dim_valid = dim_offsets < DIM
803 + hidden = tl.load(
804 + hidden_states
805 + + token_idx * hidden_stride_t
806 + + hc_idx * hidden_stride_h
807 + + dim_offsets * hidden_stride_d,
808 + mask=dim_valid,
809 + other=0.0,
810 + ).to(tl.float32)
811 + key = tl.load(
812 + kv + source_idx * kv_stride_t + (hc_idx * DIM + dim_offsets) * kv_stride_d,
813 + mask=source_valid & dim_valid,
814 + other=0.0,
815 + ).to(tl.float32)
816 + q = tl.load(
817 + q_weight + hc_idx * q_stride_h + dim_offsets * q_stride_d,
818 + mask=dim_valid,
819 + other=0.0,
820 + ).to(tl.float32)
821 + k = tl.load(
822 + k_weight + hc_idx * k_stride_h + dim_offsets * k_stride_d,
823 + mask=dim_valid,
824 + other=0.0,
825 + ).to(tl.float32)
826 +
827 + hidden_rms = tl.rsqrt(tl.sum(hidden * hidden, axis=0) / DIM + eps)
828 + key_rms = tl.rsqrt(tl.sum(key * key, axis=0) / DIM + eps)
829 + dot = tl.sum(hidden * q * k * key, axis=0)
830 + dot *= hidden_rms * key_rms * tl.rsqrt(DIM * 1.0)
831 + gate_input = tl.sqrt(tl.maximum(tl.abs(dot), clamp_value))
832 + gate_input = tl.where(dot < 0.0, -gate_input, gate_input)
833 + gate = tl.sigmoid(gate_input)
834 + if HAS_MASK:
835 + active = tl.load(
836 + token_mask + source_idx * mask_stride,
837 + mask=source_valid,
838 + other=0,
839 + )
840 + gate = tl.where(active, gate, 0.0)
841 +
842 + value = tl.load(
843 + kv + source_idx * kv_stride_t + (HC_MULT * DIM + dim_offsets) * kv_stride_d,
844 + mask=source_valid & dim_valid,
845 + other=0.0,
846 + ).to(tl.float32)
847 + tl.store(
848 + output
849 + + token_idx * output_stride_t
850 + + hc_idx * output_stride_h
851 + + dim_offsets * output_stride_d,
852 + hidden + gate * value,
853 + mask=dim_valid,
854 + )
855 +
856 +
857 +@triton.jit(do_not_specialize=["num_tokens", "token_start", "num_elements"])
858 +def _engram_sp_rows_kernel(
859 + gathered,
860 + output,
861 + num_tokens,
862 + token_start,
863 + num_elements,
864 + LOCAL_WIDTH: tl.constexpr,
865 + WIDTH: tl.constexpr,
866 + BLOCK_SIZE: tl.constexpr,
867 +):
868 + offsets = tl.program_id(0).to(tl.int64) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
869 + tokens = token_start + offsets // WIDTH
870 + cols = offsets % WIDTH
871 + source = (cols // LOCAL_WIDTH * num_tokens + tokens) * LOCAL_WIDTH
872 + source += cols % LOCAL_WIDTH
873 + values = tl.load(
874 + gathered + source, (offsets < num_elements) & (tokens < num_tokens), other=0
875 + )
876 + tl.store(output + offsets, values, offsets < num_elements)
877 +
878 +
879 +class Engram(nn.Module):
880 + """Writes an n-gram lookup into the residual stream, gated by how well it
881 + matches that stream.
882 +
883 + The hash ids fetch `n_hash_cols` rows; `wkv` turns them into one key per
884 + hc copy plus a shared value. The gate is a normalized dot product of the
885 + stream against the key, signed-sqrt'ed before the sigmoid (matching the
886 + training kernel).
887 + """
888 +
889 + def __init__(
890 + self,
891 + config,
892 + quant_config: QuantizationConfig | None,
893 + layout: EngramLayout,
894 + layer_hash_index: int,
895 + use_sequence_parallel: bool,
896 + prefix: str,
897 + ) -> None:
898 + super().__init__()
899 + self.layer_hash_index = layer_hash_index
900 + self.dim = config.hidden_size
901 + self.hc_mult = config.hc_mult
902 + self.eps = config.rms_norm_eps
903 + self.clamp_value = 1e-6
904 + self.use_sequence_parallel = use_sequence_parallel
905 +
906 + # Named ``embed_tokens`` so the checkpoint's ``engram.embed.weight``
907 + # survives the mapper's ``embed.weight`` -> ``embed_tokens.weight``
908 + # suffix rule.
909 + engram_config = get_current_vllm_config().engram_config
910 + self.embed_tokens = ParallelEngramEmbedding(
911 + layout.num_embeddings[layer_hash_index],
912 + layout.head_dim,
913 + tuple(size for order in layout.primes[layer_hash_index] for size in order),
914 + cpu_offload=engram_config.cpu_offload if engram_config else True,
915 + )
916 + n_hash_cols = (layout.max_ngram_size - 1) * layout.n_heads
917 + self.wkv = ReplicatedLinear(
918 + n_hash_cols * layout.head_dim,
919 + self.dim * (self.hc_mult + 1),
920 + bias=False,
921 + quant_config=quant_config,
922 + return_bias=False,
923 + prefix=f"{prefix}.wkv",
924 + )
925 + self.q_weight = nn.Parameter(
926 + torch.empty(self.hc_mult, self.dim, dtype=torch.bfloat16),
927 + requires_grad=False,
928 + )
929 + self.k_weight = nn.Parameter(
930 + torch.empty(self.hc_mult, self.dim, dtype=torch.bfloat16),
931 + requires_grad=False,
932 + )
933 +
934 + max_tokens = get_current_vllm_config().scheduler_config.max_num_batched_tokens
935 + # Keep lookup results alive across breakable graph segments.
936 + self.staged_rows = torch.empty(
937 + max_tokens,
938 + self.embed_tokens.part_n_hash_cols,
939 + layout.head_dim,
940 + dtype=torch.bfloat16,
941 + )
942 +
943 + def prepare_embeddings(self, hash_ids: torch.Tensor) -> None:
944 + """Gather this layer's rows on the main stream before decoder layers."""
945 + self.embed_tokens.lookup(hash_ids, self.staged_rows[: hash_ids.shape[0]])
946 +
947 + def embed(self, hash_ids: torch.Tensor) -> torch.Tensor:
948 + """Gather heads, returning only local tokens when SP is enabled."""
949 + rows = self.staged_rows[: hash_ids.shape[0]]
950 + if self.embed_tokens.tp_size == 1:
951 + return rows
952 + if self.use_sequence_parallel:
953 + tp_size = self.embed_tokens.tp_size
954 + num_tokens, local_heads, dim = rows.shape
955 + gathered = tensor_model_parallel_all_gather(rows, dim=0)
956 + chunk = (num_tokens + tp_size - 1) // tp_size
957 + rows = rows.new_empty((chunk, self.embed_tokens.n_hash_cols, dim))
958 + _engram_sp_rows_kernel[(triton.cdiv(rows.numel(), 1024),)](
959 + gathered,
960 + rows,
961 + num_tokens,
962 + get_tensor_model_parallel_rank() * chunk,
963 + rows.numel(),
964 + local_heads * dim,
965 + self.embed_tokens.n_hash_cols * dim,
966 + BLOCK_SIZE=1024,
967 + )
968 + return rows
969 + rows = tensor_model_parallel_all_gather(rows, dim=1)
970 + return rows[:, : self.embed_tokens.n_hash_cols]
971 +
972 + def forward(
973 + self,
974 + hidden_states: torch.Tensor,
975 + hash_ids: torch.Tensor,
976 + token_mask: torch.Tensor | None = None,
977 + ) -> torch.Tensor:
978 + """hidden_states: [T, hc_mult, dim]; hash_ids: [T, n_hash_cols] (all
979 + tokens, pre sequence-parallel shard); token_mask: [T], False shuts
980 + the gate so those positions pass through untouched."""
981 + kv = self.wkv(self.embed(hash_ids).flatten(-2))
982 + num_kv_tokens = hash_ids.shape[0]
983 + assert token_mask is None or token_mask.shape == (num_kv_tokens,)
984 + if self.use_sequence_parallel:
985 + tp_size = get_tensor_model_parallel_world_size()
986 + tp_rank = get_tensor_model_parallel_rank()
987 + shard_size = (num_kv_tokens + tp_size - 1) // tp_size
988 + assert hidden_states.shape[0] == shard_size
989 + start = min(tp_rank * shard_size, num_kv_tokens)
990 + num_kv_tokens = min(shard_size, num_kv_tokens - start)
991 + if token_mask is not None:
992 + token_mask = token_mask[start : start + num_kv_tokens]
993 +
994 + num_tokens, hc_mult, dim = hidden_states.shape
995 + assert hc_mult == self.hc_mult and dim == self.dim
996 + assert kv.ndim == 2 and kv.shape[1] == (hc_mult + 1) * dim
997 + output = torch.empty_like(hidden_states)
998 + if num_tokens == 0:
999 + return output
1000 +
1001 + block_size = triton.next_power_of_2(dim)
1002 + num_warps = 8 if block_size >= 2048 else 4
1003 + mask = token_mask if token_mask is not None else hidden_states
1004 + _fused_engram_post_wkv_kernel[(num_tokens * hc_mult,)](
1005 + hidden_states,
1006 + kv,
1007 + self.q_weight,
1008 + self.k_weight,
1009 + mask,
1010 + output,
1011 + num_kv_tokens,
1012 + hidden_states.stride(0),
1013 + hidden_states.stride(1),
1014 + hidden_states.stride(2),
1015 + kv.stride(0),
1016 + kv.stride(1),
1017 + self.q_weight.stride(0),
1018 + self.q_weight.stride(1),
1019 + self.k_weight.stride(0),
1020 + self.k_weight.stride(1),
1021 + token_mask.stride(0) if token_mask is not None else 0,
1022 + output.stride(0),
1023 + output.stride(1),
1024 + output.stride(2),
1025 + self.eps,
1026 + self.clamp_value,
1027 + DIM=dim,
1028 + HC_MULT=hc_mult,
1029 + BLOCK_SIZE=block_size,
1030 + HAS_MASK=token_mask is not None,
1031 + num_warps=num_warps,
1032 + )
1033 + return output
vllm/models/deepseek_v4_1/common/ops/__init__.py new
+42
@@ -0,0 +1,42 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepSeek V4.1 fused ops.
4 +
5 +Single bridging point for the kernels V4.1 shares verbatim with V4: the
6 +re-exports below are the only place this package reaches into
7 +``vllm.models.deepseek_v4``, so submodules import them from ``.`` instead of
8 +naming the V4 path themselves. Keep these imports above the ``.``-relative
9 +ones — ``indexer_k_store`` reads them off this partially-initialized module.
10 +"""
11 +
12 +from vllm.models.deepseek_v4.common.ops.fused_indexer_q import (
13 + MXFP4_BLOCK_SIZE,
14 + fused_indexer_q_rope_quant,
15 +)
16 +from vllm.models.deepseek_v4.common.ops.fused_indexer_q import (
17 + _fp32x2_to_fp4x2 as _fp32x2_to_fp4x2, # re-export for .indexer_k_store
18 +)
19 +from vllm.models.deepseek_v4.common.ops.fused_inv_rope_fp8_quant import (
20 + fused_inv_rope_fp8_quant,
21 +)
22 +
23 +from .cache_utils import (
24 + build_flashinfer_mixed_sparse_indices,
25 + combine_topk_swa_indices,
26 + compute_global_topk_indices_and_lens,
27 + dequantize_and_gather_k_cache,
28 + quantize_and_insert_k_cache,
29 +)
30 +from .indexer_k_store import indexer_k_norm_rope_store
31 +
32 +__all__ = [
33 + "MXFP4_BLOCK_SIZE",
34 + "build_flashinfer_mixed_sparse_indices",
35 + "combine_topk_swa_indices",
36 + "compute_global_topk_indices_and_lens",
37 + "dequantize_and_gather_k_cache",
38 + "fused_indexer_q_rope_quant",
39 + "fused_inv_rope_fp8_quant",
40 + "indexer_k_norm_rope_store",
41 + "quantize_and_insert_k_cache",
42 +]
vllm/models/deepseek_v4_1/common/ops/cache_utils.py new
+1254
@@ -0,0 +1,1254 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +Triton kernels for DeepseekV4 paged K-cache management and sparse-attention index
5 +preparation.
6 +
7 +- quantize_and_insert_k_cache: quantize bf16 K to UE8M0 FP8 and insert into
8 + the paged cache.
9 +- dequantize_and_gather_k_cache: gather and dequantize FP8 K from the paged
10 + cache for sparse/SWA prefill.
11 +- compute_global_topk_indices_and_lens: map local topk indices to global KV
12 + cache slots and count valid entries.
13 +- combine_topk_swa_indices: concatenate topk compressed indices with SWA
14 + window indices for sparse prefill.
15 +"""
16 +
17 +from dataclasses import dataclass
18 +from typing import Any
19 +
20 +import torch
21 +
22 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
23 + get_fp8_min_max,
24 +)
25 +from vllm.model_executor.warmup.jit_warmup import (
26 + VllmJitKernel,
27 + _WarmupInputRows,
28 + zip_inputs,
29 +)
30 +from vllm.model_executor.warmup.jit_warmup_triton_helper import (
31 + TritonPointerInputVariant,
32 + TritonWarmupTensor,
33 +)
34 +from vllm.platforms import current_platform
35 +from vllm.triton_utils import tl, triton
36 +from vllm.utils.import_utils import has_cutedsl
37 +from vllm.utils.math_utils import next_power_of_2
38 +
39 +
40 +@triton.jit
41 +def quantize_and_insert_k_kernel(
42 + # Input tensors
43 + k_ptr, # [num_tokens, 512] bf16
44 + slot_mapping_ptr, # [num_tokens] int64
45 + # Output tensor
46 + k_cache_ptr, # [num_blocks, block_bytes] as uint8 (flattened view)
47 + # Dimensions
48 + num_tokens,
49 + input_dim: tl.constexpr, # 512
50 + fp8_dim: tl.constexpr, # 448
51 + bf16_dim: tl.constexpr, # 64
52 + scale_dim: tl.constexpr, # 8
53 + quant_block: tl.constexpr, # 64 (quantization block size)
54 + cache_block_size: tl.constexpr, # 64 (paged cache block size)
55 + token_data_size: tl.constexpr, # 576 bytes per token data
56 + block_stride: tl.constexpr, # total bytes per block (padded)
57 + fp8_max: tl.constexpr,
58 + n_quant_blocks: tl.constexpr, # 8 (7 real + 1 padding)
59 + use_fnuz: tl.constexpr = False,
60 +):
61 + """
62 + Quantize K tensor and insert into paged K cache.
63 +
64 + K Cache block layout (block_size=64 tokens):
65 + - [0, 64*576): Token data, each token has 448 fp8 + 128 bf16
66 + - [64*576, 64*576 + 64*8): Scales, each token has 8 uint8 scales
67 + - [64*576 + 64*8, block_stride): Padding
68 +
69 + One program per token.
70 +
71 + ``use_fnuz=True`` selects FNUZ (``tl.float8e4b8``); default OCP
72 + (``tl.float8e4nv``) matches every production caller.
73 + """
74 + pid = tl.program_id(0)
75 +
76 + if pid >= num_tokens:
77 + return
78 +
79 + # Get slot mapping
80 + slot_idx = tl.load(slot_mapping_ptr + pid)
81 + if slot_idx == -1:
82 + return
83 +
84 + block_idx = slot_idx // cache_block_size
85 + pos_in_block = slot_idx % cache_block_size
86 +
87 + # Input pointer for this token
88 + input_row_ptr = k_ptr + pid * input_dim
89 +
90 + # int64: block_idx * block_stride can exceed 2^31 with many KV-cache blocks
91 + # (e.g. >= 57K at block_stride ~37K). Matches gather path below.
92 + cache_block_ptr = k_cache_ptr + block_idx.to(tl.int64) * block_stride
93 +
94 + # Token data pointer: token data is stored contiguously at start of block
95 + # Each token's data is at offset pos_in_block * token_data_size
96 + token_data_ptr = cache_block_ptr + pos_in_block * token_data_size
97 +
98 + # Scale pointer: scales are stored after ALL token data in the block
99 + # Scale for this token is at offset (64 * 576) + pos_in_block * 8
100 + token_scale_ptr = (
101 + cache_block_ptr + cache_block_size * token_data_size + pos_in_block * scale_dim
102 + )
103 +
104 + # Token data layout: [0:448] fp8, [448:576] bf16
105 + token_fp8_ptr = token_data_ptr
106 + token_bf16_ptr = token_data_ptr + fp8_dim
107 +
108 + # ========== Quantize and store FP8 portion (first 448 elements) ==========
109 + # Using UE8M0 quantization strategy (scale is power of 2, stored as uint8 exponent)
110 + for qblock_idx in tl.static_range(n_quant_blocks):
111 + qblock_start = qblock_idx * quant_block
112 +
113 + if qblock_start < fp8_dim:
114 + offsets = qblock_start + tl.arange(0, quant_block)
115 + mask = offsets < fp8_dim
116 +
117 + # Load bf16 input
118 + x = tl.load(input_row_ptr + offsets, mask=mask, other=0.0)
119 +
120 + # Compute absmax scale (same as CUDA kernel)
121 + abs_x = tl.abs(x)
122 + block_max = tl.max(abs_x, axis=0)
123 + block_max = tl.maximum(block_max, 1e-4) # Match CUDA: fmaxf(amax, 1e-4)
124 +
125 + # UE8M0: Round scale UP to next power of 2
126 + # scale = 2^ceil(log2(block_max / fp8_max))
127 + raw_scale = block_max / fp8_max
128 + log_scale = tl.log2(raw_scale)
129 + exponent = tl.ceil(log_scale) # Round UP to next integer exponent
130 + scale = tl.exp2(exponent) # scale = 2^exponent (power of 2)
131 +
132 + # Quantize to fp8: fp8_value = bf16_value / scale
133 + x_scaled = x / scale
134 + x_clamped = tl.clamp(x_scaled, -fp8_max, fp8_max)
135 +
136 + # Convert to fp8 (FNUZ on gfx942, OCP elsewhere), then bitcast to uint8.
137 + if use_fnuz:
138 + x_fp8 = x_clamped.to(tl.float8e4b8)
139 + else:
140 + x_fp8 = x_clamped.to(tl.float8e4nv)
141 + x_uint8 = x_fp8.to(tl.uint8, bitcast=True)
142 +
143 + # Store as uint8 (1 byte each)
144 + tl.store(token_fp8_ptr + offsets, x_uint8, mask=mask)
145 +
146 + # UE8M0 scale encoding: stored_value = exponent + 127 (bias)
147 + # During dequant: scale = 2^(stored_value - 127)
148 + encoded_scale = exponent + 127.0
149 + encoded_scale = tl.maximum(tl.minimum(encoded_scale, 255.0), 0.0)
150 + tl.store(token_scale_ptr + qblock_idx, encoded_scale.to(tl.uint8))
151 +
152 + # Padding scale at index 7
153 + tl.store(token_scale_ptr + 7, tl.zeros((), dtype=tl.uint8))
154 +
155 + # ========== Store BF16 portion (last 64 elements, no quantization) ==========
156 + bf16_input_offset = fp8_dim
157 +
158 + # Process bf16 in chunks of 16
159 + bf16_out_ptr = token_bf16_ptr.to(tl.pointer_type(tl.bfloat16))
160 + for i in tl.static_range(bf16_dim // 16):
161 + chunk_offsets = i * 16 + tl.arange(0, 16)
162 + bf16_vals = tl.load(input_row_ptr + bf16_input_offset + chunk_offsets)
163 + tl.store(bf16_out_ptr + chunk_offsets, bf16_vals)
164 +
165 +
166 +def quantize_and_insert_k_cache(
167 + k: torch.Tensor, # [num_tokens, 512] bf16
168 + k_cache: torch.Tensor, # [num_blocks, block_bytes] uint8
169 + slot_mapping: torch.Tensor, # [num_tokens] int64
170 + block_size: int = 64,
171 + is_ue8m0: bool = True,
172 + use_fnuz: bool = False,
173 +):
174 + """
175 + Quantize K tensor and insert into paged K cache.
176 +
177 + K Cache block layout (block_size=64 tokens):
178 + - First 64 * 576 = 36864 bytes: Token data
179 + - Each token: 448 bytes (fp8) + 128 bytes (bf16)
180 + - Next 64 * 8 = 512 bytes: Scales
181 + - Each token: 8 bytes (uint8 scales, 7 real + 1 padding)
182 + - Padded to multiple of 576
183 +
184 + ``use_fnuz=True`` selects FNUZ E4M3 cache encoding and is only valid on
185 + platforms whose FP8 format is FNUZ. ``use_fnuz=False`` selects OCP E4M3,
186 + which is used by OCP-encoded caches even on gfx942.
187 + """
188 + assert k.dim() == 2 and k.shape[1] == 512, (
189 + f"K must be [num_tokens, 512], got {k.shape}"
190 + )
191 + assert k.dtype == torch.bfloat16, f"K must be bf16, got {k.dtype}"
192 + assert is_ue8m0, "Only support ue8m0 quantization."
193 +
194 + # NOTE: When using DP, slot_mapping.shape[0] can be less than k.shape[0] due to
195 + # padding. Always use slot_mapping.shape[0] as the token count.
196 + num_tokens = slot_mapping.shape[0]
197 + block_stride = k_cache.stride(0) # bytes per block
198 +
199 + TOKEN_FP8_DIM = 448
200 + TOKEN_BF16_DIM = 64
201 + TOKEN_SCALE_DIM = 8
202 + QUANT_BLOCK_SIZE = 64
203 + if use_fnuz:
204 + if not current_platform.is_fp8_fnuz():
205 + raise ValueError("use_fnuz=True requires a platform using FNUZ FP8")
206 + _, FP8_MAX = get_fp8_min_max()
207 + else:
208 + FP8_MAX = torch.finfo(torch.float8_e4m3fn).max
209 + TOKEN_DATA_SIZE = TOKEN_FP8_DIM + TOKEN_BF16_DIM * 2
210 +
211 + grid = (num_tokens,)
212 +
213 + quantize_and_insert_k_kernel[grid](
214 + k,
215 + slot_mapping,
216 + k_cache,
217 + num_tokens,
218 + input_dim=512,
219 + fp8_dim=TOKEN_FP8_DIM,
220 + bf16_dim=TOKEN_BF16_DIM,
221 + scale_dim=TOKEN_SCALE_DIM,
222 + quant_block=QUANT_BLOCK_SIZE,
223 + cache_block_size=block_size,
224 + token_data_size=TOKEN_DATA_SIZE,
225 + block_stride=block_stride,
226 + fp8_max=FP8_MAX,
227 + n_quant_blocks=8,
228 + use_fnuz=use_fnuz,
229 + )
230 +
231 +
232 +@triton.jit
233 +def _dequantize_and_gather_k_kernel(
234 + out_ptr,
235 + out_stride0,
236 + out_stride1,
237 + k_cache_ptr,
238 + seq_lens_ptr,
239 + block_table_ptr,
240 + offset,
241 + gather_lens_ptr,
242 + # Constants
243 + max_blocks_per_seq: tl.constexpr,
244 + fp8_dim: tl.constexpr, # 448
245 + bf16_dim: tl.constexpr, # 64
246 + scale_dim: tl.constexpr, # 8
247 + quant_block: tl.constexpr, # 64 (quantization block size)
248 + cache_block_size: tl.constexpr, # 64 or 128 (paged cache block size)
249 + token_data_size: tl.constexpr, # 576 bytes per token data
250 + block_stride: tl.constexpr, # total bytes per block (padded) int32
251 + output_dim: tl.constexpr, # 512
252 + fp8_max: tl.constexpr,
253 + n_quant_blocks: tl.constexpr, # 7 real blocks
254 + use_fnuz: tl.constexpr = False,
255 +):
256 + batch_idx = tl.program_id(0)
257 + worker_id = tl.program_id(1)
258 + num_workers = tl.num_programs(1)
259 +
260 + seq_len = tl.load(seq_lens_ptr + batch_idx)
261 + if gather_lens_ptr is not None: # noqa: SIM108
262 + gather_len = tl.load(gather_lens_ptr + batch_idx)
263 + else:
264 + # Gather all tokens
265 + gather_len = seq_len
266 + start_pos = seq_len - gather_len
267 +
268 + for i in range(worker_id, gather_len, num_workers):
269 + # Calculate the actual token index in the sequence
270 + pos = start_pos + i
271 +
272 + # Calculate which block and position within block
273 + block_in_seq = pos // cache_block_size
274 + pos_in_block = pos % cache_block_size
275 +
276 + # Get physical block index from block table
277 + block_table_row_ptr = block_table_ptr + batch_idx * max_blocks_per_seq
278 + physical_block_idx = tl.load(block_table_row_ptr + block_in_seq) # int32
279 +
280 + # int64: physical_block_idx * block_stride can exceed 2^31 with many
281 + # KV-cache blocks (e.g. >= 57K at block_stride ~37K).
282 + cache_block_ptr = k_cache_ptr + physical_block_idx.to(tl.int64) * block_stride
283 +
284 + # Token data pointer
285 + token_data_ptr = cache_block_ptr + pos_in_block * token_data_size
286 +
287 + # Scale pointer: after all token data
288 + token_scale_ptr = (
289 + cache_block_ptr
290 + + cache_block_size * token_data_size
291 + + pos_in_block * scale_dim
292 + )
293 +
294 + # Token data layout: [0:448] fp8, [448:576] bf16
295 + token_fp8_ptr = token_data_ptr
296 + token_bf16_ptr = token_data_ptr + fp8_dim
297 +
298 + # Output pointer for this token (flattened)
299 + output_row_ptr = out_ptr + batch_idx * out_stride0 + (offset + i) * out_stride1
300 +
301 + # ========== Dequantize FP8 portion using UE8M0 ==========
302 + for qblock_idx in tl.static_range(n_quant_blocks):
303 + qblock_start = qblock_idx * quant_block
304 +
305 + if qblock_start < fp8_dim:
306 + offsets = qblock_start + tl.arange(0, quant_block)
307 + mask = offsets < fp8_dim
308 +
309 + # Load quantized fp8 values (stored as uint8)
310 + x_uint8 = tl.load(token_fp8_ptr + offsets, mask=mask, other=0)
311 +
312 + # Bitcast uint8 back to fp8 (FNUZ on gfx942, OCP elsewhere).
313 + if use_fnuz:
314 + x_fp8 = x_uint8.to(tl.float8e4b8, bitcast=True)
315 + else:
316 + x_fp8 = x_uint8.to(tl.float8e4nv, bitcast=True)
317 +
318 + # Convert fp8 to float32 for computation
319 + x_float = x_fp8.to(tl.float32)
320 +
321 + # Load and decode UE8M0 scale
322 + # UE8M0: scale = 2^(stored_value - 127)
323 + encoded_scale = tl.load(token_scale_ptr + qblock_idx)
324 + exponent = encoded_scale.to(tl.float32) - 127.0
325 + scale = tl.exp2(exponent)
326 +
327 + # Dequantize: bf16_value = fp8_value * scale
328 + x_dequant = x_float * scale
329 +
330 + # Store as bf16
331 + tl.store(output_row_ptr + offsets, x_dequant.to(tl.bfloat16), mask=mask)
332 +
333 + # ========== Copy BF16 portion directly ==========
334 + bf16_output_offset = fp8_dim # After 448 elements in output
335 +
336 + # Read bf16 from cache
337 + bf16_cache_ptr = token_bf16_ptr.to(tl.pointer_type(tl.bfloat16))
338 +
339 + # Process in chunks of 16
340 + for j in tl.static_range(bf16_dim // 16):
341 + chunk_offsets = j * 16 + tl.arange(0, 16)
342 + bf16_vals = tl.load(bf16_cache_ptr + chunk_offsets)
343 + tl.store(output_row_ptr + bf16_output_offset + chunk_offsets, bf16_vals)
344 +
345 +
346 +def dequantize_and_gather_k_cache_triton(
347 + # [num_reqs, max_num_tokens, head_size]
348 + out: torch.Tensor,
349 + # [num_blocks, block_size, head_bytes]
350 + k_cache: torch.Tensor,
351 + # [num_reqs]
352 + seq_lens: torch.Tensor,
353 + # [num_reqs]
354 + gather_lens: torch.Tensor | None,
355 + # [num_reqs, max_blocks_per_seq]
356 + block_table: torch.Tensor,
357 + block_size: int,
358 + offset: int,
359 + use_fnuz: bool = False,
360 +) -> None:
361 + TOKEN_FP8_DIM = 448
362 + TOKEN_BF16_DIM = 64
363 + TOKEN_SCALE_DIM = 8
364 + QUANT_BLOCK_SIZE = 64
365 + FP8_MAX = 448.0
366 + TOKEN_DATA_SIZE = TOKEN_FP8_DIM + TOKEN_BF16_DIM * 2
367 +
368 + num_reqs = seq_lens.shape[0]
369 + NUM_WORKERS = 128
370 + _dequantize_and_gather_k_kernel[(num_reqs, NUM_WORKERS)](
371 + out,
372 + out.stride(0),
373 + out.stride(1),
374 + k_cache,
375 + seq_lens,
376 + block_table,
377 + offset,
378 + gather_lens,
379 + max_blocks_per_seq=block_table.shape[-1],
380 + fp8_dim=TOKEN_FP8_DIM,
381 + bf16_dim=TOKEN_BF16_DIM,
382 + scale_dim=TOKEN_SCALE_DIM,
383 + quant_block=QUANT_BLOCK_SIZE,
384 + cache_block_size=block_size,
385 + token_data_size=TOKEN_DATA_SIZE,
386 + block_stride=k_cache.stride(0),
387 + output_dim=512,
388 + fp8_max=FP8_MAX,
389 + n_quant_blocks=7,
390 + use_fnuz=use_fnuz,
391 + )
392 +
393 +
394 +def dequantize_and_gather_k_cache(
395 + # [num_reqs, max_num_tokens, head_size]
396 + out: torch.Tensor,
397 + # [num_blocks, block_size, head_bytes]
398 + k_cache: torch.Tensor,
399 + # [num_reqs]
400 + seq_lens: torch.Tensor,
401 + # [num_reqs]
402 + gather_lens: torch.Tensor | None,
403 + # [num_reqs, max_blocks_per_seq]
404 + block_table: torch.Tensor,
405 + block_size: int,
406 + offset: int,
407 + use_fnuz: bool = False,
408 +) -> None:
409 + """Dequantize and gather a paged DSv4 K cache.
410 +
411 + ``use_fnuz`` MUST match the encoder of the specific cache being read:
412 + ``False`` for ``compressed_k_cache`` (Triton encoder is OCP everywhere),
413 + ``current_platform.is_fp8_fnuz()`` for ``swa_k_cache`` (C++ encoder
414 + writes FNUZ on gfx942 and OCP on gfx950).
415 + """
416 + if has_cutedsl():
417 + # lazily import, otherwise some tests fail due to CUDA driver init failure.
418 + from vllm.models.deepseek_v4.nvidia.ops.dequant_gather_k_cutedsl import (
419 + dequantize_and_gather_k_cache_cutedsl,
420 + )
421 +
422 + dequantize_and_gather_k_cache_cutedsl(
423 + out, k_cache, seq_lens, gather_lens, block_table, block_size, offset
424 + )
425 + return
426 +
427 + dequantize_and_gather_k_cache_triton(
428 + out,
429 + k_cache,
430 + seq_lens,
431 + gather_lens,
432 + block_table,
433 + block_size,
434 + offset,
435 + use_fnuz=use_fnuz,
436 + )
437 +
438 +
439 +def compute_global_topk_indices_and_lens(
440 + topk_indices: torch.Tensor,
441 + token_to_req_indices: torch.Tensor,
442 + block_table: torch.Tensor,
443 + block_size: int,
444 + is_valid_token: torch.Tensor,
445 +) -> tuple[torch.Tensor, torch.Tensor]:
446 + """Map local topk indices to global KV cache slots and count valid entries.
447 +
448 + Fuses three operations into a single kernel:
449 + 1. Block-table lookup (local index → global slot id)
450 + 2. Valid-entry counting (topk_lens per token)
451 + 3. Masking padding tokens to length 0
452 + """
453 + num_tokens = topk_indices.shape[0]
454 + global_topk_indices = torch.empty_like(topk_indices)
455 + topk_lens = torch.empty(num_tokens, dtype=torch.int32, device=topk_indices.device)
456 + _compute_global_topk_indices_and_lens_kernel[(num_tokens,)](
457 + global_topk_indices,
458 + global_topk_indices.stride(0),
459 + topk_lens,
460 + topk_indices,
461 + topk_indices.stride(0),
462 + topk_indices.shape[-1],
463 + token_to_req_indices,
464 + block_table,
465 + block_table.stride(0),
466 + block_size,
467 + is_valid_token,
468 + TRITON_BLOCK_SIZE=1024,
469 + )
470 + return global_topk_indices, topk_lens
471 +
472 +
473 +@triton.jit
474 +def _compute_global_topk_indices_and_lens_kernel(
475 + global_topk_indices_ptr,
476 + global_topk_indices_stride: tl.constexpr,
477 + topk_lens_ptr,
478 + topk_indices_ptr,
479 + topk_indices_stride: tl.constexpr,
480 + topk: tl.constexpr,
481 + token_to_req_indices_ptr,
482 + block_table_ptr,
483 + block_table_stride: tl.constexpr,
484 + block_size: tl.constexpr,
485 + is_valid_token_ptr,
486 + TRITON_BLOCK_SIZE: tl.constexpr,
487 +):
488 + token_idx = tl.program_id(0)
489 + is_valid_token = tl.load(is_valid_token_ptr + token_idx)
490 + req_idx = tl.load(token_to_req_indices_ptr + token_idx)
491 +
492 + count = tl.zeros((), dtype=tl.int32)
493 + for i in range(0, topk, TRITON_BLOCK_SIZE):
494 + offset = i + tl.arange(0, TRITON_BLOCK_SIZE)
495 + mask = offset < topk
496 +
497 + local_idx = tl.load(
498 + topk_indices_ptr + token_idx * topk_indices_stride + offset,
499 + mask=mask,
500 + other=-1,
501 + )
502 + is_valid = local_idx >= 0
503 +
504 + block_indices = local_idx // block_size
505 + block_numbers = tl.load(
506 + block_table_ptr + req_idx * block_table_stride + block_indices,
507 + mask=mask & is_valid,
508 + )
509 + block_offsets = local_idx % block_size
510 +
511 + slot_ids = block_numbers * block_size + block_offsets
512 + slot_ids = tl.where(is_valid, slot_ids, -1)
513 + tl.store(
514 + global_topk_indices_ptr + token_idx * global_topk_indices_stride + offset,
515 + slot_ids,
516 + mask=mask,
517 + )
518 + count += tl.sum(is_valid.to(tl.int32), axis=0)
519 +
520 + # Zero out length for padding tokens.
521 + tl.store(topk_lens_ptr + token_idx, tl.where(is_valid_token, count, 0))
522 +
523 +
524 +# FlashMLA sparse prefill asserts `params.topk % B_TOPK == 0` (see
525 +# flashmla/csrc/sm100/prefill/sparse/fwd/head{64,128}/phase1.cuh). B_TOPK is
526 +# 64 for the h_q=64 kernel and 128 for h_q=128; pad to 128 to satisfy both.
527 +# The extra slots stay as -1 sentinels and `combined_lens` caps the valid
528 +# range via `topk_length`, so padding is a no-op at kernel level.
529 +_SPARSE_PREFILL_TOPK_ALIGNMENT = 128
530 +
531 +
532 +def combine_topk_swa_indices(
533 + topk_indices: torch.Tensor,
534 + query_start_loc: torch.Tensor,
535 + seq_lens: torch.Tensor,
536 + gather_lens: torch.Tensor,
537 + window_size: int,
538 + compress_ratio: int,
539 + topk: int,
540 + M: int,
541 + N: int,
542 + out: tuple[torch.Tensor, torch.Tensor] | None = None,
543 + left_visible: torch.Tensor | None = None,
544 + right_visible: torch.Tensor | None = None,
545 + max_image_tokens: int = 0,
546 +) -> tuple[torch.Tensor, torch.Tensor]:
547 + num_tokens = topk_indices.shape[0]
548 + # max_image_tokens widens the SWA column region for in-image
549 + # bidirectional visibility; the width is fixed per model so the
550 + # caller-provided workspace stays valid on image-free batches.
551 + combined_topk = (
552 + (topk + window_size + max_image_tokens + _SPARSE_PREFILL_TOPK_ALIGNMENT - 1)
553 + // _SPARSE_PREFILL_TOPK_ALIGNMENT
554 + * _SPARSE_PREFILL_TOPK_ALIGNMENT
555 + )
556 + if out is None:
557 + combined_indices = torch.full(
558 + (num_tokens, combined_topk),
559 + fill_value=-1,
560 + dtype=torch.int32,
561 + device=topk_indices.device,
562 + )
563 + combined_lens = torch.empty(
564 + num_tokens, dtype=torch.int32, device=topk_indices.device
565 + )
566 + else:
567 + combined_indices, combined_lens = out
568 +
569 + _COMBINE_TOPK_SWA_INDICES_KERNEL(
570 + combined_indices,
571 + combined_lens,
572 + topk_indices,
573 + query_start_loc,
574 + seq_lens,
575 + gather_lens,
576 + M,
577 + N,
578 + TOP_K=topk,
579 + COMPRESS_RATIO=compress_ratio,
580 + WINDOW_SIZE=window_size,
581 + left_visible=left_visible,
582 + right_visible=right_visible,
583 + max_image_tokens=max_image_tokens,
584 + )
585 + return combined_indices, combined_lens
586 +
587 +
588 +_COMBINE_TOPK_SWA_NUM_WORKERS = 256
589 +
590 +
591 +# Representative pointer alignment variants for Triton pointer specialization.
592 +_COMBINE_TOPK_SWA_POINTER_INPUTS = zip_inputs(
593 + dict(
594 + topk_indices=True,
595 + query_start_loc=True,
596 + seq_lens=True,
597 + gather_lens=True,
598 + ),
599 + dict(
600 + topk_indices=True,
601 + query_start_loc=False,
602 + seq_lens=False,
603 + gather_lens=True,
604 + ),
605 + dict(
606 + topk_indices=False,
607 + query_start_loc=False,
608 + seq_lens=False,
609 + gather_lens=False,
610 + ),
611 +)
612 +
613 +
614 +def _hf_config_int(vllm_config: Any, name: str, default: int) -> int:
615 + model_config = getattr(vllm_config, "model_config", None)
616 + hf_config = getattr(model_config, "hf_config", None)
617 + return int(getattr(hf_config, name, default) or default)
618 +
619 +
620 +def _combine_topk_swa_warmup_inputs(vllm_config: Any) -> _WarmupInputRows:
621 + """One warmup row per v4.1 layer type (compress ratio) in the model.
622 +
623 + Every layer slices the shared ``index_topk``-wide indices buffer, so the
624 + padded width is the same for all rows; SWA-only layers (ratio 0) pass
625 + ``TOP_K=0``.
626 + """
627 + model_config = getattr(vllm_config, "model_config", None)
628 + hf_config = getattr(model_config, "hf_config", None)
629 + compress_ratios = getattr(hf_config, "compress_ratios", None) or [0]
630 + topk = _hf_config_int(vllm_config, "index_topk", 0)
631 + return zip_inputs(
632 + *(
633 + dict(compress_ratio=ratio, topk=topk if ratio > 0 else 0, topk_width=topk)
634 + for ratio in sorted({int(ratio) for ratio in compress_ratios})
635 + )
636 + )
637 +
638 +
639 +def _scheduler_config_int(vllm_config: Any, name: str, default: int) -> int:
640 + scheduler_config = getattr(vllm_config, "scheduler_config", None)
641 + return int(getattr(scheduler_config, name, default) or default)
642 +
643 +
644 +class CombineTopkSwaIndicesKernel(
645 + VllmJitKernel["CombineTopkSwaIndicesKernel.CompileKey"]
646 +):
647 + @dataclass(frozen=True)
648 + class CompileKey:
649 + TOP_K: int
650 + COMPRESS_RATIO: int
651 + WINDOW_SIZE: int
652 + IMAGE_WIDTH: int
653 + PADDED_TOP_K: int
654 + input_variant: TritonPointerInputVariant
655 +
656 + @staticmethod
657 + @triton.jit(
658 + do_not_specialize=[
659 + "combined_indices_stride",
660 + "topk_indices_stride",
661 + "M",
662 + "N",
663 + ]
664 + )
665 + def kernel(
666 + combined_indices_ptr,
667 + combined_indices_stride,
668 + combined_lens_ptr,
669 + topk_indices_ptr,
670 + topk_indices_stride,
671 + query_start_loc_ptr,
672 + seq_lens_ptr,
673 + gather_lens_ptr,
674 + left_visible_ptr,
675 + right_visible_ptr,
676 + M,
677 + N,
678 + TOP_K: tl.constexpr,
679 + COMPRESS_RATIO: tl.constexpr,
680 + WINDOW_SIZE: tl.constexpr,
681 + IMAGE_WIDTH: tl.constexpr,
682 + PADDED_TOP_K: tl.constexpr,
683 + ):
684 + batch_idx = tl.program_id(0)
685 + worker_id = tl.program_id(1)
686 + num_workers = tl.num_programs(1)
687 +
688 + # query_start_loc is a global tensor; rebase to chunk-local offsets
689 + # by subtracting the chunk's starting value.
690 + base = tl.load(query_start_loc_ptr)
691 + query_start = tl.load(query_start_loc_ptr + batch_idx) - base
692 + query_end = tl.load(query_start_loc_ptr + batch_idx + 1) - base
693 + query_len = query_end - query_start
694 + seq_len = tl.load(seq_lens_ptr + batch_idx)
695 + gather_len = tl.load(gather_lens_ptr + batch_idx)
696 + start_pos = seq_len - query_len
697 + # The SWA portion of the gathered buffer starts from position
698 + # (seq_len - gather_len), not position 0. We need this offset
699 + # to correctly index into the gathered buffer.
700 + gather_start = seq_len - gather_len
701 +
702 + for token_idx in range(query_start + worker_id, query_end, num_workers):
703 + # topk_len is fully determined by the query token's absolute position:
704 + # the indexer emits min((pos + 1) // compress_ratio, topk_tokens)
705 + # valid entries. Caller passes TOP_K=0 for SWA-only layers to zero
706 + # this out.
707 + token_idx_in_query = token_idx - query_start
708 + pos = start_pos + token_idx_in_query
709 + if COMPRESS_RATIO > 0:
710 + topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
711 + else:
712 + # SWA-only layers pass TOP_K=0; skip the division entirely
713 + # (integer div by constexpr 0 is UB and yields garbage, which
714 + # turns the store index below negative).
715 + topk_len = 0
716 + if IMAGE_WIDTH > 0:
717 + # In-image bidirectional visibility: the window starts up to
718 + # max(left - (window - 1), 0) positions earlier and extends
719 + # `right` positions past the query token (both 0 outside
720 + # image spans, reducing to the plain causal window).
721 + left = tl.load(left_visible_ptr + token_idx)
722 + right = tl.load(right_visible_ptr + token_idx)
723 + else:
724 + left = 0
725 + right = 0
726 + left_add = tl.maximum(left - (WINDOW_SIZE - 1), 0)
727 + swa_start = tl.maximum(pos - (WINDOW_SIZE - 1) - left_add, 0)
728 + swa_len = pos + right - swa_start + 1
729 +
730 + offset = tl.arange(0, PADDED_TOP_K)
731 + mask = offset < topk_len
732 + topk_indices = tl.load(
733 + topk_indices_ptr + token_idx * topk_indices_stride + offset,
734 + mask=mask,
735 + )
736 + tl.store(
737 + combined_indices_ptr + token_idx * combined_indices_stride + offset,
738 + topk_indices + M * batch_idx,
739 + mask=mask,
740 + )
741 + # Index into gathered buffer: N + (position - gather_start)
742 + # For positions [swa_start, pos + right], the buffer indices are:
743 + # [N + swa_start - gather_start, N + pos + right - gather_start]
744 + for i in range(0, WINDOW_SIZE + IMAGE_WIDTH, WINDOW_SIZE):
745 + swa_offset = i + tl.arange(0, WINDOW_SIZE)
746 + tl.store(
747 + combined_indices_ptr
748 + + token_idx * combined_indices_stride
749 + + topk_len
750 + + swa_offset,
751 + M * batch_idx + N + swa_start + swa_offset - gather_start,
752 + mask=swa_offset < swa_len,
753 + )
754 +
755 + combined_len = topk_len + swa_len
756 + tl.store(combined_lens_ptr + token_idx, combined_len)
757 +
758 + def dispatch( # type: ignore[override]
759 + self,
760 + *,
761 + topk_width: int,
762 + topk_indices: bool,
763 + query_start_loc: bool,
764 + seq_lens: bool,
765 + gather_lens: bool,
766 + topk: int,
767 + compress_ratio: int,
768 + WINDOW_SIZE: int,
769 + image_width: int = 0,
770 + ) -> CompileKey:
771 + padded_topk = next_power_of_2(topk_width)
772 + input_variant = TritonPointerInputVariant.from_alignment(
773 + topk_indices=topk_indices,
774 + query_start_loc=query_start_loc,
775 + seq_lens=seq_lens,
776 + gather_lens=gather_lens,
777 + )
778 + return self.CompileKey(
779 + TOP_K=topk,
780 + COMPRESS_RATIO=compress_ratio,
781 + WINDOW_SIZE=WINDOW_SIZE,
782 + IMAGE_WIDTH=image_width,
783 + PADDED_TOP_K=padded_topk,
784 + input_variant=input_variant,
785 + )
786 +
787 + def get_warmup_keys(self, vllm_config: Any) -> list[CompileKey]:
788 + if _scheduler_config_int(vllm_config, "max_num_batched_tokens", 0) <= 0:
789 + return []
790 +
791 + window_size = _hf_config_int(vllm_config, "sliding_window", 128)
792 + image_width = (
793 + _hf_config_int(vllm_config, "vision_max_n_token", 0)
794 + if _hf_config_int(vllm_config, "vision_n_layers", 0) > 0
795 + else 0
796 + )
797 + # Warm both the plain-window variant (batches without image spans)
798 + # and the in-image bidirectional variant.
799 + image_widths = [0, image_width] if image_width > 0 else [0]
800 + return self._trace_dispatch(self.dispatch)(
801 + _combine_topk_swa_warmup_inputs(vllm_config),
802 + _COMBINE_TOPK_SWA_POINTER_INPUTS,
803 + WINDOW_SIZE=window_size,
804 + image_width=image_widths,
805 + )
806 +
807 + def compile(self, compile_key: CompileKey) -> None:
808 + warmup = getattr(self.kernel, "warmup", None)
809 + assert warmup is not None
810 + int32_ptr = TritonWarmupTensor(torch.int32)
811 + input_variant = compile_key.input_variant
812 + warmup(
813 + int32_ptr,
814 + 1, # do not specialize combined_indices_stride
815 + int32_ptr,
816 + input_variant.pointer("topk_indices", torch.int32),
817 + 1, # do not specialize topk_indices_stride
818 + input_variant.pointer("query_start_loc", torch.int32),
819 + input_variant.pointer("seq_lens", torch.int32),
820 + input_variant.pointer("gather_lens", torch.int32),
821 + int32_ptr,
822 + int32_ptr,
823 + 1, # do not specialize M
824 + 1, # do not specialize N
825 + TOP_K=compile_key.TOP_K,
826 + COMPRESS_RATIO=compile_key.COMPRESS_RATIO,
827 + WINDOW_SIZE=compile_key.WINDOW_SIZE,
828 + IMAGE_WIDTH=compile_key.IMAGE_WIDTH,
829 + PADDED_TOP_K=compile_key.PADDED_TOP_K,
830 + grid=(1, _COMBINE_TOPK_SWA_NUM_WORKERS),
831 + )
832 +
833 + def __call__(
834 + self,
835 + combined_indices: torch.Tensor,
836 + combined_lens: torch.Tensor,
837 + topk_indices: torch.Tensor,
838 + query_start_loc: torch.Tensor,
839 + seq_lens: torch.Tensor,
840 + gather_lens: torch.Tensor,
841 + M: int,
842 + N: int,
843 + *,
844 + TOP_K: int,
845 + COMPRESS_RATIO: int,
846 + WINDOW_SIZE: int,
847 + left_visible: torch.Tensor | None = None,
848 + right_visible: torch.Tensor | None = None,
849 + max_image_tokens: int = 0,
850 + ) -> None:
851 + num_reqs = seq_lens.shape[0]
852 + has_image = left_visible is not None
853 + image_width = max_image_tokens if has_image else 0
854 + self.kernel[(num_reqs, _COMBINE_TOPK_SWA_NUM_WORKERS)](
855 + combined_indices,
856 + combined_indices.stride(0),
857 + combined_lens,
858 + topk_indices,
859 + topk_indices.stride(0),
860 + query_start_loc,
861 + seq_lens,
862 + gather_lens,
863 + left_visible if has_image else topk_indices,
864 + right_visible if has_image else topk_indices,
865 + M,
866 + N,
867 + TOP_K=TOP_K,
868 + COMPRESS_RATIO=COMPRESS_RATIO,
869 + WINDOW_SIZE=WINDOW_SIZE,
870 + IMAGE_WIDTH=image_width,
871 + PADDED_TOP_K=next_power_of_2(topk_indices.shape[-1]),
872 + )
873 +
874 +
875 +_COMBINE_TOPK_SWA_INDICES_KERNEL = CombineTopkSwaIndicesKernel()
876 +
877 +
878 +def build_flashinfer_mixed_sparse_indices(
879 + decode_swa_indices: torch.Tensor,
880 + decode_compressed_indices: torch.Tensor | None,
881 + decode_compressed_topk_lens: torch.Tensor | None,
882 + prefill_topk_indices: torch.Tensor,
883 + query_start_loc: torch.Tensor,
884 + seq_lens: torch.Tensor,
885 + token_to_req_indices: torch.Tensor,
886 + swa_block_table: torch.Tensor,
887 + swa_block_size: int,
888 + compressed_block_table: torch.Tensor | None,
889 + compressed_block_size: int,
890 + window_size: int,
891 + compress_ratio: int,
892 + topk: int,
893 + decode_compressed_indices_are_local: bool = False,
894 + decode_is_valid_token: torch.Tensor | None = None,
895 + swa_block_span: int | None = None,
896 + compressed_block_span: int | None = None,
897 + prefill_left_visible: torch.Tensor | None = None,
898 + prefill_right_visible: torch.Tensor | None = None,
899 + max_image_tokens: int = 0,
900 +) -> tuple[torch.Tensor, torch.Tensor]:
901 + """Build the FlashInfer DSV4 sparse-index matrix for decode-first batches.
902 +
903 + Produces ``sparse_indices`` of shape ``[num_tokens, swa_total_width +
904 + padded_topk]`` (the first ``swa_total_width`` columns are SWA slot ids, the
905 + rest are compressed/top-k slot ids) and ``sparse_topk_lens`` (active length
906 + per token). Decode tokens read precomputed SWA/compressed indices; prefill
907 + tokens derive their SWA window from the position and translate local
908 + compressed indices to global slots via the block tables.
909 +
910 + When ``prefill_left_visible``/``prefill_right_visible`` are given (vision
911 + variant), the SWA column region widens by ``max_image_tokens`` and prefill
912 + tokens inside an image span get a bidirectionally widened window; decode
913 + rows are padded with -1 across the extra columns.
914 + """
915 + assert decode_swa_indices.dtype == torch.int32
916 + assert decode_swa_indices.dim() == 2
917 + swa_index_width = decode_swa_indices.shape[-1]
918 + assert swa_index_width >= window_size
919 + has_image = prefill_left_visible is not None
920 + image_width = max_image_tokens if has_image else 0
921 + swa_total_width = swa_index_width + image_width
922 + if has_image:
923 + assert prefill_right_visible is not None
924 + if decode_compressed_topk_lens is not None:
925 + assert decode_compressed_topk_lens.dtype == torch.int32
926 + assert prefill_topk_indices.dtype == torch.int32
927 + assert prefill_topk_indices.dim() == 2
928 + assert query_start_loc.dtype == torch.int32
929 + assert seq_lens.dtype == torch.int32
930 + assert token_to_req_indices.dtype == torch.int32
931 + assert swa_block_table.dtype == torch.int32
932 +
933 + num_decode_tokens = decode_swa_indices.shape[0]
934 + num_prefill_tokens = prefill_topk_indices.shape[0]
935 + num_tokens = num_decode_tokens + num_prefill_tokens
936 + assert token_to_req_indices.shape[0] >= num_tokens
937 + if decode_compressed_topk_lens is not None:
938 + assert decode_compressed_topk_lens.shape[0] >= num_decode_tokens
939 +
940 + decode_compressed_topk = 0
941 + if decode_compressed_indices is None:
942 + decode_compressed_indices = prefill_topk_indices
943 + else:
944 + assert decode_compressed_indices.dtype == torch.int32
945 + assert decode_compressed_indices.dim() == 2
946 + assert decode_compressed_indices.shape[0] == num_decode_tokens
947 + decode_compressed_topk = decode_compressed_indices.shape[-1]
948 + if decode_compressed_topk > 0 and decode_compressed_indices_are_local:
949 + assert decode_is_valid_token is not None
950 + assert decode_is_valid_token.dtype == torch.bool
951 + assert decode_is_valid_token.shape[0] >= num_decode_tokens
952 + else:
953 + decode_is_valid_token = token_to_req_indices
954 +
955 + if compressed_block_table is None:
956 + compressed_block_table = swa_block_table
957 + assert compressed_block_table.dtype == torch.int32
958 + has_decode_compressed_lens = decode_compressed_topk_lens is not None
959 + if decode_compressed_topk_lens is None:
960 + decode_compressed_topk_lens = token_to_req_indices
961 +
962 + # The FlashInfer TRTLLM-gen sparse-MLA kernels require every per-token topk
963 + # index row to start on a 16-byte boundary: the kernel loads the compressed
964 + # indices with 128-bit (16-byte) vectorized loads, so a misaligned row would
965 + # fault or read across rows. 16 bytes = 4 int32 indices, so round the topk
966 + # width (and hence the row stride, since the SWA columns are fixed-width) up
967 + # to a multiple of 4. The extra columns are filled with -1 (invalid) and bounded
968 + # by ``sparse_topk_lens``, so padding never changes the attention result.
969 + padded_topk = max(topk, decode_compressed_topk)
970 + padded_topk = (padded_topk + 3) // 4 * 4
971 + sparse_indices = torch.empty(
972 + (num_tokens, swa_total_width + padded_topk),
973 + dtype=torch.int32,
974 + device=decode_swa_indices.device,
975 + )
976 + sparse_topk_lens = torch.empty(
977 + num_tokens, dtype=torch.int32, device=decode_swa_indices.device
978 + )
979 + if num_tokens == 0:
980 + return sparse_indices, sparse_topk_lens
981 +
982 + window_block_size = triton.next_power_of_2(max(swa_index_width, 1))
983 + topk_block_size = triton.next_power_of_2(max(padded_topk, 1))
984 + max_block_size = max(window_block_size, topk_block_size)
985 + num_warps = 4 if max_block_size >= 256 else 1
986 +
987 + # block_span = page_stride / token_stride; == block_size (no-op) for unpacked KV.
988 + swa_span = swa_block_size if swa_block_span is None else swa_block_span
989 + compressed_span = (
990 + compressed_block_size
991 + if compressed_block_span is None
992 + else compressed_block_span
993 + )
994 + _build_flashinfer_mixed_sparse_indices_kernel[(num_tokens,)](
995 + sparse_indices,
996 + sparse_indices.stride(0),
997 + sparse_topk_lens,
998 + decode_swa_indices,
999 + decode_swa_indices.stride(0),
1000 + decode_compressed_indices,
1001 + decode_compressed_indices.stride(0),
1002 + decode_compressed_topk_lens,
1003 + decode_is_valid_token,
1004 + prefill_topk_indices,
1005 + prefill_topk_indices.stride(0),
1006 + prefill_left_visible if has_image else token_to_req_indices,
1007 + prefill_right_visible if has_image else token_to_req_indices,
1008 + query_start_loc,
1009 + seq_lens,
1010 + token_to_req_indices,
1011 + swa_block_table,
1012 + swa_block_table.stride(0),
1013 + swa_block_size,
1014 + swa_span,
1015 + compressed_block_table,
1016 + compressed_block_table.stride(0),
1017 + compressed_block_size,
1018 + compressed_span,
1019 + NUM_DECODE_TOKENS=num_decode_tokens,
1020 + WINDOW_SIZE=window_size,
1021 + SWA_INDEX_WIDTH=swa_index_width,
1022 + IMAGE_WIDTH=image_width,
1023 + COMPRESS_RATIO=compress_ratio,
1024 + TOP_K=topk,
1025 + PADDED_TOP_K=padded_topk,
1026 + PREFILL_TOPK_STRIDE=prefill_topk_indices.shape[-1],
1027 + DECODE_COMPRESSED_TOPK=decode_compressed_topk,
1028 + DECODE_COMPRESSED_INDICES_ARE_LOCAL=decode_compressed_indices_are_local,
1029 + HAS_DECODE_COMPRESSED_LENS=has_decode_compressed_lens,
1030 + WINDOW_BLOCK_SIZE=window_block_size,
1031 + TOPK_BLOCK_SIZE=topk_block_size,
1032 + num_warps=num_warps,
1033 + )
1034 + return sparse_indices, sparse_topk_lens
1035 +
1036 +
1037 +@triton.jit
1038 +def _remap_flashinfer_index(values, block_size, block_span):
1039 + # FlashInfer's DSv4 kernel indexes sparse KV by physical token stride, so
1040 + # packed pages (#44577) need block*block_size+off -> block*block_span+off.
1041 + # TODO: remove once flashinfer-ai/flashinfer#3856 is fixed.
1042 + is_valid = values >= 0
1043 + safe_values = tl.where(is_valid, values, 0)
1044 + values = (safe_values // block_size) * block_span
1045 + values += safe_values % block_size
1046 + return tl.where(is_valid, values, -1)
1047 +
1048 +
1049 +@triton.jit(
1050 + do_not_specialize=[
1051 + "sparse_indices_stride",
1052 + "decode_swa_stride",
1053 + "decode_compressed_stride",
1054 + "prefill_topk_stride",
1055 + "swa_block_table_stride",
1056 + "swa_block_size",
1057 + "swa_block_span",
1058 + "compressed_block_table_stride",
1059 + "compressed_block_size",
1060 + "compressed_block_span",
1061 + "NUM_DECODE_TOKENS",
1062 + "PREFILL_TOPK_STRIDE",
1063 + ]
1064 +)
1065 +def _build_flashinfer_mixed_sparse_indices_kernel(
1066 + sparse_indices_ptr,
1067 + sparse_indices_stride,
1068 + sparse_topk_lens_ptr,
1069 + decode_swa_indices_ptr,
1070 + decode_swa_stride,
1071 + decode_compressed_indices_ptr,
1072 + decode_compressed_stride,
1073 + decode_compressed_topk_lens_ptr,
1074 + decode_is_valid_token_ptr,
1075 + prefill_topk_indices_ptr,
1076 + prefill_topk_stride,
1077 + left_visible_ptr,
1078 + right_visible_ptr,
1079 + query_start_loc_ptr,
1080 + seq_lens_ptr,
1081 + token_to_req_indices_ptr,
1082 + swa_block_table_ptr,
1083 + swa_block_table_stride,
1084 + swa_block_size,
1085 + swa_block_span,
1086 + compressed_block_table_ptr,
1087 + compressed_block_table_stride,
1088 + compressed_block_size,
1089 + compressed_block_span,
1090 + NUM_DECODE_TOKENS,
1091 + WINDOW_SIZE: tl.constexpr,
1092 + SWA_INDEX_WIDTH: tl.constexpr,
1093 + IMAGE_WIDTH: tl.constexpr,
1094 + COMPRESS_RATIO: tl.constexpr,
1095 + TOP_K: tl.constexpr,
1096 + PADDED_TOP_K: tl.constexpr,
1097 + PREFILL_TOPK_STRIDE,
1098 + DECODE_COMPRESSED_TOPK: tl.constexpr,
1099 + DECODE_COMPRESSED_INDICES_ARE_LOCAL: tl.constexpr,
1100 + HAS_DECODE_COMPRESSED_LENS: tl.constexpr,
1101 + WINDOW_BLOCK_SIZE: tl.constexpr,
1102 + TOPK_BLOCK_SIZE: tl.constexpr,
1103 +):
1104 + token_idx = tl.program_id(0)
1105 + # Total SWA column count; > SWA_INDEX_WIDTH only for in-image
1106 + # bidirectional visibility (vision variant), where the extra columns are
1107 + # -1-padded for decode rows.
1108 + SWA_TOTAL_WIDTH: tl.constexpr = SWA_INDEX_WIDTH + IMAGE_WIDTH
1109 +
1110 + if token_idx < NUM_DECODE_TOKENS:
1111 + for i in range(0, SWA_TOTAL_WIDTH, WINDOW_BLOCK_SIZE):
1112 + offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1113 + mask = offset < SWA_TOTAL_WIDTH
1114 + values = tl.load(
1115 + decode_swa_indices_ptr + token_idx * decode_swa_stride + offset,
1116 + mask=offset < SWA_INDEX_WIDTH,
1117 + other=-1,
1118 + )
1119 + values = _remap_flashinfer_index(values, swa_block_size, swa_block_span)
1120 + tl.store(
1121 + sparse_indices_ptr + token_idx * sparse_indices_stride + offset,
1122 + values,
1123 + mask=mask,
1124 + )
1125 +
1126 + compressed_len = tl.zeros((), dtype=tl.int32)
1127 + for i in range(0, PADDED_TOP_K, TOPK_BLOCK_SIZE):
1128 + offset = i + tl.arange(0, TOPK_BLOCK_SIZE)
1129 + mask = offset < PADDED_TOP_K
1130 + values = tl.load(
1131 + decode_compressed_indices_ptr
1132 + + token_idx * decode_compressed_stride
1133 + + offset,
1134 + mask=offset < DECODE_COMPRESSED_TOPK,
1135 + other=-1,
1136 + )
1137 + if DECODE_COMPRESSED_INDICES_ARE_LOCAL:
1138 + token_valid = tl.load(decode_is_valid_token_ptr + token_idx)
1139 + is_valid = values >= 0
1140 + req_idx = tl.load(token_to_req_indices_ptr + token_idx)
1141 + block_indices = values // compressed_block_size
1142 + block_numbers = tl.load(
1143 + compressed_block_table_ptr
1144 + + req_idx * compressed_block_table_stride
1145 + + block_indices,
1146 + mask=mask & is_valid,
1147 + other=-1,
1148 + )
1149 + block_offsets = values % compressed_block_size
1150 + values = block_numbers * compressed_block_size + block_offsets
1151 + values = tl.where(is_valid, values, -1)
1152 + compressed_len += tl.sum((is_valid & token_valid).to(tl.int32), axis=0)
1153 + values = _remap_flashinfer_index(
1154 + values, compressed_block_size, compressed_block_span
1155 + )
1156 + tl.store(
1157 + sparse_indices_ptr
1158 + + token_idx * sparse_indices_stride
1159 + + SWA_TOTAL_WIDTH
1160 + + offset,
1161 + values,
1162 + mask=mask,
1163 + )
1164 +
1165 + if DECODE_COMPRESSED_TOPK == 0:
1166 + compressed_len = tl.zeros((), dtype=tl.int32)
1167 + elif not DECODE_COMPRESSED_INDICES_ARE_LOCAL:
1168 + if HAS_DECODE_COMPRESSED_LENS:
1169 + compressed_len = tl.load(decode_compressed_topk_lens_ptr + token_idx)
1170 + else:
1171 + compressed_len = tl.full((), DECODE_COMPRESSED_TOPK, dtype=tl.int32)
1172 +
1173 + tl.store(sparse_topk_lens_ptr + token_idx, SWA_TOTAL_WIDTH + compressed_len)
1174 + return
1175 +
1176 + prefill_idx = token_idx - NUM_DECODE_TOKENS
1177 + req_idx = tl.load(token_to_req_indices_ptr + token_idx)
1178 + query_start = tl.load(query_start_loc_ptr + req_idx)
1179 + query_end = tl.load(query_start_loc_ptr + req_idx + 1)
1180 + query_len = query_end - query_start
1181 + seq_len = tl.load(seq_lens_ptr + req_idx)
1182 + start_pos = seq_len - query_len
1183 + token_idx_in_query = token_idx - query_start
1184 + pos = start_pos + token_idx_in_query
1185 + if IMAGE_WIDTH > 0:
1186 + # In-image bidirectional visibility: window starts up to
1187 + # max(left - (window - 1), 0) earlier and extends `right` past pos.
1188 + left = tl.load(left_visible_ptr + token_idx)
1189 + right = tl.load(right_visible_ptr + token_idx)
1190 + else:
1191 + left = 0
1192 + right = 0
1193 + left_add = tl.maximum(left - (WINDOW_SIZE - 1), 0)
1194 + swa_start_pos = tl.maximum(pos - (WINDOW_SIZE - 1) - left_add, 0)
1195 + swa_len = pos + right - swa_start_pos + 1
1196 + if COMPRESS_RATIO > 0:
1197 + topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
1198 + else:
1199 + # SWA-only layers (ratio 0): no indexer topk; div by constexpr 0 is UB.
1200 + topk_len = 0
1201 +
1202 + for i in range(0, SWA_TOTAL_WIDTH, WINDOW_BLOCK_SIZE):
1203 + offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1204 + mask = offset < SWA_TOTAL_WIDTH
1205 + pos_offset = swa_start_pos + offset
1206 + block_indices = pos_offset // swa_block_size
1207 + block_numbers = tl.load(
1208 + swa_block_table_ptr + req_idx * swa_block_table_stride + block_indices,
1209 + mask=mask & (offset < swa_len),
1210 + other=-1,
1211 + )
1212 + block_offsets = pos_offset % swa_block_size
1213 + slot_ids = block_numbers * swa_block_size + block_offsets
1214 + slot_ids = tl.where(offset < swa_len, slot_ids, -1)
1215 + slot_ids = _remap_flashinfer_index(slot_ids, swa_block_size, swa_block_span)
1216 + tl.store(
1217 + sparse_indices_ptr + token_idx * sparse_indices_stride + offset,
1218 + slot_ids,
1219 + mask=mask,
1220 + )
1221 +
1222 + for i in range(0, PADDED_TOP_K, TOPK_BLOCK_SIZE):
1223 + offset = i + tl.arange(0, TOPK_BLOCK_SIZE)
1224 + mask = offset < PADDED_TOP_K
1225 + local_idx = tl.load(
1226 + prefill_topk_indices_ptr + prefill_idx * prefill_topk_stride + offset,
1227 + mask=(offset < PREFILL_TOPK_STRIDE) & (offset < topk_len),
1228 + other=-1,
1229 + )
1230 + is_valid = local_idx >= 0
1231 + block_indices = local_idx // compressed_block_size
1232 + block_numbers = tl.load(
1233 + compressed_block_table_ptr
1234 + + req_idx * compressed_block_table_stride
1235 + + block_indices,
1236 + mask=mask & is_valid,
1237 + other=-1,
1238 + )
1239 + block_offsets = local_idx % compressed_block_size
1240 + slot_ids = block_numbers * compressed_block_size + block_offsets
1241 + slot_ids = tl.where((offset < topk_len) & is_valid, slot_ids, -1)
1242 + slot_ids = _remap_flashinfer_index(
1243 + slot_ids, compressed_block_size, compressed_block_span
1244 + )
1245 + tl.store(
1246 + sparse_indices_ptr
1247 + + token_idx * sparse_indices_stride
1248 + + SWA_TOTAL_WIDTH
1249 + + offset,
1250 + slot_ids,
1251 + mask=mask,
1252 + )
1253 +
1254 + tl.store(sparse_topk_lens_ptr + token_idx, SWA_TOTAL_WIDTH + topk_len)
vllm/models/deepseek_v4_1/common/ops/fused_compress_quant_cache.py new
+383
@@ -0,0 +1,383 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""V4.1 state saving/compression and independently schedulable cache insertion."""
4 +
5 +import torch
6 +
7 +from vllm.platforms import current_platform
8 +from vllm.triton_utils import tl, triton
9 +
10 +if current_platform.is_rocm():
11 + from vllm.platforms.rocm import _ON_GFX950
12 +else:
13 + _ON_GFX950 = False
14 +
15 +# The ring and raw rows a ratio-2 request program pools are not adjacent, so
16 +# reading them as one tile means joining two pointers. ROCm's Triton fails to
17 +# legalize `tt.join` on pointers, so there the two rows are loaded separately.
18 +_JOIN_ROW_PTRS = not current_platform.is_rocm()
19 +
20 +
21 +def fused_save_compress_norm(
22 + kv_score: torch.Tensor,
23 + positions: torch.Tensor,
24 + state_cache: torch.Tensor | None,
25 + slot_mapping: torch.Tensor,
26 + query_start_loc: torch.Tensor | None,
27 + token_to_req_indices: torch.Tensor | None,
28 + rms_norm_weight: torch.Tensor,
29 + rms_norm_eps: float,
30 + compress_ratio: int,
31 + latent_out: torch.Tensor,
32 +) -> None:
33 + """Pool each closed group into a normalized BF16 latent; save FP32 states.
34 +
35 + The latent feeds the main-cache insert and the indexer K path, which the
36 + attention layer schedules on separate streams.
37 +
38 + Ratio 2 keeps one ring block per request holding the open group's rows:
39 + position ``p`` lives in row ``p % capacity`` and ``slot_mapping`` encodes
40 + ``block * capacity + p % capacity``. The grid has one program per request
41 + followed by one per pair of packed tokens. A request program handles the
42 + group that the chunk's first token closes with its predecessor's ring row,
43 + then stores the chunk's last ``capacity`` rows to the ring; because the
44 + same program does both, ring reads and writes never race. A pair program
45 + handles the group that ends inside its pair, reading both rows from the
46 + raw input. Ratio 1 has no ring and one program per token; ``slot_mapping``
47 + then only marks valid tokens.
48 +
49 + Args:
50 + kv_score: FP32 [tokens, 512] for CR1, [tokens, 1024] for CR2.
51 + positions: Absolute positions of the packed request tokens.
52 + state_cache: Ring FP32 [blocks, capacity, 1024] KV/score states (CR2).
53 + slot_mapping: Ring slots (CR2) or main-cache slots (CR1).
54 + query_start_loc: [num_reqs + 1] token offsets of each request's chunk.
55 + token_to_req_indices: Request indices for the packed token rows.
56 + rms_norm_weight: BF16 [512] normalization weight.
57 + rms_norm_eps: RMSNorm epsilon.
58 + compress_ratio: Group size, either 1 or 2.
59 + latent_out: BF16 [tokens, 512], written only at valid group boundaries.
60 + """
61 + assert compress_ratio in (1, 2)
62 + assert kv_score.dtype == torch.float32
63 + assert kv_score.shape[1] == 512 * compress_ratio and kv_score.stride(1) == 1
64 + assert latent_out.shape == (kv_score.shape[0], 512)
65 + assert latent_out.is_contiguous() and latent_out.dtype == torch.bfloat16
66 + assert positions.is_contiguous() and slot_mapping.is_contiguous()
67 + # Rows stay 64-byte aligned, as the kernel's tl.multiple_of hint promises.
68 + assert kv_score.stride(0) % 16 == 0
69 + if compress_ratio == 2:
70 + assert state_cache is not None and query_start_loc is not None
71 + assert token_to_req_indices is not None
72 + assert query_start_loc.is_contiguous()
73 + assert token_to_req_indices.is_contiguous()
74 + assert state_cache.dtype == torch.float32
75 + assert state_cache.shape[2] == 1024 and state_cache.stride(2) == 1
76 + assert state_cache.stride(1) % 16 == 0
77 + state_stride, state_row_stride, state_block = (
78 + state_cache.stride(0),
79 + state_cache.stride(1),
80 + state_cache.shape[1],
81 + )
82 + num_reqs = query_start_loc.numel() - 1
83 + else:
84 + state_cache = query_start_loc = token_to_req_indices = None
85 + state_stride = state_row_stride = state_block = 1
86 + num_reqs = 0
87 + num_tokens = slot_mapping.numel()
88 + assert num_tokens <= min(kv_score.shape[0], positions.numel())
89 + if num_tokens == 0:
90 + return
91 + grid = num_reqs + triton.cdiv(num_tokens, compress_ratio)
92 + _fused_save_compress_norm_kernel[(grid,)](
93 + kv_score,
94 + positions,
95 + state_cache,
96 + slot_mapping,
97 + query_start_loc,
98 + token_to_req_indices,
99 + rms_norm_weight,
100 + latent_out,
101 + num_tokens,
102 + num_reqs,
103 + RAW_STRIDE=kv_score.stride(0),
104 + STATE_STRIDE=state_stride,
105 + STATE_ROW_STRIDE=state_row_stride,
106 + STATE_BLOCK=state_block,
107 + COMPRESS_RATIO=compress_ratio,
108 + EPS=rms_norm_eps,
109 + JOIN_ROW_PTRS=_JOIN_ROW_PTRS,
110 + num_warps=4,
111 + **({"launch_pdl": False} if current_platform.is_cuda() else {}),
112 + )
113 +
114 +
115 +@triton.jit(do_not_specialize=["num_tokens", "num_reqs"])
116 +def _fused_save_compress_norm_kernel(
117 + raw,
118 + positions,
119 + state,
120 + state_slots,
121 + query_start_loc,
122 + req_ids,
123 + norm_weight,
124 + latent,
125 + num_tokens,
126 + num_reqs,
127 + RAW_STRIDE: tl.constexpr,
128 + STATE_STRIDE: tl.constexpr,
129 + STATE_ROW_STRIDE: tl.constexpr,
130 + STATE_BLOCK: tl.constexpr,
131 + COMPRESS_RATIO: tl.constexpr,
132 + EPS: tl.constexpr,
133 + JOIN_ROW_PTRS: tl.constexpr,
134 +):
135 + pid = tl.program_id(0)
136 + d = tl.arange(0, 512)
137 +
138 + if COMPRESS_RATIO == 2: # noqa: SIM102 (constexpr branch, then runtime)
139 + if pid < num_reqs:
140 + # Request program: the group closed by the chunk's first token,
141 + # then the ring tail store.
142 + start = tl.load(query_start_loc + pid)
143 + end = tl.load(query_start_loc + pid + 1)
144 + if start >= end:
145 + return
146 + state_slot = tl.load(state_slots + start)
147 + if state_slot < 0:
148 + return
149 + position = tl.load(positions + start)
150 + if (position + 1) % 2 == 0:
151 + ring = state + (state_slot // STATE_BLOCK).to(tl.int64) * STATE_STRIDE
152 + prev = ring + ((position - 1) % STATE_BLOCK) * STATE_ROW_STRIDE
153 + current = raw + start.to(tl.int64) * RAW_STRIDE
154 + if JOIN_ROW_PTRS:
155 + # Joining pointers hides row alignment from Triton; restate it.
156 + rows = tl.multiple_of(tl.join(prev, current)[:, None], (16, 16))
157 + kv = tl.load(rows + d[None, :])
158 + score = tl.load(rows + 512 + d[None, :])
159 + # Every lane has read the ring before the tail store below
160 + # writes it.
161 + tl.debug_barrier()
162 + pooled = tl.sum(kv * tl.softmax(score, 0), 0)
163 + else:
164 + kv_prev = tl.load(prev + d)
165 + score_prev = tl.load(prev + 512 + d)
166 + kv_current = tl.load(current + d)
167 + score_current = tl.load(current + 512 + d)
168 + # Every lane has read the ring before the tail store below
169 + # writes it.
170 + tl.debug_barrier()
171 + peak = tl.maximum(score_prev, score_current)
172 + weight_prev = tl.exp(score_prev - peak)
173 + weight_current = tl.exp(score_current - peak)
174 + pooled = (kv_prev * weight_prev + kv_current * weight_current) / (
175 + weight_prev + weight_current
176 + )
177 + _store_latent(pooled, start, norm_weight, latent, EPS)
178 + num_rows = tl.minimum(end - start, STATE_BLOCK)
179 + for k in tl.range(0, num_rows):
180 + token = end - num_rows + k
181 + slot = tl.load(state_slots + token)
182 + if slot >= 0:
183 + row = (
184 + state
185 + + (slot // STATE_BLOCK).to(tl.int64) * STATE_STRIDE
186 + + (slot % STATE_BLOCK) * STATE_ROW_STRIDE
187 + )
188 + src = raw + token.to(tl.int64) * RAW_STRIDE
189 + tl.store(row + d, tl.load(src + d))
190 + tl.store(row + 512 + d, tl.load(src + 512 + d))
191 + return
192 +
193 + # Group program: one token (ratio 1) or the token of the pair that ends
194 + # a group (ratio 2). A chunk's first token belongs to its request program.
195 + t = (pid - num_reqs) * COMPRESS_RATIO
196 + if COMPRESS_RATIO == 2:
197 + t += (tl.load(positions + t) + 1) % 2
198 + state_slot = tl.load(state_slots + t, t < num_tokens, other=-1)
199 + if state_slot < 0:
200 + return
201 + if COMPRESS_RATIO == 1:
202 + pooled = tl.load(raw + t.to(tl.int64) * RAW_STRIDE + d)
203 + else:
204 + if t == tl.load(query_start_loc + tl.load(req_ids + t)):
205 + return
206 + rows = raw + (t.to(tl.int64) - 1 + tl.arange(0, 2)[:, None]) * RAW_STRIDE
207 + kv = tl.load(rows + d[None, :])
208 + score = tl.load(rows + 512 + d[None, :])
209 + pooled = tl.sum(kv * tl.softmax(score, 0), 0)
210 + _store_latent(pooled, t, norm_weight, latent, EPS)
211 +
212 +
213 +@triton.jit
214 +def _store_latent(pooled, t, norm_weight, latent, EPS: tl.constexpr):
215 + d = tl.arange(0, 512)
216 + weight = tl.load(norm_weight + d).to(tl.float32)
217 + variance = tl.sum(pooled * pooled, 0) / 512
218 + normed = pooled * tl.rsqrt(variance + EPS) * weight
219 + tl.store(latent + t.to(tl.int64) * 512 + d, normed.to(tl.bfloat16))
220 +
221 +
222 +def rope_quant_insert(
223 + latent: torch.Tensor,
224 + positions: torch.Tensor,
225 + cos_sin_cache: torch.Tensor,
226 + kv_cache: torch.Tensor,
227 + slot_mapping: torch.Tensor,
228 + compress_ratio: int,
229 + fp8_scale: torch.Tensor | None = None,
230 +) -> None:
231 + """Apply GPT-J RoPE and publish a latent to the compressed KV cache.
232 +
233 + The BF16 latent supplies both NoPE quantization and RoPE input. It is read
234 + only for valid slots at group boundaries. The cache dtype selects the
235 + layout: ``uint8`` is the fp8_ds_mla paged layout (576 value bytes and eight
236 + segregated UE8M0 scale bytes per token, including one zero padding scale);
237 + ``bfloat16`` and ``float8_e4m3fn`` are the plain [448 NoPE | 64 RoPE] rows
238 + read by FlashInfer, the latter scaled by the per-tensor ``fp8_scale``.
239 + """
240 + assert compress_ratio in (1, 2)
241 + assert latent.shape[1] == 512 and latent.dtype == torch.bfloat16
242 + assert latent.is_contiguous()
243 + num_tokens = slot_mapping.numel()
244 + assert num_tokens <= min(latent.shape[0], positions.numel())
245 + if num_tokens == 0:
246 + return
247 + launch_kwargs = {"launch_pdl": False} if current_platform.is_cuda() else {}
248 + if kv_cache.dtype == torch.uint8:
249 + assert kv_cache.shape[-1] == 584
250 + _rope_quant_insert_kernel[(num_tokens,)](
251 + latent,
252 + positions,
253 + cos_sin_cache,
254 + kv_cache,
255 + slot_mapping,
256 + COS_STRIDE=cos_sin_cache.stride(0),
257 + CACHE_STRIDE=kv_cache.stride(0),
258 + CACHE_BLOCK=kv_cache.shape[1],
259 + COMPRESS_RATIO=compress_ratio,
260 + SANITIZE_CACHE_NANS=_ON_GFX950,
261 + num_warps=4,
262 + **launch_kwargs,
263 + )
264 + return
265 +
266 + assert kv_cache.dtype in (torch.bfloat16, torch.float8_e4m3fn)
267 + assert kv_cache.shape[-1] == 512 and kv_cache.stride(-1) == 1
268 + store_fp8 = kv_cache.dtype == torch.float8_e4m3fn
269 + if store_fp8:
270 + assert fp8_scale is not None and fp8_scale.numel() == 1
271 + assert fp8_scale.dtype == torch.float32
272 + _rope_plain_insert_kernel[(num_tokens,)](
273 + latent,
274 + positions,
275 + cos_sin_cache,
276 + kv_cache,
277 + slot_mapping,
278 + fp8_scale if store_fp8 else None,
279 + COS_STRIDE=cos_sin_cache.stride(0),
280 + CACHE_STRIDE=kv_cache.stride(0),
281 + ROW_STRIDE=kv_cache.stride(1),
282 + CACHE_BLOCK=kv_cache.shape[1],
283 + COMPRESS_RATIO=compress_ratio,
284 + STORE_FP8=store_fp8,
285 + num_warps=4,
286 + **launch_kwargs,
287 + )
288 +
289 +
290 +@triton.jit
291 +def _rope_quant_insert_kernel(
292 + latent,
293 + positions,
294 + cos_sin,
295 + cache,
296 + cache_slots,
297 + COS_STRIDE: tl.constexpr,
298 + CACHE_STRIDE: tl.constexpr,
299 + CACHE_BLOCK: tl.constexpr,
300 + COMPRESS_RATIO: tl.constexpr,
301 + SANITIZE_CACHE_NANS: tl.constexpr,
302 +):
303 + t = tl.program_id(0)
304 + slot = tl.load(cache_slots + t)
305 + if slot < 0:
306 + return
307 + position = tl.load(positions + t)
308 + if (position + 1) % COMPRESS_RATIO != 0:
309 + return
310 + d = tl.arange(0, 512)
311 + normed = tl.load(latent + t.to(tl.int64) * 512 + d).to(tl.float32)
312 + page = cache + (slot // CACHE_BLOCK).to(tl.int64) * CACHE_STRIDE
313 + values = page + (slot % CACHE_BLOCK) * 576
314 + scales = page + CACHE_BLOCK * 576 + (slot % CACHE_BLOCK) * 8
315 +
316 + quant = tl.reshape(normed, (8, 64))
317 + amax = tl.maximum(tl.max(tl.abs(quant), 1), 1e-4)
318 + exponent = tl.ceil(tl.log2(amax * (1.0 / 448.0)))
319 + scaled = quant * tl.reshape(tl.exp2(-exponent), (8, 1))
320 + fp8 = tl.clamp(scaled, -448.0, 448.0).to(tl.float8e4nv)
321 + packed = tl.reshape(fp8.to(tl.uint8, bitcast=True), (512,))
322 + tl.store(values + d, packed, d < 448)
323 + s = tl.arange(0, 8)
324 + max_encoded: tl.constexpr = 254.0 if SANITIZE_CACHE_NANS else 255.0
325 + encoded = tl.minimum(tl.maximum(exponent + 127.0, 0.0), max_encoded)
326 + tl.store(scales + s, encoded.to(tl.uint8), s < 7)
327 + tl.store(scales + 7, tl.full((), 0, tl.uint8))
328 +
329 + even, odd = tl.split(tl.reshape(normed, (256, 2)))
330 + pair = tl.arange(0, 256) - 224
331 + cs = cos_sin + (position // COMPRESS_RATIO * COMPRESS_RATIO) * COS_STRIDE
332 + c = tl.load(cs + tl.maximum(pair, 0), pair >= 0, other=1.0).to(tl.float32)
333 + s = tl.load(cs + 32 + tl.maximum(pair, 0), pair >= 0, other=0.0).to(tl.float32)
334 + rotated = tl.interleave(even * c - odd * s, odd * c + even * s)
335 + if SANITIZE_CACHE_NANS:
336 + rotated = tl.where(rotated == rotated, rotated, 0.0)
337 + rope_dst = (values + 448).to(tl.pointer_type(tl.bfloat16))
338 + tl.store(rope_dst + d - 448, rotated.to(tl.bfloat16), d >= 448)
339 +
340 +
341 +@triton.jit
342 +def _rope_plain_insert_kernel(
343 + latent,
344 + positions,
345 + cos_sin,
346 + cache,
347 + cache_slots,
348 + fp8_scale,
349 + COS_STRIDE: tl.constexpr,
350 + CACHE_STRIDE: tl.constexpr,
351 + ROW_STRIDE: tl.constexpr,
352 + CACHE_BLOCK: tl.constexpr,
353 + COMPRESS_RATIO: tl.constexpr,
354 + STORE_FP8: tl.constexpr,
355 +):
356 + t = tl.program_id(0)
357 + slot = tl.load(cache_slots + t)
358 + if slot < 0:
359 + return
360 + position = tl.load(positions + t)
361 + if (position + 1) % COMPRESS_RATIO != 0:
362 + return
363 + d = tl.arange(0, 512)
364 + normed = tl.load(latent + t.to(tl.int64) * 512 + d).to(tl.float32)
365 +
366 + # NoPE pairs load (cos, sin) = (1, 0), so the rotation is the identity there.
367 + even, odd = tl.split(tl.reshape(normed, (256, 2)))
368 + pair = tl.arange(0, 256) - 224
369 + cs = cos_sin + (position // COMPRESS_RATIO * COMPRESS_RATIO) * COS_STRIDE
370 + c = tl.load(cs + tl.maximum(pair, 0), pair >= 0, other=1.0).to(tl.float32)
371 + s = tl.load(cs + 32 + tl.maximum(pair, 0), pair >= 0, other=0.0).to(tl.float32)
372 + row = tl.interleave(even * c - odd * s, odd * c + even * s).to(tl.bfloat16)
373 +
374 + dst = (
375 + cache
376 + + (slot // CACHE_BLOCK).to(tl.int64) * CACHE_STRIDE
377 + + (slot % CACHE_BLOCK) * ROW_STRIDE
378 + )
379 + if STORE_FP8:
380 + scaled = row.to(tl.float32) * (1.0 / tl.load(fp8_scale))
381 + tl.store(dst + d, tl.clamp(scaled, -448.0, 448.0).to(tl.float8e4nv))
382 + else:
383 + tl.store(dst + d, row)
vllm/models/deepseek_v4_1/common/ops/indexer_k_store.py new
+253
@@ -0,0 +1,253 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Indexer K production for DeepSeek V4.1 kv-source layers.
4 +
5 +In v4.1 the index key is derived from the *main* compressor's latent:
6 +``k = k_norm(wk(latent))`` (reference model.py Indexer.forward), then RoPE'd
7 +at the group's first-token position and MXFP4/FP8-quantized into the paged
8 +indexer K cache. The ``wk(latent)`` GEMM runs in torch; this kernel fuses the
9 +remaining k_norm → RoPE → quant → paged store, one program per token.
10 +
11 +Unlike the legacy (v4.0) indexer path there is no per-token pooling from a
12 +compressor state cache: the latent already stands for a whole group, so only
13 +group-boundary tokens ``(position + 1) % compress_ratio == 0`` produce a key.
14 +"""
15 +
16 +import torch
17 +
18 +from vllm.platforms import current_platform
19 +from vllm.triton_utils import tl, triton
20 +
21 +from . import MXFP4_BLOCK_SIZE, _fp32x2_to_fp4x2
22 +
23 +# ROCm tiled ("SHUFFLE") indexer K value layout, mirroring
24 +# indexer_k_quant_and_cache_triton's defaults: 16 positions x 16 bytes.
25 +_BLOCK_TILE_SIZE = 16
26 +_HEAD_TILE_SIZE = 16
27 +
28 +
29 +def indexer_k_norm_rope_store(
30 + k_pre: torch.Tensor,
31 + positions: torch.Tensor,
32 + cos_sin_cache: torch.Tensor,
33 + rms_norm_weight: torch.Tensor,
34 + rms_norm_eps: float,
35 + k_cache: torch.Tensor,
36 + kv_slot_mapping: torch.Tensor,
37 + compress_ratio: int,
38 + use_fp4_cache: bool,
39 +) -> None:
40 + """k_norm → RoPE → quant → paged store for indexer keys.
41 +
42 + Args:
43 + k_pre: [num_tokens, 128] bf16, the ``wk(latent)`` projection. Only
44 + group-boundary rows are read.
45 + positions: [num_tokens] int64 token positions.
46 + cos_sin_cache: [max_pos, rope_head_dim] GPT-J layout (cos half, then
47 + sin half), from the layer's compress-RoPE instance.
48 + rms_norm_weight: [128] k_norm weight.
49 + k_cache: uint8 paged indexer cache [num_blocks, block_size, row_bytes].
50 + kv_slot_mapping: [num_tokens] slots in the indexer cache (-1 = skip).
51 + compress_ratio: group size; keys are emitted at group boundaries.
52 + use_fp4_cache: MXFP4 (2 nibbles/byte + ue8m0 per 32) when True, else
53 + per-token FP8 with a single fp32 scale.
54 + """
55 + num_tokens = kv_slot_mapping.numel()
56 + assert k_pre.ndim == 2 and k_pre.shape[1] == 128
57 + assert k_pre.dtype == torch.bfloat16 and k_pre.stride(1) == 1
58 + assert num_tokens <= k_pre.shape[0] and num_tokens <= positions.numel()
59 + assert compress_ratio in (1, 2)
60 + if num_tokens == 0:
61 + return
62 +
63 + head_dim = k_pre.shape[1]
64 + if use_fp4_cache:
65 + token_stride = head_dim // 2
66 + scale_dim = head_dim // MXFP4_BLOCK_SIZE
67 + else:
68 + token_stride = head_dim
69 + scale_dim = 4 # single float32 scale
70 +
71 + # ROCm reads this cache back with the 16x16-tiled ("SHUFFLE") value
72 + # layout: cp_gather_indexer_k_quant_cache_triton on prefill and aiter's
73 + # deepgemm_fp8_paged_mqa_logits(Preshuffle=True) on decode both select it
74 + # from ``block_size > 1``, matching the v4.0 writer
75 + # (indexer_k_quant_and_cache_triton). Writing row-major here would hand
76 + # both readers permuted key bytes.
77 + block_size = k_cache.shape[1]
78 + shuffle = current_platform.is_rocm() and block_size > 1
79 + if shuffle:
80 + if use_fp4_cache:
81 + raise NotImplementedError(
82 + "MXFP4 indexer K cache has no tiled ROCm layout; "
83 + "the ROCm readers only implement the FP8 one."
84 + )
85 + if block_size % _BLOCK_TILE_SIZE != 0 or head_dim % _HEAD_TILE_SIZE != 0:
86 + raise ValueError(
87 + f"ROCm tiled indexer K cache needs block_size "
88 + f"({block_size}) % {_BLOCK_TILE_SIZE} == 0 and head_dim "
89 + f"({head_dim}) % {_HEAD_TILE_SIZE} == 0."
90 + )
91 +
92 + launch_kwargs = {"launch_pdl": False} if current_platform.is_cuda() else {}
93 + _indexer_k_norm_rope_quant_store_kernel[(num_tokens,)](
94 + k_pre,
95 + k_pre.stride(0),
96 + positions,
97 + rms_norm_weight,
98 + rms_norm_eps,
99 + cos_sin_cache,
100 + cos_sin_cache.stride(0),
101 + k_cache,
102 + kv_slot_mapping,
103 + k_cache.shape[1],
104 + HEAD_SIZE=head_dim,
105 + ROPE_HEAD_DIM=64,
106 + COMPRESS_RATIO=compress_ratio,
107 + TOKEN_STRIDE=token_stride,
108 + SCALE_DIM=scale_dim,
109 + KV_BLOCK_STRIDE=k_cache.stride(0),
110 + FP8_MAX=448.0,
111 + USE_FP4=use_fp4_cache,
112 + SHUFFLE=shuffle,
113 + BLOCK_TILE_SIZE=_BLOCK_TILE_SIZE,
114 + HEAD_TILE_SIZE=_HEAD_TILE_SIZE,
115 + num_warps=1,
116 + **launch_kwargs,
117 + )
118 +
119 +
120 +@triton.jit
121 +def _indexer_k_norm_rope_quant_store_kernel(
122 + k_pre_ptr,
123 + k_pre_stride,
124 + positions_ptr,
125 + rms_norm_weight_ptr,
126 + rms_norm_eps,
127 + cos_sin_cache_ptr,
128 + cos_sin_stride,
129 + k_cache_ptr,
130 + kv_slot_mapping_ptr,
131 + kv_cache_block_size,
132 + HEAD_SIZE: tl.constexpr,
133 + ROPE_HEAD_DIM: tl.constexpr,
134 + COMPRESS_RATIO: tl.constexpr,
135 + TOKEN_STRIDE: tl.constexpr,
136 + SCALE_DIM: tl.constexpr,
137 + KV_BLOCK_STRIDE: tl.constexpr,
138 + FP8_MAX: tl.constexpr,
139 + USE_FP4: tl.constexpr,
140 + SHUFFLE: tl.constexpr,
141 + BLOCK_TILE_SIZE: tl.constexpr,
142 + HEAD_TILE_SIZE: tl.constexpr,
143 +):
144 + token_idx = tl.program_id(0)
145 +
146 + kv_slot_idx = tl.load(kv_slot_mapping_ptr + token_idx)
147 + if kv_slot_idx < 0:
148 + return
149 + position = tl.load(positions_ptr + token_idx)
150 + # Only the last token of a group publishes that group's index key.
151 + if (position + 1) % COMPRESS_RATIO != 0:
152 + return
153 +
154 + block = tl.arange(0, HEAD_SIZE)
155 +
156 + # ── k_norm (fp32 throughout, bf16 roundtrip like the reference) ────
157 + k = tl.load(k_pre_ptr + token_idx * k_pre_stride + block).to(tl.float32)
158 + rms_w = tl.load(rms_norm_weight_ptr + block).to(tl.float32)
159 + variance = tl.sum(k * k, axis=0) / HEAD_SIZE
160 + k = (k * tl.rsqrt(variance + rms_norm_eps) * rms_w).to(tl.bfloat16)
161 + k = k.to(tl.float32)
162 +
163 + # ── Register-based GPT-J forward RoPE in fp32 ─────────────────────
164 + # A latent stands for the first token of its group, so group j takes
165 + # position j * compress_ratio.
166 + NUM_PAIRS: tl.constexpr = HEAD_SIZE // 2
167 + NOPE_HEAD_DIM: tl.constexpr = HEAD_SIZE - ROPE_HEAD_DIM
168 + NOPE_PAIRS: tl.constexpr = NOPE_HEAD_DIM // 2
169 + HALF_ROPE: tl.constexpr = ROPE_HEAD_DIM // 2
170 +
171 + even, odd = tl.split(tl.reshape(k, (NUM_PAIRS, 2))) # each [NUM_PAIRS]
172 + pair_idx = tl.arange(0, NUM_PAIRS)
173 + rope_pair_local = pair_idx - NOPE_PAIRS
174 + is_rope_pair = rope_pair_local >= 0
175 + cs_idx = tl.maximum(rope_pair_local, 0)
176 +
177 + compressed_pos = (position // COMPRESS_RATIO) * COMPRESS_RATIO
178 + cache_base = cos_sin_cache_ptr + compressed_pos * cos_sin_stride
179 + cos_v = tl.load(cache_base + cs_idx, mask=is_rope_pair, other=1.0)
180 + sin_v = tl.load(cache_base + HALF_ROPE + cs_idx, mask=is_rope_pair, other=0.0)
181 +
182 + new_even = even * cos_v - odd * sin_v
183 + new_odd = odd * cos_v + even * sin_v
184 +
185 + # bf16 roundtrip for parity with the reference / Q-side kernel numerics.
186 + new_even = new_even.to(tl.bfloat16).to(tl.float32)
187 + new_odd = new_odd.to(tl.bfloat16).to(tl.float32)
188 +
189 + # ── Paged cache pointers (segregated: values first, then scales) ──
190 + kv_block_idx = kv_slot_idx // kv_cache_block_size
191 + kv_pos_in_block = kv_slot_idx % kv_cache_block_size
192 + cache_block_ptr = k_cache_ptr + kv_block_idx.to(tl.int64) * KV_BLOCK_STRIDE
193 + if SHUFFLE:
194 + # [block_size // BT, HEAD_SIZE // HT, BT, HT] value tiles.
195 + val_ptr = (
196 + cache_block_ptr
197 + + (kv_pos_in_block // BLOCK_TILE_SIZE) * BLOCK_TILE_SIZE * TOKEN_STRIDE
198 + + (kv_pos_in_block % BLOCK_TILE_SIZE) * HEAD_TILE_SIZE
199 + )
200 + else:
201 + val_ptr = cache_block_ptr + kv_pos_in_block * TOKEN_STRIDE
202 + scale_ptr = (
203 + cache_block_ptr
204 + + kv_cache_block_size * TOKEN_STRIDE
205 + + kv_pos_in_block * SCALE_DIM
206 + )
207 +
208 + if USE_FP4:
209 + # MXFP4: each 32-element block = 16 consecutive even/odd pairs, so
210 + # tiling the halves into (N_BLOCKS, 16) lands one block per row.
211 + N_QUANT_BLOCKS: tl.constexpr = HEAD_SIZE // 32
212 + HALF_BLOCK: tl.constexpr = 16
213 + even_2d = tl.reshape(new_even, (N_QUANT_BLOCKS, HALF_BLOCK))
214 + odd_2d = tl.reshape(new_odd, (N_QUANT_BLOCKS, HALF_BLOCK))
215 +
216 + amax = tl.maximum(
217 + tl.max(tl.abs(even_2d), axis=1),
218 + tl.max(tl.abs(odd_2d), axis=1),
219 + )
220 + amax = tl.maximum(amax, 6.0 * (2**-126))
221 + # ue8m0 block scale: 2^ceil(log2(amax / 6.0)), stored (exp + 127).
222 + log2_ratio = tl.ceil(tl.log2(amax * (1.0 / 6.0)))
223 + log2_ratio = tl.minimum(tl.maximum(log2_ratio, -127.0), 127.0)
224 + inv_scale = tl.exp2(-log2_ratio)
225 + ue8m0 = (log2_ratio + 127.0).to(tl.uint8) # [N_QUANT_BLOCKS]
226 +
227 + inv_scale_col = tl.reshape(inv_scale, (N_QUANT_BLOCKS, 1))
228 + packed = _fp32x2_to_fp4x2(
229 + even_2d * inv_scale_col, odd_2d * inv_scale_col
230 + ) # (N_BLOCKS, HALF_BLOCK) uint8
231 + packed_flat = tl.reshape(packed, (TOKEN_STRIDE,))
232 +
233 + tl.store(val_ptr + tl.arange(0, TOKEN_STRIDE), packed_flat)
234 + tl.store(scale_ptr + tl.arange(0, SCALE_DIM), ue8m0)
235 + else:
236 + # Per-token FP8 (single 128-wide block) with one float32 scale.
237 + result = tl.interleave(new_even, new_odd) # [HEAD_SIZE] fp32
238 + result_bf16 = result.to(tl.bfloat16).to(tl.float32)
239 + INV_FP8_MAX: tl.constexpr = 1.0 / FP8_MAX
240 + absmax = tl.maximum(tl.max(tl.abs(result_bf16), axis=0), 1e-4)
241 + exponent = tl.ceil(tl.log2(absmax * INV_FP8_MAX))
242 + inv_scale = tl.exp2(-exponent)
243 + x_clamped = tl.clamp(result_bf16 * inv_scale, -FP8_MAX, FP8_MAX)
244 + x_uint8 = x_clamped.to(tl.float8e4nv).to(tl.uint8, bitcast=True)
245 + if SHUFFLE:
246 + tiled = (
247 + block // HEAD_TILE_SIZE * BLOCK_TILE_SIZE * HEAD_TILE_SIZE
248 + + block % HEAD_TILE_SIZE
249 + )
250 + tl.store(val_ptr + tiled, x_uint8)
251 + else:
252 + tl.store(val_ptr + block, x_uint8)
253 + tl.store(scale_ptr.to(tl.pointer_type(tl.float32)), tl.exp2(exponent))
vllm/models/deepseek_v4_1/common/ops/query_quant.py new
+142
@@ -0,0 +1,142 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import torch
5 +
6 +from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
7 +from vllm.model_executor.layers.quantization.utils.quant_utils import kMxfp8Dynamic
8 +from vllm.platforms import current_platform
9 +from vllm.triton_utils import tl, triton
10 +
11 +
12 +@triton.jit
13 +def _norm_row(x, weight, row, stride, eps, SIZE: tl.constexpr, BLOCK: tl.constexpr):
14 + cols = tl.arange(0, BLOCK)
15 + w = tl.load(weight + cols, cols < SIZE, 0).to(tl.float32)
16 + values = tl.load(x + row * stride + cols, cols < SIZE, 0).to(tl.float32)
17 + rrms = tl.rsqrt(tl.sum(values * values, 0) / SIZE + eps)
18 + return values * rrms * w
19 +
20 +
21 +@triton.jit(do_not_specialize=["num_tokens"])
22 +def _q_kv_norm_quant_kernel(
23 + q,
24 + kv,
25 + qw,
26 + kvw,
27 + qo,
28 + kvo,
29 + scales,
30 + num_tokens,
31 + q_stride,
32 + kv_stride,
33 + eps,
34 + Q_SIZE: tl.constexpr,
35 + KV_SIZE: tl.constexpr,
36 + BLOCK: tl.constexpr,
37 + LAUNCH_PDL: tl.constexpr,
38 +):
39 + row = tl.program_id(0).to(tl.int64)
40 + cols = tl.arange(0, BLOCK)
41 + if LAUNCH_PDL:
42 + tl.extra.cuda.gdc_wait()
43 + tl.extra.cuda.gdc_launch_dependents()
44 + if tl.program_id(1) == 0:
45 + groups = tl.arange(0, BLOCK // 32)
46 + if row < num_tokens:
47 + y = _norm_row(q, qw, row, q_stride, eps, Q_SIZE, BLOCK)
48 + # Preserve the materialized normalization output's rounding boundary.
49 + y = y.to(q.dtype.element_ty).to(tl.float32)
50 + grouped = tl.reshape(y, (BLOCK // 32, 32))
51 + amax = tl.max(tl.abs(grouped), 1)
52 + normalized = amax * (1.0 / 448.0)
53 + bits = normalized.to(tl.uint32, bitcast=True)
54 + exponent = (bits >> 23) & 255
55 + mantissa = bits & 0x7FFFFF
56 + bump = (mantissa != 0) & ~((exponent == 0) & (mantissa <= 0x400000))
57 + sf = tl.minimum(exponent + bump, 254)
58 + sf = tl.where(normalized <= 0, 0, sf)
59 + # Match FlashInfer's UE8M0 conversion, including zero/subnormal scales.
60 + inv_bits = tl.where(sf == 0, 0, (254 - sf) << 23)
61 + inv_scale = inv_bits.to(tl.float32, bitcast=True)
62 + quantized = tl.reshape(grouped * inv_scale[:, None], (BLOCK,))
63 + tl.store(qo + row * Q_SIZE + cols, quantized, cols < Q_SIZE)
64 + else:
65 + sf = tl.full((BLOCK // 32,), 0, tl.uint32)
66 + padded_groups: tl.constexpr = triton.cdiv(Q_SIZE // 32, 4) * 4
67 + sf = tl.where(groups < Q_SIZE // 32, sf, 0)
68 + # F8_128x4: [row/128, group/4, row%32, row%128/32, group%4].
69 + offsets = (
70 + row // 128 * (128 * padded_groups)
71 + + groups // 4 * 512
72 + + row % 32 * 16
73 + + row % 128 // 32 * 4
74 + + groups % 4
75 + )
76 + tl.store(scales + offsets, sf, groups < padded_groups)
77 + elif row < num_tokens:
78 + y = _norm_row(kv, kvw, row, kv_stride, eps, KV_SIZE, BLOCK)
79 + tl.store(kvo + row * KV_SIZE + cols, y, cols < KV_SIZE)
80 +
81 +
82 +def fused_q_kv_rmsnorm_quant(
83 + qr: torch.Tensor,
84 + kv: torch.Tensor,
85 + q_weight: torch.Tensor,
86 + kv_weight: torch.Tensor,
87 + eps: float,
88 +) -> tuple[QuantizedActivation, torch.Tensor]:
89 + """Normalize Q/KV and quantize Q with FlashInfer's swizzled MXFP8 scales."""
90 + assert qr.ndim == kv.ndim == 2 and qr.shape[0] == kv.shape[0]
91 + assert qr.stride(-1) == kv.stride(-1) == 1
92 + assert q_weight.is_contiguous() and kv_weight.is_contiguous()
93 + assert qr.shape[1] % 32 == 0
94 + tokens, q_size = qr.shape
95 + kv_size = kv.shape[1]
96 + qo = torch.empty(qr.shape, dtype=torch.float8_e4m3fn, device=qr.device)
97 + kvo = torch.empty(kv.shape, dtype=kv.dtype, device=kv.device)
98 + padded_tokens = triton.cdiv(tokens, 128) * 128
99 + padded_groups = triton.cdiv(q_size // 32, 4) * 4
100 + scales = torch.empty(
101 + padded_tokens * padded_groups, dtype=torch.uint8, device=qr.device
102 + )
103 + if tokens:
104 + block = triton.next_power_of_2(max(q_size, kv_size))
105 + _q_kv_norm_quant_kernel[(padded_tokens, 2)](
106 + qr,
107 + kv,
108 + q_weight,
109 + kv_weight,
110 + qo,
111 + kvo,
112 + scales,
113 + tokens,
114 + qr.stride(0),
115 + kv.stride(0),
116 + eps,
117 + q_size,
118 + kv_size,
119 + block,
120 + current_platform.is_arch_support_pdl(),
121 + num_warps=8 if block >= 2048 else 4,
122 + )
123 + return QuantizedActivation(qo, scales, qr.dtype, qr.shape, kMxfp8Dynamic), kvo
124 +
125 +
126 +def can_fuse_query_quant(linears: list[torch.nn.Module]) -> bool:
127 + """Require both local projections to use the same MXFP8 activation ABI."""
128 + if not current_platform.is_cuda():
129 + return False
130 + from vllm.model_executor.kernels.linear.mxfp8.flashinfer import (
131 + FlashInferCutedslMxfp8LinearKernel,
132 + FlashInferCutlassMxfp8LinearKernel,
133 + )
134 +
135 + # QuantKey does not encode scale layout; restrict this producer to the
136 + # consumers that accept F8_128x4 swizzled scales.
137 + return all(
138 + getattr(linear, "input_quant_key", None) == kMxfp8Dynamic
139 + and type(getattr(getattr(linear, "quant_method", None), "kernel", None))
140 + in (FlashInferCutedslMxfp8LinearKernel, FlashInferCutlassMxfp8LinearKernel)
141 + for linear in linears
142 + )
vllm/models/deepseek_v4_1/common/rope.py new
+53
@@ -0,0 +1,53 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepseekV4 rotary embedding initialization."""
4 +
5 +from vllm.model_executor.layers.rotary_embedding import get_rope
6 +from vllm.model_executor.layers.rotary_embedding.base import RotaryEmbedding
7 +
8 +
9 +def build_deepseek_v4_rope(
10 + config,
11 + *,
12 + head_dim: int,
13 + rope_head_dim: int,
14 + max_position_embeddings: int,
15 + compress_ratio: int,
16 +) -> RotaryEmbedding:
17 + rope_parameters = config.rope_parameters
18 + # Newer checkpoints nest per-layer-type rope dicts ({"main", "compress"});
19 + # older ones ship a single flat dict shared by all layer types.
20 + if isinstance(rope_parameters.get("main"), dict) and isinstance(
21 + rope_parameters.get("compress"), dict
22 + ):
23 + key = "compress" if compress_ratio > 0 else "main"
24 + rope_parameters = dict(rope_parameters[key])
25 + else:
26 + rope_parameters = dict(rope_parameters)
27 +
28 + rope_parameters["rope_theta"] = (
29 + config.compress_rope_theta if compress_ratio > 0 else config.rope_theta
30 + )
31 + if compress_ratio > 0 and rope_parameters["rope_type"] != "default":
32 + # YaRN applies to all compressed-KV layers (compress_ratio > 0); only
33 + # pure sliding-window layers (compress_ratio == 0) use plain RoPE.
34 + rope_parameters["rope_type"] = (
35 + "deepseek_yarn"
36 + if rope_parameters.get("apply_yarn_scaling", True)
37 + else "deepseek_llama_scaling"
38 + )
39 + else:
40 + # Sliding-window layers use plain RoPE (theta=rope_theta, no YaRN).
41 + rope_parameters["rope_type"] = "deepseek_yarn"
42 + rope_parameters["factor"] = 1.0
43 + rope_parameters["original_max_position_embeddings"] = max_position_embeddings
44 + rope_parameters["mscale"] = 0 # Disable mscale
45 + rope_parameters["mscale_all_dim"] = 0 # Disable mscale
46 + rope_parameters["is_deepseek_v4"] = True
47 + rope_parameters["rope_dim"] = rope_head_dim
48 + return get_rope(
49 + head_dim,
50 + max_position=max_position_embeddings,
51 + rope_parameters=rope_parameters,
52 + is_neox_style=False,
53 + )
vllm/models/deepseek_v4_1/compressor.py new
+323
@@ -0,0 +1,323 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from dataclasses import dataclass
5 +from typing import Any, ClassVar, cast
6 +
7 +import torch
8 +from torch import nn
9 +
10 +from vllm.config import VllmConfig, get_current_vllm_config
11 +from vllm.forward_context import get_forward_context
12 +from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
13 +from vllm.model_executor.layers.layernorm import RMSNorm
14 +from vllm.model_executor.layers.linear import MergedColumnParallelLinear
15 +from vllm.models.deepseek_v4_1.common.ops.fused_compress_quant_cache import (
16 + fused_save_compress_norm,
17 + rope_quant_insert,
18 +)
19 +from vllm.platforms import current_platform
20 +from vllm.triton_utils import tl, triton
21 +from vllm.v1.attention.backend import (
22 + AttentionBackend,
23 + AttentionCGSupport,
24 + AttentionMetadataBuilder,
25 + CommonAttentionMetadata,
26 + MultipleOf,
27 +)
28 +from vllm.v1.kv_cache_interface import CircularBufferSpec, KVCacheSpec
29 +
30 +
31 +class CompressorBackend(AttentionBackend):
32 + def __init__(self):
33 + super().__init__()
34 +
35 + @staticmethod
36 + def get_name() -> str:
37 + return "CompressorBackend"
38 +
39 + @staticmethod
40 + def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
41 + return [MultipleOf(1)]
42 +
43 + @classmethod
44 + def get_supported_head_sizes(cls) -> list[int]:
45 + return [512, 1024]
46 +
47 + @staticmethod
48 + def get_builder_cls() -> type["CompressorMetadataBuilder"]:
49 + return CompressorMetadataBuilder
50 +
51 +
52 +@dataclass
53 +class CompressorMetadata:
54 + # [num_tokens] ring slot of every token: block * capacity + pos % capacity.
55 + slot_mapping: torch.Tensor
56 + query_start_loc: torch.Tensor # [num_reqs + 1]
57 + token_to_req_indices: torch.Tensor # [num_tokens]
58 +
59 +
60 +@triton.jit
61 +def _ring_slot_mapping_kernel(
62 + slot_mapping_ptr,
63 + block_table_ptr,
64 + block_table_stride,
65 + token_to_req_ptr,
66 + positions_ptr,
67 + num_actual_tokens,
68 + num_tokens,
69 + CAPACITY: tl.constexpr,
70 + BLOCK: tl.constexpr,
71 +):
72 + offsets = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
73 + valid = offsets < num_actual_tokens
74 + req = tl.load(token_to_req_ptr + offsets, mask=valid, other=0).to(tl.int64)
75 + block = tl.load(block_table_ptr + req * block_table_stride, mask=valid, other=0)
76 + pos = tl.load(positions_ptr + offsets, mask=valid, other=0)
77 + slot = block.to(tl.int64) * CAPACITY + pos % CAPACITY
78 + tl.store(
79 + slot_mapping_ptr + offsets, tl.where(valid, slot, -1), mask=offsets < num_tokens
80 + )
81 +
82 +
83 +class CompressorMetadataBuilder(AttentionMetadataBuilder):
84 + _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
85 +
86 + def __init__(self, *args, **kwargs):
87 + super().__init__(*args, **kwargs)
88 + assert isinstance(self.kv_cache_spec, CircularBufferSpec)
89 + self.capacity = self.kv_cache_spec.block_size
90 + max_num_batched_tokens = (
91 + self.vllm_config.scheduler_config.max_num_batched_tokens
92 + )
93 + self.token_to_req_indices = torch.zeros(
94 + max_num_batched_tokens, dtype=torch.int32, device=self.device
95 + )
96 + self.slot_mapping_buffer = torch.empty(
97 + max_num_batched_tokens, dtype=torch.int64, device=self.device
98 + )
99 +
100 + def build(
101 + self,
102 + common_prefix_len: int,
103 + common_attn_metadata: CommonAttentionMetadata,
104 + fast_build: bool = False,
105 + ) -> CompressorMetadata:
106 + num_tokens = common_attn_metadata.slot_mapping.numel()
107 + positions = common_attn_metadata.positions
108 + assert positions is not None
109 + token_to_req_indices = common_attn_metadata.token_to_req_indices(
110 + self.token_to_req_indices
111 + )
112 + slot_mapping = self.slot_mapping_buffer[:num_tokens]
113 + block_table = common_attn_metadata.block_table_tensor
114 + _ring_slot_mapping_kernel[(triton.cdiv(num_tokens, 256),)](
115 + slot_mapping,
116 + block_table,
117 + block_table.stride(0),
118 + token_to_req_indices,
119 + positions,
120 + common_attn_metadata.num_actual_tokens,
121 + num_tokens,
122 + CAPACITY=self.capacity,
123 + BLOCK=256,
124 + )
125 + return CompressorMetadata(
126 + slot_mapping=slot_mapping,
127 + query_start_loc=common_attn_metadata.query_start_loc,
128 + token_to_req_indices=token_to_req_indices,
129 + )
130 +
131 +
132 +class CompressorStateCache(torch.nn.Module, AttentionLayerBase):
133 + """Per-request ring of the open group's [kv, score] rows (ratio > 1)."""
134 +
135 + def __init__(self, state_dim: int, dtype: torch.dtype, prefix: str):
136 + super().__init__()
137 + self.state_dim = state_dim
138 + self.dtype = dtype
139 + self.prefix = prefix
140 + self.kv_cache = torch.tensor([])
141 + compilation_config = get_current_vllm_config().compilation_config
142 + if prefix in compilation_config.static_forward_context:
143 + raise ValueError(f"Duplicate layer name: {prefix}")
144 + compilation_config.static_forward_context[prefix] = self
145 +
146 + assert self.dtype == torch.float32
147 + # Drafts + bonus token + the previous row, as a power of two >= 8.
148 + rows_per_step = get_current_vllm_config().num_speculative_tokens + 2
149 + self.block_size = max(8, 1 << (rows_per_step - 1).bit_length())
150 +
151 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
152 + # [B, H=1, N, C] -> [B, N, C]
153 + self.kv_cache = kv_cache.squeeze(1)
154 +
155 + def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
156 + del vllm_config
157 + return CircularBufferSpec(
158 + block_size=self.block_size,
159 + num_kv_heads=1,
160 + head_size=self.state_dim,
161 + head_size_v=0,
162 + dtype=self.dtype,
163 + )
164 +
165 + def forward(self): ...
166 +
167 + def get_attn_backend(self) -> type[AttentionBackend]:
168 + return CompressorBackend
169 +
170 +
171 +class DeepseekCompressor(nn.Module):
172 + """DeepSeek V4.1 KV/score compressor.
173 +
174 + Pools ``compress_ratio`` consecutive tokens into one KV latent with a
175 + learned softmax gate (ratio 1 has no gate and no pooling). Owns the
176 + linear, norm and state cache. State saving, compression and RMSNorm share
177 + one Triton kernel. The emitted latent feeds independent main-cache and
178 + indexer K kernels, which the attention layer can schedule concurrently.
179 + """
180 +
181 + def __init__(
182 + self,
183 + vllm_config: VllmConfig,
184 + compress_ratio: int,
185 + hidden_size: int,
186 + head_dim: int,
187 + rotate: bool = False,
188 + prefix: str = "",
189 + k_cache_prefix="",
190 + ):
191 + super().__init__()
192 + if compress_ratio not in (1, 2):
193 + raise NotImplementedError(
194 + "DeepSeek V4.1 compressor supports compress_ratio 1 (full-length "
195 + f"compressed cache) and 2; got {compress_ratio}. The ratio-4/128 "
196 + "CuTe-DSL kernels are v4.0-specific and not wired here."
197 + )
198 + self.compress_ratio = compress_ratio
199 + self.hidden_size = hidden_size
200 + self.head_dim = head_dim
201 + self.rotate = rotate
202 + self.prefix = prefix
203 + self.k_cache_prefix = k_cache_prefix
204 + # Ratio 1 pools single tokens, so the checkpoint carries no gate.
205 + self.has_gate = compress_ratio > 1
206 +
207 + config = vllm_config.model_config.hf_config
208 + self.rope_head_dim = config.qk_rope_head_dim
209 + self.nope_head_dim = self.head_dim - self.rope_head_dim
210 + assert self.head_dim == 512 and self.rope_head_dim == 64
211 + self.rms_norm_eps = config.rms_norm_eps
212 + self.device = current_platform.device_type
213 + self.max_num_reqs = vllm_config.scheduler_config.max_num_seqs
214 + self.max_model_len = vllm_config.model_config.max_model_len
215 +
216 + wkv_wgate_sizes = (
217 + [self.head_dim, self.head_dim] if self.has_gate else [self.head_dim]
218 + )
219 + self.fused_wkv_wgate = MergedColumnParallelLinear(
220 + self.hidden_size,
221 + wkv_wgate_sizes,
222 + bias=False,
223 + return_bias=False,
224 + quant_config=None,
225 + disable_tp=True,
226 + prefix=f"{prefix}.fused_wkv_wgate",
227 + )
228 + self.norm = RMSNorm(self.head_dim, self.rms_norm_eps)
229 +
230 + self.state_cache = (
231 + CompressorStateCache(
232 + state_dim=2 * self.head_dim, # kv_state + score_state
233 + dtype=torch.float32,
234 + prefix=f"{prefix}.state_cache",
235 + )
236 + if compress_ratio > 1
237 + else None
238 + )
239 +
240 + # Save reference to static_forward_context for forward-time KV cache lookup.
241 + # get_current_vllm_config() is only available during __init__, not forward.
242 + self._static_forward_context = (
243 + vllm_config.compilation_config.static_forward_context
244 + )
245 +
246 + def forward(
247 + self,
248 + # [num_tokens, (2 if has_gate else 1) * self.head_dim]
249 + kv_score: torch.Tensor,
250 + # [num_tokens]
251 + positions: torch.Tensor,
252 + ) -> torch.Tensor | None:
253 + """Save states and return the BF16 latent for cache insertion and indexing.
254 +
255 + Only valid group-boundary rows are written.
256 + """
257 + attn_metadata = get_forward_context().attn_metadata
258 + if not isinstance(attn_metadata, dict):
259 + return None
260 +
261 + if self.state_cache is None:
262 + state_cache = query_start_loc = token_to_req_indices = None
263 + slot_mapping = cast(Any, attn_metadata[self.k_cache_prefix]).slot_mapping
264 + else:
265 + state_metadata = cast(
266 + CompressorMetadata, attn_metadata[self.state_cache.prefix]
267 + )
268 + state_cache = self.state_cache.kv_cache
269 + slot_mapping = state_metadata.slot_mapping
270 + query_start_loc = state_metadata.query_start_loc
271 + token_to_req_indices = state_metadata.token_to_req_indices
272 +
273 + latent = torch.empty(
274 + kv_score.shape[0],
275 + self.head_dim,
276 + dtype=torch.bfloat16,
277 + device=kv_score.device,
278 + )
279 + fused_save_compress_norm(
280 + kv_score,
281 + positions,
282 + state_cache,
283 + slot_mapping,
284 + query_start_loc,
285 + token_to_req_indices,
286 + self.norm.weight,
287 + self.rms_norm_eps,
288 + self.compress_ratio,
289 + latent,
290 + )
291 + return latent
292 +
293 + def insert_cache(
294 + self,
295 + latent: torch.Tensor | None,
296 + positions: torch.Tensor,
297 + rotary_emb,
298 + ) -> None:
299 + """Publish compressed main-cache rows after the latent becomes ready."""
300 + if latent is None:
301 + return
302 + attn_metadata = get_forward_context().attn_metadata
303 + assert isinstance(attn_metadata, dict)
304 + k_cache_metadata = cast(Any, attn_metadata[self.k_cache_prefix])
305 + k_cache_layer = self._static_forward_context[self.k_cache_prefix]
306 + kv_cache = k_cache_layer.kv_cache
307 + # Plain-row per-tensor fp8 caches (FlashInfer) carry the layer's scale;
308 + # fp8_ds_mla and bf16 rows need none.
309 + fp8_scale = (
310 + getattr(k_cache_layer, "_flashinfer_fp8_kv_scale", None)
311 + if kv_cache.dtype == torch.float8_e4m3fn
312 + else None
313 + )
314 +
315 + rope_quant_insert(
316 + latent,
317 + positions,
318 + rotary_emb.cos_sin_cache,
319 + kv_cache,
320 + k_cache_metadata.slot_mapping,
321 + self.compress_ratio,
322 + fp8_scale=fp8_scale,
323 + )
vllm/models/deepseek_v4_1/nvidia/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/models/deepseek_v4_1/nvidia/dspark.py new
+558
@@ -0,0 +1,558 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DSpark draft model for DeepSeek-V4.1 (semi-autoregressive speculative decoding).
4 +
5 +See: qwen3_dspark.py for base architecture. This one is specialized to the
6 +DSV4.1 DSpark, which reuses the target model's architecture similarly to MTP.
7 +Its weights ship in the target checkpoint under the ``mtp.{0,1,2}.*`` prefix.
8 +
9 +To implement non-causal attention, we leverage the sparse attention implementation to
10 +include the future query tokens in the top-k indices for each query token.
11 +"""
12 +
13 +from collections.abc import Iterable
14 +
15 +import regex as re
16 +import torch
17 +import torch.nn as nn
18 +
19 +import vllm.envs as envs
20 +from vllm.config import VllmConfig, get_current_vllm_config
21 +from vllm.distributed import (
22 + get_tensor_model_parallel_rank,
23 + get_tensor_model_parallel_world_size,
24 +)
25 +from vllm.forward_context import get_forward_context, is_forward_context_available
26 +from vllm.logger import init_logger
27 +from vllm.model_executor.kernels.mhc.tilelang import (
28 + mhc_post_tilelang,
29 +)
30 +from vllm.model_executor.kernels.mhc.triton import hc_collapse_triton
31 +from vllm.model_executor.layers.fused_moe import (
32 + fused_moe_make_expert_params_mapping,
33 +)
34 +from vllm.model_executor.layers.layernorm import RMSNorm
35 +from vllm.model_executor.layers.linear import ReplicatedLinear
36 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
37 +from vllm.model_executor.layers.vocab_parallel_embedding import (
38 + ParallelLMHead,
39 + VocabParallelEmbedding,
40 +)
41 +from vllm.model_executor.model_loader.weight_utils import default_weight_loader
42 +from vllm.model_executor.models.qwen3_dspark import (
43 + DSparkConfidenceHead,
44 + DSparkMarkovHead,
45 +)
46 +from vllm.model_executor.models.utils import maybe_prefix
47 +from vllm.models.common.ops.sequence_parallel import (
48 + sp_all_gather,
49 + sp_padding_mask,
50 + sp_shard,
51 +)
52 +
53 +from .model import (
54 + DeepseekV4DecoderLayer,
55 + DeepseekV4Model,
56 + _linear_scale_param_name,
57 + _use_sequence_parallel,
58 + make_deepseek_v4_expert_params_mapping,
59 +)
60 +
61 +logger = init_logger(__name__)
62 +
63 +# MoE expert scale suffix differs by expert dtype (mirrors deepseek_v4 loaders):
64 +# fp4 experts register ``.weight_scale``; block-fp8 experts ``.weight_scale_inv``.
65 +_EXPERT_SCALE_RE = re.compile(r"\.experts\.\d+\.w[123]\.scale$")
66 +
67 +
68 +class DSparkDeepseekV4Model(nn.Module):
69 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
70 + super().__init__()
71 + assert vllm_config.speculative_config is not None
72 + config = vllm_config.speculative_config.draft_model_config.hf_config
73 + self.config = config
74 + self.hidden_size = config.hidden_size
75 + self.hc_mult = config.hc_mult
76 + self.hc_eps = config.hc_eps
77 + self.rms_norm_eps = config.rms_norm_eps
78 + self.num_hidden_layers = config.num_hidden_layers
79 + self.target_layer_ids = tuple(config.dspark_target_layer_ids)
80 + self.use_sequence_parallel = _use_sequence_parallel(vllm_config)
81 +
82 + self.num_dspark_layers = (
83 + getattr(config, "n_mtp_layers", None)
84 + or getattr(config, "num_nextn_predict_layers", None)
85 + or 3
86 + )
87 +
88 + # Shared with the target (aliased by the speculator's loading utility).
89 + self.embed_tokens = VocabParallelEmbedding(
90 + config.vocab_size,
91 + config.hidden_size,
92 + prefix=maybe_prefix(prefix, "embed_tokens"),
93 + )
94 +
95 + self.main_proj = ReplicatedLinear(
96 + config.hidden_size * len(self.target_layer_ids),
97 + config.hidden_size,
98 + bias=False,
99 + return_bias=False,
100 + quant_config=vllm_config.quant_config,
101 + prefix=maybe_prefix(prefix, "main_proj"),
102 + )
103 + self.main_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
104 +
105 + self.topk_indices_buffer = torch.empty(
106 + vllm_config.scheduler_config.max_num_batched_tokens,
107 + config.index_topk,
108 + dtype=torch.int32,
109 + )
110 +
111 + current_vllm_config = get_current_vllm_config()
112 + self.layers = nn.ModuleList(
113 + [
114 + DeepseekV4DecoderLayer(
115 + current_vllm_config,
116 + prefix=maybe_prefix(prefix, f"layers.{self.num_hidden_layers + i}"),
117 + topk_indices_buffer=self.topk_indices_buffer,
118 + )
119 + for i in range(self.num_dspark_layers)
120 + ]
121 + )
122 +
123 + # Heads: final norm, and the Markov + confidence heads.
124 + # Loaded from the "final" MTP layer weights (mtp.*) in the target
125 + # checkpoint. v4.1 has no learned hc_head: the hc copies are
126 + # collapsed with a pre-mix derived from the last layer's hc_ffn
127 + # projection at the end of forward().
128 + self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
129 + draft_vocab_size = (
130 + getattr(config, "draft_vocab_size", None) or config.vocab_size
131 + )
132 + self.markov_head = DSparkMarkovHead(
133 + config.vocab_size,
134 + draft_vocab_size,
135 + config.dspark_markov_rank,
136 + prefix=maybe_prefix(prefix, "markov_head"),
137 + )
138 + self.confidence_head: DSparkConfidenceHead | None = None
139 + if getattr(config, "enable_confidence_head", True):
140 + self.confidence_head = DSparkConfidenceHead(
141 + config.hidden_size + config.dspark_markov_rank,
142 + prefix=maybe_prefix(prefix, "confidence_head"),
143 + )
144 +
145 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
146 + return self.embed_tokens(input_ids)
147 +
148 + def combine_hidden_states(self, aux_hidden_states: torch.Tensor) -> torch.Tensor:
149 + """main_x = main_norm(main_proj(concat of target aux hidden states)).
150 +
151 + ``aux_hidden_states`` is [T, hidden_size * len(target_layer_ids)].
152 + """
153 + return self.main_norm(self.main_proj(aux_hidden_states))
154 +
155 + @torch.inference_mode()
156 + def precompute_and_store_context_kv(
157 + self,
158 + main_x: torch.Tensor,
159 + context_positions: torch.Tensor,
160 + context_slot_mappings: list[torch.Tensor | None] | None = None,
161 + ) -> None:
162 + """Insert the sliding-window context KV for every draft layer.
163 +
164 + Mirrors the reference DSparkAttention: each layer derives its context KV
165 + from the SAME projected target hidden ``main_x``, via that layer's own
166 + ``wkv`` + ``kv_norm`` + RoPE + quant, then writes it at the
167 + layer's context slots.
168 +
169 + ``context_slot_mappings`` is a per-layer list (each entry is the context
170 + slot mapping for that layer's kv-cache group, since the hybrid manager may
171 + place draft layers in different groups). ``None`` (or a ``None`` entry)
172 + runs the projection to reserve workspace but writes nothing (profiling).
173 + """
174 + for i, layer in enumerate(self.layers):
175 + slot_mapping = (
176 + None if context_slot_mappings is None else context_slot_mappings[i]
177 + )
178 + attn = layer.attn
179 + # Optimized DSV4 MLA path: wkv part of the fused wq_a|wkv projection
180 + # (q_lora part discarded), then RoPE/quant/insert via the fused op.
181 + qr_kv, _ = attn.fused_wqa_wkv(main_x)
182 + kv = qr_kv[..., attn.q_lora_rank :]
183 + kv = attn.kv_norm(kv)
184 + if slot_mapping is None:
185 + continue
186 + _insert_context_kv(attn, kv, context_positions, slot_mapping)
187 +
188 + def forward(
189 + self,
190 + input_ids: torch.Tensor,
191 + positions: torch.Tensor,
192 + inputs_embeds: torch.Tensor | None = None,
193 + ) -> torch.Tensor:
194 + if inputs_embeds is None:
195 + inputs_embeds = self.embed_input_ids(input_ids)
196 + full_num_tokens = positions.shape[0]
197 + if self.use_sequence_parallel:
198 + if envs.VLLM_MOE_SKIP_PADDING and is_forward_context_available():
199 + forward_context = get_forward_context()
200 + forward_context.is_padding = sp_padding_mask(
201 + forward_context.is_padding, inputs_embeds
202 + )
203 + inputs_embeds = sp_shard(inputs_embeds)
204 + input_ids = sp_shard(input_ids)
205 + # Expand to hc_mult copies for hyper-connections ([T, H] -> [T, hc, H]).
206 + hidden_states = inputs_embeds.unsqueeze(-2).repeat(1, self.hc_mult, 1)
207 +
208 + residual = post_mix = res_mix = pre_mix = None
209 + for layer in self.layers:
210 + hidden_states, residual, post_mix, res_mix, pre_mix = layer(
211 + hidden_states,
212 + positions,
213 + input_ids,
214 + pre_mix,
215 + post_mix,
216 + res_mix,
217 + residual,
218 + )
219 + hidden_states = mhc_post_tilelang(hidden_states, residual, post_mix, res_mix)
220 + if self.use_sequence_parallel:
221 + hidden_states = sp_all_gather(hidden_states)[:full_num_tokens]
222 + pre_mix = sp_all_gather(pre_mix)[:full_num_tokens]
223 + # Collapse the hc copies with the pre-mix from the last layer's FFN
224 + # mixes — the mix the reference forward_head applies (hc_pre with the
225 + # last block's ffn pre-mix). Return the PRE-norm head hidden;
226 + # compute_logits applies self.norm.
227 + assert pre_mix is not None
228 + hidden_states = hc_collapse_triton(hidden_states, pre_mix)
229 + return hidden_states
230 +
231 +
232 +def _insert_context_kv(
233 + attn: nn.Module,
234 + kv: torch.Tensor,
235 + positions: torch.Tensor,
236 + slot_mapping: torch.Tensor,
237 +) -> None:
238 + """RoPE + quant + paged-cache insert of (already kv_norm'd) context KV.
239 +
240 + Reuses the DSV4 fused insert ops (which also process a query; we pass a dummy
241 + query and discard it, since context tokens have no query). Mirrors
242 + ``DeepseekV4Attention._fused_qnorm_rope_kv_insert``.
243 + """
244 + swa_cache = attn.swa_cache_layer.kv_cache
245 + block_size = attn.swa_cache_layer.block_size
246 + cos_sin_cache = attn.rotary_emb.cos_sin_cache
247 + cache_dtype = swa_cache.dtype
248 + n_ctx = kv.shape[0]
249 + dummy_q = torch.zeros(
250 + (n_ctx, attn.n_local_heads, attn.head_dim),
251 + dtype=kv.dtype,
252 + device=kv.device,
253 + )
254 + if cache_dtype == torch.uint8:
255 + # fp8_ds_mla UE8M0 paged layout
256 + swa_2d = swa_cache.view(swa_cache.shape[0], -1)
257 + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
258 + dummy_q,
259 + kv,
260 + swa_2d,
261 + slot_mapping,
262 + positions,
263 + cos_sin_cache,
264 + attn.padded_heads,
265 + attn.eps,
266 + block_size,
267 + )
268 + elif cache_dtype == torch.bfloat16:
269 + swa_3d = swa_cache.view(-1, block_size, attn.head_dim)
270 + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(
271 + dummy_q,
272 + kv,
273 + swa_3d,
274 + slot_mapping,
275 + positions,
276 + cos_sin_cache,
277 + attn.eps,
278 + block_size,
279 + )
280 + else: # per-tensor fp8 (torch.float8_e4m3fn)
281 + # TODO(ben): double-check if this is being dispatched correctly for FI backend
282 + swa_3d = swa_cache.view(-1, block_size, attn.head_dim)
283 + dummy_q_fp8 = torch.zeros_like(dummy_q, dtype=torch.float8_e4m3fn)
284 + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert(
285 + dummy_q,
286 + kv,
287 + dummy_q_fp8,
288 + swa_3d,
289 + slot_mapping,
290 + positions,
291 + cos_sin_cache,
292 + attn._flashinfer_fp8_kv_scale,
293 + attn._flashinfer_fp8_q_scale_inv,
294 + attn.eps,
295 + block_size,
296 + )
297 +
298 +
299 +class DSparkDeepseekV4ForCausalLM(nn.Module):
300 + # Draft weights ship in the target checkpoint (mtp.*) without embed/head, so
301 + # load_dspark_model always aliases the target's.
302 + has_own_embed_tokens = False
303 + has_own_lm_head = False
304 + # Full-vocab draft: draft ids are target ids, no remapping needed.
305 + draft_id_to_target_id = None
306 +
307 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
308 + super().__init__()
309 + assert vllm_config.speculative_config is not None
310 + self.draft_model_config = vllm_config.speculative_config.draft_model_config
311 + self.config = self.draft_model_config.hf_config
312 + self.quant_config = vllm_config.quant_config
313 + self.linear_scale_name = _linear_scale_param_name(
314 + vllm_config, getattr(self.config, "expert_dtype", "fp4")
315 + )
316 + self.pad_shared_expert = getattr(
317 + self.quant_config, "weight_block_size", None
318 + ) is not None and not _use_sequence_parallel(vllm_config)
319 + self.model = DSparkDeepseekV4Model(
320 + vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
321 + )
322 + # Shared with the target (aliased by the speculator's load utility).
323 + self.lm_head = ParallelLMHead(
324 + self.config.vocab_size,
325 + self.config.hidden_size,
326 + prefix=maybe_prefix(prefix, "lm_head"),
327 + )
328 + self.logits_processor = LogitsProcessor(self.config.vocab_size)
329 +
330 + # --- Hooks used by the speculator -------------------------------------
331 +
332 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
333 + return self.model.embed_input_ids(input_ids)
334 +
335 + def combine_hidden_states(self, aux_hidden_states: torch.Tensor) -> torch.Tensor:
336 + return self.model.combine_hidden_states(aux_hidden_states)
337 +
338 + def get_draft_kv_cache_layer_names(self) -> list[str]:
339 + # DSV4 MLA path: each draft layer's sliding-window cache is a separate
340 + # layer, named by its prefix.
341 + return [layer.attn.swa_cache_layer.prefix for layer in self.model.layers]
342 +
343 + def precompute_and_store_context_kv(
344 + self,
345 + context_states: torch.Tensor,
346 + context_positions: torch.Tensor,
347 + context_slot_mappings: list[torch.Tensor | None] | None = None,
348 + ) -> None:
349 + self.model.precompute_and_store_context_kv(
350 + context_states, context_positions, context_slot_mappings
351 + )
352 +
353 + def forward(
354 + self,
355 + input_ids: torch.Tensor,
356 + positions: torch.Tensor,
357 + inputs_embeds: torch.Tensor | None = None,
358 + ) -> torch.Tensor:
359 + # Returns the pre-norm collapsed head hidden ([T, hidden_size]).
360 + return self.model(input_ids, positions, inputs_embeds)
361 +
362 + def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
363 + """Base logits U_k = lm_head(norm(head_hidden))."""
364 + return self.logits_processor(self.lm_head, self.model.norm(hidden_states))
365 +
366 + def compute_draft_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
367 + # Full-vocab draft: base logits, no d2t scatter.
368 + return self.compute_logits(hidden_states)
369 +
370 + def map_draft_to_target(self, draft_ids: torch.Tensor) -> torch.Tensor:
371 + return draft_ids # full-vocab: draft ids are target ids
372 +
373 + def markov_embed(self, token_ids: torch.Tensor) -> torch.Tensor:
374 + return self.model.markov_head.embed(token_ids)
375 +
376 + def markov_bias(self, markov_embed: torch.Tensor) -> torch.Tensor:
377 + return self.model.markov_head.bias(markov_embed, self.logits_processor)
378 +
379 + def compute_confidence(
380 + self, head_hidden: torch.Tensor, markov_embed: torch.Tensor
381 + ) -> torch.Tensor:
382 + """Per-position acceptance probability for each drafted token."""
383 + assert self.model.confidence_head is not None
384 + return torch.sigmoid(self.model.confidence_head(head_hidden, markov_embed))
385 +
386 + # --- Weight loading ----------------------------------------------------
387 +
388 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
389 + """Load the ``mtp.{0,1,2}.*`` draft weights from the target checkpoint.
390 +
391 + Non-mtp weights (embed/head/main layers) belong to the target model and
392 + are skipped here. ``embed_tokens``/``lm_head`` are aliased from the target.
393 + """
394 + first_layer = self.model.layers[0]
395 + use_mega_moe = first_layer.ffn.use_mega_moe
396 + # Draft MoE layers use the dspark_* expert counts, not the
397 + # backbone's (see DeepseekV4MoE and the reference
398 + # ModelArgs.get_moe_config).
399 + n_draft_experts = (
400 + getattr(self.config, "dspark_n_routed_experts", 0)
401 + or self.config.n_routed_experts
402 + )
403 + if use_mega_moe:
404 + expert_mapping = make_deepseek_v4_expert_params_mapping(n_draft_experts)
405 + else:
406 + expert_mapping = fused_moe_make_expert_params_mapping(
407 + self,
408 + ckpt_gate_proj_name="w1",
409 + ckpt_down_proj_name="w2",
410 + ckpt_up_proj_name="w3",
411 + num_experts=n_draft_experts,
412 + )
413 + expert_scale_suffix = (
414 + ".weight_scale"
415 + if getattr(self.config, "expert_dtype", "fp4") == "fp4"
416 + else ".weight_scale_inv"
417 + )
418 +
419 + # (param_name, ckpt_shard_name, shard_id) for non-expert stacked params.
420 + stacked_params_mapping = [
421 + ("gate_up_proj", "w1", 0),
422 + ("gate_up_proj", "w3", 1),
423 + ("attn.fused_wqa_wkv", "attn.wq_a", 0),
424 + ("attn.fused_wqa_wkv", "attn.wkv", 1),
425 + ]
426 +
427 + params_dict = dict(self.named_parameters())
428 + loaded_params: set[str] = set()
429 + loaded_confidence_head = False
430 +
431 + tp_size = get_tensor_model_parallel_world_size()
432 + tp_rank = get_tensor_model_parallel_rank()
433 + n_local_head = self.config.num_attention_heads // tp_size
434 + head_start = n_local_head * tp_rank
435 + head_end = n_local_head * (tp_rank + 1)
436 +
437 + for name, loaded_weight in weights:
438 + mapped = self._remap_dspark_name(name)
439 + if mapped is None:
440 + continue
441 + name = mapped
442 + if "confidence_head." in name:
443 + loaded_confidence_head = True
444 +
445 + # ``.scale`` -> per-method scale suffix.
446 + if name.endswith(".scale"):
447 + suffix = (
448 + expert_scale_suffix
449 + if _EXPERT_SCALE_RE.search(name)
450 + else f".{self.linear_scale_name}"
451 + )
452 + name = name.removesuffix(".scale") + suffix
453 + if ".shared_experts.w2" in name:
454 + name = name.replace(".shared_experts.w2", ".shared_experts.down_proj")
455 + if self.pad_shared_expert and ".shared_experts." in name:
456 + loaded_weight = DeepseekV4Model._pad_shared_expert_weight(
457 + self.quant_config, name, loaded_weight
458 + )
459 +
460 + # E8M0 expert scales: keep raw exponent bytes.
461 + if ".experts." in name:
462 + if (
463 + "weight_scale" in name
464 + and loaded_weight.dtype == torch.float8_e8m0fnu
465 + ):
466 + loaded_weight = loaded_weight.view(torch.uint8)
467 + for param_name, weight_name, expert_id, shard_id in expert_mapping:
468 + if weight_name not in name:
469 + continue
470 + name_mapped = name.replace(weight_name, param_name)
471 + param = params_dict[name_mapped]
472 + success = param.weight_loader(
473 + param,
474 + loaded_weight,
475 + name_mapped,
476 + shard_id=shard_id,
477 + expert_id=expert_id,
478 + return_success=True,
479 + )
480 + if success:
481 + loaded_params.add(name_mapped)
482 + break
483 + continue
484 +
485 + # Stacked rules only apply to decoder-layer weights. Head-stack params
486 + # (main_proj/norm/markov_head/confidence_head) load directly —
487 + # otherwise e.g. "markov_w1" would collide with the "w1" shard rule.
488 + is_layer_param = name.startswith("model.layers.")
489 + for param_name, weight_name, stacked_shard_id in stacked_params_mapping:
490 + if not is_layer_param or weight_name not in name:
491 + continue
492 + name = name.replace(weight_name, param_name)
493 + param = params_dict[name]
494 + param.weight_loader(param, loaded_weight, stacked_shard_id)
495 + loaded_params.add(name)
496 + break
497 + else:
498 + if "attn_sink" in name:
499 + narrow = loaded_weight[head_start:head_end]
500 + params_dict[name][: narrow.shape[0]].copy_(narrow)
501 + loaded_params.add(name)
502 + continue
503 + if name.endswith(".ffn.gate.bias"):
504 + name = name.replace(
505 + ".ffn.gate.bias", ".ffn.gate.e_score_correction_bias"
506 + )
507 + param = params_dict[name]
508 + weight_loader = getattr(param, "weight_loader", default_weight_loader)
509 + weight_loader(param, loaded_weight)
510 + loaded_params.add(name)
511 +
512 + if self.model.confidence_head is not None and not loaded_confidence_head:
513 + self.model.confidence_head = None
514 + self.process_weights_after_loading()
515 + logger.info_once("DSpark draft model loaded: %d params", len(loaded_params))
516 + return loaded_params
517 +
518 + def _finalize_moe(self) -> None:
519 + for layer in self.model.layers:
520 + layer.ffn.finalize_mega_moe_weights()
521 +
522 + def process_weights_after_loading(self) -> None:
523 + self._finalize_moe()
524 +
525 + def _remap_dspark_name(self, name: str) -> str | None:
526 + """Map a checkpoint ``mtp.{i}.*`` name to this model's parameter path.
527 +
528 + Returns None for non-mtp weights (owned by the target model).
529 + """
530 + m = re.match(r"mtp\.(\d+)\.(.*)", name)
531 + if m is None:
532 + return None
533 + stage = int(m.group(1))
534 + rest = m.group(2)
535 + if rest.startswith("confidence_head.") and self.model.confidence_head is None:
536 + return None
537 + # The checkpoint calls the Markov head's factors ``embed``/``head``;
538 + # DSparkMarkovHead registers them as ``markov_w1``/``markov_w2``.
539 + if rest.startswith("markov_head.embed."):
540 + return "model.markov_head.markov_w1." + rest.removeprefix(
541 + "markov_head.embed."
542 + )
543 + if rest.startswith("markov_head.head."):
544 + return "model.markov_head.markov_w2." + rest.removeprefix(
545 + "markov_head.head."
546 + )
547 + # Head-stack params live at model level (mtp.last), context combiner at
548 + # model level (mtp.0); everything else is a per-layer decoder block.
549 + head_prefixes = (
550 + "norm.",
551 + "markov_head.",
552 + "confidence_head.",
553 + )
554 + if rest.startswith(("main_proj.", "main_norm.")) or rest.startswith(
555 + head_prefixes
556 + ):
557 + return f"model.{rest}"
558 + return f"model.layers.{stage}.{rest}"
vllm/models/deepseek_v4_1/nvidia/flashinfer_sparse.py new
+908
@@ -0,0 +1,908 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepSeek V4 FlashInfer sparse MLA backend."""
4 +
5 +from typing import TYPE_CHECKING, ClassVar, cast
6 +
7 +import torch
8 +
9 +from vllm.config import VllmConfig
10 +from vllm.config.cache import CacheDType
11 +from vllm.forward_context import get_forward_context
12 +from vllm.models.deepseek_v4.nvidia.ops.o_proj import (
13 + compute_fp8_einsum_recipe,
14 + deep_gemm_fp8_o_proj,
15 +)
16 +from vllm.models.deepseek_v4_1.attention import DeepseekV4Attention
17 +from vllm.models.deepseek_v4_1.common.ops import (
18 + build_flashinfer_mixed_sparse_indices,
19 + compute_global_topk_indices_and_lens,
20 +)
21 +from vllm.models.deepseek_v4_1.sparse_mla import (
22 + DeepseekV4FlashMLAMetadata,
23 + DeepseekV4SparseMLABackend,
24 + DeepseekV4SparseMLAMetadataBuilder,
25 + DeepseekV41SparseSWAMetadataBuilder,
26 +)
27 +from vllm.platforms.interface import DeviceCapability
28 +from vllm.utils.flashinfer import flashinfer_trtllm_batch_decode_sparse_mla_dsv4
29 +from vllm.v1.attention.backend import AttentionCGSupport, MultipleOf
30 +from vllm.v1.attention.backends.mla.compressor_utils import (
31 + get_dspark_swa_index_width,
32 +)
33 +from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWABackend
34 +
35 +if TYPE_CHECKING:
36 + from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
37 +
38 +_FLASHINFER_DSV4_WORKSPACE_BUFFER_SIZE = 128 * 1024 * 1024
39 +_flashinfer_dsv4_workspace_by_device: dict[torch.device, torch.Tensor] = {}
40 +
41 +
42 +def _get_flashinfer_dsv4_workspace(device: torch.device) -> torch.Tensor:
43 + workspace = _flashinfer_dsv4_workspace_by_device.get(device)
44 + if workspace is None:
45 + workspace = torch.zeros(
46 + _FLASHINFER_DSV4_WORKSPACE_BUFFER_SIZE,
47 + dtype=torch.uint8,
48 + device=device,
49 + )
50 + _flashinfer_dsv4_workspace_by_device[device] = workspace
51 + return workspace
52 +
53 +
54 +def _packed_block_span(pool: torch.Tensor) -> int:
55 + """Per-block stride of ``pool`` in tokens (``stride(0)//stride(-2)``): ==
56 + block_size for unpacked KV, larger when packed (#44577). Raises if not
57 + token-aligned."""
58 + block_stride = pool.stride(0)
59 + token_stride = pool.stride(-2)
60 + if block_stride % token_stride != 0:
61 + raise NotImplementedError(
62 + "FLASHINFER_MLA_SPARSE_DSV4 packed KV requires the per-block stride "
63 + f"({block_stride}) to be a multiple of the per-token stride "
64 + f"({token_stride}); this layout is not supported yet."
65 + )
66 + return block_stride // token_stride
67 +
68 +
69 +# Sparse MLA h_q counts accepted natively (flashinfer>=0.6.14, #3545).
70 +_SPARSE_MLA_SUPPORTED_Q_HEADS = (8, 16, 32, 64, 128)
71 +
72 +
73 +def _pad_to_supported_q_heads(num_heads: int) -> int:
74 + for supported in _SPARSE_MLA_SUPPORTED_Q_HEADS:
75 + if num_heads <= supported:
76 + return supported
77 + raise ValueError(
78 + f"DeepseekV4 FlashInfer MLA Sparse does not support {num_heads} heads "
79 + "(sparse MLA kernel requires h_q in {8, 16, 32, 64, 128})."
80 + )
81 +
82 +
83 +def _required_sm120_sparse_topk(vllm_config: VllmConfig, window_size: int) -> int:
84 + """Return the SM120 DSV4 SWA specialization needed by this model."""
85 + if not vllm_config.attention_config.use_non_causal:
86 + return window_size
87 + speculative_config = vllm_config.speculative_config
88 + if speculative_config is None:
89 + return window_size
90 + return get_dspark_swa_index_width(
91 + window_size,
92 + speculative_config.num_speculative_tokens,
93 + )
94 +
95 +
96 +class DeepseekV4FlashInferMLASparseBackend(DeepseekV4SparseMLABackend):
97 + """FlashInfer backend using the DSv4 sparse metadata/cache layout.
98 +
99 + Inherits the base and backend reuses its``DeepseekV4SparseMLAMetadataBuilder``
100 + """
101 +
102 + supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16]
103 + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [
104 + "auto",
105 + "bfloat16",
106 + "fp8",
107 + "fp8_e4m3",
108 + "fp8_ds_mla",
109 + ]
110 +
111 + @staticmethod
112 + def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
113 + return [128]
114 +
115 + @staticmethod
116 + def get_name() -> str:
117 + return "FLASHINFER_MLA_SPARSE_DSV41"
118 +
119 + @classmethod
120 + def get_supported_head_sizes(cls) -> list[int]:
121 + return [512]
122 +
123 + @classmethod
124 + def supports_sink(cls) -> bool:
125 + return True
126 +
127 + @classmethod
128 + def is_sparse(cls) -> bool:
129 + return True
130 +
131 + @classmethod
132 + def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
133 + return capability.major in [10, 12]
134 +
135 + @classmethod
136 + def supports_combination(
137 + cls,
138 + head_size: int,
139 + dtype: torch.dtype,
140 + kv_cache_dtype: CacheDType | None,
141 + block_size: int | None,
142 + use_mla: bool,
143 + has_sink: bool,
144 + use_sparse: bool,
145 + use_mm_prefix: bool,
146 + device_capability: DeviceCapability,
147 + ) -> str | None:
148 + if device_capability.major == 10:
149 + if kv_cache_dtype == "fp8_ds_mla":
150 + return (
151 + "FLASHINFER_MLA_SPARSE_DSV4 SM10x uses the plain "
152 + "per-tensor FP8 KV layout, not fp8_ds_mla"
153 + )
154 + if kv_cache_dtype not in (None, "auto", "bfloat16", "fp8", "fp8_e4m3"):
155 + return "kv_cache_dtype not supported"
156 + return None
157 + if device_capability.major == 12:
158 + if kv_cache_dtype not in ("fp8", "fp8_e4m3", "fp8_ds_mla"):
159 + return "kv_cache_dtype not supported"
160 + from vllm.utils.flashinfer import has_flashinfer_sparse_mla_sm120
161 +
162 + if not has_flashinfer_sparse_mla_sm120():
163 + return (
164 + "FLASHINFER_MLA_SPARSE_DSV4 SM120 requires FlashInfer's "
165 + "sparse MLA decode API"
166 + )
167 + return None
168 + return "FLASHINFER_MLA_SPARSE_DSV4 requires SM10x or SM12x"
169 +
170 + @staticmethod
171 + def get_builder_cls() -> type["DeepseekV4FlashInferSparseMLAMetadataBuilder"]:
172 + return DeepseekV4FlashInferSparseMLAMetadataBuilder
173 +
174 +
175 +class DeepseekV4FlashInferSparseMLAMetadataBuilder(DeepseekV4SparseMLAMetadataBuilder):
176 + """Varlen-capable metadata builder for the FlashInfer sparse MLA backend."""
177 +
178 + _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
179 +
180 +
181 +class DeepseekSparseSWAFlashInferMetadataBuilder(DeepseekV41SparseSWAMetadataBuilder):
182 + """SWA metadata for the FlashInfer sparse decode path (varlen decode)."""
183 +
184 + _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
185 +
186 +
187 +class DeepseekSparseSWAFlashInferBackend(DeepseekSparseSWABackend):
188 + @staticmethod
189 + def get_builder_cls() -> type[DeepseekSparseSWAFlashInferMetadataBuilder]:
190 + return DeepseekSparseSWAFlashInferMetadataBuilder
191 +
192 +
193 +class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
194 + """FlashInfer TRTLLM-gen sparse MLA attention layer for SM100 DeepSeek V4."""
195 +
196 + backend_cls = DeepseekV4FlashInferMLASparseBackend
197 + swa_backend_cls = DeepseekSparseSWAFlashInferBackend
198 + use_fp8_ds_mla_layout: ClassVar[bool] = False
199 +
200 + @classmethod
201 + def get_padded_num_q_heads(cls, num_heads: int) -> int:
202 + return _pad_to_supported_q_heads(num_heads)
203 +
204 + def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
205 + return deep_gemm_fp8_o_proj(
206 + o,
207 + positions,
208 + self.rotary_emb.cos_sin_cache,
209 + self.wo_a,
210 + self.wo_b,
211 + n_groups=self.n_local_groups,
212 + heads_per_group=self.n_local_heads // self.n_local_groups,
213 + nope_dim=self.nope_head_dim,
214 + rope_dim=self.rope_head_dim,
215 + o_lora_rank=self.o_lora_rank,
216 + einsum_recipe=self._einsum_recipe,
217 + tma_aligned_scales=self._tma_aligned_scales,
218 + )
219 +
220 + def __init__(self, *args, **kwargs) -> None:
221 + super().__init__(*args, **kwargs)
222 + self._einsum_recipe, self._tma_aligned_scales = compute_fp8_einsum_recipe(
223 + self._o_proj_block_size
224 + )
225 + # Per-tensor FP8 scale buffers + precomputed scalar BMM scales. Only the
226 + # per-tensor FP8 cache path consumes these; bf16 reads ``self.scale``.
227 + if self.kv_cache_torch_dtype != torch.float8_e4m3fn:
228 + return
229 + fp8_q_scale = 1.0
230 + fp8_kv_scale = 1.0
231 + self.register_buffer(
232 + "_flashinfer_fp8_q_scale",
233 + torch.tensor([fp8_q_scale], dtype=torch.float32),
234 + persistent=False,
235 + )
236 + self.register_buffer(
237 + "_flashinfer_fp8_q_scale_inv",
238 + torch.tensor([1.0 / fp8_q_scale], dtype=torch.float32),
239 + persistent=False,
240 + )
241 + self.register_buffer(
242 + "_flashinfer_fp8_kv_scale",
243 + torch.tensor([fp8_kv_scale], dtype=torch.float32),
244 + persistent=False,
245 + )
246 + # TRTLLM-gen takes scalar scale args on a distinct C++ path vs
247 + # one-element tensors, so these are Python floats.
248 + self._flashinfer_fp8_bmm1_scale = self.scale * fp8_q_scale * fp8_kv_scale
249 + self._flashinfer_fp8_bmm2_scale = fp8_kv_scale
250 +
251 + def forward_mqa(
252 + self,
253 + q: torch.Tensor,
254 + kv: torch.Tensor,
255 + positions: torch.Tensor,
256 + output: torch.Tensor,
257 + ) -> None:
258 + # The TRTLLM-gen kernel requires h_q in {64, 128}, so the output buffer
259 + # is allocated at the padded head count while q arrives at the local
260 + # head count; _forward pads q to match before the launcher.
261 + assert output.shape[0] == q.shape[0] and output.shape[-1] == q.shape[-1], (
262 + f"output buffer shape {output.shape} incompatible with q shape {q.shape}"
263 + )
264 + assert output.shape[1] >= q.shape[1], (
265 + f"output heads {output.shape[1]} must be >= q heads {q.shape[1]}"
266 + )
267 + # Per-tensor FP8 q produces a bf16 attention output.
268 + expected_output_dtype = (
269 + torch.bfloat16 if q.dtype == torch.float8_e4m3fn else q.dtype
270 + )
271 + assert output.dtype == expected_output_dtype, (
272 + f"output dtype {output.dtype} must match expected {expected_output_dtype} "
273 + f"for q dtype {q.dtype}"
274 + )
275 +
276 + forward_context = get_forward_context()
277 + attn_metadata = forward_context.attn_metadata
278 + if attn_metadata is None:
279 + # Warmup dummy run: FlashInfer reads the cache directly and lazily
280 + # allocates its workspace, so nothing to reserve here.
281 + output.zero_()
282 + return
283 +
284 + assert isinstance(attn_metadata, dict)
285 + # Compressed-cache metadata lives on the kv-source layer's prefix;
286 + # consumers share that cache and its block table.
287 + flashmla_metadata = cast(
288 + DeepseekV4FlashMLAMetadata | None,
289 + attn_metadata.get(self.compressed_cache_prefix)
290 + if self.compressed_cache_prefix is not None
291 + else None,
292 + )
293 + swa_metadata = cast(
294 + "DeepseekSparseSWAMetadata | None",
295 + attn_metadata.get(self.swa_cache_layer.prefix),
296 + )
297 + assert swa_metadata is not None
298 +
299 + swa_only = self.compress_ratio == 0
300 + # SWA-only layers have no compressed KV cache; consumers read the kv
301 + # source's cache.
302 + self_kv_cache = None if swa_only else self._compressed_kv_cache()
303 + swa_kv_cache = self.swa_cache_layer.kv_cache
304 +
305 + self._forward(
306 + q=q,
307 + kv_cache=self_kv_cache,
308 + swa_k_cache=swa_kv_cache,
309 + swa_metadata=swa_metadata,
310 + attn_metadata=flashmla_metadata,
311 + swa_only=swa_only,
312 + output=output,
313 + )
314 +
315 + def _build_sparse_index_metadata(
316 + self,
317 + kv_cache: torch.Tensor | None,
318 + swa_k_cache: torch.Tensor,
319 + swa_metadata: "DeepseekSparseSWAMetadata",
320 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
321 + swa_only: bool,
322 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
323 + """Build the combined sparse-index tensors for the mixed batch.
324 +
325 + Returns ``(compressed_kv_cache, seq_lens, sparse_indices,
326 + sparse_topk_lens)``.
327 + """
328 + num_decodes = swa_metadata.num_decodes
329 + num_prefills = swa_metadata.num_prefills
330 + num_decode_tokens = swa_metadata.num_decode_tokens
331 + num_prefill_tokens = swa_metadata.num_prefill_tokens
332 + num_reqs = num_decodes + num_prefills
333 + num_tokens = num_decode_tokens + num_prefill_tokens
334 +
335 + assert swa_metadata.seq_lens is not None
336 + assert swa_metadata.query_start_loc is not None
337 + assert swa_metadata.token_to_req_indices is not None
338 + assert swa_metadata.decode_swa_indices is not None
339 + assert swa_metadata.block_table is not None
340 +
341 + decode_swa_indices = swa_metadata.decode_swa_indices.reshape(
342 + num_decode_tokens, swa_metadata.decode_swa_width
343 + )
344 + decode_compressed_topk_lens = None
345 + decode_compressed_indices_are_local = False
346 + decode_is_valid_token = None
347 +
348 + if swa_only:
349 + assert self.topk_indices_buffer is not None
350 + compressed_kv_cache = swa_k_cache
351 + decode_compressed_indices = None
352 + prefill_topk_indices = self.topk_indices_buffer[
353 + num_decode_tokens:num_tokens, :0
354 + ]
355 + compressed_block_table = None
356 + compressed_block_size = swa_metadata.block_size
357 + top_k = 0
358 + else:
359 + assert kv_cache is not None
360 + assert attn_metadata is not None
361 + assert self.topk_indices_buffer is not None
362 + assert swa_metadata.is_valid_token is not None
363 + compressed_kv_cache = kv_cache
364 + compressed_block_table = attn_metadata.block_table[:num_reqs]
365 + compressed_block_size = attn_metadata.block_size // self.compress_ratio
366 +
367 + # Local indices filled by the index-source layer's indexer.
368 + if num_prefill_tokens > 0:
369 + prefill_topk_indices = self.topk_indices_buffer[
370 + num_decode_tokens:num_tokens
371 + ]
372 + top_k = prefill_topk_indices.shape[-1]
373 + else:
374 + prefill_topk_indices = self.topk_indices_buffer[:0, :0]
375 + top_k = 0
376 +
377 + decode_compressed_indices_are_local = True
378 + decode_is_valid_token = swa_metadata.is_valid_token[:num_decode_tokens]
379 + if num_decode_tokens > 0:
380 + decode_compressed_indices = self.topk_indices_buffer[:num_decode_tokens]
381 + else:
382 + # Keep the logical width aligned with the mixed-batch case so
383 + # pure-prefill steps reuse the same Triton specialization.
384 + decode_compressed_indices = prefill_topk_indices[:0]
385 +
386 + query_start_loc = swa_metadata.query_start_loc[: num_reqs + 1]
387 + seq_lens = swa_metadata.seq_lens[:num_reqs]
388 + assert seq_lens.dtype == torch.int32
389 + # SWA-only layers all build the same mixed sparse indices, so the first
390 + # one caches them for the step; indexer layers depend on their own topk
391 + # indices and stay uncached.
392 + cached_sparse = (
393 + swa_metadata.flashinfer_sparse_index_cache.get("swa_only")
394 + if swa_only
395 + else None
396 + )
397 + if cached_sparse is None:
398 + swa_block_span = _packed_block_span(swa_k_cache)
399 + compressed_block_span = _packed_block_span(compressed_kv_cache)
400 + sparse_indices, sparse_topk_lens = build_flashinfer_mixed_sparse_indices(
401 + decode_swa_indices,
402 + decode_compressed_indices,
403 + decode_compressed_topk_lens,
404 + prefill_topk_indices[:num_prefill_tokens],
405 + query_start_loc,
406 + seq_lens,
407 + swa_metadata.token_to_req_indices[:num_tokens],
408 + swa_metadata.block_table[:num_reqs],
409 + swa_metadata.block_size,
410 + compressed_block_table,
411 + compressed_block_size,
412 + self.window_size,
413 + self.compress_ratio,
414 + top_k,
415 + decode_compressed_indices_are_local=decode_compressed_indices_are_local,
416 + decode_is_valid_token=decode_is_valid_token,
417 + swa_block_span=swa_block_span,
418 + compressed_block_span=compressed_block_span,
419 + prefill_left_visible=swa_metadata.prefill_left_visible,
420 + prefill_right_visible=swa_metadata.prefill_right_visible,
421 + # getattr for tests that bypass __init__ via object.__new__.
422 + max_image_tokens=getattr(self, "max_image_tokens", 0),
423 + )
424 + if swa_only:
425 + swa_metadata.flashinfer_sparse_index_cache["swa_only"] = (
426 + sparse_indices,
427 + sparse_topk_lens,
428 + )
429 + else:
430 + sparse_indices, sparse_topk_lens = cached_sparse
431 + return compressed_kv_cache, seq_lens, sparse_indices, sparse_topk_lens
432 +
433 + def _forward(
434 + self,
435 + q: torch.Tensor,
436 + kv_cache: torch.Tensor | None,
437 + swa_k_cache: torch.Tensor,
438 + swa_metadata: "DeepseekSparseSWAMetadata",
439 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
440 + swa_only: bool,
441 + output: torch.Tensor,
442 + ) -> None:
443 + assert self.kv_cache_torch_dtype in (torch.bfloat16, torch.float8_e4m3fn)
444 + num_decodes = swa_metadata.num_decodes
445 + num_prefills = swa_metadata.num_prefills
446 + num_decode_tokens = swa_metadata.num_decode_tokens
447 + num_prefill_tokens = swa_metadata.num_prefill_tokens
448 + num_reqs = num_decodes + num_prefills
449 + num_tokens = num_decode_tokens + num_prefill_tokens
450 + if num_tokens == 0:
451 + return
452 +
453 + (
454 + compressed_kv_cache,
455 + seq_lens,
456 + sparse_indices,
457 + sparse_topk_lens,
458 + ) = self._build_sparse_index_metadata(
459 + kv_cache=kv_cache,
460 + swa_k_cache=swa_k_cache,
461 + swa_metadata=swa_metadata,
462 + attn_metadata=attn_metadata,
463 + swa_only=swa_only,
464 + )
465 +
466 + # CUDA graph execution can pad q/output past the scheduled token count;
467 + # restrict to the real tokens (the launcher validates sparse indices).
468 + query = q[:num_tokens]
469 + output = output[:num_tokens]
470 + bmm1_scale: float | torch.Tensor = self.scale
471 + bmm2_scale: float | torch.Tensor = 1.0
472 + if self.kv_cache_torch_dtype == torch.float8_e4m3fn:
473 + assert query.dtype == torch.float8_e4m3fn
474 + bmm1_scale = self._flashinfer_fp8_bmm1_scale
475 + bmm2_scale = self._flashinfer_fp8_bmm2_scale
476 + else:
477 + assert query.dtype == torch.bfloat16
478 + query = query.contiguous()
479 +
480 + # The TRTLLM-gen sparse-MLA kernel requires h_q in {64, 128}; zero-pad
481 + # the query heads to the allocated output head count. Padded heads attend
482 + # to the shared KV and are sliced off downstream (output is padded too).
483 + padded_heads = output.shape[1]
484 + if query.shape[1] < padded_heads:
485 + padded_query = query.new_zeros(
486 + (query.shape[0], padded_heads, query.shape[2])
487 + )
488 + padded_query[:, : query.shape[1], :] = query
489 + query = padded_query
490 +
491 + workspace = _get_flashinfer_dsv4_workspace(q.device)
492 + query_start_loc = swa_metadata.query_start_loc
493 + query_start_loc_cpu = swa_metadata.query_start_loc_cpu
494 + assert query_start_loc is not None and query_start_loc_cpu is not None
495 +
496 + # Keep the TRTLLM-gen decode/prefill split: the launcher is tuned for
497 + # uniform-q batches, and this avoids flattening mixed batches into one call.
498 + if num_decode_tokens > 0:
499 + decode_cu = query_start_loc[: num_decodes + 1]
500 + flashinfer_trtllm_batch_decode_sparse_mla_dsv4(
501 + query=query[:num_decode_tokens],
502 + swa_kv_cache=swa_k_cache,
503 + workspace_buffer=workspace,
504 + sparse_indices=sparse_indices[:num_decode_tokens],
505 + compressed_kv_cache=compressed_kv_cache,
506 + sparse_topk_lens=sparse_topk_lens[:num_decode_tokens],
507 + seq_lens=seq_lens[:num_decodes],
508 + out=output[:num_decode_tokens],
509 + bmm1_scale=bmm1_scale,
510 + bmm2_scale=bmm2_scale,
511 + sinks=self.attn_sink,
512 + cum_seq_lens_q=decode_cu,
513 + max_q_len=swa_metadata.max_decode_query_len,
514 + )
515 +
516 + if num_prefill_tokens > 0:
517 + # The prefill query view re-anchors at offset 0, so rebase the
518 + # cumulative query offsets to start at 0.
519 + prefill_cu = (
520 + query_start_loc[num_decodes : num_reqs + 1]
521 + - query_start_loc[num_decodes]
522 + )
523 + prefill_cu_cpu = query_start_loc_cpu[num_decodes : num_reqs + 1]
524 + prefill_lens_cpu = prefill_cu_cpu[1:] - prefill_cu_cpu[:-1]
525 + flashinfer_trtllm_batch_decode_sparse_mla_dsv4(
526 + query=query[num_decode_tokens:num_tokens],
527 + swa_kv_cache=swa_k_cache,
528 + workspace_buffer=workspace,
529 + sparse_indices=sparse_indices[num_decode_tokens:num_tokens],
530 + compressed_kv_cache=compressed_kv_cache,
531 + sparse_topk_lens=sparse_topk_lens[num_decode_tokens:num_tokens],
532 + seq_lens=seq_lens[num_decodes:num_reqs],
533 + out=output[num_decode_tokens:num_tokens],
534 + bmm1_scale=bmm1_scale,
535 + bmm2_scale=bmm2_scale,
536 + sinks=self.attn_sink,
537 + cum_seq_lens_q=prefill_cu,
538 + max_q_len=int(prefill_lens_cpu.max().item()),
539 + )
540 +
541 +
542 +class DeepseekV4FlashInferSM120Attention(DeepseekV4Attention):
543 + """DeepSeek V4 sparse MLA attention through FlashInfer's SM120 kernels."""
544 +
545 + backend_cls = DeepseekV4FlashInferMLASparseBackend
546 + swa_backend_cls = DeepseekSparseSWAFlashInferBackend
547 + use_fp8_ds_mla_layout: ClassVar[bool] = True
548 +
549 + @staticmethod
550 + def _get_workspace(device: torch.device) -> torch.Tensor:
551 + return _get_flashinfer_dsv4_workspace(device)
552 +
553 + @staticmethod
554 + def _as_sparse_cache(kv_cache: torch.Tensor) -> torch.Tensor:
555 + if kv_cache.dtype == torch.float8_e4m3fn:
556 + kv_cache = kv_cache.view(torch.uint8)
557 + if kv_cache.dim() == 4:
558 + return kv_cache
559 + return kv_cache.unsqueeze(-2)
560 +
561 + @classmethod
562 + def get_padded_num_q_heads(cls, num_heads: int) -> int:
563 + return _pad_to_supported_q_heads(num_heads)
564 +
565 + def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
566 + return deep_gemm_fp8_o_proj(
567 + o,
568 + positions,
569 + self.rotary_emb.cos_sin_cache,
570 + self.wo_a,
571 + self.wo_b,
572 + n_groups=self.n_local_groups,
573 + heads_per_group=self.n_local_heads // self.n_local_groups,
574 + nope_dim=self.nope_head_dim,
575 + rope_dim=self.rope_head_dim,
576 + o_lora_rank=self.o_lora_rank,
577 + einsum_recipe=self._einsum_recipe,
578 + tma_aligned_scales=self._tma_aligned_scales,
579 + )
580 +
581 + def __init__(self, vllm_config: VllmConfig, *args, **kwargs) -> None:
582 + super().__init__(vllm_config, *args, **kwargs)
583 + from vllm.utils.flashinfer import has_flashinfer_sparse_mla_sm120_config
584 +
585 + required_topk = _required_sm120_sparse_topk(vllm_config, self.window_size)
586 + if not has_flashinfer_sparse_mla_sm120_config(self.padded_heads, required_topk):
587 + raise RuntimeError(
588 + "FLASHINFER_MLA_SPARSE_DSV4 on SM120 requires a FlashInfer "
589 + "DSV4 sparse MLA decode specialization for "
590 + f"(num_q_heads={self.padded_heads}, top_k={required_topk}). "
591 + "Install a FlashInfer build containing "
592 + "flashinfer-ai/flashinfer#4380."
593 + )
594 + self._einsum_recipe, self._tma_aligned_scales = compute_fp8_einsum_recipe(
595 + self._o_proj_block_size
596 + )
597 + # Per-tensor FP8 cache path scales.
598 + if self.kv_cache_torch_dtype != torch.float8_e4m3fn:
599 + return
600 + fp8_q_scale = 1.0
601 + fp8_kv_scale = 1.0
602 + self.register_buffer(
603 + "_flashinfer_fp8_q_scale",
604 + torch.tensor([fp8_q_scale], dtype=torch.float32),
605 + persistent=False,
606 + )
607 + self.register_buffer(
608 + "_flashinfer_fp8_q_scale_inv",
609 + torch.tensor([1.0 / fp8_q_scale], dtype=torch.float32),
610 + persistent=False,
611 + )
612 + self.register_buffer(
613 + "_flashinfer_fp8_kv_scale",
614 + torch.tensor([fp8_kv_scale], dtype=torch.float32),
615 + persistent=False,
616 + )
617 + # FlashInfer expects scalar scale arguments for this path.
618 + self._flashinfer_fp8_bmm1_scale = self.scale * fp8_q_scale * fp8_kv_scale
619 + self._flashinfer_fp8_bmm2_scale = fp8_kv_scale
620 +
621 + def _reserve_empty_forward_workspace(self) -> None:
622 + self._get_workspace(
623 + torch.device("cuda", torch.accelerator.current_device_index())
624 + )
625 +
626 + def _forward_sparse_impl(
627 + self,
628 + q: torch.Tensor,
629 + output: torch.Tensor,
630 + flashmla_metadata: DeepseekV4FlashMLAMetadata | None,
631 + swa_metadata: "DeepseekSparseSWAMetadata",
632 + self_kv_cache: torch.Tensor | None,
633 + swa_kv_cache: torch.Tensor,
634 + swa_only: bool,
635 + ) -> None:
636 + num_decode_tokens = swa_metadata.num_decode_tokens
637 + if swa_metadata.num_prefills > 0:
638 + self._forward_prefill(
639 + q=q[num_decode_tokens:],
640 + compressed_k_cache=self_kv_cache,
641 + swa_k_cache=swa_kv_cache,
642 + output=output[num_decode_tokens:],
643 + attn_metadata=flashmla_metadata,
644 + swa_metadata=swa_metadata,
645 + )
646 + if swa_metadata.num_decodes > 0:
647 + self._forward_decode(
648 + q=q[:num_decode_tokens],
649 + kv_cache=self_kv_cache,
650 + swa_metadata=swa_metadata,
651 + attn_metadata=flashmla_metadata,
652 + swa_only=swa_only,
653 + output=output[:num_decode_tokens],
654 + )
655 +
656 + def forward_mqa(
657 + self,
658 + q: torch.Tensor,
659 + kv: torch.Tensor,
660 + positions: torch.Tensor,
661 + output: torch.Tensor,
662 + ) -> None:
663 + # Output may be padded to backend-supported head counts.
664 + assert output.shape[0] == q.shape[0] and output.shape[-1] == q.shape[-1], (
665 + f"output buffer shape {output.shape} incompatible with q shape {q.shape}"
666 + )
667 + assert output.shape[1] >= q.shape[1], (
668 + f"output heads {output.shape[1]} must be >= q heads {q.shape[1]}"
669 + )
670 + # Per-tensor FP8 q produces a bf16 attention output.
671 + expected_output_dtype = (
672 + torch.bfloat16 if q.dtype == torch.float8_e4m3fn else q.dtype
673 + )
674 + assert output.dtype == expected_output_dtype, (
675 + f"output dtype {output.dtype} must match expected {expected_output_dtype} "
676 + f"for q dtype {q.dtype}"
677 + )
678 +
679 + forward_context = get_forward_context()
680 + attn_metadata = forward_context.attn_metadata
681 + if attn_metadata is None:
682 + self._reserve_empty_forward_workspace()
683 + output.zero_()
684 + return
685 +
686 + assert isinstance(attn_metadata, dict)
687 + # Compressed-cache metadata lives on the kv-source layer's prefix;
688 + # consumers share that cache and its block table.
689 + flashmla_metadata = cast(
690 + DeepseekV4FlashMLAMetadata | None,
691 + attn_metadata.get(self.compressed_cache_prefix)
692 + if self.compressed_cache_prefix is not None
693 + else None,
694 + )
695 + swa_metadata = cast(
696 + "DeepseekSparseSWAMetadata | None",
697 + attn_metadata.get(self.swa_cache_layer.prefix),
698 + )
699 + assert swa_metadata is not None
700 +
701 + swa_only = self.compress_ratio == 0
702 + # SWA-only layers have no compressed KV cache; consumers read the kv
703 + # source's cache.
704 + self_kv_cache = None if swa_only else self._compressed_kv_cache()
705 + swa_kv_cache = self.swa_cache_layer.kv_cache
706 +
707 + self._forward_sparse_impl(
708 + q=q,
709 + output=output,
710 + flashmla_metadata=flashmla_metadata,
711 + swa_metadata=swa_metadata,
712 + self_kv_cache=self_kv_cache,
713 + swa_kv_cache=swa_kv_cache,
714 + swa_only=swa_only,
715 + )
716 +
717 + def _prepare_query(self, q: torch.Tensor, output: torch.Tensor) -> torch.Tensor:
718 + if self.kv_cache_torch_dtype == torch.float8_e4m3fn:
719 + assert q.dtype == torch.float8_e4m3fn
720 + q = q.to(torch.bfloat16)
721 + else:
722 + assert q.dtype == torch.bfloat16
723 + padded_heads = output.shape[1]
724 + if q.shape[1] < padded_heads:
725 + padded_query = q.new_zeros((q.shape[0], padded_heads, q.shape[2]))
726 + padded_query[:, : q.shape[1], :] = q
727 + q = padded_query
728 + return q.contiguous()
729 +
730 + def _forward_decode(
731 + self,
732 + q: torch.Tensor,
733 + kv_cache: torch.Tensor | None,
734 + swa_metadata: "DeepseekSparseSWAMetadata",
735 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
736 + swa_only: bool,
737 + output: torch.Tensor,
738 + ) -> None:
739 + num_decodes = swa_metadata.num_decodes
740 + num_decode_tokens = swa_metadata.num_decode_tokens
741 +
742 + extra_sparse_indices = None
743 + extra_sparse_lengths = None
744 + if not swa_only:
745 + if attn_metadata is None:
746 + raise RuntimeError(
747 + "Sparse MLA metadata is required for compressed layers."
748 + )
749 + if swa_metadata.is_valid_token is None:
750 + raise RuntimeError(
751 + "SWA validity metadata is required for compressed layers."
752 + )
753 + if self.topk_indices_buffer is None:
754 + raise RuntimeError(
755 + "Compressed-layer decode requires top-k indices from the indexer."
756 + )
757 + # Local indices filled by the index-source layer's indexer.
758 + is_valid = swa_metadata.is_valid_token[:num_decode_tokens]
759 + block_size = attn_metadata.block_size // self.compress_ratio
760 + global_indices, extra_sparse_lengths = compute_global_topk_indices_and_lens(
761 + self.topk_indices_buffer[:num_decode_tokens],
762 + swa_metadata.token_to_req_indices,
763 + attn_metadata.block_table[:num_decodes],
764 + block_size,
765 + is_valid,
766 + )
767 + extra_sparse_indices = global_indices.view(num_decode_tokens, 1, -1)
768 +
769 + swa_indices = swa_metadata.decode_swa_indices
770 + swa_lens = swa_metadata.decode_swa_lens
771 + assert swa_indices is not None
772 + assert swa_lens is not None
773 + q = self._prepare_query(q, output)
774 + swa_cache = self._as_sparse_cache(self.swa_cache_layer.kv_cache)
775 + extra_cache = self._as_sparse_cache(kv_cache) if kv_cache is not None else None
776 + if extra_cache is not None and extra_sparse_indices is None:
777 + raise RuntimeError(
778 + "Compressed sparse MLA decode requires compressed sparse indices."
779 + )
780 + flashinfer_trtllm_batch_decode_sparse_mla_dsv4(
781 + query=q,
782 + swa_kv_cache=swa_cache,
783 + workspace_buffer=self._get_workspace(q.device),
784 + sparse_indices=swa_indices,
785 + compressed_kv_cache=extra_cache,
786 + out=output,
787 + bmm1_scale=self.scale,
788 + sinks=self.attn_sink,
789 + kv_layout="NHD",
790 + swa_topk_lens=swa_lens,
791 + extra_sparse_indices=extra_sparse_indices,
792 + extra_sparse_topk_lens=extra_sparse_lengths,
793 + )
794 +
795 + def _forward_prefill(
796 + self,
797 + q: torch.Tensor,
798 + compressed_k_cache: torch.Tensor | None,
799 + swa_k_cache: torch.Tensor,
800 + output: torch.Tensor,
801 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
802 + swa_metadata: "DeepseekSparseSWAMetadata",
803 + ) -> None:
804 + swa_only = self.compress_ratio == 0
805 +
806 + num_prefills = swa_metadata.num_prefills
807 + num_decodes = swa_metadata.num_decodes
808 + num_decode_tokens = swa_metadata.num_decode_tokens
809 + num_prefill_tokens = swa_metadata.num_prefill_tokens
810 +
811 + query_start_loc_cpu = swa_metadata.query_start_loc_cpu
812 + assert query_start_loc_cpu is not None
813 + prefill_token_base = query_start_loc_cpu[num_decodes]
814 +
815 + extra_sparse_indices: torch.Tensor | None = None
816 + extra_sparse_lengths: torch.Tensor | None = None
817 + if not swa_only:
818 + if self.topk_indices_buffer is None:
819 + raise RuntimeError(
820 + "Compressed-layer prefill requires top-k indices from the indexer."
821 + )
822 + if attn_metadata is None:
823 + raise RuntimeError("Compressed-layer prefill metadata is missing.")
824 + if swa_metadata.token_to_req_indices is None:
825 + raise RuntimeError(
826 + "Compressed-layer prefill request mapping is missing."
827 + )
828 + if swa_metadata.is_valid_token is None:
829 + raise RuntimeError(
830 + "Compressed-layer prefill validity metadata is missing."
831 + )
832 + # Local indices filled by the index-source layer's indexer.
833 + local_topk_indices = self.topk_indices_buffer[
834 + num_decode_tokens : num_decode_tokens + num_prefill_tokens
835 + ]
836 + prefill_token_slice = slice(
837 + num_decode_tokens, num_decode_tokens + num_prefill_tokens
838 + )
839 + block_size = attn_metadata.block_size // self.compress_ratio
840 + extra_sparse_indices, extra_sparse_lengths = (
841 + compute_global_topk_indices_and_lens(
842 + local_topk_indices,
843 + swa_metadata.token_to_req_indices[prefill_token_slice],
844 + attn_metadata.block_table,
845 + block_size,
846 + swa_metadata.is_valid_token[prefill_token_slice],
847 + )
848 + )
849 +
850 + assert swa_metadata.prefill_swa_indices is not None
851 + assert swa_metadata.prefill_swa_lens is not None
852 +
853 + q = self._prepare_query(q, output)
854 + swa_kv_paged = self._as_sparse_cache(swa_k_cache)
855 + if swa_only:
856 + extra_kv_paged = None
857 + else:
858 + if compressed_k_cache is None:
859 + raise RuntimeError(
860 + "Compressed sparse MLA layers require their compressed KV cache."
861 + )
862 + extra_kv_paged = self._as_sparse_cache(compressed_k_cache)
863 +
864 + num_chunks = (
865 + num_prefills + self.PREFILL_CHUNK_SIZE - 1
866 + ) // self.PREFILL_CHUNK_SIZE
867 + for chunk_idx in range(num_chunks):
868 + chunk_start = chunk_idx * self.PREFILL_CHUNK_SIZE
869 + chunk_end = min(chunk_start + self.PREFILL_CHUNK_SIZE, num_prefills)
870 + query_start = (
871 + query_start_loc_cpu[num_decodes + chunk_start] - prefill_token_base
872 + )
873 + query_end = (
874 + query_start_loc_cpu[num_decodes + chunk_end] - prefill_token_base
875 + )
876 +
877 + extra_sparse_indices_chunk = (
878 + extra_sparse_indices[query_start:query_end]
879 + if extra_sparse_indices is not None
880 + else None
881 + )
882 + extra_sparse_lengths_chunk = (
883 + extra_sparse_lengths[query_start:query_end]
884 + if extra_sparse_lengths is not None
885 + else None
886 + )
887 +
888 + q_chunk = q[query_start:query_end]
889 + swa_indices_chunk = swa_metadata.prefill_swa_indices[query_start:query_end]
890 + swa_lens_chunk = swa_metadata.prefill_swa_lens[query_start:query_end]
891 + if extra_kv_paged is not None and extra_sparse_indices_chunk is None:
892 + raise RuntimeError(
893 + "Compressed sparse MLA prefill requires compressed sparse indices."
894 + )
895 + flashinfer_trtllm_batch_decode_sparse_mla_dsv4(
896 + query=q_chunk,
897 + swa_kv_cache=swa_kv_paged,
898 + workspace_buffer=self._get_workspace(q.device),
899 + sparse_indices=swa_indices_chunk,
900 + compressed_kv_cache=extra_kv_paged,
901 + out=output[query_start:query_end],
902 + bmm1_scale=self.scale,
903 + sinks=self.attn_sink,
904 + kv_layout="NHD",
905 + swa_topk_lens=swa_lens_chunk,
906 + extra_sparse_indices=extra_sparse_indices_chunk,
907 + extra_sparse_topk_lens=extra_sparse_lengths_chunk,
908 + )
vllm/models/deepseek_v4_1/nvidia/flashmla.py new
+387
@@ -0,0 +1,387 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from typing import TYPE_CHECKING, ClassVar, cast
5 +
6 +import torch
7 +
8 +from vllm.forward_context import get_forward_context
9 +from vllm.models.deepseek_v4.nvidia.ops.o_proj import (
10 + compute_fp8_einsum_recipe,
11 + deep_gemm_fp8_o_proj,
12 +)
13 +from vllm.models.deepseek_v4_1.attention import DeepseekV4Attention
14 +from vllm.models.deepseek_v4_1.common.ops import (
15 + combine_topk_swa_indices,
16 + compute_global_topk_indices_and_lens,
17 + dequantize_and_gather_k_cache,
18 +)
19 +from vllm.models.deepseek_v4_1.sparse_mla import (
20 + DeepseekV4FlashMLABackend,
21 + DeepseekV4FlashMLAMetadata,
22 + DeepseekV41SparseSWAMetadataBuilder,
23 +)
24 +from vllm.utils.math_utils import round_up
25 +from vllm.v1.attention.backend import AttentionCGSupport
26 +from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWABackend
27 +from vllm.v1.attention.ops.flashmla import (
28 + flash_mla_sparse_fwd,
29 + flash_mla_with_kvcache,
30 +)
31 +from vllm.v1.worker.workspace import current_workspace_manager
32 +
33 +if TYPE_CHECKING:
34 + from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
35 +
36 +
37 +class DeepseekSparseSWAFlashMLAMetadataBuilder(DeepseekV41SparseSWAMetadataBuilder):
38 + """SWA metadata for the FlashMLA decode path, which allows varlen decode."""
39 +
40 + _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
41 +
42 +
43 +class DeepseekSparseSWAFlashMLABackend(DeepseekSparseSWABackend):
44 + @staticmethod
45 + def get_builder_cls() -> type[DeepseekSparseSWAFlashMLAMetadataBuilder]:
46 + return DeepseekSparseSWAFlashMLAMetadataBuilder
47 +
48 +
49 +class DeepseekV4FlashMLAAttention(DeepseekV4Attention):
50 + """FlashMLA sparse MLA attention layer for DeepSeek V4.1 (CUDA)."""
51 +
52 + backend_cls = DeepseekV4FlashMLABackend
53 + swa_backend_cls = DeepseekSparseSWAFlashMLABackend
54 +
55 + def __init__(self, *args, **kwargs) -> None:
56 + super().__init__(*args, **kwargs)
57 + self._einsum_recipe, self._tma_aligned_scales = compute_fp8_einsum_recipe(
58 + self._o_proj_block_size
59 + )
60 +
61 + def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
62 + return deep_gemm_fp8_o_proj(
63 + o,
64 + positions,
65 + self.rotary_emb.cos_sin_cache,
66 + self.wo_a,
67 + self.wo_b,
68 + n_groups=self.n_local_groups,
69 + heads_per_group=self.n_local_heads // self.n_local_groups,
70 + nope_dim=self.nope_head_dim,
71 + rope_dim=self.rope_head_dim,
72 + o_lora_rank=self.o_lora_rank,
73 + einsum_recipe=self._einsum_recipe,
74 + tma_aligned_scales=self._tma_aligned_scales,
75 + )
76 +
77 + @classmethod
78 + def get_padded_num_q_heads(cls, num_heads: int) -> int:
79 + # FP8 decode kernel only supports h_q = 64 or 128.
80 + if num_heads > 128:
81 + raise ValueError(
82 + f"DeepseekV4 FlashMLA does not support {num_heads} heads "
83 + "(FP8 decode kernel requires h_q in {64, 128})."
84 + )
85 + return 64 if num_heads <= 64 else 128
86 +
87 + def forward_mqa(
88 + self,
89 + q: torch.Tensor,
90 + kv: torch.Tensor,
91 + positions: torch.Tensor,
92 + output: torch.Tensor,
93 + ) -> None:
94 + assert output.shape == q.shape, (
95 + f"output buffer shape {output.shape} must match q shape {q.shape}"
96 + )
97 + assert output.dtype == q.dtype, (
98 + f"output buffer dtype {output.dtype} must match q dtype {q.dtype}"
99 + )
100 +
101 + # Get SWA and indexer metadata from forward context
102 + forward_context = get_forward_context()
103 + attn_metadata = forward_context.attn_metadata
104 +
105 + if attn_metadata is None:
106 + # Warmup dummy run: no real metadata. Reserve the same bf16
107 + # gather workspace _forward_prefill would; the dequantize / topk
108 + # / sparse_fwd kernels are skipped this step.
109 + swa_only = self.compress_ratio == 0
110 + N = (
111 + 0
112 + if swa_only
113 + else (self.max_model_len + self.compress_ratio - 1)
114 + // self.compress_ratio
115 + )
116 + M = N + self.window_size + self.max_num_batched_tokens
117 + if swa_only:
118 + top_k = 0
119 + else:
120 + assert self.topk_indices_buffer is not None
121 + top_k = self.topk_indices_buffer.shape[-1]
122 + combined_topk = round_up(
123 + top_k + self.window_size + self.max_image_tokens, 128
124 + )
125 + current_workspace_manager().get_simultaneous(
126 + ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
127 + ((self.max_num_batched_tokens, combined_topk), torch.int32),
128 + ((self.max_num_batched_tokens,), torch.int32),
129 + )
130 + output.zero_()
131 + return
132 +
133 + assert isinstance(attn_metadata, dict)
134 + # Compressed-cache metadata lives on the kv-source layer's prefix;
135 + # consumers share that cache and its block table.
136 + flashmla_metadata = cast(
137 + DeepseekV4FlashMLAMetadata | None,
138 + attn_metadata.get(self.compressed_cache_prefix)
139 + if self.compressed_cache_prefix is not None
140 + else None,
141 + )
142 + swa_metadata = cast(
143 + "DeepseekSparseSWAMetadata | None",
144 + attn_metadata.get(self.swa_cache_layer.prefix),
145 + )
146 + assert swa_metadata is not None
147 +
148 + swa_only = self.compress_ratio == 0
149 + # SWA-only layers (compress_ratio == 0) don't have their own KV cache
150 + # allocation; consumers read the kv source's cache, which is bound on
151 + # the source layer.
152 + self_kv_cache = None if swa_only else self._compressed_kv_cache()
153 + swa_kv_cache = self.swa_cache_layer.kv_cache
154 +
155 + # Split prefill and decode
156 + num_decodes = swa_metadata.num_decodes
157 + num_prefills = swa_metadata.num_prefills
158 + num_decode_tokens = swa_metadata.num_decode_tokens
159 +
160 + if num_prefills > 0:
161 + self._forward_prefill(
162 + q=q[num_decode_tokens:],
163 + positions=positions[num_decode_tokens:],
164 + compressed_k_cache=self_kv_cache,
165 + swa_k_cache=swa_kv_cache,
166 + output=output[num_decode_tokens:],
167 + attn_metadata=flashmla_metadata,
168 + swa_metadata=swa_metadata,
169 + )
170 + if num_decodes > 0:
171 + self._forward_decode(
172 + q=q[:num_decode_tokens],
173 + kv_cache=self_kv_cache,
174 + swa_metadata=swa_metadata,
175 + attn_metadata=flashmla_metadata,
176 + swa_only=swa_only,
177 + output=output[:num_decode_tokens],
178 + )
179 +
180 + def _forward_decode(
181 + self,
182 + q: torch.Tensor,
183 + kv_cache: torch.Tensor | None, # None for SWA-only layers
184 + swa_metadata: "DeepseekSparseSWAMetadata",
185 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
186 + swa_only: bool,
187 + output: torch.Tensor,
188 + ) -> None:
189 + num_decodes = swa_metadata.num_decodes
190 + num_decode_tokens = swa_metadata.num_decode_tokens
191 +
192 + topk_indices = None
193 + topk_lens = None
194 + if not swa_only:
195 + # Local indices filled by the index-source layer's indexer.
196 + assert attn_metadata is not None
197 + assert swa_metadata.is_valid_token is not None
198 + assert self.topk_indices_buffer is not None
199 + block_size = attn_metadata.block_size // self.compress_ratio
200 + is_valid = swa_metadata.is_valid_token[:num_decode_tokens]
201 + global_indices, topk_lens = compute_global_topk_indices_and_lens(
202 + self.topk_indices_buffer[:num_decode_tokens],
203 + swa_metadata.token_to_req_indices,
204 + attn_metadata.block_table[:num_decodes],
205 + block_size,
206 + is_valid,
207 + )
208 + topk_indices = global_indices.view(num_decode_tokens, 1, -1)
209 +
210 + swa_indices = swa_metadata.decode_swa_indices
211 + swa_lens = swa_metadata.decode_swa_lens
212 +
213 + # We treat queries in the same seq as different queries
214 + # and later we only attend by generated indices.
215 + # q arrives pre-padded to self.padded_heads by the outer wrapper.
216 + q = q.unsqueeze(1)
217 +
218 + # Prepare SWA cache (num_blocks, swa_block_size, 1, head_bytes)
219 + # Use unsqueeze to preserve strides (handles padded blocks correctly)
220 + swa_cache = self.swa_cache_layer.kv_cache.unsqueeze(-2)
221 + # Reshape KV cache to (num_blocks, block_size, 1, head_bytes)
222 + if kv_cache is not None:
223 + kv_cache = kv_cache.unsqueeze(-2)
224 +
225 + # One FlashMLASchedMeta per layer type, shared across all same-type
226 + # layers within this decode step. The first forward call per type
227 + # triggers the in-kernel planner (allocating tile_scheduler_metadata
228 + # and num_splits via PyTorch's graph-aware allocator so CUDA graph
229 + # capture reuses the same addresses on replay); subsequent same-type
230 + # layers see have_initialized=True and skip the planner.
231 + tile_metadata = {
232 + 0: swa_metadata.tile_sched_swaonly,
233 + 1: swa_metadata.tile_sched_c1a,
234 + 2: swa_metadata.tile_sched_c2a,
235 + }[self.compress_ratio]
236 + assert tile_metadata is not None, (
237 + "swa_metadata missing tile_sched entry for "
238 + f"compress_ratio={self.compress_ratio}; "
239 + "DeepseekSparseSWAMetadataBuilder.build_tile_scheduler did not "
240 + "allocate one for this layer type."
241 + )
242 +
243 + out, _ = flash_mla_with_kvcache(
244 + q=q,
245 + k_cache=swa_cache,
246 + block_table=None,
247 + head_dim_v=512,
248 + tile_scheduler_metadata=tile_metadata,
249 + cache_seqlens=None,
250 + is_fp8_kvcache=True,
251 + indices=swa_indices,
252 + topk_length=swa_lens,
253 + softmax_scale=self.scale,
254 + attn_sink=self.attn_sink,
255 + extra_k_cache=kv_cache if not swa_only else None,
256 + extra_indices_in_kvcache=topk_indices,
257 + extra_topk_length=topk_lens,
258 + out=output.unsqueeze(1),
259 + )
260 +
261 + def _forward_prefill(
262 + self,
263 + q: torch.Tensor,
264 + positions: torch.Tensor,
265 + compressed_k_cache: torch.Tensor | None, # None for SWA-only layers
266 + swa_k_cache: torch.Tensor,
267 + output: torch.Tensor,
268 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
269 + swa_metadata: "DeepseekSparseSWAMetadata",
270 + ) -> None:
271 + swa_only = self.compress_ratio == 0
272 +
273 + num_prefill_tokens = swa_metadata.num_prefill_tokens
274 + num_decodes = swa_metadata.num_decodes
275 + num_decode_tokens = swa_metadata.num_decode_tokens
276 +
277 + # Use pre-computed prefill metadata.
278 + seq_lens = swa_metadata.prefill_seq_lens
279 + gather_lens = swa_metadata.prefill_gather_lens
280 + assert seq_lens is not None
281 + assert gather_lens is not None
282 +
283 + # Derive prefill-local token offsets from the full query_start_loc_cpu.
284 + query_start_loc_cpu = swa_metadata.query_start_loc_cpu
285 + query_start_loc = swa_metadata.query_start_loc
286 + assert query_start_loc_cpu is not None
287 + assert query_start_loc is not None
288 + prefill_token_base = query_start_loc_cpu[num_decodes]
289 +
290 + # Local indices filled by the index source; SWA-only layers pass
291 + # top_k=0 and never read them.
292 + assert self.topk_indices_buffer is not None
293 + topk_indices = self.topk_indices_buffer[num_decode_tokens:]
294 + topk_indices = topk_indices[:num_prefill_tokens]
295 + top_k = 0 if swa_only else topk_indices.shape[-1]
296 + chunk_plan = swa_metadata.get_prefill_chunk_plan(
297 + compress_ratio=self.compress_ratio,
298 + prefill_chunk_size=self.PREFILL_CHUNK_SIZE,
299 + # v4.1: every cr>0 layer (including cr==1) gathers a full-length
300 + # compressed region; only cr==0 layers are SWA-only.
301 + has_compressed=not swa_only,
302 + )
303 + assert chunk_plan, "prefill chunk plan must be non-empty when num_prefills > 0"
304 + workspace_manager = current_workspace_manager()
305 + combined_topk = round_up(top_k + self.window_size + self.max_image_tokens, 128)
306 + for chunk_start, chunk_end, chunk_N, chunk_M in chunk_plan:
307 + chunk_size = chunk_end - chunk_start
308 + workspace = workspace_manager.get_simultaneous(
309 + ((chunk_size, chunk_M, q.shape[-1]), torch.bfloat16),
310 + ((self.max_num_batched_tokens, combined_topk), torch.int32),
311 + ((self.max_num_batched_tokens,), torch.int32),
312 + )
313 + kv, combined_indices_out, combined_lens_out = workspace
314 + if not swa_only:
315 + # Gather compressed KV
316 + assert attn_metadata is not None
317 + block_table = attn_metadata.block_table[num_decodes:]
318 + dequantize_and_gather_k_cache(
319 + kv[:chunk_size],
320 + compressed_k_cache,
321 + seq_lens=seq_lens[chunk_start:chunk_end] // self.compress_ratio,
322 + gather_lens=None,
323 + block_table=block_table[chunk_start:chunk_end],
324 + block_size=attn_metadata.block_size // self.compress_ratio,
325 + offset=0,
326 + )
327 +
328 + # Gather SWA KV
329 + swa_block_table = swa_metadata.block_table[num_decodes:]
330 + dequantize_and_gather_k_cache(
331 + kv[:chunk_size],
332 + swa_k_cache,
333 + seq_lens=seq_lens[chunk_start:chunk_end],
334 + gather_lens=gather_lens[chunk_start:chunk_end],
335 + block_table=swa_block_table[chunk_start:chunk_end],
336 + block_size=swa_metadata.block_size,
337 + offset=chunk_N,
338 + )
339 +
340 + # Combine the topk indices and SWA indices for gathered KV cache
341 + query_start = (
342 + query_start_loc_cpu[num_decodes + chunk_start] - prefill_token_base
343 + )
344 + query_end = (
345 + query_start_loc_cpu[num_decodes + chunk_end] - prefill_token_base
346 + )
347 + combined_indices_out = combined_indices_out[: query_end - query_start]
348 + combined_lens_out = combined_lens_out[: query_end - query_start]
349 +
350 + combined_indices, combined_lens = combine_topk_swa_indices(
351 + topk_indices[query_start:query_end],
352 + query_start_loc[
353 + num_decodes + chunk_start : num_decodes + chunk_end + 1
354 + ],
355 + seq_lens[chunk_start:chunk_end],
356 + gather_lens[chunk_start:chunk_end],
357 + self.window_size,
358 + self.compress_ratio,
359 + top_k,
360 + chunk_M,
361 + chunk_N,
362 + out=(combined_indices_out, combined_lens_out),
363 + left_visible=(
364 + swa_metadata.prefill_left_visible[
365 + num_decode_tokens + query_start : num_decode_tokens + query_end
366 + ]
367 + if swa_metadata.prefill_left_visible is not None
368 + else None
369 + ),
370 + right_visible=(
371 + swa_metadata.prefill_right_visible[
372 + num_decode_tokens + query_start : num_decode_tokens + query_end
373 + ]
374 + if swa_metadata.prefill_right_visible is not None
375 + else None
376 + ),
377 + max_image_tokens=self.max_image_tokens,
378 + )
379 + flash_mla_sparse_fwd(
380 + q=q[query_start:query_end],
381 + kv=kv.view(-1, 1, q.shape[-1]),
382 + indices=combined_indices.unsqueeze(1),
383 + sm_scale=self.scale,
384 + attn_sink=self.attn_sink,
385 + topk_length=combined_lens,
386 + out=output[query_start:query_end],
387 + )
vllm/models/deepseek_v4_1/nvidia/model.py new
+1111
@@ -0,0 +1,1111 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import typing
4 +from collections.abc import Callable, Iterable
5 +from itertools import islice
6 +
7 +import regex as re
8 +import torch
9 +import torch.nn as nn
10 +
11 +import vllm.envs as envs
12 +from vllm.config import VllmConfig
13 +from vllm.config.kernel import MEGA_MOE_BACKENDS
14 +from vllm.distributed import (
15 + get_pp_group,
16 + get_tensor_model_parallel_rank,
17 + get_tensor_model_parallel_world_size,
18 +)
19 +from vllm.forward_context import get_forward_context, is_forward_context_available
20 +from vllm.logger import init_logger
21 +from vllm.model_executor.kernels.mhc.tilelang import (
22 + mhc_post_tilelang,
23 + mhc_pre_delayed_tilelang,
24 +)
25 +from vllm.model_executor.kernels.mhc.triton import hc_collapse_triton
26 +from vllm.model_executor.layers.fused_moe import (
27 + fused_moe_make_expert_params_mapping,
28 +)
29 +from vllm.model_executor.layers.layernorm import RMSNorm
30 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
31 +from vllm.model_executor.layers.quantization import QuantizationConfig
32 +from vllm.model_executor.layers.vocab_parallel_embedding import (
33 + ParallelLMHead,
34 + VocabParallelEmbedding,
35 +)
36 +from vllm.model_executor.model_loader.weight_utils import default_weight_loader
37 +from vllm.model_executor.models.interfaces import (
38 + EagleModelMixin,
39 + MixtureOfExperts,
40 + SupportsEagle3,
41 + SupportsLoRA,
42 + SupportsPP,
43 +)
44 +from vllm.model_executor.models.utils import (
45 + AutoWeightsLoader,
46 + PPMissingLayer,
47 + WeightsMapper,
48 + extract_layer_index,
49 + is_pp_missing_parameter,
50 + make_layers,
51 + maybe_prefix,
52 +)
53 +from vllm.models.common.ops.sequence_parallel import (
54 + sp_all_gather,
55 + sp_padding_mask,
56 + sp_reduce_scatter,
57 + sp_shard,
58 +)
59 +from vllm.models.deepseek_v4.nvidia.model import (
60 + DeepseekV4MoE as DeepseekV4MoEBase,
61 +)
62 +from vllm.models.deepseek_v4.nvidia.model import (
63 + make_deepseek_v4_expert_params_mapping,
64 +)
65 +from vllm.models.deepseek_v4_1.attention import DeepseekV4Attention
66 +from vllm.models.deepseek_v4_1.nvidia.flashinfer_sparse import (
67 + DeepseekV4FlashInferMLAAttention,
68 + DeepseekV4FlashInferSM120Attention,
69 +)
70 +from vllm.models.deepseek_v4_1.nvidia.flashmla import DeepseekV4FlashMLAAttention
71 +from vllm.platforms import current_platform
72 +from vllm.sequence import IntermediateTensors
73 +from vllm.utils.math_utils import cdiv
74 +from vllm.v1.attention.backends.registry import AttentionBackendEnum
75 +from vllm.v1.worker.ubatching import dbo_current_ubatch_id
76 +
77 +from ..common.engram import Engram, EngramLayout, NgramHashState
78 +from ..common.mm_preprocess import IMAGE_SENTINEL_BASE_ID, image_sentinel_mask
79 +
80 +if typing.TYPE_CHECKING:
81 + from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
82 +
83 +logger = init_logger(__name__)
84 +
85 +
86 +class DeepseekV4MoE(DeepseekV4MoEBase):
87 + def __init__(
88 + self,
89 + vllm_config: VllmConfig,
90 + prefix: str = "",
91 + use_sequence_parallel: bool = False,
92 + ):
93 + config = vllm_config.model_config.hf_config
94 + n_routed_experts = config.n_routed_experts
95 + n_activated_experts = config.num_experts_per_tok
96 + if extract_layer_index(prefix) >= config.num_hidden_layers:
97 + n_routed_experts = (
98 + getattr(config, "dspark_n_routed_experts", 0) or n_routed_experts
99 + )
100 + n_activated_experts = (
101 + getattr(config, "dspark_num_experts_per_tok", 0) or n_activated_experts
102 + )
103 + super().__init__(
104 + vllm_config,
105 + prefix=prefix,
106 + use_sequence_parallel=use_sequence_parallel,
107 + n_routed_experts=n_routed_experts,
108 + n_activated_experts=n_activated_experts,
109 + num_hash_layers=0,
110 + image_sentinel_lo=IMAGE_SENTINEL_BASE_ID,
111 + )
112 +
113 +
114 +def _select_dsv4_attn_cls(vllm_config: VllmConfig) -> type[DeepseekV4Attention]:
115 + """Pick the CUDA sparse-MLA attention class for the configured backend.
116 +
117 + The generic CUDA backend selector does not instantiate DSv4 layers directly,
118 + so map generic sparse-MLA choices to the DSv4-specialized attention class.
119 + Without an explicit backend, SM12 defaults to FlashInfer while the other
120 + CUDA arches keep the FlashMLA path.
121 + """
122 + backend = vllm_config.attention_config.backend
123 + device_capability = current_platform.get_device_capability()
124 + if backend in (
125 + AttentionBackendEnum.FLASHINFER_MLA_SPARSE,
126 + AttentionBackendEnum.FLASHINFER_MLA_SPARSE_SM120,
127 + ):
128 + raise ValueError(
129 + f"{backend.name} is not a DeepSeek V4.1 attention backend. "
130 + "Use FLASHINFER_MLA_SPARSE_DSV41 for DeepSeek V4.1 FlashInfer "
131 + "sparse MLA."
132 + )
133 + if backend in (
134 + AttentionBackendEnum.FLASHINFER_MLA_SPARSE_DSV4,
135 + AttentionBackendEnum.FLASHINFER_MLA_SPARSE_DSV41,
136 + ):
137 + if device_capability is not None and device_capability.major == 12:
138 + return DeepseekV4FlashInferSM120Attention
139 + return DeepseekV4FlashInferMLAAttention
140 + if backend in (
141 + AttentionBackendEnum.FLASHMLA_SPARSE,
142 + AttentionBackendEnum.FLASHMLA_SPARSE_DSV4,
143 + AttentionBackendEnum.FLASHMLA_SPARSE_DSV41,
144 + ):
145 + return DeepseekV4FlashMLAAttention
146 +
147 + if device_capability is not None and device_capability.major == 12:
148 + return DeepseekV4FlashInferSM120Attention
149 + return DeepseekV4FlashMLAAttention
150 +
151 +
152 +def _use_sequence_parallel(vllm_config: VllmConfig) -> bool:
153 + parallel_config = vllm_config.parallel_config
154 + use_mega_moe = vllm_config.kernel_config.moe_backend in MEGA_MOE_BACKENDS
155 + return (
156 + parallel_config.pipeline_parallel_size == 1
157 + and parallel_config.enable_expert_parallel
158 + and parallel_config.tensor_parallel_size > 1
159 + and (use_mega_moe or parallel_config.data_parallel_size > 1)
160 + )
161 +
162 +
163 +class DeepseekV4DecoderLayer(nn.Module):
164 + def __init__(
165 + self,
166 + vllm_config,
167 + prefix,
168 + topk_indices_buffer: torch.Tensor | None = None,
169 + aux_stream_list: list[torch.cuda.Stream] | None = None,
170 + candidate_block_buffer: torch.Tensor | None = None,
171 + engram_layout: EngramLayout | None = None,
172 + ):
173 + super().__init__()
174 +
175 + config = vllm_config.model_config.hf_config
176 + self.hidden_size = config.hidden_size
177 + self.use_sequence_parallel = _use_sequence_parallel(vllm_config)
178 +
179 + self.engram: Engram | None = None
180 + if engram_layout is not None:
181 + layer_id = extract_layer_index(prefix)
182 + if layer_id in engram_layout.layer_ids:
183 + self.engram = Engram(
184 + config,
185 + vllm_config.quant_config,
186 + engram_layout,
187 + engram_layout.layer_ids.index(layer_id),
188 + use_sequence_parallel=self.use_sequence_parallel,
189 + prefix=f"{prefix}.engram",
190 + )
191 +
192 + self.rms_norm_eps = config.rms_norm_eps
193 + self.attn = _select_dsv4_attn_cls(vllm_config)(
194 + vllm_config,
195 + prefix=f"{prefix}.attn",
196 + topk_indices_buffer=topk_indices_buffer,
197 + aux_stream_list=aux_stream_list,
198 + candidate_block_buffer=candidate_block_buffer,
199 + )
200 + if self.use_sequence_parallel:
201 + self.attn.wo_b.reduce_results = False
202 + self.ffn = DeepseekV4MoE(
203 + vllm_config,
204 + prefix=f"{prefix}.ffn",
205 + use_sequence_parallel=self.use_sequence_parallel,
206 + )
207 +
208 + self.attn_norm = RMSNorm(self.hidden_size, self.rms_norm_eps)
209 + self.ffn_norm = RMSNorm(self.hidden_size, self.rms_norm_eps)
210 + self.hc_mult = config.hc_mult
211 + self.hc_sinkhorn_iters = config.hc_sinkhorn_iters
212 + self.hc_eps = config.hc_eps
213 + self.hc_post_alpha = 2.0
214 + if vllm_config.kernel_config.enable_jit_warmup and current_platform.is_cuda():
215 + from vllm.model_executor.kernels.mhc.warmup import MHC_PRE_NORM_KERNEL
216 +
217 + max_tokens = vllm_config.scheduler_config.max_num_batched_tokens
218 + if self.use_sequence_parallel:
219 + tp_size = vllm_config.parallel_config.tensor_parallel_size
220 + max_tokens = (max_tokens + tp_size - 1) // tp_size
221 + for input_size, use_pre_mix in (
222 + (self.hidden_size, False),
223 + (self.hc_mult * self.hidden_size, False),
224 + (self.hc_mult * self.hidden_size, True),
225 + ):
226 + MHC_PRE_NORM_KERNEL.register_warmup(
227 + max_tokens=max_tokens,
228 + hidden_size=self.hidden_size,
229 + rms_eps=self.rms_norm_eps,
230 + hc_pre_eps=self.hc_eps,
231 + hc_sinkhorn_eps=self.hc_eps,
232 + hc_post_mult_value=self.hc_post_alpha,
233 + sinkhorn_repeat=self.hc_sinkhorn_iters,
234 + norm_eps=self.rms_norm_eps,
235 + hc_mult=self.hc_mult,
236 + use_pre_mix_in=use_pre_mix,
237 + rms_numel=input_size,
238 + )
239 + mix_hc = (2 + self.hc_mult) * self.hc_mult
240 + hc_dim = self.hc_mult * self.hidden_size
241 + self.hc_attn_fn = nn.Parameter(
242 + torch.empty(
243 + (mix_hc, hc_dim),
244 + dtype=torch.float32,
245 + ),
246 + requires_grad=False,
247 + )
248 + self.hc_attn_fn_broadcast: torch.Tensor | None = None
249 + self.hc_ffn_fn = nn.Parameter(
250 + torch.empty(
251 + (mix_hc, hc_dim),
252 + dtype=torch.float32,
253 + ),
254 + requires_grad=False,
255 + )
256 + self.hc_attn_base = nn.Parameter(
257 + torch.empty(
258 + mix_hc,
259 + dtype=torch.float32,
260 + ),
261 + requires_grad=False,
262 + )
263 + self.hc_ffn_base = nn.Parameter(
264 + torch.empty(
265 + mix_hc,
266 + dtype=torch.float32,
267 + ),
268 + requires_grad=False,
269 + )
270 + self.hc_attn_scale = nn.Parameter(
271 + torch.empty(
272 + 3,
273 + dtype=torch.float32,
274 + ),
275 + requires_grad=False,
276 + )
277 + self.hc_ffn_scale = nn.Parameter(
278 + torch.empty(
279 + 3,
280 + dtype=torch.float32,
281 + ),
282 + requires_grad=False,
283 + )
284 +
285 + def forward(
286 + self,
287 + x: torch.Tensor,
288 + positions: torch.Tensor,
289 + input_ids: torch.Tensor | None,
290 + pre_mix: torch.Tensor | None = None,
291 + post_mix: torch.Tensor | None = None,
292 + res_mix: torch.Tensor | None = None,
293 + residual: torch.Tensor | None = None,
294 + engram_hashes: torch.Tensor | None = None,
295 + engram_mask: torch.Tensor | None = None,
296 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
297 + # The reference collapses each sublayer's input with the *previous*
298 + # sublayer's pre-mix: attention uses the pre-mix carried in (identity
299 + # for the first layer), the FFN uses this layer's attention pre-mix.
300 + if residual is None:
301 + if x.dim() == 2:
302 + # First layer: the stream is the embedding broadcast to hc
303 + # copies and the identity pre-mix selects copy 0.
304 + assert self.hc_attn_fn_broadcast is not None
305 + residual = x.unsqueeze(1).expand(-1, self.hc_mult, -1).contiguous()
306 + post_mix, res_mix, x, attn_pre = mhc_pre_delayed_tilelang(
307 + residual,
308 + self.hc_attn_fn_broadcast,
309 + self.hc_attn_scale,
310 + self.hc_attn_base,
311 + self.rms_norm_eps,
312 + self.hc_eps,
313 + self.hc_eps,
314 + self.hc_post_alpha,
315 + self.hc_sinkhorn_iters,
316 + x=x,
317 + norm_weight=self.attn_norm.weight,
318 + norm_eps=self.attn_norm.variance_epsilon,
319 + )
320 + else:
321 + residual = x
322 + post_mix, res_mix, x, attn_pre = mhc_pre_delayed_tilelang(
323 + residual,
324 + self.hc_attn_fn,
325 + self.hc_attn_scale,
326 + self.hc_attn_base,
327 + self.rms_norm_eps,
328 + self.hc_eps,
329 + self.hc_eps,
330 + self.hc_post_alpha,
331 + self.hc_sinkhorn_iters,
332 + pre_mix=pre_mix,
333 + norm_weight=self.attn_norm.weight,
334 + norm_eps=self.attn_norm.variance_epsilon,
335 + )
336 + else:
337 + residual = mhc_post_tilelang(x, residual, post_mix, res_mix)
338 + if self.engram is not None and engram_hashes is not None:
339 + # Engram injection happens between the previous sublayer's
340 + # post and this block's pre, on the full hc stream, so the
341 + # mix coefficients see the injected stream.
342 + residual = self.engram(
343 + residual,
344 + engram_hashes[:, self.engram.layer_hash_index],
345 + engram_mask,
346 + )
347 + post_mix, res_mix, x, attn_pre = mhc_pre_delayed_tilelang(
348 + residual,
349 + self.hc_attn_fn,
350 + self.hc_attn_scale,
351 + self.hc_attn_base,
352 + self.rms_norm_eps,
353 + self.hc_eps,
354 + self.hc_eps,
355 + self.hc_post_alpha,
356 + self.hc_sinkhorn_iters,
357 + pre_mix=pre_mix,
358 + norm_weight=self.attn_norm.weight,
359 + norm_eps=self.attn_norm.variance_epsilon,
360 + )
361 +
362 + if self.use_sequence_parallel:
363 + x = sp_all_gather(x)[: positions.shape[0]]
364 +
365 + x = self.attn(positions, x, None)
366 + if self.use_sequence_parallel:
367 + x = sp_reduce_scatter(x)
368 +
369 + residual = mhc_post_tilelang(x, residual, post_mix, res_mix)
370 + post_mix, res_mix, x, ffn_pre = mhc_pre_delayed_tilelang(
371 + residual,
372 + self.hc_ffn_fn,
373 + self.hc_ffn_scale,
374 + self.hc_ffn_base,
375 + self.rms_norm_eps,
376 + self.hc_eps,
377 + self.hc_eps,
378 + self.hc_post_alpha,
379 + self.hc_sinkhorn_iters,
380 + pre_mix=attn_pre,
381 + norm_weight=self.ffn_norm.weight,
382 + norm_eps=self.ffn_norm.variance_epsilon,
383 + )
384 + x = self.ffn(x, input_ids)
385 + return x, residual, post_mix, res_mix, ffn_pre
386 +
387 +
388 +class DeepseekV4Model(nn.Module, EagleModelMixin):
389 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
390 + super().__init__()
391 +
392 + config = vllm_config.model_config.hf_config
393 + quant_config = vllm_config.quant_config
394 + self.config = config
395 + self.quant_config = quant_config
396 + self.parallel_config = vllm_config.parallel_config
397 + self.use_mega_moe = vllm_config.kernel_config.moe_backend in MEGA_MOE_BACKENDS
398 + self.use_sequence_parallel = _use_sequence_parallel(vllm_config)
399 + if self.use_mega_moe and not vllm_config.parallel_config.enable_expert_parallel:
400 + raise NotImplementedError(
401 + "DeepSeek V4 MegaMoE currently requires expert parallel. "
402 + "Enable it with --enable-expert-parallel, or pick a different "
403 + "moe backend."
404 + )
405 + self.vocab_size = config.vocab_size
406 + self.hc_eps = config.hc_eps
407 + self.hc_mult = config.hc_mult
408 + self.hc_dim = self.hc_mult * config.hidden_size
409 + self.rms_norm_eps = config.rms_norm_eps
410 +
411 + # Three aux streams: one per non-default input GEMM in
412 + # DeepseekV4Attention._run_parallel_input_projections
413 + # (compressor kv_score, indexer.weights_proj). fused_wqa_wkv stays on
414 + # the default stream.
415 + aux_stream_list = [torch.cuda.Stream() for _ in range(3)]
416 +
417 + # Reserved topk indices buffer for all Indexer layers to reuse.
418 + self.topk_indices_buffer = torch.empty(
419 + vllm_config.scheduler_config.max_num_batched_tokens,
420 + config.index_topk,
421 + dtype=torch.int32,
422 + )
423 +
424 + # Two-level candidate filtering: the indexer at
425 + # candidate_source_layer_id publishes the top candidate blocks of
426 + # compressed positions here; later ratio-1 indexers (24/28/32/36)
427 + # mask their scores with it.
428 + candidate_source_layer = getattr(config, "candidate_source_layer_id", -1)
429 + candidate_topk_blocks = getattr(config, "candidate_topk_blocks", 0)
430 + if candidate_source_layer >= 0 and candidate_topk_blocks > 0:
431 + self.candidate_block_buffer = torch.empty(
432 + vllm_config.scheduler_config.max_num_batched_tokens,
433 + candidate_topk_blocks,
434 + dtype=torch.int32,
435 + )
436 + else:
437 + self.candidate_block_buffer = None
438 +
439 + if get_pp_group().is_first_rank:
440 + self.embed_tokens = VocabParallelEmbedding(
441 + config.vocab_size,
442 + config.hidden_size,
443 + quant_config=quant_config,
444 + prefix=f"{prefix}.embed_tokens",
445 + )
446 + else:
447 + self.embed_tokens = PPMissingLayer()
448 +
449 + self.engram_layout = EngramLayout.from_config(config)
450 +
451 + self.start_layer, self.end_layer, self.layers = make_layers(
452 + config.num_hidden_layers,
453 + lambda prefix: DeepseekV4DecoderLayer(
454 + vllm_config,
455 + prefix=prefix,
456 + topk_indices_buffer=self.topk_indices_buffer,
457 + aux_stream_list=aux_stream_list,
458 + candidate_block_buffer=self.candidate_block_buffer,
459 + engram_layout=self.engram_layout,
460 + ),
461 + prefix=f"{prefix}.layers",
462 + )
463 +
464 + # The n-gram hash needs a slot-keyed rolling store of compressed ids
465 + # (chunked prefill / decode lookback); key it off the first local
466 + # layer's sliding-window KV cache. Only PP ranks owning an engram
467 + # layer need it.
468 + self.engram_hash: NgramHashState | None = None
469 + self.engram_swa_prefix: str | None = None
470 + if self.engram_layout is not None:
471 + local_engram = any(
472 + isinstance(layer, DeepseekV4DecoderLayer) and layer.engram is not None
473 + for layer in islice(self.layers, self.start_layer, self.end_layer)
474 + )
475 + if local_engram:
476 + first_layer = next(
477 + iter(islice(self.layers, self.start_layer, self.end_layer))
478 + )
479 + swa_cache_module = first_layer.attn.swa_cache_layer
480 + self.engram_hash = NgramHashState(
481 + vllm_config, self.engram_layout, swa_cache_module
482 + )
483 + self.engram_swa_prefix = swa_cache_module.prefix
484 +
485 + if get_pp_group().is_last_rank:
486 + self.norm = RMSNorm(config.hidden_size, self.rms_norm_eps)
487 + else:
488 + self.norm = PPMissingLayer()
489 +
490 + spec_config = vllm_config.speculative_config
491 + needs_mtp_hidden_states = spec_config is not None and (
492 + spec_config.use_eagle() or spec_config.uses_draft_model()
493 + )
494 + if get_pp_group().is_last_rank and needs_mtp_hidden_states:
495 + self._mtp_hidden_buffer = torch.empty(
496 + vllm_config.scheduler_config.max_num_batched_tokens,
497 + self.hc_dim,
498 + dtype=vllm_config.model_config.dtype,
499 + )
500 + else:
501 + self._mtp_hidden_buffer = None
502 +
503 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
504 + return self.embed_tokens(input_ids)
505 +
506 + def make_empty_intermediate_tensors(
507 + self,
508 + batch_size: int,
509 + dtype: torch.dtype,
510 + device: torch.device,
511 + ) -> IntermediateTensors:
512 + # PP intermediate tensors carry the multi-stream hidden_states
513 + # of shape (num_tokens, hc_mult, hidden_size) — V4 expands the
514 + # token embedding to hc_mult streams before the first decoder
515 + # layer and keeps that shape until the final hc collapse — plus the
516 + # (num_tokens, hc_mult) pre-mix the next rank's first layer needs
517 + # for its attention collapse.
518 + return IntermediateTensors(
519 + {
520 + "hidden_states": torch.zeros(
521 + (batch_size, self.hc_mult, self.config.hidden_size),
522 + dtype=dtype,
523 + device=device,
524 + ),
525 + "pre_mix": torch.zeros(
526 + (batch_size, self.hc_mult),
527 + dtype=torch.float32,
528 + device=device,
529 + ),
530 + }
531 + )
532 +
533 + def forward(
534 + self,
535 + input_ids: torch.Tensor,
536 + positions: torch.Tensor,
537 + intermediate_tensors: IntermediateTensors | None,
538 + inputs_embeds: torch.Tensor | None = None,
539 + lookback_token_ids: torch.Tensor | None = None,
540 + ) -> torch.Tensor | IntermediateTensors:
541 + if get_pp_group().is_first_rank:
542 + if inputs_embeds is not None:
543 + hidden_states = inputs_embeds
544 + else:
545 + hidden_states = self.embed_input_ids(input_ids)
546 + else:
547 + assert intermediate_tensors is not None
548 + hidden_states = intermediate_tensors["hidden_states"]
549 +
550 + if self.use_mega_moe:
551 + input_ids = input_ids.to(torch.int64)
552 +
553 + # Engram n-gram hashes for the whole (flattened) batch, computed once
554 + # on the full token stream — before any sequence-parallel sharding —
555 + # and consumed by the engram layers (1 and 14) below. Skipped on
556 + # profile runs (KV cache unbound).
557 + engram_hashes: torch.Tensor | None = None
558 + engram_mask: torch.Tensor | None = None
559 + if (
560 + self.engram_hash is not None
561 + and input_ids is not None
562 + and is_forward_context_available()
563 + ):
564 + attn_metadata = get_forward_context().attn_metadata
565 + if isinstance(attn_metadata, list):
566 + attn_metadata = attn_metadata[dbo_current_ubatch_id()]
567 + if isinstance(attn_metadata, dict) and self.engram_hash.ensure_cache():
568 + assert self.engram_swa_prefix is not None
569 + swa_metadata = typing.cast(
570 + "DeepseekSparseSWAMetadata", attn_metadata[self.engram_swa_prefix]
571 + )
572 + # Image-span tokens are dead: they break n-grams (hash op
573 + # takes True=dead) and their gate is zeroed (Engram.forward
574 + # takes True=keep).
575 + image_mask = image_sentinel_mask(input_ids)
576 + engram_mask = ~image_mask
577 + if lookback_token_ids is None:
578 + if not self.engram_hash.use_slot_cache:
579 + raise NotImplementedError(
580 + "engram needs `lookback_token_ids` from the model "
581 + "runner (the DBO/ubatch wrapper drops model kwargs)"
582 + )
583 + num_reqs = swa_metadata.num_decodes + swa_metadata.num_prefills
584 + lookback_token_ids = input_ids.new_full(
585 + (num_reqs, self.engram_hash.lookback_depth), -1
586 + )
587 + engram_hashes = self.engram_hash(
588 + input_ids,
589 + positions,
590 + swa_metadata.query_start_loc,
591 + image_mask,
592 + lookback_token_ids,
593 + image_sentinel_mask(lookback_token_ids),
594 + swa_metadata.slot_mapping,
595 + swa_metadata.block_table,
596 + )
597 + # Gather all Engram rows before entering the decoder layers.
598 + for layer in islice(self.layers, self.start_layer, self.end_layer):
599 + engram = getattr(layer, "engram", None)
600 + if engram is not None:
601 + engram.prepare_embeddings(
602 + engram_hashes[:, engram.layer_hash_index]
603 + )
604 +
605 + full_num_tokens = positions.shape[0]
606 + if self.use_sequence_parallel:
607 + if envs.VLLM_MOE_SKIP_PADDING and is_forward_context_available():
608 + forward_context = get_forward_context()
609 + forward_context.is_padding = sp_padding_mask(
610 + forward_context.is_padding, hidden_states
611 + )
612 + hidden_states = sp_shard(hidden_states)
613 + input_ids = sp_shard(input_ids)
614 +
615 + residual, post_mix, res_mix = None, None, None
616 + pre_mix: torch.Tensor | None = None
617 + if not get_pp_group().is_first_rank:
618 + assert intermediate_tensors is not None
619 + pre_mix = intermediate_tensors["pre_mix"]
620 + aux_hidden_states: list[torch.Tensor] = []
621 + final_aux_recon: torch.Tensor | None = None # avoid duplicate mhc_post call
622 + for idx, layer in enumerate(
623 + islice(self.layers, self.start_layer, self.end_layer),
624 + start=self.start_layer,
625 + ):
626 + hidden_states, residual, post_mix, res_mix, pre_mix = layer(
627 + hidden_states,
628 + positions,
629 + input_ids,
630 + pre_mix,
631 + post_mix,
632 + res_mix,
633 + residual,
634 + engram_hashes,
635 + engram_mask,
636 + )
637 + if idx + 1 in self.aux_hidden_state_layers:
638 + # Reconstruct the aux hidden state for draft models
639 + aux_recon = mhc_post_tilelang(
640 + hidden_states, residual, post_mix, res_mix
641 + )
642 + aux_hidden_state = aux_recon.mean(dim=1)
643 + if self.use_sequence_parallel:
644 + aux_hidden_state = sp_all_gather(aux_hidden_state)[:full_num_tokens]
645 + aux_hidden_states.append(aux_hidden_state)
646 + final_aux_recon = aux_recon
647 + if layer is not None:
648 + # Reuse if the last layer was captured as an aux hidden state
649 + if self.end_layer in self.aux_hidden_state_layers:
650 + hidden_states = final_aux_recon
651 + else:
652 + hidden_states = mhc_post_tilelang(
653 + hidden_states, residual, post_mix, res_mix
654 + )
655 +
656 + if not get_pp_group().is_last_rank:
657 + return IntermediateTensors(
658 + {"hidden_states": hidden_states, "pre_mix": pre_mix}
659 + )
660 +
661 + # MTP needs full HC states; otherwise collapse and normalize locally
662 + # before gathering to reduce communication.
663 + if self._mtp_hidden_buffer is not None:
664 + if self.use_sequence_parallel:
665 + hidden_states = sp_all_gather(hidden_states)[:full_num_tokens]
666 + pre_mix = sp_all_gather(pre_mix)[:full_num_tokens]
667 + num_tokens = hidden_states.shape[0]
668 + self._mtp_hidden_buffer[:num_tokens].copy_(hidden_states.flatten(1))
669 +
670 + # Collapse the hc copies with the pre-mix from the last layer's FFN
671 + # mixes — the mix the reference applies via
672 + # ``last_layer.hc_pre(h, pre_mix)`` (v4.1 has no learned hc_head).
673 + assert pre_mix is not None
674 + hidden_states = hc_collapse_triton(hidden_states, pre_mix)
675 + hidden_states = self.norm(hidden_states)
676 + if self.use_sequence_parallel and self._mtp_hidden_buffer is None:
677 + # Without MTP, gather only the collapsed and normalized hidden states.
678 + hidden_states = sp_all_gather(hidden_states)[:full_num_tokens]
679 + if len(aux_hidden_states) > 0:
680 + return hidden_states, aux_hidden_states
681 + return hidden_states
682 +
683 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
684 + stacked_params_mapping = [
685 + # (param_name, shard_name, shard_id)
686 + ("gate_up_proj", "w1", 0),
687 + ("gate_up_proj", "w3", 1),
688 + ("attn.fused_wqa_wkv", "attn.wq_a", 0),
689 + ("attn.fused_wqa_wkv", "attn.wkv", 1),
690 + ("compressor.fused_wkv_wgate", "compressor.wkv", 0),
691 + ("compressor.fused_wkv_wgate", "compressor.wgate", 1),
692 + ]
693 + params_dict = dict(self.named_parameters())
694 + loaded_params: set[str] = set()
695 +
696 + # TP for attention
697 + tp_size = get_tensor_model_parallel_world_size()
698 + tp_rank = get_tensor_model_parallel_rank()
699 + n_head = self.config.num_attention_heads
700 + n_local_head = n_head // tp_size
701 + head_rank_start = n_local_head * tp_rank
702 + head_rank_end = n_local_head * (tp_rank + 1)
703 +
704 + # Pre-compute expert mapping ONCE.
705 + expert_mapping = self.get_expert_mapping()
706 +
707 + # Block-FP8 shared experts: pad the intermediate up to the TP-uniform
708 + # block count so the standard loaders below slice it evenly (trailing
709 + # ranks land on the zero pad). SP / unquantized ones need no padding.
710 + pad_shared_expert = (
711 + getattr(self.quant_config, "weight_block_size", None) is not None
712 + and not self.use_sequence_parallel
713 + )
714 +
715 + for name, loaded_weight in weights:
716 + if name.startswith(("vision.", "aligner.", "image_")):
717 + # Vision weights are loaded by the outer multimodal wrapper.
718 + logger.warning_once("Skipping non-text weight: %s", name)
719 + continue
720 + if pad_shared_expert and ".shared_experts." in name:
721 + loaded_weight = self._pad_shared_expert_weight(
722 + self.quant_config, name, loaded_weight
723 + )
724 + for param_name, weight_name, shard_id in stacked_params_mapping:
725 + # Skip non-stacked layers and experts (experts handled below).
726 + if ".experts." in name:
727 + continue
728 + if weight_name not in name:
729 + continue
730 + name = name.replace(weight_name, param_name)
731 +
732 + if is_pp_missing_parameter(name, self):
733 + break
734 + if name not in params_dict:
735 + head, _, leaf = name.rpartition(".")
736 + suffixed = f"{head}.base_layer.{leaf}"
737 + if suffixed in params_dict:
738 + name = suffixed
739 + param = params_dict[name]
740 + weight_loader = param.weight_loader
741 + weight_loader(param, loaded_weight, shard_id)
742 + loaded_params.add(name)
743 + break
744 + else:
745 + if ".experts." in name:
746 + # E8M0 scales are stored as float8_e8m0fnu in
747 + # checkpoints but the MoE param is uint8. copy_()
748 + # would do a numeric conversion (e.g. 2^-7 → 0),
749 + # destroying the raw exponent bytes.
750 + if (
751 + "weight_scale" in name
752 + and loaded_weight.dtype == torch.float8_e8m0fnu
753 + ):
754 + loaded_weight = loaded_weight.view(torch.uint8)
755 + for mapping in expert_mapping:
756 + param_name, weight_name, expert_id, expert_shard_id = mapping
757 + if weight_name not in name:
758 + continue
759 + name_mapped = name.replace(weight_name, param_name)
760 + if is_pp_missing_parameter(name_mapped, self):
761 + continue
762 + param = params_dict[name_mapped]
763 + # We should ask the weight loader to return success or not
764 + # here since otherwise we may skip experts with other
765 + # available replicas.
766 + weight_loader = typing.cast(
767 + Callable[..., bool], param.weight_loader
768 + )
769 + success = weight_loader(
770 + param,
771 + loaded_weight,
772 + name_mapped,
773 + shard_id=expert_shard_id,
774 + expert_id=expert_id,
775 + return_success=True,
776 + )
777 + if success:
778 + name = name_mapped
779 + break
780 + loaded_params.add(name_mapped)
781 + continue
782 + elif "attn_sink" in name:
783 + if is_pp_missing_parameter(name, self):
784 + continue
785 + narrow_weight = loaded_weight[head_rank_start:head_rank_end]
786 + n = narrow_weight.shape[0]
787 + params_dict[name][:n].copy_(narrow_weight)
788 + loaded_params.add(name)
789 + continue
790 + else:
791 + if is_pp_missing_parameter(name, self):
792 + continue
793 + # Non-LoRA params on a LoRA-wrapped module live at
794 + # ``<head>.base_layer.<leaf>``; the checkpoint is plain.
795 + if name not in params_dict:
796 + head, _, leaf = name.rpartition(".")
797 + suffixed = f"{head}.base_layer.{leaf}"
798 + if suffixed in params_dict:
799 + name = suffixed
800 + param = params_dict[name]
801 + weight_loader = getattr(
802 + param, "weight_loader", default_weight_loader
803 + )
804 + weight_loader(param, loaded_weight)
805 + loaded_params.add(name)
806 + continue
807 +
808 + return loaded_params
809 +
810 + @staticmethod
811 + def _pad_shared_expert_weight(
812 + quant_config: QuantizationConfig | None,
813 + name: str,
814 + loaded_weight: torch.Tensor,
815 + ) -> torch.Tensor:
816 + """Zero-pad a block-FP8 shared-expert weight/scale on its intermediate
817 + axis so the standard TP loaders split it into even, block-aligned shards
818 + (trailing ranks get the zero pad). gate (w1)/up (w3) [I, H] pad dim 0;
819 + down (w2 -> down_proj) [H, I] pads dim 1.
820 + """
821 + block_size = getattr(quant_config, "weight_block_size", None)
822 + assert block_size is not None
823 + # Round the intermediate axis up to a whole number of TP shards. The axis
824 + # is in elements for weights (step = block) and in blocks for scales.
825 + step = (
826 + 1 if name.endswith(("weight_scale_inv", "weight_scale")) else block_size[0]
827 + )
828 + dim = 1 if ".down_proj." in name else 0
829 + mult = get_tensor_model_parallel_world_size() * step
830 + pad = cdiv(loaded_weight.shape[dim], mult) * mult - loaded_weight.shape[dim]
831 + if pad == 0:
832 + return loaded_weight
833 + pad_shape = list(loaded_weight.shape)
834 + pad_shape[dim] = pad
835 + return torch.cat([loaded_weight, loaded_weight.new_zeros(pad_shape)], dim=dim)
836 +
837 + def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
838 + first_layer = next(iter(islice(self.layers, self.start_layer, self.end_layer)))
839 + if first_layer.ffn.use_mega_moe:
840 + return make_deepseek_v4_expert_params_mapping(self.config.n_routed_experts)
841 + # Params for weights, fp8 weight scales, fp8 activation scales
842 + # (param_name, weight_name, expert_id, shard_id)
843 + return fused_moe_make_expert_params_mapping(
844 + self,
845 + ckpt_gate_proj_name="w1",
846 + ckpt_down_proj_name="w2",
847 + ckpt_up_proj_name="w3",
848 + num_experts=self.config.n_routed_experts,
849 + )
850 +
851 + def finalize_mega_moe_weights(self) -> None:
852 + for layer in islice(self.layers, self.start_layer, self.end_layer):
853 + layer.ffn.finalize_mega_moe_weights()
854 +
855 + def finalize_mhc_broadcast_weights(self) -> None:
856 + if not get_pp_group().is_first_rank or self.start_layer >= self.end_layer:
857 + return
858 + layer = self.layers[self.start_layer]
859 + if isinstance(layer, DeepseekV4DecoderLayer):
860 + broadcast = (
861 + layer.hc_attn_fn.detach()
862 + .view(-1, layer.hc_mult, layer.hidden_size)
863 + .sum(dim=1)
864 + )
865 + if layer.hc_attn_fn_broadcast is None:
866 + layer.hc_attn_fn_broadcast = broadcast
867 + else:
868 + layer.hc_attn_fn_broadcast.copy_(broadcast)
869 +
870 +
871 +def _linear_scale_param_name(vllm_config: VllmConfig, expert_dtype: str) -> str:
872 + """Parameter name the linear quant method registers for weight scales.
873 +
874 + Native MXFP8 checkpoints ([32, 32] blocks with MXFP4 experts) route linear
875 + layers through ModelOptLinearMethod, which registers ``weight_scale``;
876 + block-FP8 linear layers register ``weight_scale_inv``.
877 + """
878 + use_mxfp8 = (
879 + getattr(vllm_config.quant_config, "weight_block_size", None) == [32, 32]
880 + and expert_dtype == "fp4"
881 + )
882 + return "weight_scale" if use_mxfp8 else "weight_scale_inv"
883 +
884 +
885 +def _make_deepseek_v4_weights_mapper(
886 + expert_dtype: str, linear_scale_name: str = "weight_scale_inv"
887 +) -> WeightsMapper:
888 + if expert_dtype == "fp4":
889 + # MXFP4 experts use Mxfp4MoEMethod, which registers scales as
890 + # ``w{1,2,3}_weight_scale`` (no _inv suffix). Linear scales use the
891 + # parameter name registered by their quantization method.
892 + scale_regex = {
893 + # ``.base_layer.``-namespace variant (LoRA-wrapped experts).
894 + re.compile(
895 + r"(\.experts\.\d+\.w[123]\.base_layer)\.scale$"
896 + ): r"\1.weight_scale",
897 + re.compile(r"(\.experts\.\d+\.w[123])\.scale$"): r"\1.weight_scale",
898 + # The ``embed.weight`` -> ``embed_tokens.weight`` suffix rule
899 + # renames the engram fp8 table but not its scale; route the
900 + # scale explicitly to the same module.
901 + re.compile(r"(engram\.embed)\.scale$"): r"\1_tokens.weight_scale_inv",
902 + re.compile(r"\.scale$"): f".{linear_scale_name}",
903 + }
904 + else:
905 + # FP8 experts use Fp8MoEMethod (block_quant=True), which registers
906 + # scales as ``w{13,2}_weight_scale_inv``. Map all ``.scale`` keys
907 + # there.
908 + scale_regex = {
909 + # ``.base_layer.``-namespace variant of the above.
910 + re.compile(
911 + r"(\.experts\.\d+\.w[123]\.base_layer)\.scale$"
912 + ): r"\1.weight_scale_inv",
913 + # Same engram reroute as the fp4 branch above.
914 + re.compile(r"(engram\.embed)\.scale$"): r"\1_tokens.weight_scale_inv",
915 + re.compile(r"\.scale$"): f".{linear_scale_name}",
916 + }
917 + return WeightsMapper(
918 + orig_to_new_prefix={
919 + "layers.": "model.layers.",
920 + "embed.": "model.embed.",
921 + "norm.": "model.norm.",
922 + "mtp.": "model.mtp.",
923 + },
924 + orig_to_new_regex=scale_regex,
925 + orig_to_new_suffix={
926 + "head.weight": "lm_head.weight",
927 + "embed.weight": "embed_tokens.weight",
928 + ".ffn.gate.bias": ".ffn.gate.e_score_correction_bias",
929 + },
930 + orig_to_new_substr={
931 + ".shared_experts.w2": ".shared_experts.down_proj",
932 + "mtp.": None,
933 + # The v4.1 checkpoint declares the VL arch even for text-only
934 + # use; the text backbone drops the vision-tower weights.
935 + "vision.": None,
936 + "aligner.": None,
937 + "image_": None,
938 + },
939 + )
940 +
941 +
942 +class DeepseekV4MixtureOfExperts(MixtureOfExperts):
943 + moe_mlp_layers: list["DeepseekV4MoE"]
944 +
945 + def extract_moe_parameters(self, example_moe: "DeepseekV4MoE | None") -> None:
946 + if example_moe is None:
947 + self.num_moe_layers = 0
948 + self.num_expert_groups = 0
949 + self.num_logical_experts = 0
950 + self.num_physical_experts = 0
951 + self.num_local_physical_experts = 0
952 + self.num_routed_experts = 0
953 + self.num_shared_experts = 0
954 + self.num_redundant_experts = 0
955 + return
956 + self.num_logical_experts = example_moe.n_logical_experts
957 + self.num_physical_experts = example_moe.n_physical_experts
958 + self.num_local_physical_experts = example_moe.n_local_physical_experts
959 + self.num_routed_experts = example_moe.n_routed_experts
960 + self.num_shared_experts = example_moe.n_shared_experts
961 + self.num_redundant_experts = example_moe.n_redundant_experts
962 +
963 + def update_physical_experts_metadata(
964 + self,
965 + num_physical_experts: int,
966 + num_local_physical_experts: int,
967 + ) -> None:
968 + assert self.num_local_physical_experts == num_local_physical_experts
969 + self.num_physical_experts = num_physical_experts
970 + self.num_local_physical_experts = num_local_physical_experts
971 + self.num_redundant_experts = num_physical_experts - self.num_logical_experts
972 + for moe in self.moe_mlp_layers:
973 + moe.n_local_physical_experts = num_local_physical_experts
974 + moe.n_physical_experts = num_physical_experts
975 + moe.n_redundant_experts = self.num_redundant_experts
976 + moe.experts.update_expert_map()
977 +
978 +
979 +class DeepseekV41LLMForCausalLM(
980 + nn.Module,
981 + SupportsPP,
982 + SupportsEagle3,
983 + SupportsLoRA,
984 + DeepseekV4MixtureOfExperts,
985 +):
986 + model_cls = DeepseekV4Model
987 +
988 + # Default mapper assumes the original FP4-expert checkpoint layout.
989 + # Overridden per-instance in __init__ when expert_dtype != "fp4".
990 + hf_to_vllm_mapper = _make_deepseek_v4_weights_mapper("fp4")
991 +
992 + packed_modules_mapping = {
993 + "gate_up_proj": ["w1", "w3"],
994 + "fused_wqa_wkv": ["wq_a", "wkv"],
995 + "fused_wkv_wgate": ["wkv", "wgate"],
996 + }
997 +
998 + # The MTP draft head is not LoRA-adapted.
999 + lora_skip_prefixes = ["mtp."]
1000 +
1001 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
1002 + super().__init__()
1003 +
1004 + config = vllm_config.model_config.hf_config
1005 + self.config = config
1006 + expert_dtype = getattr(config, "expert_dtype", "fp4")
1007 + self.hf_to_vllm_mapper = _make_deepseek_v4_weights_mapper(
1008 + expert_dtype, _linear_scale_param_name(vllm_config, expert_dtype)
1009 + )
1010 +
1011 + self.model = self.model_cls(
1012 + vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
1013 + )
1014 + if get_pp_group().is_last_rank:
1015 + self.lm_head = ParallelLMHead(
1016 + config.vocab_size,
1017 + config.hidden_size,
1018 + prefix=maybe_prefix(prefix, "lm_head"),
1019 + )
1020 + else:
1021 + self.lm_head = PPMissingLayer()
1022 + self.logits_processor = LogitsProcessor(config.vocab_size)
1023 + self.make_empty_intermediate_tensors = ( # type: ignore[method-assign]
1024 + self.model.make_empty_intermediate_tensors
1025 + )
1026 +
1027 + self.set_moe_parameters()
1028 +
1029 + def set_moe_parameters(self) -> None:
1030 + self.num_expert_groups = getattr(self.config, "n_group", 1)
1031 + self.num_moe_layers = self.config.num_hidden_layers
1032 + self.moe_layers: list[nn.Module] = []
1033 + self.moe_mlp_layers: list[DeepseekV4MoE] = []
1034 + example_moe: DeepseekV4MoE | None = None
1035 + for layer in self.model.layers:
1036 + if isinstance(layer, PPMissingLayer):
1037 + continue
1038 + if not isinstance(layer, DeepseekV4DecoderLayer):
1039 + continue
1040 + if isinstance(layer.ffn, DeepseekV4MoE):
1041 + example_moe = layer.ffn
1042 + self.moe_mlp_layers.append(layer.ffn)
1043 + self.moe_layers.append(layer.ffn.experts)
1044 +
1045 + self.num_moe_layers = len(self.moe_layers)
1046 + self.extract_moe_parameters(example_moe)
1047 +
1048 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
1049 + return self.model.embed_input_ids(input_ids)
1050 +
1051 + def compute_logits(
1052 + self,
1053 + hidden_states: torch.Tensor,
1054 + ) -> torch.Tensor | None:
1055 + logits = self.logits_processor(self.lm_head, hidden_states)
1056 + return logits
1057 +
1058 + def compute_logits_local(
1059 + self,
1060 + hidden_states: torch.Tensor,
1061 + ) -> torch.Tensor:
1062 + return self.logits_processor(self.lm_head, hidden_states, skip_gather=True)
1063 +
1064 + @staticmethod
1065 + def get_model_state_cls():
1066 + from .model_state import DeepseekV41ModelState
1067 +
1068 + return DeepseekV41ModelState
1069 +
1070 + @property
1071 + def token_lookback_depth(self) -> int:
1072 + """Tokens before a chunk start the engram hash needs; the model runner
1073 + passes them as `lookback_token_ids`."""
1074 + engram_hash = self.model.engram_hash
1075 + return engram_hash.lookback_depth if engram_hash is not None else 0
1076 +
1077 + def forward(
1078 + self,
1079 + input_ids: torch.Tensor,
1080 + positions: torch.Tensor,
1081 + intermediate_tensors: IntermediateTensors | None = None,
1082 + inputs_embeds: torch.Tensor | None = None,
1083 + lookback_token_ids: torch.Tensor | None = None,
1084 + ) -> torch.Tensor | IntermediateTensors:
1085 + hidden_states = self.model(
1086 + input_ids,
1087 + positions,
1088 + intermediate_tensors,
1089 + inputs_embeds,
1090 + lookback_token_ids=lookback_token_ids,
1091 + )
1092 + return hidden_states
1093 +
1094 + def get_mtp_target_hidden_states(self) -> torch.Tensor | None:
1095 + """Pre-collapse residual stream buffer (max_num_batched_tokens,
1096 + hc_mult * hidden_size) for the MTP draft model. Populated by
1097 + forward(); valid after each target step."""
1098 + return getattr(self.model, "_mtp_hidden_buffer", None)
1099 +
1100 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
1101 + loader = AutoWeightsLoader(self)
1102 + loaded_params = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
1103 + self.process_weights_after_loading()
1104 + return loaded_params
1105 +
1106 + def process_weights_after_loading(self) -> None:
1107 + self.model.finalize_mega_moe_weights()
1108 + self.model.finalize_mhc_broadcast_weights()
1109 +
1110 + def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
1111 + return self.model.get_expert_mapping()
vllm/models/deepseek_v4_1/nvidia/model_state.py new
+98
@@ -0,0 +1,98 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from typing import Any
4 +
5 +import torch
6 +import torch.nn as nn
7 +
8 +from vllm.config import VllmConfig
9 +from vllm.triton_utils import tl, triton
10 +from vllm.v1.worker.gpu.input_batch import InputBatch
11 +from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
12 +from vllm.v1.worker.gpu.model_states.default import DefaultModelState
13 +from vllm.v1.worker.gpu.states import RequestState
14 +
15 +
16 +@triton.jit
17 +def _gather_lookback_kernel(
18 + lookback_ptr,
19 + idx_mapping_ptr,
20 + num_computed_tokens_ptr,
21 + all_token_ids_ptr,
22 + all_token_ids_stride,
23 + num_reqs,
24 + DEPTH: tl.constexpr,
25 + BLOCK_DEPTH: tl.constexpr,
26 +):
27 + # One program per lookback row; rows past the batch are filled with -1.
28 + batch_idx = tl.program_id(0)
29 + in_batch = batch_idx < num_reqs
30 + req_state_idx = tl.load(idx_mapping_ptr + batch_idx, mask=in_batch, other=0)
31 + num_computed = tl.load(num_computed_tokens_ptr + req_state_idx)
32 +
33 + offs = tl.arange(0, BLOCK_DEPTH)
34 + pos = num_computed - 1 - offs
35 + valid = in_batch & (offs < DEPTH) & (pos >= 0)
36 + ids = tl.load(
37 + all_token_ids_ptr + req_state_idx * all_token_ids_stride + pos,
38 + mask=valid,
39 + other=-1,
40 + )
41 + tl.store(lookback_ptr + batch_idx * DEPTH + offs, ids, mask=offs < DEPTH)
42 +
43 +
44 +class DeepseekV41ModelState(DefaultModelState):
45 + """DefaultModelState plus the engram lookback window.
46 +
47 + The engram n-gram hash needs the ids of the ``depth`` tokens preceding
48 + each request's chunk start (see ``common/engram.py``). The runner keeps
49 + the full token history on device, so the window is gathered there every
50 + step: exact for prompt and generated tokens alike, whatever instance
51 + produced their KV.
52 + """
53 +
54 + def __init__(
55 + self,
56 + vllm_config: VllmConfig,
57 + model: nn.Module,
58 + encoder_cache: EncoderCache | None,
59 + device: torch.device,
60 + ):
61 + super().__init__(vllm_config, model, encoder_cache, device)
62 + depth = model.token_lookback_depth
63 + self.lookback_token_ids: torch.Tensor | None = None
64 + if depth > 0:
65 + # Persistent so a captured graph can read it on replay.
66 + self.lookback_token_ids = torch.full(
67 + (self.max_num_reqs, depth), -1, dtype=torch.int32, device=device
68 + )
69 +
70 + def prepare_inputs(
71 + self, input_batch: InputBatch, req_states: RequestState
72 + ) -> dict[str, torch.Tensor | None]:
73 + model_inputs = super().prepare_inputs(input_batch, req_states)
74 + window = self.lookback_token_ids
75 + if window is None:
76 + return model_inputs
77 + all_token_ids = req_states.all_token_ids.gpu
78 + depth = window.shape[1]
79 + _gather_lookback_kernel[(window.shape[0],)](
80 + window,
81 + input_batch.idx_mapping,
82 + req_states.num_computed_tokens.gpu,
83 + all_token_ids,
84 + all_token_ids.stride(0),
85 + input_batch.idx_mapping.shape[0],
86 + DEPTH=depth,
87 + BLOCK_DEPTH=triton.next_power_of_2(depth),
88 + )
89 + model_inputs["lookback_token_ids"] = window
90 + return model_inputs
91 +
92 + def prepare_dummy_inputs(self, num_reqs: int, num_tokens: int) -> dict[str, Any]:
93 + model_inputs = super().prepare_dummy_inputs(num_reqs, num_tokens)
94 + if self.lookback_token_ids is not None:
95 + # The captured graph reads this buffer; replays refill it in place.
96 + self.lookback_token_ids.fill_(-1)
97 + model_inputs["lookback_token_ids"] = self.lookback_token_ids
98 + return model_inputs
vllm/models/deepseek_v4_1/nvidia/vl_model.py new
+357
@@ -0,0 +1,357 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepSeek-V4.1 vision variant.
4 +
5 +Thin multimodal wrapper around the text-only ``DeepseekV41LLMForCausalLM``:
6 +
7 +- ``vision`` ViT + ``aligner`` produce per-image embeddings for the IMAGE
8 + positions; three learned vectors (``image_start`` / ``image_newline`` /
9 + ``image_end``) fill the delimiter positions. Every image-span position
10 + carries ``image_token_id`` (129264) in ``input_ids``; the per-position
11 + roles come from the processor's ``types`` tensor (see
12 + ``common/mm_preprocess.py``, and ``common/vision.py`` for the tower).
13 +- Merged embeddings enter the text model via ``inputs_embeds``, i.e. before
14 + its hyper-connection stream expansion. Raw ``input_ids`` still flow into
15 + the model so the MoE router can apply ``bias_vl`` to image tokens
16 + (``requires_raw_input_tokens``).
17 +"""
18 +
19 +from collections.abc import Iterable
20 +from typing import Annotated
21 +
22 +import torch
23 +from torch import nn
24 +
25 +from vllm.distributed import get_tensor_model_parallel_world_size
26 +from vllm.model_executor.models.interfaces import (
27 + MultiModalEmbeddings,
28 + SupportsEagle3,
29 + SupportsMultiModal,
30 + SupportsPP,
31 +)
32 +from vllm.model_executor.models.utils import (
33 + AutoWeightsLoader,
34 + WeightsMapper,
35 + maybe_prefix,
36 +)
37 +from vllm.model_executor.models.vision import is_vit_use_data_parallel
38 +from vllm.models.deepseek_v4.common.vision import (
39 + DeepseekV4Aligner,
40 + DeepseekV4ViT,
41 + run_dp_sharded_vision_tower,
42 +)
43 +from vllm.multimodal import MULTIMODAL_REGISTRY
44 +from vllm.utils.tensor_schema import TensorSchema, TensorShape
45 +
46 +from ..common.mm_preprocess import (
47 + IMAGE,
48 + IMAGE_END,
49 + IMAGE_NEW_LINE,
50 + IMAGE_PAD_ID,
51 + IMAGE_PLACEHOLDER,
52 + IMAGE_SENTINEL_BASE_ID,
53 + IMAGE_START,
54 + DeepseekV4VLDummyInputsBuilder,
55 + DeepseekV4VLMultiModalProcessor,
56 + DeepseekV4VLProcessingInfo,
57 +)
58 +from .model import (
59 + DeepseekV41LLMForCausalLM,
60 + _linear_scale_param_name,
61 + _make_deepseek_v4_weights_mapper,
62 +)
63 +
64 +
65 +class DeepseekV4VLImagePixelInputs(TensorSchema):
66 + """ViT patch inputs for one batched set of images.
67 +
68 + Dimensions:
69 + - np: Total ViT patches across images (sum of n_vit_h * n_vit_w)
70 + - c: Number of image channels (3)
71 + - p: ViT patch size
72 + - ni: Number of images
73 + - ns: Total image-span positions (sum of n_llm_h * (n_llm_w + 1) + 2)
74 + """
75 +
76 + patches: Annotated[
77 + torch.Tensor, TensorShape("np", 3, "p", "p", dynamic_dims={"np"})
78 + ]
79 + # [n_vit_h, n_vit_w] per image
80 + vit_grid: Annotated[torch.Tensor, TensorShape("ni", 2)]
81 + # [n_llm_h, n_llm_w] per image
82 + llm_grid: Annotated[torch.Tensor, TensorShape("ni", 2)]
83 + types: Annotated[torch.Tensor, TensorShape("ns", dynamic_dims={"ns"})]
84 +
85 +
86 +def _make_deepseek_v4_vl_weights_mapper(
87 + expert_dtype: str, linear_scale_name: str
88 +) -> WeightsMapper:
89 + """Text-checkpoint mapping rules re-rooted under ``language_model.``."""
90 + base = _make_deepseek_v4_weights_mapper(expert_dtype, linear_scale_name)
91 + orig_to_new_prefix: dict[str, str | None] = {
92 + "layers.": "language_model.model.layers.",
93 + "embed.": "language_model.model.embed.",
94 + "norm.": "language_model.model.norm.",
95 + "hc_head": "language_model.model.hc_head",
96 + "mtp.": "language_model.model.mtp.",
97 + }
98 + return WeightsMapper(
99 + orig_to_new_prefix=orig_to_new_prefix,
100 + orig_to_new_regex=base.orig_to_new_regex,
101 + orig_to_new_suffix={
102 + "head.weight": "language_model.lm_head.weight",
103 + "embed.weight": "embed_tokens.weight",
104 + ".ffn.gate.bias": ".ffn.gate.e_score_correction_bias",
105 + },
106 + orig_to_new_substr={
107 + ".shared_experts.w2": ".shared_experts.down_proj",
108 + # The MTP/DSpark draft heads are not supported for the vision
109 + # variant; drop their weights.
110 + "mtp.": None,
111 + },
112 + )
113 +
114 +
115 +@MULTIMODAL_REGISTRY.register_processor(
116 + DeepseekV4VLMultiModalProcessor,
117 + info=DeepseekV4VLProcessingInfo,
118 + dummy_inputs=DeepseekV4VLDummyInputsBuilder,
119 +)
120 +class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3):
121 + """Multimodal entry point for DeepSeek-V4.1 checkpoints with a vision tower.
122 +
123 + ``SupportsEagle3`` (aux hidden-state plumbing for MTP/DSpark drafters)
124 + delegates through ``language_model`` via the protocol defaults.
125 + """
126 +
127 + supports_encoder_tp_data = True
128 +
129 + # The MoE router needs raw token ids to detect image-span tokens
130 + # (all carrying image_token_id, see common/mm_preprocess.py) and apply
131 + # bias_vl.
132 + requires_raw_input_tokens = True
133 +
134 + @classmethod
135 + def get_placeholder_str(cls, modality: str, i: int) -> str | None:
136 + if modality == "image":
137 + return IMAGE_PLACEHOLDER
138 + raise ValueError(f"Unsupported modality: {modality!r}")
139 +
140 + def __init__(self, *, vllm_config, prefix: str = "") -> None:
141 + super().__init__()
142 + model_config = vllm_config.model_config
143 + config = model_config.hf_config
144 + self.config = config
145 + self.multimodal_config = model_config.multimodal_config
146 + assert self.multimodal_config is not None
147 +
148 + # The tower is always built; _mark_tower_model stubs it out
149 + # (StageMissingLayer, weights skipped) when the image limit is 0.
150 + with self._mark_tower_model(vllm_config, {"image"}):
151 + self.use_data_parallel = is_vit_use_data_parallel(config.vision_n_heads)
152 + self.vision = DeepseekV4ViT(config)
153 + self.aligner = DeepseekV4Aligner(config)
154 + self.image_start = nn.Parameter(
155 + torch.empty(config.hidden_size, dtype=torch.float32)
156 + )
157 + self.image_end = nn.Parameter(
158 + torch.empty(config.hidden_size, dtype=torch.float32)
159 + )
160 + self.image_newline = nn.Parameter(
161 + torch.empty(config.hidden_size, dtype=torch.float32)
162 + )
163 + self.vision.to(dtype=model_config.dtype)
164 + self.aligner.to(dtype=model_config.dtype)
165 +
166 + with self._mark_language_model(vllm_config):
167 + self.language_model = DeepseekV41LLMForCausalLM(
168 + vllm_config=vllm_config,
169 + prefix=maybe_prefix(prefix, "language_model"),
170 + )
171 + # The outer mapper (see load_weights) fully resolves HF names into
172 + # this wrapper's namespace before AutoWeightsLoader strips the
173 + # "language_model." prefix and delegates to the child's load_weights,
174 + # so the child's own mapper must be a no-op. Its suffix rules are not
175 + # idempotent (e.g. "lm_head.weight".endswith("head.weight") would
176 + # re-fire "head.weight" -> "lm_head.weight").
177 + self.language_model.hf_to_vllm_mapper = WeightsMapper()
178 + self.make_empty_intermediate_tensors = ( # type: ignore[method-assign]
179 + self.language_model.make_empty_intermediate_tensors
180 + )
181 +
182 + expert_dtype = getattr(config, "expert_dtype", "fp4")
183 + self.hf_to_vllm_mapper = _make_deepseek_v4_vl_weights_mapper(
184 + expert_dtype, _linear_scale_param_name(vllm_config, expert_dtype)
185 + )
186 +
187 + def _parse_and_validate_image_input(
188 + self, **kwargs: object
189 + ) -> DeepseekV4VLImagePixelInputs | None:
190 + patches = kwargs.pop("patches", None)
191 + if patches is None:
192 + return None
193 + return DeepseekV4VLImagePixelInputs(
194 + patches=patches,
195 + vit_grid=kwargs.pop("vit_grid"),
196 + llm_grid=kwargs.pop("llm_grid"),
197 + types=kwargs.pop("types"),
198 + resolve_bindings={"p": self.config.vision_patch_size},
199 + )
200 +
201 + def _encode_image(
202 + self,
203 + patches: torch.Tensor,
204 + n_vit_h: int,
205 + n_vit_w: int,
206 + ) -> torch.Tensor:
207 + # Aligner rows in reading order, one per IMAGE slot.
208 + return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
209 +
210 + def _build_image_span(
211 + self, image_embeds: torch.Tensor, types: torch.Tensor
212 + ) -> torch.Tensor:
213 + """Full image span: aligner rows at IMAGE slots, the learned
214 + delimiter vectors at IMAGE_START/IMAGE_NEW_LINE/IMAGE_END."""
215 + types = types.to(image_embeds.device)
216 + span = image_embeds.new_empty(types.numel(), image_embeds.shape[-1])
217 + dtype = image_embeds.dtype
218 + span[types == IMAGE_START] = self.image_start.to(dtype)
219 + span[types == IMAGE_END] = self.image_end.to(dtype)
220 + span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
221 + span[types == IMAGE] = image_embeds
222 + return span
223 +
224 + def _process_image_input(
225 + self,
226 + image_input: DeepseekV4VLImagePixelInputs,
227 + ) -> tuple[torch.Tensor, ...]:
228 + patches = image_input.patches.to(self.aligner.w1.weight.dtype)
229 + vit_grid = image_input.vit_grid.tolist()
230 +
231 + image_embeds_list: list[torch.Tensor]
232 + if self.use_data_parallel and get_tensor_model_parallel_world_size() > 1:
233 + # Data-parallel ViT: shard images across TP ranks and all-gather
234 + # the per-image embeddings (weights are replicated on every rank).
235 + image_embeds_list = run_dp_sharded_vision_tower(
236 + self.vision, self.aligner, patches, vit_grid
237 + )
238 + else:
239 + image_embeds_list = []
240 + vit_offset = 0
241 + for n_vit_h, n_vit_w in vit_grid:
242 + n_vit = n_vit_h * n_vit_w
243 + image_embeds_list.append(
244 + self._encode_image(
245 + patches[vit_offset : vit_offset + n_vit], n_vit_h, n_vit_w
246 + )
247 + )
248 + vit_offset += n_vit
249 +
250 + embeds: list[torch.Tensor] = []
251 + span_offset = 0
252 + for image_embeds, (n_llm_h, n_llm_w) in zip(
253 + image_embeds_list, image_input.llm_grid.tolist(), strict=True
254 + ):
255 + span_len = n_llm_h * (n_llm_w + 1) + 2
256 + embeds.append(
257 + self._build_image_span(
258 + image_embeds,
259 + image_input.types[span_offset : span_offset + span_len],
260 + )
261 + )
262 + span_offset += span_len
263 + return tuple(embeds)
264 +
265 + def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
266 + image_input = self._parse_and_validate_image_input(**kwargs)
267 + if image_input is None:
268 + return []
269 + return self._process_image_input(image_input)
270 +
271 + def embed_input_ids(
272 + self,
273 + input_ids: torch.Tensor,
274 + multimodal_embeddings: MultiModalEmbeddings | None = None,
275 + *,
276 + is_multimodal: torch.Tensor | None = None,
277 + ) -> torch.Tensor:
278 + from vllm.model_executor.models.utils import _merge_multimodal_embeddings
279 +
280 + # Compressor-alignment pads borrow a reserved id; embed them as the
281 + # plain image token (the checkpoint has no image_pad vector).
282 + # Branch-free: safe inside compiled/captured regions.
283 + input_ids = input_ids.masked_fill(
284 + input_ids == IMAGE_PAD_ID, IMAGE_SENTINEL_BASE_ID
285 + )
286 + inputs_embeds = self.language_model.embed_input_ids(input_ids)
287 +
288 + if multimodal_embeddings is None or len(multimodal_embeddings) == 0:
289 + return inputs_embeds
290 +
291 + assert is_multimodal is not None
292 + return _merge_multimodal_embeddings(
293 + inputs_embeds=inputs_embeds,
294 + multimodal_embeddings=multimodal_embeddings,
295 + is_multimodal=is_multimodal,
296 + )
297 +
298 + @staticmethod
299 + def get_model_state_cls():
300 + from .model_state import DeepseekV41ModelState
301 +
302 + return DeepseekV41ModelState
303 +
304 + @property
305 + def token_lookback_depth(self) -> int:
306 + return self.language_model.token_lookback_depth
307 +
308 + def forward(
309 + self,
310 + input_ids: torch.Tensor,
311 + positions: torch.Tensor,
312 + intermediate_tensors=None,
313 + inputs_embeds: torch.Tensor | None = None,
314 + lookback_token_ids: torch.Tensor | None = None,
315 + **kwargs,
316 + ) -> torch.Tensor:
317 + return self.language_model(
318 + input_ids,
319 + positions,
320 + intermediate_tensors,
321 + inputs_embeds,
322 + lookback_token_ids=lookback_token_ids,
323 + )
324 +
325 + def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
326 + return self.language_model.compute_logits(hidden_states)
327 +
328 + def compute_logits_local(self, hidden_states: torch.Tensor) -> torch.Tensor:
329 + return self.language_model.compute_logits_local(hidden_states)
330 +
331 + def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
332 + return self.language_model.get_expert_mapping()
333 +
334 + def get_mtp_target_hidden_states(self) -> torch.Tensor | None:
335 + """Pre-hc_head residual stream buffer for the MTP/DSpark draft model."""
336 + return self.language_model.get_mtp_target_hidden_states()
337 +
338 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
339 + # Map HF names into this wrapper's namespace up front and sort, so
340 + # the "language_model." group reaches the child loader as one
341 + # contiguous block (AutoWeightsLoader delegates per contiguous group,
342 + # and the child's load_weights finalizes fused expert weights, which
343 + # must not run on a partially loaded model).
344 + mapped = sorted(self.hf_to_vllm_mapper.apply(weights), key=lambda x: x[0])
345 + loader = AutoWeightsLoader(self)
346 + loaded_params = loader.load_weights(mapped)
347 + # The child's load_weights already ran its post-load finalization.
348 + self._weights_finalized = True
349 + return loaded_params
350 +
351 + def process_weights_after_loading(self) -> None:
352 + # Model-level post-load hook (called by the loader after any load
353 + # format). Under DummyModelLoader the child's load_weights — and
354 + # hence its finalize step — is bypassed, so run it here instead.
355 + if getattr(self, "_weights_finalized", False):
356 + return
357 + self.language_model.process_weights_after_loading()
vllm/models/deepseek_v4_1/quant_config.py new
+229
@@ -0,0 +1,229 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Quantization config for DeepSeek V4."""
4 +
5 +from __future__ import annotations
6 +
7 +from typing import TYPE_CHECKING, cast
8 +
9 +from vllm.config import get_current_vllm_config
10 +from vllm.config.quantization import QuantSpec
11 +from vllm.model_executor.layers.fused_moe import (
12 + RoutedExperts,
13 + UnquantizedFusedMoEMethod,
14 +)
15 +from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod
16 +from vllm.model_executor.layers.quantization import QuantizationMethods
17 +from vllm.model_executor.layers.quantization.fp8 import Fp8Config
18 +from vllm.model_executor.layers.quantization.mxfp4 import Mxfp4MoEMethod
19 +from vllm.model_executor.layers.quantization.utils.quant_utils import (
20 + is_layer_skipped,
21 + kMxfp8Dynamic,
22 + kMxfp8Static,
23 +)
24 +
25 +_DEEPSEEK_V4_EXPERT_DTYPES = ("fp4", "fp8")
26 +
27 +if TYPE_CHECKING:
28 + from vllm.model_executor.layers.quantization.modelopt import (
29 + ModelOptNvFp4Config,
30 + )
31 +
32 +
33 +class DeepseekV4FP8Config(Fp8Config):
34 + """FP8 config for DeepSeek V4 with expert-dtype-aware MoE dispatch.
35 +
36 + DeepSeek V4 checkpoints always use FP8 block quantization for
37 + linear/attention layers. The MoE expert weights vary by checkpoint:
38 + - ``expert_dtype="fp4"`` (e.g. DeepSeek-V4-Flash): MXFP4 experts
39 + with ue8m0 (e8m0fnu) FP8 linear scales.
40 + - ``expert_dtype="fp8"`` (e.g. DeepSeek-V4-Flash-Base): FP8 block
41 + experts with float32 FP8 linear scales.
42 +
43 + The dispatch and the linear scale dtype are both keyed off
44 + ``expert_dtype`` from the model's hf_config; missing values default
45 + to ``"fp4"`` so existing FP4 checkpoints stay unchanged.
46 +
47 + NOTE: ``expert_dtype`` is resolved lazily because this config is
48 + constructed during VllmConfig setup, before ``set_current_vllm_config``
49 + is active. Reading hf_config eagerly in ``__init__`` would always see
50 + the default ``"fp4"`` and silently misroute Flash-Base checkpoints.
51 + """
52 +
53 + def __init__(self, *args, **kwargs):
54 + super().__init__(*args, **kwargs)
55 + self._resolved_expert_dtype: str | None = None
56 + self._resolved_moe_quant_algo: str | None = None
57 + self._nvfp4_config: ModelOptNvFp4Config | None = None
58 + # ``is_scale_e8m0`` is a property that resolves on first read,
59 + # by which time the current vllm_config has been set.
60 +
61 + @property
62 + def expert_dtype(self) -> str:
63 + if self._resolved_expert_dtype is None:
64 + try:
65 + hf_config = get_current_vllm_config().model_config.hf_config
66 + except Exception:
67 + # vllm_config not yet set; defer the decision until a
68 + # later call lands inside set_current_vllm_config.
69 + return "fp4"
70 + expert_dtype = getattr(hf_config, "expert_dtype", "fp4")
71 + if expert_dtype not in _DEEPSEEK_V4_EXPERT_DTYPES:
72 + raise ValueError(
73 + f"Unsupported DeepSeek V4 expert_dtype={expert_dtype!r}; "
74 + f"expected one of {_DEEPSEEK_V4_EXPERT_DTYPES}."
75 + )
76 + self._resolved_expert_dtype = expert_dtype
77 + from vllm.logger import init_logger
78 +
79 + init_logger(__name__).info_once(
80 + "DeepSeek V4 expert_dtype resolved to %r", expert_dtype
81 + )
82 + return self._resolved_expert_dtype
83 +
84 + @property
85 + def is_scale_e8m0(self) -> bool:
86 + # FP4 checkpoints store FP8 linear scales as e8m0fnu; FP8 expert
87 + # checkpoints (Flash-Base) store them as float32.
88 + return self.expert_dtype == "fp4"
89 +
90 + def _resolve_moe_overrides(self) -> None:
91 + if self._resolved_moe_quant_algo is not None:
92 + return
93 + try:
94 + hf_config = get_current_vllm_config().model_config.hf_config
95 + except Exception:
96 + return
97 + quant_cfg = getattr(hf_config, "quantization_config", None) or {}
98 + algo = (quant_cfg.get("moe_quant_algo") or "").upper() or None
99 + self._resolved_moe_quant_algo = algo or ""
100 +
101 + @property
102 + def moe_quant_algo(self) -> str:
103 + self._resolve_moe_overrides()
104 + return self._resolved_moe_quant_algo or ""
105 +
106 + def _get_nvfp4_config(self) -> ModelOptNvFp4Config:
107 + if self._nvfp4_config is None:
108 + from vllm.model_executor.layers.quantization.modelopt import (
109 + ModelOptNvFp4Config,
110 + )
111 +
112 + self._nvfp4_config = ModelOptNvFp4Config(
113 + is_checkpoint_nvfp4_serialized=True,
114 + kv_cache_quant_algo=None,
115 + exclude_modules=[],
116 + group_size=16,
117 + )
118 + return self._nvfp4_config
119 +
120 + @classmethod
121 + def get_name(cls) -> QuantizationMethods:
122 + return "deepseek_v4_fp8"
123 +
124 + @staticmethod
125 + def _is_quark_mxfp4_ocp(hf_quant_cfg: dict) -> bool:
126 + """True for AMD-Quark exports whose global scheme is MXFP4."""
127 + weight = (hf_quant_cfg.get("global_quant_config") or {}).get("weight")
128 + # A non-dict weight (e.g. a list of multiple specs) means not an OCP
129 + # MXFP4 scheme (e.g. NVFP4 with 2-level scale).
130 + if not isinstance(weight, dict):
131 + return False
132 + return (
133 + weight.get("dtype") == "fp4"
134 + and weight.get("qscheme") == "per_group"
135 + and weight.get("group_size") == 32
136 + )
137 +
138 + @classmethod
139 + def override_quantization_method(
140 + cls, hf_quant_cfg, user_quant, hf_config=None
141 + ) -> QuantizationMethods | None:
142 + if not (
143 + isinstance(hf_quant_cfg, dict)
144 + and (
145 + hf_quant_cfg.get("quant_method") in ("fp8", "deepseek_v4_fp8")
146 + or (
147 + hf_quant_cfg.get("quant_method") == "quark"
148 + and cls._is_quark_mxfp4_ocp(hf_quant_cfg)
149 + )
150 + )
151 + ):
152 + return None
153 + model_type = getattr(hf_config, "model_type", None)
154 + if (
155 + model_type
156 + in (
157 + "deepseek_v4",
158 + "deepseek_v4_text",
159 + "deepseek_v41",
160 + "deepseek_v41_text",
161 + )
162 + or user_quant == "deepseek_v4_fp8"
163 + ):
164 + return "deepseek_v4_fp8"
165 + return None
166 +
167 + @classmethod
168 + def from_config(cls, config: dict) -> DeepseekV4FP8Config:
169 + # Reroute AMD-Quark fused shared expert MXFP4 checkpoints onto the fp8
170 + # path: the runtime layout matches the DeepSeek-native fp8 checkpoint,
171 + # so translate the schema into format Fp8Config.from_config expects.
172 + if config.get("quant_method") == "quark":
173 + quark_exclude = config.get("exclude") or []
174 + config = {
175 + "quant_method": "fp8",
176 + "activation_scheme": "dynamic",
177 + "fmt": "e4m3",
178 + "scale_fmt": "ue8m0",
179 + "weight_block_size": [128, 128],
180 + "ignored_layers": [
181 + name for name in quark_exclude if isinstance(name, str)
182 + ],
183 + }
184 + return cast("DeepseekV4FP8Config", super().from_config(config))
185 +
186 + def get_quant_method(self, layer, prefix):
187 + if (
188 + isinstance(layer, LinearBase)
189 + and self.weight_block_size == [32, 32]
190 + and self.is_scale_e8m0
191 + ):
192 + if is_layer_skipped(
193 + prefix=prefix,
194 + ignored_layers=self.ignored_layers,
195 + fused_mapping=self.packed_modules_mapping,
196 + match_mode=self.ignored_layers_match_mode,
197 + ):
198 + return UnquantizedLinearMethod()
199 + from vllm.model_executor.layers.quantization.modelopt import (
200 + CkptCtx,
201 + ModelOptLinearMethod,
202 + )
203 +
204 + rows, cols = self.weight_block_size
205 + return ModelOptLinearMethod(
206 + QuantSpec(weight=kMxfp8Static, activation=kMxfp8Dynamic),
207 + CkptCtx(scale_block_size=(rows, cols)),
208 + )
209 + if isinstance(layer, RoutedExperts):
210 + if is_layer_skipped(
211 + prefix=prefix,
212 + ignored_layers=self.ignored_layers,
213 + fused_mapping=self.packed_modules_mapping,
214 + ):
215 + return UnquantizedFusedMoEMethod(layer.moe_config)
216 + if self.expert_dtype == "fp4":
217 + if self.moe_quant_algo == "NVFP4":
218 + from vllm.model_executor.layers.quantization.modelopt import (
219 + ModelOptNvFp4FusedMoE,
220 + )
221 +
222 + return ModelOptNvFp4FusedMoE(
223 + quant_config=self._get_nvfp4_config(),
224 + moe_config=layer.moe_config,
225 + )
226 + return Mxfp4MoEMethod(layer.moe_config)
227 + # expert_dtype == "fp8": fall through to Fp8Config which
228 + # returns Fp8MoEMethod with block-wise float32 scales.
229 + return super().get_quant_method(layer, prefix)
vllm/models/deepseek_v4_1/sparse_mla.py new
+231
@@ -0,0 +1,231 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""DeepSeek-V4.1 FlashMLA sparse backend, metadata, and metadata builders."""
4 +
5 +from dataclasses import dataclass
6 +from typing import Any, ClassVar
7 +
8 +import torch
9 +
10 +from vllm.config import VllmConfig
11 +from vllm.config.cache import CacheDType
12 +from vllm.platforms import current_platform
13 +from vllm.platforms.interface import DeviceCapability
14 +from vllm.v1.attention.backend import (
15 + AttentionBackend,
16 + AttentionCGSupport,
17 + AttentionMetadata,
18 + AttentionMetadataBuilder,
19 + CommonAttentionMetadata,
20 + MultipleOf,
21 +)
22 +from vllm.v1.attention.backends.mla.compressor_utils import get_compressed_slot_mapping
23 +from vllm.v1.attention.backends.mla.sparse_swa import (
24 + _LAYER_TYPE_C1A,
25 + _LAYER_TYPE_C2A,
26 + _LAYER_TYPE_SWAONLY,
27 + DeepseekSparseSWAMetadataBuilder,
28 +)
29 +from vllm.v1.kv_cache_interface import AttentionSpec
30 +
31 +# v4.1 per-layer compress ratios: 0 = pure sliding window, 1 = full-length
32 +# compressed cache, 2 = ratio-2 compressed cache. Ratio-1 and ratio-2 layers
33 +# both attend over indexer topk indices into a shared compressed cache but
34 +# differ in compressed page block size (block_size // ratio), so each needs its
35 +# own FlashMLA tile-scheduler plan.
36 +_V41_LAYER_TYPES: dict[int, str] = {
37 + 0: _LAYER_TYPE_SWAONLY,
38 + 1: _LAYER_TYPE_C1A,
39 + 2: _LAYER_TYPE_C2A,
40 +}
41 +
42 +
43 +def deepseek_v41_layer_type(compress_ratio: int) -> str:
44 + layer_type = _V41_LAYER_TYPES.get(compress_ratio)
45 + if layer_type is None:
46 + raise ValueError(
47 + f"Unsupported DeepSeek V4.1 compress_ratio={compress_ratio}; "
48 + "expected 0, 1, or 2."
49 + )
50 + return layer_type
51 +
52 +
53 +class DeepseekV41SparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuilder):
54 + """SWA metadata builder base for v4.1.
55 +
56 + The shared builder classifies decode layer types by the v4.0 ratios
57 + (1 = SWA-only, 4, 128). v4.1 uses 0 / 1 / 2, so recompute the set of
58 + tile-scheduler plans from the v4.1 topology.
59 + """
60 +
61 + def __init__(self, *args, **kwargs):
62 + super().__init__(*args, **kwargs)
63 + compress_ratios = getattr(
64 + self.vllm_config.model_config.hf_config, "compress_ratios", None
65 + ) or [0]
66 + self._layer_types = {
67 + deepseek_v41_layer_type(int(ratio)) for ratio in compress_ratios
68 + }
69 +
70 +
71 +class DeepseekV4SparseMLABackend(AttentionBackend):
72 + """DeepSeek-V4.1 sparse-MLA backend base.
73 +
74 + Subclasses ``AttentionBackend`` directly (not the V3.2
75 + ``FlashMLASparseBackend``): DeepSeek-V4.1 runs its own attention layer
76 + (``DeepseekV4Attention``), so it does not reuse the V3.2 builder or impl, and
77 + only needs to declare its own metadata builder, KV-cache layout, and the
78 + sparse-MLA capability flags.
79 + """
80 +
81 + supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16]
82 + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [
83 + "auto",
84 + "fp8_ds_mla",
85 + "fp8", # alias for fp8_ds_mla
86 + ]
87 +
88 + @staticmethod
89 + def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
90 + return [64 if current_platform.is_device_capability_family(90) else 128]
91 +
92 + @staticmethod
93 + def get_builder_cls() -> type["DeepseekV4SparseMLAMetadataBuilder"]:
94 + return DeepseekV4SparseMLAMetadataBuilder
95 +
96 + @staticmethod
97 + def get_impl_cls() -> type[Any]:
98 + # DeepSeek-V4.1 runs its attention through ``DeepseekV4Attention.forward``,
99 + # not the generic ``Attention``/``MLAAttention`` layer, so the backend's
100 + # impl class is never instantiated.
101 + raise NotImplementedError(
102 + "DeepseekV4SparseMLABackend has no separate impl class; DeepSeek-V4.1 "
103 + "attention runs through DeepseekV4Attention."
104 + )
105 +
106 + @classmethod
107 + def get_supported_head_sizes(cls) -> list[int]:
108 + # DeepSeek V4 layout: 448 NoPE + 64 RoPE = 512.
109 + return [512]
110 +
111 + @classmethod
112 + def is_mla(cls) -> bool:
113 + return True
114 +
115 + @classmethod
116 + def is_sparse(cls) -> bool:
117 + return True
118 +
119 + @classmethod
120 + def supports_sink(cls) -> bool:
121 + return True
122 +
123 + @classmethod
124 + def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
125 + return capability.major in [9, 10]
126 +
127 +
128 +@dataclass
129 +class DeepseekV4FlashMLAMetadata(AttentionMetadata):
130 + num_reqs: int
131 + max_query_len: int
132 + max_seq_len: int
133 +
134 + num_actual_tokens: int # Number of tokens excluding padding.
135 + query_start_loc: torch.Tensor
136 + slot_mapping: torch.Tensor
137 +
138 + block_table: torch.Tensor
139 + req_id_per_token: torch.Tensor
140 + block_size: int
141 + topk_tokens: int
142 +
143 +
144 +class DeepseekV4SparseMLAMetadataBuilder(
145 + AttentionMetadataBuilder[DeepseekV4FlashMLAMetadata]
146 +):
147 + _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
148 +
149 + def __init__(
150 + self,
151 + kv_cache_spec: AttentionSpec,
152 + layer_names: list[str],
153 + vllm_config: VllmConfig,
154 + device: torch.device,
155 + ) -> None:
156 + super().__init__(kv_cache_spec, layer_names, vllm_config, device)
157 + self.model_config = vllm_config.model_config
158 + # Classify single-token queries (plus num_speculative_tokens via
159 + # supports_spec_as_decode=True) as decodes; longer queries go to prefill.
160 + self._init_reorder_batch_threshold(1, supports_spec_as_decode=True)
161 + self.topk_tokens = self.model_config.hf_config.index_topk
162 +
163 + max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
164 + self.req_id_per_token_buffer = torch.empty(
165 + (max_num_batched_tokens,), dtype=torch.int32, device=device
166 + )
167 +
168 + assert isinstance(self.kv_cache_spec.tokens_per_state, int)
169 + self.compress_ratio = self.kv_cache_spec.tokens_per_state
170 + # Only kv-source layers own a compressed cache, and v4.1 compresses at
171 + # ratio 1 or 2.
172 + if self.compress_ratio not in (1, 2):
173 + raise ValueError(
174 + "DeepSeek V4.1 compressed-KV caches use compress_ratio 1 or 2; "
175 + f"got {self.compress_ratio}."
176 + )
177 +
178 + # Pre-allocate compressed slot mapping buffer for CUDA graph address
179 + # stability when compress_ratio > 1.
180 + if self.compress_ratio > 1:
181 + self.compressed_slot_mapping_buffer = torch.empty(
182 + max_num_batched_tokens, dtype=torch.int64, device=device
183 + )
184 +
185 + def build(
186 + self,
187 + common_prefix_len: int,
188 + common_attn_metadata: CommonAttentionMetadata,
189 + fast_build: bool = False,
190 + ) -> DeepseekV4FlashMLAMetadata:
191 + cm = common_attn_metadata
192 + req_id_per_token = cm.token_to_req_indices(self.req_id_per_token_buffer)
193 +
194 + slot_mapping = cm.slot_mapping
195 + if self.compress_ratio > 1:
196 + slot_mapping = get_compressed_slot_mapping(
197 + cm.num_actual_tokens,
198 + cm.query_start_loc,
199 + cm.seq_lens,
200 + cm.block_table_tensor.clamp_(min=0),
201 + int(self.kv_cache_spec.num_states),
202 + self.compress_ratio,
203 + out=self.compressed_slot_mapping_buffer,
204 + )
205 +
206 + return DeepseekV4FlashMLAMetadata(
207 + num_reqs=cm.num_reqs,
208 + max_query_len=cm.max_query_len,
209 + max_seq_len=cm.max_seq_len,
210 + num_actual_tokens=cm.num_actual_tokens,
211 + query_start_loc=cm.query_start_loc,
212 + slot_mapping=slot_mapping,
213 + block_table=cm.block_table_tensor,
214 + req_id_per_token=req_id_per_token,
215 + block_size=self.kv_cache_spec.block_size,
216 + topk_tokens=self.topk_tokens,
217 + )
218 +
219 +
220 +class DeepseekV4FlashMLAMetadataBuilder(DeepseekV4SparseMLAMetadataBuilder):
221 + _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
222 +
223 +
224 +class DeepseekV4FlashMLABackend(DeepseekV4SparseMLABackend):
225 + @staticmethod
226 + def get_name() -> str:
227 + return "FLASHMLA_SPARSE_DSV41"
228 +
229 + @staticmethod
230 + def get_builder_cls() -> type[DeepseekV4FlashMLAMetadataBuilder]:
231 + return DeepseekV4FlashMLAMetadataBuilder
vllm/v1/attention/backends/mla/sparse_swa.py
+37 -17
@@ -46,18 +46,26 @@ from vllm.v1.kv_cache_interface import (
46 _LAYER_TYPE_SWAONLY = "swaonly"
47 _LAYER_TYPE_C4A = "c4a"
48 _LAYER_TYPE_C128A = "c128a"
49 +# v4.1 ratio-1 / ratio-2 indexer layers: same indexer-path shape as C4A, but
50 +# their compressed page block is block_size // ratio, so each gets its own plan.
51 +# v4.1 builders classify layer types themselves (ratio 0 is SWA-only there); see
52 +# deepseek_v4_1/sparse_mla.py.
53 +_LAYER_TYPE_C1A = "c1a"
54 +_LAYER_TYPE_C2A = "c2a"
55
56
57 def _layer_type_for(compress_ratio: int) -> str:
58 if compress_ratio <= 1:
59 return _LAYER_TYPE_SWAONLY
60 + if compress_ratio == 2:
61 + return _LAYER_TYPE_C2A
62 if compress_ratio == 4:
63 return _LAYER_TYPE_C4A
64 if compress_ratio == 128:
65 return _LAYER_TYPE_C128A
66 raise ValueError(
67 f"Unsupported DeepseekV4 compress_ratio={compress_ratio}; "
60 - "expected 1, 4, or 128."
68 + "expected 1, 2, 4, or 128."
69 )
70
71
@@ -70,6 +78,7 @@ class DeepseekV4SWACache(torch.nn.Module, AttentionLayerBase):
78 prefix: str,
79 cache_config: CacheConfig,
80 backend_cls: "type[AttentionBackend] | None" = None,
81 + block_size: int = 64,
82 ):
83 super().__init__()
84 self.backend_cls = backend_cls or DeepseekSparseSWABackend
@@ -84,12 +93,9 @@ class DeepseekV4SWACache(torch.nn.Module, AttentionLayerBase):
93 raise ValueError(f"Duplicate layer name: {prefix}")
94 compilation_config.static_forward_context[prefix] = self
95
87 - # Block size is constrained by tensor sharing between SWA and C4A KV blocks.
88 - # Since both block types share the same physical tensor, they must use the
89 - # same page size. The C4A KV block shape [256//4, head_dim] = [64, head_dim]
90 - # determines the SWA block size of 64 tokens per block.
91 - # TODO(yifan): make SWA block size automatically determined and configurable.
92 - self.block_size = 64
96 + # Any multiple of 32; the sparse decode kernels take the page size at
97 + # runtime.
98 + self.block_size = block_size
99 # uint8: fp8_ds_mla UE8M0 paged layout. bfloat16 / float8_e4m3fn:
100 # contiguous full-cache layout.
101 assert self.dtype in (torch.uint8, torch.bfloat16, torch.float8_e4m3fn)
@@ -130,7 +136,7 @@ class DeepseekSparseSWABackend(AttentionBackend):
136
137 @staticmethod
138 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
133 - return [MultipleOf(64)]
139 + return [MultipleOf(32)]
140
141 @classmethod
142 def get_preferred_block_size(cls, default_block_size: int) -> int:
@@ -213,12 +219,17 @@ class DeepseekSparseSWAMetadata:
219 tile_sched_swaonly: "FlashMLASchedMeta | None" = None
220 tile_sched_c4a: "FlashMLASchedMeta | None" = None
221 tile_sched_c128a: "FlashMLASchedMeta | None" = None
222 + tile_sched_c1a: "FlashMLASchedMeta | None" = None
223 + tile_sched_c2a: "FlashMLASchedMeta | None" = None
224 flashinfer_sparse_index_cache: dict[str, tuple[torch.Tensor, torch.Tensor]] = field(
225 default_factory=dict
226 )
227
228 def get_prefill_chunk_plan(
221 - self, compress_ratio: int, prefill_chunk_size: int
229 + self,
230 + compress_ratio: int,
231 + prefill_chunk_size: int,
232 + has_compressed: bool | None = None,
233 ) -> list[tuple[int, int, int, int]]:
234 if self.num_prefills == 0:
235 return []
@@ -226,17 +237,20 @@ class DeepseekSparseSWAMetadata:
237 assert self.prefill_seq_lens_cpu is not None
238 assert self.prefill_query_lens_cpu is not None
239
240 + # Whether the layer gathers a compressed-KV region into the prefill
241 + # workspace. v4.0 callers keep the legacy default (ratio <= 1 means
242 + # SWA-only); v4.1 passes has_compressed explicitly because its
243 + # compress_ratio==1 layers DO have a full-length compressed cache.
244 + if has_compressed is None:
245 + has_compressed = compress_ratio > 1
246 +
247 # query_len <= max_num_batched_tokens and
248 # gather_len = query_len + min(prefix_len, window_size - 1), so the
249 # worst-case gathered width is bounded by
250 # max_num_batched_tokens + window_size - 1. The compressed prefix pool
251 # is bounded by ceil(max_model_len / compress_ratio).
252 max_workspace_area = prefill_chunk_size * (
235 - (
236 - 0
237 - if compress_ratio <= 1
238 - else cdiv(self.prefill_max_model_len, compress_ratio)
239 - )
253 + (cdiv(self.prefill_max_model_len, compress_ratio) if has_compressed else 0)
254 + self.prefill_window_size
255 + self.prefill_max_num_batched_tokens
256 )
@@ -245,13 +259,13 @@ class DeepseekSparseSWAMetadata:
259 prefix_lens_cpu, min=0, max=self.prefill_window_size - 1
260 )
261 compressed_lens_cpu = (
248 - torch.zeros_like(self.prefill_seq_lens_cpu)
249 - if compress_ratio <= 1
250 - else torch.div(
262 + torch.div(
263 self.prefill_seq_lens_cpu,
264 compress_ratio,
265 rounding_mode="floor",
266 )
267 + if has_compressed
268 + else torch.zeros_like(self.prefill_seq_lens_cpu)
269 )
270
271 chunk_plan: list[tuple[int, int, int, int]] = []
@@ -715,6 +729,8 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
729 tile_sched_swaonly=tile_sched[_LAYER_TYPE_SWAONLY],
730 tile_sched_c4a=tile_sched[_LAYER_TYPE_C4A],
731 tile_sched_c128a=tile_sched[_LAYER_TYPE_C128A],
732 + tile_sched_c1a=tile_sched[_LAYER_TYPE_C1A],
733 + tile_sched_c2a=tile_sched[_LAYER_TYPE_C2A],
734 **deepseek_v4_fields, # type: ignore[arg-type]
735 )
736
@@ -795,6 +811,8 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
811 metadata.tile_sched_swaonly = tile_sched[_LAYER_TYPE_SWAONLY]
812 metadata.tile_sched_c4a = tile_sched[_LAYER_TYPE_C4A]
813 metadata.tile_sched_c128a = tile_sched[_LAYER_TYPE_C128A]
814 + metadata.tile_sched_c1a = tile_sched[_LAYER_TYPE_C1A]
815 + metadata.tile_sched_c2a = tile_sched[_LAYER_TYPE_C2A]
816 metadata.flashinfer_sparse_index_cache.clear()
817
818 def build_tile_scheduler(
@@ -815,6 +833,8 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
833 _LAYER_TYPE_SWAONLY: None,
834 _LAYER_TYPE_C4A: None,
835 _LAYER_TYPE_C128A: None,
836 + _LAYER_TYPE_C1A: None,
837 + _LAYER_TYPE_C2A: None,
838 }
839 if (
840 num_decode_tokens == 0
vllm/v1/attention/backends/registry.py
+10
@@ -98,6 +98,16 @@ class AttentionBackendEnum(Enum, metaclass=_AttentionBackendEnumMeta):
98 ROCM_FLASHMLA_SPARSE_DSV4 = (
99 "vllm.models.deepseek_v4.amd.rocm.DeepseekV4ROCMAiterMLASparseBackend"
100 )
101 + # DeepSeek V4.1 sparse MLA backends (model-driven; selected via the V4.1
102 + # layer). Separate names from DSV4 so a V4.1 model never resolves the
103 + # V4.0 backend classes through this enum.
104 + FLASHMLA_SPARSE_DSV41 = (
105 + "vllm.models.deepseek_v4_1.sparse_mla.DeepseekV4FlashMLABackend"
106 + )
107 + FLASHINFER_MLA_SPARSE_DSV41 = (
108 + "vllm.models.deepseek_v4_1.nvidia.flashinfer_sparse."
109 + "DeepseekV4FlashInferMLASparseBackend"
110 + )
111 B12X = "vllm.v1.attention.backends.b12x.B12xPagedAttentionBackend"
112 FLASH_ATTN_MLA = "vllm.v1.attention.backends.mla.flashattn_mla.FlashAttnMLABackend"
113 FLASH_ATTN_MLA_SPARSE = (