Abort during chunked prefill + PD peer-liveness abort (#28086)

This commit is contained in:
cctry
2026-06-17 23:13:28 -07:00
committed by GitHub
parent d2539980b6
commit 7976928c57
8 changed files with 763 additions and 3 deletions
@@ -406,16 +406,55 @@ class SchedulerDisaggregationPrefillMixin:
if room is not None and room in kv_mgr.transfer_infos: if room is not None and room in kv_mgr.transfer_infos:
prefetch(room) prefetch(room)
def resolve_waiting_queue_bootstrap(self: Scheduler) -> None:
"""Resolve bootstrap status for waiting prefill requests before admission.
Covers the window between leaving the bootstrap queue and being admitted
into a running batch: aborts requests whose decode peer died, and
finalizes optimistic requests whose bootstrap completed so they skip
the post-forward bootstrap check.
"""
candidates = [req for req in self.waiting_queue if not is_aborted(req)]
if not candidates:
return
polls = poll_and_all_reduce_attn_cp_tp_group(
[req.disagg_kv_sender for req in candidates],
self.attn_cp_cpu_group,
self.attn_tp_cpu_group,
)
failed = set()
for req, poll in zip(candidates, polls):
if poll == KVPoll.Failed:
self.handle_bootstrap_failure(req)
failed.add(req)
elif (
poll == KVPoll.WaitingForInput
and req.pending_bootstrap
and not should_force_retry(req)
):
# Optimistic requests reserved a metadata buffer when popped, so
# finalize cannot fail here; if it ever does, the request stays
# pending and the post-forward check resolves it.
self.disagg_prefill_bootstrap_queue.finalize_bootstrap(req)
if failed:
self.waiting_queue = [
req for req in self.waiting_queue if req not in failed
]
@scheduler_nvtx_method("scheduler.get_next_batch_to_run") @scheduler_nvtx_method("scheduler.get_next_batch_to_run")
def get_next_disagg_prefill_batch_to_run( def get_next_disagg_prefill_batch_to_run(
self: Scheduler, self: Scheduler,
) -> Optional[ScheduleBatch]: ) -> Optional[ScheduleBatch]:
self.process_pending_chunked_abort()
# HACK (byronhsu): reset the batch_is_full flag because we never enter update_running_batch which resets it # HACK (byronhsu): reset the batch_is_full flag because we never enter update_running_batch which resets it
# Otherwise, it hangs under high concurrency # Otherwise, it hangs under high concurrency
self.running_batch.batch_is_full = False self.running_batch.batch_is_full = False
self.process_prefill_chunk() self.process_prefill_chunk()
self.resolve_waiting_queue_bootstrap()
batch = self.get_new_batch_prefill() batch = self.get_new_batch_prefill()
batch = self.dp_attn_adapter.maybe_prepare_mlp_sync_batch(batch) batch = self.dp_attn_adapter.maybe_prepare_mlp_sync_batch(batch)
+53
View File
@@ -985,6 +985,7 @@ class Scheduler(
elif self.chunked_prefill_size is not None and self.chunked_prefill_size <= 0: elif self.chunked_prefill_size is not None and self.chunked_prefill_size <= 0:
self.chunked_prefill_size = None self.chunked_prefill_size = None
self.chunked_req = None self.chunked_req = None
self._pending_chunked_abort_req = None
self.is_mixed_chunk = ( self.is_mixed_chunk = (
self.chunked_prefill_size is not None self.chunked_prefill_size is not None
and self.server_args.enable_mixed_chunk and self.server_args.enable_mixed_chunk
@@ -2424,6 +2425,52 @@ class Scheduler(
def stash_chunked_request(self, req: Req): def stash_chunked_request(self, req: Req):
maybe_cache_unfinished_req(req, self.tree_cache, chunked=True) maybe_cache_unfinished_req(req, self.tree_cache, chunked=True)
def process_pending_chunked_abort(self) -> None:
"""Abort an in-flight chunked-prefill request once it is safe to do so.
``abort_request`` only records the target in ``_pending_chunked_abort_req``
(tearing it down mid-iteration is unsafe). Clearing ``chunked_req`` here at
the top of the scheduling step stops the next chunk from launching; the
chunk already launched is drained when its result is resolved. Under overlap
the result lands a step later, so the batch-result processors keep
``inflight_middle_chunks`` accounting intact and skip the aborted chunk:
``process_batch_result_disagg_prefill`` via its ``is_aborted`` drop, and
``process_batch_result_prefill`` via its chunked branch (the finished req
is excluded from streaming and its logprob offset is still accounted).
Mirrors ``handle_bootstrap_failure``.
"""
req = self._pending_chunked_abort_req
if req is None:
return
if self.chunked_req is not req:
# Already past chunked prefill; the running-batch abort path handles
# it. Drop the marker once the request is actually gone.
if req.finished() or req.req_pool_idx is None:
self._pending_chunked_abort_req = None
return
prepare_abort(req, "Aborted")
req.time_stats.trace_ctx.abort(abort_info={"reason": "Aborted"})
req.to_finish = None
if self.disaggregation_mode == DisaggregationMode.PREFILL:
req.disagg_kv_sender.abort()
maybe_release_metadata_buffer(
req, self.req_to_metadata_buffer_idx_allocator
)
req.pending_bootstrap = False
if self.enable_hicache_storage:
self.tree_cache.release_aborted_request(req.rid)
if (
req.req_pool_idx is not None or self.tree_cache.supports_mamba()
) and not req.kv_committed_freed:
release_kv_cache(req, self.tree_cache, is_insert=False)
self.chunked_req = None
self._chunked_req_scheduled_last_iter = False
self._pending_chunked_abort_req = None
self.ipc_channels.send_to_tokenizer.send_output(AbortReq(rid=req.rid), req)
logger.debug(f"Abort chunked prefill request. {req.rid=}")
def _build_hisparse_decode_batch(self, reqs): def _build_hisparse_decode_batch(self, reqs):
"""Build a ScheduleBatch for hisparse requests transitioning from staging to decode.""" """Build a ScheduleBatch for hisparse requests transitioning from staging to decode."""
device = self.device device = self.device
@@ -2467,6 +2514,8 @@ class Scheduler(
@scheduler_nvtx_method("scheduler.get_next_batch_to_run") @scheduler_nvtx_method("scheduler.get_next_batch_to_run")
def get_next_batch_to_run(self) -> Optional[ScheduleBatch]: def get_next_batch_to_run(self) -> Optional[ScheduleBatch]:
self.process_pending_chunked_abort()
if self.enable_fpm: if self.enable_fpm:
self._fpm_batch_t0 = time.monotonic() self._fpm_batch_t0 = time.monotonic()
self._abort_on_waiting_timeout() self._abort_on_waiting_timeout()
@@ -3684,6 +3733,10 @@ class Scheduler(
return RpcReqOutput(success, "" if not exec else str(exec)) return RpcReqOutput(success, "" if not exec else str(exec))
def abort_request(self, recv_req: AbortReq): def abort_request(self, recv_req: AbortReq):
if (chunked_req := self.chunked_req) is not None:
if recv_req.abort_all or chunked_req.rid.startswith(recv_req.rid):
self._pending_chunked_abort_req = chunked_req
# todo hisparse, release resources for abort requests in hisparse coordinator # todo hisparse, release resources for abort requests in hisparse coordinator
# Delete requests in the waiting queue # Delete requests in the waiting queue
to_del = [] to_del = []
@@ -216,8 +216,12 @@ class SchedulerBatchResultProcessor:
logprob_pt = 0 logprob_pt = 0
for i, (req, next_token_id) in enumerate(zip(batch.reqs, next_token_ids)): for i, (req, next_token_id) in enumerate(zip(batch.reqs, next_token_ids)):
if req.finished() or req.is_retracted: if (
# decode req in mixed batch or retracted req req.finished() and req.inflight_middle_chunks <= 0
) or req.is_retracted:
# Decode req in a mixed batch, or a retracted req. Keep an
# aborted middle chunk in the chunked branch long enough to
# drain its accounting without streaming it.
continue continue
if req.inflight_middle_chunks <= 0: if req.inflight_middle_chunks <= 0:
@@ -0,0 +1,134 @@
import threading
import time
import unittest
import uuid
from typing import Any
import requests
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
CHUNKED_PREFILL_SIZE = 64
LONG_PROMPT = (
"The quick brown fox jumps over the lazy dog. "
"Pack my box with five dozen liquor jugs. "
"Sphinx of black quartz, judge my vow. "
) * 900
def _decode_response(response: requests.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
def _is_abort_result(status_code: int, body: Any) -> bool:
if status_code == 200:
reason = (
body.get("meta_info", {}).get("finish_reason", {})
if isinstance(body, dict)
else {}
)
return isinstance(reason, dict) and reason.get("type") == "abort"
if status_code not in (500, 503):
return False
text = body if isinstance(body, str) else str(body)
return "abort" in text.lower()
class TestChunkedPrefillAbortE2E(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=[
"--disable-cuda-graph",
"--chunked-prefill-size",
str(CHUNKED_PREFILL_SIZE),
"--max-running-requests",
"4",
],
)
@classmethod
def tearDownClass(cls):
if cls.process:
kill_process_tree(cls.process.pid)
def test_abort_mid_chunked_prefill_by_rid(self):
rid = f"chunked-prefill-abort-{uuid.uuid4().hex}"
result: dict[str, Any] = {}
def run_generate():
try:
response = requests.post(
self.base_url + "/generate",
json={
"rid": rid,
"text": f"{rid}\n{LONG_PROMPT}",
"sampling_params": {
"temperature": 0,
"max_new_tokens": 4096,
"ignore_eos": True,
},
},
timeout=180,
)
result["status_code"] = response.status_code
result["body"] = _decode_response(response)
except requests.RequestException as exc:
result["exception"] = repr(exc)
thread = threading.Thread(target=run_generate)
thread.start()
time.sleep(0.5)
abort_deadline = time.monotonic() + 8
while thread.is_alive() and time.monotonic() < abort_deadline:
requests.post(
self.base_url + "/abort_request",
json={"rid": rid, "abort_all": False},
timeout=10,
)
time.sleep(0.2)
thread.join(timeout=60)
self.assertFalse(thread.is_alive(), "Chunked-prefill abort request hung")
self.assertNotIn("exception", result, result.get("exception"))
self.assertTrue(
_is_abort_result(result["status_code"], result["body"]),
f"Expected chunked-prefill request to abort, got {result}",
)
health = requests.get(self.base_url + "/health", timeout=10)
self.assertEqual(health.status_code, 200, health.text)
follow_up = requests.post(
self.base_url + "/generate",
json={
"rid": f"chunked-prefill-after-abort-{uuid.uuid4().hex}",
"text": "The capital of France is",
"sampling_params": {"temperature": 0, "max_new_tokens": 4},
},
timeout=60,
)
follow_up_body = _decode_response(follow_up)
self.assertEqual(follow_up.status_code, 200, follow_up_body)
self.assertFalse(_is_abort_result(follow_up.status_code, follow_up_body))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,109 @@
import threading
import time
import unittest
import uuid
from typing import Any
import requests
from sglang.test.server_fixtures.disaggregation_fixture import (
PDDisaggregationServerBase,
)
from sglang.test.test_utils import DEFAULT_SMALL_MODEL_NAME_FOR_TEST
PD_EXTRA_ARGS = ["--max-running-requests", "4", "--chunked-prefill-size", "64"]
LONG_PROMPT = (
"The quick brown fox jumps over the lazy dog. "
"Pack my box with five dozen liquor jugs. "
"Sphinx of black quartz, judge my vow. "
) * 900
def _decode_response(response: requests.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
def _is_abort_result(status_code: int, body: Any) -> bool:
if status_code == 200:
reason = (
body.get("meta_info", {}).get("finish_reason", {})
if isinstance(body, dict)
else {}
)
return isinstance(reason, dict) and reason.get("type") == "abort"
if status_code not in (500, 503):
return False
text = body if isinstance(body, str) else str(body)
return "abort" in text.lower()
class TestDisaggChunkedPrefillAbort(PDDisaggregationServerBase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
cls.extra_prefill_args = PD_EXTRA_ARGS
cls.extra_decode_args = PD_EXTRA_ARGS
cls.launch_all()
def _post_abort(self, rid: str):
for url in (self.prefill_url, self.decode_url):
requests.post(
url + "/abort_request",
json={"rid": rid, "abort_all": False},
timeout=10,
)
def test_abort_mid_chunked_prefill_by_rid(self):
rid = f"pd-chunked-prefill-abort-{uuid.uuid4().hex}"
result: dict[str, Any] = {}
def run_generate():
try:
response = requests.post(
self.lb_url + "/generate",
json={
"rid": rid,
"text": f"{rid}\n{LONG_PROMPT}",
"sampling_params": {
"temperature": 0,
"max_new_tokens": 4096,
"ignore_eos": True,
},
},
timeout=180,
)
result["status_code"] = response.status_code
result["body"] = _decode_response(response)
except requests.RequestException as exc:
result["exception"] = repr(exc)
thread = threading.Thread(target=run_generate)
thread.start()
time.sleep(1.0)
abort_deadline = time.monotonic() + 8
while thread.is_alive() and time.monotonic() < abort_deadline:
self._post_abort(rid)
time.sleep(0.2)
thread.join(timeout=60)
self.assertFalse(thread.is_alive(), "Chunked-prefill abort request hung")
self.assertNotIn("exception", result, result.get("exception"))
self.assertTrue(
_is_abort_result(result["status_code"], result["body"]),
f"Expected chunked-prefill request to abort, got {result}",
)
for url in (self.lb_url, self.prefill_url, self.decode_url):
health = requests.get(url + "/health", timeout=10)
self.assertEqual(health.status_code, 200, health.text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,316 @@
import json
import threading
import time
import unittest
import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
from typing import Any
import requests
from sglang.srt.environ import envs
from sglang.test.server_fixtures.disaggregation_fixture import (
PDDisaggregationServerBase,
)
from sglang.test.test_utils import DEFAULT_MODEL_NAME_FOR_TEST
FAILURE_PROB = 0.12
PD_EXTRA_ARGS = [
"--max-running-requests",
"4",
"--chunked-prefill-size",
"128",
]
PD_LONG_PROMPT = (
"The quick brown fox jumps over the lazy dog. "
"Pack my box with five dozen liquor jugs. "
"Sphinx of black quartz, judge my vow. "
) * 240
def _body_text(body: Any) -> str:
if isinstance(body, str):
return body
return json.dumps(body, sort_keys=True)
def _decode_response(response: requests.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
def _finish_reason(body: Any) -> dict[str, Any]:
if not isinstance(body, dict):
return {}
reason = body.get("meta_info", {}).get("finish_reason", {})
return reason if isinstance(reason, dict) else {}
def _is_abort_result(status_code: int, body: Any) -> bool:
if status_code == 200:
return _finish_reason(body).get("type") == "abort"
if status_code not in (500, 503):
return False
text = _body_text(body).lower()
return any(
marker in text
for marker in (
"pd peer failed",
"prefill bootstrap failed",
"abort",
"aborted",
)
)
def _is_success_result(status_code: int, body: Any) -> bool:
return (
status_code == 200
and isinstance(body, dict)
and "text" in body
and _finish_reason(body).get("type") != "abort"
)
class TestDisaggregationPeerLivenessAbort(PDDisaggregationServerBase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._disagg_failure_ctx = envs.SGLANG_TEST_DISAGG_FAILURE_PROB.override(
FAILURE_PROB
)
cls._disagg_failure_ctx.__enter__()
cls.model = DEFAULT_MODEL_NAME_FOR_TEST
cls.extra_prefill_args = PD_EXTRA_ARGS
cls.extra_decode_args = PD_EXTRA_ARGS
cls.launch_all()
@classmethod
def tearDownClass(cls):
try:
super().tearDownClass()
finally:
if cls._disagg_failure_ctx:
cls._disagg_failure_ctx.__exit__(None, None, None)
def _post_generate(self, rid: str, max_new_tokens: int = 32) -> dict[str, Any]:
response = requests.post(
self.lb_url + "/generate",
json={
"rid": rid,
"text": f"{rid}\n{PD_LONG_PROMPT}",
"sampling_params": {
"temperature": 0,
"max_new_tokens": max_new_tokens,
"ignore_eos": True,
},
},
timeout=75,
)
return {
"rid": rid,
"status_code": response.status_code,
"body": _decode_response(response),
}
def _assert_servers_healthy(self):
for url in (self.lb_url, self.prefill_url, self.decode_url):
response = requests.get(url + "/health", timeout=10)
self.assertEqual(response.status_code, 200, response.text)
def _load_count(self, url: str) -> int:
response = requests.get(
url + "/v1/loads?include=core,disagg,queues",
timeout=10,
)
response.raise_for_status()
total = 0
for load in response.json()["loads"]:
total += int(load.get("num_running_reqs", 0))
total += int(load.get("num_waiting_reqs", 0))
disagg = load.get("disaggregation", {})
total += int(disagg.get("prefill_bootstrap_queue_reqs", 0))
total += int(disagg.get("prefill_inflight_queue_reqs", 0))
total += int(disagg.get("decode_prealloc_queue_reqs", 0))
total += int(disagg.get("decode_transfer_queue_reqs", 0))
total += int(disagg.get("decode_retracted_queue_reqs", 0))
queues = load.get("queues", {})
total += int(queues.get("waiting", 0))
total += int(queues.get("grammar", 0))
total += int(queues.get("paused", 0))
total += int(queues.get("retracted", 0))
return total
def _assert_eventually_idle(self):
deadline = time.monotonic() + 30
last_counts = {}
while time.monotonic() < deadline:
last_counts = {
self.prefill_url: self._load_count(self.prefill_url),
self.decode_url: self._load_count(self.decode_url),
}
if all(count == 0 for count in last_counts.values()):
return
time.sleep(1)
self.fail(f"PD servers still have request load after cleanup: {last_counts}")
def _assert_one_successful_generate_with_retries(self):
last_result = None
for _ in range(20):
rid = f"pd-peer-liveness-health-{uuid.uuid4().hex}"
last_result = self._post_generate(rid, max_new_tokens=1)
if _is_success_result(last_result["status_code"], last_result["body"]):
return
time.sleep(0.2)
self.fail(
"Server stayed HTTP-healthy but did not complete a follow-up generate "
f"under failure injection. Last result: {last_result}"
)
def test_peer_failures_abort_without_hanging(self):
num_requests = 36
start = time.monotonic()
results: list[dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=18) as executor:
futures = [
executor.submit(
self._post_generate,
f"pd-peer-liveness-{i}-{uuid.uuid4().hex}",
)
for i in range(num_requests)
]
try:
for future in as_completed(futures, timeout=120):
results.append(future.result())
except TimeoutError:
unfinished = sum(not future.done() for future in futures)
self.fail(f"{unfinished} generate requests did not return in time")
elapsed = time.monotonic() - start
self.assertLess(elapsed, 120)
self.assertEqual(len(results), num_requests)
aborts = [
result
for result in results
if _is_abort_result(result["status_code"], result["body"])
]
successes = [
result
for result in results
if _is_success_result(result["status_code"], result["body"])
]
unexpected = [
result
for result in results
if result not in aborts and result not in successes
]
self.assertFalse(
unexpected,
"Expected only clean aborts or successful completions. "
f"Unexpected results: {unexpected[:3]}",
)
self.assertGreater(
len(aborts),
0,
"Injected KVPoll.Failed should abort at least one request",
)
self._assert_servers_healthy()
self._assert_eventually_idle()
self._assert_one_successful_generate_with_retries()
class TestDisaggregationPeerLivenessCleanRecovery(PDDisaggregationServerBase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.model = DEFAULT_MODEL_NAME_FOR_TEST
cls.extra_prefill_args = PD_EXTRA_ARGS
cls.extra_decode_args = PD_EXTRA_ARGS
cls.launch_all()
def _post_abort_to_engines(self, rid: str):
for url in (self.prefill_url, self.decode_url):
response = requests.post(
url + "/abort_request",
json={"rid": rid, "abort_all": False},
timeout=10,
)
self.assertEqual(response.status_code, 200, response.text)
def _assert_servers_healthy(self):
for url in (self.lb_url, self.prefill_url, self.decode_url):
response = requests.get(url + "/health", timeout=10)
self.assertEqual(response.status_code, 200, response.text)
def test_clean_generate_and_explicit_abort_by_rid(self):
clean_response = requests.post(
self.lb_url + "/generate",
json={
"rid": f"pd-clean-generate-{uuid.uuid4().hex}",
"text": "The capital of France is",
"sampling_params": {"temperature": 0, "max_new_tokens": 4},
},
timeout=60,
)
clean_body = _decode_response(clean_response)
self.assertTrue(
_is_success_result(clean_response.status_code, clean_body), clean_body
)
rid = f"pd-explicit-abort-{uuid.uuid4().hex}"
result: dict[str, Any] = {}
def run_generate():
try:
response = requests.post(
self.lb_url + "/generate",
json={
"rid": rid,
"text": f"{rid}\n{PD_LONG_PROMPT}",
"sampling_params": {
"temperature": 0,
"max_new_tokens": 4096,
"ignore_eos": True,
},
},
timeout=180,
)
result["status_code"] = response.status_code
result["body"] = _decode_response(response)
except requests.RequestException as exc:
result["exception"] = repr(exc)
thread = threading.Thread(target=run_generate)
thread.start()
time.sleep(0.25)
abort_deadline = time.monotonic() + 8
while thread.is_alive() and time.monotonic() < abort_deadline:
self._post_abort_to_engines(rid)
time.sleep(0.25)
thread.join(timeout=60)
self.assertFalse(thread.is_alive(), "Explicitly aborted PD request hung")
self.assertNotIn("exception", result, result.get("exception"))
self.assertTrue(
_is_abort_result(result["status_code"], result["body"]),
f"Expected explicit abort result, got {result}",
)
self._assert_servers_healthy()
if __name__ == "__main__":
unittest.main()
@@ -1,8 +1,12 @@
import asyncio import asyncio
import json import json
import os import os
import threading
import time
import unittest import unittest
import uuid
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any
import aiohttp import aiohttp
import openai import openai
@@ -19,10 +23,11 @@ from sglang.test.server_fixtures.disaggregation_fixture import (
from sglang.test.test_utils import ( from sglang.test.test_utils import (
DEFAULT_DRAFT_MODEL_EAGLE3, DEFAULT_DRAFT_MODEL_EAGLE3,
DEFAULT_MODEL_NAME_FOR_TEST, DEFAULT_MODEL_NAME_FOR_TEST,
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
DEFAULT_TARGET_MODEL_EAGLE3, DEFAULT_TARGET_MODEL_EAGLE3,
) )
register_cuda_ci(est_time=560, stage="base-b", runner_config="2-gpu-large") register_cuda_ci(est_time=700, stage="base-b", runner_config="2-gpu-large")
class TestDisaggregationAccuracy(PauseResumeInPlaceMixin, PDDisaggregationServerBase): class TestDisaggregationAccuracy(PauseResumeInPlaceMixin, PDDisaggregationServerBase):
@@ -441,5 +446,104 @@ class TestDisaggregationPauseResumePrefillLeak(PDDisaggregationServerBase):
) )
PD_CHUNKED_ABORT_EXTRA_ARGS = [
"--max-running-requests",
"4",
"--chunked-prefill-size",
"64",
]
_CHUNKED_ABORT_LONG_PROMPT = (
"The quick brown fox jumps over the lazy dog. "
"Pack my box with five dozen liquor jugs. "
"Sphinx of black quartz, judge my vow. "
) * 900
def _decode_response(response: requests.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
def _is_abort_result(status_code: int, body: Any) -> bool:
if status_code == 200:
reason = (
body.get("meta_info", {}).get("finish_reason", {})
if isinstance(body, dict)
else {}
)
return isinstance(reason, dict) and reason.get("type") == "abort"
if status_code not in (500, 503):
return False
text = body if isinstance(body, str) else str(body)
return "abort" in text.lower()
class TestDisaggChunkedPrefillAbort(PDDisaggregationServerBase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
cls.extra_prefill_args = PD_CHUNKED_ABORT_EXTRA_ARGS
cls.extra_decode_args = PD_CHUNKED_ABORT_EXTRA_ARGS
cls.launch_all()
def _post_abort(self, rid: str):
for url in (self.prefill_url, self.decode_url):
requests.post(
url + "/abort_request",
json={"rid": rid, "abort_all": False},
timeout=10,
)
def test_abort_mid_chunked_prefill_by_rid(self):
rid = f"pd-chunked-prefill-abort-{uuid.uuid4().hex}"
result: dict[str, Any] = {}
def run_generate():
try:
response = requests.post(
self.lb_url + "/generate",
json={
"rid": rid,
"text": f"{rid}\n{_CHUNKED_ABORT_LONG_PROMPT}",
"sampling_params": {
"temperature": 0,
"max_new_tokens": 4096,
"ignore_eos": True,
},
},
timeout=180,
)
result["status_code"] = response.status_code
result["body"] = _decode_response(response)
except requests.RequestException as exc:
result["exception"] = repr(exc)
thread = threading.Thread(target=run_generate)
thread.start()
time.sleep(1.0)
abort_deadline = time.monotonic() + 8
while thread.is_alive() and time.monotonic() < abort_deadline:
self._post_abort(rid)
time.sleep(0.2)
thread.join(timeout=60)
self.assertFalse(thread.is_alive(), "Chunked-prefill abort request hung")
self.assertNotIn("exception", result, result.get("exception"))
self.assertTrue(
_is_abort_result(result["status_code"], result["body"]),
f"Expected chunked-prefill request to abort, got {result}",
)
for url in (self.lb_url, self.prefill_url, self.decode_url):
health = requests.get(url + "/health", timeout=10)
self.assertEqual(health.status_code, 200, health.text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -99,6 +99,7 @@ def _scheduler_for_get_next_batch(*, tree_cache, chunked_req) -> Scheduler:
s.update_running_batch = MagicMock(side_effect=lambda batch: batch) s.update_running_batch = MagicMock(side_effect=lambda batch: batch)
s.tree_cache = tree_cache s.tree_cache = tree_cache
s.chunked_req = chunked_req s.chunked_req = chunked_req
s._pending_chunked_abort_req = None
return s return s