[Bugfix][DP] Don't assume the engines started when forwarding a wake (#51481)
Signed-off-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
aoshen02 committed
Aug 19, 2026 at 04:49 UTC
7ddb50788ddf0846518feff972028534e39bcb86
3 files changed
+155
-2
tests/v1/distributed/test_async_llm_dp.py
+137
@@ -299,6 +299,143 @@ async def test_dp_pause_resume_basic(expert_parallel: bool):
299
assert out.finished
300
301
302
+async def _consume(generator) -> None:
303
+ async for _ in generator:
304
+ pass
305
+
306
+
307
+async def _poll_flag(engine: AsyncLLM, want: bool, timeout: float) -> bool:
308
+ """Wait for the front-end's view of the DP engines to reach ``want``."""
309
+ deadline = time.monotonic() + timeout
310
+ while time.monotonic() < deadline:
311
+ if engine.engine_core.dp_engines_running() == want:
312
+ return True
313
+ await asyncio.sleep(0.05)
314
+ return False
315
+
316
+
317
+@pytest.mark.asyncio
318
+async def test_dp_pause_late_request_does_not_block_drain():
319
+ """A request arriving after pause must not leave the coordinator believing
320
+ the engines are running.
321
+
322
+ Paused engines discard START_DP_WAVE, so nothing can report wave
323
+ completion afterwards; if forwarding the wake also marks the engines as
324
+ running, drain has no way back and can only end in a timeout.
325
+
326
+ MoE only: wave coordination is enabled iff the model is MoE, so a dense
327
+ model never reaches the coordinator state this exercises.
328
+ """
329
+ with ExitStack() as after:
330
+ engine_args = _get_dp_pause_engine_args(expert_parallel=True)
331
+ engine = AsyncLLM.from_engine_args(engine_args)
332
+ after.callback(engine.shutdown)
333
+
334
+ # Run a wave first, so the engines are quiesced by the pause rather
335
+ # than by never having started, and drain has something to observe.
336
+ long_request = asyncio.create_task(
337
+ _consume(
338
+ engine.generate(
339
+ request_id="warmup",
340
+ prompt=DP_PAUSE_PROMPT,
341
+ sampling_params=SamplingParams(max_tokens=400, ignore_eos=True),
342
+ )
343
+ )
344
+ )
345
+
346
+ # A design that never reports the engines as running would satisfy
347
+ # every drain assertion below while destroying the signal, so pin it
348
+ # down first. The front-end sets its own copy optimistically when it
349
+ # forwards the wake, so sample only after several coordinator
350
+ # publishes (every 100ms while stats change) have overwritten it.
351
+ await asyncio.sleep(2)
352
+ assert not long_request.done(), "the warmup request was too short to sample"
353
+ assert engine.engine_core.dp_engines_running(), (
354
+ "the coordinator does not report the engines as running while they are"
355
+ )
356
+
357
+ await long_request
358
+ assert await _poll_flag(engine, False, timeout=30)
359
+
360
+ await engine.pause_generation(mode="abort")
361
+ await engine.wait_for_requests_to_drain(drain_timeout=30)
362
+
363
+ # Awaiting add_request guarantees the new-request notification has been
364
+ # sent to the coordinator - the message that used to latch the flag.
365
+ collector = await engine.add_request(
366
+ request_id="late",
367
+ prompt=DP_PAUSE_PROMPT,
368
+ params=SamplingParams(max_tokens=5),
369
+ )
370
+
371
+ # The front-end marks the engines running off the back of that
372
+ # notification. This is what makes the test non-vacuous: it is the
373
+ # path that used to leave the coordinator stuck.
374
+ assert await _poll_flag(engine, True, timeout=5), (
375
+ "the late request did not notify the coordinator"
376
+ )
377
+
378
+ # It must settle back by itself. Unfixed it never does, because the
379
+ # paused engines discard the wake and so never report wave completion.
380
+ assert await _poll_flag(engine, False, timeout=60)
381
+
382
+ # The late request was held rather than dropped: it completes on resume.
383
+ await engine.resume_generation()
384
+ while True:
385
+ out = await asyncio.wait_for(collector.get(), timeout=60)
386
+ if out.finished:
387
+ break
388
+
389
+
390
+@pytest.mark.asyncio
391
+async def test_dp_sleep_late_request_does_not_block_drain():
392
+ """The same latch, reached through sleep rather than pause.
393
+
394
+ Sleep stops the engines stepping just as pause does, so a request arriving
395
+ while they are asleep can leave the coordinator believing they are running
396
+ with nothing able to say otherwise. This is worth pinning separately from
397
+ the pause case because it is the shape that reaches
398
+ `_drain_requests_for_elastic_ep`, which decides from the same signal
399
+ whether it is safe to scale.
400
+ """
401
+ with ExitStack() as after:
402
+ engine_args = _get_dp_pause_engine_args(expert_parallel=True)
403
+ engine = AsyncLLM.from_engine_args(engine_args)
404
+ after.callback(engine.shutdown)
405
+
406
+ async for _ in engine.generate(
407
+ request_id="warmup",
408
+ prompt=DP_PAUSE_PROMPT,
409
+ sampling_params=SamplingParams(max_tokens=5),
410
+ ):
411
+ pass
412
+ assert await _poll_flag(engine, False, timeout=30)
413
+
414
+ await engine.sleep(level=1)
415
+ assert await engine.is_sleeping()
416
+
417
+ collector = await engine.add_request(
418
+ request_id="while-asleep",
419
+ prompt=DP_PAUSE_PROMPT,
420
+ params=SamplingParams(max_tokens=5),
421
+ )
422
+
423
+ assert await _poll_flag(engine, True, timeout=5), (
424
+ "the request did not notify the coordinator"
425
+ )
426
+
427
+ # Sleeping engines cannot report wave completion, so a coordinator that
428
+ # marked them running when it forwarded the wake never hears otherwise.
429
+ assert await _poll_flag(engine, False, timeout=60)
430
+
431
+ await engine.wake_up()
432
+ assert not await engine.is_sleeping()
433
+ while True:
434
+ out = await asyncio.wait_for(collector.get(), timeout=60)
435
+ if out.finished:
436
+ break
437
+
438
+
439
@pytest.mark.asyncio
440
@pytest.mark.parametrize("expert_parallel", [False, True])
441
async def test_dp_pause_abort(expert_parallel: bool):
vllm/v1/engine/coordinator.py
+4
-2
@@ -358,8 +358,10 @@ class DPCoordinatorProc:
358
# is handled by all the engines.
359
engine_to_exclude = None
360
361
- engines_running = True
362
- wave_state_changed = True
361
+ # engines_running is only set from the engines'
362
+ # own notifications; a paused engine discards
363
+ # START_DP_WAVE, so sending it is not evidence
364
+ # that the engines are running.
365
self._send_start_wave(
366
publish_back, current_wave, engine_to_exclude
367
)
vllm/v1/engine/core.py
+14
@@ -2156,6 +2156,7 @@ class DPEngineCoreProc(EngineCoreProc):
2156
# Loop until process is sent a SIGINT or SIGTERM
2157
while self._handle_shutdown():
2158
# 1) Poll the input queue until there is work to do.
2159
+ was_running = self.engines_running
2160
self._process_input_queue()
2161
# Publish request counts before and after GPU step to ensure freshness.
2162
self._maybe_publish_request_counts()
@@ -2216,6 +2217,19 @@ class DPEngineCoreProc(EngineCoreProc):
2217
# Increment wave count and reset step counter.
2218
self.current_wave += 1
2219
self.step_counter = 0
2220
+ elif (
2221
+ not was_running
2222
+ and self.has_coordinator
2223
+ and self.dp_rank == 0
2224
+ and not self.pending_pause
2225
+ ):
2226
+ # Mirror of the wave_complete notification above: the
2227
+ # coordinator must observe this edge too rather than assume
2228
+ # that a START_DP_WAVE it sent was acted upon, since a paused
2229
+ # engine discards it.
2230
+ self.output_queue.put_nowait(
2231
+ (-1, EngineCoreOutputs(start_wave=self.current_wave))
2232
+ )
2233
2234
raise SystemExit
2235