[Bugfix][DP] Synchronize the device on pause completion (#52914)
Signed-off-by: aoshen02 <aoshen@inferact.ai>
aoshen02 committed
Aug 27, 2026 at 00:50 UTC
17da48596c98946d3e3e6896e2ebd341e809f3bd
5 files changed
+164
-8
tests/v1/distributed/test_async_llm_dp.py
+110
@@ -713,3 +713,113 @@ async def test_dp_pause_barrier_request_deadlock():
713
assert not await engine.is_paused()
714
# Let the two requests we sent mid-barrier complete.
715
await asyncio.gather(*mid_barrier_tasks)
716
+
717
+
718
+@pytest.mark.asyncio
719
+async def test_dp_pause_wait_mode_drains_in_flight():
720
+ """mode="wait" through the DP consensus path: the pause lets the
721
+ in-flight request run to completion while the consensus is pending,
722
+ resolves only once drained, and the engine generates again on resume."""
723
+ with ExitStack() as after:
724
+ engine = AsyncLLM.from_engine_args(_get_dp_pause_engine_args(True))
725
+ after.callback(engine.shutdown)
726
+
727
+ collector = await engine.add_request(
728
+ request_id="inflight",
729
+ prompt=DP_PAUSE_PROMPT,
730
+ params=SamplingParams(max_tokens=64),
731
+ )
732
+ await engine.pause_generation(mode="wait")
733
+ assert await engine.is_paused()
734
+ while True:
735
+ out = await asyncio.wait_for(collector.get(), timeout=30)
736
+ if out.finished:
737
+ break
738
+
739
+ await engine.resume_generation()
740
+ async for out in engine.generate(
741
+ request_id="after-wait",
742
+ prompt=DP_PAUSE_PROMPT,
743
+ sampling_params=SamplingParams(max_tokens=5),
744
+ ):
745
+ pass
746
+ assert out.finished
747
+
748
+
749
+@pytest.mark.asyncio
750
+async def test_dp_pause_while_asleep():
751
+ """Pausing a sleeping DP engine: sleeping ranks skip dummy batches yet
752
+ must still reach the pause consensus, and the completion barrier must
753
+ be harmless on workers whose memory is unmapped."""
754
+ with ExitStack() as after:
755
+ engine = AsyncLLM.from_engine_args(_get_dp_pause_engine_args(True))
756
+ after.callback(engine.shutdown)
757
+
758
+ async for _ in engine.generate(
759
+ request_id="warmup",
760
+ prompt=DP_PAUSE_PROMPT,
761
+ sampling_params=SamplingParams(max_tokens=5),
762
+ ):
763
+ pass
764
+ await engine.sleep(level=1)
765
+ assert await engine.is_sleeping()
766
+
767
+ await asyncio.wait_for(engine.pause_generation(mode="abort"), timeout=30)
768
+ assert await engine.is_paused()
769
+
770
+ # A full wake makes the memory resident and resumes the scheduler.
771
+ await engine.wake_up()
772
+ assert not await engine.is_sleeping()
773
+ async for out in engine.generate(
774
+ request_id="after-wake",
775
+ prompt=DP_PAUSE_PROMPT,
776
+ sampling_params=SamplingParams(max_tokens=5),
777
+ ):
778
+ pass
779
+ assert out.finished
780
+
781
+
782
+@pytest.mark.asyncio
783
+async def test_dp_pause_completion_implies_device_idle(
784
+ monkeypatch: pytest.MonkeyPatch,
785
+):
786
+ """A resolved pause future promises an idle device. Inflate the dummy
787
+ batches the pause consensus manufactures so any work the pause fails to
788
+ wait on stays visible, then require a quiet worker stream the moment
789
+ pause_generation() returns."""
790
+ with ExitStack() as after:
791
+ # The probes below ship functions through collective_rpc.
792
+ monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
793
+ engine = AsyncLLM.from_engine_args(_get_dp_pause_engine_args(True))
794
+ after.callback(engine.shutdown)
795
+
796
+ async for _ in engine.generate(
797
+ request_id="warmup",
798
+ prompt=DP_PAUSE_PROMPT,
799
+ sampling_params=SamplingParams(max_tokens=5),
800
+ ):
801
+ pass
802
+
803
+ def inflate_dummy_batches(worker: Any) -> bool:
804
+ import torch
805
+
806
+ inner = worker.execute_dummy_batch
807
+
808
+ def slow_dummy_batch() -> None:
809
+ inner()
810
+ # Keep the stream busy after launch, like a large-MoE forward.
811
+ torch.cuda._sleep(200_000_000)
812
+
813
+ worker.execute_dummy_batch = slow_dummy_batch
814
+ return True
815
+
816
+ assert all(await engine.engine_core.collective_rpc_async(inflate_dummy_batches))
817
+
818
+ await asyncio.wait_for(engine.pause_generation(mode="abort"), timeout=60)
819
+
820
+ def device_idle(worker: Any) -> bool:
821
+ import torch
822
+
823
+ return bool(torch.cuda.current_stream().query())
824
+
825
+ assert all(await engine.engine_core.collective_rpc_async(device_idle))
tests/v1/engine/test_engine_core.py
+36
-2
@@ -5,7 +5,7 @@ import copy
5
import time
6
import uuid
7
from concurrent.futures import Future, ThreadPoolExecutor
8
-from unittest.mock import PropertyMock, patch
8
+from unittest.mock import MagicMock, PropertyMock, patch
9
10
import pytest
11
from transformers import AutoTokenizer
@@ -23,7 +23,7 @@ from vllm.engine.arg_utils import EngineArgs
23
from vllm.platforms import current_platform
24
from vllm.utils.torch_utils import set_default_torch_num_threads
25
from vllm.v1.engine import EngineCoreRequest
26
-from vllm.v1.engine.core import EngineCore
26
+from vllm.v1.engine.core import EngineCore, EngineCoreProc
27
from vllm.v1.executor.abstract import Executor
28
from vllm.v1.executor.uniproc_executor import UniProcExecutor
29
from vllm.v1.kv_cache_interface import KVCacheConfig
@@ -608,3 +608,37 @@ def test_encoder_instance_zero_kv_cache(
608
assert not engine_core.scheduler.ec_connector.is_producer, (
609
"Consumer instance EC connector should be consumer"
610
)
611
+
612
+
613
+def _pausable_engine_core_proc() -> EngineCoreProc:
614
+ """A bare EngineCoreProc holding just the state pause_scheduler touches."""
615
+ core = object.__new__(EngineCoreProc)
616
+ core.model_executor = MagicMock()
617
+ core.scheduler = MagicMock()
618
+ core.scheduler.has_requests.return_value = False
619
+ core.batch_queue = None
620
+ core.engines_running = False
621
+ core._idle_state_callbacks = []
622
+ return core
623
+
624
+
625
+@pytest.mark.parametrize("deferred", [False, True])
626
+def test_pause_synchronizes_device_before_cache_reset(deferred: bool):
627
+ """A resolved pause promises an idle device: the barrier must run before
628
+ caches are cleared and before the caller is unblocked."""
629
+ core = _pausable_engine_core_proc()
630
+ core.engines_running = deferred
631
+ order: list[str] = []
632
+ core.model_executor.collective_rpc.side_effect = lambda method: order.append(method)
633
+ core._reset_caches = lambda: order.append("reset_caches")
634
+
635
+ result = EngineCoreProc.pause_scheduler(core, mode="keep", clear_cache=True)
636
+ if deferred:
637
+ assert isinstance(result, Future)
638
+ assert not result.done() and order == []
639
+ core.engines_running = False
640
+ core._notify_idle_state_callbacks()
641
+ assert result.result(timeout=0) is None
642
+ else:
643
+ assert result is None
644
+ assert order == ["synchronize_device", "reset_caches"]
vllm/v1/engine/core.py
+10
-6
@@ -839,6 +839,13 @@ class EngineCore:
839
self.reset_mm_cache()
840
self.reset_encoder_cache()
841
842
+ def _finish_pause(self, clear_cache: bool) -> None:
843
+ # A completed pause promises an idle device: nothing else waits on
844
+ # the last dummy batch an idle DP rank launches.
845
+ self.model_executor.collective_rpc("synchronize_device")
846
+ if clear_cache:
847
+ self._reset_caches()
848
+
849
def pause_scheduler(
850
self, mode: PauseMode = "abort", clear_cache: bool = True
851
) -> Future | None:
@@ -865,8 +872,7 @@ class EngineCore:
872
873
pause_state = PauseState.PAUSED_ALL if mode == "keep" else PauseState.PAUSED_NEW
874
self.scheduler.set_pause_state(pause_state)
868
- if clear_cache:
869
- self._reset_caches()
875
+ self._finish_pause(clear_cache)
876
877
return None
878
@@ -1937,8 +1943,7 @@ class EngineCoreProc(EngineCore):
1943
raise ValueError(f"Invalid pause mode: {mode}")
1944
1945
def engine_idle_callback(engine: "EngineCoreProc", future: Future[Any]) -> None:
1940
- if clear_cache:
1941
- engine._reset_caches()
1946
+ engine._finish_pause(clear_cache)
1947
future.set_result(None)
1948
1949
if mode == "abort":
@@ -1951,8 +1956,7 @@ class EngineCoreProc(EngineCore):
1956
self.scheduler.set_pause_state(pause_state)
1957
1958
if self._pause_complete():
1954
- if clear_cache:
1955
- self._reset_caches()
1959
+ self._finish_pause(clear_cache)
1960
return None
1961
1962
future = Future[Any]()
vllm/v1/worker/gpu_worker.py
+2
@@ -259,6 +259,8 @@ class Worker(WorkerBase):
259
if tags is None or "kv_cache" in tags:
260
self.model_runner.post_kv_cache_wake_up()
261
262
+ self.synchronize_device()
263
+
264
def checkpoint_prepare(self) -> None:
265
checkpoint_prepare_distributed_state()
266
vllm/v1/worker/worker_base.py
+6
@@ -125,6 +125,12 @@ class WorkerBase:
125
"""Basic health check (override for device-specific checks)."""
126
return
127
128
+ def synchronize_device(self) -> None:
129
+ """Block until in-flight device work completes; backends outside
130
+ ``torch.accelerator`` must override with their own wait."""
131
+ if torch.accelerator.is_available():
132
+ torch.accelerator.synchronize()
133
+
134
def init_device(self) -> None:
135
"""Initialize device state, such as loading the model or other on-device
136
memory allocations.