[Qwen3.8-Flash-Next] Fuse Qwen4Exp PLE kernels (#54517)
Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg> Co-authored-by: Codex <codex@openai.com>
Thien Tran committed
Sep 2, 2026 at 12:32 UTC
f870b9297685bd3c968063b52b769315cb08fd7f
6 files changed
+1836
-594
tests/models/qwen4_exp/test_ple.py
+983
-65
@@ -1,7 +1,9 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from dataclasses import dataclass
5
from functools import partial
6
+from itertools import accumulate
7
from types import SimpleNamespace
8
9
import pytest
@@ -17,13 +19,16 @@ from vllm.models.qwen4_exp.common.ple import (
19
compute_ple_shard_overlap,
20
copy_ple_embedding_shard_,
21
)
20
-from vllm.models.qwen4_exp.nvidia import ple_layer as ple_layer_module
22
from vllm.models.qwen4_exp.nvidia.ple_layer import (
23
Qwen4ExpNGramEmbedding,
24
Qwen4ExpPLEFp8EmbeddingMethod,
25
Qwen4ExpPLELayer,
26
_get_ple_embedding_quant_method,
27
)
28
+from vllm.v1.attention.backends.short_conv_attn import (
29
+ PleShortConvAttentionMetadata,
30
+)
31
+from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
32
33
34
def _make_ngram_embedding_for_load_test() -> Qwen4ExpNGramEmbedding:
@@ -283,54 +288,9 @@ def test_ple_fp8_embedding_respects_checkpoint_shard_exclusions() -> None:
288
assert _get_ple_embedding_quant_method(quant_config, prefix) is None
289
290
286
-def test_ple_ngram_ids_custom_op_uses_current_request_layout(monkeypatch) -> None:
287
- class RuntimeNGramEmbedding(nn.Module):
288
- def compute_ngram_ids(
289
- self,
290
- input_ids: torch.Tensor,
291
- query_start_loc: torch.Tensor,
292
- ngram_context: torch.Tensor,
293
- ) -> torch.Tensor:
294
- del input_ids, ngram_context
295
- num_reqs = query_start_loc.numel() - 1
296
- return torch.full((4, 2), num_reqs, dtype=torch.long)
297
-
298
- layer = Qwen4ExpPLELayer.__new__(Qwen4ExpPLELayer)
299
- nn.Module.__init__(layer)
300
- layer.ple_embedding = RuntimeNGramEmbedding()
301
- monkeypatch.setattr(
302
- ple_layer_module,
303
- "get_forward_context",
304
- lambda: SimpleNamespace(no_compile_layers={"ple": layer}),
305
- )
306
- input_ids = torch.arange(4)
307
- ngram_context = torch.zeros(2, 2, dtype=torch.long)
308
- output = torch.empty(4, 2, dtype=torch.long)
309
-
310
- ple_layer_module.qwen4_exp_compute_ple_ngram_ids(
311
- input_ids,
312
- torch.tensor([0, 4]),
313
- ngram_context,
314
- output,
315
- "ple",
316
- )
317
- assert torch.equal(output, torch.ones_like(output))
318
-
319
- ple_layer_module.qwen4_exp_compute_ple_ngram_ids(
320
- input_ids,
321
- torch.tensor([0, 2, 4]),
322
- ngram_context,
323
- output,
324
- "ple",
325
- )
326
- assert torch.equal(output, torch.full_like(output, 2))
327
-
328
-
291
def test_dilated_ple_spec_state_rolls_back_before_next_forward() -> None:
330
- module = Qwen4ExpPLELayer.__new__(Qwen4ExpPLELayer)
331
- nn.Module.__init__(module)
332
- module.conv_state_len = 6
333
- module.short_conv_dilation = 2
292
+ conv_state_len = 6
293
+ dilation = 2
294
295
conv_weights = torch.tensor([[0.25, -0.5, 0.75, 1.0]])
296
conv_state = torch.zeros(2, 1, 9)
@@ -338,12 +298,12 @@ def test_dilated_ple_spec_state_rolls_back_before_next_forward() -> None:
298
first_inputs = torch.tensor([[10.0], [20.0], [30.0], [40.0]])
299
initial_state = conv_state[1:].clone()
300
first_history = torch.cat(
341
- (initial_state[..., : module.conv_state_len], first_inputs.T.unsqueeze(0)),
301
+ (initial_state[..., :conv_state_len], first_inputs.T.unsqueeze(0)),
302
dim=-1,
303
)
304
305
graph_padded_inputs = F.pad(first_inputs, (0, 0, 0, 4))
346
- first_output = module._short_conv_dilated_spec_batched(
306
+ first_output = _short_conv_dilated_spec_pytorch(
307
graph_padded_inputs,
308
conv_state,
309
conv_weights,
@@ -351,6 +311,8 @@ def test_dilated_ple_spec_state_rolls_back_before_next_forward() -> None:
311
torch.tensor([0, 4, 4]),
312
torch.tensor([1, 0]),
313
spec_query_len=4,
314
+ conv_state_len=conv_state_len,
315
+ dilation=dilation,
316
)
317
318
expected_first_output = F.silu(
@@ -358,7 +320,7 @@ def test_dilated_ple_spec_state_rolls_back_before_next_forward() -> None:
320
first_history,
321
conv_weights.unsqueeze(1),
322
groups=1,
361
- dilation=module.short_conv_dilation,
323
+ dilation=dilation,
324
)
325
).transpose(1, 2)[0]
326
expected_first_state = first_history[..., 1:10]
@@ -374,7 +336,7 @@ def test_dilated_ple_spec_state_rolls_back_before_next_forward() -> None:
336
expected_second_state = expected_first_state.clone()
337
expected_second_state[..., :7] = second_history[..., 1:8]
338
377
- second_output = module._short_conv_dilated_spec_batched(
339
+ second_output = _short_conv_dilated_spec_pytorch(
340
second_inputs,
341
conv_state,
342
conv_weights,
@@ -382,6 +344,8 @@ def test_dilated_ple_spec_state_rolls_back_before_next_forward() -> None:
344
torch.tensor([0, 2]),
345
torch.tensor([2]),
346
spec_query_len=4,
347
+ conv_state_len=conv_state_len,
348
+ dilation=dilation,
349
)
350
351
expected_second_output = F.silu(
@@ -389,28 +353,982 @@ def test_dilated_ple_spec_state_rolls_back_before_next_forward() -> None:
353
second_history,
354
conv_weights.unsqueeze(1),
355
groups=1,
392
- dilation=module.short_conv_dilation,
356
+ dilation=dilation,
357
)
358
).transpose(1, 2)[0, :2]
359
torch.testing.assert_close(second_output, expected_second_output)
360
torch.testing.assert_close(conv_state[1:], expected_second_state)
361
362
399
-def test_ple_short_conv_uses_fallback_when_profile_metadata_is_omitted(
400
- monkeypatch: pytest.MonkeyPatch,
363
+def _reference_ngram_ids(
364
+ input_ids: torch.Tensor,
365
+ query_start_loc: torch.Tensor,
366
+ ngram_context: torch.Tensor,
367
+ layer_multipliers: torch.Tensor,
368
+ ngram_heads_vocab_sizes: torch.Tensor,
369
+ ngram_heads_offsets: torch.Tensor,
370
+ eos_token_id: int,
371
+ heads_per_ngram: int,
372
+) -> torch.Tensor:
373
+ tokens = input_ids.tolist()
374
+ starts = query_start_loc.tolist()
375
+ contexts = ngram_context.tolist()
376
+ multipliers = layer_multipliers.cpu()
377
+ sizes = ngram_heads_vocab_sizes.cpu()
378
+ offsets = ngram_heads_offsets.cpu()
379
+ rows = []
380
+ context_len = len(contexts[0])
381
+ for req, (start, end) in enumerate(zip(starts, starts[1:])):
382
+ history = contexts[req] + tokens[start:end]
383
+ for pos in range(context_len, len(history)):
384
+ shifted = [history[pos]]
385
+ crossed_eos = False
386
+ for shift in range(1, context_len + 1):
387
+ token = eos_token_id if crossed_eos else history[pos - shift]
388
+ shifted.append(token)
389
+ crossed_eos |= token == eos_token_id
390
+
391
+ row = []
392
+ mixed = torch.tensor(shifted[0], dtype=torch.int64) * multipliers[0]
393
+ for ngram_order in range(2, context_len + 2):
394
+ shift = ngram_order - 1
395
+ mixed ^= shifted[shift] * multipliers[shift]
396
+ head_start = (ngram_order - 2) * heads_per_ngram
397
+ for head in range(head_start, head_start + heads_per_ngram):
398
+ row.append(torch.remainder(mixed, sizes[head]) + offsets[head])
399
+ rows.append(torch.stack(row))
400
+ return torch.stack(rows).to(input_ids.device)
401
+
402
+
403
+_NGRAM_MULTIPLIERS = (
404
+ 18_014_398_509_481_983,
405
+ 17_114_398_509_481_981,
406
+ 16_214_398_509_481_979,
407
+ 15_314_398_509_481_977,
408
+)
409
+_NGRAM_HEADS_VOCAB_SIZES = (
410
+ 101,
411
+ 103,
412
+ 107,
413
+ 109,
414
+ 113,
415
+ 127,
416
+ 131,
417
+ 137,
418
+ 139,
419
+ 149,
420
+ 151,
421
+ 157,
422
+ 163,
423
+ 167,
424
+ 173,
425
+ 179,
426
+ 181,
427
+ 191,
428
+ 193,
429
+ 197,
430
+ 199,
431
+ 211,
432
+ 223,
433
+ 227,
434
+)
435
+_NGRAM_EOS_TOKEN_ID = 251
436
+_NGRAM_HEADS_PER_NGRAM = 8
437
+
438
+
439
+def _ngram_hash_params(device: torch.device, context_len: int) -> dict:
440
+ num_heads = context_len * _NGRAM_HEADS_PER_NGRAM
441
+ sizes = torch.tensor(
442
+ _NGRAM_HEADS_VOCAB_SIZES[:num_heads], dtype=torch.int64, device=device
443
+ )
444
+ offsets = torch.zeros_like(sizes)
445
+ offsets[1:] = torch.cumsum(sizes, dim=0)[:-1]
446
+ return {
447
+ "layer_multipliers": torch.tensor(
448
+ _NGRAM_MULTIPLIERS[: context_len + 1],
449
+ dtype=torch.int64,
450
+ device=device,
451
+ ),
452
+ "ngram_heads_vocab_sizes": sizes,
453
+ "ngram_heads_offsets": offsets,
454
+ "eos_token_id": _NGRAM_EOS_TOKEN_ID,
455
+ "heads_per_ngram": _NGRAM_HEADS_PER_NGRAM,
456
+ }
457
+
458
+
459
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="fused PLE needs CUDA")
460
+@pytest.mark.parametrize(
461
+ ("query_lens", "eos_offsets", "contexts", "first_token_id"),
462
+ [
463
+ ([1], [], [[11, 12]], 20),
464
+ ([3], [1], [[11]], 20),
465
+ ([4, 4], [0, 7], [[11, 12], [13, 14]], 20),
466
+ ([4, 0, 3], [], [[11, 12], [13, 14], [15, 16]], 20),
467
+ (
468
+ [1, 33, 2],
469
+ [5, 32],
470
+ [[_NGRAM_EOS_TOKEN_ID, 11], [12, 13], [14, _NGRAM_EOS_TOKEN_ID]],
471
+ 20,
472
+ ),
473
+ (
474
+ [5, 12, 16, 1, 16, 17],
475
+ [10, 40],
476
+ [[11, 12], [13, 14], [15, 16], [17, 18], [19, 20], [21, 22]],
477
+ 20,
478
+ ),
479
+ ([5, 3], [3], [[11, 12, 13], [14, 15, 16]], 20),
480
+ (
481
+ [4, 0, 3],
482
+ [],
483
+ [
484
+ [200_000, 200_001],
485
+ [250_000, 250_001],
486
+ [300_000, 300_001],
487
+ ],
488
+ 350_000,
489
+ ),
490
+ ([3], [], [[1_000_000_000, 1_000_000_001]], 1_000_000_002),
491
+ ],
492
+ ids=[
493
+ "single-token",
494
+ "bigram-only",
495
+ "power-of-two",
496
+ "empty-request",
497
+ "three-requests",
498
+ "six-requests",
499
+ "four-gram",
500
+ "int64-overflow",
501
+ "large-int32-ids",
502
+ ],
503
+)
504
+def test_fused_ngram_ids_correctness(
505
+ query_lens: list[int],
506
+ eos_offsets: list[int],
507
+ contexts: list[list[int]],
508
+ first_token_id: int,
509
) -> None:
510
+ from vllm.models.qwen4_exp.nvidia.ops.ple import ple_ngram_ids
511
+
512
+ device = torch.device("cuda")
513
+ query_start_loc = torch.tensor(
514
+ [0, *accumulate(query_lens)], dtype=torch.int32, device=device
515
+ )
516
+ input_ids = torch.arange(
517
+ first_token_id,
518
+ first_token_id + sum(query_lens),
519
+ dtype=torch.int32,
520
+ device=device,
521
+ )
522
+ if eos_offsets:
523
+ input_ids[eos_offsets] = _NGRAM_EOS_TOKEN_ID
524
+ ngram_context = torch.tensor(contexts, dtype=torch.int32, device=device)
525
+ params = _ngram_hash_params(device, ngram_context.shape[1])
526
+
527
+ expected = _reference_ngram_ids(input_ids, query_start_loc, ngram_context, **params)
528
+ actual = ple_ngram_ids(input_ids, query_start_loc, ngram_context, **params)
529
+
530
+ assert torch.equal(actual, expected)
531
+ offsets = params["ngram_heads_offsets"]
532
+ sizes = params["ngram_heads_vocab_sizes"]
533
+ assert torch.all((actual >= offsets) & (actual < offsets + sizes))
534
+
535
+
536
+def _short_conv_dilated_decode_pytorch(
537
+ x_d: torch.Tensor,
538
+ conv_state: torch.Tensor,
539
+ conv_weights: torch.Tensor,
540
+ state_indices_tensor_d: torch.Tensor,
541
+ has_initial_states_d: torch.Tensor | None,
542
+ *,
543
+ conv_state_len: int,
544
+ dilation: int,
545
+) -> torch.Tensor:
546
+ state_indices = state_indices_tensor_d.to(
547
+ device=conv_state.device, dtype=torch.int64
548
+ )
549
+ # FULL cudagraph padded decode rows use NULL_BLOCK_ID. Remap them to
550
+ # slot 0 for a safe gather, then zero output and skip write-back.
551
+ valid_state = state_indices != NULL_BLOCK_ID
552
+ state_indices = torch.where(
553
+ valid_state, state_indices, torch.zeros_like(state_indices)
554
+ )
555
+ if has_initial_states_d is None:
556
+ has_initial_state = valid_state
557
+ else:
558
+ if has_initial_states_d.numel() < state_indices_tensor_d.numel():
559
+ raise ValueError(
560
+ "has_initial_states_d size mismatch: "
561
+ f"got {has_initial_states_d.numel()}, "
562
+ f"need >= {state_indices_tensor_d.numel()}."
563
+ )
564
+ has_initial_state = has_initial_states_d[: state_indices_tensor_d.numel()].to(
565
+ device=conv_state.device, dtype=torch.bool
566
+ )
567
+ has_initial_state = has_initial_state & valid_state
568
+
569
+ cached_state = conv_state.index_select(0, state_indices)
570
+ state = cached_state[..., :conv_state_len].to(x_d.dtype)
571
+ if conv_state_len > 0:
572
+ initial_state = torch.where(
573
+ has_initial_state.view(-1, 1, 1),
574
+ state,
575
+ torch.zeros_like(state),
576
+ )
577
+ history = torch.cat((initial_state, x_d.unsqueeze(-1)), dim=-1)
578
+ else:
579
+ history = x_d.unsqueeze(-1)
580
+
581
+ conv_output = F.conv1d(
582
+ history,
583
+ conv_weights.unsqueeze(1).contiguous(),
584
+ groups=history.size(1),
585
+ dilation=dilation,
586
+ ).squeeze(-1)
587
+ output = F.silu(conv_output)
588
+ output = output * valid_state.view(-1, 1).to(output.dtype)
589
+
590
+ if conv_state_len > 0:
591
+ next_state = history[..., -conv_state_len:]
592
+ # Padded rows are remapped to the reserved null slot. Preserve its
593
+ # existing value while writing the new states for valid rows.
594
+ existing_base_state = cached_state[..., :conv_state_len]
595
+ safe_next_state = torch.where(
596
+ valid_state.view(-1, 1, 1),
597
+ next_state.to(conv_state.dtype),
598
+ existing_base_state,
599
+ )
600
+ cached_state[..., :conv_state_len] = safe_next_state
601
+ conv_state.index_copy_(0, state_indices, cached_state)
602
+
603
+ return output
604
+
605
+
606
+def _short_conv_dilated_prefill_pytorch(
607
+ x_p: torch.Tensor,
608
+ metadata: PleShortConvAttentionMetadata,
609
+ conv_state: torch.Tensor,
610
+ conv_weights: torch.Tensor,
611
+ state_indices_tensor_p: torch.Tensor,
612
+ num_prefills: int,
613
+ num_decode_tokens: int,
614
+ num_prefill_tokens: int,
615
+ *,
616
+ conv_state_len: int,
617
+ dilation: int,
618
+) -> torch.Tensor:
619
+ # ``non_spec_query_start_loc`` covers the non-spec (decode + prefill)
620
+ # requests and equals ``query_start_loc`` when spec-decode is inactive.
621
+ non_spec_query_start_loc = metadata.non_spec_query_start_loc
622
+ if non_spec_query_start_loc is None:
623
+ raise ValueError("query_start_loc is required for prefill short-conv")
624
+ query_start_loc_p = (
625
+ non_spec_query_start_loc[-num_prefills - 1 :] - num_decode_tokens
626
+ )
627
+ # The metadata builder guarantees that the prefill query offsets start
628
+ # at 0 and end at num_prefill_tokens. Avoid reading those values here,
629
+ # since doing so would force a device-to-host synchronization.
630
+ has_initial_states_p = metadata.has_initial_states_p
631
+ if has_initial_states_p is None:
632
+ raise ValueError("has_initial_states_p is required for prefill short-conv")
633
+
634
+ output = torch.empty_like(x_p)
635
+ q_starts = query_start_loc_p.to(torch.int64)
636
+ if state_indices_tensor_p.numel() < num_prefills:
637
+ raise ValueError(
638
+ "state_indices_tensor_p size mismatch: "
639
+ f"got {state_indices_tensor_p.numel()}, "
640
+ f"need >= {num_prefills}."
641
+ )
642
+ if has_initial_states_p.numel() < num_prefills:
643
+ raise ValueError(
644
+ "has_initial_states_p size mismatch: "
645
+ f"got {has_initial_states_p.numel()}, "
646
+ f"need >= {num_prefills}."
647
+ )
648
+ if num_prefills == 0 or x_p.numel() == 0:
649
+ return output
650
+ lengths = q_starts[1:] - q_starts[:-1]
651
+ # Use the CPU-computed packing width from the metadata builder instead
652
+ # of synchronizing on lengths.max().
653
+ max_len = metadata.max_prefill_query_len
654
+ if max_len <= 0:
655
+ return output
656
+
657
+ hidden_size = x_p.shape[1]
658
+ positions = torch.arange(num_prefill_tokens, device=x_p.device, dtype=torch.int64)
659
+ req_indices = torch.searchsorted(q_starts[1:], positions, right=True)
660
+ col_indices = positions - q_starts[req_indices]
661
+
662
+ packed_tokens = x_p.new_zeros((num_prefills, max_len, hidden_size))
663
+ packed_tokens[req_indices, col_indices] = x_p
664
+ packed_tokens = packed_tokens.transpose(1, 2).contiguous()
665
+
666
+ state_indices = state_indices_tensor_p[:num_prefills].to(
667
+ device=conv_state.device, dtype=torch.int64
668
+ )
669
+ valid_state = state_indices != NULL_BLOCK_ID
670
+ state_indices = torch.where(
671
+ valid_state, state_indices, torch.zeros_like(state_indices)
672
+ )
673
+ has_initial = has_initial_states_p[:num_prefills].to(
674
+ device=conv_state.device, dtype=torch.bool
675
+ )
676
+ if conv_state_len > 0:
677
+ if conv_state.shape[0] == 0:
678
+ state = conv_state.new_zeros(
679
+ (num_prefills, hidden_size, conv_state_len),
680
+ dtype=x_p.dtype,
681
+ )
682
+ else:
683
+ state = conv_state.index_select(0, state_indices)[..., :conv_state_len].to(
684
+ x_p.dtype
685
+ )
686
+ use_initial_mask = (valid_state & has_initial).view(num_prefills, 1, 1)
687
+ initial_state = torch.where(
688
+ use_initial_mask,
689
+ state,
690
+ torch.zeros_like(state),
691
+ )
692
+ history = torch.cat((initial_state, packed_tokens), dim=-1)
693
+ else:
694
+ history = packed_tokens
695
+
696
+ conv_output = F.conv1d(
697
+ history,
698
+ conv_weights.unsqueeze(1).contiguous(),
699
+ groups=history.size(1),
700
+ dilation=dilation,
701
+ )
702
+ conv_output = F.silu(conv_output).transpose(1, 2).contiguous()
703
+
704
+ token_positions = torch.arange(max_len, device=x_p.device, dtype=torch.int64)
705
+ valid_tokens = token_positions.view(1, max_len) < lengths.view(num_prefills, 1)
706
+ valid_output_mask = valid_tokens & valid_state.to(device=x_p.device).view(
707
+ num_prefills, 1
708
+ )
709
+ conv_output.masked_fill_(~valid_output_mask.unsqueeze(-1), 0)
710
+ output.copy_(conv_output[req_indices, col_indices])
711
+
712
+ if conv_state_len > 0 and conv_state.shape[0] > 0:
713
+ state_starts = lengths.to(device=history.device, dtype=torch.int64).view(
714
+ num_prefills, 1, 1
715
+ )
716
+ state_offsets = torch.arange(
717
+ conv_state_len, device=history.device, dtype=torch.int64
718
+ ).view(1, 1, conv_state_len)
719
+ next_state = history.gather(
720
+ dim=2,
721
+ index=(state_starts + state_offsets).expand(-1, history.size(1), -1),
722
+ )
723
+ # Write back without a host synchronization. Valid, non-empty rows
724
+ # receive their new state; padding and zero-length rows keep the
725
+ # current cache value.
726
+ existing_state = conv_state.index_select(0, state_indices)
727
+ existing_base_state = existing_state[..., :conv_state_len]
728
+ update_mask = valid_state & (lengths.to(device=conv_state.device) > 0)
729
+ safe_next_state = torch.where(
730
+ update_mask.view(num_prefills, 1, 1),
731
+ next_state.to(conv_state.dtype),
732
+ existing_base_state,
733
+ )
734
+ existing_state[..., :conv_state_len] = safe_next_state
735
+ conv_state.index_copy_(0, state_indices, existing_state)
736
+ return output
737
+
738
+
739
+def _short_conv_dilated_spec_pytorch(
740
+ x_spec: torch.Tensor,
741
+ conv_state: torch.Tensor,
742
+ conv_weights: torch.Tensor,
743
+ spec_state_indices_tensor: torch.Tensor,
744
+ spec_query_start_loc: torch.Tensor,
745
+ num_accepted_tokens: torch.Tensor,
746
+ spec_query_len: int,
747
+ *,
748
+ conv_state_len: int,
749
+ dilation: int,
750
+) -> torch.Tensor:
751
+ """Dilated short-conv for speculative-decode (MTP) requests.
752
+
753
+ Each spec request feeds multiple (draft + 1) query tokens. The conv
754
+ outputs are computed causally after rolling back the previous draft
755
+ state by ``num_accepted_tokens - 1``. The current candidate inputs stay
756
+ in the extended cache for the next forward, matching
757
+ ``causal_conv1d_update``.
758
+
759
+ ``spec_query_len`` (== num_speculative_tokens + 1) is the maximum query
760
+ length and is a Python int, so no host synchronization is needed; this
761
+ keeps the path safe for full CUDA-graph capture/replay where the buffers
762
+ are padded at the request level.
763
+ """
764
+ num_reqs = spec_state_indices_tensor.numel()
765
+ hidden_size = x_spec.size(-1)
766
+ # Use a fixed packing width instead of synchronizing on lengths.max().
767
+ max_len = spec_query_len
768
+ # Full CUDA graphs can pad these buffers. Only the first num_reqs
769
+ # accepted-token counts belong to actual speculative requests.
770
+ num_accepted_tokens = num_accepted_tokens[:num_reqs]
771
+ q_starts = spec_query_start_loc[: num_reqs + 1].to(torch.int64)
772
+ # Keep the number of real speculative tokens on the device.
773
+ total_real_tokens = q_starts[num_reqs]
774
+
775
+ state_indices = spec_state_indices_tensor.to(
776
+ device=conv_state.device, dtype=torch.int64
777
+ )
778
+ valid_state = state_indices != NULL_BLOCK_ID
779
+ state_indices = torch.where(
780
+ valid_state, state_indices, torch.zeros_like(state_indices)
781
+ )
782
+ positions = torch.arange(x_spec.size(0), device=x_spec.device, dtype=torch.int64)
783
+ # Route graph-padded token rows to the discarded dummy request so that
784
+ # they cannot overwrite real packed data.
785
+ req_indices = torch.searchsorted(q_starts[1:], positions, right=True)
786
+ valid_tokens = (positions < total_real_tokens) & (req_indices < num_reqs)
787
+ clamped_req_indices = req_indices.clamp_max(max(num_reqs - 1, 0))
788
+ col_indices = (positions - q_starts[clamped_req_indices]).clamp_(0, max_len - 1)
789
+ pack_req_indices = torch.where(
790
+ valid_tokens,
791
+ clamped_req_indices,
792
+ torch.full_like(req_indices, num_reqs),
793
+ )
794
+ pack_col_indices = torch.where(
795
+ valid_tokens, col_indices, torch.zeros_like(col_indices)
796
+ )
797
+
798
+ # The last request row is the dummy sink for graph padding.
799
+ packed = x_spec.new_zeros((num_reqs + 1, max_len, hidden_size))
800
+ packed[pack_req_indices, pack_col_indices] = x_spec
801
+ packed = packed.transpose(1, 2).contiguous()
802
+
803
+ if conv_state_len > 0:
804
+ cached_state = conv_state.index_select(0, state_indices)
805
+ rollback_offsets = num_accepted_tokens.to(
806
+ device=conv_state.device, dtype=torch.int64
807
+ ).sub(1)
808
+ rollback_offsets = torch.where(
809
+ valid_state,
810
+ rollback_offsets.clamp_(0, max_len - 1),
811
+ torch.zeros_like(rollback_offsets),
812
+ )
813
+ state_offsets = torch.arange(
814
+ conv_state_len, device=conv_state.device, dtype=torch.int64
815
+ ).view(1, 1, conv_state_len)
816
+ rollback_indices = rollback_offsets.view(-1, 1, 1) + state_offsets
817
+ state = cached_state.gather(2, rollback_indices.expand(-1, hidden_size, -1)).to(
818
+ x_spec.dtype
819
+ )
820
+ state = torch.where(
821
+ valid_state.view(num_reqs, 1, 1),
822
+ state,
823
+ torch.zeros_like(state),
824
+ )
825
+ # Append a zeroed dummy-row state to match the [num_reqs + 1] pack.
826
+ dummy_state = state.new_zeros((1, hidden_size, conv_state_len))
827
+ state_full = torch.cat((state, dummy_state), dim=0)
828
+ history = torch.cat((state_full, packed), dim=-1)
829
+ else:
830
+ history = packed
831
+
832
+ conv_output = F.conv1d(
833
+ history,
834
+ conv_weights.unsqueeze(1).contiguous(),
835
+ groups=history.size(1),
836
+ dilation=dilation,
837
+ )
838
+ conv_output = F.silu(conv_output).transpose(1, 2).contiguous()
839
+
840
+ output = conv_output[pack_req_indices, pack_col_indices]
841
+ output = output * valid_tokens.view(-1, 1).to(output.dtype)
842
+
843
+ # Keep all current candidate inputs in the extended state. On the next
844
+ # target forward, ``num_accepted_tokens - 1`` selects the rollback
845
+ # window before processing the newly scheduled tokens.
846
+ if conv_state_len > 0:
847
+ state_capacity = conv_state_len + max_len - 1
848
+ if conv_state.size(-1) < state_capacity:
849
+ raise RuntimeError(
850
+ "PLE short-conv cache cannot retain speculative tokens: "
851
+ f"got {conv_state.size(-1)}, need {state_capacity}."
852
+ )
853
+ candidate_state = history[:num_reqs, :, 1 : state_capacity + 1]
854
+ query_lengths = q_starts[1:] - q_starts[:-1]
855
+ state_positions = torch.arange(
856
+ state_capacity, device=history.device, dtype=torch.int64
857
+ ).view(1, 1, state_capacity)
858
+ update_lengths = (conv_state_len + query_lengths - 1).view(num_reqs, 1, 1)
859
+ update_mask = valid_state.view(num_reqs, 1, 1) & (
860
+ state_positions < update_lengths
861
+ )
862
+ existing_state = cached_state[..., :state_capacity]
863
+ next_state = torch.where(
864
+ update_mask,
865
+ candidate_state.to(conv_state.dtype),
866
+ existing_state,
867
+ )
868
+ cached_state[..., :state_capacity] = next_state
869
+ conv_state.index_copy_(0, state_indices, cached_state)
870
+
871
+ return output
872
+
873
+
874
+def _short_conv_dilated_dispatch_pytorch(
875
+ inputs: torch.Tensor,
876
+ residual: torch.Tensor,
877
+ metadata: PleShortConvAttentionMetadata,
878
+ conv_state: torch.Tensor,
879
+ conv_weights: torch.Tensor,
880
+ *,
881
+ conv_state_len: int,
882
+ dilation: int,
883
+) -> None:
884
+ """PyTorch reference for fused short-convolution dispatch."""
885
+ num_prefills = metadata.num_prefills
886
+ num_decodes = metadata.num_decodes
887
+ num_decode_tokens = metadata.num_decode_tokens
888
+ num_prefill_tokens = metadata.num_prefill_tokens
889
+ has_prefill = num_prefills > 0
890
+ has_decode = num_decodes > 0
891
+ has_spec = metadata.spec_sequence_masks is not None
892
+ x = inputs[: metadata.num_actual_tokens]
893
+ residual = residual[: metadata.num_actual_tokens]
894
+
895
+ if has_spec:
896
+ if has_prefill or has_decode:
897
+ assert metadata.spec_token_indx is not None
898
+ assert metadata.non_spec_token_indx is not None
899
+ spec_token_indices = metadata.spec_token_indx.long()
900
+ non_spec_token_indices = metadata.non_spec_token_indx.long()
901
+ x_spec = x.index_select(0, spec_token_indices)
902
+ x_non_spec = x.index_select(0, non_spec_token_indices)
903
+ residual_spec = residual.index_select(0, spec_token_indices)
904
+ residual_non_spec = residual.index_select(0, non_spec_token_indices)
905
+ else:
906
+ spec_token_indices = None
907
+ non_spec_token_indices = None
908
+ x_spec = x
909
+ x_non_spec = None
910
+ residual_spec = residual
911
+ residual_non_spec = None
912
+ else:
913
+ spec_token_indices = None
914
+ non_spec_token_indices = None
915
+ x_spec = None
916
+ x_non_spec = x
917
+ residual_spec = None
918
+ residual_non_spec = residual
919
+
920
+ if has_spec:
921
+ assert metadata.spec_state_indices_tensor is not None
922
+ assert metadata.spec_query_start_loc is not None
923
+ assert metadata.num_accepted_tokens is not None
924
+ assert x_spec is not None
925
+ assert residual_spec is not None
926
+ spec_state_indices = metadata.spec_state_indices_tensor[
927
+ : metadata.num_spec_decodes
928
+ ]
929
+ conv_output = _short_conv_dilated_spec_pytorch(
930
+ x_spec=x_spec,
931
+ conv_state=conv_state,
932
+ conv_weights=conv_weights,
933
+ spec_state_indices_tensor=spec_state_indices,
934
+ spec_query_start_loc=metadata.spec_query_start_loc,
935
+ num_accepted_tokens=metadata.num_accepted_tokens,
936
+ spec_query_len=metadata.spec_query_len,
937
+ conv_state_len=conv_state_len,
938
+ dilation=dilation,
939
+ )
940
+ residual_spec.add_(conv_output)
941
+
942
+ state_indices = metadata.state_indices_tensor
943
+ if x_non_spec is not None:
944
+ assert state_indices is not None
945
+ assert residual_non_spec is not None
946
+ if has_prefill:
947
+ state_indices_d, state_indices_p = torch.split(
948
+ state_indices, [num_decodes, num_prefills], dim=0
949
+ )
950
+ x_d, x_p = torch.split(
951
+ x_non_spec, [num_decode_tokens, num_prefill_tokens], dim=0
952
+ )
953
+ residual_d, residual_p = torch.split(
954
+ residual_non_spec,
955
+ [num_decode_tokens, num_prefill_tokens],
956
+ dim=0,
957
+ )
958
+ if has_decode:
959
+ conv_output = _short_conv_dilated_decode_pytorch(
960
+ x_d=x_d,
961
+ conv_state=conv_state,
962
+ conv_weights=conv_weights,
963
+ state_indices_tensor_d=state_indices_d,
964
+ has_initial_states_d=metadata.has_initial_states_d,
965
+ conv_state_len=conv_state_len,
966
+ dilation=dilation,
967
+ )
968
+ residual_d.add_(conv_output)
969
+ conv_output = _short_conv_dilated_prefill_pytorch(
970
+ x_p=x_p,
971
+ metadata=metadata,
972
+ conv_state=conv_state,
973
+ conv_weights=conv_weights,
974
+ state_indices_tensor_p=state_indices_p,
975
+ num_prefills=num_prefills,
976
+ num_decode_tokens=num_decode_tokens,
977
+ num_prefill_tokens=num_prefill_tokens,
978
+ conv_state_len=conv_state_len,
979
+ dilation=dilation,
980
+ )
981
+ residual_p.add_(conv_output)
982
+ else:
983
+ conv_output = _short_conv_dilated_decode_pytorch(
984
+ x_d=x_non_spec,
985
+ conv_state=conv_state,
986
+ conv_weights=conv_weights,
987
+ state_indices_tensor_d=state_indices[: x_non_spec.size(0)],
988
+ has_initial_states_d=metadata.has_initial_states_d,
989
+ conv_state_len=conv_state_len,
990
+ dilation=dilation,
991
+ )
992
+ residual_non_spec.add_(conv_output)
993
+
994
+ if has_spec and residual_non_spec is not None:
995
+ assert spec_token_indices is not None
996
+ assert non_spec_token_indices is not None
997
+ assert residual_spec is not None
998
+ residual.index_copy_(0, spec_token_indices, residual_spec)
999
+ residual.index_copy_(0, non_spec_token_indices, residual_non_spec)
1000
+
1001
+
1002
+_KERNEL_STATE_BLOCKS = 64
1003
+
1004
+
1005
+def _make_conv_case(
1006
+ device: torch.device,
1007
+ seed: int,
1008
+ channels: int,
1009
+ kernel_size: int,
1010
+ dilation: int,
1011
+ state_layout: str,
1012
+ spec_query_len: int = 1,
1013
+) -> tuple[torch.Generator, torch.Tensor, torch.Tensor, torch.Tensor]:
1014
+ rng = torch.Generator(device=device).manual_seed(seed)
1015
+ state_len = (kernel_size - 1) * dilation
1016
+ state_width = state_len + spec_query_len - 1
1017
+ state_shape = (
1018
+ (_KERNEL_STATE_BLOCKS, state_width, channels)
1019
+ if state_layout == "SD"
1020
+ else (_KERNEL_STATE_BLOCKS, channels, state_width)
1021
+ )
1022
+ state_kernel = torch.randn(
1023
+ state_shape, device=device, dtype=torch.bfloat16, generator=rng
1024
+ )
1025
+ conv_state = (
1026
+ state_kernel.transpose(-1, -2) if state_layout == "SD" else state_kernel
1027
+ )
1028
+ state_reference = conv_state.clone()
1029
+ weights = torch.randn(
1030
+ channels, kernel_size, device=device, dtype=torch.bfloat16, generator=rng
1031
+ )
1032
+ return rng, state_reference, conv_state, weights
1033
+
1034
+
1035
+@dataclass(frozen=True)
1036
+class _ConvBatchCase:
1037
+ spec_query_lens: tuple[int, ...] = ()
1038
+ num_accepted: tuple[int, ...] = ()
1039
+ num_decodes: int = 0
1040
+ prefill_query_lens: tuple[int, ...] = ()
1041
+ channels: int = 2048
1042
+ kernel_size: int = 4
1043
+ dilation: int = 3
1044
+ spec_query_len: int = 1
1045
+ graph_padding: int = 0
1046
+
1047
+
1048
+def _make_conv_metadata(
1049
+ case: _ConvBatchCase,
1050
+ device: torch.device,
1051
+) -> tuple[SimpleNamespace, int]:
1052
+ request_groups: dict[str, list[int]] = {
1053
+ "spec": [],
1054
+ "decode": [],
1055
+ "prefill": [],
1056
+ }
1057
+ token_offset = 0
1058
+ max_num_reqs = max(
1059
+ len(case.spec_query_lens),
1060
+ case.num_decodes,
1061
+ len(case.prefill_query_lens),
1062
+ )
1063
+ request_kinds = []
1064
+ for req_idx in range(max_num_reqs):
1065
+ if req_idx < len(case.spec_query_lens):
1066
+ request_kinds.append(("spec", case.spec_query_lens[req_idx]))
1067
+ if req_idx < case.num_decodes:
1068
+ request_kinds.append(("decode", 1))
1069
+ if req_idx < len(case.prefill_query_lens):
1070
+ request_kinds.append(("prefill", case.prefill_query_lens[req_idx]))
1071
+ for kind, query_len in request_kinds:
1072
+ request_groups[kind].extend(range(token_offset, token_offset + query_len))
1073
+ token_offset += query_len
1074
+
1075
+ num_spec_reqs = len(case.spec_query_lens)
1076
+ num_prefills = len(case.prefill_query_lens)
1077
+ has_spec = num_spec_reqs > 0
1078
+ has_non_spec = case.num_decodes > 0 or num_prefills > 0
1079
+ mixed_batch = has_spec and has_non_spec
1080
+
1081
+ spec_state_indices = torch.arange(
1082
+ 1, num_spec_reqs + 1, dtype=torch.int32, device=device
1083
+ )
1084
+ if num_spec_reqs >= 3:
1085
+ spec_state_indices[1] = NULL_BLOCK_ID
1086
+ non_spec_state_indices = torch.arange(
1087
+ num_spec_reqs + 1,
1088
+ num_spec_reqs + case.num_decodes + num_prefills + 1,
1089
+ dtype=torch.int32,
1090
+ device=device,
1091
+ )
1092
+ if case.num_decodes > 1:
1093
+ non_spec_state_indices[case.num_decodes - 1] = NULL_BLOCK_ID
1094
+ if num_prefills > 1:
1095
+ non_spec_state_indices[case.num_decodes + 1] = NULL_BLOCK_ID
1096
+
1097
+ spec_query_start_loc = torch.tensor(
1098
+ [0, *accumulate(case.spec_query_lens)], dtype=torch.int32, device=device
1099
+ )
1100
+ non_spec_query_lens = (1,) * case.num_decodes + case.prefill_query_lens
1101
+ non_spec_query_start_loc = torch.tensor(
1102
+ [0, *accumulate(non_spec_query_lens)], dtype=torch.int32, device=device
1103
+ )
1104
+ spec_token_indices = torch.tensor(
1105
+ request_groups["spec"], dtype=torch.int64, device=device
1106
+ )
1107
+ non_spec_token_indices = torch.tensor(
1108
+ request_groups["decode"] + request_groups["prefill"],
1109
+ dtype=torch.int64,
1110
+ device=device,
1111
+ )
1112
+
1113
+ metadata = SimpleNamespace(
1114
+ num_prefills=num_prefills,
1115
+ num_decodes=case.num_decodes,
1116
+ num_decode_tokens=case.num_decodes,
1117
+ num_prefill_tokens=sum(case.prefill_query_lens),
1118
+ spec_sequence_masks=(
1119
+ torch.tensor([kind == "spec" for kind, _ in request_kinds], device=device)
1120
+ if has_spec
1121
+ else None
1122
+ ),
1123
+ spec_token_indx=spec_token_indices if mixed_batch else None,
1124
+ non_spec_token_indx=non_spec_token_indices if mixed_batch else None,
1125
+ num_actual_tokens=token_offset + case.graph_padding,
1126
+ spec_state_indices_tensor=spec_state_indices if has_spec else None,
1127
+ spec_query_start_loc=spec_query_start_loc if has_spec else None,
1128
+ num_accepted_tokens=(
1129
+ torch.tensor(case.num_accepted, dtype=torch.int32, device=device)
1130
+ if has_spec
1131
+ else None
1132
+ ),
1133
+ spec_query_len=case.spec_query_len,
1134
+ state_indices_tensor=non_spec_state_indices if has_non_spec else None,
1135
+ has_initial_states_d=(
1136
+ torch.arange(case.num_decodes, device=device) % 2 == 0
1137
+ if case.num_decodes
1138
+ else None
1139
+ ),
1140
+ non_spec_query_start_loc=(non_spec_query_start_loc if has_non_spec else None),
1141
+ has_initial_states_p=(
1142
+ torch.arange(num_prefills, device=device) % 2 == 0 if num_prefills else None
1143
+ ),
1144
+ max_prefill_query_len=max(case.prefill_query_lens, default=0),
1145
+ num_spec_decodes=num_spec_reqs,
1146
+ )
1147
+ return metadata, token_offset
1148
+
1149
+
1150
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="fused conv needs CUDA")
1151
+@pytest.mark.parametrize("state_layout", ["SD", "DS"])
1152
+@pytest.mark.parametrize(
1153
+ "case",
1154
+ [
1155
+ pytest.param(
1156
+ _ConvBatchCase(num_decodes=1, channels=512),
1157
+ id="decode-single",
1158
+ ),
1159
+ pytest.param(
1160
+ _ConvBatchCase(
1161
+ num_decodes=33,
1162
+ channels=2048,
1163
+ kernel_size=5,
1164
+ dilation=1,
1165
+ ),
1166
+ id="decode-padded",
1167
+ ),
1168
+ pytest.param(
1169
+ _ConvBatchCase(prefill_query_lens=(37, 0, 5, 128)),
1170
+ id="prefill",
1171
+ ),
1172
+ pytest.param(
1173
+ _ConvBatchCase(
1174
+ spec_query_lens=(1, 4, 4, 0),
1175
+ num_accepted=(1, 2, 4, 1),
1176
+ spec_query_len=4,
1177
+ graph_padding=4,
1178
+ ),
1179
+ id="spec-graph-padded",
1180
+ ),
1181
+ pytest.param(
1182
+ _ConvBatchCase(
1183
+ spec_query_lens=(3, 4),
1184
+ num_accepted=(2, 4),
1185
+ num_decodes=1,
1186
+ prefill_query_lens=(5,),
1187
+ spec_query_len=4,
1188
+ ),
1189
+ id="mixed",
1190
+ ),
1191
+ pytest.param(
1192
+ _ConvBatchCase(
1193
+ spec_query_lens=(1, 2),
1194
+ num_accepted=(0, 1),
1195
+ num_decodes=2,
1196
+ prefill_query_lens=(0, 8, 3),
1197
+ channels=640,
1198
+ kernel_size=3,
1199
+ dilation=2,
1200
+ spec_query_len=3,
1201
+ ),
1202
+ id="mixed-varied",
1203
+ ),
1204
+ ],
1205
+)
1206
+def test_fused_conv_correctness(
1207
+ case: _ConvBatchCase,
1208
+ state_layout: str,
1209
+) -> None:
1210
+ device = torch.device("cuda")
1211
+ metadata, num_real_tokens = _make_conv_metadata(case, device)
1212
module = Qwen4ExpPLELayer.__new__(Qwen4ExpPLELayer)
1213
nn.Module.__init__(module)
404
- module.prefix = "model.layers.1.ple"
405
- inputs = torch.arange(6, dtype=torch.float32).reshape(3, 2)
406
- expected = inputs + 1
407
- monkeypatch.setattr(module, "_short_conv_fallback", lambda _: expected)
408
- monkeypatch.setattr(
409
- ple_layer_module,
410
- "get_forward_context",
411
- lambda: SimpleNamespace(attn_metadata={"model.layers.0.self_attn": object()}),
1214
+ module.conv_state_len = (case.kernel_size - 1) * case.dilation
1215
+ module.short_conv_dilation = case.dilation
1216
+
1217
+ rng, state_reference, conv_state, weights = _make_conv_case(
1218
+ device,
1219
+ seed=num_real_tokens + case.channels,
1220
+ channels=case.channels,
1221
+ kernel_size=case.kernel_size,
1222
+ dilation=case.dilation,
1223
+ state_layout=state_layout,
1224
+ spec_query_len=case.spec_query_len,
1225
)
1226
+ inputs = torch.randn(
1227
+ metadata.num_actual_tokens,
1228
+ case.channels,
1229
+ device=device,
1230
+ dtype=torch.bfloat16,
1231
+ generator=rng,
1232
+ )
1233
+ residual = torch.randn(
1234
+ inputs.shape,
1235
+ device=device,
1236
+ dtype=torch.bfloat16,
1237
+ generator=rng,
1238
+ )
1239
+ null_state = conv_state[NULL_BLOCK_ID].clone()
1240
+ residual_kernel = residual.clone()
1241
+ residual_reference = residual.clone()
1242
+
1243
+ module._short_conv_dilated_dispatch(
1244
+ inputs=inputs,
1245
+ residual=residual_kernel,
1246
+ metadata=metadata,
1247
+ conv_state=conv_state,
1248
+ conv_weights=weights,
1249
+ )
1250
+ _short_conv_dilated_dispatch_pytorch(
1251
+ inputs=inputs,
1252
+ residual=residual_reference,
1253
+ metadata=metadata,
1254
+ conv_state=state_reference,
1255
+ conv_weights=weights,
1256
+ conv_state_len=module.conv_state_len,
1257
+ dilation=module.short_conv_dilation,
1258
+ )
1259
+
1260
+ torch.testing.assert_close(
1261
+ residual_kernel.float(), residual_reference.float(), atol=3e-2, rtol=3e-2
1262
+ )
1263
+ assert torch.equal(conv_state, state_reference)
1264
+ assert torch.equal(conv_state[NULL_BLOCK_ID], null_state)
1265
+ if case.graph_padding:
1266
+ assert torch.equal(
1267
+ residual_kernel[num_real_tokens:], residual[num_real_tokens:]
1268
+ )
1269
+
1270
414
- output = module._short_conv(inputs)
1271
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="fused gate needs CUDA")
1272
+@pytest.mark.parametrize(("num_tokens", "strided_kv"), [(1, False), (64, True)])
1273
+def test_fused_gate_correctness(num_tokens: int, strided_kv: bool) -> None:
1274
+ import math
1275
416
- assert output is expected
1276
+ from vllm.models.qwen4_exp.nvidia.ops.ple import ple_gate
1277
+
1278
+ device = torch.device("cuda")
1279
+ hc, h = 4, 2560
1280
+ generator = torch.Generator(device=device).manual_seed(num_tokens)
1281
+ kv = torch.randn(
1282
+ num_tokens,
1283
+ hc * h + h,
1284
+ device=device,
1285
+ dtype=torch.bfloat16,
1286
+ generator=generator,
1287
+ )
1288
+ key = kv[:, : hc * h]
1289
+ value = kv[:, hc * h :]
1290
+ if not strided_kv:
1291
+ key = key.contiguous()
1292
+ value = value.contiguous()
1293
+ hidden = torch.randn(
1294
+ num_tokens,
1295
+ hc * h,
1296
+ device=device,
1297
+ dtype=torch.bfloat16,
1298
+ generator=generator,
1299
+ )
1300
+
1301
+ def make_norm_weight() -> torch.Tensor:
1302
+ weight = torch.empty(hc * h, device=device, dtype=torch.bfloat16)
1303
+ return weight.normal_(mean=-0.1, std=0.1, generator=generator)
1304
+
1305
+ norm_key = make_norm_weight()
1306
+ norm_query = make_norm_weight()
1307
+ norm_conv = make_norm_weight()
1308
+
1309
+ def grouped_norm(inputs: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
1310
+ grouped = inputs.float().unflatten(-1, (hc, h))
1311
+ var = grouped.square().mean(dim=-1, keepdim=True)
1312
+ normalized = grouped * torch.rsqrt(var + 1e-6)
1313
+ return (normalized.flatten(-2) * (1.0 + weight.float())).to(inputs.dtype)
1314
+
1315
+ gated, normed = ple_gate(
1316
+ key,
1317
+ value,
1318
+ hidden,
1319
+ norm_key,
1320
+ norm_query,
1321
+ norm_conv,
1322
+ 1e-6,
1323
+ )
1324
+ key_normalized = grouped_norm(key, norm_key).reshape(num_tokens, hc, h)
1325
+ query_normalized = grouped_norm(hidden, norm_query).reshape(num_tokens, hc, h)
1326
+ dot = (key_normalized * query_normalized).sum(dim=-1, keepdim=True)
1327
+ dot = (dot / math.sqrt(h)).to(torch.bfloat16)
1328
+ gate = torch.sigmoid(dot.sign() * dot.abs().clamp_min(1e-6).sqrt()).to(
1329
+ torch.bfloat16
1330
+ )
1331
+ expected_gated = (gate * value.unsqueeze(-2)).flatten(-2)
1332
+ expected_normed = grouped_norm(expected_gated, norm_conv)
1333
+ assert torch.equal(gated, expected_gated)
1334
+ torch.testing.assert_close(normed, expected_normed, atol=1e-2, rtol=1e-2)
vllm/models/qwen4_exp/nvidia/model.py
+9
-5
@@ -141,9 +141,9 @@ _QWEN4_EXP_IGNORED_MISSING_SUFFIXES = [
141
"_input_scale",
142
]
143
144
-# The checkpoint keeps down and injection projections separate; runtime packs
145
-# them into adjacent logical shards of one MergedColumnParallelLinear.
146
-_HC_WEIGHTS_MAPPER = WeightsMapper(
144
+# The checkpoint stores these projections separately; runtime packs each group
145
+# into adjacent logical shards of a MergedColumnParallelLinear.
146
+_EXTRA_WEIGHTS_MAPPER = WeightsMapper(
147
orig_to_new_stacked={
148
"hyper_connection.input_mix_weight_down.weight": (
149
"hyper_connection.input_mix_weight_down_block_inject.weight",
@@ -153,6 +153,8 @@ _HC_WEIGHTS_MAPPER = WeightsMapper(
153
"hyper_connection.input_mix_weight_down_block_inject.weight",
154
1,
155
),
156
+ "ple.key_proj": ("ple.kv_proj", 0),
157
+ "ple.value_proj": ("ple.kv_proj", 1),
158
}
159
)
160
@@ -389,7 +391,7 @@ class Qwen4ExpMixtureOfExperts(MixtureOfExperts):
391
}
392
)
393
class Qwen4ExpModel(nn.Module):
392
- hf_to_vllm_mapper = Qwen3_5Model.hf_to_vllm_mapper | _HC_WEIGHTS_MAPPER
394
+ hf_to_vllm_mapper = Qwen3_5Model.hf_to_vllm_mapper | _EXTRA_WEIGHTS_MAPPER
395
396
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
397
super().__init__()
@@ -612,6 +614,7 @@ class Qwen4ExpForCausalLM(
614
packed_modules_mapping = {
615
"qkv_proj": ["q_proj", "k_proj", "v_proj"],
616
"gate_up_proj": ["gate_proj", "up_proj"],
617
+ "kv_proj": ["key_proj", "value_proj"],
618
"in_proj_qkvz": ["in_proj_qkv", "in_proj_z"],
619
"in_proj_ba": ["in_proj_b", "in_proj_a"],
620
"input_mix_weight_down_block_inject": [
@@ -848,11 +851,12 @@ class Qwen4ExpForConditionalGeneration(
851
requires_raw_input_tokens = True
852
853
packed_modules_mapping = Qwen3_5ForConditionalGeneration.packed_modules_mapping | {
854
+ "kv_proj": ["key_proj", "value_proj"],
855
"input_mix_weight_down_block_inject": [
856
"input_mix_weight_down",
857
"block_inject_weight",
858
"_input_mix_padding",
855
- ]
859
+ ],
860
}
861
862
@staticmethod
vllm/models/qwen4_exp/nvidia/mtp.py
+2
-2
@@ -52,7 +52,7 @@ from vllm.transformers_utils.configs.qwen4_exp import (
52
from .hyperconnection import GatedResidual, HyperConnectionConfig
53
from .low_latency_gemm import enable_qwen4_exp_low_latency_gemm
54
from .model import (
55
- _HC_WEIGHTS_MAPPER,
55
+ _EXTRA_WEIGHTS_MAPPER,
56
_QWEN4_EXP_IGNORED_MISSING_SUFFIXES,
57
Qwen4ExpDecoderLayer,
58
Qwen4ExpMixtureOfExperts,
@@ -157,7 +157,7 @@ def _make_draft_vllm_config(
157
}
158
)
159
class Qwen4ExpMultiTokenPredictor(nn.Module):
160
- hf_to_vllm_mapper = Qwen3_5Model.hf_to_vllm_mapper | _HC_WEIGHTS_MAPPER
160
+ hf_to_vllm_mapper = Qwen3_5Model.hf_to_vllm_mapper | _EXTRA_WEIGHTS_MAPPER
161
162
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
163
super().__init__()
vllm/models/qwen4_exp/nvidia/ops/ple.py
new
+669
@@ -0,0 +1,669 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Fused Qwen4Exp PLE kernels.
4
+
5
+N-gram IDs hash each suffix with
6
+``offset[h] + (xor_i(token[t-i] * multiplier[i]) % size[h])``. The gate computes
7
+``d = dot(RMSNorm(key), RMSNorm(query)) / sqrt(H)`` and
8
+``g = sigmoid(sign(d) * sqrt(max(abs(d), 1e-6)))``. Short convolution adds
9
+``silu(sum_k weight[k] * history[t + k * dilation])`` to the gated output and
10
+updates its persistent history.
11
+"""
12
+
13
+from typing import Literal
14
+
15
+import torch
16
+
17
+from vllm.platforms import current_platform
18
+from vllm.triton_utils import tl, triton
19
+from vllm.utils.torch_utils import direct_register_custom_op
20
+from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
21
+
22
+
23
+# N-gram ID generation
24
+@triton.jit(do_not_specialize=["num_tokens", "num_reqs", "binary_search_iters"])
25
+def _ple_ngram_ids_kernel(
26
+ input_ids_ptr,
27
+ qsl_ptr,
28
+ ctx_ptr,
29
+ multipliers_ptr,
30
+ sizes_ptr,
31
+ offsets_ptr,
32
+ out_ptr,
33
+ num_tokens,
34
+ num_reqs,
35
+ eos_token_id,
36
+ binary_search_iters,
37
+ NGRAM_CONTEXT_LEN: tl.constexpr,
38
+ HEADS_PER_NGRAM: tl.constexpr,
39
+ BLOCK_T: tl.constexpr,
40
+ launch_pdl: tl.constexpr,
41
+):
42
+ NGRAM_HEADS: tl.constexpr = NGRAM_CONTEXT_LEN * HEADS_PER_NGRAM
43
+ BLOCK_H: tl.constexpr = triton.next_power_of_2(NGRAM_HEADS)
44
+ pid = tl.program_id(0)
45
+ token_offsets = pid * BLOCK_T + tl.arange(0, BLOCK_T)
46
+ token_mask = token_offsets < num_tokens
47
+
48
+ if launch_pdl:
49
+ tl.extra.cuda.gdc_wait()
50
+
51
+ # A flattened token offset does not identify its request when query chunks
52
+ # have different lengths. Binary-search the request boundaries instead.
53
+ p_lo = tl.full([BLOCK_T], 1, tl.int32)
54
+ p_hi = tl.full([BLOCK_T], num_reqs + 1, tl.int32)
55
+ for _ in range(binary_search_iters):
56
+ mid = (p_lo + p_hi) // 2
57
+ qmid = tl.load(qsl_ptr + mid, mask=token_mask & (mid <= num_reqs), other=0)
58
+ pred = qmid <= token_offsets
59
+ p_lo = tl.where(pred, mid + 1, p_lo)
60
+ p_hi = tl.where(pred, p_hi, mid)
61
+ req = tl.minimum(p_lo - 1, num_reqs - 1).to(tl.int64)
62
+ request_start = tl.load(qsl_ptr + req, mask=token_mask, other=0)
63
+ chunk_pos = token_offsets - request_start
64
+
65
+ # Initialize every head with the current-token term.
66
+ current_token = tl.load(input_ids_ptr + token_offsets, mask=token_mask, other=0).to(
67
+ tl.int64
68
+ )
69
+ current_multiplier = tl.load(multipliers_ptr)
70
+ mixed = current_token[:, None] * current_multiplier
71
+
72
+ g = tl.arange(0, BLOCK_H)
73
+ head_mask = g < NGRAM_HEADS
74
+ ngram_order = g // HEADS_PER_NGRAM + 2
75
+
76
+ # Walk predecessors from newest to oldest. At chunk boundaries they come
77
+ # from ngram_context; otherwise they come from this step's input_ids.
78
+ crossed = tl.zeros([BLOCK_T], tl.int1)
79
+ for shift in tl.static_range(1, NGRAM_CONTEXT_LEN + 1):
80
+ in_step = chunk_pos >= shift
81
+ ctx_col = NGRAM_CONTEXT_LEN - shift + chunk_pos
82
+ step_token = tl.load(
83
+ input_ids_ptr + token_offsets - shift,
84
+ mask=token_mask & in_step,
85
+ other=0,
86
+ )
87
+ context_token = tl.load(
88
+ ctx_ptr + req * NGRAM_CONTEXT_LEN + ctx_col,
89
+ mask=token_mask & (~in_step),
90
+ other=0,
91
+ )
92
+ candidate = tl.where(in_step, step_token, context_token).to(tl.int64)
93
+ # Older positions remain behind the first EOS boundary.
94
+ candidate = tl.where(crossed, eos_token_id, candidate)
95
+ crossed = crossed | (candidate == eos_token_id)
96
+ multiplier = tl.load(multipliers_ptr + shift)
97
+ term = candidate[:, None] * multiplier
98
+ mixed = mixed ^ tl.where((ngram_order > shift)[None, :], term, 0)
99
+
100
+ # Map each hash into its embedding-table partition.
101
+ sizes = tl.load(sizes_ptr + g, mask=head_mask, other=1)[None, :]
102
+ head_offsets = tl.load(offsets_ptr + g, mask=head_mask, other=0)[None, :]
103
+ # Hash products may overflow int64; preserve torch.remainder semantics.
104
+ remainders = mixed % sizes
105
+ remainders = tl.where(remainders < 0, remainders + sizes, remainders)
106
+ ids = remainders + head_offsets
107
+ if launch_pdl:
108
+ tl.extra.cuda.gdc_launch_dependents()
109
+ tl.store(
110
+ out_ptr + token_offsets[:, None] * NGRAM_HEADS + g[None, :],
111
+ ids,
112
+ mask=token_mask[:, None] & head_mask[None, :],
113
+ )
114
+
115
+
116
+def _ple_ngram_ids(
117
+ input_ids: torch.Tensor,
118
+ query_start_loc: torch.Tensor,
119
+ ngram_context: torch.Tensor,
120
+ layer_multipliers: torch.Tensor,
121
+ ngram_heads_vocab_sizes: torch.Tensor,
122
+ ngram_heads_offsets: torch.Tensor,
123
+ output: torch.Tensor,
124
+ eos_token_id: int,
125
+ heads_per_ngram: int,
126
+) -> None:
127
+ input_ids = input_ids.reshape(-1)
128
+ num_tokens = input_ids.shape[0]
129
+ num_reqs = query_start_loc.numel() - 1
130
+ ctx_len = ngram_context.shape[1]
131
+ BLOCK_T = 8
132
+ launch_pdl = current_platform.is_arch_support_pdl()
133
+ _ple_ngram_ids_kernel[(triton.cdiv(num_tokens, BLOCK_T),)](
134
+ input_ids,
135
+ query_start_loc,
136
+ ngram_context,
137
+ layer_multipliers,
138
+ ngram_heads_vocab_sizes,
139
+ ngram_heads_offsets,
140
+ output,
141
+ num_tokens,
142
+ num_reqs,
143
+ eos_token_id,
144
+ binary_search_iters=num_reqs.bit_length(),
145
+ NGRAM_CONTEXT_LEN=ctx_len,
146
+ HEADS_PER_NGRAM=heads_per_ngram,
147
+ BLOCK_T=BLOCK_T,
148
+ launch_pdl=launch_pdl,
149
+ num_warps=4,
150
+ )
151
+
152
+
153
+def ple_ngram_ids(
154
+ input_ids: torch.Tensor,
155
+ query_start_loc: torch.Tensor,
156
+ ngram_context: torch.Tensor,
157
+ layer_multipliers: torch.Tensor,
158
+ ngram_heads_vocab_sizes: torch.Tensor,
159
+ ngram_heads_offsets: torch.Tensor,
160
+ eos_token_id: int,
161
+ heads_per_ngram: int,
162
+ output: torch.Tensor | None = None,
163
+) -> torch.Tensor:
164
+ if output is None:
165
+ output = torch.empty(
166
+ (input_ids.numel(), ngram_context.shape[1] * heads_per_ngram),
167
+ dtype=torch.int64,
168
+ device=input_ids.device,
169
+ )
170
+ _ple_ngram_ids(
171
+ input_ids,
172
+ query_start_loc,
173
+ ngram_context,
174
+ layer_multipliers,
175
+ ngram_heads_vocab_sizes,
176
+ ngram_heads_offsets,
177
+ output,
178
+ eos_token_id,
179
+ heads_per_ngram,
180
+ )
181
+ return output
182
+
183
+
184
+# Gating and normalization
185
+@triton.jit
186
+def _ple_gate_kernel(
187
+ key_ptr, # [T, HC*H] with row stride key_rs
188
+ value_ptr, # [T, H] with row stride value_rs
189
+ hidden_ptr, # [T, HC*H]
190
+ nk_ptr, # [HC*H] norm_key weight
191
+ nq_ptr, # [HC*H] norm_query weight
192
+ ncw_ptr, # [HC*H] norm_conv weight
193
+ gated_ptr, # [T, HC*H] out
194
+ normed_ptr, # [T, HC*H] out
195
+ key_rs,
196
+ value_rs,
197
+ eps,
198
+ H: tl.constexpr,
199
+ HC: tl.constexpr,
200
+ BLOCK_H: tl.constexpr,
201
+ launch_pdl: tl.constexpr,
202
+):
203
+ t = tl.program_id(0)
204
+ s = tl.program_id(1)
205
+ lanes = tl.arange(0, BLOCK_H)
206
+ mask = lanes < H
207
+ offs = s * H + lanes
208
+ dtype: tl.constexpr = key_ptr.dtype.element_ty
209
+
210
+ if launch_pdl:
211
+ tl.extra.cuda.gdc_wait()
212
+
213
+ k = tl.load(key_ptr + t * key_rs + offs, mask=mask, other=0.0).to(tl.float32)
214
+ q = tl.load(hidden_ptr + t * HC * H + offs, mask=mask, other=0.0).to(tl.float32)
215
+ nk = tl.load(nk_ptr + offs, mask=mask, other=0.0).to(tl.float32)
216
+ nq = tl.load(nq_ptr + offs, mask=mask, other=0.0).to(tl.float32)
217
+
218
+ # Match eager materialization at each intermediate tensor boundary.
219
+ k_n = (k * tl.rsqrt(tl.sum(k * k) / H + eps) * (1.0 + nk)).to(dtype)
220
+ q_n = (q * tl.rsqrt(tl.sum(q * q) / H + eps) * (1.0 + nq)).to(dtype)
221
+ products = (k_n.to(tl.float32) * q_n.to(tl.float32)).to(dtype)
222
+ dot = tl.sum(products.to(tl.float32)).to(dtype).to(tl.float32)
223
+ d = dot / tl.sqrt(float(H))
224
+ d = d.to(dtype).to(tl.float32)
225
+ sign = tl.where(d < 0, -1.0, 0.0)
226
+ sign = tl.where(d > 0, 1.0, sign)
227
+ magnitude = tl.sqrt(tl.maximum(tl.abs(d), 1e-6)).to(dtype)
228
+ g = tl.sigmoid(sign * magnitude)
229
+ g = g.to(dtype).to(tl.float32)
230
+
231
+ v = tl.load(value_ptr + t * value_rs + lanes, mask=mask, other=0.0).to(tl.float32)
232
+ gated = (g * v).to(dtype)
233
+ gf = gated.to(tl.float32)
234
+ ncw = tl.load(ncw_ptr + offs, mask=mask, other=0.0).to(tl.float32)
235
+ normed = gf * tl.rsqrt(tl.sum(gf * gf) / H + eps) * (1.0 + ncw)
236
+
237
+ if launch_pdl:
238
+ tl.extra.cuda.gdc_launch_dependents()
239
+ tl.store(gated_ptr + t * HC * H + offs, gated, mask=mask)
240
+ tl.store(normed_ptr + t * HC * H + offs, normed, mask=mask)
241
+
242
+
243
+def _ple_gate(
244
+ key: torch.Tensor,
245
+ value: torch.Tensor,
246
+ hidden: torch.Tensor,
247
+ norm_key_w: torch.Tensor,
248
+ norm_query_w: torch.Tensor,
249
+ norm_conv_w: torch.Tensor,
250
+ eps: float,
251
+) -> tuple[torch.Tensor, torch.Tensor]:
252
+ num_tokens = hidden.shape[0]
253
+ h = value.shape[-1]
254
+ hc = hidden.shape[-1] // h
255
+ assert key.stride(1) == 1 and value.stride(1) == 1
256
+ assert hidden.is_contiguous()
257
+ if key.dtype != value.dtype or key.dtype != hidden.dtype:
258
+ raise ValueError("key, value, and hidden must have the same dtype")
259
+ if key.dtype not in (torch.bfloat16, torch.float16):
260
+ raise ValueError("PLE gate supports BF16 and FP16 inputs")
261
+ gated = torch.empty_like(hidden)
262
+ normed = torch.empty_like(hidden)
263
+ _ple_gate_kernel[(num_tokens, hc)](
264
+ key,
265
+ value,
266
+ hidden,
267
+ norm_key_w,
268
+ norm_query_w,
269
+ norm_conv_w,
270
+ gated,
271
+ normed,
272
+ key.stride(0),
273
+ value.stride(0),
274
+ eps,
275
+ H=h,
276
+ HC=hc,
277
+ BLOCK_H=triton.next_power_of_2(h),
278
+ num_warps=4,
279
+ launch_pdl=current_platform.is_arch_support_pdl(),
280
+ )
281
+ return gated, normed
282
+
283
+
284
+def _ple_gate_fake(
285
+ key: torch.Tensor,
286
+ value: torch.Tensor,
287
+ hidden: torch.Tensor,
288
+ norm_key_w: torch.Tensor,
289
+ norm_query_w: torch.Tensor,
290
+ norm_conv_w: torch.Tensor,
291
+ eps: float,
292
+) -> tuple[torch.Tensor, torch.Tensor]:
293
+ return torch.empty_like(hidden), torch.empty_like(hidden)
294
+
295
+
296
+direct_register_custom_op(
297
+ op_name="qwen4_exp_ple_gate",
298
+ op_func=_ple_gate,
299
+ mutates_args=[],
300
+ fake_impl=_ple_gate_fake,
301
+)
302
+
303
+
304
+def ple_gate(
305
+ key: torch.Tensor,
306
+ value: torch.Tensor,
307
+ hidden: torch.Tensor,
308
+ norm_key_w: torch.Tensor,
309
+ norm_query_w: torch.Tensor,
310
+ norm_conv_w: torch.Tensor,
311
+ eps: float,
312
+) -> tuple[torch.Tensor, torch.Tensor]:
313
+ return torch.ops.vllm.qwen4_exp_ple_gate(
314
+ key, value, hidden, norm_key_w, norm_query_w, norm_conv_w, eps
315
+ )
316
+
317
+
318
+# Dilated short convolution
319
+@triton.jit(do_not_specialize=["num_reqs", "bs_iters", "has_token_map"])
320
+def _ple_conv_kernel(
321
+ x_ptr,
322
+ state_ptr,
323
+ w_ptr,
324
+ residual_ptr,
325
+ state_idx_ptr,
326
+ qsl_ptr,
327
+ num_acc_ptr,
328
+ has_init_ptr,
329
+ token_idx_ptr,
330
+ has_token_map,
331
+ num_reqs,
332
+ bs_iters,
333
+ state_bs,
334
+ state_ws,
335
+ state_cs,
336
+ C: tl.constexpr,
337
+ BLOCK_C: tl.constexpr,
338
+ STATE_LEN: tl.constexpr,
339
+ DILATION: tl.constexpr,
340
+ KERNEL_SIZE: tl.constexpr,
341
+ SPEC_QUERY_LEN: tl.constexpr,
342
+ MODE: tl.constexpr,
343
+ HAS_INIT: tl.constexpr,
344
+ NULL_STATE_ID: tl.constexpr,
345
+ launch_pdl: tl.constexpr,
346
+):
347
+ t = tl.program_id(0)
348
+ pid_c = tl.program_id(1)
349
+ c_offs = pid_c * BLOCK_C + tl.arange(0, BLOCK_C)
350
+ c_mask = c_offs < C
351
+
352
+ if launch_pdl:
353
+ tl.extra.cuda.gdc_wait()
354
+
355
+ # Mode-specific launches use logical row indices. Mixed batches map them
356
+ # back to the original token order without gathering inputs or residuals.
357
+ output_t = tl.load(token_idx_ptr + t, mask=has_token_map, other=t).to(tl.int64)
358
+
359
+ if MODE == "decode":
360
+ r = t
361
+ q_start = t
362
+ j = tl.full([], 0, tl.int32)
363
+ slot_off = tl.full([], 0, tl.int32)
364
+ in_range = True
365
+ else:
366
+ # Locate the request containing this token.
367
+ lo = tl.full([], 1, tl.int32)
368
+ hi = tl.full([], num_reqs + 1, tl.int32)
369
+ for _ in range(bs_iters):
370
+ mid = (lo + hi) // 2
371
+ qmid = tl.load(qsl_ptr + mid, mask=mid <= num_reqs, other=0)
372
+ pred = qmid <= t
373
+ lo = tl.where(pred, mid + 1, lo)
374
+ hi = tl.where(pred, hi, mid)
375
+ r = tl.minimum(lo - 1, num_reqs - 1)
376
+ q_start = tl.load(qsl_ptr + r)
377
+ j = (t - q_start).to(tl.int32)
378
+ total_real = tl.load(qsl_ptr + num_reqs)
379
+ in_range = t < total_real
380
+ if MODE == "spec":
381
+ num_acc = tl.load(num_acc_ptr + r)
382
+ # Roll back draft state to the last accepted token.
383
+ slot_off = tl.minimum(tl.maximum(num_acc - 1, 0), SPEC_QUERY_LEN - 1).to(
384
+ tl.int32
385
+ )
386
+ else:
387
+ slot_off = tl.full([], 0, tl.int32)
388
+
389
+ sid = tl.load(state_idx_ptr + r).to(tl.int64)
390
+ state_ok = sid != NULL_STATE_ID
391
+ sid_safe = tl.where(state_ok, sid, 0)
392
+ if HAS_INIT:
393
+ has_init = tl.load(has_init_ptr + r, mask=state_ok, other=0) != 0
394
+ else:
395
+ has_init = state_ok
396
+ if MODE == "spec":
397
+ # A new spec chunk can produce output from its own tokens without a
398
+ # prior state; null slots in other modes denote inactive rows.
399
+ read_state = state_ok
400
+ out_ok = in_range
401
+ else:
402
+ read_state = state_ok & has_init
403
+ out_ok = in_range & state_ok
404
+
405
+ # state[sid, w, c] may be a strided view into a shared pool.
406
+ base_state = state_ptr + sid_safe * state_bs
407
+ acc = tl.zeros([BLOCK_C], tl.float32)
408
+ for k in tl.static_range(0, KERNEL_SIZE):
409
+ h = j + DILATION * k
410
+ from_state = h <= STATE_LEN - 1
411
+ state_tap = tl.load(
412
+ base_state + (h + slot_off) * state_ws + c_offs * state_cs,
413
+ mask=c_mask & read_state & from_state,
414
+ other=0.0,
415
+ )
416
+ input_t = q_start + h - STATE_LEN
417
+ input_t = tl.load(
418
+ token_idx_ptr + input_t,
419
+ mask=has_token_map & out_ok & (~from_state),
420
+ other=input_t,
421
+ ).to(tl.int64)
422
+ input_tap = tl.load(
423
+ x_ptr + input_t * C + c_offs,
424
+ mask=c_mask & out_ok & (~from_state),
425
+ other=0.0,
426
+ )
427
+ tap = tl.where(from_state, state_tap, input_tap).to(tl.float32)
428
+ weight = tl.load(
429
+ w_ptr + c_offs * KERNEL_SIZE + k,
430
+ mask=c_mask,
431
+ other=0.0,
432
+ ).to(tl.float32)
433
+ acc += weight * tap
434
+
435
+ # F.conv1d materializes its output dtype before SiLU.
436
+ conv = acc.to(residual_ptr.dtype.element_ty).to(tl.float32)
437
+ y = conv * tl.sigmoid(conv)
438
+ conv_output = tl.where(out_ok, y, 0.0).to(residual_ptr.dtype.element_ty)
439
+ residual = tl.load(
440
+ residual_ptr + output_t * C + c_offs,
441
+ mask=c_mask,
442
+ other=0.0,
443
+ )
444
+ if launch_pdl:
445
+ tl.extra.cuda.gdc_launch_dependents()
446
+ tl.store(
447
+ residual_ptr + output_t * C + c_offs,
448
+ residual + conv_output,
449
+ mask=c_mask,
450
+ )
451
+
452
+ if MODE == "decode":
453
+ # Decode has one program per request and channel block, so no peer can
454
+ # still be reading this state slice when it is updated.
455
+ decode_input = tl.load(
456
+ x_ptr + output_t * C + c_offs,
457
+ mask=c_mask & state_ok,
458
+ other=0.0,
459
+ )
460
+ for i in tl.static_range(0, STATE_LEN):
461
+ if i < STATE_LEN - 1:
462
+ next_state = tl.load(
463
+ base_state + (i + 1) * state_ws + c_offs * state_cs,
464
+ mask=c_mask & state_ok & has_init,
465
+ other=0.0,
466
+ )
467
+ else:
468
+ next_state = decode_input
469
+ tl.store(
470
+ base_state + i * state_ws + c_offs * state_cs,
471
+ next_state,
472
+ mask=c_mask & state_ok,
473
+ )
474
+
475
+
476
+# Prefill and spec write back separately so every token reads the old state.
477
+@triton.jit(do_not_specialize=["has_token_map"])
478
+def _ple_conv_writeback_kernel(
479
+ x_ptr,
480
+ state_ptr,
481
+ state_idx_ptr,
482
+ qsl_ptr,
483
+ num_acc_ptr,
484
+ has_init_ptr,
485
+ token_idx_ptr,
486
+ has_token_map,
487
+ state_bs,
488
+ state_ws,
489
+ state_cs,
490
+ C: tl.constexpr,
491
+ BLOCK_C: tl.constexpr,
492
+ STATE_LEN: tl.constexpr,
493
+ SPEC_QUERY_LEN: tl.constexpr,
494
+ STATE_WIDTH: tl.constexpr,
495
+ MODE: tl.constexpr,
496
+ HAS_INIT: tl.constexpr,
497
+ NULL_STATE_ID: tl.constexpr,
498
+):
499
+ r = tl.program_id(0)
500
+ pid_c = tl.program_id(1)
501
+ c_offs = pid_c * BLOCK_C + tl.arange(0, BLOCK_C)
502
+ c_mask = c_offs < C
503
+
504
+ sid = tl.load(state_idx_ptr + r).to(tl.int64)
505
+ state_ok = sid != NULL_STATE_ID
506
+ if not state_ok:
507
+ return
508
+
509
+ q_start = tl.load(qsl_ptr + r)
510
+ q_end = tl.load(qsl_ptr + r + 1)
511
+ qlen = (q_end - q_start).to(tl.int32)
512
+ if MODE == "spec":
513
+ num_acc = tl.load(num_acc_ptr + r)
514
+ # Use the same rollback window as the output kernel.
515
+ slot_off = tl.minimum(tl.maximum(num_acc - 1, 0), SPEC_QUERY_LEN - 1).to(
516
+ tl.int32
517
+ )
518
+ shift = 1
519
+ else:
520
+ slot_off = tl.full([], 0, tl.int32)
521
+ shift = qlen
522
+ if qlen <= 0:
523
+ return
524
+
525
+ has_init = tl.load(has_init_ptr + r) != 0 if HAS_INIT else True
526
+ src_ok = state_ok if MODE == "spec" else state_ok & has_init
527
+
528
+ WRITE_W: tl.constexpr = STATE_WIDTH if MODE == "spec" else STATE_LEN
529
+ base_state = state_ptr + sid * state_bs
530
+ for i in tl.static_range(0, WRITE_W):
531
+ m = shift + i
532
+ from_state = m <= STATE_LEN - 1
533
+ # Short or graph-padded spec chunks preserve the unused state tail.
534
+ do_write = i < STATE_LEN + qlen - 1 if MODE == "spec" else True
535
+ state_value = tl.load(
536
+ base_state + (slot_off + m) * state_ws + c_offs * state_cs,
537
+ mask=c_mask & from_state & src_ok,
538
+ other=0.0,
539
+ )
540
+ input_t = q_start + m - STATE_LEN
541
+ input_t = tl.load(
542
+ token_idx_ptr + input_t,
543
+ mask=has_token_map & (~from_state) & do_write,
544
+ other=input_t,
545
+ ).to(tl.int64)
546
+ input_value = tl.load(
547
+ x_ptr + input_t * C + c_offs,
548
+ mask=c_mask & (~from_state) & do_write,
549
+ other=0.0,
550
+ )
551
+ value = tl.where(from_state, state_value, input_value)
552
+ tl.store(
553
+ base_state + i * state_ws + c_offs * state_cs,
554
+ value,
555
+ mask=c_mask & do_write,
556
+ )
557
+
558
+
559
+def ple_conv(
560
+ inputs: torch.Tensor,
561
+ residual: torch.Tensor,
562
+ conv_state: torch.Tensor,
563
+ conv_weights: torch.Tensor,
564
+ state_indices: torch.Tensor,
565
+ *,
566
+ mode: Literal["decode", "spec", "prefill"],
567
+ dilation: int,
568
+ query_start_loc: torch.Tensor | None = None,
569
+ num_accepted_tokens: torch.Tensor | None = None,
570
+ has_initial_states: torch.Tensor | None = None,
571
+ spec_query_len: int = 1,
572
+ token_indices: torch.Tensor | None = None,
573
+) -> None:
574
+ """Add short-convolution output to ``residual`` and update its state."""
575
+ BLOCK_C = 512
576
+ kernel_spec_query_len = spec_query_len if mode == "spec" else 1
577
+ T, C = inputs.shape
578
+ K = conv_weights.shape[1]
579
+ state_len = (K - 1) * dilation
580
+ state_width = state_len + kernel_spec_query_len - 1
581
+ if token_indices is not None:
582
+ T = token_indices.numel()
583
+ if conv_state.shape[1] != C or conv_state.shape[2] < state_width:
584
+ raise ValueError(
585
+ "conv_state must have shape [slots, channels, window], with "
586
+ f"channels={C} and window >= {state_width}"
587
+ )
588
+ state_bs, state_cs, state_ws = conv_state.stride()
589
+
590
+ if mode == "decode":
591
+ num_reqs = T
592
+ binary_search_iters = 1
593
+ has_initial_states_arg = has_initial_states is not None
594
+ elif mode == "spec":
595
+ if query_start_loc is None or num_accepted_tokens is None:
596
+ raise ValueError(
597
+ "query_start_loc and num_accepted_tokens are required for spec decode"
598
+ )
599
+ num_reqs = state_indices.numel()
600
+ binary_search_iters = max(num_reqs, 1).bit_length()
601
+ has_initial_states_arg = False
602
+ elif mode == "prefill":
603
+ if query_start_loc is None or has_initial_states is None:
604
+ raise ValueError(
605
+ "query_start_loc and has_initial_states are required for prefill"
606
+ )
607
+ num_reqs = state_indices.numel()
608
+ binary_search_iters = max(num_reqs, 1).bit_length()
609
+ has_initial_states_arg = True
610
+ else:
611
+ raise ValueError(f"Unsupported short-conv mode: {mode}")
612
+
613
+ num_warps = 4 if mode == "prefill" else 8
614
+ launch_pdl = current_platform.is_arch_support_pdl()
615
+
616
+ # Constexpr flags eliminate accesses to optional None arguments. Without a
617
+ # token map, state_indices is an unused but device-resident placeholder.
618
+ _ple_conv_kernel[(T, triton.cdiv(C, BLOCK_C))](
619
+ inputs,
620
+ conv_state,
621
+ conv_weights,
622
+ residual,
623
+ state_indices,
624
+ query_start_loc,
625
+ num_accepted_tokens,
626
+ has_initial_states,
627
+ token_indices if token_indices is not None else state_indices,
628
+ token_indices is not None,
629
+ num_reqs,
630
+ binary_search_iters,
631
+ state_bs,
632
+ state_ws,
633
+ state_cs,
634
+ C=C,
635
+ BLOCK_C=BLOCK_C,
636
+ STATE_LEN=state_len,
637
+ DILATION=dilation,
638
+ KERNEL_SIZE=K,
639
+ SPEC_QUERY_LEN=kernel_spec_query_len,
640
+ MODE=mode,
641
+ HAS_INIT=has_initial_states_arg,
642
+ NULL_STATE_ID=NULL_BLOCK_ID,
643
+ launch_pdl=launch_pdl,
644
+ num_warps=num_warps,
645
+ )
646
+ # conv state update is fused with the kernel above for decode
647
+ if mode != "decode":
648
+ _ple_conv_writeback_kernel[(num_reqs, triton.cdiv(C, BLOCK_C))](
649
+ inputs,
650
+ conv_state,
651
+ state_indices,
652
+ query_start_loc,
653
+ num_accepted_tokens,
654
+ has_initial_states,
655
+ token_indices if token_indices is not None else state_indices,
656
+ token_indices is not None,
657
+ state_bs,
658
+ state_ws,
659
+ state_cs,
660
+ C=C,
661
+ BLOCK_C=BLOCK_C,
662
+ STATE_LEN=state_len,
663
+ SPEC_QUERY_LEN=kernel_spec_query_len,
664
+ STATE_WIDTH=state_width,
665
+ MODE=mode,
666
+ HAS_INIT=has_initial_states_arg,
667
+ NULL_STATE_ID=NULL_BLOCK_ID,
668
+ num_warps=num_warps,
669
+ )
vllm/models/qwen4_exp/nvidia/ple_layer.py
+173
-516
@@ -2,7 +2,6 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""GPU-resident Qwen4Exp position-learning enhancement layers."""
4
5
-import math
5
from collections.abc import Iterable, Sequence
6
7
import torch
@@ -11,7 +10,7 @@ from torch import nn
10
11
from vllm.config import CacheConfig, ModelConfig, VllmConfig, get_current_vllm_config
12
from vllm.forward_context import get_forward_context
14
-from vllm.model_executor.layers.linear import ReplicatedLinear
13
+from vllm.model_executor.layers.linear import MergedColumnParallelLinear
14
from vllm.model_executor.layers.mamba.abstract import MambaBase
15
from vllm.model_executor.layers.mamba.mamba_utils import (
16
MambaStateDtypeCalculator,
@@ -42,9 +41,9 @@ from vllm.v1.attention.backends.short_conv_attn import (
41
PleShortConvAttentionBackend,
42
PleShortConvAttentionMetadata,
43
)
45
-from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
44
45
from ..common.ple import PLEVocabParallelEmbedding
46
+from .ops.ple import ple_conv, ple_gate, ple_ngram_ids
47
48
49
class Qwen4ExpPLEGroupedNorm(nn.Module):
@@ -256,8 +255,6 @@ class Qwen4ExpNGramEmbedding(nn.Module):
255
config: Qwen4ExpTextConfig,
256
embedding_dim: int,
257
ple_dense_layer_id: int,
259
- max_total_tokens: int,
260
- max_num_reqs: int,
258
prefix: str,
259
layer_name: str,
260
quant_config: QuantizationConfig | None = None,
@@ -324,20 +321,6 @@ class Qwen4ExpNGramEmbedding(nn.Module):
321
quant_config, f"{prefix}.ngram_embedding"
322
),
323
)
327
- self.register_buffer(
328
- "positions_buffer",
329
- torch.arange(max_total_tokens, dtype=torch.int64),
330
- persistent=False,
331
- )
332
- self.register_buffer(
333
- "padded_buffer",
334
- torch.full(
335
- (max_num_reqs, max_total_tokens),
336
- self.eos_token_id,
337
- dtype=torch.int64,
338
- ),
339
- persistent=False,
340
- )
324
325
@staticmethod
326
def _shift_precompute(
@@ -379,26 +362,34 @@ class Qwen4ExpNGramEmbedding(nn.Module):
362
input_ids: torch.Tensor,
363
query_start_loc: torch.Tensor,
364
ngram_context: torch.Tensor,
365
+ output: torch.Tensor | None = None,
366
) -> torch.Tensor:
367
"""Compute n-gram embedding indices for the current request layout."""
384
- input_ids = input_ids.reshape(-1).long()
385
- query_start_loc = query_start_loc.long()
368
+ input_ids = input_ids.reshape(-1)
369
num_reqs = query_start_loc.numel() - 1
370
num_tokens = input_ids.shape[0]
388
- if num_tokens > self.positions_buffer.numel():
389
- raise ValueError(
390
- f"PLE received {num_tokens} tokens, but its workspace supports "
391
- f"at most {self.positions_buffer.numel()}"
392
- )
393
- if num_reqs > self.padded_buffer.shape[0]:
394
- raise ValueError(
395
- f"PLE received {num_reqs} requests, but its workspace supports "
396
- f"at most {self.padded_buffer.shape[0]}"
397
- )
371
399
- positions = self.positions_buffer[:num_tokens]
400
- packed = self.padded_buffer[:num_reqs, :num_tokens]
401
- packed.fill_(self.eos_token_id)
372
+ if input_ids.is_cuda:
373
+ return ple_ngram_ids(
374
+ input_ids=input_ids,
375
+ query_start_loc=query_start_loc,
376
+ ngram_context=ngram_context,
377
+ layer_multipliers=self.layer_multipliers,
378
+ ngram_heads_vocab_sizes=self.ngram_heads_vocab_sizes,
379
+ ngram_heads_offsets=self.ngram_heads_offsets,
380
+ eos_token_id=self.eos_token_id,
381
+ heads_per_ngram=self.heads_per_ngram,
382
+ output=output,
383
+ )
384
+ input_ids = input_ids.long()
385
+ query_start_loc = query_start_loc.long()
386
+ positions = torch.arange(num_tokens, device=input_ids.device, dtype=torch.int64)
387
+ packed = torch.full(
388
+ (num_reqs, num_tokens),
389
+ self.eos_token_id,
390
+ device=input_ids.device,
391
+ dtype=torch.int64,
392
+ )
393
request_indices = torch.searchsorted(query_start_loc, positions, right=True) - 1
394
request_indices.clamp_(max=num_reqs - 1)
395
columns = (positions - query_start_loc[request_indices]).clamp(
@@ -446,12 +437,13 @@ class Qwen4ExpNGramEmbedding(nn.Module):
437
query_start_loc: torch.Tensor,
438
ngram_context: torch.Tensor,
439
) -> torch.Tensor:
449
- ngram_ids = input_ids.new_empty(
450
- (input_ids.shape[0], self.ngram_heads),
451
- dtype=torch.long,
452
- )
440
# Keep num_reqs-dependent ID generation outside PIECEWISE CUDA graphs,
441
# which dispatch only on the padded token count.
442
+ # torch.compile requires the splitting op to write graph-owned storage.
443
+ # Once compilation is removed, the op can return the IDs directly.
444
+ ngram_ids = input_ids.new_empty(
445
+ (input_ids.numel(), self.ngram_heads), dtype=torch.long
446
+ )
447
torch.ops.vllm.qwen4_exp_compute_ple_ngram_ids(
448
input_ids,
449
query_start_loc,
@@ -562,26 +554,20 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
554
config,
555
int(config.ple_embed_dim),
556
self.ple_dense_layer_id,
565
- vllm_config.scheduler_config.max_num_batched_tokens,
566
- vllm_config.scheduler_config.max_num_seqs,
557
prefix=f"{prefix}.ple_embedding",
558
layer_name=prefix,
559
quant_config=quant_config,
560
params_dtype=model_config.dtype,
561
)
572
- self.key_proj = ReplicatedLinear(
562
+ # The PLE cache is TP-replicated, so this merged projection is too.
563
+ self.kv_proj = MergedColumnParallelLinear(
564
int(config.ple_embed_dim),
574
- self.hc_hidden_size,
575
- bias=False,
576
- quant_config=quant_config,
577
- prefix=f"{prefix}.key_proj",
578
- )
579
- self.value_proj = ReplicatedLinear(
580
- int(config.ple_embed_dim),
581
- self.hidden_size,
565
+ [self.hc_hidden_size, self.hidden_size],
566
bias=False,
567
+ params_dtype=model_config.dtype,
568
quant_config=quant_config,
584
- prefix=f"{prefix}.value_proj",
569
+ prefix=f"{prefix}.kv_proj",
570
+ disable_tp=True,
571
)
572
norm_args = (
573
self.hc_hidden_size,
@@ -654,360 +640,14 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
640
num_spec=self.num_spec_tokens,
641
)
642
657
- def _apply_norm(
658
- self, norm: Qwen4ExpPLEGroupedNorm, hidden_states: torch.Tensor
659
- ) -> torch.Tensor:
660
- shape = hidden_states.shape
661
- return norm(hidden_states.flatten(-2)).reshape(shape)
662
-
663
- def _short_conv_fallback(self, inputs: torch.Tensor) -> torch.Tensor:
664
- # Profiling / CUDA graph capture only; conv state is not updated.
665
- inputs_t = inputs.transpose(0, 1).unsqueeze(0)
666
- output = self.conv1d(inputs_t)[..., : inputs_t.size(-1)]
667
- return F.silu(output).squeeze(0).transpose(0, 1)
668
-
669
- def _short_conv_dilated_decode_batched(
670
- self,
671
- x_d: torch.Tensor,
672
- conv_state: torch.Tensor,
673
- conv_weights: torch.Tensor,
674
- state_indices_tensor_d: torch.Tensor,
675
- has_initial_states_d: torch.Tensor | None,
676
- ) -> torch.Tensor:
677
- state_indices = state_indices_tensor_d.to(
678
- device=conv_state.device, dtype=torch.int64
679
- )
680
- # FULL cudagraph padded decode rows use NULL_BLOCK_ID. Remap them to
681
- # slot 0 for a safe gather, then zero output and skip write-back.
682
- valid_state = state_indices != NULL_BLOCK_ID
683
- state_indices = torch.where(
684
- valid_state, state_indices, torch.zeros_like(state_indices)
685
- )
686
- if has_initial_states_d is None:
687
- has_initial_state = valid_state
688
- else:
689
- if has_initial_states_d.numel() < state_indices_tensor_d.numel():
690
- raise ValueError(
691
- "has_initial_states_d size mismatch: "
692
- f"got {has_initial_states_d.numel()}, "
693
- f"need >= {state_indices_tensor_d.numel()}."
694
- )
695
- has_initial_state = has_initial_states_d[
696
- : state_indices_tensor_d.numel()
697
- ].to(device=conv_state.device, dtype=torch.bool)
698
- has_initial_state = has_initial_state & valid_state
699
-
700
- cached_state = conv_state.index_select(0, state_indices)
701
- state = cached_state[..., : self.conv_state_len].to(x_d.dtype)
702
- if self.conv_state_len > 0:
703
- initial_state = torch.where(
704
- has_initial_state.view(-1, 1, 1),
705
- state,
706
- torch.zeros_like(state),
707
- )
708
- history = torch.cat((initial_state, x_d.unsqueeze(-1)), dim=-1)
709
- else:
710
- history = x_d.unsqueeze(-1)
711
-
712
- conv_output = F.conv1d(
713
- history,
714
- conv_weights.unsqueeze(1).contiguous(),
715
- groups=history.size(1),
716
- dilation=self.short_conv_dilation,
717
- ).squeeze(-1)
718
- output = F.silu(conv_output)
719
- output = output * valid_state.view(-1, 1).to(output.dtype)
720
-
721
- if self.conv_state_len > 0:
722
- next_state = history[..., -self.conv_state_len :]
723
- # Padded rows are remapped to the reserved null slot. Preserve its
724
- # existing value while writing the new states for valid rows.
725
- existing_base_state = cached_state[..., : self.conv_state_len]
726
- safe_next_state = torch.where(
727
- valid_state.view(-1, 1, 1),
728
- next_state.to(conv_state.dtype),
729
- existing_base_state,
730
- )
731
- cached_state[..., : self.conv_state_len] = safe_next_state
732
- conv_state.index_copy_(0, state_indices, cached_state)
733
-
734
- return output
735
-
736
- def _short_conv_dilated_prefill_batched(
737
- self,
738
- x_p: torch.Tensor,
739
- metadata: PleShortConvAttentionMetadata,
740
- conv_state: torch.Tensor,
741
- conv_weights: torch.Tensor,
742
- state_indices_tensor_p: torch.Tensor,
743
- num_prefills: int,
744
- num_decode_tokens: int,
745
- num_prefill_tokens: int,
746
- ) -> torch.Tensor:
747
- # ``non_spec_query_start_loc`` covers the non-spec (decode + prefill)
748
- # requests and equals ``query_start_loc`` when spec-decode is inactive.
749
- non_spec_query_start_loc = metadata.non_spec_query_start_loc
750
- if non_spec_query_start_loc is None:
751
- raise ValueError("query_start_loc is required for prefill short-conv")
752
- query_start_loc_p = (
753
- non_spec_query_start_loc[-num_prefills - 1 :] - num_decode_tokens
754
- )
755
- # The metadata builder guarantees that the prefill query offsets start
756
- # at 0 and end at num_prefill_tokens. Avoid reading those values here,
757
- # since doing so would force a device-to-host synchronization.
758
- has_initial_states_p = metadata.has_initial_states_p
759
- if has_initial_states_p is None:
760
- raise ValueError("has_initial_states_p is required for prefill short-conv")
761
-
762
- output = torch.empty_like(x_p)
763
- q_starts = query_start_loc_p.to(torch.int64)
764
- if state_indices_tensor_p.numel() < num_prefills:
765
- raise ValueError(
766
- "state_indices_tensor_p size mismatch: "
767
- f"got {state_indices_tensor_p.numel()}, "
768
- f"need >= {num_prefills}."
769
- )
770
- if has_initial_states_p.numel() < num_prefills:
771
- raise ValueError(
772
- "has_initial_states_p size mismatch: "
773
- f"got {has_initial_states_p.numel()}, "
774
- f"need >= {num_prefills}."
775
- )
776
- if num_prefills == 0 or x_p.numel() == 0:
777
- return output
778
- lengths = q_starts[1:] - q_starts[:-1]
779
- # Use the CPU-computed packing width from the metadata builder instead
780
- # of synchronizing on lengths.max().
781
- max_len = metadata.max_prefill_query_len
782
- if max_len <= 0:
783
- return output
784
-
785
- hidden_size = x_p.shape[1]
786
- positions = torch.arange(
787
- num_prefill_tokens, device=x_p.device, dtype=torch.int64
788
- )
789
- req_indices = torch.searchsorted(q_starts[1:], positions, right=True)
790
- col_indices = positions - q_starts[req_indices]
791
-
792
- packed_tokens = x_p.new_zeros((num_prefills, max_len, hidden_size))
793
- packed_tokens[req_indices, col_indices] = x_p
794
- packed_tokens = packed_tokens.transpose(1, 2).contiguous()
795
-
796
- state_indices = state_indices_tensor_p[:num_prefills].to(
797
- device=conv_state.device, dtype=torch.int64
798
- )
799
- valid_state = state_indices != NULL_BLOCK_ID
800
- state_indices = torch.where(
801
- valid_state, state_indices, torch.zeros_like(state_indices)
802
- )
803
- has_initial = has_initial_states_p[:num_prefills].to(
804
- device=conv_state.device, dtype=torch.bool
805
- )
806
- if self.conv_state_len > 0:
807
- if conv_state.shape[0] == 0:
808
- state = conv_state.new_zeros(
809
- (num_prefills, hidden_size, self.conv_state_len),
810
- dtype=x_p.dtype,
811
- )
812
- else:
813
- state = conv_state.index_select(0, state_indices)[
814
- ..., : self.conv_state_len
815
- ].to(x_p.dtype)
816
- use_initial_mask = (valid_state & has_initial).view(num_prefills, 1, 1)
817
- initial_state = torch.where(
818
- use_initial_mask,
819
- state,
820
- torch.zeros_like(state),
821
- )
822
- history = torch.cat((initial_state, packed_tokens), dim=-1)
823
- else:
824
- history = packed_tokens
825
-
826
- conv_output = F.conv1d(
827
- history,
828
- conv_weights.unsqueeze(1).contiguous(),
829
- groups=history.size(1),
830
- dilation=self.short_conv_dilation,
831
- )
832
- conv_output = F.silu(conv_output).transpose(1, 2).contiguous()
833
-
834
- token_positions = torch.arange(max_len, device=x_p.device, dtype=torch.int64)
835
- valid_tokens = token_positions.view(1, max_len) < lengths.view(num_prefills, 1)
836
- valid_output_mask = valid_tokens & valid_state.to(device=x_p.device).view(
837
- num_prefills, 1
838
- )
839
- conv_output.masked_fill_(~valid_output_mask.unsqueeze(-1), 0)
840
- output.copy_(conv_output[req_indices, col_indices])
841
-
842
- if self.conv_state_len > 0 and conv_state.shape[0] > 0:
843
- state_starts = lengths.to(device=history.device, dtype=torch.int64).view(
844
- num_prefills, 1, 1
845
- )
846
- state_offsets = torch.arange(
847
- self.conv_state_len, device=history.device, dtype=torch.int64
848
- ).view(1, 1, self.conv_state_len)
849
- next_state = history.gather(
850
- dim=2,
851
- index=(state_starts + state_offsets).expand(-1, history.size(1), -1),
852
- )
853
- # Write back without a host synchronization. Valid, non-empty rows
854
- # receive their new state; padding and zero-length rows keep the
855
- # current cache value.
856
- existing_state = conv_state.index_select(0, state_indices)
857
- existing_base_state = existing_state[..., : self.conv_state_len]
858
- update_mask = valid_state & (lengths.to(device=conv_state.device) > 0)
859
- safe_next_state = torch.where(
860
- update_mask.view(num_prefills, 1, 1),
861
- next_state.to(conv_state.dtype),
862
- existing_base_state,
863
- )
864
- existing_state[..., : self.conv_state_len] = safe_next_state
865
- conv_state.index_copy_(0, state_indices, existing_state)
866
- return output
867
-
868
- def _short_conv_dilated_spec_batched(
869
- self,
870
- x_spec: torch.Tensor,
871
- conv_state: torch.Tensor,
872
- conv_weights: torch.Tensor,
873
- spec_state_indices_tensor: torch.Tensor,
874
- spec_query_start_loc: torch.Tensor,
875
- num_accepted_tokens: torch.Tensor,
876
- spec_query_len: int,
877
- ) -> torch.Tensor:
878
- """Dilated short-conv for speculative-decode (MTP) requests.
879
-
880
- Each spec request feeds multiple (draft + 1) query tokens. The conv
881
- outputs are computed causally after rolling back the previous draft
882
- state by ``num_accepted_tokens - 1``. The current candidate inputs stay
883
- in the extended cache for the next forward, matching
884
- ``causal_conv1d_update``.
885
-
886
- ``spec_query_len`` (== num_speculative_tokens + 1) is the maximum query
887
- length and is a Python int, so no host synchronization is needed; this
888
- keeps the path safe for full CUDA-graph capture/replay where the buffers
889
- are padded at the request level.
890
- """
891
- num_reqs = spec_state_indices_tensor.numel()
892
- hidden_size = x_spec.size(-1)
893
- # Use a fixed packing width instead of synchronizing on lengths.max().
894
- max_len = spec_query_len
895
- # Full CUDA graphs can pad these buffers. Only the first num_reqs
896
- # accepted-token counts belong to actual speculative requests.
897
- num_accepted_tokens = num_accepted_tokens[:num_reqs]
898
- q_starts = spec_query_start_loc[: num_reqs + 1].to(torch.int64)
899
- # Keep the number of real speculative tokens on the device.
900
- total_real_tokens = q_starts[num_reqs]
901
-
902
- state_indices = spec_state_indices_tensor.to(
903
- device=conv_state.device, dtype=torch.int64
904
- )
905
- valid_state = state_indices != NULL_BLOCK_ID
906
- state_indices = torch.where(
907
- valid_state, state_indices, torch.zeros_like(state_indices)
908
- )
909
- positions = torch.arange(
910
- x_spec.size(0), device=x_spec.device, dtype=torch.int64
911
- )
912
- # Route graph-padded token rows to the discarded dummy request so that
913
- # they cannot overwrite real packed data.
914
- req_indices = torch.searchsorted(q_starts[1:], positions, right=True)
915
- valid_tokens = (positions < total_real_tokens) & (req_indices < num_reqs)
916
- clamped_req_indices = req_indices.clamp_max(max(num_reqs - 1, 0))
917
- col_indices = (positions - q_starts[clamped_req_indices]).clamp_(0, max_len - 1)
918
- pack_req_indices = torch.where(
919
- valid_tokens,
920
- clamped_req_indices,
921
- torch.full_like(req_indices, num_reqs),
922
- )
923
- pack_col_indices = torch.where(
924
- valid_tokens, col_indices, torch.zeros_like(col_indices)
925
- )
926
-
927
- # The last request row is the dummy sink for graph padding.
928
- packed = x_spec.new_zeros((num_reqs + 1, max_len, hidden_size))
929
- packed[pack_req_indices, pack_col_indices] = x_spec
930
- packed = packed.transpose(1, 2).contiguous()
931
-
932
- if self.conv_state_len > 0:
933
- cached_state = conv_state.index_select(0, state_indices)
934
- rollback_offsets = num_accepted_tokens.to(
935
- device=conv_state.device, dtype=torch.int64
936
- ).sub(1)
937
- rollback_offsets = torch.where(
938
- valid_state,
939
- rollback_offsets.clamp_(0, max_len - 1),
940
- torch.zeros_like(rollback_offsets),
941
- )
942
- state_offsets = torch.arange(
943
- self.conv_state_len, device=conv_state.device, dtype=torch.int64
944
- ).view(1, 1, self.conv_state_len)
945
- rollback_indices = rollback_offsets.view(-1, 1, 1) + state_offsets
946
- state = cached_state.gather(
947
- 2, rollback_indices.expand(-1, hidden_size, -1)
948
- ).to(x_spec.dtype)
949
- state = torch.where(
950
- valid_state.view(num_reqs, 1, 1),
951
- state,
952
- torch.zeros_like(state),
953
- )
954
- # Append a zeroed dummy-row state to match the [num_reqs + 1] pack.
955
- dummy_state = state.new_zeros((1, hidden_size, self.conv_state_len))
956
- state_full = torch.cat((state, dummy_state), dim=0)
957
- history = torch.cat((state_full, packed), dim=-1)
958
- else:
959
- history = packed
960
-
961
- conv_output = F.conv1d(
962
- history,
963
- conv_weights.unsqueeze(1).contiguous(),
964
- groups=history.size(1),
965
- dilation=self.short_conv_dilation,
966
- )
967
- conv_output = F.silu(conv_output).transpose(1, 2).contiguous()
968
-
969
- output = conv_output[pack_req_indices, pack_col_indices]
970
- output = output * valid_tokens.view(-1, 1).to(output.dtype)
971
-
972
- # Keep all current candidate inputs in the extended state. On the next
973
- # target forward, ``num_accepted_tokens - 1`` selects the rollback
974
- # window before processing the newly scheduled tokens.
975
- if self.conv_state_len > 0:
976
- state_capacity = self.conv_state_len + max_len - 1
977
- if conv_state.size(-1) < state_capacity:
978
- raise RuntimeError(
979
- "PLE short-conv cache cannot retain speculative tokens: "
980
- f"got {conv_state.size(-1)}, need {state_capacity}."
981
- )
982
- candidate_state = history[:num_reqs, :, 1 : state_capacity + 1]
983
- query_lengths = q_starts[1:] - q_starts[:-1]
984
- state_positions = torch.arange(
985
- state_capacity, device=history.device, dtype=torch.int64
986
- ).view(1, 1, state_capacity)
987
- update_lengths = (self.conv_state_len + query_lengths - 1).view(
988
- num_reqs, 1, 1
989
- )
990
- update_mask = valid_state.view(num_reqs, 1, 1) & (
991
- state_positions < update_lengths
992
- )
993
- existing_state = cached_state[..., :state_capacity]
994
- next_state = torch.where(
995
- update_mask,
996
- candidate_state.to(conv_state.dtype),
997
- existing_state,
998
- )
999
- cached_state[..., :state_capacity] = next_state
1000
- conv_state.index_copy_(0, state_indices, cached_state)
1001
-
1002
- return output
1003
-
643
def _short_conv_dilated_dispatch(
644
self,
645
inputs: torch.Tensor,
646
+ residual: torch.Tensor,
647
metadata: PleShortConvAttentionMetadata,
648
conv_state: torch.Tensor,
649
conv_weights: torch.Tensor,
1010
- ) -> torch.Tensor:
650
+ ) -> None:
651
num_prefills = metadata.num_prefills
652
num_decodes = metadata.num_decodes
653
num_decode_tokens = metadata.num_decode_tokens
@@ -1015,110 +655,129 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
655
has_prefill = num_prefills > 0
656
has_decode = num_decodes > 0
657
has_spec = metadata.spec_sequence_masks is not None
1018
- x = inputs[: metadata.num_actual_tokens]
658
+ has_non_spec = has_prefill or has_decode
659
+ inputs = inputs[: metadata.num_actual_tokens]
660
+ residual = residual[: metadata.num_actual_tokens]
661
1020
- # Split spec / non-spec tokens.
1021
- if has_spec:
1022
- if has_prefill or has_decode:
1023
- assert metadata.spec_token_indx is not None
1024
- assert metadata.non_spec_token_indx is not None
1025
- x_spec = x.index_select(0, metadata.spec_token_indx.long())
1026
- x_non_spec = x.index_select(0, metadata.non_spec_token_indx.long())
1027
- else:
1028
- x_spec = x
1029
- x_non_spec = None
1030
- else:
1031
- x_spec = None
1032
- x_non_spec = x
662
+ spec_token_indices = None
663
+ non_spec_token_indices = None
664
+ if has_spec and has_non_spec:
665
+ assert metadata.spec_token_indx is not None
666
+ assert metadata.non_spec_token_indx is not None
667
+ spec_token_indices = metadata.spec_token_indx
668
+ non_spec_token_indices = metadata.non_spec_token_indx
669
1034
- spec_output = None
1035
- # 1. Run the multi-query speculative-decode part.
670
if has_spec:
671
assert metadata.spec_state_indices_tensor is not None
1038
- assert metadata.spec_query_start_loc is not None
1039
- assert metadata.num_accepted_tokens is not None
1040
- spec_output = self._short_conv_dilated_spec_batched(
1041
- x_spec=x_spec,
672
+ query_start_loc = metadata.spec_query_start_loc
673
+ num_accepted_tokens = metadata.num_accepted_tokens
674
+ assert query_start_loc is not None
675
+ assert num_accepted_tokens is not None
676
+ spec_state_indices = metadata.spec_state_indices_tensor[
677
+ : metadata.num_spec_decodes
678
+ ]
679
+ # Mixed batches stay in their original row order; the kernels map
680
+ # logical spec/non-spec rows instead of materializing both groups.
681
+ ple_conv(
682
+ inputs=inputs,
683
+ residual=residual,
684
conv_state=conv_state,
685
conv_weights=conv_weights,
1044
- spec_state_indices_tensor=metadata.spec_state_indices_tensor[
1045
- : metadata.num_spec_decodes
1046
- ],
1047
- spec_query_start_loc=metadata.spec_query_start_loc,
1048
- num_accepted_tokens=metadata.num_accepted_tokens,
686
+ state_indices=spec_state_indices,
687
+ mode="spec",
688
+ dilation=self.short_conv_dilation,
689
+ query_start_loc=query_start_loc,
690
+ num_accepted_tokens=num_accepted_tokens,
691
spec_query_len=metadata.spec_query_len,
692
+ token_indices=spec_token_indices,
693
)
694
1052
- # 2. Run regular decode and prefill requests.
1053
- conv_out_non_spec = None
1054
- state_indices_tensor = metadata.state_indices_tensor
1055
- if x_non_spec is not None:
1056
- assert state_indices_tensor is not None
1057
- if has_prefill:
1058
- state_indices_tensor_d, state_indices_tensor_p = torch.split(
1059
- state_indices_tensor,
1060
- [num_decodes, num_prefills],
1061
- dim=0,
695
+ if not has_non_spec:
696
+ return
697
+
698
+ state_indices = metadata.state_indices_tensor
699
+ assert state_indices is not None
700
+ if has_prefill:
701
+ state_indices_d, state_indices_p = torch.split(
702
+ state_indices, [num_decodes, num_prefills], dim=0
703
+ )
704
+ if non_spec_token_indices is None:
705
+ inputs_d, inputs_p = torch.split(
706
+ inputs, [num_decode_tokens, num_prefill_tokens], dim=0
707
)
1063
- x_d, x_p = torch.split(
1064
- x_non_spec,
708
+ residual_d, residual_p = torch.split(
709
+ residual, [num_decode_tokens, num_prefill_tokens], dim=0
710
+ )
711
+ token_indices_d = None
712
+ token_indices_p = None
713
+ else:
714
+ inputs_d = inputs_p = inputs
715
+ residual_d = residual_p = residual
716
+ token_indices_d, token_indices_p = torch.split(
717
+ non_spec_token_indices,
718
[num_decode_tokens, num_prefill_tokens],
719
dim=0,
720
)
1068
- non_spec_parts: list[torch.Tensor] = []
1069
- if has_decode:
1070
- non_spec_parts.append(
1071
- self._short_conv_dilated_decode_batched(
1072
- x_d=x_d,
1073
- conv_state=conv_state,
1074
- conv_weights=conv_weights,
1075
- state_indices_tensor_d=state_indices_tensor_d,
1076
- has_initial_states_d=metadata.has_initial_states_d,
1077
- )
1078
- )
1079
- non_spec_parts.append(
1080
- self._short_conv_dilated_prefill_batched(
1081
- x_p=x_p,
1082
- metadata=metadata,
1083
- conv_state=conv_state,
1084
- conv_weights=conv_weights,
1085
- state_indices_tensor_p=state_indices_tensor_p,
1086
- num_prefills=num_prefills,
1087
- num_decode_tokens=num_decode_tokens,
1088
- num_prefill_tokens=num_prefill_tokens,
1089
- )
1090
- )
1091
- conv_out_non_spec = torch.vstack(non_spec_parts)
1092
- else:
1093
- conv_out_non_spec = self._short_conv_dilated_decode_batched(
1094
- x_d=x_non_spec,
721
+
722
+ if has_decode:
723
+ ple_conv(
724
+ inputs=inputs_d,
725
+ residual=residual_d,
726
conv_state=conv_state,
727
conv_weights=conv_weights,
1097
- state_indices_tensor_d=state_indices_tensor[: x_non_spec.size(0)],
1098
- has_initial_states_d=metadata.has_initial_states_d,
728
+ state_indices=state_indices_d,
729
+ mode="decode",
730
+ dilation=self.short_conv_dilation,
731
+ has_initial_states=metadata.has_initial_states_d,
732
+ token_indices=token_indices_d,
733
)
734
1101
- # 3. Merge both parts back into the original token order.
1102
- if has_spec and conv_out_non_spec is not None:
1103
- assert metadata.spec_token_indx is not None
1104
- assert metadata.non_spec_token_indx is not None
1105
- assert spec_output is not None
1106
- output = x.new_empty((metadata.num_actual_tokens, x.size(-1)))
1107
- output.index_copy_(0, metadata.spec_token_indx, spec_output)
1108
- output.index_copy_(0, metadata.non_spec_token_indx, conv_out_non_spec)
1109
- return output
1110
- elif has_spec:
1111
- assert spec_output is not None
1112
- return spec_output
1113
- if conv_out_non_spec is None:
1114
- return x
1115
- return conv_out_non_spec
1116
-
1117
- def _short_conv(self, inputs: torch.Tensor) -> torch.Tensor:
735
+ query_start_loc = metadata.non_spec_query_start_loc
736
+ if query_start_loc is None:
737
+ raise ValueError("query_start_loc is required for prefill short-conv")
738
+ query_start_loc = query_start_loc[-num_prefills - 1 :] - num_decode_tokens
739
+ has_initial_states = metadata.has_initial_states_p
740
+ if has_initial_states is None:
741
+ raise ValueError(
742
+ "has_initial_states_p is required for prefill short-conv"
743
+ )
744
+ ple_conv(
745
+ inputs=inputs_p,
746
+ residual=residual_p,
747
+ conv_state=conv_state,
748
+ conv_weights=conv_weights,
749
+ state_indices=state_indices_p,
750
+ mode="prefill",
751
+ dilation=self.short_conv_dilation,
752
+ query_start_loc=query_start_loc,
753
+ has_initial_states=has_initial_states,
754
+ token_indices=token_indices_p,
755
+ )
756
+ else:
757
+ num_decode_rows = (
758
+ non_spec_token_indices.numel()
759
+ if non_spec_token_indices is not None
760
+ else inputs.size(0)
761
+ )
762
+ ple_conv(
763
+ inputs=inputs,
764
+ residual=residual,
765
+ conv_state=conv_state,
766
+ conv_weights=conv_weights,
767
+ state_indices=state_indices[:num_decode_rows],
768
+ mode="decode",
769
+ dilation=self.short_conv_dilation,
770
+ has_initial_states=metadata.has_initial_states_d,
771
+ token_indices=non_spec_token_indices,
772
+ )
773
+
774
+ def _short_conv(self, inputs: torch.Tensor, residual: torch.Tensor) -> None:
775
forward_context = get_forward_context()
776
attn_metadata = forward_context.attn_metadata
777
+ # Profiling omits all metadata or this Mamba entry. The residual
778
+ # already contains the gated output, so short convolution is a no-op.
779
if attn_metadata is None:
1121
- return self._short_conv_fallback(inputs)
780
+ return
781
782
if not isinstance(attn_metadata, dict):
783
raise RuntimeError(
@@ -1128,8 +787,7 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
787
788
layer_attn_metadata = attn_metadata.get(self.prefix)
789
if layer_attn_metadata is None:
1131
- # MRV2 omits Mamba-family metadata during profile warmup.
1132
- return self._short_conv_fallback(inputs)
790
+ return
791
if not isinstance(layer_attn_metadata, PleShortConvAttentionMetadata):
792
raise TypeError(
793
"Expected PleShortConvAttentionMetadata for layer "
@@ -1138,24 +796,27 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
796
)
797
798
conv_state = self.kv_cache[0]
799
+ # Canonicalize both backend cache layouts to [slot, channel, window].
800
if not is_conv_state_dim_first():
801
conv_state = conv_state.transpose(-1, -2)
802
conv_weights = self.conv1d.weight.squeeze(1)
803
804
state_capacity = self.conv_state_len + self.num_spec_tokens
805
if state_capacity > 0:
1147
- if conv_state.size(-1) < state_capacity:
806
+ state_size = conv_state.size(-1)
807
+ if state_size < state_capacity:
808
raise RuntimeError(
809
"PLE short-conv cache is smaller than expected for "
1150
- f"dilated convolution: got {conv_state.size(-1)}, "
810
+ f"dilated convolution: got {state_size}, "
811
f"expect at least {state_capacity}."
812
)
813
conv_state = conv_state[..., -state_capacity:]
1154
- return self._short_conv_dilated_dispatch(
1155
- inputs,
1156
- layer_attn_metadata,
1157
- conv_state,
1158
- conv_weights.to(dtype=inputs.dtype),
814
+ self._short_conv_dilated_dispatch(
815
+ inputs=inputs,
816
+ residual=residual,
817
+ metadata=layer_attn_metadata,
818
+ conv_state=conv_state,
819
+ conv_weights=conv_weights.to(dtype=inputs.dtype),
820
)
821
822
def forward(
@@ -1174,24 +835,21 @@ class Qwen4ExpPLELayer(nn.Module, MambaBase):
835
)
836
embeddings = self.ple_embedding(input_ids, query_start_loc, ngram_context)
837
embeddings = self._dequantize_embeddings(embeddings, hidden_states.dtype)
1177
- key, _ = self.key_proj(embeddings)
1178
- value, _ = self.value_proj(embeddings)
1179
- token_count = hidden_states.shape[0]
1180
- key = key.reshape(token_count, self.hc_count, self.hidden_size)
1181
- query = hidden_states.reshape(token_count, self.hc_count, self.hidden_size)
1182
- key = self._apply_norm(self.norm_key, key)
1183
- query = self._apply_norm(self.norm_query, query)
1184
- gate = (key * query).sum(dim=-1, keepdim=True) / math.sqrt(self.hidden_size)
1185
- gate = torch.sigmoid(gate.sign() * gate.abs().clamp_min(1e-6).sqrt())
1186
- gated_value = gate * value.unsqueeze(-2)
1187
- normalized = self._apply_norm(self.norm_conv, gated_value).flatten(-2)
1188
- conv_output = torch.zeros_like(normalized)
1189
- torch.ops.vllm.qwen4_exp_ple_short_conv(
1190
- normalized,
1191
- conv_output,
1192
- self.prefix,
1193
- )
1194
- return gated_value.flatten(-2) + conv_output
838
+ kv, _ = self.kv_proj(embeddings)
839
+ key, value = kv.split(self.kv_proj.output_sizes, dim=-1)
840
+ gated_output, conv_input = ple_gate(
841
+ key,
842
+ value,
843
+ hidden_states,
844
+ self.norm_key.weight,
845
+ self.norm_query.weight,
846
+ self.norm_conv.weight,
847
+ self.norm_key.eps,
848
+ )
849
+ # State routing depends on runtime request metadata and remains outside
850
+ # the piecewise graph; short convolution accumulates into gated_output.
851
+ torch.ops.vllm.qwen4_exp_ple_short_conv(conv_input, gated_output, self.prefix)
852
+ return gated_output
853
854
855
def qwen4_exp_compute_ple_ngram_ids(
@@ -1203,12 +861,12 @@ def qwen4_exp_compute_ple_ngram_ids(
861
) -> None:
862
"""Compute request-dependent PLE n-gram IDs outside piecewise graphs."""
863
layer = get_forward_context().no_compile_layers[layer_name]
1206
- ngram_ids = layer.ple_embedding.compute_ngram_ids(
864
+ layer.ple_embedding.compute_ngram_ids(
865
input_ids,
866
query_start_loc,
867
ngram_context,
868
+ output,
869
)
1211
- output.copy_(ngram_ids)
870
871
872
def qwen4_exp_compute_ple_ngram_ids_fake(
@@ -1223,17 +881,16 @@ def qwen4_exp_compute_ple_ngram_ids_fake(
881
882
def qwen4_exp_ple_short_conv(
883
inputs: torch.Tensor,
1226
- output: torch.Tensor,
884
+ residual_output: torch.Tensor,
885
layer_name: str,
886
) -> None:
887
layer = get_forward_context().no_compile_layers[layer_name]
1230
- result = layer._short_conv(inputs)
1231
- output[: result.shape[0]].copy_(result)
888
+ layer._short_conv(inputs, residual_output)
889
890
891
def qwen4_exp_ple_short_conv_fake(
892
inputs: torch.Tensor,
1236
- output: torch.Tensor,
893
+ residual_output: torch.Tensor,
894
layer_name: str,
895
) -> None:
896
return
@@ -1250,7 +907,7 @@ direct_register_custom_op(
907
direct_register_custom_op(
908
op_name="qwen4_exp_ple_short_conv",
909
op_func=qwen4_exp_ple_short_conv,
1253
- mutates_args=["output"],
910
+ mutates_args=["residual_output"],
911
fake_impl=qwen4_exp_ple_short_conv_fake,
912
)
913
vllm/v1/attention/backends/short_conv_attn.py
-6
@@ -141,12 +141,6 @@ class PleShortConvAttentionMetadataBuilder(ShortConvAttentionMetadataBuilder):
141
self.spec_sequence_masks = torch.empty(
142
(self.decode_cudagraph_max_bs,), dtype=torch.bool, device=device
143
)
144
- self.spec_token_indx = torch.empty(
145
- (self.decode_cudagraph_max_tokens,), dtype=torch.int32, device=device
146
- )
147
- self.non_spec_token_indx = torch.empty(
148
- (self.decode_cudagraph_max_tokens,), dtype=torch.int32, device=device
149
- )
144
self.spec_query_start_loc = torch.empty(
145
(self.decode_cudagraph_max_bs + 1,), dtype=torch.int32, device=device
146
)