Skip to content

Commit 7976928

Browse files
authored
Abort during chunked prefill + PD peer-liveness abort (sgl-project#28086)
1 parent d253998 commit 7976928

8 files changed

Lines changed: 763 additions & 3 deletions

File tree

python/sglang/srt/disaggregation/prefill.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,16 +406,55 @@ def maybe_prefetch_staging_for_batch(self: Scheduler, batch: ScheduleBatch) -> N
406406
if room is not None and room in kv_mgr.transfer_infos:
407407
prefetch(room)
408408

409+
def resolve_waiting_queue_bootstrap(self: Scheduler) -> None:
410+
"""Resolve bootstrap status for waiting prefill requests before admission.
411+
412+
Covers the window between leaving the bootstrap queue and being admitted
413+
into a running batch: aborts requests whose decode peer died, and
414+
finalizes optimistic requests whose bootstrap completed so they skip
415+
the post-forward bootstrap check.
416+
"""
417+
candidates = [req for req in self.waiting_queue if not is_aborted(req)]
418+
if not candidates:
419+
return
420+
polls = poll_and_all_reduce_attn_cp_tp_group(
421+
[req.disagg_kv_sender for req in candidates],
422+
self.attn_cp_cpu_group,
423+
self.attn_tp_cpu_group,
424+
)
425+
failed = set()
426+
for req, poll in zip(candidates, polls):
427+
if poll == KVPoll.Failed:
428+
self.handle_bootstrap_failure(req)
429+
failed.add(req)
430+
elif (
431+
poll == KVPoll.WaitingForInput
432+
and req.pending_bootstrap
433+
and not should_force_retry(req)
434+
):
435+
# Optimistic requests reserved a metadata buffer when popped, so
436+
# finalize cannot fail here; if it ever does, the request stays
437+
# pending and the post-forward check resolves it.
438+
self.disagg_prefill_bootstrap_queue.finalize_bootstrap(req)
439+
if failed:
440+
self.waiting_queue = [
441+
req for req in self.waiting_queue if req not in failed
442+
]
443+
409444
@scheduler_nvtx_method("scheduler.get_next_batch_to_run")
410445
def get_next_disagg_prefill_batch_to_run(
411446
self: Scheduler,
412447
) -> Optional[ScheduleBatch]:
448+
self.process_pending_chunked_abort()
449+
413450
# HACK (byronhsu): reset the batch_is_full flag because we never enter update_running_batch which resets it
414451
# Otherwise, it hangs under high concurrency
415452
self.running_batch.batch_is_full = False
416453

417454
self.process_prefill_chunk()
418455

456+
self.resolve_waiting_queue_bootstrap()
457+
419458
batch = self.get_new_batch_prefill()
420459
batch = self.dp_attn_adapter.maybe_prepare_mlp_sync_batch(batch)
421460

python/sglang/srt/managers/scheduler.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,7 @@ def init_chunked_prefill(self):
985985
elif self.chunked_prefill_size is not None and self.chunked_prefill_size <= 0:
986986
self.chunked_prefill_size = None
987987
self.chunked_req = None
988+
self._pending_chunked_abort_req = None
988989
self.is_mixed_chunk = (
989990
self.chunked_prefill_size is not None
990991
and self.server_args.enable_mixed_chunk
@@ -2424,6 +2425,52 @@ def handle_batch_embedding_request(
24242425
def stash_chunked_request(self, req: Req):
24252426
maybe_cache_unfinished_req(req, self.tree_cache, chunked=True)
24262427

2428+
def process_pending_chunked_abort(self) -> None:
2429+
"""Abort an in-flight chunked-prefill request once it is safe to do so.
2430+
2431+
``abort_request`` only records the target in ``_pending_chunked_abort_req``
2432+
(tearing it down mid-iteration is unsafe). Clearing ``chunked_req`` here at
2433+
the top of the scheduling step stops the next chunk from launching; the
2434+
chunk already launched is drained when its result is resolved. Under overlap
2435+
the result lands a step later, so the batch-result processors keep
2436+
``inflight_middle_chunks`` accounting intact and skip the aborted chunk:
2437+
``process_batch_result_disagg_prefill`` via its ``is_aborted`` drop, and
2438+
``process_batch_result_prefill`` via its chunked branch (the finished req
2439+
is excluded from streaming and its logprob offset is still accounted).
2440+
Mirrors ``handle_bootstrap_failure``.
2441+
"""
2442+
req = self._pending_chunked_abort_req
2443+
if req is None:
2444+
return
2445+
if self.chunked_req is not req:
2446+
# Already past chunked prefill; the running-batch abort path handles
2447+
# it. Drop the marker once the request is actually gone.
2448+
if req.finished() or req.req_pool_idx is None:
2449+
self._pending_chunked_abort_req = None
2450+
return
2451+
2452+
prepare_abort(req, "Aborted")
2453+
req.time_stats.trace_ctx.abort(abort_info={"reason": "Aborted"})
2454+
req.to_finish = None
2455+
if self.disaggregation_mode == DisaggregationMode.PREFILL:
2456+
req.disagg_kv_sender.abort()
2457+
maybe_release_metadata_buffer(
2458+
req, self.req_to_metadata_buffer_idx_allocator
2459+
)
2460+
req.pending_bootstrap = False
2461+
if self.enable_hicache_storage:
2462+
self.tree_cache.release_aborted_request(req.rid)
2463+
if (
2464+
req.req_pool_idx is not None or self.tree_cache.supports_mamba()
2465+
) and not req.kv_committed_freed:
2466+
release_kv_cache(req, self.tree_cache, is_insert=False)
2467+
2468+
self.chunked_req = None
2469+
self._chunked_req_scheduled_last_iter = False
2470+
self._pending_chunked_abort_req = None
2471+
self.ipc_channels.send_to_tokenizer.send_output(AbortReq(rid=req.rid), req)
2472+
logger.debug(f"Abort chunked prefill request. {req.rid=}")
2473+
24272474
def _build_hisparse_decode_batch(self, reqs):
24282475
"""Build a ScheduleBatch for hisparse requests transitioning from staging to decode."""
24292476
device = self.device
@@ -2467,6 +2514,8 @@ def _build_hisparse_decode_batch(self, reqs):
24672514

24682515
@scheduler_nvtx_method("scheduler.get_next_batch_to_run")
24692516
def get_next_batch_to_run(self) -> Optional[ScheduleBatch]:
2517+
self.process_pending_chunked_abort()
2518+
24702519
if self.enable_fpm:
24712520
self._fpm_batch_t0 = time.monotonic()
24722521
self._abort_on_waiting_timeout()
@@ -3684,6 +3733,10 @@ def handle_rpc_request(self, recv_req: RpcReqInput):
36843733
return RpcReqOutput(success, "" if not exec else str(exec))
36853734

36863735
def abort_request(self, recv_req: AbortReq):
3736+
if (chunked_req := self.chunked_req) is not None:
3737+
if recv_req.abort_all or chunked_req.rid.startswith(recv_req.rid):
3738+
self._pending_chunked_abort_req = chunked_req
3739+
36873740
# todo hisparse, release resources for abort requests in hisparse coordinator
36883741
# Delete requests in the waiting queue
36893742
to_del = []

python/sglang/srt/managers/scheduler_components/batch_result_processor.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,12 @@ def process_batch_result_prefill(
216216
logprob_pt = 0
217217

218218
for i, (req, next_token_id) in enumerate(zip(batch.reqs, next_token_ids)):
219-
if req.finished() or req.is_retracted:
220-
# decode req in mixed batch or retracted req
219+
if (
220+
req.finished() and req.inflight_middle_chunks <= 0
221+
) or req.is_retracted:
222+
# Decode req in a mixed batch, or a retracted req. Keep an
223+
# aborted middle chunk in the chunked branch long enough to
224+
# drain its accounting without streaming it.
221225
continue
222226

223227
if req.inflight_middle_chunks <= 0:
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import threading
2+
import time
3+
import unittest
4+
import uuid
5+
from typing import Any
6+
7+
import requests
8+
9+
from sglang.srt.utils import kill_process_tree
10+
from sglang.test.test_utils import (
11+
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
12+
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
13+
DEFAULT_URL_FOR_TEST,
14+
CustomTestCase,
15+
popen_launch_server,
16+
)
17+
18+
CHUNKED_PREFILL_SIZE = 64
19+
LONG_PROMPT = (
20+
"The quick brown fox jumps over the lazy dog. "
21+
"Pack my box with five dozen liquor jugs. "
22+
"Sphinx of black quartz, judge my vow. "
23+
) * 900
24+
25+
26+
def _decode_response(response: requests.Response) -> Any:
27+
try:
28+
return response.json()
29+
except ValueError:
30+
return response.text
31+
32+
33+
def _is_abort_result(status_code: int, body: Any) -> bool:
34+
if status_code == 200:
35+
reason = (
36+
body.get("meta_info", {}).get("finish_reason", {})
37+
if isinstance(body, dict)
38+
else {}
39+
)
40+
return isinstance(reason, dict) and reason.get("type") == "abort"
41+
42+
if status_code not in (500, 503):
43+
return False
44+
45+
text = body if isinstance(body, str) else str(body)
46+
return "abort" in text.lower()
47+
48+
49+
class TestChunkedPrefillAbortE2E(CustomTestCase):
50+
@classmethod
51+
def setUpClass(cls):
52+
cls.base_url = DEFAULT_URL_FOR_TEST
53+
cls.process = popen_launch_server(
54+
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
55+
cls.base_url,
56+
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
57+
other_args=[
58+
"--disable-cuda-graph",
59+
"--chunked-prefill-size",
60+
str(CHUNKED_PREFILL_SIZE),
61+
"--max-running-requests",
62+
"4",
63+
],
64+
)
65+
66+
@classmethod
67+
def tearDownClass(cls):
68+
if cls.process:
69+
kill_process_tree(cls.process.pid)
70+
71+
def test_abort_mid_chunked_prefill_by_rid(self):
72+
rid = f"chunked-prefill-abort-{uuid.uuid4().hex}"
73+
result: dict[str, Any] = {}
74+
75+
def run_generate():
76+
try:
77+
response = requests.post(
78+
self.base_url + "/generate",
79+
json={
80+
"rid": rid,
81+
"text": f"{rid}\n{LONG_PROMPT}",
82+
"sampling_params": {
83+
"temperature": 0,
84+
"max_new_tokens": 4096,
85+
"ignore_eos": True,
86+
},
87+
},
88+
timeout=180,
89+
)
90+
result["status_code"] = response.status_code
91+
result["body"] = _decode_response(response)
92+
except requests.RequestException as exc:
93+
result["exception"] = repr(exc)
94+
95+
thread = threading.Thread(target=run_generate)
96+
thread.start()
97+
98+
time.sleep(0.5)
99+
abort_deadline = time.monotonic() + 8
100+
while thread.is_alive() and time.monotonic() < abort_deadline:
101+
requests.post(
102+
self.base_url + "/abort_request",
103+
json={"rid": rid, "abort_all": False},
104+
timeout=10,
105+
)
106+
time.sleep(0.2)
107+
108+
thread.join(timeout=60)
109+
self.assertFalse(thread.is_alive(), "Chunked-prefill abort request hung")
110+
self.assertNotIn("exception", result, result.get("exception"))
111+
self.assertTrue(
112+
_is_abort_result(result["status_code"], result["body"]),
113+
f"Expected chunked-prefill request to abort, got {result}",
114+
)
115+
116+
health = requests.get(self.base_url + "/health", timeout=10)
117+
self.assertEqual(health.status_code, 200, health.text)
118+
119+
follow_up = requests.post(
120+
self.base_url + "/generate",
121+
json={
122+
"rid": f"chunked-prefill-after-abort-{uuid.uuid4().hex}",
123+
"text": "The capital of France is",
124+
"sampling_params": {"temperature": 0, "max_new_tokens": 4},
125+
},
126+
timeout=60,
127+
)
128+
follow_up_body = _decode_response(follow_up)
129+
self.assertEqual(follow_up.status_code, 200, follow_up_body)
130+
self.assertFalse(_is_abort_result(follow_up.status_code, follow_up_body))
131+
132+
133+
if __name__ == "__main__":
134+
unittest.main()
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import threading
2+
import time
3+
import unittest
4+
import uuid
5+
from typing import Any
6+
7+
import requests
8+
9+
from sglang.test.server_fixtures.disaggregation_fixture import (
10+
PDDisaggregationServerBase,
11+
)
12+
from sglang.test.test_utils import DEFAULT_SMALL_MODEL_NAME_FOR_TEST
13+
14+
PD_EXTRA_ARGS = ["--max-running-requests", "4", "--chunked-prefill-size", "64"]
15+
LONG_PROMPT = (
16+
"The quick brown fox jumps over the lazy dog. "
17+
"Pack my box with five dozen liquor jugs. "
18+
"Sphinx of black quartz, judge my vow. "
19+
) * 900
20+
21+
22+
def _decode_response(response: requests.Response) -> Any:
23+
try:
24+
return response.json()
25+
except ValueError:
26+
return response.text
27+
28+
29+
def _is_abort_result(status_code: int, body: Any) -> bool:
30+
if status_code == 200:
31+
reason = (
32+
body.get("meta_info", {}).get("finish_reason", {})
33+
if isinstance(body, dict)
34+
else {}
35+
)
36+
return isinstance(reason, dict) and reason.get("type") == "abort"
37+
38+
if status_code not in (500, 503):
39+
return False
40+
41+
text = body if isinstance(body, str) else str(body)
42+
return "abort" in text.lower()
43+
44+
45+
class TestDisaggChunkedPrefillAbort(PDDisaggregationServerBase):
46+
@classmethod
47+
def setUpClass(cls):
48+
super().setUpClass()
49+
cls.model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
50+
cls.extra_prefill_args = PD_EXTRA_ARGS
51+
cls.extra_decode_args = PD_EXTRA_ARGS
52+
cls.launch_all()
53+
54+
def _post_abort(self, rid: str):
55+
for url in (self.prefill_url, self.decode_url):
56+
requests.post(
57+
url + "/abort_request",
58+
json={"rid": rid, "abort_all": False},
59+
timeout=10,
60+
)
61+
62+
def test_abort_mid_chunked_prefill_by_rid(self):
63+
rid = f"pd-chunked-prefill-abort-{uuid.uuid4().hex}"
64+
result: dict[str, Any] = {}
65+
66+
def run_generate():
67+
try:
68+
response = requests.post(
69+
self.lb_url + "/generate",
70+
json={
71+
"rid": rid,
72+
"text": f"{rid}\n{LONG_PROMPT}",
73+
"sampling_params": {
74+
"temperature": 0,
75+
"max_new_tokens": 4096,
76+
"ignore_eos": True,
77+
},
78+
},
79+
timeout=180,
80+
)
81+
result["status_code"] = response.status_code
82+
result["body"] = _decode_response(response)
83+
except requests.RequestException as exc:
84+
result["exception"] = repr(exc)
85+
86+
thread = threading.Thread(target=run_generate)
87+
thread.start()
88+
89+
time.sleep(1.0)
90+
abort_deadline = time.monotonic() + 8
91+
while thread.is_alive() and time.monotonic() < abort_deadline:
92+
self._post_abort(rid)
93+
time.sleep(0.2)
94+
95+
thread.join(timeout=60)
96+
self.assertFalse(thread.is_alive(), "Chunked-prefill abort request hung")
97+
self.assertNotIn("exception", result, result.get("exception"))
98+
self.assertTrue(
99+
_is_abort_result(result["status_code"], result["body"]),
100+
f"Expected chunked-prefill request to abort, got {result}",
101+
)
102+
103+
for url in (self.lb_url, self.prefill_url, self.decode_url):
104+
health = requests.get(url + "/health", timeout=10)
105+
self.assertEqual(health.status_code, 200, health.text)
106+
107+
108+
if __name__ == "__main__":
109+
unittest.main()

0 commit comments

Comments
 (0)