Skip to content

Commit 4f12158

Browse files
mudlerclaude
andcommitted
feat(v1/sched): update_from_output + check_stop (schedule->execute->update loop)
Port scheduler.py::update_from_output + sched/utils.py::check_stop (T0 subset) from vllm @ e24d1b24, closing the schedule->execute->update loop: - sched/utils.{h,cpp}: check_stop(request, max_model_len). Precedence matched to the pin: min_tokens gate FIRST, then eos, stop_token_ids (sets stop_reason), then length cap (max_model_len / max_tokens). Repetition-detection deferred. - Scheduler::update_from_output: append sampled token(s) per scheduled request, run check_stop after each (trim on stop), free KV + record finished_req_ids + erase from running/waiting/requests on finish, build EngineCoreOutputs. Deferred: logprobs, spec-decode, structured output, pooling, encoder, KV-connector, routed-experts, stats, resumable/streaming, client fan-out. - request.h: un-defer stop_reason (optional<int>) for the stop-token path. - Tests ported from test_scheduler.py: append, eos stop, max_tokens length cap, min_tokens gate, stop_token_ids/stop_reason, partial-prefill no-token, batched EngineCoreOutputs shape. ctest 40/40 green; warnings-as-errors clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f09509c commit 4f12158

7 files changed

Lines changed: 468 additions & 1 deletion

File tree

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ add_library(vllm STATIC
7070
src/vllm/v1/core/sched/request_queue.cpp
7171
src/vllm/v1/core/sched/output.cpp
7272
src/vllm/v1/core/sched/scheduler.cpp
73+
src/vllm/v1/core/sched/utils.cpp
7374
src/vllm/v1/engine/types.cpp
7475
src/vllm/v1/engine/detokenizer.cpp
7576
src/vt/dtype.cpp

include/vllm/v1/core/sched/scheduler.h

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
#include "vllm/v1/core/kv_cache_manager.h"
6868
#include "vllm/v1/core/sched/output.h"
6969
#include "vllm/v1/core/sched/request_queue.h"
70+
#include "vllm/v1/engine/types.h" // ModelRunnerOutput, EngineCoreOutputs
7071
#include "vllm/v1/kv_cache_interface.h"
7172
#include "vllm/v1/request.h"
7273

@@ -104,6 +105,31 @@ class Scheduler {
104105
// schedule(): the core token-budget algorithm. See the file header.
105106
SchedulerOutput schedule();
106107

108+
// update_from_output: the final leg of the schedule -> execute -> update loop.
109+
// For each scheduled request (iterating scheduler_output.num_scheduled_tokens,
110+
// matching upstream's `for req_id ... in num_scheduled_tokens.items()`), it
111+
// reads that request's sampled token(s) from model_runner_output via
112+
// req_id_to_index, appends them one at a time running check_stop after each
113+
// (trimming any tokens past a stop), and on a stop sets the finished status,
114+
// frees the request's KV blocks, records its id in finished_req_ids, removes it
115+
// from running_/waiting_ and erases it from the requests map. It builds one
116+
// EngineCoreOutput per request that produced tokens or finished, and returns
117+
// the batched EngineCoreOutputs for the step. A request still in chunked
118+
// prefill receives an empty token list from the runner and stays running with
119+
// no output. (Upstream scheduler.py::update_from_output, T0 subset.)
120+
//
121+
// DEFERRED (matches upstream structure): logprobs / prompt_logprobs,
122+
// speculative-decode acceptance & num_computed rollback, structured-output
123+
// grammar advance, pooling outputs, encoder-input free, KV-connector finish /
124+
// invalid-block reload, routed-experts, num_nans_in_logits, spec/perf/spec-
125+
// decoding stats, resumable / streaming sessions (so _handle_stopped_request
126+
// is always "finished" here), and the per-client_index output fan-out (T0 has
127+
// a single client, so a flat EngineCoreOutputs is returned rather than
128+
// dict[client_index, EngineCoreOutputs]).
129+
EngineCoreOutputs update_from_output(
130+
const SchedulerOutput& scheduler_output,
131+
const ModelRunnerOutput& model_runner_output);
132+
107133
// get_num_unfinished_requests: len(waiting) + len(running) (T0 subset).
108134
int get_num_unfinished_requests() const;
109135
// get_request_counts: (num_running, num_waiting).

include/vllm/v1/core/sched/utils.h

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Ported from: vllm/v1/core/sched/utils.py @ e24d1b24
2+
//
3+
// Scope (M1.4 Task 4): the token-level stop check the scheduler runs after each
4+
// sampled token. check_stop is the only piece of sched/utils.py the V1 engine's
5+
// update_from_output path needs at T0.
6+
//
7+
// DEFERRED (marked; the upstream file's other helpers are not needed at T0):
8+
// - _has_repeating_pattern / check_sequence_repetition (the
9+
// repetition-detection path — sampling_params.repetition_detection is a
10+
// deferred SamplingParams field, so check_stop's repetition branch is
11+
// omitted here).
12+
// - remove_all (the list-removal helper) — update_from_output removes stopped
13+
// requests from the running vector / waiting queue directly.
14+
//
15+
// IMPORTANT (stop precedence, verified against the pin — NOT the summary in the
16+
// plan): the pinned check_stop order is
17+
// 1. min_tokens gate: num_output_tokens < min_tokens -> return false FIRST
18+
// (this gates *everything*, including the length cap, at this pin);
19+
// 2. eos: last token == eos_token_id -> FINISHED_STOPPED;
20+
// 3. stop_token_ids: last token in stop_token_ids -> FINISHED_STOPPED
21+
// (stop_reason = the matched token id);
22+
// 4. length cap: num_tokens >= max_model_len OR num_output_tokens >= max_tokens
23+
// -> FINISHED_LENGTH_CAPPED;
24+
// 5. repetition (DEFERRED).
25+
#ifndef VLLM_V1_CORE_SCHED_UTILS_H_
26+
#define VLLM_V1_CORE_SCHED_UTILS_H_
27+
28+
#include "vllm/v1/request.h"
29+
30+
namespace vllm::v1 {
31+
32+
// check_stop(request, max_model_len): returns true and sets request.status
33+
// (and, for a stop_token_ids match, request.stop_reason) when the request has
34+
// hit a stop condition after its latest sampled token. Mirrors
35+
// vllm/v1/core/sched/utils.py::check_stop for the T0 subset (pooling and
36+
// repetition-detection branches deferred). Mutates the request exactly as
37+
// upstream does.
38+
bool check_stop(Request& request, int max_model_len);
39+
40+
} // namespace vllm::v1
41+
42+
#endif // VLLM_V1_CORE_SCHED_UTILS_H_

include/vllm/v1/request.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
// - prompt_embeds / prompt_is_token_ids / _prompt_embeds_per_block_hashes,
1616
// mm_features (multimodal), pooling_params, structured_output_request,
1717
// lora_request, cache_salt (prefix caching salt), events /
18-
// stop_reason / kv_transfer_params, spec_token_ids, priority /
18+
// kv_transfer_params, spec_token_ids, priority /
1919
// client_index / __lt__ (priority scheduling), streaming / resumable
2020
// state, prefill_stats, async-scheduling counters
2121
// (num_output_placeholders, async_tokens_to_discard,
@@ -134,6 +134,15 @@ struct Request {
134134
std::vector<int32_t> output_token_ids;
135135
int num_computed_tokens = 0;
136136
RequestStatus status = RequestStatus::kWaiting;
137+
// stop_reason (upstream Request.stop_reason: int | str | None = None). Set by
138+
// check_stop (sched/utils) when a stop_token_ids match ends the request — it
139+
// carries the matched token id — and read by update_from_output when it builds
140+
// the request's EngineCoreOutput. Un-deferred from the Request deferred list
141+
// because M1.4 Task 4 needs it. Repetition detection's string reason
142+
// ("repetition_detected") is deferred with sampling_params.repetition_detection,
143+
// so at T0 only the int (stop-token) form ever occurs — hence std::optional<int>
144+
// rather than a variant<int, string>.
145+
std::optional<int> stop_reason;
137146
double arrival_time = 0.0;
138147
// Set at construction from prompt_token_ids.size() (upstream:
139148
// length_from_prompt_token_ids_or_embeds).

src/vllm/v1/core/sched/scheduler.cpp

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#include <algorithm>
66
#include <cassert>
7+
#include <cstdint>
78
#include <map>
89
#include <memory>
910
#include <optional>
@@ -13,6 +14,8 @@
1314
#include <utility>
1415
#include <vector>
1516

17+
#include "vllm/v1/core/sched/utils.h" // check_stop
18+
1619
namespace vllm::v1 {
1720

1821
namespace {
@@ -316,6 +319,133 @@ SchedulerOutput Scheduler::schedule() {
316319
return scheduler_output;
317320
}
318321

322+
EngineCoreOutputs Scheduler::update_from_output(
323+
const SchedulerOutput& scheduler_output,
324+
const ModelRunnerOutput& model_runner_output) {
325+
const std::vector<std::vector<int32_t>>& sampled_token_ids =
326+
model_runner_output.sampled_token_ids;
327+
const std::map<std::string, int>& num_scheduled_tokens =
328+
scheduler_output.num_scheduled_tokens;
329+
330+
std::vector<EngineCoreOutput> outputs;
331+
// Requests that stopped this step, split by the queue they must be removed
332+
// from (upstream stopped_running_reqs / stopped_preempted_reqs). The KV blocks
333+
// are freed and finished_req_ids updated inside the loop, but the owning
334+
// requests-map erase is deferred until after these pointers are used to filter
335+
// running/waiting (so the Request* stays valid — upstream relies on Python GC).
336+
std::set<Request*> stopped_running_reqs;
337+
std::set<Request*> stopped_preempted_reqs;
338+
std::vector<std::string> finished_ids_to_erase;
339+
340+
// NOTE(woosuk): upstream iterates num_scheduled_tokens.items() (dict/schedule
341+
// order); std::map iterates in sorted key order. The set of outputs is the
342+
// same — only their order in the returned vector differs, which is benign
343+
// (each EngineCoreOutput is keyed by request_id).
344+
for (const auto& [req_id, num_tokens_scheduled] : num_scheduled_tokens) {
345+
assert(num_tokens_scheduled > 0);
346+
(void)num_tokens_scheduled;
347+
348+
auto it = requests.find(req_id);
349+
if (it == requests.end() || it->second->IsFinished()) {
350+
// Already finished — e.g. aborted while the model was executing it.
351+
continue;
352+
}
353+
Request* request = it->second.get();
354+
355+
const int req_index = model_runner_output.req_id_to_index.at(req_id);
356+
// sampled_token_ids[req_index] if sampled_token_ids else []. A request still
357+
// being prefilled gets an empty list from the runner.
358+
std::vector<int32_t> new_token_ids =
359+
sampled_token_ids.empty()
360+
? std::vector<int32_t>{}
361+
: sampled_token_ids[static_cast<std::size_t>(req_index)];
362+
363+
// DEFERRED: speculative-decode acceptance / num_computed rollback; encoder-
364+
// input free.
365+
366+
bool stopped = false;
367+
const RequestStatus status_before_stop = request->status;
368+
369+
// _update_request_with_output: append each generated token, run check_stop
370+
// after each, and trim any tokens generated past the stop.
371+
if (!new_token_ids.empty()) {
372+
for (std::size_t num_new = 1; num_new <= new_token_ids.size(); ++num_new) {
373+
request->AppendOutputToken(new_token_ids[num_new - 1]);
374+
stopped = check_stop(*request, max_model_len);
375+
if (stopped) {
376+
new_token_ids.resize(num_new); // del new_token_ids[num_new:]
377+
break;
378+
}
379+
}
380+
}
381+
// DEFERRED: pooling stop, structured-output grammar accept.
382+
383+
std::optional<FinishReason> finish_reason;
384+
if (stopped) {
385+
// Capture the finish reason before freeing (upstream captures it before
386+
// _handle_stopped_request, which may reset the status for resumable reqs —
387+
// resumable/streaming is deferred, so _handle_stopped_request is always
388+
// "finished" at T0).
389+
finish_reason = request->GetFinishedReason();
390+
// _free_request + _free_blocks (T0 subset): free the KV blocks and record
391+
// the finished id now; defer the requests-map erase (see above).
392+
kv_cache_manager->free(*request);
393+
finished_req_ids.insert(request->request_id);
394+
finished_ids_to_erase.push_back(request->request_id);
395+
if (status_before_stop == RequestStatus::kRunning) {
396+
stopped_running_reqs.insert(request);
397+
} else {
398+
stopped_preempted_reqs.insert(request);
399+
}
400+
}
401+
402+
// DEFERRED: sample logprobs / prompt logprobs / num_nans_in_logits.
403+
404+
// Emit an EngineCoreOutput only when the request produced tokens or finished
405+
// (upstream's `if new_token_ids or ... or stopped`). A partial-prefill
406+
// request that produced neither is skipped: "EngineCore returns no partial
407+
// prefill outputs".
408+
if (!new_token_ids.empty() || stopped) {
409+
EngineCoreOutput out;
410+
out.request_id = req_id;
411+
out.new_token_ids = new_token_ids;
412+
out.finish_reason = finish_reason;
413+
// stop_reason is int|str|None upstream; our EngineCoreOutput carries an
414+
// optional<string> (see engine/types.h). Only a stop_token_ids match sets
415+
// request.stop_reason at T0 — stringify that token id; otherwise nullopt.
416+
if (request->stop_reason.has_value()) {
417+
out.stop_reason = std::to_string(*request->stop_reason);
418+
}
419+
outputs.push_back(std::move(out));
420+
}
421+
}
422+
423+
// Remove the stopped requests from the running list and the waiting queue.
424+
if (!stopped_running_reqs.empty()) {
425+
running.erase(
426+
std::remove_if(running.begin(), running.end(),
427+
[&](Request* r) {
428+
return stopped_running_reqs.count(r) > 0;
429+
}),
430+
running.end());
431+
}
432+
if (!stopped_preempted_reqs.empty()) {
433+
// Rare (a stopped-while-preempted request); remove each from waiting.
434+
std::vector<Request*> to_remove(stopped_preempted_reqs.begin(),
435+
stopped_preempted_reqs.end());
436+
waiting->remove_requests(to_remove);
437+
}
438+
// Now that no queue references them, drop the owning entries (destroys the
439+
// finished Request objects — upstream _free_blocks' `del self.requests[...]`).
440+
for (const std::string& id : finished_ids_to_erase) {
441+
requests.erase(id);
442+
}
443+
444+
EngineCoreOutputs engine_core_outputs;
445+
engine_core_outputs.outputs = std::move(outputs);
446+
return engine_core_outputs;
447+
}
448+
319449
CachedRequestData Scheduler::make_cached_request_data(
320450
const std::vector<Request*>& running_reqs,
321451
const std::vector<Request*>& resumed_reqs,

src/vllm/v1/core/sched/utils.cpp

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Ported from: vllm/v1/core/sched/utils.py @ e24d1b24
2+
// See include/vllm/v1/core/sched/utils.h for the T0 scope + the (verified) stop
3+
// precedence.
4+
#include "vllm/v1/core/sched/utils.h"
5+
6+
#include <cassert>
7+
8+
#include "vllm/sampling_params.h"
9+
10+
namespace vllm::v1 {
11+
12+
bool check_stop(Request& request, int max_model_len) {
13+
// assert not request.pooling_params — pooling is deferred at T0, so there is
14+
// nothing to assert against here.
15+
const SamplingParams& sampling_params = request.sampling_params;
16+
17+
// (1) min_tokens gate — FIRST at this pin, so it gates the length cap too:
18+
// do not stop before at least min_tokens have been generated.
19+
if (request.NumOutputTokens() < sampling_params.min_tokens) {
20+
return false;
21+
}
22+
23+
const int last_token_id = request.output_token_ids.back();
24+
25+
// (2) EOS. Upstream compares against sampling_params.eos_token_id, which is a
26+
// *property* returning None when ignore_eos is set (sampling_params.py only
27+
// assigns _eos_token_id `if not self.ignore_eos`). Our SamplingParams stores
28+
// the raw eos id plus a separate ignore_eos flag, so we replicate the
29+
// property's ignore_eos gate here — behaviorally identical to the pin.
30+
if (!sampling_params.ignore_eos && sampling_params.eos_token_id.has_value() &&
31+
last_token_id == *sampling_params.eos_token_id) {
32+
request.status = RequestStatus::kFinishedStopped;
33+
return true;
34+
}
35+
36+
// (3) stop_token_ids. On a match, carry the matched token id as stop_reason
37+
// (upstream `request.stop_reason = last_token_id`).
38+
for (const int32_t stop_token_id : sampling_params.stop_token_ids) {
39+
if (last_token_id == stop_token_id) {
40+
request.status = RequestStatus::kFinishedStopped;
41+
request.stop_reason = last_token_id;
42+
return true;
43+
}
44+
}
45+
46+
// (4) length cap. request.max_tokens == sampling_params.max_tokens upstream
47+
// (Request.__init__ asserts it is not None for sampling requests), so assert
48+
// it here too rather than inventing a fallback.
49+
assert(sampling_params.max_tokens.has_value() &&
50+
"sampling request must have max_tokens set (Request.max_tokens)");
51+
if (request.NumTokens() >= max_model_len ||
52+
request.NumOutputTokens() >= *sampling_params.max_tokens) {
53+
request.status = RequestStatus::kFinishedLengthCapped;
54+
return true;
55+
}
56+
57+
// (5) repetition detection — DEFERRED (sampling_params.repetition_detection is
58+
// a deferred field). Upstream would check check_sequence_repetition here.
59+
60+
return false;
61+
}
62+
63+
} // namespace vllm::v1

0 commit comments

Comments
 (0)