Files
vLLM-4x-RTX-3090/patches/0031-ple_offload-carry-the-host-mapped-pull-transport-ont.patch
T
MOVZX 777e910dd0 Initial commit
Signed-off-by: MOVZX <movzx@yahoo.com>
2026-10-15 01:01:52 +07:00

7872 lines
285 KiB
Diff

From 4c793b234fff0b909893dfdbcccd78b98d6fc4e0 Mon Sep 17 00:00:00 2001
From: halt95 <halt95@users.noreply.github.com>
Date: Wed, 23 Sep 2026 00:01:58 -0400
Subject: [PATCH 031/103] ple_offload: carry the host-mapped pull transport
onto v0.30.0
Restores the fork's PLE offload package (not upstream) and its pull
transport: the offload process holds no CUDA context; the captured op waits on
a device-mapped host flag and copies host->GPU on the model stream; publication
is fenced; releases are synchronous across dummy/capture work; failures close
the rank. Includes the test-only fault-injection cuts for the transport.
---
probe/faultcut_gate.py | 46 +
tests/v1/ple_offload/test_correspondence.py | 197 +++
tests/v1/ple_offload/test_fatal_rpc.py | 145 ++
.../test_fault_bootstrap_disarmed.py | 80 +
.../ple_offload/test_fault_injector_basics.py | 260 ++++
.../ple_offload/test_fault_injector_gates.py | 468 ++++++
.../v1/ple_offload/test_fault_wire_frames.py | 96 ++
.../v1/ple_offload/test_ipc_socket_unlink.py | 133 ++
tests/v1/ple_offload/test_pending_rpc.py | 81 +
.../v1/ple_offload/test_progress_deadline.py | 216 +++
tests/v1/ple_offload/test_shm_unlink.py | 397 +++++
tests/v1/ple_offload/test_shutdown_order.py | 171 +++
.../ple_offload/test_spawn_bootstrap_cuda.py | 352 +++++
tests/v1/ple_offload/test_test_fault.py | 293 ++++
.../ple_offload/test_transport_bounded_io.py | 343 +++++
.../test_transport_fatal_helper.py | 387 +++++
.../layers/ple_offload_layer.py | 433 ++++++
vllm/v1/executor/multiproc_executor.py | 31 +-
vllm/v1/ple_offload/__init__.py | 2 +
vllm/v1/ple_offload/connector.py | 855 +++++++++++
vllm/v1/ple_offload/fatal.py | 153 ++
vllm/v1/ple_offload/progress.py | 314 ++++
vllm/v1/ple_offload/protocol.py | 50 +
vllm/v1/ple_offload/worker.py | 1298 +++++++++++++++++
vllm/v1/worker/gpu/cudagraph_utils.py | 140 +-
vllm/v1/worker/gpu/model_runner.py | 140 ++
vllm/v1/worker/gpu_worker.py | 145 ++
vllm_ple_offload_bootstrap/__init__.py | 210 +++
vllm_ple_offload_bootstrap/__main__.py | 7 +
29 files changed, 7429 insertions(+), 14 deletions(-)
create mode 100755 probe/faultcut_gate.py
create mode 100644 tests/v1/ple_offload/test_correspondence.py
create mode 100644 tests/v1/ple_offload/test_fatal_rpc.py
create mode 100644 tests/v1/ple_offload/test_fault_bootstrap_disarmed.py
create mode 100644 tests/v1/ple_offload/test_fault_injector_basics.py
create mode 100644 tests/v1/ple_offload/test_fault_injector_gates.py
create mode 100644 tests/v1/ple_offload/test_fault_wire_frames.py
create mode 100644 tests/v1/ple_offload/test_ipc_socket_unlink.py
create mode 100644 tests/v1/ple_offload/test_pending_rpc.py
create mode 100644 tests/v1/ple_offload/test_progress_deadline.py
create mode 100644 tests/v1/ple_offload/test_shm_unlink.py
create mode 100644 tests/v1/ple_offload/test_shutdown_order.py
create mode 100644 tests/v1/ple_offload/test_spawn_bootstrap_cuda.py
create mode 100644 tests/v1/ple_offload/test_test_fault.py
create mode 100644 tests/v1/ple_offload/test_transport_bounded_io.py
create mode 100644 tests/v1/ple_offload/test_transport_fatal_helper.py
create mode 100644 vllm/model_executor/layers/ple_offload_layer.py
create mode 100644 vllm/v1/ple_offload/__init__.py
create mode 100644 vllm/v1/ple_offload/connector.py
create mode 100644 vllm/v1/ple_offload/fatal.py
create mode 100644 vllm/v1/ple_offload/progress.py
create mode 100644 vllm/v1/ple_offload/protocol.py
create mode 100644 vllm/v1/ple_offload/worker.py
create mode 100644 vllm_ple_offload_bootstrap/__init__.py
create mode 100644 vllm_ple_offload_bootstrap/__main__.py
diff --git a/probe/faultcut_gate.py b/probe/faultcut_gate.py
new file mode 100755
index 0000000..3eb74b5
--- /dev/null
+++ b/probe/faultcut_gate.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""CPU-testable validation for external gates excluded from GPU campaigns."""
+
+from collections.abc import Callable
+
+
+def validate_partial_send_gate(
+ *,
+ pid: int,
+ expected_start: int,
+ observed_start: int,
+ alive: bool,
+ bytes_sent: int,
+ frame_size: int,
+ completed: bool,
+ child_not_reading: bool,
+) -> tuple[bool, str]:
+ if pid <= 1 or not alive:
+ return False, "target is stale or not alive"
+ if expected_start <= 0 or observed_start != expected_start:
+ return False, "target PID was reused"
+ if frame_size <= 0 or not 0 < bytes_sent < frame_size:
+ return False, "send is zero-byte or complete"
+ if completed:
+ return False, "send already completed"
+ if not child_not_reading:
+ return False, "child non-reading state was not acknowledged"
+ return True, "valid partial send"
+
+
+def deliver_checked_signal(
+ *,
+ pid: int,
+ expected_start: int,
+ read_start: Callable[[int], int],
+ alive: Callable[[int], bool],
+ send: Callable[[int, int], bool],
+ signal_number: int,
+) -> tuple[bool, str]:
+ if pid <= 1 or not alive(pid):
+ return False, "target is stale or not alive"
+ if read_start(pid) != expected_start:
+ return False, "target PID was reused"
+ if not send(pid, signal_number):
+ return False, "signal delivery failed"
+ return True, "signal delivered"
diff --git a/tests/v1/ple_offload/test_correspondence.py b/tests/v1/ple_offload/test_correspondence.py
new file mode 100644
index 0000000..e8d8b9e
--- /dev/null
+++ b/tests/v1/ple_offload/test_correspondence.py
@@ -0,0 +1,197 @@
+# SPDX-License-Identifier: Apache-2.0
+
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+import torch
+
+from vllm.config import CUDAGraphMode
+from vllm.model_executor.layers.ple_offload_layer import (
+ _record_ple_copy_geometry,
+)
+from vllm.v1.ple_offload.connector import PleOffloadConnector
+from vllm.v1.ple_offload.protocol import PleOffloadRequest
+from vllm.v1.ple_offload.worker import (
+ PleOffloadInputBuffers,
+ PleOffloadRunner,
+)
+from vllm.v1.worker.gpu.cudagraph_utils import (
+ BatchExecutionDescriptor,
+ CudaGraphManager,
+)
+from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import (
+ SpeculatorCudaGraphManager,
+)
+from vllm.v1.worker.gpu.model_runner import GPUModelRunner
+
+
+class _ConnectorForValidation:
+ _input_ids_buf = torch.empty(4, dtype=torch.int32)
+ _query_start_loc_buf = torch.empty(3, dtype=torch.int32)
+ _rowdump_n = None
+
+ def _signal_dummy_outputs_impl(self, num_tokens: int) -> None:
+ raise AssertionError("must fail before dummy publication")
+
+ def _launch(self, num_reqs: int, num_tokens: int) -> None:
+ raise AssertionError("must fail before request publication")
+
+
+def test_request_consumer_count_mismatch_precedes_publication() -> None:
+ connector = _ConnectorForValidation()
+ with pytest.raises(RuntimeError, match="request/consumer"):
+ PleOffloadConnector._prepare_forward_impl(
+ connector,
+ 1,
+ 3,
+ False,
+ consumer_num_tokens=4,
+ )
+
+
+def _runner(query_start_loc: list[int]) -> PleOffloadRunner:
+ runner = PleOffloadRunner.__new__(PleOffloadRunner)
+ flag = torch.zeros(1, dtype=torch.int32)
+ target = SimpleNamespace(
+ host_output_buffer=torch.zeros(4, 2),
+ host_flag=flag,
+ )
+ runner._worker_targets = {0: {"ple": [target]}}
+ runner._scratch_bufs = {0: {"ple": torch.empty(4, 2)}}
+ query = torch.zeros(3, dtype=torch.int32)
+ query[: len(query_start_loc)] = torch.tensor(
+ query_start_loc, dtype=torch.int32
+ )
+ runner._input_bufs = {
+ 0: PleOffloadInputBuffers(
+ input_ids_buf=torch.zeros(4, dtype=torch.int32),
+ query_start_loc_buf=query,
+ ngram_context_buf=torch.zeros(2, 1, dtype=torch.int32),
+ )
+ }
+ return runner
+
+
+@pytest.mark.parametrize(
+ ("ple_request", "query_start_loc", "match"),
+ [
+ (PleOffloadRequest(dp_rank=0, num_tokens=3, num_reqs=2), [0, 2, 1],
+ "query_start_loc"),
+ (PleOffloadRequest(dp_rank=0, num_tokens=5, num_reqs=1), [0, 1],
+ "num_tokens"),
+ (PleOffloadRequest(dp_rank=0, num_tokens=1, num_reqs=3), [0, 1, 1],
+ "num_reqs"),
+ (PleOffloadRequest(dp_rank=9, num_tokens=1, num_reqs=1), [0, 1],
+ "dp_rank=9"),
+ ],
+)
+def test_invalid_request_fails_before_publication(
+ ple_request: PleOffloadRequest,
+ query_start_loc: list[int],
+ match: str,
+) -> None:
+ runner = _runner(query_start_loc)
+ flags = [
+ target.host_flag
+ for layers in runner._worker_targets.values()
+ for targets in layers.values()
+ for target in targets
+ ]
+ with pytest.raises(RuntimeError, match=match):
+ runner._validate_requests_before_publication([ple_request])
+ assert all(int(flag[0]) == 0 for flag in flags)
+
+
+class _SelectedGraphManager:
+ def ple_copy_rows(self, desc: BatchExecutionDescriptor) -> int:
+ return 12
+
+
+class _PublicationGuard:
+ called = False
+
+ def prepare_forward(self, *args: Any, **kwargs: Any) -> None:
+ self.called = True
+ raise AssertionError("must fail before connector publication")
+
+
+def test_selected_full_graph_copy_count_precedes_publication() -> None:
+ runner = GPUModelRunner.__new__(GPUModelRunner)
+ connector = _PublicationGuard()
+ runner._ple_offload_connector = connector
+ runner.cudagraph_manager = _SelectedGraphManager()
+ input_batch = SimpleNamespace(
+ num_reqs=1,
+ num_tokens_after_padding=8,
+ input_ids=torch.empty(8, dtype=torch.int32),
+ )
+ desc = BatchExecutionDescriptor(
+ cg_mode=CUDAGraphMode.FULL,
+ num_tokens=8,
+ num_reqs=1,
+ )
+
+ with pytest.raises(RuntimeError, match="selected-forward"):
+ GPUModelRunner._prepare_ple_forward(
+ runner, input_batch, desc, dummy_run=False
+ )
+ assert connector.called is False
+
+
+def test_production_capture_callback_rejects_actual_ple_copy_mismatch() -> None:
+ manager = CudaGraphManager.__new__(CudaGraphManager)
+ manager._ple_full_copy_geometry = {}
+ manager._expected_ple_copy_layers = ("ple",)
+ manager._allow_ple_full_graph = True
+ desc = BatchExecutionDescriptor(
+ cg_mode=CUDAGraphMode.FULL,
+ num_tokens=8,
+ num_reqs=1,
+ )
+
+ with pytest.raises(RuntimeError, match="actual PLE copy geometry"):
+ # This is the production capture scope. The injected observation uses
+ # the same hook called by the real wait-copy custom-op implementation.
+ with manager.capture_ple_copy_geometry(desc):
+ _record_ple_copy_geometry("ple", 12, 16)
+ assert desc not in manager._ple_full_copy_geometry
+
+
+def test_speculator_full_capture_refuses_observed_ple_op() -> None:
+ manager = SpeculatorCudaGraphManager.__new__(SpeculatorCudaGraphManager)
+ manager._ple_full_copy_geometry = {}
+ manager._expected_ple_copy_layers = ()
+ manager._allow_ple_full_graph = False
+ desc = BatchExecutionDescriptor(
+ cg_mode=CUDAGraphMode.FULL,
+ num_tokens=8,
+ num_reqs=1,
+ )
+
+ with pytest.raises(RuntimeError, match="speculator/draft"):
+ with manager.capture_ple_copy_geometry(desc):
+ _record_ple_copy_geometry("unexpected-draft-ple", 8, 16)
+ assert desc not in manager._ple_full_copy_geometry
+
+
+def test_eager_correspondence_uses_actual_padded_input_view() -> None:
+ runner = GPUModelRunner.__new__(GPUModelRunner)
+ connector = _PublicationGuard()
+ runner._ple_offload_connector = connector
+ runner.cudagraph_manager = None
+ input_batch = SimpleNamespace(
+ num_reqs=1,
+ num_tokens_after_padding=8,
+ input_ids=torch.empty(12, dtype=torch.int32),
+ )
+ desc = BatchExecutionDescriptor(
+ cg_mode=CUDAGraphMode.NONE,
+ num_tokens=8,
+ num_reqs=1,
+ )
+ with pytest.raises(RuntimeError, match="selected-forward"):
+ GPUModelRunner._prepare_ple_forward(
+ runner, input_batch, desc, dummy_run=False
+ )
+ assert connector.called is False
diff --git a/tests/v1/ple_offload/test_fatal_rpc.py b/tests/v1/ple_offload/test_fatal_rpc.py
new file mode 100644
index 0000000..1c86f9b
--- /dev/null
+++ b/tests/v1/ple_offload/test_fatal_rpc.py
@@ -0,0 +1,145 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import multiprocessing
+from typing import Any
+
+import pytest
+
+from vllm.v1.ple_offload.connector import PleOffloadConnector
+from vllm.v1.ple_offload.fatal import (
+ fail_ple_execute_model,
+ ple_fail_closed,
+)
+
+
+class _Progress:
+ sequence = 7
+
+ def begin(self, **_: Any) -> int:
+ return self.sequence
+
+ def complete(self) -> None:
+ return
+
+
+class _RaisingProgress(_Progress):
+ def begin(self, **_: Any) -> int:
+ raise RuntimeError("begin injection")
+
+
+class _InjectedConnector:
+ rank = 3
+ tp_rank = 1
+ _layers = {"ple": object()}
+ _progress = _Progress()
+ _input_ids_buf = None
+ _query_start_loc_buf = None
+
+ def __init__(self, where: str) -> None:
+ self.where = where
+ if where == "begin":
+ self._progress = _RaisingProgress()
+
+ def _fail_closed(self, where: str, error: BaseException) -> None:
+ ple_fail_closed(
+ where=where,
+ rank=self.rank,
+ layer="ple",
+ sequence=self._progress.sequence,
+ error=error,
+ )
+
+ def _prepare_forward_impl(self, *args: Any, **kwargs: Any) -> None:
+ if self.where == "prepare":
+ raise RuntimeError("prepare injection")
+
+ def _release_outputs_impl(self) -> None:
+ if self.where == "release":
+ raise RuntimeError("release injection")
+
+
+class _Runner:
+ def __init__(self, where: str) -> None:
+ self._ple_offload_connector = _InjectedConnector(where)
+ self.where = where
+
+ @fail_ple_execute_model
+ def execute_model(self) -> None:
+ connector = self._ple_offload_connector
+ PleOffloadConnector.prepare_forward(
+ connector,
+ 1,
+ 1,
+ False,
+ consumer_num_tokens=1,
+ )
+ if self.where == "forward":
+ raise RuntimeError("forward injection")
+ PleOffloadConnector.release_outputs(connector)
+
+
+def _rpc_loop(where: str, successor_count: Any) -> None:
+ runner = _Runner(where)
+ try:
+ runner.execute_model()
+ except Exception:
+ # Mirrors WorkerProc._execute_worker_rpc: this must never regain control.
+ pass
+ with successor_count.get_lock():
+ successor_count.value += 1
+
+
+@pytest.mark.parametrize("where", ["prepare", "forward", "release"])
+def test_ple_exception_kills_workerproc_style_rpc(
+ where: str,
+) -> None:
+ context = multiprocessing.get_context("spawn")
+ successor_count = context.Value("i", 0)
+ process = context.Process(
+ target=_rpc_loop,
+ args=(where, successor_count),
+ )
+ process.start()
+ # Spawned children import vLLM cold (measured 4-8 s on the reference host); the bound is
+ # about start-up, not the fatal path, which exits immediately.
+ process.join(timeout=120)
+ assert not process.is_alive()
+ assert process.exitcode not in (None, 0)
+ assert successor_count.value == 0
+
+
+def _rpc_loop_connector_only(where: str, successor_count: Any) -> None:
+ # No execute_model decorator: only the connector's own fatal wrappers can
+ # stop the RPC loop from regaining control. (The decorated test above
+ # passes on the decorator alone, so it cannot vouch for these wrappers.)
+ connector = _InjectedConnector(where)
+ try:
+ if where == "begin":
+ PleOffloadConnector.begin_forward(
+ connector, dummy_run=False, num_tokens=1
+ )
+ elif where == "prepare":
+ PleOffloadConnector.prepare_forward(
+ connector, 1, 1, False, consumer_num_tokens=1
+ )
+ else:
+ PleOffloadConnector.release_outputs(connector)
+ except Exception:
+ pass
+ with successor_count.get_lock():
+ successor_count.value += 1
+
+
+@pytest.mark.parametrize("where", ["begin", "prepare", "release"])
+def test_connector_wrappers_are_fatal_without_decorator(where: str) -> None:
+ context = multiprocessing.get_context("spawn")
+ successor_count = context.Value("i", 0)
+ process = context.Process(
+ target=_rpc_loop_connector_only,
+ args=(where, successor_count),
+ )
+ process.start()
+ process.join(timeout=120)
+ assert not process.is_alive()
+ assert process.exitcode not in (None, 0)
+ assert successor_count.value == 0
diff --git a/tests/v1/ple_offload/test_fault_bootstrap_disarmed.py b/tests/v1/ple_offload/test_fault_bootstrap_disarmed.py
new file mode 100644
index 0000000..900851a
--- /dev/null
+++ b/tests/v1/ple_offload/test_fault_bootstrap_disarmed.py
@@ -0,0 +1,80 @@
+# SPDX-License-Identifier: Apache-2.0
+"""Disarmed-path check: a disarmed PLE child writes nothing but the real READY."""
+
+import os
+import subprocess
+import sys
+from multiprocessing.connection import Connection
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+
+def test_partial_ready_writers_are_inert_unless_armed_for_that_cut(monkeypatch):
+ import vllm_ple_offload_bootstrap as bootstrap
+
+ sender = Mock(return_value=(8, 16))
+ monkeypatch.setattr(bootstrap, "_FAULT", None)
+ for cut in ("launch_partial_ready", "launch_partial_ready_header"):
+ assert bootstrap._write_and_ack_partial(99, cut, sender) is False
+ sender.assert_not_called()
+
+ other = SimpleNamespace(cut="exc_before_prepare", fires=Mock(return_value=True))
+ monkeypatch.setattr(bootstrap, "_FAULT", other)
+ assert bootstrap._write_and_ack_partial(99, "launch_partial_ready", sender) is False
+ sender.assert_not_called()
+ other.fires.assert_not_called()
+
+
+# The launch payload's config field is the protocol-0 encoding of None (b"N.").
+_CHILD = r"""
+import os, sys, types
+write_fd = int(sys.argv[1])
+os.environ.pop("VLLM_PLE_TEST_FAULT", None)
+import vllm_ple_offload_bootstrap as bootstrap
+assert bootstrap._FAULT is None
+bootstrap._recv_launch_payload = lambda fd: {
+ "vllm_config_payload": b"N.",
+ "num_workers": 1,
+ "ipc_addr": "ipc:///unused",
+}
+
+def proc_main(*, vllm_config, num_workers, ipc_addr, ready_pipe):
+ assert vllm_config is None
+ ready_pipe.send({"status": "READY", "layer_names": ["ple"]})
+ os._exit(0)
+
+fake = types.ModuleType("vllm.v1.ple_offload.worker")
+fake.PleOffloadWorker = types.SimpleNamespace(proc_main=proc_main)
+sys.modules["vllm.v1.ple_offload.worker"] = fake
+death_read, death_write = os.pipe()
+sys.argv = ["bootstrap", str(write_fd), str(death_read)]
+bootstrap.main()
+"""
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="inherited fds are POSIX-only")
+def test_disarmed_bootstrap_control_stream_is_exactly_one_ready_frame():
+ read_fd, write_fd = os.pipe()
+ env = dict(os.environ)
+ env.pop("VLLM_PLE_TEST_FAULT", None)
+ proc = subprocess.Popen(
+ [sys.executable, "-c", _CHILD, str(write_fd)],
+ pass_fds=(write_fd,),
+ env=env,
+ )
+ os.close(write_fd)
+ reader = Connection(read_fd)
+ try:
+ assert proc.wait(timeout=120) == 0
+ assert reader.poll(5), "no READY frame on the control stream"
+ assert reader.recv() == {"status": "READY", "layer_names": ["ple"]}
+ # Nothing may follow (or precede, which would have broken the frame).
+ with pytest.raises(EOFError):
+ reader.recv_bytes()
+ finally:
+ reader.close()
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait(timeout=120)
diff --git a/tests/v1/ple_offload/test_fault_injector_basics.py b/tests/v1/ple_offload/test_fault_injector_basics.py
new file mode 100644
index 0000000..0a9fd23
--- /dev/null
+++ b/tests/v1/ple_offload/test_fault_injector_basics.py
@@ -0,0 +1,260 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+import os
+import queue
+import subprocess
+import sys
+import threading
+from collections import deque
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+import vllm_ple_test_fault as test_fault
+
+
+def _spec(monkeypatch, tmp_path, value):
+ monkeypatch.setenv("VLLM_PLE_TEST_FAULT_LOG", str(tmp_path / "fault.log"))
+ spec = test_fault._parse_fault_spec(value)
+ assert spec is not None
+ return spec
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "cut=park_before_publish,ordinal=1,tp=0",
+ "cut=launch_silent_ready,ordinal=1,pp=0",
+ "cut=drop_request,ordinal=1,tp=1,pp=0",
+ "cut=exc_after_prepare,ordinal=1,tp=0,pp=1",
+ ],
+)
+def test_impossible_role_selectors_are_rejected(monkeypatch, tmp_path, value):
+ monkeypatch.setenv("VLLM_PLE_TEST_FAULT_LOG", str(tmp_path / "fault.log"))
+ with pytest.raises(ValueError):
+ test_fault._parse_fault_spec(value)
+
+
+def test_armed_import_requires_dedicated_log(tmp_path):
+ env = dict(os.environ)
+ env["VLLM_PLE_TEST_FAULT"] = (
+ "cut=exc_before_prepare,ordinal=1,rank=any"
+ )
+ env.pop("VLLM_PLE_TEST_FAULT_LOG", None)
+ proc = subprocess.run(
+ [sys.executable, "-c", "import vllm_ple_test_fault"],
+ env=env,
+ text=True,
+ capture_output=True,
+ timeout=120,
+ )
+ assert proc.returncode != 0
+ assert "VLLM_PLE_TEST_FAULT_LOG is required" in proc.stderr
+
+
+def test_failed_marker_write_suppresses_action(monkeypatch, tmp_path):
+ spec = _spec(
+ monkeypatch,
+ tmp_path,
+ "cut=exc_after_prepare,ordinal=1,tp=0,pp=0",
+ )
+ monkeypatch.setattr(test_fault.os, "write", Mock(side_effect=BlockingIOError))
+ assert not spec.fires(
+ "exc_after_prepare",
+ role="gpu",
+ unit="runner-forward",
+ eligible=True,
+ tp_rank=0,
+ pp_rank=0,
+ dp_rank=0,
+ transaction_id="sequence:1",
+ )
+ assert spec._state.diagnostic_failed
+ assert not spec._state.fired
+
+
+@pytest.mark.parametrize("order", ["bootstrap-first", "package-first"])
+@pytest.mark.parametrize("armed", [False, True])
+def test_fresh_interpreter_import_identity(tmp_path, order, armed):
+ env = dict(os.environ)
+ env.pop("VLLM_PLE_TEST_FAULT", None)
+ env.pop("VLLM_PLE_TEST_FAULT_LOG", None)
+ if armed:
+ env["VLLM_PLE_TEST_FAULT"] = (
+ "cut=launch_silent_ready,ordinal=1,rank=any"
+ )
+ env["VLLM_PLE_TEST_FAULT_LOG"] = str(tmp_path / f"{order}.log")
+ script = f"""
+import sys
+if {order!r} == "bootstrap-first":
+ import vllm_ple_offload_bootstrap as bootstrap
+ absent_before_package = "vllm_ple_test_fault" not in sys.modules
+ import vllm_ple_test_fault as package
+else:
+ import vllm_ple_test_fault as package
+ import vllm_ple_offload_bootstrap as bootstrap
+ absent_before_package = False
+assert bootstrap._FAULT is package._FAULT
+assert getattr(sys.modules["vllm_ple_test_fault"], "_FAULT") is package._FAULT
+if {armed!r}:
+ assert package._FAULT is not None
+else:
+ assert package._FAULT is None
+ if {order!r} == "bootstrap-first":
+ assert absent_before_package
+"""
+ subprocess.run(
+ [sys.executable, "-c", script],
+ env=env,
+ check=True,
+ timeout=120,
+ )
+
+
+class _IncompleteEvent:
+ def query(self):
+ return False
+
+
+class _FatalObserved(BaseException):
+ pass
+
+
+@pytest.mark.parametrize(
+ ("record_completion", "complete_submission", "expected"),
+ [
+ (False, False, "host-submission"),
+ (True, False, "gpu-completion"),
+ (True, True, "gpu-completion"),
+ ],
+)
+def test_real_progress_monitor_observed_state(
+ monkeypatch,
+ record_completion,
+ complete_submission,
+ expected,
+):
+ import vllm.v1.ple_offload.progress as progress_module
+
+ observed = []
+ reached = threading.Event()
+
+ def fatal(**kwargs):
+ observed.append(kwargs)
+ reached.set()
+ raise _FatalObserved
+
+ monkeypatch.setattr(progress_module, "ple_fail_closed", fatal)
+ monitor = progress_module.PleProgressMonitor(
+ rank=0,
+ layer_names=("ple",),
+ serving_deadline_s=0.02,
+ startup_deadline_s=0.02,
+ )
+ monitor.begin(startup=False, num_tokens=1)
+ if record_completion:
+ monitor.track_completion(_IncompleteEvent())
+ if complete_submission:
+ monitor.complete()
+ assert reached.wait(2)
+ assert expected in observed[0]["where"]
+ monitor.close()
+
+
+def test_drop_retains_event_and_next_pool_one_launch_fails_closed(
+ monkeypatch,
+ tmp_path,
+):
+ import vllm.v1.ple_offload.connector as connector_module
+
+ spec = _spec(
+ monkeypatch,
+ tmp_path,
+ "cut=drop_request,ordinal=1,tp=0,pp=0",
+ )
+ monkeypatch.setattr(connector_module, "_FAULT", spec)
+
+ connector = object.__new__(connector_module.PleOffloadConnector)
+ connector.tp_rank = 0
+ connector.pp_rank = 0
+ connector.dp_rank = 0
+ connector._progress = SimpleNamespace(sequence=7)
+ connector._dropped_events = []
+ connector._d2h_event_pool = queue.Queue(maxsize=1)
+ event = Mock()
+ connector._d2h_event_pool.put_nowait(event)
+ connector._request_queue = Mock()
+ connector._enqueue_cuda_inputs = Mock()
+
+ connector._launch(
+ num_reqs=1,
+ num_tokens=2,
+ serving=True,
+ transaction_id="sequence:7@begin:1.000000000",
+ correlation_id="sequence:7",
+ )
+ assert connector._dropped_events == [event]
+ connector._request_queue.put_nowait.assert_not_called()
+
+ with pytest.raises(
+ RuntimeError,
+ match="more requests than configured concurrent batches",
+ ):
+ connector._launch(
+ num_reqs=1,
+ num_tokens=2,
+ serving=True,
+ transaction_id="sequence:8@begin:2.000000000",
+ correlation_id="sequence:8",
+ )
+
+
+def test_async_output_cut_uses_real_sample_boundary(monkeypatch, tmp_path):
+ import vllm.v1.worker.gpu.model_runner as model_runner_module
+
+ spec = _spec(
+ monkeypatch,
+ tmp_path,
+ "cut=exc_async_output,ordinal=1,tp=0,pp=0",
+ )
+ monkeypatch.setattr(model_runner_module, "_FAULT", spec)
+
+ class FatalReached(Exception):
+ pass
+
+ connector = SimpleNamespace(
+ tp_rank=0,
+ pp_rank=0,
+ dp_rank=0,
+ completion_is_pending=Mock(return_value=True),
+ _fail_closed=Mock(side_effect=FatalReached),
+ )
+ runner = object.__new__(model_runner_module.GPUModelRunner)
+ runner._ple_offload_connector = connector
+ runner.is_last_pp_rank = False
+ runner.pp_handler = Mock()
+ runner.execute_model_state = SimpleNamespace(
+ input_batch=object(),
+ attn_metadata=None,
+ slot_mappings_by_layer=None,
+ hidden_states=None,
+ aux_hidden_states=None,
+ dp_sync=None,
+ finished_req_ids=[],
+ ec_connector_output=None,
+ routed_experts=None,
+ cudagraph_stats=None,
+ ple_serving=True,
+ ple_sequence=11,
+ ple_transaction="sequence:11@begin:1.000000000",
+ )
+
+ with pytest.raises(FatalReached):
+ runner.sample_tokens(None)
+ assert connector._fail_closed.call_args.args[0] == "sample_tokens"
+ error = connector._fail_closed.call_args.args[1]
+ assert isinstance(error, RuntimeError)
+ assert "exc_async_output" in str(error)
diff --git a/tests/v1/ple_offload/test_fault_injector_gates.py b/tests/v1/ple_offload/test_fault_injector_gates.py
new file mode 100644
index 0000000..70e0d3e
--- /dev/null
+++ b/tests/v1/ple_offload/test_fault_injector_gates.py
@@ -0,0 +1,468 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+import ast
+import contextlib
+import importlib.util
+import os
+import pickle
+import socket
+import struct
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+import vllm_ple_test_fault as test_fault
+
+
+class _SequenceMustNotBeRead:
+ @property
+ def sequence(self):
+ raise AssertionError("disarmed path read progress.sequence")
+
+ def track_completion(self, _event):
+ pass
+
+ def complete(self):
+ pass
+
+
+def _spec(monkeypatch, tmp_path, text):
+ monkeypatch.setenv("VLLM_PLE_TEST_FAULT_LOG", str(tmp_path / "fault.log"))
+ value = test_fault._parse_fault_spec(text)
+ assert value is not None
+ return value
+
+
+def test_disarmed_sender_deadline_does_not_enter_logger(monkeypatch):
+ import vllm.v1.ple_offload.worker as worker
+
+ monkeypatch.setattr(worker, "_FAULT", None)
+ monkeypatch.setattr(
+ worker.logger,
+ "info",
+ Mock(side_effect=AssertionError("logger entered")),
+ )
+ left, right = socket.socketpair()
+ try:
+ with pytest.raises(TimeoutError):
+ worker._send_framed_payload(
+ left.fileno(),
+ b"payload",
+ time.monotonic() - 1,
+ )
+ worker.logger.info.assert_not_called()
+ finally:
+ left.close()
+ right.close()
+
+
+def test_disarmed_launch_does_not_read_progress_sequence(monkeypatch):
+ import queue
+ import vllm.v1.ple_offload.connector as connector_module
+
+ monkeypatch.setattr(connector_module, "_FAULT", None)
+ connector = object.__new__(connector_module.PleOffloadConnector)
+ connector.tp_rank = 0
+ connector.pp_rank = 0
+ connector.dp_rank = 0
+ connector._progress = _SequenceMustNotBeRead()
+ connector._d2h_event_pool = queue.Queue()
+ connector._d2h_event_pool.put_nowait(Mock())
+ connector._enqueue_cuda_inputs = Mock()
+ connector._request_queue = Mock()
+
+ connector._launch(
+ 1,
+ 2,
+ serving=True,
+ transaction_id=None,
+ correlation_id=None,
+ )
+ pending = connector._request_queue.put_nowait.call_args.args[0]
+ assert pending.transaction_id is None
+ assert pending.correlation_id is None
+
+
+def test_disarmed_release_does_not_read_progress_sequence(monkeypatch):
+ import vllm.v1.ple_offload.connector as connector_module
+
+ monkeypatch.setattr(connector_module, "_FAULT", None)
+ monkeypatch.setattr(connector_module._rowdump, "enabled", lambda: False)
+ event = Mock()
+ stream = Mock()
+ monkeypatch.setattr(connector_module.torch.cuda, "Event", lambda: event)
+ monkeypatch.setattr(
+ connector_module.torch.cuda,
+ "current_stream",
+ lambda _device: stream,
+ )
+
+ connector = object.__new__(connector_module.PleOffloadConnector)
+ connector.tp_rank = 0
+ connector.pp_rank = 0
+ connector.dp_rank = 0
+ connector.device = SimpleNamespace(index=0)
+ connector._layers = {}
+ connector._after_dummy = False
+ connector._progress = _SequenceMustNotBeRead()
+
+ connector._release_outputs_impl(serving=False)
+ event.record.assert_called_once_with(stream)
+
+
+def test_sample_tokens_connector_rank_fatalizes():
+ from vllm.v1.ple_offload.fatal import fail_ple_execute_model
+
+ class Reached(Exception):
+ pass
+
+ class Runner:
+ _ple_offload_connector = SimpleNamespace(
+ _fail_closed=Mock(side_effect=Reached)
+ )
+
+ @fail_ple_execute_model
+ def sample_tokens(self):
+ raise ValueError("sample failure")
+
+ with pytest.raises(Reached):
+ Runner().sample_tokens()
+
+
+def test_sample_tokens_non_connector_rank_rethrows():
+ from vllm.v1.ple_offload.fatal import fail_ple_execute_model
+
+ class Runner:
+ _ple_offload_connector = None
+
+ @fail_ple_execute_model
+ def sample_tokens(self):
+ raise ValueError("sample failure")
+
+ with pytest.raises(ValueError, match="sample failure"):
+ Runner().sample_tokens()
+
+
+def test_profile_is_never_eligible_even_with_phase_any(
+ monkeypatch,
+ tmp_path,
+):
+ spec = _spec(
+ monkeypatch,
+ tmp_path,
+ "cut=exc_after_prepare,ordinal=1,tp=0,pp=0,phase=any",
+ )
+ assert not spec.fires(
+ "exc_after_prepare",
+ role="gpu",
+ unit="runner-forward",
+ eligible=False,
+ tp_rank=0,
+ pp_rank=0,
+ dp_rank=0,
+ transaction_id="sequence:1@begin:1.000000000",
+ correlation_id="sequence:1",
+ )
+ assert spec._state.count == 0
+
+
+def test_offload_counts_only_serving_and_preserves_correlation(
+ monkeypatch,
+ tmp_path,
+):
+ import vllm.v1.ple_offload.worker as worker
+
+ spec = _spec(
+ monkeypatch,
+ tmp_path,
+ "cut=park_before_publish,ordinal=1,rank=any",
+ )
+ monkeypatch.setattr(worker, "_FAULT", spec)
+ runner = object.__new__(worker.PleOffloadRunner)
+ runner._validate_requests_before_publication = Mock()
+ runner._worker_targets = {0: {}}
+ runner._input_bufs = {}
+ runner._scratch_bufs = {}
+ runner._layers = {}
+ runner._clamp_input_ids = False
+ runner._step = 0
+ runner._request_index = 0
+
+ request = worker.PleOffloadRequest(dp_rank=0, num_tokens=2, num_reqs=1)
+ for _ in range(3):
+ runner._handle_requests_impl(
+ [worker._ReceivedPleOffloadRequest(request, False, None, None)]
+ )
+ assert runner._request_index == 0
+
+ reached = threading.Event()
+ monkeypatch.setattr(
+ worker.threading,
+ "Event",
+ lambda: SimpleNamespace(wait=lambda: reached.set()),
+ )
+ runner._handle_requests_impl(
+ [
+ worker._ReceivedPleOffloadRequest(
+ request,
+ True,
+ "sequence:17@begin:4.000000000",
+ "sequence:17",
+ )
+ ]
+ )
+ assert reached.is_set()
+ line = (tmp_path / "fault.log").read_text()
+ assert "transaction=request:1@sequence:17@begin:4.000000000" in line
+ assert "correlation=sequence:17" in line
+
+
+def test_cut6_requires_same_incomplete_completion(monkeypatch):
+ import vllm.v1.ple_offload.progress as progress
+
+ class Event:
+ complete = False
+
+ def query(self):
+ return self.complete
+
+ monitor = progress.PleProgressMonitor(
+ rank=0,
+ layer_names=("ple",),
+ serving_deadline_s=60,
+ startup_deadline_s=60,
+ )
+ event = Event()
+ try:
+ sequence = monitor.begin(startup=False, num_tokens=1)
+ monitor.track_completion(event)
+ monitor.complete()
+ assert monitor.completion_is_pending(sequence)
+ event.complete = True
+ assert not monitor.completion_is_pending(sequence)
+ assert not monitor.completion_is_pending(sequence + 1)
+ finally:
+ monitor.close()
+
+
+def test_partial_ack_requires_complete_required_prefix(monkeypatch):
+ import vllm_ple_offload_bootstrap as bootstrap
+
+ fault = SimpleNamespace(
+ cut="launch_partial_ready_header",
+ fires=Mock(return_value=True),
+ )
+ monkeypatch.setattr(bootstrap, "_FAULT", fault)
+ assert not bootstrap._write_and_ack_partial(
+ 1,
+ "launch_partial_ready_header",
+ lambda _fd: (1, 4),
+ )
+ fault.fires.assert_not_called()
+
+ assert bootstrap._write_and_ack_partial(
+ 1,
+ "launch_partial_ready_header",
+ lambda _fd: (2, 4),
+ )
+ assert fault.fires.call_args.kwargs["bytes_written"] == 2
+ assert fault.fires.call_args.kwargs["frame_size"] == 4
+
+
+@pytest.mark.timeout(180)
+@pytest.mark.skipif(sys.platform == "win32", reason="requires POSIX pass_fds")
+@pytest.mark.parametrize(
+ ("cut", "expected_transaction"),
+ [
+ ("launch_block_reconstruction", "reducer:configuration"),
+ (
+ "launch_block_worker_import",
+ "module:vllm.v1.ple_offload.worker",
+ ),
+ ],
+)
+def test_gate_is_inside_reducer_or_loader_and_parent_death_wins(
+ tmp_path,
+ cut,
+ expected_transaction,
+):
+ gate = tmp_path / "gate"
+ os.mkfifo(gate)
+ gate_keeper = os.open(gate, os.O_RDWR | os.O_NONBLOCK)
+ control_parent, control_child = socket.socketpair()
+ death_parent, death_child = socket.socketpair()
+ fault_log = tmp_path / "fault.log"
+ env = dict(os.environ)
+ env["VLLM_PLE_TEST_FAULT"] = f"cut={cut},ordinal=1,rank=any"
+ env["VLLM_PLE_TEST_FAULT_LOG"] = str(fault_log)
+ env["VLLM_PLE_TEST_FAULT_GATE"] = str(gate)
+
+ config_payload = pickle.dumps(None, protocol=pickle.HIGHEST_PROTOCOL)
+ if cut == "launch_block_reconstruction":
+ config_payload = test_fault._FaultReconstructionPayload(config_payload)
+ launch = pickle.dumps(
+ {
+ "vllm_config_payload": config_payload,
+ "num_workers": 1,
+ "ipc_addr": "ipc:///unused-fault",
+ },
+ protocol=pickle.HIGHEST_PROTOCOL,
+ )
+
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "vllm_ple_offload_bootstrap",
+ str(control_child.fileno()),
+ str(death_child.fileno()),
+ ],
+ env=env,
+ pass_fds=(control_child.fileno(), death_child.fileno()),
+ )
+ control_child.close()
+ death_child.close()
+ try:
+ control_parent.sendall(struct.pack("!Q", len(launch)) + launch)
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ if fault_log.exists() and expected_transaction in fault_log.read_text():
+ break
+ assert process.poll() is None
+ time.sleep(0.05)
+ else:
+ pytest.fail("operation-entry acknowledgement was not emitted")
+
+ assert process.poll() is None
+ death_parent.close()
+ assert process.wait(timeout=120) != 0
+ finally:
+ with contextlib.suppress(ProcessLookupError):
+ process.kill()
+ process.wait(timeout=120)
+ control_parent.close()
+ with contextlib.suppress(OSError):
+ death_parent.close()
+ os.close(gate_keeper)
+
+
+def _load_gate_helper():
+ root = Path(__file__).resolve().parents[3]
+ path = root / "probe" / "faultcut_gate.py"
+ spec = importlib.util.spec_from_file_location("faultcut_gate", path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ {"alive": False},
+ {"observed_start": 88},
+ {"bytes_sent": 0},
+ {"bytes_sent": 100},
+ {"completed": True},
+ {"child_not_reading": False},
+ ],
+)
+def test_external_partial_send_gate_rejections(change):
+ gate = _load_gate_helper()
+ values = {
+ "pid": 12,
+ "expected_start": 77,
+ "observed_start": 77,
+ "alive": True,
+ "bytes_sent": 4,
+ "frame_size": 100,
+ "completed": False,
+ "child_not_reading": True,
+ }
+ values.update(change)
+ ok, _reason = gate.validate_partial_send_gate(**values)
+ assert not ok
+
+
+def test_external_gate_rejects_failed_signal():
+ gate = _load_gate_helper()
+ ok, reason = gate.deliver_checked_signal(
+ pid=12,
+ expected_start=77,
+ read_start=lambda _pid: 77,
+ alive=lambda _pid: True,
+ send=lambda _pid, _sig: False,
+ signal_number=9,
+ )
+ assert not ok
+ assert "delivery failed" in reason
+
+
+def test_every_fault_call_has_one_boolop_guard_and_no_prior_state_read():
+ root = Path(__file__).resolve().parents[3]
+ expected = {
+ "vllm/v1/ple_offload/connector.py": 3,
+ "vllm/v1/ple_offload/worker.py": 1,
+ "vllm/v1/worker/gpu/model_runner.py": 4,
+ "vllm_ple_offload_bootstrap/__init__.py": 1,
+ }
+
+ for relative, wanted in expected.items():
+ tree = ast.parse((root / relative).read_text(encoding="utf-8"))
+ parents = {}
+ for parent in ast.walk(tree):
+ for child in ast.iter_child_nodes(parent):
+ parents[child] = parent
+
+ calls = [
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr == "fires"
+ and isinstance(node.func.value, ast.Name)
+ and node.func.value.id == "_FAULT"
+ ]
+ assert len(calls) == wanted, relative
+ for call in calls:
+ owner = call
+ while owner in parents and not isinstance(owner, ast.BoolOp):
+ owner = parents[owner]
+ assert isinstance(owner, ast.BoolOp), (relative, call.lineno)
+ assert isinstance(owner.op, ast.And), (relative, call.lineno)
+
+ guard_index = None
+ call_index = None
+ for index, value in enumerate(owner.values):
+ if (
+ isinstance(value, ast.Compare)
+ and isinstance(value.left, ast.Name)
+ and value.left.id == "_FAULT"
+ and len(value.ops) == 1
+ and isinstance(value.ops[0], ast.IsNot)
+ and len(value.comparators) == 1
+ and isinstance(value.comparators[0], ast.Constant)
+ and value.comparators[0].value is None
+ ):
+ guard_index = index
+ if call in ast.walk(value):
+ call_index = index
+ assert guard_index is not None
+ assert call_index is not None
+ assert guard_index < call_index
+
+ forbidden = {"sequence", "has_pending_completions",
+ "completion_is_pending", "fault_transaction"}
+ before_guard = owner.values[:guard_index]
+ assert not any(
+ isinstance(node, ast.Attribute) and node.attr in forbidden
+ for value in before_guard
+ for node in ast.walk(value)
+ )
diff --git a/tests/v1/ple_offload/test_fault_wire_frames.py b/tests/v1/ple_offload/test_fault_wire_frames.py
new file mode 100644
index 0000000..b864355
--- /dev/null
+++ b/tests/v1/ple_offload/test_fault_wire_frames.py
@@ -0,0 +1,96 @@
+# SPDX-License-Identifier: Apache-2.0
+"""Disarmed-path checks: the disarmed PLE request wire format stays single-frame."""
+
+import contextlib
+import queue
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import msgspec
+import pytest
+
+
+def _request():
+ from vllm.v1.ple_offload.protocol import PleOffloadRequest
+
+ return PleOffloadRequest(dp_rank=0, num_tokens=2, num_reqs=1)
+
+
+def test_disarmed_offload_receive_uses_single_frame(monkeypatch):
+ import vllm.v1.ple_offload.worker as worker_module
+
+ monkeypatch.setattr(worker_module, "_FAULT", None)
+ request = _request()
+ socket = Mock()
+ socket.recv.return_value = msgspec.msgpack.encode(request)
+ socket.recv_multipart.side_effect = AssertionError("multipart read while disarmed")
+
+ assert worker_module._receive_offload_request(socket) == request
+ socket.recv.assert_called_once_with(0)
+
+
+def test_armed_offload_receive_requires_metadata_frame(monkeypatch):
+ import vllm.v1.ple_offload.worker as worker_module
+
+ monkeypatch.setattr(worker_module, "_FAULT", object())
+ request = _request()
+ metadata = {"serving": True, "transaction": "request:1", "correlation": "sequence:3"}
+ socket = Mock()
+ socket.recv.side_effect = AssertionError("single-frame read while armed")
+ socket.recv_multipart.return_value = [
+ msgspec.msgpack.encode(request),
+ msgspec.msgpack.encode(metadata),
+ ]
+
+ received = worker_module._receive_offload_request(socket, 1)
+ assert received.request == request
+ assert received.serving is True
+ assert received.transaction_id == "request:1"
+ assert received.correlation_id == "sequence:3"
+ socket.recv_multipart.assert_called_once_with(1)
+
+ socket.recv_multipart.return_value = [msgspec.msgpack.encode(request)]
+ with pytest.raises(RuntimeError):
+ worker_module._receive_offload_request(socket)
+
+
+@pytest.mark.parametrize("armed", [False, True])
+def test_connector_publication_frames_follow_arming(monkeypatch, armed):
+ import vllm.v1.ple_offload.connector as connector_module
+
+ fault = SimpleNamespace(fires=lambda *args, **kwargs: False) if armed else None
+ monkeypatch.setattr(connector_module, "_FAULT", fault)
+ monkeypatch.setattr(
+ connector_module.torch.accelerator,
+ "device_index",
+ lambda _index: contextlib.nullcontext(),
+ )
+ connector = object.__new__(connector_module.PleOffloadConnector)
+ connector.tp_rank = 0
+ connector.pp_rank = 0
+ connector.dp_rank = 0
+ connector.device = SimpleNamespace(index=0)
+ connector._d2h_event_pool = queue.Queue()
+ event = Mock()
+ request = _request()
+ pending = connector_module._PendingPleOffloadRequest(
+ request, event, True, "sequence:4@begin:1.000000000", "sequence:4"
+ )
+ socket = Mock()
+
+ connector._process_request(pending, socket)
+
+ event.synchronize.assert_called_once()
+ if armed:
+ socket.send.assert_not_called()
+ payload, metadata = socket.send_multipart.call_args.args[0]
+ assert msgspec.msgpack.decode(metadata) == {
+ "serving": True,
+ "transaction": "sequence:4@begin:1.000000000",
+ "correlation": "sequence:4",
+ }
+ else:
+ socket.send_multipart.assert_not_called()
+ (payload,) = socket.send.call_args.args
+ assert payload == msgspec.msgpack.encode(request)
+ assert connector._d2h_event_pool.get_nowait() is event
diff --git a/tests/v1/ple_offload/test_ipc_socket_unlink.py b/tests/v1/ple_offload/test_ipc_socket_unlink.py
new file mode 100644
index 0000000..92fbc42
--- /dev/null
+++ b/tests/v1/ple_offload/test_ipc_socket_unlink.py
@@ -0,0 +1,133 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import contextlib
+import os
+from pathlib import Path
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+
+import pytest
+
+from vllm.v1.ple_offload.worker import PleOffloadWorkerHandle
+
+
+pytestmark = pytest.mark.skipif(
+ sys.platform != "linux", reason="requires Linux filesystem ipc sockets"
+)
+
+
+_STUB_CHILD = r"""
+import pathlib
+import signal
+import sys
+import threading
+
+import zmq
+
+from vllm.v1.ple_offload.worker import _unlink_ipc_socket
+
+ipc_addr, mode, marker = sys.argv[1:]
+stopping = threading.Event()
+if mode == "graceful":
+ signal.signal(signal.SIGTERM, lambda _signum, _frame: stopping.set())
+else:
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+
+context = zmq.Context()
+socket = context.socket(zmq.PULL)
+socket.bind(ipc_addr)
+try:
+ stopping.wait()
+finally:
+ socket.close(linger=0)
+ context.term()
+ if mode == "graceful":
+ _unlink_ipc_socket(ipc_addr)
+ pathlib.Path(marker).touch()
+"""
+
+
+def _start_stub(ipc_addr: str, mode: str, marker: Path) -> subprocess.Popen:
+ return subprocess.Popen(
+ [sys.executable, "-c", _STUB_CHILD, ipc_addr, mode, str(marker)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+
+def _wait_for_socket(proc: subprocess.Popen, path: Path) -> None:
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ if path.exists():
+ return
+ if proc.poll() is not None:
+ stderr = proc.stderr.read() if proc.stderr is not None else ""
+ pytest.fail(f"stub child exited before bind: {stderr}")
+ time.sleep(0.01)
+ pytest.fail("stub child did not bind its ipc socket")
+
+
+def _cleanup_process(proc: subprocess.Popen, socket_path: Path) -> None:
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait(timeout=5)
+ with contextlib.suppress(FileNotFoundError):
+ os.unlink(socket_path)
+ if proc.stderr is not None:
+ proc.stderr.close()
+
+
+def test_handle_graceful_close_removes_child_bound_socket() -> None:
+ with tempfile.TemporaryDirectory(prefix="ple-ipc-", dir="/tmp") as temp_dir:
+ socket_path = Path(temp_dir) / "owned.sock"
+ marker = Path(temp_dir) / "child-cleaned"
+ ipc_addr = f"ipc://{socket_path}"
+ proc = _start_stub(ipc_addr, "graceful", marker)
+ try:
+ handle = PleOffloadWorkerHandle(
+ proc=proc,
+ death_writer=None,
+ ready_pipe_reader=None,
+ launch_deadline=time.monotonic() + 30,
+ ipc_addr=ipc_addr,
+ )
+ _wait_for_socket(proc, socket_path)
+
+ handle.close()
+
+ assert proc.returncode == 0
+ assert marker.exists(), "the child ran its post-context unlink"
+ assert not socket_path.exists()
+ finally:
+ _cleanup_process(proc, socket_path)
+
+
+def test_handle_close_removes_socket_after_child_sigkill() -> None:
+ with tempfile.TemporaryDirectory(prefix="ple-ipc-", dir="/tmp") as temp_dir:
+ socket_path = Path(temp_dir) / "owned.sock"
+ marker = Path(temp_dir) / "unused"
+ ipc_addr = f"ipc://{socket_path}"
+ proc = _start_stub(ipc_addr, "park", marker)
+ try:
+ handle = PleOffloadWorkerHandle(
+ proc=proc,
+ death_writer=None,
+ ready_pipe_reader=None,
+ launch_deadline=time.monotonic() + 30,
+ ipc_addr=ipc_addr,
+ )
+ _wait_for_socket(proc, socket_path)
+ proc.kill()
+ proc.wait(timeout=5)
+ assert proc.returncode == -signal.SIGKILL
+ assert socket_path.exists()
+
+ handle.close()
+
+ assert not socket_path.exists()
+ finally:
+ _cleanup_process(proc, socket_path)
diff --git a/tests/v1/ple_offload/test_pending_rpc.py b/tests/v1/ple_offload/test_pending_rpc.py
new file mode 100644
index 0000000..ef3c0fd
--- /dev/null
+++ b/tests/v1/ple_offload/test_pending_rpc.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import multiprocessing
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+import vllm.v1.executor.multiproc_executor as executor_module
+from vllm.v1.executor.multiproc_executor import WorkerProc
+from vllm.v1.ple_offload.fatal import ple_fail_closed
+
+
+class _BrokenAsyncOutput:
+ def get_output(self) -> Any:
+ raise RuntimeError("injected asynchronous output failure")
+
+
+class _Progress:
+ sequence = 9
+
+ def __init__(self, pending: bool) -> None:
+ self.has_pending_completions = pending
+
+
+class _Connector:
+ rank = 2
+ _layers = {"ple": object()}
+
+ def __init__(self, pending: bool) -> None:
+ self._progress = _Progress(pending)
+
+ @property
+ def has_pending_completions(self) -> bool:
+ return self._progress.has_pending_completions
+
+ def _fail_closed(self, where: str, error: BaseException) -> None:
+ ple_fail_closed(
+ where=where,
+ rank=self.rank,
+ layer="ple",
+ sequence=self._progress.sequence,
+ error=error,
+ )
+
+
+def _async_output_failure_case(pending: bool) -> None:
+ connector = _Connector(pending)
+ worker = SimpleNamespace(
+ model_runner=SimpleNamespace(_ple_offload_connector=connector)
+ )
+ proc = WorkerProc.__new__(WorkerProc)
+ proc.worker = SimpleNamespace(worker=worker)
+ proc.worker_response_mq = None
+
+ # Exercise the real async-output exception recovery branch without needing
+ # to construct the production dataclass's CUDA-backed fields.
+ executor_module.AsyncModelRunnerOutput = _BrokenAsyncOutput
+ proc.enqueue_output(_BrokenAsyncOutput())
+
+
+@pytest.mark.parametrize(
+ ("pending", "must_die"),
+ [(True, True), (False, False)],
+)
+def test_async_output_failure_obeys_pending_ple_ownership(
+ pending: bool,
+ must_die: bool,
+) -> None:
+ context = multiprocessing.get_context("spawn")
+ process = context.Process(
+ target=_async_output_failure_case,
+ args=(pending,),
+ )
+ process.start()
+ process.join(timeout=120)
+ assert not process.is_alive()
+ if must_die:
+ assert process.exitcode not in (None, 0)
+ else:
+ assert process.exitcode == 0
diff --git a/tests/v1/ple_offload/test_progress_deadline.py b/tests/v1/ple_offload/test_progress_deadline.py
new file mode 100644
index 0000000..5e7ca19
--- /dev/null
+++ b/tests/v1/ple_offload/test_progress_deadline.py
@@ -0,0 +1,216 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import multiprocessing
+import os
+import threading
+from pathlib import Path
+from typing import Any
+
+import pytest
+import torch
+
+import vllm.v1.ple_offload.connector as connector_module
+from vllm.v1.ple_offload.connector import PleOffloadConnector
+from vllm.v1.ple_offload.progress import PleProgressMonitor
+
+
+class _PendingEvent:
+ def record(self, stream: Any) -> None:
+ stream.recorded = True
+
+ def query(self) -> bool:
+ return False
+
+
+class _BrokenEvent(_PendingEvent):
+ def __init__(self, query_entered: Any) -> None:
+ self.query_entered = query_entered
+
+ def query(self) -> bool:
+ self.query_entered.set()
+ raise RuntimeError("injected event query failure")
+
+
+class _FakeStream:
+ recorded = False
+
+ def synchronize(self) -> None:
+ return
+
+
+class _FakeLayer:
+ def release_offloaded_output(self, stream: Any) -> None:
+ stream.reset_written = True
+
+
+def _silent_producer_case(block_input: bool, started: Any) -> None:
+ monitor = PleProgressMonitor(
+ rank=2,
+ layer_names=("ple",),
+ serving_deadline_s=0.2,
+ startup_deadline_s=1.0,
+ )
+ monitor.begin(startup=False, num_tokens=32)
+ started.set()
+
+ if block_input:
+ # Models the request thread stuck in d2h_done_event.synchronize().
+ threading.Thread(
+ target=threading.Event().wait,
+ daemon=True,
+ ).start()
+
+ # Neither the fake producer nor the test terminates the process. The
+ # independent progress monitor must do so.
+ threading.Event().wait()
+
+
+@pytest.mark.parametrize("block_input", [False, True])
+def test_progress_deadline_terminates_alive_silent_worker(
+ block_input: bool,
+) -> None:
+ context = multiprocessing.get_context("spawn")
+ started = context.Event()
+ process = context.Process(
+ target=_silent_producer_case,
+ args=(block_input, started),
+ )
+ process.start()
+ # Cold vLLM import in the spawned child takes 4-8 s on the reference host; the deadline
+ # itself (0.2 s) is only charged after `started` is set.
+ assert started.wait(timeout=120)
+ process.join(timeout=10)
+ assert not process.is_alive()
+ assert process.exitcode not in (None, 0)
+
+
+def _pending_completion_case(use_release_wrapper: bool, started: Any) -> None:
+ monitor = PleProgressMonitor(
+ rank=2,
+ layer_names=("ple",),
+ serving_deadline_s=0.2,
+ startup_deadline_s=1.0,
+ )
+ if use_release_wrapper:
+ stream = _FakeStream()
+ connector_module.torch.cuda.current_stream = lambda device=None: stream
+ connector_module.torch.cuda.Event = _PendingEvent
+ connector_module.torch.cuda.is_current_stream_capturing = lambda: False
+ connector_module._rowdump.enabled = lambda: False
+
+ connector = PleOffloadConnector.__new__(PleOffloadConnector)
+ connector.device = torch.device("cpu")
+ connector.rank = 2
+ connector.tp_rank = 0
+ connector._layers = {"ple": _FakeLayer()}
+ connector._progress = monitor
+ connector._rowdump_n = None
+ connector._after_dummy = False
+ monitor.begin(startup=False, num_tokens=8)
+ connector.release_outputs()
+ assert stream.recorded
+ assert stream.reset_written
+ else:
+ monitor.begin(startup=False, num_tokens=8)
+ monitor.track_completion(_PendingEvent())
+ monitor.complete()
+ started.set()
+ threading.Event().wait()
+
+
+@pytest.mark.parametrize("use_release_wrapper", [False, True])
+def test_pending_gpu_completion_expires_after_release_returns(
+ use_release_wrapper: bool,
+) -> None:
+ context = multiprocessing.get_context("spawn")
+ started = context.Event()
+ process = context.Process(
+ target=_pending_completion_case,
+ args=(use_release_wrapper, started),
+ )
+ process.start()
+ assert started.wait(timeout=120)
+ process.join(timeout=10)
+ assert not process.is_alive()
+ assert process.exitcode not in (None, 0)
+
+
+@pytest.mark.parametrize("value", [float("inf"), float("nan"), -1.0, 1e12])
+def test_constructor_rejects_invalid_deadlines(value: float) -> None:
+ with pytest.raises(ValueError, match="finite"):
+ PleProgressMonitor(
+ rank=0,
+ layer_names=("ple",),
+ serving_deadline_s=value,
+ startup_deadline_s=1.0,
+ )
+ with pytest.raises(ValueError, match="finite"):
+ PleProgressMonitor(
+ rank=0,
+ layer_names=("ple",),
+ serving_deadline_s=1.0,
+ startup_deadline_s=value,
+ )
+
+
+@pytest.mark.parametrize(
+ "name",
+ ["VLLM_PLE_PROGRESS_DEADLINE_S", "VLLM_PLE_STARTUP_DEADLINE_S"],
+)
+@pytest.mark.parametrize("value", ["inf", "nan", "-1", "1e12"])
+def test_environment_rejects_invalid_deadlines(
+ monkeypatch: pytest.MonkeyPatch,
+ name: str,
+ value: str,
+) -> None:
+ monkeypatch.setenv("VLLM_PLE_PROGRESS_DEADLINE_S", "1")
+ monkeypatch.setenv("VLLM_PLE_STARTUP_DEADLINE_S", "1")
+ monkeypatch.setenv(name, value)
+ with pytest.raises(ValueError, match="finite"):
+ PleProgressMonitor.from_environment(rank=0, layer_names=("ple",))
+
+
+def _monitor_exception_case(
+ started: Any,
+ query_entered: Any,
+ diagnostic_path: str,
+) -> None:
+ diagnostic_fd = os.open(diagnostic_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ os.dup2(diagnostic_fd, 2)
+ os.close(diagnostic_fd)
+ monitor = PleProgressMonitor(
+ rank=4,
+ layer_names=("ple",),
+ serving_deadline_s=10.0,
+ startup_deadline_s=10.0,
+ )
+ monitor.begin(startup=False, num_tokens=8)
+ # Set before arming the failing event: the monitor thread's os._exit must not
+ # race a multiprocessing Event.set() that holds the shared condition lock.
+ started.set()
+ monitor.track_completion(_BrokenEvent(query_entered))
+ monitor.complete()
+ threading.Event().wait()
+
+
+def test_monitor_thread_exception_is_fatal(tmp_path: Path) -> None:
+ context = multiprocessing.get_context("spawn")
+ started = context.Event()
+ query_entered = context.Event()
+ diagnostic_path = tmp_path / "monitor-fatal.txt"
+ process = context.Process(
+ target=_monitor_exception_case,
+ args=(started, query_entered, str(diagnostic_path)),
+ )
+ process.start()
+ assert started.wait(timeout=120)
+ assert query_entered.wait(timeout=10)
+ process.join(timeout=10)
+ assert not process.is_alive()
+ assert process.exitcode not in (None, 0)
+ diagnostic = diagnostic_path.read_text(encoding="utf-8")
+ assert "where=progress-monitor-thread" in diagnostic
+ assert (
+ "error=RuntimeError:injected event query failure"
+ in diagnostic
+ )
diff --git a/tests/v1/ple_offload/test_shm_unlink.py b/tests/v1/ple_offload/test_shm_unlink.py
new file mode 100644
index 0000000..600a7bb
--- /dev/null
+++ b/tests/v1/ple_offload/test_shm_unlink.py
@@ -0,0 +1,397 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import os
+from multiprocessing.reduction import ForkingPickler
+from pathlib import Path
+import subprocess
+import sys
+
+import pytest
+import torch
+import torch.multiprocessing as torch_mp
+
+from vllm.v1.ple_offload import connector as connector_module
+from vllm.v1.ple_offload.connector import PleOffloadConnector
+from vllm.v1.ple_offload.protocol import PleOffloadRegistration
+
+
+def _require_file_system_shm() -> None:
+ if (
+ os.name != "posix"
+ or not os.path.isdir("/dev/shm")
+ or not os.access("/dev/shm", os.W_OK)
+ ):
+ pytest.skip("POSIX /dev/shm is unavailable")
+ if "file_system" not in torch_mp.get_all_sharing_strategies():
+ pytest.skip("torch file_system sharing is unavailable")
+
+
+def _shm_path(tensor: torch.Tensor) -> Path:
+ _, filename, _ = tensor.untyped_storage()._share_filename_cpu_()
+ return Path("/dev/shm") / os.fsdecode(filename).lstrip("/")
+
+
+def _make_connector_storage() -> tuple[PleOffloadConnector, list[torch.Tensor]]:
+ original_strategy = torch_mp.get_sharing_strategy()
+ torch_mp.set_sharing_strategy("file_system")
+ try:
+ host_outputs = {
+ "layer.0": torch.arange(12, dtype=torch.float32)
+ .reshape(3, 4)
+ .share_memory_(),
+ "layer.1": torch.arange(8, dtype=torch.float32)
+ .reshape(2, 4)
+ .share_memory_(),
+ }
+ host_flags = {
+ name: torch.zeros(16, dtype=torch.int32).share_memory_()
+ for name in host_outputs
+ }
+ input_ids = torch.arange(16, dtype=torch.int32).share_memory_()
+ query_start_loc = torch.arange(5, dtype=torch.int32).share_memory_()
+ ngram_context = torch.arange(12, dtype=torch.int32).reshape(4, 3)
+ ngram_context.share_memory_()
+
+ registration = PleOffloadRegistration(
+ worker_id=0,
+ tp_rank=0,
+ dp_rank=0,
+ host_output_buffers=host_outputs,
+ host_flag_tensors=host_flags,
+ input_ids_buf=input_ids,
+ query_start_loc_buf=query_start_loc,
+ ngram_context_buf=ngram_context,
+ )
+ # Match producer serialization and worker reconstruction. loads()
+ # consumes the transfer reference added by dumps(), leaving storage
+ # destruction able to reach the allocator's zero-counter branch.
+ payload = ForkingPickler.dumps(registration)
+ rebuilt_registration = ForkingPickler.loads(payload)
+ assert isinstance(rebuilt_registration, PleOffloadRegistration)
+ del rebuilt_registration
+ finally:
+ torch_mp.set_sharing_strategy(original_strategy)
+
+ connector = PleOffloadConnector.__new__(PleOffloadConnector)
+ connector._host_output_bufs = host_outputs
+ connector._host_flags = host_flags
+ connector._reserved_host_storage_filenames = set()
+ connector._input_ids_buf = input_ids
+ connector._query_start_loc_buf = query_start_loc
+ connector._ngram_context_buf = ngram_context
+ tensors = [
+ *host_outputs.values(),
+ *host_flags.values(),
+ input_ids,
+ query_start_loc,
+ ngram_context,
+ ]
+ assert len(tensors) == 7
+ return connector, tensors
+
+
+def test_release_host_storage_unlinks_names_but_keeps_mappings() -> None:
+ _require_file_system_shm()
+ connector, tensors = _make_connector_storage()
+ paths = [_shm_path(tensor) for tensor in tensors]
+ before = [tensor.clone() for tensor in tensors]
+ assert len(set(paths)) == 7
+ assert all(path.exists() for path in paths)
+
+ connector.release_host_storage()
+ connector.release_host_storage()
+
+ assert all(not path.exists() for path in paths)
+ for tensor, expected in zip(tensors, before):
+ assert torch.equal(tensor, expected)
+ tensor.add_(1)
+ assert torch.equal(tensor, expected + 1)
+
+
+def test_without_release_helper_names_remain(monkeypatch) -> None:
+ _require_file_system_shm()
+ connector, tensors = _make_connector_storage()
+ paths = [_shm_path(tensor) for tensor in tensors]
+
+ with monkeypatch.context() as patch:
+ patch.setattr(
+ connector_module,
+ "_unlink_filename_shared_storage",
+ lambda _tensor, _reservations: None,
+ )
+ connector.release_host_storage()
+
+ # Do not manually unlink these while their unreserved storages are live.
+ # Normal storage destruction removes them after the test frame is released.
+ assert all(path.exists() for path in paths)
+
+
+def test_release_host_storage_does_not_convert_fd_shared_storage() -> None:
+ if "file_descriptor" not in torch_mp.get_all_sharing_strategies():
+ pytest.skip("torch file_descriptor sharing is unavailable")
+ original_strategy = torch_mp.get_sharing_strategy()
+ torch_mp.set_sharing_strategy("file_descriptor")
+ try:
+ fd_tensor = torch.arange(8, dtype=torch.int32).share_memory_()
+ finally:
+ torch_mp.set_sharing_strategy(original_strategy)
+
+ connector = PleOffloadConnector.__new__(PleOffloadConnector)
+ connector._host_output_bufs = {"layer.0": fd_tensor}
+ connector._host_flags = {}
+ connector._reserved_host_storage_filenames = set()
+ connector._input_ids_buf = torch.arange(2, dtype=torch.int32)
+ connector._query_start_loc_buf = torch.arange(2, dtype=torch.int32)
+ connector._ngram_context_buf = None
+ expected = fd_tensor.clone()
+
+ connector.release_host_storage()
+
+ assert fd_tensor.untyped_storage()._get_shared_fd() >= 0
+ assert torch.equal(fd_tensor, expected)
+
+
+class _FakeFilenameStorage:
+ def __init__(self, filename: bytes) -> None:
+ self.device = torch.device("cpu")
+ self.filename = filename
+ self.increfs = 0
+ self.decrefs = 0
+
+ def is_shared(self) -> bool:
+ return True
+
+ def _get_shared_fd(self) -> int:
+ raise RuntimeError("filename-backed storage has no shared fd")
+
+ def _share_filename_cpu_(self) -> tuple[bytes, bytes, int]:
+ return b"manager", self.filename, 4
+
+ def _shared_incref(self) -> None:
+ self.increfs += 1
+
+ def _shared_decref(self) -> "_FakeFilenameStorage":
+ self.decrefs += 1
+ return self
+
+
+class _FakeTensor:
+ def __init__(self, storage: _FakeFilenameStorage) -> None:
+ self.storage = storage
+
+ def untyped_storage(self) -> _FakeFilenameStorage:
+ return self.storage
+
+
+def _make_fake_connector(tensor: _FakeTensor) -> PleOffloadConnector:
+ connector = PleOffloadConnector.__new__(PleOffloadConnector)
+ # Repeat the same storage in several connector positions to cover aliases.
+ connector._host_output_bufs = {"layer.0": tensor}
+ connector._host_flags = {"layer.0": tensor}
+ connector._reserved_host_storage_filenames = set()
+ connector._input_ids_buf = tensor
+ connector._query_start_loc_buf = tensor
+ connector._ngram_context_buf = None
+ return connector
+
+
+def test_release_host_storage_reserves_each_name_once(monkeypatch) -> None:
+ storage = _FakeFilenameStorage(b"/ple-test-reservation")
+ connector = _make_fake_connector(_FakeTensor(storage))
+ unlinked: list[str] = []
+ monkeypatch.setattr(connector_module.os, "unlink", unlinked.append)
+
+ connector.release_host_storage()
+ connector.release_host_storage()
+
+ assert storage.increfs == 1
+ assert storage.decrefs == 0
+ assert unlinked == ["/dev/shm/ple-test-reservation"]
+
+
+def test_release_host_storage_rolls_back_on_unlink_failure(monkeypatch) -> None:
+ storage = _FakeFilenameStorage(b"/ple-test-rollback")
+ connector = _make_fake_connector(_FakeTensor(storage))
+
+ def fail_unlink(_path: str) -> None:
+ raise PermissionError("test unlink failure")
+
+ monkeypatch.setattr(connector_module.os, "unlink", fail_unlink)
+
+ connector.release_host_storage()
+
+ # The fake connector aliases one storage in four positions. A rolled-back
+ # reservation is retried by the next alias in the same pass (by design: the
+ # name is still present), so every reservation must be balanced by a
+ # rollback and nothing may stay reserved.
+ assert storage.increfs >= 1
+ assert storage.increfs == storage.decrefs
+ assert connector._reserved_host_storage_filenames == set()
+
+
+_BALANCED_LIFETIME_SCRIPT = r"""
+import gc
+import os
+from multiprocessing.reduction import ForkingPickler
+from pathlib import Path
+
+import torch
+import torch.multiprocessing as torch_mp
+
+from vllm.v1.ple_offload.connector import (
+ PleOffloadConnector,
+ _PROCESS_LIFETIME_REGISTERED_STORAGE,
+)
+from vllm.v1.ple_offload.protocol import PleOffloadRegistration
+
+
+def shm_path(tensor):
+ _, filename, _ = tensor.untyped_storage()._share_filename_cpu_()
+ return Path("/dev/shm") / os.fsdecode(filename).lstrip("/")
+
+
+class Progress:
+ def close(self):
+ pass
+
+
+original_strategy = torch_mp.get_sharing_strategy()
+torch_mp.set_sharing_strategy("file_system")
+try:
+ host_outputs = {
+ "layer.0": torch.arange(12, dtype=torch.float32)
+ .reshape(3, 4)
+ .share_memory_(),
+ "layer.1": torch.arange(8, dtype=torch.float32)
+ .reshape(2, 4)
+ .share_memory_(),
+ }
+ host_flags = {
+ name: torch.zeros(16, dtype=torch.int32).share_memory_()
+ for name in host_outputs
+ }
+ input_ids = torch.arange(16, dtype=torch.int32).share_memory_()
+ query_start_loc = torch.arange(5, dtype=torch.int32).share_memory_()
+ ngram_context = torch.arange(12, dtype=torch.int32).reshape(4, 3)
+ ngram_context.share_memory_()
+
+ registration = PleOffloadRegistration(
+ worker_id=1,
+ tp_rank=1,
+ dp_rank=0,
+ host_output_buffers=host_outputs,
+ host_flag_tensors=host_flags,
+ input_ids_buf=input_ids,
+ query_start_loc_buf=query_start_loc,
+ ngram_context_buf=ngram_context,
+ )
+ payload = ForkingPickler.dumps(registration)
+ rebuilt_registration = ForkingPickler.loads(payload)
+finally:
+ torch_mp.set_sharing_strategy(original_strategy)
+
+connector = PleOffloadConnector.__new__(PleOffloadConnector)
+connector._host_output_bufs = host_outputs
+connector._host_flags = host_flags
+connector._reserved_host_storage_filenames = set()
+connector._input_ids_buf = input_ids
+connector._query_start_loc_buf = query_start_loc
+connector._ngram_context_buf = ngram_context
+
+# Exercise the production close ordering without CUDA or transport setup.
+connector._request_thread = None
+connector._pinned_input_buffers = []
+connector._mapped_host_buffers = []
+connector._d2h_event_pool = object()
+connector._registration_socket = None
+connector._zmq_ctx = None
+connector._progress = Progress()
+
+tensors = [
+ *host_outputs.values(),
+ *host_flags.values(),
+ input_ids,
+ query_start_loc,
+ ngram_context,
+]
+assert len(tensors) == 7
+assert len({tensor.untyped_storage()._weak_ref() for tensor in tensors}) == 7
+
+# TP != 0 creates and serializes its input buffers without CUDA registration
+# or the process-lifetime reference used by TP0.
+unpinned_tp_input = input_ids
+assert not connector._pinned_input_buffers
+assert all(
+ unpinned_tp_input is not registered
+ for registered in _PROCESS_LIFETIME_REGISTERED_STORAGE
+)
+
+paths = [shm_path(tensor) for tensor in tensors]
+before = [tensor.clone() for tensor in tensors]
+assert all(path.exists() for path in paths)
+
+connector.close()
+connector.close()
+
+assert all(not path.exists() for path in paths)
+for tensor, expected in zip(tensors, before):
+ assert torch.equal(tensor, expected)
+ tensor.add_(1)
+ assert torch.equal(tensor, expected + 1)
+del tensor, expected
+
+# Remove every original and reconstructed tensor reference. With an unreserved
+# allocator this reaches counter zero after manual unlink and aborts the child.
+connector._host_output_bufs = {}
+connector._host_flags = {}
+connector._input_ids_buf = None
+connector._query_start_loc_buf = None
+connector._ngram_context_buf = None
+del connector
+del rebuilt_registration
+del registration
+del payload
+del unpinned_tp_input
+del tensors
+del before
+del host_outputs
+del host_flags
+del input_ids
+del query_start_loc
+del ngram_context
+gc.collect()
+
+assert all(not path.exists() for path in paths)
+"""
+
+
+def test_close_balanced_filename_storage_lifetime_exits_cleanly() -> None:
+ _require_file_system_shm()
+
+ # Resolve from the connector actually imported by this pytest process. This
+ # lets the red command run this patched test against an unpatched worktree.
+ import_root = Path(connector_module.__file__).resolve().parents[3]
+ env = os.environ.copy()
+ inherited_pythonpath = env.get("PYTHONPATH")
+ env["PYTHONPATH"] = (
+ os.pathsep.join((str(import_root), inherited_pythonpath))
+ if inherited_pythonpath
+ else str(import_root)
+ )
+
+ completed = subprocess.run(
+ [sys.executable, "-c", _BALANCED_LIFETIME_SCRIPT],
+ cwd=import_root,
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+
+ assert completed.returncode == 0, (
+ "balanced PLE storage destruction failed "
+ f"(exit code {completed.returncode})\n"
+ f"stdout:\n{completed.stdout}\n"
+ f"stderr:\n{completed.stderr}"
+ )
diff --git a/tests/v1/ple_offload/test_shutdown_order.py b/tests/v1/ple_offload/test_shutdown_order.py
new file mode 100644
index 0000000..101c3da
--- /dev/null
+++ b/tests/v1/ple_offload/test_shutdown_order.py
@@ -0,0 +1,171 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import contextlib
+from types import SimpleNamespace
+from typing import Any
+
+from vllm.v1.ple_offload.connector import PleOffloadConnector
+from vllm.v1.ple_offload import worker as ple_worker
+from vllm.v1.worker import gpu_worker
+
+
+def test_shutdown_marks_ple_stopping_before_teardown(monkeypatch) -> None:
+ worker = gpu_worker.Worker.__new__(gpu_worker.Worker)
+ worker._ple_offload_stopping = False
+ order: list[tuple[str, bool]] = []
+
+ def record(name: str):
+ def call() -> None:
+ order.append((name, worker._ple_offload_stopping))
+
+ return call
+
+ monkeypatch.setattr(gpu_worker.gc, "unfreeze", record("gc"))
+ monkeypatch.setattr(
+ gpu_worker,
+ "ensure_kv_transfer_shutdown",
+ record("kv"),
+ )
+ monkeypatch.setattr(
+ gpu_worker,
+ "ensure_ec_transfer_shutdown",
+ record("ec"),
+ )
+ monkeypatch.setattr(
+ gpu_worker.current_platform,
+ "is_cuda_alike",
+ lambda: False,
+ )
+
+ worker.profiler = None
+ worker.weight_transfer_engine = None
+ worker.elastic_ep_executor = SimpleNamespace(shutdown=record("elastic"))
+ worker._ple_offload_worker_handle = SimpleNamespace(close=record("offload"))
+ worker.model_runner = SimpleNamespace(shutdown=record("runner"))
+
+ worker.shutdown()
+
+ assert order
+ assert all(stopping for _, stopping in order)
+ assert [name for name, _ in order][:2] == ["gc", "kv"]
+
+
+class _RequestThread:
+ def __init__(self, order: list[str], *, stops: bool) -> None:
+ self.order = order
+ self.stops = stops
+ self.alive = True
+
+ def is_alive(self) -> bool:
+ return self.alive
+
+ def join(self, timeout: float) -> None:
+ self.order.append("thread.join")
+ if self.stops:
+ self.alive = False
+
+
+def _record(order: list[str], name: str):
+ def call(*_args: Any, **_kwargs: Any) -> None:
+ order.append(name)
+
+ return call
+
+
+def _make_connector_for_close(
+ order: list[str], *, thread_stops: bool
+) -> PleOffloadConnector:
+ connector = PleOffloadConnector.__new__(PleOffloadConnector)
+ connector._request_thread = _RequestThread(order, stops=thread_stops)
+ connector._request_queue = SimpleNamespace(put=_record(order, "queue.stop"))
+ connector._pinned_input_buffers = []
+ connector._mapped_host_buffers = []
+ connector._d2h_event_pool = object()
+ connector._registration_socket = SimpleNamespace(
+ close=_record(order, "socket.close")
+ )
+ connector._zmq_ctx = SimpleNamespace(term=_record(order, "context.term"))
+ connector._progress = SimpleNamespace(close=_record(order, "progress.close"))
+ connector.release_host_storage = _record(order, "storage.release")
+ return connector
+
+
+def test_connector_close_releases_storage_last() -> None:
+ order: list[str] = []
+ connector = _make_connector_for_close(order, thread_stops=True)
+
+ connector.close()
+
+ assert order[-1] == "storage.release"
+ assert order.index("thread.join") < order.index("context.term")
+ assert order.index("context.term") < order.index("storage.release")
+
+
+def test_connector_close_defers_storage_release_if_thread_does_not_stop() -> None:
+ order: list[str] = []
+ connector = _make_connector_for_close(order, thread_stops=False)
+
+ connector.close()
+
+ assert "storage.release" not in order
+ assert "socket.close" not in order
+ assert "context.term" not in order
+ assert order[-1] == "progress.close"
+
+
+def test_offload_child_unlinks_owned_ipc_after_context_term(monkeypatch) -> None:
+ order: list[str] = []
+ ipc_addr = "ipc:///tmp/ple-owned-test.sock"
+ pull_socket = SimpleNamespace(
+ bind=_record(order, "socket.bind"),
+ close=_record(order, "socket.close"),
+ )
+ zmq_context = SimpleNamespace(
+ socket=lambda _kind: pull_socket,
+ term=_record(order, "context.term"),
+ )
+
+ class FakeRunner:
+ layer_names: list[str] = []
+
+ def __init__(self, _config: object) -> None:
+ pass
+
+ def accept_registrations(self, _socket: object, _count: int) -> None:
+ order.append("registrations.accept")
+
+ def busy_loop(self, _socket: object, _event: object) -> None:
+ order.append("runner.busy_loop")
+
+ ready_pipe = SimpleNamespace(
+ send_bytes=_record(order, "ready.send"),
+ close=_record(order, "ready.close"),
+ )
+ monkeypatch.setattr(ple_worker, "decorate_logs", lambda _name: None)
+ monkeypatch.setattr(ple_worker, "mark_as_offload_worker", lambda: None)
+ monkeypatch.setattr(ple_worker, "_prepare_cuda_free_process", lambda: None)
+ monkeypatch.setattr(ple_worker, "_init_offload_distributed", lambda: None)
+ monkeypatch.setattr(
+ ple_worker,
+ "set_current_vllm_config",
+ lambda _config: contextlib.nullcontext(),
+ )
+ monkeypatch.setattr(ple_worker, "PleOffloadRunner", FakeRunner)
+ monkeypatch.setattr(ple_worker.zmq, "Context", lambda: zmq_context)
+ monkeypatch.setattr(ple_worker.signal, "signal", lambda *_args: None)
+ monkeypatch.setattr(ple_worker.torch.cuda, "is_initialized", lambda: False)
+ monkeypatch.setattr(
+ ple_worker,
+ "_unlink_ipc_socket",
+ lambda address: order.append(f"ipc.unlink:{address}"),
+ )
+
+ ple_worker.PleOffloadWorker.proc_main(
+ SimpleNamespace(),
+ num_workers=1,
+ ipc_addr=ipc_addr,
+ ready_pipe=ready_pipe,
+ )
+
+ assert order.index("socket.close") < order.index("context.term")
+ assert order[-1] == f"ipc.unlink:{ipc_addr}"
diff --git a/tests/v1/ple_offload/test_spawn_bootstrap_cuda.py b/tests/v1/ple_offload/test_spawn_bootstrap_cuda.py
new file mode 100644
index 0000000..c709a93
--- /dev/null
+++ b/tests/v1/ple_offload/test_spawn_bootstrap_cuda.py
@@ -0,0 +1,352 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import ctypes
+import ctypes.util
+import inspect
+import json
+import multiprocessing
+import os
+import pickle
+import signal
+import socket
+import subprocess
+import sys
+import threading
+import time
+from multiprocessing.connection import Connection
+from pathlib import Path
+
+import pytest
+
+def _native_cuda_probe(output_path: str) -> None:
+ """Run during config unpickling, before the bootstrap imports vLLM."""
+ library = ctypes.util.find_library("cudart")
+ if library is None:
+ os._exit(77)
+ cudart = ctypes.CDLL(library)
+ result = int(cudart.cudaFree(ctypes.c_void_p()))
+ Path(output_path).write_text(
+ json.dumps(
+ {
+ "cuda_visible_devices": os.environ.get(
+ "CUDA_VISIBLE_DEVICES"
+ ),
+ "cuda_result": result,
+ "torch_imported": "torch" in sys.modules,
+ "vllm_imported": "vllm" in sys.modules,
+ }
+ ),
+ encoding="utf-8",
+ )
+ os._exit(0)
+
+
+class _ProbePayload:
+ def __init__(self, output_path: str) -> None:
+ self.output_path = output_path
+
+ def __reduce__(self):
+ # The bootstrap interpreter cannot import this test module, so rebuild
+ # the probe from its source text through a builtin instead of by name.
+ source = (
+ "import ctypes, ctypes.util, json, os, sys\n"
+ "from pathlib import Path\n"
+ + inspect.getsource(_native_cuda_probe)
+ + f"_native_cuda_probe({self.output_path!r})\n"
+ )
+ return exec, (source, {})
+
+
+def test_spawn_bootstrap_sets_visibility_before_native_cuda(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from vllm.v1.ple_offload.worker import PleOffloadWorker
+
+ if ctypes.util.find_library("cudart") is None:
+ pytest.skip("CUDA runtime is not installed")
+
+ original = os.environ.get("CUDA_VISIBLE_DEVICES")
+ output_path = tmp_path / "probe.json"
+
+ # Use the production Popen launcher. Config reconstruction executes the
+ # probe in the fresh bootstrap interpreter before worker imports.
+ with monkeypatch.context() as child_settings:
+ child_settings.setenv("CUDA_VISIBLE_DEVICES", "0")
+ handle = PleOffloadWorker.make_process(
+ _ProbePayload(str(output_path)), 0, "unused"
+ )
+ try:
+ returncode = handle.proc.wait(timeout=120)
+ finally:
+ handle.close()
+
+ if returncode == 77:
+ pytest.skip("spawned child could not load the CUDA runtime")
+ assert returncode == 0
+
+ result = json.loads(output_path.read_text(encoding="utf-8"))
+ assert result["cuda_visible_devices"] == "0"
+ assert result["cuda_result"] == 0
+ assert result["torch_imported"] is False
+ assert result["vllm_imported"] is False
+ assert os.environ.get("CUDA_VISIBLE_DEVICES") == original
+
+
+class _FreshInterpreterProbe:
+ def __init__(self, output_path: str) -> None:
+ self.output_path = output_path
+
+ def __reduce__(self):
+ expression = (
+ "(__import__('pathlib').Path("
+ + repr(self.output_path)
+ + ").write_text(__import__('json').dumps({"
+ "'cuda_visible_devices': __import__('os').environ.get("
+ "'CUDA_VISIBLE_DEVICES'),"
+ "'torch_imported': 'torch' in __import__('sys').modules,"
+ "'vllm_imported': 'vllm' in __import__('sys').modules"
+ "}), encoding='utf-8'), None)[1]"
+ )
+ return eval, (expression,)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="pass_fds is POSIX-only")
+def test_fresh_interpreter_has_child_environment_before_vllm_import(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from vllm.v1.ple_offload.worker import PleOffloadWorker
+
+ # This test module has unconditional top-level imports. The dedicated
+ # bootstrap interpreter must not import it as __mp_main__.
+ original = os.environ.get("CUDA_VISIBLE_DEVICES")
+ output_path = tmp_path / "fresh-interpreter.json"
+
+ with monkeypatch.context() as child_settings:
+ child_settings.setenv("CUDA_VISIBLE_DEVICES", "child-only")
+ handle = PleOffloadWorker.make_process(
+ _FreshInterpreterProbe(str(output_path)), 0, "unused"
+ )
+ try:
+ handle.proc.wait(timeout=120)
+ finally:
+ handle.close()
+
+ assert output_path.exists()
+ result = json.loads(output_path.read_text(encoding="utf-8"))
+ assert result["cuda_visible_devices"] == "child-only"
+ assert result["torch_imported"] is False
+ assert result["vllm_imported"] is False
+ assert os.environ.get("CUDA_VISIBLE_DEVICES") == original
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="pass_fds is POSIX-only")
+def test_launch_payload_send_has_absolute_deadline() -> None:
+ reader, sender = socket.socketpair()
+ sender.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1024)
+ code = (
+ "import sys,time\n"
+ "from vllm.v1.ple_offload.worker import _send_framed_payload\n"
+ "payload=b'x'*(64<<20)\n"
+ "start=time.monotonic()\n"
+ "try:\n"
+ " _send_framed_payload(int(sys.argv[1]),payload,start+0.25)\n"
+ "except TimeoutError:\n"
+ " print('TIMED_OUT',time.monotonic()-start,flush=True)\n"
+ "else:\n"
+ " raise AssertionError('blocked reader unexpectedly accepted payload')\n"
+ )
+ process = subprocess.Popen(
+ [sys.executable, "-c", code, str(sender.fileno())],
+ pass_fds=(sender.fileno(),),
+ close_fds=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ sender.close()
+ try:
+ stdout, stderr = process.communicate(timeout=120)
+ finally:
+ reader.close()
+ if process.poll() is None:
+ process.terminate()
+ process.wait(timeout=5)
+ assert process.returncode == 0, stderr
+ # The injector logs the send entry through vLLM's logger on stdout; read only
+ # the probe's own result line.
+ result = [line for line in stdout.splitlines() if line.startswith('TIMED_OUT ')]
+ assert len(result) == 1, stdout
+ marker, elapsed = result[0].split()
+ assert marker == "TIMED_OUT"
+ assert float(elapsed) < 5
+
+
+class _BlockingConfigProbe:
+ def __init__(self, output_path: str) -> None:
+ self.output_path = output_path
+
+ def __reduce__(self):
+ expression = (
+ "(__import__('pathlib').Path("
+ + repr(self.output_path)
+ + ").write_text(str(__import__('os').getpid()), encoding='utf-8'),"
+ "__import__('threading').Event().wait())[1]"
+ )
+ return eval, (expression,)
+
+
+def _launch_blocked_config(output_path: str, child_pids) -> None:
+ from vllm.v1.ple_offload.worker import PleOffloadWorker
+
+ handle = PleOffloadWorker.make_process(
+ _BlockingConfigProbe(output_path), 0, "unused"
+ )
+ child_pids.put(handle.proc.pid)
+ # The test kills this launcher parent. Do not close the handle here.
+ threading.Event().wait()
+
+
+def _pid_has_exited(pid: int) -> bool:
+ stat_path = Path(f"/proc/{pid}/stat")
+ if not stat_path.exists():
+ return True
+ try:
+ return stat_path.read_text(encoding="utf-8").split()[2] == "Z"
+ except FileNotFoundError:
+ return True
+
+
+@pytest.mark.skipif(sys.platform != "linux", reason="uses Linux process state")
+def test_parent_death_during_config_reconstruction_exits_child(
+ tmp_path: Path,
+) -> None:
+ context = multiprocessing.get_context("spawn")
+ child_pids = context.Queue()
+ marker = tmp_path / "blocked-config.pid"
+ parent = context.Process(
+ target=_launch_blocked_config,
+ args=(str(marker), child_pids),
+ )
+ parent.start()
+ child_pid = child_pids.get(timeout=120)
+ deadline = time.monotonic() + 120
+ while not marker.exists() and time.monotonic() < deadline:
+ time.sleep(0.05)
+ assert marker.exists()
+
+ os.kill(parent.pid, signal.SIGKILL)
+ parent.join(timeout=120)
+ assert not parent.is_alive()
+
+ deadline = time.monotonic() + 10
+ while not _pid_has_exited(child_pid) and time.monotonic() < deadline:
+ time.sleep(0.05)
+ if not _pid_has_exited(child_pid):
+ # Harness cleanup is performed only after the autonomous-exit assertion
+ # has failed.
+ os.kill(child_pid, signal.SIGKILL)
+ pytest.fail("PLE child survived launcher-parent death during unpickle")
+
+
+class _ExecFdProbe:
+ def __init__(
+ self,
+ output_path: str,
+ inherited: list[tuple[int, int]],
+ ) -> None:
+ self.output_path = output_path
+ self.inherited = inherited
+
+ def __reduce__(self):
+ helper = (
+ "import json,os,sys\n"
+ "from pathlib import Path\n"
+ "pairs=json.loads(sys.argv[3])\n"
+ "retained=[]\n"
+ "for fd,inode in pairs:\n"
+ " try:\n"
+ " retained.append(os.fstat(fd).st_ino==inode)\n"
+ " except OSError:\n"
+ " retained.append(False)\n"
+ "Path(sys.argv[1]).write_text(json.dumps({"
+ "'noninheritable_before_exec':sys.argv[2]=='True',"
+ "'closed_after_exec':not any(retained)}),encoding='utf-8')\n"
+ )
+ pairs = json.dumps(self.inherited)
+ expression = (
+ "__import__('os').execv(__import__('sys').executable,"
+ "[__import__('sys').executable,'-c',"
+ + repr(helper)
+ + ","
+ + repr(self.output_path)
+ + ",str(all(not __import__('os').get_inheritable(int(fd)) "
+ "for fd in __import__('sys').argv[1:])),"
+ + repr(pairs)
+ + "])"
+ )
+ return eval, (expression,)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="pass_fds is POSIX-only")
+def test_actual_bootstrap_restores_cloexec_before_helper_exec(
+ tmp_path: Path,
+) -> None:
+ from vllm.v1.ple_offload.worker import _send_framed_payload
+
+ control_parent, control_child = socket.socketpair()
+ death_parent, death_child = socket.socketpair()
+ inherited = [
+ (control_child.fileno(), os.fstat(control_child.fileno()).st_ino),
+ (death_child.fileno(), os.fstat(death_child.fileno()).st_ino),
+ ]
+ output_path = tmp_path / "bootstrap-fds.json"
+ config_payload = pickle.dumps(
+ _ExecFdProbe(str(output_path), inherited),
+ protocol=pickle.HIGHEST_PROTOCOL,
+ )
+ payload = pickle.dumps(
+ {
+ "vllm_config_payload": config_payload,
+ "num_workers": 0,
+ "ipc_addr": "unused",
+ },
+ protocol=pickle.HIGHEST_PROTOCOL,
+ )
+ control = Connection(control_parent.detach())
+ death_writer = Connection(death_parent.detach())
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "vllm_ple_offload_bootstrap",
+ str(control_child.fileno()),
+ str(death_child.fileno()),
+ ],
+ close_fds=True,
+ pass_fds=(control_child.fileno(), death_child.fileno()),
+ )
+ control_child.close()
+ death_child.close()
+ try:
+ _send_framed_payload(
+ control.fileno(), payload, time.monotonic() + 10
+ )
+ assert process.wait(timeout=120) == 0
+ finally:
+ control.close()
+ death_writer.close()
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=5)
+
+ result = json.loads(output_path.read_text(encoding="utf-8"))
+ assert result == {
+ "noninheritable_before_exec": True,
+ "closed_after_exec": True,
+ }
diff --git a/tests/v1/ple_offload/test_test_fault.py b/tests/v1/ple_offload/test_test_fault.py
new file mode 100644
index 0000000..3dc8470
--- /dev/null
+++ b/tests/v1/ple_offload/test_test_fault.py
@@ -0,0 +1,293 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+import contextlib
+import os
+import queue
+import sys
+from dataclasses import FrozenInstanceError
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+
+@pytest.fixture
+def fault_module(monkeypatch, tmp_path):
+ monkeypatch.delenv("VLLM_PLE_TEST_FAULT", raising=False)
+ monkeypatch.setenv("VLLM_PLE_TEST_FAULT_LOG", str(tmp_path / "fault.log"))
+ import vllm_ple_test_fault as test_fault
+
+ return test_fault
+
+
+def test_disarmed_import_is_none(fault_module):
+ assert fault_module._FAULT is None
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "cut=exc_before_prepare,ordinal=60,rank=any",
+ "cut=exc_after_prepare,ordinal=2,tp=1",
+ "cut=exc_in_release,ordinal=3,pp=0",
+ "cut=park_before_publish,ordinal=4,rank=any,phase=any",
+ ],
+)
+def test_valid_specs(fault_module, value):
+ spec = fault_module._parse_fault_spec(value)
+ assert spec is not None
+ assert spec.ordinal > 0
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "",
+ " cut=exc_in_release,ordinal=1,rank=any",
+ "cut=unknown,ordinal=1,rank=any",
+ "cut=exc_in_release,ordinal=0,rank=any",
+ "cut=exc_in_release,ordinal=01,rank=any",
+ "cut=exc_in_release,ordinal=1",
+ "cut=exc_in_release,ordinal=1,rank=tp1",
+ "cut=exc_in_release,ordinal=1,rank=any,tp=0",
+ "cut=exc_in_release,ordinal=1,tp=-1",
+ "cut=exc_in_release,ordinal=1,tp=0,phase=startup",
+ "cut=exc_in_release,ordinal=1,tp=0,extra=x",
+ "cut=exc_in_release,cut=exc_after_release,ordinal=1,tp=0",
+ ],
+)
+def test_malformed_specs_raise(fault_module, value):
+ with pytest.raises(ValueError):
+ fault_module._parse_fault_spec(value)
+
+
+def test_one_shot_rank_phase_and_ordinal(fault_module, monkeypatch):
+ spec = fault_module._parse_fault_spec(
+ "cut=exc_after_prepare,ordinal=2,tp=1,pp=0"
+ )
+ assert spec is not None
+
+ monkeypatch.setattr(fault_module.os, "getpid", lambda: 123)
+ monkeypatch.setattr(fault_module, "_pid_start_time", lambda _pid=None: 789)
+ monkeypatch.setattr(fault_module.time, "monotonic", lambda: 12.5)
+ monkeypatch.setattr(fault_module.time, "time", lambda: 456.25)
+
+ def fires(cut, tp_rank=1, eligible=True):
+ return spec.fires(
+ cut,
+ role="gpu",
+ unit="runner-forward",
+ eligible=eligible,
+ tp_rank=tp_rank,
+ pp_rank=0,
+ dp_rank=0,
+ transaction_id="sequence:9@begin:12.000000000",
+ correlation_id="sequence:9",
+ )
+
+ assert not fires("other")
+ assert not fires("exc_after_prepare", tp_rank=0)
+ assert not fires("exc_after_prepare", eligible=False)
+ assert not fires("exc_after_prepare")
+ assert fires("exc_after_prepare")
+ assert not fires("exc_after_prepare")
+ line = Path(os.environ["VLLM_PLE_TEST_FAULT_LOG"]).read_text()
+ assert line == (
+ "PLE_TEST_FAULT v=1 cut=exc_after_prepare ordinal=2 count=2 "
+ "unit=runner-forward role=gpu tp=1 pp=0 dp=0 pid=123 "
+ "pid_start=789 transaction=sequence:9@begin:12.000000000 "
+ "correlation=sequence:9 monotonic=12.500000000 "
+ "wall=456.250000000\n"
+ )
+ with pytest.raises(FrozenInstanceError):
+ spec.ordinal = 3
+
+
+def test_disarmed_connector_sites_continue(fault_module, monkeypatch):
+ import vllm.v1.ple_offload.connector as connector_module
+
+ monkeypatch.setattr(connector_module, "_FAULT", None)
+
+ connector = object.__new__(connector_module.PleOffloadConnector)
+ connector.tp_rank = 0
+ connector.pp_rank = 0
+ connector.dp_rank = 0
+ connector.device = SimpleNamespace(index=0)
+ connector._d2h_event_pool = queue.Queue()
+ event = Mock()
+ connector._d2h_event_pool.put_nowait(event)
+ connector._request_queue = Mock()
+ connector._progress = SimpleNamespace(sequence=0)
+ connector._enqueue_cuda_inputs = Mock()
+
+ connector._launch(
+ num_reqs=1,
+ num_tokens=2,
+ serving=True,
+ transaction_id=None,
+ correlation_id=None,
+ )
+ connector._enqueue_cuda_inputs.assert_called_once()
+ connector._request_queue.put_nowait.assert_called_once()
+
+ staged = connector_module._PendingPleOffloadRequest(
+ connector_module.PleOffloadRequest(dp_rank=0, num_tokens=2, num_reqs=1),
+ event,
+ True,
+ "sequence:0",
+ )
+ socket = Mock()
+ monkeypatch.setattr(
+ connector_module.torch.accelerator,
+ "device_index",
+ lambda _index: contextlib.nullcontext(),
+ )
+ connector._process_request(staged, socket)
+ event.synchronize.assert_called_once()
+ socket.send.assert_called_once()
+
+
+def test_disarmed_release_site_continues(fault_module, monkeypatch):
+ import vllm.v1.ple_offload.connector as connector_module
+
+ monkeypatch.setattr(connector_module, "_FAULT", None)
+ monkeypatch.setattr(connector_module._rowdump, "enabled", lambda: False)
+
+ stream = Mock()
+ completion = Mock()
+ monkeypatch.setattr(
+ connector_module.torch.cuda,
+ "current_stream",
+ lambda _device: stream,
+ )
+ monkeypatch.setattr(connector_module.torch.cuda, "Event", lambda: completion)
+
+ connector = object.__new__(connector_module.PleOffloadConnector)
+ connector.tp_rank = 0
+ connector.pp_rank = 0
+ connector.device = SimpleNamespace(index=0)
+ connector._layers = {}
+ connector._after_dummy = False
+ connector._progress = Mock()
+
+ connector._release_outputs_impl()
+ completion.record.assert_called_once_with(stream)
+ connector._progress.track_completion.assert_called_once_with(completion)
+
+
+def test_disarmed_worker_park_site_continues(fault_module, monkeypatch):
+ import vllm.v1.ple_offload.worker as worker_module
+
+ monkeypatch.setattr(worker_module, "_FAULT", None)
+ runner = object.__new__(worker_module.PleOffloadRunner)
+ runner._validate_requests_before_publication = Mock()
+ runner._worker_targets = {0: {}}
+ runner._input_bufs = {}
+ runner._scratch_bufs = {}
+ runner._layers = {}
+ runner._clamp_input_ids = False
+ runner._step = 0
+ runner._request_index = 0
+ request = SimpleNamespace(dp_rank=0)
+
+ runner._handle_requests_impl([request])
+ runner._validate_requests_before_publication.assert_called_once_with([request])
+ assert runner._step == 1
+
+
+def test_all_injection_sites_use_the_disarmed_short_circuit():
+ root = Path(__file__).resolve().parents[3]
+ expected = {
+ "vllm/v1/ple_offload/connector.py": 3,
+ "vllm/v1/ple_offload/worker.py": 1,
+ "vllm/v1/worker/gpu/model_runner.py": 4,
+ "vllm_ple_offload_bootstrap/__init__.py": 1,
+ }
+ for relative, count in expected.items():
+ lines = (root / relative).read_text(encoding="utf-8").splitlines()
+ sites = [i for i, line in enumerate(lines) if "_FAULT.fires(" in line]
+ assert len(sites) == count, relative
+ for i in sites:
+ window = "\n".join(lines[max(0, i - 6): i + 1])
+ # The None check must precede fires() in the same condition, so a
+ # disarmed process never calls into the fault module.
+ assert "_FAULT is not None" in window, (relative, i + 1)
+ assert window.index("_FAULT is not None") < window.index("_FAULT.fires("), (relative, i + 1)
+
+
+class _FatalReached(Exception):
+ pass
+
+
+def test_exc_in_release_reaches_real_connector_fatal_wrapper(
+ fault_module, monkeypatch
+):
+ import vllm.v1.ple_offload.connector as connector_module
+
+ armed = fault_module._parse_fault_spec(
+ "cut=exc_in_release,ordinal=1,tp=1,pp=0"
+ )
+ assert armed is not None
+ monkeypatch.setattr(connector_module, "_FAULT", armed)
+
+ fatal = Mock(side_effect=_FatalReached)
+ monkeypatch.setattr(connector_module, "ple_fail_closed", fatal)
+
+ connector = object.__new__(connector_module.PleOffloadConnector)
+ connector.rank = 1
+ connector.tp_rank = 1
+ connector.pp_rank = 0
+ connector.dp_rank = 0
+ connector._layers = {}
+ connector._after_dummy = False
+ connector._progress = SimpleNamespace(sequence=9)
+
+ with pytest.raises(_FatalReached):
+ connector.release_outputs(
+ serving=True,
+ transaction_id="sequence:9@begin:1.000000000",
+ correlation_id="sequence:9",
+ )
+
+ assert fatal.call_args.kwargs["where"] == "release_outputs"
+ assert fatal.call_args.kwargs["rank"] == 1
+ assert isinstance(fatal.call_args.kwargs["error"], RuntimeError)
+
+
+@pytest.mark.timeout(180)
+@pytest.mark.skipif(
+ sys.platform == "win32",
+ reason="production PLE launcher requires POSIX pass_fds",
+)
+@pytest.mark.parametrize("cut", ["launch_partial_ready", "launch_partial_ready_header"])
+def test_partial_ready_times_out_and_terminates_production_child(
+ monkeypatch,
+ tmp_path,
+ cut,
+):
+ import vllm.v1.ple_offload.worker as worker_module
+
+ monkeypatch.setenv(
+ "VLLM_PLE_TEST_FAULT",
+ f"cut={cut},ordinal=1,rank=any",
+ )
+ monkeypatch.setenv("VLLM_PLE_TEST_FAULT_LOG", str(tmp_path / "fault.log"))
+ monkeypatch.setattr(
+ worker_module.envs,
+ "VLLM_PLE_OFFLOAD_READY_TIMEOUT",
+ 1.0,
+ )
+
+ handle = worker_module.PleOffloadWorker.make_process(
+ object(), # The bootstrap fault fires before config reconstruction.
+ num_workers=1,
+ ipc_addr="ipc:///unused-ple-test",
+ )
+ try:
+ with pytest.raises(TimeoutError):
+ worker_module.PleOffloadWorker.wait_for_ready(handle)
+ assert handle.proc.poll() is not None
+ finally:
+ handle.close(graceful=False)
diff --git a/tests/v1/ple_offload/test_transport_bounded_io.py b/tests/v1/ple_offload/test_transport_bounded_io.py
new file mode 100644
index 0000000..fb37122
--- /dev/null
+++ b/tests/v1/ple_offload/test_transport_bounded_io.py
@@ -0,0 +1,343 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import multiprocessing
+import os
+import signal
+import struct
+import subprocess
+import sys
+import time
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+
+class _BlockingStrError(Exception):
+ def __str__(self) -> str:
+ __import__("threading").Event().wait()
+ return "unreachable"
+
+
+def _blocking_fatal_formatter_case(marker_path: str) -> None:
+ from vllm.v1.ple_offload.fatal import ple_fail_closed
+
+ Path(marker_path).write_text("armed", encoding="utf-8")
+ try:
+ raise _BlockingStrError()
+ except BaseException as error:
+ ple_fail_closed(
+ where="blocking-formatter",
+ rank=1,
+ layer="ple",
+ sequence=3,
+ error=error,
+ )
+
+
+def test_fatal_helper_does_not_call_blocking_exception_str(
+ tmp_path: Path,
+) -> None:
+ context = multiprocessing.get_context("spawn")
+ marker = tmp_path / "fatal-formatter-armed"
+ process = context.Process(
+ target=_blocking_fatal_formatter_case,
+ args=(str(marker),),
+ )
+ process.start()
+ try:
+ deadline = time.monotonic() + 120
+ while not marker.exists() and time.monotonic() < deadline:
+ time.sleep(0.05)
+ assert marker.exists()
+
+ process.join(timeout=5)
+ assert not process.is_alive()
+ assert process.exitcode not in (None, 0)
+ finally:
+ if process.is_alive():
+ process.terminate()
+ process.join(timeout=120)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="pass_fds is POSIX-only")
+@pytest.mark.parametrize(
+ ("behavior", "ready_timeout", "expected_exception"),
+ [
+ pytest.param("partial", "1", TimeoutError, id="partial-frame"),
+ pytest.param("silent", "1", TimeoutError, id="no-response"),
+ pytest.param("ready", "120", None, id="complete-ready"),
+ pytest.param("invalid", "120", RuntimeError, id="invalid-layer-names"),
+ ],
+)
+def test_wait_for_ready_receives_complete_bounded_frame(
+ monkeypatch: pytest.MonkeyPatch,
+ behavior: str,
+ ready_timeout: str,
+ expected_exception: type[BaseException] | None,
+) -> None:
+ import vllm.v1.ple_offload.worker as worker_module
+
+ real_popen = subprocess.Popen
+ spawned: list[subprocess.Popen] = []
+ child_code = (
+ "import os,signal,struct,sys,time\n"
+ "from multiprocessing.connection import Connection\n"
+ "signal.alarm(10)\n"
+ "fd=int(sys.argv[1])\n"
+ "behavior=sys.argv[3]\n"
+ "if behavior=='partial':\n"
+ " os.write(fd,struct.pack('!i',128)+b'partial')\n"
+ "elif behavior=='ready':\n"
+ " Connection(fd).send({'status':'READY','layer_names':['ple']})\n"
+ "elif behavior=='invalid':\n"
+ " Connection(fd).send({'status':'READY','layer_names':'ple'})\n"
+ "time.sleep(120)\n"
+ )
+
+ def launch_fixture(argv: list[str], **kwargs: Any) -> subprocess.Popen:
+ process = real_popen(
+ [
+ sys.executable,
+ "-c",
+ child_code,
+ argv[-2],
+ argv[-1],
+ behavior,
+ ],
+ **kwargs,
+ )
+ spawned.append(process)
+ return process
+
+ monkeypatch.setenv("VLLM_PLE_OFFLOAD_READY_TIMEOUT", ready_timeout)
+ monkeypatch.setattr(worker_module.subprocess, "Popen", launch_fixture)
+
+ handle = worker_module.PleOffloadWorker.make_process(None, 0, "unused")
+ try:
+ started = time.monotonic()
+ if expected_exception is None:
+ worker_module.PleOffloadWorker.wait_for_ready(handle)
+ assert handle.ready_pipe_reader is None
+ else:
+ with pytest.raises(expected_exception):
+ worker_module.PleOffloadWorker.wait_for_ready(handle)
+ if expected_exception is TimeoutError:
+ assert time.monotonic() - started < 5
+ assert spawned[0].poll() is not None
+ finally:
+ handle.close(graceful=False)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="pass_fds is POSIX-only")
+def test_make_process_large_payload_send_has_absolute_deadline(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ import vllm.v1.ple_offload.worker as worker_module
+
+ real_popen = subprocess.Popen
+ spawned: list[subprocess.Popen] = []
+
+ def launch_nonreader(argv: list[str], **kwargs: Any) -> subprocess.Popen:
+ process = real_popen(
+ [
+ sys.executable,
+ "-c",
+ "import signal,time; signal.alarm(120); time.sleep(120)",
+ argv[-2],
+ argv[-1],
+ ],
+ **kwargs,
+ )
+ spawned.append(process)
+ return process
+
+ monkeypatch.setenv("VLLM_PLE_OFFLOAD_READY_TIMEOUT", "5")
+ monkeypatch.setattr(worker_module.subprocess, "Popen", launch_nonreader)
+
+ started = time.monotonic()
+ with pytest.raises(TimeoutError, match="transmission"):
+ worker_module.PleOffloadWorker.make_process(
+ b"x" * (64 << 20),
+ 0,
+ "unused",
+ )
+
+ assert time.monotonic() - started < 20
+ assert spawned
+ assert spawned[0].poll() is not None
+
+
+@pytest.mark.parametrize(
+ "identities",
+ [
+ pytest.param(("shared", "shared"), id="duplicate"),
+ pytest.param(("<unbound>",), id="unbound"),
+ ],
+)
+def test_expected_ple_layers_are_bound_and_unique_before_publication(
+ monkeypatch: pytest.MonkeyPatch,
+ identities: tuple[str, ...],
+) -> None:
+ from vllm.config import CUDAGraphMode
+ from vllm.v1.worker.gpu.cudagraph_utils import (
+ CudaGraphManager,
+ ModelCudaGraphManager,
+ )
+
+ manager = ModelCudaGraphManager.__new__(ModelCudaGraphManager)
+ manager.graphs = {}
+ manager.use_breakable_cg = False
+ manager.cudagraph_mode = CUDAGraphMode.FULL
+
+ modules = [
+ (
+ f"module_{index}",
+ SimpleNamespace(
+ _is_cpu_offloaded=True,
+ _host_output_ptr=1,
+ _ple_offload_name=identity,
+ ),
+ )
+ for index, identity in enumerate(identities)
+ ]
+ model = SimpleNamespace(named_modules=lambda: iter(modules))
+
+ def publish_graph(self: Any, *_args: Any, **_kwargs: Any) -> None:
+ self.graphs["published"] = object()
+
+ monkeypatch.setattr(CudaGraphManager, "capture", publish_graph)
+
+ with pytest.raises(RuntimeError, match="distinct, bound"):
+ ModelCudaGraphManager.capture(
+ manager,
+ model=model,
+ model_state=object(),
+ input_buffers=object(),
+ intermediate_tensors=None,
+ block_tables=object(),
+ attn_groups=[],
+ kv_cache_config=object(),
+ )
+ assert manager.graphs == {}
+
+
+def test_real_wait_copy_geometry_rejected_before_graph_publication(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ import torch
+
+ import vllm.model_executor.layers.ple_offload_layer as ple_layer_module
+ from vllm.config import CUDAGraphMode
+ from vllm.v1.worker.gpu.cudagraph_utils import (
+ BatchExecutionDescriptor,
+ CudaGraphManager,
+ )
+
+ manager = CudaGraphManager.__new__(CudaGraphManager)
+ manager.graphs = {}
+ manager._ple_full_copy_geometry = {}
+ manager._expected_ple_copy_layers = ("ple",)
+ manager._allow_ple_full_graph = True
+ desc = BatchExecutionDescriptor(
+ cg_mode=CUDAGraphMode.FULL,
+ num_tokens=8,
+ num_reqs=1,
+ )
+
+ success = SimpleNamespace(value=0)
+ monkeypatch.setattr(
+ ple_layer_module.torch.cuda,
+ "current_stream",
+ lambda: SimpleNamespace(cuda_stream=1),
+ )
+ monkeypatch.setattr(
+ ple_layer_module.cuda_driver,
+ "CUstream",
+ lambda value: value,
+ )
+ monkeypatch.setattr(
+ ple_layer_module.cuda_driver,
+ "CUdeviceptr",
+ lambda value: value,
+ )
+ monkeypatch.setattr(
+ ple_layer_module.cuda_driver,
+ "cuStreamWaitValue32",
+ lambda *_args: (success,),
+ )
+ monkeypatch.setattr(
+ ple_layer_module.cuda_driver,
+ "cuMemcpyHtoDAsync",
+ lambda *_args: (success,),
+ )
+
+ gpu_output_buffer = torch.empty(12, 4, dtype=torch.float32)
+ hidden_states = torch.empty(12, 1, dtype=torch.float32)
+
+ def capture_then_publish() -> None:
+ with manager.capture_ple_copy_geometry(desc):
+ ple_layer_module._ple_offload_wait_copy_impl(
+ gpu_output_buffer,
+ hidden_states,
+ 1,
+ 2,
+ 16,
+ 12,
+ "ple",
+ )
+ manager.graphs[desc] = object()
+
+ with pytest.raises(RuntimeError, match="actual PLE copy geometry"):
+ capture_then_publish()
+ assert desc not in manager._ple_full_copy_geometry
+ assert desc not in manager.graphs
+
+
+class _CleanupConfig:
+ def __init__(self, started_path: str, cleaned_path: str) -> None:
+ self.started_path = started_path
+ self.cleaned_path = cleaned_path
+
+ def __reduce__(self):
+ source = (
+ "import signal,threading\n"
+ "from pathlib import Path\n"
+ "from vllm.v1.ple_offload.worker import PleOffloadWorker\n"
+ f"_started=Path({self.started_path!r})\n"
+ f"_cleaned=Path({self.cleaned_path!r})\n"
+ "def _cleanup_proc_main(vllm_config,num_workers,ipc_addr,ready_pipe):\n"
+ " _stop=threading.Event()\n"
+ " signal.signal(signal.SIGTERM,lambda *_args:_stop.set())\n"
+ " _started.write_text('started',encoding='utf-8')\n"
+ " try:\n"
+ " _stop.wait()\n"
+ " finally:\n"
+ " _cleaned.write_text('cleaned',encoding='utf-8')\n"
+ "PleOffloadWorker.proc_main=staticmethod(_cleanup_proc_main)\n"
+ )
+ return exec, (source, {})
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="pass_fds is POSIX-only")
+def test_normal_close_runs_child_cleanup(tmp_path: Path) -> None:
+ from vllm.v1.ple_offload.worker import PleOffloadWorker
+
+ started = tmp_path / "cleanup-started"
+ cleaned = tmp_path / "cleanup-finished"
+ handle = PleOffloadWorker.make_process(
+ _CleanupConfig(str(started), str(cleaned)),
+ 0,
+ "unused",
+ )
+ try:
+ deadline = time.monotonic() + 120
+ while not started.exists() and time.monotonic() < deadline:
+ time.sleep(0.05)
+ assert started.exists()
+ finally:
+ handle.close()
+
+ assert handle.proc.returncode == 0
+ assert cleaned.read_text(encoding="utf-8") == "cleaned"
diff --git a/tests/v1/ple_offload/test_transport_fatal_helper.py b/tests/v1/ple_offload/test_transport_fatal_helper.py
new file mode 100644
index 0000000..4e5d5c6
--- /dev/null
+++ b/tests/v1/ple_offload/test_transport_fatal_helper.py
@@ -0,0 +1,387 @@
+# SPDX-License-Identifier: Apache-2.0
+
+import os
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+
+def _run_bounded_child(
+ source: str,
+ tmp_path: Path,
+ *arguments: str,
+ expected_exitcode: int = 0,
+) -> None:
+ marker = tmp_path / f"started-{time.monotonic_ns()}"
+ process = subprocess.Popen(
+ [sys.executable, "-c", source, str(marker), *arguments],
+ stderr=subprocess.DEVNULL,
+ start_new_session=os.name == "posix",
+ )
+ stayed_alive = False
+ try:
+ deadline = time.monotonic() + 120
+ while not marker.exists() and time.monotonic() < deadline:
+ time.sleep(0.05)
+ assert marker.exists(), (
+ "child did not finish cold startup within 120 seconds"
+ )
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ stayed_alive = True
+ finally:
+ if process.poll() is None:
+ if os.name == "posix":
+ os.killpg(process.pid, signal.SIGKILL)
+ else:
+ process.terminate()
+ process.wait(timeout=120)
+
+ assert not stayed_alive, "child stayed alive after the bounded operation"
+ assert process.returncode == expected_exitcode
+
+
+_FATAL_SOURCE = r"""
+import os
+import sys
+import threading
+from pathlib import Path
+
+from vllm.v1.ple_offload.fatal import ple_fail_closed
+
+scenario = sys.argv[2]
+
+if scenario == "blocking-name-property":
+ class Meta(type):
+ @property
+ def __name__(cls):
+ threading.Event().wait()
+
+ class Error(Exception, metaclass=Meta):
+ pass
+
+ error = Error()
+elif scenario == "blocking-str-descriptor":
+ class Descriptor:
+ def __get__(self, instance, owner):
+ threading.Event().wait()
+
+ class Error(Exception):
+ __str__ = Descriptor()
+
+ error = Error()
+elif scenario == "blocking-name-format":
+ class BlockingName(str):
+ def __format__(self, format_spec):
+ threading.Event().wait()
+
+ class Meta(type):
+ @property
+ def __name__(cls):
+ return BlockingName("Error")
+
+ class Error(Exception, metaclass=Meta):
+ pass
+
+ error = Error()
+elif scenario == "blocking-str-method":
+ class Error(Exception):
+ def __str__(self):
+ threading.Event().wait()
+
+ error = Error()
+elif scenario == "overridden-getattribute":
+ class Error(Exception):
+ def __getattribute__(self, name):
+ threading.Event().wait()
+
+ error = Error("detail")
+elif scenario == "str-subclass-argument":
+ class BlockingString(str):
+ def __format__(self, format_spec):
+ threading.Event().wait()
+
+ def __str__(self):
+ threading.Event().wait()
+
+ error = RuntimeError(BlockingString("detail"))
+elif scenario == "huge-string":
+ error = RuntimeError("x" * (32 << 20))
+else:
+ raise AssertionError(scenario)
+
+Path(sys.argv[1]).write_text("armed", encoding="utf-8")
+ple_fail_closed(
+ where="transport-fatal",
+ rank=1,
+ layer="ple",
+ sequence=3,
+ error=error,
+)
+"""
+
+
+@pytest.mark.parametrize(
+ "scenario",
+ [
+ "blocking-name-property",
+ "blocking-str-descriptor",
+ "blocking-name-format",
+ ],
+)
+def test_fatal_helper_avoids_exception_controlled_descriptors(
+ tmp_path: Path,
+ scenario: str,
+) -> None:
+ _run_bounded_child(
+ _FATAL_SOURCE,
+ tmp_path,
+ scenario,
+ expected_exitcode=1,
+ )
+
+
+@pytest.mark.parametrize(
+ "scenario",
+ [
+ "blocking-str-method",
+ "overridden-getattribute",
+ "str-subclass-argument",
+ "huge-string",
+ ],
+)
+def test_fatal_helper_hard_exit_controls(
+ tmp_path: Path,
+ scenario: str,
+) -> None:
+ _run_bounded_child(
+ _FATAL_SOURCE,
+ tmp_path,
+ scenario,
+ expected_exitcode=1,
+ )
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="process groups are POSIX-only")
+def test_wait_for_ready_rejects_non_string_status_without_comparison(
+ tmp_path: Path,
+) -> None:
+ source = r"""
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+import vllm.v1.ple_offload.worker as worker_module
+
+class Status:
+ def __eq__(self, other):
+ threading.Event().wait()
+
+ def __ne__(self, other):
+ threading.Event().wait()
+
+class Reader:
+ def close(self):
+ return None
+
+sleeper = subprocess.Popen(
+ [sys.executable, "-c", "import time; time.sleep(120)"]
+)
+handle = worker_module.PleOffloadWorkerHandle(
+ proc=sleeper,
+ death_writer=None,
+ ready_pipe_reader=Reader(),
+ launch_deadline=time.monotonic() + 120,
+)
+worker_module._recv_bounded_connection_message = (
+ lambda reader, deadline: {
+ "status": Status(),
+ "layer_names": ["ple"],
+ }
+)
+Path(sys.argv[1]).write_text("armed", encoding="utf-8")
+rejected = False
+closed = False
+reader_cleared = False
+try:
+ try:
+ worker_module.PleOffloadWorker.wait_for_ready(handle)
+ except RuntimeError:
+ rejected = True
+ # Postconditions are read before the emergency cleanup below can act.
+ # Stay inside the parent's 5 second bounded wait so a miss reports here.
+ close_deadline = time.monotonic() + 3
+ while sleeper.poll() is None and time.monotonic() < close_deadline:
+ time.sleep(0.05)
+ closed = sleeper.poll() is not None
+ reader_cleared = handle.ready_pipe_reader is None
+finally:
+ if sleeper.poll() is None:
+ sleeper.kill()
+ sleeper.wait(timeout=120)
+
+if not rejected:
+ raise AssertionError("invalid response status was not rejected")
+if not closed:
+ raise AssertionError("invalid response did not close the child")
+if not reader_cleared:
+ raise AssertionError("invalid response did not clear the ready reader")
+"""
+ _run_bounded_child(source, tmp_path)
+
+
+@pytest.mark.parametrize("identity", ["duplicate", "unbound"])
+def test_profile_breakable_initializer_follows_identity_validation(
+ tmp_path: Path,
+ identity: str,
+) -> None:
+ source = r"""
+import os
+import sys
+import threading
+from pathlib import Path
+from types import SimpleNamespace
+
+from vllm.v1.worker.gpu.cudagraph_utils import ModelCudaGraphManager
+
+identity = sys.argv[2]
+if identity == "duplicate":
+ identities = ("shared", "shared")
+else:
+ identities = ("<unbound>",)
+
+modules = [
+ (
+ "module_" + str(index),
+ SimpleNamespace(
+ _is_cpu_offloaded=True,
+ _host_output_ptr=1,
+ _ple_offload_name=value,
+ ),
+ )
+ for index, value in enumerate(identities)
+]
+model = SimpleNamespace(named_modules=lambda: iter(modules))
+manager = ModelCudaGraphManager.__new__(ModelCudaGraphManager)
+spy = Path(sys.argv[1] + ".initializer")
+
+def blocking_initializer(model):
+ spy.write_text("called", encoding="utf-8")
+ threading.Event().wait()
+
+manager.init_breakable_cg_runner = blocking_initializer
+Path(sys.argv[1]).write_text("armed", encoding="utf-8")
+
+prepare = getattr(manager, "_prepare_ple_capture", None)
+if prepare is None:
+ # This is the pre-fix profiling order: initialize before validation.
+ manager.init_breakable_cg_runner(model)
+else:
+ try:
+ prepare(model, initialize_breakable=True)
+ except RuntimeError as error:
+ if "distinct, bound" not in str(error):
+ raise
+ else:
+ raise AssertionError("invalid identities were accepted")
+
+if spy.exists():
+ raise AssertionError("initializer ran before identity rejection")
+"""
+ _run_bounded_child(source, tmp_path, identity)
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="process groups are POSIX-only")
+@pytest.mark.parametrize("status", ["READY", "FAILURE"])
+def test_oversized_startup_response_becomes_bounded_failure(
+ tmp_path: Path,
+ status: str,
+) -> None:
+ source = r"""
+import socket
+import sys
+from multiprocessing.connection import Connection
+from pathlib import Path
+
+import vllm.v1.ple_offload.worker as worker_module
+
+parent_socket, child_socket = socket.socketpair()
+raw_writer = Connection(child_socket.detach())
+writer_type = getattr(
+ worker_module,
+ "_BoundedStartupResponseWriter",
+ None,
+)
+writer = raw_writer if writer_type is None else writer_type(raw_writer)
+
+if sys.argv[2] == "READY":
+ message = {
+ "status": "READY",
+ "layer_names": ["x" * (2 << 20)],
+ }
+else:
+ message = {
+ "status": "FAILURE",
+ "error": "x" * (2 << 20),
+ }
+
+Path(sys.argv[1]).write_text("armed", encoding="utf-8")
+writer.send(message)
+"""
+ _run_bounded_child(source, tmp_path, status)
+
+
+def test_startup_response_payload_at_cap_is_accepted(tmp_path: Path) -> None:
+ source = r"""
+import pickle
+import sys
+import threading
+from pathlib import Path
+
+import vllm.v1.ple_offload.worker as worker_module
+
+writer_type = getattr(
+ worker_module,
+ "_BoundedStartupResponseWriter",
+ None,
+)
+Path(sys.argv[1]).write_text("armed", encoding="utf-8")
+if writer_type is None:
+ threading.Event().wait()
+
+cap = worker_module._PLE_READY_MAX_PAYLOAD_BYTES
+name = "x" * cap
+for unused in range(8):
+ message = {"status": "READY", "layer_names": [name]}
+ payload = pickle.dumps(message, protocol=pickle.HIGHEST_PROTOCOL)
+ if len(payload) == cap:
+ break
+ next_size = len(name) + cap - len(payload)
+ if next_size < 0:
+ raise AssertionError("cannot construct exact-cap payload")
+ name = "x" * next_size
+else:
+ raise AssertionError("exact-cap payload did not converge")
+
+class Writer:
+ def __init__(self):
+ self.payload = None
+
+ def send_bytes(self, payload):
+ self.payload = payload
+
+ def close(self):
+ return None
+
+raw_writer = Writer()
+writer_type(raw_writer).send(message)
+if raw_writer.payload != payload or len(raw_writer.payload) != cap:
+ raise AssertionError("exact-cap startup response was not preserved")
+"""
+ _run_bounded_child(source, tmp_path)
diff --git a/vllm/model_executor/layers/ple_offload_layer.py b/vllm/model_executor/layers/ple_offload_layer.py
new file mode 100644
index 0000000..1b67578
--- /dev/null
+++ b/vllm/model_executor/layers/ple_offload_layer.py
@@ -0,0 +1,433 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Base class for PLE layers that support cross-process CPU offload.
+
+Synchronization between the offload process and the GPU main stream is
+handled by :class:`CpuGpuSemaphore`, which uses CUDA's
+``cuStreamWaitValue32`` / ``cuStreamWriteValue32`` stream memory operations.
+
+Decode-step timeline, host-mapped PULL design
+--------------------------------------------------------
+Offload process (CPU, no CUDA) GPU main stream (forward)
+------------------------------ ------------------------------
+forward_impl() on CPU ... other GPU ops ...
+wait host flag == 0 WaitValue32(host flag == 1) <- in graph
+rows -> shared pinned host slot cuMemcpyHtoDAsync(slot -> GPU) <- in graph
+publish fence; host flag = 1 return _gpu_output_buffer[:n]
+ ... model consumes output ...
+ WriteValue32(host flag = 0) <- release_outputs
+
+``release_outputs`` performs the sole reset. Safety requires serialized local
+forwards on one ordered model stream: a new transaction cannot begin until the
+prior release has been enqueued. If any prepared forward is abandoned or its
+correspondence becomes uncertain, the worker must terminate; resetting,
+retrying, or continuing could reuse stale rows or overwrite live input.
+"""
+
+import contextlib
+import functools
+import threading
+from abc import ABC, abstractmethod
+from collections.abc import Callable, Iterator
+from typing import Any, cast
+
+import torch
+from cuda.bindings import driver as cuda_driver
+from cuda.bindings.driver import CUstreamWaitValue_flags
+from torch import nn
+
+import vllm.envs as envs
+from vllm.utils.torch_utils import direct_register_custom_op
+
+# Module-level flag set to True inside the offload subprocess.
+# Because the offload process and GPU worker processes are separate OS
+# processes (spawned via multiprocessing), each has its own memory space.
+# A plain module-level bool is sufficient -- no thread-local storage needed.
+_offload_worker_flag = False
+
+# Native FULL capture runs on one Python thread. The custom-op implementation
+# appends the exact arguments from every wait-copy invocation while that thread
+# is inside the graph manager's capture scope.
+_ple_copy_capture = threading.local()
+
+
+@contextlib.contextmanager
+def observe_ple_copy_geometry() -> Iterator[list[tuple[str, int, int]]]:
+ """Collect ``(layer, rows, bytes)`` from actual wait-copy executions."""
+ if getattr(_ple_copy_capture, "records", None) is not None:
+ raise RuntimeError("nested PLE copy-geometry observation is unsupported")
+ records: list[tuple[str, int, int]] = []
+ _ple_copy_capture.records = records
+ try:
+ yield records
+ finally:
+ del _ple_copy_capture.records
+
+
+def _record_ple_copy_geometry(
+ layer_name: str,
+ num_tokens: int,
+ row_bytes: int,
+) -> None:
+ records = getattr(_ple_copy_capture, "records", None)
+ if records is not None:
+ rows = int(num_tokens)
+ records.append((str(layer_name), rows, rows * int(row_bytes)))
+
+
+def is_offload_process() -> bool:
+ """Return True inside the dedicated PLE CPU-offload subprocess."""
+ return _offload_worker_flag
+
+
+def mark_as_offload_worker() -> None:
+ """Mark the current process as the dedicated PLE offload worker."""
+ global _offload_worker_flag
+ _offload_worker_flag = True
+
+
+def _cuda_check(result: Any, operation: str) -> Any:
+ """Check the ``(CUresult, ...)`` tuple returned by cuda-python calls."""
+ error = result[0] if isinstance(result, tuple) else result
+ if error.value != 0:
+ raise RuntimeError(f"{operation} failed: {error}")
+ return result
+
+
+class CpuGpuSemaphore:
+ """Cross-process semaphore backed by a one-element int32 CUDA tensor.
+
+ The flag is stored in a regular GPU tensor that is shared through
+ PyTorch's CUDA IPC mechanism. Both processes obtain device pointers that
+ map to the same physical GPU memory, so a stream-memory wait in the GPU
+ worker observes the write issued by the offload process.
+ """
+
+ RESET_VALUE = 0
+ DONE_VALUE = 1
+
+ def __init__(self, device: torch.device) -> None:
+ self._flag_tensor = torch.zeros(1, dtype=torch.int32, device=device)
+ self._flag_ptr: int | None = None
+
+ @classmethod
+ def from_ipc_tensor(cls, flag_tensor: torch.Tensor) -> "CpuGpuSemaphore":
+ """Construct a semaphore from a CUDA tensor received through IPC."""
+ semaphore = cls.__new__(cls)
+ semaphore._flag_tensor = flag_tensor
+ semaphore._flag_ptr = None
+ return semaphore
+
+ @classmethod
+ def from_host_mapped(
+ cls, host_flag_tensor: torch.Tensor, device_ptr: int
+ ) -> "CpuGpuSemaphore":
+ """Semaphore on a page-locked, device-mapped shared host int32.
+
+ ``host_flag_tensor`` keeps the shared storage alive; ``device_ptr`` is
+ its device address (``cuMemHostGetDevicePointer``). Stream memory ops
+ on it are executed by the GPU against host memory, so a CPU process
+ with no CUDA context can signal it with a plain store.
+ """
+ semaphore = cls.__new__(cls)
+ semaphore._flag_tensor = host_flag_tensor
+ semaphore._flag_ptr = int(device_ptr)
+ return semaphore
+
+ @property
+ def flag_tensor(self) -> torch.Tensor:
+ """Return the tensor that backs the semaphore flag."""
+ return self._flag_tensor
+
+ @property
+ def flag_ptr(self) -> int:
+ """Device address used by the stream memory operations."""
+ if self._flag_ptr is not None:
+ return self._flag_ptr
+ return self._flag_tensor.data_ptr()
+
+ def reset(self, stream: torch.cuda.Stream | None = None) -> None:
+ """Enqueue ``WriteValue32(flag=0)`` on ``stream``."""
+ if stream is None:
+ stream = torch.cuda.current_stream()
+ _cuda_check(
+ cuda_driver.cuStreamWriteValue32(
+ cuda_driver.CUstream(stream.cuda_stream),
+ cuda_driver.CUdeviceptr(self.flag_ptr),
+ self.RESET_VALUE,
+ 0,
+ ),
+ "CpuGpuSemaphore.reset",
+ )
+
+ def signal(self, stream: torch.cuda.Stream | None = None) -> None:
+ """Enqueue ``WriteValue32(flag=1)`` on ``stream``."""
+ if stream is None:
+ stream = torch.cuda.current_stream()
+ _cuda_check(
+ cuda_driver.cuStreamWriteValue32(
+ cuda_driver.CUstream(stream.cuda_stream),
+ cuda_driver.CUdeviceptr(self.flag_ptr),
+ self.DONE_VALUE,
+ 0,
+ ),
+ "CpuGpuSemaphore.signal",
+ )
+
+ def wait_reset(self, stream: torch.cuda.Stream | None = None) -> None:
+ """Enqueue ``WaitValue32(flag==0)`` on ``stream``."""
+ if stream is None:
+ stream = torch.cuda.current_stream()
+ _cuda_check(
+ cuda_driver.cuStreamWaitValue32(
+ cuda_driver.CUstream(stream.cuda_stream),
+ cuda_driver.CUdeviceptr(self.flag_ptr),
+ self.RESET_VALUE,
+ CUstreamWaitValue_flags.CU_STREAM_WAIT_VALUE_EQ.value,
+ ),
+ "CpuGpuSemaphore.wait_reset",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Custom op: vllm::ple_offload_wait
+# ---------------------------------------------------------------------------
+# Wraps cuStreamWaitValue32 as a torch custom op so that
+# torch.compile(fullgraph=True) treats the CUDA Driver call as an opaque node.
+# The hidden_states argument creates a dynamo data-dependency edge that prevents
+# the wait from being reordered before preceding GPU work.
+# ---------------------------------------------------------------------------
+
+
+def _ple_offload_wait_impl(
+ sem_flag_tensor: torch.Tensor,
+ gpu_output_buffer: torch.Tensor,
+ hidden_states: torch.Tensor,
+) -> None:
+ """Wait for the CPU result without releasing its output buffer."""
+ stream = torch.cuda.current_stream()
+ cuda_stream = cuda_driver.CUstream(stream.cuda_stream)
+ dev_ptr = cuda_driver.CUdeviceptr(sem_flag_tensor.data_ptr())
+ _cuda_check(
+ cuda_driver.cuStreamWaitValue32(
+ cuda_stream,
+ dev_ptr,
+ CpuGpuSemaphore.DONE_VALUE,
+ CUstreamWaitValue_flags.CU_STREAM_WAIT_VALUE_EQ.value,
+ ),
+ "cuStreamWaitValue32(done)",
+ )
+
+
+def _ple_offload_wait_fake(
+ sem_flag_tensor: torch.Tensor,
+ gpu_output_buffer: torch.Tensor,
+ hidden_states: torch.Tensor,
+) -> None:
+ """Represent the side-effect-only wait during dynamo tracing."""
+ pass
+
+
+direct_register_custom_op(
+ op_name="ple_offload_wait",
+ op_func=_ple_offload_wait_impl,
+ mutates_args=["gpu_output_buffer"],
+ fake_impl=_ple_offload_wait_fake,
+)
+
+
+# ---------------------------------------------------------------------------
+# Custom op: vllm::ple_offload_wait_copy (host-mapped pull design)
+# ---------------------------------------------------------------------------
+# The offload process writes the result into a page-locked shared host buffer
+# and stores flag=1 in a page-locked shared host int32; it holds no CUDA
+# context. On the CURRENT stream (captured into the graph like the wait above):
+# cuStreamWaitValue32(host flag == 1) -> GPU polls host memory
+# cuMemcpyAsync(gpu buffer <- host buffer, num_tokens * row_bytes)
+# The copy engine reads host memory coherently after the flag was observed,
+# and the consumers read device memory as before. The flag is NOT reset here:
+# ``release_outputs`` resets it after the forward (unchanged semantics), so
+# capture warm-ups and dummy runs behave exactly as with ``ple_offload_wait``.
+# Pointers are passed as ints so no CPU tensor enters the compiled graph.
+# ---------------------------------------------------------------------------
+
+
+def _ple_offload_wait_copy_impl(
+ gpu_output_buffer: torch.Tensor,
+ hidden_states: torch.Tensor,
+ flag_ptr: int,
+ host_ptr: int,
+ row_bytes: int,
+ num_tokens: int,
+ layer_name: str,
+) -> None:
+ """Wait for the host-side result, then DMA it into the GPU buffer."""
+ if not 0 <= int(num_tokens) <= gpu_output_buffer.shape[0] or int(row_bytes) != (
+ gpu_output_buffer.shape[1] * gpu_output_buffer.element_size()
+ ):
+ raise RuntimeError(
+ f"PLE wait_copy out of bounds: {num_tokens} rows x {row_bytes} B vs "
+ f"GPU buffer {tuple(gpu_output_buffer.shape)} {gpu_output_buffer.dtype}"
+ )
+ nbytes = int(num_tokens) * int(row_bytes)
+ _record_ple_copy_geometry(layer_name, num_tokens, row_bytes)
+ stream = cuda_driver.CUstream(torch.cuda.current_stream().cuda_stream)
+ _cuda_check(
+ cuda_driver.cuStreamWaitValue32(
+ stream,
+ cuda_driver.CUdeviceptr(flag_ptr),
+ CpuGpuSemaphore.DONE_VALUE,
+ CUstreamWaitValue_flags.CU_STREAM_WAIT_VALUE_EQ.value,
+ ),
+ "cuStreamWaitValue32(host flag done)",
+ )
+ if nbytes > 0:
+ # host_ptr is this worker's host address of page-locked (registered)
+ # memory, so the copy is a true async DMA and is capturable.
+ _cuda_check(
+ cuda_driver.cuMemcpyHtoDAsync(
+ cuda_driver.CUdeviceptr(gpu_output_buffer.data_ptr()),
+ host_ptr,
+ nbytes,
+ stream,
+ ),
+ "cuMemcpyHtoDAsync(PLE host -> GPU)",
+ )
+
+
+def _ple_offload_wait_copy_fake(
+ gpu_output_buffer: torch.Tensor,
+ hidden_states: torch.Tensor,
+ flag_ptr: int,
+ host_ptr: int,
+ row_bytes: int,
+ num_tokens: int,
+ layer_name: str,
+) -> None:
+ """Represent the side-effect-only wait + copy during dynamo tracing."""
+ pass
+
+
+direct_register_custom_op(
+ op_name="ple_offload_wait_copy",
+ op_func=_ple_offload_wait_copy_impl,
+ mutates_args=["gpu_output_buffer"],
+ fake_impl=_ple_offload_wait_copy_fake,
+)
+
+
+class PleOffloadLayer(nn.Module, ABC):
+ """Base class for embedding-like PLE layers that can run on CPU.
+
+ Subclasses implement :meth:`forward_impl`, which contains the regular
+ on-device computation. In a GPU worker with PLE offload enabled, subclass
+ constructors are skipped so their large weights are never allocated. The
+ CPU process constructs a complete copy and owns those weights instead.
+ """
+
+ _is_cpu_offloaded = False
+ _gpu_output_buffer: torch.Tensor
+ _sem: CpuGpuSemaphore
+ # Host-mapped pull design: host address of the shared output buffer and
+ # the bytes per output row. ``None`` keeps the IPC push behaviour.
+ _host_output_ptr: int | None = None
+ _host_row_bytes: int = 0
+ _ple_offload_name: str = "<unbound>"
+
+ def __init_subclass__(cls, **kwargs: object) -> None:
+ """Skip subclass initialization in cross-process GPU-worker mode."""
+ super().__init_subclass__(**kwargs)
+ original_init_obj = cls.__dict__.get("__init__")
+ if original_init_obj is None or not callable(original_init_obj):
+ return
+ original_init = cast(Callable[..., None], original_init_obj)
+
+ @functools.wraps(original_init)
+ def guarded_init(
+ self: "PleOffloadLayer", *args: object, **kwargs: object
+ ) -> None:
+ if envs.VLLM_PLE_CPU_OFFLOAD and not is_offload_process():
+ nn.Module.__init__(self)
+ return
+ original_init(self, *args, **kwargs)
+
+ cls.__init__ = guarded_init # type: ignore[method-assign, assignment]
+
+ @classmethod
+ def get_target_device(cls) -> torch.device:
+ """Return CPU for the offload process and the active GPU otherwise."""
+ if envs.VLLM_PLE_CPU_OFFLOAD:
+ return torch.device("cpu")
+ return torch.device("cuda", torch.accelerator.current_device_index())
+
+ @abstractmethod
+ def forward_impl(
+ self,
+ hidden_states: torch.Tensor,
+ input_ids: torch.Tensor,
+ *args: object,
+ **kwargs: object,
+ ) -> torch.Tensor:
+ """Execute the actual embedding computation on the owning device."""
+ raise NotImplementedError
+
+ def get_offload_output_dtype(self, default_dtype: torch.dtype) -> torch.dtype:
+ """Return the dtype used by cross-process output buffers."""
+ return default_dtype
+
+ def get_offload_output_dim(self, default_dim: int) -> int:
+ """Return the last dimension used by cross-process output buffers."""
+ return default_dim
+
+ def initialize_dummy_offload_metadata(self, device: torch.device) -> None:
+ """Initialize metadata needed by dummy offload execution."""
+
+ def setup_cross_process_offload(
+ self,
+ gpu_output_buffer: torch.Tensor,
+ semaphore: CpuGpuSemaphore,
+ host_output_ptr: int | None = None,
+ host_row_bytes: int = 0,
+ ) -> None:
+ """Configure the GPU-worker placeholder with its offload resources."""
+ self._is_cpu_offloaded = True
+ self._gpu_output_buffer = gpu_output_buffer
+ self._sem = semaphore
+ self._host_output_ptr = host_output_ptr
+ self._host_row_bytes = int(host_row_bytes)
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ input_ids: torch.Tensor,
+ *args: object,
+ **kwargs: object,
+ ) -> torch.Tensor:
+ """Wait for an offloaded result or delegate to ``forward_impl``."""
+ if self._is_cpu_offloaded:
+ if self._host_output_ptr is not None:
+ torch.ops.vllm.ple_offload_wait_copy(
+ self._gpu_output_buffer,
+ hidden_states,
+ self._sem.flag_ptr,
+ self._host_output_ptr,
+ self._host_row_bytes,
+ input_ids.shape[0],
+ self._ple_offload_name,
+ )
+ else:
+ torch.ops.vllm.ple_offload_wait(
+ self._sem.flag_tensor,
+ self._gpu_output_buffer,
+ hidden_states,
+ )
+ return self._gpu_output_buffer[: input_ids.shape[0]]
+ return self.forward_impl(hidden_states, input_ids, *args, **kwargs)
+
+ def release_offloaded_output(
+ self,
+ stream: torch.cuda.Stream | None = None,
+ ) -> None:
+ """Mark the cross-process output buffer reusable on ``stream``."""
+ if self._is_cpu_offloaded:
+ self._sem.reset(stream)
diff --git a/vllm/v1/executor/multiproc_executor.py b/vllm/v1/executor/multiproc_executor.py
index 1ccd23a..e27a18b 100644
--- a/vllm/v1/executor/multiproc_executor.py
+++ b/vllm/v1/executor/multiproc_executor.py
@@ -352,9 +352,10 @@ class MultiprocExecutor(Executor):
proc = next(h.proc for h in workers if h.proc.sentinel == died[0])
logger.error(
"Worker proc %s died unexpectedly (exit code: %s), "
- "shutting down executor.",
+ "shutting down executor. executor_shutdown_monotonic=%.9f",
proc.name,
proc.exitcode,
+ time.monotonic(),
)
_self.shutdown()
callback = _self.failure_callback
@@ -1034,6 +1035,28 @@ class WorkerProc:
SUCCESS = auto()
FAILURE = auto()
+ def _fail_ple_rpc_if_pending(
+ self, where: str, error: BaseException
+ ) -> None:
+ """Fail closed if RPC recovery would abandon queued PLE GPU work."""
+ owners = (self.worker, getattr(self.worker, "worker", None))
+ for owner in owners:
+ if owner is None:
+ continue
+ runner = getattr(owner, "model_runner", None)
+ connector = getattr(runner, "_ple_offload_connector", None)
+ if connector is None:
+ continue
+ try:
+ pending = connector.has_pending_completions
+ except BaseException as state_error:
+ connector._fail_closed(
+ f"{where}:pending-state", state_error
+ )
+ if pending:
+ connector._fail_closed(where, error)
+ return
+
def enqueue_output(self, output: Any):
"""Prepares output from the worker and enqueues it to the
worker_response_mq. If the output is an Exception, it is
@@ -1044,6 +1067,9 @@ class WorkerProc:
output = output.get_output()
except Exception as e:
logger.exception("Error getting async model runner output")
+ self._fail_ple_rpc_if_pending(
+ "async-output-copy", e
+ )
output = e
if isinstance(output, Exception):
@@ -1104,6 +1130,9 @@ class WorkerProc:
if output_rank is None or self.rank == output_rank:
self.handle_output(output)
except Exception as e:
+ self._fail_ple_rpc_if_pending(
+ f"worker-rpc:{method}", e
+ )
# Notes have been introduced in python 3.11
if hasattr(e, "add_note"):
e.add_note(traceback.format_exc())
diff --git a/vllm/v1/ple_offload/__init__.py b/vllm/v1/ple_offload/__init__.py
new file mode 100644
index 0000000..208f01a
--- /dev/null
+++ b/vllm/v1/ple_offload/__init__.py
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
diff --git a/vllm/v1/ple_offload/connector.py b/vllm/v1/ple_offload/connector.py
new file mode 100644
index 0000000..52bc462
--- /dev/null
+++ b/vllm/v1/ple_offload/connector.py
@@ -0,0 +1,855 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Exchange PLE data between a GPU worker and the CPU-offload process."""
+
+import os
+import queue
+import threading
+from dataclasses import dataclass
+from multiprocessing.reduction import ForkingPickler
+from typing import Any
+
+import msgspec
+import torch
+from vllm.v1.ple_offload import rowdump as _rowdump
+from vllm.v1.ple_offload.fatal import ple_fail_closed
+from vllm.v1.ple_offload.progress import PleProgressMonitor
+import torch.nn as nn
+import zmq
+from cuda.bindings import driver as cuda_driver
+
+from vllm.config import VllmConfig
+from vllm.distributed.parallel_state import get_dp_group, get_pp_group, get_tp_group
+from vllm.logger import init_logger
+from vllm.model_executor.layers.ple_offload_layer import (
+ CpuGpuSemaphore,
+ PleOffloadLayer,
+)
+from vllm.v1.ple_offload.protocol import (
+ PleOffloadRegistration,
+ PleOffloadRequest,
+)
+from vllm.v1.utils import record_function_or_nullcontext
+from vllm_ple_test_fault import _FAULT
+
+logger = init_logger(__name__)
+
+# CUDA registration alone does not own Python storage. Captured graphs retain
+# raw addresses, so every successfully registered host allocation remains
+# strongly referenced until process exit.
+_PROCESS_LIFETIME_REGISTERED_STORAGE: list[torch.Tensor] = []
+
+
+def _unlink_filename_shared_storage(
+ tensor: torch.Tensor,
+ reserved_filenames: set[bytes],
+) -> None:
+ """Reserve and unlink one already filename-shared CPU storage."""
+ try:
+ storage = tensor.untyped_storage()
+ if storage.device.type != "cpu" or not storage.is_shared():
+ return
+ try:
+ storage._get_shared_fd()
+ except RuntimeError:
+ # Filename-shared storages do not expose a shared fd. Only now is
+ # it safe to query the filename: doing so on ordinary or fd-shared
+ # storage would convert it to a new filename-backed allocation.
+ pass
+ else:
+ return
+ _, filename, _ = storage._share_filename_cpu_()
+ if not isinstance(filename, bytes):
+ filename = os.fsencode(filename)
+ path = os.path.join("/dev/shm", os.fsdecode(filename).lstrip("/"))
+ except Exception:
+ logger.warning(
+ "Failed to inspect a PLE shared-memory storage during teardown",
+ exc_info=True,
+ )
+ return
+
+ # RefcountedMapAllocator unlinks the name when its shared counter reaches
+ # zero. Reserve one count before manual unlink so later storage destruction
+ # cannot try to unlink the already-removed name. The filename identifies
+ # the shared counter and also deduplicates tensor aliases and repeated close.
+ if filename in reserved_filenames:
+ return
+ try:
+ storage._shared_incref()
+ except Exception:
+ logger.warning(
+ "Failed to reserve the PLE shared-memory counter for %s",
+ path,
+ exc_info=True,
+ )
+ return
+ reserved_filenames.add(filename)
+
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ # Another teardown participant already removed the name. Retain the
+ # reservation because this allocator must still avoid a later unlink.
+ pass
+ except OSError as error:
+ # The name remains available, so restore the allocator's original
+ # counter and permit a later release attempt to retry.
+ try:
+ storage._shared_decref()
+ except Exception:
+ # The reservation state is uncertain after a failed private call.
+ # Keep it marked to avoid adding another reservation on retry.
+ logger.warning(
+ "Failed to roll back the PLE shared-memory counter for %s",
+ path,
+ exc_info=True,
+ )
+ else:
+ reserved_filenames.discard(filename)
+ logger.warning("Failed to unlink PLE shared memory %s: %s", path, error)
+
+
+@dataclass(frozen=True)
+class _PendingPleOffloadRequest:
+ """Bind request metadata to its MRV2 D2H completion event."""
+
+ request: PleOffloadRequest
+ d2h_done_event: torch.cuda.Event
+ eligible: bool = False
+ transaction_id: str | None = None
+ correlation_id: str | None = None
+
+
+def _cuda_check(result: Any, operation: str) -> Any:
+ """Check the ``(CUresult, ...)`` tuple returned by cuda-python calls."""
+ error = result[0] if isinstance(result, tuple) else result
+ if error.value != 0:
+ raise RuntimeError(f"{operation} failed: {error}")
+ return result
+
+
+class PleOffloadConnector:
+ """Connect a model runner V2 GPU worker to the PLE CPU worker."""
+
+ def __init__(
+ self,
+ vllm_config: VllmConfig,
+ model: nn.Module,
+ device: torch.device,
+ ipc_addr: str,
+ *,
+ input_ids_source: torch.Tensor,
+ query_start_loc_source: torch.Tensor,
+ ngram_context_source: torch.Tensor | None,
+ ) -> None:
+ self.device = device
+ self.rank = int(vllm_config.parallel_config.rank)
+ self.dp_rank = get_dp_group().rank_in_group
+ self.tp_rank = get_tp_group().rank_in_group
+ self.pp_rank = get_pp_group().rank_in_group
+ # Host-mapped pull design: per layer a shared host output buffer and a
+ # shared host int32 flag (element 0), filled/stored by the offload
+ # process with plain CPU writes. Page-locked + device-mapped here only
+ # after registration, because serialising for registration may move
+ # the shared storage to a new address.
+ self._host_output_bufs: dict[str, torch.Tensor] = {}
+ self._host_flags: dict[str, torch.Tensor] = {}
+ self._reserved_host_storage_filenames: set[bytes] = set()
+ self._gpu_output_bufs: dict[str, torch.Tensor] = {}
+ self._layers = self._setup_layers(vllm_config, model)
+ self._progress = PleProgressMonitor.from_environment(
+ rank=self.rank,
+ layer_names=tuple(self._layers),
+ )
+
+ # TP0 registers shared buffers with CUDA for asynchronous D2H copies.
+ scheduler_config = vllm_config.scheduler_config
+ self._input_ids_buf = torch.empty(
+ scheduler_config.max_num_batched_tokens,
+ dtype=torch.int32,
+ device="cpu",
+ ).share_memory_()
+ self._query_start_loc_buf = torch.empty(
+ scheduler_config.max_num_seqs + 1,
+ dtype=torch.int32,
+ device="cpu",
+ ).share_memory_()
+ self._ngram_context_buf = None
+ config = vllm_config.model_config.hf_text_config
+ ngram_context_len = int(config.ngram_size) - 1
+ if ngram_context_len > 0:
+ self._ngram_context_buf = torch.empty(
+ scheduler_config.max_num_seqs,
+ ngram_context_len,
+ dtype=torch.int32,
+ device="cpu",
+ ).share_memory_()
+
+ # Runner input allocations are address-stable, so bind them once and
+ # pass only batch sizes through the per-forward request queue.
+ self._input_ids_source = input_ids_source
+ self._query_start_loc_source = query_start_loc_source
+ self._ngram_context_source = ngram_context_source
+ self._validate_input_sources()
+
+ self._pinned_input_buffers: list[torch.Tensor] = []
+ # Device-mapped host outputs/flags referenced by raw address from
+ # captured graphs: never unregistered while the process lives
+ # (unregistering under a pending graph wait unmaps it).
+ self._mapped_host_buffers: list[torch.Tensor] = []
+ # True while dummy/capture work (seeds, dummy forwards, capture
+ # cleanup resets) has run since the last real forward. Releases in
+ # that state complete synchronously, and the next real forward first
+ # passes a TP barrier, so no rank can publish a real request while
+ # another rank still has a dummy/cleanup reset pending.
+ # In this tree real-path kernel warm-ups run BEFORE
+ # capture and capture adds seeds/resets after them, so this must hold
+ # at every dummy->real transition, not only at the first real one.
+ self._after_dummy = True
+ self._request_queue: queue.Queue[_PendingPleOffloadRequest | None] = (
+ queue.Queue(maxsize=vllm_config.max_concurrent_batches)
+ )
+ self._dropped_events: list[torch.cuda.Event] = []
+ self._request_thread: threading.Thread | None = None
+ self._request_thread_ready = threading.Event()
+ self._zmq_ctx: zmq.Context | None = None
+ self._registration_socket: zmq.Socket | None = None
+ self._d2h_event_pool: queue.Queue[torch.cuda.Event] | None = None
+
+ try:
+ self._zmq_ctx = zmq.Context()
+ self._registration_socket = self._zmq_ctx.socket(zmq.PUSH)
+ self._registration_socket.connect(ipc_addr)
+ payload = self._serialize_registration(vllm_config)
+
+ # Serialising moved the shared storages to their final addresses.
+ # Page-lock/map exactly those, THEN publish, so a received
+ # registration always means "storage usable".
+ with torch.accelerator.device_index(self.device.index):
+ self._map_host_outputs(vllm_config)
+ if self.tp_rank == 0:
+ self._pin_input_buffers()
+ self._send_registration(payload, ipc_addr)
+
+ if self.tp_rank == 0:
+ with torch.accelerator.device_index(self.device.index):
+ self._d2h_event_pool = queue.Queue(
+ maxsize=vllm_config.max_concurrent_batches
+ )
+ for _ in range(vllm_config.max_concurrent_batches):
+ self._d2h_event_pool.put_nowait(torch.cuda.Event())
+ self._start_request_thread(ipc_addr)
+ except Exception:
+ self.close()
+ raise
+
+ def _setup_layers(
+ self,
+ vllm_config: VllmConfig,
+ model: nn.Module,
+ ) -> dict[str, PleOffloadLayer]:
+ """Attach output buffers and semaphores to GPU PLE placeholders."""
+ layers = {
+ name: module
+ for name, module in model.named_modules()
+ if isinstance(module, PleOffloadLayer)
+ }
+ if not layers:
+ raise RuntimeError(
+ "VLLM_PLE_CPU_OFFLOAD is enabled, but the model has no PleOffloadLayer"
+ )
+
+ config = vllm_config.model_config.hf_text_config
+ max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens
+ for name, layer in layers.items():
+ if vllm_config.load_config.load_format == "dummy":
+ layer.initialize_dummy_offload_metadata(self.device)
+ out_dim = layer.get_offload_output_dim(int(config.ple_embed_dim))
+ out_dtype = layer.get_offload_output_dtype(vllm_config.model_config.dtype)
+ # The captured forward DMAs the host result into this device
+ # buffer after the host flag is set; consumers read it as before.
+ self._gpu_output_bufs[name] = torch.empty(
+ max_num_tokens, out_dim, dtype=out_dtype, device=self.device
+ )
+ # Zero-initialised so dummy/profile forwards copy finite values.
+ self._host_output_bufs[name] = torch.zeros(
+ max_num_tokens, out_dim, dtype=out_dtype, device="cpu"
+ ).share_memory_()
+ # 16 x int32 = one 64-byte line; element 0 is the flag.
+ self._host_flags[name] = torch.zeros(
+ 16, dtype=torch.int32, device="cpu"
+ ).share_memory_()
+ return layers
+
+ def _map_host_outputs(self, vllm_config: VllmConfig) -> None:
+ """Page-lock + device-map host outputs/flags and arm the placeholders."""
+ for name, layer in self._layers.items():
+ layer._ple_offload_name = name
+ host_out = self._host_output_bufs[name]
+ host_flag = self._host_flags[name]
+ for buffer in (host_out, host_flag):
+ self._register_host_buffer(buffer, device_map=True)
+ flag_dev_ptr = _cuda_check(
+ cuda_driver.cuMemHostGetDevicePointer(host_flag.data_ptr(), 0),
+ "cuMemHostGetDevicePointer(PLE host flag)",
+ )[1]
+ out_dev_ptr = _cuda_check(
+ cuda_driver.cuMemHostGetDevicePointer(host_out.data_ptr(), 0),
+ "cuMemHostGetDevicePointer(PLE host output)",
+ )[1]
+ row_bytes = host_out.shape[1] * host_out.element_size()
+ gpu_buf = self._gpu_output_bufs[name]
+ if (
+ gpu_buf.shape[0] != host_out.shape[0]
+ or gpu_buf.shape[1] * gpu_buf.element_size() != row_bytes
+ ):
+ raise RuntimeError(f"PLE host/GPU buffer geometry mismatch for {name}")
+ # Stream memops use the flag's DEVICE pointer; the H2D copy uses
+ # this worker's HOST pointer (no raw addresses cross
+ # processes; the offload writes through its own mapping).
+ layer.setup_cross_process_offload(
+ gpu_buf,
+ CpuGpuSemaphore.from_host_mapped(host_flag, int(flag_dev_ptr)),
+ host_output_ptr=host_out.data_ptr(),
+ host_row_bytes=row_bytes,
+ )
+ logger.info(
+ "PleOffload: host-mapped pull for %s (rows %d x %d B, flag dev "
+ "0x%x, out dev 0x%x, host==dev %s)",
+ name,
+ host_out.shape[0],
+ row_bytes,
+ int(flag_dev_ptr),
+ int(out_dev_ptr),
+ int(out_dev_ptr) == host_out.data_ptr(),
+ )
+
+ def _pin_input_buffers(self) -> None:
+ """Page-lock shared input allocations without replacing their storage."""
+ buffers = [self._input_ids_buf, self._query_start_loc_buf]
+ if self._ngram_context_buf is not None:
+ buffers.append(self._ngram_context_buf)
+ for buffer in buffers:
+ self._register_host_buffer(buffer, device_map=False)
+
+ def _register_host_buffer(self, buffer: torch.Tensor, device_map: bool) -> None:
+ """Page-lock (and optionally device-map) a shared CPU allocation."""
+ if buffer.device.type != "cpu" or not buffer.is_shared():
+ raise RuntimeError("PLE host buffers must be shared CPU tensors")
+ if not buffer.is_contiguous():
+ raise RuntimeError("PLE host buffers must be contiguous")
+ flags = cuda_driver.CU_MEMHOSTREGISTER_PORTABLE
+ if device_map:
+ flags |= cuda_driver.CU_MEMHOSTREGISTER_DEVICEMAP
+ _cuda_check(
+ cuda_driver.cuMemHostRegister(
+ buffer.data_ptr(),
+ buffer.numel() * buffer.element_size(),
+ flags,
+ ),
+ "cuMemHostRegister(PLE host buffer)",
+ )
+ _PROCESS_LIFETIME_REGISTERED_STORAGE.append(buffer)
+ if device_map:
+ self._mapped_host_buffers.append(buffer)
+ else:
+ self._pinned_input_buffers.append(buffer)
+ if not buffer.is_pinned():
+ raise RuntimeError("CUDA did not page-lock a PLE host buffer")
+
+ def _unpin_input_buffers(self) -> None:
+ """Release CUDA registrations after the request thread has stopped."""
+ for buffer in reversed(self._pinned_input_buffers):
+ try:
+ _cuda_check(
+ cuda_driver.cuMemHostUnregister(buffer.data_ptr()),
+ "cuMemHostUnregister(PLE input buffer)",
+ )
+ except RuntimeError:
+ logger.exception("Failed to unregister a PLE input buffer")
+ self._pinned_input_buffers.clear()
+
+ def _serialize_registration(self, vllm_config: VllmConfig) -> Any:
+ """Build and serialise the registration (may move shared storages)."""
+ # Each GPU worker owns distinct host output buffers and flags, while
+ # TP0's shared inputs become the request source for its DP rank.
+ registration = PleOffloadRegistration(
+ worker_id=(
+ self.dp_rank * vllm_config.parallel_config.world_size
+ + vllm_config.parallel_config.rank
+ ),
+ tp_rank=self.tp_rank,
+ dp_rank=self.dp_rank,
+ host_output_buffers=dict(self._host_output_bufs),
+ host_flag_tensors=dict(self._host_flags),
+ input_ids_buf=self._input_ids_buf,
+ query_start_loc_buf=self._query_start_loc_buf,
+ ngram_context_buf=self._ngram_context_buf,
+ )
+
+ # ForkingPickler transmits tensors through shared-memory and CUDA IPC.
+ import torch.multiprocessing as torch_mp
+
+ original_strategy = torch_mp.get_sharing_strategy()
+ torch_mp.set_sharing_strategy("file_system")
+ try:
+ payload = ForkingPickler.dumps(registration)
+ finally:
+ torch_mp.set_sharing_strategy(original_strategy)
+ return payload
+
+ def _send_registration(self, payload: Any, ipc_addr: str) -> None:
+ """Publish the serialised registration (storages already mapped)."""
+ assert self._registration_socket is not None
+ self._registration_socket.send(payload)
+
+ logger.info(
+ "PleOffload: registered %d PleOffloadLayer(s) "
+ "(dp_rank=%d, tp_rank=%d, ipc_addr=%s): %s",
+ len(self._layers),
+ self.dp_rank,
+ self.tp_rank,
+ ipc_addr,
+ sorted(self._layers),
+ )
+
+ def _start_request_thread(self, ipc_addr: str) -> None:
+ """Start the thread that publishes batches after inputs are ready."""
+ self._request_thread = threading.Thread(
+ target=self._request_loop,
+ args=(ipc_addr,),
+ name=f"ple-offload-dp{self.dp_rank}",
+ daemon=True,
+ )
+ self._request_thread.start()
+ if not self._request_thread_ready.wait(timeout=10):
+ raise RuntimeError("Timed out starting the PLE request thread")
+
+ def _request_loop(self, ipc_addr: str) -> None:
+ """Wait for staged inputs, then notify the CPU worker."""
+ socket: zmq.Socket | None = None
+ try:
+ if self._zmq_ctx is None:
+ raise RuntimeError("PLE ZMQ context closed before thread startup")
+ socket = self._zmq_ctx.socket(zmq.PUSH)
+ socket.connect(ipc_addr)
+ self._request_thread_ready.set()
+ while True:
+ request = self._request_queue.get()
+ if request is None:
+ return
+ self._process_request(request, socket)
+ except Exception:
+ logger.exception("PLE request thread failed")
+ os._exit(1)
+ finally:
+ self._request_thread_ready.set()
+ if socket is not None:
+ socket.close(linger=0)
+
+ def _process_request(
+ self, pending: _PendingPleOffloadRequest, socket: zmq.Socket
+ ) -> None:
+ """Wait for one staged batch and publish its request."""
+ request = pending.request
+ event_pool = self._d2h_event_pool
+ event = pending.d2h_done_event
+ assert event_pool is not None, "PLE D2H event pool is not initialized"
+ if _FAULT is not None and _FAULT.fires(
+ "block_input_completion",
+ role="gpu",
+ unit="connector-request",
+ eligible=pending.eligible,
+ tp_rank=self.tp_rank,
+ pp_rank=self.pp_rank,
+ dp_rank=self.dp_rank,
+ transaction_id=pending.transaction_id,
+ correlation_id=pending.correlation_id,
+ ):
+ threading.Event().wait()
+
+ with torch.accelerator.device_index(self.device.index):
+ event.synchronize()
+
+ payload = msgspec.msgpack.encode(request)
+ if _FAULT is None:
+ socket.send(payload)
+ else:
+ # Armed runs only: correlation metadata rides as a second frame.
+ metadata = msgspec.msgpack.encode(
+ {
+ "serving": pending.eligible,
+ "transaction": pending.transaction_id,
+ "correlation": pending.correlation_id,
+ }
+ )
+ socket.send_multipart([payload, metadata])
+ event_pool.put_nowait(event)
+
+ def _validate_input_sources(self) -> None:
+ """Validate fixed runner sources against shared input buffers."""
+ sources = [
+ ("input_ids", self._input_ids_source, self._input_ids_buf),
+ (
+ "query_start_loc",
+ self._query_start_loc_source,
+ self._query_start_loc_buf,
+ ),
+ ]
+ if (self._ngram_context_source is None) != (self._ngram_context_buf is None):
+ raise ValueError("PLE ngram_context source and buffer must match")
+ if self._ngram_context_source is not None:
+ assert self._ngram_context_buf is not None
+ sources.append(
+ (
+ "ngram_context",
+ self._ngram_context_source,
+ self._ngram_context_buf,
+ )
+ )
+
+ for name, source, buffer in sources:
+ if (
+ source.device != self.device
+ or source.dtype != buffer.dtype
+ or source.ndim != buffer.ndim
+ or source.shape[0] < buffer.shape[0]
+ or source.shape[1:] != buffer.shape[1:]
+ ):
+ raise ValueError(f"PLE {name} source is incompatible")
+
+ def _enqueue_cuda_inputs(
+ self,
+ request: PleOffloadRequest,
+ d2h_done_event: torch.cuda.Event,
+ ) -> None:
+ """Stage inputs on the model stream and record D2H completion."""
+ with (
+ record_function_or_nullcontext("ple_offload: enqueue_cuda_inputs"),
+ torch.accelerator.device_index(self.device.index),
+ ):
+ stream = torch.cuda.current_stream(self.device)
+ self._input_ids_buf[: request.num_tokens].copy_(
+ self._input_ids_source[: request.num_tokens],
+ non_blocking=True,
+ )
+ self._query_start_loc_buf[: request.num_reqs + 1].copy_(
+ self._query_start_loc_source[: request.num_reqs + 1],
+ non_blocking=True,
+ )
+ if self._ngram_context_buf is not None:
+ assert self._ngram_context_source is not None
+ self._ngram_context_buf[: request.num_reqs].copy_(
+ self._ngram_context_source[: request.num_reqs],
+ non_blocking=True,
+ )
+ d2h_done_event.record(stream)
+
+ def _launch(
+ self,
+ num_reqs: int,
+ num_tokens: int,
+ *,
+ serving: bool = False,
+ transaction_id: str | None = None,
+ correlation_id: str | None = None,
+ ) -> None:
+ """Stage or queue one batch for request publication."""
+ # Inputs are replicated across TP ranks. One request per DP rank drives
+ # the CPU result fan-out to every registered TP output buffer.
+ if self.tp_rank != 0:
+ return
+
+ request = PleOffloadRequest(
+ dp_rank=self.dp_rank,
+ num_tokens=num_tokens,
+ num_reqs=num_reqs,
+ )
+ assert self._d2h_event_pool is not None, "PLE D2H event pool is not initialized"
+ try:
+ d2h_done_event = self._d2h_event_pool.get_nowait()
+ except queue.Empty as exc:
+ raise RuntimeError(
+ "PLE has more requests than configured concurrent batches"
+ ) from exc
+ self._enqueue_cuda_inputs(request, d2h_done_event)
+ if _FAULT is not None and _FAULT.fires(
+ "drop_request",
+ role="gpu",
+ unit="connector-request",
+ eligible=serving,
+ tp_rank=self.tp_rank,
+ pp_rank=self.pp_rank,
+ dp_rank=self.dp_rank,
+ transaction_id=transaction_id,
+ correlation_id=correlation_id,
+ ):
+ # Deliberately retain the event: the fault is loss after D2H, not
+ # premature event-pool reuse while that D2H may still be in flight.
+ self._dropped_events.append(d2h_done_event)
+ return
+ self._request_queue.put_nowait(
+ _PendingPleOffloadRequest(
+ request,
+ d2h_done_event,
+ eligible=serving,
+ transaction_id=transaction_id,
+ correlation_id=correlation_id,
+ )
+ )
+
+ def _fail_closed(self, where: str, error: BaseException) -> None:
+ ple_fail_closed(
+ where=where,
+ rank=self.rank,
+ layer=",".join(self._layers),
+ sequence=self._progress.sequence,
+ error=error,
+ )
+
+ @property
+ def has_pending_completions(self) -> bool:
+ return self._progress.has_pending_completions
+
+ def begin_forward(self, *, dummy_run: bool, num_tokens: int) -> int:
+ """Begin the bounded transaction before any runner fault boundary."""
+ try:
+ return self._progress.begin(
+ startup=dummy_run,
+ num_tokens=num_tokens,
+ )
+ except Exception as error:
+ self._fail_closed("prepare_forward", error)
+
+ def fault_transaction(self, sequence: int) -> str:
+ """Read diagnostic state only when the caller has established arming."""
+ return self._progress.describe_submission(sequence)
+
+ def completion_is_pending(self, sequence: int) -> bool:
+ """Return exact-sequence incomplete completion state for cut 6."""
+ return self._progress.completion_is_pending(sequence)
+
+ def prepare_forward(
+ self,
+ num_reqs: int,
+ num_tokens: int,
+ dummy_run: bool,
+ *,
+ consumer_num_tokens: int | None = None,
+ serving: bool = False,
+ transaction_id: str | None = None,
+ correlation_id: str | None = None,
+ ) -> None:
+ """Prepare one PLE transaction; any ordinary exception is fatal."""
+ try:
+ self._prepare_forward_impl(
+ num_reqs,
+ num_tokens,
+ dummy_run,
+ consumer_num_tokens=consumer_num_tokens,
+ serving=serving,
+ transaction_id=transaction_id,
+ correlation_id=correlation_id,
+ )
+ except Exception as error:
+ self._fail_closed("prepare_forward", error)
+
+ def _prepare_forward_impl(
+ self,
+ num_reqs: int,
+ num_tokens: int,
+ dummy_run: bool,
+ *,
+ consumer_num_tokens: int | None = None,
+ serving: bool = False,
+ transaction_id: str | None = None,
+ correlation_id: str | None = None,
+ ) -> None:
+ """Submit real inputs or satisfy the PLE wait for a dummy forward."""
+ if consumer_num_tokens is None:
+ consumer_num_tokens = num_tokens
+ if int(num_tokens) != int(consumer_num_tokens):
+ raise RuntimeError(
+ "PLE request/consumer token-count mismatch: "
+ f"request={num_tokens}, consumer={consumer_num_tokens}"
+ )
+ if not 0 <= int(num_tokens) <= self._input_ids_buf.shape[0]:
+ raise RuntimeError(
+ f"PLE token count {num_tokens} exceeds connector capacity "
+ f"{self._input_ids_buf.shape[0]}"
+ )
+ if not 0 <= int(num_reqs) < self._query_start_loc_buf.shape[0]:
+ raise RuntimeError(
+ f"PLE request count {num_reqs} exceeds connector capacity "
+ f"{self._query_start_loc_buf.shape[0] - 1}"
+ )
+ self._rowdump_n = None if dummy_run else num_tokens
+ if dummy_run:
+ self._signal_dummy_outputs_impl(num_tokens)
+ return
+ if self._after_dummy:
+ # Every dummy/cleanup reset on THIS rank already completed
+ # (synchronous releases); the barrier extends that to every
+ # stage-0 TP rank before TP0 can publish (CPU group, no GPU work;
+ # all TP ranks run the same forwards in the same order).
+ get_tp_group().barrier()
+ self._after_dummy = False
+ logger.info(
+ "PleOffload: dummy->real transition (dp_rank=%d, tp_rank=%d): "
+ "releases were synchronous, TP barrier passed",
+ self.dp_rank,
+ self.tp_rank,
+ )
+ self._launch(
+ num_reqs,
+ num_tokens,
+ serving=serving,
+ transaction_id=transaction_id,
+ correlation_id=correlation_id,
+ )
+
+ def signal_dummy_outputs(self, num_tokens: int) -> None:
+ """Locally satisfy PLE waits for dummy and capture forwards."""
+ self._progress.begin(startup=True, num_tokens=num_tokens)
+ self._signal_dummy_outputs_impl(num_tokens)
+
+ def _signal_dummy_outputs_impl(self, num_tokens: int) -> None:
+ """Signal dummy outputs after the progress transaction is armed."""
+ # Dummy and capture forwards do not send CPU requests, but every PLE
+ # placeholder still waits for a completed output semaphore.
+ self._after_dummy = True
+ stream = torch.cuda.current_stream(self.device)
+ for layer in self._layers.values():
+ layer._gpu_output_buffer[:num_tokens].zero_()
+ layer._sem.signal(stream)
+
+ def release_outputs(
+ self,
+ *,
+ serving: bool = False,
+ transaction_id: str | None = None,
+ correlation_id: str | None = None,
+ ) -> None:
+ """Release one PLE transaction; any ordinary exception is fatal."""
+ try:
+ self._release_outputs_impl(
+ serving=serving,
+ transaction_id=transaction_id,
+ correlation_id=correlation_id,
+ )
+ self._progress.complete()
+ except Exception as error:
+ self._fail_closed("release_outputs", error)
+
+ def _release_outputs_impl(
+ self,
+ *,
+ serving: bool = False,
+ transaction_id: str | None = None,
+ correlation_id: str | None = None,
+ ) -> None:
+ """Mark GPU output buffers reusable after the model consumes them."""
+ if _FAULT is not None and _FAULT.fires(
+ "exc_in_release",
+ role="gpu",
+ unit="connector-release",
+ eligible=serving,
+ tp_rank=self.tp_rank,
+ pp_rank=self.pp_rank,
+ dp_rank=self.dp_rank,
+ transaction_id=transaction_id,
+ correlation_id=correlation_id,
+ ):
+ raise RuntimeError("PLE test fault: exc_in_release")
+ n = getattr(self, "_rowdump_n", None)
+ self._rowdump_n = None
+ if _rowdump.enabled() and n is not None and not torch.cuda.is_current_stream_capturing():
+ torch.cuda.current_stream(self.device).synchronize()
+ seq = getattr(self, "_rowdump_seq", 0)
+ self._rowdump_seq = seq + 1
+ for layer in self._layers.values():
+ _rowdump.write(f"gpu-tp{self.tp_rank}", 0, self.tp_rank, seq,
+ self._input_ids_source[:n], layer._gpu_output_buffer[:n])
+ # Reset only after the consumer forward so the CPU worker cannot
+ # overwrite an output that a GPU PLE placeholder may still read.
+ stream = torch.cuda.current_stream(self.device)
+ for layer in self._layers.values():
+ layer.release_offloaded_output(stream)
+ # Retain the serving deadline until the model stream has actually
+ # executed every reset above. The monitor thread is the sole query site.
+ completion_event = torch.cuda.Event()
+ completion_event.record(stream)
+ self._progress.track_completion(completion_event)
+ # After dummy/capture work (profile, warm-up sizes, capture seed,
+ # adaptive-verification dummy replays, capture cleanup): complete the
+ # reset before returning, so it cannot still be pending when a real
+ # request lets the offload publish. Real-forward releases stay async.
+ # Never inside a stream capture.
+ if self._after_dummy and not torch.cuda.is_current_stream_capturing():
+ stream.synchronize()
+
+ def release_host_storage(self) -> None:
+ """Unlink host-storage names while retaining mappings and tensors."""
+ buffers = [
+ *self._host_output_bufs.values(),
+ *self._host_flags.values(),
+ self._input_ids_buf,
+ self._query_start_loc_buf,
+ ]
+ if self._ngram_context_buf is not None:
+ buffers.append(self._ngram_context_buf)
+ for buffer in buffers:
+ _unlink_filename_shared_storage(
+ buffer,
+ self._reserved_host_storage_filenames,
+ )
+
+ def close(self) -> None:
+ """Stop transport and unlink names, retaining host mappings."""
+ request_thread = self._request_thread
+ if request_thread is not None and request_thread.is_alive():
+ try:
+ self._request_queue.put(None, timeout=5)
+ except queue.Full:
+ logger.error("Timed out stopping the PLE request thread")
+ request_thread.join(timeout=5)
+ if request_thread is not None and request_thread.is_alive():
+ # The thread may still access the registered buffers or ZMQ context.
+ # It may also still race another process opening a shared name, so
+ # this deferred-cleanup branch must not unlink host storage.
+ logger.error("PLE request thread did not stop; deferring resource cleanup")
+ self._progress.close()
+ return
+ self._request_thread = None
+
+ # Inputs, outputs, and flags remain registered and strongly referenced.
+ # A captured graph or delayed request can still contain their addresses;
+ # the CUDA driver releases registrations when the process exits.
+ registered = len(self._pinned_input_buffers) + len(self._mapped_host_buffers)
+ if registered:
+ logger.debug(
+ "PleOffload: %d registered host buffers retained until exit",
+ registered,
+ )
+ self._d2h_event_pool = None
+ if self._registration_socket is not None:
+ self._registration_socket.close(linger=0)
+ self._registration_socket = None
+ if self._zmq_ctx is not None:
+ self._zmq_ctx.term()
+ self._zmq_ctx = None
+ self._progress.close()
+
+ # __init__'s failure handler can reach here before the child has rebuilt
+ # a registration. Startup is already failing in that case; if a payload
+ # was sent, a late rebuild may also observe the removed name. Successful
+ # startup accepts every registration before an external close begins.
+ #
+ # NOTE: Graceful shutdown unlinks the PLE shared-memory names and the
+ # PLE ipc socket. The remaining 17 /tmp UUID sockets per boot are
+ # upstream vLLM VLLM_RPC_BASE_PATH behaviour; a tmpfiles/launch-profile
+ # sweep is recommended.
+ # Keep this last: unlinking preserves every existing CPU/CUDA mapping,
+ # but no transport user may still need to open a name at this point.
+ self.release_host_storage()
diff --git a/vllm/v1/ple_offload/fatal.py b/vllm/v1/ple_offload/fatal.py
new file mode 100644
index 0000000..a80cad4
--- /dev/null
+++ b/vllm/v1/ple_offload/fatal.py
@@ -0,0 +1,153 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Fail-closed helpers for the PLE single-slot transport."""
+
+import functools
+import os
+import time
+import traceback
+from collections.abc import Callable
+from typing import Any, NoReturn, TypeVar
+
+_F = TypeVar("_F", bound=Callable[..., Any])
+
+_TRUSTED_ERROR_LABELS = (
+ (RuntimeError, "RuntimeError"),
+ (TimeoutError, "TimeoutError"),
+ (ValueError, "ValueError"),
+ (OSError, "OSError"),
+ (EOFError, "EOFError"),
+ (BrokenPipeError, "BrokenPipeError"),
+ (AssertionError, "AssertionError"),
+ (KeyError, "KeyError"),
+ (IndexError, "IndexError"),
+ (TypeError, "TypeError"),
+ (MemoryError, "MemoryError"),
+ (KeyboardInterrupt, "KeyboardInterrupt"),
+)
+
+
+def ple_fail_closed(
+ *,
+ where: str,
+ rank: int,
+ layer: str,
+ sequence: int,
+ error: BaseException,
+) -> NoReturn:
+ """Emit one best-effort nonblocking diagnostic and terminate immediately."""
+ try:
+ os.set_blocking(2, False)
+ error_type = type(error)
+ error_name = "Exception"
+ trusted_error = False
+ for trusted_type, trusted_name in _TRUSTED_ERROR_LABELS:
+ if error_type is trusted_type:
+ error_name = trusted_name
+ trusted_error = True
+ break
+
+ safe_where = where[:128] if type(where) is str else "unknown"
+ safe_layer = layer[:256] if type(layer) is str else "unknown"
+ safe_rank = rank if type(rank) is int else -1
+ safe_sequence = sequence if type(sequence) is int else -1
+ detail_suffix = ""
+ if trusted_error:
+ args = BaseException.args.__get__(error, error_type)
+ if (
+ type(args) is tuple
+ and len(args) == 1
+ and type(args[0]) is str
+ ):
+ detail = args[0][:512].replace("\n", "\\n")
+ detail_suffix = f":{detail}"
+ message = (
+ f"PLE fatal: where={safe_where} rank={safe_rank} layer={safe_layer} "
+ f"sequence={safe_sequence} error={error_name}{detail_suffix} "
+ f"pid={os.getpid()} "
+ f"monotonic={time.monotonic():.9f} wall={time.time():.9f}\n"
+ )
+ os.write(2, message.encode("utf-8", "backslashreplace")[:2048])
+ except BaseException:
+ pass
+ # THE TRACEBACK, unconditionally, in its own try so it cannot cost us the
+ # line above.
+ #
+ # This path os._exit(1)s, which runs no handlers and flushes nothing, so
+ # whatever is not written here is gone. The line above names the TYPE only
+ # when the type is one of _TRUSTED_ERROR_LABELS and flattens everything
+ # else to the literal string "Exception" -- and the flattened set includes
+ # the two that matter most here: torch.AcceleratorError (a RuntimeError
+ # SUBCLASS, so the `is` comparison misses it) and Triton CompilationError.
+ # An earlier boot died exactly that way: both PP0 ranks
+ # reported "error=Exception" and there was no traceback anywhere in the
+ # log, in any rank, so the cause could not be named at all and the next
+ # step was a re-run rather than a fix.
+ #
+ # A fail-closed path is where evidence matters most, not least: it fires
+ # once, it kills the process, and it is the only witness. Printing the type
+ # of a wrapped exception without the frames that raised it is not a
+ # diagnostic.
+ try:
+ # Three-arg form: it does not depend on being inside an active except
+ # block, and this function is called from one frame further out than
+ # the raise.
+ tb = "".join(
+ traceback.format_exception(type(error), error, error.__traceback__)
+ )
+ raw = tb.encode("utf-8", "backslashreplace")
+ # Truncate from the END, not the start. format_exception puts the
+ # frames first and the exception TYPE AND MESSAGE last, so a head-side
+ # cut throws away the one line that names what went wrong and keeps the
+ # import-time frames nobody needs. A long chained traceback is exactly
+ # the case where that matters.
+ clipped = len(raw) > 8192
+ raw = raw[-8192:]
+ os.write(
+ 2,
+ b"PLE fatal traceback (tail) follows:\n" if clipped
+ else b"PLE fatal traceback follows:\n",
+ )
+ os.write(2, raw)
+ if not raw.endswith(b"\n"):
+ os.write(2, b"\n")
+ except BaseException:
+ # Never silent HERE of all places: without this the reader cannot tell
+ # "the wrapper predates the traceback fix" from "the traceback itself
+ # could not be produced", and those call for different next steps.
+ # Still guarded, because os._exit below must run whatever happens: an
+ # escape from this handler would leave the worker alive in the state
+ # the fail-closed path exists to end.
+ try:
+ os.write(2, b"PLE fatal: traceback unavailable\n")
+ except BaseException:
+ pass
+ os._exit(1)
+
+
+def fail_ple_execute_model(func: _F) -> _F:
+ """Fail a non-shutdown worker-step escape only on PLE-owning ranks.
+
+ This includes ``sample_tokens``, the second half of the split execute-model
+ step. A rank without a PLE connector rethrows the original exception.
+ """
+
+ @functools.wraps(func)
+ def wrapped(self: Any, *args: Any, **kwargs: Any) -> Any:
+ try:
+ return func(self, *args, **kwargs)
+ except SystemExit:
+ # WorkerProc's signal handler uses SystemExit for clean shutdown.
+ raise
+ except BaseException as error:
+ connector = getattr(self, "_ple_offload_connector", None)
+ if connector is None:
+ model_runner = getattr(self, "model_runner", None)
+ connector = getattr(
+ model_runner, "_ple_offload_connector", None
+ )
+ if connector is None:
+ raise
+ connector._fail_closed(func.__name__, error)
+
+ return wrapped # type: ignore[return-value]
diff --git a/vllm/v1/ple_offload/progress.py b/vllm/v1/ple_offload/progress.py
new file mode 100644
index 0000000..bf88291
--- /dev/null
+++ b/vllm/v1/ple_offload/progress.py
@@ -0,0 +1,314 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Independent progress deadline for PLE transactions."""
+
+import math
+import os
+import threading
+import time
+from collections import deque
+from dataclasses import dataclass
+from typing import Any
+
+from vllm.v1.ple_offload.fatal import ple_fail_closed
+
+_DEFAULT_SERVING_DEADLINE_S = 300.0
+_DEFAULT_STARTUP_DEADLINE_S = 1800.0
+_MAX_PROGRESS_DEADLINE_S = 86400.0
+_POLL_INTERVAL_S = 0.1
+
+
+def _validate_deadline(value: float, name: str) -> float:
+ value = float(value)
+ if (
+ not math.isfinite(value)
+ or value <= 0
+ or value > _MAX_PROGRESS_DEADLINE_S
+ ):
+ raise ValueError(
+ f"{name} must be finite, greater than zero, and no greater than "
+ f"{_MAX_PROGRESS_DEADLINE_S:g} seconds"
+ )
+ return value
+
+
+def _environment_deadline(name: str, default: float) -> float:
+ value = float(os.environ.get(name, str(default)))
+ return _validate_deadline(value, name)
+
+
+@dataclass(frozen=True)
+class _Submission:
+ sequence: int
+ started: float
+ startup: bool
+ num_tokens: int
+ deadline: float
+ depth: int
+
+
+@dataclass(frozen=True)
+class _PendingCompletion:
+ sequence: int
+ started: float
+ startup: bool
+ num_tokens: int
+ deadline: float
+ event: Any
+
+
+class PleProgressMonitor:
+ """Bound host submission and the resulting ordered GPU completions."""
+
+ def __init__(
+ self,
+ *,
+ rank: int,
+ layer_names: tuple[str, ...],
+ serving_deadline_s: float,
+ startup_deadline_s: float,
+ ) -> None:
+ serving_deadline_s = _validate_deadline(
+ serving_deadline_s, "serving_deadline_s"
+ )
+ startup_deadline_s = _validate_deadline(
+ startup_deadline_s, "startup_deadline_s"
+ )
+ self._rank = rank
+ self._layer = ",".join(layer_names)
+ self._serving_deadline_s = serving_deadline_s
+ self._startup_deadline_s = startup_deadline_s
+ self._condition = threading.Condition()
+ self._submission: _Submission | None = None
+ self._pending: deque[_PendingCompletion] = deque()
+ self._sequence = 0
+ self._stopping = False
+ self._thread = threading.Thread(
+ target=self._run,
+ name=f"ple-progress-rank{rank}",
+ daemon=True,
+ )
+ self._thread.start()
+
+ @classmethod
+ def from_environment(
+ cls, *, rank: int, layer_names: tuple[str, ...]
+ ) -> "PleProgressMonitor":
+ return cls(
+ rank=rank,
+ layer_names=layer_names,
+ serving_deadline_s=_environment_deadline(
+ "VLLM_PLE_PROGRESS_DEADLINE_S",
+ _DEFAULT_SERVING_DEADLINE_S,
+ ),
+ startup_deadline_s=_environment_deadline(
+ "VLLM_PLE_STARTUP_DEADLINE_S",
+ _DEFAULT_STARTUP_DEADLINE_S,
+ ),
+ )
+
+ @property
+ def sequence(self) -> int:
+ with self._condition:
+ return self._sequence
+
+ @property
+ def has_pending_completions(self) -> bool:
+ with self._condition:
+ return bool(self._pending)
+
+ def describe_submission(self, sequence: int) -> str:
+ """Return the armed transaction descriptor for the active sequence."""
+ with self._condition:
+ active = self._submission
+ if active is None or active.sequence != sequence:
+ raise RuntimeError("PLE fault correlation has no active submission")
+ return (
+ f"sequence:{active.sequence}"
+ f"@begin:{active.started:.9f}"
+ )
+
+ def completion_is_pending(self, sequence: int) -> bool:
+ """Whether this serving sequence has a retained, incomplete event.
+
+ This method is called only behind the armed fault guard. An event that
+ has completed on the GPU but has not yet been dequeued is not counted.
+ """
+ with self._condition:
+ pending = next(
+ (
+ item
+ for item in self._pending
+ if item.sequence == sequence and not item.startup
+ ),
+ None,
+ )
+ if pending is None:
+ return False
+ return not bool(pending.event.query())
+
+ def begin(self, *, startup: bool, num_tokens: int) -> int:
+ now = time.monotonic()
+ budget = (
+ self._startup_deadline_s if startup else self._serving_deadline_s
+ )
+ with self._condition:
+ active = self._submission
+ if active is not None:
+ if not startup or not active.startup:
+ raise RuntimeError(
+ "Overlapping PLE host submissions violate the serialized "
+ "single-slot protocol"
+ )
+ sequence = active.sequence
+ self._submission = _Submission(
+ sequence=sequence,
+ started=active.started,
+ startup=True,
+ num_tokens=num_tokens,
+ deadline=now + budget,
+ depth=active.depth + 1,
+ )
+ else:
+ self._sequence += 1
+ sequence = self._sequence
+ self._submission = _Submission(
+ sequence=sequence,
+ started=now,
+ startup=startup,
+ num_tokens=num_tokens,
+ deadline=now + budget,
+ depth=1,
+ )
+ self._condition.notify_all()
+ return sequence
+
+ def track_completion(self, event: Any) -> None:
+ """Retain a release event until the monitor observes GPU completion."""
+ if not callable(getattr(event, "query", None)):
+ raise TypeError("PLE completion event must provide query()")
+ with self._condition:
+ active = self._submission
+ if active is None:
+ raise RuntimeError("PLE completion has no matching submission")
+ self._pending.append(
+ _PendingCompletion(
+ sequence=active.sequence,
+ started=active.started,
+ startup=active.startup,
+ num_tokens=active.num_tokens,
+ deadline=active.deadline,
+ event=event,
+ )
+ )
+ self._condition.notify_all()
+
+ def complete(self) -> None:
+ with self._condition:
+ active = self._submission
+ if active is None or active.depth <= 0:
+ raise RuntimeError("PLE release has no matching transaction")
+ if active.depth == 1:
+ self._submission = None
+ else:
+ self._submission = _Submission(
+ sequence=active.sequence,
+ started=active.started,
+ startup=active.startup,
+ num_tokens=active.num_tokens,
+ deadline=active.deadline,
+ depth=active.depth - 1,
+ )
+ self._condition.notify_all()
+
+ def close(self) -> None:
+ with self._condition:
+ self._stopping = True
+ self._condition.notify_all()
+ self._thread.join(timeout=1)
+
+ def _run(self) -> None:
+ try:
+ self._monitor()
+ except BaseException as error:
+ ple_fail_closed(
+ where="progress-monitor-thread",
+ rank=self._rank,
+ layer=self._layer,
+ sequence=self._sequence,
+ error=error,
+ )
+
+ def _monitor(self) -> None:
+ while True:
+ with self._condition:
+ if self._stopping:
+ return
+ submission = self._submission
+ pending = self._pending[0] if self._pending else None
+ if submission is None and pending is None:
+ self._condition.wait(timeout=_POLL_INTERVAL_S)
+ continue
+
+ # CUDA completion is observed only here. Never synchronize and never
+ # use the host flag as evidence that the queued reset completed.
+ if pending is not None and pending.event.query():
+ with self._condition:
+ if self._pending and self._pending[0] is pending:
+ self._pending.popleft()
+ self._condition.notify_all()
+ continue
+
+ now = time.monotonic()
+ if pending is not None and now >= pending.deadline:
+ with self._condition:
+ if not self._pending or self._pending[0] is not pending:
+ continue
+ mode = "startup" if pending.startup else "serving"
+ ple_fail_closed(
+ where=(
+ f"progress-deadline:{mode}:gpu-completion:"
+ f"tokens={pending.num_tokens}"
+ ),
+ rank=self._rank,
+ layer=self._layer,
+ sequence=pending.sequence,
+ error=TimeoutError("PLE GPU completion made no bounded progress"),
+ )
+
+ if submission is not None and now >= submission.deadline:
+ with self._condition:
+ if self._submission is not submission:
+ continue
+ mode = "startup" if submission.startup else "serving"
+ ple_fail_closed(
+ where=(
+ f"progress-deadline:{mode}:host-submission:"
+ f"tokens={submission.num_tokens}"
+ ),
+ rank=self._rank,
+ layer=self._layer,
+ sequence=submission.sequence,
+ error=TimeoutError("PLE host submission made no bounded progress"),
+ )
+
+ deadlines = []
+ if pending is not None:
+ deadlines.append(pending.deadline)
+ if submission is not None:
+ deadlines.append(submission.deadline)
+ timeout = _POLL_INTERVAL_S
+ if deadlines:
+ timeout = min(
+ timeout,
+ max(0.0, min(deadlines) - time.monotonic()),
+ )
+ with self._condition:
+ if self._stopping:
+ return
+ if (
+ self._submission is not submission
+ or (self._pending[0] if self._pending else None) is not pending
+ ):
+ continue
+ self._condition.wait(timeout=timeout)
diff --git a/vllm/v1/ple_offload/protocol.py b/vllm/v1/ple_offload/protocol.py
new file mode 100644
index 0000000..87eba13
--- /dev/null
+++ b/vllm/v1/ple_offload/protocol.py
@@ -0,0 +1,50 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""IPC message definitions for PLE CPU offload (host-mapped pull design).
+
+The offload process owns no CUDA context. Every GPU worker owns, per PLE
+layer, a shared host output buffer and a shared host int32 flag, both
+page-locked and device-mapped in the worker's context. The offload process
+waits for ``flag == 0``, writes the layer result into the host buffer and
+stores ``flag = 1`` with plain CPU stores; the GPU's captured forward waits on
+the host flag, DMAs host -> GPU on the model stream, and ``release_outputs``
+resets the flag after the forward.
+"""
+
+from dataclasses import dataclass
+
+import msgspec
+import torch
+
+# ---------------------------------------------------------------------------
+# IPC message dataclasses
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class PleOffloadRegistration:
+ """Sent once from each GPU worker during offload setup."""
+
+ worker_id: int
+ tp_rank: int
+ dp_rank: int
+ # Shared CPU tensors per PLE layer, filled by the offload process.
+ host_output_buffers: dict[str, torch.Tensor]
+ # Shared CPU int32 tensors per PLE layer; element 0 is the ready flag.
+ host_flag_tensors: dict[str, torch.Tensor]
+ # CPU tensors are allocated in shared memory and registered once.
+ input_ids_buf: torch.Tensor
+ query_start_loc_buf: torch.Tensor
+ ngram_context_buf: torch.Tensor | None
+
+
+@dataclass
+class PleOffloadRequest:
+ """Sent by each DP rank's TP rank zero at every inference step."""
+
+ dp_rank: int
+ num_tokens: int
+ num_reqs: int
+
+
+_PLE_OFFLOAD_REQUEST_DECODER = msgspec.msgpack.Decoder(PleOffloadRequest)
diff --git a/vllm/v1/ple_offload/worker.py b/vllm/v1/ple_offload/worker.py
new file mode 100644
index 0000000..43caf80
--- /dev/null
+++ b/vllm/v1/ple_offload/worker.py
@@ -0,0 +1,1298 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Dedicated CPU-offload process for PLE embedding layers.
+
+This module implements a standalone process that:
+1. Loads only the :class:`PleOffloadLayer` weights into CPU memory.
+2. Accepts per-step computation requests from GPU worker processes.
+3. Runs ``forward_impl()`` on CPU, writes the rows into every TP worker's
+ shared host output buffer for the requesting DP rank and sets that
+ worker's shared host flag (host-mapped pull design: this process holds no
+ CUDA context; the GPU waits on the host flag and DMAs host -> GPU inside
+ its captured forward).
+
+The TP workers within one DP rank receive identical inputs, so the CPU result
+is computed once per DP rank and fanned out to all of its TP ranks.
+
+Class structure mirrors the GPU worker pattern in multiproc_executor.py:
+
+ PleOffloadWorkerHandle -- handle held by the spawning GPU worker
+ PleOffloadWorker -- process lifecycle and READY handshake
+ PleOffloadRunner -- owns weights and serves inference requests
+"""
+
+import contextlib
+import os
+import pickle
+import select
+import signal
+import socket
+import struct
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from collections.abc import Iterable
+from dataclasses import dataclass
+from multiprocessing.connection import Connection
+from typing import Any, cast
+
+import msgspec
+import torch
+import torch.distributed as dist
+import zmq
+
+import vllm.envs as envs
+from vllm.config import VllmConfig, set_current_vllm_config
+from vllm.distributed.parallel_state import (
+ ensure_model_parallel_initialized,
+ init_distributed_environment,
+)
+from vllm.logger import init_logger
+from vllm.model_executor.layers.ple_offload_layer import (
+ PleOffloadLayer,
+ mark_as_offload_worker,
+)
+from vllm.model_executor.model_loader import get_model_loader
+from vllm.model_executor.model_loader.default_loader import DefaultModelLoader
+from vllm.model_executor.model_loader.dummy_loader import DummyModelLoader
+from vllm.model_executor.model_loader.utils import (
+ initialize_model,
+ process_weights_after_loading,
+)
+from vllm.model_executor.model_loader.weight_utils import initialize_dummy_weights
+from vllm.utils.system_utils import decorate_logs
+from vllm.utils.torch_utils import set_default_torch_dtype
+from vllm.v1.ple_offload.protocol import (
+ _PLE_OFFLOAD_REQUEST_DECODER,
+ PleOffloadRegistration,
+ PleOffloadRequest,
+)
+from vllm.v1.utils import record_function_or_nullcontext
+from vllm_ple_test_fault import (
+ _FAULT,
+ _FaultReconstructionPayload,
+)
+
+logger = init_logger(__name__)
+
+# Publication fence for the host-mapped pull: a locked read-modify-write (the
+# uncontended lock acquire) is a full barrier on x86 and also drains
+# write-combining buffers, so every row store of a result is globally visible
+# before the flag store that tells the GPU to DMA it (do not rely
+# on "memcpy uses no non-temporal stores at this size").
+_PUBLISH_FENCE = threading.Lock()
+
+
+def _unlink_ipc_socket(ipc_addr: str) -> None:
+ """Best-effort unlink of the exact filesystem IPC endpoint PLE owns."""
+ if not ipc_addr.startswith("ipc://"):
+ return
+ path = ipc_addr.removeprefix("ipc://")
+ # PLE receives an absolute UUID path from get_open_zmq_ipc_path(). Avoid
+ # treating abstract or relative ZMQ addresses as filesystem ownership.
+ if not os.path.isabs(path):
+ return
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ pass
+ except OSError as error:
+ logger.warning("Failed to unlink PLE IPC socket %s: %s", path, error)
+
+
+def _publish_fence() -> None:
+ with _PUBLISH_FENCE:
+ pass
+
+
+def _offload_child_environment() -> dict[str, str]:
+ """Build the offload environment without mutating the parent."""
+ child_env = dict(os.environ)
+ child_env["VLLM_DISABLE_SHARED_EXPERTS_STREAM"] = "1"
+ child_env.setdefault("CUTLASS_CUDA_DEVICE_ID", "0")
+ child_env.pop("VLLM_PP_LAYER_PARTITION", None)
+
+ home_device = child_env.get("VLLM_PLE_OFFLOAD_HOME_DEVICE", "").strip()
+ if home_device:
+ parent_visible = child_env.get("CUDA_VISIBLE_DEVICES", "").strip()
+ try:
+ physical = (
+ parent_visible.split(",")[int(home_device)].strip()
+ if parent_visible
+ else home_device
+ )
+ except (IndexError, ValueError) as error:
+ raise ValueError("Invalid VLLM_PLE_OFFLOAD_HOME_DEVICE") from error
+ child_env["CUDA_VISIBLE_DEVICES"] = physical
+ return child_env
+
+
+def _prepare_cuda_free_process() -> None:
+ """Keep the PLE offload process free of any CUDA context.
+
+ The host-mapped pull design issues no CUDA work here, yet model
+ construction reached CUDA incidentally (found by the offload-startup
+ replays):
+ 1. ``VLLM_DISABLE_SHARED_EXPERTS_STREAM=1``: each MoE layer's shared-experts
+ runner calls ``aux_stream()`` -> ``torch.cuda.Stream()``, whose C++
+ constructor creates the primary context without calling
+ ``torch.cuda._lazy_init`` (``torch.cuda.is_initialized()`` stays False).
+ This is the context the replay caught (stack dumped inside
+ ``Stream.__new__``). This process never runs an MoE layer, so it never
+ needs the stream. vLLM reads the variable when the layer is built; this
+ process does not enable vLLM's env cache.
+ 2. ``CUTLASS_CUDA_DEVICE_ID``: CUTLASS python (``cutlass_cppgen``) runs
+ ``initialize_cuda_context()`` -> ``cudart.cudaFree(0)`` at import unless
+ it is set. Not observed on this import path; kept as a cheap defence.
+ 3. The torch guard (``_install_cuda_guard``).
+ """
+ os.environ["VLLM_DISABLE_SHARED_EXPERTS_STREAM"] = "1"
+ os.environ.setdefault("CUTLASS_CUDA_DEVICE_ID", "0")
+ _install_cuda_guard()
+
+
+def _install_cuda_guard() -> None:
+ """Make torch-level CUDA initialization raise in the PLE offload process.
+
+ An offload-startup check found the host-mapped pull still got a 322 MiB context
+ on its home card: importing the Qwen4Exp model imports the vendored FLA
+ ops, whose utils call triton's ``get_current_target()`` at import time,
+ i.e. ``torch.cuda.current_device()`` -> lazy init. FLA catches
+ ``RuntimeError`` and falls back to "cpu" (and then skips its Hopper
+ probe), so raising here keeps this process context-free. Any other call
+ site fails loudly at offload startup with its stack logged, instead of
+ silently taking VRAM on a stage-0 card. The GPU workers are untouched.
+
+ torch also has C++ entry points that create the context without calling
+ ``torch.cuda._lazy_init``: stream construction (``torch.cuda.Stream``) and
+ the pinned host allocator (``Tensor.pin_memory``). Both raise here too;
+ this process needs neither (all of its buffers are plain CPU memory).
+ ``torch.cuda.is_initialized()`` only reflects the Python-level path, so
+ the per-PID GPU sampler, not that flag, is the authority on contexts.
+ """
+ import traceback
+
+ if torch.cuda.is_initialized():
+ # Fail closed: a context that already exists would stay on a
+ # stage-0 card for the whole run.
+ raise RuntimeError(
+ "CUDA is already initialized in the PLE offload process before its "
+ "guard; the host-mapped pull offload must stay CUDA-free"
+ )
+
+ def _refuse(what: str) -> None:
+ stack = "".join(traceback.format_stack(limit=12)[:-2])
+ logger.info(
+ "PLE offload CUDA guard: blocked a CUDA initialization attempt "
+ "(%s; the caller must fall back to CPU or fail); call site:\n%s",
+ what,
+ stack,
+ )
+ raise RuntimeError(
+ f"CUDA is disabled in the PLE offload process (host-mapped pull): {what}"
+ )
+
+ def _blocked_lazy_init() -> None:
+ _refuse("torch.cuda lazy init")
+
+ def _blocked_stream(cls, *args, **kwargs):
+ _refuse("torch.cuda.Stream construction")
+
+ def _blocked_pin_memory(self, *args, **kwargs):
+ _refuse("Tensor.pin_memory")
+
+ torch.cuda._lazy_init = _blocked_lazy_init # type: ignore[assignment]
+ torch.cuda.Stream.__new__ = staticmethod(_blocked_stream) # type: ignore[assignment]
+ torch.Tensor.pin_memory = _blocked_pin_memory # type: ignore[assignment]
+ logger.info(
+ "PLE offload CUDA guard installed (torch CUDA lazy init, stream "
+ "construction and pin_memory raise)"
+ )
+
+
+@contextlib.contextmanager
+def _platform_device_is_meta():
+ """During the offload's meta-model construction, report the meta device.
+
+ Some layer constructors pass an explicit ``current_platform.current_device()``
+ (e.g. the GDN output norm in qwen_gdn_linear_attn.py), which overrides the
+ ``torch.device("meta")`` context and would allocate on CUDA. In the offload
+ process nothing but the CPU PLE layers is ever materialized, so those
+ tensors belong on meta too. The patch is an instance attribute removed on
+ exit, restoring the platform's own method.
+ """
+ from vllm.platforms import current_platform
+
+ current_platform.current_device = lambda *args, **kwargs: torch.device("meta")
+ try:
+ yield
+ finally:
+ with contextlib.suppress(AttributeError):
+ del current_platform.current_device
+
+
+# Bounded wait for the GPU to release a result (seconds). A timed-out ownership
+# transfer is fatal: the offload process raises and exits, never overwrites.
+_PLE_WAIT_TIMEOUT_S = float(os.environ.get("VLLM_PLE_OFFLOAD_WAIT_TIMEOUT", "120"))
+
+
+@dataclass
+class PleOffloadOutputTarget:
+ """Host output destination and ready flag for one TP worker.
+
+ Both are shared CPU tensors owned by the GPU worker, page-locked and
+ device-mapped in that worker's context. This process only reads/writes
+ them with plain CPU loads/stores (no CUDA): wait for ``flag == 0`` (the GPU
+ released the previous result), write the rows, then store ``flag = 1``.
+ """
+
+ tp_rank: int
+ host_output_buffer: torch.Tensor
+ host_flag: torch.Tensor # int32, element 0 is the flag
+
+
+@dataclass
+class PleOffloadInputBuffers:
+ """Shared-memory input buffers registered for one DP rank."""
+
+ input_ids_buf: torch.Tensor # int32 (max_num_tokens,)
+ query_start_loc_buf: torch.Tensor # int32 (max_num_reqs + 1,)
+ ngram_context_buf: torch.Tensor | None # int32 (max_num_reqs, ngram_context_len)
+
+
+@dataclass(frozen=True)
+class _ReceivedPleOffloadRequest:
+ request: PleOffloadRequest
+ serving: bool
+ transaction_id: str | None
+ correlation_id: str | None
+
+
+def _decode_offload_request(
+ frames: list[bytes],
+) -> _ReceivedPleOffloadRequest:
+ if len(frames) != 2:
+ raise RuntimeError("PLE offload request must contain two frames")
+ request = _PLE_OFFLOAD_REQUEST_DECODER.decode(frames[0])
+ metadata = msgspec.msgpack.decode(frames[1])
+ if type(metadata) is not dict or set(metadata) != {
+ "serving",
+ "transaction",
+ "correlation",
+ }:
+ raise RuntimeError("PLE offload request metadata is invalid")
+ serving = metadata["serving"]
+ transaction = metadata["transaction"]
+ correlation = metadata["correlation"]
+ if type(serving) is not bool:
+ raise RuntimeError("PLE offload serving metadata is invalid")
+ if transaction is not None and type(transaction) is not str:
+ raise RuntimeError("PLE offload transaction metadata is invalid")
+ if correlation is not None and type(correlation) is not str:
+ raise RuntimeError("PLE offload correlation metadata is invalid")
+ return _ReceivedPleOffloadRequest(
+ request=request,
+ serving=serving,
+ transaction_id=transaction,
+ correlation_id=correlation,
+ )
+
+
+def _receive_offload_request(
+ socket: zmq.Socket, flags: int = 0
+) -> _ReceivedPleOffloadRequest | PleOffloadRequest:
+ """Disarmed: the unchanged single-frame request. Armed: request + metadata."""
+ if _FAULT is None:
+ return _PLE_OFFLOAD_REQUEST_DECODER.decode(socket.recv(flags))
+ return _decode_offload_request(socket.recv_multipart(flags))
+
+
+def _send_framed_payload(fd: int, payload: bytes, deadline: float) -> None:
+ """Send one launch payload without blocking beyond an absolute deadline."""
+ frame = memoryview(struct.pack("!Q", len(payload)) + payload)
+ offset = 0
+ os.set_blocking(fd, False)
+ try:
+ while offset < len(frame):
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError("PLE launch payload transmission timed out")
+ _, writable, _ = select.select([], [fd], [], remaining)
+ if not writable:
+ raise TimeoutError("PLE launch payload transmission timed out")
+ try:
+ sent = os.write(fd, frame[offset:])
+ except BlockingIOError:
+ continue
+ if sent <= 0:
+ raise BrokenPipeError("PLE launch control socket closed")
+ offset += sent
+ if (
+ _FAULT is not None
+ and _FAULT.cut == "launch_park_before_payload_read"
+ ):
+ _FAULT.acknowledge_launch_send(
+ bytes_sent=offset,
+ frame_size=len(frame),
+ )
+ finally:
+ with contextlib.suppress(OSError):
+ os.set_blocking(fd, True)
+
+
+_PLE_READY_MAX_PAYLOAD_BYTES = 1 << 20
+
+class _BoundedStartupResponseWriter:
+ """Preflight startup responses against the parent receiver's frame cap."""
+
+ def __init__(self, writer: Connection) -> None:
+ self._writer = writer
+
+ def send(self, message: Any) -> None:
+ payload = pickle.dumps(message, protocol=pickle.HIGHEST_PROTOCOL)
+ if len(payload) > _PLE_READY_MAX_PAYLOAD_BYTES:
+ payload = pickle.dumps(
+ {
+ "status": "FAILURE",
+ "error": "PLE startup response exceeded size limit",
+ },
+ protocol=pickle.HIGHEST_PROTOCOL,
+ )
+ if len(payload) > _PLE_READY_MAX_PAYLOAD_BYTES:
+ raise RuntimeError(
+ "Bounded PLE startup failure response exceeds its frame cap"
+ )
+ self._writer.send_bytes(payload)
+
+ def close(self) -> None:
+ self._writer.close()
+
+
+def _read_exact_before_deadline(
+ fd: int,
+ size: int,
+ deadline: float,
+) -> bytes:
+ """Read exactly ``size`` bytes without crossing an absolute deadline."""
+ chunks = bytearray()
+ while len(chunks) < size:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(
+ "PLE offload worker did not become ready before its "
+ "absolute launch deadline"
+ )
+ readable, _, _ = select.select([fd], [], [], remaining)
+ if not readable:
+ raise TimeoutError(
+ "PLE offload worker did not become ready before its "
+ "absolute launch deadline"
+ )
+ try:
+ chunk = os.read(fd, size - len(chunks))
+ except BlockingIOError:
+ continue
+ if not chunk:
+ raise EOFError("PLE startup response was truncated")
+ chunks.extend(chunk)
+ return bytes(chunks)
+
+
+def _recv_bounded_connection_message(
+ reader: Connection,
+ deadline: float,
+) -> Any:
+ """Receive one bounded multiprocessing Connection frame by ``deadline``."""
+ fd = reader.fileno()
+ os.set_blocking(fd, False)
+ try:
+ header = _read_exact_before_deadline(fd, 4, deadline)
+ size = struct.unpack("!i", header)[0]
+ if size == -1:
+ size = struct.unpack(
+ "!Q", _read_exact_before_deadline(fd, 8, deadline)
+ )[0]
+ elif size < 0:
+ raise RuntimeError("PLE startup response has an invalid frame size")
+ if size > _PLE_READY_MAX_PAYLOAD_BYTES:
+ raise RuntimeError(
+ f"PLE startup response exceeds "
+ f"{_PLE_READY_MAX_PAYLOAD_BYTES} bytes"
+ )
+ frame = _read_exact_before_deadline(fd, size, deadline)
+ finally:
+ with contextlib.suppress(OSError):
+ os.set_blocking(fd, True)
+ try:
+ return pickle.loads(frame)
+ except BaseException as error:
+ raise RuntimeError(
+ "PLE startup response is not a valid serialized message"
+ ) from error
+
+
+@dataclass
+class PleOffloadWorkerHandle:
+ """Resources owned by the GPU worker that spawned the offload process."""
+
+ proc: subprocess.Popen
+ death_writer: Connection | None
+ ready_pipe_reader: Connection | None
+ launch_deadline: float
+ ipc_addr: str | None = None
+
+ def close(self, *, graceful: bool = True) -> None:
+ """Release all process resources. Safe to call more than once."""
+ if self.ready_pipe_reader is not None:
+ self.ready_pipe_reader.close()
+ self.ready_pipe_reader = None
+ if graceful and self.proc.poll() is None:
+ # Keep parent-death supervision connected while SIGTERM lets
+ # proc_main run its shutdown event and finally cleanup.
+ self.proc.terminate()
+ try:
+ self.proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ pass
+ if self.death_writer is not None:
+ self.death_writer.close()
+ self.death_writer = None
+ if not graceful and self.proc.poll() is None:
+ # Expired launch: retain the immediate EOF -> TERM path.
+ self.proc.terminate()
+ try:
+ self.proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ pass
+ if self.proc.poll() is None:
+ self.proc.kill()
+ try:
+ self.proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ pass
+ # The child normally removes the endpoint in proc_main's finally.
+ # Once it has exited, this exact PLE-owned address is safe to remove as
+ # a fallback for SIGKILL or an interrupted child cleanup.
+ if self.proc.poll() is not None and self.ipc_addr is not None:
+ _unlink_ipc_socket(self.ipc_addr)
+
+
+def _init_offload_distributed() -> None:
+ """Initialize the single-rank Gloo world required by TP-aware layers."""
+ if dist.is_initialized():
+ return
+
+ # VocabParallelEmbedding reads the TP process group during construction.
+ # The offload process owns the full embedding table, so it uses an isolated
+ # TP1/PP1 Gloo world and never joins the GPU workers' NCCL groups.
+ store_dir = tempfile.mkdtemp(prefix="vllm_ple_offload_")
+ init_distributed_environment(
+ world_size=1,
+ rank=0,
+ distributed_init_method=f"file://{store_dir}/store",
+ local_rank=0,
+ backend="gloo",
+ )
+ # initialize_model_parallel reads the active VllmConfig in the current
+ # vLLM version. Explicitly configure DP1/TP1/PP1 to match the isolated
+ # world, regardless of any DP environment variables inherited from the GPU
+ # worker. The real DP/TP configuration is used later for model construction,
+ # registration, and request routing.
+ offload_config = VllmConfig()
+ offload_parallel_config = offload_config.parallel_config
+ offload_parallel_config.data_parallel_size = 1
+ offload_parallel_config.data_parallel_size_local = 1
+ offload_parallel_config.data_parallel_rank = 0
+ offload_parallel_config.data_parallel_rank_local = 0
+ offload_parallel_config.data_parallel_index = 0
+ offload_parallel_config.tensor_parallel_size = 1
+ offload_parallel_config.pipeline_parallel_size = 1
+ offload_parallel_config.prefill_context_parallel_size = 1
+ offload_parallel_config.decode_context_parallel_size = 1
+ offload_parallel_config.world_size = 1
+ offload_parallel_config.nnodes = 1
+ offload_parallel_config.node_rank = 0
+ with set_current_vllm_config(offload_config):
+ ensure_model_parallel_initialized(
+ tensor_model_parallel_size=1,
+ pipeline_model_parallel_size=1,
+ backend="gloo",
+ )
+ logger.info(
+ "Distributed environment initialized (backend=gloo, rank=0, world_size=1)."
+ )
+
+
+class PleOffloadWorker:
+ """Manage process creation, READY handshake, and the child entry point."""
+
+ READY_STR = "READY"
+
+ @staticmethod
+ def make_process(
+ vllm_config: VllmConfig,
+ num_workers: int,
+ ipc_addr: str,
+ ) -> PleOffloadWorkerHandle:
+ """Start one CPU offload process in a fresh interpreter."""
+ launch_deadline = (
+ time.monotonic() + float(envs.VLLM_PLE_OFFLOAD_READY_TIMEOUT)
+ )
+ config_payload = pickle.dumps(
+ vllm_config, protocol=pickle.HIGHEST_PROTOCOL
+ )
+ child_env = _offload_child_environment()
+
+ # READY and parent-death supervision must never compete for reads.
+ parent_control, child_control = socket.socketpair()
+ parent_death, child_death = socket.socketpair()
+ ready_reader = Connection(parent_control.detach())
+ death_writer = Connection(parent_death.detach())
+ try:
+ proc = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "vllm_ple_offload_bootstrap",
+ str(child_control.fileno()),
+ str(child_death.fileno()),
+ ],
+ env=child_env,
+ close_fds=True,
+ pass_fds=(child_control.fileno(), child_death.fileno()),
+ )
+ except BaseException:
+ ready_reader.close()
+ death_writer.close()
+ raise
+ finally:
+ child_control.close()
+ child_death.close()
+
+ handle = PleOffloadWorkerHandle(
+ proc=proc,
+ death_writer=death_writer,
+ ready_pipe_reader=ready_reader,
+ launch_deadline=launch_deadline,
+ ipc_addr=ipc_addr,
+ )
+ try:
+ config_for_launch: bytes | _FaultReconstructionPayload = config_payload
+ if (
+ _FAULT is not None
+ and _FAULT.cut == "launch_block_reconstruction"
+ ):
+ config_for_launch = _FaultReconstructionPayload(config_payload)
+ launch_state = {
+ "vllm_config_payload": config_for_launch,
+ "num_workers": num_workers,
+ "ipc_addr": ipc_addr,
+ }
+ if (
+ _FAULT is not None
+ and _FAULT.cut == "launch_park_before_payload_read"
+ ):
+ # Larger than ordinary AF_UNIX socket buffers: an acknowledged
+ # non-reading child leaves the parent inside framed transmission.
+ launch_state["_test_padding"] = b"\0" * (8 << 20)
+ launch_payload = pickle.dumps(
+ launch_state,
+ protocol=pickle.HIGHEST_PROTOCOL,
+ )
+ _send_framed_payload(
+ ready_reader.fileno(), launch_payload, launch_deadline
+ )
+ except BaseException:
+ handle.close(graceful=False)
+ raise
+
+ return handle
+
+ @staticmethod
+ def wait_for_ready(handle: PleOffloadWorkerHandle) -> None:
+ """Wait until weights and all GPU registrations are ready to serve."""
+ reader = handle.ready_pipe_reader
+ if reader is None:
+ return
+ try:
+ message = _recv_bounded_connection_message(
+ reader, handle.launch_deadline
+ )
+ if type(message) is not dict:
+ raise RuntimeError("PLE offload worker sent an invalid response")
+ status = dict.get(message, "status")
+ if type(status) is not str:
+ raise RuntimeError(
+ "PLE offload worker sent an invalid response status"
+ )
+ if status == PleOffloadWorker.READY_STR:
+ pass
+ elif status == "FAILURE":
+ error_detail = dict.get(message, "error")
+ if type(error_detail) is not str:
+ error_detail = "unknown error"
+ raise RuntimeError(
+ "PLE offload worker failed during startup: "
+ f"{error_detail}"
+ )
+ else:
+ raise RuntimeError("PLE offload worker sent unsupported status")
+ layer_names = dict.get(message, "layer_names")
+ if (
+ type(layer_names) is not list
+ or any(
+ type(name) is not str for name in layer_names
+ )
+ ):
+ raise RuntimeError(
+ "PLE offload worker sent invalid READY layer_names"
+ )
+ logger.info(
+ "Worker ready - %d PleOffloadLayer(s): %s",
+ len(layer_names),
+ layer_names,
+ )
+ except EOFError as error:
+ handle.close(graceful=False)
+ raise RuntimeError("PLE offload worker exited during startup") from error
+ except BaseException:
+ handle.close(graceful=False)
+ raise
+ finally:
+ if handle.ready_pipe_reader is not None:
+ handle.ready_pipe_reader.close()
+ handle.ready_pipe_reader = None
+
+ @staticmethod
+ def proc_main(
+ vllm_config: VllmConfig,
+ num_workers: int,
+ ipc_addr: str,
+ ready_pipe: Connection,
+ ) -> None:
+ """Load PLE weights, accept registrations, and run the request loop."""
+ decorate_logs("PleOffloadWorker")
+ ready_writer = _BoundedStartupResponseWriter(ready_pipe)
+ shutdown_event = threading.Event()
+
+ def handle_signal(_signum: int, _frame: object) -> None:
+ shutdown_event.set()
+
+ signal.signal(signal.SIGTERM, handle_signal)
+ signal.signal(signal.SIGINT, handle_signal)
+
+ zmq_context: zmq.Context | None = None
+ pull_socket: zmq.Socket | None = None
+ owns_ipc_socket = False
+ try:
+ # The flag lets PleOffloadLayer subclasses execute their complete
+ # constructors instead of becoming empty GPU-worker placeholders.
+ mark_as_offload_worker()
+
+ # The bootstrap established child-only visibility before importing
+ # torch or vLLM. Retain the Python-level guards for known CUDA
+ # initialization paths and fail if initialization already occurred.
+ _prepare_cuda_free_process()
+ home_device = os.environ.get("VLLM_PLE_OFFLOAD_HOME_DEVICE", "").strip()
+ if home_device:
+ logger.info(
+ "PLE offload bootstrap visibility is CUDA_VISIBLE_DEVICES=%s "
+ "(host-mapped pull: no CUDA context expected)",
+ os.environ.get("CUDA_VISIBLE_DEVICES", ""),
+ )
+
+ # Initialize Gloo before installing the real VllmConfig. This keeps
+ # the CPU process in an isolated rank-zero, world-size-one group.
+ # The offload worker is a PP=1 world; a VLLM_PP_LAYER_PARTITION set
+ # for the GPU workers (e.g. "25,23") would make make_layers raise
+ # "len(partitions) does not match pp_size=1" while the meta model
+ # is built here. Drop it in this process only.
+ os.environ.pop("VLLM_PP_LAYER_PARTITION", None)
+ _init_offload_distributed()
+
+ # Model components read the active VllmConfig while the meta model
+ # is constructed, so keep the context around runner initialization.
+ with set_current_vllm_config(vllm_config):
+ runner = PleOffloadRunner(vllm_config)
+
+ zmq_context = zmq.Context()
+ pull_socket = zmq_context.socket(zmq.PULL)
+ pull_socket.bind(ipc_addr)
+ owns_ipc_socket = True
+ logger.info(
+ "Bound IPC address %s; waiting for %d GPU worker registration(s).",
+ ipc_addr,
+ num_workers,
+ )
+
+ # READY means that the process can immediately serve requests. Wait
+ # for every DP/TP worker to register before notifying the parent.
+ runner.accept_registrations(pull_socket, num_workers)
+ # Checked: the host-mapped pull design must not create a context.
+ logger.info(
+ "PLE offload host-mapped pull: CUDA initialized in this process = %s",
+ torch.cuda.is_initialized(),
+ )
+ ready_writer.send(
+ {
+ "status": PleOffloadWorker.READY_STR,
+ "layer_names": sorted(runner.layer_names),
+ }
+ )
+ ready_writer.close()
+ ready_writer = None # type: ignore[assignment]
+
+ runner.busy_loop(pull_socket, shutdown_event)
+ except Exception as error:
+ logger.exception("Unexpected failure in PLE offload worker.")
+ if ready_writer is not None:
+ with contextlib.suppress(Exception):
+ ready_writer.send(
+ {"status": "FAILURE", "error": repr(error)[:4096]}
+ )
+ raise
+ finally:
+ if pull_socket is not None:
+ pull_socket.close(linger=0)
+ if zmq_context is not None:
+ zmq_context.term()
+ if owns_ipc_socket:
+ _unlink_ipc_socket(ipc_addr)
+ if ready_writer is not None:
+ ready_writer.close()
+
+
+class PleOffloadRunner:
+ """Own all discovered PLE tables and serve every local DP rank."""
+
+ def __init__(self, vllm_config: VllmConfig) -> None:
+ self.vllm_config = vllm_config
+ self._clamp_input_ids = (
+ getattr(vllm_config, "speculative_config", None) is not None
+ )
+ # name -> PleOffloadLayer (CPU)
+ self._layers: dict[str, PleOffloadLayer] = {}
+ # dp_rank -> layer_name -> one destination per TP rank
+ self._worker_targets: dict[int, dict[str, list[PleOffloadOutputTarget]]] = {}
+ # Each (dp_rank, layer_name) pair owns a plain CPU scratch buffer that
+ # forward_impl writes; the rows are then copied into every TP worker's
+ # shared host buffer. Nothing in this process is pinned or on a GPU.
+ self._scratch_bufs: dict[int, dict[str, torch.Tensor]] = {}
+ self._shutdown_event: threading.Event | None = None
+ self._step = 0 # number of handled request batches (diagnostics)
+ self._request_index = 0
+ # Shared-memory inputs are registered once per DP rank by TP rank zero.
+ self._input_bufs: dict[int, PleOffloadInputBuffers] = {}
+ self._load_weights()
+
+ @property
+ def layer_names(self) -> list[str]:
+ """Return PleOffloadLayer names in model traversal order."""
+ return list(self._layers)
+
+ def _load_weights(self) -> None:
+ """Load only :class:`PleOffloadLayer` subtrees into CPU memory.
+
+ Strategy:
+ 1. Build the entire model on ``meta`` so non-offloaded parameters use no
+ physical memory. PleOffloadLayer constructors explicitly target CPU.
+ 2. Discover all PleOffloadLayer modules from the complete model.
+ 3. Stream the checkpoint through a prefix filter so only matching PLE
+ tensors are materialized and passed to ``model.load_weights``.
+ 4. Run post-load processing only on the CPU-owned PLE subtrees.
+ """
+ model_config = self.vllm_config.model_config
+ load_config = self.vllm_config.load_config
+
+ # Step 1: build complete structure, while only PLE subtrees allocate CPU
+ # memory. All transformer, MoE, and vision parameters remain on meta.
+ logger.info("Initializing model structure for PLE weight discovery ...")
+ model_dtype = cast(torch.dtype, model_config.dtype)
+ with (
+ set_default_torch_dtype(model_dtype),
+ torch.device("meta"),
+ _platform_device_is_meta(),
+ ):
+ model = initialize_model(
+ vllm_config=self.vllm_config,
+ model_config=model_config,
+ )
+
+ # Step 2: preserve named_modules DFS order so CPU execution follows the
+ # same layer order as the GPU model forward.
+ offload_layers = {
+ name: module
+ for name, module in model.named_modules()
+ if isinstance(module, PleOffloadLayer)
+ }
+ if not offload_layers:
+ raise RuntimeError(
+ "VLLM_PLE_CPU_OFFLOAD is enabled, but no PleOffloadLayer "
+ "was found in the initialized model"
+ )
+ logger.info(
+ "Found %d PleOffloadLayer(s): %s",
+ len(offload_layers),
+ sorted(offload_layers),
+ )
+ offload_prefixes = tuple(f"{name}." for name in offload_layers)
+
+ # Step 3: filter checkpoint tensors before model.load_weights(). The
+ # conditional-generation checkpoint uses HF names such as
+ # ``model.language_model.*`` while named_modules exposes mapped vLLM
+ # names such as ``language_model.model.*``. Apply the model mapper only
+ # for matching, then yield the original pair so load_weights performs
+ # its normal single mapping pass.
+ mapper = getattr(model, "hf_to_vllm_mapper", None)
+ matched_checkpoint_tensors = 0
+
+ def offload_only_iter(
+ weights: Iterable[tuple[str, torch.Tensor]],
+ ) -> Iterable[tuple[str, torch.Tensor]]:
+ nonlocal matched_checkpoint_tensors
+ for weight_name, tensor in weights:
+ mapped_name: str | None = weight_name
+ if mapper is not None:
+ mapped_names = mapper.apply_list([weight_name])
+ mapped_name = mapped_names[0] if mapped_names else None
+ if mapped_name is not None and mapped_name.startswith(offload_prefixes):
+ matched_checkpoint_tensors += 1
+ yield weight_name, tensor
+
+ loader = get_model_loader(load_config)
+ if isinstance(loader, DummyModelLoader):
+ logger.info(
+ "Initializing dummy weights for %d PleOffloadLayer(s) ...",
+ len(offload_layers),
+ )
+ for layer in offload_layers.values():
+ initialize_dummy_weights(layer, model_config)
+ elif isinstance(loader, DefaultModelLoader):
+ all_weights = loader.get_all_weights(model_config, model)
+ loaded_params = model.load_weights(offload_only_iter(all_weights))
+ if matched_checkpoint_tensors == 0:
+ raise RuntimeError(
+ "PLE offload checkpoint filter matched no weights for "
+ f"layers: {sorted(offload_layers)}"
+ )
+
+ expected_offload_params = {
+ f"{layer_name}.{param_name}"
+ for layer_name, layer in offload_layers.items()
+ for param_name, _ in layer.named_parameters()
+ }
+ loaded_offload_entries = {
+ name for name in loaded_params if name.startswith(offload_prefixes)
+ }
+ loaded_expected_params = expected_offload_params.intersection(loaded_params)
+ missing_offload_params = sorted(
+ expected_offload_params.difference(loaded_expected_params)
+ )
+ if missing_offload_params:
+ raise RuntimeError(
+ "PLE offload checkpoint did not load all materialized "
+ f"parameters: {missing_offload_params}"
+ )
+ logger.info(
+ "PLE offload matched %d checkpoint tensor(s), loaded %d "
+ "offload entries, and verified %d/%d materialized "
+ "parameter(s) for layers: %s",
+ matched_checkpoint_tensors,
+ len(loaded_offload_entries),
+ len(loaded_expected_params),
+ len(expected_offload_params),
+ sorted(offload_layers),
+ )
+ else:
+ raise NotImplementedError(
+ "PLE offload requires the default or dummy model loader, got "
+ f"{type(loader).__name__}"
+ )
+
+ # Step 4: post-load processing is restricted to CPU-owned PLE modules;
+ # the remainder of the model is still on meta and must not be visited.
+ for layer in offload_layers.values():
+ process_weights_after_loading(layer, model_config, torch.device("cpu"))
+
+ self._layers.update(offload_layers)
+ del model
+ logger.info("PLE weight loading complete.")
+
+ def accept_registrations(
+ self,
+ pull_socket: zmq.Socket,
+ num_workers: int,
+ ) -> None:
+ """Receive every local DP/TP worker's IPC and shared-memory buffers."""
+ logger.info("Waiting for %d GPU worker registration(s) ...", num_workers)
+ registrations: list[PleOffloadRegistration] = []
+ for index in range(num_workers):
+ item = pickle.loads(pull_socket.recv())
+ if not isinstance(item, PleOffloadRegistration):
+ raise RuntimeError(
+ "Expected PleOffloadRegistration during setup, got "
+ f"{type(item).__name__} ({index + 1}/{num_workers})"
+ )
+ registrations.append(item)
+ logger.info(
+ "GPU worker %d registered (dp_rank=%d, tp_rank=%d, layers=%s).",
+ item.worker_id,
+ item.dp_rank,
+ item.tp_rank,
+ sorted(item.host_output_buffers),
+ )
+
+ dp_size = self.vllm_config.parallel_config.data_parallel_size
+ tp_size = self.vllm_config.parallel_config.tensor_parallel_size
+ if num_workers != dp_size * tp_size:
+ raise RuntimeError(
+ f"Expected {dp_size * tp_size} registrations for DP={dp_size}, "
+ f"TP={tp_size}, got {num_workers}"
+ )
+
+ registrations_by_dp: dict[int, list[PleOffloadRegistration]] = {}
+ for registration in registrations:
+ registrations_by_dp.setdefault(registration.dp_rank, []).append(
+ registration
+ )
+ if set(registrations_by_dp) != set(range(dp_size)):
+ raise RuntimeError(
+ f"Expected DP ranks {set(range(dp_size))}, "
+ f"got {set(registrations_by_dp)}"
+ )
+ for dp_rank, dp_registrations in registrations_by_dp.items():
+ tp_ranks = {registration.tp_rank for registration in dp_registrations}
+ if tp_ranks != set(range(tp_size)):
+ raise RuntimeError(
+ f"DP rank {dp_rank} expected TP ranks {set(range(tp_size))}, "
+ f"got {tp_ranks}"
+ )
+
+ for registration in registrations:
+ if set(registration.host_output_buffers) != set(self.layer_names) or set(
+ registration.host_flag_tensors
+ ) != set(self.layer_names):
+ raise RuntimeError(
+ "Registered PLE layers do not match CPU layers: "
+ f"registered={sorted(registration.host_output_buffers)} / "
+ f"{sorted(registration.host_flag_tensors)}, "
+ f"cpu={sorted(self.layer_names)}"
+ )
+ targets_for_dp = self._worker_targets.setdefault(registration.dp_rank, {})
+ for layer_name, host_buffer in registration.host_output_buffers.items():
+ host_flag = registration.host_flag_tensors[layer_name]
+ for tensor in (host_buffer, host_flag):
+ if tensor.device.type != "cpu" or not tensor.is_shared():
+ raise RuntimeError(
+ f"PLE host buffer for {layer_name} (dp="
+ f"{registration.dp_rank}, tp={registration.tp_rank}) "
+ "is not a shared CPU tensor"
+ )
+ if host_flag.dtype != torch.int32 or host_flag.numel() < 1:
+ raise RuntimeError(f"PLE host flag for {layer_name} must be int32")
+ target = PleOffloadOutputTarget(
+ tp_rank=registration.tp_rank,
+ host_output_buffer=host_buffer,
+ host_flag=host_flag,
+ )
+ targets_for_dp.setdefault(layer_name, []).append(target)
+ # All TP ranks in one DP group receive the same input, so buffers
+ # registered by TP rank zero are sufficient for that DP rank.
+ if registration.tp_rank == 0:
+ self._input_bufs[registration.dp_rank] = PleOffloadInputBuffers(
+ input_ids_buf=registration.input_ids_buf,
+ query_start_loc_buf=registration.query_start_loc_buf,
+ ngram_context_buf=registration.ngram_context_buf,
+ )
+
+ if set(self._input_bufs) != set(range(dp_size)):
+ raise RuntimeError(
+ "TP rank zero did not register PLE input buffers for every DP "
+ f"rank: expected={set(range(dp_size))}, got={set(self._input_bufs)}"
+ )
+
+ config = self.vllm_config.model_config.hf_text_config
+ max_tokens = self.vllm_config.scheduler_config.max_num_batched_tokens
+ for dp_rank, layer_targets in self._worker_targets.items():
+ self._scratch_bufs[dp_rank] = {}
+ for layer_name, targets in layer_targets.items():
+ if len(targets) != tp_size:
+ raise RuntimeError(
+ f"PLE layer {layer_name} for DP rank {dp_rank} received "
+ f"{len(targets)} targets, expected {tp_size}"
+ )
+ targets.sort(key=lambda target: target.tp_rank)
+ out_dim = self._layers[layer_name].get_offload_output_dim(
+ int(config.ple_embed_dim)
+ )
+ out_dtype = self._layers[layer_name].get_offload_output_dtype(
+ self.vllm_config.model_config.dtype
+ )
+ for target in targets:
+ shape = tuple(target.host_output_buffer.shape)
+ if shape != (max_tokens, out_dim) or (
+ target.host_output_buffer.dtype != out_dtype
+ ):
+ raise RuntimeError(
+ f"PLE host buffer for {layer_name} (dp={dp_rank}, "
+ f"tp={target.tp_rank}) is {shape} "
+ f"{target.host_output_buffer.dtype}, expected "
+ f"{(max_tokens, out_dim)} {out_dtype}"
+ )
+ self._scratch_bufs[dp_rank][layer_name] = torch.empty(
+ max_tokens, out_dim, dtype=out_dtype, device="cpu"
+ )
+ logger.info(
+ "Registrations complete (dp_size=%d, tp_size=%d, layers=%s).",
+ dp_size,
+ tp_size,
+ sorted(self.layer_names),
+ )
+
+ @torch.inference_mode()
+ def busy_loop(
+ self,
+ pull_socket: zmq.Socket,
+ shutdown_event: threading.Event,
+ ) -> None:
+ """Decode and batch available requests by DP rank until shutdown."""
+ logger.info("Busy-loop started.")
+ self._shutdown_event = shutdown_event
+ poller = zmq.Poller()
+ poller.register(pull_socket, zmq.POLLIN)
+ while not shutdown_event.is_set():
+ if pull_socket not in dict(poller.poll(timeout=100)):
+ continue
+
+ requests = []
+ try:
+ requests.append(_receive_offload_request(pull_socket))
+ while True:
+ requests.append(
+ _receive_offload_request(pull_socket, zmq.NOBLOCK)
+ )
+ except zmq.Again:
+ pass
+ except (msgspec.DecodeError, RuntimeError) as error:
+ raise RuntimeError("Unexpected PLE offload request") from error
+
+ self._handle_requests(requests)
+
+ def _handle_requests(
+ self, requests: list[_ReceivedPleOffloadRequest]
+ ) -> None:
+ """Run requests layer-first so each DP rank can resume promptly."""
+ with record_function_or_nullcontext("ple_offload: handle_requests"):
+ self._handle_requests_impl(requests)
+
+ def _validate_requests_before_publication(
+ self, requests: list[PleOffloadRequest]
+ ) -> None:
+ """Validate a drained request batch before computation or publication."""
+ for request in requests:
+ dp_rank = request.dp_rank
+ if dp_rank not in self._worker_targets or dp_rank not in self._input_bufs:
+ raise RuntimeError(
+ f"No PLE buffers or output targets for dp_rank={dp_rank}; "
+ "refusing request"
+ )
+
+ input_bufs = self._input_bufs[dp_rank]
+ if not 0 < request.num_tokens <= input_bufs.input_ids_buf.numel():
+ raise RuntimeError(
+ f"PLE num_tokens={request.num_tokens} is outside capacity "
+ f"{input_bufs.input_ids_buf.numel()} for dp_rank={dp_rank}"
+ )
+ max_reqs = input_bufs.query_start_loc_buf.numel() - 1
+ if not 0 < request.num_reqs <= max_reqs:
+ raise RuntimeError(
+ f"PLE num_reqs={request.num_reqs} is outside capacity "
+ f"{max_reqs} for dp_rank={dp_rank}"
+ )
+ if (
+ input_bufs.query_start_loc_buf.dtype != torch.int32
+ or input_bufs.query_start_loc_buf.dim() != 1
+ ):
+ raise RuntimeError(
+ f"PLE query_start_loc for dp_rank={dp_rank} must be 1-D int32"
+ )
+ if (
+ input_bufs.ngram_context_buf is not None
+ and request.num_reqs > input_bufs.ngram_context_buf.shape[0]
+ ):
+ raise RuntimeError(
+ f"PLE num_reqs={request.num_reqs} exceeds ngram context "
+ f"capacity for dp_rank={dp_rank}"
+ )
+
+ query_start_loc = input_bufs.query_start_loc_buf[
+ : request.num_reqs + 1
+ ].tolist()
+ if (
+ query_start_loc[0] != 0
+ or query_start_loc[-1] > request.num_tokens
+ or any(a > b for a, b in zip(query_start_loc, query_start_loc[1:]))
+ ):
+ raise RuntimeError(
+ f"Invalid PLE query_start_loc for dp_rank={dp_rank}: "
+ f"{query_start_loc}, num_tokens={request.num_tokens}"
+ )
+
+ for layer_name, targets in self._worker_targets[dp_rank].items():
+ scratch = self._scratch_bufs[dp_rank][layer_name]
+ if request.num_tokens > scratch.shape[0] or any(
+ request.num_tokens > target.host_output_buffer.shape[0]
+ for target in targets
+ ):
+ raise RuntimeError(
+ f"PLE num_tokens={request.num_tokens} exceeds output "
+ f"capacity for {layer_name}, dp_rank={dp_rank}"
+ )
+
+ def _handle_requests_impl(
+ self,
+ requests: list[_ReceivedPleOffloadRequest | PleOffloadRequest],
+ ) -> None:
+ """Process one batch of PLE requests."""
+ received = [
+ item
+ if isinstance(item, _ReceivedPleOffloadRequest)
+ else _ReceivedPleOffloadRequest(item, False, None, None)
+ for item in requests
+ ]
+ plain_requests = [item.request for item in received]
+ self._validate_requests_before_publication(plain_requests)
+ received_by_dp: dict[int, _ReceivedPleOffloadRequest] = {}
+ for item in received:
+ request = item.request
+ if request.dp_rank not in self._worker_targets:
+ raise RuntimeError(
+ f"No PLE output targets for dp_rank={request.dp_rank}; "
+ "refusing request"
+ )
+ if request.dp_rank in received_by_dp:
+ # One input slot and one output slot per DP rank: a second
+ # request in the same batch would alias them. Fatal, never
+ # silently dropped.
+ raise RuntimeError(
+ f"Two PLE requests for dp_rank={request.dp_rank} in one batch "
+ f"(step {self._step}); the single-slot protocol is violated"
+ )
+ received_by_dp[request.dp_rank] = item
+ self._step += 1
+ if _FAULT is not None:
+ for item in received_by_dp.values():
+ if not item.serving:
+ continue
+ self._request_index += 1
+ transaction = f"request:{self._request_index}"
+ if item.transaction_id is not None:
+ transaction = f"{transaction}@{item.transaction_id}"
+ for cut in ("park_before_publish", "park_before_publish_ack"):
+ if _FAULT is not None and _FAULT.fires(
+ cut,
+ role="offload",
+ unit="offload-request",
+ eligible=True,
+ dp_rank=item.request.dp_rank,
+ transaction_id=transaction,
+ correlation_id=item.correlation_id,
+ ):
+ # No multiprocessing or fault-state lock is held here.
+ threading.Event().wait()
+
+ requests_by_dp = {
+ dp_rank: item.request
+ for dp_rank, item in received_by_dp.items()
+ }
+
+ # Speculative placeholders are not vocabulary IDs. Normalize each DP
+ # input once before all PLE layers consume the shared buffer.
+ if self._clamp_input_ids:
+ for dp_rank, request in requests_by_dp.items():
+ self._input_bufs[dp_rank].input_ids_buf[
+ : request.num_tokens
+ ].clamp_min_(0)
+
+ for layer_name, layer in self._layers.items():
+ for dp_rank, request in requests_by_dp.items():
+ targets = self._worker_targets[dp_rank][layer_name]
+
+ input_bufs = self._input_bufs[dp_rank]
+ ngram_context = (
+ input_bufs.ngram_context_buf[: request.num_reqs]
+ if input_bufs.ngram_context_buf is not None
+ else None
+ )
+ result = layer.forward_impl(
+ input_bufs.input_ids_buf[: request.num_tokens],
+ input_bufs.input_ids_buf[: request.num_tokens],
+ input_bufs.query_start_loc_buf[: request.num_reqs + 1],
+ ngram_context,
+ output_buffer=self._scratch_bufs[dp_rank][layer_name],
+ )
+ if _rowdump.enabled():
+ _seq = getattr(self, "_rowdump_seq", 0)
+ self._rowdump_seq = _seq + 1
+ _rowdump.write(f"offload-dp{dp_rank}", 1, dp_rank, _seq,
+ input_bufs.input_ids_buf[: request.num_tokens], result)
+
+ # The result is identical on every TP rank in this DP group.
+ # A worker's host buffer may be overwritten only after its GPU
+ # released the previous result (``release_outputs`` stores
+ # flag=0 on the model stream after the forward, which is also
+ # after the in-graph DMA read it). Then write rows, then flag=1.
+ scratch = self._scratch_bufs[dp_rank][layer_name]
+ if result.dim() != 2 or result.shape[0] != request.num_tokens or (
+ result.shape[1] != scratch.shape[1]
+ ) or result.dtype != scratch.dtype or result.device.type != "cpu":
+ raise RuntimeError(
+ f"PLE result {tuple(result.shape)} {result.dtype} "
+ f"{result.device} for {layer_name} does not "
+ f"match the request ({request.num_tokens} rows x "
+ f"{scratch.shape[1]} {scratch.dtype} CPU); refusing to "
+ f"publish (step {self._step})"
+ )
+ slices = tuple(slice(0, size) for size in result.shape)
+ for target in targets:
+ self._wait_flag_released(target, layer_name, dp_rank)
+ target.host_output_buffer[slices].copy_(result[slices])
+ _publish_fence()
+ target.host_flag[0] = 1
+
+ def _wait_flag_released(
+ self, target: PleOffloadOutputTarget, layer_name: str, dp_rank: int
+ ) -> None:
+ """Spin until the GPU reset this target's flag (previous result used)."""
+ flag = target.host_flag
+ if int(flag[0]) == 0:
+ return
+ spins = 0
+ warned = False
+ start = time.monotonic()
+ while int(flag[0]) != 0:
+ spins += 1
+ if self._shutdown_event is not None and self._shutdown_event.is_set():
+ raise RuntimeError("PLE offload shutting down while waiting for GPU")
+ if spins > 200:
+ time.sleep(0.0001)
+ waited = time.monotonic() - start
+ if not warned and waited > 5.0:
+ warned = True
+ logger.warning(
+ "PLE offload waited >5 s for the GPU to release %s "
+ "(dp=%d, tp=%d, step %d); flag=%d",
+ layer_name,
+ dp_rank,
+ target.tp_rank,
+ self._step,
+ int(flag[0]),
+ )
+ if waited > _PLE_WAIT_TIMEOUT_S:
+ raise RuntimeError(
+ f"PLE offload: GPU did not release {layer_name} (dp={dp_rank}, "
+ f"tp={target.tp_rank}, step {self._step}) within "
+ f"{_PLE_WAIT_TIMEOUT_S:.0f} s; refusing to overwrite"
+ )
diff --git a/vllm/v1/worker/gpu/cudagraph_utils.py b/vllm/v1/worker/gpu/cudagraph_utils.py
index 9657c28..0e132b6 100644
--- a/vllm/v1/worker/gpu/cudagraph_utils.py
+++ b/vllm/v1/worker/gpu/cudagraph_utils.py
@@ -1,9 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+import contextlib
import gc
import itertools
from collections import defaultdict
-from collections.abc import Callable, Iterable
+from collections.abc import Callable, Iterable, Iterator
from contextlib import ExitStack
from dataclasses import dataclass, replace
from itertools import groupby, product
@@ -30,6 +31,9 @@ from vllm.distributed.parallel_state import (
)
from vllm.forward_context import BatchDescriptor, set_forward_context
from vllm.logger import init_logger
+from vllm.model_executor.layers.ple_offload_layer import (
+ observe_ple_copy_geometry,
+)
from vllm.model_executor.offloader.base import get_offloader
from vllm.platforms import current_platform
from vllm.sequence import IntermediateTensors
@@ -170,6 +174,14 @@ class CudaGraphManager:
self._lora_dispatch_map, self._max_lora_case = self._build_lora_dispatch_map()
self.graphs: dict[BatchExecutionDescriptor, torch.cuda.CUDAGraph] = {}
+ # FULL graphs bake every PLE wait/copy into their native graph. Metadata
+ # comes from the actual custom-op implementation during native capture.
+ self._ple_full_copy_geometry: dict[
+ BatchExecutionDescriptor, tuple[tuple[str, int, int], ...]
+ ] = {}
+ self._expected_ple_copy_layers: tuple[str, ...] = ()
+ # Only the target-model manager is allowed to capture PLE operations.
+ self._allow_ple_full_graph = False
self.pool = current_platform.get_global_graph_pool() if cudagraph_mode else None
self._graphs_captured = False
@@ -469,15 +481,16 @@ class CudaGraphManager:
if self._capture_mem_samples is not None:
torch.accelerator.synchronize()
free_before = torch.accelerator.get_memory_info()[0]
- with torch.cuda.graph(
- graph, self.pool, stream=self._capture_stream(desc)
- ):
- forward_fn(CUDAGraphMode.NONE)
- # Join offloader's copy stream after forward to avoid
- # unjoined stream error. The last layer's start_prefetch
- # forks copy_stream, but wait_prefetch only happens in
- # the next forward pass.
- get_offloader().join_after_forward()
+ with self.capture_ple_copy_geometry(desc):
+ with torch.cuda.graph(
+ graph, self.pool, stream=self._capture_stream(desc)
+ ):
+ forward_fn(CUDAGraphMode.NONE)
+ # Join offloader's copy stream after forward to avoid
+ # unjoined stream error. The last layer's start_prefetch
+ # forks copy_stream, but wait_prefetch only happens in
+ # the next forward pass.
+ get_offloader().join_after_forward()
if self._capture_mem_samples is not None:
torch.accelerator.synchronize()
free_after = torch.accelerator.get_memory_info()[0]
@@ -493,6 +506,78 @@ class CudaGraphManager:
{desc.num_tokens for desc in self.graphs if desc.num_active_loras == 0}
)
+ @contextlib.contextmanager
+ def capture_ple_copy_geometry(
+ self, desc: BatchExecutionDescriptor
+ ) -> Iterator[None]:
+ """Production capture callback for actual PLE custom-op geometry."""
+ with observe_ple_copy_geometry() as geometry:
+ yield
+
+ if geometry and not self._allow_ple_full_graph:
+ raise RuntimeError(
+ "PLE wait-copy reached an unsupported FULL graph path; "
+ "speculator/draft FULL graphs may not contain PLE operations"
+ )
+ if geometry:
+ self.record_ple_copy_geometry(desc, geometry)
+ elif self._expected_ple_copy_layers:
+ raise RuntimeError(
+ f"PLE copy geometry was not observed while capturing FULL graph {desc}"
+ )
+
+ def record_ple_copy_geometry(
+ self,
+ desc: BatchExecutionDescriptor,
+ geometry: Iterable[tuple[str, int, int]],
+ ) -> None:
+ if desc.cg_mode != CUDAGraphMode.FULL:
+ raise ValueError("PLE capture geometry is recorded only for FULL graphs")
+ observed = tuple(
+ (str(layer), int(rows), int(nbytes))
+ for layer, rows, nbytes in geometry
+ )
+ if not observed:
+ raise RuntimeError(f"PLE FULL graph {desc} observed no wait-copy operations")
+ observed_layers = tuple(layer for layer, _, _ in observed)
+ expected_layers = self._expected_ple_copy_layers
+ if expected_layers and sorted(observed_layers) != sorted(expected_layers):
+ raise RuntimeError(
+ f"PLE FULL graph {desc} observed layers {observed_layers}, "
+ f"expected {expected_layers}"
+ )
+ inconsistent = [
+ (layer, rows, nbytes)
+ for layer, rows, nbytes in observed
+ if rows != desc.num_tokens or nbytes < 0
+ ]
+ if inconsistent:
+ raise RuntimeError(
+ f"PLE FULL graph {desc} actual PLE copy geometry "
+ f"{inconsistent} does not match descriptor rows={desc.num_tokens}"
+ )
+ previous = self._ple_full_copy_geometry.get(desc)
+ if previous is not None and previous != observed:
+ raise RuntimeError(
+ f"PLE FULL graph {desc} changed copy geometry from "
+ f"{previous} to {observed}"
+ )
+ self._ple_full_copy_geometry[desc] = observed
+
+ def ple_copy_rows(self, desc: BatchExecutionDescriptor) -> int:
+ try:
+ geometry = self._ple_full_copy_geometry[desc]
+ except KeyError as error:
+ raise RuntimeError(
+ f"PLE copy geometry was not recorded for FULL graph {desc}"
+ ) from error
+ rows = {num_rows for _, num_rows, _ in geometry}
+ if len(rows) != 1:
+ raise RuntimeError(
+ f"PLE FULL graph {desc} has inconsistent per-layer rows: {geometry}"
+ )
+ return rows.pop()
+
def dispatch(
self,
num_reqs: int,
@@ -532,6 +617,11 @@ class CudaGraphManager:
f"Expected FULL mode, got {desc.cg_mode}"
)
assert desc in self.graphs, f"No cudagraph for {desc}"
+ if (
+ desc in self._ple_full_copy_geometry
+ and not self._allow_ple_full_graph
+ ):
+ raise RuntimeError("refusing unsupported PLE speculator FULL replay")
# Sync offloader before replay - needed when transitioning from
# eager/piecewise to full cudagraph (e.g., prefill → decode).
# The previous eager iteration's start_prefetch may have queued
@@ -577,11 +667,32 @@ class ModelCudaGraphManager(CudaGraphManager):
varlen_decode=varlen_decode,
ubatch_runner=ubatch_runner,
)
+ self._allow_ple_full_graph = True
self.hidden_states: torch.Tensor | None = None
self.aux_hidden_states: list[torch.Tensor] = []
self.use_aux_hidden_state_outputs = False
self.intermediate_tensors: IntermediateTensors | None = None
+ def _prepare_ple_capture(
+ self, model: nn.Module, *, initialize_breakable: bool
+ ) -> None:
+ expected_ple_copy_layers = tuple(
+ str(getattr(module, "_ple_offload_name", name))
+ for name, module in model.named_modules()
+ if getattr(module, "_is_cpu_offloaded", False)
+ and getattr(module, "_host_output_ptr", None) is not None
+ )
+ if (
+ "<unbound>" in expected_ple_copy_layers
+ or len(set(expected_ple_copy_layers)) != len(expected_ple_copy_layers)
+ ):
+ raise RuntimeError(
+ "PLE FULL capture requires distinct, bound connector layer identities"
+ )
+ self._expected_ple_copy_layers = expected_ple_copy_layers
+ if initialize_breakable:
+ self.init_breakable_cg_runner(model)
+
def capture(
self,
model: nn.Module,
@@ -599,8 +710,9 @@ class ModelCudaGraphManager(CudaGraphManager):
) -> None:
"""Capture CUDA graphs for model forward pass."""
self.use_aux_hidden_state_outputs = use_aux_hidden_state_outputs
- if self.use_breakable_cg:
- self.init_breakable_cg_runner(model)
+ self._prepare_ple_capture(
+ model, initialize_breakable=self.use_breakable_cg
+ )
if self.cudagraph_mode.has_piecewise_cudagraphs() and not (
self.use_breakable_cg or has_compiled_submodule(model)
@@ -901,7 +1013,9 @@ def profile_cudagraph_memory(runner: "GPUModelRunner") -> int:
if manager.use_breakable_cg:
# Create the breakable runner before the wrapper pool swap so
# its pool is covered as well.
- manager.init_breakable_cg_runner(runner.model)
+ manager._prepare_ple_capture(
+ runner.model, initialize_breakable=True
+ )
all_wrappers = list(CUDAGraphWrapper._all_instances) + list(
BreakableCUDAGraphWrapper._all_instances
)
diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py
index 744fe5b..e2ddc52 100644
--- a/vllm/v1/worker/gpu/model_runner.py
+++ b/vllm/v1/worker/gpu/model_runner.py
@@ -77,6 +77,9 @@ from vllm.v1.outputs import (
ModelRunnerOutput,
RoutedExpertsTensors,
)
+from vllm.v1.ple_offload.connector import PleOffloadConnector
+from vllm.v1.ple_offload.fatal import fail_ple_execute_model
+from vllm.v1.ple_offload.test_fault import _FAULT
from vllm.v1.watermarking import create_watermarker
from vllm.v1.watermarking.gpu_sampler import GPUWatermarkSampler
from vllm.v1.watermarking.spec_decode import (
@@ -1689,6 +1692,45 @@ class GPUModelRunner(LoRAModelRunnerMixin):
self.ec_connector.no_forward(scheduler_output).ec_connector_output,
)
+ def _prepare_ple_forward(
+ self,
+ input_batch: InputBatch,
+ batch_desc: BatchExecutionDescriptor,
+ dummy_run: bool,
+ *,
+ serving: bool = False,
+ transaction_id: str | None = None,
+ correlation_id: str | None = None,
+ ) -> None:
+ connector = self._ple_offload_connector
+ assert connector is not None
+ request_num_tokens = int(input_batch.num_tokens_after_padding)
+ if batch_desc.cg_mode == CUDAGraphMode.FULL:
+ manager = self.cudagraph_manager
+ if manager is None:
+ raise RuntimeError("PLE FULL replay has no CUDA graph manager")
+ copy_num_tokens = manager.ple_copy_rows(batch_desc)
+ else:
+ # PIECEWISE/eager execute the Python op with this actual padded view.
+ copy_num_tokens = int(input_batch.input_ids.shape[0])
+
+ if request_num_tokens != copy_num_tokens:
+ raise RuntimeError(
+ "PLE request/selected-forward copy-count mismatch: "
+ f"request={request_num_tokens}, copy={copy_num_tokens}, "
+ f"mode={batch_desc.cg_mode}"
+ )
+ connector.prepare_forward(
+ input_batch.num_reqs,
+ request_num_tokens,
+ dummy_run,
+ consumer_num_tokens=copy_num_tokens,
+ serving=serving,
+ transaction_id=transaction_id,
+ correlation_id=correlation_id,
+ )
+
+ @fail_ple_execute_model
@torch.inference_mode()
def execute_model(
self,
@@ -1956,6 +1998,54 @@ class GPUModelRunner(LoRAModelRunnerMixin):
)
self.step_timing.forward_start()
+ # prepare_inputs has finalized MRV2's GPU buffers. Stage D2H on this
+ # stream before the PLE placeholder can wait for CPU output.
+ connector = self._ple_offload_connector
+ ple_serving = False
+ ple_sequence = None
+ ple_transaction = None
+ ple_correlation = None
+ if connector is not None:
+ ple_serving = not dummy_run and not is_profile
+ ple_sequence = connector.begin_forward(
+ dummy_run=dummy_run,
+ num_tokens=int(input_batch.num_tokens_after_padding),
+ )
+ if _FAULT is not None and ple_serving:
+ ple_correlation = f"sequence:{ple_sequence}"
+ ple_transaction = connector.fault_transaction(ple_sequence)
+ if _FAULT is not None and _FAULT.fires(
+ "exc_before_prepare",
+ role="gpu",
+ unit="runner-forward",
+ eligible=ple_serving,
+ tp_rank=connector.tp_rank,
+ pp_rank=connector.pp_rank,
+ dp_rank=connector.dp_rank,
+ transaction_id=ple_transaction,
+ correlation_id=ple_correlation,
+ ):
+ raise RuntimeError("PLE test fault: exc_before_prepare")
+ self._prepare_ple_forward(
+ input_batch,
+ batch_desc,
+ dummy_run,
+ serving=ple_serving,
+ transaction_id=ple_transaction,
+ correlation_id=ple_correlation,
+ )
+ if _FAULT is not None and _FAULT.fires(
+ "exc_after_prepare",
+ role="gpu",
+ unit="runner-forward",
+ eligible=ple_serving,
+ tp_rank=connector.tp_rank,
+ pp_rank=connector.pp_rank,
+ dp_rank=connector.dp_rank,
+ transaction_id=ple_transaction,
+ correlation_id=ple_correlation,
+ ):
+ raise RuntimeError("PLE test fault: exc_after_prepare")
connector_kwargs = dict(
scheduler_output=scheduler_output,
request_state_indices=input_batch.idx_mapping,
@@ -2013,6 +2103,26 @@ class GPUModelRunner(LoRAModelRunnerMixin):
# Eager (NONE): call the raw model directly.
model_output = self.model(**model_inputs)
+ # The model has consumed every PLE output, so the CPU worker may reuse
+ # the registered buffers for the next request.
+ if connector is not None:
+ connector.release_outputs(
+ serving=ple_serving,
+ transaction_id=ple_transaction,
+ correlation_id=ple_correlation,
+ )
+ if _FAULT is not None and _FAULT.fires(
+ "exc_after_release",
+ role="gpu",
+ unit="runner-forward",
+ eligible=ple_serving,
+ tp_rank=connector.tp_rank,
+ pp_rank=connector.pp_rank,
+ dp_rank=connector.dp_rank,
+ transaction_id=ple_transaction,
+ correlation_id=ple_correlation,
+ ):
+ raise RuntimeError("PLE test fault: exc_after_release")
self.kv_connector.finish_forward()
if self.is_last_pp_rank:
@@ -2047,6 +2157,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
ec_connector_output=ec_connector_output,
routed_experts=routed_experts,
cudagraph_stats=cudagraph_stats,
+ ple_serving=ple_serving,
+ ple_sequence=ple_sequence,
+ ple_transaction=ple_transaction,
)
if not self.is_last_pp_rank:
@@ -2058,6 +2171,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
)
return None
+ @fail_ple_execute_model
@torch.inference_mode()
@step_eplb_after()
def sample_tokens(
@@ -2077,6 +2191,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
ec_connector_output = self.execute_model_state.ec_connector_output
routed_experts = self.execute_model_state.routed_experts
cudagraph_stats = self.execute_model_state.cudagraph_stats
+ ple_serving = self.execute_model_state.ple_serving
+ ple_sequence = self.execute_model_state.ple_sequence
+ ple_transaction = self.execute_model_state.ple_transaction
self.execute_model_state = None
if not self.is_last_pp_rank:
@@ -2084,6 +2201,26 @@ class GPUModelRunner(LoRAModelRunnerMixin):
# IntermediateTensors instead of final hidden states. Receive the
# sampled tokens broadcast from the last rank and update local state.
assert self.pp_handler is not None
+ connector = self._ple_offload_connector
+ if (
+ connector is not None
+ and _FAULT is not None
+ and ple_serving
+ and ple_sequence is not None
+ and connector.completion_is_pending(ple_sequence)
+ and _FAULT.fires(
+ "exc_async_output",
+ role="gpu",
+ unit="runner-output-relay",
+ eligible=True,
+ tp_rank=connector.tp_rank,
+ pp_rank=connector.pp_rank,
+ dp_rank=connector.dp_rank,
+ transaction_id=ple_transaction,
+ correlation_id=f"sequence:{ple_sequence}",
+ )
+ ):
+ raise RuntimeError("PLE test fault: exc_async_output")
all_decode_next = self.pp_handler.receive(input_batch)
# Optimistically update num_computed_tokens for entire batch here.
# Will be adjusted for rejections if necessary in update_requests.
@@ -2379,6 +2516,9 @@ class ExecuteModelState(NamedTuple):
ec_connector_output: ECConnectorOutput | None
routed_experts: RoutedExpertsTensors | None
cudagraph_stats: CUDAGraphStat | None
+ ple_serving: bool
+ ple_sequence: int | None
+ ple_transaction: str | None
class BatchReqState(NamedTuple):
diff --git a/vllm/v1/worker/gpu_worker.py b/vllm/v1/worker/gpu_worker.py
index 09f91f9..43bc705 100644
--- a/vllm/v1/worker/gpu_worker.py
+++ b/vllm/v1/worker/gpu_worker.py
@@ -87,6 +87,7 @@ from vllm.v1.outputs import (
DraftTokenIds,
ModelRunnerOutput,
)
+from vllm.v1.ple_offload.fatal import fail_ple_execute_model
from vllm.v1.utils import compute_iteration_details, report_usage_stats
from vllm.v1.worker import guard_telemetry
from vllm.v1.worker.sentinel.gpu_worker_sentinel import WorkerSentinel
@@ -231,6 +232,148 @@ class Worker(WorkerBase):
# Resolved lazily on first sleep/wake; persists worker-process state.
self._sleep_mode_backend: SleepModeBackend | None = None
+ def _has_ple_layers(self) -> bool:
+ """Return whether this model configuration constructs PLE layers."""
+ if not envs.VLLM_PLE_CPU_OFFLOAD:
+ return False
+ text_config = self.model_config.hf_text_config
+ ple_layer_ids = getattr(text_config, "ple_layer_ids", None)
+ if not ple_layer_ids:
+ return False
+ # PP (vllm#54709, carried from the earlier overlay): only the pipeline rank
+ # that owns a PLE layer takes part in the offload (rank 0 with the
+ # default partition). Other ranks build PPMissingLayer there and must
+ # skip the connector entirely.
+ pp_size = self.parallel_config.pipeline_parallel_size
+ if pp_size > 1:
+ from vllm.distributed.utils import get_pp_indices
+
+ tp_size = self.parallel_config.tensor_parallel_size
+ pp_rank = (self.rank // tp_size) % pp_size
+ start, end = get_pp_indices(
+ int(text_config.num_hidden_layers), pp_rank, pp_size
+ )
+ return any(start <= int(i) - 1 < end for i in ple_layer_ids)
+ return True
+
+ def _validate_ple_offload_config(self) -> None:
+ """Reject unsupported PLE offload execution modes."""
+ parallel_config = self.parallel_config
+ unsupported = []
+ if not self.use_v2_model_runner:
+ unsupported.append("model runner V1")
+ if not current_platform.is_cuda():
+ unsupported.append(f"device={current_platform.device_type}")
+ if parallel_config.nnodes != 1:
+ unsupported.append(f"nnodes={parallel_config.nnodes}")
+ if parallel_config.data_parallel_backend != "mp":
+ unsupported.append(f"DP backend={parallel_config.data_parallel_backend}")
+ if (
+ parallel_config.data_parallel_size_local
+ != parallel_config.data_parallel_size
+ ):
+ unsupported.append(
+ "non-local DP "
+ f"({parallel_config.data_parallel_size_local}/"
+ f"{parallel_config.data_parallel_size} local ranks)"
+ )
+ # PP allowed (vllm#54709): PLE layers must live on pipeline rank 0
+ # (enforced placement-based in models/config.py); num_workers stays
+ # dp_size * tp_size = the ranks of the owning stage.
+ if parallel_config.prefill_context_parallel_size != 1:
+ unsupported.append(f"PCP={parallel_config.prefill_context_parallel_size}")
+ if parallel_config.decode_context_parallel_size != 1:
+ unsupported.append(f"DCP={parallel_config.decode_context_parallel_size}")
+ if parallel_config.use_ubatching:
+ unsupported.append("ubatching/DBO")
+ if self.model_config.architecture not in {
+ "Qwen4ExpForCausalLM",
+ "Qwen4ExpForConditionalGeneration",
+ }:
+ unsupported.append("architecture")
+ if self.vllm_config.weight_transfer_config is not None:
+ unsupported.append("weight transfer")
+
+ if unsupported:
+ raise ValueError(
+ "VLLM_PLE_CPU_OFFLOAD does not support the requested "
+ "configuration. Unsupported settings: "
+ f"{', '.join(unsupported)}"
+ )
+
+ def spawn_ple_offload(self) -> None:
+ """Spawn one node-local PLE CPU worker from DP0/TP0."""
+ if (
+ not self._ple_offload_enabled
+ or self.rank != 0
+ or self.parallel_config.data_parallel_rank != 0
+ ):
+ return
+
+ from vllm.v1.ple_offload.worker import PleOffloadWorker
+
+ ipc_addr = self.parallel_config._ple_offload_ipc_path
+ if not ipc_addr:
+ raise RuntimeError("PLE offload IPC address was not initialized")
+ dp_size = self.parallel_config.data_parallel_size
+ tp_size = self.parallel_config.tensor_parallel_size
+ num_workers = dp_size * tp_size
+ logger.info(
+ "PleOffload: spawning worker "
+ "(rank=%d, local_rank=%d, dp_size=%d, tp_size=%d, "
+ "num_workers=%d, ipc_addr=%s).",
+ self.rank,
+ self.local_rank,
+ dp_size,
+ tp_size,
+ num_workers,
+ ipc_addr,
+ )
+ self._ple_offload_worker_handle = PleOffloadWorker.make_process(
+ self.vllm_config,
+ num_workers,
+ ipc_addr,
+ )
+ self._start_ple_offload_watchdog()
+
+ def _start_ple_offload_watchdog(self) -> None:
+ """Fail this worker if the PLE offload process exits unexpectedly.
+
+ Host-mapped pull: a dead producer would leave the captured forward
+ waiting on its host flag forever; turn that into a fast, visible
+ engine failure instead of a hang. ``shutdown`` sets the stop marker
+ before closing the handle, so a normal close is not a failure.
+ """
+ import threading
+
+ handle = self._ple_offload_worker_handle
+ self._ple_offload_stopping = False
+
+ def watch() -> None:
+ returncode = handle.proc.wait()
+ if getattr(self, "_ple_offload_stopping", False):
+ return
+ logger.error(
+ "PLE offload process %s exited unexpectedly (exitcode %s); "
+ "failing this worker so the engine does not hang on a PLE wait",
+ handle.proc.pid,
+ returncode,
+ )
+ os._exit(1)
+
+ threading.Thread(
+ target=watch, name="ple-offload-watchdog", daemon=True
+ ).start()
+
+ def wait_ple_offload_ready(self) -> None:
+ """Wait until weights and every TP registration are ready."""
+ if self._ple_offload_worker_handle is None:
+ return
+
+ from vllm.v1.ple_offload.worker import PleOffloadWorker
+
+ PleOffloadWorker.wait_for_ready(self._ple_offload_worker_handle)
+
@property
def sleep_mode_backend(self) -> "SleepModeBackend":
if self._sleep_mode_backend is None:
@@ -1152,6 +1295,7 @@ class Worker(WorkerBase):
) -> ModelRunnerOutput | AsyncModelRunnerOutput:
return self.model_runner.sample_tokens(grammar_output)
+ @fail_ple_execute_model
@torch.inference_mode()
@with_gpu_sync_check
def execute_model(
@@ -1487,6 +1631,7 @@ class Worker(WorkerBase):
self.model_runner.reset_lora_state()
def shutdown(self) -> None:
+ self._ple_offload_stopping = True
gc.unfreeze()
# Bounds-guard instrumentation: synchronous final read of the guard counters, so
diff --git a/vllm_ple_offload_bootstrap/__init__.py b/vllm_ple_offload_bootstrap/__init__.py
new file mode 100644
index 0000000..60ddda4
--- /dev/null
+++ b/vllm_ple_offload_bootstrap/__init__.py
@@ -0,0 +1,210 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Minimal PLE child bootstrap; intentionally imports no torch or vLLM."""
+
+import importlib.abc
+import importlib.machinery
+import os
+import pickle
+import struct
+import sys
+import threading
+from multiprocessing.connection import Connection
+from types import ModuleType
+from typing import Any
+
+_FAULT = None
+if os.environ.get("VLLM_PLE_TEST_FAULT") is not None:
+ from vllm_ple_test_fault import (
+ _FAULT as _FAULT,
+ _block_on_harness_gate,
+ )
+
+
+def _send_partial_ready_frame(fd: int) -> tuple[int, int]:
+ """Write the complete header and an incomplete body in one syscall."""
+ body = pickle.dumps(
+ {"status": "READY", "layer_names": []},
+ protocol=pickle.HIGHEST_PROTOCOL,
+ )
+ frame = struct.pack("!i", len(body)) + body
+ fragment = frame[: 4 + max(1, len(body) // 2)]
+ try:
+ return os.write(fd, fragment), len(frame)
+ except OSError:
+ return 0, len(frame)
+
+
+def _send_partial_ready_header(fd: int) -> tuple[int, int]:
+ """Write fewer than the four required header bytes in one syscall."""
+ header = struct.pack("!i", 1)
+ try:
+ return os.write(fd, header[:2]), len(header)
+ except OSError:
+ return 0, len(header)
+
+
+def _read_exact(fd: int, size: int) -> bytes:
+ chunks = bytearray()
+ while len(chunks) < size:
+ chunk = os.read(fd, size - len(chunks))
+ if not chunk:
+ raise EOFError("PLE launch payload was truncated")
+ chunks.extend(chunk)
+ return bytes(chunks)
+
+
+def _recv_launch_payload(fd: int) -> dict:
+ size = struct.unpack("!Q", _read_exact(fd, 8))[0]
+ payload = pickle.loads(_read_exact(fd, size))
+ if not isinstance(payload, dict):
+ raise RuntimeError("invalid PLE bootstrap payload")
+ return payload
+
+
+def _fires(
+ cut: str,
+ *,
+ unit: str = "bootstrap-launch",
+ transaction: str = "launch:1",
+ bytes_written: int | None = None,
+ frame_size: int | None = None,
+) -> bool:
+ return _FAULT is not None and _FAULT.fires(
+ cut,
+ role="bootstrap",
+ unit=unit,
+ eligible=True,
+ transaction_id=transaction,
+ correlation_id="launch:1",
+ bytes_written=bytes_written,
+ frame_size=frame_size,
+ )
+
+
+def _write_and_ack_partial(
+ fd: int,
+ cut: str,
+ sender: Any,
+) -> bool:
+ """A short or failed prefix write cannot acknowledge or enter the park."""
+ if _FAULT is None or _FAULT.cut != cut:
+ # Disarmed or armed for another cut: the control stream is untouched.
+ return False
+ written, frame_size = sender(fd)
+ if written <= 0 or written >= frame_size:
+ return False
+ expected = 2 if cut == "launch_partial_ready_header" else None
+ if expected is not None and written != expected:
+ return False
+ if expected is None and written <= 4:
+ return False
+ return _fires(
+ cut,
+ bytes_written=written,
+ frame_size=frame_size,
+ )
+
+
+class _FaultImportLoader(importlib.abc.Loader):
+ def __init__(self, delegate: importlib.abc.Loader) -> None:
+ self._delegate = delegate
+
+ def create_module(self, spec):
+ create = getattr(self._delegate, "create_module", None)
+ return None if create is None else create(spec)
+
+ def exec_module(self, module: ModuleType) -> None:
+ if _fires(
+ "launch_block_worker_import",
+ unit="bootstrap-import",
+ transaction="module:vllm.v1.ple_offload.worker",
+ ):
+ _block_on_harness_gate()
+ self._delegate.exec_module(module)
+
+
+class _FaultImportFinder(importlib.abc.MetaPathFinder):
+ _TARGET = "vllm.v1.ple_offload.worker"
+
+ def find_spec(self, fullname: str, path, target=None):
+ if fullname != self._TARGET:
+ return None
+ spec = importlib.machinery.PathFinder.find_spec(fullname, path, target)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"cannot find {fullname}")
+ if not isinstance(spec.loader, importlib.abc.Loader):
+ raise ImportError(f"{fullname} has no executable loader")
+ spec.loader = _FaultImportLoader(spec.loader)
+ return spec
+
+
+def _install_import_gate() -> None:
+ target = _FaultImportFinder._TARGET
+ if target in sys.modules:
+ raise RuntimeError("PLE worker import gate was a cache hit")
+ sys.meta_path.insert(0, _FaultImportFinder())
+
+
+def main() -> None:
+ """Receive launch state before importing torch or vLLM."""
+ if len(sys.argv) != 3:
+ raise SystemExit("expected inherited PLE control and parent-death fds")
+ control_fd = int(sys.argv[1])
+ death_fd = int(sys.argv[2])
+ if control_fd == death_fd:
+ raise RuntimeError("PLE control and parent-death fds must be distinct")
+
+ os.set_inheritable(control_fd, False)
+ os.set_inheritable(death_fd, False)
+ death_pipe = Connection(death_fd)
+
+ def monitor_parent() -> None:
+ try:
+ death_pipe.recv()
+ except (EOFError, OSError):
+ pass
+ os._exit(1)
+
+ threading.Thread(
+ target=monitor_parent,
+ daemon=True,
+ name="PleOffloadBootstrapDeathMonitor",
+ ).start()
+
+ if _fires("launch_park_before_payload_read"):
+ threading.Event().wait()
+
+ # An armed reconstruction payload invokes its reducer inside this loads().
+ payload = _recv_launch_payload(control_fd)
+
+ if _write_and_ack_partial(
+ control_fd,
+ "launch_partial_ready",
+ _send_partial_ready_frame,
+ ):
+ threading.Event().wait()
+
+ if _write_and_ack_partial(
+ control_fd,
+ "launch_partial_ready_header",
+ _send_partial_ready_header,
+ ):
+ threading.Event().wait()
+
+ if _fires("launch_silent_ready"):
+ threading.Event().wait()
+
+ ready_pipe = Connection(control_fd)
+ vllm_config = pickle.loads(payload["vllm_config_payload"])
+
+ if _FAULT is not None and _FAULT.cut == "launch_block_worker_import":
+ _install_import_gate()
+ from vllm.v1.ple_offload.worker import PleOffloadWorker
+
+ PleOffloadWorker.proc_main(
+ vllm_config=vllm_config,
+ num_workers=int(payload["num_workers"]),
+ ipc_addr=str(payload["ipc_addr"]),
+ ready_pipe=ready_pipe,
+ )
diff --git a/vllm_ple_offload_bootstrap/__main__.py b/vllm_ple_offload_bootstrap/__main__.py
new file mode 100644
index 0000000..5b3ca57
--- /dev/null
+++ b/vllm_ple_offload_bootstrap/__main__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Fresh-interpreter entry point for the PLE CPU-offload worker."""
+
+from vllm_ple_offload_bootstrap import main
+
+main()
--
2.43.0