@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / d8d95998dc
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[Core] Add prefill step cadence for better non-PD DP balancing (#44558)

Signed-off-by: Nick Hill <nickhill123@gmail.com>

Nick Hill committed Jun 16, 2026 at 13:17 UTC d8d95998dcb261229369eaae28c4413268ea803d
7 files changed +306 -4
tests/v1/core/test_scheduler.py
+181
@@ -207,6 +207,187 @@ def test_schedule_partial_requests():
207 assert requests[2].request_id not in output.num_scheduled_tokens
208
209
210 +@pytest.mark.parametrize("has_running", [True, False])
211 +def test_schedule_prefills_gating(has_running: bool):
212 + """DP prefill-balancing gate: when `throttle_prefills` is True, a new
213 + WAITING (prefill) request is deferred ONLY if this rank has running work to
214 + protect. With no running requests, the prefill is admitted regardless (so a
215 + throttled step is never wasted as a dummy), and running/decode requests are
216 + unaffected. Once the cadence allows prefills again, the request is admitted.
217 + """
218 + scheduler = create_scheduler(max_num_seqs=16, max_num_batched_tokens=8192)
219 +
220 + if has_running:
221 + # Establish a running (decode) request via a prefill + output step.
222 + (running_req,) = create_requests(num_requests=1, num_tokens=8, req_ids=["run0"])
223 + scheduler.add_request(running_req)
224 + output = scheduler.schedule()
225 + assert len(output.scheduled_new_reqs) == 1
226 + scheduler.update_from_output(
227 + output,
228 + ModelRunnerOutput(
229 + req_ids=["run0"],
230 + req_id_to_index={"run0": 0},
231 + sampled_token_ids=[[0]],
232 + logprobs=None,
233 + prompt_logprobs_dict={},
234 + pooler_output=[],
235 + ),
236 + )
237 + assert len(scheduler.running) == 1
238 +
239 + # Add a new WAITING (prefill) request, with prefills gated off.
240 + (new_req,) = create_requests(num_requests=1, num_tokens=8, req_ids=["new0"])
241 + scheduler.add_request(new_req)
242 + output = scheduler.schedule(throttle_prefills=True)
243 +
244 + if has_running:
245 + # There is running work to protect, so the new prefill is deferred...
246 + assert "new0" not in output.num_scheduled_tokens
247 + assert new_req.status == RequestStatus.WAITING
248 + # ...while the running/decode request keeps being scheduled.
249 + assert "run0" in output.num_scheduled_tokens
250 + # When the cadence allows prefills again, the request is admitted.
251 + output = scheduler.schedule()
252 +
253 + # No running work to protect (or cadence now open): the prefill is admitted.
254 + assert "new0" in output.num_scheduled_tokens
255 + assert any(r.req_id == "new0" for r in output.scheduled_new_reqs)
256 +
257 +
258 +def test_throttle_prefills_excludes_remote_kv_resume():
259 + """A request resuming after a completed async KV load (num_computed_tokens
260 + > 0, e.g. the decode side of P/D disaggregation) must NOT be throttled by
261 + the DP prefill cadence: only fresh prefills are deferred. Otherwise the
262 + resumed request's first (single-token) step would be needlessly delayed.
263 + """
264 + from tests.v1.kv_connector.unit.utils import create_model_runner_output
265 +
266 + BLOCK_SIZE = 16
267 + NUM_MATCHED = BLOCK_SIZE * 2
268 + scheduler = create_scheduler(
269 + enable_prefix_caching=True,
270 + use_kv_connector=mock_kv(matched_tokens=NUM_MATCHED, is_async=True),
271 + block_size=BLOCK_SIZE,
272 + )
273 +
274 + # Two remote-KV requests with distinct prompts (so r2 gets no local prefix
275 + # cache hit from r1, only the connector's external async load).
276 + r1, r2 = create_requests(
277 + num_requests=2,
278 + num_tokens=NUM_MATCHED * 2,
279 + max_tokens=20,
280 + block_size=BLOCK_SIZE,
281 + req_ids=["r1", "r2"],
282 + )
283 +
284 + # r1: drive through its async KV load and into the running (decode) state,
285 + # so that self.running is non-empty for the assertion below.
286 + scheduler.add_request(r1)
287 + _step_until_kv_transfer_finished(scheduler, ["r1"])
288 + output = scheduler.schedule() # promote + schedule r1
289 + assert "r1" in output.num_scheduled_tokens
290 + scheduler.update_from_output(
291 + output, create_model_runner_output([r1], token_id=1000)
292 + )
293 + assert scheduler.running # r1 now decoding
294 +
295 + # r2: a second remote-KV request; complete its async load while r1 decodes.
296 + scheduler.add_request(r2)
297 + output = scheduler.schedule() # r1 decodes; r2 -> WAITING_FOR_REMOTE_KVS
298 + assert r2.status == RequestStatus.WAITING_FOR_REMOTE_KVS
299 + scheduler.update_from_output(
300 + output, create_model_runner_output([r1], finished_recving={"r2"})
301 + )
302 + assert "r2" in scheduler.finished_recving_kv_req_ids
303 +
304 + # Throttle prefills. r2's load is complete, so it must be promoted and
305 + # scheduled (a resume, not a fresh prefill) even though the running decode
306 + # (r1) would otherwise make this a throttled step.
307 + output = scheduler.schedule(throttle_prefills=True)
308 + assert "r2" in output.num_scheduled_tokens
309 + assert "r1" in output.num_scheduled_tokens
310 +
311 +
312 +def test_throttle_defers_inflight_prefill_chunk():
313 + """DP prefill balancing throttles ALL prefill compute on a throttled step,
314 + not just new admissions: an in-progress (chunked) prefill already in the
315 + running queue is also deferred, so the step runs decode-only, while a
316 + separate decode keeps being scheduled."""
317 + scheduler = create_scheduler(
318 + max_num_seqs=16, max_num_batched_tokens=50, enable_chunked_prefill=True
319 + )
320 +
321 + # A short request that finishes prefill in one step -> a running decode.
322 + (decode_req,) = create_requests(num_requests=1, num_tokens=4, req_ids=["dec0"])
323 + scheduler.add_request(decode_req)
324 + output = scheduler.schedule()
325 + scheduler.update_from_output(
326 + output,
327 + ModelRunnerOutput(
328 + req_ids=["dec0"],
329 + req_id_to_index={"dec0": 0},
330 + sampled_token_ids=[[0]],
331 + logprobs=None,
332 + prompt_logprobs_dict={},
333 + pooler_output=[],
334 + ),
335 + )
336 + assert decode_req in scheduler.running and not decode_req.is_prefill_chunk
337 +
338 + # A long request (80 tokens, budget 50) -> prefilled in chunks.
339 + (chunk_req,) = create_requests(num_requests=1, num_tokens=80, req_ids=["chk0"])
340 + scheduler.add_request(chunk_req)
341 + output = scheduler.schedule() # first chunk of chk0 + decode of dec0
342 + assert output.num_scheduled_tokens["chk0"] > 0
343 + scheduler.update_from_output(
344 + output,
345 + ModelRunnerOutput(
346 + req_ids=["dec0", "chk0"],
347 + req_id_to_index={"dec0": 0, "chk0": 1},
348 + sampled_token_ids=[[0], []], # no token sampled for partial prefill
349 + logprobs=None,
350 + prompt_logprobs_dict={},
351 + pooler_output=[],
352 + ),
353 + )
354 + assert chunk_req.is_prefill_chunk # still mid-prefill, in running
355 +
356 + # Throttled step: the in-flight prefill chunk is deferred, the decode runs.
357 + output = scheduler.schedule(throttle_prefills=True)
358 + assert "chk0" not in output.num_scheduled_tokens
359 + assert "dec0" in output.num_scheduled_tokens
360 +
361 + # When the cadence opens again, the prefill chunk resumes.
362 + output = scheduler.schedule()
363 + assert "chk0" in output.num_scheduled_tokens
364 +
365 +
366 +def test_throttle_capacity_bound_guard_admits():
367 + """Saturation guard: if a cadence-aligned release step cannot drain the
368 + waiting prefill queue (it ran out of token budget), the throttle backs off on
369 + the next step so the backlog cannot grow into a TTFT avalanche -- prefills are
370 + admitted even though throttle_prefills is set."""
371 + scheduler = create_scheduler(
372 + max_num_seqs=16, max_num_batched_tokens=200, enable_chunked_prefill=True
373 + )
374 + a, b = create_requests(num_requests=2, num_tokens=200, req_ids=["a", "b"])
375 + scheduler.add_request(a)
376 + scheduler.add_request(b)
377 +
378 + # Release step (throttle off): `a` fills the 200-token budget; `b` cannot be
379 + # reached, so the waiting queue is not drained -> capacity-bound.
380 + output = scheduler.schedule()
381 + assert "a" in output.num_scheduled_tokens
382 + assert "b" not in output.num_scheduled_tokens
383 + assert scheduler.prefill_capacity_bound
384 +
385 + # Throttle. Because the previous release was capacity-bound, the guard backs
386 + # off and `b` is admitted rather than stalling the backlog.
387 + output = scheduler.schedule(throttle_prefills=True)
388 + assert "b" in output.num_scheduled_tokens
389 +
390 +
391 def test_no_mm_input_chunking():
392 # Disable multimodal input chunking.
393 scheduler = create_scheduler(
tests/v1/distributed/test_async_llm_dp.py
+61
@@ -186,6 +186,67 @@ async def test_load(
186 )
187
188
189 +@pytest.mark.parametrize("prefill_schedule_interval", [1, 4])
190 +@pytest.mark.asyncio
191 +async def test_dp_prefill_schedule_interval(prefill_schedule_interval: int):
192 + """Throttling new prefills to every Nth step (DP balancing) must not break
193 + generation: a stream of staggered requests should still all complete with
194 + the expected number of tokens.
195 +
196 + The throttle only engages in the DP MoE/EP engine-core path
197 + (`DPEngineCoreProc`), so this uses an MoE model with expert parallel.
198 + """
199 + with ExitStack() as after:
200 + prompt = "This is a test of data parallel"
201 +
202 + engine_args = AsyncEngineArgs(
203 + model="ibm-research/PowerMoE-3b",
204 + enforce_eager=True,
205 + tensor_parallel_size=int(os.getenv("TP_SIZE", 1)),
206 + data_parallel_size=DP_SIZE,
207 + data_parallel_backend="mp",
208 + enable_expert_parallel=True,
209 + prefill_schedule_interval=prefill_schedule_interval,
210 + )
211 + engine = AsyncLLM.from_engine_args(engine_args)
212 + after.callback(engine.shutdown)
213 +
214 + NUM_REQUESTS = 50
215 + NUM_EXPECTED_TOKENS = 10
216 +
217 + request_ids = [f"request-{i}" for i in range(NUM_REQUESTS)]
218 +
219 + # Create requests with a small stagger so they arrive across many
220 + # steps and (with interval > 1) accumulate in the waiting queue
221 + # before being admitted together on cadence-aligned steps.
222 + tasks = []
223 + for request_id in request_ids:
224 + tasks.append(
225 + asyncio.create_task(
226 + generate(
227 + engine,
228 + request_id,
229 + prompt,
230 + RequestOutputKind.DELTA,
231 + NUM_EXPECTED_TOKENS,
232 + )
233 + )
234 + )
235 + await asyncio.sleep(0.01)
236 +
237 + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
238 + for task in pending:
239 + task.cancel()
240 + for task in done:
241 + num_generated_tokens, request_id = await task
242 + assert num_generated_tokens == NUM_EXPECTED_TOKENS, (
243 + f"{request_id} generated {num_generated_tokens} but "
244 + f"expected {NUM_EXPECTED_TOKENS}"
245 + )
246 +
247 + assert not engine.output_processor.has_unfinished_requests()
248 +
249 +
250 # =============================================================================
251 # DP Pause/Resume Tests
252 # =============================================================================
vllm/config/scheduler.py
+5
@@ -150,6 +150,11 @@ class SchedulerConfig:
150 of requests when GPU memory is scarce. Must be in the range [0.0, 1.0); 0.0
151 (the default) disables the watermark."""
152
153 + prefill_schedule_interval: int = Field(default=1, ge=1)
154 + """For data-parallel deployments, only admit new prefill requests
155 + once every N engine steps, aligned across DP ranks, to better balance
156 + per-step forward-pass times."""
157 +
158 async_scheduling: bool | None = None
159 """If set to False, disable async scheduling. Async scheduling helps to
160 avoid gaps in GPU utilization, leading to better latency and throughput.
vllm/engine/arg_utils.py
+6
@@ -599,6 +599,7 @@ class EngineArgs:
599 disable_chunked_mm_input: bool = SchedulerConfig.disable_chunked_mm_input
600
601 scheduler_reserve_full_isl: bool = SchedulerConfig.scheduler_reserve_full_isl
602 + prefill_schedule_interval: int = SchedulerConfig.prefill_schedule_interval
603
604 watermark: float = SchedulerConfig.watermark
605
@@ -1417,6 +1418,10 @@ class EngineArgs:
1418 **scheduler_kwargs["scheduler_reserve_full_isl"],
1419 )
1420 scheduler_group.add_argument("--watermark", **scheduler_kwargs["watermark"])
1421 + scheduler_group.add_argument(
1422 + "--prefill-schedule-interval",
1423 + **scheduler_kwargs["prefill_schedule_interval"],
1424 + )
1425 scheduler_group.add_argument(
1426 "--disable-hybrid-kv-cache-manager",
1427 **scheduler_kwargs["disable_hybrid_kv_cache_manager"],
@@ -2064,6 +2069,7 @@ class EngineArgs:
2069 long_prefill_token_threshold=self.long_prefill_token_threshold,
2070 scheduler_reserve_full_isl=self.scheduler_reserve_full_isl,
2071 watermark=self.watermark,
2072 + prefill_schedule_interval=self.prefill_schedule_interval,
2073 disable_hybrid_kv_cache_manager=self.disable_hybrid_kv_cache_manager,
2074 async_scheduling=self.async_scheduling,
2075 stream_interval=self.stream_interval,
vllm/v1/core/sched/interface.py
+7 -1
@@ -49,7 +49,7 @@ class SchedulerInterface(ABC):
49 raise NotImplementedError
50
51 @abstractmethod
52 - def schedule(self) -> "SchedulerOutput":
52 + def schedule(self, throttle_prefills: bool = False) -> "SchedulerOutput":
53 """Schedule the requests to process in this scheduling step.
54
55 The scheduling decision is made at the iteration level. Each scheduling
@@ -68,6 +68,12 @@ class SchedulerInterface(ABC):
68 or the batch as a whole. The model runner will use this information in
69 preparing inputs to the model.
70
71 + Args:
72 + throttle_prefills: DP prefill balancing. When True (set by the DP
73 + engine core on non-cadence-aligned steps), new prefill compute is
74 + deferred to a later step so prefills stay aligned across DP ranks;
75 + automatically overridden when the rank is saturated.
76 +
77 Returns:
78 A SchedulerOutput object containing information about the scheduled
79 requests.
vllm/v1/core/sched/scheduler.py
+27 -1
@@ -275,6 +275,10 @@ class Scheduler(SchedulerInterface):
275 # Scheduler iteration counter. Drives the V2+PP+async decode-throttle
276 # cadence (`next_decode_eligible_step`).
277 self.current_step = 0
278 + # DP prefill balancing: Flag to track whether the last cadence-aligned
279 + # prefill batch fully drained the waiting queue. Prefill throttling
280 + # is disabled in this case.
281 + self.prefill_capacity_bound = False
282 self.scheduler_reserve_full_isl = (
283 self.scheduler_config.scheduler_reserve_full_isl
284 )
@@ -380,7 +384,7 @@ class Scheduler(SchedulerInterface):
384 num_new_tokens = num_new_tokens // block_size * block_size
385 return num_new_tokens
386
383 - def schedule(self) -> SchedulerOutput:
387 + def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
388 self.current_step += 1
389 # NOTE(woosuk) on the scheduling algorithm:
390 # There's no "decoding phase" nor "prefill phase" in the scheduler.
@@ -416,6 +420,12 @@ class Scheduler(SchedulerInterface):
420
421 self.kv_cache_manager.new_step_starts()
422
423 + # DP prefill balancing: on a throttled (non-cadence-aligned) step, defer
424 + # all prefill compute unless saturated.
425 + defer_prefills = (
426 + throttle_prefills and not self.prefill_capacity_bound
427 + ) and any(not r.is_prefill_chunk for r in self.running)
428 +
429 # First, schedule the RUNNING requests.
430 req_index = 0
431 while req_index < len(self.running) and token_budget > 0:
@@ -443,6 +453,12 @@ class Scheduler(SchedulerInterface):
453 req_index += 1
454 continue
455
456 + if defer_prefills and request.is_prefill_chunk:
457 + # DP prefill balancing: defer this in-progress prefill chunk to a
458 + # cadence-aligned step; decodes still run to fill this step.
459 + req_index += 1
460 + continue
461 +
462 num_new_tokens = (
463 request.num_tokens_with_spec
464 + request.num_output_placeholders
@@ -766,6 +782,11 @@ class Scheduler(SchedulerInterface):
782 # KVTransfer: loading remote KV, do not allocate for new work.
783 assert num_external_computed_tokens > 0
784 num_new_tokens = 0
785 + elif defer_prefills and request.num_computed_tokens == 0:
786 + # DP prefill balancing: async KV loads (the branch above) are
787 + # allowed to start even on throttled steps, but committing new
788 + # prefill compute is deferred to a cadence-aligned step.
789 + break
790 else:
791 # Number of tokens to be scheduled.
792 # We use `request.num_tokens` instead of
@@ -959,6 +980,11 @@ class Scheduler(SchedulerInterface):
980 if step_skipped_waiting:
981 self.skipped_waiting.prepend_requests(step_skipped_waiting)
982
983 + # DP prefill balancing: on a step that admitted prefills (release),
984 + # record whether it was capacity-bound.
985 + if not defer_prefills:
986 + self.prefill_capacity_bound = bool(self.waiting)
987 +
988 # Check if the scheduling constraints are satisfied.
989 total_num_scheduled_tokens = sum(num_scheduled_tokens.values())
990 assert total_num_scheduled_tokens <= self.max_num_scheduled_tokens
vllm/v1/engine/core.py
+19 -2
@@ -471,6 +471,11 @@ class EngineCore:
471 )
472 self._iteration_index += 1
473
474 + def _should_throttle_prefills(self) -> bool:
475 + """Whether to defer new prefills this step (DP prefill balancing).
476 + Overridden by the DP engine core; never throttles otherwise."""
477 + return False
478 +
479 def step(self) -> tuple[dict[int, EngineCoreOutputs], bool]:
480 """Schedule, execute, and make output.
481
@@ -482,7 +487,7 @@ class EngineCore:
487 # or finished and not yet removed from the batch.
488 if not self.scheduler.has_requests():
489 return {}, False
485 - scheduler_output = self.scheduler.schedule()
490 + scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
491 future = self.model_executor.execute_model(scheduler_output, non_block=True)
492 grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output)
493 with (
@@ -539,7 +544,7 @@ class EngineCore:
544 model_executed = False
545 deferred_scheduler_output = None
546 if self.scheduler.has_requests():
542 - scheduler_output = self.scheduler.schedule()
547 + scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
548 with self.log_error_detail(scheduler_output):
549 exec_future = self.model_executor.execute_model(
550 scheduler_output, non_block=True
@@ -1751,6 +1756,9 @@ class DPEngineCoreProc(EngineCoreProc):
1756 "DPEngineCoreProc should only be used for MoE models"
1757 )
1758
1759 + scheduler_config = vllm_config.scheduler_config
1760 + self.prefill_schedule_interval = scheduler_config.prefill_schedule_interval
1761 +
1762 # Counts forward-passes of the model so that we can synchronize
1763 # finished with DP peers every N steps.
1764 self.step_counter = 0
@@ -1901,6 +1909,15 @@ class DPEngineCoreProc(EngineCoreProc):
1909 )
1910 self.output_queue.put_nowait((-1, EngineCoreOutputs(scheduler_stats=stats)))
1911
1912 + def _should_throttle_prefills(self) -> bool:
1913 + # Throttle new prefills to cadence-aligned steps for DP balancing.
1914 + # step_counter is identical across DP ranks. On a fresh wave the
1915 + # counter is 0, so prefills are admitted immediately after idle.
1916 + return (
1917 + self.prefill_schedule_interval > 1
1918 + and self.step_counter % self.prefill_schedule_interval != 0
1919 + )
1920 +
1921 def run_busy_loop(self):
1922 """Core busy loop of the EngineCore for data parallel case."""
1923