[Qwen3.8-Flash-Next] Remove torch.compile for NVIDIA implementation (#55272)

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

Thien Tran committed Sep 7, 2026 at 12:57 UTC d9105ea8001e0a6d77a96327d17515bb5791fb36
13 files changed +165 -185
tests/models/qwen4_exp/test_config.py
+37 -14
@@ -147,9 +147,9 @@ def test_qwen4_exp_model_state_prepares_ngram_context() -> None:
147 model_state.uses_ngram_embedding = True
148 model_state.ngram_context_len = 3
149 model_state.ngram_eos_token_id = 99
150 - model_state.ngram_context = torch.empty((4, 3), dtype=torch.int32)
150 + model_state.ngram_context = torch.empty((8, 3), dtype=torch.int32)
151 model_state.ngram_context_offsets = torch.arange(-3, 0, dtype=torch.int64)
152 - model_state.ple_query_start_loc = torch.empty(5, dtype=torch.int32)
152 + model_state.ple_query_start_loc = torch.empty(9, dtype=torch.int32)
153
154 input_batch = SimpleNamespace(
155 num_reqs=2,
@@ -167,34 +167,57 @@ def test_qwen4_exp_model_state_prepares_ngram_context() -> None:
167 with patch.object(MambaHybridModelState, "prepare_inputs", return_value={}):
168 model_inputs = model_state.prepare_inputs(input_batch, req_states)
169
170 + expected_query_start_loc = torch.full((9,), 3, dtype=torch.int32)
171 + expected_query_start_loc[0] = 0
172 + expected_query_start_loc[1] = 2
173 torch.testing.assert_close(
171 - model_inputs["query_start_loc"],
172 - torch.tensor([0, 2, 3, 3], dtype=torch.int32),
174 + model_inputs["query_start_loc"], expected_query_start_loc
175 )
176 + expected_context = torch.full((8, 3), 99, dtype=torch.int32)
177 + expected_context[:2] = torch.tensor([[99, 99, 20], [1, 2, 3]])
178 + torch.testing.assert_close(model_inputs["ngram_context"], expected_context)
179 +
180 + # Retain the views to detect reallocations as the request layout changes.
181 + query_start_loc = model_inputs["query_start_loc"]
182 + ngram_context = model_inputs["ngram_context"]
183 + input_batch.num_reqs = 1
184 + input_batch.num_reqs_after_padding = 1
185 + input_batch.idx_mapping = torch.tensor([0])
186 + input_batch.query_start_loc = torch.tensor([0, 3], dtype=torch.int32)
187 + with patch.object(MambaHybridModelState, "prepare_inputs", return_value={}):
188 + model_inputs = model_state.prepare_inputs(input_batch, req_states)
189 +
190 + expected_query_start_loc.fill_(3)
191 + expected_query_start_loc[0] = 0
192 torch.testing.assert_close(
175 - model_inputs["ngram_context"],
176 - torch.tensor([[99, 99, 20], [1, 2, 3], [99, 99, 99]], dtype=torch.int32),
193 + model_inputs["query_start_loc"], expected_query_start_loc
194 )
195 + expected_context.fill_(99)
196 + expected_context[0] = torch.tensor([1, 2, 3])
197 + torch.testing.assert_close(model_inputs["ngram_context"], expected_context)
198 + assert model_inputs["query_start_loc"].data_ptr() == query_start_loc.data_ptr()
199 + assert model_inputs["ngram_context"].data_ptr() == ngram_context.data_ptr()
200
201
202 def test_qwen4_exp_model_state_prepares_stable_dummy_ngram_inputs() -> None:
203 model_state = object.__new__(Qwen4ExpModelState)
204 model_state.uses_ngram_embedding = True
205 model_state.ngram_eos_token_id = 99
184 - model_state.ngram_context = torch.empty((4, 3), dtype=torch.int32)
185 - model_state.ple_query_start_loc = torch.empty(5, dtype=torch.int32)
206 + model_state.ngram_context = torch.empty((8, 3), dtype=torch.int32)
207 + model_state.ple_query_start_loc = torch.empty(9, dtype=torch.int32)
208
209 with patch.object(MambaHybridModelState, "prepare_dummy_inputs", return_value={}):
188 - first = model_state.prepare_dummy_inputs(num_reqs=3, num_tokens=8)
210 + first = model_state.prepare_dummy_inputs(num_reqs=3, num_tokens=4)
211 + # Dummy runs establish the addresses used during CUDA graph capture.
212 query_start_loc_ptr = first["query_start_loc"].data_ptr()
213 ngram_context_ptr = first["ngram_context"].data_ptr()
191 - second = model_state.prepare_dummy_inputs(num_reqs=3, num_tokens=8)
214 + second = model_state.prepare_dummy_inputs(num_reqs=3, num_tokens=4)
215
216 + expected_query_start_loc = torch.full((9,), 4, dtype=torch.int32)
217 + expected_query_start_loc[:4] = torch.tensor([0, 1, 2, 4], dtype=torch.int32)
218 + torch.testing.assert_close(second["query_start_loc"], expected_query_start_loc)
219 torch.testing.assert_close(
194 - second["query_start_loc"], torch.tensor([0, 2, 5, 8], dtype=torch.int32)
195 - )
196 - torch.testing.assert_close(
197 - second["ngram_context"], torch.full((3, 3), 99, dtype=torch.int32)
220 + second["ngram_context"], torch.full((8, 3), 99, dtype=torch.int32)
221 )
222 assert second["query_start_loc"].data_ptr() == query_start_loc_ptr
223 assert second["ngram_context"].data_ptr() == ngram_context_ptr
tests/models/qwen4_exp/test_ple.py
+12
@@ -498,6 +498,17 @@ def _ngram_hash_params(device: torch.device, context_len: int) -> dict:
498 ([3], [1], [[11]], 20),
499 ([4, 4], [0, 7], [[11, 12], [13, 14]], 20),
500 ([4, 0, 3], [], [[11, 12], [13, 14], [15, 16]], 20),
501 + (
502 + [3, 2, 0, 0],
503 + [],
504 + [
505 + [11, 12],
506 + [13, 14],
507 + [_NGRAM_EOS_TOKEN_ID, _NGRAM_EOS_TOKEN_ID],
508 + [_NGRAM_EOS_TOKEN_ID, _NGRAM_EOS_TOKEN_ID],
509 + ],
510 + 20,
511 + ),
512 (
513 [1, 33, 2],
514 [5, 32],
@@ -528,6 +539,7 @@ def _ngram_hash_params(device: torch.device, context_len: int) -> dict:
539 "bigram-only",
540 "power-of-two",
541 "empty-request",
542 + "trailing-padded-requests",
543 "three-requests",
544 "six-requests",
545 "four-gram",
tests/models/qwen4_exp/test_qsa_reference.py
+66 -2
@@ -45,7 +45,6 @@ def test_qsa_mtp_index_share_updates_cache_but_skips_selection(
45 indexer = SimpleNamespace(
46 skip_topk=True,
47 _metadata=lambda: (raw_metadata, compressed_metadata),
48 - index_qk_proj=lambda hidden: (torch.zeros(2, 2), None),
48 index_n_heads=1,
49 index_kv_heads=1,
50 index_head_dim=1,
@@ -80,7 +79,7 @@ def test_qsa_mtp_index_share_updates_cache_but_skips_selection(
79
80 actual = indexer_qsa.QSAIndexer.forward(
81 indexer,
83 - torch.zeros(2, 4),
82 + torch.zeros(2, 2),
83 torch.tensor([7, 8]),
84 rows,
85 )
@@ -446,6 +445,71 @@ def test_qsa_compressed_metadata_keeps_dummy_slots_inert() -> None:
445 assert metadata.k_work_metadata.tolist() == [[0, 0], [2, 0], [2, 1], [-1, -1]]
446
447
448 +@requires_qsa_kernels
449 +@pytest.mark.usefixtures("default_vllm_config")
450 +def test_qsa_unfused_cache_update_ignores_padded_qk() -> None:
451 + """Padded projected Q/K rows must not affect either side cache."""
452 + from vllm.model_executor.layers.rotary_embedding import get_rope
453 +
454 + device = torch.device("cuda")
455 + # Five tokens complete one compressed group and retain four keys in the ring.
456 + raw_metadata = SimpleNamespace(
457 + num_actual_tokens=5,
458 + slot_mapping=torch.tensor([-1, 1, 2, 3, 0], device=device),
459 + block_table=torch.zeros((1, 1), dtype=torch.int32, device=device),
460 + token_to_req=torch.zeros(5, dtype=torch.int32, device=device),
461 + query_start_loc=torch.tensor([0, 5], dtype=torch.int32, device=device),
462 + logical_positions=torch.arange(5, device=device),
463 + )
464 + compressed_metadata = SimpleNamespace(
465 + slot_mapping=torch.tensor([-1, -1, -1, 0, -1], device=device),
466 + )
467 + with torch.device(device):
468 + rope = get_rope(
469 + head_size=128,
470 + max_position=32,
471 + rope_parameters={"rope_type": "default", "partial_rotary_factor": 0.5},
472 + dtype=torch.bfloat16,
473 + )
474 + raw_cache = torch.zeros((1, 4, 1, 64), dtype=torch.bfloat16, device=device)
475 + compressed_cache = torch.zeros((1, 2, 1, 64), dtype=torch.bfloat16, device=device)
476 + norm = SimpleNamespace(
477 + weight=torch.zeros(64, dtype=torch.bfloat16, device=device),
478 + variance_epsilon=1e-6,
479 + )
480 + indexer = SimpleNamespace(
481 + _metadata=lambda: (raw_metadata, compressed_metadata),
482 + skip_topk=True,
483 + index_kv_heads=1,
484 + use_fused_pre_indexer=False,
485 + index_n_heads=1,
486 + index_head_dim=64,
487 + q_layernorm=norm,
488 + k_layernorm=norm,
489 + rotary_emb=rope,
490 + compress_ratio=4,
491 + raw_key_cache=SimpleNamespace(
492 + kv_cache=raw_cache, key_cache=raw_cache, rope_position_cache=None
493 + ),
494 + compressed_key_cache=SimpleNamespace(kv_cache=compressed_cache),
495 + )
496 + keys = torch.arange(1, 6, dtype=torch.bfloat16, device=device)[:, None].expand(
497 + 5, 64
498 + )
499 + padded_keys = torch.full((8, 64), torch.nan, dtype=torch.bfloat16, device=device)
500 + padded_keys[:5].copy_(keys)
501 + indexer_qsa.QSAIndexer.forward(
502 + indexer,
503 + torch.cat((torch.ones_like(padded_keys), padded_keys), dim=-1),
504 + torch.zeros(8, dtype=torch.long, device=device),
505 + torch.full((5, 5), -1, dtype=torch.int32, device=device),
506 + )
507 + torch.testing.assert_close(raw_cache[0, :, 0], keys[[4, 1, 2, 3]])
508 + expected_compressed = torch.zeros_like(compressed_cache)
509 + expected_compressed[0, 0] = 1
510 + torch.testing.assert_close(compressed_cache, expected_compressed)
511 +
512 +
513 @requires_qsa_kernels
514 @pytest.mark.parametrize("compress_ratio", [1, 4])
515 @pytest.mark.parametrize("num_reqs", [2, 3, 4, 7, 8, 9])
tests/test_config.py
+7 -1
@@ -338,9 +338,15 @@ def test_dsa_models_default_to_mrv2_and_breakable_cudagraph(
338 ("DeepseekV32MTPModel", True, False),
339 ("GlmMoeDsaForCausalLM", False, True),
340 ("GlmMoeDsaForCausalLM", True, False),
341 + ("Qwen4ExpForCausalLM", False, True),
342 + ("Qwen4ExpForCausalLM", True, False),
343 + ("Qwen4ExpForConditionalGeneration", False, True),
344 + ("Qwen4ExpForConditionalGeneration", True, False),
345 + ("Qwen4ExpMTP", False, True),
346 + ("Qwen4ExpMTP", True, False),
347 ],
348 )
343 -def test_dsa_breakable_cudagraph_platform_default(
349 +def test_breakable_cudagraph_platform_default(
350 monkeypatch, architecture, is_rocm, expected
351 ):
352 from vllm.config.vllm import default_breakable_cudagraph_architectures
vllm/config/compilation.py
+1 -1
@@ -768,7 +768,7 @@ class CompilationConfig:
768 "vllm::mamba_mixer2",
769 "vllm::mamba_mixer",
770 "vllm::short_conv",
771 - "vllm::qwen4_exp_compute_ple_ngram_ids",
771 + # Qwen4Exp's AMD backend still uses these splitting ops.
772 "vllm::qwen4_exp_ple_short_conv",
773 "vllm::qwen4_exp_qsa_with_output",
774 "vllm::linear_attention",
vllm/config/vllm.py
+3
@@ -94,6 +94,9 @@ DEFAULT_BREAKABLE_CUDAGRAPH_ARCHITECTURES = frozenset(
94 "KimiLinearForCausalLM",
95 "MiniMaxM3SparseForCausalLM",
96 "MiniMaxM3SparseForConditionalGeneration",
97 + "Qwen4ExpForCausalLM",
98 + "Qwen4ExpForConditionalGeneration",
99 + "Qwen4ExpMTP",
100 }
101 )
102
vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
+3
@@ -12,6 +12,7 @@ from torch import nn
12 from vllm import _custom_ops as ops
13 from vllm import envs
14 from vllm._aiter_ops import rocm_aiter_ops
15 +from vllm.compilation.breakable_cudagraph import eager_break_during_capture
16 from vllm.config import (
17 VllmConfig,
18 get_current_vllm_config,
@@ -1909,6 +1910,7 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
1910 )
1911
1912
1913 +@eager_break_during_capture
1914 def qwen_gdn_attention_core(
1915 qkv_or_qkvz: torch.Tensor,
1916 b_or_ba: torch.Tensor,
@@ -1956,6 +1958,7 @@ direct_register_custom_op(
1958 )
1959
1960
1961 +@eager_break_during_capture
1962 def qwen_gdn_attention_core_fused_norm_packed(
1963 mixed_qkvz: torch.Tensor,
1964 ba: torch.Tensor,
vllm/models/qwen4_exp/nvidia/indexer_qsa.py
+6 -8
@@ -88,7 +88,7 @@ def _supports_fused_pre_indexer(
88
89
90 class QSAIndexer(nn.Module):
91 - """Replicated Q/K projection plus paged, weight-free QSA selection.
91 + """QSA projection weights, side caches, and paged, weight-free selection.
92
93 ``prefix`` must be the checkpoint's indexer prefix, normally
94 ``model.layers.N.self_attn.indexer``. Consequently the trainable names are
@@ -218,11 +218,11 @@ class QSAIndexer(nn.Module):
218
219 def forward(
220 self,
221 - hidden_states: torch.Tensor,
221 + projected_qk: torch.Tensor,
222 positions: torch.Tensor,
223 out: torch.Tensor | None = None,
224 ) -> torch.Tensor:
225 - """Select each query row's token indices.
225 + """Update side caches and select token indices from pre-projected Q/K.
226
227 Returns the packed buffer of shape [num_tokens, output_width + 1]:
228 the leading ``output_width`` columns are ``-1``-padded
@@ -237,10 +237,10 @@ class QSAIndexer(nn.Module):
237 if self.skip_topk and out is not None:
238 return out
239 result = torch.full(
240 - (hidden_states.shape[0], self.packed_output_width),
240 + (projected_qk.shape[0], self.packed_output_width),
241 -1,
242 dtype=torch.int32,
243 - device=hidden_states.device,
243 + device=projected_qk.device,
244 )
245 # Inert rows carry a zero valid count (empty loop bound), not -1.
246 result[:, -1] = 0
@@ -258,11 +258,9 @@ class QSAIndexer(nn.Module):
258
259 raw_metadata, compressed_metadata = metadata
260 num_tokens = raw_metadata.num_actual_tokens
261 - hidden_states = hidden_states[:num_tokens]
261 + projected_qk = projected_qk[:num_tokens]
262 positions = positions[..., :num_tokens]
263
264 - # Q/K projection
265 - projected_qk, _ = self.index_qk_proj(hidden_states)
264 projected_q, raw_keys = projected_qk.split(
265 (
266 self.index_n_heads * self.index_head_dim,
vllm/models/qwen4_exp/nvidia/model.py
-12
@@ -8,7 +8,6 @@ from itertools import islice
8 import torch
9 from torch import nn
10
11 -from vllm.compilation.decorators import support_torch_compile
11 from vllm.config import VllmConfig
12 from vllm.distributed import get_pp_group
13 from vllm.model_executor.layers.fused_moe.utils import (
@@ -381,17 +380,6 @@ class Qwen4ExpMixtureOfExperts(MixtureOfExperts):
380 moe.experts.update_expert_map()
381
382
384 -@support_torch_compile(
385 - dynamic_arg_dims={
386 - "input_ids": 0,
387 - "positions": -1,
388 - "intermediate_tensors": 0,
389 - "inputs_embeds": 0,
390 - "query_start_loc": 0,
391 - "ngram_context": 0,
392 - "deepstack_input_embeds": 0,
393 - }
394 -)
383 class Qwen4ExpModel(nn.Module):
384 hf_to_vllm_mapper = Qwen3_5Model.hf_to_vllm_mapper | _EXTRA_WEIGHTS_MAPPER
385
vllm/models/qwen4_exp/nvidia/model_state.py
+11 -7
@@ -44,6 +44,8 @@ class Qwen4ExpModelState(MambaHybridModelState):
44 if self.ngram_context_len <= 0:
45 raise ValueError("N-gram embedding requires context length >= 1.")
46 self.ngram_eos_token_id = int(config.eos_token_id)
47 + # PLE runs inside captured regions, so these buffers keep a fixed shape
48 + # and address as the active request count changes between replays.
49 self.ngram_context = torch.full(
50 (self.max_num_reqs, self.ngram_context_len),
51 self.ngram_eos_token_id,
@@ -68,8 +70,7 @@ class Qwen4ExpModelState(MambaHybridModelState):
70 req_states: RequestState,
71 ) -> torch.Tensor:
72 num_reqs = input_batch.num_reqs
71 - num_reqs_padded = input_batch.num_reqs_after_padding
72 - context = self.ngram_context[:num_reqs_padded]
73 + context = self.ngram_context
74 context.fill_(self.ngram_eos_token_id)
75 if num_reqs == 0:
76 return context
@@ -101,8 +102,10 @@ class Qwen4ExpModelState(MambaHybridModelState):
102 return model_inputs
103
104 num_reqs_padded = input_batch.num_reqs_after_padding
104 - query_start_loc = self.ple_query_start_loc[: num_reqs_padded + 1]
105 - query_start_loc.copy_(input_batch.query_start_loc[: num_reqs_padded + 1])
105 + query_start_loc = self.ple_query_start_loc
106 + query_start_loc[: num_reqs_padded + 1].copy_(input_batch.query_start_loc)
107 + # Represent unused capacity as trailing zero-length requests.
108 + query_start_loc[num_reqs_padded + 1 :].copy_(input_batch.query_start_loc[-1])
109 model_inputs.update(
110 query_start_loc=query_start_loc,
111 ngram_context=self._prepare_ngram_context(input_batch, req_states),
@@ -118,7 +121,7 @@ class Qwen4ExpModelState(MambaHybridModelState):
121 if not self.uses_ngram_embedding:
122 return model_inputs
123
121 - query_start_loc = self.ple_query_start_loc[: num_reqs + 1]
124 + query_start_loc = self.ple_query_start_loc
125 query_start_loc[0] = 0
126 tokens_per_req, num_extra_tokens = divmod(num_tokens, num_reqs)
127 query_lens = torch.full(
@@ -129,9 +132,10 @@ class Qwen4ExpModelState(MambaHybridModelState):
132 )
133 if num_extra_tokens > 0:
134 query_lens[-num_extra_tokens:] += 1
132 - torch.cumsum(query_lens, dim=0, out=query_start_loc[1:])
135 + torch.cumsum(query_lens, dim=0, out=query_start_loc[1 : num_reqs + 1])
136 + query_start_loc[num_reqs + 1 :].fill_(num_tokens)
137
134 - ngram_context = self.ngram_context[:num_reqs]
138 + ngram_context = self.ngram_context
139 ngram_context.fill_(self.ngram_eos_token_id)
140 model_inputs.update(
141 query_start_loc=query_start_loc,
vllm/models/qwen4_exp/nvidia/mtp.py
-19
@@ -19,7 +19,6 @@ import regex as re
19 import torch
20 from torch import nn
21
22 -from vllm.compilation.decorators import support_torch_compile
22 from vllm.config import VllmConfig, replace, set_current_vllm_config
23 from vllm.distributed import get_pp_group
24 from vllm.model_executor.layers.fused_moe.utils import (
@@ -147,15 +146,6 @@ def _make_draft_vllm_config(
146 return draft_vllm_config
147
148
150 -@support_torch_compile(
151 - dynamic_arg_dims={
152 - "input_ids": 0,
153 - "positions": -1,
154 - "intermediate_tensors": 0,
155 - "inputs_embeds": 0,
156 - "hidden_states": 0,
157 - }
158 -)
149 class Qwen4ExpMultiTokenPredictor(nn.Module):
150 hf_to_vllm_mapper = Qwen3_5Model.hf_to_vllm_mapper | _EXTRA_WEIGHTS_MAPPER
151
@@ -355,15 +345,6 @@ class Qwen4ExpMultiTokenPredictor(nn.Module):
345 return loader.load_weights(weights, mapper=mapper)
346
347
358 -@support_torch_compile(
359 - dynamic_arg_dims={
360 - "input_ids": 0,
361 - "positions": -1,
362 - "intermediate_tensors": 0,
363 - "inputs_embeds": 0,
364 - "hidden_states": 0,
365 - }
366 -)
348 class Qwen4ExpMTP(nn.Module, SupportsPP, Qwen4ExpMixtureOfExperts):
349 packed_modules_mapping = {
350 "qkv_proj": ["q_proj", "k_proj", "v_proj"],
vllm/models/qwen4_exp/nvidia/ple_layer.py
+5 -61
@@ -8,6 +8,7 @@ import torch
8 import torch.nn.functional as F
9 from torch import nn
10
11 +from vllm.compilation.breakable_cudagraph import eager_break_during_capture
12 from vllm.config import CacheConfig, ModelConfig, VllmConfig, get_current_vllm_config
13 from vllm.forward_context import get_forward_context
14 from vllm.model_executor.layers.linear import MergedColumnParallelLinear
@@ -38,7 +39,6 @@ from vllm.model_executor.parameter import PerTensorScaleParameter
39 from vllm.transformers_utils.configs.qwen4_exp import (
40 Qwen4ExpTextConfig,
41 )
41 -from vllm.utils.torch_utils import direct_register_custom_op
42 from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
43 from vllm.v1.attention.backends.short_conv_attn import (
44 PleShortConvAttentionBackend,
@@ -264,12 +264,10 @@ class Qwen4ExpNGramEmbedding(nn.Module):
264 embedding_dim: int,
265 ple_dense_layer_id: int,
266 prefix: str,
267 - layer_name: str,
267 quant_config: QuantizationConfig | None = None,
268 params_dtype: torch.dtype | None = None,
269 ) -> None:
270 super().__init__()
272 - self.layer_name = layer_name
271 self.embedding_dim = embedding_dim
272 self.ngram_size = int(config.ngram_size)
273 self.heads_per_ngram = int(config.heads_per_ngram)
@@ -445,20 +443,7 @@ class Qwen4ExpNGramEmbedding(nn.Module):
443 query_start_loc: torch.Tensor,
444 ngram_context: torch.Tensor,
445 ) -> torch.Tensor:
448 - # Keep num_reqs-dependent ID generation outside PIECEWISE CUDA graphs,
449 - # which dispatch only on the padded token count.
450 - # torch.compile requires the splitting op to write graph-owned storage.
451 - # Once compilation is removed, the op can return the IDs directly.
452 - ngram_ids = input_ids.new_empty(
453 - (input_ids.numel(), self.ngram_heads), dtype=torch.long
454 - )
455 - torch.ops.vllm.qwen4_exp_compute_ple_ngram_ids(
456 - input_ids,
457 - query_start_loc,
458 - ngram_context,
459 - ngram_ids,
460 - self.layer_name,
461 - )
446 + ngram_ids = self.compute_ngram_ids(input_ids, query_start_loc, ngram_context)
447 return self.ngram_embedding(ngram_ids).flatten(-2)
448
449 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
@@ -563,7 +548,6 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
548 int(config.ple_embed_dim),
549 self.ple_dense_layer_id,
550 prefix=f"{prefix}.ple_embedding",
566 - layer_name=prefix,
551 quant_config=quant_config,
552 params_dtype=model_config.dtype,
553 )
@@ -779,6 +763,8 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
763 token_indices=non_spec_token_indices,
764 )
765
766 + # State routing consumes the current request metadata on every replay.
767 + @eager_break_during_capture
768 def _short_conv(self, inputs: torch.Tensor, residual: torch.Tensor) -> None:
769 forward_context = get_forward_context()
770 attn_metadata = forward_context.attn_metadata
@@ -854,52 +840,10 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
840 self.norm_conv.weight,
841 self.norm_key.eps,
842 )
857 - # State routing depends on runtime request metadata and remains outside
858 - # the piecewise graph; short convolution accumulates into gated_output.
859 - torch.ops.vllm.qwen4_exp_ple_short_conv(conv_input, gated_output, self.prefix)
843 + self._short_conv(conv_input, gated_output)
844 return gated_output
845
846
863 -def qwen4_exp_compute_ple_ngram_ids(
864 - input_ids: torch.Tensor,
865 - query_start_loc: torch.Tensor,
866 - ngram_context: torch.Tensor,
867 - output: torch.Tensor,
868 - layer_name: str,
869 -) -> None:
870 - """Compute request-dependent PLE n-gram IDs outside piecewise graphs."""
871 - layer = get_forward_context().no_compile_layers[layer_name]
872 - layer.ple_embedding.compute_ngram_ids(
873 - input_ids,
874 - query_start_loc,
875 - ngram_context,
876 - output,
877 - )
878 -
879 -
880 -def qwen4_exp_ple_short_conv(
881 - inputs: torch.Tensor,
882 - residual_output: torch.Tensor,
883 - layer_name: str,
884 -) -> None:
885 - layer = get_forward_context().no_compile_layers[layer_name]
886 - layer._short_conv(inputs, residual_output)
887 -
888 -
889 -direct_register_custom_op(
890 - op_name="qwen4_exp_compute_ple_ngram_ids",
891 - op_func=qwen4_exp_compute_ple_ngram_ids,
892 - mutates_args=["output"],
893 -)
894 -
895 -
896 -direct_register_custom_op(
897 - op_name="qwen4_exp_ple_short_conv",
898 - op_func=qwen4_exp_ple_short_conv,
899 - mutates_args=["residual_output"],
900 -)
901 -
902 -
847 __all__ = [
848 "Qwen4ExpNGramEmbedding",
849 "Qwen4ExpPLEGroupedNorm",
vllm/models/qwen4_exp/nvidia/qsa.py
+14 -60
@@ -9,6 +9,7 @@ from typing import ClassVar, cast
9 import torch
10 from torch import nn
11
12 +from vllm.compilation.breakable_cudagraph import eager_break_during_capture
13 from vllm.config import VllmConfig
14 from vllm.config.cache import CacheDType
15 from vllm.distributed import get_tensor_model_parallel_world_size
@@ -27,10 +28,6 @@ from vllm.transformers_utils.configs.qwen4_exp import (
28 Qwen4ExpTextConfig,
29 )
30 from vllm.utils.torch_utils import (
30 - LayerNameType,
31 - _encode_layer_name,
32 - _resolve_layer_name,
33 - direct_register_custom_op,
31 kv_cache_dtype_str_to_dtype,
32 )
33 from vllm.v1.attention.backend import (
@@ -347,9 +344,10 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
344 kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
345 )
346
347 + @eager_break_during_capture
348 def _run_qsa(
349 self,
352 - hidden_states: torch.Tensor,
350 + projected_qk: torch.Tensor,
351 positions: torch.Tensor,
352 query: torch.Tensor,
353 key: torch.Tensor,
@@ -374,7 +372,7 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
372 if side_metadata.num_actual_tokens != num_tokens:
373 raise RuntimeError("QSA main and side metadata token counts disagree")
374 selected = self.indexer(
377 - hidden_states,
375 + projected_qk,
376 positions,
377 self.topk_indices_buffer[:num_tokens],
378 )
@@ -412,27 +410,16 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
410 key = k.view(num_tokens, self.num_kv_heads, self.head_dim)
411 value = v.view(num_tokens, self.num_kv_heads, self.head_dim)
412 attn_output = torch.empty_like(query)
415 - encoded_layer_name = _encode_layer_name(self.layer_name)
416 - if current_platform.opaque_attention_op():
417 - torch.ops.vllm.qwen4_exp_qsa_with_output(
418 - hidden_states,
419 - positions,
420 - query,
421 - key,
422 - value,
423 - attn_output,
424 - encoded_layer_name,
425 - )
426 - else:
427 - qwen4_exp_qsa_with_output(
428 - hidden_states,
429 - positions,
430 - query,
431 - key,
432 - value,
433 - attn_output,
434 - encoded_layer_name,
435 - )
413 + # Keep the index projection outside the eager break.
414 + projected_qk, _ = self.indexer.index_qk_proj(hidden_states)
415 + self._run_qsa(
416 + projected_qk,
417 + positions,
418 + query,
419 + key,
420 + value,
421 + attn_output,
422 + )
423 flat_output = attn_output.view(num_tokens, -1)
424 if gate is not None:
425 flat_output = flat_output * torch.sigmoid(gate)
@@ -440,42 +427,9 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
427 return output
428
429
443 -def qwen4_exp_qsa_with_output(
444 - hidden_states: torch.Tensor,
445 - positions: torch.Tensor,
446 - query: torch.Tensor,
447 - key: torch.Tensor,
448 - value: torch.Tensor,
449 - output: torch.Tensor,
450 - layer_name: LayerNameType,
451 -) -> None:
452 - """Run the complete QSA state/update/attend transaction."""
453 -
454 - layer_name = _resolve_layer_name(layer_name)
455 - layer = get_forward_context().no_compile_layers[layer_name]
456 - if not isinstance(layer, Qwen4ExpQSAAttention):
457 - raise TypeError(f"{layer_name} is not a Qwen4Exp QSA owner")
458 - layer._run_qsa(
459 - hidden_states,
460 - positions,
461 - query,
462 - key,
463 - value,
464 - output,
465 - )
466 -
467 -
468 -direct_register_custom_op(
469 - op_name="qwen4_exp_qsa_with_output",
470 - op_func=qwen4_exp_qsa_with_output,
471 - mutates_args=["output"],
472 -)
473 -
474 -
430 __all__ = [
431 "QSAIndexer",
432 "Qwen4ExpQSAAttention",
433 "Qwen4ExpQSAFlashAttentionBackend",
434 "Qwen4ExpQSAFlashAttentionImpl",
480 - "qwen4_exp_qsa_with_output",
435 ]