Negotiate PrefillDelayer only after KV-budget admission checks (#31835)

This commit is contained in:
Hanming Lu
2026-07-21 12:07:30 -07:00
committed by GitHub
parent 927979e127
commit d03c8cee80
2 changed files with 164 additions and 10 deletions
+25 -10
View File
@@ -845,6 +845,17 @@ class PrefillAdder:
return req
_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(
req.prefix_indices
)
@@ -1001,16 +1012,6 @@ class PrefillAdder:
def add_one_req(
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
# Enabling context parallelism currently presents precision issues;
# therefore, the prefill-batch setting is temporarily set to 1.
@@ -1086,6 +1087,20 @@ class PrefillAdder:
return AddReqResult.NO_TOKEN
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():
new_indices, req.last_node = self.tree_cache.init_load_back(
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")
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):
def setUp(self):
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)
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):
# Non-hybrid path: the SWA-pool reservation must NOT apply, otherwise
# the fix would regress non-SWA models.