[Spec Decode] Add NVFP4 DSpark gathered top-k projection (#55713)
Signed-off-by: Andrii Skliar <askliar@nvidia.com> Co-authored-by: Andrii Skliar <askliar@nvidia.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Andrii Skliar committed
Sep 9, 2026 at 19:33 UTC
83fe99399ec0603b32393a14324b65c67ad04af2
4 files changed
+491
-3
tests/v1/spec_decode/test_dspark_topk.py
+183
@@ -1,10 +1,20 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import pytest
5
import torch
6
from torch import nn
7
8
+from vllm.model_executor.layers.quantization.modelopt import (
9
+ ModelOptLinearMethod,
10
+ resolve,
11
+)
12
+from vllm.model_executor.layers.quantization.utils.nvfp4_emulation_utils import (
13
+ dequantize_to_dtype,
14
+ nvfp4_gathered_bias,
15
+)
16
from vllm.model_executor.models.qwen3_dspark import DSparkMarkovHead
17
+from vllm.platforms import current_platform
18
19
20
def _markov_head(weight: torch.Tensor) -> DSparkMarkovHead:
@@ -14,6 +24,8 @@ def _markov_head(weight: torch.Tensor) -> DSparkMarkovHead:
24
weight.shape[1], weight.shape[0], bias=False, dtype=weight.dtype
25
)
26
head.markov_w2.weight.data.copy_(weight)
27
+ head.markov_w2._retain_weight_for_gather = False
28
+ head.markov_w2.is_w4a16_nvfp4 = False
29
return head
30
31
@@ -56,3 +68,174 @@ def test_gathered_markov_bias_matches_dense_at_full_vocab():
68
69
expected = original + markov_embed @ weight.T * scale
70
torch.testing.assert_close(result, expected)
71
+
72
+
73
+@pytest.mark.parametrize("cuda_alike_platform", [False, True])
74
+def test_gathered_markov_bias_dequantizes_selected_w4a16_rows(
75
+ monkeypatch, cuda_alike_platform
76
+):
77
+ packed_weight = torch.tensor(
78
+ [[0x00] * 8, [0x22] * 8, [0xAA] * 8, [0x31] * 8], dtype=torch.uint8
79
+ )
80
+ weight_scale = torch.ones((4, 1), dtype=torch.float8_e4m3fn)
81
+
82
+ layer = nn.Module()
83
+ layer.weight = nn.Parameter(packed_weight, requires_grad=False)
84
+ layer.weight_scale = nn.Parameter(weight_scale, requires_grad=False)
85
+ layer.weight_scale_2 = nn.Parameter(torch.ones(1), requires_grad=False)
86
+ layer._retain_weight_for_gather = True
87
+
88
+ spec, ctx, fmt = resolve("W4A16_NVFP4", type("Config", (), {"group_size": 16}), "")
89
+ method = ModelOptLinearMethod(spec, ctx, fmt)
90
+
91
+ class RepackingKernel:
92
+ @staticmethod
93
+ def process_weights_after_loading(layer):
94
+ layer.weight = nn.Parameter(
95
+ torch.zeros((1, 1), dtype=torch.int32), requires_grad=False
96
+ )
97
+ layer.weight_scale = nn.Parameter(
98
+ torch.zeros((1, 1), dtype=torch.float32), requires_grad=False
99
+ )
100
+ layer.weight_global_scale = nn.Parameter(
101
+ torch.zeros(1, dtype=torch.float32), requires_grad=False
102
+ )
103
+
104
+ method.kernel = RepackingKernel()
105
+ method.process_weights_after_loading(layer)
106
+ assert layer.is_w4a16_nvfp4
107
+
108
+ head = DSparkMarkovHead.__new__(DSparkMarkovHead)
109
+ nn.Module.__init__(head)
110
+ head.markov_w2 = layer
111
+ markov_embed = torch.tensor([[1.0] * 16, [0.5, -0.5] * 8], dtype=torch.float32)
112
+ index = torch.tensor([[1, 3], [2, 0]])
113
+ values = torch.tensor([[0.25, -0.5], [1.0, 2.0]])
114
+ original_values = values.clone()
115
+ logits = torch.full((2, 4), float("-inf"))
116
+
117
+ # CPU tensors must use the fallback even on CUDA/ROCm hosts.
118
+ monkeypatch.setattr(current_platform, "is_cuda_alike", lambda: cuda_alike_platform)
119
+ result = head.apply_bias_gathered(markov_embed, logits, values, index)
120
+
121
+ dense_weight = torch.tensor([[0.0] * 16, [1.0] * 16, [-1.0] * 16, [0.5, 1.5] * 8])
122
+ expected = original_values + torch.bmm(
123
+ dense_weight[index], markov_embed.unsqueeze(-1)
124
+ ).squeeze(-1)
125
+ torch.testing.assert_close(result.gather(1, index), expected)
126
+ torch.testing.assert_close(values, original_values)
127
+
128
+
129
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
130
+def test_nvfp4_gathered_bias_rejects_non_16_group_size():
131
+ device = torch.device("cuda")
132
+ rank = 32
133
+ vocab_size = 2
134
+
135
+ with pytest.raises(ValueError, match="group size of 16"):
136
+ nvfp4_gathered_bias(
137
+ torch.zeros((1, rank), dtype=torch.bfloat16, device=device),
138
+ torch.zeros((vocab_size, rank // 2), dtype=torch.uint8, device=device),
139
+ torch.ones(
140
+ (vocab_size, rank // 32), dtype=torch.float8_e4m3fn, device=device
141
+ ),
142
+ torch.ones(1, dtype=torch.float32, device=device),
143
+ torch.zeros((1, 1), dtype=torch.bfloat16, device=device),
144
+ torch.zeros((1, 1), dtype=torch.int64, device=device),
145
+ torch.full(
146
+ (1, vocab_size), float("-inf"), dtype=torch.bfloat16, device=device
147
+ ),
148
+ 1.0,
149
+ )
150
+
151
+
152
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
153
+@pytest.mark.parametrize("batch_size", [1, 2, 4, 8])
154
+def test_nvfp4_gathered_markov_bias_kernel_matches_reference(batch_size: int):
155
+ torch.manual_seed(0)
156
+ device = torch.device("cuda")
157
+ topk = 256
158
+ rank = 512
159
+ vocab_size = 512
160
+ alpha = 0.75
161
+
162
+ packed_weight = torch.randint(
163
+ 0, 256, (vocab_size, rank // 2), dtype=torch.uint8, device=device
164
+ )
165
+ weight_scale = torch.full(
166
+ (vocab_size, rank // 16),
167
+ 0.5,
168
+ dtype=torch.float8_e4m3fn,
169
+ device=device,
170
+ )
171
+ global_scale = torch.tensor([0.25], dtype=torch.float32, device=device)
172
+
173
+ markov_storage = torch.randn(
174
+ batch_size, 2, rank, dtype=torch.bfloat16, device=device
175
+ )
176
+ values_storage = torch.randn(
177
+ batch_size, 2, topk, dtype=torch.bfloat16, device=device
178
+ )
179
+ index_storage = torch.empty(batch_size, 2, topk, dtype=torch.int64, device=device)
180
+ for batch_idx in range(batch_size):
181
+ index_storage[batch_idx, 0] = (
182
+ torch.arange(topk, device=device) + batch_idx
183
+ ) % vocab_size
184
+ logits_storage = torch.full(
185
+ (batch_size, 2, vocab_size),
186
+ float("-inf"),
187
+ dtype=torch.bfloat16,
188
+ device=device,
189
+ )
190
+
191
+ markov_embed = markov_storage[:, 0]
192
+ values = values_storage[:, 0]
193
+ index = index_storage[:, 0]
194
+ logits = logits_storage[:, 0]
195
+
196
+ layer = nn.Module()
197
+ layer.register_buffer("_nvfp4_weight_for_gather", packed_weight)
198
+ layer.register_buffer("_nvfp4_weight_scale_for_gather", weight_scale)
199
+ layer.register_buffer("_nvfp4_weight_global_scale_for_gather", global_scale)
200
+ layer._nvfp4_group_size_for_gather = 16
201
+ layer._retain_weight_for_gather = True
202
+ layer.is_w4a16_nvfp4 = True
203
+ head = DSparkMarkovHead.__new__(DSparkMarkovHead)
204
+ nn.Module.__init__(head)
205
+ head.markov_w2 = layer
206
+
207
+ flat_index = index.reshape(-1)
208
+ selected_weight = dequantize_to_dtype(
209
+ packed_weight.index_select(0, flat_index),
210
+ weight_scale.index_select(0, flat_index),
211
+ global_scale,
212
+ dtype=torch.bfloat16,
213
+ block_size=16,
214
+ swizzle=False,
215
+ ).view(batch_size, topk, rank)
216
+ expected = (
217
+ values
218
+ + torch.bmm(selected_weight, markov_embed.unsqueeze(-1)).squeeze(-1) * alpha
219
+ )
220
+
221
+ result = head.apply_bias_gathered(markov_embed, logits, values, index, alpha)
222
+
223
+ torch.testing.assert_close(
224
+ result.gather(1, index), expected, rtol=1e-2, atol=6.25e-2
225
+ )
226
+ selected = torch.zeros_like(result, dtype=torch.bool).scatter_(1, index, True)
227
+ assert torch.isneginf(result.masked_select(~selected)).all()
228
+
229
+ if batch_size == 1:
230
+ logits.fill_(float("-inf"))
231
+ graph = torch.cuda.CUDAGraph()
232
+ with torch.cuda.graph(graph):
233
+ captured = head.apply_bias_gathered(
234
+ markov_embed, logits, values, index, alpha
235
+ )
236
+ logits.fill_(float("-inf"))
237
+ graph.replay()
238
+ assert captured is logits
239
+ torch.testing.assert_close(
240
+ logits.gather(1, index), expected, rtol=1e-2, atol=6.25e-2
241
+ )
vllm/model_executor/layers/quantization/modelopt.py
+24
@@ -2458,6 +2458,30 @@ class ModelOptLinearMethod(LinearMethodBase):
2458
self.akey.process(layer, ACT)
2459
maybe_fuse_global_scales(layer)
2460
self.fmt.post_process(layer)
2461
+ layer.is_w4a16_nvfp4 = (
2462
+ self.spec.weight == kNvfp4Static and self.spec.activation is None
2463
+ )
2464
+ if getattr(layer, "_retain_weight_for_gather", False):
2465
+ if not layer.is_w4a16_nvfp4:
2466
+ raise NotImplementedError(
2467
+ "Gathered projection is only supported for ModelOpt W4A16 "
2468
+ "NVFP4 weights."
2469
+ )
2470
+ assert self.ctx.group_size is not None
2471
+ layer.register_buffer(
2472
+ "_nvfp4_weight_for_gather", layer.weight.detach(), persistent=False
2473
+ )
2474
+ layer.register_buffer(
2475
+ "_nvfp4_weight_scale_for_gather",
2476
+ layer.weight_scale.detach(),
2477
+ persistent=False,
2478
+ )
2479
+ layer.register_buffer(
2480
+ "_nvfp4_weight_global_scale_for_gather",
2481
+ layer.weight_global_scale.detach(),
2482
+ persistent=False,
2483
+ )
2484
+ layer._nvfp4_group_size_for_gather = self.ctx.group_size
2485
self.kernel.process_weights_after_loading(layer)
2486
2487
def apply(self, layer, x, bias=None):
vllm/model_executor/layers/quantization/utils/nvfp4_emulation_utils.py
+227
-1
@@ -7,10 +7,12 @@ import torch
7
from vllm.platforms import current_platform
8
from vllm.scalar_type import scalar_types
9
from vllm.triton_utils import tl, triton
10
+from vllm.utils.torch_utils import direct_register_custom_op
11
12
__all__ = [
13
"break_fp4_bytes",
14
"dequantize_to_dtype",
15
+ "nvfp4_gathered_bias",
16
"ref_nvfp4_quant",
17
]
18
@@ -43,6 +45,230 @@ def _e2m1_inline(nibble):
45
return tl.where(sign == 1, -val, val)
46
47
48
+@triton.jit
49
+def _nvfp4_gathered_bias_kernel(
50
+ markov_ptr,
51
+ packed_weight_ptr,
52
+ weight_scale_ptr,
53
+ global_scale_ptr,
54
+ values_ptr,
55
+ index_ptr,
56
+ logits_ptr,
57
+ stride_markov_b,
58
+ stride_markov_k,
59
+ stride_weight_v,
60
+ stride_weight_k,
61
+ stride_scale_v,
62
+ stride_scale_k,
63
+ stride_values_b,
64
+ stride_values_j,
65
+ stride_index_b,
66
+ stride_index_j,
67
+ stride_logits_b,
68
+ stride_logits_v,
69
+ alpha,
70
+ TOPK: tl.constexpr,
71
+ K: tl.constexpr,
72
+ NUM_BLOCKS: tl.constexpr,
73
+):
74
+ """Apply selected NVFP4 Markov-projection rows to dense logits.
75
+
76
+ BF16: ``weight[index] -> baddbmm_ -> scatter_``.
77
+ NVFP4: ``gather packed W2 row -> dequantize 16-element blocks -> dot with
78
+ Markov embedding -> add base value -> scatter corrected logit``.
79
+
80
+ The fused NVFP4 path is needed because packed weights cannot be indexed as
81
+ BF16 rows, while dequantizing the full vocabulary would defeat top-k.
82
+ """
83
+ candidate_id = tl.program_id(0)
84
+ request_id = candidate_id // TOPK
85
+ candidate = candidate_id - request_id * TOPK
86
+
87
+ vocab_id = tl.load(
88
+ index_ptr + request_id * stride_index_b + candidate * stride_index_j
89
+ ).to(tl.int64)
90
+
91
+ block_ids = tl.arange(0, NUM_BLOCKS)
92
+ byte_ids = tl.arange(0, 8)
93
+ block_mask = block_ids * 16 < K
94
+
95
+ packed_offsets = block_ids[:, None] * 8 + byte_ids[None, :]
96
+ packed = tl.load(
97
+ packed_weight_ptr
98
+ + vocab_id * stride_weight_v
99
+ + packed_offsets * stride_weight_k,
100
+ mask=block_mask[:, None],
101
+ other=0,
102
+ )
103
+ low = _e2m1_inline(packed & 0x0F)
104
+ high = _e2m1_inline((packed >> 4) & 0x0F)
105
+ decoded = tl.interleave(low, high)
106
+
107
+ raw_weight_scale = tl.load(
108
+ weight_scale_ptr + vocab_id * stride_scale_v + block_ids * stride_scale_k,
109
+ mask=block_mask,
110
+ other=0,
111
+ )
112
+ weight_scale = tl.cast(
113
+ raw_weight_scale,
114
+ tl.float8e4nv,
115
+ bitcast=True,
116
+ ).to(tl.float32)
117
+ global_scale = tl.load(global_scale_ptr).to(tl.float32)
118
+
119
+ element_ids = tl.arange(0, 16)
120
+ k_offsets = block_ids[:, None] * 16 + element_ids[None, :]
121
+ k_mask = k_offsets < K
122
+ markov = tl.load(
123
+ markov_ptr + request_id * stride_markov_b + k_offsets * stride_markov_k,
124
+ mask=k_mask,
125
+ other=0.0,
126
+ )
127
+
128
+ dequant_weight = (decoded * weight_scale[:, None] * global_scale).to(tl.bfloat16)
129
+ products = dequant_weight.to(tl.float32) * markov.to(tl.float32)
130
+ block_sums = tl.sum(products, axis=1)
131
+ dot = tl.sum(block_sums, axis=0)
132
+
133
+ base_value = tl.load(
134
+ values_ptr + request_id * stride_values_b + candidate * stride_values_j
135
+ ).to(tl.float32)
136
+ corrected = base_value + alpha * dot
137
+ tl.store(
138
+ logits_ptr + request_id * stride_logits_b + vocab_id * stride_logits_v,
139
+ corrected,
140
+ )
141
+
142
+
143
+def _nvfp4_gathered_bias_impl(
144
+ markov_embed: torch.Tensor,
145
+ packed_weight: torch.Tensor,
146
+ weight_scale: torch.Tensor,
147
+ global_scale: torch.Tensor,
148
+ values: torch.Tensor,
149
+ index: torch.Tensor,
150
+ logits: torch.Tensor,
151
+ alpha: float,
152
+) -> None:
153
+ if index.numel() == 0:
154
+ return
155
+
156
+ assert markov_embed.dtype == torch.bfloat16
157
+ assert values.dtype == torch.bfloat16
158
+ assert logits.dtype == torch.bfloat16
159
+ assert packed_weight.dtype == torch.uint8
160
+ assert weight_scale.dtype == torch.float8_e4m3fn
161
+ assert index.dtype == torch.int64
162
+ assert markov_embed.is_cuda
163
+ assert all(
164
+ tensor.device == markov_embed.device
165
+ for tensor in (
166
+ packed_weight,
167
+ weight_scale,
168
+ global_scale,
169
+ values,
170
+ index,
171
+ logits,
172
+ )
173
+ )
174
+ assert markov_embed.ndim == 2
175
+ assert packed_weight.ndim == 2
176
+ assert weight_scale.ndim == 2
177
+ assert values.ndim == 2
178
+ assert index.ndim == 2
179
+ assert logits.ndim == 2
180
+ assert global_scale.numel() == 1
181
+ assert values.shape == index.shape
182
+ assert logits.shape[0] == index.shape[0] == markov_embed.shape[0]
183
+
184
+ k = markov_embed.shape[-1]
185
+ if k % 16 != 0 or packed_weight.shape[-1] * 2 != k:
186
+ raise ValueError(
187
+ "nvfp4_gathered_bias requires k to be divisible by 16 and "
188
+ f"packed_weight width to equal k / 2; got k={k} and "
189
+ f"packed_weight width={packed_weight.shape[-1]}"
190
+ )
191
+ if weight_scale.shape[-1] != k // 16:
192
+ raise ValueError(
193
+ "nvfp4_gathered_bias only supports an NVFP4 group size of 16; "
194
+ f"got {weight_scale.shape[-1]} scale blocks for k={k}"
195
+ )
196
+
197
+ topk = index.shape[1]
198
+ num_blocks = triton.next_power_of_2(k // 16)
199
+ scale_bytes = weight_scale.view(torch.uint8)
200
+ _nvfp4_gathered_bias_kernel[(index.numel(),)](
201
+ markov_embed,
202
+ packed_weight,
203
+ scale_bytes,
204
+ global_scale,
205
+ values,
206
+ index,
207
+ logits,
208
+ markov_embed.stride(0),
209
+ markov_embed.stride(1),
210
+ packed_weight.stride(0),
211
+ packed_weight.stride(1),
212
+ scale_bytes.stride(0),
213
+ scale_bytes.stride(1),
214
+ values.stride(0),
215
+ values.stride(1),
216
+ index.stride(0),
217
+ index.stride(1),
218
+ logits.stride(0),
219
+ logits.stride(1),
220
+ alpha,
221
+ TOPK=topk,
222
+ K=k,
223
+ NUM_BLOCKS=num_blocks,
224
+ num_warps=4,
225
+ num_stages=2,
226
+ )
227
+
228
+
229
+def _nvfp4_gathered_bias_fake(
230
+ markov_embed: torch.Tensor,
231
+ packed_weight: torch.Tensor,
232
+ weight_scale: torch.Tensor,
233
+ global_scale: torch.Tensor,
234
+ values: torch.Tensor,
235
+ index: torch.Tensor,
236
+ logits: torch.Tensor,
237
+ alpha: float,
238
+) -> None:
239
+ return None
240
+
241
+
242
+direct_register_custom_op(
243
+ op_name="nvfp4_gathered_bias",
244
+ op_func=_nvfp4_gathered_bias_impl,
245
+ mutates_args=["logits"],
246
+ fake_impl=_nvfp4_gathered_bias_fake,
247
+)
248
+
249
+
250
+def nvfp4_gathered_bias(
251
+ markov_embed: torch.Tensor,
252
+ packed_weight: torch.Tensor,
253
+ weight_scale: torch.Tensor,
254
+ global_scale: torch.Tensor,
255
+ values: torch.Tensor,
256
+ index: torch.Tensor,
257
+ logits: torch.Tensor,
258
+ alpha: float,
259
+) -> None:
260
+ torch.ops.vllm.nvfp4_gathered_bias(
261
+ markov_embed,
262
+ packed_weight,
263
+ weight_scale,
264
+ global_scale,
265
+ values,
266
+ index,
267
+ logits,
268
+ alpha,
269
+ )
270
+
271
+
272
@triton.jit
273
def _dequantize_nvfp4_kernel(
274
fp4_ptr,
@@ -347,7 +573,7 @@ def dequantize_to_dtype(
573
# Two fp4 values are packed into one uint8.
574
assert tensor_fp4.dtype == torch.uint8
575
350
- if not swizzle and current_platform.is_cuda_alike():
576
+ if not swizzle and tensor_fp4.is_cuda and current_platform.is_cuda_alike():
577
return _triton_dequantize_nvfp4(
578
tensor_fp4, tensor_sf, global_scale, dtype, block_size
579
)
vllm/model_executor/models/qwen3_dspark.py
+57
-2
@@ -25,9 +25,14 @@ from vllm.logger import init_logger
25
from vllm.model_executor.layers.linear import ReplicatedLinear
26
from vllm.model_executor.layers.logits_processor import LogitsProcessor
27
from vllm.model_executor.layers.quantization import QuantizationConfig
28
+from vllm.model_executor.layers.quantization.utils.nvfp4_emulation_utils import (
29
+ dequantize_to_dtype,
30
+ nvfp4_gathered_bias,
31
+)
32
from vllm.model_executor.layers.vocab_parallel_embedding import (
33
ParallelLMHead,
34
)
35
+from vllm.platforms import current_platform
36
37
from .qwen3_dflash import DFlashQwen3ForCausalLM, DFlashQwen3Model
38
from .utils import (
@@ -60,6 +65,7 @@ class DSparkMarkovHead(nn.Module):
65
markov_rank: int,
66
prefix: str,
67
quant_config: QuantizationConfig | None = None,
68
+ retain_weight_for_gather: bool = False,
69
) -> None:
70
super().__init__()
71
self.markov_w1 = nn.Embedding(vocab_size, markov_rank)
@@ -71,6 +77,8 @@ class DSparkMarkovHead(nn.Module):
77
prefix=maybe_prefix(prefix, "markov_w2"),
78
disable_tp=True,
79
)
80
+ self.markov_w2._retain_weight_for_gather = retain_weight_for_gather
81
+ self.markov_w2.is_w4a16_nvfp4 = False
82
83
def embed(self, token_ids: torch.Tensor) -> torch.Tensor:
84
"""r-dim Markov embedding of ``token_ids`` ([B] -> [B, r])."""
@@ -98,8 +106,52 @@ class DSparkMarkovHead(nn.Module):
106
positions. This method scatters the corrected candidate values into
107
that dense buffer so the normal sampler sees the truncated proposal.
108
"""
101
- weight = self.markov_w2.weight[index]
102
- corrected = values.unsqueeze(-1)
109
+ if self.markov_w2._retain_weight_for_gather and self.markov_w2.is_w4a16_nvfp4:
110
+ # Use the fast Triton kernel for W4A16 NVFP4 weights with BF16
111
+ # activations on CUDA. FP16 activations, non-CUDA accelerators, and
112
+ # CPU use the slower dequantize/scatter fallback below.
113
+ if (
114
+ current_platform.is_cuda()
115
+ and markov_embed.is_cuda
116
+ and markov_embed.dtype == torch.bfloat16
117
+ and values.dtype == torch.bfloat16
118
+ and logits.dtype == torch.bfloat16
119
+ ):
120
+ nvfp4_gathered_bias(
121
+ markov_embed,
122
+ self.markov_w2._nvfp4_weight_for_gather,
123
+ self.markov_w2._nvfp4_weight_scale_for_gather,
124
+ self.markov_w2._nvfp4_weight_global_scale_for_gather,
125
+ values,
126
+ index,
127
+ logits,
128
+ scale,
129
+ )
130
+ return logits
131
+
132
+ flat_index = index.reshape(-1)
133
+ packed_weight = self.markov_w2._nvfp4_weight_for_gather.index_select(
134
+ 0, flat_index
135
+ )
136
+ weight_scale = self.markov_w2._nvfp4_weight_scale_for_gather.index_select(
137
+ 0, flat_index
138
+ )
139
+ weight = dequantize_to_dtype(
140
+ packed_weight,
141
+ weight_scale,
142
+ self.markov_w2._nvfp4_weight_global_scale_for_gather,
143
+ dtype=markov_embed.dtype,
144
+ block_size=self.markov_w2._nvfp4_group_size_for_gather,
145
+ swizzle=False,
146
+ ).view(*index.shape, -1)
147
+ else:
148
+ weight = self.markov_w2.weight[index]
149
+ if weight.dtype != markov_embed.dtype:
150
+ raise RuntimeError(
151
+ "Gathered Markov projection requires unquantized weights or "
152
+ "retained ModelOpt W4A16 NVFP4 weights."
153
+ )
154
+ corrected = values.unsqueeze(-1).clone()
155
corrected.baddbmm_(
156
weight,
157
markov_embed.unsqueeze(-1),
@@ -160,6 +212,9 @@ class Qwen3DSparkModel(DFlashQwen3Model):
212
config.markov_rank,
213
prefix=maybe_prefix(prefix, "markov_head"),
214
quant_config=self.quant_config,
215
+ retain_weight_for_gather=(
216
+ getattr(config, "dspark_draft_topk", None) is not None
217
+ ),
218
)
219
self.confidence_head: DSparkConfidenceHead | None = None
220
if getattr(config, "enable_confidence_head", False):