Skip to content

Commit 8ea07fe

Browse files
committed
[RL] pause: use abort pipeline with scheduling loop alive for graceful termination
Replace the old preempted_all + error_response approach in _control_pause with a two-phase design: Phase 1: Block new requests via _rejecting_new_requests (NOT is_paused) - Scheduling loop keeps running so _trigger_abort can process - add_abort_req_ids(ALL) marks all requests for abort - Scheduling loop catches them via _trigger_abort as they cycle through Phase 2: After drain, set is_paused=True to fully stop scheduling loop - Handle scheduler-only stragglers with direct _send_error_response - Wait for output queue empty, then reset Depends-on: #7615 (refact abort_requests to fire-and-forget)
1 parent 8396ef6 commit 8ea07fe

2 files changed

Lines changed: 94 additions & 23 deletions

File tree

fastdeploy/engine/common_engine.py

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def __init__(self, cfg: FDConfig, start_queue=True, use_async_llm=False):
142142

143143
self.is_paused = False # pause request generation
144144
self._pause_cond = threading.Condition()
145+
self._rejecting_new_requests = False # blocks new requests during abort drain
145146

146147
self._ctrl_output_queues = {}
147148
self._ctrl_response_mailboxes = collections.defaultdict(collections.OrderedDict)
@@ -1325,7 +1326,7 @@ def _insert_zmq_task_to_scheduler(self):
13251326
trace_print(LoggingEventName.REQUEST_QUEUE_START, data["request_id"], data.get("user", ""))
13261327
self.llm_logger.debug(f"Receive request from api server: {request}")
13271328

1328-
if self.is_paused:
1329+
if self.is_paused or self._rejecting_new_requests:
13291330
self.llm_logger.warning(f"Engine is paused, drop request: {request}")
13301331
self._send_error_response(
13311332
request.request_id,
@@ -1445,12 +1446,14 @@ def _control_pause(self, control_request: ControlRequest):
14451446
if self.is_paused:
14461447
self.llm_logger.info("Engine is already paused, no need to pause again.")
14471448
return
1448-
self.is_paused = True
14491449

1450-
self.llm_logger.info("Abort running requests.")
1450+
# Phase 1: Block new requests but keep scheduling loop running
1451+
# (scheduling loop must continue to process _trigger_abort)
1452+
self._rejecting_new_requests = True
14511453

14521454
self.resource_manager.log_status()
1453-
# preempted all running reqs. preempted reqs will be append to ResourceManager.waiting queue
1455+
1456+
# Wait for current worker batch to complete
14541457
timeout, count = 60, 0
14551458
while self.engine_worker_queue.exist_tasks():
14561459
time.sleep(0.001)
@@ -1461,21 +1464,37 @@ def _control_pause(self, control_request: ControlRequest):
14611464
error_msg = f"Emptying engine worker queue timed out after {timeout} seconds, worker may hanged!"
14621465
self.llm_logger.error(error_msg)
14631466
raise Exception(error_msg)
1464-
running_reqs = self.resource_manager.preempted_all()
1465-
if len(running_reqs) > 0:
1466-
self.llm_logger.info(f"Total {len(running_reqs)} requests need to be aborted.")
1467-
self.resource_manager.get_real_bsz()
1468-
self.engine_worker_queue.put_tasks((running_reqs, self.resource_manager.real_bsz))
1469-
self.resource_manager.wait_worker_inflight_requests_finish(timeout=60)
1470-
# self.engine_worker_queue.clear_data()
1467+
1468+
# Phase 2: Trigger abort for ALL known requests
1469+
# Scheduling loop picks them up via _trigger_abort when they enter resource_manager
1470+
all_req_ids = list(set(self.resource_manager.requests.keys()) | set(self.scheduler.requests.keys()))
1471+
self.llm_logger.info(f"Pause: aborting {len(all_req_ids)} total requests.")
1472+
if all_req_ids:
1473+
self.resource_manager.add_abort_req_ids(all_req_ids)
1474+
1475+
# Phase 3: Wait for resource_manager to drain
1476+
# (all requests that enter rm will be caught by _trigger_abort)
1477+
self._wait_inflight_drained()
1478+
1479+
# Phase 4: Fully pause the scheduling loop
1480+
with self._pause_cond:
1481+
self.is_paused = True
1482+
self._rejecting_new_requests = False
1483+
1484+
# Handle any requests remaining in scheduler (never pulled into rm)
1485+
remaining = set(self.scheduler.requests.keys())
1486+
if remaining:
1487+
self.llm_logger.info(f"Pause: {len(remaining)} scheduler-only requests, sending abort directly.")
1488+
for req_id in remaining:
1489+
self._send_error_response(req_id, "Aborted", error_code=200)
1490+
self.resource_manager.waiting_abort_req_id_set.discard(req_id)
1491+
1492+
# Phase 5: Wait for output queue to be consumed (responses sent via ZMQ)
1493+
self._wait_output_queue_empty()
1494+
1495+
# Phase 6: Reset
14711496
self.token_processor.clear_data()
14721497
self.resource_manager.log_status()
1473-
1474-
# abort inflight requests to user
1475-
inflight_requests = self.scheduler.get_inflight_requests()
1476-
self.llm_logger.info(f"Abort inflight requests (total {len(inflight_requests)}).")
1477-
for req in inflight_requests:
1478-
self._send_error_response(req.request_id, "Request is aborted since engine is paused.")
14791498
self.scheduler.reset()
14801499

14811500
if envs.ENABLE_V1_KVCACHE_MANAGER:
@@ -1500,6 +1519,50 @@ def _control_pause(self, control_request: ControlRequest):
15001519
self.llm_logger.info("Successfully paused request generation.")
15011520
return None
15021521

1522+
def _wait_inflight_drained(self):
1523+
"""
1524+
Wait until resource_manager.requests is completely empty.
1525+
No timeout — abort pipeline will complete. Aligned with SGLang's poll-until-drained.
1526+
"""
1527+
start_time = time.time()
1528+
warned_10s = False
1529+
warned_30s = False
1530+
while self.resource_manager.requests:
1531+
elapsed = time.time() - start_time
1532+
if elapsed > 30 and not warned_30s:
1533+
warned_30s = True
1534+
self.llm_logger.warning(
1535+
f"Abort drain stalled ({elapsed:.1f}s): "
1536+
f"{len(self.resource_manager.requests)} requests remaining, "
1537+
f"waiting_abort={len(self.resource_manager.waiting_abort_req_id_set)}, "
1538+
f"to_be_aborted={len(self.resource_manager.to_be_aborted_req_id_set)}"
1539+
)
1540+
elif elapsed > 10 and not warned_10s:
1541+
warned_10s = True
1542+
self.llm_logger.warning(
1543+
f"Abort drain slow ({elapsed:.1f}s): " f"{len(self.resource_manager.requests)} requests remaining"
1544+
)
1545+
time.sleep(0.005)
1546+
self.llm_logger.info("All inflight requests drained.")
1547+
1548+
def _wait_output_queue_empty(self):
1549+
"""
1550+
Wait until scheduler output queue is consumed by the output thread.
1551+
Ensures all abort responses have been sent via ZMQ before reset.
1552+
"""
1553+
start_time = time.time()
1554+
warned = False
1555+
while True:
1556+
with self.scheduler.mutex:
1557+
has_pending = bool(self.scheduler.responses) or bool(self.scheduler.batch_responses_per_step)
1558+
if not has_pending:
1559+
return
1560+
elapsed = time.time() - start_time
1561+
if elapsed > 5 and not warned:
1562+
warned = True
1563+
self.llm_logger.warning(f"Output queue drain slow ({elapsed:.1f}s)")
1564+
time.sleep(0.005)
1565+
15031566
def _control_resume(self, control_request: ControlRequest) -> Optional[dict]:
15041567
"""Control function for resuming request generation.
15051568

tests/engine/test_common_engine.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,22 +1137,30 @@ def test_control_pause_and_resume_paths(self):
11371137
eng = self._make_mixed_engine()
11381138
eng.is_paused = False
11391139
eng._pause_cond = threading.Condition()
1140-
eng.engine_worker_queue = Mock(exist_tasks=Mock(return_value=False), put_tasks=Mock())
1140+
eng.engine_worker_queue = Mock(exist_tasks=Mock(return_value=False))
11411141
eng.resource_manager = Mock(
1142-
preempted_all=Mock(return_value=[Request(request_id="r1", prompt_token_ids=[1], prompt_token_ids_len=1)]),
1143-
get_real_bsz=Mock(),
1144-
wait_worker_inflight_requests_finish=Mock(),
1142+
requests={"r1": Mock(output_token_ids=[1, 2, 3])},
1143+
waiting_abort_req_id_set=set(),
1144+
to_be_aborted_req_id_set=set(),
1145+
add_abort_req_ids=Mock(),
11451146
log_status=Mock(),
11461147
cache_manager=Mock(reset=Mock()),
1147-
real_bsz=1,
11481148
)
11491149
eng.token_processor = Mock(clear_data=Mock())
1150-
eng.scheduler = Mock(get_inflight_requests=Mock(return_value=[]), reset=Mock())
1150+
mock_scheduler = Mock(reset=Mock())
1151+
mock_scheduler.requests = {}
1152+
mock_scheduler.mutex = threading.Lock()
1153+
mock_scheduler.responses = {}
1154+
mock_scheduler.batch_responses_per_step = []
1155+
eng.scheduler = mock_scheduler
11511156
eng._send_error_response = Mock()
1157+
eng._wait_inflight_drained = Mock()
1158+
eng._wait_output_queue_empty = Mock()
11521159

11531160
with patch("fastdeploy.engine.common_engine.envs.ENABLE_V1_KVCACHE_SCHEDULER", True):
11541161
eng._control_pause(ControlRequest(request_id="ctrl1", method="pause"))
11551162
self.assertTrue(eng.is_paused)
1163+
eng.resource_manager.add_abort_req_ids.assert_called_once()
11561164

11571165
eng._control_resume(ControlRequest(request_id="ctrl2", method="resume"))
11581166
self.assertFalse(eng.is_paused)

0 commit comments

Comments
 (0)