Skip to content

[Scheduler] Separate request lifecycle from scheduled work - #137

Open
LyxWxj wants to merge 8 commits into
InferMatrixLab:mainfrom
LyxWxj:refactor/stage-workkey
Open

LyxWxj wants to merge 8 commits into
InferMatrixLab:mainfrom
LyxWxj:refactor/stage-workkey

Conversation

@LyxWxj

@LyxWxj LyxWxj commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Required human contributor answers

1. What outcome are you trying to achieve?

I want to separate request lifecycle, scheduled work, and model execution. StagePhase currently carries several of these meanings, which makes scheduling and execution ownership harder to follow and extend.

2. What is the highest-leverage variable for that outcome?

The highest-leverage variable is how the scheduler represents and selects pending work. Explicit WorkKey values let the scheduler identify work independently from request lifecycle (RequestStatus) and model operations (Operation).

3. How will this work move that variable rather than create busywork?

This change introduces lifecycle-only RequestStatus and seven valid WorkKey values while keeping Operation as the model-call boundary. Scheduler queues and immutable scheduled work use WorkKey; compatible prompt and decode work can be selected into the same single-operation batch. Admission slots separately track execution-resource ownership, including asynchronous work that has logically finished but has not retired.

4. Who is the customer or user, and what do they actually need?

The primary users are contributors maintaining or extending the vllm-rlt scheduler and engine. They need clear ownership of request lifecycle, pending work, and occupied execution resources, plus a reliable way to batch compatible prompt and decode work.

PR necessity and bugfix evidence

A separate PR is needed because the splite-prefill and prefill-decode mixed batching are already in main, while the Stage/WorkKey redesign is not. This PR isolates that scheduler and execution-model change for review as a follow up for #118 .

This is primarily an architecture and batching capability change, not a report of one production incident. The tests cover scheduler selection, mixed execution, preemption, asynchronous speculative work, and PD paths; no single production failure is claimed as the PR's justification.

Purpose

This PR separates request lifecycle from scheduled work and model execution:

  • RequestStatus represents WAITING, RECEIVING, TRANSFERRING, ACTIVE, and FINISHED.
  • WorkKey represents the seven supported pending work types: atomic prompt, split prompt prelude/recurrent, decode prelude/recurrent/coda, and speculative work.
  • Operation remains the model execution boundary: PRELUDE, RECURRENT, or CODA.
  • Scheduler queues are keyed by WorkKey. A request can have at most one pending work item, and scheduled batches and asynchronous tickets preserve the selected work identity.
  • admission_slots tracks execution-resource ownership independently of lifecycle status. activate_request() centralizes activation and slot reservation for admission, PD activation, and preemption resume.
  • Mixed scheduling can combine compatible prompt and decode prelude work, or prompt and decode recurrent work, while keeping each model batch bound to one Operation. Decode readiness and fairness decisions use exact decode work queues.
  • Preemption, asynchronous speculative execution, PD, engine progress, serving tests, benchmark traces, and scheduler documentation are migrated to the new work identity.

Atomic prefill remains the default. Mixed scheduling requires split prefill and does not support speculative work. Each request still has at most one queued WorkKey; this change provides the scheduler basis for WCPB but does not add multiple concurrent prompt tasks per request.

Test Plan

vllm-rlt Version / Commit: base main 4a350344027960a48384626aa646f1765fda6d4f;

  • Run the A100-supported GPU-marked suite with two GPUs reserved through canhazgpu. FlashAttention-specific tests and the listed FA4/UVA patterns are excluded from this run; FA4 is unsupported on the available A100 SM80 device.
  • Run PD prefix-reuse tests with two GPUs, the split-prefill decode-graph test, and the KV memory-plan recreation test separately.
  • Run Ruff and git diff --check on the changed files.
  • GSM8K accuracy and performance comparisons were not run for this scheduler refactor.

Test Result

  • A100-supported GPU suite: 270 passed, 1 skipped, 1 failed, 843 deselected in 241.91 seconds.
  • The remaining suite failure is tests/test_cdb_runtime.py::test_cuda_memory_plan_reclaims_allocator_residue_on_recreation: within the suite, the recreated KV budget differed by 190,840,832 bytes, exceeding the test's 16 MiB tolerance. The same test passed in isolation (1 passed in 1.70 seconds); the suite-level variance remains unresolved.
  • PD prefix reuse with two reserved GPUs: 6 passed in 40.26 seconds.
  • tests/test_split_prefill.py::test_cuda_split_prefill_survives_decode_graph_capture_and_replay: 1 passed in 1.82 seconds.
  • python -m ruff check tests/test_split_prefill.py and git diff --check passed.
  • The full CPU regression suite and pre-commit run --all-files were not run on this head. The excluded FlashAttention/FA4 paths were not validated on this A100.

introduce RequestStatus for request lifecycle and WorkKey for pending work while keeping Operation as the model execution boundary.
Move scheduling to WorkKey queues with explicit admission slots, and carry work identity through execution, preemption, async speculaive and PD paths.
Update regression tests, scheduler documentation, and benchmark work traces.

Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>

# Conflicts:
#	docs/scheduler.md
#	tests/test_pd.py
#	tests/test_split_prefill.py
#	vllm_rlt/core/scheduler.py
#	vllm_rlt/engine/stage_engine.py
…sfer_connector.py, vllm_rlt/core/scheduling_policy.py, vllm_rlt/worker/executor.py

Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
@LyxWxj

LyxWxj commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

cc @bjf-frz @Carlos779988

@0z5a 0z5a left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — separating lifecycle (RequestStatus), pending work (WorkKey) and execution ownership (admission_slots) makes the scheduler much easier to follow. I checked the refactor against its base 4a35034 with CPU probes and on an RTX 5090.

Equivalence inside the diff looks good. For refill-atomic, no-refill, split-refill, priority + preemption (atomic and split) and synchronous speculation (atomic and split), the per-step batch composition is identical to the base, and so are the outputs. On every step of sync, CPU-async, mixed, preemption and random-abort runs, these invariants held:

  • len(admission_slots) <= max_num_seqs
  • every ACTIVE request holds a slot
  • every slot belongs to a live ACTIVE, TRANSFERRING or FINISHED request
  • at most one pending WorkKey per request
  • WAITING requests are only in waiting
  • no slots or blocks remain at drain.

On the RTX 5090 (Triton), the GPU-marked suite at this head gives 245 passed, 53 failed, 24 skipped. The failures are 46 FlashAttention cases (both flash_attn and flash_attn_4 report that paged FA4 is not supported on SM120), 6 PD cases that need a second GPU, and test_cuda_memory_plan_reclaims_allocator_residue_on_recreation. That is the same environmental set I see on main-based code without this PR. CPU-async in the probes does not create AsyncState and does not use UVA or graphs, so the GPU async paths are only covered by the GPU suite.

Blocker: rebase onto main (#52, #128, #141)

vllm_rlt/core/scheduler.py and vllm_rlt/engine/preemption.py conflict with #52 ("yield execution slots without offloading resident KV"). The two designs fit together: #52's "yield the execution slot, keep KV" is exactly what admission_slots + park_waiting can express. However, three assumptions in this PR contradict main and will break after a naive resolution:

  1. engine/preemption.py suspend() raises when pending_work(victim) is None. On main, capacity preemption may pick a victim that is already WAITING with resident KV (a priority victim that kept its KV) and only offloads it. PreemptionManager.suspend then reuses the existing snapshot without syncing, rewinding, suspending the runner or counting a preemption. So:

    • when a snapshot exists, take work, hidden and token from it;
    • call pending_work / _rewind_split_prefill / model_runner.suspend / preemptions += 1 only on the first suspension;
    • port main's kv is None resume path.
  2. _is_preemption_candidate requires ACTIVE + slot + pending work. That rejects main's WAITING-but-resident victims. Here is the failure:

    • A high-priority arrival yields a low-priority victim, which keeps its KV.
    • The arrival's allocation then fails.
    • preempt(..., waiting_only=True) finds no candidate, so the arrival is deferred.
    • The resident victim resumes into the freed slot: priority inversion, and possibly ping-pong.

    The predicate needs a second case — WAITING with an allocation, a snapshot and not priority_only — plus main's waiting_only filter.

  3. preempt() always calls park_waiting. For an already-WAITING victim this moves it to the tail of waiting; main returns early to preserve its queue position.

Main also changed the following, which need porting:

  • the _admit early returns, expressed with has_admission_capacity()
  • the retry loops in _try_resume, in the admission commit and in _ensure_execution_capacity
  • the relaxed speculative + preemption check in stage_engine.

Two places auto-merge without a conflict marker but break:

  • tests/test_speculative.py (main's new test) asserts suspended.phase == RequestPhase.WAITING.
  • benchmarks/preemption.py counts request.phase.value == "waiting".

Main's new speculative-preemption tests need to pass after the rebase:

  • test_priority_preempts_only_between_speculative_rounds_and_resumes
  • test_speculative_preemption_preserves_request_rng
  • test_abort_suspended_speculative_request_allows_id_reuse

The A100 results in the description predate #52/#128/#141, so they do not cover the merged state.

Should fix

  1. MixedPolicy now co-batches PRELUDE work. PRELUDE_WORKS groups DECODE_PRELUDE with PROMPT_PRELUDE, so prompt chunks join top-priority decode-prelude batches. That happens before admission and before the decode_due check, and record_batch counts these batches as prefill.
    • On CPU the outputs are correct.
    • No test asserts that such a batch forms: the mixed test only requires len(works) == 2 somewhere.
    • Under async scheduling, mixed prelude batches skip the AsyncState.prepare routing in model_runner.py, which before this PR only ran for RECURRENT.
    • Please add a test that asserts a DECODE_PRELUDE + PROMPT_PRELUDE batch, covering CUDA, async and graphs, plus a boundary-KV model if mixed mode is allowed there.
  2. MixedPolicy ordering changed. The old Mixed policy took the mixed recurrent batch first whenever both recurrent queues were ready. The new one inherits refill order: DECODE_PRELUDE, then CODA, then decode_due, and only then mixed recurrent.
    • The batch composition differs from the base. In one of four seeds, mixed recurrent batches went from 39 to 35, CODA batches from 26 to 36, and steps from 119 to 131; the other seeds improved slightly.
    • tests/test_mixed_execution.py now clears the other queues to get the old selection, which suggests the test was adapted to the new order rather than kept.
    • prefer_recurrent in stage_engine.py also now fires for mixed in-flight batches.
    • Please either restore the old priority, or document it and add a throughput/GSM8K comparison for mixed mode.
  3. Docs. docs/scheduler.md still says Mixed combines only DECODE_RECURRENT + PROMPT_RECURRENT and that "PRELUDE and CODA are selected separately". The MixedPolicy row still describes the old rule. The SVGs (loop-scheduler.svg, stage-components.svg and others) and docs/unified_execution.md still say "phase queues", "take_phases" or "saved phase".
  4. A behavior change is folded into a test migration. tests/test_transfer_connector.py now calls _admit() before the export is suspended or acknowledged.
    • Previously, a never-admitted export cancelled into TRANSFERRING counted against max_num_seqs. Now it does not.
    • That is probably a fix, but the original "unsubmitted export" scenario is no longer tested and the description doesn't mention it.
    • Related: pd/connector.py uses not has_admission_slot for both "released after the final staging read" and "never admitted". This is not reachable today, but it is fragile.

Nits

  • Quadratic per-step cost. pending_work / remove_pending_work scan all seven deques with in, and enqueue_work calls them on every enqueue. Re-enqueueing a full batch costs about 0.22 ms at 256 requests and 2.7 ms at 1024. A dict[request_id, WorkKey] index makes it O(1).
  • Selection. In the selection loop, if request.request_id in selected: continue drops the queue entry without re-appending it. It is dead under the uniqueness invariant, but it would strand a request silently if the invariant ever broke. Assert or re-append instead.
  • Weakened assertion. In tests/test_scheduler_selection.py, batch.requests[0].work == WorkKey.PROMPT_ATOMIC replaced the meaningful "a was not moved to WAITING" check. Assert status == ACTIVE.
  • Public API. vllm_rlt.request no longer exports RequestPhase, Operation, ExecutionBatch or StateSpan, with no aliases. Also, benchmarks/cdb_runtime.py trace keys changed from stage to works. Worth a note for downstream users.
  • tests/test_engine.py. drain() uses next(iter(batch.works)), which is arbitrary for mixed batches.

Pre-existing, not from this PR

Split prefill + policy="priority" + enable_preemption can livelock. I saw about 1958 consecutive PROMPT_PRELUDE batches with four requests stuck in prompt recurrent. The behaviour is identical on the base, this PR and main.

Repro: CacheConfig(40, 2, incremental_allocation=True), max_num_seqs=4, max_num_batched_tokens=6, prefill_chunk_size=4, atomic_prefill=False. I can open a separate issue if that helps.

Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
Signed-off-by: LyxWxj <yuxuanlou621@foxmail.com>
@LyxWxj

LyxWxj commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

@0z5a
Thanks for the detailed review. I addressed the remaining findings in this update.

  • Preemption now preserves and reuses an existing resident snapshot when a WAITING request is offloaded later. The candidate check accepts WAITING requests with resident KV and a snapshot for capacity reclamation, and re-preempting a WAITING request preserves its queue position. I also ported the admission and capacity retry paths and added regression coverage.
  • MixedPolicy again prioritizes compatible recurrent work when both queues are ready. I added coverage for mixed PRELUDE batches, including async scheduling and CUDA Graph execution, and updated the scheduler docs. Huginn rejects staged prefill, and its existing rejection test covers that boundary.
  • The unsubmitted-export case now has its own regression test. An unadmitted export does not consume a compute slot, and acknowledgement alone does not retire it.
  • Pending work lookup now uses a request-to-WorkKey index. Duplicate selection raises explicitly, and the weakened ACTIVE assertion and nondeterministic mixed-batch trace helper were corrected.
  • The Python API and CDB trace schema changes are documented, including the stage to works trace-key change.

Validation on the rebased revision:

  • Focused CPU regression tests: 227 passed, 86 skipped; Huginn tests: 16 passed, 3 skipped.
  • Focused GPU tests on an A100: 53 passed. FA4 cases were excluded because this SM80 device does not support FA4.
  • Ruff check and formatting pass for the changed Python files.

The priority + split-prefill livelock remains outside this change.

@LyxWxj
LyxWxj marked this pull request as ready for review October 26, 2026 11:35
@0z5a

0z5a commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Following up with the complete reproduction configuration, and an update from rerunning the original workload today.

The historical livelock is reproducible on the original base and reviewed head, but this same workload drains on today's main and rebased PR head. It should not be treated as an outstanding regression of the current #137 revision on the strength of my earlier comment.

Version boundary and fresh results

All runs below are CPU, FP32, attention_backend="torch", Python 3.13.18, PyTorch 2.14.0+cu130, one CPU thread, and CUDA_VISIBLE_DEVICES="". No downloaded checkpoint, GPU kernel, CUDA Graph, async scheduling or self-speculation is needed. The model is the small randomly initialized Ouro architecture shown in the script.

Source Prefill configuration Result
Original base 4a350344027960a48384626aa646f1765fda6d4f ExecutionConfig(atomic_prefill=False) At 2001 steps: 2/10 finished, 8 still registered; livelock reproduced
Previously reviewed #137 17c13dbe1802fa7eeda9bb9c8151e83e441627c3 same Identical batch-label sequence and final progress state
Current main 6b164d3844d35ab8e753df1864f0c87dc180401a SchedulerConfig(wavefront_chunked_prefill=True) 10/10 finished in 178 steps; 16 preemptions / 16 resumptions; 0 occupied KV blocks at drain
Current #137 964628b53a565800daf6f812da077cff3c52b9aa same current API 10/10 finished in 178 steps; same schedule, outputs and cleanup as current main

The current API has removed ExecutionConfig.atomic_prefill; the script explicitly uses the current wavefront staged-prefill option when that field is absent. This is an API/implementation boundary, not a claim that old split prefill and current WCPB have identical scheduling semantics. I have not bisected the intervening changes or established a general livelock fix.

Historical failing batch-label MD5: 9e003585fa02dbf0476bcfff9433efc9. Current main/head MD5: 9fe72a3a70aeaefee5eb1e33d8947bf3. The hash is over " ".join(sequence) and is only a compact schedule comparison, not an output-correctness oracle.

Exact trigger configuration

  • Cache: CacheConfig(num_blocks=40, block_size=2, layout="last_exited", incremental_allocation=True); prefix caching off; watermark 0.
  • Scheduler: refill mode, priority policy, preemption enabled, max_num_seqs=4, max_num_batched_tokens=6, prefill_chunk_size=4; other scheduler settings at their revision's defaults.
  • Original execution: split prefill, synchronous scheduling, no CUDA Graphs, no speculative runner.
  • Model: vocab 64, hidden size 32, intermediate size 64, 2 layers, 4 query heads, 2 KV heads, head dimension 8, max positions 128, 4 recurrent loops, BOS/EOS 0. Parameters remain FP32 on CPU.
  • Seeds: torch.manual_seed(123) for weights; random.seed(2) for the workload.
  • Sampling: greedy/default temperature 0, ignore_eos=True, max_loops=4, exit_threshold=0.6; each request's output limit and priority are below. Smaller priority values win.
  • Submit requests at the start of the listed zero-based engine step, preserving listed order for simultaneous arrivals. All arrivals are finished by step 18; this is a finite ten-request workload, not an unbounded stream of high-priority arrivals.
Arrival step ID Exact prompt token IDs max_tokens priority
0 r0 [6] 4 1
4 r1 [24, 54] 4 0
5 r2 [48, 52, 43] 6 1
6 r3 [17, 39, 14, 39, 3] 3 1
8 r4 [44, 11, 28, 41, 26, 52, 47, 56, 33, 24] 4 1
8 r5 [29, 33, 18, 58, 3, 56, 2, 24, 30] 6 2
9 r6 [59, 25, 28, 58, 57, 34] 2 1
14 r7 [36, 12, 16] 3 2
18 r8 [2, 12, 21, 12] 6 0
18 r9 [33, 33, 24] 3 2

What actually repeats on the historical revision

The last non-prelude batch is DECODE_PRELUDE for r2 at step 49. From step 50 through step 2000 there are 1951 consecutive PROMPT_PRELUDE batches. I should correct the wording in my original review: 1958 is the total number of PROMPT_PRELUDE batches, including seven earlier batches; it is not the length of the final consecutive suffix.

A representative trace is:

step 50: PROMPT_PRELUDE r6 start=4 count=2   preemptions=2 resumptions=1
step 51: PROMPT_PRELUDE r7 start=0 count=3   preemptions=2 resumptions=1
step 52: PROMPT_PRELUDE r9 start=0 count=3   preemptions=3 resumptions=1
step 53: PROMPT_PRELUDE r7 start=0 count=3   preemptions=4 resumptions=2
step 54: PROMPT_PRELUDE r9 start=0 count=3   preemptions=5 resumptions=3
step 55: PROMPT_PRELUDE r7 start=0 count=3   preemptions=6 resumptions=4
... r7/r9 continue alternating the same prompt interval ...

At the cap, r0 and r1 have finished. r3, r6, r8, r9 have pending prompt recurrent work, with fully prefilled-token frontiers 0, 4, 1, 0; r2, r4, r5, r7 are waiting. r2 already produced five of its six requested tokens. The counters reach 1951 preemptions and 1949 resumptions. The engine continues returning from step() and executing selected work, but cannot advance the pending recurrent work to completion. This distinguishes it from a blocked CUDA call or a test timeout without progress evidence.

The observed cycle is repeated preemption/resumption and replay of the same split-prefill interval, with prelude work continually selected ahead of the ready prompt recurrent work. These observations explain the failure mode; they do not establish a minimal general-purpose scheduler fix.

Controls on the old #137 head, changing one option at a time while preserving the same seed-generated arrivals:

Change Finished Engine steps Preemptions
Atomic prefill 10/10 110 5
Preemption disabled 10/10 182 0
Cache increased from 40 to 128 blocks 10/10 171 1

Self-contained bounded reproducer

Save the following as livelock_exact.py outside the checkout. Select one of the exact revisions above and run:

CUDA_VISIBLE_DEVICES="" OMP_NUM_THREADS=1 PYTHONPATH="$PWD"   timeout 120 python /absolute/path/livelock_exact.py > livelock-result.json

It intentionally stops after 2001 engine steps, emits arrivals, batch rows, progress checkpoints, preemption counters and final state as JSON, and closes the engine in finally. Exit 0 means the diagnostic completed; inspect steps, finished, prelude_suffix and the state rather than treating that exit status as a liveness pass. The three controls are --atomic, --no-preemption, and --blocks 128.

Complete script
"""Original seed-2 workload, with current API compatibility and bounded diagnostics."""
import argparse
import collections
import hashlib
import json
import random
import sys
import torch
from vllm_rlt import SamplingParams, SchedulerConfig, CacheConfig, ExecutionConfig
from vllm_rlt.engine.stage_engine import StageEngine
from vllm_rlt.models import OuroForCausalLM, OuroConfig

p = argparse.ArgumentParser()
p.add_argument('--blocks', type=int, default=40)
p.add_argument('--atomic', action='store_true')
p.add_argument('--no-preemption', action='store_true')
p.add_argument('--policy', default='priority')
p.add_argument('--steps', type=int, default=2001)
a = p.parse_args()
torch.set_num_threads(1)
torch.manual_seed(123)
random.seed(2)
cfg = OuroConfig(vocab_size=64,hidden_size=32,intermediate_size=64,num_hidden_layers=2,num_attention_heads=4,num_key_value_heads=2,head_dim=8,max_position_embeddings=128,total_ut_steps=4,bos_token_id=0,eos_token_id=0)
sc = dict(mode='refill', policy=a.policy, enable_preemption=not a.no_preemption, max_num_seqs=4,max_num_batched_tokens=6,prefill_chunk_size=4)
ec = {}
if 'atomic_prefill' in ExecutionConfig.__dataclass_fields__:
    ec['atomic_prefill'] = a.atomic
else:
    sc['wavefront_chunked_prefill'] = not a.atomic

def label(b):
    if hasattr(b,'works'): return '+'.join(sorted(w.name for w in b.works))
    m = {'prefilling_prelude':'PROMPT_PRELUDE','prefilling_recurrent':'PROMPT_RECURRENT','prelude':'DECODE_PRELUDE','recurrent':'DECODE_RECURRENT','coda':'DECODE_CODA','prefill':'PROMPT_ATOMIC'}
    return '+'.join(sorted({m[(i.phase or b.phase).value] for i in b.requests}))

def state(e):
    ans=[]
    for rid,q in e.scheduler.requests.items():
        work=e.scheduler.pending_work(q).name if hasattr(e.scheduler,'pending_work') and e.scheduler.pending_work(q) is not None else str(getattr(q,'phase',None))
        ans.append(dict(id=rid,status=str(getattr(q,'status',getattr(q,'phase',None))),work=work,prefilled=q.num_prefilled_tokens,preluded=q.num_preluded_tokens,generated=len(q.generated_token_ids),ranges=[x[:] for x in q.prefill_depth_ranges]))
    return ans

e=StageEngine(OuroForCausalLM(cfg), attention_backend='torch',cache_config=CacheConfig(a.blocks,2,incremental_allocation=True),scheduler_config=SchedulerConfig(**sc),execution_config=ExecutionConfig(**ec))
seq=[];trace=[];arrivals=[];finished={};step=0;checks={}
try:
    prompts=[[random.randrange(1,60) for _ in range(random.randrange(1,12))] for _ in range(10)]
    pending=list(enumerate(prompts))
    while (pending or e.has_unfinished_requests()) and step<a.steps:
        while pending and random.random()<0.5:
            i,tokens=pending.pop(0)
            params=SamplingParams(max_tokens=random.randrange(1,7),ignore_eos=True,priority=random.randrange(0,3),max_loops=4,exit_threshold=0.6)
            arrivals.append(dict(step=step,id=f'r{i}',prompt=tokens,max_tokens=params.max_tokens,priority=params.priority))
            e.add_request(f'r{i}',tokens,params)
        if not e.has_unfinished_requests():continue
        for out in e.step():
            if out.finished:finished[out.request_id]=out.token_ids
        b=e.last_schedule
        if b is not None:
            seq.append(label(b))
            trace.append(dict(step=step,work=seq[-1],rows=[dict(id=i.request.request_id,start=i.token_start,count=i.token_count,depth=i.depth) for i in b.requests],preemptions=e.preemption.preemptions if e.preemption else 0,resumptions=e.preemption.resumptions if e.preemption else 0))
        step+=1
        if step in [50,100,200,1000,2000,2001]:checks[step]=state(e)
    suffix=0
    for item in reversed(seq):
        if item!='PROMPT_PRELUDE':break
        suffix+=1
    print(json.dumps(dict(python=sys.version,torch=torch.__version__,args=vars(a),scheduler=sc,execution=ec,arrivals=arrivals,steps=step,finished=finished,state=state(e),checkpoints=checks,counts=dict(collections.Counter(seq)),sequence_md5=hashlib.md5(' '.join(seq).encode()).hexdigest(),prelude_suffix=suffix,preemptions=e.preemption.preemptions if e.preemption else 0,resumptions=e.preemption.resumptions if e.preemption else 0,blocks=e.cache_manager.num_used_blocks,trace=trace)))
finally:e.close()

This follow-up is a CPU scheduler reproduction and current-head non-reproduction of the same workload. It is not GPU serving, throughput, async, graph or general preemption qualification.

@LyxWxj

LyxWxj commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

@0z5a Thanks for the complete reproduction details. I reran the original workload and also inspected the behavior change introduced by 51479a8 from #144 .

1. The original workload no longer reproduces

The historical livelock is reproducible on:

Both revisions reach 2001 engine steps with only 2/10 requests finished and a long PROMPT_PRELUDE suffix.

The same workload drains on:

Both current revisions finish 10/10 requests in 178 steps, with 16 preemptions, 16 resumptions, and zero occupied KV blocks at drain.

Therefore, the original report is no longer reproducible as a regression of the current #137 head.

2. What changed in 51479a8

The important change is not just a small execution optimization. The commit changes which scheduling path represents “split prefill”.

Before 51479a8

The scheduler and runner were controlled by ExecutionConfig.atomic_prefill.

With:

ExecutionConfig(atomic_prefill=False)

the engine entered the split-prefill path even when WCPB was disabled:

atomic_prefill=False
  -> PROMPT_PRELUDE / PROMPT_RECURRENT
  -> SplitPrefillPolicy
  -> prelude-first refill scheduling

In particular:

  • Scheduler._enter_prefill() enqueued PREFILLING_PRELUDE.
  • create_scheduling_policy() selected SplitPrefillPolicy whenever atomic_prefill=False.
  • ModelRunner inferred whether split prefill was active directly from ExecutionConfig.
  • build_execution_batch() inferred the recurrent loop count from the phase rather than from an explicit scheduler decision.

Thus the old reviewer workload was exercising the non-WCPB split-prefill path.

After 51479a8

ExecutionConfig.atomic_prefill was removed as the user-facing scheduling switch.

The staged-prefill decision is now derived from scheduler requirements:

staged_prefill =
    wavefront_chunked_prefill
    or (mode == "mixed" and produces_tokens)

Consequently:

mode=refill, WCPB disabled
  -> ordinary PROMPT_ATOMIC
  -> all recurrent depths of the prompt chunk execute in one submission

while:

mode=refill, WCPB enabled
  -> staged PROMPT_PRELUDE / PROMPT_RECURRENT
  -> explicit wavefront scheduling

The commit also introduces SchedulerOutput.loop_count. The scheduler now determines the actual recurrent boundary for each submitted batch, and that value is carried through:

Scheduler
  -> SchedulerOutput.loop_count
  -> ExecutionBatch.loop_count
  -> ModelRunner execution
  -> Scheduler progress update

The relevant behavior is:

ordinary atomic prompt chunk:
  loop_count = total_ut_steps

WCPB prompt prelude:
  loop_count = 0

WCPB prompt recurrent:
  loop_count = 1

mixed prompt/decode recurrent batch:
  loop_count = 1 when a depth boundary is required

This removes the previous ambiguity where the scheduler selected work using one notion of a boundary while the executor inferred another one from the phase.

The progress update was changed accordingly. The scheduler now applies the actual loop_count when updating depth ranges, deciding whether a prompt chunk completed, and publishing the final prompt frontier. WCPB additionally represents prompt work as explicit depth/chunk spans and retains staged hidden state in its wavefront window.

3. Why the old workload disappears after this change

The old workload specifically depended on:

atomic_prefill=False
+ refill mode
+ WCPB disabled
+ split-prefill prelude-first scheduling

That exact configuration no longer exists after 51479a8.

The compatibility logic in the reproduction script makes this boundary explicit:

if "atomic_prefill" in ExecutionConfig.__dataclass_fields__:
    ec["atomic_prefill"] = a.atomic
else:
    sc["wavefront_chunked_prefill"] = not a.atomic

Therefore:

  • on the old revisions, --atomic absent means atomic_prefill=False, i.e. the old non-WCPB split path;
  • on the current revisions, --atomic absent means WCPB is enabled;
  • on the current revisions, WCPB disabled means ordinary atomic prefill, not the old split-prefill behavior.

So the current result is not evidence that old split prefill and current WCPB have identical semantics. They are different scheduling implementations.

For the original seed-2 workload, 51479a8 and the subsequent scheduler changes therefore remove the exact old cycle in two ways:

  1. The old non-WCPB split path is no longer selected by default.
  2. When staged execution is requested through WCPB, prompt chunks are represented as explicit wavefront depth work, with scheduler-derived loop boundaries and depth-aware progress updates.

The resulting schedule is materially different. The historical batch-label hash was:

9e003585fa02dbf0476bcfff9433efc9

whereas current main and the current PR head produce:

9fe72a3a70aeaefee5eb1e33d8947bf3

The current schedule advances recurrent prompt work and drains in 178 steps instead of remaining in the historical prelude replay cycle.

This should be interpreted as fixing the old scheduling/path interaction, not as proving a general liveness theorem for all staged-prefill workloads.

4. Residual risk in the WCPB path

I also tested whether the remaining issue only appears with an artificial configuration such as:

max_num_seqs=2
max_num_batched_tokens=1
prefill_chunk_size=1

It does not.

Using the normal reported configuration:

mode=refill
policy=priority
enable_preemption=True
max_num_seqs=4
max_num_batched_tokens=6
prefill_chunk_size=4
num_blocks=40
block_size=2
incremental_allocation=True
wavefront_chunked_prefill=True

I constructed a finite 10-request workload. All requests arrive by step 11; there is no unbounded arrival stream.

On current #137 head, bounded at 500 steps, the result is:

finished: 3/10
PROMPT_PRELUDE batches: 471
PROMPT_RECURRENT batches: 11
consecutive PROMPT_PRELUDE suffix: 463
preemptions: 465
resumptions: 463

The same workload also reproduces on current main. The exact batch grouping differs slightly between the two revisions, but both remain at 3/10 completed requests.

The controls are:

refill + priority + WCPB, 40 blocks:
  500 steps, 3/10 finished

WCPB disabled:
  66 steps, 10/10 finished

128 KV blocks:
  103 steps, 10/10 finished

preemption disabled:
  113 steps, 10/10 finished

FCFS policy:
  107 steps, 10/10 finished

mixed mode + WCPB:
  98 steps, 10/10 finished

This shows that WCPB does not always livelock. The remaining risk is the interaction between:

refill-mode prelude-first ordering
+ priority admission/preemption
+ WCPB staged state
+ a particular finite capacity and arrival pattern

The relevant limitation is that WCPB round-robins requests within one work queue, but does not establish fairness between:

PROMPT_PRELUDE
and
PROMPT_RECURRENT

When a staged-prefill request is preempted, StageEngine._rewind_split_prefill() still rewinds it to the last fully prefetched-token frontier and returns it as PROMPT_PRELUDE work. If no recurrent depth has been completed for that prompt interval, the next resume can make the same prelude interval eligible again.

The resulting cycle is:

select PROMPT_PRELUDE
  -> execute staged prelude work
  -> preemption caused by admission/cache pressure
  -> rewind to the same completed frontier
  -> resume as PROMPT_PRELUDE
  -> select the same prelude work again

The WCPB window limits the amount of staged hidden state in flight. It does not limit how many times the same prelude interval may be replayed after a rewind.

Conclusion

My updated conclusion is:

  1. The original seed-2 workload is no longer reproducible after the scheduler/execution-boundary changes introduced by 51479a8 and the subsequent WCPB integration.
  2. The main behavioral change is that ordinary refill prefill is now atomic by default, while staged prefill is selected explicitly by WCPB or mixed scheduling.
  3. SchedulerOutput.loop_count makes the scheduler’s selected execution boundary explicit and keeps executor-side execution and scheduler-side progress accounting consistent.
  4. This removes the specific old non-WCPB split-prefill cycle exercised by the reviewer’s original workload.
  5. It does not establish general fairness between WCPB prelude and recurrent work.
  6. A separate finite workload can still reproduce a WCPB/refill/priority-preemption livelock with the normal max_num_seqs=4, max_num_batched_tokens=6, and prefill_chunk_size=4 configuration.

I have not bisected the secondary workload or established a minimal general-purpose scheduler fix. The likely direction for that remaining issue is an explicit progress/fairness rule between PROMPT_PRELUDE and PROMPT_RECURRENT, especially after a request is repeatedly rewound to the same prefetched frontier.

@LyxWxj

LyxWxj commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

@0z5a I wonder if it is convenient for you to open an issue to discuss it in detail or I'd be glad to : ).

@prettygirlisnotme

Copy link
Copy Markdown

@LyxWxj Could you share the script for the remaining 10-request case? I'd like to reproduce it before changing the WCPB scheduling path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants