main
py 2,196 lines 78.4 KB
Raw
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
10 import torch
11 from torch import nn
12 from torch.nn import functional as F
13 from transformers import Qwen4ExpTextConfig
14
15 import vllm.model_executor.layers.vocab_parallel_embedding as embedding_module
16 import vllm.model_executor.parameter as parameter_module
17 import vllm.models.qwen4_exp.common.ngram_embedding as ngram_embedding_module
18 from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
19 from vllm.config import SpeculativeConfig, VllmConfig
20 from vllm.config.compilation import CUDAGraphMode
21 from vllm.config.quantization import QuantizationConfigArgs
22 from vllm.model_executor.layers.quantization.fp8 import Fp8Config
23 from vllm.model_executor.layers.quantization.modelopt import (
24 ModelOptMixedPrecisionConfig,
25 ModelOptNvFp4Config,
26 )
27 from vllm.model_executor.layers.quantization.online.base import OnlineQuantizationConfig
28 from vllm.models.qwen4_exp.amd import ple_layer as amd_ple_layer
29 from vllm.models.qwen4_exp.amd.ple_layer import (
30 Qwen4ExpPLELayer as Qwen4ExpPLELayerAMD,
31 )
32 from vllm.models.qwen4_exp.common.ple import (
33 PLEShardOverlap,
34 compute_ple_shard_overlap,
35 copy_ple_embedding_shard_,
36 )
37 from vllm.models.qwen4_exp.nvidia.ngram_embedding import (
38 Qwen4ExpNGramEmbedding,
39 Qwen4ExpPLEDeviceEmbedding,
40 Qwen4ExpPLEEmbeddingMethod,
41 Qwen4ExpPLEFp8EmbeddingMethod,
42 Qwen4ExpPLEPinnedHostEmbedding,
43 Qwen4ExpPLEUnquantizedEmbeddingMethod,
44 )
45 from vllm.models.qwen4_exp.nvidia.ple_layer import Qwen4ExpPLELayer
46 from vllm.utils.torch_utils import weak_ref_tensor
47 from vllm.v1.attention.backends.short_conv_attn import (
48 PleShortConvAttentionMetadata,
49 PleShortConvAttentionMetadataBuilder,
50 )
51 from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
52 from vllm.v1.kv_cache_interface import MambaSpec
53
54
55 @pytest.mark.parametrize("device", ["cpu", "cuda"])
56 @pytest.mark.parametrize("full_graph", [False, True])
57 @pytest.mark.parametrize(
58 "query_lens,drafts,spec_rows,non_spec_rows,token_order",
59 [
60 pytest.param([4, 2, 0], [3, 1, -1], [0, 1], [], None, id="spec-padding"),
61 pytest.param(
62 [3, 1, 2, 5, 0],
63 [2, -1, 1, -1, -1],
64 [0, 2],
65 [1, 3],
66 [0, 1, 2, 4, 5, 3, 6, 7, 8, 9, 10],
67 id="mixed-padding",
68 ),
69 pytest.param([1, 5], [-1, -1], [], [0, 1], None, id="non-spec"),
70 ],
71 )
72 def test_ple_metadata_preserves_request_and_token_order(
73 device, full_graph, query_lens, drafts, spec_rows, non_spec_rows, token_order
74 ):
75 """Preserve state routing, acceptance and prefill offsets without GPU sync."""
76 if device == "cuda" and not torch.cuda.is_available():
77 pytest.skip("CUDA required")
78 device = torch.device(device)
79 config = VllmConfig()
80 config.speculative_config = SpeculativeConfig(
81 method="ngram", num_speculative_tokens=3
82 )
83 config.compilation_config.cudagraph_mode = (
84 CUDAGraphMode.FULL if full_graph else CUDAGraphMode.NONE
85 )
86 config.compilation_config.max_cudagraph_capture_size = 32
87 builder = PleShortConvAttentionMetadataBuilder(
88 MambaSpec(block_size=16, shapes=((16, 64),), dtypes=(torch.float32,)),
89 ["ple"],
90 config,
91 device,
92 )
93 common = create_common_attn_metadata(
94 BatchSpec(seq_lens=[q + 16 for q in query_lens], query_lens=query_lens),
95 16,
96 device,
97 arange_block_indices=True,
98 )
99 common.is_prefilling = torch.tensor(
100 [q > 1 and d < 0 for q, d in zip(query_lens, drafts)]
101 )
102 # Token padding is independent of the sum of per-request query lengths.
103 common.num_actual_tokens += 3
104 accepted = torch.arange(1, len(query_lens) + 1, dtype=torch.int32, device=device)
105 drafts_cpu = torch.tensor(drafts, dtype=torch.int32)
106 sync_mode = torch.cuda.get_sync_debug_mode() if device.type == "cuda" else None
107 if sync_mode is not None:
108 torch.cuda.set_sync_debug_mode("error")
109 try:
110 metadata = builder.build(
111 0,
112 common,
113 num_accepted_tokens=accepted,
114 num_decode_draft_tokens_cpu=drafts_cpu,
115 )
116 finally:
117 if sync_mode is not None:
118 torch.cuda.set_sync_debug_mode(sync_mode)
119
120 def check(actual, expected):
121 torch.testing.assert_close(
122 actual, torch.tensor(expected, dtype=actual.dtype, device=device)
123 )
124
125 assert metadata.num_spec_decodes == len(spec_rows)
126 assert metadata.num_decodes == sum(query_lens[i] == 1 for i in non_spec_rows)
127 assert metadata.num_prefills == sum(query_lens[i] > 1 for i in non_spec_rows)
128 if spec_rows:
129 torch.testing.assert_close(
130 metadata.spec_state_indices_tensor[: len(spec_rows)],
131 common.block_table_tensor[spec_rows, 0],
132 )
133 torch.testing.assert_close(
134 metadata.num_accepted_tokens[: len(spec_rows)], accepted[spec_rows]
135 )
136 check(
137 metadata.spec_query_start_loc[: len(spec_rows) + 1],
138 [0, *accumulate(query_lens[i] for i in spec_rows)],
139 )
140 if full_graph and not non_spec_rows:
141 check(metadata.spec_state_indices_tensor[len(spec_rows) :], [NULL_BLOCK_ID])
142 check(metadata.num_accepted_tokens[len(spec_rows) :], [1])
143 check(
144 metadata.spec_query_start_loc[len(spec_rows) + 1 :], [sum(query_lens)]
145 )
146 assert (
147 metadata.spec_state_indices_tensor.data_ptr()
148 == builder.spec_state_indices_tensor.data_ptr()
149 )
150 if non_spec_rows:
151 torch.testing.assert_close(
152 metadata.state_indices_tensor, common.block_table_tensor[non_spec_rows, 0]
153 )
154 prefill_lens = [query_lens[i] for i in non_spec_rows if query_lens[i] > 1]
155 check(metadata.query_start_loc_p, [0, *accumulate(prefill_lens)])
156 check(metadata.has_initial_states_p, [True] * len(prefill_lens))
157 if token_order is not None:
158 check(
159 torch.cat((metadata.spec_token_indx, metadata.non_spec_token_indx)),
160 token_order,
161 )
162 else:
163 assert metadata.spec_token_indx is None
164 assert metadata.non_spec_token_indx is None
165 assert metadata.nums_dict is None
166 assert metadata.batch_ptr is None
167 assert metadata.token_chunk_offset_ptr is None
168
169
170 def _mock_etp_group(
171 monkeypatch: pytest.MonkeyPatch,
172 world_size: int = 1,
173 all_reduce=lambda tensor: tensor,
174 ) -> None:
175 group = SimpleNamespace(
176 rank_in_group=0,
177 world_size=world_size,
178 all_reduce=all_reduce,
179 )
180 monkeypatch.setattr(ngram_embedding_module, "get_etp_group", lambda: group)
181 monkeypatch.setattr(
182 ngram_embedding_module,
183 "get_tp_group",
184 lambda: SimpleNamespace(world_size=world_size),
185 )
186
187
188 def _make_ngram_embedding_for_load_test() -> Qwen4ExpNGramEmbedding:
189 module = Qwen4ExpNGramEmbedding.__new__(Qwen4ExpNGramEmbedding)
190 nn.Module.__init__(module)
191 module.split_ngram_parts = 2
192 module.register_buffer("layer_multipliers", torch.zeros(1, dtype=torch.long))
193 module.register_buffer("ngram_heads_offsets", torch.zeros(1, dtype=torch.long))
194 module.register_buffer("ngram_heads_vocab_sizes", torch.zeros(1, dtype=torch.long))
195 module.ngram_embedding = SimpleNamespace(
196 org_vocab_size=8,
197 embedding_dim=2,
198 weight=nn.Parameter(torch.full((4, 2), -1.0)),
199 shard_indices=SimpleNamespace(
200 org_vocab_start_index=2,
201 org_vocab_end_index=6,
202 ),
203 )
204 _set_test_embedding_weight_loader(module.ngram_embedding)
205 return module
206
207
208 def _set_test_embedding_weight_loader(embedding) -> None:
209 embedding.weight.weight_loader = partial(
210 copy_ple_embedding_shard_,
211 tp_start=embedding.shard_indices.org_vocab_start_index,
212 tp_end=embedding.shard_indices.org_vocab_end_index,
213 )
214
215
216 def _make_fp8_ngram_embedding_for_load_test() -> Qwen4ExpNGramEmbedding:
217 module = _make_ngram_embedding_for_load_test()
218 embedding = nn.Module()
219 embedding.org_vocab_size = 8
220 embedding.embedding_dim = 2
221 embedding.shard_indices = SimpleNamespace(
222 org_vocab_start_index=2,
223 org_vocab_end_index=6,
224 )
225 embedding.register_parameter(
226 "weight",
227 nn.Parameter(
228 torch.full((4, 2), -1.0).to(torch.float8_e4m3fn),
229 requires_grad=False,
230 ),
231 )
232 embedding.register_parameter(
233 "weight_scale",
234 nn.Parameter(torch.zeros(1, dtype=torch.float32), requires_grad=False),
235 )
236 _set_test_embedding_weight_loader(embedding)
237 module.ngram_embedding = embedding
238 return module
239
240
241 def test_ple_shard_overlap_and_copy() -> None:
242 overlap = compute_ple_shard_overlap(
243 checkpoint_start=2, checkpoint_rows=5, tp_start=4, tp_end=8
244 )
245 assert overlap == PLEShardOverlap(source_start=2, destination_start=0, row_count=3)
246
247 destination = torch.full((4, 2), -1.0)
248 loaded = torch.arange(10, dtype=torch.float64).reshape(5, 2)
249 copied = copy_ple_embedding_shard_(
250 destination,
251 loaded,
252 checkpoint_start=2,
253 tp_start=4,
254 tp_end=8,
255 )
256
257 assert copied == 3
258 torch.testing.assert_close(destination[:3], loaded[2:5].float())
259 torch.testing.assert_close(destination[3], torch.tensor([-1.0, -1.0]))
260
261
262 def test_ple_shard_copy_is_a_noop_without_overlap() -> None:
263 destination = torch.ones(4, 2)
264 copied = copy_ple_embedding_shard_(
265 destination,
266 torch.zeros(2, 2),
267 checkpoint_start=10,
268 tp_start=4,
269 tp_end=8,
270 )
271
272 assert copied == 0
273 assert torch.equal(destination, torch.ones_like(destination))
274
275
276 def test_ngram_embedding_loads_shards_and_ignores_legacy_token_lookup() -> None:
277 module = _make_ngram_embedding_for_load_test()
278 shard_0 = torch.arange(8, dtype=torch.float32).reshape(4, 2)
279 shard_1 = torch.arange(8, 16, dtype=torch.float32).reshape(4, 2)
280
281 loaded = module.load_weights(
282 [
283 ("ngram_embedding.shard_0.weight", shard_0),
284 ("ngram_embedding.shard_1.weight", shard_1),
285 ("token_lookup", torch.tensor([2, 1, 0])),
286 ]
287 )
288
289 assert loaded == {"ngram_embedding.weight"}
290 torch.testing.assert_close(
291 module.ngram_embedding.weight,
292 torch.cat((shard_0[2:4], shard_1[0:2])),
293 )
294
295
296 def test_ngram_embedding_loads_fp8_shards_and_global_scale() -> None:
297 module = _make_fp8_ngram_embedding_for_load_test()
298 shard_0 = torch.arange(8, dtype=torch.float32).reshape(4, 2).to(torch.float8_e4m3fn)
299 shard_1 = (
300 torch.arange(8, 16, dtype=torch.float32).reshape(4, 2).to(torch.float8_e4m3fn)
301 )
302 weight_scale = torch.tensor([0.25], dtype=torch.bfloat16)
303
304 loaded = module.load_weights(
305 [
306 ("ngram_embedding.shard_0.weight", shard_0),
307 ("ngram_embedding.shard_1.weight", shard_1),
308 ("ngram_embedding.weight_scale", weight_scale),
309 ]
310 )
311
312 assert loaded == {"ngram_embedding.weight", "ngram_embedding.weight_scale"}
313 assert module.ngram_embedding.weight.dtype == torch.float8_e4m3fn
314 assert torch.equal(
315 module.ngram_embedding.weight.float(),
316 torch.cat((shard_0[2:4], shard_1[0:2])).float(),
317 )
318 assert module.ngram_embedding.weight_scale.dtype == torch.float32
319 torch.testing.assert_close(
320 module.ngram_embedding.weight_scale,
321 weight_scale.float(),
322 )
323
324
325 @pytest.mark.parametrize(("dp_rank", "local_tokens"), [(0, 2), (1, 3)])
326 def test_etp_lookup_gathers_and_returns_dp_local_rows(
327 monkeypatch: pytest.MonkeyPatch,
328 dp_rank: int,
329 local_tokens: int,
330 ) -> None:
331 embedding = Qwen4ExpPLEDeviceEmbedding.__new__(Qwen4ExpPLEDeviceEmbedding)
332 nn.Module.__init__(embedding)
333 embedding.tp_size = 2
334 embedding.etp_data_parallel_size = 2
335 embedding.data_parallel_rank = dp_rank
336 gathered_ids = torch.tensor([[10], [11], [0], [20], [21], [22]])
337 group = SimpleNamespace(
338 rank_in_group=dp_rank,
339 all_gather=lambda input_ids, dim: gathered_ids,
340 all_reduce=lambda embeddings: embeddings,
341 )
342 embedding.parallel_group = group
343 forward_context = SimpleNamespace(
344 dp_metadata=SimpleNamespace(
345 num_tokens_across_dp_cpu=torch.tensor([2, 3]),
346 )
347 )
348 monkeypatch.setattr(ngram_embedding_module, "get_dp_group", lambda: group)
349 monkeypatch.setattr(
350 ngram_embedding_module, "get_forward_context", lambda: forward_context
351 )
352 local_ids = gathered_ids[dp_rank * 3 : dp_rank * 3 + local_tokens]
353 embeddings = torch.arange(12).reshape(6, 2)
354
355 def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
356 assert torch.equal(input_ids, gathered_ids)
357 return embeddings
358
359 monkeypatch.setattr(
360 ngram_embedding_module.PLEVocabParallelEmbedding,
361 "forward",
362 forward,
363 )
364 output = embedding(local_ids)
365
366 expected = embeddings[dp_rank * 3 : dp_rank * 3 + local_tokens]
367 assert torch.equal(output, expected)
368
369
370 def _make_fp8_embedding_layer(
371 monkeypatch: pytest.MonkeyPatch,
372 *,
373 load_scale: bool = True,
374 ) -> Qwen4ExpPLEDeviceEmbedding:
375 _mock_etp_group(monkeypatch)
376 monkeypatch.setattr(embedding_module, "get_tensor_model_parallel_rank", lambda: 0)
377 monkeypatch.setattr(
378 embedding_module, "get_tensor_model_parallel_world_size", lambda: 1
379 )
380 monkeypatch.setattr(parameter_module, "get_tensor_model_parallel_rank", lambda: 0)
381 monkeypatch.setattr(
382 parameter_module, "get_tensor_model_parallel_world_size", lambda: 1
383 )
384 method = Qwen4ExpPLEFp8EmbeddingMethod()
385 layer = Qwen4ExpPLEDeviceEmbedding(
386 3,
387 2,
388 params_dtype=torch.bfloat16,
389 padding_size=1,
390 prefix="test.ple_embedding",
391 embedding_method=method,
392 )
393 weight = torch.tensor([[1.0, 2.0], [4.0, 8.0], [16.0, 32.0]])
394 layer.weight.data.copy_(weight.to(torch.float8_e4m3fn))
395 if load_scale:
396 layer.weight_scale.data.copy_(torch.tensor([0.25], dtype=torch.bfloat16))
397 return layer
398
399
400 def test_ple_fp8_embedding_dequantizes_in_ple_layer(monkeypatch) -> None:
401 layer = _make_fp8_embedding_layer(monkeypatch)
402 layer.quant_method.process_weights_after_loading(layer)
403 quantized_output = layer(torch.tensor([2, 0]))
404 ple_layer = Qwen4ExpPLELayer.__new__(Qwen4ExpPLELayer)
405 nn.Module.__init__(ple_layer)
406 ple_layer.ple_embedding = nn.Module()
407 ple_layer.ple_embedding.ngram_embedding = layer
408
409 output = ple_layer._dequantize_embeddings(
410 quantized_output,
411 torch.bfloat16,
412 )
413
414 assert layer.weight.dtype == torch.float8_e4m3fn
415 assert layer.weight_scale.dtype == torch.float32
416 assert quantized_output.dtype == torch.float8_e4m3fn
417 assert output.dtype == torch.bfloat16
418 weight = torch.tensor([[1.0, 2.0], [4.0, 8.0], [16.0, 32.0]])
419 torch.testing.assert_close(output, (weight[[2, 0]] * 0.25).bfloat16())
420
421
422 def test_ple_fp8_embedding_rejects_missing_global_scale(monkeypatch) -> None:
423 layer = _make_fp8_embedding_layer(monkeypatch, load_scale=False)
424
425 with pytest.raises(ValueError, match="missing its global scale"):
426 layer.quant_method.process_weights_after_loading(layer)
427
428
429 @pytest.mark.parametrize("requires_device_loading", [False, True])
430 @pytest.mark.parametrize("load_scale", [False, True])
431 def test_pinned_ple_post_load_validates_scale_without_staging(
432 monkeypatch: pytest.MonkeyPatch, requires_device_loading: bool, load_scale: bool
433 ) -> None:
434 """Validate scales through the loader while honoring the staging policy."""
435 from contextlib import nullcontext
436
437 from vllm.model_executor.layers.quantization.base_config import QuantizeMethodBase
438 from vllm.model_executor.model_loader import utils as loader_utils
439
440 source = _make_fp8_embedding_layer(monkeypatch, load_scale=load_scale)
441 embedding = Qwen4ExpPLEPinnedHostEmbedding.__new__(Qwen4ExpPLEPinnedHostEmbedding)
442 nn.Module.__init__(embedding)
443 embedding.weight = source.weight
444 embedding.weight_scale = source.weight_scale
445 embedding.embedding_method = source.embedding_method
446 embedding.quant_method = source.embedding_method
447 embedding.tp_rank = source.tp_rank
448 embedding.tp_size = source.tp_size
449 assert QuantizeMethodBase.requires_device_loading
450 assert not embedding.quant_method.requires_device_loading
451 if requires_device_loading:
452 embedding.quant_method.requires_device_loading = True
453 model = nn.Module()
454 model.embedding = embedding
455 weight_ptr = embedding.weight.data_ptr()
456 staged_modules = []
457
458 def track_staging(module, target_device):
459 staged_modules.append(module)
460 return nullcontext()
461
462 monkeypatch.setattr(loader_utils, "device_loading_context", track_staging)
463 monkeypatch.setattr(loader_utils, "maybe_retie_word_embeddings", lambda *args: None)
464 monkeypatch.setattr(
465 loader_utils, "release_device_memory_under_pressure", lambda *args: None
466 )
467 config = SimpleNamespace(quantization="fp8")
468 if load_scale:
469 loader_utils.process_weights_after_loading(model, config, torch.device("cuda"))
470 else:
471 with pytest.raises(ValueError, match="missing its global scale"):
472 loader_utils.process_weights_after_loading(
473 model, config, torch.device("cuda")
474 )
475 assert embedding.weight.device.type == "cpu"
476 assert embedding.weight.data_ptr() == weight_ptr
477 assert staged_modules == ([embedding] if requires_device_loading else [])
478
479
480 def test_ple_fp8_embedding_uses_int8_for_parallel_reduce(monkeypatch) -> None:
481 monkeypatch.setattr(embedding_module, "get_tensor_model_parallel_rank", lambda: 0)
482 monkeypatch.setattr(
483 embedding_module, "get_tensor_model_parallel_world_size", lambda: 2
484 )
485 monkeypatch.setattr(parameter_module, "get_tensor_model_parallel_rank", lambda: 0)
486 monkeypatch.setattr(
487 parameter_module, "get_tensor_model_parallel_world_size", lambda: 2
488 )
489 monkeypatch.setattr(
490 embedding_module,
491 "get_masked_input_and_mask",
492 lambda *args: (
493 torch.tensor([0, 0]),
494 torch.tensor([False, True]),
495 ),
496 )
497 reduced_dtypes = []
498
499 def all_reduce(tensor: torch.Tensor) -> torch.Tensor:
500 reduced_dtypes.append(tensor.dtype)
501 return tensor.clone()
502
503 _mock_etp_group(monkeypatch, world_size=2, all_reduce=all_reduce)
504 layer = Qwen4ExpPLEDeviceEmbedding(
505 4,
506 2,
507 params_dtype=torch.bfloat16,
508 padding_size=1,
509 prefix="test.ple_embedding",
510 embedding_method=Qwen4ExpPLEFp8EmbeddingMethod(),
511 )
512 layer.weight.data.copy_(
513 torch.tensor([[1.0, 2.0], [4.0, 8.0]]).to(torch.float8_e4m3fn)
514 )
515
516 output = layer(torch.tensor([0, 2]))
517
518 assert reduced_dtypes == [torch.int8]
519 assert output.dtype == torch.float8_e4m3fn
520 torch.testing.assert_close(output[0].float(), layer.weight[0].float())
521 assert torch.count_nonzero(output[1].float()) == 0
522
523
524 def test_ple_fp8_embedding_respects_checkpoint_shard_exclusions() -> None:
525 prefix = "model.layers.1.ple.ple_embedding.ngram_embedding"
526 quant_config = Fp8Config(
527 ignored_layers=[],
528 weight_block_size=[128, 128],
529 )
530 assert isinstance(
531 Qwen4ExpPLEEmbeddingMethod.from_quant_config(quant_config, prefix),
532 Qwen4ExpPLEFp8EmbeddingMethod,
533 )
534
535 quant_config.ignored_layers = [f"{prefix}.shard_0"]
536 assert isinstance(
537 Qwen4ExpPLEEmbeddingMethod.from_quant_config(quant_config, prefix),
538 Qwen4ExpPLEUnquantizedEmbeddingMethod,
539 )
540 assert isinstance(
541 Qwen4ExpPLEEmbeddingMethod.from_quant_config(None, prefix),
542 Qwen4ExpPLEUnquantizedEmbeddingMethod,
543 )
544
545
546 def test_ple_embedding_rejects_unsupported_quantization_configs() -> None:
547 prefix = "model.layers.1.ple.ple_embedding.ngram_embedding"
548 nvfp4_config = ModelOptNvFp4Config(exclude_modules=[])
549 with pytest.raises(NotImplementedError, match="ModelOptNvFp4Config"):
550 Qwen4ExpPLEEmbeddingMethod.from_quant_config(nvfp4_config, prefix)
551
552 online_fp8_config = OnlineQuantizationConfig(
553 QuantizationConfigArgs(linear="fp8_per_tensor")
554 )
555 with pytest.raises(NotImplementedError, match="OnlineQuantizationConfig"):
556 Qwen4ExpPLEEmbeddingMethod.from_quant_config(online_fp8_config, prefix)
557
558
559 def test_ple_embedding_respects_modelopt_exclusion() -> None:
560 prefix = "model.layers.1.ple.ple_embedding.ngram_embedding"
561 quant_config = ModelOptNvFp4Config(exclude_modules=[prefix])
562
563 assert isinstance(
564 Qwen4ExpPLEEmbeddingMethod.from_quant_config(quant_config, prefix),
565 Qwen4ExpPLEUnquantizedEmbeddingMethod,
566 )
567
568
569 def test_ple_embedding_dtype_overrides_modelopt_exclusion() -> None:
570 prefix = "model.layers.1.ple.ple_embedding.ngram_embedding"
571 quant_config = ModelOptNvFp4Config(exclude_modules=[prefix])
572
573 assert isinstance(
574 Qwen4ExpPLEEmbeddingMethod.from_quant_config(
575 quant_config,
576 prefix,
577 "float8_e4m3fn",
578 ),
579 Qwen4ExpPLEFp8EmbeddingMethod,
580 )
581
582
583 def test_pinned_embedding_forward_finalizes_prefetched_output(
584 monkeypatch: pytest.MonkeyPatch,
585 ) -> None:
586 embedding = Qwen4ExpPLEPinnedHostEmbedding.__new__(Qwen4ExpPLEPinnedHostEmbedding)
587 nn.Module.__init__(embedding)
588 embedding._prefetch_buffer = torch.empty(4, 2, 3, dtype=torch.float8_e4m3fn)
589 embedding._output_dim = 6
590 hidden_states = torch.zeros(2, 4, dtype=torch.bfloat16)
591 expected = torch.arange(12).reshape(2, 6).to(torch.float8_e4m3fn)
592
593 def finalize_prefetched(
594 prefetch_output: torch.Tensor,
595 output: torch.Tensor,
596 ) -> None:
597 assert prefetch_output is embedding._prefetch_buffer
598 output.copy_(expected)
599
600 monkeypatch.setattr(
601 embedding,
602 "_finalize_prefetch",
603 finalize_prefetched,
604 )
605
606 output = embedding(hidden_states)
607
608 assert output.dtype == embedding._prefetch_buffer.dtype
609 assert torch.equal(output, expected)
610
611
612 def test_pinned_embedding_forward_requires_prior_start_prefetch() -> None:
613 """Forward before any start_prefetch must fail loudly, not read garbage."""
614 embedding = Qwen4ExpPLEPinnedHostEmbedding.__new__(Qwen4ExpPLEPinnedHostEmbedding)
615 nn.Module.__init__(embedding)
616 embedding._prefetch_buffer = None
617 embedding._prefetch_stream = None
618 embedding._output_dim = 6
619 hidden_states = torch.zeros(2, 4, dtype=torch.bfloat16)
620
621 with pytest.raises(RuntimeError, match="prior start_prefetch"):
622 embedding(hidden_states)
623
624
625 def test_pinned_embedding_finalize_requires_prior_start_prefetch() -> None:
626 """_finalize_prefetch before any start_prefetch must fail loudly."""
627 embedding = Qwen4ExpPLEPinnedHostEmbedding.__new__(Qwen4ExpPLEPinnedHostEmbedding)
628 nn.Module.__init__(embedding)
629 embedding._prefetch_buffer = None
630 embedding._prefetch_stream = None
631 embedding.tp_size = 1
632 output = torch.zeros(2, 6, dtype=torch.bfloat16)
633
634 with pytest.raises(RuntimeError, match="prior start_prefetch"):
635 embedding._finalize_prefetch(torch.empty(4, 2, 3), output)
636
637
638 def test_pinned_fp8_embedding_uses_int8_for_parallel_reduce() -> None:
639 embedding = Qwen4ExpPLEPinnedHostEmbedding.__new__(Qwen4ExpPLEPinnedHostEmbedding)
640 nn.Module.__init__(embedding)
641 embedding.tp_size = 2
642 reduced_dtypes = []
643
644 def all_reduce(tensor: torch.Tensor) -> torch.Tensor:
645 reduced_dtypes.append(tensor.dtype)
646 return tensor.clone()
647
648 embedding.parallel_group = SimpleNamespace(all_reduce=all_reduce)
649 embeddings = torch.arange(8).reshape(2, 4).to(torch.float8_e4m3fn)
650
651 output = embedding._reduce_etp_embeddings(embeddings)
652
653 assert reduced_dtypes == [torch.int8]
654 assert output.dtype == embeddings.dtype
655 torch.testing.assert_close(output.float(), embeddings.float())
656
657
658 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
659 def test_ple_device_embedding_allocates_on_active_device(
660 monkeypatch: pytest.MonkeyPatch,
661 ) -> None:
662 _mock_etp_group(monkeypatch)
663 monkeypatch.setattr(embedding_module, "get_tensor_model_parallel_rank", lambda: 0)
664 monkeypatch.setattr(
665 embedding_module, "get_tensor_model_parallel_world_size", lambda: 1
666 )
667 monkeypatch.setattr(parameter_module, "get_tensor_model_parallel_rank", lambda: 0)
668 monkeypatch.setattr(
669 parameter_module, "get_tensor_model_parallel_world_size", lambda: 1
670 )
671 embedding_method = Qwen4ExpPLEUnquantizedEmbeddingMethod()
672
673 with torch.device("cuda:0"):
674 embedding = Qwen4ExpPLEDeviceEmbedding(
675 4,
676 3,
677 params_dtype=torch.bfloat16,
678 padding_size=1,
679 prefix="test.ple_embedding",
680 embedding_method=embedding_method,
681 )
682
683 assert embedding.weight.device == torch.device("cuda:0")
684
685
686 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
687 @pytest.mark.parametrize("fp8_checkpoint", [False, True])
688 def test_ple_pinned_embedding_loads_on_cpu_and_looks_up_through_uva(
689 monkeypatch: pytest.MonkeyPatch,
690 fp8_checkpoint: bool,
691 ) -> None:
692 _mock_etp_group(monkeypatch)
693 monkeypatch.setattr(embedding_module, "get_tensor_model_parallel_rank", lambda: 0)
694 monkeypatch.setattr(
695 embedding_module, "get_tensor_model_parallel_world_size", lambda: 1
696 )
697 monkeypatch.setattr(parameter_module, "get_tensor_model_parallel_rank", lambda: 0)
698 monkeypatch.setattr(
699 parameter_module, "get_tensor_model_parallel_world_size", lambda: 1
700 )
701
702 with torch.device("cuda:0"):
703 embedding_method = (
704 Qwen4ExpPLEFp8EmbeddingMethod()
705 if fp8_checkpoint
706 else Qwen4ExpPLEUnquantizedEmbeddingMethod()
707 )
708 embedding = Qwen4ExpPLEPinnedHostEmbedding(
709 4,
710 3,
711 params_dtype=torch.bfloat16,
712 padding_size=1,
713 prefix="test.ple_embedding",
714 embedding_method=embedding_method,
715 )
716
717 storage_dtype = torch.float8_e4m3fn if fp8_checkpoint else torch.bfloat16
718 loaded_weight = (
719 torch.arange(12, dtype=torch.float32).reshape(4, 3).to(storage_dtype)
720 )
721 copied = copy_ple_embedding_shard_(
722 embedding.weight,
723 loaded_weight,
724 checkpoint_start=0,
725 tp_start=0,
726 tp_end=4,
727 )
728 input_ids = torch.tensor([[3, 0], [1, 2]], device="cuda:0")
729 output = embedding._lookup(input_ids)
730 if fp8_checkpoint:
731 embedding.weight_scale.data.fill_(0.25)
732 weight_ptr = embedding.weight.data_ptr()
733 embedding.quant_method.process_weights_after_loading(embedding)
734 assert embedding.weight.data_ptr() == weight_ptr
735 dequantized = embedding.dequantize(output, torch.bfloat16)
736
737 assert copied == 4
738 assert embedding.weight.device.type == "cpu"
739 assert embedding.weight.is_pinned()
740 assert output.dtype == storage_dtype
741 expected = loaded_weight[input_ids.cpu()].to(device="cuda:0")
742 torch.testing.assert_close(output.float(), expected.float(), rtol=0, atol=0)
743 expected_scale = 0.25 if fp8_checkpoint else 1.0
744 torch.testing.assert_close(
745 dequantized,
746 expected.to(torch.bfloat16) * expected_scale,
747 rtol=0,
748 atol=0,
749 )
750
751
752 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
753 def test_pinned_embedding_start_prefetch_allocates_lazily_and_feeds_forward(
754 monkeypatch: pytest.MonkeyPatch,
755 ) -> None:
756 """start_prefetch lazily allocates the stream/buffer, then forward consumes it.
757
758 Exercises the full pinned-prefetch contract end to end: first call
759 allocates the side stream and buffer with the constructor geometry,
760 and forward finalizes the lookup into the returned output.
761 """
762 _mock_etp_group(monkeypatch)
763 monkeypatch.setattr(embedding_module, "get_tensor_model_parallel_rank", lambda: 0)
764 monkeypatch.setattr(
765 embedding_module, "get_tensor_model_parallel_world_size", lambda: 1
766 )
767 monkeypatch.setattr(parameter_module, "get_tensor_model_parallel_rank", lambda: 0)
768 monkeypatch.setattr(
769 parameter_module, "get_tensor_model_parallel_world_size", lambda: 1
770 )
771
772 with torch.device("cuda:0"):
773 embedding = Qwen4ExpPLEPinnedHostEmbedding(
774 4,
775 3,
776 params_dtype=torch.bfloat16,
777 padding_size=1,
778 prefix="test.ple_embedding",
779 embedding_method=Qwen4ExpPLEUnquantizedEmbeddingMethod(),
780 num_ngram_heads=2,
781 max_total_tokens=4,
782 )
783 assert embedding._prefetch_buffer is None
784 assert embedding._prefetch_stream is None
785
786 loaded_weight = torch.arange(12, dtype=torch.float32).reshape(4, 3)
787 loaded_weight = loaded_weight.to(torch.bfloat16)
788 copy_ple_embedding_shard_(
789 embedding.weight,
790 loaded_weight,
791 checkpoint_start=0,
792 tp_start=0,
793 tp_end=4,
794 )
795
796 ngram_ids = torch.tensor([[3, 0], [1, 2]], device="cuda:0")
797 hidden_states = torch.zeros(2, 4, dtype=torch.bfloat16, device="cuda:0")
798 embedding.start_prefetch(hidden_states, ngram_ids)
799
800 buffer = embedding._prefetch_buffer
801 assert buffer is not None
802 assert buffer.shape == (4, 2, 3)
803 assert buffer.dtype == torch.bfloat16
804 assert buffer.device.type == "cuda"
805 assert embedding._prefetch_stream is not None
806
807 output = embedding(hidden_states)
808
809 expected = loaded_weight[ngram_ids.cpu()].to(device="cuda:0").flatten(-2)
810 torch.testing.assert_close(output, expected, rtol=0, atol=0)
811
812
813 def test_ple_fp8_embedding_supports_mixed_precision_config() -> None:
814 prefix = "model.language_model.layers.1.ple.ple_embedding.ngram_embedding"
815 quant_config = ModelOptMixedPrecisionConfig.from_config(
816 {
817 "quantization": {
818 "quant_algo": "MIXED_PRECISION",
819 "exclude_modules": [],
820 "group_size": 16,
821 "quantized_layers": {
822 prefix: {"quant_algo": "FP8"},
823 "model.language_model.layers.2.moe.gate_proj": {
824 "quant_algo": "NVFP4"
825 },
826 },
827 }
828 }
829 )
830
831 assert isinstance(
832 Qwen4ExpPLEEmbeddingMethod.from_quant_config(quant_config, prefix),
833 Qwen4ExpPLEFp8EmbeddingMethod,
834 )
835 assert isinstance(
836 Qwen4ExpPLEEmbeddingMethod.from_quant_config(
837 quant_config,
838 "model.language_model.layers.2.moe.gate_proj",
839 ),
840 Qwen4ExpPLEUnquantizedEmbeddingMethod,
841 )
842
843
844 def test_dilated_ple_spec_state_rolls_back_before_next_forward() -> None:
845 conv_state_len = 6
846 dilation = 2
847
848 conv_weights = torch.tensor([[0.25, -0.5, 0.75, 1.0]])
849 conv_state = torch.zeros(2, 1, 9)
850 conv_state[1] = torch.arange(1, 10, dtype=torch.float32).reshape(1, 9)
851 first_inputs = torch.tensor([[10.0], [20.0], [30.0], [40.0]])
852 initial_state = conv_state[1:].clone()
853 first_history = torch.cat(
854 (initial_state[..., :conv_state_len], first_inputs.T.unsqueeze(0)),
855 dim=-1,
856 )
857
858 graph_padded_inputs = F.pad(first_inputs, (0, 0, 0, 4))
859 first_output = _short_conv_dilated_spec_pytorch(
860 graph_padded_inputs,
861 conv_state,
862 conv_weights,
863 torch.tensor([1, 0]),
864 torch.tensor([0, 4, 4]),
865 torch.tensor([1, 0]),
866 spec_query_len=4,
867 conv_state_len=conv_state_len,
868 dilation=dilation,
869 )
870
871 expected_first_output = F.silu(
872 F.conv1d(
873 first_history,
874 conv_weights.unsqueeze(1),
875 groups=1,
876 dilation=dilation,
877 )
878 ).transpose(1, 2)[0]
879 expected_first_state = first_history[..., 1:10]
880 torch.testing.assert_close(first_output[:4], expected_first_output)
881 assert torch.count_nonzero(first_output[4:]) == 0
882 assert torch.count_nonzero(conv_state[0]) == 0
883 torch.testing.assert_close(conv_state[1:], expected_first_state)
884
885 second_inputs = torch.tensor([[50.0], [60.0]])
886 rollback_state = expected_first_state[..., 1:7]
887 padded_second_inputs = F.pad(second_inputs.T.unsqueeze(0), (0, 2))
888 second_history = torch.cat((rollback_state, padded_second_inputs), dim=-1)
889 expected_second_state = expected_first_state.clone()
890 expected_second_state[..., :7] = second_history[..., 1:8]
891
892 second_output = _short_conv_dilated_spec_pytorch(
893 second_inputs,
894 conv_state,
895 conv_weights,
896 torch.tensor([1]),
897 torch.tensor([0, 2]),
898 torch.tensor([2]),
899 spec_query_len=4,
900 conv_state_len=conv_state_len,
901 dilation=dilation,
902 )
903
904 expected_second_output = F.silu(
905 F.conv1d(
906 second_history,
907 conv_weights.unsqueeze(1),
908 groups=1,
909 dilation=dilation,
910 )
911 ).transpose(1, 2)[0, :2]
912 torch.testing.assert_close(second_output, expected_second_output)
913 torch.testing.assert_close(conv_state[1:], expected_second_state)
914
915
916 def _reference_ngram_ids(
917 input_ids: torch.Tensor,
918 query_start_loc: torch.Tensor,
919 ngram_context: torch.Tensor,
920 layer_multipliers: torch.Tensor,
921 ngram_heads_vocab_sizes: torch.Tensor,
922 ngram_heads_offsets: torch.Tensor,
923 eos_token_id: int,
924 heads_per_ngram: int,
925 ) -> torch.Tensor:
926 tokens = input_ids.tolist()
927 starts = query_start_loc.tolist()
928 contexts = ngram_context.tolist()
929 multipliers = layer_multipliers.cpu()
930 sizes = ngram_heads_vocab_sizes.cpu()
931 offsets = ngram_heads_offsets.cpu()
932 rows = []
933 context_len = len(contexts[0])
934 for req, (start, end) in enumerate(zip(starts, starts[1:])):
935 history = contexts[req] + tokens[start:end]
936 for pos in range(context_len, len(history)):
937 shifted = [history[pos]]
938 crossed_eos = False
939 for shift in range(1, context_len + 1):
940 token = eos_token_id if crossed_eos else history[pos - shift]
941 shifted.append(token)
942 crossed_eos |= token == eos_token_id
943
944 row = []
945 mixed = torch.tensor(shifted[0], dtype=torch.int64) * multipliers[0]
946 for ngram_order in range(2, context_len + 2):
947 shift = ngram_order - 1
948 mixed ^= shifted[shift] * multipliers[shift]
949 head_start = (ngram_order - 2) * heads_per_ngram
950 for head in range(head_start, head_start + heads_per_ngram):
951 row.append(torch.remainder(mixed, sizes[head]) + offsets[head])
952 rows.append(torch.stack(row))
953 return torch.stack(rows).to(input_ids.device)
954
955
956 _NGRAM_MULTIPLIERS = (
957 18_014_398_509_481_983,
958 17_114_398_509_481_981,
959 16_214_398_509_481_979,
960 15_314_398_509_481_977,
961 )
962 _NGRAM_HEADS_VOCAB_SIZES = (
963 101,
964 103,
965 107,
966 109,
967 113,
968 127,
969 131,
970 137,
971 139,
972 149,
973 151,
974 157,
975 163,
976 167,
977 173,
978 179,
979 181,
980 191,
981 193,
982 197,
983 199,
984 211,
985 223,
986 227,
987 )
988 _NGRAM_EOS_TOKEN_ID = 251
989 _NGRAM_HEADS_PER_NGRAM = 8
990
991
992 def _ngram_hash_params(device: torch.device, context_len: int) -> dict:
993 num_heads = context_len * _NGRAM_HEADS_PER_NGRAM
994 sizes = torch.tensor(
995 _NGRAM_HEADS_VOCAB_SIZES[:num_heads], dtype=torch.int64, device=device
996 )
997 offsets = torch.zeros_like(sizes)
998 offsets[1:] = torch.cumsum(sizes, dim=0)[:-1]
999 return {
1000 "layer_multipliers": torch.tensor(
1001 _NGRAM_MULTIPLIERS[: context_len + 1],
1002 dtype=torch.int64,
1003 device=device,
1004 ),
1005 "ngram_heads_vocab_sizes": sizes,
1006 "ngram_heads_offsets": offsets,
1007 "eos_token_id": _NGRAM_EOS_TOKEN_ID,
1008 "heads_per_ngram": _NGRAM_HEADS_PER_NGRAM,
1009 }
1010
1011
1012 @pytest.mark.skipif(not torch.cuda.is_available(), reason="fused PLE needs CUDA")
1013 @pytest.mark.parametrize(
1014 ("query_lens", "eos_offsets", "contexts", "first_token_id"),
1015 [
1016 ([1], [], [[11, 12]], 20),
1017 ([3], [1], [[11]], 20),
1018 ([4, 4], [0, 7], [[11, 12], [13, 14]], 20),
1019 ([4, 0, 3], [], [[11, 12], [13, 14], [15, 16]], 20),
1020 (
1021 [3, 2, 0, 0],
1022 [],
1023 [
1024 [11, 12],
1025 [13, 14],
1026 [_NGRAM_EOS_TOKEN_ID, _NGRAM_EOS_TOKEN_ID],
1027 [_NGRAM_EOS_TOKEN_ID, _NGRAM_EOS_TOKEN_ID],
1028 ],
1029 20,
1030 ),
1031 (
1032 [1, 33, 2],
1033 [5, 32],
1034 [[_NGRAM_EOS_TOKEN_ID, 11], [12, 13], [14, _NGRAM_EOS_TOKEN_ID]],
1035 20,
1036 ),
1037 (
1038 [5, 12, 16, 1, 16, 17],
1039 [10, 40],
1040 [[11, 12], [13, 14], [15, 16], [17, 18], [19, 20], [21, 22]],
1041 20,
1042 ),
1043 ([5, 3], [3], [[11, 12, 13], [14, 15, 16]], 20),
1044 (
1045 [4, 0, 3],
1046 [],
1047 [
1048 [200_000, 200_001],
1049 [250_000, 250_001],
1050 [300_000, 300_001],
1051 ],
1052 350_000,
1053 ),
1054 ([3], [], [[1_000_000_000, 1_000_000_001]], 1_000_000_002),
1055 ],
1056 ids=[
1057 "single-token",
1058 "bigram-only",
1059 "power-of-two",
1060 "empty-request",
1061 "trailing-padded-requests",
1062 "three-requests",
1063 "six-requests",
1064 "four-gram",
1065 "int64-overflow",
1066 "large-int32-ids",
1067 ],
1068 )
1069 def test_fused_ngram_ids_correctness(
1070 query_lens: list[int],
1071 eos_offsets: list[int],
1072 contexts: list[list[int]],
1073 first_token_id: int,
1074 ) -> None:
1075 from vllm.models.qwen4_exp.nvidia.ops.ple import ple_ngram_ids
1076
1077 device = torch.device("cuda")
1078 query_start_loc = torch.tensor(
1079 [0, *accumulate(query_lens)], dtype=torch.int32, device=device
1080 )
1081 input_ids = torch.arange(
1082 first_token_id,
1083 first_token_id + sum(query_lens),
1084 dtype=torch.int32,
1085 device=device,
1086 )
1087 if eos_offsets:
1088 input_ids[eos_offsets] = _NGRAM_EOS_TOKEN_ID
1089 ngram_context = torch.tensor(contexts, dtype=torch.int32, device=device)
1090 params = _ngram_hash_params(device, ngram_context.shape[1])
1091
1092 expected = _reference_ngram_ids(input_ids, query_start_loc, ngram_context, **params)
1093 actual = ple_ngram_ids(input_ids, query_start_loc, ngram_context, **params)
1094
1095 assert torch.equal(actual, expected)
1096 offsets = params["ngram_heads_offsets"]
1097 sizes = params["ngram_heads_vocab_sizes"]
1098 assert torch.all((actual >= offsets) & (actual < offsets + sizes))
1099
1100
1101 @pytest.mark.skipif(not torch.cuda.is_available(), reason="fused PLE needs CUDA")
1102 def test_ngram_prefetch_ids_outlive_start_prefetch() -> None:
1103 """Eager breaks hand the side-stream lookup weak refs, so the ids must stay
1104 valid after start_prefetch returns rather than be reused from the pool."""
1105 device = torch.device("cuda")
1106 query_start_loc = torch.tensor([0, 3, 5], dtype=torch.int32, device=device)
1107 input_ids = torch.arange(20, 25, dtype=torch.int32, device=device)
1108 ngram_context = torch.tensor([[11, 12], [13, 14]], dtype=torch.int32, device=device)
1109 params = _ngram_hash_params(device, ngram_context.shape[1])
1110 module = Qwen4ExpNGramEmbedding.__new__(Qwen4ExpNGramEmbedding)
1111 nn.Module.__init__(module)
1112 for name in ("layer_multipliers", "ngram_heads_vocab_sizes", "ngram_heads_offsets"):
1113 module.register_buffer(name, params[name])
1114 module.eos_token_id = params["eos_token_id"]
1115 module.heads_per_ngram = params["heads_per_ngram"]
1116 num_heads = params["ngram_heads_vocab_sizes"].numel()
1117 module._prefetch_ids = torch.empty(8, num_heads, dtype=torch.long, device=device)
1118 prefetched: list[torch.Tensor] = []
1119 module.ngram_embedding = SimpleNamespace(
1120 supports_prefetch=True,
1121 start_prefetch=lambda _, ids: prefetched.append(weak_ref_tensor(ids)),
1122 )
1123 expected = _reference_ngram_ids(input_ids, query_start_loc, ngram_context, **params)
1124
1125 # A fresh pool makes the next same-size allocation reuse any freed ids,
1126 # like a later CUDA graph segment would.
1127 pool = torch.cuda.MemPool()
1128 with torch.cuda.use_mem_pool(pool):
1129 module.start_prefetch(None, input_ids, query_start_loc, ngram_context)
1130 torch.full_like(expected, -1)
1131
1132 assert torch.equal(prefetched[0], expected)
1133
1134
1135 def _short_conv_dilated_decode_pytorch(
1136 x_d: torch.Tensor,
1137 conv_state: torch.Tensor,
1138 conv_weights: torch.Tensor,
1139 state_indices_tensor_d: torch.Tensor,
1140 has_initial_states_d: torch.Tensor | None,
1141 *,
1142 conv_state_len: int,
1143 dilation: int,
1144 ) -> torch.Tensor:
1145 state_indices = state_indices_tensor_d.to(
1146 device=conv_state.device, dtype=torch.int64
1147 )
1148 # FULL cudagraph padded decode rows use NULL_BLOCK_ID. Remap them to
1149 # slot 0 for a safe gather, then zero output and skip write-back.
1150 valid_state = state_indices != NULL_BLOCK_ID
1151 state_indices = torch.where(
1152 valid_state, state_indices, torch.zeros_like(state_indices)
1153 )
1154 if has_initial_states_d is None:
1155 has_initial_state = valid_state
1156 else:
1157 if has_initial_states_d.numel() < state_indices_tensor_d.numel():
1158 raise ValueError(
1159 "has_initial_states_d size mismatch: "
1160 f"got {has_initial_states_d.numel()}, "
1161 f"need >= {state_indices_tensor_d.numel()}."
1162 )
1163 has_initial_state = has_initial_states_d[: state_indices_tensor_d.numel()].to(
1164 device=conv_state.device, dtype=torch.bool
1165 )
1166 has_initial_state = has_initial_state & valid_state
1167
1168 cached_state = conv_state.index_select(0, state_indices)
1169 state = cached_state[..., :conv_state_len].to(x_d.dtype)
1170 if conv_state_len > 0:
1171 initial_state = torch.where(
1172 has_initial_state.view(-1, 1, 1),
1173 state,
1174 torch.zeros_like(state),
1175 )
1176 history = torch.cat((initial_state, x_d.unsqueeze(-1)), dim=-1)
1177 else:
1178 history = x_d.unsqueeze(-1)
1179
1180 conv_output = F.conv1d(
1181 history,
1182 conv_weights.unsqueeze(1).contiguous(),
1183 groups=history.size(1),
1184 dilation=dilation,
1185 ).squeeze(-1)
1186 output = F.silu(conv_output)
1187 output = output * valid_state.view(-1, 1).to(output.dtype)
1188
1189 if conv_state_len > 0:
1190 next_state = history[..., -conv_state_len:]
1191 # Padded rows are remapped to the reserved null slot. Preserve its
1192 # existing value while writing the new states for valid rows.
1193 existing_base_state = cached_state[..., :conv_state_len]
1194 safe_next_state = torch.where(
1195 valid_state.view(-1, 1, 1),
1196 next_state.to(conv_state.dtype),
1197 existing_base_state,
1198 )
1199 cached_state[..., :conv_state_len] = safe_next_state
1200 conv_state.index_copy_(0, state_indices, cached_state)
1201
1202 return output
1203
1204
1205 def _short_conv_dilated_prefill_pytorch(
1206 x_p: torch.Tensor,
1207 metadata: PleShortConvAttentionMetadata,
1208 conv_state: torch.Tensor,
1209 conv_weights: torch.Tensor,
1210 state_indices_tensor_p: torch.Tensor,
1211 num_prefills: int,
1212 num_decode_tokens: int,
1213 num_prefill_tokens: int,
1214 *,
1215 conv_state_len: int,
1216 dilation: int,
1217 ) -> torch.Tensor:
1218 # ``non_spec_query_start_loc`` covers the non-spec (decode + prefill)
1219 # requests and equals ``query_start_loc`` when spec-decode is inactive.
1220 non_spec_query_start_loc = metadata.non_spec_query_start_loc
1221 if non_spec_query_start_loc is None:
1222 raise ValueError("query_start_loc is required for prefill short-conv")
1223 query_start_loc_p = (
1224 non_spec_query_start_loc[-num_prefills - 1 :] - num_decode_tokens
1225 )
1226 # The metadata builder guarantees that the prefill query offsets start
1227 # at 0 and end at num_prefill_tokens. Avoid reading those values here,
1228 # since doing so would force a device-to-host synchronization.
1229 has_initial_states_p = metadata.has_initial_states_p
1230 if has_initial_states_p is None:
1231 raise ValueError("has_initial_states_p is required for prefill short-conv")
1232
1233 output = torch.empty_like(x_p)
1234 q_starts = query_start_loc_p.to(torch.int64)
1235 if state_indices_tensor_p.numel() < num_prefills:
1236 raise ValueError(
1237 "state_indices_tensor_p size mismatch: "
1238 f"got {state_indices_tensor_p.numel()}, "
1239 f"need >= {num_prefills}."
1240 )
1241 if has_initial_states_p.numel() < num_prefills:
1242 raise ValueError(
1243 "has_initial_states_p size mismatch: "
1244 f"got {has_initial_states_p.numel()}, "
1245 f"need >= {num_prefills}."
1246 )
1247 if num_prefills == 0 or x_p.numel() == 0:
1248 return output
1249 lengths = q_starts[1:] - q_starts[:-1]
1250 # Use the CPU-computed packing width from the metadata builder instead
1251 # of synchronizing on lengths.max().
1252 max_len = metadata.max_prefill_query_len
1253 if max_len <= 0:
1254 return output
1255
1256 hidden_size = x_p.shape[1]
1257 positions = torch.arange(num_prefill_tokens, device=x_p.device, dtype=torch.int64)
1258 req_indices = torch.searchsorted(q_starts[1:], positions, right=True)
1259 col_indices = positions - q_starts[req_indices]
1260
1261 packed_tokens = x_p.new_zeros((num_prefills, max_len, hidden_size))
1262 packed_tokens[req_indices, col_indices] = x_p
1263 packed_tokens = packed_tokens.transpose(1, 2).contiguous()
1264
1265 state_indices = state_indices_tensor_p[:num_prefills].to(
1266 device=conv_state.device, dtype=torch.int64
1267 )
1268 valid_state = state_indices != NULL_BLOCK_ID
1269 state_indices = torch.where(
1270 valid_state, state_indices, torch.zeros_like(state_indices)
1271 )
1272 has_initial = has_initial_states_p[:num_prefills].to(
1273 device=conv_state.device, dtype=torch.bool
1274 )
1275 if conv_state_len > 0:
1276 if conv_state.shape[0] == 0:
1277 state = conv_state.new_zeros(
1278 (num_prefills, hidden_size, conv_state_len),
1279 dtype=x_p.dtype,
1280 )
1281 else:
1282 state = conv_state.index_select(0, state_indices)[..., :conv_state_len].to(
1283 x_p.dtype
1284 )
1285 use_initial_mask = (valid_state & has_initial).view(num_prefills, 1, 1)
1286 initial_state = torch.where(
1287 use_initial_mask,
1288 state,
1289 torch.zeros_like(state),
1290 )
1291 history = torch.cat((initial_state, packed_tokens), dim=-1)
1292 else:
1293 history = packed_tokens
1294
1295 conv_output = F.conv1d(
1296 history,
1297 conv_weights.unsqueeze(1).contiguous(),
1298 groups=history.size(1),
1299 dilation=dilation,
1300 )
1301 conv_output = F.silu(conv_output).transpose(1, 2).contiguous()
1302
1303 token_positions = torch.arange(max_len, device=x_p.device, dtype=torch.int64)
1304 valid_tokens = token_positions.view(1, max_len) < lengths.view(num_prefills, 1)
1305 valid_output_mask = valid_tokens & valid_state.to(device=x_p.device).view(
1306 num_prefills, 1
1307 )
1308 conv_output.masked_fill_(~valid_output_mask.unsqueeze(-1), 0)
1309 output.copy_(conv_output[req_indices, col_indices])
1310
1311 if conv_state_len > 0 and conv_state.shape[0] > 0:
1312 state_starts = lengths.to(device=history.device, dtype=torch.int64).view(
1313 num_prefills, 1, 1
1314 )
1315 state_offsets = torch.arange(
1316 conv_state_len, device=history.device, dtype=torch.int64
1317 ).view(1, 1, conv_state_len)
1318 next_state = history.gather(
1319 dim=2,
1320 index=(state_starts + state_offsets).expand(-1, history.size(1), -1),
1321 )
1322 # Write back without a host synchronization. Valid, non-empty rows
1323 # receive their new state; padding and zero-length rows keep the
1324 # current cache value.
1325 existing_state = conv_state.index_select(0, state_indices)
1326 existing_base_state = existing_state[..., :conv_state_len]
1327 update_mask = valid_state & (lengths.to(device=conv_state.device) > 0)
1328 safe_next_state = torch.where(
1329 update_mask.view(num_prefills, 1, 1),
1330 next_state.to(conv_state.dtype),
1331 existing_base_state,
1332 )
1333 existing_state[..., :conv_state_len] = safe_next_state
1334 conv_state.index_copy_(0, state_indices, existing_state)
1335 return output
1336
1337
1338 def _short_conv_dilated_spec_pytorch(
1339 x_spec: torch.Tensor,
1340 conv_state: torch.Tensor,
1341 conv_weights: torch.Tensor,
1342 spec_state_indices_tensor: torch.Tensor,
1343 spec_query_start_loc: torch.Tensor,
1344 num_accepted_tokens: torch.Tensor,
1345 spec_query_len: int,
1346 *,
1347 conv_state_len: int,
1348 dilation: int,
1349 ) -> torch.Tensor:
1350 """Dilated short-conv for speculative-decode (MTP) requests.
1351
1352 Each spec request feeds multiple (draft + 1) query tokens. The conv
1353 outputs are computed causally after rolling back the previous draft
1354 state by ``num_accepted_tokens - 1``. The current candidate inputs stay
1355 in the extended cache for the next forward, matching
1356 ``causal_conv1d_update``.
1357
1358 ``spec_query_len`` (== num_speculative_tokens + 1) is the maximum query
1359 length and is a Python int, so no host synchronization is needed; this
1360 keeps the path safe for full CUDA-graph capture/replay where the buffers
1361 are padded at the request level.
1362 """
1363 num_reqs = spec_state_indices_tensor.numel()
1364 hidden_size = x_spec.size(-1)
1365 # Use a fixed packing width instead of synchronizing on lengths.max().
1366 max_len = spec_query_len
1367 # Full CUDA graphs can pad these buffers. Only the first num_reqs
1368 # accepted-token counts belong to actual speculative requests.
1369 num_accepted_tokens = num_accepted_tokens[:num_reqs]
1370 q_starts = spec_query_start_loc[: num_reqs + 1].to(torch.int64)
1371 # Keep the number of real speculative tokens on the device.
1372 total_real_tokens = q_starts[num_reqs]
1373
1374 state_indices = spec_state_indices_tensor.to(
1375 device=conv_state.device, dtype=torch.int64
1376 )
1377 valid_state = state_indices != NULL_BLOCK_ID
1378 state_indices = torch.where(
1379 valid_state, state_indices, torch.zeros_like(state_indices)
1380 )
1381 positions = torch.arange(x_spec.size(0), device=x_spec.device, dtype=torch.int64)
1382 # Route graph-padded token rows to the discarded dummy request so that
1383 # they cannot overwrite real packed data.
1384 req_indices = torch.searchsorted(q_starts[1:], positions, right=True)
1385 valid_tokens = (positions < total_real_tokens) & (req_indices < num_reqs)
1386 clamped_req_indices = req_indices.clamp_max(max(num_reqs - 1, 0))
1387 col_indices = (positions - q_starts[clamped_req_indices]).clamp_(0, max_len - 1)
1388 pack_req_indices = torch.where(
1389 valid_tokens,
1390 clamped_req_indices,
1391 torch.full_like(req_indices, num_reqs),
1392 )
1393 pack_col_indices = torch.where(
1394 valid_tokens, col_indices, torch.zeros_like(col_indices)
1395 )
1396
1397 # The last request row is the dummy sink for graph padding.
1398 packed = x_spec.new_zeros((num_reqs + 1, max_len, hidden_size))
1399 packed[pack_req_indices, pack_col_indices] = x_spec
1400 packed = packed.transpose(1, 2).contiguous()
1401
1402 if conv_state_len > 0:
1403 cached_state = conv_state.index_select(0, state_indices)
1404 rollback_offsets = num_accepted_tokens.to(
1405 device=conv_state.device, dtype=torch.int64
1406 ).sub(1)
1407 rollback_offsets = torch.where(
1408 valid_state,
1409 rollback_offsets.clamp_(0, max_len - 1),
1410 torch.zeros_like(rollback_offsets),
1411 )
1412 state_offsets = torch.arange(
1413 conv_state_len, device=conv_state.device, dtype=torch.int64
1414 ).view(1, 1, conv_state_len)
1415 rollback_indices = rollback_offsets.view(-1, 1, 1) + state_offsets
1416 state = cached_state.gather(2, rollback_indices.expand(-1, hidden_size, -1)).to(
1417 x_spec.dtype
1418 )
1419 state = torch.where(
1420 valid_state.view(num_reqs, 1, 1),
1421 state,
1422 torch.zeros_like(state),
1423 )
1424 # Append a zeroed dummy-row state to match the [num_reqs + 1] pack.
1425 dummy_state = state.new_zeros((1, hidden_size, conv_state_len))
1426 state_full = torch.cat((state, dummy_state), dim=0)
1427 history = torch.cat((state_full, packed), dim=-1)
1428 else:
1429 history = packed
1430
1431 conv_output = F.conv1d(
1432 history,
1433 conv_weights.unsqueeze(1).contiguous(),
1434 groups=history.size(1),
1435 dilation=dilation,
1436 )
1437 conv_output = F.silu(conv_output).transpose(1, 2).contiguous()
1438
1439 output = conv_output[pack_req_indices, pack_col_indices]
1440 output = output * valid_tokens.view(-1, 1).to(output.dtype)
1441
1442 # Keep all current candidate inputs in the extended state. On the next
1443 # target forward, ``num_accepted_tokens - 1`` selects the rollback
1444 # window before processing the newly scheduled tokens.
1445 if conv_state_len > 0:
1446 state_capacity = conv_state_len + max_len - 1
1447 if conv_state.size(-1) < state_capacity:
1448 raise RuntimeError(
1449 "PLE short-conv cache cannot retain speculative tokens: "
1450 f"got {conv_state.size(-1)}, need {state_capacity}."
1451 )
1452 candidate_state = history[:num_reqs, :, 1 : state_capacity + 1]
1453 query_lengths = q_starts[1:] - q_starts[:-1]
1454 state_positions = torch.arange(
1455 state_capacity, device=history.device, dtype=torch.int64
1456 ).view(1, 1, state_capacity)
1457 update_lengths = (conv_state_len + query_lengths - 1).view(num_reqs, 1, 1)
1458 update_mask = valid_state.view(num_reqs, 1, 1) & (
1459 state_positions < update_lengths
1460 )
1461 existing_state = cached_state[..., :state_capacity]
1462 next_state = torch.where(
1463 update_mask,
1464 candidate_state.to(conv_state.dtype),
1465 existing_state,
1466 )
1467 cached_state[..., :state_capacity] = next_state
1468 conv_state.index_copy_(0, state_indices, cached_state)
1469
1470 return output
1471
1472
1473 def _short_conv_dilated_dispatch_pytorch(
1474 inputs: torch.Tensor,
1475 residual: torch.Tensor,
1476 metadata: PleShortConvAttentionMetadata,
1477 conv_state: torch.Tensor,
1478 conv_weights: torch.Tensor,
1479 *,
1480 conv_state_len: int,
1481 dilation: int,
1482 ) -> None:
1483 """PyTorch reference for fused short-convolution dispatch."""
1484 num_prefills = metadata.num_prefills
1485 num_decodes = metadata.num_decodes
1486 num_decode_tokens = metadata.num_decode_tokens
1487 num_prefill_tokens = metadata.num_prefill_tokens
1488 has_prefill = num_prefills > 0
1489 has_decode = num_decodes > 0
1490 has_spec = metadata.spec_sequence_masks is not None
1491 x = inputs[: metadata.num_actual_tokens]
1492 residual = residual[: metadata.num_actual_tokens]
1493
1494 if has_spec:
1495 if has_prefill or has_decode:
1496 assert metadata.spec_token_indx is not None
1497 assert metadata.non_spec_token_indx is not None
1498 spec_token_indices = metadata.spec_token_indx.long()
1499 non_spec_token_indices = metadata.non_spec_token_indx.long()
1500 x_spec = x.index_select(0, spec_token_indices)
1501 x_non_spec = x.index_select(0, non_spec_token_indices)
1502 residual_spec = residual.index_select(0, spec_token_indices)
1503 residual_non_spec = residual.index_select(0, non_spec_token_indices)
1504 else:
1505 spec_token_indices = None
1506 non_spec_token_indices = None
1507 x_spec = x
1508 x_non_spec = None
1509 residual_spec = residual
1510 residual_non_spec = None
1511 else:
1512 spec_token_indices = None
1513 non_spec_token_indices = None
1514 x_spec = None
1515 x_non_spec = x
1516 residual_spec = None
1517 residual_non_spec = residual
1518
1519 if has_spec:
1520 assert metadata.spec_state_indices_tensor is not None
1521 assert metadata.spec_query_start_loc is not None
1522 assert metadata.num_accepted_tokens is not None
1523 assert x_spec is not None
1524 assert residual_spec is not None
1525 spec_state_indices = metadata.spec_state_indices_tensor[
1526 : metadata.num_spec_decodes
1527 ]
1528 conv_output = _short_conv_dilated_spec_pytorch(
1529 x_spec=x_spec,
1530 conv_state=conv_state,
1531 conv_weights=conv_weights,
1532 spec_state_indices_tensor=spec_state_indices,
1533 spec_query_start_loc=metadata.spec_query_start_loc,
1534 num_accepted_tokens=metadata.num_accepted_tokens,
1535 spec_query_len=metadata.spec_query_len,
1536 conv_state_len=conv_state_len,
1537 dilation=dilation,
1538 )
1539 residual_spec.add_(conv_output)
1540
1541 state_indices = metadata.state_indices_tensor
1542 if x_non_spec is not None:
1543 assert state_indices is not None
1544 assert residual_non_spec is not None
1545 if has_prefill:
1546 state_indices_d, state_indices_p = torch.split(
1547 state_indices, [num_decodes, num_prefills], dim=0
1548 )
1549 x_d, x_p = torch.split(
1550 x_non_spec, [num_decode_tokens, num_prefill_tokens], dim=0
1551 )
1552 residual_d, residual_p = torch.split(
1553 residual_non_spec,
1554 [num_decode_tokens, num_prefill_tokens],
1555 dim=0,
1556 )
1557 if has_decode:
1558 conv_output = _short_conv_dilated_decode_pytorch(
1559 x_d=x_d,
1560 conv_state=conv_state,
1561 conv_weights=conv_weights,
1562 state_indices_tensor_d=state_indices_d,
1563 has_initial_states_d=metadata.has_initial_states_d,
1564 conv_state_len=conv_state_len,
1565 dilation=dilation,
1566 )
1567 residual_d.add_(conv_output)
1568 conv_output = _short_conv_dilated_prefill_pytorch(
1569 x_p=x_p,
1570 metadata=metadata,
1571 conv_state=conv_state,
1572 conv_weights=conv_weights,
1573 state_indices_tensor_p=state_indices_p,
1574 num_prefills=num_prefills,
1575 num_decode_tokens=num_decode_tokens,
1576 num_prefill_tokens=num_prefill_tokens,
1577 conv_state_len=conv_state_len,
1578 dilation=dilation,
1579 )
1580 residual_p.add_(conv_output)
1581 else:
1582 conv_output = _short_conv_dilated_decode_pytorch(
1583 x_d=x_non_spec,
1584 conv_state=conv_state,
1585 conv_weights=conv_weights,
1586 state_indices_tensor_d=state_indices[: x_non_spec.size(0)],
1587 has_initial_states_d=metadata.has_initial_states_d,
1588 conv_state_len=conv_state_len,
1589 dilation=dilation,
1590 )
1591 residual_non_spec.add_(conv_output)
1592
1593 if has_spec and residual_non_spec is not None:
1594 assert spec_token_indices is not None
1595 assert non_spec_token_indices is not None
1596 assert residual_spec is not None
1597 residual.index_copy_(0, spec_token_indices, residual_spec)
1598 residual.index_copy_(0, non_spec_token_indices, residual_non_spec)
1599
1600
1601 _KERNEL_STATE_BLOCKS = 64
1602
1603
1604 def _make_conv_case(
1605 device: torch.device,
1606 seed: int,
1607 channels: int,
1608 kernel_size: int,
1609 dilation: int,
1610 state_layout: str,
1611 spec_query_len: int = 1,
1612 ) -> tuple[torch.Generator, torch.Tensor, torch.Tensor, torch.Tensor]:
1613 rng = torch.Generator(device=device).manual_seed(seed)
1614 state_len = (kernel_size - 1) * dilation
1615 state_width = state_len + spec_query_len - 1
1616 state_shape = (
1617 (_KERNEL_STATE_BLOCKS, state_width, channels)
1618 if state_layout == "SD"
1619 else (_KERNEL_STATE_BLOCKS, channels, state_width)
1620 )
1621 state_kernel = torch.randn(
1622 state_shape, device=device, dtype=torch.bfloat16, generator=rng
1623 )
1624 conv_state = (
1625 state_kernel.transpose(-1, -2) if state_layout == "SD" else state_kernel
1626 )
1627 state_reference = conv_state.clone()
1628 weights = torch.randn(
1629 channels, kernel_size, device=device, dtype=torch.bfloat16, generator=rng
1630 )
1631 return rng, state_reference, conv_state, weights
1632
1633
1634 @dataclass(frozen=True)
1635 class _ConvBatchCase:
1636 spec_query_lens: tuple[int, ...] = ()
1637 num_accepted: tuple[int, ...] = ()
1638 num_decodes: int = 0
1639 prefill_query_lens: tuple[int, ...] = ()
1640 channels: int = 2048
1641 kernel_size: int = 4
1642 dilation: int = 3
1643 spec_query_len: int = 1
1644 graph_padding: int = 0
1645 state_index_stride: int = 1
1646 include_null_state: bool = True
1647
1648
1649 def _make_conv_metadata(
1650 case: _ConvBatchCase,
1651 device: torch.device,
1652 ) -> tuple[SimpleNamespace, int]:
1653 request_groups: dict[str, list[int]] = {
1654 "spec": [],
1655 "decode": [],
1656 "prefill": [],
1657 }
1658 token_offset = 0
1659 max_num_reqs = max(
1660 len(case.spec_query_lens),
1661 case.num_decodes,
1662 len(case.prefill_query_lens),
1663 )
1664 request_kinds = []
1665 for req_idx in range(max_num_reqs):
1666 if req_idx < len(case.spec_query_lens):
1667 request_kinds.append(("spec", case.spec_query_lens[req_idx]))
1668 if req_idx < case.num_decodes:
1669 request_kinds.append(("decode", 1))
1670 if req_idx < len(case.prefill_query_lens):
1671 request_kinds.append(("prefill", case.prefill_query_lens[req_idx]))
1672 for kind, query_len in request_kinds:
1673 request_groups[kind].extend(range(token_offset, token_offset + query_len))
1674 token_offset += query_len
1675
1676 num_spec_reqs = len(case.spec_query_lens)
1677 num_prefills = len(case.prefill_query_lens)
1678 has_spec = num_spec_reqs > 0
1679 has_non_spec = case.num_decodes > 0 or num_prefills > 0
1680 mixed_batch = has_spec and has_non_spec
1681
1682 spec_state_indices = torch.arange(
1683 1, num_spec_reqs + 1, dtype=torch.int32, device=device
1684 )
1685 if num_spec_reqs >= 3:
1686 spec_state_indices[1] = NULL_BLOCK_ID
1687 non_spec_state_indices = torch.arange(
1688 num_spec_reqs + 1,
1689 num_spec_reqs + case.num_decodes + num_prefills + 1,
1690 dtype=torch.int32,
1691 device=device,
1692 )
1693 if case.state_index_stride > 1:
1694 strided_indices = torch.full(
1695 (non_spec_state_indices.numel(), case.state_index_stride),
1696 NULL_BLOCK_ID,
1697 dtype=torch.int32,
1698 device=device,
1699 )
1700 strided_indices[:, 0] = non_spec_state_indices
1701 non_spec_state_indices = strided_indices[:, 0]
1702 if case.num_decodes > 1:
1703 non_spec_state_indices[case.num_decodes - 1] = NULL_BLOCK_ID
1704 if num_prefills > 1 and case.include_null_state:
1705 non_spec_state_indices[case.num_decodes + 1] = NULL_BLOCK_ID
1706
1707 spec_query_start_loc = torch.tensor(
1708 [0, *accumulate(case.spec_query_lens)], dtype=torch.int32, device=device
1709 )
1710 non_spec_query_lens = (1,) * case.num_decodes + case.prefill_query_lens
1711 non_spec_query_start_loc = torch.tensor(
1712 [0, *accumulate(non_spec_query_lens)], dtype=torch.int32, device=device
1713 )
1714 spec_token_indices = torch.tensor(
1715 request_groups["spec"], dtype=torch.int64, device=device
1716 )
1717 non_spec_token_indices = torch.tensor(
1718 request_groups["decode"] + request_groups["prefill"],
1719 dtype=torch.int64,
1720 device=device,
1721 )
1722
1723 metadata = SimpleNamespace(
1724 num_prefills=num_prefills,
1725 num_decodes=case.num_decodes,
1726 num_decode_tokens=case.num_decodes,
1727 num_prefill_tokens=sum(case.prefill_query_lens),
1728 spec_sequence_masks=(
1729 torch.tensor([kind == "spec" for kind, _ in request_kinds], device=device)
1730 if has_spec
1731 else None
1732 ),
1733 spec_token_indx=spec_token_indices if mixed_batch else None,
1734 non_spec_token_indx=non_spec_token_indices if mixed_batch else None,
1735 num_actual_tokens=token_offset + case.graph_padding,
1736 spec_state_indices_tensor=spec_state_indices if has_spec else None,
1737 spec_query_start_loc=spec_query_start_loc if has_spec else None,
1738 num_accepted_tokens=(
1739 torch.tensor(case.num_accepted, dtype=torch.int32, device=device)
1740 if has_spec
1741 else None
1742 ),
1743 spec_query_len=case.spec_query_len,
1744 state_indices_tensor=non_spec_state_indices if has_non_spec else None,
1745 has_initial_states_d=(
1746 torch.arange(case.num_decodes, device=device) % 2 == 0
1747 if case.num_decodes
1748 else None
1749 ),
1750 non_spec_query_start_loc=(non_spec_query_start_loc if has_non_spec else None),
1751 query_start_loc_p=(
1752 non_spec_query_start_loc[case.num_decodes :] - case.num_decodes
1753 if num_prefills
1754 else None
1755 ),
1756 has_initial_states_p=(
1757 torch.arange(num_prefills, device=device) % 2 == 0 if num_prefills else None
1758 ),
1759 max_prefill_query_len=max(case.prefill_query_lens, default=0),
1760 num_spec_decodes=num_spec_reqs,
1761 )
1762 return metadata, token_offset
1763
1764
1765 @pytest.mark.skipif(not torch.cuda.is_available(), reason="fused conv needs CUDA")
1766 @pytest.mark.parametrize("state_layout", ["SD", "DS"])
1767 @pytest.mark.parametrize(
1768 "case",
1769 [
1770 pytest.param(
1771 _ConvBatchCase(num_decodes=1, channels=512),
1772 id="decode-single",
1773 ),
1774 pytest.param(
1775 _ConvBatchCase(
1776 num_decodes=33,
1777 channels=2048,
1778 kernel_size=5,
1779 dilation=1,
1780 ),
1781 id="decode-padded",
1782 ),
1783 pytest.param(
1784 _ConvBatchCase(prefill_query_lens=(37, 0, 5, 128)),
1785 id="prefill",
1786 ),
1787 pytest.param(
1788 _ConvBatchCase(
1789 prefill_query_lens=(16, 676),
1790 channels=10240,
1791 state_index_stride=4,
1792 include_null_state=False,
1793 ),
1794 id="prefill-strided-state-indices",
1795 ),
1796 pytest.param(
1797 _ConvBatchCase(
1798 spec_query_lens=(1, 4, 4, 0),
1799 num_accepted=(1, 2, 4, 1),
1800 spec_query_len=4,
1801 graph_padding=4,
1802 ),
1803 id="spec-graph-padded",
1804 ),
1805 pytest.param(
1806 _ConvBatchCase(
1807 spec_query_lens=(3, 4),
1808 num_accepted=(2, 4),
1809 num_decodes=1,
1810 prefill_query_lens=(5,),
1811 spec_query_len=4,
1812 ),
1813 id="mixed",
1814 ),
1815 pytest.param(
1816 _ConvBatchCase(
1817 spec_query_lens=(1, 2),
1818 num_accepted=(0, 1),
1819 num_decodes=2,
1820 prefill_query_lens=(0, 8, 3),
1821 channels=640,
1822 kernel_size=3,
1823 dilation=2,
1824 spec_query_len=3,
1825 ),
1826 id="mixed-varied",
1827 ),
1828 ],
1829 )
1830 def test_fused_conv_correctness(
1831 case: _ConvBatchCase,
1832 state_layout: str,
1833 ) -> None:
1834 device = torch.device("cuda")
1835 metadata, num_real_tokens = _make_conv_metadata(case, device)
1836 if case.state_index_stride > 1:
1837 assert metadata.state_indices_tensor.stride(0) == case.state_index_stride
1838 module = Qwen4ExpPLELayer.__new__(Qwen4ExpPLELayer)
1839 nn.Module.__init__(module)
1840 module.conv_state_len = (case.kernel_size - 1) * case.dilation
1841 module.short_conv_dilation = case.dilation
1842
1843 rng, state_reference, conv_state, weights = _make_conv_case(
1844 device,
1845 seed=num_real_tokens + case.channels,
1846 channels=case.channels,
1847 kernel_size=case.kernel_size,
1848 dilation=case.dilation,
1849 state_layout=state_layout,
1850 spec_query_len=case.spec_query_len,
1851 )
1852 inputs = torch.randn(
1853 metadata.num_actual_tokens,
1854 case.channels,
1855 device=device,
1856 dtype=torch.bfloat16,
1857 generator=rng,
1858 )
1859 residual = torch.randn(
1860 inputs.shape,
1861 device=device,
1862 dtype=torch.bfloat16,
1863 generator=rng,
1864 )
1865 outer_residual = torch.randn(
1866 inputs.shape,
1867 device=device,
1868 dtype=torch.bfloat16,
1869 generator=rng,
1870 )
1871 null_state = conv_state[NULL_BLOCK_ID].clone()
1872 residual_kernel = residual.clone()
1873 residual_reference = residual.clone()
1874
1875 module._short_conv_dilated_dispatch(
1876 inputs=inputs,
1877 residual=residual_kernel,
1878 outer_residual=outer_residual,
1879 metadata=metadata,
1880 conv_state=conv_state,
1881 conv_weights=weights,
1882 )
1883 _short_conv_dilated_dispatch_pytorch(
1884 inputs=inputs,
1885 residual=residual_reference,
1886 metadata=metadata,
1887 conv_state=state_reference,
1888 conv_weights=weights,
1889 conv_state_len=module.conv_state_len,
1890 dilation=module.short_conv_dilation,
1891 )
1892 residual_reference = outer_residual + residual_reference
1893
1894 assert torch.equal(
1895 residual_kernel[:num_real_tokens], residual_reference[:num_real_tokens]
1896 )
1897 assert torch.equal(conv_state, state_reference)
1898 assert torch.equal(conv_state[NULL_BLOCK_ID], null_state)
1899 if case.graph_padding:
1900 assert torch.equal(
1901 residual_kernel[num_real_tokens:],
1902 (outer_residual + residual)[num_real_tokens:],
1903 )
1904
1905
1906 @pytest.mark.skipif(not torch.cuda.is_available(), reason="fused gate needs CUDA")
1907 @pytest.mark.parametrize(("num_tokens", "strided_kv"), [(1, False), (64, True)])
1908 def test_fused_gate_correctness(num_tokens: int, strided_kv: bool) -> None:
1909 import math
1910
1911 from vllm.models.qwen4_exp.nvidia.ops.ple import ple_gate
1912
1913 device = torch.device("cuda")
1914 hc, h = 4, 2560
1915 generator = torch.Generator(device=device).manual_seed(num_tokens)
1916 kv = torch.randn(
1917 num_tokens,
1918 hc * h + h,
1919 device=device,
1920 dtype=torch.bfloat16,
1921 generator=generator,
1922 )
1923 key = kv[:, : hc * h]
1924 value = kv[:, hc * h :]
1925 if not strided_kv:
1926 key = key.contiguous()
1927 value = value.contiguous()
1928 hidden = torch.randn(
1929 num_tokens,
1930 hc * h,
1931 device=device,
1932 dtype=torch.bfloat16,
1933 generator=generator,
1934 )
1935
1936 def make_norm_weight() -> torch.Tensor:
1937 weight = torch.empty(hc * h, device=device, dtype=torch.bfloat16)
1938 return weight.normal_(mean=-0.1, std=0.1, generator=generator)
1939
1940 norm_key = make_norm_weight()
1941 norm_query = make_norm_weight()
1942 norm_conv = make_norm_weight()
1943
1944 def grouped_norm(inputs: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
1945 grouped = inputs.float().unflatten(-1, (hc, h))
1946 var = grouped.square().mean(dim=-1, keepdim=True)
1947 normalized = grouped * torch.rsqrt(var + 1e-6)
1948 return (normalized.flatten(-2) * (1.0 + weight.float())).to(inputs.dtype)
1949
1950 gated, normed = ple_gate(
1951 key,
1952 value,
1953 hidden,
1954 norm_key,
1955 norm_query,
1956 norm_conv,
1957 1e-6,
1958 )
1959 key_normalized = grouped_norm(key, norm_key).reshape(num_tokens, hc, h)
1960 query_normalized = grouped_norm(hidden, norm_query).reshape(num_tokens, hc, h)
1961 dot = (key_normalized * query_normalized).sum(dim=-1, keepdim=True)
1962 dot = (dot / math.sqrt(h)).to(torch.bfloat16)
1963 gate = torch.sigmoid(dot.sign() * dot.abs().clamp_min(1e-6).sqrt()).to(
1964 torch.bfloat16
1965 )
1966 expected_gated = (gate * value.unsqueeze(-2)).flatten(-2)
1967 expected_normed = grouped_norm(expected_gated, norm_conv)
1968 assert torch.equal(gated, expected_gated)
1969 torch.testing.assert_close(normed, expected_normed, atol=1e-2, rtol=1e-2)
1970
1971
1972 def _build_amd_ngram_embedding(
1973 monkeypatch: pytest.MonkeyPatch,
1974 *,
1975 device: str,
1976 cpu_offload: bool,
1977 fp8_checkpoint: bool,
1978 ) -> tuple[amd_ple_layer.Qwen4ExpNGramEmbedding, torch.Tensor]:
1979 """Construct and load a small AMD embedding through its checkpoint loader."""
1980 _mock_etp_group(monkeypatch)
1981 monkeypatch.setattr(embedding_module, "get_tensor_model_parallel_rank", lambda: 0)
1982 monkeypatch.setattr(
1983 embedding_module, "get_tensor_model_parallel_world_size", lambda: 1
1984 )
1985 monkeypatch.setattr(parameter_module, "get_tensor_model_parallel_rank", lambda: 0)
1986 monkeypatch.setattr(
1987 parameter_module, "get_tensor_model_parallel_world_size", lambda: 1
1988 )
1989
1990 monkeypatch.setattr(
1991 amd_ple_layer,
1992 "get_current_vllm_config",
1993 lambda: SimpleNamespace(engram_config=SimpleNamespace(cpu_offload=cpu_offload)),
1994 )
1995 config = Qwen4ExpTextConfig(
1996 ngram_size=3,
1997 heads_per_ngram=1,
1998 ngram_vocab_size_base=5,
1999 make_ngram_vocab_size_divisible_by=8,
2000 split_ngram_parts=2,
2001 ple_embed_dim=6,
2002 eos_token_id=0,
2003 vocab_size=64,
2004 )
2005 with torch.device(device):
2006 module = amd_ple_layer.Qwen4ExpNGramEmbedding(
2007 config,
2008 embedding_dim=config.ple_embed_dim,
2009 ple_dense_layer_id=0,
2010 max_total_tokens=4,
2011 max_num_reqs=2,
2012 prefix="test.ple_embedding",
2013 layer_name="test.ple",
2014 quant_config=(
2015 Fp8Config(is_checkpoint_fp8_serialized=True) if fp8_checkpoint else None
2016 ),
2017 params_dtype=torch.bfloat16,
2018 )
2019 # Only the outer owner is a stub; the embedding constructor and loader run.
2020 layer = Qwen4ExpPLELayerAMD.__new__(Qwen4ExpPLELayerAMD)
2021 nn.Module.__init__(layer)
2022 layer.ple_embedding = module
2023
2024 monkeypatch.setattr(
2025 amd_ple_layer,
2026 "get_forward_context",
2027 lambda: SimpleNamespace(no_compile_layers={module.layer_name: layer}),
2028 )
2029
2030 embedding = module.ngram_embedding
2031 loaded_weight = (
2032 torch.arange(embedding.org_vocab_size * embedding.embedding_dim)
2033 .reshape(embedding.org_vocab_size, embedding.embedding_dim)
2034 .to(embedding.weight.dtype)
2035 )
2036 shard_size = (embedding.org_vocab_size + 1) // 2
2037 weights = [
2038 (f"ngram_embedding.shard_{i}.weight", shard)
2039 for i, shard in enumerate(loaded_weight.split(shard_size))
2040 ]
2041 expected_loaded = {"ngram_embedding.weight"}
2042 if fp8_checkpoint:
2043 weights.append(("ngram_embedding.weight_scale", torch.tensor([0.25])))
2044 expected_loaded.add("ngram_embedding.weight_scale")
2045 assert module.load_weights(weights) == expected_loaded
2046 embedding.quant_method.process_weights_after_loading(embedding)
2047 return module, loaded_weight
2048
2049
2050 @pytest.mark.parametrize(
2051 "device,cpu_offload",
2052 [
2053 pytest.param("cpu", False, id="cpu-resident"),
2054 pytest.param(
2055 "cuda:0",
2056 False,
2057 id="gpu-resident",
2058 marks=pytest.mark.skipif(
2059 not torch.cuda.is_available(), reason="requires CUDA or ROCm"
2060 ),
2061 ),
2062 pytest.param(
2063 "cuda:0",
2064 True,
2065 id="gpu-offloaded",
2066 marks=pytest.mark.skipif(
2067 not torch.cuda.is_available(), reason="requires CUDA or ROCm"
2068 ),
2069 ),
2070 ],
2071 )
2072 def test_amd_fp8_embedding_loads_checkpoint_shards_and_global_scale(
2073 monkeypatch: pytest.MonkeyPatch,
2074 device: str,
2075 cpu_offload: bool,
2076 ) -> None:
2077 """AMD checkpoint loading must accept and apply the global FP8 scale."""
2078 module, loaded_weight = _build_amd_ngram_embedding(
2079 monkeypatch, device=device, cpu_offload=cpu_offload, fp8_checkpoint=True
2080 )
2081 embedding = module.ngram_embedding
2082 assert embedding.weight.dtype == torch.float8_e4m3fn
2083 assert embedding.weight.device == torch.device("cpu" if cpu_offload else device)
2084 if cpu_offload:
2085 assert embedding.weight.is_pinned()
2086 assert embedding.weight_scale.device == torch.device(device)
2087 torch.testing.assert_close(embedding.weight.float().cpu(), loaded_weight.float())
2088 dequantized = embedding.dequantize(embedding.weight.to(device), torch.bfloat16)
2089 torch.testing.assert_close(
2090 dequantized.cpu(), loaded_weight.to(torch.bfloat16) * 0.25, rtol=0, atol=0
2091 )
2092
2093
2094 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA or ROCm")
2095 def test_amd_pinned_embedding_prefetch_machinery_is_lazy(
2096 monkeypatch: pytest.MonkeyPatch,
2097 ) -> None:
2098 """The pinned embedding must not allocate the prefetch stream/buffer in init.
2099
2100 Paths that never prefetch (AMD uses the synchronous pinned-lookup op)
2101 keep the stream/buffer unallocated for the process lifetime. NVIDIA's
2102 first ``start_prefetch`` — the eager profile run, always before cudagraph
2103 capture — allocates them lazily.
2104 """
2105 module, _ = _build_amd_ngram_embedding(
2106 monkeypatch, device="cuda:0", cpu_offload=True, fp8_checkpoint=False
2107 )
2108 embedding = module.ngram_embedding
2109 assert embedding._prefetch_buffer is None
2110 assert embedding._prefetch_stream is None
2111
2112
2113 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA or ROCm")
2114 @pytest.mark.parametrize("fp8_checkpoint", [False, True], ids=["bf16", "fp8"])
2115 def test_amd_pinned_embedding_output_written_under_compile(
2116 monkeypatch: pytest.MonkeyPatch,
2117 fp8_checkpoint: bool,
2118 ) -> None:
2119 """The AMD pinned lookup op must survive aot_autograd/Inductor.
2120
2121 The op writes via the declared-mutated ``output``. Without
2122 ``mutates_args=["output"]`` the call is dead-code-eliminated, the UVA lookup
2123 never runs, and the returned ``output`` stays untouched.
2124 """
2125 module, loaded_weight = _build_amd_ngram_embedding(
2126 monkeypatch, device="cuda:0", cpu_offload=True, fp8_checkpoint=fp8_checkpoint
2127 )
2128
2129 ngram_ids = torch.tensor([[3, 0], [1, 2]], device="cuda:0")
2130 output = torch.zeros(
2131 ngram_ids.shape[0],
2132 module.embedding_dim,
2133 dtype=module.ngram_embedding.weight.dtype,
2134 device="cuda:0",
2135 )
2136
2137 def lookup(ids: torch.Tensor, out: torch.Tensor) -> torch.Tensor:
2138 torch.ops.vllm.qwen4_exp_amd_ple_ngram_embedding_pinned(
2139 ids, out, module.layer_name
2140 )
2141 return out
2142
2143 torch.compile(lookup, fullgraph=True)(ngram_ids, output)
2144 torch.cuda.current_stream().synchronize()
2145
2146 expected = loaded_weight[ngram_ids.cpu()].to(device="cuda:0").flatten(-2)
2147 torch.testing.assert_close(output.float(), expected.float(), rtol=0, atol=0)
2148
2149
2150 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA or ROCm")
2151 @pytest.mark.parametrize("fp8_checkpoint", [False, True], ids=["bf16", "fp8"])
2152 def test_amd_pinned_embedding_output_written_under_cudagraph_capture(
2153 monkeypatch: pytest.MonkeyPatch,
2154 fp8_checkpoint: bool,
2155 ) -> None:
2156 """The AMD pinned lookup op must be capture-safe.
2157
2158 It launches a single kernel on the current stream, so a cudagraph capture
2159 reproduces the lookup into ``output`` without cross-stream work.
2160 """
2161 module, loaded_weight = _build_amd_ngram_embedding(
2162 monkeypatch, device="cuda:0", cpu_offload=True, fp8_checkpoint=fp8_checkpoint
2163 )
2164
2165 ngram_ids = torch.tensor([[3, 0], [1, 2]], device="cuda:0")
2166 output = torch.zeros(
2167 ngram_ids.shape[0],
2168 module.embedding_dim,
2169 dtype=module.ngram_embedding.weight.dtype,
2170 device="cuda:0",
2171 )
2172
2173 def lookup(ids: torch.Tensor, out: torch.Tensor) -> torch.Tensor:
2174 torch.ops.vllm.qwen4_exp_amd_ple_ngram_embedding_pinned(
2175 ids, out, module.layer_name
2176 )
2177 return out
2178
2179 graph = torch.cuda.CUDAGraph()
2180 warmup_stream = torch.cuda.Stream(device="cuda:0")
2181 warmup_stream.wait_stream(torch.cuda.current_stream())
2182 with torch.cuda.stream(warmup_stream):
2183 for _ in range(3):
2184 lookup(ngram_ids, output)
2185 torch.cuda.current_stream().wait_stream(warmup_stream)
2186
2187 with torch.cuda.graph(graph):
2188 lookup(ngram_ids, output)
2189 for _ in range(2):
2190 ngram_ids.copy_(ngram_ids.flip(-1) + 3)
2191 output.fill_(float("nan"))
2192 graph.replay()
2193 torch.cuda.current_stream().synchronize()
2194
2195 expected = loaded_weight[ngram_ids.cpu()].to(device="cuda:0").flatten(-2)
2196 torch.testing.assert_close(output.float(), expected.float(), rtol=0, atol=0)