fix(SCHED-PRIORITY-PREEMPT): a preempted victim below the cursor silently drops the next request
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 423
- Forks
- 53
- Avg merge
- 20h 26m
- Merged PRs (30d)
- 310
Description
Row: -
Owed by .agents/specs/upstream-sync-pinport.md
## Owed. Found while re-deriving the PORT-NOW queue for the cdefd9d499 sync
cycle (#2524).
The bug
Under the priority scheduling policy, a preemption can make the scheduler
silently never schedule a running request for that step.
Scheduler::schedule() walks running with a cursor req_index. When the KV
allocator cannot fit the current request, the priority branch picks the
lowest-priority victim anywhere in running and erases it:
src/vllm/v1/core/sched/scheduler.cpp:739 (at 63889449c)
preempted_req = *victim;
running.erase(victim);
The cursor is then stepped back only inside the branch that handles a victim
which was already scheduled this step:
src/vllm/v1/core/sched/scheduler.cpp:762
if (sit != scheduled_running_reqs.end()) {
...
req_index -= 1;
}
The loop has three paths that advance req_index without scheduling the
request: the async max-tokens guard (:663), the PP decode-cadence guard
(:670), and num_new_tokens == 0 (:711, reached when a request hits its cap
or its encoder budget is exhausted). A victim taken from one of those positions
sits before the cursor but is absent from scheduled_running_reqs, so
running shrinks in front of the cursor with no decrement. The request that
shifts into the victim's slot is stepped over and never scheduled.
The loop's own comment at :649-651 says the signed req_index exists so the
"req_index -= 1 fix-up" is well-defined, so the tree mirrors the pre-fix
upstream shape deliberately rather than by accident.
Upstream
Fixed by vllm#49206 at
4d341ca829 fix: resolve silent request skipping in PRIORITY scheduling,
which records the victim's index before erasing and decrements on
victim_index < req_index regardless of whether the victim was scheduled.
4d341ca829 is an ancestor of the sync target cdefd9d499. Upstream also added
tests/v1/core/test_priority_preemption_bug.py, which drives the skip through
throttle_prefills plus long_prefill_token_threshold.
Old behaviour is a strict subset of new: a victim that WAS scheduled is
necessarily at an index below the cursor, so the added case is exactly the
skipped-victim one.
Reproduction shape for our tree
We have no throttle_prefills, so the skip must come from another path. The
num_new_tokens == 0 path is reachable through the max_model_len clamp at
:695-697: CreateSchedulerWithPriority already takes max_model_len, so a
request whose num_computed_tokens reaches max_model_len - 1 yields zero new
tokens and is skipped. Give that request the worst priority so it is the victim,
put two better-priority requests after it, and exhaust KV on the second: the
third is silently dropped from the step.
Fix
Verified against the upstream diff and our tree, but NOT COMPILED — the dev
box was at load ~50 with two other worktrees compiling and ~1 GB free, and a
third build is the recorded OOM failure mode, so this was deliberately not
landed unverified. The patch below is the reconciled change; it still needs the
red test, a build, and a focused green before it lands.
diff --git a/src/vllm/v1/core/sched/scheduler.cpp b/src/vllm/v1/core/sched/scheduler.cpp
index 36f8953db..585912624 100644
--- a/src/vllm/v1/core/sched/scheduler.cpp
+++ b/src/vllm/v1/core/sched/scheduler.cpp
@@ -12,6 +12,7 @@
#include <cstdint>
#include <cstdlib>
#include <iostream>
+#include <iterator>
#include <map>
#include <memory>
#include <mutex>
@@ -647,8 +648,8 @@ SchedulerOutput Scheduler::schedule() {
scheduler_config_.policy == SchedulerPolicy::kPriority;
// First, schedule the RUNNING requests. req_index is a signed int so the
- // priority-preemption `req_index -= 1` fix-up (upstream scheduler.py:570) is
- // well-defined.
+ // priority-preemption `req_index -= 1` fix-up (scheduler.py:596-603 @
+ // cdefd9d499, vllm#49206) is well-defined.
int req_index = 0;
while (req_index < static_cast<int>(running.size()) && token_budget > 0) {
Request* request = running[req_index];
@@ -735,11 +736,22 @@ SchedulerOutput Scheduler::schedule() {
return std::make_pair(a->priority, a->arrival_time) <
std::make_pair(b->priority, b->arrival_time);
});
+ // Record the victim's index BEFORE erasing it, so the loop cursor can
+ // be corrected below (scheduler.py:596-603, vllm#49206).
+ const int victim_index =
+ static_cast<int>(std::distance(running.begin(), victim));
preempted_req = *victim;
running.erase(victim);
+ // The victim sat before the cursor, so `running` shrank in front of it.
+ // Step the cursor back one, or the request that shifts into the
+ // victim's slot is silently never scheduled. This must NOT be
+ // conditional on the victim having been scheduled this step: a victim
+ // the loop SKIPPED (it reached its cap, or its encoder budget was
+ // exhausted) also sits before the cursor, and that is the case
+ // vllm#49206 fixes.
+ if (victim_index < req_index) req_index -= 1;
// If the victim was already scheduled earlier this step, undo it:
- // restore its token budget, drop its block reservation, and step
- // req_index back one (running shrank in front of the cursor).
+ // restore its token budget and drop its block reservation.
auto sit = std::find(scheduled_running_reqs.begin(),
scheduled_running_reqs.end(), preempted_req);
if (sit != scheduled_running_reqs.end()) {
@@ -759,7 +771,6 @@ SchedulerOutput Scheduler::schedule() {
}
scheduled_encoder_inputs.erase(enc_it);
}
- req_index -= 1;
}
} else {
// FCFS: preempt the tail (lowest scheduling priority = last arrival).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/vllm/v1/core/sched/scheduler.cpp around Scheduler::schedule(), especially the priority preemption and req_index handling near lines 649-771. Use CreateSchedulerWithPriority with max_model_len to reproduce the skipped request, then add a focused regression test under tests/v1/core and build the project. Done means the regression fails before the fix and passes afterward with a successful build.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100