-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(mocker): model prefill→decode KV stranding + abort for disagg cascade tests #10557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
4539f2d
ddd9c95
4478414
8b8794d
b874918
4a44959
6d16bab
290a532
474ec7a
de8f294
0cb234e
57f324d
6c97bbb
639d2f7
70f972d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -258,6 +258,67 @@ impl MockEngine { | |
| }); | ||
| } | ||
|
|
||
| /// Wait until the scheduler at `dp_rank` reports both **block** and **sequence** | ||
| /// headroom for admitting a new request, up to `timeout`. Used by the decode side | ||
| /// of disagg before connecting to a prefill bootstrap server. | ||
| /// | ||
| /// Models real NIXL admission behavior in vLLM v1 and sglang — both engines gate | ||
| /// the disaggregated KV recv on **two independent budgets**: a block/token budget | ||
| /// (enough KV-cache blocks to absorb the projected sequence) and a sequence-slot | ||
| /// budget (a free slot in the per-request scheduling state — vLLM `max_num_seqs` / | ||
| /// `len(self.running)`; sglang's `DecodeReqToTokenPool`). Both must have headroom; | ||
| /// either alone blocks the NIXL recv from being armed. Without the seq-slot check | ||
| /// the prefill-side abort path is unreachable from realistic seq-bound load shapes. | ||
| /// | ||
| /// Returns Ok(()) immediately if schedulers haven't reported metrics yet | ||
| /// (total_blocks == 0) so warmup is graceful. Also treats `max_num_seqs == 0` | ||
| /// as "no seq cap configured" (mirrors `MockEngineArgs.max_num_seqs == None`). | ||
| /// | ||
| /// **No timeout on this side.** Real vLLM and sglang decode workers do | ||
| /// not run an independent "give up waiting for capacity" timer — a | ||
| /// WAITING_FOR_REMOTE_KVS request stays WAITING until the scheduler admits it, the | ||
| /// request context is dropped, or the prefill side's `kv_transfer_abort_timeout_ms` | ||
| /// fires and closes the bootstrap room (which surfaces to the decode side later when | ||
| /// it attempts `connect_to_prefill(room_id)` — that connect then fails with a | ||
| /// closed-room error). Cancellation is delegated to the surrounding request context. | ||
| pub async fn wait_for_decode_kv_capacity(&self, dp_rank: u32) -> Result<()> { | ||
| let schedulers = self | ||
| ._schedulers | ||
| .get() | ||
| .ok_or_else(|| anyhow::anyhow!("schedulers not initialized"))?; | ||
| let scheduler_idx = dp_rank as usize; | ||
| if scheduler_idx >= schedulers.len() { | ||
| return Err(anyhow::anyhow!( | ||
| "dp_rank {dp_rank} out of bounds (have {} schedulers)", | ||
| schedulers.len() | ||
| )); | ||
| } | ||
| let mut rx = schedulers[scheduler_idx].metrics_receiver(); | ||
|
|
||
| loop { | ||
| let metrics = rx.borrow().clone(); | ||
| // total_blocks == 0 means the scheduler hasn't published yet (still warming up). | ||
| // Don't block on warmup — the scheduler's own queue will handle admission | ||
| // once metrics start flowing. | ||
| let has_block_capacity = | ||
| metrics.total_blocks == 0 || metrics.active_decode_blocks < metrics.total_blocks; | ||
| // max_num_seqs == 0 means "no seq cap configured" — mirror vLLM/sglang's | ||
| // treatment of unset max_running_requests as effectively unbounded. | ||
| // running_requests is the live sequence count (vLLM len(self.running) / | ||
| // sglang req_to_token_pool occupancy). | ||
| let has_seq_capacity = | ||
| metrics.max_num_seqs == 0 || metrics.running_requests < metrics.max_num_seqs; | ||
| if has_block_capacity && has_seq_capacity { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a metrics snapshot, not decoder admission or a capacity reservation. It proves only that at least one block and sequence slot are currently free; it does not account for this request's KV footprint, and multiple waiting decodes can all pass the same snapshot. |
||
| return Ok(()); | ||
| } | ||
| // Wait indefinitely for the next metrics update — no decode-side timer. | ||
| // Cancellation lands via the request context being dropped. | ||
| rx.changed() | ||
| .await | ||
| .map_err(|_| anyhow::anyhow!("scheduler metrics channel closed"))?; | ||
| } | ||
| } | ||
|
|
||
| /// Send a request to the appropriate scheduler, waiting for initialization if needed. | ||
| pub async fn direct(&self, request: DirectRequest, dp_rank: usize) { | ||
| let sender = self.request_sender(dp_rank).await; | ||
|
|
@@ -523,12 +584,37 @@ impl AsyncEngine<SingleIn<PreprocessedRequest>, ManyOut<LLMEngineOutput>, Error> | |
| .await; | ||
|
|
||
| // Bootstrap rendezvous for disaggregated serving | ||
| // - Decode: send receiver metadata to prefill, then wait for prefill completion | ||
| // - Prefill: wait for decode metadata before emitting output, then complete_room() | ||
| // - Decode: wait for own KV capacity, then send receiver metadata to prefill | ||
| // and wait for prefill completion or abort | ||
| // - Prefill: wait for decode metadata (bounded by abort_timeout when set) before emitting | ||
| // output, then complete_room(); on abort-timeout abort_room() | ||
| let bootstrap_room = request.bootstrap_info.as_ref().map(|b| b.bootstrap_room); | ||
| let abort_timeout = self | ||
| .engine_args | ||
| .kv_transfer_abort_timeout_ms | ||
| .map(Duration::from_millis); | ||
|
|
||
| if let Some(bootstrap_info) = &request.bootstrap_info | ||
| && self.engine_args.is_decode() | ||
| { | ||
| // Gate decode on local KV capacity before connecting (see | ||
| // wait_for_decode_kv_capacity). Only when abort_timeout is configured, to | ||
| // preserve the legacy "skip the wait" path for older DGDs. | ||
| if abort_timeout.is_some() { | ||
| self.wait_for_decode_kv_capacity(dp_rank) | ||
| .await | ||
| .map_err(|e| Error::msg(format!("Decode KV wait failed: {e}")))?; | ||
| } | ||
| // Forensic logging: emit decode-side wait-start event so post-hoc | ||
| // analysis can reconstruct (decode_worker, target_prefill, room) stranding graphs. | ||
| tracing::info!( | ||
| target: "mocker::kv_abort", | ||
| decode_dp_rank = dp_rank, | ||
| room_id = bootstrap_info.bootstrap_room, | ||
| target_host = %bootstrap_info.bootstrap_host, | ||
| target_port = bootstrap_info.bootstrap_port, | ||
| "decode_kv_wait_start" | ||
| ); | ||
| connect_to_prefill( | ||
| &bootstrap_info.bootstrap_host, | ||
| bootstrap_info.bootstrap_port, | ||
|
|
@@ -579,15 +665,49 @@ impl AsyncEngine<SingleIn<PreprocessedRequest>, ManyOut<LLMEngineOutput>, Error> | |
| // Spawn a task to handle the complex async logic | ||
| tokio::spawn(async move { | ||
| if let Some((server, room_id, sender, direct_request)) = delayed_prefill_submission { | ||
| // Forensic logging: pin-start event with room_id + prefill dp_rank. | ||
| // Lets post-hoc analysis join with decode logs on room_id to build the | ||
| // (prefill -> decode) stranding graph. While this prefill waits for decode to | ||
| // arrive, its scheduler request stays active so KV stays pinned. | ||
| let pin_start = std::time::Instant::now(); | ||
| tracing::info!( | ||
| target: "mocker::kv_abort", | ||
| prefill_dp_rank = dp_rank, | ||
| room_id, | ||
| "prefill_kv_pin_start" | ||
| ); | ||
| tokio::select! { | ||
| result = server.wait_for_decode_ready(room_id) => { | ||
| // Bound the decode-arrival wait by abort_timeout (when set). On | ||
| // timeout, abort the room so waiting/late decodes get a clean ABORT instead | ||
| // of hanging, surface the abort to the client, and end the stream. | ||
| result = server.wait_for_decode_ready(room_id, abort_timeout) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: this wait happens before the later Please represent this as a shared scheduler lifecycle instead:
The scheduler should run prefill normally, retain the completed request and its KV allocation in |
||
| if let Err(e) = result { | ||
| tracing::warn!( | ||
| "Prefill aborting transfer for room {room_id}: {e}" | ||
| ); | ||
| server.abort_room(room_id); | ||
| tracing::info!( | ||
| target: "mocker::kv_abort", | ||
| prefill_dp_rank = dp_rank, | ||
| room_id, | ||
| outcome = "aborted", | ||
| duration_ms = pin_start.elapsed().as_millis() as u64, | ||
| "prefill_kv_pin_end" | ||
| ); | ||
| let _ = stream_tx.send(LLMEngineOutput::error(format!( | ||
| "Bootstrap wait for decode metadata failed: {e}" | ||
| "NIXL transfer aborted: {e}" | ||
| ))); | ||
| active_requests.remove(&request_uuid); | ||
| return; | ||
| } | ||
| tracing::info!( | ||
| target: "mocker::kv_abort", | ||
| prefill_dp_rank = dp_rank, | ||
| room_id, | ||
| outcome = "completed", | ||
| duration_ms = pin_start.elapsed().as_millis() as u64, | ||
| "prefill_kv_pin_end" | ||
| ); | ||
| } | ||
| _ = async_context.stopped() => { | ||
| let _ = stream_tx.send(LLMEngineOutput::cancelled()); | ||
|
|
@@ -653,6 +773,9 @@ impl AsyncEngine<SingleIn<PreprocessedRequest>, ManyOut<LLMEngineOutput>, Error> | |
| } | ||
|
|
||
| if signal.completed { | ||
| // By this point a disagg prefill has already waited for | ||
| // decode to arrive (or aborted) in wait_for_decode_ready below, so | ||
| // KV stayed pinned during the wait — modeling real NIXL behavior. | ||
| if stream_tx.send(output).is_err() { | ||
| tracing::error!("Output stream receiver closed."); | ||
| break; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.