Repository navigation
[REFACTOR](scheduler): unify wavefront selection and prefill progress - #159
Conversation
Share wavefront prefill progress handling between output and submission updates. Unify PRELUDE and RECURRENT wavefront task admission while preserving candidate queue order, token budgeting, capacity checks, and stable task ordering. Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
|
cc @prettygirlisnotme @MinhaoLi0318 I would appreciate any advice you can offer to improve this implementation and adapt it for future features, such as #156. The fastloop algorithm seems to contradict the assumption that the earlier chunk will not be completed before the later chunk of the same prompt. Since I havn't perused the paper, I may have no much idea for implementing that but I'll catch up. |
MinhaoLi0318
left a comment
There was a problem hiding this comment.
I reviewed this at b4a848a61 against 6b164d384, mostly from the token-sparse prefill side (#156). The active-row view there (window id, absolute position, active indices) will sit on the wavefront selection and prefill-progress code that this PR reshapes.
What I checked, CPU only (macOS, Python 3.12, torch 2.14.0, OMP_NUM_THREADS=1):
- Your two test-plan commands give the same counts here: 81 passed / 40 skipped, and 150 passed / 12 skipped. On base they give 80 / 40 and 150 / 12; the difference is the new test.
- Full
pytest tests: 899 passed / 430 skipped on base, 900 passed / 430 skipped on this head. - The new
test_split_prefill_progress_advances_multiple_recurrent_depths_per_submissionalso passes on base. It pins the existing non-WCPB multi-depth path; it does not cover the WCPB selection change. - I traced two 10-token prompts through WCPB (chunk size 2, budgets 3, 5 and 8) and compared every scheduled task (request, start, count, depth) on base and head. Selection, budget truncation and depth order are identical. The only difference is the row order inside PRELUDE batches (inline 1).
- Reading the two versions side by side:
- Capacity checks still run as each task is accepted, and
selectedis updated before the next candidate's_ensure_execution_capacity, so already-selected requests are still protected from preemption. - The event passed to
publish_prefixand the WCPB grouping are the same inupdate_from_outputandupdate_after_submission. _advance_prefill_depth_frontierreproduces both old frontier updates. Theloop_count <= 0early return keepsfinal_depth_reachedequivalent to the olddepth + loop_count == total_depths.
- Capacity checks still run as each task is accepted, and
From the active-row side the refactor helps. After it, _advance_prefill_depth_frontier is the only per-step writer of prefill_depth_ranges; the other writers are resets at admission and in stage_engine.py. accept_task is the only place _take_wavefront_phase charges budget and KV capacity. Here is what I expect token-sparse prefill to add on top. None of it is for this PR:
- Readiness. A sparse depth may start only on a whole window (inline 2).
- Cost.
accept_task, andtake_phasesL761 for non-WCPB split prefill, would charge the active row count for a sparse depth. That count is retention × window length, which the host knows, so no readback is needed. - Row identity. The view is keyed by (request generation, absolute window, depth), not by batch row; inline 1 shows why. Mixed batches are already decode-first, because
take_phaseswalks(RECURRENT, PREFILLING_RECURRENT)in that order. So the sort here only orders rows inside the prompt part of the batch, which fits the decode-first partition the RFC describes. - Window origin.
_make_wavefront_taskderiveschunk_startfromprefill_window_origin(L621). That origin is the prefix-cache hit,len(prefix) * block_size(L390, L457), while #156 anchors windows to absolute prompt positions. The two agree once prefix hits stop at window boundaries (the prefix-cache part of #156). Until then, the window id cannot be read offchunk_start.
Inline 3 answers your question in this thread about chunk completion order.
Inline 1 is the only change I'd ask for here: one line in the description and a test for the PRELUDE row order. Inline 2 and 3 are questions about direction.
AI assistance: drafted with an AI assistant; reviewed by me before posting.
| if phase == RequestPhase.PREFILLING_PRELUDE: | ||
| prelude_cursors[request_id] += scheduled_request.token_count | ||
| tasks.append((owner_index, scheduled_request)) | ||
| tasks.sort(key=lambda item: (item[0], item[1].token_start)) |
There was a problem hiding this comment.
This sort now applies to PRELUDE too, which changes PRELUDE row order. Before this PR, PRELUDE tasks were appended in selection order, so rows from different requests interleaved. Now they are grouped by request. Two 10-token prompts, chunk size 2, budget 8, first batch:
- base
6b164d384:a:0, b:0, a:2, b:2 - this head:
a:0, a:2, b:0, b:2
The selected spans are the same; only the order differs. I don't see anything that depends on it. build_execution_batch (worker/executor.py:26-79) and _prefill_segments (worker/model_runner.py:396-423) give each span its own segment, and prefill_submitted (pd/connector.py:186) works per request. The description says sorting affects execution-row order. Could it also say explicitly that PRELUDE rows were not sorted before? A test would pin the new order. This one fails on base and passes here:
def test_wavefront_prelude_rows_are_grouped_by_request_after_depth_major_selection():
e = make_prefill_engine(
scheduler_config=SchedulerConfig(
max_num_seqs=2,
max_num_batched_tokens=5,
prefill_chunk_size=2,
wavefront_chunked_prefill=True,
)
)
for request_id, prompt in (("a", list(range(1, 11))), ("b", list(range(11, 21)))):
e.add_request(request_id, prompt, SamplingParams(max_tokens=1, min_loops=1, max_loops=1))
prelude = e.scheduler.schedule()
assert prelude.phase is RequestPhase.PREFILLING_PRELUDE
# Selection is still depth-major (a:0-2, b:0-2, then a:2-3 under the 5-token
# budget); only the emitted row order is grouped by request.
assert [
(item.request.request_id, item.token_start, item.token_count)
for item in prelude.requests
] == [("a", 0, 2), ("a", 2, 1), ("b", 0, 2)]
e.close()There was a problem hiding this comment.
Agreed. PRELUDE previously emitted spans in selection order; this PR changes that to request-grouped, token-position order after selection. I kept the sorting to share the emission path with RECURRENT, not to introduce a new fairness rule or claim a performance benefit.
The important distinction is that selection and budget allocation are already complete before sorting, and candidate requeueing still follows the original candidate order.
I’ll explicitly document the PRELUDE ordering change and add your proposed regression test. The budget-5 case is particularly useful because it pins both the selected spans under truncation and their emitted order. You’re also right that the existing new test covers the pre-existing multi-depth progress path rather than this ordering change.
| """Advance one submitted prompt span through one recurrent depth.""" | ||
| request.prefill_depth_ranges[depth][0] = token_end | ||
| if depth + 1 < self.total_ut_steps: | ||
| request.prefill_depth_ranges[depth + 1][1] = token_end |
There was a problem hiding this comment.
A question for token-sparse prefill (#156); no change needed in this PR. This line publishes the next depth's readiness. Under budget truncation it can publish a point inside a chunk, so the next depth then runs part of a chunk. With the two prompts from inline 1 and budget 5, 10 recurrent tasks at depth 2 or 3 cover only half of their chunk, for example a, depth 2, tokens 2-3 of chunk 2-4. Base and head behave the same here. With retention [1, 1, 0.25, 0.10], depth 2 is the first sparse loop, and its top-k needs the scores of the whole window from depths 0 and 1.
I'd keep this helper logical and do the gating where tasks are built: a sparse depth gets a task only once right covers the whole window, since selection needs that window's complete scoring input. Whether the selected active rows may then be split across submissions by the budget is a separate choice. The places are _make_wavefront_task (L642), and for non-WCPB split prefill, _make_scheduled_request (L585-591) and _max_contiguous_prefill_loops (L834). Clamping here instead would leave the tokens between the clamp and token_end in no depth range, and Request.prefill_depth (request.py:89-95) would raise for them. Does that match how you want these helpers to evolve?
| boundaries[depth][0] = end | ||
| if depth + 1 < self.total_ut_steps: | ||
| boundaries[depth + 1][1] = end | ||
| if not self._advance_prefill_depth_frontier( |
There was a problem hiding this comment.
On your question in this thread, whether FlashLoop breaks the assumption about chunk completion order: as I read #156, the out-of-order check a few lines below (L1130-1132) stays valid. A sparse depth still advances the per-depth frontier over the whole logical window; only the physical rows shrink. So the final-depth frontier still passes chunk ends in prompt order, and prefill_reclaimable_tokens advances as it does today. Two things change. One is when a sparse depth may start (inline 2). The other is that the active maps and the saved old hidden rows have to stay alive until this same chunk retirement. If you had a different case in mind, could you describe it?
There was a problem hiding this comment.
Thanks, this resolves my earlier concern. I was conflating a reduction in physical model rows with a change in logical completion order. I don’t have a separate case that requires out-of-order logical chunk completion.
Provided active updates and inactive inheritance are correctly ordered, the completed logical span still advances the contiguous per-depth frontier. Fewer—or even zero—active rows in a fragment do not by themselves invalidate the existing ordered chunk-retirement check. I would therefore keep that check unchanged.
I would interpret “over the whole logical window” as accounting for all logical positions, not requiring an atomic window-sized frontier update; execution may still advance through valid fragments.
One lifetime detail for independent window/chunk boundaries: window-level maps or saved scoring inputs may be shared across several chunks. Their lifetime must cover all dependent fragments and readers, rather than being inferred solely from one chunk’s retirement. I would give that auxiliary state explicit ownership in #156, without changing this PR’s existing progress and retirement rules.
|
Thanks, I think we can keep the current progress and chunk retirement rules. Skipped tokens still inherit hidden/KV and count toward the logical span; only the model rows are compacted. Selection waits for the full window's scoring inputs, then execution can split it into budget-sized fragments. Our fixed-selection CPU prototype passed unchanged on |
|
|
||
| tasks = [] | ||
| prelude_cursors = { | ||
| request.request_id: request.num_preluded_tokens for request in candidates |
There was a problem hiding this comment.
prelude_cursors is only used by PREFILLING_PRELUDE, but the refactor now builds it for every RECURRENT batch too. Please guard its initialization with if phase == RequestPhase.PREFILLING_PRELUDE to avoid the unnecessary allocation and iteration.
bjf-frz
left a comment
There was a problem hiding this comment.
One suggestion on the PRELUDE span validation.
| raise RuntimeError("wavefront prelude contains a prompt-range gap") | ||
| if any( | ||
| previous.token_start + previous.token_count != current.token_start | ||
| for previous, current in zip(scheduled_requests, scheduled_requests[1:]) |
There was a problem hiding this comment.
Could we keep the original frontier loop here? It checks both the initial frontier and span continuity in one pass, then updates num_preluded_tokens after validation. The replacement splits the same check across the first span, adjacent pairs, and the last span, while adding a list slice through scheduled_requests[1:]. I don’t see a clarity or correctness benefit, and there’s no measured performance benefit to justify the change.
There was a problem hiding this comment.
Aggree. Rolled back to the original implementation.
Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
Validate contiguous PRELUDE spans with a single accumulated frontier and avoid the unnecessary pairwise list slice. Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
|
lgtm |
Brings in InferMatrixLab#159 (scheduler wavefront selection / prefill progress) and InferMatrixLab#160 (native CUDA SiLU-and-Mul) so the branch is measured against current main.
Description
What
This PR refactors the scheduler's wavefront selection and split-prefill progress paths.
The changes keep the existing request lifecycle, token-budget accounting, capacity checks,
prefix publication ordering, and asynchronous event propagation while removing duplicated
control flow between submission-time and output-time updates.
Changes
_take_wavefront_phase.update_from_outputandupdate_after_submissionthrough_apply_prefill_phase_progress._advance_prefill_depth_frontierfor the common one-depth frontier mutation._apply_split_prefill_progressretains its phase-specific validation and lifecycle rules._apply_wavefront_prefill_batch_progressretains its ordered-span and chunk-retirement rules.PREFILLremains a separate path._apply_prefill_progressdispatching.including frontier propagation, chunk completion, event forwarding, and prefix publication.
Scheduling semantics
The refactor does not move capacity checks or token-budget accounting out of the selection loop. In particular, PRELUDE selection remains depth-major so that a bounded budget can advance different requests at the same depth instead of allowing one request to consume all available depth work first.
Sorting of selected tasks is performed only after selection. It affects the execution-row order, not the request queue order, candidate requeue order, token-budget decisions, or fairness during selection.
Required human contributor answers
1. What outcome are you trying to achieve?
Make the scheduler's wavefront and split-prefill progress logic easier to reason about and maintain without changing the intended scheduling and lifecycle contracts.
2. What is the highest-leverage variable for that outcome?
The highest-leverage variable is the number of independent implementations of the same frontier and lifecycle transition. Reducing duplicated transition logic lowers the risk that submission and output paths diverge.
3. How will this work move that variable rather than create busywork?
The PR centralizes the shared one-depth frontier mutation and the common prefill update routing, while keeping phase-specific validation, chunk retirement, prefix publication, and queue transitions in their respective owners. The wavefront selection refactor also makes the depth-major fairness rule explicit without moving resource checks out of the selection loop.
4. Who is the customer or user, and what do they actually need?
The primary users are maintainers and runtime users of split-prefill and wavefront-prefill
scheduling. They need predictable budget/fairness behavior, correct depth frontiers, correct prefix-publication timing, and equivalent behavior across synchronous submission and asynchronous output-update paths.
PR necessity
Yes. This is a core scheduler refactor involving request selection, frontier ownership,asynchronous lifecycle updates, and prefix publication. A PR review is needed to verify that the reduced duplication does not weaken scheduling fairness or request-state invariants.
Test plan
CPU Tests
OMP_NUM_THREADS=1 python -m pytest -q tests/test_split_prefill.py tests/test_scheduler_selection.py tests/test_mixed_execution.py81 passed, 40 skippedOMP_NUM_THREADS=1 python -m pytest -q tests/test_engine.py tests/test_serving.py tests/test_stage_engine.py tests/test_submission_queue.py tests/test_engine_core_contract.py150 passed, 12 skippedgit diff --checkGPU Tests
On 2 * A100
120 passed, 1 skipped1228 passed, 70 failed, 32 skippedThe remaining full-suite failures are environment or pre-existing test issues rather than failures
in the changed scheduler path:
cuda_graph_max_batch_sizeassertion reproduces on a clean6b164d3baseline.vLLM-rlt Version/commit 6b164d3