perf(server): rotate the speculative slice slot across waiting requests#816
Merged
Merged
Conversation
The tick-slice path (issue #734, PR #745) held the worker's single speculative slot for a request's whole generation, so a second speculative-eligible request arriving mid-slice fell back to classic decode permanently and one long stream monopolized speculative acceleration (the MEDIUM QoS finding on PR #745). The slot is now granted in bounded turns. While a slice is in flight, up to 2 further tick-slice-eligible requests park in a grant backlog (`speculative_slice_backlog`, one FIFO ring of parked in-flight jobs and not-yet-started waiters); beyond that cap, or when the request cannot be served by the slice path, the pre-existing classic fallback applies unchanged. Once the active job has executed `MLXCEL_MTP_SLICE_GRANT_ROUNDS` slices (new env, default 8, 0 restores the legacy whole-generation hold including the classic fallback for concurrent requests) with the backlog non-empty, it parks at the next round boundary and the slot is granted to the next entry, priority lane first and FIFO within a lane. A parked job resumes from its saved `MtpSessionState`; a waiter runs its slice 0 as that tick's single action, so the #734 HOL bound of one model action per tick holds across rotations, and the budget binds only under contention, so an uncontended request never rotates. Drafter handoff: the park boundary routes the worker's one drafter handle through the existing `WorkerDrafterSlot::return_drafter` / `take` plumbing (the end-of-session path). This is correct for the MTP arm because `Gemma4AssistantDraftModel` does not override `Drafter::reset` (trait default no-op, so the return destroys nothing), its bind state is target-derived and recomputed identically by the promotion-time re-bind, and `step_session` re-arms the drafter's shared KV from the session's own stored `MtpVerifyOutput` at the top of every round, so rounds of another session in between cannot leak state. The `step_session` resumability contract doc is updated accordingly. Validation: `cargo check --lib --tests` and `make test-fast-cuda FILTER=batch::speculative` (pure-function tests for the grant budget env, expiry, backlog cap, and grant order; a rotation harness over the real policy functions and slice driver with one shared drafter handle pinning grant alternation, per-request stream parity with isolated runs, the uncontended no-rotation invariant, and cancelled parked-job / cancelled waiter resolution; all pre-existing slice and `slice_takes_tick` tests unchanged). Docs: rotation semantics in docs/CONTINUOUS_BATCHING.md, `MLXCEL_MTP_SLICE_GRANT_ROUNDS` in docs/environment-variables.md. Refs #746.
…tion Two review findings on the #746 slot rotation. Promotion-time verdict gap: `can_wait_for_slice_grant` admits a waiter only while `mtp_b1_should_run()` holds, but the Waiter promotion arm started speculative decode without re-checking it, so an adaptive policy (issue #333) that settled to Decline while the request waited would still get a speculative slice, contradicting the doc comments that claimed promotion-time handling. The Waiter arm now re-checks `mtp_b1_should_run()` before `start_mtp_slice_b1` and routes a then-declined waiter through the new `route_declined_slice_waiter_to_classic` helper (shared with the start's own decline path); the comments now describe the actual behavior. The scheduler-side branch has no direct unit test because a full `BatchScheduler` requires a real LoadedModel (see scheduler_tests.rs); the rotation harness mirrors the protocol and pins that a declined waiter resolves with bookkeeping only, never runs a speculative slice, and does not wedge the ring. Budget off-by-one at MLXCEL_MTP_SLICE_GRANT_ROUNDS=1: an admission grant installed the active job with the counter at 1 after slice 0 but the first expiry check only ran after the next round, so an opening grant held 2 slices while a resumed parked-job grant correctly held 1. The install site in `start_mtp_slice_b1` now evaluates `slice_grant_expired` immediately and parks the job when slice 0 already spent the grant (pure bookkeeping, the slice-0 forward stays the tick's one model action, preserving the #734 HOL bound). Reachable only from waiter promotion since a direct admission always sees an empty backlog. Validation: `cargo check --lib --tests`, `cargo test --profile test-fast --features cuda --lib batch::speculative`; new harness tests `rotation_budget_one_interleaves_strictly_per_slice` (pins the strict per-slice grant schedule at budget 1) and `rotation_declined_waiter_at_promotion_routes_to_classic`; the existing budget-2 rotation pin passes unchanged. Refs #746.
Three security-review findings on the #746 slot rotation. Anti-starvation floor: strict priority-lane-first granting let a sustained stream of higher-lane arrivals starve a lower-lane parked entry indefinitely; a parked job is not in the active batch, so starving it stalls its client stream completely (losing the #745 "everyone still progresses" floor), and priorities are client-assignable via the unauthenticated X-Priority header, making the starvation remotely triggerable. `SliceBacklogEntry` is now a struct carrying a `skipped_grants` counter, `pop_next_speculative_grantee` increments it for every non-selected entry per grant decision, and `next_grant_index` takes (lane, skips) pairs: an entry skipped `MTP_SLICE_GRANT_SKIP_CAP` (2) times is overdue and must be granted next regardless of lane (most-skipped first, earliest in ring order on ties), so every entry is granted within a bounded number of grant boundaries. Lane precedence then FIFO still rules the un-escalated case; a granted or re-parked entry re-enters with a fresh counter. Per-grant budget caching: the round-tail expiry check read `MLXCEL_MTP_SLICE_GRANT_ROUNDS` every round (std ENV lock plus a String allocation on the uncontended path). The budget is now resolved once per grant at the two grant-start sites into `speculative_slice_grant_budget` and the per-round check compares against the cached value, so the uncontended per-round added cost is a counter increment plus one comparison; runtime tunability stays at the same per-request cadence as the tick-slice gate. Doc precision: the backlog field doc and CONTINUOUS_BATCHING.md now state that a park transiently pushes the ring to cap + 1 entries until the next promotion, so total live speculative KV sessions peak at 3 (active plus parked plus waiting). Validation: `cargo check --lib --tests`, `cargo test --profile test-fast --features cuda --lib batch::speculative`. New tests: pure `grant_order_escalates_overdue_entries_over_lanes` (cap reach, most-skipped tiebreak, below-cap lane order), pure `grant_order_skip_cap_bounds_starvation_under_high_lane_stream` (fresh-entry reset loop, bounded selection), and harness `rotation_skip_cap_prevents_high_lane_starvation` (two High-lane jobs rotating between themselves can no longer starve a Normal-lane parked job: bounded grant gaps plus stream parity for all three). The budget=1 and budget=2 grant-schedule pins pass unchanged (their rings never let a skip counter reach the cap). Refs #746.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The tick-slice path (issue #734, PR #745) held the worker's single speculative slot for a request's whole generation: a second speculative-eligible request arriving mid-slice fell back to classic decode permanently, so one long stream monopolized speculative acceleration (the MEDIUM QoS finding from PR #745's review). This PR implements the issue's option (a), grant-based slot rotation at round boundaries, with priority-ordered granting plus a skip-cap anti-starvation floor as a hardened slice of option (b).
Design
While a slice is in flight, up to 2 further tick-slice-eligible requests are parked in a grant backlog (
BatchScheduler::speculative_slice_backlog, one FIFO ring holding both parked in-flight jobs and not-yet-started waiters); the cap bounds a waiter's worst-case pre-first-token wait to about cap x budget speculative slices, and anything beyond it, or any request the slice path cannot serve, keeps the pre-#746 classic-decode fallback. Once the active job has executedMLXCEL_MTP_SLICE_GRANT_ROUNDSslices (new env, default 8; slice 0 counts as the first slice of an admission grant, enforced even at budget 1 via an immediate post-install expiry check; 0 disables rotation and restores the legacy whole-generation hold including the classic fallback for concurrent requests; unparseable values fall back to the default) with the backlog non-empty, it parks at the next round boundary and the slot is granted to the next entry. Grant order is priority lane first and FIFO within a lane, bounded by a skip-cap escalation: every non-selected backlog entry counts the grant decisions that skipped it, and once any entry has been skippedMTP_SLICE_GRANT_SKIP_CAP(2) times the next grant must select the most-skipped entry regardless of lane. This restores the #745 "everyone progresses" floor that strict lane precedence would otherwise lose for parked jobs (which sit outside the active batch and receive no classic decode while parked), and matters because priorities are client-assignable via the unauthenticatedX-Priorityheader; sustained higher-lane pressure now yields bounded delay instead of a stalled stream. The budget is resolved from the env once per grant (at admission install and at promotion) and cached in a scheduler field, so the uncontended per-round added cost is a counter increment plus one comparison, with no per-round env read or allocation. A parked job resumes from its savedMtpSessionStateand its round runs in the promotion tick (promotion is bookkeeping); a promoted waiter is re-checked against the adaptive B=1 policy verdict and routed to classic prefill if the policy settled to decline while it was parked, and an accepted waiter's slice 0 is that tick's single action, so the #734 HOL bound of one model action per tick holds across rotations. The expiry condition requires a non-empty backlog, so an uncontended request never rotates and behaves exactly as under #734, and a job that overran its budget while uncontended parks at the first round boundary after contention appears, so a newcomer waits at most about one round for the first handoff. Operators should note the ring transiently peaks at cap+1 entries between a park and the next promotion, and peak live speculative KV residency is up to 3 sessions (1 active + 2 parked) versus 1 before this PR.Drafter handoff decision: the park boundary routes the worker's one drafter handle through the existing
WorkerDrafterSlot::return_drafter/takeplumbing, the same path the end of a session uses, rather than a raw no-reset handle handoff. Correctness argument, verified in code: (a)Gemma4AssistantDraftModeldoes not overrideDrafter::reset, so the return's reset is the trait default no-op (drafter/mod.rs:630); it unloads no weights and unbinds nothing; (b) the drafter's bind state (captured target embedding + resolved LM head) is derived from the worker's one target model and the promotion-time re-bind recomputes it identically (and is required in the defensive case where a failed reset dropped the handle andensure_loadedreloaded from disk); (c)step_sessionre-arms the drafter's shared KV from the session's own storedMtpVerifyOutputat the top of every round (set_shared_kvis a stateless overwrite consumed only by the followingdraft_block), so rounds of another session run in between cannot leak state into a parked session. Thestep_sessionresumability contract doc (generator.rs) is amended to state this precisely.Acceptance criteria
Two long concurrent speculative-eligible requests both receive speculative rounds over the run (rotation harness test asserts both jobs' round counts match their isolated runs). The rotation behavior is pinned by a scheduler-protocol harness driving the real policy functions (
slice_grant_expired,next_grant_index) and the real slice driver with one shared drafter handle, asserting the exact grant schedules[(A,2),(B,2),(A,2),(B,1)]at budget 2 and[(A,2),(B,1),(A,1),(B,1),(A,1),(B,1)]at budget 1. The single-slice invariant and the #734 HOL bound are preserved: at most one job is active, promotions run at most one model action per tick, and all pre-existingslice_takes_tickalternation tests pass unchanged. Env-gate semantics are unchanged (MLXCEL_MTP_TICK_SLICEuntouched; the new budget env only adds rotation, and 0 restores pre-#746 behavior). Greedy parity is unchanged: the rotation tests assert each request's committed stream is byte-identical to its isolated single-request run, on top of the existing slice-vs-burst identity test.Review hardening
Three review rounds are folded into this PR. Implementation review: the waiter-promotion arm now re-checks
mtp_b1_should_run()so a waiter parked while the adaptive policy was profiling is routed to classic prefill if the policy settled to decline (previously the comments claimed this but no code enforced it), and an admission grant at budget 1 parks immediately after slice 0 instead of holding 2 slices. Security review: the skip-cap anti-starvation escalation described above (lane starvation of a parked job was client-triggerable and had no classic-progress floor), the once-per-grant cached budget read (the round tail previously took the process env lock and allocated per round on the uncontended path, contradicting the documented counter-only cost), and the documented cap+1 ring peak / 3-session KV residency bound.Tests
cargo check --lib --testsclean;cargo fmt --checkclean.make test-fast-cuda FILTER=batch::speculative(also re-run ascargo test --profile test-fast --features cuda --lib batch::speculative): 73 passed, 0 failed. New: pure-function tests for the budget env (default 8 / custom / 0 / garbage), grant expiry (binds only under contention, budget 0 unbounded), backlog cap margins (admits below 2, rejects at and beyond, rejects all under budget 0, pinning the classic fallback at the margin), grant order (priority lane first, FIFO within a lane, skip-cap escalation with most-skipped-then-earliest selection, bounded selection under an endless high-lane stream), promotion-tick alternation; rotation harness tests for slot sharing + stream parity, budget-1 strict per-slice interleave, high-lane starvation pressure with a bounded inter-grant gap for the normal-lane job, declined-waiter routing, uncontended no-rotation, cancelled parked job, and cancelled waiter. All pre-existing speculative slice/burst tests unchanged and passing.generator.rs is touched only in the
step_sessionresumability contract doc comment: clause (b) previously required the drafter be held strictly withoutDrafter::reset, which the slot rotation technically violates; the amended clause states the real requirement (no draft-relevant state destroyed) and records the verified no-op-reset fact that makes the rotation correct.Docs
docs/CONTINUOUS_BATCHING.md gains a rotation paragraph in the tick-slice section covering the grant budget, skip-cap escalation, budget read cadence, and residency bounds; docs/environment-variables.md documents
MLXCEL_MTP_SLICE_GRANT_ROUNDS.Closes #746