[Qwen3.8-Flash-Next] Fuse PLE residual and QSA output gate (#55309)

Signed-off-by: Canlin <canlinguosdu@gmail.com> Signed-off-by: zjy0516 <riverclouds.zhu@qq.com> Co-authored-by: zjy0516 <riverclouds.zhu@qq.com> Co-authored-by: Kimi Code <noreply@moonshot.cn>

Canlin Guo committed Sep 14, 2026 at 20:16 UTC 3f55ad2f073a7d1f573db5a5a3fe3dc9c688ddee
7 files changed +111 -12
tests/models/qwen4_exp/test_ple.py
+12 -3
@@ -1610,6 +1610,12 @@ def test_fused_conv_correctness(
1610 dtype=torch.bfloat16,
1611 generator=rng,
1612 )
1613 + outer_residual = torch.randn(
1614 + inputs.shape,
1615 + device=device,
1616 + dtype=torch.bfloat16,
1617 + generator=rng,
1618 + )
1619 null_state = conv_state[NULL_BLOCK_ID].clone()
1620 residual_kernel = residual.clone()
1621 residual_reference = residual.clone()
@@ -1617,6 +1623,7 @@ def test_fused_conv_correctness(
1623 module._short_conv_dilated_dispatch(
1624 inputs=inputs,
1625 residual=residual_kernel,
1626 + outer_residual=outer_residual,
1627 metadata=metadata,
1628 conv_state=conv_state,
1629 conv_weights=weights,
@@ -1630,15 +1637,17 @@ def test_fused_conv_correctness(
1637 conv_state_len=module.conv_state_len,
1638 dilation=module.short_conv_dilation,
1639 )
1640 + residual_reference = outer_residual + residual_reference
1641
1634 - torch.testing.assert_close(
1635 - residual_kernel.float(), residual_reference.float(), atol=3e-2, rtol=3e-2
1642 + assert torch.equal(
1643 + residual_kernel[:num_real_tokens], residual_reference[:num_real_tokens]
1644 )
1645 assert torch.equal(conv_state, state_reference)
1646 assert torch.equal(conv_state[NULL_BLOCK_ID], null_state)
1647 if case.graph_padding:
1648 assert torch.equal(
1641 - residual_kernel[num_real_tokens:], residual[num_real_tokens:]
1649 + residual_kernel[num_real_tokens:],
1650 + (outer_residual + residual)[num_real_tokens:],
1651 )
1652
1653
tests/models/qwen4_exp/test_qsa_reference.py
+3
@@ -944,6 +944,7 @@ def test_qsa_sparse_paged_attention_correctness(
944 q = torch.randn(
945 num_rows, num_query_heads, head_dim, device="cuda", dtype=torch.bfloat16
946 )
947 + output_gate = torch.randn_like(q)
948 kv_cache = torch.randn(
949 num_cache_blocks,
950 page_size,
@@ -1023,6 +1024,7 @@ def test_qsa_sparse_paged_attention_correctness(
1024 block_table,
1025 token_to_req,
1026 use_prefill_config=use_prefill_config,
1027 + output_gate=output_gate,
1028 )
1029 expected = _qsa_sparse_paged_attention_reference(
1030 q,
@@ -1033,6 +1035,7 @@ def test_qsa_sparse_paged_attention_correctness(
1035 token_to_req,
1036 scale,
1037 )
1038 + expected = expected * torch.sigmoid(output_gate)
1039
1040 torch.testing.assert_close(actual, expected, rtol=2e-2, atol=2e-2)
1041
vllm/models/qwen4_exp/nvidia/model.py
+1 -1
@@ -297,7 +297,7 @@ class Qwen4ExpDecoderLayer(nn.Module):
297
298 if input_ids is None or query_start_loc is None or ngram_context is None:
299 raise RuntimeError("PLE inputs were not prepared")
300 - hidden_states = hidden_states + self.ple(
300 + hidden_states = self.ple(
301 hidden_states,
302 input_ids,
303 query_start_loc,
vllm/models/qwen4_exp/nvidia/ops/ple.py
+15 -2
@@ -322,6 +322,7 @@ def _ple_conv_kernel(
322 state_ptr,
323 w_ptr,
324 residual_ptr,
325 + outer_residual_ptr,
326 state_idx_ptr,
327 qsl_ptr,
328 num_acc_ptr,
@@ -442,11 +443,21 @@ def _ple_conv_kernel(
443 mask=c_mask,
444 other=0.0,
445 )
446 + # Preserve the original eager operation boundaries: short convolution is
447 + # first accumulated into the BF16/FP16 PLE output, then the outer residual
448 + # is added to that rounded value.
449 + ple_output = (residual + conv_output).to(residual_ptr.dtype.element_ty)
450 + outer_residual = tl.load(
451 + outer_residual_ptr + output_t * C + c_offs,
452 + mask=c_mask,
453 + other=0.0,
454 + )
455 + ple_output = outer_residual.to(tl.float32) + ple_output.to(tl.float32)
456 if launch_pdl:
457 tl.extra.cuda.gdc_launch_dependents()
458 tl.store(
459 residual_ptr + output_t * C + c_offs,
449 - residual + conv_output,
460 + ple_output,
461 mask=c_mask,
462 )
463
@@ -564,6 +575,7 @@ def ple_conv(
575 conv_state: torch.Tensor,
576 conv_weights: torch.Tensor,
577 state_indices: torch.Tensor,
578 + outer_residual: torch.Tensor,
579 *,
580 mode: Literal["decode", "spec", "prefill"],
581 dilation: int,
@@ -573,7 +585,7 @@ def ple_conv(
585 spec_query_len: int = 1,
586 token_indices: torch.Tensor | None = None,
587 ) -> None:
576 - """Add short-convolution output to ``residual`` and update its state."""
588 + """Add short convolution and the outer residual; update state."""
589 BLOCK_C = 512
590 kernel_spec_query_len = spec_query_len if mode == "spec" else 1
591 T, C = inputs.shape
@@ -624,6 +636,7 @@ def ple_conv(
636 conv_state,
637 conv_weights,
638 residual,
639 + outer_residual,
640 state_indices,
641 query_start_loc,
642 num_accepted_tokens,
vllm/models/qwen4_exp/nvidia/ops/qsa.py
+48
@@ -24,6 +24,7 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
24 partial_output_ptr,
25 partial_lse_ptr,
26 output_ptr,
27 + output_gate_ptr,
28 stride_q_row,
29 stride_q_head,
30 stride_k_block,
@@ -36,6 +37,8 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
37 stride_table_req,
38 stride_output_row,
39 stride_output_head,
40 + stride_output_gate_row,
41 + stride_output_gate_head,
42 num_rows,
43 num_cache_blocks,
44 num_requests,
@@ -151,6 +154,18 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
154 )
155 output_mask = head_offsets[:, None] < GROUP_SIZE
156 if NUM_SPLITS == 1:
157 + # Preserve the unfused path's BF16 attention-output rounding before
158 + # applying the gate in FP32.
159 + normalized_output = normalized_output.to(output_ptr.dtype.element_ty)
160 + output_gate = tl.load(
161 + output_gate_ptr
162 + + row * stride_output_gate_row
163 + + (first_head + head_offsets[:, None]) * stride_output_gate_head
164 + + dim_offsets[None, :],
165 + mask=output_mask,
166 + other=0.0,
167 + ).to(tl.float32)
168 + normalized_output = normalized_output.to(tl.float32) * tl.sigmoid(output_gate)
169 tl.store(
170 output_ptr
171 + row * stride_output_row
@@ -186,8 +201,11 @@ def _qsa_merge_splitk_kernel(
201 partial_output_ptr,
202 partial_lse_ptr,
203 output_ptr,
204 + output_gate_ptr,
205 stride_output_row,
206 stride_output_head,
207 + stride_output_gate_row,
208 + stride_output_gate_head,
209 num_rows,
210 HEAD_DIM: tl.constexpr,
211 NUM_QUERY_HEADS: tl.constexpr,
@@ -219,6 +237,16 @@ def _qsa_merge_splitk_kernel(
237 )
238 merged = tl.sum(partial_output * weights[:, None], axis=0)
239 merged = tl.where(denominator > 0, merged / denominator, 0.0)
240 + # Preserve the unfused path's BF16 attention-output rounding before
241 + # applying the gate in FP32.
242 + merged = merged.to(output_ptr.dtype.element_ty)
243 + output_gate = tl.load(
244 + output_gate_ptr
245 + + row * stride_output_gate_row
246 + + head * stride_output_gate_head
247 + + dim_offsets
248 + ).to(tl.float32)
249 + merged = merged.to(tl.float32) * tl.sigmoid(output_gate)
250 tl.store(
251 output_ptr + row * stride_output_row + head * stride_output_head + dim_offsets,
252 merged,
@@ -455,6 +483,8 @@ def qsa_sparse_paged_attention(
483 token_to_req: torch.Tensor,
484 use_prefill_config: bool,
485 out: torch.Tensor | None = None,
486 + *,
487 + output_gate: torch.Tensor,
488 ) -> torch.Tensor:
489 """Run sparse GQA directly over paged BF16 K/V caches.
490
@@ -496,6 +526,8 @@ def qsa_sparse_paged_attention(
526 raise ValueError("QSA sparse output must match its query")
527 assert out.dtype == q.dtype and out.device == q.device
528 assert out.stride(2) == 1
529 + assert output_gate.is_contiguous()
530 + output_gate_view = output_gate.view_as(q)
531 if not q.shape[0]:
532 return out
533
@@ -533,6 +565,7 @@ def qsa_sparse_paged_attention(
565 partial_output,
566 partial_lse,
567 out,
568 + output_gate_view,
569 q.stride(0),
570 q.stride(1),
571 k_cache.stride(0),
@@ -545,6 +578,8 @@ def qsa_sparse_paged_attention(
578 block_table.stride(0),
579 out.stride(0),
580 out.stride(1),
581 + output_gate_view.stride(0),
582 + output_gate_view.stride(1),
583 q.shape[0],
584 k_cache.shape[0],
585 block_table.shape[0],
@@ -568,8 +603,11 @@ def qsa_sparse_paged_attention(
603 partial_output,
604 partial_lse,
605 out,
606 + output_gate_view,
607 out.stride(0),
608 out.stride(1),
609 + output_gate_view.stride(0),
610 + output_gate_view.stride(1),
611 q.shape[0],
612 HEAD_DIM=q.shape[2],
613 NUM_QUERY_HEADS=q.shape[1],
@@ -632,6 +670,10 @@ def warmup_qsa_sparse_paged_attention(
670 output_ptr = TritonWarmupTensor(
671 torch.bfloat16, shape=(num_rows, num_query_heads, head_dim)
672 )
673 + # The output gate is mandatory at runtime; warm the gated specialization.
674 + output_gate_ptr = TritonWarmupTensor(
675 + torch.bfloat16, shape=(num_rows, num_query_heads, head_dim)
676 + )
677 head_stride = head_dim
678 row_stride = num_query_heads * head_dim
679 num_cache_blocks = triton_scalar_specialization_rep(kv_cache.shape[0])
@@ -659,6 +701,7 @@ def warmup_qsa_sparse_paged_attention(
701 partial_output_ptr,
702 partial_lse_ptr,
703 output_ptr,
704 + output_gate_ptr,
705 row_stride,
706 head_stride,
707 key_cache.stride(0),
@@ -671,6 +714,8 @@ def warmup_qsa_sparse_paged_attention(
714 block_table.stride(0),
715 row_stride,
716 head_stride,
717 + row_stride,
718 + head_stride,
719 num_rows,
720 num_cache_blocks,
721 num_requests,
@@ -693,6 +738,9 @@ def warmup_qsa_sparse_paged_attention(
738 partial_output_ptr,
739 partial_lse_ptr,
740 output_ptr,
741 + output_gate_ptr,
742 + row_stride,
743 + head_stride,
744 row_stride,
745 head_stride,
746 num_rows,
vllm/models/qwen4_exp/nvidia/ple_layer.py
+26 -4
@@ -195,6 +195,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
195 self,
196 inputs: torch.Tensor,
197 residual: torch.Tensor,
198 + outer_residual: torch.Tensor,
199 metadata: PleShortConvAttentionMetadata,
200 conv_state: torch.Tensor,
201 conv_weights: torch.Tensor,
@@ -209,6 +210,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
210 has_non_spec = has_prefill or has_decode
211 inputs = inputs[: metadata.num_actual_tokens]
212 residual = residual[: metadata.num_actual_tokens]
213 + outer_residual = outer_residual[: metadata.num_actual_tokens]
214
215 spec_token_indices = None
216 non_spec_token_indices = None
@@ -235,6 +237,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
237 conv_state=conv_state,
238 conv_weights=conv_weights,
239 state_indices=spec_state_indices,
240 + outer_residual=outer_residual,
241 mode="spec",
242 dilation=self.short_conv_dilation,
243 query_start_loc=query_start_loc,
@@ -259,11 +262,17 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
262 residual_d, residual_p = torch.split(
263 residual, [num_decode_tokens, num_prefill_tokens], dim=0
264 )
265 + outer_residual_d, outer_residual_p = torch.split(
266 + outer_residual,
267 + [num_decode_tokens, num_prefill_tokens],
268 + dim=0,
269 + )
270 token_indices_d = None
271 token_indices_p = None
272 else:
273 inputs_d = inputs_p = inputs
274 residual_d = residual_p = residual
275 + outer_residual_d = outer_residual_p = outer_residual
276 token_indices_d, token_indices_p = torch.split(
277 non_spec_token_indices,
278 [num_decode_tokens, num_prefill_tokens],
@@ -277,6 +286,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
286 conv_state=conv_state,
287 conv_weights=conv_weights,
288 state_indices=state_indices_d,
289 + outer_residual=outer_residual_d,
290 mode="decode",
291 dilation=self.short_conv_dilation,
292 has_initial_states=metadata.has_initial_states_d,
@@ -298,6 +308,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
308 conv_state=conv_state,
309 conv_weights=conv_weights,
310 state_indices=state_indices_p,
311 + outer_residual=outer_residual_p,
312 mode="prefill",
313 dilation=self.short_conv_dilation,
314 query_start_loc=query_start_loc,
@@ -316,6 +327,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
327 conv_state=conv_state,
328 conv_weights=conv_weights,
329 state_indices=state_indices[:num_decode_rows],
330 + outer_residual=outer_residual,
331 mode="decode",
332 dilation=self.short_conv_dilation,
333 has_initial_states=metadata.has_initial_states_d,
@@ -324,12 +336,18 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
336
337 # State routing consumes the current request metadata on every replay.
338 @eager_break_during_capture
327 - def _short_conv(self, inputs: torch.Tensor, residual: torch.Tensor) -> None:
339 + def _short_conv(
340 + self,
341 + inputs: torch.Tensor,
342 + residual: torch.Tensor,
343 + outer_residual: torch.Tensor,
344 + ) -> None:
345 forward_context = get_forward_context()
346 attn_metadata = forward_context.attn_metadata
330 - # Profiling omits all metadata or this Mamba entry. The residual
331 - # already contains the gated output, so short convolution is a no-op.
347 + # Profiling omits all metadata or this Mamba entry. Short convolution
348 + # is a no-op there, but preserve the outer residual addition.
349 if attn_metadata is None:
350 + residual.add_(outer_residual)
351 return
352
353 if not isinstance(attn_metadata, dict):
@@ -340,6 +358,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
358
359 layer_attn_metadata = attn_metadata.get(self.prefix)
360 if layer_attn_metadata is None:
361 + residual.add_(outer_residual)
362 return
363 if not isinstance(layer_attn_metadata, PleShortConvAttentionMetadata):
364 raise TypeError(
@@ -367,6 +386,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
386 self._short_conv_dilated_dispatch(
387 inputs=inputs,
388 residual=residual,
389 + outer_residual=outer_residual,
390 metadata=layer_attn_metadata,
391 conv_state=conv_state,
392 conv_weights=conv_weights.to(dtype=inputs.dtype),
@@ -404,7 +424,9 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
424 self.norm_conv.weight,
425 self.norm_key.eps,
426 )
407 - self._short_conv(conv_input, gated_output)
427 + # State routing depends on runtime request metadata and remains outside
428 + # the graph; short convolution accumulates into gated_output.
429 + self._short_conv(conv_input, gated_output, hidden_states)
430 return gated_output
431
432
vllm/models/qwen4_exp/nvidia/qsa.py
+6 -2
@@ -121,6 +121,7 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
121 output: torch.Tensor,
122 token_to_req: torch.Tensor,
123 use_prefill_config: bool,
124 + output_gate: torch.Tensor,
125 output_scale: torch.Tensor | None = None,
126 output_block_scale: torch.Tensor | None = None,
127 ) -> torch.Tensor:
@@ -157,6 +158,7 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
158 token_to_req,
159 use_prefill_config,
160 output[:num_tokens],
161 + output_gate=output_gate[:num_tokens],
162 )
163 return output
164
@@ -353,6 +355,7 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
355 key: torch.Tensor,
356 value: torch.Tensor,
357 output: torch.Tensor,
358 + output_gate: torch.Tensor,
359 ) -> None:
360 metadata = get_forward_context().attn_metadata
361 if isinstance(metadata, list):
@@ -396,6 +399,7 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
399 output,
400 token_to_req=side_metadata.token_to_req,
401 use_prefill_config=main_metadata.max_query_len > self._max_decode_query_len,
402 + output_gate=output_gate,
403 )
404
405 def forward(
@@ -405,6 +409,7 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
409 ) -> torch.Tensor:
410 qkv, _ = self.qkv_proj(hidden_states)
411 q, k, v, gate = self._project_qkv_gate(qkv, positions)
412 + assert gate is not None
413 num_tokens = hidden_states.shape[0]
414 query = q.view(num_tokens, self.num_heads, self.head_dim)
415 key = k.view(num_tokens, self.num_kv_heads, self.head_dim)
@@ -419,10 +424,9 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
424 key,
425 value,
426 attn_output,
427 + gate,
428 )
429 flat_output = attn_output.view(num_tokens, -1)
424 - if gate is not None:
425 - flat_output = flat_output * torch.sigmoid(gate)
430 output, _ = self.o_proj(flat_output)
431 return output
432