Conversation
_schedule_next_batch gives prefill unconditional priority (or-short-circuit), so a long prompt prefilled in chunks occupies that many consecutive scheduler steps during which in-flight decodes are never scheduled. Measured on the live service: four cold prefills of 680/797/506/250 chunks over 267/317/190/93 s, a prefill-burst share of 70% in a 15-minute window, at 0.388 s per 384-token chunk. An already-decoding request therefore waited up to ~5 minutes for its next token. Reversing the order outright would starve prefill, and a request that never prefills never starts. The scheduler already carried the marker for this: a TODO reading "support other policies: e.g. DECODE first". Add --decode-interleave-every N: prefill keeps priority, but after N consecutive prefill steps one decode step is taken if a decode is runnable. At N=8 that bounds the stall to ~3 s instead of ~300 s, for about 1% of prefill wall time. Unset keeps the historical order bit-for-bit, and a Scheduler constructed without the policy (as the accounting tests build it) still works. Assisted-by: Claude Code
…of it Review of the previous commit returned "merge with 2 test nits" and no correctness defect, and named the gap precisely: several tests drove _decide, a pure re-implementation of the decision written inside the test file. That is a model of the code, not the code, so it can drift and the tests keep passing while the real _schedule_next_batch diverges. Adds coverage against the real method via Scheduler.__new__ with stub managers: a truth table over policy=None/1/3/8 asserting the exact slot sequence, strict alternation at every=1, the latch case (decode due but unavailable, prefills continue, then decode fires exactly once), the pure-decode and no-work paths, a policy that exists but is disabled, decode-manager call counts, and the config -> Scheduler.__init__ -> CLI wiring. Two expectations written from reasoning were wrong and the real scheduler corrected them (every=1 starts with prefill, because the streak begins at 0), which is the value this coverage adds. Assisted-by: Claude Code
Correction: the constants in this PR's description came from an older configurationThe PR body cites 384-token chunks, 0.388 s per chunk, and a 267 s stall. Those were measured when this deployment ran Re-measured, controlled, on the current configTwo requests,
7.7x collapse. The log shows why:
Corrected bound for this flagThe measured per-chunk cost is 1.35 s, not 0.388 s, so my earlier "~3 s at N=8" was optimistic by ~3.5x. Corrected (58 chunks / N decode steps, ~0.17 s per decode step at the measured single-stream rate):
The change itself is unaffected — the policy, the tests, and the zero-regression result all stand. What changes is the expected benefit: N=8 buys 77 s -> ~11 s, not 77 s -> 3 s. I would rather correct that here than let a reviewer size the win from a stale number. One related win worth noting: the larger chunk also improved per-token prefill cost from 990 tok/s (384-token chunks) to 2598 tok/s (3456-token chunks), 2.6x. That is a separate effect and does not remove the starvation — it shortens the burst in seconds while leaving decode unscheduled for all of it. Still not validated end-to-end on hardware: the flag has never been enabled on this deployment. The measurement above establishes the defect under the live config; the fix remains unit-tested only. |
Closes #483.
Summary
Scheduler._schedule_next_batchgives prefill unconditional priority via anorshort-circuit, so decode is never consulted while a long prompt's chunked prefill is running. On an 8x RTX 4090 DSV4 deployment, a request already decoding went 77 s without being scheduled at all while one 200K-token prompt prefilled.This adds
--decode-interleave-every N: prefill keeps priority, but after N consecutive prefill steps one decode step is taken if a decode is runnable. Measured on the production box with the flag enabled, the worst inter-token gap went from a single 77 s stall to 13.03 s.This is the policy the scheduler's own TODO names (
support other policies: e.g. DECODE first). Reversing the priority outright is not the fix — it would starve prefill, and a request that never prefills never starts.Design
getattrbecause the accounting tests drive_schedule_next_batchon aSchedulerbuilt with__new__. A stripped object keeps the historical prefill-first order instead of raising.DecodeInterleavePolicyis a pure counter + decision in its own module, so it is testable without a Scheduler, an engine, or a GPU.Measured on hardware
The flag has now been deployed on the production box. Controlled experiment,
temperature: 0, no other traffic, current production geometry (--swa-full-tokens-ratio 0.12-> 308 window pages -> 3456-token chunk budget,--max-running-requests 50):Three independent checks agree that this is the mechanism and not noise:
N=8should interleave58 / 8 = 7.25decode steps. Measuredgaps > 5 s= 7.For the unset row the gap is established from the engine log rather than a streamed run: 58 consecutive prefill steps over 77 s with the victim queued throughout, and no decode step in that span.
The earlier numbers in this PR were wrong and I have corrected them (see #483): the 267 s / 384-token-chunk figures were from an older
--swa-full-tokens-ratio 0.02configuration. The corrected expectation for this flag is 77 s -> ~11 s, not 267 s -> 3 s.Testing
tests/scheduler/test_interleave.py(30 tests).Schedulervia__new__with call-counting stub managers, not a local re-implementation of the decision. The first cut of this file modelled the decision inside the test; review correctly called that out as a model that can drift from the code it describes, and the second commit converts it. Two expectations written from reasoning turned out wrong and the real scheduler corrected them (every=1starts with prefill, because the streak begins at 0).None/1/3/8, strict alternation atevery=1, the latch case (decode due but unavailable -> prefills continue -> decode later fires exactly once), pure-decode fallback, no-workNonepath, a policy that exists but is disabled, decode-manager call counts (a disabled policy must not probe decode at all), and the config ->Scheduler.__init__-> CLI wiring.tests/scheduler tests/kvcache tests/engine: 500 passed, 3 skipped. On unmodifiedmainat68a81ffthe same command gives 470 passed; the delta is exactly the 30 new tests. The 2 failures (tests/engine/test_cache_budget.py,RuntimeError: Attention backend 'fi' requires flashinfer) are pre-existing and environmental — they fail identically on unmodifiedmain, and this change touches neither that file nor flashinfer.Notes for review
Decode batchlog line is throttled to everydecode_log_interval(20 by default) decode steps, whilePrefill batchlogs every step. On a first pass this makes interleaving look like it is not working — 7 interleaved decode steps produce no log line at all. The verification above therefore measures the streamed inter-token gap, not the log.Environment tested on
8x NVIDIA RTX 4090 24564 MiB, driver
595.58.03; 2x Intel Xeon Platinum 8468V; 1007 GiB RAM; Ubuntu 22.04.5, Python 3.10. Checkpoint: DeepSeek-V4-Flash (deepseek_v4, 43 layers, 256 routed experts, 1M native context). Serving command and the full measurement detail are in #483.🤖 Generated with Claude Code