[ROCm][Perf] Allocate the pinned PLE prefetch buffer lazily (#58797)

Signed-off-by: Mathew Odden <modden@redhat.com> Co-authored-by: opencode+glm-5.3+vllm <opencode+glm-5.3+vllm@example.com>

Mathew Odden committed Sep 30, 2026 at 04:43 UTC 91dab0eb7f42efd8580086935a7c0768a594f575
2 files changed +155 -15
tests/models/qwen4_exp/test_ple.py
+106
@@ -609,6 +609,32 @@ def test_pinned_embedding_forward_finalizes_prefetched_output(
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)
@@ -723,6 +749,67 @@ def test_ple_pinned_embedding_loads_on_cpu_and_looks_up_through_uva(
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(
@@ -2004,6 +2091,25 @@ def test_amd_fp8_embedding_loads_checkpoint_shards_and_global_scale(
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(
vllm/models/qwen4_exp/common/ngram_embedding.py
+49 -15
@@ -7,6 +7,7 @@ n-gram embedding table can be kept in pinned host memory and looked up through
7 Unified Virtual Addressing on any CUDA-alike platform.
8 """
9
10 +import threading
11 from abc import ABC, abstractmethod
12 from typing import ClassVar
13
@@ -415,14 +416,11 @@ class Qwen4ExpPLEPinnedHostEmbedding(Qwen4ExpPLEEmbedding):
416 )
417 self._uva_weight = get_accelerator_view_from_cpu_tensor(self.weight)
418 self._block_d = triton.next_power_of_2(self.embedding_dim)
418 - self._prefetch_stream = torch.cuda.Stream(device=self._uva_weight.device)
419 - self._prefetch_buffer = torch.empty(
420 - max_total_tokens * self.etp_data_parallel_size,
421 - num_ngram_heads,
422 - self.embedding_dim,
423 - dtype=self.weight.dtype,
424 - device=self._uva_weight.device,
425 - )
419 + self._prefetch_stream: torch.cuda.Stream | None = None
420 + self._prefetch_buffer: torch.Tensor | None = None
421 + self._prefetch_alloc_lock = threading.Lock()
422 + self._prefetch_rows = max_total_tokens * self.etp_data_parallel_size
423 + self._num_ngram_heads = num_ngram_heads
424 self._output_dim = num_ngram_heads * self.embedding_dim
425
426 def allocate_embedding_weight(
@@ -506,10 +504,42 @@ class Qwen4ExpPLEPinnedHostEmbedding(Qwen4ExpPLEEmbedding):
504 ngram_ids: torch.Tensor,
505 ) -> None:
506 """Gather ETP IDs and launch their UVA lookup on the side stream."""
507 + buffer = self._prefetch_buffer
508 + if buffer is None:
509 + # First use allocates. The eager profile run always precedes
510 + # cudagraph capture, so allocation never happens mid-capture;
511 + # the lock keeps concurrent first callers from tearing the
512 + # stream/buffer pair.
513 + with self._prefetch_alloc_lock:
514 + buffer = self._prefetch_buffer
515 + if buffer is None:
516 + if torch.cuda.is_current_stream_capturing():
517 + raise RuntimeError(
518 + "pinned PLE prefetch buffer must be allocated "
519 + "eagerly, before cudagraph capture"
520 + )
521 + self._prefetch_stream = torch.cuda.Stream(
522 + device=self._uva_weight.device
523 + )
524 + buffer = torch.empty(
525 + self._prefetch_rows,
526 + self._num_ngram_heads,
527 + self.embedding_dim,
528 + dtype=self.weight.dtype,
529 + device=self._uva_weight.device,
530 + )
531 + self._prefetch_buffer = buffer
532 + prefetch_stream = self._prefetch_stream
533 + if prefetch_stream is None:
534 + raise RuntimeError("pinned PLE prefetch stream was not allocated")
535 slot_size, _ = self._get_dp_gather_slot(ngram_ids.shape[0])
536 gathered_ids = self._gather_dp_ids(ngram_ids, slot_size)
511 - active_output = self._prefetch_buffer[: gathered_ids.shape[0]]
512 - prefetch_stream = self._prefetch_stream
537 + if gathered_ids.shape[0] > buffer.shape[0]:
538 + raise ValueError(
539 + f"pinned PLE prefetch buffer holds {buffer.shape[0]} rows, "
540 + f"but the batch needs {gathered_ids.shape[0]}"
541 + )
542 + active_output = buffer[: gathered_ids.shape[0]]
543 prefetch_stream.wait_stream(torch.cuda.current_stream())
544 gathered_ids.record_stream(prefetch_stream)
545 with torch.cuda.stream(prefetch_stream):
@@ -522,7 +552,10 @@ class Qwen4ExpPLEPinnedHostEmbedding(Qwen4ExpPLEEmbedding):
552 output: torch.Tensor,
553 ) -> None:
554 """Join the side stream, reduce ETP shards, and select local rows."""
525 - torch.cuda.current_stream().wait_stream(self._prefetch_stream)
555 + prefetch_stream = self._prefetch_stream
556 + if prefetch_stream is None:
557 + raise RuntimeError("pinned PLE finalize requires a prior start_prefetch")
558 + torch.cuda.current_stream().wait_stream(prefetch_stream)
559 slot_size, slot_offset = self._get_dp_gather_slot(output.shape[0])
560 active_output = prefetch_output[: slot_size * self.etp_data_parallel_size]
561 embeddings = self._reduce_etp_embeddings(active_output)
@@ -535,8 +568,9 @@ class Qwen4ExpPLEPinnedHostEmbedding(Qwen4ExpPLEEmbedding):
568
569 def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
570 """Finish the pinned lookup into graph-owned output storage."""
538 - output = self._prefetch_buffer.new_empty(
539 - (hidden_states.shape[0], self._output_dim)
540 - )
541 - self._finalize_prefetch(self._prefetch_buffer, output)
571 + buffer = self._prefetch_buffer
572 + if buffer is None:
573 + raise RuntimeError("pinned PLE lookup requires a prior start_prefetch")
574 + output = buffer.new_empty((hidden_states.shape[0], self._output_dim))
575 + self._finalize_prefetch(buffer, output)
576 return output