Negotiate PrefillDelayer only after KV-budget admission checks (#31835)
This commit is contained in:
@@ -845,6 +845,17 @@ class PrefillAdder:
|
|||||||
return req
|
return req
|
||||||
_rem_tokens = self.rem_chunk_tokens
|
_rem_tokens = self.rem_chunk_tokens
|
||||||
|
|
||||||
|
# A mid-chunk rank prefills this pass regardless of the delayer
|
||||||
|
# verdict, so report prefillable=True and ignore the result.
|
||||||
|
if self.prefill_delayer_single_pass is not None:
|
||||||
|
self.prefill_delayer_single_pass.negotiate_should_allow_prefill(
|
||||||
|
local_prefillable=True,
|
||||||
|
running_batch=self.running_batch.batch_size(),
|
||||||
|
max_prefill_bs=self.max_prefill_bs,
|
||||||
|
max_running_requests=self.max_running_requests,
|
||||||
|
waiting_queue_len=self.waiting_queue_len,
|
||||||
|
)
|
||||||
|
|
||||||
cand_extend_input_len = len(req.full_untruncated_fill_ids) - len(
|
cand_extend_input_len = len(req.full_untruncated_fill_ids) - len(
|
||||||
req.prefix_indices
|
req.prefix_indices
|
||||||
)
|
)
|
||||||
@@ -1001,16 +1012,6 @@ class PrefillAdder:
|
|||||||
def add_one_req(
|
def add_one_req(
|
||||||
self, req: Req, has_chunked_req: bool, truncation_align_size: Optional[int]
|
self, req: Req, has_chunked_req: bool, truncation_align_size: Optional[int]
|
||||||
):
|
):
|
||||||
if (self.prefill_delayer_single_pass is not None) and (
|
|
||||||
not self.prefill_delayer_single_pass.negotiate_should_allow_prefill(
|
|
||||||
local_prefillable=True,
|
|
||||||
running_batch=self.running_batch.batch_size(),
|
|
||||||
max_prefill_bs=self.max_prefill_bs,
|
|
||||||
max_running_requests=self.max_running_requests,
|
|
||||||
waiting_queue_len=self.waiting_queue_len,
|
|
||||||
)
|
|
||||||
):
|
|
||||||
return AddReqResult.OTHER
|
|
||||||
# TODO support cp with multiple requests
|
# TODO support cp with multiple requests
|
||||||
# Enabling context parallelism currently presents precision issues;
|
# Enabling context parallelism currently presents precision issues;
|
||||||
# therefore, the prefill-batch setting is temporarily set to 1.
|
# therefore, the prefill-batch setting is temporarily set to 1.
|
||||||
@@ -1086,6 +1087,20 @@ class PrefillAdder:
|
|||||||
return AddReqResult.NO_TOKEN
|
return AddReqResult.NO_TOKEN
|
||||||
chunk_tokens_limit = min(self.rem_chunk_tokens, swa_cap)
|
chunk_tokens_limit = min(self.rem_chunk_tokens, swa_cap)
|
||||||
|
|
||||||
|
# Negotiate only after every KV-budget gate (a NO_TOKEN rank must
|
||||||
|
# report not-prefillable via finalize()) and before init_load_back
|
||||||
|
# (a delay verdict must not start KV load-back).
|
||||||
|
if (self.prefill_delayer_single_pass is not None) and (
|
||||||
|
not self.prefill_delayer_single_pass.negotiate_should_allow_prefill(
|
||||||
|
local_prefillable=True,
|
||||||
|
running_batch=self.running_batch.batch_size(),
|
||||||
|
max_prefill_bs=self.max_prefill_bs,
|
||||||
|
max_running_requests=self.max_running_requests,
|
||||||
|
waiting_queue_len=self.waiting_queue_len,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return AddReqResult.OTHER
|
||||||
|
|
||||||
if req.needs_host_load_back():
|
if req.needs_host_load_back():
|
||||||
new_indices, req.last_node = self.tree_cache.init_load_back(
|
new_indices, req.last_node = self.tree_cache.init_load_back(
|
||||||
InitLoadBackParams(
|
InitLoadBackParams(
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ register_amd_ci(est_time=2, suite="stage-b-test-1-gpu-small-amd")
|
|||||||
register_cpu_ci(est_time=8, suite="base-c-test-cpu")
|
register_cpu_ci(est_time=8, suite="base-c-test-cpu")
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingDelayer:
|
||||||
|
"""Duck-typed stand-in for PrefillDelayerSinglePassExecutor that records
|
||||||
|
the local_prefillable value of every negotiate call and returns a fixed
|
||||||
|
verdict."""
|
||||||
|
|
||||||
|
def __init__(self, allow: bool):
|
||||||
|
self.allow = allow
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def negotiate_should_allow_prefill(self, local_prefillable, **kwargs):
|
||||||
|
self.calls.append(local_prefillable)
|
||||||
|
return self.allow
|
||||||
|
|
||||||
|
|
||||||
class TestPrefillAdder(CustomTestCase):
|
class TestPrefillAdder(CustomTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
set_global_server_args_for_scheduler(ServerArgs(model_path="dummy"))
|
set_global_server_args_for_scheduler(ServerArgs(model_path="dummy"))
|
||||||
@@ -537,6 +551,131 @@ class TestPrefillAdder(CustomTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(adder._swa_budget_for_req(extend), expected)
|
self.assertEqual(adder._swa_budget_for_req(extend), expected)
|
||||||
|
|
||||||
|
def test_delayer_not_consulted_when_kv_budget_rejects(self):
|
||||||
|
"""A rank whose first candidate fails the KV-budget gate must NOT
|
||||||
|
report local_prefillable=True: add_one_req returns NO_TOKEN before
|
||||||
|
negotiating, and finalize() later reports the rank as not
|
||||||
|
prefillable. Regression guard: the negotiate used to run at the top
|
||||||
|
of add_one_req, so under KV pressure a full rank still claimed
|
||||||
|
prefillable=True, the delayer saw "all prefillable" and allowed, the
|
||||||
|
full ranks then NO_TOKEN'ed out, and the DP-synced forward mixed
|
||||||
|
prefill and decode — the exact pattern the delayer exists to
|
||||||
|
prevent."""
|
||||||
|
delayer = _RecordingDelayer(allow=True)
|
||||||
|
adder = self._create_delayer_adder(available_tokens=10, delayer=delayer)
|
||||||
|
|
||||||
|
result = adder.add_one_req(
|
||||||
|
self._create_delayer_req(50),
|
||||||
|
has_chunked_req=False,
|
||||||
|
truncation_align_size=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, AddReqResult.NO_TOKEN)
|
||||||
|
self.assertEqual(delayer.calls, [])
|
||||||
|
self.assertEqual(adder.can_run_list, [])
|
||||||
|
|
||||||
|
def test_delayer_not_consulted_when_post_lock_recheck_rejects(self):
|
||||||
|
"""Locking the request's own prefix converts evictable tokens into
|
||||||
|
protected ones, so a request can pass the pre-lock KV gate yet fail
|
||||||
|
the post-lock recheck — precisely the high-utilization regime the
|
||||||
|
delayer targets. The negotiate must sit after that recheck too, or
|
||||||
|
the rank claims prefillable=True and then runs decode."""
|
||||||
|
delayer = _RecordingDelayer(allow=True)
|
||||||
|
adder = self._create_delayer_adder(available_tokens=10, delayer=delayer)
|
||||||
|
# Pre-lock budget is covered by evictable tokens; inc_lock_ref pins
|
||||||
|
# them (evictable -> protected), shrinking the budget below demand.
|
||||||
|
self.mock_tree_cache.evictable_size.return_value = 1000
|
||||||
|
self.mock_tree_cache.full_evictable_size.return_value = 1000
|
||||||
|
|
||||||
|
def _pin_prefix(node):
|
||||||
|
self.mock_tree_cache.evictable_size.return_value = 0
|
||||||
|
self.mock_tree_cache.full_evictable_size.return_value = 0
|
||||||
|
return IncLockRefResult()
|
||||||
|
|
||||||
|
self.mock_tree_cache.inc_lock_ref.side_effect = _pin_prefix
|
||||||
|
|
||||||
|
result = adder.add_one_req(
|
||||||
|
self._create_delayer_req(50),
|
||||||
|
has_chunked_req=False,
|
||||||
|
truncation_align_size=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, AddReqResult.NO_TOKEN)
|
||||||
|
self.assertEqual(delayer.calls, [])
|
||||||
|
self.assertEqual(adder.can_run_list, [])
|
||||||
|
|
||||||
|
def test_delay_verdict_blocks_admissible_request(self):
|
||||||
|
"""An admissible request must still be gated by the (relocated)
|
||||||
|
negotiate: on a delay verdict it is not admitted, and the rank
|
||||||
|
reported prefillable=True exactly once."""
|
||||||
|
delayer = _RecordingDelayer(allow=False)
|
||||||
|
adder = self._create_delayer_adder(available_tokens=100_000, delayer=delayer)
|
||||||
|
|
||||||
|
result = adder.add_one_req(
|
||||||
|
self._create_delayer_req(50),
|
||||||
|
has_chunked_req=False,
|
||||||
|
truncation_align_size=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, AddReqResult.OTHER)
|
||||||
|
self.assertEqual(delayer.calls, [True])
|
||||||
|
self.assertEqual(adder.can_run_list, [])
|
||||||
|
|
||||||
|
def test_allow_verdict_admits_request(self):
|
||||||
|
"""On an allow verdict the request proceeds through admission — the
|
||||||
|
relocated negotiate must not block the commit path."""
|
||||||
|
delayer = _RecordingDelayer(allow=True)
|
||||||
|
adder = self._create_delayer_adder(available_tokens=100_000, delayer=delayer)
|
||||||
|
req = self._create_delayer_req(50)
|
||||||
|
|
||||||
|
result = adder.add_one_req(
|
||||||
|
req, has_chunked_req=False, truncation_align_size=None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, AddReqResult.CONTINUE)
|
||||||
|
self.assertEqual(delayer.calls, [True])
|
||||||
|
self.assertIn(req, adder.can_run_list)
|
||||||
|
|
||||||
|
def test_chunked_req_negotiates_prefillable_and_proceeds(self):
|
||||||
|
"""A rank resuming a chunked prefill runs it this pass regardless of
|
||||||
|
the verdict, so add_chunked_req must report prefillable=True (else a
|
||||||
|
rank with an empty waiting queue reports False via finalize() and
|
||||||
|
peers delay while it prefills alone) and must not drop the chunk on
|
||||||
|
a delay verdict (that would leak memory)."""
|
||||||
|
delayer = _RecordingDelayer(allow=False)
|
||||||
|
adder = self._create_delayer_adder(
|
||||||
|
available_tokens=100_000, delayer=delayer, rem_chunk_tokens=500
|
||||||
|
)
|
||||||
|
req = self._create_delayer_req(200)
|
||||||
|
|
||||||
|
result = adder.add_chunked_req(req)
|
||||||
|
|
||||||
|
self.assertIsNone(result) # chunk fully admitted, not truncated
|
||||||
|
self.assertEqual(delayer.calls, [True])
|
||||||
|
self.assertIn(req, adder.can_run_list)
|
||||||
|
|
||||||
|
def _create_delayer_adder(self, *, available_tokens, delayer, **kwargs):
|
||||||
|
self.mock_token_allocator.available_size.return_value = available_tokens
|
||||||
|
self.mock_token_allocator.full_available_size.return_value = available_tokens
|
||||||
|
return self.create_adder(
|
||||||
|
self.create_running_batch(),
|
||||||
|
prefill_delayer_single_pass=delayer,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_delayer_req(self, num_tokens: int):
|
||||||
|
req = self.create_mock_req("delayer_req", priority=0, max_new_tokens=8)
|
||||||
|
req.full_untruncated_fill_ids = list(range(num_tokens))
|
||||||
|
req.host_hit_length = 0
|
||||||
|
req.last_node = MagicMock()
|
||||||
|
req.sampling_params.ignore_eos = False
|
||||||
|
req.set_extend_range = MagicMock(
|
||||||
|
side_effect=lambda start, end: setattr(
|
||||||
|
req, "extend_range", Range(start, end)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return req
|
||||||
|
|
||||||
def test_add_chunked_req_non_hybrid_no_swa_reservation(self):
|
def test_add_chunked_req_non_hybrid_no_swa_reservation(self):
|
||||||
# Non-hybrid path: the SWA-pool reservation must NOT apply, otherwise
|
# Non-hybrid path: the SWA-pool reservation must NOT apply, otherwise
|
||||||
# the fix would regress non-SWA models.
|
# the fix would regress non-SWA models.
|
||||||
|
|||||||
Reference in New Issue
Block a user